From 9f62da9ac311b593e1b3b135704d419c159b5664 Mon Sep 17 00:00:00 2001 From: Aamer Akhter Date: Thu, 22 Oct 2026 17:22:21 -0400 Subject: [PATCH 01/68] feat(terminal): link URLs and paths that wrap across rows or sit in table cells A CLI that word-wraps inside a markdown table cell or an indented paragraph breaks a long URL or path short of the right edge and indents the rest, so nothing is flagged isWrapped and no row is full width. Each row linked only a fragment, and the fragment opened the wrong target. - findTerminalUrls() (constants.js) is the URL grammar for the link provider: a ")" that closes a "(" inside the URL is kept, an unmatched one still ends it, so (https://...) and [text](https://...) stay clean. - terminalUrlContinuationExtent() joins rows the CLI wrapped a URL across, only when the fragment ends on a wrap-break character, the next row is indented no further right than the URL started, and its first token looks like URL material. Table columns after the first fragment are left out. - terminalLinkLine() widens terminalPathLine() to that extent; a URL that spans rows is emitted as one link per row, each opening the whole URL, so the indent and other columns between the pieces are not underlined. - terminalCellWrappedPaths() follows an absolute path down its own table cell (space-separated or box-drawn), so two-column tables link both paths. - A full-width table divider no longer counts as a hard-wrapped line. - OSC 8 hyperlinks open through the same opener, web URLs only. --- src/web/public/constants.js | 307 ++++++++++++++++++++- src/web/public/terminal-tile.js | 2 + src/web/public/terminal-ui.js | 242 ++++++++++------ test/terminal-cell-wrapped-paths.test.ts | 210 ++++++++++++++ test/terminal-url-continuation.test.ts | 337 +++++++++++++++++++++++ test/webview-loopback-links.test.ts | 5 +- 6 files changed, 1019 insertions(+), 84 deletions(-) create mode 100644 test/terminal-cell-wrapped-paths.test.ts create mode 100644 test/terminal-url-continuation.test.ts diff --git a/src/web/public/constants.js b/src/web/public/constants.js index 4cb2f099a..6e8d4bff6 100644 --- a/src/web/public/constants.js +++ b/src/web/public/constants.js @@ -2173,9 +2173,9 @@ function findFilePathLinks(text) { // `?img=b.png` belong to the URL link, not to a second, overlapping file link. // (`file:///home/x.png` is not one of them: its path is the file.) const urls = []; - const urlPattern = /\bhttps?:\/\/\S+/gi; + const webUrlPattern = /\bhttps?:\/\/\S+/gi; let match; - while ((match = urlPattern.exec(value)) !== null) urls.push({ index: match.index, path: match[0] }); + while ((match = webUrlPattern.exec(value)) !== null) urls.push({ index: match.index, path: match[0] }); const inUrl = (index, length) => urls.some((u) => index < u.index + u.path.length && u.index < index + length); const absolute = absoluteFilePathPattern(); @@ -2334,7 +2334,12 @@ function terminalLogicalLine(buffer, row, cols, maxRows) { if (r <= 0) return false; if (lineAt(r)?.isWrapped) return true; const prev = lineAt(r - 1); - return !!prev && (prev.translateToString(true) || '').length >= width; + const prevText = prev?.translateToString(true) || ''; + // A full-width table divider is a row boundary, not a hard-wrapped line. + // Treating it as a continuation glues the divider onto the row below and + // hides that row's wrapped URL from the link provider. + if (/^[\s─━═╌╍┄┅┈┉-]+$/u.test(prevText)) return false; + return prevText.length >= width; }; let startRow = row; @@ -2508,6 +2513,293 @@ function terminalPathLine(buffer, row, cols, maxRows) { return { startRow: parts[0].startRow, endRow: parts[parts.length - 1].endRow, text, offsetToCell, cellToOffset }; } +/** + * Web URLs in terminal text, with the trimming the link provider applies. + * + * A single `&` is part of the URL (query separator); `&&` is the shell operator + * and ends it. `)` is kept INSIDE a URL when it closes a `(` that is also inside + * it (`…/Team-Space(QA)Archive/…` used to link only as far as `(QA`), and an + * unmatched `)` ends the URL, which keeps the closing paren of `(https://…)` and + * `[text](https://…)` out of the link. Trailing sentence punctuation is trimmed. + * + * @param {string} text + * @returns {Array<{url: string, startIndex: number, endIndex: number, rawEnd: number}>} + * `endIndex` is exclusive; `rawEnd` is where the match ran to before the + * paren/punctuation trim (the wrap-continuation test needs it). + */ +function findTerminalUrls(text) { + // Named `urlPattern` on purpose: test/link-provider-regex.test.ts extracts it + // by name to prove it stays linear-time on hostile lines. + const urlPattern = /https?:\/\/(?:[^\s"'<>|;&\]\x00-\x1f]|&(?!&))+/g; + const urls = []; + let match; + while ((match = urlPattern.exec(String(text || ''))) !== null) { + const raw = match[0]; + let end = raw.length; + let depth = 0; + for (let i = 0; i < raw.length; i++) { + if (raw[i] === '(') depth++; + else if (raw[i] === ')') { + if (depth === 0) { + end = i; + break; + } + depth--; + } + } + const url = raw.slice(0, end).replace(/[.,;:!?&]+$/, ''); + if (!/:\/\/[^/]/.test(url)) continue; + urls.push({ url, startIndex: match.index, endIndex: match.index + url.length, rawEnd: match.index + raw.length }); + } + return urls; +} + +/** + * The row span a URL covers when the CLI wrapped it with its OWN newlines at a + * point short of the terminal width. + * + * {@link terminalLogicalLine} joins soft wraps and full-width hard wraps. A CLI + * that word-wraps inside a table cell or a paragraph (codex's markdown tables, + * for one) breaks a long URL at `/`, `?`, `-` … well before the right edge and + * indents the rest to the cell's column, so neither rule fires and each row + * linked a fragment. A row continues the URL above it only when ALL hold: + * - the URL fragment ends on a character a wrapper breaks after + * (`/ ? & = - _ % # + ~`); a following table column may be excluded; + * - the next row is indented no further right than the column the URL + * started in (a table cell or list item continuation, not unrelated text); + * - that row's first token looks like URL material (contains one of + * `% & = / ? . - _` or a digit) and is not itself a new `scheme://`. + * The last rule is what stops prose such as "…/files/" + "then run it" from + * being glued into one link. + * + * Bounded by `maxRows`, like the logical line. Pure: reads rows only. + * + * @param {{getLine: (row: number) => any, length: number}} buffer + * @param {number} row 0-based absolute row being examined. + * @param {number} cols Terminal width. + * @param {number} [maxRows] + * @returns {{startRow: number, endRow: number, firstRowUrlEnd?: number} | null} + * `firstRowUrlEnd` is where the URL's cell ends on the first row when + * other table columns follow it; null means the row is not part of a + * URL continued this way. + */ +function terminalUrlContinuationExtent(buffer, row, cols, maxRows) { + const bound = Math.max(1, maxRows || 12); + const here = terminalLogicalLine(buffer, row, cols, bound); + if (!here) return null; + + const chainFrom = (start) => { + const first = terminalLogicalLine(buffer, start, cols, bound); + if (!first || first.startRow !== start) return null; + let text = first.text.replace(/\s+$/, ''); + let endRow = first.endRow; + let urlStartCol = -1; + let firstRowUrlEnd = null; + let extended = false; + while (endRow - start + 1 < bound) { + const urls = findTerminalUrls(text); + const last = urls[urls.length - 1]; + const trailingColumns = + endRow === first.endRow && last && last.rawEnd < text.length && /^ {2,}\S/.test(text.slice(last.rawEnd)); + const urlText = trailingColumns ? text.slice(0, last.rawEnd) : text; + if (!last || last.rawEnd !== urlText.length || !/[/?&=\-_%#+~]$/.test(urlText)) break; + if (urlStartCol < 0) urlStartCol = first.offsetToCell(last.startIndex).col; + const next = buffer.getLine(endRow + 1); + if (!next || next.isWrapped) break; + const nextText = next.translateToString(true) || ''; + const indent = nextText.length - nextText.replace(/^\s+/, '').length; + const token = nextText.slice(indent).split(/\s/, 1)[0]; + if (!token || indent > urlStartCol) break; + // When other table columns follow the URL, the next row must align with + // the start of its cell. A new numbered row must not become URL text. + if ( + trailingColumns && + (!indent || + first.text[indent - 1] !== ' ' || + first.text[indent] === ' ' || + / {2,}/.test(first.text.slice(indent, last.startIndex))) + ) + break; + if (/^[a-z][a-z0-9+.-]*:\/\//i.test(token) || !/^[A-Za-z0-9%_~]/.test(token)) break; + if (!/[%&=/?.\-_0-9]/.test(token)) break; + const nextLogical = terminalLogicalLine(buffer, endRow + 1, cols, bound); + if (!nextLogical) break; + if (trailingColumns) firstRowUrlEnd = last.rawEnd; + text = urlText + nextLogical.text.slice(indent).replace(/\s+$/, ''); + endRow = nextLogical.endRow; + extended = true; + } + return { startRow: start, endRow, extended, firstRowUrlEnd }; + }; + + // Walk up through earlier logical lines: the nearest one whose URL chain + // reaches this row owns it. + let start = here.startRow; + while (start >= 0 && here.startRow - start < bound) { + const chain = chainFrom(start); + if (chain && chain.extended && chain.endRow >= here.startRow) { + return { + startRow: chain.startRow, + endRow: chain.endRow, + ...(chain.firstRowUrlEnd === null ? {} : { firstRowUrlEnd: chain.firstRowUrlEnd }), + }; + } + if (start === 0) break; + const above = terminalLogicalLine(buffer, start - 1, cols, bound); + if (!above) break; + start = above.startRow; + } + return null; +} + +/** + * The text the link provider scans for `row`: {@link terminalPathLine}, widened + * to the rows of a URL the CLI wrapped short of the edge + * ({@link terminalUrlContinuationExtent}). Same shape as terminalPathLine. + * + * Over a widened span every row is read trimmed (except one a soft wrap + * continues from, which fills the width), a HARD continuation's indent is + * dropped (the cell mapping keeps the cells), and the first row stops at the + * URL's cell when other table columns follow it, so those columns never sit + * between the URL's pieces. + * + * @returns Same shape as {@link terminalPathLine}; null when the row does not exist. + */ +function terminalLinkLine(buffer, row, cols, maxRows) { + const base = terminalPathLine(buffer, row, cols, maxRows); + if (!base) return null; + const extent = terminalUrlContinuationExtent(buffer, row, cols, maxRows); + if (!extent || (extent.startRow >= base.startRow && extent.endRow <= base.endRow)) return base; + const startRow = Math.min(extent.startRow, base.startRow); + const endRow = Math.max(extent.endRow, base.endRow); + const segments = []; + let text = ''; + for (let r = startRow; r <= endRow; r++) { + const line = buffer.getLine(r); + if (!line) break; + const next = r < endRow ? buffer.getLine(r + 1) : null; + let rowText = line.translateToString(!(next && next.isWrapped)) || ''; + if (r === extent.startRow && extent.firstRowUrlEnd != null) rowText = rowText.slice(0, extent.firstRowUrlEnd); + let colStart = 0; + if (r > startRow && !line.isWrapped) { + colStart = rowText.length - rowText.replace(/^\s+/, '').length; + rowText = rowText.slice(colStart); + } + segments.push({ row: r, textStart: text.length, colStart }); + text += rowText; + } + const offsetToCell = (offset) => { + for (let i = segments.length - 1; i >= 0; i--) { + const seg = segments[i]; + if (offset >= seg.textStart || i === 0) return { row: seg.row, col: seg.colStart + (offset - seg.textStart) }; + } + return { row: startRow, col: offset }; + }; + const cellToOffset = (targetRow, targetCol) => { + const seg = segments.find((s) => s.row === targetRow); + return seg ? seg.textStart + Math.max(0, targetCol - seg.colStart) : -1; + }; + return { startRow, endRow: segments[segments.length - 1].row, text, offsetToCell, cellToOffset }; +} + +/** + * Absolute file paths the CLI word-wrapped INSIDE a table cell (codex markdown + * tables), returned as one link per path with a segment per row. + * + * Before screenshot (/tmp/x/docs/review/ After screenshot (/tmp/x/review/BLDMESH- + * BLDMESH-243/before-overview.png) 243/after-overview.png) + * + * Neither fragment matches FILE_PATH_LINK_PATTERN alone (the first has no + * extension, the second no absolute root), and a linear stitch of the rows + * cannot serve a two-column table: each column's continuation sits in the same + * next row. So each fragment is followed DOWN ITS OWN CELL: the cell's left + * edge is where its text starts after the previous ≥2-space column gap, and the + * continuation is the token starting at exactly that column on the next row. + * A fragment qualifies only when it is the last thing in its cell on that row + * (row end or a ≥2-space gap follows), and the join is accepted only when the + * whole joined string matches the path pattern from its first character, which + * is what keeps unrelated cell text from being glued on. + * + * Pure: reads rows only. Cells are taken one per string character (xterm rows + * with wide glyphs would shift columns; such rows simply fail the alignment). + * + * @param {{getLine: (row: number) => any, length: number}} buffer + * @param {number} row 0-based absolute row being examined. + * @param {number} [maxRows] Bound on rows one path may span. + * @returns {Array<{path: string, segments: Array<{row: number, startCol: number, endCol: number}>}>} + * Paths with a segment on `row`; `endCol` is exclusive, columns 0-based. + */ +function terminalCellWrappedPaths(buffer, row, maxRows) { + const bound = Math.max(2, maxRows || 4); + const rootPattern = new RegExp(`\\/(?:${FILE_LINK_ROOT_GROUP})\\/`, 'g'); + // Box-drawing verticals are cell borders (Claude Code draws tables with them). + const isPathChar = (ch) => !!ch && !/[\s"'<>|;&\x00-\x1f│┃║]/.test(ch); + const rowText = (r) => { + const line = buffer.getLine(r); + return line ? (line.translateToString(true) || '').replace(/\s+$/, '') : null; + }; + // A fragment is the last text in its cell on this row when the row ends, a + // column gap (≥2 spaces) follows it, or a cell border does. + const endsCell = (text, end) => end >= text.length || /^(?: {2,}| *[│┃║|])/.test(text.slice(end)); + + const fromStart = (startRow) => { + const found = []; + const text = rowText(startRow); + if (!text) return found; + rootPattern.lastIndex = 0; + let m; + while ((m = rootPattern.exec(text)) !== null) { + const start = m.index; + if (start > 0 && !/[\s(\[{"'`<]/.test(text[start - 1])) continue; + let end = start; + while (end < text.length && isPathChar(text[end])) end++; + rootPattern.lastIndex = Math.max(end, start + 1); + if (!endsCell(text, end)) continue; + // Already a complete path on its own row: the ordinary detector owns it. + const alone = absoluteFilePathPattern().exec(text.slice(start, end)); + if (alone && alone.index === 0 && alone[0].length === end - start) continue; + // The cell's left edge: just past the nearest ≥2-space gap or cell border + // (and the padding after it) before the path. + const before = text.slice(0, start); + let cellCol = before.length - before.replace(/^\s+/, '').length; + const edge = /(?: {2,}|[│┃║|] *)(?=\S)/g; + let e; + const upToPath = text.slice(0, start + 1); + while ((e = edge.exec(upToPath)) !== null) cellCol = e.index + e[0].length; + + let joined = text.slice(start, end); + const segments = [{ row: startRow, startCol: start, endCol: end }]; + for (let r = startRow + 1; r < startRow + bound; r++) { + const line = buffer.getLine(r); + if (!line || line.isWrapped) break; + const next = rowText(r); + if (!next || !isPathChar(next[cellCol]) || (cellCol > 0 && next[cellCol - 1] !== ' ')) break; + let tokenEnd = cellCol; + while (tokenEnd < next.length && isPathChar(next[tokenEnd])) tokenEnd++; + const candidate = joined + next.slice(cellCol, tokenEnd); + const match = absoluteFilePathPattern().exec(candidate); + if (match && match.index === 0 && match[0].length > joined.length) { + segments.push({ row: r, startCol: cellCol, endCol: cellCol + match[0].length - joined.length }); + found.push({ path: match[0], segments }); + break; + } + if (!endsCell(next, tokenEnd)) break; + joined = candidate; + segments.push({ row: r, startCol: cellCol, endCol: tokenEnd }); + } + } + return found; + }; + + const results = []; + for (let s = row; s >= 0 && row - s < bound; s--) { + for (const hit of fromStart(s)) { + if (hit.segments.some((seg) => seg.row === row)) results.push(hit); + } + } + return results; +} + // ═══════════════════════════════════════════════════════════════ // Split-Pane Sessions — pure helpers (divider math, picker list) // ═══════════════════════════════════════════════════════════════ @@ -3429,7 +3721,14 @@ if (typeof window !== 'undefined') { rendersAsPage, HTML_PAGE_EXTENSIONS, }; - window.CodemanTerminalLines = { terminalLogicalLine, terminalPathLine }; + window.CodemanTerminalLines = { + terminalLogicalLine, + terminalPathLine, + terminalLinkLine, + terminalUrlContinuationExtent, + findTerminalUrls, + terminalCellWrappedPaths, + }; window.CodemanUrlSession = { sessionIdFromFragment }; window.CodemanSplitPane = { clampDividerPercent, diff --git a/src/web/public/terminal-tile.js b/src/web/public/terminal-tile.js index 1a0c70584..961b56b32 100644 --- a/src/web/public/terminal-tile.js +++ b/src/web/public/terminal-tile.js @@ -244,6 +244,8 @@ scrollback: this.scrollback ?? DEFAULT_SCROLLBACK, allowTransparency: true, allowProposedApi: true, + // OSC 8 hyperlinks open through the primary pane's opener (terminal-ui.js). + linkHandler: global.app?._terminalOsc8LinkHandler?.(), }); this.fitAddon = new FitAddon.FitAddon(); diff --git a/src/web/public/terminal-ui.js b/src/web/public/terminal-ui.js index c3778ffec..e4794be65 100644 --- a/src/web/public/terminal-ui.js +++ b/src/web/public/terminal-ui.js @@ -702,6 +702,7 @@ Object.assign(CodemanApp.prototype, { scrollback: scrollback, allowTransparency: true, allowProposedApi: true, + linkHandler: this._terminalOsc8LinkHandler(), }); this.fitAddon = new FitAddon.FitAddon(); @@ -2143,8 +2144,11 @@ Object.assign(CodemanApp.prototype, { // links as the one file it is. // Bounded so a screenful of full-width output (wide tables, box drawing) // cannot make every hover stitch and re-scan the entire viewport. + // `terminalLinkLine` widens that once more to the rows of a URL the CLI + // wrapped short of the edge (a table cell or an indented paragraph), + // where nothing marks the break and no row is full width. const MAX_STITCHED_ROWS = 12; - const logical = window.CodemanTerminalLines?.terminalPathLine( + const logical = window.CodemanTerminalLines?.terminalLinkLine( buffer, bufferLineNumber - 1, terminal.cols, @@ -2162,8 +2166,16 @@ Object.assign(CodemanApp.prototype, { return { x: cell.col + 1, y: cell.row + 1 }; }; + // Paths a CLI word-wrapped inside a table cell (terminalCellWrappedPaths, + // constants.js). They follow their own cell down the rows, which the + // linear stitch above cannot do for a multi-column table, so they are + // found from the buffer directly and claim their cells over the rest. + const cellWrappedPaths = ( + window.CodemanTerminalLines?.terminalCellWrappedPaths?.(buffer, bufferLineNumber - 1, 4) ?? [] + ).filter((hit) => !/(^|\/)\.\.(\/|$)/.test(hit.path)); + // A path needs a `/` or an extension's `.`; a line with neither has no link. - if (!lineText || (!lineText.includes('/') && !lineText.includes('.'))) { + if (!lineText || (!lineText.includes('/') && !lineText.includes('.') && cellWrappedPaths.length === 0)) { callback(undefined); return; } @@ -2175,44 +2187,46 @@ Object.assign(CodemanApp.prototype, { const taken = []; const isTaken = (start, length) => taken.some((t) => start < t.end && t.start < start + length); - // Pattern 0: URLs (https://, http://) — matched first so they take priority - // - // A single `&` is PART of the URL: it separates query parameters, so excluding - // it truncated every real query string (`?post=1479&action=edit` linked only - // through `1479`, landing on the wrong page). `&&` is still a boundary, since - // that is the shell operator and never appears inside a URL. A lone trailing - // `&` is trimmed below with the other trailing punctuation. - const urlPattern = /https?:\/\/(?:[^\s"'<>|;&)\]\x00-\x1f]|&(?!&))+/g; - - const addUrlLink = (url, matchIndex) => { - // Strip trailing punctuation that's likely not part of the URL - const cleaned = url.replace(/[.,;:!?)&]+$/, ''); - const startCol = lineText.indexOf(cleaned, matchIndex); - if (startCol === -1) return; - - const start = coordAt(startCol); - const end = coordAt(startCol + cleaned.length); + // Pattern 0: URLs (https://, http://) — matched first so they take priority. + // The grammar (balanced parens, `&` vs `&&`, trailing punctuation) is + // findTerminalUrls() in constants.js, which the wrap-continuation extent + // above reads URLs with too, so the two cannot disagree. + const addUrlLink = ({ url, startIndex }) => { + const start = coordAt(startIndex); if (links.some((l) => l.range.start.x === start.x && l.range.start.y === start.y)) return; - taken.push({ start: startCol, end: startCol + cleaned.length }); - - links.push({ - text: cleaned, - range: { start, end }, - decorations: { pointerCursor: true, underline: true }, - activate(_event, text) { - // A `localhost` link tapped from another device can only work - // through the server: route it into a proxied web tab - // (webview-tabs.js). Anything else opens as before. - if (self.openLinkThroughWebTabIfLoopback?.(text)) return; - window.open(text, '_blank', 'noopener,noreferrer'); - }, - hover() { - setHovered(true); - }, - leave() { - setHovered(false); - }, - }); + taken.push({ start: startIndex, end: startIndex + url.length }); + + // One link per terminal row the URL occupies, each opening the WHOLE + // URL. A single range spanning rows would also underline (and make + // clickable) the indent and table columns between the pieces of a + // URL the CLI wrapped. A single-row URL keeps its original range. + const endIndex = startIndex + url.length; + const pieces = []; + let pieceStart = startIndex; + for (let i = startIndex + 1; i < endIndex; i++) { + if (coordAt(i).y !== coordAt(i - 1).y) { + pieces.push([pieceStart, i]); + pieceStart = i; + } + } + pieces.push([pieceStart, endIndex]); + for (let n = 0; n < pieces.length; n++) { + const [from, to] = pieces[n]; + links.push({ + text: url, + range: { start: coordAt(from), end: pieces.length === 1 ? coordAt(to) : coordAt(to - 1) }, + decorations: { pointerCursor: true, underline: true }, + activate() { + self._openTerminalUrl(url); + }, + hover() { + setHovered(true); + }, + leave() { + setHovered(false); + }, + }); + } }; // Pattern 1: Commands with file paths (tail -f, cat, head, grep pattern, etc.) @@ -2235,6 +2249,41 @@ Object.assign(CodemanApp.prototype, { // Pattern 3: Bash() tool output const bashPattern = /Bash\([^)]*?(\/(?:home|tmp|var|etc|opt)[^\s"'<>|;&\)\n\x00-\x1f]+)/g; + const activateFilePath = (text, folder = false) => { + // Tailing a PNG in the log viewer shows binary noise; the file preview + // already renders images, PDFs, documents and media inline — and it + // now reaches files outside the workspace too, which is where an + // agent's screenshots and scratchpad captures actually land. + // + // Text goes to the log viewer, which follows a file that is still + // being written — but ONLY where it can actually read: it spawns + // `tail -f` and allows the workspace, /var/log and ~/logs, so an + // out-of-workspace path there answered "Path must be within + // working directory or allowed log directories" while the SAME + // path clicked in the response viewer previewed fine. The preview + // reads those through the guarded attachment routes, so external + // paths route there and the two surfaces agree. + // + // A relative path is resolved against the session's working + // directory first, and a `~/` path always previews (only the + // server knows that host's home, and the log viewer cannot ask). + const sessionId = getSessionId(); + const target = self._resolveLinkedFilePath(text, sessionId); + // A folder path opens the folder grid (which falls back to the + // preview if the server finds a file there after all). + if (folder) { + self.openFolderGrid(target, sessionId); + return; + } + // An HTML file renders as a page in the preview, never as source + // in the log viewer (markdown is in FILE_PREVIEW_EXTENSIONS). + if (previewsInFileViewer(target) || rendersAsPage(target) || self._isExternalPreviewPath(target, sessionId)) { + self.openFilePreview(target, sessionId); + return; + } + self.openLogViewerWindow(target, sessionId); + }; + const addLink = (filePath, matchIndex, folder = false) => { const startCol = lineText.indexOf(filePath, matchIndex); if (startCol === -1) return; @@ -2253,43 +2302,8 @@ Object.assign(CodemanApp.prototype, { pointerCursor: true, underline: true, }, - activate(event, text) { - // Tailing a PNG in the log viewer shows binary noise; the file preview - // already renders images, PDFs, documents and media inline — and it - // now reaches files outside the workspace too, which is where an - // agent's screenshots and scratchpad captures actually land. - // - // Text goes to the log viewer, which follows a file that is still - // being written — but ONLY where it can actually read: it spawns - // `tail -f` and allows the workspace, /var/log and ~/logs, so an - // out-of-workspace path there answered "Path must be within - // working directory or allowed log directories" while the SAME - // path clicked in the response viewer previewed fine. The preview - // reads those through the guarded attachment routes, so external - // paths route there and the two surfaces agree. - // - // A relative path is resolved against the session's working - // directory first, and a `~/` path always previews (only the - // server knows that host's home, and the log viewer cannot ask). - const sessionId = getSessionId(); - const target = self._resolveLinkedFilePath(text, sessionId); - // A folder path opens the folder grid (which falls back to the - // preview if the server finds a file there after all). - if (folder) { - self.openFolderGrid(target, sessionId); - return; - } - // An HTML file renders as a page in the preview, never as source - // in the log viewer (markdown is in FILE_PREVIEW_EXTENSIONS). - if ( - previewsInFileViewer(target) || - rendersAsPage(target) || - self._isExternalPreviewPath(target, sessionId) - ) { - self.openFilePreview(target, sessionId); - return; - } - self.openLogViewerWindow(target, sessionId); + activate(_event, text) { + activateFilePath(text, folder); }, hover() { setHovered(true); @@ -2303,9 +2317,8 @@ Object.assign(CodemanApp.prototype, { // Match all patterns — URLs first so they take priority let match; - urlPattern.lastIndex = 0; - while ((match = urlPattern.exec(lineText)) !== null) { - addUrlLink(match[0], match.index); + for (const reference of window.CodemanTerminalLines?.findTerminalUrls?.(lineText) ?? []) { + addUrlLink(reference); } cmdPattern.lastIndex = 0; @@ -2322,6 +2335,54 @@ Object.assign(CodemanApp.prototype, { addLink(match[1], match.index); } + // One link per row segment, each opening the WHOLE path (as for a wrapped + // URL). A fragment of the same cells linked by another detector (a + // relative `243/after-overview.png`, say) would open the wrong file, so + // the cell-wrapped path displaces it. + // A path the line stitch already joined whole (a single-column wrap, which + // terminalPathLine handles) keeps its one link. + const cellOnlyPaths = cellWrappedPaths.filter( + (hit) => + !links.some( + (l) => + l.text === hit.path && + l.range.start.y === hit.segments[0].row + 1 && + l.range.start.x === hit.segments[0].startCol + 1 + ) + ); + if (cellOnlyPaths.length > 0) { + const claimed = cellOnlyPaths.flatMap((hit) => hit.segments); + const overlapsClaim = (link) => + claimed.some((seg) => { + const y = seg.row + 1; + if (y < link.range.start.y || y > link.range.end.y) return false; + const from = y === link.range.start.y ? link.range.start.x : 1; + const to = y === link.range.end.y ? link.range.end.x : Number.MAX_SAFE_INTEGER; + return from <= seg.endCol && to >= seg.startCol + 1; + }); + for (let i = links.length - 1; i >= 0; i--) { + if (overlapsClaim(links[i])) links.splice(i, 1); + } + for (const hit of cellOnlyPaths) { + for (const seg of hit.segments) { + links.push({ + text: hit.path, + range: { start: { x: seg.startCol + 1, y: seg.row + 1 }, end: { x: seg.endCol, y: seg.row + 1 } }, + decorations: { pointerCursor: true, underline: true }, + activate() { + activateFilePath(hit.path); + }, + hover() { + setHovered(true); + }, + leave() { + setHovered(false); + }, + }); + } + } + } + if (links.length > 0) { console.debug( '[LinkProvider] Found links:', @@ -2344,6 +2405,29 @@ Object.assign(CodemanApp.prototype, { return provider; }, + /** + * xterm's handler for OSC 8 hyperlinks (a program's own `ESC ]8;;url` links), + * which take precedence over the text link provider. Routed through the same + * opener, and only for web URLs: any other scheme a program sends is ignored. + */ + _terminalOsc8LinkHandler() { + return { + activate: (_event, text) => { + if (!/^https?:\/\/[^\s\x00-\x1f]+$/i.test(text)) return; + this._openTerminalUrl(text); + }, + }; + }, + + /** Open a URL clicked in a terminal (text link or OSC 8). */ + _openTerminalUrl(text) { + // A `localhost` link tapped from another device can only work through the + // server: route it into a proxied web tab (webview-tabs.js). Anything else + // opens in a new tab. + if (this.openLinkThroughWebTabIfLoopback?.(text)) return; + window.open(text, '_blank', 'noopener,noreferrer'); + }, + /** * The terminal link under a viewport point, or null. * diff --git a/test/terminal-cell-wrapped-paths.test.ts b/test/terminal-cell-wrapped-paths.test.ts new file mode 100644 index 000000000..6ed0da6a2 --- /dev/null +++ b/test/terminal-cell-wrapped-paths.test.ts @@ -0,0 +1,210 @@ +// Port: none (pure functions + the link provider in a vm context — no browser, no server). +// +// Absolute file paths codex word-wrapped inside a markdown table cell were not +// clickable: the first fragment has no extension and the continuation has no +// absolute root, so FILE_PATH_LINK_PATTERN matched neither. In a two-column +// table both continuations sit on the same next row, so each path has to be +// followed down its OWN cell (terminalCellWrappedPaths in constants.js). +import { readFileSync } from 'node:fs'; +import { resolve } from 'node:path'; +import vm from 'node:vm'; +import { describe, expect, it, vi } from 'vitest'; + +const CONSTANTS = readFileSync(resolve(import.meta.dirname, '../src/web/public/constants.js'), 'utf8'); +const TERMINAL_UI = readFileSync(resolve(import.meta.dirname, '../src/web/public/terminal-ui.js'), 'utf8'); + +type Row = { text: string; isWrapped?: boolean }; +type Hit = { path: string; segments: Array<{ row: number; startCol: number; endCol: number }> }; +type Link = { + text: string; + range: { start: { x: number; y: number }; end: { x: number; y: number } }; + activate: (event: unknown, text: string) => void; +}; + +const COLS = 200; + +function fakeBuffer(rows: Row[], cols: number) { + const lines = rows.map((row) => ({ + isWrapped: row.isWrapped ?? false, + translateToString(trimRight?: boolean) { + const padded = row.text.padEnd(cols, ' '); + return trimRight ? padded.trimEnd() : padded; + }, + getCell(index: number) { + const padded = row.text.padEnd(cols, ' '); + if (index >= padded.length) return undefined; + return { getChars: () => (padded[index] === ' ' ? '' : padded[index]), getWidth: () => 1 }; + }, + })); + return { length: lines.length, getLine: (i: number) => lines[i] }; +} + +function loadPure() { + const context = vm.createContext({ console, window: undefined }); + vm.runInContext(`${CONSTANTS}\nglobalThis.__cell = terminalCellWrappedPaths;`, context, { + filename: 'constants.js', + }); + return (context as unknown as { __cell: (buffer: unknown, row: number, maxRows?: number) => Hit[] }).__cell; +} + +// Shape copied from a real codex pane (synthetic paths). +const ROW1 = + ' Before screenshot (/tmp/demo-check/docs/review/ After screenshot (/tmp/demo-check/docs/review/BLDMESH-'; +const ROW2 = ' BLDMESH-243/before-overview.png) 243/after-overview.png)'; +const TABLE: Row[] = [ + { text: ' No Vision selected Reprocessed' }, + { text: ROW1 }, + { text: ROW2 }, + { text: '' }, +]; +const BEFORE = '/tmp/demo-check/docs/review/BLDMESH-243/before-overview.png'; +const AFTER = '/tmp/demo-check/docs/review/BLDMESH-243/after-overview.png'; + +describe('terminalCellWrappedPaths', () => { + const find = loadPure(); + + it('joins each column’s path with its own continuation, from either row', () => { + for (const row of [1, 2]) { + const hits = find(fakeBuffer(TABLE, COLS), row); + expect(hits.map((h) => h.path).sort()).toEqual([AFTER, BEFORE]); + } + }); + + it('places the segments on the path cells only', () => { + const hits = find(fakeBuffer(TABLE, COLS), 1); + const before = hits.find((h) => h.path === BEFORE)!; + expect(before.segments).toEqual([ + { row: 1, startCol: ROW1.indexOf('/tmp/'), endCol: ROW1.indexOf('/review/') + '/review/'.length }, + { row: 2, startCol: 3, endCol: 3 + 'BLDMESH-243/before-overview.png'.length }, + ]); + const after = hits.find((h) => h.path === AFTER)!; + const afterCell = ROW1.indexOf('After'); + expect(after.segments[0]).toEqual({ row: 1, startCol: ROW1.lastIndexOf('/tmp/'), endCol: ROW1.length }); + expect(after.segments[1]).toEqual({ + row: 2, + startCol: afterCell, + endCol: afterCell + '243/after-overview.png'.length, + }); + }); + + it('ignores a path already complete on its row', () => { + const rows = [{ text: ' see /tmp/demo/report.md' }, { text: ' next.md' }]; + expect(find(fakeBuffer(rows, COLS), 0)).toEqual([]); + }); + + it('does not glue unrelated text onto a path that is not followed by a matching continuation', () => { + const rows = [{ text: ' saved under /tmp/demo/docs/' }, { text: ' then run the build' }]; + expect(find(fakeBuffer(rows, COLS), 0)).toEqual([]); + expect(find(fakeBuffer(rows, COLS), 1)).toEqual([]); + }); + + it('does not join a continuation that is not aligned to the cell', () => { + const rows = [{ text: ' shot (/tmp/demo/docs/review/' }, { text: ' before.png)' }]; + expect(find(fakeBuffer(rows, COLS), 0)).toEqual([]); + }); + + it('does not join across a soft wrap (the logical line owns that)', () => { + const rows = [{ text: ' shot (/tmp/demo/docs/review/' }, { text: ' before.png', isWrapped: true }]; + expect(find(fakeBuffer(rows, COLS), 0)).toEqual([]); + }); + + it('follows a path across three rows of one cell', () => { + const rows = [{ text: ' a (/tmp/demo/very/' }, { text: ' long/nested/' }, { text: ' file.png)' }]; + const hits = find(fakeBuffer(rows, COLS), 1); + expect(hits.map((h) => h.path)).toEqual(['/tmp/demo/very/long/nested/file.png']); + expect(hits[0].segments.map((s) => s.row)).toEqual([0, 1, 2]); + }); + + it('follows a path down a box-drawn table cell (Claude Code renders tables with │ borders)', () => { + const rows = [ + { text: '│ Before │ /tmp/demo/docs/review/ │ After │ /tmp/demo/docs/review/BLD- │' }, + { text: '│ │ before-overview.png │ │ 243/after-overview.png │' }, + ]; + const hits = find(fakeBuffer(rows, COLS), 1); + expect(hits.map((h) => h.path).sort()).toEqual([ + '/tmp/demo/docs/review/BLD-243/after-overview.png', + '/tmp/demo/docs/review/before-overview.png', + ]); + const before = hits.find((h) => h.path.endsWith('before-overview.png'))!; + expect(before.segments).toEqual([ + { row: 0, startCol: rows[0].text.indexOf('/tmp/'), endCol: rows[0].text.indexOf('/review/') + 8 }, + { row: 1, startCol: rows[1].text.indexOf('before'), endCol: rows[1].text.indexOf('.png') + 4 }, + ]); + }); + + it('does not take a box border or the next cell into a path', () => { + const rows = [{ text: '│ /tmp/demo/docs/ │ x │' }, { text: '│ │ notes.md │ y │' }]; + expect(find(fakeBuffer(rows, COLS), 0)).toEqual([]); + }); +}); + +describe('terminal link provider with cell-wrapped paths', () => { + function harness(rows: Row[]) { + const context = vm.createContext({ + console: { ...console, debug: vi.fn(), log: vi.fn() }, + CodemanApp: class CodemanApp {}, + setInterval: vi.fn(), + clearInterval: vi.fn(), + setTimeout, + clearTimeout, + requestAnimationFrame: vi.fn(), + document: { addEventListener: vi.fn(), getElementById: vi.fn(), documentElement: { dataset: {} } }, + window: { + addEventListener: vi.fn(), + removeEventListener: vi.fn(), + open: vi.fn(), + document: { documentElement: { dataset: {} } }, + }, + }); + vm.runInContext(`${CONSTANTS}\n${TERMINAL_UI}\nglobalThis.__proto = CodemanApp.prototype;`, context); + const proto = (context as unknown as { __proto: Record unknown> }).__proto; + let provider: { provideLinks: (line: number, cb: (links?: Link[]) => void) => void } | undefined; + const app = { + terminal: { + cols: COLS, + rows: 24, + registerLinkProvider(value: typeof provider) { + provider = value; + }, + buffer: { active: { viewportY: 0, ...fakeBuffer(rows, COLS) } }, + }, + activeSessionId: 's1', + sessions: new Map([['s1', { workingDir: '/workspace/project' }]]), + openFilePreview: vi.fn(), + openLogViewerWindow: vi.fn(), + _openTerminalUrl: proto._openTerminalUrl, + _resolveLinkedFilePath: proto._resolveLinkedFilePath, + _isExternalPreviewPath: () => false, + registerFilePathLinkProvider: proto.registerFilePathLinkProvider, + }; + app.registerFilePathLinkProvider.call(app); + const provide = (line: number): Link[] => { + let links: Link[] | undefined; + provider!.provideLinks(line, (value) => { + links = value; + }); + return links ?? []; + }; + return { provide, app }; + } + + it('makes both screenshot paths clickable from either row and opens the whole path', () => { + const { provide, app } = harness(TABLE); + for (const line of [2, 3]) { + const links = provide(line); + for (const target of [BEFORE, AFTER]) { + const onRow = links.find((l) => l.text === target && l.range.start.y === line); + expect(onRow, `${target} on row ${line}`).toBeDefined(); + onRow!.activate(null, onRow!.text); + expect(app.openFilePreview).toHaveBeenLastCalledWith(target, 's1'); + } + } + }); + + it('leaves no fragment link that would open a partial path', () => { + const { provide } = harness(TABLE); + for (const line of [2, 3]) { + for (const link of provide(line)) expect([BEFORE, AFTER]).toContain(link.text); + } + }); +}); diff --git a/test/terminal-url-continuation.test.ts b/test/terminal-url-continuation.test.ts new file mode 100644 index 000000000..9616ac532 --- /dev/null +++ b/test/terminal-url-continuation.test.ts @@ -0,0 +1,337 @@ +// Port: none (pure functions + the link provider in a vm context — no browser, no server). +// +// Terminal URL links that stopped short. +// +// 1. `)` ended every URL, so a path like `…/Team-Space(QA)Archive/…` linked only as +// far as `(QA`. The grammar now keeps a `)` that closes a `(` inside the URL and +// still leaves out the closing paren of `(https://…)` / `[text](https://…)`. +// 2. A CLI that word-wraps inside a table cell (codex) breaks a long URL at `/`, `?`, +// `-` well short of the right edge and indents the rest to the cell's column. +// Nothing is `isWrapped` and no row is full width, so each row linked a fragment. +// +// All URLs here are synthetic (example.com); none come from real output. +import { readFileSync } from 'node:fs'; +import { resolve } from 'node:path'; +import vm from 'node:vm'; +import { describe, expect, it, vi } from 'vitest'; + +const CONSTANTS = readFileSync(resolve(import.meta.dirname, '../src/web/public/constants.js'), 'utf8'); +const TERMINAL_UI = readFileSync(resolve(import.meta.dirname, '../src/web/public/terminal-ui.js'), 'utf8'); + +type UrlRef = { url: string; startIndex: number; endIndex: number; rawEnd: number }; +type Extent = { startRow: number; endRow: number } | null; +type Row = { text: string; isWrapped?: boolean }; +type Link = { + text: string; + range: { start: { x: number; y: number }; end: { x: number; y: number } }; + activate: (event: unknown, text: string) => void; +}; + +function loadPure() { + const context = vm.createContext({ console, window: undefined }); + vm.runInContext( + `${CONSTANTS}\nglobalThis.__urls = findTerminalUrls; globalThis.__extent = terminalUrlContinuationExtent;`, + context, + { filename: 'constants.js' } + ); + const ctx = context as unknown as { + __urls: (text: string) => UrlRef[]; + __extent: (buffer: unknown, row: number, cols: number, maxRows?: number) => Extent; + }; + return { findTerminalUrls: ctx.__urls, terminalUrlContinuationExtent: ctx.__extent }; +} + +function fakeBuffer(rows: Row[], cols: number) { + const lines = rows.map((row) => ({ + isWrapped: row.isWrapped ?? false, + translateToString(trimRight?: boolean) { + const padded = row.text.padEnd(cols, ' '); + return trimRight ? padded.trimEnd() : padded; + }, + getCell(index: number) { + const padded = row.text.padEnd(cols, ' '); + if (index >= padded.length) return undefined; + return { getChars: () => (padded[index] === ' ' ? '' : padded[index]), getWidth: () => 1 }; + }, + })); + return { length: lines.length, getLine: (i: number) => lines[i] }; +} + +// A codex-style markdown table row: number, reason, then a `name (url)` cell whose +// URL the CLI wrapped across three rows, each continuation indented to the cell. +const URL = + 'https://docs.example.com/sites/Team-Space(QA)Archive/_layouts/15/Doc.aspx?' + + 'sourcedoc=%7B11111111-2222-3333-4444-555555555555%7D&file=Quarterly%20Plan%20(Draft).xlsx&action=default&' + + 'mobileredirect=true'; +const CELL = 26; +const TABLE: Row[] = [ + { text: ' 11 Unrelated row plain text only' }, + { + text: ' 12 New to trending Quarterly Plan.xlsx (https://docs.example.com/sites/Team-Space(QA)Archive/_layouts/15/Doc.aspx?', + }, + { + text: + ' '.repeat(CELL) + + 'sourcedoc=%7B11111111-2222-3333-4444-555555555555%7D&file=Quarterly%20Plan%20(Draft).xlsx&action=default&', + }, + { text: ' '.repeat(CELL) + 'mobileredirect=true)' }, + { text: ' 13 Next row another cell' }, +]; +const COLS = 160; +const TABLE_CELL_START = TABLE[1].text.indexOf('Quarterly'); +const TABLE_WITH_TRAILING_COLUMNS: Row[] = [ + TABLE[0], + { + text: TABLE[1].text + ' 2026-09-16 18:00:54 Document Owner', + }, + { text: ' '.repeat(TABLE_CELL_START) + TABLE[2].text.slice(CELL) }, + { text: ' '.repeat(TABLE_CELL_START) + TABLE[3].text.slice(CELL) }, + TABLE[4], +]; +const TABLE_COLS = 220; + +describe('findTerminalUrls (grammar)', () => { + const { findTerminalUrls } = loadPure(); + const urls = (text: string) => findTerminalUrls(text).map((u) => u.url); + + it('keeps a paren pair that is part of the URL', () => { + expect(urls('see https://example.com/wiki/Widget_(tool) now')).toEqual(['https://example.com/wiki/Widget_(tool)']); + expect(urls('https://example.com/Team-Space(QA)Archive/x')).toEqual([ + 'https://example.com/Team-Space(QA)Archive/x', + ]); + }); + + it('leaves out the paren that wraps the URL', () => { + expect(urls('(https://example.com/a(b)c)')).toEqual(['https://example.com/a(b)c']); + expect(urls('file (https://example.com/doc?x=1).')).toEqual(['https://example.com/doc?x=1']); + }); + + it('leaves out the closing paren of a markdown link', () => { + expect(urls('[the docs](https://example.com/docs?a=1&b=2) for details')).toEqual([ + 'https://example.com/docs?a=1&b=2', + ]); + }); + + it('still keeps query ampersands and stops at the shell &&', () => { + expect(urls('open https://example.com/p?post=1&action=edit&& echo done')).toEqual([ + 'https://example.com/p?post=1&action=edit', + ]); + }); + + it('trims trailing sentence punctuation', () => { + expect(urls('Go to https://example.com/path.')).toEqual(['https://example.com/path']); + expect(urls('Is it https://example.com/q?')).toEqual(['https://example.com/q']); + }); + + it('reports where the raw match ended before trimming', () => { + const [ref] = findTerminalUrls('x https://example.com/a) y'); + expect(ref.url).toBe('https://example.com/a'); + expect(ref.rawEnd).toBe('x https://example.com/a)'.length); + }); +}); + +describe('terminalUrlContinuationExtent (wrap joining)', () => { + const { terminalUrlContinuationExtent } = loadPure(); + + it('spans all three rows of a URL wrapped inside a table cell, from any of them', () => { + const buffer = fakeBuffer(TABLE, COLS); + for (const row of [1, 2, 3]) { + expect(terminalUrlContinuationExtent(buffer, row, COLS, 12)).toEqual({ startRow: 1, endRow: 3 }); + } + }); + + it('spans a table URL even when date and owner columns follow its first fragment', () => { + const buffer = fakeBuffer(TABLE_WITH_TRAILING_COLUMNS, TABLE_COLS); + for (const row of [1, 2, 3]) { + expect(terminalUrlContinuationExtent(buffer, row, TABLE_COLS, 12)).toMatchObject({ startRow: 1, endRow: 3 }); + } + }); + + it('starts at the URL row when a full-width table divider precedes it', () => { + const rows = [{ text: '─'.repeat(TABLE_COLS) }, ...TABLE_WITH_TRAILING_COLUMNS.slice(1, 4)]; + const buffer = fakeBuffer(rows, TABLE_COLS); + for (const row of [1, 2, 3]) { + expect(terminalUrlContinuationExtent(buffer, row, TABLE_COLS, 12)).toMatchObject({ startRow: 1, endRow: 3 }); + } + }); + + it('does not treat the next numbered table row as a URL continuation', () => { + const rows = [TABLE_WITH_TRAILING_COLUMNS[1], { text: ' 13 New to trending Another document' }]; + const buffer = fakeBuffer(rows, TABLE_COLS); + expect(terminalUrlContinuationExtent(buffer, 0, TABLE_COLS, 12)).toBeNull(); + expect(terminalUrlContinuationExtent(buffer, 1, TABLE_COLS, 12)).toBeNull(); + }); + + it('leaves the neighbouring table rows alone', () => { + const buffer = fakeBuffer(TABLE, COLS); + expect(terminalUrlContinuationExtent(buffer, 0, COLS, 12)).toBeNull(); + expect(terminalUrlContinuationExtent(buffer, 4, COLS, 12)).toBeNull(); + }); + + it('does not glue a following prose line onto a URL that ends a line', () => { + const buffer = fakeBuffer( + [{ text: 'Download from https://example.com/files/' }, { text: 'then run the installer' }], + 80 + ); + expect(terminalUrlContinuationExtent(buffer, 0, 80, 12)).toBeNull(); + expect(terminalUrlContinuationExtent(buffer, 1, 80, 12)).toBeNull(); + }); + + it('does not join a second URL on the next row', () => { + const buffer = fakeBuffer([{ text: '- https://example.com/one/' }, { text: ' https://example.com/two' }], 80); + expect(terminalUrlContinuationExtent(buffer, 0, 80, 12)).toBeNull(); + }); + + it('does not join a row indented to the right of where the URL started', () => { + const buffer = fakeBuffer([{ text: 'x https://example.com/a/b?' }, { text: ' q=1&r=2' }], 80); + expect(terminalUrlContinuationExtent(buffer, 0, 80, 12)).toBeNull(); + }); + + it('does not join when the URL line ends on a character a wrapper would not break after', () => { + const buffer = fakeBuffer([{ text: 'see https://example.com/page' }, { text: 'v2-notes.txt follows' }], 80); + expect(terminalUrlContinuationExtent(buffer, 0, 80, 12)).toBeNull(); + }); + + it('stays within the row bound', () => { + const rows: Row[] = [{ text: 'u https://example.com/' }]; + for (let i = 0; i < 20; i++) rows.push({ text: ` seg${i}-` }); + const extent = terminalUrlContinuationExtent(fakeBuffer(rows, 80), 0, 80, 5); + expect(extent).not.toBeNull(); + expect(extent!.endRow - extent!.startRow + 1).toBeLessThanOrEqual(5); + }); +}); + +describe('native OSC 8 terminal URLs', () => { + it('opens complete HTTP links through the terminal opener and rejects unsafe schemes', () => { + const open = vi.fn(); + const context = vm.createContext({ + console, + CodemanApp: class CodemanApp {}, + document: { addEventListener: vi.fn() }, + window: { addEventListener: vi.fn(), removeEventListener: vi.fn() }, + }); + vm.runInContext(`${CONSTANTS}\n${TERMINAL_UI}\nglobalThis.__proto = CodemanApp.prototype;`, context); + const proto = ( + context as unknown as { + __proto: { _terminalOsc8LinkHandler: () => { activate: (event: unknown, url: string) => void } }; + } + ).__proto; + const handler = proto._terminalOsc8LinkHandler.call({ _openTerminalUrl: open }); + const url = 'https://docs.example.com/Doc.aspx?file=Q3%20Plan&action=default&mobileredirect=true'; + handler.activate(null, url); + expect(open).toHaveBeenCalledExactlyOnceWith(url); + for (const unsafe of ['javascript:alert(1)', 'data:text/html,hello', 'file:///etc/passwd']) { + handler.activate(null, unsafe); + } + expect(open).toHaveBeenCalledTimes(1); + }); +}); + +describe('terminal link provider with wrapped table URLs', () => { + function harness(rows: Row[], cols: number) { + const openSpy = vi.fn(); + const context = vm.createContext({ + console: { ...console, debug: vi.fn(), log: vi.fn() }, + CodemanApp: class CodemanApp {}, + setInterval: vi.fn(), + clearInterval: vi.fn(), + setTimeout, + clearTimeout, + requestAnimationFrame: vi.fn(), + document: { addEventListener: vi.fn(), getElementById: vi.fn(), documentElement: { dataset: {} } }, + window: { + addEventListener: vi.fn(), + removeEventListener: vi.fn(), + open: openSpy, + document: { documentElement: { dataset: {} } }, + }, + }); + vm.runInContext(`${CONSTANTS}\n${TERMINAL_UI}\nglobalThis.__proto = CodemanApp.prototype;`, context); + const proto = (context as unknown as { __proto: Record unknown> }).__proto; + let provider: { provideLinks: (line: number, cb: (links?: Link[]) => void) => void } | undefined; + const app = { + terminal: { + cols, + rows: 24, + registerLinkProvider(value: typeof provider) { + provider = value; + }, + buffer: { active: { viewportY: 0, ...fakeBuffer(rows, cols) } }, + }, + activeSessionId: 's1', + sessions: new Map([['s1', { workingDir: '/workspace/project' }]]), + openFilePreview: vi.fn(), + openLogViewerWindow: vi.fn(), + _openTerminalUrl: proto._openTerminalUrl, + _resolveLinkedFilePath: proto._resolveLinkedFilePath, + _isExternalPreviewPath: () => false, + registerFilePathLinkProvider: proto.registerFilePathLinkProvider, + }; + app.registerFilePathLinkProvider.call(app); + const provide = (line: number): Link[] => { + let links: Link[] | undefined; + provider!.provideLinks(line, (value) => { + links = value; + }); + return links ?? []; + }; + return { provide, openSpy }; + } + + it('opens the whole URL from any of its rows', () => { + const { provide, openSpy } = harness(TABLE, COLS); + for (const line of [2, 3, 4]) { + const urlLinks = provide(line).filter((link) => link.text.startsWith('https://')); + expect(urlLinks.length).toBeGreaterThan(0); + for (const link of urlLinks) expect(link.text).toBe(URL); + const onRow = urlLinks.find((link) => link.range.start.y === line); + expect(onRow).toBeDefined(); + onRow!.activate(null, onRow!.text); + expect(openSpy).toHaveBeenLastCalledWith(URL, '_blank', 'noopener,noreferrer'); + } + }); + + it('opens the whole table URL without including the adjacent date and owner columns', () => { + const { provide, openSpy } = harness(TABLE_WITH_TRAILING_COLUMNS, TABLE_COLS); + for (const line of [2, 3, 4]) { + const links = provide(line).filter((link) => link.text.startsWith('https://')); + expect(links.length).toBeGreaterThan(0); + for (const link of links) expect(link.text).toBe(URL); + links[0].activate(null, links[0].text); + expect(openSpy).toHaveBeenLastCalledWith(URL, '_blank', 'noopener,noreferrer'); + } + const firstRowLink = provide(2).find((link) => link.text === URL && link.range.start.y === 2); + expect(firstRowLink?.range.end.x).toBe(TABLE[1].text.length); + }); + + it('opens every segment after a full-width table divider', () => { + const rows = [{ text: '─'.repeat(TABLE_COLS) }, ...TABLE_WITH_TRAILING_COLUMNS.slice(1, 4)]; + const { provide, openSpy } = harness(rows, TABLE_COLS); + for (const line of [2, 3, 4]) { + const link = provide(line).find((item) => item.text === URL && item.range.start.y === line); + expect(link).toBeDefined(); + link!.activate(null, link!.text); + expect(openSpy).toHaveBeenLastCalledWith(URL, '_blank', 'noopener,noreferrer'); + } + }); + + it('underlines only the URL cells on each row, never the indent between pieces', () => { + const { provide } = harness(TABLE, COLS); + const segments = provide(3).filter((link) => link.text === URL); + expect(segments.map((link) => link.range.start.y)).toEqual([2, 3, 4]); + for (const link of segments) expect(link.range.start.y).toBe(link.range.end.y); + expect(segments[0].range.start.x).toBe(TABLE[1].text.indexOf('https://') + 1); + expect(segments[0].range.end.x).toBe(TABLE[1].text.length); + expect(segments[1].range.start.x).toBe(CELL + 1); + expect(segments[2].range.start.x).toBe(CELL + 1); + // Ends on `true`, not on the closing paren after it. + expect(segments[2].range.end.x).toBe(TABLE[3].text.length - 1); + }); + + it('keeps a single-row URL with a paren pair whole', () => { + const line = 'Open https://example.com/wiki/Widget_(tool) please'; + const { provide } = harness([{ text: line }], 80); + const [link] = provide(1).filter((l) => l.text.startsWith('https://')); + expect(link.text).toBe('https://example.com/wiki/Widget_(tool)'); + }); +}); diff --git a/test/webview-loopback-links.test.ts b/test/webview-loopback-links.test.ts index c19d30861..3d053971e 100644 --- a/test/webview-loopback-links.test.ts +++ b/test/webview-loopback-links.test.ts @@ -299,7 +299,10 @@ describe('openLinkThroughWebTabIfLoopback', () => { describe('callers consult the hook first', () => { it('terminal URL links try the web tab before window.open', () => { - const activate = TERMINAL_UI.indexOf('activate(_event, text) {'); + // URL links (and OSC 8 links) open through _openTerminalUrl(), so the desktop + // click and the mobile touch-tap path both consult the hook. Read that function. + expect(TERMINAL_UI).toContain('self._openTerminalUrl(url);'); + const activate = TERMINAL_UI.indexOf('_openTerminalUrl(text) {'); expect(activate).toBeGreaterThan(-1); const body = TERMINAL_UI.slice(activate, TERMINAL_UI.indexOf('},', activate)); expect(body.indexOf('openLinkThroughWebTabIfLoopback?.(text)')).toBeGreaterThan(-1); From 32a1d544ab9c127d673b68252cdae485ce8e9467 Mon Sep 17 00:00:00 2001 From: Aamer Akhter Date: Thu, 22 Oct 2026 17:22:46 -0400 Subject: [PATCH 02/68] feat(terminal): link file paths that contain spaces as one path Every path pattern ended a path at whitespace, so "/mnt/c/OneDrive - Contoso/a.docx" linked "/mnt/c/OneDrive" plus a relative "Contoso/a.docx", two links to the wrong files, in the terminal and the response viewer alike. findFilePathLinks() now takes a spaced path whole where the line bounds it: the whole line is the path, or it follows a list marker and/or a short "Label:". A relative spaced path needs the marker or label. The candidate must read as one path (no earlier word ends on an extension, starts a new "/" path or is an arrow). A spaced line that is one path but not linkable (unknown root, "." or ".." segment) links nothing instead of a fragment. --- src/web/public/constants.js | 74 ++++++++++++- test/spaced-file-path-links.test.ts | 165 ++++++++++++++++++++++++++++ 2 files changed, 236 insertions(+), 3 deletions(-) create mode 100644 test/spaced-file-path-links.test.ts diff --git a/src/web/public/constants.js b/src/web/public/constants.js index 4cb2f099a..3308dfd2f 100644 --- a/src/web/public/constants.js +++ b/src/web/public/constants.js @@ -2178,27 +2178,93 @@ function findFilePathLinks(text) { while ((match = urlPattern.exec(value)) !== null) urls.push({ index: match.index, path: match[0] }); const inUrl = (index, length) => urls.some((u) => index < u.index + u.path.length && u.index < index + length); + // A path with spaces is taken whole, or the span it covers links nothing + // (spacedFilePathOnLine). Either way no detector below may cut a fragment out of it. + const reserved = []; + let lineStart = 0; + for (const line of value.split('\n')) { + const spaced = spacedFilePathOnLine(line); + if (spaced && !inUrl(lineStart + spaced.index, spaced.path.length)) { + const index = lineStart + spaced.index; + reserved.push({ index, path: spaced.path }); + if (spaced.link) found.push({ path: spaced.path, index }); + } + lineStart += line.length + 1; + } + const isReserved = (index, length) => + reserved.some((r) => index < r.index + r.path.length && r.index < index + length); + const absolute = absoluteFilePathPattern(); while ((match = absolute.exec(value)) !== null) { - if (!inUrl(match.index, match[1].length)) found.push({ path: match[1], index: match.index }); + if (!inUrl(match.index, match[1].length) && !isReserved(match.index, match[1].length)) { + found.push({ path: match[1], index: match.index }); + } } const relative = new RegExp(RELATIVE_FILE_PATH_LINK_PATTERN.source, 'g'); while ((match = relative.exec(value)) !== null) { const path = match[2]; const index = match.index + match[1].length; - if (overlaps(index, path.length) || inUrl(index, path.length)) continue; + if (overlaps(index, path.length) || inUrl(index, path.length) || isReserved(index, path.length)) continue; found.push({ path, index }); } const folders = new RegExp(FOLDER_PATH_LINK_PATTERN.source, 'g'); while ((match = folders.exec(value)) !== null) { const path = folderLinkPath(match[2]); const index = match.index + match[1].length; - if (!path || overlaps(index, path.length) || inUrl(index, path.length)) continue; + if (!path || overlaps(index, path.length) || inUrl(index, path.length) || isReserved(index, path.length)) continue; found.push({ path, index, folder: true }); } return found.sort((a, b) => a.index - b.index); } +/** + * A file path WITH SPACES on one line, or null. + * + * Every path pattern ends a path at whitespace, so `/mnt/c/OneDrive - Contoso/a.docx` + * linked `/mnt/c/OneDrive` plus a relative `Contoso/a.docx`, two links to the + * wrong targets. A space is only safe inside a path where the line itself bounds + * the path, so a spaced path is recognised in exactly these shapes: + * + * /mnt/c/My Docs/report.docx the whole line is the path + * - /mnt/c/My Docs/report.docx after a list marker (`-`, `*`, `•`, `1.`, `2)`) + * Saved to: 03 Attachments/plan.pptx after a short label (one to four words + `:`) + * + * An absolute path may stand alone; a relative or `~/` one needs a marker or a + * label, since a bare spaced relative candidate is ordinary prose. One trailing + * sentence mark is left out. The candidate must read as ONE path: it ends on a + * known extension, and no earlier word ends on one, starts a new `/` path, ends + * in `:` or is an arrow, so two paths on a line are never merged. + * + * A candidate that is one path but not a linkable one (an unknown root such as + * `/etc`, a `.`/`..`/empty segment) comes back with `link: false`: its span links + * nothing, rather than the fragment after its last space. + * + * Linear: one bounded prefix match, then string scans. + * + * @param {string} line One line, no newline. + * @returns {{path: string, index: number, link: boolean} | null} + */ +function spacedFilePathOnLine(line) { + const text = String(line || ''); + const prefix = /^[ \t]*((?:\d{1,3}[.)]|[-*•])[ \t]+)?((?:[A-Za-z][\w-]{0,30}(?: [\w-]{1,30}){0,3}):[ \t]+)?/.exec(text); + const index = prefix[0].length; + const path = text.slice(index).replace(/\s+$/, '').replace(/[.,;:!?]$/, ''); + if (!path.includes(' ') || !path.includes('/') || path.length > 1000) return null; + if (!path.startsWith('/') && !prefix[1] && !prefix[2]) return null; + if (!PATH_TOKEN_ENDS_WITH_EXTENSION.test(path)) return null; + if (/[\t"'`<>|;&\x00-\x1f\\]/.test(path) || path.includes('://')) return null; + const words = path.split(' '); + for (const word of words.slice(0, -1)) { + if (word.endsWith(':') || /^(?:—|–|→|=>|->|<-)$/.test(word)) return null; + if (PATH_TOKEN_ENDS_WITH_EXTENSION.test(word.replace(/[.,;:!?)\]}]+$/, ''))) return null; + } + if (words.slice(1).some((word) => word.startsWith('/'))) return null; + const rooted = SPACED_PATH_ROOT.test(path) || path.startsWith('~/'); + const segments = path.replace(/^~?\//, '').split('/'); + const link = (rooted || !path.startsWith('/')) && segments.every((seg) => seg && seg !== '.' && seg !== '..'); + return { path, index, link }; +} + /** * Folder paths, for the folder grid: rooted like {@link FILE_PATH_LINK_PATTERN} * (`/mnt/raid1/neon_getaway/styleframes`), `~/renders`, or relative with a @@ -2385,6 +2451,8 @@ const FILE_LINK_ROOT_GROUP = /^\(\\\/\(\?:([^()]*)\)/.exec(FILE_PATH_LINK_PATTER const PATH_TOKEN_ENDS_WITH_EXTENSION = new RegExp(`\\.${FILE_LINK_EXTENSION_GROUP}$`); const PATH_TOKEN_HAS_EXTENSION = new RegExp(`\\.${FILE_LINK_EXTENSION_GROUP}\\b`); const PATH_TOKEN_STARTS_NEW_PATH = new RegExp(`^(?:~\\/|\\/(?:${FILE_LINK_ROOT_GROUP})\\/)`); +/** A rooted path's start (`/home/`, `/mnt/`, …), for {@link spacedFilePathOnLine}. */ +const SPACED_PATH_ROOT = new RegExp(`^\\/(?:${FILE_LINK_ROOT_GROUP})\\/`); /** A relative head that names its own location (`./a`, `../a`) is an entry of its own. */ const PATH_TOKEN_STARTS_EXPLICIT_RELATIVE = /^\.{1,2}\//; /** First directory of a path, a leading `/` or `./` ignored: `src` for `src/a.ts` and `/src/a.ts`. */ diff --git a/test/spaced-file-path-links.test.ts b/test/spaced-file-path-links.test.ts new file mode 100644 index 000000000..b799a8883 --- /dev/null +++ b/test/spaced-file-path-links.test.ts @@ -0,0 +1,165 @@ +// Port: none (a pure function in a vm context — no browser, no server). +// +// File paths with spaces in them (`OneDrive - Contoso/…`, `90 Sensitive/…`) were +// cut at the first space by every path pattern, so the terminal and the response +// viewer linked a fragment such as `/mnt/c/proj/My` plus a bogus relative tail +// `Docs/report.docx`, each opening the wrong target. findFilePathLinks() now takes +// a spaced path WHOLE where the line makes its extent unambiguous: the path is the +// whole line, or follows a list marker and/or a short `Label:`. A spaced line that +// is one path but not a linkable one (unknown root, `..`) links nothing at all, +// rather than a fragment. +import { readFileSync } from 'node:fs'; +import { resolve } from 'node:path'; +import vm from 'node:vm'; +import { describe, expect, it } from 'vitest'; + +const SOURCE = readFileSync(resolve(import.meta.dirname, '../src/web/public/constants.js'), 'utf8'); + +function load(): (text: string) => Array<{ path: string; index: number; folder?: true }> { + const context = vm.createContext({ console, window: undefined }); + vm.runInContext(`${SOURCE}\nglobalThis.__fn = findFilePathLinks;`, context, { filename: 'constants.js' }); + return (context as { __fn: never })['__fn'] as never; +} + +const findFilePathLinks = load(); +const paths = (text: string) => findFilePathLinks(text).map((f) => f.path); + +const LONG = + '/mnt/c/Users/someone/OneDrive - Contoso/01_Projects/Other/kb/out/deliverables/fy27-messaging-market-story.docx'; + +describe('spaced file paths', () => { + it('links an own-line absolute path with spaces whole', () => { + expect(paths(LONG)).toEqual([LONG]); + expect(paths('/mnt/c/proj/My Big Docs/report.docx')).toEqual(['/mnt/c/proj/My Big Docs/report.docx']); + }); + + it('reports where the whole path starts', () => { + const line = ' - Path: /mnt/c/proj/My Big Docs/report.docx'; + expect(findFilePathLinks(line)).toEqual([ + { path: '/mnt/c/proj/My Big Docs/report.docx', index: line.indexOf('/mnt') }, + ]); + }); + + it('leaves a single trailing sentence period out', () => { + expect(paths('/mnt/c/proj/My Big Docs/report.docx.')).toEqual(['/mnt/c/proj/My Big Docs/report.docx']); + }); + + it.each(['Path:', 'File:', 'Saved to:'])('links an absolute spaced path after %s', (label) => { + const p = '/mnt/sync/brain/90 Sensitive/team/training/attendees.xlsx'; + expect(paths(`${label} ${p}`)).toEqual([p]); + }); + + it.each(['Path:', 'File:', 'Saved to:'])('links a relative spaced path after %s', (label) => { + const p = '03 Attachments/Build SIPOC Design/Build-SIPOC.pptx'; + expect(paths(`${label} ${p}`)).toEqual([p]); + }); + + it.each(['- ', '* ', '• ', '1. ', '2) '])('links a spaced path after the list marker %j', (marker) => { + const rel = '03 Attachments/Build SIPOC Design/Build-SIPOC.pptx'; + expect(paths(`${marker}${rel}`)).toEqual([rel]); + expect(paths(` ${marker}${LONG}`)).toEqual([LONG]); + }); + + it('works line by line inside multi-line text (the response viewer hands over text nodes)', () => { + const text = `Wrote two files:\n- ${LONG}\n- notes/a b/c.md\n`; + expect(paths(text)).toEqual([LONG, 'notes/a b/c.md']); + }); + + it('links nothing for a spaced path outside the known roots (no partial tail)', () => { + expect(paths('/etc/secret files/passwords.txt')).toEqual([]); + expect(paths('/srvx/team share/notes/passwords.txt')).toEqual([]); + }); + + it('links nothing for a spaced path with a `..` or `.` segment', () => { + expect(paths('/mnt/c/proj/../other place/docs/report.docx')).toEqual([]); + expect(paths('File: 03 Attachments/../private/report.xlsx')).toEqual([]); + expect(paths('- 03 Attachments/./private/report.xlsx')).toEqual([]); + }); + + it('never merges two space-separated paths into one link', () => { + expect(paths('/mnt/c/proj/a.md /mnt/c/proj/b.pdf')).toEqual(['/mnt/c/proj/a.md', '/mnt/c/proj/b.pdf']); + expect(paths('- docs/a.md docs/b.md')).toEqual(['docs/a.md', 'docs/b.md']); + expect(paths('/mnt/c/proj/a b.md -> /mnt/c/proj/c.md')).not.toContain('/mnt/c/proj/a b.md -> /mnt/c/proj/c.md'); + }); + + it('does not take prose into a path', () => { + // No marker or label: a relative spaced candidate is prose, not a path. + expect(paths('See docs/report.docx for details')).toEqual(['docs/report.docx']); + expect(paths('see the docs/a b.md')).not.toContain('see the docs/a b.md'); + // A long description before a colon is a sentence, not a label: only the path links. + const rel = '.worktrees/feature-x/docs/specs/2026-09-05-design.md'; + expect(paths(`• Design committed in the isolated worktree: ${rel}`)).toEqual([rel]); + }); + + it('leaves unspaced paths to the ordinary detectors', () => { + expect(paths('- /mnt/c/proj/docs/report.docx')).toEqual(['/mnt/c/proj/docs/report.docx']); + expect(paths('• committed as /tmp/x/docs/specs/design.md:1 (e7ae1b4).')).toEqual(['/tmp/x/docs/specs/design.md']); + }); + + it('stays linear on a long spaced line', () => { + const line = '- ' + 'a '.repeat(20000) + '/x.md'; + const t0 = performance.now(); + findFilePathLinks(line); + expect(performance.now() - t0).toBeLessThan(500); + }); +}); + +describe('terminal link provider with spaced paths', () => { + type Link = { text: string; activate: (event: unknown, text: string) => void }; + const TERMINAL_UI = readFileSync(resolve(import.meta.dirname, '../src/web/public/terminal-ui.js'), 'utf8'); + + function linksFor(line: string) { + const noop = () => {}; + const context = vm.createContext({ + console: { ...console, debug: noop, log: noop }, + CodemanApp: class CodemanApp {}, + document: { addEventListener: noop, getElementById: noop, documentElement: { dataset: {} } }, + window: { addEventListener: noop, removeEventListener: noop, document: { documentElement: { dataset: {} } } }, + }); + vm.runInContext(`${SOURCE}\n${TERMINAL_UI}\nglobalThis.__proto = CodemanApp.prototype;`, context); + const proto = (context as unknown as { __proto: Record unknown> }).__proto; + let provider: { provideLinks: (row: number, cb: (links?: Link[]) => void) => void } | undefined; + const opened: string[] = []; + const app = { + terminal: { + cols: 200, + registerLinkProvider: (p: typeof provider) => (provider = p), + buffer: { + active: { + length: 1, + getLine: (r: number) => + r === 0 + ? { + isWrapped: false, + translateToString: (trim?: boolean) => (trim === false ? line.padEnd(200) : line), + } + : undefined, + }, + }, + }, + activeSessionId: 's1', + sessions: new Map([['s1', { workingDir: '/workspace/project' }]]), + openFilePreview: (p: string) => opened.push(p), + openLogViewerWindow: (p: string) => opened.push(`log:${p}`), + _isExternalPreviewPath: () => false, + _resolveLinkedFilePath: proto._resolveLinkedFilePath, + registerFilePathLinkProvider: proto.registerFilePathLinkProvider, + }; + app.registerFilePathLinkProvider.call(app); + let links: Link[] = []; + provider!.provideLinks(1, (value) => (links = value ?? [])); + return { links, opened }; + } + + it('links the whole spaced path and opens it, resolved against the workspace', () => { + const rel = '03 Attachments/Build SIPOC Design/Build-SIPOC.pptx'; + const { links, opened } = linksFor(`Saved to: ${rel}`); + expect(links.map((l) => l.text)).toEqual([rel]); + links[0].activate(null, links[0].text); + expect(opened).toEqual([`/workspace/project/${rel}`]); + }); + + it('links nothing for an unlinkable spaced path', () => { + expect(linksFor('/etc/secret files/passwords.txt').links).toEqual([]); + }); +}); From efa647d2578d59d8a4dc721ced1ed9b683b705c5 Mon Sep 17 00:00:00 2001 From: Aamer Akhter Date: Fri, 23 Oct 2026 07:26:15 -0400 Subject: [PATCH 03/68] fix(terminal): exclude relative prose and trim spaced paths linearly --- CLAUDE.md | 4 +++- docs/architecture-invariants.md | 4 +++- src/web/public/constants.js | 15 ++++++++++----- test/spaced-file-path-links.test.ts | 26 ++++++++++++++++++++++++++ 4 files changed, 42 insertions(+), 7 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 02704d0fe..d341d772a 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -222,7 +222,9 @@ Codeman is a Claude Code session manager with web interface and autonomous Ralph **Prompt uploads** (`POST /api/sessions/:id/paste-image`: clipboard paste, drop, the Compose Attach key and the phone toolbar's Attach button; client wiring in `image-input.js` and `keyboard-accessory.js`): a pick is written to `/.codeman-uploads/` and its path lands on the prompt as unsent text. The folder is IN the workspace because that is the only path identical for a local and a container agent, FLAT because `/.codeman/` would BE the data dir when the workspace is the home directory, and self-ignoring through a `.gitignore` of `*` written ONCE with `wx` (a file already there is the user's and is never overwritten; the user's own ignore file is never touched). ⚠️ The directory names live ONLY in `paste-image-gc.ts` (`UPLOAD_DIR_NAMES`): the pre-move `.claude-images/` receives nothing but stays readable for one release, so the GC sweep and the delete cleanup (through `uploadDirs()`) and the image watcher's ignore filter (reading the names) cover BOTH; retire the legacy entry there, never by hand elsewhere. ⚠️ `uploadDirs()` is async and probes the workspace BOUNDED first (`probePathKind()`, the #516 rule: an `unknown` path is skipped, never touched; the sweep keeps the stall cap and the delete passes `pastCap`), then returns only REAL directories (lstat, so a planted symlink is never one), none that is or contains `getDataDir()` (one strictly inside it only holds uploads and is listed), and none for a remote session, since both consumers delete; the check is made at list time, and the same-user race after it is accepted. Images are buffered in memory under `MAX_PASTE_IMAGE_BYTES` (50MB) after a client-side canvas re-encode; a DOCUMENT (pdf, docx, pptx, xlsx, zip and the text family txt/md/csv/tsv/json/log/yaml/yml/xml; the curated `ALLOWED_DOCUMENT_EXTS`, never `SUPPORTED_ATTACHMENT_EXTENSIONS` or `EDITABLE_EXTENSIONS`, which carry sh/py/js/html) is admitted by its name's extension alone, buffered like an image, judged on its bytes by the same fail-closed `imageMagicMatchesExt` (`%PDF-`, `PK\x03\x04` for the OOXML trio and zip, UTF-8 without a NUL byte for text: a SIGNATURE or ENCODING check, never a structure or content check, so generic zip bytes pass as `.docx` and a script renamed `.txt` is text; the sh/py/js/html exclusion is a SCOPE choice by extension, keeping the server from storing what it would serve or run, not a content filter: the agent can already write any of those) and saved as `paste---` with a sanitized stem (`[A-Za-z0-9._-]`, 40 chars, a wholly non-ASCII name leaves no stem; the `paste-` prefix is what the GC matches). A VIDEO is never buffered: `?kind=video` streams it to disk under `MAX_PASTE_VIDEO_BYTES` (1GB, env `CODEMAN_MAX_PASTE_VIDEO_BYTES`), judges the container on the first 12 bytes as they arrive (ISO BMFF / QuickTime atom, EBML for webm, Ogg for ogv; a mislabeled body is drained unwritten from there, never parked on disk first) and unlinks on any failure, a dead transfer included. ⚠️ The flag is the ONLY way onto that path and admits video types alone (the registry's `VIDEO_ATTACHMENT_EXTENSIONS`, single-sourced: mp4/webm/mov/m4v/ogv, so an uploaded video is one the file viewer can also play; 3gp/mkv are out, since a phone's camera roll produces neither and neither previews), so an image can never ride the larger cap through memory; without it the route is byte-identical to the image-only version. Busboy takes its size limit before the part is seen, which is why the choice rides the URL and not the file. ⚠️ A video draws on its OWN per-(IP, session) rate bucket (`consumeVideoToken`, 3/min) beside the image bucket (`consumePasteToken`, 30/min): at 1GB each, 30/min would be 30GB of disk a minute, so the lower rate bounds it to 3GB; the image path keeps the 30/min bucket and is unchanged without the flag. A remote (SSH) session is refused (400) before any disk touch: its `workingDir` is the remote path, so the file would land on THIS host where the agent cannot read it (Docker is fine, the workspace is bind-mounted at the same absolute path). ⚠️ A live session's workspace can still sit on a network mount that stopped answering, so the route probes it BOUNDED (`probePathKind(session.workingDir, { pastCap: true })`, the #516 rule) before the first disk touch and refuses an `unknown` mount through `describeUnknownPath()` (OPERATION_FAILED), the way session create does, so a mount already unreachable cannot freeze a libuv worker on every upload (a mount that dies after the probe is the same residual race create accepts). ⚠️ Dispatch mirrors the client: an `image/*` MIME takes the IMAGE path whatever the name says (a GIF named `animation.txt` stays a GIF), a document extension takes the document branch, and anything else takes the image path exactly as before, where the HEIC sniff still recovers a HEIC under a generic name; a foreign extension with no image MIME gets its `Unsupported file type` 400 only AFTER that sniff fails, never before, so nothing the image path ever accepted by its bytes is refused. A text mismatch is refused as not UTF-8 text (UTF-16 when the file opens with a UTF-16 BOM, as Excel's "Unicode Text" and Notepad's "Unicode" do), telling the user to save it as UTF-8, and its log line names the failure (`utf16-bom`/`nul-byte`/`not-utf8`) and never prints the bytes, since the head of a text file is user content. ⚠️ The client classifies a pick through ONE helper, `_promptAttachKind()` (MIME type first, then the name's extension, because some Android file managers hand over a File with none; returns `image`, `video` or `document`); every pick and paste filter (Attach, drop, the tile grid's drop in `tile-grid.js`, the Compose and paste-dialog pickers, the clipboard handlers), the flag and the re-encode gate read it, never `file.type` directly. Those surfaces ADMIT through `_admitPromptAttachments()` (image-input.js), which calls it and toasts what a pick skipped (how many files of a mixed pick, `Unsupported file type` when none pass, nothing for a text paste); a new surface admits through that helper too, never by filtering on `_promptAttachKind()` itself, or it loses the toasts. ⚠️ A pick that lands while the tile grid owns the terminal goes to the PTY, never the main terminal's overlay: that terminal is parked and hidden then, and a tile's Enter cannot flush it (`_tilesOwnTerminal()` in the `_attachFromToolbar` guard). Tests: `test/toolbar-attach.test.ts`, `test/mobile-prompt-composer.test.ts`, `test/paste-image-gc.test.ts`, `test/paste-image-dir-shared.test.ts`, `test/upload-document-list-parity.test.ts` (the browser's copies of `ALLOWED_DOCUMENT_EXTS`: `_promptAttachKind()`, the three pickers' `accept=` and the two file-path link patterns), the paste-image block of `test/routes/session-routes.test.ts`. → [architecture-invariants#buffers-uploads-and-terminal-history](docs/architecture-invariants.md#buffers-uploads-and-terminal-history) -**File-path links (terminal + chat)**: a path an agent prints is clickable on BOTH surfaces and opens the file-preview overlay. ⚠️ ONE pattern (`FILE_PATH_LINK_PATTERN` / `absoluteFilePathPattern()` in constants.js) feeds the xterm link provider AND `_linkifyFilePaths()`, a fresh instance per call (`lastIndex`). Both call `findFilePathLinks()`, which adds `~/`, relative and folder paths without overlapping a rooted match or a URL; a relative path resolves against `workingDir` (`resolveLinkedFilePath`), `~/` only server-side on the session's host (attachment route), and `terminalPathLine()` joins a path a program hard-wrapped short of the edge. ⚠️ A folder link opens the **folder grid**: `POST /api/sessions/:id/folders` guards + GRANTS the folder (`folder-listing.ts`, own registry, never the 200-capped attachment registry), files come by bare name via `…/folders/:folderId/files/:name/*` through `resolveServableAttachmentPath` plus the same extension/regular-file gate on the symlink's resolved target, so no browser request carries a path; `~/` is refused in a Docker case (the container's home is not this host's). The chat linkifier walks TEXT NODES with DOM APIs, never rebuilds sanitized markup as a string. ⚠️ An out-of-workspace path goes through the ATTACHMENT routes (`POST /api/sessions/:id/attachments` with `notify: false`), never by widening `file-content`/`file-raw` or `file-stream-manager`'s `tail -f` allowlist. ⚠️ `TEXT_ATTACHMENT_EXTENSIONS` IS `EDITABLE_EXTENSIONS` (never a second list), and widening READ must never widen RUN: `html`/`htm`/`svg` stay download-only, other text is inert `text/plain`+`nosniff`. ⚠️ The ONE place HTML renders is `/html-view//...` (`html-view-routes.ts`, `html-view-capabilities.ts`): `POST /api/sessions/:id/html-view` mints a capability for the file's own directory, every response carries `Content-Security-Policy: sandbox` WITHOUT `allow-same-origin` (opaque origin even as a top-level tab, so the page can never read Codeman or drive its API) and with NO fetch directive (open network, owner decision 2026-10-13: never restrict it or add `allow-same-origin` without the owner), and only non-dot, asset-allowlisted files under that directory are served (GET/HEAD only; capabilities revoked with the web-tab ones on logout). That tree is served recursively, so minting is refused for a hidden directory or a broad root (`isBroadHtmlViewRoot`); the preview renders by default and the **Page** pill (or a refused mint) falls through to the source view and its Edit button. Never serve HTML same-origin anywhere else. Tests: `test/routes/html-view-routes.test.ts`. Media extensions are single-sourced in `attachment-registry.ts`. ⚠️ **XLSX previews parse in the BROWSER**, never on the server; the admission limits that keep a hostile workbook from freezing the worker live in `src/web/CLAUDE.md` (Spreadsheet preview). → [architecture-invariants#file-path-links-terminal--response-viewer](docs/architecture-invariants.md#file-path-links-terminal--response-viewer) +**File-path links (terminal + chat)**: a path an agent prints is clickable on BOTH surfaces and opens the file-preview overlay. ⚠️ ONE pattern (`FILE_PATH_LINK_PATTERN` / `absoluteFilePathPattern()` in constants.js) feeds the xterm link provider AND `_linkifyFilePaths()`, a fresh instance per call (`lastIndex`). Both call `findFilePathLinks()`, which reserves spaced-path spans first (even unlinkable ones), then adds rooted, `~/`, relative and folder paths without overlapping a reservation, earlier match or URL; a relative path resolves against `workingDir` (`resolveLinkedFilePath`), `~/` only server-side on the session's host (attachment route), and `terminalPathLine()` joins a path a program hard-wrapped short of the edge. ⚠️ A folder link opens the **folder grid**: `POST /api/sessions/:id/folders` guards + GRANTS the folder (`folder-listing.ts`, own registry, never the 200-capped attachment registry), files come by bare name via `…/folders/:folderId/files/:name/*` through `resolveServableAttachmentPath` plus the same extension/regular-file gate on the symlink's resolved target, so no browser request carries a path; `~/` is refused in a Docker case (the container's home is not this host's). The chat linkifier walks TEXT NODES with DOM APIs, never rebuilds sanitized markup as a string. ⚠️ An out-of-workspace path goes through the ATTACHMENT routes (`POST /api/sessions/:id/attachments` with `notify: false`), never by widening `file-content`/`file-raw` or `file-stream-manager`'s `tail -f` allowlist. ⚠️ `TEXT_ATTACHMENT_EXTENSIONS` IS `EDITABLE_EXTENSIONS` (never a second list), and widening READ must never widen RUN: `html`/`htm`/`svg` stay download-only, other text is inert `text/plain`+`nosniff`. ⚠️ The ONE place HTML renders is `/html-view//...` (`html-view-routes.ts`, `html-view-capabilities.ts`): `POST /api/sessions/:id/html-view` mints a capability for the file's own directory, every response carries `Content-Security-Policy: sandbox` WITHOUT `allow-same-origin` (opaque origin even as a top-level tab, so the page can never read Codeman or drive its API) and with NO fetch directive (open network, owner decision 2026-10-13: never restrict it or add `allow-same-origin` without the owner), and only non-dot, asset-allowlisted files under that directory are served (GET/HEAD only; capabilities revoked with the web-tab ones on logout). That tree is served recursively, so minting is refused for a hidden directory or a broad root (`isBroadHtmlViewRoot`); the preview renders by default and the **Page** pill (or a refused mint) falls through to the source view and its Edit button. Never serve HTML same-origin anywhere else. Tests: `test/routes/html-view-routes.test.ts`. Media extensions are single-sourced in `attachment-registry.ts`. ⚠️ **XLSX previews parse in the BROWSER**, never on the server; the admission limits that keep a hostile workbook from freezing the worker live in `src/web/CLAUDE.md` (Spreadsheet preview). → [architecture-invariants#file-path-links-terminal--response-viewer](docs/architecture-invariants.md#file-path-links-terminal--response-viewer) + +Spaced paths are considered first by `findFilePathLinks()`: accepted candidates reserve their entire span even when unlinkable, so later rooted, relative and folder detectors cannot link fragments. Relative candidates whose first slash-containing word is their last word defer to ordinary detection to exclude list/label prose. This deliberately gives up spaces only in the top directory (`Saved to: 03 Attachments/plan.pptx`); prose shaped like deeper spaced paths (`- Fixed bug in src/My Docs/a.md`, `/home/me/proj contains a.md`) remains ambiguous. Trailing whitespace uses linear `trimEnd()` before the length cap. **Filesystem path picker** (Link Existing "Browse" + the mobile keyboard's `📁 Path` key): lazy one-directory browsing via `GET /api/filesystem/browse`, with `GET /api/filesystem/preview` for the tapped file. Inserts the path **without** Enter, so the prompt is never submitted; the sibling `⌫ All` key clears only the unsent prompt and must never send the agent's `/clear`. ⚠️ This is a **second file-serving surface and inherits neither the attachment confinement nor its ownership scoping** — it allowlists Home, `CASES_DIR`, `/mnt/d` and `CODEMAN_FILE_PICKER_ROOTS`, blocks sensitive trees, and rejects symlink escapes **after** `realpath`. ⚠️ The optional `sessionId` is an ownership boundary that must be `canAccessOwned`-checked by hand (it does not go through `findSessionOrFail`), and in multi-user mode a non-admin gets only their own `userSpacePath` as a root: per-user spaces live INSIDE `homedir()`, so a `Home` root exposes every other user's workspace. Previews go through the same global conversion limiter, and Markdown/TXT/JSON are served as inert `text/plain`. → [architecture-invariants#filesystem-path-picker](docs/architecture-invariants.md#filesystem-path-picker) diff --git a/docs/architecture-invariants.md b/docs/architecture-invariants.md index f446f4a1a..0a4ac07e5 100644 --- a/docs/architecture-invariants.md +++ b/docs/architecture-invariants.md @@ -407,7 +407,9 @@ A file path an agent prints is a link on both surfaces it can appear on, and cli ⚠️ **The terminal routes an out-of-workspace path to the preview, not the log viewer.** The log viewer spawns `tail -f` and allows only the workspace, `/var/log` and `~/logs`, so an external `.log`/`.json`/code path answered `Path must be within working directory or allowed log directories` while the SAME path clicked in the response viewer previewed fine. `activate()` now checks `_isExternalPreviewPath` alongside `previewsInFileViewer`. In-workspace text keeps the tail viewer, which is the point of it (live follow), except markdown (`.md`/`.markdown`, in `FILE_PREVIEW_EXTENSIONS` since the owner's 2026-10-13 decision, rendered as a document) and `.html`/`.htm` (`rendersAsPage()`, rendered as a page, see below), which always go to the preview; nothing widened `file-stream-manager`'s allowlist, so no `tail -f` is spawned on an arbitrary host path. -**Path forms beyond the rooted one.** `findFilePathLinks(text)` (constants.js) is the single entry point both consumers call; it returns the rooted matches first, then `~/` and relative paths (`RELATIVE_FILE_PATH_LINK_PATTERN`, same extension list, pinned equal by `test/link-provider-regex.test.ts`), then folder paths, each only where it overlaps neither an earlier match nor an `http(s)` URL. A relative match must start at a boundary (line start, whitespace, quote, bracket, `=`, `,`, `>`), so it can never begin inside a word, an absolute path or a URL; brackets, `=` and `,` end it (`(shot.png)`, `OUT=shots/a.png`). `resolveLinkedFilePath()` resolves a relative path against the session's `workingDir` (`.`/`..` collapsed, so `../x.png` lands outside and routes like any external path) and passes absolute and `~/` paths through. ⚠️ `~` is the home of the SESSION'S host, which only the server knows: `~/` paths always take the attachment route, where `registerExternalAttachment` expands them (`homedir()` locally, `$HOME` inside the remote probe script), and the guard runs on the expanded, realpath-resolved path as before. ⚠️ Programs hard-wrap their own output short of the terminal edge (Claude Code breaks tool output at 59-118 columns), so xterm's `isWrapped` never fires. `terminalPathLine()` (link provider only; touch selection keeps `terminalLogicalLine`) joins the next row when the previous row ends in a path token with a `/` and no extension and the join reaches an extension; a row that starts a new path is never joined: rooted or `~/`, `./`/`../`, or a relative one that opens on the same top directory the broken token did (the next entry of `git ls-files`/`find`/`ls -R`: `src/components` + `src/index.ts` are two paths, not `src/componentssrc/index.ts`), while a wrapped path continues INSIDE a name, so `…/captures/2` + `026-10-10/…` still joins; a 3+ row chain is kept only if a later row completes the path, and the broken row must be at least as long as the continuing one (keeps `cd /mnt/foo` + a longer next line apart). Tests: `test/file-path-link-forms.test.ts`. +**Path forms beyond the rooted one.** `findFilePathLinks(text)` (constants.js) is the single entry point both consumers call; it reserves spaced candidates first (including unlinkable spans), then detects rooted matches, then `~/` and relative paths (`RELATIVE_FILE_PATH_LINK_PATTERN`, same extension list, pinned equal by `test/link-provider-regex.test.ts`), then folder paths, each only where it overlaps neither an earlier match nor an `http(s)` URL. A relative match must start at a boundary (line start, whitespace, quote, bracket, `=`, `,`, `>`), so it can never begin inside a word, an absolute path or a URL; brackets, `=` and `,` end it (`(shot.png)`, `OUT=shots/a.png`). `resolveLinkedFilePath()` resolves a relative path against the session's `workingDir` (`.`/`..` collapsed, so `../x.png` lands outside and routes like any external path) and passes absolute and `~/` paths through. ⚠️ `~` is the home of the SESSION'S host, which only the server knows: `~/` paths always take the attachment route, where `registerExternalAttachment` expands them (`homedir()` locally, `$HOME` inside the remote probe script), and the guard runs on the expanded, realpath-resolved path as before. ⚠️ Programs hard-wrap their own output short of the terminal edge (Claude Code breaks tool output at 59-118 columns), so xterm's `isWrapped` never fires. `terminalPathLine()` (link provider only; touch selection keeps `terminalLogicalLine`) joins the next row when the previous row ends in a path token with a `/` and no extension and the join reaches an extension; a row that starts a new path is never joined: rooted or `~/`, `./`/`../`, or a relative one that opens on the same top directory the broken token did (the next entry of `git ls-files`/`find`/`ls -R`: `src/components` + `src/index.ts` are two paths, not `src/componentssrc/index.ts`), while a wrapped path continues INSIDE a name, so `…/captures/2` + `026-10-10/…` still joins; a 3+ row chain is kept only if a later row completes the path, and the broken row must be at least as long as the continuing one (keeps `cd /mnt/foo` + a longer next line apart). Tests: `test/file-path-link-forms.test.ts`. + +Spaced paths are considered first by `findFilePathLinks()`: accepted candidates reserve their entire span even when unlinkable, so later rooted, relative and folder detectors cannot link fragments. Relative candidates whose first slash-containing word is their last word defer to ordinary detection to exclude list/label prose. This deliberately gives up spaces only in the top directory (`Saved to: 03 Attachments/plan.pptx`); prose shaped like deeper spaced paths (`- Fixed bug in src/My Docs/a.md`, `/home/me/proj contains a.md`) remains ambiguous. Trailing whitespace uses linear `trimEnd()` before the length cap. **Folder grid: a folder is granted, then served by bare file name.** A folder path (rooted or `~/` whose last segment has no extension, relative only with a trailing `/`; `FOLDER_PATH_LINK_PATTERN` + `folderLinkPath()`) opens `openFolderGrid()` (panels-ui.js), as does the preview's "Show folder" button. `POST /api/sessions/:id/folders {path}` (`src/folder-listing.ts`) resolves the folder (locally a bounded `probePathKind(…, { pastCap: true })` first, then async `fs.realpath`; remotely one ssh round trip via `remoteListFolder`, through `runWithRemoteSshLimit`), runs the attachment guard on it (blocklist, `confineToWorkspace`, and in multi-user mode `folderAllowedForUser`: a non-admin only inside their own `userSpacePath` or the session's `workingDir`, as the filesystem path picker), locally BEFORE reading it, lists non-hidden subfolders and files of a supported attachment type (each file also blocklist-checked, newest first, capped at `MAX_FOLDER_ENTRIES`), and GRANTS it an id. Files are then fetched as `GET …/folders/:folderId/files/:name/(raw|preview|thumbnail)`: `:name` must be a bare file name of a supported type (no `/`, `\`, NUL, no leading `.`, since the listing hides those), the local file is probed bounded (`unknown` → 503, never 404), and the joined path goes through the SAME `resolveServableAttachmentPath` as an attachment by id (symlink re-resolution, remote re-probe, guard), then the same gate registration applies to what a symlink RESOLVES to (a supported extension on the resolved path and a regular file: locally `fs.stat`, remotely the probe kind; else 400, so `shot.png -> ../secret.bin` is not served and a FIFO or device behind a supported name never reaches `open`), then `folderAllowedForUser` once more on the resolved file, before `serveServableRaw` (the attachment raw route's body, shared) streams it. So browser requests still never carry a path. ⚠️ Grants live in their own registry (`folderGrants`, 32 per session, cleared with the session's attachments), NOT the attachment registry: that one is capped at 200 per session, and paging a folder of 300 renders would evict the session's real attachments. ⚠️ A local folder is never touched unbounded (#516 rule): a folder on a network mount that stopped answering answers 503 with `describeUnknownPath()`, not 404 and not a frozen server. ⚠️ The remote listing script tells "missing" (`E|n`, 404) from "is a file" (`E|f`, 400 `Not a folder`), because the client falls back to the file preview on `Not a folder`; a remote extensionless file otherwise answered "Folder not found". ⚠️ `.folder-grid-overlay` sits at `z-index: 5090`, one step BELOW the preview (5100) and above the response viewer (5000): a tile's preview opens on top and closing it lands back on the grid. ⚠️ ←/→/Esc are handled in a document CAPTURE listener with `stopPropagation`, because the terminal keeps keyboard focus under the overlays and an arrow that reached xterm would be typed into the session; inputs other than xterm's helper textarea keep their keys. ⚠️ A route PARAMETER is capped by the router (Fastify's default is 100 characters, answered as 404 "Route not found"), and a file name goes up to 255, percent-encoded: the server sets `routerOptions.maxParamLength` to `MAX_ROUTE_PARAM_LENGTH` (route-helpers.ts), the route test harness uses the same constant, and a wiring guard pins it. ⚠️ `~/` in a Docker case is refused with 400 (attachment POST and `POST …/folders`): the session is local for these routes, but `~` inside the container is the container's own home, and expanding it on the host would open a different file than the one the agent printed. Tests: `test/routes/folder-grid-routes.test.ts` (incl. the listing script on a real `sh`), `test/folder-grid-ui.test.ts`. diff --git a/src/web/public/constants.js b/src/web/public/constants.js index 3308dfd2f..60b8a8bc7 100644 --- a/src/web/public/constants.js +++ b/src/web/public/constants.js @@ -2154,9 +2154,9 @@ const RELATIVE_FILE_PATH_LINK_PATTERN = const FILE_LINK_EXTENSION_GROUP = /\\\.(\(\?:[^()]*\))\)\\b$/.exec(FILE_PATH_LINK_PATTERN.source)[1]; /** - * Every linkable file path in `text`, in order: absolute (rooted) paths first, - * then `~/` and relative ones, then folder paths — each only where it overlaps - * neither an earlier one nor a URL. + * Every linkable file path in `text`, sorted by position. Spaced candidates + * reserve their whole span first, even when unlinkable; rooted, `~/`, relative + * and folder detectors then skip reserved spans, earlier matches and URLs. * * The one entry point for both consumers (terminal link provider, response * viewer), so a path form is clickable in both or in neither. @@ -2227,7 +2227,7 @@ function findFilePathLinks(text) { * * /mnt/c/My Docs/report.docx the whole line is the path * - /mnt/c/My Docs/report.docx after a list marker (`-`, `*`, `•`, `1.`, `2)`) - * Saved to: 03 Attachments/plan.pptx after a short label (one to four words + `:`) + * Saved to: 03 Attachments/Build SIPOC Design/plan.pptx after a short label (one to four words + `:`) * * An absolute path may stand alone; a relative or `~/` one needs a marker or a * label, since a bare spaced relative candidate is ordinary prose. One trailing @@ -2248,7 +2248,7 @@ function spacedFilePathOnLine(line) { const text = String(line || ''); const prefix = /^[ \t]*((?:\d{1,3}[.)]|[-*•])[ \t]+)?((?:[A-Za-z][\w-]{0,30}(?: [\w-]{1,30}){0,3}):[ \t]+)?/.exec(text); const index = prefix[0].length; - const path = text.slice(index).replace(/\s+$/, '').replace(/[.,;:!?]$/, ''); + const path = text.slice(index).trimEnd().replace(/[.,;:!?]$/, ''); if (!path.includes(' ') || !path.includes('/') || path.length > 1000) return null; if (!path.startsWith('/') && !prefix[1] && !prefix[2]) return null; if (!PATH_TOKEN_ENDS_WITH_EXTENSION.test(path)) return null; @@ -2262,6 +2262,11 @@ function spacedFilePathOnLine(line) { const rooted = SPACED_PATH_ROOT.test(path) || path.startsWith('~/'); const segments = path.replace(/^~?\//, '').split('/'); const link = (rooted || !path.startsWith('/')) && segments.every((seg) => seg && seg !== '.' && seg !== '..'); + // A lone slash-containing final word after plain words is usually prose + // ("- Updated src/foo.ts"), not a spaced top-level directory. Let the + // ordinary detectors handle it, including explicit relative paths. + const firstSlash = words.findIndex((word) => word.includes('/')); + if (link && firstSlash === words.length - 1 && !path.startsWith('/')) return null; return { path, index, link }; } diff --git a/test/spaced-file-path-links.test.ts b/test/spaced-file-path-links.test.ts index b799a8883..8f49b89c2 100644 --- a/test/spaced-file-path-links.test.ts +++ b/test/spaced-file-path-links.test.ts @@ -91,6 +91,32 @@ describe('spaced file paths', () => { expect(paths(`• Design committed in the isolated worktree: ${rel}`)).toEqual([rel]); }); + it.each([ + ['- Updated src/foo.ts', 'src/foo.ts'], + ['1. Added a test in test/foo.test.ts', 'test/foo.test.ts'], + ['• Ran npm test -- test/foo.test.ts', 'test/foo.test.ts'], + ['Error: cannot open src/foo.ts', 'src/foo.ts'], + ['Note: see src/foo.ts', 'src/foo.ts'], + ['- Moved ../a/b.ts', '../a/b.ts'], + ['Saved to: 03 Attachments/plan.pptx', 'Attachments/plan.pptx'], + ])('defers ambiguous relative prose to ordinary detectors: %s', (line, path) => { + expect(paths(line)).toEqual([path]); + }); + + it('pins residual ambiguity when spaces occur below the first directory', () => { + // Without filesystem knowledge these still have the shape of spaced paths. + expect(paths('- Fixed bug in src/My Docs/a.md')).toEqual(['Fixed bug in src/My Docs/a.md']); + expect(paths('/home/me/proj contains a.md')).toEqual(['/home/me/proj contains a.md']); + expect(paths('Saved to: ~/My Docs/plan.md')).toEqual(['~/My Docs/plan.md']); + }); + + it.each([40000, 80000])('stays linear with %i interior spaces', (length) => { + const line = '/home/a' + ' '.repeat(length) + 'x'; + const start = performance.now(); + findFilePathLinks(line); + expect(performance.now() - start).toBeLessThan(500); + }); + it('leaves unspaced paths to the ordinary detectors', () => { expect(paths('- /mnt/c/proj/docs/report.docx')).toEqual(['/mnt/c/proj/docs/report.docx']); expect(paths('• committed as /tmp/x/docs/specs/design.md:1 (e7ae1b4).')).toEqual(['/tmp/x/docs/specs/design.md']); From 40a9528be11b294b22e076d89624d8e9cda0ac38 Mon Sep 17 00:00:00 2001 From: Aamer Akhter Date: Fri, 23 Oct 2026 07:26:15 -0400 Subject: [PATCH 04/68] fix(terminal): keep unrelated rows out of wrapped links --- CLAUDE.md | 2 ++ docs/architecture-invariants.md | 2 ++ src/web/public/constants.js | 21 ++++++++++++------ src/web/public/terminal-ui.js | 5 +++-- test/link-provider-regex.test.ts | 11 +++++----- test/terminal-cell-wrapped-paths.test.ts | 27 ++++++++++++++++++++++++ test/terminal-logical-line.test.ts | 6 ++++++ test/terminal-url-continuation.test.ts | 19 ++++++++++++++++- 8 files changed, 77 insertions(+), 16 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 02704d0fe..f528681b6 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -224,6 +224,8 @@ Codeman is a Claude Code session manager with web interface and autonomous Ralph **File-path links (terminal + chat)**: a path an agent prints is clickable on BOTH surfaces and opens the file-preview overlay. ⚠️ ONE pattern (`FILE_PATH_LINK_PATTERN` / `absoluteFilePathPattern()` in constants.js) feeds the xterm link provider AND `_linkifyFilePaths()`, a fresh instance per call (`lastIndex`). Both call `findFilePathLinks()`, which adds `~/`, relative and folder paths without overlapping a rooted match or a URL; a relative path resolves against `workingDir` (`resolveLinkedFilePath`), `~/` only server-side on the session's host (attachment route), and `terminalPathLine()` joins a path a program hard-wrapped short of the edge. ⚠️ A folder link opens the **folder grid**: `POST /api/sessions/:id/folders` guards + GRANTS the folder (`folder-listing.ts`, own registry, never the 200-capped attachment registry), files come by bare name via `…/folders/:folderId/files/:name/*` through `resolveServableAttachmentPath` plus the same extension/regular-file gate on the symlink's resolved target, so no browser request carries a path; `~/` is refused in a Docker case (the container's home is not this host's). The chat linkifier walks TEXT NODES with DOM APIs, never rebuilds sanitized markup as a string. ⚠️ An out-of-workspace path goes through the ATTACHMENT routes (`POST /api/sessions/:id/attachments` with `notify: false`), never by widening `file-content`/`file-raw` or `file-stream-manager`'s `tail -f` allowlist. ⚠️ `TEXT_ATTACHMENT_EXTENSIONS` IS `EDITABLE_EXTENSIONS` (never a second list), and widening READ must never widen RUN: `html`/`htm`/`svg` stay download-only, other text is inert `text/plain`+`nosniff`. ⚠️ The ONE place HTML renders is `/html-view//...` (`html-view-routes.ts`, `html-view-capabilities.ts`): `POST /api/sessions/:id/html-view` mints a capability for the file's own directory, every response carries `Content-Security-Policy: sandbox` WITHOUT `allow-same-origin` (opaque origin even as a top-level tab, so the page can never read Codeman or drive its API) and with NO fetch directive (open network, owner decision 2026-10-13: never restrict it or add `allow-same-origin` without the owner), and only non-dot, asset-allowlisted files under that directory are served (GET/HEAD only; capabilities revoked with the web-tab ones on logout). That tree is served recursively, so minting is refused for a hidden directory or a broad root (`isBroadHtmlViewRoot`); the preview renders by default and the **Page** pill (or a refused mint) falls through to the source view and its Edit button. Never serve HTML same-origin anywhere else. Tests: `test/routes/html-view-routes.test.ts`. Media extensions are single-sourced in `attachment-registry.ts`. ⚠️ **XLSX previews parse in the BROWSER**, never on the server; the admission limits that keep a hostile workbook from freezing the worker live in `src/web/CLAUDE.md` (Spreadsheet preview). → [architecture-invariants#file-path-links-terminal--response-viewer](docs/architecture-invariants.md#file-path-links-terminal--response-viewer) +The terminal provider scans `terminalLinkLine()`, which extends `terminalPathLine()` for URL continuations in indented paragraphs and table cells. List markers, bare numbers and version tokens never continue a URL. `terminalCellWrappedPaths()` follows each table cell independently, gated by `pathContinuation()` and the per-cell row-length rule; accepted paths displace other links on their cells. Full-width divider rows stop `terminalLogicalLine()` stitching for both links and touch selection. OSC 8 links confirm the real target URI before the shared opener runs. + **Filesystem path picker** (Link Existing "Browse" + the mobile keyboard's `📁 Path` key): lazy one-directory browsing via `GET /api/filesystem/browse`, with `GET /api/filesystem/preview` for the tapped file. Inserts the path **without** Enter, so the prompt is never submitted; the sibling `⌫ All` key clears only the unsent prompt and must never send the agent's `/clear`. ⚠️ This is a **second file-serving surface and inherits neither the attachment confinement nor its ownership scoping** — it allowlists Home, `CASES_DIR`, `/mnt/d` and `CODEMAN_FILE_PICKER_ROOTS`, blocks sensitive trees, and rejects symlink escapes **after** `realpath`. ⚠️ The optional `sessionId` is an ownership boundary that must be `canAccessOwned`-checked by hand (it does not go through `findSessionOrFail`), and in multi-user mode a non-admin gets only their own `userSpacePath` as a root: per-user spaces live INSIDE `homedir()`, so a `Home` root exposes every other user's workspace. Previews go through the same global conversion limiter, and Markdown/TXT/JSON are served as inert `text/plain`. → [architecture-invariants#filesystem-path-picker](docs/architecture-invariants.md#filesystem-path-picker) **File Viewer edit mode** (issue #212): the file-preview overlay edits workspace text files in place — `GET .../file-content?edit=1` + `PUT /api/sessions/:id/file-content`, policy in `src/config/file-editing.ts`. This is a **third file surface and the only one that WRITES**: read-path confinement (realpath + workspace + ownership) plus sensitive/blocked/`.git` denies and an extension **allowlist**; writes are `wx`-temp + rename (no `O_CREAT` anywhere = edit-in-place is structural); optimistic concurrency via sha256 `baseHash` → 409. ⚠️ `edit=1` never truncates and the client must never save a plain-preview buffer (the 500-line truncation would silently delete the rest). ⚠️ CRLF/UTF-8 guards: EOL re-applied server-side, non-UTF-8 refused via round-trip compare. → [architecture-invariants#file-viewer-edit-mode](docs/architecture-invariants.md#file-viewer-edit-mode), `docs/file-viewer-edit-plan.md` diff --git a/docs/architecture-invariants.md b/docs/architecture-invariants.md index f446f4a1a..6dc61ed7d 100644 --- a/docs/architecture-invariants.md +++ b/docs/architecture-invariants.md @@ -409,6 +409,8 @@ A file path an agent prints is a link on both surfaces it can appear on, and cli **Path forms beyond the rooted one.** `findFilePathLinks(text)` (constants.js) is the single entry point both consumers call; it returns the rooted matches first, then `~/` and relative paths (`RELATIVE_FILE_PATH_LINK_PATTERN`, same extension list, pinned equal by `test/link-provider-regex.test.ts`), then folder paths, each only where it overlaps neither an earlier match nor an `http(s)` URL. A relative match must start at a boundary (line start, whitespace, quote, bracket, `=`, `,`, `>`), so it can never begin inside a word, an absolute path or a URL; brackets, `=` and `,` end it (`(shot.png)`, `OUT=shots/a.png`). `resolveLinkedFilePath()` resolves a relative path against the session's `workingDir` (`.`/`..` collapsed, so `../x.png` lands outside and routes like any external path) and passes absolute and `~/` paths through. ⚠️ `~` is the home of the SESSION'S host, which only the server knows: `~/` paths always take the attachment route, where `registerExternalAttachment` expands them (`homedir()` locally, `$HOME` inside the remote probe script), and the guard runs on the expanded, realpath-resolved path as before. ⚠️ Programs hard-wrap their own output short of the terminal edge (Claude Code breaks tool output at 59-118 columns), so xterm's `isWrapped` never fires. `terminalPathLine()` (link provider only; touch selection keeps `terminalLogicalLine`) joins the next row when the previous row ends in a path token with a `/` and no extension and the join reaches an extension; a row that starts a new path is never joined: rooted or `~/`, `./`/`../`, or a relative one that opens on the same top directory the broken token did (the next entry of `git ls-files`/`find`/`ls -R`: `src/components` + `src/index.ts` are two paths, not `src/componentssrc/index.ts`), while a wrapped path continues INSIDE a name, so `…/captures/2` + `026-10-10/…` still joins; a 3+ row chain is kept only if a later row completes the path, and the broken row must be at least as long as the continuing one (keeps `cd /mnt/foo` + a longer next line apart). Tests: `test/file-path-link-forms.test.ts`. +The terminal provider scans `terminalLinkLine()`, which extends `terminalPathLine()` for URL continuations in indented paragraphs and table cells. List markers, bare numbers and version tokens never continue a URL. `terminalCellWrappedPaths()` follows each table cell independently, gated by `pathContinuation()` and the per-cell row-length rule; accepted paths displace other links on their cells. Full-width divider rows stop `terminalLogicalLine()` stitching for both links and touch selection. OSC 8 links confirm the real target URI before the shared opener runs. + **Folder grid: a folder is granted, then served by bare file name.** A folder path (rooted or `~/` whose last segment has no extension, relative only with a trailing `/`; `FOLDER_PATH_LINK_PATTERN` + `folderLinkPath()`) opens `openFolderGrid()` (panels-ui.js), as does the preview's "Show folder" button. `POST /api/sessions/:id/folders {path}` (`src/folder-listing.ts`) resolves the folder (locally a bounded `probePathKind(…, { pastCap: true })` first, then async `fs.realpath`; remotely one ssh round trip via `remoteListFolder`, through `runWithRemoteSshLimit`), runs the attachment guard on it (blocklist, `confineToWorkspace`, and in multi-user mode `folderAllowedForUser`: a non-admin only inside their own `userSpacePath` or the session's `workingDir`, as the filesystem path picker), locally BEFORE reading it, lists non-hidden subfolders and files of a supported attachment type (each file also blocklist-checked, newest first, capped at `MAX_FOLDER_ENTRIES`), and GRANTS it an id. Files are then fetched as `GET …/folders/:folderId/files/:name/(raw|preview|thumbnail)`: `:name` must be a bare file name of a supported type (no `/`, `\`, NUL, no leading `.`, since the listing hides those), the local file is probed bounded (`unknown` → 503, never 404), and the joined path goes through the SAME `resolveServableAttachmentPath` as an attachment by id (symlink re-resolution, remote re-probe, guard), then the same gate registration applies to what a symlink RESOLVES to (a supported extension on the resolved path and a regular file: locally `fs.stat`, remotely the probe kind; else 400, so `shot.png -> ../secret.bin` is not served and a FIFO or device behind a supported name never reaches `open`), then `folderAllowedForUser` once more on the resolved file, before `serveServableRaw` (the attachment raw route's body, shared) streams it. So browser requests still never carry a path. ⚠️ Grants live in their own registry (`folderGrants`, 32 per session, cleared with the session's attachments), NOT the attachment registry: that one is capped at 200 per session, and paging a folder of 300 renders would evict the session's real attachments. ⚠️ A local folder is never touched unbounded (#516 rule): a folder on a network mount that stopped answering answers 503 with `describeUnknownPath()`, not 404 and not a frozen server. ⚠️ The remote listing script tells "missing" (`E|n`, 404) from "is a file" (`E|f`, 400 `Not a folder`), because the client falls back to the file preview on `Not a folder`; a remote extensionless file otherwise answered "Folder not found". ⚠️ `.folder-grid-overlay` sits at `z-index: 5090`, one step BELOW the preview (5100) and above the response viewer (5000): a tile's preview opens on top and closing it lands back on the grid. ⚠️ ←/→/Esc are handled in a document CAPTURE listener with `stopPropagation`, because the terminal keeps keyboard focus under the overlays and an arrow that reached xterm would be typed into the session; inputs other than xterm's helper textarea keep their keys. ⚠️ A route PARAMETER is capped by the router (Fastify's default is 100 characters, answered as 404 "Route not found"), and a file name goes up to 255, percent-encoded: the server sets `routerOptions.maxParamLength` to `MAX_ROUTE_PARAM_LENGTH` (route-helpers.ts), the route test harness uses the same constant, and a wiring guard pins it. ⚠️ `~/` in a Docker case is refused with 400 (attachment POST and `POST …/folders`): the session is local for these routes, but `~` inside the container is the container's own home, and expanding it on the host would open a different file than the one the agent printed. Tests: `test/routes/folder-grid-routes.test.ts` (incl. the listing script on a real `sh`), `test/folder-grid-ui.test.ts`. **Text reuses the edit-mode allowlist; markup stays download-only.** `TEXT_ATTACHMENT_EXTENSIONS` IS `EDITABLE_EXTENSIONS` (`config/file-editing.ts`) rather than a second curated list that would drift from it: if the viewer would open a file for editing inside the workspace, the same file outside it can be read. The justification for widening is that the agent in the session can already `cat` any of these and the picker already previews them, so the suffix was never the confidentiality gate; the path guard is (sensitive-file blocklist, `/root` and `/etc` trees, realpath first). ⚠️ Two consequences had to be handled at the same time: `~/.codeman*/state.json` joined `isSensitivePath` (it persists `SessionState.envOverrides`, and the env allowlist admits key-shaped names like `GEMINI_API_KEY`, so it can hold a live credential), and `html`/`htm` joined `svg` in `serveRawFile`'s **download-only** branch so that widening what can be READ never widens what can RUN on our own origin. Text with no dedicated MIME entry goes out as inert `text/plain; charset=utf-8` + `nosniff`, matching the picker. ⚠️ **The one place HTML renders is `/html-view//...`** (`html-view-routes.ts`, `html-view-capabilities.ts`), never these routes: `POST /api/sessions/:id/html-view` mints a capability for the HTML file's own directory, and every response carries `Content-Security-Policy: sandbox` WITHOUT `allow-same-origin`, so the page is opaque-origin even as a top-level tab and cannot read Codeman or drive its API. ⚠️ **Network access is open, by the owner's decision (2026-10-13, #547):** `HTML_VIEW_CSP` is the `sandbox` directive alone, which REPLACES Codeman's own CSP, with no fetch directive, so a page loads scripts, styles, fonts, images and media from anywhere and may fetch, XHR, open WebSockets, send beacons and post forms anywhere, like a file opened from disk. It can read its own folder through the capability and nothing else on this server; it cannot use Codeman's origin, cookies or API, or reach other folders. Never add a fetch directive or `allow-same-origin` without the owner; the exact header is pinned in `test/routes/html-view-routes.test.ts`. The directory is served recursively to the page's scripts (non-dot, asset-allowlisted, realpath-confined, blocklist applied, GET/HEAD only; the dot and extension rules run on the requested name AND on the symlink-resolved target, or a `cfg.json -> .git/config` link would serve the git config), which is why minting is refused for an HTML file in a hidden directory (for a file inside the workspace, a hidden directory below the workspace root, so a workspace that itself lives under one still renders) or directly in a broad root (`/`, home, `/tmp`, `/var/tmp` and `$TMPDIR` (named literally, since macOS sets `TMPDIR` in every terminal), the cases and user-space roots, a drive's mount point such as WSL's `/mnt/d`) or in any directory above one of them (`/home`, `/Users`, macOS's `/private`; `isBroadHtmlViewRoot`). A capability minted for a folder outside the workspace records that, and the serving route refuses it per request once `attachmentConfineToWorkspace` is on, the same re-check the attachment serve path makes. The preview renders it by default; the **Page** pill (`htmlRendered`, per-device like `mdRendered`) and any refused mint fall through to the source view with its Edit button. Tests: `test/routes/html-view-routes.test.ts`, the html-view block in `test/webview-auth-exemption.test.ts`, `test/webview-capability-revocation.test.ts`. The by-id text preview is bounded like the workspace one: a `Range` request for the first 512KB (a real partial read, not a discarded 50MB download) plus a 500-line cap, with the footer saying so. diff --git a/src/web/public/constants.js b/src/web/public/constants.js index 6e8d4bff6..b89bfdb7b 100644 --- a/src/web/public/constants.js +++ b/src/web/public/constants.js @@ -2568,7 +2568,8 @@ function findTerminalUrls(text) { * - the next row is indented no further right than the column the URL * started in (a table cell or list item continuation, not unrelated text); * - that row's first token looks like URL material (contains one of - * `% & = / ? . - _` or a digit) and is not itself a new `scheme://`. + * `% & = / ? . - _`), is not a bare number/version or list marker, + * and is not itself a new `scheme://`. * The last rule is what stops prose such as "…/files/" + "then run it" from * being glued into one link. * @@ -2609,7 +2610,7 @@ function terminalUrlContinuationExtent(buffer, row, cols, maxRows) { const nextText = next.translateToString(true) || ''; const indent = nextText.length - nextText.replace(/^\s+/, '').length; const token = nextText.slice(indent).split(/\s/, 1)[0]; - if (!token || indent > urlStartCol) break; + if (!token || indent > urlStartCol || /^(?:\d+[.)]|[-*•])(?:\s|$)/.test(nextText.slice(indent))) break; // When other table columns follow the URL, the next row must align with // the start of its cell. A new numbered row must not become URL text. if ( @@ -2621,7 +2622,7 @@ function terminalUrlContinuationExtent(buffer, row, cols, maxRows) { ) break; if (/^[a-z][a-z0-9+.-]*:\/\//i.test(token) || !/^[A-Za-z0-9%_~]/.test(token)) break; - if (!/[%&=/?.\-_0-9]/.test(token)) break; + if (!/[%&=/?.\-_]/.test(token) || /^v?\d+(?:\.\d+)*(?:[-+][\w.-]+)?[.,;:!?)]?$/i.test(token)) break; const nextLogical = terminalLogicalLine(buffer, endRow + 1, cols, bound); if (!nextLogical) break; if (trailingColumns) firstRowUrlEnd = last.rawEnd; @@ -2716,9 +2717,9 @@ function terminalLinkLine(buffer, row, cols, maxRows) { * edge is where its text starts after the previous ≥2-space column gap, and the * continuation is the token starting at exactly that column on the next row. * A fragment qualifies only when it is the last thing in its cell on that row - * (row end or a ≥2-space gap follows), and the join is accepted only when the - * whole joined string matches the path pattern from its first character, which - * is what keeps unrelated cell text from being glued on. + * (row end or a ≥2-space gap follows). Every continuation must pass + * pathContinuation and its cell must be no wider than the preceding cell's + * text, so independent listing rows and longer prose never displace links. * * Pure: reads rows only. Cells are taken one per string character (xterm rows * with wide glyphs would shift columns; such rows simply fail the alignment). @@ -2768,6 +2769,7 @@ function terminalCellWrappedPaths(buffer, row, maxRows) { while ((e = edge.exec(upToPath)) !== null) cellCol = e.index + e[0].length; let joined = text.slice(start, end); + let previousCellLength = end - cellCol; const segments = [{ row: startRow, startCol: start, endCol: end }]; for (let r = startRow + 1; r < startRow + bound; r++) { const line = buffer.getLine(r); @@ -2776,7 +2778,11 @@ function terminalCellWrappedPaths(buffer, row, maxRows) { if (!next || !isPathChar(next[cellCol]) || (cellCol > 0 && next[cellCol - 1] !== ' ')) break; let tokenEnd = cellCol; while (tokenEnd < next.length && isPathChar(next[tokenEnd])) tokenEnd++; - const candidate = joined + next.slice(cellCol, tokenEnd); + const token = next.slice(cellCol, tokenEnd); + // Include prose after the token in this cell, but not another column. + const cellText = next.slice(cellCol).split(/ {2,}|[│┃║|]/, 1)[0].trimEnd(); + if (cellText.length > previousCellLength || !pathContinuation(joined, token)) break; + const candidate = joined + token; const match = absoluteFilePathPattern().exec(candidate); if (match && match.index === 0 && match[0].length > joined.length) { segments.push({ row: r, startCol: cellCol, endCol: cellCol + match[0].length - joined.length }); @@ -2785,6 +2791,7 @@ function terminalCellWrappedPaths(buffer, row, maxRows) { } if (!endsCell(next, tokenEnd)) break; joined = candidate; + previousCellLength = cellText.length; segments.push({ row: r, startCol: cellCol, endCol: tokenEnd }); } } diff --git a/src/web/public/terminal-ui.js b/src/web/public/terminal-ui.js index e4794be65..6d6ac4d83 100644 --- a/src/web/public/terminal-ui.js +++ b/src/web/public/terminal-ui.js @@ -2408,13 +2408,14 @@ Object.assign(CodemanApp.prototype, { /** * xterm's handler for OSC 8 hyperlinks (a program's own `ESC ]8;;url` links), * which take precedence over the text link provider. Routed through the same - * opener, and only for web URLs: any other scheme a program sends is ignored. + * opener after confirming the real target (the label may hide it), and only + * for web URLs: any other scheme a program sends is ignored. */ _terminalOsc8LinkHandler() { return { activate: (_event, text) => { if (!/^https?:\/\/[^\s\x00-\x1f]+$/i.test(text)) return; - this._openTerminalUrl(text); + if (window.confirm(`Open this link?\n\n${text}`)) this._openTerminalUrl(text); }, }; }, diff --git a/test/link-provider-regex.test.ts b/test/link-provider-regex.test.ts index 7b896097b..7eaeb1f86 100644 --- a/test/link-provider-regex.test.ts +++ b/test/link-provider-regex.test.ts @@ -17,6 +17,7 @@ import { describe, it, expect } from 'vitest'; import { readFileSync } from 'fs'; import { join } from 'path'; +import vm from 'node:vm'; const publicFile = (name: string) => readFileSync(join(__dirname, '..', 'src', 'web', 'public', name), 'utf-8'); @@ -99,8 +100,9 @@ describe('terminal link-provider regexes (shipped source)', () => { // Excluding `&` truncated every real query string: a WordPress edit link // resolved to `?post=1479` and opened the wrong page, and Claude Code's OAuth // login URL (many `&` params) was not clickable at all. - const url = shippedPattern('urlPattern'); - const strip = (u: string) => u.replace(/[.,;:!?)&]+$/, ''); + const context = vm.createContext({ console, window: undefined }); + vm.runInContext(`${CONSTANTS_SOURCE}\nglobalThis.findUrls = findTerminalUrls;`, context); + const findUrls = context.findUrls as (text: string) => Array<{ url: string }>; const cases: Array<[string, string]> = [ [ 'updated in place: https://bio-hacking.blog/wp-admin/post.php?post=1479&action=edit', @@ -115,10 +117,7 @@ describe('terminal link-provider regexes (shipped source)', () => { ['trailing https://x.com/a?b=1& next', 'https://x.com/a?b=1'], ]; for (const [line, want] of cases) { - url.lastIndex = 0; - const m = url.exec(line); - expect(m, line).not.toBeNull(); - expect(strip(m![0]), line).toBe(want); + expect(findUrls(line).map((match) => match.url), line).toEqual([want]); } }); diff --git a/test/terminal-cell-wrapped-paths.test.ts b/test/terminal-cell-wrapped-paths.test.ts index 6ed0da6a2..7b25672de 100644 --- a/test/terminal-cell-wrapped-paths.test.ts +++ b/test/terminal-cell-wrapped-paths.test.ts @@ -57,6 +57,14 @@ const TABLE: Row[] = [ { text: ROW2 }, { text: '' }, ]; +const LISTINGS = [ + ['/tmp/x', '/tmp/x/dir', '/tmp/x/dir/a.png'], + [' ⎿ /home/u/proj/src', ' /home/u/proj/src/a.ts'], + ['4.0K /tmp/foo/bar/', '8.0K /tmp/foo/baz.png'], + ['/home/u/proj/docs:', 'guide.md notes.md'], + ['Created /tmp/out', 'report.md written next to it'], +]; + const BEFORE = '/tmp/demo-check/docs/review/BLDMESH-243/before-overview.png'; const AFTER = '/tmp/demo-check/docs/review/BLDMESH-243/after-overview.png'; @@ -70,6 +78,11 @@ describe('terminalCellWrappedPaths', () => { } }); + it.each(LISTINGS.map((lines) => [lines]))('does not join independent listing rows: %j', (lines) => { + const rows = lines.map((text) => ({ text })); + for (let row = 0; row < rows.length; row++) expect(find(fakeBuffer(rows, COLS), row)).toEqual([]); + }); + it('places the segments on the path cells only', () => { const hits = find(fakeBuffer(TABLE, COLS), 1); const before = hits.find((h) => h.path === BEFORE)!; @@ -188,6 +201,20 @@ describe('terminal link provider with cell-wrapped paths', () => { return { provide, app }; } + it.each(LISTINGS.map((lines) => [lines]))('preserves ordinary listing links: %j', (lines) => { + const { provide } = harness(lines.map((text) => ({ text }))); + const expected = [ + ['/tmp/x', '/tmp/x/dir', '/tmp/x/dir/a.png'], + ['/home/u/proj/src', '/home/u/proj/src/a.ts'], + ['/tmp/foo/bar/', '/tmp/foo/baz.png'], + ['/home/u/proj/docs', 'guide.md'], + ['/tmp/out', 'report.md'], + ][LISTINGS.indexOf(lines)]; + for (let row = 0; row < lines.length; row++) { + expect(provide(row + 1).map((link) => link.text)).toContain(expected[row]); + } + }); + it('makes both screenshot paths clickable from either row and opens the whole path', () => { const { provide, app } = harness(TABLE); for (const line of [2, 3]) { diff --git a/test/terminal-logical-line.test.ts b/test/terminal-logical-line.test.ts index 6832216d9..9cb3ca9ce 100644 --- a/test/terminal-logical-line.test.ts +++ b/test/terminal-logical-line.test.ts @@ -64,6 +64,12 @@ describe('terminalLogicalLine', () => { const ROW1 = ' ackage/thing'; const HARD = [{ text: ROW0 }, { text: ROW1 }]; + it('keeps full-width dividers out of link and touch-selection text', () => { + const rows = buffer([{ text: '─'.repeat(COLS) }, { text: 'https://example.com/docs' }]); + expect(terminalLogicalLine(rows, 0, COLS)).toMatchObject({ startRow: 0, endRow: 0, text: '─'.repeat(COLS) }); + expect(terminalLogicalLine(rows, 1, COLS)).toMatchObject({ startRow: 1, endRow: 1, text: 'https://example.com/docs' }); + }); + it('stitches a hard wrap and drops the indent the continuation carries', () => { const line = terminalLogicalLine(buffer(HARD), 0, COLS)!; diff --git a/test/terminal-url-continuation.test.ts b/test/terminal-url-continuation.test.ts index 9616ac532..6d97ac2f2 100644 --- a/test/terminal-url-continuation.test.ts +++ b/test/terminal-url-continuation.test.ts @@ -168,6 +168,16 @@ describe('terminalUrlContinuationExtent (wrap joining)', () => { expect(terminalUrlContinuationExtent(buffer, 4, COLS, 12)).toBeNull(); }); + it.each([ + ['1. https://example.com/one/', '2. https://example.com/two/'], + ['Docs: https://docs.example.com/guide/', '1. Install the package'], + ['Fetching https://registry.example.com/pkg/', 'v1.2.3 is the latest'], + ['GET http://localhost:3000/api/', '200 OK in 12ms'], + ])('keeps list, version and status rows separate after %s', (first, next) => { + const buffer = fakeBuffer([{ text: first }, { text: next }], 100); + for (const row of [0, 1]) expect(terminalUrlContinuationExtent(buffer, row, 100, 12)).toBeNull(); + }); + it('does not glue a following prose line onto a URL that ends a line', () => { const buffer = fakeBuffer( [{ text: 'Download from https://example.com/files/' }, { text: 'then run the installer' }], @@ -204,11 +214,12 @@ describe('terminalUrlContinuationExtent (wrap joining)', () => { describe('native OSC 8 terminal URLs', () => { it('opens complete HTTP links through the terminal opener and rejects unsafe schemes', () => { const open = vi.fn(); + const confirm = vi.fn(() => true); const context = vm.createContext({ console, CodemanApp: class CodemanApp {}, document: { addEventListener: vi.fn() }, - window: { addEventListener: vi.fn(), removeEventListener: vi.fn() }, + window: { addEventListener: vi.fn(), removeEventListener: vi.fn(), confirm }, }); vm.runInContext(`${CONSTANTS}\n${TERMINAL_UI}\nglobalThis.__proto = CodemanApp.prototype;`, context); const proto = ( @@ -220,10 +231,16 @@ describe('native OSC 8 terminal URLs', () => { const url = 'https://docs.example.com/Doc.aspx?file=Q3%20Plan&action=default&mobileredirect=true'; handler.activate(null, url); expect(open).toHaveBeenCalledExactlyOnceWith(url); + expect(confirm).toHaveBeenCalledExactlyOnceWith(`Open this link?\n\n${url}`); + confirm.mockReturnValue(false); + handler.activate(null, 'http://localhost:3000/private'); + expect(confirm).toHaveBeenLastCalledWith('Open this link?\n\nhttp://localhost:3000/private'); + expect(open).toHaveBeenCalledTimes(1); for (const unsafe of ['javascript:alert(1)', 'data:text/html,hello', 'file:///etc/passwd']) { handler.activate(null, unsafe); } expect(open).toHaveBeenCalledTimes(1); + expect(confirm).toHaveBeenCalledTimes(2); }); }); From 74a107cf4fbb10e069b69a2cc2ba49fc0a12017f Mon Sep 17 00:00:00 2001 From: Aamer Akhter Date: Fri, 23 Oct 2026 09:01:44 -0400 Subject: [PATCH 05/68] fix(terminal): require path labels and preserve markdown context --- CLAUDE.md | 4 +- docs/architecture-invariants.md | 2 +- src/web/public/app.js | 14 ++++++- src/web/public/constants.js | 26 ++++++------ test/response-viewer-file-links.test.ts | 41 ++++++++++++++++++ test/spaced-file-path-links.test.ts | 55 ++++++++++++++++++++----- 6 files changed, 115 insertions(+), 27 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index d341d772a..b6ce6117b 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -222,9 +222,7 @@ Codeman is a Claude Code session manager with web interface and autonomous Ralph **Prompt uploads** (`POST /api/sessions/:id/paste-image`: clipboard paste, drop, the Compose Attach key and the phone toolbar's Attach button; client wiring in `image-input.js` and `keyboard-accessory.js`): a pick is written to `/.codeman-uploads/` and its path lands on the prompt as unsent text. The folder is IN the workspace because that is the only path identical for a local and a container agent, FLAT because `/.codeman/` would BE the data dir when the workspace is the home directory, and self-ignoring through a `.gitignore` of `*` written ONCE with `wx` (a file already there is the user's and is never overwritten; the user's own ignore file is never touched). ⚠️ The directory names live ONLY in `paste-image-gc.ts` (`UPLOAD_DIR_NAMES`): the pre-move `.claude-images/` receives nothing but stays readable for one release, so the GC sweep and the delete cleanup (through `uploadDirs()`) and the image watcher's ignore filter (reading the names) cover BOTH; retire the legacy entry there, never by hand elsewhere. ⚠️ `uploadDirs()` is async and probes the workspace BOUNDED first (`probePathKind()`, the #516 rule: an `unknown` path is skipped, never touched; the sweep keeps the stall cap and the delete passes `pastCap`), then returns only REAL directories (lstat, so a planted symlink is never one), none that is or contains `getDataDir()` (one strictly inside it only holds uploads and is listed), and none for a remote session, since both consumers delete; the check is made at list time, and the same-user race after it is accepted. Images are buffered in memory under `MAX_PASTE_IMAGE_BYTES` (50MB) after a client-side canvas re-encode; a DOCUMENT (pdf, docx, pptx, xlsx, zip and the text family txt/md/csv/tsv/json/log/yaml/yml/xml; the curated `ALLOWED_DOCUMENT_EXTS`, never `SUPPORTED_ATTACHMENT_EXTENSIONS` or `EDITABLE_EXTENSIONS`, which carry sh/py/js/html) is admitted by its name's extension alone, buffered like an image, judged on its bytes by the same fail-closed `imageMagicMatchesExt` (`%PDF-`, `PK\x03\x04` for the OOXML trio and zip, UTF-8 without a NUL byte for text: a SIGNATURE or ENCODING check, never a structure or content check, so generic zip bytes pass as `.docx` and a script renamed `.txt` is text; the sh/py/js/html exclusion is a SCOPE choice by extension, keeping the server from storing what it would serve or run, not a content filter: the agent can already write any of those) and saved as `paste---` with a sanitized stem (`[A-Za-z0-9._-]`, 40 chars, a wholly non-ASCII name leaves no stem; the `paste-` prefix is what the GC matches). A VIDEO is never buffered: `?kind=video` streams it to disk under `MAX_PASTE_VIDEO_BYTES` (1GB, env `CODEMAN_MAX_PASTE_VIDEO_BYTES`), judges the container on the first 12 bytes as they arrive (ISO BMFF / QuickTime atom, EBML for webm, Ogg for ogv; a mislabeled body is drained unwritten from there, never parked on disk first) and unlinks on any failure, a dead transfer included. ⚠️ The flag is the ONLY way onto that path and admits video types alone (the registry's `VIDEO_ATTACHMENT_EXTENSIONS`, single-sourced: mp4/webm/mov/m4v/ogv, so an uploaded video is one the file viewer can also play; 3gp/mkv are out, since a phone's camera roll produces neither and neither previews), so an image can never ride the larger cap through memory; without it the route is byte-identical to the image-only version. Busboy takes its size limit before the part is seen, which is why the choice rides the URL and not the file. ⚠️ A video draws on its OWN per-(IP, session) rate bucket (`consumeVideoToken`, 3/min) beside the image bucket (`consumePasteToken`, 30/min): at 1GB each, 30/min would be 30GB of disk a minute, so the lower rate bounds it to 3GB; the image path keeps the 30/min bucket and is unchanged without the flag. A remote (SSH) session is refused (400) before any disk touch: its `workingDir` is the remote path, so the file would land on THIS host where the agent cannot read it (Docker is fine, the workspace is bind-mounted at the same absolute path). ⚠️ A live session's workspace can still sit on a network mount that stopped answering, so the route probes it BOUNDED (`probePathKind(session.workingDir, { pastCap: true })`, the #516 rule) before the first disk touch and refuses an `unknown` mount through `describeUnknownPath()` (OPERATION_FAILED), the way session create does, so a mount already unreachable cannot freeze a libuv worker on every upload (a mount that dies after the probe is the same residual race create accepts). ⚠️ Dispatch mirrors the client: an `image/*` MIME takes the IMAGE path whatever the name says (a GIF named `animation.txt` stays a GIF), a document extension takes the document branch, and anything else takes the image path exactly as before, where the HEIC sniff still recovers a HEIC under a generic name; a foreign extension with no image MIME gets its `Unsupported file type` 400 only AFTER that sniff fails, never before, so nothing the image path ever accepted by its bytes is refused. A text mismatch is refused as not UTF-8 text (UTF-16 when the file opens with a UTF-16 BOM, as Excel's "Unicode Text" and Notepad's "Unicode" do), telling the user to save it as UTF-8, and its log line names the failure (`utf16-bom`/`nul-byte`/`not-utf8`) and never prints the bytes, since the head of a text file is user content. ⚠️ The client classifies a pick through ONE helper, `_promptAttachKind()` (MIME type first, then the name's extension, because some Android file managers hand over a File with none; returns `image`, `video` or `document`); every pick and paste filter (Attach, drop, the tile grid's drop in `tile-grid.js`, the Compose and paste-dialog pickers, the clipboard handlers), the flag and the re-encode gate read it, never `file.type` directly. Those surfaces ADMIT through `_admitPromptAttachments()` (image-input.js), which calls it and toasts what a pick skipped (how many files of a mixed pick, `Unsupported file type` when none pass, nothing for a text paste); a new surface admits through that helper too, never by filtering on `_promptAttachKind()` itself, or it loses the toasts. ⚠️ A pick that lands while the tile grid owns the terminal goes to the PTY, never the main terminal's overlay: that terminal is parked and hidden then, and a tile's Enter cannot flush it (`_tilesOwnTerminal()` in the `_attachFromToolbar` guard). Tests: `test/toolbar-attach.test.ts`, `test/mobile-prompt-composer.test.ts`, `test/paste-image-gc.test.ts`, `test/paste-image-dir-shared.test.ts`, `test/upload-document-list-parity.test.ts` (the browser's copies of `ALLOWED_DOCUMENT_EXTS`: `_promptAttachKind()`, the three pickers' `accept=` and the two file-path link patterns), the paste-image block of `test/routes/session-routes.test.ts`. → [architecture-invariants#buffers-uploads-and-terminal-history](docs/architecture-invariants.md#buffers-uploads-and-terminal-history) -**File-path links (terminal + chat)**: a path an agent prints is clickable on BOTH surfaces and opens the file-preview overlay. ⚠️ ONE pattern (`FILE_PATH_LINK_PATTERN` / `absoluteFilePathPattern()` in constants.js) feeds the xterm link provider AND `_linkifyFilePaths()`, a fresh instance per call (`lastIndex`). Both call `findFilePathLinks()`, which reserves spaced-path spans first (even unlinkable ones), then adds rooted, `~/`, relative and folder paths without overlapping a reservation, earlier match or URL; a relative path resolves against `workingDir` (`resolveLinkedFilePath`), `~/` only server-side on the session's host (attachment route), and `terminalPathLine()` joins a path a program hard-wrapped short of the edge. ⚠️ A folder link opens the **folder grid**: `POST /api/sessions/:id/folders` guards + GRANTS the folder (`folder-listing.ts`, own registry, never the 200-capped attachment registry), files come by bare name via `…/folders/:folderId/files/:name/*` through `resolveServableAttachmentPath` plus the same extension/regular-file gate on the symlink's resolved target, so no browser request carries a path; `~/` is refused in a Docker case (the container's home is not this host's). The chat linkifier walks TEXT NODES with DOM APIs, never rebuilds sanitized markup as a string. ⚠️ An out-of-workspace path goes through the ATTACHMENT routes (`POST /api/sessions/:id/attachments` with `notify: false`), never by widening `file-content`/`file-raw` or `file-stream-manager`'s `tail -f` allowlist. ⚠️ `TEXT_ATTACHMENT_EXTENSIONS` IS `EDITABLE_EXTENSIONS` (never a second list), and widening READ must never widen RUN: `html`/`htm`/`svg` stay download-only, other text is inert `text/plain`+`nosniff`. ⚠️ The ONE place HTML renders is `/html-view//...` (`html-view-routes.ts`, `html-view-capabilities.ts`): `POST /api/sessions/:id/html-view` mints a capability for the file's own directory, every response carries `Content-Security-Policy: sandbox` WITHOUT `allow-same-origin` (opaque origin even as a top-level tab, so the page can never read Codeman or drive its API) and with NO fetch directive (open network, owner decision 2026-10-13: never restrict it or add `allow-same-origin` without the owner), and only non-dot, asset-allowlisted files under that directory are served (GET/HEAD only; capabilities revoked with the web-tab ones on logout). That tree is served recursively, so minting is refused for a hidden directory or a broad root (`isBroadHtmlViewRoot`); the preview renders by default and the **Page** pill (or a refused mint) falls through to the source view and its Edit button. Never serve HTML same-origin anywhere else. Tests: `test/routes/html-view-routes.test.ts`. Media extensions are single-sourced in `attachment-registry.ts`. ⚠️ **XLSX previews parse in the BROWSER**, never on the server; the admission limits that keep a hostile workbook from freezing the worker live in `src/web/CLAUDE.md` (Spreadsheet preview). → [architecture-invariants#file-path-links-terminal--response-viewer](docs/architecture-invariants.md#file-path-links-terminal--response-viewer) - -Spaced paths are considered first by `findFilePathLinks()`: accepted candidates reserve their entire span even when unlinkable, so later rooted, relative and folder detectors cannot link fragments. Relative candidates whose first slash-containing word is their last word defer to ordinary detection to exclude list/label prose. This deliberately gives up spaces only in the top directory (`Saved to: 03 Attachments/plan.pptx`); prose shaped like deeper spaced paths (`- Fixed bug in src/My Docs/a.md`, `/home/me/proj contains a.md`) remains ambiguous. Trailing whitespace uses linear `trimEnd()` before the length cap. +**File-path links (terminal + chat)**: a path an agent prints is clickable on BOTH surfaces and opens the file-preview overlay. ⚠️ ONE pattern (`FILE_PATH_LINK_PATTERN` / `absoluteFilePathPattern()` in constants.js) feeds the xterm link provider AND `_linkifyFilePaths()`, a fresh instance per call (`lastIndex`). Both call `findFilePathLinks()`, which reserves spaced-path spans first (even unlinkable ones; relative/`~/` spaced paths require an explicit path label), then adds rooted, `~/`, relative and folder paths without overlapping a reservation, earlier match or URL; a relative path resolves against `workingDir` (`resolveLinkedFilePath`), `~/` only server-side on the session's host (attachment route), and `terminalPathLine()` joins a path a program hard-wrapped short of the edge. ⚠️ A folder link opens the **folder grid**: `POST /api/sessions/:id/folders` guards + GRANTS the folder (`folder-listing.ts`, own registry, never the 200-capped attachment registry), files come by bare name via `…/folders/:folderId/files/:name/*` through `resolveServableAttachmentPath` plus the same extension/regular-file gate on the symlink's resolved target, so no browser request carries a path; `~/` is refused in a Docker case (the container's home is not this host's). The chat linkifier walks TEXT NODES with DOM APIs, never rebuilds sanitized markup as a string. ⚠️ An out-of-workspace path goes through the ATTACHMENT routes (`POST /api/sessions/:id/attachments` with `notify: false`), never by widening `file-content`/`file-raw` or `file-stream-manager`'s `tail -f` allowlist. ⚠️ `TEXT_ATTACHMENT_EXTENSIONS` IS `EDITABLE_EXTENSIONS` (never a second list), and widening READ must never widen RUN: `html`/`htm`/`svg` stay download-only, other text is inert `text/plain`+`nosniff`. ⚠️ The ONE place HTML renders is `/html-view//...` (`html-view-routes.ts`, `html-view-capabilities.ts`): `POST /api/sessions/:id/html-view` mints a capability for the file's own directory, every response carries `Content-Security-Policy: sandbox` WITHOUT `allow-same-origin` (opaque origin even as a top-level tab, so the page can never read Codeman or drive its API) and with NO fetch directive (open network, owner decision 2026-10-13: never restrict it or add `allow-same-origin` without the owner), and only non-dot, asset-allowlisted files under that directory are served (GET/HEAD only; capabilities revoked with the web-tab ones on logout). That tree is served recursively, so minting is refused for a hidden directory or a broad root (`isBroadHtmlViewRoot`); the preview renders by default and the **Page** pill (or a refused mint) falls through to the source view and its Edit button. Never serve HTML same-origin anywhere else. Tests: `test/routes/html-view-routes.test.ts`. Media extensions are single-sourced in `attachment-registry.ts`. ⚠️ **XLSX previews parse in the BROWSER**, never on the server; the admission limits that keep a hostile workbook from freezing the worker live in `src/web/CLAUDE.md` (Spreadsheet preview). → [architecture-invariants#file-path-links-terminal--response-viewer](docs/architecture-invariants.md#file-path-links-terminal--response-viewer) **Filesystem path picker** (Link Existing "Browse" + the mobile keyboard's `📁 Path` key): lazy one-directory browsing via `GET /api/filesystem/browse`, with `GET /api/filesystem/preview` for the tapped file. Inserts the path **without** Enter, so the prompt is never submitted; the sibling `⌫ All` key clears only the unsent prompt and must never send the agent's `/clear`. ⚠️ This is a **second file-serving surface and inherits neither the attachment confinement nor its ownership scoping** — it allowlists Home, `CASES_DIR`, `/mnt/d` and `CODEMAN_FILE_PICKER_ROOTS`, blocks sensitive trees, and rejects symlink escapes **after** `realpath`. ⚠️ The optional `sessionId` is an ownership boundary that must be `canAccessOwned`-checked by hand (it does not go through `findSessionOrFail`), and in multi-user mode a non-admin gets only their own `userSpacePath` as a root: per-user spaces live INSIDE `homedir()`, so a `Home` root exposes every other user's workspace. Previews go through the same global conversion limiter, and Markdown/TXT/JSON are served as inert `text/plain`. → [architecture-invariants#filesystem-path-picker](docs/architecture-invariants.md#filesystem-path-picker) diff --git a/docs/architecture-invariants.md b/docs/architecture-invariants.md index 0a4ac07e5..b0acb7cc4 100644 --- a/docs/architecture-invariants.md +++ b/docs/architecture-invariants.md @@ -409,7 +409,7 @@ A file path an agent prints is a link on both surfaces it can appear on, and cli **Path forms beyond the rooted one.** `findFilePathLinks(text)` (constants.js) is the single entry point both consumers call; it reserves spaced candidates first (including unlinkable spans), then detects rooted matches, then `~/` and relative paths (`RELATIVE_FILE_PATH_LINK_PATTERN`, same extension list, pinned equal by `test/link-provider-regex.test.ts`), then folder paths, each only where it overlaps neither an earlier match nor an `http(s)` URL. A relative match must start at a boundary (line start, whitespace, quote, bracket, `=`, `,`, `>`), so it can never begin inside a word, an absolute path or a URL; brackets, `=` and `,` end it (`(shot.png)`, `OUT=shots/a.png`). `resolveLinkedFilePath()` resolves a relative path against the session's `workingDir` (`.`/`..` collapsed, so `../x.png` lands outside and routes like any external path) and passes absolute and `~/` paths through. ⚠️ `~` is the home of the SESSION'S host, which only the server knows: `~/` paths always take the attachment route, where `registerExternalAttachment` expands them (`homedir()` locally, `$HOME` inside the remote probe script), and the guard runs on the expanded, realpath-resolved path as before. ⚠️ Programs hard-wrap their own output short of the terminal edge (Claude Code breaks tool output at 59-118 columns), so xterm's `isWrapped` never fires. `terminalPathLine()` (link provider only; touch selection keeps `terminalLogicalLine`) joins the next row when the previous row ends in a path token with a `/` and no extension and the join reaches an extension; a row that starts a new path is never joined: rooted or `~/`, `./`/`../`, or a relative one that opens on the same top directory the broken token did (the next entry of `git ls-files`/`find`/`ls -R`: `src/components` + `src/index.ts` are two paths, not `src/componentssrc/index.ts`), while a wrapped path continues INSIDE a name, so `…/captures/2` + `026-10-10/…` still joins; a 3+ row chain is kept only if a later row completes the path, and the broken row must be at least as long as the continuing one (keeps `cd /mnt/foo` + a longer next line apart). Tests: `test/file-path-link-forms.test.ts`. -Spaced paths are considered first by `findFilePathLinks()`: accepted candidates reserve their entire span even when unlinkable, so later rooted, relative and folder detectors cannot link fragments. Relative candidates whose first slash-containing word is their last word defer to ordinary detection to exclude list/label prose. This deliberately gives up spaces only in the top directory (`Saved to: 03 Attachments/plan.pptx`); prose shaped like deeper spaced paths (`- Fixed bug in src/My Docs/a.md`, `/home/me/proj contains a.md`) remains ambiguous. Trailing whitespace uses linear `trimEnd()` before the length cap. +Spaced paths are considered first by `findFilePathLinks()`: accepted candidates reserve their entire span even when unlinkable, so later detectors cannot link fragments. Relative and `~/` spaced paths require an explicit path label (`Path:`, `File:`, `Files:`, `Saved to:`, `Saved as:`, `Wrote:`, `Written to:`, `Output:`, `Created:`, `Location:`, `Attachment:`), optionally preceded by a marker. Generic labels and bare bullets defer to ordinary detection, including slash-containing prose such as `CI/CD` or `45/45`. A later `/`, `~/`, `./` or `../` word starts a separate path; an earlier word ending in `/` is a separate folder. Absolute paths retain own-line and marker forms, including the residual ambiguity `/home/me/proj contains a.md`. The response viewer uses a leading `` path label in a paragraph or list item as detection context without changing the label or displayed text; paths split across other inline elements remain detected per text node. Tests feed vendored marked output into the jsdom linkifier, so consumed bullets and bold labels are covered. Trailing whitespace uses linear `trimEnd()` before the length cap. **Folder grid: a folder is granted, then served by bare file name.** A folder path (rooted or `~/` whose last segment has no extension, relative only with a trailing `/`; `FOLDER_PATH_LINK_PATTERN` + `folderLinkPath()`) opens `openFolderGrid()` (panels-ui.js), as does the preview's "Show folder" button. `POST /api/sessions/:id/folders {path}` (`src/folder-listing.ts`) resolves the folder (locally a bounded `probePathKind(…, { pastCap: true })` first, then async `fs.realpath`; remotely one ssh round trip via `remoteListFolder`, through `runWithRemoteSshLimit`), runs the attachment guard on it (blocklist, `confineToWorkspace`, and in multi-user mode `folderAllowedForUser`: a non-admin only inside their own `userSpacePath` or the session's `workingDir`, as the filesystem path picker), locally BEFORE reading it, lists non-hidden subfolders and files of a supported attachment type (each file also blocklist-checked, newest first, capped at `MAX_FOLDER_ENTRIES`), and GRANTS it an id. Files are then fetched as `GET …/folders/:folderId/files/:name/(raw|preview|thumbnail)`: `:name` must be a bare file name of a supported type (no `/`, `\`, NUL, no leading `.`, since the listing hides those), the local file is probed bounded (`unknown` → 503, never 404), and the joined path goes through the SAME `resolveServableAttachmentPath` as an attachment by id (symlink re-resolution, remote re-probe, guard), then the same gate registration applies to what a symlink RESOLVES to (a supported extension on the resolved path and a regular file: locally `fs.stat`, remotely the probe kind; else 400, so `shot.png -> ../secret.bin` is not served and a FIFO or device behind a supported name never reaches `open`), then `folderAllowedForUser` once more on the resolved file, before `serveServableRaw` (the attachment raw route's body, shared) streams it. So browser requests still never carry a path. ⚠️ Grants live in their own registry (`folderGrants`, 32 per session, cleared with the session's attachments), NOT the attachment registry: that one is capped at 200 per session, and paging a folder of 300 renders would evict the session's real attachments. ⚠️ A local folder is never touched unbounded (#516 rule): a folder on a network mount that stopped answering answers 503 with `describeUnknownPath()`, not 404 and not a frozen server. ⚠️ The remote listing script tells "missing" (`E|n`, 404) from "is a file" (`E|f`, 400 `Not a folder`), because the client falls back to the file preview on `Not a folder`; a remote extensionless file otherwise answered "Folder not found". ⚠️ `.folder-grid-overlay` sits at `z-index: 5090`, one step BELOW the preview (5100) and above the response viewer (5000): a tile's preview opens on top and closing it lands back on the grid. ⚠️ ←/→/Esc are handled in a document CAPTURE listener with `stopPropagation`, because the terminal keeps keyboard focus under the overlays and an arrow that reached xterm would be typed into the session; inputs other than xterm's helper textarea keep their keys. ⚠️ A route PARAMETER is capped by the router (Fastify's default is 100 characters, answered as 404 "Route not found"), and a file name goes up to 255, percent-encoded: the server sets `routerOptions.maxParamLength` to `MAX_ROUTE_PARAM_LENGTH` (route-helpers.ts), the route test harness uses the same constant, and a wiring guard pins it. ⚠️ `~/` in a Docker case is refused with 400 (attachment POST and `POST …/folders`): the session is local for these routes, but `~` inside the container is the container's own home, and expanding it on the host would open a different file than the one the agent printed. Tests: `test/routes/folder-grid-routes.test.ts` (incl. the listing script on a real `sh`), `test/folder-grid-ui.test.ts`. diff --git a/src/web/public/app.js b/src/web/public/app.js index 033838544..7aee56366 100644 --- a/src/web/public/app.js +++ b/src/web/public/app.js @@ -3017,7 +3017,19 @@ class CodemanApp { const targets = []; for (let node = walker.nextNode(); node; node = walker.nextNode()) { if (node.parentElement?.closest('a')) continue; - const found = findFilePathLinks(node.nodeValue || ''); + // Markdown puts **Saved to:** in a separate node. Carry only + // an explicit leading path label into detection; never arbitrary prose + // or a label from another block. The label itself stays untouched. + const label = node.previousSibling; + const beforeLabel = label?.previousSibling; + const labelText = label?.nodeName === 'STRONG' ? label.textContent : ''; + const leadingLabel = + /^(?:P|LI)$/.test(node.parentElement?.tagName || '') && + (!beforeLabel || (beforeLabel.nodeType === 3 && !beforeLabel.nodeValue.trim() && !beforeLabel.previousSibling)); + const prefix = leadingLabel && SPACED_FILE_PATH_LABEL_PATTERN.test(labelText) ? `${labelText} ` : ''; + const found = findFilePathLinks(prefix + (node.nodeValue || '')) + .filter((hit) => hit.index >= prefix.length) + .map((hit) => ({ ...hit, index: hit.index - prefix.length })); if (found.length > 0) targets.push([node, found]); } diff --git a/src/web/public/constants.js b/src/web/public/constants.js index 60b8a8bc7..db3006b4c 100644 --- a/src/web/public/constants.js +++ b/src/web/public/constants.js @@ -2159,7 +2159,8 @@ const FILE_LINK_EXTENSION_GROUP = /\\\.(\(\?:[^()]*\))\)\\b$/.exec(FILE_PATH_LIN * and folder detectors then skip reserved spans, earlier matches and URLs. * * The one entry point for both consumers (terminal link provider, response - * viewer), so a path form is clickable in both or in neither. + * viewer). The viewer restores a leading bold path label's context, but paths + * split across other inline elements are still detected per text node. * * @param {string} text * @returns {Array<{path: string, index: number, folder?: true}>} `index` is where `path` starts in @@ -2217,6 +2218,10 @@ function findFilePathLinks(text) { return found.sort((a, b) => a.index - b.index); } +/** Labels that explicitly identify a path, shared with the rendered-markdown linkifier. */ +const SPACED_FILE_PATH_LABEL_PATTERN = + /^(?:path|files?|saved (?:to|as)|wrote|written to|output|created|location|attachment):[ \t]*$/i; + /** * A file path WITH SPACES on one line, or null. * @@ -2229,10 +2234,12 @@ function findFilePathLinks(text) { * - /mnt/c/My Docs/report.docx after a list marker (`-`, `*`, `•`, `1.`, `2)`) * Saved to: 03 Attachments/Build SIPOC Design/plan.pptx after a short label (one to four words + `:`) * - * An absolute path may stand alone; a relative or `~/` one needs a marker or a - * label, since a bare spaced relative candidate is ordinary prose. One trailing + * An absolute path may stand alone or follow a marker. Relative and `~/` + * paths require an explicit path label (SPACED_FILE_PATH_LABEL_PATTERN), with + * an optional marker before it: generic labels and bullets also introduce prose. One trailing * sentence mark is left out. The candidate must read as ONE path: it ends on a - * known extension, and no earlier word ends on one, starts a new `/` path, ends + * known extension, and no earlier word ends on one or `/`; later words cannot + * start a new `/`, `~/`, `./` or `../` path. No earlier word ends * in `:` or is an arrow, so two paths on a line are never merged. * * A candidate that is one path but not a linkable one (an unknown root such as @@ -2250,23 +2257,18 @@ function spacedFilePathOnLine(line) { const index = prefix[0].length; const path = text.slice(index).trimEnd().replace(/[.,;:!?]$/, ''); if (!path.includes(' ') || !path.includes('/') || path.length > 1000) return null; - if (!path.startsWith('/') && !prefix[1] && !prefix[2]) return null; + if (!path.startsWith('/') && !SPACED_FILE_PATH_LABEL_PATTERN.test(prefix[2] || '')) return null; if (!PATH_TOKEN_ENDS_WITH_EXTENSION.test(path)) return null; if (/[\t"'`<>|;&\x00-\x1f\\]/.test(path) || path.includes('://')) return null; const words = path.split(' '); for (const word of words.slice(0, -1)) { - if (word.endsWith(':') || /^(?:—|–|→|=>|->|<-)$/.test(word)) return null; + if (word.endsWith('/') || word.endsWith(':') || /^(?:—|–|→|=>|->|<-)$/.test(word)) return null; if (PATH_TOKEN_ENDS_WITH_EXTENSION.test(word.replace(/[.,;:!?)\]}]+$/, ''))) return null; } - if (words.slice(1).some((word) => word.startsWith('/'))) return null; + if (words.slice(1).some((word) => /^(?:\/|~\/|\.{1,2}\/)/.test(word))) return null; const rooted = SPACED_PATH_ROOT.test(path) || path.startsWith('~/'); const segments = path.replace(/^~?\//, '').split('/'); const link = (rooted || !path.startsWith('/')) && segments.every((seg) => seg && seg !== '.' && seg !== '..'); - // A lone slash-containing final word after plain words is usually prose - // ("- Updated src/foo.ts"), not a spaced top-level directory. Let the - // ordinary detectors handle it, including explicit relative paths. - const firstSlash = words.findIndex((word) => word.includes('/')); - if (link && firstSlash === words.length - 1 && !path.startsWith('/')) return null; return { path, index, link }; } diff --git a/test/response-viewer-file-links.test.ts b/test/response-viewer-file-links.test.ts index 85947186a..fa08cca2e 100644 --- a/test/response-viewer-file-links.test.ts +++ b/test/response-viewer-file-links.test.ts @@ -53,6 +53,13 @@ function loadCodemanAppClass() { return (context as { __CodemanApp: { prototype: { _linkifyFilePaths(root: unknown): void } } }).__CodemanApp; } +const markedContext = vm.createContext({}); +vm.runInContext( + readFileSync(resolve(import.meta.dirname, '../src/web/public/vendor/marked.min.js'), 'utf8'), + markedContext +); +const marked = markedContext.marked as { parse(text: string): string }; + const CodemanApp = loadCodemanAppClass(); const APP_SOURCE = readFileSync(resolve(import.meta.dirname, '../src/web/public/app.js'), 'utf8'); @@ -69,6 +76,40 @@ function linkify(html: string): HTMLElement { const paths = (root: HTMLElement) => Array.from(root.querySelectorAll('a.rv-path')); describe('response viewer file-path linkifier', () => { + it.each([ + ['- 03 Attachments/Build SIPOC Design/Build-SIPOC.pptx', ['Design/Build-SIPOC.pptx']], + ['- notes/a b/c.md', ['b/c.md']], + [ + '**Saved to:** 03 Attachments/Build SIPOC Design/Build-SIPOC.pptx', + ['03 Attachments/Build SIPOC Design/Build-SIPOC.pptx'], + ], + ['**Saved to:** ~/My Docs/plan.md', ['~/My Docs/plan.md']], + ['- **Path:** notes/a b/c.md', ['notes/a b/c.md']], + ['- /mnt/c/My Docs/a.docx', ['/mnt/c/My Docs/a.docx']], + ['**Saved to:** /mnt/c/My Docs/a.docx', ['/mnt/c/My Docs/a.docx']], + ['**Tests:** 45/45 pass in test/foo.test.ts', ['test/foo.test.ts']], + ['**Error:** I/O failure reading data.json', ['data.json']], + ])('links rendered Markdown with consumed markers and labels: %s', (markdown, expected) => { + const html = marked.parse(markdown); + const original = document.createElement('div'); + original.innerHTML = html; + const root = linkify(html); + expect(paths(root).map((a) => a.getAttribute('data-path'))).toEqual(expected); + expect(root.textContent).toBe(original.textContent); + expect(root.querySelector('strong')?.textContent).toBe(original.querySelector('strong')?.textContent); + }); + + it('does not inherit a bold label from earlier prose or another block', () => { + for (const markdown of [ + 'See **Saved to:** 03 Attachments/Build SIPOC Design/Build-SIPOC.pptx', + '**Saved to:**\n\n03 Attachments/Build SIPOC Design/Build-SIPOC.pptx', + ]) { + expect(paths(linkify(marked.parse(markdown))).map((a) => a.getAttribute('data-path'))).toEqual([ + 'Design/Build-SIPOC.pptx', + ]); + } + }); + it('links an absolute path written as prose', () => { const path = '/tmp/claude-1000/-home-arkon-default-claudeman/7b3fefd2/scratchpad/probe-run-native.png'; const root = linkify(`

Saved the capture to ${path} — have a look.

`); diff --git a/test/spaced-file-path-links.test.ts b/test/spaced-file-path-links.test.ts index 8f49b89c2..25675c5b7 100644 --- a/test/spaced-file-path-links.test.ts +++ b/test/spaced-file-path-links.test.ts @@ -5,7 +5,7 @@ // viewer linked a fragment such as `/mnt/c/proj/My` plus a bogus relative tail // `Docs/report.docx`, each opening the wrong target. findFilePathLinks() now takes // a spaced path WHOLE where the line makes its extent unambiguous: the path is the -// whole line, or follows a list marker and/or a short `Label:`. A spaced line that +// whole absolute path, or a relative path after an explicit path label. A spaced line that // is one path but not a linkable one (unknown root, `..`) links nothing at all, // rather than a fragment. import { readFileSync } from 'node:fs'; @@ -49,19 +49,33 @@ describe('spaced file paths', () => { expect(paths(`${label} ${p}`)).toEqual([p]); }); - it.each(['Path:', 'File:', 'Saved to:'])('links a relative spaced path after %s', (label) => { + it.each([ + 'Path:', + 'File:', + 'Files:', + 'Saved to:', + 'Saved as:', + 'Wrote:', + 'Written to:', + 'Output:', + 'Created:', + 'Location:', + 'Attachment:', + 'pAtH:', + ])('links a relative spaced path after %s', (label) => { const p = '03 Attachments/Build SIPOC Design/Build-SIPOC.pptx'; expect(paths(`${label} ${p}`)).toEqual([p]); }); it.each(['- ', '* ', '• ', '1. ', '2) '])('links a spaced path after the list marker %j', (marker) => { const rel = '03 Attachments/Build SIPOC Design/Build-SIPOC.pptx'; - expect(paths(`${marker}${rel}`)).toEqual([rel]); + expect(paths(`${marker}${rel}`)).toEqual(['Design/Build-SIPOC.pptx']); + expect(paths(`${marker}Path: ${rel}`)).toEqual([rel]); expect(paths(` ${marker}${LONG}`)).toEqual([LONG]); }); - it('works line by line inside multi-line text (the response viewer hands over text nodes)', () => { - const text = `Wrote two files:\n- ${LONG}\n- notes/a b/c.md\n`; + it('works line by line in code-block text, where list markers remain literal', () => { + const text = `Wrote two files:\n- ${LONG}\n- Path: notes/a b/c.md\n`; expect(paths(text)).toEqual([LONG, 'notes/a b/c.md']); }); @@ -73,7 +87,7 @@ describe('spaced file paths', () => { it('links nothing for a spaced path with a `..` or `.` segment', () => { expect(paths('/mnt/c/proj/../other place/docs/report.docx')).toEqual([]); expect(paths('File: 03 Attachments/../private/report.xlsx')).toEqual([]); - expect(paths('- 03 Attachments/./private/report.xlsx')).toEqual([]); + expect(paths('- File: 03 Attachments/./private/report.xlsx')).toEqual([]); }); it('never merges two space-separated paths into one link', () => { @@ -98,18 +112,39 @@ describe('spaced file paths', () => { ['Error: cannot open src/foo.ts', 'src/foo.ts'], ['Note: see src/foo.ts', 'src/foo.ts'], ['- Moved ../a/b.ts', '../a/b.ts'], - ['Saved to: 03 Attachments/plan.pptx', 'Attachments/plan.pptx'], ])('defers ambiguous relative prose to ordinary detectors: %s', (line, path) => { expect(paths(line)).toEqual([path]); }); - it('pins residual ambiguity when spaces occur below the first directory', () => { - // Without filesystem knowledge these still have the shape of spaced paths. - expect(paths('- Fixed bug in src/My Docs/a.md')).toEqual(['Fixed bug in src/My Docs/a.md']); + it('requires a path label for relative candidates but retains absolute ambiguity', () => { + expect(paths('- Fixed bug in src/My Docs/a.md')).toEqual(['Docs/a.md']); + expect(paths('Saved to: 03 Attachments/plan.pptx')).toEqual(['03 Attachments/plan.pptx']); expect(paths('/home/me/proj contains a.md')).toEqual(['/home/me/proj contains a.md']); expect(paths('Saved to: ~/My Docs/plan.md')).toEqual(['~/My Docs/plan.md']); }); + it.each([ + ['1. Ran 45/45 tests in test/foo.test.ts', ['test/foo.test.ts']], + ['- Fixed CI/CD pipeline in .github/workflows/ci.yml', ['.github/workflows/ci.yml']], + ['- Added read/write support in config.ts', ['config.ts']], + ['- Tests: 45/45 pass in test/foo.test.ts', ['test/foo.test.ts']], + ['Error: I/O failure reading data.json', ['data.json']], + ['- Moved src/ into lib/a.ts', ['src/', 'lib/a.ts']], + ['- Copied files from ../shared into lib/a.ts', ['lib/a.ts']], + ['- Ran 45/45 tests in ~/proj/foo.test.ts', ['~/proj/foo.test.ts']], + ])('does not swallow slash-containing prose: %s', (line, expected) => { + expect(paths(line)).toEqual(expected); + }); + + it.each(['~/', './', '../', '/tmp/'])('does not merge a later new path starting with %s', (prefix) => { + const line = `Path: notes/a ${prefix}other/file.md`; + expect(paths(line)).not.toContain(line.slice(6)); + }); + + it('does not merge a folder token into a labelled spaced path', () => { + expect(paths('Path: src/ into lib/a.ts')).toEqual(['src/', 'lib/a.ts']); + }); + it.each([40000, 80000])('stays linear with %i interior spaces', (length) => { const line = '/home/a' + ' '.repeat(length) + 'x'; const start = performance.now(); From 27c93b20fb7451ee644882c78f5f150d4f3aa8fb Mon Sep 17 00:00:00 2001 From: Aamer Akhter Date: Fri, 23 Oct 2026 10:00:38 -0400 Subject: [PATCH 06/68] fix(terminal): stop URL continuations from swallowing prose --- docs/architecture-invariants.md | 2 +- src/web/public/constants.js | 7 +++- test/terminal-url-continuation.test.ts | 44 ++++++++++++++++++++++++++ 3 files changed, 51 insertions(+), 2 deletions(-) diff --git a/docs/architecture-invariants.md b/docs/architecture-invariants.md index 6dc61ed7d..2f68fad8d 100644 --- a/docs/architecture-invariants.md +++ b/docs/architecture-invariants.md @@ -409,7 +409,7 @@ A file path an agent prints is a link on both surfaces it can appear on, and cli **Path forms beyond the rooted one.** `findFilePathLinks(text)` (constants.js) is the single entry point both consumers call; it returns the rooted matches first, then `~/` and relative paths (`RELATIVE_FILE_PATH_LINK_PATTERN`, same extension list, pinned equal by `test/link-provider-regex.test.ts`), then folder paths, each only where it overlaps neither an earlier match nor an `http(s)` URL. A relative match must start at a boundary (line start, whitespace, quote, bracket, `=`, `,`, `>`), so it can never begin inside a word, an absolute path or a URL; brackets, `=` and `,` end it (`(shot.png)`, `OUT=shots/a.png`). `resolveLinkedFilePath()` resolves a relative path against the session's `workingDir` (`.`/`..` collapsed, so `../x.png` lands outside and routes like any external path) and passes absolute and `~/` paths through. ⚠️ `~` is the home of the SESSION'S host, which only the server knows: `~/` paths always take the attachment route, where `registerExternalAttachment` expands them (`homedir()` locally, `$HOME` inside the remote probe script), and the guard runs on the expanded, realpath-resolved path as before. ⚠️ Programs hard-wrap their own output short of the terminal edge (Claude Code breaks tool output at 59-118 columns), so xterm's `isWrapped` never fires. `terminalPathLine()` (link provider only; touch selection keeps `terminalLogicalLine`) joins the next row when the previous row ends in a path token with a `/` and no extension and the join reaches an extension; a row that starts a new path is never joined: rooted or `~/`, `./`/`../`, or a relative one that opens on the same top directory the broken token did (the next entry of `git ls-files`/`find`/`ls -R`: `src/components` + `src/index.ts` are two paths, not `src/componentssrc/index.ts`), while a wrapped path continues INSIDE a name, so `…/captures/2` + `026-10-10/…` still joins; a 3+ row chain is kept only if a later row completes the path, and the broken row must be at least as long as the continuing one (keeps `cd /mnt/foo` + a longer next line apart). Tests: `test/file-path-link-forms.test.ts`. -The terminal provider scans `terminalLinkLine()`, which extends `terminalPathLine()` for URL continuations in indented paragraphs and table cells. List markers, bare numbers and version tokens never continue a URL. `terminalCellWrappedPaths()` follows each table cell independently, gated by `pathContinuation()` and the per-cell row-length rule; accepted paths displace other links on their cells. Full-width divider rows stop `terminalLogicalLine()` stitching for both links and touch selection. OSC 8 links confirm the real target URI before the shared opener runs. +The terminal provider scans `terminalLinkLine()`, which extends `terminalPathLine()` for URL continuations in indented paragraphs and table cells. List markers, bare numbers and version tokens never continue a URL. A token with only hyphens, underscores or dots must end its cell (row end or a gap of at least two spaces), so prose such as `self-hosted runners` is not appended. A real plain slug followed by prose falls back to the original fragment link; `%`, `&`, `=`, `/` or `?` still qualify as stronger URL punctuation. `terminalCellWrappedPaths()` follows each table cell independently, gated by `pathContinuation()` and the per-cell row-length rule; accepted paths displace other links on their cells. Full-width divider rows stop `terminalLogicalLine()` stitching for both links and touch selection. OSC 8 links confirm the real target URI before the shared opener runs. **Folder grid: a folder is granted, then served by bare file name.** A folder path (rooted or `~/` whose last segment has no extension, relative only with a trailing `/`; `FOLDER_PATH_LINK_PATTERN` + `folderLinkPath()`) opens `openFolderGrid()` (panels-ui.js), as does the preview's "Show folder" button. `POST /api/sessions/:id/folders {path}` (`src/folder-listing.ts`) resolves the folder (locally a bounded `probePathKind(…, { pastCap: true })` first, then async `fs.realpath`; remotely one ssh round trip via `remoteListFolder`, through `runWithRemoteSshLimit`), runs the attachment guard on it (blocklist, `confineToWorkspace`, and in multi-user mode `folderAllowedForUser`: a non-admin only inside their own `userSpacePath` or the session's `workingDir`, as the filesystem path picker), locally BEFORE reading it, lists non-hidden subfolders and files of a supported attachment type (each file also blocklist-checked, newest first, capped at `MAX_FOLDER_ENTRIES`), and GRANTS it an id. Files are then fetched as `GET …/folders/:folderId/files/:name/(raw|preview|thumbnail)`: `:name` must be a bare file name of a supported type (no `/`, `\`, NUL, no leading `.`, since the listing hides those), the local file is probed bounded (`unknown` → 503, never 404), and the joined path goes through the SAME `resolveServableAttachmentPath` as an attachment by id (symlink re-resolution, remote re-probe, guard), then the same gate registration applies to what a symlink RESOLVES to (a supported extension on the resolved path and a regular file: locally `fs.stat`, remotely the probe kind; else 400, so `shot.png -> ../secret.bin` is not served and a FIFO or device behind a supported name never reaches `open`), then `folderAllowedForUser` once more on the resolved file, before `serveServableRaw` (the attachment raw route's body, shared) streams it. So browser requests still never carry a path. ⚠️ Grants live in their own registry (`folderGrants`, 32 per session, cleared with the session's attachments), NOT the attachment registry: that one is capped at 200 per session, and paging a folder of 300 renders would evict the session's real attachments. ⚠️ A local folder is never touched unbounded (#516 rule): a folder on a network mount that stopped answering answers 503 with `describeUnknownPath()`, not 404 and not a frozen server. ⚠️ The remote listing script tells "missing" (`E|n`, 404) from "is a file" (`E|f`, 400 `Not a folder`), because the client falls back to the file preview on `Not a folder`; a remote extensionless file otherwise answered "Folder not found". ⚠️ `.folder-grid-overlay` sits at `z-index: 5090`, one step BELOW the preview (5100) and above the response viewer (5000): a tile's preview opens on top and closing it lands back on the grid. ⚠️ ←/→/Esc are handled in a document CAPTURE listener with `stopPropagation`, because the terminal keeps keyboard focus under the overlays and an arrow that reached xterm would be typed into the session; inputs other than xterm's helper textarea keep their keys. ⚠️ A route PARAMETER is capped by the router (Fastify's default is 100 characters, answered as 404 "Route not found"), and a file name goes up to 255, percent-encoded: the server sets `routerOptions.maxParamLength` to `MAX_ROUTE_PARAM_LENGTH` (route-helpers.ts), the route test harness uses the same constant, and a wiring guard pins it. ⚠️ `~/` in a Docker case is refused with 400 (attachment POST and `POST …/folders`): the session is local for these routes, but `~` inside the container is the container's own home, and expanding it on the host would open a different file than the one the agent printed. Tests: `test/routes/folder-grid-routes.test.ts` (incl. the listing script on a real `sh`), `test/folder-grid-ui.test.ts`. diff --git a/src/web/public/constants.js b/src/web/public/constants.js index b89bfdb7b..b0bf0f2bc 100644 --- a/src/web/public/constants.js +++ b/src/web/public/constants.js @@ -2569,7 +2569,8 @@ function findTerminalUrls(text) { * started in (a table cell or list item continuation, not unrelated text); * - that row's first token looks like URL material (contains one of * `% & = / ? . - _`), is not a bare number/version or list marker, - * and is not itself a new `scheme://`. + * and is not itself a new `scheme://`; without `% & = / ?`, the token + * must finish its cell (row end or a gap of at least two spaces). * The last rule is what stops prose such as "…/files/" + "then run it" from * being glued into one link. * @@ -2623,6 +2624,10 @@ function terminalUrlContinuationExtent(buffer, row, cols, maxRows) { break; if (/^[a-z][a-z0-9+.-]*:\/\//i.test(token) || !/^[A-Za-z0-9%_~]/.test(token)) break; if (!/[%&=/?.\-_]/.test(token) || /^v?\d+(?:\.\d+)*(?:[-+][\w.-]+)?[.,;:!?)]?$/i.test(token)) break; + // Hyphens, underscores and dots also occur in prose. Without stronger + // URL punctuation, the token must finish its cell, not introduce a sentence. + const rest = nextText.slice(indent + token.length); + if (!/[%&=/?]/.test(token) && rest.trim() !== '' && !/^ {2,}/.test(rest)) break; const nextLogical = terminalLogicalLine(buffer, endRow + 1, cols, bound); if (!nextLogical) break; if (trailingColumns) firstRowUrlEnd = last.rawEnd; diff --git a/test/terminal-url-continuation.test.ts b/test/terminal-url-continuation.test.ts index 6d97ac2f2..64b4868ac 100644 --- a/test/terminal-url-continuation.test.ts +++ b/test/terminal-url-continuation.test.ts @@ -89,6 +89,19 @@ const TABLE_WITH_TRAILING_COLUMNS: Row[] = [ TABLE[4], ]; const TABLE_COLS = 220; +const PROSE_ROWS = [ + [ + '⏺ The guide lives at https://example.com/guide/', + ' self-hosted runners need a token first.', + 'https://example.com/guide/', + ], + ['Docs: https://example.com/guide/', 'Self-hosting is covered in chapter 3.', 'https://example.com/guide/'], + ['Open http://localhost:3000/', 'e.g. to check the dashboard', 'http://localhost:3000/'], + ['Endpoint https://api.example.com/v1/', 'user_id is required', 'https://api.example.com/v1/'], + ['Repo: https://github.com/org/repo/', 'well-known issues are tracked', 'https://github.com/org/repo/'], + ['Callback https://example.com/cb?code=', 'Re-run the login', 'https://example.com/cb?code='], + ['Branch https://github.com/o/r/tree/feat-', 'x.y happened', 'https://github.com/o/r/tree/feat-'], +]; describe('findTerminalUrls (grammar)', () => { const { findTerminalUrls } = loadPure(); @@ -178,6 +191,28 @@ describe('terminalUrlContinuationExtent (wrap joining)', () => { for (const row of [0, 1]) expect(terminalUrlContinuationExtent(buffer, row, 100, 12)).toBeNull(); }); + it.each(PROSE_ROWS)('does not join prose after %s', (first, next) => { + const buffer = fakeBuffer([{ text: first }, { text: next }], 100); + for (const row of [0, 1]) expect(terminalUrlContinuationExtent(buffer, row, 100, 12)).toBeNull(); + }); + + it.each(['my-post-title', 'user_id', 'x.html', 'my-post-title ', 'my-post-title Other column'])( + 'keeps a weak-punctuation continuation that ends its cell: %s', + (next) => { + const buffer = fakeBuffer([{ text: 'Docs: https://example.com/guide/' }, { text: next }], 100); + for (const row of [0, 1]) + expect(terminalUrlContinuationExtent(buffer, row, 100, 12)).toMatchObject({ startRow: 0, endRow: 1 }); + } + ); + + it.each(['part%20two', 'x=1&y=2', 'path/slug', 'query?x=1'])( + 'keeps stronger URL punctuation followed by prose: %s', + (token) => { + const buffer = fakeBuffer([{ text: 'Docs: https://example.com/guide/' }, { text: token + ' more text' }], 100); + expect(terminalUrlContinuationExtent(buffer, 0, 100, 12)).toMatchObject({ startRow: 0, endRow: 1 }); + } + ); + it('does not glue a following prose line onto a URL that ends a line', () => { const buffer = fakeBuffer( [{ text: 'Download from https://example.com/files/' }, { text: 'then run the installer' }], @@ -295,6 +330,15 @@ describe('terminal link provider with wrapped table URLs', () => { return { provide, openSpy }; } + it.each(PROSE_ROWS)('preserves the original clickable URL before prose: %s', (first, next, url) => { + const { provide, openSpy } = harness([{ text: first }, { text: next }], 100); + const links = provide(1).filter((link) => /^https?:/.test(link.text)); + expect(links.map((link) => link.text)).toEqual([url]); + links[0].activate(null, links[0].text); + expect(openSpy).toHaveBeenCalledExactlyOnceWith(url, '_blank', 'noopener,noreferrer'); + expect(provide(2).filter((link) => /^https?:/.test(link.text))).toEqual([]); + }); + it('opens the whole URL from any of its rows', () => { const { provide, openSpy } = harness(TABLE, COLS); for (const line of [2, 3, 4]) { From 8cdd18fa64a1ab51b9f3230ed56e9b7860d67bd9 Mon Sep 17 00:00:00 2001 From: JD Date: Sat, 24 Oct 2026 18:56:37 -0400 Subject: [PATCH 07/68] test(deepseek): stop pinning Node's one-shot zstd decoder to the first frame The frame-walking test asserted that zlib.zstdDecompressSync returns only the first of 40 concatenated frames. That was true when the walker was written, but nodejs/node#65865 (shipped in 26.10.0 and in 24.22.0 LTS) makes Node decode every frame, so the assertion fails on current Node 24 LTS and 26 while the walker itself is still correct. The assertion tested Node, not Codeman: the walker's own 40-frame checks stay, and the comments now say which Node versions stop at the first frame, since the supported range (>=22) still includes them. --- docs/deepseek-integration.md | 7 ++++--- src/deepseek-transcript.ts | 9 +++++---- test/deepseek-transcript.test.ts | 13 +++++-------- 3 files changed, 14 insertions(+), 15 deletions(-) diff --git a/docs/deepseek-integration.md b/docs/deepseek-integration.md index 874f6fd15..5f1072a74 100644 --- a/docs/deepseek-integration.md +++ b/docs/deepseek-integration.md @@ -235,9 +235,10 @@ worker's first answer reads as an answer. Three things about the file shaped the reader (`src/deepseek-transcript.ts`): - **It is one zstd FRAME per append, not one zstd stream.** `zstd -dc` decodes all - of them, Node's `zlib` zstd decoder stops at the first: a real 56-line - transcript came back as 1 line. The reader walks frame headers itself. On a Node - older than 22.15 (no zstd at all) the mode falls back to the pane, as before. + of them, Node's `zlib` zstd decoder before 24.22 and 26.10 stops at the first: + a real 56-line transcript came back as 1 line. The reader walks frame headers + itself, which stays correct on a Node that decodes every frame + (nodejs/node#65865). On a Node older than 22.15 (no zstd at all) the mode falls back to the pane, as before. - **Not every `user/message` is the user.** Each turn also records a plugin-sourced runtime-context snapshot; only `source.kind === 'user'` is a prompt. diff --git a/src/deepseek-transcript.ts b/src/deepseek-transcript.ts index dc374f101..7fbdcdd16 100644 --- a/src/deepseek-transcript.ts +++ b/src/deepseek-transcript.ts @@ -26,10 +26,11 @@ * **1. One zstd FRAME per append, not one zstd stream.** The file is * `session.jsonl.zstd`, and dsh appends by compressing each batch of lines into * its own frame and writing it at the end. `zstd -dc` handles that (frames - * concatenate by definition), but Node's `zlib.zstdDecompress()` and - * `createZstdDecompress()` both stop at the first frame end: measured on a real - * 56-line transcript, Node returned 158 bytes / 1 line where the CLI returned - * 43,747 bytes / 56 lines. That is a silent truncation to the session header — + * concatenate by definition), but before Node 24.22 and 26.10 + * (nodejs/node#65865) `zlib.zstdDecompress()` and `createZstdDecompress()` both + * stop at the first frame end, and the supported range still includes those + * versions: measured on a real 56-line transcript, Node returned 158 bytes / + * 1 line where the CLI returned 43,747 bytes / 56 lines. That is a silent truncation to the session header — * every call would have reported "no answer yet" forever. `decodeZstdFrames()` * below walks the frame headers itself and decompresses each frame, and * `test/deepseek-transcript.test.ts` pins it against multi-frame fixtures. diff --git a/test/deepseek-transcript.test.ts b/test/deepseek-transcript.test.ts index e0837070d..292aac14c 100644 --- a/test/deepseek-transcript.test.ts +++ b/test/deepseek-transcript.test.ts @@ -4,10 +4,11 @@ * Two of these assertions exist because the obvious implementation was measured * to be wrong against real files: * - * - dsh appends ONE ZSTD FRAME PER WRITE, and Node's `zlib` zstd decoder stops - * at the first frame end. A 56-line transcript decoded as 1 line / 158 bytes, - * which reads as "the worker never answered" rather than as an error. The - * multi-frame fixtures below are the guard. + * - dsh appends ONE ZSTD FRAME PER WRITE, and Node's `zlib` zstd decoder + * before 24.22 and 26.10 stops at the first frame end. A 56-line transcript + * decoded as 1 line / 158 bytes, which reads as "the worker never answered" + * rather than as an error. Newer Node decodes every frame (nodejs/node#65865), + * so the multi-frame fixtures below pin the walker itself, not Node's decoder. * - a fresh worker in a case directory that had been used before answered its * first `last-response` with the PREVIOUS session's reply. Session-to- * transcript pairing is therefore its own describe block. @@ -67,10 +68,6 @@ describe.skipIf(!zstdSupported())('zstd frame walking', () => { const lines = Array.from({ length: 40 }, (_, i) => JSON.stringify({ type: 'noise', seq: i })); const buf = framed(lines); - // The one-shot decoder is what this module had to replace. - const oneShot = (zlib as unknown as { zstdDecompressSync?: (b: Buffer) => Buffer }).zstdDecompressSync!(buf); - expect(oneShot.toString('utf8').trim().split('\n')).toHaveLength(1); - expect(zstdFrameRanges(buf)).toHaveLength(40); expect(decodeZstdFrames(buf).trim().split('\n')).toHaveLength(40); }); From 3a4e2c996ee28e622afd5d505190c313a8f067b3 Mon Sep 17 00:00:00 2001 From: Aamer Akhter Date: Sat, 24 Oct 2026 19:54:40 -0400 Subject: [PATCH 08/68] fix(terminal): keep shell prompts and status lines out of wrapped URLs A URL ending a row on `/` took the next row's first word whenever it carried URL punctuation, so a shell prompt, a home path or an HTTP status line could be appended to it. Refuse those tokens, and refuse a column-0 row as a hard continuation (real continuations are indented), which also keeps `status=200`, `24/7` and `really?` out. A divider row no longer continues the row above it, so a URL reaching the right edge does not absorb a full-width rule below it. Pin the new negatives at detector and provider level, re-indent the positive continuation fixtures, and pin the `self-hosted.` and `and/or` trade-offs. --- docs/architecture-invariants.md | 2 +- src/web/public/constants.js | 16 +++- test/terminal-url-continuation.test.ts | 106 +++++++++++++++++++++++-- 3 files changed, 115 insertions(+), 9 deletions(-) diff --git a/docs/architecture-invariants.md b/docs/architecture-invariants.md index 2f68fad8d..68559b282 100644 --- a/docs/architecture-invariants.md +++ b/docs/architecture-invariants.md @@ -409,7 +409,7 @@ A file path an agent prints is a link on both surfaces it can appear on, and cli **Path forms beyond the rooted one.** `findFilePathLinks(text)` (constants.js) is the single entry point both consumers call; it returns the rooted matches first, then `~/` and relative paths (`RELATIVE_FILE_PATH_LINK_PATTERN`, same extension list, pinned equal by `test/link-provider-regex.test.ts`), then folder paths, each only where it overlaps neither an earlier match nor an `http(s)` URL. A relative match must start at a boundary (line start, whitespace, quote, bracket, `=`, `,`, `>`), so it can never begin inside a word, an absolute path or a URL; brackets, `=` and `,` end it (`(shot.png)`, `OUT=shots/a.png`). `resolveLinkedFilePath()` resolves a relative path against the session's `workingDir` (`.`/`..` collapsed, so `../x.png` lands outside and routes like any external path) and passes absolute and `~/` paths through. ⚠️ `~` is the home of the SESSION'S host, which only the server knows: `~/` paths always take the attachment route, where `registerExternalAttachment` expands them (`homedir()` locally, `$HOME` inside the remote probe script), and the guard runs on the expanded, realpath-resolved path as before. ⚠️ Programs hard-wrap their own output short of the terminal edge (Claude Code breaks tool output at 59-118 columns), so xterm's `isWrapped` never fires. `terminalPathLine()` (link provider only; touch selection keeps `terminalLogicalLine`) joins the next row when the previous row ends in a path token with a `/` and no extension and the join reaches an extension; a row that starts a new path is never joined: rooted or `~/`, `./`/`../`, or a relative one that opens on the same top directory the broken token did (the next entry of `git ls-files`/`find`/`ls -R`: `src/components` + `src/index.ts` are two paths, not `src/componentssrc/index.ts`), while a wrapped path continues INSIDE a name, so `…/captures/2` + `026-10-10/…` still joins; a 3+ row chain is kept only if a later row completes the path, and the broken row must be at least as long as the continuing one (keeps `cd /mnt/foo` + a longer next line apart). Tests: `test/file-path-link-forms.test.ts`. -The terminal provider scans `terminalLinkLine()`, which extends `terminalPathLine()` for URL continuations in indented paragraphs and table cells. List markers, bare numbers and version tokens never continue a URL. A token with only hyphens, underscores or dots must end its cell (row end or a gap of at least two spaces), so prose such as `self-hosted runners` is not appended. A real plain slug followed by prose falls back to the original fragment link; `%`, `&`, `=`, `/` or `?` still qualify as stronger URL punctuation. `terminalCellWrappedPaths()` follows each table cell independently, gated by `pathContinuation()` and the per-cell row-length rule; accepted paths displace other links on their cells. Full-width divider rows stop `terminalLogicalLine()` stitching for both links and touch selection. OSC 8 links confirm the real target URI before the shared opener runs. +The terminal provider scans `terminalLinkLine()`, which extends `terminalPathLine()` for URL continuations in indented paragraphs and table cells. List markers, bare numbers and version tokens never continue a URL. A token with only hyphens, underscores or dots must end its cell (row end or a gap of at least two spaces), so prose such as `self-hosted runners` is not appended. A row end counts as a cell end, so `⏺ Docs live at https://example.com/guide/` followed by ` self-hosted.` still links `.../guide/self-hosted`, and an indented `and/or the API reference covers it.` still links `.../guide/and/or`; both are pinned as known trade-offs. A continuation must be indented (a column-0 row is new output, which keeps `status=200`, `24/7` and `really?` out), and a shell prompt (`user@host:`), a home path (`~/`) or an HTTP status line never continues a URL. A divider row never continues the row above it, so a URL that reaches the right edge does not absorb a full-width `────` row below. A real plain slug followed by prose falls back to the original fragment link; `%`, `&`, `=`, `/` or `?` still qualify as stronger URL punctuation. `terminalCellWrappedPaths()` follows each table cell independently, gated by `pathContinuation()` and the per-cell row-length rule; accepted paths displace other links on their cells. Full-width divider rows stop `terminalLogicalLine()` stitching for both links and touch selection. OSC 8 links confirm the real target URI before the shared opener runs. **Folder grid: a folder is granted, then served by bare file name.** A folder path (rooted or `~/` whose last segment has no extension, relative only with a trailing `/`; `FOLDER_PATH_LINK_PATTERN` + `folderLinkPath()`) opens `openFolderGrid()` (panels-ui.js), as does the preview's "Show folder" button. `POST /api/sessions/:id/folders {path}` (`src/folder-listing.ts`) resolves the folder (locally a bounded `probePathKind(…, { pastCap: true })` first, then async `fs.realpath`; remotely one ssh round trip via `remoteListFolder`, through `runWithRemoteSshLimit`), runs the attachment guard on it (blocklist, `confineToWorkspace`, and in multi-user mode `folderAllowedForUser`: a non-admin only inside their own `userSpacePath` or the session's `workingDir`, as the filesystem path picker), locally BEFORE reading it, lists non-hidden subfolders and files of a supported attachment type (each file also blocklist-checked, newest first, capped at `MAX_FOLDER_ENTRIES`), and GRANTS it an id. Files are then fetched as `GET …/folders/:folderId/files/:name/(raw|preview|thumbnail)`: `:name` must be a bare file name of a supported type (no `/`, `\`, NUL, no leading `.`, since the listing hides those), the local file is probed bounded (`unknown` → 503, never 404), and the joined path goes through the SAME `resolveServableAttachmentPath` as an attachment by id (symlink re-resolution, remote re-probe, guard), then the same gate registration applies to what a symlink RESOLVES to (a supported extension on the resolved path and a regular file: locally `fs.stat`, remotely the probe kind; else 400, so `shot.png -> ../secret.bin` is not served and a FIFO or device behind a supported name never reaches `open`), then `folderAllowedForUser` once more on the resolved file, before `serveServableRaw` (the attachment raw route's body, shared) streams it. So browser requests still never carry a path. ⚠️ Grants live in their own registry (`folderGrants`, 32 per session, cleared with the session's attachments), NOT the attachment registry: that one is capped at 200 per session, and paging a folder of 300 renders would evict the session's real attachments. ⚠️ A local folder is never touched unbounded (#516 rule): a folder on a network mount that stopped answering answers 503 with `describeUnknownPath()`, not 404 and not a frozen server. ⚠️ The remote listing script tells "missing" (`E|n`, 404) from "is a file" (`E|f`, 400 `Not a folder`), because the client falls back to the file preview on `Not a folder`; a remote extensionless file otherwise answered "Folder not found". ⚠️ `.folder-grid-overlay` sits at `z-index: 5090`, one step BELOW the preview (5100) and above the response viewer (5000): a tile's preview opens on top and closing it lands back on the grid. ⚠️ ←/→/Esc are handled in a document CAPTURE listener with `stopPropagation`, because the terminal keeps keyboard focus under the overlays and an arrow that reached xterm would be typed into the session; inputs other than xterm's helper textarea keep their keys. ⚠️ A route PARAMETER is capped by the router (Fastify's default is 100 characters, answered as 404 "Route not found"), and a file name goes up to 255, percent-encoded: the server sets `routerOptions.maxParamLength` to `MAX_ROUTE_PARAM_LENGTH` (route-helpers.ts), the route test harness uses the same constant, and a wiring guard pins it. ⚠️ `~/` in a Docker case is refused with 400 (attachment POST and `POST …/folders`): the session is local for these routes, but `~` inside the container is the container's own home, and expanding it on the host would open a different file than the one the agent printed. Tests: `test/routes/folder-grid-routes.test.ts` (incl. the listing script on a real `sh`), `test/folder-grid-ui.test.ts`. diff --git a/src/web/public/constants.js b/src/web/public/constants.js index b0bf0f2bc..3f8f5d23f 100644 --- a/src/web/public/constants.js +++ b/src/web/public/constants.js @@ -2333,6 +2333,9 @@ function terminalLogicalLine(buffer, row, cols, maxRows) { const continuesPrevious = (r) => { if (r <= 0) return false; if (lineAt(r)?.isWrapped) return true; + // A divider row never continues the row above, even when that row reaches + // the right edge: a URL ending at the edge must not absorb `────` below it. + if (/^[\s─━═╌╍┄┅┈┉-]+$/u.test(lineAt(r)?.translateToString(true) || '')) return false; const prev = lineAt(r - 1); const prevText = prev?.translateToString(true) || ''; // A full-width table divider is a row boundary, not a hard-wrapped line. @@ -2565,11 +2568,12 @@ function findTerminalUrls(text) { * linked a fragment. A row continues the URL above it only when ALL hold: * - the URL fragment ends on a character a wrapper breaks after * (`/ ? & = - _ % # + ~`); a following table column may be excluded; - * - the next row is indented no further right than the column the URL + * - the next row is indented, but no further right than the column the URL * started in (a table cell or list item continuation, not unrelated text); * - that row's first token looks like URL material (contains one of * `% & = / ? . - _`), is not a bare number/version or list marker, - * and is not itself a new `scheme://`; without `% & = / ?`, the token + * and is not itself a new `scheme://`, a shell prompt (`user@host:`), + * a home path (`~/`) or an HTTP status line; without `% & = / ?`, the token * must finish its cell (row end or a gap of at least two spaces). * The last rule is what stops prose such as "…/files/" + "then run it" from * being glued into one link. @@ -2611,7 +2615,10 @@ function terminalUrlContinuationExtent(buffer, row, cols, maxRows) { const nextText = next.translateToString(true) || ''; const indent = nextText.length - nextText.replace(/^\s+/, '').length; const token = nextText.slice(indent).split(/\s/, 1)[0]; - if (!token || indent > urlStartCol || /^(?:\d+[.)]|[-*•])(?:\s|$)/.test(nextText.slice(indent))) break; + // A hard continuation is indented (a table cell, a list item, Claude's + // two-space paragraph indent); a column-0 row is a new line of output. + if (!token || !indent || indent > urlStartCol || /^(?:\d+[.)]|[-*•])(?:\s|$)/.test(nextText.slice(indent))) + break; // When other table columns follow the URL, the next row must align with // the start of its cell. A new numbered row must not become URL text. if ( @@ -2623,6 +2630,9 @@ function terminalUrlContinuationExtent(buffer, row, cols, maxRows) { ) break; if (/^[a-z][a-z0-9+.-]*:\/\//i.test(token) || !/^[A-Za-z0-9%_~]/.test(token)) break; + // A shell prompt (`user@host:~/dir$`), a home path and an HTTP status line + // are never the rest of a URL, however much URL punctuation they carry. + if (/^[\w.-]+@[\w.-]+:|^~\/|^HTTP\/\d/i.test(token)) break; if (!/[%&=/?.\-_]/.test(token) || /^v?\d+(?:\.\d+)*(?:[-+][\w.-]+)?[.,;:!?)]?$/i.test(token)) break; // Hyphens, underscores and dots also occur in prose. Without stronger // URL punctuation, the token must finish its cell, not introduce a sentence. diff --git a/test/terminal-url-continuation.test.ts b/test/terminal-url-continuation.test.ts index 64b4868ac..b03a5ccf9 100644 --- a/test/terminal-url-continuation.test.ts +++ b/test/terminal-url-continuation.test.ts @@ -30,15 +30,20 @@ type Link = { function loadPure() { const context = vm.createContext({ console, window: undefined }); vm.runInContext( - `${CONSTANTS}\nglobalThis.__urls = findTerminalUrls; globalThis.__extent = terminalUrlContinuationExtent;`, + `${CONSTANTS}\nglobalThis.__urls = findTerminalUrls; globalThis.__extent = terminalUrlContinuationExtent; globalThis.__logical = terminalLogicalLine;`, context, { filename: 'constants.js' } ); const ctx = context as unknown as { __urls: (text: string) => UrlRef[]; __extent: (buffer: unknown, row: number, cols: number, maxRows?: number) => Extent; + __logical: (buffer: unknown, row: number, cols: number, maxRows?: number) => Extent; + }; + return { + findTerminalUrls: ctx.__urls, + terminalUrlContinuationExtent: ctx.__extent, + terminalLogicalLine: ctx.__logical, }; - return { findTerminalUrls: ctx.__urls, terminalUrlContinuationExtent: ctx.__extent }; } function fakeBuffer(rows: Row[], cols: number) { @@ -102,6 +107,29 @@ const PROSE_ROWS = [ ['Callback https://example.com/cb?code=', 'Re-run the login', 'https://example.com/cb?code='], ['Branch https://github.com/o/r/tree/feat-', 'x.y happened', 'https://github.com/o/r/tree/feat-'], ]; +// Rows that carry URL punctuation but are never the rest of the URL above them: +// a shell prompt, an HTTP status line, a home path, and column-0 prose. Each +// third element is the URL, the fourth the path link expected on the second row. +const NOT_CONTINUATION_ROWS: [string, string, string, string | null][] = [ + ['https://registry.npmjs.org/', 'arkon@box:~/proj$', 'https://registry.npmjs.org/', '~/proj'], + ['https://pypi.org/simple/', 'dev@laptop:/srv/app$', 'https://pypi.org/simple/', '/srv/app'], + ['$ curl -sI https://example.com/', 'HTTP/2 200', 'https://example.com/', null], + ['$ curl -i http://localhost:3000/api/', 'HTTP/1.1 200 OK', 'http://localhost:3000/api/', null], + ['Docs: https://example.com/guide/', '~/notes.md has more', 'https://example.com/guide/', '~/notes.md'], + ['fetch https://api.example.com/items/', 'status=200 took=12ms', 'https://api.example.com/items/', null], + ['Status page: https://status.example.com/', '24/7 monitoring is on.', 'https://status.example.com/', null], + ['Did you mean https://example.com/search?q=', 'really? no.', 'https://example.com/search?q=', null], +]; +// Known trade-offs: a row end counts as a cell end, so a short indented word +// with URL punctuation still continues the URL. Pinned so a change is deliberate. +const TRADE_OFF_ROWS: [string, string, string][] = [ + ['⏺ Docs live at https://example.com/guide/', ' self-hosted.', 'https://example.com/guide/self-hosted'], + [ + '⏺ The docs are at https://example.com/guide/', + ' and/or the API reference covers it.', + 'https://example.com/guide/and/or', + ], +]; describe('findTerminalUrls (grammar)', () => { const { findTerminalUrls } = loadPure(); @@ -144,7 +172,7 @@ describe('findTerminalUrls (grammar)', () => { }); describe('terminalUrlContinuationExtent (wrap joining)', () => { - const { terminalUrlContinuationExtent } = loadPure(); + const { terminalUrlContinuationExtent, terminalLogicalLine } = loadPure(); it('spans all three rows of a URL wrapped inside a table cell, from any of them', () => { const buffer = fakeBuffer(TABLE, COLS); @@ -196,10 +224,42 @@ describe('terminalUrlContinuationExtent (wrap joining)', () => { for (const row of [0, 1]) expect(terminalUrlContinuationExtent(buffer, row, 100, 12)).toBeNull(); }); + it.each(NOT_CONTINUATION_ROWS)('does not join a prompt, status line, home path or prose after %s', (first, next) => { + const buffer = fakeBuffer([{ text: first }, { text: next }], 100); + for (const row of [0, 1]) expect(terminalUrlContinuationExtent(buffer, row, 100, 12)).toBeNull(); + }); + + // Indented, so only the prompt / home path / status-line rule can refuse them. + it.each(['arkon@box:~/proj$', 'dev@laptop:/srv/app$', '~/notes.md', 'HTTP/2 200', 'HTTP/1.1 200 OK'])( + 'does not join an indented %s either', + (next) => { + const buffer = fakeBuffer([{ text: 'Docs: https://example.com/guide/' }, { text: ' ' + next }], 100); + for (const row of [0, 1]) expect(terminalUrlContinuationExtent(buffer, row, 100, 12)).toBeNull(); + } + ); + + it.each(TRADE_OFF_ROWS)('still joins the known trade-off after %s', (first, next) => { + const buffer = fakeBuffer([{ text: first }, { text: next }], 100); + for (const row of [0, 1]) + expect(terminalUrlContinuationExtent(buffer, row, 100, 12)).toMatchObject({ startRow: 0, endRow: 1 }); + }); + + it('does not let a URL at the right edge absorb a full-width divider row below it', () => { + const cols = 40; + const url = 'https://example.com/' + 'a'.repeat(cols - 'https://example.com/'.length); + for (const divider of ['─'.repeat(cols), '-'.repeat(cols)]) { + const buffer = fakeBuffer([{ text: url }, { text: divider }, { text: 'next' }], cols); + expect(terminalLogicalLine(buffer, 0, cols, 12)).toMatchObject({ startRow: 0, endRow: 0 }); + expect(terminalLogicalLine(buffer, 1, cols, 12)).toMatchObject({ startRow: 1 }); + } + }); + + // Real continuations are indented (codex cells, Claude's two-space paragraph + // indent); a column-0 row is refused, so these fixtures are indented. it.each(['my-post-title', 'user_id', 'x.html', 'my-post-title ', 'my-post-title Other column'])( 'keeps a weak-punctuation continuation that ends its cell: %s', (next) => { - const buffer = fakeBuffer([{ text: 'Docs: https://example.com/guide/' }, { text: next }], 100); + const buffer = fakeBuffer([{ text: 'Docs: https://example.com/guide/' }, { text: ' ' + next }], 100); for (const row of [0, 1]) expect(terminalUrlContinuationExtent(buffer, row, 100, 12)).toMatchObject({ startRow: 0, endRow: 1 }); } @@ -208,7 +268,10 @@ describe('terminalUrlContinuationExtent (wrap joining)', () => { it.each(['part%20two', 'x=1&y=2', 'path/slug', 'query?x=1'])( 'keeps stronger URL punctuation followed by prose: %s', (token) => { - const buffer = fakeBuffer([{ text: 'Docs: https://example.com/guide/' }, { text: token + ' more text' }], 100); + const buffer = fakeBuffer( + [{ text: 'Docs: https://example.com/guide/' }, { text: ' ' + token + ' more text' }], + 100 + ); expect(terminalUrlContinuationExtent(buffer, 0, 100, 12)).toMatchObject({ startRow: 0, endRow: 1 }); } ); @@ -339,6 +402,39 @@ describe('terminal link provider with wrapped table URLs', () => { expect(provide(2).filter((link) => /^https?:/.test(link.text))).toEqual([]); }); + it.each(NOT_CONTINUATION_ROWS)('keeps the URL and the second row separate: %s', (first, next, url, pathOnNext) => { + const { provide, openSpy } = harness([{ text: first }, { text: next }], 100); + const links = provide(1).filter((link) => /^https?:/.test(link.text)); + expect(links.map((link) => link.text)).toEqual([url]); + links[0].activate(null, links[0].text); + expect(openSpy).toHaveBeenCalledExactlyOnceWith(url, '_blank', 'noopener,noreferrer'); + const second = provide(2); + expect(second.filter((link) => /^https?:/.test(link.text))).toEqual([]); + if (pathOnNext) expect(second.map((link) => link.text)).toContain(pathOnNext); + }); + + it.each(TRADE_OFF_ROWS)('pins the known trade-off link after %s', (first, next, joined) => { + const { provide } = harness([{ text: first }, { text: next }], 100); + for (const line of [1, 2]) { + const links = provide(line).filter((link) => /^https?:/.test(link.text)); + expect(links.map((link) => link.text)).toContain(joined); + } + }); + + it('keeps a full-width divider row out of a URL that reaches the right edge', () => { + const cols = 40; + const url = 'https://example.com/' + 'a'.repeat(cols - 'https://example.com/'.length); + for (const divider of ['─'.repeat(cols), '-'.repeat(cols)]) { + const { provide } = harness([{ text: url }, { text: divider }], cols); + expect( + provide(1) + .filter((link) => /^https?:/.test(link.text)) + .map((link) => link.text) + ).toEqual([url]); + expect(provide(2).filter((link) => /^https?:/.test(link.text))).toEqual([]); + } + }); + it('opens the whole URL from any of its rows', () => { const { provide, openSpy } = harness(TABLE, COLS); for (const line of [2, 3, 4]) { From 405c9928d9de675d64b05f600106785415d362f7 Mon Sep 17 00:00:00 2001 From: Aamer Akhter Date: Sat, 24 Oct 2026 19:55:11 -0400 Subject: [PATCH 09/68] fix(terminal): end spaced paths at line suffixes and generic labels A spaced path is no longer taken across a `path:line` suffix (grep -n, rg -n and compiler output) and an absolute path after a generic label such as `Error:` or `Note:` goes back to the ordinary detectors: any label must now be a path label. `Saved the report to: /mnt/c/My Docs/a.docx` splits as on master, pinned as a documented trade-off. Also: - a rooted candidate needs a directory before its first space, and an unlinkable one reserves its span only while its last word holds a `/`, so `/usr/bin/env node build.js` and `/review the changes in src/foo.ts` keep their links while `/etc/secret files/passwords.txt` still links nothing - a run of spaces is a column gap, not part of a name - a leading `./` is not read as a segment - the response viewer carries a bold path label that starts a later line of a paragraph (after a `
`), and degrades if the label pattern is missing - response viewer tests render with the options chat uses --- docs/architecture-invariants.md | 2 +- src/web/public/app.js | 17 ++++-- src/web/public/constants.js | 42 ++++++++++---- test/response-viewer-file-links.test.ts | 17 +++++- test/spaced-file-path-links.test.ts | 73 +++++++++++++++++++++++-- 5 files changed, 123 insertions(+), 28 deletions(-) diff --git a/docs/architecture-invariants.md b/docs/architecture-invariants.md index b0acb7cc4..83a6d304d 100644 --- a/docs/architecture-invariants.md +++ b/docs/architecture-invariants.md @@ -409,7 +409,7 @@ A file path an agent prints is a link on both surfaces it can appear on, and cli **Path forms beyond the rooted one.** `findFilePathLinks(text)` (constants.js) is the single entry point both consumers call; it reserves spaced candidates first (including unlinkable spans), then detects rooted matches, then `~/` and relative paths (`RELATIVE_FILE_PATH_LINK_PATTERN`, same extension list, pinned equal by `test/link-provider-regex.test.ts`), then folder paths, each only where it overlaps neither an earlier match nor an `http(s)` URL. A relative match must start at a boundary (line start, whitespace, quote, bracket, `=`, `,`, `>`), so it can never begin inside a word, an absolute path or a URL; brackets, `=` and `,` end it (`(shot.png)`, `OUT=shots/a.png`). `resolveLinkedFilePath()` resolves a relative path against the session's `workingDir` (`.`/`..` collapsed, so `../x.png` lands outside and routes like any external path) and passes absolute and `~/` paths through. ⚠️ `~` is the home of the SESSION'S host, which only the server knows: `~/` paths always take the attachment route, where `registerExternalAttachment` expands them (`homedir()` locally, `$HOME` inside the remote probe script), and the guard runs on the expanded, realpath-resolved path as before. ⚠️ Programs hard-wrap their own output short of the terminal edge (Claude Code breaks tool output at 59-118 columns), so xterm's `isWrapped` never fires. `terminalPathLine()` (link provider only; touch selection keeps `terminalLogicalLine`) joins the next row when the previous row ends in a path token with a `/` and no extension and the join reaches an extension; a row that starts a new path is never joined: rooted or `~/`, `./`/`../`, or a relative one that opens on the same top directory the broken token did (the next entry of `git ls-files`/`find`/`ls -R`: `src/components` + `src/index.ts` are two paths, not `src/componentssrc/index.ts`), while a wrapped path continues INSIDE a name, so `…/captures/2` + `026-10-10/…` still joins; a 3+ row chain is kept only if a later row completes the path, and the broken row must be at least as long as the continuing one (keeps `cd /mnt/foo` + a longer next line apart). Tests: `test/file-path-link-forms.test.ts`. -Spaced paths are considered first by `findFilePathLinks()`: accepted candidates reserve their entire span even when unlinkable, so later detectors cannot link fragments. Relative and `~/` spaced paths require an explicit path label (`Path:`, `File:`, `Files:`, `Saved to:`, `Saved as:`, `Wrote:`, `Written to:`, `Output:`, `Created:`, `Location:`, `Attachment:`), optionally preceded by a marker. Generic labels and bare bullets defer to ordinary detection, including slash-containing prose such as `CI/CD` or `45/45`. A later `/`, `~/`, `./` or `../` word starts a separate path; an earlier word ending in `/` is a separate folder. Absolute paths retain own-line and marker forms, including the residual ambiguity `/home/me/proj contains a.md`. The response viewer uses a leading `` path label in a paragraph or list item as detection context without changing the label or displayed text; paths split across other inline elements remain detected per text node. Tests feed vendored marked output into the jsdom linkifier, so consumed bullets and bold labels are covered. Trailing whitespace uses linear `trimEnd()` before the length cap. +Spaced paths are considered first by `findFilePathLinks()`: accepted candidates reserve their entire span even when unlinkable, so later detectors cannot link fragments. Relative and `~/` spaced paths require an explicit path label (`Path:`, `File:`, `Files:`, `Saved to:`, `Saved as:`, `Wrote:`, `Written to:`, `Output:`, `Created:`, `Location:`, `Attachment:`), optionally preceded by a marker. Generic labels and bare bullets defer to ordinary detection, including slash-containing prose such as `CI/CD` or `45/45`. A later `/`, `~/`, `./` or `../` word starts a separate path; an earlier word ending in `/` is a separate folder. Absolute paths keep the own-line and marker forms, including the residual ambiguity `/home/me/proj contains a.md`, plus the same path labels; any other label (`Error:`, `Note:`, `Saved the report to:`) defers an absolute path too. A rooted candidate needs a directory before its first space, so `/review the changes in src/a.ts` is prose. A `:` followed by a digit inside an earlier word is a line suffix (`a.ts:42:See b.md`, grep and compiler output) and ends the path there, a run of spaces is a column gap rather than a name, and a leading `./` is not a segment. An unlinkable candidate reserves its span only while its last word holds a `/`, so `/usr/bin/env node build.js` keeps its ordinary links while `/etc/secret files/passwords.txt` links nothing. The response viewer uses a leading `` path label in a paragraph or list item, or one starting a later line of a paragraph (after a `
`, since chat renders with `breaks: true`), as detection context without changing the label or displayed text; paths split across other inline elements remain detected per text node. Tests feed vendored marked output into the jsdom linkifier, so consumed bullets and bold labels are covered. Trailing whitespace uses linear `trimEnd()` before the length cap. **Folder grid: a folder is granted, then served by bare file name.** A folder path (rooted or `~/` whose last segment has no extension, relative only with a trailing `/`; `FOLDER_PATH_LINK_PATTERN` + `folderLinkPath()`) opens `openFolderGrid()` (panels-ui.js), as does the preview's "Show folder" button. `POST /api/sessions/:id/folders {path}` (`src/folder-listing.ts`) resolves the folder (locally a bounded `probePathKind(…, { pastCap: true })` first, then async `fs.realpath`; remotely one ssh round trip via `remoteListFolder`, through `runWithRemoteSshLimit`), runs the attachment guard on it (blocklist, `confineToWorkspace`, and in multi-user mode `folderAllowedForUser`: a non-admin only inside their own `userSpacePath` or the session's `workingDir`, as the filesystem path picker), locally BEFORE reading it, lists non-hidden subfolders and files of a supported attachment type (each file also blocklist-checked, newest first, capped at `MAX_FOLDER_ENTRIES`), and GRANTS it an id. Files are then fetched as `GET …/folders/:folderId/files/:name/(raw|preview|thumbnail)`: `:name` must be a bare file name of a supported type (no `/`, `\`, NUL, no leading `.`, since the listing hides those), the local file is probed bounded (`unknown` → 503, never 404), and the joined path goes through the SAME `resolveServableAttachmentPath` as an attachment by id (symlink re-resolution, remote re-probe, guard), then the same gate registration applies to what a symlink RESOLVES to (a supported extension on the resolved path and a regular file: locally `fs.stat`, remotely the probe kind; else 400, so `shot.png -> ../secret.bin` is not served and a FIFO or device behind a supported name never reaches `open`), then `folderAllowedForUser` once more on the resolved file, before `serveServableRaw` (the attachment raw route's body, shared) streams it. So browser requests still never carry a path. ⚠️ Grants live in their own registry (`folderGrants`, 32 per session, cleared with the session's attachments), NOT the attachment registry: that one is capped at 200 per session, and paging a folder of 300 renders would evict the session's real attachments. ⚠️ A local folder is never touched unbounded (#516 rule): a folder on a network mount that stopped answering answers 503 with `describeUnknownPath()`, not 404 and not a frozen server. ⚠️ The remote listing script tells "missing" (`E|n`, 404) from "is a file" (`E|f`, 400 `Not a folder`), because the client falls back to the file preview on `Not a folder`; a remote extensionless file otherwise answered "Folder not found". ⚠️ `.folder-grid-overlay` sits at `z-index: 5090`, one step BELOW the preview (5100) and above the response viewer (5000): a tile's preview opens on top and closing it lands back on the grid. ⚠️ ←/→/Esc are handled in a document CAPTURE listener with `stopPropagation`, because the terminal keeps keyboard focus under the overlays and an arrow that reached xterm would be typed into the session; inputs other than xterm's helper textarea keep their keys. ⚠️ A route PARAMETER is capped by the router (Fastify's default is 100 characters, answered as 404 "Route not found"), and a file name goes up to 255, percent-encoded: the server sets `routerOptions.maxParamLength` to `MAX_ROUTE_PARAM_LENGTH` (route-helpers.ts), the route test harness uses the same constant, and a wiring guard pins it. ⚠️ `~/` in a Docker case is refused with 400 (attachment POST and `POST …/folders`): the session is local for these routes, but `~` inside the container is the container's own home, and expanding it on the host would open a different file than the one the agent printed. Tests: `test/routes/folder-grid-routes.test.ts` (incl. the listing script on a real `sh`), `test/folder-grid-ui.test.ts`. diff --git a/src/web/public/app.js b/src/web/public/app.js index 7aee56366..131f28371 100644 --- a/src/web/public/app.js +++ b/src/web/public/app.js @@ -3015,18 +3015,23 @@ class CodemanApp { // invalidates the traversal. const walker = document.createTreeWalker(root, NodeFilter.SHOW_TEXT); const targets = []; + // Same promise as the guard above: a stale constants.js without the label + // pattern only loses the carried label, it never throws. + const labelPattern = typeof SPACED_FILE_PATH_LABEL_PATTERN !== 'undefined' ? SPACED_FILE_PATH_LABEL_PATTERN : null; for (let node = walker.nextNode(); node; node = walker.nextNode()) { if (node.parentElement?.closest('a')) continue; // Markdown puts **Saved to:** in a separate node. Carry only - // an explicit leading path label into detection; never arbitrary prose - // or a label from another block. The label itself stays untouched. + // an explicit path label that starts its line into detection; never + // arbitrary prose or a label from another block. Chat renders with + // `breaks: true`, so a later line of a paragraph starts after a
. + // The label itself stays untouched. const label = node.previousSibling; - const beforeLabel = label?.previousSibling; + let beforeLabel = label?.previousSibling; + if (beforeLabel?.nodeType === 3 && !beforeLabel.nodeValue.trim()) beforeLabel = beforeLabel.previousSibling; const labelText = label?.nodeName === 'STRONG' ? label.textContent : ''; const leadingLabel = - /^(?:P|LI)$/.test(node.parentElement?.tagName || '') && - (!beforeLabel || (beforeLabel.nodeType === 3 && !beforeLabel.nodeValue.trim() && !beforeLabel.previousSibling)); - const prefix = leadingLabel && SPACED_FILE_PATH_LABEL_PATTERN.test(labelText) ? `${labelText} ` : ''; + /^(?:P|LI)$/.test(node.parentElement?.tagName || '') && (!beforeLabel || beforeLabel.nodeName === 'BR'); + const prefix = leadingLabel && labelPattern?.test(labelText) ? `${labelText} ` : ''; const found = findFilePathLinks(prefix + (node.nodeValue || '')) .filter((hit) => hit.index >= prefix.length) .map((hit) => ({ ...hit, index: hit.index - prefix.length })); diff --git a/src/web/public/constants.js b/src/web/public/constants.js index db3006b4c..d8b87884a 100644 --- a/src/web/public/constants.js +++ b/src/web/public/constants.js @@ -2234,17 +2234,24 @@ const SPACED_FILE_PATH_LABEL_PATTERN = * - /mnt/c/My Docs/report.docx after a list marker (`-`, `*`, `•`, `1.`, `2)`) * Saved to: 03 Attachments/Build SIPOC Design/plan.pptx after a short label (one to four words + `:`) * - * An absolute path may stand alone or follow a marker. Relative and `~/` - * paths require an explicit path label (SPACED_FILE_PATH_LABEL_PATTERN), with - * an optional marker before it: generic labels and bullets also introduce prose. One trailing - * sentence mark is left out. The candidate must read as ONE path: it ends on a - * known extension, and no earlier word ends on one or `/`; later words cannot - * start a new `/`, `~/`, `./` or `../` path. No earlier word ends - * in `:` or is an arrow, so two paths on a line are never merged. + * An absolute path may stand alone or follow a marker. Any label, before an + * absolute, relative or `~/` path, must be an explicit path label + * (SPACED_FILE_PATH_LABEL_PATTERN), with an optional marker before it: generic + * labels (`Error:`, `Note:`) and bare bullets also introduce prose, so a relative + * path needs a path label to be taken spaced. One trailing sentence mark is left + * out, and a leading `./` is not a segment. The candidate must read as ONE path: + * it ends on a known extension, a rooted one has a directory before its first + * space, no earlier word ends on an extension or `/`, and none holds a `:` and a + * digit (`a.ts:42`, a line suffix ends the path). Later words cannot start a new + * `/`, `~/`, `./` or `../` path, no earlier word ends in `:` or is an arrow, and + * a run of spaces (a column gap) is refused, so two paths on a line are never + * merged. * * A candidate that is one path but not a linkable one (an unknown root such as - * `/etc`, a `.`/`..`/empty segment) comes back with `link: false`: its span links - * nothing, rather than the fragment after its last space. + * `/etc`, a `.`/`..`/empty segment) comes back with `link: false` when its last + * word still holds a `/`: its span links nothing, rather than the fragment after + * its last space. Otherwise (`/usr/bin/env node build.js`) the line is left to + * the ordinary detectors. * * Linear: one bounded prefix match, then string scans. * @@ -2257,18 +2264,29 @@ function spacedFilePathOnLine(line) { const index = prefix[0].length; const path = text.slice(index).trimEnd().replace(/[.,;:!?]$/, ''); if (!path.includes(' ') || !path.includes('/') || path.length > 1000) return null; - if (!path.startsWith('/') && !SPACED_FILE_PATH_LABEL_PATTERN.test(prefix[2] || '')) return null; + // After a label, only a path label introduces a path; with no label, only an + // absolute path may stand alone. `Error: /a/b contains c.md` is prose. + if (prefix[2] ? !SPACED_FILE_PATH_LABEL_PATTERN.test(prefix[2]) : !path.startsWith('/')) return null; if (!PATH_TOKEN_ENDS_WITH_EXTENSION.test(path)) return null; - if (/[\t"'`<>|;&\x00-\x1f\\]/.test(path) || path.includes('://')) return null; + // A run of spaces is a column gap (`git status`, `ls -l`), not a name. + if (/[\t"'`<>|;&\x00-\x1f\\]/.test(path) || path.includes('://') || path.includes(' ')) return null; const words = path.split(' '); + // A rooted candidate must have a directory before its first space: `/usr/bin/env node a.js` + // or `/review the changes in src/a.ts` is a command, not a spaced path. + if (path.startsWith('/') && !/^\/[^/]+\//.test(words[0])) return null; for (const word of words.slice(0, -1)) { if (word.endsWith('/') || word.endsWith(':') || /^(?:—|–|→|=>|->|<-)$/.test(word)) return null; + // `a.ts:42` (grep -n, compiler output): the path already ended at the line suffix. + if (/:\d/.test(word)) return null; if (PATH_TOKEN_ENDS_WITH_EXTENSION.test(word.replace(/[.,;:!?)\]}]+$/, ''))) return null; } if (words.slice(1).some((word) => /^(?:\/|~\/|\.{1,2}\/)/.test(word))) return null; const rooted = SPACED_PATH_ROOT.test(path) || path.startsWith('~/'); - const segments = path.replace(/^~?\//, '').split('/'); + const segments = path.replace(/^(?:~?\/|\.\/)/, '').split('/'); const link = (rooted || !path.startsWith('/')) && segments.every((seg) => seg && seg !== '.' && seg !== '..'); + // Reserve an unlinkable span only when it still reads as a path to its end; + // otherwise leave the line to the ordinary detectors. + if (!link && !words[words.length - 1].includes('/')) return null; return { path, index, link }; } diff --git a/test/response-viewer-file-links.test.ts b/test/response-viewer-file-links.test.ts index fa08cca2e..36f454d2e 100644 --- a/test/response-viewer-file-links.test.ts +++ b/test/response-viewer-file-links.test.ts @@ -58,7 +58,9 @@ vm.runInContext( readFileSync(resolve(import.meta.dirname, '../src/web/public/vendor/marked.min.js'), 'utf8'), markedContext ); -const marked = markedContext.marked as { parse(text: string): string }; +const marked = markedContext.marked as { parse(text: string, options?: object): string }; +/** The options `_renderMarkdown` passes, so the tests see chat's real markup (a newline becomes `
`). */ +const renderChat = (markdown: string) => marked.parse(markdown, { breaks: true, gfm: true }); const CodemanApp = loadCodemanAppClass(); const APP_SOURCE = readFileSync(resolve(import.meta.dirname, '../src/web/public/app.js'), 'utf8'); @@ -89,8 +91,16 @@ describe('response viewer file-path linkifier', () => { ['**Saved to:** /mnt/c/My Docs/a.docx', ['/mnt/c/My Docs/a.docx']], ['**Tests:** 45/45 pass in test/foo.test.ts', ['test/foo.test.ts']], ['**Error:** I/O failure reading data.json', ['data.json']], + [ + 'Done.\n**Saved to:** 03 Attachments/Build SIPOC Design/Build-SIPOC.pptx', + ['03 Attachments/Build SIPOC Design/Build-SIPOC.pptx'], + ], + [ + '**Size:** 2 MB\n**Saved to:** 03 Attachments/Build SIPOC Design/Build-SIPOC.pptx', + ['03 Attachments/Build SIPOC Design/Build-SIPOC.pptx'], + ], ])('links rendered Markdown with consumed markers and labels: %s', (markdown, expected) => { - const html = marked.parse(markdown); + const html = renderChat(markdown); const original = document.createElement('div'); original.innerHTML = html; const root = linkify(html); @@ -103,8 +113,9 @@ describe('response viewer file-path linkifier', () => { for (const markdown of [ 'See **Saved to:** 03 Attachments/Build SIPOC Design/Build-SIPOC.pptx', '**Saved to:**\n\n03 Attachments/Build SIPOC Design/Build-SIPOC.pptx', + 'Done.\nSee **Saved to:** 03 Attachments/Build SIPOC Design/Build-SIPOC.pptx', ]) { - expect(paths(linkify(marked.parse(markdown))).map((a) => a.getAttribute('data-path'))).toEqual([ + expect(paths(linkify(renderChat(markdown))).map((a) => a.getAttribute('data-path'))).toEqual([ 'Design/Build-SIPOC.pptx', ]); } diff --git a/test/spaced-file-path-links.test.ts b/test/spaced-file-path-links.test.ts index 25675c5b7..a5aca00d6 100644 --- a/test/spaced-file-path-links.test.ts +++ b/test/spaced-file-path-links.test.ts @@ -67,12 +67,15 @@ describe('spaced file paths', () => { expect(paths(`${label} ${p}`)).toEqual([p]); }); - it.each(['- ', '* ', '• ', '1. ', '2) '])('links a spaced path after the list marker %j', (marker) => { - const rel = '03 Attachments/Build SIPOC Design/Build-SIPOC.pptx'; - expect(paths(`${marker}${rel}`)).toEqual(['Design/Build-SIPOC.pptx']); - expect(paths(`${marker}Path: ${rel}`)).toEqual([rel]); - expect(paths(` ${marker}${LONG}`)).toEqual([LONG]); - }); + it.each(['- ', '* ', '• ', '1. ', '2) '])( + 'defers a relative spaced path after a bare list marker %j, links it after a path label', + (marker) => { + const rel = '03 Attachments/Build SIPOC Design/Build-SIPOC.pptx'; + expect(paths(`${marker}${rel}`)).toEqual(['Design/Build-SIPOC.pptx']); + expect(paths(`${marker}Path: ${rel}`)).toEqual([rel]); + expect(paths(` ${marker}${LONG}`)).toEqual([LONG]); + } + ); it('works line by line in code-block text, where list markers remain literal', () => { const text = `Wrote two files:\n- ${LONG}\n- Path: notes/a b/c.md\n`; @@ -165,6 +168,64 @@ describe('spaced file paths', () => { }); }); +/** The links master returned for `line`, in order: `[path, folder?]` pairs, each at its first occurrence. */ +const masterLinks = (line: string, ...links: Array<[string, true?]>) => + links.map(([path, folder]) => ({ path, index: line.indexOf(path), ...(folder ? { folder } : {}) })); + +describe('spaced file paths: shapes that keep master links', () => { + // grep -n, rg -n and compiler output: a `:` plus a digit inside an earlier + // word is a line suffix, so the path already ended there. + it.each([ + ['/home/me/proj/README.md:12:See docs/setup.md', '/home/me/proj/README.md', 'docs/setup.md'], + [ + ' /home/me/proj/CLAUDE.md:42:Deep detail lives in docs/architecture-invariants.md', + '/home/me/proj/CLAUDE.md', + 'docs/architecture-invariants.md', + ], + ['/home/me/proj/src/a.ts:42:5 - error in b.ts', '/home/me/proj/src/a.ts', 'b.ts'], + ['/home/me/proj/src/index.ts:10 updated config.json', '/home/me/proj/src/index.ts', 'config.json'], + ])('ends the path at a line suffix: %s', (line, first, second) => { + expect(findFilePathLinks(line)).toEqual(masterLinks(line, [first], [second])); + }); + + // A generic label introduces prose for an absolute path as much as a relative one. + it.each([ + ['Error: /home/me/proj contains a.md', '/home/me/proj', 'a.md'], + ['Warning: /home/me/proj is not tracked, see notes.md', '/home/me/proj', 'notes.md'], + ['Working directory: /home/me/proj, see README.md', '/home/me/proj', 'README.md'], + ['- Note: /tmp/out has the logs in run.log', '/tmp/out', 'run.log'], + ])('defers an absolute path after a generic label: %s', (line, folder, file) => { + expect(findFilePathLinks(line)).toEqual(masterLinks(line, [folder, true], [file])); + }); + + it('documented trade-off: an absolute spaced path after a non-path label splits as on master', () => { + const line = 'Saved the report to: /mnt/c/My Docs/a.docx'; + expect(findFilePathLinks(line)).toEqual(masterLinks(line, ['/mnt/c/My', true], ['Docs/a.docx'])); + }); + + it.each([ + ['/usr/bin/python3 script.py', 'script.py'], + ['/usr/bin/env node build.js', 'build.js'], + ['/review the changes in src/foo.ts', 'src/foo.ts'], + ])('does not reserve a command line that only looks like one path: %s', (line, file) => { + expect(findFilePathLinks(line)).toEqual(masterLinks(line, [file])); + }); + + it('still refuses an unlinkable spaced path that reads as one path to its end', () => { + expect(paths('/etc/secret files/passwords.txt')).toEqual([]); + }); + + it('does not merge a column gap into one path', () => { + const line = '/home/me/proj modified notes.md'; + expect(findFilePathLinks(line)).toEqual(masterLinks(line, ['/home/me/proj', true], ['notes.md'])); + }); + + it('reads a leading ./ as the workspace, not a segment', () => { + expect(paths('Saved to: ./My Docs/plan.md')).toEqual(['./My Docs/plan.md']); + expect(paths('Saved to: ./notes.md')).toEqual(['./notes.md']); + }); +}); + describe('terminal link provider with spaced paths', () => { type Link = { text: string; activate: (event: unknown, text: string) => void }; const TERMINAL_UI = readFileSync(resolve(import.meta.dirname, '../src/web/public/terminal-ui.js'), 'utf8'); From 3e334357e850b9c301a1a31554faf47d225bfb05 Mon Sep 17 00:00:00 2001 From: JD Date: Sat, 24 Oct 2026 19:26:36 -0400 Subject: [PATCH 10/68] fix(mobile): hold the PTY rows while the on-screen keyboard is up Every keyboard open and close resized the PTY, and Claude Code's inline renderer answers each height change by clearing the screen and reprinting only the transcript tail that fits, so rows above that tail were lost from tmux for good. Measured on Claude Code 2.1.306 with tmux 3.2a: five keyboard cycles (62x38 to 62x15 and back) left 75 of 100 numbered lines in the pane's history; with the rows held the PTY stays 62x38, no SIGWINCH reaches the pane and all 100 remain (#672). While the keyboard is up the rows captured at the show transition are held through getTerminalDimensions, columns still follow the box, and the screen, now taller than its box, is translated up by --kb-clip so the last content row sits at the bottom of the visible area. This is the one documented exception to the columns-only geometry rule: there the extra rows came from another device and nothing kept the composer in view, here they are this device's own rows held on purpose and the clip keeps the composer visible. The local-echo overlay's setRowLimit narrows the renderer's existing totalRows bound, so one place decides where a long draft stops growing downward. Only inline renderers hold, through a new registry capability keyboardHold: 'inline' (Claude alone) published to the page as window.__codemanKeyboardHoldModes. Full-height layouts such as codex, Claude's fullscreen mode (mouse tracking on, unmeasured), and remote or docker panes resize as before. The hold is released on keyboard close, a shape change, a tab switch, a session update, a reconnect, or the setting turned off. Per-device setting Keep Size Under Keyboard, default on. --- CLAUDE.md | 2 +- docs/architecture-invariants.md | 4 +- docs/wiki/Mobile-Guide.md | 6 + docs/wiki/Settings-Reference.md | 3 +- packages/xterm-zerolag-input/README.md | 1 + .../src/zerolag-input-addon.ts | 42 +- .../test/zerolag-input-addon.test.ts | 75 +++ src/config/cli-registry/schema.ts | 3 + src/config/cli-registry/stock.ts | 4 + src/config/cli-registry/types.ts | 24 + src/web/CLAUDE.md | 2 + src/web/public/app.js | 7 + src/web/public/constants.js | 62 +- src/web/public/i18n.js | 3 + src/web/public/index.html | 7 + src/web/public/mobile-handlers.js | 22 + src/web/public/settings-ui.js | 10 +- src/web/public/styles.css | 9 + src/web/public/terminal-ui.js | 127 +++- src/web/server.ts | 10 + test/cli-registry-schema.test.ts | 24 + test/keyboard-held-rows.test.ts | 619 ++++++++++++++++++ test/keyboard-hold-i18n.test.ts | 67 ++ test/mobile/keyboard.test.ts | 93 +++ test/render-index-html.test.ts | 10 + test/server-index-title.test.ts | 4 +- 26 files changed, 1226 insertions(+), 14 deletions(-) create mode 100644 test/keyboard-held-rows.test.ts create mode 100644 test/keyboard-hold-i18n.test.ts diff --git a/CLAUDE.md b/CLAUDE.md index bd2594acd..9a2f531f8 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -262,7 +262,7 @@ Codeman is a Claude Code session manager with web interface and autonomous Ralph **Gesture control** (camera hand-tracking overlay, opt-in, default OFF): `CODEMAN_GESTURE=1` makes the feature *available*; `gestureControlEnabled` turns it on. The bundle is injected by `renderIndexHtml` only when enabled, which is why that method is `async` and reads settings with `readSettings(true)` (a fresh read: a post-save reload lands inside the 2s cache TTL and would otherwise render the pre-toggle state). **Source lives in `packages/gesture-control/`; edit there, run `npm run build:gesture`, and commit the regenerated bundle** because dev serves the committed bundle with no runtime bundler. The MediaPipe wasm + model are fetched separately and gitignored. ⚠️ Keep `MP_VERSION` in `fetch-gesture-assets.mjs` in sync with `@mediapipe/tasks-vision`. → [architecture-invariants#gesture-control-the-source-package](docs/architecture-invariants.md#gesture-control-the-source-package) -**PTY and browser terminal geometry** (#464): a browser terminal whose width differs from the PTY's garbles Claude's redraws, so `syncTerminalGeometry()` (terminal-ui.js) is the ONE function that may resize the main terminal (never a bare `fitAddon.fit()`), a font change is a geometry change, and the fit is withheld wherever the SIGWINCH is. ⚠️ Resize is answered with `Session.ptyGeometry`, and a client adopts its COLUMNS only (never rows); `ptyGeometry` is null without a live pane. → [architecture-invariants#pty-and-browser-terminal-geometry](docs/architecture-invariants.md#pty-and-browser-terminal-geometry) +**PTY and browser terminal geometry** (#464): a browser terminal whose width differs from the PTY's garbles Claude's redraws, so `syncTerminalGeometry()` (terminal-ui.js) is the ONE function that may resize the main terminal (never a bare `fitAddon.fit()`), a font change is a geometry change, and the fit is withheld wherever the SIGWINCH is. ⚠️ Resize is answered with `Session.ptyGeometry`, and a client adopts its COLUMNS only (never rows); `ptyGeometry` is null without a live pane. ⚠️ The one sanctioned screen taller than its box is the phone keyboard row hold (#672, `keyboardHoldsRows`, default ON, inline renderers only via `capabilities.keyboardHold`): the PTY keeps its rows while the keyboard is up (each height change makes Claude's inline renderer drop transcript lines from tmux) and `--kb-clip` keeps the last content row at the bottom of the visible box. → [architecture-invariants#pty-and-browser-terminal-geometry](docs/architecture-invariants.md#pty-and-browser-terminal-geometry) **Service worker precache** (`sw.js` + `scripts/build.mjs`): `BUILD_ID` and `HASHED_ASSETS` are build-generated and the build THROWS unless each declaration appears exactly once; `caches.match` must pass `ignoreSearch: true` because `cacheBustAssets` appends `?v=` to hashed names. → [architecture-invariants#service-worker-precache-and-cache-key](docs/architecture-invariants.md#service-worker-precache-and-cache-key) diff --git a/docs/architecture-invariants.md b/docs/architecture-invariants.md index 7a743bb62..766506f4e 100644 --- a/docs/architecture-invariants.md +++ b/docs/architecture-invariants.md @@ -1151,7 +1151,9 @@ Tests: `test/mobile-prompt-composer.test.ts` (in the CI gate, deliberately not u ### PTY and browser terminal geometry -**The PTY and the browser terminal must never disagree about size** (issue #464, `syncTerminalGeometry()` in terminal-ui.js): Claude Code's TUI wraps its frame at the width the PTY reported and erases the previous frame by walking the cursor up the number of rows it BELIEVES that frame occupied. A browser terminal of a different width makes each logical line take more physical rows than Ink counted, so `eraseLines(n)` clears too few and the new frame paints over rows nothing erased — the doubled lines and half-overwritten prose in #464, **measured** against this repo's xterm (a 120-column PTY against a 62-column terminal renders every wrapped line twice; `test/terminal-pty-geometry.test.ts` pins it, and pins the clean render at matching widths so the assertion cannot pass against code that fixes nothing). ⚠️ **`fitAddon.fit()` is NOT the way to resize this terminal.** It resizes xterm to `proposeDimensions()` RAW while every server-facing path reports those floored at 40x10, so whenever the floor bit the two diverged silently — **measured in Chrome at 430px**: font size 44 proposed 13 columns, the server was told 40, and xterm stayed at 13. `syncTerminalGeometry()` fits, floors and applies as one step and is the ONE function that may change the size; `test/terminal-pty-geometry.test.ts` sweeps the seven modules that touch the main terminal (terminal-ui, mobile-handlers, app, ralph-panel, settings-ui, tab-rail-resize, notification-manager) for a bare fit; the split pane and teammate terminals own their own sizing and are out of scope. ⚠️ **Withhold the fit wherever you withhold the SIGWINCH.** `throttledResize` (virtual keyboard up) and `sendResize` (session detached into its own window) used to reflow locally and skip only the server write, which is the one combination that cannot be right. ⚠️ **A font change is a geometry change**: `setFontSize`/`setFontFamily`/`setFontWeight` move the cell size and told the server nothing, so raising the font on a phone left the CLI wrapping at the old column count (`_refitAfterCellSizeChange`). ⚠️ **Resize is no longer write-only.** `Session.resize` DECLINES a small-viewport request while a desktop connection holds an active sizing claim and says nothing, so both transports now answer with `Session.ptyGeometry` (`{"t":"zc"}` on the socket, the body of the resize POST) and `_onPtyGeometryReport` adopts it — a terminal that keeps a WIDTH the PTY refused renders GARBLED, not merely wrong-sized. ⚠️ While that refusal stands (`_paneWidthRefused`), a resize ASKS for the container's width without applying it (`_geometryForResizeRequest`: rows follow the container, columns stay at the PTY's): fitting first re-wrapped the whole buffer to the container and back on every 30s mobile retry, and ran the scrollback clear for a resize that brings no redraw. `selectSession` clears the flag, since it belongs to the previous pane. ⚠️ **`ptyGeometry` is null without a live pane**, never the field values: they are seeded at spawn (`_notePtySpawnGeometry`, so a reattached pane reports its tmux window's real size) and moved by `resize()`, but a dead-pane session still holds the constructor defaults of 120x40 or a gone pane's size — reporting those made a client adopt a size no process was ever told and claim another device owned the pane when none existed. ⚠️ **COLUMNS ONLY.** Adopting the PTY's ROWS was a regression: a phone taking a desktop's 43 rows into a viewport with room for 18 painted an `.xterm-screen` far taller than its container, xterm's own viewport then had nothing to scroll, and the CLI's input line sat below the container with no gesture able to reach it — output visible, typing invisible, for as long as the claim stayed hot. Width is the axis the wrap arithmetic depends on; rows only decide how much is on screen, and keeping the local count keeps the composer at the bottom of a viewport that scrolls. ⚠️ **`.term-overflows-x` keys on what does not FIT, not on a PTY mismatch**, because the 40-column floor widens the terminal past a narrow container with the PTY agreeing throughout (measured at 360px: font 18 paints 433px, font 24 paints 578px — 38% unreachable, and `increaseFontSize` reaches 24 in two taps). `_syncTerminalOverflowAffordance()` MEASURES `.xterm-screen` against the container on the next frame rather than deriving it from cell arithmetic. ⚠️ That rule sets **both** overflow axes — mobile.css loads later with `.terminal-container { overflow: visible }`, and a bare `overflow-x` would leave overflow-y computing to `auto`, handing the browser a vertical scroll container the terminal's touch handler does not know about — and sets **no `touch-action`**: the terminal's own touchmove handler pans it (`canPanHorizontally`), because `touchstart` preventDefault()s every 'content' tap and that cancels a native `pan-x` before it starts (measured: a 140px swipe reached scrollLeft 141 without that preventDefault and 0 with it). Removing the class returns `scrollLeft` to 0, so a resolved mismatch cannot leave the pane parked off-screen. +**The PTY and the browser terminal must never disagree about size** (issue #464, `syncTerminalGeometry()` in terminal-ui.js): Claude Code's TUI wraps its frame at the width the PTY reported and erases the previous frame by walking the cursor up the number of rows it BELIEVES that frame occupied. A browser terminal of a different width makes each logical line take more physical rows than Ink counted, so `eraseLines(n)` clears too few and the new frame paints over rows nothing erased — the doubled lines and half-overwritten prose in #464, **measured** against this repo's xterm (a 120-column PTY against a 62-column terminal renders every wrapped line twice; `test/terminal-pty-geometry.test.ts` pins it, and pins the clean render at matching widths so the assertion cannot pass against code that fixes nothing). ⚠️ **`fitAddon.fit()` is NOT the way to resize this terminal.** It resizes xterm to `proposeDimensions()` RAW while every server-facing path reports those floored at 40x10, so whenever the floor bit the two diverged silently — **measured in Chrome at 430px**: font size 44 proposed 13 columns, the server was told 40, and xterm stayed at 13. `syncTerminalGeometry()` fits, floors and applies as one step and is the ONE function that may change the size; `test/terminal-pty-geometry.test.ts` sweeps the seven modules that touch the main terminal (terminal-ui, mobile-handlers, app, ralph-panel, settings-ui, tab-rail-resize, notification-manager) for a bare fit; the split pane and teammate terminals own their own sizing and are out of scope. ⚠️ **Withhold the fit wherever you withhold the SIGWINCH.** `throttledResize` (virtual keyboard up) and `sendResize` (session detached into its own window) used to reflow locally and skip only the server write, which is the one combination that cannot be right. ⚠️ **A font change is a geometry change**: `setFontSize`/`setFontFamily`/`setFontWeight` move the cell size and told the server nothing, so raising the font on a phone left the CLI wrapping at the old column count (`_refitAfterCellSizeChange`). ⚠️ **Resize is no longer write-only.** `Session.resize` DECLINES a small-viewport request while a desktop connection holds an active sizing claim and says nothing, so both transports now answer with `Session.ptyGeometry` (`{"t":"zc"}` on the socket, the body of the resize POST) and `_onPtyGeometryReport` adopts it — a terminal that keeps a WIDTH the PTY refused renders GARBLED, not merely wrong-sized. ⚠️ While that refusal stands (`_paneWidthRefused`), a resize ASKS for the container's width without applying it (`_geometryForResizeRequest`: rows follow the container, columns stay at the PTY's): fitting first re-wrapped the whole buffer to the container and back on every 30s mobile retry, and ran the scrollback clear for a resize that brings no redraw. `selectSession` clears the flag, since it belongs to the previous pane. ⚠️ **`ptyGeometry` is null without a live pane**, never the field values: they are seeded at spawn (`_notePtySpawnGeometry`, so a reattached pane reports its tmux window's real size) and moved by `resize()`, but a dead-pane session still holds the constructor defaults of 120x40 or a gone pane's size — reporting those made a client adopt a size no process was ever told and claim another device owned the pane when none existed. ⚠️ **COLUMNS ONLY** (one documented exception, the keyboard row hold in the next paragraph). Adopting the PTY's ROWS was a regression: a phone taking a desktop's 43 rows into a viewport with room for 18 painted an `.xterm-screen` far taller than its container, xterm's own viewport then had nothing to scroll, and the CLI's input line sat below the container with no gesture able to reach it — output visible, typing invisible, for as long as the claim stayed hot. Width is the axis the wrap arithmetic depends on; rows only decide how much is on screen, and keeping the local count keeps the composer at the bottom of a viewport that scrolls. ⚠️ **`.term-overflows-x` keys on what does not FIT, not on a PTY mismatch**, because the 40-column floor widens the terminal past a narrow container with the PTY agreeing throughout (measured at 360px: font 18 paints 433px, font 24 paints 578px — 38% unreachable, and `increaseFontSize` reaches 24 in two taps). `_syncTerminalOverflowAffordance()` MEASURES `.xterm-screen` against the container on the next frame rather than deriving it from cell arithmetic. ⚠️ That rule sets **both** overflow axes — mobile.css loads later with `.terminal-container { overflow: visible }`, and a bare `overflow-x` would leave overflow-y computing to `auto`, handing the browser a vertical scroll container the terminal's touch handler does not know about — and sets **no `touch-action`**: the terminal's own touchmove handler pans it (`canPanHorizontally`), because `touchstart` preventDefault()s every 'content' tap and that cancels a native `pan-x` before it starts (measured: a 140px swipe reached scrollLeft 141 without that preventDefault and 0 with it). Removing the class returns `scrollLeft` to 0, so a resolved mismatch cannot leave the pane parked off-screen. + +**The one exception to COLUMNS ONLY: rows held under the on-screen keyboard** (#672, `keyboardHoldsRows`, per-device, default ON): every PTY height change makes Claude Code's inline renderer clear the screen and reprint only the transcript tail that fits the new height, so the rows above that tail are gone from tmux and older ones stay in history as stale copies, and the phone keyboard opening and closing was such a change each time. **Measured** on Claude Code 2.1.306 with tmux 3.2a, a fresh session already at the phone's size printing 100 numbered lines: five keyboard cycles (PTY 62x38 to 62x15 and back, one SIGWINCH per transition) left 75 of the 100 in `tmux capture-pane` history; with the rows held the PTY stayed 62x38, no SIGWINCH reached the pane and all 100 remained. So `KeyboardHandler` captures `terminal.rows` on the show transition (`app._keyboardHeldRows`) and `getTerminalDimensions()` never reports fewer rows while the keyboard is visible; columns still follow the container, so the #464 width rule is untouched. ⚠️ **Why this is not the regression the COLUMNS ONLY note describes.** There the extra rows came from ANOTHER device's sizing claim, adopted from `ptyGeometry`, and nothing kept the composer in view: an `.xterm-screen` taller than its container with the input line below it and no gesture able to reach it. Here the rows are this device's own keyboard-free rows, held on purpose only while the keyboard is up, and the screen is translated up by `--kb-clip` (`_applyKeyboardClip()`, pure math in `keyboardClipPx()` in constants.js) so the last content row sits at the bottom of the visible box: following live output, the cursor counts only within `KEYBOARD_CLIP_CURSOR_REACH` (2) rows below the last content (a TUI that parks its cursor far below what it drew must not scroll its content away); reading history, the offset follows the scroll position so the top of the scrollback stays reachable. The local-echo overlay is bounded to the visible rows through `setRowLimit()`, which narrows the overlay renderer's existing `totalRows` bound rather than adding a second one, so a long draft grows upward from the last row the user can see. `reconcilePtyGeometry` still adopts columns only: nothing here takes rows from the PTY's report. `test/keyboard-held-rows.test.ts` pins the composer row inside the visible box with the rows held, against a real headless xterm and the shipped `_applyKeyboardClip()`. ⚠️ Holding goes through `getTerminalDimensions()`, never one caller, so the fit, the PTY resize and the capture-geometry comparisons agree; `syncTerminalGeometry()` skips `fitAddon.fit()` while held, so xterm never visits the short size. ⚠️ A shape change (rotation, fold) releases the hold AND arms the viewport settle (`_scheduleViewportSettle()`), which refits and tells the PTY: with the keyboard still up `throttledResize` stands aside and a rotation alone arms nothing, so without it xterm kept the held rows with no clip and the composer sat under the keyboard until it closed. A rotation with no hold arms nothing, as before. ⚠️ ONLY inline renderers qualify (`_keyboardHoldEligible()`): the registry's `capabilities.keyboardHold: 'inline'` (Claude alone; read through `window.__codemanKeyboardHoldModes`, never an id literal), never while `cliMouseTracking` reports Claude's fullscreen mode (it lays out the full height and has not been measured losing lines, so it resizes as before), never remote or docker (their local pane hosts a nested client). A full-height layout (codex: banner at the top, composer pinned to the bottom) cannot fit both ends above the keyboard, which is what the first, unscoped version got wrong. ⚠️ The hold lives on the shared terminal, so `_reconcileKeyboardHold()` releases it whenever the active session stops qualifying (tab switch, a `session:updated`, a reconnect, the setting turned off), and keyboard close releases it too; it never re-holds with the keyboard already up, so a session that becomes eligible mid-keyboard keeps the short rows until the keyboard closes. ### Terminal resilience: replay clears, renderer liveness, fetch deadlines diff --git a/docs/wiki/Mobile-Guide.md b/docs/wiki/Mobile-Guide.md index a61714601..c090c6bf8 100644 --- a/docs/wiki/Mobile-Guide.md +++ b/docs/wiki/Mobile-Guide.md @@ -153,6 +153,12 @@ already writing in the editor. - The terminal and toolbar shift up when the keyboard opens, tracked through the browser's visual viewport rather than guessed. +- **Opening the keyboard does not resize a Claude session.** Each resize made Claude reprint + its screen at the new height and drop transcript lines from the session's history, so for a + local Claude session the terminal keeps its size while the keyboard is up and the view + scrolls instead, with the prompt and the line under it just above the keyboard. Codex, + Claude's fullscreen mode, remote and Docker sessions still resize. **Keep Size Under + Keyboard** in App Settings turns it off on this device. - **Two ways to dismiss the keyboard**: tap outside the terminal on inert space, or tap twice on inert terminal content. Tapping a control never dismisses it, and tapping the prompt row keeps focus so you can place the caret. diff --git a/docs/wiki/Settings-Reference.md b/docs/wiki/Settings-Reference.md index 19d1676be..357a4a9e7 100644 --- a/docs/wiki/Settings-Reference.md +++ b/docs/wiki/Settings-Reference.md @@ -21,7 +21,7 @@ desktop. | Category | Examples | | ----------------------- | ------------------------------------------------------------------------------- | -| **Per-device, local** | Skin, WebGL renderer, local echo, CJK input, extended keyboard bar, File Viewer and Cron header buttons. Never sent to the server at all. | +| **Per-device, local** | Skin, WebGL renderer, local echo, keep size under keyboard, CJK input, extended keyboard bar, File Viewer and Cron header buttons. Never sent to the server at all. | | **Per-device policy** | Most `show*` toggles, plan usage chip, language. Stored server-side, but a device only takes the server value when it has no local one of its own. | | **Synced** | Models, effort, CLI options, notification preferences, voice settings, display name, the agent skill and approvals toggles. | @@ -46,6 +46,7 @@ supervised by systemd or launchd; npm installs report as non-updatable. See | Setting | Default | Notes | | ----------------------------- | -------------------- | --------------------------------------------------------------------- | | Local Echo | On for touch devices | Paints keystrokes locally and flushes on Enter. See [Input And Voice](Input-And-Voice). | +| Keep Size Under Keyboard | On | For local Claude sessions, the on-screen keyboard no longer resizes the session: the terminal keeps its rows and the view scrolls so the prompt sits just above the keyboard. Resizing made Claude reprint its screen and drop transcript lines from history. Codex, Claude's fullscreen mode, remote and Docker sessions resize as before. See [Mobile Guide](Mobile-Guide). | | CJK Input | Off | IME composition through a dedicated text field. | | Extended Keyboard Bar | Per device | Which accessory bar phones get. Shell sessions override it while they are active. | | Wheel Scrolls Local History | Off | Keeps the wheel on the local buffer instead of forwarding it to the CLI. | diff --git a/packages/xterm-zerolag-input/README.md b/packages/xterm-zerolag-input/README.md index 1129a8a85..526105944 100644 --- a/packages/xterm-zerolag-input/README.md +++ b/packages/xterm-zerolag-input/README.md @@ -205,6 +205,7 @@ Implements the xterm.js `ITerminalAddon` interface. It deliberately does **not** | `removeChar()` | `'pending'` \| `'flushed'` \| `false` | Remove the last character and drop any IME composition. See [backspace handling](#backspace-handling). | | `clear()` | `void` | Clear all state, the composition included, and hide the overlay. Call on Enter, Ctrl+C, Escape. | | `setComposition(text)` | `void` | Show text an IME is still composing as an underlined tail after the typed text. Pass `''` to remove it. See [IME composition](#ime-composition). | +| `setRowLimit(limit)` | `void` | Narrow the row bound the overlay keeps itself on screen with (normally `terminal.rows`) to the first `limit` screen rows, so it grows upward from the last of them; `null` restores all rows, and a limit above `terminal.rows` changes nothing. For hosts that keep the terminal taller than the box showing it, e.g. while an on-screen keyboard covers the bottom. | ### Backspace handling diff --git a/packages/xterm-zerolag-input/src/zerolag-input-addon.ts b/packages/xterm-zerolag-input/src/zerolag-input-addon.ts index 3993de8bf..e1320a989 100644 --- a/packages/xterm-zerolag-input/src/zerolag-input-addon.ts +++ b/packages/xterm-zerolag-input/src/zerolag-input-addon.ts @@ -69,6 +69,9 @@ export class ZerolagInputAddon implements XtermAddon { private _bufferDetectDone = false; // IME text still being composed: drawn after the pending text, never sent. private _composition = ''; + // Rows from the top of the screen that are actually visible (see setRowLimit); + // null means all of them. + private _rowLimit: number | null = null; // Render cache private _lastRenderKey = ''; @@ -329,6 +332,35 @@ export class ZerolagInputAddon implements XtermAddon { } } + /** + * Bound the layout to the first `limit` rows of the screen, or to all of them + * with `null`. It narrows the bound the overlay already keeps itself on + * screen with (`totalRows`, see _layoutRows), never adds a second one. + * + * For a host that keeps the terminal taller than the box showing it and + * scrolls the screen element instead (e.g. holding the PTY's rows while an + * on-screen keyboard covers the bottom): the composer must grow upward from + * the last row the user can SEE, not from the terminal's last row, which is + * under the keyboard. + */ + setRowLimit(limit: number | null): void { + const next = limit !== null && Number.isFinite(limit) && limit > 0 ? Math.trunc(limit) : null; + if (next === this._rowLimit) return; + this._rowLimit = next; + this.rerender(); + } + + /** + * The `totalRows` bound handed to renderOverlay: the one place that decides + * where the block stops growing downward. The terminal's rows, narrowed by + * setRowLimit when the host shows fewer; a limit above the terminal's rows + * changes nothing. + */ + private _layoutRows(): number { + const rows = this._terminal!.rows; + return this._rowLimit !== null ? Math.min(this._rowLimit, rows) : rows; + } + /** * Re-read font properties from the terminal and re-render. * Call after font size changes, theme changes, etc. @@ -621,9 +653,11 @@ export class ZerolagInputAddon implements XtermAddon { // Skip redundant re-renders — include text content to detect // same-length changes (e.g., setFlushed with different text) // `rows` is part of the key: the layout is clamped to the visible rows - // (see renderOverlay), so a keyboard opening — which changes rows without - // changing the text — must not be skipped as a redundant render. - const renderKey = `${displayText}:${compositionStart}:${startCol}:${activePrompt.row}:${activePrompt.col}:${totalCols}:${this._terminal.rows}:${this._flushedOffset}`; + // (see renderOverlay), so a keyboard opening, which changes rows or the + // row limit without changing the text, must not be skipped as a + // redundant render. + const totalRows = this._layoutRows(); + const renderKey = `${displayText}:${compositionStart}:${startCol}:${activePrompt.row}:${activePrompt.col}:${totalCols}:${totalRows}:${this._flushedOffset}`; if (renderKey === this._lastRenderKey && this._overlay.style.display !== 'none') return; this._lastRenderKey = renderKey; @@ -671,7 +705,7 @@ export class ZerolagInputAddon implements XtermAddon { charTop, charHeight, promptRow: activePrompt.row, - totalRows: this._terminal.rows, + totalRows, font: this._font, showCursor: this._options.showCursor, cursorColor, diff --git a/packages/xterm-zerolag-input/test/zerolag-input-addon.test.ts b/packages/xterm-zerolag-input/test/zerolag-input-addon.test.ts index e46538687..4718cb253 100644 --- a/packages/xterm-zerolag-input/test/zerolag-input-addon.test.ts +++ b/packages/xterm-zerolag-input/test/zerolag-input-addon.test.ts @@ -756,3 +756,78 @@ describe('viewport scrolled away from the bottom', () => { expect(overlay.style.display).toBe('none'); }); }); + +describe('setRowLimit', () => { + function rowLimitSetup(promptRow = 10) { + const lines = Array.from({ length: 24 }, (_, i) => (i === promptRow ? '$ ' : '')); + const mock = createMockTerminal({ buffer: { lines }, cols: 20, rows: 24, cellHeight: 17 }); + const addon = new ZerolagInputAddon({ prompt: { type: 'character', char: '$', offset: 2 } }); + mock.terminal.loadAddon(addon); + cleanups.push(() => { + addon.dispose(); + mock.cleanup(); + }); + const overlay = () => mock.terminal.element.querySelector('.xterm-screen')!.lastElementChild as HTMLDivElement; + return { addon, overlay }; + } + + it('grows the composer upward from the last VISIBLE row, not the terminal last row', () => { + const { addon, overlay } = rowLimitSetup(); + addon.appendText('x'.repeat(50)); // 3 lines at 20 cols from the prompt on row 10 + expect(overlay().style.top).toBe(`${10 * 17}px`); + addon.setRowLimit(12); + expect(overlay().style.top).toBe(`${9 * 17}px`); + }); + + it('null restores the full terminal height, and junk reads as null', () => { + const { addon, overlay } = rowLimitSetup(); + addon.appendText('x'.repeat(50)); + addon.setRowLimit(12); + addon.setRowLimit(null); + expect(overlay().style.top).toBe(`${10 * 17}px`); + addon.setRowLimit(12); + addon.setRowLimit(Number.NaN); + expect(overlay().style.top).toBe(`${10 * 17}px`); + }); + + // setRowLimit narrows the SAME bound renderOverlay already keeps the block on + // screen with (RenderParams.totalRows, filled from terminal.rows), so there is + // one place that decides where the block stops growing downward. + it('narrows the terminal-rows bound and never widens it', () => { + const { addon, overlay } = rowLimitSetup(22); + addon.appendText('x'.repeat(50)); // 3 lines from row 22 of 24: the terminal bound lifts it to row 21 + expect(overlay().style.top).toBe(`${21 * 17}px`); + addon.setRowLimit(24); + expect(overlay().style.top).toBe(`${21 * 17}px`); + addon.setRowLimit(100); + expect(overlay().style.top).toBe(`${21 * 17}px`); + addon.setRowLimit(23); + expect(overlay().style.top).toBe(`${20 * 17}px`); + }); + + it('leaves a block that fits above the limit where it is', () => { + const { addon, overlay } = rowLimitSetup(); + addon.appendText('x'.repeat(50)); + addon.setRowLimit(13); // rows 10..12 are exactly the three lines + expect(overlay().style.top).toBe(`${10 * 17}px`); + }); + + it('keeps the TAIL of a block taller than the limit', () => { + const { addon, overlay } = rowLimitSetup(); + addon.appendText('x'.repeat(50)); // lines of 18, 20 and 12 characters + addon.setRowLimit(2); + expect(overlay().style.top).toBe('0px'); + const xs = [...overlay().querySelectorAll(':scope > div')].map( + (line) => (line.textContent ?? '').replace(/[^x]/g, '').length + ); + expect(xs).toEqual([20, 12]); + }); + + it('holds across later keystrokes, not only the re-render it triggers', () => { + const { addon, overlay } = rowLimitSetup(); + addon.appendText('x'.repeat(50)); + addon.setRowLimit(12); + addon.appendText('y'); + expect(overlay().style.top).toBe(`${9 * 17}px`); + }); +}); diff --git a/src/config/cli-registry/schema.ts b/src/config/cli-registry/schema.ts index de4a5542f..3cd335288 100644 --- a/src/config/cli-registry/schema.ts +++ b/src/config/cli-registry/schema.ts @@ -301,6 +301,9 @@ const capabilitiesSchema = z // the CLI to page its own transcript. Distinct from wheelForward (SGR reports). // Optional: absent means no paging, the safe default for an unmeasured CLI. transcriptPageKeys: z.boolean().optional(), + // Keep the PTY's rows while a phone keyboard is up (inline renderers only). + // Optional: absent means the keyboard resizes, the safe default. + keyboardHold: z.literal('inline').optional(), keyboardAccessory: z.enum(['agent', 'shell']), privilegedCommandGate: z.boolean(), startMode: z.enum(['interactive', 'shell']), diff --git a/src/config/cli-registry/stock.ts b/src/config/cli-registry/stock.ts index 1b0e00ea9..41425f7bc 100644 --- a/src/config/cli-registry/stock.ts +++ b/src/config/cli-registry/stock.ts @@ -289,6 +289,10 @@ const CLAUDE: CliEntry = { // forwarded (older CLI, inline renderer, the local-scrollback opt-out) a scroll is // turned into PageUp/PageDown, which Claude pages its transcript on (#205). transcriptPageKeys: true, + // Claude's default renderer is inline, and each keyboard-driven PTY height change made + // it reprint only the tail that fits, erasing transcript rows from tmux (measured on + // 2.1.306: 25 of 100 lines over five keyboard cycles). See CliCapabilities.keyboardHold. + keyboardHold: 'inline', keyboardAccessory: 'agent', privilegedCommandGate: false, startMode: 'interactive', diff --git a/src/config/cli-registry/types.ts b/src/config/cli-registry/types.ts index 81fd48b44..ede7dd342 100644 --- a/src/config/cli-registry/types.ts +++ b/src/config/cli-registry/types.ts @@ -563,6 +563,30 @@ export interface CliCapabilities { * Absent means no paging, so a CLI nobody has measured keeps plain local scrolling. */ transcriptPageKeys?: boolean; + /** + * `'inline'`: the CLI renders inline (its content stacks at the bottom of the + * main screen), so while a phone's on-screen keyboard is up the browser keeps + * the PTY's rows and scrolls the view instead of resizing (`keyboardHoldsRows`, + * terminal-ui.js `_keyboardHoldEligible`). Read by the frontend through + * `window.__codemanKeyboardHoldModes`, which the server builds from this field. + * + * Why it exists: every PTY height change makes Claude Code's inline renderer + * clear the screen and reprint only the tail that fits (#672). Measured on + * Claude Code 2.1.306 with tmux 3.2a, a fresh session already at the phone's + * size printing 100 numbered lines: five keyboard open/close cycles (PTY + * 62x38 to 62x15 and back, one SIGWINCH per transition) left 75 of the 100 in + * `tmux capture-pane` history, the other 25 gone for good; with the rows held + * the PTY stayed 62x38, no SIGWINCH reached the pane and all 100 remained. + * + * ⚠ Only for a renderer that is inline. A CLI that lays out the FULL height + * (codex: banner at the top, composer pinned to the bottom) cannot fit both + * ends above the keyboard and must be resized instead, so it stays absent. + * Absent means resize, the safe default for an unmeasured CLI. Even when set, + * the hold stands aside while the CLI reports mouse tracking (Claude's + * fullscreen mode, which lays out the full height and has not been measured + * losing lines, so it resizes as before), and for remote and docker sessions. + */ + keyboardHold?: 'inline'; keyboardAccessory: 'agent' | 'shell'; /** Multi-user: this CLI is a raw shell, so its commands need the privileged gate. */ privilegedCommandGate: boolean; diff --git a/src/web/CLAUDE.md b/src/web/CLAUDE.md index 1c884dfce..4a2bc7bbc 100644 --- a/src/web/CLAUDE.md +++ b/src/web/CLAUDE.md @@ -66,6 +66,8 @@ Frontend JS modules have `@fileoverview` with `@dependency`/`@loadorder` tags. L **Folding devices: a fold is not a keyboard, and dialogs avoid the hinge**: ⚠️ in `handleViewportResize()`, a visual-viewport resize that changes the WIDTH is a shape change (rotation, fold) and must never be read as the keyboard; it re-baselines instead, or `keyboardVisible` latches with no keyboard. ⚠️ `init()` must seed `lastViewportWidth`. ⚠️ With the keyboard up, a shape change baselines to `window.innerHeight`, never the shrunk visual height. ⚠️ The hinge is reserved via `--fold-inline-end`/`--fold-block-end` (0px when unfolded): each overlay fold rule must re-state its own gutter, a base gutter overridden by a later `@media` block needs its own fold restatement there on a zero base, and dialogs use physical sides (left/top segment) in every language. Guard: `test/foldable-layout.test.ts`. → [architecture-invariants#folding-devices](../../docs/architecture-invariants.md#folding-devices) +**Rows held under the on-screen keyboard** (#672, `keyboardHoldsRows`, per-device, default ON): while the keyboard is up, an eligible session keeps its PTY rows (`app._keyboardHeldRows`, captured by `KeyboardHandler` on the show transition and read through `getTerminalDimensions()`) and `.xterm-screen` is translated up by `--kb-clip` (`_applyKeyboardClip()`, `keyboardClipPx()`), with the local-echo overlay bounded through `setRowLimit()`. ⚠️ Eligibility is `_keyboardHoldEligible()` only (the published `window.__codemanKeyboardHoldModes`, never an id; never with `cliMouseTracking`, remote or docker). ⚠️ Every path that can change eligibility calls `_reconcileKeyboardHold()` (tab switch, `session:updated`, reconnect, settings save), keyboard hide and a shape change release it (a shape change also arms the viewport settle, the only refit while the keyboard is up), and nothing re-holds with the keyboard already up. → [architecture-invariants#pty-and-browser-terminal-geometry](../../docs/architecture-invariants.md#pty-and-browser-terminal-geometry) + **WebGL renderer toggle** (`webglRendererEnabled`, per-device): the GPU-stall watchdog's sticky `codeman-webgl-disabled` marker survives page loads and is cleared only by an explicit OFF→ON save or `?webgl=force`. `?nowebgl` forces the DOM renderer per-load. → [architecture-invariants#webgl-renderer-toggle](../../docs/architecture-invariants.md#webgl-renderer-toggle) **Shell keyboard accessory bar + one-shot Ctrl** (`keyboard-accessory.js`): a shell-mode session swaps the mobile accessory bar for terminal controls; `setMode()` records `extendedKeyboardBar` as the base layout and `refreshForActiveSession()` resolves base-vs-shell. ⚠️ Ctrl is a one-shot modifier applied in `terminal.onData` (after `shouldSuppressTerminalQueryResponse`, before every send path), and must skip `isTerminalFocusOrMouseReport()` chunks. ⚠️ It must disarm on use, second tap, any other accessory key, session switch, keyboard dismissal and layout swap. ⚠️ `_handleCjkInput()` must apply it too (the CJK textarea bypasses onData). Mapping: `ctrlByteFor()`. ⚠️ mobile.css's light-skin repaint must keep excluding `.accessory-btn:not(.armed)` or the armed state is invisible. → [architecture-invariants#shell-keyboard-accessory-bar-and-one-shot-ctrl](../../docs/architecture-invariants.md#shell-keyboard-accessory-bar-and-one-shot-ctrl) diff --git a/src/web/public/app.js b/src/web/public/app.js index db6551312..c26ded85a 100644 --- a/src/web/public/app.js +++ b/src/web/public/app.js @@ -2452,6 +2452,8 @@ class CodemanApp { if (session.id === this.activeSessionId && session.tokens) { this.updateRespawnTokens(session.tokens); } + // cliMouseTracking (Claude's fullscreen mode) ends a keyboard row hold. + if (session.id === this.activeSessionId) this._reconcileKeyboardHold?.(); // Update parentSessionName for any subagents belonging to this session // (fixes stale name display after session rename) this.updateSubagentParentNames(session.id); @@ -5166,6 +5168,8 @@ class CodemanApp { // backoff reconnect, but if it is not on this session (dead socket, or // a give-up) nothing else would re-establish it from here. if (this._wsSessionId !== restoreId) this._connectWs(restoreId); + // The fresh session record may no longer qualify for a keyboard row hold. + this._reconcileKeyboardHold?.(); void this._onSessionNeedsRefresh({ id: restoreId }); // The split pane's second terminal reconnects on its own backoff (up to // 10 s between tries); the server is back now, so skip the wait. A no-op @@ -9703,6 +9707,9 @@ class CodemanApp { this._cleanupPreviousSession(sessionId); this.activeSessionId = sessionId; this._activateFileBrowserSession?.(sessionId); + // A keyboard row hold belongs to the session it was taken for: release it here + // if this one resizes instead (the geometry work below then fits it). + this._reconcileKeyboardHold?.({ resize: false }); // Repaint the partial-history banner for the tab being switched TO. The // replay paths refresh it when their fetch lands; without this the previous // session's notice stays on screen until then (#258). The switch lands at diff --git a/src/web/public/constants.js b/src/web/public/constants.js index eb16edcde..ca9939001 100644 --- a/src/web/public/constants.js +++ b/src/web/public/constants.js @@ -3349,7 +3349,9 @@ function clampTerminalDimensions(proposed) { * could reach it. Output visible, typing invisible, for as long as the claim * stayed hot. Width is the axis the wrap arithmetic depends on; rows only * decide how much is on screen at once, and keeping the local row count keeps - * the composer at the bottom of a viewport that scrolls. + * the composer at the bottom of a viewport that scrolls. The one deliberate + * screen taller than its box is this device's own rows held under the + * on-screen keyboard, which keyboardClipPx keeps the composer in view for. * * @param {{cols: number, rows: number}|null} local - what xterm currently holds * @param {{cols: number, rows: number}|null} pty - what the server just reported @@ -3361,6 +3363,62 @@ function reconcilePtyGeometry(local, pty) { return { adopt: true, cols: pty.cols }; } +/** Rows below the last content within which the cursor still counts (keyboardClipPx). */ +const KEYBOARD_CLIP_CURSOR_REACH = 2; + +/** + * How far to scroll the terminal SCREEN up while its rows are held under the + * on-screen keyboard (setting `keyboardHoldsRows`). + * + * Every PTY height change makes Claude Code clear the screen and reprint only + * the transcript tail that fits, so rows above that tail are lost from tmux and + * older ones stay in history as stale copies. The keyboard opening and closing + * was such a change each time. Instead the rows stay as they were and + * `.xterm-screen` (taller than its box now) is translated up by this many px. + * + * This is the one deliberate exception to "a screen taller than its container + * hides the input line" (see reconcilePtyGeometry): the offset below is what + * keeps the live content in view. + * + * - Following live output: the last row with content sits at the bottom of the + * box. A fresh shell prompting on row 1 shows the top; Claude's footer under + * its composer stays visible. The cursor counts only when it is right below + * that content (a prompt or composer line still empty): a TUI that parks its + * cursor far below what it drew (codex's dialogs, with the cursor on the last + * row of an otherwise blank screen) must not scroll its content out of view. + * - Reading history: the offset follows the scroll position, so the top of the + * scrollback is reachable and scrolling stays continuous. + * + * ponytail: recomputed on every render with no hysteresis; add some if a TUI's + * mid-redraw frames ever make the view jitter. + * + * @param {{rows: number, cellHeight: number, viewHeight: number, cursorRow: number, + * lastContentRow: number, following: boolean, viewportY: number, baseY: number}} input + * lastContentRow: the last non-blank screen row, or -1 when the screen is blank + * @returns {number} px, between 0 and the screen's overflow + */ +function keyboardClipPx(input) { + const { rows, cellHeight, viewHeight, cursorRow, lastContentRow, following, viewportY, baseY } = input; + if (!(cellHeight > 0) || !(viewHeight > 0) || !(rows > 0)) return 0; + const max = Math.max(0, rows * cellHeight - viewHeight); + if (max === 0) return 0; + if (!following && baseY > 0) return Math.round(max * Math.min(1, Math.max(0, viewportY / baseY))); + const cursorCounts = lastContentRow < 0 || cursorRow <= lastContentRow + KEYBOARD_CLIP_CURSOR_REACH; + const contentBottomRow = cursorCounts ? Math.max(cursorRow, lastContentRow) : lastContentRow; + const bottom = (Math.min(rows - 1, Math.max(0, contentBottomRow)) + 1) * cellHeight; + return Math.round(Math.min(max, Math.max(0, bottom - viewHeight))); +} + +/** + * How many screen rows, from the top, are fully visible at a given offset: the + * local-echo overlay grows upward from the last of them (`setRowLimit`). + * @returns {number} + */ +function keyboardVisibleRowLimit(clipPx, viewHeight, cellHeight) { + if (!(cellHeight > 0)) return 0; + return Math.max(1, Math.floor((clipPx + viewHeight) / cellHeight)); +} + /** * Which session does a dashboard URL's fragment ask for? Another page that * holds the dashboard's window, such as a task board, points it at @@ -3491,6 +3549,8 @@ if (typeof window !== 'undefined') { window.CodemanTerminalGeometry = { clampTerminalDimensions, reconcilePtyGeometry, + keyboardClipPx, + keyboardVisibleRowLimit, TERMINAL_MIN_COLS, TERMINAL_MIN_ROWS, }; diff --git a/src/web/public/i18n.js b/src/web/public/i18n.js index 0f17c78d4..045273228 100644 --- a/src/web/public/i18n.js +++ b/src/web/public/i18n.js @@ -667,6 +667,9 @@ 'Weight for bold terminal text. Only useful with a family carrying something heavier than its bold face.': '终端粗体文本的字重。仅当字体提供比其粗体更重的字形时才有意义。', 'Local Echo': '本地回显', + 'Keep Size Under Keyboard': '键盘弹出时保持尺寸', + "For local Claude sessions, opening the on-screen keyboard keeps the session's size and scrolls the view instead, so Claude does not reprint its screen and lose transcript lines. Full-screen interfaces (Codex, Claude's fullscreen mode) are resized as before.": + '对于本地 Claude 会话,打开屏幕键盘时会保持会话尺寸并改为滚动视图,这样 Claude 不会重绘屏幕而丢失对话记录行。全屏界面(Codex、Claude 的全屏模式)仍像以前一样调整尺寸。', 'CJK Input': '中日韩输入', 'Extended Keyboard Bar': '扩展键盘栏', 'Gesture Control (beta)': '手势控制(测试版)', diff --git a/src/web/public/index.html b/src/web/public/index.html index b52a2956b..5838005d8 100644 --- a/src/web/public/index.html +++ b/src/web/public/index.html @@ -1944,6 +1944,13 @@

Terminal & Input

+
+
+ Keep Size Under Keyboard + For local Claude sessions, opening the on-screen keyboard keeps the session's size and scrolls the view instead, so Claude does not reprint its screen and lose transcript lines. Full-screen interfaces (Codex, Claude's fullscreen mode) are resized as before. +
+ +
CJK Input diff --git a/src/web/public/mobile-handlers.js b/src/web/public/mobile-handlers.js index f63b848c8..e172683e7 100644 --- a/src/web/public/mobile-handlers.js +++ b/src/web/public/mobile-handlers.js @@ -344,6 +344,13 @@ const KeyboardHandler = { // branches are skipped on a shape change, whichever way the height moved. if (!shapeChanged && heightDiff > 150 && !this.keyboardVisible) { this.keyboardVisible = true; + // Hold the PTY's rows for as long as the keyboard covers the terminal + // (keyboardHoldsRows, keyboardClipPx in constants.js): every height + // change makes Claude Code clear and reprint only the tail that fits. + // Captured before anything refits, so these are the keyboard-free rows. + if (typeof app !== 'undefined' && app.terminal && app._keyboardHoldEligible?.()) { + app._keyboardHeldRows = app.terminal.rows; + } document.body.classList.add('keyboard-visible'); // While the keyboard is open, size the app to the visual viewport so // xterm's bottom row and cursor sit above the OS keyboard. @@ -355,6 +362,7 @@ const KeyboardHandler = { // iOS 26's persistent 24px discrepancy, and Safari bottom bar changes else if (!shapeChanged && heightDiff < 100 && this.keyboardVisible) { this.keyboardVisible = false; + this._releaseHeldRows(); document.body.classList.remove('keyboard-visible'); this.onKeyboardHide(); // Re-sync --app-height now that keyboard is gone (MobileDetection skipped @@ -381,6 +389,12 @@ const KeyboardHandler = { // drop can re-arm the show branch against a baseline that already sits // at the shrunk height. if (shapeChanged) { + // Held rows belong to the display that is gone: release them and arm the + // settle, which refits and tells the PTY. Nothing else would: with the + // keyboard up throttledResize stands aside, and a rotation alone arms no + // settle, so xterm kept the held rows with no clip and the composer sat + // under the keyboard until it closed. + if (this._releaseHeldRows()) this._scheduleViewportSettle(); this.initialViewportHeight = this.keyboardVisible ? window.innerHeight : currentHeight; } else if (!this.keyboardVisible) { this.initialViewportHeight = currentHeight; @@ -394,6 +408,11 @@ const KeyboardHandler = { this.lastViewportHeight = currentHeight; }, + /** End a keyboard row hold (app._releaseKeyboardHold); true when one was held. */ + _releaseHeldRows() { + return typeof app !== 'undefined' && !!app._releaseKeyboardHold?.(); + }, + /** Update layout when keyboard shows/hides */ updateLayoutForKeyboard() { if (!window.visualViewport) return; @@ -606,6 +625,9 @@ const KeyboardHandler = { // geometry xterm will actually keep (issue #464). app.syncTerminalGeometry?.(); if (this.keyboardVisible) this._shrinkPaddingToFit(); + // Rows held: nothing re-renders on its own (the size did not change), + // so place the screen for the settled box here. + app._applyKeyboardClip?.(); // Following live output → bottom, as before. Reading history → back to // the pre-reflow anchor instead of being yanked down (#259). if (shouldRestoreScroll) { diff --git a/src/web/public/settings-ui.js b/src/web/public/settings-ui.js index db745f450..4b71bd24a 100644 --- a/src/web/public/settings-ui.js +++ b/src/web/public/settings-ui.js @@ -618,6 +618,8 @@ Object.assign(CodemanApp.prototype, { document.getElementById('appSettingsTunnelEnabled').checked = settings.tunnelEnabled ?? false; this.loadTunnelStatus(); document.getElementById('appSettingsLocalEcho').checked = settings.localEchoEnabled ?? MobileDetection.isTouchDevice(); + // Default ON, so an absent key reads as enabled rather than as off. + document.getElementById('appSettingsKeyboardHoldsRows').checked = settings.keyboardHoldsRows !== false; // Auto Copy (copy-on-select): per-device, default OFF everywhere. It quietly // overwrites the system clipboard on a gesture the user may have meant only as // a way to read, so it is opt-in rather than a default anyone has to discover. @@ -2958,6 +2960,7 @@ Object.assign(CodemanApp.prototype, { Math.min(24, Math.max(0.5, parseFloat(document.getElementById('appSettingsPromptCacheKeepWarmMaxHours').value) || 2)), tunnelEnabled: document.getElementById('appSettingsTunnelEnabled').checked, localEchoEnabled: document.getElementById('appSettingsLocalEcho').checked, + keyboardHoldsRows: document.getElementById('appSettingsKeyboardHoldsRows').checked, autoCopySelection: document.getElementById('appSettingsAutoCopySelection').checked, copyStripMargin: document.getElementById('appSettingsCopyStripMargin').checked, terminalFontFamily: document.getElementById('appSettingsTerminalFont').value.trim(), @@ -3050,6 +3053,8 @@ Object.assign(CodemanApp.prototype, { // Save to localStorage this.saveAppSettingsToStorage(settings); this._updateLocalEchoState(); + // Turning Keep Size Under Keyboard off while it holds releases it now. + this._reconcileKeyboardHold?.(); this.applyTerminalFontFamily?.(settings.terminalFontFamily); this.applyTerminalFontWeights?.(settings); @@ -3202,6 +3207,9 @@ Object.assign(CodemanApp.prototype, { const _chipFlip = this.planUsageCollectionFlip(_prev, settings.showPlanUsageLimits); const { localEchoEnabled: _leo, + // Per-device (it is about this screen's keyboard) and absent from + // SettingsUpdateSchema (.strict()), so sending it would 400 the whole PUT. + keyboardHoldsRows: _khr, cjkInputEnabled: _cjk, extendedKeyboardBar: _ekb, skin: _skin, @@ -4896,7 +4904,7 @@ Object.assign(CodemanApp.prototype, { 'showFontControls', 'showSystemStats', 'showGpuStats', 'showCpuMhz', 'headerStatsStyle', 'showTokenCount', 'showCost', 'showLifecycleLog', 'showResponseViewer', 'showRedrawButton', 'showMonitor', 'showProjectInsights', 'showFileBrowser', 'showSubagents', - 'subagentActiveTabOnly', 'tabTwoRows', 'showTabCliLogos', 'tabOrientation', 'tabRailWidth', 'tabRailDetail', 'tabRailSort', 'tabArrangement', 'tabStateOrder', 'sessionListLayout', 'sessionSidebarFontSize', 'localEchoEnabled', 'cjkInputEnabled', 'extendedKeyboardBar', + 'subagentActiveTabOnly', 'tabTwoRows', 'showTabCliLogos', 'tabOrientation', 'tabRailWidth', 'tabRailDetail', 'tabRailSort', 'tabArrangement', 'tabStateOrder', 'sessionListLayout', 'sessionSidebarFontSize', 'localEchoEnabled', 'keyboardHoldsRows', 'cjkInputEnabled', 'extendedKeyboardBar', 'skin', 'showPlanUsageLimits', 'showAttachmentsButton', 'showFileViewerButton', 'webglRendererEnabled', 'terminalFontFamily', 'terminalFontWeight', 'terminalFontWeightBold', 'language', diff --git a/src/web/public/styles.css b/src/web/public/styles.css index 49af0d3e8..0b5b9f477 100644 --- a/src/web/public/styles.css +++ b/src/web/public/styles.css @@ -23195,3 +23195,12 @@ html[data-header-stats='tiles'] .header-plan-usage:not(:has(.pu-row)) { border: 1px solid var(--control-border); color: var(--text-muted); } + +/* Rows held under the on-screen keyboard (keyboardHoldsRows; keyboardClipPx in + constants.js): the screen stays taller than its box and is scrolled up by + --kb-clip, set on #terminalContainer by _applyKeyboardClip (terminal-ui.js). + The helper textarea, IME preview, link layer and both echo overlays live + inside .xterm-screen, so they move with it, and every hit test reads rects. */ +body.keyboard-visible .terminal-container .xterm-screen { + transform: translateY(calc(-1 * var(--kb-clip, 0px))); +} diff --git a/src/web/public/terminal-ui.js b/src/web/public/terminal-ui.js index dd0066521..cdc86730a 100644 --- a/src/web/public/terminal-ui.js +++ b/src/web/public/terminal-ui.js @@ -1060,6 +1060,9 @@ Object.assign(CodemanApp.prototype, { if (MobileDetection.isTouchDevice()) { this.terminal.onCursorMove(() => this._syncMobileHelperTextareaToCursor()); this.terminal.onRender(() => this._syncMobileHelperTextareaToCursor()); + // Keep the live content above the keyboard while the rows are held. + this.terminal.onCursorMove(() => this._applyKeyboardClip()); + this.terminal.onRender(() => this._applyKeyboardClip()); } // Renderer liveness — see _startRenderLivenessWatchdog. Registered for every @@ -6556,12 +6559,124 @@ Object.assign(CodemanApp.prototype, { // timer and the resize observer, where an exception takes the rest of the // callback (the padding fit, the scroll restore, the SIGWINCH) with it. try { - return window.CodemanTerminalGeometry.clampTerminalDimensions(this.fitAddon?.proposeDimensions()); + const dims = window.CodemanTerminalGeometry.clampTerminalDimensions(this.fitAddon?.proposeDimensions()); + // Rows held under the on-screen keyboard (see keyboardClipPx): the + // container shrank, the PTY must not. Columns still follow the container. + const held = this._keyboardHeldRowsActive?.(); + if (dims && held) dims.rows = Math.max(dims.rows, held); + return dims; } catch { return null; } }, + /** + * Rows held while the on-screen keyboard is up, or 0. Set by KeyboardHandler + * on the show transition when `keyboardHoldsRows` is on (default), cleared on + * hide and on a shape change. Every geometry read goes through + * getTerminalDimensions(), so holding it there holds the fit, the PTY resize + * and every capture-geometry comparison alike. + * @returns {number} + */ + _keyboardHeldRowsActive() { + if (!(this._keyboardHeldRows > 0)) return 0; + return typeof KeyboardHandler !== 'undefined' && KeyboardHandler.keyboardVisible ? this._keyboardHeldRows : 0; + }, + + /** + * May the keyboard hold this session's rows? Only an INLINE renderer (the + * `keyboardHold` capability, read from `window.__codemanKeyboardHoldModes`, + * never an id literal here): a CLI that lays out the full height (codex) cannot + * show both its content and its composer above the keyboard and must be + * resized, as before. Never while the CLI reports mouse tracking (Claude's + * fullscreen mode), and never for a remote or docker session, whose pane hosts + * a nested client. Absent data means no hold. Per-device setting + * `keyboardHoldsRows`, default ON. + */ + _keyboardHoldEligible(sessionId = this.activeSessionId) { + if (this.loadAppSettingsFromStorage?.()?.keyboardHoldsRows === false) return false; + const session = sessionId ? this.sessions?.get(sessionId) : null; + if (!session || session.remote || session.docker || session.cliMouseTracking) return false; + const modes = window.__codemanKeyboardHoldModes; + return Array.isArray(modes) && modes.includes(session.mode || 'claude'); + }, + + /** + * Drop a hold the active session no longer qualifies for (switched to a CLI + * that resizes, entered fullscreen, setting turned off) and, unless the caller + * resizes itself (selectSession), fit and tell the PTY at once, which is the + * old keyboard behavior. Never re-holds: a session that becomes eligible with + * the keyboard already up keeps the short rows until the keyboard closes. + */ + _reconcileKeyboardHold({ resize = true } = {}) { + if (!this._keyboardHeldRows || this._keyboardHoldEligible()) return; + this._releaseKeyboardHold(); + if (resize && this.activeSessionId) this.sendResize(this.activeSessionId)?.catch?.(() => {}); + }, + + /** + * End a keyboard row hold and put the screen back in place (no offset, no + * overlay bound). Shared by KeyboardHandler (keyboard hide, shape change) + * and _reconcileKeyboardHold. + * @returns {boolean} whether a hold was released + */ + _releaseKeyboardHold() { + if (!this._keyboardHeldRows) return false; + this._keyboardHeldRows = 0; + this._applyKeyboardClip(); + return true; + }, + + /** + * Scroll the (held, taller than its box) terminal screen so the live content + * sits above the keyboard, and bound the local-echo overlay to the rows that + * are visible. Runs on every render and cursor move while the hold is active; + * resets once when it ends. + */ + _applyKeyboardClip() { + const container = this.terminal?.element?.parentElement; + if (!container) return; + const rows = this._keyboardHeldRowsActive() ? this.terminal.rows : 0; + const cellHeight = rows ? this.terminal._core?._renderService?.dimensions?.css?.cell?.height : 0; + const viewHeight = container.clientHeight; + let clip = 0; + if (cellHeight && viewHeight) { + const buffer = this.terminal.buffer.active; + const following = buffer.viewportY >= buffer.baseY; + let lastContentRow = -1; + if (following) { + for (let row = rows - 1; row >= 0; row--) { + if (buffer.getLine(buffer.baseY + row)?.translateToString(true).trim()) { + lastContentRow = row; + break; + } + } + } + clip = window.CodemanTerminalGeometry.keyboardClipPx({ + rows, + cellHeight, + viewHeight, + cursorRow: buffer.cursorY, + lastContentRow, + following, + viewportY: buffer.viewportY, + baseY: buffer.baseY, + }); + } + if (clip !== (this._keyboardClipPx || 0)) { + this._keyboardClipPx = clip; + if (clip) container.style.setProperty('--kb-clip', `${clip}px`); + else container.style.removeProperty('--kb-clip'); + } + // Bounded whenever rows are held, even at offset 0 (a prompt near the top + // still has held rows below it, under the keyboard). setRowLimit dedupes. + this._localEchoOverlay?.setRowLimit?.( + cellHeight && viewHeight + ? window.CodemanTerminalGeometry.keyboardVisibleRowLimit(clip, viewHeight, cellHeight) + : null + ); + }, + /** * Fit xterm to its container and return the geometry that was APPLIED. * @@ -6589,7 +6704,10 @@ Object.assign(CodemanApp.prototype, { syncTerminalGeometry() { if (!this.fitAddon || !this.terminal) return null; try { - this.fitAddon.fit(); + // fit() would shrink xterm to the keyboard-shortened box before the held + // rows put it back: skip it while they are held (_resizeTerminalTo below + // still applies the container's columns). + if (!this._keyboardHeldRowsActive?.()) this.fitAddon.fit(); } catch { /* a disposed or unattached terminal cannot be fitted; fall through to the read */ } @@ -6614,8 +6732,9 @@ Object.assign(CodemanApp.prototype, { * part of the scrollback in between. The mobile retry asks every 30 seconds, * so that happened on a timer for as long as the refusal lasted. So the * columns stay at the PTY's (the #464 invariant: the browser never draws at - * a width the PTY does not have), the rows follow the container (they are - * never adopted, see reconcilePtyGeometry), and the container's columns go + * a width the PTY does not have), the rows follow the container or the + * keyboard hold (they are never adopted, see reconcilePtyGeometry and + * getTerminalDimensions), and the container's columns go * out as the request. An accepted request is adopted by the report. * * @returns {{cols: number, rows: number}|null} the geometry to request diff --git a/src/web/server.ts b/src/web/server.ts index 2b3e582c4..8bcb0b6f2 100644 --- a/src/web/server.ts +++ b/src/web/server.ts @@ -1914,6 +1914,16 @@ export class WebServer extends EventEmitter { '', () => `\n` ); + // Which run modes keep their PTY rows under a phone keyboard (inline renderers, + // the `keyboardHold` capability; `_keyboardHoldEligible` in terminal-ui.js). Same + // shape and reasoning as the page-key list above. + const keyboardHoldClis = enabledClis() + .filter((entry) => entry.capabilities.keyboardHold === 'inline') + .map((entry) => entry.id); + html = html.replace( + '', + () => `\n` + ); if (!soloSessionId && process.env.CODEMAN_GESTURE === '1') { html = html.replace('', () => `\n`); if (settings.gestureControlEnabled === true) { diff --git a/test/cli-registry-schema.test.ts b/test/cli-registry-schema.test.ts index 9fa1c6ee5..6e62cfd16 100644 --- a/test/cli-registry-schema.test.ts +++ b/test/cli-registry-schema.test.ts @@ -485,6 +485,30 @@ describe('capability shapes', () => { }, 'transcriptPageKeys is a boolean'); }); + it("takes keyboardHold as an optional 'inline', absent meaning the keyboard resizes", () => { + const inline = baseEntry(); + (inline.capabilities as Record).keyboardHold = 'inline'; + expect(CliEntrySchema.safeParse(inline).success).toBe(true); + const absent = baseEntry(); + delete (absent.capabilities as Record).keyboardHold; + expect(CliEntrySchema.safeParse(absent).success).toBe(true); + for (const value of [true, 'fullscreen', '']) { + expectRejected( + (e) => { + (e.capabilities as Record).keyboardHold = value; + }, + `keyboardHold=${JSON.stringify(value)} names no renderer kind` + ); + } + }); + + it('declares keyboardHold for exactly the inline renderer measured losing lines on a height change', () => { + // Claude Code 2.1.306's inline renderer was measured dropping transcript lines from + // tmux on keyboard-driven height changes; every other stock entry resizes. + const holding = STOCK_CLIS.filter((e) => e.capabilities.keyboardHold === 'inline').map((e) => e.id as string); + expect(holding).toEqual(['claude']); + }); + it('declares which CLIs get the wheel forwarded, and whether a version gates it', () => { // Claude forwards from 2.1.187 (fullscreen only); tracked Codex and Copilot // forward without a version gate. Unmeasured entries stay 'never'. diff --git a/test/keyboard-held-rows.test.ts b/test/keyboard-held-rows.test.ts new file mode 100644 index 000000000..a01f14858 --- /dev/null +++ b/test/keyboard-held-rows.test.ts @@ -0,0 +1,619 @@ +// Port: none (pure logic and a real headless xterm in vm contexts: no browser, +// no server). +// +// Rows held under the on-screen keyboard (`keyboardHoldsRows`, per-device, +// default ON). Every PTY height change makes Claude Code clear the screen and +// reprint only the transcript tail that fits, and the keyboard opening and +// closing used to be such a change each time (measured on the dev tier: tmux +// went 62x38 -> 62x15 -> 62x38 with a SIGWINCH per transition). Now the rows +// captured at the show transition are held, columns still follow the box, and +// the over-tall screen is scrolled up instead (keyboardClipPx). +// +// Outside test/mobile/ on purpose: that suite is not in the CI gate. +import { readFileSync } from 'node:fs'; +import { resolve } from 'node:path'; +import vm from 'node:vm'; +import { describe, expect, it, vi } from 'vitest'; +import xtermHeadless from '@xterm/headless'; + +type HeadlessBuffer = { + viewportY: number; + baseY: number; + cursorY: number; + length: number; + getLine(y: number): { translateToString(trim?: boolean): string } | undefined; +}; +const { Terminal: HeadlessTerminal } = xtermHeadless as unknown as { + Terminal: new (opts: Record) => { + rows: number; + write(data: string, cb?: () => void): void; + buffer: { active: HeadlessBuffer }; + }; +}; + +const read = (file: string) => readFileSync(resolve(import.meta.dirname, '../src/web/public', file), 'utf8'); + +type Geometry = { + keyboardClipPx(input: Record): number; + keyboardVisibleRowLimit(clipPx: number, viewHeight: number, cellHeight: number): number; +}; + +function loadGeometry(): Geometry { + const window: Record = {}; + vm.runInContext(read('constants.js'), vm.createContext({ console, window, URLSearchParams })); + return window.CodemanTerminalGeometry as Geometry; +} + +describe('keyboardClipPx', () => { + const g = loadGeometry(); + // 38 held rows of 17px in a 255px box (15 rows visible): 391px of overflow. + const base = { rows: 38, cellHeight: 17, viewHeight: 255, following: true, viewportY: 100, baseY: 100 }; + + it('puts the last content row at the bottom of the box', () => { + // Claude: composer and footer run to the last row, cursor in the composer. + expect(g.keyboardClipPx({ ...base, cursorRow: 34, lastContentRow: 37 })).toBe(38 * 17 - 255); + // Content ends at row 20: rows 6..20 are in view. + expect(g.keyboardClipPx({ ...base, cursorRow: 20, lastContentRow: 20 })).toBe(21 * 17 - 255); + }); + + it('counts a cursor just below the content (an empty prompt line)', () => { + expect(g.keyboardClipPx({ ...base, cursorRow: 22, lastContentRow: 20 })).toBe(23 * 17 - 255); + }); + + it('ignores a cursor parked far below the content (codex dialogs)', () => { + // Measured on the dev tier: codex's update dialog on rows 1-8 of 42, its + // cursor on row 38 of an otherwise blank screen. Anchoring to the cursor + // scrolled the dialog out of view; the top stays in view instead. + expect(g.keyboardClipPx({ ...base, cursorRow: 37, lastContentRow: 8 })).toBe(0); + }); + + it('shows the top for a prompt near the top (a fresh shell), and uses the cursor on a blank screen', () => { + expect(g.keyboardClipPx({ ...base, cursorRow: 1, lastContentRow: 1, viewportY: 0, baseY: 0 })).toBe(0); + expect(g.keyboardClipPx({ ...base, cursorRow: 37, lastContentRow: -1 })).toBe(38 * 17 - 255); + }); + + it('follows the scroll position while reading history, top of scrollback included', () => { + const max = 38 * 17 - 255; + expect(g.keyboardClipPx({ ...base, following: false, viewportY: 0, cursorRow: 37, lastContentRow: 37 })).toBe(0); + expect(g.keyboardClipPx({ ...base, following: false, viewportY: 50, cursorRow: 37, lastContentRow: 37 })).toBe( + Math.round(max / 2) + ); + }); + + it('is 0 when nothing overflows or the terminal cannot be measured', () => { + expect(g.keyboardClipPx({ ...base, viewHeight: 38 * 17, cursorRow: 37, lastContentRow: 37 })).toBe(0); + expect(g.keyboardClipPx({ ...base, cellHeight: 0, cursorRow: 37, lastContentRow: 37 })).toBe(0); + }); + + it('bounds the overlay to the fully visible rows', () => { + expect(g.keyboardVisibleRowLimit(0, 255, 17)).toBe(15); + expect(g.keyboardVisibleRowLimit(38 * 17 - 255, 255, 17)).toBe(38); + expect(g.keyboardVisibleRowLimit(10, 255, 17)).toBe(15); + }); +}); + +type Proto = Record, ...args: unknown[]) => unknown>; + +function loadTerminalProto(keyboard: Record = { keyboardVisible: true }) { + const windowStub: Record = { addEventListener: vi.fn(), removeEventListener: vi.fn() }; + const context = vm.createContext({ + console, + setInterval: vi.fn(), + clearInterval: vi.fn(), + setTimeout, + clearTimeout, + requestAnimationFrame: vi.fn(), + HTMLCanvasElement: class HTMLCanvasElement {}, + WebSocket: { OPEN: 1 }, + fetch: vi.fn(), + URLSearchParams, + document: { addEventListener: vi.fn(), documentElement: { dataset: {} }, getElementById: () => null }, + localStorage: { length: 0, key: vi.fn(), getItem: vi.fn(), setItem: vi.fn(), removeItem: vi.fn() }, + window: windowStub, + MobileDetection: { isTouchDevice: () => true, isHandheldDevice: () => false, getDeviceType: () => 'mobile' }, + KeyboardHandler: keyboard, + }); + vm.runInContext( + `${read('constants.js')}\n${read('app.js')}\n${read('terminal-ui.js')}\nglobalThis.__CodemanApp = CodemanApp;`, + context + ); + const proto = (context as unknown as { __CodemanApp: { prototype: Proto } }).__CodemanApp.prototype; + return { proto, keyboard, windowStub }; +} + +describe('getTerminalDimensions holds the rows while the keyboard is up', () => { + const { proto, keyboard } = loadTerminalProto(); + const fake = (held: number) => ({ + _keyboardHeldRows: held, + _keyboardHeldRowsActive: proto._keyboardHeldRowsActive, + fitAddon: { proposeDimensions: () => ({ cols: 62, rows: 15 }) }, + }); + + it('reports the held rows and the container columns', () => { + keyboard.keyboardVisible = true; + expect(proto.getTerminalDimensions.call(fake(38))).toEqual({ cols: 62, rows: 38 }); + }); + + it('follows the container once the keyboard is gone, or with no hold', () => { + keyboard.keyboardVisible = false; + expect(proto.getTerminalDimensions.call(fake(38))).toEqual({ cols: 62, rows: 15 }); + keyboard.keyboardVisible = true; + expect(proto.getTerminalDimensions.call(fake(0))).toEqual({ cols: 62, rows: 15 }); + }); + + it('never holds fewer rows than the box offers', () => { + keyboard.keyboardVisible = true; + expect(proto.getTerminalDimensions.call(fake(12))).toEqual({ cols: 62, rows: 15 }); + }); + + it('skips the raw fit while held, so xterm never visits the short size', () => { + keyboard.keyboardVisible = true; + const fit = vi.fn(); + const resizes: unknown[] = []; + const self = { + ...fake(38), + fitAddon: { fit, proposeDimensions: () => ({ cols: 62, rows: 15 }) }, + terminal: { cols: 62, rows: 38, resize: (c: number, r: number) => resizes.push([c, r]) }, + getTerminalDimensions: proto.getTerminalDimensions, + _resizeTerminalTo: proto._resizeTerminalTo, + _scheduleOverflowAffordanceSync: () => {}, + }; + expect(proto.syncTerminalGeometry.call(self)).toEqual({ cols: 62, rows: 38 }); + expect(fit).not.toHaveBeenCalled(); + expect(resizes).toEqual([]); + }); +}); + +describe('only inline renderers hold, and a hold is released when the session stops qualifying', () => { + const { proto, windowStub } = loadTerminalProto(); + windowStub.__codemanKeyboardHoldModes = ['claude']; + const appWith = (session: Record | null, settings: Record = {}) => ({ + activeSessionId: session ? 's1' : null, + sessions: new Map(session ? [['s1', session]] : []), + loadAppSettingsFromStorage: () => settings, + _keyboardHoldEligible: proto._keyboardHoldEligible, + _releaseKeyboardHold: proto._releaseKeyboardHold, + }); + const eligible = (session: Record | null, settings?: Record) => + proto._keyboardHoldEligible.call(appWith(session, settings)); + + it('holds for an inline renderer (the keyboardHold capability), mode defaulting to claude', () => { + expect(eligible({ mode: 'claude' })).toBe(true); + expect(eligible({})).toBe(true); + }); + + it('never holds a full-height CLI, fullscreen Claude, a remote or docker pane, or with the setting off', () => { + expect(eligible({ mode: 'codex' })).toBe(false); + expect(eligible({ mode: 'shell' })).toBe(false); + expect(eligible({ mode: 'claude', cliMouseTracking: true })).toBe(false); + expect(eligible({ mode: 'claude', remote: { hostId: 'h' } })).toBe(false); + expect(eligible({ mode: 'claude', docker: { caseName: 'c' } })).toBe(false); + expect(eligible({ mode: 'claude' }, { keyboardHoldsRows: false })).toBe(false); + expect(eligible(null)).toBe(false); + }); + + it('holds nothing when the server sent no list', () => { + const saved = windowStub.__codemanKeyboardHoldModes; + delete windowStub.__codemanKeyboardHoldModes; + try { + expect(eligible({ mode: 'claude' })).toBe(false); + } finally { + windowStub.__codemanKeyboardHoldModes = saved; + } + }); + + const reconcile = (session: Record, opts?: Record) => { + const app = { + ...appWith(session), + _keyboardHeldRows: 38, + _applyKeyboardClip: vi.fn(), + sendResize: vi.fn(() => Promise.resolve(true)), + }; + proto._reconcileKeyboardHold.call(app, opts); + return app; + }; + + it('releases and resizes once the active session no longer qualifies', () => { + const app = reconcile({ mode: 'claude', cliMouseTracking: true }); + expect(app._keyboardHeldRows).toBe(0); + expect(app._applyKeyboardClip).toHaveBeenCalled(); + expect(app.sendResize).toHaveBeenCalledWith('s1'); + }); + + it('leaves the resize to a caller that does its own (selectSession)', () => { + const app = reconcile({ mode: 'codex' }, { resize: false }); + expect(app._keyboardHeldRows).toBe(0); + expect(app.sendResize).not.toHaveBeenCalled(); + }); + + it('keeps a hold the session still qualifies for', () => { + const app = reconcile({ mode: 'claude' }); + expect(app._keyboardHeldRows).toBe(38); + expect(app.sendResize).not.toHaveBeenCalled(); + }); + + // session:updated is how a running Claude session reports entering its + // fullscreen renderer (cliMouseTracking), so the shipped handler must release. + const updatedApp = () => ({ + ...appWith({ mode: 'claude' }), + _keyboardHeldRows: 38, + _applyKeyboardClip: vi.fn(), + sendResize: vi.fn(() => Promise.resolve(true)), + _reconcileKeyboardHold: proto._reconcileKeyboardHold, + renderSessionTabs: vi.fn(), + updateCost: vi.fn(), + updateSubagentParentNames: vi.fn(), + }); + + it('a session:updated that turns mouse tracking on releases the hold and resizes', () => { + const app = updatedApp(); + app.sendResize.mockClear(); + proto._onSessionUpdated.call(app, { session: { id: 's1', mode: 'claude', cliMouseTracking: true } }); + expect(app._keyboardHeldRows).toBe(0); + expect(app.sendResize).toHaveBeenCalledWith('s1'); + }); + + it('a session:updated for another session, or one that still qualifies, keeps it', () => { + const app = updatedApp(); + app.sessions.set('s2', { id: 's2', mode: 'claude' }); + proto._onSessionUpdated.call(app, { session: { id: 's2', mode: 'claude', cliMouseTracking: true } }); + proto._onSessionUpdated.call(app, { session: { id: 's1', mode: 'claude' } }); + expect(app._keyboardHeldRows).toBe(38); + expect(app.sendResize).not.toHaveBeenCalled(); + }); +}); + +// The composer must stay on screen with the rows held. The COLUMNS ONLY rule in +// reconcilePtyGeometry exists because a screen taller than its box once hid the +// CLI's input line with nothing able to reach it; the hold makes the screen +// taller on purpose, and this pins that the clip puts the composer back in view. +// A real headless xterm holds Claude's inline frame at the held 38 rows, and the +// shipped getTerminalDimensions and _applyKeyboardClip run against it. +describe('the composer row stays visible with the rows held', () => { + const ROWS = 38; + const COLS = 62; + const CELL = 17; + const BOX = 15 * CELL; // the box left above the keyboard: 15 rows + + async function heldApp(transcriptLines: number) { + const { proto, keyboard } = loadTerminalProto(); + const term = new HeadlessTerminal({ cols: COLS, rows: ROWS, scrollback: 1000, allowProposedApi: true }); + // Claude's inline renderer: the transcript, then its composer between two + // rules with the footer under it, the cursor parked after the prompt glyph. + const rule = '\u2500'.repeat(COLS); + const transcript = Array.from({ length: transcriptLines }, (_, i) => `KBTEST-${String(i + 1).padStart(3, '0')}`); + const frame = [...transcript, rule, '\u276f ', rule, ' ? for shortcuts'].join('\r\n'); + await new Promise((done) => term.write(`${frame}\x1b[2A\x1b[3G`, () => done())); + const style = { setProperty: vi.fn(), removeProperty: vi.fn() }; + const container = { clientHeight: BOX, style }; + const app: Record = { + _keyboardHeldRows: 0, + fitAddon: { proposeDimensions: () => ({ cols: COLS, rows: 15 }) }, + terminal: { + rows: term.rows, + buffer: term.buffer, + element: { parentElement: container }, + _core: { _renderService: { dimensions: { css: { cell: { height: CELL } } } } }, + }, + _localEchoOverlay: { setRowLimit: vi.fn() }, + _keyboardHeldRowsActive: proto._keyboardHeldRowsActive, + getTerminalDimensions: proto.getTerminalDimensions, + _applyKeyboardClip: proto._applyKeyboardClip, + }; + const buffer = term.buffer.active; + const screenRow = (pattern: RegExp) => { + for (let row = 0; row < ROWS; row++) { + if (pattern.test(buffer.getLine(buffer.baseY + row)?.translateToString(true) ?? '')) return row; + } + return -1; + }; + return { app, keyboard, style, buffer, screenRow }; + } + + /** The --kb-clip offset last written to the container, 0 when removed or never set. */ + function clipOf(style: { setProperty: ReturnType; removeProperty: ReturnType }) { + const set = style.setProperty.mock.calls.at(-1); + return set ? parseFloat(String(set[1])) : 0; + } + + /** Is screen row `row` wholly inside the box once the screen is translated up by `clip` px? */ + const fullyVisible = (row: number, clip: number) => row * CELL >= clip && (row + 1) * CELL <= clip + BOX; + + it('a long transcript: the composer and the footer under it sit at the bottom of the box', async () => { + const { app, keyboard, style, buffer, screenRow } = await heldApp(100); + keyboard.keyboardVisible = true; + app._keyboardHeldRows = ROWS; // what KeyboardHandler captures on the show transition + + // The PTY keeps its rows while the box shrinks to 15; columns still follow the box. + expect((app.getTerminalDimensions as () => unknown).call(app)).toEqual({ cols: COLS, rows: ROWS }); + + (app._applyKeyboardClip as () => void).call(app); + const composer = screenRow(/^\u276f/); + const footer = screenRow(/\? for shortcuts/); + expect([composer, footer, buffer.cursorY]).toEqual([35, 37, 35]); + const clip = clipOf(style); + expect(clip).toBe(ROWS * CELL - BOX); + expect(fullyVisible(composer, clip)).toBe(true); + expect(fullyVisible(footer, clip)).toBe(true); + // The local-echo overlay may draw down to the last visible row, the composer's included. + const limit = (app._localEchoOverlay as { setRowLimit: ReturnType }).setRowLimit.mock.calls.at( + -1 + )![0]; + expect(limit).toBe(ROWS); + }); + + it('a fresh session whose frame ends near the top: no offset, the composer already in view', async () => { + const { app, keyboard, style, screenRow } = await heldApp(5); + keyboard.keyboardVisible = true; + app._keyboardHeldRows = ROWS; + (app._applyKeyboardClip as () => void).call(app); + const composer = screenRow(/^\u276f/); + expect(composer).toBe(6); + expect(clipOf(style)).toBe(0); + expect(fullyVisible(composer, 0)).toBe(true); + // Rows 0..14 are the visible ones: the overlay must grow upward from row 14, + // not from row 37 under the keyboard. + expect((app._localEchoOverlay as { setRowLimit: ReturnType }).setRowLimit).toHaveBeenLastCalledWith( + 15 + ); + }); + + it('keyboard closed: the offset and the overlay bound are both removed', async () => { + const { app, keyboard, style } = await heldApp(100); + keyboard.keyboardVisible = true; + app._keyboardHeldRows = ROWS; + (app._applyKeyboardClip as () => void).call(app); + expect(clipOf(style)).toBeGreaterThan(0); + + keyboard.keyboardVisible = false; + const overlay = app._localEchoOverlay as { setRowLimit: ReturnType }; + overlay.setRowLimit.mockClear(); + style.setProperty.mockClear(); + (app._applyKeyboardClip as () => void).call(app); + expect(style.removeProperty).toHaveBeenCalledWith('--kb-clip'); + expect(style.setProperty).not.toHaveBeenCalled(); + expect(overlay.setRowLimit).toHaveBeenCalledWith(null); + expect(app._keyboardClipPx).toBe(0); + }); +}); + +type ManualTimers = { setTimeout(fn: () => void): number; clearTimeout(id: number): void; run(): void }; + +/** setTimeout/clearTimeout whose callbacks run only when the test says so. */ +function manualTimers(): ManualTimers { + const pending = new Map void>(); + let next = 0; + return { + setTimeout(fn) { + pending.set(++next, fn); + return next; + }, + clearTimeout(id) { + pending.delete(id); + }, + run() { + for (let round = 0; pending.size && round < 10; round++) { + for (const [id, fn] of [...pending]) { + pending.delete(id); + fn(); + } + } + }, + }; +} + +function loadKeyboardHandler(enabled: boolean, opts: { app?: Record; timers?: ManualTimers } = {}) { + const viewport = { width: 390, height: 664, offsetTop: 0, addEventListener: () => {}, removeEventListener: () => {} }; + const layout = { height: 664 }; + const app = opts.app ?? { + terminal: { rows: 38 }, + _keyboardHeldRows: 0, + _keyboardHoldEligible: () => enabled, + _applyKeyboardClip: vi.fn(), + // The shipped release, so a hide or a shape change goes through the real helper. + _releaseKeyboardHold: loadTerminalProto().proto._releaseKeyboardHold, + relayoutMobileSubagentWindows: () => {}, + }; + const context = vm.createContext({ + console, + app, + navigator: { userAgent: 'iPhone', maxTouchPoints: 5 }, + window: { + get innerWidth() { + return viewport.width; + }, + get innerHeight() { + return layout.height; + }, + visualViewport: viewport, + addEventListener: () => {}, + removeEventListener: () => {}, + matchMedia: () => ({ matches: true }), + scrollTo: () => {}, + }, + document: { + body: { classList: { add: () => {}, remove: () => {} } }, + documentElement: { style: { setProperty: () => {} } }, + addEventListener: () => {}, + removeEventListener: () => {}, + getElementById: () => null, + querySelector: () => null, + }, + setTimeout: opts.timers ? opts.timers.setTimeout : () => 1, + clearTimeout: opts.timers ? opts.timers.clearTimeout : () => {}, + }); + vm.runInContext(`${read('mobile-handlers.js')}\nglobalThis.__KH = KeyboardHandler;`, context); + const handler = (context as unknown as { __KH: { init(): void; handleViewportResize(): void } }).__KH; + handler.init(); + const resizeTo = (width: number, height: number, layoutHeight = height) => { + viewport.width = width; + viewport.height = height; + layout.height = layoutHeight; + handler.handleViewportResize(); + }; + return { app: app as typeof app & Record, handler, resizeTo }; +} + +describe('KeyboardHandler captures and releases the hold', () => { + it('captures the keyboard-free rows on show and releases them on hide', () => { + const { app, resizeTo } = loadKeyboardHandler(true); + resizeTo(390, 364, 664); + expect(app._keyboardHeldRows).toBe(38); + app._applyKeyboardClip.mockClear(); + resizeTo(390, 664); + expect(app._keyboardHeldRows).toBe(0); + expect(app._applyKeyboardClip).toHaveBeenCalledTimes(1); + }); + + it('holds nothing for a session that does not qualify (setting off, a full-height CLI)', () => { + const { app, resizeTo } = loadKeyboardHandler(false); + resizeTo(390, 364, 664); + expect(app._keyboardHeldRows).toBe(0); + }); + + it('releases on a shape change with the keyboard still up', () => { + const { app, resizeTo } = loadKeyboardHandler(true); + resizeTo(390, 364, 664); + resizeTo(664, 90, 390); // rotated, keyboard still up + expect(app._keyboardHeldRows).toBe(0); + }); +}); + +// A rotation (or fold) with the keyboard still up releases the hold, since its +// rows belong to the display that is gone. The keyboard is still visible, so +// throttledResize stands aside and only the viewport settle can refit: the +// release has to arm it, or xterm keeps the held rows with no clip and the +// composer sits under the keyboard until it closes. The real KeyboardHandler +// drives the real terminal-ui methods over a headless xterm, with the timers +// run by hand so the opening settle finishes before the rotation. +describe('a rotation with the keyboard up', () => { + const CELL = 17; + + async function rotationSetup(eligible: boolean) { + const box = { cols: 44, rows: 38 }; + const term = new HeadlessTerminal({ cols: box.cols, rows: box.rows, scrollback: 1000, allowProposedApi: true }); + const rule = '\u2500'.repeat(40); + const transcript = Array.from({ length: 100 }, (_, i) => `KBTEST-${String(i + 1).padStart(3, '0')}`); + const frame = [...transcript, rule, '\u276f ', rule, ' ? for shortcuts'].join('\r\n'); + await new Promise((done) => term.write(`${frame}\x1b[2A\x1b[3G`, () => done())); + const style = { setProperty: vi.fn(), removeProperty: vi.fn() }; + const container = { + get clientHeight() { + return box.rows * CELL; + }, + style, + }; + const sent: unknown[] = []; + const app: Record = { + activeSessionId: 's1', + sessions: new Map([['s1', { id: 's1', mode: eligible ? 'claude' : 'codex' }]]), + _keyboardHeldRows: 0, + loadAppSettingsFromStorage: () => ({}), + relayoutMobileSubagentWindows: () => {}, + isTerminalAtBottom: () => true, + _scheduleOverflowAffordanceSync: () => {}, + _syncMobileHelperTextareaToCursor: () => {}, + fitAddon: { proposeDimensions: () => ({ ...box }), fit: () => term.resize(box.cols, box.rows) }, + terminal: { + get rows() { + return term.rows; + }, + get cols() { + return term.cols; + }, + get buffer() { + return term.buffer; + }, + resize: (cols: number, rows: number) => term.resize(cols, rows), + scrollToBottom: () => {}, + element: { parentElement: container }, + _core: { _renderService: { dimensions: { css: { cell: { height: CELL } } } } }, + }, + sendResize: vi.fn(() => { + sent.push(app.getTerminalDimensions()); + return Promise.resolve(true); + }), + }; + const timers = manualTimers(); + const { handler, resizeTo } = loadKeyboardHandler(eligible, { app, timers }); + const { proto, windowStub } = loadTerminalProto(handler as unknown as Record); + windowStub.__codemanKeyboardHoldModes = ['claude']; + // Every method not stubbed above is the shipped one. + Object.setPrototypeOf(app, proto); + const buffer = term.buffer.active; + const composerRow = () => { + for (let row = 0; row < term.rows; row++) { + if (/^\u276f/.test(buffer.getLine(buffer.baseY + row)?.translateToString(true) ?? '')) return row; + } + return -1; + }; + // Keyboard up over a portrait display, and the opening settle runs to the end. + box.rows = 15; + resizeTo(390, 364, 664); + timers.run(); + return { app, box, term, style, sent, timers, resizeTo, composerRow }; + } + + it('refits to the new display and tells the PTY, so the composer is back in the box', async () => { + const { app, box, term, style, sent, timers, resizeTo, composerRow } = await rotationSetup(true); + // The hold is in place after the opening settle: 38 rows in a 15-row box, clipped. + expect([term.cols, term.rows, app._keyboardHeldRows]).toEqual([44, 38, 38]); + expect(app._keyboardClipPx).toBe((38 - 15) * CELL); + + // Rotate to landscape with the keyboard still up: a 12-row, 76-column box. + box.cols = 76; + box.rows = 12; + app.sendResize.mockClear(); + sent.length = 0; + style.removeProperty.mockClear(); + resizeTo(664, 120, 390); + timers.run(); + + expect(app._keyboardHeldRows).toBe(0); + expect([term.cols, term.rows]).toEqual([76, 12]); + expect(app.sendResize).toHaveBeenCalledWith('s1'); + expect(sent).toEqual([{ cols: 76, rows: 12 }]); + // No clip left, and with the rows fitted to the box the composer is inside it. + expect(style.removeProperty).toHaveBeenCalledWith('--kb-clip'); + expect(app._keyboardClipPx).toBe(0); + // Shrinking the rows, xterm keeps the cursor row (the composer) on the last + // screen row; Claude redraws its footer under it after the SIGWINCH. + const composer = composerRow(); + expect(composer).toBe(11); + expect((composer + 1) * CELL).toBeLessThanOrEqual(box.rows * CELL); + }); + + it('with no hold, a rotation arms nothing, as before', async () => { + const { app, box, term, timers, resizeTo } = await rotationSetup(false); + // Not eligible: the opening settle fitted the short box, as before the hold existed. + expect([term.rows, app._keyboardHeldRows]).toEqual([15, 0]); + box.cols = 76; + box.rows = 12; + app.sendResize.mockClear(); + resizeTo(664, 120, 390); + timers.run(); + expect(app.sendResize).not.toHaveBeenCalled(); + expect([term.cols, term.rows]).toEqual([44, 15]); + }); +}); + +describe('keyboardHoldsRows is a per-device setting', () => { + const settingsUi = read('settings-ui.js'); + const html = read('index.html'); + const schemas = readFileSync(resolve(import.meta.dirname, '../src/web/schemas.ts'), 'utf8'); + + it('is a display key, stripped from the PUT and absent from the strict schema', () => { + const start = settingsUi.indexOf('const displayKeys = new Set(['); + expect(settingsUi.slice(start, settingsUi.indexOf('])', start))).toContain("'keyboardHoldsRows'"); + expect(settingsUi).toContain('keyboardHoldsRows: _khr,'); + expect(schemas).not.toContain('keyboardHoldsRows'); + }); + + it('defaults ON and loads/saves by id', () => { + expect(html).toContain('id="appSettingsKeyboardHoldsRows"'); + expect(settingsUi).toContain( + "document.getElementById('appSettingsKeyboardHoldsRows').checked = settings.keyboardHoldsRows !== false;" + ); + expect(settingsUi).toContain("keyboardHoldsRows: document.getElementById('appSettingsKeyboardHoldsRows').checked,"); + }); +}); diff --git a/test/keyboard-hold-i18n.test.ts b/test/keyboard-hold-i18n.test.ts new file mode 100644 index 000000000..f41ab057a --- /dev/null +++ b/test/keyboard-hold-i18n.test.ts @@ -0,0 +1,67 @@ +/** + * @fileoverview The "Keep Size Under Keyboard" App Settings row (#672) in Chinese (zh-CN): + * its label and description run through the real translator over the real index.html in + * JSDOM, read in Chinese with no English left beyond `Claude` and `Codex`, and each key is + * in the dictionary once (a repeated key in the object literal silently replaces the first). + * + * Port: N/A. + */ +import { readFileSync } from 'node:fs'; +import { resolve } from 'node:path'; +import vm from 'node:vm'; +import { JSDOM } from 'jsdom'; +import { afterAll, describe, expect, it } from 'vitest'; + +const PUBLIC = resolve(import.meta.dirname, '../src/web/public'); +const I18N = readFileSync(resolve(PUBLIC, 'i18n.js'), 'utf8'); +const INDEX = readFileSync(resolve(PUBLIC, 'index.html'), 'utf8'); + +type Api = { configure(o: Record): void; start(): void }; + +/** What may stay Latin: the two CLI names. */ +const leftover = (text: string) => text.replace(/\b(Claude|Codex)\b/g, '').match(/[A-Za-z]+/g) ?? []; + +function rowStrings(doc: Document): { label: string; description: string } { + const row = doc.getElementById('appSettingsKeyboardHoldsRows')!.closest('.set-row')!; + return { + label: row.querySelector('.set-row-label')!.textContent!.trim(), + description: row.querySelector('.set-row-desc')!.textContent!.trim(), + }; +} + +const source = new JSDOM(INDEX); +const english = rowStrings(source.window.document); + +const dom = new JSDOM(INDEX, { runScripts: 'outside-only', url: 'http://localhost/' }); +vm.runInContext(I18N, dom.getInternalVMContext(), { filename: 'i18n.js' }); +const api = (dom.window as unknown as { CodemanI18n: Api }).CodemanI18n; +api.start(); +api.configure({ language: 'zh-CN' }); +const chinese = rowStrings(dom.window.document); + +afterAll(() => { + source.window.close(); + dom.window.close(); +}); + +describe('the Keep Size Under Keyboard row in zh-CN', () => { + it('finds the row (the check is not vacuous)', () => { + expect(english.label).toBe('Keep Size Under Keyboard'); + expect(english.description).toMatch(/^For local Claude sessions, opening the on-screen keyboard/); + }); + + it('translates the label and the description, with no English left', () => { + expect(chinese).toEqual({ + label: '键盘弹出时保持尺寸', + description: + '对于本地 Claude 会话,打开屏幕键盘时会保持会话尺寸并改为滚动视图,这样 Claude 不会重绘屏幕而丢失对话记录行。全屏界面(Codex、Claude 的全屏模式)仍像以前一样调整尺寸。', + }); + for (const key of ['label', 'description'] as const) expect(leftover(chinese[key])).toEqual([]); + }); + + it('adds each key to the dictionary once', () => { + for (const text of [english.label, english.description]) { + expect(I18N.split(`'${text}'`).length + I18N.split(`"${text}"`).length - 2).toBe(1); + } + }); +}); diff --git a/test/mobile/keyboard.test.ts b/test/mobile/keyboard.test.ts index a6ddb7652..1dfd4c971 100644 --- a/test/mobile/keyboard.test.ts +++ b/test/mobile/keyboard.test.ts @@ -323,6 +323,99 @@ describe('Virtual Keyboard', () => { expect(mainPadding).toBe(''); }); + it('holds the PTY rows under the keyboard and keeps the Claude composer inside the visible box', async () => { + // #672: a keyboard-driven height change makes Claude's inline renderer reprint + // only the tail that fits and lose the rest, so the rows are held instead, and + // --kb-clip has to keep the composer in view of a screen now taller than its box. + const modes = await page.evaluate(() => (window as any).__codemanKeyboardHoldModes); + expect(modes).toEqual(['claude']); + const rowsBefore = await page.evaluate(async () => { + (window as any).__resizes = []; + app.activeSessionId = 'mobile-keyboard-hold-test'; + app.sessions.set('mobile-keyboard-hold-test', { + id: 'mobile-keyboard-hold-test', + mode: 'claude', + status: 'running', + }); + app.hideWelcome(); + // Fit the terminal to its box first, as a selected session is: these are + // the keyboard-free rows the hold must keep. + app.syncTerminalGeometry(); + app.sendResize = (id: string) => { + (window as any).__resizes.push({ id, ...app.getTerminalDimensions() }); + return Promise.resolve(true); + }; + app.terminal.reset(); + const cols = app.terminal.cols; + const rule = '\u2500'.repeat(cols); + const transcript = Array.from({ length: 100 }, (_, i) => `KBTEST-${String(i + 1).padStart(3, '0')}`); + const frame = [...transcript, rule, '\u276f ', rule, ' ? for shortcuts'].join('\r\n'); + await new Promise((resolve) => app.terminal.write(`${frame}\x1b[2A\x1b[3G`, resolve)); + return app.terminal.rows; + }); + + await showKeyboard(page, KEYBOARD.TYPICAL_IOS_HEIGHT); + await page.waitForTimeout(800); + + const held = await page.evaluate(() => { + const term = app.terminal; + const buffer = term.buffer.active; + const rowOf = (pattern: RegExp) => { + for (let row = 0; row < term.rows; row++) { + if (pattern.test(buffer.getLine(buffer.baseY + row)?.translateToString(true) ?? '')) return row; + } + return -1; + }; + const container = document.getElementById('terminalContainer') as HTMLElement; + const box = container.getBoundingClientRect(); + const screen = (container.querySelector('.xterm-screen') as HTMLElement).getBoundingClientRect(); + const cell = term._core._renderService.dimensions.css.cell.height; + const composer = rowOf(/^\u276f/); + const footer = rowOf(/\? for shortcuts/); + return { + rows: term.rows, + heldRows: app._keyboardHeldRows, + clip: container.style.getPropertyValue('--kb-clip'), + composer, + footer, + // Where the rows land on the page with the clip applied. + composerTop: screen.top + composer * cell, + footerBottom: screen.top + (footer + 1) * cell, + // Where they would land without it: below the box, i.e. under the keyboard. + footerBottomUnclipped: box.top + (footer + 1) * cell, + boxTop: box.top, + boxBottom: box.bottom, + viewportBottom: + (window.visualViewport?.offsetTop ?? 0) + (window.visualViewport?.height ?? window.innerHeight), + resizes: (window as any).__resizes, + }; + }); + + expect(held.rows).toBe(rowsBefore); + expect(held.heldRows).toBe(rowsBefore); + // Every resize sent while the keyboard was up carried the held rows. + for (const r of held.resizes) expect(r.rows).toBe(rowsBefore); + expect([held.composer, held.footer]).toEqual([rowsBefore - 3, rowsBefore - 1]); + // Not vacuous: unclipped, the footer would sit below the box. + expect(held.footerBottomUnclipped).toBeGreaterThan(held.boxBottom + 1); + expect(parseFloat(held.clip)).toBeGreaterThan(0); + expect(held.composerTop).toBeGreaterThanOrEqual(held.boxTop - 1); + expect(held.footerBottom).toBeLessThanOrEqual(held.boxBottom + 1); + expect(held.boxBottom).toBeLessThanOrEqual(held.viewportBottom + 1); + + await hideKeyboard(page); + await page.waitForTimeout(800); + // Released: no hold, no offset, and the rows follow the box again (the box + // after a keyboard cycle need not be the one before it, so compare with the fit). + const released = await page.evaluate(() => ({ + heldRows: app._keyboardHeldRows, + clip: (document.getElementById('terminalContainer') as HTMLElement).style.getPropertyValue('--kb-clip'), + rows: app.terminal.rows, + fits: window.CodemanTerminalGeometry.clampTerminalDimensions(app.fitAddon.proposeDimensions()).rows, + })); + expect(released).toEqual({ heldRows: 0, clip: '', rows: released.fits, fits: released.fits }); + }); + it('coalesces keyboard animation frames into one final terminal fit', async () => { const result = await page.evaluate(async () => { // `app.terminal` and `app.fitAddon` are only assigned by initTerminal(), diff --git a/test/render-index-html.test.ts b/test/render-index-html.test.ts index fc81713c3..7e0931d4b 100644 --- a/test/render-index-html.test.ts +++ b/test/render-index-html.test.ts @@ -181,6 +181,16 @@ describe('WebServer.renderIndexHtml', () => { } }); + it('injects the keyboard-hold modes from the keyboardHold capability: inline Claude, never full-height codex', async () => { + for (const solo of [undefined, 'sess-123']) { + const { server } = makeServer(); + const html = await render(server, solo); + const match = html.match(/window\.__codemanKeyboardHoldModes=(\[[^<]*\]);/); + expect(match).not.toBeNull(); + expect(JSON.parse(match![1])).toEqual(['claude']); + } + }); + it('escapes the solo id so it cannot break out of the inline '); diff --git a/test/server-index-title.test.ts b/test/server-index-title.test.ts index 726b5f094..b1cb73797 100644 --- a/test/server-index-title.test.ts +++ b/test/server-index-title.test.ts @@ -115,7 +115,9 @@ describe('WebServer index.html templating (#82)', () => { // enabled CLI declares a gutter, so it needs stripping on every machine. .replace(/<script>window\.__codemanTranscriptGutter=\{.*?\};<\/script>\n/, '') // Likewise the page-key mode list: an array, possibly empty, always injected. - .replace(/<script>window\.__codemanTranscriptPageKeys=\[.*?\];<\/script>\n/, ''); + .replace(/<script>window\.__codemanTranscriptPageKeys=\[.*?\];<\/script>\n/, '') + // And the keyboard-hold mode list, built the same way. + .replace(/<script>window\.__codemanKeyboardHoldModes=\[.*?\];<\/script>\n/, ''); const beforeTitle = rawTemplate.split('<title>Codeman')[0]; const afterTitle = rawTemplate.split('Codeman')[1]; expect(html.startsWith(beforeTitle)).toBe(true); From c7835a159c85a570d2a27ce2b3a1d93151d07111 Mon Sep 17 00:00:00 2001 From: JD Date: Sat, 24 Oct 2026 19:31:56 -0400 Subject: [PATCH 11/68] feat(tabs): start a session beside any tab from an opt-in + in its actions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Opening another session in the case you are already working in meant re-picking the case and the mode in the toolbar. An opt-in + now sits with each tab's gear and close icons (App Settings, Tabs, New Session Button on Tabs: per device, off by default everywhere). It opens a menu of the CLIs that tab's case can start, each shown with its logo and name as in the Run menus, and a pick starts one session in that tab's case, or the managed worktree it runs in, and in its named group, without changing the Run button's mode or case. The CLIs offered follow the Run menu's own rule for the case, so a Docker case lists what its container has rather than what the host has. A tab outside any named group sends no group, so the case's default session group places the new session the way it places a toolbar Run. Wherever a tab has a row menu (the vertical rail, the sidebar) that menu gains "New session here…", the keyboard route to the same picker, since Enter on the + itself does nothing. On a phone the + joins gear and close on the active tab only, and the label reserve grows to 68px while the setting is on, so the tab's centre stays off the icons. The shared tab layout menu no longer closes when focus leaves an item for nowhere: Safari never focuses a clicked button, so pressing an item blurred the focused one with no related target and the click was lost. This is the tile count menu's rule, and it fixes the named-group menu as well. --- docs/architecture-invariants.md | 6 +- docs/wiki/Settings-Reference.md | 1 + docs/wiki/The-Dashboard.md | 8 + src/web/CLAUDE.md | 2 +- src/web/public/app.js | 119 +++- src/web/public/i18n.js | 8 + src/web/public/index.html | 7 + src/web/public/mobile.css | 27 +- src/web/public/session-ui.js | 58 +- src/web/public/settings-ui.js | 8 + src/web/public/styles.css | 31 +- src/web/public/tab-rail-resize.js | 6 + test/mobile-tab-tap-zones.test.ts | 11 + test/routes/case-default-group-routes.test.ts | 112 +++- test/tab-group-launch.test.ts | 24 +- test/tab-layout-editing.test.ts | 16 + test/tab-new-session.test.ts | 606 ++++++++++++++++++ test/worktree-ui.test.ts | 8 +- 18 files changed, 1013 insertions(+), 45 deletions(-) create mode 100644 test/tab-new-session.test.ts diff --git a/docs/architecture-invariants.md b/docs/architecture-invariants.md index 7a743bb62..12c0a83f6 100644 --- a/docs/architecture-invariants.md +++ b/docs/architecture-invariants.md @@ -345,8 +345,8 @@ So: `_confirmIdle()` (session.ts) requires the pane to go quiet, and then asks t - **Lineage arcs to a collapse-hidden session anchor to its group header** (`lineage-line--proxied`); two endpoints proxied to one header draw nothing. - **The upstream HTML5 drag stays off in the grouped rail**: a flat-order drop cannot express a group move, and the server re-ranks within the old group. The grouped rail has its OWN pointer drag instead (`_bindTabLayoutPointerDrag`, mouse/pen only, bound once on the container): rows move before/after a row or into a group, a header drag reorders groups, and the drop maps to ONE operation through the pure `dropOperation()`. Escape cancels a drag, and the click that ends one is swallowed. ⚠️ A press is only captured once it moves 6 px, so its release can land outside the rail: `pointerup`/`pointercancel` are heard on `window` while a press is pending, a move with the primary button up cancels it, and a new press cancels any previous one. Without that a stale press became a phantom drag on the next hover, and a replaced drag left its capture-phase Escape listener behind, swallowing every Escape before the terminal saw it. The flat rail and the header strip keep the HTML5 drag untouched. For the same reason Ctrl+Shift+{ / } only swaps with a neighbour in the active session's own section (`_canSwapActiveTabWith`, reading the projection's `sectionByRef`): a cross-group swap moves nothing on the server, gets no `session:orderChanged` back, and would leave this client's `sessionOrder` and Alt+N targets out of step with every other device. - **Edits are named operations, saved serially.** `createEditCoordinator` applies `createGroup` / `renameGroup` / `deleteGroup` / `reorderGroup` / `moveRef` to the rail at once, then sends ONE `PUT /api/tab-layout {baseVersion, layout}` at a time; edits made meanwhile wait and go out on the version that write returns. A 409 carries the server's layout: the in-flight operations are replayed onto it (an operation that no longer applies is dropped and reported) and re-sent, at most `maxAttempts` times; a 400 re-reads once (a second 400 is reported as a failed save, not as a race); anything else reports and re-reads. A `moveRef` moves the session together with the sessions that still follow it and marks a hand-moved child `placement: 'manual'`, mirroring the server's `moveRef`, and `normalizeLayout` keeps `placement` because whole layouts are written back. `_onTabLayoutChanged` / `_applyTabLayout` defer a read while a write is in flight and rebase unsaved edits onto a read otherwise. ⚠️ A FAILED read (`_applyTabLayout(null)`) while edits are pending keeps the held layout and the editor and re-reads after the write settles: disposing there would orphan the in-flight write, whose 409 then never gets its rebase. Any path that does drop unsaved work says so in a toast. On `pagehide`, unconfirmed operations go out in a `keepalive` PUT AND into sessionStorage as `{ owner, baseVersion, savedAt, operations }`; after reload they replay onto the fresh layout (a no-op when the keepalive landed), but only for the same owner, within `TAB_LAYOUT_PENDING_MAX_AGE_MS` (60 s), and never onto a layout older than the copy's base. A "Move to " with no anchor carries no `index`, so a replay still puts the row last. -- **Every way in has a keyboard path.** The session row menu (Shift+F10 in the tree, the rail's overflow button) gains Move up/down, Move to , Move to Ungrouped and Move to new group in the vertical rail (only "new group" before the first group exists; nothing on the strip). A group header opens its menu with Shift+F10 / ContextMenu, right-click or its hover glyph (a non-focusable, `aria-hidden` span: a treeitem holds no interactive children), and F2 renames it inline (Enter commits and refocuses the header; a commit by BLUR leaves focus where it went, since refocusing from inside the blur handler overrides the user's click). The glyph stays visible under `@media (hover: none)`: a touch tablet has no hover and no long-press `contextmenu`. Group names in "Move to" labels are quoted, so a group named "New group" or "ungrouped" cannot read (or translate, case-insensitively) like the fixed entries. The menu closes on Escape (which it consumes before the global Escape handler), a pointer outside, Tab, focus leaving it, a resize, a second open and any full re-render. It borrows the `.tab-rail-action-menu` class for its look only: `closeTabRailActionMenu()` excludes `.tab-layout-group-action-menu`, so closing the row menu (every `session:deleted` does) cannot strand the group menu's listeners. The inline group editor shares `_activeRename` with the session rename, so only the CURRENT editor may release `_inlineRenameActive`. -- **New, child and web tabs land in groups.** A group menu's "New session" (`createSessionInTabGroup`) runs ONE launch with `run({ count: 1, tabGroupId })`; every launch path puts `tabGroupId` on its create request (`POST /api/sessions` or `/api/quick-start`), and `TabLayoutService.sessionCreated(owner, id, { tabGroupId }, register)` places the session at the end of that group in the SAME versioned write that first records it. The session enters the live map inside the owner lock (`register`, rolled back if the write fails), so no queued layout edit can commit it first. The group is looked up in the creating owner's own layout, so an unknown, deleted or foreign id is ignored rather than failing the spawn, and an explicit group is a hand placement (a child placed this way is `manual`). The create response carries the committed `tabLayout`, adopted before the tab is drawn. A session spawned by another one (`parentSessionId`) and placed by nobody lands after its parent: the server's normalization puts it after the parent's rows in the parent's container, and `project({ parents })` draws a not-yet-stored child there at once. Whether it then FOLLOWS its parent is the synced `spawnedTabsFollowParent` setting (default OFF), which `sessionCreated()` reads fresh through the `childrenFollowParent` dep: off, the child is marked `manual` in that same creating write; on, it follows until moved by hand. Deciding at creation is enough, so flipping the setting leaves existing tabs alone. A hand move makes a following child `manual`; with the setting on, the row menu notes where a child sits (following, placed by hand, parent closed, parent loop; plain text, never a focusable menu item) and offers "Follow parent again" only to a `manual` child, which clears `placement` and is re-derived by the server on save. With the setting off the row menu says nothing about placement. A legacy `PUT /api/session-order` (strip or phone drag, a same-section Ctrl+Shift+{/}, a device without a layout, an admin's cross-owner order) marks a child `manual` ONLY when the requested order splits it from its parent's block (`applyLegacySessionRank`, `childrenSplitFromParent`); a PUT of the current order, or one that moves a family together, leaves every stored placement alone. The browser never echoes a create or delete through that PUT, with or without groups: the service already re-projects the order and broadcasts it (`session:orderChanged`), and `syncSessionOrder` adopts it. A child whose parent is gone stands on its own and is never re-adopted. Saved web tabs are layout refs like sessions, but OPENNESS stays per device: a grouped web tab renders (and takes an Alt+N slot) only while it is open on this device, and reopens where the layout keeps it. +- **Every way in has a keyboard path.** The session row menu (Shift+F10 in the tree, the rail's overflow button) gains Move up/down, Move to , Move to Ungrouped and Move to new group in the vertical rail (only "new group" before the first group exists; nothing on the strip). With **New Session Button on Tabs** on (`showTabNewButton`, per device, `html.tabs-show-new`), every session row menu (the rail, compact or full, the expanded sidebar, the tree; never a tile's, which shares the function) also offers "New session here…", which opens the row's "+" picker: Enter on the "+" itself does nothing (the strip's key handler acts only on the focused tab), and the compact rail hides it. A group header opens its menu with Shift+F10 / ContextMenu, right-click or its hover glyph (a non-focusable, `aria-hidden` span: a treeitem holds no interactive children), and F2 renames it inline (Enter commits and refocuses the header; a commit by BLUR leaves focus where it went, since refocusing from inside the blur handler overrides the user's click). The glyph stays visible under `@media (hover: none)`: a touch tablet has no hover and no long-press `contextmenu`. Group names in "Move to" labels are quoted, so a group named "New group" or "ungrouped" cannot read (or translate, case-insensitively) like the fixed entries. The menu closes on Escape (which it consumes before the global Escape handler), a pointer outside, Tab, focus moving to an element outside it, a resize, a second open and any full re-render. ⚠️ A `focusout` with no `relatedTarget` is not a departure: Safari never focuses a clicked button, so pressing an item blurs the focused one with focus going nowhere, and closing on that dropped the item's click (the tile count menu's rule). The tab "+" picker is this same menu (`_openTabLayoutMenu` with `anyLayout`, so it opens on every layout, not only the editable grouped rail). It borrows the `.tab-rail-action-menu` class for its look only: `closeTabRailActionMenu()` excludes `.tab-layout-group-action-menu`, so closing the row menu (every `session:deleted` does) cannot strand the group menu's listeners. The inline group editor shares `_activeRename` with the session rename, so only the CURRENT editor may release `_inlineRenameActive`. +- **New, child and web tabs land in groups.** A group menu's "New session" (`createSessionInTabGroup`) runs ONE launch with `run({ count: 1, tabGroupId })`; a tab's "+" (`openTabNewSessionMenu`) runs ONE launch in that tab's case or managed worktree through `_launchOnce` (the Git worktrees modal's launcher too), `run({ count: 1, mode, caseName | worktree, tabGroupId? })`, carrying the tab's named group if it has one. ⚠️ The mode rides the call and is never swapped into the Run button's `_runMode`, so a mode picked while the launch is pending is kept, and the case-list reload after a launch that names its case keeps the toolbar's own case (`_reloadCasesAfterRun`; the no-argument reload would re-select the `lastUsedCase` quick-start has just written). ⚠️ The tab's case is found by what the session runs on (`_tabCaseOf`), never by path alone: `GET /api/cases` gives remote and Docker cases a DISPLAY path with the raw values under `remote`/`docker`, so a Docker session belongs to its container's case on its own engine host, a remote one to the case on its own host whose path encloses its own, and a local one to the deepest case enclosing its folder (a display path never encloses one). from an ungrouped tab it sends no `tabGroupId` on purpose (the create schema cannot say "Ungrouped"), so the case default group below applies, as for a toolbar Run. every launch path puts `tabGroupId` on its create request (`POST /api/sessions` or `/api/quick-start`), and `TabLayoutService.sessionCreated(owner, id, { tabGroupId }, register)` places the session at the end of that group in the SAME versioned write that first records it. The session enters the live map inside the owner lock (`register`, rolled back if the write fails), so no queued layout edit can commit it first. The group is looked up in the creating owner's own layout, so an unknown, deleted or foreign id is ignored rather than failing the spawn, and an explicit group is a hand placement (a child placed this way is `manual`). The create response carries the committed `tabLayout`, adopted before the tab is drawn. A session spawned by another one (`parentSessionId`) and placed by nobody lands after its parent: the server's normalization puts it after the parent's rows in the parent's container, and `project({ parents })` draws a not-yet-stored child there at once. Whether it then FOLLOWS its parent is the synced `spawnedTabsFollowParent` setting (default OFF), which `sessionCreated()` reads fresh through the `childrenFollowParent` dep: off, the child is marked `manual` in that same creating write; on, it follows until moved by hand. Deciding at creation is enough, so flipping the setting leaves existing tabs alone. A hand move makes a following child `manual`; with the setting on, the row menu notes where a child sits (following, placed by hand, parent closed, parent loop; plain text, never a focusable menu item) and offers "Follow parent again" only to a `manual` child, which clears `placement` and is re-derived by the server on save. With the setting off the row menu says nothing about placement. A legacy `PUT /api/session-order` (strip or phone drag, a same-section Ctrl+Shift+{/}, a device without a layout, an admin's cross-owner order) marks a child `manual` ONLY when the requested order splits it from its parent's block (`applyLegacySessionRank`, `childrenSplitFromParent`); a PUT of the current order, or one that moves a family together, leaves every stored placement alone. The browser never echoes a create or delete through that PUT, with or without groups: the service already re-projects the order and broadcasts it (`session:orderChanged`), and `syncSessionOrder` adopts it. A child whose parent is gone stands on its own and is never re-adopted. Saved web tabs are layout refs like sessions, but OPENNESS stays per device: a grouped web tab renders (and takes an Alt+N slot) only while it is open on this device, and reopens where the layout keeps it. - **A case's default session group** (`caseDefaultGroups` in state.json, keyed by the layout owner key then case name; a group id or `null` for Ungrouped) is the fallback placement for a new session of that case. It is read and written ONLY through `TabLayoutService` (`caseDefaultGroup`, `setCaseDefaultGroup`, `forgetCaseDefaultGroup`, and `sessionCreated`'s `caseName` hint), under the owner lock, never by a route or the store directly. Precedence inside `sessionCreated`'s single write: an explicit `tabGroupId` found in the owner's layout, then a parent that places the child (`hasPlacingParent`, the same conditions as `pinCreatedChild`), then the case default, else normal placement. The id is looked up in the creating owner's own layout, so a deleted or foreign id never fails a spawn; a stale id is forgotten only AFTER the creating write lands (a failed write rolls back and changes nothing). `POST /api/quick-start` and `POST /api/ralph-loop/start` pass their `caseName`; `POST /api/sessions` infers it LEXICALLY from `workingDir` (`inferLocalCaseName`: a direct child of the caller's case space, or an exact linked-case path for an admin or single-user, or a Claude worktree of either, `/.claude/worktrees/`, which is how the Git worktrees modal launches Claude while every other CLI goes through quick-start), only for a named `workingDir` (never the server's own cwd), never for a remote attach and never touching the filesystem. Cron jobs, the one-shot run route and reboot restore keep normal placement. The Case settings selector (`onCaseDefaultGroupChanged`, session-ui.js) reads it on popover open and is redrawn from an adopted layout whose groups changed (`_refreshCaseDefaultGroupOptions` in `_applyTabLayout`; an unchanged group list is skipped, since every create, delete and reorder broadcasts a layout and a rebuild would reset an open dropdown). - **Only the grouped rail is a tree.** `#sessionTabs` ships as `role=tablist` with `role=tab` rows, and the header strip, sidebar and flat rail keep exactly that. While grouped, `_applyTabListRole` makes it `role=tree` (and restores `tablist` + its label when grouping ends), named-group headers are level-1 `treeitem`s that `aria-owns` their rows' `role=group` (rows sit beside the header, not inside it), and ungrouped rows plus a collapsed group's kept selection are level-1 items. A collapsed header owns nothing, a group with no open rows is a leaf (no `aria-expanded`, no owned group), and the "Ungrouped" heading is `aria-hidden`. Rows are re-roled in the DOM by `_applyTabTreeSemantics` after render, never by rewriting their markup, so a grouped row's content stays the flat row's. - **One tab stop in the tree.** Exactly one treeitem carries `tabindex=0` (the focused or selected item); every control inside a row drops to `-1`, which is why Shift+F10 / ContextMenu open a row's actions from the keyboard. Focus survives a full re-render by identity (`group:`/`session:`/`webview:`; a row a collapse just hid hands focus to its header), but only when focus was already inside the rail. The tree walk (`_tabTreeItems`) follows painted order WITHIN each group when the rail is sorted; the flat list keeps its own whole-list computed-order walk. `aria-posinset`/`aria-setsize` follow painted order too, so the incremental render path re-runs `_applyTabTreePositions` after it re-sorts rows in place. ⚠️ `_handleTabTreeKeydown` acts only when the key lands on the treeitem ITSELF: a key on a focused in-row control (close, overflow, the rename input) is that control's, or Enter on the overflow button re-selects the row instead of reopening its menu. ⚠️ The roving `tabindex=-1` also hides every item but the stop from the keyboard-dismiss selector's `[tabindex]` arm, which is why `MOBILE_KEYBOARD_DISMISS_EXEMPT_SELECTOR` lists `[role="treeitem"]` (see Dismissing the on-screen keyboard). @@ -1025,6 +1025,8 @@ Tests: `test/terminal-touch-tap.test.ts`, `test/session-swipe-gesture.test.ts` ( ⚠️ **The floor is set by the 10th tab onward, not by the tabs you can see**: `.tab-number` renders only for `_tabIdx < 9`, so tab 10 loses 16px + a gap off its left and its centre sits 10px further right. The centre clears the icons when `reserved > icons + rightEdge - leftRunUp - gap` (= 50 + 9 - 17 - 4 = **38px**), hit-testing snaps to whole pixels so 39px still lands on the gear, and the practical floor is 40px — a NUMBERED tab clears it at 20px, which is exactly why reasoning from the tabs on screen would put the centre back on the gear. `test/mobile-tab-tap-zones.test.ts` recomputes that inequality from the stylesheet, so widening the gear or the padding fails there rather than on a phone. The guarantee is centre-off-the-ICONS, not centre-inside-the-label (on a numberless tab it lands in the gap between them, which still switches). Non-active tabs keep their icons hidden and stay tappable end to end. +⚠️ **The opt-in tab "+" is a third icon in that cluster**: with **New Session Button on Tabs** on (`showTabNewButton`, per device, off by default; `html.tabs-show-new`) the active phone tab shows a 28px "+" between the gear and close, and `html.tabs-show-new .session-tab.active .tab-name` grows the reserve to 68px (gear 32 + "+" 28 + close 20 less close's 2px margin needs a 64px label, 66 with the test's rounding allowance), still under the 80px `max-width`. Growing the reserve while the setting is on is fine; shrinking the base one never is. The same test recomputes it with the "+" counted. + ⚠️ Mobile no longer hoists the active session to the front of the strip: that reordering ran on full renders only, so tab order flipped depending on which render path fired, and it renumbered the Alt+N badges. Scroll-into-view replaces it; do not reintroduce it. ### Tab layouts (by state, by case, ledger) diff --git a/docs/wiki/Settings-Reference.md b/docs/wiki/Settings-Reference.md index 19d1676be..ae6180834 100644 --- a/docs/wiki/Settings-Reference.md +++ b/docs/wiki/Settings-Reference.md @@ -121,6 +121,7 @@ memory. Messages expire 12 hours after they were posted either way. See | Tall Tabs | Taller tab strip. | | CLI Logos on Tabs | Each agent tab, and its row on the desktop home rail, shows the CLI's logo before the name. Off hides those logos on this device; the status dot and the shell's SH badge stay, and tiles, split headers and the Run menus keep their logos. On by default. | | Pop-out Button on Tabs | Adds the detach control to tabs, with a per-tab override. | +| New Session Button on Tabs | Adds a **+** beside each tab's gear: pick a CLI the tab's case can start and one session starts in that case (or worktree) and named group, leaving the Run button alone. The row menu offers it as *New session here…*. Per device, off by default. See [The Dashboard](The-Dashboard#session-tabs). | | Spawn Lineage Lines | Lines from each tab to the sessions it spawned; the selected tab's family is drawn thicker. Desktop only, on by default. | | Auto-name Sessions | Titles a new tab after its first prompt, keeping the case prefix (`w3-myapp: fix the login redirect`). Synced, off by default. See [The Dashboard](The-Dashboard#automatic-session-names). | | Spawned Tabs Follow Their Parent | A session started by another one (an agent's worker, a `parentSessionId` spawn) follows its parent's tab into its group and moves with it until you move it by hand; the row menu then says where it sits and offers *Follow parent again*. Off, it starts right after its parent and stays put. Decided when the tab is created, so flipping it leaves existing tabs alone. Synced, off by default. | diff --git a/docs/wiki/The-Dashboard.md b/docs/wiki/The-Dashboard.md index 47854b093..1a67c4124 100644 --- a/docs/wiki/The-Dashboard.md +++ b/docs/wiki/The-Dashboard.md @@ -77,6 +77,14 @@ An agent tab shows its CLI's logo before the name, and a shell tab an `SH` badge on Tabs** (App Settings → Appearance → Tabs) hides the logos on that device; the tile and split headers and the Run menus keep theirs. +**New Session Button on Tabs** (same place, per device, off by default) adds a **+** beside +the tab's gear. It lists the CLIs that tab's case can start, each with its logo and name (a +Docker case lists what its container has), and a pick starts one session in that tab's case, +or the worktree it runs in, and in its named group. The Run button keeps its own mode and case. +Wherever a tab has a row menu (the vertical rail, the sidebar, `Shift+F10` on a grouped row), +the same list is its **New session here…** entry. From a tab outside any named group, the new +session lands in the case's default group if the case has one, as a Run from the toolbar does. + **Status is carried by the dot and the tab's own styling:** | Look | Meaning | diff --git a/src/web/CLAUDE.md b/src/web/CLAUDE.md index 1c884dfce..3dfbefe37 100644 --- a/src/web/CLAUDE.md +++ b/src/web/CLAUDE.md @@ -34,7 +34,7 @@ Frontend JS modules have `@fileoverview` with `@dependency`/`@loadorder` tags. L **Entrance animations** (`entrance-animations.js`, all OFF by default): opt-in animations for tabs, terminal, windows, connection lines and tile-grid tiles, chosen via `data-tab-anim` / `data-term-anim` / `data-win-anim` / `data-line-anim` / `data-tile-anim` on ``; the default `legacy` theme short-circuits every hook (tiles keep the grid's own `settle`). ⚠️ Tabs and lines are destroyed mid-animation on re-render, so re-apply to the fresh element by id with a negative `animation-delay` (resume, never restart). ⚠️ Terminal-pane styles may animate only transform / opacity / clip-path (anything else resizes the PTY via FitAddon); `blur` is the ONE sanctioned `filter` exception, do not generalise it. ⚠️ Line glow lives in `--line-glow` so blur keyframes interpolate. Every animation setting lives in App Settings → **Animations** (`#settings-animations`); put new ones there. ⚠️ Tiles are OFF by default (Animations → Tile Animations, default `settle` = the grid's own fade, no screen beat); a theme only presets it. A tile's FRAME animates transform/opacity only (six at once, no filter) with `tile-enter*`/`tile-leave*` keyframe names (what the mount and the still copy listen for); its SCREEN replays the pane style on `.tile-body.term-enter`; a reload's restore always settles. Persisted per-device in `codeman:*Anim` localStorage keys, never in `SettingsUpdateSchema`; lab at `?animlab=1`. Test: `test/entrance-animations.test.ts`. → [architecture-invariants#entrance-animations](../../docs/architecture-invariants.md#entrance-animations) -**Mobile tab strip scrolling** (issue #257): under 768px the tab strip scrolls horizontally, so the active tab must be kept reachable. `_updateActiveTabImmediate()` reveals it via `computeTabScrollLeft()` (constants.js, rect math on the strip's own `scrollLeft`, never `scrollIntoView()`, which scrolls the document under the fixed header); `_fullRenderSessionTabs()` must restore `scrollLeft` across rebuilds and re-reveal only when the active tab changed (`_lastRenderedActiveTabId`) or, grouped by state, the ACTIVE tab moved to another state band (`_noteActiveTabBand()`, both render paths, scrolling row only; another tab's move never scrolls). ⚠️ The phone-block `min-width` on `.session-tab.active .tab-name` keeps the tab's centre off the gear/close icons, sized for numberless tabs 10+ (floor 40px); do not shrink it. ⚠️ Never reintroduce hoisting the active session to the front of the strip. Tests: `test/mobile-tab-tap-zones.test.ts`, `test/tab-triage.test.ts`. → [architecture-invariants#mobile-tab-strip-scrolling](../../docs/architecture-invariants.md#mobile-tab-strip-scrolling) +**Mobile tab strip scrolling** (issue #257): under 768px the tab strip scrolls horizontally, so the active tab must be kept reachable. `_updateActiveTabImmediate()` reveals it via `computeTabScrollLeft()` (constants.js, rect math on the strip's own `scrollLeft`, never `scrollIntoView()`, which scrolls the document under the fixed header); `_fullRenderSessionTabs()` must restore `scrollLeft` across rebuilds and re-reveal only when the active tab changed (`_lastRenderedActiveTabId`) or, grouped by state, the ACTIVE tab moved to another state band (`_noteActiveTabBand()`, both render paths, scrolling row only; another tab's move never scrolls). ⚠️ The phone-block `min-width` on `.session-tab.active .tab-name` keeps the tab's centre off the gear/close icons, sized for numberless tabs 10+ (floor 40px); do not shrink it. With the opt-in tab "+" on (`showTabNewButton`, `html.tabs-show-new`) a third icon joins them and the reserve grows to 68px, still under the 80px `max-width`. ⚠️ Never reintroduce hoisting the active session to the front of the strip. Tests: `test/mobile-tab-tap-zones.test.ts`, `test/tab-triage.test.ts`. → [architecture-invariants#mobile-tab-strip-scrolling](../../docs/architecture-invariants.md#mobile-tab-strip-scrolling) **Session list layout: header strip or left sidebar** (`sessionListLayout`, default `header`; per-device via `displayKeys`, also in `SettingsUpdateSchema`): the list can move into a collapsible `
+
+
+ New Session Button on Tabs + A + on the tab that starts a session in the same case and group, in any installed CLI. +
+ +
Detach Tiles desktop beta diff --git a/src/web/public/mobile.css b/src/web/public/mobile.css index 6dcd57d29..f4f56b1e0 100644 --- a/src/web/public/mobile.css +++ b/src/web/public/mobile.css @@ -949,6 +949,29 @@ html.mobile-init .file-browser-panel { display: none !important; } + /* The "+" is opt-in (showTabNewButton, per-device; html.tabs-show-new). + Switched on it joins gear + close on the active tab only, and the label + reserve above grows to clear all three (test/mobile-tab-tap-zones). */ + .session-tab .tab-new { + display: none; + } + + html.tabs-show-new .session-tab.active .tab-new { + display: inline-flex; + align-items: center; + justify-content: center; + font-size: 1rem; + line-height: 1; + width: 28px; + height: 32px; + padding: 0; + opacity: 0.8; + } + + html.tabs-show-new .session-tab.active .tab-name { + min-width: 68px; + } + /* Gear icon on active tab - tiny, subtle */ .session-tab.active .tab-gear { display: inline-flex; @@ -3427,7 +3450,8 @@ html:is([data-skin="paper-gray"], [data-skin="solarized-light"], [data-skin="cat @media (hover: none) and (pointer: coarse) { /* Remove hover-dependent UI patterns */ .session-tab .tab-close, - .session-tab .tab-gear { + .session-tab .tab-gear, + .session-tab .tab-new { opacity: 1; width: auto; padding: 0.15rem 0.25rem; @@ -4127,6 +4151,7 @@ html[data-session-list="sidebar"] .session-sidebar .session-tab { Matches the (hover: none) block above, but has to be repeated here because the phone block hides them on non-active tabs with (0,2,0). */ html[data-session-list="sidebar"] .session-sidebar .session-tab .tab-gear, +html[data-session-list="sidebar"] .session-sidebar .session-tab .tab-new, html[data-session-list="sidebar"] .session-sidebar .session-tab .tab-close { display: inline-flex; align-items: center; diff --git a/src/web/public/session-ui.js b/src/web/public/session-ui.js index c4dde6245..38a464f43 100644 --- a/src/web/public/session-ui.js +++ b/src/web/public/session-ui.js @@ -684,9 +684,12 @@ Object.assign(CodemanApp.prototype, { /** * Run using the selected mode (Claude Code, OpenCode, Codex, Gemini, or Antigravity). - * `options` comes from the tab group menu's "New session": `count` overrides - * the instance steppers for this launch only, `tabGroupId` rides every create - * request so the server places the session in that group. + * `options` comes from the tab group menu's "New session", `_launchOnce` (a + * worktree, a tab's "+"): `count` overrides the instance steppers for this + * launch only, `tabGroupId` rides every create request so the server places the + * session in that group, `caseName` launches in that case instead of the + * toolbar's selected one, and `mode` starts that CLI instead of the Run + * button's mode, which is never touched. */ async run(options = {}) { if (this._runInFlight) return; @@ -701,7 +704,7 @@ Object.assign(CodemanApp.prototype, { } try { - const mode = this._runMode || 'claude'; + const mode = options.mode || this._runMode || 'claude'; if (mode === 'shell') { return await this.runShell(options); } @@ -2014,11 +2017,26 @@ Object.assign(CodemanApp.prototype, { } }, + /** The case a launch runs in: its worktree's, the one it names (a tab's "+"), else the toolbar's. */ + _runCaseName(options) { + if (options.worktree) return options.worktree.caseName; + return options.caseName || document.getElementById('quickStartCase').value || 'testcase'; + }, + + /** + * Re-reads the case list after a launch. The no-argument reload re-selects the + * server's `lastUsedCase`, which quick-start has just set to the launched case, + * so a launch in a case it names (a tab's "+") keeps the toolbar on its own. + */ + _reloadCasesAfterRun(options) { + this.loadQuickStartCases(options.caseName ? document.getElementById('quickStartCase').value : null); + }, + async runClaude(options = {}) { // `options.worktree` ({ caseName, name, path }, from the worktree modal): the session runs in // that git worktree of the case and its tab is named after the worktree. const wt = options.worktree || null; - const caseName = wt ? wt.caseName : document.getElementById('quickStartCase').value || 'testcase'; + const caseName = this._runCaseName(options); const nameBase = wt ? wt.name : caseName; const tabCount = this._readTabCount(options.count); const group = options.tabGroupId ? { tabGroupId: options.tabGroupId } : {}; @@ -2123,7 +2141,7 @@ Object.assign(CodemanApp.prototype, { this._appendSessionLaunchStatus(ownsLaunchTerminal, `All ${tabCount} remote session(s) ready`); if (remoteIds[0]) { await this.selectSession(remoteIds[0]); - this.loadQuickStartCases(); + this._reloadCasesAfterRun(options); } this.terminal.focus(); return; @@ -2202,7 +2220,7 @@ Object.assign(CodemanApp.prototype, { // Auto-switch to the new session using selectSession (does proper refresh) if (firstSessionId) { await this.selectSession(firstSessionId); - this.loadQuickStartCases(); + this._reloadCasesAfterRun(options); } this.terminal.focus(); @@ -2249,7 +2267,7 @@ Object.assign(CodemanApp.prototype, { async runShell(options = {}) { // `options.worktree`: see runClaude. const wt = options.worktree || null; - const caseName = wt ? wt.caseName : document.getElementById('quickStartCase').value || 'testcase'; + const caseName = this._runCaseName(options); const nameBase = wt ? wt.name : caseName; // Run Shell reads the toolbar's one instance stepper, like every other run*(); // its own second `− 1 +` group (#shellCount) was removed (#428). @@ -2450,7 +2468,7 @@ Object.assign(CodemanApp.prototype, { // `options.worktree`: see runClaude. Quick-start resolves it server-side (`worktree`), so the // session starts through the same path as any other, only in the worktree's folder. const wt = options.worktree || null; - const caseName = wt ? wt.caseName : document.getElementById('quickStartCase').value || 'testcase'; + const caseName = this._runCaseName(options); // Remote/docker cases run the CLI on the OTHER side — the local status // probe and the local-only config/env below don't apply (quick-start // rejects them for remote cases). @@ -5754,7 +5772,7 @@ Object.assign(CodemanApp.prototype, { const created = r.data.worktree; this.showToast?.(`Worktree ${created.name} created on branch ${created.branch}`, 'success'); this.closeWorktreeModal(); - await this._launchInWorktree({ caseName, name: created.name, path: created.path }, mode); + await this._launchOnce(mode, { worktree: { caseName, name: created.name, path: created.path } }); } finally { if (btn) btn.disabled = false; } @@ -5766,26 +5784,22 @@ Object.assign(CodemanApp.prototype, { if (!caseName || !wt) return; const mode = this._selectedWorktreeMode(); this.closeWorktreeModal(); - await this._launchInWorktree({ caseName, name, path: wt.path }, mode); + await this._launchOnce(mode, { worktree: { caseName, name, path: wt.path } }); }, /** - * Start ONE session of `mode` in a worktree through run(), the Run button's own entry (its - * launch lock, its per-mode paths), with the same temporary `_runMode` swap the custom-model - * entries use, so the Run button's chosen mode is untouched afterwards. + * Start ONE session of `mode` through run(), the Run button's own entry (its launch lock, its + * per-mode paths), outside the toolbar's choices: `runOptions` names a worktree, or a case and + * tab group (a tab's "+"). The mode rides the call (`run({ mode })`) rather than a swap of the + * Run button's mode, so a mode the user picks while the launch is pending is never overwritten. */ - async _launchInWorktree(worktree, mode) { + async _launchOnce(mode, runOptions) { if (this._runInFlight) { this.showToast?.('A launch is already in progress', 'info'); return; } - const previous = this._runMode; - this._runMode = mode || previous; - try { - await this.run({ count: 1, worktree }); - } finally { - this._runMode = previous; - } + if (runOptions.tabGroupId) this.toggleTabGroupCollapsed?.(runOptions.tabGroupId, false); + await this.run({ count: 1, ...runOptions, ...(mode ? { mode } : {}) }); }, /** The Codeman-managed worktree a session runs in, or null (see managedWorktreeOfDir). */ diff --git a/src/web/public/settings-ui.js b/src/web/public/settings-ui.js index db745f450..6053e569b 100644 --- a/src/web/public/settings-ui.js +++ b/src/web/public/settings-ui.js @@ -658,6 +658,8 @@ Object.assign(CodemanApp.prototype, { document.getElementById('appSettingsShowTabDetachButton').checked = this.tabDetachButtonEnabled?.(settings, defaults) ?? (settings.showTabDetachButton ?? defaults.showTabDetachButton ?? false); + // The tab's "+" (applyHeaderVisibilitySettings): per-device, OFF unless switched on. + document.getElementById('appSettingsShowTabNewButton').checked = settings.showTabNewButton === true; // Detach Tiles (tile-grid.js): per-device, OFF unless switched on. The tile // grid is desktop-only, so the row is hidden elsewhere, like the lineage lines. document.getElementById('appSettingsTileDetach').checked = settings.tileDetachEnabled === true; @@ -2978,6 +2980,7 @@ Object.assign(CodemanApp.prototype, { tabArrangement: document.getElementById('appSettingsTabArrangement').value, tabStateOrder: document.getElementById('appSettingsTabStateOrder').value, showTabDetachButton: document.getElementById('appSettingsShowTabDetachButton').checked, + showTabNewButton: document.getElementById('appSettingsShowTabNewButton').checked, tileDetachEnabled: document.getElementById('appSettingsTileDetach').checked, sessionListLayout: document.getElementById('appSettingsSessionListLayout').value, sessionSidebarFontSize: this.resolveSessionSidebarFontSize( @@ -3249,6 +3252,7 @@ Object.assign(CodemanApp.prototype, { gitStatusMaxRepos: _gsm, gitStatusTimeoutSeconds: _gst2, showTabDetachButton: _tdb, + showTabNewButton: _tnb, // Detach Tiles: what a tile drag does is a property of this device's // windows, and the key is absent from the .strict() SettingsUpdateSchema. tileDetachEnabled: _tde, @@ -4387,6 +4391,9 @@ Object.assign(CodemanApp.prototype, { this.tabDetachButtonEnabled?.(settings, defaults) ?? (settings.showTabDetachButton ?? defaults.showTabDetachButton ?? false); document.documentElement.classList.toggle('tabs-show-detach', showTabDetach); + // The tab's "+" (new session beside it): per-device and opt-in everywhere, + // like the pop-out button. On a phone it also widens the active tab (mobile.css). + document.documentElement.classList.toggle('tabs-show-new', settings.showTabNewButton === true); const compactHeader = MobileDetection.getDeviceType() !== 'desktop'; const showFontControls = compactHeader ? false : (settings.showFontControls ?? defaults.showFontControls ?? false); const showSystemStats = compactHeader ? false : (settings.showSystemStats ?? defaults.showSystemStats ?? true); @@ -4904,6 +4911,7 @@ Object.assign(CodemanApp.prototype, { 'autoCopySelection', 'copyStripMargin', 'showSessionButton', 'showAwayDigestButton', 'showCronButton', 'showGitStatus', 'gitStatusTree', 'gitStatusMaxRepos', 'gitStatusTimeoutSeconds', 'showTabDetachButton', + 'showTabNewButton', 'mobileOverviewEnabled', 'sessionLineageLines', 'showSplitButton', diff --git a/src/web/public/styles.css b/src/web/public/styles.css index 49af0d3e8..d82856770 100644 --- a/src/web/public/styles.css +++ b/src/web/public/styles.css @@ -2992,6 +2992,33 @@ html[data-header-stats] .header-system-stats .stat-item[hidden] { transform: rotate(45deg); } +/* "+" (new session beside this tab, app.openTabNewSessionMenu): same reveal as the gear. */ +.session-tab .tab-new { + opacity: 0; + width: 0; + padding: 0; + font-size: 1rem; + line-height: 1; + color: var(--text-muted); + cursor: pointer; + overflow: hidden; + transition: opacity 0.15s, width 0.15s, padding 0.15s; +} +.session-tab.active .tab-new { + opacity: 1; + width: auto; + padding: 0 0.3rem; +} +.session-tab .tab-new:hover { + color: var(--accent-hover); +} + +/* Per-device toggle (App Settings → Tab Bar), mirrored as tabs-show-new on + by settings-ui.js. !important so no layout's reveal rule (sidebar, touch) wins. */ +html:not(.tabs-show-new) .session-tab .tab-new { + display: none !important; +} + /* ===== Session detach / undock (beta/session-detach) ===================== */ /* Pop-out (detach) icon on each tab — mirrors .tab-gear reveal-on-hover. */ @@ -3145,7 +3172,7 @@ html[data-tab-orientation='vertical']:not(.tab-rail-compact) .tab-rail pointer-events: auto; } -html[data-tab-orientation='vertical'].tab-rail-compact .session-tab .tab-actions > :is(.tab-gear, .tab-detach, .tab-close) { +html[data-tab-orientation='vertical'].tab-rail-compact .session-tab .tab-actions > :is(.tab-gear, .tab-new, .tab-detach, .tab-close) { display: none; } @@ -19865,6 +19892,7 @@ html[data-tab-orientation='vertical'] .tab-rail .session-tab .tab-name-row > .ta actions permanently visible on the active session — no layout jitter when the pointer crosses the list. */ html[data-session-list="sidebar"] .session-sidebar .session-tab.active .tab-gear, +html[data-session-list="sidebar"] .session-sidebar .session-tab.active .tab-new, html[data-session-list="sidebar"] .session-sidebar .session-tab.active .tab-detach, html[data-session-list="sidebar"] .session-sidebar .session-tab.active .tab-close { opacity: 1; @@ -20379,6 +20407,7 @@ html[data-session-list="sidebar"][data-sidebar="collapsed"] .session-sidebar .se html[data-session-list="sidebar"][data-sidebar="collapsed"] .session-sidebar .tab-info, html[data-session-list="sidebar"][data-sidebar="collapsed"] .session-sidebar .tab-number, html[data-session-list="sidebar"][data-sidebar="collapsed"] .session-sidebar .tab-gear, +html[data-session-list="sidebar"][data-sidebar="collapsed"] .session-sidebar .tab-new, html[data-session-list="sidebar"][data-sidebar="collapsed"] .session-sidebar .tab-detach, html[data-session-list="sidebar"][data-sidebar="collapsed"] .session-sidebar .tab-close { display: none; diff --git a/src/web/public/tab-rail-resize.js b/src/web/public/tab-rail-resize.js index 0c271c86a..9cdfafc67 100644 --- a/src/web/public/tab-rail-resize.js +++ b/src/web/public/tab-rail-resize.js @@ -327,6 +327,12 @@ Object.assign(CodemanApp.prototype, { const settings = this.loadAppSettingsFromStorage(); const actions = [ { label: 'Session options', run: () => this.openSessionOptions(sessionId) }, + // The row's "+" (showTabNewButton on) as a menu item: its keyboard route, since + // Enter on the "+" itself does nothing, and its only route in the compact rail, + // which hides it. Session rows only: tiles share this menu and carry no "+". + ...(document.documentElement.classList.contains('tabs-show-new') && trigger.closest('.session-tab') + ? [{ label: 'New session here…', run: () => this.openTabNewSessionMenu({ currentTarget: trigger }, sessionId) }] + : []), // Focus and group placement (vertical rail with a tab layout only; [] elsewhere). ...(this._tabRefFocusActions?.({ kind: 'session', id: sessionId }) || []), ...(this._tabRefMoveActions?.({ kind: 'session', id: sessionId }) || []), diff --git a/test/mobile-tab-tap-zones.test.ts b/test/mobile-tab-tap-zones.test.ts index 8ac630bde..ecdf38d5c 100644 --- a/test/mobile-tab-tap-zones.test.ts +++ b/test/mobile-tab-tap-zones.test.ts @@ -162,6 +162,17 @@ describe('phone tab tap zones', () => { expect(phoneDeclarations('.session-tab .tab-gear').display).toBe('none'); }); + it('with the opt-in "+" switched on, the wider reserve clears all three icons', () => { + // showTabNewButton adds a third icon to the active phone tab (html.tabs-show-new) + // and a larger reserve to go with it; the same arithmetic must still hold. + const plus = phoneDeclarations('html.tabs-show-new .session-tab.active .tab-new'); + const wide = len(phoneDeclarations('html.tabs-show-new .session-tab.active .tab-name')['min-width']); + expect(len(plus.width)).toBeGreaterThanOrEqual(28); + expect(wide).toBeGreaterThanOrEqual(requiredReserve() + len(plus.width) + ROUNDING_ALLOWANCE); + expect(wide).toBeLessThanOrEqual(len(nameShared['max-width'])); + expect(phoneDeclarations('.session-tab .tab-new').display).toBe('none'); + }); + it('the pop-out icon stays out of the cluster on handhelds', () => { // `icons` above counts gear + close only. If detach ever became visible on a // phone the reserve would be ~30px short and the centre would walk back. diff --git a/test/routes/case-default-group-routes.test.ts b/test/routes/case-default-group-routes.test.ts index 637d4dad2..6cbd6ae14 100644 --- a/test/routes/case-default-group-routes.test.ts +++ b/test/routes/case-default-group-routes.test.ts @@ -3,13 +3,16 @@ * read and write it, and the create routes that carry the case into the real * TabLayoutService so a new session lands in its case's group in the creating * write (POST /api/sessions, and POST /api/quick-start for local, remote and - * Docker cases). + * Docker cases). The tab "+" picker (app.js) is driven end to end against the + * same routes, since an ungrouped tab relies on this fallback by design. * * Uses app.inject(), so no real HTTP port is needed. */ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { readFileSync } from 'node:fs'; import { mkdir, rm, writeFile } from 'node:fs/promises'; import { join } from 'node:path'; +import { JSDOM } from 'jsdom'; import { homedir } from 'node:os'; import { createRouteTestHarness, type RouteTestHarness } from './_route-test-utils.js'; import { registerSessionRoutes } from '../../src/web/routes/session-routes.js'; @@ -101,6 +104,56 @@ const unwrap = (raw: string) => { return parsed.data ?? parsed; }; +/** + * The shipping frontend in JSDOM, with fetch answered by the route harnesses, so + * a click in the tab "+" picker reaches the real create route and layout service. + */ +async function bootTabApp(route: (url: string, init?: { method?: string; body?: string }) => Promise) { + const dom = new JSDOM('', { + url: 'https://localhost/', + runScripts: 'outside-only', + }); + if (dom.window.document.readyState !== 'complete') { + await new Promise((resolve) => dom.window.addEventListener('load', resolve)); + } + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const win = dom.window as any; + win.setInterval = () => 0; + win.requestAnimationFrame = () => 0; + win.__codemanCliCatalog = [{ id: 'shell', kind: 'shell', label: 'Shell', enabled: true }]; + const publicDir = join(process.cwd(), 'src/web/public'); + win.eval( + 'var MobileDetection = { isTouchDevice: () => false, getDeviceType: () => "desktop" }, KeyboardHandler = {}, ' + + 'SwipeHandler = {}, VoiceInput = {}, DeepgramProvider = {}, NotificationManager = function(){};\n' + + ['constants.js', 'app.js', 'mobile-overview.js', 'session-ui.js'] + .map((file) => readFileSync(join(publicDir, file), 'utf8')) + .join('\n;\n') + + '\n;window.__CdgCodemanApp = CodemanApp;' + ); + win.fetch = async (url: string, init?: { method?: string; body?: string }) => { + const body = await route(url, init); + return { ok: true, status: 200, json: async () => body }; + }; + win.document.body.innerHTML = '
'; + const app = Object.create(win.__CdgCodemanApp.prototype); + Object.assign(app, { + $: (id: string) => win.document.getElementById(id), + sessions: new Map(), + activeSessionId: 'tab-1', + terminal: { clear: () => {}, writeln: () => {}, focus: () => {}, cols: 80, rows: 24 }, + _runMinLockMs: 0, + _runMode: 'claude', + getTerminalDimensions: () => null, + selectSession: async () => {}, + loadQuickStartCases: () => {}, + showToast: vi.fn(), + _applyTabLayout: () => {}, + _onSessionCreated: () => {}, + _renderSessionTabsImmediate: () => {}, + }); + return { app, win }; +} + describe('case default group: create routes', () => { let harness: RouteTestHarness; let world: World; @@ -280,6 +333,63 @@ describe('case default group: create routes', () => { // The route's own error path ran cleanupSession for the registered id. expect(harness.ctx.cleanupSession).toHaveBeenCalledTimes(1); }); + + describe('from a tab "+" in the browser', () => { + /** Opens the tab's "+" picker, picks Shell, and returns the create request and the new session's group. */ + async function launchBeside(tabGroupId: string | null) { + const cases = await createRouteTestHarness(registerCaseRoutes); + const creates: Array> = []; + try { + const { app, win } = await bootTabApp(async (url, init) => { + const payload = init?.body ? JSON.parse(init.body) : undefined; + if (url === '/api/sessions' && init?.method === 'POST') creates.push(payload); + const target = url.startsWith('/api/cases/') ? cases : harness; + const res = await target.app.inject({ method: (init?.method ?? 'GET') as 'GET', url, payload }); + // The envelope server.ts's preSerialization hook adds, which the harness does not install. + const body = res.body ? JSON.parse(res.body) : {}; + return typeof body.success === 'boolean' ? body : { success: true, data: body }; + }); + app.cases = [{ name: CASE, path: caseDir }]; + app.sessions.set('tab-1', { id: 'tab-1', name: 'w1-cdg', workingDir: caseDir }); + app.tabLayout = { + ...world.layouts[OWNER], + groups: world.layouts[OWNER].groups.map((g) => ({ + ...g, + refs: g.id === tabGroupId ? [{ kind: 'session', id: 'tab-1' }] : [], + })), + }; + app.collapsedTabGroupIds = new Set(); + app.toggleTabGroupCollapsed = () => {}; + const plus = win.document.createElement('span'); + win.document.body.appendChild(plus); + app.openTabNewSessionMenu({ preventDefault() {}, stopPropagation() {}, currentTarget: plus }, 'tab-1'); + const shell = [...win.document.querySelectorAll('[aria-label="New session here"] button')].find( + (button: Element) => button.textContent === 'Terminal / Shell' + ) as HTMLElement; + shell.click(); + await vi.waitFor(() => expect(creates).toHaveLength(1)); + await vi.waitFor(() => expect(harness.ctx.sessions.size).toBe(1)); + const [id] = [...harness.ctx.sessions.keys()]; + return { create: creates[0], group: groupOf(world.layouts[OWNER], id), toasts: app.showToast.mock.calls }; + } finally { + await cases.app.close(); + } + } + + it('an ungrouped tab sends no group, so the case default group places the session (chosen on purpose)', async () => { + const { create, group, toasts } = await launchBeside(null); + expect(create).toEqual({ workingDir: caseDir, mode: 'shell', name: `s1-${CASE}` }); + expect(group).toBe('g-ops'); + // The launch went through: no error reported along the way. + expect(toasts.filter(([, kind]: [string, string]) => kind === 'error')).toEqual([]); + }); + + it('a grouped tab sends its own group, which wins over the case default', async () => { + const { create, group } = await launchBeside('g-core'); + expect(create).toEqual({ workingDir: caseDir, mode: 'shell', name: `s1-${CASE}`, tabGroupId: 'g-core' }); + expect(group).toBe('g-core'); + }); + }); }); describe('case default group: GET/PUT /api/cases/:name/default-group', () => { diff --git a/test/tab-group-launch.test.ts b/test/tab-group-launch.test.ts index c0ffe846c..e2f60e674 100644 --- a/test/tab-group-launch.test.ts +++ b/test/tab-group-launch.test.ts @@ -6,7 +6,8 @@ * /api/sessions, remote/docker and external-CLI /api/quick-start), that the * one-session count overrides the toolbar steppers without touching them, that * the layout returned by the create response is adopted BEFORE the new tab is - * drawn (so it is drawn inside its group), and that an ordinary Run sends no + * drawn (so it is drawn inside its group), that a tab's "+" (`caseName`) starts + * in that case rather than the toolbar's, and that an ordinary Run sends no * group at all. * * Port: none (vm sandbox, stubbed fetch). @@ -36,7 +37,7 @@ function harness(caseData: Record, extra: { mode?: string } = { const body = init?.body ? JSON.parse(init.body) : undefined; requests.push({ url, body }); const json = (value: unknown) => ({ json: async () => value }); - if (url === '/api/cases/proj') return json({ success: true, data: caseData }); + if (url.startsWith('/api/cases/')) return json({ success: true, data: caseData }); if (url.endsWith('/status')) return json({ success: true, data: { available: true, runnable: true } }); if (url === '/api/sessions') { const id = `sess-${++counter}`; @@ -124,6 +125,25 @@ describe('launching into a tab group', () => { expect(app._applyTabLayout).toHaveBeenCalledWith(LAYOUT); }); + // A tab's "+" launches beside that tab: its case, never the toolbar's selected one. + it.each([ + // Claude's create body names no mode: it is the server's default. + ['local Claude', 'claude', '/api/sessions', { workingDir: '/tmp/other' }], + ['local Shell', 'shell', '/api/sessions', { mode: 'shell', workingDir: '/tmp/other' }], + ['an external CLI', 'gemini', '/api/quick-start', { mode: 'gemini', caseName: 'other' }], + ])('%s launches in `caseName` instead of the toolbar case', async (_label, mode, url, where) => { + const { app, elements, requests } = harness({ name: 'other', path: '/tmp/other' }, { mode }); + await app.run({ count: 1, caseName: 'other' }); + const made = creates(requests); + expect(made).toHaveLength(1); + expect(made[0].url).toBe(url); + expect(made[0].body).toMatchObject(where); + expect(JSON.stringify(made[0].body)).not.toContain('proj'); + // The local paths look the case up by the given name, never the toolbar's. + if (url === '/api/sessions') expect(requests[0].url).toBe('/api/cases/other'); + expect(elements.quickStartCase.value).toBe('proj'); + }); + it('an ordinary Run sends no group and keeps the stepper count', async () => { const { app, requests } = harness({ name: 'proj', path: '/tmp/proj' }); await app.run(); diff --git a/test/tab-layout-editing.test.ts b/test/tab-layout-editing.test.ts index 6620133d2..b5d703a21 100644 --- a/test/tab-layout-editing.test.ts +++ b/test/tab-layout-editing.test.ts @@ -910,6 +910,22 @@ describe('group menu', () => { expect(app._tabGroupMenuKeydown).toBeNull(); }); + it("Safari's item click (a blur with nowhere to go) keeps the menu open, so the item still runs", () => { + // Safari never focuses a clicked button: the press blurs the focused item + // with relatedTarget null, and closing on that dropped the click. + installFetch(); + const app = makeApp(); + open(app); + app.startTabGroupRename = vi.fn(); + const buttons = document.querySelectorAll('.tab-layout-group-action-menu button'); + const rename = [...buttons].find((button) => button.textContent === 'Rename group')!; + buttons[0].dispatchEvent(new win.FocusEvent('focusout', { bubbles: true, relatedTarget: null })); + expect(isOpen()).toBe(true); + rename.click(); + expect(app.startTabGroupRename).toHaveBeenCalledWith('gx'); + expect(isOpen()).toBe(false); + }); + it('a pointer outside, Tab, focus leaving, a resize, a second open and a re-render all close it', () => { installFetch(); const app = makeApp(); diff --git a/test/tab-new-session.test.ts b/test/tab-new-session.test.ts new file mode 100644 index 000000000..b108ad015 --- /dev/null +++ b/test/tab-new-session.test.ts @@ -0,0 +1,606 @@ +/** + * @fileoverview A session tab's "+" (`showTabNewButton`, per device, off by + * default) and the row menu's "New session here…": the picker of CLIs the + * tab's case can start, and the one-off launch beside that tab. + * + * Driven through the shipping CodemanApp inside JSDOM (app.js, tab-rail-resize.js, + * mobile-overview.js and session-ui.js loaded as the page loads them). Pins: + * - each entry is a Run menu entry (the CLI's logo, then its name), agents in + * catalog order and the shell last, the Run button's mode preselected; + * - the tab's case is found by what the session runs on: a Docker session's + * container, a remote session's host and path, a local session's folder among + * LOCAL cases only (the case records are shaped as GET /api/cases sends them: + * display paths for remote and Docker cases, the raw values nested); + * - which CLIs are offered follows the Run menu's own rule for that case + * (_runModeAvailableForCase): the host's CLIs for a local case, the + * container's for a Docker case (live probe, then the stored list, nothing + * gated for an owned container), only the shell for an unreadable one; + * - a pick runs ONE launch in the tab's case or managed worktree with the tab's + * named group, and an ungrouped tab sends no group (the server's case default + * group then applies, pinned end to end in case-default-group-routes.test.ts); + * - the Run button's mode and the toolbar case are left as they were, also when + * the mode is changed while the launch is pending and when the server has + * moved lastUsedCase to the tab's case; + * - a failed launch reports the server's own error; + * - the row menu offers the picker on session rows only while the setting is on; + * - the picker closes on a re-render and gives focus back to the tab on Escape; + * - the setting stays per device and OFF until switched on, and every new + * string has its zh-CN entry. + * + * Port: none (stubbed fetch). + */ + +import { readFileSync } from 'node:fs'; +import { join } from 'node:path'; +import { JSDOM } from 'jsdom'; +import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'; +import { SettingsUpdateSchema } from '../src/web/schemas.js'; + +const PUBLIC = join(process.cwd(), 'src/web/public'); +const read = (name: string) => readFileSync(join(PUBLIC, name), 'utf8'); + +const CATALOG = [ + { id: 'claude', kind: 'agent', label: 'Claude Code', enabled: true }, + { id: 'shell', kind: 'shell', label: 'Shell', enabled: true }, + { id: 'codex', kind: 'agent', label: 'Codex', enabled: true }, + { id: 'gemini', kind: 'agent', label: 'Gemini CLI', enabled: true }, + { id: 'pi', kind: 'agent', label: 'Pi', enabled: false }, +]; +/** What this host has installed: no gemini. */ +const HOST = new Set(['claude', 'codex']); +/** As GET /api/cases sends them: remote and Docker cases carry a DISPLAY path, the raw values nested. */ +const CASES = [ + { name: 'toolbar-case', path: '/tmp/toolbar-case', location: 'local' }, + // Same container name and same remote path on ANOTHER host, listed first: only the host tells them apart. + { + name: 'box-h2', + path: 'box:/srv/box', + location: 'docker', + docker: { hostId: 'h2', container: 'box', path: '/srv/box', containerWorkdir: '/srv/box' }, + }, + { + name: 'rem2', + path: 'dev@host2:/home/dev/work', + location: 'remote', + remote: { hostId: 'r2', host: 'host2', username: 'dev', path: '/home/dev/work' }, + }, + // Inside proj and listed before it: the deepest enclosing case wins, whatever the order. + { name: 'nested', path: '/tmp/proj/nested', location: 'local' }, + { name: 'proj', path: '/tmp/proj', location: 'local' }, + { + name: 'box', + path: 'box:/srv/box', + location: 'docker', + docker: { + hostId: 'h1', + container: 'box', + path: '/srv/box', + containerWorkdir: '/srv/box', + owned: false, + availableModes: ['gemini', 'shell'], + }, + }, + { + name: 'own', + path: 'own:/srv/own', + location: 'docker', + docker: { hostId: 'h1', container: 'own', path: '/srv/own', containerWorkdir: '/srv/own' }, + }, + // A local case whose folder is the Docker case's workspace: only a LOCAL session belongs to it. + { name: 'clash', path: '/srv/box', location: 'linked-local' }, + { + name: 'rem', + path: 'dev@host1:/home/dev/work', + location: 'remote', + remote: { hostId: 'r1', host: 'host1', username: 'dev', path: '/home/dev/work' }, + }, +]; +const DOCKER_BOX = { hostId: 'h1', containerName: 'box', hostWorkspacePath: '/srv/box', containerWorkdir: '/srv/box' }; +const DOCKER_OWN = { hostId: 'h1', containerName: 'own', hostWorkspacePath: '/srv/own', containerWorkdir: '/srv/own' }; +const REMOTE_R1 = { hostId: 'r1', host: 'host1', username: 'dev', remotePath: '/home/dev/work/api' }; +const SESSIONS: Array<[string, string, Record?]> = [ + ['a', '/tmp/proj/sub'], + ['b', '/tmp/proj'], + ['c', '/srv/box', { docker: DOCKER_BOX }], + ['d', '/srv/own', { docker: DOCKER_OWN }], + ['e', '/home/u/scratch'], + ['f', '/tmp/proj/.claude/worktrees/feat'], + ['g', '/srv/box'], + ['r', '/home/dev/work/api', { remote: REMOTE_R1 }], + ['n', '/tmp/proj/nested/src'], +]; +const LAYOUT = { + version: 4, + updatedAt: '2026-10-01T00:00:00.000Z', + groups: [{ id: 'gx', name: 'Core', refs: [{ kind: 'session', id: 'b' }] }], + ungrouped: ['a', 'c', 'd', 'e', 'f', 'g', 'r', 'n'].map((id) => ({ kind: 'session', id })), +}; + +let CodemanApp: { prototype: Record }; +/** The test's app, so afterEach can close its menus: their document listeners would outlive the DOM. */ +let current: Record | null = null; +let win: any; +let document: Document; + +beforeAll(async () => { + const dom = new JSDOM('', { + url: 'https://localhost/', + runScripts: 'outside-only', + }); + if (dom.window.document.readyState !== 'complete') { + await new Promise((resolve) => dom.window.addEventListener('load', resolve)); + } + win = dom.window; + document = win.document; + win.setInterval = () => 0; + win.requestAnimationFrame = () => 0; + win.CSS = { escape: (value: string) => value }; + win.eval( + 'var MobileDetection = { isTouchDevice: () => false, getDeviceType: () => "desktop" }, KeyboardHandler = {}, ' + + 'SwipeHandler = {}, VoiceInput = {}, DeepgramProvider = {}, NotificationManager = function(){};\n' + + [ + 'constants.js', + 'tab-layout-browser.js', + 'app.js', + 'tab-rail-resize.js', + 'api-client.js', + 'webview-tabs.js', + 'mobile-overview.js', + 'session-ui.js', + ] + .map(read) + .join('\n;\n') + + '\n;window.__TabNewCodemanApp = CodemanApp;' + ); + CodemanApp = win.__TabNewCodemanApp; +}); + +function makeApp({ orientation = 'horizontal' } = {}) { + const app = Object.create(CodemanApp.prototype) as Record; + document.documentElement.setAttribute('data-tab-orientation', orientation); + document.documentElement.dataset.tabRailSort = 'manual'; + document.body.innerHTML = + '
' + + '' + + ''; + app.$ = (id: string) => document.getElementById(id); + app.sessions = new Map( + SESSIONS.map(([id, workingDir, extra]) => [id, { id, name: id, status: 'idle', workingDir, ...extra }]) + ); + app.sessionOrder = SESSIONS.map(([id]) => id); + app.webviews = new Map(); + app.webviewOrder = []; + app.activeSessionId = 'a'; + app.activeWebviewId = null; + app.tabLayout = null; + app.collapsedTabGroupIds = new Set(); + app._hiddenTabGroupByRef = new Map(); + app._lastTabGroupStructureKey = null; + app._tabCollapseStorageFailed = false; + app._inlineRenameActive = false; + app.tabAlerts = new Map(); + app.terminalLoadStates = new Map(); + app.minimizedSubagents = new Map(); + app.hasTabDetachOverride = () => false; + app.renderSubagentTabBadge = () => ''; + app.cancelHideSubagentDropdown = () => {}; + app.updateTabOverflowMode = () => {}; + app.updateConnectionLines = () => {}; + app._applyTabEntrances = () => {}; + app._scrollActiveTabIntoView = () => {}; + app.applySidebarFilter = () => {}; + app.isSessionSidebarActive = () => false; + app._startSidebarRichClock = () => {}; + app._stopSidebarRichClock = () => {}; + app.loadAppSettingsFromStorage = () => ({}); + app.openSessionOptions = vi.fn(); + app.requestCloseSession = vi.fn(); + app.selectSession = vi.fn(async () => {}); + app.showToast = vi.fn(); + app.closeAllPanels = vi.fn(); + // What the real run() reads beyond the DOM (the same stubs as tab-group-launch.test.ts). + app._runMinLockMs = 0; + app._runMode = 'codex'; + app.terminal = { clear: () => {}, writeln: () => {}, focus: () => {}, cols: 80, rows: 24 }; + app.cases = CASES; + app.isCliAvailable = (id: string) => HOST.has(id); + app.getCaseSettings = () => ({}); + app.buildEnvOverrides = () => ({}); + app.getEffortSetting = () => undefined; + app.isRalphTrackerEnabledByDefault = () => false; + app.getTerminalDimensions = () => null; + app.loadQuickStartCases = () => {}; + app._fullRenderSessionTabs(); + app._applyTabLayout(LAYOUT); + current = app; + return app; +} + +const tab = (id: string) => document.querySelector(`.session-tab[data-id="${id}"]`)!; +const plus = (id: string) => tab(id).querySelector('.tab-new')!; +const picker = () => document.querySelector('[role="menu"][aria-label="New session here"]'); +/** Each entry as [logo slot classes, visible text, button classes]. */ +const entries = () => + [...(picker()?.querySelectorAll('button') || [])].map((button) => [ + button.querySelector('.run-mode-dot')?.className, + button.textContent, + button.className, + ]); +const openFrom = (app: Record, id: string) => + app.openTabNewSessionMenu({ preventDefault() {}, stopPropagation() {}, currentTarget: plus(id) }, id); +const pick = (label: string) => + [...picker()!.querySelectorAll('button')].find((button) => button.textContent === label)!.click(); +const rowMenuLabels = () => + [...document.querySelectorAll('.tab-rail-action-menu:not(.tab-layout-group-action-menu) button')].map( + (button) => button.textContent + ); +const flush = async () => { + for (let i = 0; i < 6; i++) await new Promise((resolve) => setTimeout(resolve, 0)); +}; + +beforeEach(() => { + win.localStorage.clear(); + document.body.innerHTML = ''; + document.documentElement.className = ''; + win.__codemanCliCatalog = CATALOG; + win.__codemanUser = { username: 'admin', role: 'admin', multiUser: false }; +}); + +afterEach(() => { + current?.closeTabGroupMenu(); + current?.closeTabRailActionMenu(); + current = null; +}); + +describe('the tab "+" picker', () => { + it('lists the CLIs the case can start as logo + name, agents in catalog order, the shell last', () => { + const app = makeApp(); + // The markup wires the "+" to this picker for its own tab. + expect(plus('a').getAttribute('onclick')).toBe('event.stopPropagation(); app.openTabNewSessionMenu(event, "a")'); + expect(openFrom(app, 'a')).toBe(true); + // gemini is not on this host and pi is disabled in the catalog. + expect(entries()).toEqual([ + ['run-mode-dot claude', 'Claude Code', 'run-mode-option'], + ['run-mode-dot codex', 'Codex', 'run-mode-option selected'], + ['run-mode-dot shell', 'Terminal / Shell', 'run-mode-option'], + ]); + for (const dot of picker()!.querySelectorAll('.run-mode-dot')) expect(dot.getAttribute('aria-hidden')).toBe('true'); + // The Run button's mode is the one preselected for the keyboard. + expect(document.activeElement?.textContent).toBe('Codex'); + }); + + it("a Docker case offers the container's CLIs through the Run menu's rule, never the host's", () => { + const app = makeApp(); + // Adopted, not probed yet: the list stored at adoption (gemini, which the host lacks; no claude, which it has). + openFrom(app, 'c'); + expect(entries()).toEqual([ + ['run-mode-dot gemini', 'Gemini CLI', 'run-mode-option'], + ['run-mode-dot shell', 'Terminal / Shell', 'run-mode-option'], + ]); + app.closeTabGroupMenu(); + // A live probe wins over the stored list. + app._dockerCaseModes = { box: ['claude', 'shell'] }; + openFrom(app, 'c'); + expect(entries()).toEqual([ + ['run-mode-dot claude', 'Claude Code', 'run-mode-option'], + ['run-mode-dot shell', 'Terminal / Shell', 'run-mode-option'], + ]); + app.closeTabGroupMenu(); + // An unreadable adopted container offers no agent at all. + app._dockerCaseProbeError = { box: 'Could not read container "box".' }; + openFrom(app, 'c'); + expect(entries()).toEqual([['run-mode-dot shell', 'Terminal / Shell', 'run-mode-option']]); + app.closeTabGroupMenu(); + // An owned container with nothing probed is not gated: its image ships every CLI. + openFrom(app, 'd'); + expect(entries()).toEqual([ + ['run-mode-dot claude', 'Claude Code', 'run-mode-option'], + ['run-mode-dot codex', 'Codex', 'run-mode-option selected'], + ['run-mode-dot gemini', 'Gemini CLI', 'run-mode-option'], + ['run-mode-dot shell', 'Terminal / Shell', 'run-mode-option'], + ]); + }); + + it('finds the case by what the session runs on, never a same-path case elsewhere', async () => { + const app = makeApp(); + const runs: unknown[] = []; + app.run = vi.fn(async (options: unknown) => { + runs.push(structuredClone(options)); + }); + // A Docker session belongs to its container's case, not to the local case at the same path. + openFrom(app, 'c'); + pick('Terminal / Shell'); + // A local session at that path belongs to the local case, never the Docker one. + openFrom(app, 'g'); + expect(entries().map(([, text]) => text)).toEqual(['Claude Code', 'Codex', 'Terminal / Shell']); + pick('Terminal / Shell'); + // A remote session belongs to the case on its own host, by path prefix. + openFrom(app, 'r'); + pick('Terminal / Shell'); + // A local session in a case inside another case belongs to the inner one. + openFrom(app, 'n'); + pick('Terminal / Shell'); + await flush(); + expect(runs).toStrictEqual([ + { count: 1, caseName: 'box', mode: 'shell' }, + { count: 1, caseName: 'clash', mode: 'shell' }, + { count: 1, caseName: 'rem', mode: 'shell' }, + { count: 1, caseName: 'nested', mode: 'shell' }, + ]); + }); + + it('a tab whose folder is no case says so and opens nothing', () => { + const app = makeApp(); + expect(openFrom(app, 'e')).toBe(false); + expect(picker()).toBeNull(); + expect(app.showToast).toHaveBeenCalledWith( + "This tab's folder is not a case, so there is nothing to start beside it.", + 'info' + ); + }); +}); + +describe('a pick from the "+"', () => { + function recordRuns(app: Record) { + const runs: Array<{ options: unknown; runMode: string }> = []; + app.run = vi.fn(async (options: unknown) => { + // Cloned into this realm, so toStrictEqual compares keys and values, not JSDOM's prototypes. + runs.push({ options: structuredClone(options), runMode: app._runMode }); + }); + return runs; + } + + it("starts ONE session in the tab's case and named group, then leaves the Run mode and case alone", async () => { + const app = makeApp(); + const runs = recordRuns(app); + app.collapsedTabGroupIds.add('gx'); + openFrom(app, 'b'); + pick('Claude Code'); + await flush(); + // Its group is opened, so the new tab is visible when it arrives. + expect(app.collapsedTabGroupIds.has('gx')).toBe(false); + // The mode rides the launch; the Run button's own mode is never swapped. + expect(runs).toStrictEqual([ + { options: { count: 1, caseName: 'proj', tabGroupId: 'gx', mode: 'claude' }, runMode: 'codex' }, + ]); + expect(picker()).toBeNull(); + expect(app._runMode).toBe('codex'); + expect((document.getElementById('quickStartCase') as HTMLSelectElement).value).toBe('toolbar-case'); + }); + + it('from an ungrouped tab it sends no group at all, so the case default group can place it', async () => { + const app = makeApp(); + const runs = recordRuns(app); + openFrom(app, 'a'); + pick('Terminal / Shell'); + await flush(); + expect(runs).toStrictEqual([{ options: { count: 1, caseName: 'proj', mode: 'shell' }, runMode: 'codex' }]); + }); + + it('from a tab in a managed worktree it starts in that worktree, not the case folder', async () => { + const app = makeApp(); + const runs = recordRuns(app); + openFrom(app, 'f'); + pick('Codex'); + await flush(); + expect(runs).toStrictEqual([ + { + options: { + count: 1, + worktree: { caseName: 'proj', name: 'feat', path: '/tmp/proj/.claude/worktrees/feat' }, + mode: 'codex', + }, + runMode: 'codex', + }, + ]); + }); + + it('refuses while another launch is in flight, and says so', async () => { + const app = makeApp(); + const runs = recordRuns(app); + openFrom(app, 'a'); + app._runInFlight = true; + app.showToast.mockClear(); + pick('Codex'); + await flush(); + expect(runs).toEqual([]); + expect(app.showToast).toHaveBeenCalledWith('A launch is already in progress', 'info'); + expect(app._runMode).toBe('codex'); + }); + + it("a launch the server refuses reports the server's own error", async () => { + const app = makeApp(); + app._runMode = 'claude'; + const posted: Array<{ url: string; body: unknown }> = []; + win.fetch = vi.fn(async (url: string, init?: { body?: string }) => { + const body = init?.body ? JSON.parse(init.body) : undefined; + posted.push({ url, body }); + const json = (value: unknown) => ({ ok: true, status: 200, json: async () => value }); + if (url === '/api/codex/status') return json({ success: true, data: { available: true, runnable: true } }); + if (url === '/api/quick-start') { + return json({ success: false, error: 'Case "proj" has no free session slot', errorCode: 'CONFLICT' }); + } + return json({ success: true, data: {} }); + }); + openFrom(app, 'a'); + app.showToast.mockClear(); + pick('Codex'); + await flush(); + expect(posted.find((req) => req.url === '/api/quick-start')?.body).toMatchObject({ + caseName: 'proj', + mode: 'codex', + }); + expect(app.showToast).toHaveBeenLastCalledWith('Case "proj" has no free session slot', 'error'); + expect(app._runMode).toBe('claude'); + }); + + it('a Run mode chosen while the launch is pending is kept, on screen and on disk', async () => { + const app = makeApp(); + app._applyRunMode = () => {}; + app._apiPut = vi.fn(async () => ({ success: true })); + let finish: () => void = () => {}; + app.runShell = vi.fn(() => new Promise((resolve) => (finish = resolve))); + openFrom(app, 'a'); + pick('Terminal / Shell'); + await flush(); + expect(app.runShell).toHaveBeenCalledTimes(1); + app.setRunMode('claude'); + finish(); + await flush(); + expect(app._runMode).toBe('claude'); + expect(win.localStorage.getItem('codeman_runMode')).toBe('claude'); + expect(app._apiPut).toHaveBeenCalledWith('/api/settings', { runMode: 'claude' }); + }); + + it.each([ + ['a remote Claude launch (quick-start)', 'rem', 'r'], + ['a local Claude launch', 'proj', 'a'], + ])("%s leaves the toolbar on its own case, though the server's lastUsedCase moved", async (_label, tabCase, id) => { + const app = makeApp(); + app._runMode = 'claude'; + app.getAdvisorSetting = () => undefined; + app._onSessionCreated = () => {}; + delete app.loadQuickStartCases; // the shipping reload, not makeApp's no-op + // The UI-only steps of the case list reload. + for (const name of ['updateDirDisplayForCase', 'updateMobileCaseLabel', 'updateCasePickerInput']) + app[name] = () => {}; + for (const name of ['renderCasePickerList', 'closeCasePicker', 'setupQuickStartCasePicker']) app[name] = () => {}; + const select = document.getElementById('quickStartCase') as HTMLSelectElement; + const record = CASES.find((c) => c.name === tabCase)!; + win.fetch = vi.fn(async (url: string) => { + const json = (value: unknown) => ({ ok: true, status: 200, json: async () => value }); + // quick-start saved the launched case as lastUsedCase (session-routes.ts). + if (url === '/api/settings') return json({ success: true, data: { lastUsedCase: tabCase } }); + if (url === '/api/cases') return json({ success: true, data: CASES }); + if (url === `/api/cases/${tabCase}`) return json({ success: true, data: record }); + if (url === '/api/quick-start') + return json({ success: true, data: { sessionId: 'new1', session: { id: 'new1' } } }); + if (url === '/api/sessions') return json({ success: true, data: { session: { id: 'new1', name: 'w1' } } }); + return json({ success: true, data: {} }); + }); + openFrom(app, id); + pick('Claude Code'); + await vi.waitFor(() => expect(app.selectSession).toHaveBeenCalledWith('new1')); + // The case list is reloaded after the launch (not awaited by it). + await vi.waitFor(() => expect(win.fetch.mock.calls.map((call: unknown[]) => call[0])).toContain('/api/cases')); + await flush(); + expect(select.value).toBe('toolbar-case'); + }); +}); + +describe('"New session here…" in the row menu', () => { + const rowMenu = (app: Record, trigger: HTMLElement, id: string) => + app.openTabRailActionMenu({ preventDefault() {}, stopPropagation() {}, currentTarget: trigger }, id); + + it('is offered on a session row only while the "+" is switched on, and opens the same picker', () => { + const app = makeApp({ orientation: 'vertical' }); + rowMenu(app, tab('a'), 'a'); + expect(rowMenuLabels()).toEqual([ + 'Session options', + 'Add to Focus', + 'Move down', + 'Move to "Core"', + 'Move to new group', + 'Close session', + ]); + app.closeTabRailActionMenu(); + + document.documentElement.classList.add('tabs-show-new'); + rowMenu(app, tab('a'), 'a'); + expect(rowMenuLabels()).toEqual([ + 'Session options', + 'New session here…', + 'Add to Focus', + 'Move down', + 'Move to "Core"', + 'Move to new group', + 'Close session', + ]); + [...document.querySelectorAll('.tab-rail-action-menu button')] + .find((button) => button.textContent === 'New session here…')! + .click(); + expect(rowMenuLabels()).toEqual([]); + expect(entries().map(([, text]) => text)).toEqual(['Claude Code', 'Codex', 'Terminal / Shell']); + // Escape gives the keyboard back to the tab row. + document.activeElement!.dispatchEvent( + new win.KeyboardEvent('keydown', { key: 'Escape', bubbles: true, cancelable: true }) + ); + expect(picker()).toBeNull(); + expect(document.activeElement).toBe(tab('a')); + }); + + it("is never offered on a tile's menu, which shares the function", () => { + const app = makeApp({ orientation: 'vertical' }); + document.documentElement.classList.add('tabs-show-new'); + const tileButton = document.createElement('button'); + tileButton.className = 'tile-menu'; + document.body.appendChild(tileButton); + rowMenu(app, tileButton, 'a'); + expect(rowMenuLabels()).toEqual([ + 'Session options', + 'Add to Focus', + 'Move down', + 'Move to "Core"', + 'Move to new group', + 'Close session', + ]); + }); +}); + +describe('picker lifetime', () => { + it('a full re-render closes it and drops its document listeners', () => { + const app = makeApp(); + openFrom(app, 'a'); + expect(picker()).not.toBeNull(); + app.sessions.delete('a'); + app._fullRenderSessionTabs(); + expect(picker()).toBeNull(); + expect(app._tabGroupMenuOutside).toBeNull(); + expect(app._tabGroupMenuKeydown).toBeNull(); + expect(app._tabGroupMenuResize).toBeNull(); + }); + + it('pressing the same "+" again closes it, like the row menu', () => { + const app = makeApp(); + openFrom(app, 'a'); + expect(openFrom(app, 'a')).toBe(false); + expect(picker()).toBeNull(); + }); +}); + +describe('showTabNewButton stays per-device and OFF by default', () => { + const settingsUi = read('settings-ui.js'); + const index = read('index.html'); + + it('is a display key, stripped from the settings PUT, which would refuse it', () => { + const start = settingsUi.indexOf('const displayKeys = new Set(['); + expect(settingsUi.slice(start, settingsUi.indexOf('])', start))).toContain("'showTabNewButton'"); + expect(settingsUi).toContain('showTabNewButton: _tnb,'); + expect(SettingsUpdateSchema.safeParse({ showTabNewButton: true }).success).toBe(false); + }); + + it('loads OFF unless switched on, saves from its switch, and drives the class the CSS reads', () => { + expect(settingsUi).toContain( + "document.getElementById('appSettingsShowTabNewButton').checked = settings.showTabNewButton === true;" + ); + expect(settingsUi).toContain("showTabNewButton: document.getElementById('appSettingsShowTabNewButton').checked,"); + expect(settingsUi).toContain( + "document.documentElement.classList.toggle('tabs-show-new', settings.showTabNewButton === true);" + ); + expect(index).toContain(''); + }); + + it('translates every string it adds', () => { + const i18n = read('i18n.js'); + for (const text of [ + "'New session here'", + "'New session here…'", + '"New session in this tab\'s case"', + '"This tab\'s folder is not a case, so there is nothing to start beside it."', + "'New Session Button on Tabs'", + "'A + on the tab that starts a session in the same case and group, in any installed CLI.'", + ]) { + expect(i18n).toContain(`${text}:`); + } + }); +}); diff --git a/test/worktree-ui.test.ts b/test/worktree-ui.test.ts index 4702af30b..66681fa27 100644 --- a/test/worktree-ui.test.ts +++ b/test/worktree-ui.test.ts @@ -6,7 +6,7 @@ * - a worktree launch goes through the EXISTING create paths with the worktree's folder: * POST /api/sessions with `workingDir` for claude and shell, quick-start with `worktree` for * every other CLI; never a case lookup or scaffold, and tabs named after the worktree; - * - `_launchInWorktree()` swaps the run mode for the one launch and puts it back. + * - `_launchOnce()` starts the one launch in the chosen mode without touching the Run button's mode. * * Port: N/A. */ @@ -138,15 +138,15 @@ describe('launching in a worktree', () => { }); }); - it('_launchInWorktree runs ONE session of the chosen mode and restores the Run button mode', async () => { + it("_launchOnce runs ONE session of the chosen mode in the worktree and leaves the Run button's mode", async () => { const { app } = load(async () => json({})); app._runMode = 'claude'; const seen: Array<{ mode: string; options: unknown }> = []; app.run = vi.fn(async (options: unknown) => { seen.push({ mode: app._runMode, options }); }); - await app._launchInWorktree(WT, 'codex'); - expect(seen).toEqual([{ mode: 'codex', options: { count: 1, worktree: WT } }]); + await app._launchOnce('codex', { worktree: WT }); + expect(seen).toEqual([{ mode: 'claude', options: { count: 1, worktree: WT, mode: 'codex' } }]); expect(app._runMode).toBe('claude'); }); From af4b65697af0a12490b9539949524da0a42dcb63 Mon Sep 17 00:00:00 2001 From: Aamer Akhter Date: Sat, 24 Oct 2026 20:41:14 -0400 Subject: [PATCH 12/68] fix(agent): COD-549 preserve spawned ID on readiness transport errors --- .changeset/tidy-workers-return.md | 5 +++++ src/cli-agent.ts | 34 +++++++++++++++++-------------- test/cli-agent.test.ts | 29 ++++++++++++++++++++++++++ 3 files changed, 53 insertions(+), 15 deletions(-) create mode 100644 .changeset/tidy-workers-return.md diff --git a/.changeset/tidy-workers-return.md b/.changeset/tidy-workers-return.md new file mode 100644 index 000000000..812810a03 --- /dev/null +++ b/.changeset/tidy-workers-return.md @@ -0,0 +1,5 @@ +--- +'aicodeman': patch +--- + +Preserve the created session ID in agent spawn output when the readiness request fails with a transport error. diff --git a/src/cli-agent.ts b/src/cli-agent.ts index 6a5fad476..ac96664b3 100644 --- a/src/cli-agent.ts +++ b/src/cli-agent.ts @@ -555,21 +555,25 @@ export async function agentSpawn(deps: AgentDeps, options: SpawnOptions): Promis let dead = false; const mark = composerReadyMark(options.mode); if (options.ready && mark) { - const wait = await deps.request(deps.ctx, { - method: 'GET', - path: `/api/v1/sessions/${encodeURIComponent(sid)}/wait-output`, - query: { match: mark, from: 'buffer', timeout: options.timeoutMs }, - timeoutMs: options.timeoutMs + 10_000, - }); - // A failed readiness call (waiter cap, 400, network) is its own error, not "the - // composer never showed up": report the real reason instead of the trust-dialog hint. - if (!wait.json?.success) readinessError = describeFailure(wait); - else { - const result = (wait.json.data as { wait?: WaitResult } | undefined)?.wait; - // A worker that died while we waited is exit 3 like every other wait, not a - // "composer not seen" timeout that sends the caller looking for a dialog. - dead = waitExitCode(result) === EXIT.dead; - ready = !dead && Boolean(result?.matched); + try { + const wait = await deps.request(deps.ctx, { + method: 'GET', + path: `/api/v1/sessions/${encodeURIComponent(sid)}/wait-output`, + query: { match: mark, from: 'buffer', timeout: options.timeoutMs }, + timeoutMs: options.timeoutMs + 10_000, + }); + // A failed readiness call (waiter cap, 400, network) is its own error, not "the + // composer never showed up": report the real reason instead of the trust-dialog hint. + if (!wait.json?.success) readinessError = describeFailure(wait); + else { + const result = (wait.json.data as { wait?: WaitResult } | undefined)?.wait; + // A worker that died while we waited is exit 3 like every other wait, not a + // "composer not seen" timeout that sends the caller looking for a dialog. + dead = waitExitCode(result) === EXIT.dead; + ready = !dead && Boolean(result?.matched); + } + } catch (error) { + readinessError = getErrorMessage(error); } } diff --git a/test/cli-agent.test.ts b/test/cli-agent.test.ts index c6331aadc..604549912 100644 --- a/test/cli-agent.test.ts +++ b/test/cli-agent.test.ts @@ -548,6 +548,35 @@ describe('agent spawn', () => { expect(deps.calls).toHaveLength(1); expect(deps.err.join('')).toMatch(/SESSION_BUSY/); }); + + it.each([false, true])('preserves the created id when readiness throws (json=%s)', async (json) => { + const deps = fakeDeps((options) => { + if (options.path === '/api/v1/quick-start') return ok({ sessionId: OTHER, caseName: 'c' }); + throw new Error('readiness connection reset'); + }, json); + const exit = await agentSpawn(deps, { caseName: 'c', mode: 'claude', ready: true, timeoutMs: 1000 }).catch( + () => undefined + ); + + if (json) { + expect(deps.out).toHaveLength(1); + expect(JSON.parse(deps.out[0])).toEqual({ + sessionId: OTHER, + caseName: 'c', + readinessError: 'readiness connection reset', + }); + expect(deps.err).toEqual([]); + } else { + expect(deps.out).toEqual([OTHER]); + expect(deps.err.join('')).toContain('readiness check failed: readiness connection reset'); + expect(deps.err.join('')).not.toMatch(/composer not seen|startup dialog/); + } + expect(exit).toBe(EXIT.error); + expect(deps.calls.map(({ method, path }) => ({ method, path }))).toEqual([ + { method: 'POST', path: '/api/v1/quick-start' }, + { method: 'GET', path: `/api/v1/sessions/${OTHER}/wait-output` }, + ]); + }); }); describe('session id prefixes', () => { From e8ea8b0715f854ccdcee6c2d42377a68af47a8fb Mon Sep 17 00:00:00 2001 From: Aamer Akhter Date: Sat, 24 Oct 2026 20:43:17 -0400 Subject: [PATCH 13/68] feat(history): COD-549 read saved Codex conversations offline --- .changeset/offline-codex-history.md | 5 + docs/offline-history.md | 52 ++ plugins/codeman/skills/codeman/SKILL.md | 15 +- .../skills/codeman/reference/history.md | 51 ++ skills/codeman/SKILL.md | 15 +- skills/codeman/reference/history.md | 51 ++ src/cli-history.ts | 47 ++ src/cli.ts | 2 + src/local-history.ts | 484 ++++++++++++++++++ test/local-history.test.ts | 246 +++++++++ 10 files changed, 960 insertions(+), 8 deletions(-) create mode 100644 .changeset/offline-codex-history.md create mode 100644 docs/offline-history.md create mode 100644 plugins/codeman/skills/codeman/reference/history.md create mode 100644 skills/codeman/reference/history.md create mode 100644 src/cli-history.ts create mode 100644 src/local-history.ts create mode 100644 test/local-history.test.ts diff --git a/.changeset/offline-codex-history.md b/.changeset/offline-codex-history.md new file mode 100644 index 000000000..57090231b --- /dev/null +++ b/.changeset/offline-codex-history.md @@ -0,0 +1,5 @@ +--- +'aicodeman': minor +--- + +Add offline `codeman history read` for saved Codex transcripts by file, native session ID, or conversation family, with whole-turn topic/date filters and bounded local reads and output. diff --git a/docs/offline-history.md b/docs/offline-history.md new file mode 100644 index 000000000..219aabc14 --- /dev/null +++ b/docs/offline-history.md @@ -0,0 +1,52 @@ +# Saved Codex history (offline) + +Use `codeman history read` for retained local Codex JSONL transcripts. This command +works outside a managed session and never calls the HTTP API. Do not bootstrap +API credentials or require `CODEMAN_MUX` for this read-only job. + +Choose exactly one selector: + +```bash +codeman history read --file /path/to/rollout.jsonl +codeman history read --session-id aaaaaaaa --sessions-root /path/to/sessions +codeman history read --root-session-id aaaaaaaa-1111-4111-8111-111111111111 \ + --topic 'authentication' --after 2026-10-01 --before 2026-10-24 \ + --max-bytes 65536 +``` + +`--session-id` accepts a native conversation UUID or unique prefix of at least +8 characters; identity comes from metadata, not filenames. `--root-session-id` +requires a full native UUID and includes transitive descendants found in metadata. +The default store is `$CODEX_HOME/sessions`, or `~/.codex/sessions` when unset. +`--file` selects one file and does not search for its ancestors or children. + +Topic matching is a case-insensitive literal substring. Dates are inclusive +`YYYY-MM-DD` bounds. Each filter selects whole user/assistant turns; a turn is +retained if it has a date match and a topic match, which may be different messages +in that turn. Tool calls/results and injected context are omitted. Adjacent duplicate +native representations are collapsed; repeated messages from the same +representation are retained. + +JSON is the default (`--json` or `--format json`); `--format text` is available. +Results identify source paths, native session/root IDs, timestamps, roles and phases, +plus scan counters and `complete`. A root ID is `null` when ancestry cannot be proven. +Report that uncertainty; never relabel the queried child as its own root. +`complete: false` reports malformed or partial records, skipped links, unattributed +files or missing ancestors. It does not prove the store includes every conversation +ever created, and a filtered result is only evidence for its selected scope. + +Reads require regular UTF8 files and reject final-component symlinks, conflicting +metadata, ambiguous IDs, cyclic ancestry, invalid selectors and changed selected +files. Directory traversal skips symlinks. Parent directories of an explicitly +supplied path must be trusted; this is not a filesystem sandbox. + +The default total metadata/transcript scan budget is 16 MiB; `--scan-bytes` allows +1–67108864 bytes. Directory scans are capped at 8192 entries and depth 8. The output +budget is 8192 bytes by default; `--max-bytes` allows 1–1048576 bytes, including the +final newline. Exhausted budgets fail with an error: output is never silently +truncated or split into partial JSON or turns. Narrow the store/filters or explicitly +raise the relevant budget. Missing evidence is an error, not an empty success. + +This command does not inspect live panes, stage prompts, submit input or manage +sessions. Live session operations use the separate `codeman agent` commands and +require the managed-session environment and running server. diff --git a/plugins/codeman/skills/codeman/SKILL.md b/plugins/codeman/skills/codeman/SKILL.md index 50133e8c9..a1328e4ba 100644 --- a/plugins/codeman/skills/codeman/SKILL.md +++ b/plugins/codeman/skills/codeman/SKILL.md @@ -8,12 +8,19 @@ description: >- read their output, and clean up; where available, message claude workers directly (Claude Code cross-session messaging). Use when asked to orchestrate or parallelize work across Codeman sessions, get a second opinion from another CLI or model, watch - another session, or start and manage workers. Only usable inside a Codeman-managed - session (CODEMAN_MUX=1); refuse to act otherwise. + another session, start and manage workers, or retrieve saved local Codex history + by file/native ID or root and descendants with topic/date filters. Live API + operations require a Codeman-managed session (CODEMAN_MUX=1). Offline history + reads do not require a server or managed session. --- # Driving Codeman from inside a session +For **saved Codex conversation history**, use `codeman history read` and +[reference/history.md](reference/history.md). This is a read-only local command: +route to it **before §0**, including outside a managed session. It needs no API +URL, credentials or running server. All live API operations below retain §0. + You are an agent running inside a Codeman-managed terminal session. Codeman is the server that spawned you; its HTTP API can start, prompt, watch, and delete other sessions. @@ -43,8 +50,8 @@ machine has no `jq` for the preamble below. ## 0. Guard and bootstrap -If `CODEMAN_MUX` is not `1`, **stop and say so**. Do not guess an API URL; a server -you are not part of is not yours to drive. +For live API operations, if `CODEMAN_MUX` is not `1`, **stop and say so**. +Do not guess an API URL; a server you are not part of is not yours to drive. Two failures come from your CLI, not from Codeman: name them to the user instead of retrying. diff --git a/plugins/codeman/skills/codeman/reference/history.md b/plugins/codeman/skills/codeman/reference/history.md new file mode 100644 index 000000000..0fa872058 --- /dev/null +++ b/plugins/codeman/skills/codeman/reference/history.md @@ -0,0 +1,51 @@ +# Saved Codex history (offline) + +Use `codeman history read` for retained local Codex JSONL transcripts. This command +works outside a managed session and never calls the HTTP API. Do not bootstrap +API credentials or require `CODEMAN_MUX` for this read-only job. + +Choose exactly one selector: + +```bash +codeman history read --file /path/to/rollout.jsonl +codeman history read --session-id aaaaaaaa --sessions-root /path/to/sessions +codeman history read --root-session-id aaaaaaaa-1111-4111-8111-111111111111 \ + --topic 'authentication' --after 2026-10-01 --before 2026-10-24 \ + --max-bytes 65536 +``` + +`--session-id` accepts a native conversation UUID or unique prefix of at least +8 characters; identity comes from metadata, not filenames. `--root-session-id` +requires a full native UUID and includes transitive descendants found in metadata. +The default store is `$CODEX_HOME/sessions`, or `~/.codex/sessions` when unset. +`--file` selects one file and does not search for its ancestors or children. + +Topic matching is a case-insensitive literal substring. Dates are inclusive +`YYYY-MM-DD` bounds. Each filter selects whole user/assistant turns; a turn is +retained if it has a date match and a topic match, which may be different messages +in that turn. Tool calls/results and injected context are omitted. Adjacent duplicate +native representations are collapsed; repeated messages from the same +representation are retained. + +JSON is the default (`--json` or `--format json`); `--format text` is available. +Results identify source paths, native session/root IDs, timestamps, roles and phases, +plus scan counters and `complete`. A root ID is `null` when ancestry cannot be proven. +Report that uncertainty; never relabel the queried child as its own root. +`complete: false` reports malformed or partial records, skipped links, unattributed +files or missing ancestors. It does not prove the store includes every conversation +ever created, and a filtered result is only evidence for its selected scope. + +Reads require regular UTF8 files and reject final-component symlinks, conflicting +metadata, ambiguous IDs, cyclic ancestry, invalid selectors and changed selected +files. Directory traversal skips symlinks. Parent directories of an explicitly +supplied path must be trusted; this is not a filesystem sandbox. + +The default total metadata/transcript scan budget is 16 MiB; `--scan-bytes` allows +1–67108864 bytes. Directory scans are capped at 8192 entries and depth 8. The output +budget is 8192 bytes by default; `--max-bytes` allows 1–1048576 bytes, including the +final newline. Exhausted budgets fail with an error: output is never silently +truncated or split into partial JSON or turns. Narrow the store/filters or explicitly +raise the relevant budget. Missing evidence is an error, not an empty success. + +This command does not inspect live panes, stage prompts, submit input or manage +sessions. For live operations return to the managed-session guard in the skill. diff --git a/skills/codeman/SKILL.md b/skills/codeman/SKILL.md index 50133e8c9..a1328e4ba 100644 --- a/skills/codeman/SKILL.md +++ b/skills/codeman/SKILL.md @@ -8,12 +8,19 @@ description: >- read their output, and clean up; where available, message claude workers directly (Claude Code cross-session messaging). Use when asked to orchestrate or parallelize work across Codeman sessions, get a second opinion from another CLI or model, watch - another session, or start and manage workers. Only usable inside a Codeman-managed - session (CODEMAN_MUX=1); refuse to act otherwise. + another session, start and manage workers, or retrieve saved local Codex history + by file/native ID or root and descendants with topic/date filters. Live API + operations require a Codeman-managed session (CODEMAN_MUX=1). Offline history + reads do not require a server or managed session. --- # Driving Codeman from inside a session +For **saved Codex conversation history**, use `codeman history read` and +[reference/history.md](reference/history.md). This is a read-only local command: +route to it **before §0**, including outside a managed session. It needs no API +URL, credentials or running server. All live API operations below retain §0. + You are an agent running inside a Codeman-managed terminal session. Codeman is the server that spawned you; its HTTP API can start, prompt, watch, and delete other sessions. @@ -43,8 +50,8 @@ machine has no `jq` for the preamble below. ## 0. Guard and bootstrap -If `CODEMAN_MUX` is not `1`, **stop and say so**. Do not guess an API URL; a server -you are not part of is not yours to drive. +For live API operations, if `CODEMAN_MUX` is not `1`, **stop and say so**. +Do not guess an API URL; a server you are not part of is not yours to drive. Two failures come from your CLI, not from Codeman: name them to the user instead of retrying. diff --git a/skills/codeman/reference/history.md b/skills/codeman/reference/history.md new file mode 100644 index 000000000..0fa872058 --- /dev/null +++ b/skills/codeman/reference/history.md @@ -0,0 +1,51 @@ +# Saved Codex history (offline) + +Use `codeman history read` for retained local Codex JSONL transcripts. This command +works outside a managed session and never calls the HTTP API. Do not bootstrap +API credentials or require `CODEMAN_MUX` for this read-only job. + +Choose exactly one selector: + +```bash +codeman history read --file /path/to/rollout.jsonl +codeman history read --session-id aaaaaaaa --sessions-root /path/to/sessions +codeman history read --root-session-id aaaaaaaa-1111-4111-8111-111111111111 \ + --topic 'authentication' --after 2026-10-01 --before 2026-10-24 \ + --max-bytes 65536 +``` + +`--session-id` accepts a native conversation UUID or unique prefix of at least +8 characters; identity comes from metadata, not filenames. `--root-session-id` +requires a full native UUID and includes transitive descendants found in metadata. +The default store is `$CODEX_HOME/sessions`, or `~/.codex/sessions` when unset. +`--file` selects one file and does not search for its ancestors or children. + +Topic matching is a case-insensitive literal substring. Dates are inclusive +`YYYY-MM-DD` bounds. Each filter selects whole user/assistant turns; a turn is +retained if it has a date match and a topic match, which may be different messages +in that turn. Tool calls/results and injected context are omitted. Adjacent duplicate +native representations are collapsed; repeated messages from the same +representation are retained. + +JSON is the default (`--json` or `--format json`); `--format text` is available. +Results identify source paths, native session/root IDs, timestamps, roles and phases, +plus scan counters and `complete`. A root ID is `null` when ancestry cannot be proven. +Report that uncertainty; never relabel the queried child as its own root. +`complete: false` reports malformed or partial records, skipped links, unattributed +files or missing ancestors. It does not prove the store includes every conversation +ever created, and a filtered result is only evidence for its selected scope. + +Reads require regular UTF8 files and reject final-component symlinks, conflicting +metadata, ambiguous IDs, cyclic ancestry, invalid selectors and changed selected +files. Directory traversal skips symlinks. Parent directories of an explicitly +supplied path must be trusted; this is not a filesystem sandbox. + +The default total metadata/transcript scan budget is 16 MiB; `--scan-bytes` allows +1–67108864 bytes. Directory scans are capped at 8192 entries and depth 8. The output +budget is 8192 bytes by default; `--max-bytes` allows 1–1048576 bytes, including the +final newline. Exhausted budgets fail with an error: output is never silently +truncated or split into partial JSON or turns. Narrow the store/filters or explicitly +raise the relevant budget. Missing evidence is an error, not an empty success. + +This command does not inspect live panes, stage prompts, submit input or manage +sessions. For live operations return to the managed-session guard in the skill. diff --git a/src/cli-history.ts b/src/cli-history.ts new file mode 100644 index 000000000..614a416ef --- /dev/null +++ b/src/cli-history.ts @@ -0,0 +1,47 @@ +/** Local offline transcript commands. Deliberately separate from the agent API guard. */ +import type { Command } from 'commander'; +import { formatLocalHistory, HISTORY_DEFAULT_SCAN_BYTES, readLocalHistory } from './local-history.js'; + +export function registerHistoryCommands(program: Command): Command { + const history = program + .command('history') + .description('Read local saved native transcripts without a running server'); + history + .command('read') + .description('Read Codex history by file, native id, or root conversation and descendants') + .option('--file ', 'Explicit local JSONL transcript (regular file only)') + .option('--session-id ', 'Native Codex conversation UUID or unique prefix (at least 8 characters)') + .option('--root-session-id ', 'Native root UUID, including transitive child conversations') + .option('--sessions-root ', 'Local Codex sessions directory (default: CODEX_HOME/sessions)') + .option('--topic ', 'Case-insensitive literal match, preserving whole matching turns') + .option('--after ', 'Inclusive YYYY-MM-DD lower bound; preserves whole matching turns') + .option('--before ', 'Inclusive YYYY-MM-DD upper bound; preserves whole matching turns') + .option('--max-bytes ', 'Maximum complete output bytes (1–1048576)', '8192') + .option( + '--scan-bytes ', + 'Total metadata and selected transcript read budget (1–67108864)', + String(HISTORY_DEFAULT_SCAN_BYTES) + ) + .option('--format ', 'json or text', 'json') + .option('--json', 'JSON output (also the default)') + .action(async (options: Record) => { + try { + const format = options.json ? 'json' : options.format; + if (format !== 'json' && format !== 'text') throw new Error('--format must be json or text'); + const result = await readLocalHistory({ + file: options.file as string | undefined, + sessionId: options.sessionId as string | undefined, + rootSessionId: options.rootSessionId as string | undefined, + sessionsRoot: options.sessionsRoot as string | undefined, + topic: options.topic as string | undefined, + after: options.after as string | undefined, + before: options.before as string | undefined, + scanBytes: Number(options.scanBytes), + }); + program.configureOutput().writeOut?.(`${formatLocalHistory(result, format, Number(options.maxBytes))}\n`); + } catch (error) { + program.error(error instanceof Error ? error.message : String(error), { exitCode: 1, code: 'history.read' }); + } + }); + return history; +} diff --git a/src/cli.ts b/src/cli.ts index ba07793b3..c8a4e5e52 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -29,6 +29,7 @@ import { import type { AgentSkillDir } from './config/cli-registry/types.js'; import { getCli, listClis } from './config/cli-registry/registry.js'; import { registerAgentCommands } from './cli-agent.js'; +import { registerHistoryCommands } from './cli-history.js'; import { resolveMcpConfig, runMcpStdio } from './mcp-server.js'; import { getSessionManager } from './session-manager.js'; import { getTaskQueue } from './task-queue.js'; @@ -324,6 +325,7 @@ skillCmd // ============ Agent Commands (session-to-session, any CLI mode) ============ registerAgentCommands(program); +registerHistoryCommands(program); // ============ MCP server (any MCP client drives Codeman) ============ diff --git a/src/local-history.ts b/src/local-history.ts new file mode 100644 index 000000000..ac7fb04f1 --- /dev/null +++ b/src/local-history.ts @@ -0,0 +1,484 @@ +/** Offline, local-operator Codex history. No HTTP routes or running session required. */ +import { constants } from 'node:fs'; +import { isUtf8 } from 'node:buffer'; +import { lstat, open, opendir, type FileHandle } from 'node:fs/promises'; +import { homedir } from 'node:os'; +import { join, resolve } from 'node:path'; + +export const HISTORY_DEFAULT_SCAN_BYTES = 16 * 1024 * 1024; +export const HISTORY_MAX_SCAN_BYTES = 64 * 1024 * 1024; +export const HISTORY_MAX_OUTPUT_BYTES = 1024 * 1024; +const ID = /^[0-9a-f]{8}(?:[0-9a-f-]{0,28})$/i; +const FULL_ID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i; +const METADATA_BYTES = 128 * 1024; +type Json = Record; +export interface LocalHistoryOptions { + file?: string; + sessionId?: string; + rootSessionId?: string; + sessionsRoot?: string; + topic?: string; + after?: string; + before?: string; + scanBytes?: number; + maxEntries?: number; +} +export interface HistoryRecord { + rootSessionId: string | null; + sessionId: string; + timestamp: string; + role: 'user' | 'assistant'; + phase: string; + message: string; +} +interface Source { + path: string; + sessionId: string; + parentId?: string; + rootId?: string; + timestamp: string; + fingerprint?: string; +} +export interface LocalHistoryResult { + source: 'codex-jsonl'; + complete: boolean; + sessions: Array<{ sessionId: string; rootSessionId: string | null; parentSessionId?: string; path: string }>; + records: HistoryRecord[]; + scan: { + bytesRead: number; + entries: number; + malformedRecords: number; + partialRecords: number; + skippedSymlinks: number; + unattributedFiles: number; + }; +} +interface Budget { + remaining: number; + entriesRemaining: number; + scan: LocalHistoryResult['scan']; +} +function object(value: unknown): Json { + return value && typeof value === 'object' && !Array.isArray(value) ? (value as Json) : {}; +} +function string(value: unknown): string | undefined { + return typeof value === 'string' ? value : undefined; +} +function validDate(value: string | undefined): boolean { + return ( + value === undefined || + (/^\d{4}-\d{2}-\d{2}$/.test(value) && + Number.isFinite(Date.parse(value)) && + new Date(value).toISOString().slice(0, 10) === value) + ); +} +function validate(options: LocalHistoryOptions): void { + if ([options.file, options.sessionId, options.rootSessionId].filter((v) => v !== undefined).length !== 1) + throw new Error('Select exactly one of --file, --session-id, or --root-session-id'); + if (options.file !== undefined && !options.file) throw new Error('A nonempty file is required'); + if (options.sessionId !== undefined && !ID.test(options.sessionId)) throw new Error('Invalid native session id'); + if (options.rootSessionId !== undefined && !FULL_ID.test(options.rootSessionId)) + throw new Error('Invalid root session id'); + if ( + !validDate(options.after) || + !validDate(options.before) || + (options.after && options.before && options.after > options.before) + ) + throw new Error('Invalid date or date range (use YYYY-MM-DD)'); + if (options.topic !== undefined && !options.topic.trim()) throw new Error('Invalid empty topic'); + for (const [value, max] of [ + [options.scanBytes ?? HISTORY_DEFAULT_SCAN_BYTES, HISTORY_MAX_SCAN_BYTES], + [options.maxEntries ?? 8192, 65536], + ]) + if (!Number.isSafeInteger(value) || value < 1 || value > max) throw new Error('Invalid scan budget'); +} +async function regularFile(path: string): Promise { + // O_NOFOLLOW prevents a final-component link swap between lstat and open. fstat + // validates the opened descriptor, not a previously inspected pathname. + if (!(await lstat(path)).isFile()) throw new Error('History source must be a regular file, not a symlink'); + const handle = await open(path, constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK); + if (!(await handle.stat()).isFile()) { + await handle.close(); + throw new Error('History source must be a regular file'); + } + return handle; +} +async function readChunk(handle: FileHandle, position: number, size: number, budget: Budget): Promise { + if (size > budget.remaining) + throw new Error('History scan byte budget exhausted; narrow the store or raise --scan-bytes'); + const buffer = Buffer.alloc(size); + const { bytesRead } = await handle.read(buffer, 0, size, position); + budget.remaining -= bytesRead; + budget.scan.bytesRead += bytesRead; + return buffer.subarray(0, bytesRead); +} +function metadata(value: unknown, path: string): Source | undefined { + const entry = object(value); + if (entry.type !== 'session_meta') return undefined; + const p = object(entry.payload); + const sessionId = string(p.id) ?? string(p.session_id); + if (!sessionId || !FULL_ID.test(sessionId)) return undefined; + const source = object(p.source); + const spawn = object(object(source.subagent).thread_spawn); + const parentId = string(p.parent_thread_id) ?? string(p.parent_session_id) ?? string(spawn.parent_thread_id); + const declaredRoot = string(p.root_session_id) ?? string(p.session_id); + return { + path, + sessionId: sessionId.toLowerCase(), + parentId: parentId && FULL_ID.test(parentId) ? parentId.toLowerCase() : undefined, + rootId: + declaredRoot && declaredRoot !== sessionId && FULL_ID.test(declaredRoot) ? declaredRoot.toLowerCase() : undefined, + timestamp: string(p.timestamp) ?? string(entry.timestamp) ?? '', + }; +} +async function readMetadata(path: string, budget: Budget): Promise { + const handle = await regularFile(path); + try { + const before = await handle.stat(); + const parts: Buffer[] = []; + let offset = 0; + let parsed = 0; + while (offset < before.size && offset < METADATA_BYTES) { + const chunk = await readChunk( + handle, + offset, + Math.min(4096, before.size - offset, METADATA_BYTES - offset), + budget + ); + if (!chunk.length) break; + parts.push(chunk); + offset += chunk.length; + const buffer = Buffer.concat(parts); + let end = buffer.indexOf(10, parsed); + while (end >= 0) { + const line = buffer.subarray(parsed, end); + if (!isUtf8(line)) throw new Error('History metadata contains invalid UTF8'); + try { + const found = metadata(JSON.parse(line.toString('utf8')), path); + if (found) { + const after = await handle.stat(); + const fingerprint = (value: typeof before) => + `${value.dev}:${value.ino}:${value.size}:${value.mtimeMs}:${value.ctimeMs}`; + if (fingerprint(before) !== fingerprint(after)) + throw new Error('History source changed during metadata scan'); + return { ...found, fingerprint: fingerprint(before) }; + } + } catch (error) { + if (!(error instanceof SyntaxError)) throw error; + } + parsed = end + 1; + end = buffer.indexOf(10, parsed); + } + } + if (offset < before.size) + throw new Error('History metadata scan budget exhausted; cannot prove family completeness'); + return undefined; + } finally { + await handle.close(); + } +} +async function findSources(root: string, budget: Budget): Promise { + if (!(await lstat(root)).isDirectory()) throw new Error('Sessions root must be a real directory, not a symlink'); + const sources: Source[] = []; + const visit = async (dir: string, depth: number): Promise => { + if (depth > 8) throw new Error('History directory scan depth budget exhausted'); + const entries = await opendir(dir); + for await (const entry of entries) { + if (--budget.entriesRemaining < 0) throw new Error('History directory scan entry budget exhausted'); + budget.scan.entries++; + const path = join(dir, entry.name); + if (entry.isSymbolicLink()) { + budget.scan.skippedSymlinks++; + continue; + } + if (entry.isDirectory()) { + // Recheck immediately before descending; never intentionally follow links. + if (!(await lstat(path)).isDirectory()) throw new Error('History directory changed during scan'); + await visit(path, depth + 1); + } else if (entry.isFile() && entry.name.endsWith('.jsonl')) { + const source = await readMetadata(path, budget); + if (source) sources.push(source); + else budget.scan.unattributedFiles++; + } + } + }; + await visit(root, 0); + return sources; +} +function contentText(value: unknown): string { + if (typeof value === 'string') return value; + if (!Array.isArray(value)) return ''; + return value + .map((block) => { + const item = object(block); + return typeof item.type === 'string' && ['text', 'input_text', 'output_text'].includes(item.type.toLowerCase()) + ? (string(item.text) ?? '') + : ''; + }) + .filter(Boolean) + .join('\n'); +} +interface Message { + representation: 'event' | 'modern' | 'response'; + role: 'user' | 'assistant'; + message: string; + phase: string; + timestamp: string; +} +function message(value: unknown): Message | undefined { + const entry = object(value); + const p = object(entry.payload); + if (entry.isSidechain || p.isSidechain) return undefined; + let role: Message['role'] | undefined; + let representation: Message['representation'] = 'event'; + let text = ''; + let phase = string(p.phase) ?? 'progress'; + if (entry.type === 'event_msg') { + if (p.type === 'user_message' || p.type === 'agent_message') { + role = p.type === 'user_message' ? 'user' : 'assistant'; + text = contentText(p.message); + } else if (p.type === 'item_completed') { + representation = 'modern'; + const item = object(p.item); + if (item.type === 'UserMessage' || item.type === 'AgentMessage') { + role = item.type === 'UserMessage' ? 'user' : 'assistant'; + text = contentText(item.content); + phase = string(item.phase) ?? phase; + } + } + } else if ( + entry.type === 'response_item' && + (p.type === 'message' || p.type === undefined) && + (p.role === 'user' || p.role === 'assistant') + ) { + role = p.role; + representation = 'response'; + text = contentText(p.content); + // Codex context is not a human turn. Do not omit arbitrary markdown prompts. + if ( + role === 'user' && + /^(?:# AGENTS\.md instructions|||)/.test(text) + ) + return undefined; + } + if (!role || !text) return undefined; + return { + role, + message: text, + phase, + representation, + timestamp: string(entry.timestamp) ?? string(p.timestamp) ?? '', + }; +} +async function readMessages(source: Source, budget: Budget): Promise { + const handle = await regularFile(source.path); + try { + const before = await handle.stat(); + if (`${before.dev}:${before.ino}:${before.size}:${before.mtimeMs}:${before.ctimeMs}` !== source.fingerprint) + throw new Error('History source changed after attribution; retry explicitly'); + const buffer = await readChunk(handle, 0, before.size, budget); + const after = await handle.stat(); + if ( + buffer.length !== before.size || + before.size !== after.size || + before.mtimeMs !== after.mtimeMs || + before.ctimeMs !== after.ctimeMs + ) + throw new Error('History source changed while reading; retry explicitly'); + if (!isUtf8(buffer)) throw new Error('History source contains invalid UTF8'); + const lines = buffer.toString('utf8').split('\n'); + const partial = lines.pop(); + if (partial) budget.scan.partialRecords++; + const records: Message[] = []; + let representations = new Set(); + for (const line of lines) { + if (!line.trim()) continue; + let parsed: unknown; + try { + parsed = JSON.parse(line); + } catch { + budget.scan.malformedRecords++; + continue; + } + if (object(parsed).type === 'session_meta') { + const declared = metadata(parsed, source.path); + if ( + !declared || + declared.sessionId !== source.sessionId || + declared.parentId !== source.parentId || + declared.rootId !== source.rootId + ) + throw new Error('History source contains conflicting native metadata; attribution refused'); + continue; + } + const item = message(parsed); + if (!item) continue; + const previous = records.at(-1); + // Native events and response_item commonly duplicate one accepted message. + if ( + previous?.role === item.role && + previous.message === item.message && + !representations.has(item.representation) + ) { + representations.add(item.representation); + if (!previous.timestamp) previous.timestamp = item.timestamp; + if (item.phase === 'final_answer') previous.phase = item.phase; + continue; + } + records.push(item); + representations = new Set([item.representation]); + } + return records; + } finally { + await handle.close(); + } +} +function selectedTurns(messages: Message[], options: LocalHistoryOptions): Message[] { + const turns: Message[][] = []; + for (const item of messages) { + if (item.role === 'user' || !turns.length) turns.push([]); + turns.at(-1)!.push(item); + } + const needle = options.topic?.trim().toLocaleLowerCase(); + return turns + .filter((turn) => { + const dateMatches = + (!options.after && !options.before) || + turn.some((item) => { + const date = item.timestamp.slice(0, 10); + return ( + validDate(date) && (!options.after || date >= options.after) && (!options.before || date <= options.before) + ); + }); + return dateMatches && (!needle || turn.some((item) => item.message.toLocaleLowerCase().includes(needle))); + }) + .flat(); +} + +/** Native ancestry is metadata evidence, not the conversation selected by a query. */ +function nativeRoot(source: Source, sources: Source[]): string | null { + const seen = new Set(); + let current = source; + while (true) { + if (seen.has(current.sessionId)) throw new Error('Native history contains cyclic ancestry'); + seen.add(current.sessionId); + if (current.rootId) return current.rootId; + if (!current.parentId) return current.sessionId; + const parents = sources.filter((candidate) => candidate.sessionId === current.parentId); + if (parents.length === 0) return null; + if (parents.length > 1) throw new Error('Native ancestor id is ambiguous'); + current = parents[0]; + } +} + +export async function readLocalHistory(options: LocalHistoryOptions): Promise { + validate(options); + const scan: LocalHistoryResult['scan'] = { + bytesRead: 0, + entries: 0, + malformedRecords: 0, + partialRecords: 0, + skippedSymlinks: 0, + unattributedFiles: 0, + }; + const budget: Budget = { + remaining: options.scanBytes ?? HISTORY_DEFAULT_SCAN_BYTES, + entriesRemaining: options.maxEntries ?? 8192, + scan, + }; + let sources: Source[]; + let attributionSources: Source[]; + if (options.file !== undefined) { + const source = await readMetadata(resolve(options.file), budget); + if (!source) throw new Error('History source is missing valid native session metadata'); + sources = [source]; + attributionSources = sources; + } else { + const root = options.sessionsRoot ?? join(process.env.CODEX_HOME || join(homedir(), '.codex'), 'sessions'); + const all = await findSources(resolve(root), budget); + const wanted = (options.sessionId ?? options.rootSessionId)!.toLowerCase(); + const matches = all.filter((source) => source.sessionId.toLowerCase().startsWith(wanted)); + if (!matches.length) throw new Error('Native session missing from history store'); + if (matches.length !== 1) throw new Error('Native session id is ambiguous'); + const target = matches[0]; + attributionSources = all; + const queryRootId = target.sessionId; + sources = [target]; + if (options.rootSessionId) { + const selected = new Set([target.sessionId]); + let changed = true; + while (changed) { + changed = false; + for (const source of all) { + if ( + !selected.has(source.sessionId) && + (source.rootId === queryRootId || (source.parentId && selected.has(source.parentId))) + ) { + selected.add(source.sessionId); + sources.push(source); + changed = true; + } + } + } + for (const source of sources) { + if (all.filter((other) => other.sessionId === source.sessionId).length !== 1) + throw new Error('Native family contains ambiguous duplicated session ids'); + } + } + } + sources.sort((a, b) => a.timestamp.localeCompare(b.timestamp) || a.sessionId.localeCompare(b.sessionId)); + const roots = new Map(sources.map((source) => [source.sessionId, nativeRoot(source, attributionSources)])); + const records: HistoryRecord[] = []; + for (const source of sources) { + records.push( + ...selectedTurns(await readMessages(source, budget), options).map((item) => ({ + rootSessionId: roots.get(source.sessionId) ?? null, + sessionId: source.sessionId, + role: item.role, + message: item.message, + phase: item.phase, + timestamp: item.timestamp, + })) + ); + } + if (!records.length) throw new Error('No matching user/assistant evidence found'); + return { + source: 'codex-jsonl', + complete: + scan.malformedRecords === 0 && + scan.partialRecords === 0 && + scan.skippedSymlinks === 0 && + scan.unattributedFiles === 0 && + [...roots.values()].every((root) => root !== null), + sessions: sources.map((source) => ({ + sessionId: source.sessionId, + rootSessionId: roots.get(source.sessionId) ?? null, + ...(source.parentId ? { parentSessionId: source.parentId } : {}), + path: source.path, + })), + records, + scan, + }; +} +/** Output never splits a JSON document, UTF8 character, record, or selected turn. */ +export function formatLocalHistory( + result: LocalHistoryResult, + format: 'json' | 'text' = 'json', + maxBytes = 8192 +): string { + if (!Number.isSafeInteger(maxBytes) || maxBytes < 1 || maxBytes > HISTORY_MAX_OUTPUT_BYTES) + throw new Error('Invalid output byte budget'); + const body = + format === 'json' + ? JSON.stringify(result) + : [ + `source=${result.source} complete=${result.complete} scannedBytes=${result.scan.bytesRead}`, + ...result.records.map( + (row) => + `[root=${row.rootSessionId} session=${row.sessionId} ${row.timestamp} ${row.role} ${row.phase}]\n${row.message}` + ), + ].join('\n\n'); + if (Buffer.byteLength(body) + 1 > maxBytes) + throw new Error('History output byte budget exceeded; narrow filters or raise --max-bytes'); + return body; +} diff --git a/test/local-history.test.ts b/test/local-history.test.ts new file mode 100644 index 000000000..7ffa4c4bb --- /dev/null +++ b/test/local-history.test.ts @@ -0,0 +1,246 @@ +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import { mkdtemp, mkdir, rm, symlink, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import * as history from '../src/local-history.js'; +import { Command } from 'commander'; +import { registerHistoryCommands } from '../src/cli-history.js'; + +const ROOT = 'aaaaaaaa-1111-4111-8111-111111111111'; +const CHILD = 'bbbbbbbb-1111-4111-8111-111111111111'; +const GRANDCHILD = 'cccccccc-1111-4111-8111-111111111111'; +let dir: string; +const meta = (id: string, parent?: string) => ({ + type: 'session_meta', + payload: { + id, + timestamp: '2026-10-24T01:00:00Z', + ...(parent ? { source: { subagent: { thread_spawn: { parent_thread_id: parent } } } } : {}), + }, +}); +const row = (role: string, text: string, timestamp = '2026-10-24T01:00:00Z') => ({ + type: 'response_item', + timestamp, + payload: { type: 'message', role, content: [{ type: role === 'user' ? 'input_text' : 'output_text', text }] }, +}); +async function fixture(id: string, records: unknown[], parent?: string) { + const path = join(dir, `rollout-${id}.jsonl`); + await writeFile(path, [meta(id, parent), ...records].map((r) => JSON.stringify(r)).join('\n') + '\n'); + return path; +} +beforeEach(async () => { + dir = await mkdtemp(join(tmpdir(), 'local-history-')); +}); +afterEach(async () => { + await rm(dir, { recursive: true, force: true }); +}); + +describe('offline native history', () => { + it('reads a native transcript without a server and preserves identity and timestamps', async () => { + const file = await fixture(ROOT, [row('user', 'question'), row('assistant', 'answer')]); + const result = await history.readLocalHistory({ file }); + expect(result.records.map((r) => r.message)).toEqual(['question', 'answer']); + expect(result.records[0]).toMatchObject({ + sessionId: ROOT, + rootSessionId: ROOT, + timestamp: '2026-10-24T01:00:00Z', + }); + expect(result.complete).toBe(true); + }); + it('resolves metadata identities rather than filenames', async () => { + await fixture(ROOT, [row('assistant', 'yes')]); + const result = await history.readLocalHistory({ sessionId: ROOT.slice(0, 8), sessionsRoot: dir }); + expect(result.records[0].sessionId).toBe(ROOT); + }); + it('rejects ambiguous native prefixes', async () => { + await fixture(ROOT, [row('assistant', 'one')]); + await fixture('aaaaaaaa-2222-4222-8222-222222222222', [row('assistant', 'two')]); + await expect(history.readLocalHistory({ sessionId: 'aaaaaaaa', sessionsRoot: dir })).rejects.toThrow(/ambiguous/); + }); + it('gathers transitive children and retains a whole topic/date matching turn', async () => { + await fixture(ROOT, [row('user', 'other'), row('assistant', 'unrelated')]); + await fixture( + CHILD, + [row('user', 'topic question'), row('assistant', 'answer after midnight', '2026-10-25T00:01:00Z')], + ROOT + ); + await fixture(GRANDCHILD, [row('user', 'topic child'), row('assistant', 'child answer')], CHILD); + const result = await history.readLocalHistory({ + rootSessionId: ROOT, + sessionsRoot: dir, + topic: 'TOPIC', + before: '2026-10-24', + }); + expect(result.sessions).toHaveLength(3); + expect(result.records.map((r) => r.message)).toEqual([ + 'topic question', + 'answer after midnight', + 'topic child', + 'child answer', + ]); + expect(result.records.every((r) => r.rootSessionId === ROOT)).toBe(true); + }); + it('resolves the real native root when querying a child by id', async () => { + await fixture(ROOT, [row('assistant', 'root')]); + await fixture(CHILD, [row('assistant', 'child')], ROOT); + await fixture(GRANDCHILD, [row('assistant', 'grandchild')], CHILD); + const result = await history.readLocalHistory({ sessionId: GRANDCHILD, sessionsRoot: dir }); + expect(result.records[0].rootSessionId).toBe(ROOT); + expect(result.sessions[0]).toMatchObject({ parentSessionId: CHILD, rootSessionId: ROOT }); + }); + it('labels a file-only child root unknown rather than inventing an ancestor', async () => { + const file = await fixture(CHILD, [row('assistant', 'child')], ROOT); + const result = await history.readLocalHistory({ file }); + expect(result.records[0].rootSessionId).toBeNull(); + expect(result.sessions[0]).toMatchObject({ parentSessionId: ROOT, rootSessionId: null }); + }); + it('labels missing ancestors unknown and refuses cyclic ancestry', async () => { + await fixture(CHILD, [row('assistant', 'child')], ROOT); + expect( + (await history.readLocalHistory({ sessionId: CHILD, sessionsRoot: dir })).records[0].rootSessionId + ).toBeNull(); + await fixture(ROOT, [row('assistant', 'root')], CHILD); + await expect(history.readLocalHistory({ sessionId: CHILD, sessionsRoot: dir })).rejects.toThrow(/cyclic/); + }); + it('supports legacy shared session_id genealogy', async () => { + await fixture(ROOT, [row('assistant', 'root')]); + await writeFile( + join(dir, 'legacy.jsonl'), + [{ type: 'session_meta', payload: { id: CHILD, session_id: ROOT } }, row('assistant', 'legacy child')] + .map((r) => JSON.stringify(r)) + .join('\n') + '\n' + ); + expect((await history.readLocalHistory({ rootSessionId: ROOT, sessionsRoot: dir })).sessions).toHaveLength(2); + }); + it('parses modern events and removes adjacent duplicate representations without erasing repeated turns', async () => { + const file = await fixture(ROOT, [ + row('user', 'question'), + { + type: 'event_msg', + payload: { + type: 'item_completed', + item: { type: 'UserMessage', content: [{ type: 'Text', text: 'question' }] }, + }, + }, + row('assistant', 'answer'), + { + type: 'event_msg', + payload: { + type: 'item_completed', + item: { type: 'AgentMessage', phase: 'final_answer', content: [{ type: 'Text', text: 'answer' }] }, + }, + }, + { type: 'event_msg', payload: { type: 'user_message', message: 'question' } }, + { type: 'event_msg', payload: { type: 'agent_message', message: 'answer' } }, + ]); + expect((await history.readLocalHistory({ file })).records.map((r) => r.message)).toEqual([ + 'question', + 'answer', + 'question', + 'answer', + ]); + }); + it('preserves genuinely repeated identical messages from the same representation', async () => { + const file = await fixture(ROOT, [row('user', 'repeat'), row('user', 'repeat'), row('assistant', 'answer')]); + expect((await history.readLocalHistory({ file })).records.map((r) => r.message)).toEqual([ + 'repeat', + 'repeat', + 'answer', + ]); + }); + it('labels unreadable metadata as incomplete rather than hiding possible children', async () => { + await fixture(ROOT, [row('assistant', 'root')]); + await writeFile(join(dir, 'unattributed.jsonl'), '{bad metadata}\n'); + const result = await history.readLocalHistory({ rootSessionId: ROOT, sessionsRoot: dir }); + expect(result.complete).toBe(false); + expect(result.scan.unattributedFiles).toBe(1); + }); + it('refuses conflicting session metadata instead of attributing another conversation answer', async () => { + const file = await fixture(ROOT, [row('user', 'root question'), meta(CHILD), row('assistant', 'child answer')]); + await expect(history.readLocalHistory({ file })).rejects.toThrow(/conflicting.*metadata/); + }); + it('accepts repeated metadata only when native identity and genealogy agree', async () => { + const file = await fixture(ROOT, [row('user', 'question'), meta(ROOT), row('assistant', 'answer')]); + expect((await history.readLocalHistory({ file })).records.map((record) => record.message)).toEqual([ + 'question', + 'answer', + ]); + await fixture(CHILD, [meta(CHILD, GRANDCHILD), row('assistant', 'child answer')], ROOT); + await expect(history.readLocalHistory({ sessionId: CHILD, sessionsRoot: dir })).rejects.toThrow( + /conflicting.*metadata/ + ); + }); + it('labels skipped malformed and pending partial records', async () => { + const file = await fixture(ROOT, [row('assistant', 'complete')]); + const { appendFile } = await import('node:fs/promises'); + await appendFile(file, 'not json\n{"type":'); + const result = await history.readLocalHistory({ file }); + expect(result.complete).toBe(false); + expect(result.scan.malformedRecords).toBe(1); + expect(result.scan.partialRecords).toBe(1); + expect(result.records[0].message).toBe('complete'); + }); + it('rejects invalid UTF8, nonregular inputs and symlinks', async () => { + const file = await fixture(ROOT, [row('assistant', 'answer')]); + const linked = join(dir, 'linked.jsonl'); + await symlink(file, linked); + await expect(history.readLocalHistory({ file: linked })).rejects.toThrow(/regular|symlink/); + await expect(history.readLocalHistory({ file: dir })).rejects.toThrow(/regular/); + await writeFile(file, Buffer.from([0xff, 0x0a])); + await expect(history.readLocalHistory({ file })).rejects.toThrow(/UTF8/); + }); + it('fails closed for byte budgets and preserves valid JSON for successful output', async () => { + const file = await fixture(ROOT, [row('assistant', '😀'.repeat(80))]); + await expect(history.readLocalHistory({ file, scanBytes: 32 })).rejects.toThrow(/scan.*budget/); + const result = await history.readLocalHistory({ file }); + expect(() => history.formatLocalHistory(result, 'json', 40)).toThrow(/output.*budget/); + const output = history.formatLocalHistory(result, 'json', 8192); + expect(Buffer.byteLength(output)).toBeLessThanOrEqual(8192); + expect(JSON.parse(output).records[0].message).toBe('😀'.repeat(80)); + }); + it.each([{ after: '2026-02-30' }, { before: 'bad' }, { after: '2026-10-25', before: '2026-10-24' }, { topic: ' ' }])( + 'rejects invalid filters %j', + async (filter) => { + const file = await fixture(ROOT, [row('assistant', 'answer')]); + await expect(history.readLocalHistory({ file, ...filter })).rejects.toThrow(/date|topic/); + } + ); + it('rejects missing selectors, multiple selectors, empty matches and absent native ids', async () => { + const file = await fixture(ROOT, [row('assistant', 'answer')]); + await expect(history.readLocalHistory({})).rejects.toThrow(/exactly one/); + await expect(history.readLocalHistory({ file, sessionId: ROOT })).rejects.toThrow(/exactly one/); + await expect(history.readLocalHistory({ file, topic: 'absent' })).rejects.toThrow(/evidence/); + await expect(history.readLocalHistory({ sessionId: CHILD, sessionsRoot: dir })).rejects.toThrow(/missing/); + }); + it('rejects exhausted directory traversal rather than returning an incomplete family', async () => { + await fixture(ROOT, [row('assistant', 'root')]); + await mkdir(join(dir, 'nested')); + await fixture(CHILD, [row('assistant', 'child')], ROOT); + await expect(history.readLocalHistory({ rootSessionId: ROOT, sessionsRoot: dir, maxEntries: 1 })).rejects.toThrow( + /scan.*budget/ + ); + }); + it('executes the offline CLI with complete JSON and no internal parser fields', async () => { + const file = await fixture(ROOT, [row('assistant', 'offline')]); + let output = ''; + const program = new Command().exitOverride().configureOutput({ + writeOut: (text) => { + output += text; + }, + writeErr: () => {}, + }); + registerHistoryCommands(program); + await program.parseAsync(['history', 'read', '--file', file, '--json'], { from: 'user' }); + const result = JSON.parse(output); + expect(result.records[0].message).toBe('offline'); + expect(result.records[0]).not.toHaveProperty('representation'); + await expect( + program.parseAsync(['history', 'read', '--file', file, '--session-id', ROOT], { from: 'user' }) + ).rejects.toThrow(/exactly one/); + }); + it('registers offline commands and reports invalid selectors without process exit', async () => { + const program = new Command().exitOverride(); + registerHistoryCommands(program); + expect(program.commands.find((c) => c.name() === 'history')?.commands.map((c) => c.name())).toEqual(['read']); + }); +}); From b2e96ddfafcb4747f3d4b37d032cfc0d83195211 Mon Sep 17 00:00:00 2001 From: Aamer Akhter Date: Sat, 24 Oct 2026 20:41:47 -0400 Subject: [PATCH 14/68] feat(agent): COD-549 start workers in existing project directories --- .changeset/project-worker-directories.md | 5 + docs/wiki/Driving-Codeman-From-An-Agent.md | 8 ++ plugins/codeman/skills/codeman/SKILL.md | 6 + skills/codeman/SKILL.md | 6 + src/cli-agent.ts | 73 +++++++++-- test/cli-agent-project-spawn.test.ts | 141 +++++++++++++++++++++ 6 files changed, 226 insertions(+), 13 deletions(-) create mode 100644 .changeset/project-worker-directories.md create mode 100644 test/cli-agent-project-spawn.test.ts diff --git a/.changeset/project-worker-directories.md b/.changeset/project-worker-directories.md new file mode 100644 index 000000000..c24035d5b --- /dev/null +++ b/.changeset/project-worker-directories.md @@ -0,0 +1,5 @@ +--- +"aicodeman": minor +--- + +Allow `codeman agent spawn` to start workers in the current project directory or an existing directory selected with `--working-dir`, while preserving explicit case quick-start behavior. diff --git a/docs/wiki/Driving-Codeman-From-An-Agent.md b/docs/wiki/Driving-Codeman-From-An-Agent.md index 6b2b337f1..9654dd003 100644 --- a/docs/wiki/Driving-Codeman-From-An-Agent.md +++ b/docs/wiki/Driving-Codeman-From-An-Agent.md @@ -53,6 +53,14 @@ codeman agent ack # remove what you hand codeman agent rm "$SID" # any session except this one ``` +- **`spawn [case]`** uses the current directory when no case is supplied. Use + `codeman agent spawn --working-dir /path/to/project --mode codex` for another existing + local directory. A case and `--working-dir` cannot be combined. Explicit case names + retain quick-start behavior (creating a scratch case if missing). Directory launches + preserve the project and parent lineage, and never label it as agent scratch. Choose + a separate worktree for each worker that will edit code. If startup fails or its + response is lost, the CLI reports the exact created session ID; inspect that session + before deciding what to do next. - **Ids** may be the 8-character form `ls` prints. Anything shorter refuses, and so does an ambiguous prefix. - **`send`** takes ONE quoted argument of printable text and presses Enter. A prompt that diff --git a/plugins/codeman/skills/codeman/SKILL.md b/plugins/codeman/skills/codeman/SKILL.md index 50133e8c9..7e10e28a8 100644 --- a/plugins/codeman/skills/codeman/SKILL.md +++ b/plugins/codeman/skills/codeman/SKILL.md @@ -41,6 +41,12 @@ verbs over the same endpoints, with the guards below (no control bytes, no self- no guessed URL) enforced in code, and it is also your own way through when this machine has no `jq` for the preamble below. +For an existing project, use `codeman agent spawn --working-dir /path/to/project --mode codex` (one command). With no case or `--working-dir`, `spawn` uses cwd. Give +editing workers separate worktrees. A positional case keeps quick-start behavior; +it cannot be combined with `--working-dir`. Directory launches keep parent lineage +and never mark the project as agent scratch. If startup fails or is ambiguous, the +CLI reports the exact created ID: inspect it before retrying or cleaning up. + ## 0. Guard and bootstrap If `CODEMAN_MUX` is not `1`, **stop and say so**. Do not guess an API URL; a server diff --git a/skills/codeman/SKILL.md b/skills/codeman/SKILL.md index 50133e8c9..7e10e28a8 100644 --- a/skills/codeman/SKILL.md +++ b/skills/codeman/SKILL.md @@ -41,6 +41,12 @@ verbs over the same endpoints, with the guards below (no control bytes, no self- no guessed URL) enforced in code, and it is also your own way through when this machine has no `jq` for the preamble below. +For an existing project, use `codeman agent spawn --working-dir /path/to/project --mode codex` (one command). With no case or `--working-dir`, `spawn` uses cwd. Give +editing workers separate worktrees. A positional case keeps quick-start behavior; +it cannot be combined with `--working-dir`. Directory launches keep parent lineage +and never mark the project as agent scratch. If startup fails or is ambiguous, the +CLI reports the exact created ID: inspect it before retrying or cleaning up. + ## 0. Guard and bootstrap If `CODEMAN_MUX` is not `1`, **stop and say so**. Do not guess an API URL; a server diff --git a/src/cli-agent.ts b/src/cli-agent.ts index 6a5fad476..6b78ab48c 100644 --- a/src/cli-agent.ts +++ b/src/cli-agent.ts @@ -29,6 +29,8 @@ * * @module cli-agent */ +import { stat } from 'node:fs/promises'; +import { resolve } from 'node:path'; import http from 'node:http'; import https from 'node:https'; import type { Command } from 'commander'; @@ -509,7 +511,8 @@ export async function agentLs(deps: AgentDeps): Promise { } export interface SpawnOptions { - caseName: string; + caseName?: string; + workingDir?: string; mode: string; name?: string; /** Wait for the composer before returning, where the registry gives the mode a ready mark. */ @@ -532,23 +535,64 @@ export const AGENT_ORIGIN_HEADER = { 'X-Codeman-Agent-Origin': 'codeman-agent-cl export function composerReadyMark(mode: string): string | undefined { return getCli(mode)?.capabilities.composerReadyMark; } -/** `agent spawn` — quick-start with lineage, then the readiness ladder where the mode has one. */ +/** `agent spawn` — start in a project directory or named case, preserving lineage and mode readiness. */ export async function agentSpawn(deps: AgentDeps, options: SpawnOptions): Promise { + const cli = getCli(options.mode); + if (!cli) return fail(deps, `Unknown mode: ${options.mode}`, EXIT.refused); + if (options.caseName && options.workingDir !== undefined) + return fail(deps, 'Use a case OR --working-dir, not both', EXIT.refused); + const workingDir = options.caseName ? undefined : resolve(options.workingDir ?? process.cwd()); + if (workingDir) { + // eslint-disable-next-line no-control-regex + if (/[\x00-\x1f\x7f]/.test(workingDir)) + return fail(deps, 'Working directory contains control characters', EXIT.refused); + try { + if (!(await stat(workingDir)).isDirectory()) throw new Error('not a directory'); + } catch { + return fail(deps, 'Working directory must be an existing local directory', EXIT.refused); + } + } const body: Record = { - caseName: options.caseName, + ...(workingDir ? { workingDir } : { caseName: options.caseName }), mode: options.mode, parentSessionId: deps.ctx.selfId, }; - if (options.name) body.sessionName = options.name; + if (options.name) body[workingDir ? 'name' : 'sessionName'] = options.name; const res = await deps.request(deps.ctx, { method: 'POST', - path: '/api/v1/quick-start', + path: workingDir ? '/api/v1/sessions' : '/api/v1/quick-start', body, - headers: { ...AGENT_ORIGIN_HEADER }, + headers: workingDir ? undefined : { ...AGENT_ORIGIN_HEADER }, }); - const data = res.json?.data as { sessionId?: string; caseName?: string; casePath?: string } | undefined; + const created = res.json?.data as + | { id?: string; session?: { id?: string }; sessionId?: string; caseName?: string; casePath?: string } + | undefined; + const createdId = created?.session?.id ?? created?.id; + const data: + | { sessionId?: string; caseName?: string; casePath?: string; workingDir?: string; created?: boolean } + | undefined = workingDir && createdId ? { sessionId: createdId, workingDir, created: true } : created; if (!res.json?.success || !data?.sessionId) return fail(deps, describeFailure(res)); const sid = data.sessionId; + if (workingDir) { + // Creation alone leaves a session without a pane. Report the exact created id + // on an ambiguous or rejected start; never retry a start or hide a leaked tab. + let startupError: string | undefined; + try { + const started = await deps.request(deps.ctx, { + method: 'POST', + path: `/api/v1/sessions/${encodeURIComponent(sid)}/${cli.capabilities.startMode}`, + }); + if (!started.json?.success) startupError = describeFailure(started); + } catch (error) { + startupError = getErrorMessage(error) || 'Startup request failed'; + } + if (startupError) { + if (deps.json) emitJson(deps, { ...data, ready: false, startupError }); + else deps.io.out(sid); + deps.io.err(`Session ${sid} was created but startup failed or is ambiguous: ${startupError}`); + return EXIT.error; + } + } let ready: boolean | undefined; let readinessError: string | undefined; @@ -578,7 +622,7 @@ export async function agentSpawn(deps: AgentDeps, options: SpawnOptions): Promis } else { // Human lines go to stderr so `SID=$(codeman agent spawn …)` captures the id alone. const say = (line: string) => deps.io.err(line); - say(palette.ok(`${GLYPH.ok} spawned ${sid} (${options.mode}, case ${data.caseName ?? options.caseName})`)); + say(palette.ok(`${GLYPH.ok} spawned ${sid} (${options.mode}, ${workingDir ?? data.caseName ?? options.caseName})`)); if (ready === true) say(palette.muted(' composer up: the worker can take a prompt')); if (dead) say(palette.err(`${GLYPH.fail} the worker exited during the readiness wait`)); if (ready === false && !dead) { @@ -1059,20 +1103,23 @@ export function registerAgentCommands(program: Command): Command { .action((options: { json?: boolean }) => run(Boolean(options.json), agentLs)); agent - .command('spawn ') - .description( - 'Start a worker session in a case (created if missing) and wait for its composer where the mode draws one' - ) + .command('spawn [case]') + .description('Start a worker in cwd, an existing directory, or a named case; wait for its composer where supported') .option('-m, --mode ', 'Run mode id, as the Run menu names it', 'claude') .option('-n, --name ', 'Session name shown in the UI') + .option('--working-dir ', 'Existing project directory (defaults to cwd when no case is given)') .option('--no-ready', 'Return as soon as the session exists, without the readiness wait') .option('-t, --timeout ', 'Readiness budget in ms', String(DEFAULT_WAIT_MS)) .option('--json', 'Machine-readable output') .action( - (caseName: string, options: { mode: string; name?: string; ready: boolean; timeout?: string; json?: boolean }) => + ( + caseName: string | undefined, + options: { mode: string; name?: string; workingDir?: string; ready: boolean; timeout?: string; json?: boolean } + ) => run(Boolean(options.json), (deps) => agentSpawn(deps, { caseName, + workingDir: options.workingDir, mode: options.mode, name: options.name, ready: options.ready, diff --git a/test/cli-agent-project-spawn.test.ts b/test/cli-agent-project-spawn.test.ts new file mode 100644 index 000000000..b1af3e88b --- /dev/null +++ b/test/cli-agent-project-spawn.test.ts @@ -0,0 +1,141 @@ +import { mkdtemp, rm, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { Command } from 'commander'; +import { afterEach, describe, expect, it } from 'vitest'; +import { + agentSpawn, + registerAgentCommands, + EXIT, + type AgentDeps, + type ApiResponse, + type RequestOptions, +} from '../src/cli-agent.js'; + +const SELF = '058ee7b5-b2aa-4c33-8cc1-e900eb0b28af'; +const OTHER = '94990c6d-e461-4a29-aa83-89275327732c'; +const dirs: string[] = []; +afterEach(async () => { + for (const dir of dirs.splice(0)) await rm(dir, { recursive: true, force: true }); +}); +const ok = (data: unknown): ApiResponse => ({ status: 200, json: { success: true, data }, text: '' }); +function deps(responses: ApiResponse[]) { + const calls: RequestOptions[] = []; + const out: string[] = []; + const err: string[] = []; + const d: AgentDeps = { + ctx: { selfId: SELF, apiUrl: 'http://127.0.0.1:1' }, + json: true, + now: Date.now, + io: { out: (s) => out.push(s), err: (s) => err.push(s) }, + request: async (_ctx, options) => { + calls.push(options); + return responses.shift() ?? ok({}); + }, + }; + return { d, calls, out, err }; +} +const base = { mode: 'codex', ready: false, timeoutMs: 1000 }; +describe('project-preserving agent spawn', () => { + it('uses existing session create and interactive endpoints with exact directory and lineage', async () => { + const directory = await mkdtemp(join(tmpdir(), 'cod549-project-')); + dirs.push(directory); + const t = deps([ok({ session: { id: OTHER } }), ok({})]); + expect(await agentSpawn(t.d, { ...base, workingDir: directory, name: 'review' })).toBe(EXIT.ok); + expect(t.calls).toHaveLength(2); + expect(t.calls[0]).toMatchObject({ + method: 'POST', + path: '/api/v1/sessions', + body: { workingDir: directory, mode: 'codex', name: 'review', parentSessionId: SELF }, + }); + expect(t.calls[0].headers ?? {}).not.toHaveProperty('X-Codeman-Agent-Origin'); + expect(t.calls[1]).toMatchObject({ method: 'POST', path: `/api/v1/sessions/${OTHER}/interactive` }); + expect(JSON.parse(t.out[0])).toMatchObject({ sessionId: OTHER, workingDir: directory }); + }); + it('defaults to the current directory when no case is supplied', async () => { + const t = deps([ok({ id: OTHER }), ok({})]); + expect(await agentSpawn(t.d, base)).toBe(EXIT.ok); + expect(t.calls[0].body).toMatchObject({ workingDir: process.cwd() }); + }); + it('keeps explicit case quick-start behavior', async () => { + const t = deps([ok({ sessionId: OTHER })]); + expect(await agentSpawn(t.d, { ...base, caseName: 'scratch' })).toBe(EXIT.ok); + expect(t.calls).toHaveLength(1); + expect(t.calls[0].path).toBe('/api/v1/quick-start'); + }); + it('refuses case and working directory together before network IO', async () => { + const t = deps([]); + expect(await agentSpawn(t.d, { ...base, caseName: 'scratch', workingDir: process.cwd() })).toBe(EXIT.refused); + expect(t.calls).toEqual([]); + }); + it('refuses missing directories, regular files and unknown modes before creating a session', async () => { + const directory = await mkdtemp(join(tmpdir(), 'cod549-project-')); + dirs.push(directory); + const file = join(directory, 'file'); + await writeFile(file, 'x'); + for (const options of [{ workingDir: join(directory, 'absent') }, { workingDir: file }, { mode: 'not-a-cli' }]) { + const t = deps([]); + expect(await agentSpawn(t.d, { ...base, ...options })).toBe(EXIT.refused); + expect(t.calls).toEqual([]); + } + }); + it('reports the created session ID when startup fails instead of retrying or silently leaking it', async () => { + const t = deps([ + ok({ id: OTHER }), + { status: 400, text: '', json: { success: false, error: 'cannot start', errorCode: 'OPERATION_FAILED' } }, + ]); + expect(await agentSpawn(t.d, base)).toBe(EXIT.error); + expect(t.out).toHaveLength(1); + expect(JSON.parse(t.out.join('\n'))).toMatchObject({ sessionId: OTHER, created: true, ready: false }); + expect(t.calls).toHaveLength(2); + }); + it('does not request startup when creation is refused', async () => { + const t = deps([{ status: 403, text: 'Forbidden' }]); + expect(await agentSpawn(t.d, base)).toBe(EXIT.error); + expect(t.calls).toHaveLength(1); + }); + it('retains the exact created ID on an ambiguous startup transport failure without retrying', async () => { + const t = deps([ok({ session: { id: OTHER } })]); + const request = t.d.request; + t.d.request = async (context, options) => { + if (options.path.endsWith('/interactive')) { + t.calls.push(options); + throw new Error('connection lost'); + } + return request(context, options); + }; + expect(await agentSpawn(t.d, base)).toBe(EXIT.error); + expect(t.out).toHaveLength(1); + expect(JSON.parse(t.out.join('\n'))).toMatchObject({ + sessionId: OTHER, + created: true, + startupError: 'connection lost', + }); + expect(t.calls).toHaveLength(2); + }); + it('treats an empty startup exception as a failure and keeps the created ID', async () => { + const t = deps([ok({ session: { id: OTHER } })]); + const request = t.d.request; + t.d.request = async (context, options) => { + if (options.path.endsWith('/interactive')) throw new Error(''); + return request(context, options); + }; + expect(await agentSpawn(t.d, base)).toBe(EXIT.error); + expect(t.out).toHaveLength(1); + expect(JSON.parse(t.out[0])).toMatchObject({ + sessionId: OTHER, + ready: false, + startupError: 'Startup request failed', + }); + }); + it('exposes an optional case and working directory in the CLI', () => { + const spawn = registerAgentCommands(new Command()).commands.find((command) => command.name() === 'spawn')!; + expect(spawn.registeredArguments[0].required).toBe(false); + expect(spawn.options.some((option) => option.long === '--working-dir')).toBe(true); + }); + it('starts a shell through its registered startMode endpoint', async () => { + const t = deps([ok({ id: OTHER }), ok({})]); + expect(await agentSpawn(t.d, { ...base, mode: 'shell' })).toBe(EXIT.ok); + expect(t.calls[1].path).toBe(`/api/v1/sessions/${OTHER}/shell`); + }); +}); From d25d71b595e56fd4e3bc2db09ff7ea22368de1c3 Mon Sep 17 00:00:00 2001 From: Aamer Akhter Date: Sat, 24 Oct 2026 20:45:00 -0400 Subject: [PATCH 15/68] fix(agent): COD-549 report readiness failures with empty errors --- src/cli-agent.ts | 2 +- test/cli-agent.test.ts | 15 +++++++++++++++ 2 files changed, 16 insertions(+), 1 deletion(-) diff --git a/src/cli-agent.ts b/src/cli-agent.ts index ac96664b3..fc3fec03b 100644 --- a/src/cli-agent.ts +++ b/src/cli-agent.ts @@ -573,7 +573,7 @@ export async function agentSpawn(deps: AgentDeps, options: SpawnOptions): Promis ready = !dead && Boolean(result?.matched); } } catch (error) { - readinessError = getErrorMessage(error); + readinessError = getErrorMessage(error) || 'Readiness request failed'; } } diff --git a/test/cli-agent.test.ts b/test/cli-agent.test.ts index 604549912..ff41f7cea 100644 --- a/test/cli-agent.test.ts +++ b/test/cli-agent.test.ts @@ -577,6 +577,21 @@ describe('agent spawn', () => { { method: 'GET', path: `/api/v1/sessions/${OTHER}/wait-output` }, ]); }); + + it.each([ + { error: new Error(''), kind: 'Error' }, + { error: '', kind: 'string' }, + ])('reports a readiness failure when the thrown $kind has no message', async ({ error }) => { + const deps = fakeDeps((options) => { + if (options.path === '/api/v1/quick-start') return ok({ sessionId: OTHER }); + throw error; + }, true); + + expect(await agentSpawn(deps, { caseName: 'c', mode: 'claude', ready: true, timeoutMs: 1000 })).toBe(EXIT.error); + expect(deps.out).toHaveLength(1); + expect(JSON.parse(deps.out[0])).toEqual({ sessionId: OTHER, readinessError: 'Readiness request failed' }); + expect(deps.calls).toHaveLength(2); + }); }); describe('session id prefixes', () => { From 2a90fb8118a8f603c52c8d8a77c4077bedbc342e Mon Sep 17 00:00:00 2001 From: Aamer Akhter Date: Sat, 24 Oct 2026 22:28:29 -0400 Subject: [PATCH 16/68] fix(terminal): keep the next table record out of a last-column URL A URL in the last column of a space-aligned table ended on a slash, so the first cell of the next record (billing-api, CHANGELOG.md, ...) passed every continuation test and was joined onto it. The alignment check that refused this only ran when other columns followed the URL. A continuation row must now start inside the URL's own cell: a gap of two or more spaces between its indent and where the URL starts on the first row means it opens an earlier column. An indented token that is a whole email address is also refused. Adds the six table shapes and the email case as negative tests at detector and provider level, and documents the rule. --- CLAUDE.md | 2 +- docs/architecture-invariants.md | 2 +- src/web/public/constants.js | 15 ++++- test/terminal-url-continuation.test.ts | 82 ++++++++++++++++++++++++++ 4 files changed, 96 insertions(+), 5 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index f528681b6..de0200247 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -224,7 +224,7 @@ Codeman is a Claude Code session manager with web interface and autonomous Ralph **File-path links (terminal + chat)**: a path an agent prints is clickable on BOTH surfaces and opens the file-preview overlay. ⚠️ ONE pattern (`FILE_PATH_LINK_PATTERN` / `absoluteFilePathPattern()` in constants.js) feeds the xterm link provider AND `_linkifyFilePaths()`, a fresh instance per call (`lastIndex`). Both call `findFilePathLinks()`, which adds `~/`, relative and folder paths without overlapping a rooted match or a URL; a relative path resolves against `workingDir` (`resolveLinkedFilePath`), `~/` only server-side on the session's host (attachment route), and `terminalPathLine()` joins a path a program hard-wrapped short of the edge. ⚠️ A folder link opens the **folder grid**: `POST /api/sessions/:id/folders` guards + GRANTS the folder (`folder-listing.ts`, own registry, never the 200-capped attachment registry), files come by bare name via `…/folders/:folderId/files/:name/*` through `resolveServableAttachmentPath` plus the same extension/regular-file gate on the symlink's resolved target, so no browser request carries a path; `~/` is refused in a Docker case (the container's home is not this host's). The chat linkifier walks TEXT NODES with DOM APIs, never rebuilds sanitized markup as a string. ⚠️ An out-of-workspace path goes through the ATTACHMENT routes (`POST /api/sessions/:id/attachments` with `notify: false`), never by widening `file-content`/`file-raw` or `file-stream-manager`'s `tail -f` allowlist. ⚠️ `TEXT_ATTACHMENT_EXTENSIONS` IS `EDITABLE_EXTENSIONS` (never a second list), and widening READ must never widen RUN: `html`/`htm`/`svg` stay download-only, other text is inert `text/plain`+`nosniff`. ⚠️ The ONE place HTML renders is `/html-view//...` (`html-view-routes.ts`, `html-view-capabilities.ts`): `POST /api/sessions/:id/html-view` mints a capability for the file's own directory, every response carries `Content-Security-Policy: sandbox` WITHOUT `allow-same-origin` (opaque origin even as a top-level tab, so the page can never read Codeman or drive its API) and with NO fetch directive (open network, owner decision 2026-10-13: never restrict it or add `allow-same-origin` without the owner), and only non-dot, asset-allowlisted files under that directory are served (GET/HEAD only; capabilities revoked with the web-tab ones on logout). That tree is served recursively, so minting is refused for a hidden directory or a broad root (`isBroadHtmlViewRoot`); the preview renders by default and the **Page** pill (or a refused mint) falls through to the source view and its Edit button. Never serve HTML same-origin anywhere else. Tests: `test/routes/html-view-routes.test.ts`. Media extensions are single-sourced in `attachment-registry.ts`. ⚠️ **XLSX previews parse in the BROWSER**, never on the server; the admission limits that keep a hostile workbook from freezing the worker live in `src/web/CLAUDE.md` (Spreadsheet preview). → [architecture-invariants#file-path-links-terminal--response-viewer](docs/architecture-invariants.md#file-path-links-terminal--response-viewer) -The terminal provider scans `terminalLinkLine()`, which extends `terminalPathLine()` for URL continuations in indented paragraphs and table cells. List markers, bare numbers and version tokens never continue a URL. `terminalCellWrappedPaths()` follows each table cell independently, gated by `pathContinuation()` and the per-cell row-length rule; accepted paths displace other links on their cells. Full-width divider rows stop `terminalLogicalLine()` stitching for both links and touch selection. OSC 8 links confirm the real target URI before the shared opener runs. +The terminal provider scans `terminalLinkLine()`, which extends `terminalPathLine()` for URL continuations in indented paragraphs and table cells. List markers, bare numbers, version tokens and whole email addresses never continue a URL. A URL in the last column of a space-aligned table never takes the next row's first cell: a continuation row must start inside the URL's own cell, so a gap of two or more spaces between its indent and where the URL starts on the row above means it is the next record. `terminalCellWrappedPaths()` follows each table cell independently, gated by `pathContinuation()` and the per-cell row-length rule; accepted paths displace other links on their cells. Full-width divider rows stop `terminalLogicalLine()` stitching for both links and touch selection. OSC 8 links confirm the real target URI before the shared opener runs. **Filesystem path picker** (Link Existing "Browse" + the mobile keyboard's `📁 Path` key): lazy one-directory browsing via `GET /api/filesystem/browse`, with `GET /api/filesystem/preview` for the tapped file. Inserts the path **without** Enter, so the prompt is never submitted; the sibling `⌫ All` key clears only the unsent prompt and must never send the agent's `/clear`. ⚠️ This is a **second file-serving surface and inherits neither the attachment confinement nor its ownership scoping** — it allowlists Home, `CASES_DIR`, `/mnt/d` and `CODEMAN_FILE_PICKER_ROOTS`, blocks sensitive trees, and rejects symlink escapes **after** `realpath`. ⚠️ The optional `sessionId` is an ownership boundary that must be `canAccessOwned`-checked by hand (it does not go through `findSessionOrFail`), and in multi-user mode a non-admin gets only their own `userSpacePath` as a root: per-user spaces live INSIDE `homedir()`, so a `Home` root exposes every other user's workspace. Previews go through the same global conversion limiter, and Markdown/TXT/JSON are served as inert `text/plain`. → [architecture-invariants#filesystem-path-picker](docs/architecture-invariants.md#filesystem-path-picker) diff --git a/docs/architecture-invariants.md b/docs/architecture-invariants.md index 68559b282..1d0e124ed 100644 --- a/docs/architecture-invariants.md +++ b/docs/architecture-invariants.md @@ -409,7 +409,7 @@ A file path an agent prints is a link on both surfaces it can appear on, and cli **Path forms beyond the rooted one.** `findFilePathLinks(text)` (constants.js) is the single entry point both consumers call; it returns the rooted matches first, then `~/` and relative paths (`RELATIVE_FILE_PATH_LINK_PATTERN`, same extension list, pinned equal by `test/link-provider-regex.test.ts`), then folder paths, each only where it overlaps neither an earlier match nor an `http(s)` URL. A relative match must start at a boundary (line start, whitespace, quote, bracket, `=`, `,`, `>`), so it can never begin inside a word, an absolute path or a URL; brackets, `=` and `,` end it (`(shot.png)`, `OUT=shots/a.png`). `resolveLinkedFilePath()` resolves a relative path against the session's `workingDir` (`.`/`..` collapsed, so `../x.png` lands outside and routes like any external path) and passes absolute and `~/` paths through. ⚠️ `~` is the home of the SESSION'S host, which only the server knows: `~/` paths always take the attachment route, where `registerExternalAttachment` expands them (`homedir()` locally, `$HOME` inside the remote probe script), and the guard runs on the expanded, realpath-resolved path as before. ⚠️ Programs hard-wrap their own output short of the terminal edge (Claude Code breaks tool output at 59-118 columns), so xterm's `isWrapped` never fires. `terminalPathLine()` (link provider only; touch selection keeps `terminalLogicalLine`) joins the next row when the previous row ends in a path token with a `/` and no extension and the join reaches an extension; a row that starts a new path is never joined: rooted or `~/`, `./`/`../`, or a relative one that opens on the same top directory the broken token did (the next entry of `git ls-files`/`find`/`ls -R`: `src/components` + `src/index.ts` are two paths, not `src/componentssrc/index.ts`), while a wrapped path continues INSIDE a name, so `…/captures/2` + `026-10-10/…` still joins; a 3+ row chain is kept only if a later row completes the path, and the broken row must be at least as long as the continuing one (keeps `cd /mnt/foo` + a longer next line apart). Tests: `test/file-path-link-forms.test.ts`. -The terminal provider scans `terminalLinkLine()`, which extends `terminalPathLine()` for URL continuations in indented paragraphs and table cells. List markers, bare numbers and version tokens never continue a URL. A token with only hyphens, underscores or dots must end its cell (row end or a gap of at least two spaces), so prose such as `self-hosted runners` is not appended. A row end counts as a cell end, so `⏺ Docs live at https://example.com/guide/` followed by ` self-hosted.` still links `.../guide/self-hosted`, and an indented `and/or the API reference covers it.` still links `.../guide/and/or`; both are pinned as known trade-offs. A continuation must be indented (a column-0 row is new output, which keeps `status=200`, `24/7` and `really?` out), and a shell prompt (`user@host:`), a home path (`~/`) or an HTTP status line never continues a URL. A divider row never continues the row above it, so a URL that reaches the right edge does not absorb a full-width `────` row below. A real plain slug followed by prose falls back to the original fragment link; `%`, `&`, `=`, `/` or `?` still qualify as stronger URL punctuation. `terminalCellWrappedPaths()` follows each table cell independently, gated by `pathContinuation()` and the per-cell row-length rule; accepted paths displace other links on their cells. Full-width divider rows stop `terminalLogicalLine()` stitching for both links and touch selection. OSC 8 links confirm the real target URI before the shared opener runs. +The terminal provider scans `terminalLinkLine()`, which extends `terminalPathLine()` for URL continuations in indented paragraphs and table cells. List markers, bare numbers and version tokens never continue a URL. A token with only hyphens, underscores or dots must end its cell (row end or a gap of at least two spaces), so prose such as `self-hosted runners` is not appended. A row end counts as a cell end, so `⏺ Docs live at https://example.com/guide/` followed by ` self-hosted.` still links `.../guide/self-hosted`, and an indented `and/or the API reference covers it.` still links `.../guide/and/or`; both are pinned as known trade-offs. A continuation must be indented (a column-0 row is new output, which keeps `status=200`, `24/7` and `really?` out), and a shell prompt (`user@host:`), a home path (`~/`), a whole email address or an HTTP status line never continues a URL. A URL in the last column of a space-aligned table never takes the next row's first cell: a continuation row must start inside the URL's own cell, so a gap of two or more spaces between its indent and where the URL starts on the row above means it is the next record. A divider row never continues the row above it, so a URL that reaches the right edge does not absorb a full-width `────` row below. A real plain slug followed by prose falls back to the original fragment link; `%`, `&`, `=`, `/` or `?` still qualify as stronger URL punctuation. `terminalCellWrappedPaths()` follows each table cell independently, gated by `pathContinuation()` and the per-cell row-length rule; accepted paths displace other links on their cells. Full-width divider rows stop `terminalLogicalLine()` stitching for both links and touch selection. OSC 8 links confirm the real target URI before the shared opener runs. **Folder grid: a folder is granted, then served by bare file name.** A folder path (rooted or `~/` whose last segment has no extension, relative only with a trailing `/`; `FOLDER_PATH_LINK_PATTERN` + `folderLinkPath()`) opens `openFolderGrid()` (panels-ui.js), as does the preview's "Show folder" button. `POST /api/sessions/:id/folders {path}` (`src/folder-listing.ts`) resolves the folder (locally a bounded `probePathKind(…, { pastCap: true })` first, then async `fs.realpath`; remotely one ssh round trip via `remoteListFolder`, through `runWithRemoteSshLimit`), runs the attachment guard on it (blocklist, `confineToWorkspace`, and in multi-user mode `folderAllowedForUser`: a non-admin only inside their own `userSpacePath` or the session's `workingDir`, as the filesystem path picker), locally BEFORE reading it, lists non-hidden subfolders and files of a supported attachment type (each file also blocklist-checked, newest first, capped at `MAX_FOLDER_ENTRIES`), and GRANTS it an id. Files are then fetched as `GET …/folders/:folderId/files/:name/(raw|preview|thumbnail)`: `:name` must be a bare file name of a supported type (no `/`, `\`, NUL, no leading `.`, since the listing hides those), the local file is probed bounded (`unknown` → 503, never 404), and the joined path goes through the SAME `resolveServableAttachmentPath` as an attachment by id (symlink re-resolution, remote re-probe, guard), then the same gate registration applies to what a symlink RESOLVES to (a supported extension on the resolved path and a regular file: locally `fs.stat`, remotely the probe kind; else 400, so `shot.png -> ../secret.bin` is not served and a FIFO or device behind a supported name never reaches `open`), then `folderAllowedForUser` once more on the resolved file, before `serveServableRaw` (the attachment raw route's body, shared) streams it. So browser requests still never carry a path. ⚠️ Grants live in their own registry (`folderGrants`, 32 per session, cleared with the session's attachments), NOT the attachment registry: that one is capped at 200 per session, and paging a folder of 300 renders would evict the session's real attachments. ⚠️ A local folder is never touched unbounded (#516 rule): a folder on a network mount that stopped answering answers 503 with `describeUnknownPath()`, not 404 and not a frozen server. ⚠️ The remote listing script tells "missing" (`E|n`, 404) from "is a file" (`E|f`, 400 `Not a folder`), because the client falls back to the file preview on `Not a folder`; a remote extensionless file otherwise answered "Folder not found". ⚠️ `.folder-grid-overlay` sits at `z-index: 5090`, one step BELOW the preview (5100) and above the response viewer (5000): a tile's preview opens on top and closing it lands back on the grid. ⚠️ ←/→/Esc are handled in a document CAPTURE listener with `stopPropagation`, because the terminal keeps keyboard focus under the overlays and an arrow that reached xterm would be typed into the session; inputs other than xterm's helper textarea keep their keys. ⚠️ A route PARAMETER is capped by the router (Fastify's default is 100 characters, answered as 404 "Route not found"), and a file name goes up to 255, percent-encoded: the server sets `routerOptions.maxParamLength` to `MAX_ROUTE_PARAM_LENGTH` (route-helpers.ts), the route test harness uses the same constant, and a wiring guard pins it. ⚠️ `~/` in a Docker case is refused with 400 (attachment POST and `POST …/folders`): the session is local for these routes, but `~` inside the container is the container's own home, and expanding it on the host would open a different file than the one the agent printed. Tests: `test/routes/folder-grid-routes.test.ts` (incl. the listing script on a real `sh`), `test/folder-grid-ui.test.ts`. diff --git a/src/web/public/constants.js b/src/web/public/constants.js index 3f8f5d23f..deb5cf2d4 100644 --- a/src/web/public/constants.js +++ b/src/web/public/constants.js @@ -2569,12 +2569,15 @@ function findTerminalUrls(text) { * - the URL fragment ends on a character a wrapper breaks after * (`/ ? & = - _ % # + ~`); a following table column may be excluded; * - the next row is indented, but no further right than the column the URL - * started in (a table cell or list item continuation, not unrelated text); + * started in (a table cell or list item continuation, not unrelated text), + * and no column gap lies between its indent and the URL on the first row + * (that would be the next record of a table); * - that row's first token looks like URL material (contains one of * `% & = / ? . - _`), is not a bare number/version or list marker, * and is not itself a new `scheme://`, a shell prompt (`user@host:`), - * a home path (`~/`) or an HTTP status line; without `% & = / ?`, the token - * must finish its cell (row end or a gap of at least two spaces). + * a home path (`~/`), an email address or an HTTP status line; without + * `% & = / ?`, the token must finish its cell (row end or a gap of at + * least two spaces). * The last rule is what stops prose such as "…/files/" + "then run it" from * being glued into one link. * @@ -2629,10 +2632,16 @@ function terminalUrlContinuationExtent(buffer, row, cols, maxRows) { / {2,}/.test(first.text.slice(indent, last.startIndex))) ) break; + // The next row must start inside the URL's own cell: a column gap between + // its indent and the URL on the first row means it opens an earlier + // column (the next record of a table), not the rest of the URL. + if (/ {2,}/.test(first.text.slice(indent, last.startIndex).trimStart())) break; if (/^[a-z][a-z0-9+.-]*:\/\//i.test(token) || !/^[A-Za-z0-9%_~]/.test(token)) break; // A shell prompt (`user@host:~/dir$`), a home path and an HTTP status line // are never the rest of a URL, however much URL punctuation they carry. if (/^[\w.-]+@[\w.-]+:|^~\/|^HTTP\/\d/i.test(token)) break; + // Nor is a token that is a whole email address. + if (/^[\w.+-]+@[\w-]+(?:\.[\w-]+)+$/.test(token)) break; if (!/[%&=/?.\-_]/.test(token) || /^v?\d+(?:\.\d+)*(?:[-+][\w.-]+)?[.,;:!?)]?$/i.test(token)) break; // Hyphens, underscores and dots also occur in prose. Without stronger // URL punctuation, the token must finish its cell, not introduce a sentence. diff --git a/test/terminal-url-continuation.test.ts b/test/terminal-url-continuation.test.ts index b03a5ccf9..ad6fc98f1 100644 --- a/test/terminal-url-continuation.test.ts +++ b/test/terminal-url-continuation.test.ts @@ -120,6 +120,54 @@ const NOT_CONTINUATION_ROWS: [string, string, string, string | null][] = [ ['Status page: https://status.example.com/', '24/7 monitoring is on.', 'https://status.example.com/', null], ['Did you mean https://example.com/search?q=', 'really? no.', 'https://example.com/search?q=', null], ]; +// The last column of a space-aligned table: the next row opens the next record +// in an earlier column, so its first cell is never the rest of the URL. Each +// row is indented two spaces. The URL on each row, then any path link expected +// on the second row. +const TABLE_LAST_COLUMN_ROWS: [string, string, string, string, string | null][] = [ + [ + ' auth-api https://auth.example.com/', + ' billing-api https://billing.example.com/', + 'https://auth.example.com/', + 'https://billing.example.com/', + null, + ], + [ + ' docs.example.com https://docs.example.com/', + ' api.example.com https://api.example.com/', + 'https://docs.example.com/', + 'https://api.example.com/', + null, + ], + [ + ' Homepage: https://example.com/', + ' Bug-reports: https://github.com/org/repo/issues/', + 'https://example.com/', + 'https://github.com/org/repo/issues/', + null, + ], + [ + ' user_service https://users.example.com/v1/', + ' order_service https://orders.example.com/v1/', + 'https://users.example.com/v1/', + 'https://orders.example.com/v1/', + null, + ], + [ + ' README.md https://example.com/docs/', + ' CHANGELOG.md https://example.com/changes/', + 'https://example.com/docs/', + 'https://example.com/changes/', + 'CHANGELOG.md', + ], + [ + ' my-service- https://svc.example.com/api/', + ' long-name https://svc2.example.com/', + 'https://svc.example.com/api/', + 'https://svc2.example.com/', + null, + ], +]; // Known trade-offs: a row end counts as a cell end, so a short indented word // with URL punctuation still continues the URL. Pinned so a change is deliberate. const TRADE_OFF_ROWS: [string, string, string][] = [ @@ -238,6 +286,16 @@ describe('terminalUrlContinuationExtent (wrap joining)', () => { } ); + it.each(TABLE_LAST_COLUMN_ROWS)('does not join the next table record after %s', (first, next) => { + const buffer = fakeBuffer([{ text: first }, { text: next }], 100); + for (const row of [0, 1]) expect(terminalUrlContinuationExtent(buffer, row, 100, 12)).toBeNull(); + }); + + it('does not join an indented email address', () => { + const buffer = fakeBuffer([{ text: ' https://example.com/' }, { text: ' user@example.com' }], 100); + for (const row of [0, 1]) expect(terminalUrlContinuationExtent(buffer, row, 100, 12)).toBeNull(); + }); + it.each(TRADE_OFF_ROWS)('still joins the known trade-off after %s', (first, next) => { const buffer = fakeBuffer([{ text: first }, { text: next }], 100); for (const row of [0, 1]) @@ -413,6 +471,30 @@ describe('terminal link provider with wrapped table URLs', () => { if (pathOnNext) expect(second.map((link) => link.text)).toContain(pathOnNext); }); + it.each(TABLE_LAST_COLUMN_ROWS)( + 'keeps each last-column URL on its own row: %s', + (first, next, url, nextUrl, pathOnNext) => { + const { provide, openSpy } = harness([{ text: first }, { text: next }], 100); + const links = provide(1).filter((link) => /^https?:/.test(link.text)); + expect(links.map((link) => link.text)).toEqual([url]); + links[0].activate(null, links[0].text); + expect(openSpy).toHaveBeenCalledExactlyOnceWith(url, '_blank', 'noopener,noreferrer'); + const second = provide(2); + expect(second.filter((link) => /^https?:/.test(link.text)).map((link) => link.text)).toEqual([nextUrl]); + if (pathOnNext) expect(second.map((link) => link.text)).toContain(pathOnNext); + } + ); + + it('keeps an indented email address out of the URL above it', () => { + const { provide } = harness([{ text: ' https://example.com/' }, { text: ' user@example.com' }], 100); + expect( + provide(1) + .filter((link) => /^https?:/.test(link.text)) + .map((link) => link.text) + ).toEqual(['https://example.com/']); + expect(provide(2).filter((link) => /^https?:/.test(link.text))).toEqual([]); + }); + it.each(TRADE_OFF_ROWS)('pins the known trade-off link after %s', (first, next, joined) => { const { provide } = harness([{ text: first }, { text: next }], 100); for (const line of [1, 2]) { From 5e1daaf7c7a843b138fcf7ef26e32c4659e6d715 Mon Sep 17 00:00:00 2001 From: Aamer Akhter Date: Sat, 24 Oct 2026 22:28:57 -0400 Subject: [PATCH 17/68] fix(terminal): keep label prose out of labelled spaced paths A path label says a path follows, not that the words before it belong to the path. Restore the guard that defers a relative candidate whose only slash word is its last, so `Wrote: 120 lines to src/foo.ts` links src/foo.ts as on master. `Saved to: My Docs/plan.md` and `Saved to: 03 Attachments/plan.pptx` split again as a documented trade-off; `Files: src/components and utils/x.ts` is pinned as the residual the guard does not catch. Refuse command lines whose executable sits under a bin/ or .bin/ directory, or that carry a flag word, so they keep master's links. Update the JSDoc example, the CLAUDE.md pointer and the architecture-invariants paragraph. --- CLAUDE.md | 2 +- docs/architecture-invariants.md | 2 +- src/web/public/constants.js | 15 ++++++- test/response-viewer-file-links.test.ts | 8 ++++ test/spaced-file-path-links.test.ts | 60 ++++++++++++++++++++++++- 5 files changed, 82 insertions(+), 5 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index b6ce6117b..ed1dd67ce 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -222,7 +222,7 @@ Codeman is a Claude Code session manager with web interface and autonomous Ralph **Prompt uploads** (`POST /api/sessions/:id/paste-image`: clipboard paste, drop, the Compose Attach key and the phone toolbar's Attach button; client wiring in `image-input.js` and `keyboard-accessory.js`): a pick is written to `/.codeman-uploads/` and its path lands on the prompt as unsent text. The folder is IN the workspace because that is the only path identical for a local and a container agent, FLAT because `/.codeman/` would BE the data dir when the workspace is the home directory, and self-ignoring through a `.gitignore` of `*` written ONCE with `wx` (a file already there is the user's and is never overwritten; the user's own ignore file is never touched). ⚠️ The directory names live ONLY in `paste-image-gc.ts` (`UPLOAD_DIR_NAMES`): the pre-move `.claude-images/` receives nothing but stays readable for one release, so the GC sweep and the delete cleanup (through `uploadDirs()`) and the image watcher's ignore filter (reading the names) cover BOTH; retire the legacy entry there, never by hand elsewhere. ⚠️ `uploadDirs()` is async and probes the workspace BOUNDED first (`probePathKind()`, the #516 rule: an `unknown` path is skipped, never touched; the sweep keeps the stall cap and the delete passes `pastCap`), then returns only REAL directories (lstat, so a planted symlink is never one), none that is or contains `getDataDir()` (one strictly inside it only holds uploads and is listed), and none for a remote session, since both consumers delete; the check is made at list time, and the same-user race after it is accepted. Images are buffered in memory under `MAX_PASTE_IMAGE_BYTES` (50MB) after a client-side canvas re-encode; a DOCUMENT (pdf, docx, pptx, xlsx, zip and the text family txt/md/csv/tsv/json/log/yaml/yml/xml; the curated `ALLOWED_DOCUMENT_EXTS`, never `SUPPORTED_ATTACHMENT_EXTENSIONS` or `EDITABLE_EXTENSIONS`, which carry sh/py/js/html) is admitted by its name's extension alone, buffered like an image, judged on its bytes by the same fail-closed `imageMagicMatchesExt` (`%PDF-`, `PK\x03\x04` for the OOXML trio and zip, UTF-8 without a NUL byte for text: a SIGNATURE or ENCODING check, never a structure or content check, so generic zip bytes pass as `.docx` and a script renamed `.txt` is text; the sh/py/js/html exclusion is a SCOPE choice by extension, keeping the server from storing what it would serve or run, not a content filter: the agent can already write any of those) and saved as `paste---` with a sanitized stem (`[A-Za-z0-9._-]`, 40 chars, a wholly non-ASCII name leaves no stem; the `paste-` prefix is what the GC matches). A VIDEO is never buffered: `?kind=video` streams it to disk under `MAX_PASTE_VIDEO_BYTES` (1GB, env `CODEMAN_MAX_PASTE_VIDEO_BYTES`), judges the container on the first 12 bytes as they arrive (ISO BMFF / QuickTime atom, EBML for webm, Ogg for ogv; a mislabeled body is drained unwritten from there, never parked on disk first) and unlinks on any failure, a dead transfer included. ⚠️ The flag is the ONLY way onto that path and admits video types alone (the registry's `VIDEO_ATTACHMENT_EXTENSIONS`, single-sourced: mp4/webm/mov/m4v/ogv, so an uploaded video is one the file viewer can also play; 3gp/mkv are out, since a phone's camera roll produces neither and neither previews), so an image can never ride the larger cap through memory; without it the route is byte-identical to the image-only version. Busboy takes its size limit before the part is seen, which is why the choice rides the URL and not the file. ⚠️ A video draws on its OWN per-(IP, session) rate bucket (`consumeVideoToken`, 3/min) beside the image bucket (`consumePasteToken`, 30/min): at 1GB each, 30/min would be 30GB of disk a minute, so the lower rate bounds it to 3GB; the image path keeps the 30/min bucket and is unchanged without the flag. A remote (SSH) session is refused (400) before any disk touch: its `workingDir` is the remote path, so the file would land on THIS host where the agent cannot read it (Docker is fine, the workspace is bind-mounted at the same absolute path). ⚠️ A live session's workspace can still sit on a network mount that stopped answering, so the route probes it BOUNDED (`probePathKind(session.workingDir, { pastCap: true })`, the #516 rule) before the first disk touch and refuses an `unknown` mount through `describeUnknownPath()` (OPERATION_FAILED), the way session create does, so a mount already unreachable cannot freeze a libuv worker on every upload (a mount that dies after the probe is the same residual race create accepts). ⚠️ Dispatch mirrors the client: an `image/*` MIME takes the IMAGE path whatever the name says (a GIF named `animation.txt` stays a GIF), a document extension takes the document branch, and anything else takes the image path exactly as before, where the HEIC sniff still recovers a HEIC under a generic name; a foreign extension with no image MIME gets its `Unsupported file type` 400 only AFTER that sniff fails, never before, so nothing the image path ever accepted by its bytes is refused. A text mismatch is refused as not UTF-8 text (UTF-16 when the file opens with a UTF-16 BOM, as Excel's "Unicode Text" and Notepad's "Unicode" do), telling the user to save it as UTF-8, and its log line names the failure (`utf16-bom`/`nul-byte`/`not-utf8`) and never prints the bytes, since the head of a text file is user content. ⚠️ The client classifies a pick through ONE helper, `_promptAttachKind()` (MIME type first, then the name's extension, because some Android file managers hand over a File with none; returns `image`, `video` or `document`); every pick and paste filter (Attach, drop, the tile grid's drop in `tile-grid.js`, the Compose and paste-dialog pickers, the clipboard handlers), the flag and the re-encode gate read it, never `file.type` directly. Those surfaces ADMIT through `_admitPromptAttachments()` (image-input.js), which calls it and toasts what a pick skipped (how many files of a mixed pick, `Unsupported file type` when none pass, nothing for a text paste); a new surface admits through that helper too, never by filtering on `_promptAttachKind()` itself, or it loses the toasts. ⚠️ A pick that lands while the tile grid owns the terminal goes to the PTY, never the main terminal's overlay: that terminal is parked and hidden then, and a tile's Enter cannot flush it (`_tilesOwnTerminal()` in the `_attachFromToolbar` guard). Tests: `test/toolbar-attach.test.ts`, `test/mobile-prompt-composer.test.ts`, `test/paste-image-gc.test.ts`, `test/paste-image-dir-shared.test.ts`, `test/upload-document-list-parity.test.ts` (the browser's copies of `ALLOWED_DOCUMENT_EXTS`: `_promptAttachKind()`, the three pickers' `accept=` and the two file-path link patterns), the paste-image block of `test/routes/session-routes.test.ts`. → [architecture-invariants#buffers-uploads-and-terminal-history](docs/architecture-invariants.md#buffers-uploads-and-terminal-history) -**File-path links (terminal + chat)**: a path an agent prints is clickable on BOTH surfaces and opens the file-preview overlay. ⚠️ ONE pattern (`FILE_PATH_LINK_PATTERN` / `absoluteFilePathPattern()` in constants.js) feeds the xterm link provider AND `_linkifyFilePaths()`, a fresh instance per call (`lastIndex`). Both call `findFilePathLinks()`, which reserves spaced-path spans first (even unlinkable ones; relative/`~/` spaced paths require an explicit path label), then adds rooted, `~/`, relative and folder paths without overlapping a reservation, earlier match or URL; a relative path resolves against `workingDir` (`resolveLinkedFilePath`), `~/` only server-side on the session's host (attachment route), and `terminalPathLine()` joins a path a program hard-wrapped short of the edge. ⚠️ A folder link opens the **folder grid**: `POST /api/sessions/:id/folders` guards + GRANTS the folder (`folder-listing.ts`, own registry, never the 200-capped attachment registry), files come by bare name via `…/folders/:folderId/files/:name/*` through `resolveServableAttachmentPath` plus the same extension/regular-file gate on the symlink's resolved target, so no browser request carries a path; `~/` is refused in a Docker case (the container's home is not this host's). The chat linkifier walks TEXT NODES with DOM APIs, never rebuilds sanitized markup as a string. ⚠️ An out-of-workspace path goes through the ATTACHMENT routes (`POST /api/sessions/:id/attachments` with `notify: false`), never by widening `file-content`/`file-raw` or `file-stream-manager`'s `tail -f` allowlist. ⚠️ `TEXT_ATTACHMENT_EXTENSIONS` IS `EDITABLE_EXTENSIONS` (never a second list), and widening READ must never widen RUN: `html`/`htm`/`svg` stay download-only, other text is inert `text/plain`+`nosniff`. ⚠️ The ONE place HTML renders is `/html-view//...` (`html-view-routes.ts`, `html-view-capabilities.ts`): `POST /api/sessions/:id/html-view` mints a capability for the file's own directory, every response carries `Content-Security-Policy: sandbox` WITHOUT `allow-same-origin` (opaque origin even as a top-level tab, so the page can never read Codeman or drive its API) and with NO fetch directive (open network, owner decision 2026-10-13: never restrict it or add `allow-same-origin` without the owner), and only non-dot, asset-allowlisted files under that directory are served (GET/HEAD only; capabilities revoked with the web-tab ones on logout). That tree is served recursively, so minting is refused for a hidden directory or a broad root (`isBroadHtmlViewRoot`); the preview renders by default and the **Page** pill (or a refused mint) falls through to the source view and its Edit button. Never serve HTML same-origin anywhere else. Tests: `test/routes/html-view-routes.test.ts`. Media extensions are single-sourced in `attachment-registry.ts`. ⚠️ **XLSX previews parse in the BROWSER**, never on the server; the admission limits that keep a hostile workbook from freezing the worker live in `src/web/CLAUDE.md` (Spreadsheet preview). → [architecture-invariants#file-path-links-terminal--response-viewer](docs/architecture-invariants.md#file-path-links-terminal--response-viewer) +**File-path links (terminal + chat)**: a path an agent prints is clickable on BOTH surfaces and opens the file-preview overlay. ⚠️ ONE pattern (`FILE_PATH_LINK_PATTERN` / `absoluteFilePathPattern()` in constants.js) feeds the xterm link provider AND `_linkifyFilePaths()`, a fresh instance per call (`lastIndex`). Both call `findFilePathLinks()`, which reserves spaced-path spans first (an unlinkable one only while its last word holds a `/`; any label, before an absolute or relative path, must be an explicit path label, and prose after that label stays out; rules and trade-offs in the architecture-invariants paragraph linked below), then adds rooted, `~/`, relative and folder paths without overlapping a reservation, earlier match or URL; a relative path resolves against `workingDir` (`resolveLinkedFilePath`), `~/` only server-side on the session's host (attachment route), and `terminalPathLine()` joins a path a program hard-wrapped short of the edge. ⚠️ A folder link opens the **folder grid**: `POST /api/sessions/:id/folders` guards + GRANTS the folder (`folder-listing.ts`, own registry, never the 200-capped attachment registry), files come by bare name via `…/folders/:folderId/files/:name/*` through `resolveServableAttachmentPath` plus the same extension/regular-file gate on the symlink's resolved target, so no browser request carries a path; `~/` is refused in a Docker case (the container's home is not this host's). The chat linkifier walks TEXT NODES with DOM APIs, never rebuilds sanitized markup as a string. ⚠️ An out-of-workspace path goes through the ATTACHMENT routes (`POST /api/sessions/:id/attachments` with `notify: false`), never by widening `file-content`/`file-raw` or `file-stream-manager`'s `tail -f` allowlist. ⚠️ `TEXT_ATTACHMENT_EXTENSIONS` IS `EDITABLE_EXTENSIONS` (never a second list), and widening READ must never widen RUN: `html`/`htm`/`svg` stay download-only, other text is inert `text/plain`+`nosniff`. ⚠️ The ONE place HTML renders is `/html-view//...` (`html-view-routes.ts`, `html-view-capabilities.ts`): `POST /api/sessions/:id/html-view` mints a capability for the file's own directory, every response carries `Content-Security-Policy: sandbox` WITHOUT `allow-same-origin` (opaque origin even as a top-level tab, so the page can never read Codeman or drive its API) and with NO fetch directive (open network, owner decision 2026-10-13: never restrict it or add `allow-same-origin` without the owner), and only non-dot, asset-allowlisted files under that directory are served (GET/HEAD only; capabilities revoked with the web-tab ones on logout). That tree is served recursively, so minting is refused for a hidden directory or a broad root (`isBroadHtmlViewRoot`); the preview renders by default and the **Page** pill (or a refused mint) falls through to the source view and its Edit button. Never serve HTML same-origin anywhere else. Tests: `test/routes/html-view-routes.test.ts`. Media extensions are single-sourced in `attachment-registry.ts`. ⚠️ **XLSX previews parse in the BROWSER**, never on the server; the admission limits that keep a hostile workbook from freezing the worker live in `src/web/CLAUDE.md` (Spreadsheet preview). → [architecture-invariants#file-path-links-terminal--response-viewer](docs/architecture-invariants.md#file-path-links-terminal--response-viewer) **Filesystem path picker** (Link Existing "Browse" + the mobile keyboard's `📁 Path` key): lazy one-directory browsing via `GET /api/filesystem/browse`, with `GET /api/filesystem/preview` for the tapped file. Inserts the path **without** Enter, so the prompt is never submitted; the sibling `⌫ All` key clears only the unsent prompt and must never send the agent's `/clear`. ⚠️ This is a **second file-serving surface and inherits neither the attachment confinement nor its ownership scoping** — it allowlists Home, `CASES_DIR`, `/mnt/d` and `CODEMAN_FILE_PICKER_ROOTS`, blocks sensitive trees, and rejects symlink escapes **after** `realpath`. ⚠️ The optional `sessionId` is an ownership boundary that must be `canAccessOwned`-checked by hand (it does not go through `findSessionOrFail`), and in multi-user mode a non-admin gets only their own `userSpacePath` as a root: per-user spaces live INSIDE `homedir()`, so a `Home` root exposes every other user's workspace. Previews go through the same global conversion limiter, and Markdown/TXT/JSON are served as inert `text/plain`. → [architecture-invariants#filesystem-path-picker](docs/architecture-invariants.md#filesystem-path-picker) diff --git a/docs/architecture-invariants.md b/docs/architecture-invariants.md index 83a6d304d..88805b8bb 100644 --- a/docs/architecture-invariants.md +++ b/docs/architecture-invariants.md @@ -409,7 +409,7 @@ A file path an agent prints is a link on both surfaces it can appear on, and cli **Path forms beyond the rooted one.** `findFilePathLinks(text)` (constants.js) is the single entry point both consumers call; it reserves spaced candidates first (including unlinkable spans), then detects rooted matches, then `~/` and relative paths (`RELATIVE_FILE_PATH_LINK_PATTERN`, same extension list, pinned equal by `test/link-provider-regex.test.ts`), then folder paths, each only where it overlaps neither an earlier match nor an `http(s)` URL. A relative match must start at a boundary (line start, whitespace, quote, bracket, `=`, `,`, `>`), so it can never begin inside a word, an absolute path or a URL; brackets, `=` and `,` end it (`(shot.png)`, `OUT=shots/a.png`). `resolveLinkedFilePath()` resolves a relative path against the session's `workingDir` (`.`/`..` collapsed, so `../x.png` lands outside and routes like any external path) and passes absolute and `~/` paths through. ⚠️ `~` is the home of the SESSION'S host, which only the server knows: `~/` paths always take the attachment route, where `registerExternalAttachment` expands them (`homedir()` locally, `$HOME` inside the remote probe script), and the guard runs on the expanded, realpath-resolved path as before. ⚠️ Programs hard-wrap their own output short of the terminal edge (Claude Code breaks tool output at 59-118 columns), so xterm's `isWrapped` never fires. `terminalPathLine()` (link provider only; touch selection keeps `terminalLogicalLine`) joins the next row when the previous row ends in a path token with a `/` and no extension and the join reaches an extension; a row that starts a new path is never joined: rooted or `~/`, `./`/`../`, or a relative one that opens on the same top directory the broken token did (the next entry of `git ls-files`/`find`/`ls -R`: `src/components` + `src/index.ts` are two paths, not `src/componentssrc/index.ts`), while a wrapped path continues INSIDE a name, so `…/captures/2` + `026-10-10/…` still joins; a 3+ row chain is kept only if a later row completes the path, and the broken row must be at least as long as the continuing one (keeps `cd /mnt/foo` + a longer next line apart). Tests: `test/file-path-link-forms.test.ts`. -Spaced paths are considered first by `findFilePathLinks()`: accepted candidates reserve their entire span even when unlinkable, so later detectors cannot link fragments. Relative and `~/` spaced paths require an explicit path label (`Path:`, `File:`, `Files:`, `Saved to:`, `Saved as:`, `Wrote:`, `Written to:`, `Output:`, `Created:`, `Location:`, `Attachment:`), optionally preceded by a marker. Generic labels and bare bullets defer to ordinary detection, including slash-containing prose such as `CI/CD` or `45/45`. A later `/`, `~/`, `./` or `../` word starts a separate path; an earlier word ending in `/` is a separate folder. Absolute paths keep the own-line and marker forms, including the residual ambiguity `/home/me/proj contains a.md`, plus the same path labels; any other label (`Error:`, `Note:`, `Saved the report to:`) defers an absolute path too. A rooted candidate needs a directory before its first space, so `/review the changes in src/a.ts` is prose. A `:` followed by a digit inside an earlier word is a line suffix (`a.ts:42:See b.md`, grep and compiler output) and ends the path there, a run of spaces is a column gap rather than a name, and a leading `./` is not a segment. An unlinkable candidate reserves its span only while its last word holds a `/`, so `/usr/bin/env node build.js` keeps its ordinary links while `/etc/secret files/passwords.txt` links nothing. The response viewer uses a leading `` path label in a paragraph or list item, or one starting a later line of a paragraph (after a `
`, since chat renders with `breaks: true`), as detection context without changing the label or displayed text; paths split across other inline elements remain detected per text node. Tests feed vendored marked output into the jsdom linkifier, so consumed bullets and bold labels are covered. Trailing whitespace uses linear `trimEnd()` before the length cap. +Spaced paths are considered first by `findFilePathLinks()`: accepted candidates reserve their entire span even when unlinkable, so later detectors cannot link fragments. Relative and `~/` spaced paths require an explicit path label (`Path:`, `File:`, `Files:`, `Saved to:`, `Saved as:`, `Wrote:`, `Written to:`, `Output:`, `Created:`, `Location:`, `Attachment:`), optionally preceded by a marker. Generic labels and bare bullets defer to ordinary detection, including slash-containing prose such as `CI/CD` or `45/45`. A later `/`, `~/`, `./` or `../` word starts a separate path; an earlier word ending in `/` is a separate folder. Absolute paths keep the own-line and marker forms, including the residual ambiguity `/home/me/proj contains a.md`, plus the same path labels; any other label (`Error:`, `Note:`, `Saved the report to:`) defers an absolute path too. A rooted candidate needs a directory before its first space, so `/review the changes in src/a.ts` is prose. A `:` followed by a digit inside an earlier word is a line suffix (`a.ts:42:See b.md`, grep and compiler output) and ends the path there, a run of spaces is a column gap rather than a name, and a leading `./` is not a segment. An unlinkable candidate reserves its span only while its last word holds a `/`, so `/usr/bin/env node build.js` keeps its ordinary links while `/etc/secret files/passwords.txt` links nothing. A path label vouches that a path follows, not for the words before it: a relative candidate whose only `/` word is its last is prose after the label (`Wrote: 120 lines to src/foo.ts`, `Path: see src/foo.ts`) and defers to the ordinary detectors. The cost is a labelled relative path whose only space is in its top directory, `Saved to: My Docs/plan.md` (and `Saved to: 03 Attachments/plan.pptx`), which splits as on master; `03 Attachments/Build SIPOC Design/Build-SIPOC.pptx`, `notes/a b/c.md`, `~/` and `./` forms and absolute paths still link whole. The guard does not catch prose between two `/` words, so `Files: src/components and utils/x.ts` still links as one path (pinned as a trade-off). A command line is refused too: a first word that is an executable under `bin/` or `.bin/` (`/opt/homebrew/bin/node server.js`) or any `-x`/`--x` flag word (`…/.bin/tsc -p tsconfig.json`); a bare `-` (`Docs - Copy`, `OneDrive - Contoso`) is not a flag. The response viewer uses a leading `` path label in a paragraph or list item, or one starting a later line of a paragraph (after a `
`, since chat renders with `breaks: true`), as detection context without changing the label or displayed text; paths split across other inline elements remain detected per text node. Tests feed vendored marked output into the jsdom linkifier, so consumed bullets and bold labels are covered. Trailing whitespace uses linear `trimEnd()` before the length cap. **Folder grid: a folder is granted, then served by bare file name.** A folder path (rooted or `~/` whose last segment has no extension, relative only with a trailing `/`; `FOLDER_PATH_LINK_PATTERN` + `folderLinkPath()`) opens `openFolderGrid()` (panels-ui.js), as does the preview's "Show folder" button. `POST /api/sessions/:id/folders {path}` (`src/folder-listing.ts`) resolves the folder (locally a bounded `probePathKind(…, { pastCap: true })` first, then async `fs.realpath`; remotely one ssh round trip via `remoteListFolder`, through `runWithRemoteSshLimit`), runs the attachment guard on it (blocklist, `confineToWorkspace`, and in multi-user mode `folderAllowedForUser`: a non-admin only inside their own `userSpacePath` or the session's `workingDir`, as the filesystem path picker), locally BEFORE reading it, lists non-hidden subfolders and files of a supported attachment type (each file also blocklist-checked, newest first, capped at `MAX_FOLDER_ENTRIES`), and GRANTS it an id. Files are then fetched as `GET …/folders/:folderId/files/:name/(raw|preview|thumbnail)`: `:name` must be a bare file name of a supported type (no `/`, `\`, NUL, no leading `.`, since the listing hides those), the local file is probed bounded (`unknown` → 503, never 404), and the joined path goes through the SAME `resolveServableAttachmentPath` as an attachment by id (symlink re-resolution, remote re-probe, guard), then the same gate registration applies to what a symlink RESOLVES to (a supported extension on the resolved path and a regular file: locally `fs.stat`, remotely the probe kind; else 400, so `shot.png -> ../secret.bin` is not served and a FIFO or device behind a supported name never reaches `open`), then `folderAllowedForUser` once more on the resolved file, before `serveServableRaw` (the attachment raw route's body, shared) streams it. So browser requests still never carry a path. ⚠️ Grants live in their own registry (`folderGrants`, 32 per session, cleared with the session's attachments), NOT the attachment registry: that one is capped at 200 per session, and paging a folder of 300 renders would evict the session's real attachments. ⚠️ A local folder is never touched unbounded (#516 rule): a folder on a network mount that stopped answering answers 503 with `describeUnknownPath()`, not 404 and not a frozen server. ⚠️ The remote listing script tells "missing" (`E|n`, 404) from "is a file" (`E|f`, 400 `Not a folder`), because the client falls back to the file preview on `Not a folder`; a remote extensionless file otherwise answered "Folder not found". ⚠️ `.folder-grid-overlay` sits at `z-index: 5090`, one step BELOW the preview (5100) and above the response viewer (5000): a tile's preview opens on top and closing it lands back on the grid. ⚠️ ←/→/Esc are handled in a document CAPTURE listener with `stopPropagation`, because the terminal keeps keyboard focus under the overlays and an arrow that reached xterm would be typed into the session; inputs other than xterm's helper textarea keep their keys. ⚠️ A route PARAMETER is capped by the router (Fastify's default is 100 characters, answered as 404 "Route not found"), and a file name goes up to 255, percent-encoded: the server sets `routerOptions.maxParamLength` to `MAX_ROUTE_PARAM_LENGTH` (route-helpers.ts), the route test harness uses the same constant, and a wiring guard pins it. ⚠️ `~/` in a Docker case is refused with 400 (attachment POST and `POST …/folders`): the session is local for these routes, but `~` inside the container is the container's own home, and expanding it on the host would open a different file than the one the agent printed. Tests: `test/routes/folder-grid-routes.test.ts` (incl. the listing script on a real `sh`), `test/folder-grid-ui.test.ts`. diff --git a/src/web/public/constants.js b/src/web/public/constants.js index d8b87884a..fafd87b3c 100644 --- a/src/web/public/constants.js +++ b/src/web/public/constants.js @@ -2232,7 +2232,7 @@ const SPACED_FILE_PATH_LABEL_PATTERN = * * /mnt/c/My Docs/report.docx the whole line is the path * - /mnt/c/My Docs/report.docx after a list marker (`-`, `*`, `•`, `1.`, `2)`) - * Saved to: 03 Attachments/Build SIPOC Design/plan.pptx after a short label (one to four words + `:`) + * Saved to: 03 Attachments/Build SIPOC Design/plan.pptx after a path label (SPACED_FILE_PATH_LABEL_PATTERN) * * An absolute path may stand alone or follow a marker. Any label, before an * absolute, relative or `~/` path, must be an explicit path label @@ -2245,7 +2245,10 @@ const SPACED_FILE_PATH_LABEL_PATTERN = * digit (`a.ts:42`, a line suffix ends the path). Later words cannot start a new * `/`, `~/`, `./` or `../` path, no earlier word ends in `:` or is an arrow, and * a run of spaces (a column gap) is refused, so two paths on a line are never - * merged. + * merged. A relative candidate whose only `/` word is its last is prose after the + * label (`Wrote: 120 lines to src/foo.ts`) and is left alone, which also splits + * `Saved to: My Docs/plan.md` as master does. A command line is refused: an + * executable under `bin/` or `.bin/` as the first word, or any `-x`/`--x` flag word. * * A candidate that is one path but not a linkable one (an unknown root such as * `/etc`, a `.`/`..`/empty segment) comes back with `link: false` when its last @@ -2274,6 +2277,9 @@ function spacedFilePathOnLine(line) { // A rooted candidate must have a directory before its first space: `/usr/bin/env node a.js` // or `/review the changes in src/a.ts` is a command, not a spaced path. if (path.startsWith('/') && !/^\/[^/]+\//.test(words[0])) return null; + // A command whose executable sits under a known root (`/opt/homebrew/bin/node a.js`, + // `…/node_modules/.bin/tsc -p tsconfig.json`) is not a path either. + if (/\/\.?bin\/[^/]+$/.test(words[0]) || words.some((word) => /^-{1,2}\w/.test(word))) return null; for (const word of words.slice(0, -1)) { if (word.endsWith('/') || word.endsWith(':') || /^(?:—|–|→|=>|->|<-)$/.test(word)) return null; // `a.ts:42` (grep -n, compiler output): the path already ended at the line suffix. @@ -2284,6 +2290,11 @@ function spacedFilePathOnLine(line) { const rooted = SPACED_PATH_ROOT.test(path) || path.startsWith('~/'); const segments = path.replace(/^(?:~?\/|\.\/)/, '').split('/'); const link = (rooted || !path.startsWith('/')) && segments.every((seg) => seg && seg !== '.' && seg !== '..'); + // A path label vouches that a path follows, not for the words before it: when + // only the last word holds a `/` (`Wrote: 120 lines to src/foo.ts`), the spaces + // are prose, so a relative candidate is left to the ordinary detectors. + const firstSlash = words.findIndex((word) => word.includes('/')); + if (link && firstSlash === words.length - 1 && !path.startsWith('/')) return null; // Reserve an unlinkable span only when it still reads as a path to its end; // otherwise leave the line to the ordinary detectors. if (!link && !words[words.length - 1].includes('/')) return null; diff --git a/test/response-viewer-file-links.test.ts b/test/response-viewer-file-links.test.ts index 36f454d2e..8d5e5cc12 100644 --- a/test/response-viewer-file-links.test.ts +++ b/test/response-viewer-file-links.test.ts @@ -109,6 +109,14 @@ describe('response viewer file-path linkifier', () => { expect(root.querySelector('strong')?.textContent).toBe(original.querySelector('strong')?.textContent); }); + it.each([ + ['**Wrote:** 120 lines to src/foo.ts', ['src/foo.ts']], + ['- **Output:** stored in dist/app.js', ['dist/app.js']], + ['**Created:** new file src/components/Button.tsx', ['src/components/Button.tsx']], + ])('keeps prose after a bold path label out of the link: %s', (markdown, expected) => { + expect(paths(linkify(renderChat(markdown))).map((a) => a.getAttribute('data-path'))).toEqual(expected); + }); + it('does not inherit a bold label from earlier prose or another block', () => { for (const markdown of [ 'See **Saved to:** 03 Attachments/Build SIPOC Design/Build-SIPOC.pptx', diff --git a/test/spaced-file-path-links.test.ts b/test/spaced-file-path-links.test.ts index a5aca00d6..479926938 100644 --- a/test/spaced-file-path-links.test.ts +++ b/test/spaced-file-path-links.test.ts @@ -121,7 +121,10 @@ describe('spaced file paths', () => { it('requires a path label for relative candidates but retains absolute ambiguity', () => { expect(paths('- Fixed bug in src/My Docs/a.md')).toEqual(['Docs/a.md']); - expect(paths('Saved to: 03 Attachments/plan.pptx')).toEqual(['03 Attachments/plan.pptx']); + // Documented trade-off: a labelled relative path whose only space is in its top + // directory reads like prose after the label, so it splits as on master. + expect(paths('Saved to: 03 Attachments/plan.pptx')).toEqual(['Attachments/plan.pptx']); + expect(paths('Saved to: My Docs/plan.md')).toEqual(['Docs/plan.md']); expect(paths('/home/me/proj contains a.md')).toEqual(['/home/me/proj contains a.md']); expect(paths('Saved to: ~/My Docs/plan.md')).toEqual(['~/My Docs/plan.md']); }); @@ -211,6 +214,61 @@ describe('spaced file paths: shapes that keep master links', () => { expect(findFilePathLinks(line)).toEqual(masterLinks(line, [file])); }); + // A path label vouches that a path follows, not for the prose before it. + it.each([ + ['Wrote: 120 lines to src/foo.ts', 'src/foo.ts'], + ['Created: new file src/components/Button.tsx', 'src/components/Button.tsx'], + ['Output: written to logs/run.log', 'logs/run.log'], + ['Files: 3 changed in src/foo.ts', 'src/foo.ts'], + ['Location: line 42 in src/foo.ts', 'src/foo.ts'], + ['Path: see src/foo.ts', 'src/foo.ts'], + ['- Output: stored in dist/app.js', 'dist/app.js'], + ['1. File: changes in src/a.ts', 'src/a.ts'], + ['Created: 2026-10-24 build/x.json', 'build/x.json'], + ])('keeps label prose out of the path: %s', (line, file) => { + expect(findFilePathLinks(line)).toEqual(masterLinks(line, [file])); + }); + + it('keeps labelled spaced paths whole when a `/` word comes before the last', () => { + for (const p of [ + '03 Attachments/Build SIPOC Design/Build-SIPOC.pptx', + '~/My Docs/plan.md', + './My Docs/plan.md', + 'notes/a b/c.md', + '/mnt/c/My Docs/a.docx', + ]) { + expect(paths(`Saved to: ${p}`)).toEqual([p]); + } + }); + + it('documented trade-off: prose between two `/` words after a path label stays in the link', () => { + // The first `/` word is not the last, so the prose guard does not fire; master + // linked only `utils/x.ts` here. + expect(paths('Files: src/components and utils/x.ts')).toEqual(['src/components and utils/x.ts']); + }); + + // A command whose executable lives under a known root, or one carrying a flag. + it.each([ + ['/opt/homebrew/bin/node server.js', '/opt/homebrew/bin/node', 'server.js'], + ['/home/me/.venv/bin/python run.py', '/home/me/.venv/bin/python', 'run.py'], + [ + '/home/me/proj/node_modules/.bin/vitest run test/foo.test.ts', + '/home/me/proj/node_modules/.bin/vitest', + 'test/foo.test.ts', + ], + ['/home/me/proj/node_modules/.bin/tsc -p tsconfig.json', '/home/me/proj/node_modules/.bin/tsc', 'tsconfig.json'], + ])('does not reserve a command under a known root: %s', (line, exe, file) => { + expect(findFilePathLinks(line)).toEqual(masterLinks(line, [exe, true], [file])); + }); + + it.each([ + '/Users/me/Desktop/Screenshot 2026-10-24 at 10.15.32 AM.png', + '/mnt/c/Users/me/Docs - Copy/a.docx', + '- /mnt/c/Users/me/Docs - Copy/a.docx', + ])('keeps a spaced path whole when a word only looks like a flag or a version: %s', (line) => { + expect(paths(line)).toEqual([line.replace(/^- /, '')]); + }); + it('still refuses an unlinkable spaced path that reads as one path to its end', () => { expect(paths('/etc/secret files/passwords.txt')).toEqual([]); }); From a24b0ec7bbf9c832f7522c2e9096fa7f97943131 Mon Sep 17 00:00:00 2001 From: Aamer Akhter Date: Fri, 23 Oct 2026 14:15:14 -0400 Subject: [PATCH 18/68] feat(agent): add bounded inspection and guarded submission --- .changeset/bounded-agent-coordination.md | 7 + README.md | 5 +- docs/api-reference.md | 68 +++- docs/wiki/Driving-Codeman-From-An-Agent.md | 5 + plugins/codeman/skills/codeman/SKILL.md | 6 +- .../reference/inspection-and-staging.md | 84 +++++ .../codeman/skills/codeman/reference/verbs.md | 9 + skills/codeman/SKILL.md | 6 +- .../reference/inspection-and-staging.md | 84 +++++ skills/codeman/reference/verbs.md | 9 + src/agent-codex-identity.ts | 148 ++++++++ src/agent-inspection.ts | 191 ++++++++++ src/agent-staging.ts | 183 ++++++++++ src/cli-agent.ts | 138 ++++++++ src/mux-interface.ts | 3 +- src/session.ts | 160 ++++++++- src/terminal-input-classifier.ts | 62 ++++ src/tmux-manager.ts | 8 +- src/web/routes/agent-session-routes.ts | 148 ++++++++ src/web/routes/session-routes.ts | 28 +- src/web/schemas.ts | 12 + test/agent-codex-identity.test.ts | 119 +++++++ test/agent-inspection.test.ts | 161 +++++++++ test/agent-staging-session.test.ts | 329 ++++++++++++++++++ test/agent-staging.test.ts | 153 ++++++++ test/cli-agent-inspection.test.ts | 94 +++++ test/mocks/mock-session.ts | 4 + test/routes/agent-session-routes.test.ts | 220 ++++++++++++ test/terminal-input-classifier.test.ts | 37 ++ test/tmux-submit-paste.test.ts | 15 + 30 files changed, 2461 insertions(+), 35 deletions(-) create mode 100644 .changeset/bounded-agent-coordination.md create mode 100644 plugins/codeman/skills/codeman/reference/inspection-and-staging.md create mode 100644 skills/codeman/reference/inspection-and-staging.md create mode 100644 src/agent-codex-identity.ts create mode 100644 src/agent-inspection.ts create mode 100644 src/agent-staging.ts create mode 100644 src/terminal-input-classifier.ts create mode 100644 src/web/routes/agent-session-routes.ts create mode 100644 test/agent-codex-identity.test.ts create mode 100644 test/agent-inspection.test.ts create mode 100644 test/agent-staging-session.test.ts create mode 100644 test/agent-staging.test.ts create mode 100644 test/cli-agent-inspection.test.ts create mode 100644 test/routes/agent-session-routes.test.ts create mode 100644 test/terminal-input-classifier.test.ts diff --git a/.changeset/bounded-agent-coordination.md b/.changeset/bounded-agent-coordination.md new file mode 100644 index 000000000..1122ed67c --- /dev/null +++ b/.changeset/bounded-agent-coordination.md @@ -0,0 +1,7 @@ +--- +"aicodeman": minor +--- + +Add bounded agent inspection with expiring scoped cursors and explicit pane snapshots, plus staged local Codex input and single-use hash-confirmed submission receipts. Verify the exact composer, caret, lifecycle and input revision before sending Enter, and serialize dispatch against managed input and auxiliary key operations. + +Document the initial local Codex support and conversation-attribution limits in the API, CLI and packaged skill. Other modes retain labeled snapshots or refuse unverifiable guarded input. diff --git a/README.md b/README.md index bb3c142c2..1850841ab 100644 --- a/README.md +++ b/README.md @@ -977,7 +977,7 @@ codeman tui 3 # attach to session 3 of that list ### `codeman agent` — session-to-session verbs in every CLI mode -The skill above drives the API with `curl` and `jq` from a preamble Codeman seeds per session. Where it does not reach (a `pi` or `shell` session, a machine without `jq`), the same environment is still there (`CODEMAN_MUX=1`, `CODEMAN_SESSION_ID`, `CODEMAN_API_URL` are exported into every pane), so `codeman agent` packages the same verbs as commands. It is a thin client over the endpoints listed under [API](#api): no new route, no new transport, auth and ownership unchanged. One line in a case's `AGENTS.md` is enough: *"other sessions: `codeman agent --help`"*. +The skill above drives the API with `curl` and `jq` from a preamble Codeman seeds per session. Where it does not reach (a `pi` or `shell` session, a machine without `jq`), the same environment is still there (`CODEMAN_MUX=1`, `CODEMAN_SESSION_ID`, `CODEMAN_API_URL` are exported into every pane), so `codeman agent` packages the same verbs as commands. It is a thin client over the endpoints listed under [API](#api): auth and ownership apply to every endpoint, including bounded inspection and guarded input. One line in a case's `AGENTS.md` is enough: *"other sessions: `codeman agent --help`"*. ```bash codeman agent ls # sessions; * marks this one @@ -985,6 +985,9 @@ SID=$(codeman agent spawn scratch-1 --mode claude) # quick-start + wait f codeman agent send "$SID" 'review src/, then say DONE' --until stop,exit --timeout 300000 # --wait = default signal set codeman agent read "$SID" # last answer (as the server reads it for that mode) codeman agent read "$SID" --tail 3000 # terminal tail, ANSI stripped (every mode) +codeman agent inspect "$SID" --max-bytes 8192 --json # recent evidence + opaque cursor (pane snapshots are labeled) +codeman agent stage "$SID" --message-file /tmp/prompt.txt --json # no Enter; known local Codex composer only +codeman agent submit "$SID" --stage-id "$STAGE_ID" --confirm-sha "$SHA256" --json # one conditional Enter codeman agent send "$SID" 'run the tests, then print WORKDONE followed by _4711' # hook-less modes (opencode, pi, …): ask for the marker in halves … codeman agent wait "$SID" --match WORKDONE_4711 # … and wait on the joined form, which the prompt's echo never contains codeman agent interrupt "$SID" # a bare ESC, conversation intact diff --git a/docs/api-reference.md b/docs/api-reference.md index 95a28b178..dd538bd3c 100644 --- a/docs/api-reference.md +++ b/docs/api-reference.md @@ -589,9 +589,75 @@ request returns. The capture runs synchronous tmux calls on the server; the | `lines=` | With `full=1` only: read at most `` lines of tmux history above the visible frame. An integer of at least 1, clamped to the configured history limit; absent or malformed, the whole limit (100,000 lines by default), as before. When a bound below the configured limit left older lines in tmux (its `history_size` is deeper), `truncated` is true with `truncationReason: 'tail'`; a byte cap still reports `'capped'`. Without it a full capture reads all of that history before `tail` cuts it, so a client that keeps a fixed number of lines (the tile grid sends its xterm's scrollback plus its rows) should send it. | | `alt=recording` | With `full=1` only: if the pane is in the alternate screen (a nested tmux, where tmux keeps no history, or any full-screen app such as vim or less while it is open), answer with the visible frame over the byte recording instead (`source: 'mux-visible'`, `tail` applied as without `full=1`). Shells' automatic loads send it. | +## Bounded agent inspection and guarded input + +These authenticated routes use the standard envelope and session ownership checks. +`/api/v1` aliases `/api`; an inaccessible session returns `404 NOT_FOUND` before +any transcript, pane or receipt lookup. + +| Endpoint | Parameters/body | Result | +| --- | --- | --- | +| `GET /api/v1/sessions/:id/inspect` | `maxBytes=8192` (integer 1–65536), optional opaque `since` UUID | `source`, `identity`, observed `activity`, bounded UTF8 `evidence`, `truncated`, `incremental`, `cursor`, `maxBytes`; native results also include `omittedBeforeBytes`, `scannedBytes`, optional `blockedReason` | +| `POST /api/v1/sessions/:id/stage` | `{ "message": "printable single line" }` | `{ stageId, sha256, expiresAt, sessionId, submitted:false }`; types without Enter | +| `POST /api/v1/sessions/:id/submit` | `{ "stageId": "UUID", "confirmSha": "64 lowercase hex characters" }` | `{ stageId, sessionId, submitted:true }`; sends one conditional Enter | + +Inspection initially supports native local Codex JSONL with strict conversation +attribution, using the session's effective `CODEX_HOME`, explicit resume identity +or unique managed originator. It never selects an arbitrary newest cwd match. +The initial read selects recent complete records; `omittedBeforeBytes` explicitly +labels its tail omission. Cursor reads consume only complete appended records, +keeping UTF8/JSONL boundaries. Evidence bytes exclude response metadata and JSON +escaping. Record scans cap at 256 KiB plus bounded identity anchors; attribution +separately caps at 8192 directory entries, eight directory levels and 1 MiB of +metadata reads. Oversized/unfinished records report `blockedReason`; incremental +reads do not silently skip them. Other modes, remote/container sessions, and +unprovable native attribution return `source:"pane-snapshot"`, `incremental:false`, +`cursor:null`; these snapshots can repeat evidence. + +Attribution identifies a file, not an authoritative current-thread signal. A +configured launch resume and a new owned rollout conflict and force fallback. +An in-CLI `/resume` of an existing foreign-originator thread may leave no new +owned rollout; launch metadata cannot detect that switch. Check the returned +conversation identity and do not use these observations as proof of the active +thread, task completion or authorization. + +Opaque server-side cursors bind caller, session, pane generation, conversation, +opened-file identity, byte offset and bounded content anchors. At most 1024 live +cursors are retained for 15 minutes; restart, eviction or expiry requires a fresh +tail baseline. Replacement, truncation, detected rewrites, reassociation, foreign +cursors or loss of the attributed source return `409 CONFLICT`. No transcript +filesystem path is returned as cursor/identity metadata; evidence retains the +owner's native record content, which can itself contain paths. + +Guarded input initially requires a local characterized Codex composer, one +unwrapped visible line, a recognized footer and a cursor at its exact end. +Unknown/working/modal/shell/remote/container composers fail closed. Stage accepts +at most 8192 UTF8 bytes, no C0/C1/DEL controls, multiline text, or trailing +whitespace (the existing mux transport trims it). Its two-minute receipt stores +only hashes/ephemeral identities in a bounded in-memory store (1024 live receipts), +never persisted prompt content. A receipt acknowledges typing; a delayed screen +paint can still make submit refuse until exact verification becomes possible. + +Direct auxiliary key operations participate in the same pending-write and input +lease checks. Complete allowlisted focus/mouse reports and empty writes preserve +input revision; mixed or unknown sequences remain keyboard-bearing. Exact +composer/caret checks also reject a mouse-moved caret. + +Submit consumes an owned receipt before awaited delivery, verifies the caller, +confirmation hash, expiry, lifecycle/input revisions and exact composer/caret, +then uses a session-level conditional Enter dispatch with a temporary input lease. +Competing Codeman writes cannot enter during that dispatch. Managed restarts and +intervening browser/API/auxiliary input invalidate receipts independently of pid +reuse or restored visible text. Direct tmux edits remain outside Codeman's +serialization. Failed, ambiguous, expired, changed or repeated submits return +`409 CONFLICT`; every owned attempt consumes the receipt. Neither submit nor its +error recovery automatically replays Enter. Success acknowledges delivery of one +Enter, not native acceptance or task completion. Invalid schema input returns +`400 INVALID_INPUT`. + ## The `codeman agent` CLI (client over these endpoints) -`codeman agent ls|spawn|send|wait|read|interrupt|rm|post|inbox|ack` (`src/cli-agent.ts`) is the command-line client for the endpoints above, for agents the skill does not reach (a pi or shell session, a machine without `jq`). It adds no route: `spawn` is `POST /api/v1/quick-start` (+ `wait-output` on the mode's `capabilities.composerReadyMark` from the CLI registry, where it declares one), `send` is `POST …/input` with `clientId`+`seq` (and `wait`/`waitTimeout` for `--wait` / `--until `; `delivered:false` without `duplicate` and `wait.ended` both exit 3 — the CLI never reports a dead worker as done), `wait` is `GET …/wait` (`--until`) or `GET …/wait-output` (`--match`, `from=buffer` by default), `read` is `GET …/last-response` or `GET …/terminal?tail=`, `interrupt` is `POST …/input` with a bare `\u001b`, `rm` is `DELETE …/sessions/:id`, and the inbox verbs map one to one onto the [agent inbox](#agent-inbox) routes (`post` → `POST …/inbox`, `inbox [id] [--wait]` → `GET …/inbox[?wait=]`, own inbox by default, `ack ` → `POST …/inbox/ack`); `ls` adds an `INBOX` column from `GET /api/v1/agent-inbox/summary` and drops it when that route does not answer. A fire-and-forget `send` to a sleeping wake-on-LAN host reads the route's `buffered` (own line, exit 0) and `dropped` (exit 1: the chunk is gone). An id may be the 8-character form `ls` prints, resolved through `GET /api/v1/sessions`; anything shorter refuses before any request, the same floor as `PARENT_SESSION_ID_MIN_PREFIX`. Every call carries `X-Codeman-Parent-Session`; only `spawn`'s quick-start carries `X-Codeman-Agent-Origin: codeman-agent-cli` (the agent-scratch label must never reach a request that cannot create the case directory). Basic auth comes from `CODEMAN_PASSWORD` or the data dir's `.env`. Server-side error codes are shown verbatim (`INVALID_INPUT: until=stop …` on a hook-less mode is not hidden); exit codes are `0` ok, `1` error, `2` timeout, `3` the session exited, `4` refused by a client-side guard (for `inbox --wait`, `3` only when `GET /api/v1/sessions` no longer lists the session; a wait the server ended otherwise, on a restart or a detach, is `2`). `post` takes text that starts with `-` as it is (a markdown file often does); text that is exactly an option (`--json`, `-h`) goes after `--`. See the README section "`codeman agent`" for the guards and `test/cli-agent.test.ts` for the pinned behaviour. +`codeman agent ls|spawn|send|wait|read|inspect|stage|submit|interrupt|rm|post|inbox|ack` (`src/cli-agent.ts`) is the command-line client for the endpoints above, for agents the skill does not reach (a pi or shell session, a machine without `jq`). It uses the guarded endpoints above for `inspect`/`stage`/`submit`; `spawn` is `POST /api/v1/quick-start` (+ `wait-output` on the mode's `capabilities.composerReadyMark` from the CLI registry, where it declares one), `send` is `POST …/input` with `clientId`+`seq` (and `wait`/`waitTimeout` for `--wait` / `--until `; `delivered:false` without `duplicate` and `wait.ended` both exit 3 — the CLI never reports a dead worker as done), `wait` is `GET …/wait` (`--until`) or `GET …/wait-output` (`--match`, `from=buffer` by default), `read` is `GET …/last-response` or `GET …/terminal?tail=`, `interrupt` is `POST …/input` with a bare `\u001b`, `rm` is `DELETE …/sessions/:id`, and the inbox verbs map one to one onto the [agent inbox](#agent-inbox) routes (`post` → `POST …/inbox`, `inbox [id] [--wait]` → `GET …/inbox[?wait=]`, own inbox by default, `ack ` → `POST …/inbox/ack`); `ls` adds an `INBOX` column from `GET /api/v1/agent-inbox/summary` and drops it when that route does not answer. A fire-and-forget `send` to a sleeping wake-on-LAN host reads the route's `buffered` (own line, exit 0) and `dropped` (exit 1: the chunk is gone). An id may be the 8-character form `ls` prints, resolved through `GET /api/v1/sessions`; anything shorter refuses before any request, the same floor as `PARENT_SESSION_ID_MIN_PREFIX`. Every call carries `X-Codeman-Parent-Session`; only `spawn`'s quick-start carries `X-Codeman-Agent-Origin: codeman-agent-cli` (the agent-scratch label must never reach a request that cannot create the case directory). Basic auth comes from `CODEMAN_PASSWORD` or the data dir's `.env`. Server-side error codes are shown verbatim (`INVALID_INPUT: until=stop …` on a hook-less mode is not hidden); exit codes are `0` ok, `1` error, `2` timeout, `3` the session exited, `4` refused by a client-side guard (for `inbox --wait`, `3` only when `GET /api/v1/sessions` no longer lists the session; a wait the server ended otherwise, on a restart or a detach, is `2`). `post` takes text that starts with `-` as it is (a markdown file often does); text that is exactly an option (`--json`, `-h`) goes after `--`. See the README section "`codeman agent`" for the guards and `test/cli-agent.test.ts` for the pinned behaviour. `codeman mcp` (`src/mcp-server.ts`) is a Model Context Protocol server on stdio over the same endpoints, for MCP clients: `list_sessions` (`GET /api/v1/sessions`), `start_session` (`POST /api/v1/quick-start` with `X-Codeman-Agent-Origin: codeman-mcp`, then `wait-output` on the mode's ready mark), `send_prompt` (`POST …/input` with `wait`/`waitTimeout`), `wait_for_session` (`GET …/wait` or `…/wait-output`), `read_output` (`GET …/last-response` or `…/terminal?tail=`), `interrupt_session` and `delete_session`. It adds no route. The URL comes from `CODEMAN_API_URL` or `--url`, never a guess; prompting, interrupting and deleting are limited to sessions the process started (or the calling session's own children) unless it runs with `--allow-any-session`. See the README section "MCP server" and `test/mcp-server.test.ts`. diff --git a/docs/wiki/Driving-Codeman-From-An-Agent.md b/docs/wiki/Driving-Codeman-From-An-Agent.md index 6b2b337f1..776eddf14 100644 --- a/docs/wiki/Driving-Codeman-From-An-Agent.md +++ b/docs/wiki/Driving-Codeman-From-An-Agent.md @@ -44,6 +44,9 @@ SID=$(codeman agent spawn scratch-1 --mode claude) # quick-start + wait f codeman agent send "$SID" 'review src/, then say DONE' --until stop,exit --timeout 300000 codeman agent read "$SID" # last answer (as the server reads it for that mode) codeman agent read "$SID" --tail 3000 # terminal tail, ANSI stripped (every mode) +codeman agent inspect "$SID" --max-bytes 8192 --json # recent evidence + opaque cursor (pane snapshots are labeled) +codeman agent stage "$SID" --message-file /tmp/prompt.txt --json # no Enter; known local Codex composer only +codeman agent submit "$SID" --stage-id "$STAGE_ID" --confirm-sha "$SHA256" --json # one conditional Enter codeman agent send "$SID" 'run the tests, then print WORKDONE followed by _4711' # hook-less modes: the marker in halves … codeman agent wait "$SID" --match WORKDONE_4711 # … and the wait on the joined form codeman agent interrupt "$SID" # a bare ESC, conversation intact @@ -264,3 +267,5 @@ failing the spawn, because a cosmetic field must never break a worker. - [Hooks And Integrations](Hooks-And-Integrations) - events flowing the other way. - [Watching Agents Work](Watching-Agents-Work) - seeing the fan-out in the UI. - [`skills/codeman/SKILL.md`](https://github.com/Ark0N/Codeman/blob/master/skills/codeman/SKILL.md) - the skill itself. + +Bounded inspection and guarded stage/submit details: [API reference](../api-reference.md#bounded-agent-inspection-and-guarded-input). Stage accepts one printable UTF8 line without trailing whitespace; receipts expire after two minutes. Submit refuses changed/unprovable composers and never automatically replays Enter. `send --no-enter` alone has no receipt. diff --git a/plugins/codeman/skills/codeman/SKILL.md b/plugins/codeman/skills/codeman/SKILL.md index 50133e8c9..ce34728a0 100644 --- a/plugins/codeman/skills/codeman/SKILL.md +++ b/plugins/codeman/skills/codeman/SKILL.md @@ -30,13 +30,15 @@ through: the verbs in detail (the old §5) in [reference/verbs.md](reference/ver worked multi-worker flows in [reference/recipes.md](reference/recipes.md), endpoint tables and a symptom gallery in [reference/endpoints.md](reference/endpoints.md), and direct messaging to claude workers in [reference/messaging.md](reference/messaging.md). +Bounded inspection and guarded stage/submit load only when needed from +[reference/inspection-and-staging.md](reference/inspection-and-staging.md). This skill works the same whichever CLI you are: Codeman puts it where each CLI looks for skills (`.claude/skills/` for Claude Code, `.agents/skills/` for Codex, Gemini CLI, OpenCode, Antigravity, Grok, DeepSeek and OMP). A **pi** or **shell** worker never gets it, but has the same environment: tell it *"other sessions: `codeman agent --help`"*. -That bundled CLI (`ls`, `spawn`, `send`, `wait`, `read`, `interrupt`, `rm`, and the inbox -verbs `post`, `inbox`, `ack`) is the same +That bundled CLI (`ls`, `spawn`, `send`, `wait`, `read`, `inspect`, `stage`, `submit`, +`interrupt`, `rm`, and the inbox verbs `post`, `inbox`, `ack`) is the same verbs over the same endpoints, with the guards below (no control bytes, no self-delete, no guessed URL) enforced in code, and it is also your own way through when this machine has no `jq` for the preamble below. diff --git a/plugins/codeman/skills/codeman/reference/inspection-and-staging.md b/plugins/codeman/skills/codeman/reference/inspection-and-staging.md new file mode 100644 index 000000000..394f6be41 --- /dev/null +++ b/plugins/codeman/skills/codeman/reference/inspection-and-staging.md @@ -0,0 +1,84 @@ +# Bounded inspection and guarded submission + +Use these commands inside a Codeman session. They use its authenticated HTTP API; +no direct tmux access, second transport, or session-bridge installation is required. +They supplement `send`, `wait` and `read`. They do not establish full parity with +session-bridge or retire that skill. + +## Inspect recent evidence + +```bash +codeman agent inspect "$SID" --max-bytes 8192 --json +codeman agent inspect "$SID" --max-bytes 8192 --since "$CURSOR" --json +``` + +Read `source`, `identity`, `activity`, `evidence`, `truncated` and `cursor` before +interpreting the result. `maxBytes` bounds UTF8 evidence bytes, excluding the JSON +metadata/envelope. The default is 8192, maximum 65536. + +- `codex-jsonl`: strictly attributed local Codex JSONL records. The first call + selects recent complete records within a bounded tail window; + `omittedBeforeBytes` labels older bytes deliberately omitted. A cursor continues + after the last consumed complete record. Partial final records remain pending. +- `pane-snapshot`: UTF8-safe tail of a visible frame or the session's buffered + output. It has `incremental:false` and `cursor:null`. This is the fallback for + other modes, remote/container sessions, ambiguous attribution, and exhausted + attribution budgets. Repeating a snapshot can repeat evidence. + +Attribution proves which file supplied the evidence, not which thread is now +active in the TUI. A launch resume that conflicts with a newly owned rollout +falls back to a snapshot. An in-CLI `/resume` of an existing foreign-originator +thread may leave launch metadata unchanged. Check `identity.conversationId`; +these reads do not provide authoritative current-thread tracking. + +Cursors are opaque, scoped to the requesting identity, session, pane generation +and attributed transcript, expire after 15 minutes, and disappear on server +restart. At most 1024 are retained; capacity eviction can invalidate an older +cursor. A replaced/truncated/rewritten source, reassociated session, foreign cursor +or unavailable native source returns `409 CONFLICT`. Inspect again without +`--since` to establish a new tail baseline; this may omit older evidence. + +`blockedReason` explains an unfinished or oversized record. Incremental reads +never split or silently skip one. Increase the byte budget for +`record-exceeds-output-budget`; when the record exceeds the maximum or scan cap, +use `read --tail` for a labeled terminal view or start a fresh tail baseline. +Inspect scans at most 256 KiB of record data plus bounded generation anchors. +Attribution separately caps directory entries and metadata bytes. Evidence and +activity are observations, not proof of task completion or authorization. + +## Stage, inspect, then submit once + +```bash +printf '%s' 'Review src/ and report the failing tests' > /tmp/codeman-message.txt +codeman agent stage "$SID" --message-file /tmp/codeman-message.txt --json +# Review the returned stageId, sha256, expiresAt; inspect the target if needed. +codeman agent submit "$SID" --stage-id "$STAGE_ID" --confirm-sha "$SHA256" --json +``` + +Stage types the file contents **without Enter**. The file must be regular, UTF8, +one printable line, at most 8192 bytes, with no control characters or trailing +whitespace/newline. It initially supports a local Codex composer with a recognized +footer and an exact, unwrapped line/caret capture. Unknown, working, modal, +wrapped, remote/container and shell composers are refused. A customized footer or +unsupported character-width rendering can also make verification unavailable. +Self-targets are refused by the CLI. + +The receipt stores a hash and ephemeral identities, never prompt content. It +expires after two minutes; capacity is 1024 live receipts. Stage delivery can +precede the next screen paint: a receipt acknowledges typing, while submit still +requires the fully painted exact composer. If typing fails or is ambiguous, stage +issues no receipt; inspect the pane before trying again. + +Submit checks the caller, session, hash, expiry, pane lifecycle, input revision, +exact composer and caret, then sends one conditional Enter at the session's input +boundary. Competing Codeman writes are refused during dispatch. Browser/API/key +input and managed restarts invalidate receipts even when the visible text or pid +is restored. Direct tmux edits are outside that input serialization; captures +remain evidence at the time observed, rather than terminal isolation. + +Every owned submit attempt consumes its receipt, including hash mismatch, +verification failure and delivery failure. Concurrent/repeated submits cannot +replay Enter, and there is no autonomous retry. A successful response means one +Enter was delivered; use inspection or a suitable wait signal to observe native +acceptance. A `409` or broken connection means inspect first. Do not automatically +stage/submit again or send an unguarded Enter to recover. diff --git a/plugins/codeman/skills/codeman/reference/verbs.md b/plugins/codeman/skills/codeman/reference/verbs.md index 65af5c65f..3f511649f 100644 --- a/plugins/codeman/skills/codeman/reference/verbs.md +++ b/plugins/codeman/skills/codeman/reference/verbs.md @@ -811,3 +811,12 @@ Rules that matter: not parked, `send` it one line ("read your inbox: `codeman agent inbox`"). - **Lifetime.** An inbox goes with its session on delete. A server restart empties all inboxes unless the user turned on *Keep agent inbox across restarts* in Settings. + +## Bounded inspection and guarded submission + +Use `codeman agent inspect` for bounded evidence and incremental local Codex cursors. +Use `codeman agent stage --message-file` followed by one hash-confirmed +`codeman agent submit --stage-id --confirm-sha` when the exact local Codex +composer and caret can be verified. Other targets provide labeled snapshots or +refuse guarded input. See [inspection and staging](inspection-and-staging.md) +for budgets, receipt expiry, conflict handling and supported composers. diff --git a/skills/codeman/SKILL.md b/skills/codeman/SKILL.md index 50133e8c9..ce34728a0 100644 --- a/skills/codeman/SKILL.md +++ b/skills/codeman/SKILL.md @@ -30,13 +30,15 @@ through: the verbs in detail (the old §5) in [reference/verbs.md](reference/ver worked multi-worker flows in [reference/recipes.md](reference/recipes.md), endpoint tables and a symptom gallery in [reference/endpoints.md](reference/endpoints.md), and direct messaging to claude workers in [reference/messaging.md](reference/messaging.md). +Bounded inspection and guarded stage/submit load only when needed from +[reference/inspection-and-staging.md](reference/inspection-and-staging.md). This skill works the same whichever CLI you are: Codeman puts it where each CLI looks for skills (`.claude/skills/` for Claude Code, `.agents/skills/` for Codex, Gemini CLI, OpenCode, Antigravity, Grok, DeepSeek and OMP). A **pi** or **shell** worker never gets it, but has the same environment: tell it *"other sessions: `codeman agent --help`"*. -That bundled CLI (`ls`, `spawn`, `send`, `wait`, `read`, `interrupt`, `rm`, and the inbox -verbs `post`, `inbox`, `ack`) is the same +That bundled CLI (`ls`, `spawn`, `send`, `wait`, `read`, `inspect`, `stage`, `submit`, +`interrupt`, `rm`, and the inbox verbs `post`, `inbox`, `ack`) is the same verbs over the same endpoints, with the guards below (no control bytes, no self-delete, no guessed URL) enforced in code, and it is also your own way through when this machine has no `jq` for the preamble below. diff --git a/skills/codeman/reference/inspection-and-staging.md b/skills/codeman/reference/inspection-and-staging.md new file mode 100644 index 000000000..394f6be41 --- /dev/null +++ b/skills/codeman/reference/inspection-and-staging.md @@ -0,0 +1,84 @@ +# Bounded inspection and guarded submission + +Use these commands inside a Codeman session. They use its authenticated HTTP API; +no direct tmux access, second transport, or session-bridge installation is required. +They supplement `send`, `wait` and `read`. They do not establish full parity with +session-bridge or retire that skill. + +## Inspect recent evidence + +```bash +codeman agent inspect "$SID" --max-bytes 8192 --json +codeman agent inspect "$SID" --max-bytes 8192 --since "$CURSOR" --json +``` + +Read `source`, `identity`, `activity`, `evidence`, `truncated` and `cursor` before +interpreting the result. `maxBytes` bounds UTF8 evidence bytes, excluding the JSON +metadata/envelope. The default is 8192, maximum 65536. + +- `codex-jsonl`: strictly attributed local Codex JSONL records. The first call + selects recent complete records within a bounded tail window; + `omittedBeforeBytes` labels older bytes deliberately omitted. A cursor continues + after the last consumed complete record. Partial final records remain pending. +- `pane-snapshot`: UTF8-safe tail of a visible frame or the session's buffered + output. It has `incremental:false` and `cursor:null`. This is the fallback for + other modes, remote/container sessions, ambiguous attribution, and exhausted + attribution budgets. Repeating a snapshot can repeat evidence. + +Attribution proves which file supplied the evidence, not which thread is now +active in the TUI. A launch resume that conflicts with a newly owned rollout +falls back to a snapshot. An in-CLI `/resume` of an existing foreign-originator +thread may leave launch metadata unchanged. Check `identity.conversationId`; +these reads do not provide authoritative current-thread tracking. + +Cursors are opaque, scoped to the requesting identity, session, pane generation +and attributed transcript, expire after 15 minutes, and disappear on server +restart. At most 1024 are retained; capacity eviction can invalidate an older +cursor. A replaced/truncated/rewritten source, reassociated session, foreign cursor +or unavailable native source returns `409 CONFLICT`. Inspect again without +`--since` to establish a new tail baseline; this may omit older evidence. + +`blockedReason` explains an unfinished or oversized record. Incremental reads +never split or silently skip one. Increase the byte budget for +`record-exceeds-output-budget`; when the record exceeds the maximum or scan cap, +use `read --tail` for a labeled terminal view or start a fresh tail baseline. +Inspect scans at most 256 KiB of record data plus bounded generation anchors. +Attribution separately caps directory entries and metadata bytes. Evidence and +activity are observations, not proof of task completion or authorization. + +## Stage, inspect, then submit once + +```bash +printf '%s' 'Review src/ and report the failing tests' > /tmp/codeman-message.txt +codeman agent stage "$SID" --message-file /tmp/codeman-message.txt --json +# Review the returned stageId, sha256, expiresAt; inspect the target if needed. +codeman agent submit "$SID" --stage-id "$STAGE_ID" --confirm-sha "$SHA256" --json +``` + +Stage types the file contents **without Enter**. The file must be regular, UTF8, +one printable line, at most 8192 bytes, with no control characters or trailing +whitespace/newline. It initially supports a local Codex composer with a recognized +footer and an exact, unwrapped line/caret capture. Unknown, working, modal, +wrapped, remote/container and shell composers are refused. A customized footer or +unsupported character-width rendering can also make verification unavailable. +Self-targets are refused by the CLI. + +The receipt stores a hash and ephemeral identities, never prompt content. It +expires after two minutes; capacity is 1024 live receipts. Stage delivery can +precede the next screen paint: a receipt acknowledges typing, while submit still +requires the fully painted exact composer. If typing fails or is ambiguous, stage +issues no receipt; inspect the pane before trying again. + +Submit checks the caller, session, hash, expiry, pane lifecycle, input revision, +exact composer and caret, then sends one conditional Enter at the session's input +boundary. Competing Codeman writes are refused during dispatch. Browser/API/key +input and managed restarts invalidate receipts even when the visible text or pid +is restored. Direct tmux edits are outside that input serialization; captures +remain evidence at the time observed, rather than terminal isolation. + +Every owned submit attempt consumes its receipt, including hash mismatch, +verification failure and delivery failure. Concurrent/repeated submits cannot +replay Enter, and there is no autonomous retry. A successful response means one +Enter was delivered; use inspection or a suitable wait signal to observe native +acceptance. A `409` or broken connection means inspect first. Do not automatically +stage/submit again or send an unguarded Enter to recover. diff --git a/skills/codeman/reference/verbs.md b/skills/codeman/reference/verbs.md index 65af5c65f..3f511649f 100644 --- a/skills/codeman/reference/verbs.md +++ b/skills/codeman/reference/verbs.md @@ -811,3 +811,12 @@ Rules that matter: not parked, `send` it one line ("read your inbox: `codeman agent inbox`"). - **Lifetime.** An inbox goes with its session on delete. A server restart empties all inboxes unless the user turned on *Keep agent inbox across restarts* in Settings. + +## Bounded inspection and guarded submission + +Use `codeman agent inspect` for bounded evidence and incremental local Codex cursors. +Use `codeman agent stage --message-file` followed by one hash-confirmed +`codeman agent submit --stage-id --confirm-sha` when the exact local Codex +composer and caret can be verified. Other targets provide labeled snapshots or +refuse guarded input. See [inspection and staging](inspection-and-staging.md) +for budgets, receipt expiry, conflict handling and supported composers. diff --git a/src/agent-codex-identity.ts b/src/agent-codex-identity.ts new file mode 100644 index 000000000..81a261623 --- /dev/null +++ b/src/agent-codex-identity.ts @@ -0,0 +1,148 @@ +/** + * @fileoverview Bounded Codex rollout attribution for agent evidence. Never uses + * newest-mtime, prompt timestamps or cwd alone as proof of session ownership. + */ +import { realpathSync } from 'node:fs'; +import { open, opendir } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join, resolve } from 'node:path'; + +const UUID_AT_END = /-([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})\.jsonl$/i; +const HEAD_BYTES = 131072; +export interface CodexAttributionOptions { + codexHome: string; + sessionId: string; + workingDir: string; + resumeSessionId?: string; +} +export interface CodexAttributionBudget { + maxEntries: number; + maxBytes: number; +} +interface Metadata { + id?: string; + cwd?: string; + originator?: string; +} +function canonical(path: string): string | undefined { + try { + return realpathSync.native(path); + } catch { + return undefined; + } +} +/** Match the CLI's relative CODEX_HOME interpretation without exposing env credentials. */ +export function resolveEffectiveCodexHome( + sessionHome: string | undefined, + workingDir: string, + env: Readonly> = process.env +): string { + const path = resolve(workingDir, sessionHome || env.CODEX_HOME || join(env.HOME || tmpdir(), '.codex')); + return canonical(path) ?? path; +} +/** Complete metadata only: a prefix or a partially flushed JSONL row proves nothing. */ +function strictMetadata(bytes: Buffer): Metadata | undefined { + const rows = bytes.toString('utf8').split('\n'); + rows.pop(); + let found: Metadata | undefined; + for (const line of rows) { + try { + const row = JSON.parse(line) as { type?: string; payload?: Metadata }; + if (row.type !== 'session_meta') continue; + if ( + found || + typeof row.payload?.id !== 'string' || + typeof row.payload.cwd !== 'string' || + (row.payload.originator !== undefined && typeof row.payload.originator !== 'string') + ) + return undefined; + found = row.payload; + } catch { + /* Incomplete or malformed rows are not identity evidence. */ + } + } + return found; +} +/** + * A configured resume and a newly owned rollout conflict rather than pinning an + * old launch forever after /new. TUI resumes of foreign-originator threads cannot + * be authoritatively tracked from this metadata; this returns attributed evidence. + */ +export async function findExactCodexConversation( + options: CodexAttributionOptions, + budget: CodexAttributionBudget = { maxEntries: 8192, maxBytes: 1048576 } +): Promise<{ id: string; path: string } | undefined> { + const directories = [{ path: join(options.codexHome, 'sessions'), depth: 0 }]; + const candidates: Array<{ id: string; path: string; meta: Metadata }> = []; + const originator = `codeman_${options.sessionId}`; + const explicit = options.resumeSessionId; + let entries = 0; + let scanned = HEAD_BYTES; // Reserve an independent final verification. + while (directories.length) { + const dir = directories.pop()!; + const handle = await opendir(dir.path).catch(() => undefined); + if (!handle) return undefined; + for await (const entry of handle) { + if (++entries > budget.maxEntries) return undefined; + const path = join(dir.path, entry.name); + if (entry.isDirectory()) { + if (dir.depth >= 8) return undefined; + directories.push({ path, depth: dir.depth + 1 }); + } else if (entry.isFile() && entry.name.endsWith('.jsonl')) { + const file = await open(path, 'r').catch(() => undefined); + if (!file) return undefined; + try { + const info = await file.stat(); + if (!info.isFile()) return undefined; + const id = entry.name.match(UUID_AT_END)?.[1]; + const prefixBytes = Math.min(info.size, 4096); + if (scanned + prefixBytes > budget.maxBytes) return undefined; + scanned += prefixBytes; + const prefix = Buffer.alloc(prefixBytes); + const first = await file.read(prefix, 0, prefixBytes, 0); + // Cheap negative filter only; a match still needs full parsed metadata. + if ( + id !== explicit && + !prefix.subarray(0, first.bytesRead).toString('utf8').includes(JSON.stringify(originator)) + ) + continue; + const size = Math.min(info.size, HEAD_BYTES); + if (scanned + size > budget.maxBytes) return undefined; + scanned += size; + const bytes = Buffer.alloc(size); + const read = await file.read(bytes, 0, size, 0); + const meta = strictMetadata(bytes.subarray(0, read.bytesRead)); + if (!meta || !id || meta.id !== id) continue; + if (id === explicit || meta.originator === originator) candidates.push({ id, path, meta }); + } finally { + await file.close(); + } + } + } + } + if (candidates.length !== 1) return undefined; + const candidate = candidates[0]; + if ( + !candidate.meta.cwd || + canonical(candidate.meta.cwd) !== canonical(options.workingDir) || + !canonical(options.workingDir) + ) + return undefined; + const handle = await open(candidate.path, 'r').catch(() => undefined); + if (!handle) return undefined; + try { + const bytes = Buffer.alloc(HEAD_BYTES); + const read = await handle.read(bytes, 0, bytes.length, 0); + const meta = strictMetadata(bytes.subarray(0, read.bytesRead)); + if ( + !meta || + meta.id !== candidate.id || + meta.cwd !== candidate.meta.cwd || + meta.originator !== candidate.meta.originator + ) + return undefined; + return { id: candidate.id, path: candidate.path }; + } finally { + await handle.close(); + } +} diff --git a/src/agent-inspection.ts b/src/agent-inspection.ts new file mode 100644 index 000000000..1fc15b4eb --- /dev/null +++ b/src/agent-inspection.ts @@ -0,0 +1,191 @@ +/** + * @fileoverview Bounded append-only JSONL evidence reads for agent inspection. + * Opaque, expiring server-side cursors contain no public filesystem paths. A cursor + * binds the caller, pane generation, attributed conversation and opened file identity. + * Complete records only; an oversized row stalls explicitly rather than losing evidence. + */ +import { open, type FileHandle } from 'node:fs/promises'; +import { createHash, randomUUID } from 'node:crypto'; +import { isUtf8 } from 'node:buffer'; + +export const INSPECT_MAX_BYTES = 65536; +export const INSPECT_SCAN_BYTES = 262144; +export interface InspectionScope { + owner: string; + sessionId: string; + generation: string; + conversationId: string; +} +interface CursorState { + scope: string; + path: string; + identity: string; + offset: number; + observedSize: number; + modifiedAt: number; + changedAt: number; + anchor: string; + expiresAt: number; +} +export class InspectionConflict extends Error {} + +/** UTF8-safe suffix, also used for explicitly non-incremental pane evidence. */ +export function boundedUtf8Tail(text: string, maxBytes: number): string { + // A UTF8 suffix fits within this many UTF16 units. Include a pair of extra + // units so slicing cannot introduce a surrogate replacement inside the result. + const bytes = Buffer.from(text.slice(-maxBytes - 2)); + let start = Math.max(0, bytes.length - maxBytes); + while (start < bytes.length && (bytes[start] & 0xc0) === 0x80) start++; + return bytes.subarray(start).toString('utf8'); +} +async function anchor(handle: FileHandle, offset: number): Promise { + const count = Math.min(offset, 4096); + const head = Buffer.alloc(count); + const tail = Buffer.alloc(count); + await handle.read(head, 0, count, 0); + await handle.read(tail, 0, count, Math.max(0, offset - count)); + return createHash('sha256').update(head).update(tail).digest('hex'); +} + +export class InspectionReader { + private readonly cursors = new Map(); + constructor( + private readonly now = Date.now, + private readonly capacity = 1024, + private readonly ttlMs = 900000 + ) {} + + async read(path: string, scope: InspectionScope, maxBytes: number, since?: string) { + if (!Number.isInteger(maxBytes) || maxBytes < 1 || maxBytes > INSPECT_MAX_BYTES) + throw new Error('Invalid byte budget'); + const scopeKey = JSON.stringify(scope); + const previous = since ? this.cursors.get(since) : undefined; + if ( + since && + (!previous || previous.expiresAt <= this.now() || previous.scope !== scopeKey || previous.path !== path) + ) { + throw new InspectionConflict('Invalid or expired inspection cursor; inspect without since to start again'); + } + const handle = await open(path, 'r'); + try { + const before = await handle.stat(); + if (!before.isFile()) throw new InspectionConflict('Inspection source is not a regular file'); + const identity = `${before.dev}:${before.ino}:${before.birthtimeMs}`; + let offset = previous?.offset ?? Math.max(0, before.size - INSPECT_SCAN_BYTES + 1); + if ( + previous && + (identity !== previous.identity || + before.size < previous.observedSize || + (before.size === previous.observedSize && + (before.mtimeMs !== previous.modifiedAt || before.ctimeMs !== previous.changedAt)) || + (await anchor(handle, offset)) !== previous.anchor) + ) { + throw new InspectionConflict('Inspection cursor source changed; inspect without since to start again'); + } + let startsAtBoundary = offset === 0; + if (!previous && offset > 0) { + const preceding = Buffer.alloc(1); + await handle.read(preceding, 0, 1, offset - 1); + startsAtBoundary = preceding[0] === 10; + } + const scan = Buffer.alloc( + Math.min(INSPECT_SCAN_BYTES - (!previous && offset > 0 ? 1 : 0), Math.max(0, before.size - offset)) + ); + const { bytesRead } = await handle.read(scan, 0, scan.length, offset); + let selectedStart = 0; + let consumed = 0; + let blockedReason: string | undefined; + let omittedBeforeBytes = 0; + if (!previous) { + const leading = startsAtBoundary ? 0 : scan.indexOf(10) + 1; + const completeEnd = scan.lastIndexOf(10, bytesRead - 1) + 1; + if (!startsAtBoundary && leading === 0) { + // The tail lies wholly inside one unfinished oversized record. Its + // beginning is not known: retain the known file boundary rather than + // manufacture a cursor pointing into a JSON/UTF8 fragment. + offset = 0; + blockedReason = 'record-exceeds-scan-limit'; + } else { + selectedStart = completeEnd; + consumed = completeEnd; + while (selectedStart > leading) { + const recordStart = Math.max(leading, scan.lastIndexOf(10, selectedStart - 2) + 1); + if (completeEnd - recordStart > maxBytes) { + if (selectedStart === completeEnd) { + selectedStart = recordStart; + consumed = recordStart; + blockedReason = 'record-exceeds-output-budget'; + } + break; + } + selectedStart = recordStart; + } + if (completeEnd < bytesRead && !blockedReason) blockedReason = 'partial-record'; + // If the only known boundary is the end of a skipped leading row, + // the remainder is a partial record and stays pending there. + if (completeEnd < leading) selectedStart = consumed = leading; + omittedBeforeBytes = offset + selectedStart; + } + } else { + while (consumed < bytesRead) { + const end = scan.indexOf(10, consumed); + if (end < 0) { + blockedReason = bytesRead === INSPECT_SCAN_BYTES ? 'record-exceeds-scan-limit' : 'partial-record'; + break; + } + if (end + 1 > maxBytes) { + blockedReason = 'record-exceeds-output-budget'; + break; + } + consumed = end + 1; + } + } + const evidenceBytes = scan.subarray(selectedStart, consumed); + if (!isUtf8(evidenceBytes)) throw new InspectionConflict('Inspection source contains invalid UTF8'); + for (const row of evidenceBytes.toString('utf8').split('\n').slice(0, -1)) { + try { + JSON.parse(row); + } catch { + throw new InspectionConflict('Inspection source contains invalid JSONL'); + } + } + const after = await handle.stat(); + if ( + after.size < before.size || + after.ino !== before.ino || + (after.size === before.size && (after.mtimeMs !== before.mtimeMs || after.ctimeMs !== before.ctimeMs)) + ) + throw new InspectionConflict('Inspection source changed during read'); + const nextAnchor = await anchor(handle, offset + consumed); + const cursor = randomUUID(); + // No await between capacity enforcement and insertion: concurrent reads + // must not all reserve the same remaining slot while hashing anchors. + for (const [key, value] of this.cursors) if (value.expiresAt <= this.now()) this.cursors.delete(key); + while (this.cursors.size >= this.capacity) this.cursors.delete(this.cursors.keys().next().value!); + this.cursors.set(cursor, { + scope: scopeKey, + path, + identity, + offset: offset + consumed, + observedSize: before.size, + modifiedAt: before.mtimeMs, + changedAt: before.ctimeMs, + anchor: nextAnchor, + expiresAt: this.now() + this.ttlMs, + }); + return { + evidence: evidenceBytes.toString('utf8'), + cursor, + omittedBeforeBytes, + truncated: omittedBeforeBytes > 0 || offset + consumed < before.size, + blockedReason, + scannedBytes: bytesRead, + }; + } finally { + await handle.close(); + } + } + clear(): void { + this.cursors.clear(); + } +} diff --git a/src/agent-staging.ts b/src/agent-staging.ts new file mode 100644 index 000000000..ff8cc8c9f --- /dev/null +++ b/src/agent-staging.ts @@ -0,0 +1,183 @@ +/** + * @fileoverview Short-lived, bounded guarded prompt receipts. No prompt content is + * retained: only caller/session identity, lifecycle/input revisions and SHA-256. + * A receipt is consumed synchronously before verification/delivery, including failures. + */ +import { createHash, randomUUID } from 'node:crypto'; +import { stripAnsi } from './utils/regex-patterns.js'; +import { visibleWidth } from './tui/tui-ansi.js'; +export const STAGE_MAX_BYTES = 8192; +export const promptSha256 = (text: string): string => createHash('sha256').update(text).digest('hex'); +export interface GuardedInputSnapshot { + generation: string; + revision: number; + composerSha: string; +} +export interface GuardedInputTarget { + id: string; + snapshot(): GuardedInputSnapshot | null; + write(text: string): Promise; + submit(expected: GuardedInputSnapshot): Promise; +} +interface Receipt extends GuardedInputSnapshot { + owner: string; + sessionId: string; + expiresAt: number; + sha256: string; +} +export class GuardedInputConflict extends Error {} + +/** + * Conservatively read a Codex composer: exactly one visible, unwrapped row and a + * known footer. Never remove internal spaces, infer an unknown shell prompt, or + * interpret dialog options as authority. An unsupported frame yields no proof. + */ +export function readGuardedCodexComposer(screen: string): string | null { + const lines = stripAnsi(screen) + .split('\n') + .map((line) => line.trimEnd()); + const rows = lines.map((line, index) => ({ line, index })).filter((row) => /^\s*›(?:\s|$)/.test(row.line)); + const last = rows.at(-1); + if (!last) return null; + const below = lines.slice(last.index + 1).filter((line) => line.trim()); + if ( + below.length === 0 || + below.length > 3 || + below.some( + (line) => + !/^\s+(?:\d+% context left|\? for shortcuts|← for agents|\S+ (?:default|low|medium|high|xhigh|max) · )/.test( + line + ) + ) + ) + return null; + // Preserve exact printable content; only SGR-dim placeholder/suggestion text is excluded. + const raw = screen.split('\n')[last.index]; + let dim = false; + let visible = ''; + // eslint-disable-next-line no-control-regex + for (const token of raw.split(/(\x1b\[[0-9;]*m)/)) { + // eslint-disable-next-line no-control-regex + if (/^\x1b\[/.test(token)) { + const codes = token.slice(2, -1).split(';').map(Number); + for (let i = 0; i < codes.length; i++) { + // Extended color payloads may contain 0, 2 or 22; those are not SGR modes. + if (codes[i] === 38 || codes[i] === 48 || codes[i] === 58) { + i += codes[i + 1] === 2 ? 4 : codes[i + 1] === 5 ? 2 : 0; + } else if (codes[i] === 0 || codes[i] === 22) dim = false; + else if (codes[i] === 2) dim = true; + } + } else if (!dim) visible += token; + } + return stripAnsi(visible).trimStart().slice(2).trimEnd(); +} + +/** Require the capture's caret at the exact end of the unwrapped composer. */ +export function readGuardedCodexSnapshot(snapshot: string): string | null { + // formatPaneSnapshot emits one absolute row address per line, then the caret. + // Plain captures lack caret evidence and cannot prove invisible trailing edits. + // eslint-disable-next-line no-control-regex + const positions = [...snapshot.matchAll(/\x1b\[(\d+);(\d+)H/g)]; + if (positions.length < 2) return null; + const caret = positions.at(-1)!; + if (caret.index! + caret[0].length !== snapshot.length) return null; + const rows: string[] = []; + for (let i = 0; i < positions.length - 1; i++) { + const position = positions[i]; + if (Number(position[1]) !== i + 1 || position[2] !== '1') return null; + rows.push(snapshot.slice(position.index! + position[0].length, positions[i + 1].index)); + } + const composer = readGuardedCodexComposer(rows.join('\n')); + if (composer === null) return null; + let composerRow = -1; + for (let i = rows.length - 1; i >= 0; i--) + if (/^\s*›(?:\s|$)/.test(stripAnsi(rows[i]))) { + composerRow = i; + break; + } + const prefix = stripAnsi(rows[composerRow]).match(/^(\s*›[ \u00a0])/); + if (!prefix || Number(caret[1]) !== composerRow + 1 || Number(caret[2]) !== visibleWidth(prefix[1] + composer) + 1) + return null; + return composer; +} + +export class GuardedStageStore { + private readonly receipts = new Map(); + private readonly busy = new Set(); + constructor( + private readonly now = Date.now, + private readonly capacity = 1024, + private readonly ttlMs = 120000 + ) {} + async stage(target: GuardedInputTarget, owner: string, message: string) { + // eslint-disable-next-line no-control-regex + const hasControls = /[\x00-\x1f\x7f-\x9f]/.test(message); + if ( + !message || + message !== message.trimEnd() || + Buffer.byteLength(message) > STAGE_MAX_BYTES || + Buffer.from(message).toString('utf8') !== message || + hasControls + ) + throw new GuardedInputConflict( + 'Stage requires printable single-line input within 8192 UTF8 bytes, without trailing whitespace' + ); + const key = target.id; + if (this.busy.has(key)) throw new GuardedInputConflict('Another guarded input operation is in progress'); + this.busy.add(key); + try { + const before = target.snapshot(); + if (!before || before.composerSha !== promptSha256('')) + throw new GuardedInputConflict('An empty, verifiable composer is required'); + for (const [id, receipt] of this.receipts) + if (receipt.expiresAt <= this.now() || receipt.sessionId === target.id) this.receipts.delete(id); + // Refuse capacity before typing. Do not evict a caller's still-live receipt. + // Include in-flight stages (this operation already owns one busy slot), + // so different sessions cannot type concurrently into the same last slot. + if (this.receipts.size + this.busy.size > this.capacity) + throw new GuardedInputConflict('Guarded stage receipt capacity reached'); + if (!(await target.write(message))) + throw new GuardedInputConflict('Staging delivery failed; inspect the pane before trying again'); + const after = target.snapshot(); + if (!after || after.generation !== before.generation || after.revision !== before.revision + 1) + throw new GuardedInputConflict('Input or pane changed while staging; no receipt was issued'); + const stageId = randomUUID(); + const expiresAt = this.now() + this.ttlMs; + const sha256 = promptSha256(message); + this.receipts.set(stageId, { ...after, owner, sessionId: target.id, expiresAt, sha256 }); + return { stageId, sha256, expiresAt, sessionId: target.id, submitted: false as const }; + } finally { + this.busy.delete(key); + } + } + async submit(target: GuardedInputTarget, owner: string, stageId: string, confirmSha: string) { + const receipt = this.receipts.get(stageId); + if (!receipt || receipt.owner !== owner || receipt.sessionId !== target.id) + throw new GuardedInputConflict('Unknown, used, or foreign stage receipt'); + // One shot, even a wrong hash or unprovable composer. No retry of ambiguous Enter. + this.receipts.delete(stageId); + if (this.busy.has(target.id)) throw new GuardedInputConflict('Another guarded input operation is in progress'); + this.busy.add(target.id); + try { + if (receipt.expiresAt <= this.now() || receipt.sha256 !== confirmSha) + throw new GuardedInputConflict('Expired stage receipt or confirmation hash mismatch'); + const current = target.snapshot(); + if ( + !current || + current.generation !== receipt.generation || + current.revision !== receipt.revision || + current.composerSha !== receipt.sha256 + ) + throw new GuardedInputConflict('Staged composer, input, or pane identity changed; nothing submitted'); + if (!(await target.submit(current))) + throw new GuardedInputConflict('Submission delivery failed or ambiguous; receipt consumed, inspect the pane'); + return { stageId, sessionId: target.id, submitted: true as const }; + } finally { + this.busy.delete(target.id); + } + } + clear(): void { + this.receipts.clear(); + this.busy.clear(); + } +} diff --git a/src/cli-agent.ts b/src/cli-agent.ts index 6a5fad476..9f0ccb8b1 100644 --- a/src/cli-agent.ts +++ b/src/cli-agent.ts @@ -31,6 +31,10 @@ */ import http from 'node:http'; import https from 'node:https'; +import { open, stat } from 'node:fs/promises'; +import { isUtf8 } from 'node:buffer'; +import { STAGE_MAX_BYTES } from './agent-staging.js'; +import { INSPECT_MAX_BYTES } from './agent-inspection.js'; import type { Command } from 'commander'; import { basicAuthHeader, @@ -757,6 +761,103 @@ export async function agentWait(deps: AgentDeps, options: WaitOptions): Promise< return waitExitCode(data?.wait); } +/** Bounded incremental evidence, with an opaque server-owned cursor. */ +export async function agentInspect( + deps: AgentDeps, + options: { id: string; maxBytes: number; since?: string } +): Promise { + if (!Number.isInteger(options.maxBytes) || options.maxBytes < 1 || options.maxBytes > INSPECT_MAX_BYTES) + return fail(deps, '--max-bytes must be between 1 and 65536', EXIT.refused); + const target = await resolveSessionId(deps, options.id); + if ('error' in target) return fail(deps, target.error, target.code); + const response = await deps.request(deps.ctx, { + method: 'GET', + path: `/api/v1/sessions/${encodeURIComponent(target.id)}/inspect`, + query: { maxBytes: options.maxBytes, since: options.since }, + }); + if (!response.json?.success) return fail(deps, describeFailure(response)); + if (deps.json) emitJson(deps, response.json.data); + else { + const data = response.json.data as { + source?: string; + evidence?: string; + truncated?: boolean; + cursor?: string; + blockedReason?: string; + }; + deps.io.out( + `[${data.source ?? 'unknown'}]${data.truncated ? ' truncated' : ''}${data.blockedReason ? ` (${data.blockedReason})` : ''}` + ); + deps.io.out(data.evidence ?? ''); + if (data.cursor) deps.io.out(`cursor: ${data.cursor}`); + } + return EXIT.ok; +} + +/** Stage exactly the printable UTF8 file contents; never sends Enter. */ +export async function agentStage(deps: AgentDeps, options: { id: string; messageFile: string }): Promise { + if (isSelfSession(deps.ctx.selfId, options.id)) return fail(deps, `refusing: ${options.id} is me`, EXIT.refused); + let message: string; + try { + const info = await stat(options.messageFile); + if (!info.isFile() || info.size > STAGE_MAX_BYTES) + return fail(deps, 'message file must be a regular file within 8192 UTF8 bytes', EXIT.refused); + const handle = await open(options.messageFile, 'r'); + try { + const buffer = Buffer.alloc(STAGE_MAX_BYTES + 1); + const { bytesRead } = await handle.read(buffer, 0, buffer.length, 0); + const bytes = buffer.subarray(0, bytesRead); + if (bytesRead > STAGE_MAX_BYTES || !isUtf8(bytes)) + return fail(deps, 'message file must contain at most 8192 bytes of UTF8', EXIT.refused); + message = bytes.toString('utf8'); + } finally { + await handle.close(); + } + } catch (error) { + return fail(deps, `cannot read message file: ${getErrorMessage(error)}`, EXIT.refused); + } + const refusal = inputRefusal(message); + if (refusal || message !== message.trimEnd()) + return fail(deps, refusal ?? 'stage refuses trailing whitespace', EXIT.refused); + const target = await resolveSessionId(deps, options.id); + if ('error' in target) return fail(deps, target.error, target.code); + const response = await deps.request(deps.ctx, { + method: 'POST', + path: `/api/v1/sessions/${encodeURIComponent(target.id)}/stage`, + body: { message }, + }); + if (!response.json?.success) return fail(deps, describeFailure(response)); + if (deps.json) emitJson(deps, response.json.data); + else { + const data = response.json.data as { stageId: string; sha256: string; expiresAt: number }; + deps.io.out( + `staged (no Enter): ${data.stageId}\nsha256: ${data.sha256}\nexpires: ${new Date(data.expiresAt).toISOString()}` + ); + } + return EXIT.ok; +} + +/** Submit one verified staged composer; failure never automatically replays Enter. */ +export async function agentSubmit( + deps: AgentDeps, + options: { id: string; stageId: string; confirmSha: string } +): Promise { + if (isSelfSession(deps.ctx.selfId, options.id)) return fail(deps, `refusing: ${options.id} is me`, EXIT.refused); + if (!/^[a-f0-9]{64}$/.test(options.confirmSha) || !/^[a-f0-9-]{36}$/.test(options.stageId)) + return fail(deps, 'submit requires a stage UUID and its exact SHA-256', EXIT.refused); + const target = await resolveSessionId(deps, options.id); + if ('error' in target) return fail(deps, target.error, target.code); + const response = await deps.request(deps.ctx, { + method: 'POST', + path: `/api/v1/sessions/${encodeURIComponent(target.id)}/submit`, + body: { stageId: options.stageId, confirmSha: options.confirmSha }, + }); + if (!response.json?.success) return fail(deps, describeFailure(response)); + if (deps.json) emitJson(deps, response.json.data); + else deps.io.out('Enter delivered once; inspect or wait for acceptance (no automatic retry).'); + return EXIT.ok; +} + export interface ReadOptions { id: string; /** Bytes of raw terminal to fetch; ANSI is stripped for humans. */ @@ -1169,6 +1270,43 @@ export function registerAgentCommands(program: Command): Command { ) ); + agent + .command('inspect ') + .description('Bounded transcript evidence or a labeled pane snapshot; opaque incremental cursors') + .option('--max-bytes ', 'UTF8 evidence budget (1–65536)', '8192') + .option('--since ', 'Resume a cursor from the same owner, pane and transcript') + .option('--json', 'Machine-readable output') + .action((id: string, options: { maxBytes: string; since?: string; json?: boolean }) => + run(Boolean(options.json), (deps) => + agentInspect(deps, { + id, + maxBytes: parsePositiveInt(options.maxBytes, 8192, '--max-bytes'), + since: options.since, + }) + ) + ); + + agent + .command('stage ') + .description('Type a printable single-line UTF8 file without Enter; requires a verifiable local Codex composer') + .requiredOption('--message-file ', 'Regular UTF8 file, at most 8192 bytes, no trailing newline') + .option('--json', 'Machine-readable output') + .action((id: string, options: { messageFile: string; json?: boolean }) => + run(Boolean(options.json), (deps) => agentStage(deps, { id, messageFile: options.messageFile })) + ); + + agent + .command('submit ') + .description('Send Enter once only when the staged composer, pane and hash still match') + .requiredOption('--stage-id ', 'One-shot stage receipt') + .requiredOption('--confirm-sha ', 'Exact SHA-256 returned by stage') + .option('--json', 'Machine-readable output') + .action((id: string, options: { stageId: string; confirmSha: string; json?: boolean }) => + run(Boolean(options.json), (deps) => + agentSubmit(deps, { id, stageId: options.stageId, confirmSha: options.confirmSha }) + ) + ); + agent .command('read ') .description("Print a session's last answer (as the server reads it for that mode) or, with --tail, its terminal") diff --git a/src/mux-interface.ts b/src/mux-interface.ts index 0f37758fe..94c362831 100644 --- a/src/mux-interface.ts +++ b/src/mux-interface.ts @@ -316,7 +316,8 @@ export interface TerminalMultiplexer extends EventEmitter { /** * Send input to a session via tmux send-keys. */ - sendInput(sessionId: string, input: string): Promise; + /** Internal conditional Enter guard, checked synchronously before transport dispatch. */ + sendInput(sessionId: string, input: string, options?: { beforeDispatch?: () => boolean }): Promise; // ========== Metadata ========== diff --git a/src/session.ts b/src/session.ts index 1cb18fe9b..6f6732229 100644 --- a/src/session.ts +++ b/src/session.ts @@ -129,6 +129,9 @@ import { DEFAULT_TMUX_HISTORY_LIMIT } from './config/terminal-history.js'; import { EXEC_TIMEOUT_MS } from './config/exec-timeout.js'; import { getCli } from './config/cli-registry/registry.js'; import { SubmitVerifier } from './session-submit-verifier.js'; +import { promptSha256, readGuardedCodexSnapshot, type GuardedInputSnapshot } from './agent-staging.js'; +import { isKeyboardTerminalInput } from './terminal-input-classifier.js'; +import { stripAnsi } from './utils/regex-patterns.js'; import { KEEP_WARM_NUDGE } from './prompt-cache-keepwarm.js'; import { compileVersionRegex } from './config/cli-registry/patterns.js'; import { resolveSessionCliVersion } from './utils/cli-resolver.js'; @@ -1308,6 +1311,7 @@ export class Session extends EventEmitter { /** Set the owning username (used by recovery to restore ownership). */ set owner(username: string | undefined) { + if (this._owner !== username) this._guardedPaneRevision++; this._owner = username; } @@ -1412,11 +1416,13 @@ export class Session extends EventEmitter { * tmux session whose record is about to be deleted (Ark0N/Codeman#446). */ markClosing(closing: boolean): void { + if (this._closing !== closing) this._guardedPaneRevision++; this._closing = closing; } /** Run one pane start, attach or relaunch with {@link paneLifecycleInFlight} raised. */ private async _withPaneLifecycle(op: () => Promise): Promise { + this._guardedPaneRevision++; this._paneLifecycleOps++; try { return await op(); @@ -1443,6 +1449,7 @@ export class Session extends EventEmitter { * Ark0N/Codeman#446 review; both now persist on their success path. */ private clearPaneExitForNewPane(): void { + this._guardedPaneRevision++; this.setPaneExit(undefined); if (this._muxSession) this._mux?.clearPaneExit?.(this._muxSession.muxName); } @@ -4206,6 +4213,11 @@ export class Session extends EventEmitter { prompt: string, options?: { model?: string; onProgress?: (info: { tokens?: number; status?: string }) => void } ): Promise<{ result: string; cost: number }> { + if (this._guardedSubmitInFlight) { + this._guardedInputRevision++; + throw new Error('Guarded submission in progress'); + } + this._guardedPaneRevision++; return new Promise((resolve, reject) => { if (this.ptyProcess) { reject(new Error('Session already has a running process')); @@ -4733,7 +4745,109 @@ export class Session extends EventEmitter { * discards the data, but it used to do so with no signal at all — which is how * input could disappear while the caller believed it had been delivered. */ + private readonly _inspectionInstance = uuidv4(); + private _guardedPaneRevision = 0; + private _guardedInputRevision = 0; + private _guardedPendingWrites = 0; + private _guardedSubmitInFlight = false; + /** Ephemeral identity, never persisted or based on a potentially reused pid alone. */ + get inspectionGeneration(): string { + return `${this._inspectionInstance}:${this._guardedPaneRevision}:${this.owner ?? ''}`; + } + get guardedInputPendingWrites(): number { + return this._guardedPendingWrites; + } + /** Expose only the path selection needed for attributed Codex evidence, never credentials. */ + getCodexHomeOverride(): string | undefined { + return this._envOverrides?.CODEX_HOME || undefined; + } + private _observeGuardedKeyboardInput(data: string): void { + if (data && isKeyboardTerminalInput(data)) this._guardedInputRevision++; + } + guardedInputSnapshot(): GuardedInputSnapshot | null { + return this._readGuardedInputSnapshot(false); + } + private _readGuardedInputSnapshot(allowLease: boolean): GuardedInputSnapshot | null { + if ( + this._isStopped || + this._closing || + this.paneLifecycleInFlight || + this._guardedPendingWrites || + (!allowLease && this._guardedSubmitInFlight) || + this.remote || + this.docker || + this.paneExit || + getCli(this.mode)?.capabilities.transcript !== 'codex-rollout' || + !this._muxSession + ) + return null; + const screen = this._mux?.capturePaneBuffer?.(this._muxSession.muxName); + if (!screen) return null; + // tmux's replay has CUP addresses rather than line breaks. Read the SAME + // fresh frame for dialog/work chrome and exact composer/caret proof. + // eslint-disable-next-line no-control-regex + const plain = stripAnsi(screen.replace(/\x1b\[\d+;\d+H/g, '\n')); + const working = this._workingLinePattern(); + working.lastIndex = 0; + if (this._paneShowsBlocked(plain) || working.test(plain) || this._paneAwaitsWorkers(plain)) return null; + const composer = readGuardedCodexSnapshot(screen); + return composer === null + ? null + : { + generation: this.inspectionGeneration, + revision: this._guardedInputRevision, + composerSha: promptSha256(composer), + }; + } + /** Stage through ordinary delivery, after cancelling any older Enter retry timer. */ + async stageGuardedInput(message: string): Promise { + if (/[\r\n]/.test(message)) return false; + if (this._guardedPendingWrites || this._guardedSubmitInFlight) { + this._guardedInputRevision++; + return false; + } + this._submitVerifier?.cancel(); + return this.writeViaMux(message, { fromUser: true }); + } + /** One conditional Enter. An ambiguous acknowledgement is never replayed. */ + async submitGuardedInput(expected: GuardedInputSnapshot): Promise { + const matches = (current: GuardedInputSnapshot | null): boolean => + !!current && + current.generation === expected.generation && + current.revision === expected.revision && + current.composerSha === expected.composerSha; + if (!matches(this.guardedInputSnapshot()) || !this._mux || !this._muxSession) return false; + this._guardedSubmitInFlight = true; + let dispatched = false; + try { + const sent = await this._mux.sendInput(this.id, '\r', { + beforeDispatch: () => { + if (!matches(this._readGuardedInputSnapshot(true))) return false; + this._guardedInputRevision++; + dispatched = true; + return true; + }, + }); + if (sent && dispatched) this._emitSubmittedPrompt(this._trackSubmit('\r', { fromUser: true })); + return sent && dispatched; + } finally { + this._guardedSubmitInFlight = false; + } + } + /** Lease/pending boundary for named keys whose bytes bypass the ordinary write methods. */ + async performAuxiliaryInput(operation: () => Promise): Promise { + this._guardedInputRevision++; + if (this._guardedSubmitInFlight) throw new Error('Guarded submission in progress'); + this._guardedPendingWrites++; + try { + await operation(); + } finally { + this._guardedPendingWrites--; + } + } write(data: string, options: SessionWriteOptions = {}): boolean { + this._observeGuardedKeyboardInput(data); + if (this._guardedSubmitInFlight) return false; const submittedPrompt = this._trackSubmit(data, options); if (!this.ptyProcess) return false; this.ptyProcess.write(data); @@ -4782,6 +4896,7 @@ export class Session extends EventEmitter { * separator. Reports submissions like a write would (a line feed never is one). */ trackUserInput(data: string): void { + this._observeGuardedKeyboardInput(data); if (!this._acceptsPrompts) return; this._emitSubmittedPrompt(this._submittedPromptTracker.feed(data)); } @@ -4803,6 +4918,7 @@ export class Session extends EventEmitter { * their prompt must count as real and alert normally. */ markPromptSubmitted(prompt?: string): void { + this._guardedInputRevision++; if (prompt !== undefined && prompt.trim() !== KEEP_WARM_NUDGE) this._keepAliveTurnUntil = 0; this._stampSubmit(); } @@ -4898,22 +5014,29 @@ export class Session extends EventEmitter { * ``` */ async writeViaMux(data: string, options: SessionWriteOptions = {}): Promise { - const submittedPrompt = this._trackSubmit(data, options); - if (this._mux && this._muxSession) { - const sent = await this._mux.sendInput(this.id, data); - if (sent) { + this._observeGuardedKeyboardInput(data); + if (this._guardedSubmitInFlight) return false; + this._guardedPendingWrites++; + try { + const submittedPrompt = this._trackSubmit(data, options); + if (this._mux && this._muxSession) { + const sent = await this._mux.sendInput(this.id, data); + if (sent) { + this._emitSubmittedPrompt(submittedPrompt); + this._verifySubmitted(data); + } + return sent; + } + // Fallback to PTY write + if (this.ptyProcess) { + this.ptyProcess.write(data); this._emitSubmittedPrompt(submittedPrompt); - this._verifySubmitted(data); + return true; } - return sent; - } - // Fallback to PTY write - if (this.ptyProcess) { - this.ptyProcess.write(data); - this._emitSubmittedPrompt(submittedPrompt); - return true; + return false; + } finally { + this._guardedPendingWrites--; } - return false; } /** @@ -4941,7 +5064,10 @@ export class Session extends EventEmitter { this._isStopped || !this._mux || !this._muxSession ? null : this._mux.capturePaneText?.(this._muxSession.muxName), - sendEnter: () => this._mux?.sendInput(this.id, '\r'), + sendEnter: () => { + if (this._guardedSubmitInFlight) this._submitVerifier?.cancel(); + return this.writeViaMux('\r'); + }, // ⚠ NO fallback glyph here, unlike the screen-reading probe elsewhere in this file. // Only claude, codex, pi, opencode, omp, gemini and antigravity declare a promptGlyph; the other modes would fall back // to claude's `❯`, which is ALSO starship's default shell prompt (and pure's, and @@ -4970,6 +5096,7 @@ export class Session extends EventEmitter { * `resize()` skipped a real resize that happened to match them. */ private _notePtySpawnGeometry(cols: number, rows: number): void { + this._guardedPaneRevision++; this._ptyCols = cols; this._ptyRows = rows; } @@ -5092,6 +5219,7 @@ export class Session extends EventEmitter { } const dimsChanged = cols !== this._ptyCols || rows !== this._ptyRows; if (this.ptyProcess && (dimsChanged || options.force)) { + this._guardedPaneRevision++; this._ptyCols = cols; this._ptyRows = rows; if (!IS_TEST_MODE && this._mux && this._muxSession) { @@ -5108,6 +5236,10 @@ export class Session extends EventEmitter { // Legacy method for sending input - wraps runPrompt async sendInput(input: string): Promise { + if (this._guardedSubmitInFlight) { + this._guardedInputRevision++; + throw new Error('Guarded submission in progress'); + } this._status = 'busy'; this._markActivity(true); this.runPrompt(input).catch((err) => { diff --git a/src/terminal-input-classifier.ts b/src/terminal-input-classifier.ts new file mode 100644 index 000000000..34ea2e85a --- /dev/null +++ b/src/terminal-input-classifier.ts @@ -0,0 +1,62 @@ +/** @fileoverview Distinguish complete terminal focus/mouse reports from keyboard-bearing input. */ +const FOCUS_REPORT_LENGTH = 3; +const X10_REPORT_LENGTH = 6; + +function sgrMouseReportLength(rest: string): number { + if (!rest.startsWith('\x1b[<')) return 0; + + let offset = 3; + for (let field = 0; field < 3; field += 1) { + const digitStart = offset; + while (offset < rest.length && rest.charCodeAt(offset) >= 48 && rest.charCodeAt(offset) <= 57) { + offset += 1; + } + if (offset === digitStart) return 0; + + if (field < 2) { + if (rest[offset] !== ';') return 0; + offset += 1; + } + } + + return rest[offset] === 'M' || rest[offset] === 'm' ? offset + 1 : 0; +} + +function x10MouseReportLength(rest: string): number { + if (!rest.startsWith('\x1b[M') || rest.length < X10_REPORT_LENGTH) return 0; + + const button = rest.charCodeAt(3); + const x = rest.charCodeAt(4); + const y = rest.charCodeAt(5); + if (button < 32 || button > 255 || x < 33 || x > 255 || y < 33 || y > 255) return 0; + + return X10_REPORT_LENGTH; +} + +function terminalReportLengthAt(data: string, offset: number): number { + const rest = data.slice(offset); + if (rest.startsWith('\x1b[I') || rest.startsWith('\x1b[O')) return FOCUS_REPORT_LENGTH; + + const sgrLength = sgrMouseReportLength(rest); + if (sgrLength > 0) return sgrLength; + + return x10MouseReportLength(rest); +} + +/** + * Whether terminal input contains any keyboard-bearing bytes. + * + * Returns false only when every byte is consumed as an allowlisted mouse or + * focus report. Unknown, malformed, mixed, and empty input fails closed. + */ +export function isKeyboardTerminalInput(data: string): boolean { + if (data.length === 0) return true; + + let offset = 0; + while (offset < data.length) { + const reportLength = terminalReportLengthAt(data, offset); + if (reportLength === 0) return true; + offset += reportLength; + } + return false; +} diff --git a/src/tmux-manager.ts b/src/tmux-manager.ts index 158888502..0c52cf399 100644 --- a/src/tmux-manager.ts +++ b/src/tmux-manager.ts @@ -3983,7 +3983,8 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer { * - `Enter` key is sent as a SEPARATE tmux invocation after a small delay * - Ink (Claude CLI) needs text and Enter split to avoid treating Enter as a newline */ - async sendInput(sessionId: string, input: string): Promise { + async sendInput(sessionId: string, input: string, options?: { beforeDispatch?: () => boolean }): Promise { + if (options?.beforeDispatch && input !== '\r') return false; const session = this.sessions.get(sessionId); if (!session) { console.error( @@ -3996,7 +3997,7 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer { // inputRunner() so a test subclass can record what WOULD reach tmux. const run = this.inputRunner(); if (!run) { - return true; + return options?.beforeDispatch?.() ?? true; } console.log( @@ -4050,7 +4051,8 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer { `${this.tmux()} send-keys -t "${session.muxName}" -l -- ${shellescape(escapeTmuxTrailingSemicolon(textPart))}` ); } else if (hasCarriageReturn) { - // Enter only + // Enter only: no await between the guard and dispatch. + if (options?.beforeDispatch && !options.beforeDispatch()) return false; await run(`${this.tmux()} send-keys -t "${session.muxName}" Enter`); } diff --git a/src/web/routes/agent-session-routes.ts b/src/web/routes/agent-session-routes.ts new file mode 100644 index 000000000..a3892b349 --- /dev/null +++ b/src/web/routes/agent-session-routes.ts @@ -0,0 +1,148 @@ +/** + * @fileoverview Agent-facing bounded inspection and guarded stage/submit endpoints. + * Ownership is checked before any disk or pane read. Native evidence comes only + * from strict conversation attribution; all other modes get labeled snapshots. + */ +import type { FastifyInstance } from 'fastify'; +import type { Session } from '../../session.js'; +import type { SessionPort, InfraPort } from '../ports/index.js'; +import { getCli } from '../../config/cli-registry/registry.js'; +import { findExactCodexConversation, resolveEffectiveCodexHome } from '../../agent-codex-identity.js'; +import { InspectionReader, InspectionConflict, boundedUtf8Tail } from '../../agent-inspection.js'; +import { GuardedStageStore, GuardedInputConflict, type GuardedInputTarget } from '../../agent-staging.js'; +import { ApiErrorCode, createErrorResponse } from '../../types.js'; +import { findSessionOrFail, getAuthUser, parseBody } from '../route-helpers.js'; +import { AgentInspectQuerySchema, AgentStageSchema, AgentSubmitSchema } from '../schemas.js'; + +export function registerAgentSessionRoutes(app: FastifyInstance, ctx: SessionPort & InfraPort): void { + const reader = new InspectionReader(); + const stages = new GuardedStageStore(); + app.addHook('onClose', async () => { + reader.clear(); + stages.clear(); + }); + const generation = (session: Session) => session.inspectionGeneration; + const target = (session: Session): GuardedInputTarget => ({ + id: session.id, + snapshot: () => { + if (ctx.sessions.get(session.id) !== session) return null; + const snapshot = session.guardedInputSnapshot?.(); + return snapshot ? { ...snapshot, generation: generation(session) } : null; + }, + write: (text) => session.stageGuardedInput(text), + submit: (expected) => session.submitGuardedInput(expected), + }); + app.get('/api/sessions/:id/inspect', async (req, reply) => { + const { id } = req.params as { id: string }; + const session = findSessionOrFail(ctx, id, req); + const { maxBytes, since } = parseBody(AgentInspectQuerySchema, req.query); + const initialGeneration = generation(session); + const identity = { sessionId: session.id, mode: session.mode }; + const activity = { status: session.status, lastSubmitAt: session.lastSubmitAt || null, observedAt: Date.now() }; + try { + if (getCli(session.mode)?.capabilities.transcript === 'codex-rollout' && !session.remote && !session.docker) { + const conversation = await findExactCodexConversation( + { + codexHome: resolveEffectiveCodexHome(session.getCodexHomeOverride(), session.workingDir), + sessionId: session.id, + workingDir: session.workingDir, + resumeSessionId: session.codexConfig?.resumeSessionId, + }, + { maxEntries: 8192, maxBytes: 1048576 } + ); + if (generation(session) !== initialGeneration || ctx.sessions.get(id) !== session) + throw new InspectionConflict('Session changed during inspection'); + if (conversation) { + const result = await reader.read( + conversation.path, + { + owner: getAuthUser(req).username, + sessionId: id, + generation: initialGeneration, + conversationId: conversation.id, + }, + maxBytes, + since + ); + if (generation(session) !== initialGeneration || ctx.sessions.get(id) !== session) + throw new InspectionConflict('Session changed during inspection'); + return { + success: true, + data: { + source: 'codex-jsonl', + identity: { ...identity, conversationId: conversation.id }, + activity, + incremental: true, + maxBytes, + ...result, + }, + }; + } + } + if (since) + throw new InspectionConflict('Attributed transcript unavailable; this cursor cannot resume a pane snapshot'); + const frame = session.muxName ? ctx.mux.capturePaneText?.(session.muxName) : null; + const evidence = frame ?? session.terminalBuffer; + return { + success: true, + data: { + source: 'pane-snapshot', + identity, + activity, + incremental: false, + cursor: null, + maxBytes, + evidence: boundedUtf8Tail(evidence, maxBytes), + truncated: evidence.length > maxBytes || Buffer.byteLength(evidence) > maxBytes, + }, + }; + } catch (error) { + if (error instanceof InspectionConflict) + return reply.code(409).send(createErrorResponse(ApiErrorCode.CONFLICT, error.message)); + // Native paths and arbitrary IO errors must never escape into the response. + return reply + .code(409) + .send(createErrorResponse(ApiErrorCode.CONFLICT, 'Inspection source unavailable; retry without a cursor')); + } + }); + app.post('/api/sessions/:id/stage', async (req, reply) => { + const session = findSessionOrFail(ctx, (req.params as { id: string }).id, req); + const { message } = parseBody(AgentStageSchema, req.body); + try { + const receipt = await stages.stage(target(session), getAuthUser(req).username, message); + return { success: true, data: receipt }; + } catch (error) { + return reply + .code(409) + .send( + createErrorResponse( + ApiErrorCode.CONFLICT, + error instanceof GuardedInputConflict + ? error.message + : 'Stage delivery failed; inspect the pane before retrying' + ) + ); + } + }); + app.post('/api/sessions/:id/submit', async (req, reply) => { + const session = findSessionOrFail(ctx, (req.params as { id: string }).id, req); + const { stageId, confirmSha } = parseBody(AgentSubmitSchema, req.body); + try { + return { + success: true, + data: await stages.submit(target(session), getAuthUser(req).username, stageId, confirmSha), + }; + } catch (error) { + return reply + .code(409) + .send( + createErrorResponse( + ApiErrorCode.CONFLICT, + error instanceof GuardedInputConflict + ? error.message + : 'Submit delivery failed or ambiguous; receipt consumed, inspect the pane' + ) + ); + } + }); +} diff --git a/src/web/routes/session-routes.ts b/src/web/routes/session-routes.ts index 7569a4dcb..507ef7ab9 100644 --- a/src/web/routes/session-routes.ts +++ b/src/web/routes/session-routes.ts @@ -5,6 +5,7 @@ */ import { claudeProjectDirName } from '../../case-rename.js'; +import { registerAgentSessionRoutes } from './agent-session-routes.js'; import { FastifyInstance, type FastifyRequest } from 'fastify'; import { z } from 'zod'; import { join, dirname, extname, basename, resolve as resolvePath } from 'node:path'; @@ -1072,6 +1073,8 @@ export function registerSessionRoutes( // ========== Logout ========== + registerAgentSessionRoutes(app, ctx); + app.post('/api/logout', async (req, reply) => { // Invalidate server-side session token (not just the browser cookie) const sessionToken = req.cookies[AUTH_COOKIE_NAME]; @@ -2428,17 +2431,20 @@ export function registerSessionRoutes( try { // Route through the dedicated Codeman socket — bare `tmux` would target the // user's default server and never find this session (same #80 regression class). - await new Promise((resolve, reject) => { - execFile( - 'tmux', - ['-L', ctx.mux.muxSocket, 'send-keys', '-H', '-t', muxName, ...hex], - { timeout: 5000 }, - (err) => { - if (err) reject(err); - else resolve(); - } - ); - }); + await session.performAuxiliaryInput( + () => + new Promise((resolve, reject) => { + execFile( + 'tmux', + ['-L', ctx.mux.muxSocket, 'send-keys', '-H', '-t', muxName, ...hex], + { timeout: 5000 }, + (err) => { + if (err) reject(err); + else resolve(); + } + ); + }) + ); } catch (err) { console.error('[Server] send-key failed:', err); return createErrorResponse(ApiErrorCode.INTERNAL_ERROR, 'tmux send-keys failed'); diff --git a/src/web/schemas.ts b/src/web/schemas.ts index 8336a2e3a..295441d2d 100644 --- a/src/web/schemas.ts +++ b/src/web/schemas.ts @@ -2410,3 +2410,15 @@ export const CustomModelSelectionSchema = z.union([ }), z.object({ clear: z.literal(true) }), ]); + +/** Agent inspection bounds are bytes of evidence, independent of the JSON envelope. */ +export const AgentInspectQuerySchema = z + .object({ + maxBytes: z.coerce.number().int().min(1).max(65536).default(8192), + since: z.string().uuid().optional(), + }) + .strict(); +export const AgentStageSchema = z.object({ message: z.string().min(1).max(8192) }).strict(); +export const AgentSubmitSchema = z + .object({ stageId: z.string().uuid(), confirmSha: z.string().regex(/^[a-f0-9]{64}$/) }) + .strict(); diff --git a/test/agent-codex-identity.test.ts b/test/agent-codex-identity.test.ts new file mode 100644 index 000000000..b568b3d5a --- /dev/null +++ b/test/agent-codex-identity.test.ts @@ -0,0 +1,119 @@ +/** @fileoverview Strict bounded native rollout attribution without viewer heuristics. */ +import { afterEach, expect, it } from 'vitest'; +import { mkdtemp, mkdir, writeFile, rm, symlink } from 'node:fs/promises'; +import { join } from 'node:path'; +import { tmpdir } from 'node:os'; +import { findExactCodexConversation, resolveEffectiveCodexHome } from '../src/agent-codex-identity.js'; +const roots: string[] = []; +afterEach(async () => { + await Promise.all(roots.splice(0).map((root) => rm(root, { recursive: true, force: true }))); +}); +const A = '11111111-1111-4111-8111-111111111111'; +const B = '22222222-2222-4222-8222-222222222222'; +async function fixture() { + const root = await mkdtemp(join(tmpdir(), 'agent-attribution-')); + roots.push(root); + const home = join(root, 'home'); + await mkdir(join(home, 'sessions'), { recursive: true }); + const options = { codexHome: home, sessionId: 'managed', workingDir: root }; + const put = async (id: string, payload: object = {}, text?: string) => { + const path = join(home, 'sessions', `rollout-${id}.jsonl`); + await writeFile( + path, + text ?? + JSON.stringify({ + type: 'session_meta', + payload: { id, cwd: root, originator: 'codeman_managed', ...payload }, + }) + '\n' + ); + return path; + }; + return { root, home, options, put }; +} +it('requires one complete native session_meta matching the rollout ID, managed originator and canonical workspace', async () => { + const f = await fixture(); + const path = await f.put(A); + expect(await findExactCodexConversation(f.options)).toEqual({ id: A, path }); + await f.put(A, { id: B }); + expect(await findExactCodexConversation(f.options)).toBeUndefined(); +}); +it('never chooses newest cwd files or an ambiguous managed originator', async () => { + const f = await fixture(); + await f.put(A, { originator: 'codex_cli' }); + expect(await findExactCodexConversation(f.options)).toBeUndefined(); + await f.put(A); + await f.put(B); + expect(await findExactCodexConversation(f.options)).toBeUndefined(); +}); +it('allows an explicit resume identity but still requires native metadata and the workspace', async () => { + const f = await fixture(); + const path = await f.put(A, { originator: 'original-launch' }); + expect(await findExactCodexConversation({ ...f.options, resumeSessionId: A })).toEqual({ id: A, path }); + await f.put(B); + expect(await findExactCodexConversation({ ...f.options, resumeSessionId: A })).toBeUndefined(); + await f.put(A, { cwd: f.home }); + expect(await findExactCodexConversation({ ...f.options, resumeSessionId: A })).toBeUndefined(); +}); +it.each(['partial', 'duplicate', 'oversized'] as const)('refuses %s metadata', async (kind) => { + const f = await fixture(); + const row = JSON.stringify({ type: 'session_meta', payload: { id: A, cwd: f.root, originator: 'codeman_managed' } }); + await f.put( + A, + {}, + kind === 'partial' + ? row + : kind === 'duplicate' + ? row + '\n' + row + '\n' + : row.slice(0, -2) + ',"instructions":"' + 'x'.repeat(140000) + '"}}\n' + ); + expect(await findExactCodexConversation(f.options)).toBeUndefined(); +}); +it('enforces traversal/read caps and ignores symlinked directories', async () => { + const f = await fixture(); + await f.put(A); + expect(await findExactCodexConversation(f.options, { maxEntries: 0, maxBytes: 1048576 })).toBeUndefined(); + expect(await findExactCodexConversation(f.options, { maxEntries: 8192, maxBytes: 128 })).toBeUndefined(); + await mkdir(join(f.root, 'elsewhere')); + await symlink(join(f.root, 'elsewhere'), join(f.home, 'sessions', 'alias')); + expect((await findExactCodexConversation(f.options))?.id).toBe(A); +}); +it('resolves per-session, process and default CODEX_HOME relative to the session workspace', () => { + expect(resolveEffectiveCodexHome('custom', '/tmp/work', { CODEX_HOME: '/other' })).toBe('/tmp/work/custom'); + expect(resolveEffectiveCodexHome(undefined, '/tmp/work', { CODEX_HOME: 'process' })).toBe('/tmp/work/process'); + expect(resolveEffectiveCodexHome(undefined, '/tmp/work', { HOME: '/tmp/user' })).toBe('/tmp/user/.codex'); +}); + +it.each([ + ['trailing-space', 'codex-home ', 'codex-home'], + ['whitespace-only', ' ', '.codex'], +])( + 'keeps the raw %s CODEX_HOME namespace through the Session getter and attribution helper', + async (_label, configured, wrongHome) => { + const { Session } = await import('../src/session.js'); + const f = await fixture(); + const expectedHome = join(f.root, configured); + const siblingHome = join(f.root, wrongHome); + const record = + JSON.stringify({ type: 'session_meta', payload: { id: A, cwd: f.root, originator: 'codeman_managed' } }) + '\n'; + for (const home of [expectedHome, siblingHome]) { + await mkdir(join(home, 'sessions'), { recursive: true }); + await writeFile(join(home, 'sessions', `rollout-${A}.jsonl`), record); + } + const session = new Session({ + id: 'managed', + workingDir: f.root, + mode: 'codex', + envOverrides: { CODEX_HOME: configured }, + }); + try { + const codexHome = resolveEffectiveCodexHome(session.getCodexHomeOverride(), session.workingDir, { HOME: f.root }); + expect(await findExactCodexConversation({ ...f.options, codexHome })).toEqual({ + id: A, + path: join(expectedHome, 'sessions', `rollout-${A}.jsonl`), + }); + expect(session.getCodexHomeOverride()).toBe(configured); + } finally { + await session.stop(false); + } + } +); diff --git a/test/agent-inspection.test.ts b/test/agent-inspection.test.ts new file mode 100644 index 000000000..2a1f4270a --- /dev/null +++ b/test/agent-inspection.test.ts @@ -0,0 +1,161 @@ +/** @fileoverview Bounded JSONL inspection: cursors, byte budgets and file generations. */ +import { afterEach, beforeEach, expect, it } from 'vitest'; +import { mkdtemp, writeFile, appendFile, rename, rm } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { InspectionReader } from '../src/agent-inspection.js'; +let dir: string; +let file: string; +let reader: InspectionReader; +const scope = { owner: 'alice', sessionId: 's', generation: 'pane-1', conversationId: 'thread-1' }; +beforeEach(async () => { + dir = await mkdtemp(join(tmpdir(), 'inspect-')); + file = join(dir, 'rollout.jsonl'); + reader = new InspectionReader(); +}); +afterEach(async () => { + await rm(dir, { recursive: true, force: true }); +}); +it('returns complete UTF8 records within the byte budget and resumes exactly', async () => { + const row = JSON.stringify({ text: 'é😀' }) + '\n'; + await writeFile(file, row + row); + const first = await reader.read(file, scope, Buffer.byteLength(row)); + expect(first.evidence).toBe(row); + expect(first.truncated).toBe(true); + await appendFile(file, row); + const next = await reader.read(file, scope, 1024, first.cursor); + expect(next.evidence).toBe(row); + expect(next.truncated).toBe(false); +}); +it('does not consume partial final records', async () => { + await writeFile(file, '{"text":"hello'); + const first = await reader.read(file, scope, 100); + expect(first.evidence).toBe(''); + await appendFile(file, '"}\n'); + const next = await reader.read(file, scope, 100, first.cursor); + expect(next.evidence).toBe('{"text":"hello"}\n'); +}); +it('reports an oversized incremental record without splitting it or advancing past it', async () => { + await writeFile(file, '{}\n'); + const initial = await reader.read(file, scope, 100); + await appendFile(file, JSON.stringify({ text: 'x'.repeat(300000) }) + '\n'); + const result = await reader.read(file, scope, 100, initial.cursor); + expect(result.evidence).toBe(''); + expect(result.truncated).toBe(true); + expect(result.blockedReason).toBe('record-exceeds-scan-limit'); + expect(result.scannedBytes).toBeLessThanOrEqual(262144); +}); +it('rejects malformed, cross-owner and changed-session cursors', async () => { + await writeFile(file, '{}\n'); + const first = await reader.read(file, scope, 100); + for (const changed of [ + { ...scope, owner: 'bob' }, + { ...scope, sessionId: 'other' }, + { ...scope, generation: 'new' }, + { ...scope, conversationId: 'new' }, + ]) { + await expect(reader.read(file, changed, 100, first.cursor)).rejects.toThrow(/cursor/i); + } + await expect(reader.read(file, scope, 100, 'garbage')).rejects.toThrow(/cursor/i); +}); +it('rejects truncation, replacement and rewritten consumed evidence', async () => { + await writeFile(file, '{}\n{}\n'); + const first = await reader.read(file, scope, 100); + await writeFile(file, '{}\n'); + await expect(reader.read(file, scope, 100, first.cursor)).rejects.toThrow(/cursor/i); + await writeFile(file, '{}\n{}\n'); + const second = await reader.read(file, scope, 100); + await writeFile(file, '[]\n[]\n'); + await expect(reader.read(file, scope, 100, second.cursor)).rejects.toThrow(/cursor/i); + const third = await reader.read(file, scope, 100); + await rename(file, join(dir, 'old')); + await writeFile(file, '[]\n[]\n'); + await expect(reader.read(file, scope, 100, third.cursor)).rejects.toThrow(/cursor/i); +}); +it('expires cursors and bounds retained state', async () => { + await writeFile(file, '{}\n'); + let now = 0; + reader = new InspectionReader(() => now, 2, 1000); + const first = await reader.read(file, scope, 100); + await reader.read(file, scope, 100); + await reader.read(file, scope, 100); + await expect(reader.read(file, scope, 100, first.cursor)).rejects.toThrow(/cursor/i); + const last = await reader.read(file, scope, 100); + now = 1001; + await expect(reader.read(file, scope, 100, last.cursor)).rejects.toThrow(/cursor/i); +}); +it('bounds strict attribution itself and refuses an incomplete inventory', async () => { + const { mkdir } = await import('node:fs/promises'); + const { findExactCodexConversation } = await import('../src/agent-codex-identity.js'); + const home = join(dir, 'codex'); + const sessions = join(home, 'sessions'); + await mkdir(sessions, { recursive: true }); + const id = '11111111-1111-4111-8111-111111111111'; + await writeFile( + join(sessions, `rollout-${id}.jsonl`), + JSON.stringify({ type: 'session_meta', payload: { id, cwd: dir, originator: 'codeman_s' } }) + '\n' + ); + await writeFile(join(sessions, 'other.txt'), 'other'); + const options = { codexHome: home, sessionId: 's', workingDir: dir }; + expect((await findExactCodexConversation(options))?.id).toBe(id); + expect(await findExactCodexConversation(options, { maxEntries: 1, maxBytes: 16384 })).toBeUndefined(); +}); + +it('starts at recent complete records past oversized bootstrap evidence, labeling all omission', async () => { + const old = JSON.stringify({ system: 'x'.repeat(300000) }) + '\n'; + const latest = JSON.stringify({ assistant: 'latest 😀' }) + '\n'; + await writeFile(file, old + latest); + const result = await reader.read(file, scope, Buffer.byteLength(latest)); + expect(result.evidence).toBe(latest); + expect(result.omittedBeforeBytes).toBe(Buffer.byteLength(old)); + expect(result.truncated).toBe(true); + await appendFile(file, '{"assistant":"next"}\n'); + expect((await reader.read(file, scope, 100, result.cursor)).evidence).toBe('{"assistant":"next"}\n'); +}); +it('attributes a real-sized session_meta row rather than requiring a tiny header', async () => { + const { mkdir } = await import('node:fs/promises'); + const { findExactCodexConversation } = await import('../src/agent-codex-identity.js'); + const home = join(dir, 'codex'); + await mkdir(join(home, 'sessions'), { recursive: true }); + const id = '11111111-1111-4111-8111-111111111111'; + await writeFile( + join(home, 'sessions', `rollout-${id}.jsonl`), + JSON.stringify({ + type: 'session_meta', + payload: { id, cwd: dir, originator: 'codeman_s', instructions: 'x'.repeat(20000) }, + }) + '\n' + ); + expect( + ( + await findExactCodexConversation( + { codexHome: home, sessionId: 's', workingDir: dir }, + { maxEntries: 8192, maxBytes: 1048576 } + ) + )?.id + ).toBe(id); +}); +it('rejects same-size rewrites outside the bounded cursor anchors', async () => { + const { utimes } = await import('node:fs/promises'); + const row = JSON.stringify({ text: 'a'.repeat(20000) }) + '\n'; + await writeFile(file, row); + await utimes(file, 1, 1); + const first = await reader.read(file, scope, 30000); + await writeFile(file, row.slice(0, 10000) + 'b' + row.slice(10001)); + await expect(reader.read(file, scope, 30000, first.cursor)).rejects.toThrow(/cursor/i); +}); +it('keeps cursor capacity bounded across concurrent reads', async () => { + await writeFile(file, '{}\n'); + reader = new InspectionReader(Date.now, 2); + const results = await Promise.all(Array.from({ length: 16 }, () => reader.read(file, scope, 100))); + const resumed = await Promise.allSettled(results.map((result) => reader.read(file, scope, 100, result.cursor))); + expect(resumed.filter((result) => result.status === 'fulfilled').length).toBeLessThanOrEqual(2); +}); +it('keeps an incomplete UTF8 sequence pending until its JSONL record is complete', async () => { + const row = Buffer.from('{"text":"😀"}\n'); + const split = row.indexOf(Buffer.from('😀')) + 2; + await writeFile(file, row.subarray(0, split)); + const first = await reader.read(file, scope, 100); + expect(first.evidence).toBe(''); + await appendFile(file, row.subarray(split)); + expect((await reader.read(file, scope, 100, first.cursor)).evidence).toBe(row.toString('utf8')); +}); diff --git a/test/agent-staging-session.test.ts b/test/agent-staging-session.test.ts new file mode 100644 index 000000000..f0bb1328c --- /dev/null +++ b/test/agent-staging-session.test.ts @@ -0,0 +1,329 @@ +/** @fileoverview Guarded input observes all production write and lifecycle primitives. */ +import { expect, it, vi } from 'vitest'; +import { formatPaneSnapshot } from '../src/tmux-manager.js'; +import { Session } from '../src/session.js'; +import { promptSha256 } from '../src/agent-staging.js'; +it('fences writes and reused-pid pane lifecycles without persisting prompt content', async () => { + let screen = '› \n GPT-6-Luna default · ~/case\n'; + const mux = { + isAvailable: () => true, + capturePaneBuffer: () => + formatPaneSnapshot(screen.trimEnd().split('\n'), { + cols: 100, + rows: 20, + cursorX: screen.startsWith('› hello') ? 7 : 2, + cursorY: 0, + }), + sendInput: async () => true, + }; + const session = new Session({ + workingDir: '/tmp', + mode: 'codex', + mux, + muxSession: { muxName: 'codeman-test', sessionId: 'test', createdAt: 1, pid: 12345 }, + } as unknown as ConstructorParameters[0]); + const before = session.guardedInputSnapshot(); + expect(before?.composerSha).toBe(promptSha256('')); + await session.writeViaMux('hello'); + screen = '› hello\n GPT-6-Luna default · ~/case\n'; + expect(session.guardedInputSnapshot()?.revision).toBe(before!.revision + 1); + session.trackUserInput('x'); + expect(session.guardedInputSnapshot()?.revision).toBe(before!.revision + 2); + await session.performAuxiliaryInput(async () => {}); + expect(session.guardedInputSnapshot()?.revision).toBe(before!.revision + 3); + const generation = session.inspectionGeneration; + await (session as unknown as { _withPaneLifecycle(op: () => Promise): Promise })._withPaneLifecycle( + async () => { + expect(session.guardedInputSnapshot()).toBeNull(); + } + ); + expect(session.inspectionGeneration).not.toBe(generation); + session.markPromptSubmitted(); + expect(session.guardedInputSnapshot()?.revision).toBe(before!.revision + 4); + await session.stop(false); + expect(session.guardedInputSnapshot()).toBeNull(); +}); + +it('checks conditional Enter at the dispatch boundary and rejects concurrent browser input', async () => { + let dispatch: (() => void) | undefined; + const writes: string[] = []; + const mux = { + isAvailable: () => true, + capturePaneBuffer: () => + formatPaneSnapshot(['› hello', ' GPT-6-Luna default · ~/case'], { cols: 100, rows: 20, cursorX: 7, cursorY: 0 }), + sendInput: async (_id: string, text: string, options?: { beforeDispatch?: () => boolean }) => { + await new Promise((resolve) => { + dispatch = resolve; + }); + if (options?.beforeDispatch && !options.beforeDispatch()) return false; + writes.push(text); + return true; + }, + }; + const session = new Session({ + workingDir: '/tmp', + mode: 'codex', + mux, + muxSession: { muxName: 'codeman-test', sessionId: 'test', createdAt: 1, pid: 1 }, + } as unknown as ConstructorParameters[0]); + try { + const expected = session.guardedInputSnapshot()!; + const sending = session.submitGuardedInput(expected); + expect(await session.writeViaMux('other')).toBe(false); + dispatch!(); + expect(await sending).toBe(false); + expect(writes).toEqual([]); + const next = session.submitGuardedInput(session.guardedInputSnapshot()!); + dispatch!(); + expect(await next).toBe(true); + expect(writes).toEqual(['\r']); + const restarting = session.submitGuardedInput(session.guardedInputSnapshot()!); + await (session as unknown as { _withPaneLifecycle(op: () => Promise): Promise })._withPaneLifecycle( + async () => {} + ); + dispatch!(); + expect(await restarting).toBe(false); + expect(writes).toEqual(['\r']); + const auxiliary = session.submitGuardedInput(session.guardedInputSnapshot()!); + await expect( + session.performAuxiliaryInput(async () => { + throw new Error('must not execute'); + }) + ).rejects.toThrow('Guarded submission'); + dispatch!(); + expect(await auxiliary).toBe(false); + expect(writes).toEqual(['\r']); + } finally { + await session.stop(false); + } +}); + +it('cancels an older automatic submit verifier before staging an identical prompt', async () => { + vi.useFakeTimers(); + let composer = ''; + const writes: string[] = []; + const mux = { + isAvailable: () => true, + capturePaneBuffer: () => + formatPaneSnapshot([`› ${composer}`, ' GPT-6-Luna default · ~/case'], { + cols: 100, + rows: 20, + cursorX: 2 + composer.length, + cursorY: 0, + }), + capturePaneText: () => `› ${composer}\n GPT-6-Luna default · ~/case`, + sendInput: async (_id: string, text: string) => { + writes.push(text); + composer += text; + return true; + }, + }; + const session = new Session({ + workingDir: '/tmp', + mode: 'codex', + mux, + muxSession: { muxName: 'codeman-test', sessionId: 'test', createdAt: 1, pid: 1 }, + } as unknown as ConstructorParameters[0]); + try { + session.verifySubmitted('hello'); + expect(await session.stageGuardedInput('hello')).toBe(true); + await vi.advanceTimersByTimeAsync(2000); + expect(writes).toEqual(['hello']); + } finally { + await session.stop(false); + vi.useRealTimers(); + } +}); + +it('routes automatic verifier Enter through the shared guarded input boundary', async () => { + vi.useFakeTimers(); + let release!: () => void; + const writes: string[] = []; + const mux = { + isAvailable: () => true, + capturePaneBuffer: () => + formatPaneSnapshot(['› hello', ' GPT-6-Luna default · ~/case'], { cols: 100, rows: 20, cursorX: 7, cursorY: 0 }), + capturePaneText: () => '› hello\n GPT-6-Luna default · ~/case', + sendInput: async (_id: string, text: string, options?: { beforeDispatch?: () => boolean }) => { + if (options?.beforeDispatch) { + await new Promise((resolve) => { + release = resolve; + }); + if (!options.beforeDispatch()) return false; + } + writes.push(text); + return true; + }, + }; + const session = new Session({ + workingDir: '/tmp', + mode: 'codex', + mux, + muxSession: { muxName: 'codeman-test', sessionId: 'test', createdAt: 1, pid: 1 }, + } as unknown as ConstructorParameters[0]); + try { + session.verifySubmitted('hello'); + const submission = session.submitGuardedInput(session.guardedInputSnapshot()!); + await vi.advanceTimersByTimeAsync(2000); + release(); + expect(await submission).toBe(false); + await vi.advanceTimersByTimeAsync(60000); + expect(writes).toEqual([]); + } finally { + await session.stop(false); + vi.useRealTimers(); + } +}); + +it('refuses legacy one-shot prompt entrypoints during a guarded submission before spawning', async () => { + let release!: () => void; + const writes: string[] = []; + const mux = { + isAvailable: () => true, + capturePaneBuffer: () => + formatPaneSnapshot(['› hello', ' GPT-6-Luna default · ~/case'], { cols: 100, rows: 20, cursorX: 7, cursorY: 0 }), + sendInput: async (_id: string, text: string, options?: { beforeDispatch?: () => boolean }) => { + await new Promise((resolve) => { + release = resolve; + }); + if (options?.beforeDispatch && !options.beforeDispatch()) return false; + writes.push(text); + return true; + }, + }; + const session = new Session({ + workingDir: '/tmp', + mode: 'codex', + mux, + muxSession: { muxName: 'codeman-test', sessionId: 'test', createdAt: 1, pid: 1 }, + } as unknown as ConstructorParameters[0]); + const access = session as unknown as { ptyProcess: { write(data: string): void } | null }; + try { + const submission = session.submitGuardedInput(session.guardedInputSnapshot()!); + // This also prevents a real spawn if the lease guard regresses. + access.ptyProcess = { + write: () => { + throw new Error('must not write'); + }, + }; + await expect(session.runPrompt('other')).rejects.toThrow('Guarded submission'); + await expect(session.sendInput('other')).rejects.toThrow('Guarded submission'); + access.ptyProcess = null; + release(); + expect(await submission).toBe(false); + expect(writes).toEqual([]); + } finally { + access.ptyProcess = null; + await session.stop(false); + } +}); + +it('counts an ordinary transport pending window before any awaited acknowledgement', async () => { + let release!: () => void; + const session = new Session({ + workingDir: '/tmp', + mode: 'codex', + mux: { + isAvailable: () => true, + sendInput: async () => { + await new Promise((resolve) => { + release = resolve; + }); + return true; + }, + }, + muxSession: { muxName: 'codeman-12345678', sessionId: 'test', createdAt: 1, pid: 1 }, + } as unknown as ConstructorParameters[0]); + try { + const writing = session.writeViaMux('hello'); + expect(session.guardedInputPendingWrites).toBe(1); + release(); + await writing; + expect(session.guardedInputPendingWrites).toBe(0); + } finally { + release?.(); + await session.stop(false); + } +}); + +it('uses fresh normalized pane rows for working/dialog denial before exact composer proof', async () => { + let working = false; + const capture = vi.fn(() => + formatPaneSnapshot( + [working ? '• Working (1s · esc to interrupt)' : 'ready', '› hello', ' GPT-6-Luna default · ~/case'], + { cols: 100, rows: 20, cursorX: 7, cursorY: 1 } + ) + ); + const session = new Session({ + workingDir: '/tmp', + mode: 'codex', + mux: { + isAvailable: () => true, + capturePaneBuffer: capture, + capturePaneText: () => { + throw Error('must use same frame'); + }, + }, + muxSession: { muxName: 'codeman-12345678', sessionId: 'test', createdAt: 1, pid: 1 }, + } as unknown as ConstructorParameters[0]); + try { + const blocked = vi.spyOn(session as unknown as { _paneShowsBlocked(text: string): boolean }, '_paneShowsBlocked'); + expect(session.guardedInputSnapshot()).not.toBeNull(); + expect(blocked.mock.calls[0][0]).toContain('ready\n› hello\n'); + working = true; + expect(session.guardedInputSnapshot()).toBeNull(); + blocked.mockReturnValue(true); + working = false; + expect(session.guardedInputSnapshot()).toBeNull(); + } finally { + await session.stop(false); + } +}); + +it('keeps raw user input synchronous and preserves receipts for focus/mouse/noop reports', async () => { + const writes: string[] = []; + const session = new Session({ + workingDir: '/tmp', + mode: 'codex', + mux: { + isAvailable: () => true, + capturePaneBuffer: () => + formatPaneSnapshot(['› hello', ' GPT-6-Luna default · ~/case'], { + cols: 100, + rows: 20, + cursorX: 7, + cursorY: 0, + }), + }, + muxSession: { muxName: 'codeman-12345678', sessionId: 'test', createdAt: 1, pid: 1 }, + } as unknown as ConstructorParameters[0]); + const access = session as unknown as { + ptyProcess: { write(data: string): void; resize(cols: number, rows: number): void } | null; + }; + access.ptyProcess = { + write: (text) => { + writes.push(text); + }, + resize: () => {}, + }; + try { + const before = session.guardedInputSnapshot()!; + for (const data of ['\x1b[I', '\x1b[O', '\x1b[<0;10;5M', '\x1b[M !!', '']) { + expect(session.write(data, { fromUser: true })).toBe(true); + expect(writes.at(-1)).toBe(data); + expect(session.guardedInputSnapshot()?.revision).toBe(before.revision); + } + session.write('\x1b[Oedit'); + expect(session.guardedInputSnapshot()?.revision).toBe(before.revision + 1); + const generation = session.inspectionGeneration; + session.owner = 'other'; + session.owner = undefined; + expect(session.inspectionGeneration).not.toBe(generation); + const priorResize = session.inspectionGeneration; + session.resize(80, 24); + expect(session.inspectionGeneration).not.toBe(priorResize); + } finally { + access.ptyProcess = null; + await session.stop(false); + } +}); diff --git a/test/agent-staging.test.ts b/test/agent-staging.test.ts new file mode 100644 index 000000000..1cd920a63 --- /dev/null +++ b/test/agent-staging.test.ts @@ -0,0 +1,153 @@ +/** @fileoverview Guarded staging one-shot and content-verification safety. */ +import { beforeEach, expect, it } from 'vitest'; +import { createHash } from 'node:crypto'; +import { GuardedStageStore, type GuardedInputTarget } from '../src/agent-staging.js'; +const sha = (s: string) => createHash('sha256').update(s).digest('hex'); +let store: GuardedStageStore; +let time: number; +let generation: string; +let revision: number; +let composer: string | null; +let writes: string[]; +let fail: boolean; +let target: GuardedInputTarget; +beforeEach(() => { + time = 0; + generation = 'pane-1'; + revision = 0; + composer = ''; + writes = []; + fail = false; + store = new GuardedStageStore(() => time, 2, 1000); + target = { + id: 's', + snapshot: () => (composer === null ? null : { generation, revision, composerSha: sha(composer) }), + submit: async () => target.write('\r'), + write: async (text) => { + writes.push(text); + revision++; + if (fail) return false; + composer = text === '\r' ? '' : composer + text; + return true; + }, + }; +}); +it('stages without Enter, stores only a hash, and submits exactly once', async () => { + const receipt = await store.stage(target, 'alice', 'hello'); + expect(writes).toEqual(['hello']); + expect(receipt.sha256).toBe(sha('hello')); + expect(JSON.stringify(receipt)).not.toContain('hello'); + await store.submit(target, 'alice', receipt.stageId, receipt.sha256); + expect(writes).toEqual(['hello', '\r']); + await expect(store.submit(target, 'alice', receipt.stageId, receipt.sha256)).rejects.toThrow(); +}); +it('refuses multiline, controls, oversized prompts and nonempty or unprovable composers before writing', async () => { + for (const text of ['', 'one\ntwo', 'one\rtwo', '\u001b', '\u0085', 'x'.repeat(20000), '\ud800']) + await expect(store.stage(target, 'alice', text)).rejects.toThrow(); + composer = 'existing'; + await expect(store.stage(target, 'alice', 'hello')).rejects.toThrow(); + composer = null; + await expect(store.stage(target, 'alice', 'hello')).rejects.toThrow(); + expect(writes).toEqual([]); +}); +it('refuses altered composer including whitespace-only differences', async () => { + const receipt = await store.stage(target, 'alice', 'hello world'); + composer = 'helloworld'; + await expect(store.submit(target, 'alice', receipt.stageId, receipt.sha256)).rejects.toThrow(); + expect(writes).toEqual(['hello world']); +}); +it('refuses changed generation, intervening input, wrong owner/session/hash and expiry', async () => { + for (const change of [ + () => { + generation = 'pane-2'; + }, + () => { + revision++; + }, + () => { + time += 1001; + }, + ]) { + composer = ''; + const receipt = await store.stage(target, 'alice', 'hello'); + change(); + await expect(store.submit(target, 'alice', receipt.stageId, receipt.sha256)).rejects.toThrow(); + } + composer = ''; + const receipt = await store.stage(target, 'alice', 'hello'); + await expect(store.submit(target, 'bob', receipt.stageId, receipt.sha256)).rejects.toThrow(); + await expect(store.submit({ ...target, id: 'other' }, 'alice', receipt.stageId, receipt.sha256)).rejects.toThrow(); + await expect(store.submit(target, 'alice', receipt.stageId, sha('other'))).rejects.toThrow(); + expect(writes.filter((s) => s === '\r')).toHaveLength(0); +}); +it('consumes before write so concurrent and failed deliveries never replay Enter', async () => { + const receipt = await store.stage(target, 'alice', 'hello'); + fail = true; + const results = await Promise.allSettled([ + store.submit(target, 'alice', receipt.stageId, receipt.sha256), + store.submit(target, 'alice', receipt.stageId, receipt.sha256), + ]); + expect(results.every((r) => r.status === 'rejected')).toBe(true); + expect(writes).toEqual(['hello', '\r']); + await expect(store.submit(target, 'alice', receipt.stageId, receipt.sha256)).rejects.toThrow(); +}); +it('does not issue a receipt on failed staging or intervening input during its write', async () => { + fail = true; + await expect(store.stage(target, 'alice', 'hello')).rejects.toThrow(); + fail = false; + composer = ''; + const altered = { + ...target, + write: async (s: string) => { + await target.write(s); + revision++; + return true; + }, + }; + await expect(store.stage(altered, 'alice', 'hello')).rejects.toThrow(); +}); + +it('reads exact composer spaces and refuses wrapped or modal frames', async () => { + const { readGuardedCodexComposer } = await import('../src/agent-staging.js'); + expect(readGuardedCodexComposer('› hello world\n GPT-6-Luna default · ~/case\n')).toBe('hello world'); + expect( + readGuardedCodexComposer( + '› \x1b[2mAsk Codex to do anything\x1b[0m\n GPT-6-Luna default · ~/case\n ← for agents · ? for shortcuts' + ) + ).toBe(''); + expect(readGuardedCodexComposer('› wrapped\n more text\n GPT-6-Luna default · ~/case')).toBeNull(); + expect(readGuardedCodexComposer('› 1. Yes\n Press enter to confirm')).toBeNull(); + expect(readGuardedCodexComposer('› hello\n')).toBeNull(); +}); +it('requires capture cursor geometry, rejecting invisible trailing edits and moved cursors', async () => { + const { readGuardedCodexSnapshot } = await import('../src/agent-staging.js'); + const { formatPaneSnapshot } = await import('../src/tmux-manager.js'); + const frame = (text: string, x: number, y = 0) => + formatPaneSnapshot([`› ${text}`, ' GPT-6-Luna default · ~/case'], { cols: 100, rows: 20, cursorX: x, cursorY: y }); + expect(readGuardedCodexSnapshot(frame('hello', 7))).toBe('hello'); + expect(readGuardedCodexSnapshot(frame('hello', 8))).toBeNull(); + expect(readGuardedCodexSnapshot(frame('hello', 4))).toBeNull(); + expect(readGuardedCodexSnapshot(frame('hello', 7, 1))).toBeNull(); + expect(readGuardedCodexSnapshot('› hello\n GPT-6-Luna default · ~/case')).toBeNull(); + expect(readGuardedCodexSnapshot(frame('你好', 6))).toBe('你好'); + expect(readGuardedCodexSnapshot(frame('\x1b[38;2;2;100;100mhello\x1b[0m', 7))).toBe('hello'); +}); +it('reserves receipt capacity before concurrent stages type', async () => { + store = new GuardedStageStore(() => time, 1, 1000); + let release: (() => void) | undefined; + const firstTarget = { + ...target, + write: async (text: string) => { + await new Promise((resolve) => { + release = resolve; + }); + return target.write(text); + }, + }; + const first = store.stage(firstTarget, 'alice', 'hello'); + const otherTarget = { ...target, id: 'other' }; + await expect(store.stage(otherTarget, 'alice', 'other')).rejects.toThrow(/capacity/i); + expect(writes).toEqual([]); + release!(); + await first; +}); diff --git a/test/cli-agent-inspection.test.ts b/test/cli-agent-inspection.test.ts new file mode 100644 index 000000000..a0b023a7e --- /dev/null +++ b/test/cli-agent-inspection.test.ts @@ -0,0 +1,94 @@ +/** @fileoverview Agent inspect/stage/submit transport, refusal and file boundaries. */ +import { expect, it } from 'vitest'; +import { mkdtemp, writeFile, rm } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { + agentInspect, + agentStage, + agentSubmit, + registerAgentCommands, + type AgentDeps, + type RequestOptions, +} from '../src/cli-agent.js'; +import { Command } from 'commander'; +const SELF = '11111111-1111-4111-8111-111111111111'; +const OTHER = '22222222-2222-4222-8222-222222222222'; +function deps() { + const calls: RequestOptions[] = [], + output: string[] = []; + const deps: AgentDeps = { + ctx: { apiUrl: 'http://localhost:1', selfId: SELF }, + json: true, + now: () => 1, + io: { out: (line) => output.push(line), err: () => {} }, + request: async (_ctx, options) => { + calls.push(options); + return { + status: 200, + text: '', + json: { success: true, data: { evidence: 'hello', stageId: OTHER, sha256: 'a'.repeat(64) } }, + }; + }, + }; + return { deps, calls, output }; +} +it('inspect uses the ownership-checked versioned endpoint with exact bounds and cursor', async () => { + const d = deps(); + expect(await agentInspect(d.deps, { id: OTHER, maxBytes: 42, since: 'cursor' })).toBe(0); + expect(d.calls[0]).toMatchObject({ + method: 'GET', + path: `/api/v1/sessions/${OTHER}/inspect`, + query: { maxBytes: 42, since: 'cursor' }, + }); + expect(JSON.parse(d.output[0]).evidence).toBe('hello'); + expect(await agentInspect(d.deps, { id: OTHER, maxBytes: 65537 })).toBe(4); + expect(d.calls).toHaveLength(1); +}); +it('stage reads a bounded UTF8 file and never adds Enter; submit sends only receipt and hash', async () => { + const dir = await mkdtemp(join(tmpdir(), 'agent-stage-cli-')); + try { + const path = join(dir, 'message'); + await writeFile(path, 'hello'); + const d = deps(); + expect(await agentStage(d.deps, { id: OTHER, messageFile: path })).toBe(0); + expect(d.calls[0]).toMatchObject({ + method: 'POST', + path: `/api/v1/sessions/${OTHER}/stage`, + body: { message: 'hello' }, + }); + expect(await agentSubmit(d.deps, { id: OTHER, stageId: OTHER, confirmSha: 'a'.repeat(64) })).toBe(0); + expect(d.calls[1].body).toEqual({ stageId: OTHER, confirmSha: 'a'.repeat(64) }); + await writeFile(path, 'hello\n'); + expect(await agentStage(d.deps, { id: OTHER, messageFile: path })).toBe(4); + await writeFile(path, Buffer.from([0xff])); + expect(await agentStage(d.deps, { id: OTHER, messageFile: path })).toBe(4); + await writeFile(path, 'x'.repeat(8193)); + expect(await agentStage(d.deps, { id: OTHER, messageFile: path })).toBe(4); + expect(d.calls).toHaveLength(2); + } finally { + await rm(dir, { recursive: true, force: true }); + } +}); +it('refuses self-target and malformed submission before transport', async () => { + const d = deps(); + expect(await agentStage(d.deps, { id: SELF, messageFile: '/nonexistent' })).toBe(4); + expect(await agentSubmit(d.deps, { id: SELF, stageId: OTHER, confirmSha: 'a'.repeat(64) })).toBe(4); + expect(await agentSubmit(d.deps, { id: OTHER, stageId: OTHER, confirmSha: 'bad' })).toBe(4); + expect(d.calls).toHaveLength(0); +}); +it('registers required file and confirmation options and bounded inspect flags', () => { + const program = new Command(); + registerAgentCommands(program); + const commands = program.commands.find((command) => command.name() === 'agent')!.commands; + for (const name of ['inspect', 'stage', 'submit']) + expect(commands.some((command) => command.name() === name)).toBe(true); + expect( + commands.find((command) => command.name() === 'stage')!.options.find((option) => option.long === '--message-file') + ?.mandatory + ).toBe(true); + expect( + commands.find((command) => command.name() === 'submit')!.options.find((option) => option.long === '--confirm-sha') + ?.mandatory + ).toBe(true); +}); diff --git a/test/mocks/mock-session.ts b/test/mocks/mock-session.ts index 665cffbe1..fa2b8c5e8 100644 --- a/test/mocks/mock-session.ts +++ b/test/mocks/mock-session.ts @@ -107,6 +107,10 @@ export class MockSession extends EventEmitter { /** Mirrors Session.trackUserInput (the send-key route feeds it around the write path). */ trackUserInput(_data: string): void {} + async performAuxiliaryInput(operation: () => Promise): Promise { + await operation(); + } + private _muxName: string | null = null; constructor(id: string = 'mock-session-id') { diff --git a/test/routes/agent-session-routes.test.ts b/test/routes/agent-session-routes.test.ts new file mode 100644 index 000000000..02cec22b5 --- /dev/null +++ b/test/routes/agent-session-routes.test.ts @@ -0,0 +1,220 @@ +/** @fileoverview Agent inspection and guarded input HTTP contracts (no live ports). */ +import { afterEach, describe, expect, it } from 'vitest'; +import { registerSessionRoutes } from '../../src/web/routes/session-routes.js'; +import { createRouteTestHarness, type RouteTestHarness } from './_route-test-utils.js'; + +let harness: RouteTestHarness; +afterEach(async () => { + await harness?.app.close(); +}); +describe('agent session routes', () => { + it('returns an explicitly bounded pane snapshot when no attributed transcript exists', async () => { + harness = await createRouteTestHarness(registerSessionRoutes); + const session = [...harness.ctx.sessions.values()][0]; + const result = await harness.app.inject({ url: `/api/sessions/${session.id}/inspect?maxBytes=20` }); + expect(result.statusCode).toBe(200); + const body = result.json(); + expect(body.data.source).toBe('pane-snapshot'); + expect(body.data.cursor).toBeNull(); + expect(Buffer.byteLength(body.data.evidence)).toBeLessThanOrEqual(20); + }); + it('refuses guarded staging for an uncharacterized composer', async () => { + harness = await createRouteTestHarness(registerSessionRoutes); + const session = [...harness.ctx.sessions.values()][0]; + const result = await harness.app.inject({ + method: 'POST', + url: `/api/sessions/${session.id}/stage`, + payload: { message: 'hello' }, + }); + expect(result.statusCode).toBe(409); + }); +}); + +it('checks ownership before inspection, stage and submit', async () => { + process.env.CODEMAN_MULTIUSER = '1'; + try { + harness = await createRouteTestHarness(registerSessionRoutes, { authUser: { username: 'alice', role: 'user' } }); + const session = [...harness.ctx.sessions.values()][0]; + session.owner = 'bob'; + for (const request of [ + { method: 'GET' as const, url: `/api/sessions/${session.id}/inspect` }, + { method: 'POST' as const, url: `/api/sessions/${session.id}/stage`, payload: { message: 'hello' } }, + { + method: 'POST' as const, + url: `/api/sessions/${session.id}/submit`, + payload: { stageId: '11111111-1111-4111-8111-111111111111', confirmSha: 'a'.repeat(64) }, + }, + ]) + expect((await harness.app.inject(request)).statusCode).toBe(404); + expect(session.writeBuffer).toEqual([]); + } finally { + delete process.env.CODEMAN_MULTIUSER; + } +}); +it('validates output budgets and refuses to resume a pane fallback', async () => { + harness = await createRouteTestHarness(registerSessionRoutes); + const session = [...harness.ctx.sessions.values()][0]; + session.terminalBuffer = '😀é'.repeat(20); + for (const value of ['0', '-1', '65537', '1.5', 'NaN']) + expect( + (await harness.app.inject({ url: `/api/sessions/${session.id}/inspect?maxBytes=${value}` })).statusCode + ).toBe(400); + const result = (await harness.app.inject({ url: `/api/sessions/${session.id}/inspect?maxBytes=7` })).json(); + expect(result.data.evidence).not.toContain('�'); + expect(Buffer.byteLength(result.data.evidence)).toBeLessThanOrEqual(7); + expect(result.data.truncated).toBe(true); + expect( + ( + await harness.app.inject({ + url: `/api/sessions/${session.id}/inspect?since=11111111-1111-4111-8111-111111111111`, + }) + ).statusCode + ).toBe(409); +}); +it('uses real session writes to stage without Enter and refuses intervening API input', async () => { + const { Session } = await import('../../src/session.js'); + const { formatPaneSnapshot } = await import('../../src/tmux-manager.js'); + harness = await createRouteTestHarness(registerSessionRoutes); + let composer = ''; + const writes: string[] = []; + const session = new Session({ + id: 'guarded-session', + workingDir: '/tmp', + mode: 'codex', + mux: { + isAvailable: () => true, + capturePaneBuffer: () => + formatPaneSnapshot([`› ${composer}`, ' GPT-6-Luna default · ~/case'], { + cols: 100, + rows: 20, + cursorX: 2 + composer.length, + cursorY: 0, + }), + sendInput: async (_id: string, text: string, options?: { beforeDispatch?: () => boolean }) => { + if (options?.beforeDispatch && !options.beforeDispatch()) return false; + writes.push(text); + composer += text; + return true; + }, + }, + muxSession: { muxName: 'codeman-test', sessionId: 'guarded-session', createdAt: 1, pid: 1 }, + } as unknown as ConstructorParameters[0]); + (harness.ctx.sessions as unknown as Map).set(session.id, session); + try { + const stage = await harness.app.inject({ + method: 'POST', + url: `/api/sessions/${session.id}/stage`, + payload: { message: 'hello' }, + }); + expect(stage.statusCode).toBe(200); + expect(writes).toEqual(['hello']); + const { stageId, sha256 } = stage.json().data; + const submit = () => + harness.app.inject({ + method: 'POST', + url: `/api/sessions/${session.id}/submit`, + payload: { stageId, confirmSha: sha256 }, + }); + expect((await submit()).statusCode).toBe(200); + expect(writes).toEqual(['hello', '\r']); + expect((await submit()).statusCode).toBe(409); + composer = ''; + const next = ( + await harness.app.inject({ + method: 'POST', + url: `/api/sessions/${session.id}/stage`, + payload: { message: 'again' }, + }) + ).json().data; + await session.writeViaMux('x'); + composer = 'again'; // Even restoring the visible text cannot erase the input revision. + const stale = await harness.app.inject({ + method: 'POST', + url: `/api/sessions/${session.id}/submit`, + payload: { stageId: next.stageId, confirmSha: next.sha256 }, + }); + expect(stale.statusCode).toBe(409); + expect(writes.filter((s) => s === '\r')).toHaveLength(1); + } finally { + await session.stop(false); + } +}); +it('reads only a strictly attributed Codex rollout and binds HTTP cursors to its owner', async () => { + const { mkdtemp, mkdir, writeFile, rm } = await import('node:fs/promises'); + const { tmpdir } = await import('node:os'); + const { join } = await import('node:path'); + const { Session } = await import('../../src/session.js'); + const root = await mkdtemp(join(tmpdir(), 'agent-inspect-route-')); + const home = join(root, 'codex'); + await mkdir(join(home, 'sessions'), { recursive: true }); + const session = new Session({ + id: 'inspect-session', + workingDir: root, + mode: 'codex', + envOverrides: { CODEX_HOME: home }, + }); + const id = '11111111-1111-4111-8111-111111111111'; + const row = + JSON.stringify({ type: 'session_meta', payload: { id, cwd: root, originator: `codeman_${session.id}` } }) + '\n'; + await writeFile(join(home, 'sessions', `rollout-${id}.jsonl`), row + '{"text":"hello"}\n'); + harness = await createRouteTestHarness(registerSessionRoutes, { authUser: { username: 'alice', role: 'admin' } }); + (harness.ctx.sessions as unknown as Map).set(session.id, session); + try { + const first = await harness.app.inject({ + url: `/api/sessions/${session.id}/inspect?maxBytes=${Buffer.byteLength(row)}`, + }); + expect(first.statusCode).toBe(200); + const data = first.json().data; + expect(data.source).toBe('codex-jsonl'); + expect(data.evidence).toBe('{\"text\":\"hello\"}\n'); + expect(data.omittedBeforeBytes).toBe(Buffer.byteLength(row)); + expect(data.identity).toEqual({ sessionId: session.id, mode: 'codex', conversationId: id }); + expect(data.cursor).not.toContain(home); + const next = await harness.app.inject({ url: `/api/sessions/${session.id}/inspect?since=${data.cursor}` }); + expect(next.json().data.evidence).toBe(''); + session.owner = 'changed-owner'; + expect( + (await harness.app.inject({ url: `/api/sessions/${session.id}/inspect?since=${data.cursor}` })).statusCode + ).toBe(409); + } finally { + await session.stop(false); + await rm(root, { recursive: true, force: true }); + } +}); + +it('refuses a stale launch-resume cursor once a new managed rollout conflicts with it', async () => { + const { mkdtemp, mkdir, writeFile, rm } = await import('node:fs/promises'); + const { tmpdir } = await import('node:os'); + const { join } = await import('node:path'); + const { Session } = await import('../../src/session.js'); + const root = await mkdtemp(join(tmpdir(), 'agent-resume-conflict-')); + const home = join(root, 'codex'); + const old = '11111111-1111-4111-8111-111111111111', + fresh = '22222222-2222-4222-8222-222222222222'; + await mkdir(join(home, 'sessions'), { recursive: true }); + const session = new Session({ + id: 'resumed-session', + workingDir: root, + mode: 'codex', + envOverrides: { CODEX_HOME: home }, + codexConfig: { resumeSessionId: old }, + }); + const row = (id: string, originator: string) => + JSON.stringify({ type: 'session_meta', payload: { id, cwd: root, originator } }) + '\n'; + await writeFile(join(home, 'sessions', `rollout-${old}.jsonl`), row(old, 'original-launch')); + harness = await createRouteTestHarness(registerSessionRoutes); + (harness.ctx.sessions as unknown as Map).set(session.id, session); + try { + const initial = (await harness.app.inject({ url: `/api/sessions/${session.id}/inspect` })).json().data; + expect(initial.source).toBe('codex-jsonl'); + await writeFile(join(home, 'sessions', `rollout-${fresh}.jsonl`), row(fresh, `codeman_${session.id}`)); + const stale = await harness.app.inject({ url: `/api/sessions/${session.id}/inspect?since=${initial.cursor}` }); + expect(stale.statusCode).toBe(409); + const fallback = (await harness.app.inject({ url: `/api/sessions/${session.id}/inspect` })).json().data; + expect(fallback.source).toBe('pane-snapshot'); + expect(fallback.cursor).toBeNull(); + } finally { + await session.stop(false); + await rm(root, { recursive: true, force: true }); + } +}); diff --git a/test/terminal-input-classifier.test.ts b/test/terminal-input-classifier.test.ts new file mode 100644 index 000000000..3f5b6bf8f --- /dev/null +++ b/test/terminal-input-classifier.test.ts @@ -0,0 +1,37 @@ +import { describe, expect, it } from 'vitest'; +import { isKeyboardTerminalInput } from '../src/terminal-input-classifier.js'; + +describe('isKeyboardTerminalInput', () => { + it.each([ + ['focus in', '\x1b[I'], + ['focus out', '\x1b[O'], + ['SGR press', '\x1b[<0;10;5M'], + ['SGR release', '\x1b[<0;10;5m'], + ['SGR wheel', '\x1b[<64;999;1000M'], + ['X10 mouse', '\x1b[M !!'], + ['coalesced reports', '\x1b[<0;10;5M\x1b[O\x1b[M !!'], + ])('classifies %s as report-only', (_name, data) => { + expect(isKeyboardTerminalInput(data)).toBe(false); + }); + + it.each([ + ['empty', ''], + ['text', 'continue'], + ['paste', 'git status'], + ['Enter', '\r'], + ['Escape', '\x1b'], + ['arrow', '\x1b[A'], + ['control key', '\x03'], + ['truncated SGR', '\x1b[<0;10;5'], + ['truncated X10', '\x1b[M !'], + ['X10 with a control button byte', '\x1b[M\r!!'], + ['X10 with a control coordinate byte', '\x1b[M \x1b!'], + ['X10-shaped navigation input', '\x1b[M\x1b[A'], + ['X10 with a zero coordinate', '\x1b[M !'], + ['report then text', '\x1b[<0;10;5Mx'], + ['text then report', 'x\x1b[<0;10;5M'], + ['report then Enter', '\x1b[O\r'], + ])('classifies %s as keyboard-bearing', (_name, data) => { + expect(isKeyboardTerminalInput(data)).toBe(true); + }); +}); diff --git a/test/tmux-submit-paste.test.ts b/test/tmux-submit-paste.test.ts index e0a3fa887..c131c4e5c 100644 --- a/test/tmux-submit-paste.test.ts +++ b/test/tmux-submit-paste.test.ts @@ -229,6 +229,21 @@ describe('sendInput line breaks', () => { return argvOf(setBufferCmd.replace(/^.*? set-buffer /, `${FAKE_TMUX} set-buffer `)).at(-1) as string; } + it('checks a conditional Enter before the actual inputRunner command', async () => { + const manager = managerWithSession(); + try { + expect(await manager.sendInput('s1', '\r', { beforeDispatch: () => false })).toBe(false); + expect(manager.ran).toEqual([]); + expect(await manager.sendInput('s1', '\r', { beforeDispatch: () => true })).toBe(true); + expect(manager.ran).toEqual([expect.stringMatching(/ Enter$/)]); + manager.ran = []; + expect(await manager.sendInput('s1', 'draft\r', { beforeDispatch: () => true })).toBe(false); + expect(manager.ran).toEqual([]); + } finally { + manager.destroy(); + } + }); + it('submits a multi-line prompt as one paste with its line breaks, then Enter', async () => { const manager = managerWithSession(); expect(await manager.sendInput('s1', 'line one\nline two\n\nparagraph three\r')).toBe(true); From 4c2f050a96dbd4cf4c11bd2fd3e76bdc4ba59d02 Mon Sep 17 00:00:00 2001 From: Aamer Akhter Date: Fri, 23 Oct 2026 16:22:46 -0400 Subject: [PATCH 19/68] fix(agent): address inspection and staging review feedback --- .changeset/bounded-agent-coordination.md | 2 + CLAUDE.md | 5 +- docs/api-reference.md | 19 +- docs/architecture-invariants.md | 27 +++ .../reference/inspection-and-staging.md | 50 ++++- .../codeman/skills/codeman/reference/verbs.md | 6 +- .../reference/inspection-and-staging.md | 50 ++++- skills/codeman/reference/verbs.md | 6 +- src/agent-codex-identity.ts | 34 ++-- src/agent-inspection.ts | 2 +- src/agent-staging.ts | 34 ++-- src/mux-interface.ts | 4 +- src/session.ts | 60 +++--- src/web/routes/agent-session-routes.ts | 2 +- src/web/routes/index.ts | 2 + src/web/routes/session-routes.ts | 3 - src/web/server.ts | 2 + test/agent-codex-identity.test.ts | 37 ++++ test/agent-inspection.test.ts | 19 ++ test/agent-staging-session.test.ts | 173 ++++++++++++++++-- test/agent-staging.test.ts | 19 +- test/routes/agent-session-routes.test.ts | 39 ++-- test/tmux-submit-paste.test.ts | 10 + test/webserver-bound-port.test.ts | 28 +++ 24 files changed, 517 insertions(+), 116 deletions(-) diff --git a/.changeset/bounded-agent-coordination.md b/.changeset/bounded-agent-coordination.md index 1122ed67c..1239817a5 100644 --- a/.changeset/bounded-agent-coordination.md +++ b/.changeset/bounded-agent-coordination.md @@ -5,3 +5,5 @@ Add bounded agent inspection with expiring scoped cursors and explicit pane snapshots, plus staged local Codex input and single-use hash-confirmed submission receipts. Verify the exact composer, caret, lifecycle and input revision before sending Enter, and serialize dispatch against managed input and auxiliary key operations. Document the initial local Codex support and conversation-attribution limits in the API, CLI and packaged skill. Other modes retain labeled snapshots or refuse unverifiable guarded input. + +SubmitVerifier Enter re-presses now use the shared input boundary and update `lastSubmitAt` for each retry on every supported CLI. This keeps submission timestamps consistent with delivered Enter attempts while preventing a stale retry from bypassing a guarded submission. diff --git a/CLAUDE.md b/CLAUDE.md index bd2594acd..683084f98 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -129,9 +129,10 @@ Codeman is a Claude Code session manager with web interface and autonomous Ralph **Agent wait primitives**: bounded long-polls: `GET /api/sessions/:id/wait`, `GET .../wait-output` (literal substring, **never** regex) and `wait`/`waitTimeout` on `POST .../input`. Registry `session-wait-registry.ts` (pure), bounds `config/agent-wait.ts`. ⚠️ A timeout is a 200 (`wait.timedOut`). ⚠️ `stop` exists for `claude` and `deepseek` ONLY (rule lives in `hooksAvailableForMode()`), `blocked` also for a CLI declaring `capabilities.workDetect.blockedLine` (codex, read off the screen; `blockedSignalAvailable()`): explicit request elsewhere is a 400. ⚠️ Send-and-wait registers the waiter BEFORE the write; teardown must `notifySignal('exit')` BEFORE `cancelAll()`; hangup abort listens on `reply.raw` (guarded by `writableFinished`), never `req.raw`; liveness comes from `isPaneDead`, never `session.pid`. ⚠️ Signals are edge-triggered with no history: gather fan-outs via send-and-wait or `wait-output` markers. Packaged as the `skills/codeman` skill (`codeman skill install`, plugin marketplace, or injection behind `agentSkillEnabled`, SYNCED, default OFF, into the directory each CLI scans: `capabilities.agentSkillDir`, `.claude/skills` for claude, `.agents/skills` for the others, none for pi): injection is add-only, marker-owned (`applyAgentSkill`), refuses symlinks, and refreshes a marker-owned user-level copy (`refreshUserAgentSkill`). → [architecture-invariants#agent-wait-primitives](docs/architecture-invariants.md#agent-wait-primitives), `docs/api-reference.md` -**Agent CLI (`codeman agent …`, `src/cli-agent.ts`)**: the command-line client for the wait primitives and the session verbs, for agents the skill does not reach (pi, shell, no `jq`; `seedAgentSessionPreamble` covers every local session whose CLI takes the skill, `capabilities.agentSkillDir`; every pane still exports `CODEMAN_MUX`/`CODEMAN_SESSION_ID`/`CODEMAN_API_URL`). It is a CLIENT: no new route, no second transport (the plan's "Socket API" non-goal stands), every call carries `X-Codeman-Parent-Session`, only `spawn`'s quick-start carries `X-Codeman-Agent-Origin` (pinned in test/cli-agent.test.ts), auth (`src/codeman-credentials.ts`, shared with `codeman attach` and the TUI) from `CODEMAN_PASSWORD` or the data dir's `.env` (the same reader `codeman attach` uses — `readCodemanEnvFile`). Verbs return exit codes (`0` ok / `1` error / `2` timeout / `3` exited / `4` refused) instead of calling `process.exit`, so `test/cli-agent.test.ts` drives every verb against a recording fake transport plus the real `httpRequest` against a local server. ⚠️ Three guards live in code, not prose, and the tests pin them: refuse without `CODEMAN_MUX=1`+`CODEMAN_API_URL` (never guess a URL); `send` transmits printable text + `\r` only (`inputRefusal`: any byte < 0x20 or DEL refuses BEFORE the transport — opencode's `Ctrl+C` is `app_exit`), ESC exists solely as `interrupt` with no `\r`; `rm` fails closed (`deleteRefusal`: empty id, self shorter than 8, prefix match in EITHER direction, re-checked on the resolved id). ⚠️ The routes accept FULL ids only — an 8-char prefix is a 404 (measured live) — while `ls` prints prefixes, so `resolveSessionId` maps a short id through `GET /api/v1/sessions` and refuses an ambiguous one. It also refuses any id under 8 characters (exit 4, on every verb, before any request), the same floor as the server's `PARENT_SESSION_ID_MIN_PREFIX`, so `rm 9` can never pick an arbitrary session. ⚠️ A 400 for `--until stop` on a hook-less mode is passed through verbatim, never papered over: the marker path is the answer there. `spawn` on a readiness timeout exits 2 and LEAVES the session for inspection (the skill's `spawn_worker` deletes it; the CLI's caller is often a human); its stdout is the id ALONE (prose on stderr) so `SID=$(…)` works. ⚠️ Two server answers that look like success and are not: `delivered:false` without `duplicate` (the bytes went into a dead pane — the field exists so a client says "restart" instead of "wait longer") and `wait.ended:true, signal:null` (the worker died during a `stop`/`idle` wait: the registry satisfies only waiters that listed `exit`, then cancels the rest) — both exit 3, checked BEFORE the happy paths in `waitExitCode`. → README "`codeman agent`", `docs/api-reference.md` +**Agent CLI (`codeman agent …`, `src/cli-agent.ts`)**: the command-line client for the wait primitives and the session verbs, for agents the skill does not reach (pi, shell, no `jq`; `seedAgentSessionPreamble` covers every local session whose CLI takes the skill, `capabilities.agentSkillDir`; every pane still exports `CODEMAN_MUX`/`CODEMAN_SESSION_ID`/`CODEMAN_API_URL`). It is an HTTP CLIENT: inspection and guarded stage/submit use the ownership-checked +`agent-session-routes.ts` module; the other verbs reuse existing routes, with no second transport (the plan's "Socket API" non-goal stands), every call carries `X-Codeman-Parent-Session`, only `spawn`'s quick-start carries `X-Codeman-Agent-Origin` (pinned in test/cli-agent.test.ts), auth (`src/codeman-credentials.ts`, shared with `codeman attach` and the TUI) from `CODEMAN_PASSWORD` or the data dir's `.env` (the same reader `codeman attach` uses — `readCodemanEnvFile`). Verbs return exit codes (`0` ok / `1` error / `2` timeout / `3` exited / `4` refused) instead of calling `process.exit`, so `test/cli-agent.test.ts` drives every verb against a recording fake transport plus the real `httpRequest` against a local server. ⚠️ Three guards live in code, not prose, and the tests pin them: refuse without `CODEMAN_MUX=1`+`CODEMAN_API_URL` (never guess a URL); `send` transmits printable text + `\r` only (`inputRefusal`: any byte < 0x20 or DEL refuses BEFORE the transport — opencode's `Ctrl+C` is `app_exit`), ESC exists solely as `interrupt` with no `\r`; `rm` fails closed (`deleteRefusal`: empty id, self shorter than 8, prefix match in EITHER direction, re-checked on the resolved id). ⚠️ The routes accept FULL ids only — an 8-char prefix is a 404 (measured live) — while `ls` prints prefixes, so `resolveSessionId` maps a short id through `GET /api/v1/sessions` and refuses an ambiguous one. It also refuses any id under 8 characters (exit 4, on every verb, before any request), the same floor as the server's `PARENT_SESSION_ID_MIN_PREFIX`, so `rm 9` can never pick an arbitrary session. ⚠️ A 400 for `--until stop` on a hook-less mode is passed through verbatim, never papered over: the marker path is the answer there. `spawn` on a readiness timeout exits 2 and LEAVES the session for inspection (the skill's `spawn_worker` deletes it; the CLI's caller is often a human); its stdout is the id ALONE (prose on stderr) so `SID=$(…)` works. ⚠️ Two server answers that look like success and are not: `delivered:false` without `duplicate` (the bytes went into a dead pane — the field exists so a client says "restart" instead of "wait longer") and `wait.ended:true, signal:null` (the worker died during a `stop`/`idle` wait: the registry satisfies only waiters that listed `exit`, then cancels the rest) — both exit 3, checked BEFORE the happy paths in `waitExitCode`. → README "`codeman agent`", `docs/api-reference.md` -**MCP server (`codeman mcp`, `src/mcp-server.ts`)**: a Model Context Protocol server on stdio for any MCP client, a CLIENT of the HTTP API like `codeman agent` (reuses its `httpRequest`, `inputRefusal`, `buildSendBody`; no new route). Seven tools: `list_sessions`, `start_session`, `send_prompt`, `wait_for_session`, `read_output`, `interrupt_session`, `delete_session`. ⚠️ Dual-era: answers the legacy `initialize` handshake (2024-11-05 to 2025-11-25) AND the 2026-07-28 revision (per-request `_meta` with `protocolVersion` + `clientCapabilities`, `server/discover`, `resultType: "complete"` on every modern result); a modern `tools/list`/`server/discover` MUST carry `ttlMs` + `cacheScope` (Claude Code 2.1.296 drops every tool without them: measured). ⚠️ stdout is protocol only; logs go to stderr. ⚠️ The URL comes from `--url`/`CODEMAN_API_URL`, never a guess, and unconfigured it still lists tools (registries introspect) while every call answers how to configure it. ⚠️ Prompt/interrupt/delete only sessions the PROCESS started (in-memory allowlist, so a restart fails closed) or, inside a pane, the calling session's own children, unless `--allow-any-session`; never its own session. ⚠️ `read_output` returns text ONLY, no `structuredContent` (a client that prefers it showed the model metadata instead of the output: measured). Waits default 45 s (Codex's MCP tool timeout is 60 s). Registry manifest `config/mcp-registry/server.json` (`mcpName` in package.json, version kept by `scripts/sync-plugin.mjs`). Tests: `test/mcp-server.test.ts`. +**MCP server (`codeman mcp`, `src/mcp-server.ts`)**: a Model Context Protocol server on stdio for any MCP client, a CLIENT of the HTTP API like `codeman agent` (reuses its `httpRequest`, `inputRefusal`, `buildSendBody`; the MCP server adds no routes). Seven tools: `list_sessions`, `start_session`, `send_prompt`, `wait_for_session`, `read_output`, `interrupt_session`, `delete_session`. ⚠️ Dual-era: answers the legacy `initialize` handshake (2024-11-05 to 2025-11-25) AND the 2026-07-28 revision (per-request `_meta` with `protocolVersion` + `clientCapabilities`, `server/discover`, `resultType: "complete"` on every modern result); a modern `tools/list`/`server/discover` MUST carry `ttlMs` + `cacheScope` (Claude Code 2.1.296 drops every tool without them: measured). ⚠️ stdout is protocol only; logs go to stderr. ⚠️ The URL comes from `--url`/`CODEMAN_API_URL`, never a guess, and unconfigured it still lists tools (registries introspect) while every call answers how to configure it. ⚠️ Prompt/interrupt/delete only sessions the PROCESS started (in-memory allowlist, so a restart fails closed) or, inside a pane, the calling session's own children, unless `--allow-any-session`; never its own session. ⚠️ `read_output` returns text ONLY, no `structuredContent` (a client that prefers it showed the model metadata instead of the output: measured). Waits default 45 s (Codex's MCP tool timeout is 60 s). Registry manifest `config/mcp-registry/server.json` (`mcpName` in package.json, version kept by `scripts/sync-plugin.mjs`). Tests: `test/mcp-server.test.ts`. **Agent-created case marker** (`src/agent-case-marker.ts`): a case dir that `POST /api/quick-start` CREATES for an agent-driven spawn (signal: the preamble's `X-Codeman-Agent-Origin` header / `agentOrigin` field, else a resolved `parentSessionId`) gets `.codeman-agent-case.json`, published as `agentCreated` on `GET /api/cases`; `GET /api/cases/agent-created` is the cleanup listing (`inUse`, `modifiedAt`) behind Add Case → Manage. ⚠️ Only the branch that CREATES the directory may write it: never label a linked case, cloned repo or pre-existing path (it drives a recursive delete). ⚠️ Reading is total: anything but a well-formed v1 marker reads as not agent-created. ⚠️ Removal stays on `DELETE /api/cases/:name` (the ONE recursive-delete path), and the sweep excludes `inUse` cases. ⚠️ Changing the preamble's headers requires bumping `CODEMAN_PREAMBLE`. → [architecture-invariants#agent-created-case-marker](docs/architecture-invariants.md#agent-created-case-marker) diff --git a/docs/api-reference.md b/docs/api-reference.md index dd538bd3c..ca04f1cf0 100644 --- a/docs/api-reference.md +++ b/docs/api-reference.md @@ -608,8 +608,13 @@ The initial read selects recent complete records; `omittedBeforeBytes` explicitl labels its tail omission. Cursor reads consume only complete appended records, keeping UTF8/JSONL boundaries. Evidence bytes exclude response metadata and JSON escaping. Record scans cap at 256 KiB plus bounded identity anchors; attribution -separately caps at 8192 directory entries, eight directory levels and 1 MiB of -metadata reads. Oversized/unfinished records report `blockedReason`; incremental +separately caps at 8192 directory entries, eight directory levels, 8 MiB of +negative-filter prefix reads and 1 MiB of candidate metadata reads (including +final identity verification). Unreachable workspace/home/metadata directories +are probed with a bounded past-cap probe before canonical reads and remain +unattributed. A full incremental page can be continued with its cursor; only a +first pending record too large for the budget reports an output-budget block. +Oversized/unfinished records report `blockedReason`; incremental reads do not silently skip them. Other modes, remote/container sessions, and unprovable native attribution return `source:"pane-snapshot"`, `incremental:false`, `cursor:null`; these snapshots can repeat evidence. @@ -622,7 +627,8 @@ conversation identity and do not use these observations as proof of the active thread, task completion or authorization. Opaque server-side cursors bind caller, session, pane generation, conversation, -opened-file identity, byte offset and bounded content anchors. At most 1024 live +opened-file identity, byte offset and bounded content anchors. Terminal resizes +do not change this inspection scope. At most 1024 live cursors are retained for 15 minutes; restart, eviction or expiry requires a fresh tail baseline. Replacement, truncation, detected rewrites, reassociation, foreign cursors or loss of the attributed source return `409 CONFLICT`. No transcript @@ -633,7 +639,10 @@ Guarded input initially requires a local characterized Codex composer, one unwrapped visible line, a recognized footer and a cursor at its exact end. Unknown/working/modal/shell/remote/container composers fail closed. Stage accepts at most 8192 UTF8 bytes, no C0/C1/DEL controls, multiline text, or trailing -whitespace (the existing mux transport trims it). Its two-minute receipt stores +whitespace (the existing mux transport trims it). Before typing, it checks the +actual captured pane width and rendered composer indentation, refusing any +message that would reach the right edge or wrap. Every declared Codex effort +word plus `default` is recognized. Its two-minute receipt stores only hashes/ephemeral identities in a bounded in-memory store (1024 live receipts), never persisted prompt content. A receipt acknowledges typing; a delayed screen paint can still make submit refuse until exact verification becomes possible. @@ -644,7 +653,7 @@ input revision; mixed or unknown sequences remain keyboard-bearing. Exact composer/caret checks also reject a mouse-moved caret. Submit consumes an owned receipt before awaited delivery, verifies the caller, -confirmation hash, expiry, lifecycle/input revisions and exact composer/caret, +confirmation hash, expiry, lifecycle/input/geometry revisions and exact composer/caret, then uses a session-level conditional Enter dispatch with a temporary input lease. Competing Codeman writes cannot enter during that dispatch. Managed restarts and intervening browser/API/auxiliary input invalidate receipts independently of pid diff --git a/docs/architecture-invariants.md b/docs/architecture-invariants.md index 7a743bb62..140d394dd 100644 --- a/docs/architecture-invariants.md +++ b/docs/architecture-invariants.md @@ -1260,3 +1260,30 @@ Its CLI knowledge is a GENERATED block (`npm run generate:cli-catalog`, markers ⚠️ It must stay **bash 3.2** clean — macOS ships it and the documented install is `curl | bash` under `set -euo pipefail`, so `declare -A`, `mapfile`, namerefs, `${x,,}` and here-strings are all fatal there; CI runs `bash -n` plus a real `bash:3.2` container, since expanding an EMPTY array under `set -u` is a runtime abort `bash -n` cannot see. ⚠️ It executes ONLY commands from the embedded block (`CLI_INSTALL_CMD_TRUSTED`) — there is no network fetch of the catalogue at install time to worry about at all. + +## Agent inspection and guarded submission + +`agent-session-routes.ts` is registered by `WebServer` with the other route +modules. Ownership is checked before transcript/pane/receipt lookup. Inspection +uses strict native metadata attribution, bounded traversal, separate prefix and +candidate-metadata budgets, bounded path probes before canonical reads, and +complete UTF8 JSONL records. A full page advances its cursor without claiming a +blocked record. Opaque cursors bind caller/session/lifecycle/file identity; pane +geometry is excluded so changing devices does not invalidate transcript paging. + +Staging requires a fresh, known local Codex composer/caret capture. The footer +uses the shared `CODEX_REASONING_EFFORTS` vocabulary. Before any write, available +columns are calculated from actual captured geometry and the rendered prefix, +including indentation; reaching the last column or wrapping is refused. Stage +cancels older verifier retries and sends no Enter. Receipts contain hashes and +bounded ephemeral identities only, expire, and are consumed before awaited +submission, including failures. + +Submission additionally binds input and geometry revisions and rechecks the +exact composer/caret at the conditional Enter boundary. The route must retain +the Session snapshot's staging generation rather than replacing it with the +inspection generation. Managed input and auxiliary named keys participate in the +same pending/lease boundary. Complete focus/mouse reports and empty writes are +not edits. Never retry ambiguous Enter delivery or clear an unverified composer. +The ordinary SubmitVerifier uses the shared write boundary, so each re-press +updates `lastSubmitAt` just like any other Enter. diff --git a/plugins/codeman/skills/codeman/reference/inspection-and-staging.md b/plugins/codeman/skills/codeman/reference/inspection-and-staging.md index 394f6be41..bb15a5e6e 100644 --- a/plugins/codeman/skills/codeman/reference/inspection-and-staging.md +++ b/plugins/codeman/skills/codeman/reference/inspection-and-staging.md @@ -1,9 +1,8 @@ # Bounded inspection and guarded submission -Use these commands inside a Codeman session. They use its authenticated HTTP API; -no direct tmux access, second transport, or session-bridge installation is required. -They supplement `send`, `wait` and `read`. They do not establish full parity with -session-bridge or retire that skill. +Use these commands inside a Codeman session. They use its authenticated HTTP API. +They supplement `send`, `wait` and `read` over the same HTTP transport; no direct +tmux access is required. ## Inspect recent evidence @@ -31,7 +30,8 @@ falls back to a snapshot. An in-CLI `/resume` of an existing foreign-originator thread may leave launch metadata unchanged. Check `identity.conversationId`; these reads do not provide authoritative current-thread tracking. -Cursors are opaque, scoped to the requesting identity, session, pane generation +Terminal resizes preserve inspection cursors. Cursors are opaque, scoped to the +requesting identity, session, pane lifecycle generation and attributed transcript, expire after 15 minutes, and disappear on server restart. At most 1024 are retained; capacity eviction can invalidate an older cursor. A replaced/truncated/rewritten source, reassociated session, foreign cursor @@ -43,7 +43,8 @@ never split or silently skip one. Increase the byte budget for `record-exceeds-output-budget`; when the record exceeds the maximum or scan cap, use `read --tail` for a labeled terminal view or start a fresh tail baseline. Inspect scans at most 256 KiB of record data plus bounded generation anchors. -Attribution separately caps directory entries and metadata bytes. Evidence and +Attribution separately caps directory entries (8192), depth (8), negative-filter +prefix reads (8 MiB) and candidate metadata including final verification (1 MiB). Evidence and activity are observations, not proof of task completion or authorization. ## Stage, inspect, then submit once @@ -58,7 +59,9 @@ codeman agent submit "$SID" --stage-id "$STAGE_ID" --confirm-sha "$SHA256" --jso Stage types the file contents **without Enter**. The file must be regular, UTF8, one printable line, at most 8192 bytes, with no control characters or trailing whitespace/newline. It initially supports a local Codex composer with a recognized -footer and an exact, unwrapped line/caret capture. Unknown, working, modal, +footer and an exact, unwrapped line/caret capture. Before typing, stage checks +the actual captured width and composer indentation; a message that would reach +the right edge or wrap is refused without writing anything. Unknown, working, modal, wrapped, remote/container and shell composers are refused. A customized footer or unsupported character-width rendering can also make verification unavailable. Self-targets are refused by the CLI. @@ -70,7 +73,7 @@ requires the fully painted exact composer. If typing fails or is ambiguous, stag issues no receipt; inspect the pane before trying again. Submit checks the caller, session, hash, expiry, pane lifecycle, input revision, -exact composer and caret, then sends one conditional Enter at the session's input +geometry, exact composer and caret, then sends one conditional Enter at the session's input boundary. Competing Codeman writes are refused during dispatch. Browser/API/key input and managed restarts invalidate receipts even when the visible text or pid is restored. Direct tmux edits are outside that input serialization; captures @@ -82,3 +85,34 @@ replay Enter, and there is no autonomous retry. A successful response means one Enter was delivered; use inspection or a suitable wait signal to observe native acceptance. A `409` or broken connection means inspect first. Do not automatically stage/submit again or send an unguarded Enter to recover. + +## Recover after a refused submit + +A consumed receipt cannot be reused. First observe whether the prompt was +accepted: use transcript evidence/wait signals and a fresh terminal capture +(`GET /api/v1/sessions/:id/terminal?tail=8192`). If the target is working, has +accepted the prompt, shows a dialog, or delivery is ambiguous, keep observing; +do not clear or resend anything. A transcript prompt row alone is not proof +that text remains unsent in the current composer. + +Only recover an unambiguously unsent draft in an input-ready local Codex +composer: verify the complete draft, recognized footer and caret at its end in +the current frame, and ensure no other writer can edit this target during +recovery. If the frame is wrapped, incomplete or unclear, ask the user to clear +it in the terminal instead of guessing keys. Never use `send` to overwrite a +draft: that appends and submits both prompts together. + +After those checks, the known Codex line-clear chord may be sent once through +the existing raw input route, with **no Enter**. This conditional example uses +the authenticated `CURL`/`API` values loaded by §0 and the exact target id: + +```bash +"${CURL[@]}" -X POST "$API/api/v1/sessions/$SID/input" \ + -H 'Content-Type: application/json' -d '{"input":"\u0015","useMux":false}' +``` + +Do not retry a failed/ambiguous clear blindly. Read a fresh terminal frame and +verify the composer is empty and input-ready. Only then create a **fresh** stage +receipt for the intended prompt and submit that receipt once. If the draft did +not clear, the target began working, or verification remains uncertain, stop and +ask the user; do not send an unguarded Enter or replay the old prompt. diff --git a/plugins/codeman/skills/codeman/reference/verbs.md b/plugins/codeman/skills/codeman/reference/verbs.md index 3f511649f..dd68d64d2 100644 --- a/plugins/codeman/skills/codeman/reference/verbs.md +++ b/plugins/codeman/skills/codeman/reference/verbs.md @@ -550,8 +550,10 @@ this (`src/web/routes/approval-routes.ts:43`). ESC is then claude's own interrup that half is the CLI's behavior, not something this API guarantees. - **This is not the composer-clearing tool.** Esc (and Ctrl+U) do **not** clear a - typed-but-unsubmitted prompt, verified live. The only recovery there is to submit it - with `{"input":"\r"}` and let the worker read the junk line. + typed-but-unsubmitted Claude prompt, verified live. Do not submit an unverified + draft merely to clear it. For a refused guarded local Codex submit, use the + verified-unsent recovery in [inspection and staging](inspection-and-staging.md#recover-after-a-refused-submit); + when the composer is unclear, ask the user to clear it in the terminal. - The interrupted turn already burned its tokens. Interrupting early saves the rest. - `POST /api/sessions/:id/send-key` is a different endpoint and cannot do this: its allowlist is S-Enter / C-Enter only. diff --git a/skills/codeman/reference/inspection-and-staging.md b/skills/codeman/reference/inspection-and-staging.md index 394f6be41..bb15a5e6e 100644 --- a/skills/codeman/reference/inspection-and-staging.md +++ b/skills/codeman/reference/inspection-and-staging.md @@ -1,9 +1,8 @@ # Bounded inspection and guarded submission -Use these commands inside a Codeman session. They use its authenticated HTTP API; -no direct tmux access, second transport, or session-bridge installation is required. -They supplement `send`, `wait` and `read`. They do not establish full parity with -session-bridge or retire that skill. +Use these commands inside a Codeman session. They use its authenticated HTTP API. +They supplement `send`, `wait` and `read` over the same HTTP transport; no direct +tmux access is required. ## Inspect recent evidence @@ -31,7 +30,8 @@ falls back to a snapshot. An in-CLI `/resume` of an existing foreign-originator thread may leave launch metadata unchanged. Check `identity.conversationId`; these reads do not provide authoritative current-thread tracking. -Cursors are opaque, scoped to the requesting identity, session, pane generation +Terminal resizes preserve inspection cursors. Cursors are opaque, scoped to the +requesting identity, session, pane lifecycle generation and attributed transcript, expire after 15 minutes, and disappear on server restart. At most 1024 are retained; capacity eviction can invalidate an older cursor. A replaced/truncated/rewritten source, reassociated session, foreign cursor @@ -43,7 +43,8 @@ never split or silently skip one. Increase the byte budget for `record-exceeds-output-budget`; when the record exceeds the maximum or scan cap, use `read --tail` for a labeled terminal view or start a fresh tail baseline. Inspect scans at most 256 KiB of record data plus bounded generation anchors. -Attribution separately caps directory entries and metadata bytes. Evidence and +Attribution separately caps directory entries (8192), depth (8), negative-filter +prefix reads (8 MiB) and candidate metadata including final verification (1 MiB). Evidence and activity are observations, not proof of task completion or authorization. ## Stage, inspect, then submit once @@ -58,7 +59,9 @@ codeman agent submit "$SID" --stage-id "$STAGE_ID" --confirm-sha "$SHA256" --jso Stage types the file contents **without Enter**. The file must be regular, UTF8, one printable line, at most 8192 bytes, with no control characters or trailing whitespace/newline. It initially supports a local Codex composer with a recognized -footer and an exact, unwrapped line/caret capture. Unknown, working, modal, +footer and an exact, unwrapped line/caret capture. Before typing, stage checks +the actual captured width and composer indentation; a message that would reach +the right edge or wrap is refused without writing anything. Unknown, working, modal, wrapped, remote/container and shell composers are refused. A customized footer or unsupported character-width rendering can also make verification unavailable. Self-targets are refused by the CLI. @@ -70,7 +73,7 @@ requires the fully painted exact composer. If typing fails or is ambiguous, stag issues no receipt; inspect the pane before trying again. Submit checks the caller, session, hash, expiry, pane lifecycle, input revision, -exact composer and caret, then sends one conditional Enter at the session's input +geometry, exact composer and caret, then sends one conditional Enter at the session's input boundary. Competing Codeman writes are refused during dispatch. Browser/API/key input and managed restarts invalidate receipts even when the visible text or pid is restored. Direct tmux edits are outside that input serialization; captures @@ -82,3 +85,34 @@ replay Enter, and there is no autonomous retry. A successful response means one Enter was delivered; use inspection or a suitable wait signal to observe native acceptance. A `409` or broken connection means inspect first. Do not automatically stage/submit again or send an unguarded Enter to recover. + +## Recover after a refused submit + +A consumed receipt cannot be reused. First observe whether the prompt was +accepted: use transcript evidence/wait signals and a fresh terminal capture +(`GET /api/v1/sessions/:id/terminal?tail=8192`). If the target is working, has +accepted the prompt, shows a dialog, or delivery is ambiguous, keep observing; +do not clear or resend anything. A transcript prompt row alone is not proof +that text remains unsent in the current composer. + +Only recover an unambiguously unsent draft in an input-ready local Codex +composer: verify the complete draft, recognized footer and caret at its end in +the current frame, and ensure no other writer can edit this target during +recovery. If the frame is wrapped, incomplete or unclear, ask the user to clear +it in the terminal instead of guessing keys. Never use `send` to overwrite a +draft: that appends and submits both prompts together. + +After those checks, the known Codex line-clear chord may be sent once through +the existing raw input route, with **no Enter**. This conditional example uses +the authenticated `CURL`/`API` values loaded by §0 and the exact target id: + +```bash +"${CURL[@]}" -X POST "$API/api/v1/sessions/$SID/input" \ + -H 'Content-Type: application/json' -d '{"input":"\u0015","useMux":false}' +``` + +Do not retry a failed/ambiguous clear blindly. Read a fresh terminal frame and +verify the composer is empty and input-ready. Only then create a **fresh** stage +receipt for the intended prompt and submit that receipt once. If the draft did +not clear, the target began working, or verification remains uncertain, stop and +ask the user; do not send an unguarded Enter or replay the old prompt. diff --git a/skills/codeman/reference/verbs.md b/skills/codeman/reference/verbs.md index 3f511649f..dd68d64d2 100644 --- a/skills/codeman/reference/verbs.md +++ b/skills/codeman/reference/verbs.md @@ -550,8 +550,10 @@ this (`src/web/routes/approval-routes.ts:43`). ESC is then claude's own interrup that half is the CLI's behavior, not something this API guarantees. - **This is not the composer-clearing tool.** Esc (and Ctrl+U) do **not** clear a - typed-but-unsubmitted prompt, verified live. The only recovery there is to submit it - with `{"input":"\r"}` and let the worker read the junk line. + typed-but-unsubmitted Claude prompt, verified live. Do not submit an unverified + draft merely to clear it. For a refused guarded local Codex submit, use the + verified-unsent recovery in [inspection and staging](inspection-and-staging.md#recover-after-a-refused-submit); + when the composer is unclear, ask the user to clear it in the terminal. - The interrupted turn already burned its tokens. Interrupting early saves the rest. - `POST /api/sessions/:id/send-key` is a different endpoint and cannot do this: its allowlist is S-Enter / C-Enter only. diff --git a/src/agent-codex-identity.ts b/src/agent-codex-identity.ts index 81a261623..97cfe50ce 100644 --- a/src/agent-codex-identity.ts +++ b/src/agent-codex-identity.ts @@ -2,13 +2,14 @@ * @fileoverview Bounded Codex rollout attribution for agent evidence. Never uses * newest-mtime, prompt timestamps or cwd alone as proof of session ownership. */ -import { realpathSync } from 'node:fs'; -import { open, opendir } from 'node:fs/promises'; +import { open, opendir, realpath } from 'node:fs/promises'; +import { probePathKind } from './utils/bounded-path-probe.js'; import { tmpdir } from 'node:os'; import { join, resolve } from 'node:path'; const UUID_AT_END = /-([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})\.jsonl$/i; const HEAD_BYTES = 131072; +const PREFIX_SCAN_BYTES = 8 * 1024 * 1024; export interface CodexAttributionOptions { codexHome: string; sessionId: string; @@ -18,15 +19,18 @@ export interface CodexAttributionOptions { export interface CodexAttributionBudget { maxEntries: number; maxBytes: number; + /** Separate bound for cheap negative prefixes; full candidate metadata has its own budget. */ + maxPrefixBytes?: number; } interface Metadata { id?: string; cwd?: string; originator?: string; } -function canonical(path: string): string | undefined { +async function canonical(path: string): Promise { + if ((await probePathKind(path, { pastCap: true })) !== 'directory') return undefined; try { - return realpathSync.native(path); + return await realpath(path); } catch { return undefined; } @@ -38,7 +42,7 @@ export function resolveEffectiveCodexHome( env: Readonly> = process.env ): string { const path = resolve(workingDir, sessionHome || env.CODEX_HOME || join(env.HOME || tmpdir(), '.codex')); - return canonical(path) ?? path; + return path; } /** Complete metadata only: a prefix or a partially flushed JSONL row proves nothing. */ function strictMetadata(bytes: Buffer): Metadata | undefined { @@ -72,11 +76,18 @@ export async function findExactCodexConversation( options: CodexAttributionOptions, budget: CodexAttributionBudget = { maxEntries: 8192, maxBytes: 1048576 } ): Promise<{ id: string; path: string } | undefined> { - const directories = [{ path: join(options.codexHome, 'sessions'), depth: 0 }]; + const workspace = await canonical(options.workingDir); + const home = await canonical(options.codexHome); + if (!workspace || !home) return undefined; + const sessions = join(home, 'sessions'); + if ((await probePathKind(sessions, { pastCap: true })) !== 'directory') return undefined; + const directories = [{ path: sessions, depth: 0 }]; const candidates: Array<{ id: string; path: string; meta: Metadata }> = []; const originator = `codeman_${options.sessionId}`; const explicit = options.resumeSessionId; let entries = 0; + let prefixes = 0; + const prefixLimit = Math.min(budget.maxPrefixBytes ?? PREFIX_SCAN_BYTES, PREFIX_SCAN_BYTES); let scanned = HEAD_BYTES; // Reserve an independent final verification. while (directories.length) { const dir = directories.pop()!; @@ -96,8 +107,8 @@ export async function findExactCodexConversation( if (!info.isFile()) return undefined; const id = entry.name.match(UUID_AT_END)?.[1]; const prefixBytes = Math.min(info.size, 4096); - if (scanned + prefixBytes > budget.maxBytes) return undefined; - scanned += prefixBytes; + if (prefixes + prefixBytes > prefixLimit) return undefined; + prefixes += prefixBytes; const prefix = Buffer.alloc(prefixBytes); const first = await file.read(prefix, 0, prefixBytes, 0); // Cheap negative filter only; a match still needs full parsed metadata. @@ -122,12 +133,7 @@ export async function findExactCodexConversation( } if (candidates.length !== 1) return undefined; const candidate = candidates[0]; - if ( - !candidate.meta.cwd || - canonical(candidate.meta.cwd) !== canonical(options.workingDir) || - !canonical(options.workingDir) - ) - return undefined; + if (!candidate.meta.cwd || (await canonical(candidate.meta.cwd)) !== workspace) return undefined; const handle = await open(candidate.path, 'r').catch(() => undefined); if (!handle) return undefined; try { diff --git a/src/agent-inspection.ts b/src/agent-inspection.ts index 1fc15b4eb..c48c90fc5 100644 --- a/src/agent-inspection.ts +++ b/src/agent-inspection.ts @@ -134,7 +134,7 @@ export class InspectionReader { break; } if (end + 1 > maxBytes) { - blockedReason = 'record-exceeds-output-budget'; + if (consumed === 0) blockedReason = 'record-exceeds-output-budget'; break; } consumed = end + 1; diff --git a/src/agent-staging.ts b/src/agent-staging.ts index ff8cc8c9f..2dbd23822 100644 --- a/src/agent-staging.ts +++ b/src/agent-staging.ts @@ -6,12 +6,18 @@ import { createHash, randomUUID } from 'node:crypto'; import { stripAnsi } from './utils/regex-patterns.js'; import { visibleWidth } from './tui/tui-ansi.js'; +import { CODEX_REASONING_EFFORTS } from './types/session.js'; +const FOOTER_LINE = new RegExp( + String.raw`^\s+(?:\d+% context left|\? for shortcuts|← for agents|\S+ (?:${[...CODEX_REASONING_EFFORTS, 'default'].join('|')}) · )` +); export const STAGE_MAX_BYTES = 8192; export const promptSha256 = (text: string): string => createHash('sha256').update(text).digest('hex'); export interface GuardedInputSnapshot { generation: string; revision: number; composerSha: string; + /** Actual captured pane width minus the rendered composer prefix (including indentation). */ + availableColumns: number; } export interface GuardedInputTarget { id: string; @@ -40,17 +46,7 @@ export function readGuardedCodexComposer(screen: string): string | null { const last = rows.at(-1); if (!last) return null; const below = lines.slice(last.index + 1).filter((line) => line.trim()); - if ( - below.length === 0 || - below.length > 3 || - below.some( - (line) => - !/^\s+(?:\d+% context left|\? for shortcuts|← for agents|\S+ (?:default|low|medium|high|xhigh|max) · )/.test( - line - ) - ) - ) - return null; + if (below.length === 0 || below.length > 3 || below.some((line) => !FOOTER_LINE.test(line))) return null; // Preserve exact printable content; only SGR-dim placeholder/suggestion text is excluded. const raw = screen.split('\n')[last.index]; let dim = false; @@ -74,6 +70,10 @@ export function readGuardedCodexComposer(screen: string): string | null { /** Require the capture's caret at the exact end of the unwrapped composer. */ export function readGuardedCodexSnapshot(snapshot: string): string | null { + return readGuardedCodexSnapshotDetails(snapshot)?.composer ?? null; +} +/** Exact input-row prefix width, shared by proof and preflight wrapping checks. */ +export function readGuardedCodexSnapshotDetails(snapshot: string): { composer: string; prefixWidth: number } | null { // formatPaneSnapshot emits one absolute row address per line, then the caret. // Plain captures lack caret evidence and cannot prove invisible trailing edits. // eslint-disable-next-line no-control-regex @@ -98,7 +98,7 @@ export function readGuardedCodexSnapshot(snapshot: string): string | null { const prefix = stripAnsi(rows[composerRow]).match(/^(\s*›[ \u00a0])/); if (!prefix || Number(caret[1]) !== composerRow + 1 || Number(caret[2]) !== visibleWidth(prefix[1] + composer) + 1) return null; - return composer; + return { composer, prefixWidth: visibleWidth(prefix[1]) }; } export class GuardedStageStore { @@ -129,6 +129,8 @@ export class GuardedStageStore { const before = target.snapshot(); if (!before || before.composerSha !== promptSha256('')) throw new GuardedInputConflict('An empty, verifiable composer is required'); + if (!Number.isFinite(before.availableColumns) || visibleWidth(message) >= before.availableColumns) + throw new GuardedInputConflict('Message would reach the pane edge or wrap; shorten it or widen the pane'); for (const [id, receipt] of this.receipts) if (receipt.expiresAt <= this.now() || receipt.sessionId === target.id) this.receipts.delete(id); // Refuse capacity before typing. Do not evict a caller's still-live receipt. @@ -139,7 +141,12 @@ export class GuardedStageStore { if (!(await target.write(message))) throw new GuardedInputConflict('Staging delivery failed; inspect the pane before trying again'); const after = target.snapshot(); - if (!after || after.generation !== before.generation || after.revision !== before.revision + 1) + if ( + !after || + after.generation !== before.generation || + after.revision !== before.revision + 1 || + after.availableColumns !== before.availableColumns + ) throw new GuardedInputConflict('Input or pane changed while staging; no receipt was issued'); const stageId = randomUUID(); const expiresAt = this.now() + this.ttlMs; @@ -166,6 +173,7 @@ export class GuardedStageStore { !current || current.generation !== receipt.generation || current.revision !== receipt.revision || + current.availableColumns !== receipt.availableColumns || current.composerSha !== receipt.sha256 ) throw new GuardedInputConflict('Staged composer, input, or pane identity changed; nothing submitted'); diff --git a/src/mux-interface.ts b/src/mux-interface.ts index 94c362831..57fa360e0 100644 --- a/src/mux-interface.ts +++ b/src/mux-interface.ts @@ -314,9 +314,9 @@ export interface TerminalMultiplexer extends EventEmitter { // ========== Input ========== /** - * Send input to a session via tmux send-keys. + * Send input to a session via tmux send-keys. The optional internal Enter-only + * guard runs synchronously immediately before transport dispatch. */ - /** Internal conditional Enter guard, checked synchronously before transport dispatch. */ sendInput(sessionId: string, input: string, options?: { beforeDispatch?: () => boolean }): Promise; // ========== Metadata ========== diff --git a/src/session.ts b/src/session.ts index 6f6732229..8d3a57911 100644 --- a/src/session.ts +++ b/src/session.ts @@ -129,7 +129,7 @@ import { DEFAULT_TMUX_HISTORY_LIMIT } from './config/terminal-history.js'; import { EXEC_TIMEOUT_MS } from './config/exec-timeout.js'; import { getCli } from './config/cli-registry/registry.js'; import { SubmitVerifier } from './session-submit-verifier.js'; -import { promptSha256, readGuardedCodexSnapshot, type GuardedInputSnapshot } from './agent-staging.js'; +import { promptSha256, readGuardedCodexSnapshotDetails, type GuardedInputSnapshot } from './agent-staging.js'; import { isKeyboardTerminalInput } from './terminal-input-classifier.js'; import { stripAnsi } from './utils/regex-patterns.js'; import { KEEP_WARM_NUDGE } from './prompt-cache-keepwarm.js'; @@ -4727,26 +4727,9 @@ export class Session extends EventEmitter { // Note: checkAutoCompact/checkAutoClear moved to SessionAutoOps (this._autoOps) - /** - * Sends input directly to the PTY process. - * - * For interactive sessions, this is how you send user input to Claude. - * Remember to include `\r` (carriage return) to simulate pressing Enter. - * - * @example - * ```typescript - * session.write('hello world'); // Text only, no Enter - * session.write('\r'); // Enter key - * session.write('ls -la\r'); // Command with Enter - * ``` - * - * @param data - The input data to send (text, escape sequences, etc.) - * @returns true if the data reached a PTY. A session whose PTY is gone still - * discards the data, but it used to do so with no signal at all — which is how - * input could disappear while the caller believed it had been delivered. - */ private readonly _inspectionInstance = uuidv4(); private _guardedPaneRevision = 0; + private _guardedGeometryRevision = 0; private _guardedInputRevision = 0; private _guardedPendingWrites = 0; private _guardedSubmitInFlight = false; @@ -4754,6 +4737,10 @@ export class Session extends EventEmitter { get inspectionGeneration(): string { return `${this._inspectionInstance}:${this._guardedPaneRevision}:${this.owner ?? ''}`; } + /** Staging additionally binds geometry; transcript cursors survive a terminal resize. */ + get guardedInputGeneration(): string { + return `${this.inspectionGeneration}:${this._guardedGeometryRevision}`; + } get guardedInputPendingWrites(): number { return this._guardedPendingWrites; } @@ -4781,7 +4768,8 @@ export class Session extends EventEmitter { !this._muxSession ) return null; - const screen = this._mux?.capturePaneBuffer?.(this._muxSession.muxName); + const capture: import('./mux-interface.js').PaneCaptureOptions = {}; + const screen = this._mux?.capturePaneBuffer?.(this._muxSession.muxName, undefined, capture); if (!screen) return null; // tmux's replay has CUP addresses rather than line breaks. Read the SAME // fresh frame for dialog/work chrome and exact composer/caret proof. @@ -4790,13 +4778,16 @@ export class Session extends EventEmitter { const working = this._workingLinePattern(); working.lastIndex = 0; if (this._paneShowsBlocked(plain) || working.test(plain) || this._paneAwaitsWorkers(plain)) return null; - const composer = readGuardedCodexSnapshot(screen); + const composer = readGuardedCodexSnapshotDetails(screen); return composer === null ? null : { - generation: this.inspectionGeneration, + generation: this.guardedInputGeneration, revision: this._guardedInputRevision, - composerSha: promptSha256(composer), + composerSha: promptSha256(composer.composer), + availableColumns: Number.isFinite(capture.capturedGeometry?.cols) + ? capture.capturedGeometry!.cols - composer.prefixWidth + : 0, }; } /** Stage through ordinary delivery, after cancelling any older Enter retry timer. */ @@ -4815,7 +4806,8 @@ export class Session extends EventEmitter { !!current && current.generation === expected.generation && current.revision === expected.revision && - current.composerSha === expected.composerSha; + current.composerSha === expected.composerSha && + current.availableColumns === expected.availableColumns; if (!matches(this.guardedInputSnapshot()) || !this._mux || !this._muxSession) return false; this._guardedSubmitInFlight = true; let dispatched = false; @@ -4845,6 +4837,24 @@ export class Session extends EventEmitter { this._guardedPendingWrites--; } } + /** + * Sends input directly to the PTY process. + * + * For interactive sessions, this is how you send user input to Claude. + * Remember to include `\r` (carriage return) to simulate pressing Enter. + * + * @example + * ```typescript + * session.write('hello world'); // Text only, no Enter + * session.write('\r'); // Enter key + * session.write('ls -la\r'); // Command with Enter + * ``` + * + * @param data - The input data to send (text, escape sequences, etc.) + * @returns true if the data reached a PTY. A session whose PTY is gone still + * discards the data, but it used to do so with no signal at all — which is how + * input could disappear while the caller believed it had been delivered. + */ write(data: string, options: SessionWriteOptions = {}): boolean { this._observeGuardedKeyboardInput(data); if (this._guardedSubmitInFlight) return false; @@ -5219,7 +5229,7 @@ export class Session extends EventEmitter { } const dimsChanged = cols !== this._ptyCols || rows !== this._ptyRows; if (this.ptyProcess && (dimsChanged || options.force)) { - this._guardedPaneRevision++; + this._guardedGeometryRevision++; this._ptyCols = cols; this._ptyRows = rows; if (!IS_TEST_MODE && this._mux && this._muxSession) { diff --git a/src/web/routes/agent-session-routes.ts b/src/web/routes/agent-session-routes.ts index a3892b349..662cf41f2 100644 --- a/src/web/routes/agent-session-routes.ts +++ b/src/web/routes/agent-session-routes.ts @@ -27,7 +27,7 @@ export function registerAgentSessionRoutes(app: FastifyInstance, ctx: SessionPor snapshot: () => { if (ctx.sessions.get(session.id) !== session) return null; const snapshot = session.guardedInputSnapshot?.(); - return snapshot ? { ...snapshot, generation: generation(session) } : null; + return snapshot ?? null; }, write: (text) => session.stageGuardedInput(text), submit: (expected) => session.submitGuardedInput(expected), diff --git a/src/web/routes/index.ts b/src/web/routes/index.ts index ec5e0e807..ed8873068 100644 --- a/src/web/routes/index.ts +++ b/src/web/routes/index.ts @@ -47,3 +47,5 @@ export { type CustomModelSwapDisplacement, } from './custom-model-routes.js'; export { registerCliRegistryRoutes, readCliManagementEnabled, type CliListItem } from './cli-registry-routes.js'; + +export { registerAgentSessionRoutes } from './agent-session-routes.js'; diff --git a/src/web/routes/session-routes.ts b/src/web/routes/session-routes.ts index 507ef7ab9..fbceb684a 100644 --- a/src/web/routes/session-routes.ts +++ b/src/web/routes/session-routes.ts @@ -5,7 +5,6 @@ */ import { claudeProjectDirName } from '../../case-rename.js'; -import { registerAgentSessionRoutes } from './agent-session-routes.js'; import { FastifyInstance, type FastifyRequest } from 'fastify'; import { z } from 'zod'; import { join, dirname, extname, basename, resolve as resolvePath } from 'node:path'; @@ -1073,8 +1072,6 @@ export function registerSessionRoutes( // ========== Logout ========== - registerAgentSessionRoutes(app, ctx); - app.post('/api/logout', async (req, reply) => { // Invalidate server-side session token (not just the browser cookie) const sessionToken = req.cookies[AUTH_COOKIE_NAME]; diff --git a/src/web/server.ts b/src/web/server.ts index 2b3e582c4..3e2d07b89 100644 --- a/src/web/server.ts +++ b/src/web/server.ts @@ -213,6 +213,7 @@ import { registerCaseRoutes, registerWorktreeRoutes, registerSessionRoutes, + registerAgentSessionRoutes, registerRespawnRoutes, registerRalphRoutes, registerPlanRoutes, @@ -1249,6 +1250,7 @@ export class WebServer extends EventEmitter { // The registry's lifetime is the server's: it drops per-session wake state on every // cleanup path and resolves in-flight wakes on shutdown. this.remoteWake = registerSessionRoutes(this.app, ctx); + registerAgentSessionRoutes(this.app, ctx); registerRespawnRoutes(this.app, ctx); registerRalphRoutes(this.app, ctx); registerPlanRoutes(this.app, ctx); diff --git a/test/agent-codex-identity.test.ts b/test/agent-codex-identity.test.ts index b568b3d5a..d3bc9cadc 100644 --- a/test/agent-codex-identity.test.ts +++ b/test/agent-codex-identity.test.ts @@ -117,3 +117,40 @@ it.each([ } } ); + +it('attributes an owned rollout among 320 unrelated full-size metadata prefixes', async () => { + const f = await fixture(); + const path = await f.put(A); + await Promise.all( + Array.from({ length: 320 }, (_, n) => { + const id = `10000000-1111-4111-8111-${String(n).padStart(12, '0')}`; + return f.put(id, { originator: 'codex_cli', instructions: 'x'.repeat(5000) }); + }) + ); + expect(await findExactCodexConversation(f.options)).toEqual({ id: A, path }); + expect( + await findExactCodexConversation(f.options, { maxEntries: 8192, maxBytes: 1048576, maxPrefixBytes: 4096 }) + ).toBeUndefined(); +}); + +it.each(['workspace', 'metadata'] as const)( + 'probes %s bounded past the cap before canonical filesystem reads', + async (where) => { + const { vi } = await import('vitest'); + const probe = await import('../src/utils/bounded-path-probe.js'); + const f = await fixture(); + const alias = join(f.root, 'workspace-alias'); + await symlink(f.root, alias); + await f.put(A, { cwd: where === 'metadata' ? alias : f.root }); + const blocked = where === 'workspace' ? f.root : alias; + const spy = vi + .spyOn(probe, 'probePathKind') + .mockImplementation(async (path) => (path === blocked ? 'unknown' : 'directory')); + try { + expect(await findExactCodexConversation(f.options)).toBeUndefined(); + expect(spy).toHaveBeenCalledWith(blocked, { pastCap: true }); + } finally { + spy.mockRestore(); + } + } +); diff --git a/test/agent-inspection.test.ts b/test/agent-inspection.test.ts index 2a1f4270a..989f005b4 100644 --- a/test/agent-inspection.test.ts +++ b/test/agent-inspection.test.ts @@ -159,3 +159,22 @@ it('keeps an incomplete UTF8 sequence pending until its JSONL record is complete await appendFile(file, row.subarray(split)); expect((await reader.read(file, scope, 100, first.cursor)).evidence).toBe(row.toString('utf8')); }); + +it('treats a full incremental page as pageable rather than a blocked oversized record', async () => { + await writeFile(file, '{}\n'); + const initial = await reader.read(file, scope, 100); + await appendFile(file, '{}\n{}\n'); + const page = await reader.read(file, scope, 3, initial.cursor); + expect(page.evidence).toBe('{}\n'); + expect(page.truncated).toBe(true); + expect(page.blockedReason).toBeUndefined(); + const next = await reader.read(file, scope, 3, page.cursor); + expect(next.evidence).toBe('{}\n'); +}); + +it('still labels the first selected oversized tail record after omitted older bytes', async () => { + await writeFile(file, '{}\n' + JSON.stringify({ text: 'x'.repeat(100) }) + '\n'); + const page = await reader.read(file, scope, 10); + expect(page.evidence).toBe(''); + expect(page.blockedReason).toBe('record-exceeds-output-budget'); +}); diff --git a/test/agent-staging-session.test.ts b/test/agent-staging-session.test.ts index f0bb1328c..8d97cc23b 100644 --- a/test/agent-staging-session.test.ts +++ b/test/agent-staging-session.test.ts @@ -7,13 +7,19 @@ it('fences writes and reused-pid pane lifecycles without persisting prompt conte let screen = '› \n GPT-6-Luna default · ~/case\n'; const mux = { isAvailable: () => true, - capturePaneBuffer: () => - formatPaneSnapshot(screen.trimEnd().split('\n'), { + capturePaneBuffer: ( + _name: string, + _pane?: string, + opts?: { capturedGeometry?: { cols: number; rows: number } } + ) => { + if (opts) opts.capturedGeometry = { cols: 100, rows: 20 }; + return formatPaneSnapshot(screen.trimEnd().split('\n'), { cols: 100, rows: 20, cursorX: screen.startsWith('› hello') ? 7 : 2, cursorY: 0, - }), + }); + }, sendInput: async () => true, }; const session = new Session({ @@ -49,8 +55,19 @@ it('checks conditional Enter at the dispatch boundary and rejects concurrent bro const writes: string[] = []; const mux = { isAvailable: () => true, - capturePaneBuffer: () => - formatPaneSnapshot(['› hello', ' GPT-6-Luna default · ~/case'], { cols: 100, rows: 20, cursorX: 7, cursorY: 0 }), + capturePaneBuffer: ( + _name: string, + _pane?: string, + opts?: { capturedGeometry?: { cols: number; rows: number } } + ) => { + if (opts) opts.capturedGeometry = { cols: 100, rows: 20 }; + return formatPaneSnapshot(['› hello', ' GPT-6-Luna default · ~/case'], { + cols: 100, + rows: 20, + cursorX: 7, + cursorY: 0, + }); + }, sendInput: async (_id: string, text: string, options?: { beforeDispatch?: () => boolean }) => { await new Promise((resolve) => { dispatch = resolve; @@ -104,13 +121,19 @@ it('cancels an older automatic submit verifier before staging an identical promp const writes: string[] = []; const mux = { isAvailable: () => true, - capturePaneBuffer: () => - formatPaneSnapshot([`› ${composer}`, ' GPT-6-Luna default · ~/case'], { + capturePaneBuffer: ( + _name: string, + _pane?: string, + opts?: { capturedGeometry?: { cols: number; rows: number } } + ) => { + if (opts) opts.capturedGeometry = { cols: 100, rows: 20 }; + return formatPaneSnapshot([`› ${composer}`, ' GPT-6-Luna default · ~/case'], { cols: 100, rows: 20, cursorX: 2 + composer.length, cursorY: 0, - }), + }); + }, capturePaneText: () => `› ${composer}\n GPT-6-Luna default · ~/case`, sendInput: async (_id: string, text: string) => { writes.push(text); @@ -141,8 +164,19 @@ it('routes automatic verifier Enter through the shared guarded input boundary', const writes: string[] = []; const mux = { isAvailable: () => true, - capturePaneBuffer: () => - formatPaneSnapshot(['› hello', ' GPT-6-Luna default · ~/case'], { cols: 100, rows: 20, cursorX: 7, cursorY: 0 }), + capturePaneBuffer: ( + _name: string, + _pane?: string, + opts?: { capturedGeometry?: { cols: number; rows: number } } + ) => { + if (opts) opts.capturedGeometry = { cols: 100, rows: 20 }; + return formatPaneSnapshot(['› hello', ' GPT-6-Luna default · ~/case'], { + cols: 100, + rows: 20, + cursorX: 7, + cursorY: 0, + }); + }, capturePaneText: () => '› hello\n GPT-6-Luna default · ~/case', sendInput: async (_id: string, text: string, options?: { beforeDispatch?: () => boolean }) => { if (options?.beforeDispatch) { @@ -180,8 +214,19 @@ it('refuses legacy one-shot prompt entrypoints during a guarded submission befor const writes: string[] = []; const mux = { isAvailable: () => true, - capturePaneBuffer: () => - formatPaneSnapshot(['› hello', ' GPT-6-Luna default · ~/case'], { cols: 100, rows: 20, cursorX: 7, cursorY: 0 }), + capturePaneBuffer: ( + _name: string, + _pane?: string, + opts?: { capturedGeometry?: { cols: number; rows: number } } + ) => { + if (opts) opts.capturedGeometry = { cols: 100, rows: 20 }; + return formatPaneSnapshot(['› hello', ' GPT-6-Luna default · ~/case'], { + cols: 100, + rows: 20, + cursorX: 7, + cursorY: 0, + }); + }, sendInput: async (_id: string, text: string, options?: { beforeDispatch?: () => boolean }) => { await new Promise((resolve) => { release = resolve; @@ -259,7 +304,14 @@ it('uses fresh normalized pane rows for working/dialog denial before exact compo mode: 'codex', mux: { isAvailable: () => true, - capturePaneBuffer: capture, + capturePaneBuffer: ( + _name: string, + _pane?: string, + opts?: { capturedGeometry?: { cols: number; rows: number } } + ) => { + if (opts) opts.capturedGeometry = { cols: 100, rows: 20 }; + return capture(); + }, capturePaneText: () => { throw Error('must use same frame'); }, @@ -287,13 +339,19 @@ it('keeps raw user input synchronous and preserves receipts for focus/mouse/noop mode: 'codex', mux: { isAvailable: () => true, - capturePaneBuffer: () => - formatPaneSnapshot(['› hello', ' GPT-6-Luna default · ~/case'], { + capturePaneBuffer: ( + _name: string, + _pane?: string, + opts?: { capturedGeometry?: { cols: number; rows: number } } + ) => { + if (opts) opts.capturedGeometry = { cols: 100, rows: 20 }; + return formatPaneSnapshot(['› hello', ' GPT-6-Luna default · ~/case'], { cols: 100, rows: 20, cursorX: 7, cursorY: 0, - }), + }); + }, }, muxSession: { muxName: 'codeman-12345678', sessionId: 'test', createdAt: 1, pid: 1 }, } as unknown as ConstructorParameters[0]); @@ -319,11 +377,90 @@ it('keeps raw user input synchronous and preserves receipts for focus/mouse/noop session.owner = 'other'; session.owner = undefined; expect(session.inspectionGeneration).not.toBe(generation); - const priorResize = session.inspectionGeneration; + const priorResize = session.guardedInputGeneration; session.resize(80, 24); - expect(session.inspectionGeneration).not.toBe(priorResize); + expect(session.guardedInputGeneration).not.toBe(priorResize); } finally { access.ptyProcess = null; await session.stop(false); } }); + +it('refuses a message that would wrap using the actual captured indent and width before any write', async () => { + const writes: string[] = []; + const session = new Session({ + workingDir: '/tmp', + mode: 'codex', + mux: { + isAvailable: () => true, + capturePaneBuffer: ( + _name: string, + _pane: string, + opts: { capturedGeometry?: { cols: number; rows: number } } + ) => { + if (opts) opts.capturedGeometry = { cols: 30, rows: 20 }; + return formatPaneSnapshot([' › ', ' GPT-6 default · ~/case'], { + cols: 30, + rows: 20, + cursorX: 6, + cursorY: 0, + }); + }, + sendInput: async (_id: string, text: string) => { + writes.push(text); + return true; + }, + }, + muxSession: { muxName: 'codeman-12345678', sessionId: 'test', createdAt: 1, pid: 1 }, + } as unknown as ConstructorParameters[0]); + const { GuardedStageStore } = await import('../src/agent-staging.js'); + try { + const store = new GuardedStageStore(); + const target = { + id: session.id, + snapshot: () => session.guardedInputSnapshot(), + write: (text: string) => session.stageGuardedInput(text), + submit: (snap: Parameters[0]) => session.submitGuardedInput(snap), + }; + await expect(store.stage(target, 'alice', 'x'.repeat(24))).rejects.toThrow(); + expect(writes).toEqual([]); + } finally { + await session.stop(false); + } +}); + +it('keeps inspection generations stable across resize but invalidates a staged snapshot', async () => { + const session = new Session({ workingDir: '/tmp', mode: 'codex' }); + const access = session as unknown as { ptyProcess: { resize(cols: number, rows: number): void } | null }; + access.ptyProcess = { resize: () => {} }; + try { + const inspection = session.inspectionGeneration; + const staging = session.guardedInputGeneration; + session.resize(80, 24); + expect(session.inspectionGeneration).toBe(inspection); + expect(session.guardedInputGeneration).not.toBe(staging); + } finally { + access.ptyProcess = null; + await session.stop(false); + } +}); + +it('records a verifier re-press as the latest submit time', async () => { + vi.useFakeTimers(); + vi.setSystemTime(10000); + const session = new Session({ + workingDir: '/tmp', + mode: 'codex', + mux: { isAvailable: () => true, capturePaneText: () => '› hello\n', sendInput: async () => true }, + muxSession: { muxName: 'codeman-12345678', sessionId: 'test', createdAt: 1, pid: 1 }, + } as unknown as ConstructorParameters[0]); + try { + await session.writeViaMux('hello\r'); + const initial = session.lastSubmitAt; + await vi.advanceTimersByTimeAsync(2000); + expect(session.lastSubmitAt).toBe(initial + 2000); + } finally { + await session.stop(false); + vi.useRealTimers(); + } +}); diff --git a/test/agent-staging.test.ts b/test/agent-staging.test.ts index 1cd920a63..6a9c1ad37 100644 --- a/test/agent-staging.test.ts +++ b/test/agent-staging.test.ts @@ -1,6 +1,7 @@ /** @fileoverview Guarded staging one-shot and content-verification safety. */ import { beforeEach, expect, it } from 'vitest'; import { createHash } from 'node:crypto'; +import { CODEX_REASONING_EFFORTS } from '../src/types/session.js'; import { GuardedStageStore, type GuardedInputTarget } from '../src/agent-staging.js'; const sha = (s: string) => createHash('sha256').update(s).digest('hex'); let store: GuardedStageStore; @@ -21,7 +22,8 @@ beforeEach(() => { store = new GuardedStageStore(() => time, 2, 1000); target = { id: 's', - snapshot: () => (composer === null ? null : { generation, revision, composerSha: sha(composer) }), + snapshot: () => + composer === null ? null : { generation, revision, composerSha: sha(composer), availableColumns: 100 }, submit: async () => target.write('\r'), write: async (text) => { writes.push(text); @@ -151,3 +153,18 @@ it('reserves receipt capacity before concurrent stages type', async () => { release!(); await first; }); + +it.each([...CODEX_REASONING_EFFORTS, 'default'])( + 'accepts the shared Codex effort word %s in the footer', + async (word) => { + const { readGuardedCodexComposer } = await import('../src/agent-staging.js'); + expect(readGuardedCodexComposer(`› hello\n GPT-6 ${word} · ~/case\n`)).toBe('hello'); + } +); + +it('refuses messages that reach the available pane columns before typing', async () => { + target.snapshot = () => ({ generation, revision, composerSha: sha(composer ?? ''), availableColumns: 4 }); + for (const text of ['four', '界界']) await expect(store.stage(target, 'alice', text)).rejects.toThrow(); + expect(writes).toEqual([]); + await expect(store.stage(target, 'alice', 'abc')).resolves.toMatchObject({ submitted: false }); +}); diff --git a/test/routes/agent-session-routes.test.ts b/test/routes/agent-session-routes.test.ts index 02cec22b5..27fbd4158 100644 --- a/test/routes/agent-session-routes.test.ts +++ b/test/routes/agent-session-routes.test.ts @@ -1,6 +1,6 @@ /** @fileoverview Agent inspection and guarded input HTTP contracts (no live ports). */ import { afterEach, describe, expect, it } from 'vitest'; -import { registerSessionRoutes } from '../../src/web/routes/session-routes.js'; +import { registerAgentSessionRoutes } from '../../src/web/routes/agent-session-routes.js'; import { createRouteTestHarness, type RouteTestHarness } from './_route-test-utils.js'; let harness: RouteTestHarness; @@ -9,7 +9,7 @@ afterEach(async () => { }); describe('agent session routes', () => { it('returns an explicitly bounded pane snapshot when no attributed transcript exists', async () => { - harness = await createRouteTestHarness(registerSessionRoutes); + harness = await createRouteTestHarness(registerAgentSessionRoutes); const session = [...harness.ctx.sessions.values()][0]; const result = await harness.app.inject({ url: `/api/sessions/${session.id}/inspect?maxBytes=20` }); expect(result.statusCode).toBe(200); @@ -19,7 +19,7 @@ describe('agent session routes', () => { expect(Buffer.byteLength(body.data.evidence)).toBeLessThanOrEqual(20); }); it('refuses guarded staging for an uncharacterized composer', async () => { - harness = await createRouteTestHarness(registerSessionRoutes); + harness = await createRouteTestHarness(registerAgentSessionRoutes); const session = [...harness.ctx.sessions.values()][0]; const result = await harness.app.inject({ method: 'POST', @@ -33,7 +33,9 @@ describe('agent session routes', () => { it('checks ownership before inspection, stage and submit', async () => { process.env.CODEMAN_MULTIUSER = '1'; try { - harness = await createRouteTestHarness(registerSessionRoutes, { authUser: { username: 'alice', role: 'user' } }); + harness = await createRouteTestHarness(registerAgentSessionRoutes, { + authUser: { username: 'alice', role: 'user' }, + }); const session = [...harness.ctx.sessions.values()][0]; session.owner = 'bob'; for (const request of [ @@ -52,7 +54,7 @@ it('checks ownership before inspection, stage and submit', async () => { } }); it('validates output budgets and refuses to resume a pane fallback', async () => { - harness = await createRouteTestHarness(registerSessionRoutes); + harness = await createRouteTestHarness(registerAgentSessionRoutes); const session = [...harness.ctx.sessions.values()][0]; session.terminalBuffer = '😀é'.repeat(20); for (const value of ['0', '-1', '65537', '1.5', 'NaN']) @@ -74,7 +76,7 @@ it('validates output budgets and refuses to resume a pane fallback', async () => it('uses real session writes to stage without Enter and refuses intervening API input', async () => { const { Session } = await import('../../src/session.js'); const { formatPaneSnapshot } = await import('../../src/tmux-manager.js'); - harness = await createRouteTestHarness(registerSessionRoutes); + harness = await createRouteTestHarness(registerAgentSessionRoutes); let composer = ''; const writes: string[] = []; const session = new Session({ @@ -83,13 +85,19 @@ it('uses real session writes to stage without Enter and refuses intervening API mode: 'codex', mux: { isAvailable: () => true, - capturePaneBuffer: () => - formatPaneSnapshot([`› ${composer}`, ' GPT-6-Luna default · ~/case'], { + capturePaneBuffer: ( + _name: string, + _pane?: string, + opts?: { capturedGeometry?: { cols: number; rows: number } } + ) => { + if (opts) opts.capturedGeometry = { cols: 100, rows: 20 }; + return formatPaneSnapshot([`› ${composer}`, ' GPT-6-Luna default · ~/case'], { cols: 100, rows: 20, cursorX: 2 + composer.length, cursorY: 0, - }), + }); + }, sendInput: async (_id: string, text: string, options?: { beforeDispatch?: () => boolean }) => { if (options?.beforeDispatch && !options.beforeDispatch()) return false; writes.push(text); @@ -157,7 +165,9 @@ it('reads only a strictly attributed Codex rollout and binds HTTP cursors to its const row = JSON.stringify({ type: 'session_meta', payload: { id, cwd: root, originator: `codeman_${session.id}` } }) + '\n'; await writeFile(join(home, 'sessions', `rollout-${id}.jsonl`), row + '{"text":"hello"}\n'); - harness = await createRouteTestHarness(registerSessionRoutes, { authUser: { username: 'alice', role: 'admin' } }); + harness = await createRouteTestHarness(registerAgentSessionRoutes, { + authUser: { username: 'alice', role: 'admin' }, + }); (harness.ctx.sessions as unknown as Map).set(session.id, session); try { const first = await harness.app.inject({ @@ -172,6 +182,13 @@ it('reads only a strictly attributed Codex rollout and binds HTTP cursors to its expect(data.cursor).not.toContain(home); const next = await harness.app.inject({ url: `/api/sessions/${session.id}/inspect?since=${data.cursor}` }); expect(next.json().data.evidence).toBe(''); + const access = session as unknown as { ptyProcess: { resize(cols: number, rows: number): void } | null }; + access.ptyProcess = { resize: () => {} }; + session.resize(80, 24); + access.ptyProcess = null; + expect( + (await harness.app.inject({ url: `/api/sessions/${session.id}/inspect?since=${data.cursor}` })).statusCode + ).toBe(200); session.owner = 'changed-owner'; expect( (await harness.app.inject({ url: `/api/sessions/${session.id}/inspect?since=${data.cursor}` })).statusCode @@ -202,7 +219,7 @@ it('refuses a stale launch-resume cursor once a new managed rollout conflicts wi const row = (id: string, originator: string) => JSON.stringify({ type: 'session_meta', payload: { id, cwd: root, originator } }) + '\n'; await writeFile(join(home, 'sessions', `rollout-${old}.jsonl`), row(old, 'original-launch')); - harness = await createRouteTestHarness(registerSessionRoutes); + harness = await createRouteTestHarness(registerAgentSessionRoutes); (harness.ctx.sessions as unknown as Map).set(session.id, session); try { const initial = (await harness.app.inject({ url: `/api/sessions/${session.id}/inspect` })).json().data; diff --git a/test/tmux-submit-paste.test.ts b/test/tmux-submit-paste.test.ts index c131c4e5c..614cb904b 100644 --- a/test/tmux-submit-paste.test.ts +++ b/test/tmux-submit-paste.test.ts @@ -326,3 +326,13 @@ describe('sendInput line breaks', () => { expect(answer.ran[1]).toMatch(/ send-keys -t "codeman-abc12345" Enter$/); }); }); + +it('terminates literal operands in both sendInputToPane branches', async () => { + const { readFile } = await import('node:fs/promises'); + const src = await readFile(new URL('../src/tmux-manager.ts', import.meta.url), 'utf8'); + const start = src.indexOf(' sendInputToPane('); + const body = src.slice(start, src.indexOf('\n }\n', start)); + const literal = body.match(/send-keys[^`\n]*-l[^`\n]*/g) ?? []; + expect(literal).toHaveLength(2); + for (const command of literal) expect(command).toContain(' -l -- '); +}); diff --git a/test/webserver-bound-port.test.ts b/test/webserver-bound-port.test.ts index e81612b96..6bce845a5 100644 --- a/test/webserver-bound-port.test.ts +++ b/test/webserver-bound-port.test.ts @@ -40,6 +40,34 @@ describe('WebServer on an ephemeral port', () => { expect(ctx.port).toBe(socketPort()); }); + it('registers bounded agent routes with the server route modules', async () => { + const { Session } = await import('../src/session.js'); + const session = new Session({ id: 'agent-route-wiring', workingDir: process.env.HOME!, mode: 'shell' }); + const sessions = (server as unknown as { sessions: Map }).sessions; + sessions.set(session.id, session); + try { + const prefix = `http://127.0.0.1:${server.boundPort}/api/v1/sessions/${session.id}`; + const inspected = await fetch(prefix + '/inspect'); + expect(inspected.status).toBe(200); + expect((await inspected.json()).data.source).toBe('pane-snapshot'); + for (const [verb, body] of [ + ['stage', { message: 'hello' }], + ['submit', { stageId: '11111111-1111-4111-8111-111111111111', confirmSha: 'a'.repeat(64) }], + ] as const) { + const response = await fetch(prefix + '/' + verb, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body), + }); + expect(response.status).toBe(409); + expect((await response.json()).errorCode).toBe('CONFLICT'); + } + } finally { + sessions.delete(session.id); + await session.stop(false); + } + }); + it('exports the real port to the panes as CODEMAN_API_URL', () => { expect(process.env.CODEMAN_API_URL).toBe(`http://127.0.0.1:${socketPort()}`); }); From 1a2c3be0ea3aea93680842398390ace17207babb Mon Sep 17 00:00:00 2001 From: Aamer Akhter Date: Fri, 23 Oct 2026 17:06:45 -0400 Subject: [PATCH 20/68] docs(agent): mark coordination API experimental --- .changeset/bounded-agent-coordination.md | 4 +++- README.md | 2 +- docs/api-reference.md | 9 ++++++++- docs/architecture-invariants.md | 4 ++++ docs/versioning-policy.md | 14 ++++++++++++++ plugins/codeman/skills/codeman/SKILL.md | 2 +- .../codeman/reference/inspection-and-staging.md | 9 ++++++++- plugins/codeman/skills/codeman/reference/verbs.md | 5 ++++- skills/codeman/SKILL.md | 2 +- skills/codeman/reference/inspection-and-staging.md | 9 ++++++++- skills/codeman/reference/verbs.md | 5 ++++- src/cli-agent.ts | 8 +++++--- 12 files changed, 61 insertions(+), 12 deletions(-) diff --git a/.changeset/bounded-agent-coordination.md b/.changeset/bounded-agent-coordination.md index 1239817a5..b88bcef9b 100644 --- a/.changeset/bounded-agent-coordination.md +++ b/.changeset/bounded-agent-coordination.md @@ -2,7 +2,9 @@ "aicodeman": minor --- -Add bounded agent inspection with expiring scoped cursors and explicit pane snapshots, plus staged local Codex input and single-use hash-confirmed submission receipts. Verify the exact composer, caret, lifecycle and input revision before sending Enter, and serialize dispatch against managed input and auxiliary key operations. +Add experimental bounded agent inspection with expiring scoped cursors and explicit pane snapshots, plus staged local Codex input and single-use hash-confirmed submission receipts. Verify the exact composer, caret, lifecycle and input revision before sending Enter, and serialize dispatch against managed input and auxiliary key operations. + +Explicitly exclude the three new CLI verbs and matching HTTP routes (including `/api/v1` and `/api` aliases) from the stable contract while experimental; their shape and behavior may change in minor or patch releases. Document the initial local Codex support and conversation-attribution limits in the API, CLI and packaged skill. Other modes retain labeled snapshots or refuse unverifiable guarded input. diff --git a/README.md b/README.md index 1850841ab..36f0bf745 100644 --- a/README.md +++ b/README.md @@ -977,7 +977,7 @@ codeman tui 3 # attach to session 3 of that list ### `codeman agent` — session-to-session verbs in every CLI mode -The skill above drives the API with `curl` and `jq` from a preamble Codeman seeds per session. Where it does not reach (a `pi` or `shell` session, a machine without `jq`), the same environment is still there (`CODEMAN_MUX=1`, `CODEMAN_SESSION_ID`, `CODEMAN_API_URL` are exported into every pane), so `codeman agent` packages the same verbs as commands. It is a thin client over the endpoints listed under [API](#api): auth and ownership apply to every endpoint, including bounded inspection and guarded input. One line in a case's `AGENTS.md` is enough: *"other sessions: `codeman agent --help`"*. +The skill above drives the API with `curl` and `jq` from a preamble Codeman seeds per session. Where it does not reach (a `pi` or `shell` session, a machine without `jq`), the same environment is still there (`CODEMAN_MUX=1`, `CODEMAN_SESSION_ID`, `CODEMAN_API_URL` are exported into every pane), so `codeman agent` packages the same verbs as commands. It is a thin client over the endpoints listed under [API](#api): auth and ownership apply to every endpoint, including bounded inspection and guarded input. The new `inspect`, `stage` and `submit` commands and their HTTP routes are **experimental**: commands, flags, paths, payloads and behavior may change or be removed in minor or patch releases. Pin a compatible version when automating them; see the [stability exception](docs/versioning-policy.md#experimental-agent-coordination). One line in a case's `AGENTS.md` is enough: *"other sessions: `codeman agent --help`"*. ```bash codeman agent ls # sessions; * marks this one diff --git a/docs/api-reference.md b/docs/api-reference.md index ca04f1cf0..2bcbebb62 100644 --- a/docs/api-reference.md +++ b/docs/api-reference.md @@ -589,7 +589,14 @@ request returns. The capture runs synchronous tmux calls on the server; the | `lines=` | With `full=1` only: read at most `` lines of tmux history above the visible frame. An integer of at least 1, clamped to the configured history limit; absent or malformed, the whole limit (100,000 lines by default), as before. When a bound below the configured limit left older lines in tmux (its `history_size` is deeper), `truncated` is true with `truncationReason: 'tail'`; a byte cap still reports `'capped'`. Without it a full capture reads all of that history before `tail` cuts it, so a client that keeps a fixed number of lines (the tile grid sends its xterm's scrollback plus its rows) should send it. | | `alt=recording` | With `full=1` only: if the pane is in the alternate screen (a nested tmux, where tmux keeps no history, or any full-screen app such as vim or less while it is open), answer with the visible frame over the byte recording instead (`source: 'mux-visible'`, `tail` applied as without `full=1`). Shells' automatic loads send it. | -## Bounded agent inspection and guarded input +## Experimental bounded agent inspection and guarded input + +**Experimental:** `codeman agent inspect`, `stage` and `submit`, and their +`/api/v1/sessions/:id/inspect|stage|submit` routes (including `/api` aliases), +are excluded from the stable CLI/HTTP contract. Their commands, flags, paths, +payloads and behavior may change or be removed in a minor or patch release. +Pin a compatible Codeman version when automating them. Authentication and +session ownership checks still apply. These authenticated routes use the standard envelope and session ownership checks. `/api/v1` aliases `/api`; an inaccessible session returns `404 NOT_FOUND` before diff --git a/docs/architecture-invariants.md b/docs/architecture-invariants.md index 140d394dd..3feb4f965 100644 --- a/docs/architecture-invariants.md +++ b/docs/architecture-invariants.md @@ -1263,6 +1263,10 @@ Its CLI knowledge is a GENERATED block (`npm run generate:cli-catalog`, markers ## Agent inspection and guarded submission +These three operations are experimental across both CLI and HTTP, including the +`/api/v1` paths and unversioned aliases. See `docs/versioning-policy.md` for the +explicit stability exception; do not infer stability from the URL prefix. + `agent-session-routes.ts` is registered by `WebServer` with the other route modules. Ownership is checked before transcript/pane/receipt lookup. Inspection uses strict native metadata attribution, bounded traversal, separate prefix and diff --git a/docs/versioning-policy.md b/docs/versioning-policy.md index 6ec92a2fe..bf42c0958 100644 --- a/docs/versioning-policy.md +++ b/docs/versioning-policy.md @@ -62,6 +62,20 @@ These may change in a **MINOR** (or even PATCH) release without a MAJOR bump: anything labeled experimental in the UI or docs. These may change or be removed at any time. +### Experimental agent coordination + +`codeman agent inspect`, `codeman agent stage` and `codeman agent submit` are +experimental. The matching `GET /api/v1/sessions/:id/inspect`, +`POST /api/v1/sessions/:id/stage` and `POST /api/v1/sessions/:id/submit` routes, +including their unversioned `/api` aliases, share this designation. This is an +explicit exception to both the CLI and HTTP stability promises above: these +commands, flags, endpoint paths, payloads and behavior may change or be removed +in a MINOR or PATCH release without a stable-surface deprecation period. Pin a +compatible Codeman version when automating them. Existing authentication, +ownership and shared response-envelope rules still apply. Other agent commands +and API routes retain their existing stability guarantees. Promotion to stable +requires an explicit documented decision. + ## Deprecation policy When we need to change a covered surface: diff --git a/plugins/codeman/skills/codeman/SKILL.md b/plugins/codeman/skills/codeman/SKILL.md index ce34728a0..80f26c418 100644 --- a/plugins/codeman/skills/codeman/SKILL.md +++ b/plugins/codeman/skills/codeman/SKILL.md @@ -30,7 +30,7 @@ through: the verbs in detail (the old §5) in [reference/verbs.md](reference/ver worked multi-worker flows in [reference/recipes.md](reference/recipes.md), endpoint tables and a symptom gallery in [reference/endpoints.md](reference/endpoints.md), and direct messaging to claude workers in [reference/messaging.md](reference/messaging.md). -Bounded inspection and guarded stage/submit load only when needed from +Experimental bounded inspection and guarded stage/submit load only when needed from [reference/inspection-and-staging.md](reference/inspection-and-staging.md). This skill works the same whichever CLI you are: Codeman puts it where each CLI looks diff --git a/plugins/codeman/skills/codeman/reference/inspection-and-staging.md b/plugins/codeman/skills/codeman/reference/inspection-and-staging.md index bb15a5e6e..d624f5d7f 100644 --- a/plugins/codeman/skills/codeman/reference/inspection-and-staging.md +++ b/plugins/codeman/skills/codeman/reference/inspection-and-staging.md @@ -1,4 +1,11 @@ -# Bounded inspection and guarded submission +# Experimental bounded inspection and guarded submission + +**Experimental:** `codeman agent inspect`, `stage` and `submit`, and their +`/api/v1/sessions/:id/inspect|stage|submit` routes (including `/api` aliases), +are excluded from the stable CLI/HTTP contract. Their commands, flags, paths, +payloads and behavior may change or be removed in a minor or patch release. +Pin a compatible Codeman version when automating them. Authentication and +session ownership checks still apply. Use these commands inside a Codeman session. They use its authenticated HTTP API. They supplement `send`, `wait` and `read` over the same HTTP transport; no direct diff --git a/plugins/codeman/skills/codeman/reference/verbs.md b/plugins/codeman/skills/codeman/reference/verbs.md index dd68d64d2..54a8899c5 100644 --- a/plugins/codeman/skills/codeman/reference/verbs.md +++ b/plugins/codeman/skills/codeman/reference/verbs.md @@ -814,7 +814,10 @@ Rules that matter: - **Lifetime.** An inbox goes with its session on delete. A server restart empties all inboxes unless the user turned on *Keep agent inbox across restarts* in Settings. -## Bounded inspection and guarded submission +## Experimental bounded inspection and guarded submission + +These three commands and their HTTP routes may change or be removed in minor or +patch releases; they are excluded from the stable CLI/HTTP contract. Use `codeman agent inspect` for bounded evidence and incremental local Codex cursors. Use `codeman agent stage --message-file` followed by one hash-confirmed diff --git a/skills/codeman/SKILL.md b/skills/codeman/SKILL.md index ce34728a0..80f26c418 100644 --- a/skills/codeman/SKILL.md +++ b/skills/codeman/SKILL.md @@ -30,7 +30,7 @@ through: the verbs in detail (the old §5) in [reference/verbs.md](reference/ver worked multi-worker flows in [reference/recipes.md](reference/recipes.md), endpoint tables and a symptom gallery in [reference/endpoints.md](reference/endpoints.md), and direct messaging to claude workers in [reference/messaging.md](reference/messaging.md). -Bounded inspection and guarded stage/submit load only when needed from +Experimental bounded inspection and guarded stage/submit load only when needed from [reference/inspection-and-staging.md](reference/inspection-and-staging.md). This skill works the same whichever CLI you are: Codeman puts it where each CLI looks diff --git a/skills/codeman/reference/inspection-and-staging.md b/skills/codeman/reference/inspection-and-staging.md index bb15a5e6e..d624f5d7f 100644 --- a/skills/codeman/reference/inspection-and-staging.md +++ b/skills/codeman/reference/inspection-and-staging.md @@ -1,4 +1,11 @@ -# Bounded inspection and guarded submission +# Experimental bounded inspection and guarded submission + +**Experimental:** `codeman agent inspect`, `stage` and `submit`, and their +`/api/v1/sessions/:id/inspect|stage|submit` routes (including `/api` aliases), +are excluded from the stable CLI/HTTP contract. Their commands, flags, paths, +payloads and behavior may change or be removed in a minor or patch release. +Pin a compatible Codeman version when automating them. Authentication and +session ownership checks still apply. Use these commands inside a Codeman session. They use its authenticated HTTP API. They supplement `send`, `wait` and `read` over the same HTTP transport; no direct diff --git a/skills/codeman/reference/verbs.md b/skills/codeman/reference/verbs.md index dd68d64d2..54a8899c5 100644 --- a/skills/codeman/reference/verbs.md +++ b/skills/codeman/reference/verbs.md @@ -814,7 +814,10 @@ Rules that matter: - **Lifetime.** An inbox goes with its session on delete. A server restart empties all inboxes unless the user turned on *Keep agent inbox across restarts* in Settings. -## Bounded inspection and guarded submission +## Experimental bounded inspection and guarded submission + +These three commands and their HTTP routes may change or be removed in minor or +patch releases; they are excluded from the stable CLI/HTTP contract. Use `codeman agent inspect` for bounded evidence and incremental local Codex cursors. Use `codeman agent stage --message-file` followed by one hash-confirmed diff --git a/src/cli-agent.ts b/src/cli-agent.ts index 9f0ccb8b1..8db5ad3e4 100644 --- a/src/cli-agent.ts +++ b/src/cli-agent.ts @@ -1272,7 +1272,7 @@ export function registerAgentCommands(program: Command): Command { agent .command('inspect ') - .description('Bounded transcript evidence or a labeled pane snapshot; opaque incremental cursors') + .description('Experimental: bounded transcript evidence or a labeled pane snapshot; opaque incremental cursors') .option('--max-bytes ', 'UTF8 evidence budget (1–65536)', '8192') .option('--since ', 'Resume a cursor from the same owner, pane and transcript') .option('--json', 'Machine-readable output') @@ -1288,7 +1288,9 @@ export function registerAgentCommands(program: Command): Command { agent .command('stage ') - .description('Type a printable single-line UTF8 file without Enter; requires a verifiable local Codex composer') + .description( + 'Experimental: type a printable single-line UTF8 file without Enter; requires a verifiable local Codex composer' + ) .requiredOption('--message-file ', 'Regular UTF8 file, at most 8192 bytes, no trailing newline') .option('--json', 'Machine-readable output') .action((id: string, options: { messageFile: string; json?: boolean }) => @@ -1297,7 +1299,7 @@ export function registerAgentCommands(program: Command): Command { agent .command('submit ') - .description('Send Enter once only when the staged composer, pane and hash still match') + .description('Experimental: send Enter once only when the staged composer, pane and hash still match') .requiredOption('--stage-id ', 'One-shot stage receipt') .requiredOption('--confirm-sha ', 'Exact SHA-256 returned by stage') .option('--json', 'Machine-readable output') From c5ffea0ed2d57ae77cf21aeaba98f24abd6756e5 Mon Sep 17 00:00:00 2001 From: Aamer Akhter Date: Sat, 24 Oct 2026 22:39:16 -0400 Subject: [PATCH 21/68] fix(agent): treat only an omitted case as a directory spawn, and document the directory launch `codeman agent spawn ""` (for example `spawn "$CASE"` with CASE unset) used to launch a worker in the caller's cwd. Both case checks now test `caseName !== undefined`, so an empty name reaches quick-start and is refused by its schema as before. Drop the `{ id }` fallback on the create answer: `POST /api/sessions` answers `{ session: { id } }`, and the test fakes now use that shape. Tests: an empty case name (unit and through the real argument parser and HTTP transport), the claude `--ready` order create, /interactive, wait-output on the created id, and a non-JSON failed start (id alone on stdout, reason on stderr, exit 1). Docs: the spawn mapping and directory-launch JSON shape in docs/api-reference.md, the README and wiki `codeman agent` sections, the cli-agent @fileoverview and the skill reference tables; reflow the new SKILL.md paragraph (plugin mirror synced); the changeset notes the local exit-4 refusal of an unknown mode for named cases. --- .changeset/project-worker-directories.md | 4 +- README.md | 3 +- docs/api-reference.md | 2 +- docs/wiki/Driving-Codeman-From-An-Agent.md | 20 +-- plugins/codeman/skills/codeman/SKILL.md | 12 +- .../skills/codeman/reference/messaging.md | 2 +- .../codeman/skills/codeman/reference/verbs.md | 2 +- skills/codeman/SKILL.md | 12 +- skills/codeman/reference/messaging.md | 2 +- skills/codeman/reference/verbs.md | 2 +- src/cli-agent.ts | 19 +-- test/cli-agent-project-spawn.test.ts | 115 +++++++++++++++++- 12 files changed, 160 insertions(+), 35 deletions(-) diff --git a/.changeset/project-worker-directories.md b/.changeset/project-worker-directories.md index c24035d5b..8062be168 100644 --- a/.changeset/project-worker-directories.md +++ b/.changeset/project-worker-directories.md @@ -2,4 +2,6 @@ "aicodeman": minor --- -Allow `codeman agent spawn` to start workers in the current project directory or an existing directory selected with `--working-dir`, while preserving explicit case quick-start behavior. +Allow `codeman agent spawn` to start workers in the current project directory or an existing directory selected with `--working-dir`, while preserving explicit case quick-start behavior. A directory launch uses `POST /api/v1/sessions` followed by the mode's start route (`/interactive` or `/shell`), and its `--json` output is `{ sessionId, workingDir, created: true, ready }` (plus `startupError` when the start failed, exit 1). Only an omitted case means "use a directory": an empty case name still goes to quick-start, which refuses it. + +One change for named-case spawns too: an unknown `--mode` is now refused locally (exit 4) before any request, where it used to reach quick-start and come back as the server's 400 (exit 1). diff --git a/README.md b/README.md index bb3c142c2..bec342ddf 100644 --- a/README.md +++ b/README.md @@ -982,6 +982,7 @@ The skill above drives the API with `curl` and `jq` from a preamble Codeman seed ```bash codeman agent ls # sessions; * marks this one SID=$(codeman agent spawn scratch-1 --mode claude) # quick-start + wait for the composer where the mode has a ready mark +SID=$(codeman agent spawn --working-dir ~/src/app --mode codex) # no case: an existing directory (cwd by default) codeman agent send "$SID" 'review src/, then say DONE' --until stop,exit --timeout 300000 # --wait = default signal set codeman agent read "$SID" # last answer (as the server reads it for that mode) codeman agent read "$SID" --tail 3000 # terminal tail, ANSI stripped (every mode) @@ -994,7 +995,7 @@ codeman agent ack # remove what you hand codeman agent rm "$SID" # any session except this one ``` -Rules the commands enforce rather than document: they refuse outside a Codeman session and never guess a URL; `send` transmits printable text plus Enter only (a control byte such as `Ctrl+C` is `app_exit` in opencode — ESC exists solely as `interrupt`, which never appends Enter); `rm` refuses an empty id, an unprovable self id and a prefix match in either direction. Ids may be the 8-character prefixes `ls` prints (resolved through the list; an ambiguous prefix refuses, anything shorter than 8 characters refuses on every verb). A prompt that starts with `-` goes after `--` (`send "$SID" -- "- fix the bug"`). The echo of the prompt you sent is output too: a `--match` marker that appears verbatim in the prompt matches at once, before the worker has done anything, so the prompt asks for it in halves. A remote session whose host is asleep answers a fire-and-forget `send` with `buffered` (Codeman wakes the host and types the prompt once the pane is back) or `dropped` (over the wake buffer's cap, nothing will be typed: exit `1`). Exit codes: `0` delivered/matched/signal, `1` error, `2` timeout, `3` the worker exited or the wait ended without an answer (`delivered:false`, `ended:true`), `4` refused. `spawn` prints the id alone on stdout (prose goes to stderr), so `SID=$(…)` captures exactly the id. `--json` prints the envelope's `data` for every verb. `--until stop` on a mode without hook signals is the server's 400, passed through — the marker path (`--match`) is the answer there, exactly as for the skill. +Rules the commands enforce rather than document: they refuse outside a Codeman session and never guess a URL; `send` transmits printable text plus Enter only (a control byte such as `Ctrl+C` is `app_exit` in opencode — ESC exists solely as `interrupt`, which never appends Enter); `rm` refuses an empty id, an unprovable self id and a prefix match in either direction. Ids may be the 8-character prefixes `ls` prints (resolved through the list; an ambiguous prefix refuses, anything shorter than 8 characters refuses on every verb). A prompt that starts with `-` goes after `--` (`send "$SID" -- "- fix the bug"`). The echo of the prompt you sent is output too: a `--match` marker that appears verbatim in the prompt matches at once, before the worker has done anything, so the prompt asks for it in halves. A remote session whose host is asleep answers a fire-and-forget `send` with `buffered` (Codeman wakes the host and types the prompt once the pane is back) or `dropped` (over the wake buffer's cap, nothing will be typed: exit `1`). Exit codes: `0` delivered/matched/signal, `1` error, `2` timeout, `3` the worker exited or the wait ended without an answer (`delivered:false`, `ended:true`), `4` refused. `spawn` prints the id alone on stdout (prose goes to stderr), so `SID=$(…)` captures exactly the id. `spawn` with no case, or with `--working-dir`, starts the worker in that existing directory through `POST /api/v1/sessions` plus `POST /api/v1/sessions/:id/interactive` (or `/shell`) instead of quick-start, so no case is created and the directory is never labelled agent scratch; if the start fails it still prints the created id and exits `1`. `--json` prints the envelope's `data` for every verb, except a directory `spawn`, which prints `{ sessionId, workingDir, created: true, ready }` (plus `startupError` when the start failed). `--until stop` on a mode without hook signals is the server's 400, passed through — the marker path (`--match`) is the answer there, exactly as for the skill. ### MCP server (`codeman mcp`): drive Codeman from any MCP client diff --git a/docs/api-reference.md b/docs/api-reference.md index 95a28b178..9e0cbbc93 100644 --- a/docs/api-reference.md +++ b/docs/api-reference.md @@ -591,7 +591,7 @@ request returns. The capture runs synchronous tmux calls on the server; the ## The `codeman agent` CLI (client over these endpoints) -`codeman agent ls|spawn|send|wait|read|interrupt|rm|post|inbox|ack` (`src/cli-agent.ts`) is the command-line client for the endpoints above, for agents the skill does not reach (a pi or shell session, a machine without `jq`). It adds no route: `spawn` is `POST /api/v1/quick-start` (+ `wait-output` on the mode's `capabilities.composerReadyMark` from the CLI registry, where it declares one), `send` is `POST …/input` with `clientId`+`seq` (and `wait`/`waitTimeout` for `--wait` / `--until `; `delivered:false` without `duplicate` and `wait.ended` both exit 3 — the CLI never reports a dead worker as done), `wait` is `GET …/wait` (`--until`) or `GET …/wait-output` (`--match`, `from=buffer` by default), `read` is `GET …/last-response` or `GET …/terminal?tail=`, `interrupt` is `POST …/input` with a bare `\u001b`, `rm` is `DELETE …/sessions/:id`, and the inbox verbs map one to one onto the [agent inbox](#agent-inbox) routes (`post` → `POST …/inbox`, `inbox [id] [--wait]` → `GET …/inbox[?wait=]`, own inbox by default, `ack ` → `POST …/inbox/ack`); `ls` adds an `INBOX` column from `GET /api/v1/agent-inbox/summary` and drops it when that route does not answer. A fire-and-forget `send` to a sleeping wake-on-LAN host reads the route's `buffered` (own line, exit 0) and `dropped` (exit 1: the chunk is gone). An id may be the 8-character form `ls` prints, resolved through `GET /api/v1/sessions`; anything shorter refuses before any request, the same floor as `PARENT_SESSION_ID_MIN_PREFIX`. Every call carries `X-Codeman-Parent-Session`; only `spawn`'s quick-start carries `X-Codeman-Agent-Origin: codeman-agent-cli` (the agent-scratch label must never reach a request that cannot create the case directory). Basic auth comes from `CODEMAN_PASSWORD` or the data dir's `.env`. Server-side error codes are shown verbatim (`INVALID_INPUT: until=stop …` on a hook-less mode is not hidden); exit codes are `0` ok, `1` error, `2` timeout, `3` the session exited, `4` refused by a client-side guard (for `inbox --wait`, `3` only when `GET /api/v1/sessions` no longer lists the session; a wait the server ended otherwise, on a restart or a detach, is `2`). `post` takes text that starts with `-` as it is (a markdown file often does); text that is exactly an option (`--json`, `-h`) goes after `--`. See the README section "`codeman agent`" for the guards and `test/cli-agent.test.ts` for the pinned behaviour. +`codeman agent ls|spawn|send|wait|read|interrupt|rm|post|inbox|ack` (`src/cli-agent.ts`) is the command-line client for the endpoints above, for agents the skill does not reach (a pi or shell session, a machine without `jq`). It adds no route: `spawn ` is `POST /api/v1/quick-start`, and `spawn` with no case (the caller's cwd) or with `--working-dir ` (an existing local directory; combining it with a case refuses) is `POST /api/v1/sessions` with `workingDir`, `mode`, `name` and `parentSessionId` followed by `POST /api/v1/sessions/:id/interactive` or `/shell` (the mode's `capabilities.startMode`), never retried; either form then runs `wait-output` on the mode's `capabilities.composerReadyMark` from the CLI registry where it declares one. `send` is `POST …/input` with `clientId`+`seq` (and `wait`/`waitTimeout` for `--wait` / `--until `; `delivered:false` without `duplicate` and `wait.ended` both exit 3 — the CLI never reports a dead worker as done), `wait` is `GET …/wait` (`--until`) or `GET …/wait-output` (`--match`, `from=buffer` by default), `read` is `GET …/last-response` or `GET …/terminal?tail=`, `interrupt` is `POST …/input` with a bare `\u001b`, `rm` is `DELETE …/sessions/:id`, and the inbox verbs map one to one onto the [agent inbox](#agent-inbox) routes (`post` → `POST …/inbox`, `inbox [id] [--wait]` → `GET …/inbox[?wait=]`, own inbox by default, `ack ` → `POST …/inbox/ack`); `ls` adds an `INBOX` column from `GET /api/v1/agent-inbox/summary` and drops it when that route does not answer. A fire-and-forget `send` to a sleeping wake-on-LAN host reads the route's `buffered` (own line, exit 0) and `dropped` (exit 1: the chunk is gone). An id may be the 8-character form `ls` prints, resolved through `GET /api/v1/sessions`; anything shorter refuses before any request, the same floor as `PARENT_SESSION_ID_MIN_PREFIX`. Every call carries `X-Codeman-Parent-Session`; only `spawn`'s quick-start carries `X-Codeman-Agent-Origin: codeman-agent-cli` (the agent-scratch label must never reach a request that cannot create the case directory). Basic auth comes from `CODEMAN_PASSWORD` or the data dir's `.env`. `--json` prints the envelope's `data`, except a directory `spawn`, which prints `{ sessionId, workingDir, created: true, ready, readinessError }`, and on a failed or ambiguous start `{ sessionId, workingDir, created: true, ready: false, startupError }` (exit 1, the created id kept so the caller can inspect it). An unknown `--mode` refuses (exit 4) before any request, for a named case too. Server-side error codes are shown verbatim (`INVALID_INPUT: until=stop …` on a hook-less mode is not hidden); exit codes are `0` ok, `1` error, `2` timeout, `3` the session exited, `4` refused by a client-side guard (for `inbox --wait`, `3` only when `GET /api/v1/sessions` no longer lists the session; a wait the server ended otherwise, on a restart or a detach, is `2`). `post` takes text that starts with `-` as it is (a markdown file often does); text that is exactly an option (`--json`, `-h`) goes after `--`. See the README section "`codeman agent`" for the guards and `test/cli-agent.test.ts` for the pinned behaviour. `codeman mcp` (`src/mcp-server.ts`) is a Model Context Protocol server on stdio over the same endpoints, for MCP clients: `list_sessions` (`GET /api/v1/sessions`), `start_session` (`POST /api/v1/quick-start` with `X-Codeman-Agent-Origin: codeman-mcp`, then `wait-output` on the mode's ready mark), `send_prompt` (`POST …/input` with `wait`/`waitTimeout`), `wait_for_session` (`GET …/wait` or `…/wait-output`), `read_output` (`GET …/last-response` or `…/terminal?tail=`), `interrupt_session` and `delete_session`. It adds no route. The URL comes from `CODEMAN_API_URL` or `--url`, never a guess; prompting, interrupting and deleting are limited to sessions the process started (or the calling session's own children) unless it runs with `--allow-any-session`. See the README section "MCP server" and `test/mcp-server.test.ts`. diff --git a/docs/wiki/Driving-Codeman-From-An-Agent.md b/docs/wiki/Driving-Codeman-From-An-Agent.md index 9654dd003..6797a1b7a 100644 --- a/docs/wiki/Driving-Codeman-From-An-Agent.md +++ b/docs/wiki/Driving-Codeman-From-An-Agent.md @@ -41,6 +41,7 @@ act outside a Codeman session. One line in a case's `AGENTS.md` is enough: *"oth ```bash codeman agent ls # sessions; * marks this one SID=$(codeman agent spawn scratch-1 --mode claude) # quick-start + wait for the composer where the mode has a ready mark +SID=$(codeman agent spawn --working-dir ~/src/app --mode codex) # no case: an existing directory (cwd by default) codeman agent send "$SID" 'review src/, then say DONE' --until stop,exit --timeout 300000 codeman agent read "$SID" # last answer (as the server reads it for that mode) codeman agent read "$SID" --tail 3000 # terminal tail, ANSI stripped (every mode) @@ -55,12 +56,15 @@ codeman agent rm "$SID" # any session except t - **`spawn [case]`** uses the current directory when no case is supplied. Use `codeman agent spawn --working-dir /path/to/project --mode codex` for another existing - local directory. A case and `--working-dir` cannot be combined. Explicit case names - retain quick-start behavior (creating a scratch case if missing). Directory launches - preserve the project and parent lineage, and never label it as agent scratch. Choose - a separate worktree for each worker that will edit code. If startup fails or its - response is lost, the CLI reports the exact created session ID; inspect that session - before deciding what to do next. + local directory. A case and `--working-dir` cannot be combined, and an empty case + name (`spawn "$CASE"` with `CASE` unset) is refused rather than read as "no case". + Explicit case names retain quick-start behavior (creating a scratch case if missing). + A directory launch is `POST /api/v1/sessions` followed by + `POST /api/v1/sessions/:id/interactive` (or `/shell`); it preserves the project and + parent lineage, and never labels it as agent scratch. Choose a separate worktree for + each worker that will edit code. If startup fails or its response is lost, the CLI + reports the exact created session ID and exits `1`; inspect that session before + deciding what to do next. - **Ids** may be the 8-character form `ls` prints. Anything shorter refuses, and so does an ambiguous prefix. - **`send`** takes ONE quoted argument of printable text and presses Enter. A prompt that @@ -76,7 +80,9 @@ codeman agent rm "$SID" # any session except t **Keep agent inbox across restarts** is on (see [Settings Reference](Settings-Reference)), and goes when its session is deleted. - **Exit codes** are the same for every verb: `0` done, `1` error, `2` timeout, `3` the - worker exited, `4` refused. `--json` prints the response's `data`. + worker exited, `4` refused. `--json` prints the response's `data`, except a directory + `spawn`, which prints `{ sessionId, workingDir, created: true, ready }` (plus + `startupError` when the start failed). ## The MCP server diff --git a/plugins/codeman/skills/codeman/SKILL.md b/plugins/codeman/skills/codeman/SKILL.md index 7e10e28a8..75f69edd4 100644 --- a/plugins/codeman/skills/codeman/SKILL.md +++ b/plugins/codeman/skills/codeman/SKILL.md @@ -41,11 +41,13 @@ verbs over the same endpoints, with the guards below (no control bytes, no self- no guessed URL) enforced in code, and it is also your own way through when this machine has no `jq` for the preamble below. -For an existing project, use `codeman agent spawn --working-dir /path/to/project --mode codex` (one command). With no case or `--working-dir`, `spawn` uses cwd. Give -editing workers separate worktrees. A positional case keeps quick-start behavior; -it cannot be combined with `--working-dir`. Directory launches keep parent lineage -and never mark the project as agent scratch. If startup fails or is ambiguous, the -CLI reports the exact created ID: inspect it before retrying or cleaning up. +For an existing project, use one command: +`codeman agent spawn --working-dir /path/to/project --mode codex`. With no case and +no `--working-dir`, `spawn` uses cwd. Give editing workers separate worktrees. A +positional case keeps quick-start behavior and cannot be combined with +`--working-dir`. Directory launches keep parent lineage and never mark the project +as agent scratch. If startup fails or is ambiguous, the CLI reports the exact +created ID: inspect it before retrying or cleaning up. ## 0. Guard and bootstrap diff --git a/plugins/codeman/skills/codeman/reference/messaging.md b/plugins/codeman/skills/codeman/reference/messaging.md index 3b99f200e..99db822e7 100644 --- a/plugins/codeman/skills/codeman/reference/messaging.md +++ b/plugins/codeman/skills/codeman/reference/messaging.md @@ -48,7 +48,7 @@ own head: the worker enforcing the cap is the one who has to be told about it. | Job | Channel | | --- | --- | -| spawn a worker, create its case | HTTP `quick-start` (the only path) | +| spawn a worker, create its case | HTTP `quick-start` (the only path that creates a case); an existing directory is `POST sessions` + `interactive` (`codeman agent spawn --working-dir`) | | readiness, incl. the trust dialog | HTTP, Flow 1 (a message cannot answer a dialog) | | deliver a task to a READY claude worker | **messaging** (preferred) or HTTP input | | steer a BUSY claude worker mid-turn | **messaging** (read between the worker's tool calls; the HTTP path can only type into the composer, where text waits for the turn to end) | diff --git a/plugins/codeman/skills/codeman/reference/verbs.md b/plugins/codeman/skills/codeman/reference/verbs.md index 65af5c65f..d2e088ad7 100644 --- a/plugins/codeman/skills/codeman/reference/verbs.md +++ b/plugins/codeman/skills/codeman/reference/verbs.md @@ -31,7 +31,7 @@ wrong directory.** `quick-start` with a new `caseName` does not find your repo: |-------------------|------|------------------------------| | a fresh scratch dir (throwaway experiments) | `POST /api/v1/quick-start {"caseName":"scratch-1","mode":"claude"}` with a **new** case name | Codeman creates the directory and **writes hooks**: `stop` and `blocked` fire, send-and-wait is trustworthy | | a linked case (a real repo in the linked-cases registry) | same call with the linked name | **hooks installed at session create**, so `stop` fires here too. Not guaranteed: the operator can turn it off. Check | -| any other absolute path, e.g. a git worktree you made | `POST /api/v1/sessions {"workingDir":"/abs/path","mode":"claude"}` then `POST /api/v1/sessions/:id/interactive` | same: **hooks installed at session create**, subject to the same setting. Check | +| any other absolute path, e.g. a git worktree you made | `POST /api/v1/sessions {"workingDir":"/abs/path","mode":"claude"}` then `POST /api/v1/sessions/:id/interactive` (what `codeman agent spawn --working-dir /abs/path` sends) | same: **hooks installed at session create**, subject to the same setting. Check | Read `.data.casePath` back from the `quick-start` response and check it is where you meant. `caseName` accepts letters, digits, `-` and `_` only, and it resolves through diff --git a/skills/codeman/SKILL.md b/skills/codeman/SKILL.md index 7e10e28a8..75f69edd4 100644 --- a/skills/codeman/SKILL.md +++ b/skills/codeman/SKILL.md @@ -41,11 +41,13 @@ verbs over the same endpoints, with the guards below (no control bytes, no self- no guessed URL) enforced in code, and it is also your own way through when this machine has no `jq` for the preamble below. -For an existing project, use `codeman agent spawn --working-dir /path/to/project --mode codex` (one command). With no case or `--working-dir`, `spawn` uses cwd. Give -editing workers separate worktrees. A positional case keeps quick-start behavior; -it cannot be combined with `--working-dir`. Directory launches keep parent lineage -and never mark the project as agent scratch. If startup fails or is ambiguous, the -CLI reports the exact created ID: inspect it before retrying or cleaning up. +For an existing project, use one command: +`codeman agent spawn --working-dir /path/to/project --mode codex`. With no case and +no `--working-dir`, `spawn` uses cwd. Give editing workers separate worktrees. A +positional case keeps quick-start behavior and cannot be combined with +`--working-dir`. Directory launches keep parent lineage and never mark the project +as agent scratch. If startup fails or is ambiguous, the CLI reports the exact +created ID: inspect it before retrying or cleaning up. ## 0. Guard and bootstrap diff --git a/skills/codeman/reference/messaging.md b/skills/codeman/reference/messaging.md index 3b99f200e..99db822e7 100644 --- a/skills/codeman/reference/messaging.md +++ b/skills/codeman/reference/messaging.md @@ -48,7 +48,7 @@ own head: the worker enforcing the cap is the one who has to be told about it. | Job | Channel | | --- | --- | -| spawn a worker, create its case | HTTP `quick-start` (the only path) | +| spawn a worker, create its case | HTTP `quick-start` (the only path that creates a case); an existing directory is `POST sessions` + `interactive` (`codeman agent spawn --working-dir`) | | readiness, incl. the trust dialog | HTTP, Flow 1 (a message cannot answer a dialog) | | deliver a task to a READY claude worker | **messaging** (preferred) or HTTP input | | steer a BUSY claude worker mid-turn | **messaging** (read between the worker's tool calls; the HTTP path can only type into the composer, where text waits for the turn to end) | diff --git a/skills/codeman/reference/verbs.md b/skills/codeman/reference/verbs.md index 65af5c65f..d2e088ad7 100644 --- a/skills/codeman/reference/verbs.md +++ b/skills/codeman/reference/verbs.md @@ -31,7 +31,7 @@ wrong directory.** `quick-start` with a new `caseName` does not find your repo: |-------------------|------|------------------------------| | a fresh scratch dir (throwaway experiments) | `POST /api/v1/quick-start {"caseName":"scratch-1","mode":"claude"}` with a **new** case name | Codeman creates the directory and **writes hooks**: `stop` and `blocked` fire, send-and-wait is trustworthy | | a linked case (a real repo in the linked-cases registry) | same call with the linked name | **hooks installed at session create**, so `stop` fires here too. Not guaranteed: the operator can turn it off. Check | -| any other absolute path, e.g. a git worktree you made | `POST /api/v1/sessions {"workingDir":"/abs/path","mode":"claude"}` then `POST /api/v1/sessions/:id/interactive` | same: **hooks installed at session create**, subject to the same setting. Check | +| any other absolute path, e.g. a git worktree you made | `POST /api/v1/sessions {"workingDir":"/abs/path","mode":"claude"}` then `POST /api/v1/sessions/:id/interactive` (what `codeman agent spawn --working-dir /abs/path` sends) | same: **hooks installed at session create**, subject to the same setting. Check | Read `.data.casePath` back from the `quick-start` response and check it is where you meant. `caseName` accepts letters, digits, `-` and `_` only, and it resolves through diff --git a/src/cli-agent.ts b/src/cli-agent.ts index 6b78ab48c..1e4b37fb0 100644 --- a/src/cli-agent.ts +++ b/src/cli-agent.ts @@ -2,9 +2,11 @@ * @fileoverview `codeman agent …` — session-to-session verbs for the agent running * inside a Codeman session, in every CLI mode. * - * A thin HTTP client over endpoints that already exist (`quick-start`, `input`, - * `wait`, `wait-output`, `last-response`, `terminal`, `DELETE sessions/:id`, and the - * agent inbox: `inbox`, `inbox/ack`, `agent-inbox/summary`). It + * A thin HTTP client over endpoints that already exist (`quick-start` for a named + * case, `POST sessions` + `sessions/:id/interactive|shell` for a worker in an + * existing directory, `input`, `wait`, `wait-output`, `last-response`, `terminal`, + * `DELETE sessions/:id`, and the agent inbox: `inbox`, `inbox/ack`, + * `agent-inbox/summary`). It * invents no route and no transport: everything goes through `CODEMAN_API_URL`, so * auth, ownership and the per-session waiter cap apply unchanged. The behaviour is * the packaged agent skill's (`skills/codeman`), ported from shell prose into code @@ -539,9 +541,11 @@ export function composerReadyMark(mode: string): string | undefined { export async function agentSpawn(deps: AgentDeps, options: SpawnOptions): Promise { const cli = getCli(options.mode); if (!cli) return fail(deps, `Unknown mode: ${options.mode}`, EXIT.refused); - if (options.caseName && options.workingDir !== undefined) + if (options.caseName !== undefined && options.workingDir !== undefined) return fail(deps, 'Use a case OR --working-dir, not both', EXIT.refused); - const workingDir = options.caseName ? undefined : resolve(options.workingDir ?? process.cwd()); + // Only an OMITTED case means "use a directory": an empty one (`spawn "$CASE"` with + // CASE unset) still goes to quick-start, whose schema refuses it. + const workingDir = options.caseName !== undefined ? undefined : resolve(options.workingDir ?? process.cwd()); if (workingDir) { // eslint-disable-next-line no-control-regex if (/[\x00-\x1f\x7f]/.test(workingDir)) @@ -565,9 +569,10 @@ export async function agentSpawn(deps: AgentDeps, options: SpawnOptions): Promis headers: workingDir ? undefined : { ...AGENT_ORIGIN_HEADER }, }); const created = res.json?.data as - | { id?: string; session?: { id?: string }; sessionId?: string; caseName?: string; casePath?: string } + | { session?: { id?: string }; sessionId?: string; caseName?: string; casePath?: string } | undefined; - const createdId = created?.session?.id ?? created?.id; + // `POST /api/sessions` answers `{ session: { id, ... } }`; quick-start answers `{ sessionId, ... }`. + const createdId = created?.session?.id; const data: | { sessionId?: string; caseName?: string; casePath?: string; workingDir?: string; created?: boolean } | undefined = workingDir && createdId ? { sessionId: createdId, workingDir, created: true } : created; diff --git a/test/cli-agent-project-spawn.test.ts b/test/cli-agent-project-spawn.test.ts index b1af3e88b..dc19650d7 100644 --- a/test/cli-agent-project-spawn.test.ts +++ b/test/cli-agent-project-spawn.test.ts @@ -1,8 +1,9 @@ import { mkdtemp, rm, writeFile } from 'node:fs/promises'; +import http from 'node:http'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { Command } from 'commander'; -import { afterEach, describe, expect, it } from 'vitest'; +import { afterEach, describe, expect, it, vi } from 'vitest'; import { agentSpawn, registerAgentCommands, @@ -53,7 +54,7 @@ describe('project-preserving agent spawn', () => { expect(JSON.parse(t.out[0])).toMatchObject({ sessionId: OTHER, workingDir: directory }); }); it('defaults to the current directory when no case is supplied', async () => { - const t = deps([ok({ id: OTHER }), ok({})]); + const t = deps([ok({ session: { id: OTHER } }), ok({})]); expect(await agentSpawn(t.d, base)).toBe(EXIT.ok); expect(t.calls[0].body).toMatchObject({ workingDir: process.cwd() }); }); @@ -81,7 +82,7 @@ describe('project-preserving agent spawn', () => { }); it('reports the created session ID when startup fails instead of retrying or silently leaking it', async () => { const t = deps([ - ok({ id: OTHER }), + ok({ session: { id: OTHER } }), { status: 400, text: '', json: { success: false, error: 'cannot start', errorCode: 'OPERATION_FAILED' } }, ]); expect(await agentSpawn(t.d, base)).toBe(EXIT.error); @@ -134,8 +135,114 @@ describe('project-preserving agent spawn', () => { expect(spawn.options.some((option) => option.long === '--working-dir')).toBe(true); }); it('starts a shell through its registered startMode endpoint', async () => { - const t = deps([ok({ id: OTHER }), ok({})]); + const t = deps([ok({ session: { id: OTHER } }), ok({})]); expect(await agentSpawn(t.d, { ...base, mode: 'shell' })).toBe(EXIT.ok); expect(t.calls[1].path).toBe(`/api/v1/sessions/${OTHER}/shell`); }); + it('a create answer without session.id is a failure, and nothing is started', async () => { + // `POST /api/sessions` answers `{ session: { id } }`; a bare `{ id }` is not that route. + const t = deps([ok({ id: OTHER })]); + expect(await agentSpawn(t.d, base)).toBe(EXIT.error); + expect(t.calls).toHaveLength(1); + }); + it('an empty case name goes to quick-start (which refuses it), never to the caller cwd', async () => { + const t = deps([ + { + status: 400, + text: '', + json: { success: false, error: 'Invalid case name format', errorCode: 'INVALID_INPUT' }, + }, + ]); + expect(await agentSpawn(t.d, { ...base, caseName: '' })).toBe(EXIT.error); + expect(t.calls).toHaveLength(1); + expect(t.calls[0]).toMatchObject({ method: 'POST', path: '/api/v1/quick-start', body: { caseName: '' } }); + expect(t.calls[0].body).not.toHaveProperty('workingDir'); + }); + it('an empty case name with --working-dir is refused like any case plus --working-dir', async () => { + const t = deps([]); + expect(await agentSpawn(t.d, { ...base, caseName: '', workingDir: process.cwd() })).toBe(EXIT.refused); + expect(t.calls).toEqual([]); + }); + it('with ready on claude: create, then /interactive, then wait-output on the created id', async () => { + const t = deps([ok({ session: { id: OTHER } }), ok({}), ok({ wait: { matched: true } })]); + expect(await agentSpawn(t.d, { ...base, mode: 'claude', ready: true })).toBe(EXIT.ok); + expect(t.calls.map((c) => `${c.method} ${c.path}`)).toEqual([ + 'POST /api/v1/sessions', + `POST /api/v1/sessions/${OTHER}/interactive`, + `GET /api/v1/sessions/${OTHER}/wait-output`, + ]); + expect(t.calls[2].query).toMatchObject({ match: 'shift+tab', from: 'buffer' }); + expect(JSON.parse(t.out[0])).toMatchObject({ sessionId: OTHER, created: true, ready: true }); + }); + it('without --json a failed start prints the id alone on stdout, the reason on stderr, and exits 1', async () => { + const t = deps([ + ok({ session: { id: OTHER } }), + { status: 400, text: '', json: { success: false, error: 'cannot start', errorCode: 'OPERATION_FAILED' } }, + ]); + t.d.json = false; + expect(await agentSpawn(t.d, { ...base, ready: true })).toBe(EXIT.error); + expect(t.out).toEqual([OTHER]); + expect(t.err).toHaveLength(1); + expect(t.err[0]).toContain(`Session ${OTHER} was created but startup failed`); + expect(t.err[0]).toContain('cannot start'); + expect(t.calls).toHaveLength(2); // no readiness wait after a failed start + }); +}); + +describe('agent spawn through the real argument parser and transport', () => { + async function runSpawn(args: string[]) { + const seen: Array<{ method?: string; url?: string; body: unknown }> = []; + const server = http.createServer((req, res) => { + const chunks: Buffer[] = []; + req.on('data', (c: Buffer) => chunks.push(c)); + req.on('end', () => { + const raw = Buffer.concat(chunks).toString(); + seen.push({ method: req.method, url: req.url, body: raw ? JSON.parse(raw) : undefined }); + res + .writeHead(400, { 'Content-Type': 'application/json' }) + .end(JSON.stringify({ success: false, error: 'refused', errorCode: 'INVALID_INPUT' })); + }); + }); + await new Promise((done) => server.listen(0, '127.0.0.1', done)); + const { port } = server.address() as { port: number }; + const saved = { ...process.env }; + const savedExit = process.exitCode; + const log = vi.spyOn(console, 'log').mockImplementation(() => {}); + const error = vi.spyOn(console, 'error').mockImplementation(() => {}); + try { + Object.assign(process.env, { + CODEMAN_MUX: '1', + CODEMAN_API_URL: `http://127.0.0.1:${port}`, + CODEMAN_SESSION_ID: SELF, + }); + delete process.env.CODEMAN_PASSWORD; + const program = new Command(); + program.exitOverride(); + registerAgentCommands(program); + await program.parseAsync(['node', 'codeman', 'agent', 'spawn', ...args, '--no-ready', '--json']); + return { seen, exitCode: process.exitCode }; + } finally { + process.exitCode = savedExit; + for (const key of ['CODEMAN_MUX', 'CODEMAN_API_URL', 'CODEMAN_SESSION_ID', 'CODEMAN_PASSWORD']) { + if (saved[key] === undefined) delete process.env[key]; + else process.env[key] = saved[key]; + } + log.mockRestore(); + error.mockRestore(); + await new Promise((done) => server.close(() => done())); + } + } + + it('`spawn ""` (as `spawn "$CASE"` with CASE unset) reaches quick-start with caseName "" and exits 1', async () => { + const { seen, exitCode } = await runSpawn(['']); + expect(seen).toHaveLength(1); + expect(seen[0]).toMatchObject({ method: 'POST', url: '/api/v1/quick-start', body: { caseName: '' } }); + expect(exitCode).toBe(EXIT.error); + }); + it('`spawn` with no case argument creates in the caller cwd', async () => { + const { seen, exitCode } = await runSpawn([]); + expect(seen).toHaveLength(1); + expect(seen[0]).toMatchObject({ method: 'POST', url: '/api/v1/sessions', body: { workingDir: process.cwd() } }); + expect(exitCode).toBe(EXIT.error); + }); }); From 77ce4816a861f84cb5410bb92f9c85f24595fab3 Mon Sep 17 00:00:00 2001 From: Aamer Akhter Date: Sat, 24 Oct 2026 22:39:58 -0400 Subject: [PATCH 22/68] fix(agent): keep the error code when a request fails without a message Node 22 rejects a refused connection to a host that resolves to both ::1 and 127.0.0.1 with an AggregateError whose message is empty and whose code is ECONNREFUSED. The readiness fallback dropped that code, and the agent CLI's top-level error line and the MCP server's "Cannot reach Codeman" error printed nothing at all for it. Add describeRequestError() (message, else code, else a generic line) and use it at all three sites. --- src/cli-agent.ts | 16 ++++++++++++++-- src/mcp-server.ts | 3 ++- test/cli-agent.test.ts | 28 +++++++++++++++++++++++++++- test/mcp-server.test.ts | 11 +++++++++++ 4 files changed, 54 insertions(+), 4 deletions(-) diff --git a/src/cli-agent.ts b/src/cli-agent.ts index fc3fec03b..efacd6547 100644 --- a/src/cli-agent.ts +++ b/src/cli-agent.ts @@ -343,6 +343,18 @@ export const httpRequest: ApiRequest = (ctx, options) => { }); }; +/** + * One line describing a thrown request error. Node 22 rejects a refused connection to a host + * that resolves to both ::1 and 127.0.0.1 with an AggregateError whose `message` is empty, so + * fall back to its `code` (ECONNREFUSED) before a generic line rather than printing nothing. + */ +export function describeRequestError(error: unknown): string { + const message = getErrorMessage(error); + if (message) return message; + const code = (error as { code?: unknown } | null | undefined)?.code; + return code ? String(code) : 'request failed without a message'; +} + /** One line describing a failed response, for humans. Plain-text guards (401/403/429) have no envelope. */ export function describeFailure(res: ApiResponse): string { if (res.json && !res.json.success) { @@ -573,7 +585,7 @@ export async function agentSpawn(deps: AgentDeps, options: SpawnOptions): Promis ready = !dead && Boolean(result?.matched); } } catch (error) { - readinessError = getErrorMessage(error) || 'Readiness request failed'; + readinessError = describeRequestError(error); } } @@ -1042,7 +1054,7 @@ async function run(json: boolean, verb: (deps: AgentDeps) => Promise): P try { process.exitCode = await verb(deps); } catch (err) { - console.error(palette.err(`${GLYPH.fail} ${getErrorMessage(err)}`)); + console.error(palette.err(`${GLYPH.fail} ${describeRequestError(err)}`)); process.exitCode = EXIT.error; } } diff --git a/src/mcp-server.ts b/src/mcp-server.ts index c79b2b4ae..7d9a1b810 100644 --- a/src/mcp-server.ts +++ b/src/mcp-server.ts @@ -38,6 +38,7 @@ import { buildSendBody, composerReadyMark, describeFailure, + describeRequestError, httpRequest, inputRefusal, isSelfSession, @@ -497,7 +498,7 @@ export function createToolRunner(deps: ToolRunnerDeps) { } catch (err) { if (signal?.aborted) throw err; throw new ToolError( - `Cannot reach Codeman at ${ctx.apiUrl}: ${getErrorMessage(err)}. Check that it is running. If every call ` + + `Cannot reach Codeman at ${ctx.apiUrl}: ${describeRequestError(err)}. Check that it is running. If every call ` + 'fails at once, a sandbox around this MCP server may be blocking network access to this machine.' ); } diff --git a/test/cli-agent.test.ts b/test/cli-agent.test.ts index ff41f7cea..7f5796968 100644 --- a/test/cli-agent.test.ts +++ b/test/cli-agent.test.ts @@ -30,6 +30,7 @@ import { buildSendBody, deleteRefusal, describeFailure, + describeRequestError, httpRequest, inboxCell, inputRefusal, @@ -589,9 +590,34 @@ describe('agent spawn', () => { expect(await agentSpawn(deps, { caseName: 'c', mode: 'claude', ready: true, timeoutMs: 1000 })).toBe(EXIT.error); expect(deps.out).toHaveLength(1); - expect(JSON.parse(deps.out[0])).toEqual({ sessionId: OTHER, readinessError: 'Readiness request failed' }); + expect(JSON.parse(deps.out[0])).toEqual({ sessionId: OTHER, readinessError: 'request failed without a message' }); expect(deps.calls).toHaveLength(2); }); + + it('keeps the error code when a refused connection rejects with an empty AggregateError', async () => { + // Node 22 on a host resolving to ::1 and 127.0.0.1: message '', code ECONNREFUSED. + const refused = Object.assign(new AggregateError([new Error('connect ECONNREFUSED ::1:3000')], ''), { + code: 'ECONNREFUSED', + }); + const deps = fakeDeps((options) => { + if (options.path === '/api/v1/quick-start') return ok({ sessionId: OTHER }); + throw refused; + }, true); + + expect(await agentSpawn(deps, { caseName: 'c', mode: 'claude', ready: true, timeoutMs: 1000 })).toBe(EXIT.error); + expect(JSON.parse(deps.out[0])).toEqual({ sessionId: OTHER, readinessError: 'ECONNREFUSED' }); + }); +}); + +describe('describeRequestError', () => { + it('prefers the message, then the code, then a generic line', () => { + expect(describeRequestError(new Error('socket hang up'))).toBe('socket hang up'); + expect(describeRequestError(Object.assign(new AggregateError([], ''), { code: 'ECONNREFUSED' }))).toBe( + 'ECONNREFUSED' + ); + expect(describeRequestError(new Error(''))).toBe('request failed without a message'); + expect(describeRequestError('')).toBe('request failed without a message'); + }); }); describe('session id prefixes', () => { diff --git a/test/mcp-server.test.ts b/test/mcp-server.test.ts index fc552cf2f..a6f47e882 100644 --- a/test/mcp-server.test.ts +++ b/test/mcp-server.test.ts @@ -248,6 +248,17 @@ describe('protocol: both eras', () => { expect(result.isError).toBe(true); expect(result.content[0].text).toContain('CODEMAN_API_URL'); }); + + it('a refused connection with an empty AggregateError still names the error code', async () => { + const refused: ApiRequest = async () => { + throw Object.assign(new AggregateError([new Error('connect ECONNREFUSED ::1:9')], ''), { code: 'ECONNREFUSED' }); + }; + const { out, send } = server(config(), { request: refused, calls: [] }); + await send({ jsonrpc: '2.0', id: 1, method: 'tools/call', params: { name: 'list_sessions', arguments: {} } }); + const result = (out[0] as { result: { isError: boolean; content: Array<{ text: string }> } }).result; + expect(result.isError).toBe(true); + expect(result.content[0].text).toContain('Cannot reach Codeman at http://127.0.0.1:9: ECONNREFUSED.'); + }); }); describe('tool definitions', () => { From df9f93d3eeb58f1d957b790104f5c93708e4760f Mon Sep 17 00:00:00 2001 From: Aamer Akhter Date: Sat, 24 Oct 2026 22:47:44 -0400 Subject: [PATCH 23/68] fix(terminal): keep sentences and script commands out of spaced paths A side-by-side sweep of master and this branch over about 590 realistic lines found more shapes where a spaced candidate swallowed prose or a command: - An earlier word that is a lowercase connective or verb (and, or, to, in, the, is, has, contains, then, as and a few more) now makes the candidate a sentence, for own-line, list-marker and labelled forms alike: `/home/me/proj contains a.md`, `- /tmp/out has the logs in run.log`, `Path: /home/me/proj/out contains a.md`, `Files: src/components and utils/x.ts`, `Wrote: 3 files to docs/design notes/overview.md`. `at` and `of` stay out so screenshot names and `Copy of report.docx` still link whole; a name such as `Pros and Cons.md` splits as on master. - A bare `=` word is refused like the arrows. - An executable under `scripts/` or `tools/` is refused like one under `bin/` (`/home/me/proj/scripts/deploy prod.yml`). - An earlier word holding an extension anywhere (`a.ts#L12`) ends the path. The two residuals pinned earlier (`/home/me/proj contains a.md` and `Files: src/components and utils/x.ts`) now return master's links. --- CLAUDE.md | 2 +- docs/architecture-invariants.md | 2 +- src/web/public/constants.js | 35 ++++++++++++---- test/response-viewer-file-links.test.ts | 6 ++- test/spaced-file-path-links.test.ts | 56 ++++++++++++++++++++++--- 5 files changed, 84 insertions(+), 17 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index ed1dd67ce..b63958d23 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -222,7 +222,7 @@ Codeman is a Claude Code session manager with web interface and autonomous Ralph **Prompt uploads** (`POST /api/sessions/:id/paste-image`: clipboard paste, drop, the Compose Attach key and the phone toolbar's Attach button; client wiring in `image-input.js` and `keyboard-accessory.js`): a pick is written to `/.codeman-uploads/` and its path lands on the prompt as unsent text. The folder is IN the workspace because that is the only path identical for a local and a container agent, FLAT because `/.codeman/` would BE the data dir when the workspace is the home directory, and self-ignoring through a `.gitignore` of `*` written ONCE with `wx` (a file already there is the user's and is never overwritten; the user's own ignore file is never touched). ⚠️ The directory names live ONLY in `paste-image-gc.ts` (`UPLOAD_DIR_NAMES`): the pre-move `.claude-images/` receives nothing but stays readable for one release, so the GC sweep and the delete cleanup (through `uploadDirs()`) and the image watcher's ignore filter (reading the names) cover BOTH; retire the legacy entry there, never by hand elsewhere. ⚠️ `uploadDirs()` is async and probes the workspace BOUNDED first (`probePathKind()`, the #516 rule: an `unknown` path is skipped, never touched; the sweep keeps the stall cap and the delete passes `pastCap`), then returns only REAL directories (lstat, so a planted symlink is never one), none that is or contains `getDataDir()` (one strictly inside it only holds uploads and is listed), and none for a remote session, since both consumers delete; the check is made at list time, and the same-user race after it is accepted. Images are buffered in memory under `MAX_PASTE_IMAGE_BYTES` (50MB) after a client-side canvas re-encode; a DOCUMENT (pdf, docx, pptx, xlsx, zip and the text family txt/md/csv/tsv/json/log/yaml/yml/xml; the curated `ALLOWED_DOCUMENT_EXTS`, never `SUPPORTED_ATTACHMENT_EXTENSIONS` or `EDITABLE_EXTENSIONS`, which carry sh/py/js/html) is admitted by its name's extension alone, buffered like an image, judged on its bytes by the same fail-closed `imageMagicMatchesExt` (`%PDF-`, `PK\x03\x04` for the OOXML trio and zip, UTF-8 without a NUL byte for text: a SIGNATURE or ENCODING check, never a structure or content check, so generic zip bytes pass as `.docx` and a script renamed `.txt` is text; the sh/py/js/html exclusion is a SCOPE choice by extension, keeping the server from storing what it would serve or run, not a content filter: the agent can already write any of those) and saved as `paste---` with a sanitized stem (`[A-Za-z0-9._-]`, 40 chars, a wholly non-ASCII name leaves no stem; the `paste-` prefix is what the GC matches). A VIDEO is never buffered: `?kind=video` streams it to disk under `MAX_PASTE_VIDEO_BYTES` (1GB, env `CODEMAN_MAX_PASTE_VIDEO_BYTES`), judges the container on the first 12 bytes as they arrive (ISO BMFF / QuickTime atom, EBML for webm, Ogg for ogv; a mislabeled body is drained unwritten from there, never parked on disk first) and unlinks on any failure, a dead transfer included. ⚠️ The flag is the ONLY way onto that path and admits video types alone (the registry's `VIDEO_ATTACHMENT_EXTENSIONS`, single-sourced: mp4/webm/mov/m4v/ogv, so an uploaded video is one the file viewer can also play; 3gp/mkv are out, since a phone's camera roll produces neither and neither previews), so an image can never ride the larger cap through memory; without it the route is byte-identical to the image-only version. Busboy takes its size limit before the part is seen, which is why the choice rides the URL and not the file. ⚠️ A video draws on its OWN per-(IP, session) rate bucket (`consumeVideoToken`, 3/min) beside the image bucket (`consumePasteToken`, 30/min): at 1GB each, 30/min would be 30GB of disk a minute, so the lower rate bounds it to 3GB; the image path keeps the 30/min bucket and is unchanged without the flag. A remote (SSH) session is refused (400) before any disk touch: its `workingDir` is the remote path, so the file would land on THIS host where the agent cannot read it (Docker is fine, the workspace is bind-mounted at the same absolute path). ⚠️ A live session's workspace can still sit on a network mount that stopped answering, so the route probes it BOUNDED (`probePathKind(session.workingDir, { pastCap: true })`, the #516 rule) before the first disk touch and refuses an `unknown` mount through `describeUnknownPath()` (OPERATION_FAILED), the way session create does, so a mount already unreachable cannot freeze a libuv worker on every upload (a mount that dies after the probe is the same residual race create accepts). ⚠️ Dispatch mirrors the client: an `image/*` MIME takes the IMAGE path whatever the name says (a GIF named `animation.txt` stays a GIF), a document extension takes the document branch, and anything else takes the image path exactly as before, where the HEIC sniff still recovers a HEIC under a generic name; a foreign extension with no image MIME gets its `Unsupported file type` 400 only AFTER that sniff fails, never before, so nothing the image path ever accepted by its bytes is refused. A text mismatch is refused as not UTF-8 text (UTF-16 when the file opens with a UTF-16 BOM, as Excel's "Unicode Text" and Notepad's "Unicode" do), telling the user to save it as UTF-8, and its log line names the failure (`utf16-bom`/`nul-byte`/`not-utf8`) and never prints the bytes, since the head of a text file is user content. ⚠️ The client classifies a pick through ONE helper, `_promptAttachKind()` (MIME type first, then the name's extension, because some Android file managers hand over a File with none; returns `image`, `video` or `document`); every pick and paste filter (Attach, drop, the tile grid's drop in `tile-grid.js`, the Compose and paste-dialog pickers, the clipboard handlers), the flag and the re-encode gate read it, never `file.type` directly. Those surfaces ADMIT through `_admitPromptAttachments()` (image-input.js), which calls it and toasts what a pick skipped (how many files of a mixed pick, `Unsupported file type` when none pass, nothing for a text paste); a new surface admits through that helper too, never by filtering on `_promptAttachKind()` itself, or it loses the toasts. ⚠️ A pick that lands while the tile grid owns the terminal goes to the PTY, never the main terminal's overlay: that terminal is parked and hidden then, and a tile's Enter cannot flush it (`_tilesOwnTerminal()` in the `_attachFromToolbar` guard). Tests: `test/toolbar-attach.test.ts`, `test/mobile-prompt-composer.test.ts`, `test/paste-image-gc.test.ts`, `test/paste-image-dir-shared.test.ts`, `test/upload-document-list-parity.test.ts` (the browser's copies of `ALLOWED_DOCUMENT_EXTS`: `_promptAttachKind()`, the three pickers' `accept=` and the two file-path link patterns), the paste-image block of `test/routes/session-routes.test.ts`. → [architecture-invariants#buffers-uploads-and-terminal-history](docs/architecture-invariants.md#buffers-uploads-and-terminal-history) -**File-path links (terminal + chat)**: a path an agent prints is clickable on BOTH surfaces and opens the file-preview overlay. ⚠️ ONE pattern (`FILE_PATH_LINK_PATTERN` / `absoluteFilePathPattern()` in constants.js) feeds the xterm link provider AND `_linkifyFilePaths()`, a fresh instance per call (`lastIndex`). Both call `findFilePathLinks()`, which reserves spaced-path spans first (an unlinkable one only while its last word holds a `/`; any label, before an absolute or relative path, must be an explicit path label, and prose after that label stays out; rules and trade-offs in the architecture-invariants paragraph linked below), then adds rooted, `~/`, relative and folder paths without overlapping a reservation, earlier match or URL; a relative path resolves against `workingDir` (`resolveLinkedFilePath`), `~/` only server-side on the session's host (attachment route), and `terminalPathLine()` joins a path a program hard-wrapped short of the edge. ⚠️ A folder link opens the **folder grid**: `POST /api/sessions/:id/folders` guards + GRANTS the folder (`folder-listing.ts`, own registry, never the 200-capped attachment registry), files come by bare name via `…/folders/:folderId/files/:name/*` through `resolveServableAttachmentPath` plus the same extension/regular-file gate on the symlink's resolved target, so no browser request carries a path; `~/` is refused in a Docker case (the container's home is not this host's). The chat linkifier walks TEXT NODES with DOM APIs, never rebuilds sanitized markup as a string. ⚠️ An out-of-workspace path goes through the ATTACHMENT routes (`POST /api/sessions/:id/attachments` with `notify: false`), never by widening `file-content`/`file-raw` or `file-stream-manager`'s `tail -f` allowlist. ⚠️ `TEXT_ATTACHMENT_EXTENSIONS` IS `EDITABLE_EXTENSIONS` (never a second list), and widening READ must never widen RUN: `html`/`htm`/`svg` stay download-only, other text is inert `text/plain`+`nosniff`. ⚠️ The ONE place HTML renders is `/html-view//...` (`html-view-routes.ts`, `html-view-capabilities.ts`): `POST /api/sessions/:id/html-view` mints a capability for the file's own directory, every response carries `Content-Security-Policy: sandbox` WITHOUT `allow-same-origin` (opaque origin even as a top-level tab, so the page can never read Codeman or drive its API) and with NO fetch directive (open network, owner decision 2026-10-13: never restrict it or add `allow-same-origin` without the owner), and only non-dot, asset-allowlisted files under that directory are served (GET/HEAD only; capabilities revoked with the web-tab ones on logout). That tree is served recursively, so minting is refused for a hidden directory or a broad root (`isBroadHtmlViewRoot`); the preview renders by default and the **Page** pill (or a refused mint) falls through to the source view and its Edit button. Never serve HTML same-origin anywhere else. Tests: `test/routes/html-view-routes.test.ts`. Media extensions are single-sourced in `attachment-registry.ts`. ⚠️ **XLSX previews parse in the BROWSER**, never on the server; the admission limits that keep a hostile workbook from freezing the worker live in `src/web/CLAUDE.md` (Spreadsheet preview). → [architecture-invariants#file-path-links-terminal--response-viewer](docs/architecture-invariants.md#file-path-links-terminal--response-viewer) +**File-path links (terminal + chat)**: a path an agent prints is clickable on BOTH surfaces and opens the file-preview overlay. ⚠️ ONE pattern (`FILE_PATH_LINK_PATTERN` / `absoluteFilePathPattern()` in constants.js) feeds the xterm link provider AND `_linkifyFilePaths()`, a fresh instance per call (`lastIndex`). Both call `findFilePathLinks()`, which reserves spaced-path spans first (an unlinkable one only while its last word holds a `/`; any label, before an absolute or relative path, must be an explicit path label, prose after that label stays out, and an earlier connective or verb such as `and` or `has` (`SPACED_PATH_PROSE_WORD`) makes any candidate prose; rules and trade-offs in the architecture-invariants paragraph linked below), then adds rooted, `~/`, relative and folder paths without overlapping a reservation, earlier match or URL; a relative path resolves against `workingDir` (`resolveLinkedFilePath`), `~/` only server-side on the session's host (attachment route), and `terminalPathLine()` joins a path a program hard-wrapped short of the edge. ⚠️ A folder link opens the **folder grid**: `POST /api/sessions/:id/folders` guards + GRANTS the folder (`folder-listing.ts`, own registry, never the 200-capped attachment registry), files come by bare name via `…/folders/:folderId/files/:name/*` through `resolveServableAttachmentPath` plus the same extension/regular-file gate on the symlink's resolved target, so no browser request carries a path; `~/` is refused in a Docker case (the container's home is not this host's). The chat linkifier walks TEXT NODES with DOM APIs, never rebuilds sanitized markup as a string. ⚠️ An out-of-workspace path goes through the ATTACHMENT routes (`POST /api/sessions/:id/attachments` with `notify: false`), never by widening `file-content`/`file-raw` or `file-stream-manager`'s `tail -f` allowlist. ⚠️ `TEXT_ATTACHMENT_EXTENSIONS` IS `EDITABLE_EXTENSIONS` (never a second list), and widening READ must never widen RUN: `html`/`htm`/`svg` stay download-only, other text is inert `text/plain`+`nosniff`. ⚠️ The ONE place HTML renders is `/html-view//...` (`html-view-routes.ts`, `html-view-capabilities.ts`): `POST /api/sessions/:id/html-view` mints a capability for the file's own directory, every response carries `Content-Security-Policy: sandbox` WITHOUT `allow-same-origin` (opaque origin even as a top-level tab, so the page can never read Codeman or drive its API) and with NO fetch directive (open network, owner decision 2026-10-13: never restrict it or add `allow-same-origin` without the owner), and only non-dot, asset-allowlisted files under that directory are served (GET/HEAD only; capabilities revoked with the web-tab ones on logout). That tree is served recursively, so minting is refused for a hidden directory or a broad root (`isBroadHtmlViewRoot`); the preview renders by default and the **Page** pill (or a refused mint) falls through to the source view and its Edit button. Never serve HTML same-origin anywhere else. Tests: `test/routes/html-view-routes.test.ts`. Media extensions are single-sourced in `attachment-registry.ts`. ⚠️ **XLSX previews parse in the BROWSER**, never on the server; the admission limits that keep a hostile workbook from freezing the worker live in `src/web/CLAUDE.md` (Spreadsheet preview). → [architecture-invariants#file-path-links-terminal--response-viewer](docs/architecture-invariants.md#file-path-links-terminal--response-viewer) **Filesystem path picker** (Link Existing "Browse" + the mobile keyboard's `📁 Path` key): lazy one-directory browsing via `GET /api/filesystem/browse`, with `GET /api/filesystem/preview` for the tapped file. Inserts the path **without** Enter, so the prompt is never submitted; the sibling `⌫ All` key clears only the unsent prompt and must never send the agent's `/clear`. ⚠️ This is a **second file-serving surface and inherits neither the attachment confinement nor its ownership scoping** — it allowlists Home, `CASES_DIR`, `/mnt/d` and `CODEMAN_FILE_PICKER_ROOTS`, blocks sensitive trees, and rejects symlink escapes **after** `realpath`. ⚠️ The optional `sessionId` is an ownership boundary that must be `canAccessOwned`-checked by hand (it does not go through `findSessionOrFail`), and in multi-user mode a non-admin gets only their own `userSpacePath` as a root: per-user spaces live INSIDE `homedir()`, so a `Home` root exposes every other user's workspace. Previews go through the same global conversion limiter, and Markdown/TXT/JSON are served as inert `text/plain`. → [architecture-invariants#filesystem-path-picker](docs/architecture-invariants.md#filesystem-path-picker) diff --git a/docs/architecture-invariants.md b/docs/architecture-invariants.md index 88805b8bb..ebbcbaa6c 100644 --- a/docs/architecture-invariants.md +++ b/docs/architecture-invariants.md @@ -409,7 +409,7 @@ A file path an agent prints is a link on both surfaces it can appear on, and cli **Path forms beyond the rooted one.** `findFilePathLinks(text)` (constants.js) is the single entry point both consumers call; it reserves spaced candidates first (including unlinkable spans), then detects rooted matches, then `~/` and relative paths (`RELATIVE_FILE_PATH_LINK_PATTERN`, same extension list, pinned equal by `test/link-provider-regex.test.ts`), then folder paths, each only where it overlaps neither an earlier match nor an `http(s)` URL. A relative match must start at a boundary (line start, whitespace, quote, bracket, `=`, `,`, `>`), so it can never begin inside a word, an absolute path or a URL; brackets, `=` and `,` end it (`(shot.png)`, `OUT=shots/a.png`). `resolveLinkedFilePath()` resolves a relative path against the session's `workingDir` (`.`/`..` collapsed, so `../x.png` lands outside and routes like any external path) and passes absolute and `~/` paths through. ⚠️ `~` is the home of the SESSION'S host, which only the server knows: `~/` paths always take the attachment route, where `registerExternalAttachment` expands them (`homedir()` locally, `$HOME` inside the remote probe script), and the guard runs on the expanded, realpath-resolved path as before. ⚠️ Programs hard-wrap their own output short of the terminal edge (Claude Code breaks tool output at 59-118 columns), so xterm's `isWrapped` never fires. `terminalPathLine()` (link provider only; touch selection keeps `terminalLogicalLine`) joins the next row when the previous row ends in a path token with a `/` and no extension and the join reaches an extension; a row that starts a new path is never joined: rooted or `~/`, `./`/`../`, or a relative one that opens on the same top directory the broken token did (the next entry of `git ls-files`/`find`/`ls -R`: `src/components` + `src/index.ts` are two paths, not `src/componentssrc/index.ts`), while a wrapped path continues INSIDE a name, so `…/captures/2` + `026-10-10/…` still joins; a 3+ row chain is kept only if a later row completes the path, and the broken row must be at least as long as the continuing one (keeps `cd /mnt/foo` + a longer next line apart). Tests: `test/file-path-link-forms.test.ts`. -Spaced paths are considered first by `findFilePathLinks()`: accepted candidates reserve their entire span even when unlinkable, so later detectors cannot link fragments. Relative and `~/` spaced paths require an explicit path label (`Path:`, `File:`, `Files:`, `Saved to:`, `Saved as:`, `Wrote:`, `Written to:`, `Output:`, `Created:`, `Location:`, `Attachment:`), optionally preceded by a marker. Generic labels and bare bullets defer to ordinary detection, including slash-containing prose such as `CI/CD` or `45/45`. A later `/`, `~/`, `./` or `../` word starts a separate path; an earlier word ending in `/` is a separate folder. Absolute paths keep the own-line and marker forms, including the residual ambiguity `/home/me/proj contains a.md`, plus the same path labels; any other label (`Error:`, `Note:`, `Saved the report to:`) defers an absolute path too. A rooted candidate needs a directory before its first space, so `/review the changes in src/a.ts` is prose. A `:` followed by a digit inside an earlier word is a line suffix (`a.ts:42:See b.md`, grep and compiler output) and ends the path there, a run of spaces is a column gap rather than a name, and a leading `./` is not a segment. An unlinkable candidate reserves its span only while its last word holds a `/`, so `/usr/bin/env node build.js` keeps its ordinary links while `/etc/secret files/passwords.txt` links nothing. A path label vouches that a path follows, not for the words before it: a relative candidate whose only `/` word is its last is prose after the label (`Wrote: 120 lines to src/foo.ts`, `Path: see src/foo.ts`) and defers to the ordinary detectors. The cost is a labelled relative path whose only space is in its top directory, `Saved to: My Docs/plan.md` (and `Saved to: 03 Attachments/plan.pptx`), which splits as on master; `03 Attachments/Build SIPOC Design/Build-SIPOC.pptx`, `notes/a b/c.md`, `~/` and `./` forms and absolute paths still link whole. The guard does not catch prose between two `/` words, so `Files: src/components and utils/x.ts` still links as one path (pinned as a trade-off). A command line is refused too: a first word that is an executable under `bin/` or `.bin/` (`/opt/homebrew/bin/node server.js`) or any `-x`/`--x` flag word (`…/.bin/tsc -p tsconfig.json`); a bare `-` (`Docs - Copy`, `OneDrive - Contoso`) is not a flag. The response viewer uses a leading `` path label in a paragraph or list item, or one starting a later line of a paragraph (after a `
`, since chat renders with `breaks: true`), as detection context without changing the label or displayed text; paths split across other inline elements remain detected per text node. Tests feed vendored marked output into the jsdom linkifier, so consumed bullets and bold labels are covered. Trailing whitespace uses linear `trimEnd()` before the length cap. +Spaced paths are considered first by `findFilePathLinks()`: accepted candidates reserve their entire span even when unlinkable, so later detectors cannot link fragments. Relative and `~/` spaced paths require an explicit path label (`Path:`, `File:`, `Files:`, `Saved to:`, `Saved as:`, `Wrote:`, `Written to:`, `Output:`, `Created:`, `Location:`, `Attachment:`), optionally preceded by a marker. Generic labels and bare bullets defer to ordinary detection, including slash-containing prose such as `CI/CD` or `45/45`. A later `/`, `~/`, `./` or `../` word starts a separate path; an earlier word ending in `/` is a separate folder. Absolute paths keep the own-line and marker forms plus the same path labels; any other label (`Error:`, `Note:`, `Saved the report to:`) defers an absolute path too. A rooted candidate needs a directory before its first space, so `/review the changes in src/a.ts` is prose. An earlier word holding a known extension anywhere (`a.ts#L12 foo.ts`) ends the path at that word, a `:` followed by a digit inside an earlier word is a line suffix (`a.ts:42:See b.md`, grep and compiler output) and ends the path there, a run of spaces is a column gap rather than a name, and a leading `./` is not a segment. An unlinkable candidate reserves its span only while its last word holds a `/`, so `/usr/bin/env node build.js` keeps its ordinary links while `/etc/secret files/passwords.txt` links nothing. A path label vouches that a path follows, not for the words before it: a relative candidate whose only `/` word is its last is prose after the label (`Wrote: 120 lines to src/foo.ts`, `Path: see src/foo.ts`) and defers to the ordinary detectors. The cost is a labelled relative path whose only space is in its top directory, `Saved to: My Docs/plan.md` (and `Saved to: 03 Attachments/plan.pptx`), which splits as on master; `03 Attachments/Build SIPOC Design/Build-SIPOC.pptx`, `notes/a b/c.md`, `~/` and `./` forms and absolute paths still link whole. An earlier word that is a lowercase connective or verb (`SPACED_PATH_PROSE_WORD`: `and`, `or`, `to`, `in`, `the`, `is`, `has`, `contains`, `then`, `as` and a few more) or a bare `=` makes the candidate a sentence, in every shape: `/home/me/proj contains a.md`, `- /tmp/out has the logs in run.log`, `Path: /home/me/proj/out contains a.md`, `Files: src/components and utils/x.ts`, `Wrote: 3 files to docs/design notes/overview.md`. The cost is a name that uses one of those words (`Pros and Cons.md`, `How to deploy.md`, `Documents and Settings`), which splits as on master; `at` (`Screenshot 2026-10-24 at 10.00.00.png`) and `of` (`Copy of report.docx`) are left out of the list on purpose. What remains ambiguous is an own-line absolute path followed by words that hold none of them (`/home/me/proj/out report.pdf` reads like `/home/me/docs/Read Me.txt`), which links as one path. A command line is refused too: a first word that is an executable under `bin/`, `.bin/`, `scripts/` or `tools/` (`/opt/homebrew/bin/node server.js`, `/home/me/proj/scripts/deploy prod.yml`) or any `-x`/`--x` flag word (`/home/me/proj/node_modules/.bin/tsc -p tsconfig.json`); a bare `-` (`Docs - Copy`, `OneDrive - Contoso`) is not a flag. The response viewer uses a leading `` path label in a paragraph or list item, or one starting a later line of a paragraph (after a `
`, since chat renders with `breaks: true`), as detection context without changing the label or displayed text; paths split across other inline elements remain detected per text node. Tests feed vendored marked output into the jsdom linkifier, so consumed bullets and bold labels are covered. Trailing whitespace uses linear `trimEnd()` before the length cap. **Folder grid: a folder is granted, then served by bare file name.** A folder path (rooted or `~/` whose last segment has no extension, relative only with a trailing `/`; `FOLDER_PATH_LINK_PATTERN` + `folderLinkPath()`) opens `openFolderGrid()` (panels-ui.js), as does the preview's "Show folder" button. `POST /api/sessions/:id/folders {path}` (`src/folder-listing.ts`) resolves the folder (locally a bounded `probePathKind(…, { pastCap: true })` first, then async `fs.realpath`; remotely one ssh round trip via `remoteListFolder`, through `runWithRemoteSshLimit`), runs the attachment guard on it (blocklist, `confineToWorkspace`, and in multi-user mode `folderAllowedForUser`: a non-admin only inside their own `userSpacePath` or the session's `workingDir`, as the filesystem path picker), locally BEFORE reading it, lists non-hidden subfolders and files of a supported attachment type (each file also blocklist-checked, newest first, capped at `MAX_FOLDER_ENTRIES`), and GRANTS it an id. Files are then fetched as `GET …/folders/:folderId/files/:name/(raw|preview|thumbnail)`: `:name` must be a bare file name of a supported type (no `/`, `\`, NUL, no leading `.`, since the listing hides those), the local file is probed bounded (`unknown` → 503, never 404), and the joined path goes through the SAME `resolveServableAttachmentPath` as an attachment by id (symlink re-resolution, remote re-probe, guard), then the same gate registration applies to what a symlink RESOLVES to (a supported extension on the resolved path and a regular file: locally `fs.stat`, remotely the probe kind; else 400, so `shot.png -> ../secret.bin` is not served and a FIFO or device behind a supported name never reaches `open`), then `folderAllowedForUser` once more on the resolved file, before `serveServableRaw` (the attachment raw route's body, shared) streams it. So browser requests still never carry a path. ⚠️ Grants live in their own registry (`folderGrants`, 32 per session, cleared with the session's attachments), NOT the attachment registry: that one is capped at 200 per session, and paging a folder of 300 renders would evict the session's real attachments. ⚠️ A local folder is never touched unbounded (#516 rule): a folder on a network mount that stopped answering answers 503 with `describeUnknownPath()`, not 404 and not a frozen server. ⚠️ The remote listing script tells "missing" (`E|n`, 404) from "is a file" (`E|f`, 400 `Not a folder`), because the client falls back to the file preview on `Not a folder`; a remote extensionless file otherwise answered "Folder not found". ⚠️ `.folder-grid-overlay` sits at `z-index: 5090`, one step BELOW the preview (5100) and above the response viewer (5000): a tile's preview opens on top and closing it lands back on the grid. ⚠️ ←/→/Esc are handled in a document CAPTURE listener with `stopPropagation`, because the terminal keeps keyboard focus under the overlays and an arrow that reached xterm would be typed into the session; inputs other than xterm's helper textarea keep their keys. ⚠️ A route PARAMETER is capped by the router (Fastify's default is 100 characters, answered as 404 "Route not found"), and a file name goes up to 255, percent-encoded: the server sets `routerOptions.maxParamLength` to `MAX_ROUTE_PARAM_LENGTH` (route-helpers.ts), the route test harness uses the same constant, and a wiring guard pins it. ⚠️ `~/` in a Docker case is refused with 400 (attachment POST and `POST …/folders`): the session is local for these routes, but `~` inside the container is the container's own home, and expanding it on the host would open a different file than the one the agent printed. Tests: `test/routes/folder-grid-routes.test.ts` (incl. the listing script on a real `sh`), `test/folder-grid-ui.test.ts`. diff --git a/src/web/public/constants.js b/src/web/public/constants.js index fafd87b3c..9ac144564 100644 --- a/src/web/public/constants.js +++ b/src/web/public/constants.js @@ -2241,14 +2241,18 @@ const SPACED_FILE_PATH_LABEL_PATTERN = * path needs a path label to be taken spaced. One trailing sentence mark is left * out, and a leading `./` is not a segment. The candidate must read as ONE path: * it ends on a known extension, a rooted one has a directory before its first - * space, no earlier word ends on an extension or `/`, and none holds a `:` and a - * digit (`a.ts:42`, a line suffix ends the path). Later words cannot start a new + * space, no earlier word holds an extension (`a.md,`, `a.ts#L12`) or ends in `/`, + * and none holds a `:` and a digit (`a.ts:42`, a line suffix ends the path). Later words cannot start a new * `/`, `~/`, `./` or `../` path, no earlier word ends in `:` or is an arrow, and * a run of spaces (a column gap) is refused, so two paths on a line are never * merged. A relative candidate whose only `/` word is its last is prose after the * label (`Wrote: 120 lines to src/foo.ts`) and is left alone, which also splits - * `Saved to: My Docs/plan.md` as master does. A command line is refused: an - * executable under `bin/` or `.bin/` as the first word, or any `-x`/`--x` flag word. + * `Saved to: My Docs/plan.md` as master does. An earlier word that is a lowercase + * connective or verb (SPACED_PATH_PROSE_WORD: `and`, `in`, `has`, `contains`, ...) + * or a bare `=` makes the candidate a sentence (`/tmp/out has the logs in run.log`), + * so a name such as `Pros and Cons.md` splits as master does. A command line is + * refused: an executable under `bin/`, `.bin/`, `scripts/` or `tools/` as the + * first word, or any `-x`/`--x` flag word. * * A candidate that is one path but not a linkable one (an unknown root such as * `/etc`, a `.`/`..`/empty segment) comes back with `link: false` when its last @@ -2278,13 +2282,20 @@ function spacedFilePathOnLine(line) { // or `/review the changes in src/a.ts` is a command, not a spaced path. if (path.startsWith('/') && !/^\/[^/]+\//.test(words[0])) return null; // A command whose executable sits under a known root (`/opt/homebrew/bin/node a.js`, - // `…/node_modules/.bin/tsc -p tsconfig.json`) is not a path either. - if (/\/\.?bin\/[^/]+$/.test(words[0]) || words.some((word) => /^-{1,2}\w/.test(word))) return null; + // `/home/me/proj/node_modules/.bin/tsc -p tsconfig.json`, `/home/me/proj/scripts/deploy prod.yml`) + // is not a path either. + if (/\/(?:\.?bin|scripts?|tools)\/[^/]+$/.test(words[0]) || words.some((word) => /^-{1,2}\w/.test(word))) { + return null; + } for (const word of words.slice(0, -1)) { - if (word.endsWith('/') || word.endsWith(':') || /^(?:—|–|→|=>|->|<-)$/.test(word)) return null; + if (word.endsWith('/') || word.endsWith(':') || /^(?:—|–|→|=>|->|<-|=)$/.test(word)) return null; // `a.ts:42` (grep -n, compiler output): the path already ended at the line suffix. if (/:\d/.test(word)) return null; - if (PATH_TOKEN_ENDS_WITH_EXTENSION.test(word.replace(/[.,;:!?)\]}]+$/, ''))) return null; + // An extension anywhere in an earlier word (`a.md,`, `a.ts#L12`) ends a path there. + if (PATH_TOKEN_HAS_EXTENSION.test(word)) return null; + // A connective or verb joins a path to prose (`/tmp/out has the logs in run.log`, + // `Files: src/components and utils/x.ts`), so the candidate is a sentence. + if (SPACED_PATH_PROSE_WORD.test(word)) return null; } if (words.slice(1).some((word) => /^(?:\/|~\/|\.{1,2}\/)/.test(word))) return null; const rooted = SPACED_PATH_ROOT.test(path) || path.startsWith('~/'); @@ -2487,6 +2498,14 @@ const FILE_LINK_ROOT_GROUP = /^\(\\\/\(\?:([^()]*)\)/.exec(FILE_PATH_LINK_PATTER const PATH_TOKEN_ENDS_WITH_EXTENSION = new RegExp(`\\.${FILE_LINK_EXTENSION_GROUP}$`); const PATH_TOKEN_HAS_EXTENSION = new RegExp(`\\.${FILE_LINK_EXTENSION_GROUP}\\b`); const PATH_TOKEN_STARTS_NEW_PATH = new RegExp(`^(?:~\\/|\\/(?:${FILE_LINK_ROOT_GROUP})\\/)`); +/** + * Lowercase connectives and verbs that put a spaced candidate in a sentence, for + * {@link spacedFilePathOnLine}. `at` stays out (`Screenshot 2026-10-24 at 10.00.00.png`) + * and so does `of` (`Copy of report.docx`); a name using a listed word + * (`Pros and Cons.md`) splits as it did before spaced paths were linked. + */ +const SPACED_PATH_PROSE_WORD = + /^(?:and|or|to|in|into|from|the|is|are|was|were|has|have|had|contains|holds|see|then|vs|via|as)$/; /** A rooted path's start (`/home/`, `/mnt/`, …), for {@link spacedFilePathOnLine}. */ const SPACED_PATH_ROOT = new RegExp(`^\\/(?:${FILE_LINK_ROOT_GROUP})\\/`); /** A relative head that names its own location (`./a`, `../a`) is an entry of its own. */ diff --git a/test/response-viewer-file-links.test.ts b/test/response-viewer-file-links.test.ts index 8d5e5cc12..fff275c4a 100644 --- a/test/response-viewer-file-links.test.ts +++ b/test/response-viewer-file-links.test.ts @@ -113,7 +113,11 @@ describe('response viewer file-path linkifier', () => { ['**Wrote:** 120 lines to src/foo.ts', ['src/foo.ts']], ['- **Output:** stored in dist/app.js', ['dist/app.js']], ['**Created:** new file src/components/Button.tsx', ['src/components/Button.tsx']], - ])('keeps prose after a bold path label out of the link: %s', (markdown, expected) => { + ['**Files:** src/components and utils/x.ts', ['utils/x.ts']], + ['**Wrote:** 3 files to docs/design notes/overview.md', ['notes/overview.md']], + ['**Path:** /home/me/proj/out contains a.md', ['/home/me/proj/out', 'a.md']], + ['- /tmp/out has the logs in run.log', ['/tmp/out', 'run.log']], + ])('keeps prose out of the link in rendered Markdown: %s', (markdown, expected) => { expect(paths(linkify(renderChat(markdown))).map((a) => a.getAttribute('data-path'))).toEqual(expected); }); diff --git a/test/spaced-file-path-links.test.ts b/test/spaced-file-path-links.test.ts index 479926938..ef4c26987 100644 --- a/test/spaced-file-path-links.test.ts +++ b/test/spaced-file-path-links.test.ts @@ -119,13 +119,15 @@ describe('spaced file paths', () => { expect(paths(line)).toEqual([path]); }); - it('requires a path label for relative candidates but retains absolute ambiguity', () => { + it('requires a path label for relative candidates but retains own-line absolute ambiguity', () => { expect(paths('- Fixed bug in src/My Docs/a.md')).toEqual(['Docs/a.md']); // Documented trade-off: a labelled relative path whose only space is in its top // directory reads like prose after the label, so it splits as on master. expect(paths('Saved to: 03 Attachments/plan.pptx')).toEqual(['Attachments/plan.pptx']); expect(paths('Saved to: My Docs/plan.md')).toEqual(['Docs/plan.md']); - expect(paths('/home/me/proj contains a.md')).toEqual(['/home/me/proj contains a.md']); + // Documented residual: words after an own-line absolute path that hold no prose + // word read like a spaced name (`/home/me/docs/Read Me.txt`), so they stay in. + expect(paths('/home/me/proj/out report.pdf')).toEqual(['/home/me/proj/out report.pdf']); expect(paths('Saved to: ~/My Docs/plan.md')).toEqual(['~/My Docs/plan.md']); }); @@ -241,10 +243,49 @@ describe('spaced file paths: shapes that keep master links', () => { } }); - it('documented trade-off: prose between two `/` words after a path label stays in the link', () => { - // The first `/` word is not the last, so the prose guard does not fire; master - // linked only `utils/x.ts` here. - expect(paths('Files: src/components and utils/x.ts')).toEqual(['src/components and utils/x.ts']); + // A connective or verb before the last word puts the candidate in a sentence, + // whatever the shape: own line, list marker or path label, absolute or relative. + it.each([ + ['/home/me/proj contains a.md', ['/home/me/proj', true], ['a.md']], + ['/home/me/proj in the README.md', ['/home/me/proj', true], ['README.md']], + ['/home/me/proj/src is where the code lives, see README.md', ['/home/me/proj/src', true], ['README.md']], + ['/tmp/a and b.md', ['/tmp/a', true], ['b.md']], + ['/home/me/proj/out vs b.md', ['/home/me/proj/out', true], ['b.md']], + ['- /tmp/out has the logs in run.log', ['/tmp/out', true], ['run.log']], + ['* /home/me/proj/docs contains a.md', ['/home/me/proj/docs', true], ['a.md']], + ['1. /home/me/proj/src then test/foo.test.ts', ['/home/me/proj/src', true], ['test/foo.test.ts']], + ['- /home/me/proj is the root, see README.md', ['/home/me/proj', true], ['README.md']], + ['Path: /home/me/proj/out contains a.md', ['/home/me/proj/out', true], ['a.md']], + ['Saved to: /home/me/out as report.docx', ['/home/me/out', true], ['report.docx']], + ['Files: src/components and utils/x.ts', ['utils/x.ts']], + ['Wrote: 3 files to docs/design notes/overview.md', ['notes/overview.md']], + ['Created: the file src/components/My Button/Button.tsx', ['Button/Button.tsx']], + ['Output: 2 files in dist/my app/a.js', ['app/a.js']], + ['Output: 12 KB written to dist/my app/a.js', ['app/a.js']], + ['/home/me/proj/out = report.pdf', ['/home/me/proj/out', true], ['report.pdf']], + ] as Array<[string, ...Array<[string, true?]>]>)('keeps a sentence out of the path: %s', (line, ...links) => { + expect(findFilePathLinks(line)).toEqual(masterLinks(line, ...links)); + }); + + it('ends the path at an earlier word that holds an extension', () => { + expect(findFilePathLinks('/home/me/proj/src/foo.ts#L12 foo.ts')).toEqual([ + { path: '/home/me/proj/src/foo.ts', index: 0 }, + { path: 'foo.ts', index: 29 }, + ]); + }); + + it('documented trade-off: a name that uses a prose word splits as on master', () => { + const line = '/home/me/docs/Pros and Cons.md'; + expect(findFilePathLinks(line)).toEqual(masterLinks(line, ['/home/me/docs/Pros', true], ['Cons.md'])); + // `at` and `of` are not prose words, so these names stay whole. + for (const p of [ + '/Users/me/Desktop/Screenshot 2026-10-24 at 10.00.00.png', + '/home/me/docs/Copy of report.docx', + '/home/me/docs/Terms of Service.pdf', + ]) { + expect(paths(p)).toEqual([p]); + expect(paths(`Saved to: ${p}`)).toEqual([p]); + } }); // A command whose executable lives under a known root, or one carrying a flag. @@ -257,6 +298,9 @@ describe('spaced file paths: shapes that keep master links', () => { 'test/foo.test.ts', ], ['/home/me/proj/node_modules/.bin/tsc -p tsconfig.json', '/home/me/proj/node_modules/.bin/tsc', 'tsconfig.json'], + ['/home/me/proj/scripts/deploy prod.yml', '/home/me/proj/scripts/deploy', 'prod.yml'], + ['/home/me/scripts/deploy config.yml', '/home/me/scripts/deploy', 'config.yml'], + ['/home/me/proj/tools/lint src/a.ts', '/home/me/proj/tools/lint', 'src/a.ts'], ])('does not reserve a command under a known root: %s', (line, exe, file) => { expect(findFilePathLinks(line)).toEqual(masterLinks(line, [exe, true], [file])); }); From 1bdc341784bd7253ea36fabbf744981d3f0f1db4 Mon Sep 17 00:00:00 2001 From: Aamer Akhter Date: Sat, 24 Oct 2026 22:50:23 -0400 Subject: [PATCH 24/68] fix(terminal): keep code, fields and measurements out of wrapped URLs A URL ending in `/` still took the next indented row when that row was code, a label, a log or config field, a measurement or the end of a sentence. A side-by-side sweep against master turned up shapes such as `ready.` under a codex `Listening on` line, `expect(x).toBe(y)` under a jest title, `Failure/Error:` under an rspec title, `API_KEY=...` under `BASE_URL=...`, `level=info` log fields, `45%|####` tqdm bars, `12.3MB/45.0MB`, `12.3ms` and `10:00:00.123`. A continuation is now refused when it ends with a colon, when it is a measurement or clock time, or when it is a `name=value` token under a URL ending in `/` (a query needs `?` first). A token with only `-`, `_` or `.` is also refused when it carries a call paren or ends a sentence. Wiki titles such as `Mercury_(planet)`, a file name before its period, query parameters after `?`, `&` or `_`, and percent-encoding that starts with digits still join. `self-hosted.` moves from the pinned trade-offs to the negatives; the trade-off is now a lone word without sentence punctuation. --- CLAUDE.md | 2 +- docs/architecture-invariants.md | 2 +- src/web/public/constants.js | 28 +++++- test/terminal-url-continuation.test.ts | 120 ++++++++++++++++++++++--- 4 files changed, 135 insertions(+), 17 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index de0200247..68de089e1 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -224,7 +224,7 @@ Codeman is a Claude Code session manager with web interface and autonomous Ralph **File-path links (terminal + chat)**: a path an agent prints is clickable on BOTH surfaces and opens the file-preview overlay. ⚠️ ONE pattern (`FILE_PATH_LINK_PATTERN` / `absoluteFilePathPattern()` in constants.js) feeds the xterm link provider AND `_linkifyFilePaths()`, a fresh instance per call (`lastIndex`). Both call `findFilePathLinks()`, which adds `~/`, relative and folder paths without overlapping a rooted match or a URL; a relative path resolves against `workingDir` (`resolveLinkedFilePath`), `~/` only server-side on the session's host (attachment route), and `terminalPathLine()` joins a path a program hard-wrapped short of the edge. ⚠️ A folder link opens the **folder grid**: `POST /api/sessions/:id/folders` guards + GRANTS the folder (`folder-listing.ts`, own registry, never the 200-capped attachment registry), files come by bare name via `…/folders/:folderId/files/:name/*` through `resolveServableAttachmentPath` plus the same extension/regular-file gate on the symlink's resolved target, so no browser request carries a path; `~/` is refused in a Docker case (the container's home is not this host's). The chat linkifier walks TEXT NODES with DOM APIs, never rebuilds sanitized markup as a string. ⚠️ An out-of-workspace path goes through the ATTACHMENT routes (`POST /api/sessions/:id/attachments` with `notify: false`), never by widening `file-content`/`file-raw` or `file-stream-manager`'s `tail -f` allowlist. ⚠️ `TEXT_ATTACHMENT_EXTENSIONS` IS `EDITABLE_EXTENSIONS` (never a second list), and widening READ must never widen RUN: `html`/`htm`/`svg` stay download-only, other text is inert `text/plain`+`nosniff`. ⚠️ The ONE place HTML renders is `/html-view//...` (`html-view-routes.ts`, `html-view-capabilities.ts`): `POST /api/sessions/:id/html-view` mints a capability for the file's own directory, every response carries `Content-Security-Policy: sandbox` WITHOUT `allow-same-origin` (opaque origin even as a top-level tab, so the page can never read Codeman or drive its API) and with NO fetch directive (open network, owner decision 2026-10-13: never restrict it or add `allow-same-origin` without the owner), and only non-dot, asset-allowlisted files under that directory are served (GET/HEAD only; capabilities revoked with the web-tab ones on logout). That tree is served recursively, so minting is refused for a hidden directory or a broad root (`isBroadHtmlViewRoot`); the preview renders by default and the **Page** pill (or a refused mint) falls through to the source view and its Edit button. Never serve HTML same-origin anywhere else. Tests: `test/routes/html-view-routes.test.ts`. Media extensions are single-sourced in `attachment-registry.ts`. ⚠️ **XLSX previews parse in the BROWSER**, never on the server; the admission limits that keep a hostile workbook from freezing the worker live in `src/web/CLAUDE.md` (Spreadsheet preview). → [architecture-invariants#file-path-links-terminal--response-viewer](docs/architecture-invariants.md#file-path-links-terminal--response-viewer) -The terminal provider scans `terminalLinkLine()`, which extends `terminalPathLine()` for URL continuations in indented paragraphs and table cells. List markers, bare numbers, version tokens and whole email addresses never continue a URL. A URL in the last column of a space-aligned table never takes the next row's first cell: a continuation row must start inside the URL's own cell, so a gap of two or more spaces between its indent and where the URL starts on the row above means it is the next record. `terminalCellWrappedPaths()` follows each table cell independently, gated by `pathContinuation()` and the per-cell row-length rule; accepted paths displace other links on their cells. Full-width divider rows stop `terminalLogicalLine()` stitching for both links and touch selection. OSC 8 links confirm the real target URI before the shared opener runs. +The terminal provider scans `terminalLinkLine()`, which extends `terminalPathLine()` for URL continuations in indented paragraphs and table cells. List markers, bare numbers, version tokens, whole email addresses, labels ending in `:`, measurements and clock times (`45%`, `12.3ms`, `10:00:00`) and a `name=value` field under a URL ending in `/` never continue a URL; a token with only `-`, `_` or `.` is also refused when it carries a call paren (`client.get()`) or ends a sentence (`Done.`). A URL in the last column of a space-aligned table never takes the next row's first cell: a continuation row must start inside the URL's own cell, so a gap of two or more spaces between its indent and where the URL starts on the row above means it is the next record. `terminalCellWrappedPaths()` follows each table cell independently, gated by `pathContinuation()` and the per-cell row-length rule; accepted paths displace other links on their cells. Full-width divider rows stop `terminalLogicalLine()` stitching for both links and touch selection. OSC 8 links confirm the real target URI before the shared opener runs. **Filesystem path picker** (Link Existing "Browse" + the mobile keyboard's `📁 Path` key): lazy one-directory browsing via `GET /api/filesystem/browse`, with `GET /api/filesystem/preview` for the tapped file. Inserts the path **without** Enter, so the prompt is never submitted; the sibling `⌫ All` key clears only the unsent prompt and must never send the agent's `/clear`. ⚠️ This is a **second file-serving surface and inherits neither the attachment confinement nor its ownership scoping** — it allowlists Home, `CASES_DIR`, `/mnt/d` and `CODEMAN_FILE_PICKER_ROOTS`, blocks sensitive trees, and rejects symlink escapes **after** `realpath`. ⚠️ The optional `sessionId` is an ownership boundary that must be `canAccessOwned`-checked by hand (it does not go through `findSessionOrFail`), and in multi-user mode a non-admin gets only their own `userSpacePath` as a root: per-user spaces live INSIDE `homedir()`, so a `Home` root exposes every other user's workspace. Previews go through the same global conversion limiter, and Markdown/TXT/JSON are served as inert `text/plain`. → [architecture-invariants#filesystem-path-picker](docs/architecture-invariants.md#filesystem-path-picker) diff --git a/docs/architecture-invariants.md b/docs/architecture-invariants.md index 1d0e124ed..e06bb19c1 100644 --- a/docs/architecture-invariants.md +++ b/docs/architecture-invariants.md @@ -409,7 +409,7 @@ A file path an agent prints is a link on both surfaces it can appear on, and cli **Path forms beyond the rooted one.** `findFilePathLinks(text)` (constants.js) is the single entry point both consumers call; it returns the rooted matches first, then `~/` and relative paths (`RELATIVE_FILE_PATH_LINK_PATTERN`, same extension list, pinned equal by `test/link-provider-regex.test.ts`), then folder paths, each only where it overlaps neither an earlier match nor an `http(s)` URL. A relative match must start at a boundary (line start, whitespace, quote, bracket, `=`, `,`, `>`), so it can never begin inside a word, an absolute path or a URL; brackets, `=` and `,` end it (`(shot.png)`, `OUT=shots/a.png`). `resolveLinkedFilePath()` resolves a relative path against the session's `workingDir` (`.`/`..` collapsed, so `../x.png` lands outside and routes like any external path) and passes absolute and `~/` paths through. ⚠️ `~` is the home of the SESSION'S host, which only the server knows: `~/` paths always take the attachment route, where `registerExternalAttachment` expands them (`homedir()` locally, `$HOME` inside the remote probe script), and the guard runs on the expanded, realpath-resolved path as before. ⚠️ Programs hard-wrap their own output short of the terminal edge (Claude Code breaks tool output at 59-118 columns), so xterm's `isWrapped` never fires. `terminalPathLine()` (link provider only; touch selection keeps `terminalLogicalLine`) joins the next row when the previous row ends in a path token with a `/` and no extension and the join reaches an extension; a row that starts a new path is never joined: rooted or `~/`, `./`/`../`, or a relative one that opens on the same top directory the broken token did (the next entry of `git ls-files`/`find`/`ls -R`: `src/components` + `src/index.ts` are two paths, not `src/componentssrc/index.ts`), while a wrapped path continues INSIDE a name, so `…/captures/2` + `026-10-10/…` still joins; a 3+ row chain is kept only if a later row completes the path, and the broken row must be at least as long as the continuing one (keeps `cd /mnt/foo` + a longer next line apart). Tests: `test/file-path-link-forms.test.ts`. -The terminal provider scans `terminalLinkLine()`, which extends `terminalPathLine()` for URL continuations in indented paragraphs and table cells. List markers, bare numbers and version tokens never continue a URL. A token with only hyphens, underscores or dots must end its cell (row end or a gap of at least two spaces), so prose such as `self-hosted runners` is not appended. A row end counts as a cell end, so `⏺ Docs live at https://example.com/guide/` followed by ` self-hosted.` still links `.../guide/self-hosted`, and an indented `and/or the API reference covers it.` still links `.../guide/and/or`; both are pinned as known trade-offs. A continuation must be indented (a column-0 row is new output, which keeps `status=200`, `24/7` and `really?` out), and a shell prompt (`user@host:`), a home path (`~/`), a whole email address or an HTTP status line never continues a URL. A URL in the last column of a space-aligned table never takes the next row's first cell: a continuation row must start inside the URL's own cell, so a gap of two or more spaces between its indent and where the URL starts on the row above means it is the next record. A divider row never continues the row above it, so a URL that reaches the right edge does not absorb a full-width `────` row below. A real plain slug followed by prose falls back to the original fragment link; `%`, `&`, `=`, `/` or `?` still qualify as stronger URL punctuation. `terminalCellWrappedPaths()` follows each table cell independently, gated by `pathContinuation()` and the per-cell row-length rule; accepted paths displace other links on their cells. Full-width divider rows stop `terminalLogicalLine()` stitching for both links and touch selection. OSC 8 links confirm the real target URI before the shared opener runs. +The terminal provider scans `terminalLinkLine()`, which extends `terminalPathLine()` for URL continuations in indented paragraphs and table cells. List markers, bare numbers and version tokens never continue a URL. A token with only hyphens, underscores or dots must end its cell (row end or a gap of at least two spaces), so prose such as `self-hosted runners` is not appended. A row end counts as a cell end, so `⏺ Docs live at https://example.com/guide/` followed by ` self-hosted` still links `.../guide/self-hosted`, and an indented `and/or the API reference covers it.` still links `.../guide/and/or`; both are pinned as known trade-offs. Such a token is still refused when it is code with a call paren (`client.get()`, `expect(x).toBe(y)`; a wiki title like `Mercury_(planet)` keeps its paren) or a word ending a sentence (`ready.`, `Done.`, `self-hosted.`; a file name keeps its period, so `install.html.` still joins). Whatever its punctuation, a token never continues a URL when it ends with a colon (a label such as `Failure/Error:` or `prometheus.io/scrape:`), when it is a measurement or clock time (`45%`, `45%|####`, `12.3MB/45.0MB`, `12.3ms`, `10:00:00`; percent-encoding such as `2026%20Plan` still joins), or when it is a `name=value` field (`API_KEY=...`, `level=info`) under a URL ending in `/`, since a query needs a `?` first (after `?`, `&` or `_` it is a query parameter and joins). A continuation must be indented (a column-0 row is new output, which keeps `status=200`, `24/7` and `really?` out), and a shell prompt (`user@host:`), a home path (`~/`), a whole email address or an HTTP status line never continues a URL. A URL in the last column of a space-aligned table never takes the next row's first cell: a continuation row must start inside the URL's own cell, so a gap of two or more spaces between its indent and where the URL starts on the row above means it is the next record. A divider row never continues the row above it, so a URL that reaches the right edge does not absorb a full-width `────` row below. A real plain slug followed by prose falls back to the original fragment link; `%`, `&`, `=`, `/` or `?` still qualify as stronger URL punctuation. `terminalCellWrappedPaths()` follows each table cell independently, gated by `pathContinuation()` and the per-cell row-length rule; accepted paths displace other links on their cells. Full-width divider rows stop `terminalLogicalLine()` stitching for both links and touch selection. OSC 8 links confirm the real target URI before the shared opener runs. **Folder grid: a folder is granted, then served by bare file name.** A folder path (rooted or `~/` whose last segment has no extension, relative only with a trailing `/`; `FOLDER_PATH_LINK_PATTERN` + `folderLinkPath()`) opens `openFolderGrid()` (panels-ui.js), as does the preview's "Show folder" button. `POST /api/sessions/:id/folders {path}` (`src/folder-listing.ts`) resolves the folder (locally a bounded `probePathKind(…, { pastCap: true })` first, then async `fs.realpath`; remotely one ssh round trip via `remoteListFolder`, through `runWithRemoteSshLimit`), runs the attachment guard on it (blocklist, `confineToWorkspace`, and in multi-user mode `folderAllowedForUser`: a non-admin only inside their own `userSpacePath` or the session's `workingDir`, as the filesystem path picker), locally BEFORE reading it, lists non-hidden subfolders and files of a supported attachment type (each file also blocklist-checked, newest first, capped at `MAX_FOLDER_ENTRIES`), and GRANTS it an id. Files are then fetched as `GET …/folders/:folderId/files/:name/(raw|preview|thumbnail)`: `:name` must be a bare file name of a supported type (no `/`, `\`, NUL, no leading `.`, since the listing hides those), the local file is probed bounded (`unknown` → 503, never 404), and the joined path goes through the SAME `resolveServableAttachmentPath` as an attachment by id (symlink re-resolution, remote re-probe, guard), then the same gate registration applies to what a symlink RESOLVES to (a supported extension on the resolved path and a regular file: locally `fs.stat`, remotely the probe kind; else 400, so `shot.png -> ../secret.bin` is not served and a FIFO or device behind a supported name never reaches `open`), then `folderAllowedForUser` once more on the resolved file, before `serveServableRaw` (the attachment raw route's body, shared) streams it. So browser requests still never carry a path. ⚠️ Grants live in their own registry (`folderGrants`, 32 per session, cleared with the session's attachments), NOT the attachment registry: that one is capped at 200 per session, and paging a folder of 300 renders would evict the session's real attachments. ⚠️ A local folder is never touched unbounded (#516 rule): a folder on a network mount that stopped answering answers 503 with `describeUnknownPath()`, not 404 and not a frozen server. ⚠️ The remote listing script tells "missing" (`E|n`, 404) from "is a file" (`E|f`, 400 `Not a folder`), because the client falls back to the file preview on `Not a folder`; a remote extensionless file otherwise answered "Folder not found". ⚠️ `.folder-grid-overlay` sits at `z-index: 5090`, one step BELOW the preview (5100) and above the response viewer (5000): a tile's preview opens on top and closing it lands back on the grid. ⚠️ ←/→/Esc are handled in a document CAPTURE listener with `stopPropagation`, because the terminal keeps keyboard focus under the overlays and an arrow that reached xterm would be typed into the session; inputs other than xterm's helper textarea keep their keys. ⚠️ A route PARAMETER is capped by the router (Fastify's default is 100 characters, answered as 404 "Route not found"), and a file name goes up to 255, percent-encoded: the server sets `routerOptions.maxParamLength` to `MAX_ROUTE_PARAM_LENGTH` (route-helpers.ts), the route test harness uses the same constant, and a wiring guard pins it. ⚠️ `~/` in a Docker case is refused with 400 (attachment POST and `POST …/folders`): the session is local for these routes, but `~` inside the container is the container's own home, and expanding it on the host would open a different file than the one the agent printed. Tests: `test/routes/folder-grid-routes.test.ts` (incl. the listing script on a real `sh`), `test/folder-grid-ui.test.ts`. diff --git a/src/web/public/constants.js b/src/web/public/constants.js index deb5cf2d4..78e04721b 100644 --- a/src/web/public/constants.js +++ b/src/web/public/constants.js @@ -2575,9 +2575,12 @@ function findTerminalUrls(text) { * - that row's first token looks like URL material (contains one of * `% & = / ? . - _`), is not a bare number/version or list marker, * and is not itself a new `scheme://`, a shell prompt (`user@host:`), - * a home path (`~/`), an email address or an HTTP status line; without - * `% & = / ?`, the token must finish its cell (row end or a gap of at - * least two spaces). + * a home path (`~/`), an email address, an HTTP status line, a label + * ending in `:`, a measurement or clock time (`45%`, `12.3ms`, + * `10:00:00`), or a `name=value` field under a URL ending in `/`; + * without `% & = / ?`, the token must finish its cell (row end or a + * gap of at least two spaces) and must not be code with a call paren + * or a word ending a sentence (`Done.`). * The last rule is what stops prose such as "…/files/" + "then run it" from * being glued into one link. * @@ -2643,10 +2646,27 @@ function terminalUrlContinuationExtent(buffer, row, cols, maxRows) { // Nor is a token that is a whole email address. if (/^[\w.+-]+@[\w-]+(?:\.[\w-]+)+$/.test(token)) break; if (!/[%&=/?.\-_]/.test(token) || /^v?\d+(?:\.\d+)*(?:[-+][\w.-]+)?[.,;:!?)]?$/i.test(token)) break; + // A label ends with a colon (`Failure/Error:`, `prometheus.io/scrape:`); + // the rest of a URL does not. + if (token.endsWith(':')) break; + // After a `/`, a `name=value` token is the next assignment or log field + // (`API_KEY=...`, `level=info`), not the URL: a query starts with `?`. + if (urlText.endsWith('/') && /^[A-Za-z_][\w.-]*=/.test(token)) break; + // Nor is a measurement or a clock time: `45%` and `45%|####` progress, + // `12.3MB/45.0MB`, `12.3ms`, `10:00:00` (`%7B` stays percent-encoding). + if (/^\d+(?:\.\d+)?(?:%(?![0-9A-Fa-f]{2})|(?:ms|s|[kKMGT]i?B)(?![\w-]))|^\d{1,2}:\d{2}/.test(token)) break; // Hyphens, underscores and dots also occur in prose. Without stronger // URL punctuation, the token must finish its cell, not introduce a sentence. const rest = nextText.slice(indent + token.length); - if (!/[%&=/?]/.test(token) && rest.trim() !== '' && !/^ {2,}/.test(rest)) break; + if (!/[%&=/?]/.test(token)) { + // Code (`client.get()`, `expect(x).toBe(y)`) is not a URL piece, and a + // word that ends a sentence (`ready.`, `Done.`) is prose. A wiki title + // (`Mercury_(planet)`) and a file name before its period + // (`install.html.`) still continue the URL. + if (/[^_]\(/.test(token)) break; + if (/[.,;!]$/.test(token) && !/\.[A-Za-z][A-Za-z0-9]{1,4}[.,;!]+$/.test(token)) break; + if (rest.trim() !== '' && !/^ {2,}/.test(rest)) break; + } const nextLogical = terminalLogicalLine(buffer, endRow + 1, cols, bound); if (!nextLogical) break; if (trailingColumns) firstRowUrlEnd = last.rawEnd; diff --git a/test/terminal-url-continuation.test.ts b/test/terminal-url-continuation.test.ts index ad6fc98f1..9f8cd8477 100644 --- a/test/terminal-url-continuation.test.ts +++ b/test/terminal-url-continuation.test.ts @@ -171,7 +171,7 @@ const TABLE_LAST_COLUMN_ROWS: [string, string, string, string, string | null][] // Known trade-offs: a row end counts as a cell end, so a short indented word // with URL punctuation still continues the URL. Pinned so a change is deliberate. const TRADE_OFF_ROWS: [string, string, string][] = [ - ['⏺ Docs live at https://example.com/guide/', ' self-hosted.', 'https://example.com/guide/self-hosted'], + ['⏺ Docs live at https://example.com/guide/', ' self-hosted', 'https://example.com/guide/self-hosted'], [ '⏺ The docs are at https://example.com/guide/', ' and/or the API reference covers it.', @@ -179,6 +179,69 @@ const TRADE_OFF_ROWS: [string, string, string][] = [ ], ]; +// Indented rows under a URL ending in `/` that carry URL punctuation but are +// code, a label, a `name=value` field, a measurement or a clock time, or a word +// ending a sentence. Each third element is the URL on the first row. +const FIELD_AND_CODE_ROWS: [string, string, string][] = [ + [' └ Listening on http://127.0.0.1:8080/', ' ready.', 'http://127.0.0.1:8080/'], + ['⏺ Deployed to https://app.example.com/', ' Done.', 'https://app.example.com/'], + ['⏺ Docs live at https://example.com/guide/', ' self-hosted.', 'https://example.com/guide/'], + [' Fetching https://example.com/files/', ' done...', 'https://example.com/files/'], + [ + ' ● fetch › handles https://api.example.com/v1/', + ' expect(received).toBe(expected)', + 'https://api.example.com/v1/', + ], + [' See https://example.com/api/', ' client.get()', 'https://example.com/api/'], + [ + '/home/u/.venv/lib/urllib3/__init__.py:35: Warning: see https://github.com/urllib3/urllib3/issues/', + ' warnings.warn(', + 'https://github.com/urllib3/urllib3/issues/', + ], + [ + ' 1) Client fetches https://api.example.com/v1/', + ' Failure/Error: expect(res.code).to eq(200)', + 'https://api.example.com/v1/', + ], + [ + 'Annotations: link.argocd.argoproj.io/external-link: https://grafana.example.com/d/', + ' prometheus.io/scrape: true', + 'https://grafana.example.com/d/', + ], + [' BASE_URL=https://api.example.com/', ' API_KEY=your-key-here', 'https://api.example.com/'], + [' AUTH_URL=https://auth.example.com/', ' LOG_LEVEL=debug', 'https://auth.example.com/'], + [ + ' level=info msg="request" url=https://api.example.com/v1/', + ' level=info msg="done" status=200', + 'https://api.example.com/v1/', + ], + [' url=https://api.example.com/v1/', ' request_id=abc-123', 'https://api.example.com/v1/'], + [ + ' Callback: https://example.com/oauth/callback/', + ' state=abc123 must match', + 'https://example.com/oauth/callback/', + ], + [ + ' Downloading https://huggingface.co/org/model/resolve/main/', + ' 45%|████▌ | 450M/1.00G [00:10<00:12, 45.0MB/s]', + 'https://huggingface.co/org/model/resolve/main/', + ], + [' Downloading https://example.com/files/', ' 12.3MB/45.0MB', 'https://example.com/files/'], + [' GET https://example.com/api/', ' 12.3ms', 'https://example.com/api/'], + [' https://example.com/blog/', ' 10:00:00.123', 'https://example.com/blog/'], +]; +// Continuations at the edge of those rules that are still the rest of the URL: +// a wiki title with parens, a file name before a sentence period, a query +// parameter after `?`, `&` or `_`, and percent-encoding that starts with digits. +const STILL_CONTINUES_ROWS: [string, string, string][] = [ + [' https://en.wikipedia.org/wiki/', ' Mercury_(planet)', 'https://en.wikipedia.org/wiki/Mercury_(planet)'], + ['⏺ The guide is at https://example.com/docs/', ' install.html.', 'https://example.com/docs/install.html'], + [' https://example.com/oauth?client_', ' id=abc&scope=read', 'https://example.com/oauth?client_id=abc&scope=read'], + [' https://example.com/cb?code=abc&', ' state=xyz', 'https://example.com/cb?code=abc&state=xyz'], + [' Docs: https://example.com/guide?', ' x=1&y=2 more text', 'https://example.com/guide?x=1&y=2'], + [' https://example.com/files/', ' 2026%20Plan.xlsx', 'https://example.com/files/2026%20Plan.xlsx'], +]; + describe('findTerminalUrls (grammar)', () => { const { findTerminalUrls } = loadPure(); const urls = (text: string) => findTerminalUrls(text).map((u) => u.url); @@ -291,6 +354,20 @@ describe('terminalUrlContinuationExtent (wrap joining)', () => { for (const row of [0, 1]) expect(terminalUrlContinuationExtent(buffer, row, 100, 12)).toBeNull(); }); + it.each(FIELD_AND_CODE_ROWS)( + 'does not join code, a field, a measurement or a sentence end after %s', + (first, next) => { + const buffer = fakeBuffer([{ text: first }, { text: next }], 100); + for (const row of [0, 1]) expect(terminalUrlContinuationExtent(buffer, row, 100, 12)).toBeNull(); + } + ); + + it.each(STILL_CONTINUES_ROWS)('still joins the continuation after %s', (first, next) => { + const buffer = fakeBuffer([{ text: first }, { text: next }], 100); + for (const row of [0, 1]) + expect(terminalUrlContinuationExtent(buffer, row, 100, 12)).toMatchObject({ startRow: 0, endRow: 1 }); + }); + it('does not join an indented email address', () => { const buffer = fakeBuffer([{ text: ' https://example.com/' }, { text: ' user@example.com' }], 100); for (const row of [0, 1]) expect(terminalUrlContinuationExtent(buffer, row, 100, 12)).toBeNull(); @@ -323,16 +400,13 @@ describe('terminalUrlContinuationExtent (wrap joining)', () => { } ); - it.each(['part%20two', 'x=1&y=2', 'path/slug', 'query?x=1'])( - 'keeps stronger URL punctuation followed by prose: %s', - (token) => { - const buffer = fakeBuffer( - [{ text: 'Docs: https://example.com/guide/' }, { text: ' ' + token + ' more text' }], - 100 - ); - expect(terminalUrlContinuationExtent(buffer, 0, 100, 12)).toMatchObject({ startRow: 0, endRow: 1 }); - } - ); + it.each(['part%20two', 'path/slug', 'query?x=1'])('keeps stronger URL punctuation followed by prose: %s', (token) => { + const buffer = fakeBuffer( + [{ text: 'Docs: https://example.com/guide/' }, { text: ' ' + token + ' more text' }], + 100 + ); + expect(terminalUrlContinuationExtent(buffer, 0, 100, 12)).toMatchObject({ startRow: 0, endRow: 1 }); + }); it('does not glue a following prose line onto a URL that ends a line', () => { const buffer = fakeBuffer( @@ -485,6 +559,30 @@ describe('terminal link provider with wrapped table URLs', () => { } ); + it.each(FIELD_AND_CODE_ROWS)( + 'keeps code, a field, a measurement or a sentence end separate: %s', + (first, next, url) => { + const { provide, openSpy } = harness([{ text: first }, { text: next }], 100); + const links = provide(1).filter((link) => /^https?:/.test(link.text)); + expect(links.map((link) => link.text)).toEqual([url]); + links[0].activate(null, links[0].text); + expect(openSpy).toHaveBeenCalledExactlyOnceWith(url, '_blank', 'noopener,noreferrer'); + expect(provide(2).filter((link) => /^https?:/.test(link.text))).toEqual([]); + } + ); + + it.each(STILL_CONTINUES_ROWS)('links the whole URL from both rows after %s', (first, next, joined) => { + const { provide, openSpy } = harness([{ text: first }, { text: next }], 100); + for (const line of [1, 2]) { + // One link per row the URL covers, each opening the whole URL. + const links = provide(line).filter((link) => /^https?:/.test(link.text)); + expect(new Set(links.map((link) => link.text))).toEqual(new Set([joined])); + } + const [link] = provide(2).filter((l) => /^https?:/.test(l.text)); + link.activate(null, link.text); + expect(openSpy).toHaveBeenCalledExactlyOnceWith(joined, '_blank', 'noopener,noreferrer'); + }); + it('keeps an indented email address out of the URL above it', () => { const { provide } = harness([{ text: ' https://example.com/' }, { text: ' user@example.com' }], 100); expect( From c78635a3d3d7010192344fbe4c8ff6833a6714ca Mon Sep 17 00:00:00 2001 From: Aamer Akhter Date: Sat, 24 Oct 2026 22:54:17 -0400 Subject: [PATCH 25/68] feat(agent): make coordination routes opt-in and address review Gate inspect, stage and submit behind the synced agentCoordinationEnabled setting (default OFF, App Settings > Cross-session features, zh-CN strings). While off, all three routes answer 403 FORBIDDEN naming the setting, checked before session lookup; the CLI verbs print it verbatim. Remember the attributed Codex rollout per session and re-verify only its head on later calls, rescanning only date directories not older than it for a /new conflict. The first walk reads 1 KiB prefixes first, so a store of 2,100+ unrelated rollouts still attributes. Answer unexpected inspection errors with 422 OPERATION_FAILED and log the session id and error code only. Read the guarded composer glyph and footer from the CLI registry: codex workDetect.promptGlyph and a new workDetect.composerFooterLine capability, compiled through compileVersionRegex. Codex footer effort words are one shared list with modelDetect.screenLine. Fix the wiki API reference link, frame the versioning policy entry as a case of the experimental rule, join the CLAUDE.md Agent CLI paragraph, and update the changeset, skill reference and architecture notes. --- .changeset/bounded-agent-coordination.md | 6 +- CLAUDE.md | 5 +- README.md | 2 +- docs/api-reference.md | 25 ++- docs/architecture-invariants.md | 33 ++- docs/cli-registry.md | 19 +- docs/versioning-policy.md | 27 ++- docs/wiki/Driving-Codeman-From-An-Agent.md | 17 +- docs/wiki/Settings-Reference.md | 8 + .../reference/inspection-and-staging.md | 7 +- .../codeman/skills/codeman/reference/verbs.md | 9 +- .../reference/inspection-and-staging.md | 7 +- skills/codeman/reference/verbs.md | 9 +- src/agent-codex-identity.ts | 188 ++++++++++++++---- src/agent-staging.ts | 70 +++++-- src/cli-agent.ts | 22 +- src/config/cli-registry/schema.ts | 10 + src/config/cli-registry/stock.ts | 16 +- src/config/cli-registry/types.ts | 9 + src/session.ts | 13 +- src/web/public/i18n.js | 3 + src/web/public/index.html | 7 + src/web/public/settings-ui.js | 3 + src/web/routes/agent-session-routes.ts | 50 ++++- src/web/schemas.ts | 7 + test/agent-codex-identity.test.ts | 85 +++++++- test/agent-coordination-setting.test.ts | 90 +++++++++ test/agent-staging.test.ts | 35 +++- test/cli-agent-inspection.test.ts | 33 +++ test/cli-registry-schema.test.ts | 21 ++ test/routes/agent-session-routes.test.ts | 111 ++++++++++- test/webserver-bound-port.test.ts | 25 +++ 32 files changed, 848 insertions(+), 124 deletions(-) create mode 100644 test/agent-coordination-setting.test.ts diff --git a/.changeset/bounded-agent-coordination.md b/.changeset/bounded-agent-coordination.md index b88bcef9b..73f9513ad 100644 --- a/.changeset/bounded-agent-coordination.md +++ b/.changeset/bounded-agent-coordination.md @@ -4,7 +4,11 @@ Add experimental bounded agent inspection with expiring scoped cursors and explicit pane snapshots, plus staged local Codex input and single-use hash-confirmed submission receipts. Verify the exact composer, caret, lifecycle and input revision before sending Enter, and serialize dispatch against managed input and auxiliary key operations. -Explicitly exclude the three new CLI verbs and matching HTTP routes (including `/api/v1` and `/api` aliases) from the stable contract while experimental; their shape and behavior may change in minor or patch releases. +The three new CLI verbs (`codeman agent inspect`, `stage`, `submit`) and their HTTP routes (`/api/v1` and `/api`) are labeled experimental, so the versioning policy's rule for experimental surfaces applies: their shape and behavior may change in minor or patch releases. They are opt-in behind a new synced setting, `agentCoordinationEnabled` (App Settings, Cross-session features, "Experimental agent coordination", default OFF, translated for zh-CN): while it is off all three routes answer `403 FORBIDDEN` with a message naming it, and the CLI verbs print that refusal verbatim. + +Codex rollout attribution remembers the attributed file per session and re-verifies only that file's head on later calls, rescanning only the date directories not older than it for a codex `/new`, so a large never-pruned `~/.codex/sessions` store no longer falls back to a pane snapshot or costs a full walk per call. An unexpected inspection read error answers `422 OPERATION_FAILED` and is logged with the session id and error code only, instead of a retryable `409`. + +The guarded composer read takes its glyph and footer from the CLI registry: codex's `workDetect.promptGlyph` and a new `workDetect.composerFooterLine` capability (config-supplied regex, compiled through the ReDoS guard). Only a CLI declaring it takes guarded input. Codex's footer effort words are now one shared list for this and `modelDetect.screenLine`. Document the initial local Codex support and conversation-attribution limits in the API, CLI and packaged skill. Other modes retain labeled snapshots or refuse unverifiable guarded input. diff --git a/CLAUDE.md b/CLAUDE.md index 683084f98..a5e8101f0 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -129,8 +129,7 @@ Codeman is a Claude Code session manager with web interface and autonomous Ralph **Agent wait primitives**: bounded long-polls: `GET /api/sessions/:id/wait`, `GET .../wait-output` (literal substring, **never** regex) and `wait`/`waitTimeout` on `POST .../input`. Registry `session-wait-registry.ts` (pure), bounds `config/agent-wait.ts`. ⚠️ A timeout is a 200 (`wait.timedOut`). ⚠️ `stop` exists for `claude` and `deepseek` ONLY (rule lives in `hooksAvailableForMode()`), `blocked` also for a CLI declaring `capabilities.workDetect.blockedLine` (codex, read off the screen; `blockedSignalAvailable()`): explicit request elsewhere is a 400. ⚠️ Send-and-wait registers the waiter BEFORE the write; teardown must `notifySignal('exit')` BEFORE `cancelAll()`; hangup abort listens on `reply.raw` (guarded by `writableFinished`), never `req.raw`; liveness comes from `isPaneDead`, never `session.pid`. ⚠️ Signals are edge-triggered with no history: gather fan-outs via send-and-wait or `wait-output` markers. Packaged as the `skills/codeman` skill (`codeman skill install`, plugin marketplace, or injection behind `agentSkillEnabled`, SYNCED, default OFF, into the directory each CLI scans: `capabilities.agentSkillDir`, `.claude/skills` for claude, `.agents/skills` for the others, none for pi): injection is add-only, marker-owned (`applyAgentSkill`), refuses symlinks, and refreshes a marker-owned user-level copy (`refreshUserAgentSkill`). → [architecture-invariants#agent-wait-primitives](docs/architecture-invariants.md#agent-wait-primitives), `docs/api-reference.md` -**Agent CLI (`codeman agent …`, `src/cli-agent.ts`)**: the command-line client for the wait primitives and the session verbs, for agents the skill does not reach (pi, shell, no `jq`; `seedAgentSessionPreamble` covers every local session whose CLI takes the skill, `capabilities.agentSkillDir`; every pane still exports `CODEMAN_MUX`/`CODEMAN_SESSION_ID`/`CODEMAN_API_URL`). It is an HTTP CLIENT: inspection and guarded stage/submit use the ownership-checked -`agent-session-routes.ts` module; the other verbs reuse existing routes, with no second transport (the plan's "Socket API" non-goal stands), every call carries `X-Codeman-Parent-Session`, only `spawn`'s quick-start carries `X-Codeman-Agent-Origin` (pinned in test/cli-agent.test.ts), auth (`src/codeman-credentials.ts`, shared with `codeman attach` and the TUI) from `CODEMAN_PASSWORD` or the data dir's `.env` (the same reader `codeman attach` uses — `readCodemanEnvFile`). Verbs return exit codes (`0` ok / `1` error / `2` timeout / `3` exited / `4` refused) instead of calling `process.exit`, so `test/cli-agent.test.ts` drives every verb against a recording fake transport plus the real `httpRequest` against a local server. ⚠️ Three guards live in code, not prose, and the tests pin them: refuse without `CODEMAN_MUX=1`+`CODEMAN_API_URL` (never guess a URL); `send` transmits printable text + `\r` only (`inputRefusal`: any byte < 0x20 or DEL refuses BEFORE the transport — opencode's `Ctrl+C` is `app_exit`), ESC exists solely as `interrupt` with no `\r`; `rm` fails closed (`deleteRefusal`: empty id, self shorter than 8, prefix match in EITHER direction, re-checked on the resolved id). ⚠️ The routes accept FULL ids only — an 8-char prefix is a 404 (measured live) — while `ls` prints prefixes, so `resolveSessionId` maps a short id through `GET /api/v1/sessions` and refuses an ambiguous one. It also refuses any id under 8 characters (exit 4, on every verb, before any request), the same floor as the server's `PARENT_SESSION_ID_MIN_PREFIX`, so `rm 9` can never pick an arbitrary session. ⚠️ A 400 for `--until stop` on a hook-less mode is passed through verbatim, never papered over: the marker path is the answer there. `spawn` on a readiness timeout exits 2 and LEAVES the session for inspection (the skill's `spawn_worker` deletes it; the CLI's caller is often a human); its stdout is the id ALONE (prose on stderr) so `SID=$(…)` works. ⚠️ Two server answers that look like success and are not: `delivered:false` without `duplicate` (the bytes went into a dead pane — the field exists so a client says "restart" instead of "wait longer") and `wait.ended:true, signal:null` (the worker died during a `stop`/`idle` wait: the registry satisfies only waiters that listed `exit`, then cancels the rest) — both exit 3, checked BEFORE the happy paths in `waitExitCode`. → README "`codeman agent`", `docs/api-reference.md` +**Agent CLI (`codeman agent …`, `src/cli-agent.ts`)**: the command-line client for the wait primitives and the session verbs, for agents the skill does not reach (pi, shell, no `jq`; `seedAgentSessionPreamble` covers every local session whose CLI takes the skill, `capabilities.agentSkillDir`; every pane still exports `CODEMAN_MUX`/`CODEMAN_SESSION_ID`/`CODEMAN_API_URL`). It is an HTTP CLIENT: inspection and guarded stage/submit use the ownership-checked `agent-session-routes.ts` module (experimental, behind `agentCoordinationEnabled`, SYNCED, default OFF: all three routes answer `403 FORBIDDEN` naming it while off, and the CLI shows that verbatim); the other verbs reuse existing routes, with no second transport (the plan's "Socket API" non-goal stands), every call carries `X-Codeman-Parent-Session`, only `spawn`'s quick-start carries `X-Codeman-Agent-Origin` (pinned in test/cli-agent.test.ts), auth (`src/codeman-credentials.ts`, shared with `codeman attach` and the TUI) from `CODEMAN_PASSWORD` or the data dir's `.env` (the same reader `codeman attach` uses — `readCodemanEnvFile`). Verbs return exit codes (`0` ok / `1` error / `2` timeout / `3` exited / `4` refused) instead of calling `process.exit`, so `test/cli-agent.test.ts` drives every verb against a recording fake transport plus the real `httpRequest` against a local server. ⚠️ Three guards live in code, not prose, and the tests pin them: refuse without `CODEMAN_MUX=1`+`CODEMAN_API_URL` (never guess a URL); `send` transmits printable text + `\r` only (`inputRefusal`: any byte < 0x20 or DEL refuses BEFORE the transport — opencode's `Ctrl+C` is `app_exit`), ESC exists solely as `interrupt` with no `\r`; `rm` fails closed (`deleteRefusal`: empty id, self shorter than 8, prefix match in EITHER direction, re-checked on the resolved id). ⚠️ The routes accept FULL ids only — an 8-char prefix is a 404 (measured live) — while `ls` prints prefixes, so `resolveSessionId` maps a short id through `GET /api/v1/sessions` and refuses an ambiguous one. It also refuses any id under 8 characters (exit 4, on every verb, before any request), the same floor as the server's `PARENT_SESSION_ID_MIN_PREFIX`, so `rm 9` can never pick an arbitrary session. ⚠️ A 400 for `--until stop` on a hook-less mode is passed through verbatim, never papered over: the marker path is the answer there. `spawn` on a readiness timeout exits 2 and LEAVES the session for inspection (the skill's `spawn_worker` deletes it; the CLI's caller is often a human); its stdout is the id ALONE (prose on stderr) so `SID=$(…)` works. ⚠️ Two server answers that look like success and are not: `delivered:false` without `duplicate` (the bytes went into a dead pane — the field exists so a client says "restart" instead of "wait longer") and `wait.ended:true, signal:null` (the worker died during a `stop`/`idle` wait: the registry satisfies only waiters that listed `exit`, then cancels the rest) — both exit 3, checked BEFORE the happy paths in `waitExitCode`. → README "`codeman agent`", `docs/api-reference.md` **MCP server (`codeman mcp`, `src/mcp-server.ts`)**: a Model Context Protocol server on stdio for any MCP client, a CLIENT of the HTTP API like `codeman agent` (reuses its `httpRequest`, `inputRefusal`, `buildSendBody`; the MCP server adds no routes). Seven tools: `list_sessions`, `start_session`, `send_prompt`, `wait_for_session`, `read_output`, `interrupt_session`, `delete_session`. ⚠️ Dual-era: answers the legacy `initialize` handshake (2024-11-05 to 2025-11-25) AND the 2026-07-28 revision (per-request `_meta` with `protocolVersion` + `clientCapabilities`, `server/discover`, `resultType: "complete"` on every modern result); a modern `tools/list`/`server/discover` MUST carry `ttlMs` + `cacheScope` (Claude Code 2.1.296 drops every tool without them: measured). ⚠️ stdout is protocol only; logs go to stderr. ⚠️ The URL comes from `--url`/`CODEMAN_API_URL`, never a guess, and unconfigured it still lists tools (registries introspect) while every call answers how to configure it. ⚠️ Prompt/interrupt/delete only sessions the PROCESS started (in-memory allowlist, so a restart fails closed) or, inside a pane, the calling session's own children, unless `--allow-any-session`; never its own session. ⚠️ `read_output` returns text ONLY, no `structuredContent` (a client that prefers it showed the model metadata instead of the output: measured). Waits default 45 s (Codex's MCP tool timeout is 60 s). Registry manifest `config/mcp-registry/server.json` (`mcpName` in package.json, version kept by `scripts/sync-plugin.mjs`). Tests: `test/mcp-server.test.ts`. @@ -176,7 +175,7 @@ Codeman is a Claude Code session manager with web interface and autonomous Ralph **Docker Compose deployment** (`docker/`): Codeman runs in a container and spawns Docker cases as **SIBLING** containers via the host socket, never nested. `resolveDockerDaemonMountSource()` maps HOME bind sources into the daemon's namespace (`CODEMAN_DOCKER_HOST_HOME`); `CODEMAN_CASES_PATH` makes workspaces resolve to the same absolute path on both sides. ⚠️ `CODEMAN_CASES_PATH` must move every consumer: resolve it only via `config/cases-dir.ts`. ⚠️ `.dockerignore` matches whole paths: keep `**/.env` or `docker/.env` secrets ship in the image. ⚠️ Long-form binds create missing sources ROOT-OWNED: `Start-Codeman.sh` pre-creates them, and `docker/entrypoint.sh` (root, `cap_add: [CHOWN, DAC_OVERRIDE, KILL, SETGID, SETUID]` against `cap_drop: ALL`, `KILL` for tini; pinned by the test) fixes ownership then drops to `PUID:PGID` via `setpriv`, never re-owning foreign dirs. ⚠️ Append `/opt/codeman-cli` and `~/.local/bin` to `PATH`, never prepend. ⚠️ `server.Dockerfile`, the compose file and `.env.example` feed the self-updater's environment gate (`docs/docker-self-update.md`). → [architecture-invariants#docker-compose-deployment](docs/architecture-invariants.md#docker-compose-deployment), `docs/docker-compose.md` -**CLI registry** (`src/config/cli-registry/`): every run mode is a `CliEntry` (discovery, launch argv template, env handling, `capabilities`, and the `overlays` behind remote/docker pane commands). **No code outside `stock.ts` may branch on a CLI id**: use a capability field or a NAMED PROFILE (`profiles.ts`); `test/cli-registry-no-id-branching.test.ts` and `test/frontend-cli-no-id-branching.test.ts` enforce it. ⚠️ Config holds typed argv tokens, never shell text; literals are validated at LOAD time and a bad one rejects the whole entry. ⚠️ Keep `external`, `hooks` and `altScreen` independent; never derive one from another. ⚠️ Config regexes (`discovery.version.regex`, `capabilities.workDetect.workingLine`, `capabilities.workDetect.watchingLine`, `capabilities.workDetect.awaitingLine`, `capabilities.workDetect.blockedLine`, `capabilities.modelDetect.screenLine`) must compile through `compileVersionRegex()`. ⚠️ `privilegedParams[].param` names a LAUNCH PARAM, not the legacy `Config` field (bridged only by `launch.legacyConfigAliases`); a wrong name silently clamps nothing. ⚠️ Resolve the registry AT CALL TIME, never in a module-level const. ⚠️ Remote claude/omp arms of `buildRemoteLaunchCommand` are not covered by the pane-command golden. `~/.codeman/clis.json` overrides entries. It is WRITTEN only by the opt-in CLI management routes (`cliManagementEnabled`, default OFF; `/api/clis`, `cli-registry-routes.ts`), and only through `mutateRegistryFile()` in `registry-writer.ts` (the one exception: a config-backup restore of `clis.json`, which then calls `reloadCliRegistry()`), which serializes mutations and refuses (409) a file that does not parse or has group/world permission bits rather than overwriting it. Importing the registry still writes nothing. → [architecture-invariants#cli-registry](docs/architecture-invariants.md#cli-registry), `docs/cli-registry.md` +**CLI registry** (`src/config/cli-registry/`): every run mode is a `CliEntry` (discovery, launch argv template, env handling, `capabilities`, and the `overlays` behind remote/docker pane commands). **No code outside `stock.ts` may branch on a CLI id**: use a capability field or a NAMED PROFILE (`profiles.ts`); `test/cli-registry-no-id-branching.test.ts` and `test/frontend-cli-no-id-branching.test.ts` enforce it. ⚠️ Config holds typed argv tokens, never shell text; literals are validated at LOAD time and a bad one rejects the whole entry. ⚠️ Keep `external`, `hooks` and `altScreen` independent; never derive one from another. ⚠️ Config regexes (`discovery.version.regex`, `capabilities.workDetect.workingLine`, `capabilities.workDetect.watchingLine`, `capabilities.workDetect.awaitingLine`, `capabilities.workDetect.blockedLine`, `capabilities.workDetect.composerFooterLine`, `capabilities.modelDetect.screenLine`) must compile through `compileVersionRegex()`. ⚠️ `privilegedParams[].param` names a LAUNCH PARAM, not the legacy `Config` field (bridged only by `launch.legacyConfigAliases`); a wrong name silently clamps nothing. ⚠️ Resolve the registry AT CALL TIME, never in a module-level const. ⚠️ Remote claude/omp arms of `buildRemoteLaunchCommand` are not covered by the pane-command golden. `~/.codeman/clis.json` overrides entries. It is WRITTEN only by the opt-in CLI management routes (`cliManagementEnabled`, default OFF; `/api/clis`, `cli-registry-routes.ts`), and only through `mutateRegistryFile()` in `registry-writer.ts` (the one exception: a config-backup restore of `clis.json`, which then calls `reloadCliRegistry()`), which serializes mutations and refuses (409) a file that does not parse or has group/world permission bits rather than overwriting it. Importing the registry still writes nothing. → [architecture-invariants#cli-registry](docs/architecture-invariants.md#cli-registry), `docs/cli-registry.md` **External CLI modes (OpenCode, Codex, Gemini, Antigravity, Pi, Grok, DeepSeek, OMP, Copilot)**: `isExternalCliMode()` in `session.ts` gates Claude-specific behavior off (Ralph tracker, BashToolParser, token parsing, ❯ readiness; readiness is output stabilization); work detection is per-CLI `capabilities.workDetect` data, not this gate. All nine **require tmux, no direct PTY fallback** (secrets go via socket-scoped `tmux setenv`, never the command line). ⚠️ `run*()` in `session-ui.js` MUST unwrap the `{success,data}` envelope. ⚠️ **Codex uses predictive write-through echo, never the buffer overlay**: `_predictHookOnData` must never `return` (wire path stays byte-identical), and flushed text and a bracketed paste must go out as separate delayed writes. ⚠️ **Pi**: no bypass flag, never invent one; `approveProjectTrust` executes repo code, so it is in the clamp's **materialize** branch; never wire `--api-key`. ⚠️ **Grok**: `alwaysApprove` is stripped for non-granted owners (only-if-sent). ⚠️ **DeepSeek**: the agent is a PROFILE (Run gates on `isDeepSeekRunnable()`); the permission switch is the `DSH_PERMISSION_MODE` env var, so `clampEnvOverridesForOwner()` must DROP `DSH_PERMISSION_MODE`, `DSH_HOME` and `DEEPSEEK_BASE_URL` for non-granted owners; `hooksAvailableForMode()` is per-SESSION for it (pass `sessionHookOptions(session)`) and is never a stand-in for `mode === 'claude'`; answers come from `deepseek-transcript.ts`, paired by header `cwd` + boot window, never newest-mtime. ⚠️ **OMP**: `OMP_AUTH_BROKER_URL`/`_TOKEN` are clamped the same way. ⚠️ **Copilot**: `allowAll` (`--yolo`) is a `privilegedParam` and `COPILOT_HOME`/`COPILOT_ALLOW_ALL`/`COPILOT_PROVIDER_*`/`COPILOT_MODEL` are privileged env keys, and so is every other `COPILOT_*` variable the CLI reads that switches approvals, runs code with no approval row or sends its sign-in token elsewhere (the 1.0.98 list is in `stock.ts`; the prefix admits whatever a later release adds, so re-read the bundle whenever the measured version moves); the resume id must start with a letter or digit (`path-segment`), since `--resume`'s value is optional and `--resume --yolo` would otherwise make `--yolo` its own flag; `--name` is dropped next to `--resume`/`--continue` (Copilot refuses the pair); its mouse DECSETs are stripped (`strip-mux-and-mouse`) so a drag selects and the wheel is forwarded by `_shouldForwardWheelToApp` while `cliMouseTracking` is on; `composerReadyMark` is the footer hint `/ commands · ? help`, NOT `❯` (the folder-trust dialog draws `❯ 1. Yes`); `last-response` reads the pane's visible screen (`transcriptNeedsScreenCapture`) through a per-CLI `PANE_DIALECTS` row, because its stream is cursor moves, not lines (a prompt row scrolled off by a tall answer reads as the answer's tail unless a banner/trust dialog is on screen, and typed composer text is not a prompt); Docker seeds no Copilot credential (`config.json` holds a plain-text token without a keyring). → [architecture-invariants#external-cli-modes-opencode-codex-gemini-antigravity-pi-grok-deepseek-omp](docs/architecture-invariants.md#external-cli-modes-opencode-codex-gemini-antigravity-pi-grok-deepseek-omp) diff --git a/README.md b/README.md index 36f0bf745..19d133141 100644 --- a/README.md +++ b/README.md @@ -977,7 +977,7 @@ codeman tui 3 # attach to session 3 of that list ### `codeman agent` — session-to-session verbs in every CLI mode -The skill above drives the API with `curl` and `jq` from a preamble Codeman seeds per session. Where it does not reach (a `pi` or `shell` session, a machine without `jq`), the same environment is still there (`CODEMAN_MUX=1`, `CODEMAN_SESSION_ID`, `CODEMAN_API_URL` are exported into every pane), so `codeman agent` packages the same verbs as commands. It is a thin client over the endpoints listed under [API](#api): auth and ownership apply to every endpoint, including bounded inspection and guarded input. The new `inspect`, `stage` and `submit` commands and their HTTP routes are **experimental**: commands, flags, paths, payloads and behavior may change or be removed in minor or patch releases. Pin a compatible version when automating them; see the [stability exception](docs/versioning-policy.md#experimental-agent-coordination). One line in a case's `AGENTS.md` is enough: *"other sessions: `codeman agent --help`"*. +The skill above drives the API with `curl` and `jq` from a preamble Codeman seeds per session. Where it does not reach (a `pi` or `shell` session, a machine without `jq`), the same environment is still there (`CODEMAN_MUX=1`, `CODEMAN_SESSION_ID`, `CODEMAN_API_URL` are exported into every pane), so `codeman agent` packages the same verbs as commands. It is a thin client over the endpoints listed under [API](#api): auth and ownership apply to every endpoint, including bounded inspection and guarded input. The `inspect`, `stage` and `submit` commands and their HTTP routes are **experimental** and **opt-in**: turn on **Experimental agent coordination** in App Settings (`agentCoordinationEnabled`, synced, off by default; until then the routes answer `403 FORBIDDEN` naming it). Their commands, flags, paths, payloads and behavior may change or be removed in minor or patch releases, so pin a compatible version when automating them; see [Experimental agent coordination](docs/versioning-policy.md#experimental-agent-coordination). One line in a case's `AGENTS.md` is enough: *"other sessions: `codeman agent --help`"*. ```bash codeman agent ls # sessions; * marks this one diff --git a/docs/api-reference.md b/docs/api-reference.md index 2bcbebb62..2d4c61612 100644 --- a/docs/api-reference.md +++ b/docs/api-reference.md @@ -593,11 +593,20 @@ request returns. The capture runs synchronous tmux calls on the server; the **Experimental:** `codeman agent inspect`, `stage` and `submit`, and their `/api/v1/sessions/:id/inspect|stage|submit` routes (including `/api` aliases), -are excluded from the stable CLI/HTTP contract. Their commands, flags, paths, +are labeled experimental, so the versioning policy's rule for experimental +surfaces applies (`docs/versioning-policy.md`, "Experimental agent +coordination"): they are not part of the stable contract. Their commands, flags, paths, payloads and behavior may change or be removed in a minor or patch release. Pin a compatible Codeman version when automating them. Authentication and session ownership checks still apply. +**Opt-in:** all three routes answer `403 FORBIDDEN` while the synced +`agentCoordinationEnabled` setting is off (the default; App Settings, +Cross-session features, **Experimental agent coordination**), with a message +naming it. The check runs before session lookup and body validation, and reads +the saved setting on every request, so turning it on needs no restart. +`codeman agent inspect|stage|submit` print that refusal verbatim (exit 1). + These authenticated routes use the standard envelope and session ownership checks. `/api/v1` aliases `/api`; an inaccessible session returns `404 NOT_FOUND` before any transcript, pane or receipt lookup. @@ -617,7 +626,14 @@ keeping UTF8/JSONL boundaries. Evidence bytes exclude response metadata and JSON escaping. Record scans cap at 256 KiB plus bounded identity anchors; attribution separately caps at 8192 directory entries, eight directory levels, 8 MiB of negative-filter prefix reads and 1 MiB of candidate metadata reads (including -final identity verification). Unreachable workspace/home/metadata directories +final identity verification). The negative filter reads 1 KiB of each rollout +first and 4 KiB only when that window holds no complete `originator` field. Once +a rollout is attributed, later calls re-verify only that file (same file identity, +same strict metadata) and rescan only the `sessions/YYYY/MM/DD` directories not +older than its own, for a second candidate such as a codex `/new`; a failed check +falls back to one full walk. So the caps bound the first call, not every call. +An unexpected read error is `422 OPERATION_FAILED` (logged with the session id +and error code only), not a retryable `409`. Unreachable workspace/home/metadata directories are probed with a bounded past-cap probe before canonical reads and remain unattributed. A full incremental page can be continued with its cursor; only a first pending record too large for the budget reports an output-budget block. @@ -643,7 +659,10 @@ filesystem path is returned as cursor/identity metadata; evidence retains the owner's native record content, which can itself contain paths. Guarded input initially requires a local characterized Codex composer, one -unwrapped visible line, a recognized footer and a cursor at its exact end. +unwrapped visible line, a recognized footer and a cursor at its exact end. The +composer glyph and footer are the CLI registry's +`capabilities.workDetect.promptGlyph` and `composerFooterLine`; a CLI that +declares no `composerFooterLine` is refused. Unknown/working/modal/shell/remote/container composers fail closed. Stage accepts at most 8192 UTF8 bytes, no C0/C1/DEL controls, multiline text, or trailing whitespace (the existing mux transport trims it). Before typing, it checks the diff --git a/docs/architecture-invariants.md b/docs/architecture-invariants.md index 3feb4f965..edd12634f 100644 --- a/docs/architecture-invariants.md +++ b/docs/architecture-invariants.md @@ -111,7 +111,7 @@ Further detail: ⚠️ An ADOPTED container may back SEVERAL cases at different ⚠️ `external`, `hooks` and `altScreen` are three INDEPENDENT capabilities on purpose; deriving one from another shipped the `until=stop`-hangs-on-shell bug. -⚠️ Four capability fields carry a REGEX from config (`discovery.version.regex`, `capabilities.workDetect.workingLine`, `capabilities.workDetect.watchingLine` and `capabilities.workDetect.awaitingLine`) and all four must compile through `compileVersionRegex()`, which caps length and refuses nested quantifiers; `workingLine` is the one that runs on the PTY hot path. +⚠️ Several capability fields carry a REGEX from config (`discovery.version.regex`, `capabilities.workDetect.workingLine`, `watchingLine`, `awaitingLine`, `blockedLine`, `composerFooterLine` and `capabilities.modelDetect.screenLine`) and all of them must compile through `compileVersionRegex()`, which caps length and refuses nested quantifiers; `workingLine` is the one that runs on the PTY hot path. ⚠️ **`param` is TWO namespaces.** `launch.params` keys, `env.configSetenv[].fromParam` and `capabilities.privilegedParams[].param` all name a LAUNCH PARAM; the legacy `Config` wire field is a separate namespace, bridged only by `launch.legacyConfigAliases`. Getting `privilegedParams[].param` wrong is SILENT — it is the multi-user bypass clamp's only handle on a CLI's privilege switch, and a wrong name clamps nothing with no load error and no failing test — so `schema.ts` rejects an entry naming a param it never declared. Codex is the entry where the two names differ (`bypassApprovals` vs `dangerouslyBypassApprovals`) and therefore the one that catches a regression. @@ -1264,8 +1264,15 @@ Its CLI knowledge is a GENERATED block (`npm run generate:cli-catalog`, markers ## Agent inspection and guarded submission These three operations are experimental across both CLI and HTTP, including the -`/api/v1` paths and unversioned aliases. See `docs/versioning-policy.md` for the -explicit stability exception; do not infer stability from the URL prefix. +`/api/v1` paths and unversioned aliases: a case of the "labeled experimental" rule +in `docs/versioning-policy.md`, so do not infer stability from the URL prefix. + +⚠️ They are opt-in. All three routes answer `403 FORBIDDEN` with a message naming +`agentCoordinationEnabled` (synced, default OFF, App Settings, Cross-session +features) until it is on, the `cliManagementEnabled` / `mcpSyncEnabled` shape. The +gate runs first, before session lookup or body validation, and reads +`settings.json` fresh on every request, so a toggle applies without a restart. +`codeman agent inspect|stage|submit` pass the 403 through verbatim. `agent-session-routes.ts` is registered by `WebServer` with the other route modules. Ownership is checked before transcript/pane/receipt lookup. Inspection @@ -1275,8 +1282,24 @@ complete UTF8 JSONL records. A full page advances its cursor without claiming a blocked record. Opaque cursors bind caller/session/lifecycle/file identity; pane geometry is excluded so changing devices does not invalidate transcript paging. -Staging requires a fresh, known local Codex composer/caret capture. The footer -uses the shared `CODEX_REASONING_EFFORTS` vocabulary. Before any write, available +⚠️ Codex never prunes `~/.codex/sessions`, so attribution must not cost a store +walk per call. The first call walks (negative-filter prefixes read 1 KiB first, +4 KiB only when that window holds no complete `originator` field; non-UUID file +names are skipped unread), then the attributed `{path, dev:ino}` and its strict +metadata are remembered per session (`attributionMemo`, bounded LRU). A later call +re-verifies only that file's head, then scans just the date directories not older +than its own (`sessions/YYYY/MM/DD`, compared numerically; any non-numeric name is +never pruned) for a second candidate, which is how a codex `/new` or a new owned +rollout beside a configured resume is still caught. A failed check drops the memo +and falls back to one full walk. Non-`InspectionConflict` errors are `422 +OPERATION_FAILED` (never a retryable `409`) and are logged with the session id and +error code only, never the path. + +Staging requires a fresh, known composer/caret capture. The composer glyph and +footer are registry data (`capabilities.workDetect.promptGlyph` and +`composerFooterLine`, compiled through `compileVersionRegex()`), and only a CLI +declaring `composerFooterLine` takes guarded input (codex today). Codex's footer +words come from the same `CODEX_EFFORT_WORDS` as its `modelDetect.screenLine`. Before any write, available columns are calculated from actual captured geometry and the rendered prefix, including indentation; reaching the last column or wrapping is refused. Stage cancels older verifier retries and sends no Enter. Receipts contain hashes and diff --git a/docs/cli-registry.md b/docs/cli-registry.md index 72c28a2de..955b6f888 100644 --- a/docs/cli-registry.md +++ b/docs/cli-registry.md @@ -47,9 +47,10 @@ interface CliEntry { env: CliEnv; // exports, tmux setenv keys, the env-override allowlist capabilities: CliCapabilities; // what every call site reads instead of the id // .workDetect?: { promptGlyph, workingLine, watchingLine?, watchingLines?, awaitingLine?, - // blockedLine?, blockedLines? } + // blockedLine?, blockedLines?, composerFooterLine? } // (how this CLI's pane shows work, work it started in the background, a turn - // that ended waiting for workers it will resume from, and a dialog it is blocked on) + // that ended waiting for workers it will resume from, a dialog it is blocked on, + // and the rows under its idle composer that guarded input relies on) // .modelDetect?: { screenLine, screenLines? } // (where this CLI's own chrome names the model it runs: SessionState.displayModel) // .launchDefaults?: { [launchParam]: settingsKey } @@ -63,7 +64,7 @@ interface CliEntry { ### Regexes that come from config -Six capability fields carry a regular expression an override file can set: `discovery.version.regex`, `capabilities.workDetect.workingLine`, `capabilities.workDetect.watchingLine`, `capabilities.workDetect.awaitingLine`, `capabilities.workDetect.blockedLine` and `capabilities.modelDetect.screenLine`. All six go through `compileVersionRegex()`, which caps the source at 200 characters, refuses the nested-quantifier shapes that cause catastrophic backtracking, and returns `null` rather than throwing so every caller degrades instead of crashing. +Seven capability fields carry a regular expression an override file can set: `discovery.version.regex`, `capabilities.workDetect.workingLine`, `capabilities.workDetect.watchingLine`, `capabilities.workDetect.awaitingLine`, `capabilities.workDetect.blockedLine`, `capabilities.workDetect.composerFooterLine` and `capabilities.modelDetect.screenLine`. All seven go through `compileVersionRegex()`, which caps the source at 200 characters, refuses the nested-quantifier shapes that cause catastrophic backtracking, and returns `null` rather than throwing so every caller degrades instead of crashing. `workingLine` is the one that matters most, because it is compiled once per session and then run against every accumulated PTY chunk and every pane capture. A nested quantifier there is a ReDoS against the event loop for the whole server, not just that session. The guard therefore runs in two places, and neither is redundant: `schema.ts` rejects the entry at LOAD time so a bad pattern never reaches a session, and `_workingLinePattern()` in `session.ts` compiles through the same helper so the runtime cannot end up with a pattern the schema would have refused. @@ -124,6 +125,18 @@ below, open PERMISSION items, which a watching label never acknowledges), so a f costs a wrong badge and cannot silence an alert. Before giving a CLI idle signals and a pattern, make sure its row is one the agent cannot write. +### The idle composer guarded input reads (`composerFooterLine`) + +The experimental guarded `stage`/`submit` routes (`src/agent-staging.ts`, opt-in through +`agentCoordinationEnabled`) type into a composer only after proving, from a fresh capture, +that the newest `promptGlyph` row is the CLI's own composer at rest, with the caret at the +exact end of its text. `composerFooterLine` is the second half of that proof: every +non-blank row below the composer (at most three) must match it, one row at a time. A CLI +that declares none never takes guarded input; only codex declares one, built from the same +effort-word list as its `modelDetect.screenLine` (`CODEX_EFFORT_WORDS` in `stock.ts`), so a +codex footer change is a one-place edit. Like `blockedLine`, the rows it sees are near the +foot of the screen, so a pattern must anchor on chrome only that CLI draws there. + ### Dialogs a CLI blocks on (`blockedLine`) `blockedLine` is the screen-read source of the `blocked` state for a CLI with no hook to diff --git a/docs/versioning-policy.md b/docs/versioning-policy.md index bf42c0958..8f8581a58 100644 --- a/docs/versioning-policy.md +++ b/docs/versioning-policy.md @@ -64,17 +64,22 @@ These may change in a **MINOR** (or even PATCH) release without a MAJOR bump: ### Experimental agent coordination -`codeman agent inspect`, `codeman agent stage` and `codeman agent submit` are -experimental. The matching `GET /api/v1/sessions/:id/inspect`, -`POST /api/v1/sessions/:id/stage` and `POST /api/v1/sessions/:id/submit` routes, -including their unversioned `/api` aliases, share this designation. This is an -explicit exception to both the CLI and HTTP stability promises above: these -commands, flags, endpoint paths, payloads and behavior may change or be removed -in a MINOR or PATCH release without a stable-surface deprecation period. Pin a -compatible Codeman version when automating them. Existing authentication, -ownership and shared response-envelope rules still apply. Other agent commands -and API routes retain their existing stability guarantees. Promotion to stable -requires an explicit documented decision. +`codeman agent inspect`, `codeman agent stage` and `codeman agent submit`, and +the matching `GET /api/v1/sessions/:id/inspect`, `POST /api/v1/sessions/:id/stage` +and `POST /api/v1/sessions/:id/submit` routes (with their `/api` aliases, since +every `/api` route is also served under `/api/v1`), are labeled experimental, so +item 3 above applies to them: their commands, flags, endpoint paths, payloads and +behavior may change or be removed in a MINOR or PATCH release. They are also +opt-in: the routes answer `403 FORBIDDEN` with a message naming the synced +`agentCoordinationEnabled` setting (default OFF) until it is turned on, so +automation cannot come to depend on them without choosing to. Pin a compatible +Codeman version when automating them. Existing authentication, ownership and +shared response-envelope rules still apply. The session behavior underneath them +(input revision tracking in the session's write paths, the SubmitVerifier +re-press going through the shared write boundary) runs for every session and is +covered like any other stable behavior. Other agent commands and API routes keep +their existing guarantees. Promotion to stable requires an explicit documented +decision. ## Deprecation policy diff --git a/docs/wiki/Driving-Codeman-From-An-Agent.md b/docs/wiki/Driving-Codeman-From-An-Agent.md index 776eddf14..18d21b5d7 100644 --- a/docs/wiki/Driving-Codeman-From-An-Agent.md +++ b/docs/wiki/Driving-Codeman-From-An-Agent.md @@ -44,9 +44,9 @@ SID=$(codeman agent spawn scratch-1 --mode claude) # quick-start + wait f codeman agent send "$SID" 'review src/, then say DONE' --until stop,exit --timeout 300000 codeman agent read "$SID" # last answer (as the server reads it for that mode) codeman agent read "$SID" --tail 3000 # terminal tail, ANSI stripped (every mode) -codeman agent inspect "$SID" --max-bytes 8192 --json # recent evidence + opaque cursor (pane snapshots are labeled) -codeman agent stage "$SID" --message-file /tmp/prompt.txt --json # no Enter; known local Codex composer only -codeman agent submit "$SID" --stage-id "$STAGE_ID" --confirm-sha "$SHA256" --json # one conditional Enter +codeman agent inspect "$SID" --max-bytes 8192 --json # experimental, opt-in: recent evidence + opaque cursor +codeman agent stage "$SID" --message-file /tmp/prompt.txt --json # experimental, opt-in: no Enter; local Codex composer only +codeman agent submit "$SID" --stage-id "$STAGE_ID" --confirm-sha "$SHA256" --json # experimental, opt-in: one conditional Enter codeman agent send "$SID" 'run the tests, then print WORKDONE followed by _4711' # hook-less modes: the marker in halves … codeman agent wait "$SID" --match WORKDONE_4711 # … and the wait on the joined form codeman agent interrupt "$SID" # a bare ESC, conversation intact @@ -70,6 +70,15 @@ codeman agent rm "$SID" # any session except t `ls` shows each session's mail in an INBOX column. Mail lives in memory unless **Keep agent inbox across restarts** is on (see [Settings Reference](Settings-Reference)), and goes when its session is deleted. +- **`inspect`, `stage`, `submit`** are experimental and opt-in: they answer `403 FORBIDDEN` + naming the setting until **Experimental agent coordination** is on (see + [Settings Reference](Settings-Reference); `agentCoordinationEnabled`, synced, off by + default), and they may change in any release. `inspect` returns bounded evidence with an + opaque cursor. `stage` types one printable UTF8 line without trailing whitespace into a + local Codex composer, with no Enter, and returns a receipt that expires after two minutes; + `submit` sends one Enter only if the composer still shows exactly that text, and never + replays it. `send --no-enter` alone has no receipt. Details: + [API reference](https://github.com/Ark0N/Codeman/blob/master/docs/api-reference.md#experimental-bounded-agent-inspection-and-guarded-input). - **Exit codes** are the same for every verb: `0` done, `1` error, `2` timeout, `3` the worker exited, `4` refused. `--json` prints the response's `data`. @@ -267,5 +276,3 @@ failing the spawn, because a cosmetic field must never break a worker. - [Hooks And Integrations](Hooks-And-Integrations) - events flowing the other way. - [Watching Agents Work](Watching-Agents-Work) - seeing the fan-out in the UI. - [`skills/codeman/SKILL.md`](https://github.com/Ark0N/Codeman/blob/master/skills/codeman/SKILL.md) - the skill itself. - -Bounded inspection and guarded stage/submit details: [API reference](../api-reference.md#bounded-agent-inspection-and-guarded-input). Stage accepts one printable UTF8 line without trailing whitespace; receipts expire after two minutes. Submit refuses changed/unprovable composers and never automatically replays Enter. `send --no-enter` alone has no receipt. diff --git a/docs/wiki/Settings-Reference.md b/docs/wiki/Settings-Reference.md index 19d1676be..430997362 100644 --- a/docs/wiki/Settings-Reference.md +++ b/docs/wiki/Settings-Reference.md @@ -106,6 +106,14 @@ memory and a restart empties it; switching it off deletes the file and keeps wha memory. Messages expire 12 hours after they were posted either way. See [Driving Codeman From An Agent](Driving-Codeman-From-An-Agent#the-codeman-agent-commands). +**Experimental agent coordination** (under **Cross-session features**, synced, off by +default) turns on the `codeman agent inspect`, `stage` and `submit` commands: bounded, +paged reads of another session's output, and typing a prompt into a local Codex composer +that is sent with one Enter only after Codeman checks it is unchanged. Off, their HTTP +routes answer `403 FORBIDDEN` naming the setting (`agentCoordinationEnabled`). They are +experimental and may change in any release. See +[Driving Codeman From An Agent](Driving-Codeman-From-An-Agent#the-codeman-agent-commands). + ### Appearance | Setting | Notes | diff --git a/plugins/codeman/skills/codeman/reference/inspection-and-staging.md b/plugins/codeman/skills/codeman/reference/inspection-and-staging.md index d624f5d7f..c53530aa7 100644 --- a/plugins/codeman/skills/codeman/reference/inspection-and-staging.md +++ b/plugins/codeman/skills/codeman/reference/inspection-and-staging.md @@ -2,11 +2,16 @@ **Experimental:** `codeman agent inspect`, `stage` and `submit`, and their `/api/v1/sessions/:id/inspect|stage|submit` routes (including `/api` aliases), -are excluded from the stable CLI/HTTP contract. Their commands, flags, paths, +are labeled experimental and so are not part of the stable CLI/HTTP contract. Their commands, flags, paths, payloads and behavior may change or be removed in a minor or patch release. Pin a compatible Codeman version when automating them. Authentication and session ownership checks still apply. +They are also opt-in: until the user turns on **Experimental agent coordination** +in App Settings (`agentCoordinationEnabled`, off by default), every one of them +fails with `FORBIDDEN: ... (HTTP 403)` naming that setting. That is not something +to retry or work around: tell the user, or use `send`/`read` instead. + Use these commands inside a Codeman session. They use its authenticated HTTP API. They supplement `send`, `wait` and `read` over the same HTTP transport; no direct tmux access is required. diff --git a/plugins/codeman/skills/codeman/reference/verbs.md b/plugins/codeman/skills/codeman/reference/verbs.md index 54a8899c5..85a7b9817 100644 --- a/plugins/codeman/skills/codeman/reference/verbs.md +++ b/plugins/codeman/skills/codeman/reference/verbs.md @@ -816,10 +816,11 @@ Rules that matter: ## Experimental bounded inspection and guarded submission -These three commands and their HTTP routes may change or be removed in minor or -patch releases; they are excluded from the stable CLI/HTTP contract. - -Use `codeman agent inspect` for bounded evidence and incremental local Codex cursors. +These three commands and their HTTP routes are labeled experimental, so they may +change or be removed in minor or patch releases. They are also opt-in +(`agentCoordinationEnabled`, off by default: a 403 naming it until the user turns +it on). Use `codeman agent inspect` for bounded evidence and incremental local +Codex cursors. Use `codeman agent stage --message-file` followed by one hash-confirmed `codeman agent submit --stage-id --confirm-sha` when the exact local Codex composer and caret can be verified. Other targets provide labeled snapshots or diff --git a/skills/codeman/reference/inspection-and-staging.md b/skills/codeman/reference/inspection-and-staging.md index d624f5d7f..c53530aa7 100644 --- a/skills/codeman/reference/inspection-and-staging.md +++ b/skills/codeman/reference/inspection-and-staging.md @@ -2,11 +2,16 @@ **Experimental:** `codeman agent inspect`, `stage` and `submit`, and their `/api/v1/sessions/:id/inspect|stage|submit` routes (including `/api` aliases), -are excluded from the stable CLI/HTTP contract. Their commands, flags, paths, +are labeled experimental and so are not part of the stable CLI/HTTP contract. Their commands, flags, paths, payloads and behavior may change or be removed in a minor or patch release. Pin a compatible Codeman version when automating them. Authentication and session ownership checks still apply. +They are also opt-in: until the user turns on **Experimental agent coordination** +in App Settings (`agentCoordinationEnabled`, off by default), every one of them +fails with `FORBIDDEN: ... (HTTP 403)` naming that setting. That is not something +to retry or work around: tell the user, or use `send`/`read` instead. + Use these commands inside a Codeman session. They use its authenticated HTTP API. They supplement `send`, `wait` and `read` over the same HTTP transport; no direct tmux access is required. diff --git a/skills/codeman/reference/verbs.md b/skills/codeman/reference/verbs.md index 54a8899c5..85a7b9817 100644 --- a/skills/codeman/reference/verbs.md +++ b/skills/codeman/reference/verbs.md @@ -816,10 +816,11 @@ Rules that matter: ## Experimental bounded inspection and guarded submission -These three commands and their HTTP routes may change or be removed in minor or -patch releases; they are excluded from the stable CLI/HTTP contract. - -Use `codeman agent inspect` for bounded evidence and incremental local Codex cursors. +These three commands and their HTTP routes are labeled experimental, so they may +change or be removed in minor or patch releases. They are also opt-in +(`agentCoordinationEnabled`, off by default: a 403 naming it until the user turns +it on). Use `codeman agent inspect` for bounded evidence and incremental local +Codex cursors. Use `codeman agent stage --message-file` followed by one hash-confirmed `codeman agent submit --stage-id --confirm-sha` when the exact local Codex composer and caret can be verified. Other targets provide labeled snapshots or diff --git a/src/agent-codex-identity.ts b/src/agent-codex-identity.ts index 97cfe50ce..490f7267a 100644 --- a/src/agent-codex-identity.ts +++ b/src/agent-codex-identity.ts @@ -1,11 +1,16 @@ /** * @fileoverview Bounded Codex rollout attribution for agent evidence. Never uses * newest-mtime, prompt timestamps or cwd alone as proof of session ownership. + * The first call walks the store under explicit budgets; the attributed file is + * then remembered per session and only re-verified (plus a scan of the date + * directories not older than it) on later calls, since codex never prunes + * `~/.codex/sessions`. */ import { open, opendir, realpath } from 'node:fs/promises'; import { probePathKind } from './utils/bounded-path-probe.js'; import { tmpdir } from 'node:os'; -import { join, resolve } from 'node:path'; +import { dirname, join, relative, resolve, sep } from 'node:path'; +import { LRUMap } from './utils/lru-map.js'; const UUID_AT_END = /-([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})\.jsonl$/i; const HEAD_BYTES = 131072; @@ -67,6 +72,42 @@ function strictMetadata(bytes: Buffer): Metadata | undefined { } return found; } +/** + * The rollout a previous call attributed, per session. A later call re-verifies only + * this file's head (same `dev:ino`, same strict metadata) and then scans just the + * directories not older than its own for a second candidate (codex `/new`, or a new + * owned rollout beside a configured resume), instead of re-reading the whole store, + * which codex never prunes. Any failed check drops the entry and falls back to the + * full walk. Bounded: one entry per live session is the expected size. + */ +interface AttributionMemo { + id: string; + path: string; + dev: number; + ino: number; + meta: Metadata; + /** Directory components of `path` below `sessions/`, used to prune older siblings. */ + dirParts: string[]; +} +const attributionMemo = new LRUMap({ maxSize: 1024 }); +/** Test-only: forget every remembered attribution. */ +export function __clearCodexAttributionMemo(): void { + attributionMemo.clear(); +} +/** Short first read: `originator` sits right after `id`, `timestamp` and `cwd`. */ +const PREFIX_FIRST_BYTES = 1024; +/** Fallback read when the first window holds no complete `originator` field. */ +const PREFIX_MAX_BYTES = 4096; +const ORIGINATOR_FIELD = /"originator"\s*:\s*"(?:[^"\\]|\\.)*"/; +/** + * Is a date-shaped directory name older than the remembered one at the same depth? + * Only all-digit names compare (codex writes `sessions/YYYY/MM/DD/`); anything else + * is never pruned, so an unexpected layout costs a wider scan, not a missed rollout. + */ +function olderDatePart(name: string, memo: string | undefined): boolean { + if (memo === undefined || !/^\d+$/.test(name) || !/^\d+$/.test(memo)) return false; + return Number(name) < Number(memo); +} /** * A configured resume and a newly owned rollout conflict rather than pinning an * old launch forever after /new. TUI resumes of foreign-originator threads cannot @@ -81,9 +122,92 @@ export async function findExactCodexConversation( if (!workspace || !home) return undefined; const sessions = join(home, 'sessions'); if ((await probePathKind(sessions, { pastCap: true })) !== 'directory') return undefined; - const directories = [{ path: sessions, depth: 0 }]; + const key = [home, options.sessionId, options.resumeSessionId ?? '', workspace].join('\0'); + const remembered = attributionMemo.get(key); + if (remembered) { + const verified = await verifyRemembered(remembered, workspace, budget); + if (verified) { + const others = await scanCandidates(sessions, options, budget, remembered.dirParts); + if (others && others.length === 1 && others[0].path === remembered.path) { + return { id: remembered.id, path: remembered.path }; + } + } + attributionMemo.delete(key); + // A second candidate is a conflict a full walk would report too; anything + // else (a replaced file, an exhausted budget) gets one full walk now. + if (verified) return undefined; + } + const candidates = await scanCandidates(sessions, options, budget); + if (!candidates || candidates.length !== 1) return undefined; + const candidate = candidates[0]; + if (!candidate.meta.cwd || (await canonical(candidate.meta.cwd)) !== workspace) return undefined; + const handle = await open(candidate.path, 'r').catch(() => undefined); + if (!handle) return undefined; + try { + const info = await handle.stat(); + const bytes = Buffer.alloc(HEAD_BYTES); + const read = await handle.read(bytes, 0, bytes.length, 0); + const meta = strictMetadata(bytes.subarray(0, read.bytesRead)); + if ( + !meta || + meta.id !== candidate.id || + meta.cwd !== candidate.meta.cwd || + meta.originator !== candidate.meta.originator + ) + return undefined; + attributionMemo.set(key, { + id: candidate.id, + path: candidate.path, + dev: info.dev, + ino: info.ino, + meta, + dirParts: relative(sessions, dirname(candidate.path)).split(sep).filter(Boolean), + }); + return { id: candidate.id, path: candidate.path }; + } finally { + await handle.close(); + } +} +/** Same file (`dev:ino`), same strict metadata, same canonical workspace: nothing else. */ +async function verifyRemembered( + memo: AttributionMemo, + workspace: string, + budget: CodexAttributionBudget +): Promise { + if (HEAD_BYTES > budget.maxBytes) return false; + if (!memo.meta.cwd || (await canonical(memo.meta.cwd)) !== workspace) return false; + const handle = await open(memo.path, 'r').catch(() => undefined); + if (!handle) return false; + try { + const info = await handle.stat(); + if (!info.isFile() || info.dev !== memo.dev || info.ino !== memo.ino) return false; + const bytes = Buffer.alloc(HEAD_BYTES); + const read = await handle.read(bytes, 0, bytes.length, 0); + const meta = strictMetadata(bytes.subarray(0, read.bytesRead)); + return !!meta && meta.id === memo.id && meta.cwd === memo.meta.cwd && meta.originator === memo.meta.originator; + } catch { + return false; + } finally { + await handle.close(); + } +} +/** + * Every rollout under `sessions` whose strict metadata names this session (or the + * configured resume id), or undefined when the walk hit a bound or an unreadable + * entry (an incomplete inventory proves nothing). With `notOlderThan`, date-shaped + * directories older than those components are skipped. + */ +async function scanCandidates( + sessions: string, + options: CodexAttributionOptions, + budget: CodexAttributionBudget, + notOlderThan?: string[] +): Promise | undefined> { + // `onBoundary`: every ancestor so far equals the remembered path, so pruning applies. + const directories = [{ path: sessions, depth: 0, onBoundary: notOlderThan !== undefined }]; const candidates: Array<{ id: string; path: string; meta: Metadata }> = []; const originator = `codeman_${options.sessionId}`; + const originatorJson = JSON.stringify(originator); const explicit = options.resumeSessionId; let entries = 0; let prefixes = 0; @@ -98,32 +222,43 @@ export async function findExactCodexConversation( const path = join(dir.path, entry.name); if (entry.isDirectory()) { if (dir.depth >= 8) return undefined; - directories.push({ path, depth: dir.depth + 1 }); + const part = notOlderThan?.[dir.depth]; + if (dir.onBoundary && olderDatePart(entry.name, part)) continue; + directories.push({ path, depth: dir.depth + 1, onBoundary: dir.onBoundary && entry.name === part }); } else if (entry.isFile() && entry.name.endsWith('.jsonl')) { + const id = entry.name.match(UUID_AT_END)?.[1]; + if (!id) continue; // no rollout id in the name: never a candidate, read nothing const file = await open(path, 'r').catch(() => undefined); if (!file) return undefined; try { const info = await file.stat(); if (!info.isFile()) return undefined; - const id = entry.name.match(UUID_AT_END)?.[1]; - const prefixBytes = Math.min(info.size, 4096); - if (prefixes + prefixBytes > prefixLimit) return undefined; - prefixes += prefixBytes; - const prefix = Buffer.alloc(prefixBytes); - const first = await file.read(prefix, 0, prefixBytes, 0); - // Cheap negative filter only; a match still needs full parsed metadata. - if ( - id !== explicit && - !prefix.subarray(0, first.bytesRead).toString('utf8').includes(JSON.stringify(originator)) - ) - continue; + if (id !== explicit) { + // Cheap negative filter only; a match still needs full parsed metadata. + // A short first window decides when it already holds the whole + // `originator` field; only otherwise is the wider window read. + let window = Math.min(info.size, PREFIX_FIRST_BYTES); + let prefix = ''; + for (;;) { + if (prefixes + window > prefixLimit) return undefined; + prefixes += window; + const buffer = Buffer.alloc(window); + const first = await file.read(buffer, 0, window, 0); + prefix = buffer.subarray(0, first.bytesRead).toString('utf8'); + if (prefix.includes(originatorJson) || ORIGINATOR_FIELD.test(prefix)) break; + const wider = Math.min(info.size, PREFIX_MAX_BYTES); + if (wider <= window) break; + window = wider; + } + if (!prefix.includes(originatorJson)) continue; + } const size = Math.min(info.size, HEAD_BYTES); if (scanned + size > budget.maxBytes) return undefined; scanned += size; const bytes = Buffer.alloc(size); const read = await file.read(bytes, 0, size, 0); const meta = strictMetadata(bytes.subarray(0, read.bytesRead)); - if (!meta || !id || meta.id !== id) continue; + if (!meta || meta.id !== id) continue; if (id === explicit || meta.originator === originator) candidates.push({ id, path, meta }); } finally { await file.close(); @@ -131,24 +266,5 @@ export async function findExactCodexConversation( } } } - if (candidates.length !== 1) return undefined; - const candidate = candidates[0]; - if (!candidate.meta.cwd || (await canonical(candidate.meta.cwd)) !== workspace) return undefined; - const handle = await open(candidate.path, 'r').catch(() => undefined); - if (!handle) return undefined; - try { - const bytes = Buffer.alloc(HEAD_BYTES); - const read = await handle.read(bytes, 0, bytes.length, 0); - const meta = strictMetadata(bytes.subarray(0, read.bytesRead)); - if ( - !meta || - meta.id !== candidate.id || - meta.cwd !== candidate.meta.cwd || - meta.originator !== candidate.meta.originator - ) - return undefined; - return { id: candidate.id, path: candidate.path }; - } finally { - await handle.close(); - } + return candidates; } diff --git a/src/agent-staging.ts b/src/agent-staging.ts index 2dbd23822..847951704 100644 --- a/src/agent-staging.ts +++ b/src/agent-staging.ts @@ -6,10 +6,30 @@ import { createHash, randomUUID } from 'node:crypto'; import { stripAnsi } from './utils/regex-patterns.js'; import { visibleWidth } from './tui/tui-ansi.js'; -import { CODEX_REASONING_EFFORTS } from './types/session.js'; -const FOOTER_LINE = new RegExp( - String.raw`^\s+(?:\d+% context left|\? for shortcuts|← for agents|\S+ (?:${[...CODEX_REASONING_EFFORTS, 'default'].join('|')}) · )` -); +import { compileVersionRegex } from './config/cli-registry/patterns.js'; +import type { CliEntry } from './config/cli-registry/types.js'; + +/** + * The screen chrome a guarded composer read relies on, both from the CLI registry + * (`capabilities.workDetect`): the composer glyph and the pattern every row below an + * idle composer must match. Nothing here names a CLI; a CLI takes part in guarded + * input by declaring `composerFooterLine`. + */ +export interface GuardedComposerChrome { + promptGlyph: string; + footerLine: RegExp; +} +/** The chrome a registry entry declares, or null (no guarded input for that CLI). */ +export function guardedComposerChrome(entry: CliEntry | undefined): GuardedComposerChrome | null { + const detect = entry?.capabilities.workDetect; + if (!detect?.promptGlyph || !detect.composerFooterLine) return null; + // Config-supplied (~/.codeman/clis.json): compiled through the ReDoS guard, null on refusal. + const footerLine = compileVersionRegex(detect.composerFooterLine); + return footerLine ? { promptGlyph: detect.promptGlyph, footerLine } : null; +} +const escapeRegex = (text: string) => text.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); +const composerRow = (chrome: GuardedComposerChrome) => + new RegExp(String.raw`^\s*${escapeRegex(chrome.promptGlyph)}(?:\s|$)`); export const STAGE_MAX_BYTES = 8192; export const promptSha256 = (text: string): string => createHash('sha256').update(text).digest('hex'); export interface GuardedInputSnapshot { @@ -34,19 +54,21 @@ interface Receipt extends GuardedInputSnapshot { export class GuardedInputConflict extends Error {} /** - * Conservatively read a Codex composer: exactly one visible, unwrapped row and a - * known footer. Never remove internal spaces, infer an unknown shell prompt, or - * interpret dialog options as authority. An unsupported frame yields no proof. + * Conservatively read a composer: exactly one visible, unwrapped row and a known + * footer, both as the registry describes them. Never remove internal spaces, infer + * an unknown shell prompt, or interpret dialog options as authority. An unsupported + * frame yields no proof. */ -export function readGuardedCodexComposer(screen: string): string | null { +export function readGuardedComposer(screen: string, chrome: GuardedComposerChrome): string | null { + const isComposer = composerRow(chrome); const lines = stripAnsi(screen) .split('\n') .map((line) => line.trimEnd()); - const rows = lines.map((line, index) => ({ line, index })).filter((row) => /^\s*›(?:\s|$)/.test(row.line)); + const rows = lines.map((line, index) => ({ line, index })).filter((row) => isComposer.test(row.line)); const last = rows.at(-1); if (!last) return null; const below = lines.slice(last.index + 1).filter((line) => line.trim()); - if (below.length === 0 || below.length > 3 || below.some((line) => !FOOTER_LINE.test(line))) return null; + if (below.length === 0 || below.length > 3 || below.some((line) => !chrome.footerLine.test(line))) return null; // Preserve exact printable content; only SGR-dim placeholder/suggestion text is excluded. const raw = screen.split('\n')[last.index]; let dim = false; @@ -65,15 +87,22 @@ export function readGuardedCodexComposer(screen: string): string | null { } } else if (!dim) visible += token; } - return stripAnsi(visible).trimStart().slice(2).trimEnd(); + // The glyph and the one space after it are the prefix, not input. + return stripAnsi(visible) + .trimStart() + .slice(chrome.promptGlyph.length + 1) + .trimEnd(); } /** Require the capture's caret at the exact end of the unwrapped composer. */ -export function readGuardedCodexSnapshot(snapshot: string): string | null { - return readGuardedCodexSnapshotDetails(snapshot)?.composer ?? null; +export function readGuardedSnapshot(snapshot: string, chrome: GuardedComposerChrome): string | null { + return readGuardedSnapshotDetails(snapshot, chrome)?.composer ?? null; } /** Exact input-row prefix width, shared by proof and preflight wrapping checks. */ -export function readGuardedCodexSnapshotDetails(snapshot: string): { composer: string; prefixWidth: number } | null { +export function readGuardedSnapshotDetails( + snapshot: string, + chrome: GuardedComposerChrome +): { composer: string; prefixWidth: number } | null { // formatPaneSnapshot emits one absolute row address per line, then the caret. // Plain captures lack caret evidence and cannot prove invisible trailing edits. // eslint-disable-next-line no-control-regex @@ -87,16 +116,17 @@ export function readGuardedCodexSnapshotDetails(snapshot: string): { composer: s if (Number(position[1]) !== i + 1 || position[2] !== '1') return null; rows.push(snapshot.slice(position.index! + position[0].length, positions[i + 1].index)); } - const composer = readGuardedCodexComposer(rows.join('\n')); + const composer = readGuardedComposer(rows.join('\n'), chrome); if (composer === null) return null; - let composerRow = -1; + const isComposer = composerRow(chrome); + let row = -1; for (let i = rows.length - 1; i >= 0; i--) - if (/^\s*›(?:\s|$)/.test(stripAnsi(rows[i]))) { - composerRow = i; + if (isComposer.test(stripAnsi(rows[i]))) { + row = i; break; } - const prefix = stripAnsi(rows[composerRow]).match(/^(\s*›[ \u00a0])/); - if (!prefix || Number(caret[1]) !== composerRow + 1 || Number(caret[2]) !== visibleWidth(prefix[1] + composer) + 1) + const prefix = stripAnsi(rows[row]).match(new RegExp(String.raw`^(\s*${escapeRegex(chrome.promptGlyph)}[ \u00a0])`)); + if (!prefix || Number(caret[1]) !== row + 1 || Number(caret[2]) !== visibleWidth(prefix[1] + composer) + 1) return null; return { composer, prefixWidth: visibleWidth(prefix[1]) }; } diff --git a/src/cli-agent.ts b/src/cli-agent.ts index 8db5ad3e4..b8e09d51e 100644 --- a/src/cli-agent.ts +++ b/src/cli-agent.ts @@ -2,11 +2,13 @@ * @fileoverview `codeman agent …` — session-to-session verbs for the agent running * inside a Codeman session, in every CLI mode. * - * A thin HTTP client over endpoints that already exist (`quick-start`, `input`, - * `wait`, `wait-output`, `last-response`, `terminal`, `DELETE sessions/:id`, and the - * agent inbox: `inbox`, `inbox/ack`, `agent-inbox/summary`). It - * invents no route and no transport: everything goes through `CODEMAN_API_URL`, so - * auth, ownership and the per-session waiter cap apply unchanged. The behaviour is + * A thin HTTP client over the server's endpoints (`quick-start`, `input`, `wait`, + * `wait-output`, `last-response`, `terminal`, `DELETE sessions/:id`, the agent inbox: + * `inbox`, `inbox/ack`, `agent-inbox/summary`, and the experimental, opt-in + * `inspect`/`stage`/`submit` of `web/routes/agent-session-routes.ts`, which answer 403 + * until `agentCoordinationEnabled` is on; that refusal is printed verbatim). It uses no + * transport of its own: everything goes through `CODEMAN_API_URL`, so auth, ownership + * and the per-session waiter cap apply unchanged. The behaviour is * the packaged agent skill's (`skills/codeman`), ported from shell prose into code * with tests, so a `codex`/`opencode`/`pi` agent — which never gets the claude-only * preamble — has the same verbs from one line in its AGENTS.md. @@ -1272,7 +1274,9 @@ export function registerAgentCommands(program: Command): Command { agent .command('inspect ') - .description('Experimental: bounded transcript evidence or a labeled pane snapshot; opaque incremental cursors') + .description( + 'Experimental, opt-in (agentCoordinationEnabled): bounded transcript evidence or a labeled pane snapshot; opaque incremental cursors' + ) .option('--max-bytes ', 'UTF8 evidence budget (1–65536)', '8192') .option('--since ', 'Resume a cursor from the same owner, pane and transcript') .option('--json', 'Machine-readable output') @@ -1289,7 +1293,7 @@ export function registerAgentCommands(program: Command): Command { agent .command('stage ') .description( - 'Experimental: type a printable single-line UTF8 file without Enter; requires a verifiable local Codex composer' + 'Experimental, opt-in (agentCoordinationEnabled): type a printable single-line UTF8 file without Enter; requires a verifiable local Codex composer' ) .requiredOption('--message-file ', 'Regular UTF8 file, at most 8192 bytes, no trailing newline') .option('--json', 'Machine-readable output') @@ -1299,7 +1303,9 @@ export function registerAgentCommands(program: Command): Command { agent .command('submit ') - .description('Experimental: send Enter once only when the staged composer, pane and hash still match') + .description( + 'Experimental, opt-in (agentCoordinationEnabled): send Enter once only when the staged composer, pane and hash still match' + ) .requiredOption('--stage-id ', 'One-shot stage receipt') .requiredOption('--confirm-sha ', 'Exact SHA-256 returned by stage') .option('--json', 'Machine-readable output') diff --git a/src/config/cli-registry/schema.ts b/src/config/cli-registry/schema.ts index de4a5542f..3876310b8 100644 --- a/src/config/cli-registry/schema.ts +++ b/src/config/cli-registry/schema.ts @@ -384,6 +384,16 @@ const capabilitiesSchema = z // marker but never the footer. Still bounded, since it is also how much text the // pattern runs over. blockedLines: z.number().int().min(1).max(24).optional(), + // Same guard again: run over the rows under the composer each time guarded + // input reads the pane, and ~/.codeman/clis.json can set it. + composerFooterLine: z + .string() + .min(1) + .refine( + (src) => compileVersionRegex(src) !== null, + 'composerFooterLine must be a regex compileVersionRegex() accepts: at most 200 characters, no nested quantifiers' + ) + .optional(), }) .strict() // A window with nothing to search is a typo, not a configuration. Refused at LOAD diff --git a/src/config/cli-registry/stock.ts b/src/config/cli-registry/stock.ts index 1b0e00ea9..59bf49512 100644 --- a/src/config/cli-registry/stock.ts +++ b/src/config/cli-registry/stock.ts @@ -13,6 +13,14 @@ import type { CliEntry } from './types.js'; import { CODEX_REASONING_EFFORTS } from '../../types/session.js'; +/** + * Codex's footer effort words: every CODEX_REASONING_EFFORTS level plus `default` (what + * codex prints when no effort is configured), as one regex alternation. Shared by the two + * patterns that read that footer (`modelDetect.screenLine` and + * `workDetect.composerFooterLine`), so a new level is a one-place edit. + */ +const CODEX_EFFORT_WORDS = [...CODEX_REASONING_EFFORTS, 'default'].join('|'); + const HOME_DIRS = { local: '~/.local/bin', usrLocal: '/usr/local/bin', @@ -734,6 +742,12 @@ const CODEX: CliEntry = { watchingLines: 4, blockedLine: String.raw`(?:^|\n) {0,4}› \d\. [\s\S]*\n {2}(?:Press enter to confirm|tab (?:to add|or esc to clear) notes|enter continue)(?:[^\n]|\n {2}(?=[a-z←])){0,120}(?:cancel|interrupt|quit|back|answer|all)$`, blockedLines: 20, + // The rows codex draws under an idle composer, tested one at a time by guarded + // stage/submit (agent-staging.ts): the model footer (` gpt-5.6-terra default · + // ~/case`, effort words from CODEX_EFFORT_WORDS as in modelDetect below), the + // `? for shortcuts` / `← for agents` hint row 0.162.0 adds at rest, and the older + // `NN% context left` footer. Indented, so the `›` composer row itself never matches. + composerFooterLine: String.raw`^\s+(?:\d+% context left|\? for shortcuts|← for agents|\S+ (?:${CODEX_EFFORT_WORDS}) · )`, }, // The footer under the composer, measured on a live 0.147.0 pane: // ` gpt-5.6-terra default · ~/codeman-cases/th-scratch` (model, reasoning effort, @@ -757,7 +771,7 @@ const CODEX: CliEntry = { // and the `›` composer, and a forged footer-shaped transcript line is not followed by // an indented row, so it is not read. modelDetect: { - screenLine: String.raw`(?:^|\n) {2}([A-Za-z0-9][\w.:/@+-]{0,79}) (?:${[...CODEX_REASONING_EFFORTS, 'default'].join('|')}) · [^\n]*(?:\n {2}[^\n]*)?$`, + screenLine: String.raw`(?:^|\n) {2}([A-Za-z0-9][\w.:/@+-]{0,79}) (?:${CODEX_EFFORT_WORDS}) · [^\n]*(?:\n {2}[^\n]*)?$`, screenLines: 2, }, // App Settings → Codex model / reasoning effort (synced), filled into a LOCAL launch's diff --git a/src/config/cli-registry/types.ts b/src/config/cli-registry/types.ts index 81fd48b44..0f5a97f18 100644 --- a/src/config/cli-registry/types.ts +++ b/src/config/cli-registry/types.ts @@ -432,6 +432,15 @@ export interface CliCapabilities { * this is sized for the narrowest pane the CLI was measured at. Default 1. */ blockedLines?: number; + /** + * Source of a regex every non-blank row BELOW an idle composer must match (at most + * three rows): the footer and hint rows this CLI draws there and nowhere else. It is + * what lets the experimental guarded stage/submit routes (`agent-staging.ts`) prove + * that the `promptGlyph` row they read is this CLI's own composer, at rest, and not + * a dialog option or a transcript line the agent printed. A CLI that omits it never + * takes guarded input: stage and submit refuse it. Tested one row at a time. + */ + composerFooterLine?: string; }; /** * How many columns this CLI indents its transcript body by, so a copy taken from its diff --git a/src/session.ts b/src/session.ts index 8d3a57911..3b965eb86 100644 --- a/src/session.ts +++ b/src/session.ts @@ -129,7 +129,12 @@ import { DEFAULT_TMUX_HISTORY_LIMIT } from './config/terminal-history.js'; import { EXEC_TIMEOUT_MS } from './config/exec-timeout.js'; import { getCli } from './config/cli-registry/registry.js'; import { SubmitVerifier } from './session-submit-verifier.js'; -import { promptSha256, readGuardedCodexSnapshotDetails, type GuardedInputSnapshot } from './agent-staging.js'; +import { + guardedComposerChrome, + promptSha256, + readGuardedSnapshotDetails, + type GuardedInputSnapshot, +} from './agent-staging.js'; import { isKeyboardTerminalInput } from './terminal-input-classifier.js'; import { stripAnsi } from './utils/regex-patterns.js'; import { KEEP_WARM_NUDGE } from './prompt-cache-keepwarm.js'; @@ -4764,10 +4769,12 @@ export class Session extends EventEmitter { this.remote || this.docker || this.paneExit || - getCli(this.mode)?.capabilities.transcript !== 'codex-rollout' || !this._muxSession ) return null; + // Only a CLI whose registry entry describes its composer chrome takes guarded input. + const chrome = guardedComposerChrome(getCli(this.mode)); + if (!chrome) return null; const capture: import('./mux-interface.js').PaneCaptureOptions = {}; const screen = this._mux?.capturePaneBuffer?.(this._muxSession.muxName, undefined, capture); if (!screen) return null; @@ -4778,7 +4785,7 @@ export class Session extends EventEmitter { const working = this._workingLinePattern(); working.lastIndex = 0; if (this._paneShowsBlocked(plain) || working.test(plain) || this._paneAwaitsWorkers(plain)) return null; - const composer = readGuardedCodexSnapshotDetails(screen); + const composer = readGuardedSnapshotDetails(screen, chrome); return composer === null ? null : { diff --git a/src/web/public/i18n.js b/src/web/public/i18n.js index 0f17c78d4..1e585379d 100644 --- a/src/web/public/i18n.js +++ b/src/web/public/i18n.js @@ -697,6 +697,9 @@ 'New sessions for this case land in this group': '此案例的新会话将放入此分组', 'Could not save the default session group.': '无法保存默认会话分组。', 'Remote auto-reconnect': '远程自动重连', + 'Experimental agent coordination': '实验性智能体协作', + "Lets agents read another session's output in bounded pages and type a prompt into a local Codex composer, then press Enter once after checking it is unchanged (the inspect, stage and submit commands). Experimental: the commands and their HTTP routes may change in any release. Off by default; while off those routes refuse every request.": + '允许智能体分页读取另一个会话的输出(每页有上限),并把提示词输入本地 Codex 输入框,确认未被改动后只按一次 Enter(即 inspect、stage 和 submit 命令)。实验性功能:这些命令及其 HTTP 路由可能在任何版本中变化。默认关闭;关闭时这些路由拒绝所有请求。', 'Pin tab names on the CLI': '在 CLI 上固定标签名', "Launch every Claude session with its tab name, so the CLI's prompt label and /resume list match your tabs. Claude then stops generating its own titles for those sessions, and a rename in Codeman holds only until the next turn. Applies from the next session start or respawn, no server restart needed.": '以标签名启动每个 Claude 会话,让 CLI 的提示框标签和 /resume 列表与你的标签一致。Claude 随后不再为这些会话生成自己的标题,在 Codeman 中的重命名也只保留到下一轮。从下一次会话启动或重新生成时生效,无需重启服务器。', diff --git a/src/web/public/index.html b/src/web/public/index.html index b52a2956b..49ce6bf4b 100644 --- a/src/web/public/index.html +++ b/src/web/public/index.html @@ -2178,6 +2178,13 @@

Header & Panels

+
+
+ Experimental agent coordination + Lets agents read another session's output in bounded pages and type a prompt into a local Codex composer, then press Enter once after checking it is unchanged (the inspect, stage and submit commands). Experimental: the commands and their HTTP routes may change in any release. Off by default; while off those routes refuse every request. +
+ +
diff --git a/src/web/public/settings-ui.js b/src/web/public/settings-ui.js index db745f450..162fabb76 100644 --- a/src/web/public/settings-ui.js +++ b/src/web/public/settings-ui.js @@ -541,6 +541,8 @@ Object.assign(CodemanApp.prototype, { document.getElementById('appSettingsApprovalsInbox').checked = settings.approvalsInboxEnabled === true; // Agent inbox snapshot on disk: synced, default OFF. document.getElementById('appSettingsAgentInboxPersist').checked = settings.agentInboxPersistEnabled === true; + // Experimental agent coordination (inspect/stage/submit routes): synced, default OFF. + document.getElementById('appSettingsAgentCoordination').checked = settings.agentCoordinationEnabled === true; // Custom Model Endpoint Profiles: synced, default OFF. The toggle governs both // the Run-menu picker's generated entries and this settings panel's visibility; // the endpoint list itself is server state, loaded on demand below. @@ -2929,6 +2931,7 @@ Object.assign(CodemanApp.prototype, { configBackupDir: document.getElementById('appSettingsConfigBackupDir').value.trim(), ...this._configBackupRetentionPayload(_prev), agentInboxPersistEnabled: document.getElementById('appSettingsAgentInboxPersist').checked, + agentCoordinationEnabled: document.getElementById('appSettingsAgentCoordination').checked, readMyMindEnabled: document.getElementById('appSettingsReadMyMind').checked, ultracodeFloatingWindows: document.getElementById('appSettingsUltracodeFloatingWindows').checked, showMultiMonitorButton: document.getElementById('appSettingsShowMultiMonitorButton').checked, diff --git a/src/web/routes/agent-session-routes.ts b/src/web/routes/agent-session-routes.ts index 662cf41f2..280138ffb 100644 --- a/src/web/routes/agent-session-routes.ts +++ b/src/web/routes/agent-session-routes.ts @@ -2,6 +2,11 @@ * @fileoverview Agent-facing bounded inspection and guarded stage/submit endpoints. * Ownership is checked before any disk or pane read. Native evidence comes only * from strict conversation attribution; all other modes get labeled snapshots. + * + * Experimental and opt-in: all three routes answer `403 FORBIDDEN`, naming the + * setting, until the synced `agentCoordinationEnabled` is on (default OFF), the + * same shape as `cliManagementEnabled` and `mcpSyncEnabled`. The gate runs first, + * before session lookup, so a disabled install reveals nothing about its sessions. */ import type { FastifyInstance } from 'fastify'; import type { Session } from '../../session.js'; @@ -10,10 +15,32 @@ import { getCli } from '../../config/cli-registry/registry.js'; import { findExactCodexConversation, resolveEffectiveCodexHome } from '../../agent-codex-identity.js'; import { InspectionReader, InspectionConflict, boundedUtf8Tail } from '../../agent-inspection.js'; import { GuardedStageStore, GuardedInputConflict, type GuardedInputTarget } from '../../agent-staging.js'; -import { ApiErrorCode, createErrorResponse } from '../../types.js'; -import { findSessionOrFail, getAuthUser, parseBody } from '../route-helpers.js'; +import { ApiErrorCode, createErrorResponse, type ApiResponse } from '../../types.js'; +import { findSessionOrFail, getAuthUser, parseBody, readJsonConfig, SETTINGS_PATH } from '../route-helpers.js'; import { AgentInspectQuerySchema, AgentStageSchema, AgentSubmitSchema } from '../schemas.js'; +/** Default OFF, same shape as `readMcpSyncEnabled`: read fresh so a toggle applies at once. */ +export async function readAgentCoordinationEnabled(): Promise { + const settings = await readJsonConfig>(SETTINGS_PATH, 'settings.json', {}); + return settings.agentCoordinationEnabled === true; +} + +/** The refusal every route sends while the setting is off. Exported for the tests. */ +export const AGENT_COORDINATION_DISABLED_MESSAGE = + 'Experimental agent coordination (inspect, stage, submit) is disabled. Turn on "Experimental agent coordination" in Settings (agentCoordinationEnabled) and save first.'; + +async function gate(): Promise | null> { + if (await readAgentCoordinationEnabled()) return null; + return createErrorResponse(ApiErrorCode.FORBIDDEN, AGENT_COORDINATION_DISABLED_MESSAGE); +} + +/** A Node error code (`EACCES`, `EIO`, ...) or the error's class name; never its message. */ +export function errorCode(error: unknown): string { + const code = (error as { code?: unknown } | null)?.code; + if (typeof code === 'string' && /^[A-Z0-9_]{1,40}$/.test(code)) return code; + return error instanceof Error ? error.constructor.name : 'unknown'; +} + export function registerAgentSessionRoutes(app: FastifyInstance, ctx: SessionPort & InfraPort): void { const reader = new InspectionReader(); const stages = new GuardedStageStore(); @@ -33,6 +60,8 @@ export function registerAgentSessionRoutes(app: FastifyInstance, ctx: SessionPor submit: (expected) => session.submitGuardedInput(expected), }); app.get('/api/sessions/:id/inspect', async (req, reply) => { + const refused = await gate(); + if (refused) return reply.code(403).send(refused); const { id } = req.params as { id: string }; const session = findSessionOrFail(ctx, id, req); const { maxBytes, since } = parseBody(AgentInspectQuerySchema, req.query); @@ -99,19 +128,24 @@ export function registerAgentSessionRoutes(app: FastifyInstance, ctx: SessionPor } catch (error) { if (error instanceof InspectionConflict) return reply.code(409).send(createErrorResponse(ApiErrorCode.CONFLICT, error.message)); - // Native paths and arbitrary IO errors must never escape into the response. - return reply - .code(409) - .send(createErrorResponse(ApiErrorCode.CONFLICT, 'Inspection source unavailable; retry without a cursor')); + // Anything else (an unreadable rollout, an IO error) is not a retryable cursor + // conflict. Log the session and the error code only: the message can carry the + // native transcript path, which must never reach the log or the response. + console.warn(`[agent-inspect] session ${session.id}: inspection failed (${errorCode(error)})`); + return reply.code(422).send(createErrorResponse(ApiErrorCode.OPERATION_FAILED, 'Inspection source unavailable')); } }); app.post('/api/sessions/:id/stage', async (req, reply) => { + const refused = await gate(); + if (refused) return reply.code(403).send(refused); const session = findSessionOrFail(ctx, (req.params as { id: string }).id, req); const { message } = parseBody(AgentStageSchema, req.body); try { const receipt = await stages.stage(target(session), getAuthUser(req).username, message); return { success: true, data: receipt }; } catch (error) { + if (!(error instanceof GuardedInputConflict)) + console.warn(`[agent-stage] session ${session.id}: delivery failed (${errorCode(error)})`); return reply .code(409) .send( @@ -125,6 +159,8 @@ export function registerAgentSessionRoutes(app: FastifyInstance, ctx: SessionPor } }); app.post('/api/sessions/:id/submit', async (req, reply) => { + const refused = await gate(); + if (refused) return reply.code(403).send(refused); const session = findSessionOrFail(ctx, (req.params as { id: string }).id, req); const { stageId, confirmSha } = parseBody(AgentSubmitSchema, req.body); try { @@ -133,6 +169,8 @@ export function registerAgentSessionRoutes(app: FastifyInstance, ctx: SessionPor data: await stages.submit(target(session), getAuthUser(req).username, stageId, confirmSha), }; } catch (error) { + if (!(error instanceof GuardedInputConflict)) + console.warn(`[agent-submit] session ${session.id}: delivery failed (${errorCode(error)})`); return reply .code(409) .send( diff --git a/src/web/schemas.ts b/src/web/schemas.ts index 295441d2d..0cb55d1b1 100644 --- a/src/web/schemas.ts +++ b/src/web/schemas.ts @@ -1544,6 +1544,13 @@ export const SettingsUpdateSchema = z * inboxes stay). On, pending messages between agents survive a server restart. */ agentInboxPersistEnabled: z.boolean().optional(), + /** + * Experimental agent coordination (src/web/routes/agent-session-routes.ts): bounded + * inspection and the guarded stage/submit routes behind `codeman agent inspect|stage|submit`. + * SYNCED, default OFF: the routes are labeled experimental and may change in a minor or + * patch release, so automation has to opt in. While OFF, all three routes answer 403. + */ + agentCoordinationEnabled: z.boolean().optional(), /** * Read My Mind predictor model override. Empty/absent = the AI-checker * default (opus: prediction quality is the product and it runs only on an diff --git a/test/agent-codex-identity.test.ts b/test/agent-codex-identity.test.ts index d3bc9cadc..a12922756 100644 --- a/test/agent-codex-identity.test.ts +++ b/test/agent-codex-identity.test.ts @@ -1,9 +1,13 @@ /** @fileoverview Strict bounded native rollout attribution without viewer heuristics. */ -import { afterEach, expect, it } from 'vitest'; +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; import { mkdtemp, mkdir, writeFile, rm, symlink } from 'node:fs/promises'; import { join } from 'node:path'; import { tmpdir } from 'node:os'; -import { findExactCodexConversation, resolveEffectiveCodexHome } from '../src/agent-codex-identity.js'; +import { + __clearCodexAttributionMemo, + findExactCodexConversation, + resolveEffectiveCodexHome, +} from '../src/agent-codex-identity.js'; const roots: string[] = []; afterEach(async () => { await Promise.all(roots.splice(0).map((root) => rm(root, { recursive: true, force: true }))); @@ -154,3 +158,80 @@ it.each(['workspace', 'metadata'] as const)( } } ); + +describe('remembered attribution on a large, never-pruned store', () => { + beforeEach(() => __clearCodexAttributionMemo()); + const OWNED = '33333333-3333-4333-8333-333333333333'; + const NEWER = '44444444-4444-4444-8444-444444444444'; + /** codex's own layout: sessions/YYYY/MM/DD/rollout--.jsonl */ + async function dated(f: Awaited>, day: string, id: string, payload: object = {}) { + const dir = join(f.home, 'sessions', ...day.split('/')); + await mkdir(dir, { recursive: true }); + const path = join(dir, `rollout-${day.replaceAll('/', '-')}T00-00-00-${id}.jsonl`); + await writeFile( + path, + JSON.stringify({ + type: 'session_meta', + payload: { id, cwd: f.root, originator: 'codeman_managed', ...payload }, + }) + '\n' + ); + return path; + } + /** `count` unrelated 19 KiB rollouts (a real session_meta size) spread over older days. */ + async function unrelated(f: Awaited>, count: number) { + const instructions = 'x'.repeat(19 * 1024); + for (let start = 0; start < count; start += 200) { + await Promise.all( + Array.from({ length: Math.min(200, count - start) }, (_, k) => { + const n = start + k; + const day = `2026/${String(1 + (n % 9)).padStart(2, '0')}/${String(1 + (n % 28)).padStart(2, '0')}`; + return dated(f, day, `10000000-1111-4111-8111-${String(n).padStart(12, '0')}`, { + originator: 'codex_cli', + instructions, + }); + }) + ); + } + } + const small = { maxEntries: 20, maxBytes: 1048576 }; + + it('attributes one owned rollout beside 2,100 unrelated full-size rollouts', async () => { + const f = await fixture(); + await unrelated(f, 2100); + const path = await dated(f, '2026/10/24', OWNED); + expect(await findExactCodexConversation(f.options)).toEqual({ id: OWNED, path }); + }, 60_000); + + it('re-verifies only the remembered file on a later call instead of re-walking the store', async () => { + const f = await fixture(); + await unrelated(f, 300); + const path = await dated(f, '2026/10/24', OWNED); + expect(await findExactCodexConversation(f.options)).toEqual({ id: OWNED, path }); + // 20 entries cannot cover 300 rollouts in 9 months of day directories: only the + // remembered head plus the directories not older than it fit. + expect(await findExactCodexConversation(f.options, small)).toEqual({ id: OWNED, path }); + __clearCodexAttributionMemo(); + expect(await findExactCodexConversation(f.options, small)).toBeUndefined(); + }); + + it('still catches a newer owned rollout (codex /new) after remembering one', async () => { + const f = await fixture(); + await unrelated(f, 50); + const path = await dated(f, '2026/10/24', OWNED); + expect(await findExactCodexConversation(f.options)).toEqual({ id: OWNED, path }); + await dated(f, '2026/10/25', NEWER); + expect(await findExactCodexConversation(f.options, small)).toBeUndefined(); + expect(await findExactCodexConversation(f.options)).toBeUndefined(); + }); + + it('drops the remembered file when it is replaced, and walks again', async () => { + const f = await fixture(); + const path = await dated(f, '2026/10/24', OWNED); + expect(await findExactCodexConversation(f.options)).toEqual({ id: OWNED, path }); + await rm(path); + await dated(f, '2026/10/24', OWNED, { originator: 'codex_cli' }); + expect(await findExactCodexConversation(f.options)).toBeUndefined(); + const moved = await dated(f, '2026/10/23', NEWER); + expect(await findExactCodexConversation(f.options)).toEqual({ id: NEWER, path: moved }); + }); +}); diff --git a/test/agent-coordination-setting.test.ts b/test/agent-coordination-setting.test.ts new file mode 100644 index 000000000..57ac54b15 --- /dev/null +++ b/test/agent-coordination-setting.test.ts @@ -0,0 +1,90 @@ +/** + * @fileoverview The synced `agentCoordinationEnabled` setting (default OFF) that gates the + * experimental inspect/stage/submit routes: the strict settings schema admits it, App + * Settings loads and saves it under the same control id, and its row reads in Chinese + * (zh-CN) with no English left beyond the command and product names. The route gate itself + * is pinned in test/routes/agent-session-routes.test.ts. + * + * Port: N/A. + */ +import { readFileSync } from 'node:fs'; +import { resolve } from 'node:path'; +import vm from 'node:vm'; +import { JSDOM } from 'jsdom'; +import { afterAll, describe, expect, it } from 'vitest'; +import { SettingsUpdateSchema } from '../src/web/schemas.js'; + +const PUBLIC = resolve(import.meta.dirname, '../src/web/public'); +const I18N = readFileSync(resolve(PUBLIC, 'i18n.js'), 'utf8'); +const INDEX = readFileSync(resolve(PUBLIC, 'index.html'), 'utf8'); +const SETTINGS_UI = readFileSync(resolve(PUBLIC, 'settings-ui.js'), 'utf8'); +const ID = 'appSettingsAgentCoordination'; + +type Api = { configure(o: Record): void; start(): void }; + +/** What may stay Latin: the product, CLI, key and protocol names and the three commands. */ +const leftover = (text: string) => + text.replace(/\b(Codex|Enter|HTTP|inspect|stage|submit)\b/g, '').match(/[A-Za-z]+/g) ?? []; + +function rowStrings(doc: Document): { label: string; description: string } { + const row = doc.getElementById(ID)!.closest('.set-row')!; + return { + label: row.querySelector('.set-row-label')!.textContent!.trim(), + description: row.querySelector('.set-row-desc')!.textContent!.trim(), + }; +} + +const source = new JSDOM(INDEX); +const english = rowStrings(source.window.document); +const dom = new JSDOM(INDEX, { runScripts: 'outside-only', url: 'http://localhost/' }); +vm.runInContext(I18N, dom.getInternalVMContext(), { filename: 'i18n.js' }); +const api = (dom.window as unknown as { CodemanI18n: Api }).CodemanI18n; +api.start(); +api.configure({ language: 'zh-CN' }); +const chinese = rowStrings(dom.window.document); + +afterAll(() => { + source.window.close(); + dom.window.close(); +}); + +describe('agentCoordinationEnabled', () => { + it('is a boolean the strict settings schema accepts, and nothing else', () => { + expect(SettingsUpdateSchema.safeParse({ agentCoordinationEnabled: true }).success).toBe(true); + expect(SettingsUpdateSchema.safeParse({ agentCoordinationEnabled: false }).success).toBe(true); + expect(SettingsUpdateSchema.safeParse({ agentCoordinationEnabled: 'yes' }).success).toBe(false); + }); + + it('has one checkbox in App Settings, loaded only from an explicit true and saved by id', () => { + expect(INDEX.split(`id="${ID}"`)).toHaveLength(2); + expect(source.window.document.getElementById(ID)!.getAttribute('type')).toBe('checkbox'); + expect(SETTINGS_UI).toContain( + `document.getElementById('${ID}').checked = settings.agentCoordinationEnabled === true;` + ); + expect(SETTINGS_UI).toContain(`agentCoordinationEnabled: document.getElementById('${ID}').checked,`); + }); + + it('sits in the synced Cross-session features group', () => { + const group = source.window.document.getElementById(ID)!.closest('.set-group')!; + expect(group.querySelector('.set-group-head h4')!.textContent).toBe('Cross-session features'); + expect(group.querySelector('.set-group-head .set-scope')!.textContent).toBe('synced'); + }); + + it('finds the row (the translation check is not vacuous)', () => { + expect(english.label).toBe('Experimental agent coordination'); + expect(english.description).toMatch(/^Lets agents read another session's output/); + }); + + it('translates the label and the description, with no English left', () => { + for (const key of ['label', 'description'] as const) { + expect(chinese[key]).not.toBe(english[key]); + expect(leftover(chinese[key])).toEqual([]); + } + }); + + it('adds each key to the dictionary once', () => { + for (const text of [english.label, english.description]) { + expect(I18N.split(`'${text}'`).length + I18N.split(`"${text}"`).length - 2).toBe(1); + } + }); +}); diff --git a/test/agent-staging.test.ts b/test/agent-staging.test.ts index 6a9c1ad37..976e2cbd1 100644 --- a/test/agent-staging.test.ts +++ b/test/agent-staging.test.ts @@ -2,7 +2,18 @@ import { beforeEach, expect, it } from 'vitest'; import { createHash } from 'node:crypto'; import { CODEX_REASONING_EFFORTS } from '../src/types/session.js'; -import { GuardedStageStore, type GuardedInputTarget } from '../src/agent-staging.js'; +import { + GuardedStageStore, + guardedComposerChrome, + readGuardedComposer, + readGuardedSnapshot, + type GuardedInputTarget, +} from '../src/agent-staging.js'; +import { getCli, listClis } from '../src/config/cli-registry/registry.js'; +/** Codex's composer chrome exactly as the registry declares it: no copy of the glyph or footer here. */ +const CODEX = guardedComposerChrome(getCli('codex'))!; +const readGuardedCodexComposer = (screen: string) => readGuardedComposer(screen, CODEX); +const readGuardedCodexSnapshot = (snapshot: string) => readGuardedSnapshot(snapshot, CODEX); const sha = (s: string) => createHash('sha256').update(s).digest('hex'); let store: GuardedStageStore; let time: number; @@ -110,7 +121,6 @@ it('does not issue a receipt on failed staging or intervening input during its w }); it('reads exact composer spaces and refuses wrapped or modal frames', async () => { - const { readGuardedCodexComposer } = await import('../src/agent-staging.js'); expect(readGuardedCodexComposer('› hello world\n GPT-6-Luna default · ~/case\n')).toBe('hello world'); expect( readGuardedCodexComposer( @@ -122,7 +132,6 @@ it('reads exact composer spaces and refuses wrapped or modal frames', async () = expect(readGuardedCodexComposer('› hello\n')).toBeNull(); }); it('requires capture cursor geometry, rejecting invisible trailing edits and moved cursors', async () => { - const { readGuardedCodexSnapshot } = await import('../src/agent-staging.js'); const { formatPaneSnapshot } = await import('../src/tmux-manager.js'); const frame = (text: string, x: number, y = 0) => formatPaneSnapshot([`› ${text}`, ' GPT-6-Luna default · ~/case'], { cols: 100, rows: 20, cursorX: x, cursorY: y }); @@ -157,7 +166,6 @@ it('reserves receipt capacity before concurrent stages type', async () => { it.each([...CODEX_REASONING_EFFORTS, 'default'])( 'accepts the shared Codex effort word %s in the footer', async (word) => { - const { readGuardedCodexComposer } = await import('../src/agent-staging.js'); expect(readGuardedCodexComposer(`› hello\n GPT-6 ${word} · ~/case\n`)).toBe('hello'); } ); @@ -168,3 +176,22 @@ it('refuses messages that reach the available pane columns before typing', async expect(writes).toEqual([]); await expect(store.stage(target, 'alice', 'abc')).resolves.toMatchObject({ submitted: false }); }); + +it('takes the composer glyph and footer from the registry, and only codex declares a footer', () => { + const codex = getCli('codex')!.capabilities.workDetect!; + expect(CODEX.promptGlyph).toBe(codex.promptGlyph); + expect(CODEX.footerLine.source).toBe(codex.composerFooterLine); + // Another glyph drives the same reader: nothing in it is codex-specific. + const other = { promptGlyph: '>>', footerLine: CODEX.footerLine }; + expect(readGuardedComposer('>> hello\n GPT-6 default · ~/case\n', other)).toBe('hello'); + expect(readGuardedComposer('› hello\n GPT-6 default · ~/case\n', other)).toBeNull(); + const declaring = listClis() + .filter((entry) => entry.capabilities.workDetect?.composerFooterLine) + .map((entry) => entry.id); + expect(declaring).toEqual(['codex']); + expect(guardedComposerChrome(getCli('claude'))).toBeNull(); + // A pattern the ReDoS guard refuses yields no chrome, so no guarded input. + const unsafe = structuredClone(getCli('codex')!); + unsafe.capabilities.workDetect!.composerFooterLine = '(a+)+b'; + expect(guardedComposerChrome(unsafe)).toBeNull(); +}); diff --git a/test/cli-agent-inspection.test.ts b/test/cli-agent-inspection.test.ts index a0b023a7e..d1858a3a5 100644 --- a/test/cli-agent-inspection.test.ts +++ b/test/cli-agent-inspection.test.ts @@ -92,3 +92,36 @@ it('registers required file and confirmation options and bounded inspect flags', ?.mandatory ).toBe(true); }); +it('shows the opt-in 403 verbatim on inspect, stage and submit while the setting is off', async () => { + const dir = await mkdtemp(join(tmpdir(), 'agent-stage-cli-')); + try { + const path = join(dir, 'message'); + await writeFile(path, 'hello'); + const message = + 'Experimental agent coordination (inspect, stage, submit) is disabled. Turn on "Experimental agent coordination" in Settings (agentCoordinationEnabled) and save first.'; + for (const json of [false, true]) { + const lines: string[] = []; + const refusing: AgentDeps = { + ctx: { apiUrl: 'http://localhost:1', selfId: SELF }, + json, + now: () => 1, + io: { out: (line) => lines.push(line), err: (line) => lines.push(line) }, + request: async () => ({ + status: 403, + text: '', + json: { success: false, error: message, errorCode: 'FORBIDDEN' }, + }), + }; + expect(await agentInspect(refusing, { id: OTHER, maxBytes: 10 })).toBe(1); + expect(await agentStage(refusing, { id: OTHER, messageFile: path })).toBe(1); + expect(await agentSubmit(refusing, { id: OTHER, stageId: OTHER, confirmSha: 'a'.repeat(64) })).toBe(1); + expect(lines).toHaveLength(3); + for (const line of lines) { + const shown = json ? (JSON.parse(line) as { success: boolean; error: string }).error : line; + expect(shown).toContain(`FORBIDDEN: ${message} (HTTP 403)`); + } + } + } finally { + await rm(dir, { recursive: true, force: true }); + } +}); diff --git a/test/cli-registry-schema.test.ts b/test/cli-registry-schema.test.ts index 9fa1c6ee5..5c4a468c7 100644 --- a/test/cli-registry-schema.test.ts +++ b/test/cli-registry-schema.test.ts @@ -184,6 +184,27 @@ describe('workDetect.workingLine is guarded like every other config regex', () = } }); + it('holds the optional composerFooterLine to the same guard', () => { + // Run over the rows under the composer each time guarded input reads the pane. + for (const composerFooterLine of ['(a+)+b', 'a'.repeat(201), '([unclosed', '']) { + expectRejected((e) => { + (e.capabilities as Record).workDetect = { + promptGlyph: '>', + workingLine: 'working', + composerFooterLine, + }; + }, 'guarded input tests it against pane rows a clis.json override can supply'); + } + }); + + it('accepts every shipped composerFooterLine', () => { + for (const entry of STOCK_CLIS) { + const src = entry.capabilities.workDetect?.composerFooterLine; + if (!src) continue; + expect(compileVersionRegex(src), `${entry.id} declares a composerFooterLine the guard refuses`).not.toBeNull(); + } + }); + it('holds the optional blockedLine to the same guard', () => { // Run over the foot of a capture at every settle and on a timer while a dialog is // up, and a clis.json override can set it. diff --git a/test/routes/agent-session-routes.test.ts b/test/routes/agent-session-routes.test.ts index 27fbd4158..8bafbb151 100644 --- a/test/routes/agent-session-routes.test.ts +++ b/test/routes/agent-session-routes.test.ts @@ -1,11 +1,118 @@ /** @fileoverview Agent inspection and guarded input HTTP contracts (no live ports). */ -import { afterEach, describe, expect, it } from 'vitest'; -import { registerAgentSessionRoutes } from '../../src/web/routes/agent-session-routes.js'; +import { mkdirSync, rmSync, writeFileSync } from 'node:fs'; +import { dirname } from 'node:path'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { + AGENT_COORDINATION_DISABLED_MESSAGE, + registerAgentSessionRoutes, +} from '../../src/web/routes/agent-session-routes.js'; +import { InspectionReader } from '../../src/agent-inspection.js'; +import { SETTINGS_PATH } from '../../src/web/route-helpers.js'; import { createRouteTestHarness, type RouteTestHarness } from './_route-test-utils.js'; let harness: RouteTestHarness; +/** The routes are opt-in (`agentCoordinationEnabled`, default OFF); undefined = key absent. */ +function setEnabled(on: boolean | undefined): void { + mkdirSync(dirname(SETTINGS_PATH), { recursive: true }); + writeFileSync(SETTINGS_PATH, JSON.stringify(on === undefined ? {} : { agentCoordinationEnabled: on })); +} +beforeEach(() => setEnabled(true)); afterEach(async () => { await harness?.app.close(); + rmSync(SETTINGS_PATH, { force: true }); +}); + +describe('the agentCoordinationEnabled gate', () => { + const requests = (id: string) => [ + { method: 'GET' as const, url: `/api/sessions/${id}/inspect` }, + { method: 'GET' as const, url: `/api/sessions/${id}/inspect?maxBytes=0` }, + { method: 'POST' as const, url: `/api/sessions/${id}/stage`, payload: { message: 'hello' } }, + { + method: 'POST' as const, + url: `/api/sessions/${id}/submit`, + payload: { stageId: '11111111-1111-4111-8111-111111111111', confirmSha: 'a'.repeat(64) }, + }, + ]; + it.each([ + ['absent', undefined], + ['false', false], + ] as const)('answers 403 naming the setting on every route while it is %s', async (_label, value) => { + setEnabled(value); + harness = await createRouteTestHarness(registerAgentSessionRoutes); + const session = [...harness.ctx.sessions.values()][0]; + // A session id that does not exist is refused the same way: the gate runs before lookup. + for (const request of [...requests(session.id), ...requests('no-such-session')]) { + const result = await harness.app.inject(request); + expect(result.statusCode).toBe(403); + expect(result.json()).toEqual({ + success: false, + error: AGENT_COORDINATION_DISABLED_MESSAGE, + errorCode: 'FORBIDDEN', + }); + } + expect(AGENT_COORDINATION_DISABLED_MESSAGE).toContain('agentCoordinationEnabled'); + expect(session.writeBuffer).toEqual([]); + }); + it('answers on every route once it is on, with no restart', async () => { + setEnabled(false); + harness = await createRouteTestHarness(registerAgentSessionRoutes); + const session = [...harness.ctx.sessions.values()][0]; + expect((await harness.app.inject(requests(session.id)[0])).statusCode).toBe(403); + setEnabled(true); + const [inspect, invalid, stage, submit] = await Promise.all( + requests(session.id).map((request) => harness.app.inject(request)) + ); + expect(inspect.statusCode).toBe(200); + expect(inspect.json().data.source).toBe('pane-snapshot'); + expect(invalid.statusCode).toBe(400); + // The mock session has no characterized composer, so the guards refuse: the route answered. + expect(stage.statusCode).toBe(409); + expect(submit.statusCode).toBe(409); + }); +}); + +it('reports a non-cursor inspection failure as OPERATION_FAILED and logs only the session and error code', async () => { + const { mkdtemp, mkdir, writeFile, rm } = await import('node:fs/promises'); + const { tmpdir } = await import('node:os'); + const { join } = await import('node:path'); + const { Session } = await import('../../src/session.js'); + const root = await mkdtemp(join(tmpdir(), 'agent-inspect-fail-')); + const home = join(root, 'codex'); + await mkdir(join(home, 'sessions'), { recursive: true }); + const session = new Session({ + id: 'inspect-fail', + workingDir: root, + mode: 'codex', + envOverrides: { CODEX_HOME: home }, + }); + const id = '11111111-1111-4111-8111-111111111111'; + const path = join(home, 'sessions', `rollout-${id}.jsonl`); + await writeFile( + path, + JSON.stringify({ type: 'session_meta', payload: { id, cwd: root, originator: `codeman_${session.id}` } }) + '\n' + ); + harness = await createRouteTestHarness(registerAgentSessionRoutes); + (harness.ctx.sessions as unknown as Map).set(session.id, session); + const read = vi + .spyOn(InspectionReader.prototype, 'read') + .mockRejectedValue(Object.assign(new Error(`EACCES: permission denied, open '${path}'`), { code: 'EACCES' })); + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}); + try { + const result = await harness.app.inject({ url: `/api/sessions/${session.id}/inspect` }); + expect(read).toHaveBeenCalled(); + expect(result.statusCode).toBe(422); + expect(result.json().errorCode).toBe('OPERATION_FAILED'); + expect(result.body).not.toContain(path); + const logged = warn.mock.calls.map((call) => call.join(' ')).join('\n'); + expect(logged).toContain(session.id); + expect(logged).toContain('EACCES'); + expect(logged).not.toContain(home); + } finally { + read.mockRestore(); + warn.mockRestore(); + await session.stop(false); + await rm(root, { recursive: true, force: true }); + } }); describe('agent session routes', () => { it('returns an explicitly bounded pane snapshot when no attributed transcript exists', async () => { diff --git a/test/webserver-bound-port.test.ts b/test/webserver-bound-port.test.ts index 6bce845a5..020b83a4e 100644 --- a/test/webserver-bound-port.test.ts +++ b/test/webserver-bound-port.test.ts @@ -45,8 +45,32 @@ describe('WebServer on an ephemeral port', () => { const session = new Session({ id: 'agent-route-wiring', workingDir: process.env.HOME!, mode: 'shell' }); const sessions = (server as unknown as { sessions: Map }).sessions; sessions.set(session.id, session); + const { mkdirSync, rmSync, writeFileSync } = await import('node:fs'); + const { dirname } = await import('node:path'); + const { SETTINGS_PATH } = await import('../src/web/route-helpers.js'); + const setEnabled = (on: boolean) => { + mkdirSync(dirname(SETTINGS_PATH), { recursive: true }); + writeFileSync(SETTINGS_PATH, JSON.stringify({ agentCoordinationEnabled: on })); + }; try { const prefix = `http://127.0.0.1:${server.boundPort}/api/v1/sessions/${session.id}`; + // Opt-in: off, the /api/v1 alias of every route refuses with 403 FORBIDDEN. + setEnabled(false); + for (const [method, verb] of [ + ['GET', 'inspect'], + ['POST', 'stage'], + ['POST', 'submit'], + ] as const) { + const refused = await fetch(prefix + '/' + verb, { + method, + ...(method === 'POST' ? { headers: { 'Content-Type': 'application/json' }, body: '{}' } : {}), + }); + expect(refused.status).toBe(403); + const body = await refused.json(); + expect(body.errorCode).toBe('FORBIDDEN'); + expect(body.error).toContain('agentCoordinationEnabled'); + } + setEnabled(true); const inspected = await fetch(prefix + '/inspect'); expect(inspected.status).toBe(200); expect((await inspected.json()).data.source).toBe('pane-snapshot'); @@ -63,6 +87,7 @@ describe('WebServer on an ephemeral port', () => { expect((await response.json()).errorCode).toBe('CONFLICT'); } } finally { + rmSync(SETTINGS_PATH, { force: true }); sessions.delete(session.id); await session.stop(false); } From 803e7c7f852a8b668eca5dc93bba2ea402b737f8 Mon Sep 17 00:00:00 2001 From: Aamer Akhter Date: Sat, 24 Oct 2026 23:00:49 -0400 Subject: [PATCH 26/68] refactor(codex): share the rollout format between the two Codex history readers The Past Sessions scanner and the offline history reader each parsed Codex rollouts with their own shape helpers, their own CODEX_HOME resolution and different injected-context rules. Move the store location, the three message shapes and the injected-context rule into codex-rollout-format.ts. The scanner's behaviour is unchanged; the offline reader adopts its rule in the next commit. --- src/codex-rollout-format.ts | 128 ++++++++++++++++++++++++++++++ src/codex-transcript.ts | 69 ++++------------ test/codex-rollout-format.test.ts | 112 ++++++++++++++++++++++++++ 3 files changed, 256 insertions(+), 53 deletions(-) create mode 100644 src/codex-rollout-format.ts create mode 100644 test/codex-rollout-format.test.ts diff --git a/src/codex-rollout-format.ts b/src/codex-rollout-format.ts new file mode 100644 index 000000000..068e68bf9 --- /dev/null +++ b/src/codex-rollout-format.ts @@ -0,0 +1,128 @@ +/** + * @fileoverview Shape helpers for Codex rollout JSONL lines, shared by the two + * readers of `~/.codex/sessions`: the Past Sessions scanner + * (`codex-transcript.ts`, previews only) and the offline history reader + * (`local-history.ts`, whole conversations). + * + * Both readers must agree on three facts, so they live here once: + * + * - where the store is (`$CODEX_HOME/sessions`, else `~/.codex/sessions`); + * - which line shapes carry a user or assistant message; + * - which `response_item` user rows are injected context rather than input. + * + * ## The message shapes + * + * Codex has written conversation text under three shapes: + * + * - `event_msg` / `user_message` and `agent_message`, written by every version + * seen so far. `agent_message` carries a `phase` (`commentary` or + * `final_answer`); `user_message` carries none. + * - `event_msg` / `item_completed` with an `item.type` of `UserMessage` or + * `AgentMessage`, written by newer versions (0.152.1 and later). + * - `response_item` rows with `role: 'user'` or `'assistant'`. These are what + * the model saw, so the user rows mix real input with injected context. + * + * ## The injected-context rule + * + * Codex injects context as `response_item` user rows that start with a Markdown + * heading (`# AGENTS.md instructions for ...`) or an XML-style tag + * (``, ``, ``, + * ``, ``, ``, ``). + * Measured on a store of 2,916 rollouts (codex 0.120 to 0.163), every + * `response_item` user row with either prefix and no event twin was injected; + * the real prompts that start with `#` all have a `user_message` or + * `UserMessage` event twin, which this rule never filters. A fixed list of tag + * names would leak every wrapper Codex adds later (`` + * alone was 1,355 rows on that store), so the rule is the prefix. + * + * @module codex-rollout-format + */ + +import { homedir } from 'node:os'; +import { join } from 'node:path'; + +/** Codex's home directory: `$CODEX_HOME`, else `~/.codex`, exactly as codex resolves it. */ +export function codexHomeDir(): string { + return process.env.CODEX_HOME || join(homedir(), '.codex'); +} + +/** The rollout store under {@link codexHomeDir}. */ +export function codexSessionsRoot(): string { + return join(codexHomeDir(), 'sessions'); +} + +/** + * Flatten Codex message content, which is a string or an array of blocks. + * Only blocks carrying a string `text` contribute (images and skills do not). + * Not trimmed: callers that want a preview trim. + */ +export function codexContentText(content: unknown): string { + if (typeof content === 'string') return content; + if (!Array.isArray(content)) return ''; + return content + .filter( + (b): b is { text: string } => !!b && typeof b === 'object' && typeof (b as { text?: unknown }).text === 'string' + ) + .map((b) => b.text) + .filter(Boolean) + .join('\n'); +} + +/** Injected context rather than typed input. Applies to `response_item` user rows only. */ +export function isCodexInjectedContext(text: string): boolean { + const start = text.trimStart(); + return start.startsWith('#') || start.startsWith('<'); +} + +/** One rollout line's user or assistant message, whichever shape carried it. */ +export interface CodexLineMessage { + role: 'user' | 'assistant'; + /** Untrimmed text, never empty. */ + text: string; + /** `event` = user_message/agent_message, `item` = item_completed, `response` = response_item. */ + shape: 'event' | 'item' | 'response'; + /** Codex's own phase (`commentary` / `final_answer`) when the line carries one. */ + phase?: string; + /** A `response_item` user row: may be injected context, see {@link isCodexInjectedContext}. */ + injectionProne: boolean; +} + +function record(value: unknown): Record { + return value && typeof value === 'object' && !Array.isArray(value) ? (value as Record) : {}; +} + +function phaseOf(value: unknown): string | undefined { + return typeof value === 'string' && value ? value : undefined; +} + +/** The message a parsed rollout line carries, or null for every other line. */ +export function codexMessageFromLine(entry: unknown): CodexLineMessage | null { + const e = record(entry); + const p = record(e.payload); + let role: CodexLineMessage['role'] | undefined; + let text = ''; + let shape: CodexLineMessage['shape'] = 'event'; + let phase: string | undefined; + if (e.type === 'event_msg') { + if (p.type === 'user_message' || p.type === 'agent_message') { + role = p.type === 'user_message' ? 'user' : 'assistant'; + text = codexContentText(p.message); + phase = phaseOf(p.phase); + } else if (p.type === 'item_completed') { + const item = record(p.item); + if (item.type === 'UserMessage' || item.type === 'AgentMessage') { + role = item.type === 'UserMessage' ? 'user' : 'assistant'; + shape = 'item'; + text = codexContentText(item.content); + phase = phaseOf(item.phase); + } + } + } else if (e.type === 'response_item' && (p.role === 'user' || p.role === 'assistant')) { + role = p.role; + shape = 'response'; + text = codexContentText(p.content); + phase = phaseOf(p.phase); + } + if (!role || !text) return null; + return { role, text, shape, phase, injectionProne: shape === 'response' && role === 'user' }; +} diff --git a/src/codex-transcript.ts b/src/codex-transcript.ts index 9f360ee3b..aabc0624c 100644 --- a/src/codex-transcript.ts +++ b/src/codex-transcript.ts @@ -37,13 +37,20 @@ * summaries), so they are read only when neither shape above appears, and * the obvious injections are dropped. * + * The shapes, the injected-context rule and the store location live in + * `codex-rollout-format.ts`, shared with the offline reader `local-history.ts`. + * * @module codex-transcript */ import { open, readdir, stat } from 'node:fs/promises'; -import { homedir } from 'node:os'; import { join } from 'node:path'; +import { + codexMessageFromLine, + codexSessionsRoot, + isCodexInjectedContext as isInjectedContext, +} from './codex-rollout-format.js'; import { LRUMap } from './utils/lru-map.js'; /** Covers `session_meta` (~19 KiB) plus the first user message (~69 KiB behind it). */ @@ -134,11 +141,6 @@ function shouldCacheIdentity(identity: RolloutIdentity, fileSize: number): boole return identity.firstPrompt !== undefined || fileSize >= HEAD_BYTES; } -function codexSessionsRoot(): string { - const home = process.env.CODEX_HOME || join(homedir(), '.codex'); - return join(home, 'sessions'); -} - /** Read at most `bytes` from the front of a file. Returns '' when unreadable. */ async function readHead(path: string, bytes: number): Promise { const fh = await open(path, 'r').catch(() => null); @@ -176,55 +178,16 @@ async function readTail(path: string, size: number, bytes: number): Promise !!b && typeof b === 'object' && typeof (b as { text?: unknown }).text === 'string' - ) - .map((b) => b.text) - .join('\n') - .trim(); -} - -/** One line's user-prompt text, whichever of the three shapes it is. */ -function userPromptFromLine(entry: { - type?: string; - payload?: { - type?: string; - role?: string; - content?: unknown; - message?: unknown; - item?: { type?: string; content?: unknown }; - }; -}): { text: string; injectionProne: boolean } | null { - const p = entry.payload; - if (!p) return null; - - if (entry.type === 'event_msg' && p.type === 'item_completed' && p.item?.type === 'UserMessage') { - const text = contentText(p.item.content); - return text ? { text, injectionProne: false } : null; - } - if (entry.type === 'event_msg' && p.type === 'user_message') { - const text = typeof p.message === 'string' ? p.message.trim() : contentText(p.message); - return text ? { text, injectionProne: false } : null; - } - if (entry.type === 'response_item' && p.role === 'user') { - const text = contentText(p.content); - return text ? { text, injectionProne: true } : null; - } - return null; -} - /** - * Injected context rather than something the user typed. Codex prepends the - * repository's AGENTS.md and wraps environment context in a tag, and both arrive - * as `response_item` user rows. + * One line's user-prompt text, whichever of the three shapes it is. The shapes + * and the injected-context rule are shared with the offline history reader + * (`codex-rollout-format.ts`), so the two never disagree about what a prompt is. */ -function isInjectedContext(text: string): boolean { - return text.startsWith('#') || text.startsWith('<'); +function userPromptFromLine(entry: unknown): { text: string; injectionProne: boolean } | null { + const message = codexMessageFromLine(entry); + if (!message || message.role !== 'user') return null; + const text = message.text.trim(); + return text ? { text, injectionProne: message.injectionProne } : null; } /** Collapse to one line and cap, so a row carries a title rather than an essay. */ diff --git a/test/codex-rollout-format.test.ts b/test/codex-rollout-format.test.ts new file mode 100644 index 000000000..761f32b83 --- /dev/null +++ b/test/codex-rollout-format.test.ts @@ -0,0 +1,112 @@ +/** + * @fileoverview The Codex rollout shape helpers shared by the Past Sessions + * scanner (codex-transcript.ts) and the offline history reader + * (local-history.ts): store location, message shapes and the injected-context + * rule. Both readers' own tests cover their use of these; this pins the shared + * contract itself. + */ +import { afterEach, describe, expect, it } from 'vitest'; +import { join } from 'node:path'; +import { + codexContentText, + codexHomeDir, + codexMessageFromLine, + codexSessionsRoot, + isCodexInjectedContext, +} from '../src/codex-rollout-format.js'; + +const savedHome = process.env.CODEX_HOME; +afterEach(() => { + if (savedHome === undefined) delete process.env.CODEX_HOME; + else process.env.CODEX_HOME = savedHome; +}); + +describe('codex rollout format', () => { + it('resolves the store from CODEX_HOME, else ~/.codex', () => { + process.env.CODEX_HOME = '/custom/codex'; + expect(codexHomeDir()).toBe('/custom/codex'); + expect(codexSessionsRoot()).toBe(join('/custom/codex', 'sessions')); + process.env.CODEX_HOME = ''; + expect(codexSessionsRoot()).toBe(join(process.env.HOME!, '.codex', 'sessions')); + }); + + it('reads all three message shapes with their role, shape and phase', () => { + expect(codexMessageFromLine({ type: 'event_msg', payload: { type: 'user_message', message: 'hi' } })).toEqual({ + role: 'user', + text: 'hi', + shape: 'event', + phase: undefined, + injectionProne: false, + }); + expect( + codexMessageFromLine({ + type: 'event_msg', + payload: { type: 'agent_message', message: 'ok', phase: 'commentary' }, + }) + ).toMatchObject({ role: 'assistant', shape: 'event', phase: 'commentary', injectionProne: false }); + expect( + codexMessageFromLine({ + type: 'event_msg', + payload: { + type: 'item_completed', + item: { type: 'AgentMessage', phase: 'final_answer', content: [{ type: 'Text', text: 'done' }] }, + }, + }) + ).toMatchObject({ role: 'assistant', text: 'done', shape: 'item', phase: 'final_answer' }); + expect( + codexMessageFromLine({ + type: 'response_item', + payload: { type: 'message', role: 'user', content: [{ type: 'input_text', text: 'typed' }] }, + }) + ).toMatchObject({ role: 'user', text: 'typed', shape: 'response', injectionProne: true }); + expect( + codexMessageFromLine({ + type: 'response_item', + payload: { type: 'message', role: 'assistant', content: [{ type: 'output_text', text: 'a' }] }, + }) + ).toMatchObject({ role: 'assistant', shape: 'response', injectionProne: false }); + }); + + it('ignores every other line, empty text and malformed input', () => { + for (const value of [ + null, + 42, + 'text', + [], + { type: 'session_meta', payload: { id: 'x' } }, + { type: 'response_item', payload: { type: 'message', role: 'developer', content: 'policy' } }, + { type: 'response_item', payload: { type: 'reasoning', summary: [] } }, + { type: 'event_msg', payload: { type: 'user_message', message: '' } }, + { type: 'event_msg', payload: { type: 'item_completed', item: { type: 'CommandExecution' } } }, + ]) + expect(codexMessageFromLine(value)).toBeNull(); + }); + + it('flattens only blocks that carry text', () => { + expect(codexContentText('plain')).toBe('plain'); + expect( + codexContentText([ + { type: 'input_text', text: 'a' }, + { type: 'input_image', image_url: 'x' }, + { text: 'b' }, + null, + ]) + ).toBe('a\nb'); + expect(codexContentText(undefined)).toBe(''); + }); + + it('treats a heading or a tag as injected context, wherever the wrapper name is new', () => { + for (const text of [ + '# AGENTS.md instructions for /repo', + '', + '', + '', + '', + '', + ' ', + ]) + expect(isCodexInjectedContext(text)).toBe(true); + for (const text of ['fix the bug', 'You are a reviewer', '$skill run', 'a tag later']) + expect(isCodexInjectedContext(text)).toBe(false); + }); +}); From 002d876556d89967e1c37072de03e1f740502361 Mon Sep 17 00:00:00 2001 From: Aamer Akhter Date: Sat, 24 Oct 2026 23:00:49 -0400 Subject: [PATCH 27/68] fix(history): read forked threads, large stores and live rollouts A forked or spawned thread's rollout carries its own session_meta followed by its parent's and a copy of the parent's history. A later session_meta for a declared ancestor now opens an inherited prefix that is dropped: by ordinal below subagent_history_start_ordinal in paginated history, and up to the thread_settings_applied event Codex appends after the copy in legacy history, checked against the parent's own messages when the copy holds more than one candidate. An unrelated id still refuses the file. Store attribution now has its own budget (--metadata-bytes) separate from the transcript budget (--scan-bytes), reads each head in 32 KiB steps, and --max-entries is a flag, so id lookups work on stores of thousands of rollouts. An unattributable store file or unreadable directory is counted instead of aborting the lookup, and a rollout that a running Codex appends to is read as a snapshot up to its last complete line. User records carry no phase, date bounds are UTC calendar dates, the injected-context rule is the shared one, a symlinked sessions root is followed as the Past Sessions scanner follows it, and --json with --format text is refused. --- src/cli-history.ts | 70 +++- src/local-history.ts | 636 ++++++++++++++++++++++---------- test/fixtures/codex-rollout.ts | 125 +++++++ test/local-history-live.test.ts | 129 +++++++ test/local-history.test.ts | 472 +++++++++++++++++++++++- 5 files changed, 1233 insertions(+), 199 deletions(-) create mode 100644 test/fixtures/codex-rollout.ts create mode 100644 test/local-history-live.test.ts diff --git a/src/cli-history.ts b/src/cli-history.ts index 614a416ef..11be60888 100644 --- a/src/cli-history.ts +++ b/src/cli-history.ts @@ -1,6 +1,33 @@ -/** Local offline transcript commands. Deliberately separate from the agent API guard. */ +/** + * @fileoverview `codeman history read`: the command-line front of the offline + * Codex history reader (`local-history.ts`). + * + * Deliberately separate from `codeman agent` (`cli-agent.ts`): that command is + * a client of a running Codeman and refuses outside a managed session, while + * this one reads rollout files from disk and needs neither a server nor + * `CODEMAN_MUX`. It never writes, never inspects a live pane and never sends + * input. + * + * Every bound the reader enforces has a flag here with the reader's own + * maximum, so a large store is a matter of raising a number rather than an + * impossible lookup. Output is JSON unless `--format text` is given; `--json` + * is the JSON spelling and refuses to combine with `--format text`. + * + * @module cli-history + */ import type { Command } from 'commander'; -import { formatLocalHistory, HISTORY_DEFAULT_SCAN_BYTES, readLocalHistory } from './local-history.js'; +import { + formatLocalHistory, + HISTORY_DEFAULT_MAX_ENTRIES, + HISTORY_DEFAULT_METADATA_BYTES, + HISTORY_DEFAULT_OUTPUT_BYTES, + HISTORY_DEFAULT_SCAN_BYTES, + HISTORY_MAX_ENTRIES, + HISTORY_MAX_METADATA_BYTES, + HISTORY_MAX_OUTPUT_BYTES, + HISTORY_MAX_SCAN_BYTES, + readLocalHistory, +} from './local-history.js'; export function registerHistoryCommands(program: Command): Command { const history = program @@ -12,22 +39,41 @@ export function registerHistoryCommands(program: Command): Command { .option('--file ', 'Explicit local JSONL transcript (regular file only)') .option('--session-id ', 'Native Codex conversation UUID or unique prefix (at least 8 characters)') .option('--root-session-id ', 'Native root UUID, including transitive child conversations') - .option('--sessions-root ', 'Local Codex sessions directory (default: CODEX_HOME/sessions)') + .option( + '--sessions-root ', + 'Local Codex sessions directory (default: $CODEX_HOME/sessions, else ~/.codex/sessions)' + ) .option('--topic ', 'Case-insensitive literal match, preserving whole matching turns') - .option('--after ', 'Inclusive YYYY-MM-DD lower bound; preserves whole matching turns') - .option('--before ', 'Inclusive YYYY-MM-DD upper bound; preserves whole matching turns') - .option('--max-bytes ', 'Maximum complete output bytes (1–1048576)', '8192') + .option('--after ', 'Inclusive YYYY-MM-DD lower bound, as a UTC date; preserves whole matching turns') + .option('--before ', 'Inclusive YYYY-MM-DD upper bound, as a UTC date; preserves whole matching turns') + .option( + '--max-bytes ', + `Maximum complete output bytes (1-${HISTORY_MAX_OUTPUT_BYTES})`, + String(HISTORY_DEFAULT_OUTPUT_BYTES) + ) .option( '--scan-bytes ', - 'Total metadata and selected transcript read budget (1–67108864)', + `Read budget for the selected transcripts (1-${HISTORY_MAX_SCAN_BYTES})`, String(HISTORY_DEFAULT_SCAN_BYTES) ) + .option( + '--metadata-bytes ', + `Read budget for attributing every rollout in the store (1-${HISTORY_MAX_METADATA_BYTES})`, + String(HISTORY_DEFAULT_METADATA_BYTES) + ) + .option( + '--max-entries ', + `Directory entries visited in the store (1-${HISTORY_MAX_ENTRIES})`, + String(HISTORY_DEFAULT_MAX_ENTRIES) + ) .option('--format ', 'json or text', 'json') - .option('--json', 'JSON output (also the default)') - .action(async (options: Record) => { + .option('--json', 'JSON output (the default; conflicts with --format text)') + .action(async (options: Record, command: Command) => { try { - const format = options.json ? 'json' : options.format; - if (format !== 'json' && format !== 'text') throw new Error('--format must be json or text'); + if (options.format !== 'json' && options.format !== 'text') throw new Error('--format must be json or text'); + if (options.json && options.format === 'text' && command.getOptionValueSource('format') === 'cli') + throw new Error('--json conflicts with --format text; pass one of them'); + const format = options.format; const result = await readLocalHistory({ file: options.file as string | undefined, sessionId: options.sessionId as string | undefined, @@ -37,6 +83,8 @@ export function registerHistoryCommands(program: Command): Command { after: options.after as string | undefined, before: options.before as string | undefined, scanBytes: Number(options.scanBytes), + metadataBytes: Number(options.metadataBytes), + maxEntries: Number(options.maxEntries), }); program.configureOutput().writeOut?.(`${formatLocalHistory(result, format, Number(options.maxBytes))}\n`); } catch (error) { diff --git a/src/local-history.ts b/src/local-history.ts index ac7fb04f1..e6162c0d8 100644 --- a/src/local-history.ts +++ b/src/local-history.ts @@ -1,26 +1,117 @@ -/** Offline, local-operator Codex history. No HTTP routes or running session required. */ +/** + * @fileoverview Offline reader for saved Codex conversations, behind + * `codeman history read` (`cli-history.ts`). It reads rollout JSONL files from + * disk directly: no HTTP route, no running server and no managed session. + * + * ## Selecting conversations + * + * Exactly one selector: `file` (one rollout, no store search), `sessionId` (a + * native thread id or unique prefix of at least 8 characters) or + * `rootSessionId` (a full id plus every transitive descendant). Identity always + * comes from each file's `session_meta`, never from its filename. + * + * ## Budgets + * + * Three separate bounds, each raisable from the CLI up to a fixed maximum, and + * each failing closed with an error rather than returning a partial answer: + * + * - `metadataBytes`: the head reads that attribute every rollout in the store. + * A `session_meta` line embeds Codex's base instructions (about 20 KiB on + * every rollout measured), so this scales with the store, not the query, + * and is kept apart from the transcript budget: a lookup on a store of + * thousands of rollouts must not spend the budget meant for the transcripts + * it returns. Each file's head read is also capped at 128 KiB. + * - `scanBytes`: the selected transcripts, read whole. + * - `maxEntries`: directory entries visited. + * + * ## Forks and sub-agents + * + * A thread Codex forks or spawns as a sub-agent can carry a copy of its + * parent's history in its own file (codex-rs `ForkPersistence::Copied`, written + * by `record_initial_history` in `core/src/session/mod.rs`; the copy keeps the + * parent's `session_meta`, see `keep_forked_rollout_item` in + * `core/src/agent/control/spawn.rs`). Such a file starts with its own + * `session_meta`, then the parent's, then the copied records, then one + * `thread_settings_applied` event, then the child's own records. The copy + * already lives in the parent's own file, so it is dropped here: + * + * - Paginated history (`history_mode: "paginated"`) numbers every record with + * an `ordinal` and declares `subagent_history_start_ordinal`; records below + * it are inherited, exactly as Codex's own projection treats them + * (`thread-store/src/local/thread_history_materialization.rs`). A paginated + * thread with a `history_base` references its prefix in another file and has + * nothing inherited to drop. + * - Legacy history has no ordinals. The prefix runs from the inherited + * `session_meta` to the `thread_settings_applied` event Codex appends after + * the copy. A legacy copy keeps the parent's own `thread_settings_applied` + * events too, so when more than one candidate follows, the boundary is the + * last one whose preceding messages are still a prefix of the parent's own + * messages, read from the parent's file. Without the parent's file that + * cannot be proven: the cut then falls at the LAST candidate, so parent text + * is never attributed to the child, and the result says so + * (`unverifiedPrefixes`, `complete: false`). + * + * A later `session_meta` must be the file's own id or a declared ancestor + * (`parent_thread_id`, `forked_from_id`, or the shared root `session_id`, and + * transitively the ancestors those declare); any other id still refuses the + * whole file rather than attribute one conversation's answer to another. + * + * ## Live rollouts + * + * A running Codex appends to its rollout. Reads take a snapshot: the file's + * identity (device and inode) must match the attributed one and its size may + * only have grown; everything up to the last complete line is read, and an + * unterminated tail is reported as a partial record. + * + * ## Format + * + * The line shapes and the injected-context rule are shared with the Past + * Sessions scanner through `codex-rollout-format.ts`. + * + * @module local-history + */ import { constants } from 'node:fs'; import { isUtf8 } from 'node:buffer'; -import { lstat, open, opendir, type FileHandle } from 'node:fs/promises'; -import { homedir } from 'node:os'; +import { lstat, open, opendir, stat, type FileHandle } from 'node:fs/promises'; import { join, resolve } from 'node:path'; -export const HISTORY_DEFAULT_SCAN_BYTES = 16 * 1024 * 1024; -export const HISTORY_MAX_SCAN_BYTES = 64 * 1024 * 1024; -export const HISTORY_MAX_OUTPUT_BYTES = 1024 * 1024; +import { codexMessageFromLine, codexSessionsRoot, isCodexInjectedContext } from './codex-rollout-format.js'; + +const KIB = 1024; +const MIB = 1024 * KIB; +const GIB = 1024 * MIB; + +export const HISTORY_DEFAULT_SCAN_BYTES = 16 * MIB; +export const HISTORY_MAX_SCAN_BYTES = 256 * MIB; +export const HISTORY_DEFAULT_METADATA_BYTES = 1 * GIB; +export const HISTORY_MAX_METADATA_BYTES = 16 * GIB; +export const HISTORY_DEFAULT_MAX_ENTRIES = 65536; +export const HISTORY_MAX_ENTRIES = 1048576; +export const HISTORY_DEFAULT_OUTPUT_BYTES = 8192; +export const HISTORY_MAX_OUTPUT_BYTES = 1 * MIB; +/** Per-file head window for `session_meta`. The line itself runs to about 20 KiB. */ +const METADATA_HEAD_BYTES = 128 * KIB; +/** Read the head in steps, so a typical rollout costs one step past its session_meta line. */ +const METADATA_CHUNK_BYTES = 32 * KIB; +/** Codex writes `sessions/YYYY/MM/DD`; this leaves five levels of headroom. */ +const MAX_DIRECTORY_DEPTH = 8; + const ID = /^[0-9a-f]{8}(?:[0-9a-f-]{0,28})$/i; const FULL_ID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i; -const METADATA_BYTES = 128 * 1024; type Json = Record; + export interface LocalHistoryOptions { file?: string; sessionId?: string; rootSessionId?: string; sessionsRoot?: string; topic?: string; + /** Inclusive UTC date, `YYYY-MM-DD`. */ after?: string; + /** Inclusive UTC date, `YYYY-MM-DD`. */ before?: string; scanBytes?: number; + metadataBytes?: number; maxEntries?: number; } export interface HistoryRecord { @@ -28,16 +119,23 @@ export interface HistoryRecord { sessionId: string; timestamp: string; role: 'user' | 'assistant'; - phase: string; + /** Codex's own phase for assistant messages (`commentary`, `final_answer`); null when none was recorded. */ + phase: string | null; message: string; } interface Source { path: string; sessionId: string; parentId?: string; + forkedFromId?: string; rootId?: string; + /** `subagent_history_start_ordinal`: records below it are inherited. */ + historyStart?: number; timestamp: string; - fingerprint?: string; + /** `dev:ino` of the attributed file. */ + identity: string; + /** Size when attributed; a later read may only see it grow. */ + size: number; } export interface LocalHistoryResult { source: 'codex-jsonl'; @@ -45,25 +143,49 @@ export interface LocalHistoryResult { sessions: Array<{ sessionId: string; rootSessionId: string | null; parentSessionId?: string; path: string }>; records: HistoryRecord[]; scan: { + /** Transcript bytes read (`--scan-bytes`). */ bytesRead: number; + /** Head bytes read to attribute rollouts (`--metadata-bytes`). */ + metadataBytesRead: number; entries: number; malformedRecords: number; partialRecords: number; skippedSymlinks: number; unattributedFiles: number; + unreadableDirectories: number; + /** Inherited fork or sub-agent prefixes dropped (informational). */ + inheritedPrefixes: number; + /** Inherited prefixes that never reached their end marker. */ + incompletePrefixes: number; + /** Legacy prefixes whose end could not be checked against the parent's own file. */ + unverifiedPrefixes: number; }; } interface Budget { - remaining: number; + transcriptRemaining: number; + metadataRemaining: number; entriesRemaining: number; scan: LocalHistoryResult['scan']; } + +/** A budget ran out: always fatal, never counted. */ +class HistoryBudgetError extends Error {} +/** One store file could not be attributed: fatal for `--file`, counted for a store scan. */ +class UnattributableFileError extends Error {} + function object(value: unknown): Json { return value && typeof value === 'object' && !Array.isArray(value) ? (value as Json) : {}; } function string(value: unknown): string | undefined { return typeof value === 'string' ? value : undefined; } +function fullId(value: unknown): string | undefined { + const id = string(value); + return id && FULL_ID.test(id) ? id.toLowerCase() : undefined; +} +function fileIdentity(value: { dev: number; ino: number }): string { + return `${value.dev}:${value.ino}`; +} function validDate(value: string | undefined): boolean { return ( value === undefined || @@ -72,6 +194,14 @@ function validDate(value: string | undefined): boolean { new Date(value).toISOString().slice(0, 10) === value) ); } +/** The UTC calendar date of a native timestamp, or undefined when it does not parse. */ +function utcDate(timestamp: string): string | undefined { + const ms = Date.parse(timestamp); + return Number.isFinite(ms) ? new Date(ms).toISOString().slice(0, 10) : undefined; +} +function checkBudget(value: number, max: number, flag: string): void { + if (!Number.isSafeInteger(value) || value < 1 || value > max) throw new Error(`Invalid ${flag} (1-${max})`); +} function validate(options: LocalHistoryOptions): void { if ([options.file, options.sessionId, options.rootSessionId].filter((v) => v !== undefined).length !== 1) throw new Error('Select exactly one of --file, --session-id, or --root-session-id'); @@ -84,13 +214,11 @@ function validate(options: LocalHistoryOptions): void { !validDate(options.before) || (options.after && options.before && options.after > options.before) ) - throw new Error('Invalid date or date range (use YYYY-MM-DD)'); + throw new Error('Invalid date or date range (use YYYY-MM-DD, UTC)'); if (options.topic !== undefined && !options.topic.trim()) throw new Error('Invalid empty topic'); - for (const [value, max] of [ - [options.scanBytes ?? HISTORY_DEFAULT_SCAN_BYTES, HISTORY_MAX_SCAN_BYTES], - [options.maxEntries ?? 8192, 65536], - ]) - if (!Number.isSafeInteger(value) || value < 1 || value > max) throw new Error('Invalid scan budget'); + checkBudget(options.scanBytes ?? HISTORY_DEFAULT_SCAN_BYTES, HISTORY_MAX_SCAN_BYTES, '--scan-bytes'); + checkBudget(options.metadataBytes ?? HISTORY_DEFAULT_METADATA_BYTES, HISTORY_MAX_METADATA_BYTES, '--metadata-bytes'); + checkBudget(options.maxEntries ?? HISTORY_DEFAULT_MAX_ENTRIES, HISTORY_MAX_ENTRIES, '--max-entries'); } async function regularFile(path: string): Promise { // O_NOFOLLOW prevents a final-component link swap between lstat and open. fstat @@ -103,88 +231,148 @@ async function regularFile(path: string): Promise { } return handle; } -async function readChunk(handle: FileHandle, position: number, size: number, budget: Budget): Promise { - if (size > budget.remaining) - throw new Error('History scan byte budget exhausted; narrow the store or raise --scan-bytes'); +async function readAt(handle: FileHandle, position: number, size: number): Promise { const buffer = Buffer.alloc(size); - const { bytesRead } = await handle.read(buffer, 0, size, position); - budget.remaining -= bytesRead; - budget.scan.bytesRead += bytesRead; - return buffer.subarray(0, bytesRead); + let filled = 0; + while (filled < size) { + const { bytesRead } = await handle.read(buffer, filled, size - filled, position + filled); + if (!bytesRead) break; + filled += bytesRead; + } + return buffer.subarray(0, filled); +} +async function readMetadataChunk(handle: FileHandle, position: number, size: number, budget: Budget) { + if (size > budget.metadataRemaining) + throw new HistoryBudgetError('History metadata byte budget exhausted; raise --metadata-bytes'); + const chunk = await readAt(handle, position, size); + budget.metadataRemaining -= chunk.length; + budget.scan.metadataBytesRead += chunk.length; + return chunk; +} +async function readTranscript(handle: FileHandle, size: number, budget: Budget): Promise { + if (size > budget.transcriptRemaining) + throw new HistoryBudgetError('History scan byte budget exhausted; narrow the selection or raise --scan-bytes'); + const buffer = await readAt(handle, 0, size); + budget.transcriptRemaining -= buffer.length; + budget.scan.bytesRead += buffer.length; + return buffer; } -function metadata(value: unknown, path: string): Source | undefined { + +type Identity = Omit; +function metadata(value: unknown, path: string): Identity | undefined { const entry = object(value); if (entry.type !== 'session_meta') return undefined; const p = object(entry.payload); - const sessionId = string(p.id) ?? string(p.session_id); - if (!sessionId || !FULL_ID.test(sessionId)) return undefined; - const source = object(p.source); - const spawn = object(object(source.subagent).thread_spawn); - const parentId = string(p.parent_thread_id) ?? string(p.parent_session_id) ?? string(spawn.parent_thread_id); - const declaredRoot = string(p.root_session_id) ?? string(p.session_id); + const sessionId = fullId(p.id) ?? fullId(p.session_id); + if (!sessionId) return undefined; + const spawn = object(object(object(p.source).subagent).thread_spawn); + const forkedFromId = fullId(p.forked_from_id); + // A fork with no spawn parent (a `/fork` in the TUI) still descends from its source thread. + const parentId = + fullId(p.parent_thread_id) ?? fullId(p.parent_session_id) ?? fullId(spawn.parent_thread_id) ?? forkedFromId; + // Codex: "session_id is equal to the root thread's ID". + const declaredRoot = fullId(p.root_session_id) ?? fullId(p.session_id); + const start = p.subagent_history_start_ordinal; return { path, - sessionId: sessionId.toLowerCase(), - parentId: parentId && FULL_ID.test(parentId) ? parentId.toLowerCase() : undefined, - rootId: - declaredRoot && declaredRoot !== sessionId && FULL_ID.test(declaredRoot) ? declaredRoot.toLowerCase() : undefined, + sessionId, + parentId, + forkedFromId, + rootId: declaredRoot && declaredRoot !== sessionId ? declaredRoot : undefined, + historyStart: Number.isSafeInteger(start) && (start as number) >= 0 ? (start as number) : undefined, timestamp: string(p.timestamp) ?? string(entry.timestamp) ?? '', }; } +function sameIdentity(a: Identity, b: Identity): boolean { + return ( + a.sessionId === b.sessionId && + a.parentId === b.parentId && + a.forkedFromId === b.forkedFromId && + a.rootId === b.rootId && + a.historyStart === b.historyStart + ); +} + +/** + * The first `session_meta` within the file's head. Problems with the file + * itself throw {@link UnattributableFileError}; a budget throws + * {@link HistoryBudgetError}; a short file with no metadata yet returns undefined. + */ async function readMetadata(path: string, budget: Budget): Promise { - const handle = await regularFile(path); + let handle: FileHandle; + try { + handle = await regularFile(path); + } catch (error) { + throw new UnattributableFileError(error instanceof Error ? error.message : String(error)); + } try { const before = await handle.stat(); - const parts: Buffer[] = []; - let offset = 0; + const limit = Math.min(before.size, METADATA_HEAD_BYTES); + let buffer = Buffer.alloc(0); let parsed = 0; - while (offset < before.size && offset < METADATA_BYTES) { - const chunk = await readChunk( + while (buffer.length < limit) { + const chunk = await readMetadataChunk( handle, - offset, - Math.min(4096, before.size - offset, METADATA_BYTES - offset), + buffer.length, + Math.min(METADATA_CHUNK_BYTES, limit - buffer.length), budget ); if (!chunk.length) break; - parts.push(chunk); - offset += chunk.length; - const buffer = Buffer.concat(parts); + buffer = Buffer.concat([buffer, chunk]); let end = buffer.indexOf(10, parsed); while (end >= 0) { const line = buffer.subarray(parsed, end); - if (!isUtf8(line)) throw new Error('History metadata contains invalid UTF8'); - try { - const found = metadata(JSON.parse(line.toString('utf8')), path); - if (found) { - const after = await handle.stat(); - const fingerprint = (value: typeof before) => - `${value.dev}:${value.ino}:${value.size}:${value.mtimeMs}:${value.ctimeMs}`; - if (fingerprint(before) !== fingerprint(after)) - throw new Error('History source changed during metadata scan'); - return { ...found, fingerprint: fingerprint(before) }; - } - } catch (error) { - if (!(error instanceof SyntaxError)) throw error; - } parsed = end + 1; end = buffer.indexOf(10, parsed); + if (!isUtf8(line)) throw new UnattributableFileError('History metadata contains invalid UTF8'); + let value: unknown; + try { + value = JSON.parse(line.toString('utf8')); + } catch { + continue; + } + const found = metadata(value, path); + if (!found) continue; + // session_meta is written once; a growing file is a live rollout, not a change. + const after = await handle.stat(); + if (fileIdentity(after) !== fileIdentity(before) || after.size < before.size) + throw new UnattributableFileError('History source changed during metadata scan'); + return { ...found, identity: fileIdentity(before), size: before.size }; } } - if (offset < before.size) - throw new Error('History metadata scan budget exhausted; cannot prove family completeness'); + if (buffer.length < limit) throw new UnattributableFileError('History source changed during metadata scan'); + if (before.size > METADATA_HEAD_BYTES) + throw new UnattributableFileError('No session metadata within the first 128 KiB'); return undefined; } finally { await handle.close(); } } + async function findSources(root: string, budget: Budget): Promise { - if (!(await lstat(root)).isDirectory()) throw new Error('Sessions root must be a real directory, not a symlink'); + // The root may itself be a symlink, as the Past Sessions scanner allows + // (`codex-transcript.ts` reads it with readdir, which follows it); links + // BELOW the root are never followed by either reader. + let rootStat; + try { + rootStat = await stat(root); + } catch { + throw new Error(`Sessions root not found: ${root}`); + } + if (!rootStat.isDirectory()) throw new Error('Sessions root must be a directory'); const sources: Source[] = []; const visit = async (dir: string, depth: number): Promise => { - if (depth > 8) throw new Error('History directory scan depth budget exhausted'); - const entries = await opendir(dir); + if (depth > MAX_DIRECTORY_DEPTH) throw new HistoryBudgetError('History directory scan depth budget exhausted'); + let entries; + try { + entries = await opendir(dir); + } catch { + budget.scan.unreadableDirectories++; + return; + } for await (const entry of entries) { - if (--budget.entriesRemaining < 0) throw new Error('History directory scan entry budget exhausted'); + if (--budget.entriesRemaining < 0) + throw new HistoryBudgetError('History directory scan entry budget exhausted; raise --max-entries'); budget.scan.entries++; const path = join(dir, entry.name); if (entry.isSymbolicLink()) { @@ -193,10 +381,19 @@ async function findSources(root: string, budget: Budget): Promise { } if (entry.isDirectory()) { // Recheck immediately before descending; never intentionally follow links. - if (!(await lstat(path)).isDirectory()) throw new Error('History directory changed during scan'); + const now = await lstat(path).catch(() => undefined); + if (!now?.isDirectory()) { + budget.scan.unreadableDirectories++; + continue; + } await visit(path, depth + 1); } else if (entry.isFile() && entry.name.endsWith('.jsonl')) { - const source = await readMetadata(path, budget); + let source: Source | undefined; + try { + source = await readMetadata(path, budget); + } catch (error) { + if (!(error instanceof UnattributableFileError)) throw error; + } if (source) sources.push(source); else budget.scan.unattributedFiles++; } @@ -205,134 +402,167 @@ async function findSources(root: string, budget: Budget): Promise { await visit(root, 0); return sources; } -function contentText(value: unknown): string { - if (typeof value === 'string') return value; - if (!Array.isArray(value)) return ''; - return value - .map((block) => { - const item = object(block); - return typeof item.type === 'string' && ['text', 'input_text', 'output_text'].includes(item.type.toLowerCase()) - ? (string(item.text) ?? '') - : ''; - }) - .filter(Boolean) - .join('\n'); -} + interface Message { representation: 'event' | 'modern' | 'response'; role: 'user' | 'assistant'; message: string; - phase: string; + phase: string | null; timestamp: string; } function message(value: unknown): Message | undefined { const entry = object(value); const p = object(entry.payload); if (entry.isSidechain || p.isSidechain) return undefined; - let role: Message['role'] | undefined; - let representation: Message['representation'] = 'event'; - let text = ''; - let phase = string(p.phase) ?? 'progress'; - if (entry.type === 'event_msg') { - if (p.type === 'user_message' || p.type === 'agent_message') { - role = p.type === 'user_message' ? 'user' : 'assistant'; - text = contentText(p.message); - } else if (p.type === 'item_completed') { - representation = 'modern'; - const item = object(p.item); - if (item.type === 'UserMessage' || item.type === 'AgentMessage') { - role = item.type === 'UserMessage' ? 'user' : 'assistant'; - text = contentText(item.content); - phase = string(item.phase) ?? phase; - } - } - } else if ( - entry.type === 'response_item' && - (p.type === 'message' || p.type === undefined) && - (p.role === 'user' || p.role === 'assistant') - ) { - role = p.role; - representation = 'response'; - text = contentText(p.content); - // Codex context is not a human turn. Do not omit arbitrary markdown prompts. - if ( - role === 'user' && - /^(?:# AGENTS\.md instructions|||)/.test(text) - ) - return undefined; - } - if (!role || !text) return undefined; + const found = codexMessageFromLine(value); + if (!found) return undefined; + // Codex context is not a human turn. Real prompts also arrive as events, which are never filtered. + if (found.injectionProne && isCodexInjectedContext(found.text)) return undefined; return { - role, - message: text, - phase, - representation, + role: found.role, + message: found.text, + // Codex records a phase on assistant messages only; a user turn has none. + phase: found.role === 'assistant' ? (found.phase ?? null) : null, + representation: found.shape === 'item' ? 'modern' : found.shape, timestamp: string(entry.timestamp) ?? string(p.timestamp) ?? '', }; } -async function readMessages(source: Source, budget: Budget): Promise { +/** Native events and response_item commonly duplicate one accepted message. */ +function collapse(items: Message[]): Message[] { + const records: Message[] = []; + let representations = new Set(); + for (const raw of items) { + const item = { ...raw }; + const previous = records.at(-1); + if ( + previous?.role === item.role && + previous.message === item.message && + !representations.has(item.representation) + ) { + representations.add(item.representation); + if (!previous.timestamp) previous.timestamp = item.timestamp; + if (item.phase === 'final_answer' || previous.phase === null) previous.phase = item.phase ?? previous.phase; + continue; + } + records.push(item); + representations = new Set([item.representation]); + } + return records; +} +function isPrefixOf(prefix: Message[], whole: Message[]): boolean { + return ( + prefix.length <= whole.length && + prefix.every((item, i) => item.role === whole[i].role && item.message === whole[i].message) + ); +} + +interface ReadContext { + /** The ancestor's own messages, uncut, or undefined when its file is not in the store. */ + ancestorMessages(id: string): Promise; +} +type Piece = Message | { boundary: true }; +interface LegacyPrefix { + ancestorId: string; + /** Messages and `thread_settings_applied` boundaries after the inherited session_meta. */ + pieces: Piece[]; +} + +async function readMessages(source: Source, budget: Budget, context: ReadContext, raw = false): Promise { const handle = await regularFile(source.path); + let buffer: Buffer; try { - const before = await handle.stat(); - if (`${before.dev}:${before.ino}:${before.size}:${before.mtimeMs}:${before.ctimeMs}` !== source.fingerprint) + const now = await handle.stat(); + // A live rollout only grows. A different file or a shorter one is a rewrite. + if (fileIdentity(now) !== source.identity || now.size < source.size) throw new Error('History source changed after attribution; retry explicitly'); - const buffer = await readChunk(handle, 0, before.size, budget); - const after = await handle.stat(); - if ( - buffer.length !== before.size || - before.size !== after.size || - before.mtimeMs !== after.mtimeMs || - before.ctimeMs !== after.ctimeMs - ) - throw new Error('History source changed while reading; retry explicitly'); - if (!isUtf8(buffer)) throw new Error('History source contains invalid UTF8'); - const lines = buffer.toString('utf8').split('\n'); - const partial = lines.pop(); - if (partial) budget.scan.partialRecords++; - const records: Message[] = []; - let representations = new Set(); - for (const line of lines) { - if (!line.trim()) continue; - let parsed: unknown; - try { - parsed = JSON.parse(line); - } catch { - budget.scan.malformedRecords++; - continue; - } - if (object(parsed).type === 'session_meta') { - const declared = metadata(parsed, source.path); - if ( - !declared || - declared.sessionId !== source.sessionId || - declared.parentId !== source.parentId || - declared.rootId !== source.rootId - ) - throw new Error('History source contains conflicting native metadata; attribution refused'); - continue; - } - const item = message(parsed); - if (!item) continue; - const previous = records.at(-1); - // Native events and response_item commonly duplicate one accepted message. - if ( - previous?.role === item.role && - previous.message === item.message && - !representations.has(item.representation) - ) { - representations.add(item.representation); - if (!previous.timestamp) previous.timestamp = item.timestamp; - if (item.phase === 'final_answer') previous.phase = item.phase; - continue; - } - records.push(item); - representations = new Set([item.representation]); - } - return records; + buffer = await readTranscript(handle, now.size, budget); + if (buffer.length !== now.size) throw new Error('History source shrank while reading; retry explicitly'); } finally { await handle.close(); } + // Snapshot up to the last complete line: a running Codex may be mid-append. + const end = buffer.lastIndexOf(10) + 1; + const complete = buffer.subarray(0, end); + if (buffer.subarray(end).toString('latin1').trim()) budget.scan.partialRecords++; + if (!isUtf8(complete)) throw new Error('History source contains invalid UTF8'); + + const ancestors = new Set([source.parentId, source.forkedFromId, source.rootId].filter((id): id is string => !!id)); + const own: Message[] = []; + let legacy: LegacyPrefix | undefined; + let lastOrdinal = -1; + for (const line of complete.toString('utf8').split('\n')) { + if (!line.trim()) continue; + let parsed: unknown; + try { + parsed = JSON.parse(line); + } catch { + budget.scan.malformedRecords++; + continue; + } + const entry = object(parsed); + const ordinal = typeof entry.ordinal === 'number' ? entry.ordinal : undefined; + if (ordinal !== undefined) lastOrdinal = Math.max(lastOrdinal, ordinal); + if (source.historyStart !== undefined && ordinal === undefined) { + // Codex's projection skips a paginated record without an ordinal; so does this, visibly. + budget.scan.malformedRecords++; + continue; + } + const inherited = source.historyStart !== undefined && ordinal! < source.historyStart; + if (entry.type === 'session_meta') { + const declared = metadata(parsed, source.path); + if (declared && declared.sessionId === source.sessionId && sameIdentity(declared, source)) continue; + if (!declared || declared.sessionId === source.sessionId || !ancestors.has(declared.sessionId)) + throw new Error('History source contains conflicting native metadata; attribution refused'); + // An ancestor that was itself a fork carries its own ancestors' metadata in the copy. + for (const id of [declared.parentId, declared.forkedFromId, declared.rootId]) if (id) ancestors.add(id); + if (raw || inherited || legacy) continue; + budget.scan.inheritedPrefixes++; + legacy = { ancestorId: declared.sessionId, pieces: [] }; + continue; + } + if (inherited && !raw) continue; + if (legacy && entry.type === 'event_msg' && object(entry.payload).type === 'thread_settings_applied') { + legacy.pieces.push({ boundary: true }); + continue; + } + const item = message(parsed); + if (!item) continue; + if (legacy) legacy.pieces.push(item); + else own.push(item); + } + if (!raw && source.historyStart !== undefined && source.historyStart > 0) { + budget.scan.inheritedPrefixes++; + // Initialization died while copying the prefix: Codex refuses to resume such a file. + if (lastOrdinal < source.historyStart - 1) budget.scan.incompletePrefixes++; + } + if (legacy) own.push(...(await afterLegacyPrefix(legacy, budget, context))); + return collapse(own); } + +/** The child's own messages after a legacy inherited prefix. */ +async function afterLegacyPrefix(prefix: LegacyPrefix, budget: Budget, context: ReadContext): Promise { + const boundaries = prefix.pieces.flatMap((piece, i) => ('boundary' in piece ? [i] : [])); + if (!boundaries.length) { + budget.scan.incompletePrefixes++; + return []; + } + const messagesBefore = (index: number) => + collapse(prefix.pieces.slice(0, index).filter((piece): piece is Message => !('boundary' in piece))); + let cut = boundaries[0]; + if (boundaries.length > 1) { + // The last candidate whose preceding messages the parent's own file also holds. + const parent = await context.ancestorMessages(prefix.ancestorId); + const proven = parent ? boundaries.filter((candidate) => isPrefixOf(messagesBefore(candidate), parent)) : []; + if (proven.length) cut = proven.at(-1)!; + else { + // Unprovable: drop up to the last candidate, so parent text is never attributed to the child. + budget.scan.unverifiedPrefixes++; + cut = boundaries.at(-1)!; + } + } + return prefix.pieces.slice(cut + 1).filter((piece): piece is Message => !('boundary' in piece)); +} + function selectedTurns(messages: Message[], options: LocalHistoryOptions): Message[] { const turns: Message[][] = []; for (const item of messages) { @@ -345,9 +575,11 @@ function selectedTurns(messages: Message[], options: LocalHistoryOptions): Messa const dateMatches = (!options.after && !options.before) || turn.some((item) => { - const date = item.timestamp.slice(0, 10); + const date = utcDate(item.timestamp); return ( - validDate(date) && (!options.after || date >= options.after) && (!options.before || date <= options.before) + date !== undefined && + (!options.after || date >= options.after) && + (!options.before || date <= options.before) ); }); return dateMatches && (!needle || turn.some((item) => item.message.toLocaleLowerCase().includes(needle))); @@ -375,30 +607,46 @@ export async function readLocalHistory(options: LocalHistoryOptions): Promise source.sessionId.toLowerCase().startsWith(wanted)); - if (!matches.length) throw new Error('Native session missing from history store'); + const matches = all.filter((source) => source.sessionId.startsWith(wanted)); + if (!matches.length) + throw new Error( + scan.unattributedFiles + ? `Native session missing from history store (${scan.unattributedFiles} file(s) could not be attributed)` + : 'Native session missing from history store' + ); if (matches.length !== 1) throw new Error('Native session id is ambiguous'); const target = matches[0]; attributionSources = all; @@ -428,10 +676,23 @@ export async function readLocalHistory(options: LocalHistoryOptions): Promise a.timestamp.localeCompare(b.timestamp) || a.sessionId.localeCompare(b.sessionId)); const roots = new Map(sources.map((source) => [source.sessionId, nativeRoot(source, attributionSources)])); + const ancestorCache = new Map>(); + const context: ReadContext = { + // A raw read never opens a legacy prefix, so this never recurses. + ancestorMessages(id) { + const cached = ancestorCache.get(id); + if (cached) return cached; + const candidates = attributionSources.filter((source) => source.sessionId === id); + if (candidates.length !== 1) return Promise.resolve(undefined); + const read = readMessages(candidates[0], budget, context, true); + ancestorCache.set(id, read); + return read; + }, + }; const records: HistoryRecord[] = []; for (const source of sources) { records.push( - ...selectedTurns(await readMessages(source, budget), options).map((item) => ({ + ...selectedTurns(await readMessages(source, budget, context), options).map((item) => ({ rootSessionId: roots.get(source.sessionId) ?? null, sessionId: source.sessionId, role: item.role, @@ -449,6 +710,9 @@ export async function readLocalHistory(options: LocalHistoryOptions): Promise root !== null), sessions: sources.map((source) => ({ sessionId: source.sessionId, @@ -464,18 +728,18 @@ export async function readLocalHistory(options: LocalHistoryOptions): Promise HISTORY_MAX_OUTPUT_BYTES) - throw new Error('Invalid output byte budget'); + throw new Error(`Invalid --max-bytes (1-${HISTORY_MAX_OUTPUT_BYTES})`); const body = format === 'json' ? JSON.stringify(result) : [ - `source=${result.source} complete=${result.complete} scannedBytes=${result.scan.bytesRead}`, + `source=${result.source} complete=${result.complete} scannedBytes=${result.scan.bytesRead} metadataBytes=${result.scan.metadataBytesRead}`, ...result.records.map( (row) => - `[root=${row.rootSessionId} session=${row.sessionId} ${row.timestamp} ${row.role} ${row.phase}]\n${row.message}` + `[root=${row.rootSessionId} session=${row.sessionId} ${row.timestamp} ${row.role}${row.phase ? ` ${row.phase}` : ''}]\n${row.message}` ), ].join('\n\n'); if (Buffer.byteLength(body) + 1 > maxBytes) diff --git a/test/fixtures/codex-rollout.ts b/test/fixtures/codex-rollout.ts new file mode 100644 index 000000000..9f90f5c55 --- /dev/null +++ b/test/fixtures/codex-rollout.ts @@ -0,0 +1,125 @@ +/** + * @fileoverview Codex rollout lines shaped like real ones, for the offline + * history tests. The structure (keys, their order, the event types around a + * fork boundary) was taken from real rollouts written by codex 0.120 to 0.163 + * and from codex-rs `SessionMeta` (`protocol/src/protocol.rs`); no + * conversation content was copied. + * + * A forked or spawned thread's file, as `record_initial_history` writes it for + * `ForkPersistence::Copied`: + * + * own session_meta, parent session_meta, copied parent records, + * thread_settings_applied, the child's own records + * + * In paginated history every line also carries an `ordinal`, and the child's + * own `session_meta` declares `subagent_history_start_ordinal`. + */ +import { writeFile } from 'node:fs/promises'; +import { join } from 'node:path'; + +/** Codex's base instructions make every real session_meta line about 20 KiB. */ +export const BASE_INSTRUCTIONS_BYTES = 19 * 1024; + +export interface MetaOptions { + id: string; + /** Spawn parent: sets parent_thread_id, forked_from_id and source.subagent.thread_spawn. */ + parent?: string; + /** Shared root (`session_id`); defaults to `parent ?? id`, as Codex writes it. */ + root?: string; + mode?: 'legacy' | 'paginated'; + historyStart?: number; + timestamp?: string; + baseInstructionsBytes?: number; +} + +export function realMeta(options: MetaOptions): Record { + const { id, parent, mode = 'legacy', timestamp = '2026-10-24T01:00:00.000Z' } = options; + const payload: Record = { + session_id: options.root ?? parent ?? id, + id, + ...(parent ? { forked_from_id: parent, parent_thread_id: parent } : {}), + timestamp, + cwd: '/work/repo', + originator: 'codex_exec', + cli_version: '0.163.0', + source: parent + ? { + subagent: { + thread_spawn: { + parent_thread_id: parent, + depth: 1, + agent_path: null, + agent_nickname: 'Kepler', + agent_role: null, + }, + }, + } + : 'exec', + thread_source: parent ? 'subagent' : 'user', + ...(parent ? { agent_nickname: 'Kepler' } : {}), + model_provider: 'openai', + base_instructions: { text: 'b'.repeat(options.baseInstructionsBytes ?? BASE_INSTRUCTIONS_BYTES) }, + history_mode: mode, + ...(options.historyStart !== undefined ? { subagent_history_start_ordinal: options.historyStart } : {}), + ...(parent ? { multi_agent_version: 'v1' } : {}), + context_window: { window_id: `${id.slice(0, 8)}-window` }, + }; + return { timestamp, type: 'session_meta', payload }; +} + +export const taskStarted = () => ({ + timestamp: '2026-10-24T01:00:00.000Z', + type: 'event_msg', + payload: { type: 'task_started' }, +}); +export const settingsApplied = () => ({ + timestamp: '2026-10-24T01:00:00.000Z', + type: 'event_msg', + payload: { type: 'thread_settings_applied', thread_settings: { model: 'gpt-5.5', cwd: '/work/repo' } }, +}); +export const developer = () => ({ + timestamp: '2026-10-24T01:00:00.000Z', + type: 'response_item', + payload: { type: 'message', role: 'developer', content: [{ type: 'input_text', text: 'policy' }] }, +}); +export const injected = (tag = 'environment_context') => ({ + timestamp: '2026-10-24T01:00:00.000Z', + type: 'response_item', + payload: { type: 'message', role: 'user', content: [{ type: 'input_text', text: `<${tag}>\n ctx\n` }] }, +}); +/** A real user turn: the response_item Codex sent plus its user_message event twin. */ +export const userTurn = (text: string, timestamp = '2026-10-24T01:00:00.000Z') => [ + { + timestamp, + type: 'response_item', + payload: { type: 'message', role: 'user', content: [{ type: 'input_text', text }] }, + }, + { timestamp, type: 'event_msg', payload: { type: 'user_message', message: text, images: [] } }, +]; +/** An assistant message: the event and the response_item, both carrying Codex's phase. */ +export const assistantTurn = (text: string, phase: 'commentary' | 'final_answer' = 'final_answer') => [ + { + timestamp: '2026-10-24T01:00:01.000Z', + type: 'event_msg', + payload: { type: 'agent_message', message: text, phase }, + }, + { + timestamp: '2026-10-24T01:00:01.000Z', + type: 'response_item', + payload: { type: 'message', role: 'assistant', content: [{ type: 'output_text', text }], phase }, + }, +]; + +/** Number every line, as paginated history does. */ +export function withOrdinals(lines: Array>): Array> { + return lines.map((line, ordinal) => { + const { timestamp, type, payload, ...rest } = line; + return { timestamp, ordinal, type, payload, ...rest }; + }); +} + +export async function writeRollout(dir: string, name: string, lines: unknown[]): Promise { + const path = join(dir, name); + await writeFile(path, lines.map((line) => JSON.stringify(line)).join('\n') + '\n'); + return path; +} diff --git a/test/local-history-live.test.ts b/test/local-history-live.test.ts new file mode 100644 index 000000000..aa0348a6c --- /dev/null +++ b/test/local-history-live.test.ts @@ -0,0 +1,129 @@ +/** + * @fileoverview The offline history reader against rollouts that change while + * it reads them: a running Codex appending to its own rollout (or a child's), + * an unrelated rollout rewritten mid-scan, and a selected rollout replaced + * outright. `node:fs/promises` is wrapped so a test can act at an exact point + * of the read (after the open, after a read call) without timing races. + */ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { appendFile, mkdtemp, rename, rm, truncate, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; + +type Hook = (path: string, openCount: number, handle: import('node:fs/promises').FileHandle) => Promise | void; +const hooks: { onOpen?: Hook } = {}; +const opens = new Map(); + +vi.mock('node:fs/promises', async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + open: async (...args: Parameters) => { + const handle = await actual.open(...args); + const path = String(args[0]); + const count = (opens.get(path) ?? 0) + 1; + opens.set(path, count); + await hooks.onOpen?.(path, count, handle); + return handle; + }, + }; +}); + +const history = await import('../src/local-history.js'); +const { assistantTurn, realMeta, userTurn, writeRollout } = await import('./fixtures/codex-rollout.js'); + +const ROOT = 'aaaaaaaa-1111-4111-8111-111111111111'; +const CHILD = 'bbbbbbbb-1111-4111-8111-111111111111'; +let dir: string; +const line = (value: unknown) => `${JSON.stringify(value)}\n`; + +beforeEach(async () => { + dir = await mkdtemp(join(tmpdir(), 'local-history-live-')); + opens.clear(); + hooks.onOpen = undefined; +}); +afterEach(async () => { + hooks.onOpen = undefined; + await rm(dir, { recursive: true, force: true }); +}); + +describe('rollouts that change while they are read', () => { + it('reads a snapshot of a rollout a running Codex appends to after attribution', async () => { + const path = await writeRollout(dir, 'rollout-root.jsonl', [realMeta({ id: ROOT }), ...userTurn('question')]); + hooks.onOpen = async (opened, count) => { + // The second open is the transcript read; Codex writes a whole record and starts the next. + if (opened === path && count === 2) { + await appendFile(path, assistantTurn('answer').map(line).join('')); + await appendFile(path, '{"timestamp":"2026-10-24T01:00:02.000Z","type":"event_'); + } + }; + const result = await history.readLocalHistory({ sessionId: ROOT, sessionsRoot: dir }); + expect(result.records.map((r) => r.message)).toEqual(['question', 'answer']); + // The record cut mid-write is reported, not parsed and not fatal. + expect(result.scan.partialRecords).toBe(1); + expect(result.scan.malformedRecords).toBe(0); + expect(result.complete).toBe(false); + }); + + it('reads a family whose child is still running', async () => { + await writeRollout(dir, 'rollout-root.jsonl', [realMeta({ id: ROOT }), ...userTurn('root question')]); + const child = await writeRollout(dir, 'rollout-child.jsonl', [ + realMeta({ id: CHILD, parent: ROOT, mode: 'legacy' }), + ...userTurn('child task'), + ]); + hooks.onOpen = async (opened, count) => { + // Grows during the metadata scan and again before its transcript read. + if (opened === child) + await appendFile(child, assistantTurn(`child progress ${count}`, 'commentary').map(line).join('')); + }; + const result = await history.readLocalHistory({ rootSessionId: ROOT, sessionsRoot: dir }); + expect(result.records.map((r) => r.message)).toEqual([ + 'root question', + 'child task', + 'child progress 1', + 'child progress 2', + ]); + expect(result.complete).toBe(true); + }); + + it('counts an unrelated rollout that shrinks during its head read as unattributed', async () => { + await writeRollout(dir, 'rollout-root.jsonl', [realMeta({ id: ROOT }), ...userTurn('question')]); + const other = await writeRollout(dir, 'rollout-other.jsonl', [realMeta({ id: CHILD }), ...userTurn('other')]); + hooks.onOpen = async (opened, count, handle) => { + if (opened !== other || count !== 1) return; + const read = handle.read.bind(handle) as (...args: unknown[]) => Promise; + (handle as unknown as { read: (...args: unknown[]) => Promise }).read = async (...args) => { + const result = await read(...args); + await truncate(other, 10); + return result; + }; + }; + const result = await history.readLocalHistory({ sessionId: ROOT, sessionsRoot: dir }); + expect(result.records.map((r) => r.message)).toEqual(['question']); + expect(result.scan.unattributedFiles).toBe(1); + expect(result.complete).toBe(false); + }); + + it('still refuses a selected rollout replaced by another file after attribution', async () => { + const path = await writeRollout(dir, 'rollout-root.jsonl', [realMeta({ id: ROOT }), ...userTurn('question')]); + hooks.onOpen = async (opened, count) => { + if (opened !== path || count !== 1) return; + const replacement = join(dir, 'replacement.tmp'); + await writeFile(replacement, [realMeta({ id: ROOT }), ...userTurn('rewritten')].map(line).join('')); + await rename(replacement, path); + }; + await expect(history.readLocalHistory({ sessionId: ROOT, sessionsRoot: dir })).rejects.toThrow( + /changed after attribution/ + ); + }); + + it('still refuses a selected rollout truncated after attribution', async () => { + const path = await writeRollout(dir, 'rollout-root.jsonl', [realMeta({ id: ROOT }), ...userTurn('question')]); + hooks.onOpen = async (opened, count) => { + if (opened === path && count === 2) await truncate(path, 100); + }; + await expect(history.readLocalHistory({ sessionId: ROOT, sessionsRoot: dir })).rejects.toThrow( + /changed after attribution/ + ); + }); +}); diff --git a/test/local-history.test.ts b/test/local-history.test.ts index 7ffa4c4bb..0a7cf6dc3 100644 --- a/test/local-history.test.ts +++ b/test/local-history.test.ts @@ -1,10 +1,21 @@ import { afterEach, beforeEach, describe, expect, it } from 'vitest'; -import { mkdtemp, mkdir, rm, symlink, writeFile } from 'node:fs/promises'; +import { chmod, mkdtemp, mkdir, rm, symlink, writeFile } from 'node:fs/promises'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; import * as history from '../src/local-history.js'; import { Command } from 'commander'; import { registerHistoryCommands } from '../src/cli-history.js'; +import { + assistantTurn, + developer, + injected, + realMeta, + settingsApplied, + taskStarted, + userTurn, + withOrdinals, + writeRollout, +} from './fixtures/codex-rollout.js'; const ROOT = 'aaaaaaaa-1111-4111-8111-111111111111'; const CHILD = 'bbbbbbbb-1111-4111-8111-111111111111'; @@ -155,9 +166,19 @@ describe('offline native history', () => { expect(result.complete).toBe(false); expect(result.scan.unattributedFiles).toBe(1); }); - it('refuses conflicting session metadata instead of attributing another conversation answer', async () => { + it('refuses a later session_meta whose id is not a declared ancestor', async () => { + // ROOT declares no parent, fork source or shared root, so CHILD can only be another conversation. const file = await fixture(ROOT, [row('user', 'root question'), meta(CHILD), row('assistant', 'child answer')]); await expect(history.readLocalHistory({ file })).rejects.toThrow(/conflicting.*metadata/); + // A fork child is refused too when the inherited metadata names a thread it does not descend from. + const forked = await writeRollout(dir, 'unrelated-fork.jsonl', [ + realMeta({ id: CHILD, parent: ROOT, mode: 'legacy' }), + realMeta({ id: GRANDCHILD, mode: 'legacy' }), + row('assistant', 'not ours'), + settingsApplied(), + row('assistant', 'child answer'), + ]); + await expect(history.readLocalHistory({ file: forked })).rejects.toThrow(/conflicting.*metadata/); }); it('accepts repeated metadata only when native identity and genealogy agree', async () => { const file = await fixture(ROOT, [row('user', 'question'), meta(ROOT), row('assistant', 'answer')]); @@ -244,3 +265,450 @@ describe('offline native history', () => { expect(program.commands.find((c) => c.name() === 'history')?.commands.map((c) => c.name())).toEqual(['read']); }); }); + +const messages = (result: history.LocalHistoryResult) => + result.records.map((r) => `${r.sessionId.slice(0, 1)}:${r.message}`); + +/** A parent R, and a legacy child C whose file copies R's history before its own, as Codex writes it. */ +async function legacyFamily(root: string, parentExtra: unknown[] = [], copiedExtra: unknown[] = []) { + await mkdir(root, { recursive: true }); + const parentHistory = [ + taskStarted(), + developer(), + injected(), + ...userTurn('parent question'), + ...assistantTurn('parent commentary', 'commentary'), + ...assistantTurn('parent answer'), + ]; + await writeRollout(root, `rollout-${ROOT}.jsonl`, [ + realMeta({ id: ROOT }), + ...parentHistory, + ...parentExtra, + ...userTurn('parent later question'), + ...assistantTurn('parent later answer'), + ]); + return writeRollout(root, `rollout-${CHILD}.jsonl`, [ + realMeta({ id: CHILD, parent: ROOT }), + realMeta({ id: ROOT }), + ...parentHistory, + ...copiedExtra, + settingsApplied(), + taskStarted(), + ...userTurn('child task'), + ...assistantTurn('child commentary', 'commentary'), + ...assistantTurn('child answer'), + ]); +} + +describe('forked and spawned threads', () => { + it('drops the parent history a legacy fork copies, so the child reads as itself', async () => { + const childFile = await legacyFamily(dir); + const child = await history.readLocalHistory({ sessionId: CHILD, sessionsRoot: dir }); + expect(messages(child)).toEqual(['b:child task', 'b:child commentary', 'b:child answer']); + expect(child.complete).toBe(true); + expect(child.scan.inheritedPrefixes).toBe(1); + expect(child.sessions[0]).toMatchObject({ sessionId: CHILD, parentSessionId: ROOT, rootSessionId: ROOT }); + // The whole family: every message once, the copy not repeated under the child. + const family = await history.readLocalHistory({ rootSessionId: ROOT, sessionsRoot: dir }); + expect(messages(family)).toEqual([ + 'a:parent question', + 'a:parent commentary', + 'a:parent answer', + 'a:parent later question', + 'a:parent later answer', + 'b:child task', + 'b:child commentary', + 'b:child answer', + ]); + expect(family.complete).toBe(true); + // An explicit file needs no store: the copy ends at the one thread_settings_applied. + const file = await history.readLocalHistory({ file: childFile }); + expect(messages(file)).toEqual(['b:child task', 'b:child commentary', 'b:child answer']); + expect(file.complete).toBe(true); + }); + + it('finds the end of a legacy copy that carries the parent own thread_settings_applied events', async () => { + // The parent compacted (or checkpointed its first turn) before the fork, so the copy holds that event too. + const parentSettings = [ + settingsApplied(), + ...userTurn('parent second question'), + ...assistantTurn('parent second answer'), + ]; + const childFile = await legacyFamily(dir, parentSettings, parentSettings); + const child = await history.readLocalHistory({ sessionId: CHILD, sessionsRoot: dir }); + expect(messages(child)).toEqual(['b:child task', 'b:child commentary', 'b:child answer']); + expect(child.complete).toBe(true); + // Without the parent's file the boundary cannot be proven: nothing of the parent is attributed to the child. + const alone = await history.readLocalHistory({ file: childFile }); + expect(messages(alone)).toEqual(['b:child task', 'b:child commentary', 'b:child answer']); + expect(alone.scan.unverifiedPrefixes).toBe(1); + expect(alone.complete).toBe(false); + }); + + it('keeps the child own later thread_settings_applied events on its side of the cut', async () => { + await legacyFamily(dir); + const path = join(dir, `rollout-${CHILD}.jsonl`); + const { appendFile } = await import('node:fs/promises'); + await appendFile( + path, + [settingsApplied(), ...userTurn('child second task'), ...assistantTurn('child second answer')] + .map((line) => JSON.stringify(line)) + .join('\n') + '\n' + ); + const child = await history.readLocalHistory({ sessionId: CHILD, sessionsRoot: dir }); + expect(messages(child)).toEqual([ + 'b:child task', + 'b:child commentary', + 'b:child answer', + 'b:child second task', + 'b:child second answer', + ]); + expect(child.complete).toBe(true); + }); + + it('accepts the copied metadata of an ancestor that was itself a fork', async () => { + const file = await writeRollout(dir, 'grandchild.jsonl', [ + realMeta({ id: GRANDCHILD, parent: CHILD, root: ROOT }), + realMeta({ id: CHILD, parent: ROOT }), + realMeta({ id: ROOT }), + ...userTurn('root question'), + settingsApplied(), + ...userTurn('child question'), + settingsApplied(), + ...userTurn('grandchild task'), + ...assistantTurn('grandchild answer'), + ]); + const result = await history.readLocalHistory({ file }); + expect(messages(result)).toEqual(['c:grandchild task', 'c:grandchild answer']); + expect(result.records[0].rootSessionId).toBe(ROOT); + }); + + it('marks a legacy copy that never reached its end marker incomplete and keeps none of it', async () => { + const file = await writeRollout(dir, 'cut.jsonl', [ + realMeta({ id: CHILD, parent: ROOT }), + realMeta({ id: ROOT }), + ...userTurn('parent question'), + ]); + await expect(history.readLocalHistory({ file })).rejects.toThrow(/evidence/); + await writeRollout(dir, 'cut.jsonl', [ + realMeta({ id: CHILD, parent: ROOT }), + ...userTurn('child before copy'), + realMeta({ id: ROOT }), + ...userTurn('parent question'), + ]); + const result = await history.readLocalHistory({ file }); + expect(messages(result)).toEqual(['b:child before copy']); + expect(result.scan.incompletePrefixes).toBe(1); + expect(result.complete).toBe(false); + }); + + it('drops the inherited records of a paginated sub-agent by ordinal, as Codex projects them', async () => { + const inherited = [ + realMeta({ id: CHILD, parent: ROOT, mode: 'paginated', historyStart: 7 }), + realMeta({ id: ROOT, mode: 'paginated' }), + taskStarted(), + injected(), + ...userTurn('parent question'), + ...assistantTurn('parent answer').slice(1), + ]; + expect(inherited).toHaveLength(7); + const lines = withOrdinals([ + ...inherited, + settingsApplied(), + taskStarted(), + ...assistantTurn('child commentary', 'commentary'), + ...assistantTurn('child answer'), + ]); + await writeRollout( + dir, + `rollout-${ROOT}.jsonl`, + withOrdinals([realMeta({ id: ROOT, mode: 'paginated' }), ...userTurn('parent question')]) + ); + await writeRollout(dir, `rollout-${CHILD}.jsonl`, lines); + const child = await history.readLocalHistory({ sessionId: CHILD, sessionsRoot: dir }); + expect(messages(child)).toEqual(['b:child commentary', 'b:child answer']); + expect(child.records.map((r) => r.phase)).toEqual(['commentary', 'final_answer']); + expect(child.complete).toBe(true); + const family = await history.readLocalHistory({ rootSessionId: ROOT, sessionsRoot: dir }); + expect(messages(family)).toEqual(['a:parent question', 'b:child commentary', 'b:child answer']); + }); + + it('reports a paginated copy that ends before its declared start ordinal', async () => { + const file = await writeRollout( + dir, + 'paginated-cut.jsonl', + withOrdinals([ + realMeta({ id: CHILD, parent: ROOT, mode: 'paginated', historyStart: 9 }), + realMeta({ id: ROOT, mode: 'paginated' }), + ...userTurn('parent question'), + ...assistantTurn('parent answer'), + ]) + ); + await expect(history.readLocalHistory({ file })).rejects.toThrow(/evidence/); + // In a family read the parent still answers, and the cut child makes the result incomplete. + await writeRollout( + dir, + `rollout-${ROOT}.jsonl`, + withOrdinals([realMeta({ id: ROOT, mode: 'paginated' }), ...userTurn('parent question')]) + ); + const family = await history.readLocalHistory({ rootSessionId: ROOT, sessionsRoot: dir }); + expect(messages(family)).toEqual(['a:parent question']); + expect(family.scan.incompletePrefixes).toBe(1); + expect(family.complete).toBe(false); + await rm(join(dir, `rollout-${ROOT}.jsonl`)); + // A paginated record without an ordinal is skipped, visibly, as Codex's projection skips it. + const lines = withOrdinals([ + realMeta({ id: CHILD, parent: ROOT, mode: 'paginated', historyStart: 2 }), + realMeta({ id: ROOT, mode: 'paginated' }), + settingsApplied(), + ...assistantTurn('child answer'), + ]); + await writeRollout(dir, 'paginated-cut.jsonl', [...lines, assistantTurn('stray')[0]]); + const result = await history.readLocalHistory({ file }); + expect(messages(result)).toEqual(['b:child answer']); + expect(result.scan.malformedRecords).toBe(1); + expect(result.complete).toBe(false); + }); +}); + +describe('large and untidy stores', () => { + it('finds one id among thousands of rollouts with real-sized session_meta at the default budgets', async () => { + const count = 3500; + const base = realMeta({ id: ROOT }); + const pad = JSON.stringify(base).length; + expect(pad).toBeGreaterThan(19 * 1024); + const writes: Array> = []; + for (let i = 0; i < count; i++) { + const day = join(dir, '2026', String(1 + (i % 12)).padStart(2, '0'), String(1 + (i % 28)).padStart(2, '0')); + const id = `${i.toString(16).padStart(8, '0')}-2222-4222-8222-222222222222`; + writes.push( + mkdir(day, { recursive: true }).then(() => + writeFile( + join(day, `rollout-${id}.jsonl`), + [realMeta({ id }), ...userTurn(`question ${i}`), ...assistantTurn(`answer ${i}`)] + .map((line) => JSON.stringify(line)) + .join('\n') + '\n' + ) + ) + ); + } + await Promise.all(writes); + const target = `${(count - 1).toString(16).padStart(8, '0')}-2222-4222-8222-222222222222`; + const result = await history.readLocalHistory({ sessionId: target, sessionsRoot: dir }); + expect(messages(result)).toEqual([`0:question ${count - 1}`, `0:answer ${count - 1}`]); + expect(result.complete).toBe(true); + // Metadata is charged to its own budget; the transcript budget only paid for the one rollout. + expect(result.scan.metadataBytesRead).toBeGreaterThan(history.HISTORY_MAX_SCAN_BYTES / 4); + expect(result.scan.bytesRead).toBeLessThan(64 * 1024); + // The store-wide caps fail closed and are raisable. + await expect( + history.readLocalHistory({ sessionId: target, sessionsRoot: dir, metadataBytes: 1024 * 1024 }) + ).rejects.toThrow(/metadata.*--metadata-bytes/); + await expect(history.readLocalHistory({ sessionId: target, sessionsRoot: dir, maxEntries: 100 })).rejects.toThrow( + /entry budget.*--max-entries/ + ); + }, 60_000); + + it('reads only the head of each rollout to attribute it, not its whole window', async () => { + await writeRollout(dir, 'rollout-big.jsonl', [ + realMeta({ id: ROOT }), + ...Array.from({ length: 400 }, (_, i) => assistantTurn(`${i} ${'x'.repeat(2048)}`)).flat(), + ]); + const result = await history.readLocalHistory({ sessionId: ROOT, sessionsRoot: dir, scanBytes: 8 * 1024 * 1024 }); + expect(result.records).toHaveLength(400); + // One 32 KiB step holds the ~20 KiB session_meta line; the 128 KiB window is only the ceiling. + expect(result.scan.metadataBytesRead).toBe(32 * 1024); + }); + + it.each([ + ['invalid UTF8 in its head', Buffer.from([0x7b, 0xff, 0xfe, 0x7d, 0x0a])], + [ + 'no session_meta within 128 KiB', + Buffer.from(`${JSON.stringify({ type: 'event_msg', payload: {} })}\n`.repeat(6000)), + ], + ])('counts one unrelated file with %s as unattributed instead of failing the lookup', async (_label, bytes) => { + await fixture(ROOT, [row('user', 'question'), row('assistant', 'answer')]); + await writeFile(join(dir, 'rollout-broken.jsonl'), bytes); + const result = await history.readLocalHistory({ sessionId: ROOT, sessionsRoot: dir }); + expect(result.records.map((r) => r.message)).toEqual(['question', 'answer']); + expect(result.scan.unattributedFiles).toBe(1); + expect(result.complete).toBe(false); + // The same file named explicitly is still an error. + await expect(history.readLocalHistory({ file: join(dir, 'rollout-broken.jsonl') })).rejects.toThrow(/UTF8|128 KiB/); + }); + + it('counts an unreadable directory instead of failing the lookup', async () => { + await fixture(ROOT, [row('assistant', 'answer')]); + const locked = join(dir, 'locked'); + await mkdir(locked); + await chmod(locked, 0); + try { + const result = await history.readLocalHistory({ sessionId: ROOT, sessionsRoot: dir }); + expect(result.records[0].message).toBe('answer'); + expect(result.scan.unreadableDirectories).toBe(process.getuid?.() === 0 ? 0 : 1); + } finally { + await chmod(locked, 0o700); + } + }); + + it('follows a sessions root that is itself a symlink, and still skips links below it', async () => { + const real = join(dir, 'real-sessions'); + await mkdir(real); + await writeRollout(real, 'rollout-root.jsonl', [ + realMeta({ id: ROOT }), + ...userTurn('question'), + ...assistantTurn('answer'), + ]); + const linkedRoot = join(dir, 'sessions-link'); + await symlink(real, linkedRoot); + const result = await history.readLocalHistory({ sessionId: ROOT, sessionsRoot: linkedRoot }); + expect(result.records.map((r) => r.message)).toEqual(['question', 'answer']); + expect(result.complete).toBe(true); + await symlink(join(real, 'rollout-root.jsonl'), join(real, 'rollout-link.jsonl')); + const withLink = await history.readLocalHistory({ sessionId: ROOT, sessionsRoot: linkedRoot }); + expect(withLink.scan.skippedSymlinks).toBe(1); + await expect( + history.readLocalHistory({ sessionId: ROOT, sessionsRoot: join(real, 'rollout-root.jsonl') }) + ).rejects.toThrow(/must be a directory/); + }); +}); + +describe('records, filters and output', () => { + it('gives user records no phase and keeps the phase Codex recorded on assistant messages', async () => { + const file = await writeRollout(dir, 'phases.jsonl', [ + realMeta({ id: ROOT }), + ...userTurn('question'), + ...assistantTurn('working', 'commentary'), + ...assistantTurn('done'), + row('assistant', 'legacy answer without a phase'), + ]); + const result = await history.readLocalHistory({ file }); + expect(result.records.map((r) => [r.role, r.phase])).toEqual([ + ['user', null], + ['assistant', 'commentary'], + ['assistant', 'final_answer'], + ['assistant', null], + ]); + }); + + it('drops every injected-context wrapper but never a typed prompt that starts with #', async () => { + const file = await writeRollout(dir, 'injected.jsonl', [ + realMeta({ id: ROOT }), + injected('environment_context'), + injected('recommended_plugins'), + injected('subagent_notification'), + { + type: 'response_item', + payload: { + type: 'message', + role: 'user', + content: [{ type: 'input_text', text: '# AGENTS.md instructions for /x' }], + }, + }, + ...userTurn('# a heading the user typed'), + ...assistantTurn('answer'), + ]); + const result = await history.readLocalHistory({ file }); + expect(result.records.map((r) => r.message)).toEqual(['# a heading the user typed', 'answer']); + }); + + it('compares date bounds as UTC calendar dates', async () => { + const file = await writeRollout(dir, 'dates.jsonl', [ + realMeta({ id: ROOT }), + // 23:30 at UTC-2 is 01:30 UTC the next day. + ...userTurn('late evening west of UTC', '2026-10-24T23:30:00.000-02:00'), + ...userTurn('plain UTC', '2026-10-24T12:00:00.000Z'), + ]); + const after = await history.readLocalHistory({ file, after: '2026-10-25' }); + expect(after.records.map((r) => r.message)).toEqual(['late evening west of UTC']); + const on = await history.readLocalHistory({ file, before: '2026-10-24' }); + expect(on.records.map((r) => r.message)).toEqual(['plain UTC']); + }); + + it('reads the default store at $CODEX_HOME/sessions, else ~/.codex/sessions', async () => { + const saved = { CODEX_HOME: process.env.CODEX_HOME, HOME: process.env.HOME }; + try { + process.env.CODEX_HOME = join(dir, 'codex-home'); + await mkdir(join(dir, 'codex-home', 'sessions', '2026', '10', '24'), { recursive: true }); + await writeRollout(join(dir, 'codex-home', 'sessions', '2026', '10', '24'), 'rollout-a.jsonl', [ + realMeta({ id: ROOT }), + ...userTurn('from CODEX_HOME'), + ]); + expect((await history.readLocalHistory({ sessionId: ROOT })).records[0].message).toBe('from CODEX_HOME'); + delete process.env.CODEX_HOME; + process.env.HOME = join(dir, 'home'); + await mkdir(join(dir, 'home', '.codex', 'sessions'), { recursive: true }); + await writeRollout(join(dir, 'home', '.codex', 'sessions'), 'rollout-b.jsonl', [ + realMeta({ id: ROOT }), + ...userTurn('from ~/.codex'), + ]); + const result = await history.readLocalHistory({ sessionId: ROOT }); + expect(result.records[0].message).toBe('from ~/.codex'); + expect(result.sessions[0].path).toBe(join(dir, 'home', '.codex', 'sessions', 'rollout-b.jsonl')); + } finally { + for (const [key, value] of Object.entries(saved)) { + if (value === undefined) delete process.env[key]; + else process.env[key] = value; + } + } + }); +}); + +describe('history CLI flags', () => { + const run = async (args: string[]) => { + let output = ''; + const program = new Command().exitOverride().configureOutput({ + writeOut: (text) => { + output += text; + }, + writeErr: () => {}, + }); + registerHistoryCommands(program); + await program.parseAsync(['history', 'read', ...args], { from: 'user' }); + return output; + }; + + it('prints text with --format text, a header line and one block per record', async () => { + const file = await writeRollout(dir, 'text.jsonl', [ + realMeta({ id: ROOT }), + ...userTurn('question'), + ...assistantTurn('answer'), + ]); + const output = await run(['--file', file, '--format', 'text']); + const blocks = output.trimEnd().split('\n\n'); + expect(blocks[0]).toMatch(/^source=codex-jsonl complete=true scannedBytes=\d+ metadataBytes=\d+$/); + expect(blocks[1]).toBe(`[root=${ROOT} session=${ROOT} 2026-10-24T01:00:00.000Z user]\nquestion`); + expect(blocks[2]).toBe(`[root=${ROOT} session=${ROOT} 2026-10-24T01:00:01.000Z assistant final_answer]\nanswer`); + expect(() => JSON.parse(output)).toThrow(); + }); + + it('refuses --json together with --format text, and accepts either alone', async () => { + const file = await writeRollout(dir, 'flags.jsonl', [realMeta({ id: ROOT }), ...assistantTurn('answer')]); + await expect(run(['--file', file, '--json', '--format', 'text'])).rejects.toThrow( + /--json conflicts with --format text/ + ); + expect(JSON.parse(await run(['--file', file, '--json'])).records[0].message).toBe('answer'); + expect(JSON.parse(await run(['--file', file, '--json', '--format', 'json'])).records[0].message).toBe('answer'); + expect(await run(['--file', file, '--format', 'text'])).toMatch(/^source=codex-jsonl/); + }); + + it('passes the store caps through and validates them', async () => { + await fixture(ROOT, [row('assistant', 'answer')]); + await fixture(CHILD, [row('assistant', 'child')], ROOT); + await expect(run(['--session-id', ROOT, '--sessions-root', dir, '--max-entries', '1'])).rejects.toThrow( + /--max-entries/ + ); + expect(JSON.parse(await run(['--session-id', ROOT, '--sessions-root', dir, '--max-entries', '2'])).complete).toBe( + true + ); + await expect(run(['--session-id', ROOT, '--sessions-root', dir, '--metadata-bytes', '16'])).rejects.toThrow( + /--metadata-bytes/ + ); + await expect(run(['--session-id', ROOT, '--sessions-root', dir, '--metadata-bytes', 'lots'])).rejects.toThrow( + /Invalid --metadata-bytes/ + ); + await expect(run(['--session-id', ROOT, '--sessions-root', dir, '--max-entries', '0'])).rejects.toThrow( + /Invalid --max-entries/ + ); + }); +}); From 44fa2d47572dd243d1d0783402d7c35cc74ac4af Mon Sep 17 00:00:00 2001 From: Aamer Akhter Date: Sat, 24 Oct 2026 23:00:50 -0400 Subject: [PATCH 28/68] docs(history): document the offline reader next to codeman agent Replace docs/offline-history.md, which repeated the skill reference and was linked from nowhere, with a README section and a wiki section beside the codeman agent commands. Bring the skill reference, its plugin mirror, the Codeman Skill wiki page and the changeset in line with the reader: forks, the separate budgets and their flags, UTC dates, phases and complete:false. --- .changeset/offline-codex-history.md | 2 +- README.md | 12 ++++ docs/offline-history.md | 52 -------------- docs/wiki/Codeman-Skill.md | 8 ++- docs/wiki/Driving-Codeman-From-An-Agent.md | 26 +++++++ docs/wiki/Home.md | 2 +- .../skills/codeman/reference/history.md | 72 +++++++++++++------ skills/codeman/reference/history.md | 72 +++++++++++++------ 8 files changed, 144 insertions(+), 102 deletions(-) delete mode 100644 docs/offline-history.md diff --git a/.changeset/offline-codex-history.md b/.changeset/offline-codex-history.md index 57090231b..9ed54d373 100644 --- a/.changeset/offline-codex-history.md +++ b/.changeset/offline-codex-history.md @@ -2,4 +2,4 @@ 'aicodeman': minor --- -Add offline `codeman history read` for saved Codex transcripts by file, native session ID, or conversation family, with whole-turn topic/date filters and bounded local reads and output. +Add offline `codeman history read` for saved Codex transcripts by file, native session ID, or conversation family (spawned sub-agents and forks included), with whole-turn topic and UTC date filters. A forked or spawned thread's copy of its parent's history is dropped, so every record belongs to the thread that wrote it. Store attribution has its own budget (`--metadata-bytes`), separate from the transcript budget (`--scan-bytes`), and every bound is raisable (`--max-entries` too), so id lookups keep working on stores of thousands of rollouts. A store file that cannot be attributed, an unreadable directory, or a rollout a running Codex is still appending to no longer fails the lookup; each is reported through `complete: false`. Assistant records carry Codex's own phase and user records none. The Past Sessions scanner and the offline reader now share one Codex rollout format module, including the injected-context rule. diff --git a/README.md b/README.md index bb3c142c2..3fc1a4956 100644 --- a/README.md +++ b/README.md @@ -996,6 +996,18 @@ codeman agent rm "$SID" # any session except t Rules the commands enforce rather than document: they refuse outside a Codeman session and never guess a URL; `send` transmits printable text plus Enter only (a control byte such as `Ctrl+C` is `app_exit` in opencode — ESC exists solely as `interrupt`, which never appends Enter); `rm` refuses an empty id, an unprovable self id and a prefix match in either direction. Ids may be the 8-character prefixes `ls` prints (resolved through the list; an ambiguous prefix refuses, anything shorter than 8 characters refuses on every verb). A prompt that starts with `-` goes after `--` (`send "$SID" -- "- fix the bug"`). The echo of the prompt you sent is output too: a `--match` marker that appears verbatim in the prompt matches at once, before the worker has done anything, so the prompt asks for it in halves. A remote session whose host is asleep answers a fire-and-forget `send` with `buffered` (Codeman wakes the host and types the prompt once the pane is back) or `dropped` (over the wake buffer's cap, nothing will be typed: exit `1`). Exit codes: `0` delivered/matched/signal, `1` error, `2` timeout, `3` the worker exited or the wait ended without an answer (`delivered:false`, `ended:true`), `4` refused. `spawn` prints the id alone on stdout (prose goes to stderr), so `SID=$(…)` captures exactly the id. `--json` prints the envelope's `data` for every verb. `--until stop` on a mode without hook signals is the server's 400, passed through — the marker path (`--match`) is the answer there, exactly as for the skill. +### `codeman history read`: saved Codex conversations, offline + +`codeman history read` reads Codex's own saved rollouts (`$CODEX_HOME/sessions`, else `~/.codex/sessions`) straight from disk: no server, no managed session, nothing written. Unlike `codeman agent` it works anywhere, including on a machine where Codeman is not running. + +```bash +codeman history read --session-id 019fc604 # one conversation, by native id or 8+ character prefix +codeman history read --root-session-id --topic auth # a conversation and every sub-agent or fork under it +codeman history read --file /path/to/rollout.jsonl --format text +``` + +Filters keep whole user/assistant turns (`--topic` is a literal, case-insensitive match; `--after`/`--before` are inclusive UTC dates). A forked or spawned thread's copy of its parent's history is dropped, so every record belongs to the thread that wrote it. Output is JSON by default and carries `complete: false` whenever the evidence is partial (a file that could not be attributed, a record a running Codex is still writing, an unknown ancestor). Every bound fails closed and is raisable: `--metadata-bytes` (attributing the store, default 1 GiB), `--max-entries` (default 65536), `--scan-bytes` (the selected transcripts, default 16 MiB) and `--max-bytes` (output, default 8192). The full reference is [`skills/codeman/reference/history.md`](skills/codeman/reference/history.md). + ### MCP server (`codeman mcp`): drive Codeman from any MCP client `codeman mcp` is a [Model Context Protocol](https://modelcontextprotocol.io) server on stdio, so any MCP client (Claude Code, Claude Desktop, Codex, Gemini CLI, Cursor, ...) can start Codeman sessions in any installed agent CLI, prompt them, wait for them, read their answers and clean up, from inside Codeman or from outside it. Like `codeman agent`, it is a client of the HTTP API: no new route, auth and ownership unchanged. diff --git a/docs/offline-history.md b/docs/offline-history.md deleted file mode 100644 index 219aabc14..000000000 --- a/docs/offline-history.md +++ /dev/null @@ -1,52 +0,0 @@ -# Saved Codex history (offline) - -Use `codeman history read` for retained local Codex JSONL transcripts. This command -works outside a managed session and never calls the HTTP API. Do not bootstrap -API credentials or require `CODEMAN_MUX` for this read-only job. - -Choose exactly one selector: - -```bash -codeman history read --file /path/to/rollout.jsonl -codeman history read --session-id aaaaaaaa --sessions-root /path/to/sessions -codeman history read --root-session-id aaaaaaaa-1111-4111-8111-111111111111 \ - --topic 'authentication' --after 2026-10-01 --before 2026-10-24 \ - --max-bytes 65536 -``` - -`--session-id` accepts a native conversation UUID or unique prefix of at least -8 characters; identity comes from metadata, not filenames. `--root-session-id` -requires a full native UUID and includes transitive descendants found in metadata. -The default store is `$CODEX_HOME/sessions`, or `~/.codex/sessions` when unset. -`--file` selects one file and does not search for its ancestors or children. - -Topic matching is a case-insensitive literal substring. Dates are inclusive -`YYYY-MM-DD` bounds. Each filter selects whole user/assistant turns; a turn is -retained if it has a date match and a topic match, which may be different messages -in that turn. Tool calls/results and injected context are omitted. Adjacent duplicate -native representations are collapsed; repeated messages from the same -representation are retained. - -JSON is the default (`--json` or `--format json`); `--format text` is available. -Results identify source paths, native session/root IDs, timestamps, roles and phases, -plus scan counters and `complete`. A root ID is `null` when ancestry cannot be proven. -Report that uncertainty; never relabel the queried child as its own root. -`complete: false` reports malformed or partial records, skipped links, unattributed -files or missing ancestors. It does not prove the store includes every conversation -ever created, and a filtered result is only evidence for its selected scope. - -Reads require regular UTF8 files and reject final-component symlinks, conflicting -metadata, ambiguous IDs, cyclic ancestry, invalid selectors and changed selected -files. Directory traversal skips symlinks. Parent directories of an explicitly -supplied path must be trusted; this is not a filesystem sandbox. - -The default total metadata/transcript scan budget is 16 MiB; `--scan-bytes` allows -1–67108864 bytes. Directory scans are capped at 8192 entries and depth 8. The output -budget is 8192 bytes by default; `--max-bytes` allows 1–1048576 bytes, including the -final newline. Exhausted budgets fail with an error: output is never silently -truncated or split into partial JSON or turns. Narrow the store/filters or explicitly -raise the relevant budget. Missing evidence is an error, not an empty success. - -This command does not inspect live panes, stage prompts, submit input or manage -sessions. Live session operations use the separate `codeman agent` commands and -require the managed-session environment and running server. diff --git a/docs/wiki/Codeman-Skill.md b/docs/wiki/Codeman-Skill.md index dcd57f081..6417606e2 100644 --- a/docs/wiki/Codeman-Skill.md +++ b/docs/wiki/Codeman-Skill.md @@ -123,8 +123,11 @@ message a worker directly instead of typing into its terminal. ## What it will and will not do -- **It self-gates.** Outside a Codeman session (`CODEMAN_MUX` unset) it refuses to act and - never guesses an API URL, so a global install costs an unrelated agent session nothing. +- **It self-gates.** Outside a Codeman session (`CODEMAN_MUX` unset) it refuses every live + API operation and never guesses an API URL, so a global install cannot drive a server it + is not part of. The one exception is reading saved Codex conversations with + `codeman history read` (see [Driving Codeman From An Agent](Driving-Codeman-From-An-Agent#reading-saved-codex-conversations)), + a local, read-only command that needs no server or session. - **Unprompted, it may only** start sessions, prompt them, and delete the ones **it created in that conversation, by exact id**, behind a guard that refuses to delete the agent's own session. @@ -153,6 +156,7 @@ The skill checks for both and says so instead of acting. | [`reference/recipes.md`](https://github.com/Ark0N/Codeman/blob/master/skills/codeman/reference/recipes.md) | Worked flows: claude, DeepSeek Harness and shell workers, fan-out, watching a blocked worker, messaging. Loaded on demand. | | [`reference/endpoints.md`](https://github.com/Ark0N/Codeman/blob/master/skills/codeman/reference/endpoints.md) | Endpoint tables, error codes, the signals each mode emits, capacity limits. Loaded on demand. | | [`reference/messaging.md`](https://github.com/Ark0N/Codeman/blob/master/skills/codeman/reference/messaging.md) | Talking to claude workers directly through Claude Code's cross-session messaging. Loaded on demand. | +| [`reference/history.md`](https://github.com/Ark0N/Codeman/blob/master/skills/codeman/reference/history.md) | Reading saved Codex conversations offline with `codeman history read`: selectors, filters, forks, budgets. Loaded on demand. | Only `SKILL.md` sits in the agent's context; the references load when a job needs them, so a quick run stays quick. diff --git a/docs/wiki/Driving-Codeman-From-An-Agent.md b/docs/wiki/Driving-Codeman-From-An-Agent.md index 6b2b337f1..6e7ce85c8 100644 --- a/docs/wiki/Driving-Codeman-From-An-Agent.md +++ b/docs/wiki/Driving-Codeman-From-An-Agent.md @@ -70,6 +70,32 @@ codeman agent rm "$SID" # any session except t - **Exit codes** are the same for every verb: `0` done, `1` error, `2` timeout, `3` the worker exited, `4` refused. `--json` prints the response's `data`. +## Reading saved Codex conversations + +`codeman history read` is the one command here that needs no server and no Codeman +session: it reads Codex's own saved rollouts (`$CODEX_HOME/sessions`, else +`~/.codex/sessions`) from disk and writes nothing. + +```bash +codeman history read --session-id 019fc604 # one conversation, by id or 8+ character prefix +codeman history read --root-session-id --topic auth # it and every sub-agent or fork under it +codeman history read --file /path/to/rollout.jsonl --format text +``` + +- **Filters** keep whole user/assistant turns: `--topic` is a literal, case-insensitive + match, and `--after`/`--before` are inclusive UTC dates. +- **Forks and sub-agents**: a child's copy of its parent's history is dropped, so every + record belongs to the thread that wrote it. +- **`complete: false`** in the JSON means the evidence is partial: a store file that could + not be attributed, a record a running Codex is still writing, an ancestor not found. +- **Budgets** fail closed and are raisable: `--metadata-bytes` (attributing the store, + default 1 GiB), `--max-entries` (65536), `--scan-bytes` (the selected transcripts, + 16 MiB) and `--max-bytes` (output, 8192). `--json` and `--format text` together are an + error. + +The full reference ships with the skill as +[`reference/history.md`](https://github.com/Ark0N/Codeman/blob/master/skills/codeman/reference/history.md). + ## The MCP server `codeman mcp` is a [Model Context Protocol](https://modelcontextprotocol.io) server, so any MCP diff --git a/docs/wiki/Home.md b/docs/wiki/Home.md index 50c108bba..3a0cd5663 100644 --- a/docs/wiki/Home.md +++ b/docs/wiki/Home.md @@ -42,7 +42,7 @@ codeman web # then open http://localhost:3000 **Driving it from code** - [The Codeman Skill](Codeman-Skill) - one prompt, a whole crew: an agent starting and supervising worker sessions. -- [Driving Codeman From An Agent](Driving-Codeman-From-An-Agent) - the `codeman agent` commands, the MCP server, wait primitives. +- [Driving Codeman From An Agent](Driving-Codeman-From-An-Agent) - the `codeman agent` commands, the MCP server, wait primitives, reading saved Codex conversations offline. - [HTTP API](HTTP-API) - the envelope, auth, the endpoint map, SSE events. - [Hooks And Integrations](Hooks-And-Integrations) - events flowing back into Codeman. diff --git a/plugins/codeman/skills/codeman/reference/history.md b/plugins/codeman/skills/codeman/reference/history.md index 0fa872058..bcdfc92c4 100644 --- a/plugins/codeman/skills/codeman/reference/history.md +++ b/plugins/codeman/skills/codeman/reference/history.md @@ -16,36 +16,62 @@ codeman history read --root-session-id aaaaaaaa-1111-4111-8111-111111111111 \ `--session-id` accepts a native conversation UUID or unique prefix of at least 8 characters; identity comes from metadata, not filenames. `--root-session-id` -requires a full native UUID and includes transitive descendants found in metadata. -The default store is `$CODEX_HOME/sessions`, or `~/.codex/sessions` when unset. -`--file` selects one file and does not search for its ancestors or children. +requires a full native UUID and includes transitive descendants found in metadata +(spawned sub-agents and forks). The default store is `$CODEX_HOME/sessions`, or +`~/.codex/sessions` when unset; a store directory that is itself a symlink is +followed, links inside it are skipped. `--file` selects one file and does not +search for its ancestors or children. Topic matching is a case-insensitive literal substring. Dates are inclusive -`YYYY-MM-DD` bounds. Each filter selects whole user/assistant turns; a turn is -retained if it has a date match and a topic match, which may be different messages -in that turn. Tool calls/results and injected context are omitted. Adjacent duplicate -native representations are collapsed; repeated messages from the same -representation are retained. +`YYYY-MM-DD` bounds compared as UTC calendar dates. Each filter selects whole +user/assistant turns; a turn is retained if it has a date match and a topic match, +which may be different messages in that turn. Tool calls/results and injected +context (rows Codex adds that start with `#` or `<`, such as AGENTS.md and +``) are omitted. Adjacent duplicate native representations +are collapsed; repeated messages from the same representation are retained. -JSON is the default (`--json` or `--format json`); `--format text` is available. +A forked or spawned thread's file starts with a copy of its parent's history. That +copy is dropped (it is in the parent's own file), so each record belongs to the +thread that wrote it. A later `session_meta` for any thread that is not a declared +ancestor still refuses the file. + +JSON is the default (`--json` or `--format json`); `--format text` prints one block +per record. `--json` together with `--format text` is an error. Results identify source paths, native session/root IDs, timestamps, roles and phases, -plus scan counters and `complete`. A root ID is `null` when ancestry cannot be proven. +plus scan counters and `complete`. `phase` is Codex's own (`commentary` or +`final_answer`) on assistant records and `null` on user records or when none was +recorded. A root ID is `null` when ancestry cannot be proven. Report that uncertainty; never relabel the queried child as its own root. -`complete: false` reports malformed or partial records, skipped links, unattributed -files or missing ancestors. It does not prove the store includes every conversation -ever created, and a filtered result is only evidence for its selected scope. +`complete: false` reports malformed or partial records (a running Codex may be +mid-write), skipped links, unattributed files, unreadable directories, missing +ancestors, or an inherited copy whose end could not be found or proven. It does not +prove the store includes every conversation ever created, and a filtered result is +only evidence for its selected scope. Reads require regular UTF8 files and reject final-component symlinks, conflicting -metadata, ambiguous IDs, cyclic ancestry, invalid selectors and changed selected -files. Directory traversal skips symlinks. Parent directories of an explicitly -supplied path must be trusted; this is not a filesystem sandbox. - -The default total metadata/transcript scan budget is 16 MiB; `--scan-bytes` allows -1–67108864 bytes. Directory scans are capped at 8192 entries and depth 8. The output -budget is 8192 bytes by default; `--max-bytes` allows 1–1048576 bytes, including the -final newline. Exhausted budgets fail with an error: output is never silently -truncated or split into partial JSON or turns. Narrow the store/filters or explicitly -raise the relevant budget. Missing evidence is an error, not an empty success. +metadata, ambiguous IDs, cyclic ancestry and invalid selectors. A store file that +cannot be attributed (invalid UTF8 in its head, no metadata in its first 128 KiB, +changing while its head is read) is counted in `unattributedFiles` instead of +failing the lookup. A selected rollout that a running Codex is appending to is read +as a snapshot up to its last complete line; one that was replaced or truncated +fails. Parent directories of an explicitly supplied path must be trusted; this is +not a filesystem sandbox. + +Budgets, each failing with an error rather than a partial answer: + +| Flag | Bounds | Default | Maximum | +| ---- | ------ | ------- | ------- | +| `--metadata-bytes` | head reads attributing every rollout in the store | 1 GiB | 16 GiB | +| `--max-entries` | directory entries visited | 65536 | 1048576 | +| `--scan-bytes` | the selected transcripts | 16 MiB | 256 MiB | +| `--max-bytes` | output, including the final newline | 8192 | 1048576 | + +Each rollout's `session_meta` carries Codex's base instructions (about 20 KiB), so +attributing a store costs about that much per rollout, charged to +`--metadata-bytes`, never to `--scan-bytes`. Directory depth is capped at 8 (Codex +writes `YYYY/MM/DD`). Output is never silently truncated or split into partial +JSON or turns. Narrow the store/filters or explicitly raise the relevant budget. +Missing evidence is an error, not an empty success. This command does not inspect live panes, stage prompts, submit input or manage sessions. For live operations return to the managed-session guard in the skill. diff --git a/skills/codeman/reference/history.md b/skills/codeman/reference/history.md index 0fa872058..bcdfc92c4 100644 --- a/skills/codeman/reference/history.md +++ b/skills/codeman/reference/history.md @@ -16,36 +16,62 @@ codeman history read --root-session-id aaaaaaaa-1111-4111-8111-111111111111 \ `--session-id` accepts a native conversation UUID or unique prefix of at least 8 characters; identity comes from metadata, not filenames. `--root-session-id` -requires a full native UUID and includes transitive descendants found in metadata. -The default store is `$CODEX_HOME/sessions`, or `~/.codex/sessions` when unset. -`--file` selects one file and does not search for its ancestors or children. +requires a full native UUID and includes transitive descendants found in metadata +(spawned sub-agents and forks). The default store is `$CODEX_HOME/sessions`, or +`~/.codex/sessions` when unset; a store directory that is itself a symlink is +followed, links inside it are skipped. `--file` selects one file and does not +search for its ancestors or children. Topic matching is a case-insensitive literal substring. Dates are inclusive -`YYYY-MM-DD` bounds. Each filter selects whole user/assistant turns; a turn is -retained if it has a date match and a topic match, which may be different messages -in that turn. Tool calls/results and injected context are omitted. Adjacent duplicate -native representations are collapsed; repeated messages from the same -representation are retained. +`YYYY-MM-DD` bounds compared as UTC calendar dates. Each filter selects whole +user/assistant turns; a turn is retained if it has a date match and a topic match, +which may be different messages in that turn. Tool calls/results and injected +context (rows Codex adds that start with `#` or `<`, such as AGENTS.md and +``) are omitted. Adjacent duplicate native representations +are collapsed; repeated messages from the same representation are retained. -JSON is the default (`--json` or `--format json`); `--format text` is available. +A forked or spawned thread's file starts with a copy of its parent's history. That +copy is dropped (it is in the parent's own file), so each record belongs to the +thread that wrote it. A later `session_meta` for any thread that is not a declared +ancestor still refuses the file. + +JSON is the default (`--json` or `--format json`); `--format text` prints one block +per record. `--json` together with `--format text` is an error. Results identify source paths, native session/root IDs, timestamps, roles and phases, -plus scan counters and `complete`. A root ID is `null` when ancestry cannot be proven. +plus scan counters and `complete`. `phase` is Codex's own (`commentary` or +`final_answer`) on assistant records and `null` on user records or when none was +recorded. A root ID is `null` when ancestry cannot be proven. Report that uncertainty; never relabel the queried child as its own root. -`complete: false` reports malformed or partial records, skipped links, unattributed -files or missing ancestors. It does not prove the store includes every conversation -ever created, and a filtered result is only evidence for its selected scope. +`complete: false` reports malformed or partial records (a running Codex may be +mid-write), skipped links, unattributed files, unreadable directories, missing +ancestors, or an inherited copy whose end could not be found or proven. It does not +prove the store includes every conversation ever created, and a filtered result is +only evidence for its selected scope. Reads require regular UTF8 files and reject final-component symlinks, conflicting -metadata, ambiguous IDs, cyclic ancestry, invalid selectors and changed selected -files. Directory traversal skips symlinks. Parent directories of an explicitly -supplied path must be trusted; this is not a filesystem sandbox. - -The default total metadata/transcript scan budget is 16 MiB; `--scan-bytes` allows -1–67108864 bytes. Directory scans are capped at 8192 entries and depth 8. The output -budget is 8192 bytes by default; `--max-bytes` allows 1–1048576 bytes, including the -final newline. Exhausted budgets fail with an error: output is never silently -truncated or split into partial JSON or turns. Narrow the store/filters or explicitly -raise the relevant budget. Missing evidence is an error, not an empty success. +metadata, ambiguous IDs, cyclic ancestry and invalid selectors. A store file that +cannot be attributed (invalid UTF8 in its head, no metadata in its first 128 KiB, +changing while its head is read) is counted in `unattributedFiles` instead of +failing the lookup. A selected rollout that a running Codex is appending to is read +as a snapshot up to its last complete line; one that was replaced or truncated +fails. Parent directories of an explicitly supplied path must be trusted; this is +not a filesystem sandbox. + +Budgets, each failing with an error rather than a partial answer: + +| Flag | Bounds | Default | Maximum | +| ---- | ------ | ------- | ------- | +| `--metadata-bytes` | head reads attributing every rollout in the store | 1 GiB | 16 GiB | +| `--max-entries` | directory entries visited | 65536 | 1048576 | +| `--scan-bytes` | the selected transcripts | 16 MiB | 256 MiB | +| `--max-bytes` | output, including the final newline | 8192 | 1048576 | + +Each rollout's `session_meta` carries Codex's base instructions (about 20 KiB), so +attributing a store costs about that much per rollout, charged to +`--metadata-bytes`, never to `--scan-bytes`. Directory depth is capped at 8 (Codex +writes `YYYY/MM/DD`). Output is never silently truncated or split into partial +JSON or turns. Narrow the store/filters or explicitly raise the relevant budget. +Missing evidence is an error, not an empty success. This command does not inspect live panes, stage prompts, submit input or manage sessions. For live operations return to the managed-session guard in the skill. From 1082724db9f2b383a6f98e6e24be207ce8cf5543 Mon Sep 17 00:00:00 2001 From: Codeman maintainer Date: Sun, 25 Oct 2026 05:52:07 +0100 Subject: [PATCH 29/68] perf(zerolag): rebuild only the changed overlay line, one paint layer per line The local echo overlay rebuilt every character of the prompt on each keystroke, one absolutely positioned span per character, so a keystroke cost O(prompt length) in DOM work, style, layout and paint. Every positioned span was also its own paint layer, so the browser re-layerized and repainted thousands of them per frame. In headless Chromium with a 6x CPU slowdown, a ~4,100-character prompt ran at a median 813 ms per frame, and ~300 characters already missed 60 fps (54 ms). renderOverlay() now keeps a per-container record of the line elements it drew and rebuilds only lines whose text, position or composition range changed. It starts over when the font, colour, cell size or Unicode version changes, or when something else emptied or edited the container. makeLine() lays the spans out in a flex row instead of positioning each one, and sets the shared font styles once on the line. Cell edges are snapped to a quarter pixel, which Blink, WebKit and Gecko all store exactly, so the widths add up to the grid without drift and every edge stays within 1/8 px of col * cellW. Same prompt: 17 ms per frame. Co-Authored-By: Claude Opus 5.5 (1M context) --- .changeset/zerolag-overlay-render-cost.md | 6 + README.md | 2 +- README.zh-CN.md | 2 +- packages/xterm-zerolag-input/README.md | 33 ++- .../src/overlay-renderer.ts | 163 ++++++++--- .../test/cjk-testplan.test.ts | 35 +-- .../test/composition.test.ts | 17 +- packages/xterm-zerolag-input/test/helpers.ts | 18 ++ .../test/overlay-renderer.test.ts | 271 ++++++++++++++---- 9 files changed, 425 insertions(+), 122 deletions(-) create mode 100644 .changeset/zerolag-overlay-render-cost.md diff --git a/.changeset/zerolag-overlay-render-cost.md b/.changeset/zerolag-overlay-render-cost.md new file mode 100644 index 000000000..64216ecc5 --- /dev/null +++ b/.changeset/zerolag-overlay-render-cost.md @@ -0,0 +1,6 @@ +--- +"aicodeman": patch +"xterm-zerolag-input": patch +--- + +**Typing stays smooth in a long prompt.** The local echo overlay (the instant preview of what you type before the session echoes it back) used to throw away and rebuild every character of the prompt on each keystroke, so the longer the prompt, the slower each key. On a phone-class CPU it dropped below 60 fps at about 300 characters, and at about 4,000 characters a single keystroke took most of a second to appear. It now rebuilds only the line that changed, and each line is one paint layer instead of one layer per character, so a keystroke costs the same however long the prompt is. What you see is unchanged: every character still sits on its terminal cell, within 1/8 px. This ships in `xterm-zerolag-input` as well: the README's rendering section describes the new layout. diff --git a/README.md b/README.md index bb3c142c2..fda77d05a 100644 --- a/README.md +++ b/README.md @@ -1227,7 +1227,7 @@ Full details: [`docs/archive/code-structure-findings.md`](docs/archive/code-stru [![npm](https://img.shields.io/npm/v/xterm-zerolag-input?style=flat-square&color=22c55e)](https://www.npmjs.com/package/xterm-zerolag-input) -Instant keystroke feedback overlay for xterm.js. Eliminates perceived input latency over high-RTT connections by rendering typed characters immediately as a pixel-perfect DOM overlay. Zero dependencies, 6.1 kB gzipped, configurable prompt detection, CJK/emoji wide-character support, full state machine with 238 tests. +Instant keystroke feedback overlay for xterm.js. Eliminates perceived input latency over high-RTT connections by rendering typed characters immediately as a pixel-perfect DOM overlay. Zero dependencies, 5.7 kB minified and gzipped, configurable prompt detection, CJK/emoji wide-character support, full state machine with 276 tests. Typing cost does not grow with prompt length. ```bash npm install xterm-zerolag-input diff --git a/README.zh-CN.md b/README.zh-CN.md index 39f858eb4..6e78e0005 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -1221,7 +1221,7 @@ npm test # 运行测试(与 CI 相同;浏览器/移动端 [![npm](https://img.shields.io/npm/v/xterm-zerolag-input?style=flat-square&color=22c55e)](https://www.npmjs.com/package/xterm-zerolag-input) -为 xterm.js 提供即时按键反馈的叠加层。通过把输入的字符立即渲染为像素级精准的 DOM 叠加层,消除高 RTT 连接下的感知输入延迟。零依赖、gzip 后 6.1 kB、可配置的提示符检测、CJK/emoji 宽字符支持、带 238 个测试的完整状态机。 +为 xterm.js 提供即时按键反馈的叠加层。通过把输入的字符立即渲染为像素级精准的 DOM 叠加层,消除高 RTT 连接下的感知输入延迟。零依赖、压缩并 gzip 后 5.7 kB、可配置的提示符检测、CJK/emoji 宽字符支持、带 276 个测试的完整状态机。输入开销不随提示词长度增长。 ```bash npm install xterm-zerolag-input diff --git a/packages/xterm-zerolag-input/README.md b/packages/xterm-zerolag-input/README.md index 1129a8a85..7ef21a9ff 100644 --- a/packages/xterm-zerolag-input/README.md +++ b/packages/xterm-zerolag-input/README.md @@ -8,8 +8,8 @@ npm MIT Zero dependencies - 6.1 kB gzipped - 175 tests + 5.7 kB minified and gzipped + 276 tests xterm.js v5 and v7+

@@ -60,12 +60,13 @@ Since 0.2.0 the package ships **two addons for two kinds of TUIs**: | | | |---|---| | **Survives full-screen TUIs** | Ink, blessed, and friends repaint the whole screen constantly. The overlay is a separate DOM layer they cannot reach, so it does not get clobbered. | -| **Pixel-matched to the canvas** | Each character is its own absolutely-positioned `` at exact cell coordinates, so it does not drift out of the grid like normal DOM text flow. | +| **Pixel-matched to the canvas** | Each character is its own ``, exactly as wide as its cells and snapped to the grid, so it does not drift out of the grid like normal DOM text flow. | +| **Cheap per keystroke** | Only the line that changed is rebuilt, and each line is one paint layer, so a keystroke costs the same in a 4,000-character prompt as in a short one. | | **Wide characters included** | CJK, fullwidth forms and emoji render double-width and position by visual column, using the terminal's Unicode addon when one is loaded. | | **Backspace that actually works** | A three-layer cascade (unsent, in-flight, already on screen) tells you exactly what to forward to the PTY, so editing works through any mix of typed, flushed and tab-completed text. | | **You keep control of input** | The addon never hooks `onData` for you. You decide what gets echoed and what gets forwarded, which is what makes char-at-a-time, buffered, and multi-session tab switching all possible. | -| **Small and self-contained** | 6.1 kB gzipped, zero runtime dependencies, dual CJS/ESM with full type declarations. | -| **Proven under load** | Extracted from [Codeman](https://getcodeman.com), hardened over thousands of hours of real remote and mobile usage, 175 tests over every state transition. | +| **Small and self-contained** | 5.7 kB minified and gzipped, zero runtime dependencies, dual CJS/ESM with full type declarations. | +| **Proven under load** | Extracted from [Codeman](https://getcodeman.com), hardened over thousands of hours of real remote and mobile usage, 276 tests over every state transition. | Built for anything that puts a terminal behind a network hop: SSH web clients, cloud IDEs, mobile terminals, Kubernetes and container consoles, remote agent dashboards, browser-based dev environments. @@ -128,7 +129,7 @@ This library answers all three: - a **DOM overlay** on its own z-index layer, which Ink cannot touch - **bottom-up buffer scanning** for the prompt instead of trusting the cursor -- **one absolutely-positioned `` per character** at exact cell-grid coordinates +- **one `` per character**, sized to its cells and snapped to the cell grid --- @@ -508,15 +509,23 @@ div.xterm-screen (position: relative) ### Per-character grid alignment -Each character is an absolutely-positioned ``: +Each line is an absolutely-positioned `
` at its row, and each character is a `` in a flex row inside it, as wide as the cells it covers: ``` -left = visualColumn * cellWidth (CSS pixels) -top = lineIndex * cellHeight (CSS pixels) -width = cellWidth * charCellWidth (1 cell, or 2 for wide characters) +line top = lineIndex * cellHeight (CSS pixels) +edge(col) = round(col * cellWidth * 4) / 4 (snapped to a quarter pixel) +span width = edge(col + charCellWidth) - edge(col) (1 cell, or 2 for wide characters) ``` -Positioning by visual column instead of letting the browser lay out text is what removes sub-pixel drift. +A span starts where the widths before it add up to. Normal DOM text drifts because glyph advances that the layout engine cannot store exactly carry their rounding into every later cell. A quarter pixel is stored exactly by Blink and WebKit (1/64 px layout units) and Gecko (1/60 px app units), so the snapped widths always add up to the snapped edge: no drift at any column, and every edge stays within 1/8 px of `col * cellWidth`. + +The spans are deliberately not absolutely positioned. A positioned element gets its own paint layer, and with one per character the browser had to re-layerize and repaint thousands of them on every keystroke of a long prompt. The font, colour, line height, centring and ligature settings are set once per line and inherited. + +### Rendering cost + +Each render compares every line with what the previous render left in the overlay and rebuilds only the lines whose text or position changed. Typing a character changes only the last line, so a keystroke costs one line of DOM work, layout and paint, however long the prompt. A font, colour, cell size or Unicode version change rebuilds everything, and so does anything else emptying the overlay element. + +Measured in headless Chromium with a 6x CPU slowdown standing in for a phone, typing into a prompt of about 4,100 characters: 0.4.x took a median 813 ms per frame (513 ms of script, style and layout per keystroke), this version 17 ms (8.6 ms). At about 300 characters, 0.4.x already missed 60 fps (54 ms frames). ### Font matching @@ -543,7 +552,7 @@ The overlay hides while the cursor row is scrolled out of the viewport and re-re ## Known limitations -- **Canvas and WebGL font mismatch**: minor sub-pixel differences are still possible. Per-character absolute positioning keeps them small. +- **Canvas and WebGL font mismatch**: minor sub-pixel differences are still possible. Per-character cells snapped to a quarter pixel keep them under 1/8 px. - **Grapheme clusters**: widths are summed per code point, so ZWJ emoji sequences (for example 👨‍👩‍👧) and combining marks can be over-counted. Single-code-point emoji and CJK are correct. - **Password prompts**: the overlay will happily show characters the server is not echoing. Call `clear()` when you detect a no-echo prompt. - **Prompt characters in output**: if your prompt marker also appears in command output, detection can latch onto the wrong line. Use a regex or a custom finder. diff --git a/packages/xterm-zerolag-input/src/overlay-renderer.ts b/packages/xterm-zerolag-input/src/overlay-renderer.ts index 7e8d7a07b..baa1bab23 100644 --- a/packages/xterm-zerolag-input/src/overlay-renderer.ts +++ b/packages/xterm-zerolag-input/src/overlay-renderer.ts @@ -46,14 +46,61 @@ export function stringCellWidth(terminal: XtermTerminal | null | undefined, str: // ─── Overlay rendering ──────────────────────────────────────────────── +/** + * Cell edges are snapped to this fraction of a CSS pixel. + * + * The glyph spans sit in normal flow, so each one starts where the widths + * before it add up to, and a width the layout engine cannot store exactly + * would carry its rounding error into every later cell (the drift that plain + * DOM text shows against the canvas grid). A quarter pixel is exact in + * Blink and WebKit (1/64 px layout units) and in Gecko (1/60 px app units), + * so the widths always add up to the snapped edge: no drift at any column, + * and every edge is within 1/8 px of `col * cellW`. + */ +const EDGE_SNAP = 4; + +function cellEdge(col: number, cellW: number): number { + return Math.round(col * cellW * EDGE_SNAP) / EDGE_SNAP; +} + +/** What the last render left in a container, so the next one rebuilds only the lines that changed. */ +interface RenderedLines { + /** Everything a line's markup depends on besides its own text and position. */ + styleKey: string; + lines: HTMLDivElement[]; + lineKeys: string[]; + cursor: HTMLSpanElement | null; +} + +const rendered = new WeakMap(); + +/** + * False once anything else has emptied or edited the container (the addon's + * hide path clears it): the children must be exactly the recorded lines, in + * order, then the cursor. + */ +function holdsOnlyRendered(container: HTMLDivElement, state: RenderedLines): boolean { + const children = container.children; + if (children.length !== state.lines.length + (state.cursor ? 1 : 0)) return false; + for (let i = 0; i < state.lines.length; i++) { + if (children[i] !== state.lines[i]) return false; + } + return !state.cursor || children[state.lines.length] === state.cursor; +} + /** * Render the overlay content into the container element. * - * Creates per-character `` elements positioned on an exact grid - * matching xterm.js's canvas renderer. This avoids sub-pixel drift that - * occurs with normal DOM text flow. + * Each line is a `
` holding one `` per character on the cell grid + * of xterm.js's canvas renderer (see makeLine). CJK wide characters get + * double-width spans. * - * CJK wide characters are rendered with double-width spans. + * Only lines whose content or position changed since the previous render + * into the same container are rebuilt. Typing a character changes just the + * last line, so a keystroke costs one line of DOM work however long the + * prompt is; rebuilding everything made each keystroke O(prompt length) in + * DOM work, layout and paint, which a long prompt on a phone paid for in + * dropped frames. */ export function renderOverlay(container: HTMLDivElement, params: RenderParams): void { const { @@ -94,11 +141,14 @@ export function renderOverlay(container: HTMLDivElement, params: RenderParams): // Code-point offset of each line in the whole text, so the composition // styling survives the tail slice below. const lineOffsets: number[] = []; + const lineLengths: number[] = []; { let offset = 0; for (const line of lines) { + const length = [...line].length; lineOffsets.push(offset); - offset += [...line].length; + lineLengths.push(length); + offset += length; } } let visibleLines = lines; @@ -120,8 +170,29 @@ export function renderOverlay(container: HTMLDivElement, params: RenderParams): container.style.left = '0px'; container.style.top = topRow * cellH + 'px'; - // Clear and rebuild (typically 1-3 line divs, negligible cost) - container.innerHTML = ''; + // Reuse the previous render's lines when nothing they depend on but their + // own text and position changed; otherwise start from an empty container. + const styleKey = [ + cellW, + cellH, + font.fontFamily, + font.fontSize, + font.fontWeight, + font.color, + font.backgroundColor, + font.letterSpacing, + terminal?.unicode?.activeVersion ?? '', + ].join('\n'); + let state = rendered.get(container); + if (!state || state.styleKey !== styleKey || !holdsOnlyRendered(container, state)) { + container.innerHTML = ''; + state = { styleKey, lines: [], lineKeys: [], cursor: null }; + rendered.set(container, state); + } + // Taken out while the lines change, so it is appended last again below. + state.cursor?.remove(); + state.cursor = null; + const fullWidthPx = totalCols * cellW; for (let i = 0; i < visibleLines.length; i++) { @@ -129,8 +200,16 @@ export function renderOverlay(container: HTMLDivElement, params: RenderParams): const leftPx = indents ? startCol * cellW : 0; const widthPx = indents ? fullWidthPx - leftPx : fullWidthPx; const topPx = i * cellH; - const lineCompositionFrom = - compositionStart === undefined ? undefined : compositionStart - lineOffsets[firstVisible + i]; + // Clamped to the line, so a line the composition does not reach keeps + // the same key (and its element) while the composition moves along. + let lineCompositionFrom: number | undefined; + if (compositionStart !== undefined) { + const from = compositionStart - lineOffsets[firstVisible + i]; + if (from < lineLengths[firstVisible + i]) lineCompositionFrom = Math.max(0, from); + } + // Text goes last, so no character in it can make two different lines collide. + const key = `${leftPx}|${topPx}|${widthPx}|${lineCompositionFrom ?? ''}|${visibleLines[i]}`; + if (state.lineKeys[i] === key) continue; const lineEl = makeLine( visibleLines[i], leftPx, @@ -144,8 +223,15 @@ export function renderOverlay(container: HTMLDivElement, params: RenderParams): terminal, lineCompositionFrom ); - container.appendChild(lineEl); + const previous = state.lines[i]; + if (previous) container.replaceChild(lineEl, previous); + else container.appendChild(lineEl); + state.lines[i] = lineEl; + state.lineKeys[i] = key; } + for (let i = state.lines.length - 1; i >= visibleLines.length; i--) state.lines[i].remove(); + state.lines.length = visibleLines.length; + state.lineKeys.length = visibleLines.length; // Block cursor at end of last line (use visual width for CJK support) if (showCursor) { @@ -161,6 +247,7 @@ export function renderOverlay(container: HTMLDivElement, params: RenderParams): cursor.style.height = cellH + 'px'; cursor.style.backgroundColor = cursorColor; container.appendChild(cursor); + state.cursor = cursor; } } @@ -168,13 +255,20 @@ export function renderOverlay(container: HTMLDivElement, params: RenderParams): } /** - * Create a styled line `
` with per-character grid positioning. + * Create a styled line `
` with one `` per character on the cell grid. + * + * The spans sit side by side in a flex row, each exactly as wide as its cells + * (CJK wide characters occupy 2), with every edge snapped by cellEdge() so the + * widths add up to the grid without drifting. They are deliberately not + * absolutely positioned: a positioned element gets its own paint layer, and + * with one per character a long prompt left the browser re-layerizing and + * repainting thousands of them on every keystroke. Everything the spans share + * (font, colour, line height, centring, ligatures off) is set once on the + * line and inherited. * - * Each character gets its own `` positioned by visual column offset. - * CJK wide characters occupy 2 cell widths. Characters at or after - * `compositionFrom` (a code-point index into `text`, may be negative) are IME - * composition text: underlined, like xterm's own composition view, and marked - * `data-zerolag-composition` + `aria-hidden` since they are provisional. + * Characters at or after `compositionFrom` (a code-point index into `text`) + * are IME composition text: underlined, like xterm's own composition view, and + * marked `data-zerolag-composition` + `aria-hidden` since they are provisional. */ function makeLine( text: string, @@ -190,7 +284,9 @@ function makeLine( compositionFrom?: number ): HTMLDivElement { const el = document.createElement('div'); - el.style.cssText = 'position:absolute;pointer-events:none'; + // No ligatures: the canvas renders each glyph independently. + el.style.cssText = + "position:absolute;display:flex;pointer-events:none;text-align:center;font-feature-settings:'liga' 0,'calt' 0"; el.style.backgroundColor = font.backgroundColor; el.style.left = leftPx + 'px'; el.style.top = topPx + 'px'; @@ -200,27 +296,24 @@ function makeLine( // The extra 1px lands in the next row's charTop gap (empty area before // text rendering starts), so no canvas content is obscured. el.style.height = cellH + 1 + 'px'; + el.style.lineHeight = cellH + 'px'; + el.style.fontFamily = font.fontFamily; + el.style.fontSize = font.fontSize; + el.style.fontWeight = font.fontWeight; + el.style.color = font.color; + if (font.letterSpacing) el.style.letterSpacing = font.letterSpacing; - // CJK wide chars occupy 2 cells — position by visual column offset - let colOffset = 0; + // Each span runs from the previous snapped edge to its own, so the spans + // land on the grid by visual column (CJK wide chars span 2 cells). + const spanHeight = ';height:' + cellH + 'px'; + let col = 0; + let edge = 0; let index = 0; for (const ch of text) { - const cw = charCellWidth(terminal, ch); + col += charCellWidth(terminal, ch); + const nextEdge = cellEdge(col, cellW); const span = document.createElement('span'); - // No ligatures — canvas renders each glyph independently. - span.style.cssText = - 'position:absolute;display:inline-block;text-align:center;pointer-events:none;' + - "font-feature-settings:'liga' 0,'calt' 0"; - span.style.left = colOffset * cellW + 'px'; - span.style.top = '0px'; - span.style.width = cw * cellW + 'px'; - span.style.height = cellH + 'px'; - span.style.lineHeight = cellH + 'px'; - span.style.fontFamily = font.fontFamily; - span.style.fontSize = font.fontSize; - span.style.fontWeight = font.fontWeight; - span.style.color = font.color; - if (font.letterSpacing) span.style.letterSpacing = font.letterSpacing; + span.style.cssText = 'flex:none;width:' + (nextEdge - edge) + 'px' + spanHeight; if (compositionFrom !== undefined && index >= compositionFrom) { span.style.textDecoration = 'underline'; span.setAttribute('data-zerolag-composition', ''); @@ -228,7 +321,7 @@ function makeLine( } span.textContent = ch; el.appendChild(span); - colOffset += cw; + edge = nextEdge; index++; } diff --git a/packages/xterm-zerolag-input/test/cjk-testplan.test.ts b/packages/xterm-zerolag-input/test/cjk-testplan.test.ts index 57b815e92..d13fa789d 100644 --- a/packages/xterm-zerolag-input/test/cjk-testplan.test.ts +++ b/packages/xterm-zerolag-input/test/cjk-testplan.test.ts @@ -14,7 +14,7 @@ import { describe, it, expect, afterEach } from 'vitest'; import { renderOverlay, charCellWidth, stringCellWidth } from '../src/overlay-renderer.js'; import type { RenderParams, FontStyle } from '../src/types.js'; -import { createMockTerminal } from './helpers.js'; +import { createMockTerminal, lineSpans } from './helpers.js'; import { ZerolagInputAddon } from '../src/zerolag-input-addon.js'; // ─── Shared fixtures ───────────────────────────────────────────────── @@ -47,16 +47,7 @@ function makeParams(overrides: Partial = {}): RenderParams { /** Extract span data from a rendered line div */ function getSpans(lineDiv: HTMLDivElement) { - const spans: { text: string; left: number; width: number }[] = []; - for (let i = 0; i < lineDiv.children.length; i++) { - const span = lineDiv.children[i] as HTMLSpanElement; - spans.push({ - text: span.textContent || '', - left: parseFloat(span.style.left), - width: parseFloat(span.style.width), - }); - } - return spans; + return lineSpans(lineDiv).map(({ text, left, width }) => ({ text, left, width })); } // ─── Addon setup helpers ───────────────────────────────────────────── @@ -199,18 +190,27 @@ describe('Test 2: CJK double-width spacing', () => { it('CJK span widths are exactly 2 * cellW pixels', () => { const container = document.createElement('div'); - renderOverlay(container, makeParams({ lines: ['你'], cellW: 8.4 })); + renderOverlay(container, makeParams({ lines: ['你'], cellW: 8.5 })); const lineDiv = container.children[0] as HTMLDivElement; const span = lineDiv.children[0] as HTMLSpanElement; - expect(span.style.width).toBe('16.8px'); // 2 * 8.4 + expect(span.style.width).toBe('17px'); // 2 * 8.5 }); it('ASCII span widths remain 1 * cellW pixels', () => { const container = document.createElement('div'); - renderOverlay(container, makeParams({ lines: ['a'], cellW: 8.4 })); + renderOverlay(container, makeParams({ lines: ['a'], cellW: 8.5 })); const lineDiv = container.children[0] as HTMLDivElement; const span = lineDiv.children[0] as HTMLSpanElement; - expect(span.style.width).toBe('8.4px'); // 1 * 8.4 + expect(span.style.width).toBe('8.5px'); // 1 * 8.5 + }); + + it('at a fractional cell width, CJK and ASCII spans still cover 2 and 1 cells to within 1/8 px', () => { + const container = document.createElement('div'); + renderOverlay(container, makeParams({ lines: ['你a'], cellW: 8.4 })); + const [wide, narrow] = getSpans(container.children[0] as HTMLDivElement); + expect(Math.abs(wide.width - 2 * 8.4)).toBeLessThanOrEqual(0.125); + expect(Math.abs(narrow.left - 2 * 8.4)).toBeLessThanOrEqual(0.125); + expect(Math.abs(narrow.left + narrow.width - 3 * 8.4)).toBeLessThanOrEqual(0.125); }); it('terminal unicode addon is preferred over fallback when available', () => { @@ -644,8 +644,9 @@ describe('Test 6: ASCII input — no regression', () => { const spans = getSpans(lineDiv); expect(spans[0].left).toBe(0); - expect(spans[1].left).toBeCloseTo(8.4, 5); - expect(spans[2].left).toBeCloseTo(16.8, 5); + // Edges are snapped to a quarter pixel, so within 1/8 px of the grid. + expect(Math.abs(spans[1].left - 8.4)).toBeLessThanOrEqual(0.125); + expect(Math.abs(spans[2].left - 16.8)).toBeLessThanOrEqual(0.125); }); it('ASCII cursor positions at correct column', () => { diff --git a/packages/xterm-zerolag-input/test/composition.test.ts b/packages/xterm-zerolag-input/test/composition.test.ts index ceacfc46a..9a06b7088 100644 --- a/packages/xterm-zerolag-input/test/composition.test.ts +++ b/packages/xterm-zerolag-input/test/composition.test.ts @@ -1,5 +1,5 @@ import { describe, it, expect, afterEach } from 'vitest'; -import { createMockTerminal } from './helpers.js'; +import { createMockTerminal, lineSpans } from './helpers.js'; import { ZerolagInputAddon } from '../src/zerolag-input-addon.js'; // setComposition(): IME text still being composed, drawn as an underlined tail @@ -68,8 +68,9 @@ describe('setComposition', () => { expect(span.style.textDecoration).toBe('underline'); } // Grid positions continue straight on from the pending text. - expect(spans[3].style.left).toBe(3 * CELL_W + 'px'); - expect(spans[4].style.left).toBe(4 * CELL_W + 'px'); + const cells = lineSpans(line); + expect(cells[3].left).toBe(3 * CELL_W); + expect(cells[4].left).toBe(4 * CELL_W); expect(overlay.style.display).toBe(''); }); @@ -79,9 +80,10 @@ describe('setComposition', () => { addon.setComposition('天気'); const spans = Array.from(lineDivs(overlay)[0].children) as HTMLSpanElement[]; expect(spans.map((s) => s.textContent).join('')).toBe('今日は天気'); - expect(spans[3].style.left).toBe(6 * CELL_W + 'px'); - expect(spans[3].style.width).toBe(2 * CELL_W + 'px'); - expect(spans[4].style.left).toBe(8 * CELL_W + 'px'); + const cells = lineSpans(lineDivs(overlay)[0]); + expect(cells[3].left).toBe(6 * CELL_W); + expect(cells[3].width).toBe(2 * CELL_W); + expect(cells[4].left).toBe(8 * CELL_W); }); it('does not touch pendingText, hasPending, flushed state or the state snapshot', () => { @@ -115,7 +117,8 @@ describe('setComposition', () => { expect(compositionText(overlay)).toBe('WXYZ'); const second = Array.from(lines[1].children) as HTMLSpanElement[]; expect(second.every((s) => s.hasAttribute('data-zerolag-composition'))).toBe(true); - expect(second[0].style.left).toBe('0px'); + expect(lineSpans(lines[1])[0].left).toBe(0); + expect(lines[1].style.left).toBe('0px'); }); it('keeps the composition styling when only the tail of a tall prompt fits', () => { diff --git a/packages/xterm-zerolag-input/test/helpers.ts b/packages/xterm-zerolag-input/test/helpers.ts index d22295769..878dffb9a 100644 --- a/packages/xterm-zerolag-input/test/helpers.ts +++ b/packages/xterm-zerolag-input/test/helpers.ts @@ -204,3 +204,21 @@ export function createMockTerminal(opts: MockTerminalOptions = {}) { }, }; } + +/** + * The glyph spans of a rendered overlay line, with each one's left edge and + * width in px. The spans sit side by side in a flex row, so a span's left + * edge is the sum of the widths before it (what the browser lays out), not a + * style property of its own; jsdom does no layout, so this adds them up. + */ +export function lineSpans(lineDiv: Element): { text: string; left: number; width: number; el: HTMLSpanElement }[] { + const spans: { text: string; left: number; width: number; el: HTMLSpanElement }[] = []; + let left = 0; + for (const child of Array.from(lineDiv.children)) { + const el = child as HTMLSpanElement; + const width = parseFloat(el.style.width); + spans.push({ text: el.textContent || '', left, width, el }); + left += width; + } + return spans; +} diff --git a/packages/xterm-zerolag-input/test/overlay-renderer.test.ts b/packages/xterm-zerolag-input/test/overlay-renderer.test.ts index a95846a85..a83d09012 100644 --- a/packages/xterm-zerolag-input/test/overlay-renderer.test.ts +++ b/packages/xterm-zerolag-input/test/overlay-renderer.test.ts @@ -1,6 +1,7 @@ import { describe, it, expect } from 'vitest'; import { renderOverlay, charCellWidth, stringCellWidth } from '../src/overlay-renderer.js'; import type { RenderParams, FontStyle } from '../src/types.js'; +import { lineSpans } from './helpers.js'; const FONT: FontStyle = { fontFamily: 'monospace', @@ -38,7 +39,7 @@ describe('renderOverlay', () => { it('creates per-character spans in a line div', () => { const container = document.createElement('div'); - renderOverlay(container, makeParams({ lines: ['abc'] })); + renderOverlay(container, makeParams({ lines: ['abc'], cellW: 10 })); // Line div + cursor span expect(container.children.length).toBe(2); @@ -46,17 +47,43 @@ describe('renderOverlay', () => { const lineDiv = container.children[0] as HTMLDivElement; expect(lineDiv.children.length).toBe(3); // a, b, c - const spanA = lineDiv.children[0] as HTMLSpanElement; - expect(spanA.textContent).toBe('a'); - expect(spanA.style.left).toBe('0px'); + const [a, b, c] = lineSpans(lineDiv); + expect([a.text, a.left, a.width]).toEqual(['a', 0, 10]); + expect([b.text, b.left, b.width]).toEqual(['b', 10, 10]); + expect([c.text, c.left, c.width]).toEqual(['c', 20, 10]); + }); - const spanB = lineDiv.children[1] as HTMLSpanElement; - expect(spanB.textContent).toBe('b'); - expect(spanB.style.left).toBe('8.4px'); + it('lays the spans out in a flex row, not absolutely positioned (one paint layer per line)', () => { + const container = document.createElement('div'); + renderOverlay(container, makeParams({ lines: ['ab'] })); + const lineDiv = container.children[0] as HTMLDivElement; + expect(lineDiv.style.position).toBe('absolute'); + expect(lineDiv.style.display).toBe('flex'); + for (const { el } of lineSpans(lineDiv)) { + expect(el.style.position).toBe(''); + expect(el.style.left).toBe(''); + // flex: none, so a span is never stretched or squeezed off its cells. + expect(el.style.flexGrow).toBe('0'); + expect(el.style.flexShrink).toBe('0'); + } + }); - const spanC = lineDiv.children[2] as HTMLSpanElement; - expect(spanC.textContent).toBe('c'); - expect(spanC.style.left).toBe('16.8px'); + it('snaps cell edges to a quarter pixel so a fractional cell width never drifts', () => { + const container = document.createElement('div'); + const cellW = 8.4; + renderOverlay(container, makeParams({ lines: ['x'.repeat(78) + '你'], startCol: 0, totalCols: 80, cellW })); + const spans = lineSpans(container.children[0]); + let col = 0; + for (const span of spans) { + // Every width is a whole number of quarter pixels, which Blink, WebKit + // and Gecko all store exactly, so the browser adds them up exactly too. + expect(Number.isInteger(span.width * 4)).toBe(true); + expect(Math.abs(span.left - col * cellW)).toBeLessThanOrEqual(0.125); + col += span.text === '你' ? 2 : 1; + } + // The far edge is still on the grid after 80 columns. + const last = spans[spans.length - 1]; + expect(Math.abs(last.left + last.width - 80 * cellW)).toBeLessThanOrEqual(0.125); }); it('sets span width to cellW', () => { @@ -67,7 +94,7 @@ describe('renderOverlay', () => { expect(span.style.width).toBe('9.5px'); }); - it('applies font styles to spans', () => { + it('applies font styles once on the line, for its spans to inherit', () => { const font: FontStyle = { fontFamily: 'Fira Code', fontSize: '16px', @@ -83,12 +110,16 @@ describe('renderOverlay', () => { // jsdom normalizes hex to rgb() expect(lineDiv.style.backgroundColor).toBe('rgb(0, 0, 0)'); + expect(lineDiv.style.fontFamily).toBe('Fira Code'); + expect(lineDiv.style.fontSize).toBe('16px'); + expect(lineDiv.style.fontWeight).toBe('bold'); + expect(lineDiv.style.color).toBe('rgb(255, 0, 0)'); + expect(lineDiv.style.letterSpacing).toBe('0.5px'); + + // Inherited, so a span sets none of them itself. const span = lineDiv.children[0] as HTMLSpanElement; - expect(span.style.fontFamily).toBe('Fira Code'); - expect(span.style.fontSize).toBe('16px'); - expect(span.style.fontWeight).toBe('bold'); - expect(span.style.color).toBe('rgb(255, 0, 0)'); - expect(span.style.letterSpacing).toBe('0.5px'); + expect(span.style.fontFamily).toBe(''); + expect(span.style.color).toBe(''); }); it('offsets first line by startCol', () => { @@ -217,47 +248,49 @@ describe('renderOverlay', () => { const lineDiv = container.children[0] as HTMLDivElement; const span = lineDiv.children[0] as HTMLSpanElement; expect(span.style.height).toBe('19px'); - expect(span.style.lineHeight).toBe('19px'); + // Set on the line and inherited by the span. + expect(lineDiv.style.lineHeight).toBe('19px'); }); - it('span top is 0px (no vertical offset / no transform)', () => { + it('span has no vertical offset and no transform', () => { const container = document.createElement('div'); renderOverlay(container, makeParams({ lines: ['a'], cellH: 19 })); const lineDiv = container.children[0] as HTMLDivElement; const span = lineDiv.children[0] as HTMLSpanElement; - expect(span.style.top).toBe('0px'); + // A flex item starts at the top of its line; nothing shifts it down. + expect(span.style.top).toBe(''); + expect(span.style.marginTop).toBe(''); // No translateY transform — sub-pixel overhang causes artifacts expect(span.style.transform).toBe(''); }); // ─── Font rendering tests ───────────────────────────────────── - it('span disables ligatures via font-feature-settings', () => { + // These are inherited properties, set once on the line for every span. + + it('line disables ligatures via font-feature-settings', () => { const container = document.createElement('div'); renderOverlay(container, makeParams({ lines: ['fi'] })); const lineDiv = container.children[0] as HTMLDivElement; - const span = lineDiv.children[0] as HTMLSpanElement; // Check cssText includes the ligature-disabling settings // jsdom may normalize whitespace; check that both liga and calt are disabled - expect(span.style.cssText).toContain('font-feature-settings:'); - expect(span.style.cssText).toContain("'liga' 0"); - expect(span.style.cssText).toContain("'calt' 0"); + expect(lineDiv.style.cssText).toContain('font-feature-settings:'); + expect(lineDiv.style.cssText).toContain("'liga' 0"); + expect(lineDiv.style.cssText).toContain("'calt' 0"); }); - it('span has text-align: center for glyph centering', () => { + it('line has text-align: center for glyph centering', () => { const container = document.createElement('div'); renderOverlay(container, makeParams({ lines: ['m'] })); const lineDiv = container.children[0] as HTMLDivElement; - const span = lineDiv.children[0] as HTMLSpanElement; - expect(span.style.textAlign).toBe('center'); + expect(lineDiv.style.textAlign).toBe('center'); }); - it('span has pointer-events: none', () => { + it('line has pointer-events: none', () => { const container = document.createElement('div'); renderOverlay(container, makeParams({ lines: ['a'] })); const lineDiv = container.children[0] as HTMLDivElement; - const span = lineDiv.children[0] as HTMLSpanElement; - expect(span.style.pointerEvents).toBe('none'); + expect(lineDiv.style.pointerEvents).toBe('none'); }); // ─── Multi-line cursor positioning ──────────────────────────── @@ -323,20 +356,18 @@ describe('renderOverlay', () => { const lineDiv = container.children[0] as HTMLDivElement; expect(lineDiv.children.length).toBe(3); - const spanA = lineDiv.children[0] as HTMLSpanElement; - expect(spanA.textContent).toBe('a'); - expect(spanA.style.left).toBe('0px'); - expect(spanA.style.width).toBe('10px'); // 1 cell + const [spanA, spanCJK, spanB] = lineSpans(lineDiv); + expect(spanA.text).toBe('a'); + expect(spanA.left).toBe(0); + expect(spanA.width).toBe(10); // 1 cell - const spanCJK = lineDiv.children[1] as HTMLSpanElement; - expect(spanCJK.textContent).toBe('你'); - expect(spanCJK.style.left).toBe('10px'); // col 1 - expect(spanCJK.style.width).toBe('20px'); // 2 cells + expect(spanCJK.text).toBe('你'); + expect(spanCJK.left).toBe(10); // col 1 + expect(spanCJK.width).toBe(20); // 2 cells - const spanB = lineDiv.children[2] as HTMLSpanElement; - expect(spanB.textContent).toBe('b'); - expect(spanB.style.left).toBe('30px'); // col 3 - expect(spanB.style.width).toBe('10px'); // 1 cell + expect(spanB.text).toBe('b'); + expect(spanB.left).toBe(30); // col 3 + expect(spanB.width).toBe(10); // 1 cell }); it('cursor position accounts for CJK width', () => { @@ -360,13 +391,11 @@ describe('renderOverlay', () => { renderOverlay(container, makeParams({ lines: ['hi你'], cellW: 8 })); const lineDiv = container.children[0] as HTMLDivElement; // h(col 0), i(col 1), 你(col 2, width 2) - const spanH = lineDiv.children[0] as HTMLSpanElement; - expect(spanH.style.left).toBe('0px'); - const spanI = lineDiv.children[1] as HTMLSpanElement; - expect(spanI.style.left).toBe('8px'); - const spanCJK = lineDiv.children[2] as HTMLSpanElement; - expect(spanCJK.style.left).toBe('16px'); - expect(spanCJK.style.width).toBe('16px'); + const [spanH, spanI, spanCJK] = lineSpans(lineDiv); + expect(spanH.left).toBe(0); + expect(spanI.left).toBe(8); + expect(spanCJK.left).toBe(16); + expect(spanCJK.width).toBe(16); }); }); @@ -503,3 +532,147 @@ describe('renderOverlay — staying on screen (totalRows)', () => { expect(container.style.top).toBe(6 * 17 + 'px'); }); }); + +describe('renderOverlay: rebuilding only the lines that changed', () => { + const lineEls = (container: HTMLDivElement) => + Array.from(container.children).filter((el) => el.tagName === 'DIV') as HTMLDivElement[]; + const text = (line: HTMLDivElement) => Array.from(line.children, (s) => s.textContent).join(''); + + it('appending to the last line keeps every earlier line element', () => { + const container = document.createElement('div'); + renderOverlay(container, makeParams({ lines: ['first', 'second', 'th'] })); + const [first, second, third] = lineEls(container); + + renderOverlay(container, makeParams({ lines: ['first', 'second', 'thi'] })); + const after = lineEls(container); + expect(after[0]).toBe(first); + expect(after[1]).toBe(second); + expect(after[2]).not.toBe(third); + expect(after.map(text)).toEqual(['first', 'second', 'thi']); + }); + + it('keeps the cursor as the last child and moves it with the text', () => { + const container = document.createElement('div'); + const params = { startCol: 0, cellW: 10 }; + renderOverlay(container, makeParams({ ...params, lines: ['ab'] })); + renderOverlay(container, makeParams({ ...params, lines: ['ab', 'c'] })); + const children = Array.from(container.children) as HTMLElement[]; + expect(children.length).toBe(3); + const cursor = children[2]; + expect(cursor.tagName).toBe('SPAN'); + expect(cursor.style.left).toBe('10px'); + expect(cursor.style.top).toBe(17 + 'px'); + expect(container.querySelectorAll(':scope > span').length).toBe(1); + }); + + it('a wrapped line coming back (backspace) removes the extra line', () => { + const container = document.createElement('div'); + renderOverlay(container, makeParams({ lines: ['abc', 'd'] })); + const [first] = lineEls(container); + renderOverlay(container, makeParams({ lines: ['abc'] })); + expect(lineEls(container)).toEqual([first]); + expect(container.children.length).toBe(2); // line + cursor + }); + + it('removes the cursor when the next render does not show one', () => { + const container = document.createElement('div'); + renderOverlay(container, makeParams({ lines: ['ab'] })); + renderOverlay(container, makeParams({ lines: ['ab'], showCursor: false })); + expect(container.children.length).toBe(1); + expect(container.children[0].tagName).toBe('DIV'); + }); + + it('a font, colour or cell size change rebuilds every line', () => { + const container = document.createElement('div'); + renderOverlay(container, makeParams({ lines: ['one', 'two'] })); + const before = lineEls(container); + + renderOverlay(container, makeParams({ lines: ['one', 'two'], font: { ...FONT, color: '#ff0000' } })); + const recoloured = lineEls(container); + expect(recoloured[0]).not.toBe(before[0]); + expect(recoloured[0].style.color).toBe('rgb(255, 0, 0)'); + + renderOverlay(container, makeParams({ lines: ['one', 'two'], font: { ...FONT, color: '#ff0000' }, cellW: 12 })); + const resized = lineEls(container); + expect(resized[1]).not.toBe(recoloured[1]); + expect(lineSpans(resized[1])[1].left).toBe(12); + }); + + it('a Unicode version switch rebuilds lines whose text did not change', () => { + const container = document.createElement('div'); + const unicode = { activeVersion: '6', getStringCellWidth: (s: string) => (s === 'W' ? 1 : 1) }; + const terminal = { unicode } as any; + renderOverlay(container, makeParams({ lines: ['aW'], cellW: 10, terminal })); + const before = lineEls(container)[0]; + + unicode.activeVersion = '11'; + unicode.getStringCellWidth = (s: string) => (s === 'W' ? 2 : 1); + renderOverlay(container, makeParams({ lines: ['aW'], cellW: 10, terminal })); + const after = lineEls(container)[0]; + expect(after).not.toBe(before); + expect(lineSpans(after)[1].width).toBe(20); + }); + + it('starts over when something else emptied the container', () => { + const container = document.createElement('div'); + renderOverlay(container, makeParams({ lines: ['one', 'two'] })); + container.innerHTML = ''; + renderOverlay(container, makeParams({ lines: ['one', 'two'] })); + expect(lineEls(container).map(text)).toEqual(['one', 'two']); + expect(container.children.length).toBe(3); + }); + + it('starts over when something else reordered the lines', () => { + const container = document.createElement('div'); + renderOverlay(container, makeParams({ lines: ['one', 'two'] })); + const [one, two] = lineEls(container); + container.insertBefore(two, one); + renderOverlay(container, makeParams({ lines: ['one', 'two'] })); + expect(lineEls(container).map(text)).toEqual(['one', 'two']); + expect(container.children.length).toBe(3); + }); + + it('starts over when something else added to the container', () => { + const container = document.createElement('div'); + renderOverlay(container, makeParams({ lines: ['one'] })); + container.appendChild(document.createElement('div')); + renderOverlay(container, makeParams({ lines: ['one'] })); + expect(container.children.length).toBe(2); + expect(text(lineEls(container)[0])).toBe('one'); + }); + + it('a composition growing on the last line leaves the lines it does not reach alone', () => { + const container = document.createElement('div'); + // 'first' (5) + 'abc' pending, composition starts at code point 8. + renderOverlay(container, makeParams({ lines: ['first', 'abcx'], compositionStart: 8 })); + const [first] = lineEls(container); + renderOverlay(container, makeParams({ lines: ['first', 'abcxy'], compositionStart: 8 })); + const after = lineEls(container); + expect(after[0]).toBe(first); + const marked = Array.from(after[1].children).filter((s) => s.hasAttribute('data-zerolag-composition')); + expect(marked.map((s) => s.textContent).join('')).toBe('xy'); + }); + + it('a composition that starts on an earlier line marks the whole later line', () => { + const container = document.createElement('div'); + renderOverlay(container, makeParams({ lines: ['abXY', 'ZW'], compositionStart: 2 })); + const [first, second] = lineEls(container); + const marked = (line: HTMLDivElement) => + Array.from(line.children) + .filter((s) => s.hasAttribute('data-zerolag-composition')) + .map((s) => s.textContent) + .join(''); + expect(marked(first)).toBe('XY'); + expect(marked(second)).toBe('ZW'); + }); + + it('two containers keep separate records', () => { + const a = document.createElement('div'); + const b = document.createElement('div'); + renderOverlay(a, makeParams({ lines: ['same'] })); + renderOverlay(b, makeParams({ lines: ['same'] })); + expect(lineEls(a)[0]).not.toBe(lineEls(b)[0]); + expect(text(lineEls(a)[0])).toBe('same'); + expect(text(lineEls(b)[0])).toBe('same'); + }); +}); From 9432d9f9e21eb1946d14ad8a611bedd2135526cf Mon Sep 17 00:00:00 2001 From: Devvyn <22340871+opticon454@users.noreply.github.com> Date: Sun, 25 Oct 2026 17:23:55 +0800 Subject: [PATCH 30/68] feat(git): a Fetch button in the Git status header, and an interval for it app-wide and per case Fetch is the only git command Codeman runs that writes, and the only one that runs a repository's own fetch-post hook, so it stays behind a click and off by default. The Git status window's header row gains Fetch; App Settings -> Bottom bar gains the switch and the interval, synced because the scheduler acts on them, and Session Options -> Git overrides both per case. The scheduler reads settings fresh each tick, times the interval from the start of a run, never overlaps runs, and re-checks the Docker and remote rules every tick before any git runs. Nothing but remote-tracking refs moves: HEAD, the index and the working tree are proven unchanged against real git while origin/main advances. Tests: src/git-fetch.ts and the scheduler, the per-case settings route, the synced/display setting split, the two new i18n rows, and the Fetch block of the Git status browser suite. --- CLAUDE.md | 6 +- README.md | 2 +- docs/api-reference.md | 8 +- docs/wiki/Settings-Reference.md | 16 +- docs/wiki/Working-With-Files.md | 23 +- src/config/server-timing.ts | 11 + src/git-fetch-scheduler.ts | 166 +++++++++ src/git-fetch.ts | 246 ++++++++++++ src/git-workspace-status.ts | 14 +- src/hooks-config.ts | 20 + src/session.ts | 49 +++ src/types/session.ts | 11 + src/web/public/app.js | 3 + src/web/public/git-status-ui.js | 88 ++++- src/web/public/i18n.js | 29 +- src/web/public/index.html | 70 +++- src/web/public/session-ui.js | 89 +++++ src/web/public/settings-ui.js | 12 + src/web/public/styles.css | 48 ++- src/web/routes/git-status-routes.ts | 45 ++- src/web/routes/session-routes.ts | 33 ++ src/web/schemas.ts | 19 + src/web/server.ts | 56 +++ test/agent-inbox-server-wiring.test.ts | 1 + test/app-settings-structure.test.ts | 42 +++ test/git-fetch-i18n.test.ts | 101 +++++ test/git-fetch-scheduler.test.ts | 308 ++++++++++++++++ test/git-fetch.test.ts | 349 ++++++++++++++++++ test/git-status.browser.test.ts | 145 ++++++++ test/mocks/mock-session.ts | 17 + test/routes/git-status-routes.test.ts | 171 +++++++++ .../session-routes-git-fetch-settings.test.ts | 104 ++++++ test/session-options-structure.test.ts | 36 +- test/session-settings-recovery.test.ts | 32 ++ test/tab-layout-settings-i18n.test.ts | 11 +- 35 files changed, 2341 insertions(+), 40 deletions(-) create mode 100644 src/git-fetch-scheduler.ts create mode 100644 src/git-fetch.ts create mode 100644 test/git-fetch-i18n.test.ts create mode 100644 test/git-fetch-scheduler.test.ts create mode 100644 test/git-fetch.test.ts create mode 100644 test/routes/session-routes-git-fetch-settings.test.ts diff --git a/CLAUDE.md b/CLAUDE.md index bd2594acd..b267b4200 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -234,7 +234,9 @@ Codeman is a Claude Code session manager with web interface and autonomous Ralph **Files panel search** (COD-236, the `q` param on `GET /api/sessions/:id/files`): `compileFileQuery()` (`utils/file-query.ts`, pure) compiles the query into a predicate the server-side walk prunes with; a query returns a FLAT match list and the walk recurses past non-matching directories. An empty, whitespace-only or overlong (`MAX_QUERY_LENGTH`, 256) query compiles to `null`, keeping the default tree response byte-identical. ⚠️ **Never compile a glob into a RegExp** (`*a*a*a…` backtracks and freezes the event loop for the whole server): `globMatch()` is a two-pointer wildcard walk. → [architecture-invariants#files-panel-search](docs/architecture-invariants.md#files-panel-search) -**Git status indicator** (`showGitStatus`, per-device, default OFF; `src/git-workspace-status.ts`, `routes/git-status-routes.ts`, `git-status-ui.js`): the bottom-bar indicator and its panel read `GET /api/sessions/:id/git-status` and `/git-diff`, read-only and offline (it never fetches and never writes). ⚠️ git never runs on a repository at or inside a Docker case workspace (walk-up, scan and diff alike, and a cached repository list is re-checked against the CURRENT Docker roots), since a container could plant a clean filter that runs on the host; remote and Docker sessions answer `unsupported`. ⚠️ `git-diff` takes `repo`/`path`/`kind` only as keys matched against the workspace's own repository list (`findWorkspaceRepo()`) and that repository's current status, never as paths. ⚠️ Keep `--no-optional-locks`, `core.fsmonitor=false` and `log.showSignature=false` on every call and `--no-ext-diff --no-textconv` on diffs; clean filters still run, which is why the Docker rule exists. ⚠️ An enclosing repository at `$HOME` or above is ignored (`isUnrelatedAncestor()`), and is identified by one cached `rev-parse` before any full status runs. Everything git supplies renders via `textContent`. Tests: `test/git-workspace-status.test.ts`, `test/routes/git-status-routes.test.ts`, and `test/git-status.browser.test.ts` (browser suite, not in the gate). +**Git status indicator** (`showGitStatus`, per-device, default OFF; `src/git-workspace-status.ts`, `routes/git-status-routes.ts`, `git-status-ui.js`): the bottom-bar indicator and its panel read `GET /api/sessions/:id/git-status` and `/git-diff`, read-only and offline on its own (it never fetches; fetching is the separate path below). ⚠️ git never runs on a repository at or inside a Docker case workspace (walk-up, scan and diff alike, and a cached repository list is re-checked against the CURRENT Docker roots), since a container could plant a clean filter that runs on the host; remote and Docker sessions answer `unsupported`. ⚠️ `git-diff` takes `repo`/`path`/`kind` only as keys matched against the workspace's own repository list (`findWorkspaceRepo()`) and that repository's current status, never as paths. ⚠️ Keep `--no-optional-locks`, `core.fsmonitor=false` and `log.showSignature=false` on every call and `--no-ext-diff --no-textconv` on diffs; clean filters still run, which is why the Docker rule exists. ⚠️ An enclosing repository at `$HOME` or above is ignored (`isUnrelatedAncestor()`), and is identified by one cached `rev-parse` before any full status runs. Everything git supplies renders via `textContent`. Tests: `test/git-workspace-status.test.ts`, `test/routes/git-status-routes.test.ts`, and `test/git-status.browser.test.ts` (browser suite, not in the gate). + +**Git fetch** (`src/git-fetch.ts`, `src/git-fetch-scheduler.ts`, `routes/git-status-routes.ts`, the panel's **Fetch** button): `POST /api/sessions/:id/git-fetch` is the ONLY git command Codeman runs that WRITES, and the only one that runs a repository's own `fetch-post` hook — which is why it is opt-in and never runs on a timer until the user turns it on. ⚠️ Fetch updates remote-tracking refs only; it must never commit, check out, merge or touch the working tree, since an agent is often working in the same repository. ⚠️ The same Docker rule as above applies BEFORE any git runs (a repository at or inside a Docker case workspace is refused), and remote sessions answer `unsupported`. ⚠️ Keep it non-interactive (`gitNonInteractiveEnv()`: `GIT_TERMINAL_PROMPT=0` + BatchMode ssh) or a fetch hangs on an invisible login prompt, and bounded: `MAX_REPOS`/`MAX_REPOS_LIMIT` (12/50), `FETCH_CONCURRENCY` (4), `MAX_FETCH_OUTPUT_BYTES` (2 MB) and the git timeout per spawn. ⚠️ `buildFetchArgs()` is `['fetch', '--quiet']` and must stay verb-invariant (`test/git-workspace-status.test.ts`). ⚠️ Failure text reaching a client is control-stripped and credential-redacted (`sanitizeGitOutput`) and the browser writes it with `textContent`. Scheduler semantics: settings are read fresh each tick (`GIT_FETCH_TICK_INTERVAL_MS`, 15 s), a per-case override wins even while the app-wide switch is off, the interval is timed from the run START, runs never overlap, and remote/Docker cases are re-checked every tick. ⚠️ The two settings (`gitFetchEnabled`, `gitFetchIntervalSeconds`, off/300 by default, clamped 30–3600) are **synced**, not per-device: they must stay out of `displayKeys` and out of the strip destructure in settings-ui.js, or the scheduler reads nobody's value. Per-case state rides `SessionUpdated` (no new SSE event) as `gitFetch`. Tests: `test/git-fetch.test.ts`, `test/git-fetch-scheduler.test.ts`, `test/routes/session-routes-git-fetch-settings.test.ts`, `test/git-fetch-i18n.test.ts`, `test/app-settings-structure.test.ts`, `test/session-options-structure.test.ts`, and the Fetch block of `test/git-status.browser.test.ts` (browser suite, not in the gate). **Raw file bodies are streamed and range-aware**: `file-raw`, the attachments `/raw` route, `GET /api/download` and the rendered-HTML route `GET /html-view//...` share `sendFileBody()`, advertise `Accept-Ranges: bytes` and answer `Range` with `206` + `Content-Range` (single-range, parser in `src/web/http-range.ts`); without it `
@@ -1331,6 +1332,12 @@

Session Options

Summary + +

These options belong to the selected session and save as you change them.

@@ -1719,6 +1726,53 @@

Summary

+ + + @@ -2196,7 +2250,7 @@

Header & Panels

Git status device - Shows, at the right of the bottom bar, when the active session's repository (or each repository inside its folder, up to two levels down) has uncommitted files or commits that are not pushed. Click it for the list. Read-only: Codeman never fetches or changes the repository. Not shown for Docker or remote sessions. Off by default. + Shows, at the right of the bottom bar, when the active session's repository (or each repository inside its folder, up to two levels down) has uncommitted files or commits that are not pushed. Click it for the list. Read-only apart from Fetch: Codeman never commits or changes the repository, and `git fetch` — the one git command here that writes — runs only when you click Fetch in that window, or on the interval below if you turned it on. Not shown for Docker or remote sessions. Off by default.
@@ -2221,6 +2275,20 @@

Header & Panels

+
+
+ Git fetch on an interval synced + Runs `git fetch` for the repositories the Git window lists on a timer, so the "behind" number is current without a click. Off by default: this is the one git command Codeman runs that writes, and the one that runs a repository's own hooks, so nothing runs on a timer until you turn it on. A case can override both the switch and the interval in Session Options → Git. +
+ +
+
+
+ Git fetch: how often synced + Seconds between runs for a case that has no override of its own (30 to 3600, default 300). Only a case that is on — here, or in Session Options → Git — is fetched. +
+ +
diff --git a/src/web/public/session-ui.js b/src/web/public/session-ui.js index c4dde6245..10f1ec86c 100644 --- a/src/web/public/session-ui.js +++ b/src/web/public/session-ui.js @@ -2685,6 +2685,9 @@ Object.assign(CodemanApp.prototype, { document.getElementById('modalKeepWarmEnabled').checked = session.keepWarm?.enabled ?? session.keepWarmEnabled ?? false; this.updateKeepWarmStatus(sessionId); + // Git: the case's own choice if it has one, otherwise the app setting. updateGitFetchStatus is also + // what SSE calls when a scheduled run lands, so the rows stay current while the modal is open. + this.updateGitFetchStatus(sessionId); document.getElementById('modalImageWatcherEnabled').checked = session.imageWatcherEnabled ?? true; document.getElementById('modalFlickerFilterEnabled').checked = session.flickerFilterEnabled ?? false; @@ -2741,6 +2744,12 @@ Object.assign(CodemanApp.prototype, { if (respawnTabBtn) respawnTabBtn.style.display = ''; } + // Git is local git only: a remote (SSH) or Docker case answers 'unsupported' on both git routes and is + // never fetched by the interval scheduler, so its Git tab has nothing to say and its rail entry goes + // hidden the same way. + const gitTabBtn = document.querySelector('#sessionOptionsModal .set-rail-item[data-tab="git"]'); + if (gitTabBtn) gitTabBtn.style.display = session.remote || session.docker ? 'none' : ''; + // Populate Ralph Wiggum form with current session values (skip for external CLI sessions) if (!isExternalCli) { const ralphState = this.ralphStates.get(sessionId); @@ -3022,6 +3031,85 @@ Object.assign(CodemanApp.prototype, { } }, + /** + * The Git tab: the case's last scheduled run, and which values its rows show. A case with no choice of its + * own follows the app setting, so its switch and interval display the app's numbers and go inert — the same + * inert-row pattern as updateKeepWarmStatus above — because changing them here is what makes the case stop + * following. The app numbers come from the settings cache, so what this shows can be one settings load + * behind; the server decides from a fresh read, not from what the page displays. + */ + updateGitFetchStatus(sessionId) { + const statusEl = document.getElementById('gitFetchStatus'); + const followBox = document.getElementById('modalGitFetchFollow'); + const box = document.getElementById('modalGitFetchEnabled'); + const num = document.getElementById('modalGitFetchInterval'); + if (!statusEl || !followBox || !box || !num || this.editingSessionId !== sessionId) return; + const session = this.sessions.get(sessionId); + if (!session) return; + + const app = this.loadAppSettingsFromStorage() || {}; + // No choice of its own on either field means the case follows the app. A partial one — its own switch, + // the app's interval — means it does not, and the row that still follows shows the app's value. + const follows = session.gitFetchEnabled === undefined && session.gitFetchIntervalSeconds === undefined; + const enabled = follows ? app.gitFetchEnabled === true : session.gitFetchEnabled ?? false; + const interval = follows ? app.gitFetchIntervalSeconds ?? 300 : session.gitFetchIntervalSeconds ?? 300; + + followBox.checked = follows; + box.checked = enabled; + num.value = interval; + for (const el of [box, num]) { + el.closest('.set-row')?.classList.toggle('set-row-disabled', follows); + el.disabled = follows; + } + + const run = session.gitFetch; + const parts = run + ? [ + `Last run ${this._gitFetchAgo(run.ranAt)}${run.trigger === 'interval' ? ' on the interval' : ' by click'}`, + run.state === 'ok' ? `${run.repos} repositories` : run.state, + run.failed ? `${run.failed} failed` : '', + ] + : [follows ? 'Nothing fetched on a timer yet, and this case follows the app setting.' : 'Nothing fetched on a timer yet for this case.']; + statusEl.textContent = parts.filter(Boolean).join(' · '); + statusEl.hidden = false; + }, + + /** 'just now', '4m', '2h' — the short stamps the tab rows use, measured from a run's start. */ + _gitFetchAgo(ts) { + const seconds = Math.max(0, Math.round((Date.now() - ts) / 1000)); + if (seconds < 60) return seconds < 10 ? 'just now' : `${seconds}s`; + if (seconds < 3600) return `${Math.round(seconds / 60)}m`; + return `${Math.round(seconds / 3600)}h`; + }, + + async autoSaveGitFetch() { + if (!this.editingSessionId) return; + const follow = document.getElementById('modalGitFetchFollow').checked; + // `null` on the route means "no choice of your own", which is what following the app setting is. + const body = follow + ? { enabled: null, intervalSeconds: null } + : { + enabled: document.getElementById('modalGitFetchEnabled').checked, + intervalSeconds: Math.min( + 3600, + Math.max(30, parseInt(document.getElementById('modalGitFetchInterval').value, 10) || 300) + ), + }; + try { + const data = await this._apiPost(`/api/sessions/${this.editingSessionId}/git-fetch-settings`, body); + const session = this.sessions.get(this.editingSessionId); + const saved = data?.gitFetchSettings; + if (session && saved) { + session.gitFetchEnabled = saved.enabled ?? undefined; + session.gitFetchIntervalSeconds = saved.intervalSeconds ?? undefined; + } + this.updateGitFetchStatus(this.editingSessionId); + this.showToast('Git fetch settings saved', 'success'); + } catch (err) { + this.showToast('Failed to save Git fetch settings: ' + err.message, 'error'); + } + }, + async toggleSessionImageWatcher() { if (!this.editingSessionId) return; const enabled = document.getElementById('modalImageWatcherEnabled').checked; @@ -3160,6 +3248,7 @@ Object.assign(CodemanApp.prototype, { document.getElementById('context-tab').classList.toggle('hidden', tabName !== 'context'); document.getElementById('ralph-tab').classList.toggle('hidden', tabName !== 'ralph'); document.getElementById('summary-tab').classList.toggle('hidden', tabName !== 'summary'); + document.getElementById('git-tab').classList.toggle('hidden', tabName !== 'git'); // A switched-to section starts at its own top, not at the scroll offset the // previous one was left at. diff --git a/src/web/public/settings-ui.js b/src/web/public/settings-ui.js index db745f450..0faaaa8cd 100644 --- a/src/web/public/settings-ui.js +++ b/src/web/public/settings-ui.js @@ -602,6 +602,11 @@ Object.assign(CodemanApp.prototype, { document.getElementById('appSettingsGitStatusTree').checked = settings.gitStatusTree ?? defaults.gitStatusTree ?? true; document.getElementById('appSettingsGitStatusMaxRepos').value = settings.gitStatusMaxRepos ?? defaults.gitStatusMaxRepos ?? 12; document.getElementById('appSettingsGitStatusTimeout').value = settings.gitStatusTimeoutSeconds ?? defaults.gitStatusTimeoutSeconds ?? 30; + // Git fetch on an interval: synced, default OFF, interval default 300 s (DEFAULT_GIT_FETCH_INTERVAL_SECONDS). + // Synced means the SERVER decides whether to fetch, so these keys stay out of the per-device display list + // below and are not stripped from a save — a stripped key would never reach the scheduler. + document.getElementById('appSettingsGitFetchEnabled').checked = settings.gitFetchEnabled === true; + document.getElementById('appSettingsGitFetchIntervalSeconds').value = settings.gitFetchIntervalSeconds ?? 300; // Gesture control lives in the Input section (alongside Local Echo / CJK Input) // but is only available when the instance runs with CODEMAN_GESTURE=1 (server sets // window.__codemanGestureAvailable). Hide just this item otherwise so the toggle @@ -2948,6 +2953,13 @@ Object.assign(CodemanApp.prototype, { // Clamped here and again on the server; an empty or odd value falls back to the default. gitStatusMaxRepos: Math.min(50, Math.max(1, parseInt(document.getElementById('appSettingsGitStatusMaxRepos').value, 10) || 12)), gitStatusTimeoutSeconds: Math.min(120, Math.max(5, parseInt(document.getElementById('appSettingsGitStatusTimeout').value, 10) || 30)), + // Clamped to the schema's bounds here too: a value outside them fails the whole save, and these keys + // must survive the strip below because the server's scheduler is what reads them. + gitFetchEnabled: document.getElementById('appSettingsGitFetchEnabled').checked, + gitFetchIntervalSeconds: Math.min( + 3600, + Math.max(30, parseInt(document.getElementById('appSettingsGitFetchIntervalSeconds').value, 10) || 300) + ), gestureControlEnabled: document.getElementById('appSettingsGestureControl').checked, subagentTrackingEnabled: document.getElementById('appSettingsSubagentTracking').checked, subagentActiveTabOnly: document.getElementById('appSettingsSubagentActiveTabOnly').checked, diff --git a/src/web/public/styles.css b/src/web/public/styles.css index 49af0d3e8..817761c27 100644 --- a/src/web/public/styles.css +++ b/src/web/public/styles.css @@ -7151,6 +7151,33 @@ html[data-skin="og"] .welcome-primary:hover { background: var(--bg-hover); } +/* A Fetch button cannot be clicked twice while its fetch is running, and is not offered for a session + there is nothing to fetch for (no repository, remote or Docker). */ +.btn-icon-sm[disabled] { + color: var(--text-dim); + opacity: 0.45; + cursor: default; +} + +.btn-icon-sm[disabled]:hover { + background: none; +} + +.btn-icon-sm[aria-busy='true'] { + color: var(--text); + animation: btn-icon-busy 1.1s ease-in-out infinite; +} + +@keyframes btn-icon-busy { + 0%, + 100% { + opacity: 0.55; + } + 50% { + opacity: 0.9; + } +} + .settings-section { margin-bottom: 1.25rem; padding-bottom: 1rem; @@ -22167,7 +22194,8 @@ html .toolbar .btn-git-status[aria-expanded='true'] { .git-status-empty, .git-status-ok, .git-status-note, -.git-status-more { +.git-status-more, +.git-status-fetch-note { color: var(--text-muted); padding: 0.25rem 0; } @@ -22176,6 +22204,24 @@ html .toolbar .btn-git-status[aria-expanded='true'] { color: rgb(34, 197, 94); } +/* The last Fetch's result: a summary line, then a row per repository it could not fetch. */ +.git-status-fetch-note { + margin-top: 0.6rem; + padding-top: 0.4rem; + border-top: 1px solid var(--border); +} + +.git-status-fetch-summary { + margin-bottom: 0.2rem; +} + +.git-status-fetch-row { + display: flex; + flex-wrap: wrap; + gap: 0.35rem; + padding: 0.1rem 0; +} + .git-status-branchline { display: flex; flex-wrap: wrap; diff --git a/src/web/routes/git-status-routes.ts b/src/web/routes/git-status-routes.ts index 0d6d8cbc7..39c475f59 100644 --- a/src/web/routes/git-status-routes.ts +++ b/src/web/routes/git-status-routes.ts @@ -1,18 +1,22 @@ /** - * @fileoverview `GET /api/sessions/:id/git-status`: what the session's workspace has not committed or - * pushed (src/git-workspace-status.ts), for the bottom-bar Git indicator and its panel. The answer is + * @fileoverview The Git panel's routes: `GET /api/sessions/:id/git-status` (what the session's workspace + * has not committed or pushed, src/git-workspace-status.ts) and `POST /api/sessions/:id/git-fetch` (the + * panel's Fetch button, src/git-fetch.ts), for the bottom-bar Git indicator and its window. The answer is * an overview: the enclosing repository, or each repository found below a folder that holds several * projects (see `getGitWorkspaceOverview` for exactly which). * - * Read-only and offline: it never fetches and never runs a git write command. A remote (SSH) or - * Docker session is not inspected and answers `state: 'unsupported'`, and neither is any repository at or - * inside a Docker case workspace (a container can write there, and git here would run on the host). Ownership goes through + * Read-only and offline: the status route never fetches and never runs a git write command. Fetching is a + * separate, user-triggered path (`POST /api/sessions/:id/git-fetch`, src/git-fetch.ts) that the panel calls + * only when the user clicks Fetch. A remote (SSH) or Docker session is not inspected and answers + * `state: 'unsupported'` on both routes, and neither is any repository at or inside a Docker case + * workspace (a container can write there, and git here would run on the host). Ownership goes through * `findSessionOrFail`, like every session-scoped route. */ import type { FastifyInstance } from 'fastify'; import { ApiErrorCode, createErrorResponse, getErrorMessage, type ApiResponse } from '../../types.js'; import { redactGitCredentials } from '../../git-clone.js'; +import { emptyReport, fetchGitWorkspaceOverview, type GitFetchReport } from '../../git-fetch.js'; import { readDockerCases } from '../../docker-hosts.js'; import { getDataDir } from '../../config/instance.js'; import { findSessionOrFail } from '../route-helpers.js'; @@ -30,8 +34,12 @@ import { } from '../../git-workspace-status.js'; import type { SessionPort } from '../ports/index.js'; -/** Host paths of every Docker case workspace: repositories at or inside these are never inspected. */ -const defaultDockerWorkspaces = async (): Promise => +/** + * Host paths of every Docker case workspace: repositories at or inside these are never inspected. + * Exported so the interval scheduler (src/git-fetch-scheduler.ts) uses the SAME list — the Docker rule has + * to be one rule, checked by every path that could run git. + */ +export const defaultDockerWorkspaces = async (): Promise => (await readDockerCases(getDataDir()).catch(() => [])).map((c) => c.hostWorkspacePath).filter(Boolean); /** @@ -119,4 +127,27 @@ export function registerGitStatusRoutes( ); } }); + + // Fetch, and only because the user clicked Fetch in the panel (src/git-fetch.ts): one + // `git fetch --quiet` per repository the panel would show, through the same resolver so the two cannot + // disagree about which repositories belong to the workspace. This is the only git command Codeman runs + // that writes, and it writes remote-tracking refs only — nothing commits, checks out or merges, so an + // agent working in the same repository is not disturbed. Remote and Docker sessions answer `unsupported` + // like the status route, and a repository a container can write to is refused before any git runs. + app.post('/api/sessions/:id/git-fetch', async (req): Promise> => { + const { id } = req.params as { id: string }; + const query = req.query as { maxRepos?: unknown; timeout?: unknown }; + const limits = limitsFrom(query); + const session = findSessionOrFail(ctx, id, req); + if (session.remote) return { success: true, data: emptyReport('unsupported', { reason: 'remote' }) }; + if (session.docker) return { success: true, data: emptyReport('unsupported', { reason: 'docker' }) }; + return { + success: true, + data: await fetchGitWorkspaceOverview(session.workingDir, { + git, + ...limits, + dockerWorkspaces: await dockerWorkspaces(), + }), + }; + }); } diff --git a/src/web/routes/session-routes.ts b/src/web/routes/session-routes.ts index 7569a4dcb..337725a65 100644 --- a/src/web/routes/session-routes.ts +++ b/src/web/routes/session-routes.ts @@ -57,6 +57,7 @@ import { AutoCompactSchema, AutoResumeSchema, KeepWarmSchema, + GitFetchSettingsSchema, PinSessionSchema, ImageWatcherSchema, FlickerFilterSchema, @@ -3608,6 +3609,38 @@ export function registerSessionRoutes( }; }); + // ========== Git fetch on an interval ========== + + // Per-case override for the fetch interval (src/git-fetch-scheduler.ts). `null` on either field clears + // the case's choice so the app-wide setting applies again, and a field left out of the body leaves the + // other half as it was: the Session Options rows save on their own. Refused for a remote or Docker + // session, exactly as the Git panel excludes them — a container can write to the repository it mounts, + // so git there would run that repository's own configuration on the host. + app.post('/api/sessions/:id/git-fetch-settings', async (req) => { + const { id } = req.params as { id: string }; + const body = parseBody(GitFetchSettingsSchema, req.body, 'Invalid request body'); + const session = findSessionOrFail(ctx, id, req); + if (session.remote || session.docker) { + return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Git fetch does not run for remote or Docker sessions'); + } + + const enabled = 'enabled' in body ? (body.enabled ?? undefined) : session.gitFetchEnabled; + const intervalSeconds = + 'intervalSeconds' in body ? (body.intervalSeconds ?? undefined) : session.gitFetchIntervalSeconds; + session.setGitFetchSettings(enabled, intervalSeconds); + persistAndBroadcastSession(ctx, session); + + return { + success: true, + data: { + gitFetchSettings: { + enabled: session.gitFetchEnabled ?? null, + intervalSeconds: session.gitFetchIntervalSeconds ?? null, + }, + }, + }; + }); + // ========== Auto-Resume (usage-limit pause) ========== app.post('/api/sessions/:id/auto-resume', async (req) => { diff --git a/src/web/schemas.ts b/src/web/schemas.ts index 8336a2e3a..e5a2be118 100644 --- a/src/web/schemas.ts +++ b/src/web/schemas.ts @@ -1488,6 +1488,16 @@ export const SettingsUpdateSchema = z promptCacheKeepWarm: z.boolean().optional(), /** Hours after the last real prompt during which keep-alives are sent (default 2, DEFAULT_KEEP_WARM_MAX_HOURS). */ promptCacheKeepWarmMaxHours: z.number().min(0.5).max(24).optional(), + /** + * Git fetch on an interval (src/git-fetch-scheduler.ts): `git fetch` for the repositories the Git panel + * lists, run on a timer instead of only on the panel's Fetch click. SYNCED, default OFF, because the + * server is what runs it: `git fetch` is the only git command Codeman runs that writes, and the only + * one that runs a repository's own hooks, so nothing runs on a timer until the user turns this on. + * Cap in seconds between runs (30 to 3600, default 300). A case can override both in Session Options + * → Git; `null` there clears the override. + */ + gitFetchEnabled: z.boolean().optional(), + gitFetchIntervalSeconds: z.number().int().min(30).max(3600).optional(), /** * Spawned tabs follow their parent: a session created by another one keeps * following its parent's tab (into its group, moving with it) until moved by @@ -1928,6 +1938,15 @@ export const AutoResumeSchema = z.object({ /** `POST /api/sessions/:id/keep-warm`: `null` clears the session's choice so the app-wide default applies. */ export const KeepWarmSchema = z.object({ enabled: z.boolean().nullable() }); +/** + * `POST /api/sessions/:id/git-fetch-settings`: `null` clears the session's choice so the app-wide default + * applies. Both fields are optional, so a row that saves on its own does not clear the other. + */ +export const GitFetchSettingsSchema = z.object({ + enabled: z.boolean().nullable().optional(), + intervalSeconds: z.number().int().min(30).max(3600).nullable().optional(), +}); + /** POST /api/sessions/:id/pin (COD-139) — explicit pin state for idempotency. */ export const PinSessionSchema = z.object({ pinned: z.boolean(), diff --git a/src/web/server.ts b/src/web/server.ts index 2b3e582c4..aa1e8f5db 100644 --- a/src/web/server.ts +++ b/src/web/server.ts @@ -100,6 +100,7 @@ import { getLifecycleLog } from '../session-lifecycle-log.js'; import { applyWorkspaceHooks, pruneAgentSessionPreambles, + readGitFetchSettings, readKeepWarmSettingsOrThrow, readPlanUsageTelemetryEnabled, removeAgentSessionPreamble, @@ -123,6 +124,9 @@ import { AI_CHECK_MODEL } from '../config/ai-defaults.js'; import { approvalInbox } from './approval-inbox.js'; import { SCREEN_BLOCKED_SOURCE } from './screen-blocked.js'; import { PromptCacheKeepWarmService } from '../prompt-cache-keepwarm.js'; +import { fetchGitWorkspaceOverview } from '../git-fetch.js'; +import { GitFetchScheduler } from '../git-fetch-scheduler.js'; +import { defaultDockerWorkspaces } from './routes/git-status-routes.js'; import { composerIsEmpty, plainFrame } from '../session-submit-verifier.js'; import { detectUsageLimitPause } from '../usage-limit-patterns.js'; import { stopDeepSeekWeb } from '../deepseek-web-server.js'; @@ -293,6 +297,7 @@ import { STATS_COLLECTION_INTERVAL_MS, INACTIVITY_TIMEOUT_MS, CRON_TICK_INTERVAL, + GIT_FETCH_TICK_INTERVAL_MS, } from '../config/server-timing.js'; /** @@ -389,6 +394,28 @@ export class WebServer extends EventEmitter { }, log: (message) => console.log(message), }); + // Git fetch on an interval (git-fetch-scheduler.ts): the due-tracking lives in the service; the server + // lends it the facts a decision needs and stores what it reports on the session. A field initializer for + // the same reason as the keep-warm one above, and the scheduler reads the app setting FRESH on every tick + // — that is what makes a live toggle take effect on the next tick, and what makes turning it off actually + // stop the fetching. Default OFF, so an install that never enabled it spawns nothing at all. + private readonly gitFetchScheduler = new GitFetchScheduler({ + sessionIds: () => this.sessions.keys(), + getSession: (id) => this.sessions.get(id), + readSettings: readGitFetchSettings, + // The same Docker list the panel's routes use: the rule has to be one rule. + dockerWorkspaces: defaultDockerWorkspaces, + fetch: (cwd, opts) => fetchGitWorkspaceOverview(cwd, opts), + publish: (id, status) => { + const session = this.sessions.get(id); + if (!session?.setGitFetchStatus(status)) return; + // No new SSE event: the status rides the session state the page already listens for, and the Git + // panel polls on its own clock. Persisted, because what a run found is the panel's current truth. + this.persistSessionState(session); + this.broadcast(SseEvent.SessionUpdated, this.getSessionStateWithRespawn(session)); + }, + log: (message) => console.log(message), + }); /** * Wake-on-LAN registry, returned by `registerSessionRoutes`. Held for its LIFETIME * only — `drop()` on session cleanup, `stop()` on shutdown. Waking from here would @@ -1242,6 +1269,21 @@ export class WebServer extends EventEmitter { registerRebootRestoreRoutes(this.app, ctx); registerReadMyMindRoutes(this.app, ctx); registerGitStatusRoutes(this.app, ctx); + // Git fetch on an interval: this is only the wake-up, the scheduler itself decides what is due. Off in + // tests for the same reason as the polls below — a test drives `tick()` directly instead of waiting on + // a timer the harness would have to keep alive. The setting is read FRESH inside `tick()`, so turning + // it on takes effect on the next tick and turning it off actually stops the fetching. + if (!this.testMode) { + this.cleanup.setInterval( + () => { + this.gitFetchScheduler.tick().catch((err) => { + console.error('[git fetch] interval tick failed:', getErrorMessage(err)); + }); + }, + GIT_FETCH_TICK_INTERVAL_MS, + { description: 'git fetch interval checker' } + ); + } registerStatusTelemetryRoutes(this.app, ctx); registerSystemRoutes(this.app, ctx); registerCaseRoutes(this.app, ctx); @@ -1499,6 +1541,9 @@ export class WebServer extends EventEmitter { ): Promise { const session = this.sessions.get(sessionId); this.keepWarmService.remove(sessionId); + // A closed case takes its place in the fetch schedule with it: otherwise a detached-and-forgotten id + // would still count as tracked, and its next run would land on a session that no longer exists. + this.gitFetchScheduler.remove(sessionId); const pinned = session?.pinned === true || this.store.getSession(sessionId)?.pinned === true; if (coordinateLayout && session && killMux && !pinned) { return this.tabLayouts.runSessionDeletion([{ id: sessionId, owner: session.owner }], () => @@ -3540,6 +3585,9 @@ export class WebServer extends EventEmitter { if (saved.autoClearEnabled !== undefined || saved.autoClearThreshold !== undefined) { session.setAutoClear(saved.autoClearEnabled ?? false, saved.autoClearThreshold); } + if (saved.gitFetchEnabled !== undefined || saved.gitFetchIntervalSeconds !== undefined) { + session.setGitFetchSettings(saved.gitFetchEnabled, saved.gitFetchIntervalSeconds); + } if (saved.autoResumeEnabled) { // The stamp is re-armed by default, because a Codeman restart leaves the // limit footer un-reprinted and dropping it there would strand the pause. @@ -3762,6 +3810,11 @@ export class WebServer extends EventEmitter { // below, a future `expiresAt` re-arms the timer once the pane is attached. lastRealPromptAt: savedState?.lastRealPromptAt, keepWarmEnabled: savedState?.keepWarmEnabled, + // The case's own fetch switch and interval, carried over rather than reset to the app default: + // the choice is the user's and outlives the run that made it. The scheduler reads them fresh + // on its next tick, so a restored case is back on its own schedule without a restart. + gitFetchEnabled: savedState?.gitFetchEnabled, + gitFetchIntervalSeconds: savedState?.gitFetchIntervalSeconds, // Conversations this pane provably owned, oldest first. Its tail is // the conversation the CLI was on when the server stopped, which is // what a re-attach must point the viewer at instead of the launch id. @@ -4233,6 +4286,9 @@ export class WebServer extends EventEmitter { sessionWaits.cancelEverything(); approvalInbox.stop(); this.keepWarmService.stop(); + // Stops the fetch sweep from starting another run while the server is closing; a git process already + // running is left to finish, which is what a fetch does to a repository nothing else is touching. + this.gitFetchScheduler.stop(); // A post within the debounce window before shutdown would otherwise not reach disk. // Flushed again after `app.close()` below, for a post that lands in between. agentInboxPersistence.flush(); diff --git a/test/agent-inbox-server-wiring.test.ts b/test/agent-inbox-server-wiring.test.ts index 6cb416cd1..a0987815c 100644 --- a/test/agent-inbox-server-wiring.test.ts +++ b/test/agent-inbox-server-wiring.test.ts @@ -29,6 +29,7 @@ function bareServer(live: string[] = [], persisted: string[] = []): CleanupServe Object.assign(server, { sessions: new Map(live.map((id) => [id, {}])), keepWarmService: { remove: () => {} }, + gitFetchScheduler: { remove: () => {} }, store: { getSession: () => null, getSessions: () => Object.fromEntries(persisted.map((id) => [id, {}])), diff --git a/test/app-settings-structure.test.ts b/test/app-settings-structure.test.ts index 4045d8129..316d621b6 100644 --- a/test/app-settings-structure.test.ts +++ b/test/app-settings-structure.test.ts @@ -12,6 +12,7 @@ import { describe, it, expect } from 'vitest'; import { readFileSync } from 'node:fs'; import { resolve } from 'node:path'; +import { SettingsUpdateSchema } from '../src/web/schemas.js'; const publicDir = resolve(import.meta.dirname, '../src/web/public'); const html = readFileSync(resolve(publicDir, 'index.html'), 'utf8'); @@ -195,6 +196,47 @@ describe('App Settings modal structure', () => { } }); + /** + * Git fetch on an interval (src/git-fetch-scheduler.ts). Both keys are SYNCED, not display keys, because + * the server's scheduler is what acts on them: kept per-device, the scheduler would read nobody's number. + * Off by default, and the interval clamped on save to the same bounds the schema accepts. + */ + it('keeps the Git fetch rows synced, off by default, and inside the bounds the scheduler acts on', () => { + const modal = settingsModal(); + const bar = modal.match(/

Bottom bar<\/h4>([\s\S]*?)(?=
)/)?.[1]; + expect(bar, 'the Git fetch rows are not in the Bottom bar group').toBeTruthy(); + for (const id of ['appSettingsGitFetchEnabled', 'appSettingsGitFetchIntervalSeconds']) { + const at = modal.indexOf(`id="${id}"`); + expect(at, `no element ${id}`).toBeGreaterThan(-1); + const row = modal.slice(modal.lastIndexOf('
', at)); + expect(row).toContain('synced'); + } + // The number row's own bounds must agree with the schema's, or the browser sends a value the PUT rejects. + expect(modal).toContain('id="appSettingsGitFetchIntervalSeconds" class="set-num" value="300" min="30" max="3600"'); + + // Display keys are the half of the per-device split that keeps a value off the server. + const displayStart = settingsUi.indexOf('const displayKeys = new Set(['); + const displayBlock = settingsUi.slice(displayStart, settingsUi.indexOf(']);', displayStart)); + expect(displayBlock).not.toContain('gitFetchEnabled'); + expect(displayBlock).not.toContain('gitFetchIntervalSeconds'); + // The strip destructure is the other half: an aliased key here never reaches the settings PUT. + expect(settingsUi).not.toMatch(/gitFetch(?:Enabled|IntervalSeconds):\s*_\w+/); + + // Declared in the .strict() schema — an undeclared key 400s the whole settings PUT. + expect(SettingsUpdateSchema.safeParse({ gitFetchEnabled: true, gitFetchIntervalSeconds: 300 }).success).toBe(true); + + // Off by default (only an explicit true loads as on), and an empty box falls back to the default interval. + expect(settingsUi).toContain( + "document.getElementById('appSettingsGitFetchEnabled').checked = settings.gitFetchEnabled === true;" + ); + expect(settingsUi).toContain( + "document.getElementById('appSettingsGitFetchIntervalSeconds').value = settings.gitFetchIntervalSeconds ?? 300;" + ); + expect(settingsUi).toMatch( + /gitFetchIntervalSeconds: Math\.min\(\s*3600,\s*Math\.max\(30, parseInt\(document\.getElementById\('appSettingsGitFetchIntervalSeconds'\)\.value, 10\) \|\| 300\)\s*\),/ + ); + }); + it('has retired the modal-tab chrome everywhere, not just here', () => { // Session Options and Add Case moved onto this same `set-*` surface, so the // old tab classes have no users left. A reappearance means a modal drifted diff --git a/test/git-fetch-i18n.test.ts b/test/git-fetch-i18n.test.ts new file mode 100644 index 000000000..0d9cfff08 --- /dev/null +++ b/test/git-fetch-i18n.test.ts @@ -0,0 +1,101 @@ +/** + * @fileoverview The Git fetch interval rows (App Settings → Bottom bar, Session Options → Git) in Chinese + * (zh-CN): each label and description runs through the real translator over the real index.html in JSDOM and + * reads in Chinese with no English left beyond the product name and the technical names Git / git fetch, and + * every new key — the save toast in session-ui.js included — is in the dictionary exactly once (a repeated key + * in the object literal silently replaces the first). + * + * The Git tab's Status row is not here: its text is written at runtime by updateGitFetchStatus and carries + * data-i18n-skip, so translating it would rewrite the stamp the code builds. 'sec' is not a key either — only + * whole unit words are ('hours'), so 'sec' stays as written. + * + * Port: N/A. + */ +import { readFileSync } from 'node:fs'; +import { resolve } from 'node:path'; +import vm from 'node:vm'; +import { JSDOM } from 'jsdom'; +import { afterAll, describe, expect, it } from 'vitest'; + +const PUBLIC = resolve(import.meta.dirname, '../src/web/public'); +const I18N = readFileSync(resolve(PUBLIC, 'i18n.js'), 'utf8'); +const INDEX = readFileSync(resolve(PUBLIC, 'index.html'), 'utf8'); +// Keys that contain a quote are written escaped in the object literal, so the dictionary is counted unescaped. +const DICT = I18N.replace(/\\'/g, "'").replace(/\\"/g, '"'); + +type Api = { configure(o: Record): void; start(): void }; + +const ROWS = [ + 'appSettingsGitFetchEnabled', + 'appSettingsGitFetchIntervalSeconds', + 'modalGitFetchFollow', + 'modalGitFetchEnabled', + 'modalGitFetchInterval', +]; + +/** What may stay Latin: the product names, and Git / git fetch as technical names. */ +const leftover = (text: string) => + text.replace(/\b(Claude Code|Claude|Codeman|Ralph|Git|git|fetch)\b/g, '').match(/[A-Za-z]+/g) ?? []; + +function rowStrings(doc: Document, id: string): { label: string; description: string } { + const row = doc.getElementById(id)!.closest('.set-row')!; + const label = row.querySelector('.set-row-label')!; + // The row's own scope marker ('synced') rides inside the label; the dictionary key is the label without it. + const scope = label.querySelector('.set-scope')?.textContent ?? ''; + return { + label: label.textContent!.replace(scope, '').trim(), + description: row.querySelector('.set-row-desc')!.textContent!.trim(), + }; +} + +/** The Git section's blurb, which is not a row. */ +function blurb(doc: Document): string { + const section = doc.getElementById('git-tab')!; + return section.querySelector('.set-section-blurb')!.textContent!.trim(); +} + +const source = new JSDOM(INDEX); +const dom = new JSDOM(INDEX, { runScripts: 'outside-only', url: 'http://localhost/' }); +vm.runInContext(I18N, dom.getInternalVMContext(), { filename: 'i18n.js' }); +const api = (dom.window as unknown as { CodemanI18n: Api }).CodemanI18n; +api.start(); +api.configure({ language: 'zh-CN' }); + +afterAll(() => { + source.window.close(); + dom.window.close(); +}); + +describe('the Git fetch rows in zh-CN', () => { + it.each(ROWS)('translates the %s row, with no English left', (id) => { + const english = rowStrings(source.window.document, id); + const chinese = rowStrings(dom.window.document, id); + expect(english.label.length).toBeGreaterThan(0); + for (const key of ['label', 'description'] as const) { + expect(chinese[key]).not.toBe(english[key]); + expect(leftover(chinese[key])).toEqual([]); + } + }); + + it('translates the Git section blurb too, which is not a row', () => { + expect(blurb(dom.window.document)).not.toBe(blurb(source.window.document)); + expect(leftover(blurb(dom.window.document))).toEqual([]); + }); + + it("leaves the Status row's runtime text to the code that writes it", () => { + // updateGitFetchStatus builds this row's sentence; a dictionary key for it would overwrite the stamp. + expect(INDEX).toContain('id="gitFetchStatus" data-i18n-skip hidden'); + }); + + it('adds each key to the dictionary once, the save toast included', () => { + const texts = ROWS.flatMap((id) => Object.values(rowStrings(source.window.document, id))); + texts.push(blurb(source.window.document), 'Git fetch settings saved'); + // 'sec' is deliberately not a key: only whole unit words are, so the unit stays as the markup writes it. + expect(DICT).not.toMatch(/\n sec: /); + for (const text of texts) { + const quoted = DICT.split(`'${text}'`).length + DICT.split(`"${text}"`).length - 2; + const bare = /^\w+$/.test(text) ? DICT.split(`\n ${text}: `).length - 1 : 0; + expect(quoted + bare, text).toBe(1); + } + }); +}); diff --git a/test/git-fetch-scheduler.test.ts b/test/git-fetch-scheduler.test.ts new file mode 100644 index 000000000..6bf5029b9 --- /dev/null +++ b/test/git-fetch-scheduler.test.ts @@ -0,0 +1,308 @@ +/** + * @fileoverview The Git fetch interval scheduler (src/git-fetch-scheduler.ts): which cases a tick runs, and + * which it must not. Every rule is checked by calling `tick(now)` with an explicit clock, so nothing here + * depends on fake timers or on how long a real tick takes — the scheduler owns no timers itself. + * + * Port: N/A. + */ +import { describe, expect, it, vi } from 'vitest'; +import { + GitFetchScheduler, + effectiveGitFetch, + type GitFetchPorts, + type GitFetchSessionView, +} from '../src/git-fetch-scheduler.js'; +import { + FETCH_CONCURRENCY, + type GitFetchReport, + type GitFetchSettings, + type GitFetchStatus, +} from '../src/git-fetch.js'; +import { DEFAULT_GIT_TIMEOUT_MS, MAX_REPOS } from '../src/git-workspace-status.js'; + +const SETTINGS: GitFetchSettings = { enabled: true, intervalSeconds: 300 }; +const VIEW = (id: string, extra: Partial = {}): GitFetchSessionView => ({ + id, + workingDir: `/w/${id}`, + ...extra, +}); + +/** An empty report, in the shape a run produces. */ +const report = (cwd: string, name: string): GitFetchReport => ({ + state: 'ok', + repos: [{ name, path: '.', ok: true }], + reposTruncated: false, + fetchedAt: Date.now(), +}); + +type Harness = { + scheduler: GitFetchScheduler; + ports: GitFetchPorts; + calls: Array<{ id: string; opts: unknown }>; + published: Array<{ id: string; status: GitFetchStatus }>; + settings: GitFetchSettings; + dockerReads: number; +}; + +/** A run the test decides when to let finish, so a slow repository can be held open across ticks. */ +function deferred(value: T) { + let resolve = (v: T) => {}; + const promise = new Promise((r) => (resolve = r)); + return { promise, resolve }; +} + +/** + * A scheduler over an injected set of ports: nothing here touches a settings file, a repository or the + * server, so what is being pinned is the scheduler's own decisions. + */ +function harness( + sessions: GitFetchSessionView[], + opts: { settings?: GitFetchSettings; fetch?: (id: string) => Promise } = {} +): Harness { + const customFetch = opts.fetch; + const settings = opts.settings ?? { ...SETTINGS }; + const h: Harness = { + scheduler: undefined as unknown as GitFetchScheduler, + ports: undefined as unknown as GitFetchPorts, + calls: [], + published: [], + settings, + dockerReads: 0, + }; + h.ports = { + sessionIds: () => sessions.map((s) => s.id), + getSession: (id) => sessions.find((s) => s.id === id), + readSettings: async () => { + if (h.settings === null) throw new Error('settings.json is mid-write'); + return h.settings; + }, + dockerWorkspaces: async () => { + h.dockerReads++; + return []; + }, + fetch: async (cwd, portOpts) => { + const id = cwd.slice(3); // the '/w/' the view was built with + h.calls.push({ id, opts: portOpts }); + return customFetch ? customFetch(id) : report(cwd, id); + }, + publish: (id, status) => h.published.push({ id, status }), + }; + h.scheduler = new GitFetchScheduler(h.ports); + return h; +} + +describe('a quiet tick', () => { + it('costs one settings read and nothing else, because nothing is on', async () => { + const h = harness([VIEW('a'), VIEW('b')], { settings: { enabled: false, intervalSeconds: 300 } }); + expect(await h.scheduler.tick(1000)).toBe(0); + expect(h.calls).toEqual([]); + // The Docker case list is only worth reading once something is actually due. + expect(h.dockerReads).toBe(0); + }); + + it('reads settings FRESH every tick, so a live toggle takes effect on the next tick and not at boot', async () => { + const h = harness([VIEW('a')], { settings: { enabled: false, intervalSeconds: 30 } }); + await h.scheduler.tick(0); + expect(h.calls.length).toBe(0); + + h.settings = { enabled: true, intervalSeconds: 30 }; + await h.scheduler.tick(1000); + expect(h.calls.length).toBe(1); + + // Turning it off has to stop the fetching, not just stop it starting again later. + h.settings = { enabled: false, intervalSeconds: 30 }; + await h.scheduler.tick(2000); + expect(h.calls.length).toBe(1); + }); + + it('skips this tick only when a settings read fails, because settings.json is rewritten in place', async () => { + const h = harness([VIEW('a')]); + h.settings = null as unknown as GitFetchSettings; + const logged = vi.fn(); + h.ports.log = logged; + expect(await h.scheduler.tick(0)).toBe(0); + expect(h.calls).toEqual([]); + expect(logged).toHaveBeenCalled(); + + h.settings = SETTINGS; + // Nothing was recorded for the failed tick, so the case is still due at once. + expect(await h.scheduler.tick(1000)).toBe(1); + }); +}); + +describe('who runs', () => { + it('runs every case the app setting covers, once per interval', async () => { + const h = harness([VIEW('a'), VIEW('b')], { settings: { enabled: true, intervalSeconds: 60 } }); + await h.scheduler.tick(0); + expect(h.calls.map((c) => c.id).sort()).toEqual(['a', 'b']); + + await h.scheduler.tick(30_000); // not due yet + expect(h.calls.length).toBe(2); + + await h.scheduler.tick(60_000); + expect(h.calls.length).toBe(4); + }); + + it('runs a case that turned itself on even while the app-wide switch is off — that is what a per-case switch has to mean', async () => { + const h = harness([VIEW('plain'), VIEW('own', { gitFetchEnabled: true, gitFetchIntervalSeconds: 30 })], { + settings: { enabled: false, intervalSeconds: 300 }, + }); + await h.scheduler.tick(0); + expect(h.calls.map((c) => c.id)).toEqual(['own']); + + // Its own interval, not the app's: due at 30 s, not at 300. + await h.scheduler.tick(30_000); + expect(h.calls.length).toBe(2); + }); + + it('never fetches a remote or Docker case, exactly as the Git panel excludes them', async () => { + const h = harness([ + VIEW('ssh', { remote: { host: 'x' } }), + VIEW('docker', { docker: { container: 'c' } }), + VIEW('local'), + ]); + await h.scheduler.tick(0); + expect(h.calls.map((c) => c.id)).toEqual(['local']); + }); + + it('re-checks the Docker rule every tick, so a case that became a Docker case stops fetching', async () => { + const sessions = [VIEW('a')]; + const h = harness(sessions); + await h.scheduler.tick(0); + expect(h.calls.length).toBe(1); + + sessions[0] = { ...sessions[0], docker: { container: 'c' } }; + await h.scheduler.tick(400_000); + expect(h.calls.length).toBe(1); + }); + + it('uses the module defaults for the limit and the timeout, because those settings are per device and never reach the server', async () => { + const h = harness([VIEW('a')]); + await h.scheduler.tick(0); + expect(h.calls[0].opts).toMatchObject({ maxRepos: MAX_REPOS, timeoutMs: DEFAULT_GIT_TIMEOUT_MS }); + }); +}); + +describe('one run at a time', () => { + it('skips a case still being fetched instead of stacking runs on it', async () => { + const gate = deferred(report('/w/a', 'a')); + const h = harness([VIEW('a')], { settings: { enabled: true, intervalSeconds: 30 }, fetch: () => gate.promise }); + const running = h.scheduler.tick(0); + await new Promise((resolve) => setImmediate(resolve)); // the run has started, and is still held open + expect(await h.scheduler.tick(15_000)).toBe(0); + expect(h.calls.length).toBe(1); + gate.resolve(report('/w/a', 'a')); + await running; + }); + + it('times the interval from the START of a run, so a slow repository does not push the next one out', async () => { + const slow = deferred(report('/w/a', 'a')); + const h = harness([VIEW('a')], { settings: { enabled: true, intervalSeconds: 30 }, fetch: () => slow.promise }); + + const first = h.scheduler.tick(0); + await new Promise((resolve) => setImmediate(resolve)); + expect(h.calls.length).toBe(1); + + // The run takes 200 s, longer than its own interval. Once it lands, the case is already past due: the + // next tick runs it again instead of waiting another 30 s from when the slow one finished. + slow.resolve(report('/w/a', 'a')); + await first; + expect(await h.scheduler.tick(200_000)).toBe(1); + expect(h.calls.length).toBe(2); + }); + + it('runs at most FETCH_CONCURRENCY cases at a time, because each one is a git process', async () => { + const ids = Array.from({ length: FETCH_CONCURRENCY + 3 }, (_, i) => `s${i}`); + let live = 0; + let peak = 0; + const h = harness( + ids.map((id) => VIEW(id)), + { + fetch: async () => { + live++; + peak = Math.max(peak, live); + await new Promise((resolve) => setImmediate(resolve)); + live--; + return report('/w/x', 'x'); + }, + } + ); + await h.scheduler.tick(0); + expect(peak).toBe(FETCH_CONCURRENCY); + expect(h.calls.length).toBe(ids.length); + }); +}); + +describe('what a run reports', () => { + it("publishes the run as the interval's own, so the panel can say the number was not asked for", async () => { + const h = harness([VIEW('a')]); + await h.scheduler.tick(0); + expect(h.published).toEqual([ + { + id: 'a', + status: { ranAt: expect.any(Number), trigger: 'interval', state: 'ok', repos: 1, failed: 0 }, + }, + ]); + }); + + it("reports a failure instead of retrying harder, and keeps the case's own next run at its own interval", async () => { + const h = harness([VIEW('a')], { + settings: { enabled: true, intervalSeconds: 30 }, + fetch: () => Promise.reject(new Error('spawn git ENOENT')), + }); + const logged = vi.fn(); + h.ports.log = logged; + await h.scheduler.tick(0); + expect(logged).toHaveBeenCalled(); + expect(h.published).toEqual([]); + // Still remembered, so the next run is on schedule rather than immediate. + expect(h.scheduler.tracked()).toBe(1); + }); +}); + +describe('the effective choice', () => { + it("a case's own override wins over the app setting", () => { + const settings = { enabled: false, intervalSeconds: 600 }; + expect(effectiveGitFetch(VIEW('a', { gitFetchEnabled: true, gitFetchIntervalSeconds: 45 }), settings)).toEqual({ + enabled: true, + intervalSeconds: 45, + }); + // Half a choice still follows the app for the other half. + expect(effectiveGitFetch(VIEW('b', { gitFetchEnabled: true }), settings)).toEqual({ + enabled: true, + intervalSeconds: 600, + }); + }); + + it('re-clamps a persisted interval that can predate the schema bounds', () => { + expect(effectiveGitFetch(VIEW('a', { gitFetchIntervalSeconds: 5 }), SETTINGS)).toEqual({ + enabled: true, + intervalSeconds: 30, + }); + expect(effectiveGitFetch(VIEW('b', { gitFetchIntervalSeconds: 9000 }), SETTINGS)).toEqual({ + enabled: true, + intervalSeconds: 3600, + }); + }); +}); + +describe('a case that goes away', () => { + it('takes its schedule with it, so a reopened case starts on its own clock', async () => { + const h = harness([VIEW('a')]); + await h.scheduler.tick(0); + expect(h.scheduler.tracked()).toBe(1); + + h.scheduler.remove('a'); + expect(h.scheduler.tracked()).toBe(0); + await h.scheduler.tick(1000); + expect(h.calls.length).toBe(2); // remembered again, from now + }); + + it('stops everything after stop(), including a case that was already due', async () => { + const h = harness([VIEW('a')]); + h.scheduler.stop(); + expect(await h.scheduler.tick(0)).toBe(0); + expect(h.calls).toEqual([]); + expect(h.scheduler.tracked()).toBe(0); + }); +}); diff --git a/test/git-fetch.test.ts b/test/git-fetch.test.ts new file mode 100644 index 000000000..94be95cc2 --- /dev/null +++ b/test/git-fetch.test.ts @@ -0,0 +1,349 @@ +/** + * @fileoverview Tests for the Git panel's Fetch button (src/git-fetch.ts): the one git command Codeman + * runs that writes, and the one that runs a repository's own hooks — which is why it is a click, not a poll. + * + * Two halves, mirroring the module: + * + * 1. The PURE half — the fixed argv, the failure classification, and the two reads the interval scheduler + * depends on: what the settings say (`gitFetchSettingsFrom`) and what a run did + * (`gitFetchStatusFrom`). No spawning. + * 2. The IO half — a REAL git fetching a REAL local bare repo, so that "remote-tracking refs only" is + * proven against git's actual behavior: HEAD, the index and the working tree must be exactly what they + * were while the remote-tracking ref moves. The rest of the half uses an injected runner over real + * repositories on disk, because what is being pinned is WHICH repositories git is allowed to touch. + * + * Port: N/A (no server). + */ + +import { execFileSync } from 'node:child_process'; +import { mkdirSync, mkdtempSync, readdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { basename, join } from 'node:path'; +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import { classifyGitFailure, isGitAvailable } from '../src/git-clone.js'; +import { + FETCH_CONCURRENCY, + buildFetchArgs, + describeFetchFailure, + fetchGitWorkspaceOverview, + gitFetchSettingsFrom, + gitFetchStatusFrom, + type GitFetchReport, +} from '../src/git-fetch.js'; +import { runGit, type GitRunner } from '../src/git-workspace-status.js'; + +const ENV = { + ...process.env, + // As the production runner: git's messages are matched in English, and nothing reads the host's config. + LC_ALL: 'C', + GIT_AUTHOR_NAME: 'T', + GIT_AUTHOR_EMAIL: 't@example.com', + GIT_COMMITTER_NAME: 'T', + GIT_COMMITTER_EMAIL: 't@example.com', + GIT_CONFIG_GLOBAL: '/dev/null', + GIT_CONFIG_NOSYSTEM: '1', +}; +const git = (cwd: string, ...args: string[]) => + execFileSync('git', args, { cwd, env: ENV, encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'] }); + +// ─── The pure half ─────────────────────────────────────────────────────────── + +describe('buildFetchArgs', () => { + it('pins the argv to a fetch and nothing else', () => { + expect(buildFetchArgs()).toEqual(['fetch', '--quiet']); + // Not just the first word: the whole argv is fixed, so no verb that changes the repository can slip in. + for (const verb of ['commit', 'pull', 'merge', 'checkout', 'reset', 'clean', 'prune', 'gc', 'amend']) { + expect(buildFetchArgs().join(' ')).not.toContain(verb); + } + }); + + it('names no repository, so the repository can only be the process cwd', () => { + // An operand-shaped argument would let a path or a URL choose the repository; the fixed argv cannot. + for (const arg of buildFetchArgs()) { + expect(arg.startsWith('-')).toBe(arg === '--quiet'); + } + }); +}); + +describe('describeFetchFailure', () => { + it("calls a repository with no remote a fact, not a fault, in each of git's wordings", () => { + for (const stderr of [ + 'fatal: no default remote repository -- use --remote or specify a repository', + 'No remote configured', + 'fatal: nothing to fetch from remote repository', + ]) { + const failure = describeFetchFailure({ stderr }); + expect(failure.code).toBe('NO_REMOTE'); + expect(failure.message).toMatch(/no remote/i); + expect(failure.message).not.toMatch(/fail|error|unable/i); + } + }); + + it('writes its own timeout message, because the one to reuse was written for clones', () => { + const failure = describeFetchFailure({ code: 'ETIMEDOUT', stderr: 'fatal: repository not found' }); + expect(failure.code).toBe('TIMEOUT'); + expect(failure.message).toMatch(/Git status: git timeout/); + expect(failure.message).toMatch(/fetch from a shell/i); + expect(failure.message).not.toBe(classifyGitFailure('fatal: repository not found', true).message); + expect(describeFetchFailure({ killed: true, stderr: '' }).code).toBe('TIMEOUT'); + }); + + it('reuses the network reasons it already knows', () => { + const prompt = "fatal: could not read Username for 'https://github.com': terminal prompts disabled"; + expect(describeFetchFailure({ stderr: prompt }).code).toBe('AUTH_REQUIRED'); + expect(describeFetchFailure({ stderr: 'git@github.com: Permission denied (publickey).' }).code).toBe( + 'AUTH_REQUIRED' + ); + expect(describeFetchFailure({ message: 'Error: spawn git ENOENT' }).code).toBe('GIT_MISSING'); + expect(describeFetchFailure({ stderr: 'fatal: unable to access: Could not resolve host: nope.invalid' }).code).toBe( + 'HOST_UNREACHABLE' + ); + }); + + it('hands the client a redacted, control-stripped tail, never what git printed', () => { + const failure = describeFetchFailure({ + stderr: "fatal: unable to access 'https://bob:ghp_secret@github.com/x.git/'\u001b[31m", + }); + expect(failure.stderr).toBe("fatal: unable to access 'https://***:***@github.com/x.git/'[31m"); + }); +}); + +// ─── The two reads the interval feature depends on ─────────────────────────── + +describe('gitFetchSettingsFrom', () => { + it('is off until the user turns it on, and 300 s when nothing was chosen', () => { + // Absent means off: a quiet install must not start running git on a timer nobody asked for. + for (const settings of [undefined, {}, { gitFetchEnabled: false }, { gitFetchEnabled: 'true' }]) { + expect(gitFetchSettingsFrom(settings).enabled).toBe(false); + } + expect(gitFetchSettingsFrom({ gitFetchEnabled: true }).enabled).toBe(true); + expect(gitFetchSettingsFrom({}).intervalSeconds).toBe(300); + }); + + it('clamps a number outside the schema instead of trusting it, because a persisted value can predate the bounds', () => { + expect(gitFetchSettingsFrom({ gitFetchIntervalSeconds: 1 }).intervalSeconds).toBe(30); + expect(gitFetchSettingsFrom({ gitFetchIntervalSeconds: 99999 }).intervalSeconds).toBe(3600); + expect(gitFetchSettingsFrom({ gitFetchIntervalSeconds: 120.6 }).intervalSeconds).toBe(121); + expect(gitFetchSettingsFrom({ gitFetchIntervalSeconds: -300 }).intervalSeconds).toBe(30); + // A hand-edited 0 is still a number it can act on, and acting on it means the smallest interval that runs. + expect(gitFetchSettingsFrom({ gitFetchIntervalSeconds: 0 }).intervalSeconds).toBe(30); + }); + + it('falls back to the default on a value it cannot act on, which is what a hand-edited settings.json can hold', () => { + for (const raw of ['300', undefined, null, NaN, Infinity, true]) { + expect(gitFetchSettingsFrom({ gitFetchIntervalSeconds: raw }).intervalSeconds, String(raw)).toBe(300); + } + }); +}); + +describe('gitFetchStatusFrom', () => { + it('counts what a run covered and what it could not do, and names the first failure', () => { + const report: GitFetchReport = { + state: 'ok', + repos: [ + { name: 'Codeman', path: '.', ok: true }, + { name: 'api', path: 'api', ok: false, failure: { code: 'NO_REMOTE', message: 'no remote', stderr: '' } }, + ], + reposTruncated: false, + fetchedAt: 1700, + }; + expect(gitFetchStatusFrom(report, 'interval')).toEqual({ + ranAt: 1700, + trigger: 'interval', + state: 'ok', + repos: 2, + failed: 1, + firstFailure: { name: 'api', code: 'NO_REMOTE' }, + }); + }); + + it('keeps the trigger, because a number the user did not ask for should read differently from one they did', () => { + const empty: GitFetchReport = { state: 'ok', repos: [], reposTruncated: false, fetchedAt: 1000 }; + expect(gitFetchStatusFrom(empty, 'click').trigger).toBe('click'); + expect(gitFetchStatusFrom(empty, 'interval').trigger).toBe('interval'); + }); + + it('records a state that stopped the run, with nothing to count and no failure to name', () => { + const status = gitFetchStatusFrom( + { state: 'unsupported', reason: 'docker', repos: [], reposTruncated: false, fetchedAt: 5 }, + 'interval' + ); + expect(status.state).toBe('unsupported'); + expect(status.repos).toBe(0); + expect(status.failed).toBe(0); + expect(status.firstFailure).toBeUndefined(); + }); +}); + +// ─── Real git, real local repository ───────────────────────────────────────── + +const gitPresent = isGitAvailable(); + +let dir: string; + +beforeEach(() => { + dir = mkdtempSync(join(tmpdir(), 'git-fetch-')); +}); +afterEach(() => { + rmSync(dir, { recursive: true, force: true }); +}); + +function initRepo(path: string, remote?: string) { + mkdirSync(path, { recursive: true }); + git(path, 'init', '-q', '-b', 'main'); + // So a `file://` remote stays allowed for the production runner too, not only for this test's own git calls. + git(path, 'config', 'protocol.file.allow', 'always'); + writeFileSync(join(path, 'f.txt'), '1\n'); + git(path, 'add', '-A'); + git(path, 'commit', '-q', '-m', 'init'); + if (remote) git(path, 'remote', 'add', 'origin', remote); + return path; +} + +/** Records what each repository was asked to do, and lets real git answer the rest. */ +function recordingRunner() { + const calls: Array<[string, string[]]> = []; + const runner: GitRunner = async (cwd, args, opts) => { + calls.push([cwd, args]); + return runGit(cwd, args, opts); + }; + return { calls, runner }; +} + +describe.skipIf(!gitPresent)('fetchGitWorkspaceOverview (real git)', () => { + let work: string; + + const snapshot = (path: string) => ({ + head: git(path, 'rev-parse', 'HEAD').trim(), + tree: readdirSync(path, { withFileTypes: true }) + .filter((e) => e.name !== '.git' && e.isFile()) + .sort((a, b) => a.name.localeCompare(b.name)) + .map((e) => `${e.name}=${readFileSync(join(path, e.name), 'utf8')}`), + // The global option has to come before the verb, exactly as the read-only status module runs it. + index: git(path, '--no-optional-locks', 'status', '--porcelain=v2').trim(), + }); + + beforeEach(() => { + const origin = join(dir, 'origin.git'); + mkdirSync(origin); + git(origin, 'init', '--bare', '--quiet', '-b', 'main'); + work = initRepo(join(dir, 'work'), origin); + git(work, 'push', '-q', 'origin', 'main'); + + // A commit made elsewhere and pushed: work's origin/main cannot hear about it until a fetch runs. + // This has to be a clone of origin, not a fresh init — a commit on its own root commit is no + // descendant of what origin already holds, so pushing it is rejected as non-fast-forward. + const other = join(dir, 'other'); + git(dir, 'clone', '-q', origin, 'other'); + git(other, 'config', 'protocol.file.allow', 'always'); + writeFileSync(join(other, 'g.txt'), '2\n'); + git(other, 'add', '-A'); + git(other, 'commit', '-q', '-m', 'second'); + git(other, 'push', '-q', 'origin', 'main'); + + // Left dirty on purpose, so the snapshot has something proving it was not touched. + writeFileSync(join(work, 'untracked.txt'), 'u\n'); + writeFileSync(join(work, 'f.txt'), 'modified\n'); + }); + + it('fetches through the production runner, moving remote-tracking refs and writing nothing else', async () => { + // A push does not update the remote-tracking ref, so the fetch is what has to move it: 0 before, 1 after. + expect(git(work, 'rev-list', '--count', 'main..origin/main').trim()).toBe('0'); + const before = snapshot(work); + + const report = await fetchGitWorkspaceOverview(work); + expect(report.state).toBe('ok'); + expect(report.repos).toHaveLength(1); + expect(report.repos[0]).toMatchObject({ name: 'work', path: '.', ok: true }); + expect(report.fetchedAt).toBeGreaterThan(0); + + expect(snapshot(work)).toEqual(before); // HEAD, the working tree and the index are exactly as they were + expect(git(work, 'rev-list', '--count', 'main..origin/main').trim()).toBe('1'); + }); + + it('fetches exactly the repositories the panel would show, in the panel order', async () => { + const folder = join(dir, 'folder'); + for (const name of ['api', 'web']) initRepo(join(folder, name)); + mkdirSync(join(folder, 'node_modules', 'pkg'), { recursive: true }); // never a project the panel lists + const { calls, runner } = recordingRunner(); + const report = await fetchGitWorkspaceOverview(folder, { git: runner }); + expect(report.state).toBe('ok'); + expect(report.repos.map((r) => [r.name, r.path])).toEqual([ + ['api', 'api'], + ['web', 'web'], + ]); + // Only the fetches, not the resolver's own `rev-parse`: the repositories touched are the point. + // A bounded run does not promise which one starts first, so compare the set. + expect(calls.filter(([, args]) => args[0] === 'fetch').sort()).toEqual([ + [join(folder, 'api'), ['fetch', '--quiet']], + [join(folder, 'web'), ['fetch', '--quiet']], + ]); + }); + + it('never reaches an unrelated repository above the workspace', async () => { + const home = join(dir, 'home'); + initRepo(home); + const caseDir = join(home, 'case'); + mkdirSync(caseDir); + const { calls, runner } = recordingRunner(); + const report = await fetchGitWorkspaceOverview(caseDir, { git: runner, home }); + expect(report.state).toBe('not-a-repo'); + expect(calls.filter(([, args]) => args[0] === 'fetch')).toEqual([]); + }); + + it('refuses a repository at or inside a Docker case workspace before any git runs', async () => { + initRepo(join(dir, 'repo')); + const { calls, runner } = recordingRunner(); + const report = await fetchGitWorkspaceOverview(dir, { git: runner, dockerWorkspaces: [dir] }); + expect(report).toMatchObject({ state: 'unsupported', reason: 'docker' }); + expect(calls).toEqual([]); + }); + + it('keeps a repository that could not be fetched, with its reason', async () => { + for (const name of ['ok', 'private']) initRepo(join(dir, 'folder', name)); + const runner: GitRunner = async (cwd) => { + if (basename(cwd) === 'private') { + throw Object.assign(new Error('Command failed'), { + stderr: "fatal: could not read Username for 'https://github.com': terminal prompts disabled", + }); + } + return ''; + }; + const report = await fetchGitWorkspaceOverview(join(dir, 'folder'), { git: runner }); + expect(report.state).toBe('ok'); + const priv = report.repos.find((r) => r.name === 'private'); + expect(priv?.ok).toBe(false); + expect(priv?.failure?.code).toBe('AUTH_REQUIRED'); + expect(priv?.failure?.message).toMatch(/authentication/i); + expect(report.repos.find((r) => r.name === 'ok')?.ok).toBe(true); + }); + + it("runs at most FETCH_CONCURRENCY fetches at a time, with the setting's timeout", async () => { + const folder = join(dir, 'folder'); + for (let i = 0; i < FETCH_CONCURRENCY + 2; i++) initRepo(join(folder, `r${i}`)); + let inFlight = 0; + let max = 0; + const seenTimeouts = new Set(); + const runner: GitRunner = async (cwd, args, opts) => { + seenTimeouts.add(opts?.timeoutMs); + inFlight++; + max = Math.max(max, inFlight); + await new Promise((r) => setTimeout(r, 10)); + inFlight--; + return ''; + }; + const report = await fetchGitWorkspaceOverview(folder, { git: runner, timeoutMs: 7000 }); + expect(report.repos).toHaveLength(FETCH_CONCURRENCY + 2); + expect(report.repos.every((r) => r.ok)).toBe(true); + expect(max).toBe(FETCH_CONCURRENCY); + expect(seenTimeouts).toEqual(new Set([7000])); + }); + + it('answers not-a-repo for a folder that is not a repository, running no fetch', async () => { + const plain = join(dir, 'plain'); + mkdirSync(plain); + const { calls, runner } = recordingRunner(); + expect(await fetchGitWorkspaceOverview(plain, { git: runner })).toMatchObject({ state: 'not-a-repo', repos: [] }); + expect(calls.filter(([, args]) => args[0] === 'fetch')).toEqual([]); + }); +}); diff --git a/test/git-status.browser.test.ts b/test/git-status.browser.test.ts index e8c06e9b3..1522e4beb 100644 --- a/test/git-status.browser.test.ts +++ b/test/git-status.browser.test.ts @@ -29,6 +29,7 @@ describe('Git status indicator in a real browser', () => { let repoSession: string; let plainSession: string; const gitStatusRequests: string[] = []; + const gitFetchRequests: string[] = []; const settingsPutStatuses: number[] = []; const write = (rel: string, text = 'x\n') => writeFileSync(join(repo, rel), text); @@ -91,6 +92,9 @@ describe('Git status indicator in a real browser', () => { page.on('request', (r) => { // The API route only: the page also loads /git-status-ui.js, whose URL contains the same words. if (/\/api\/sessions\/[^/]+\/git-status/.test(r.url())) gitStatusRequests.push(r.url()); + // Anchored: the per-case Git settings route (/git-fetch-settings) is a different route, and a bare + // /git-fetch test would count its PUTs as fetches. The fetch route carries the limits as a query. + if (/\/api\/sessions\/[^/]+\/git-fetch(\?|$)/.test(r.url())) gitFetchRequests.push(r.url()); }); page.on('response', (r) => { if (r.request().method() === 'PUT' && r.url().endsWith('/api/settings')) settingsPutStatuses.push(r.status()); @@ -514,6 +518,147 @@ describe('Git status indicator in a real browser', () => { await refresh(); }, 40000); + it('puts Fetch in the header row, between Refresh and Close', async () => { + const order = await page.evaluate(() => + [...document.querySelectorAll('.git-status-actions button')].map((b) => b.getAttribute('aria-label')) + ); + expect(order).toEqual(['Refresh git status', 'Fetch git status', 'Close git status']); + // A repository the panel can read is a repository Fetch can fetch, so the button is live here. + expect(await page.evaluate(() => document.getElementById('gitStatusFetchBtn')!.disabled)).toBe(false); + }); + + it('Fetch fetches for real, moves the behind number, and reports what it brought', async () => { + // A second clone pushes a commit this repository has not fetched, so its remote-tracking ref is stale: + // the panel cannot show a `behind` number until a fetch runs. That is what the footer means by + // "as of the last fetch", and the number only becomes current by the button. + const other = join(root, 'other-clone'); + git(root, 'clone', '-q', join(root, 'origin.git'), other); + writeFileSync(join(other, 'from-elsewhere.txt'), 'y\n'); + git(other, 'add', '-A'); + git(other, 'commit', '-q', '-m', 'from elsewhere'); + git(other, 'push', '-q'); + await refresh(); + expect(await page.textContent('#gitStatusBody')).not.toMatch(/behind/); + + const before = gitFetchRequests.length; + await page.click('#gitStatusFetchBtn'); + await page.waitForFunction( + () => /↓ 1 behind/.test(document.getElementById('gitStatusBody')?.textContent ?? ''), + null, + { timeout: 20000 } + ); + expect(gitFetchRequests.length).toBe(before + 1); + // The same limits the status route gets go with the fetch, so the two agree on which repositories count. + expect(gitFetchRequests.at(-1)).toMatch(/maxRepos=12/); + expect(await page.locator('.git-status-chip', { hasText: 'behind' }).textContent()).toBe('↓ 1 behind'); + expect(await page.textContent('#gitStatusBody .git-status-fetch-summary')).toMatch( + /^Fetched .+: 1 of 1 repository\.$/ + ); + expect(await page.locator('#gitStatusBody .git-status-fetch-row').count()).toBe(0); + + // The 15 s poll rebuilds the whole body, so the note has to come from the renderer, not from the click. + await refresh(); + expect(await page.locator('#gitStatusBody .git-status-fetch-summary').count()).toBe(1); + const foot = (await page.textContent('#gitStatusFooter')) ?? ''; + expect(foot).toMatch(/Read-only apart from Fetch/); + // No longer "only when you click": an interval the user turned on fetches too, and the footer says so. + expect(foot).toMatch(/interval/); + // The chip itself says how it got current, so the number is not read as live. + expect(await page.locator('.git-status-chip', { hasText: 'behind' }).getAttribute('title')).toMatch(/interval/); + rmSync(other, { recursive: true, force: true }); + }, 30000); + + it('a repository Fetch could not fetch keeps its row and shows why', async () => { + await page.route('**/api/sessions/*/git-fetch*', (route) => + route.fulfill({ + contentType: 'application/json', + body: JSON.stringify({ + success: true, + data: { + state: 'ok', + reposTruncated: false, + fetchedAt: Date.now(), + repos: [ + { name: 'fetchable', path: 'fetchable', ok: true }, + { + name: 'private', + path: '/srv/private', + ok: false, + failure: { code: 'AUTH', message: 'could not read the remote' }, + }, + ], + }, + }), + }) + ); + await page.click('#gitStatusFetchBtn'); + await page.waitForFunction(() => + /could not read the remote/.test(document.getElementById('gitStatusBody')?.textContent ?? '') + ); + // The summary counts what landed, and the failure is named rather than folded into the count. The + // summary is the panel's first note line, so the count is read off it, not off the whole body. + expect(await page.textContent('#gitStatusBody .git-status-fetch-summary')).toMatch( + /^Fetched .+: 1 of 2 repositories\.$/ + ); + const row = (await page.textContent('#gitStatusBody .git-status-fetch-row')) ?? ''; + expect(row).toContain('private'); + expect(row).toContain('⚠ could not read the remote'); + expect(await page.locator('#gitStatusBody .git-status-fetch-row').count()).toBe(1); + // Git text is untrusted: the row is text, never markup. + expect(await page.$('#gitStatusBody img')).toBeNull(); + await page.unroute('**/api/sessions/*/git-fetch*'); + }, 30000); + + it('one click means one fetch: the button is busy while it runs and a second call is ignored', async () => { + let slow = true; + await page.route('**/api/sessions/*/git-fetch*', async (route) => { + if (slow) await new Promise((r) => setTimeout(r, 1500)); + await route.continue(); + }); + page.setDefaultTimeout(6000); + await page.click('#gitStatusFetchBtn'); + await page.waitForFunction(() => (window as any).app._gitFetchInFlight === true); + const busy = await page.evaluate(() => { + const b = document.getElementById('gitStatusFetchBtn')!; + return { ariaBusy: b.getAttribute('aria-busy'), disabled: b.disabled }; + }); + expect(busy.ariaBusy).toBe('true'); + // Disabled is what stops a second click reaching the handler, so one click is one fetch per repository. + expect(busy.disabled).toBe(true); + const during = gitFetchRequests.length; + await page.evaluate(() => void (window as any).app.fetchGitStatusNow()); + expect(gitFetchRequests.length).toBe(during); + slow = false; + // The flag goes false before the re-render that follows it, so wait for the button itself, not the flag: + // reading it right at the flip catches the panel mid-render. + await page.waitForFunction( + () => { + const b = document.getElementById('gitStatusFetchBtn')!; + return (window as any).app._gitFetchInFlight === false && !b.disabled && !b.getAttribute('aria-busy'); + }, + null, + { timeout: 15000 } + ); + // The handler is still asleep: unrouting without waiting fails the run as an unhandled rejection. + await page.unrouteAll({ behavior: 'wait' }); + page.setDefaultTimeout(30000); + }, 30000); + + it('Fetch is inert for a session nothing can be fetched for', async () => { + await select(plainSession); + await page.waitForFunction( + () => /No git repository here/.test(document.getElementById('gitStatusBody')?.textContent ?? ''), + null, + { timeout: 15000 } + ); + expect(await page.evaluate(() => document.getElementById('gitStatusFetchBtn')!.disabled)).toBe(true); + await select(repoSession); + await page.waitForFunction(() => document.getElementById('gitStatusFetchBtn')!.disabled === false, null, { + timeout: 15000, + }); + await page.waitForSelector('#gitStatusPanel.visible'); + }, 30000); + it('closing the panel resets it; turning the setting off hides the button, closes the panel and stops polling', async () => { await page.click('.git-status-actions button[aria-label="Close git status"]'); expect(await page.isVisible('#gitStatusPanel')).toBe(false); diff --git a/test/mocks/mock-session.ts b/test/mocks/mock-session.ts index 665cffbe1..2da74c347 100644 --- a/test/mocks/mock-session.ts +++ b/test/mocks/mock-session.ts @@ -11,6 +11,7 @@ import type { SessionStatus, SessionRemote, } from '../../src/types.js'; +import type { GitFetchStatus } from '../../src/git-fetch.js'; /** * Enhanced mock session for testing RespawnController. @@ -69,6 +70,22 @@ export class MockSession extends EventEmitter { this.keepWarm = status; return changed; } + /** + * Mirrors the Git fetch interval surface on Session (src/git-fetch.ts): the case's own override, `undefined` + * for either half meaning the app-wide setting applies, and what the scheduler last reported. + */ + gitFetchEnabled: boolean | undefined = undefined; + gitFetchIntervalSeconds: number | undefined = undefined; + setGitFetchSettings(enabled: boolean | undefined, intervalSeconds: number | undefined): void { + this.gitFetchEnabled = enabled; + this.gitFetchIntervalSeconds = intervalSeconds; + } + gitFetch: GitFetchStatus | null = null; + setGitFetchStatus(status: GitFetchStatus): boolean { + const changed = JSON.stringify(status) !== JSON.stringify(this.gitFetch); + this.gitFetch = status; + return changed; + } private _activeChildProcesses: { pid: number; command: string }[] = []; ralphTracker: null = null; writeBuffer: string[] = []; diff --git a/test/routes/git-status-routes.test.ts b/test/routes/git-status-routes.test.ts index 5b1c60369..ead176bbc 100644 --- a/test/routes/git-status-routes.test.ts +++ b/test/routes/git-status-routes.test.ts @@ -353,6 +353,177 @@ describe('GET /api/sessions/:id/git-status limits', () => { }); }); +describe('POST /api/sessions/:id/git-fetch', () => { + /** Records what each repository was asked to do, and lets real git answer the rest. */ + function recordingRunner() { + const calls: Array<[string, string[]]> = []; + const seenTimeouts: Array = []; + const runner: GitRunner = async (cwd, args, opts) => { + calls.push([cwd, args]); + seenTimeouts.push(opts?.timeoutMs); + return execFileSyncGit(cwd, args); + }; + return { calls, seenTimeouts, runner, fetches: () => calls.filter(([, args]) => args[0] === 'fetch') }; + } + + it('runs one fetch per repository the window would show, in the session working directory', async () => { + for (const name of ['api', 'web']) { + mkdirSync(join(dir, name)); + git(join(dir, name), 'init', '-q', '-b', 'main'); + } + const { calls, fetches, runner } = recordingRunner(); + const { app } = await setup({ git: runner }); + const res = await app.inject({ method: 'POST', url: '/api/sessions/test-session-1/git-fetch' }); + expect(res.statusCode).toBe(200); + const data = res.json().data; + expect(data.state).toBe('ok'); + expect(data.repos.map((r: { name: string; path: string }) => [r.name, r.path])).toEqual([ + ['api', 'api'], + ['web', 'web'], + ]); + expect(data.repos.every((r: { ok: boolean }) => r.ok)).toBe(true); + // A bounded run does not promise which repository starts first, so compare the set. + expect(fetches().sort()).toEqual([ + [join(dir, 'api'), ['fetch', '--quiet']], + [join(dir, 'web'), ['fetch', '--quiet']], + ]); + // Every repository git was asked to touch sits under the session folder, and under nothing else. + for (const [cwd] of calls) expect(cwd.startsWith(dir)).toBe(true); + }); + + it('keeps a repository it could not fetch, with its reason, and says which ones it fetched', async () => { + for (const name of ['ok', 'private']) { + mkdirSync(join(dir, name)); + git(join(dir, name), 'init', '-q', '-b', 'main'); + } + const { fetches, runner } = recordingRunner(); + const injected: GitRunner = async (cwd, args, opts) => { + const recorded = runner(cwd, args, opts); // records the call before deciding what git would have said + if (args[0] === 'fetch' && cwd === join(dir, 'private')) { + throw Object.assign(new Error('Command failed'), { + stderr: "fatal: could not read Username for 'https://github.com': terminal prompts disabled", + }); + } + return recorded; + }; + const { app } = await setup({ git: injected }); + const data = (await app.inject({ method: 'POST', url: '/api/sessions/test-session-1/git-fetch' })).json().data; + const priv = data.repos.find((r: { name: string }) => r.name === 'private'); + expect(priv?.ok).toBe(false); + expect(priv?.failure?.code).toBe('AUTH_REQUIRED'); + expect(data.repos.find((r: { name: string }) => r.name === 'ok')?.ok).toBe(true); + // Both repositories are still in the list, so both were attempted: a failure does not cancel the run. + expect(fetches()).toHaveLength(2); + }); + + it('redacts credentials git may have echoed back before the failure reaches the client', async () => { + git(dir, 'init', '-q', '-b', 'main'); + // Only the fetch is faked: the resolver still needs its own `rev-parse` to find the repository at all. + const runner: GitRunner = async (cwd, args) => { + if (args[0] === 'fetch') { + throw Object.assign(new Error('Command failed'), { + stderr: "fatal: unable to access 'https://bob:ghp_secret@github.com/x.git/'\u001b[31m", + }); + } + return execFileSyncGit(cwd, args); + }; + const { app } = await setup({ git: runner }); + const data = (await app.inject({ method: 'POST', url: '/api/sessions/test-session-1/git-fetch' })).json().data; + expect(data.state).toBe('ok'); + expect(data.repos[0].failure.stderr).toBe("fatal: unable to access 'https://***:***@github.com/x.git/'[31m"); + }); + + it.each([ + ['remote', { host: 'h', user: 'u' }], + ['docker', { container: 'c' }], + ])('does not run git for a %s session and says it is unsupported', async (kind, value) => { + git(dir, 'init', '-q', '-b', 'main'); + const { calls, fetches, runner } = recordingRunner(); + const { app } = await setup({ git: runner }); + session[kind] = value; + const res = await app.inject({ method: 'POST', url: '/api/sessions/test-session-1/git-fetch' }); + expect(res.statusCode).toBe(200); + expect(res.json().data).toMatchObject({ state: 'unsupported', reason: kind }); + expect(fetches()).toEqual([]); + expect(calls).toEqual([]); // the resolver never ran either: the refusal comes before any git + }); + + it('refuses a repository inside a Docker case workspace before any git runs', async () => { + git(dir, 'init', '-q', '-b', 'main'); + const { calls, fetches, runner } = recordingRunner(); + const { app } = await setup({ git: runner, dockerWorkspaces: [realpathSync(dir)] }); + const res = await app.inject({ method: 'POST', url: '/api/sessions/test-session-1/git-fetch' }); + expect(res.json().data).toMatchObject({ state: 'unsupported', reason: 'docker' }); + expect(fetches()).toEqual([]); + expect(calls).toEqual([]); + }); + + it('answers not-a-repo for a folder that is not a repository, running no fetch', async () => { + const { fetches, runner } = recordingRunner(); + const { app } = await setup({ git: runner }); + const data = (await app.inject({ method: 'POST', url: '/api/sessions/test-session-1/git-fetch' })).json().data; + expect(data.state).toBe('not-a-repo'); + // The resolver looked once and found nothing, so nothing was fetched — and never guessed a repository. + expect(fetches()).toEqual([]); + }); + + it('404s an unknown session, running no fetch', async () => { + const { calls, fetches, runner } = recordingRunner(); + const { app } = await setup({ git: runner }); + expect((await app.inject({ method: 'POST', url: '/api/sessions/nope/git-fetch' })).statusCode).toBe(404); + expect(fetches()).toEqual([]); + expect(calls).toEqual([]); + }); + + it('multi-user: another user’s session is not found, and no fetch runs', async () => { + process.env.CODEMAN_MULTIUSER = '1'; + git(dir, 'init', '-q', '-b', 'main'); + const { fetches, runner } = recordingRunner(); + const { app } = await setup({ git: runner, authUser: { username: 'bob', role: 'user' } }); + session.owner = 'alice'; + expect((await app.inject({ method: 'POST', url: '/api/sessions/test-session-1/git-fetch' })).statusCode).toBe(404); + expect(fetches()).toEqual([]); + }); + + it('honours maxRepos and timeout (seconds) for a fetch too, clamped, and reports the limit it used', async () => { + for (const n of ['a', 'b', 'c']) { + mkdirSync(join(dir, n)); + git(join(dir, n), 'init', '-q', '-b', 'main'); + } + const { seenTimeouts, fetches, runner } = recordingRunner(); + const { app } = await setup({ git: runner }); + const post = async (query: string) => { + const res = await app.inject({ method: 'POST', url: `/api/sessions/test-session-1/git-fetch${query}` }); + expect(res.statusCode).toBe(200); + return res.json().data as { repoLimit: number; repos: Array<{ name: string }> }; + }; + const fetchCount = () => fetches().length; + const ran = async (query: string, expected: number) => { + const before = fetchCount(); + const data = await post(query); + expect(fetchCount() - before).toBe(expected); + return data; + }; + + const limited = await ran('?maxRepos=2&timeout=7', 2); + expect(limited.repos.map((r) => r.name)).toEqual(['a', 'b']); + expect(limited).toMatchObject({ reposTruncated: true, repoLimit: 2 }); // the window has to say three were found + expect(new Set(seenTimeouts)).toEqual(new Set([7000])); + + const wild = await ran('?maxRepos=9999&timeout=1', 3); + expect(wild.repoLimit).toBe(50); // clamped to MAX_REPOS_LIMIT, so one request cannot scan the whole disk + expect(seenTimeouts.at(-1)).toBe(5000); + + const junk = await ran('?maxRepos=abc&timeout=xyz', 3); + expect(junk.repoLimit).toBe(12); + expect(seenTimeouts.at(-1)).toBe(30_000); + + // A repeated key reaches the route as an array: it means "default", never a 500. + await ran('?timeout=5&timeout=6', 3); + expect(seenTimeouts.at(-1)).toBe(30_000); + }); +}); + function execFileSyncGit(cwd: string, args: string[]): Promise { return Promise.resolve(execFileSync('git', ['--no-optional-locks', ...args], { cwd, env: ENV, encoding: 'utf8' })); } diff --git a/test/routes/session-routes-git-fetch-settings.test.ts b/test/routes/session-routes-git-fetch-settings.test.ts new file mode 100644 index 000000000..4a7111bc3 --- /dev/null +++ b/test/routes/session-routes-git-fetch-settings.test.ts @@ -0,0 +1,104 @@ +/** + * `POST /api/sessions/:id/git-fetch-settings` (session-routes.ts): the per-case half of the fetch interval + * (src/git-fetch-scheduler.ts) — the case's own switch and its own interval. `null` on either half clears + * that half so the app-wide setting applies again, and a body that names one row leaves the other as it was, + * because the Session Options rows save on their own. Refused for remote and Docker cases, the same rule the + * Git panel and the scheduler use. app.inject(), no port. + */ +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import Fastify, { type FastifyInstance } from 'fastify'; +import fastifyCookie from '@fastify/cookie'; +import { createMockRouteContext, type MockRouteContext } from '../mocks/index.js'; +import { installRouteErrorHandler } from '../../src/web/route-error-handler.js'; +import { registerSessionRoutes } from '../../src/web/routes/session-routes.js'; + +interface Harness { + app: FastifyInstance; + ctx: MockRouteContext; +} + +async function createHarness(): Promise { + const app = Fastify({ logger: false }); + await app.register(fastifyCookie); + const ctx = createMockRouteContext(); + registerSessionRoutes(app, ctx); + installRouteErrorHandler(app); + await app.ready(); + return { app, ctx }; +} + +describe('POST /api/sessions/:id/git-fetch-settings', () => { + let harness: Harness; + + beforeEach(async () => { + harness = await createHarness(); + }); + + afterEach(async () => { + await harness.app.close(); + }); + + const post = (payload: unknown) => + harness.app.inject({ method: 'POST', url: `/api/sessions/${harness.ctx._sessionId}/git-fetch-settings`, payload }); + + it('stores the case’s own choice, persists and broadcasts it', async () => { + const res = await post({ enabled: true, intervalSeconds: 120 }); + expect(res.statusCode).toBe(200); + expect(JSON.parse(res.body).data.gitFetchSettings).toEqual({ enabled: true, intervalSeconds: 120 }); + expect(harness.ctx._session.gitFetchEnabled).toBe(true); + expect(harness.ctx._session.gitFetchIntervalSeconds).toBe(120); + expect(harness.ctx.persistSessionState).toHaveBeenCalled(); + }); + + it('answers `null` for a case that follows the app, which is what the Session Options row shows', async () => { + const data = JSON.parse((await post({})).body).data.gitFetchSettings; + expect(data).toEqual({ enabled: null, intervalSeconds: null }); + }); + + it('clears the override on null so the app-wide setting applies again', async () => { + await post({ enabled: true, intervalSeconds: 120 }); + const res = await post({ enabled: null, intervalSeconds: null }); + expect(res.statusCode).toBe(200); + expect(harness.ctx._session.gitFetchEnabled).toBeUndefined(); + expect(harness.ctx._session.gitFetchIntervalSeconds).toBeUndefined(); + expect(JSON.parse(res.body).data.gitFetchSettings).toEqual({ enabled: null, intervalSeconds: null }); + }); + + it('leaves the other half as it was when only one row was saved', async () => { + await post({ enabled: true }); + expect(harness.ctx._session.gitFetchIntervalSeconds).toBeUndefined(); // still following the app on that half + const res = await post({ intervalSeconds: 90 }); + expect(JSON.parse(res.body).data.gitFetchSettings).toEqual({ enabled: true, intervalSeconds: 90 }); + }); + + it('rejects an interval outside the schema bounds, storing nothing, because that is what the scheduler acts on', async () => { + // `null` is NOT on this list: it is the schema's way of clearing the override, tested above. + for (const raw of [29, 3601, 12.5, '120', true]) { + const res = await post({ enabled: true, intervalSeconds: raw }); + expect(res.statusCode, String(raw)).toBe(400); + } + expect(harness.ctx._session.gitFetchEnabled).toBeUndefined(); + expect(harness.ctx.persistSessionState).not.toHaveBeenCalled(); + }); + + it.each([ + ['remote', { host: 'h', user: 'u' }], + ['docker', { container: 'c' }], + ])('refuses a %s case, storing nothing', async (kind, value) => { + (harness.ctx._session as unknown as Record)[kind] = value; + const res = await post({ enabled: true, intervalSeconds: 120 }); + expect(JSON.parse(res.body)).toMatchObject({ success: false, errorCode: 'INVALID_INPUT' }); + expect(harness.ctx._session.gitFetchEnabled).toBeUndefined(); + expect(harness.ctx.persistSessionState).not.toHaveBeenCalled(); + }); + + it('404s an unknown session, storing nothing', async () => { + const res = await harness.app.inject({ + method: 'POST', + url: '/api/sessions/nope/git-fetch-settings', + payload: { enabled: true }, + }); + expect(res.statusCode).toBe(404); + expect(harness.ctx.persistSessionState).not.toHaveBeenCalled(); + }); +}); diff --git a/test/session-options-structure.test.ts b/test/session-options-structure.test.ts index 9f4f26961..e4ca5a254 100644 --- a/test/session-options-structure.test.ts +++ b/test/session-options-structure.test.ts @@ -34,13 +34,18 @@ function methodBody(signature: string): string { return sessionUi.slice(start, sessionUi.indexOf('\n },', start)); } -const TABS = ['respawn', 'context', 'ralph', 'summary']; +const TABS = ['respawn', 'context', 'ralph', 'summary', 'git']; describe('Session Options modal structure', () => { it('keeps every element openSessionOptions and switchOptionsTab touch by id', () => { const modal = optionsModal(); const ids = new Set(); - for (const sig of ['openSessionOptions(sessionId)', 'switchOptionsTab(tabName)', 'getRalphConfig()']) { + for (const sig of [ + 'openSessionOptions(sessionId)', + 'switchOptionsTab(tabName)', + 'getRalphConfig()', + 'updateGitFetchStatus(sessionId)', + ]) { for (const m of methodBody(sig).matchAll(/getElementById\('([A-Za-z0-9_-]+)'\)/g)) ids.add(m[1]); } // openSessionOptions also drives elements outside this modal (tabs, toasts); @@ -83,6 +88,33 @@ describe('Session Options modal structure', () => { expect(modal.match(/]*data-tab="context"[^>]*>/)?.[0]).not.toContain('data-claude-only'); }); + it('keeps the Git rail entry for every local case, and hides it for the ones nothing is ever fetched for', () => { + const modal = optionsModal(); + // A shell case has repositories too, so Git must not ride the Claude-only flag. + expect(modal.match(/]*data-tab="git"[^>]*>/)?.[0]).not.toContain('data-claude-only'); + const open = methodBody('openSessionOptions(sessionId)'); + expect(open).toContain('.set-rail-item[data-tab="git"]'); + // Remote and Docker cases answer 'unsupported' on both git routes and the scheduler never fetches them, + // so the entry goes the same way the Claude-only ones do. + expect(open).toMatch(/gitTabBtn\.style\.display = session\.remote \|\| session\.docker \? 'none' : ''/); + }); + + it('makes the Git rows inert while a case follows the app, the way the keep-warm rows go inert', () => { + const modal = optionsModal(); + const update = methodBody('updateGitFetchStatus(sessionId)'); + expect(update).toContain('set-row-disabled'); + expect(update).toContain('el.disabled = follows'); + // Decided from BOTH fields: a half-choice means the case is not following, and the half that still follows + // shows the app's value while the other shows its own — a switch-only check would gray out a live row. + expect(update).toMatch( + /follows = session\.gitFetchEnabled === undefined && session\.gitFetchIntervalSeconds === undefined/ + ); + // Following saves null on both halves, which is what clears the override on the route. + expect(methodBody('async autoSaveGitFetch()')).toContain('{ enabled: null, intervalSeconds: null }'); + // The status row is runtime text this method writes, so translating it would rewrite the stamp. + expect(modal).toContain('id="gitFetchStatus" data-i18n-skip hidden'); + }); + it('uses the shared settings surface rather than the modal-tab chrome', () => { const modal = optionsModal(); expect(modal).toContain('class="modal-content modal-lg set-shell"'); diff --git a/test/session-settings-recovery.test.ts b/test/session-settings-recovery.test.ts index 881c64fa7..b67c4c4a0 100644 --- a/test/session-settings-recovery.test.ts +++ b/test/session-settings-recovery.test.ts @@ -40,6 +40,7 @@ describe('restoreMuxSessions() hands back every persisted user setting', () => { ['auto-compact', /session\.setAutoCompact\(/], ['auto-resume', /session\.restoreAutoResume\(/], ['nice priority', /session\.setNice\(/], + ['git fetch', /gitFetchEnabled:\s*savedState\?\.gitFetchEnabled/], ])('%s', (_label, pattern) => { expect(body).toMatch(pattern); }); @@ -58,6 +59,15 @@ describe('restoreMuxSessions() hands back every persisted user setting', () => { expect(body, `restoreMuxSessions lacks ${call}`).toContain(call); } }); + + it('re-applies the fetch choice only when the case actually chose one', () => { + // `undefined` is what "follows the app" means here, so a restore that called the setter unconditionally + // would hand every case the app default as if it had picked it, and the half-choice rows would read wrong. + const reapply = functionBody('reapplyPersistedSessionState'); + expect(reapply).toMatch( + /if \(saved\.gitFetchEnabled !== undefined \|\| saved\.gitFetchIntervalSeconds !== undefined\)[\s\S]{0,160}session\.setGitFetchSettings\(saved\.gitFetchEnabled, saved\.gitFetchIntervalSeconds\)/ + ); + }); }); describe('the setters round-trip through toState()', () => { @@ -83,4 +93,26 @@ describe('the setters round-trip through toState()', () => { imageWatcherEnabled: true, }); }); + + it('round-trips the fetch choice, and keeps a case that follows the app unchosen', () => { + const chose = new Session({ workingDir: '/tmp', mode: 'shell' }); + chose.setGitFetchSettings(true, 120); + const state = chose.toState(); + expect(state).toMatchObject({ gitFetchEnabled: true, gitFetchIntervalSeconds: 120 }); + + const rebuilt = new Session({ + workingDir: '/tmp', + mode: 'shell', + gitFetchEnabled: state.gitFetchEnabled, + gitFetchIntervalSeconds: state.gitFetchIntervalSeconds, + }); + expect(rebuilt.toState()).toMatchObject({ gitFetchEnabled: true, gitFetchIntervalSeconds: 120 }); + + // Undefined is the whole meaning of "follows the app": a restore that wrote the app default into the + // case would make it look like the case had picked it, and the row would stop following. + const follower = new Session({ workingDir: '/tmp', mode: 'shell' }); + follower.setGitFetchSettings(undefined, undefined); + expect(follower.toState().gitFetchEnabled).toBeUndefined(); + expect(follower.toState().gitFetchIntervalSeconds).toBeUndefined(); + }); }); diff --git a/test/tab-layout-settings-i18n.test.ts b/test/tab-layout-settings-i18n.test.ts index 55b68ac49..fd02807c0 100644 --- a/test/tab-layout-settings-i18n.test.ts +++ b/test/tab-layout-settings-i18n.test.ts @@ -140,11 +140,13 @@ describe('the Bottom bar settings group in zh-CN', () => { /** What may stay Latin here: the git and Docker names, and the product name. */ const left = (text: string) => text.replace(/\b(Git|git|Docker|Codeman)\b/g, '').match(/[A-Za-z]+/g) ?? []; - it("finds the heading and the four Git status rows, #543's two included (the check is not vacuous)", () => { - expect(englishGroup.size).toBe(9); + it("finds the heading and the six Git rows, #543's two included and the fetch pair among them (the check is not vacuous)", () => { + expect(englishGroup.size).toBe(13); expect(englishGroup.get('heading')).toBe('Bottom bar'); expect(englishGroup.get('appSettingsGitStatusMaxRepos label')).toBe('Git status: max repositories'); expect(englishGroup.get('appSettingsGitStatusTimeout label')).toBe('Git status: git timeout'); + expect(englishGroup.get('appSettingsGitFetchEnabled label')).toBe('Git fetch on an interval'); + expect(englishGroup.get('appSettingsGitFetchIntervalSeconds label')).toBe('Git fetch: how often'); }); it('translates the heading, every label and every description, with no English left', () => { @@ -167,7 +169,8 @@ describe('the Bottom bar settings group in zh-CN', () => { expect([...englishGroup.values()].filter((s) => t.t(s) !== s)).toEqual([]); en.window.close(); const repeated = [...englishGroup.values()].filter((s) => { - const key = s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); + // A key with an apostrophe is written escaped in the object literal, so the pattern must allow both spellings. + const key = s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&').replace(/'/g, "(?:\\\\)?'"); return (I18N.match(new RegExp(`^\\s*(?:'${key}'|"${key}"|${key}):`, 'gm')) ?? []).length !== 1; }); expect(repeated).toEqual([]); @@ -179,6 +182,6 @@ describe('the Bottom bar settings group in zh-CN', () => { const labels = [...doc.querySelectorAll('#gitStatusPanel .git-status-actions button')].map((b) => b.getAttribute('aria-label') ); - expect(labels).toEqual(['刷新 Git 状态', '关闭 Git 状态']); + expect(labels).toEqual(['刷新 Git 状态', '拉取 Git 状态', '关闭 Git 状态']); }); }); From ea4318947b3bd0475171a05fb52190f1b91b4d26 Mon Sep 17 00:00:00 2001 From: Devvyn <22340871+opticon454@users.noreply.github.com> Date: Sun, 25 Oct 2026 20:04:01 +0800 Subject: [PATCH 31/68] fix(git): the interval fetches one run per folder, and the rest of the review (review) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The scheduler is the part that runs with nobody watching, so it is decided per folder rather than per session: sessions sharing a folder are the same clone and share one run that both see, `inFlight`/`lastRunAt` are keyed by the resolved folder, the schedule is stamped when a folder is picked instead of when its run starts, and a tick is skipped while the previous one is still going. The click route joins a run already going for the same folder rather than starting a second fetch in one clone. The tick keeps ONE bound for the git processes of the whole run and hands it to every folder, and bounds its folders with a bound of its own: the same semaphore cannot serve both levels, because a folder holding a slot would then wait for a slot its own fetch needs. Both bounds are `FETCH_CONCURRENCY`, so the ceiling is unchanged. The bound claim in CLAUDE.md, the `Limiter` jsdoc and docs/api-reference.md now describes it as it is. Before anything is fetched the folder is probed with the bounded path probe, so a case on a mount that stopped answering is skipped and never touched. The rest of the review: the hooks are named as they really are (`reference-transaction`, `pre-auto-gc` through the maintenance a fetch triggers, config commands such as `credential.helper`) and `FETCH_CONFIG_ARGS` turns maintenance and gc off for an unattended run; the claim that an agent in the same repository is not disturbed is replaced by the ref-lock collision it actually is; the click route publishes its own run as `trigger: 'click'`; `autoSaveGitFetch` uses `_apiJson` so a 400 shows the error toast and applies the returned settings; and the two wording fixes. Tests: the scheduler pins one run per folder, the two bounds, the probe before anything runs, and that closing the session holding the override keeps the folder while another session is still open on it; the route test pins the click publish and the join, asserting the join before the held run is released — releasing first lets the run finish and clears the lane, so the joiner would be entitled to its own fetch and the test would pass for the wrong reason. --- CLAUDE.md | 2 +- README.md | 2 +- docs/api-reference.md | 4 +- docs/wiki/Settings-Reference.md | 12 +- docs/wiki/Working-With-Files.md | 14 +- src/git-fetch-scheduler.ts | 196 +++++++++++++++------- src/git-fetch.ts | 86 ++++++++-- src/git-workspace-status.ts | 49 +++++- src/types/session.ts | 10 +- src/web/public/i18n.js | 20 +-- src/web/public/index.html | 20 ++- src/web/public/session-ui.js | 17 +- src/web/routes/git-status-routes.ts | 40 +++-- src/web/routes/session-routes.ts | 12 +- src/web/schemas.ts | 8 +- src/web/server.ts | 14 +- test/git-fetch-scheduler.test.ts | 218 ++++++++++++++++++------- test/git-fetch.test.ts | 187 ++++++++++++++++++++- test/routes/git-status-routes.test.ts | 51 ++++++ test/session-options-structure.test.ts | 21 ++- 20 files changed, 774 insertions(+), 209 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index b267b4200..201306f9a 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -236,7 +236,7 @@ Codeman is a Claude Code session manager with web interface and autonomous Ralph **Git status indicator** (`showGitStatus`, per-device, default OFF; `src/git-workspace-status.ts`, `routes/git-status-routes.ts`, `git-status-ui.js`): the bottom-bar indicator and its panel read `GET /api/sessions/:id/git-status` and `/git-diff`, read-only and offline on its own (it never fetches; fetching is the separate path below). ⚠️ git never runs on a repository at or inside a Docker case workspace (walk-up, scan and diff alike, and a cached repository list is re-checked against the CURRENT Docker roots), since a container could plant a clean filter that runs on the host; remote and Docker sessions answer `unsupported`. ⚠️ `git-diff` takes `repo`/`path`/`kind` only as keys matched against the workspace's own repository list (`findWorkspaceRepo()`) and that repository's current status, never as paths. ⚠️ Keep `--no-optional-locks`, `core.fsmonitor=false` and `log.showSignature=false` on every call and `--no-ext-diff --no-textconv` on diffs; clean filters still run, which is why the Docker rule exists. ⚠️ An enclosing repository at `$HOME` or above is ignored (`isUnrelatedAncestor()`), and is identified by one cached `rev-parse` before any full status runs. Everything git supplies renders via `textContent`. Tests: `test/git-workspace-status.test.ts`, `test/routes/git-status-routes.test.ts`, and `test/git-status.browser.test.ts` (browser suite, not in the gate). -**Git fetch** (`src/git-fetch.ts`, `src/git-fetch-scheduler.ts`, `routes/git-status-routes.ts`, the panel's **Fetch** button): `POST /api/sessions/:id/git-fetch` is the ONLY git command Codeman runs that WRITES, and the only one that runs a repository's own `fetch-post` hook — which is why it is opt-in and never runs on a timer until the user turns it on. ⚠️ Fetch updates remote-tracking refs only; it must never commit, check out, merge or touch the working tree, since an agent is often working in the same repository. ⚠️ The same Docker rule as above applies BEFORE any git runs (a repository at or inside a Docker case workspace is refused), and remote sessions answer `unsupported`. ⚠️ Keep it non-interactive (`gitNonInteractiveEnv()`: `GIT_TERMINAL_PROMPT=0` + BatchMode ssh) or a fetch hangs on an invisible login prompt, and bounded: `MAX_REPOS`/`MAX_REPOS_LIMIT` (12/50), `FETCH_CONCURRENCY` (4), `MAX_FETCH_OUTPUT_BYTES` (2 MB) and the git timeout per spawn. ⚠️ `buildFetchArgs()` is `['fetch', '--quiet']` and must stay verb-invariant (`test/git-workspace-status.test.ts`). ⚠️ Failure text reaching a client is control-stripped and credential-redacted (`sanitizeGitOutput`) and the browser writes it with `textContent`. Scheduler semantics: settings are read fresh each tick (`GIT_FETCH_TICK_INTERVAL_MS`, 15 s), a per-case override wins even while the app-wide switch is off, the interval is timed from the run START, runs never overlap, and remote/Docker cases are re-checked every tick. ⚠️ The two settings (`gitFetchEnabled`, `gitFetchIntervalSeconds`, off/300 by default, clamped 30–3600) are **synced**, not per-device: they must stay out of `displayKeys` and out of the strip destructure in settings-ui.js, or the scheduler reads nobody's value. Per-case state rides `SessionUpdated` (no new SSE event) as `gitFetch`. Tests: `test/git-fetch.test.ts`, `test/git-fetch-scheduler.test.ts`, `test/routes/session-routes-git-fetch-settings.test.ts`, `test/git-fetch-i18n.test.ts`, `test/app-settings-structure.test.ts`, `test/session-options-structure.test.ts`, and the Fetch block of `test/git-status.browser.test.ts` (browser suite, not in the gate). +**Git fetch** (`src/git-fetch.ts`, `src/git-fetch-scheduler.ts`, `routes/git-status-routes.ts`, the panel's **Fetch** button): `POST /api/sessions/:id/git-fetch` is the ONLY git command Codeman runs that WRITES, and the only one that can run a repository's own hooks (`reference-transaction`, `pre-auto-gc` through the automatic maintenance a fetch triggers, config commands such as `credential.helper`) — which is why it is opt-in and never runs on a timer until the user turns it on. ⚠️ Fetch updates remote-tracking refs only; it must never commit, check out, merge or touch the working tree. ⚠️ It is not as harmless as a read: a fetch holds git's ref lock, so an agent's own `git pull` in the same clone can hit the same `cannot lock ref` — hence one folder is fetched by at most one run at a time (`fetchWorkspaceOnce` joins a run already going), and `FETCH_CONFIG_ARGS` (`-c core.fsmonitor=false -c maintenance.auto=false -c gc.auto=0`) keeps an unattended fetch from starting a gc in a repository the agent is using. ⚠️ `buildFetchArgs()` is `['fetch', '--quiet']` and must stay verb-invariant (`test/git-workspace-status.test.ts`); those `-c` overrides are added by the runner, not by `buildFetchArgs()`. ⚠️ The same Docker rule as above applies BEFORE any git runs (a repository at or inside a Docker case workspace is refused), and remote sessions answer `unsupported`. ⚠️ Keep it non-interactive (`gitNonInteractiveEnv()`: `GIT_TERMINAL_PROMPT=0` + BatchMode ssh) or a fetch hangs on an invisible login prompt, and bounded: `MAX_REPOS`/`MAX_REPOS_LIMIT` (12/50), `MAX_FETCH_OUTPUT_BYTES` (2 MB), the git timeout per spawn, and `FETCH_CONCURRENCY` (4) as ONE `Limiter` shared by the fetches of the whole tick — `createLimiter` keeps its count in the closure, so a limiter that delegated to `mapLimited` per call bounds each call, and 4 folders × 4 repositories would start 16 git processes. ⚠️ One semaphore cannot bound both the folders and the fetches: a folder holding a slot would then wait for a slot its own fetch needs, so the tick bounds its folders with `mapLimited` and hands the shared bound to every run. ⚠️ Failure text reaching a client is control-stripped and credential-redacted (`sanitizeGitOutput`) and the browser writes it with `textContent`. Scheduler semantics: settings are read fresh each tick (`GIT_FETCH_TICK_INTERVAL_MS`, 15 s), a per-session override wins even while the app-wide switch is off, the interval is timed from the run START, state is keyed by FOLDER (sessions sharing one folder are the same clone, so one run serves them all, the schedule is stamped at selection, and every session on a folder is remembered — closing the one that holds the override must not stop the folder's fetching while another session is still open on it), a tick is skipped while the previous one is still going, and a user-chosen folder is probed BOUNDED with the default options before anything runs (rule #516 — `unknown` is never read as `absent`). ⚠️ The two settings (`gitFetchEnabled`, `gitFetchIntervalSeconds`, off/300 by default, clamped 30–3600) are **synced**, not per-device: they must stay out of `displayKeys` and out of the strip destructure in settings-ui.js, or the scheduler reads nobody's value. Per-session state rides `SessionUpdated` (no new SSE event) as `gitFetch`, and the click route publishes its own run with `trigger: 'click'` — the UI's "by click" text depends on it. Tests: `test/git-fetch.test.ts`, `test/git-fetch-scheduler.test.ts`, `test/routes/session-routes-git-fetch-settings.test.ts`, `test/git-fetch-i18n.test.ts`, `test/app-settings-structure.test.ts`, `test/session-options-structure.test.ts`, and the Fetch block of `test/git-status.browser.test.ts` (browser suite, not in the gate). **Raw file bodies are streamed and range-aware**: `file-raw`, the attachments `/raw` route, `GET /api/download` and the rendered-HTML route `GET /html-view//...` share `sendFileBody()`, advertise `Accept-Ranges: bytes` and answer `Range` with `206` + `Content-Range` (single-range, parser in `src/web/http-range.ts`); without it `
-

Whether Codeman fetches this case's repositories on a timer.

+

Whether Codeman fetches this session's repositories on a timer.

Fetch on an interval

Fetching is the only git command Codeman runs that writes, and the only one that runs a repository's own hooks, so nothing runs on a timer until you turn it on — here, or in App Settings → Bottom bar. The panel's Fetch button is always just a click.

- +
Status @@ -1749,14 +1751,14 @@

Git

Follow the app setting - Uses the app-wide switch and interval for this case. Switch it off to pick your own. + Uses the app-wide switch and interval for this session. Switch it off to pick your own.
- Fetch this case - Runs on its own even while the app-wide switch is off: a case that turned itself on means exactly that. + Fetch this session + Runs on its own even while the app-wide switch is off: a session that turned itself on means exactly that. Sessions sharing one folder share one run, and each sees that run's result.
@@ -2278,14 +2280,14 @@

Header & Panels

Git fetch on an interval synced - Runs `git fetch` for the repositories the Git window lists on a timer, so the "behind" number is current without a click. Off by default: this is the one git command Codeman runs that writes, and the one that runs a repository's own hooks, so nothing runs on a timer until you turn it on. A case can override both the switch and the interval in Session Options → Git. + Runs `git fetch` for the repositories the Git window lists on a timer, so the "behind" number is current without a click. Off by default: this is the one git command Codeman runs that writes, and the one that runs a repository's own hooks, so nothing runs on a timer until you turn it on. A session can override both the switch and the interval in Session Options → Git.
Git fetch: how often synced - Seconds between runs for a case that has no override of its own (30 to 3600, default 300). Only a case that is on — here, or in Session Options → Git — is fetched. + Seconds between runs for a session that has no override of its own (30 to 3600, default 300). Only a session that is on — here, or in Session Options → Git — is fetched, and sessions sharing one folder are fetched by one run.
diff --git a/src/web/public/session-ui.js b/src/web/public/session-ui.js index 10f1ec86c..d399423ad 100644 --- a/src/web/public/session-ui.js +++ b/src/web/public/session-ui.js @@ -3048,7 +3048,7 @@ Object.assign(CodemanApp.prototype, { if (!session) return; const app = this.loadAppSettingsFromStorage() || {}; - // No choice of its own on either field means the case follows the app. A partial one — its own switch, + // No choice of its own on either field means the session follows the app. A partial one — its own switch, // the app's interval — means it does not, and the row that still follows shows the app's value. const follows = session.gitFetchEnabled === undefined && session.gitFetchIntervalSeconds === undefined; const enabled = follows ? app.gitFetchEnabled === true : session.gitFetchEnabled ?? false; @@ -3069,7 +3069,7 @@ Object.assign(CodemanApp.prototype, { run.state === 'ok' ? `${run.repos} repositories` : run.state, run.failed ? `${run.failed} failed` : '', ] - : [follows ? 'Nothing fetched on a timer yet, and this case follows the app setting.' : 'Nothing fetched on a timer yet for this case.']; + : [follows ? 'Nothing fetched on a timer yet, and this session follows the app setting.' : 'Nothing fetched on a timer yet for this session.']; statusEl.textContent = parts.filter(Boolean).join(' · '); statusEl.hidden = false; }, @@ -3096,9 +3096,18 @@ Object.assign(CodemanApp.prototype, { ), }; try { - const data = await this._apiPost(`/api/sessions/${this.editingSessionId}/git-fetch-settings`, body); + // `_apiPost` hands back the Response without checking it, so a refused body (a 400) still read as a + // save. `_apiJson` is the one that says whether the call went through, and null here means it did not. + const data = await this._apiJson(`/api/sessions/${this.editingSessionId}/git-fetch-settings`, { + method: 'POST', + body, + }); + if (!data) { + this.showToast('Failed to save Git fetch settings', 'error'); + return; + } const session = this.sessions.get(this.editingSessionId); - const saved = data?.gitFetchSettings; + const saved = data.gitFetchSettings; if (session && saved) { session.gitFetchEnabled = saved.enabled ?? undefined; session.gitFetchIntervalSeconds = saved.intervalSeconds ?? undefined; diff --git a/src/web/routes/git-status-routes.ts b/src/web/routes/git-status-routes.ts index 39c475f59..1e078c6e8 100644 --- a/src/web/routes/git-status-routes.ts +++ b/src/web/routes/git-status-routes.ts @@ -16,10 +16,10 @@ import type { FastifyInstance } from 'fastify'; import { ApiErrorCode, createErrorResponse, getErrorMessage, type ApiResponse } from '../../types.js'; import { redactGitCredentials } from '../../git-clone.js'; -import { emptyReport, fetchGitWorkspaceOverview, type GitFetchReport } from '../../git-fetch.js'; +import { emptyReport, fetchWorkspaceOnce, gitFetchStatusFrom, type GitFetchReport } from '../../git-fetch.js'; import { readDockerCases } from '../../docker-hosts.js'; import { getDataDir } from '../../config/instance.js'; -import { findSessionOrFail } from '../route-helpers.js'; +import { findSessionOrFail, persistAndBroadcastSession } from '../route-helpers.js'; import { emptyOverview, findWorkspaceRepo, @@ -32,7 +32,7 @@ import { type GitRunner, type GitWorkspaceOverview, } from '../../git-workspace-status.js'; -import type { SessionPort } from '../ports/index.js'; +import type { EventPort, SessionPort } from '../ports/index.js'; /** * Host paths of every Docker case workspace: repositories at or inside these are never inspected. @@ -58,7 +58,9 @@ function limitsFrom(query: { maxRepos?: unknown; timeout?: unknown }) { export function registerGitStatusRoutes( app: FastifyInstance, - ctx: SessionPort, + // EventPort as well as SessionPort: the fetch route publishes the run it did, and a route that broadcasts + // has to say so in its own signature. + ctx: SessionPort & EventPort, git?: GitRunner, dockerWorkspaces: () => Promise = defaultDockerWorkspaces ): void { @@ -131,9 +133,14 @@ export function registerGitStatusRoutes( // Fetch, and only because the user clicked Fetch in the panel (src/git-fetch.ts): one // `git fetch --quiet` per repository the panel would show, through the same resolver so the two cannot // disagree about which repositories belong to the workspace. This is the only git command Codeman runs - // that writes, and it writes remote-tracking refs only — nothing commits, checks out or merges, so an - // agent working in the same repository is not disturbed. Remote and Docker sessions answer `unsupported` - // like the status route, and a repository a container can write to is refused before any git runs. + // that writes, and it writes remote-tracking refs only — nothing commits, checks out or merges. It is not + // as harmless as a read though: a fetch holds git's ref lock, so an agent's own `git pull` in the same + // clone can collide with it, which is why the run goes through `fetchWorkspaceOnce` — a click while a tick + // is already fetching this folder joins that run instead of starting a second one. It can also run a + // repository's own hooks (`reference-transaction`, `pre-auto-gc` through the maintenance git runs after a + // fetch) and its config commands such as `credential.helper`, so the `FETCH_CONFIG_ARGS` overrides turn + // maintenance and gc off, and a repository a container can write to is refused before any git runs. Remote + // and Docker sessions answer `unsupported` like the status route. app.post('/api/sessions/:id/git-fetch', async (req): Promise> => { const { id } = req.params as { id: string }; const query = req.query as { maxRepos?: unknown; timeout?: unknown }; @@ -141,13 +148,16 @@ 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' }) }; - return { - success: true, - data: await fetchGitWorkspaceOverview(session.workingDir, { - git, - ...limits, - dockerWorkspaces: await dockerWorkspaces(), - }), - }; + const report = await fetchWorkspaceOnce(session.workingDir, { + git, + ...limits, + dockerWorkspaces: await dockerWorkspaces(), + }); + // The response answers the click, and the run is also the session's current truth: what the panel's + // "behind" number is based on. Publishing it is what the api docs say a click does, and what makes a + // second tab open on the same folder see the same run — including a click that joined a tick already + // going, where the row has to say who last fetched. + if (session.setGitFetchStatus(gitFetchStatusFrom(report, 'click'))) persistAndBroadcastSession(ctx, session); + return { success: true, data: report }; }); } diff --git a/src/web/routes/session-routes.ts b/src/web/routes/session-routes.ts index 337725a65..1cf28ac9a 100644 --- a/src/web/routes/session-routes.ts +++ b/src/web/routes/session-routes.ts @@ -3611,11 +3611,13 @@ export function registerSessionRoutes( // ========== Git fetch on an interval ========== - // Per-case override for the fetch interval (src/git-fetch-scheduler.ts). `null` on either field clears - // the case's choice so the app-wide setting applies again, and a field left out of the body leaves the - // other half as it was: the Session Options rows save on their own. Refused for a remote or Docker - // session, exactly as the Git panel excludes them — a container can write to the repository it mounts, - // so git there would run that repository's own configuration on the host. + // Per-session override for the fetch interval (src/git-fetch-scheduler.ts), which is what the Session + // Options rows say it is: two sessions on one folder can hold different choices, and that folder is + // fetched when the first of them is due. `null` on either field clears this session's choice so the + // app-wide setting applies again, and a field left out of the body leaves the other half as it was: the + // Session Options rows save on their own. Refused for a remote or Docker session, exactly as the Git + // panel excludes them — a container can write to the repository it mounts, so git there would run that + // repository's own configuration on the host. app.post('/api/sessions/:id/git-fetch-settings', async (req) => { const { id } = req.params as { id: string }; const body = parseBody(GitFetchSettingsSchema, req.body, 'Invalid request body'); diff --git a/src/web/schemas.ts b/src/web/schemas.ts index e5a2be118..ec121450f 100644 --- a/src/web/schemas.ts +++ b/src/web/schemas.ts @@ -1492,9 +1492,11 @@ export const SettingsUpdateSchema = z * Git fetch on an interval (src/git-fetch-scheduler.ts): `git fetch` for the repositories the Git panel * lists, run on a timer instead of only on the panel's Fetch click. SYNCED, default OFF, because the * server is what runs it: `git fetch` is the only git command Codeman runs that writes, and the only - * one that runs a repository's own hooks, so nothing runs on a timer until the user turns this on. - * Cap in seconds between runs (30 to 3600, default 300). A case can override both in Session Options - * → Git; `null` there clears the override. + * one that can run a repository's own hooks (`reference-transaction`, `pre-auto-gc` through the + * automatic maintenance a fetch triggers, config commands such as `credential.helper`), so nothing runs + * on a timer until the user turns this on. Cap in seconds between runs (30 to 3600, default 300). A + * session can override both in Session Options → Git; `null` there clears the override, and sessions + * sharing one folder are fetched by one run. */ gitFetchEnabled: z.boolean().optional(), gitFetchIntervalSeconds: z.number().int().min(30).max(3600).optional(), diff --git a/src/web/server.ts b/src/web/server.ts index aa1e8f5db..54961d1dc 100644 --- a/src/web/server.ts +++ b/src/web/server.ts @@ -124,8 +124,9 @@ import { AI_CHECK_MODEL } from '../config/ai-defaults.js'; import { approvalInbox } from './approval-inbox.js'; import { SCREEN_BLOCKED_SOURCE } from './screen-blocked.js'; import { PromptCacheKeepWarmService } from '../prompt-cache-keepwarm.js'; -import { fetchGitWorkspaceOverview } from '../git-fetch.js'; +import { fetchWorkspaceOnce } from '../git-fetch.js'; import { GitFetchScheduler } from '../git-fetch-scheduler.js'; +import { probePathKind } from '../utils/bounded-path-probe.js'; import { defaultDockerWorkspaces } from './routes/git-status-routes.js'; import { composerIsEmpty, plainFrame } from '../session-submit-verifier.js'; import { detectUsageLimitPause } from '../usage-limit-patterns.js'; @@ -405,12 +406,19 @@ export class WebServer extends EventEmitter { readSettings: readGitFetchSettings, // The same Docker list the panel's routes use: the rule has to be one rule. dockerWorkspaces: defaultDockerWorkspaces, - fetch: (cwd, opts) => fetchGitWorkspaceOverview(cwd, opts), + // Bounded probe, DEFAULT options (rule #516): a tick is a bulk scan, so it keeps the stall cap and must + // never be handed `pastCap`. A folder on a mount that stopped answering is skipped, not fetched. + probePath: (path) => probePathKind(path), + // Through the join, so the tick and the panel's Fetch button are the same run: a click on a folder the + // tick is already fetching joins it rather than starting a second `git fetch` in the same clone. + fetch: (cwd, opts) => fetchWorkspaceOnce(cwd, opts), publish: (id, status) => { const session = this.sessions.get(id); if (!session?.setGitFetchStatus(status)) return; // No new SSE event: the status rides the session state the page already listens for, and the Git - // panel polls on its own clock. Persisted, because what a run found is the panel's current truth. + // panel polls on its own clock. It is written to disk with the rest of the record, but the restore + // copies only the fields its own config lists and this is not one of them (src/types/session.ts), so + // what a run found is the panel's truth for the life of the session, not across a restart. this.persistSessionState(session); this.broadcast(SseEvent.SessionUpdated, this.getSessionStateWithRespawn(session)); }, diff --git a/test/git-fetch-scheduler.test.ts b/test/git-fetch-scheduler.test.ts index 6bf5029b9..ad6932ac9 100644 --- a/test/git-fetch-scheduler.test.ts +++ b/test/git-fetch-scheduler.test.ts @@ -1,8 +1,12 @@ /** - * @fileoverview The Git fetch interval scheduler (src/git-fetch-scheduler.ts): which cases a tick runs, and - * which it must not. Every rule is checked by calling `tick(now)` with an explicit clock, so nothing here + * @fileoverview The Git fetch interval scheduler (src/git-fetch-scheduler.ts): which sessions a tick runs, + * and which it must not. Every rule is checked by calling `tick(now)` with an explicit clock, so nothing here * depends on fake timers or on how long a real tick takes — the scheduler owns no timers itself. * + * The run belongs to the FOLDER, so these tests put two sessions on one folder and expect one fetch that + * both see, and they check the tick's two bounds separately: one shared bound for the fetches of the whole + * tick, and the folders bounded by a bound that cannot be the same semaphore. + * * Port: N/A. */ import { describe, expect, it, vi } from 'vitest'; @@ -14,11 +18,13 @@ import { } from '../src/git-fetch-scheduler.js'; import { FETCH_CONCURRENCY, + type GitFetchOptions, type GitFetchReport, type GitFetchSettings, type GitFetchStatus, } from '../src/git-fetch.js'; import { DEFAULT_GIT_TIMEOUT_MS, MAX_REPOS } from '../src/git-workspace-status.js'; +import type { PathProbeKind } from '../src/utils/bounded-path-probe.js'; const SETTINGS: GitFetchSettings = { enabled: true, intervalSeconds: 300 }; const VIEW = (id: string, extra: Partial = {}): GitFetchSessionView => ({ @@ -38,10 +44,19 @@ const report = (cwd: string, name: string): GitFetchReport => ({ type Harness = { scheduler: GitFetchScheduler; ports: GitFetchPorts; - calls: Array<{ id: string; opts: unknown }>; + /** The folders the tick actually ran, in the order they were picked. */ + calls: Array<{ cwd: string; opts: GitFetchOptions }>; published: Array<{ id: string; status: GitFetchStatus }>; + /** Every path the tick probed, in order — the probe has to happen before anything is fetched. */ + probed: string[]; settings: GitFetchSettings; dockerReads: number; + /** + * A session that closed: the server calls `remove()` when the session is gone, so this takes it out of the + * ports as well as out of the scheduler. Otherwise a test that removes a session still sees it on the next + * tick, and the folder it was on looks still open. + */ + remove(id: string): void; }; /** A run the test decides when to let finish, so a slow repository can be held open across ticks. */ @@ -57,21 +72,32 @@ function deferred(value: T) { */ function harness( sessions: GitFetchSessionView[], - opts: { settings?: GitFetchSettings; fetch?: (id: string) => Promise } = {} + opts: { + settings?: GitFetchSettings; + fetch?: (cwd: string, portOpts: GitFetchOptions) => Promise; + /** Default: every path answers as a folder, so a test that does not care about the probe stays quiet. */ + probe?: (path: string) => Promise; + } = {} ): Harness { const customFetch = opts.fetch; const settings = opts.settings ?? { ...SETTINGS }; + const gone = new Set(); const h: Harness = { scheduler: undefined as unknown as GitFetchScheduler, ports: undefined as unknown as GitFetchPorts, calls: [], published: [], + probed: [], settings, dockerReads: 0, + remove: (id) => { + gone.add(id); + h.scheduler.remove(id); + }, }; h.ports = { - sessionIds: () => sessions.map((s) => s.id), - getSession: (id) => sessions.find((s) => s.id === id), + sessionIds: () => sessions.filter((s) => !gone.has(s.id)).map((s) => s.id), + getSession: (id) => (gone.has(id) ? undefined : sessions.find((s) => s.id === id)), readSettings: async () => { if (h.settings === null) throw new Error('settings.json is mid-write'); return h.settings; @@ -80,10 +106,13 @@ function harness( h.dockerReads++; return []; }, + probePath: async (path) => { + h.probed.push(path); + return opts.probe ? await opts.probe(path) : 'directory'; + }, fetch: async (cwd, portOpts) => { - const id = cwd.slice(3); // the '/w/' the view was built with - h.calls.push({ id, opts: portOpts }); - return customFetch ? customFetch(id) : report(cwd, id); + h.calls.push({ cwd, opts: portOpts }); + return customFetch ? customFetch(cwd, portOpts) : report(cwd, cwd.slice(3)); }, publish: (id, status) => h.published.push({ id, status }), }; @@ -96,7 +125,8 @@ describe('a quiet tick', () => { const h = harness([VIEW('a'), VIEW('b')], { settings: { enabled: false, intervalSeconds: 300 } }); expect(await h.scheduler.tick(1000)).toBe(0); expect(h.calls).toEqual([]); - // The Docker case list is only worth reading once something is actually due. + // The probe and the Docker case list are only worth reading once something is actually due. + expect(h.probed).toEqual([]); expect(h.dockerReads).toBe(0); }); @@ -125,16 +155,38 @@ describe('a quiet tick', () => { expect(logged).toHaveBeenCalled(); h.settings = SETTINGS; - // Nothing was recorded for the failed tick, so the case is still due at once. + // Nothing was recorded for the failed tick, so the folder is still due at once. expect(await h.scheduler.tick(1000)).toBe(1); }); }); +describe('the bounded probe', () => { + it('probes the folder before fetching it, and skips anything that is not a directory', async () => { + const kinds = new Map([ + ['/w/file', 'file'], + ['/w/unknown', 'unknown'], + ['/w/absent', 'absent'], + ]); + const h = harness([VIEW('file'), VIEW('unknown'), VIEW('absent'), VIEW('ok')], { + probe: async (path) => kinds.get(path) ?? 'directory', + }); + const logged = vi.fn(); + h.ports.log = logged; + expect(await h.scheduler.tick(0)).toBe(1); + expect(h.calls.map((c) => c.cwd)).toEqual(['/w/ok']); + // The folders that answered badly were probed and left alone, never fetched. + expect(h.probed).toEqual(['/w/file', '/w/unknown', '/w/absent', '/w/ok']); + expect(logged).toHaveBeenCalledTimes(3); + // `unknown` is never read as `absent`: the log says what it answered rather than what it was assumed. + expect(logged.mock.calls.join(' ')).toContain('unknown'); + }); +}); + describe('who runs', () => { - it('runs every case the app setting covers, once per interval', async () => { + it('runs every session the app setting covers, once per interval', async () => { const h = harness([VIEW('a'), VIEW('b')], { settings: { enabled: true, intervalSeconds: 60 } }); await h.scheduler.tick(0); - expect(h.calls.map((c) => c.id).sort()).toEqual(['a', 'b']); + expect(h.calls.map((c) => c.cwd).sort()).toEqual(['/w/a', '/w/b']); await h.scheduler.tick(30_000); // not due yet expect(h.calls.length).toBe(2); @@ -143,29 +195,29 @@ describe('who runs', () => { expect(h.calls.length).toBe(4); }); - it('runs a case that turned itself on even while the app-wide switch is off — that is what a per-case switch has to mean', async () => { + it('runs a session that turned itself on even while the app-wide switch is off — that is what a per-session switch has to mean', async () => { const h = harness([VIEW('plain'), VIEW('own', { gitFetchEnabled: true, gitFetchIntervalSeconds: 30 })], { settings: { enabled: false, intervalSeconds: 300 }, }); await h.scheduler.tick(0); - expect(h.calls.map((c) => c.id)).toEqual(['own']); + expect(h.calls.map((c) => c.cwd)).toEqual(['/w/own']); // Its own interval, not the app's: due at 30 s, not at 300. await h.scheduler.tick(30_000); expect(h.calls.length).toBe(2); }); - it('never fetches a remote or Docker case, exactly as the Git panel excludes them', async () => { + it('never fetches a remote or Docker session, exactly as the Git panel excludes them', async () => { const h = harness([ VIEW('ssh', { remote: { host: 'x' } }), VIEW('docker', { docker: { container: 'c' } }), VIEW('local'), ]); await h.scheduler.tick(0); - expect(h.calls.map((c) => c.id)).toEqual(['local']); + expect(h.calls.map((c) => c.cwd)).toEqual(['/w/local']); }); - it('re-checks the Docker rule every tick, so a case that became a Docker case stops fetching', async () => { + it('re-checks the Docker rule every tick, so a session whose case became a Docker case stops fetching', async () => { const sessions = [VIEW('a')]; const h = harness(sessions); await h.scheduler.tick(0); @@ -183,53 +235,94 @@ describe('who runs', () => { }); }); -describe('one run at a time', () => { - it('skips a case still being fetched instead of stacking runs on it', async () => { - const gate = deferred(report('/w/a', 'a')); - const h = harness([VIEW('a')], { settings: { enabled: true, intervalSeconds: 30 }, fetch: () => gate.promise }); - const running = h.scheduler.tick(0); - await new Promise((resolve) => setImmediate(resolve)); // the run has started, and is still held open - expect(await h.scheduler.tick(15_000)).toBe(0); +describe('one folder, one run', () => { + it('fetches a shared folder once and publishes the result to every session open on it', async () => { + const h = harness([VIEW('a'), { ...VIEW('b'), workingDir: '/w/a' }]); + expect(await h.scheduler.tick(0)).toBe(1); // one folder, not two runs expect(h.calls.length).toBe(1); - gate.resolve(report('/w/a', 'a')); - await running; + expect(h.published.map((p) => p.id).sort()).toEqual(['a', 'b']); + // Both see the same run, so the panel's row says the same thing in both tabs. + expect(new Set(h.published.map((p) => p.status.failed)).size).toBe(1); }); - it('times the interval from the START of a run, so a slow repository does not push the next one out', async () => { - const slow = deferred(report('/w/a', 'a')); - const h = harness([VIEW('a')], { settings: { enabled: true, intervalSeconds: 30 }, fetch: () => slow.promise }); + it('does not pick a folder again while its run is going, and does not run a second tick over the same selection', async () => { + const ids = Array.from({ length: FETCH_CONCURRENCY + 2 }, (_, i) => `s${i}`); + const gate = deferred(report('/w/first', 'first')); + const h = harness( + ids.map((id) => VIEW(id)), + { + settings: { enabled: true, intervalSeconds: 30 }, + fetch: () => gate.promise, + } + ); const first = h.scheduler.tick(0); - await new Promise((resolve) => setImmediate(resolve)); - expect(h.calls.length).toBe(1); + await new Promise((resolve) => setImmediate(resolve)); // the first runs are going, the rest are queued + const picked = h.calls.length; + expect(picked).toBe(FETCH_CONCURRENCY); - // The run takes 200 s, longer than its own interval. Once it lands, the case is already past due: the - // next tick runs it again instead of waiting another 30 s from when the slow one finished. - slow.resolve(report('/w/a', 'a')); + // The server fires the next tick without waiting. The schedule was stamped at selection, so the folders + // already picked are not picked again, and this tick is skipped while the previous one is still going. + expect(await h.scheduler.tick(15_000)).toBe(0); + expect(h.calls.length).toBe(picked); + + gate.resolve(report('/w/first', 'first')); await first; - expect(await h.scheduler.tick(200_000)).toBe(1); - expect(h.calls.length).toBe(2); + // Every folder ran exactly once, and no folder ran twice. + expect(h.calls.length).toBe(ids.length); + expect(new Set(h.calls.map((c) => c.cwd)).size).toBe(ids.length); }); - it('runs at most FETCH_CONCURRENCY cases at a time, because each one is a git process', async () => { - const ids = Array.from({ length: FETCH_CONCURRENCY + 3 }, (_, i) => `s${i}`); - let live = 0; - let peak = 0; + it('shares one bound across the whole tick instead of giving each folder its own, and bounds the folders with a bound of its own', async () => { + const ids = Array.from({ length: FETCH_CONCURRENCY + 1 }, (_, i) => `s${i}`); + let fetchesLive = 0; + let fetchesPeak = 0; + let foldersLive = 0; + let foldersPeak = 0; + const hold = () => new Promise((resolve) => setImmediate(resolve)); const h = harness( ids.map((id) => VIEW(id)), { - fetch: async () => { - live++; - peak = Math.max(peak, live); - await new Promise((resolve) => setImmediate(resolve)); - live--; - return report('/w/x', 'x'); + // Every folder is handed the SAME limiter for its work, so that work cannot get its own bound: a + // per-call bound would let 5 folders × 6 items run at once. The folders themselves are bounded by a + // DIFFERENT bound, because one semaphore cannot serve both levels — a folder holding a slot and then + // waiting for one for its own fetch is waiting on itself, and the folders that fill the bound are + // exactly the ones blocking it. If the tick used one semaphore for both, this test would hang and fail + // as a timeout rather than as an assertion. + fetch: async (cwd, opts) => { + foldersLive++; + foldersPeak = Math.max(foldersPeak, foldersLive); + await opts.limiter?.([0, 1, 2, 3, 4, 5], async () => { + fetchesLive++; + fetchesPeak = Math.max(fetchesPeak, fetchesLive); + await hold(); + fetchesLive--; + }); + foldersLive--; + return report(cwd, cwd.slice(3)); }, } ); await h.scheduler.tick(0); - expect(peak).toBe(FETCH_CONCURRENCY); - expect(h.calls.length).toBe(ids.length); + expect(fetchesPeak).toBe(FETCH_CONCURRENCY); // the tick's ceiling, not each folder's own + expect(foldersPeak).toBe(FETCH_CONCURRENCY); + expect(h.calls.length).toBe(ids.length); // every folder ran, none was left waiting forever + }); + + it('times the interval from the START of a run, so a slow repository does not push the next one out', async () => { + const slow = deferred(report('/w/a', 'a')); + const h = harness([VIEW('a')], { settings: { enabled: true, intervalSeconds: 30 }, fetch: () => slow.promise }); + + const first = h.scheduler.tick(0); + await new Promise((resolve) => setImmediate(resolve)); + expect(h.calls.length).toBe(1); + + // The run takes 200 s, longer than its own interval. Once it lands, the folder is already past due: the + // next tick runs it again instead of waiting another 30 s from when the slow one finished. + slow.resolve(report('/w/a', 'a')); + await first; + expect(await h.scheduler.tick(200_000)).toBe(1); + expect(h.calls.length).toBe(2); }); }); @@ -245,7 +338,7 @@ describe('what a run reports', () => { ]); }); - it("reports a failure instead of retrying harder, and keeps the case's own next run at its own interval", async () => { + it("reports a failure instead of retrying harder, and keeps the folder's next run at its own interval", async () => { const h = harness([VIEW('a')], { settings: { enabled: true, intervalSeconds: 30 }, fetch: () => Promise.reject(new Error('spawn git ENOENT')), @@ -261,7 +354,7 @@ describe('what a run reports', () => { }); describe('the effective choice', () => { - it("a case's own override wins over the app setting", () => { + it("a session's own override wins over the app setting", () => { const settings = { enabled: false, intervalSeconds: 600 }; expect(effectiveGitFetch(VIEW('a', { gitFetchEnabled: true, gitFetchIntervalSeconds: 45 }), settings)).toEqual({ enabled: true, @@ -286,19 +379,30 @@ describe('the effective choice', () => { }); }); -describe('a case that goes away', () => { - it('takes its schedule with it, so a reopened case starts on its own clock', async () => { - const h = harness([VIEW('a')]); +describe('a session that goes away', () => { + it('takes its own choice with it, but keeps the folder while another session is still open on it', async () => { + const sessions = [VIEW('a'), { ...VIEW('b'), workingDir: '/w/a' }]; + const h = harness(sessions); await h.scheduler.tick(0); expect(h.scheduler.tracked()).toBe(1); - h.scheduler.remove('a'); + h.remove('a'); + // The run belongs to the clone, so 'b' keeps the folder's schedule and its next run is still on time: + // the choice that got the folder going was 'a's, but the folder is not 'a's. + expect(h.scheduler.tracked()).toBe(1); + await h.scheduler.tick(1000); // not due yet, and 'b' is still open on the folder + expect(h.calls.length).toBe(1); + + // The last session open on the folder goes, so the folder is forgotten: a session coming back on it is due + // at once rather than on the schedule the closed one left behind. + h.remove('b'); expect(h.scheduler.tracked()).toBe(0); - await h.scheduler.tick(1000); + sessions.push({ ...VIEW('c'), workingDir: '/w/a' }); + await h.scheduler.tick(2000); expect(h.calls.length).toBe(2); // remembered again, from now }); - it('stops everything after stop(), including a case that was already due', async () => { + it('stops everything after stop(), including a folder that was already due', async () => { const h = harness([VIEW('a')]); h.scheduler.stop(); expect(await h.scheduler.tick(0)).toBe(0); diff --git a/test/git-fetch.test.ts b/test/git-fetch.test.ts index 94be95cc2..6444a3eb8 100644 --- a/test/git-fetch.test.ts +++ b/test/git-fetch.test.ts @@ -1,16 +1,20 @@ /** * @fileoverview Tests for the Git panel's Fetch button (src/git-fetch.ts): the one git command Codeman - * runs that writes, and the one that runs a repository's own hooks — which is why it is a click, not a poll. + * runs that writes, and the one that can run a repository's own hooks — which is why it is a click, not a + * poll. * - * Two halves, mirroring the module: + * Three halves, mirroring the module: * - * 1. The PURE half — the fixed argv, the failure classification, and the two reads the interval scheduler - * depends on: what the settings say (`gitFetchSettingsFrom`) and what a run did - * (`gitFetchStatusFrom`). No spawning. + * 1. The PURE half — the fixed argv, the `-c` overrides the runner adds ahead of it, the failure + * classification, and the two reads the interval scheduler depends on: what the settings say + * (`gitFetchSettingsFrom`) and what a run did (`gitFetchStatusFrom`). No spawning. * 2. The IO half — a REAL git fetching a REAL local bare repo, so that "remote-tracking refs only" is * proven against git's actual behavior: HEAD, the index and the working tree must be exactly what they * were while the remote-tracking ref moves. The rest of the half uses an injected runner over real * repositories on disk, because what is being pinned is WHICH repositories git is allowed to touch. + * 3. The join (`fetchWorkspaceOnce`) — one run per folder at a time, because two fetches in one clone + * collide on git's ref lock, and the bound is ONE limiter shared with the scheduler's tick rather than a + * bound each run gets for itself. * * Port: N/A (no server). */ @@ -23,14 +27,17 @@ import { afterEach, beforeEach, describe, expect, it } from 'vitest'; import { classifyGitFailure, isGitAvailable } from '../src/git-clone.js'; import { FETCH_CONCURRENCY, + FETCH_CONFIG_ARGS, buildFetchArgs, + clearGitFetchLane, describeFetchFailure, fetchGitWorkspaceOverview, + fetchWorkspaceOnce, gitFetchSettingsFrom, gitFetchStatusFrom, type GitFetchReport, } from '../src/git-fetch.js'; -import { runGit, type GitRunner } from '../src/git-workspace-status.js'; +import { createLimiter, runGit, type GitRunner } from '../src/git-workspace-status.js'; const ENV = { ...process.env, @@ -175,6 +182,36 @@ describe('gitFetchStatusFrom', () => { }); }); +describe('FETCH_CONFIG_ARGS', () => { + it('turns off the hooks and the maintenance a fetch can otherwise start, pinned without spawning git', () => { + expect(FETCH_CONFIG_ARGS).toEqual([ + '-c', + 'core.fsmonitor=false', + '-c', + 'maintenance.auto=false', + '-c', + 'gc.auto=0', + ]); + // Read as pairs: a `-c` and the setting it overrides, and every override is one of these three. + for (let i = 0; i < FETCH_CONFIG_ARGS.length; i += 2) { + expect(FETCH_CONFIG_ARGS[i]).toBe('-c'); + expect(FETCH_CONFIG_ARGS[i + 1]).toMatch(/^(core\.fsmonitor|maintenance\.auto|gc\.auto)=/); + } + expect(FETCH_CONFIG_ARGS.filter((a) => a !== '-c').sort()).toEqual([ + 'core.fsmonitor=false', + 'gc.auto=0', + 'maintenance.auto=false', + ]); + }); + + it('is kept apart from the pinned argv so both can be read without spawning, and the runner composes them in that order', () => { + // The argv the recorded calls pin stays the verb alone: the overrides come from the runner, not from here. + for (const arg of buildFetchArgs()) expect(arg).not.toBe('-c'); + // Overrides before the verb, which is where git will take them. + expect([...FETCH_CONFIG_ARGS, ...buildFetchArgs()]).toEqual([...FETCH_CONFIG_ARGS, 'fetch', '--quiet']); + }); +}); + // ─── Real git, real local repository ───────────────────────────────────────── const gitPresent = isGitAvailable(); @@ -347,3 +384,141 @@ describe.skipIf(!gitPresent)('fetchGitWorkspaceOverview (real git)', () => { expect(calls.filter(([, args]) => args[0] === 'fetch')).toEqual([]); }); }); + +// ─── One run per folder, and one bound for the whole tick ──────────────────── + +describe.skipIf(!gitPresent)('fetchWorkspaceOnce (the join)', () => { + // The resolver still runs real git, so these need real repositories on disk; what is injected is only the + // fetch, because what is being pinned here is the joining, not git's answer. + beforeEach(() => { + // The lane is module state: a run left in it by the previous test would be joined by the next one. + clearGitFetchLane(); + }); + + it('joins a run already going for the same folder instead of starting a second git fetch in the same clone', async () => { + const folder = join(dir, 'folder'); + initRepo(join(folder, 'api')); + + let releaseRun = () => {}; + const held = new Promise((resolve) => (releaseRun = resolve)); + let running = () => {}; + const runStarted = new Promise((resolve) => (running = resolve)); + let firstRunFetches = 0; + const firstRunner: GitRunner = async (cwd, args) => { + if (args[0] === 'fetch') { + firstRunFetches++; + running(); + await held; + } + return ''; + }; + const first = fetchWorkspaceOnce(folder, { git: firstRunner }); + await runStarted; // the run is going, and still going + + // A click while that run is working the folder: it joins, so it never touches git at all — not even the + // resolver's own probe, because it does not resolve anything for itself. + let joinerCalls = 0; + const joiner = fetchWorkspaceOnce(folder, { + git: async () => { + joinerCalls++; + return ''; + }, + }); + + releaseRun(); + const answer = await first; + // The joiner gets that same run's answer, including its limits: the point is not to fetch twice. + expect(await joiner).toBe(answer); + expect(joinerCalls).toBe(0); + expect(firstRunFetches).toBe(1); + }); + + it('keys the join on the resolved folder, so the same folder written two ways is one run', async () => { + const folder = join(dir, 'folder'); + initRepo(join(folder, 'api')); + + let releaseRun = () => {}; + const held = new Promise((resolve) => (releaseRun = resolve)); + let running = () => {}; + const runStarted = new Promise((resolve) => (running = resolve)); + let fetches = 0; + const firstRunner: GitRunner = async (cwd, args) => { + if (args[0] === 'fetch') { + fetches++; + running(); + await held; + } + return ''; + }; + const first = fetchWorkspaceOnce(folder, { git: firstRunner }); + await runStarted; + // Two sessions on one folder reach it by different spellings; the scheduler keys by `resolve`. + let joinerCalls = 0; + const second = fetchWorkspaceOnce(join(dir, 'folder', '.'), { + git: async () => { + joinerCalls++; + return ''; + }, + }); + releaseRun(); + expect(await second).toBe(await first); + expect(joinerCalls).toBe(0); + expect(fetches).toBe(1); + }); + + it('releases the folder when a run lands, so the next click is a new run and not an old answer', async () => { + const folder = join(dir, 'folder'); + initRepo(join(folder, 'api')); + let fetches = 0; + const runner: GitRunner = async (cwd, args) => { + if (args[0] === 'fetch') fetches++; + return ''; + }; + await fetchWorkspaceOnce(folder, { git: runner }); + expect(fetches).toBe(1); + await fetchWorkspaceOnce(folder, { git: runner }); + expect(fetches).toBe(2); + }); + + it('bounds a whole tick with the limiter it is handed, because two runs given a bound each would get twice the bound', async () => { + const left = join(dir, 'left'); + const right = join(dir, 'right'); + for (let i = 0; i < FETCH_CONCURRENCY + 1; i++) { + initRepo(join(left, `l${i}`)); + initRepo(join(right, `r${i}`)); + } + const held = () => new Promise((resolve) => setTimeout(resolve, 10)); + // Only the fetches go through the limiter, so only they are counted here: the resolver's own probe is + // not a bounded git process. + const counting = + (state: { live: number; peak: number }): GitRunner => + async (cwd, args) => { + if (args[0] !== 'fetch') return ''; + state.live++; + state.peak = Math.max(state.peak, state.live); + await held(); + state.live--; + return ''; + }; + + // The scheduler hands every folder in the tick the SAME limiter, so the bound is the tick's, not the run's. + const shared = { live: 0, peak: 0 }; + const sharedRunner = counting(shared); + const limiter = createLimiter(FETCH_CONCURRENCY); + await Promise.all([ + fetchGitWorkspaceOverview(left, { git: sharedRunner, limiter }), + fetchGitWorkspaceOverview(right, { git: sharedRunner, limiter }), + ]); + expect(shared.peak).toBe(FETCH_CONCURRENCY); + + // Without a shared limiter each run bounds only its own call, which is the failure the scheduler is there + // to prevent: two folders at FETCH_CONCURRENCY each, so twice as many git processes as the rule says. + const alone = { live: 0, peak: 0 }; + const aloneRunner = counting(alone); + await Promise.all([ + fetchGitWorkspaceOverview(left, { git: aloneRunner }), + fetchGitWorkspaceOverview(right, { git: aloneRunner }), + ]); + expect(alone.peak).toBe(2 * FETCH_CONCURRENCY); + }); +}); diff --git a/test/routes/git-status-routes.test.ts b/test/routes/git-status-routes.test.ts index ead176bbc..bf515fc49 100644 --- a/test/routes/git-status-routes.test.ts +++ b/test/routes/git-status-routes.test.ts @@ -11,6 +11,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { createRouteTestHarness } from './_route-test-utils.js'; import { registerGitStatusRoutes } from '../../src/web/routes/git-status-routes.js'; import { clearGitStatusCache, runGit, type GitRunner } from '../../src/git-workspace-status.js'; +import { clearGitFetchLane } from '../../src/git-fetch.js'; const ENV = { ...process.env, @@ -44,6 +45,8 @@ async function setup( beforeEach(() => { clearGitStatusCache(); + // The fetch lane is module state: a run left in it by the previous test would be joined by the next one. + clearGitFetchLane(); dir = mkdtempSync(join(tmpdir(), 'git-status-route-')); }); afterEach(() => { @@ -467,6 +470,54 @@ describe('POST /api/sessions/:id/git-fetch', () => { expect(fetches()).toEqual([]); }); + it("publishes what the click got as the session's own fetch state, naming the trigger the user asked for", async () => { + git(dir, 'init', '-q', '-b', 'main'); + const { runner } = recordingRunner(); + const { app, ctx } = await setup({ git: runner }); + const res = await app.inject({ method: 'POST', url: '/api/sessions/test-session-1/git-fetch' }); + expect(res.statusCode).toBe(200); + // The response body is one tab's copy of the run; the state is what every other tab open on the same + // folder sees, and a number the user did not ask for has to read differently from one they did. + expect(session.gitFetch).toMatchObject({ trigger: 'click', state: 'ok', repos: 1, failed: 0 }); + expect(ctx.persistSessionState).toHaveBeenCalled(); + expect(ctx.broadcast).toHaveBeenCalled(); + }); + + it('joins a run already going for the same folder instead of starting a second git process in the same clone', async () => { + git(dir, 'init', '-q', '-b', 'main'); + let releaseRun = () => {}; + const held = new Promise((resolve) => (releaseRun = resolve)); + let running = () => {}; + const runStarted = new Promise((resolve) => (running = resolve)); + const fetches: string[] = []; + const runner: GitRunner = async (cwd, args) => { + if (args[0] === 'fetch') { + fetches.push(cwd); + running(); + await held; + } + return execFileSyncGit(cwd, args); + }; + const { app } = await setup({ git: runner }); + + const first = app.inject({ method: 'POST', url: '/api/sessions/test-session-1/git-fetch' }); + await runStarted; // the run is going, and still going + // A click from another tab open on the same clone. Two fetches there would collide on git's ref lock, so + // this one joins the run already going rather than starting its own. + const second = app.inject({ method: 'POST', url: '/api/sessions/test-session-1/git-fetch' }); + // Assert the join BEFORE releasing the run: the lane entry goes away when the run finishes, so releasing + // first would let the run complete before this request reached the lane, and it would then be entitled to + // start its own fetch — a test that passes for the wrong reason. One tick of the loop is enough for both + // handlers to reach the lane while the run is still held open. + await new Promise((resolve) => setImmediate(resolve)); + expect(fetches).toHaveLength(1); // one run, and the second request is waiting on it, not working + + releaseRun(); + const a = (await first).json().data; + const b = (await second).json().data; + expect(b).toEqual(a); // both tabs get the same run's answer, not a partial one + }); + it('404s an unknown session, running no fetch', async () => { const { calls, fetches, runner } = recordingRunner(); const { app } = await setup({ git: runner }); diff --git a/test/session-options-structure.test.ts b/test/session-options-structure.test.ts index e4ca5a254..58a8c5aad 100644 --- a/test/session-options-structure.test.ts +++ b/test/session-options-structure.test.ts @@ -88,29 +88,36 @@ describe('Session Options modal structure', () => { expect(modal.match(/]*data-tab="context"[^>]*>/)?.[0]).not.toContain('data-claude-only'); }); - it('keeps the Git rail entry for every local case, and hides it for the ones nothing is ever fetched for', () => { + it('keeps the Git rail entry for every local session, and hides it for the ones nothing is ever fetched for', () => { const modal = optionsModal(); - // A shell case has repositories too, so Git must not ride the Claude-only flag. + // A shell session has repositories too, so Git must not ride the Claude-only flag. expect(modal.match(/]*data-tab="git"[^>]*>/)?.[0]).not.toContain('data-claude-only'); const open = methodBody('openSessionOptions(sessionId)'); expect(open).toContain('.set-rail-item[data-tab="git"]'); - // Remote and Docker cases answer 'unsupported' on both git routes and the scheduler never fetches them, + // Remote and Docker sessions answer 'unsupported' on both git routes and the scheduler never fetches them, // so the entry goes the same way the Claude-only ones do. expect(open).toMatch(/gitTabBtn\.style\.display = session\.remote \|\| session\.docker \? 'none' : ''/); }); - it('makes the Git rows inert while a case follows the app, the way the keep-warm rows go inert', () => { + it('makes the Git rows inert while a session follows the app, the way the keep-warm rows go inert', () => { const modal = optionsModal(); const update = methodBody('updateGitFetchStatus(sessionId)'); expect(update).toContain('set-row-disabled'); expect(update).toContain('el.disabled = follows'); - // Decided from BOTH fields: a half-choice means the case is not following, and the half that still follows - // shows the app's value while the other shows its own — a switch-only check would gray out a live row. + // Decided from BOTH fields: a half-choice means the session is not following, and the half that still + // follows shows the app's value while the other shows its own — a switch-only check would gray out a live row. expect(update).toMatch( /follows = session\.gitFetchEnabled === undefined && session\.gitFetchIntervalSeconds === undefined/ ); // Following saves null on both halves, which is what clears the override on the route. - expect(methodBody('async autoSaveGitFetch()')).toContain('{ enabled: null, intervalSeconds: null }'); + const save = methodBody('async autoSaveGitFetch()'); + expect(save).toContain('{ enabled: null, intervalSeconds: null }'); + // Saves through the helper that says whether the call went through: `_apiPost` hands back the Response + // without checking it, so a body the route refused (a 400) read as a save, and the panel showed a choice + // the server never took. + expect(save).toContain('this._apiJson(`/api/sessions/${this.editingSessionId}/git-fetch-settings`'); + expect(save).toContain('if (!data)'); + expect(save).not.toMatch(/await this\._apiPost\(/); // The status row is runtime text this method writes, so translating it would rewrite the stamp. expect(modal).toContain('id="gitFetchStatus" data-i18n-skip hidden'); }); From b26234d6c6d6eeaae4a2fd1e9e9b1f1a6e45e915 Mon Sep 17 00:00:00 2001 From: Aamer Akhter Date: Sun, 25 Oct 2026 09:03:15 -0400 Subject: [PATCH 32/68] fix(agent): refuse an empty --working-dir instead of spawning in cwd `codeman agent spawn --working-dir "$WT"` with WT empty (a failed `git worktree add`, say) resolved '' to the caller's cwd and started an editing worker in the caller's own checkout. An empty or whitespace-only --working-dir now refuses with exit 4 before any request. The emitJson comment names the directory-spawn exception to "the envelope's data": `{ sessionId, workingDir, created: true, ready }`. Tests: unit (`''`, spaces, tab: exit 4, no calls) and through the real argument parser and HTTP transport (nothing sent, exit 4). Docs: README, api-reference, wiki, SKILL.md (plugin mirror synced) and the changeset note the refusal. --- .changeset/project-worker-directories.md | 2 +- README.md | 2 +- docs/api-reference.md | 2 +- docs/wiki/Driving-Codeman-From-An-Agent.md | 3 ++- plugins/codeman/skills/codeman/SKILL.md | 7 ++++--- skills/codeman/SKILL.md | 7 ++++--- src/cli-agent.ts | 12 +++++++++++- test/cli-agent-project-spawn.test.ts | 15 +++++++++++++++ 8 files changed, 39 insertions(+), 11 deletions(-) diff --git a/.changeset/project-worker-directories.md b/.changeset/project-worker-directories.md index 8062be168..2707b60e3 100644 --- a/.changeset/project-worker-directories.md +++ b/.changeset/project-worker-directories.md @@ -2,6 +2,6 @@ "aicodeman": minor --- -Allow `codeman agent spawn` to start workers in the current project directory or an existing directory selected with `--working-dir`, while preserving explicit case quick-start behavior. A directory launch uses `POST /api/v1/sessions` followed by the mode's start route (`/interactive` or `/shell`), and its `--json` output is `{ sessionId, workingDir, created: true, ready }` (plus `startupError` when the start failed, exit 1). Only an omitted case means "use a directory": an empty case name still goes to quick-start, which refuses it. +Allow `codeman agent spawn` to start workers in the current project directory or an existing directory selected with `--working-dir`, while preserving explicit case quick-start behavior. A directory launch uses `POST /api/v1/sessions` followed by the mode's start route (`/interactive` or `/shell`), and its `--json` output is `{ sessionId, workingDir, created: true, ready }` (plus `startupError` when the start failed, exit 1). Only an omitted case means "use a directory": an empty case name still goes to quick-start, which refuses it, and an empty or whitespace-only `--working-dir` is refused locally (exit 4) before any request instead of resolving to the cwd. One change for named-case spawns too: an unknown `--mode` is now refused locally (exit 4) before any request, where it used to reach quick-start and come back as the server's 400 (exit 1). diff --git a/README.md b/README.md index bec342ddf..35e62c72e 100644 --- a/README.md +++ b/README.md @@ -995,7 +995,7 @@ codeman agent ack # remove what you hand codeman agent rm "$SID" # any session except this one ``` -Rules the commands enforce rather than document: they refuse outside a Codeman session and never guess a URL; `send` transmits printable text plus Enter only (a control byte such as `Ctrl+C` is `app_exit` in opencode — ESC exists solely as `interrupt`, which never appends Enter); `rm` refuses an empty id, an unprovable self id and a prefix match in either direction. Ids may be the 8-character prefixes `ls` prints (resolved through the list; an ambiguous prefix refuses, anything shorter than 8 characters refuses on every verb). A prompt that starts with `-` goes after `--` (`send "$SID" -- "- fix the bug"`). The echo of the prompt you sent is output too: a `--match` marker that appears verbatim in the prompt matches at once, before the worker has done anything, so the prompt asks for it in halves. A remote session whose host is asleep answers a fire-and-forget `send` with `buffered` (Codeman wakes the host and types the prompt once the pane is back) or `dropped` (over the wake buffer's cap, nothing will be typed: exit `1`). Exit codes: `0` delivered/matched/signal, `1` error, `2` timeout, `3` the worker exited or the wait ended without an answer (`delivered:false`, `ended:true`), `4` refused. `spawn` prints the id alone on stdout (prose goes to stderr), so `SID=$(…)` captures exactly the id. `spawn` with no case, or with `--working-dir`, starts the worker in that existing directory through `POST /api/v1/sessions` plus `POST /api/v1/sessions/:id/interactive` (or `/shell`) instead of quick-start, so no case is created and the directory is never labelled agent scratch; if the start fails it still prints the created id and exits `1`. `--json` prints the envelope's `data` for every verb, except a directory `spawn`, which prints `{ sessionId, workingDir, created: true, ready }` (plus `startupError` when the start failed). `--until stop` on a mode without hook signals is the server's 400, passed through — the marker path (`--match`) is the answer there, exactly as for the skill. +Rules the commands enforce rather than document: they refuse outside a Codeman session and never guess a URL; `send` transmits printable text plus Enter only (a control byte such as `Ctrl+C` is `app_exit` in opencode — ESC exists solely as `interrupt`, which never appends Enter); `rm` refuses an empty id, an unprovable self id and a prefix match in either direction. Ids may be the 8-character prefixes `ls` prints (resolved through the list; an ambiguous prefix refuses, anything shorter than 8 characters refuses on every verb). A prompt that starts with `-` goes after `--` (`send "$SID" -- "- fix the bug"`). The echo of the prompt you sent is output too: a `--match` marker that appears verbatim in the prompt matches at once, before the worker has done anything, so the prompt asks for it in halves. A remote session whose host is asleep answers a fire-and-forget `send` with `buffered` (Codeman wakes the host and types the prompt once the pane is back) or `dropped` (over the wake buffer's cap, nothing will be typed: exit `1`). Exit codes: `0` delivered/matched/signal, `1` error, `2` timeout, `3` the worker exited or the wait ended without an answer (`delivered:false`, `ended:true`), `4` refused. `spawn` prints the id alone on stdout (prose goes to stderr), so `SID=$(…)` captures exactly the id. `spawn` with no case, or with `--working-dir`, starts the worker in that existing directory through `POST /api/v1/sessions` plus `POST /api/v1/sessions/:id/interactive` (or `/shell`) instead of quick-start, so no case is created and the directory is never labelled agent scratch; an empty `--working-dir` refuses (exit `4`) rather than falling back to cwd; if the start fails it still prints the created id and exits `1`. `--json` prints the envelope's `data` for every verb, except a directory `spawn`, which prints `{ sessionId, workingDir, created: true, ready }` (plus `startupError` when the start failed). `--until stop` on a mode without hook signals is the server's 400, passed through — the marker path (`--match`) is the answer there, exactly as for the skill. ### MCP server (`codeman mcp`): drive Codeman from any MCP client diff --git a/docs/api-reference.md b/docs/api-reference.md index 9e0cbbc93..f3651cb55 100644 --- a/docs/api-reference.md +++ b/docs/api-reference.md @@ -591,7 +591,7 @@ request returns. The capture runs synchronous tmux calls on the server; the ## The `codeman agent` CLI (client over these endpoints) -`codeman agent ls|spawn|send|wait|read|interrupt|rm|post|inbox|ack` (`src/cli-agent.ts`) is the command-line client for the endpoints above, for agents the skill does not reach (a pi or shell session, a machine without `jq`). It adds no route: `spawn ` is `POST /api/v1/quick-start`, and `spawn` with no case (the caller's cwd) or with `--working-dir ` (an existing local directory; combining it with a case refuses) is `POST /api/v1/sessions` with `workingDir`, `mode`, `name` and `parentSessionId` followed by `POST /api/v1/sessions/:id/interactive` or `/shell` (the mode's `capabilities.startMode`), never retried; either form then runs `wait-output` on the mode's `capabilities.composerReadyMark` from the CLI registry where it declares one. `send` is `POST …/input` with `clientId`+`seq` (and `wait`/`waitTimeout` for `--wait` / `--until `; `delivered:false` without `duplicate` and `wait.ended` both exit 3 — the CLI never reports a dead worker as done), `wait` is `GET …/wait` (`--until`) or `GET …/wait-output` (`--match`, `from=buffer` by default), `read` is `GET …/last-response` or `GET …/terminal?tail=`, `interrupt` is `POST …/input` with a bare `\u001b`, `rm` is `DELETE …/sessions/:id`, and the inbox verbs map one to one onto the [agent inbox](#agent-inbox) routes (`post` → `POST …/inbox`, `inbox [id] [--wait]` → `GET …/inbox[?wait=]`, own inbox by default, `ack ` → `POST …/inbox/ack`); `ls` adds an `INBOX` column from `GET /api/v1/agent-inbox/summary` and drops it when that route does not answer. A fire-and-forget `send` to a sleeping wake-on-LAN host reads the route's `buffered` (own line, exit 0) and `dropped` (exit 1: the chunk is gone). An id may be the 8-character form `ls` prints, resolved through `GET /api/v1/sessions`; anything shorter refuses before any request, the same floor as `PARENT_SESSION_ID_MIN_PREFIX`. Every call carries `X-Codeman-Parent-Session`; only `spawn`'s quick-start carries `X-Codeman-Agent-Origin: codeman-agent-cli` (the agent-scratch label must never reach a request that cannot create the case directory). Basic auth comes from `CODEMAN_PASSWORD` or the data dir's `.env`. `--json` prints the envelope's `data`, except a directory `spawn`, which prints `{ sessionId, workingDir, created: true, ready, readinessError }`, and on a failed or ambiguous start `{ sessionId, workingDir, created: true, ready: false, startupError }` (exit 1, the created id kept so the caller can inspect it). An unknown `--mode` refuses (exit 4) before any request, for a named case too. Server-side error codes are shown verbatim (`INVALID_INPUT: until=stop …` on a hook-less mode is not hidden); exit codes are `0` ok, `1` error, `2` timeout, `3` the session exited, `4` refused by a client-side guard (for `inbox --wait`, `3` only when `GET /api/v1/sessions` no longer lists the session; a wait the server ended otherwise, on a restart or a detach, is `2`). `post` takes text that starts with `-` as it is (a markdown file often does); text that is exactly an option (`--json`, `-h`) goes after `--`. See the README section "`codeman agent`" for the guards and `test/cli-agent.test.ts` for the pinned behaviour. +`codeman agent ls|spawn|send|wait|read|interrupt|rm|post|inbox|ack` (`src/cli-agent.ts`) is the command-line client for the endpoints above, for agents the skill does not reach (a pi or shell session, a machine without `jq`). It adds no route: `spawn ` is `POST /api/v1/quick-start`, and `spawn` with no case (the caller's cwd) or with `--working-dir ` (an existing local directory; combining it with a case, or an empty or whitespace-only value, refuses with exit 4 before any request) is `POST /api/v1/sessions` with `workingDir`, `mode`, `name` and `parentSessionId` followed by `POST /api/v1/sessions/:id/interactive` or `/shell` (the mode's `capabilities.startMode`), never retried; either form then runs `wait-output` on the mode's `capabilities.composerReadyMark` from the CLI registry where it declares one. `send` is `POST …/input` with `clientId`+`seq` (and `wait`/`waitTimeout` for `--wait` / `--until `; `delivered:false` without `duplicate` and `wait.ended` both exit 3 — the CLI never reports a dead worker as done), `wait` is `GET …/wait` (`--until`) or `GET …/wait-output` (`--match`, `from=buffer` by default), `read` is `GET …/last-response` or `GET …/terminal?tail=`, `interrupt` is `POST …/input` with a bare `\u001b`, `rm` is `DELETE …/sessions/:id`, and the inbox verbs map one to one onto the [agent inbox](#agent-inbox) routes (`post` → `POST …/inbox`, `inbox [id] [--wait]` → `GET …/inbox[?wait=]`, own inbox by default, `ack ` → `POST …/inbox/ack`); `ls` adds an `INBOX` column from `GET /api/v1/agent-inbox/summary` and drops it when that route does not answer. A fire-and-forget `send` to a sleeping wake-on-LAN host reads the route's `buffered` (own line, exit 0) and `dropped` (exit 1: the chunk is gone). An id may be the 8-character form `ls` prints, resolved through `GET /api/v1/sessions`; anything shorter refuses before any request, the same floor as `PARENT_SESSION_ID_MIN_PREFIX`. Every call carries `X-Codeman-Parent-Session`; only `spawn`'s quick-start carries `X-Codeman-Agent-Origin: codeman-agent-cli` (the agent-scratch label must never reach a request that cannot create the case directory). Basic auth comes from `CODEMAN_PASSWORD` or the data dir's `.env`. `--json` prints the envelope's `data`, except a directory `spawn`, which prints `{ sessionId, workingDir, created: true, ready, readinessError }`, and on a failed or ambiguous start `{ sessionId, workingDir, created: true, ready: false, startupError }` (exit 1, the created id kept so the caller can inspect it). An unknown `--mode` refuses (exit 4) before any request, for a named case too. Server-side error codes are shown verbatim (`INVALID_INPUT: until=stop …` on a hook-less mode is not hidden); exit codes are `0` ok, `1` error, `2` timeout, `3` the session exited, `4` refused by a client-side guard (for `inbox --wait`, `3` only when `GET /api/v1/sessions` no longer lists the session; a wait the server ended otherwise, on a restart or a detach, is `2`). `post` takes text that starts with `-` as it is (a markdown file often does); text that is exactly an option (`--json`, `-h`) goes after `--`. See the README section "`codeman agent`" for the guards and `test/cli-agent.test.ts` for the pinned behaviour. `codeman mcp` (`src/mcp-server.ts`) is a Model Context Protocol server on stdio over the same endpoints, for MCP clients: `list_sessions` (`GET /api/v1/sessions`), `start_session` (`POST /api/v1/quick-start` with `X-Codeman-Agent-Origin: codeman-mcp`, then `wait-output` on the mode's ready mark), `send_prompt` (`POST …/input` with `wait`/`waitTimeout`), `wait_for_session` (`GET …/wait` or `…/wait-output`), `read_output` (`GET …/last-response` or `…/terminal?tail=`), `interrupt_session` and `delete_session`. It adds no route. The URL comes from `CODEMAN_API_URL` or `--url`, never a guess; prompting, interrupting and deleting are limited to sessions the process started (or the calling session's own children) unless it runs with `--allow-any-session`. See the README section "MCP server" and `test/mcp-server.test.ts`. diff --git a/docs/wiki/Driving-Codeman-From-An-Agent.md b/docs/wiki/Driving-Codeman-From-An-Agent.md index 6797a1b7a..2b68c9259 100644 --- a/docs/wiki/Driving-Codeman-From-An-Agent.md +++ b/docs/wiki/Driving-Codeman-From-An-Agent.md @@ -57,7 +57,8 @@ codeman agent rm "$SID" # any session except t - **`spawn [case]`** uses the current directory when no case is supplied. Use `codeman agent spawn --working-dir /path/to/project --mode codex` for another existing local directory. A case and `--working-dir` cannot be combined, and an empty case - name (`spawn "$CASE"` with `CASE` unset) is refused rather than read as "no case". + name (`spawn "$CASE"` with `CASE` unset) is refused rather than read as "no case", + as is an empty `--working-dir` (exit 4, before any request), so it never falls back to cwd. Explicit case names retain quick-start behavior (creating a scratch case if missing). A directory launch is `POST /api/v1/sessions` followed by `POST /api/v1/sessions/:id/interactive` (or `/shell`); it preserves the project and diff --git a/plugins/codeman/skills/codeman/SKILL.md b/plugins/codeman/skills/codeman/SKILL.md index 75f69edd4..1433f58c5 100644 --- a/plugins/codeman/skills/codeman/SKILL.md +++ b/plugins/codeman/skills/codeman/SKILL.md @@ -45,9 +45,10 @@ For an existing project, use one command: `codeman agent spawn --working-dir /path/to/project --mode codex`. With no case and no `--working-dir`, `spawn` uses cwd. Give editing workers separate worktrees. A positional case keeps quick-start behavior and cannot be combined with -`--working-dir`. Directory launches keep parent lineage and never mark the project -as agent scratch. If startup fails or is ambiguous, the CLI reports the exact -created ID: inspect it before retrying or cleaning up. +`--working-dir`; an empty `--working-dir` (`"$WT"` unset) refuses with exit 4 +rather than falling back to cwd. Directory launches keep parent lineage and never +mark the project as agent scratch. If startup fails or is ambiguous, the CLI +reports the exact created ID: inspect it before retrying or cleaning up. ## 0. Guard and bootstrap diff --git a/skills/codeman/SKILL.md b/skills/codeman/SKILL.md index 75f69edd4..1433f58c5 100644 --- a/skills/codeman/SKILL.md +++ b/skills/codeman/SKILL.md @@ -45,9 +45,10 @@ For an existing project, use one command: `codeman agent spawn --working-dir /path/to/project --mode codex`. With no case and no `--working-dir`, `spawn` uses cwd. Give editing workers separate worktrees. A positional case keeps quick-start behavior and cannot be combined with -`--working-dir`. Directory launches keep parent lineage and never mark the project -as agent scratch. If startup fails or is ambiguous, the CLI reports the exact -created ID: inspect it before retrying or cleaning up. +`--working-dir`; an empty `--working-dir` (`"$WT"` unset) refuses with exit 4 +rather than falling back to cwd. Directory launches keep parent lineage and never +mark the project as agent scratch. If startup fails or is ambiguous, the CLI +reports the exact created ID: inspect it before retrying or cleaning up. ## 0. Guard and bootstrap diff --git a/src/cli-agent.ts b/src/cli-agent.ts index 1e4b37fb0..56a628530 100644 --- a/src/cli-agent.ts +++ b/src/cli-agent.ts @@ -376,7 +376,12 @@ export interface AgentDeps { now?: () => number; } -/** Print `data` as JSON (the `--json` path) — always the envelope's `data`, never a reshaped copy. */ +/** + * Print `data` as JSON (the `--json` path): the envelope's `data`, never a reshaped copy, + * except a directory spawn, which prints `{ sessionId, workingDir, created: true, ready }` + * (plus `startupError` when its start fails) because `POST /api/sessions` answers with + * the whole session row. + */ function emitJson(deps: AgentDeps, data: unknown): void { deps.io.out(JSON.stringify(data, null, 2)); } @@ -543,6 +548,11 @@ export async function agentSpawn(deps: AgentDeps, options: SpawnOptions): Promis if (!cli) return fail(deps, `Unknown mode: ${options.mode}`, EXIT.refused); if (options.caseName !== undefined && options.workingDir !== undefined) return fail(deps, 'Use a case OR --working-dir, not both', EXIT.refused); + // Same rule for the flag: `--working-dir "$WT"` with WT empty (a failed + // `git worktree add`) must not fall through to the caller's own checkout, which + // `resolve('')` would make it. + if (options.workingDir !== undefined && options.workingDir.trim() === '') + return fail(deps, 'refusing: --working-dir is empty; pass an existing directory or omit the flag', EXIT.refused); // Only an OMITTED case means "use a directory": an empty one (`spawn "$CASE"` with // CASE unset) still goes to quick-start, whose schema refuses it. const workingDir = options.caseName !== undefined ? undefined : resolve(options.workingDir ?? process.cwd()); diff --git a/test/cli-agent-project-spawn.test.ts b/test/cli-agent-project-spawn.test.ts index dc19650d7..3a5be98fe 100644 --- a/test/cli-agent-project-spawn.test.ts +++ b/test/cli-agent-project-spawn.test.ts @@ -163,6 +163,14 @@ describe('project-preserving agent spawn', () => { expect(await agentSpawn(t.d, { ...base, caseName: '', workingDir: process.cwd() })).toBe(EXIT.refused); expect(t.calls).toEqual([]); }); + it('an empty or whitespace-only --working-dir is refused before any request, never the caller cwd', async () => { + for (const workingDir of ['', ' ', '\t']) { + const t = deps([]); + expect(await agentSpawn(t.d, { ...base, workingDir })).toBe(EXIT.refused); + expect(t.calls).toEqual([]); + expect(t.out.join('\n')).toContain('--working-dir is empty'); + } + }); it('with ready on claude: create, then /interactive, then wait-output on the created id', async () => { const t = deps([ok({ session: { id: OTHER } }), ok({}), ok({ wait: { matched: true } })]); expect(await agentSpawn(t.d, { ...base, mode: 'claude', ready: true })).toBe(EXIT.ok); @@ -239,6 +247,13 @@ describe('agent spawn through the real argument parser and transport', () => { expect(seen[0]).toMatchObject({ method: 'POST', url: '/api/v1/quick-start', body: { caseName: '' } }); expect(exitCode).toBe(EXIT.error); }); + it('`spawn --working-dir ""` (as `--working-dir "$WT"` with WT unset) sends nothing and exits 4', async () => { + for (const value of ['', ' ']) { + const { seen, exitCode } = await runSpawn(['--working-dir', value]); + expect(seen).toEqual([]); + expect(exitCode).toBe(EXIT.refused); + } + }); it('`spawn` with no case argument creates in the caller cwd', async () => { const { seen, exitCode } = await runSpawn([]); expect(seen).toHaveLength(1); From 8ad60facf3e3b7988556b56424f6618366d4608d Mon Sep 17 00:00:00 2001 From: Aamer Akhter Date: Sun, 25 Oct 2026 09:03:29 -0400 Subject: [PATCH 33/68] fix(terminal): keep a directory cell from taking the next row's file name A table cell holding a whole directory (`/home/u/proj` over `config.yaml`) was joined onto the file name in the same column on the next row, which is the next record, and the joined link then replaced the correct links on both cells. A wrapper breaks a path after `/` or `-` (codex wraps cells on Unicode line-break opportunities plus a hyphen splitter, which never break at `_`), so a cell fragment must end on one of those before the next row can continue it. A directory written with its trailing slash over a relative path in the same column still joins, since it reads exactly like a wrap at `/`; pinned as a known trade-off. --- CLAUDE.md | 2 +- docs/architecture-invariants.md | 2 +- src/web/public/constants.js | 17 +++++-- test/terminal-cell-wrapped-paths.test.ts | 64 ++++++++++++++++++++++++ 4 files changed, 80 insertions(+), 5 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 68de089e1..9fa84f574 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -224,7 +224,7 @@ Codeman is a Claude Code session manager with web interface and autonomous Ralph **File-path links (terminal + chat)**: a path an agent prints is clickable on BOTH surfaces and opens the file-preview overlay. ⚠️ ONE pattern (`FILE_PATH_LINK_PATTERN` / `absoluteFilePathPattern()` in constants.js) feeds the xterm link provider AND `_linkifyFilePaths()`, a fresh instance per call (`lastIndex`). Both call `findFilePathLinks()`, which adds `~/`, relative and folder paths without overlapping a rooted match or a URL; a relative path resolves against `workingDir` (`resolveLinkedFilePath`), `~/` only server-side on the session's host (attachment route), and `terminalPathLine()` joins a path a program hard-wrapped short of the edge. ⚠️ A folder link opens the **folder grid**: `POST /api/sessions/:id/folders` guards + GRANTS the folder (`folder-listing.ts`, own registry, never the 200-capped attachment registry), files come by bare name via `…/folders/:folderId/files/:name/*` through `resolveServableAttachmentPath` plus the same extension/regular-file gate on the symlink's resolved target, so no browser request carries a path; `~/` is refused in a Docker case (the container's home is not this host's). The chat linkifier walks TEXT NODES with DOM APIs, never rebuilds sanitized markup as a string. ⚠️ An out-of-workspace path goes through the ATTACHMENT routes (`POST /api/sessions/:id/attachments` with `notify: false`), never by widening `file-content`/`file-raw` or `file-stream-manager`'s `tail -f` allowlist. ⚠️ `TEXT_ATTACHMENT_EXTENSIONS` IS `EDITABLE_EXTENSIONS` (never a second list), and widening READ must never widen RUN: `html`/`htm`/`svg` stay download-only, other text is inert `text/plain`+`nosniff`. ⚠️ The ONE place HTML renders is `/html-view//...` (`html-view-routes.ts`, `html-view-capabilities.ts`): `POST /api/sessions/:id/html-view` mints a capability for the file's own directory, every response carries `Content-Security-Policy: sandbox` WITHOUT `allow-same-origin` (opaque origin even as a top-level tab, so the page can never read Codeman or drive its API) and with NO fetch directive (open network, owner decision 2026-10-13: never restrict it or add `allow-same-origin` without the owner), and only non-dot, asset-allowlisted files under that directory are served (GET/HEAD only; capabilities revoked with the web-tab ones on logout). That tree is served recursively, so minting is refused for a hidden directory or a broad root (`isBroadHtmlViewRoot`); the preview renders by default and the **Page** pill (or a refused mint) falls through to the source view and its Edit button. Never serve HTML same-origin anywhere else. Tests: `test/routes/html-view-routes.test.ts`. Media extensions are single-sourced in `attachment-registry.ts`. ⚠️ **XLSX previews parse in the BROWSER**, never on the server; the admission limits that keep a hostile workbook from freezing the worker live in `src/web/CLAUDE.md` (Spreadsheet preview). → [architecture-invariants#file-path-links-terminal--response-viewer](docs/architecture-invariants.md#file-path-links-terminal--response-viewer) -The terminal provider scans `terminalLinkLine()`, which extends `terminalPathLine()` for URL continuations in indented paragraphs and table cells. List markers, bare numbers, version tokens, whole email addresses, labels ending in `:`, measurements and clock times (`45%`, `12.3ms`, `10:00:00`) and a `name=value` field under a URL ending in `/` never continue a URL; a token with only `-`, `_` or `.` is also refused when it carries a call paren (`client.get()`) or ends a sentence (`Done.`). A URL in the last column of a space-aligned table never takes the next row's first cell: a continuation row must start inside the URL's own cell, so a gap of two or more spaces between its indent and where the URL starts on the row above means it is the next record. `terminalCellWrappedPaths()` follows each table cell independently, gated by `pathContinuation()` and the per-cell row-length rule; accepted paths displace other links on their cells. Full-width divider rows stop `terminalLogicalLine()` stitching for both links and touch selection. OSC 8 links confirm the real target URI before the shared opener runs. +The terminal provider scans `terminalLinkLine()`, which extends `terminalPathLine()` for URL continuations in indented paragraphs and table cells. List markers, bare numbers, version tokens, whole email addresses, labels ending in `:`, measurements and clock times (`45%`, `12.3ms`, `10:00:00`) and a `name=value` field under a URL ending in `/` never continue a URL; a token with only `-`, `_` or `.` is also refused when it carries a call paren (`client.get()`) or ends a sentence (`Done.`). A URL in the last column of a space-aligned table never takes the next row's first cell: a continuation row must start inside the URL's own cell, so a gap of two or more spaces between its indent and where the URL starts on the row above means it is the next record. `terminalCellWrappedPaths()` follows each table cell independently: a fragment carries on into the next row only when it ends its cell on `/` or `-` (where a wrapper breaks a path; codex's cell wrap never breaks at `_`), the next row's token passes `pathContinuation()`, and the cell is no wider than the one above. So a directory that is a whole cell value (`/home/u/proj` over `config.yaml`) keeps its folder link and the next row keeps its own file link. A directory written with its trailing slash over a relative path in the same column (`/home/u/proj/src/` over `lib/util.ts`) still joins, a known trade-off: it reads exactly like a wrap at `/`. Accepted paths displace other links on their cells. Full-width divider rows stop `terminalLogicalLine()` stitching for both links and touch selection. OSC 8 links confirm the real target URI before the shared opener runs. **Filesystem path picker** (Link Existing "Browse" + the mobile keyboard's `📁 Path` key): lazy one-directory browsing via `GET /api/filesystem/browse`, with `GET /api/filesystem/preview` for the tapped file. Inserts the path **without** Enter, so the prompt is never submitted; the sibling `⌫ All` key clears only the unsent prompt and must never send the agent's `/clear`. ⚠️ This is a **second file-serving surface and inherits neither the attachment confinement nor its ownership scoping** — it allowlists Home, `CASES_DIR`, `/mnt/d` and `CODEMAN_FILE_PICKER_ROOTS`, blocks sensitive trees, and rejects symlink escapes **after** `realpath`. ⚠️ The optional `sessionId` is an ownership boundary that must be `canAccessOwned`-checked by hand (it does not go through `findSessionOrFail`), and in multi-user mode a non-admin gets only their own `userSpacePath` as a root: per-user spaces live INSIDE `homedir()`, so a `Home` root exposes every other user's workspace. Previews go through the same global conversion limiter, and Markdown/TXT/JSON are served as inert `text/plain`. → [architecture-invariants#filesystem-path-picker](docs/architecture-invariants.md#filesystem-path-picker) diff --git a/docs/architecture-invariants.md b/docs/architecture-invariants.md index e06bb19c1..8d1713a25 100644 --- a/docs/architecture-invariants.md +++ b/docs/architecture-invariants.md @@ -409,7 +409,7 @@ A file path an agent prints is a link on both surfaces it can appear on, and cli **Path forms beyond the rooted one.** `findFilePathLinks(text)` (constants.js) is the single entry point both consumers call; it returns the rooted matches first, then `~/` and relative paths (`RELATIVE_FILE_PATH_LINK_PATTERN`, same extension list, pinned equal by `test/link-provider-regex.test.ts`), then folder paths, each only where it overlaps neither an earlier match nor an `http(s)` URL. A relative match must start at a boundary (line start, whitespace, quote, bracket, `=`, `,`, `>`), so it can never begin inside a word, an absolute path or a URL; brackets, `=` and `,` end it (`(shot.png)`, `OUT=shots/a.png`). `resolveLinkedFilePath()` resolves a relative path against the session's `workingDir` (`.`/`..` collapsed, so `../x.png` lands outside and routes like any external path) and passes absolute and `~/` paths through. ⚠️ `~` is the home of the SESSION'S host, which only the server knows: `~/` paths always take the attachment route, where `registerExternalAttachment` expands them (`homedir()` locally, `$HOME` inside the remote probe script), and the guard runs on the expanded, realpath-resolved path as before. ⚠️ Programs hard-wrap their own output short of the terminal edge (Claude Code breaks tool output at 59-118 columns), so xterm's `isWrapped` never fires. `terminalPathLine()` (link provider only; touch selection keeps `terminalLogicalLine`) joins the next row when the previous row ends in a path token with a `/` and no extension and the join reaches an extension; a row that starts a new path is never joined: rooted or `~/`, `./`/`../`, or a relative one that opens on the same top directory the broken token did (the next entry of `git ls-files`/`find`/`ls -R`: `src/components` + `src/index.ts` are two paths, not `src/componentssrc/index.ts`), while a wrapped path continues INSIDE a name, so `…/captures/2` + `026-10-10/…` still joins; a 3+ row chain is kept only if a later row completes the path, and the broken row must be at least as long as the continuing one (keeps `cd /mnt/foo` + a longer next line apart). Tests: `test/file-path-link-forms.test.ts`. -The terminal provider scans `terminalLinkLine()`, which extends `terminalPathLine()` for URL continuations in indented paragraphs and table cells. List markers, bare numbers and version tokens never continue a URL. A token with only hyphens, underscores or dots must end its cell (row end or a gap of at least two spaces), so prose such as `self-hosted runners` is not appended. A row end counts as a cell end, so `⏺ Docs live at https://example.com/guide/` followed by ` self-hosted` still links `.../guide/self-hosted`, and an indented `and/or the API reference covers it.` still links `.../guide/and/or`; both are pinned as known trade-offs. Such a token is still refused when it is code with a call paren (`client.get()`, `expect(x).toBe(y)`; a wiki title like `Mercury_(planet)` keeps its paren) or a word ending a sentence (`ready.`, `Done.`, `self-hosted.`; a file name keeps its period, so `install.html.` still joins). Whatever its punctuation, a token never continues a URL when it ends with a colon (a label such as `Failure/Error:` or `prometheus.io/scrape:`), when it is a measurement or clock time (`45%`, `45%|####`, `12.3MB/45.0MB`, `12.3ms`, `10:00:00`; percent-encoding such as `2026%20Plan` still joins), or when it is a `name=value` field (`API_KEY=...`, `level=info`) under a URL ending in `/`, since a query needs a `?` first (after `?`, `&` or `_` it is a query parameter and joins). A continuation must be indented (a column-0 row is new output, which keeps `status=200`, `24/7` and `really?` out), and a shell prompt (`user@host:`), a home path (`~/`), a whole email address or an HTTP status line never continues a URL. A URL in the last column of a space-aligned table never takes the next row's first cell: a continuation row must start inside the URL's own cell, so a gap of two or more spaces between its indent and where the URL starts on the row above means it is the next record. A divider row never continues the row above it, so a URL that reaches the right edge does not absorb a full-width `────` row below. A real plain slug followed by prose falls back to the original fragment link; `%`, `&`, `=`, `/` or `?` still qualify as stronger URL punctuation. `terminalCellWrappedPaths()` follows each table cell independently, gated by `pathContinuation()` and the per-cell row-length rule; accepted paths displace other links on their cells. Full-width divider rows stop `terminalLogicalLine()` stitching for both links and touch selection. OSC 8 links confirm the real target URI before the shared opener runs. +The terminal provider scans `terminalLinkLine()`, which extends `terminalPathLine()` for URL continuations in indented paragraphs and table cells. List markers, bare numbers and version tokens never continue a URL. A token with only hyphens, underscores or dots must end its cell (row end or a gap of at least two spaces), so prose such as `self-hosted runners` is not appended. A row end counts as a cell end, so `⏺ Docs live at https://example.com/guide/` followed by ` self-hosted` still links `.../guide/self-hosted`, and an indented `and/or the API reference covers it.` still links `.../guide/and/or`; both are pinned as known trade-offs. Such a token is still refused when it is code with a call paren (`client.get()`, `expect(x).toBe(y)`; a wiki title like `Mercury_(planet)` keeps its paren) or a word ending a sentence (`ready.`, `Done.`, `self-hosted.`; a file name keeps its period, so `install.html.` still joins). Whatever its punctuation, a token never continues a URL when it ends with a colon (a label such as `Failure/Error:` or `prometheus.io/scrape:`), when it is a measurement or clock time (`45%`, `45%|####`, `12.3MB/45.0MB`, `12.3ms`, `10:00:00`; percent-encoding such as `2026%20Plan` still joins), or when it is a `name=value` field (`API_KEY=...`, `level=info`) under a URL ending in `/`, since a query needs a `?` first (after `?`, `&` or `_` it is a query parameter and joins). A continuation must be indented (a column-0 row is new output, which keeps `status=200`, `24/7` and `really?` out), and a shell prompt (`user@host:`), a home path (`~/`), a whole email address or an HTTP status line never continues a URL. A URL in the last column of a space-aligned table never takes the next row's first cell: a continuation row must start inside the URL's own cell, so a gap of two or more spaces between its indent and where the URL starts on the row above means it is the next record. A divider row never continues the row above it, so a URL that reaches the right edge does not absorb a full-width `────` row below. A real plain slug followed by prose falls back to the original fragment link; `%`, `&`, `=`, `/` or `?` still qualify as stronger URL punctuation. `terminalCellWrappedPaths()` follows each table cell independently: a fragment carries on into the next row only when it ends its cell on `/` or `-` (where a wrapper breaks a path; codex's cell wrap never breaks at `_`), the next row's token passes `pathContinuation()`, and the cell is no wider than the one above. So a directory that is a whole cell value (`/home/u/proj` over `config.yaml`) keeps its folder link and the next row keeps its own file link. A directory written with its trailing slash over a relative path in the same column (`/home/u/proj/src/` over `lib/util.ts`) still joins, a known trade-off: it reads exactly like a wrap at `/`. Accepted paths displace other links on their cells. Full-width divider rows stop `terminalLogicalLine()` stitching for both links and touch selection. OSC 8 links confirm the real target URI before the shared opener runs. **Folder grid: a folder is granted, then served by bare file name.** A folder path (rooted or `~/` whose last segment has no extension, relative only with a trailing `/`; `FOLDER_PATH_LINK_PATTERN` + `folderLinkPath()`) opens `openFolderGrid()` (panels-ui.js), as does the preview's "Show folder" button. `POST /api/sessions/:id/folders {path}` (`src/folder-listing.ts`) resolves the folder (locally a bounded `probePathKind(…, { pastCap: true })` first, then async `fs.realpath`; remotely one ssh round trip via `remoteListFolder`, through `runWithRemoteSshLimit`), runs the attachment guard on it (blocklist, `confineToWorkspace`, and in multi-user mode `folderAllowedForUser`: a non-admin only inside their own `userSpacePath` or the session's `workingDir`, as the filesystem path picker), locally BEFORE reading it, lists non-hidden subfolders and files of a supported attachment type (each file also blocklist-checked, newest first, capped at `MAX_FOLDER_ENTRIES`), and GRANTS it an id. Files are then fetched as `GET …/folders/:folderId/files/:name/(raw|preview|thumbnail)`: `:name` must be a bare file name of a supported type (no `/`, `\`, NUL, no leading `.`, since the listing hides those), the local file is probed bounded (`unknown` → 503, never 404), and the joined path goes through the SAME `resolveServableAttachmentPath` as an attachment by id (symlink re-resolution, remote re-probe, guard), then the same gate registration applies to what a symlink RESOLVES to (a supported extension on the resolved path and a regular file: locally `fs.stat`, remotely the probe kind; else 400, so `shot.png -> ../secret.bin` is not served and a FIFO or device behind a supported name never reaches `open`), then `folderAllowedForUser` once more on the resolved file, before `serveServableRaw` (the attachment raw route's body, shared) streams it. So browser requests still never carry a path. ⚠️ Grants live in their own registry (`folderGrants`, 32 per session, cleared with the session's attachments), NOT the attachment registry: that one is capped at 200 per session, and paging a folder of 300 renders would evict the session's real attachments. ⚠️ A local folder is never touched unbounded (#516 rule): a folder on a network mount that stopped answering answers 503 with `describeUnknownPath()`, not 404 and not a frozen server. ⚠️ The remote listing script tells "missing" (`E|n`, 404) from "is a file" (`E|f`, 400 `Not a folder`), because the client falls back to the file preview on `Not a folder`; a remote extensionless file otherwise answered "Folder not found". ⚠️ `.folder-grid-overlay` sits at `z-index: 5090`, one step BELOW the preview (5100) and above the response viewer (5000): a tile's preview opens on top and closing it lands back on the grid. ⚠️ ←/→/Esc are handled in a document CAPTURE listener with `stopPropagation`, because the terminal keeps keyboard focus under the overlays and an arrow that reached xterm would be typed into the session; inputs other than xterm's helper textarea keep their keys. ⚠️ A route PARAMETER is capped by the router (Fastify's default is 100 characters, answered as 404 "Route not found"), and a file name goes up to 255, percent-encoded: the server sets `routerOptions.maxParamLength` to `MAX_ROUTE_PARAM_LENGTH` (route-helpers.ts), the route test harness uses the same constant, and a wiring guard pins it. ⚠️ `~/` in a Docker case is refused with 400 (attachment POST and `POST …/folders`): the session is local for these routes, but `~` inside the container is the container's own home, and expanding it on the host would open a different file than the one the agent printed. Tests: `test/routes/folder-grid-routes.test.ts` (incl. the listing script on a real `sh`), `test/folder-grid-ui.test.ts`. diff --git a/src/web/public/constants.js b/src/web/public/constants.js index 78e04721b..5e22d152a 100644 --- a/src/web/public/constants.js +++ b/src/web/public/constants.js @@ -2761,9 +2761,12 @@ function terminalLinkLine(buffer, row, cols, maxRows) { * edge is where its text starts after the previous ≥2-space column gap, and the * continuation is the token starting at exactly that column on the next row. * A fragment qualifies only when it is the last thing in its cell on that row - * (row end or a ≥2-space gap follows). Every continuation must pass - * pathContinuation and its cell must be no wider than the preceding cell's - * text, so independent listing rows and longer prose never displace links. + * (row end or a ≥2-space gap follows) and ends on `/` or `-`, the only places + * a wrapper breaks a path; a cell that ends inside a name (`/home/u/proj` over + * `config.yaml`) is a whole value, never the first piece of the next row's. Every + * continuation must pass pathContinuation and its cell must be no wider than the + * preceding cell's text, so independent listing rows and longer prose never + * displace links. * * Pure: reads rows only. Cells are taken one per string character (xterm rows * with wide glyphs would shift columns; such rows simply fail the alignment). @@ -2825,6 +2828,14 @@ function terminalCellWrappedPaths(buffer, row, maxRows) { const token = next.slice(cellCol, tokenEnd); // Include prose after the token in this cell, but not another column. const cellText = next.slice(cellCol).split(/ {2,}|[│┃║|]/, 1)[0].trimEnd(); + // A wrapper breaks a path after `/` or `-`; a fragment that ends inside a + // name (`/home/u/proj` over `config.yaml`) is a whole value, not a piece. + // codex wraps table cells on Unicode line-break opportunities plus a + // hyphen splitter, which break after `/` and `-` but never at `_` (UAX #14 + // class AL). Known trade-off: a directory written with its trailing slash + // over a relative path in the same column (`/home/u/proj/src/` over + // `lib/util.ts`) still joins, since it reads exactly like a wrap at `/`. + if (!/[/-]$/.test(joined)) break; if (cellText.length > previousCellLength || !pathContinuation(joined, token)) break; const candidate = joined + token; const match = absoluteFilePathPattern().exec(candidate); diff --git a/test/terminal-cell-wrapped-paths.test.ts b/test/terminal-cell-wrapped-paths.test.ts index 7b25672de..873aaf142 100644 --- a/test/terminal-cell-wrapped-paths.test.ts +++ b/test/terminal-cell-wrapped-paths.test.ts @@ -65,6 +65,32 @@ const LISTINGS = [ ['Created /tmp/out', 'report.md written next to it'], ]; +// A directory that is the whole value of its cell, over the next record's file +// name in the same column. A wrapper breaks a path after `/` or `-`, never inside +// a name, so none of these is one path: each cell keeps its own link. +const WHOLE_VALUE_SHAPES: Array<{ rows: string[]; dir: string; file: string }> = [ + { rows: [' Repo /home/u/proj', ' Config config.yaml'], dir: '/home/u/proj', file: 'config.yaml' }, + { + rows: [' Workspace /home/u/proj/app', ' Entry main.py'], + dir: '/home/u/proj/app', + file: 'main.py', + }, + { + rows: ['│ Workspace │ /home/u/proj/app │', '│ Entry │ main.py │'], + dir: '/home/u/proj/app', + file: 'main.py', + }, + { rows: [' Output dir /tmp/build/out', ' Log build.log'], dir: '/tmp/build/out', file: 'build.log' }, + { + rows: [' Path Purpose', ' /home/u/proj/src sources', ' index.ts entry'], + dir: '/home/u/proj/src', + file: 'index.ts', + }, +]; +// Known trade-off: a directory written WITH its trailing slash over a relative +// path in the same column reads exactly like a wrap at `/`, so it still joins. +const TRAILING_SLASH_TRADE_OFF = [' Source /home/u/proj/src/', ' Helper lib/util.ts']; + const BEFORE = '/tmp/demo-check/docs/review/BLDMESH-243/before-overview.png'; const AFTER = '/tmp/demo-check/docs/review/BLDMESH-243/after-overview.png'; @@ -83,6 +109,25 @@ describe('terminalCellWrappedPaths', () => { for (let row = 0; row < rows.length; row++) expect(find(fakeBuffer(rows, COLS), row)).toEqual([]); }); + it.each(WHOLE_VALUE_SHAPES.map((shape) => [shape.rows, shape]))( + 'does not join a directory cell onto the next record: %j', + (_rows, shape) => { + const rows = shape.rows.map((text) => ({ text })); + for (let row = 0; row < rows.length; row++) expect(find(fakeBuffer(rows, COLS), row)).toEqual([]); + } + ); + + it('does not join a fragment that ends on `_` (codex wraps cells only after `/` or `-`)', () => { + const rows = [{ text: ' shot (/tmp/demo/docs/review_' }, { text: ' before.png)' }]; + expect(find(fakeBuffer(rows, COLS), 0)).toEqual([]); + expect(find(fakeBuffer(rows, COLS), 1)).toEqual([]); + }); + + it('still joins a trailing-slash directory over a relative path in its column (known trade-off)', () => { + const rows = TRAILING_SLASH_TRADE_OFF.map((text) => ({ text })); + expect(find(fakeBuffer(rows, COLS), 1).map((h) => h.path)).toEqual(['/home/u/proj/src/lib/util.ts']); + }); + it('places the segments on the path cells only', () => { const hits = find(fakeBuffer(TABLE, COLS), 1); const before = hits.find((h) => h.path === BEFORE)!; @@ -215,6 +260,25 @@ describe('terminal link provider with cell-wrapped paths', () => { } }); + it.each(WHOLE_VALUE_SHAPES.map((shape) => [shape.rows, shape]))( + 'keeps the folder link and the next record’s file link: %j', + (_rows, shape) => { + const { provide, app } = harness(shape.rows.map((text) => ({ text }))); + const dirLine = shape.rows.findIndex((text) => text.includes(shape.dir)) + 1; + const fileLine = shape.rows.findIndex((text) => text.includes(shape.file)) + 1; + const all = [...provide(dirLine), ...provide(fileLine)].map((link) => link.text); + expect(all.some((text) => text.includes(shape.dir + shape.file))).toBe(false); + const dirLink = provide(dirLine).find((link) => link.text === shape.dir); + expect(dirLink, `${shape.dir} on row ${dirLine}`).toBeDefined(); + expect(dirLink!.range.start.y).toBe(dirLine); + const fileLink = provide(fileLine).find((link) => link.text === shape.file); + expect(fileLink, `${shape.file} on row ${fileLine}`).toBeDefined(); + expect(fileLink!.range.start.y).toBe(fileLine); + fileLink!.activate(null, fileLink!.text); + expect(app.openFilePreview).not.toHaveBeenCalledWith(shape.dir + shape.file, 's1'); + } + ); + it('makes both screenshot paths clickable from either row and opens the whole path', () => { const { provide, app } = harness(TABLE); for (const line of [2, 3]) { From 17b2edce2203fdea33cb72b15236d5d73f9466de Mon Sep 17 00:00:00 2001 From: Aamer Akhter Date: Sun, 25 Oct 2026 09:08:54 -0400 Subject: [PATCH 34/68] fix(history): count compressed rollouts, read each file once, degrade an unreadable parent Codex's local_thread_store_compression rewrites cold rollouts to *.jsonl.zst, which the store scan skipped without a trace. Each one is now counted in compressedFiles (complete: false), skipped only when its plain .jsonl sibling exists, as Codex's own discovery does, and a lookup that misses names the compressed file whose name carries the wanted id. A Zstandard file given to --file, or sitting in the store under a .jsonl name, is recognised by its magic number. Each selected rollout is parsed once into every view later needed of it, so a legacy copy's boundary proof reuses the parent's own read instead of charging it to --scan-bytes a second time (and counting its malformed or inherited records twice). A parent read that fails for any reason other than a budget now leaves the boundary unproven (cut at the last candidate, unverifiedPrefixes, complete: false) instead of failing a child whose own file is intact. A budget error still fails the read. --max-bytes is validated before the first file is read, next to the other budgets, and every budget flag accepts plain decimal integers only. --- .changeset/offline-codex-history.md | 2 +- README.md | 2 +- docs/wiki/Driving-Codeman-From-An-Agent.md | 3 +- .../skills/codeman/reference/history.md | 25 ++- skills/codeman/reference/history.md | 25 ++- src/cli-history.ts | 22 ++- src/local-history.ts | 154 ++++++++++++++---- test/local-history-live.test.ts | 30 +++- test/local-history.test.ts | 139 ++++++++++++++++ 9 files changed, 346 insertions(+), 56 deletions(-) diff --git a/.changeset/offline-codex-history.md b/.changeset/offline-codex-history.md index 9ed54d373..f0a2eb0c4 100644 --- a/.changeset/offline-codex-history.md +++ b/.changeset/offline-codex-history.md @@ -2,4 +2,4 @@ 'aicodeman': minor --- -Add offline `codeman history read` for saved Codex transcripts by file, native session ID, or conversation family (spawned sub-agents and forks included), with whole-turn topic and UTC date filters. A forked or spawned thread's copy of its parent's history is dropped, so every record belongs to the thread that wrote it. Store attribution has its own budget (`--metadata-bytes`), separate from the transcript budget (`--scan-bytes`), and every bound is raisable (`--max-entries` too), so id lookups keep working on stores of thousands of rollouts. A store file that cannot be attributed, an unreadable directory, or a rollout a running Codex is still appending to no longer fails the lookup; each is reported through `complete: false`. Assistant records carry Codex's own phase and user records none. The Past Sessions scanner and the offline reader now share one Codex rollout format module, including the injected-context rule. +Add offline `codeman history read` for saved Codex transcripts by file, native session ID, or conversation family (spawned sub-agents and forks included), with whole-turn topic and UTC date filters. A forked or spawned thread's copy of its parent's history is dropped, so every record belongs to the thread that wrote it. Store attribution has its own budget (`--metadata-bytes`), separate from the transcript budget (`--scan-bytes`), and every bound is raisable (`--max-entries` too), so id lookups keep working on stores of thousands of rollouts. A store file that cannot be attributed, an unreadable directory, or a rollout a running Codex is still appending to no longer fails the lookup; each is reported through `complete: false`, as is a Codex rollout compressed to `.jsonl.zst` (counted in `compressedFiles` and named when a lookup misses). A parent file read to prove where a forked copy ends is charged to `--scan-bytes` once, and a parent that cannot be read leaves that boundary unproven instead of failing the child. Assistant records carry Codex's own phase and user records none. The Past Sessions scanner and the offline reader now share one Codex rollout format module, including the injected-context rule. diff --git a/README.md b/README.md index 3fc1a4956..90412d5c1 100644 --- a/README.md +++ b/README.md @@ -1006,7 +1006,7 @@ codeman history read --root-session-id --topic auth # a conversa codeman history read --file /path/to/rollout.jsonl --format text ``` -Filters keep whole user/assistant turns (`--topic` is a literal, case-insensitive match; `--after`/`--before` are inclusive UTC dates). A forked or spawned thread's copy of its parent's history is dropped, so every record belongs to the thread that wrote it. Output is JSON by default and carries `complete: false` whenever the evidence is partial (a file that could not be attributed, a record a running Codex is still writing, an unknown ancestor). Every bound fails closed and is raisable: `--metadata-bytes` (attributing the store, default 1 GiB), `--max-entries` (default 65536), `--scan-bytes` (the selected transcripts, default 16 MiB) and `--max-bytes` (output, default 8192). The full reference is [`skills/codeman/reference/history.md`](skills/codeman/reference/history.md). +Filters keep whole user/assistant turns (`--topic` is a literal, case-insensitive match; `--after`/`--before` are inclusive UTC dates). A forked or spawned thread's copy of its parent's history is dropped, so every record belongs to the thread that wrote it. Output is JSON by default and carries `complete: false` whenever the evidence is partial (a file that could not be attributed, a compressed `.jsonl.zst` rollout, a record a running Codex is still writing, an unknown ancestor). Every bound fails closed and is raisable: `--metadata-bytes` (attributing the store, default 1 GiB), `--max-entries` (default 65536), `--scan-bytes` (the selected transcripts, default 16 MiB) and `--max-bytes` (output, default 8192). The full reference is [`skills/codeman/reference/history.md`](skills/codeman/reference/history.md). ### MCP server (`codeman mcp`): drive Codeman from any MCP client diff --git a/docs/wiki/Driving-Codeman-From-An-Agent.md b/docs/wiki/Driving-Codeman-From-An-Agent.md index 6e7ce85c8..1a5946829 100644 --- a/docs/wiki/Driving-Codeman-From-An-Agent.md +++ b/docs/wiki/Driving-Codeman-From-An-Agent.md @@ -87,7 +87,8 @@ codeman history read --file /path/to/rollout.jsonl --format text - **Forks and sub-agents**: a child's copy of its parent's history is dropped, so every record belongs to the thread that wrote it. - **`complete: false`** in the JSON means the evidence is partial: a store file that could - not be attributed, a record a running Codex is still writing, an ancestor not found. + not be attributed, a compressed `.jsonl.zst` rollout (not read), a record a running + Codex is still writing, an ancestor not found. - **Budgets** fail closed and are raisable: `--metadata-bytes` (attributing the store, default 1 GiB), `--max-entries` (65536), `--scan-bytes` (the selected transcripts, 16 MiB) and `--max-bytes` (output, 8192). `--json` and `--format text` together are an diff --git a/plugins/codeman/skills/codeman/reference/history.md b/plugins/codeman/skills/codeman/reference/history.md index bcdfc92c4..ae087fb67 100644 --- a/plugins/codeman/skills/codeman/reference/history.md +++ b/plugins/codeman/skills/codeman/reference/history.md @@ -43,27 +43,34 @@ plus scan counters and `complete`. `phase` is Codex's own (`commentary` or recorded. A root ID is `null` when ancestry cannot be proven. Report that uncertainty; never relabel the queried child as its own root. `complete: false` reports malformed or partial records (a running Codex may be -mid-write), skipped links, unattributed files, unreadable directories, missing -ancestors, or an inherited copy whose end could not be found or proven. It does not -prove the store includes every conversation ever created, and a filtered result is -only evidence for its selected scope. +mid-write), skipped links, unattributed files, compressed rollouts, unreadable +directories, missing ancestors, or an inherited copy whose end could not be found +or proven (including when the parent's file is present but cannot be read). It +does not prove the store includes every conversation ever created, and a filtered +result is only evidence for its selected scope. Reads require regular UTF8 files and reject final-component symlinks, conflicting metadata, ambiguous IDs, cyclic ancestry and invalid selectors. A store file that cannot be attributed (invalid UTF8 in its head, no metadata in its first 128 KiB, changing while its head is read) is counted in `unattributedFiles` instead of -failing the lookup. A selected rollout that a running Codex is appending to is read -as a snapshot up to its last complete line; one that was replaced or truncated -fails. Parent directories of an explicitly supplied path must be trusted; this is +failing the lookup. Codex's optional rollout compression rewrites cold rollouts to +`*.jsonl.zst`; those are not read. Each is counted in `compressedFiles` (one whose +plain `.jsonl` sibling exists is skipped, as Codex skips it), and a lookup that +misses names them. A compressed file passed to `--file` is refused; read a +decompressed copy instead (`zstd -dc > copy.jsonl`). A selected rollout +that a running Codex is appending to is read as a snapshot up to its last complete +line; one that was replaced or truncated fails. Parent directories of an explicitly supplied path must be trusted; this is not a filesystem sandbox. -Budgets, each failing with an error rather than a partial answer: +Budgets, each failing with an error rather than a partial answer. Every flag is +validated before the first file is read, and only plain decimal integers are +accepted: | Flag | Bounds | Default | Maximum | | ---- | ------ | ------- | ------- | | `--metadata-bytes` | head reads attributing every rollout in the store | 1 GiB | 16 GiB | | `--max-entries` | directory entries visited | 65536 | 1048576 | -| `--scan-bytes` | the selected transcripts | 16 MiB | 256 MiB | +| `--scan-bytes` | the selected transcripts, plus any parent file read to prove where a copy ends (each file charged once) | 16 MiB | 256 MiB | | `--max-bytes` | output, including the final newline | 8192 | 1048576 | Each rollout's `session_meta` carries Codex's base instructions (about 20 KiB), so diff --git a/skills/codeman/reference/history.md b/skills/codeman/reference/history.md index bcdfc92c4..ae087fb67 100644 --- a/skills/codeman/reference/history.md +++ b/skills/codeman/reference/history.md @@ -43,27 +43,34 @@ plus scan counters and `complete`. `phase` is Codex's own (`commentary` or recorded. A root ID is `null` when ancestry cannot be proven. Report that uncertainty; never relabel the queried child as its own root. `complete: false` reports malformed or partial records (a running Codex may be -mid-write), skipped links, unattributed files, unreadable directories, missing -ancestors, or an inherited copy whose end could not be found or proven. It does not -prove the store includes every conversation ever created, and a filtered result is -only evidence for its selected scope. +mid-write), skipped links, unattributed files, compressed rollouts, unreadable +directories, missing ancestors, or an inherited copy whose end could not be found +or proven (including when the parent's file is present but cannot be read). It +does not prove the store includes every conversation ever created, and a filtered +result is only evidence for its selected scope. Reads require regular UTF8 files and reject final-component symlinks, conflicting metadata, ambiguous IDs, cyclic ancestry and invalid selectors. A store file that cannot be attributed (invalid UTF8 in its head, no metadata in its first 128 KiB, changing while its head is read) is counted in `unattributedFiles` instead of -failing the lookup. A selected rollout that a running Codex is appending to is read -as a snapshot up to its last complete line; one that was replaced or truncated -fails. Parent directories of an explicitly supplied path must be trusted; this is +failing the lookup. Codex's optional rollout compression rewrites cold rollouts to +`*.jsonl.zst`; those are not read. Each is counted in `compressedFiles` (one whose +plain `.jsonl` sibling exists is skipped, as Codex skips it), and a lookup that +misses names them. A compressed file passed to `--file` is refused; read a +decompressed copy instead (`zstd -dc > copy.jsonl`). A selected rollout +that a running Codex is appending to is read as a snapshot up to its last complete +line; one that was replaced or truncated fails. Parent directories of an explicitly supplied path must be trusted; this is not a filesystem sandbox. -Budgets, each failing with an error rather than a partial answer: +Budgets, each failing with an error rather than a partial answer. Every flag is +validated before the first file is read, and only plain decimal integers are +accepted: | Flag | Bounds | Default | Maximum | | ---- | ------ | ------- | ------- | | `--metadata-bytes` | head reads attributing every rollout in the store | 1 GiB | 16 GiB | | `--max-entries` | directory entries visited | 65536 | 1048576 | -| `--scan-bytes` | the selected transcripts | 16 MiB | 256 MiB | +| `--scan-bytes` | the selected transcripts, plus any parent file read to prove where a copy ends (each file charged once) | 16 MiB | 256 MiB | | `--max-bytes` | output, including the final newline | 8192 | 1048576 | Each rollout's `session_meta` carries Codex's base instructions (about 20 KiB), so diff --git a/src/cli-history.ts b/src/cli-history.ts index 11be60888..238224a2d 100644 --- a/src/cli-history.ts +++ b/src/cli-history.ts @@ -27,8 +27,18 @@ import { HISTORY_MAX_OUTPUT_BYTES, HISTORY_MAX_SCAN_BYTES, readLocalHistory, + validateOutputBytes, } from './local-history.js'; +/** + * A budget flag's value as a number. Only plain decimal digits count, so `0x10`, + * `1e3` and ` 5 ` are refused rather than read as 16, 1000 and 5; anything else + * becomes NaN, which the reader's range check rejects with the flag's name. + */ +function budgetFlag(value: string | boolean | undefined): number { + return typeof value === 'string' && /^\d+$/.test(value) ? Number(value) : NaN; +} + export function registerHistoryCommands(program: Command): Command { const history = program .command('history') @@ -74,6 +84,10 @@ export function registerHistoryCommands(program: Command): Command { if (options.json && options.format === 'text' && command.getOptionValueSource('format') === 'cli') throw new Error('--json conflicts with --format text; pass one of them'); const format = options.format; + // Every flag is checked before the first file is read: --max-bytes would otherwise + // only be judged after the whole store had been scanned. + const maxBytes = budgetFlag(options.maxBytes); + validateOutputBytes(maxBytes); const result = await readLocalHistory({ file: options.file as string | undefined, sessionId: options.sessionId as string | undefined, @@ -82,11 +96,11 @@ export function registerHistoryCommands(program: Command): Command { topic: options.topic as string | undefined, after: options.after as string | undefined, before: options.before as string | undefined, - scanBytes: Number(options.scanBytes), - metadataBytes: Number(options.metadataBytes), - maxEntries: Number(options.maxEntries), + scanBytes: budgetFlag(options.scanBytes), + metadataBytes: budgetFlag(options.metadataBytes), + maxEntries: budgetFlag(options.maxEntries), }); - program.configureOutput().writeOut?.(`${formatLocalHistory(result, format, Number(options.maxBytes))}\n`); + program.configureOutput().writeOut?.(`${formatLocalHistory(result, format, maxBytes)}\n`); } catch (error) { program.error(error instanceof Error ? error.message : String(error), { exitCode: 1, code: 'history.read' }); } diff --git a/src/local-history.ts b/src/local-history.ts index e6162c0d8..4ff244bf8 100644 --- a/src/local-history.ts +++ b/src/local-history.ts @@ -46,16 +46,28 @@ * the copy. A legacy copy keeps the parent's own `thread_settings_applied` * events too, so when more than one candidate follows, the boundary is the * last one whose preceding messages are still a prefix of the parent's own - * messages, read from the parent's file. Without the parent's file that - * cannot be proven: the cut then falls at the LAST candidate, so parent text - * is never attributed to the child, and the result says so - * (`unverifiedPrefixes`, `complete: false`). + * messages, read from the parent's file (once: a family read that selects + * the parent reuses that same read, so no file is charged to `scanBytes` + * twice). Without a readable parent file that cannot be proven: the cut then + * falls at the LAST candidate, so parent text is never attributed to the + * child, and the result says so (`unverifiedPrefixes`, `complete: false`). + * Any failure of the parent's read other than a budget leads here, rather + * than failing the child whose own file is intact. * * A later `session_meta` must be the file's own id or a declared ancestor * (`parent_thread_id`, `forked_from_id`, or the shared root `session_id`, and * transitively the ancestors those declare); any other id still refuses the * whole file rather than attribute one conversation's answer to another. * + * ## Compressed rollouts + * + * Codex's `local_thread_store_compression` feature rewrites cold rollouts to + * `*.jsonl.zst` in place. This reader does not decompress them: a store scan + * counts each one in `compressedFiles` (so `complete` is false and a not-found + * error names them), skipping one whose plain `.jsonl` sibling exists, as + * Codex's own discovery does. A Zstandard file given to `file` is refused by + * its magic number, whatever its name. + * * ## Live rollouts * * A running Codex appends to its rollout. Reads take a snapshot: the file's @@ -73,7 +85,7 @@ import { constants } from 'node:fs'; import { isUtf8 } from 'node:buffer'; import { lstat, open, opendir, stat, type FileHandle } from 'node:fs/promises'; -import { join, resolve } from 'node:path'; +import { basename, join, resolve } from 'node:path'; import { codexMessageFromLine, codexSessionsRoot, isCodexInjectedContext } from './codex-rollout-format.js'; @@ -93,6 +105,13 @@ export const HISTORY_MAX_OUTPUT_BYTES = 1 * MIB; const METADATA_HEAD_BYTES = 128 * KIB; /** Read the head in steps, so a typical rollout costs one step past its session_meta line. */ const METADATA_CHUNK_BYTES = 32 * KIB; +/** + * Codex's compressed rollout suffix (codex-rs `rollout/src/compression.rs`: + * `is_compressed_rollout_path` matches a name ending in `.jsonl.zst`). + */ +const COMPRESSED_ROLLOUT_SUFFIX = '.jsonl.zst'; +/** The Zstandard frame magic number, little-endian on disk. */ +const ZSTD_MAGIC = Buffer.from([0x28, 0xb5, 0x2f, 0xfd]); /** Codex writes `sessions/YYYY/MM/DD`; this leaves five levels of headroom. */ const MAX_DIRECTORY_DEPTH = 8; @@ -152,6 +171,11 @@ export interface LocalHistoryResult { partialRecords: number; skippedSymlinks: number; unattributedFiles: number; + /** + * Zstandard-compressed rollouts (`*.jsonl.zst`, Codex's `local_thread_store_compression`) + * this reader cannot open. Their threads are missing from the result. + */ + compressedFiles: number; unreadableDirectories: number; /** Inherited fork or sub-agent prefixes dropped (informational). */ inheritedPrefixes: number; @@ -166,6 +190,8 @@ interface Budget { metadataRemaining: number; entriesRemaining: number; scan: LocalHistoryResult['scan']; + /** Paths behind `scan.compressedFiles`, for the not-found error. */ + compressedPaths: string[]; } /** A budget ran out: always fatal, never counted. */ @@ -318,6 +344,10 @@ async function readMetadata(path: string, budget: Budget): Promise= 0) { @@ -396,6 +426,13 @@ async function findSources(root: string, budget: Budget): Promise { } if (source) sources.push(source); else budget.scan.unattributedFiles++; + } else if (entry.isFile() && entry.name.endsWith(COMPRESSED_ROLLOUT_SUFFIX)) { + // Codex reads the plain sibling instead when both exist (`should_skip_compressed_sibling`), + // and that sibling is visited (or counted) on its own. + const plain = await lstat(path.slice(0, -'.zst'.length)).catch(() => undefined); + if (plain) continue; + budget.scan.compressedFiles++; + budget.compressedPaths.push(path); } } }; @@ -457,7 +494,10 @@ function isPrefixOf(prefix: Message[], whole: Message[]): boolean { } interface ReadContext { - /** The ancestor's own messages, uncut, or undefined when its file is not in the store. */ + /** + * The ancestor's own messages, uncut, or undefined when its file is not in the + * store or could not be read for any reason other than a budget. + */ ancestorMessages(id: string): Promise; } type Piece = Message | { boundary: true }; @@ -466,8 +506,16 @@ interface LegacyPrefix { /** Messages and `thread_settings_applied` boundaries after the inherited session_meta. */ pieces: Piece[]; } +/** One read of one rollout: every view later needed of it, so no file is read (or charged) twice. */ +interface ParsedSource { + /** Every message in file order, inherited copies included: what a child's copy of this thread holds. */ + raw: Message[]; + /** Messages before any legacy inherited prefix, paginated inherited records already dropped. */ + own: Message[]; + legacy?: LegacyPrefix; +} -async function readMessages(source: Source, budget: Budget, context: ReadContext, raw = false): Promise { +async function parseSource(source: Source, budget: Budget): Promise { const handle = await regularFile(source.path); let buffer: Buffer; try { @@ -487,6 +535,7 @@ async function readMessages(source: Source, budget: Budget, context: ReadContext if (!isUtf8(complete)) throw new Error('History source contains invalid UTF8'); const ancestors = new Set([source.parentId, source.forkedFromId, source.rootId].filter((id): id is string => !!id)); + const raw: Message[] = []; const own: Message[] = []; let legacy: LegacyPrefix | undefined; let lastOrdinal = -1; @@ -515,27 +564,34 @@ async function readMessages(source: Source, budget: Budget, context: ReadContext throw new Error('History source contains conflicting native metadata; attribution refused'); // An ancestor that was itself a fork carries its own ancestors' metadata in the copy. for (const id of [declared.parentId, declared.forkedFromId, declared.rootId]) if (id) ancestors.add(id); - if (raw || inherited || legacy) continue; + if (inherited || legacy) continue; budget.scan.inheritedPrefixes++; legacy = { ancestorId: declared.sessionId, pieces: [] }; continue; } - if (inherited && !raw) continue; if (legacy && entry.type === 'event_msg' && object(entry.payload).type === 'thread_settings_applied') { - legacy.pieces.push({ boundary: true }); + if (!inherited) legacy.pieces.push({ boundary: true }); continue; } const item = message(parsed); if (!item) continue; + raw.push(item); + if (inherited) continue; if (legacy) legacy.pieces.push(item); else own.push(item); } - if (!raw && source.historyStart !== undefined && source.historyStart > 0) { + if (source.historyStart !== undefined && source.historyStart > 0) { budget.scan.inheritedPrefixes++; // Initialization died while copying the prefix: Codex refuses to resume such a file. if (lastOrdinal < source.historyStart - 1) budget.scan.incompletePrefixes++; } - if (legacy) own.push(...(await afterLegacyPrefix(legacy, budget, context))); + return { raw: collapse(raw), own, legacy }; +} + +/** The thread's own messages: its inherited prefix dropped, proven against the parent where one is needed. */ +async function readMessages(parsed: ParsedSource, budget: Budget, context: ReadContext): Promise { + const own = [...parsed.own]; + if (parsed.legacy) own.push(...(await afterLegacyPrefix(parsed.legacy, budget, context))); return collapse(own); } @@ -603,6 +659,32 @@ function nativeRoot(source: Source, sources: Source[]): string | null { } } +/** + * The not-found error, naming what the store held but could not attribute. + * Codex names a rollout `rollout--.jsonl[.zst]`, so a + * compressed file whose name carries the wanted id is named outright; the name + * is only a hint here, never identity. + */ +function missingSessionMessage(wanted: string, budget: Budget): string { + const { scan } = budget; + const notes: string[] = []; + if (scan.compressedFiles) { + // `rollout-` (8) + `YYYY-MM-DDTHH-MM-SS` (19) + `-` (1), then the thread id. + const named = budget.compressedPaths.filter((path) => { + const name = basename(path).toLowerCase(); + return name.startsWith('rollout-') && name.slice(28).startsWith(wanted); + }); + const shown = named.slice(0, 3).join(', ') + (named.length > 3 ? ` and ${named.length - 3} more` : ''); + notes.push( + named.length + ? `it may be in compressed rollout ${shown}, which this reader cannot open; read a decompressed copy with --file (zstd -dc > copy.jsonl)` + : `${scan.compressedFiles} compressed .jsonl.zst rollout(s) could not be read` + ); + } + if (scan.unattributedFiles) notes.push(`${scan.unattributedFiles} file(s) could not be attributed`); + return `Native session missing from history store${notes.length ? ` (${notes.join('; ')})` : ''}`; +} + export async function readLocalHistory(options: LocalHistoryOptions): Promise { validate(options); const scan: LocalHistoryResult['scan'] = { @@ -613,6 +695,7 @@ export async function readLocalHistory(options: LocalHistoryOptions): Promise source.sessionId.startsWith(wanted)); - if (!matches.length) - throw new Error( - scan.unattributedFiles - ? `Native session missing from history store (${scan.unattributedFiles} file(s) could not be attributed)` - : 'Native session missing from history store' - ); + if (!matches.length) throw new Error(missingSessionMessage(wanted, budget)); if (matches.length !== 1) throw new Error('Native session id is ambiguous'); const target = matches[0]; attributionSources = all; @@ -676,23 +755,34 @@ export async function readLocalHistory(options: LocalHistoryOptions): Promise a.timestamp.localeCompare(b.timestamp) || a.sessionId.localeCompare(b.sessionId)); const roots = new Map(sources.map((source) => [source.sessionId, nativeRoot(source, attributionSources)])); - const ancestorCache = new Map>(); + // Each selected file is read once, whether the main loop or a child's boundary proof asks first. + const parsedCache = new Map>(); + const parse = (source: Source) => { + let parsed = parsedCache.get(source); + if (!parsed) { + parsed = parseSource(source, budget); + parsedCache.set(source, parsed); + } + return parsed; + }; const context: ReadContext = { - // A raw read never opens a legacy prefix, so this never recurses. - ancestorMessages(id) { - const cached = ancestorCache.get(id); - if (cached) return cached; + // Raw messages need no boundary proof of their own, so this never recurses. + async ancestorMessages(id) { const candidates = attributionSources.filter((source) => source.sessionId === id); - if (candidates.length !== 1) return Promise.resolve(undefined); - const read = readMessages(candidates[0], budget, context, true); - ancestorCache.set(id, read); - return read; + if (candidates.length !== 1) return undefined; + try { + return (await parse(candidates[0])).raw; + } catch (error) { + // A budget always fails the read; any other failure only leaves the boundary unproven. + if (error instanceof HistoryBudgetError) throw error; + return undefined; + } }, }; const records: HistoryRecord[] = []; for (const source of sources) { records.push( - ...selectedTurns(await readMessages(source, budget, context), options).map((item) => ({ + ...selectedTurns(await readMessages(await parse(source), budget, context), options).map((item) => ({ rootSessionId: roots.get(source.sessionId) ?? null, sessionId: source.sessionId, role: item.role, @@ -710,6 +800,7 @@ export async function readLocalHistory(options: LocalHistoryOptions): Promise HISTORY_MAX_OUTPUT_BYTES) - throw new Error(`Invalid --max-bytes (1-${HISTORY_MAX_OUTPUT_BYTES})`); + validateOutputBytes(maxBytes); const body = format === 'json' ? JSON.stringify(result) diff --git a/test/local-history-live.test.ts b/test/local-history-live.test.ts index aa0348a6c..850afdabe 100644 --- a/test/local-history-live.test.ts +++ b/test/local-history-live.test.ts @@ -30,7 +30,8 @@ vi.mock('node:fs/promises', async (importOriginal) => { }); const history = await import('../src/local-history.js'); -const { assistantTurn, realMeta, userTurn, writeRollout } = await import('./fixtures/codex-rollout.js'); +const { assistantTurn, realMeta, settingsApplied, userTurn, writeRollout } = + await import('./fixtures/codex-rollout.js'); const ROOT = 'aaaaaaaa-1111-4111-8111-111111111111'; const CHILD = 'bbbbbbbb-1111-4111-8111-111111111111'; @@ -126,4 +127,31 @@ describe('rollouts that change while they are read', () => { /changed after attribution/ ); }); + it('leaves a child readable, with an unproven cut, when its parent is replaced after attribution', async () => { + const parentSettings = [settingsApplied(), ...userTurn('parent second question')]; + const parent = await writeRollout(dir, 'rollout-root.jsonl', [ + realMeta({ id: ROOT }), + ...userTurn('parent question'), + ...parentSettings, + ]); + const child = await writeRollout(dir, 'rollout-child.jsonl', [ + realMeta({ id: CHILD, parent: ROOT }), + realMeta({ id: ROOT }), + ...userTurn('parent question'), + ...parentSettings, + settingsApplied(), + ...userTurn('child task'), + ]); + hooks.onOpen = async (opened, count) => { + // The child's transcript read comes before the parent's proof read: replace the parent in between. + if (opened !== child || count !== 2) return; + const replacement = join(dir, 'replacement.tmp'); + await writeFile(replacement, [realMeta({ id: ROOT }), ...userTurn('rewritten')].map(line).join('')); + await rename(replacement, parent); + }; + const result = await history.readLocalHistory({ sessionId: CHILD, sessionsRoot: dir }); + expect(result.records.map((r) => r.message)).toEqual(['child task']); + expect(result.scan.unverifiedPrefixes).toBe(1); + expect(result.complete).toBe(false); + }); }); diff --git a/test/local-history.test.ts b/test/local-history.test.ts index 0a7cf6dc3..15989c6c7 100644 --- a/test/local-history.test.ts +++ b/test/local-history.test.ts @@ -471,6 +471,120 @@ describe('forked and spawned threads', () => { }); }); +describe('one read per file, and a parent that cannot be read', () => { + const parentSettings = [ + settingsApplied(), + ...userTurn('parent second question'), + ...assistantTurn('parent second answer'), + ]; + const sizes = async () => { + const { stat } = await import('node:fs/promises'); + const parent = (await stat(join(dir, `rollout-${ROOT}.jsonl`))).size; + const child = (await stat(join(dir, `rollout-${CHILD}.jsonl`))).size; + return { parent, child }; + }; + + it('charges the parent of a legacy copy to --scan-bytes once, even when it proves the boundary', async () => { + await legacyFamily(dir, parentSettings, parentSettings); + const { parent, child } = await sizes(); + // A budget of exactly the two files: the family read selects the parent and proves the child's cut with it. + const family = await history.readLocalHistory({ + rootSessionId: ROOT, + sessionsRoot: dir, + scanBytes: parent + child, + }); + expect(family.scan.bytesRead).toBe(parent + child); + expect(family.complete).toBe(true); + expect(messages(family).filter((m) => m.startsWith('b:'))).toEqual([ + 'b:child task', + 'b:child commentary', + 'b:child answer', + ]); + // One byte less fails closed rather than returning less. + await expect( + history.readLocalHistory({ rootSessionId: ROOT, sessionsRoot: dir, scanBytes: parent + child - 1 }) + ).rejects.toThrow(/scan byte budget/); + // A child read pays for the parent it proves against, once. + const alone = await history.readLocalHistory({ sessionId: CHILD, sessionsRoot: dir, scanBytes: parent + child }); + expect(alone.scan.bytesRead).toBe(parent + child); + expect(alone.complete).toBe(true); + }); + + it('counts a parent record once when the parent is both selected and the proof of a child', async () => { + await legacyFamily(dir, parentSettings, parentSettings); + const { appendFile } = await import('node:fs/promises'); + await appendFile(join(dir, `rollout-${ROOT}.jsonl`), '{not json\n'); + const family = await history.readLocalHistory({ rootSessionId: ROOT, sessionsRoot: dir }); + expect(family.scan.malformedRecords).toBe(1); + expect(family.scan.inheritedPrefixes).toBe(1); + }); + + it('falls back to the unproven cut when the parent file cannot be read, instead of failing the child', async () => { + await legacyFamily(dir, parentSettings, parentSettings); + const { appendFile } = await import('node:fs/promises'); + await appendFile(join(dir, `rollout-${ROOT}.jsonl`), Buffer.from([0xff, 0x0a])); + const child = await history.readLocalHistory({ sessionId: CHILD, sessionsRoot: dir }); + expect(messages(child)).toEqual(['b:child task', 'b:child commentary', 'b:child answer']); + expect(child.scan.unverifiedPrefixes).toBe(1); + expect(child.complete).toBe(false); + // The parent's own read still fails: its evidence is what is broken. + await expect(history.readLocalHistory({ sessionId: ROOT, sessionsRoot: dir })).rejects.toThrow(/invalid UTF8/); + }); + + it('still fails when the parent read runs out of budget', async () => { + await legacyFamily(dir, parentSettings, parentSettings); + const { child } = await sizes(); + await expect( + history.readLocalHistory({ sessionId: CHILD, sessionsRoot: dir, scanBytes: child + 1 }) + ).rejects.toThrow(/scan byte budget/); + }); +}); + +describe('compressed rollouts', () => { + const ZSTD = Buffer.from([0x28, 0xb5, 0x2f, 0xfd, 0x04, 0x00, 0x00, 0x00]); + const compressedName = (id: string) => `rollout-2026-10-24T01-00-00-${id}.jsonl.zst`; + + it('counts a .jsonl.zst rollout, so a family with a compressed member is incomplete', async () => { + await legacyFamily(dir); + await mkdir(join(dir, '2026', '10', '24'), { recursive: true }); + await writeFile(join(dir, '2026', '10', '24', compressedName(GRANDCHILD)), ZSTD); + const family = await history.readLocalHistory({ rootSessionId: ROOT, sessionsRoot: dir }); + expect(family.scan.compressedFiles).toBe(1); + expect(family.scan.unattributedFiles).toBe(0); + expect(family.complete).toBe(false); + }); + + it('names the compressed rollout when the wanted thread is not otherwise found', async () => { + await fixture(ROOT, [row('assistant', 'root')]); + const path = join(dir, compressedName(CHILD)); + await writeFile(path, ZSTD); + const error = await history.readLocalHistory({ sessionId: CHILD.slice(0, 8), sessionsRoot: dir }).catch((e) => e); + expect(error.message).toContain(path); + expect(error.message).toMatch(/compressed rollout/); + // A compressed file whose name does not carry the id is still reported, by count. + const other = await history.readLocalHistory({ sessionId: GRANDCHILD, sessionsRoot: dir }).catch((e) => e); + expect(other.message).toMatch(/1 compressed \.jsonl\.zst rollout\(s\) could not be read/); + }); + + it('skips a compressed rollout whose plain sibling exists, as Codex does', async () => { + const plain = await fixture(ROOT, [row('assistant', 'root')]); + await writeFile(`${plain}.zst`, ZSTD); + const result = await history.readLocalHistory({ sessionId: ROOT, sessionsRoot: dir }); + expect(result.scan.compressedFiles).toBe(0); + expect(result.complete).toBe(true); + }); + + it('refuses a Zstandard file given to --file, and counts one in the store whatever its name', async () => { + const file = join(dir, 'renamed.jsonl'); + await writeFile(file, ZSTD); + await expect(history.readLocalHistory({ file })).rejects.toThrow(/Zstandard-compressed/); + await fixture(ROOT, [row('assistant', 'root')]); + const result = await history.readLocalHistory({ sessionId: ROOT, sessionsRoot: dir }); + expect(result.scan.unattributedFiles).toBe(1); + expect(result.complete).toBe(false); + }); +}); + describe('large and untidy stores', () => { it('finds one id among thousands of rollouts with real-sized session_meta at the default budgets', async () => { const count = 3500; @@ -692,6 +806,31 @@ describe('history CLI flags', () => { expect(await run(['--file', file, '--format', 'text'])).toMatch(/^source=codex-jsonl/); }); + it('validates --max-bytes before any file is read', async () => { + // A missing store would be the first thing a read touches; an invalid --max-bytes must be refused first. + const missing = join(dir, 'no-such-store'); + for (const value of ['0', String(history.HISTORY_MAX_OUTPUT_BYTES + 1), 'lots', '0x10', '1e3', ' 5']) + await expect(run(['--session-id', ROOT, '--sessions-root', missing, '--max-bytes', value])).rejects.toThrow( + /Invalid --max-bytes/ + ); + await expect(run(['--session-id', ROOT, '--sessions-root', missing, '--max-bytes', '65536'])).rejects.toThrow( + /Sessions root not found/ + ); + }); + + it('reads budget flags as plain decimal integers only', async () => { + await fixture(ROOT, [row('assistant', 'answer')]); + await expect(run(['--session-id', ROOT, '--sessions-root', dir, '--scan-bytes', '0x100000'])).rejects.toThrow( + /Invalid --scan-bytes/ + ); + await expect(run(['--session-id', ROOT, '--sessions-root', dir, '--max-entries', '1e3'])).rejects.toThrow( + /Invalid --max-entries/ + ); + expect( + JSON.parse(await run(['--session-id', ROOT, '--sessions-root', dir, '--scan-bytes', '1048576'])).complete + ).toBe(true); + }); + it('passes the store caps through and validates them', async () => { await fixture(ROOT, [row('assistant', 'answer')]); await fixture(CHILD, [row('assistant', 'child')], ROOT); From ae3d31407bbfeb1d35cecdf1d712d5eb6f626bbc Mon Sep 17 00:00:00 2001 From: Aamer Akhter Date: Sun, 25 Oct 2026 09:11:46 -0400 Subject: [PATCH 35/68] fix(terminal): keep a labelled table record out of a cell-wrapped path MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A wrapper wraps every column of one table row together, so the row that carries a cell's continuation is blank to the left of that cell unless a path in a left column continued on it too. A label there (`Entry`, `│ Helper │`) opens the next record, so a directory ending in `/` or `-` (`Workspace /home/u/proj/app/` over `Entry main.py`) no longer takes the next record's file name. What still joins is a trailing-slash directory over a relative path in the same column with nothing to its left, which reads exactly like a wrap at `/`; that trade-off stays pinned. --- CLAUDE.md | 2 +- docs/architecture-invariants.md | 2 +- src/web/public/constants.js | 23 ++++++++++++++--- test/terminal-cell-wrapped-paths.test.ts | 32 ++++++++++++++++++------ 4 files changed, 46 insertions(+), 13 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 9fa84f574..e8609a10c 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -224,7 +224,7 @@ Codeman is a Claude Code session manager with web interface and autonomous Ralph **File-path links (terminal + chat)**: a path an agent prints is clickable on BOTH surfaces and opens the file-preview overlay. ⚠️ ONE pattern (`FILE_PATH_LINK_PATTERN` / `absoluteFilePathPattern()` in constants.js) feeds the xterm link provider AND `_linkifyFilePaths()`, a fresh instance per call (`lastIndex`). Both call `findFilePathLinks()`, which adds `~/`, relative and folder paths without overlapping a rooted match or a URL; a relative path resolves against `workingDir` (`resolveLinkedFilePath`), `~/` only server-side on the session's host (attachment route), and `terminalPathLine()` joins a path a program hard-wrapped short of the edge. ⚠️ A folder link opens the **folder grid**: `POST /api/sessions/:id/folders` guards + GRANTS the folder (`folder-listing.ts`, own registry, never the 200-capped attachment registry), files come by bare name via `…/folders/:folderId/files/:name/*` through `resolveServableAttachmentPath` plus the same extension/regular-file gate on the symlink's resolved target, so no browser request carries a path; `~/` is refused in a Docker case (the container's home is not this host's). The chat linkifier walks TEXT NODES with DOM APIs, never rebuilds sanitized markup as a string. ⚠️ An out-of-workspace path goes through the ATTACHMENT routes (`POST /api/sessions/:id/attachments` with `notify: false`), never by widening `file-content`/`file-raw` or `file-stream-manager`'s `tail -f` allowlist. ⚠️ `TEXT_ATTACHMENT_EXTENSIONS` IS `EDITABLE_EXTENSIONS` (never a second list), and widening READ must never widen RUN: `html`/`htm`/`svg` stay download-only, other text is inert `text/plain`+`nosniff`. ⚠️ The ONE place HTML renders is `/html-view//...` (`html-view-routes.ts`, `html-view-capabilities.ts`): `POST /api/sessions/:id/html-view` mints a capability for the file's own directory, every response carries `Content-Security-Policy: sandbox` WITHOUT `allow-same-origin` (opaque origin even as a top-level tab, so the page can never read Codeman or drive its API) and with NO fetch directive (open network, owner decision 2026-10-13: never restrict it or add `allow-same-origin` without the owner), and only non-dot, asset-allowlisted files under that directory are served (GET/HEAD only; capabilities revoked with the web-tab ones on logout). That tree is served recursively, so minting is refused for a hidden directory or a broad root (`isBroadHtmlViewRoot`); the preview renders by default and the **Page** pill (or a refused mint) falls through to the source view and its Edit button. Never serve HTML same-origin anywhere else. Tests: `test/routes/html-view-routes.test.ts`. Media extensions are single-sourced in `attachment-registry.ts`. ⚠️ **XLSX previews parse in the BROWSER**, never on the server; the admission limits that keep a hostile workbook from freezing the worker live in `src/web/CLAUDE.md` (Spreadsheet preview). → [architecture-invariants#file-path-links-terminal--response-viewer](docs/architecture-invariants.md#file-path-links-terminal--response-viewer) -The terminal provider scans `terminalLinkLine()`, which extends `terminalPathLine()` for URL continuations in indented paragraphs and table cells. List markers, bare numbers, version tokens, whole email addresses, labels ending in `:`, measurements and clock times (`45%`, `12.3ms`, `10:00:00`) and a `name=value` field under a URL ending in `/` never continue a URL; a token with only `-`, `_` or `.` is also refused when it carries a call paren (`client.get()`) or ends a sentence (`Done.`). A URL in the last column of a space-aligned table never takes the next row's first cell: a continuation row must start inside the URL's own cell, so a gap of two or more spaces between its indent and where the URL starts on the row above means it is the next record. `terminalCellWrappedPaths()` follows each table cell independently: a fragment carries on into the next row only when it ends its cell on `/` or `-` (where a wrapper breaks a path; codex's cell wrap never breaks at `_`), the next row's token passes `pathContinuation()`, and the cell is no wider than the one above. So a directory that is a whole cell value (`/home/u/proj` over `config.yaml`) keeps its folder link and the next row keeps its own file link. A directory written with its trailing slash over a relative path in the same column (`/home/u/proj/src/` over `lib/util.ts`) still joins, a known trade-off: it reads exactly like a wrap at `/`. Accepted paths displace other links on their cells. Full-width divider rows stop `terminalLogicalLine()` stitching for both links and touch selection. OSC 8 links confirm the real target URI before the shared opener runs. +The terminal provider scans `terminalLinkLine()`, which extends `terminalPathLine()` for URL continuations in indented paragraphs and table cells. List markers, bare numbers, version tokens, whole email addresses, labels ending in `:`, measurements and clock times (`45%`, `12.3ms`, `10:00:00`) and a `name=value` field under a URL ending in `/` never continue a URL; a token with only `-`, `_` or `.` is also refused when it carries a call paren (`client.get()`) or ends a sentence (`Done.`). A URL in the last column of a space-aligned table never takes the next row's first cell: a continuation row must start inside the URL's own cell, so a gap of two or more spaces between its indent and where the URL starts on the row above means it is the next record. `terminalCellWrappedPaths()` follows each table cell independently: a fragment carries on into the next row only when it ends its cell on `/` or `-` (where a wrapper breaks a path; codex's cell wrap never breaks at `_`), the continuation row is blank to the left of the cell (a wrapper wraps every column of a table row together, so a label there such as `Entry` or `│ Log │` is the next record, unless a left column's path continued on that row too), the next row's token passes `pathContinuation()`, and the cell is no wider than the one above. So a directory that is a whole cell value (`/home/u/proj` over `config.yaml`, or `/home/u/proj/app/` over a labelled `main.py`) keeps its folder link and the next row keeps its own file link. With no label to tell them apart, a directory written with its trailing slash over a relative path in the same column (`/home/u/proj/src/` over `lib/util.ts`) still joins, a known trade-off: it reads exactly like a wrap at `/`. Accepted paths displace other links on their cells. Full-width divider rows stop `terminalLogicalLine()` stitching for both links and touch selection. OSC 8 links confirm the real target URI before the shared opener runs. **Filesystem path picker** (Link Existing "Browse" + the mobile keyboard's `📁 Path` key): lazy one-directory browsing via `GET /api/filesystem/browse`, with `GET /api/filesystem/preview` for the tapped file. Inserts the path **without** Enter, so the prompt is never submitted; the sibling `⌫ All` key clears only the unsent prompt and must never send the agent's `/clear`. ⚠️ This is a **second file-serving surface and inherits neither the attachment confinement nor its ownership scoping** — it allowlists Home, `CASES_DIR`, `/mnt/d` and `CODEMAN_FILE_PICKER_ROOTS`, blocks sensitive trees, and rejects symlink escapes **after** `realpath`. ⚠️ The optional `sessionId` is an ownership boundary that must be `canAccessOwned`-checked by hand (it does not go through `findSessionOrFail`), and in multi-user mode a non-admin gets only their own `userSpacePath` as a root: per-user spaces live INSIDE `homedir()`, so a `Home` root exposes every other user's workspace. Previews go through the same global conversion limiter, and Markdown/TXT/JSON are served as inert `text/plain`. → [architecture-invariants#filesystem-path-picker](docs/architecture-invariants.md#filesystem-path-picker) diff --git a/docs/architecture-invariants.md b/docs/architecture-invariants.md index 8d1713a25..0060343c7 100644 --- a/docs/architecture-invariants.md +++ b/docs/architecture-invariants.md @@ -409,7 +409,7 @@ A file path an agent prints is a link on both surfaces it can appear on, and cli **Path forms beyond the rooted one.** `findFilePathLinks(text)` (constants.js) is the single entry point both consumers call; it returns the rooted matches first, then `~/` and relative paths (`RELATIVE_FILE_PATH_LINK_PATTERN`, same extension list, pinned equal by `test/link-provider-regex.test.ts`), then folder paths, each only where it overlaps neither an earlier match nor an `http(s)` URL. A relative match must start at a boundary (line start, whitespace, quote, bracket, `=`, `,`, `>`), so it can never begin inside a word, an absolute path or a URL; brackets, `=` and `,` end it (`(shot.png)`, `OUT=shots/a.png`). `resolveLinkedFilePath()` resolves a relative path against the session's `workingDir` (`.`/`..` collapsed, so `../x.png` lands outside and routes like any external path) and passes absolute and `~/` paths through. ⚠️ `~` is the home of the SESSION'S host, which only the server knows: `~/` paths always take the attachment route, where `registerExternalAttachment` expands them (`homedir()` locally, `$HOME` inside the remote probe script), and the guard runs on the expanded, realpath-resolved path as before. ⚠️ Programs hard-wrap their own output short of the terminal edge (Claude Code breaks tool output at 59-118 columns), so xterm's `isWrapped` never fires. `terminalPathLine()` (link provider only; touch selection keeps `terminalLogicalLine`) joins the next row when the previous row ends in a path token with a `/` and no extension and the join reaches an extension; a row that starts a new path is never joined: rooted or `~/`, `./`/`../`, or a relative one that opens on the same top directory the broken token did (the next entry of `git ls-files`/`find`/`ls -R`: `src/components` + `src/index.ts` are two paths, not `src/componentssrc/index.ts`), while a wrapped path continues INSIDE a name, so `…/captures/2` + `026-10-10/…` still joins; a 3+ row chain is kept only if a later row completes the path, and the broken row must be at least as long as the continuing one (keeps `cd /mnt/foo` + a longer next line apart). Tests: `test/file-path-link-forms.test.ts`. -The terminal provider scans `terminalLinkLine()`, which extends `terminalPathLine()` for URL continuations in indented paragraphs and table cells. List markers, bare numbers and version tokens never continue a URL. A token with only hyphens, underscores or dots must end its cell (row end or a gap of at least two spaces), so prose such as `self-hosted runners` is not appended. A row end counts as a cell end, so `⏺ Docs live at https://example.com/guide/` followed by ` self-hosted` still links `.../guide/self-hosted`, and an indented `and/or the API reference covers it.` still links `.../guide/and/or`; both are pinned as known trade-offs. Such a token is still refused when it is code with a call paren (`client.get()`, `expect(x).toBe(y)`; a wiki title like `Mercury_(planet)` keeps its paren) or a word ending a sentence (`ready.`, `Done.`, `self-hosted.`; a file name keeps its period, so `install.html.` still joins). Whatever its punctuation, a token never continues a URL when it ends with a colon (a label such as `Failure/Error:` or `prometheus.io/scrape:`), when it is a measurement or clock time (`45%`, `45%|####`, `12.3MB/45.0MB`, `12.3ms`, `10:00:00`; percent-encoding such as `2026%20Plan` still joins), or when it is a `name=value` field (`API_KEY=...`, `level=info`) under a URL ending in `/`, since a query needs a `?` first (after `?`, `&` or `_` it is a query parameter and joins). A continuation must be indented (a column-0 row is new output, which keeps `status=200`, `24/7` and `really?` out), and a shell prompt (`user@host:`), a home path (`~/`), a whole email address or an HTTP status line never continues a URL. A URL in the last column of a space-aligned table never takes the next row's first cell: a continuation row must start inside the URL's own cell, so a gap of two or more spaces between its indent and where the URL starts on the row above means it is the next record. A divider row never continues the row above it, so a URL that reaches the right edge does not absorb a full-width `────` row below. A real plain slug followed by prose falls back to the original fragment link; `%`, `&`, `=`, `/` or `?` still qualify as stronger URL punctuation. `terminalCellWrappedPaths()` follows each table cell independently: a fragment carries on into the next row only when it ends its cell on `/` or `-` (where a wrapper breaks a path; codex's cell wrap never breaks at `_`), the next row's token passes `pathContinuation()`, and the cell is no wider than the one above. So a directory that is a whole cell value (`/home/u/proj` over `config.yaml`) keeps its folder link and the next row keeps its own file link. A directory written with its trailing slash over a relative path in the same column (`/home/u/proj/src/` over `lib/util.ts`) still joins, a known trade-off: it reads exactly like a wrap at `/`. Accepted paths displace other links on their cells. Full-width divider rows stop `terminalLogicalLine()` stitching for both links and touch selection. OSC 8 links confirm the real target URI before the shared opener runs. +The terminal provider scans `terminalLinkLine()`, which extends `terminalPathLine()` for URL continuations in indented paragraphs and table cells. List markers, bare numbers and version tokens never continue a URL. A token with only hyphens, underscores or dots must end its cell (row end or a gap of at least two spaces), so prose such as `self-hosted runners` is not appended. A row end counts as a cell end, so `⏺ Docs live at https://example.com/guide/` followed by ` self-hosted` still links `.../guide/self-hosted`, and an indented `and/or the API reference covers it.` still links `.../guide/and/or`; both are pinned as known trade-offs. Such a token is still refused when it is code with a call paren (`client.get()`, `expect(x).toBe(y)`; a wiki title like `Mercury_(planet)` keeps its paren) or a word ending a sentence (`ready.`, `Done.`, `self-hosted.`; a file name keeps its period, so `install.html.` still joins). Whatever its punctuation, a token never continues a URL when it ends with a colon (a label such as `Failure/Error:` or `prometheus.io/scrape:`), when it is a measurement or clock time (`45%`, `45%|####`, `12.3MB/45.0MB`, `12.3ms`, `10:00:00`; percent-encoding such as `2026%20Plan` still joins), or when it is a `name=value` field (`API_KEY=...`, `level=info`) under a URL ending in `/`, since a query needs a `?` first (after `?`, `&` or `_` it is a query parameter and joins). A continuation must be indented (a column-0 row is new output, which keeps `status=200`, `24/7` and `really?` out), and a shell prompt (`user@host:`), a home path (`~/`), a whole email address or an HTTP status line never continues a URL. A URL in the last column of a space-aligned table never takes the next row's first cell: a continuation row must start inside the URL's own cell, so a gap of two or more spaces between its indent and where the URL starts on the row above means it is the next record. A divider row never continues the row above it, so a URL that reaches the right edge does not absorb a full-width `────` row below. A real plain slug followed by prose falls back to the original fragment link; `%`, `&`, `=`, `/` or `?` still qualify as stronger URL punctuation. `terminalCellWrappedPaths()` follows each table cell independently: a fragment carries on into the next row only when it ends its cell on `/` or `-` (where a wrapper breaks a path; codex's cell wrap never breaks at `_`), the continuation row is blank to the left of the cell (a wrapper wraps every column of a table row together, so a label there such as `Entry` or `│ Log │` is the next record, unless a left column's path continued on that row too), the next row's token passes `pathContinuation()`, and the cell is no wider than the one above. So a directory that is a whole cell value (`/home/u/proj` over `config.yaml`, or `/home/u/proj/app/` over a labelled `main.py`) keeps its folder link and the next row keeps its own file link. With no label to tell them apart, a directory written with its trailing slash over a relative path in the same column (`/home/u/proj/src/` over `lib/util.ts`) still joins, a known trade-off: it reads exactly like a wrap at `/`. Accepted paths displace other links on their cells. Full-width divider rows stop `terminalLogicalLine()` stitching for both links and touch selection. OSC 8 links confirm the real target URI before the shared opener runs. **Folder grid: a folder is granted, then served by bare file name.** A folder path (rooted or `~/` whose last segment has no extension, relative only with a trailing `/`; `FOLDER_PATH_LINK_PATTERN` + `folderLinkPath()`) opens `openFolderGrid()` (panels-ui.js), as does the preview's "Show folder" button. `POST /api/sessions/:id/folders {path}` (`src/folder-listing.ts`) resolves the folder (locally a bounded `probePathKind(…, { pastCap: true })` first, then async `fs.realpath`; remotely one ssh round trip via `remoteListFolder`, through `runWithRemoteSshLimit`), runs the attachment guard on it (blocklist, `confineToWorkspace`, and in multi-user mode `folderAllowedForUser`: a non-admin only inside their own `userSpacePath` or the session's `workingDir`, as the filesystem path picker), locally BEFORE reading it, lists non-hidden subfolders and files of a supported attachment type (each file also blocklist-checked, newest first, capped at `MAX_FOLDER_ENTRIES`), and GRANTS it an id. Files are then fetched as `GET …/folders/:folderId/files/:name/(raw|preview|thumbnail)`: `:name` must be a bare file name of a supported type (no `/`, `\`, NUL, no leading `.`, since the listing hides those), the local file is probed bounded (`unknown` → 503, never 404), and the joined path goes through the SAME `resolveServableAttachmentPath` as an attachment by id (symlink re-resolution, remote re-probe, guard), then the same gate registration applies to what a symlink RESOLVES to (a supported extension on the resolved path and a regular file: locally `fs.stat`, remotely the probe kind; else 400, so `shot.png -> ../secret.bin` is not served and a FIFO or device behind a supported name never reaches `open`), then `folderAllowedForUser` once more on the resolved file, before `serveServableRaw` (the attachment raw route's body, shared) streams it. So browser requests still never carry a path. ⚠️ Grants live in their own registry (`folderGrants`, 32 per session, cleared with the session's attachments), NOT the attachment registry: that one is capped at 200 per session, and paging a folder of 300 renders would evict the session's real attachments. ⚠️ A local folder is never touched unbounded (#516 rule): a folder on a network mount that stopped answering answers 503 with `describeUnknownPath()`, not 404 and not a frozen server. ⚠️ The remote listing script tells "missing" (`E|n`, 404) from "is a file" (`E|f`, 400 `Not a folder`), because the client falls back to the file preview on `Not a folder`; a remote extensionless file otherwise answered "Folder not found". ⚠️ `.folder-grid-overlay` sits at `z-index: 5090`, one step BELOW the preview (5100) and above the response viewer (5000): a tile's preview opens on top and closing it lands back on the grid. ⚠️ ←/→/Esc are handled in a document CAPTURE listener with `stopPropagation`, because the terminal keeps keyboard focus under the overlays and an arrow that reached xterm would be typed into the session; inputs other than xterm's helper textarea keep their keys. ⚠️ A route PARAMETER is capped by the router (Fastify's default is 100 characters, answered as 404 "Route not found"), and a file name goes up to 255, percent-encoded: the server sets `routerOptions.maxParamLength` to `MAX_ROUTE_PARAM_LENGTH` (route-helpers.ts), the route test harness uses the same constant, and a wiring guard pins it. ⚠️ `~/` in a Docker case is refused with 400 (attachment POST and `POST …/folders`): the session is local for these routes, but `~` inside the container is the container's own home, and expanding it on the host would open a different file than the one the agent printed. Tests: `test/routes/folder-grid-routes.test.ts` (incl. the listing script on a real `sh`), `test/folder-grid-ui.test.ts`. diff --git a/src/web/public/constants.js b/src/web/public/constants.js index 5e22d152a..e5668a255 100644 --- a/src/web/public/constants.js +++ b/src/web/public/constants.js @@ -2763,7 +2763,10 @@ function terminalLinkLine(buffer, row, cols, maxRows) { * A fragment qualifies only when it is the last thing in its cell on that row * (row end or a ≥2-space gap follows) and ends on `/` or `-`, the only places * a wrapper breaks a path; a cell that ends inside a name (`/home/u/proj` over - * `config.yaml`) is a whole value, never the first piece of the next row's. Every + * `config.yaml`) is a whole value, never the first piece of the next row's. A + * continuation row must be blank to the left of the cell (a wrapper wraps every + * column of one table row together), unless a path in a left column continued + * on it too: a label there (`Entry`, `│ Log │`) is the next record. Every * continuation must pass pathContinuation and its cell must be no wider than the * preceding cell's text, so independent listing rows and longer prose never * displace links. @@ -2832,10 +2835,22 @@ function terminalCellWrappedPaths(buffer, row, maxRows) { // name (`/home/u/proj` over `config.yaml`) is a whole value, not a piece. // codex wraps table cells on Unicode line-break opportunities plus a // hyphen splitter, which break after `/` and `-` but never at `_` (UAX #14 - // class AL). Known trade-off: a directory written with its trailing slash - // over a relative path in the same column (`/home/u/proj/src/` over - // `lib/util.ts`) still joins, since it reads exactly like a wrap at `/`. + // class AL). if (!/[/-]$/.test(joined)) break; + // A wrapper wraps every column of one table row together, so a row that + // carries a cell's continuation is blank to its left unless a left column + // wrapped on that row too. A label there (`Entry`, `│ Log │`) opens the + // next record, even under a directory ending in `/` (`Workspace + // /home/u/proj/app/` over `Entry main.py`). + // Known trade-off: with nothing to the left, a directory written with its + // trailing slash over a relative path in the same column (`/home/u/proj/` + // over `index.ts`) still joins, since it reads exactly like a wrap at `/`. + if ( + /[^\s│┃║|]/.test(next.slice(0, cellCol)) && + !found.some((hit) => hit.segments.some((seg) => seg.row === r && seg.endCol <= cellCol)) + ) { + break; + } if (cellText.length > previousCellLength || !pathContinuation(joined, token)) break; const candidate = joined + token; const match = absoluteFilePathPattern().exec(candidate); diff --git a/test/terminal-cell-wrapped-paths.test.ts b/test/terminal-cell-wrapped-paths.test.ts index 873aaf142..765ec3dfa 100644 --- a/test/terminal-cell-wrapped-paths.test.ts +++ b/test/terminal-cell-wrapped-paths.test.ts @@ -87,9 +87,27 @@ const WHOLE_VALUE_SHAPES: Array<{ rows: string[]; dir: string; file: string }> = file: 'index.ts', }, ]; -// Known trade-off: a directory written WITH its trailing slash over a relative -// path in the same column reads exactly like a wrap at `/`, so it still joins. -const TRAILING_SLASH_TRADE_OFF = [' Source /home/u/proj/src/', ' Helper lib/util.ts']; +// The same records where the directory DOES end on a break character: the label +// on the next row (`Entry`, `│ Helper │`) says it is a new record, since a wrapper +// wraps every column of one table row together and leaves the label cell blank. +const LABELLED_RECORD_SHAPES: Array<{ rows: string[]; dir: string; file: string }> = [ + { + rows: [' Workspace /home/u/proj/app/', ' Entry main.py'], + dir: '/home/u/proj/app/', + file: 'main.py', + }, + { + rows: ['│ Source │ /home/u/proj/src/ │', '│ Helper │ lib/util.ts │'], + dir: '/home/u/proj/src/', + file: 'lib/util.ts', + }, + { rows: [' Prefix /tmp/run-', ' Suffix log.txt'], dir: '/tmp/run-', file: 'log.txt' }, +]; +const SEPARATE_RECORD_SHAPES = [...WHOLE_VALUE_SHAPES, ...LABELLED_RECORD_SHAPES]; +// Known trade-off: with no label to tell them apart, a directory written WITH its +// trailing slash over a relative path in the same column reads exactly like a +// wrap at `/`, so it still joins. +const TRAILING_SLASH_TRADE_OFF = [' /home/u/proj/src/ sources', ' lib/util.ts helpers']; const BEFORE = '/tmp/demo-check/docs/review/BLDMESH-243/before-overview.png'; const AFTER = '/tmp/demo-check/docs/review/BLDMESH-243/after-overview.png'; @@ -109,7 +127,7 @@ describe('terminalCellWrappedPaths', () => { for (let row = 0; row < rows.length; row++) expect(find(fakeBuffer(rows, COLS), row)).toEqual([]); }); - it.each(WHOLE_VALUE_SHAPES.map((shape) => [shape.rows, shape]))( + it.each(SEPARATE_RECORD_SHAPES.map((shape) => [shape.rows, shape]))( 'does not join a directory cell onto the next record: %j', (_rows, shape) => { const rows = shape.rows.map((text) => ({ text })); @@ -123,7 +141,7 @@ describe('terminalCellWrappedPaths', () => { expect(find(fakeBuffer(rows, COLS), 1)).toEqual([]); }); - it('still joins a trailing-slash directory over a relative path in its column (known trade-off)', () => { + it('still joins an unlabelled trailing-slash directory over a relative path in its column (known trade-off)', () => { const rows = TRAILING_SLASH_TRADE_OFF.map((text) => ({ text })); expect(find(fakeBuffer(rows, COLS), 1).map((h) => h.path)).toEqual(['/home/u/proj/src/lib/util.ts']); }); @@ -260,8 +278,8 @@ describe('terminal link provider with cell-wrapped paths', () => { } }); - it.each(WHOLE_VALUE_SHAPES.map((shape) => [shape.rows, shape]))( - 'keeps the folder link and the next record’s file link: %j', + it.each(SEPARATE_RECORD_SHAPES.map((shape) => [shape.rows, shape]))( + 'keeps the folder link and the file link of the next record: %j', (_rows, shape) => { const { provide, app } = harness(shape.rows.map((text) => ({ text }))); const dirLine = shape.rows.findIndex((text) => text.includes(shape.dir)) + 1; From 2832a037d6884f83767a4cb49d6c86f757403a2d Mon Sep 17 00:00:00 2001 From: Aamer Akhter Date: Sun, 25 Oct 2026 09:22:35 -0400 Subject: [PATCH 36/68] fix(agent): verify staged text on a live codex pane and refuse popups Stage now re-reads the pane after typing (every 50 ms, up to 1 s) and issues a receipt only for a frame with the same generation, the input revision its own write moved, and a composer hash equal to the message's. Codex repaints 30 to 60 ms after the keys, so the first capture was often stale and stage refused with the text already typed. Every refusal after typing now says the message may be in the composer and points at the Ctrl+U recovery. A visible-frame capture reads the screen and the caret in one tmux invocation, with the old two calls as the fallback. Codex draws its suggestion popups above the composer and keeps the footer, so a staged /stat verified and submit ran /status. Stage now refuses a leading / or ! and a last word starting with @ or $ before typing, and stage and submit refuse a frame with popup rows just above the composer. The pattern and characters are registry data (composerPopupLine, composerCommandPrefixes, composerCompletionSigils), read from the codex 0.162.0 bottom_pane sources. Submit waits until 250 ms after the staged keys: codex turns an Enter within 120 ms of a typed burst into a newline. POST .../input answers 409 with its seq returned while a guarded submit holds the input lease, instead of 200 for bytes it never wrote. Also align the attribution memo comment with the code (a verified file whose incremental scan fails is not re-walked, since the full walk reads a superset), and list stage as a fromUser source in CLAUDE.md. --- .changeset/bounded-agent-coordination.md | 2 + CLAUDE.md | 4 +- docs/api-reference.md | 56 +++++- docs/architecture-invariants.md | 32 ++- docs/cli-registry.md | 23 ++- docs/wiki/Driving-Codeman-From-An-Agent.md | 8 +- .../reference/inspection-and-staging.md | 33 ++- .../codeman/skills/codeman/reference/verbs.md | 4 +- .../reference/inspection-and-staging.md | 33 ++- skills/codeman/reference/verbs.md | 4 +- src/agent-codex-identity.ts | 13 +- src/agent-staging.ts | 169 ++++++++++++++-- src/cli-agent.ts | 2 +- src/config/cli-registry/schema.ts | 19 ++ src/config/cli-registry/stock.ts | 22 ++ src/config/cli-registry/types.ts | 24 +++ src/session.ts | 8 + src/tmux-manager.ts | 44 +++- src/web/routes/agent-session-routes.ts | 11 +- src/web/routes/session-routes.ts | 18 ++ test/agent-codex-identity.test.ts | 16 ++ test/agent-staging-session.test.ts | 73 ++++++- test/agent-staging.test.ts | 189 +++++++++++++++++- test/cli-registry-schema.test.ts | 42 ++++ test/routes/agent-session-routes.test.ts | 11 + test/routes/session-routes.test.ts | 26 +++ test/tmux-capture-full-history.test.ts | 37 +++- 27 files changed, 849 insertions(+), 74 deletions(-) diff --git a/.changeset/bounded-agent-coordination.md b/.changeset/bounded-agent-coordination.md index 73f9513ad..2ff2c0af5 100644 --- a/.changeset/bounded-agent-coordination.md +++ b/.changeset/bounded-agent-coordination.md @@ -10,6 +10,8 @@ Codex rollout attribution remembers the attributed file per session and re-verif The guarded composer read takes its glyph and footer from the CLI registry: codex's `workDetect.promptGlyph` and a new `workDetect.composerFooterLine` capability (config-supplied regex, compiled through the ReDoS guard). Only a CLI declaring it takes guarded input. Codex's footer effort words are now one shared list for this and `modelDetect.screenLine`. +Stage waits for the pane to show what it typed: it re-reads the composer every 50 ms for up to one second and issues a receipt only for a frame showing exactly the message (Codex repaints 30 to 60 ms after the keys, so the first capture was often stale and stage refused with the text already typed). Every refusal after typing now says the message may be in the composer and points at the Ctrl+U recovery. A visible-frame pane capture reads the screen and the caret in one tmux invocation. Stage refuses messages that would change what Enter does (codex: a leading `/` or `!`, or a last word starting with `@` or `$`, which open a command or completion popup) and frames with a suggestion popup above the composer, from three new registry fields (`workDetect.composerPopupLine`, `composerCommandPrefixes`, `composerCompletionSigils`). Submit waits until 250 ms after the staged keys before sending Enter, since codex turns an Enter within 120 ms of typed text into a newline. While a guarded submit is in flight, `POST /api/sessions/:id/input` answers `409 CONFLICT` with its seq returned instead of a `200` for input that was never written. + Document the initial local Codex support and conversation-attribution limits in the API, CLI and packaged skill. Other modes retain labeled snapshots or refuse unverifiable guarded input. SubmitVerifier Enter re-presses now use the shared input boundary and update `lastSubmitAt` for each retry on every supported CLI. This keeps submission timestamps consistent with delivered Enter attempts while preventing a stale retry from bypassing a guarded submission. diff --git a/CLAUDE.md b/CLAUDE.md index a5e8101f0..6cf354515 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -175,7 +175,7 @@ Codeman is a Claude Code session manager with web interface and autonomous Ralph **Docker Compose deployment** (`docker/`): Codeman runs in a container and spawns Docker cases as **SIBLING** containers via the host socket, never nested. `resolveDockerDaemonMountSource()` maps HOME bind sources into the daemon's namespace (`CODEMAN_DOCKER_HOST_HOME`); `CODEMAN_CASES_PATH` makes workspaces resolve to the same absolute path on both sides. ⚠️ `CODEMAN_CASES_PATH` must move every consumer: resolve it only via `config/cases-dir.ts`. ⚠️ `.dockerignore` matches whole paths: keep `**/.env` or `docker/.env` secrets ship in the image. ⚠️ Long-form binds create missing sources ROOT-OWNED: `Start-Codeman.sh` pre-creates them, and `docker/entrypoint.sh` (root, `cap_add: [CHOWN, DAC_OVERRIDE, KILL, SETGID, SETUID]` against `cap_drop: ALL`, `KILL` for tini; pinned by the test) fixes ownership then drops to `PUID:PGID` via `setpriv`, never re-owning foreign dirs. ⚠️ Append `/opt/codeman-cli` and `~/.local/bin` to `PATH`, never prepend. ⚠️ `server.Dockerfile`, the compose file and `.env.example` feed the self-updater's environment gate (`docs/docker-self-update.md`). → [architecture-invariants#docker-compose-deployment](docs/architecture-invariants.md#docker-compose-deployment), `docs/docker-compose.md` -**CLI registry** (`src/config/cli-registry/`): every run mode is a `CliEntry` (discovery, launch argv template, env handling, `capabilities`, and the `overlays` behind remote/docker pane commands). **No code outside `stock.ts` may branch on a CLI id**: use a capability field or a NAMED PROFILE (`profiles.ts`); `test/cli-registry-no-id-branching.test.ts` and `test/frontend-cli-no-id-branching.test.ts` enforce it. ⚠️ Config holds typed argv tokens, never shell text; literals are validated at LOAD time and a bad one rejects the whole entry. ⚠️ Keep `external`, `hooks` and `altScreen` independent; never derive one from another. ⚠️ Config regexes (`discovery.version.regex`, `capabilities.workDetect.workingLine`, `capabilities.workDetect.watchingLine`, `capabilities.workDetect.awaitingLine`, `capabilities.workDetect.blockedLine`, `capabilities.workDetect.composerFooterLine`, `capabilities.modelDetect.screenLine`) must compile through `compileVersionRegex()`. ⚠️ `privilegedParams[].param` names a LAUNCH PARAM, not the legacy `Config` field (bridged only by `launch.legacyConfigAliases`); a wrong name silently clamps nothing. ⚠️ Resolve the registry AT CALL TIME, never in a module-level const. ⚠️ Remote claude/omp arms of `buildRemoteLaunchCommand` are not covered by the pane-command golden. `~/.codeman/clis.json` overrides entries. It is WRITTEN only by the opt-in CLI management routes (`cliManagementEnabled`, default OFF; `/api/clis`, `cli-registry-routes.ts`), and only through `mutateRegistryFile()` in `registry-writer.ts` (the one exception: a config-backup restore of `clis.json`, which then calls `reloadCliRegistry()`), which serializes mutations and refuses (409) a file that does not parse or has group/world permission bits rather than overwriting it. Importing the registry still writes nothing. → [architecture-invariants#cli-registry](docs/architecture-invariants.md#cli-registry), `docs/cli-registry.md` +**CLI registry** (`src/config/cli-registry/`): every run mode is a `CliEntry` (discovery, launch argv template, env handling, `capabilities`, and the `overlays` behind remote/docker pane commands). **No code outside `stock.ts` may branch on a CLI id**: use a capability field or a NAMED PROFILE (`profiles.ts`); `test/cli-registry-no-id-branching.test.ts` and `test/frontend-cli-no-id-branching.test.ts` enforce it. ⚠️ Config holds typed argv tokens, never shell text; literals are validated at LOAD time and a bad one rejects the whole entry. ⚠️ Keep `external`, `hooks` and `altScreen` independent; never derive one from another. ⚠️ Config regexes (`discovery.version.regex`, `capabilities.workDetect.workingLine`, `capabilities.workDetect.watchingLine`, `capabilities.workDetect.awaitingLine`, `capabilities.workDetect.blockedLine`, `capabilities.workDetect.composerFooterLine`, `capabilities.workDetect.composerPopupLine`, `capabilities.modelDetect.screenLine`) must compile through `compileVersionRegex()`. ⚠️ `privilegedParams[].param` names a LAUNCH PARAM, not the legacy `Config` field (bridged only by `launch.legacyConfigAliases`); a wrong name silently clamps nothing. ⚠️ Resolve the registry AT CALL TIME, never in a module-level const. ⚠️ Remote claude/omp arms of `buildRemoteLaunchCommand` are not covered by the pane-command golden. `~/.codeman/clis.json` overrides entries. It is WRITTEN only by the opt-in CLI management routes (`cliManagementEnabled`, default OFF; `/api/clis`, `cli-registry-routes.ts`), and only through `mutateRegistryFile()` in `registry-writer.ts` (the one exception: a config-backup restore of `clis.json`, which then calls `reloadCliRegistry()`), which serializes mutations and refuses (409) a file that does not parse or has group/world permission bits rather than overwriting it. Importing the registry still writes nothing. → [architecture-invariants#cli-registry](docs/architecture-invariants.md#cli-registry), `docs/cli-registry.md` **External CLI modes (OpenCode, Codex, Gemini, Antigravity, Pi, Grok, DeepSeek, OMP, Copilot)**: `isExternalCliMode()` in `session.ts` gates Claude-specific behavior off (Ralph tracker, BashToolParser, token parsing, ❯ readiness; readiness is output stabilization); work detection is per-CLI `capabilities.workDetect` data, not this gate. All nine **require tmux, no direct PTY fallback** (secrets go via socket-scoped `tmux setenv`, never the command line). ⚠️ `run*()` in `session-ui.js` MUST unwrap the `{success,data}` envelope. ⚠️ **Codex uses predictive write-through echo, never the buffer overlay**: `_predictHookOnData` must never `return` (wire path stays byte-identical), and flushed text and a bracketed paste must go out as separate delayed writes. ⚠️ **Pi**: no bypass flag, never invent one; `approveProjectTrust` executes repo code, so it is in the clamp's **materialize** branch; never wire `--api-key`. ⚠️ **Grok**: `alwaysApprove` is stripped for non-granted owners (only-if-sent). ⚠️ **DeepSeek**: the agent is a PROFILE (Run gates on `isDeepSeekRunnable()`); the permission switch is the `DSH_PERMISSION_MODE` env var, so `clampEnvOverridesForOwner()` must DROP `DSH_PERMISSION_MODE`, `DSH_HOME` and `DEEPSEEK_BASE_URL` for non-granted owners; `hooksAvailableForMode()` is per-SESSION for it (pass `sessionHookOptions(session)`) and is never a stand-in for `mode === 'claude'`; answers come from `deepseek-transcript.ts`, paired by header `cwd` + boot window, never newest-mtime. ⚠️ **OMP**: `OMP_AUTH_BROKER_URL`/`_TOKEN` are clamped the same way. ⚠️ **Copilot**: `allowAll` (`--yolo`) is a `privilegedParam` and `COPILOT_HOME`/`COPILOT_ALLOW_ALL`/`COPILOT_PROVIDER_*`/`COPILOT_MODEL` are privileged env keys, and so is every other `COPILOT_*` variable the CLI reads that switches approvals, runs code with no approval row or sends its sign-in token elsewhere (the 1.0.98 list is in `stock.ts`; the prefix admits whatever a later release adds, so re-read the bundle whenever the measured version moves); the resume id must start with a letter or digit (`path-segment`), since `--resume`'s value is optional and `--resume --yolo` would otherwise make `--yolo` its own flag; `--name` is dropped next to `--resume`/`--continue` (Copilot refuses the pair); its mouse DECSETs are stripped (`strip-mux-and-mouse`) so a drag selects and the wheel is forwarded by `_shouldForwardWheelToApp` while `cliMouseTracking` is on; `composerReadyMark` is the footer hint `/ commands · ? help`, NOT `❯` (the folder-trust dialog draws `❯ 1. Yes`); `last-response` reads the pane's visible screen (`transcriptNeedsScreenCapture`) through a per-CLI `PANE_DIALECTS` row, because its stream is cursor moves, not lines (a prompt row scrolled off by a tall answer reads as the answer's tail unless a banner/trust dialog is on screen, and typed composer text is not a prompt); Docker seeds no Copilot credential (`config.json` holds a plain-text token without a keyring). → [architecture-invariants#external-cli-modes-opencode-codex-gemini-antigravity-pi-grok-deepseek-omp](docs/architecture-invariants.md#external-cli-modes-opencode-codex-gemini-antigravity-pi-grok-deepseek-omp) @@ -189,7 +189,7 @@ Codeman is a Claude Code session manager with web interface and autonomous Ralph **MCP server sync** (opt-in, `mcpSyncEnabled`, SYNCED, default OFF; `src/mcp-sync.ts`, `GET`/`POST /api/mcp-sync`): copies each installed, enabled CLI's user-level MCP servers into the others. It is the ONE subsystem that writes another CLI's REAL user config (`~/.claude.json`, `~/.codex/config.toml`, `~/.gemini/*`, opencode's), which is why it is opt-in and admin-only in multi-user mode (both verbs 403 for a non-admin, and the Settings group is hidden for them). Where each CLI keeps the file is registry data, `capabilities.mcpConfig` (`{ path, format, relocation? }`), never a branch on the id. ⚠️ Tools that sync but are not run modes (GitHub Copilot CLI, the first) are plain data in `MCP_SYNC_ONLY_TOOLS` (`src/mcp-sync-targets.ts`), take part only when installed or already configured, and their companion `settings.json` (`disabledMcpServers`) is read, never written; a test must clear their relocation vars too. ⚠️ ADDITIVE only: a name already defined, in any shape, is never edited or removed (a different same-name definition is a reported conflict), and a server switched off in its own CLI is never copied. ⚠️ Never write a file that did not parse; re-parse the NEW text and require every added server to read back before the tmp+rename (written through a symlink, previous file kept as `.codeman-bak`, one apply at a time, else 409). ⚠️ A file that receives copied `env`/`headers` (secrets) is left `0600`, and so is the backup. ⚠️ Responses carry server NAMES only, never env values, headers or file text: a parse failure is reported by line and column (`describeMcpSyncError`), never the parser's own message (smol-toml and V8 both quote source). ⚠️ `mcpConfig.relocation` names the env var the CLI reads to move its file (`CLAUDE_CONFIG_DIR`, `CODEX_HOME`, `XDG_CONFIG_HOME`, `GEMINI_CLI_HOME`, and `COPILOT_HOME` for Copilot), resolved from the SERVER env at call time; a relative value reports the target `skipped`, never a guessed write, and a per-session `envOverrides` relocation is not followed. Tests must pass `home` (which drops the `process.env` default) or clear those vars first. → `docs/cli-registry.md` (MCP server sync), `docs/api-reference.md`, `docs/wiki/Settings-Reference.md` -**Auto-named sessions** (`autoNameSessions`, SYNCED, default OFF): a placeholder tab (`w3-myapp`) takes its first real prompt as a title in the `: ` form, so the case identity and `w<n>` counter survive. Ownership is `SessionState.nameSource` (`placeholder` | `auto` | `manual`; the `name` setter / `PUT /api/sessions/:id/name` makes it `manual`, never touched again). ⚠️ `applyAutoName()` flips to `auto` even if the string is unchanged, so only the FIRST titled prompt names the tab. ⚠️ Only user input counts: `SessionWriteOptions.fromUser` is set by the browser WS path and `POST /api/sessions/:id/input` ONLY; any new user-input path must set it (and the send-key Shift+Enter path must call `trackUserInput()`). ⚠️ The pure tracker (`session-auto-name.ts`) sits on the raw keystroke stream with an explicit rule per key; add a rule for any new key class. ⚠️ `nameSource` also decides `--name`: by default only a `manual` name is pinned on the claude CLI (`Session.cliPinnedName`), since `--name` is also the `/resume` title and a pinned one stops Claude generating its own; the opt-in synced `pinCliSessionName` (default OFF, re-read at every spawn and respawn) pins every tab name, after which a Codeman rename holds only until the next turn; a rename appends a `custom-title` row to a LOCAL, non-docker transcript, and a same-name PUT is a no-op (never flips to `manual`). Tests: `test/session-auto-name.test.ts`. → [architecture-invariants#auto-named-sessions-first-prompt--tab-title](docs/architecture-invariants.md#auto-named-sessions-first-prompt--tab-title) +**Auto-named sessions** (`autoNameSessions`, SYNCED, default OFF): a placeholder tab (`w3-myapp`) takes its first real prompt as a title in the `<prefix>: <title>` form, so the case identity and `w<n>` counter survive. Ownership is `SessionState.nameSource` (`placeholder` | `auto` | `manual`; the `name` setter / `PUT /api/sessions/:id/name` makes it `manual`, never touched again). ⚠️ `applyAutoName()` flips to `auto` even if the string is unchanged, so only the FIRST titled prompt names the tab. ⚠️ Only user input counts: `SessionWriteOptions.fromUser` is set by the browser WS path, `POST /api/sessions/:id/input` and the experimental guarded stage (`stageGuardedInput()`, `POST /api/sessions/:id/stage`, typed on an agent's behalf like the input route) ONLY; any new user-input path must set it (and the send-key Shift+Enter path must call `trackUserInput()`). ⚠️ The pure tracker (`session-auto-name.ts`) sits on the raw keystroke stream with an explicit rule per key; add a rule for any new key class. ⚠️ `nameSource` also decides `--name`: by default only a `manual` name is pinned on the claude CLI (`Session.cliPinnedName`), since `--name` is also the `/resume` title and a pinned one stops Claude generating its own; the opt-in synced `pinCliSessionName` (default OFF, re-read at every spawn and respawn) pins every tab name, after which a Codeman rename holds only until the next turn; a rename appends a `custom-title` row to a LOCAL, non-docker transcript, and a same-name PUT is a no-op (never flips to `manual`). Tests: `test/session-auto-name.test.ts`. → [architecture-invariants#auto-named-sessions-first-prompt--tab-title](docs/architecture-invariants.md#auto-named-sessions-first-prompt--tab-title) **Maintainer bot (external)**: the Telegram bot that reviews open PRs and triages discussion threads in Codeman sessions used to live at `scripts/pr-bot/`. It moved OUT of this repository on 2026-09-14, to `~/codeman-cases/prbot/` (its own private git repo, systemd unit `codeman-pr-bot`, guide + agent rules in its own `README.md` and `CLAUDE.md`). It is a CLIENT of Codeman's HTTP API like any other, so nothing here depends on it and it is not part of the server, the CLI or the npm package. ⚠️ It spawns real sessions named `prbot-<n>` / `dscbot-<n>` on the local Codeman and holds clones under `~/.codeman/pr-bot/`, so those session names and that data dir are taken; it also fetches PR heads into `refs/pr-bot/*` of this checkout and must never check out, reset or clean it. The CHANGELOG entries for 1.25.0 and earlier still describe it, which is history rather than drift. diff --git a/docs/api-reference.md b/docs/api-reference.md index 2d4c61612..d99d4ef2e 100644 --- a/docs/api-reference.md +++ b/docs/api-reference.md @@ -426,6 +426,11 @@ worker's prompt but never submitted, and the wait then runs its full timeout on turn that never started. Verified live; this is the most common silent failure on this endpoint. +Any input, waiting or not, answers `409 CONFLICT` with nothing written while an +experimental guarded submit holds the session's input lease (a few milliseconds; +see [guarded input](#experimental-bounded-agent-inspection-and-guarded-input)). +Retry it. + A **plain prompt** (printable text followed by exactly one `\r`, nothing else) is delivered through tmux even without `useMux`: the text is typed, Enter is pressed as a separate key, and the server re-presses Enter while the prompt is still visibly @@ -630,8 +635,11 @@ final identity verification). The negative filter reads 1 KiB of each rollout first and 4 KiB only when that window holds no complete `originator` field. Once a rollout is attributed, later calls re-verify only that file (same file identity, same strict metadata) and rescan only the `sessions/YYYY/MM/DD` directories not -older than its own, for a second candidate such as a codex `/new`; a failed check -falls back to one full walk. So the caps bound the first call, not every call. +older than its own, for a second candidate such as a codex `/new`. A file that no +longer verifies falls back to one full walk; a verified file whose incremental scan +finds a second candidate or runs out of budget answers unattributed without one, +since the full walk reads a superset of that scan. So the caps bound the first call, +not every call. An unexpected read error is `422 OPERATION_FAILED` (logged with the session id and error code only), not a retryable `409`. Unreachable workspace/home/metadata directories are probed with a bounded past-cap probe before canonical reads and remain @@ -670,8 +678,43 @@ actual captured pane width and rendered composer indentation, refusing any message that would reach the right edge or wrap. Every declared Codex effort word plus `default` is recognized. Its two-minute receipt stores only hashes/ephemeral identities in a bounded in-memory store (1024 live receipts), -never persisted prompt content. A receipt acknowledges typing; a delayed screen -paint can still make submit refuse until exact verification becomes possible. +never persisted prompt content. + +Stage refuses, before typing anything, a message that would change what Enter +does rather than what it sends, from the registry's +`composerCommandPrefixes` and `composerCompletionSigils`. For codex: a message +starting with `/` (a slash command; codex runs it, or with its command list open +runs the highlighted one) or `!` (a shell command), checked after any leading +whitespace, and a message whose last word starts with `@` (file and mention +search) or `$` (skills; every `$` word, since whether codex opens that list +depends on its skill catalog). Those popups follow the caret, which stage leaves +at the end of the message, so a `@` or `$` word earlier in the message is +accepted. Send slash and shell commands through `POST .../input` +(`codeman agent send`). Independently, a frame with a suggestion popup in the +block directly above the composer (the registry's `composerPopupLine`) is refused +at stage and submit, since codex draws every popup there and leaves the footer +below the composer unchanged. + +A receipt is issued only for a capture that shows exactly the message with the +caret after it. Codex repaints its composer 30 to 60 ms after the keys arrive, +so after typing stage re-reads the pane every 50 ms for up to one second and +takes the first frame with the same pane generation, the input revision its own +write moved and a composer whose SHA-256 equals the message's. Each capture +reads the screen and the caret in one tmux invocation. A frame showing another +writer's input, a new pane lifecycle or a new geometry ends the wait at once. +Every refusal after the keys went out (typing failed or was ambiguous, the +pane changed, or no frame verified in time) is a `409` that says the message +may already be in the composer: inspect the pane and follow the stranded-draft +recovery (Ctrl+U) in the skill reference before staging again. + +While a guarded submit holds the input lease (the few milliseconds of its +conditional Enter), every other Codeman write path refuses, and +`POST /api/v1/sessions/:id/input` answers `409 CONFLICT` ("A guarded submit is in +progress on this session; nothing was written") instead of a `200` for bytes +that were never written. A tagged `clientId`/`seq` frame keeps its seq, so the +browser's durable queue, which keeps a frame on a `409`, delivers it on the next +retry. WebSocket keystrokes in that window are left unacknowledged and redelivered +the same way. Direct auxiliary key operations participate in the same pending-write and input lease checks. Complete allowlisted focus/mouse reports and empty writes preserve @@ -679,7 +722,10 @@ input revision; mixed or unknown sequences remain keyboard-bearing. Exact composer/caret checks also reject a mouse-moved caret. Submit consumes an owned receipt before awaited delivery, verifies the caller, -confirmation hash, expiry, lifecycle/input/geometry revisions and exact composer/caret, +confirmation hash and expiry, then waits until at least 250 ms have passed since +the staged keys went out (codex reads keys typed in one burst as a paste and, for +120 ms after one, turns Enter into a newline instead of a submit), then verifies +lifecycle/input/geometry revisions and exact composer/caret, then uses a session-level conditional Enter dispatch with a temporary input lease. Competing Codeman writes cannot enter during that dispatch. Managed restarts and intervening browser/API/auxiliary input invalidate receipts independently of pid diff --git a/docs/architecture-invariants.md b/docs/architecture-invariants.md index edd12634f..d0fd2444c 100644 --- a/docs/architecture-invariants.md +++ b/docs/architecture-invariants.md @@ -111,7 +111,7 @@ Further detail: ⚠️ An ADOPTED container may back SEVERAL cases at different ⚠️ `external`, `hooks` and `altScreen` are three INDEPENDENT capabilities on purpose; deriving one from another shipped the `until=stop`-hangs-on-shell bug. -⚠️ Several capability fields carry a REGEX from config (`discovery.version.regex`, `capabilities.workDetect.workingLine`, `watchingLine`, `awaitingLine`, `blockedLine`, `composerFooterLine` and `capabilities.modelDetect.screenLine`) and all of them must compile through `compileVersionRegex()`, which caps length and refuses nested quantifiers; `workingLine` is the one that runs on the PTY hot path. +⚠️ Several capability fields carry a REGEX from config (`discovery.version.regex`, `capabilities.workDetect.workingLine`, `watchingLine`, `awaitingLine`, `blockedLine`, `composerFooterLine`, `composerPopupLine` and `capabilities.modelDetect.screenLine`) and all of them must compile through `compileVersionRegex()`, which caps length and refuses nested quantifiers; `workingLine` is the one that runs on the PTY hot path. ⚠️ **`param` is TWO namespaces.** `launch.params` keys, `env.configSetenv[].fromParam` and `capabilities.privilegedParams[].param` all name a LAUNCH PARAM; the legacy `<Mode>Config` wire field is a separate namespace, bridged only by `launch.legacyConfigAliases`. Getting `privilegedParams[].param` wrong is SILENT — it is the multi-user bypass clamp's only handle on a CLI's privilege switch, and a wrong name clamps nothing with no load error and no failing test — so `schema.ts` rejects an entry naming a param it never declared. Codex is the entry where the two names differ (`bypassApprovals` vs `dangerouslyBypassApprovals`) and therefore the one that catches a regression. @@ -205,7 +205,7 @@ History: before the tree, every pair got a U-bridge bezier hanging below the str **Three owners, one setter.** `SessionState.nameSource` is `placeholder` | `auto` | `manual`. The constructor infers a missing value from the name (`isGeneratedSessionName()` = `w<n>-<case>` / `s<n>-<case>`, or no name at all, is a placeholder; anything else was a person's), the create routes pass none, the boot restore passes the persisted one. The `name` setter is the manual path and the ONLY thing that produces `manual` after construction; `applyAutoName()` is the only thing that produces `auto`, and it does so whether or not the string changed, which is what makes "first prompt" mean the first. A prompt whose title is null (`/clear`, `! npm test`, blank) never reaches it, so the session stays eligible: the first REAL prompt names the tab. -**The origin gate defaults to "system".** `write()` / `writeViaMux()` take `SessionWriteOptions.fromUser`; only the browser WS path and `POST /api/sessions/:id/input` set it. Every other caller (Ralph, respawn, cron, approvals, the orchestrator's `/compact`, auto-ops, the trust-dialog keys) is system by omission, so a new user-input path that forgets the flag fails toward a tab that keeps its placeholder, never toward a tab named after a Ralph prompt. `_lastSubmitAt` is still stamped for every write; only the tracker feed is gated. The shell gate is `getCli(mode)?.capabilities.startMode !== 'shell'`, a capability rather than an id check (the no-id-branching guard), and the send-key route feeds `trackUserInput()` by hand because its `tmux send-keys -H` newline (always a line feed for Ctrl+Enter; for Shift+Enter a line feed unless the CLI declares another `capabilities.newline` chord) never passes through the session. +**The origin gate defaults to "system".** `write()` / `writeViaMux()` take `SessionWriteOptions.fromUser`; only the browser WS path, `POST /api/sessions/:id/input` and the experimental guarded stage (`stageGuardedInput()`, behind `POST /api/sessions/:id/stage`) set it. Every other caller (Ralph, respawn, cron, approvals, the orchestrator's `/compact`, auto-ops, the trust-dialog keys) is system by omission, so a new user-input path that forgets the flag fails toward a tab that keeps its placeholder, never toward a tab named after a Ralph prompt. `_lastSubmitAt` is still stamped for every write; only the tracker feed is gated. The shell gate is `getCli(mode)?.capabilities.startMode !== 'shell'`, a capability rather than an id check (the no-id-branching guard), and the send-key route feeds `trackUserInput()` by hand because its `tmux send-keys -H` newline (always a line feed for Ctrl+Enter; for Shift+Enter a line feed unless the CLI declares another `capabilities.newline` chord) never passes through the session. **The tracker is a best-effort transcript with explicit per-key rules**, not a byte filter. Mirrored: printable text, backspace, Ctrl+W, Ctrl+U/Ctrl+C (composer emptied), `\n` and Alt+Enter (a newline IN the composer, joined with a space), bracketed paste (newlines inside it likewise). Ignored: cursor keys, Home/End/Delete, Shift+Tab, Tab, SGR mouse and focus reports, Alt chords, OSC/DCS, the rest of C0. Tainting: Up/Down (CSI and SS3), Ctrl+P/N/R, Ctrl+_, because the composer then holds a history line the tracker never saw and Enter must submit nothing rather than a fragment. A bare Esc is resolved at the END of the chunk it arrives in, since xterm hands each key's whole sequence to one write and the programmatic senders send Esc alone; a CSI split across chunks still resumes. The draft keeps its HEAD past 8192 code points (the title is the first sentence, so keeping the tail would title a long paste by its last line) and an escape sequence is abandoned past 64 bytes. @@ -1290,8 +1290,11 @@ metadata are remembered per session (`attributionMemo`, bounded LRU). A later ca re-verifies only that file's head, then scans just the date directories not older than its own (`sessions/YYYY/MM/DD`, compared numerically; any non-numeric name is never pruned) for a second candidate, which is how a codex `/new` or a new owned -rollout beside a configured resume is still caught. A failed check drops the memo -and falls back to one full walk. Non-`InspectionConflict` errors are `422 +rollout beside a configured resume is still caught. A head that no longer verifies +drops the memo and falls back to one full walk; a verified head whose incremental +scan fails (a second candidate, an exhausted budget, an unreadable entry) drops it +and answers unattributed without one, because the full walk reads a superset of that +scan under the same budget and could only fail the same way. Non-`InspectionConflict` errors are `422 OPERATION_FAILED` (never a retryable `409`) and are logged with the session id and error code only, never the path. @@ -1301,8 +1304,25 @@ footer are registry data (`capabilities.workDetect.promptGlyph` and declaring `composerFooterLine` takes guarded input (codex today). Codex's footer words come from the same `CODEX_EFFORT_WORDS` as its `modelDetect.screenLine`. Before any write, available columns are calculated from actual captured geometry and the rendered prefix, -including indentation; reaching the last column or wrapping is refused. Stage -cancels older verifier retries and sends no Enter. Receipts contain hashes and +including indentation; reaching the last column or wrapping is refused. A message +starting with a `composerCommandPrefixes` character or whose last word starts with a +`composerCompletionSigils` one is refused before typing (codex `/!` and `@$`), and a +frame with a `composerPopupLine` row in the block just above the composer is refused at +stage and submit: codex draws its popups there and keeps its footer, so the footer +alone read `› /stat` with the command list open as idle, and Enter ran `/status`. +⚠️ After typing, stage polls (`STAGE_SETTLE_TIMEOUT_MS` 1000 / `STAGE_SETTLE_INTERVAL_MS` +50) for the first frame with the same generation, `revision === before + 1` and +`composerSha === promptSha256(message)`, since codex repaints 30 to 60 ms after the keys +and the first capture is often from before them; another writer or a lifecycle change +ends the wait at once, and every refusal after typing says the message may be in the +composer. `capturePaneBuffer()` reads the visible frame and its caret in ONE tmux call +(`capture-pane \; display-message`, split by `splitFrameAndCursor()`), falling back to +the two calls on any doubt; keep the poll anyway. While a guarded submit holds the +lease, `POST .../input` answers `409` (seq returned) instead of a `200` for unwritten +bytes. ⚠️ Submit waits `SUBMIT_QUIET_MS` (250) after the staged keys before its +checks: stage types plain keys, codex's paste-burst detector reads them as a paste, and +an Enter within 120 ms of one becomes a newline while submit would report it +delivered. Stage cancels older verifier retries and sends no Enter. Receipts contain hashes and bounded ephemeral identities only, expire, and are consumed before awaited submission, including failures. diff --git a/docs/cli-registry.md b/docs/cli-registry.md index 955b6f888..a2a029e83 100644 --- a/docs/cli-registry.md +++ b/docs/cli-registry.md @@ -47,10 +47,12 @@ interface CliEntry { env: CliEnv; // exports, tmux setenv keys, the env-override allowlist capabilities: CliCapabilities; // what every call site reads instead of the id // .workDetect?: { promptGlyph, workingLine, watchingLine?, watchingLines?, awaitingLine?, - // blockedLine?, blockedLines?, composerFooterLine? } + // blockedLine?, blockedLines?, composerFooterLine?, composerPopupLine?, + // composerCommandPrefixes?, composerCompletionSigils? } // (how this CLI's pane shows work, work it started in the background, a turn // that ended waiting for workers it will resume from, a dialog it is blocked on, - // and the rows under its idle composer that guarded input relies on) + // the rows around its idle composer that guarded input relies on, and what a + // draft can trigger) // .modelDetect?: { screenLine, screenLines? } // (where this CLI's own chrome names the model it runs: SessionState.displayModel) // .launchDefaults?: { [launchParam]: settingsKey } @@ -64,7 +66,7 @@ interface CliEntry { ### Regexes that come from config -Seven capability fields carry a regular expression an override file can set: `discovery.version.regex`, `capabilities.workDetect.workingLine`, `capabilities.workDetect.watchingLine`, `capabilities.workDetect.awaitingLine`, `capabilities.workDetect.blockedLine`, `capabilities.workDetect.composerFooterLine` and `capabilities.modelDetect.screenLine`. All seven go through `compileVersionRegex()`, which caps the source at 200 characters, refuses the nested-quantifier shapes that cause catastrophic backtracking, and returns `null` rather than throwing so every caller degrades instead of crashing. +Eight capability fields carry a regular expression an override file can set: `discovery.version.regex`, `capabilities.workDetect.workingLine`, `capabilities.workDetect.watchingLine`, `capabilities.workDetect.awaitingLine`, `capabilities.workDetect.blockedLine`, `capabilities.workDetect.composerFooterLine`, `capabilities.workDetect.composerPopupLine` and `capabilities.modelDetect.screenLine`. All eight go through `compileVersionRegex()`, which caps the source at 200 characters, refuses the nested-quantifier shapes that cause catastrophic backtracking, and returns `null` rather than throwing so every caller degrades instead of crashing. `workingLine` is the one that matters most, because it is compiled once per session and then run against every accumulated PTY chunk and every pane capture. A nested quantifier there is a ReDoS against the event loop for the whole server, not just that session. The guard therefore runs in two places, and neither is redundant: `schema.ts` rejects the entry at LOAD time so a bad pattern never reaches a session, and `_workingLinePattern()` in `session.ts` compiles through the same helper so the runtime cannot end up with a pattern the schema would have refused. @@ -137,6 +139,21 @@ effort-word list as its `modelDetect.screenLine` (`CODEX_EFFORT_WORDS` in `stock codex footer change is a one-place edit. Like `blockedLine`, the rows it sees are near the foot of the screen, so a pattern must anchor on chrome only that CLI draws there. +Three more fields cover what a draft can do to Enter. Codex draws every suggestion popup +(its slash-command list, file search, skill and mention lists) ABOVE the composer and +leaves the footer below it alone, so the footer check reads `› /stat` with the command +list open as an idle draft, and Enter would run the highlighted `/status`. +`composerPopupLine` matches a popup row; it is tested against the contiguous block of +non-blank rows directly above the composer (past at most three blank rows, at most twelve +rows), never further up, and any match refuses the frame. `composerCommandPrefixes` lists +the characters that, first in a draft, make Enter run a command (codex: `/!`), and +`composerCompletionSigils` the ones that, starting the word under the caret, open a popup +(codex: `@$`); stage refuses such a message before typing, the sigils only on the last +word since the caret ends there. Both are 1 to 8 ASCII punctuation characters. Codex's +values were read from `codex-rs/tui/src/bottom_pane` at `rust-v0.162.0`; a file-search +popup with results shows bare path rows no pattern can tell from prose, which is why the +trigger refusal is there as well as the frame check. + ### Dialogs a CLI blocks on (`blockedLine`) `blockedLine` is the screen-read source of the `blocked` state for a CLI with no hook to diff --git a/docs/wiki/Driving-Codeman-From-An-Agent.md b/docs/wiki/Driving-Codeman-From-An-Agent.md index 18d21b5d7..84c57644c 100644 --- a/docs/wiki/Driving-Codeman-From-An-Agent.md +++ b/docs/wiki/Driving-Codeman-From-An-Agent.md @@ -75,9 +75,11 @@ codeman agent rm "$SID" # any session except t [Settings Reference](Settings-Reference); `agentCoordinationEnabled`, synced, off by default), and they may change in any release. `inspect` returns bounded evidence with an opaque cursor. `stage` types one printable UTF8 line without trailing whitespace into a - local Codex composer, with no Enter, and returns a receipt that expires after two minutes; - `submit` sends one Enter only if the composer still shows exactly that text, and never - replays it. `send --no-enter` alone has no receipt. Details: + local Codex composer, with no Enter, and returns a receipt that expires after two minutes + once the pane shows exactly that text; it refuses slash (`/`) and shell (`!`) commands + and a message whose last word starts with `@` or `$` (those open a Codex popup that Enter + would accept), so send commands with `send`. `submit` sends one Enter only if the + composer still shows exactly that text, and never replays it. `send --no-enter` alone has no receipt. Details: [API reference](https://github.com/Ark0N/Codeman/blob/master/docs/api-reference.md#experimental-bounded-agent-inspection-and-guarded-input). - **Exit codes** are the same for every verb: `0` done, `1` error, `2` timeout, `3` the worker exited, `4` refused. `--json` prints the response's `data`. diff --git a/plugins/codeman/skills/codeman/reference/inspection-and-staging.md b/plugins/codeman/skills/codeman/reference/inspection-and-staging.md index c53530aa7..960a1f13f 100644 --- a/plugins/codeman/skills/codeman/reference/inspection-and-staging.md +++ b/plugins/codeman/skills/codeman/reference/inspection-and-staging.md @@ -78,15 +78,32 @@ wrapped, remote/container and shell composers are refused. A customized footer o unsupported character-width rendering can also make verification unavailable. Self-targets are refused by the CLI. +Stage also refuses, before typing, a message that would change what Enter does: +one starting with `/` (a Codex slash command; with its command list open, Enter +would run the highlighted command instead) or `!` (a shell command), and one whose +**last** word starts with `@` (file/mention search) or `$` (skills). Those popups +follow the caret, which stage leaves at the end, so `look at @src/app.ts please` +is fine. Send slash and shell commands with `codeman agent send`, and end a +message with an ordinary word when it mentions a file. A frame showing a +suggestion popup above the composer is refused at stage and submit too. + The receipt stores a hash and ephemeral identities, never prompt content. It -expires after two minutes; capacity is 1024 live receipts. Stage delivery can -precede the next screen paint: a receipt acknowledges typing, while submit still -requires the fully painted exact composer. If typing fails or is ambiguous, stage -issues no receipt; inspect the pane before trying again. +expires after two minutes; capacity is 1024 live receipts. After typing, stage +re-reads the pane (every 50 ms, up to one second, since Codex repaints a little +after the keys) and issues a receipt only for a frame that shows exactly the +message with the caret after it. Any refusal after the keys went out (typing +failed or was ambiguous, the pane or input changed, or no frame verified in time) +says the message may already be in the composer: no receipt was issued. Do not +stage again on top of it; follow [Recover a stranded draft](#recover-a-stranded-draft) below first. + +While a submit's Enter is in flight (milliseconds), `send` and other input to that +session answer `409 CONFLICT` with nothing written; retry the input. Submit checks the caller, session, hash, expiry, pane lifecycle, input revision, geometry, exact composer and caret, then sends one conditional Enter at the session's input -boundary. Competing Codeman writes are refused during dispatch. Browser/API/key +boundary. It first waits until 250 ms have passed since stage typed (Codex turns an +Enter that follows typed text within 120 ms into a newline), so submitting right +after stage is fine. Competing Codeman writes are refused during dispatch. Browser/API/key input and managed restarts invalidate receipts even when the visible text or pid is restored. Direct tmux edits are outside that input serialization; captures remain evidence at the time observed, rather than terminal isolation. @@ -98,10 +115,10 @@ Enter was delivered; use inspection or a suitable wait signal to observe native acceptance. A `409` or broken connection means inspect first. Do not automatically stage/submit again or send an unguarded Enter to recover. -## Recover after a refused submit +## Recover a stranded draft -A consumed receipt cannot be reused. First observe whether the prompt was -accepted: use transcript evidence/wait signals and a fresh terminal capture +A consumed receipt cannot be reused, and a stage refused after typing issued +none. First observe whether the prompt is still in the composer or was accepted: use transcript evidence/wait signals and a fresh terminal capture (`GET /api/v1/sessions/:id/terminal?tail=8192`). If the target is working, has accepted the prompt, shows a dialog, or delivery is ambiguous, keep observing; do not clear or resend anything. A transcript prompt row alone is not proof diff --git a/plugins/codeman/skills/codeman/reference/verbs.md b/plugins/codeman/skills/codeman/reference/verbs.md index 85a7b9817..3982b3058 100644 --- a/plugins/codeman/skills/codeman/reference/verbs.md +++ b/plugins/codeman/skills/codeman/reference/verbs.md @@ -551,8 +551,8 @@ that half is the CLI's behavior, not something this API guarantees. - **This is not the composer-clearing tool.** Esc (and Ctrl+U) do **not** clear a typed-but-unsubmitted Claude prompt, verified live. Do not submit an unverified - draft merely to clear it. For a refused guarded local Codex submit, use the - verified-unsent recovery in [inspection and staging](inspection-and-staging.md#recover-after-a-refused-submit); + draft merely to clear it. For a refused guarded local Codex stage or submit, use the + verified-unsent recovery in [inspection and staging](inspection-and-staging.md#recover-a-stranded-draft); when the composer is unclear, ask the user to clear it in the terminal. - The interrupted turn already burned its tokens. Interrupting early saves the rest. - `POST /api/sessions/:id/send-key` is a different endpoint and cannot do this: its diff --git a/skills/codeman/reference/inspection-and-staging.md b/skills/codeman/reference/inspection-and-staging.md index c53530aa7..960a1f13f 100644 --- a/skills/codeman/reference/inspection-and-staging.md +++ b/skills/codeman/reference/inspection-and-staging.md @@ -78,15 +78,32 @@ wrapped, remote/container and shell composers are refused. A customized footer o unsupported character-width rendering can also make verification unavailable. Self-targets are refused by the CLI. +Stage also refuses, before typing, a message that would change what Enter does: +one starting with `/` (a Codex slash command; with its command list open, Enter +would run the highlighted command instead) or `!` (a shell command), and one whose +**last** word starts with `@` (file/mention search) or `$` (skills). Those popups +follow the caret, which stage leaves at the end, so `look at @src/app.ts please` +is fine. Send slash and shell commands with `codeman agent send`, and end a +message with an ordinary word when it mentions a file. A frame showing a +suggestion popup above the composer is refused at stage and submit too. + The receipt stores a hash and ephemeral identities, never prompt content. It -expires after two minutes; capacity is 1024 live receipts. Stage delivery can -precede the next screen paint: a receipt acknowledges typing, while submit still -requires the fully painted exact composer. If typing fails or is ambiguous, stage -issues no receipt; inspect the pane before trying again. +expires after two minutes; capacity is 1024 live receipts. After typing, stage +re-reads the pane (every 50 ms, up to one second, since Codex repaints a little +after the keys) and issues a receipt only for a frame that shows exactly the +message with the caret after it. Any refusal after the keys went out (typing +failed or was ambiguous, the pane or input changed, or no frame verified in time) +says the message may already be in the composer: no receipt was issued. Do not +stage again on top of it; follow [Recover a stranded draft](#recover-a-stranded-draft) below first. + +While a submit's Enter is in flight (milliseconds), `send` and other input to that +session answer `409 CONFLICT` with nothing written; retry the input. Submit checks the caller, session, hash, expiry, pane lifecycle, input revision, geometry, exact composer and caret, then sends one conditional Enter at the session's input -boundary. Competing Codeman writes are refused during dispatch. Browser/API/key +boundary. It first waits until 250 ms have passed since stage typed (Codex turns an +Enter that follows typed text within 120 ms into a newline), so submitting right +after stage is fine. Competing Codeman writes are refused during dispatch. Browser/API/key input and managed restarts invalidate receipts even when the visible text or pid is restored. Direct tmux edits are outside that input serialization; captures remain evidence at the time observed, rather than terminal isolation. @@ -98,10 +115,10 @@ Enter was delivered; use inspection or a suitable wait signal to observe native acceptance. A `409` or broken connection means inspect first. Do not automatically stage/submit again or send an unguarded Enter to recover. -## Recover after a refused submit +## Recover a stranded draft -A consumed receipt cannot be reused. First observe whether the prompt was -accepted: use transcript evidence/wait signals and a fresh terminal capture +A consumed receipt cannot be reused, and a stage refused after typing issued +none. First observe whether the prompt is still in the composer or was accepted: use transcript evidence/wait signals and a fresh terminal capture (`GET /api/v1/sessions/:id/terminal?tail=8192`). If the target is working, has accepted the prompt, shows a dialog, or delivery is ambiguous, keep observing; do not clear or resend anything. A transcript prompt row alone is not proof diff --git a/skills/codeman/reference/verbs.md b/skills/codeman/reference/verbs.md index 85a7b9817..3982b3058 100644 --- a/skills/codeman/reference/verbs.md +++ b/skills/codeman/reference/verbs.md @@ -551,8 +551,8 @@ that half is the CLI's behavior, not something this API guarantees. - **This is not the composer-clearing tool.** Esc (and Ctrl+U) do **not** clear a typed-but-unsubmitted Claude prompt, verified live. Do not submit an unverified - draft merely to clear it. For a refused guarded local Codex submit, use the - verified-unsent recovery in [inspection and staging](inspection-and-staging.md#recover-after-a-refused-submit); + draft merely to clear it. For a refused guarded local Codex stage or submit, use the + verified-unsent recovery in [inspection and staging](inspection-and-staging.md#recover-a-stranded-draft); when the composer is unclear, ask the user to clear it in the terminal. - The interrupted turn already burned its tokens. Interrupting early saves the rest. - `POST /api/sessions/:id/send-key` is a different endpoint and cannot do this: its diff --git a/src/agent-codex-identity.ts b/src/agent-codex-identity.ts index 490f7267a..1ff2f280e 100644 --- a/src/agent-codex-identity.ts +++ b/src/agent-codex-identity.ts @@ -77,8 +77,10 @@ function strictMetadata(bytes: Buffer): Metadata | undefined { * this file's head (same `dev:ino`, same strict metadata) and then scans just the * directories not older than its own for a second candidate (codex `/new`, or a new * owned rollout beside a configured resume), instead of re-reading the whole store, - * which codex never prunes. Any failed check drops the entry and falls back to the - * full walk. Bounded: one entry per live session is the expected size. + * which codex never prunes. A head that no longer verifies drops the entry and falls + * back to the full walk; a failed incremental scan drops it and answers unattributed, + * since the full walk reads a superset of that scan. Bounded: one entry per live + * session is the expected size. */ interface AttributionMemo { id: string; @@ -133,8 +135,11 @@ export async function findExactCodexConversation( } } attributionMemo.delete(key); - // A second candidate is a conflict a full walk would report too; anything - // else (a replaced file, an exhausted budget) gets one full walk now. + // A file that no longer verifies (replaced, rewritten, gone) gets one full walk + // now. A verified file whose incremental scan failed does not: that scan reads a + // subset of what a full walk reads under the same budget, so a second candidate, + // an exhausted budget or an unreadable entry it hit is one the full walk would hit + // too, and walking the whole never-pruned store could only end the same way. if (verified) return undefined; } const candidates = await scanCandidates(sessions, options, budget); diff --git a/src/agent-staging.ts b/src/agent-staging.ts index 847951704..f79e12349 100644 --- a/src/agent-staging.ts +++ b/src/agent-staging.ts @@ -10,14 +10,21 @@ import { compileVersionRegex } from './config/cli-registry/patterns.js'; import type { CliEntry } from './config/cli-registry/types.js'; /** - * The screen chrome a guarded composer read relies on, both from the CLI registry - * (`capabilities.workDetect`): the composer glyph and the pattern every row below an - * idle composer must match. Nothing here names a CLI; a CLI takes part in guarded - * input by declaring `composerFooterLine`. + * The screen chrome a guarded composer read relies on, all from the CLI registry + * (`capabilities.workDetect`): the composer glyph, the pattern every row below an + * idle composer must match, and what opens a popup or turns Enter into a command. + * Nothing here names a CLI; a CLI takes part in guarded input by declaring + * `composerFooterLine`. */ export interface GuardedComposerChrome { promptGlyph: string; footerLine: RegExp; + /** A row of a suggestion popup drawn directly above the composer (`composerPopupLine`). */ + popupLine?: RegExp; + /** First characters of a draft that make Enter run a command (`composerCommandPrefixes`). */ + commandPrefixes?: string; + /** First characters of the word at the caret that open a completion popup (`composerCompletionSigils`). */ + completionSigils?: string; } /** The chrome a registry entry declares, or null (no guarded input for that CLI). */ export function guardedComposerChrome(entry: CliEntry | undefined): GuardedComposerChrome | null { @@ -25,8 +32,42 @@ export function guardedComposerChrome(entry: CliEntry | undefined): GuardedCompo if (!detect?.promptGlyph || !detect.composerFooterLine) return null; // Config-supplied (~/.codeman/clis.json): compiled through the ReDoS guard, null on refusal. const footerLine = compileVersionRegex(detect.composerFooterLine); - return footerLine ? { promptGlyph: detect.promptGlyph, footerLine } : null; + if (!footerLine) return null; + // A declared popup pattern the guard refuses is no chrome at all: dropping it would + // silently read a popup frame as an idle composer. + const popupLine = detect.composerPopupLine ? compileVersionRegex(detect.composerPopupLine) : undefined; + if (popupLine === null) return null; + return { + promptGlyph: detect.promptGlyph, + footerLine, + ...(popupLine ? { popupLine } : {}), + ...(detect.composerCommandPrefixes ? { commandPrefixes: detect.composerCommandPrefixes } : {}), + ...(detect.composerCompletionSigils ? { completionSigils: detect.composerCompletionSigils } : {}), + }; } +/** + * Why a message cannot be staged into this composer, or null. Typing it would change + * what Enter does rather than what it sends: a draft starting with a command prefix + * (codex: `/` for a slash command, `!` for a shell command) is run, not sent as a + * prompt, and a last word starting with a completion sigil (codex: `@` file and + * mention search, `$` skills) opens a popup at the caret whose highlighted entry Enter + * would accept. Checked before anything is typed. Only the LAST word matters for the + * sigils because the caret is at the end of the message and the popup follows the + * caret; a sigil word earlier in the message opens nothing. + */ +export function composerTriggerRefusal(message: string, chrome: GuardedComposerChrome): string | null { + const first = message.trimStart().charAt(0); + if (first && chrome.commandPrefixes?.includes(first)) + return `Stage refuses a message starting with "${first}": the CLI runs it as a command instead of sending it as a prompt. Send commands with \`codeman agent send\` (POST /api/v1/sessions/:id/input) instead`; + const last = message.split(/\s/).at(-1)?.charAt(0); + if (last && chrome.completionSigils?.includes(last)) + return `Stage refuses a message whose last word starts with "${last}": it opens a completion popup at the caret and Enter would accept the highlighted entry instead of sending the message. End the message with another word, or use \`codeman agent send\``; + return null; +} +/** How many rows above the composer a popup check reads: the popup cap (8) plus header and hint rows. */ +const POPUP_WINDOW_ROWS = 12; +/** Blank rows a popup can leave between itself and the composer row (the composer's own top padding, plus a popup's). */ +const POPUP_GAP_ROWS = 3; const escapeRegex = (text: string) => text.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); const composerRow = (chrome: GuardedComposerChrome) => new RegExp(String.raw`^\s*${escapeRegex(chrome.promptGlyph)}(?:\s|$)`); @@ -41,6 +82,8 @@ export interface GuardedInputSnapshot { } export interface GuardedInputTarget { id: string; + /** The session's composer chrome, read before typing for what a message would trigger. */ + chrome(): GuardedComposerChrome | null; snapshot(): GuardedInputSnapshot | null; write(text: string): Promise<boolean>; submit(expected: GuardedInputSnapshot): Promise<boolean>; @@ -50,9 +93,46 @@ interface Receipt extends GuardedInputSnapshot { sessionId: string; expiresAt: number; sha256: string; + /** When the staged keys went out, for `SUBMIT_QUIET_MS`. */ + typedAt: number; } export class GuardedInputConflict extends Error {} +/** + * How long stage keeps reading the pane after typing for a frame that shows the + * message, and how often. A capture taken right after the keys land is often a + * frame from before them: codex repaints its composer 30 to 60 ms after input + * arrives (measured on a live 0.162.0 pane), so the first read can show the old, + * empty composer, or nothing provable at all. A second of 50 ms reads covers that + * many times over and still answers the caller quickly. + */ +export const STAGE_SETTLE_TIMEOUT_MS = 1000; +export const STAGE_SETTLE_INTERVAL_MS = 50; +/** + * The least time between the staged keys and the conditional Enter. Stage types its + * text as plain keys (`send-keys -l`), which codex's paste-burst detector reads as a + * paste, and for 120 ms after such a burst codex turns Enter into a NEWLINE rather + * than a submit (`PASTE_ENTER_SUPPRESS_WINDOW`, codex-rs/tui/src/bottom_pane/ + * paste_burst.rs at rust-v0.162.0). A caller that submits the moment stage answers + * would land inside it and be told one Enter was delivered while codex only broke + * the line. Submit waits out the rest of this window first; a keystroke in that + * time still invalidates the receipt. + */ +export const SUBMIT_QUIET_MS = 250; +/** Settle-window overrides for tests: a fake clock comes from the store's `now`. */ +export interface StageSettleOptions { + timeoutMs?: number; + intervalMs?: number; + sleep?: (ms: number) => Promise<void>; +} +/** + * Every refusal after the keys went out says so: stage cannot tell whether the text + * landed, so the caller must look before staging again (the recovery steps clear a + * stranded draft with Ctrl+U). + */ +export const MAY_BE_TYPED = + 'no receipt was issued. The message may already be in the composer: inspect the pane and follow the stranded-draft recovery steps (Ctrl+U) before staging again'; + /** * Conservatively read a composer: exactly one visible, unwrapped row and a known * footer, both as the registry describes them. Never remove internal spaces, infer @@ -69,6 +149,7 @@ export function readGuardedComposer(screen: string, chrome: GuardedComposerChrom if (!last) return null; const below = lines.slice(last.index + 1).filter((line) => line.trim()); if (below.length === 0 || below.length > 3 || below.some((line) => !chrome.footerLine.test(line))) return null; + if (chrome.popupLine && popupAbove(lines, last.index, chrome.popupLine)) return null; // Preserve exact printable content; only SGR-dim placeholder/suggestion text is excluded. const raw = screen.split('\n')[last.index]; let dim = false; @@ -94,6 +175,23 @@ export function readGuardedComposer(screen: string, chrome: GuardedComposerChrom .trimEnd(); } +/** + * Does a suggestion popup sit directly above the composer row? A popup draws over + * the rows above the composer and leaves the footer below it alone, so the footer + * check alone reads a popup frame as an idle composer. Read the contiguous block of + * rows just above (past at most `POPUP_GAP_ROWS` blank rows), never the whole pane: + * the transcript further up is the agent's own and may print anything. A transcript + * row that happens to match costs a refusal, never a receipt. + */ +function popupAbove(lines: string[], composerIndex: number, popupLine: RegExp): boolean { + let index = composerIndex - 1; + for (let gap = 0; gap < POPUP_GAP_ROWS && index >= 0 && !lines[index].trim(); gap++) index--; + for (let seen = 0; seen < POPUP_WINDOW_ROWS && index >= 0 && lines[index].trim(); seen++, index--) { + if (popupLine.test(lines[index])) return true; + } + return false; +} + /** Require the capture's caret at the exact end of the unwrapped composer. */ export function readGuardedSnapshot(snapshot: string, chrome: GuardedComposerChrome): string | null { return readGuardedSnapshotDetails(snapshot, chrome)?.composer ?? null; @@ -134,11 +232,19 @@ export function readGuardedSnapshotDetails( export class GuardedStageStore { private readonly receipts = new Map<string, Receipt>(); private readonly busy = new Set<string>(); + private readonly settleTimeoutMs: number; + private readonly settleIntervalMs: number; + private readonly sleep: (ms: number) => Promise<void>; constructor( private readonly now = Date.now, private readonly capacity = 1024, - private readonly ttlMs = 120000 - ) {} + private readonly ttlMs = 120000, + settle: StageSettleOptions = {} + ) { + this.settleTimeoutMs = settle.timeoutMs ?? STAGE_SETTLE_TIMEOUT_MS; + this.settleIntervalMs = settle.intervalMs ?? STAGE_SETTLE_INTERVAL_MS; + this.sleep = settle.sleep ?? ((ms) => new Promise((resolve) => setTimeout(resolve, ms))); + } async stage(target: GuardedInputTarget, owner: string, message: string) { // eslint-disable-next-line no-control-regex const hasControls = /[\x00-\x1f\x7f-\x9f]/.test(message); @@ -152,6 +258,9 @@ export class GuardedStageStore { throw new GuardedInputConflict( 'Stage requires printable single-line input within 8192 UTF8 bytes, without trailing whitespace' ); + const chrome = target.chrome(); + const refusal = chrome && composerTriggerRefusal(message, chrome); + if (refusal) throw new GuardedInputConflict(refusal); const key = target.id; if (this.busy.has(key)) throw new GuardedInputConflict('Another guarded input operation is in progress'); this.busy.add(key); @@ -169,24 +278,48 @@ export class GuardedStageStore { if (this.receipts.size + this.busy.size > this.capacity) throw new GuardedInputConflict('Guarded stage receipt capacity reached'); if (!(await target.write(message))) - throw new GuardedInputConflict('Staging delivery failed; inspect the pane before trying again'); - const after = target.snapshot(); - if ( - !after || - after.generation !== before.generation || - after.revision !== before.revision + 1 || - after.availableColumns !== before.availableColumns - ) - throw new GuardedInputConflict('Input or pane changed while staging; no receipt was issued'); + throw new GuardedInputConflict(`Staging delivery failed or ambiguous; ${MAY_BE_TYPED}`); + const typedAt = this.now(); + const after = await this.settle(target, before, promptSha256(message)); const stageId = randomUUID(); const expiresAt = this.now() + this.ttlMs; const sha256 = promptSha256(message); - this.receipts.set(stageId, { ...after, owner, sessionId: target.id, expiresAt, sha256 }); + this.receipts.set(stageId, { ...after, owner, sessionId: target.id, expiresAt, sha256, typedAt }); return { stageId, sha256, expiresAt, sessionId: target.id, submitted: false as const }; } finally { this.busy.delete(key); } } + /** + * The first frame after the write that shows exactly this message, with the input + * revision the write itself moved and nothing else changed. A null or stale frame + * (the old composer, a half-painted one) is read again until the settle window + * ends; a frame showing another writer's keystroke, a new pane lifecycle or a new + * geometry is refused at once, since waiting cannot undo that. + */ + private async settle( + target: GuardedInputTarget, + before: GuardedInputSnapshot, + expectedSha: string + ): Promise<GuardedInputSnapshot> { + const deadline = this.now() + this.settleTimeoutMs; + for (;;) { + const frame = target.snapshot(); + if ( + frame && + (frame.generation !== before.generation || + frame.revision !== before.revision + 1 || + frame.availableColumns !== before.availableColumns) + ) + throw new GuardedInputConflict(`Input or pane changed while staging; ${MAY_BE_TYPED}`); + if (frame?.composerSha === expectedSha) return frame; + if (this.now() >= deadline) + throw new GuardedInputConflict( + `The composer did not show the staged message within ${this.settleTimeoutMs} ms; ${MAY_BE_TYPED}` + ); + await this.sleep(this.settleIntervalMs); + } + } async submit(target: GuardedInputTarget, owner: string, stageId: string, confirmSha: string) { const receipt = this.receipts.get(stageId); if (!receipt || receipt.owner !== owner || receipt.sessionId !== target.id) @@ -198,6 +331,8 @@ export class GuardedStageStore { try { if (receipt.expiresAt <= this.now() || receipt.sha256 !== confirmSha) throw new GuardedInputConflict('Expired stage receipt or confirmation hash mismatch'); + const quiet = receipt.typedAt + SUBMIT_QUIET_MS - this.now(); + if (quiet > 0) await this.sleep(quiet); const current = target.snapshot(); if ( !current || diff --git a/src/cli-agent.ts b/src/cli-agent.ts index b8e09d51e..082c3ee89 100644 --- a/src/cli-agent.ts +++ b/src/cli-agent.ts @@ -1293,7 +1293,7 @@ export function registerAgentCommands(program: Command): Command { agent .command('stage <id>') .description( - 'Experimental, opt-in (agentCoordinationEnabled): type a printable single-line UTF8 file without Enter; requires a verifiable local Codex composer' + 'Experimental, opt-in (agentCoordinationEnabled): type a printable single-line UTF8 file without Enter; requires a verifiable local Codex composer, and refuses slash or shell commands and a last word starting with @ or $ (use send)' ) .requiredOption('--message-file <path>', 'Regular UTF8 file, at most 8192 bytes, no trailing newline') .option('--json', 'Machine-readable output') diff --git a/src/config/cli-registry/schema.ts b/src/config/cli-registry/schema.ts index 3876310b8..8c6486f59 100644 --- a/src/config/cli-registry/schema.ts +++ b/src/config/cli-registry/schema.ts @@ -394,6 +394,25 @@ const capabilitiesSchema = z 'composerFooterLine must be a regex compileVersionRegex() accepts: at most 200 characters, no nested quantifiers' ) .optional(), + // Same guard again: run over the rows just above the composer by the same reads. + composerPopupLine: z + .string() + .min(1) + .refine( + (src) => compileVersionRegex(src) !== null, + 'composerPopupLine must be a regex compileVersionRegex() accepts: at most 200 characters, no nested quantifiers' + ) + .optional(), + // Single ASCII punctuation characters, never letters, digits or whitespace: each one + // refuses every message that starts (or whose last word starts) with it. + composerCommandPrefixes: z + .string() + .regex(/^[!-/:-@[-`{-~]{1,8}$/, 'composerCommandPrefixes must be 1 to 8 ASCII punctuation characters') + .optional(), + composerCompletionSigils: z + .string() + .regex(/^[!-/:-@[-`{-~]{1,8}$/, 'composerCompletionSigils must be 1 to 8 ASCII punctuation characters') + .optional(), }) .strict() // A window with nothing to search is a typo, not a configuration. Refused at LOAD diff --git a/src/config/cli-registry/stock.ts b/src/config/cli-registry/stock.ts index 59bf49512..187d73591 100644 --- a/src/config/cli-registry/stock.ts +++ b/src/config/cli-registry/stock.ts @@ -748,6 +748,28 @@ const CODEX: CliEntry = { // `? for shortcuts` / `← for agents` hint row 0.162.0 adds at rest, and the older // `NN% context left` footer. Indented, so the `›` composer row itself never matches. composerFooterLine: String.raw`^\s+(?:\d+% context left|\? for shortcuts|← for agents|\S+ (?:${CODEX_EFFORT_WORDS}) · )`, + // Read from codex-rs/tui/src/bottom_pane at rust-v0.162.0 (chat_composer/ + // popup_state.rs, composer_layout.rs and the popups' render code and snapshots): + // every suggestion popup renders ABOVE the composer and keeps the footer below it, + // so the footer alone cannot tell `› /stat` with the command list open from an + // idle draft, and Enter then runs the highlighted `/status`. What sits in the + // block just above the composer (past its blank top padding row): + // command list (`/`) `› /status show current session ...` + // ` /statusline configure which items ...` + // skill and mention lists ` enter insert · esc close` (key-hint row) + // (`$`, `@` with mentions on) ` enter/tab insert · esc close · ↑/↓ select · ←/→ filter` + // file search (`@`) ` loading...` / ` no matches`, else bare path + // rows, which no pattern can tell from prose + // The last case is why stage also refuses the triggers themselves (below). + composerPopupLine: String.raw`^ {0,2}(?:enter(?:/tab)? insert · esc close|loading\.\.\.$|no matches$)|^[› ] /[\w:-]+ {2,}\S`, + // Same source. A draft starting with `/` is a slash command (the popup opens while + // the caret is in its name, and Enter runs a command either way), and one starting + // with `!` is a shell command (codex switches its prompt to `!` and runs it). Popups + // open for the word under the caret only: `@` (file search, or the unified mention + // list) and `$` (skills and mentions; whether a given `$word` opens it depends on + // codex's skill catalog, so every `$` word is refused). + composerCommandPrefixes: '/!', + composerCompletionSigils: '@$', }, // The footer under the composer, measured on a live 0.147.0 pane: // ` gpt-5.6-terra default · ~/codeman-cases/th-scratch` (model, reasoning effort, diff --git a/src/config/cli-registry/types.ts b/src/config/cli-registry/types.ts index 0f5a97f18..fc8730700 100644 --- a/src/config/cli-registry/types.ts +++ b/src/config/cli-registry/types.ts @@ -441,6 +441,30 @@ export interface CliCapabilities { * takes guarded input: stage and submit refuse it. Tested one row at a time. */ composerFooterLine?: string; + /** + * Source of a regex matching a row of a suggestion popup this CLI draws directly + * ABOVE its composer (a slash-command list, a file or mention search, its key-hint + * row). The footer below such a composer is unchanged, so without this guarded + * stage/submit would read the popup frame as an idle composer, and Enter would + * accept the highlighted entry instead of sending the draft. Tested one row at a + * time against the contiguous block just above the composer row, never further + * up the pane: a match there refuses the frame. See `readGuardedComposer()`. + */ + composerPopupLine?: string; + /** + * Characters that, as the first character of a draft, make Enter run a command + * rather than send a prompt (codex: `/` slash commands, `!` shell commands). + * Guarded stage refuses such a message before typing; commands go through the + * ordinary input route. + */ + composerCommandPrefixes?: string; + /** + * Characters that, starting the word under the caret, open a completion popup + * whose highlighted entry Enter accepts (codex: `@` file and mention search, `$` + * skills). Guarded stage refuses a message whose LAST word starts with one, + * before typing: the caret ends there, and the popup follows the caret. + */ + composerCompletionSigils?: string; }; /** * How many columns this CLI indents its transcript body by, so a copy taken from its diff --git a/src/session.ts b/src/session.ts index 3b965eb86..1b1410d1a 100644 --- a/src/session.ts +++ b/src/session.ts @@ -4749,6 +4749,14 @@ export class Session extends EventEmitter { get guardedInputPendingWrites(): number { return this._guardedPendingWrites; } + /** + * True while a guarded submit holds the input lease. Every write path refuses for + * that span (a few milliseconds), so a route that can report a refusal checks this + * first instead of answering as if the bytes went out. + */ + get guardedSubmitInFlight(): boolean { + return this._guardedSubmitInFlight; + } /** Expose only the path selection needed for attributed Codex evidence, never credentials. */ getCodexHomeOverride(): string | undefined { return this._envOverrides?.CODEX_HOME || undefined; diff --git a/src/tmux-manager.ts b/src/tmux-manager.ts index 0c52cf399..0eba7b222 100644 --- a/src/tmux-manager.ts +++ b/src/tmux-manager.ts @@ -984,6 +984,20 @@ export function formatCursorRestore(geometry: PaneCursorGeometry): string { return `${up > 0 ? `\x1b[${up}A` : ''}\r${right > 0 ? `\x1b[${right}C` : ''}`; } +/** + * Split the output of `capture-pane -p \; display-message -p '<cursor format>'` + * into the captured rows and the cursor line, which tmux prints last. Null unless + * that last line has the cursor query's shape (four leading integers), so a query + * that printed nothing can never have the frame's own last row taken for it. + */ +export function splitFrameAndCursor(output: string): { frame: string; cursor: string } | null { + const body = output.endsWith('\n') ? output.slice(0, -1) : output; + const split = body.lastIndexOf('\n'); + const cursor = body.slice(split + 1); + if (!/^\d+ \d+ \d+ \d+(?: |$)/.test(cursor)) return null; + return { frame: body.slice(0, split + 1), cursor }; +} + export function formatPaneSnapshot( lines: string[], geometry: { cols: number; rows: number; cursorX: number; cursorY: number } @@ -4319,9 +4333,21 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer { execOpts.maxBuffer = (opts?.maxCaptureBytes ?? DEFAULT_TERMINAL_BUFFER_MAX_BYTES) + FULL_HISTORY_CAPTURE_SLACK_BYTES; } + const cursorQuery = `display-message -p -t ${shellescape(target)} '#{cursor_x} #{cursor_y} #{pane_width} #{pane_height} #{history_size} #{alternate_on}'`; + const captureCommand = `${this.tmux()} ${captureFlags} -t ${shellescape(target)}`; + // The visible frame and its caret come from ONE tmux invocation + // (the capture, `\;`, then the cursor query): tmux runs a command list back to back + // in its command queue, with no pane output parsed between the two, so the + // caret belongs to the frame. Two separate calls raced the CLI's repaint: a + // CLI that redraws its composer a few ms after input arrives (codex) could be + // captured before the redraw and its caret queried after it, and guarded + // stage then refused a frame the keys had already reached. Any doubt about + // the combined output (a failed call, a last line that is not the query's) + // falls back to the two separate calls, which is what this always did. + const combined = fullHistory ? null : this.captureFrameWithCursor(`${captureCommand} \\; ${cursorQuery}`); const rawCapture = joinScrollbackAndFrame( scrollbackOverride, - execSync(`${this.tmux()} ${captureFlags} -t ${shellescape(target)}`, execOpts), + combined?.frame ?? execSync(captureCommand, execOpts), scrollbackOverride !== undefined ? opts?.scrollbackOverrideScreen : undefined ); // Query the cursor BEFORE deciding anything else. On the full-history path @@ -4329,10 +4355,9 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer { // appended, and those two have to agree: trailing blank rows are only safe // to keep when a move follows to put the caret back above them. const geometry = queryPaneCursor(() => - execSync( - `${this.tmux()} display-message -p -t ${shellescape(target)} '#{cursor_x} #{cursor_y} #{pane_width} #{pane_height} #{history_size} #{alternate_on}'`, - { encoding: 'utf-8', timeout: EXEC_TIMEOUT_MS } - ) + combined + ? combined.cursor + : execSync(`${this.tmux()} ${cursorQuery}`, { encoding: 'utf-8', timeout: EXEC_TIMEOUT_MS }) ); // Report the size the pane was really drawing at. The visible-frame path // below addresses every row absolutely, so a consumer whose terminal is @@ -4398,6 +4423,15 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer { } } + /** One combined capture-and-cursor-query call, split; null when it failed or is ambiguous. */ + private captureFrameWithCursor(command: string): { frame: string; cursor: string } | null { + try { + return splitFrameAndCursor(execSync(command, { encoding: 'utf-8', timeout: EXEC_TIMEOUT_MS })); + } catch { + return null; + } + } + /** * Capture the active pane for a tmux session. * diff --git a/src/web/routes/agent-session-routes.ts b/src/web/routes/agent-session-routes.ts index 280138ffb..011018931 100644 --- a/src/web/routes/agent-session-routes.ts +++ b/src/web/routes/agent-session-routes.ts @@ -14,7 +14,13 @@ import type { SessionPort, InfraPort } from '../ports/index.js'; import { getCli } from '../../config/cli-registry/registry.js'; import { findExactCodexConversation, resolveEffectiveCodexHome } from '../../agent-codex-identity.js'; import { InspectionReader, InspectionConflict, boundedUtf8Tail } from '../../agent-inspection.js'; -import { GuardedStageStore, GuardedInputConflict, type GuardedInputTarget } from '../../agent-staging.js'; +import { + GuardedStageStore, + GuardedInputConflict, + guardedComposerChrome, + MAY_BE_TYPED, + type GuardedInputTarget, +} from '../../agent-staging.js'; import { ApiErrorCode, createErrorResponse, type ApiResponse } from '../../types.js'; import { findSessionOrFail, getAuthUser, parseBody, readJsonConfig, SETTINGS_PATH } from '../route-helpers.js'; import { AgentInspectQuerySchema, AgentStageSchema, AgentSubmitSchema } from '../schemas.js'; @@ -51,6 +57,7 @@ export function registerAgentSessionRoutes(app: FastifyInstance, ctx: SessionPor const generation = (session: Session) => session.inspectionGeneration; const target = (session: Session): GuardedInputTarget => ({ id: session.id, + chrome: () => guardedComposerChrome(getCli(session.mode)), snapshot: () => { if (ctx.sessions.get(session.id) !== session) return null; const snapshot = session.guardedInputSnapshot?.(); @@ -153,7 +160,7 @@ export function registerAgentSessionRoutes(app: FastifyInstance, ctx: SessionPor ApiErrorCode.CONFLICT, error instanceof GuardedInputConflict ? error.message - : 'Stage delivery failed; inspect the pane before retrying' + : `Stage delivery failed or ambiguous; ${MAY_BE_TYPED}` ) ); } diff --git a/src/web/routes/session-routes.ts b/src/web/routes/session-routes.ts index fbceb684a..369ea2dc0 100644 --- a/src/web/routes/session-routes.ts +++ b/src/web/routes/session-routes.ts @@ -2077,6 +2077,24 @@ export function registerSessionRoutes( } } + // A guarded submit (agent-staging.ts) holds the input lease for the few + // milliseconds of its conditional Enter, and every write path refuses while it + // does. Say so instead of answering 200 for bytes that were never written, and + // give the seq back so the browser's durable queue (which keeps a frame on any + // 409) delivers it once the lease is released. Nothing from here to the write + // below awaits, so the lease cannot be taken in between. + if (!duplicate && session.guardedSubmitInFlight === true) { + if (tagged) session.forgetInputSeq(clientId as string, seq as number); + return reply + .code(409) + .send( + createErrorResponse( + ApiErrorCode.CONFLICT, + 'A guarded submit is in progress on this session; nothing was written. Retry the input.' + ) + ); + } + // Only a waiting request pays for the tmux probe: the browser's plain input path // (thousands of calls per session) must stay exec-free. const workerDead = wantsWait && workerIsDead(ctx.mux, session); diff --git a/test/agent-codex-identity.test.ts b/test/agent-codex-identity.test.ts index a12922756..a0e571768 100644 --- a/test/agent-codex-identity.test.ts +++ b/test/agent-codex-identity.test.ts @@ -224,6 +224,22 @@ describe('remembered attribution on a large, never-pruned store', () => { expect(await findExactCodexConversation(f.options)).toBeUndefined(); }); + it('answers unattributed, without a full walk, when the remembered file verifies but its incremental scan runs out', async () => { + const f = await fixture(); + const path = await dated(f, '2026/10/24', OWNED); + expect(await findExactCodexConversation(f.options)).toEqual({ id: OWNED, path }); + // 30 rollouts in the remembered day itself: the incremental scan reads them all, + // so 20 entries cannot cover it, and a full walk (a superset) could not either. + await Promise.all( + Array.from({ length: 30 }, (_, n) => + dated(f, '2026/10/24', `20000000-2222-4222-8222-${String(n).padStart(12, '0')}`, { originator: 'codex_cli' }) + ) + ); + expect(await findExactCodexConversation(f.options, small)).toBeUndefined(); + // The memo was dropped, so a call with room walks the store and attributes again. + expect(await findExactCodexConversation(f.options)).toEqual({ id: OWNED, path }); + }); + it('drops the remembered file when it is replaced, and walks again', async () => { const f = await fixture(); const path = await dated(f, '2026/10/24', OWNED); diff --git a/test/agent-staging-session.test.ts b/test/agent-staging-session.test.ts index 8d97cc23b..ff106fdf4 100644 --- a/test/agent-staging-session.test.ts +++ b/test/agent-staging-session.test.ts @@ -413,16 +413,18 @@ it('refuses a message that would wrap using the actual captured indent and width }, muxSession: { muxName: 'codeman-12345678', sessionId: 'test', createdAt: 1, pid: 1 }, } as unknown as ConstructorParameters<typeof Session>[0]); - const { GuardedStageStore } = await import('../src/agent-staging.js'); + const { GuardedStageStore, guardedComposerChrome } = await import('../src/agent-staging.js'); + const { getCli } = await import('../src/config/cli-registry/registry.js'); try { const store = new GuardedStageStore(); const target = { id: session.id, + chrome: () => guardedComposerChrome(getCli('codex')), snapshot: () => session.guardedInputSnapshot(), write: (text: string) => session.stageGuardedInput(text), submit: (snap: Parameters<Session['submitGuardedInput']>[0]) => session.submitGuardedInput(snap), }; - await expect(store.stage(target, 'alice', 'x'.repeat(24))).rejects.toThrow(); + await expect(store.stage(target, 'alice', 'x'.repeat(24))).rejects.toThrow(/wrap/); expect(writes).toEqual([]); } finally { await session.stop(false); @@ -464,3 +466,70 @@ it('records a verifier re-press as the latest submit time', async () => { vi.useRealTimers(); } }); + +it('stages through a real session whose first post-write capture is the torn frame codex produces', async () => { + // Reproduces the live 0.162.0 race: right after the keys land, the screen still + // shows the empty composer while the caret is already past the new text. That + // frame proves nothing; the repaint a few ms later does. + let composer = ''; + let torn = 0; + const writes: string[] = []; + const session = new Session({ + workingDir: '/tmp', + mode: 'codex', + mux: { + isAvailable: () => true, + capturePaneBuffer: ( + _name: string, + _pane?: string, + opts?: { capturedGeometry?: { cols: number; rows: number } } + ) => { + if (opts) opts.capturedGeometry = { cols: 100, rows: 20 }; + const shown = torn > 0 ? '' : composer; + if (torn > 0) torn--; + return formatPaneSnapshot([`› ${shown}`, ' GPT-6-Luna default · ~/case'], { + cols: 100, + rows: 20, + cursorX: 2 + composer.length, + cursorY: 0, + }); + }, + sendInput: async (_id: string, text: string, options?: { beforeDispatch?: () => boolean }) => { + if (options?.beforeDispatch && !options.beforeDispatch()) return false; + writes.push(text); + if (text !== '\r') { + composer += text; + torn = 2; + } + return true; + }, + }, + muxSession: { muxName: 'codeman-12345678', sessionId: 'test', createdAt: 1, pid: 1 }, + } as unknown as ConstructorParameters<typeof Session>[0]); + const { GuardedStageStore, guardedComposerChrome } = await import('../src/agent-staging.js'); + const { getCli } = await import('../src/config/cli-registry/registry.js'); + const sleeps: number[] = []; + const store = new GuardedStageStore(Date.now, 1024, 120000, { + sleep: async (ms) => { + sleeps.push(ms); + }, + }); + const target = { + id: session.id, + chrome: () => guardedComposerChrome(getCli('codex')), + snapshot: () => session.guardedInputSnapshot(), + write: (text: string) => session.stageGuardedInput(text), + submit: (snap: Parameters<Session['submitGuardedInput']>[0]) => session.submitGuardedInput(snap), + }; + try { + const receipt = await store.stage(target, 'alice', 'hello'); + expect(receipt.sha256).toBe(promptSha256('hello')); + expect(sleeps).toHaveLength(2); + await expect(store.submit(target, 'alice', receipt.stageId, receipt.sha256)).resolves.toMatchObject({ + submitted: true, + }); + expect(writes).toEqual(['hello', '\r']); + } finally { + await session.stop(false); + } +}); diff --git a/test/agent-staging.test.ts b/test/agent-staging.test.ts index 976e2cbd1..e6055b752 100644 --- a/test/agent-staging.test.ts +++ b/test/agent-staging.test.ts @@ -1,9 +1,13 @@ /** @fileoverview Guarded staging one-shot and content-verification safety. */ -import { beforeEach, expect, it } from 'vitest'; +import { beforeEach, describe, expect, it } from 'vitest'; import { createHash } from 'node:crypto'; import { CODEX_REASONING_EFFORTS } from '../src/types/session.js'; import { GuardedStageStore, + STAGE_SETTLE_INTERVAL_MS, + STAGE_SETTLE_TIMEOUT_MS, + SUBMIT_QUIET_MS, + composerTriggerRefusal, guardedComposerChrome, readGuardedComposer, readGuardedSnapshot, @@ -23,16 +27,24 @@ let composer: string | null; let writes: string[]; let fail: boolean; let target: GuardedInputTarget; +/** Every settle-window wait, on the fake clock: no real timer runs in this file. */ +let sleeps: number[]; +const fakeSleep = async (ms: number) => { + sleeps.push(ms); + time += ms; +}; beforeEach(() => { + sleeps = []; time = 0; generation = 'pane-1'; revision = 0; composer = ''; writes = []; fail = false; - store = new GuardedStageStore(() => time, 2, 1000); + store = new GuardedStageStore(() => time, 2, 1000, { sleep: fakeSleep }); target = { id: 's', + chrome: () => CODEX, snapshot: () => composer === null ? null : { generation, revision, composerSha: sha(composer), availableColumns: 100 }, submit: async () => target.write('\r'), @@ -144,7 +156,7 @@ it('requires capture cursor geometry, rejecting invisible trailing edits and mov expect(readGuardedCodexSnapshot(frame('\x1b[38;2;2;100;100mhello\x1b[0m', 7))).toBe('hello'); }); it('reserves receipt capacity before concurrent stages type', async () => { - store = new GuardedStageStore(() => time, 1, 1000); + store = new GuardedStageStore(() => time, 1, 1000, { sleep: fakeSleep }); let release: (() => void) | undefined; const firstTarget = { ...target, @@ -195,3 +207,174 @@ it('takes the composer glyph and footer from the registry, and only codex declar unsafe.capabilities.workDetect!.composerFooterLine = '(a+)+b'; expect(guardedComposerChrome(unsafe)).toBeNull(); }); + +describe('the settle window after typing (a live codex pane repaints 30 to 60 ms after the keys)', () => { + /** Serve these post-write frames in order, then the target's own snapshot. */ + function afterWrite(frames: Array<(() => ReturnType<GuardedInputTarget['snapshot']>) | null>) { + let written = false; + const own = target.snapshot; + const write = target.write; + target.write = async (text) => { + const ok = await write(text); + written = true; + return ok; + }; + target.snapshot = () => { + if (!written || frames.length === 0) return own(); + const next = frames.shift()!; + return next ? next() : null; + }; + } + const stale = () => ({ generation, revision, composerSha: sha(''), availableColumns: 100 }); + + it('names its bounds', () => { + expect(STAGE_SETTLE_TIMEOUT_MS).toBe(1000); + expect(STAGE_SETTLE_INTERVAL_MS).toBe(50); + }); + it('issues a receipt once a null first frame is followed by a valid one', async () => { + afterWrite([null]); + const receipt = await store.stage(target, 'alice', 'hello'); + expect(receipt.sha256).toBe(sha('hello')); + expect(sleeps).toEqual([STAGE_SETTLE_INTERVAL_MS]); + }); + it('issues a receipt once a stale frame (the old, empty composer) is followed by a valid one', async () => { + afterWrite([stale, stale]); + await expect(store.stage(target, 'alice', 'hello')).resolves.toMatchObject({ submitted: false }); + expect(sleeps).toHaveLength(2); + }); + it('refuses, saying the text may be in the composer, when no frame verifies within the window', async () => { + target.snapshot = () => (writes.length ? null : stale()); + const refusal = await store.stage(target, 'alice', 'hello').catch((error: Error) => error.message); + expect(refusal).toMatch(/within 1000 ms; no receipt was issued/); + expect(refusal).toMatch(/may already be in the composer.*Ctrl\+U/); + expect(sleeps.length).toBeGreaterThanOrEqual(STAGE_SETTLE_TIMEOUT_MS / STAGE_SETTLE_INTERVAL_MS); + }); + it('issues no receipt for a frame whose composer text differs from the message', async () => { + const write = target.write; + target.write = async (text) => { + await write(text); + composer = 'hellp'; // codex shows something else: a transformed paste, a dropped key + return true; + }; + await expect(store.stage(target, 'alice', 'hello')).rejects.toThrow(/did not show the staged message/); + // Nothing to submit: no receipt was stored for the frame that did not match. + composer = 'hello'; + await expect(store.submit(target, 'alice', '11111111-1111-4111-8111-111111111111', sha('hello'))).rejects.toThrow( + /Unknown/ + ); + }); + it('refuses at once, without waiting, when another writer moved the input during the window', async () => { + afterWrite([() => ({ generation, revision: revision + 1, composerSha: sha('hello'), availableColumns: 100 })]); + await expect(store.stage(target, 'alice', 'hello')).rejects.toThrow( + /Input or pane changed while staging; no receipt was issued\. The message may already be in the composer/ + ); + expect(sleeps).toEqual([]); + }); + it('words a failed typing delivery like the submit refusal: it cannot know whether the text landed', async () => { + fail = true; + await expect(store.stage(target, 'alice', 'hello')).rejects.toThrow( + /^Staging delivery failed or ambiguous; no receipt was issued\. The message may already be in the composer/ + ); + }); +}); + +describe('popups and commands that change what Enter does (codex rust-v0.162.0 bottom_pane)', () => { + const footer = ' gpt-5.6-terra default · ~/case'; + /** A frame as codex lays it out: transcript, popup, the composer's blank top row, composer, blank, footer. */ + const frame = (popup: string[], draft: string) => + ['• Earlier answer from the agent.', '', ...popup, '', `› ${draft}`, '', footer].join('\n'); + + it('reads the idle frame with transcript above it', () => { + expect(readGuardedCodexComposer(frame([], 'hello'))).toBe('hello'); + }); + it('refuses the slash-command list codex opens above `› /stat` (Enter would run /status)', () => { + const popup = [ + '› /status show current session configuration and token usage', + ' /statusline configure which items appear in the status line', + ]; + expect(readGuardedCodexComposer(frame(popup, '/stat'))).toBeNull(); + // A wrapped description row at the bottom of the list does not hide it. + expect(readGuardedCodexComposer(frame(['› /tier-one Use faster', ' inference'], '/tier'))).toBeNull(); + }); + it('refuses the skill, mention and file-search popups', () => { + const skill = ['› 1password [Skill] Example skill used in tests.', '', ' enter insert · esc close']; + const mention = [ + ' Mentions', + ' All Results Filesystem Only Plugins', + ' sa', + '› Sample Plugin Plugin with skills and an MCP server Plugin', + ' enter/tab insert · esc close · ↑/↓ select · ←/→ filter', + ]; + expect(readGuardedCodexComposer(frame(skill, '$1p'))).toBeNull(); + expect(readGuardedCodexComposer(frame(mention, '@sa'))).toBeNull(); + expect(readGuardedCodexComposer(frame([' loading...', ''], '@src'))).toBeNull(); + expect(readGuardedCodexComposer(frame(['no matches'], '/zz'))).toBeNull(); + }); + it('looks only at the block directly above the composer, not the transcript further up', () => { + const screen = [' /status shows the status', '', '• Answer.', '', '', '', '› hello', '', footer].join('\n'); + expect(readGuardedCodexComposer(screen)).toBe('hello'); + }); + it('takes the popup pattern and the trigger characters from the registry', () => { + const codex = getCli('codex')!.capabilities.workDetect!; + expect(CODEX.popupLine?.source).toBe(new RegExp(codex.composerPopupLine!).source); + expect(CODEX.commandPrefixes).toBe('/!'); + expect(CODEX.completionSigils).toBe('@$'); + // A popup pattern the ReDoS guard refuses yields no chrome, rather than chrome without it. + const unsafe = structuredClone(getCli('codex')!); + unsafe.capabilities.workDetect!.composerPopupLine = '(a+)+b'; + expect(guardedComposerChrome(unsafe)).toBeNull(); + }); + it.each([ + ['/stat', '/'], + ['/status now', '/'], + [' /review', '/'], + ['!ls -la', '!'], + ['review @src/app', '@'], + ['@notes', '@'], + ['run $skill', '$'], + ['costs $5', '$'], + ])('refuses %j before typing (trigger %s)', async (message, trigger) => { + expect(composerTriggerRefusal(message, CODEX)).toContain(`"${trigger}"`); + await expect(store.stage(target, 'alice', message)).rejects.toThrow( + trigger === '/' || trigger === '!' ? /codeman agent send/ : /completion popup/ + ); + expect(writes).toEqual([]); + }); + it.each(['look at @src/app.ts please', 'mail a@b.example', 'pay $5 today', 'a/b and c!'])( + 'stages %j: no trigger at the start, and the caret word opens nothing', + async (message) => { + expect(composerTriggerRefusal(message, CODEX)).toBeNull(); + await expect(store.stage(target, 'alice', message)).resolves.toMatchObject({ submitted: false }); + } + ); +}); + +describe("the quiet time before Enter (codex's 120 ms paste-burst window turns Enter into a newline)", () => { + it('waits out the rest of the window after the staged keys before checking and sending Enter', async () => { + expect(SUBMIT_QUIET_MS).toBeGreaterThan(120); + const receipt = await store.stage(target, 'alice', 'hello'); + time += 40; + await store.submit(target, 'alice', receipt.stageId, receipt.sha256); + expect(sleeps).toEqual([SUBMIT_QUIET_MS - 40]); + expect(writes).toEqual(['hello', '\r']); + }); + it('does not wait once the window has passed', async () => { + const receipt = await store.stage(target, 'alice', 'hello'); + time += SUBMIT_QUIET_MS; + await store.submit(target, 'alice', receipt.stageId, receipt.sha256); + expect(sleeps).toEqual([]); + }); + it('still refuses when a keystroke lands during the wait', async () => { + store = new GuardedStageStore(() => time, 2, 1000, { + sleep: async (ms) => { + await fakeSleep(ms); + revision++; // the user typed while submit waited + }, + }); + const receipt = await store.stage(target, 'alice', 'hello'); + await expect(store.submit(target, 'alice', receipt.stageId, receipt.sha256)).rejects.toThrow( + /changed; nothing submitted/ + ); + expect(writes).toEqual(['hello']); + }); +}); diff --git a/test/cli-registry-schema.test.ts b/test/cli-registry-schema.test.ts index 5c4a468c7..5ee368eec 100644 --- a/test/cli-registry-schema.test.ts +++ b/test/cli-registry-schema.test.ts @@ -205,6 +205,48 @@ describe('workDetect.workingLine is guarded like every other config regex', () = } }); + it('holds the optional composerPopupLine to the same guard, and accepts the shipped one', () => { + // Run over the rows just above the composer each time guarded input reads the pane. + for (const composerPopupLine of ['(a+)+b', 'a'.repeat(201), '([unclosed', '']) { + expectRejected((e) => { + (e.capabilities as Record<string, unknown>).workDetect = { + promptGlyph: '>', + workingLine: 'working', + composerPopupLine, + }; + }, 'guarded input tests it against pane rows a clis.json override can supply'); + } + for (const entry of STOCK_CLIS) { + const src = entry.capabilities.workDetect?.composerPopupLine; + if (src) + expect(compileVersionRegex(src), `${entry.id} declares a composerPopupLine the guard refuses`).not.toBeNull(); + } + }); + + it('takes only short runs of ASCII punctuation as composer trigger characters', () => { + for (const field of ['composerCommandPrefixes', 'composerCompletionSigils']) { + for (const value of ['', 'a', '/a', '/ ', '/\t', '\u00a7', '!!!!!!!!!']) { + expectRejected( + (e) => { + (e.capabilities as Record<string, unknown>).workDetect = { + promptGlyph: '>', + workingLine: 'working', + [field]: value, + }; + }, + `${field} ${JSON.stringify(value)} would refuse letters, spaces or an unbounded set` + ); + } + const entry = baseEntry(); + (entry.capabilities as Record<string, unknown>).workDetect = { + promptGlyph: '>', + workingLine: 'working', + [field]: '/!@$', + }; + expect(CliEntrySchema.safeParse(entry).success, field).toBe(true); + } + }); + it('holds the optional blockedLine to the same guard', () => { // Run over the foot of a capture at every settle and on a timer while a dialog is // up, and a clis.json override can set it. diff --git a/test/routes/agent-session-routes.test.ts b/test/routes/agent-session-routes.test.ts index 8bafbb151..e97e401bc 100644 --- a/test/routes/agent-session-routes.test.ts +++ b/test/routes/agent-session-routes.test.ts @@ -250,6 +250,17 @@ it('uses real session writes to stage without Enter and refuses intervening API }); expect(stale.statusCode).toBe(409); expect(writes.filter((s) => s === '\r')).toHaveLength(1); + // Codex's command popup opens on `/`; Enter would run the highlighted command. + composer = ''; + writes.length = 0; + const slash = await harness.app.inject({ + method: 'POST', + url: `/api/sessions/${session.id}/stage`, + payload: { message: '/stat' }, + }); + expect(slash.statusCode).toBe(409); + expect(slash.json().error).toMatch(/starting with "\/".*codeman agent send/); + expect(writes).toEqual([]); } finally { await session.stop(false); } diff --git a/test/routes/session-routes.test.ts b/test/routes/session-routes.test.ts index b531756d0..f770a76cc 100644 --- a/test/routes/session-routes.test.ts +++ b/test/routes/session-routes.test.ts @@ -1197,6 +1197,32 @@ describe('session-routes', () => { expect(session.writeBuffer).toEqual(['prompt', '\r']); }); + it('refuses with 409 and gives the seq back while a guarded submit holds the input lease', async () => { + const url = `/api/sessions/${harness.ctx._sessionId}/input`; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const session = harness.ctx.sessions.get(harness.ctx._sessionId) as any; + session.writeBuffer.length = 0; + const post = (payload: unknown) => harness.app.inject({ method: 'POST', url, payload }); + session.guardedSubmitInFlight = true; + for (const payload of [ + { input: 'x' }, + { input: 'x', useMux: true }, + { input: 'prompt\r' }, + { input: 'tagged', seq: 7, clientId: 'cid-guard' }, + ]) { + const refused = await post(payload); + expect(refused.statusCode, JSON.stringify(payload)).toBe(409); + expect(refused.json()).toMatchObject({ success: false, errorCode: 'CONFLICT' }); + expect(refused.json().error).toMatch(/guarded submit is in progress.*nothing was written/); + } + expect(session.writeBuffer).toEqual([]); + // Once the lease is released the browser's retry of the same seq is applied, not + // rejected as a duplicate. + session.guardedSubmitInFlight = false; + expect((await post({ input: 'tagged', seq: 7, clientId: 'cid-guard' })).statusCode).toBe(200); + expect(session.writeBuffer).toEqual(['tagged']); + }); + it('always applies untagged input (curl/legacy, no dedup)', async () => { const url = `/api/sessions/${harness.ctx._sessionId}/input`; // eslint-disable-next-line @typescript-eslint/no-explicit-any diff --git a/test/tmux-capture-full-history.test.ts b/test/tmux-capture-full-history.test.ts index 9ed86ea3f..b9b721f6a 100644 --- a/test/tmux-capture-full-history.test.ts +++ b/test/tmux-capture-full-history.test.ts @@ -11,7 +11,13 @@ import { readFileSync } from 'node:fs'; import { resolve } from 'node:path'; import { describe, expect, it } from 'vitest'; -import { formatCursorRestore, formatPaneSnapshot, hasVisibleContent, queryPaneCursor } from '../src/tmux-manager.js'; +import { + formatCursorRestore, + formatPaneSnapshot, + hasVisibleContent, + queryPaneCursor, + splitFrameAndCursor, +} from '../src/tmux-manager.js'; describe('tmux full-history pane capture (COD-47)', () => { const source = readFileSync(resolve(import.meta.dirname, '../src/tmux-manager.ts'), 'utf8'); @@ -265,3 +271,32 @@ describe('queryPaneCursor history depth', () => { expect(source).toContain("'#{cursor_x} #{cursor_y} #{pane_width} #{pane_height} #{history_size} #{alternate_on}'"); }); }); + +describe('the visible frame and its caret come from one tmux invocation', () => { + const source = readFileSync(resolve(import.meta.dirname, '../src/tmux-manager.ts'), 'utf8'); + const methodStart = source.indexOf('capturePaneBuffer(muxName: string'); + const methodBody = source.slice(methodStart, source.indexOf('captureActivePaneBuffer(muxName: string', methodStart)); + + it('chains the capture and the cursor query with `\;` on the visible path only', () => { + // Two separate execs raced a CLI repaint (codex redraws its composer 30-60 ms + // after input): the frame could predate the caret, and guarded stage refused it. + // Two backslashes in the source: one escaped backslash in the shell command, which hands tmux its `;`. + const chained = '${captureCommand} ' + '\\'.repeat(2) + '; ${cursorQuery}'; + expect(methodBody).toContain('fullHistory ? null : this.captureFrameWithCursor(`' + chained + '`)'); + // Any doubt falls back to the two calls the capture always made. + expect(methodBody).toContain('combined?.frame ?? execSync(captureCommand, execOpts)'); + expect(methodBody).toContain('combined\n ? combined.cursor'); + }); + it('splits the combined output into the rows and the cursor line tmux prints last', () => { + expect(splitFrameAndCursor('› hello\n footer\n\n7 0 100 20 0 0\n')).toEqual({ + frame: '› hello\n footer\n\n', + cursor: '7 0 100 20 0 0', + }); + expect(splitFrameAndCursor('only\n3 0 80 24\n')).toEqual({ frame: 'only\n', cursor: '3 0 80 24' }); + }); + it('refuses output whose last line is not the cursor query, so a frame row is never taken for it', () => { + expect(splitFrameAndCursor('› hello\n footer\n')).toBeNull(); + expect(splitFrameAndCursor('row\nworld 1 2 3 4\n')).toBeNull(); + expect(splitFrameAndCursor('')).toBeNull(); + }); +}); From 3411d7f35a17bf940a6a25dd79ae775bccc8d96a Mon Sep 17 00:00:00 2001 From: Devvyn <22340871+opticon454@users.noreply.github.com> Date: Sun, 25 Oct 2026 22:31:25 +0800 Subject: [PATCH 37/68] fix(git): the fetch timeout reaches the whole tree, a tick covers every session on its folder, and the rest of the review (review) `execFile` signals the `git` parent only, so a fetch that times out can leave its http helper still running, and on the interval that repeats every run with nobody watching. `runFetch` now spawns `detached`, so the fetch and the helper it spawns share one process group, and the kill is SIGTERM to the negative pid then SIGKILL `FETCH_KILL_ESCALATION_MS` later; the escalation is not cancelled when the promise settles, because the caller is answered at the timeout while the group is still dying. The output cap is kept, and the clone's slot queue is not needed here. The tick is two passes: every enabled local session is collected by folder, a folder is due when ANY session open on it is due, the folder is probed once for the group, and the run is published to every session on it. A single pass decided by the order the sessions were read, so [long, short] published only the short one and the 3600 s session's row never updated again while its folder was fetched every 30 s. `gitNonInteractiveEnv` writes BatchMode ssh as a default, not an override: `core.sshCommand` is read inside the same slot and the same bound as the fetch it serves, and `fetchEnvFor` keeps the repository's answer when it has one, because that is usually the setup that works on a machine with several ssh setups. The `accept-new` host key it can write is stated next to the BatchMode note: a prompt is stopped, a write is not. The write claim is scoped to the Git panel and to the timer, with cloning and worktree management named as the writes that happen when the user asked for them, and the zh keys sit with the group of the UI surface their English string belongs to. Tests: the process-group test proves the tree by group membership, because the helper is a grandchild (`git fetch` runs `git remote-http`, which spawns `git-remote-http`) and because the group is what the negative pid reaches, and it remembers each process once found instead of re-scanning a group it had already emptied. The non-zero-exit test changed premises: `git fetch --quiet` in a repository with no remote exits 0 on git 2.47.3, so resolving empty stdout there was correct, and the case that has to reject is a remote pointing at a path that is not a repository. The join test asserts after the run and does not await the request before the held run is released, which would wait on the run the test is holding. --- CLAUDE.md | 2 +- docs/api-reference.md | 4 +- docs/wiki/Settings-Reference.md | 12 +- docs/wiki/Working-With-Files.md | 14 +- src/git-fetch-scheduler.ts | 86 ++++++--- src/git-fetch.ts | 230 ++++++++++++++++++++--- src/web/public/git-status-ui.js | 17 +- src/web/public/i18n.js | 54 +++--- src/web/public/index.html | 6 +- src/web/routes/git-status-routes.ts | 32 ++-- src/web/schemas.ts | 9 +- src/web/server.ts | 5 +- test/git-fetch-scheduler.test.ts | 83 +++++++-- test/git-fetch.test.ts | 259 +++++++++++++++++++++++--- test/routes/git-status-routes.test.ts | 38 +++- 15 files changed, 686 insertions(+), 165 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 201306f9a..d434054a2 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -236,7 +236,7 @@ Codeman is a Claude Code session manager with web interface and autonomous Ralph **Git status indicator** (`showGitStatus`, per-device, default OFF; `src/git-workspace-status.ts`, `routes/git-status-routes.ts`, `git-status-ui.js`): the bottom-bar indicator and its panel read `GET /api/sessions/:id/git-status` and `/git-diff`, read-only and offline on its own (it never fetches; fetching is the separate path below). ⚠️ git never runs on a repository at or inside a Docker case workspace (walk-up, scan and diff alike, and a cached repository list is re-checked against the CURRENT Docker roots), since a container could plant a clean filter that runs on the host; remote and Docker sessions answer `unsupported`. ⚠️ `git-diff` takes `repo`/`path`/`kind` only as keys matched against the workspace's own repository list (`findWorkspaceRepo()`) and that repository's current status, never as paths. ⚠️ Keep `--no-optional-locks`, `core.fsmonitor=false` and `log.showSignature=false` on every call and `--no-ext-diff --no-textconv` on diffs; clean filters still run, which is why the Docker rule exists. ⚠️ An enclosing repository at `$HOME` or above is ignored (`isUnrelatedAncestor()`), and is identified by one cached `rev-parse` before any full status runs. Everything git supplies renders via `textContent`. Tests: `test/git-workspace-status.test.ts`, `test/routes/git-status-routes.test.ts`, and `test/git-status.browser.test.ts` (browser suite, not in the gate). -**Git fetch** (`src/git-fetch.ts`, `src/git-fetch-scheduler.ts`, `routes/git-status-routes.ts`, the panel's **Fetch** button): `POST /api/sessions/:id/git-fetch` is the ONLY git command Codeman runs that WRITES, and the only one that can run a repository's own hooks (`reference-transaction`, `pre-auto-gc` through the automatic maintenance a fetch triggers, config commands such as `credential.helper`) — which is why it is opt-in and never runs on a timer until the user turns it on. ⚠️ Fetch updates remote-tracking refs only; it must never commit, check out, merge or touch the working tree. ⚠️ It is not as harmless as a read: a fetch holds git's ref lock, so an agent's own `git pull` in the same clone can hit the same `cannot lock ref` — hence one folder is fetched by at most one run at a time (`fetchWorkspaceOnce` joins a run already going), and `FETCH_CONFIG_ARGS` (`-c core.fsmonitor=false -c maintenance.auto=false -c gc.auto=0`) keeps an unattended fetch from starting a gc in a repository the agent is using. ⚠️ `buildFetchArgs()` is `['fetch', '--quiet']` and must stay verb-invariant (`test/git-workspace-status.test.ts`); those `-c` overrides are added by the runner, not by `buildFetchArgs()`. ⚠️ The same Docker rule as above applies BEFORE any git runs (a repository at or inside a Docker case workspace is refused), and remote sessions answer `unsupported`. ⚠️ Keep it non-interactive (`gitNonInteractiveEnv()`: `GIT_TERMINAL_PROMPT=0` + BatchMode ssh) or a fetch hangs on an invisible login prompt, and bounded: `MAX_REPOS`/`MAX_REPOS_LIMIT` (12/50), `MAX_FETCH_OUTPUT_BYTES` (2 MB), the git timeout per spawn, and `FETCH_CONCURRENCY` (4) as ONE `Limiter` shared by the fetches of the whole tick — `createLimiter` keeps its count in the closure, so a limiter that delegated to `mapLimited` per call bounds each call, and 4 folders × 4 repositories would start 16 git processes. ⚠️ One semaphore cannot bound both the folders and the fetches: a folder holding a slot would then wait for a slot its own fetch needs, so the tick bounds its folders with `mapLimited` and hands the shared bound to every run. ⚠️ Failure text reaching a client is control-stripped and credential-redacted (`sanitizeGitOutput`) and the browser writes it with `textContent`. Scheduler semantics: settings are read fresh each tick (`GIT_FETCH_TICK_INTERVAL_MS`, 15 s), a per-session override wins even while the app-wide switch is off, the interval is timed from the run START, state is keyed by FOLDER (sessions sharing one folder are the same clone, so one run serves them all, the schedule is stamped at selection, and every session on a folder is remembered — closing the one that holds the override must not stop the folder's fetching while another session is still open on it), a tick is skipped while the previous one is still going, and a user-chosen folder is probed BOUNDED with the default options before anything runs (rule #516 — `unknown` is never read as `absent`). ⚠️ The two settings (`gitFetchEnabled`, `gitFetchIntervalSeconds`, off/300 by default, clamped 30–3600) are **synced**, not per-device: they must stay out of `displayKeys` and out of the strip destructure in settings-ui.js, or the scheduler reads nobody's value. Per-session state rides `SessionUpdated` (no new SSE event) as `gitFetch`, and the click route publishes its own run with `trigger: 'click'` — the UI's "by click" text depends on it. Tests: `test/git-fetch.test.ts`, `test/git-fetch-scheduler.test.ts`, `test/routes/session-routes-git-fetch-settings.test.ts`, `test/git-fetch-i18n.test.ts`, `test/app-settings-structure.test.ts`, `test/session-options-structure.test.ts`, and the Fetch block of `test/git-status.browser.test.ts` (browser suite, not in the gate). +**Git fetch** (`src/git-fetch.ts`, `src/git-fetch-scheduler.ts`, `routes/git-status-routes.ts`, the panel's **Fetch** button): `POST /api/sessions/:id/git-fetch` is the ONLY git command the Git PANEL runs that WRITES — cloning and worktree management write too, but only when the user asked for them at that moment — and it is the only one that can run later by itself, on a timer, and the only one that can run a repository's own hooks (`reference-transaction`, `pre-auto-gc` through the automatic maintenance a fetch triggers, config commands such as `credential.helper`) — which is why it is opt-in and never runs on a timer until the user turns it on. ⚠️ Fetch updates remote-tracking refs only; it must never commit, check out, merge or touch the working tree. ⚠️ It is not as harmless as a read: a fetch holds git's ref lock, so an agent's own `git pull` in the same clone can hit the same `cannot lock ref` — hence one folder is fetched by at most one run at a time (`fetchWorkspaceOnce` joins a run already going), and `FETCH_CONFIG_ARGS` (`-c core.fsmonitor=false -c maintenance.auto=false -c gc.auto=0`) keeps an unattended fetch from starting a gc in a repository the agent is using. ⚠️ `buildFetchArgs()` is `['fetch', '--quiet']` and must stay verb-invariant (`test/git-fetch.test.ts`); those `-c` overrides are added by the runner, not by `buildFetchArgs()`. ⚠️ The git timeout must reach the whole process TREE, not just the `git` parent: `execFile` signals the parent only, so `runFetch` spawns `detached` and signals the negative pid (SIGTERM, then SIGKILL `FETCH_KILL_ESCALATION_MS` later), and the caller is answered AT the timeout while the group is still dying — that is the point. `test/git-fetch.test.ts` proves it against a local server that never answers, by group membership rather than parentage. A non-zero exit must still reject the way `execFile` would, or a real failure reads as a clean run with no reason. ⚠️ The same Docker rule as above applies BEFORE any git runs (a repository at or inside a Docker case workspace is refused), and remote sessions answer `unsupported`. ⚠️ Keep it non-interactive (`gitNonInteractiveEnv()`: `GIT_TERMINAL_PROMPT=0` + BatchMode ssh) or a fetch hangs on an invisible login prompt — but that BatchMode ssh is a DEFAULT, never an override: `fetchEnvFor` must not overwrite the repository's `core.sshCommand` or the user's own `GIT_SSH_COMMAND` (`readRepoSshCommand` reads it inside the same limiter slot and the same wall-clock bound as the fetch it serves), and `StrictHostKeyChecking=accept-new` writes an unknown host key into `~/.ssh/known_hosts` without asking — BatchMode stops a prompt, not a write. Keep it bounded: `MAX_REPOS`/`MAX_REPOS_LIMIT` (12/50), `MAX_FETCH_OUTPUT_BYTES` (2 MB), the git timeout per spawn, and `FETCH_CONCURRENCY` (4) as ONE `Limiter` shared by the fetches of the whole tick — `createLimiter` keeps its count in the closure, so a limiter that delegated to `mapLimited` per call bounds each call, and 4 folders × 4 repositories would start 16 git processes. ⚠️ One semaphore cannot bound both the folders and the fetches: a folder holding a slot would then wait for a slot its own fetch needs, so the tick bounds its folders with `mapLimited` and hands the shared bound to every run. ⚠️ Failure text reaching a client is control-stripped and credential-redacted (`sanitizeGitOutput`) and the browser writes it with `textContent`. Scheduler semantics: settings are read fresh each tick (`GIT_FETCH_TICK_INTERVAL_MS`, 15 s), a per-session override wins even while the app-wide switch is off, the interval is timed from the run START, state is keyed by FOLDER (sessions sharing one folder are the same clone, so one run serves them all, the schedule is stamped at selection, and every session on a folder is remembered, a folder is due when ANY session open on it is due — so the order the sessions were read cannot decide which interval wins (both orders are tested) — and the folder is probed once for the whole group rather than once per session on it — closing the one that holds the override must not stop the folder's fetching while another session is still open on it), a tick is skipped while the previous one is still going, and a user-chosen folder is probed BOUNDED with the default options before anything runs (rule #516 — `unknown` is never read as `absent`). ⚠️ The two settings (`gitFetchEnabled`, `gitFetchIntervalSeconds`, off/300 by default, clamped 30–3600) are **synced**, not per-device: they must stay out of `displayKeys` and out of the strip destructure in settings-ui.js, or the scheduler reads nobody's value. Per-session state rides `SessionUpdated` (no new SSE event) as `gitFetch`, and the click route publishes the run's trigger, not the request's: `trigger: 'click'` only when the click was the run, because a click that joined a tick already going fetched nothing and cannot report itself as the fetch (`GitFetchRun` carries it, and the scheduler publishes the run's trigger for the same reason — its tick can join a click too). The UI's "by click" text depends on it. Tests: `test/git-fetch.test.ts`, `test/git-fetch-scheduler.test.ts`, `test/routes/session-routes-git-fetch-settings.test.ts`, `test/git-fetch-i18n.test.ts`, `test/app-settings-structure.test.ts`, `test/session-options-structure.test.ts`, and the Fetch block of `test/git-status.browser.test.ts` (browser suite, not in the gate). **Raw file bodies are streamed and range-aware**: `file-raw`, the attachments `/raw` route, `GET /api/download` and the rendered-HTML route `GET /html-view/<cap>/...` share `sendFileBody()`, advertise `Accept-Ranges: bytes` and answer `Range` with `206` + `Content-Range` (single-range, parser in `src/web/http-range.ts`); without it `<video>` cannot seek. The size cap (`MAX_FILE_DOWNLOAD_BYTES`, default 2GB, env `CODEMAN_MAX_DOWNLOAD_BYTES`, `0` = unlimited) is a sanity bound, not memory protection; never reintroduce a whole-file buffer. ⚠️ Bodies go out via `reply.hijack()`, so `sendRawStream` must copy the status onto `reply.raw` by hand or a partial body ships as `200`. ⚠️ Closing the preview must pause and unload media (`_stopFilePreviewMedia`), since a detached `HTMLMediaElement` keeps playing. → [architecture-invariants#raw-file-bodies-streamed-and-range-aware](docs/architecture-invariants.md#raw-file-bodies-streamed-and-range-aware) diff --git a/docs/api-reference.md b/docs/api-reference.md index 2aa7cd3ec..50b759e77 100644 --- a/docs/api-reference.md +++ b/docs/api-reference.md @@ -1175,11 +1175,11 @@ Both routes accept two optional query parameters, which the UI sends from its pe Lists are capped (300 files and 50 commits per repository) while the counts stay exact. A branch with no upstream reports the commits no remote has (`HEAD --not --remotes`); a repository with no remote reports `unpushedCount: 0`, since there is nothing to push to. Concurrent polls of one folder share a single git invocation; `?fresh=1` (what the panel's Refresh button and opening the panel send) skips the short-lived caches, though it still joins a computation already running. -`POST /api/sessions/:id/git-fetch` is the panel's **Fetch** button: one `git fetch --quiet` per repository the panel would show, through the same resolver so the two cannot disagree about which repositories belong to a workspace, with the same `maxRepos` and `timeout` clamps. It is the only git command Codeman runs that writes, and it writes remote-tracking refs only — nothing commits, checks out, merges or touches the working tree. It is not quite as harmless as a read: a fetch holds git's ref lock, so an agent's own `git pull` in the same clone can hit the same `cannot lock ref` while a fetch is running, which is why one folder is fetched by one run at a time. It runs on that click, or on the interval below and never otherwise, and `git fetch` is exactly the command that can run a repository's own hooks (`reference-transaction`, `pre-auto-gc` through the automatic maintenance a fetch triggers, and config commands such as `credential.helper`), which is why nothing runs on a timer until you opt in, and why a repository at or inside a Docker case workspace is refused before any git runs. Remote and Docker sessions answer `state: 'unsupported'` like the status route. Bounded: 4 repositories at a time across the whole run (one shared bound, not one per repository group — a scheduled tick keeps that one bound for all its folders, so 4 folders do not mean 4 × 4, and works at most 4 folders at a time), one spawn per repository with the same per-command timeout, `GIT_TERMINAL_PROMPT=0` plus BatchMode ssh so a repository that needs a login fails fast instead of hanging the request on an invisible prompt — Codeman supplies no credentials, so a repository your own git is already signed in to works because that is your existing setup. A click joins a run already going for the same folder, and the click publishes what it got as the session's `gitFetch` state with `trigger: 'click'` (below), so another tab open on the same folder sees the same run. +`POST /api/sessions/:id/git-fetch` is the panel's **Fetch** button: one `git fetch --quiet` per repository the panel would show, through the same resolver so the two cannot disagree about which repositories belong to a workspace, with the same `maxRepos` and `timeout` clamps. It is the only git command the Git panel runs that writes — cloning and worktree management write too, but only when you asked for them at the moment you asked — and it writes remote-tracking refs only — nothing commits, checks out, merges or touches the working tree. It is not quite as harmless as a read: a fetch holds git's ref lock, so an agent's own `git pull` in the same clone can hit the same `cannot lock ref` while a fetch is running, which is why one folder is fetched by one run at a time. It runs on that click, or on the interval below and never otherwise, and `git fetch` is exactly the command that can run a repository's own hooks (`reference-transaction`, `pre-auto-gc` through the automatic maintenance a fetch triggers, and config commands such as `credential.helper`), which is why nothing runs on a timer until you opt in, and why a repository at or inside a Docker case workspace is refused before any git runs. Remote and Docker sessions answer `state: 'unsupported'` like the status route. Bounded: 4 repositories at a time across the whole run (one shared bound, not one per repository group — a scheduled tick keeps that one bound for all its folders, so 4 folders do not mean 4 × 4, and works at most 4 folders at a time), one spawn per repository with the same per-command timeout, which reaches the whole process group rather than only the `git` parent (a fetch over http spawns helper processes, and a timeout that signals the parent alone can leave one running after the answer goes out), `GIT_TERMINAL_PROMPT=0` plus BatchMode ssh so a repository that needs a login fails fast instead of hanging the request on an invisible prompt — Codeman supplies no credentials, so a repository your own git is already signed in to works because that is your existing setup. That BatchMode ssh is a default, never an override: your own `GIT_SSH_COMMAND` and the repository's `core.sshCommand` are honoured, and ⚠️ the default's `StrictHostKeyChecking=accept-new` records an unknown host key in `~/.ssh/known_hosts` without asking — BatchMode stops a prompt, not a write. A click joins a run already going for the same folder, and the click publishes the trigger of the run it actually did — `click` when the click was the run, `interval` when it joined a tick already going, because a click that fetched nothing cannot report itself as the fetch — as the session's `gitFetch` state (below), so another tab open on the same folder sees the same run. `data` is `{ state, repos, reposTruncated, repoLimit, fetchedAt }`, `repos[]` each `{ name, path, ok }` and, when it failed, `failure` (`{ code, message, stderr }`: the codes as in the clone failures, plus `NO_REMOTE`). A repository that could not be fetched keeps its row rather than being dropped. The panel shows the result under its list and then re-reads, so `behind` reflects what this fetch brought. -The interval is two **synced** settings in `PUT /api/settings` (`src/git-fetch-scheduler.ts`): `gitFetchEnabled` (default `false`) and `gitFetchIntervalSeconds` (30 to 3600, default 300, the gap between runs timed from the start of a run so a slow repository does not push the next one out). Synced, not per-device, because the server is what acts on them; the scheduler reads them fresh on each 15 s tick, so a change works without a restart. A session can override both, and the override wins even while the app-wide switch is off: `POST /api/sessions/:id/git-fetch-settings` takes `{ "enabled": true | false | null, "intervalSeconds": number | null }` — `null` on a field clears that half so the app value applies again, a field left out of the body leaves the other half as it was — is `400 INVALID_INPUT` for a remote or Docker session, and answers `200` with `{ gitFetchSettings: { enabled, intervalSeconds } }` where `null` means the session follows the app. What the last run did rides the session state as `gitFetch` (`{ ranAt, trigger, state, repos, failed, firstFailure? }`, `trigger` is `click` or `interval`), on the existing `SessionUpdated` event: no new event. Sessions on the same folder share one run and both see its result, the schedule is keyed by folder, and a folder is fetched by at most one run at a time; every session open on a folder is remembered, so closing the one that holds the override does not stop that folder's fetching while another session is still open on it. The tick skips anything its bounded probe does not confirm is a directory, stamps each folder's schedule when it picks it, and does not run a second tick over a selection still going. Remote and Docker sessions are never fetched on a timer, and the Docker rule is re-checked against the CURRENT Docker roots every tick. +The interval is two **synced** settings in `PUT /api/settings` (`src/git-fetch-scheduler.ts`): `gitFetchEnabled` (default `false`) and `gitFetchIntervalSeconds` (30 to 3600, default 300, the gap between runs timed from the start of a run so a slow repository does not push the next one out). Synced, not per-device, because the server is what acts on them; the scheduler reads them fresh on each 15 s tick, so a change works without a restart. A session can override both, and the override wins even while the app-wide switch is off: `POST /api/sessions/:id/git-fetch-settings` takes `{ "enabled": true | false | null, "intervalSeconds": number | null }` — `null` on a field clears that half so the app value applies again, a field left out of the body leaves the other half as it was — is `400 INVALID_INPUT` for a remote or Docker session, and answers `200` with `{ gitFetchSettings: { enabled, intervalSeconds } }` where `null` means the session follows the app. What the last run did rides the session state as `gitFetch` (`{ ranAt, trigger, state, repos, failed, firstFailure? }`, `trigger` is `click` or `interval`), on the existing `SessionUpdated` event: no new event. Sessions on the same folder share one run and both see its result, the schedule is keyed by folder, and a folder is fetched by at most one run at a time; a folder is due when ANY session open on it is due, so the order the sessions were read cannot decide which interval wins, and the folder is probed once for the whole group rather than once per session on it; every session open on a folder is remembered, so closing the one that holds the override does not stop that folder's fetching while another session is still open on it. The tick skips anything its bounded probe does not confirm is a directory, stamps each folder's schedule when it picks it, and does not run a second tick over a selection still going. Remote and Docker sessions are never fetched on a timer, and the Docker rule is re-checked against the CURRENT Docker roots every tick. ## Git worktrees of a case diff --git a/docs/wiki/Settings-Reference.md b/docs/wiki/Settings-Reference.md index 24e044bf3..bb2fe42e0 100644 --- a/docs/wiki/Settings-Reference.md +++ b/docs/wiki/Settings-Reference.md @@ -89,17 +89,21 @@ bounds the Git window's Fetch button; raise it for repositories on a slow networ **Git fetch on an interval** (synced, off by default) runs `git fetch` on a timer for the repositories the Git window lists, so the `behind` number is current without a click, and **Git fetch: how often** (synced, -30 to 3600 seconds, default 300) is the gap between runs for a case with no choice of its own — timed from +30 to 3600 seconds, default 300) is the gap between runs for a session that has no override of its own — timed from the start of a run, so a slow repository does not push the next one out. These are the only Git settings that are synced rather than per device, because the server's scheduler is what acts on them: kept per device, the scheduler would read nobody's number. **Session Options → Git** overrides both the switch and the interval for one session, and that override wins even while the app switch is off; a session that follows the app shows the app's values and its rows go inert until you turn **Follow the app setting** off. Nothing is ever fetched for a Docker or remote (SSH) session, and a repository a container can write to is refused -before any git runs. Sessions open on the same folder share one run. Both default to off because `git fetch` -is the one git command Codeman runs that writes, and the one that can run a repository's own hooks +before any git runs. Sessions open on the same folder share one run, and a folder is fetched when ANY session open on it is due, so the order the sessions were read cannot decide which interval wins. Both default to off because `git fetch` +is the one git command the Git window runs that writes — cloning and worktree management write too, but only +when you asked for them at the moment you asked — and the one that can run a repository's own hooks (`reference-transaction`, `pre-auto-gc` through the maintenance a fetch triggers, and config commands such as -`credential.helper`). +`credential.helper`), and the one that can run later by itself, on a timer. The ssh it runs is BatchMode so +nothing waits for a login, and that is a default, never an override: your own `GIT_SSH_COMMAND`, or the +repository's `core.sshCommand`, is used when either is set. ⚠️ The default's `StrictHostKeyChecking=accept-new` +records an unknown host key in `~/.ssh/known_hosts` without asking — BatchMode stops a prompt, not a write. Most default to off. The stock desktop header is system stats, File Viewer, Tiles, and the gear. **Header Stats Style** picks how the system stats and plan usage are drawn: *Compact* diff --git a/docs/wiki/Working-With-Files.md b/docs/wiki/Working-With-Files.md index fb49d8fed..1783c7063 100644 --- a/docs/wiki/Working-With-Files.md +++ b/docs/wiki/Working-With-Files.md @@ -254,16 +254,20 @@ Click it for a draggable window, in the style of the File Viewer: remote (SSH) sessions. It is read-only apart from that fetch: Codeman never commits, checks out or changes the -repository, and `git fetch` writes remote-tracking refs only — it is the one git command here that -writes, and the one that can run a repository's own hooks (`reference-transaction`, `pre-auto-gc` through the -maintenance a fetch triggers, and config commands such as `credential.helper`), which is why nothing runs on -a timer until you opt in. A fetch holds git's ref lock, so an agent's own `git pull` in the same repository +repository, and `git fetch` writes remote-tracking refs only — it is the only git command the Git window +runs that writes, the only one that can run a repository's own hooks (`reference-transaction`, +`pre-auto-gc` through the maintenance a fetch triggers, and config commands such as `credential.helper`), +and the only one that can run later by itself, on a timer: cloning and worktree management write too, but +only when you asked for them at the moment you asked, which is why nothing runs on a timer until you opt in. A fetch holds git's ref lock, so an agent's own `git pull` in the same repository can hit the same `cannot lock ref`, and a folder is fetched by one run at a time. It is not shown for Docker or remote (SSH) sessions, and a repository at or inside a Docker case workspace is skipped even from a local session, before any git runs (a container can write there, and git would run that repository's own configuration on the host). Fetch asks nothing of you: it uses the sign-in your own git already has, and it gives up quickly rather than waiting on an -invisible login prompt. The +invisible login prompt. That BatchMode ssh is a default, never an override: your own +`GIT_SSH_COMMAND`, or the repository's `core.sshCommand`, is used when either is set, and ⚠️ the default's +`StrictHostKeyChecking=accept-new` records an unknown host key in `~/.ssh/known_hosts` without asking — +BatchMode stops a prompt, not a write. The data comes from `GET /api/sessions/:id/git-status`, `GET /api/sessions/:id/git-diff` and, on that click or the interval you turned on, `POST /api/sessions/:id/git-fetch` (see the [API reference](https://github.com/Ark0N/Codeman/blob/master/docs/api-reference.md)). diff --git a/src/git-fetch-scheduler.ts b/src/git-fetch-scheduler.ts index 848cb3d4a..8c57f5604 100644 --- a/src/git-fetch-scheduler.ts +++ b/src/git-fetch-scheduler.ts @@ -2,9 +2,10 @@ * @fileoverview Git fetch on an interval: the cases the user turned on, fetched by a tick the server owns * (`src/git-fetch-scheduler.ts`, wired in `src/web/server.ts`). * - * The fetch module (src/git-fetch.ts) is the only place Codeman runs a git command that writes, and on a - * timer it is the only one that can run without being asked — which is why the whole feature is off until - * the user turns it on, in Settings → Bottom bar, per session in Session Options → Git. + * The fetch module (src/git-fetch.ts) is the only git command the Git panel runs that writes, and on a timer + * it is the only one that can run without being asked — cloning and worktree management write too, but those + * happen when the user asks for them. Which is why the whole feature is off until the user turns it on, in + * Settings → Bottom bar, per session in Session Options → Git. * * The scheduler holds no timers of its own. The server ticks it (`GIT_FETCH_TICK_INTERVAL_MS`) and every * tick reads the app setting FRESH, so a live toggle takes effect on the next tick instead of at boot, and @@ -23,7 +24,10 @@ * its result. State is keyed by `resolve(workingDir)` — deliberately not `realpath`, because `realpath` * is itself one of the unbounded fs calls rule #516 bounds; the bounded probe below is the fs read. * Every session open on a folder is remembered, so closing the one holding the override does not stop - * the folder's fetching while another session is still on it. + * the folder's fetching while another session is still on it. A folder is due when ANY session open on it + * is due, and the tick decides that per folder in a second pass: the order the sessions were read cannot + * change which interval wins, so a 30 s session on a folder a 3600 s session last fetched ten minutes ago + * is fetched now. The folder is probed once, not once per session on it. * - Before anything is fetched, the folder is probed BOUNDED with the default options (rule #516): a case * on a mount that stopped answering is skipped, never touched, and a probe that answers `unknown` is not * read as "not a folder". @@ -55,9 +59,10 @@ import { MIN_GIT_FETCH_INTERVAL_SECONDS, gitFetchStatusFrom, type GitFetchOptions, - type GitFetchReport, + type GitFetchRun, type GitFetchSettings, type GitFetchStatus, + type GitFetchTrigger, } from './git-fetch.js'; import { clampInt, DEFAULT_GIT_TIMEOUT_MS, MAX_REPOS, createLimiter, mapLimited } from './git-workspace-status.js'; import type { PathProbeKind } from './utils/bounded-path-probe.js'; @@ -73,6 +78,12 @@ export interface GitFetchSessionView { gitFetchIntervalSeconds?: number; } +/** A session open on a folder, with the interval that makes IT due. One folder's sessions are a group. */ +interface GroupMember { + view: GitFetchSessionView; + intervalSeconds: number; +} + export interface GitFetchPorts { sessionIds(): Iterable<string>; getSession(id: string): GitFetchSessionView | undefined; @@ -86,7 +97,7 @@ export interface GitFetchPorts { */ probePath(path: string): Promise<PathProbeKind>; /** Called through `fetchWorkspaceOnce`, so a run already going for this folder is joined, not stacked. */ - fetch(cwd: string, opts: GitFetchOptions): Promise<GitFetchReport>; + fetch(cwd: string, opts: GitFetchOptions, trigger: GitFetchTrigger): Promise<GitFetchRun>; publish(id: string, status: GitFetchStatus): void; log?(message: string): void; } @@ -139,39 +150,45 @@ export class GitFetchScheduler { return 0; } - // One entry per folder, holding every session open on it. Stamped here, at selection: a tick that is - // still running must not pick the same folder again, and a folder already being fetched is not picked - // at all. - const due = new Map<string, GitFetchSessionView[]>(); + // Pass 1 — who is on what, before anything is decided. Every session open on a folder is recorded here, + // not only the one that would get picked, so `remove()` can still see who is open on it when the session + // holding the override is the one that closes. + const groups = new Map<string, GroupMember[]>(); for (const id of this.ports.sessionIds()) { const view = this.ports.getSession(id); if (!view || view.remote || view.docker) continue; const effective = effectiveGitFetch(view, settings); if (!effective.enabled) continue; const folder = resolve(view.workingDir); - // Recorded for EVERY session on the folder, not just the one that got picked, so `remove()` can still - // see who is open on it when the session holding the override is the one that closes. this.folderOf.set(id, folder); - // A session on a folder this tick already picked is the same clone: it joins that folder's run rather - // than starting its own. Two fetches in one clone collide on git's ref lock, so one run has to serve - // every session open on the folder. - const group = due.get(folder); - if (group) { - group.push(view); - continue; - } + const group = groups.get(folder); + const member = { view, intervalSeconds: effective.intervalSeconds }; + if (group) group.push(member); + else groups.set(folder, [member]); + } + + // Pass 2 — the folder's turn, decided from the whole group rather than from whichever session happened to + // be read first. A folder is due when ANY session open on it is due: a session on a 30 s interval on a + // folder a 3600 s session last fetched ten minutes ago has to be fetched now, because that same run + // satisfies the long interval too. Deciding in one pass means the session read first owns the clock — a + // group ordered [short, long] and a group ordered [long, short] would have behaved differently, and the + // run belongs to the folder, so no session in the group should. + const due = new Map<string, GroupMember[]>(); + for (const [folder, group] of groups) { if (this.inFlight.has(folder)) continue; const started = this.lastRunAt.get(folder); - if (started !== undefined && now - started < effective.intervalSeconds * 1000) continue; - const kind = await this.ports.probePath(view.workingDir); + const isDue = started === undefined || group.some((m) => now - started >= m.intervalSeconds * 1000); + if (!isDue) continue; + // Bounded probe (rule #516), once per folder rather than once per session on it. + const kind = await this.ports.probePath(folder); if (kind !== 'directory') { - this.ports.log?.(`git fetch: ${view.id} skipped, folder answered ${kind}`); + this.ports.log?.(`git fetch: ${folder} skipped, answered ${kind}`); continue; } this.inFlight.add(folder); // The clock starts at the selection, so a slow folder does not shift its own schedule. this.lastRunAt.set(folder, now); - due.set(folder, [view]); + due.set(folder, group); } if (!due.size) return 0; @@ -188,13 +205,20 @@ export class GitFetchScheduler { // processes than the module's ceiling. await mapLimited([...due.entries()], FETCH_CONCURRENCY, async ([folder, group]) => { try { - const report = await this.ports.fetch(folder, { - dockerWorkspaces, - maxRepos: MAX_REPOS, - timeoutMs: DEFAULT_GIT_TIMEOUT_MS, - limiter: fetches, - }); - for (const view of group) this.ports.publish(view.id, gitFetchStatusFrom(report, 'interval')); + const run = await this.ports.fetch( + folder, + { + dockerWorkspaces, + maxRepos: MAX_REPOS, + timeoutMs: DEFAULT_GIT_TIMEOUT_MS, + limiter: fetches, + }, + 'interval' + ); + // Every session on the folder gets the SAME run, with the trigger of the run that actually happened: + // if this tick joined a run a click had already started, the row has to say it was a click, because + // that is what fetched it. + for (const member of group) this.ports.publish(member.view.id, gitFetchStatusFrom(run.report, run.trigger)); } catch (err) { // The run that failed keeps its reason in the panel's own row; here only the folder-level failure is // worth a line, because nothing else would say a scheduled fetch could not run at all. diff --git a/src/git-fetch.ts b/src/git-fetch.ts index 4d054cd7b..6e5b01fd4 100644 --- a/src/git-fetch.ts +++ b/src/git-fetch.ts @@ -4,15 +4,17 @@ * (`src/git-fetch-scheduler.ts`). * * The status module (src/git-workspace-status.ts) is deliberately offline: "behind" is as of the last - * fetch, so a number that is stale can only be made current by fetching. This is the one place Codeman - * runs a git command that writes, and on a timer it is also the only one that can run without being asked. - * Nothing here commits, checks out, merges or touches the working tree: `git fetch` updates remote-tracking + * fetch, so a number that is stale can only be made current by fetching. This is the only git command the + * Git panel runs that writes, and the only git command Codeman runs on a timer. Cloning a repository and + * managing worktrees write too, but those are things the user asked for at the moment they asked; this is + * the one that can happen later, by itself. Nothing here commits, checks out, merges or touches the working + * tree: `git fetch` updates remote-tracking * refs and nothing else, and `FETCH_CONFIG_ARGS` keeps it from starting a gc in a repository an agent is * already using. It is not harmless in the way a read is: a fetch holds git's ref lock, so an agent's own * `git pull` in the same clone can collide with it (`cannot lock ref`), which is why one folder is fetched * by at most one run at a time (`fetchWorkspaceOnce`). * - * Same split as git-clone.ts: the pure half (`buildFetchArgs`, `describeFetchFailure`, + * Same split as git-clone.ts: the pure half (`buildFetchArgs`, `describeFetchFailure`, `fetchEnvFor`, * `gitFetchSettingsFrom`, `gitFetchStatusFrom`) holds every decision and tests without spawning, and the IO * half runs one bounded `git fetch --quiet` per repository. * @@ -27,19 +29,31 @@ * - One run per folder at a time: `fetchWorkspaceOnce` joins a run already going for the same folder * instead of starting a second one, because two fetches in one clone collide on the ref lock. * - Bounded: the git timeout from Settings → Bottom bar per repository, `MAX_REPOS_LIMIT` repositories at - * most, a hard `timeout` on each spawn, and `FETCH_CONCURRENCY` git processes at a time for the whole - * run — one `Limiter`, shared by the scheduler's tick, not one bound per folder. + * most, a hard wall-clock bound on each spawn, and `FETCH_CONCURRENCY` git processes at a time for the + * whole run — one `Limiter`, shared by the scheduler's tick, not one bound per folder. * - Non-interactive: `GIT_TERMINAL_PROMPT=0` and BatchMode ssh, so a private repository that needs a * login fails fast instead of hanging the request on an invisible username prompt. Codeman collects * no credentials; a repository the user's own git is already signed in to works because that is the - * user's existing setup, so unlike a clone no credential helper is disabled here. + * user's existing setup, so unlike a clone no credential helper is disabled here. That BatchMode ssh is + * a default, never an override: `fetchEnvFor` keeps whatever the repository's `core.sshCommand` or the + * user's own `GIT_SSH_COMMAND` already says, because on a machine with several ssh setups that choice + * is usually the one that works. ⚠️ The default still leaves a record: `StrictHostKeyChecking=accept-new` + * writes an unknown host key into `~/.ssh/known_hosts` without asking — BatchMode stops a prompt, not a + * write. + * - The timeout kills the PROCESS GROUP, not just the `git` parent. A fetch over http spawns + * `git-remote-http` children, and `execFile`'s `timeout` signals the parent only, so the answer can go + * out while a helper is still running — and on a timer that repeats every tick with nobody watching. + * `spawn` with `detached: true` puts the tree in its own group so the escalation (SIGTERM, then SIGKILL + * `FETCH_KILL_ESCALATION_MS` later) reaches the children too, which is why the negative pid is used + * rather than `child.kill()`. Same shape as `runGit` in git-clone.ts, without that module's slot queue: + * fetch is bounded by `FETCH_CONCURRENCY`, not by a clone queue. * - Failure text reaching a client is control-stripped and credential-redacted (`sanitizeGitOutput`), * and the browser writes it with `textContent` only. * * @module git-fetch */ -import { execFile } from 'node:child_process'; +import { execFile, spawn } from 'node:child_process'; import { basename, relative, resolve } from 'node:path'; import { promisify } from 'node:util'; import { classifyGitFailure, gitNonInteractiveEnv, sanitizeGitOutput, type GitFailureCode } from './git-clone.js'; @@ -63,6 +77,15 @@ const execFileAsync = promisify(execFile); export const FETCH_CONCURRENCY = 4; /** A fetch on a busy remote can print a lot; a bound on what we will hold. */ const MAX_FETCH_OUTPUT_BYTES = 2 * 1024 * 1024; +/** git's stderr is kept as a bounded tail (as in git-clone.ts): enough for the reason, not the whole stream. */ +const MAX_FETCH_STDERR_BYTES = 8_192; +/** A `git config --get` answer is one line; this is a sanity cap, not an expected size. */ +const MAX_SSH_COMMAND_BYTES = 4_096; +/** + * How long the process group gets to answer SIGTERM before the same escalation goes to SIGKILL. Kept as a + * const so a test can say how long it is willing to wait for the group to actually die. + */ +export const FETCH_KILL_ESCALATION_MS = 3_000; /** * Config overrides set ahead of the argv, so an unattended fetch cannot do more than move refs: no @@ -151,15 +174,148 @@ export function describeFetchFailure(err: unknown): FetchFailure { return failure; } -/** Fetch is allowed to write refs, so this runner does not carry `--no-optional-locks`: fetch takes a required lock anyway. */ +/** + * The environment a fetch gets, as a pure decision so it can be pinned without spawning git. + * + * `gitNonInteractiveEnv` supplies the BatchMode default, and the default is only a default: git reads + * `GIT_SSH_COMMAND` from the environment ahead of the repository's `core.sshCommand`, so setting it would + * override a repository that already chose its own ssh — an alternative host, an identity file, a jump box — + * and break a setup that works today. That is the only case where this module makes an ssh choice on the + * user's behalf, so it is the whole decision here: the caller supplies the value it read, and this decides + * between the user's environment and the repository's own answer. + */ +export function fetchEnvFor(base: NodeJS.ProcessEnv, repoSshCommand: string | null): NodeJS.ProcessEnv { + const env = gitNonInteractiveEnv(base); + // `gitNonInteractiveEnv` only writes the default when the environment had nothing, so this is exactly the + // case where it was a choice rather than a pass-through — and the repository's own answer wins over it. + if (!base.GIT_SSH_COMMAND && repoSshCommand) delete env.GIT_SSH_COMMAND; + return env; +} + +/** + * The repository's own `core.sshCommand`, or null. One bounded, read-only `git config --get` per fetch: + * git would honour that config itself if we did not write `GIT_SSH_COMMAND` over it, so honouring it means + * not writing one. `--get` reads local and global config, which is the value git would have used, and no + * hook or filter runs on the way to it. + * + * The read happens inside the same slot and the same wall-clock bound as the fetch it serves, so it cannot + * add a fifth git process or push a fetch past the user's timeout — it spends part of it. + */ +export async function readRepoSshCommand(cwd: string, timeoutMs: number): Promise<string | null> { + try { + const { stdout } = await execFileAsync('git', ['config', '--get', 'core.sshCommand'], { + cwd, + timeout: timeoutMs, + maxBuffer: MAX_SSH_COMMAND_BYTES, + env: gitNonInteractiveEnv(), + }); + return stdout.trim() || null; + } catch { + // A repository with no such setting, or a read that could not finish: the default is what git would have + // used anyway, and a fetch must not fail because the check for a config value failed. + return null; + } +} + +/** + * Fetch is allowed to write refs, so this runner does not carry `--no-optional-locks`: fetch takes a + * required lock anyway. + * + * `spawn`, not `execFile`, because the bound has to reach the whole tree (see the fileoverview): the + * caller is answered at the timeout, and the SIGTERM → SIGKILL escalation is deliberately NOT cancelled + * when the answer goes out, because the promise is a promise about the answer while the group still has to + * die. That is the difference from `runGit` in git-clone.ts, which disarms the escalation at `finish` and so + * can leave a `git-remote-http` child running after a timed-out call. + * + * The thrown error keeps the shape `describeFetchFailure` reads: `stderr` for the reason, `message` for a + * spawn failure (`spawn git ENOENT` → GIT_MISSING), `killed` and `code` for the timeout. A non-zero exit + * rejects the way `execFile` would — `code` is then the exit code — because resolving it would report a real + * failure as a clean run with an empty reason. + */ export const runFetch: GitRunner = async (cwd, args, opts) => { - const { stdout } = await execFileAsync('git', [...FETCH_CONFIG_ARGS, ...args], { - cwd, - timeout: opts?.timeoutMs ?? DEFAULT_GIT_TIMEOUT_MS, - maxBuffer: MAX_FETCH_OUTPUT_BYTES, - env: gitNonInteractiveEnv(), + const timeoutMs = opts?.timeoutMs ?? DEFAULT_GIT_TIMEOUT_MS; + const env = fetchEnvFor(process.env, await readRepoSshCommand(cwd, timeoutMs)); + + return new Promise<string>((resolve, reject) => { + let child: ReturnType<typeof spawn>; + try { + child = spawn('git', [...FETCH_CONFIG_ARGS, ...args], { + cwd, + env, + stdio: ['ignore', 'pipe', 'pipe'], + detached: true, + }); + } catch (err) { + reject(err); + return; + } + + let stdout = ''; + let stderr = ''; + let stdoutBytes = 0; + let timedOut = false; + let settled = false; + let killTimer: NodeJS.Timeout | undefined; + + const killTree = (signal: NodeJS.Signals) => { + try { + if (child.pid) process.kill(-child.pid, signal); + } catch { + try { + child.kill(signal); + } catch { + /* already gone */ + } + } + }; + + const fail = (spawnError?: string, exitCode?: number | null) => { + if (settled) return; + settled = true; + // Only the timeout timer is disarmed here. On a timeout the escalation is what the caller is waiting + // for, and cancelling it is the bug this runner exists to fix. + clearTimeout(timeoutTimer); + reject( + Object.assign(new Error(spawnError ?? `Command failed: git ${args.join(' ')}`), { + stderr, + killed: timedOut, + code: timedOut ? 'ETIMEDOUT' : (exitCode ?? undefined), + }) + ); + }; + + const timeoutTimer = setTimeout(() => { + timedOut = true; + killTree('SIGTERM'); + killTimer = setTimeout(() => killTree('SIGKILL'), FETCH_KILL_ESCALATION_MS); + fail(); + }, timeoutMs); + + child.stdout?.on('data', (chunk: Buffer) => { + stdoutBytes += chunk.length; + if (stdoutBytes <= MAX_FETCH_OUTPUT_BYTES) stdout += chunk.toString('utf-8'); + }); + child.stderr?.on('data', (chunk: Buffer) => { + stderr += chunk.toString('utf-8'); + if (stderr.length > MAX_FETCH_STDERR_BYTES * 2) stderr = stderr.slice(-MAX_FETCH_STDERR_BYTES); + }); + + const finish = (code: number | null) => { + if (settled) return; + // execFile's contract, which `describeFetchFailure` depends on: a non-zero exit rejects, so a real + // git failure keeps its reason in `stderr` instead of becoming a green row with an empty one. `null` + // means a signal, reachable only when something outside this runner killed the group — our own timeout + // has already answered by then, and `settled` above is what keeps that answer the answer. + if (code !== 0) return fail(undefined, code); + settled = true; + clearTimeout(timeoutTimer); + if (killTimer) clearTimeout(killTimer); + resolve(stdout); + }; + + child.on('error', (err) => fail(String(err))); + child.on('close', (code) => finish(code)); }); - return stdout; }; export interface GitFetchEntry { @@ -191,9 +347,12 @@ export interface GitFetchReport { * whether the user clicked or the interval fired, because a number the user did not ask for should read * differently from one they did. */ +/** What a run was, and why it happened. */ +export type GitFetchTrigger = 'click' | 'interval'; + export interface GitFetchStatus { ranAt: number; - trigger: 'click' | 'interval'; + trigger: GitFetchTrigger; /** As in the report: `ok`, or the state that stopped the run. */ state: GitFetchReport['state']; /** Repositories this run covered. */ @@ -204,7 +363,17 @@ export interface GitFetchStatus { firstFailure?: { name: string; code: FetchFailureCode }; } -export function gitFetchStatusFrom(report: GitFetchReport, trigger: GitFetchStatus['trigger']): GitFetchStatus { +/** + * One run, with the trigger that caused it. A joiner gets the run already going, so what it reports is that + * run's trigger rather than the one that made it ask: a click that joins an interval run did not fetch + * anything itself, and the row the panel shows has to describe the fetch that actually happened. + */ +export interface GitFetchRun { + report: GitFetchReport; + trigger: GitFetchTrigger; +} + +export function gitFetchStatusFrom(report: GitFetchReport, trigger: GitFetchTrigger): GitFetchStatus { const failed = report.repos.filter((r) => !r.ok); const status: GitFetchStatus = { ranAt: report.fetchedAt, @@ -283,25 +452,34 @@ export async function fetchGitWorkspaceOverview(cwd: string, opts: GitFetchOptio * Runs already going, keyed by the resolved folder. A request for a folder another run is already working on * JOINS that run instead of starting a second `git fetch` in the same clone: two fetches there collide on * git's ref lock (`cannot lock ref 'refs/remotes/origin/main'`), which is what a click during a tick would - * otherwise do. A joiner gets the run already going's answer, including its limits — the point is not to - * fetch twice, not to fetch with the freshest options. + * otherwise do. A joiner gets the run already going's answer, including its limits and its trigger — the point + * is not to fetch twice, not to fetch with the freshest options, and a click that fetched nothing cannot + * report itself as the fetch. * * Keyed by `resolve`, not `realpath`, because `realpath` is itself one of the unbounded fs calls rule #516 * bounds for user-chosen paths; the scheduler probes the folder bounded and calls this. Two paths that * resolve the same are the same folder here; a workspace reached through a symlink resolves to the link, so * a link and its target can be fetched by two runs — the accepted trade for keeping the probe bounded. */ -const lane = new Map<string, Promise<GitFetchReport>>(); +const lane = new Map<string, Promise<GitFetchRun>>(); -export async function fetchWorkspaceOnce(folder: string, opts: GitFetchOptions = {}): Promise<GitFetchReport> { +export async function fetchWorkspaceOnce( + folder: string, + opts: GitFetchOptions = {}, + trigger: GitFetchTrigger +): Promise<GitFetchRun> { const key = resolve(folder); const going = lane.get(key); + // The joiner is handed the run already going as it is, trigger included: it did not cause that fetch, so it + // must not report itself as having. if (going) return going; - const run = fetchGitWorkspaceOverview(key, opts).finally(() => { - // Only the run that is still the lane's own: a later run for the same folder must not be dropped by an - // earlier one finishing late. - if (lane.get(key) === run) lane.delete(key); - }); + const run: Promise<GitFetchRun> = fetchGitWorkspaceOverview(key, opts) + .then((report) => ({ report, trigger })) + .finally(() => { + // Only the run that is still the lane's own: a later run for the same folder must not be dropped by an + // earlier one finishing late. + if (lane.get(key) === run) lane.delete(key); + }); lane.set(key, run); return run; } diff --git a/src/web/public/git-status-ui.js b/src/web/public/git-status-ui.js index d427d013a..c0843aa5d 100644 --- a/src/web/public/git-status-ui.js +++ b/src/web/public/git-status-ui.js @@ -11,8 +11,10 @@ * `GET /api/sessions/:id/git-status` for the active session on a slow poll (and at once when the * session changes or the window regains focus). The status route is read-only: it never changes the * repository, so the "behind" number reflects the last `git fetch`, which the panel footer says. Fetching - * happens only when the user clicks Fetch in that window's header (`POST /api/sessions/:id/git-fetch`, - * src/git-fetch.ts): the only git command Codeman runs that writes, and only on that click. Remote (SSH) + * happens when the user clicks Fetch in that window's header (`POST /api/sessions/:id/git-fetch`, + * src/git-fetch.ts), or on the interval when the session is on: this is the only git command the Git panel + * runs that writes, and the only one Codeman runs on a timer — cloning and worktree management write too, + * but only when the user asked for them at that moment. Remote (SSH) * and Docker sessions answer `unsupported`, show no indicator, and cannot be fetched. * * Everything that comes from git (file names, commit subjects, author names) is untrusted text: it is @@ -269,10 +271,13 @@ Object.assign(CodemanApp.prototype, { /** * Fetch, on this click only: one `git fetch --quiet` per repository the panel shows - * (`POST /api/sessions/:id/git-fetch`). It is the only git command Codeman runs that writes, and it - * updates remote-tracking refs only, so an agent working in the same repository is not disturbed. One - * click means one fetch per repository: the button is busy and a second click is ignored while it runs. - * The panel then re-reads, so the "behind" number reflects what this fetch brought. + * (`POST /api/sessions/:id/git-fetch`). It is the only git command the Git panel runs that writes, and the + * only one Codeman runs on a timer, and it updates remote-tracking refs only: nothing here commits or + * touches the working tree, so an agent's own work is left alone — though a fetch does hold git's ref lock, + * so an agent's `git pull` in the same repository can hit the same `cannot lock ref` while it runs. One + * click means one fetch per repository: the button is busy and a second click is ignored while it runs, and + * a click that joins a fetch already running reports that run, not itself. The panel then re-reads, so the + * "behind" number reflects what this fetch brought. */ async fetchGitStatusNow() { const sid = this.activeSessionId || null; diff --git a/src/web/public/i18n.js b/src/web/public/i18n.js index d1ad7edc2..b792cdf5c 100644 --- a/src/web/public/i18n.js +++ b/src/web/public/i18n.js @@ -520,12 +520,13 @@ 'Connection tile: offline': '离线', 'Connection tile: queued': '已排队', 'Connection tile: retry': '重连中', - // App Settings → Bottom bar, translated as one group (the Git status rows, - // #543's two included). Keys are the trimmed label text, without the scope tag. + // App Settings → Bottom bar, translated as one group (the Git status rows, #543's two included, and the + // two Git fetch rows, in the order the page lists them). Keys are the trimmed label text, without the + // scope tag. The Session Options → Git strings are in that group's own block further down. 'Bottom bar': '底部栏', 'Git status': 'Git 状态', - "Shows, at the right of the bottom bar, when the active session's repository (or each repository inside its folder, up to two levels down) has uncommitted files or commits that are not pushed. Click it for the list. Read-only apart from Fetch: Codeman never commits or changes the repository, and `git fetch` — the one git command here that writes — runs only when you click Fetch in that window, or on the interval below if you turned it on. Not shown for Docker or remote sessions. Off by default.": - '在底部栏右侧显示当前会话的仓库(或其文件夹内向下两层以内的每个仓库)是否有未提交的文件或未推送的提交。点击可查看列表。除拉取外只读:{name} 从不提交或更改仓库,而拉取(这里唯一会写入的 git 命令)只在您点击该窗口中的拉取按钮时运行,或在您开启了下方的定时后按间隔运行。Docker 和远程会话不显示。默认关闭。', + "Shows, at the right of the bottom bar, when the active session's repository (or each repository inside its folder, up to two levels down) has uncommitted files or commits that are not pushed. Click it for the list. Read-only apart from Fetch: Codeman never commits or changes the repository, and `git fetch` — the only git command the Git window runs that writes, and the only one Codeman runs on a timer (cloning and worktree management write too, but only when you asked for them at the moment you asked) — runs when you click Fetch in that window, or on the interval below if you turned it on. Not shown for Docker or remote sessions. Off by default.": + '在底部栏右侧显示当前会话的仓库(或其文件夹内向下两层以内的每个仓库)是否有未提交的文件或未推送的提交。点击可查看列表。除拉取外只读:{name} 从不提交或更改仓库,而拉取是 Git 窗口运行的唯一会写入的 git 命令,也是 {name} 唯一会按间隔自行运行的命令(克隆和工作树管理也会写入,但只在您当时要求时才运行),它只在您点击该窗口中的拉取按钮时运行,或在您开启了下方的定时后按间隔运行。Docker 和远程会话不显示。默认关闭。', 'Git status: group files by folder': 'Git 状态:按文件夹分组显示文件', 'In the Git window, show changed files under their folders, collapsed until you click a folder. Off lists every file by its full path. On by default.': '在 Git 窗口中,将更改的文件显示在各自的文件夹下,点击文件夹前保持折叠。关闭时按完整路径列出每个文件。默认开启。', @@ -535,32 +536,15 @@ 'Git status: git timeout': 'Git 状态:git 超时', 'Seconds one git command may run before that repository is reported as unreadable (5 to 120, default 30). Raise it for repositories on a slow network share.': '单条 git 命令可运行的秒数,超时后该仓库会被报告为无法读取(5 到 120,默认 30)。仓库位于较慢的网络共享上时请调高此值。', - 'Fetch git status': '拉取 Git 状态', - 'Refresh git status': '刷新 Git 状态', - 'Close git status': '关闭 Git 状态', - // Git fetch on an interval (App Settings → Bottom bar, Session Options → Git, and the save toast in - // session-ui.js). 'Status' and 'Git' are not added: the first already has a key, the second stays a - // technical name, and the status row's text is written at runtime. - "Whether Codeman fetches this session's repositories on a timer.": '{name} 是否按间隔拉取此会话的仓库。', 'Git fetch on an interval': 'Git 定时拉取', - 'Runs `git fetch` for the repositories the Git window lists on a timer, so the "behind" number is current without a click. Off by default: this is the one git command Codeman runs that writes, and the one that runs a repository\'s own hooks, so nothing runs on a timer until you turn it on. A session can override both the switch and the interval in Session Options → Git.': - '按间隔为 Git 窗口列出的仓库运行拉取,这样“落后”的数字无需点击就是最新的。默认关闭:这是 {name} 运行的唯一会写入的 git 命令,也是唯一会运行仓库自身钩子的命令,所以在您开启之前不会有任何定时运行。会话可以在“会话选项 → Git”中同时覆盖开关和间隔。', + 'Runs `git fetch` for the repositories the Git window lists on a timer, so the "behind" number is current without a click. Off by default: this is the only git command the Git panel runs that writes, and the only one Codeman runs on a timer (cloning and worktree management write too, but only when you asked for them at the moment you asked), so nothing runs until you turn it on. A session can override both the switch and the interval in Session Options → Git.': + '按间隔为 Git 窗口列出的仓库运行拉取,这样“落后”的数字无需点击就是最新的。默认关闭:这是 {name} 运行的唯一会写入的 git 命令,也是唯一会按间隔自行运行的命令(克隆和工作树管理也会写入,但只在您当时要求时才运行),所以在您开启之前不会有任何运行。会话可以在“会话选项 → Git”中同时覆盖开关和间隔。', 'Git fetch: how often': 'Git 定时拉取:多久一次', 'Seconds between runs for a session that has no override of its own (30 to 3600, default 300). Only a session that is on — here, or in Session Options → Git — is fetched, and sessions sharing one folder are fetched by one run.': '没有自己覆盖设置的会话,两次运行之间的秒数(30 到 3600,默认 300)。只有已开启的会话——这里,或在“会话选项 → Git”——才会被拉取,共用一个文件夹的会话由一次运行拉取。', - 'Fetch on an interval': '按间隔拉取', - "Fetching is the only git command Codeman runs that writes, and the only one that runs a repository's own hooks, so nothing runs on a timer until you turn it on — here, or in App Settings → Bottom bar. The panel's Fetch button is always just a click.": - '拉取是 {name} 运行的唯一会写入的 git 命令,也是唯一会运行仓库自身钩子的命令,所以在您开启之前(这里,或“应用设置 → 底部栏”)不会有任何定时运行。该窗口中的拉取按钮始终只是一次点击。', - 'Follow the app setting': '跟随应用设置', - 'Uses the app-wide switch and interval for this session. Switch it off to pick your own.': - '为此会话使用应用级的开关和间隔。关闭它即可自行选择。', - 'Fetch this session': '拉取此会话', - "Runs on its own even while the app-wide switch is off: a session that turned itself on means exactly that. Sessions sharing one folder share one run, and each sees that run's result.": - '即使应用级开关关闭也自行运行:一个自己开启的会话正是这个意思。共用一个文件夹的会话共享一次运行,每个会话都会看到该运行的结果。', - 'How often': '多久一次', - '30 sec to 1 hr, timed from the start of a run, so a slow repository does not push the next one out.': - '30 秒到 1 小时,从一次运行的开始计时,所以较慢的仓库不会把下一次推远。', - 'Git fetch settings saved': 'Git 定时拉取设置已保存', + 'Fetch git status': '拉取 Git 状态', + 'Refresh git status': '刷新 Git 状态', + 'Close git status': '关闭 Git 状态', Panels: '面板', Monitor: '监视器', 'Project Insights': '项目洞察', @@ -857,6 +841,24 @@ Memory: '内存', GPUs: 'GPU', + // Session Options → Git (the tab's blurb, its group head, its hint and its three rows in index.html, plus + // the save toast session-ui.js raises). 'Status' is not a key here: that row's text is built at runtime + // by updateGitFetchStatus, and 'Git' stays a technical name. + "Whether Codeman fetches this session's repositories on a timer.": '{name} 是否按间隔拉取此会话的仓库。', + 'Fetch on an interval': '按间隔拉取', + "Fetching is the only git command the Git panel runs that writes, and the only one Codeman runs on a timer, so nothing runs until you turn it on — here, or in App Settings → Bottom bar. Cloning and worktree management write too, but only when you asked for them at the moment you asked. The panel's Fetch button is always just a click.": + '拉取是 Git 窗口运行的唯一会写入的 git 命令,也是 {name} 唯一会按间隔自行运行的命令,所以在您开启之前(这里,或“应用设置 → 底部栏”)不会有任何运行。克隆和工作树管理也会写入,但只在您当时要求时才运行。该窗口中的拉取按钮始终只是一次点击。', + 'Follow the app setting': '跟随应用设置', + 'Uses the app-wide switch and interval for this session. Switch it off to pick your own.': + '为此会话使用应用级的开关和间隔。关闭它即可自行选择。', + 'Fetch this session': '拉取此会话', + "Runs on its own even while the app-wide switch is off: a session that turned itself on means exactly that. Sessions sharing one folder share one run, and each sees that run's result.": + '即使应用级开关关闭也自行运行:一个自己开启的会话正是这个意思。共用一个文件夹的会话共享一次运行,每个会话都会看到该运行的结果。', + 'How often': '多久一次', + '30 sec to 1 hr, timed from the start of a run, so a slow repository does not push the next one out.': + '30 秒到 1 小时,从一次运行的开始计时,所以较慢的仓库不会把下一次推远。', + 'Git fetch settings saved': 'Git 定时拉取设置已保存', + // Cron / lifecycle / panels 'Cron Jobs': '定时任务', '+ New Job': '+ 新建任务', diff --git a/src/web/public/index.html b/src/web/public/index.html index 2c4779e1f..84e06eb7b 100644 --- a/src/web/public/index.html +++ b/src/web/public/index.html @@ -1738,7 +1738,7 @@ <h2>Git</h2> <div class="set-group"> <div class="set-group-head"><h4>Fetch on an interval</h4></div> - <p class="set-group-hint">Fetching is the only git command Codeman runs that writes, and the only one that runs a repository's own hooks, so nothing runs on a timer until you turn it on — here, or in App Settings → Bottom bar. The panel's Fetch button is always just a click.</p> + <p class="set-group-hint">Fetching is the only git command the Git panel runs that writes, and the only one Codeman runs on a timer, so nothing runs until you turn it on — here, or in App Settings → Bottom bar. Cloning and worktree management write too, but only when you asked for them at the moment you asked. The panel's Fetch button is always just a click.</p> <div class="set-group-body"> <!-- Live from the session's own last run (session-ui.js: updateGitFetchStatus), which is the run its folder got: several sessions on one folder share one run and read the same row. --> @@ -2252,7 +2252,7 @@ <h2>Header & Panels</h2> <div class="set-row" data-search="git status uncommitted unpushed commit push indicator bottom bar toolbar"> <div class="set-row-text"> <span class="set-row-label">Git status <span class="set-scope">device</span></span> - <span class="set-row-desc">Shows, at the right of the bottom bar, when the active session's repository (or each repository inside its folder, up to two levels down) has uncommitted files or commits that are not pushed. Click it for the list. Read-only apart from Fetch: Codeman never commits or changes the repository, and `git fetch` — the one git command here that writes — runs only when you click Fetch in that window, or on the interval below if you turned it on. Not shown for Docker or remote sessions. Off by default.</span> + <span class="set-row-desc">Shows, at the right of the bottom bar, when the active session's repository (or each repository inside its folder, up to two levels down) has uncommitted files or commits that are not pushed. Click it for the list. Read-only apart from Fetch: Codeman never commits or changes the repository, and `git fetch` — the only git command the Git window runs that writes, and the only one Codeman runs on a timer (cloning and worktree management write too, but only when you asked for them at the moment you asked) — runs when you click Fetch in that window, or on the interval below if you turned it on. Not shown for Docker or remote sessions. Off by default.</span> </div> <label class="switch switch-sm"><input type="checkbox" id="appSettingsShowGitStatus"><span class="slider"></span></label> </div> @@ -2280,7 +2280,7 @@ <h2>Header & Panels</h2> <div class="set-row" data-search="git fetch interval timer behind remote tracking refs poll run automatically"> <div class="set-row-text"> <span class="set-row-label">Git fetch on an interval <span class="set-scope">synced</span></span> - <span class="set-row-desc">Runs `git fetch` for the repositories the Git window lists on a timer, so the "behind" number is current without a click. Off by default: this is the one git command Codeman runs that writes, and the one that runs a repository's own hooks, so nothing runs on a timer until you turn it on. A session can override both the switch and the interval in Session Options → Git.</span> + <span class="set-row-desc">Runs `git fetch` for the repositories the Git window lists on a timer, so the "behind" number is current without a click. Off by default: this is the only git command the Git panel runs that writes, and the only one Codeman runs on a timer (cloning and worktree management write too, but only when you asked for them at the moment you asked), so nothing runs until you turn it on. A session can override both the switch and the interval in Session Options → Git.</span> </div> <label class="switch switch-sm"><input type="checkbox" id="appSettingsGitFetchEnabled"><span class="slider"></span></label> </div> diff --git a/src/web/routes/git-status-routes.ts b/src/web/routes/git-status-routes.ts index 1e078c6e8..4bc9200ad 100644 --- a/src/web/routes/git-status-routes.ts +++ b/src/web/routes/git-status-routes.ts @@ -132,11 +132,13 @@ export function registerGitStatusRoutes( // Fetch, and only because the user clicked Fetch in the panel (src/git-fetch.ts): one // `git fetch --quiet` per repository the panel would show, through the same resolver so the two cannot - // disagree about which repositories belong to the workspace. This is the only git command Codeman runs - // that writes, and it writes remote-tracking refs only — nothing commits, checks out or merges. It is not - // as harmless as a read though: a fetch holds git's ref lock, so an agent's own `git pull` in the same - // clone can collide with it, which is why the run goes through `fetchWorkspaceOnce` — a click while a tick - // is already fetching this folder joins that run instead of starting a second one. It can also run a + // disagree about which repositories belong to the workspace. This is the only git command the Git panel runs + // that writes, and it writes remote-tracking refs only — nothing commits, checks out or merges. Cloning and + // worktree management write too, but those happen when the user asked for them; this route is the one that + // can also run later, on a timer. It is not as harmless as a read though: a fetch holds git's ref lock, so + // an agent's own `git pull` in the same clone can collide with it, which is why the run goes through + // `fetchWorkspaceOnce` — a click while a tick is already fetching this folder joins that run instead of + // starting a second one. It can also run a // repository's own hooks (`reference-transaction`, `pre-auto-gc` through the maintenance git runs after a // fetch) and its config commands such as `credential.helper`, so the `FETCH_CONFIG_ARGS` overrides turn // maintenance and gc off, and a repository a container can write to is refused before any git runs. Remote @@ -148,16 +150,18 @@ 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' }) }; - const report = await fetchWorkspaceOnce(session.workingDir, { - git, - ...limits, - dockerWorkspaces: await dockerWorkspaces(), - }); + const run = await fetchWorkspaceOnce( + session.workingDir, + { git, ...limits, dockerWorkspaces: await dockerWorkspaces() }, + 'click' + ); // The response answers the click, and the run is also the session's current truth: what the panel's // "behind" number is based on. Publishing it is what the api docs say a click does, and what makes a - // second tab open on the same folder see the same run — including a click that joined a tick already - // going, where the row has to say who last fetched. - if (session.setGitFetchStatus(gitFetchStatusFrom(report, 'click'))) persistAndBroadcastSession(ctx, session); - return { success: true, data: report }; + // second tab open on the same folder see the same run. The trigger recorded is the RUN's, not this + // request's: a click that joined a tick already going fetched nothing itself, so the row has to name the + // interval as what fetched. + if (session.setGitFetchStatus(gitFetchStatusFrom(run.report, run.trigger))) + persistAndBroadcastSession(ctx, session); + return { success: true, data: run.report }; }); } diff --git a/src/web/schemas.ts b/src/web/schemas.ts index ec121450f..569a40d2f 100644 --- a/src/web/schemas.ts +++ b/src/web/schemas.ts @@ -1491,10 +1491,11 @@ export const SettingsUpdateSchema = z /** * Git fetch on an interval (src/git-fetch-scheduler.ts): `git fetch` for the repositories the Git panel * lists, run on a timer instead of only on the panel's Fetch click. SYNCED, default OFF, because the - * server is what runs it: `git fetch` is the only git command Codeman runs that writes, and the only - * one that can run a repository's own hooks (`reference-transaction`, `pre-auto-gc` through the - * automatic maintenance a fetch triggers, config commands such as `credential.helper`), so nothing runs - * on a timer until the user turns this on. Cap in seconds between runs (30 to 3600, default 300). A + * server is what runs it: `git fetch` is the only git command the Git panel runs that writes — cloning + * and worktree management write too, but only when the user asked for them at the moment they asked — and + * the only one that can run later by itself, on a timer, and the only one that can run a repository's own + * hooks (`reference-transaction`, `pre-auto-gc` through the automatic maintenance a fetch triggers, config + * commands such as `credential.helper`), so nothing runs on a timer until the user turns this on. Cap in seconds between runs (30 to 3600, default 300). A * session can override both in Session Options → Git; `null` there clears the override, and sessions * sharing one folder are fetched by one run. */ diff --git a/src/web/server.ts b/src/web/server.ts index 54961d1dc..798d32082 100644 --- a/src/web/server.ts +++ b/src/web/server.ts @@ -410,8 +410,9 @@ export class WebServer extends EventEmitter { // never be handed `pastCap`. A folder on a mount that stopped answering is skipped, not fetched. probePath: (path) => probePathKind(path), // Through the join, so the tick and the panel's Fetch button are the same run: a click on a folder the - // tick is already fetching joins it rather than starting a second `git fetch` in the same clone. - fetch: (cwd, opts) => fetchWorkspaceOnce(cwd, opts), + // tick is already fetching joins it rather than starting a second `git fetch` in the same clone — and the + // run that gets published is the one that ran, with its own trigger. + fetch: (cwd, opts, trigger) => fetchWorkspaceOnce(cwd, opts, trigger), publish: (id, status) => { const session = this.sessions.get(id); if (!session?.setGitFetchStatus(status)) return; diff --git a/test/git-fetch-scheduler.test.ts b/test/git-fetch-scheduler.test.ts index ad6932ac9..e38d9e1fd 100644 --- a/test/git-fetch-scheduler.test.ts +++ b/test/git-fetch-scheduler.test.ts @@ -5,7 +5,9 @@ * * The run belongs to the FOLDER, so these tests put two sessions on one folder and expect one fetch that * both see, and they check the tick's two bounds separately: one shared bound for the fetches of the whole - * tick, and the folders bounded by a bound that cannot be the same semaphore. + * tick, and the folders bounded by a bound that cannot be the same semaphore. A folder is due when ANY session + * open on it is due, so the order the sessions were read cannot decide it — that is checked in both orders. + * A run carries the trigger that caused it, and what is published says the run's, not the tick's. * * Port: N/A. */ @@ -20,8 +22,10 @@ import { FETCH_CONCURRENCY, type GitFetchOptions, type GitFetchReport, + type GitFetchRun, type GitFetchSettings, type GitFetchStatus, + type GitFetchTrigger, } from '../src/git-fetch.js'; import { DEFAULT_GIT_TIMEOUT_MS, MAX_REPOS } from '../src/git-workspace-status.js'; import type { PathProbeKind } from '../src/utils/bounded-path-probe.js'; @@ -41,11 +45,17 @@ const report = (cwd: string, name: string): GitFetchReport => ({ fetchedAt: Date.now(), }); +/** A run: the report plus the trigger that caused it, which is what the published row has to name. */ +const run = (cwd: string, name: string, trigger: GitFetchTrigger = 'interval'): GitFetchRun => ({ + report: report(cwd, name), + trigger, +}); + type Harness = { scheduler: GitFetchScheduler; ports: GitFetchPorts; - /** The folders the tick actually ran, in the order they were picked. */ - calls: Array<{ cwd: string; opts: GitFetchOptions }>; + /** The folders the tick actually ran, in the order they were picked, with the trigger the tick passed. */ + calls: Array<{ cwd: string; opts: GitFetchOptions; trigger: GitFetchTrigger }>; published: Array<{ id: string; status: GitFetchStatus }>; /** Every path the tick probed, in order — the probe has to happen before anything is fetched. */ probed: string[]; @@ -74,7 +84,7 @@ function harness( sessions: GitFetchSessionView[], opts: { settings?: GitFetchSettings; - fetch?: (cwd: string, portOpts: GitFetchOptions) => Promise<GitFetchReport>; + fetch?: (cwd: string, portOpts: GitFetchOptions, trigger: GitFetchTrigger) => Promise<GitFetchRun>; /** Default: every path answers as a folder, so a test that does not care about the probe stays quiet. */ probe?: (path: string) => Promise<PathProbeKind>; } = {} @@ -110,9 +120,9 @@ function harness( h.probed.push(path); return opts.probe ? await opts.probe(path) : 'directory'; }, - fetch: async (cwd, portOpts) => { - h.calls.push({ cwd, opts: portOpts }); - return customFetch ? customFetch(cwd, portOpts) : report(cwd, cwd.slice(3)); + fetch: async (cwd, portOpts, trigger) => { + h.calls.push({ cwd, opts: portOpts, trigger }); + return customFetch ? customFetch(cwd, portOpts, trigger) : run(cwd, cwd.slice(3), trigger); }, publish: (id, status) => h.published.push({ id, status }), }; @@ -245,9 +255,45 @@ describe('one folder, one run', () => { expect(new Set(h.published.map((p) => p.status.failed)).size).toBe(1); }); + it('is due when ANY session open on the folder is due, in either read order, because the clock belongs to the folder and not to whichever session was read first', async () => { + const short = { ...VIEW('short'), workingDir: '/w/shared', gitFetchIntervalSeconds: 30 }; + const long = { ...VIEW('long'), workingDir: '/w/shared', gitFetchIntervalSeconds: 300 }; + + for (const order of [ + [short, long], + [long, short], + ]) { + const h = harness(order); + + // Nothing has run yet, so both are due and both are published from the one run. The folder is probed + // once, not once per session open on it. + expect(await h.scheduler.tick(0)).toBe(1); + expect(h.probed).toEqual(['/w/shared']); + expect(h.published.map((p) => p.id).sort()).toEqual(['long', 'short']); + + // Before the short interval elapses neither is due, and the order they were read in cannot change that. + h.published.length = 0; + expect(await h.scheduler.tick(15_000)).toBe(0); + expect(h.published).toEqual([]); + + // The short session is past its own interval, so the folder is fetched — once, for both. The run that + // happens covers the long session's interval too, and both still get it because both are open on the + // folder: publishing only the session that made the folder due would leave the other tab on a stale row. + h.published.length = 0; + expect(await h.scheduler.tick(30_000)).toBe(1); + expect(h.calls.length).toBe(2); + expect(h.published.map((p) => p.id).sort()).toEqual(['long', 'short']); + + // The long one's own interval comes round, so the folder is due again. + h.published.length = 0; + expect(await h.scheduler.tick(300_000)).toBe(1); + expect(h.published.map((p) => p.id).sort()).toEqual(['long', 'short']); + } + }); + it('does not pick a folder again while its run is going, and does not run a second tick over the same selection', async () => { const ids = Array.from({ length: FETCH_CONCURRENCY + 2 }, (_, i) => `s${i}`); - const gate = deferred<GitFetchReport>(report('/w/first', 'first')); + const gate = deferred<GitFetchRun>(run('/w/first', 'first')); const h = harness( ids.map((id) => VIEW(id)), { @@ -266,7 +312,7 @@ describe('one folder, one run', () => { expect(await h.scheduler.tick(15_000)).toBe(0); expect(h.calls.length).toBe(picked); - gate.resolve(report('/w/first', 'first')); + gate.resolve(run('/w/first', 'first')); await first; // Every folder ran exactly once, and no folder ran twice. expect(h.calls.length).toBe(ids.length); @@ -299,7 +345,7 @@ describe('one folder, one run', () => { fetchesLive--; }); foldersLive--; - return report(cwd, cwd.slice(3)); + return run(cwd, cwd.slice(3)); }, } ); @@ -310,7 +356,7 @@ describe('one folder, one run', () => { }); it('times the interval from the START of a run, so a slow repository does not push the next one out', async () => { - const slow = deferred<GitFetchReport>(report('/w/a', 'a')); + const slow = deferred<GitFetchRun>(run('/w/a', 'a')); const h = harness([VIEW('a')], { settings: { enabled: true, intervalSeconds: 30 }, fetch: () => slow.promise }); const first = h.scheduler.tick(0); @@ -319,7 +365,7 @@ describe('one folder, one run', () => { // The run takes 200 s, longer than its own interval. Once it lands, the folder is already past due: the // next tick runs it again instead of waiting another 30 s from when the slow one finished. - slow.resolve(report('/w/a', 'a')); + slow.resolve(run('/w/a', 'a')); await first; expect(await h.scheduler.tick(200_000)).toBe(1); expect(h.calls.length).toBe(2); @@ -338,6 +384,19 @@ describe('what a run reports', () => { ]); }); + it('publishes the trigger of the run that actually happened rather than the trigger the tick asked for, because a tick that joined a click did not fetch anything', async () => { + const h = harness([VIEW('a')], { + // The port is `fetchWorkspaceOnce`, so it can hand back a run a click had already started for this + // folder. The tick asked for its own; the row has to name the run. + fetch: (cwd, _opts, trigger) => { + expect(trigger).toBe('interval'); + return Promise.resolve({ report: report(cwd, 'a'), trigger: 'click' }); + }, + }); + await h.scheduler.tick(0); + expect(h.published[0].status.trigger).toBe('click'); + }); + it("reports a failure instead of retrying harder, and keeps the folder's next run at its own interval", async () => { const h = harness([VIEW('a')], { settings: { enabled: true, intervalSeconds: 30 }, diff --git a/test/git-fetch.test.ts b/test/git-fetch.test.ts index 6444a3eb8..8cf58a4ae 100644 --- a/test/git-fetch.test.ts +++ b/test/git-fetch.test.ts @@ -1,26 +1,35 @@ /** - * @fileoverview Tests for the Git panel's Fetch button (src/git-fetch.ts): the one git command Codeman - * runs that writes, and the one that can run a repository's own hooks — which is why it is a click, not a - * poll. + * @fileoverview Tests for the Git panel's Fetch button (src/git-fetch.ts): the only git command the Git panel + * runs that writes, and the only one that can run without being asked — which is why it is a click, or an + * interval the user turned on, and why the writing and the hooks it can run are pinned here. Cloning and + * worktree management write too, but they write when the user asked. * - * Three halves, mirroring the module: + * Four halves, mirroring the module: * - * 1. The PURE half — the fixed argv, the `-c` overrides the runner adds ahead of it, the failure - * classification, and the two reads the interval scheduler depends on: what the settings say - * (`gitFetchSettingsFrom`) and what a run did (`gitFetchStatusFrom`). No spawning. + * 1. The PURE half — the fixed argv, the `-c` overrides the runner adds ahead of it, the ssh environment the + * runner refuses to override (`fetchEnvFor`), the failure classification, and the two reads the interval + * scheduler depends on: what the settings say (`gitFetchSettingsFrom`) and what a run did + * (`gitFetchStatusFrom`). No spawning. * 2. The IO half — a REAL git fetching a REAL local bare repo, so that "remote-tracking refs only" is * proven against git's actual behavior: HEAD, the index and the working tree must be exactly what they * were while the remote-tracking ref moves. The rest of the half uses an injected runner over real * repositories on disk, because what is being pinned is WHICH repositories git is allowed to touch. - * 3. The join (`fetchWorkspaceOnce`) — one run per folder at a time, because two fetches in one clone - * collide on git's ref lock, and the bound is ONE limiter shared with the scheduler's tick rather than a - * bound each run gets for itself. + * 3. The process group — a timed-out fetch has to take its `git-remote-http` helper with it, against a + * server that accepts and never answers, so the assertion is about what is still RUNNING after the answer + * went out. A test that only checked the returned error would pass with the old `execFile` runner, which + * signals the parent only. + * 4. The join (`fetchWorkspaceOnce`) — one run per folder at a time, because two fetches in one clone + * collide on git's ref lock, the bound is ONE limiter shared with the scheduler's tick rather than a bound + * each run gets for itself, and the trigger a join reports is the joined run's, not the joiner's. * - * Port: N/A (no server). + * Port: N/A (no Codeman server). The process-group test listens on its own loopback socket, on a port the OS + * picks, because the point of that test is a remote that never answers. */ import { execFileSync } from 'node:child_process'; -import { mkdirSync, mkdtempSync, readdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; +import { existsSync, mkdirSync, mkdtempSync, readdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; +import { createServer, type AddressInfo } from 'node:http'; +import type { Socket } from 'node:net'; import { tmpdir } from 'node:os'; import { basename, join } from 'node:path'; import { afterEach, beforeEach, describe, expect, it } from 'vitest'; @@ -28,13 +37,16 @@ import { classifyGitFailure, isGitAvailable } from '../src/git-clone.js'; import { FETCH_CONCURRENCY, FETCH_CONFIG_ARGS, + FETCH_KILL_ESCALATION_MS, buildFetchArgs, clearGitFetchLane, describeFetchFailure, + fetchEnvFor, fetchGitWorkspaceOverview, fetchWorkspaceOnce, gitFetchSettingsFrom, gitFetchStatusFrom, + runFetch, type GitFetchReport, } from '../src/git-fetch.js'; import { createLimiter, runGit, type GitRunner } from '../src/git-workspace-status.js'; @@ -212,6 +224,30 @@ describe('FETCH_CONFIG_ARGS', () => { }); }); +describe('fetchEnvFor', () => { + const BASE = { HOME: '/home/t', PATH: '/usr/bin' }; + + it('writes the BatchMode default when nothing else already answered', () => { + const env = fetchEnvFor(BASE, null); + expect(env.GIT_SSH_COMMAND).toContain('BatchMode=yes'); + expect(env.GIT_TERMINAL_PROMPT).toBe('0'); + }); + + it('does not override a repository that already chose its own ssh, because that choice is usually the one that works', () => { + const env = fetchEnvFor(BASE, 'ssh -i /home/t/work_key -o ProxyJump=gw'); + // The whole point: nothing is written, so git takes the repository's value from its own config. + expect(env.GIT_SSH_COMMAND).toBeUndefined(); + // Honouring the repository's ssh is not the same as letting it prompt, so the rest still applies. + expect(env.GIT_TERMINAL_PROMPT).toBe('0'); + expect(env.SSH_ASKPASS_REQUIRE).toBe('never'); + }); + + it("the user's own GIT_SSH_COMMAND still beats the repository's, because that is git's rule and not this module's to second-guess", () => { + const env = fetchEnvFor({ ...BASE, GIT_SSH_COMMAND: 'ssh -i /home/t/personal' }, 'ssh -i /repo/key'); + expect(env.GIT_SSH_COMMAND).toBe('ssh -i /home/t/personal'); + }); +}); + // ─── Real git, real local repository ───────────────────────────────────────── const gitPresent = isGitAvailable(); @@ -385,6 +421,143 @@ describe.skipIf(!gitPresent)('fetchGitWorkspaceOverview (real git)', () => { }); }); +// ─── The whole tree, not just the git parent ───────────────────────────────── + +/** One process as /proc reports it. `null` means it is gone, which is what the last assertion is about. */ +interface Proc { + pid: number; + comm: string; + ppid: number; + /** The PROCESS GROUP. `detached: true` puts the fetch in its own, so the negative pid reaches the children. */ + pgrp: number; + cmdline: string; +} + +/** + * Every process on the machine, as /proc reports it — so this half can only run on Linux, and the fields are + * counted from the closing parenthesis because `comm` can hold spaces. + * + * Membership of the GROUP is what is checked, not parentage: a helper that outlives its parent stops being a + * child of this test process, and a test that looked for children would pass for the wrong reason. + */ +function scanProc(): Proc[] { + const out: Proc[] = []; + for (const entry of readdirSync('/proc')) { + if (!/^\d+$/.test(entry)) continue; + const pid = Number(entry); + try { + const stat = readFileSync(join('/proc', entry, 'stat'), 'utf8'); + const fields = stat.slice(stat.lastIndexOf(')') + 2).split(' '); + out.push({ + pid, + comm: readFileSync(join('/proc', entry, 'comm'), 'utf8').trim(), + ppid: Number(fields[1]), + pgrp: Number(fields[2]), + cmdline: readFileSync(join('/proc', entry, 'cmdline'), 'utf8') + .replace(/\0/g, ' ') + .trim(), + }); + } catch { + /* gone between the listing and the read */ + } + } + return out; +} + +const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)); + +describe.skipIf(!gitPresent)('runFetch (the whole tree, not just the git parent)', () => { + it.skipIf(!existsSync('/proc/self/environ'))( + 'kills the helper processes too, so nothing is left running after the timeout', + async () => { + const server = createServer(() => { + // Accept and never answer: git's http helper connects, waits, and stays. + }); + const sockets = new Set<Socket>(); + server.on('connection', (s) => { + sockets.add(s); + s.on('close', () => sockets.delete(s)); + }); + await new Promise<void>((resolve) => server.listen(0, '127.0.0.1', resolve)); + const port = (server.address() as AddressInfo).port; + + try { + const repo = initRepo(join(dir, 'hang'), `http://127.0.0.1:${port}/x.git`); + // The handler goes on the moment the promise is made, not when it is read: the timeout fires while the + // tree is still being looked at, and a rejection with nothing listening to it becomes an unhandled + // rejection instead of this test's answer. + const answered = runFetch(repo, buildFetchArgs(), { timeoutMs: 1_500 }).catch((e) => e); + + // The tree has to be a tree before the timeout, or nothing is being proven: git itself, and the helper + // it spawns for an http remote. The helper is a grandchild — `git fetch` runs `git remote-http`, which + // spawns `git-remote-http` — so the group, not the parentage, is what proves it. Each process is + // remembered once found rather than re-scanned: after the kill the group is empty, and a test that kept + // looking would report a tree it had already destroyed. The `git config` read the runner does first is + // a `git` too, so the fetch is found by what it was asked to do. + let parent: Proc | null = null; + let helper: Proc | null = null; + for (let i = 0; i < 60 && !(parent && helper); i++) { + const live = scanProc(); + parent = + parent ?? + live.find((p) => p.comm === 'git' && p.cmdline.includes('fetch') && p.ppid === process.pid) ?? + null; + helper = + helper ?? + (parent + ? live.find((p) => p.pgrp === parent!.pgrp && p.pid !== parent!.pid && p.cmdline.includes('remote-http')) + : null); + if (!helper) await sleep(25); + } + expect(parent, 'the fetch was never running').not.toBeNull(); + expect(helper, 'nothing else in the fetch group, so there was no tree to kill').not.toBeNull(); + // `detached: true` puts the fetch in a group of its own, which is why the negative pid is the right + // thing to signal: a helper outside that group would survive a `child.kill()`. + expect(parent!.pgrp).toBe(parent!.pid); + expect(helper!.pgrp).toBe(parent!.pgrp); + + const err = (await answered) as { code?: string; killed?: boolean }; + expect(err.code).toBe('ETIMEDOUT'); + expect(err.killed).toBe(true); + + // The answer went out before the tree died — that is the whole point of the fix, and it is why the + // escalation is given its own time here instead of being cancelled when the promise settles. + const deadline = Date.now() + FETCH_KILL_ESCALATION_MS + 2_000; + while (Date.now() < deadline && scanProc().some((p) => p.pgrp === parent!.pgrp)) await sleep(50); + expect(scanProc().some((p) => p.pgrp === parent!.pgrp)).toBe(false); + + // Nothing of this test's own is left running either. + await sleep(200); + expect(scanProc().filter((p) => p.ppid === process.pid)).toEqual([]); + } finally { + for (const s of sockets) s.destroy(); + server.close(); + } + } + ); + + it('rejects on a non-zero exit instead of resolving empty stdout, so a failure keeps a reason to be read', async () => { + // A remote git cannot read. A repository with no remote is not usable for this: `git fetch --quiet` exits 0 + // there on the git this was checked against (2.47.3), so resolving empty stdout would have been the right + // answer and the test would prove nothing. A remote pointing at a path that is not a repository exits 128 + // and says why, which is the case the row needs a reason for. + const repo = initRepo(join(dir, 'unreadable-remote'), 'file:///tmp/definitely-not-a-repo-xyz'); + let err: unknown = null; + try { + await runFetch(repo, buildFetchArgs()); + } catch (e) { + err = e; + } + expect(err, 'a fetch that failed resolved instead of rejecting').not.toBeNull(); + const e = err as { code?: unknown; stderr?: string; killed?: boolean }; + // The exit code, not the timeout marker, so a caller can still tell a failure from a stall. + expect(e.code).toBe(128); + expect(e.killed).toBe(false); + expect(e.stderr).toMatch(/does not appear to be a git repository/); + expect(describeFetchFailure(err).code).toBe('NOT_FOUND'); + }); +}); + // ─── One run per folder, and one bound for the whole tick ──────────────────── describe.skipIf(!gitPresent)('fetchWorkspaceOnce (the join)', () => { @@ -412,18 +585,17 @@ describe.skipIf(!gitPresent)('fetchWorkspaceOnce (the join)', () => { } return ''; }; - const first = fetchWorkspaceOnce(folder, { git: firstRunner }); + const first = fetchWorkspaceOnce(folder, { git: firstRunner }, 'interval'); await runStarted; // the run is going, and still going // A click while that run is working the folder: it joins, so it never touches git at all — not even the // resolver's own probe, because it does not resolve anything for itself. let joinerCalls = 0; - const joiner = fetchWorkspaceOnce(folder, { - git: async () => { - joinerCalls++; - return ''; - }, - }); + const joinerRunner: GitRunner = async () => { + joinerCalls++; + return ''; + }; + const joiner = fetchWorkspaceOnce(folder, { git: joinerRunner }, 'click'); releaseRun(); const answer = await first; @@ -433,6 +605,35 @@ describe.skipIf(!gitPresent)('fetchWorkspaceOnce (the join)', () => { expect(firstRunFetches).toBe(1); }); + it('a join reports the trigger of the run it joined, not the trigger that made it ask, because a click that fetched nothing cannot claim the fetch', async () => { + const folder = join(dir, 'folder'); + initRepo(join(folder, 'api')); + + let releaseRun = () => {}; + const held = new Promise<void>((resolve) => (releaseRun = resolve)); + let running = () => {}; + const runStarted = new Promise<void>((resolve) => (running = resolve)); + const runner: GitRunner = async (cwd, args) => { + if (args[0] === 'fetch') { + running(); + await held; + } + return ''; + }; + + // The tick got there first; the click is the joiner. + const intervalRun = fetchWorkspaceOnce(folder, { git: runner }, 'interval'); + await runStarted; + const joinerRun = fetchWorkspaceOnce(folder, { git: async () => '' }, 'click'); + releaseRun(); + + const run = await intervalRun; + expect(await joinerRun).toBe(run); // one run, not two answers + expect(run.trigger).toBe('interval'); + // The row the panel shows is built from the run that ran, so it names the interval. + expect(gitFetchStatusFrom(run.report, run.trigger).trigger).toBe('interval'); + }); + it('keys the join on the resolved folder, so the same folder written two ways is one run', async () => { const folder = join(dir, 'folder'); initRepo(join(folder, 'api')); @@ -450,16 +651,15 @@ describe.skipIf(!gitPresent)('fetchWorkspaceOnce (the join)', () => { } return ''; }; - const first = fetchWorkspaceOnce(folder, { git: firstRunner }); + const first = fetchWorkspaceOnce(folder, { git: firstRunner }, 'click'); await runStarted; // Two sessions on one folder reach it by different spellings; the scheduler keys by `resolve`. let joinerCalls = 0; - const second = fetchWorkspaceOnce(join(dir, 'folder', '.'), { - git: async () => { - joinerCalls++; - return ''; - }, - }); + const joinerRunner: GitRunner = async () => { + joinerCalls++; + return ''; + }; + const second = fetchWorkspaceOnce(join(dir, 'folder', '.'), { git: joinerRunner }, 'click'); releaseRun(); expect(await second).toBe(await first); expect(joinerCalls).toBe(0); @@ -474,10 +674,13 @@ describe.skipIf(!gitPresent)('fetchWorkspaceOnce (the join)', () => { if (args[0] === 'fetch') fetches++; return ''; }; - await fetchWorkspaceOnce(folder, { git: runner }); + const firstRun = await fetchWorkspaceOnce(folder, { git: runner }, 'interval'); expect(fetches).toBe(1); - await fetchWorkspaceOnce(folder, { git: runner }); + const secondRun = await fetchWorkspaceOnce(folder, { git: runner }, 'click'); expect(fetches).toBe(2); + // A run that is its own reports its own trigger; only a joiner borrows the trigger of the run it joined. + expect(firstRun.trigger).toBe('interval'); + expect(secondRun.trigger).toBe('click'); }); it('bounds a whole tick with the limiter it is handed, because two runs given a bound each would get twice the bound', async () => { diff --git a/test/routes/git-status-routes.test.ts b/test/routes/git-status-routes.test.ts index bf515fc49..fc5d345ff 100644 --- a/test/routes/git-status-routes.test.ts +++ b/test/routes/git-status-routes.test.ts @@ -11,7 +11,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { createRouteTestHarness } from './_route-test-utils.js'; import { registerGitStatusRoutes } from '../../src/web/routes/git-status-routes.js'; import { clearGitStatusCache, runGit, type GitRunner } from '../../src/git-workspace-status.js'; -import { clearGitFetchLane } from '../../src/git-fetch.js'; +import { clearGitFetchLane, fetchWorkspaceOnce } from '../../src/git-fetch.js'; const ENV = { ...process.env, @@ -518,6 +518,42 @@ describe('POST /api/sessions/:id/git-fetch', () => { expect(b).toEqual(a); // both tabs get the same run's answer, not a partial one }); + it('publishes the trigger of the run the click joined, because a click that fetched nothing cannot report itself as the fetch', async () => { + git(dir, 'init', '-q', '-b', 'main'); + let releaseRun = () => {}; + const held = new Promise<void>((resolve) => (releaseRun = resolve)); + let running = () => {}; + const runStarted = new Promise<void>((resolve) => (running = resolve)); + const fetches: string[] = []; + const runner: GitRunner = async (cwd, args) => { + if (args[0] === 'fetch') { + fetches.push(cwd); + running(); + await held; + } + return execFileSyncGit(cwd, args); + }; + const { app, ctx } = await setup({ git: runner }); + + // The interval tick got to this clone first. The click joins it, so the row the other tabs see has to name + // the interval — the user clicked, but nothing this click did was a fetch. The request is left open, not + // awaited: awaiting it here would wait on the run this test is holding, and the test would wait on itself. + const intervalRun = fetchWorkspaceOnce(dir, { git: runner }, 'interval'); + await runStarted; + const res = app.inject({ method: 'POST', url: '/api/sessions/test-session-1/git-fetch' }); + await new Promise((resolve) => setImmediate(resolve)); + + releaseRun(); + const run = await intervalRun; + const body = (await res).json().data; + expect(body).toEqual(run.report); // the run it joined, not a partial answer of its own + // Asserted after the run, so this can only pass if the click reached the lane and joined: a click that had + // started its own fetch would make this two, and would have nothing to call the interval. + expect(fetches).toHaveLength(1); + expect(session.gitFetch).toMatchObject({ trigger: 'interval', state: 'ok', repos: 1, failed: 0 }); + expect(ctx.broadcast).toHaveBeenCalled(); + }); + it('404s an unknown session, running no fetch', async () => { const { calls, fetches, runner } = recordingRunner(); const { app } = await setup({ git: runner }); From 0cc9332f3dc302fd4c4c55050d56a897763c308e Mon Sep 17 00:00:00 2001 From: Saqeb Akhter <saqeb.akhter@gmail.com> Date: Mon, 26 Oct 2026 01:14:36 +0400 Subject: [PATCH 38/68] fix: cycle Command-K CLI picker at query edges (johoja12/Codeman#21) --- src/web/public/panels-ui.js | 8 +- ...mmand-palette-agent-arrows.browser.test.ts | 87 +++++++++++++++++++ test/command-palette-ui.test.ts | 33 ++++++- 3 files changed, 122 insertions(+), 6 deletions(-) create mode 100644 test/command-palette-agent-arrows.browser.test.ts diff --git a/src/web/public/panels-ui.js b/src/web/public/panels-ui.js index 5da8666c5..fef69c904 100644 --- a/src/web/public/panels-ui.js +++ b/src/web/public/panels-ui.js @@ -643,9 +643,9 @@ Object.assign(CodemanApp.prototype, { /** * Left/Right change the agent only while the new-session row is highlighted, - * and only when the key would not move the caret anyway (Left at the start of - * the query, Right at its end, no selection), so editing a typed case name - * keeps working. + * and the caret is at either query boundary, with no selection. Both arrows + * stay in picker navigation after typing a case name; inside the query they + * still edit text, as do modified arrows and selection changes. */ _commandPaletteArrowCyclesAgent(e) { const active = (this.commandPaletteItems || [])[this.commandPaletteActiveIndex || 0]; @@ -657,7 +657,7 @@ Object.assign(CodemanApp.prototype, { const start = Number.isFinite(input?.selectionStart) ? input.selectionStart : value.length; const end = Number.isFinite(input?.selectionEnd) ? input.selectionEnd : start; if (start !== end) return false; - return e.key === 'ArrowRight' ? end >= value.length : start <= 0; + return start <= 0 || end >= value.length; }, _findCommandPaletteCaseMatch(query = '') { diff --git a/test/command-palette-agent-arrows.browser.test.ts b/test/command-palette-agent-arrows.browser.test.ts new file mode 100644 index 000000000..774b83e0a --- /dev/null +++ b/test/command-palette-agent-arrows.browser.test.ts @@ -0,0 +1,87 @@ +import { afterAll, beforeAll, describe, expect, it } from 'vitest'; +import { chromium, type Browser } from 'playwright'; +import { WebServer } from '../src/web/server.js'; + +describe('Command-K agent arrow keys in Chromium', () => { + let server: WebServer; + let browser: Browser; + beforeAll(async () => { + server = new WebServer(0, false, true); + await server.start(); + browser = await chromium.launch({ headless: true }); + }, 60000); + afterAll(async () => { + await browser?.close(); + await server?.stop(); + }); + + it('cycles both directions at query boundaries while preserving text editing and selection', async () => { + const page = await browser.newPage({ viewport: { width: 1400, height: 900 } }); + const errors: string[] = []; + page.on('pageerror', (e) => errors.push(e.message)); + try { + await page.goto(`http://127.0.0.1:${server.boundPort}`, { waitUntil: 'domcontentloaded' }); + await page.waitForFunction(() => (window as any).app?.openCommandPalette); + await page.waitForTimeout(3500); + await page.evaluate(() => { + const a = (window as any).app; + a.cases = [{ name: 'buildmesh', path: '/fixture/buildmesh' }]; + a.sessions = new Map(); + a.sessionOrder = []; + a._runMode = 'codex'; + (window as any).__codemanCliCatalog = [ + { id: 'claude', label: 'Claude', enabled: true, kind: 'agent' }, + { id: 'codex', label: 'Codex', enabled: true, kind: 'agent' }, + { id: 'shell', label: 'Shell', enabled: true, kind: 'shell' }, + ]; + a._runModeAvailableForCase = () => true; + a._sendInputEphemeral = () => { + throw new Error('Palette leaked terminal input'); + }; + a.run = () => { + throw new Error('Arrow key launched a session'); + }; + }); + await page.keyboard.press('Control+k'); + await page.locator('#commandPaletteModal.active').waitFor(); + const input = page.locator('#commandPaletteSearch'); + await input.fill('buildmesh'); + const label = page.locator('.command-palette-agent-label'); + expect(await label.innerText()).toBe('Codex'); + await page.keyboard.press('ArrowLeft'); + expect(await label.innerText()).toBe('Claude'); + await page.keyboard.press('ArrowRight'); + expect(await label.innerText()).toBe('Codex'); + expect(await input.inputValue()).toBe('buildmesh'); + expect(await input.evaluate((e: HTMLInputElement) => e.selectionStart)).toBe(9); + await page.keyboard.press('Home'); + await page.keyboard.press('ArrowRight'); + expect(await label.innerText()).toBe('Shell'); + await page.keyboard.press('ArrowLeft'); + expect(await label.innerText()).toBe('Codex'); + await input.evaluate((e: HTMLInputElement) => e.setSelectionRange(4, 4)); + await page.keyboard.press('ArrowLeft'); + expect(await input.evaluate((e: HTMLInputElement) => e.selectionStart)).toBe(3); + await page.keyboard.press('ArrowRight'); + expect(await input.evaluate((e: HTMLInputElement) => e.selectionStart)).toBe(4); + await page.keyboard.press('Shift+End'); + await page.keyboard.press('ArrowLeft'); + expect(await label.innerText()).toBe('Codex'); + await page.screenshot({ path: '/tmp/codeman-21-palette.png' }); + await page.keyboard.press('ArrowDown'); + expect( + await page.evaluate( + () => (window as any).app.commandPaletteItems[(window as any).app.commandPaletteActiveIndex].type + ) + ).toBe('browse-sessions'); + await page.keyboard.press('End'); + await page.keyboard.press('ArrowLeft'); + expect(await label.innerText()).toBe('Codex'); + await page.keyboard.press('Escape'); + expect(await page.locator('#commandPaletteModal').evaluate((e) => e.classList.contains('active'))).toBe(false); + expect(errors).toEqual([]); + } finally { + await page.close(); + } + }); +}); diff --git a/test/command-palette-ui.test.ts b/test/command-palette-ui.test.ts index 408a4e1d6..516a11dcd 100644 --- a/test/command-palette-ui.test.ts +++ b/test/command-palette-ui.test.ts @@ -437,13 +437,28 @@ describe('Command-K new-session agent picker', () => { expect(app.commandPaletteItems[app.commandPaletteActiveIndex]).toMatchObject({ mode: 'opencode' }); }); - it('leaves Left/Right to the caret when they would move it', async () => { + it('cycles both ways at the end of a typed case query without moving its caret', async () => { const { app, listeners, elements } = loadAgentPalette(); app.openCommandPalette(); elements.commandPaletteSearch.value = 'nomatch'; app.renderCommandPalette(); - const left = arrow('ArrowLeft', 7, 'nomatch'); + await listeners['search:keydown'](left); + expect(left.preventDefault).toHaveBeenCalled(); + expect(app.commandPaletteItems[app.commandPaletteActiveIndex].mode).toBe('opencode'); + const right = arrow('ArrowRight', 7, 'nomatch'); + await listeners['search:keydown'](right); + expect(right.preventDefault).toHaveBeenCalled(); + expect(app.commandPaletteItems[app.commandPaletteActiveIndex].mode).toBe('claude'); + }); + + it('leaves Left/Right to the caret inside the query', async () => { + const { app, listeners, elements } = loadAgentPalette(); + app.openCommandPalette(); + elements.commandPaletteSearch.value = 'nomatch'; + app.renderCommandPalette(); + + const left = arrow('ArrowLeft', 3, 'nomatch'); const right = arrow('ArrowRight', 3, 'nomatch'); await listeners['search:keydown'](left); await listeners['search:keydown'](right); @@ -453,6 +468,20 @@ describe('Command-K new-session agent picker', () => { expect(app.commandPaletteItems[app.commandPaletteActiveIndex].mode).toBe('claude'); }); + it.each(['altKey', 'ctrlKey', 'metaKey', 'shiftKey'])( + 'preserves %s arrow editing at query boundaries', + async (modifier) => { + const { app, listeners, elements } = loadAgentPalette(); + app.openCommandPalette(); + elements.commandPaletteSearch.value = 'nomatch'; + app.renderCommandPalette(); + const event = { ...arrow('ArrowLeft', 7, 'nomatch'), [modifier]: true }; + await listeners['search:keydown'](event); + expect(event.preventDefault).not.toHaveBeenCalled(); + expect(app.commandPaletteItems[app.commandPaletteActiveIndex].mode).toBe('claude'); + } + ); + it('does not cycle while a session row is highlighted', async () => { const { app, listeners } = loadAgentPalette(); app.openCommandPalette(); From c0d27079d711f6d7b3dbeaa745b2e44c0b1cbebe Mon Sep 17 00:00:00 2001 From: Saqeb Akhter <saqeb.akhter@gmail.com> Date: Mon, 26 Oct 2026 01:16:02 +0400 Subject: [PATCH 39/68] test: register palette keyboard browser regression (johoja12/Codeman#21) --- config/test-suites.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/config/test-suites.ts b/config/test-suites.ts index d66abd586..c403421d4 100644 --- a/config/test-suites.ts +++ b/config/test-suites.ts @@ -19,6 +19,7 @@ * why these are a runnable suite (`npm run test:browser`) rather than skipped. */ export const BROWSER_TEST_GLOBS = [ + 'test/command-palette-agent-arrows.browser.test.ts', 'test/tmux-capture-sgr.browser.test.ts', 'test/tab-rail-resize.browser.test.ts', 'test/tab-activation.browser.test.ts', From 4f6481bf7ca3737313862856a8048261498d9841 Mon Sep 17 00:00:00 2001 From: JD <jd@jds.haus> Date: Mon, 26 Oct 2026 01:40:25 -0400 Subject: [PATCH 40/68] fix(tabs): open the tab + from the keyboard and probe a Docker tab's container The + announces a menu but Enter and Space did nothing on it, and the header strip has no row menu to fall back on. A + on a Docker tab the toolbar has not selected read only the link-time mode list, since nothing probed its container. Opening the picker from the keyboard focuses an entry, so a held Enter or Space repeated onto it and launched that CLI; the shared tab-layout menu now ignores a repeated Enter or Space. --- docs/architecture-invariants.md | 4 +- src/web/public/app.js | 15 ++- src/web/public/session-ui.js | 3 +- test/tab-new-session.test.ts | 193 +++++++++++++++++++++++++++++++- 4 files changed, 210 insertions(+), 5 deletions(-) diff --git a/docs/architecture-invariants.md b/docs/architecture-invariants.md index 12c0a83f6..d562e9b7f 100644 --- a/docs/architecture-invariants.md +++ b/docs/architecture-invariants.md @@ -345,8 +345,8 @@ So: `_confirmIdle()` (session.ts) requires the pane to go quiet, and then asks t - **Lineage arcs to a collapse-hidden session anchor to its group header** (`lineage-line--proxied`); two endpoints proxied to one header draw nothing. - **The upstream HTML5 drag stays off in the grouped rail**: a flat-order drop cannot express a group move, and the server re-ranks within the old group. The grouped rail has its OWN pointer drag instead (`_bindTabLayoutPointerDrag`, mouse/pen only, bound once on the container): rows move before/after a row or into a group, a header drag reorders groups, and the drop maps to ONE operation through the pure `dropOperation()`. Escape cancels a drag, and the click that ends one is swallowed. ⚠️ A press is only captured once it moves 6 px, so its release can land outside the rail: `pointerup`/`pointercancel` are heard on `window` while a press is pending, a move with the primary button up cancels it, and a new press cancels any previous one. Without that a stale press became a phantom drag on the next hover, and a replaced drag left its capture-phase Escape listener behind, swallowing every Escape before the terminal saw it. The flat rail and the header strip keep the HTML5 drag untouched. For the same reason Ctrl+Shift+{ / } only swaps with a neighbour in the active session's own section (`_canSwapActiveTabWith`, reading the projection's `sectionByRef`): a cross-group swap moves nothing on the server, gets no `session:orderChanged` back, and would leave this client's `sessionOrder` and Alt+N targets out of step with every other device. - **Edits are named operations, saved serially.** `createEditCoordinator` applies `createGroup` / `renameGroup` / `deleteGroup` / `reorderGroup` / `moveRef` to the rail at once, then sends ONE `PUT /api/tab-layout {baseVersion, layout}` at a time; edits made meanwhile wait and go out on the version that write returns. A 409 carries the server's layout: the in-flight operations are replayed onto it (an operation that no longer applies is dropped and reported) and re-sent, at most `maxAttempts` times; a 400 re-reads once (a second 400 is reported as a failed save, not as a race); anything else reports and re-reads. A `moveRef` moves the session together with the sessions that still follow it and marks a hand-moved child `placement: 'manual'`, mirroring the server's `moveRef`, and `normalizeLayout` keeps `placement` because whole layouts are written back. `_onTabLayoutChanged` / `_applyTabLayout` defer a read while a write is in flight and rebase unsaved edits onto a read otherwise. ⚠️ A FAILED read (`_applyTabLayout(null)`) while edits are pending keeps the held layout and the editor and re-reads after the write settles: disposing there would orphan the in-flight write, whose 409 then never gets its rebase. Any path that does drop unsaved work says so in a toast. On `pagehide`, unconfirmed operations go out in a `keepalive` PUT AND into sessionStorage as `{ owner, baseVersion, savedAt, operations }`; after reload they replay onto the fresh layout (a no-op when the keepalive landed), but only for the same owner, within `TAB_LAYOUT_PENDING_MAX_AGE_MS` (60 s), and never onto a layout older than the copy's base. A "Move to <group>" with no anchor carries no `index`, so a replay still puts the row last. -- **Every way in has a keyboard path.** The session row menu (Shift+F10 in the tree, the rail's overflow button) gains Move up/down, Move to <group>, Move to Ungrouped and Move to new group in the vertical rail (only "new group" before the first group exists; nothing on the strip). With **New Session Button on Tabs** on (`showTabNewButton`, per device, `html.tabs-show-new`), every session row menu (the rail, compact or full, the expanded sidebar, the tree; never a tile's, which shares the function) also offers "New session here…", which opens the row's "+" picker: Enter on the "+" itself does nothing (the strip's key handler acts only on the focused tab), and the compact rail hides it. A group header opens its menu with Shift+F10 / ContextMenu, right-click or its hover glyph (a non-focusable, `aria-hidden` span: a treeitem holds no interactive children), and F2 renames it inline (Enter commits and refocuses the header; a commit by BLUR leaves focus where it went, since refocusing from inside the blur handler overrides the user's click). The glyph stays visible under `@media (hover: none)`: a touch tablet has no hover and no long-press `contextmenu`. Group names in "Move to" labels are quoted, so a group named "New group" or "ungrouped" cannot read (or translate, case-insensitively) like the fixed entries. The menu closes on Escape (which it consumes before the global Escape handler), a pointer outside, Tab, focus moving to an element outside it, a resize, a second open and any full re-render. ⚠️ A `focusout` with no `relatedTarget` is not a departure: Safari never focuses a clicked button, so pressing an item blurs the focused one with focus going nowhere, and closing on that dropped the item's click (the tile count menu's rule). The tab "+" picker is this same menu (`_openTabLayoutMenu` with `anyLayout`, so it opens on every layout, not only the editable grouped rail). It borrows the `.tab-rail-action-menu` class for its look only: `closeTabRailActionMenu()` excludes `.tab-layout-group-action-menu`, so closing the row menu (every `session:deleted` does) cannot strand the group menu's listeners. The inline group editor shares `_activeRename` with the session rename, so only the CURRENT editor may release `_inlineRenameActive`. -- **New, child and web tabs land in groups.** A group menu's "New session" (`createSessionInTabGroup`) runs ONE launch with `run({ count: 1, tabGroupId })`; a tab's "+" (`openTabNewSessionMenu`) runs ONE launch in that tab's case or managed worktree through `_launchOnce` (the Git worktrees modal's launcher too), `run({ count: 1, mode, caseName | worktree, tabGroupId? })`, carrying the tab's named group if it has one. ⚠️ The mode rides the call and is never swapped into the Run button's `_runMode`, so a mode picked while the launch is pending is kept, and the case-list reload after a launch that names its case keeps the toolbar's own case (`_reloadCasesAfterRun`; the no-argument reload would re-select the `lastUsedCase` quick-start has just written). ⚠️ The tab's case is found by what the session runs on (`_tabCaseOf`), never by path alone: `GET /api/cases` gives remote and Docker cases a DISPLAY path with the raw values under `remote`/`docker`, so a Docker session belongs to its container's case on its own engine host, a remote one to the case on its own host whose path encloses its own, and a local one to the deepest case enclosing its folder (a display path never encloses one). from an ungrouped tab it sends no `tabGroupId` on purpose (the create schema cannot say "Ungrouped"), so the case default group below applies, as for a toolbar Run. every launch path puts `tabGroupId` on its create request (`POST /api/sessions` or `/api/quick-start`), and `TabLayoutService.sessionCreated(owner, id, { tabGroupId }, register)` places the session at the end of that group in the SAME versioned write that first records it. The session enters the live map inside the owner lock (`register`, rolled back if the write fails), so no queued layout edit can commit it first. The group is looked up in the creating owner's own layout, so an unknown, deleted or foreign id is ignored rather than failing the spawn, and an explicit group is a hand placement (a child placed this way is `manual`). The create response carries the committed `tabLayout`, adopted before the tab is drawn. A session spawned by another one (`parentSessionId`) and placed by nobody lands after its parent: the server's normalization puts it after the parent's rows in the parent's container, and `project({ parents })` draws a not-yet-stored child there at once. Whether it then FOLLOWS its parent is the synced `spawnedTabsFollowParent` setting (default OFF), which `sessionCreated()` reads fresh through the `childrenFollowParent` dep: off, the child is marked `manual` in that same creating write; on, it follows until moved by hand. Deciding at creation is enough, so flipping the setting leaves existing tabs alone. A hand move makes a following child `manual`; with the setting on, the row menu notes where a child sits (following, placed by hand, parent closed, parent loop; plain text, never a focusable menu item) and offers "Follow parent again" only to a `manual` child, which clears `placement` and is re-derived by the server on save. With the setting off the row menu says nothing about placement. A legacy `PUT /api/session-order` (strip or phone drag, a same-section Ctrl+Shift+{/}, a device without a layout, an admin's cross-owner order) marks a child `manual` ONLY when the requested order splits it from its parent's block (`applyLegacySessionRank`, `childrenSplitFromParent`); a PUT of the current order, or one that moves a family together, leaves every stored placement alone. The browser never echoes a create or delete through that PUT, with or without groups: the service already re-projects the order and broadcasts it (`session:orderChanged`), and `syncSessionOrder` adopts it. A child whose parent is gone stands on its own and is never re-adopted. Saved web tabs are layout refs like sessions, but OPENNESS stays per device: a grouped web tab renders (and takes an Alt+N slot) only while it is open on this device, and reopens where the layout keeps it. +- **Every way in has a keyboard path.** The session row menu (Shift+F10 in the tree, the rail's overflow button) gains Move up/down, Move to <group>, Move to Ungrouped and Move to new group in the vertical rail (only "new group" before the first group exists; nothing on the strip). With **New Session Button on Tabs** on (`showTabNewButton`, per device, `html.tabs-show-new`), every session row menu (the rail, compact or full, the expanded sidebar, the tree; never a tile's, which shares the function) also offers "New session here…", which opens the row's "+" picker, and the compact rail hides it. The "+" itself opens its picker on Enter or Space through its own inline `onkeydown` (the strip's key handler acts only on a focused tab), which is the keyboard path on the header strip, where no row menu exists. The menu cancels a REPEATED Enter or Space, so a held key that opened it cannot also pick the entry it focuses. A group header opens its menu with Shift+F10 / ContextMenu, right-click or its hover glyph (a non-focusable, `aria-hidden` span: a treeitem holds no interactive children), and F2 renames it inline (Enter commits and refocuses the header; a commit by BLUR leaves focus where it went, since refocusing from inside the blur handler overrides the user's click). The glyph stays visible under `@media (hover: none)`: a touch tablet has no hover and no long-press `contextmenu`. Group names in "Move to" labels are quoted, so a group named "New group" or "ungrouped" cannot read (or translate, case-insensitively) like the fixed entries. The menu closes on Escape (which it consumes before the global Escape handler), a pointer outside, Tab, focus moving to an element outside it, a resize, a second open and any full re-render. ⚠️ A `focusout` with no `relatedTarget` is not a departure: Safari never focuses a clicked button, so pressing an item blurs the focused one with focus going nowhere, and closing on that dropped the item's click (the tile count menu's rule). The tab "+" picker is this same menu (`_openTabLayoutMenu` with `anyLayout`, so it opens on every layout, not only the editable grouped rail). It borrows the `.tab-rail-action-menu` class for its look only: `closeTabRailActionMenu()` excludes `.tab-layout-group-action-menu`, so closing the row menu (every `session:deleted` does) cannot strand the group menu's listeners. The inline group editor shares `_activeRename` with the session rename, so only the CURRENT editor may release `_inlineRenameActive`. +- **New, child and web tabs land in groups.** A group menu's "New session" (`createSessionInTabGroup`) runs ONE launch with `run({ count: 1, tabGroupId })`; a tab's "+" (`openTabNewSessionMenu`) runs ONE launch in that tab's case or managed worktree through `_launchOnce` (the Git worktrees modal's launcher too), `run({ count: 1, mode, caseName | worktree, tabGroupId? })`, carrying the tab's named group if it has one. ⚠️ The mode rides the call and is never swapped into the Run button's `_runMode`, so a mode picked while the launch is pending is kept, and the case-list reload after a launch that names its case keeps the toolbar's own case (`_reloadCasesAfterRun`; the no-argument reload would re-select the `lastUsedCase` quick-start has just written). ⚠️ The tab's case is found by what the session runs on (`_tabCaseOf`), never by path alone: `GET /api/cases` gives remote and Docker cases a DISPLAY path with the raw values under `remote`/`docker`, so a Docker session belongs to its container's case on its own engine host, a remote one to the case on its own host whose path encloses its own, and a local one to the deepest case enclosing its folder (a display path never encloses one). A Docker tab's picker starts that container's probe (`_probeDockerCaseModes`) when nothing is cached for its case, since the toolbar probes only its own selected case: that open lists what is already known, the next one the container's answer. From an ungrouped tab the "+" sends no `tabGroupId` on purpose (the create schema cannot say "Ungrouped"), so the case default group below applies, as for a toolbar Run. Every launch path puts `tabGroupId` on its create request (`POST /api/sessions` or `/api/quick-start`), and `TabLayoutService.sessionCreated(owner, id, { tabGroupId }, register)` places the session at the end of that group in the SAME versioned write that first records it. The session enters the live map inside the owner lock (`register`, rolled back if the write fails), so no queued layout edit can commit it first. The group is looked up in the creating owner's own layout, so an unknown, deleted or foreign id is ignored rather than failing the spawn, and an explicit group is a hand placement (a child placed this way is `manual`). The create response carries the committed `tabLayout`, adopted before the tab is drawn. A session spawned by another one (`parentSessionId`) and placed by nobody lands after its parent: the server's normalization puts it after the parent's rows in the parent's container, and `project({ parents })` draws a not-yet-stored child there at once. Whether it then FOLLOWS its parent is the synced `spawnedTabsFollowParent` setting (default OFF), which `sessionCreated()` reads fresh through the `childrenFollowParent` dep: off, the child is marked `manual` in that same creating write; on, it follows until moved by hand. Deciding at creation is enough, so flipping the setting leaves existing tabs alone. A hand move makes a following child `manual`; with the setting on, the row menu notes where a child sits (following, placed by hand, parent closed, parent loop; plain text, never a focusable menu item) and offers "Follow parent again" only to a `manual` child, which clears `placement` and is re-derived by the server on save. With the setting off the row menu says nothing about placement. A legacy `PUT /api/session-order` (strip or phone drag, a same-section Ctrl+Shift+{/}, a device without a layout, an admin's cross-owner order) marks a child `manual` ONLY when the requested order splits it from its parent's block (`applyLegacySessionRank`, `childrenSplitFromParent`); a PUT of the current order, or one that moves a family together, leaves every stored placement alone. The browser never echoes a create or delete through that PUT, with or without groups: the service already re-projects the order and broadcasts it (`session:orderChanged`), and `syncSessionOrder` adopts it. A child whose parent is gone stands on its own and is never re-adopted. Saved web tabs are layout refs like sessions, but OPENNESS stays per device: a grouped web tab renders (and takes an Alt+N slot) only while it is open on this device, and reopens where the layout keeps it. - **A case's default session group** (`caseDefaultGroups` in state.json, keyed by the layout owner key then case name; a group id or `null` for Ungrouped) is the fallback placement for a new session of that case. It is read and written ONLY through `TabLayoutService` (`caseDefaultGroup`, `setCaseDefaultGroup`, `forgetCaseDefaultGroup`, and `sessionCreated`'s `caseName` hint), under the owner lock, never by a route or the store directly. Precedence inside `sessionCreated`'s single write: an explicit `tabGroupId` found in the owner's layout, then a parent that places the child (`hasPlacingParent`, the same conditions as `pinCreatedChild`), then the case default, else normal placement. The id is looked up in the creating owner's own layout, so a deleted or foreign id never fails a spawn; a stale id is forgotten only AFTER the creating write lands (a failed write rolls back and changes nothing). `POST /api/quick-start` and `POST /api/ralph-loop/start` pass their `caseName`; `POST /api/sessions` infers it LEXICALLY from `workingDir` (`inferLocalCaseName`: a direct child of the caller's case space, or an exact linked-case path for an admin or single-user, or a Claude worktree of either, `<case>/.claude/worktrees/<name>`, which is how the Git worktrees modal launches Claude while every other CLI goes through quick-start), only for a named `workingDir` (never the server's own cwd), never for a remote attach and never touching the filesystem. Cron jobs, the one-shot run route and reboot restore keep normal placement. The Case settings selector (`onCaseDefaultGroupChanged`, session-ui.js) reads it on popover open and is redrawn from an adopted layout whose groups changed (`_refreshCaseDefaultGroupOptions` in `_applyTabLayout`; an unchanged group list is skipped, since every create, delete and reorder broadcasts a layout and a rebuild would reset an open dropdown). - **Only the grouped rail is a tree.** `#sessionTabs` ships as `role=tablist` with `role=tab` rows, and the header strip, sidebar and flat rail keep exactly that. While grouped, `_applyTabListRole` makes it `role=tree` (and restores `tablist` + its label when grouping ends), named-group headers are level-1 `treeitem`s that `aria-owns` their rows' `role=group` (rows sit beside the header, not inside it), and ungrouped rows plus a collapsed group's kept selection are level-1 items. A collapsed header owns nothing, a group with no open rows is a leaf (no `aria-expanded`, no owned group), and the "Ungrouped" heading is `aria-hidden`. Rows are re-roled in the DOM by `_applyTabTreeSemantics` after render, never by rewriting their markup, so a grouped row's content stays the flat row's. - **One tab stop in the tree.** Exactly one treeitem carries `tabindex=0` (the focused or selected item); every control inside a row drops to `-1`, which is why Shift+F10 / ContextMenu open a row's actions from the keyboard. Focus survives a full re-render by identity (`group:`/`session:`/`webview:`; a row a collapse just hid hands focus to its header), but only when focus was already inside the rail. The tree walk (`_tabTreeItems`) follows painted order WITHIN each group when the rail is sorted; the flat list keeps its own whole-list computed-order walk. `aria-posinset`/`aria-setsize` follow painted order too, so the incremental render path re-runs `_applyTabTreePositions` after it re-sorts rows in place. ⚠️ `_handleTabTreeKeydown` acts only when the key lands on the treeitem ITSELF: a key on a focused in-row control (close, overflow, the rename input) is that control's, or Enter on the overflow button re-selects the row instead of reopening its menu. ⚠️ The roving `tabindex=-1` also hides every item but the stop from the keyboard-dismiss selector's `[tabindex]` arm, which is why `MOBILE_KEYBOARD_DISMISS_EXEMPT_SELECTOR` lists `[role="treeitem"]` (see Dismissing the on-screen keyboard). diff --git a/src/web/public/app.js b/src/web/public/app.js index 7e91ffd10..5ce7b0f2c 100644 --- a/src/web/public/app.js +++ b/src/web/public/app.js @@ -7141,7 +7141,9 @@ class CodemanApp { : `<span class="tab-harness run-mode-dot ${escapeHtml(mode)}" aria-hidden="true"></span>`; const inlineSessionActions = this.shouldInlineSessionActions(); - const tabActionsHtml = `<span class="tab-actions"><span class="tab-gear" onclick="event.stopPropagation(); app.openSessionOptions(${escapeHtml(JSON.stringify(id))})" title="Session options" aria-label="Session options" tabindex="0">⚙</span><span class="tab-detach" onclick="event.stopPropagation(); app.detachSession(${escapeHtml(JSON.stringify(id))})" title="Open in a new window" aria-label="Open session in a new window" tabindex="0">⧉</span><span class="tab-new" onclick="event.stopPropagation(); app.openTabNewSessionMenu(event, ${escapeHtml(JSON.stringify(id))})" title="New session here" aria-label="New session in this tab's case" aria-haspopup="menu" tabindex="0">+</span><span class="tab-close" onclick="event.stopPropagation(); app.requestCloseSession(${escapeHtml(JSON.stringify(id))})" title="Close session" aria-label="Close session" tabindex="0">×</span><button type="button" class="tab-more" onclick="event.stopPropagation(); app.openTabRailActionMenu(event, ${escapeHtml(JSON.stringify(id))})" title="Session actions" aria-label="Session actions">⋯</button></span>`; + // The "+" announces a menu, so Enter and Space open it: the strip's own key + // handler acts only on a focused tab, and the header strip has no row menu. + const tabActionsHtml = `<span class="tab-actions"><span class="tab-gear" onclick="event.stopPropagation(); app.openSessionOptions(${escapeHtml(JSON.stringify(id))})" title="Session options" aria-label="Session options" tabindex="0">⚙</span><span class="tab-detach" onclick="event.stopPropagation(); app.detachSession(${escapeHtml(JSON.stringify(id))})" title="Open in a new window" aria-label="Open session in a new window" tabindex="0">⧉</span><span class="tab-new" onclick="event.stopPropagation(); app.openTabNewSessionMenu(event, ${escapeHtml(JSON.stringify(id))})" onkeydown="if (event.key === 'Enter' || event.key === ' ') { event.preventDefault(); event.stopPropagation(); app.openTabNewSessionMenu(event, ${escapeHtml(JSON.stringify(id))}); }" title="New session here" aria-label="New session in this tab's case" aria-haspopup="menu" tabindex="0">+</span><span class="tab-close" onclick="event.stopPropagation(); app.requestCloseSession(${escapeHtml(JSON.stringify(id))})" title="Close session" aria-label="Close session" tabindex="0">×</span><button type="button" class="tab-more" onclick="event.stopPropagation(); app.openTabRailActionMenu(event, ${escapeHtml(JSON.stringify(id))})" title="Session actions" aria-label="Session actions">⋯</button></span>`; rowHtml.set(id, `<div class="session-tab ${isActive ? 'active' : ''}${alertClass}${richClass}${paneExitBadge ? ' tab-agent-exited' : ''}${loadState ? ' tab-loading' : ''}${this.hasTabDetachOverride(id) ? ' tab-show-detach' : ''}${this._tileGrid?.has(id) ? ' in-tiles' : ''}"${richData}${railOrderStyle} data-id="${id}" data-color="${color}" ${loadState ? `data-load-phase="${escapeHtml(loadState.phase)}"` : ''} onclick="app.handleSessionTabClick(event, ${escapeHtml(JSON.stringify(id))})" oncontextmenu="event.preventDefault(); app.startInlineRename(${escapeHtml(JSON.stringify(id))})" tabindex="0" role="tab" aria-selected="${isActive ? 'true' : 'false'}" aria-busy="${loadState ? 'true' : 'false'}" aria-label="${escapeHtml(paneExitAriaLabel(name, paneExitBadge))}" data-aria-source="${escapeHtml(paneExitAriaLabel(name, paneExitBadge))}" ${tabTooltip ? `title="${escapeHtml(tabTooltip)}"` : ''}> ${_tabIdx < 9 ? '<span class="tab-number">' + (_tabIdx + 1) + '</span>' : ''} @@ -8390,6 +8392,11 @@ class CodemanApp { // An ungrouped tab sends no group, so the server's case default group (if the // case has one) places the new session, as it does for a toolbar Run. const tabGroupId = this._tabRefLocation({ kind: 'session', id: sessionId })?.groupId || null; + // The toolbar probes only the case it has selected, so ask this tab's container + // now: this open reads what is already known, the next one the container's answer. + if (caseEntry?.location === 'docker' && !this._dockerCaseModes?.[caseName]) { + void this._probeDockerCaseModes(caseEntry, null); + } // The phone overview's list, gated by the Run menu's own rule for this case // (_runModeAvailableForCase: the container's CLIs for a Docker case; shell is // never gated), in the Run menu's order: agents in catalog order, the shell last. @@ -8583,6 +8590,12 @@ class CodemanApp { document.addEventListener('keydown', this._tabGroupMenuKeydown, true); window.addEventListener('resize', this._tabGroupMenuResize); menu.addEventListener('keydown', (keyEvent) => { + // A held Enter or Space that opened the menu repeats onto the entry it + // focuses; cancelling the repeat keeps it from also picking that entry. + if (keyEvent.repeat && (keyEvent.key === 'Enter' || keyEvent.key === ' ')) { + keyEvent.preventDefault(); + return; + } const buttons = [...menu.querySelectorAll('button')]; const at = buttons.indexOf(document.activeElement); if (keyEvent.key === 'ArrowDown' || keyEvent.key === 'ArrowUp') { diff --git a/src/web/public/session-ui.js b/src/web/public/session-ui.js index 38a464f43..50d4bfadf 100644 --- a/src/web/public/session-ui.js +++ b/src/web/public/session-ui.js @@ -823,7 +823,8 @@ Object.assign(CodemanApp.prototype, { * never gated; both callers skip it before asking. * * Reads only what is already known and never starts a container probe: the - * Run menu starts one (_probeDockerCaseModes), and its answer lands in + * Run menu and a tab's "+" (openTabNewSessionMenu, app.js) start one + * (_probeDockerCaseModes), and its answer lands in * _dockerCaseModes, which this reads. Until then (a probe still in flight) * the stored availableModes decide, else nothing is gated. */ diff --git a/test/tab-new-session.test.ts b/test/tab-new-session.test.ts index b108ad015..1cfd426fe 100644 --- a/test/tab-new-session.test.ts +++ b/test/tab-new-session.test.ts @@ -14,7 +14,8 @@ * - which CLIs are offered follows the Run menu's own rule for that case * (_runModeAvailableForCase): the host's CLIs for a local case, the * container's for a Docker case (live probe, then the stored list, nothing - * gated for an owned container), only the shell for an unreadable one; + * gated for an owned container), only the shell for an unreadable one; the + * picker starts that probe when nothing is cached for a Docker case; * - a pick runs ONE launch in the tab's case or managed worktree with the tab's * named group, and an ungrouped tab sends no group (the server's case default * group then applies, pinned end to end in case-default-group-routes.test.ts); @@ -22,6 +23,9 @@ * the mode is changed while the launch is pending and when the server has * moved lastUsedCase to the tab's case; * - a failed launch reports the server's own error; + * - Enter and Space on the "+" open the picker (the header strip's only + * keyboard path) and stop there; every other key passes on to the strip; a + * held key's repeat cannot also pick the entry the picker focuses; * - the row menu offers the picker on session rows only while the setting is on; * - the picker closes on a re-render and gives focus back to the tab on Escape; * - the setting stays per device and OFF until switched on, and every new @@ -301,6 +305,70 @@ describe('the tab "+" picker', () => { ]); }); + it("opening on a Docker tab whose container is not probed yet probes it, so the next open lists the container's CLIs", async () => { + const app = makeApp(); + const probe = vi.spyOn(app, '_probeDockerCaseModes'); + const previousFetch = win.fetch; + // The network boundary: what POST /api/docker-cases/adopt-preflight answers for this container. + win.fetch = vi.fn(async () => ({ + ok: true, + json: async () => ({ success: true, data: { ok: true, availableModes: ['codex', 'shell'] } }), + })); + try { + probe.mockClear(); + win.fetch.mockClear(); + openFrom(app, 'c'); + // This open reads what is already known (the list stored at adoption) and starts the probe. + expect(entries()).toEqual([ + ['run-mode-dot gemini', 'Gemini CLI', 'run-mode-option'], + ['run-mode-dot shell', 'Terminal / Shell', 'run-mode-option'], + ]); + expect(probe).toHaveBeenCalledTimes(1); + expect(probe.mock.calls[0][0]).toBe(CASES.find((c) => c.name === 'box')); + expect(probe.mock.calls[0][1]).toBeNull(); + expect(win.fetch).toHaveBeenCalledTimes(1); + const [url, init] = win.fetch.mock.calls[0]; + expect(url).toBe('/api/docker-cases/adopt-preflight'); + expect(init.method).toBe('POST'); + expect(JSON.parse(init.body)).toStrictEqual({ hostId: 'h1', container: 'box' }); + await flush(); + app.closeTabGroupMenu(); + + // The next open lists what the container answered, and probes no more. + probe.mockClear(); + win.fetch.mockClear(); + openFrom(app, 'c'); + expect(entries()).toEqual([ + ['run-mode-dot codex', 'Codex', 'run-mode-option selected'], + ['run-mode-dot shell', 'Terminal / Shell', 'run-mode-option'], + ]); + expect(probe).not.toHaveBeenCalled(); + expect(win.fetch).not.toHaveBeenCalled(); + app.closeTabGroupMenu(); + + // An owned container with nothing probed is probed as well. + probe.mockClear(); + openFrom(app, 'd'); + expect(probe).toHaveBeenCalledTimes(1); + expect(probe.mock.calls[0][0]).toBe(CASES.find((c) => c.name === 'own')); + expect(probe.mock.calls[0][1]).toBeNull(); + await flush(); + app.closeTabGroupMenu(); + + // Local, remote and worktree tabs have no container to ask. + for (const id of ['a', 'g', 'r', 'f']) { + probe.mockClear(); + win.fetch.mockClear(); + openFrom(app, id); + expect(probe).not.toHaveBeenCalled(); + expect(win.fetch).not.toHaveBeenCalled(); + app.closeTabGroupMenu(); + } + } finally { + win.fetch = previousFetch; + } + }); + it('finds the case by what the session runs on, never a same-path case elsewhere', async () => { const app = makeApp(); const runs: unknown[] = []; @@ -340,6 +408,129 @@ describe('the tab "+" picker', () => { }); }); +describe('the "+" from the keyboard', () => { + /** + * Wire the "+"'s inline keydown handler the way a browser wires an on* attribute + * (the attribute's text as the body of a function of `event`, `this` the + * element, `app` the page's global), since this JSDOM does not run inline handlers. + */ + function wireInlineKeydown(app: Record<string, any>, el: HTMLElement) { + win.app = app; + const body = el.getAttribute('onkeydown'); + expect(body).not.toBeNull(); + const handler = new win.Function('event', body); + el.addEventListener('keydown', function (this: HTMLElement, event: Event) { + handler.call(this, event); + }); + } + const press = (el: HTMLElement, key: string) => { + const event = new win.KeyboardEvent('keydown', { key, bubbles: true, cancelable: true }); + el.dispatchEvent(event); + return event; + }; + + it('Enter and Space on the header strip\'s "+" open its picker, and the key goes no further', () => { + const app = makeApp({ orientation: 'horizontal' }); + const strip = document.getElementById('sessionTabs')!; + // The header strip: a flat tablist, no row menu to fall back on. + expect(strip.getAttribute('role')).toBe('tablist'); + const open = vi.spyOn(app, 'openTabNewSessionMenu'); + const reachedStrip = vi.fn(); + strip.addEventListener('keydown', reachedStrip); + wireInlineKeydown(app, plus('a')); + for (const key of ['Enter', ' ']) { + open.mockClear(); + reachedStrip.mockClear(); + const event = press(plus('a'), key); + expect(open).toHaveBeenCalledTimes(1); + expect(open.mock.calls[0][0]).toBe(event); + expect(open.mock.calls[0][1]).toBe('a'); + expect(event.defaultPrevented).toBe(true); + expect(reachedStrip).not.toHaveBeenCalled(); + expect(entries()).toEqual([ + ['run-mode-dot claude', 'Claude Code', 'run-mode-option'], + ['run-mode-dot codex', 'Codex', 'run-mode-option selected'], + ['run-mode-dot shell', 'Terminal / Shell', 'run-mode-option'], + ]); + // Anchored on the "+" itself; Escape gives focus back to its tab row (the shared menu's design). + expect(app._tabGroupMenuTrigger).toBe(plus('a')); + app.closeTabGroupMenu(); + } + strip.removeEventListener('keydown', reachedStrip); + }); + + it('on a tab whose folder is no case the key still stops at the "+", so Space does not scroll the page', () => { + const app = makeApp({ orientation: 'horizontal' }); + const strip = document.getElementById('sessionTabs')!; + const reachedStrip = vi.fn(); + strip.addEventListener('keydown', reachedStrip); + wireInlineKeydown(app, plus('e')); + app.showToast.mockClear(); + const event = press(plus('e'), ' '); + expect(event.defaultPrevented).toBe(true); + expect(reachedStrip).not.toHaveBeenCalled(); + expect(picker()).toBeNull(); + expect(app.showToast).toHaveBeenCalledWith( + "This tab's folder is not a case, so there is nothing to start beside it.", + 'info' + ); + strip.removeEventListener('keydown', reachedStrip); + }); + + it('every other key leaves the "+" alone and reaches the strip as before', () => { + const app = makeApp({ orientation: 'horizontal' }); + const strip = document.getElementById('sessionTabs')!; + const open = vi.spyOn(app, 'openTabNewSessionMenu'); + const reachedStrip = vi.fn(); + strip.addEventListener('keydown', reachedStrip); + wireInlineKeydown(app, plus('a')); + for (const key of ['a', 'ArrowRight', 'ArrowDown', 'Escape', 'Tab', 'F2']) { + open.mockClear(); + reachedStrip.mockClear(); + const event = press(plus('a'), key); + expect(open).not.toHaveBeenCalled(); + expect(event.defaultPrevented).toBe(false); + expect(reachedStrip).toHaveBeenCalledTimes(1); + expect(picker()).toBeNull(); + } + strip.removeEventListener('keydown', reachedStrip); + }); + + it('a held Enter or Space that opened the picker cannot also pick its focused entry', async () => { + const app = makeApp({ orientation: 'horizontal' }); + const runs: unknown[] = []; + app.run = vi.fn(async (options: unknown) => { + runs.push(structuredClone(options)); + }); + wireInlineKeydown(app, plus('a')); + for (const key of ['Enter', ' ']) { + press(plus('a'), key); + const focused = document.activeElement as HTMLElement; + expect(focused.textContent).toBe('Codex'); + // The key's auto-repeat lands on the entry the picker just focused: its + // native activation is cancelled, so nothing launches and the picker stays. + const repeat = new win.KeyboardEvent('keydown', { key, repeat: true, bubbles: true, cancelable: true }); + focused.dispatchEvent(repeat); + expect(repeat.defaultPrevented).toBe(true); + expect(picker()).not.toBeNull(); + // A fresh press on an entry keeps its native activation. + const fresh = new win.KeyboardEvent('keydown', { key, bubbles: true, cancelable: true }); + focused.dispatchEvent(fresh); + expect(fresh.defaultPrevented).toBe(false); + app.closeTabGroupMenu(); + } + // Other repeated keys keep their own handling (ArrowDown still moves). + press(plus('a'), 'Enter'); + const arrow = new win.KeyboardEvent('keydown', { key: 'ArrowDown', repeat: true, bubbles: true, cancelable: true }); + document.activeElement!.dispatchEvent(arrow); + expect(arrow.defaultPrevented).toBe(true); + expect(document.activeElement?.textContent).toBe('Terminal / Shell'); + app.closeTabGroupMenu(); + await flush(); + expect(runs).toStrictEqual([]); + }); +}); + describe('a pick from the "+"', () => { function recordRuns(app: Record<string, any>) { const runs: Array<{ options: unknown; runMode: string }> = []; From 8fa5b340c1c3b377588e572f6b5847c551954b32 Mon Sep 17 00:00:00 2001 From: JD <jd@jds.haus> Date: Mon, 26 Oct 2026 02:01:59 -0400 Subject: [PATCH 41/68] fix(settings): serialize settings.json writes so a quick-start cannot tear the file Quick-start saved lastUsedCase with an un-awaited, non-atomic read-modify-write, and the settings PUT, model config, case order, case rename, Ralph start and the boot tunnel reset each ran their own. Two overlapping truncate-then-write calls left one write's bytes over the tail of the other (the CI flake in test/quick-start.test.ts), and most writers then read the torn file as {} and wrote back only its own keys, erasing the rest of the user's settings. All of them now go through one writer that runs one change at a time, replaces the file atomically, and refuses (409 CONFLICT) to overwrite a file that does not parse. --- CLAUDE.md | 2 +- docs/api-reference.md | 2 +- docs/architecture-invariants.md | 2 +- src/web/routes/case-routes.ts | 49 ++-- src/web/routes/config-backup-routes.ts | 4 +- src/web/routes/ralph-routes.ts | 20 +- src/web/routes/session-routes.ts | 26 +-- src/web/routes/system-routes.ts | 43 +--- src/web/server.ts | 13 +- src/web/settings-writer.ts | 107 +++++++++ test/routes/case-rename-routes.test.ts | 61 ++++- .../system-routes-settings-admin-only.test.ts | 5 + ...es-settings-get-plan-usage-default.test.ts | 4 +- ...system-routes-settings-partial-put.test.ts | 5 + .../routes/system-routes-tunnel-guard.test.ts | 5 + test/routes/system-routes.test.ts | 5 + test/settings-admin-only-multiuser.test.ts | 6 +- .../settings-json-write-serialization.test.ts | 220 ++++++++++++++++++ 18 files changed, 458 insertions(+), 121 deletions(-) create mode 100644 src/web/settings-writer.ts create mode 100644 test/settings-json-write-serialization.test.ts diff --git a/CLAUDE.md b/CLAUDE.md index bd2594acd..bc5459d9b 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -302,7 +302,7 @@ One module per domain in `src/web/routes/` (plus a barrel; `ls src/web/routes/` - **API endpoint**: Types in `src/types/` domain file, route in `src/web/routes/*-routes.ts`. Return the `ApiResponse` envelope (`{ success: true, data }`; errors via `createErrorResponse()` with proper status code). Validate with Zod schemas in `schemas.ts`. - **SSE event**: Add to `src/web/sse-events.ts` + `SSE_EVENTS` in `constants.js`, emit via `broadcast()`, handle in `app.js` (`addListener(`) - **Session setting**: Add to `SessionState`, include in `session.toState()`, call `persistSessionState()` -- **App setting**: decide per-device vs synced first. Per-device keys go in the `displayKeys` set in settings-ui.js and must NOT be added to `SettingsUpdateSchema` (it is `.strict()`). ⚠️ Anything in `PUT /api/settings` that acts on a setting (the `toggleService` watcher calls) must resolve from **`merged`** (persisted + incoming), never from the raw request body: a partial PUT omits keys it doesn't intend to change, and `body.x ?? default` turns every omission into "apply the default" and silently resets live services. Pinned by `test/routes/system-routes-settings-partial-put.test.ts`. ⚠️ A synced setting is admin-only to write in multi-user mode (the PUT 403s a non-admin, whose browser does not send it): a new route that writes `settings.json` takes the same `isMultiUserMode() && !requireAdmin(req, reply)` gate. +- **App setting**: decide per-device vs synced first. Per-device keys go in the `displayKeys` set in settings-ui.js and must NOT be added to `SettingsUpdateSchema` (it is `.strict()`). ⚠️ Anything in `PUT /api/settings` that acts on a setting (the `toggleService` watcher calls) must resolve from **`merged`** (persisted + incoming), never from the raw request body: a partial PUT omits keys it doesn't intend to change, and `body.x ?? default` turns every omission into "apply the default" and silently resets live services. Pinned by `test/routes/system-routes-settings-partial-put.test.ts`. ⚠️ A synced setting is admin-only to write in multi-user mode (the PUT 403s a non-admin, whose browser does not send it): a new route that writes `settings.json` takes the same `isMultiUserMode() && !requireAdmin(req, reply)` gate. ⚠️ Every write to the server's `settings.json` goes through `mutateSettingsFile()` (`src/web/settings-writer.ts`: one at a time, tmp+rename, and a file that does not parse is refused, never read as `{}`), and a route awaits it before answering: two unserialized plain `writeFile`s tore the file, and the `{}` fallback then erased every other setting. The one direct writer is config-backup restore (`restoreConfigBackup()`, as for `clis.json`): it must work on a file that does not parse, which the writer refuses. Pinned by `test/settings-json-write-serialization.test.ts`. - **Hook event**: Add to `HookEventType`, add hook in `hooks-config.ts:generateHooksConfig()`, update `HookEventSchema` - **Mobile feature**: Add to relevant singleton, guard with `MobileDetection.isMobile()`. New header buttons must stay off phones (`test/mobile-header-buttons-policy.test.ts`). - **New test**: a test that constructs `WebServer` itself binds port 0 (`new WebServer(0, …)`, then `server.boundPort`), and a raw `http`/`net`/Fastify/`ws` server listens on `0` and reads `address().port`; mobile tests get theirs from `createTestServer()` and read `boundPort` too. `test/test-ports-guard.test.ts` refuses a fixed port in either. Route tests use `app.inject()` (no port needed); see `test/routes/_route-test-utils.ts`. diff --git a/docs/api-reference.md b/docs/api-reference.md index 95a28b178..16836d2e4 100644 --- a/docs/api-reference.md +++ b/docs/api-reference.md @@ -1213,7 +1213,7 @@ A local case whose folder is the top of a git repository can keep **worktrees** ## Settings -`~/.codeman/settings.json` is one file for the whole server: the synced App Settings (feature switches, the plan-usage collection switch `showPlanUsageLimits`, `runMode`, `respawnPresets`, `tunnelEnabled`, ...), plus `modelConfig`, `caseOrder` and `lastUsedCase`. Reads are open to every signed-in user; in multi-user mode a non-admin reads it without what is the admin's (`voiceSettings.apiKey`, the Deepgram key an admin saved, and the admin's case names in `caseOrder` and `lastUsedCase`). **In multi-user mode every write is admin only:** for a non-admin the routes below answer `403 FORBIDDEN` before the body is even validated, so nothing is written, no watcher is toggled and no tunnel is started. Single-user mode is unaffected. +`~/.codeman/settings.json` is one file for the whole server: the synced App Settings (feature switches, the plan-usage collection switch `showPlanUsageLimits`, `runMode`, `respawnPresets`, `tunnelEnabled`, ...), plus `modelConfig`, `caseOrder` and `lastUsedCase`. Reads are open to every signed-in user; in multi-user mode a non-admin reads it without what is the admin's (`voiceSettings.apiKey`, the Deepgram key an admin saved, and the admin's case names in `caseOrder` and `lastUsedCase`). **In multi-user mode every write is admin only:** for a non-admin the routes below answer `403 FORBIDDEN` before the body is even validated, so nothing is written, no watcher is toggled and no tunnel is started. Single-user mode is unaffected. Every write to the file (these routes, and the `lastUsedCase` that `POST /api/quick-start` and `POST /api/ralph-loop/start` record before they answer) runs one at a time and replaces the file atomically (mode `0600`); the one write outside that queue is a config-backup restore, which must work on a file that does not parse. A write to a `settings.json` that exists but is not a JSON object is refused rather than starting from `{}`, which would erase every other setting: the routes below answer `409 CONFLICT` with a message naming the fix and leave the file untouched, the two starts skip their `lastUsedCase`, and `POST /api/cases/:name/rename` fails and is undone like any other registry write failure. A missing or empty file still starts from `{}`. | Method | Path | Body | Notes | | ------ | ----------------------------- | ------------------------------------ | ----- | diff --git a/docs/architecture-invariants.md b/docs/architecture-invariants.md index 7a743bb62..482a055d1 100644 --- a/docs/architecture-invariants.md +++ b/docs/architecture-invariants.md @@ -750,7 +750,7 @@ Matching semantics: a query containing `/` matches the relative path, otherwise ⚠️ **The origin is a random id in `<data dir>/config-backup-id`, never the hostname.** Instances that share a folder (two machines on one synced folder, prod and a beta) must keep to their own snapshots, so the change check, list, prune, Compare and restore are all scoped to it; without that, each instance snapshotted on every tick and Restore offered the other machine's `users.json` and `.env`. The hostname was tried first and was wrong: a Docker Compose container gets a new one on every recreate, which unlisted every earlier snapshot, and since prune is origin-scoped they were never deleted either. The id is not the hook secret and is never tracked or restored. Listing never creates it (`readConfigBackupOrigin()`; only a pass or a restore does). A fresh or reinstalled data dir is a new owner; copying an `origin` from an old manifest into the file makes those snapshots visible again. -⚠️ **Retention survives a bad `settings.json`.** Each manifest records the retention in force. `runConfigBackup()` reads `settings.json` itself, because `readJsonConfig` answers `{}` for any failure, including a read that lands inside the non-atomic write of `PUT /api/settings`: a file that does not parse still gets its snapshot but prunes nothing, and a retention key ABSENT from the file (a reset) means what the newest manifest that recorded one holds (`withRecordedRetention()`, per key, in the folder the file names, so re-entering a custom folder after a reset finds that folder's own history). `GET /api/config-backups` reports the same values the pass uses, the form fills from them, and Save sends a retention key only when it was saved before or edited there (`_configBackupRetentionPayload()`), so merely displaying the recorded retention never writes it back. Restoring `settings.json` keeps the four `configBackup*` keys at their live values (an old `configBackupDir` would point the list at another folder and hide the undo copy just made), except a retention key the live file lacks, which keeps the backup's. While `settings.json` does not parse a pass reads it as `{}`, so its snapshot goes to the default folder; nothing is widened. +⚠️ **Retention survives a bad `settings.json`.** Each manifest records the retention in force. `runConfigBackup()` reads `settings.json` itself, because `readJsonConfig` answers `{}` for any failure, including a file that does not parse: a file that does not parse still gets its snapshot but prunes nothing, and a retention key ABSENT from the file (a reset) means what the newest manifest that recorded one holds (`withRecordedRetention()`, per key, in the folder the file names, so re-entering a custom folder after a reset finds that folder's own history). `GET /api/config-backups` reports the same values the pass uses, the form fills from them, and Save sends a retention key only when it was saved before or edited there (`_configBackupRetentionPayload()`), so merely displaying the recorded retention never writes it back. Restoring `settings.json` keeps the four `configBackup*` keys at their live values (an old `configBackupDir` would point the list at another folder and hide the undo copy just made), except a retention key the live file lacks, which keeps the backup's. While `settings.json` does not parse a pass reads it as `{}`, so its snapshot goes to the default folder; nothing is widened. ⚠️ **A custom folder is probed BOUNDED before anything reads it** (#516: it may be a network mount that stopped answering, and the GET runs every time App Settings opens). `probeBackupDir()` runs `probePathKind(dir, { pastCap: true })` before every pass and in every route, and `readEffectiveBackupSettings()` probes BEFORE the recorded-retention fallback lists the folder (that fallback once did a `readdirSync` first whenever only `configBackupDir` was set). `unknown` answers 503 `OPERATION_FAILED` through `describeUnknownPath()` and skips the pass; a missing custom folder is created only when its parent is present. The default folder needs no probe. `test/routes/config-backup-unreachable.test.ts` pins all four routes with a recording `node:fs` mock. diff --git a/src/web/routes/case-routes.ts b/src/web/routes/case-routes.ts index 7fbe7b5e2..4231185e1 100644 --- a/src/web/routes/case-routes.ts +++ b/src/web/routes/case-routes.ts @@ -78,6 +78,7 @@ import { parseBody, readJsonConfig, } from '../route-helpers.js'; +import { mutateSettingsFile, settingsWriteErrorResponse } from '../settings-writer.js'; import { isMultiUserMode } from '../../config/multiuser.js'; import type { AuthUser } from '../../types.js'; import { SseEvent } from '../sse-events.js'; @@ -1876,27 +1877,23 @@ export function registerCaseRoutes( } if (moveFolder) await moveCaseFolder(oldPath, newPath); const originalLinks = isLinked ? await readLinkedCases() : undefined; - // The settings.json content before this rename wrote it, set only once a write is attempted (an - // absent file reads as {}, which must not be written back as a file that never existed). - let originalSettings: Record<string, unknown> | undefined; + // The settings.json values this rename replaced, set only once it wrote them. A failure puts back + // these keys alone, so a settings save that landed in between is kept. + let settingsUndo: Record<string, unknown> | undefined; try { // settings.json is server-wide and caseOrder/lastUsedCase name the admin's cases, so only the // admin's rename touches it (a non-admin's own rename must not rewrite someone else's list). // Written before linked-cases.json so a failure here leaves the registry untouched. if (isAdmin(req)) { - const existing = await readJsonConfig<Record<string, unknown>>(SETTINGS_PATH, 'settings.json', {}); - const order = renameInOrder(existing.caseOrder, name, newName); - const last = existing.lastUsedCase === name ? newName : undefined; - if (order || last) { - originalSettings = existing; - await fs.writeFile( - SETTINGS_PATH, - JSON.stringify( - { ...existing, ...(order ? { caseOrder: order } : {}), ...(last ? { lastUsedCase: last } : {}) }, - null, - 2 - ) - ); + const { before, after } = await mutateSettingsFile((existing) => { + const order = renameInOrder(existing.caseOrder, name, newName); + const last = existing.lastUsedCase === name ? newName : undefined; + if (!order && !last) return undefined; + return { ...existing, ...(order ? { caseOrder: order } : {}), ...(last ? { lastUsedCase: last } : {}) }; + }); + if (after) { + const keys = ['caseOrder', 'lastUsedCase'].filter((k) => after[k] !== before[k]); + settingsUndo = Object.fromEntries(keys.map((k) => [k, before[k]])); } } if (originalLinks) { @@ -1913,9 +1910,8 @@ export function registerCaseRoutes( } catch (err) { // The folder moved but the registries did not follow: put settings.json and the folder back // rather than leave a case whose name and folder disagree. - if (originalSettings) { - await fs.writeFile(SETTINGS_PATH, JSON.stringify(originalSettings, null, 2)).catch(() => {}); - } + const undo = settingsUndo; + if (undo) await mutateSettingsFile((current) => ({ ...current, ...undo })).catch(() => {}); if (moveFolder) { const movedBack = await fs.rename(newPath, oldPath).then( () => true, @@ -1970,22 +1966,11 @@ export function registerCaseRoutes( const { order } = parseBody(CaseOrderSchema, req.body, 'Invalid order data'); try { - const dir = getDataDir(); - if (!existsSync(dir)) { - mkdirSync(dir, { recursive: true }); - } - let existing: Record<string, unknown> = {}; - try { - existing = JSON.parse(await fs.readFile(SETTINGS_PATH, 'utf-8')); - } catch { - /* ignore */ - } - const merged = { ...existing, caseOrder: order }; - await fs.writeFile(SETTINGS_PATH, JSON.stringify(merged, null, 2)); + await mutateSettingsFile((current) => ({ ...current, caseOrder: order })); ctx.broadcast(SseEvent.CaseOrderChanged, { order }); return { success: true, data: { order } }; } catch (err) { - return createErrorResponse(ApiErrorCode.OPERATION_FAILED, getErrorMessage(err)); + return settingsWriteErrorResponse(err); } }); diff --git a/src/web/routes/config-backup-routes.ts b/src/web/routes/config-backup-routes.ts index 5cf78eba7..a59d852d4 100644 --- a/src/web/routes/config-backup-routes.ts +++ b/src/web/routes/config-backup-routes.ts @@ -128,8 +128,8 @@ export async function runConfigBackup( reason: string, opts: { force?: boolean } = {} ): Promise<{ result: ConfigBackupResult; removed: string[] } | null> { - // Read settings.json directly: readJsonConfig answers {} for ANY failure, so a syntax error or a read - // that lands inside PUT /api/settings's non-atomic write used to prune with the defaults (20 / 30 + // Read settings.json directly: readJsonConfig answers {} for ANY failure, so a syntax error (or, before + // settings writes became atomic, a read that landed inside one) used to prune with the defaults (20 / 30 // days) and drop the longer history the user asked to keep. const raw = await readSettingsForBackup(); const unreadable = raw === 'unreadable'; diff --git a/src/web/routes/ralph-routes.ts b/src/web/routes/ralph-routes.ts index 0f624a19b..034568729 100644 --- a/src/web/routes/ralph-routes.ts +++ b/src/web/routes/ralph-routes.ts @@ -5,7 +5,7 @@ */ import { FastifyInstance } from 'fastify'; -import { join, dirname, resolve, relative, isAbsolute } from 'node:path'; +import { join, resolve, relative, isAbsolute } from 'node:path'; import { existsSync, mkdirSync, writeFileSync } from 'node:fs'; import fs from 'node:fs/promises'; import { ApiErrorCode, createErrorResponse, getErrorMessage, type ApiResponse } from '../../types.js'; @@ -20,10 +20,10 @@ import { ownerFor, resolveCasesDir, sessionCapacityMessage, - SETTINGS_PATH, findSessionOrFail, parseBody, } from '../route-helpers.js'; +import { mutateSettingsFile } from '../settings-writer.js'; import { resolveClaudeModeForUsername } from '../../user-store.js'; import { writeHooksConfig, stripCaseEnvKeys } from '../../hooks-config.js'; import { generateClaudeMd } from '../../templates/claude-md.js'; @@ -470,18 +470,10 @@ export function registerRalphRoutes( // in single-user mode. if (isAdmin(req)) { try { - let settings: Record<string, unknown> = {}; - try { - settings = JSON.parse(await fs.readFile(SETTINGS_PATH, 'utf-8')); - } catch { - /* ignore */ - } - settings.lastUsedCase = caseName; - const dir = dirname(SETTINGS_PATH); - if (!existsSync(dir)) mkdirSync(dir, { recursive: true }); - fs.writeFile(SETTINGS_PATH, JSON.stringify(settings, null, 2)).catch(() => {}); // Ignore - persisting lastUsedCase is non-critical - } catch { - /* non-critical */ + await mutateSettingsFile((settings) => ({ ...settings, lastUsedCase: caseName })); + } catch (err) { + // Non-critical: the loop starts regardless. + console.warn('[Server] Failed to save settings (lastUsedCase):', getErrorMessage(err)); } } diff --git a/src/web/routes/session-routes.ts b/src/web/routes/session-routes.ts index 7569a4dcb..ff8a68908 100644 --- a/src/web/routes/session-routes.ts +++ b/src/web/routes/session-routes.ts @@ -114,11 +114,11 @@ import { resolveCasesDir, resolveParentSessionId, sessionCapacityMessage, - SETTINGS_PATH, validatePathWithinBase, waitCapacityResponse, abortOnClientHangUp, } from '../route-helpers.js'; +import { mutateSettingsFile } from '../settings-writer.js'; import { buildAgentCaseMarker, writeAgentCaseMarker } from '../../agent-case-marker.js'; import { agentInbox } from '../agent-inbox.js'; import { canUsernameRunPrivilegedCommands, resolveClaudeModeForUsername } from '../../user-store.js'; @@ -4576,28 +4576,14 @@ export function registerSessionRoutes( // settings.json, readable by every user through GET /api/settings, so a non-admin's // case name is not written there in multi-user mode (writes are admin-only, like // PUT /api/settings). isAdmin is always true in single-user mode. + // Awaited and serialized with every other settings.json writer (mutateSettingsFile): the + // un-awaited write this replaced could land after the response, over a settings save. if (isAdmin(req)) { try { - const settingsFilePath = SETTINGS_PATH; - let settings: Record<string, unknown> = {}; - try { - settings = JSON.parse(await fs.readFile(settingsFilePath, 'utf-8')); - } catch (err) { - if ((err as NodeJS.ErrnoException).code !== 'ENOENT') throw err; - } - settings.lastUsedCase = caseName; - const dir = dirname(settingsFilePath); - if (!existsSync(dir)) { - mkdirSync(dir, { recursive: true }); - } - // Use async write to avoid blocking event loop - fs.writeFile(settingsFilePath, JSON.stringify(settings, null, 2)).catch((err) => { - // Non-critical but log for debugging - console.warn('[Server] Failed to save settings (lastUsedCase):', err); - }); + await mutateSettingsFile((settings) => ({ ...settings, lastUsedCase: caseName })); } catch (err) { - // Non-critical but log for debugging - console.warn('[Server] Failed to prepare settings update:', err); + // Non-critical: the session is already running. + console.warn('[Server] Failed to save settings (lastUsedCase):', getErrorMessage(err)); } } diff --git a/src/web/routes/system-routes.ts b/src/web/routes/system-routes.ts index 1ebbef006..8e1659f70 100644 --- a/src/web/routes/system-routes.ts +++ b/src/web/routes/system-routes.ts @@ -48,6 +48,7 @@ import { toggleService, SETTINGS_PATH, } from '../route-helpers.js'; +import { mutateSettingsFile, settingsWriteErrorResponse } from '../settings-writer.js'; import { SseEvent } from '../sse-events.js'; import { getInstallInfo, checkForUpdate, startUpdate, getUpdateStatusForApi } from '../self-update.js'; @@ -1032,11 +1033,10 @@ export function registerSystemRoutes( app.get('/api/settings', async (req) => { // A plain read. This route must NEVER write settings.json: readJsonConfig() - // answers `{}` for ANY read failure (a parse error, EACCES, EMFILE, a read - // that lands inside PUT's non-atomic write), not only for a missing file, - // and every page load calls this route, so a "persist the default when the - // key is absent" reconcile here replaced a whole settings file with one key - // on the first unlucky read. The plan-usage default is resolved by the + // answers `{}` for ANY read failure (a parse error, EACCES, EMFILE), not + // only for a missing file, and every page load calls this route, so a + // "persist the default when the key is absent" reconcile here replaced a + // whole settings file with one key on the first unlucky read. The plan-usage default is resolved by the // READERS instead: an absent `showPlanUsageLimits` means ON to // readPlanUsageTelemetryEnabled() (hooks-config.ts), the same way an absent // `workspaceHooksEnabled` means ON, and the client resolves its own display @@ -1096,21 +1096,11 @@ export function registerSystemRoutes( } try { - const dir = dirname(SETTINGS_PATH); - if (!existsSync(dir)) { - mkdirSync(dir, { recursive: true }); - } - let existing: Record<string, unknown> = {}; - try { - existing = JSON.parse(await fs.readFile(SETTINGS_PATH, 'utf-8')); - } catch { - /* ignore */ - } // acknowledgeUnauthTunnel is an ACTION field (not a stored setting) — strip // it before persisting so settings.json stays clean. const { acknowledgeUnauthTunnel, ...settingsToStore } = settings; + const { before: existing } = await mutateSettingsFile((current) => ({ ...current, ...settingsToStore })); const merged = { ...existing, ...settingsToStore }; - await fs.writeFile(SETTINGS_PATH, JSON.stringify(merged, null, 2)); // tmux 3.7+ resizes tracked panes; older versions apply this to new panes. // Already-evicted history cannot be recovered on either version. @@ -1196,7 +1186,7 @@ export function registerSystemRoutes( return {}; } catch (err) { - return createErrorResponse(ApiErrorCode.OPERATION_FAILED, getErrorMessage(err)); + return settingsWriteErrorResponse(err); } }); @@ -1214,25 +1204,10 @@ export function registerSystemRoutes( const modelConfig = parseBody(ModelConfigUpdateSchema, req.body, 'Invalid model config') as Record<string, unknown>; try { - let existingSettings: Record<string, unknown> = {}; - try { - const content = await fs.readFile(SETTINGS_PATH, 'utf-8'); - existingSettings = JSON.parse(content); - } catch { - // File doesn't exist yet, start fresh - } - - existingSettings.modelConfig = modelConfig; - - const dir = dirname(SETTINGS_PATH); - if (!existsSync(dir)) { - mkdirSync(dir, { recursive: true }); - } - await fs.writeFile(SETTINGS_PATH, JSON.stringify(existingSettings, null, 2)); - + await mutateSettingsFile((current) => ({ ...current, modelConfig })); return {}; } catch (err) { - return createErrorResponse(ApiErrorCode.OPERATION_FAILED, getErrorMessage(err)); + return settingsWriteErrorResponse(err); } }); diff --git a/src/web/server.ts b/src/web/server.ts index 2b3e582c4..4cbb13852 100644 --- a/src/web/server.ts +++ b/src/web/server.ts @@ -174,6 +174,7 @@ import { MAX_PASTE_IMAGE_BYTES } from '../config/buffer-limits.js'; import { resolveTerminalHistoryConfig } from '../config/terminal-history.js'; import { SseEvent } from './sse-events.js'; import { MAX_ROUTE_PARAM_LENGTH } from './route-helpers.js'; +import { mutateSettingsFile } from './settings-writer.js'; import { getLatestPlanUsage, setLatestCodexPlanUsage, setLatestCopilotPlanUsage } from './plan-usage-latest.js'; import { telemetrySignature } from '../usage-telemetry.js'; import { readCodexPlanUsage, resolveCodexBinaryPath } from '../utils/codex-cli-resolver.js'; @@ -3346,16 +3347,12 @@ export class WebServer extends EventEmitter { // Tunnel only starts when user clicks the toggle in the UI — never on boot. // Reset persisted tunnelEnabled so the UI toggle reflects actual state. if (await this.isTunnelEnabled()) { - const settingsPath = dataPath('settings.json'); try { - const content = await fs.readFile(settingsPath, 'utf-8'); - const settings = JSON.parse(content); - settings.tunnelEnabled = false; - await fs.writeFile(settingsPath, JSON.stringify(settings, null, 2)); - } catch { - /* ignore */ + await mutateSettingsFile((settings) => ({ ...settings, tunnelEnabled: false })); + console.log('Cloudflare tunnel setting reset (tunnel only starts on explicit UI toggle)'); + } catch (err) { + console.warn('Could not reset the Cloudflare tunnel setting:', getErrorMessage(err)); } - console.log('Cloudflare tunnel setting reset (tunnel only starts on explicit UI toggle)'); } // Start team watcher for agent team awareness (always on — lightweight polling) diff --git a/src/web/settings-writer.ts b/src/web/settings-writer.ts new file mode 100644 index 000000000..246892911 --- /dev/null +++ b/src/web/settings-writer.ts @@ -0,0 +1,107 @@ +/** + * @fileoverview The writer of the server's `settings.json` (`SETTINGS_PATH`). + * + * Every read-modify-write of that file goes through `mutateSettingsFile()`, the same shape as + * `mutateRegistryFile()` (config/cli-registry/registry-writer.ts). The one direct writer is + * config-backup restore (`restoreConfigBackup()`, as for `clis.json`): it must work on a file that + * does not parse, which this writer refuses. + * + * - **Serialized.** Mutations run one at a time on a single promise chain, each one reading, + * changing and writing before the next starts. The writers used to run unserialized with a + * plain truncate-then-write `fs.writeFile`, and quick-start did not even await its + * `lastUsedCase` write, so a quick-start followed by a settings save could tear the file (one + * write's bytes over the tail of a longer one) or drop the other writer's key. + * - **Atomic.** Each write goes to its own temp file beside the target and is renamed over it, + * so a reader never sees a half-written file. + * - **Refuses a file it must not trust.** Only a missing (or empty) file starts from `{}`. A file + * that cannot be read or does not parse is left alone and the write throws + * `SettingsWriteRefusedError`: the writers used to read such a file as `{}` and write back only + * their own keys, which erased every other setting. + */ + +import { randomUUID } from 'node:crypto'; +import fs from 'node:fs/promises'; +import { dirname } from 'node:path'; +import { ApiErrorCode, createErrorResponse, getErrorMessage, type ApiResponse } from '../types.js'; +import { SETTINGS_PATH } from './route-helpers.js'; + +export type SettingsFile = Record<string, unknown>; + +/** A write refused because the existing `settings.json` must not be overwritten. The message is user-facing. */ +export class SettingsWriteRefusedError extends Error { + constructor(message: string) { + super(message); + this.name = 'SettingsWriteRefusedError'; + } +} + +async function readSettingsForWrite(path: string): Promise<SettingsFile> { + let raw: string; + try { + raw = await fs.readFile(path, 'utf-8'); + } catch (err) { + if ((err as NodeJS.ErrnoException).code === 'ENOENT') return {}; + throw new SettingsWriteRefusedError( + `Cannot read ${path} (${(err as Error).message}), so this change was not saved.` + ); + } + // An empty file holds no settings to lose. + if (raw.trim() === '') return {}; + let parsed: unknown; + try { + parsed = JSON.parse(raw); + } catch { + parsed = undefined; + } + // The parser's own message is not repeated: it can quote the file, which may hold an API key. + if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) { + throw new SettingsWriteRefusedError( + `${path} is not a valid JSON object, so this change was not saved (saving would have replaced every other setting in it). Fix or delete the file, or restore it from a config backup, then try again.` + ); + } + return parsed as SettingsFile; +} + +async function writeSettingsFile(path: string, settings: SettingsFile): Promise<void> { + // Through a symlink, as the plain writeFile this replaces did: the rename lands on the real file. + const target = await fs.realpath(path).catch(() => path); + await fs.mkdir(dirname(target), { recursive: true }); + const tmp = `${target}.${process.pid}.${randomUUID()}.tmp`; + try { + await fs.writeFile(tmp, JSON.stringify(settings, null, 2), { mode: 0o600 }); + await fs.rename(tmp, target); + } catch (err) { + await fs.rm(tmp, { force: true }).catch(() => {}); + throw err; + } +} + +let mutationChain: Promise<unknown> = Promise.resolve(); + +/** + * Run one change to `settings.json`. `fn` receives the current settings and returns the settings + * to write, or `undefined` to write nothing. Resolves with the settings before and after the + * change (`after` is `undefined` when nothing was written). A refused or failed mutation rejects + * its own caller only; the chain keeps going. + */ +export function mutateSettingsFile( + fn: (current: SettingsFile) => SettingsFile | undefined +): Promise<{ before: SettingsFile; after: SettingsFile | undefined }> { + const run = mutationChain.then(async () => { + const before = await readSettingsForWrite(SETTINGS_PATH).catch((err: unknown) => { + console.warn(`[settings] write refused: ${getErrorMessage(err)}`); + throw err; + }); + const after = fn({ ...before }); + if (after) await writeSettingsFile(SETTINGS_PATH, after); + return { before, after }; + }); + mutationChain = run.catch(() => {}); + return run; +} + +/** A refused write answers 409 CONFLICT with the fix; any other failure stays OPERATION_FAILED. */ +export function settingsWriteErrorResponse(err: unknown): ApiResponse<never> { + if (err instanceof SettingsWriteRefusedError) return createErrorResponse(ApiErrorCode.CONFLICT, err.message); + return createErrorResponse(ApiErrorCode.OPERATION_FAILED, getErrorMessage(err)); +} diff --git a/test/routes/case-rename-routes.test.ts b/test/routes/case-rename-routes.test.ts index 94a587215..8ef016da7 100644 --- a/test/routes/case-rename-routes.test.ts +++ b/test/routes/case-rename-routes.test.ts @@ -10,6 +10,7 @@ import { existsSync, mkdirSync, mkdtempSync, + readdirSync, readFileSync, realpathSync, symlinkSync, @@ -25,6 +26,7 @@ import { SETTINGS_PATH, resolveCasesDir } from '../../src/web/route-helpers.js'; import { caseSessionSettled, claudeProjectDirName } from '../../src/case-rename.js'; import { Session } from '../../src/session.js'; import { safeRmHomeTree } from '../mocks/index.js'; +import { mutateSettingsFile } from '../../src/web/settings-writer.js'; const LINKED = () => dataPath('linked-cases.json'); const CASES_DIR = () => join(homedir(), 'codeman-cases'); @@ -286,6 +288,47 @@ describe('POST /api/cases/:name/rename (a linked case)', () => { expect(settings()).toEqual({ caseOrder: ['a', 'proj', 'b'], lastUsedCase: 'proj' }); }); + it('puts back only the keys it wrote, keeping a settings write made in between', async () => { + const { app } = await createRouteTestHarness(registerCaseRoutes); + const real = join(work, 'my-project'); + mkdirSync(real); + writeFileSync(LINKED(), JSON.stringify({ proj: real })); + writeFileSync(SETTINGS_PATH, JSON.stringify({ caseOrder: ['a', 'proj', 'b'], lastUsedCase: 'proj' })); + chmodSync(LINKED(), 0o444); + // Another writer (a settings save) lands after the rename wrote settings.json and before the + // registry write fails. + const realWriteFile = fs.writeFile; + const spy = vi.spyOn(fs, 'writeFile').mockImplementation(async (file, data, options) => { + if (file === LINKED()) await mutateSettingsFile((current) => ({ ...current, agentSkillEnabled: true })); + return realWriteFile(file, data, options); + }); + try { + const res = await rename(app, 'proj', { newName: 'renamed', renameFolder: true }); + expect(res.statusCode).toBe(500); + } finally { + spy.mockRestore(); + } + expect(settings()).toEqual({ caseOrder: ['a', 'proj', 'b'], lastUsedCase: 'proj', agentSkillEnabled: true }); + }); + + it('refuses to rename while settings.json is not a JSON object, leaving the folder and the file alone', async () => { + const { app } = await createRouteTestHarness(registerCaseRoutes); + const oldDir = localCase('alpha'); + const torn = '{\n "agentSkillEnabled": true\n}tail"\n}'; + writeFileSync(SETTINGS_PATH, torn); + const res = await rename(app, 'alpha', { newName: 'beta' }); + expect(res.statusCode).toBe(500); + expect(res.json()).toEqual({ + success: false, + errorCode: 'OPERATION_FAILED', + error: `${SETTINGS_PATH} is not a valid JSON object, so this change was not saved (saving would have replaced every other setting in it). Fix or delete the file, or restore it from a config backup, then try again.`, + stoppedSessions: 0, + }); + expect(existsSync(oldDir)).toBe(true); + expect(existsSync(join(CASES_DIR(), 'beta'))).toBe(false); + expect(readFileSync(SETTINGS_PATH, 'utf8')).toBe(torn); + }); + it('says where the folder is when it cannot be moved back', async () => { const { app } = await createRouteTestHarness(registerCaseRoutes); const real = join(work, 'my-project'); @@ -328,12 +371,24 @@ describe('POST /api/cases/:name/rename (a linked case)', () => { mkdirSync(real); writeFileSync(LINKED(), JSON.stringify({ proj: real, other: join(work, 'x') })); writeFileSync(SETTINGS_PATH, JSON.stringify({ caseOrder: ['proj'] })); - chmodSync(SETTINGS_PATH, 0o444); - const res = await rename(app, 'proj', { newName: 'renamed', renameFolder: true }); - chmodSync(SETTINGS_PATH, 0o644); + // settings.json is replaced by renaming a temp file over it, which a read-only file does not stop, + // so the failure is injected at that rename. + const realRename = fs.rename; + const spy = vi.spyOn(fs, 'rename').mockImplementation(async (from, to) => { + if (to === SETTINGS_PATH) throw Object.assign(new Error('permission denied'), { code: 'EACCES' }); + return realRename(from, to); + }); + let res; + try { + res = await rename(app, 'proj', { newName: 'renamed', renameFolder: true }); + } finally { + spy.mockRestore(); + } expect(res.statusCode).toBe(500); expect(existsSync(real)).toBe(true); expect(linked()).toEqual({ proj: real, other: join(work, 'x') }); + // The failed write leaves no temp file behind. + expect(readdirSync(dirname(SETTINGS_PATH)).filter((n) => n.startsWith('settings.json.'))).toEqual([]); }); it('answers 404 for a linked case a non-admin cannot see in multi-user mode', async () => { diff --git a/test/routes/system-routes-settings-admin-only.test.ts b/test/routes/system-routes-settings-admin-only.test.ts index 49ea02cf7..0980862ca 100644 --- a/test/routes/system-routes-settings-admin-only.test.ts +++ b/test/routes/system-routes-settings-admin-only.test.ts @@ -54,6 +54,11 @@ vi.mock('node:fs/promises', () => ({ default: { readFile: vi.fn(async () => JSON.stringify(STORED)), writeFile: vi.fn(async () => undefined), + // settings.json is written to a temp file and renamed over the real one (settings-writer.ts). + realpath: vi.fn(async (p: string) => p), + mkdir: vi.fn(async () => undefined), + rename: vi.fn(async () => undefined), + rm: vi.fn(async () => undefined), }, })); diff --git a/test/routes/system-routes-settings-get-plan-usage-default.test.ts b/test/routes/system-routes-settings-get-plan-usage-default.test.ts index f2496d722..2e6b9ab33 100644 --- a/test/routes/system-routes-settings-get-plan-usage-default.test.ts +++ b/test/routes/system-routes-settings-get-plan-usage-default.test.ts @@ -4,8 +4,8 @@ * * PR #361 once made it reconcile `showPlanUsageLimits` on first read: if the * key was absent, the route persisted `true`. But readJsonConfig() answers - * `{}` for ANY read failure (parse error, EACCES, EMFILE, a read landing inside - * PUT's non-atomic write), not only ENOENT, and every page load calls this + * `{}` for ANY read failure (parse error, EACCES, EMFILE, and at the time a read + * landing inside PUT's non-atomic write), not only ENOENT, and every page load calls this * route, so one unlucky read replaced the whole settings file with a one-key * file. The default now lives in the readers instead: an absent key means ON * to readPlanUsageTelemetryEnabled() (pinned in test/hooks-config.test.ts) and diff --git a/test/routes/system-routes-settings-partial-put.test.ts b/test/routes/system-routes-settings-partial-put.test.ts index 416b1d646..afe22c853 100644 --- a/test/routes/system-routes-settings-partial-put.test.ts +++ b/test/routes/system-routes-settings-partial-put.test.ts @@ -70,6 +70,11 @@ vi.mock('node:fs/promises', () => ({ default: { readFile: vi.fn(async () => JSON.stringify(EXISTING_SETTINGS)), writeFile: vi.fn(async () => undefined), + // settings.json is written to a temp file and renamed over the real one (settings-writer.ts). + realpath: vi.fn(async (p: string) => p), + mkdir: vi.fn(async () => undefined), + rename: vi.fn(async () => undefined), + rm: vi.fn(async () => undefined), }, })); diff --git a/test/routes/system-routes-tunnel-guard.test.ts b/test/routes/system-routes-tunnel-guard.test.ts index 8da2b6740..a03e48db7 100644 --- a/test/routes/system-routes-tunnel-guard.test.ts +++ b/test/routes/system-routes-tunnel-guard.test.ts @@ -20,6 +20,11 @@ vi.mock('node:fs/promises', () => ({ default: { readFile: vi.fn(async () => '{}'), writeFile: vi.fn(async () => undefined), + // settings.json is written to a temp file and renamed over the real one (settings-writer.ts). + realpath: vi.fn(async (p: string) => p), + mkdir: vi.fn(async () => undefined), + rename: vi.fn(async () => undefined), + rm: vi.fn(async () => undefined), }, })); diff --git a/test/routes/system-routes.test.ts b/test/routes/system-routes.test.ts index a5550aedc..7b402fcde 100644 --- a/test/routes/system-routes.test.ts +++ b/test/routes/system-routes.test.ts @@ -22,6 +22,11 @@ vi.mock('node:fs/promises', () => ({ default: { readFile: vi.fn(async () => '{}'), writeFile: vi.fn(async () => undefined), + // settings.json is written to a temp file and renamed over the real one (settings-writer.ts). + realpath: vi.fn(async (p: string) => p), + mkdir: vi.fn(async () => undefined), + rename: vi.fn(async () => undefined), + rm: vi.fn(async () => undefined), }, })); diff --git a/test/settings-admin-only-multiuser.test.ts b/test/settings-admin-only-multiuser.test.ts index 533007ae4..e2407d430 100644 --- a/test/settings-admin-only-multiuser.test.ts +++ b/test/settings-admin-only-multiuser.test.ts @@ -53,7 +53,7 @@ async function send(method: string, path: string, headers: Record<string, string return { status: res.status, body: (await res.json()) as Record<string, any> }; } -/** The quick-start write of lastUsedCase is fire-and-forget: poll for it briefly. */ +/** Read settings.json until `predicate` holds (briefly), so a write that never comes fails on its value. */ async function settleSettings(predicate: (s: Record<string, unknown>) => boolean): Promise<Record<string, unknown>> { let last: Record<string, unknown> = {}; for (let i = 0; i < 40; i++) { @@ -61,7 +61,7 @@ async function settleSettings(predicate: (s: Record<string, unknown>) => boolean last = JSON.parse(await readSettingsBytes()); if (predicate(last)) return last; } catch { - // the write is not atomic: a read that lands between its truncate and its data is empty, so poll again + // not there yet, or not JSON: poll again } await new Promise((r) => setTimeout(r, 25)); } @@ -127,7 +127,7 @@ describe('a non-admin and the server-wide settings.json', () => { const mine = await send('POST', '/api/quick-start', bob, { caseName: 'bobproj' }); expect(mine.status).toBe(200); expect(mine.body.success).toBe(true); - // Give a (wrong) fire-and-forget write every chance to land before checking. + // Give a (wrong) write every chance to land before checking. await new Promise((r) => setTimeout(r, 200)); expect(JSON.parse(await readSettingsBytes()).lastUsedCase).toBe('rootcase'); diff --git a/test/settings-json-write-serialization.test.ts b/test/settings-json-write-serialization.test.ts new file mode 100644 index 000000000..70b283c13 --- /dev/null +++ b/test/settings-json-write-serialization.test.ts @@ -0,0 +1,220 @@ +/** + * @fileoverview Every writer of the server's settings.json is serialized, atomic, and refuses to + * overwrite a file that does not parse (live server, ephemeral port, real file on disk). + * + * The upstream CI flake in test/quick-start.test.ts ("accepts the setting via PUT, then injects") + * was a torn settings.json: `SyntaxError: Unexpected non-whitespace character after JSON at + * position 31`. Quick-start saved `lastUsedCase` with an un-awaited, non-atomic read-modify-write, + * PUT /api/settings ran its own unserialized one, and the two overlapping truncate-then-write + * calls left one write's bytes over the tail of the other. The PUT then read the torn file as `{}` + * and wrote back only its own keys, erasing every other setting. + * + * Writes to settings.json (and its temp files) are slowed down here, which is what a busy CI disk + * does: a writer that answers before its write lands, or two writers that both read before either + * writes, then fail deterministically instead of once in a while. + */ + +import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'; +import fsp from 'node:fs/promises'; +import { + lstatSync, + mkdirSync, + mkdtempSync, + readFileSync, + readlinkSync, + rmSync, + statSync, + symlinkSync, + writeFileSync, +} from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { WebServer } from '../src/web/server.js'; +import { TmuxManager } from '../src/tmux-manager.js'; +import { SETTINGS_PATH } from '../src/web/route-helpers.js'; +import { getCasesDir } from '../src/config/cases-dir.js'; + +vi.spyOn(TmuxManager, 'isTmuxAvailable').mockReturnValue(true); + +/** How long every write to settings.json (or a temp file beside it) is held before it starts. */ +const SLOW_WRITE_MS = 200; +const realWriteFile = fsp.writeFile.bind(fsp); +vi.spyOn(fsp, 'writeFile').mockImplementation((async (file: unknown, data: unknown, options?: unknown) => { + if (typeof file === 'string' && file.startsWith(SETTINGS_PATH)) { + await new Promise((resolve) => setTimeout(resolve, SLOW_WRITE_MS)); + } + return realWriteFile(file as string, data as string, options as undefined); +}) as typeof fsp.writeFile); + +/** The bytes the CI run left behind: the PUT's 31-byte file over the tail of quick-start's longer one. */ +const TORN = '{\n "agentSkillEnabled": true\n}' + 'rt-1761000000000"\n}'; + +const REFUSAL = { + success: false, + errorCode: 'CONFLICT', + error: `${SETTINGS_PATH} is not a valid JSON object, so this change was not saved (saving would have replaced every other setting in it). Fix or delete the file, or restore it from a config backup, then try again.`, +}; + +let server: WebServer; +const url = (p: string) => `http://localhost:${server.boundPort}${p}`; +const seed = (value: unknown) => writeFileSync(SETTINGS_PATH, JSON.stringify(value, null, 2)); +const readSettings = () => JSON.parse(readFileSync(SETTINGS_PATH, 'utf-8')) as Record<string, unknown>; + +async function send(method: string, path: string, body: unknown) { + const res = await fetch(url(path), { + method, + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body), + }); + return { status: res.status, body: (await res.json()) as Record<string, unknown> }; +} + +let caseCounter = 0; +const newCaseName = () => `settings-race-${process.pid}-${++caseCounter}`; + +beforeAll(async () => { + // The boot pass resets a persisted tunnelEnabled (the tunnel only starts from the UI); that write + // goes through the same writer and must keep every other key. + mkdirSync(join(SETTINGS_PATH, '..'), { recursive: true }); + seed({ tunnelEnabled: true, caseOrder: ['boot'] }); + server = new WebServer(0, false, true); + await server.start(); +}, 30000); + +afterAll(async () => { + await server?.stop(); + vi.restoreAllMocks(); +}, 30000); + +describe('settings.json writes', () => { + it('resets tunnelEnabled at boot without dropping the other keys', () => { + expect(readSettings()).toEqual({ tunnelEnabled: false, caseOrder: ['boot'] }); + }); + + describe('with a valid file', () => { + beforeEach(() => seed({ lastUsedCase: 'seeded' })); + + it('answers a quick-start only after its lastUsedCase has landed', async () => { + const caseName = newCaseName(); + const res = await send('POST', '/api/quick-start', { caseName }); + expect(res.body.success).toBe(true); + expect(readSettings()).toEqual({ lastUsedCase: caseName }); + }); + + it('answers a Ralph loop start only after its lastUsedCase has landed', async () => { + const caseName = newCaseName(); + const res = await send('POST', '/api/ralph-loop/start', { caseName, taskDescription: 'x' }); + expect(res.body.success).toBe(true); + expect(readSettings()).toEqual({ lastUsedCase: caseName }); + }); + + it('keeps every key when quick-start, the settings PUT, model config and case order write at once', async () => { + const caseName = newCaseName(); + const [quick, put, model, order] = await Promise.all([ + send('POST', '/api/quick-start', { caseName }), + send('PUT', '/api/settings', { agentSkillEnabled: true }), + send('PUT', '/api/execution/model-config', { defaultModel: 'opus' }), + send('PUT', '/api/cases/order', { order: ['a', 'b'] }), + ]); + expect(quick.body.success).toBe(true); + expect(put).toEqual({ status: 200, body: { success: true, data: {} } }); + expect(model).toEqual({ status: 200, body: { success: true, data: {} } }); + expect(order).toEqual({ status: 200, body: { success: true, data: { order: ['a', 'b'] } } }); + expect(readSettings()).toEqual({ + lastUsedCase: caseName, + agentSkillEnabled: true, + modelConfig: { defaultModel: 'opus' }, + caseOrder: ['a', 'b'], + }); + }); + + it('keeps a settings PUT that lands while a case rename rewrites caseOrder', async () => { + const oldName = newCaseName(); + const newName = newCaseName(); + mkdirSync(join(getCasesDir(), oldName), { recursive: true }); + seed({ lastUsedCase: oldName, caseOrder: [oldName, 'other'] }); + const [rename, put] = await Promise.all([ + send('POST', `/api/cases/${oldName}/rename`, { newName }), + send('PUT', '/api/settings', { agentSkillEnabled: true }), + ]); + expect(rename.body.success).toBe(true); + expect(put).toEqual({ status: 200, body: { success: true, data: {} } }); + expect(readSettings()).toEqual({ lastUsedCase: newName, caseOrder: [newName, 'other'], agentSkillEnabled: true }); + }); + + it('writes the file 0600, since it can hold an API key', async () => { + await send('PUT', '/api/settings', { agentSkillEnabled: true }); + expect(statSync(SETTINGS_PATH).mode & 0o777).toBe(0o600); + }); + }); + + it('starts from {} when the file is missing or empty', async () => { + rmSync(SETTINGS_PATH, { force: true }); + expect(await send('PUT', '/api/settings', { agentSkillEnabled: true })).toEqual({ + status: 200, + body: { success: true, data: {} }, + }); + expect(readFileSync(SETTINGS_PATH, 'utf-8')).toBe('{\n "agentSkillEnabled": true\n}'); + + writeFileSync(SETTINGS_PATH, ' \n'); + await send('PUT', '/api/execution/model-config', { defaultModel: 'opus' }); + expect(readSettings()).toEqual({ modelConfig: { defaultModel: 'opus' } }); + }); + + it('writes through a symlinked settings.json to its target', async () => { + const elsewhere = mkdtempSync(join(tmpdir(), 'codeman-settings-link-')); + const target = join(elsewhere, 'settings.json'); + writeFileSync(target, JSON.stringify({ lastUsedCase: 'linked' })); + rmSync(SETTINGS_PATH, { force: true }); + symlinkSync(target, SETTINGS_PATH); + try { + await send('PUT', '/api/settings', { agentSkillEnabled: true }); + expect(lstatSync(SETTINGS_PATH).isSymbolicLink()).toBe(true); + expect(readlinkSync(SETTINGS_PATH)).toBe(target); + expect(JSON.parse(readFileSync(target, 'utf-8'))).toEqual({ lastUsedCase: 'linked', agentSkillEnabled: true }); + } finally { + rmSync(SETTINGS_PATH, { force: true }); + rmSync(elsewhere, { recursive: true, force: true }); + } + }); + + describe('refuses to overwrite a file that is not a JSON object, and leaves its bytes alone', () => { + for (const [label, bytes] of [ + ['a torn file', TORN], + ['a JSON array', '[1, 2]'], + ['JSON null', 'null'], + ['a JSON string', '"settings"'], + ] as const) { + it(`${label}: every settings write route answers 409 CONFLICT`, async () => { + writeFileSync(SETTINGS_PATH, bytes); + expect(await send('PUT', '/api/settings', { agentSkillEnabled: true })).toEqual({ status: 409, body: REFUSAL }); + expect(await send('PUT', '/api/execution/model-config', { defaultModel: 'opus' })).toEqual({ + status: 409, + body: REFUSAL, + }); + expect(await send('PUT', '/api/cases/order', { order: ['a'] })).toEqual({ status: 409, body: REFUSAL }); + expect(readFileSync(SETTINGS_PATH, 'utf-8')).toBe(bytes); + }); + } + + it('a torn file: quick-start and a Ralph loop start still succeed without touching it', async () => { + writeFileSync(SETTINGS_PATH, TORN); + expect((await send('POST', '/api/quick-start', { caseName: newCaseName() })).body.success).toBe(true); + expect( + (await send('POST', '/api/ralph-loop/start', { caseName: newCaseName(), taskDescription: 'x' })).body.success + ).toBe(true); + expect(readFileSync(SETTINGS_PATH, 'utf-8')).toBe(TORN); + }); + + it('a torn file: a refusal does not wedge later writes once the file is fixed', async () => { + writeFileSync(SETTINGS_PATH, TORN); + expect((await send('PUT', '/api/settings', { agentSkillEnabled: true })).status).toBe(409); + seed({ lastUsedCase: 'fixed' }); + expect(await send('PUT', '/api/settings', { agentSkillEnabled: true })).toEqual({ + status: 200, + body: { success: true, data: {} }, + }); + expect(readSettings()).toEqual({ lastUsedCase: 'fixed', agentSkillEnabled: true }); + }); + }); +}); From fc0ecc6051373e0f8fd98588035ff41e6b30543c Mon Sep 17 00:00:00 2001 From: JD <jd@jds.haus> Date: Mon, 26 Oct 2026 02:11:16 -0400 Subject: [PATCH 42/68] fix(tunnel): give the unauthenticated tunnel its own acknowledgement The network acknowledgement that install.sh writes for every passwordless LAN install also satisfied the tunnel guard, so one click on the welcome screen's tunnel link published full terminal control to a public URL with no login and no confirm. The tunnel now needs CODEMAN_ALLOW_UNAUTHENTICATED_TUNNEL or --allow-unauthenticated-tunnel (carried by web -d and service install), and with no login the browser asks before every enable, reading a new loginConfigured field from GET /api/tunnel/status. The guard is now decided once per request and required again where the tunnel starts, so a save that found the tunnel running can no longer restart it unguarded after a concurrent stop. Closes #706 --- .changeset/tunnel-own-acknowledgement.md | 5 + CLAUDE.md | 2 +- docs/api-reference.md | 3 +- docs/architecture-invariants.md | 2 +- docs/security-architecture.md | 18 +- docs/tailscale-installer-plan.md | 2 +- docs/wiki/Remote-Access.md | 6 +- docs/wiki/Security.md | 3 +- docs/wiki/Settings-Reference.md | 3 +- src/cli.ts | 18 +- src/daemon-control.ts | 2 + src/web/network-auth-policy.ts | 19 +- src/web/ports/infra-port.ts | 2 + src/web/public/settings-ui.js | 103 +++++- src/web/routes/system-routes.ts | 111 ++++-- src/web/schemas.ts | 2 +- src/web/server.ts | 21 +- test/cli-commands.test.ts | 26 ++ test/cli-tunnel-flag.test.ts | 102 ++++++ test/daemon-control.test.ts | 30 ++ test/install-sh-invariants.test.ts | 4 + test/mocks/mock-route-context.ts | 1 + .../routes/system-routes-tunnel-guard.test.ts | 336 +++++++++++++----- test/service-installer.test.ts | 24 ++ test/tunnel-enable-confirm-ui.test.ts | 230 ++++++++++++ test/tunnel-flag-plumbing.test.ts | 101 ++++++ 26 files changed, 1020 insertions(+), 156 deletions(-) create mode 100644 .changeset/tunnel-own-acknowledgement.md create mode 100644 test/cli-tunnel-flag.test.ts create mode 100644 test/tunnel-enable-confirm-ui.test.ts create mode 100644 test/tunnel-flag-plumbing.test.ts diff --git a/.changeset/tunnel-own-acknowledgement.md b/.changeset/tunnel-own-acknowledgement.md new file mode 100644 index 000000000..1b8858ad0 --- /dev/null +++ b/.changeset/tunnel-own-acknowledgement.md @@ -0,0 +1,5 @@ +--- +'aicodeman': patch +--- + +Security fix: `CODEMAN_ALLOW_UNAUTHENTICATED_NETWORK` (and `--allow-unauthenticated-network`) no longer lets the Cloudflare tunnel start without a password. It now acknowledges only an unauthenticated network bind, which is what the installer writes it for on a LAN install without a password, so on those installs one click on the welcome screen's tunnel link published full terminal control to a public URL with no login and no confirm. An unauthenticated tunnel has its own opt-in now, `CODEMAN_ALLOW_UNAUTHENTICATED_TUNNEL=1` or `codeman web --allow-unauthenticated-tunnel` (carried by `codeman web -d` and `codeman service install`), which the installer never writes. If you relied on the network variable to start the tunnel without a password, set one of those instead. Without it the server still accepts an enable that carries `acknowledgeUnauthTunnel: true`, as before. With no password and no multi-user mode, the browser now asks before every tunnel enable from the welcome link, the tunnel panel and App Settings, whatever the server would accept, using the new `loginConfigured` field of `GET /api/tunnel/status`. diff --git a/CLAUDE.md b/CLAUDE.md index bd2594acd..8b10dc8eb 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -281,7 +281,7 @@ Codeman is a Claude Code session manager with web interface and autonomous Ralph | **Rate limit** | 10 failed auth/IP → 429 (15min decay). Multi-user mode instead checks 50/IP (`AUTH_IP_FAILURE_MAX`) and 10 per (username, IP) BEFORE the password, so past either even a correct password from that IP waits (the account still logs in from another IP), plus 10 per username AFTER it (429 to wrong guesses from any IP, never to a correct password). QR and hook-secret have separate buckets, so neither can lock out login | | **Hook bypass** | `/api/hook-event` + `/api/status-telemetry` skip Basic auth (localhost-only, schema-validated), but when auth is active the loopback bypass requires `X-Codeman-Hook-Secret` **unconditionally** (Codeman cannot detect a user's own loopback reverse proxy) | | **Lost-frame page** | The THIRD unauthenticated 200, beside the two hook routes, and the only one decided by request headers alone: a `GET`/`HEAD` carrying `Sec-Fetch-Dest: iframe\|frame`, `Accept: text/html` and mode `navigate` (or none), for a path that is NOT a registered route (never `/api/`, `/ws/`, `/q/`), is answered BEFORE the credential checks with the static web-tab recovery page (`lostWebviewFramePage`: no reflected input, `default-src 'none'` plus its own script hash, `no-store`). `/` is the one registered route also admitted, only when the request carries neither `codeman_session` nor `Authorization` (nothing in Codeman frames its own root; a sandboxed frame has neither), since the landing page masks to exactly `/` and its reload otherwise rendered Codeman inside the web tab. ⚠️ A non-browser client can set those headers, so an unauthenticated caller can tell a registered route (401) from a non-route (200) and enumerate the route table; accepted, the routes are public in `docs/api-reference.md`. Pinned by `test/webview-auth-exemption.test.ts` + `test/webview-lost-root-frame.test.ts` | -| **Tunnel** | Enabling a tunnel **refuses** without `CODEMAN_PASSWORD` unless exposure is acknowledged via `CODEMAN_ALLOW_UNAUTHENTICATED_NETWORK=1` or the per-request `acknowledgeUnauthTunnel:true` action field (never persisted) | +| **Tunnel** | Enabling a tunnel **refuses** without a login (`CODEMAN_PASSWORD` or multi-user mode) unless exposure is acknowledged via `CODEMAN_ALLOW_UNAUTHENTICATED_TUNNEL=1` / `--allow-unauthenticated-tunnel` or the per-request `acknowledgeUnauthTunnel:true` action field (never persisted). `CODEMAN_ALLOW_UNAUTHENTICATED_NETWORK` acknowledges the BIND only and never satisfies this guard (#706: `install.sh` writes it for every passwordless LAN install). The authorization is decided ONCE per request, running or not, and required again at the `start()` site: the settings-file awaits between them let a concurrent stop land, and an unauthorized enable never writes `tunnelEnabled:true`. With no login the browser asks before every enable (welcome link, tunnel panel, App Settings save that turns it on) through ONE helper, `_confirmTunnelEnable()` (settings-ui.js), which reads `loginConfigured` from `GET /api/tunnel/status` (computed in the route by the guard's own test, never in `TunnelManager`); the 403 handler is the fallback when that read fails | | **Validation** | Zod schemas, Unicode-aware path allowlist regex, env prefix allowlist (`CLAUDE_CODE_*`/`OPENCODE_*`/`CODEX_*`/`GEMINI_*`/`GOOGLE_*`/`ANTIGRAVITY_*`/`PI_*`/`GROK_*`/`XAI_*`/`DSH_*`/`DEEPSEEK_*`/`OMP_*`/`COPILOT_*`) | | **Headers** | CORS localhost-only, CSP, X-Frame-Options, HSTS if HTTPS | diff --git a/docs/api-reference.md b/docs/api-reference.md index 95a28b178..7d892c480 100644 --- a/docs/api-reference.md +++ b/docs/api-reference.md @@ -1218,7 +1218,8 @@ A local case whose folder is the top of a git repository can keep **worktrees** | Method | Path | Body | Notes | | ------ | ----------------------------- | ------------------------------------ | ----- | | `GET` | `/api/settings` | none | The stored settings object. Never writes. For a non-admin in multi-user mode, without `voiceSettings.apiKey`, `caseOrder` and `lastUsedCase`. | -| `PUT` | `/api/settings` | partial settings (strict schema) | Merged into the stored file; services (watchers, tunnel, tmux history limit) reconcile to the merged result. `403 FORBIDDEN` for a non-admin in multi-user mode. A tunnel enable without `CODEMAN_PASSWORD` is refused separately (`403 OPERATION_FAILED`, resend with `acknowledgeUnauthTunnel: true`). | +| `PUT` | `/api/settings` | partial settings (strict schema) | Merged into the stored file; services (watchers, tunnel, tmux history limit) reconcile to the merged result. `403 FORBIDDEN` for a non-admin in multi-user mode. A tunnel enable with no login (no `CODEMAN_PASSWORD`, not multi-user) is refused separately (`403 OPERATION_FAILED`, resend with `acknowledgeUnauthTunnel: true`) unless the server runs with `CODEMAN_ALLOW_UNAUTHENTICATED_TUNNEL=1` or `--allow-unauthenticated-tunnel`. `CODEMAN_ALLOW_UNAUTHENTICATED_NETWORK` does not count; when it is set, the refusal says so. An enable without that authorization is accepted only while the tunnel is already running and never stores `tunnelEnabled: true`; if the tunnel stops before the request would start it, it is not restarted and the answer is the same `403 OPERATION_FAILED` (the request's other settings are saved). | +| `GET` | `/api/tunnel/status` | none | `{ running, url, ..., loginConfigured }`. `loginConfigured` is `true` when a login protects the server (`CODEMAN_PASSWORD` set or multi-user mode on), the same test the tunnel guard above applies; with `false` the browser asks before every tunnel enable. Neither opt-in variable makes it `true`. | | `GET` | `/api/execution/model-config` | none | `modelConfig` (cron's and the plan orchestrator's default model). | | `PUT` | `/api/execution/model-config` | `{ defaultModel?, agentTypeOverrides?, ... }` | Replaces `modelConfig`. `403 FORBIDDEN` for a non-admin in multi-user mode. | | `PUT` | `/api/cases/order` | `{ order: string[] }` | The one case order every user's `GET /api/cases` is sorted by. `403 FORBIDDEN` for a non-admin in multi-user mode. | diff --git a/docs/architecture-invariants.md b/docs/architecture-invariants.md index 7a743bb62..f36714124 100644 --- a/docs/architecture-invariants.md +++ b/docs/architecture-invariants.md @@ -1182,7 +1182,7 @@ Tests: `test/mobile-prompt-composer.test.ts` (in the CI gate, deliberately not u | **QR Auth** | Single-use 6-char tokens (60s TTL) for tunnel login. See `docs/qr-auth-plan.md` | | **Sessions** | 24h cookie (`codeman_session`), auto-extend, device context audit | | **Rate limit** | 10 failed logins/IP → 429 (15min decay). Multi-user mode instead has two gates checked before scrypt, so past either even a correct password from that IP gets 429: 50/IP (`AUTH_IP_FAILURE_MAX`) and 10 per (username, IP) (`AUTH_FAILURE_MAX`; the same account still logs in from another IP). A 10-per-username bucket (multi-user only) is checked after the verify: 429 to failed guesses from the 10th on, from any IP, never to a correct password. QR has separate limiter | -| **Hook bypass** | `/api/hook-event` (and `/api/status-telemetry`, the statusLine exporter) skip Basic auth (localhost-only, schema-validated). When auth is active (`CODEMAN_PASSWORD` set), the loopback bypass requires the per-instance `X-Codeman-Hook-Secret` header **unconditionally** — COD-54 introduced it tunnel-gated; COD-91 (PR #127) made it always-on because Codeman can't detect a user's own loopback reverse proxy (own cloudflared/`tailscale serve`/nginx → 127.0.0.1), closing that residual plain-bypass gap. Hook curls cat the secret file at exec time via `$CODEMAN_HOOK_SECRET_FILE` (session env, `config/hook-secret.ts`); a missing/wrong secret gets 401 and rate-limits in a dedicated bucket (never locks out login). Tunnel enable **refuses** without `CODEMAN_PASSWORD` unless exposure is acknowledged — via `CODEMAN_ALLOW_UNAUTHENTICATED_NETWORK=1` (env, COD-55) **or** the per-request `acknowledgeUnauthTunnel:true` action field (1.1.9): the welcome/settings tunnel toggle pops a security confirm dialog and, on confirm, resends with that flag (server logs a loud warning on every passwordless tunnel start; curl/API stay refused without password/env/flag). The flag is an action field, never persisted | +| **Hook bypass** | `/api/hook-event` (and `/api/status-telemetry`, the statusLine exporter) skip Basic auth (localhost-only, schema-validated). When auth is active (`CODEMAN_PASSWORD` set), the loopback bypass requires the per-instance `X-Codeman-Hook-Secret` header **unconditionally** — COD-54 introduced it tunnel-gated; COD-91 (PR #127) made it always-on because Codeman can't detect a user's own loopback reverse proxy (own cloudflared/`tailscale serve`/nginx → 127.0.0.1), closing that residual plain-bypass gap. Hook curls cat the secret file at exec time via `$CODEMAN_HOOK_SECRET_FILE` (session env, `config/hook-secret.ts`); a missing/wrong secret gets 401 and rate-limits in a dedicated bucket (never locks out login). Tunnel enable **refuses** without a login (`CODEMAN_PASSWORD` or multi-user mode) unless exposure is acknowledged — via `CODEMAN_ALLOW_UNAUTHENTICATED_TUNNEL=1` / `--allow-unauthenticated-tunnel` (#706; until then the guard took `CODEMAN_ALLOW_UNAUTHENTICATED_NETWORK=1`, COD-55, which `install.sh` writes for every passwordless LAN install, so the bind acknowledgement silently published the tunnel too) **or** the per-request `acknowledgeUnauthTunnel:true` action field (1.1.9). With no login the browser asks BEFORE every enable (welcome link, tunnel panel, an App Settings save that turns it on) through `_confirmTunnelEnable()`, reading `loginConfigured` from `GET /api/tunnel/status`, and on confirm sends that flag; the 403 handler stays the fallback when the status read fails (server logs a loud warning on every passwordless tunnel start; curl/API stay refused without password/env/flag). The flag is an action field, never persisted | | **Env vars** | `CODEMAN_MUX` (managed session), `CODEMAN_API_URL` (auto-set for hooks), `CODEMAN_ALLOWED_HOSTS` (extra Host/Origin allowlist entries for reverse proxies, comma-separated; bare `.suffix` matches subdomains), `CODEMAN_DOCKER_BRIDGE_HOOKS`=1 (opt-in hooks-only listener on the docker bridge gateway so in-container hooks reach a loopback-bound server; bind IP from `CODEMAN_DOCKER_BRIDGE_HOST` or auto-detect) | | **Validation** | Zod schemas, Unicode-aware path allowlist regex, env prefix allowlist (`CLAUDE_CODE_*`/`OPENCODE_*`/`CODEX_*`/`GEMINI_*`/`GOOGLE_*`/`ANTIGRAVITY_*`/`PI_*`) | | **Headers** | CORS localhost-only, CSP, X-Frame-Options, HSTS if HTTPS | diff --git a/docs/security-architecture.md b/docs/security-architecture.md index 21f0b9e56..0a6eb6eaa 100644 --- a/docs/security-architecture.md +++ b/docs/security-architecture.md @@ -105,7 +105,9 @@ The warning points at three ways to secure the instance: 2. `--host 127.0.0.1` + an authenticated tunnel (`cloudflared` / `tailscale serve`). 3. `--allow-unauthenticated-network` / `CODEMAN_ALLOW_UNAUTHENTICATED_NETWORK=1` — explicitly accept the risk (downgrades the warning to a one‑line note). This - flag is **only** an acknowledgement; it does not change reachability. + flag is **only** an acknowledgement; it does not change reachability. It covers + the bind and nothing else: it does **not** let the managed Cloudflare tunnel + start without a password, which has its own opt‑in (§2, the tunnel caveat). `CODEMAN_API_URL` (used by hooks/child processes) is always derived as a loopback address (`0.0.0.0`/`localhost`/`::1` → `127.0.0.1`) so in‑process hooks reach the @@ -209,8 +211,15 @@ requests as local: **Mitigation:** set `CODEMAN_PASSWORD` whenever a loopback‑connecting tunnel is up — it gates everything except the (secret‑gated) hook exemption and is the documented practice; since COD‑55 enabling the managed tunnel **refuses** to start -without it unless `CODEMAN_ALLOW_UNAUTHENTICATED_NETWORK=1` explicitly -acknowledges the exposure. Prefer `tailscale serve` (below), which authenticates +without a login (`CODEMAN_PASSWORD` or multi‑user mode) unless +`CODEMAN_ALLOW_UNAUTHENTICATED_TUNNEL=1` / `--allow-unauthenticated-tunnel` +acknowledges the exposure, or the request carries `acknowledgeUnauthTunnel: true` +(what the browser sends after its confirm). The network acknowledgement +(`CODEMAN_ALLOW_UNAUTHENTICATED_NETWORK`) does **not** count: the installer writes +it for every LAN install without a password, so it once turned a click on the +welcome screen's tunnel link into a public, login‑free URL with no confirm. With +no login the browser asks before every enable, whatever the server would accept, +reading `loginConfigured` from `GET /api/tunnel/status`. Prefer `tailscale serve` (below), which authenticates at the tailnet layer so untrusted clients never reach the loopback port at all. ### Host‑header & Origin allowlist (DNS‑rebinding & CSRF defense) @@ -585,7 +594,8 @@ Opt-in and off by default: the server POSTs the Web Push events (permission prom | `--host` / `CODEMAN_HOST` | Bind host (default `127.0.0.1`) | | `CODEMAN_ALLOWED_HOSTS` | Extra `Host`/`Origin` allowlist entries for reverse proxies (comma‑separated; exact host, or leading‑dot `.suffix` for subdomains) — see §3 | | `--base-url` / `CODEMAN_BASE_URL` | Sub‑path prefix Codeman is mounted under behind a reverse proxy, e.g. `/codeman` (default `/`); the proxy must forward the prefix unchanged. Independent of `CODEMAN_ALLOWED_HOSTS` | -| `--allow-unauthenticated-network` / `CODEMAN_ALLOW_UNAUTHENTICATED_NETWORK` | Acknowledge an unauthenticated non‑loopback bind (downgrades the warning) | +| `--allow-unauthenticated-network` / `CODEMAN_ALLOW_UNAUTHENTICATED_NETWORK` | Acknowledge an unauthenticated non‑loopback bind (downgrades the warning). Does not cover the tunnel | +| `--allow-unauthenticated-tunnel` / `CODEMAN_ALLOW_UNAUTHENTICATED_TUNNEL` | Let the managed Cloudflare tunnel start without a login (the browser still asks first). Never written by the installer | | `--https` | Enable TLS (adds HSTS) | | `CODEMAN_INSTANCE` | Scope tmux socket + data dir for isolation | | `CODEMAN_GESTURE=1` | Make the gesture overlay available (widens CSP) | diff --git a/docs/tailscale-installer-plan.md b/docs/tailscale-installer-plan.md index aac7d4df2..2322ed096 100644 --- a/docs/tailscale-installer-plan.md +++ b/docs/tailscale-installer-plan.md @@ -192,7 +192,7 @@ start/stop wrapper to manage; tailscaled owns the lifecycle). | First cert issuance latency | Verify step retries ~30s and says why the first load may be slow. | | `tailscale up` needs auth | Print the auth URL prominently, poll with timeout, skip gracefully. Works headless. | | Custom `CODEMAN_PORT` | Serve target uses the actual port; `install.sh tailscale` re-reads it from the service file. | -| Funnel (public internet) | OUT OF SCOPE for v1. If ever added it must mirror the tunnel guard: refuse without `CODEMAN_PASSWORD` (`isUnauthenticatedNetworkAcknowledged`). Funnel exposes to the whole internet and is a different risk class than tailnet-only serve. Mention `tailscale funnel` in docs only, with the password warning. | +| Funnel (public internet) | OUT OF SCOPE for v1. If ever added it must mirror the tunnel guard: refuse without `CODEMAN_PASSWORD` (`isUnauthenticatedTunnelAcknowledged` and the login check in the `PUT /api/settings` tunnel guard). Funnel exposes to the whole internet and is a different risk class than tailnet-only serve. Mention `tailscale funnel` in docs only, with the password warning. | | Uninstall | Best effort: if `serve status --json` shows 443 proxying to our port, run the targeted `tailscale serve --https=443 off` (still accepted by current CLIs); if the CLI rejects it, print manual instructions. Never touch other mappings, never uninstall tailscale itself. | | User already fronting Codeman some other way (reverse proxy etc.) | The serve check only looks at tailscale state; other proxies are invisible and unaffected (same stance as the loopback-exemption note in security-architecture). | diff --git a/docs/wiki/Remote-Access.md b/docs/wiki/Remote-Access.md index 4cba2a6cd..49e577cba 100644 --- a/docs/wiki/Remote-Access.md +++ b/docs/wiki/Remote-Access.md @@ -115,8 +115,10 @@ There is also a toggle in **App Settings → System → Remote access**. **The tunnel refuses to start without a password.** That is on purpose: a public URL with no authentication is a terminal on your machine handed to the internet. Acknowledging the risk -explicitly is possible from the UI toggle, and only from there; the API will not do it for -you. +explicitly is possible from the UI toggle, which asks you every time, or for the whole server +with `CODEMAN_ALLOW_UNAUTHENTICATED_TUNNEL=1` (or `codeman web --allow-unauthenticated-tunnel`). +`CODEMAN_ALLOW_UNAUTHENTICATED_NETWORK`, which the installer sets for a LAN install without a +password, does not count. ## LAN plus password diff --git a/docs/wiki/Security.md b/docs/wiki/Security.md index c3686143e..678e1ff6c 100644 --- a/docs/wiki/Security.md +++ b/docs/wiki/Security.md @@ -20,7 +20,8 @@ In order of how much they matter: 1. **Do not expose it without `CODEMAN_PASSWORD`.** Binding a non-loopback host without one starts, but warns loudly. A tunnel refuses outright unless you acknowledge the exposure - in the UI. + in the UI, which asks every time, or set `CODEMAN_ALLOW_UNAUTHENTICATED_TUNNEL`. The + installer's `CODEMAN_ALLOW_UNAUTHENTICATED_NETWORK` covers the LAN bind only. 2. **Prefer Tailscale over a public tunnel.** Keeping the loopback bind and putting a private network in front of it removes the public attack surface entirely, and gives you real HTTPS. See [Remote Access](Remote-Access). diff --git a/docs/wiki/Settings-Reference.md b/docs/wiki/Settings-Reference.md index 19d1676be..e09056b37 100644 --- a/docs/wiki/Settings-Reference.md +++ b/docs/wiki/Settings-Reference.md @@ -244,7 +244,8 @@ Some things are configured before the server starts, not in the UI: | `CODEMAN_GESTURE` | Makes gesture control available to be enabled. | | `CODEMAN_DOCKER_BRIDGE_HOOKS` | Lets in-container hooks reach the host on a loopback bind. | | `CODEMAN_FILE_PICKER_ROOTS` | Extra roots for the path picker. | -| `CODEMAN_ALLOW_UNAUTHENTICATED_NETWORK` | Acknowledges exposing the server with no password. | +| `CODEMAN_ALLOW_UNAUTHENTICATED_NETWORK` | Acknowledges binding a non-loopback address with no password. Does not cover the tunnel. | +| `CODEMAN_ALLOW_UNAUTHENTICATED_TUNNEL` | Lets the Cloudflare tunnel start with no password (`--allow-unauthenticated-tunnel` on `codeman web`). The browser still asks first. | | `CODEMAN_BASE_URL` | Mounts Codeman under a sub-path behind a reverse proxy that forwards the prefix unchanged. See [Remote Access](Remote-Access). | | `CODEMAN_MAX_SESSIONS` | Cap on concurrent sessions. 50 by default, 1 to 462 (a larger value is clamped to 462, anything that is not a positive integer falls back to 50). Read at startup. The 462 assumes the default `CODEMAN_MAX_WEBVIEWS` and no pinned closed sessions, which share the same 512-entry tab layout. In multi-user mode the per-user cap defaults to half of it unless `CODEMAN_MAX_SESSIONS_PER_USER` is set. | | `CODEMAN_MAX_DOWNLOAD_BYTES` | Cap on raw file bodies and downloads. 2 GB by default, `0` for none. | diff --git a/src/cli.ts b/src/cli.ts index ba07793b3..a6bfda1ca 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -950,6 +950,10 @@ function addWebLaunchOptions(cmd: Command): Command { '--allow-unauthenticated-network', 'Allow non-loopback web access without CODEMAN_PASSWORD (dangerous; terminal control is exposed)' ) + .option( + '--allow-unauthenticated-tunnel', + 'Allow starting the Cloudflare tunnel without CODEMAN_PASSWORD (dangerous; publishes terminal control to a public URL; env: CODEMAN_ALLOW_UNAUTHENTICATED_TUNNEL)' + ) .option( '--multiuser', 'Enable opt-in multi-user mode (named users in ~/.codeman/users.json; env: CODEMAN_MULTIUSER)' @@ -964,6 +968,7 @@ function toWebLaunchOptions(options: { baseUrl?: string; titleHostname?: string; allowUnauthenticatedNetwork?: boolean; + allowUnauthenticatedTunnel?: boolean; multiuser?: boolean; }): WebLaunchOptions { const port = parseInt(options.port, 10); @@ -985,6 +990,7 @@ function toWebLaunchOptions(options: { basePath, titleHostname: options.titleHostname, allowUnauthenticatedNetwork: !!options.allowUnauthenticatedNetwork, + allowUnauthenticatedTunnel: !!options.allowUnauthenticatedTunnel, multiuser: !!options.multiuser, }; } @@ -1072,6 +1078,7 @@ webCmd.action(async (options) => { const https = launch.https; const titleHostname = options.titleHostname; const allowUnauthenticatedNetwork = launch.allowUnauthenticatedNetwork ?? false; + const allowUnauthenticatedTunnel = launch.allowUnauthenticatedTunnel ?? false; const basePath = launch.basePath ?? ''; // Single source of truth for subsystems that read it directly (e.g. renderers). if (basePath) process.env.CODEMAN_BASE_URL = basePath; @@ -1086,7 +1093,16 @@ webCmd.action(async (options) => { try { // The server prints its own "running at" line (it also covers the daemon and // service launch paths), so this one used to be a duplicate of it. - const server = await startWebServer(port, https, false, host, titleHostname, allowUnauthenticatedNetwork, basePath); + const server = await startWebServer( + port, + https, + false, + host, + titleHostname, + allowUnauthenticatedNetwork, + basePath, + allowUnauthenticatedTunnel + ); if (https) { console.log(palette.warn(' Note: Accept the self-signed certificate in your browser on first visit')); } diff --git a/src/daemon-control.ts b/src/daemon-control.ts index cb09f3d7d..c52efd5ee 100644 --- a/src/daemon-control.ts +++ b/src/daemon-control.ts @@ -49,6 +49,7 @@ export interface WebLaunchOptions { basePath?: string; titleHostname?: string; allowUnauthenticatedNetwork?: boolean; + allowUnauthenticatedTunnel?: boolean; multiuser?: boolean; } @@ -92,6 +93,7 @@ export function buildWebArgs(options: WebLaunchOptions): string[] { if (options.basePath) args.push('--base-url', options.basePath); if (options.titleHostname) args.push('--title-hostname', options.titleHostname); if (options.allowUnauthenticatedNetwork) args.push('--allow-unauthenticated-network'); + if (options.allowUnauthenticatedTunnel) args.push('--allow-unauthenticated-tunnel'); if (options.multiuser) args.push('--multiuser'); return args; } diff --git a/src/web/network-auth-policy.ts b/src/web/network-auth-policy.ts index 3fb049747..4e775460b 100644 --- a/src/web/network-auth-policy.ts +++ b/src/web/network-auth-policy.ts @@ -7,15 +7,28 @@ export function isExplicitlyEnabled(value: string | undefined): boolean { } /** - * True when unauthenticated network exposure is acceptable: either a password is - * set (auth active) or the operator explicitly acknowledged it. Used by the - * tunnel-enable guard (COD-55) to refuse publishing an unauthenticated public URL. + * True when an unauthenticated non-loopback BIND is acceptable: either a password is + * set (auth active) or the operator explicitly acknowledged it. Quiets the startup + * warning only; it does not cover the Cloudflare tunnel (#706), which has its own + * acknowledgement below. */ export function isUnauthenticatedNetworkAcknowledged(allowFlag = false): boolean { if (process.env.CODEMAN_PASSWORD) return true; return allowFlag || isExplicitlyEnabled(process.env.CODEMAN_ALLOW_UNAUTHENTICATED_NETWORK); } +/** + * True when the operator opted in to an unauthenticated public Cloudflare tunnel: + * `--allow-unauthenticated-tunnel` (passed in as `allowFlag`) or + * CODEMAN_ALLOW_UNAUTHENTICATED_TUNNEL. Used by the tunnel-enable guard (COD-55) in + * place of a login. Kept apart from the network acknowledgement on purpose (#706): + * install.sh writes that one for every passwordless LAN install, and it must never + * also publish the machine to the internet. install.sh never writes this one. + */ +export function isUnauthenticatedTunnelAcknowledged(allowFlag = false): boolean { + return allowFlag || isExplicitlyEnabled(process.env.CODEMAN_ALLOW_UNAUTHENTICATED_TUNNEL); +} + export function isLoopbackBindHost(host: string): boolean { const normalized = host .trim() diff --git a/src/web/ports/infra-port.ts b/src/web/ports/infra-port.ts index 21e5beca0..38d819327 100644 --- a/src/web/ports/infra-port.ts +++ b/src/web/ports/infra-port.ts @@ -35,6 +35,8 @@ export interface InfraPort { readonly scheduledRuns: Map<string, ScheduledRun>; readonly teamWatcher: TeamWatcher; readonly tunnelManager: TunnelManager; + /** `--allow-unauthenticated-tunnel` was passed to `codeman web` (the env var is read per request, #706). */ + readonly allowUnauthenticatedTunnel: boolean; readonly pushStore: PushSubscriptionStore; startScheduledRun(prompt: string, workingDir: string, durationMinutes: number, owner?: string): Promise<ScheduledRun>; stopScheduledRun(id: string): Promise<void>; diff --git a/src/web/public/settings-ui.js b/src/web/public/settings-ui.js index db745f450..f906e8dba 100644 --- a/src/web/public/settings-ui.js +++ b/src/web/public/settings-ui.js @@ -2271,10 +2271,16 @@ Object.assign(CodemanApp.prototype, { btn.disabled = true; try { const newEnabled = !isActive; + const gate = newEnabled ? await this._confirmTunnelEnable() : { proceed: true, acknowledge: false }; + if (!gate.proceed) { + this._updateWelcomeTunnelBtn(false); + btn.disabled = false; + return; + } const res = await fetch('/api/settings', { method: 'PUT', headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ tunnelEnabled: newEnabled }), + body: JSON.stringify({ tunnelEnabled: newEnabled, ...(gate.acknowledge ? { acknowledgeUnauthTunnel: true } : {}) }), }); // COD-55: server refuses an unauthenticated public tunnel (403). Surface it. if (newEnabled && (await this._handleTunnelEnableRefusal(res))) { @@ -2582,11 +2588,67 @@ Object.assign(CodemanApp.prototype, { return `${Math.floor(hrs / 24)}d ago`; }, + /** + * End an App Settings save whose tunnel enable did not go through (confirm cancelled, or + * refused by the server): the WHOLE settings PUT is off, so turn the tunnel toggle back + * off in the UI and localStorage. Other settings persisted to localStorage still apply locally. + */ + _abandonTunnelEnable(settings, keepOpen) { + settings.tunnelEnabled = false; + this.saveAppSettingsToStorage(settings); + const cb = document.getElementById('appSettingsTunnelEnabled'); + if (cb) cb.checked = false; + if (!keepOpen) this.closeAppSettings(); + }, + + /** The one SECURITY WARNING prompt for an unauthenticated public tunnel. */ + _askUnauthTunnel() { + return confirm( + '⚠️ SECURITY WARNING — no password set\n\n' + + 'Enabling the Cloudflare tunnel will publish THIS machine to a public URL with ' + + 'NO login. Anyone who gets the URL has full terminal control — effectively remote ' + + 'code execution on your computer.\n\n' + + 'Strongly recommended: set CODEMAN_PASSWORD instead.\n\n' + + 'Enable the unauthenticated public tunnel anyway?' + ); + }, + + /** + * #706: ask BEFORE a tunnel enable whenever the server has no login (no CODEMAN_PASSWORD, + * not multi-user), every time, whatever the server would accept. With + * CODEMAN_ALLOW_UNAUTHENTICATED_TUNNEL set the PUT is never refused, so the refusal + * handler below would never ask. The ONE gate for the welcome link, the tunnel panel and + * the App Settings save. A tunnel that is already running is not being turned on, and + * the server does not guard it either. When the status cannot be read, the PUT goes out + * without the acknowledgement and `_handleTunnelEnableRefusal` stays the fallback. + * @returns {Promise<{ proceed: boolean, acknowledge: boolean }>} proceed=false: cancelled, + * send nothing; acknowledge: send `acknowledgeUnauthTunnel: true` with the enable. + */ + async _confirmTunnelEnable() { + let status = null; + try { + const res = await fetch('/api/tunnel/status'); + if (res.ok) { + const env = await res.json(); + status = env?.success === true ? env.data : env; + } + } catch { + /* unknown: the 403 handler asks instead */ + } + if (!status || status.running || status.loginConfigured !== false) return { proceed: true, acknowledge: false }; + if (!this._askUnauthTunnel()) { + this.showToast('Tunnel not enabled', 'info'); + return { proceed: false, acknowledge: false }; + } + return { proceed: true, acknowledge: true }; + }, + /** * COD-55: detect the server's refusal to start an unauthenticated public tunnel. * The PUT /api/settings route returns a 4xx with { success:false, error } when no - * CODEMAN_PASSWORD is set and the unauthenticated-network opt-in is not acknowledged. - * Shows the server's (actionable) message as an error toast. + * CODEMAN_PASSWORD is set and the unauthenticated-tunnel opt-in is not acknowledged. + * Shows the server's (actionable) message as an error toast. Since #706 this is the + * fallback for when `_confirmTunnelEnable` could not read the tunnel status. * @param {Response|null} res - the fetch Response from the settings PUT * @returns {Promise<boolean>} true if the tunnel-enable was refused (caller should abort) */ @@ -2612,14 +2674,7 @@ Object.assign(CodemanApp.prototype, { // 403 = the no-password safety refusal (COD-55). Warn loudly and let the // operator acknowledge the risk; on confirm, retry with explicit acknowledgment. if (res.status === 403) { - const confirmed = confirm( - '⚠️ SECURITY WARNING — no password set\n\n' + - 'Enabling the Cloudflare tunnel will publish THIS machine to a public URL with ' + - 'NO login. Anyone who gets the URL has full terminal control — effectively remote ' + - 'code execution on your computer.\n\n' + - 'Strongly recommended: set CODEMAN_PASSWORD instead.\n\n' + - 'Enable the unauthenticated public tunnel anyway?' - ); + const confirmed = this._askUnauthTunnel(); if (!confirmed) { this._dismissTunnelConnecting?.(); this.showToast('Tunnel not enabled', 'info'); @@ -2664,10 +2719,15 @@ Object.assign(CodemanApp.prototype, { return; } try { + const gate = enable ? await this._confirmTunnelEnable() : { proceed: true, acknowledge: false }; + if (!gate.proceed) { + this.closeTunnelPanel(); + return; + } const res = await fetch('/api/settings', { method: 'PUT', headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ tunnelEnabled: enable }), + body: JSON.stringify({ tunnelEnabled: enable, ...(gate.acknowledge ? { acknowledgeUnauthTunnel: true } : {}) }), }); // COD-55: server refuses an unauthenticated public tunnel (403). Surface it. if (enable && (await this._handleTunnelEnableRefusal(res))) { @@ -3270,11 +3330,21 @@ Object.assign(CodemanApp.prototype, { let webhookError = ''; let serverSaved = false; try { + // #706: a save that turns the tunnel on asks first when the server has no login + // (one already running is not asked about, see _confirmTunnelEnable). + const tunnelGate = settings.tunnelEnabled && !settingsReadOnly + ? await this._confirmTunnelEnable() + : { proceed: true, acknowledge: false }; + if (!tunnelGate.proceed) { + this._abandonTunnelEnable(settings, keepOpen); + return; + } // A non-admin's save is not sent at all (see settingsReadOnly above): the server // would refuse it with a 403, the plan-usage collection flip included. const res = settingsReadOnly ? null : await this._apiPut('/api/settings', { ...serverSettings, ...(_chipFlip !== undefined ? { showPlanUsageLimits: _chipFlip } : {}), + ...(tunnelGate.acknowledge ? { acknowledgeUnauthTunnel: true } : {}), notificationPreferences: notifPrefsToSave, voiceSettings, }); @@ -3292,14 +3362,9 @@ Object.assign(CodemanApp.prototype, { // COD-55: the server refuses an unauthenticated public tunnel with a 403 — which // rejects the WHOLE settings PUT. Surface the message and revert the tunnel toggle - // (in the UI + localStorage) so it doesn't look enabled. Other settings persisted - // to localStorage above still apply locally. + // so it doesn't look enabled. if (!refusedAsNonAdmin && settings.tunnelEnabled && (await this._handleTunnelEnableRefusal(res))) { - settings.tunnelEnabled = false; - this.saveAppSettingsToStorage(settings); - const cb = document.getElementById('appSettingsTunnelEnabled'); - if (cb) cb.checked = false; - if (!keepOpen) this.closeAppSettings(); + this._abandonTunnelEnable(settings, keepOpen); return; } diff --git a/src/web/routes/system-routes.ts b/src/web/routes/system-routes.ts index 1ebbef006..c72453635 100644 --- a/src/web/routes/system-routes.ts +++ b/src/web/routes/system-routes.ts @@ -15,7 +15,7 @@ import { randomBytes } from 'node:crypto'; import { dataPath } from '../../config/instance.js'; import { readHostSensors } from '../../host-sensors.js'; import { ApiErrorCode, createErrorResponse, getErrorMessage, type NiceConfig } from '../../types.js'; -import { isUnauthenticatedNetworkAcknowledged } from '../network-auth-policy.js'; +import { isExplicitlyEnabled, isUnauthenticatedTunnelAcknowledged } from '../network-auth-policy.js'; import { isMultiUserMode } from '../../config/multiuser.js'; import { findUser, canUsernameRunPrivilegedCommands } from '../../user-store.js'; import { getAuthUser, isAdmin, requireAdmin, canAccessOwned, revokeUserSessions } from '../route-helpers.js'; @@ -177,6 +177,43 @@ export function settingsForNonAdmin(settings: unknown): unknown { return out; } +/** + * True when a login protects the server: CODEMAN_PASSWORD is set, or multi-user mode is + * on (every person has their own credential). The tunnel guard and the `loginConfigured` + * field of GET /api/tunnel/status both read this, so the page's answer mirrors the guard. + */ +function isLoginConfigured(): boolean { + return !!process.env.CODEMAN_PASSWORD || isMultiUserMode(); +} + +/** + * The refusal of a tunnel enable with no login and no tunnel opt-in or acknowledgement: logs + * the diagnostic line and returns the message for the 403. `late` is the case where the tunnel + * was running when the request arrived and had stopped by the time it would be started; the + * request's other settings were saved by then. + */ +function unauthenticatedTunnelRefusal(late: boolean): string { + // Someone who set the network variable to get here before #706 is told why it stopped working. + const networkAcknowledged = isExplicitlyEnabled(process.env.CODEMAN_ALLOW_UNAUTHENTICATED_NETWORK); + console.warn( + `[tunnel] Refused an unauthenticated tunnel ${late ? 'restart after a concurrent stop' : 'enable'} (no login, no tunnel opt-in, no acknowledgement${networkAcknowledged ? '; CODEMAN_ALLOW_UNAUTHENTICATED_NETWORK does not cover the tunnel' : ''})` + ); + return ( + (late + ? 'The Cloudflare tunnel stopped while these settings were being saved and was not restarted; ' + + 'the other settings were saved. ' + : '') + + 'Refusing to start the Cloudflare tunnel without authentication: it would publish ' + + 'full terminal control to a public URL with no password. Set CODEMAN_PASSWORD to ' + + 'require login, set CODEMAN_ALLOW_UNAUTHENTICATED_TUNNEL=1, or resend with ' + + 'acknowledgeUnauthTunnel:true to acknowledge an unauthenticated public tunnel.' + + (networkAcknowledged + ? ' CODEMAN_ALLOW_UNAUTHENTICATED_NETWORK is set, but it only acknowledges the network ' + + 'bind and no longer covers the tunnel.' + : '') + ); +} + export function registerSystemRoutes( app: FastifyInstance, ctx: SessionPort & EventPort & ConfigPort & InfraPort & AuthPort & TabLayoutPort & KeepWarmPort @@ -194,7 +231,14 @@ export function registerSystemRoutes( // ========== Tunnel ========== - app.get('/api/tunnel/status', async () => ctx.tunnelManager.getStatus()); + // `loginConfigured` (#706) tells the page whether a tunnel would have a login in front + // of it, so it can ask before an enable even when the server would accept one. It is + // the guard's own login test (isLoginConfigured above), never TunnelManager's, which + // knows nothing about auth. + app.get('/api/tunnel/status', async () => ({ + ...ctx.tunnelManager.getStatus(), + loginConfigured: isLoginConfigured(), + })); app.get('/api/tunnel/info', async (req) => { const status = ctx.tunnelManager.getStatus(); @@ -1063,36 +1107,29 @@ export function registerSystemRoutes( // tunnel binds to loopback, server.ts's non-loopback bind guard never trips, and // with no CODEMAN_PASSWORD the auth middleware is inactive — so the tunnel URL is // unauthenticated. Refuse to start a tunnel unless auth is configured OR exposure - // is acknowledged: either the CODEMAN_ALLOW_UNAUTHENTICATED_NETWORK env var, or an + // is acknowledged: either the CODEMAN_ALLOW_UNAUTHENTICATED_TUNNEL opt-in, or an // explicit per-request `acknowledgeUnauthTunnel:true` (the UI sends this after a // confirm dialog). This keeps curl/API/CLI callers protected by default while // letting an operator opt in from the browser without setting the env var. // Guard runs BEFORE persisting so a refused tunnelEnabled:true is not saved. - if (settings.tunnelEnabled === true && !ctx.tunnelManager.isRunning()) { - // Multi-user mode makes the tunnel authenticated (every person has their own - // credential), so it satisfies the same requirement as CODEMAN_PASSWORD. - const acknowledged = - isMultiUserMode() || isUnauthenticatedNetworkAcknowledged() || settings.acknowledgeUnauthTunnel === true; - if (!acknowledged) { - const msg = - 'Refusing to start the Cloudflare tunnel without authentication: it would publish ' + - 'full terminal control to a public URL with no password. Set CODEMAN_PASSWORD to ' + - 'require login, set CODEMAN_ALLOW_UNAUTHENTICATED_NETWORK=1, or resend with ' + - 'acknowledgeUnauthTunnel:true to acknowledge an unauthenticated public tunnel.'; - throw Object.assign(new Error(msg), { - statusCode: 403, - body: createErrorResponse(ApiErrorCode.OPERATION_FAILED, msg), - }); - } - // Loud warning whenever a public tunnel is started with no password — whether - // acknowledged via env var or the per-request UI confirmation. - if (!process.env.CODEMAN_PASSWORD) { - console.warn( - '⚠️ [tunnel] Starting an UNAUTHENTICATED public Cloudflare tunnel — no CODEMAN_PASSWORD set. ' + - 'Anyone with the tunnel URL gets full terminal control (effectively RCE). ' + - 'Set CODEMAN_PASSWORD to require login.' - ); - } + // The tunnel opt-in is CODEMAN_ALLOW_UNAUTHENTICATED_TUNNEL / --allow-unauthenticated-tunnel, + // never the network one (#706): install.sh writes CODEMAN_ALLOW_UNAUTHENTICATED_NETWORK + // for every passwordless LAN install, and that acknowledges the LAN bind only. + // Decided ONCE per request, whether or not the tunnel is running now, and required again + // where the tunnel is really started below: the settings file awaits in between leave room + // for a concurrent stop, and a save that found the tunnel running (so nothing was refused + // here) must not then restart it unguarded. + const tunnelEnableAuthorized = + settings.tunnelEnabled === true && + (isLoginConfigured() || + isUnauthenticatedTunnelAcknowledged(ctx.allowUnauthenticatedTunnel) || + settings.acknowledgeUnauthTunnel === true); + if (settings.tunnelEnabled === true && !tunnelEnableAuthorized && !ctx.tunnelManager.isRunning()) { + const msg = unauthenticatedTunnelRefusal(false); + throw Object.assign(new Error(msg), { + statusCode: 403, + body: createErrorResponse(ApiErrorCode.OPERATION_FAILED, msg), + }); } try { @@ -1109,6 +1146,10 @@ export function registerSystemRoutes( // acknowledgeUnauthTunnel is an ACTION field (not a stored setting) — strip // it before persisting so settings.json stays clean. const { acknowledgeUnauthTunnel, ...settingsToStore } = settings; + // An enable that is not authorized only got this far because the tunnel was running. + // It must never write tunnelEnabled:true: a concurrent stop has written false by now, + // and true on disk would show a stopped tunnel as on. The stored value stays as it is. + if (settings.tunnelEnabled === true && !tunnelEnableAuthorized) delete settingsToStore.tunnelEnabled; const merged = { ...existing, ...settingsToStore }; await fs.writeFile(SETTINGS_PATH, JSON.stringify(merged, null, 2)); @@ -1182,6 +1223,20 @@ export function registerSystemRoutes( if ('tunnelEnabled' in settings) { const tunnelEnabled = settings.tunnelEnabled as boolean; if (tunnelEnabled && !ctx.tunnelManager.isRunning()) { + // The tunnel stopped since the guard above saw it running: the same authorization applies. + if (!tunnelEnableAuthorized) { + reply.code(403); + return createErrorResponse(ApiErrorCode.OPERATION_FAILED, unauthenticatedTunnelRefusal(true)); + } + // Loud warning whenever a public tunnel is started with no password, whether + // acknowledged via the tunnel opt-in or the per-request UI confirmation. + if (!process.env.CODEMAN_PASSWORD) { + console.warn( + '⚠️ [tunnel] Starting an UNAUTHENTICATED public Cloudflare tunnel — no CODEMAN_PASSWORD set. ' + + 'Anyone with the tunnel URL gets full terminal control (effectively RCE). ' + + 'Set CODEMAN_PASSWORD to require login.' + ); + } ctx.tunnelManager.start(ctx.port, ctx.https); console.log('Tunnel started via settings change'); } else if (tunnelEnabled && ctx.tunnelManager.isRunning() && ctx.tunnelManager.getUrl()) { diff --git a/src/web/schemas.ts b/src/web/schemas.ts index 8336a2e3a..7276d8975 100644 --- a/src/web/schemas.ts +++ b/src/web/schemas.ts @@ -1554,7 +1554,7 @@ export const SettingsUpdateSchema = z // Action field (NOT persisted): explicit per-request acknowledgment that the // operator accepts exposing an UNAUTHENTICATED public tunnel (no CODEMAN_PASSWORD). // Lets the UI enable a tunnel after a confirm dialog without the - // CODEMAN_ALLOW_UNAUTHENTICATED_NETWORK env var. Stripped before persisting. + // CODEMAN_ALLOW_UNAUTHENTICATED_TUNNEL opt-in. Stripped before persisting. acknowledgeUnauthTunnel: z.boolean().optional(), tabTwoRows: z.boolean().optional(), /** diff --git a/src/web/server.ts b/src/web/server.ts index 2b3e582c4..14e02dc35 100644 --- a/src/web/server.ts +++ b/src/web/server.ts @@ -472,6 +472,8 @@ export class WebServer extends EventEmitter { private windowTitle: string; private readonly indexHtmlTemplate: string; private readonly allowUnauthenticatedNetwork: boolean; + /** `--allow-unauthenticated-tunnel`; the tunnel guard reads it through the route context (#706). */ + private readonly allowUnauthenticatedTunnel: boolean; private _pasteImageGcStop: (() => void) | null = null; private _eventLoopMonitor: EventLoopMonitorHandle | null = null; /** Opt-in hooks-only listener on the docker bridge gateway (CODEMAN_DOCKER_BRIDGE_HOOKS). */ @@ -489,7 +491,8 @@ export class WebServer extends EventEmitter { host: string = '127.0.0.1', titleHostname?: string, allowUnauthenticatedNetwork: boolean = false, - basePath: string = '' + basePath: string = '', + allowUnauthenticatedTunnel: boolean = false ) { super(); this.setMaxListeners(0); @@ -501,6 +504,7 @@ export class WebServer extends EventEmitter { this.testMode = testMode; this.allowUnauthenticatedNetwork = allowUnauthenticatedNetwork || isExplicitlyEnabled(process.env.CODEMAN_ALLOW_UNAUTHENTICATED_NETWORK); + this.allowUnauthenticatedTunnel = allowUnauthenticatedTunnel; this.titleHostname = titleHostname || getHostname(); this.windowTitle = `codeman:${this.titleHostname}`; this.indexHtmlTemplate = readFileSync(join(__dirname, 'public', 'index.html'), 'utf-8'); @@ -880,6 +884,7 @@ export class WebServer extends EventEmitter { scheduledRuns: this.scheduledRuns, teamWatcher: this.teamWatcher, tunnelManager: this.tunnelManager, + allowUnauthenticatedTunnel: this.allowUnauthenticatedTunnel, pushStore: this.pushStore, startScheduledRun: this.startScheduledRun.bind(this), stopScheduledRun: this.stopScheduledRun.bind(this), @@ -4399,9 +4404,19 @@ export async function startWebServer( host: string = '127.0.0.1', titleHostname?: string, allowUnauthenticatedNetwork: boolean = false, - basePath: string = '' + basePath: string = '', + allowUnauthenticatedTunnel: boolean = false ): Promise<WebServer> { - const server = new WebServer(port, https, testMode, host, titleHostname, allowUnauthenticatedNetwork, basePath); + const server = new WebServer( + port, + https, + testMode, + host, + titleHostname, + allowUnauthenticatedNetwork, + basePath, + allowUnauthenticatedTunnel + ); await server.start(); return server; } diff --git a/test/cli-commands.test.ts b/test/cli-commands.test.ts index 9714313e7..689907851 100644 --- a/test/cli-commands.test.ts +++ b/test/cli-commands.test.ts @@ -208,3 +208,29 @@ describe('help text', () => { } }); }); + +describe('unauthenticated tunnel launch option', () => { + it.each(['web', 'service install'])('registers --allow-unauthenticated-tunnel on %s', (path) => { + const command = path === 'web' ? find(program, 'web')! : find(find(program, 'service')!, 'install')!; + const options = command.options.filter((option) => option.long === '--allow-unauthenticated-tunnel'); + expect( + options.map((option) => ({ + flags: option.flags, + attribute: option.attributeName(), + required: option.required, + optional: option.optional, + })) + ).toEqual([ + { + flags: '--allow-unauthenticated-tunnel', + attribute: 'allowUnauthenticatedTunnel', + required: false, + optional: false, + }, + ]); + }); + + it('documents --allow-unauthenticated-tunnel in web help', () => { + expect(find(program, 'web')!.helpInformation()).toContain('--allow-unauthenticated-tunnel'); + }); +}); diff --git a/test/cli-tunnel-flag.test.ts b/test/cli-tunnel-flag.test.ts new file mode 100644 index 000000000..575be2ca5 --- /dev/null +++ b/test/cli-tunnel-flag.test.ts @@ -0,0 +1,102 @@ +/** + * @fileoverview Issue #706 tunnel flag plumbing through the real command parser into daemon, foreground and service launches. Launch boundaries are mocked, so the CLI's valid nonzero port never binds a socket or creates a service unit. + */ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +const launches = vi.hoisted(() => ({ + startDaemon: vi.fn(), + startWebServer: vi.fn(), + installService: vi.fn(), + stop: vi.fn(), +})); + +vi.mock('../src/daemon-control.js', async (importOriginal) => ({ + ...(await importOriginal<typeof import('../src/daemon-control.js')>()), + startDaemon: launches.startDaemon, +})); +vi.mock('../src/service-installer.js', async (importOriginal) => ({ + ...(await importOriginal<typeof import('../src/service-installer.js')>()), + installService: launches.installService, +})); +vi.mock('../src/web/server.js', () => ({ startWebServer: launches.startWebServer })); + +const SIGNALS = ['SIGTERM', 'SIGINT', 'SIGHUP'] as const; +let originalListeners: Map<NodeJS.Signals, ReturnType<typeof process.listeners>>; +let exitSpy: ReturnType<typeof vi.spyOn<typeof process, 'exit'>>; + +beforeEach(() => { + // Commander retains parsed option values; use a fresh real program for each invocation. + vi.resetModules(); + vi.clearAllMocks(); + originalListeners = new Map(SIGNALS.map((signal) => [signal, process.listeners(signal)])); + exitSpy = vi.spyOn(process, 'exit').mockImplementation((code) => { + throw new Error(`Unexpected process.exit(${code})`); + }); + launches.startDaemon.mockResolvedValue({ ok: true, url: 'http://127.0.0.1:8080', pid: 123, logPath: 'web.log' }); + launches.stop.mockResolvedValue(undefined); + launches.startWebServer.mockResolvedValue({ stop: launches.stop }); + launches.installService.mockResolvedValue({ ok: true, message: 'Installed', unitPath: 'codeman-web.service' }); +}); + +afterEach(() => { + for (const signal of SIGNALS) { + for (const listener of process.listeners(signal)) { + if (!originalListeners.get(signal)!.includes(listener)) process.removeListener(signal, listener); + } + } + vi.restoreAllMocks(); +}); + +const expectedOptions = (allowUnauthenticatedTunnel: boolean) => ({ + host: '127.0.0.1', + port: 8080, + https: false, + basePath: '', + titleHostname: undefined, + allowUnauthenticatedNetwork: false, + allowUnauthenticatedTunnel, + multiuser: false, +}); + +async function parse(args: string[], enabled: boolean) { + const { program } = await import('../src/cli.js'); + await program.parseAsync([ + 'node', + 'codeman', + ...args, + '--port', + '8080', + '--host', + '127.0.0.1', + '--base-url', + '/', + ...(enabled ? ['--allow-unauthenticated-tunnel'] : []), + ]); + expect(exitSpy).not.toHaveBeenCalled(); +} + +describe('tunnel flag CLI plumbing', () => { + it.each([true, false])('web -d passes allowUnauthenticatedTunnel=%s to startDaemon', async (enabled) => { + await parse(['web', '-d'], enabled); + expect(launches.startDaemon.mock.calls).toEqual([[expectedOptions(enabled)]]); + expect(launches.startWebServer.mock.calls).toEqual([]); + expect(launches.installService.mock.calls).toEqual([]); + }); + + it.each([true, false])('foreground web passes allowUnauthenticatedTunnel=%s as argument eight', async (enabled) => { + await parse(['web'], enabled); + expect(launches.startWebServer.mock.calls).toEqual([ + [8080, false, false, '127.0.0.1', undefined, false, '', enabled], + ]); + expect(launches.startDaemon.mock.calls).toEqual([]); + expect(launches.installService.mock.calls).toEqual([]); + expect(launches.stop.mock.calls).toEqual([]); + }); + + it.each([true, false])('service install passes allowUnauthenticatedTunnel=%s to installService', async (enabled) => { + await parse(['service', 'install'], enabled); + expect(launches.installService.mock.calls).toEqual([[expectedOptions(enabled)]]); + expect(launches.startDaemon.mock.calls).toEqual([]); + expect(launches.startWebServer.mock.calls).toEqual([]); + }); +}); diff --git a/test/daemon-control.test.ts b/test/daemon-control.test.ts index 54c19ff9a..3aefd40c4 100644 --- a/test/daemon-control.test.ts +++ b/test/daemon-control.test.ts @@ -30,6 +30,36 @@ async function closedPort(): Promise<number> { } describe('buildWebArgs', () => { + it('forwards the tunnel opt-in between the network and multi-user flags', () => { + const options = { + host: '0.0.0.0', + port: 8080, + https: true, + titleHostname: 'tower', + allowUnauthenticatedNetwork: true, + allowUnauthenticatedTunnel: true, + multiuser: true, + }; + expect(buildWebArgs(options)).toEqual([ + 'web', + '--host', + '0.0.0.0', + '--port', + '8080', + '--https', + '--title-hostname', + 'tower', + '--allow-unauthenticated-network', + '--allow-unauthenticated-tunnel', + '--multiuser', + ]); + }); + + it.each([false, undefined])('omits the tunnel opt-in when it is %s', (allowUnauthenticatedTunnel) => { + const options = { host: '127.0.0.1', port: 8080, https: false, allowUnauthenticatedTunnel }; + expect(buildWebArgs(options)).toEqual(['web', '--host', '127.0.0.1', '--port', '8080']); + }); + it('always passes host and port through explicitly', () => { expect(buildWebArgs({ host: '127.0.0.1', port: 3000, https: false })).toEqual([ 'web', diff --git a/test/install-sh-invariants.test.ts b/test/install-sh-invariants.test.ts index 2d1faebfc..31eee9270 100644 --- a/test/install-sh-invariants.test.ts +++ b/test/install-sh-invariants.test.ts @@ -531,3 +531,7 @@ describe('install.sh review fixes for #460', () => { expect(drive('--tailscale')).toContain('host=127.0.0.1 pw=s3cret ack=0'); }); }); + +it('install.sh never contains the unauthenticated tunnel opt-in', () => { + expect(SOURCE).not.toContain('CODEMAN_ALLOW_UNAUTHENTICATED_TUNNEL'); +}); diff --git a/test/mocks/mock-route-context.ts b/test/mocks/mock-route-context.ts index f74dddf28..9f75130ae 100644 --- a/test/mocks/mock-route-context.ts +++ b/test/mocks/mock-route-context.ts @@ -169,6 +169,7 @@ export function createMockRouteContext(options?: { scheduledRuns: new Map(), teamWatcher: { getTeams: vi.fn(() => []), getTeamTasks: vi.fn(() => []), hasActiveTeammates: vi.fn(() => false) }, tunnelManager: null, + allowUnauthenticatedTunnel: false, pushStore: null, startScheduledRun: vi.fn(), stopScheduledRun: vi.fn(), diff --git a/test/routes/system-routes-tunnel-guard.test.ts b/test/routes/system-routes-tunnel-guard.test.ts index 8da2b6740..42604388b 100644 --- a/test/routes/system-routes-tunnel-guard.test.ts +++ b/test/routes/system-routes-tunnel-guard.test.ts @@ -1,20 +1,11 @@ /** - * @fileoverview COD-55 — tunnel password guard. - * - * Enabling the Cloudflare tunnel publishes the whole app (full terminal control = - * effectively RCE) to a public *.trycloudflare.com URL. When no CODEMAN_PASSWORD - * is set, requests through that URL are unauthenticated. These tests assert the - * PUT /api/settings tunnel-enable path REFUSES to start the tunnel unless a - * password is set OR the unauthenticated-network opt-in is acknowledged. - * - * Uses app.inject() — no real HTTP ports needed. Port: N/A. + * @fileoverview Issue #706 keeps LAN acknowledgement separate from public tunnel consent. A login, tunnel-specific env opt-in, launch flag or per-request acknowledgement permits enabling. Early refusals persist nothing; late race refusals save unrelated settings without enabling the tunnel. Status reports login configuration independently of exposure opt-ins. */ - import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; import { createRouteTestHarness, type RouteTestHarness } from './_route-test-utils.js'; import { registerSystemRoutes } from '../../src/web/routes/system-routes.js'; -// Settings are written to disk via fs/promises — stub so the guard test never +// Settings are written to disk via fs/promises; stub so the guard test never // touches the real settings.json, and so we can assert "not persisted on refusal". vi.mock('node:fs/promises', () => ({ default: { @@ -34,129 +25,296 @@ vi.mock('node:fs', async (importOriginal) => { }); import fs from 'node:fs/promises'; +import { SETTINGS_PATH } from '../../src/web/route-helpers.js'; const mockedWriteFile = vi.mocked(fs.writeFile); -/** Build a tunnelManager stub the route's ctx can use. */ -function makeTunnelManager(running = false) { +const STATUS = { running: false, url: null }; + +function makeTunnelManager() { return { start: vi.fn(), stop: vi.fn(), - isRunning: vi.fn(() => running), + isRunning: vi.fn(() => false), getUrl: vi.fn(() => null), - getStatus: vi.fn(() => ({ running })), + getStatus: vi.fn(() => ({ ...STATUS })), }; } -describe('COD-55 tunnel password guard (PUT /api/settings tunnelEnabled)', () => { +describe('tunnel authentication policy', () => { let harness: RouteTestHarness; let tunnel: ReturnType<typeof makeTunnelManager>; - const savedPassword = process.env.CODEMAN_PASSWORD; - const savedOptIn = process.env.CODEMAN_ALLOW_UNAUTHENTICATED_NETWORK; + const envKeys = [ + 'CODEMAN_PASSWORD', + 'CODEMAN_ALLOW_UNAUTHENTICATED_NETWORK', + 'CODEMAN_ALLOW_UNAUTHENTICATED_TUNNEL', + 'CODEMAN_MULTIUSER', + ] as const; + let savedEnv: (string | undefined)[]; beforeEach(async () => { - harness = await createRouteTestHarness(registerSystemRoutes); + savedEnv = envKeys.map((key) => process.env[key]); + for (const key of envKeys) delete process.env[key]; + harness = await createRouteTestHarness(registerSystemRoutes, { + authUser: { username: 'root', role: 'admin' }, + }); vi.clearAllMocks(); + vi.mocked(fs.readFile).mockResolvedValue('{}'); mockedWriteFile.mockResolvedValue(undefined); - tunnel = makeTunnelManager(false); - // tunnelManager is null in the default mock ctx — inject our spy. + tunnel = makeTunnelManager(); (harness.ctx as unknown as { tunnelManager: unknown }).tunnelManager = tunnel; - delete process.env.CODEMAN_PASSWORD; - delete process.env.CODEMAN_ALLOW_UNAUTHENTICATED_NETWORK; + (harness.ctx as unknown as { allowUnauthenticatedTunnel: boolean }).allowUnauthenticatedTunnel = false; }); afterEach(async () => { - await harness.app.close(); - if (savedPassword === undefined) delete process.env.CODEMAN_PASSWORD; - else process.env.CODEMAN_PASSWORD = savedPassword; - if (savedOptIn === undefined) delete process.env.CODEMAN_ALLOW_UNAUTHENTICATED_NETWORK; - else process.env.CODEMAN_ALLOW_UNAUTHENTICATED_NETWORK = savedOptIn; + try { + await harness?.app.close(); + } finally { + envKeys.forEach((key, i) => { + if (savedEnv[i] === undefined) delete process.env[key]; + else process.env[key] = savedEnv[i]; + }); + } }); - it('REFUSES tunnel-enable with no password and no opt-in (4xx, start not called)', async () => { - const res = await harness.app.inject({ - method: 'PUT', - url: '/api/settings', - payload: { tunnelEnabled: true }, + const put = (payload: Record<string, boolean> = { tunnelEnabled: true }) => + harness.app.inject({ method: 'PUT', url: '/api/settings', payload }); + + function expectRefused(res: Awaited<ReturnType<typeof put>>) { + expect.soft(res.statusCode).toBe(403); + expect.soft(res.json()).toEqual({ + success: false, + errorCode: 'OPERATION_FAILED', + error: expect.any(String), }); + expect.soft(tunnel.start.mock.calls).toEqual([]); + expect.soft(mockedWriteFile.mock.calls).toEqual([]); + } + + function expectEnabled(res: Awaited<ReturnType<typeof put>>) { + expect(res.statusCode).toBe(200); + expect(tunnel.start.mock.calls).toEqual([[harness.ctx.port, harness.ctx.https]]); + expect(tunnel.stop.mock.calls).toEqual([]); + expect(mockedWriteFile.mock.calls).toEqual([[SETTINGS_PATH, JSON.stringify({ tunnelEnabled: true }, null, 2)]]); + } - expect(res.statusCode).toBeGreaterThanOrEqual(400); - expect(res.statusCode).toBeLessThan(500); - const body = JSON.parse(res.body); - expect(body.success).toBe(false); - // Message should tell the user how to fix it. - expect(body.error).toMatch(/CODEMAN_PASSWORD|CODEMAN_ALLOW_UNAUTHENTICATED_NETWORK/); - // The tunnel must NOT have been started. - expect(tunnel.start).not.toHaveBeenCalled(); - // And tunnelEnabled:true must NOT have been persisted. - expect(mockedWriteFile).not.toHaveBeenCalled(); + it('refuses the incident: network acknowledgement does not authorize a public tunnel', async () => { + process.env.CODEMAN_ALLOW_UNAUTHENTICATED_NETWORK = '1'; + const res = await put(); + expectRefused(res); + expect.soft(res.json().error).toContain('CODEMAN_ALLOW_UNAUTHENTICATED_TUNNEL'); + expect.soft(res.json().error).toContain('CODEMAN_ALLOW_UNAUTHENTICATED_NETWORK'); + expect.soft(res.json().error).toMatch(/no longer/i); }); - it('ALLOWS tunnel-enable when CODEMAN_PASSWORD is set (start called, 200)', async () => { - process.env.CODEMAN_PASSWORD = 'hunter2'; + it('refuses without credentials or acknowledgement and names the tunnel opt-in', async () => { + const res = await put(); + expectRefused(res); + expect(res.json().error).toContain('CODEMAN_ALLOW_UNAUTHENTICATED_TUNNEL'); + }); - const res = await harness.app.inject({ - method: 'PUT', - url: '/api/settings', - payload: { tunnelEnabled: true }, - }); + it.each(['1', 'true', 'yes', 'on', ' TRUE '])( + 'allows tunnel env value %j without an acknowledgement', + async (value) => { + process.env.CODEMAN_ALLOW_UNAUTHENTICATED_TUNNEL = value; + expectEnabled(await put()); + } + ); - expect(res.statusCode).toBe(200); - expect(tunnel.start).toHaveBeenCalledTimes(1); + it('allows the route context flag without an env opt-in or acknowledgement', async () => { + (harness.ctx as unknown as { allowUnauthenticatedTunnel: boolean }).allowUnauthenticatedTunnel = true; + expectEnabled(await put()); }); - it('ALLOWS tunnel-enable with the unauthenticated-network opt-in acknowledged (start called, 200)', async () => { - process.env.CODEMAN_ALLOW_UNAUTHENTICATED_NETWORK = '1'; + it('allows acknowledgement alone and persists only tunnelEnabled', async () => { + expectEnabled(await put({ tunnelEnabled: true, acknowledgeUnauthTunnel: true })); + }); - const res = await harness.app.inject({ - method: 'PUT', - url: '/api/settings', - payload: { tunnelEnabled: true }, - }); + it('refuses a false acknowledgement', async () => { + expectRefused(await put({ tunnelEnabled: true, acknowledgeUnauthTunnel: false })); + }); - expect(res.statusCode).toBe(200); - expect(tunnel.start).toHaveBeenCalledTimes(1); + it('allows a configured password without acknowledgement', async () => { + process.env.CODEMAN_PASSWORD = 'test-password'; + expectEnabled(await put()); }); - it('ALLOWS tunnel-enable with per-request acknowledgeUnauthTunnel:true (start called, 200, flag not persisted)', async () => { - const res = await harness.app.inject({ - method: 'PUT', - url: '/api/settings', - payload: { tunnelEnabled: true, acknowledgeUnauthTunnel: true }, - }); + it('allows a multi-user admin without acknowledgement', async () => { + process.env.CODEMAN_MULTIUSER = '1'; + expectEnabled(await put()); + }); - expect(res.statusCode).toBe(200); - expect(tunnel.start).toHaveBeenCalledTimes(1); - // The action flag must NOT be persisted to settings.json. - expect(mockedWriteFile).toHaveBeenCalled(); - const persisted = JSON.parse(mockedWriteFile.mock.calls[0][1] as string); - expect(persisted.acknowledgeUnauthTunnel).toBeUndefined(); - expect(persisted.tunnelEnabled).toBe(true); + it.each(['0', 'false', '', 'enabled'])('refuses non-true tunnel env value %j', async (value) => { + process.env.CODEMAN_ALLOW_UNAUTHENTICATED_TUNNEL = value; + expectRefused(await put()); }); - it('still REFUSES when acknowledgeUnauthTunnel is false (4xx, start not called)', async () => { - const res = await harness.app.inject({ - method: 'PUT', - url: '/api/settings', - payload: { tunnelEnabled: true, acknowledgeUnauthTunnel: false }, - }); + it('does not guard disabling a running tunnel', async () => { + tunnel.isRunning.mockReturnValue(true); + const res = await put({ tunnelEnabled: false }); + expect(res.statusCode).toBe(200); + expect(tunnel.start.mock.calls).toEqual([]); + expect(tunnel.stop.mock.calls).toEqual([[]]); + expect(mockedWriteFile.mock.calls).toEqual([[SETTINGS_PATH, JSON.stringify({ tunnelEnabled: false }, null, 2)]]); + }); - expect(res.statusCode).toBeGreaterThanOrEqual(400); - expect(res.statusCode).toBeLessThan(500); - expect(tunnel.start).not.toHaveBeenCalled(); - expect(mockedWriteFile).not.toHaveBeenCalled(); + it.each([ + ['password', 'CODEMAN_PASSWORD', 'test-password', false, true], + ['multi-user', 'CODEMAN_MULTIUSER', '1', false, true], + ['neither', undefined, undefined, false, false], + ['empty password', 'CODEMAN_PASSWORD', '', false, false], + ['network opt-in only', 'CODEMAN_ALLOW_UNAUTHENTICATED_NETWORK', '1', false, false], + ['tunnel opt-in only', 'CODEMAN_ALLOW_UNAUTHENTICATED_TUNNEL', '1', false, false], + ['tunnel flag only', undefined, undefined, true, false], + ] as const)('status reports loginConfigured for %s', async (_label, key, value, flag, loginConfigured) => { + if (key !== undefined) process.env[key] = value; + (harness.ctx as unknown as { allowUnauthenticatedTunnel: boolean }).allowUnauthenticatedTunnel = flag; + const res = await harness.app.inject({ method: 'GET', url: '/api/tunnel/status' }); + expect(res.statusCode).toBe(200); + expect(res.json()).toEqual({ ...STATUS, loginConfigured }); + expect(tunnel.getStatus.mock.calls).toEqual([[]]); + expect(mockedWriteFile.mock.calls).toEqual([]); }); +}); + +describe('tunnel enable authorization across a concurrent stop', () => { + let harness: RouteTestHarness; + let tunnel: ReturnType<typeof makeTunnelManager>; + let running: boolean; + let stopDuringWrite: boolean; + let afterWrite: (() => void) | undefined; + let storedSettings: Record<string, boolean>; + const envKeys = [ + 'CODEMAN_PASSWORD', + 'CODEMAN_ALLOW_UNAUTHENTICATED_NETWORK', + 'CODEMAN_ALLOW_UNAUTHENTICATED_TUNNEL', + 'CODEMAN_MULTIUSER', + ] as const; + let savedEnv: (string | undefined)[]; - it('does not guard tunnel-disable (tunnelEnabled:false always allowed)', async () => { - tunnel = makeTunnelManager(true); + beforeEach(async () => { + savedEnv = envKeys.map((key) => process.env[key]); + for (const key of envKeys) delete process.env[key]; + harness = await createRouteTestHarness(registerSystemRoutes, { + authUser: { username: 'root', role: 'admin' }, + }); + vi.clearAllMocks(); + running = true; + stopDuringWrite = true; + afterWrite = undefined; + storedSettings = { tunnelEnabled: false, subagentTrackingEnabled: false }; + tunnel = makeTunnelManager(); + tunnel.isRunning.mockImplementation(() => running); (harness.ctx as unknown as { tunnelManager: unknown }).tunnelManager = tunnel; + (harness.ctx as unknown as { allowUnauthenticatedTunnel: boolean }).allowUnauthenticatedTunnel = false; + vi.mocked(fs.readFile).mockImplementation(async (path) => { + expect(path).toBe(SETTINGS_PATH); + return JSON.stringify(storedSettings); + }); + mockedWriteFile.mockImplementation(async (path, contents) => { + expect(path).toBe(SETTINGS_PATH); + storedSettings = JSON.parse(contents as string); + // The stop becomes visible during the awaited write, regardless of isRunning() call count. + if (stopDuringWrite) running = false; + afterWrite?.(); + }); + }); - const res = await harness.app.inject({ + afterEach(async () => { + try { + await harness?.app.close(); + } finally { + envKeys.forEach((key, i) => { + if (savedEnv[i] === undefined) delete process.env[key]; + else process.env[key] = savedEnv[i]; + }); + vi.mocked(fs.readFile).mockResolvedValue('{}'); + mockedWriteFile.mockResolvedValue(undefined); + } + }); + + const put = (acknowledgement: Record<string, boolean> = {}) => + harness.app.inject({ method: 'PUT', url: '/api/settings', - payload: { tunnelEnabled: false }, + payload: { tunnelEnabled: true, autoNameSessions: false, ...acknowledgement }, }); + function expectWritten(tunnelEnabled: boolean) { + const expected = { tunnelEnabled, subagentTrackingEnabled: false, autoNameSessions: false }; + // Check every write so a transient unauthorized enable followed by a rollback also fails. + expect + .soft(mockedWriteFile.mock.calls.map(([path, contents]) => ({ path, settings: JSON.parse(contents as string) }))) + .toEqual([{ path: SETTINGS_PATH, settings: expected }]); + expect.soft(storedSettings).toEqual(expected); + } + + function expectLateRefusal(res: Awaited<ReturnType<typeof put>>) { + expect.soft(res.statusCode).toBe(403); + expect.soft(res.json()).toEqual({ + success: false, + errorCode: 'OPERATION_FAILED', + error: expect.any(String), + }); + expect.soft(res.json().error).toContain('CODEMAN_ALLOW_UNAUTHENTICATED_TUNNEL'); + expect.soft(res.json().error).toMatch(/not (re)?start/i); + expect.soft(tunnel.start.mock.calls).toEqual([]); + expect.soft(tunnel.stop.mock.calls).toEqual([]); + expectWritten(false); + } + + function expectAuthorizedRestart(res: Awaited<ReturnType<typeof put>>) { expect(res.statusCode).toBe(200); - expect(tunnel.stop).toHaveBeenCalledTimes(1); + expect(res.json()).toEqual({}); + expect(tunnel.start.mock.calls).toEqual([[harness.ctx.port, harness.ctx.https]]); + expect(tunnel.stop.mock.calls).toEqual([]); + expectWritten(true); + } + + it('refuses an unauthorized restart after a concurrent stop while saving other settings', async () => { + expectLateRefusal(await put()); + }); + + it.each(['password', 'tunnel env', 'route flag', 'per-request acknowledgement', 'multi-user admin'] as const)( + 'allows a restart with %s authorization', + async (authorization) => { + if (authorization === 'password') process.env.CODEMAN_PASSWORD = 'test-password'; + if (authorization === 'tunnel env') process.env.CODEMAN_ALLOW_UNAUTHENTICATED_TUNNEL = '1'; + if (authorization === 'route flag') { + (harness.ctx as unknown as { allowUnauthenticatedTunnel: boolean }).allowUnauthenticatedTunnel = true; + } + if (authorization === 'multi-user admin') process.env.CODEMAN_MULTIUSER = '1'; + expectAuthorizedRestart( + await put(authorization === 'per-request acknowledgement' ? { acknowledgeUnauthTunnel: true } : {}) + ); + } + ); + + it.each([false, true])( + 'preserves stored tunnelEnabled=%s when an unauthorized tunnel stays running', + async (tunnelEnabled) => { + stopDuringWrite = false; + storedSettings.tunnelEnabled = tunnelEnabled; + const res = await put(); + expect(res.statusCode).toBe(200); + expect(res.json()).toEqual({}); + expect(tunnel.start.mock.calls).toEqual([]); + expect(tunnel.stop.mock.calls).toEqual([]); + expectWritten(tunnelEnabled); + } + ); + + it('does not acquire restart authorization from an env opt-in added during the write', async () => { + afterWrite = () => { + process.env.CODEMAN_ALLOW_UNAUTHENTICATED_TUNNEL = '1'; + }; + expectLateRefusal(await put()); + }); + + it('retains the request authorization when the env opt-in is removed during the write', async () => { + process.env.CODEMAN_ALLOW_UNAUTHENTICATED_TUNNEL = '1'; + afterWrite = () => { + delete process.env.CODEMAN_ALLOW_UNAUTHENTICATED_TUNNEL; + }; + expectAuthorizedRestart(await put()); }); }); diff --git a/test/service-installer.test.ts b/test/service-installer.test.ts index 0115a59ff..01cf1ad5a 100644 --- a/test/service-installer.test.ts +++ b/test/service-installer.test.ts @@ -6,6 +6,7 @@ import { describe, it, expect } from 'vitest'; import { + resolveServicePlan, buildLaunchAgentPlist, buildServiceEnv, buildServicePath, @@ -177,3 +178,26 @@ describe('detectServiceKind', () => { expect(detectServiceKind()).toBe(expected); }); }); + +describe('resolveServicePlan tunnel opt-in', () => { + it.each(['systemd', 'launchd'] as const)('carries the tunnel flag into %s args', (kind) => { + const options = { + host: '0.0.0.0', + port: 8080, + https: false, + allowUnauthenticatedNetwork: true, + allowUnauthenticatedTunnel: true, + multiuser: true, + }; + expect(resolveServicePlan(kind, options).args).toEqual([ + 'web', + '--host', + '0.0.0.0', + '--port', + '8080', + '--allow-unauthenticated-network', + '--allow-unauthenticated-tunnel', + '--multiuser', + ]); + }); +}); diff --git a/test/tunnel-enable-confirm-ui.test.ts b/test/tunnel-enable-confirm-ui.test.ts new file mode 100644 index 000000000..c69ae9c55 --- /dev/null +++ b/test/tunnel-enable-confirm-ui.test.ts @@ -0,0 +1,230 @@ +/** + * @fileoverview Issue #706 browser confirmation policy through the real welcome, tunnel panel and App Settings entry points. HTTP and browser prompts are stubbed; settings and acknowledgement logic run unchanged. + */ +import { readFileSync } from 'node:fs'; +import { join } from 'node:path'; +import { JSDOM } from 'jsdom'; +import { afterEach, describe, expect, it, vi } from 'vitest'; + +const PUBLIC = join(process.cwd(), 'src/web/public'); +const read = (name: string) => readFileSync(join(PUBLIC, name), 'utf8'); +const INDEX = read('index.html'); +const SCRIPTS = ['constants.js', 'app.js', 'settings-ui.js', 'api-client.js'].map(read); +const STORAGE_KEY = 'codeman-app-settings'; +const NO_LOGIN = { running: false, url: null, loginConfigured: false }; +type Status = { running: boolean; url: string | null; loginConfigured?: boolean }; +type Call = { method: string; url: string; body: Record<string, unknown> | undefined }; +type Entry = 'welcome' | 'panel' | 'save'; +const windows: JSDOM[] = []; + +afterEach(() => { + for (const dom of windows.splice(0)) dom.window.close(); +}); + +async function boot( + status: Status = NO_LOGIN, + accept = true, + statusFailure?: 'reject' | 'non-ok', + refuseFirstPut = false +) { + const dom = new JSDOM(INDEX, { url: 'http://localhost/', runScripts: 'outside-only' }); + windows.push(dom); + const win = dom.window; + if (win.document.readyState !== 'complete') await new Promise((resolve) => win.addEventListener('load', resolve)); + // Keep status polling and unrelated rendering timers outside this user action. + win.setTimeout = (() => 0) as unknown as typeof win.setTimeout; + win.setInterval = (() => 0) as unknown as typeof win.setInterval; + win.localStorage.setItem(STORAGE_KEY, JSON.stringify({ tunnelEnabled: false })); + const calls: Call[] = []; + const order: string[] = []; + const confirm = vi.fn(() => { + order.push('confirm'); + return accept; + }); + win.confirm = confirm; + let putCount = 0; + const response = (body: unknown, statusCode = 200) => ({ + ok: statusCode >= 200 && statusCode < 300, + status: statusCode, + json: async () => body, + clone: () => response(body, statusCode), + }); + const fetch = vi.fn(async (url: string, opts: { method?: string; body?: string } = {}) => { + const method = opts.method || 'GET'; + calls.push({ method, url, body: opts.body === undefined ? undefined : JSON.parse(opts.body) }); + order.push(`${method} ${url}`); + if (url === '/api/tunnel/status' && method === 'GET') { + if (statusFailure === 'reject') throw new Error('Status unavailable'); + // A failed HTTP response must not be trusted, even if its JSON resembles a status. + return response(status, statusFailure === 'non-ok' ? 503 : 200); + } + if (url === '/api/settings' && method === 'PUT') { + putCount++; + if (refuseFirstPut && putCount === 1) { + return response( + { success: false, error: 'Tunnel acknowledgement required', errorCode: 'OPERATION_FAILED' }, + 403 + ); + } + return response({ success: true, data: {} }); + } + if (url === '/api/execution/model-config' && method === 'PUT') return response({ success: true, data: {} }); + throw new Error(`Unexpected request: ${method} ${url}`); + }); + Object.assign(win, { fetch }); + win.eval( + [ + `var MobileDetection = { + getDeviceType: () => 'desktop', isHandheldDevice: () => false, + isMobile: () => false, isTouchDevice: () => false, + }; + var KeyboardHandler = {}, SwipeHandler = {}, DeepgramProvider = {}, NotificationManager = function () {}; + var KeyboardAccessoryBar = { setMode() {} }; + var VoiceInput = { _getDeepgramConfig: () => ({}), _saveDeepgramConfig() {}, refreshClaudeStatus: () => Promise.resolve() }; + var FocusTrap = function () { this.activate = () => {}; this.deactivate = () => {}; }; + var DEFAULT_VOICE_KEYTERMS = '';`, + ...SCRIPTS, + 'window.__App = CodemanApp;', + ].join('\n') + ); + // Only peripheral rendering is stubbed. All tunnel logic, settings reads/writes and HTTP helpers run for real. + const app = Object.create((win as unknown as { __App: { prototype: object } }).__App.prototype); + app.sessions = new Map(); + app.sessionOrder = []; + for (const name of [ + 'showToast', + 'closeAppSettings', + '_updateLocalEchoState', + 'applyTerminalFontFamily', + 'applyTerminalFontWeights', + '_syncPushPreferences', + 'applyHeaderVisibilitySettings', + 'applySkin', + 'applyLocalization', + 'applySessionListLayout', + 'applyTabOrientation', + 'applyLineageLineSettings', + '_updateTokensImmediate', + 'applyMonitorVisibility', + 'renderApprovals', + 'renderProjectInsightsPanel', + 'updateSubagentWindowVisibility', + '_updateCjkInputState', + 'showWelcome', + ]) + app[name] = vi.fn(); + const box = win.document.getElementById('appSettingsTunnelEnabled') as HTMLInputElement; + const button = win.document.getElementById('welcomeTunnelBtn') as HTMLButtonElement; + // The welcome screen has finished its status-dependent reveal before the user clicks. + button.style.display = ''; + const puts = () => calls.filter((call) => call.method === 'PUT' && call.url === '/api/settings'); + const events = () => + order.filter( + (event) => event === 'confirm' || event.endsWith('/api/settings') || event.endsWith('/api/tunnel/status') + ); + const stored = () => JSON.parse(win.localStorage.getItem(STORAGE_KEY) || '{}'); + const act = async (entry: Entry, enable = true) => { + if (entry === 'save') box.checked = enable; + if (entry === 'welcome') button.classList.toggle('active', !enable); + vi.clearAllMocks(); + calls.length = 0; + order.length = 0; + if (entry === 'welcome') await app.toggleTunnelFromWelcome(); + if (entry === 'panel') await app._tunnelPanelToggle(enable); + if (entry === 'save') await app.saveAppSettings(); + }; + return { act, puts, events, confirm, box, button, stored }; +} + +function expectEnablePut(calls: Call[], entry: Entry, acknowledge: boolean) { + expect(calls).toHaveLength(1); + const expected = { tunnelEnabled: true, ...(acknowledge ? { acknowledgeUnauthTunnel: true } : {}) }; + if (entry === 'save') { + // The spec leaves the other App Settings fields unchanged. + expect(calls[0].body).toMatchObject(expected); + if (!acknowledge) expect(calls[0].body).not.toHaveProperty('acknowledgeUnauthTunnel'); + } else { + expect(calls).toEqual([{ method: 'PUT', url: '/api/settings', body: expected }]); + } +} + +describe.each(['welcome', 'panel', 'save'] as const)('%s tunnel enable', (entry) => { + it('confirms before a successful PUT and sends the acknowledgement', async () => { + const h = await boot(); + await h.act(entry); + expect.soft(h.confirm).toHaveBeenCalledTimes(1); + expect.soft(h.events()).toEqual(['GET /api/tunnel/status', 'confirm', 'PUT /api/settings']); + expectEnablePut(h.puts(), entry, true); + }); + + it('asks again on every enable attempt', async () => { + const h = await boot(); + for (let attempt = 0; attempt < 2; attempt++) { + await h.act(entry); + expect.soft(h.confirm).toHaveBeenCalledTimes(1); + expect.soft(h.events()).toEqual(['GET /api/tunnel/status', 'confirm', 'PUT /api/settings']); + } + }); + + it('cancels without a PUT and leaves the tunnel off', async () => { + const h = await boot(NO_LOGIN, false); + await h.act(entry); + expect.soft(h.confirm).toHaveBeenCalledTimes(1); + expect.soft(h.puts()).toEqual([]); + expect.soft(h.events()).toEqual(['GET /api/tunnel/status', 'confirm']); + expect.soft(h.button.classList.contains('active')).toBe(false); + expect.soft(h.button.disabled).toBe(false); + expect.soft(h.button.style.display).not.toBe('none'); + expect.soft(h.box.disabled).toBe(false); + expect.soft(h.box.checked).toBe(false); + if (entry === 'save') expect.soft(h.stored().tunnelEnabled).toBe(false); + }); + + it('does not confirm or acknowledge when login is configured', async () => { + const h = await boot({ ...NO_LOGIN, loginConfigured: true }); + await h.act(entry); + expect(h.confirm).not.toHaveBeenCalled(); + expectEnablePut(h.puts(), entry, false); + }); + + it('does not confirm or acknowledge when loginConfigured is absent', async () => { + const h = await boot({ running: false, url: null }); + await h.act(entry); + expect(h.confirm).not.toHaveBeenCalled(); + expect(h.events()).toEqual(['GET /api/tunnel/status', 'PUT /api/settings']); + expectEnablePut(h.puts(), entry, false); + }); + + it.each(['reject', 'non-ok'] as const)('sends no acknowledgement when the status read is %s', async (failure) => { + const h = await boot(NO_LOGIN, true, failure); + await h.act(entry); + expect(h.confirm).not.toHaveBeenCalled(); + expect.soft(h.events()).toEqual(['GET /api/tunnel/status', 'PUT /api/settings']); + expectEnablePut(h.puts(), entry, false); + }); +}); + +it('save does not confirm or acknowledge an already running tunnel', async () => { + const h = await boot({ running: true, url: 'https://test.trycloudflare.com', loginConfigured: false }); + await h.act('save'); + expect(h.confirm).not.toHaveBeenCalled(); + expectEnablePut(h.puts(), 'save', false); +}); + +it.each(['reject', 'non-ok'] as const)('welcome keeps the 403 fallback when the status read is %s', async (failure) => { + const h = await boot(NO_LOGIN, true, failure, true); + await h.act('welcome'); + expect.soft(h.confirm).toHaveBeenCalledTimes(1); + expect.soft(h.events()).toEqual(['GET /api/tunnel/status', 'PUT /api/settings', 'confirm', 'PUT /api/settings']); + expect(h.puts()).toEqual([ + { method: 'PUT', url: '/api/settings', body: { tunnelEnabled: true } }, + { method: 'PUT', url: '/api/settings', body: { tunnelEnabled: true, acknowledgeUnauthTunnel: true } }, + ]); +}); + +it.each(['welcome', 'panel'] as const)('%s disables without confirmation', async (entry) => { + const h = await boot(); + await h.act(entry, false); + expect(h.confirm).not.toHaveBeenCalled(); + expect(h.puts()).toEqual([{ method: 'PUT', url: '/api/settings', body: { tunnelEnabled: false } }]); +}); diff --git a/test/tunnel-flag-plumbing.test.ts b/test/tunnel-flag-plumbing.test.ts new file mode 100644 index 000000000..9f7f92e9e --- /dev/null +++ b/test/tunnel-flag-plumbing.test.ts @@ -0,0 +1,101 @@ +/** + * @fileoverview Issue #706 carries the WebServer tunnel opt-in into its real route context. Requests use Fastify injection and settings use the isolated HOME from test/setup.ts; the tunnel process and socket listener are stubbed. + */ +import { mkdir, readFile, writeFile } from 'node:fs/promises'; +import { dirname } from 'node:path'; +import type { FastifyInstance } from 'fastify'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { startWebServer, WebServer } from '../src/web/server.js'; +import { TunnelManager } from '../src/tunnel-manager.js'; +import { TmuxManager } from '../src/tmux-manager.js'; +import { SETTINGS_PATH } from '../src/web/route-helpers.js'; + +const ENV_KEYS = [ + 'CODEMAN_PASSWORD', + 'CODEMAN_ALLOW_UNAUTHENTICATED_NETWORK', + 'CODEMAN_ALLOW_UNAUTHENTICATED_TUNNEL', + 'CODEMAN_MULTIUSER', +] as const; +const INITIAL = { tunnelEnabled: false, subagentTrackingEnabled: false }; +let savedEnv: (string | undefined)[]; +let server: WebServer | undefined; +let startTunnel: ReturnType<typeof vi.spyOn<TunnelManager, 'start'>>; + +beforeEach(async () => { + savedEnv = ENV_KEYS.map((key) => process.env[key]); + for (const key of ENV_KEYS) delete process.env[key]; + vi.spyOn(TmuxManager, 'isTmuxAvailable').mockReturnValue(true); + startTunnel = vi.spyOn(TunnelManager.prototype, 'start').mockImplementation(() => {}); + vi.spyOn(TunnelManager.prototype, 'isRunning').mockReturnValue(false); + await mkdir(dirname(SETTINGS_PATH), { recursive: true }); + await writeFile(SETTINGS_PATH, JSON.stringify(INITIAL)); +}); + +afterEach(async () => { + try { + await server?.stop(); + } finally { + server = undefined; + vi.restoreAllMocks(); + ENV_KEYS.forEach((key, i) => { + if (savedEnv[i] === undefined) delete process.env[key]; + else process.env[key] = savedEnv[i]; + }); + } +}); + +async function enableTunnel(instance: WebServer) { + server = instance; + const app = (server as unknown as { app: FastifyInstance }).app; + // Only the transport is replaced: start() still builds the production route context and middleware. + const listen = vi.spyOn(app, 'listen').mockImplementation(async () => { + await app.ready(); + return 'http://127.0.0.1:0'; + }); + await server.start(); + expect(listen.mock.calls).toEqual([[{ port: 0, host: '127.0.0.1' }]]); + startTunnel.mockClear(); + return app.inject({ method: 'PUT', url: '/api/settings', payload: { tunnelEnabled: true } }); +} + +describe('WebServer tunnel flag plumbing', () => { + it('permits an unauthenticated tunnel through the startWebServer factory flag', async () => { + const start = WebServer.prototype.start; + // Keep the real factory and startup path, replacing only the socket transport before startup. + vi.spyOn(WebServer.prototype, 'start').mockImplementation(async function (this: WebServer) { + // eslint-disable-next-line @typescript-eslint/no-this-alias -- Teardown must also stop an instance whose factory startup fails. + server = this; + const app = (this as unknown as { app: FastifyInstance }).app; + const listen = vi.spyOn(app, 'listen').mockImplementation(async () => { + await app.ready(); + return 'http://127.0.0.1:0'; + }); + await start.call(this); + expect(listen.mock.calls).toEqual([[{ port: 0, host: '127.0.0.1' }]]); + }); + server = await startWebServer(0, false, true, '127.0.0.1', undefined, false, '', true); + const app = (server as unknown as { app: FastifyInstance }).app; + startTunnel.mockClear(); + const res = await app.inject({ method: 'PUT', url: '/api/settings', payload: { tunnelEnabled: true } }); + expect(res.statusCode).toBe(200); + expect(res.json()).toEqual({ success: true, data: {} }); + expect(startTunnel.mock.calls).toEqual([[server.boundPort, false]]); + expect(JSON.parse(await readFile(SETTINGS_PATH, 'utf8'))).toEqual({ ...INITIAL, tunnelEnabled: true }); + }); + + it('permits an unauthenticated tunnel through the constructor flag', async () => { + const res = await enableTunnel(new WebServer(0, false, true, '127.0.0.1', undefined, false, '', true)); + expect(res.statusCode).toBe(200); + expect(res.json()).toEqual({ success: true, data: {} }); + expect(startTunnel.mock.calls).toEqual([[0, false]]); + expect(JSON.parse(await readFile(SETTINGS_PATH, 'utf8'))).toEqual({ ...INITIAL, tunnelEnabled: true }); + }); + + it('refuses an unauthenticated tunnel when the constructor flag is omitted', async () => { + const res = await enableTunnel(new WebServer(0, false, true, '127.0.0.1', undefined, false, '')); + expect(res.statusCode).toBe(403); + expect(res.json()).toEqual({ success: false, errorCode: 'OPERATION_FAILED', error: expect.any(String) }); + expect(startTunnel.mock.calls).toEqual([]); + expect(await readFile(SETTINGS_PATH, 'utf8')).toBe(JSON.stringify(INITIAL)); + }); +}); From 003398e209b9cd6ba1bb62e0dc6a82c44520f1ed Mon Sep 17 00:00:00 2001 From: Aamer Akhter <aakhter@gmail.com> Date: Mon, 26 Oct 2026 08:51:15 -0400 Subject: [PATCH 43/68] fix(agent): refuse an implicit HOME cwd for a directory spawn `codeman agent spawn` always sends its directory as an explicit workingDir, so the server cannot apply its no-workingDir rule and installs workspace hooks (and an injected skill) into ~/.claude when the caller's cwd is HOME. A bare spawn whose cwd resolves to the home directory now refuses with exit 4 before any request and names `--working-dir ~`; an explicit `--working-dir ~` is still sent. Paths are compared by realpath, so a symlinked HOME matches, and an unresolvable HOME or cwd falls back to the resolved path without throwing. The directory-spawn --json shape now reads the same in the README, the API reference, the wiki page and the changeset: `ready` when a readiness wait ran, `readinessError` when that call failed, and `startupError` when the start failed. A test pins all four. --- .changeset/project-worker-directories.md | 2 +- README.md | 2 +- docs/api-reference.md | 2 +- docs/wiki/Driving-Codeman-From-An-Agent.md | 7 +- plugins/codeman/skills/codeman/SKILL.md | 3 +- skills/codeman/SKILL.md | 3 +- src/cli-agent.ts | 53 +++++++++++-- test/cli-agent-project-spawn.test.ts | 92 +++++++++++++++++++++- 8 files changed, 151 insertions(+), 13 deletions(-) diff --git a/.changeset/project-worker-directories.md b/.changeset/project-worker-directories.md index 2707b60e3..594ccef06 100644 --- a/.changeset/project-worker-directories.md +++ b/.changeset/project-worker-directories.md @@ -2,6 +2,6 @@ "aicodeman": minor --- -Allow `codeman agent spawn` to start workers in the current project directory or an existing directory selected with `--working-dir`, while preserving explicit case quick-start behavior. A directory launch uses `POST /api/v1/sessions` followed by the mode's start route (`/interactive` or `/shell`), and its `--json` output is `{ sessionId, workingDir, created: true, ready }` (plus `startupError` when the start failed, exit 1). Only an omitted case means "use a directory": an empty case name still goes to quick-start, which refuses it, and an empty or whitespace-only `--working-dir` is refused locally (exit 4) before any request instead of resolving to the cwd. +Allow `codeman agent spawn` to start workers in the current project directory or an existing directory selected with `--working-dir`, while preserving explicit case quick-start behavior. A directory launch uses `POST /api/v1/sessions` followed by the mode's start route (`/interactive` or `/shell`), and its `--json` output is `{ sessionId, workingDir, created: true }`, plus `ready` when a readiness wait ran, `readinessError` when that call failed, and `startupError` when the start failed (exit 1). Only an omitted case means "use a directory": an empty case name still goes to quick-start, which refuses it, and an empty or whitespace-only `--working-dir` is refused locally (exit 4) before any request instead of resolving to the cwd. A cwd that resolves to the home directory is refused the same way, because the server would install its workspace hooks (and an injected skill) into `~/.claude`; an explicit `--working-dir ~` is still accepted. One change for named-case spawns too: an unknown `--mode` is now refused locally (exit 4) before any request, where it used to reach quick-start and come back as the server's 400 (exit 1). diff --git a/README.md b/README.md index 35e62c72e..160f90326 100644 --- a/README.md +++ b/README.md @@ -995,7 +995,7 @@ codeman agent ack <msg-id> # remove what you hand codeman agent rm "$SID" # any session except this one ``` -Rules the commands enforce rather than document: they refuse outside a Codeman session and never guess a URL; `send` transmits printable text plus Enter only (a control byte such as `Ctrl+C` is `app_exit` in opencode — ESC exists solely as `interrupt`, which never appends Enter); `rm` refuses an empty id, an unprovable self id and a prefix match in either direction. Ids may be the 8-character prefixes `ls` prints (resolved through the list; an ambiguous prefix refuses, anything shorter than 8 characters refuses on every verb). A prompt that starts with `-` goes after `--` (`send "$SID" -- "- fix the bug"`). The echo of the prompt you sent is output too: a `--match` marker that appears verbatim in the prompt matches at once, before the worker has done anything, so the prompt asks for it in halves. A remote session whose host is asleep answers a fire-and-forget `send` with `buffered` (Codeman wakes the host and types the prompt once the pane is back) or `dropped` (over the wake buffer's cap, nothing will be typed: exit `1`). Exit codes: `0` delivered/matched/signal, `1` error, `2` timeout, `3` the worker exited or the wait ended without an answer (`delivered:false`, `ended:true`), `4` refused. `spawn` prints the id alone on stdout (prose goes to stderr), so `SID=$(…)` captures exactly the id. `spawn` with no case, or with `--working-dir`, starts the worker in that existing directory through `POST /api/v1/sessions` plus `POST /api/v1/sessions/:id/interactive` (or `/shell`) instead of quick-start, so no case is created and the directory is never labelled agent scratch; an empty `--working-dir` refuses (exit `4`) rather than falling back to cwd; if the start fails it still prints the created id and exits `1`. `--json` prints the envelope's `data` for every verb, except a directory `spawn`, which prints `{ sessionId, workingDir, created: true, ready }` (plus `startupError` when the start failed). `--until stop` on a mode without hook signals is the server's 400, passed through — the marker path (`--match`) is the answer there, exactly as for the skill. +Rules the commands enforce rather than document: they refuse outside a Codeman session and never guess a URL; `send` transmits printable text plus Enter only (a control byte such as `Ctrl+C` is `app_exit` in opencode — ESC exists solely as `interrupt`, which never appends Enter); `rm` refuses an empty id, an unprovable self id and a prefix match in either direction. Ids may be the 8-character prefixes `ls` prints (resolved through the list; an ambiguous prefix refuses, anything shorter than 8 characters refuses on every verb). A prompt that starts with `-` goes after `--` (`send "$SID" -- "- fix the bug"`). The echo of the prompt you sent is output too: a `--match` marker that appears verbatim in the prompt matches at once, before the worker has done anything, so the prompt asks for it in halves. A remote session whose host is asleep answers a fire-and-forget `send` with `buffered` (Codeman wakes the host and types the prompt once the pane is back) or `dropped` (over the wake buffer's cap, nothing will be typed: exit `1`). Exit codes: `0` delivered/matched/signal, `1` error, `2` timeout, `3` the worker exited or the wait ended without an answer (`delivered:false`, `ended:true`), `4` refused. `spawn` prints the id alone on stdout (prose goes to stderr), so `SID=$(…)` captures exactly the id. `spawn` with no case, or with `--working-dir`, starts the worker in that existing directory through `POST /api/v1/sessions` plus `POST /api/v1/sessions/:id/interactive` (or `/shell`) instead of quick-start, so no case is created and the directory is never labelled agent scratch; an empty `--working-dir` refuses (exit `4`) rather than falling back to cwd, and so does a cwd that is your home directory (Codeman's hooks would land in `~/.claude`), which needs an explicit `--working-dir ~`; if the start fails it still prints the created id and exits `1`. `--json` prints the envelope's `data` for every verb, except a directory `spawn`, which prints `{ sessionId, workingDir, created: true }`, plus `ready` when a readiness wait ran, `readinessError` when that call failed, and `startupError` when the start failed. `--until stop` on a mode without hook signals is the server's 400, passed through — the marker path (`--match`) is the answer there, exactly as for the skill. ### MCP server (`codeman mcp`): drive Codeman from any MCP client diff --git a/docs/api-reference.md b/docs/api-reference.md index f3651cb55..7366bdaa8 100644 --- a/docs/api-reference.md +++ b/docs/api-reference.md @@ -591,7 +591,7 @@ request returns. The capture runs synchronous tmux calls on the server; the ## The `codeman agent` CLI (client over these endpoints) -`codeman agent ls|spawn|send|wait|read|interrupt|rm|post|inbox|ack` (`src/cli-agent.ts`) is the command-line client for the endpoints above, for agents the skill does not reach (a pi or shell session, a machine without `jq`). It adds no route: `spawn <case>` is `POST /api/v1/quick-start`, and `spawn` with no case (the caller's cwd) or with `--working-dir <dir>` (an existing local directory; combining it with a case, or an empty or whitespace-only value, refuses with exit 4 before any request) is `POST /api/v1/sessions` with `workingDir`, `mode`, `name` and `parentSessionId` followed by `POST /api/v1/sessions/:id/interactive` or `/shell` (the mode's `capabilities.startMode`), never retried; either form then runs `wait-output` on the mode's `capabilities.composerReadyMark` from the CLI registry where it declares one. `send` is `POST …/input` with `clientId`+`seq` (and `wait`/`waitTimeout` for `--wait` / `--until <signals>`; `delivered:false` without `duplicate` and `wait.ended` both exit 3 — the CLI never reports a dead worker as done), `wait` is `GET …/wait` (`--until`) or `GET …/wait-output` (`--match`, `from=buffer` by default), `read` is `GET …/last-response` or `GET …/terminal?tail=`, `interrupt` is `POST …/input` with a bare `\u001b`, `rm` is `DELETE …/sessions/:id`, and the inbox verbs map one to one onto the [agent inbox](#agent-inbox) routes (`post` → `POST …/inbox`, `inbox [id] [--wait]` → `GET …/inbox[?wait=]`, own inbox by default, `ack <ids…>` → `POST …/inbox/ack`); `ls` adds an `INBOX` column from `GET /api/v1/agent-inbox/summary` and drops it when that route does not answer. A fire-and-forget `send` to a sleeping wake-on-LAN host reads the route's `buffered` (own line, exit 0) and `dropped` (exit 1: the chunk is gone). An id may be the 8-character form `ls` prints, resolved through `GET /api/v1/sessions`; anything shorter refuses before any request, the same floor as `PARENT_SESSION_ID_MIN_PREFIX`. Every call carries `X-Codeman-Parent-Session`; only `spawn`'s quick-start carries `X-Codeman-Agent-Origin: codeman-agent-cli` (the agent-scratch label must never reach a request that cannot create the case directory). Basic auth comes from `CODEMAN_PASSWORD` or the data dir's `.env`. `--json` prints the envelope's `data`, except a directory `spawn`, which prints `{ sessionId, workingDir, created: true, ready, readinessError }`, and on a failed or ambiguous start `{ sessionId, workingDir, created: true, ready: false, startupError }` (exit 1, the created id kept so the caller can inspect it). An unknown `--mode` refuses (exit 4) before any request, for a named case too. Server-side error codes are shown verbatim (`INVALID_INPUT: until=stop …` on a hook-less mode is not hidden); exit codes are `0` ok, `1` error, `2` timeout, `3` the session exited, `4` refused by a client-side guard (for `inbox --wait`, `3` only when `GET /api/v1/sessions` no longer lists the session; a wait the server ended otherwise, on a restart or a detach, is `2`). `post` takes text that starts with `-` as it is (a markdown file often does); text that is exactly an option (`--json`, `-h`) goes after `--`. See the README section "`codeman agent`" for the guards and `test/cli-agent.test.ts` for the pinned behaviour. +`codeman agent ls|spawn|send|wait|read|interrupt|rm|post|inbox|ack` (`src/cli-agent.ts`) is the command-line client for the endpoints above, for agents the skill does not reach (a pi or shell session, a machine without `jq`). It adds no route: `spawn <case>` is `POST /api/v1/quick-start`, and `spawn` with no case (the caller's cwd) or with `--working-dir <dir>` (an existing local directory; combining it with a case, or an empty or whitespace-only value, refuses with exit 4 before any request; so does a cwd that resolves to the home directory, since the server would install its hooks there, so HOME needs an explicit `--working-dir ~`) is `POST /api/v1/sessions` with `workingDir`, `mode`, `name` and `parentSessionId` followed by `POST /api/v1/sessions/:id/interactive` or `/shell` (the mode's `capabilities.startMode`), never retried; either form then runs `wait-output` on the mode's `capabilities.composerReadyMark` from the CLI registry where it declares one. `send` is `POST …/input` with `clientId`+`seq` (and `wait`/`waitTimeout` for `--wait` / `--until <signals>`; `delivered:false` without `duplicate` and `wait.ended` both exit 3 — the CLI never reports a dead worker as done), `wait` is `GET …/wait` (`--until`) or `GET …/wait-output` (`--match`, `from=buffer` by default), `read` is `GET …/last-response` or `GET …/terminal?tail=`, `interrupt` is `POST …/input` with a bare `\u001b`, `rm` is `DELETE …/sessions/:id`, and the inbox verbs map one to one onto the [agent inbox](#agent-inbox) routes (`post` → `POST …/inbox`, `inbox [id] [--wait]` → `GET …/inbox[?wait=]`, own inbox by default, `ack <ids…>` → `POST …/inbox/ack`); `ls` adds an `INBOX` column from `GET /api/v1/agent-inbox/summary` and drops it when that route does not answer. A fire-and-forget `send` to a sleeping wake-on-LAN host reads the route's `buffered` (own line, exit 0) and `dropped` (exit 1: the chunk is gone). An id may be the 8-character form `ls` prints, resolved through `GET /api/v1/sessions`; anything shorter refuses before any request, the same floor as `PARENT_SESSION_ID_MIN_PREFIX`. Every call carries `X-Codeman-Parent-Session`; only `spawn`'s quick-start carries `X-Codeman-Agent-Origin: codeman-agent-cli` (the agent-scratch label must never reach a request that cannot create the case directory). Basic auth comes from `CODEMAN_PASSWORD` or the data dir's `.env`. `--json` prints the envelope's `data`, except a directory `spawn`, which prints `{ sessionId, workingDir, created: true }`, plus `ready` when a readiness wait ran, `readinessError` when that call failed, and `startupError` when the start failed (with `ready: false`, exit 1, the created id kept so the caller can inspect it). An unknown `--mode` refuses (exit 4) before any request, for a named case too. Server-side error codes are shown verbatim (`INVALID_INPUT: until=stop …` on a hook-less mode is not hidden); exit codes are `0` ok, `1` error, `2` timeout, `3` the session exited, `4` refused by a client-side guard (for `inbox --wait`, `3` only when `GET /api/v1/sessions` no longer lists the session; a wait the server ended otherwise, on a restart or a detach, is `2`). `post` takes text that starts with `-` as it is (a markdown file often does); text that is exactly an option (`--json`, `-h`) goes after `--`. See the README section "`codeman agent`" for the guards and `test/cli-agent.test.ts` for the pinned behaviour. `codeman mcp` (`src/mcp-server.ts`) is a Model Context Protocol server on stdio over the same endpoints, for MCP clients: `list_sessions` (`GET /api/v1/sessions`), `start_session` (`POST /api/v1/quick-start` with `X-Codeman-Agent-Origin: codeman-mcp`, then `wait-output` on the mode's ready mark), `send_prompt` (`POST …/input` with `wait`/`waitTimeout`), `wait_for_session` (`GET …/wait` or `…/wait-output`), `read_output` (`GET …/last-response` or `…/terminal?tail=`), `interrupt_session` and `delete_session`. It adds no route. The URL comes from `CODEMAN_API_URL` or `--url`, never a guess; prompting, interrupting and deleting are limited to sessions the process started (or the calling session's own children) unless it runs with `--allow-any-session`. See the README section "MCP server" and `test/mcp-server.test.ts`. diff --git a/docs/wiki/Driving-Codeman-From-An-Agent.md b/docs/wiki/Driving-Codeman-From-An-Agent.md index 2b68c9259..bab92f3fc 100644 --- a/docs/wiki/Driving-Codeman-From-An-Agent.md +++ b/docs/wiki/Driving-Codeman-From-An-Agent.md @@ -59,6 +59,8 @@ codeman agent rm "$SID" # any session except t local directory. A case and `--working-dir` cannot be combined, and an empty case name (`spawn "$CASE"` with `CASE` unset) is refused rather than read as "no case", as is an empty `--working-dir` (exit 4, before any request), so it never falls back to cwd. + A cwd that is your home directory is refused the same way, because Codeman's hooks would + land in `~/.claude`; pass `--working-dir ~` to start a worker there on purpose. Explicit case names retain quick-start behavior (creating a scratch case if missing). A directory launch is `POST /api/v1/sessions` followed by `POST /api/v1/sessions/:id/interactive` (or `/shell`); it preserves the project and @@ -82,8 +84,9 @@ codeman agent rm "$SID" # any session except t and goes when its session is deleted. - **Exit codes** are the same for every verb: `0` done, `1` error, `2` timeout, `3` the worker exited, `4` refused. `--json` prints the response's `data`, except a directory - `spawn`, which prints `{ sessionId, workingDir, created: true, ready }` (plus - `startupError` when the start failed). + `spawn`, which prints `{ sessionId, workingDir, created: true }`, plus `ready` when a + readiness wait ran, `readinessError` when that call failed, and `startupError` when the + start failed. ## The MCP server diff --git a/plugins/codeman/skills/codeman/SKILL.md b/plugins/codeman/skills/codeman/SKILL.md index 1433f58c5..f4ed3988c 100644 --- a/plugins/codeman/skills/codeman/SKILL.md +++ b/plugins/codeman/skills/codeman/SKILL.md @@ -46,7 +46,8 @@ For an existing project, use one command: no `--working-dir`, `spawn` uses cwd. Give editing workers separate worktrees. A positional case keeps quick-start behavior and cannot be combined with `--working-dir`; an empty `--working-dir` (`"$WT"` unset) refuses with exit 4 -rather than falling back to cwd. Directory launches keep parent lineage and never +rather than falling back to cwd, and a cwd that is HOME refuses too (hooks would +land in `~/.claude`; pass `--working-dir ~` to mean it). Directory launches keep parent lineage and never mark the project as agent scratch. If startup fails or is ambiguous, the CLI reports the exact created ID: inspect it before retrying or cleaning up. diff --git a/skills/codeman/SKILL.md b/skills/codeman/SKILL.md index 1433f58c5..f4ed3988c 100644 --- a/skills/codeman/SKILL.md +++ b/skills/codeman/SKILL.md @@ -46,7 +46,8 @@ For an existing project, use one command: no `--working-dir`, `spawn` uses cwd. Give editing workers separate worktrees. A positional case keeps quick-start behavior and cannot be combined with `--working-dir`; an empty `--working-dir` (`"$WT"` unset) refuses with exit 4 -rather than falling back to cwd. Directory launches keep parent lineage and never +rather than falling back to cwd, and a cwd that is HOME refuses too (hooks would +land in `~/.claude`; pass `--working-dir ~` to mean it). Directory launches keep parent lineage and never mark the project as agent scratch. If startup fails or is ambiguous, the CLI reports the exact created ID: inspect it before retrying or cleaning up. diff --git a/src/cli-agent.ts b/src/cli-agent.ts index 56a628530..ca6155ccf 100644 --- a/src/cli-agent.ts +++ b/src/cli-agent.ts @@ -31,7 +31,8 @@ * * @module cli-agent */ -import { stat } from 'node:fs/promises'; +import { realpath, stat } from 'node:fs/promises'; +import { homedir } from 'node:os'; import { resolve } from 'node:path'; import http from 'node:http'; import https from 'node:https'; @@ -378,9 +379,10 @@ export interface AgentDeps { /** * Print `data` as JSON (the `--json` path): the envelope's `data`, never a reshaped copy, - * except a directory spawn, which prints `{ sessionId, workingDir, created: true, ready }` - * (plus `startupError` when its start fails) because `POST /api/sessions` answers with - * the whole session row. + * except a directory spawn, which prints `{ sessionId, workingDir, created: true }` plus + * `ready` when a readiness wait ran, `readinessError` when that call failed, and + * `startupError` when its start fails, because `POST /api/sessions` answers with the + * whole session row. */ function emitJson(deps: AgentDeps, data: unknown): void { deps.io.out(JSON.stringify(data, null, 2)); @@ -542,6 +544,32 @@ export const AGENT_ORIGIN_HEADER = { 'X-Codeman-Agent-Origin': 'codeman-agent-cl export function composerReadyMark(mode: string): string | undefined { return getCli(mode)?.capabilities.composerReadyMark; } + +/** realpath, or the plain resolved path when it cannot be resolved; never throws. */ +async function canonicalPath(path: string): Promise<string> { + try { + return await realpath(path); + } catch { + return resolve(path); + } +} + +/** + * Whether `directory` is the user's home directory, comparing realpaths so a symlinked + * HOME (or a cwd reached through a symlink) still matches. False when HOME is unknown. + */ +export async function isHomeDirectory(directory: string): Promise<boolean> { + let home: string; + try { + home = homedir(); + } catch { + return false; + } + if (!home) return false; + const [a, b] = await Promise.all([canonicalPath(directory), canonicalPath(home)]); + return a === b; +} + /** `agent spawn` — start in a project directory or named case, preserving lineage and mode readiness. */ export async function agentSpawn(deps: AgentDeps, options: SpawnOptions): Promise<number> { const cli = getCli(options.mode); @@ -565,6 +593,18 @@ export async function agentSpawn(deps: AgentDeps, options: SpawnOptions): Promis } catch { return fail(deps, 'Working directory must be an existing local directory', EXIT.refused); } + // The server installs workspace hooks (and, with agent skill injection on, the + // skill) into the directory it is given, and it skips that only when the caller + // names no workingDir, because the cwd fallback is $HOME under installer-created + // services. This CLI always sends its directory explicitly, so an implicit cwd of + // HOME must be refused here or it would write ~/.claude/settings.local.json and + // ~/.claude/skills. An explicit `--working-dir ~` is the caller's choice and stays. + if (options.workingDir === undefined && (await isHomeDirectory(workingDir))) + return fail( + deps, + 'refusing: the current directory is your home directory; pass --working-dir ~ to start a worker there on purpose', + EXIT.refused + ); } const body: Record<string, unknown> = { ...(workingDir ? { workingDir } : { caseName: options.caseName }), @@ -1122,7 +1162,10 @@ export function registerAgentCommands(program: Command): Command { .description('Start a worker in cwd, an existing directory, or a named case; wait for its composer where supported') .option('-m, --mode <mode>', 'Run mode id, as the Run menu names it', 'claude') .option('-n, --name <name>', 'Session name shown in the UI') - .option('--working-dir <directory>', 'Existing project directory (defaults to cwd when no case is given)') + .option( + '--working-dir <directory>', + 'Existing project directory (defaults to cwd when no case is given; a cwd of HOME is refused, pass it explicitly)' + ) .option('--no-ready', 'Return as soon as the session exists, without the readiness wait') .option('-t, --timeout <ms>', 'Readiness budget in ms', String(DEFAULT_WAIT_MS)) .option('--json', 'Machine-readable output') diff --git a/test/cli-agent-project-spawn.test.ts b/test/cli-agent-project-spawn.test.ts index 3a5be98fe..4f76aac22 100644 --- a/test/cli-agent-project-spawn.test.ts +++ b/test/cli-agent-project-spawn.test.ts @@ -1,11 +1,13 @@ -import { mkdtemp, rm, writeFile } from 'node:fs/promises'; +import { mkdtemp, rm, symlink, writeFile } from 'node:fs/promises'; import http from 'node:http'; import { tmpdir } from 'node:os'; +import { readFileSync } from 'node:fs'; import { join } from 'node:path'; import { Command } from 'commander'; import { afterEach, describe, expect, it, vi } from 'vitest'; import { agentSpawn, + isHomeDirectory, registerAgentCommands, EXIT, type AgentDeps, @@ -260,4 +262,92 @@ describe('agent spawn through the real argument parser and transport', () => { expect(seen[0]).toMatchObject({ method: 'POST', url: '/api/v1/sessions', body: { workingDir: process.cwd() } }); expect(exitCode).toBe(EXIT.error); }); + it('bare `spawn` from a HOME cwd sends nothing and exits 4; `--working-dir <HOME>` is sent', async () => { + const home = await mkdtemp(join(tmpdir(), 'cod549-home-')); + dirs.push(home); + vi.stubEnv('HOME', home); + const cwd = vi.spyOn(process, 'cwd').mockReturnValue(home); + try { + const bare = await runSpawn([]); + expect(bare.seen).toEqual([]); + expect(bare.exitCode).toBe(EXIT.refused); + const explicit = await runSpawn(['--working-dir', home]); + expect(explicit.seen).toHaveLength(1); + expect(explicit.seen[0]).toMatchObject({ url: '/api/v1/sessions', body: { workingDir: home } }); + } finally { + cwd.mockRestore(); + vi.unstubAllEnvs(); + } + }); +}); + +describe('agent spawn never treats an implicit HOME cwd as a project', () => { + afterEach(() => { + vi.unstubAllEnvs(); + vi.restoreAllMocks(); + }); + async function homeDir(): Promise<string> { + const directory = await mkdtemp(join(tmpdir(), 'cod549-home-')); + dirs.push(directory); + return directory; + } + + it('refuses a cwd that is HOME with exit 4 before any request and names the explicit flag', async () => { + const home = await homeDir(); + vi.stubEnv('HOME', home); + vi.spyOn(process, 'cwd').mockReturnValue(home); + const t = deps([]); + expect(await agentSpawn(t.d, { ...base, mode: 'claude' })).toBe(EXIT.refused); + expect(t.calls).toEqual([]); + expect(JSON.parse(t.out[0]).error).toContain('pass --working-dir ~'); + }); + it('compares realpaths, so a symlinked HOME still matches the cwd it points at', async () => { + const real = await homeDir(); + const link = join(await homeDir(), 'home-link'); + await symlink(real, link); + vi.stubEnv('HOME', link); + vi.spyOn(process, 'cwd').mockReturnValue(real); + const t = deps([]); + expect(await agentSpawn(t.d, base)).toBe(EXIT.refused); + expect(t.calls).toEqual([]); + }); + it('allows an explicit --working-dir that is HOME, as POST /api/sessions does', async () => { + const home = await homeDir(); + vi.stubEnv('HOME', home); + vi.spyOn(process, 'cwd').mockReturnValue(home); + const t = deps([ok({ session: { id: OTHER } }), ok({})]); + expect(await agentSpawn(t.d, { ...base, workingDir: home })).toBe(EXIT.ok); + expect(t.calls[0].body).toMatchObject({ workingDir: home }); + }); + it('allows a cwd below HOME', async () => { + const home = await homeDir(); + vi.stubEnv('HOME', home); + const t = deps([ok({ session: { id: OTHER } }), ok({})]); + expect(await agentSpawn(t.d, base)).toBe(EXIT.ok); + expect(t.calls[0].body).toMatchObject({ workingDir: process.cwd() }); + }); + it('never throws when HOME cannot be resolved, and compares the resolved paths instead', async () => { + const missing = join(await homeDir(), 'absent'); + vi.stubEnv('HOME', missing); + await expect(isHomeDirectory(process.cwd())).resolves.toBe(false); + await expect(isHomeDirectory(missing)).resolves.toBe(true); + vi.stubEnv('HOME', ''); + await expect(isHomeDirectory(process.cwd())).resolves.toBe(false); + }); +}); + +describe('the directory-spawn --json shape is documented the same way everywhere', () => { + // The wiki wraps lines, so whitespace is normalized before comparing. + const clause = + '`{ sessionId, workingDir, created: true }`, plus `ready` when a readiness wait ran, `readinessError` when that call failed, and `startupError` when the start failed'; + for (const file of [ + 'README.md', + 'docs/api-reference.md', + 'docs/wiki/Driving-Codeman-From-An-Agent.md', + '.changeset/project-worker-directories.md', + ]) { + it(file, () => { + expect(readFileSync(new URL(`../${file}`, import.meta.url), 'utf8').replace(/\s+/g, ' ')).toContain(clause); + }); + } }); From a29192a248a9c7f3b531866466e08e2de079f208 Mon Sep 17 00:00:00 2001 From: Aamer Akhter <aakhter@gmail.com> Date: Mon, 26 Oct 2026 09:04:52 -0400 Subject: [PATCH 44/68] fix(agent): do not rerun a pane capture whose combined call failed The combined capture-pane and cursor query swallowed a thrown error and the caller then ran the same capture again, so a hung tmux blocked the event loop for two 5 s timeouts on every visible-frame capture, which serves every GET .../terminal load. A thrown error now reaches the capture's own catch; only output that splitFrameAndCursor cannot split falls back to the two separate calls. The capture runs through a captureRunner() seam, like inputRunner(), so the tests count the tmux calls each path makes. --- src/tmux-manager.ts | 39 +++++++++++------ test/tmux-capture-full-history.test.ts | 60 ++++++++++++++++++++++++-- 2 files changed, 81 insertions(+), 18 deletions(-) diff --git a/src/tmux-manager.ts b/src/tmux-manager.ts index 0eba7b222..f6a5efaf6 100644 --- a/src/tmux-manager.ts +++ b/src/tmux-manager.ts @@ -4295,7 +4295,8 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer { } capturePaneBuffer(muxName: string, paneTarget?: string, opts?: PaneCaptureOptions): string | null { - if (IS_TEST_MODE) return ''; + const exec = this.captureRunner(); + if (!exec) return ''; const target = resolveTmuxPaneTarget(muxName, paneTarget); if (!target) { console.error('[TmuxManager] Invalid pane target in capturePaneBuffer:', { muxName, paneTarget }); @@ -4341,13 +4342,20 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer { // caret belongs to the frame. Two separate calls raced the CLI's repaint: a // CLI that redraws its composer a few ms after input arrives (codex) could be // captured before the redraw and its caret queried after it, and guarded - // stage then refused a frame the keys had already reached. Any doubt about - // the combined output (a failed call, a last line that is not the query's) - // falls back to the two separate calls, which is what this always did. - const combined = fullHistory ? null : this.captureFrameWithCursor(`${captureCommand} \\; ${cursorQuery}`); + // stage then refused a frame the keys had already reached. Only output that + // splitFrameAndCursor() cannot split (a last line that is not the query's) + // falls back to the two separate calls, which is what this always did. A call + // that THREW (a hung tmux timing out) goes to the catch below like any other + // capture failure: retrying it would block the event loop for a second timeout + // on every `GET .../terminal` load. + const combined = fullHistory + ? null + : splitFrameAndCursor( + exec(`${captureCommand} \\; ${cursorQuery}`, { encoding: 'utf-8', timeout: EXEC_TIMEOUT_MS }) + ); const rawCapture = joinScrollbackAndFrame( scrollbackOverride, - combined?.frame ?? execSync(captureCommand, execOpts), + combined?.frame ?? exec(captureCommand, execOpts), scrollbackOverride !== undefined ? opts?.scrollbackOverrideScreen : undefined ); // Query the cursor BEFORE deciding anything else. On the full-history path @@ -4357,7 +4365,7 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer { const geometry = queryPaneCursor(() => combined ? combined.cursor - : execSync(`${this.tmux()} ${cursorQuery}`, { encoding: 'utf-8', timeout: EXEC_TIMEOUT_MS }) + : exec(`${this.tmux()} ${cursorQuery}`, { encoding: 'utf-8', timeout: EXEC_TIMEOUT_MS }) ); // Report the size the pane was really drawing at. The visible-frame path // below addresses every row absolutely, so a consumer whose terminal is @@ -4423,13 +4431,16 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer { } } - /** One combined capture-and-cursor-query call, split; null when it failed or is ambiguous. */ - private captureFrameWithCursor(command: string): { frame: string; cursor: string } | null { - try { - return splitFrameAndCursor(execSync(command, { encoding: 'utf-8', timeout: EXEC_TIMEOUT_MS })); - } catch { - return null; - } + /** + * How {@link capturePaneBuffer} runs tmux, or null where it must not (vitest). + * Split out like {@link inputRunner} so a test subclass can count and script the + * calls a capture makes. + */ + protected captureRunner(): + | ((cmd: string, opts: { encoding: 'utf-8'; timeout: number; maxBuffer?: number }) => string) + | null { + if (IS_TEST_MODE) return null; + return (cmd, opts) => execSync(cmd, opts); } /** diff --git a/test/tmux-capture-full-history.test.ts b/test/tmux-capture-full-history.test.ts index b9b721f6a..cc9270110 100644 --- a/test/tmux-capture-full-history.test.ts +++ b/test/tmux-capture-full-history.test.ts @@ -10,13 +10,14 @@ */ import { readFileSync } from 'node:fs'; import { resolve } from 'node:path'; -import { describe, expect, it } from 'vitest'; +import { describe, expect, it, vi } from 'vitest'; import { formatCursorRestore, formatPaneSnapshot, hasVisibleContent, queryPaneCursor, splitFrameAndCursor, + TmuxManager, } from '../src/tmux-manager.js'; describe('tmux full-history pane capture (COD-47)', () => { @@ -282,9 +283,9 @@ describe('the visible frame and its caret come from one tmux invocation', () => // after input): the frame could predate the caret, and guarded stage refused it. // Two backslashes in the source: one escaped backslash in the shell command, which hands tmux its `;`. const chained = '${captureCommand} ' + '\\'.repeat(2) + '; ${cursorQuery}'; - expect(methodBody).toContain('fullHistory ? null : this.captureFrameWithCursor(`' + chained + '`)'); - // Any doubt falls back to the two calls the capture always made. - expect(methodBody).toContain('combined?.frame ?? execSync(captureCommand, execOpts)'); + expect(methodBody).toContain('exec(`' + chained + '`'); + // Output it cannot split falls back to the two calls the capture always made. + expect(methodBody).toContain('combined?.frame ?? exec(captureCommand, execOpts)'); expect(methodBody).toContain('combined\n ? combined.cursor'); }); it('splits the combined output into the rows and the cursor line tmux prints last', () => { @@ -300,3 +301,54 @@ describe('the visible frame and its caret come from one tmux invocation', () => expect(splitFrameAndCursor('')).toBeNull(); }); }); + +describe('how many tmux calls one visible-frame capture makes', () => { + type Run = (cmd: string, opts: { encoding: 'utf-8'; timeout: number; maxBuffer?: number }) => string; + class ScriptedTmux extends TmuxManager { + calls: string[] = []; + constructor(private readonly answer: (cmd: string) => string) { + super(); + } + protected override captureRunner(): Run { + return (cmd) => { + this.calls.push(cmd.includes(' \\; ') ? 'combined' : cmd.includes('display-message') ? 'cursor' : 'capture'); + return this.answer(cmd); + }; + } + } + const quiet = () => vi.spyOn(console, 'error').mockImplementation(() => {}); + + it('makes ONE call when the combined output splits', () => { + const tmux = new ScriptedTmux(() => '› hello\n footer\n7 0 100 20 0 0\n'); + expect(tmux.capturePaneBuffer('codeman-12345678')).toContain('hello'); + expect(tmux.calls).toEqual(['combined']); + }); + + it('does not rerun a combined call that threw: a hung tmux costs one timeout, not two', () => { + const errors = quiet(); + try { + const tmux = new ScriptedTmux(() => { + throw Object.assign(new Error('spawnSync /bin/sh ETIMEDOUT'), { code: 'ETIMEDOUT' }); + }); + expect(tmux.capturePaneBuffer('codeman-12345678')).toBeNull(); + expect(tmux.calls).toEqual(['combined']); + expect(errors).toHaveBeenCalledTimes(1); + } finally { + errors.mockRestore(); + } + }); + + it('falls back to the two separate calls only for output it cannot split', () => { + const tmux = new ScriptedTmux((cmd) => + cmd.includes(' \\; ') ? '› hello\n footer\n' : cmd.includes('display-message') ? '7 0 100 20 0 0\n' : '› hello\n' + ); + expect(tmux.capturePaneBuffer('codeman-12345678')).toContain('hello'); + expect(tmux.calls).toEqual(['combined', 'capture', 'cursor']); + }); + + it('keeps the full-history path on its two calls', () => { + const tmux = new ScriptedTmux((cmd) => (cmd.includes('display-message') ? '0 0 80 24 0 0\n' : 'row\n')); + tmux.capturePaneBuffer('codeman-12345678', undefined, { fullHistory: true }); + expect(tmux.calls).toEqual(['capture', 'cursor']); + }); +}); From ecaddb40ae1266f31d0e6b3a283db1e9f01b931b Mon Sep 17 00:00:00 2001 From: Aamer Akhter <aakhter@gmail.com> Date: Mon, 26 Oct 2026 09:04:53 -0400 Subject: [PATCH 45/68] fix(agent): cap inspection cursors per owner and session Cursors shared one global FIFO of 1,024 and were never retired on use, so one agent polling every second evicted every other caller's cursors after about 17 minutes. Each owner and session now keeps its newest 8 cursors, older ones retired first, under the same global backstop; the cursor just used stays among the newest, so a retry of the last call still works. --- .../reference/inspection-and-staging.md | 4 ++-- .../reference/inspection-and-staging.md | 4 ++-- src/agent-inspection.ts | 21 ++++++++++++++-- test/agent-inspection.test.ts | 24 ++++++++++++++++++- 4 files changed, 46 insertions(+), 7 deletions(-) diff --git a/plugins/codeman/skills/codeman/reference/inspection-and-staging.md b/plugins/codeman/skills/codeman/reference/inspection-and-staging.md index 960a1f13f..630088c23 100644 --- a/plugins/codeman/skills/codeman/reference/inspection-and-staging.md +++ b/plugins/codeman/skills/codeman/reference/inspection-and-staging.md @@ -45,8 +45,8 @@ these reads do not provide authoritative current-thread tracking. Terminal resizes preserve inspection cursors. Cursors are opaque, scoped to the requesting identity, session, pane lifecycle generation and attributed transcript, expire after 15 minutes, and disappear on server -restart. At most 1024 are retained; capacity eviction can invalidate an older -cursor. A replaced/truncated/rewritten source, reassociated session, foreign cursor +restart. Each caller and session keeps its newest 8 (so retrying the last call +works), at most 1024 in all; an older cursor can be retired. A replaced/truncated/rewritten source, reassociated session, foreign cursor or unavailable native source returns `409 CONFLICT`. Inspect again without `--since` to establish a new tail baseline; this may omit older evidence. diff --git a/skills/codeman/reference/inspection-and-staging.md b/skills/codeman/reference/inspection-and-staging.md index 960a1f13f..630088c23 100644 --- a/skills/codeman/reference/inspection-and-staging.md +++ b/skills/codeman/reference/inspection-and-staging.md @@ -45,8 +45,8 @@ these reads do not provide authoritative current-thread tracking. Terminal resizes preserve inspection cursors. Cursors are opaque, scoped to the requesting identity, session, pane lifecycle generation and attributed transcript, expire after 15 minutes, and disappear on server -restart. At most 1024 are retained; capacity eviction can invalidate an older -cursor. A replaced/truncated/rewritten source, reassociated session, foreign cursor +restart. Each caller and session keeps its newest 8 (so retrying the last call +works), at most 1024 in all; an older cursor can be retired. A replaced/truncated/rewritten source, reassociated session, foreign cursor or unavailable native source returns `409 CONFLICT`. Inspect again without `--since` to establish a new tail baseline; this may omit older evidence. diff --git a/src/agent-inspection.ts b/src/agent-inspection.ts index c48c90fc5..eec6c159c 100644 --- a/src/agent-inspection.ts +++ b/src/agent-inspection.ts @@ -18,6 +18,8 @@ export interface InspectionScope { } interface CursorState { scope: string; + /** Owner and session only: the unit the per-scope cap counts. */ + holder: string; path: string; identity: string; offset: number; @@ -47,12 +49,16 @@ async function anchor(handle: FileHandle, offset: number): Promise<string> { return createHash('sha256').update(head).update(tail).digest('hex'); } +/** Cursors kept per owner and session; older ones are retired first. */ +export const INSPECT_CURSORS_PER_SCOPE = 8; + export class InspectionReader { private readonly cursors = new Map<string, CursorState>(); constructor( private readonly now = Date.now, private readonly capacity = 1024, - private readonly ttlMs = 900000 + private readonly ttlMs = 900000, + private readonly perScope = INSPECT_CURSORS_PER_SCOPE ) {} async read(path: string, scope: InspectionScope, maxBytes: number, since?: string) { @@ -160,10 +166,21 @@ export class InspectionReader { const cursor = randomUUID(); // No await between capacity enforcement and insertion: concurrent reads // must not all reserve the same remaining slot while hashing anchors. - for (const [key, value] of this.cursors) if (value.expiresAt <= this.now()) this.cursors.delete(key); + // Each owner and session keeps only its newest cursors, so one agent polling in + // a loop retires its own oldest ones instead of pushing every other caller's out + // of the shared map. The cursor just used stays among the newest, so a retry of + // the last call still works. The global capacity is only a backstop. + const holder = JSON.stringify([scope.owner, scope.sessionId]); + const mine: string[] = []; + for (const [key, value] of this.cursors) { + if (value.expiresAt <= this.now()) this.cursors.delete(key); + else if (value.holder === holder) mine.push(key); + } + while (mine.length >= this.perScope) this.cursors.delete(mine.shift()!); while (this.cursors.size >= this.capacity) this.cursors.delete(this.cursors.keys().next().value!); this.cursors.set(cursor, { scope: scopeKey, + holder, path, identity, offset: offset + consumed, diff --git a/test/agent-inspection.test.ts b/test/agent-inspection.test.ts index 989f005b4..cadbf53f8 100644 --- a/test/agent-inspection.test.ts +++ b/test/agent-inspection.test.ts @@ -3,7 +3,7 @@ import { afterEach, beforeEach, expect, it } from 'vitest'; import { mkdtemp, writeFile, appendFile, rename, rm } from 'node:fs/promises'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; -import { InspectionReader } from '../src/agent-inspection.js'; +import { INSPECT_CURSORS_PER_SCOPE, InspectionReader } from '../src/agent-inspection.js'; let dir: string; let file: string; let reader: InspectionReader; @@ -150,6 +150,28 @@ it('keeps cursor capacity bounded across concurrent reads', async () => { const resumed = await Promise.allSettled(results.map((result) => reader.read(file, scope, 100, result.cursor))); expect(resumed.filter((result) => result.status === 'fulfilled').length).toBeLessThanOrEqual(2); }); +it('caps cursors per owner and session, so one polling scope cannot evict another', async () => { + await writeFile(file, '{}\n'); + const other = { ...scope, owner: 'bob', sessionId: 't' }; + const bobs = await reader.read(file, other, 100); + // Alice polls every second for well past the old global FIFO of 1,024. + let last = await reader.read(file, scope, 100); + for (let i = 0; i < 1100; i++) last = await reader.read(file, scope, 100, last.cursor); + // Bob's cursor survives, and so does a retry of Alice's last call. + await expect(reader.read(file, other, 100, bobs.cursor)).resolves.toMatchObject({ evidence: '' }); + await expect(reader.read(file, scope, 100, last.cursor)).resolves.toMatchObject({ evidence: '' }); + // Alice keeps only her newest cursors: the oldest of one more than the cap is retired. + const issued: string[] = []; + for (let i = 0; i <= INSPECT_CURSORS_PER_SCOPE; i++) issued.push((await reader.read(file, scope, 100)).cursor); + await expect(reader.read(file, scope, 100, issued[0])).rejects.toThrow(/cursor/i); + await expect(reader.read(file, scope, 100, issued[issued.length - 1])).resolves.toBeDefined(); + // The cap counts owner and session: a new pane generation of Alice's session shares + // it, Bob's session does not. + const nextPane = { ...scope, generation: 'pane-2' }; + for (let i = 0; i < INSPECT_CURSORS_PER_SCOPE; i++) await reader.read(file, nextPane, 100); + await expect(reader.read(file, scope, 100, issued[issued.length - 1])).rejects.toThrow(/cursor/i); + await expect(reader.read(file, other, 100, bobs.cursor)).resolves.toBeDefined(); +}); it('keeps an incomplete UTF8 sequence pending until its JSONL record is complete', async () => { const row = Buffer.from('{"text":"😀"}\n'); const split = row.indexOf(Buffer.from('😀')) + 2; From 2aad93914e2e36005c7cbf29d3db47ead3bf9315 Mon Sep 17 00:00:00 2001 From: Aamer Akhter <aakhter@gmail.com> Date: Mon, 26 Oct 2026 09:05:25 -0400 Subject: [PATCH 46/68] fix(agent): hold input during a guarded submit instead of refusing it The input lease is held across the awaited tmux Enter, so WebSocket frames keep arriving while it is held. A tagged frame (seq N) was refused and its seq rolled back, but the browser's WebSocket path does not wait for ACKs: seq N+1 landed after the lease and moved the watermark past N, and the redelivered N was then dropped as a duplicate. The keystroke was lost. Input that arrives during the lease is now held and written in arrival order when the lease is released, whatever the submit's outcome. A WebSocket frame is applied and ACKed only once written; managed writes (writeViaMux) and named keys (send-key) wait for their turn the same way, and a held write that is still being sent holds everything behind it. A keystroke held before the guard runs still moves the input revision, so it still invalidates the Enter. The hold is capped per lease (256 inputs, 1 MiB): past it the WebSocket is closed with 1013 and nothing later from it is applied, so the browser resends every unACKed frame in order, and send-key answers 409 CONFLICT instead of logging a tmux failure as INTERNAL_ERROR. POST .../input keeps its 409 for the lease plus the held input's drain; its browser drainer is serialized. Docs: correct the api-reference paragraph that said these keystrokes are redelivered, and describe the capture fallback and the per-scope cursor cap from the previous two commits in api-reference, architecture-invariants and the changeset. --- .changeset/bounded-agent-coordination.md | 4 +- docs/api-reference.md | 34 +- docs/architecture-invariants.md | 21 +- src/session.ts | 161 ++++++++- src/web/routes/session-routes.ts | 22 +- src/web/routes/ws-routes.ts | 128 +++++--- test/agent-staging-session.test.ts | 29 +- test/guarded-lease-held-input.test.ts | 395 +++++++++++++++++++++++ test/routes/session-routes.test.ts | 33 +- 9 files changed, 727 insertions(+), 100 deletions(-) create mode 100644 test/guarded-lease-held-input.test.ts diff --git a/.changeset/bounded-agent-coordination.md b/.changeset/bounded-agent-coordination.md index 2ff2c0af5..62724678b 100644 --- a/.changeset/bounded-agent-coordination.md +++ b/.changeset/bounded-agent-coordination.md @@ -2,7 +2,7 @@ "aicodeman": minor --- -Add experimental bounded agent inspection with expiring scoped cursors and explicit pane snapshots, plus staged local Codex input and single-use hash-confirmed submission receipts. Verify the exact composer, caret, lifecycle and input revision before sending Enter, and serialize dispatch against managed input and auxiliary key operations. +Add experimental bounded agent inspection with expiring scoped cursors (the newest 8 kept per caller and session) and explicit pane snapshots, plus staged local Codex input and single-use hash-confirmed submission receipts. Verify the exact composer, caret, lifecycle and input revision before sending Enter, and serialize dispatch against managed input and auxiliary key operations. The three new CLI verbs (`codeman agent inspect`, `stage`, `submit`) and their HTTP routes (`/api/v1` and `/api`) are labeled experimental, so the versioning policy's rule for experimental surfaces applies: their shape and behavior may change in minor or patch releases. They are opt-in behind a new synced setting, `agentCoordinationEnabled` (App Settings, Cross-session features, "Experimental agent coordination", default OFF, translated for zh-CN): while it is off all three routes answer `403 FORBIDDEN` with a message naming it, and the CLI verbs print that refusal verbatim. @@ -10,7 +10,7 @@ Codex rollout attribution remembers the attributed file per session and re-verif The guarded composer read takes its glyph and footer from the CLI registry: codex's `workDetect.promptGlyph` and a new `workDetect.composerFooterLine` capability (config-supplied regex, compiled through the ReDoS guard). Only a CLI declaring it takes guarded input. Codex's footer effort words are now one shared list for this and `modelDetect.screenLine`. -Stage waits for the pane to show what it typed: it re-reads the composer every 50 ms for up to one second and issues a receipt only for a frame showing exactly the message (Codex repaints 30 to 60 ms after the keys, so the first capture was often stale and stage refused with the text already typed). Every refusal after typing now says the message may be in the composer and points at the Ctrl+U recovery. A visible-frame pane capture reads the screen and the caret in one tmux invocation. Stage refuses messages that would change what Enter does (codex: a leading `/` or `!`, or a last word starting with `@` or `$`, which open a command or completion popup) and frames with a suggestion popup above the composer, from three new registry fields (`workDetect.composerPopupLine`, `composerCommandPrefixes`, `composerCompletionSigils`). Submit waits until 250 ms after the staged keys before sending Enter, since codex turns an Enter within 120 ms of typed text into a newline. While a guarded submit is in flight, `POST /api/sessions/:id/input` answers `409 CONFLICT` with its seq returned instead of a `200` for input that was never written. +Stage waits for the pane to show what it typed: it re-reads the composer every 50 ms for up to one second and issues a receipt only for a frame showing exactly the message (Codex repaints 30 to 60 ms after the keys, so the first capture was often stale and stage refused with the text already typed). Every refusal after typing now says the message may be in the composer and points at the Ctrl+U recovery. A visible-frame pane capture reads the screen and the caret in one tmux invocation, falling back to the two separate calls only for output it cannot split; a call that fails is not repeated. Stage refuses messages that would change what Enter does (codex: a leading `/` or `!`, or a last word starting with `@` or `$`, which open a command or completion popup) and frames with a suggestion popup above the composer, from three new registry fields (`workDetect.composerPopupLine`, `composerCommandPrefixes`, `composerCompletionSigils`). Submit waits until 250 ms after the staged keys before sending Enter, since codex turns an Enter within 120 ms of typed text into a newline. Input that arrives while a guarded submit holds its input lease (the few milliseconds of its Enter) is held and written in arrival order once the lease is released, so a browser keystroke, a Shift+Enter or Ctrl+Enter, or an approval answer typed in that window is no longer lost; a WebSocket frame is acknowledged only once it is written. `POST /api/sessions/:id/input` answers `409 CONFLICT` with its seq returned for that span instead of a `200` for input that was never written, and a lease that would hold more than 256 inputs or 1 MiB closes the terminal WebSocket (code 1013) so the browser resends in order, and answers `send-key` with `409 CONFLICT`. Document the initial local Codex support and conversation-attribution limits in the API, CLI and packaged skill. Other modes retain labeled snapshots or refuse unverifiable guarded input. diff --git a/docs/api-reference.md b/docs/api-reference.md index d99d4ef2e..2476f9835 100644 --- a/docs/api-reference.md +++ b/docs/api-reference.md @@ -427,8 +427,9 @@ turn that never started. Verified live; this is the most common silent failure o this endpoint. Any input, waiting or not, answers `409 CONFLICT` with nothing written while an -experimental guarded submit holds the session's input lease (a few milliseconds; -see [guarded input](#experimental-bounded-agent-inspection-and-guarded-input)). +experimental guarded submit holds the session's input lease, or input held behind +it is still being written (a few milliseconds; see +[guarded input](#experimental-bounded-agent-inspection-and-guarded-input)). Retry it. A **plain prompt** (printable text followed by exactly one `\r`, nothing else) is @@ -659,9 +660,10 @@ thread, task completion or authorization. Opaque server-side cursors bind caller, session, pane generation, conversation, opened-file identity, byte offset and bounded content anchors. Terminal resizes -do not change this inspection scope. At most 1024 live -cursors are retained for 15 minutes; restart, eviction or expiry requires a fresh -tail baseline. Replacement, truncation, detected rewrites, reassociation, foreign +do not change this inspection scope. Each owner and session keeps its newest 8 +cursors (a retry of the last call still works; older ones are retired), at most +1024 live cursors are retained in all, each for 15 minutes; restart, eviction or +expiry requires a fresh tail baseline. Replacement, truncation, detected rewrites, reassociation, foreign cursors or loss of the attributed source return `409 CONFLICT`. No transcript filesystem path is returned as cursor/identity metadata; evidence retains the owner's native record content, which can itself contain paths. @@ -708,13 +710,21 @@ may already be in the composer: inspect the pane and follow the stranded-draft recovery (Ctrl+U) in the skill reference before staging again. While a guarded submit holds the input lease (the few milliseconds of its -conditional Enter), every other Codeman write path refuses, and -`POST /api/v1/sessions/:id/input` answers `409 CONFLICT` ("A guarded submit is in -progress on this session; nothing was written") instead of a `200` for bytes -that were never written. A tagged `clientId`/`seq` frame keeps its seq, so the -browser's durable queue, which keeps a frame on a `409`, delivers it on the next -retry. WebSocket keystrokes in that window are left unacknowledged and redelivered -the same way. +conditional Enter), input that reaches the session is held, not refused, and is +written in arrival order as soon as the lease is released, whatever the submit's +outcome. That covers terminal WebSocket keystrokes (each frame is acknowledged +only once it is written, so a frame typed during the lease lands before the one +typed after it), Shift+Enter and Ctrl+Enter from `POST .../send-key`, approval +answers and Codeman's own managed writes. A held write that is still being sent +holds everything behind it. `POST /api/v1/sessions/:id/input` instead answers +`409 CONFLICT` ("A guarded submit is in progress on this session; nothing was +written") for that span, the lease plus the held input's drain, rather than a +`200` for bytes that were never written; a tagged `clientId`/`seq` frame keeps +its seq, so the browser's durable queue, whose POST fallback sends one frame at a +time and keeps it on a `409`, delivers it on the next retry. A lease holds at +most 256 inputs or 1 MiB; past that a WebSocket is closed with code `1013` and +nothing later from it is applied, so the browser reconnects and resends every +unacknowledged frame in order, and `send-key` answers `409 CONFLICT`. Direct auxiliary key operations participate in the same pending-write and input lease checks. Complete allowlisted focus/mouse reports and empty writes preserve diff --git a/docs/architecture-invariants.md b/docs/architecture-invariants.md index d0fd2444c..2e2495eb7 100644 --- a/docs/architecture-invariants.md +++ b/docs/architecture-invariants.md @@ -1281,6 +1281,9 @@ candidate-metadata budgets, bounded path probes before canonical reads, and complete UTF8 JSONL records. A full page advances its cursor without claiming a blocked record. Opaque cursors bind caller/session/lifecycle/file identity; pane geometry is excluded so changing devices does not invalidate transcript paging. +Cursors are capped per owner and session (`INSPECT_CURSORS_PER_SCOPE`, the newest 8, +under the global 1,024), so one polling agent retires its own oldest cursors instead +of evicting every other caller's from the shared FIFO. ⚠️ Codex never prunes `~/.codex/sessions`, so attribution must not cost a store walk per call. The first call walks (negative-filter prefixes read 1 KiB first, @@ -1317,9 +1320,18 @@ and the first capture is often from before them; another writer or a lifecycle c ends the wait at once, and every refusal after typing says the message may be in the composer. `capturePaneBuffer()` reads the visible frame and its caret in ONE tmux call (`capture-pane \; display-message`, split by `splitFrameAndCursor()`), falling back to -the two calls on any doubt; keep the poll anyway. While a guarded submit holds the -lease, `POST .../input` answers `409` (seq returned) instead of a `200` for unwritten -bytes. ⚠️ Submit waits `SUBMIT_QUIET_MS` (250) after the staged keys before its +the two calls only for output it cannot split; a combined call that THREW goes to the +capture's catch and is never rerun (a hung tmux would block the event loop for a second +timeout on every `GET .../terminal`). Keep the poll anyway. ⚠️ Input that arrives +while a guarded submit holds the lease is HELD, never refused (`holdForGuardedLease()`, +`_inGuardedTurn()`), and written in arrival order when the lease is released: the +browser's WebSocket path does not wait for ACKs, so a refused seq N was overtaken by +N+1 and its redelivery dropped as a duplicate. A WS frame is ACKed only once written; +a held async write (mux write, named key) holds everything behind it until it settles +(`guardedInputHeld`); a full hold queue (256 inputs / 1 MiB) closes the socket with +`1013` and applies nothing later from it, so the browser resends in order. `POST +.../input` alone answers `409` (seq returned) for the lease plus the drain, since its +browser drainer is serialized and retries. ⚠️ Submit waits `SUBMIT_QUIET_MS` (250) after the staged keys before its checks: stage types plain keys, codex's paste-burst detector reads them as a paste, and an Enter within 120 ms of one becomes a newline while submit would report it delivered. Stage cancels older verifier retries and sends no Enter. Receipts contain hashes and @@ -1330,7 +1342,8 @@ Submission additionally binds input and geometry revisions and rechecks the exact composer/caret at the conditional Enter boundary. The route must retain the Session snapshot's staging generation rather than replacing it with the inspection generation. Managed input and auxiliary named keys participate in the -same pending/lease boundary. Complete focus/mouse reports and empty writes are +same pending/lease boundary: they wait for the lease, and only a full hold queue +refuses them (`GuardedInputBusyError`, which `send-key` answers as `409 CONFLICT`). Complete focus/mouse reports and empty writes are not edits. Never retry ambiguous Enter delivery or clear an unverified composer. The ordinary SubmitVerifier uses the shared write boundary, so each re-press updates `lastSubmitAt` just like any other Enter. diff --git a/src/session.ts b/src/session.ts index 1b1410d1a..dc43c4ff2 100644 --- a/src/session.ts +++ b/src/session.ts @@ -465,6 +465,17 @@ export interface ClaudeMessage { duration_ms?: number; } +/** + * A write that a guarded submit's lease could not hold, because the per-lease hold + * queue (`Session.MAX_GUARDED_HELD_INPUTS`) was full. Nothing was written; retry it. + */ +export class GuardedInputBusyError extends Error { + constructor() { + super('A guarded submit is in progress on this session; nothing was written. Retry the input.'); + this.name = 'GuardedInputBusyError'; + } +} + /** * Whether a programmatic write leaves the keep-alive turn running: the nudge itself, or a bare * Enter (which submits nothing new). Anything else carrying an Enter starts a turn of its own. @@ -4218,7 +4229,7 @@ export class Session extends EventEmitter { prompt: string, options?: { model?: string; onProgress?: (info: { tokens?: number; status?: string }) => void } ): Promise<{ result: string; cost: number }> { - if (this._guardedSubmitInFlight) { + if (this.guardedInputHeld) { this._guardedInputRevision++; throw new Error('Guarded submission in progress'); } @@ -4738,6 +4749,19 @@ export class Session extends EventEmitter { private _guardedInputRevision = 0; private _guardedPendingWrites = 0; private _guardedSubmitInFlight = false; + /** + * Input that arrived while a guarded submit held the lease, in arrival order. Each + * entry runs when the lease is released; an entry that returns a promise (a mux + * write, a named key) is waited for before the next runs, so nothing overtakes it. + */ + private _guardedHeld: Array<{ run: () => void | Promise<unknown>; bytes: number }> = []; + private _guardedHeldBytes = 0; + /** True while the drain waits for a held asynchronous write to finish. */ + private _guardedHeldDraining = false; + /** Held entries per lease. A lease lasts milliseconds; past this the caller backs off. */ + static readonly MAX_GUARDED_HELD_INPUTS = 256; + /** Held bytes per lease (16 browser frames at the 64 KiB frame cap). */ + static readonly MAX_GUARDED_HELD_BYTES = 16 * 64 * 1024; /** Ephemeral identity, never persisted or based on a potentially reused pid alone. */ get inspectionGeneration(): string { return `${this._inspectionInstance}:${this._guardedPaneRevision}:${this.owner ?? ''}`; @@ -4750,13 +4774,89 @@ export class Session extends EventEmitter { return this._guardedPendingWrites; } /** - * True while a guarded submit holds the input lease. Every write path refuses for - * that span (a few milliseconds), so a route that can report a refusal checks this - * first instead of answering as if the bytes went out. + * True while a guarded submit holds the input lease (a few milliseconds). Input that + * arrives in that span is held and written after it (see {@link guardedInputHeld}). */ get guardedSubmitInFlight(): boolean { return this._guardedSubmitInFlight; } + /** + * True while input is being held behind a guarded submit: the lease itself, or held + * input not yet written after it. A write that is not routed through + * {@link holdForGuardedLease} must refuse in this window, or it overtakes held input. + */ + get guardedInputHeld(): boolean { + return this._guardedSubmitInFlight || this._guardedHeld.length > 0 || this._guardedHeldDraining; + } + /** + * Hold input behind a guarded submit's lease instead of refusing it. + * + * `free`: nothing is held, so the caller writes inline as it always did. `held`: `run` + * is queued and runs, in arrival order with every other held input, once the lease is + * released (whatever the submit's outcome). `full`: the per-lease cap is reached and + * nothing was queued; the caller must stop delivering from that source until it can + * resend in order. Refusing instead lost input: the browser's WebSocket path does not + * wait for ACKs, so a refused seq N was overtaken by N+1 and its redelivery was then + * rejected as a duplicate. `data`, when given, is observed as input on arrival, so a + * keystroke held during the lease still moves the input revision at once. + */ + holdForGuardedLease(run: () => void | Promise<unknown>, bytes = 0, data?: string): 'free' | 'held' | 'full' { + if (!this.guardedInputHeld) return 'free'; + if (data !== undefined) this._observeGuardedKeyboardInput(data); + if ( + this._guardedHeld.length >= Session.MAX_GUARDED_HELD_INPUTS || + this._guardedHeldBytes + bytes > Session.MAX_GUARDED_HELD_BYTES + ) + return 'full'; + this._guardedHeld.push({ run, bytes }); + this._guardedHeldBytes += bytes; + return 'held'; + } + /** + * Run an asynchronous write now, or in its turn behind a guarded submit's lease. The + * turn lasts until the write settles, so later held input waits for it. Resolves + * `whenFull` without running anything when the hold queue is full. + */ + private _inGuardedTurn<T>(write: () => Promise<T>, whenFull: () => T): Promise<T> { + if (!this.guardedInputHeld) return write(); + return new Promise<T>((resolve, reject) => { + const held = this.holdForGuardedLease(() => { + const running = write(); + running.then(resolve, reject); + return running; + }); + if (held === 'full') { + try { + resolve(whenFull()); + } catch (err) { + reject(err); + } + } + }); + } + /** Run held input in arrival order until it is all written or a new lease is taken. */ + private _drainGuardedHeld(): void { + while (this._guardedHeld.length > 0 && !this._guardedSubmitInFlight && !this._guardedHeldDraining) { + const entry = this._guardedHeld.shift()!; + this._guardedHeldBytes -= entry.bytes; + let running: void | Promise<unknown>; + try { + running = entry.run(); + } catch (err) { + console.error(`[Session ${this.id}] held input failed after a guarded submit:`, getErrorMessage(err)); + continue; + } + if (running && typeof running.then === 'function') { + this._guardedHeldDraining = true; + void running + .catch(() => {}) + .then(() => { + this._guardedHeldDraining = false; + this._drainGuardedHeld(); + }); + } + } + } /** Expose only the path selection needed for attributed Codex evidence, never credentials. */ getCodexHomeOverride(): string | undefined { return this._envOverrides?.CODEX_HOME || undefined; @@ -4773,7 +4873,7 @@ export class Session extends EventEmitter { this._closing || this.paneLifecycleInFlight || this._guardedPendingWrites || - (!allowLease && this._guardedSubmitInFlight) || + (!allowLease && this.guardedInputHeld) || this.remote || this.docker || this.paneExit || @@ -4808,7 +4908,7 @@ export class Session extends EventEmitter { /** Stage through ordinary delivery, after cancelling any older Enter retry timer. */ async stageGuardedInput(message: string): Promise<boolean> { if (/[\r\n]/.test(message)) return false; - if (this._guardedPendingWrites || this._guardedSubmitInFlight) { + if (this._guardedPendingWrites || this.guardedInputHeld) { this._guardedInputRevision++; return false; } @@ -4839,18 +4939,29 @@ export class Session extends EventEmitter { return sent && dispatched; } finally { this._guardedSubmitInFlight = false; + this._drainGuardedHeld(); } } - /** Lease/pending boundary for named keys whose bytes bypass the ordinary write methods. */ + /** + * Lease/pending boundary for named keys whose bytes bypass the ordinary write methods. + * During a guarded submit the operation waits for the lease like any other write; + * only a full hold queue refuses it, with {@link GuardedInputBusyError}. + */ async performAuxiliaryInput(operation: () => Promise<void>): Promise<void> { this._guardedInputRevision++; - if (this._guardedSubmitInFlight) throw new Error('Guarded submission in progress'); - this._guardedPendingWrites++; - try { - await operation(); - } finally { - this._guardedPendingWrites--; - } + return this._inGuardedTurn( + async () => { + this._guardedPendingWrites++; + try { + await operation(); + } finally { + this._guardedPendingWrites--; + } + }, + () => { + throw new GuardedInputBusyError(); + } + ); } /** * Sends input directly to the PTY process. @@ -5040,7 +5151,17 @@ export class Session extends EventEmitter { */ async writeViaMux(data: string, options: SessionWriteOptions = {}): Promise<boolean> { this._observeGuardedKeyboardInput(data); - if (this._guardedSubmitInFlight) return false; + // A guarded submit's lease lasts for its Enter only: wait for it, in arrival order, + // rather than refusing, so an approval answer or a programmatic prompt is not + // dropped. Without a lease the write starts synchronously, so the pending count + // rises before any await and no lease can be taken while it is in flight. + return this._inGuardedTurn( + () => this._writeViaMuxNow(data, options), + () => false + ); + } + + private async _writeViaMuxNow(data: string, options: SessionWriteOptions): Promise<boolean> { this._guardedPendingWrites++; try { const submittedPrompt = this._trackSubmit(data, options); @@ -5090,7 +5211,13 @@ export class Session extends EventEmitter { ? null : this._mux.capturePaneText?.(this._muxSession.muxName), sendEnter: () => { - if (this._guardedSubmitInFlight) this._submitVerifier?.cancel(); + // A retry Enter is never held behind a guarded submit: once that submit's + // Enter went out, a re-press would submit whatever is on the composer next. + if (this.guardedInputHeld) { + this._guardedInputRevision++; + this._submitVerifier?.cancel(); + return Promise.resolve(false); + } return this.writeViaMux('\r'); }, // ⚠ NO fallback glyph here, unlike the screen-reading probe elsewhere in this file. @@ -5261,7 +5388,7 @@ export class Session extends EventEmitter { // Legacy method for sending input - wraps runPrompt async sendInput(input: string): Promise<void> { - if (this._guardedSubmitInFlight) { + if (this.guardedInputHeld) { this._guardedInputRevision++; throw new Error('Guarded submission in progress'); } diff --git a/src/web/routes/session-routes.ts b/src/web/routes/session-routes.ts index 369ea2dc0..ae4a523f8 100644 --- a/src/web/routes/session-routes.ts +++ b/src/web/routes/session-routes.ts @@ -35,6 +35,7 @@ import { type RemoteHost, } from '../../types.js'; import { + GuardedInputBusyError, Session, cliTakesSessionModel, isAltScreenStripMode, @@ -2078,12 +2079,14 @@ export function registerSessionRoutes( } // A guarded submit (agent-staging.ts) holds the input lease for the few - // milliseconds of its conditional Enter, and every write path refuses while it - // does. Say so instead of answering 200 for bytes that were never written, and - // give the seq back so the browser's durable queue (which keeps a frame on any - // 409) delivers it once the lease is released. Nothing from here to the write - // below awaits, so the lease cannot be taken in between. - if (!duplicate && session.guardedSubmitInFlight === true) { + // milliseconds of its conditional Enter, and input that arrives meanwhile is held + // and written in arrival order after it. This route refuses for that span (the + // lease plus the held input's drain) rather than overtake held input. Say so + // instead of answering 200 for bytes that were never written, and give the seq + // back so the browser's durable queue (which keeps a frame on any 409, and whose + // POST drainer sends one frame at a time) delivers it on its next retry. Nothing + // from here to the write below awaits, so the lease cannot be taken in between. + if (!duplicate && session.guardedInputHeld === true) { if (tagged) session.forgetInputSeq(clientId as string, seq as number); return reply .code(409) @@ -2416,7 +2419,7 @@ export function registerSessionRoutes( // own `capabilities.newline`. Claude Code's Ink input recognizes 0x0a as "insert newline" vs // 0x0d (carriage return = submit). - app.post('/api/sessions/:id/send-key', async (req) => { + app.post('/api/sessions/:id/send-key', async (req, reply) => { const { id } = req.params as { id: string }; const body = req.body as Record<string, unknown>; const key = typeof body?.key === 'string' ? body.key : ''; @@ -2461,6 +2464,11 @@ export function registerSessionRoutes( }) ); } catch (err) { + // A guarded submit's lease holds the key until its Enter is out; only a full + // hold queue refuses it, and that is a retryable conflict, not a tmux failure. + if (err instanceof GuardedInputBusyError) { + return reply.code(409).send(createErrorResponse(ApiErrorCode.CONFLICT, err.message)); + } console.error('[Server] send-key failed:', err); return createErrorResponse(ApiErrorCode.INTERNAL_ERROR, 'tmux send-keys failed'); } diff --git a/src/web/routes/ws-routes.ts b/src/web/routes/ws-routes.ts index 19c2ddca1..99a86691f 100644 --- a/src/web/routes/ws-routes.ts +++ b/src/web/routes/ws-routes.ts @@ -29,6 +29,11 @@ * {"t":"i","d":"...","seq":N,"cid":"..."} — input (keystroke or paste). seq+cid are * optional reliable-delivery tags: the server applies each * (cid,seq) at-most-once and ACKs with {"t":"ia","seq":N}. + * A frame that arrives while a guarded submit holds the + * session's input lease is held, then applied and ACKed in + * arrival order once it is released; when that hold queue + * is full the socket is closed with 1013 and nothing later + * from it is applied, so the client resends in order. * {"t":"z","c":N,"r":N,"f":bool} — resize terminal (f=true forces SIGWINCH even if dims unchanged) */ @@ -169,12 +174,69 @@ export function registerWsRoutes(app: FastifyInstance, ctx: SessionPort, getHost const sizingToken = Symbol('ws-desktop-sizing'); let holdsDesktopClaim = false; + // Set once an input frame could not be held behind a guarded submit's lease + // (the hold queue was full). Nothing later from this socket is applied: it is + // closed, and the browser reconnects and resends everything unACKed, in order. + let inputStalled = false; + + // Apply one input frame and ACK it. Runs inline, or later and in arrival order + // when a guarded submit held the input lease as the frame arrived. + const applyInputFrame = (msg: { d: string; seq?: unknown; cid?: unknown }) => { + // Reliable delivery: when the frame carries a clientId + seq, apply it + // exactly once (skip a duplicate redelivery) but ACK it regardless so + // the client can drop it from its durable queue. Frames without seq + // (legacy/other tools) are applied as-is — no behavior change. + const cid = typeof msg.cid === 'string' ? msg.cid : null; + const seq = Number.isInteger(msg.seq) ? (msg.seq as number) : null; + const apply = cid && seq !== null ? session.shouldApplyInput(cid, seq) : true; + let delivered = true; + if (apply) { + // Typed input from a claim-holding desktop keeps the claim "hot" + // and re-asserts the desktop layout after a mobile override. + if (holdsDesktopClaim) session.noteDesktopActivity(); + // Browser keystrokes are the user's own, so they may name the tab. + delivered = session.write(msg.d, { fromUser: true }); + // A session whose PTY is gone swallows the write. ACKing anyway told + // the client to drop the frame from its durable queue and left the seq + // burnt, so the retry that reliable delivery exists for was rejected as + // a duplicate: the input was lost for good. + if (!delivered && cid && seq !== null) session.forgetInputSeq(cid, seq); + } + if (seq !== null && socket.readyState === 1) { + if (apply) { + if (delivered) socket.send(`{"t":"ia","seq":${seq}}`); + } else { + // REJECTED as a duplicate. ACK it — the client must still drop it + // from its durable queue — but say so, and hand back our watermark. + // + // A plain ACK here is indistinguishable from "applied", which is + // what made a client with a rolled-back counter unrecoverable: its + // seqs persist to localStorage on a DEBOUNCED write, so a tab killed + // between a send and that write comes back with a counter BELOW this + // watermark, every later keystroke lands at or under it, and each one + // is dropped-but-ACKed. The UI stays clean, nothing is delivered, and + // a reload restores the same stale counter. `last` is what lets the + // client lift itself out. + // ⚠️ Defensive: the session arrives through a structural port, and an + // implementation without this method must not take the whole input + // path down with it — a throw here aborts the message handler and the + // frame is never ACKed at all, which strands it in the client's queue. + const watermark = + typeof (session as { lastInputSeq?: (c: string) => number }).lastInputSeq === 'function' + ? (session as { lastInputSeq: (c: string) => number }).lastInputSeq(cid as string) + : seq; + socket.send(`{"t":"ia","seq":${seq},"dup":true,"last":${watermark}}`); + } + } + }; + // Attach message handler synchronously BEFORE any async work // (@fastify/websocket requirement to avoid dropped messages). socket.on('message', (raw) => { try { const msg = JSON.parse(String(raw)); if (msg.t === 'i' && typeof msg.d === 'string') { + if (inputStalled) return; if (msg.d.length > MAX_INPUT_LENGTH) { // Refused for good, so say so: a silent return left the frame // unACKed and the client redelivered it every few seconds forever @@ -185,51 +247,27 @@ export function registerWsRoutes(app: FastifyInstance, ctx: SessionPort, getHost } return; } - // Reliable delivery: when the frame carries a clientId + seq, apply it - // exactly once (skip a duplicate redelivery) but ACK it regardless so - // the client can drop it from its durable queue. Frames without seq - // (legacy/other tools) are applied as-is — no behavior change. - const cid = typeof msg.cid === 'string' ? msg.cid : null; - const seq = Number.isInteger(msg.seq) ? (msg.seq as number) : null; - const apply = cid && seq !== null ? session.shouldApplyInput(cid, seq) : true; - let delivered = true; - if (apply) { - // Typed input from a claim-holding desktop keeps the claim "hot" - // and re-asserts the desktop layout after a mobile override. - if (holdsDesktopClaim) session.noteDesktopActivity(); - // Browser keystrokes are the user's own, so they may name the tab. - delivered = session.write(msg.d, { fromUser: true }); - // A session whose PTY is gone swallows the write. ACKing anyway told - // the client to drop the frame from its durable queue and left the seq - // burnt, so the retry that reliable delivery exists for was rejected as - // a duplicate: the input was lost for good. - if (!delivered && cid && seq !== null) session.forgetInputSeq(cid, seq); - } - if (seq !== null && socket.readyState === 1) { - if (apply) { - if (delivered) socket.send(`{"t":"ia","seq":${seq}}`); - } else { - // REJECTED as a duplicate. ACK it — the client must still drop it - // from its durable queue — but say so, and hand back our watermark. - // - // A plain ACK here is indistinguishable from "applied", which is - // what made a client with a rolled-back counter unrecoverable: its - // seqs persist to localStorage on a DEBOUNCED write, so a tab killed - // between a send and that write comes back with a counter BELOW this - // watermark, every later keystroke lands at or under it, and each one - // is dropped-but-ACKed. The UI stays clean, nothing is delivered, and - // a reload restores the same stale counter. `last` is what lets the - // client lift itself out. - // ⚠️ Defensive: the session arrives through a structural port, and an - // implementation without this method must not take the whole input - // path down with it — a throw here aborts the message handler and the - // frame is never ACKed at all, which strands it in the client's queue. - const watermark = - typeof (session as { lastInputSeq?: (c: string) => number }).lastInputSeq === 'function' - ? (session as { lastInputSeq: (c: string) => number }).lastInputSeq(cid as string) - : seq; - socket.send(`{"t":"ia","seq":${seq},"dup":true,"last":${watermark}}`); - } + // A guarded submit (agent-staging.ts) holds the input lease across its + // awaited Enter, so frames keep arriving while it is held. Refusing one + // lost it: this path does not wait for ACKs, so the next seq landed after + // the lease, moved the watermark past the refused one, and its redelivery + // was then dropped as a duplicate. Hold the frame instead; the session runs + // it, ACK included, in arrival order the moment the lease is released. + // ⚠️ Defensive, like `lastInputSeq` below: the session arrives through a + // structural port, and one without this method must still deliver input + // (a throw here would drop the frame unACKed). + const held = + typeof session.holdForGuardedLease === 'function' + ? session.holdForGuardedLease(() => applyInputFrame(msg), msg.d.length, msg.d) + : 'free'; + if (held === 'free') { + applyInputFrame(msg); + } else if (held === 'full') { + // Not held, not applied, not ACKed, and no later frame from this socket + // is applied either, so nothing can overtake it. + inputStalled = true; + console.warn('[ws] input backlog during a guarded submit; closing for redelivery', { sessionId: id }); + socket.close(1013, 'Input backlog during guarded submit'); } } else if ( msg.t === 'z' && diff --git a/test/agent-staging-session.test.ts b/test/agent-staging-session.test.ts index ff106fdf4..38d2d852a 100644 --- a/test/agent-staging-session.test.ts +++ b/test/agent-staging-session.test.ts @@ -50,7 +50,7 @@ it('fences writes and reused-pid pane lifecycles without persisting prompt conte expect(session.guardedInputSnapshot()).toBeNull(); }); -it('checks conditional Enter at the dispatch boundary and rejects concurrent browser input', async () => { +it('checks conditional Enter at the dispatch boundary and holds concurrent input until after it', async () => { let dispatch: (() => void) | undefined; const writes: string[] = []; const mux = { @@ -84,32 +84,41 @@ it('checks conditional Enter at the dispatch boundary and rejects concurrent bro muxSession: { muxName: 'codeman-test', sessionId: 'test', createdAt: 1, pid: 1 }, } as unknown as ConstructorParameters<typeof Session>[0]); try { + const settle = () => new Promise<void>((resolve) => setImmediate(resolve)); const expected = session.guardedInputSnapshot()!; const sending = session.submitGuardedInput(expected); - expect(await session.writeViaMux('other')).toBe(false); + // Held behind the lease, not refused: it still invalidates the pending Enter, and + // it goes out once the lease is released. + const other = session.writeViaMux('other'); dispatch!(); expect(await sending).toBe(false); expect(writes).toEqual([]); + await settle(); + dispatch!(); + expect(await other).toBe(true); + expect(writes).toEqual(['other']); const next = session.submitGuardedInput(session.guardedInputSnapshot()!); dispatch!(); expect(await next).toBe(true); - expect(writes).toEqual(['\r']); + expect(writes).toEqual(['other', '\r']); const restarting = session.submitGuardedInput(session.guardedInputSnapshot()!); await (session as unknown as { _withPaneLifecycle(op: () => Promise<void>): Promise<void> })._withPaneLifecycle( async () => {} ); dispatch!(); expect(await restarting).toBe(false); - expect(writes).toEqual(['\r']); + expect(writes).toEqual(['other', '\r']); const auxiliary = session.submitGuardedInput(session.guardedInputSnapshot()!); - await expect( - session.performAuxiliaryInput(async () => { - throw new Error('must not execute'); - }) - ).rejects.toThrow('Guarded submission'); + const key = session.performAuxiliaryInput(async () => { + writes.push('aux'); + }); + await settle(); + // The named key waits for the lease instead of failing, and never runs inside it. + expect(writes).toEqual(['other', '\r']); dispatch!(); expect(await auxiliary).toBe(false); - expect(writes).toEqual(['\r']); + await key; + expect(writes).toEqual(['other', '\r', 'aux']); } finally { await session.stop(false); } diff --git a/test/guarded-lease-held-input.test.ts b/test/guarded-lease-held-input.test.ts new file mode 100644 index 000000000..ffeb0a99e --- /dev/null +++ b/test/guarded-lease-held-input.test.ts @@ -0,0 +1,395 @@ +/** + * @fileoverview Input that lands while a guarded submit holds the input lease is held + * and applied in arrival order once the lease is released, never refused and lost. + * + * Shaped like the review repro: a real `Session`, a mux that runs `beforeDispatch` and + * then awaits the Enter the way `TmuxManager.sendInput` does, and the real WebSocket + * route driven through a fake socket. The browser's WebSocket path does not wait for + * ACKs, so the client model below sends every frame at once and redelivers whatever + * is still unACKed afterwards, in seq order, the way `_redeliverSweep` does. + */ +import { EventEmitter } from 'node:events'; +import { afterEach, describe, expect, it } from 'vitest'; +import type { FastifyInstance } from 'fastify'; +import { formatPaneSnapshot } from '../src/tmux-manager.js'; +import { GuardedInputBusyError, Session } from '../src/session.js'; +import { registerWsRoutes } from '../src/web/routes/ws-routes.js'; +import type { SessionPort } from '../src/web/ports/session-port.js'; + +type Frame = { t: 'i'; d: string; seq?: number; cid?: string }; + +class FakeSocket extends EventEmitter { + readyState = 1; + sent: Array<Record<string, unknown>> = []; + closedWith: number | null = null; + send(data: string): void { + this.sent.push(JSON.parse(data)); + } + close(code?: number): void { + this.closedWith = code ?? 1000; + this.readyState = 3; + } + ping(): void {} + terminate(): void { + this.readyState = 3; + } + frame(msg: Frame): void { + this.emit('message', Buffer.from(JSON.stringify(msg))); + } + acks(): number[] { + return this.sent.filter((m) => m.t === 'ia').map((m) => m.seq as number); + } +} + +interface Harness { + session: Session; + pty: string[]; + /** Resolve the in-flight Enter (true = tmux accepted it, false = it failed). */ + finishEnter(ok?: boolean | Error): void; + enterDispatched(): boolean; + connect(cid: string): FakeSocket; + stop(): Promise<void>; +} + +const sockets: FakeSocket[] = []; + +function harness(awaitBeforeGuard = false): Harness { + const pty: string[] = []; + let finish: ((ok: boolean | Error) => void) | undefined; + let dispatched = false; + const mux = { + isAvailable: () => true, + capturePaneBuffer: ( + _name: string, + _pane?: string, + opts?: { capturedGeometry?: { cols: number; rows: number } } + ) => { + if (opts) opts.capturedGeometry = { cols: 100, rows: 20 }; + return formatPaneSnapshot(['› hello', ' GPT-6-Luna default · ~/case'], { + cols: 100, + rows: 20, + cursorX: 7, + cursorY: 0, + }); + }, + // Like TmuxManager.sendInput's Enter-only branch: the guard runs synchronously, + // then the `send-keys Enter` child process is awaited with the lease still held. + sendInput: async (_id: string, text: string, options?: { beforeDispatch?: () => boolean }) => { + if (options?.beforeDispatch && text !== '\r') return false; + const settle = () => + new Promise<boolean | Error>((resolve) => { + finish = resolve; + }); + // A transport that awaits before its guard leaves a window in which held + // keystrokes must still invalidate the Enter. + const early = awaitBeforeGuard ? await settle() : undefined; + if (options?.beforeDispatch && !options.beforeDispatch()) return false; + dispatched = true; + const outcome = early ?? (await settle()); + if (outcome instanceof Error) throw outcome; + if (outcome) pty.push(text); + return outcome; + }, + }; + const session = new Session({ + workingDir: '/tmp', + mode: 'codex', + mux, + muxSession: { muxName: 'codeman-test', sessionId: 'test', createdAt: 1, pid: 1 }, + } as unknown as ConstructorParameters<typeof Session>[0]); + const access = session as unknown as { ptyProcess: { write(data: string): void } | null }; + access.ptyProcess = { write: (data: string) => pty.push(data) }; + + let handler: ((socket: FakeSocket, req: unknown) => void) | undefined; + const app = { + get: (_path: string, _opts: unknown, h: (socket: FakeSocket, req: unknown) => void) => { + handler = h; + }, + } as unknown as FastifyInstance; + const ctx = { sessions: new Map([[session.id, session]]) } as unknown as SessionPort; + registerWsRoutes(app, ctx, () => ({ bindHost: '127.0.0.1', allowedHosts: [] })); + + return { + session, + pty, + finishEnter: (ok = true) => finish!(ok), + enterDispatched: () => dispatched, + connect: (cid: string) => { + const socket = new FakeSocket(); + sockets.push(socket); + handler!(socket, { headers: { host: 'localhost:3000' }, params: { id: session.id }, query: { cid } }); + return socket; + }, + stop: async () => { + access.ptyProcess = null; + await session.stop(false); + }, + }; +} + +/** Redeliver every still-unACKed frame in seq order, as the browser's sweep does. */ +function redeliverUnacked(socket: FakeSocket, frames: Frame[]): void { + const acked = new Set(socket.acks()); + for (const f of frames) if (f.seq !== undefined && !acked.has(f.seq)) socket.frame(f); +} + +afterEach(() => { + for (const s of sockets.splice(0)) s.emit('close', 1000, Buffer.from('')); +}); + +describe('input during a guarded submit lease', () => { + it('delivers a keystroke that landed during the lease before the one typed after it (review repro)', async () => { + const h = harness(); + try { + const socket = h.connect('c1'); + const a: Frame = { t: 'i', d: 'a', seq: 1, cid: 'c1' }; + const b: Frame = { t: 'i', d: 'b', seq: 2, cid: 'c1' }; + const submit = h.session.submitGuardedInput(h.session.guardedInputSnapshot()!); + await Promise.resolve(); + expect(h.enterDispatched()).toBe(true); + expect(h.session.guardedSubmitInFlight).toBe(true); + socket.frame(a); + // Held, not refused: no ACK yet and nothing written while the lease is held. + expect(socket.acks()).toEqual([]); + expect(h.pty).toEqual([]); + h.finishEnter(true); + await expect(submit).resolves.toBe(true); + socket.frame(b); + redeliverUnacked(socket, [a, b]); + expect(h.pty).toEqual(['\r', 'a', 'b']); + expect(socket.acks()).toEqual([1, 2]); + expect(socket.sent.filter((m) => m.dup)).toEqual([]); + } finally { + await h.stop(); + } + }); + + it('keeps arrival order for several frames during the lease and one after it', async () => { + const h = harness(); + try { + const socket = h.connect('c1'); + const frames: Frame[] = ['a', 'b', 'c', 'd'].map((d, i) => ({ t: 'i', d, seq: i + 1, cid: 'c1' })); + const submit = h.session.submitGuardedInput(h.session.guardedInputSnapshot()!); + await Promise.resolve(); + for (const f of frames.slice(0, 3)) socket.frame(f); + expect(h.pty).toEqual([]); + h.finishEnter(true); + await submit; + socket.frame(frames[3]); + redeliverUnacked(socket, frames); + expect(h.pty).toEqual(['\r', 'a', 'b', 'c', 'd']); + expect(socket.acks()).toEqual([1, 2, 3, 4]); + } finally { + await h.stop(); + } + }); + + it('interleaves a second client by arrival, each with its own seq space', async () => { + const h = harness(); + try { + const one = h.connect('c1'); + const two = h.connect('c2'); + const submit = h.session.submitGuardedInput(h.session.guardedInputSnapshot()!); + await Promise.resolve(); + one.frame({ t: 'i', d: 'a', seq: 1, cid: 'c1' }); + two.frame({ t: 'i', d: 'x', seq: 1, cid: 'c2' }); + one.frame({ t: 'i', d: 'b', seq: 2, cid: 'c1' }); + h.finishEnter(true); + await submit; + two.frame({ t: 'i', d: 'y', seq: 2, cid: 'c2' }); + expect(h.pty).toEqual(['\r', 'a', 'x', 'b', 'y']); + expect(one.acks()).toEqual([1, 2]); + expect(two.acks()).toEqual([1, 2]); + } finally { + await h.stop(); + } + }); + + it('flushes held input when the lease ends in failure, refused Enter or thrown', async () => { + for (const outcome of [false, new Error('tmux send-keys failed')]) { + const h = harness(); + try { + const socket = h.connect('c1'); + const submit = h.session.submitGuardedInput(h.session.guardedInputSnapshot()!); + await Promise.resolve(); + socket.frame({ t: 'i', d: 'a', seq: 1, cid: 'c1' }); + h.finishEnter(outcome); + if (outcome instanceof Error) await expect(submit).rejects.toThrow('tmux send-keys failed'); + else await expect(submit).resolves.toBe(false); + socket.frame({ t: 'i', d: 'b', seq: 2, cid: 'c1' }); + expect(h.pty).toEqual(['a', 'b']); + expect(socket.acks()).toEqual([1, 2]); + expect(h.session.guardedSubmitInFlight).toBe(false); + } finally { + await h.stop(); + } + } + }); + + it('holds untagged frames too, which have no redelivery to fall back on', async () => { + const h = harness(); + try { + const socket = h.connect('c1'); + const submit = h.session.submitGuardedInput(h.session.guardedInputSnapshot()!); + await Promise.resolve(); + socket.frame({ t: 'i', d: 'a' }); + h.finishEnter(true); + await submit; + socket.frame({ t: 'i', d: 'b' }); + expect(h.pty).toEqual(['\r', 'a', 'b']); + } finally { + await h.stop(); + } + }); + + it('a frame held for a socket that closed is still applied once; its redelivery is a duplicate', async () => { + const h = harness(); + try { + const first = h.connect('c1'); + const a: Frame = { t: 'i', d: 'a', seq: 1, cid: 'c1' }; + const submit = h.session.submitGuardedInput(h.session.guardedInputSnapshot()!); + await Promise.resolve(); + first.frame(a); + first.close(); + first.emit('close', 1006, Buffer.from('')); + h.finishEnter(true); + await submit; + expect(first.sent).toEqual([]); + const second = h.connect('c1'); + second.frame(a); + second.frame({ t: 'i', d: 'b', seq: 2, cid: 'c1' }); + expect(h.pty).toEqual(['\r', 'a', 'b']); + expect(second.sent.filter((m) => m.t === 'ia')).toEqual([ + { t: 'ia', seq: 1, dup: true, last: 1 }, + { t: 'ia', seq: 2 }, + ]); + } finally { + await h.stop(); + } + }); + + it('closes the socket instead of holding past the cap, and the reconnect resends in order', async () => { + const h = harness(); + try { + const socket = h.connect('c1'); + const cap = Session.MAX_GUARDED_HELD_INPUTS; + const frames: Frame[] = Array.from({ length: cap + 3 }, (_, i) => ({ + t: 'i', + d: `k${i}`, + seq: i + 1, + cid: 'c1', + })); + const submit = h.session.submitGuardedInput(h.session.guardedInputSnapshot()!); + await Promise.resolve(); + for (const f of frames) socket.frame(f); + // Frames past the cap are not held, not applied and not ACKed; the socket is + // closed so the browser reconnects and resends everything still unACKed. + expect(socket.closedWith).toBe(1013); + h.finishEnter(true); + await submit; + expect(h.pty.length).toBe(1 + cap); + expect(socket.acks()).toEqual([]); + socket.emit('close', 1013, Buffer.from('')); + const again = h.connect('c1'); + for (const f of frames) again.frame(f); + expect(h.pty).toEqual(['\r', ...frames.map((f) => f.d)]); + expect(again.acks()).toEqual(frames.map((f) => f.seq)); + } finally { + await h.stop(); + } + }); + + it('holds managed writes and named keys behind the lease, and refuses them only when the hold queue is full', async () => { + const h = harness(); + try { + const submit = h.session.submitGuardedInput(h.session.guardedInputSnapshot()!); + await Promise.resolve(); + const order: string[] = []; + const key = h.session.performAuxiliaryInput(async () => { + order.push('key'); + }); + await new Promise<void>((resolve) => setImmediate(resolve)); + expect(order).toEqual([]); + for (let i = 1; i < Session.MAX_GUARDED_HELD_INPUTS; i++) { + expect(h.session.holdForGuardedLease(() => order.push('frame'))).toBe('held'); + } + expect(h.session.holdForGuardedLease(() => order.push('lost'))).toBe('full'); + await expect(h.session.performAuxiliaryInput(async () => order.push('lost'))).rejects.toBeInstanceOf( + GuardedInputBusyError + ); + await expect(h.session.writeViaMux('lost')).resolves.toBe(false); + h.finishEnter(true); + await submit; + await key; + expect(order[0]).toBe('key'); + expect(order.filter((o) => o === 'frame')).toHaveLength(Session.MAX_GUARDED_HELD_INPUTS - 1); + expect(order).not.toContain('lost'); + } finally { + await h.stop(); + } + }); + + it('caps held bytes as well as held frames', async () => { + const h = harness(); + try { + const submit = h.session.submitGuardedInput(h.session.guardedInputSnapshot()!); + await Promise.resolve(); + expect(h.session.holdForGuardedLease(() => {}, Session.MAX_GUARDED_HELD_BYTES)).toBe('held'); + expect(h.session.holdForGuardedLease(() => {}, 1)).toBe('full'); + h.finishEnter(true); + await submit; + expect(h.session.holdForGuardedLease(() => {}, Session.MAX_GUARDED_HELD_BYTES + 1)).toBe('free'); + } finally { + await h.stop(); + } + }); + + it('a frame arriving while a held named key is still being sent waits for it', async () => { + const h = harness(); + try { + const socket = h.connect('c1'); + let keySent!: () => void; + const submit = h.session.submitGuardedInput(h.session.guardedInputSnapshot()!); + await Promise.resolve(); + const key = h.session.performAuxiliaryInput(async () => { + await new Promise<void>((resolve) => { + keySent = resolve; + }); + h.pty.push('<newline>'); + }); + socket.frame({ t: 'i', d: 'a', seq: 1, cid: 'c1' }); + h.finishEnter(true); + await submit; + // The lease is over, but the held key is still in flight: input stays held. + expect(h.session.guardedSubmitInFlight).toBe(false); + expect(h.session.guardedInputHeld).toBe(true); + socket.frame({ t: 'i', d: 'b', seq: 2, cid: 'c1' }); + expect(h.pty).toEqual(['\r']); + keySent(); + await key; + await new Promise<void>((resolve) => setImmediate(resolve)); + expect(h.pty).toEqual(['\r', '<newline>', 'a', 'b']); + expect(socket.acks()).toEqual([1, 2]); + expect(h.session.guardedInputHeld).toBe(false); + } finally { + await h.stop(); + } + }); + + it('a keystroke held before the guard runs still invalidates the Enter, then lands', async () => { + const h = harness(true); + try { + const socket = h.connect('c1'); + const submit = h.session.submitGuardedInput(h.session.guardedInputSnapshot()!); + await Promise.resolve(); + socket.frame({ t: 'i', d: 'a', seq: 1, cid: 'c1' }); + h.finishEnter(true); + await expect(submit).resolves.toBe(false); + expect(h.enterDispatched()).toBe(false); + expect(h.pty).toEqual(['a']); + expect(socket.acks()).toEqual([1]); + } finally { + await h.stop(); + } + }); +}); diff --git a/test/routes/session-routes.test.ts b/test/routes/session-routes.test.ts index f770a76cc..cb9d48915 100644 --- a/test/routes/session-routes.test.ts +++ b/test/routes/session-routes.test.ts @@ -25,7 +25,7 @@ import { tmpdir } from 'node:os'; import { createMockRouteContext, type MockRouteContext } from '../mocks/index.js'; import { installRouteErrorHandler } from '../../src/web/route-error-handler.js'; import { ApiErrorCode, httpStatusForErrorCode } from '../../src/types.js'; -import { Session } from '../../src/session.js'; +import { GuardedInputBusyError, Session } from '../../src/session.js'; import { agentInbox } from '../../src/web/agent-inbox.js'; // Mock execFile so the send-key route's `tmux` invocation is observable (not run for real). @@ -265,6 +265,33 @@ describe('session-routes', () => { }); }); + it('answers 409 CONFLICT, without an error log, when a guarded submit cannot hold the key', async () => { + execFile.mockReset(); + const session = harness.ctx._session as unknown as { + performAuxiliaryInput: (op: () => Promise<void>) => Promise<void>; + }; + const original = session.performAuxiliaryInput; + session.performAuxiliaryInput = async () => { + throw new GuardedInputBusyError(); + }; + const errors = vi.spyOn(console, 'error').mockImplementation(() => {}); + try { + const res = await harness.app.inject({ + method: 'POST', + url: '/api/sessions/test-session-1/send-key', + payload: { key: 'S-Enter' }, + }); + expect(res.statusCode).toBe(409); + expect(res.json()).toMatchObject({ success: false, errorCode: 'CONFLICT' }); + expect(res.json().error).toMatch(/guarded submit is in progress.*nothing was written/); + expect(errors).not.toHaveBeenCalled(); + expect(execFile).not.toHaveBeenCalled(); + } finally { + errors.mockRestore(); + session.performAuxiliaryInput = original; + } + }); + it('rejects keys outside the hex allowlist without invoking tmux', async () => { execFile.mockReset(); const res = await harness.app.inject({ @@ -1203,7 +1230,7 @@ describe('session-routes', () => { const session = harness.ctx.sessions.get(harness.ctx._sessionId) as any; session.writeBuffer.length = 0; const post = (payload: unknown) => harness.app.inject({ method: 'POST', url, payload }); - session.guardedSubmitInFlight = true; + session.guardedInputHeld = true; for (const payload of [ { input: 'x' }, { input: 'x', useMux: true }, @@ -1218,7 +1245,7 @@ describe('session-routes', () => { expect(session.writeBuffer).toEqual([]); // Once the lease is released the browser's retry of the same seq is applied, not // rejected as a duplicate. - session.guardedSubmitInFlight = false; + session.guardedInputHeld = false; expect((await post({ input: 'tagged', seq: 7, clientId: 'cid-guard' })).statusCode).toBe(200); expect(session.writeBuffer).toEqual(['tagged']); }); From cd1b231876035bb71d49fe98888fab5586b6223d Mon Sep 17 00:00:00 2001 From: Codeman maintainer <noreply@anthropic.com> Date: Tue, 27 Oct 2026 01:21:22 +0100 Subject: [PATCH 47/68] fix: apply the review items for #718 at merge - docs/api-reference.md:1222: the tunnel status shape is { running, url, loginConfigured } (getStatus() returns only running and url) - src/web/routes/system-routes.ts:1233: the unauthenticated-tunnel warning keys on !isLoginConfigured(), so it no longer fires in multi-user mode - test/install-sh-invariants.test.ts:537: install.sh must not contain --allow-unauthenticated-tunnel either (it writes service argv as well as env) Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- docs/api-reference.md | 2 +- src/web/routes/system-routes.ts | 4 ++-- test/install-sh-invariants.test.ts | 1 + 3 files changed, 4 insertions(+), 3 deletions(-) diff --git a/docs/api-reference.md b/docs/api-reference.md index 7d892c480..f909ea00a 100644 --- a/docs/api-reference.md +++ b/docs/api-reference.md @@ -1219,7 +1219,7 @@ A local case whose folder is the top of a git repository can keep **worktrees** | ------ | ----------------------------- | ------------------------------------ | ----- | | `GET` | `/api/settings` | none | The stored settings object. Never writes. For a non-admin in multi-user mode, without `voiceSettings.apiKey`, `caseOrder` and `lastUsedCase`. | | `PUT` | `/api/settings` | partial settings (strict schema) | Merged into the stored file; services (watchers, tunnel, tmux history limit) reconcile to the merged result. `403 FORBIDDEN` for a non-admin in multi-user mode. A tunnel enable with no login (no `CODEMAN_PASSWORD`, not multi-user) is refused separately (`403 OPERATION_FAILED`, resend with `acknowledgeUnauthTunnel: true`) unless the server runs with `CODEMAN_ALLOW_UNAUTHENTICATED_TUNNEL=1` or `--allow-unauthenticated-tunnel`. `CODEMAN_ALLOW_UNAUTHENTICATED_NETWORK` does not count; when it is set, the refusal says so. An enable without that authorization is accepted only while the tunnel is already running and never stores `tunnelEnabled: true`; if the tunnel stops before the request would start it, it is not restarted and the answer is the same `403 OPERATION_FAILED` (the request's other settings are saved). | -| `GET` | `/api/tunnel/status` | none | `{ running, url, ..., loginConfigured }`. `loginConfigured` is `true` when a login protects the server (`CODEMAN_PASSWORD` set or multi-user mode on), the same test the tunnel guard above applies; with `false` the browser asks before every tunnel enable. Neither opt-in variable makes it `true`. | +| `GET` | `/api/tunnel/status` | none | `{ running, url, loginConfigured }`. `loginConfigured` is `true` when a login protects the server (`CODEMAN_PASSWORD` set or multi-user mode on), the same test the tunnel guard above applies; with `false` the browser asks before every tunnel enable. Neither opt-in variable makes it `true`. | | `GET` | `/api/execution/model-config` | none | `modelConfig` (cron's and the plan orchestrator's default model). | | `PUT` | `/api/execution/model-config` | `{ defaultModel?, agentTypeOverrides?, ... }` | Replaces `modelConfig`. `403 FORBIDDEN` for a non-admin in multi-user mode. | | `PUT` | `/api/cases/order` | `{ order: string[] }` | The one case order every user's `GET /api/cases` is sorted by. `403 FORBIDDEN` for a non-admin in multi-user mode. | diff --git a/src/web/routes/system-routes.ts b/src/web/routes/system-routes.ts index c72453635..52deb24f0 100644 --- a/src/web/routes/system-routes.ts +++ b/src/web/routes/system-routes.ts @@ -1228,9 +1228,9 @@ export function registerSystemRoutes( reply.code(403); return createErrorResponse(ApiErrorCode.OPERATION_FAILED, unauthenticatedTunnelRefusal(true)); } - // Loud warning whenever a public tunnel is started with no password, whether + // Loud warning whenever a public tunnel is started with no login in front of it, whether // acknowledged via the tunnel opt-in or the per-request UI confirmation. - if (!process.env.CODEMAN_PASSWORD) { + if (!isLoginConfigured()) { console.warn( '⚠️ [tunnel] Starting an UNAUTHENTICATED public Cloudflare tunnel — no CODEMAN_PASSWORD set. ' + 'Anyone with the tunnel URL gets full terminal control (effectively RCE). ' + diff --git a/test/install-sh-invariants.test.ts b/test/install-sh-invariants.test.ts index 31eee9270..0fd588be2 100644 --- a/test/install-sh-invariants.test.ts +++ b/test/install-sh-invariants.test.ts @@ -534,4 +534,5 @@ describe('install.sh review fixes for #460', () => { it('install.sh never contains the unauthenticated tunnel opt-in', () => { expect(SOURCE).not.toContain('CODEMAN_ALLOW_UNAUTHENTICATED_TUNNEL'); + expect(SOURCE).not.toContain('--allow-unauthenticated-tunnel'); }); From e8f66c644697b2f23e8c1f4d6e0f8015bcaf59e3 Mon Sep 17 00:00:00 2001 From: Codeman maintainer <noreply@anthropic.com> Date: Tue, 27 Oct 2026 01:21:51 +0100 Subject: [PATCH 48/68] fix: apply the review items for #711 at merge - terminal-ui.js _applyKeyboardClip(): read container.clientHeight only while rows are held, so the release path and the setting-off path skip the forced layout on every render and cursor move - constants.js keyboardClipPx JSDoc: drop the stray "ponytail:" marker, the note reads as plain text - src/web/CLAUDE.md "Per-device vs synced settings": list keyboardHoldsRows among the per-device keys SettingsUpdateSchema does not declare Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- src/web/CLAUDE.md | 2 +- src/web/public/constants.js | 4 ++-- src/web/public/terminal-ui.js | 2 +- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/src/web/CLAUDE.md b/src/web/CLAUDE.md index 4a2bc7bbc..d47b6caec 100644 --- a/src/web/CLAUDE.md +++ b/src/web/CLAUDE.md @@ -52,7 +52,7 @@ Frontend JS modules have `@fileoverview` with `@dependency`/`@loadorder` tags. L **Command palette + shortcut registry**: `Ctrl/Cmd/Alt+K` opens the session palette; shortcuts live in a rebindable registry (`DEFAULT_SHORTCUTS`/`getShortcutRegistry()`/`matchesShortcutEvent()` in app.js, overrides in `settings.shortcutOverrides`). ⚠️ Palette-chord keys must ALSO be swallowed in `attachCustomKeyEventHandler` (terminal-ui.js) or xterm writes the control byte into the PTY. ⚠️ The global Escape handler (app.js) calls EVERY close method on every Escape, in the capture phase, so a close method that does more than hide (the palette and Session Manager restore focus) must return early when its overlay is not open. ⚠️ `saveAppSettings()` rebuilds settings from the DOM, so keys edited elsewhere (`shortcutOverrides`, `showTokenCount`, `showCost`) need explicit `_prev` carry-over. ⚠️ **Smart copy (`Ctrl+C`)**: with no selection it must `return true` without `preventDefault()` or the interrupt is lost; keep `copyTerminalSelection` out of `SHORTCUT_ACTIONS`. The gate tests the CLEANED selection (`CodemanCopySelection.clean`: trailing padding, plus a LEADING margin only up to the width the CLI declares in `capabilities.transcriptGutter`, never one derived from the pane); the strip is not idempotent, so clean once and pass the RAW selection on, and leave Alt+drag column selections untouched. → [architecture-invariants#command-palette-and-shortcut-registry](../../docs/architecture-invariants.md#command-palette-and-shortcut-registry) -**Per-device vs synced settings**: the `displayKeys` set in settings-ui.js is a **client-side merge policy**, not a wire filter. A display key seeds from the server only when localStorage has no value for it, which is what prevents one device overwriting another; `showPlanUsageLimits` is additionally `delete`d from the incoming payload outright. Separately, `SettingsUpdateSchema` is `.strict()` and simply **does not declare** `skin`, `showFileViewerButton`, `showCronButton`, `webglRendererEnabled`, `localEchoEnabled`, `cjkInputEnabled`, or `extendedKeyboardBar`, so sending one of those is a validation error. The rest (`showResponseViewer`, `showPlanUsageLimits`, `language`, and most `show*` keys) ARE in the schema and do persist server-side; they are per-device by client policy only. ⚠️ Adding a new per-device setting means deciding **both** questions: membership in `displayKeys`, and presence in the schema. +**Per-device vs synced settings**: the `displayKeys` set in settings-ui.js is a **client-side merge policy**, not a wire filter. A display key seeds from the server only when localStorage has no value for it, which is what prevents one device overwriting another; `showPlanUsageLimits` is additionally `delete`d from the incoming payload outright. Separately, `SettingsUpdateSchema` is `.strict()` and simply **does not declare** `skin`, `showFileViewerButton`, `showCronButton`, `webglRendererEnabled`, `localEchoEnabled`, `keyboardHoldsRows`, `cjkInputEnabled`, or `extendedKeyboardBar`, so sending one of those is a validation error. The rest (`showResponseViewer`, `showPlanUsageLimits`, `language`, and most `show*` keys) ARE in the schema and do persist server-side; they are per-device by client policy only. ⚠️ Adding a new per-device setting means deciding **both** questions: membership in `displayKeys`, and presence in the schema. **Settings surface** (`#appSettingsModal` + `#sessionOptionsModal` + `#createCaseModal`): one `set-*` language shared through a single `:is(...)` id scope in styles.css. App Settings' rail is a table of contents over ONE scrolling document (`switchSettingsTab` scrolls); Session Options and Add Case really switch (`switchOptionsTab` / `switchCaseModalTab`), and their larger per-modal size blocks are the design, not drift. ⚠️ **The load/save contract is `getElementById` by id**: renaming or dropping a control id silently stops it loading or saving. ⚠️ The Session Options "Session" entry still keys off `context` (label-only rename). ⚠️ Add Case keeps its legacy `.form-row` markup via an adapter; every `<details>` there needs `.set-adv-chev` plus both marker suppressions. ⚠️ Model cards and the effort segment are views over hidden `<select>`s, which stay the source of truth. ⚠️ `.modal-tabs*` classes are retired; `admin-ui.js` needs `.set-rail-items` + `.set-doc` to survive any restructure. Guard: `test/app-settings-structure.test.ts`. → [architecture-invariants#settings-surface-app-settings-session-options-add-case](../../docs/architecture-invariants.md#settings-surface-app-settings-session-options-add-case) diff --git a/src/web/public/constants.js b/src/web/public/constants.js index ca9939001..226038df4 100644 --- a/src/web/public/constants.js +++ b/src/web/public/constants.js @@ -3389,8 +3389,8 @@ const KEYBOARD_CLIP_CURSOR_REACH = 2; * - Reading history: the offset follows the scroll position, so the top of the * scrollback is reachable and scrolling stays continuous. * - * ponytail: recomputed on every render with no hysteresis; add some if a TUI's - * mid-redraw frames ever make the view jitter. + * Recomputed on every render with no hysteresis; add some if a TUI's mid-redraw + * frames ever make the view jitter. * * @param {{rows: number, cellHeight: number, viewHeight: number, cursorRow: number, * lastContentRow: number, following: boolean, viewportY: number, baseY: number}} input diff --git a/src/web/public/terminal-ui.js b/src/web/public/terminal-ui.js index cdc86730a..f8b78ad7d 100644 --- a/src/web/public/terminal-ui.js +++ b/src/web/public/terminal-ui.js @@ -6638,7 +6638,7 @@ Object.assign(CodemanApp.prototype, { if (!container) return; const rows = this._keyboardHeldRowsActive() ? this.terminal.rows : 0; const cellHeight = rows ? this.terminal._core?._renderService?.dimensions?.css?.cell?.height : 0; - const viewHeight = container.clientHeight; + const viewHeight = rows ? container.clientHeight : 0; let clip = 0; if (cellHeight && viewHeight) { const buffer = this.terminal.buffer.active; From e9f3aa20a5c6693de5ad9c80f74a0c3541c41b9e Mon Sep 17 00:00:00 2001 From: Codeman maintainer <noreply@anthropic.com> Date: Tue, 27 Oct 2026 01:21:56 +0100 Subject: [PATCH 49/68] fix: apply the review items for #710 at merge - docs/architecture-invariants.md DeepSeek mechanism (1): the decoder stops at the first frame only before Node 24.22 and 26.10 (nodejs/node#65865) - src/deepseek-transcript.ts:33: reflow the over-long comment line to the width of the lines around it - docs/deepseek-integration.md:241: same reflow Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- docs/architecture-invariants.md | 2 +- docs/deepseek-integration.md | 3 ++- src/deepseek-transcript.ts | 9 +++++---- 3 files changed, 8 insertions(+), 6 deletions(-) diff --git a/docs/architecture-invariants.md b/docs/architecture-invariants.md index 7a743bb62..26141a7d0 100644 --- a/docs/architecture-invariants.md +++ b/docs/architecture-invariants.md @@ -53,7 +53,7 @@ Pure helpers unit-tested in `test/base-path.test.ts` + `test/webview-proxy.test. ⚠️ **`agent_working` is a hook event with no Claude Code hook behind it** (157th SSE constant). It exists because a harness turn cannot run while one of its own modal approvals is on screen, so "the agent started working" proves a dialog was answered in the terminal. It joins `APPROVAL_RESOLVING_EVENTS`; without it a dsh session's red alert would survive until the next `stop`, the exact stuck-alert bug the claude path already had to fix once — and the pane-capture staleness sweep that fixed it there is Claude-dialog-shaped and cannot help here. -⚠️ **Answers are READ FROM DISK for this mode, not scraped off the pane** (`src/deepseek-transcript.ts`, behind `GET /api/sessions/:id/last-response`). dsh writes a structured JSONL transcript at `$DSH_HOME/sessions/<mangled-cwd>/<dsh-session-id>/session.jsonl.zstd`, so it belongs with claude and codex rather than with the pane-segmented modes — and for dsh specifically the segmenter was not merely coarse but WRONG: dsh-TUI paints a full-screen splash, so a `last-response` call on a fresh dsh session returned its ASCII-art logo, which anything polling for a worker's first answer reads as an answer. Four mechanisms in that file are load-bearing. **(1)** dsh appends **one zstd FRAME per write**, and Node's `zlib` zstd decoder — one-shot AND streaming — stops at the first frame end: a real 56-line transcript decoded as 1 line / 158 bytes, i.e. the session header alone, so every call would have reported "nothing said yet" forever. `zstdFrameRanges()` walks frame and block headers (no decompression) to find exact boundaries and decompresses each frame; splitting on the 4-byte magic instead would corrupt everything after a magic sequence that happens to occur inside compressed data. zstd itself is resolved at RUNTIME (`zstdSupported()`), because it landed in Node 22.15 while the project floor is 22.0 and `@types/node` still does not declare it; without it the mode falls back to the pane exactly as before, which is why `readDeepSeekLastResponse()` distinguishes `null` ("this reader cannot run here") from an empty result ("read fine, nothing said yet"). **(2)** Every turn also records a plugin-sourced `user/message` (dsh's runtime-context snapshot: sandbox policy, approval policy, cwd), so only `source.kind === 'user'` is a real prompt. **(3)** A turn that ends in `reason.kind: 'error'` is surfaced as `Turn error: …` (and a non-error early stop such as `max-tokens` as `Turn ended: …`) rather than as an empty string, which an agent reads as "still thinking" through fifteen polls. **(4)** Reply text is assembled per (turn, step): a finalized `assistant/message` wins, and the streamed `assistant/chunk` / `text-chunks` deltas are consulted ONLY for a step that never finalized (so a partial answer is readable mid-turn without ever being appended twice) — ⚠️ and "finalized" is tracked as a SET of steps, not as non-empty text, because a step whose whole reply was reasoning strips to `''` at the `</think>` boundary and would otherwise resurrect the raw, unstripped deltas in its place (measured on a real conversation). ⚠️ Session→transcript pairing is by the transcript's own header `cwd` plus a ±60 s boot window against the Codeman session's `createdAt`, never by reproducing dsh's directory mangling (which already has two forms on disk, `<uuid>` and `session-<uuid>`) and never by newest-mtime alone: mtime alone handed a freshly spawned worker its PREDECESSOR's answer in the same case directory, which is worse than saying nothing because an agent cannot tell a stale answer from a fresh one. The `DSH_HOME` override reaches the reader through the narrow `Session.deepSeekHomeOverride` getter rather than an `envOverrides` accessor, since that map can hold provider credentials; it is ephemeral by design (never persisted), so a session that overrode it and outlived a server restart resolves the default tree and reads as "nothing said yet". Tests: `test/deepseek-transcript.test.ts`. +⚠️ **Answers are READ FROM DISK for this mode, not scraped off the pane** (`src/deepseek-transcript.ts`, behind `GET /api/sessions/:id/last-response`). dsh writes a structured JSONL transcript at `$DSH_HOME/sessions/<mangled-cwd>/<dsh-session-id>/session.jsonl.zstd`, so it belongs with claude and codex rather than with the pane-segmented modes — and for dsh specifically the segmenter was not merely coarse but WRONG: dsh-TUI paints a full-screen splash, so a `last-response` call on a fresh dsh session returned its ASCII-art logo, which anything polling for a worker's first answer reads as an answer. Four mechanisms in that file are load-bearing. **(1)** dsh appends **one zstd FRAME per write**, and Node's `zlib` zstd decoder (one-shot AND streaming) before 24.22 and 26.10 (nodejs/node#65865) stops at the first frame end: a real 56-line transcript decoded as 1 line / 158 bytes, i.e. the session header alone, so every call would have reported "nothing said yet" forever. `zstdFrameRanges()` walks frame and block headers (no decompression) to find exact boundaries and decompresses each frame; splitting on the 4-byte magic instead would corrupt everything after a magic sequence that happens to occur inside compressed data. zstd itself is resolved at RUNTIME (`zstdSupported()`), because it landed in Node 22.15 while the project floor is 22.0 and `@types/node` still does not declare it; without it the mode falls back to the pane exactly as before, which is why `readDeepSeekLastResponse()` distinguishes `null` ("this reader cannot run here") from an empty result ("read fine, nothing said yet"). **(2)** Every turn also records a plugin-sourced `user/message` (dsh's runtime-context snapshot: sandbox policy, approval policy, cwd), so only `source.kind === 'user'` is a real prompt. **(3)** A turn that ends in `reason.kind: 'error'` is surfaced as `Turn error: …` (and a non-error early stop such as `max-tokens` as `Turn ended: …`) rather than as an empty string, which an agent reads as "still thinking" through fifteen polls. **(4)** Reply text is assembled per (turn, step): a finalized `assistant/message` wins, and the streamed `assistant/chunk` / `text-chunks` deltas are consulted ONLY for a step that never finalized (so a partial answer is readable mid-turn without ever being appended twice) — ⚠️ and "finalized" is tracked as a SET of steps, not as non-empty text, because a step whose whole reply was reasoning strips to `''` at the `</think>` boundary and would otherwise resurrect the raw, unstripped deltas in its place (measured on a real conversation). ⚠️ Session→transcript pairing is by the transcript's own header `cwd` plus a ±60 s boot window against the Codeman session's `createdAt`, never by reproducing dsh's directory mangling (which already has two forms on disk, `<uuid>` and `session-<uuid>`) and never by newest-mtime alone: mtime alone handed a freshly spawned worker its PREDECESSOR's answer in the same case directory, which is worse than saying nothing because an agent cannot tell a stale answer from a fresh one. The `DSH_HOME` override reaches the reader through the narrow `Session.deepSeekHomeOverride` getter rather than an `envOverrides` accessor, since that map can hold provider credentials; it is ephemeral by design (never persisted), so a session that overrode it and outlived a server restart resolves the default tree and reads as "nothing said yet". Tests: `test/deepseek-transcript.test.ts`. ⚠️ **This is also what makes dsh the one non-claude mode the `codeman` agent skill drives like claude** (`skills/codeman/preamble.sh`, preamble 1.20.0): with a real end-of-turn signal AND a real transcript, `spawn_workers alpha beta:deepseek` is a mixed fleet in one call and `sendwait`/`last_text` need no per-mode variant. Two traps are handled in the preamble rather than left to the agent. **Readiness is not the stop signal**: the harness reports `idle` at BOOT roughly 300 ms before its composer paints (measured 2.26 s vs 2.56 s after spawn, twice), so a send-and-wait fired straight after `quick-start` resolves on the boot edge, reports a turn that never ran, and strands the prompt in a pane that was not yet accepting input — `spawn_worker`'s dsh branch gates on the composer glyph (`❯`, overridable via `DSH_READY_MARK`) instead, after which the boot edge is spent and unobservable. And `sendwait` asks for `wait:"stop,exit"` rather than the `wait:true` default set, because that set also carries `idle`, which for any external CLI is inferred from output stabilization: on a dsh worker whose TUI repaints rarely, a re-wait resolved in 0 ms with `signal:"idle"` on a turn that had three minutes left to run. The skill also sends `deepSeekConfig.permissionMode: 'danger-full-access'` for its own workers, matching what the Run button sends, because the harness default still asks and a worker parked on an approval row cannot finish a fan-out (the multi-user clamp still applies). diff --git a/docs/deepseek-integration.md b/docs/deepseek-integration.md index 5f1072a74..02af10027 100644 --- a/docs/deepseek-integration.md +++ b/docs/deepseek-integration.md @@ -238,7 +238,8 @@ reader (`src/deepseek-transcript.ts`): of them, Node's `zlib` zstd decoder before 24.22 and 26.10 stops at the first: a real 56-line transcript came back as 1 line. The reader walks frame headers itself, which stays correct on a Node that decodes every frame - (nodejs/node#65865). On a Node older than 22.15 (no zstd at all) the mode falls back to the pane, as before. + (nodejs/node#65865). On a Node older than 22.15 (no zstd at all) the mode + falls back to the pane, as before. - **Not every `user/message` is the user.** Each turn also records a plugin-sourced runtime-context snapshot; only `source.kind === 'user'` is a prompt. diff --git a/src/deepseek-transcript.ts b/src/deepseek-transcript.ts index 7fbdcdd16..525188f76 100644 --- a/src/deepseek-transcript.ts +++ b/src/deepseek-transcript.ts @@ -30,10 +30,11 @@ * (nodejs/node#65865) `zlib.zstdDecompress()` and `createZstdDecompress()` both * stop at the first frame end, and the supported range still includes those * versions: measured on a real 56-line transcript, Node returned 158 bytes / - * 1 line where the CLI returned 43,747 bytes / 56 lines. That is a silent truncation to the session header — - * every call would have reported "no answer yet" forever. `decodeZstdFrames()` - * below walks the frame headers itself and decompresses each frame, and - * `test/deepseek-transcript.test.ts` pins it against multi-frame fixtures. + * 1 line where the CLI returned 43,747 bytes / 56 lines. That is a silent + * truncation to the session header — every call would have reported "no answer + * yet" forever. `decodeZstdFrames()` below walks the frame headers itself and + * decompresses each frame, and `test/deepseek-transcript.test.ts` pins it + * against multi-frame fixtures. * * **2. The user's prompts are mixed with injected context.** Every turn also * writes a `user/message` whose source is a plugin (the runtime-context From e93382f2f180705eca266e27f0c37841cf74eaca Mon Sep 17 00:00:00 2001 From: Codeman maintainer <noreply@anthropic.com> Date: Tue, 27 Oct 2026 01:23:56 +0100 Subject: [PATCH 50/68] fix: apply the review items for #686 at merge - constants.js:2618 terminalUrlContinuationExtent: trailingColumns now needs a one-row first line (first.startRow === first.endRow), so a URL row that soft-wraps with a column on its wrapped part no longer glues the next row onto that column (`/tmp/report.txtccc/x.html` at 41 columns); master's links come back. Negative tests for the extent and the link provider. - constants.js:2847 terminalCellWrappedPaths: comment reworded to match the JSDoc (the exception is a left-column PATH), plus the wrapped-prose case. - docs/architecture-invariants.md, CLAUDE.md: one clause that a left column of wrapped prose (a Description | Path table) also fills the continuation row, so the path beside it keeps its per-row fragment links, the safe direction. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- CLAUDE.md | 2 +- docs/architecture-invariants.md | 2 +- src/web/public/constants.js | 24 +++++++++++++++++------- test/terminal-url-continuation.test.ts | 24 ++++++++++++++++++++++++ 4 files changed, 43 insertions(+), 9 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index e8609a10c..8281923ad 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -224,7 +224,7 @@ Codeman is a Claude Code session manager with web interface and autonomous Ralph **File-path links (terminal + chat)**: a path an agent prints is clickable on BOTH surfaces and opens the file-preview overlay. ⚠️ ONE pattern (`FILE_PATH_LINK_PATTERN` / `absoluteFilePathPattern()` in constants.js) feeds the xterm link provider AND `_linkifyFilePaths()`, a fresh instance per call (`lastIndex`). Both call `findFilePathLinks()`, which adds `~/`, relative and folder paths without overlapping a rooted match or a URL; a relative path resolves against `workingDir` (`resolveLinkedFilePath`), `~/` only server-side on the session's host (attachment route), and `terminalPathLine()` joins a path a program hard-wrapped short of the edge. ⚠️ A folder link opens the **folder grid**: `POST /api/sessions/:id/folders` guards + GRANTS the folder (`folder-listing.ts`, own registry, never the 200-capped attachment registry), files come by bare name via `…/folders/:folderId/files/:name/*` through `resolveServableAttachmentPath` plus the same extension/regular-file gate on the symlink's resolved target, so no browser request carries a path; `~/` is refused in a Docker case (the container's home is not this host's). The chat linkifier walks TEXT NODES with DOM APIs, never rebuilds sanitized markup as a string. ⚠️ An out-of-workspace path goes through the ATTACHMENT routes (`POST /api/sessions/:id/attachments` with `notify: false`), never by widening `file-content`/`file-raw` or `file-stream-manager`'s `tail -f` allowlist. ⚠️ `TEXT_ATTACHMENT_EXTENSIONS` IS `EDITABLE_EXTENSIONS` (never a second list), and widening READ must never widen RUN: `html`/`htm`/`svg` stay download-only, other text is inert `text/plain`+`nosniff`. ⚠️ The ONE place HTML renders is `/html-view/<cap>/...` (`html-view-routes.ts`, `html-view-capabilities.ts`): `POST /api/sessions/:id/html-view` mints a capability for the file's own directory, every response carries `Content-Security-Policy: sandbox` WITHOUT `allow-same-origin` (opaque origin even as a top-level tab, so the page can never read Codeman or drive its API) and with NO fetch directive (open network, owner decision 2026-10-13: never restrict it or add `allow-same-origin` without the owner), and only non-dot, asset-allowlisted files under that directory are served (GET/HEAD only; capabilities revoked with the web-tab ones on logout). That tree is served recursively, so minting is refused for a hidden directory or a broad root (`isBroadHtmlViewRoot`); the preview renders by default and the **Page** pill (or a refused mint) falls through to the source view and its Edit button. Never serve HTML same-origin anywhere else. Tests: `test/routes/html-view-routes.test.ts`. Media extensions are single-sourced in `attachment-registry.ts`. ⚠️ **XLSX previews parse in the BROWSER**, never on the server; the admission limits that keep a hostile workbook from freezing the worker live in `src/web/CLAUDE.md` (Spreadsheet preview). → [architecture-invariants#file-path-links-terminal--response-viewer](docs/architecture-invariants.md#file-path-links-terminal--response-viewer) -The terminal provider scans `terminalLinkLine()`, which extends `terminalPathLine()` for URL continuations in indented paragraphs and table cells. List markers, bare numbers, version tokens, whole email addresses, labels ending in `:`, measurements and clock times (`45%`, `12.3ms`, `10:00:00`) and a `name=value` field under a URL ending in `/` never continue a URL; a token with only `-`, `_` or `.` is also refused when it carries a call paren (`client.get()`) or ends a sentence (`Done.`). A URL in the last column of a space-aligned table never takes the next row's first cell: a continuation row must start inside the URL's own cell, so a gap of two or more spaces between its indent and where the URL starts on the row above means it is the next record. `terminalCellWrappedPaths()` follows each table cell independently: a fragment carries on into the next row only when it ends its cell on `/` or `-` (where a wrapper breaks a path; codex's cell wrap never breaks at `_`), the continuation row is blank to the left of the cell (a wrapper wraps every column of a table row together, so a label there such as `Entry` or `│ Log │` is the next record, unless a left column's path continued on that row too), the next row's token passes `pathContinuation()`, and the cell is no wider than the one above. So a directory that is a whole cell value (`/home/u/proj` over `config.yaml`, or `/home/u/proj/app/` over a labelled `main.py`) keeps its folder link and the next row keeps its own file link. With no label to tell them apart, a directory written with its trailing slash over a relative path in the same column (`/home/u/proj/src/` over `lib/util.ts`) still joins, a known trade-off: it reads exactly like a wrap at `/`. Accepted paths displace other links on their cells. Full-width divider rows stop `terminalLogicalLine()` stitching for both links and touch selection. OSC 8 links confirm the real target URI before the shared opener runs. +The terminal provider scans `terminalLinkLine()`, which extends `terminalPathLine()` for URL continuations in indented paragraphs and table cells. List markers, bare numbers, version tokens, whole email addresses, labels ending in `:`, measurements and clock times (`45%`, `12.3ms`, `10:00:00`) and a `name=value` field under a URL ending in `/` never continue a URL; a token with only `-`, `_` or `.` is also refused when it carries a call paren (`client.get()`) or ends a sentence (`Done.`). A URL in the last column of a space-aligned table never takes the next row's first cell: a continuation row must start inside the URL's own cell, so a gap of two or more spaces between its indent and where the URL starts on the row above means it is the next record. `terminalCellWrappedPaths()` follows each table cell independently: a fragment carries on into the next row only when it ends its cell on `/` or `-` (where a wrapper breaks a path; codex's cell wrap never breaks at `_`), the continuation row is blank to the left of the cell (a wrapper wraps every column of a table row together, so a label there such as `Entry` or `│ Log │` is the next record, unless a left column's path continued on that row too; a left column of wrapped prose, as in a `Description | Path` table, fills that row as well, so the path beside it keeps its per-row fragment links, the safe direction), the next row's token passes `pathContinuation()`, and the cell is no wider than the one above. So a directory that is a whole cell value (`/home/u/proj` over `config.yaml`, or `/home/u/proj/app/` over a labelled `main.py`) keeps its folder link and the next row keeps its own file link. With no label to tell them apart, a directory written with its trailing slash over a relative path in the same column (`/home/u/proj/src/` over `lib/util.ts`) still joins, a known trade-off: it reads exactly like a wrap at `/`. Accepted paths displace other links on their cells. Full-width divider rows stop `terminalLogicalLine()` stitching for both links and touch selection. OSC 8 links confirm the real target URI before the shared opener runs. **Filesystem path picker** (Link Existing "Browse" + the mobile keyboard's `📁 Path` key): lazy one-directory browsing via `GET /api/filesystem/browse`, with `GET /api/filesystem/preview` for the tapped file. Inserts the path **without** Enter, so the prompt is never submitted; the sibling `⌫ All` key clears only the unsent prompt and must never send the agent's `/clear`. ⚠️ This is a **second file-serving surface and inherits neither the attachment confinement nor its ownership scoping** — it allowlists Home, `CASES_DIR`, `/mnt/d` and `CODEMAN_FILE_PICKER_ROOTS`, blocks sensitive trees, and rejects symlink escapes **after** `realpath`. ⚠️ The optional `sessionId` is an ownership boundary that must be `canAccessOwned`-checked by hand (it does not go through `findSessionOrFail`), and in multi-user mode a non-admin gets only their own `userSpacePath` as a root: per-user spaces live INSIDE `homedir()`, so a `Home` root exposes every other user's workspace. Previews go through the same global conversion limiter, and Markdown/TXT/JSON are served as inert `text/plain`. → [architecture-invariants#filesystem-path-picker](docs/architecture-invariants.md#filesystem-path-picker) diff --git a/docs/architecture-invariants.md b/docs/architecture-invariants.md index 0060343c7..707ce1383 100644 --- a/docs/architecture-invariants.md +++ b/docs/architecture-invariants.md @@ -409,7 +409,7 @@ A file path an agent prints is a link on both surfaces it can appear on, and cli **Path forms beyond the rooted one.** `findFilePathLinks(text)` (constants.js) is the single entry point both consumers call; it returns the rooted matches first, then `~/` and relative paths (`RELATIVE_FILE_PATH_LINK_PATTERN`, same extension list, pinned equal by `test/link-provider-regex.test.ts`), then folder paths, each only where it overlaps neither an earlier match nor an `http(s)` URL. A relative match must start at a boundary (line start, whitespace, quote, bracket, `=`, `,`, `>`), so it can never begin inside a word, an absolute path or a URL; brackets, `=` and `,` end it (`(shot.png)`, `OUT=shots/a.png`). `resolveLinkedFilePath()` resolves a relative path against the session's `workingDir` (`.`/`..` collapsed, so `../x.png` lands outside and routes like any external path) and passes absolute and `~/` paths through. ⚠️ `~` is the home of the SESSION'S host, which only the server knows: `~/` paths always take the attachment route, where `registerExternalAttachment` expands them (`homedir()` locally, `$HOME` inside the remote probe script), and the guard runs on the expanded, realpath-resolved path as before. ⚠️ Programs hard-wrap their own output short of the terminal edge (Claude Code breaks tool output at 59-118 columns), so xterm's `isWrapped` never fires. `terminalPathLine()` (link provider only; touch selection keeps `terminalLogicalLine`) joins the next row when the previous row ends in a path token with a `/` and no extension and the join reaches an extension; a row that starts a new path is never joined: rooted or `~/`, `./`/`../`, or a relative one that opens on the same top directory the broken token did (the next entry of `git ls-files`/`find`/`ls -R`: `src/components` + `src/index.ts` are two paths, not `src/componentssrc/index.ts`), while a wrapped path continues INSIDE a name, so `…/captures/2` + `026-10-10/…` still joins; a 3+ row chain is kept only if a later row completes the path, and the broken row must be at least as long as the continuing one (keeps `cd /mnt/foo` + a longer next line apart). Tests: `test/file-path-link-forms.test.ts`. -The terminal provider scans `terminalLinkLine()`, which extends `terminalPathLine()` for URL continuations in indented paragraphs and table cells. List markers, bare numbers and version tokens never continue a URL. A token with only hyphens, underscores or dots must end its cell (row end or a gap of at least two spaces), so prose such as `self-hosted runners` is not appended. A row end counts as a cell end, so `⏺ Docs live at https://example.com/guide/` followed by ` self-hosted` still links `.../guide/self-hosted`, and an indented `and/or the API reference covers it.` still links `.../guide/and/or`; both are pinned as known trade-offs. Such a token is still refused when it is code with a call paren (`client.get()`, `expect(x).toBe(y)`; a wiki title like `Mercury_(planet)` keeps its paren) or a word ending a sentence (`ready.`, `Done.`, `self-hosted.`; a file name keeps its period, so `install.html.` still joins). Whatever its punctuation, a token never continues a URL when it ends with a colon (a label such as `Failure/Error:` or `prometheus.io/scrape:`), when it is a measurement or clock time (`45%`, `45%|####`, `12.3MB/45.0MB`, `12.3ms`, `10:00:00`; percent-encoding such as `2026%20Plan` still joins), or when it is a `name=value` field (`API_KEY=...`, `level=info`) under a URL ending in `/`, since a query needs a `?` first (after `?`, `&` or `_` it is a query parameter and joins). A continuation must be indented (a column-0 row is new output, which keeps `status=200`, `24/7` and `really?` out), and a shell prompt (`user@host:`), a home path (`~/`), a whole email address or an HTTP status line never continues a URL. A URL in the last column of a space-aligned table never takes the next row's first cell: a continuation row must start inside the URL's own cell, so a gap of two or more spaces between its indent and where the URL starts on the row above means it is the next record. A divider row never continues the row above it, so a URL that reaches the right edge does not absorb a full-width `────` row below. A real plain slug followed by prose falls back to the original fragment link; `%`, `&`, `=`, `/` or `?` still qualify as stronger URL punctuation. `terminalCellWrappedPaths()` follows each table cell independently: a fragment carries on into the next row only when it ends its cell on `/` or `-` (where a wrapper breaks a path; codex's cell wrap never breaks at `_`), the continuation row is blank to the left of the cell (a wrapper wraps every column of a table row together, so a label there such as `Entry` or `│ Log │` is the next record, unless a left column's path continued on that row too), the next row's token passes `pathContinuation()`, and the cell is no wider than the one above. So a directory that is a whole cell value (`/home/u/proj` over `config.yaml`, or `/home/u/proj/app/` over a labelled `main.py`) keeps its folder link and the next row keeps its own file link. With no label to tell them apart, a directory written with its trailing slash over a relative path in the same column (`/home/u/proj/src/` over `lib/util.ts`) still joins, a known trade-off: it reads exactly like a wrap at `/`. Accepted paths displace other links on their cells. Full-width divider rows stop `terminalLogicalLine()` stitching for both links and touch selection. OSC 8 links confirm the real target URI before the shared opener runs. +The terminal provider scans `terminalLinkLine()`, which extends `terminalPathLine()` for URL continuations in indented paragraphs and table cells. List markers, bare numbers and version tokens never continue a URL. A token with only hyphens, underscores or dots must end its cell (row end or a gap of at least two spaces), so prose such as `self-hosted runners` is not appended. A row end counts as a cell end, so `⏺ Docs live at https://example.com/guide/` followed by ` self-hosted` still links `.../guide/self-hosted`, and an indented `and/or the API reference covers it.` still links `.../guide/and/or`; both are pinned as known trade-offs. Such a token is still refused when it is code with a call paren (`client.get()`, `expect(x).toBe(y)`; a wiki title like `Mercury_(planet)` keeps its paren) or a word ending a sentence (`ready.`, `Done.`, `self-hosted.`; a file name keeps its period, so `install.html.` still joins). Whatever its punctuation, a token never continues a URL when it ends with a colon (a label such as `Failure/Error:` or `prometheus.io/scrape:`), when it is a measurement or clock time (`45%`, `45%|####`, `12.3MB/45.0MB`, `12.3ms`, `10:00:00`; percent-encoding such as `2026%20Plan` still joins), or when it is a `name=value` field (`API_KEY=...`, `level=info`) under a URL ending in `/`, since a query needs a `?` first (after `?`, `&` or `_` it is a query parameter and joins). A continuation must be indented (a column-0 row is new output, which keeps `status=200`, `24/7` and `really?` out), and a shell prompt (`user@host:`), a home path (`~/`), a whole email address or an HTTP status line never continues a URL. A URL in the last column of a space-aligned table never takes the next row's first cell: a continuation row must start inside the URL's own cell, so a gap of two or more spaces between its indent and where the URL starts on the row above means it is the next record. A divider row never continues the row above it, so a URL that reaches the right edge does not absorb a full-width `────` row below. A real plain slug followed by prose falls back to the original fragment link; `%`, `&`, `=`, `/` or `?` still qualify as stronger URL punctuation. `terminalCellWrappedPaths()` follows each table cell independently: a fragment carries on into the next row only when it ends its cell on `/` or `-` (where a wrapper breaks a path; codex's cell wrap never breaks at `_`), the continuation row is blank to the left of the cell (a wrapper wraps every column of a table row together, so a label there such as `Entry` or `│ Log │` is the next record, unless a left column's path continued on that row too; a left column of wrapped prose, as in a `Description | Path` table, fills that row as well, so the path beside it keeps its per-row fragment links, the safe direction), the next row's token passes `pathContinuation()`, and the cell is no wider than the one above. So a directory that is a whole cell value (`/home/u/proj` over `config.yaml`, or `/home/u/proj/app/` over a labelled `main.py`) keeps its folder link and the next row keeps its own file link. With no label to tell them apart, a directory written with its trailing slash over a relative path in the same column (`/home/u/proj/src/` over `lib/util.ts`) still joins, a known trade-off: it reads exactly like a wrap at `/`. Accepted paths displace other links on their cells. Full-width divider rows stop `terminalLogicalLine()` stitching for both links and touch selection. OSC 8 links confirm the real target URI before the shared opener runs. **Folder grid: a folder is granted, then served by bare file name.** A folder path (rooted or `~/` whose last segment has no extension, relative only with a trailing `/`; `FOLDER_PATH_LINK_PATTERN` + `folderLinkPath()`) opens `openFolderGrid()` (panels-ui.js), as does the preview's "Show folder" button. `POST /api/sessions/:id/folders {path}` (`src/folder-listing.ts`) resolves the folder (locally a bounded `probePathKind(…, { pastCap: true })` first, then async `fs.realpath`; remotely one ssh round trip via `remoteListFolder`, through `runWithRemoteSshLimit`), runs the attachment guard on it (blocklist, `confineToWorkspace`, and in multi-user mode `folderAllowedForUser`: a non-admin only inside their own `userSpacePath` or the session's `workingDir`, as the filesystem path picker), locally BEFORE reading it, lists non-hidden subfolders and files of a supported attachment type (each file also blocklist-checked, newest first, capped at `MAX_FOLDER_ENTRIES`), and GRANTS it an id. Files are then fetched as `GET …/folders/:folderId/files/:name/(raw|preview|thumbnail)`: `:name` must be a bare file name of a supported type (no `/`, `\`, NUL, no leading `.`, since the listing hides those), the local file is probed bounded (`unknown` → 503, never 404), and the joined path goes through the SAME `resolveServableAttachmentPath` as an attachment by id (symlink re-resolution, remote re-probe, guard), then the same gate registration applies to what a symlink RESOLVES to (a supported extension on the resolved path and a regular file: locally `fs.stat`, remotely the probe kind; else 400, so `shot.png -> ../secret.bin` is not served and a FIFO or device behind a supported name never reaches `open`), then `folderAllowedForUser` once more on the resolved file, before `serveServableRaw` (the attachment raw route's body, shared) streams it. So browser requests still never carry a path. ⚠️ Grants live in their own registry (`folderGrants`, 32 per session, cleared with the session's attachments), NOT the attachment registry: that one is capped at 200 per session, and paging a folder of 300 renders would evict the session's real attachments. ⚠️ A local folder is never touched unbounded (#516 rule): a folder on a network mount that stopped answering answers 503 with `describeUnknownPath()`, not 404 and not a frozen server. ⚠️ The remote listing script tells "missing" (`E|n`, 404) from "is a file" (`E|f`, 400 `Not a folder`), because the client falls back to the file preview on `Not a folder`; a remote extensionless file otherwise answered "Folder not found". ⚠️ `.folder-grid-overlay` sits at `z-index: 5090`, one step BELOW the preview (5100) and above the response viewer (5000): a tile's preview opens on top and closing it lands back on the grid. ⚠️ ←/→/Esc are handled in a document CAPTURE listener with `stopPropagation`, because the terminal keeps keyboard focus under the overlays and an arrow that reached xterm would be typed into the session; inputs other than xterm's helper textarea keep their keys. ⚠️ A route PARAMETER is capped by the router (Fastify's default is 100 characters, answered as 404 "Route not found"), and a file name goes up to 255, percent-encoded: the server sets `routerOptions.maxParamLength` to `MAX_ROUTE_PARAM_LENGTH` (route-helpers.ts), the route test harness uses the same constant, and a wiring guard pins it. ⚠️ `~/` in a Docker case is refused with 400 (attachment POST and `POST …/folders`): the session is local for these routes, but `~` inside the container is the container's own home, and expanding it on the host would open a different file than the one the agent printed. Tests: `test/routes/folder-grid-routes.test.ts` (incl. the listing script on a real `sh`), `test/folder-grid-ui.test.ts`. diff --git a/src/web/public/constants.js b/src/web/public/constants.js index e5668a255..8e2b4fcc9 100644 --- a/src/web/public/constants.js +++ b/src/web/public/constants.js @@ -2592,8 +2592,8 @@ function findTerminalUrls(text) { * @param {number} [maxRows] * @returns {{startRow: number, endRow: number, firstRowUrlEnd?: number} | null} * `firstRowUrlEnd` is where the URL's cell ends on the first row when - * other table columns follow it; null means the row is not part of a - * URL continued this way. + * other table columns follow it and that row does not soft-wrap; null + * means the row is not part of a URL continued this way. */ function terminalUrlContinuationExtent(buffer, row, cols, maxRows) { const bound = Math.max(1, maxRows || 12); @@ -2611,8 +2611,15 @@ function terminalUrlContinuationExtent(buffer, row, cols, maxRows) { while (endRow - start + 1 < bound) { const urls = findTerminalUrls(text); const last = urls[urls.length - 1]; + // Only on a one-row first line: firstRowUrlEnd cuts the first PHYSICAL + // row (terminalLinkLine), so a column sitting on the soft-wrapped part of + // the URL's row would stay in the text and take the next row with it. const trailingColumns = - endRow === first.endRow && last && last.rawEnd < text.length && /^ {2,}\S/.test(text.slice(last.rawEnd)); + first.startRow === first.endRow && + endRow === first.endRow && + last && + last.rawEnd < text.length && + /^ {2,}\S/.test(text.slice(last.rawEnd)); const urlText = trailingColumns ? text.slice(0, last.rawEnd) : text; if (!last || last.rawEnd !== urlText.length || !/[/?&=\-_%#+~]$/.test(urlText)) break; if (urlStartCol < 0) urlStartCol = first.offsetToCell(last.startIndex).col; @@ -2838,10 +2845,13 @@ function terminalCellWrappedPaths(buffer, row, maxRows) { // class AL). if (!/[/-]$/.test(joined)) break; // A wrapper wraps every column of one table row together, so a row that - // carries a cell's continuation is blank to its left unless a left column - // wrapped on that row too. A label there (`Entry`, `│ Log │`) opens the - // next record, even under a directory ending in `/` (`Workspace - // /home/u/proj/app/` over `Entry main.py`). + // carries a cell's continuation is blank to its left unless a path in a + // left column continued on that row too. A label there (`Entry`, + // `│ Log │`) opens the next record, even under a directory ending in `/` + // (`Workspace /home/u/proj/app/` over `Entry main.py`). A left column of + // wrapped prose (a `Description | Path` table) fills that row as well, so + // the path beside it is not joined and keeps its per-row fragment links: + // the safe direction. // Known trade-off: with nothing to the left, a directory written with its // trailing slash over a relative path in the same column (`/home/u/proj/` // over `index.ts`) still joins, since it reads exactly like a wrap at `/`. diff --git a/test/terminal-url-continuation.test.ts b/test/terminal-url-continuation.test.ts index 9f8cd8477..e91c58609 100644 --- a/test/terminal-url-continuation.test.ts +++ b/test/terminal-url-continuation.test.ts @@ -241,6 +241,15 @@ const STILL_CONTINUES_ROWS: [string, string, string][] = [ [' Docs: https://example.com/guide?', ' x=1&y=2 more text', 'https://example.com/guide?x=1&y=2'], [' https://example.com/files/', ' 2026%20Plan.xlsx', 'https://example.com/files/2026%20Plan.xlsx'], ]; +// A URL row that soft-wraps at 41 columns with a table column on its wrapped +// part, over an indented row: the column and that row are separate links, never +// one glued `/tmp/report.txtccc/x.html`. +const SOFT_WRAP_COLS = 41; +const SOFT_WRAPPED_URL_ROWS: Row[] = [ + { text: ' Doc https://example.com/aaaaaaaaaaaaaa' }, + { text: 'bbb/ /tmp/report.txt', isWrapped: true }, + { text: ' ccc/x.html' }, +]; describe('findTerminalUrls (grammar)', () => { const { findTerminalUrls } = loadPure(); @@ -439,6 +448,13 @@ describe('terminalUrlContinuationExtent (wrap joining)', () => { expect(extent).not.toBeNull(); expect(extent!.endRow - extent!.startRow + 1).toBeLessThanOrEqual(5); }); + + it('does not continue a URL whose row soft-wraps with a column on the wrapped part', () => { + const buffer = fakeBuffer(SOFT_WRAPPED_URL_ROWS, SOFT_WRAP_COLS); + for (const row of [0, 1, 2]) { + expect(terminalUrlContinuationExtent(buffer, row, SOFT_WRAP_COLS, 12)).toBeNull(); + } + }); }); describe('native OSC 8 terminal URLs', () => { @@ -665,6 +681,14 @@ describe('terminal link provider with wrapped table URLs', () => { expect(segments[2].range.end.x).toBe(TABLE[3].text.length - 1); }); + it('links the column on a soft-wrapped URL row and the row below it separately', () => { + const { provide } = harness(SOFT_WRAPPED_URL_ROWS, SOFT_WRAP_COLS); + const texts = [1, 2, 3].flatMap((line) => provide(line).map((link) => link.text)); + expect(texts.filter((text) => text.includes('report.txtccc'))).toEqual([]); + expect(provide(2).map((link) => link.text)).toContain('/tmp/report.txt'); + expect(provide(3).map((link) => link.text)).toContain('ccc/x.html'); + }); + it('keeps a single-row URL with a paren pair whole', () => { const line = 'Open https://example.com/wiki/Widget_(tool) please'; const { provide } = harness([{ text: line }], 80); From 8574e3c8806bf202bdc8fc6f6a82457f242404fe Mon Sep 17 00:00:00 2001 From: Codeman maintainer <noreply@anthropic.com> Date: Tue, 27 Oct 2026 01:24:15 +0100 Subject: [PATCH 51/68] fix: apply the review items for #719 at merge - src/web/settings-writer.ts:110: export assertSettingsWritable(), the writer's own read-for-write rule run on its own - src/web/routes/case-routes.ts:1860: an admin's rename checks settings.json before it ends any session or moves the folder, and answers 409 CONFLICT with the writer's message (placed before the session-starting check, so nothing is awaited between it and the lock) - test/routes/case-rename-routes.test.ts:314: the torn-file pin now expects 409 CONFLICT (no stoppedSessions, like the other pre-checks) - test/routes/case-rename-routes.test.ts:331: new test, a live session with stopSessions and a torn file: 409 CONFLICT, cleanupSession not called - docs/api-reference.md: the Settings paragraph says an admin's rename answers this refusal as 409 CONFLICT before ending sessions - src/hooks-config.ts:1161, src/prompt-cache-keepwarm.ts:341: comments describe the atomic writer, not an in-place rewrite Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- docs/api-reference.md | 2 +- src/hooks-config.ts | 5 +++-- src/prompt-cache-keepwarm.ts | 8 +++++--- src/web/routes/case-routes.ts | 18 +++++++++++++++++- src/web/settings-writer.ts | 11 +++++++++++ test/routes/case-rename-routes.test.ts | 23 ++++++++++++++++++++--- 6 files changed, 57 insertions(+), 10 deletions(-) diff --git a/docs/api-reference.md b/docs/api-reference.md index 16836d2e4..396812059 100644 --- a/docs/api-reference.md +++ b/docs/api-reference.md @@ -1213,7 +1213,7 @@ A local case whose folder is the top of a git repository can keep **worktrees** ## Settings -`~/.codeman/settings.json` is one file for the whole server: the synced App Settings (feature switches, the plan-usage collection switch `showPlanUsageLimits`, `runMode`, `respawnPresets`, `tunnelEnabled`, ...), plus `modelConfig`, `caseOrder` and `lastUsedCase`. Reads are open to every signed-in user; in multi-user mode a non-admin reads it without what is the admin's (`voiceSettings.apiKey`, the Deepgram key an admin saved, and the admin's case names in `caseOrder` and `lastUsedCase`). **In multi-user mode every write is admin only:** for a non-admin the routes below answer `403 FORBIDDEN` before the body is even validated, so nothing is written, no watcher is toggled and no tunnel is started. Single-user mode is unaffected. Every write to the file (these routes, and the `lastUsedCase` that `POST /api/quick-start` and `POST /api/ralph-loop/start` record before they answer) runs one at a time and replaces the file atomically (mode `0600`); the one write outside that queue is a config-backup restore, which must work on a file that does not parse. A write to a `settings.json` that exists but is not a JSON object is refused rather than starting from `{}`, which would erase every other setting: the routes below answer `409 CONFLICT` with a message naming the fix and leave the file untouched, the two starts skip their `lastUsedCase`, and `POST /api/cases/:name/rename` fails and is undone like any other registry write failure. A missing or empty file still starts from `{}`. +`~/.codeman/settings.json` is one file for the whole server: the synced App Settings (feature switches, the plan-usage collection switch `showPlanUsageLimits`, `runMode`, `respawnPresets`, `tunnelEnabled`, ...), plus `modelConfig`, `caseOrder` and `lastUsedCase`. Reads are open to every signed-in user; in multi-user mode a non-admin reads it without what is the admin's (`voiceSettings.apiKey`, the Deepgram key an admin saved, and the admin's case names in `caseOrder` and `lastUsedCase`). **In multi-user mode every write is admin only:** for a non-admin the routes below answer `403 FORBIDDEN` before the body is even validated, so nothing is written, no watcher is toggled and no tunnel is started. Single-user mode is unaffected. Every write to the file (these routes, and the `lastUsedCase` that `POST /api/quick-start` and `POST /api/ralph-loop/start` record before they answer) runs one at a time and replaces the file atomically (mode `0600`); the one write outside that queue is a config-backup restore, which must work on a file that does not parse. A write to a `settings.json` that exists but is not a JSON object is refused rather than starting from `{}`, which would erase every other setting: the routes below answer `409 CONFLICT` with a message naming the fix and leave the file untouched, the two starts skip their `lastUsedCase`, and an admin's `POST /api/cases/:name/rename` answers the same `409 CONFLICT`, checked before it ends any session or moves the folder. A missing or empty file still starts from `{}`. | Method | Path | Body | Notes | | ------ | ----------------------------- | ------------------------------------ | ----- | diff --git a/src/hooks-config.ts b/src/hooks-config.ts index f1bd7ebc9..1cf2a9a26 100644 --- a/src/hooks-config.ts +++ b/src/hooks-config.ts @@ -1158,8 +1158,9 @@ export async function readKeepWarmSettings(): Promise<KeepWarmSettings> { /** * The same two keys for the keep-warm service, which keeps what it reads until the next * settings change. A missing file is the defaults, but any other read or parse failure rejects - * rather than falling back: settings.json is rewritten in place, so a read can land mid-write, - * and a fallback kept from it would hold keep-warm off until the next keep-warm settings change. + * rather than falling back: settings.json is replaced atomically, so such a failure is a real + * read error or a file that does not parse (a hand edit), never a write in progress, and a + * fallback kept from it would hold keep-warm off until the next keep-warm settings change. */ export async function readKeepWarmSettingsOrThrow(): Promise<KeepWarmSettings> { let text: string; diff --git a/src/prompt-cache-keepwarm.ts b/src/prompt-cache-keepwarm.ts index 2dd5e754e..40080c343 100644 --- a/src/prompt-cache-keepwarm.ts +++ b/src/prompt-cache-keepwarm.ts @@ -338,9 +338,11 @@ export class PromptCacheKeepWarmService implements KeepWarmScheduler { try { settings = await this.readSettings(); } catch (err) { - // A read that lands mid-write is retried. A settings.json that stays broken (a hand edit - // with a trailing comma) fails every read until it is fixed, so it is logged once rather - // than once a minute per session, and only a session that could nudge retries. + // A failed read is retried (settings.json is replaced atomically, so a read never sees a + // half-written file; a failure is a read error or a file that does not parse). A settings.json + // that stays broken (a hand edit with a trailing comma) fails every read until it is fixed, so + // it is logged once rather than once a minute per session, and only a session that could + // nudge retries. const message = err instanceof Error ? err.message : String(err); if (message !== this.settingsFailure) { this.settingsFailure = message; diff --git a/src/web/routes/case-routes.ts b/src/web/routes/case-routes.ts index 4231185e1..b0849767e 100644 --- a/src/web/routes/case-routes.ts +++ b/src/web/routes/case-routes.ts @@ -78,7 +78,12 @@ import { parseBody, readJsonConfig, } from '../route-helpers.js'; -import { mutateSettingsFile, settingsWriteErrorResponse } from '../settings-writer.js'; +import { + assertSettingsWritable, + mutateSettingsFile, + SettingsWriteRefusedError, + settingsWriteErrorResponse, +} from '../settings-writer.js'; import { isMultiUserMode } from '../../config/multiuser.js'; import type { AuthUser } from '../../types.js'; import { SseEvent } from '../sse-events.js'; @@ -1850,6 +1855,17 @@ export function registerCaseRoutes( if (open.some((s) => !canAccessOwned(user, s.owner))) { return fail(403, ApiErrorCode.FORBIDDEN, "Another user's session has this case open"); } + // The admin's rename writes settings.json only after the sessions are ended and the folder moved, and + // the writer refuses a file that is not a JSON object: check it by the writer's own rule now, so no + // session is ended for a rename that cannot complete. Not between the starting check and the lock below. + if (isAdmin(req)) { + try { + await assertSettingsWritable(); + } catch (err) { + if (err instanceof SettingsWriteRefusedError) return fail(409, ApiErrorCode.CONFLICT, err.message); + throw err; + } + } // A session being created in the folder is not in ctx.sessions yet, so it could be neither listed nor // stopped: it would start in the old path after the move. Checked in the same tick that takes the lock // below (nothing is awaited in between), and the Session constructor refuses the folder from then on. diff --git a/src/web/settings-writer.ts b/src/web/settings-writer.ts index 246892911..d5bbaca49 100644 --- a/src/web/settings-writer.ts +++ b/src/web/settings-writer.ts @@ -100,6 +100,17 @@ export function mutateSettingsFile( return run; } +/** + * Rejects with `SettingsWriteRefusedError` when `mutateSettingsFile()` would refuse a write right + * now: the writer's own read-for-write rule, run on its own. For a route that does something it + * cannot take back (case rename ends sessions) before its settings write. A check only, outside + * the chain (writes are atomic, so it never reads a half-written file); the file can still change + * before the write. + */ +export async function assertSettingsWritable(): Promise<void> { + await readSettingsForWrite(SETTINGS_PATH); +} + /** A refused write answers 409 CONFLICT with the fix; any other failure stays OPERATION_FAILED. */ export function settingsWriteErrorResponse(err: unknown): ApiResponse<never> { if (err instanceof SettingsWriteRefusedError) return createErrorResponse(ApiErrorCode.CONFLICT, err.message); diff --git a/test/routes/case-rename-routes.test.ts b/test/routes/case-rename-routes.test.ts index 8ef016da7..2a28d08a8 100644 --- a/test/routes/case-rename-routes.test.ts +++ b/test/routes/case-rename-routes.test.ts @@ -317,18 +317,35 @@ describe('POST /api/cases/:name/rename (a linked case)', () => { const torn = '{\n "agentSkillEnabled": true\n}tail"\n}'; writeFileSync(SETTINGS_PATH, torn); const res = await rename(app, 'alpha', { newName: 'beta' }); - expect(res.statusCode).toBe(500); + expect(res.statusCode).toBe(409); expect(res.json()).toEqual({ success: false, - errorCode: 'OPERATION_FAILED', + errorCode: 'CONFLICT', error: `${SETTINGS_PATH} is not a valid JSON object, so this change was not saved (saving would have replaced every other setting in it). Fix or delete the file, or restore it from a config backup, then try again.`, - stoppedSessions: 0, }); expect(existsSync(oldDir)).toBe(true); expect(existsSync(join(CASES_DIR(), 'beta'))).toBe(false); expect(readFileSync(SETTINGS_PATH, 'utf8')).toBe(torn); }); + it('checks settings.json before it stops any session, so none is ended for a rename that cannot complete', async () => { + const { app, ctx } = await createRouteTestHarness(registerCaseRoutes); + const oldDir = localCase('alpha'); + const torn = '{\n "agentSkillEnabled": true\n}tail"\n}'; + writeFileSync(SETTINGS_PATH, torn); + (ctx.sessions as Map<string, unknown>).set('s1', { id: 's1', name: 'w1', workingDir: oldDir }); + + const res = await rename(app, 'alpha', { newName: 'beta', stopSessions: true }); + + expect(res.statusCode).toBe(409); + expect(res.json().errorCode).toBe('CONFLICT'); + expect(res.json().error).toContain('is not a valid JSON object'); + expect(ctx.cleanupSession).not.toHaveBeenCalled(); + expect(existsSync(oldDir)).toBe(true); + expect(existsSync(join(CASES_DIR(), 'beta'))).toBe(false); + expect(readFileSync(SETTINGS_PATH, 'utf8')).toBe(torn); + }); + it('says where the folder is when it cannot be moved back', async () => { const { app } = await createRouteTestHarness(registerCaseRoutes); const real = join(work, 'my-project'); From ffa7f57c8a0a0a006459aab04740b08a48eaffc5 Mon Sep 17 00:00:00 2001 From: Codeman maintainer <noreply@anthropic.com> Date: Tue, 27 Oct 2026 01:26:33 +0100 Subject: [PATCH 52/68] fix: apply the review items for #717 at merge - test/command-palette-agent-arrows.browser.test.ts: drop the page.screenshot() that wrote /tmp/codeman-21-palette.png on every browser-suite run (nothing read it) Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- test/command-palette-agent-arrows.browser.test.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/test/command-palette-agent-arrows.browser.test.ts b/test/command-palette-agent-arrows.browser.test.ts index 774b83e0a..182f4e234 100644 --- a/test/command-palette-agent-arrows.browser.test.ts +++ b/test/command-palette-agent-arrows.browser.test.ts @@ -67,7 +67,6 @@ describe('Command-K agent arrow keys in Chromium', () => { await page.keyboard.press('Shift+End'); await page.keyboard.press('ArrowLeft'); expect(await label.innerText()).toBe('Codex'); - await page.screenshot({ path: '/tmp/codeman-21-palette.png' }); await page.keyboard.press('ArrowDown'); expect( await page.evaluate( From 04aa44b69c32e6883d358c64cadd141f69772501 Mon Sep 17 00:00:00 2001 From: Codeman maintainer <noreply@anthropic.com> Date: Tue, 27 Oct 2026 01:30:07 +0100 Subject: [PATCH 53/68] fix: apply the review items for #712 at merge MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - session-ui.js _reloadCasesAfterRun: reload onto the toolbar's own case after every launch, not only one that names its case; comment updated - test/tab-new-session.test.ts: a worktree tab's "+" Claude launch, the toolbar Run and a group's "New session" each keep the toolbar's case after a "+" moved lastUsedCase (fail without the fix); the reload setup is shared with the existing lastUsedCase test - docs/architecture-invariants.md:349: the _reloadCasesAfterRun sentence covers every launch - app.js _openTabLayoutMenu focusout: focus landing on the trigger is not a departure, so pressing the "+" again closes its picker in a real browser - tab-rail-resize.js openTabRailActionMenu: close the group menu too, so the rail's ... pressed again does not leave the picker beside the row menu - tab-rail-resize.js:329-331: the "New session here…" comment no longer says Enter on the "+" does nothing Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- docs/architecture-invariants.md | 2 +- src/web/public/app.js | 6 ++- src/web/public/session-ui.js | 16 +++---- src/web/public/tab-rail-resize.js | 5 ++- test/tab-new-session.test.ts | 70 ++++++++++++++++++++++++------- 5 files changed, 73 insertions(+), 26 deletions(-) diff --git a/docs/architecture-invariants.md b/docs/architecture-invariants.md index d562e9b7f..3640d617c 100644 --- a/docs/architecture-invariants.md +++ b/docs/architecture-invariants.md @@ -346,7 +346,7 @@ So: `_confirmIdle()` (session.ts) requires the pane to go quiet, and then asks t - **The upstream HTML5 drag stays off in the grouped rail**: a flat-order drop cannot express a group move, and the server re-ranks within the old group. The grouped rail has its OWN pointer drag instead (`_bindTabLayoutPointerDrag`, mouse/pen only, bound once on the container): rows move before/after a row or into a group, a header drag reorders groups, and the drop maps to ONE operation through the pure `dropOperation()`. Escape cancels a drag, and the click that ends one is swallowed. ⚠️ A press is only captured once it moves 6 px, so its release can land outside the rail: `pointerup`/`pointercancel` are heard on `window` while a press is pending, a move with the primary button up cancels it, and a new press cancels any previous one. Without that a stale press became a phantom drag on the next hover, and a replaced drag left its capture-phase Escape listener behind, swallowing every Escape before the terminal saw it. The flat rail and the header strip keep the HTML5 drag untouched. For the same reason Ctrl+Shift+{ / } only swaps with a neighbour in the active session's own section (`_canSwapActiveTabWith`, reading the projection's `sectionByRef`): a cross-group swap moves nothing on the server, gets no `session:orderChanged` back, and would leave this client's `sessionOrder` and Alt+N targets out of step with every other device. - **Edits are named operations, saved serially.** `createEditCoordinator` applies `createGroup` / `renameGroup` / `deleteGroup` / `reorderGroup` / `moveRef` to the rail at once, then sends ONE `PUT /api/tab-layout {baseVersion, layout}` at a time; edits made meanwhile wait and go out on the version that write returns. A 409 carries the server's layout: the in-flight operations are replayed onto it (an operation that no longer applies is dropped and reported) and re-sent, at most `maxAttempts` times; a 400 re-reads once (a second 400 is reported as a failed save, not as a race); anything else reports and re-reads. A `moveRef` moves the session together with the sessions that still follow it and marks a hand-moved child `placement: 'manual'`, mirroring the server's `moveRef`, and `normalizeLayout` keeps `placement` because whole layouts are written back. `_onTabLayoutChanged` / `_applyTabLayout` defer a read while a write is in flight and rebase unsaved edits onto a read otherwise. ⚠️ A FAILED read (`_applyTabLayout(null)`) while edits are pending keeps the held layout and the editor and re-reads after the write settles: disposing there would orphan the in-flight write, whose 409 then never gets its rebase. Any path that does drop unsaved work says so in a toast. On `pagehide`, unconfirmed operations go out in a `keepalive` PUT AND into sessionStorage as `{ owner, baseVersion, savedAt, operations }`; after reload they replay onto the fresh layout (a no-op when the keepalive landed), but only for the same owner, within `TAB_LAYOUT_PENDING_MAX_AGE_MS` (60 s), and never onto a layout older than the copy's base. A "Move to <group>" with no anchor carries no `index`, so a replay still puts the row last. - **Every way in has a keyboard path.** The session row menu (Shift+F10 in the tree, the rail's overflow button) gains Move up/down, Move to <group>, Move to Ungrouped and Move to new group in the vertical rail (only "new group" before the first group exists; nothing on the strip). With **New Session Button on Tabs** on (`showTabNewButton`, per device, `html.tabs-show-new`), every session row menu (the rail, compact or full, the expanded sidebar, the tree; never a tile's, which shares the function) also offers "New session here…", which opens the row's "+" picker, and the compact rail hides it. The "+" itself opens its picker on Enter or Space through its own inline `onkeydown` (the strip's key handler acts only on a focused tab), which is the keyboard path on the header strip, where no row menu exists. The menu cancels a REPEATED Enter or Space, so a held key that opened it cannot also pick the entry it focuses. A group header opens its menu with Shift+F10 / ContextMenu, right-click or its hover glyph (a non-focusable, `aria-hidden` span: a treeitem holds no interactive children), and F2 renames it inline (Enter commits and refocuses the header; a commit by BLUR leaves focus where it went, since refocusing from inside the blur handler overrides the user's click). The glyph stays visible under `@media (hover: none)`: a touch tablet has no hover and no long-press `contextmenu`. Group names in "Move to" labels are quoted, so a group named "New group" or "ungrouped" cannot read (or translate, case-insensitively) like the fixed entries. The menu closes on Escape (which it consumes before the global Escape handler), a pointer outside, Tab, focus moving to an element outside it, a resize, a second open and any full re-render. ⚠️ A `focusout` with no `relatedTarget` is not a departure: Safari never focuses a clicked button, so pressing an item blurs the focused one with focus going nowhere, and closing on that dropped the item's click (the tile count menu's rule). The tab "+" picker is this same menu (`_openTabLayoutMenu` with `anyLayout`, so it opens on every layout, not only the editable grouped rail). It borrows the `.tab-rail-action-menu` class for its look only: `closeTabRailActionMenu()` excludes `.tab-layout-group-action-menu`, so closing the row menu (every `session:deleted` does) cannot strand the group menu's listeners. The inline group editor shares `_activeRename` with the session rename, so only the CURRENT editor may release `_inlineRenameActive`. -- **New, child and web tabs land in groups.** A group menu's "New session" (`createSessionInTabGroup`) runs ONE launch with `run({ count: 1, tabGroupId })`; a tab's "+" (`openTabNewSessionMenu`) runs ONE launch in that tab's case or managed worktree through `_launchOnce` (the Git worktrees modal's launcher too), `run({ count: 1, mode, caseName | worktree, tabGroupId? })`, carrying the tab's named group if it has one. ⚠️ The mode rides the call and is never swapped into the Run button's `_runMode`, so a mode picked while the launch is pending is kept, and the case-list reload after a launch that names its case keeps the toolbar's own case (`_reloadCasesAfterRun`; the no-argument reload would re-select the `lastUsedCase` quick-start has just written). ⚠️ The tab's case is found by what the session runs on (`_tabCaseOf`), never by path alone: `GET /api/cases` gives remote and Docker cases a DISPLAY path with the raw values under `remote`/`docker`, so a Docker session belongs to its container's case on its own engine host, a remote one to the case on its own host whose path encloses its own, and a local one to the deepest case enclosing its folder (a display path never encloses one). A Docker tab's picker starts that container's probe (`_probeDockerCaseModes`) when nothing is cached for its case, since the toolbar probes only its own selected case: that open lists what is already known, the next one the container's answer. From an ungrouped tab the "+" sends no `tabGroupId` on purpose (the create schema cannot say "Ungrouped"), so the case default group below applies, as for a toolbar Run. Every launch path puts `tabGroupId` on its create request (`POST /api/sessions` or `/api/quick-start`), and `TabLayoutService.sessionCreated(owner, id, { tabGroupId }, register)` places the session at the end of that group in the SAME versioned write that first records it. The session enters the live map inside the owner lock (`register`, rolled back if the write fails), so no queued layout edit can commit it first. The group is looked up in the creating owner's own layout, so an unknown, deleted or foreign id is ignored rather than failing the spawn, and an explicit group is a hand placement (a child placed this way is `manual`). The create response carries the committed `tabLayout`, adopted before the tab is drawn. A session spawned by another one (`parentSessionId`) and placed by nobody lands after its parent: the server's normalization puts it after the parent's rows in the parent's container, and `project({ parents })` draws a not-yet-stored child there at once. Whether it then FOLLOWS its parent is the synced `spawnedTabsFollowParent` setting (default OFF), which `sessionCreated()` reads fresh through the `childrenFollowParent` dep: off, the child is marked `manual` in that same creating write; on, it follows until moved by hand. Deciding at creation is enough, so flipping the setting leaves existing tabs alone. A hand move makes a following child `manual`; with the setting on, the row menu notes where a child sits (following, placed by hand, parent closed, parent loop; plain text, never a focusable menu item) and offers "Follow parent again" only to a `manual` child, which clears `placement` and is re-derived by the server on save. With the setting off the row menu says nothing about placement. A legacy `PUT /api/session-order` (strip or phone drag, a same-section Ctrl+Shift+{/}, a device without a layout, an admin's cross-owner order) marks a child `manual` ONLY when the requested order splits it from its parent's block (`applyLegacySessionRank`, `childrenSplitFromParent`); a PUT of the current order, or one that moves a family together, leaves every stored placement alone. The browser never echoes a create or delete through that PUT, with or without groups: the service already re-projects the order and broadcasts it (`session:orderChanged`), and `syncSessionOrder` adopts it. A child whose parent is gone stands on its own and is never re-adopted. Saved web tabs are layout refs like sessions, but OPENNESS stays per device: a grouped web tab renders (and takes an Alt+N slot) only while it is open on this device, and reopens where the layout keeps it. +- **New, child and web tabs land in groups.** A group menu's "New session" (`createSessionInTabGroup`) runs ONE launch with `run({ count: 1, tabGroupId })`; a tab's "+" (`openTabNewSessionMenu`) runs ONE launch in that tab's case or managed worktree through `_launchOnce` (the Git worktrees modal's launcher too), `run({ count: 1, mode, caseName | worktree, tabGroupId? })`, carrying the tab's named group if it has one. ⚠️ The mode rides the call and is never swapped into the Run button's `_runMode`, so a mode picked while the launch is pending is kept, and the case-list reload after every launch keeps the toolbar's own case (`_reloadCasesAfterRun`): the no-argument reload would re-select the server's `lastUsedCase`, which a "+" launching through quick-start moves to the tab's case while the toolbar stays put, so a later worktree launch, plain Run or group "New session" would carry the toolbar there. ⚠️ The tab's case is found by what the session runs on (`_tabCaseOf`), never by path alone: `GET /api/cases` gives remote and Docker cases a DISPLAY path with the raw values under `remote`/`docker`, so a Docker session belongs to its container's case on its own engine host, a remote one to the case on its own host whose path encloses its own, and a local one to the deepest case enclosing its folder (a display path never encloses one). A Docker tab's picker starts that container's probe (`_probeDockerCaseModes`) when nothing is cached for its case, since the toolbar probes only its own selected case: that open lists what is already known, the next one the container's answer. From an ungrouped tab the "+" sends no `tabGroupId` on purpose (the create schema cannot say "Ungrouped"), so the case default group below applies, as for a toolbar Run. Every launch path puts `tabGroupId` on its create request (`POST /api/sessions` or `/api/quick-start`), and `TabLayoutService.sessionCreated(owner, id, { tabGroupId }, register)` places the session at the end of that group in the SAME versioned write that first records it. The session enters the live map inside the owner lock (`register`, rolled back if the write fails), so no queued layout edit can commit it first. The group is looked up in the creating owner's own layout, so an unknown, deleted or foreign id is ignored rather than failing the spawn, and an explicit group is a hand placement (a child placed this way is `manual`). The create response carries the committed `tabLayout`, adopted before the tab is drawn. A session spawned by another one (`parentSessionId`) and placed by nobody lands after its parent: the server's normalization puts it after the parent's rows in the parent's container, and `project({ parents })` draws a not-yet-stored child there at once. Whether it then FOLLOWS its parent is the synced `spawnedTabsFollowParent` setting (default OFF), which `sessionCreated()` reads fresh through the `childrenFollowParent` dep: off, the child is marked `manual` in that same creating write; on, it follows until moved by hand. Deciding at creation is enough, so flipping the setting leaves existing tabs alone. A hand move makes a following child `manual`; with the setting on, the row menu notes where a child sits (following, placed by hand, parent closed, parent loop; plain text, never a focusable menu item) and offers "Follow parent again" only to a `manual` child, which clears `placement` and is re-derived by the server on save. With the setting off the row menu says nothing about placement. A legacy `PUT /api/session-order` (strip or phone drag, a same-section Ctrl+Shift+{/}, a device without a layout, an admin's cross-owner order) marks a child `manual` ONLY when the requested order splits it from its parent's block (`applyLegacySessionRank`, `childrenSplitFromParent`); a PUT of the current order, or one that moves a family together, leaves every stored placement alone. The browser never echoes a create or delete through that PUT, with or without groups: the service already re-projects the order and broadcasts it (`session:orderChanged`), and `syncSessionOrder` adopts it. A child whose parent is gone stands on its own and is never re-adopted. Saved web tabs are layout refs like sessions, but OPENNESS stays per device: a grouped web tab renders (and takes an Alt+N slot) only while it is open on this device, and reopens where the layout keeps it. - **A case's default session group** (`caseDefaultGroups` in state.json, keyed by the layout owner key then case name; a group id or `null` for Ungrouped) is the fallback placement for a new session of that case. It is read and written ONLY through `TabLayoutService` (`caseDefaultGroup`, `setCaseDefaultGroup`, `forgetCaseDefaultGroup`, and `sessionCreated`'s `caseName` hint), under the owner lock, never by a route or the store directly. Precedence inside `sessionCreated`'s single write: an explicit `tabGroupId` found in the owner's layout, then a parent that places the child (`hasPlacingParent`, the same conditions as `pinCreatedChild`), then the case default, else normal placement. The id is looked up in the creating owner's own layout, so a deleted or foreign id never fails a spawn; a stale id is forgotten only AFTER the creating write lands (a failed write rolls back and changes nothing). `POST /api/quick-start` and `POST /api/ralph-loop/start` pass their `caseName`; `POST /api/sessions` infers it LEXICALLY from `workingDir` (`inferLocalCaseName`: a direct child of the caller's case space, or an exact linked-case path for an admin or single-user, or a Claude worktree of either, `<case>/.claude/worktrees/<name>`, which is how the Git worktrees modal launches Claude while every other CLI goes through quick-start), only for a named `workingDir` (never the server's own cwd), never for a remote attach and never touching the filesystem. Cron jobs, the one-shot run route and reboot restore keep normal placement. The Case settings selector (`onCaseDefaultGroupChanged`, session-ui.js) reads it on popover open and is redrawn from an adopted layout whose groups changed (`_refreshCaseDefaultGroupOptions` in `_applyTabLayout`; an unchanged group list is skipped, since every create, delete and reorder broadcasts a layout and a rebuild would reset an open dropdown). - **Only the grouped rail is a tree.** `#sessionTabs` ships as `role=tablist` with `role=tab` rows, and the header strip, sidebar and flat rail keep exactly that. While grouped, `_applyTabListRole` makes it `role=tree` (and restores `tablist` + its label when grouping ends), named-group headers are level-1 `treeitem`s that `aria-owns` their rows' `role=group` (rows sit beside the header, not inside it), and ungrouped rows plus a collapsed group's kept selection are level-1 items. A collapsed header owns nothing, a group with no open rows is a leaf (no `aria-expanded`, no owned group), and the "Ungrouped" heading is `aria-hidden`. Rows are re-roled in the DOM by `_applyTabTreeSemantics` after render, never by rewriting their markup, so a grouped row's content stays the flat row's. - **One tab stop in the tree.** Exactly one treeitem carries `tabindex=0` (the focused or selected item); every control inside a row drops to `-1`, which is why Shift+F10 / ContextMenu open a row's actions from the keyboard. Focus survives a full re-render by identity (`group:`/`session:`/`webview:`; a row a collapse just hid hands focus to its header), but only when focus was already inside the rail. The tree walk (`_tabTreeItems`) follows painted order WITHIN each group when the rail is sorted; the flat list keeps its own whole-list computed-order walk. `aria-posinset`/`aria-setsize` follow painted order too, so the incremental render path re-runs `_applyTabTreePositions` after it re-sorts rows in place. ⚠️ `_handleTabTreeKeydown` acts only when the key lands on the treeitem ITSELF: a key on a focused in-row control (close, overflow, the rename input) is that control's, or Enter on the overflow button re-selects the row instead of reopening its menu. ⚠️ The roving `tabindex=-1` also hides every item but the stop from the keyboard-dismiss selector's `[tabindex]` arm, which is why `MOBILE_KEYBOARD_DISMISS_EXEMPT_SELECTOR` lists `[role="treeitem"]` (see Dismissing the on-screen keyboard). diff --git a/src/web/public/app.js b/src/web/public/app.js index 5ce7b0f2c..928261249 100644 --- a/src/web/public/app.js +++ b/src/web/public/app.js @@ -8615,10 +8615,12 @@ class CodemanApp { // menu's own items are not a departure, and neither is focus going nowhere: // Safari never focuses a clicked button, so a press on an item blurs the // focused one with no relatedTarget, and closing then dropped its click - // (the tile count menu's rule, tile-grid.js). + // (the tile count menu's rule, tile-grid.js). Nor is focus landing on the + // trigger: a press on a focusable one (a tab's "+") focuses it before its + // click, and closing there let that click open the menu again. menu.addEventListener('focusout', (focusEvent) => { const to = focusEvent.relatedTarget; - if (!to || menu.contains(to)) return; + if (!to || menu.contains(to) || trigger?.contains?.(to)) return; if (this._tabGroupMenu === menu) this.closeTabGroupMenu(); }); (menu.querySelector('button.selected') || menu.querySelector('button'))?.focus(); diff --git a/src/web/public/session-ui.js b/src/web/public/session-ui.js index 50d4bfadf..fe7d5c0b3 100644 --- a/src/web/public/session-ui.js +++ b/src/web/public/session-ui.js @@ -2025,12 +2025,14 @@ Object.assign(CodemanApp.prototype, { }, /** - * Re-reads the case list after a launch. The no-argument reload re-selects the - * server's `lastUsedCase`, which quick-start has just set to the launched case, - * so a launch in a case it names (a tab's "+") keeps the toolbar on its own. + * Re-reads the case list after a launch, keeping the toolbar on its own case. + * The no-argument reload would re-select the server's `lastUsedCase`, and a + * tab's "+" launching through quick-start sets that to the tab's case while the + * toolbar stays where it was, so every later launch (a worktree's, a plain Run, + * a group's "New session") would otherwise move the toolbar there. */ - _reloadCasesAfterRun(options) { - this.loadQuickStartCases(options.caseName ? document.getElementById('quickStartCase').value : null); + _reloadCasesAfterRun() { + this.loadQuickStartCases(document.getElementById('quickStartCase').value || null); }, async runClaude(options = {}) { @@ -2142,7 +2144,7 @@ Object.assign(CodemanApp.prototype, { this._appendSessionLaunchStatus(ownsLaunchTerminal, `All ${tabCount} remote session(s) ready`); if (remoteIds[0]) { await this.selectSession(remoteIds[0]); - this._reloadCasesAfterRun(options); + this._reloadCasesAfterRun(); } this.terminal.focus(); return; @@ -2221,7 +2223,7 @@ Object.assign(CodemanApp.prototype, { // Auto-switch to the new session using selectSession (does proper refresh) if (firstSessionId) { await this.selectSession(firstSessionId); - this._reloadCasesAfterRun(options); + this._reloadCasesAfterRun(); } this.terminal.focus(); diff --git a/src/web/public/tab-rail-resize.js b/src/web/public/tab-rail-resize.js index 9cdfafc67..4dc434aee 100644 --- a/src/web/public/tab-rail-resize.js +++ b/src/web/public/tab-rail-resize.js @@ -319,6 +319,7 @@ Object.assign(CodemanApp.prototype, { event.preventDefault(); event.stopPropagation(); this.closeTabRailActionMenu(); + this.closeTabGroupMenu?.(); const trigger = event.currentTarget; const menu = document.createElement('div'); menu.className = 'tab-rail-action-menu'; @@ -327,8 +328,8 @@ Object.assign(CodemanApp.prototype, { const settings = this.loadAppSettingsFromStorage(); const actions = [ { label: 'Session options', run: () => this.openSessionOptions(sessionId) }, - // The row's "+" (showTabNewButton on) as a menu item: its keyboard route, since - // Enter on the "+" itself does nothing, and its only route in the compact rail, + // The row's "+" (showTabNewButton on) as a menu item: a keyboard route beside + // Enter or Space on the "+" itself, and its only route in the compact rail, // which hides it. Session rows only: tiles share this menu and carry no "+". ...(document.documentElement.classList.contains('tabs-show-new') && trigger.closest('.session-tab') ? [{ label: 'New session here…', run: () => this.openTabNewSessionMenu({ currentTarget: trigger }, sessionId) }] diff --git a/test/tab-new-session.test.ts b/test/tab-new-session.test.ts index 1cfd426fe..bf18389d3 100644 --- a/test/tab-new-session.test.ts +++ b/test/tab-new-session.test.ts @@ -21,7 +21,9 @@ * group then applies, pinned end to end in case-default-group-routes.test.ts); * - the Run button's mode and the toolbar case are left as they were, also when * the mode is changed while the launch is pending and when the server has - * moved lastUsedCase to the tab's case; + * moved lastUsedCase to the tab's case, and a later launch that names no case + * (a worktree tab's "+", the toolbar Run, a group's "New session") keeps the + * toolbar's case too; * - a failed launch reports the server's own error; * - Enter and Space on the "+" open the picker (the header strip's only * keyboard path) and stop there; every other key passes on to the strip; a @@ -643,11 +645,11 @@ describe('a pick from the "+"', () => { expect(app._apiPut).toHaveBeenCalledWith('/api/settings', { runMode: 'claude' }); }); - it.each([ - ['a remote Claude launch (quick-start)', 'rem', 'r'], - ['a local Claude launch', 'proj', 'a'], - ])("%s leaves the toolbar on its own case, though the server's lastUsedCase moved", async (_label, tabCase, id) => { - const app = makeApp(); + /** + * Readies a Claude launch with the shipping case-list reload and the server's + * lastUsedCase at `lastUsedCase`; the returned wait resolves once that reload ran. + */ + function useShippingCaseReload(app: Record<string, any>, lastUsedCase: string) { app._runMode = 'claude'; app.getAdvisorSetting = () => undefined; app._onSessionCreated = () => {}; @@ -656,25 +658,65 @@ describe('a pick from the "+"', () => { for (const name of ['updateDirDisplayForCase', 'updateMobileCaseLabel', 'updateCasePickerInput']) app[name] = () => {}; for (const name of ['renderCasePickerList', 'closeCasePicker', 'setupQuickStartCasePicker']) app[name] = () => {}; - const select = document.getElementById('quickStartCase') as HTMLSelectElement; - const record = CASES.find((c) => c.name === tabCase)!; win.fetch = vi.fn(async (url: string) => { const json = (value: unknown) => ({ ok: true, status: 200, json: async () => value }); // quick-start saved the launched case as lastUsedCase (session-routes.ts). - if (url === '/api/settings') return json({ success: true, data: { lastUsedCase: tabCase } }); + if (url === '/api/settings') return json({ success: true, data: { lastUsedCase } }); if (url === '/api/cases') return json({ success: true, data: CASES }); - if (url === `/api/cases/${tabCase}`) return json({ success: true, data: record }); + if (url.startsWith('/api/cases/')) { + const record = CASES.find((c) => `/api/cases/${c.name}` === url); + return json(record ? { success: true, data: record } : { success: false, errorCode: 'NOT_FOUND' }); + } if (url === '/api/quick-start') return json({ success: true, data: { sessionId: 'new1', session: { id: 'new1' } } }); if (url === '/api/sessions') return json({ success: true, data: { session: { id: 'new1', name: 'w1' } } }); return json({ success: true, data: {} }); }); + return async () => { + await vi.waitFor(() => expect(app.selectSession).toHaveBeenCalledWith('new1')); + // The case list is reloaded after the launch (not awaited by it). + await vi.waitFor(() => expect(win.fetch.mock.calls.map((call: unknown[]) => call[0])).toContain('/api/cases')); + await flush(); + }; + } + + it.each([ + ['a remote Claude launch (quick-start)', 'rem', 'r'], + ['a local Claude launch', 'proj', 'a'], + ])("%s leaves the toolbar on its own case, though the server's lastUsedCase moved", async (_label, tabCase, id) => { + const app = makeApp(); + const reloaded = useShippingCaseReload(app, tabCase); + const select = document.getElementById('quickStartCase') as HTMLSelectElement; openFrom(app, id); pick('Claude Code'); - await vi.waitFor(() => expect(app.selectSession).toHaveBeenCalledWith('new1')); - // The case list is reloaded after the launch (not awaited by it). - await vi.waitFor(() => expect(win.fetch.mock.calls.map((call: unknown[]) => call[0])).toContain('/api/cases')); - await flush(); + await reloaded(); + expect(select.value).toBe('toolbar-case'); + }); + + // An earlier "+" launch through quick-start left lastUsedCase on the tab's case + // (proj); a later Claude launch that names no case must not follow it there. + it.each([ + [ + 'a "+" Claude launch from a managed worktree tab', + 'horizontal', + (app: Record<string, any>) => { + openFrom(app, 'f'); + pick('Claude Code'); + }, + ], + ['the toolbar Run with Claude', 'horizontal', (app: Record<string, any>) => app.run()], + [ + 'a group\'s "New session" with Claude', + 'vertical', + (app: Record<string, any>) => app.createSessionInTabGroup('gx'), + ], + ])('%s keeps the toolbar on its own case after a "+" moved lastUsedCase', async (_label, orientation, launch) => { + const app = makeApp({ orientation }); + const reloaded = useShippingCaseReload(app, 'proj'); + const select = document.getElementById('quickStartCase') as HTMLSelectElement; + (document.getElementById('tabCount') as HTMLInputElement).value = '1'; + void launch(app); + await reloaded(); expect(select.value).toBe('toolbar-case'); }); }); From e52cc60c7c2ec8955296714ba8aa95d345d09e1c Mon Sep 17 00:00:00 2001 From: Codeman maintainer <noreply@anthropic.com> Date: Tue, 27 Oct 2026 01:34:15 +0100 Subject: [PATCH 54/68] fix: apply the review items for #687 at merge MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - constants.js:2296,2301 spacedFilePathOnLine: refuse a first word ending in `$` and an earlier word with no letter or digit (other than a bare `-`), so a shell prompt showing an absolute working directory (`/mnt/c/proj ❯ code notes.md`, `/home/me/proj$ cat notes.md`) keeps master's links; the six rows from the review are pinned, plus OneDrive, `Program Files (x86)` and `Docs - Copy` still linking whole. - constants.js:2312: the leading strip is now `/^(?:~?\/|(?:\.\.?\/)+)/`, so `Saved to: ../My Docs/plan.md` links whole; an interior `..` still links nothing. Tests for the detector and the terminal provider. - app.js:3032 _linkifyFilePaths: carry any leading bold label and let spacedFilePathOnLine() judge it, and give a text node that follows another inline element a placeholder word, so `**Note:** /home/me/proj/out report.pdf` and ``Use `--config` /home/me/proj/cfg data.json`` split as in the terminal; a spaced path inside inline code still links whole. The now-unused label-pattern guard is gone. - JSDoc (findFilePathLinks, SPACED_FILE_PATH_LABEL_PATTERN, spacedFilePathOnLine) and the spaced-path paragraph in docs/architecture-invariants.md updated to match. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- docs/architecture-invariants.md | 2 +- src/web/public/app.js | 23 ++++++++------- src/web/public/constants.js | 24 +++++++++++----- test/response-viewer-file-links.test.ts | 22 ++++++++++++++ test/spaced-file-path-links.test.ts | 38 +++++++++++++++++++++++++ 5 files changed, 91 insertions(+), 18 deletions(-) diff --git a/docs/architecture-invariants.md b/docs/architecture-invariants.md index ebbcbaa6c..17e7ade0b 100644 --- a/docs/architecture-invariants.md +++ b/docs/architecture-invariants.md @@ -409,7 +409,7 @@ A file path an agent prints is a link on both surfaces it can appear on, and cli **Path forms beyond the rooted one.** `findFilePathLinks(text)` (constants.js) is the single entry point both consumers call; it reserves spaced candidates first (including unlinkable spans), then detects rooted matches, then `~/` and relative paths (`RELATIVE_FILE_PATH_LINK_PATTERN`, same extension list, pinned equal by `test/link-provider-regex.test.ts`), then folder paths, each only where it overlaps neither an earlier match nor an `http(s)` URL. A relative match must start at a boundary (line start, whitespace, quote, bracket, `=`, `,`, `>`), so it can never begin inside a word, an absolute path or a URL; brackets, `=` and `,` end it (`(shot.png)`, `OUT=shots/a.png`). `resolveLinkedFilePath()` resolves a relative path against the session's `workingDir` (`.`/`..` collapsed, so `../x.png` lands outside and routes like any external path) and passes absolute and `~/` paths through. ⚠️ `~` is the home of the SESSION'S host, which only the server knows: `~/` paths always take the attachment route, where `registerExternalAttachment` expands them (`homedir()` locally, `$HOME` inside the remote probe script), and the guard runs on the expanded, realpath-resolved path as before. ⚠️ Programs hard-wrap their own output short of the terminal edge (Claude Code breaks tool output at 59-118 columns), so xterm's `isWrapped` never fires. `terminalPathLine()` (link provider only; touch selection keeps `terminalLogicalLine`) joins the next row when the previous row ends in a path token with a `/` and no extension and the join reaches an extension; a row that starts a new path is never joined: rooted or `~/`, `./`/`../`, or a relative one that opens on the same top directory the broken token did (the next entry of `git ls-files`/`find`/`ls -R`: `src/components` + `src/index.ts` are two paths, not `src/componentssrc/index.ts`), while a wrapped path continues INSIDE a name, so `…/captures/2` + `026-10-10/…` still joins; a 3+ row chain is kept only if a later row completes the path, and the broken row must be at least as long as the continuing one (keeps `cd /mnt/foo` + a longer next line apart). Tests: `test/file-path-link-forms.test.ts`. -Spaced paths are considered first by `findFilePathLinks()`: accepted candidates reserve their entire span even when unlinkable, so later detectors cannot link fragments. Relative and `~/` spaced paths require an explicit path label (`Path:`, `File:`, `Files:`, `Saved to:`, `Saved as:`, `Wrote:`, `Written to:`, `Output:`, `Created:`, `Location:`, `Attachment:`), optionally preceded by a marker. Generic labels and bare bullets defer to ordinary detection, including slash-containing prose such as `CI/CD` or `45/45`. A later `/`, `~/`, `./` or `../` word starts a separate path; an earlier word ending in `/` is a separate folder. Absolute paths keep the own-line and marker forms plus the same path labels; any other label (`Error:`, `Note:`, `Saved the report to:`) defers an absolute path too. A rooted candidate needs a directory before its first space, so `/review the changes in src/a.ts` is prose. An earlier word holding a known extension anywhere (`a.ts#L12 foo.ts`) ends the path at that word, a `:` followed by a digit inside an earlier word is a line suffix (`a.ts:42:See b.md`, grep and compiler output) and ends the path there, a run of spaces is a column gap rather than a name, and a leading `./` is not a segment. An unlinkable candidate reserves its span only while its last word holds a `/`, so `/usr/bin/env node build.js` keeps its ordinary links while `/etc/secret files/passwords.txt` links nothing. A path label vouches that a path follows, not for the words before it: a relative candidate whose only `/` word is its last is prose after the label (`Wrote: 120 lines to src/foo.ts`, `Path: see src/foo.ts`) and defers to the ordinary detectors. The cost is a labelled relative path whose only space is in its top directory, `Saved to: My Docs/plan.md` (and `Saved to: 03 Attachments/plan.pptx`), which splits as on master; `03 Attachments/Build SIPOC Design/Build-SIPOC.pptx`, `notes/a b/c.md`, `~/` and `./` forms and absolute paths still link whole. An earlier word that is a lowercase connective or verb (`SPACED_PATH_PROSE_WORD`: `and`, `or`, `to`, `in`, `the`, `is`, `has`, `contains`, `then`, `as` and a few more) or a bare `=` makes the candidate a sentence, in every shape: `/home/me/proj contains a.md`, `- /tmp/out has the logs in run.log`, `Path: /home/me/proj/out contains a.md`, `Files: src/components and utils/x.ts`, `Wrote: 3 files to docs/design notes/overview.md`. The cost is a name that uses one of those words (`Pros and Cons.md`, `How to deploy.md`, `Documents and Settings`), which splits as on master; `at` (`Screenshot 2026-10-24 at 10.00.00.png`) and `of` (`Copy of report.docx`) are left out of the list on purpose. What remains ambiguous is an own-line absolute path followed by words that hold none of them (`/home/me/proj/out report.pdf` reads like `/home/me/docs/Read Me.txt`), which links as one path. A command line is refused too: a first word that is an executable under `bin/`, `.bin/`, `scripts/` or `tools/` (`/opt/homebrew/bin/node server.js`, `/home/me/proj/scripts/deploy prod.yml`) or any `-x`/`--x` flag word (`/home/me/proj/node_modules/.bin/tsc -p tsconfig.json`); a bare `-` (`Docs - Copy`, `OneDrive - Contoso`) is not a flag. The response viewer uses a leading `<strong>` path label in a paragraph or list item, or one starting a later line of a paragraph (after a `<br>`, since chat renders with `breaks: true`), as detection context without changing the label or displayed text; paths split across other inline elements remain detected per text node. Tests feed vendored marked output into the jsdom linkifier, so consumed bullets and bold labels are covered. Trailing whitespace uses linear `trimEnd()` before the length cap. +Spaced paths are considered first by `findFilePathLinks()`: accepted candidates reserve their entire span even when unlinkable, so later detectors cannot link fragments. Relative and `~/` spaced paths require an explicit path label (`Path:`, `File:`, `Files:`, `Saved to:`, `Saved as:`, `Wrote:`, `Written to:`, `Output:`, `Created:`, `Location:`, `Attachment:`), optionally preceded by a marker. Generic labels and bare bullets defer to ordinary detection, including slash-containing prose such as `CI/CD` or `45/45`. A later `/`, `~/`, `./` or `../` word starts a separate path; an earlier word ending in `/` is a separate folder. Absolute paths keep the own-line and marker forms plus the same path labels; any other label (`Error:`, `Note:`, `Saved the report to:`) defers an absolute path too. A rooted candidate needs a directory before its first space, so `/review the changes in src/a.ts` is prose. An earlier word holding a known extension anywhere (`a.ts#L12 foo.ts`) ends the path at that word, a `:` followed by a digit inside an earlier word is a line suffix (`a.ts:42:See b.md`, grep and compiler output) and ends the path there, a run of spaces is a column gap rather than a name, and a leading run of `./` or `../` is not a segment (`Saved to: ../My Docs/plan.md` links whole; an interior `..` is still refused). An unlinkable candidate reserves its span only while its last word holds a `/`, so `/usr/bin/env node build.js` keeps its ordinary links while `/etc/secret files/passwords.txt` links nothing. A path label vouches that a path follows, not for the words before it: a relative candidate whose only `/` word is its last is prose after the label (`Wrote: 120 lines to src/foo.ts`, `Path: see src/foo.ts`) and defers to the ordinary detectors. The cost is a labelled relative path whose only space is in its top directory, `Saved to: My Docs/plan.md` (and `Saved to: 03 Attachments/plan.pptx`), which splits as on master; `03 Attachments/Build SIPOC Design/Build-SIPOC.pptx`, `notes/a b/c.md`, `~/`, `./` and `../` forms and absolute paths still link whole. An earlier word that is a lowercase connective or verb (`SPACED_PATH_PROSE_WORD`: `and`, `or`, `to`, `in`, `the`, `is`, `has`, `contains`, `then`, `as` and a few more) or a bare `=` makes the candidate a sentence, in every shape: `/home/me/proj contains a.md`, `- /tmp/out has the logs in run.log`, `Path: /home/me/proj/out contains a.md`, `Files: src/components and utils/x.ts`, `Wrote: 3 files to docs/design notes/overview.md`. The cost is a name that uses one of those words (`Pros and Cons.md`, `How to deploy.md`, `Documents and Settings`), which splits as on master; `at` (`Screenshot 2026-10-24 at 10.00.00.png`) and `of` (`Copy of report.docx`) are left out of the list on purpose. What remains ambiguous is an own-line absolute path followed by words that hold none of them (`/home/me/proj/out report.pdf` reads like `/home/me/docs/Read Me.txt`), which links as one path. A command line is refused too: a first word that is an executable under `bin/`, `.bin/`, `scripts/` or `tools/` (`/opt/homebrew/bin/node server.js`, `/home/me/proj/scripts/deploy prod.yml`) or any `-x`/`--x` flag word (`/home/me/proj/node_modules/.bin/tsc -p tsconfig.json`); a bare `-` (`Docs - Copy`, `OneDrive - Contoso`) is not a flag. So is a shell prompt that prints an absolute working directory (WSL's `/mnt/c/...` is outside `$HOME`, so every earlier command in a shell's scrollback has one): a first word ending in `$` (`/home/me/proj$ cat notes.md`) and any earlier word with no letter or digit other than a bare `-` (`/mnt/c/proj ❯ code notes.md`, `/home/me/proj $ cat notes.md`, `/home/me/proj/out ✓ report.pdf`) refuse the candidate, while `Program Files (x86)` and `Docs - Copy` still link whole. The response viewer carries any leading `<strong>` label in a paragraph or list item, or one starting a later line of a paragraph (after a `<br>`, since chat renders with `breaks: true`), into detection without changing the label or displayed text, and `spacedFilePathOnLine()` judges it as it judges the same line in the terminal (`**Note:** /home/me/proj/out report.pdf` splits). A text node that follows any other inline element starts mid-line, so a placeholder word stands in for what precedes it (``Use `--config` /home/me/proj/cfg data.json`` splits as in the terminal), while a path inside inline code still links whole; paths split across inline elements remain detected per text node. Tests feed vendored marked output into the jsdom linkifier, so consumed bullets and bold labels are covered. Trailing whitespace uses linear `trimEnd()` before the length cap. **Folder grid: a folder is granted, then served by bare file name.** A folder path (rooted or `~/` whose last segment has no extension, relative only with a trailing `/`; `FOLDER_PATH_LINK_PATTERN` + `folderLinkPath()`) opens `openFolderGrid()` (panels-ui.js), as does the preview's "Show folder" button. `POST /api/sessions/:id/folders {path}` (`src/folder-listing.ts`) resolves the folder (locally a bounded `probePathKind(…, { pastCap: true })` first, then async `fs.realpath`; remotely one ssh round trip via `remoteListFolder`, through `runWithRemoteSshLimit`), runs the attachment guard on it (blocklist, `confineToWorkspace`, and in multi-user mode `folderAllowedForUser`: a non-admin only inside their own `userSpacePath` or the session's `workingDir`, as the filesystem path picker), locally BEFORE reading it, lists non-hidden subfolders and files of a supported attachment type (each file also blocklist-checked, newest first, capped at `MAX_FOLDER_ENTRIES`), and GRANTS it an id. Files are then fetched as `GET …/folders/:folderId/files/:name/(raw|preview|thumbnail)`: `:name` must be a bare file name of a supported type (no `/`, `\`, NUL, no leading `.`, since the listing hides those), the local file is probed bounded (`unknown` → 503, never 404), and the joined path goes through the SAME `resolveServableAttachmentPath` as an attachment by id (symlink re-resolution, remote re-probe, guard), then the same gate registration applies to what a symlink RESOLVES to (a supported extension on the resolved path and a regular file: locally `fs.stat`, remotely the probe kind; else 400, so `shot.png -> ../secret.bin` is not served and a FIFO or device behind a supported name never reaches `open`), then `folderAllowedForUser` once more on the resolved file, before `serveServableRaw` (the attachment raw route's body, shared) streams it. So browser requests still never carry a path. ⚠️ Grants live in their own registry (`folderGrants`, 32 per session, cleared with the session's attachments), NOT the attachment registry: that one is capped at 200 per session, and paging a folder of 300 renders would evict the session's real attachments. ⚠️ A local folder is never touched unbounded (#516 rule): a folder on a network mount that stopped answering answers 503 with `describeUnknownPath()`, not 404 and not a frozen server. ⚠️ The remote listing script tells "missing" (`E|n`, 404) from "is a file" (`E|f`, 400 `Not a folder`), because the client falls back to the file preview on `Not a folder`; a remote extensionless file otherwise answered "Folder not found". ⚠️ `.folder-grid-overlay` sits at `z-index: 5090`, one step BELOW the preview (5100) and above the response viewer (5000): a tile's preview opens on top and closing it lands back on the grid. ⚠️ ←/→/Esc are handled in a document CAPTURE listener with `stopPropagation`, because the terminal keeps keyboard focus under the overlays and an arrow that reached xterm would be typed into the session; inputs other than xterm's helper textarea keep their keys. ⚠️ A route PARAMETER is capped by the router (Fastify's default is 100 characters, answered as 404 "Route not found"), and a file name goes up to 255, percent-encoded: the server sets `routerOptions.maxParamLength` to `MAX_ROUTE_PARAM_LENGTH` (route-helpers.ts), the route test harness uses the same constant, and a wiring guard pins it. ⚠️ `~/` in a Docker case is refused with 400 (attachment POST and `POST …/folders`): the session is local for these routes, but `~` inside the container is the container's own home, and expanding it on the host would open a different file than the one the agent printed. Tests: `test/routes/folder-grid-routes.test.ts` (incl. the listing script on a real `sh`), `test/folder-grid-ui.test.ts`. diff --git a/src/web/public/app.js b/src/web/public/app.js index 131f28371..cea66e2f5 100644 --- a/src/web/public/app.js +++ b/src/web/public/app.js @@ -3015,23 +3015,26 @@ class CodemanApp { // invalidates the traversal. const walker = document.createTreeWalker(root, NodeFilter.SHOW_TEXT); const targets = []; - // Same promise as the guard above: a stale constants.js without the label - // pattern only loses the carried label, it never throws. - const labelPattern = typeof SPACED_FILE_PATH_LABEL_PATTERN !== 'undefined' ? SPACED_FILE_PATH_LABEL_PATTERN : null; for (let node = walker.nextNode(); node; node = walker.nextNode()) { if (node.parentElement?.closest('a')) continue; - // Markdown puts **Saved to:** in a separate <strong> node. Carry only - // an explicit path label that starts its line into detection; never - // arbitrary prose or a label from another block. Chat renders with + // Markdown puts **Saved to:** in a separate <strong> node. Carry a bold + // label that starts its line into detection and let spacedFilePathOnLine() + // judge it, as it does the same line in the terminal (`Note:` is not a + // path label); never a label from another block. Chat renders with // `breaks: true`, so a later line of a paragraph starts after a <br>. - // The label itself stays untouched. + // A text node after any other inline element (`Use <code>--config</code> + // /a/b c.json`) starts mid-line, so a placeholder word stands in for what + // precedes it. The label itself stays untouched. const label = node.previousSibling; let beforeLabel = label?.previousSibling; if (beforeLabel?.nodeType === 3 && !beforeLabel.nodeValue.trim()) beforeLabel = beforeLabel.previousSibling; - const labelText = label?.nodeName === 'STRONG' ? label.textContent : ''; const leadingLabel = - /^(?:P|LI)$/.test(node.parentElement?.tagName || '') && (!beforeLabel || beforeLabel.nodeName === 'BR'); - const prefix = leadingLabel && labelPattern?.test(labelText) ? `${labelText} ` : ''; + label?.nodeName === 'STRONG' && + /^(?:P|LI)$/.test(node.parentElement?.tagName || '') && + (!beforeLabel || beforeLabel.nodeName === 'BR'); + let prefix = ''; + if (leadingLabel) prefix = `${label.textContent} `; + else if (label?.nodeType === 1 && label.nodeName !== 'BR') prefix = 'x '; const found = findFilePathLinks(prefix + (node.nodeValue || '')) .filter((hit) => hit.index >= prefix.length) .map((hit) => ({ ...hit, index: hit.index - prefix.length })); diff --git a/src/web/public/constants.js b/src/web/public/constants.js index 9ac144564..5e3d3dbc5 100644 --- a/src/web/public/constants.js +++ b/src/web/public/constants.js @@ -2159,8 +2159,10 @@ const FILE_LINK_EXTENSION_GROUP = /\\\.(\(\?:[^()]*\))\)\\b$/.exec(FILE_PATH_LIN * and folder detectors then skip reserved spans, earlier matches and URLs. * * The one entry point for both consumers (terminal link provider, response - * viewer). The viewer restores a leading bold path label's context, but paths - * split across other inline elements are still detected per text node. + * viewer). The viewer carries a leading bold label into the text it passes + * (spacedFilePathOnLine judges it, as for the same line in the terminal) and + * reads a text node that follows another inline element as mid-line; paths + * split across inline elements are still detected per text node. * * @param {string} text * @returns {Array<{path: string, index: number, folder?: true}>} `index` is where `path` starts in @@ -2218,7 +2220,7 @@ function findFilePathLinks(text) { return found.sort((a, b) => a.index - b.index); } -/** Labels that explicitly identify a path, shared with the rendered-markdown linkifier. */ +/** Labels that explicitly identify a path, for {@link spacedFilePathOnLine}. */ const SPACED_FILE_PATH_LABEL_PATTERN = /^(?:path|files?|saved (?:to|as)|wrote|written to|output|created|location|attachment):[ \t]*$/i; @@ -2239,11 +2241,12 @@ const SPACED_FILE_PATH_LABEL_PATTERN = * (SPACED_FILE_PATH_LABEL_PATTERN), with an optional marker before it: generic * labels (`Error:`, `Note:`) and bare bullets also introduce prose, so a relative * path needs a path label to be taken spaced. One trailing sentence mark is left - * out, and a leading `./` is not a segment. The candidate must read as ONE path: + * out, and a leading run of `./` or `../` is not a segment. The candidate must read as ONE path: * it ends on a known extension, a rooted one has a directory before its first * space, no earlier word holds an extension (`a.md,`, `a.ts#L12`) or ends in `/`, * and none holds a `:` and a digit (`a.ts:42`, a line suffix ends the path). Later words cannot start a new - * `/`, `~/`, `./` or `../` path, no earlier word ends in `:` or is an arrow, and + * `/`, `~/`, `./` or `../` path, no earlier word ends in `:`, is an arrow or has + * no letter or digit (a prompt glyph such as `❯` or `$`; a bare `-` is kept), and * a run of spaces (a column gap) is refused, so two paths on a line are never * merged. A relative candidate whose only `/` word is its last is prose after the * label (`Wrote: 120 lines to src/foo.ts`) and is left alone, which also splits @@ -2252,7 +2255,8 @@ const SPACED_FILE_PATH_LABEL_PATTERN = * or a bare `=` makes the candidate a sentence (`/tmp/out has the logs in run.log`), * so a name such as `Pros and Cons.md` splits as master does. A command line is * refused: an executable under `bin/`, `.bin/`, `scripts/` or `tools/` as the - * first word, or any `-x`/`--x` flag word. + * first word, a first word ending in `$` (a shell prompt, `/home/me/proj$ cat a.md`), + * or any `-x`/`--x` flag word. * * A candidate that is one path but not a linkable one (an unknown root such as * `/etc`, a `.`/`..`/empty segment) comes back with `link: false` when its last @@ -2287,8 +2291,14 @@ function spacedFilePathOnLine(line) { if (/\/(?:\.?bin|scripts?|tools)\/[^/]+$/.test(words[0]) || words.some((word) => /^-{1,2}\w/.test(word))) { return null; } + // A shell prompt that prints its working directory in full (`/home/me/proj$ cat + // notes.md`; WSL's `/mnt/c/...` is outside `$HOME`) is a command line too. + if (words[0].endsWith('$')) return null; for (const word of words.slice(0, -1)) { if (word.endsWith('/') || word.endsWith(':') || /^(?:—|–|→|=>|->|<-|=)$/.test(word)) return null; + // A word with no letter or digit (a prompt glyph `❯` or `$`, a `✓`) is never + // part of a file name, apart from a bare `-` (`OneDrive - Contoso`). + if (word !== '-' && !/[\p{L}\p{N}]/u.test(word)) return null; // `a.ts:42` (grep -n, compiler output): the path already ended at the line suffix. if (/:\d/.test(word)) return null; // An extension anywhere in an earlier word (`a.md,`, `a.ts#L12`) ends a path there. @@ -2299,7 +2309,7 @@ function spacedFilePathOnLine(line) { } if (words.slice(1).some((word) => /^(?:\/|~\/|\.{1,2}\/)/.test(word))) return null; const rooted = SPACED_PATH_ROOT.test(path) || path.startsWith('~/'); - const segments = path.replace(/^(?:~?\/|\.\/)/, '').split('/'); + const segments = path.replace(/^(?:~?\/|(?:\.\.?\/)+)/, '').split('/'); const link = (rooted || !path.startsWith('/')) && segments.every((seg) => seg && seg !== '.' && seg !== '..'); // A path label vouches that a path follows, not for the words before it: when // only the last word holds a `/` (`Wrote: 120 lines to src/foo.ts`), the spaces diff --git a/test/response-viewer-file-links.test.ts b/test/response-viewer-file-links.test.ts index fff275c4a..0eff0b357 100644 --- a/test/response-viewer-file-links.test.ts +++ b/test/response-viewer-file-links.test.ts @@ -121,6 +121,28 @@ describe('response viewer file-path linkifier', () => { expect(paths(linkify(renderChat(markdown))).map((a) => a.getAttribute('data-path'))).toEqual(expected); }); + // The terminal splits these lines (`Note:` is not a path label, and the path does + // not start the line), so the viewer must split them too. + it.each([ + ['**Note:** /home/me/proj/out report.pdf', ['/home/me/proj/out', 'report.pdf']], + ['Use `--config` /home/me/proj/cfg data.json', ['/home/me/proj/cfg', 'data.json']], + ])('splits a spaced candidate the terminal splits: %s', (markdown, expected) => { + const html = renderChat(markdown); + const original = document.createElement('div'); + original.innerHTML = html; + const root = linkify(html); + expect(paths(root).map((a) => a.getAttribute('data-path'))).toEqual(expected); + expect(root.textContent).toBe(original.textContent); + }); + + it('still links a spaced path inside inline code whole, after another inline element too', () => { + const root = linkify(renderChat('Use `--config` `/home/me/My Docs/cfg data.json`')); + + const links = paths(root); + expect(links.map((a) => a.getAttribute('data-path'))).toEqual(['/home/me/My Docs/cfg data.json']); + expect(links[0].closest('code')).not.toBeNull(); + }); + it('does not inherit a bold label from earlier prose or another block', () => { for (const markdown of [ 'See **Saved to:** 03 Attachments/Build SIPOC Design/Build-SIPOC.pptx', diff --git a/test/spaced-file-path-links.test.ts b/test/spaced-file-path-links.test.ts index ef4c26987..73a6fe8fe 100644 --- a/test/spaced-file-path-links.test.ts +++ b/test/spaced-file-path-links.test.ts @@ -326,6 +326,37 @@ describe('spaced file paths: shapes that keep master links', () => { expect(paths('Saved to: ./My Docs/plan.md')).toEqual(['./My Docs/plan.md']); expect(paths('Saved to: ./notes.md')).toEqual(['./notes.md']); }); + + it('reads a leading run of ../ as parent directories, not segments', () => { + expect(paths('Saved to: ../My Docs/plan.md')).toEqual(['../My Docs/plan.md']); + expect(paths('Saved to: ../../My Docs/plan.md')).toEqual(['../../My Docs/plan.md']); + expect(paths('Saved to: ../notes.md')).toEqual(['../notes.md']); + // An interior `..` is still a segment, so the span links nothing. + expect(paths('Saved to: My Docs/../plan.md')).toEqual([]); + expect(paths('Saved to: ../My Docs/../x/plan.md')).toEqual([]); + }); + + // A shell prompt that prints its working directory in full (WSL's `/mnt/c/...` is + // outside `$HOME`): a word with no letter or digit, or a first word ending in `$`, + // is never part of a file name, so every earlier command in the scrollback keeps + // master's links. + it.each([ + ['/mnt/c/proj ❯ code notes.md', ['/mnt/c/proj', true], ['notes.md']], + ['/home/me/proj $ cat notes.md', ['/home/me/proj', true], ['notes.md']], + ['/home/me/proj$ cat notes.md', ['/home/me/proj', true], ['notes.md']], + ['/home/me/proj ❯ git add src/a.ts', ['/home/me/proj', true], ['src/a.ts']], + ['/mnt/c/Users/me/OneDrive - Contoso/proj ❯ code notes.md', ['/mnt/c/Users/me/OneDrive', true], ['notes.md']], + ['/home/me/proj/out ✓ report.pdf', ['/home/me/proj/out', true], ['report.pdf']], + ] as Array<[string, ...Array<[string, true?]>]>)('keeps a shell prompt out of the path: %s', (line, ...links) => { + expect(findFilePathLinks(line)).toEqual(masterLinks(line, ...links)); + }); + + it.each([LONG, '/mnt/c/Program Files (x86)/app/readme.txt', '/mnt/c/Users/me/Docs - Copy/a.docx'])( + 'keeps a name whose words hold a letter or digit, or a bare -, whole: %s', + (line) => { + expect(paths(line)).toEqual([line]); + } + ); }); describe('terminal link provider with spaced paths', () => { @@ -383,6 +414,13 @@ describe('terminal link provider with spaced paths', () => { expect(opened).toEqual([`/workspace/project/${rel}`]); }); + it('links a labelled spaced path under ../ and opens it outside the workspace', () => { + const { links, opened } = linksFor('Saved to: ../My Docs/plan.md'); + expect(links.map((l) => l.text)).toEqual(['../My Docs/plan.md']); + links[0].activate(null, links[0].text); + expect(opened).toEqual(['/workspace/My Docs/plan.md']); + }); + it('links nothing for an unlinkable spaced path', () => { expect(linksFor('/etc/secret files/passwords.txt').links).toEqual([]); }); From 4b3eebd0edf7f90685fc7e929efb02a99147ae13 Mon Sep 17 00:00:00 2001 From: Codeman maintainer <noreply@anthropic.com> Date: Tue, 27 Oct 2026 01:48:21 +0100 Subject: [PATCH 55/68] fix: apply the review items for #708 at merge - test/cli-agent-project-spawn.test.ts:345: the doc-sync test no longer reads .changeset/project-worker-directories.md (changeset version deletes it at release); README, API reference and wiki stay. - CLAUDE.md:132 + src/cli-agent.ts @fileoverview (invariant 5): the directory spawn (POST /api/v1/sessions plus /interactive or /shell, no Agent-Origin header, so no marker) and its refusals (empty --working-dir, bare spawn from a HOME cwd); the invariants now name test/cli-agent-project-spawn.test.ts too. - test/cli-agent-project-spawn.test.ts:322: "allows a cwd below HOME" mocks the cwd to <temp HOME>/src/app, so it covers the case the guard must leave alone. - docs/wiki/Driving-Codeman-From-An-Agent.md:59: split the sentence so exit 4 covers only the empty --working-dir; spawn "" reaches quick-start and exits 1. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- CLAUDE.md | 2 +- docs/wiki/Driving-Codeman-From-An-Agent.md | 7 ++++--- src/cli-agent.ts | 9 ++++++++- test/cli-agent-project-spawn.test.ts | 14 ++++++-------- 4 files changed, 19 insertions(+), 13 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index bd2594acd..e395329bc 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -129,7 +129,7 @@ Codeman is a Claude Code session manager with web interface and autonomous Ralph **Agent wait primitives**: bounded long-polls: `GET /api/sessions/:id/wait`, `GET .../wait-output` (literal substring, **never** regex) and `wait`/`waitTimeout` on `POST .../input`. Registry `session-wait-registry.ts` (pure), bounds `config/agent-wait.ts`. ⚠️ A timeout is a 200 (`wait.timedOut`). ⚠️ `stop` exists for `claude` and `deepseek` ONLY (rule lives in `hooksAvailableForMode()`), `blocked` also for a CLI declaring `capabilities.workDetect.blockedLine` (codex, read off the screen; `blockedSignalAvailable()`): explicit request elsewhere is a 400. ⚠️ Send-and-wait registers the waiter BEFORE the write; teardown must `notifySignal('exit')` BEFORE `cancelAll()`; hangup abort listens on `reply.raw` (guarded by `writableFinished`), never `req.raw`; liveness comes from `isPaneDead`, never `session.pid`. ⚠️ Signals are edge-triggered with no history: gather fan-outs via send-and-wait or `wait-output` markers. Packaged as the `skills/codeman` skill (`codeman skill install`, plugin marketplace, or injection behind `agentSkillEnabled`, SYNCED, default OFF, into the directory each CLI scans: `capabilities.agentSkillDir`, `.claude/skills` for claude, `.agents/skills` for the others, none for pi): injection is add-only, marker-owned (`applyAgentSkill`), refuses symlinks, and refreshes a marker-owned user-level copy (`refreshUserAgentSkill`). → [architecture-invariants#agent-wait-primitives](docs/architecture-invariants.md#agent-wait-primitives), `docs/api-reference.md` -**Agent CLI (`codeman agent …`, `src/cli-agent.ts`)**: the command-line client for the wait primitives and the session verbs, for agents the skill does not reach (pi, shell, no `jq`; `seedAgentSessionPreamble` covers every local session whose CLI takes the skill, `capabilities.agentSkillDir`; every pane still exports `CODEMAN_MUX`/`CODEMAN_SESSION_ID`/`CODEMAN_API_URL`). It is a CLIENT: no new route, no second transport (the plan's "Socket API" non-goal stands), every call carries `X-Codeman-Parent-Session`, only `spawn`'s quick-start carries `X-Codeman-Agent-Origin` (pinned in test/cli-agent.test.ts), auth (`src/codeman-credentials.ts`, shared with `codeman attach` and the TUI) from `CODEMAN_PASSWORD` or the data dir's `.env` (the same reader `codeman attach` uses — `readCodemanEnvFile`). Verbs return exit codes (`0` ok / `1` error / `2` timeout / `3` exited / `4` refused) instead of calling `process.exit`, so `test/cli-agent.test.ts` drives every verb against a recording fake transport plus the real `httpRequest` against a local server. ⚠️ Three guards live in code, not prose, and the tests pin them: refuse without `CODEMAN_MUX=1`+`CODEMAN_API_URL` (never guess a URL); `send` transmits printable text + `\r` only (`inputRefusal`: any byte < 0x20 or DEL refuses BEFORE the transport — opencode's `Ctrl+C` is `app_exit`), ESC exists solely as `interrupt` with no `\r`; `rm` fails closed (`deleteRefusal`: empty id, self shorter than 8, prefix match in EITHER direction, re-checked on the resolved id). ⚠️ The routes accept FULL ids only — an 8-char prefix is a 404 (measured live) — while `ls` prints prefixes, so `resolveSessionId` maps a short id through `GET /api/v1/sessions` and refuses an ambiguous one. It also refuses any id under 8 characters (exit 4, on every verb, before any request), the same floor as the server's `PARENT_SESSION_ID_MIN_PREFIX`, so `rm 9` can never pick an arbitrary session. ⚠️ A 400 for `--until stop` on a hook-less mode is passed through verbatim, never papered over: the marker path is the answer there. `spawn` on a readiness timeout exits 2 and LEAVES the session for inspection (the skill's `spawn_worker` deletes it; the CLI's caller is often a human); its stdout is the id ALONE (prose on stderr) so `SID=$(…)` works. ⚠️ Two server answers that look like success and are not: `delivered:false` without `duplicate` (the bytes went into a dead pane — the field exists so a client says "restart" instead of "wait longer") and `wait.ended:true, signal:null` (the worker died during a `stop`/`idle` wait: the registry satisfies only waiters that listed `exit`, then cancels the rest) — both exit 3, checked BEFORE the happy paths in `waitExitCode`. → README "`codeman agent`", `docs/api-reference.md` +**Agent CLI (`codeman agent …`, `src/cli-agent.ts`)**: the command-line client for the wait primitives and the session verbs, for agents the skill does not reach (pi, shell, no `jq`; `seedAgentSessionPreamble` covers every local session whose CLI takes the skill, `capabilities.agentSkillDir`; every pane still exports `CODEMAN_MUX`/`CODEMAN_SESSION_ID`/`CODEMAN_API_URL`). It is a CLIENT: no new route, no second transport (the plan's "Socket API" non-goal stands), every call carries `X-Codeman-Parent-Session`, only `spawn`'s quick-start carries `X-Codeman-Agent-Origin` (pinned in test/cli-agent.test.ts), while `spawn` without a case (cwd or `--working-dir`) is `POST /api/v1/sessions` plus `/interactive` or `/shell` without that header, so no agent-case marker, and refuses (exit 4, before any request) an empty `--working-dir` and a bare spawn from a HOME cwd, since it always sends an explicit `workingDir` and the server's no-`workingDir` hooks skip cannot apply (test/cli-agent-project-spawn.test.ts), auth (`src/codeman-credentials.ts`, shared with `codeman attach` and the TUI) from `CODEMAN_PASSWORD` or the data dir's `.env` (the same reader `codeman attach` uses — `readCodemanEnvFile`). Verbs return exit codes (`0` ok / `1` error / `2` timeout / `3` exited / `4` refused) instead of calling `process.exit`, so `test/cli-agent.test.ts` drives every verb against a recording fake transport plus the real `httpRequest` against a local server. ⚠️ Three guards live in code, not prose, and the tests pin them: refuse without `CODEMAN_MUX=1`+`CODEMAN_API_URL` (never guess a URL); `send` transmits printable text + `\r` only (`inputRefusal`: any byte < 0x20 or DEL refuses BEFORE the transport — opencode's `Ctrl+C` is `app_exit`), ESC exists solely as `interrupt` with no `\r`; `rm` fails closed (`deleteRefusal`: empty id, self shorter than 8, prefix match in EITHER direction, re-checked on the resolved id). ⚠️ The routes accept FULL ids only — an 8-char prefix is a 404 (measured live) — while `ls` prints prefixes, so `resolveSessionId` maps a short id through `GET /api/v1/sessions` and refuses an ambiguous one. It also refuses any id under 8 characters (exit 4, on every verb, before any request), the same floor as the server's `PARENT_SESSION_ID_MIN_PREFIX`, so `rm 9` can never pick an arbitrary session. ⚠️ A 400 for `--until stop` on a hook-less mode is passed through verbatim, never papered over: the marker path is the answer there. `spawn` on a readiness timeout exits 2 and LEAVES the session for inspection (the skill's `spawn_worker` deletes it; the CLI's caller is often a human); its stdout is the id ALONE (prose on stderr) so `SID=$(…)` works. ⚠️ Two server answers that look like success and are not: `delivered:false` without `duplicate` (the bytes went into a dead pane — the field exists so a client says "restart" instead of "wait longer") and `wait.ended:true, signal:null` (the worker died during a `stop`/`idle` wait: the registry satisfies only waiters that listed `exit`, then cancels the rest) — both exit 3, checked BEFORE the happy paths in `waitExitCode`. → README "`codeman agent`", `docs/api-reference.md` **MCP server (`codeman mcp`, `src/mcp-server.ts`)**: a Model Context Protocol server on stdio for any MCP client, a CLIENT of the HTTP API like `codeman agent` (reuses its `httpRequest`, `inputRefusal`, `buildSendBody`; no new route). Seven tools: `list_sessions`, `start_session`, `send_prompt`, `wait_for_session`, `read_output`, `interrupt_session`, `delete_session`. ⚠️ Dual-era: answers the legacy `initialize` handshake (2024-11-05 to 2025-11-25) AND the 2026-07-28 revision (per-request `_meta` with `protocolVersion` + `clientCapabilities`, `server/discover`, `resultType: "complete"` on every modern result); a modern `tools/list`/`server/discover` MUST carry `ttlMs` + `cacheScope` (Claude Code 2.1.296 drops every tool without them: measured). ⚠️ stdout is protocol only; logs go to stderr. ⚠️ The URL comes from `--url`/`CODEMAN_API_URL`, never a guess, and unconfigured it still lists tools (registries introspect) while every call answers how to configure it. ⚠️ Prompt/interrupt/delete only sessions the PROCESS started (in-memory allowlist, so a restart fails closed) or, inside a pane, the calling session's own children, unless `--allow-any-session`; never its own session. ⚠️ `read_output` returns text ONLY, no `structuredContent` (a client that prefers it showed the model metadata instead of the output: measured). Waits default 45 s (Codex's MCP tool timeout is 60 s). Registry manifest `config/mcp-registry/server.json` (`mcpName` in package.json, version kept by `scripts/sync-plugin.mjs`). Tests: `test/mcp-server.test.ts`. diff --git a/docs/wiki/Driving-Codeman-From-An-Agent.md b/docs/wiki/Driving-Codeman-From-An-Agent.md index bab92f3fc..f9fa407fe 100644 --- a/docs/wiki/Driving-Codeman-From-An-Agent.md +++ b/docs/wiki/Driving-Codeman-From-An-Agent.md @@ -56,9 +56,10 @@ codeman agent rm "$SID" # any session except t - **`spawn [case]`** uses the current directory when no case is supplied. Use `codeman agent spawn --working-dir /path/to/project --mode codex` for another existing - local directory. A case and `--working-dir` cannot be combined, and an empty case - name (`spawn "$CASE"` with `CASE` unset) is refused rather than read as "no case", - as is an empty `--working-dir` (exit 4, before any request), so it never falls back to cwd. + local directory. A case and `--working-dir` cannot be combined. An empty case name + (`spawn "$CASE"` with `CASE` unset) is never read as "no case": it goes to quick-start, + which refuses it (exit 1). An empty `--working-dir` is refused before any request + (exit 4), so it never falls back to cwd. A cwd that is your home directory is refused the same way, because Codeman's hooks would land in `~/.claude`; pass `--working-dir ~` to start a worker there on purpose. Explicit case names retain quick-start behavior (creating a scratch case if missing). diff --git a/src/cli-agent.ts b/src/cli-agent.ts index ca6155ccf..cb9b8b01c 100644 --- a/src/cli-agent.ts +++ b/src/cli-agent.ts @@ -13,7 +13,8 @@ * with tests, so a `codex`/`opencode`/`pi` agent — which never gets the claude-only * preamble — has the same verbs from one line in its AGENTS.md. * - * Invariants (each asserted in `test/cli-agent.test.ts`): + * Invariants (each asserted in `test/cli-agent.test.ts` or + * `test/cli-agent-project-spawn.test.ts`): * 1. Refuses outside a Codeman session (`CODEMAN_MUX=1` + `CODEMAN_API_URL`); it * never guesses a URL — a server you are not part of is not yours to drive. * 2. `send` transmits printable text plus `\r` only. ESC exists solely as @@ -25,6 +26,12 @@ * 4. An id shorter than 8 characters refuses (exit 4) before any request, on every * verb. `9` would resolve to whichever session is alone with that first * character, the user's own interactive tab included. + * 5. `spawn` without a case starts the worker in an existing directory (cwd, or + * `--working-dir`) via `POST sessions` + `interactive|shell`, without the + * `X-Codeman-Agent-Origin` header, so no agent-case marker is written. It refuses + * (exit 4, before any request) an empty `--working-dir` and a bare spawn from a + * HOME cwd: it always sends an explicit `workingDir`, so the server's skip of + * workspace hooks for a request that names none cannot protect `~/.claude`. * * Commands live here as functions returning an exit code, not calling * `process.exit`, so the whole surface is unit-testable against a fake server. diff --git a/test/cli-agent-project-spawn.test.ts b/test/cli-agent-project-spawn.test.ts index 4f76aac22..68f8f767c 100644 --- a/test/cli-agent-project-spawn.test.ts +++ b/test/cli-agent-project-spawn.test.ts @@ -1,4 +1,4 @@ -import { mkdtemp, rm, symlink, writeFile } from 'node:fs/promises'; +import { mkdir, mkdtemp, rm, symlink, writeFile } from 'node:fs/promises'; import http from 'node:http'; import { tmpdir } from 'node:os'; import { readFileSync } from 'node:fs'; @@ -321,10 +321,13 @@ describe('agent spawn never treats an implicit HOME cwd as a project', () => { }); it('allows a cwd below HOME', async () => { const home = await homeDir(); + const project = join(home, 'src', 'app'); + await mkdir(project, { recursive: true }); vi.stubEnv('HOME', home); + vi.spyOn(process, 'cwd').mockReturnValue(project); const t = deps([ok({ session: { id: OTHER } }), ok({})]); expect(await agentSpawn(t.d, base)).toBe(EXIT.ok); - expect(t.calls[0].body).toMatchObject({ workingDir: process.cwd() }); + expect(t.calls[0].body).toMatchObject({ workingDir: project }); }); it('never throws when HOME cannot be resolved, and compares the resolved paths instead', async () => { const missing = join(await homeDir(), 'absent'); @@ -340,12 +343,7 @@ describe('the directory-spawn --json shape is documented the same way everywhere // The wiki wraps lines, so whitespace is normalized before comparing. const clause = '`{ sessionId, workingDir, created: true }`, plus `ready` when a readiness wait ran, `readinessError` when that call failed, and `startupError` when the start failed'; - for (const file of [ - 'README.md', - 'docs/api-reference.md', - 'docs/wiki/Driving-Codeman-From-An-Agent.md', - '.changeset/project-worker-directories.md', - ]) { + for (const file of ['README.md', 'docs/api-reference.md', 'docs/wiki/Driving-Codeman-From-An-Agent.md']) { it(file, () => { expect(readFileSync(new URL(`../${file}`, import.meta.url), 'utf8').replace(/\s+/g, ' ')).toContain(clause); }); From 6539bc6c827be68f47c934d6e47509ce2e616bdb Mon Sep 17 00:00:00 2001 From: Codeman maintainer <noreply@anthropic.com> Date: Tue, 27 Oct 2026 01:58:14 +0100 Subject: [PATCH 56/68] fix: apply the review items for #714 at merge - Session update no longer resets "How often" while typing: updateGitFetchStatus (session-ui.js) leaves a control alone while it is document.activeElement, and autoSaveGitFetch writes the clamped interval back itself (Enter saves with the field still focused). - Partial override shows the app values: enabled = session ?? app switch, interval = session ?? app interval ?? 300 (session-ui.js updateGitFetchStatus). - runFetch (git-fetch.ts) arms the spawn with what the core.sshCommand read left of timeoutMs, and answers ETIMEDOUT without spawning when nothing is left, so a git that never answers costs one timeout, not two. - fetchEnvFor keeps a GIT_SSH from the server's environment (repoSshCommand || base.GIT_SSH), fileoverview and CLAUDE.md line updated to say so. - i18n.js: the zh-CN "Git fetch on an interval" description scopes the only writing git command to the Git window. - styles.css: the disabled/busy rules are scoped to .git-status-actions .btn-icon-sm (moved beside .git-status-actions), and the busy pulse stops under prefers-reduced-motion. - Comments that called the override per case now say per session: session.ts (field, restore and getter docs, setGitFetchSettings), git-fetch-scheduler.ts:2, server-timing.ts:78, server.ts (cleanup and restore), app.js _onSessionUpdated, session-ui.js (openSessionOptions and updateGitFetchStatus). - Tests: test/git-fetch-session-options-ui.test.ts (new, JSDOM: typing survives an SSE update, the Enter write-back, both partial cases); test/git-fetch.test.ts (GIT_SSH kept; one bound for the read and the fetch, stub git on PATH). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- CLAUDE.md | 2 +- src/config/server-timing.ts | 2 +- src/git-fetch-scheduler.ts | 2 +- src/git-fetch.ts | 34 +++-- src/session.ts | 14 +- src/web/public/app.js | 5 +- src/web/public/i18n.js | 2 +- src/web/public/session-ui.js | 39 +++--- src/web/public/styles.css | 63 +++++---- src/web/server.ts | 6 +- test/git-fetch-session-options-ui.test.ts | 155 ++++++++++++++++++++++ test/git-fetch.test.ts | 43 ++++++ 12 files changed, 299 insertions(+), 68 deletions(-) create mode 100644 test/git-fetch-session-options-ui.test.ts diff --git a/CLAUDE.md b/CLAUDE.md index d434054a2..a3b872fe2 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -236,7 +236,7 @@ Codeman is a Claude Code session manager with web interface and autonomous Ralph **Git status indicator** (`showGitStatus`, per-device, default OFF; `src/git-workspace-status.ts`, `routes/git-status-routes.ts`, `git-status-ui.js`): the bottom-bar indicator and its panel read `GET /api/sessions/:id/git-status` and `/git-diff`, read-only and offline on its own (it never fetches; fetching is the separate path below). ⚠️ git never runs on a repository at or inside a Docker case workspace (walk-up, scan and diff alike, and a cached repository list is re-checked against the CURRENT Docker roots), since a container could plant a clean filter that runs on the host; remote and Docker sessions answer `unsupported`. ⚠️ `git-diff` takes `repo`/`path`/`kind` only as keys matched against the workspace's own repository list (`findWorkspaceRepo()`) and that repository's current status, never as paths. ⚠️ Keep `--no-optional-locks`, `core.fsmonitor=false` and `log.showSignature=false` on every call and `--no-ext-diff --no-textconv` on diffs; clean filters still run, which is why the Docker rule exists. ⚠️ An enclosing repository at `$HOME` or above is ignored (`isUnrelatedAncestor()`), and is identified by one cached `rev-parse` before any full status runs. Everything git supplies renders via `textContent`. Tests: `test/git-workspace-status.test.ts`, `test/routes/git-status-routes.test.ts`, and `test/git-status.browser.test.ts` (browser suite, not in the gate). -**Git fetch** (`src/git-fetch.ts`, `src/git-fetch-scheduler.ts`, `routes/git-status-routes.ts`, the panel's **Fetch** button): `POST /api/sessions/:id/git-fetch` is the ONLY git command the Git PANEL runs that WRITES — cloning and worktree management write too, but only when the user asked for them at that moment — and it is the only one that can run later by itself, on a timer, and the only one that can run a repository's own hooks (`reference-transaction`, `pre-auto-gc` through the automatic maintenance a fetch triggers, config commands such as `credential.helper`) — which is why it is opt-in and never runs on a timer until the user turns it on. ⚠️ Fetch updates remote-tracking refs only; it must never commit, check out, merge or touch the working tree. ⚠️ It is not as harmless as a read: a fetch holds git's ref lock, so an agent's own `git pull` in the same clone can hit the same `cannot lock ref` — hence one folder is fetched by at most one run at a time (`fetchWorkspaceOnce` joins a run already going), and `FETCH_CONFIG_ARGS` (`-c core.fsmonitor=false -c maintenance.auto=false -c gc.auto=0`) keeps an unattended fetch from starting a gc in a repository the agent is using. ⚠️ `buildFetchArgs()` is `['fetch', '--quiet']` and must stay verb-invariant (`test/git-fetch.test.ts`); those `-c` overrides are added by the runner, not by `buildFetchArgs()`. ⚠️ The git timeout must reach the whole process TREE, not just the `git` parent: `execFile` signals the parent only, so `runFetch` spawns `detached` and signals the negative pid (SIGTERM, then SIGKILL `FETCH_KILL_ESCALATION_MS` later), and the caller is answered AT the timeout while the group is still dying — that is the point. `test/git-fetch.test.ts` proves it against a local server that never answers, by group membership rather than parentage. A non-zero exit must still reject the way `execFile` would, or a real failure reads as a clean run with no reason. ⚠️ The same Docker rule as above applies BEFORE any git runs (a repository at or inside a Docker case workspace is refused), and remote sessions answer `unsupported`. ⚠️ Keep it non-interactive (`gitNonInteractiveEnv()`: `GIT_TERMINAL_PROMPT=0` + BatchMode ssh) or a fetch hangs on an invisible login prompt — but that BatchMode ssh is a DEFAULT, never an override: `fetchEnvFor` must not overwrite the repository's `core.sshCommand` or the user's own `GIT_SSH_COMMAND` (`readRepoSshCommand` reads it inside the same limiter slot and the same wall-clock bound as the fetch it serves), and `StrictHostKeyChecking=accept-new` writes an unknown host key into `~/.ssh/known_hosts` without asking — BatchMode stops a prompt, not a write. Keep it bounded: `MAX_REPOS`/`MAX_REPOS_LIMIT` (12/50), `MAX_FETCH_OUTPUT_BYTES` (2 MB), the git timeout per spawn, and `FETCH_CONCURRENCY` (4) as ONE `Limiter` shared by the fetches of the whole tick — `createLimiter` keeps its count in the closure, so a limiter that delegated to `mapLimited` per call bounds each call, and 4 folders × 4 repositories would start 16 git processes. ⚠️ One semaphore cannot bound both the folders and the fetches: a folder holding a slot would then wait for a slot its own fetch needs, so the tick bounds its folders with `mapLimited` and hands the shared bound to every run. ⚠️ Failure text reaching a client is control-stripped and credential-redacted (`sanitizeGitOutput`) and the browser writes it with `textContent`. Scheduler semantics: settings are read fresh each tick (`GIT_FETCH_TICK_INTERVAL_MS`, 15 s), a per-session override wins even while the app-wide switch is off, the interval is timed from the run START, state is keyed by FOLDER (sessions sharing one folder are the same clone, so one run serves them all, the schedule is stamped at selection, and every session on a folder is remembered, a folder is due when ANY session open on it is due — so the order the sessions were read cannot decide which interval wins (both orders are tested) — and the folder is probed once for the whole group rather than once per session on it — closing the one that holds the override must not stop the folder's fetching while another session is still open on it), a tick is skipped while the previous one is still going, and a user-chosen folder is probed BOUNDED with the default options before anything runs (rule #516 — `unknown` is never read as `absent`). ⚠️ The two settings (`gitFetchEnabled`, `gitFetchIntervalSeconds`, off/300 by default, clamped 30–3600) are **synced**, not per-device: they must stay out of `displayKeys` and out of the strip destructure in settings-ui.js, or the scheduler reads nobody's value. Per-session state rides `SessionUpdated` (no new SSE event) as `gitFetch`, and the click route publishes the run's trigger, not the request's: `trigger: 'click'` only when the click was the run, because a click that joined a tick already going fetched nothing and cannot report itself as the fetch (`GitFetchRun` carries it, and the scheduler publishes the run's trigger for the same reason — its tick can join a click too). The UI's "by click" text depends on it. Tests: `test/git-fetch.test.ts`, `test/git-fetch-scheduler.test.ts`, `test/routes/session-routes-git-fetch-settings.test.ts`, `test/git-fetch-i18n.test.ts`, `test/app-settings-structure.test.ts`, `test/session-options-structure.test.ts`, and the Fetch block of `test/git-status.browser.test.ts` (browser suite, not in the gate). +**Git fetch** (`src/git-fetch.ts`, `src/git-fetch-scheduler.ts`, `routes/git-status-routes.ts`, the panel's **Fetch** button): `POST /api/sessions/:id/git-fetch` is the ONLY git command the Git PANEL runs that WRITES — cloning and worktree management write too, but only when the user asked for them at that moment — and it is the only one that can run later by itself, on a timer, and the only one that can run a repository's own hooks (`reference-transaction`, `pre-auto-gc` through the automatic maintenance a fetch triggers, config commands such as `credential.helper`) — which is why it is opt-in and never runs on a timer until the user turns it on. ⚠️ Fetch updates remote-tracking refs only; it must never commit, check out, merge or touch the working tree. ⚠️ It is not as harmless as a read: a fetch holds git's ref lock, so an agent's own `git pull` in the same clone can hit the same `cannot lock ref` — hence one folder is fetched by at most one run at a time (`fetchWorkspaceOnce` joins a run already going), and `FETCH_CONFIG_ARGS` (`-c core.fsmonitor=false -c maintenance.auto=false -c gc.auto=0`) keeps an unattended fetch from starting a gc in a repository the agent is using. ⚠️ `buildFetchArgs()` is `['fetch', '--quiet']` and must stay verb-invariant (`test/git-fetch.test.ts`); those `-c` overrides are added by the runner, not by `buildFetchArgs()`. ⚠️ The git timeout must reach the whole process TREE, not just the `git` parent: `execFile` signals the parent only, so `runFetch` spawns `detached` and signals the negative pid (SIGTERM, then SIGKILL `FETCH_KILL_ESCALATION_MS` later), and the caller is answered AT the timeout while the group is still dying — that is the point. `test/git-fetch.test.ts` proves it against a local server that never answers, by group membership rather than parentage. A non-zero exit must still reject the way `execFile` would, or a real failure reads as a clean run with no reason. ⚠️ The same Docker rule as above applies BEFORE any git runs (a repository at or inside a Docker case workspace is refused), and remote sessions answer `unsupported`. ⚠️ Keep it non-interactive (`gitNonInteractiveEnv()`: `GIT_TERMINAL_PROMPT=0` + BatchMode ssh) or a fetch hangs on an invisible login prompt — but that BatchMode ssh is a DEFAULT, never an override: `fetchEnvFor` must not overwrite the repository's `core.sshCommand`, the user's own `GIT_SSH_COMMAND` or a `GIT_SSH` (`readRepoSshCommand` reads it inside the same limiter slot and the same wall-clock bound as the fetch it serves, and the spawn gets only what the read left), and `StrictHostKeyChecking=accept-new` writes an unknown host key into `~/.ssh/known_hosts` without asking — BatchMode stops a prompt, not a write. Keep it bounded: `MAX_REPOS`/`MAX_REPOS_LIMIT` (12/50), `MAX_FETCH_OUTPUT_BYTES` (2 MB), the git timeout per spawn, and `FETCH_CONCURRENCY` (4) as ONE `Limiter` shared by the fetches of the whole tick — `createLimiter` keeps its count in the closure, so a limiter that delegated to `mapLimited` per call bounds each call, and 4 folders × 4 repositories would start 16 git processes. ⚠️ One semaphore cannot bound both the folders and the fetches: a folder holding a slot would then wait for a slot its own fetch needs, so the tick bounds its folders with `mapLimited` and hands the shared bound to every run. ⚠️ Failure text reaching a client is control-stripped and credential-redacted (`sanitizeGitOutput`) and the browser writes it with `textContent`. Scheduler semantics: settings are read fresh each tick (`GIT_FETCH_TICK_INTERVAL_MS`, 15 s), a per-session override wins even while the app-wide switch is off, the interval is timed from the run START, state is keyed by FOLDER (sessions sharing one folder are the same clone, so one run serves them all, the schedule is stamped at selection, and every session on a folder is remembered, a folder is due when ANY session open on it is due — so the order the sessions were read cannot decide which interval wins (both orders are tested) — and the folder is probed once for the whole group rather than once per session on it — closing the one that holds the override must not stop the folder's fetching while another session is still open on it), a tick is skipped while the previous one is still going, and a user-chosen folder is probed BOUNDED with the default options before anything runs (rule #516 — `unknown` is never read as `absent`). ⚠️ The two settings (`gitFetchEnabled`, `gitFetchIntervalSeconds`, off/300 by default, clamped 30–3600) are **synced**, not per-device: they must stay out of `displayKeys` and out of the strip destructure in settings-ui.js, or the scheduler reads nobody's value. Per-session state rides `SessionUpdated` (no new SSE event) as `gitFetch`, and the click route publishes the run's trigger, not the request's: `trigger: 'click'` only when the click was the run, because a click that joined a tick already going fetched nothing and cannot report itself as the fetch (`GitFetchRun` carries it, and the scheduler publishes the run's trigger for the same reason — its tick can join a click too). The UI's "by click" text depends on it. Tests: `test/git-fetch.test.ts`, `test/git-fetch-scheduler.test.ts`, `test/routes/session-routes-git-fetch-settings.test.ts`, `test/git-fetch-i18n.test.ts`, `test/app-settings-structure.test.ts`, `test/session-options-structure.test.ts`, `test/git-fetch-session-options-ui.test.ts`, and the Fetch block of `test/git-status.browser.test.ts` (browser suite, not in the gate). **Raw file bodies are streamed and range-aware**: `file-raw`, the attachments `/raw` route, `GET /api/download` and the rendered-HTML route `GET /html-view/<cap>/...` share `sendFileBody()`, advertise `Accept-Ranges: bytes` and answer `Range` with `206` + `Content-Range` (single-range, parser in `src/web/http-range.ts`); without it `<video>` cannot seek. The size cap (`MAX_FILE_DOWNLOAD_BYTES`, default 2GB, env `CODEMAN_MAX_DOWNLOAD_BYTES`, `0` = unlimited) is a sanity bound, not memory protection; never reintroduce a whole-file buffer. ⚠️ Bodies go out via `reply.hijack()`, so `sendRawStream` must copy the status onto `reply.raw` by hand or a partial body ships as `200`. ⚠️ Closing the preview must pause and unload media (`_stopFilePreviewMedia`), since a detached `HTMLMediaElement` keeps playing. → [architecture-invariants#raw-file-bodies-streamed-and-range-aware](docs/architecture-invariants.md#raw-file-bodies-streamed-and-range-aware) diff --git a/src/config/server-timing.ts b/src/config/server-timing.ts index 37c6e1d91..0db258b20 100644 --- a/src/config/server-timing.ts +++ b/src/config/server-timing.ts @@ -75,7 +75,7 @@ export const ITERATION_PAUSE_MS = 2000; // ============================================================================ /** - * How often the fetch scheduler (src/git-fetch-scheduler.ts) wakes to check which cases are due (ms). A + * How often the fetch scheduler (src/git-fetch-scheduler.ts) wakes to check which sessions are due (ms). A * run lands within one tick of the interval the user set, because the interval is timed from the start of * a run; a tick is a settings read and a due check, not a spawn, so a quiet install costs nothing. */ diff --git a/src/git-fetch-scheduler.ts b/src/git-fetch-scheduler.ts index 8c57f5604..5f8b1dd27 100644 --- a/src/git-fetch-scheduler.ts +++ b/src/git-fetch-scheduler.ts @@ -1,5 +1,5 @@ /** - * @fileoverview Git fetch on an interval: the cases the user turned on, fetched by a tick the server owns + * @fileoverview Git fetch on an interval: the sessions the user turned on, fetched by a tick the server owns * (`src/git-fetch-scheduler.ts`, wired in `src/web/server.ts`). * * The fetch module (src/git-fetch.ts) is the only git command the Git panel runs that writes, and on a timer diff --git a/src/git-fetch.ts b/src/git-fetch.ts index 6e5b01fd4..b12537226 100644 --- a/src/git-fetch.ts +++ b/src/git-fetch.ts @@ -35,9 +35,9 @@ * login fails fast instead of hanging the request on an invisible username prompt. Codeman collects * no credentials; a repository the user's own git is already signed in to works because that is the * user's existing setup, so unlike a clone no credential helper is disabled here. That BatchMode ssh is - * a default, never an override: `fetchEnvFor` keeps whatever the repository's `core.sshCommand` or the - * user's own `GIT_SSH_COMMAND` already says, because on a machine with several ssh setups that choice - * is usually the one that works. ⚠️ The default still leaves a record: `StrictHostKeyChecking=accept-new` + * a default, never an override: `fetchEnvFor` keeps whatever the repository's `core.sshCommand`, the + * user's own `GIT_SSH_COMMAND` or a `GIT_SSH` already says, because on a machine with several ssh setups + * that choice is usually the one that works. ⚠️ The default still leaves a record: `StrictHostKeyChecking=accept-new` * writes an unknown host key into `~/.ssh/known_hosts` without asking — BatchMode stops a prompt, not a * write. * - The timeout kills the PROCESS GROUP, not just the `git` parent. A fetch over http spawns @@ -180,15 +180,18 @@ export function describeFetchFailure(err: unknown): FetchFailure { * `gitNonInteractiveEnv` supplies the BatchMode default, and the default is only a default: git reads * `GIT_SSH_COMMAND` from the environment ahead of the repository's `core.sshCommand`, so setting it would * override a repository that already chose its own ssh — an alternative host, an identity file, a jump box — - * and break a setup that works today. That is the only case where this module makes an ssh choice on the - * user's behalf, so it is the whole decision here: the caller supplies the value it read, and this decides - * between the user's environment and the repository's own answer. + * and break a setup that works today. The server's own `GIT_SSH` is the same kind of answer: git reads it + * after both of those (`GIT_SSH_COMMAND`, then `core.sshCommand`, then `GIT_SSH`), so a written default would + * hide it too. That is the only case where this module makes an ssh choice on the user's behalf, so it is the + * whole decision here: the caller supplies the value it read, and this decides between the user's + * environment and the repository's own answer. */ export function fetchEnvFor(base: NodeJS.ProcessEnv, repoSshCommand: string | null): NodeJS.ProcessEnv { const env = gitNonInteractiveEnv(base); // `gitNonInteractiveEnv` only writes the default when the environment had nothing, so this is exactly the - // case where it was a choice rather than a pass-through — and the repository's own answer wins over it. - if (!base.GIT_SSH_COMMAND && repoSshCommand) delete env.GIT_SSH_COMMAND; + // case where it was a choice rather than a pass-through, and the repository's own answer, or a `GIT_SSH` in + // the environment, wins over it. + if (!base.GIT_SSH_COMMAND && (repoSshCommand || base.GIT_SSH)) delete env.GIT_SSH_COMMAND; return env; } @@ -231,10 +234,23 @@ export async function readRepoSshCommand(cwd: string, timeoutMs: number): Promis * spawn failure (`spawn git ENOENT` → GIT_MISSING), `killed` and `code` for the timeout. A non-zero exit * rejects the way `execFile` would — `code` is then the exit code — because resolving it would report a real * failure as a clean run with an empty reason. + * + * `timeoutMs` bounds the `core.sshCommand` read and the fetch TOGETHER: the spawn is armed with what the read + * left, so a `git` that never answers costs one timeout, not two. A read that used the whole bound answers + * as a timeout without starting the fetch. */ export const runFetch: GitRunner = async (cwd, args, opts) => { const timeoutMs = opts?.timeoutMs ?? DEFAULT_GIT_TIMEOUT_MS; + const startedAt = Date.now(); const env = fetchEnvFor(process.env, await readRepoSshCommand(cwd, timeoutMs)); + const remainingMs = timeoutMs - (Date.now() - startedAt); + if (remainingMs <= 0) { + throw Object.assign(new Error(`Command failed: git ${args.join(' ')}`), { + stderr: '', + killed: true, + code: 'ETIMEDOUT', + }); + } return new Promise<string>((resolve, reject) => { let child: ReturnType<typeof spawn>; @@ -289,7 +305,7 @@ export const runFetch: GitRunner = async (cwd, args, opts) => { killTree('SIGTERM'); killTimer = setTimeout(() => killTree('SIGKILL'), FETCH_KILL_ESCALATION_MS); fail(); - }, timeoutMs); + }, remainingMs); child.stdout?.on('data', (chunk: Buffer) => { stdoutBytes += chunk.length; diff --git a/src/session.ts b/src/session.ts index de6cbddd7..4ada6201b 100644 --- a/src/session.ts +++ b/src/session.ts @@ -615,9 +615,9 @@ export class Session extends EventEmitter { private _keepWarmEnabled: boolean | undefined; /** Last status the keep-warm service published for this session; ephemeral. */ private _keepWarm: KeepWarmStatus | null = null; - /** Git fetch on an interval for this case; undefined = follow the app-wide default. Persisted. */ + /** Git fetch on an interval for this session; undefined = follow the app-wide default. Persisted. */ private _gitFetchEnabled: boolean | undefined; - /** Seconds between this case's fetch runs; undefined = follow the app-wide default. Persisted. */ + /** Seconds between this session's fetch runs; undefined = follow the app-wide default. Persisted. */ private _gitFetchIntervalSeconds: number | undefined; /** Last status the fetch scheduler published for this session; ephemeral. */ private _gitFetch: GitFetchStatus | null = null; @@ -933,9 +933,9 @@ export class Session extends EventEmitter { lastRealPromptAt?: number; /** Restored prompt-cache keep-warm choice (see `keepWarmEnabled`). */ keepWarmEnabled?: boolean; - /** Restored Git fetch override for this case (see `gitFetchEnabled`). */ + /** Restored Git fetch override for this session (see `gitFetchEnabled`). */ gitFetchEnabled?: boolean; - /** Restored Git fetch interval for this case (see `gitFetchIntervalSeconds`). */ + /** Restored Git fetch interval for this session (see `gitFetchIntervalSeconds`). */ gitFetchIntervalSeconds?: number; /** Restored conversation chain, oldest first (see `claudeSessionChain`). */ claudeSessionChain?: string[]; @@ -1594,18 +1594,18 @@ export class Session extends EventEmitter { return changed; } - /** Git fetch override for this case; undefined means the app-wide setting applies. */ + /** Git fetch override for this session; undefined means the app-wide setting applies. */ get gitFetchEnabled(): boolean | undefined { return this._gitFetchEnabled; } - /** Interval override for this case; undefined means the app-wide setting applies. */ + /** Interval override for this session; undefined means the app-wide setting applies. */ get gitFetchIntervalSeconds(): number | undefined { return this._gitFetchIntervalSeconds; } /** - * Set the case's override. The Session Options rows save on their own, so a caller passes what it has + * Set the session's override. The Session Options rows save on their own, so a caller passes what it has * and leaves the other half as it was; `undefined` on either means the app-wide setting applies again. */ setGitFetchSettings(enabled: boolean | undefined, intervalSeconds: number | undefined): void { diff --git a/src/web/public/app.js b/src/web/public/app.js index f042a8cdb..febf0cd6c 100644 --- a/src/web/public/app.js +++ b/src/web/public/app.js @@ -2455,8 +2455,9 @@ class CodemanApp { // Update parentSessionName for any subagents belonging to this session // (fixes stale name display after session rename) this.updateSubagentParentNames(session.id); - // A scheduled fetch publishes the case's result on this same event, so an open Session Options modal on - // that case shows it without polling its own (session-ui.js: updateGitFetchStatus). + // A scheduled fetch publishes the session's result on this same event, so an open Session Options modal + // on that session shows it without polling its own (session-ui.js: updateGitFetchStatus, which leaves the + // control being typed in alone). if (this.editingSessionId === session.id) this.updateGitFetchStatus(session.id); // If claudeSessionId was just set, re-check orphan subagents // This connects subagents that were waiting for the session to identify itself diff --git a/src/web/public/i18n.js b/src/web/public/i18n.js index b792cdf5c..52b1eeef5 100644 --- a/src/web/public/i18n.js +++ b/src/web/public/i18n.js @@ -538,7 +538,7 @@ '单条 git 命令可运行的秒数,超时后该仓库会被报告为无法读取(5 到 120,默认 30)。仓库位于较慢的网络共享上时请调高此值。', 'Git fetch on an interval': 'Git 定时拉取', 'Runs `git fetch` for the repositories the Git window lists on a timer, so the "behind" number is current without a click. Off by default: this is the only git command the Git panel runs that writes, and the only one Codeman runs on a timer (cloning and worktree management write too, but only when you asked for them at the moment you asked), so nothing runs until you turn it on. A session can override both the switch and the interval in Session Options → Git.': - '按间隔为 Git 窗口列出的仓库运行拉取,这样“落后”的数字无需点击就是最新的。默认关闭:这是 {name} 运行的唯一会写入的 git 命令,也是唯一会按间隔自行运行的命令(克隆和工作树管理也会写入,但只在您当时要求时才运行),所以在您开启之前不会有任何运行。会话可以在“会话选项 → Git”中同时覆盖开关和间隔。', + '按间隔为 Git 窗口列出的仓库运行拉取,这样“落后”的数字无需点击就是最新的。默认关闭:这是 Git 窗口运行的唯一会写入的 git 命令,也是 {name} 唯一会按间隔自行运行的命令(克隆和工作树管理也会写入,但只在您当时要求时才运行),所以在您开启之前不会有任何运行。会话可以在“会话选项 → Git”中同时覆盖开关和间隔。', 'Git fetch: how often': 'Git 定时拉取:多久一次', 'Seconds between runs for a session that has no override of its own (30 to 3600, default 300). Only a session that is on — here, or in Session Options → Git — is fetched, and sessions sharing one folder are fetched by one run.': '没有自己覆盖设置的会话,两次运行之间的秒数(30 到 3600,默认 300)。只有已开启的会话——这里,或在“会话选项 → Git”——才会被拉取,共用一个文件夹的会话由一次运行拉取。', diff --git a/src/web/public/session-ui.js b/src/web/public/session-ui.js index d399423ad..0130daaf0 100644 --- a/src/web/public/session-ui.js +++ b/src/web/public/session-ui.js @@ -2685,7 +2685,7 @@ Object.assign(CodemanApp.prototype, { document.getElementById('modalKeepWarmEnabled').checked = session.keepWarm?.enabled ?? session.keepWarmEnabled ?? false; this.updateKeepWarmStatus(sessionId); - // Git: the case's own choice if it has one, otherwise the app setting. updateGitFetchStatus is also + // Git: the session's own choice if it has one, otherwise the app setting. updateGitFetchStatus is also // what SSE calls when a scheduled run lands, so the rows stay current while the modal is open. this.updateGitFetchStatus(sessionId); document.getElementById('modalImageWatcherEnabled').checked = session.imageWatcherEnabled ?? true; @@ -3032,11 +3032,15 @@ Object.assign(CodemanApp.prototype, { }, /** - * The Git tab: the case's last scheduled run, and which values its rows show. A case with no choice of its - * own follows the app setting, so its switch and interval display the app's numbers and go inert — the same - * inert-row pattern as updateKeepWarmStatus above — because changing them here is what makes the case stop - * following. The app numbers come from the settings cache, so what this shows can be one settings load + * The Git tab: the session's last scheduled run, and which values its rows show. A session with no choice of + * its own follows the app setting, so its switch and interval display the app's numbers and go inert (the + * same inert-row pattern as updateKeepWarmStatus above), because changing them here is what makes the session + * stop following. The app numbers come from the settings cache, so what this shows can be one settings load * behind; the server decides from a fresh read, not from what the page displays. + * + * SSE calls this on every `session:updated` for the open session (app.js `_onSessionUpdated`), and a working + * session sends one on each working/idle change, so a control the user is in (`document.activeElement`) is + * left as it is: rewriting it put a half-typed interval back to the stored one before `onchange` could save it. */ updateGitFetchStatus(sessionId) { const statusEl = document.getElementById('gitFetchStatus'); @@ -3048,15 +3052,17 @@ Object.assign(CodemanApp.prototype, { if (!session) return; const app = this.loadAppSettingsFromStorage() || {}; - // No choice of its own on either field means the session follows the app. A partial one — its own switch, - // the app's interval — means it does not, and the row that still follows shows the app's value. + // No choice of its own on either field means the session follows the app. A partial one (its own switch, + // the app's interval) means it does not, and the row that still follows shows the app's value, which is + // also the value the scheduler runs it with (a default here would show one number and fetch at another). const follows = session.gitFetchEnabled === undefined && session.gitFetchIntervalSeconds === undefined; - const enabled = follows ? app.gitFetchEnabled === true : session.gitFetchEnabled ?? false; - const interval = follows ? app.gitFetchIntervalSeconds ?? 300 : session.gitFetchIntervalSeconds ?? 300; + const enabled = session.gitFetchEnabled ?? (app.gitFetchEnabled === true); + const interval = session.gitFetchIntervalSeconds ?? app.gitFetchIntervalSeconds ?? 300; - followBox.checked = follows; - box.checked = enabled; - num.value = interval; + const focused = document.activeElement; + if (followBox !== focused) followBox.checked = follows; + if (box !== focused) box.checked = enabled; + if (num !== focused) num.value = interval; for (const el of [box, num]) { el.closest('.set-row')?.classList.toggle('set-row-disabled', follows); el.disabled = follows; @@ -3085,16 +3091,17 @@ Object.assign(CodemanApp.prototype, { async autoSaveGitFetch() { if (!this.editingSessionId) return; const follow = document.getElementById('modalGitFetchFollow').checked; + const num = document.getElementById('modalGitFetchInterval'); // `null` on the route means "no choice of your own", which is what following the app setting is. const body = follow ? { enabled: null, intervalSeconds: null } : { enabled: document.getElementById('modalGitFetchEnabled').checked, - intervalSeconds: Math.min( - 3600, - Math.max(30, parseInt(document.getElementById('modalGitFetchInterval').value, 10) || 300) - ), + intervalSeconds: Math.min(3600, Math.max(30, parseInt(num.value, 10) || 300)), }; + // Enter fires `onchange` with the field still focused, and updateGitFetchStatus leaves a focused field + // alone, so the clamped number is written back here or the field would keep showing what was typed. + if (!follow) num.value = body.intervalSeconds; try { // `_apiPost` hands back the Response without checking it, so a refused body (a 400) still read as a // save. `_apiJson` is the one that says whether the call went through, and null here means it did not. diff --git a/src/web/public/styles.css b/src/web/public/styles.css index 817761c27..1ca5cd433 100644 --- a/src/web/public/styles.css +++ b/src/web/public/styles.css @@ -7151,33 +7151,6 @@ html[data-skin="og"] .welcome-primary:hover { background: var(--bg-hover); } -/* A Fetch button cannot be clicked twice while its fetch is running, and is not offered for a session - there is nothing to fetch for (no repository, remote or Docker). */ -.btn-icon-sm[disabled] { - color: var(--text-dim); - opacity: 0.45; - cursor: default; -} - -.btn-icon-sm[disabled]:hover { - background: none; -} - -.btn-icon-sm[aria-busy='true'] { - color: var(--text); - animation: btn-icon-busy 1.1s ease-in-out infinite; -} - -@keyframes btn-icon-busy { - 0%, - 100% { - opacity: 0.55; - } - 50% { - opacity: 0.9; - } -} - .settings-section { margin-bottom: 1.25rem; padding-bottom: 1rem; @@ -22175,6 +22148,42 @@ html .toolbar .btn-git-status[aria-expanded='true'] { gap: 0.25rem; } +/* A Fetch button cannot be clicked twice while its fetch is running, and is not offered for a session + there is nothing to fetch for (no repository, remote or Docker). Scoped to the Git panel's header: the + File Viewer's Back/Forward and the folder grid's Up button are .btn-icon-sm too, and keep their own look. */ +.git-status-actions .btn-icon-sm[disabled] { + color: var(--text-dim); + opacity: 0.45; + cursor: default; +} + +.git-status-actions .btn-icon-sm[disabled]:hover { + background: none; +} + +.git-status-actions .btn-icon-sm[aria-busy='true'] { + color: var(--text); + animation: btn-icon-busy 1.1s ease-in-out infinite; +} + +@keyframes btn-icon-busy { + 0%, + 100% { + opacity: 0.55; + } + 50% { + opacity: 0.9; + } +} + +/* No pulse under reduced motion: the button holds the pulse's brighter step, still dimmer than at rest. */ +@media (prefers-reduced-motion: reduce) { + .git-status-actions .btn-icon-sm[aria-busy='true'] { + animation: none; + opacity: 0.7; + } +} + .git-status-body { flex: 1; overflow-y: auto; diff --git a/src/web/server.ts b/src/web/server.ts index 798d32082..42a73c4a2 100644 --- a/src/web/server.ts +++ b/src/web/server.ts @@ -1550,7 +1550,7 @@ export class WebServer extends EventEmitter { ): Promise<void> { const session = this.sessions.get(sessionId); this.keepWarmService.remove(sessionId); - // A closed case takes its place in the fetch schedule with it: otherwise a detached-and-forgotten id + // A closed session takes its place in the fetch schedule with it: otherwise a detached-and-forgotten id // would still count as tracked, and its next run would land on a session that no longer exists. this.gitFetchScheduler.remove(sessionId); const pinned = session?.pinned === true || this.store.getSession(sessionId)?.pinned === true; @@ -3819,9 +3819,9 @@ export class WebServer extends EventEmitter { // below, a future `expiresAt` re-arms the timer once the pane is attached. lastRealPromptAt: savedState?.lastRealPromptAt, keepWarmEnabled: savedState?.keepWarmEnabled, - // The case's own fetch switch and interval, carried over rather than reset to the app default: + // The session's own fetch switch and interval, carried over rather than reset to the app default: // the choice is the user's and outlives the run that made it. The scheduler reads them fresh - // on its next tick, so a restored case is back on its own schedule without a restart. + // on its next tick, so a restored session is back on its own schedule without a restart. gitFetchEnabled: savedState?.gitFetchEnabled, gitFetchIntervalSeconds: savedState?.gitFetchIntervalSeconds, // Conversations this pane provably owned, oldest first. Its tail is diff --git a/test/git-fetch-session-options-ui.test.ts b/test/git-fetch-session-options-ui.test.ts new file mode 100644 index 000000000..ec2ec44aa --- /dev/null +++ b/test/git-fetch-session-options-ui.test.ts @@ -0,0 +1,155 @@ +/** + * @fileoverview The Session Options → Git rows (session-ui.js `updateGitFetchStatus`) as the page drives them: the + * real index.html in JSDOM with the real app.js and session-ui.js loaded into it, and every session update + * delivered through app.js `_onSessionUpdated`, the path a `session:updated` SSE event takes. + * + * 1. A working session sends `session:updated` on each working/idle change, and that path refreshes the open Git + * tab every time, so the control the user is in is left alone: rewriting it put a half-typed "12" back to "300" + * before `onchange` could save it. Enter saves with the field still focused, so the save writes the clamped + * number back itself. + * 2. The route takes one field at a time, so a session can hold half an override. The half it did not choose + * shows the app's value, which is what the scheduler runs it with, never a default. + * + * Port: N/A (no server; the one save is answered by a stub). + */ +import { readFileSync } from 'node:fs'; +import { resolve } from 'node:path'; +import vm from 'node:vm'; +import { JSDOM } from 'jsdom'; +import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'; + +const PUBLIC = resolve(import.meta.dirname, '../src/web/public'); +const SID = 'session-git-rows'; +/** The app setting every case here runs against: on, every 900 s. */ +const APP = { gitFetchEnabled: true, gitFetchIntervalSeconds: 900 }; + +type Update = Record<string, unknown> & { id: string }; +type TestApp = Record<string, unknown> & { + _onSessionUpdated(data: Update): void; + autoSaveGitFetch(): Promise<void>; + _apiJson: ReturnType<typeof vi.fn>; +}; + +let dom: JSDOM; +let CodemanApp: { prototype: object }; + +beforeAll(async () => { + dom = new JSDOM(readFileSync(resolve(PUBLIC, 'index.html'), 'utf8'), { + runScripts: 'outside-only', + url: 'http://localhost/', + }); + // app.js starts the app on DOMContentLoaded. Loading it once the document is complete means that never fires + // here, so the real constructor (SSE, WebSocket, timers) never runs in a window with no server behind it. + await new Promise<void>((done) => + dom.window.document.readyState === 'complete' ? done() : dom.window.addEventListener('load', () => done()) + ); + const context = dom.getInternalVMContext(); + // app.js's last line publishes MobileDetection (mobile-handlers.js), which nothing here otherwise needs. + vm.runInContext('var MobileDetection = { isTouchDevice: () => false };', context); + for (const file of ['constants.js', 'app.js', 'session-ui.js']) { + vm.runInContext(readFileSync(resolve(PUBLIC, file), 'utf8'), context, { filename: file }); + } + CodemanApp = vm.runInContext('CodemanApp', context); +}); + +afterAll(() => dom.window.close()); + +beforeEach(() => { + (dom.window.document.activeElement as HTMLElement | null)?.blur?.(); +}); + +const el = <T extends HTMLElement = HTMLInputElement>(id: string) => dom.window.document.getElementById(id) as T; + +/** An app with the Git tab open on SID and everything the update path draws besides it stubbed. */ +function openGitTab(): TestApp { + const app = Object.create(CodemanApp.prototype) as TestApp; + Object.assign(app, { + sessions: new Map(), + _closingSessions: new Set(), + activeSessionId: null, + editingSessionId: SID, + renderSessionTabs: vi.fn(), + updateCost: vi.fn(), + updateSubagentParentNames: vi.fn(), + showToast: vi.fn(), + loadAppSettingsFromStorage: () => ({ ...APP }), + }); + return app; +} + +describe('Session Options → Git, under session updates', () => { + it('leaves the interval the user is typing alone when a session update arrives, and still refreshes the run line', () => { + const app = openGitTab(); + app._onSessionUpdated({ id: SID, status: 'idle', gitFetchEnabled: true, gitFetchIntervalSeconds: 300 }); + const num = el('modalGitFetchInterval'); + expect(num.disabled).toBe(false); + expect(num.value).toBe('300'); + + num.focus(); + expect(dom.window.document.activeElement).toBe(num); + num.value = '12'; + // The working/idle flip that used to put the field back to 300, carrying a scheduled run's result this time. + app._onSessionUpdated({ + id: SID, + status: 'busy', + gitFetchEnabled: true, + gitFetchIntervalSeconds: 300, + gitFetch: { ranAt: Date.now(), trigger: 'interval', state: 'ok', repos: 2, failed: 0 }, + }); + expect(num.value).toBe('12'); + expect(el('gitFetchStatus').textContent).toBe('Last run just now on the interval · 2 repositories'); + + // Only the control the user is in is spared: once they leave it, an update shows the stored value again. + num.blur(); + app._onSessionUpdated({ id: SID, status: 'idle', gitFetchEnabled: true, gitFetchIntervalSeconds: 300 }); + expect(num.value).toBe('300'); + }); + + it('writes the clamped interval back when Enter saves with the field still focused', async () => { + const app = openGitTab(); + app._apiJson = vi.fn(async (_url: string, opts: { body: unknown }) => ({ gitFetchSettings: opts.body })); + app._onSessionUpdated({ id: SID, status: 'idle', gitFetchEnabled: true, gitFetchIntervalSeconds: 300 }); + const num = el('modalGitFetchInterval'); + num.focus(); + num.value = '12'; + + await app.autoSaveGitFetch(); + expect(app._apiJson).toHaveBeenCalledWith(`/api/sessions/${SID}/git-fetch-settings`, { + method: 'POST', + body: { enabled: true, intervalSeconds: 30 }, + }); + expect(dom.window.document.activeElement).toBe(num); + expect(num.value).toBe('30'); + }); +}); + +describe('Session Options → Git, with half an override', () => { + it("shows the app's switch beside an interval-only override, because the scheduler runs it with that switch", () => { + const app = openGitTab(); + app._onSessionUpdated({ id: SID, status: 'idle', gitFetchIntervalSeconds: 120 }); + expect(el('modalGitFetchFollow').checked).toBe(false); + expect(el('modalGitFetchEnabled').checked).toBe(true); + expect(el('modalGitFetchInterval').value).toBe('120'); + // Not following, so both rows are live, and touching either saves what they show. + expect(el('modalGitFetchEnabled').disabled).toBe(false); + expect(el('modalGitFetchInterval').disabled).toBe(false); + }); + + it("shows the app's interval beside a switch-only override, because the scheduler runs it at that interval", () => { + const app = openGitTab(); + app._onSessionUpdated({ id: SID, status: 'idle', gitFetchEnabled: true }); + expect(el('modalGitFetchFollow').checked).toBe(false); + expect(el('modalGitFetchEnabled').checked).toBe(true); + expect(el('modalGitFetchInterval').value).toBe('900'); + }); + + it('follows the app on both rows, inert, when the session holds no choice of its own', () => { + const app = openGitTab(); + app._onSessionUpdated({ id: SID, status: 'idle' }); + expect(el('modalGitFetchFollow').checked).toBe(true); + expect(el('modalGitFetchEnabled').checked).toBe(true); + expect(el('modalGitFetchInterval').value).toBe('900'); + expect(el('modalGitFetchEnabled').disabled).toBe(true); + expect(el('modalGitFetchInterval').disabled).toBe(true); + }); +}); diff --git a/test/git-fetch.test.ts b/test/git-fetch.test.ts index 8cf58a4ae..918214eeb 100644 --- a/test/git-fetch.test.ts +++ b/test/git-fetch.test.ts @@ -246,6 +246,13 @@ describe('fetchEnvFor', () => { const env = fetchEnvFor({ ...BASE, GIT_SSH_COMMAND: 'ssh -i /home/t/personal' }, 'ssh -i /repo/key'); expect(env.GIT_SSH_COMMAND).toBe('ssh -i /home/t/personal'); }); + + it("leaves a GIT_SSH in the server's environment to git, which reads it last and would never see it under a written default", () => { + const env = fetchEnvFor({ ...BASE, GIT_SSH: '/home/t/bin/work-ssh' }, null); + expect(env.GIT_SSH_COMMAND).toBeUndefined(); + expect(env.GIT_SSH).toBe('/home/t/bin/work-ssh'); + expect(env.GIT_TERMINAL_PROMPT).toBe('0'); + }); }); // ─── Real git, real local repository ───────────────────────────────────────── @@ -558,6 +565,42 @@ describe.skipIf(!gitPresent)('runFetch (the whole tree, not just the git parent) }); }); +// ─── One bound for the config read and the fetch ───────────────────────────── + +describe.skipIf(process.platform === 'win32')('runFetch (one bound for the config read and the fetch)', () => { + it('answers at the timeout when git never answers, not at twice it, because the core.sshCommand read spends part of the bound', async () => { + // A `git` that never answers anything, first on PATH, so the read ahead of the fetch hangs exactly as long as + // the fetch would. Both spawns take PATH from process.env at call time. It records its arguments, so the test + // can tell which of the two it was asked to run. + const bin = join(dir, 'bin'); + const calls = join(dir, 'calls'); + mkdirSync(bin); + writeFileSync(join(bin, 'git'), `#!/bin/sh\necho "$*" >> '${calls}'\nexec sleep 30\n`, { mode: 0o755 }); + const savedPath = process.env.PATH; + process.env.PATH = `${bin}:${savedPath ?? ''}`; + try { + const started = Date.now(); + const err = (await runFetch(dir, buildFetchArgs(), { timeoutMs: 1_000 }).catch((e) => e)) as { + code?: unknown; + killed?: boolean; + }; + const elapsed = Date.now() - started; + + expect(err.code).toBe('ETIMEDOUT'); + expect(err.killed).toBe(true); + expect(describeFetchFailure(err).code).toBe('TIMEOUT'); + // One bound, not one per spawn: the read used all of it, so the fetch got nothing and was not started + // (2019 ms here before the fix, when the fetch was armed with a fresh timeout of its own). + expect(elapsed).toBeGreaterThanOrEqual(950); + expect(elapsed).toBeLessThan(1_600); + expect(readFileSync(calls, 'utf8').trim().split('\n')).toEqual(['config --get core.sshCommand']); + } finally { + if (savedPath === undefined) delete process.env.PATH; + else process.env.PATH = savedPath; + } + }); +}); + // ─── One run per folder, and one bound for the whole tick ──────────────────── describe.skipIf(!gitPresent)('fetchWorkspaceOnce (the join)', () => { From 6368128f1e951353511a6b97c4da1cd21f34b997 Mon Sep 17 00:00:00 2001 From: Codeman maintainer <noreply@anthropic.com> Date: Tue, 27 Oct 2026 02:07:39 +0100 Subject: [PATCH 57/68] fix: apply the review items for #694 at merge - src/web/routes/session-routes.ts:2102: the guarded-submit 409 now applies to tagged input (clientId + seq) only; untagged input is held like a WebSocket frame. The raw write (:2228) goes through holdForGuardedLease and answers 200 once held (a send-and-wait reads `delivered` from the held write; a full queue releases the waiter and answers 409); a mux write takes its own turn in writeViaMux. - test/guarded-lease-held-input.test.ts: real-Session route tests for an untagged raw POST during the lease (written after the Enter) and a held send-and-wait (delivered: true); both fail without the fix. - test/routes/session-routes.test.ts + test/mocks/mock-session.ts: the 409 test now covers tagged input only; new tests for untagged raw (held), untagged mux (own turn) and a full hold queue (409); MockSession gains holdForGuardedLease/releaseGuardedLease and a lease-aware writeViaMux. - src/session.ts:4787 (guardedInputHeld JSDoc) + write() + docs/architecture-invariants.md: write() refuses during the lease only, so cron's paste mode can land ahead of input still draining. - docs/api-reference.md: the input route's 409 is for tagged input; untagged input is held. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- docs/api-reference.md | 30 ++++++----- docs/architecture-invariants.md | 11 ++++- src/session.ts | 4 ++ src/web/routes/session-routes.ts | 53 +++++++++++++++++--- test/guarded-lease-held-input.test.ts | 57 ++++++++++++++++++++- test/mocks/mock-session.ts | 27 ++++++++-- test/routes/session-routes.test.ts | 71 +++++++++++++++++++++++++-- 7 files changed, 221 insertions(+), 32 deletions(-) diff --git a/docs/api-reference.md b/docs/api-reference.md index 2476f9835..1be7dc6ff 100644 --- a/docs/api-reference.md +++ b/docs/api-reference.md @@ -426,11 +426,12 @@ worker's prompt but never submitted, and the wait then runs its full timeout on turn that never started. Verified live; this is the most common silent failure on this endpoint. -Any input, waiting or not, answers `409 CONFLICT` with nothing written while an -experimental guarded submit holds the session's input lease, or input held behind -it is still being written (a few milliseconds; see -[guarded input](#experimental-bounded-agent-inspection-and-guarded-input)). -Retry it. +Input tagged with `clientId` and `seq`, waiting or not, answers `409 CONFLICT` +with nothing written while an experimental guarded submit holds the session's +input lease, or input held behind it is still being written (a few milliseconds; +see [guarded input](#experimental-bounded-agent-inspection-and-guarded-input)). +Retry it. Untagged input is held instead and written in arrival order once the +lease is released. A **plain prompt** (printable text followed by exactly one `\r`, nothing else) is delivered through tmux even without `useMux`: the text is typed, Enter is pressed as @@ -716,15 +717,18 @@ outcome. That covers terminal WebSocket keystrokes (each frame is acknowledged only once it is written, so a frame typed during the lease lands before the one typed after it), Shift+Enter and Ctrl+Enter from `POST .../send-key`, approval answers and Codeman's own managed writes. A held write that is still being sent -holds everything behind it. `POST /api/v1/sessions/:id/input` instead answers -`409 CONFLICT` ("A guarded submit is in progress on this session; nothing was -written") for that span, the lease plus the held input's drain, rather than a -`200` for bytes that were never written; a tagged `clientId`/`seq` frame keeps -its seq, so the browser's durable queue, whose POST fallback sends one frame at a -time and keeps it on a `409`, delivers it on the next retry. A lease holds at -most 256 inputs or 1 MiB; past that a WebSocket is closed with code `1013` and +holds everything behind it. A tagged `POST /api/v1/sessions/:id/input` (one +carrying `clientId` and `seq`) instead answers `409 CONFLICT` ("A guarded submit +is in progress on this session; nothing was written") for that span, the lease +plus the held input's drain, rather than a `200` for bytes that were never +written; it keeps its seq, so the browser's durable queue, whose POST fallback +sends one frame at a time and keeps it on a `409`, delivers it on the next retry. +Untagged input to that route is held like a WebSocket frame, because its senders +(the phone accessory keys, the Stop button) never read the answer. A lease holds +at most 256 inputs or 1 MiB; past that a WebSocket is closed with code `1013` and nothing later from it is applied, so the browser reconnects and resends every -unacknowledged frame in order, and `send-key` answers `409 CONFLICT`. +unacknowledged frame in order, and `send-key` and an untagged raw input answer +`409 CONFLICT`. Direct auxiliary key operations participate in the same pending-write and input lease checks. Complete allowlisted focus/mouse reports and empty writes preserve diff --git a/docs/architecture-invariants.md b/docs/architecture-invariants.md index 2e2495eb7..34016ed21 100644 --- a/docs/architecture-invariants.md +++ b/docs/architecture-invariants.md @@ -1330,8 +1330,15 @@ N+1 and its redelivery dropped as a duplicate. A WS frame is ACKed only once wri a held async write (mux write, named key) holds everything behind it until it settles (`guardedInputHeld`); a full hold queue (256 inputs / 1 MiB) closes the socket with `1013` and applies nothing later from it, so the browser resends in order. `POST -.../input` alone answers `409` (seq returned) for the lease plus the drain, since its -browser drainer is serialized and retries. ⚠️ Submit waits `SUBMIT_QUIET_MS` (250) after the staged keys before its +.../input` answers `409` (seq returned) to TAGGED input for the lease plus the drain, +since its browser drainer is serialized and retries; untagged input there (the phone +accessory keys and the Stop button's Ctrl+C, fire-and-forget POSTs that never read the +answer) is held instead, the raw write through `holdForGuardedLease()` and a mux write +by `writeViaMux`'s own turn (never wrap `writeViaMux` in a hold: the drain would queue +it again behind later input and wait on itself). `write()` refuses during the lease +only, since the drain writes held input through it, so a direct `write()` caller such +as cron's `paste` mode (`cron-service.ts`) can land ahead of input still draining. +⚠️ Submit waits `SUBMIT_QUIET_MS` (250) after the staged keys before its checks: stage types plain keys, codex's paste-burst detector reads them as a paste, and an Enter within 120 ms of one becomes a newline while submit would report it delivered. Stage cancels older verifier retries and sends no Enter. Receipts contain hashes and diff --git a/src/session.ts b/src/session.ts index dc43c4ff2..08dd83d35 100644 --- a/src/session.ts +++ b/src/session.ts @@ -4784,6 +4784,9 @@ export class Session extends EventEmitter { * True while input is being held behind a guarded submit: the lease itself, or held * input not yet written after it. A write that is not routed through * {@link holdForGuardedLease} must refuse in this window, or it overtakes held input. + * `write()` is the known exception: it refuses during the lease only, since the drain + * writes held input through it, so a direct `write()` caller (cron's `paste` mode in + * `cron/cron-service.ts`) can still land ahead of input that is draining. */ get guardedInputHeld(): boolean { return this._guardedSubmitInFlight || this._guardedHeld.length > 0 || this._guardedHeldDraining; @@ -4983,6 +4986,7 @@ export class Session extends EventEmitter { */ write(data: string, options: SessionWriteOptions = {}): boolean { this._observeGuardedKeyboardInput(data); + // The lease only, not the drain, which writes held input through here (see guardedInputHeld). if (this._guardedSubmitInFlight) return false; const submittedPrompt = this._trackSubmit(data, options); if (!this.ptyProcess) return false; diff --git a/src/web/routes/session-routes.ts b/src/web/routes/session-routes.ts index ae4a523f8..1d72f9863 100644 --- a/src/web/routes/session-routes.ts +++ b/src/web/routes/session-routes.ts @@ -2080,15 +2080,18 @@ export function registerSessionRoutes( // A guarded submit (agent-staging.ts) holds the input lease for the few // milliseconds of its conditional Enter, and input that arrives meanwhile is held - // and written in arrival order after it. This route refuses for that span (the - // lease plus the held input's drain) rather than overtake held input. Say so + // and written in arrival order after it. A TAGGED request is refused for that span + // (the lease plus the held input's drain) rather than overtake held input: say so // instead of answering 200 for bytes that were never written, and give the seq // back so the browser's durable queue (which keeps a frame on any 409, and whose - // POST drainer sends one frame at a time) delivers it on its next retry. Nothing - // from here to the write below awaits, so the lease cannot be taken in between. - if (!duplicate && session.guardedInputHeld === true) { - if (tagged) session.forgetInputSeq(clientId as string, seq as number); - return reply + // POST drainer sends one frame at a time) delivers it on its next retry. An + // UNTAGGED request has no retry behind it (the phone accessory keys and the Stop + // button's Ctrl+C are fire-and-forget POSTs that never read the answer), so it is + // held instead, like a WebSocket frame: `writeViaMux` takes its own turn behind + // the lease, and the raw write below is held explicitly. Nothing from here to the + // write below awaits, so the lease cannot be taken in between. + const refuseGuarded = () => + reply .code(409) .send( createErrorResponse( @@ -2096,6 +2099,9 @@ export function registerSessionRoutes( 'A guarded submit is in progress on this session; nothing was written. Retry the input.' ) ); + if (!duplicate && tagged && session.guardedInputHeld === true) { + session.forgetInputSeq(clientId as string, seq as number); + return refuseGuarded(); } // Only a waiting request pays for the tmux probe: the browser's plain input path @@ -2208,7 +2214,38 @@ export function registerSessionRoutes( // Same rollback. NOT an error response, deliberately: a session can // legitimately have no PTY yet (created but not started), and callers have // always been able to write to one without a 4xx. - delivered = session.write(inputStr, { fromUser: true }); + // + // During a guarded submit (untagged only: a tagged one was refused above) the + // write is held and runs in arrival order once the lease is released. The answer + // is a 200 once held; a send-and-wait reads `delivered` from the held write. Only + // a full hold queue refuses, after releasing the waiter registered above. + let heldWrite: Promise<boolean> | null = null; + if (session.guardedInputHeld === true) { + let settle!: (written: boolean) => void; + const written = new Promise<boolean>((resolve) => { + settle = resolve; + }); + const held = session.holdForGuardedLease( + () => { + let ok = false; + try { + ok = session.write(inputStr, { fromUser: true }); + } finally { + settle(ok); + } + }, + inputStr.length, + inputStr + ); + if (held === 'full') { + abort.abort(); + stopDeathWatch(); + return refuseGuarded(); + } + if (held === 'held') heldWrite = written; + } + if (heldWrite && !waitPromise) return {}; + delivered = heldWrite ? await heldWrite : session.write(inputStr, { fromUser: true }); if (!delivered && tagged) { session.forgetInputSeq(clientId as string, seq as number); } diff --git a/test/guarded-lease-held-input.test.ts b/test/guarded-lease-held-input.test.ts index ffeb0a99e..31191fcb6 100644 --- a/test/guarded-lease-held-input.test.ts +++ b/test/guarded-lease-held-input.test.ts @@ -9,12 +9,14 @@ * is still unACKed afterwards, in seq order, the way `_redeliverSweep` does. */ import { EventEmitter } from 'node:events'; -import { afterEach, describe, expect, it } from 'vitest'; +import { afterEach, describe, expect, it, vi } from 'vitest'; import type { FastifyInstance } from 'fastify'; import { formatPaneSnapshot } from '../src/tmux-manager.js'; import { GuardedInputBusyError, Session } from '../src/session.js'; import { registerWsRoutes } from '../src/web/routes/ws-routes.js'; +import { registerSessionRoutes } from '../src/web/routes/session-routes.js'; import type { SessionPort } from '../src/web/ports/session-port.js'; +import { createRouteTestHarness } from './routes/_route-test-utils.js'; type Frame = { t: 'i'; d: string; seq?: number; cid?: string }; @@ -376,6 +378,59 @@ describe('input during a guarded submit lease', () => { } }); + it('holds an untagged raw POST .../input during the lease and writes it after the Enter', async () => { + const h = harness(); + const route = await createRouteTestHarness(registerSessionRoutes); + try { + (route.ctx.sessions as unknown as Map<string, Session>).set(h.session.id, h.session); + const submit = h.session.submitGuardedInput(h.session.guardedInputSnapshot()!); + await Promise.resolve(); + expect(h.session.guardedSubmitInFlight).toBe(true); + // The Stop button's Ctrl+C: an untagged POST whose answer nobody reads. + const res = await route.app.inject({ + method: 'POST', + url: `/api/sessions/${h.session.id}/input`, + payload: { input: '\x03' }, + }); + expect(res.statusCode).toBe(200); + expect(h.pty).toEqual([]); + h.finishEnter(true); + await expect(submit).resolves.toBe(true); + expect(h.pty).toEqual(['\r', '\x03']); + } finally { + await route.app.close(); + await h.stop(); + } + }); + + it('a held untagged raw send-and-wait reports the held write as delivered', async () => { + const h = harness(); + const route = await createRouteTestHarness(registerSessionRoutes); + try { + (route.ctx.sessions as unknown as Map<string, Session>).set(h.session.id, h.session); + const submit = h.session.submitGuardedInput(h.session.guardedInputSnapshot()!); + await Promise.resolve(); + const answer = route.app.inject({ + method: 'POST', + url: `/api/sessions/${h.session.id}/input`, + payload: { input: '\x03', useMux: false, wait: 'exit', waitTimeout: 1000 }, + }); + // Private, but the only proof the route reached the hold before the lease ends. + const queue = (h.session as unknown as { _guardedHeld: unknown[] })._guardedHeld; + await vi.waitFor(() => expect(queue).toHaveLength(1)); + expect(h.pty).toEqual([]); + h.finishEnter(true); + await submit; + const res = await answer; + expect(res.statusCode).toBe(200); + expect(res.json().data).toMatchObject({ delivered: true, duplicate: false }); + expect(h.pty).toEqual(['\r', '\x03']); + } finally { + await route.app.close(); + await h.stop(); + } + }); + it('a keystroke held before the guard runs still invalidates the Enter, then lands', async () => { const h = harness(true); try { diff --git a/test/mocks/mock-session.ts b/test/mocks/mock-session.ts index fa2b8c5e8..d5df61040 100644 --- a/test/mocks/mock-session.ts +++ b/test/mocks/mock-session.ts @@ -111,6 +111,22 @@ export class MockSession extends EventEmitter { await operation(); } + /** Mirrors Session.guardedInputHeld; a route test sets it to stand in for a guarded submit's lease. */ + guardedInputHeld = false; + /** Input held behind that lease, in arrival order (see releaseGuardedLease). */ + heldGuardedInput: Array<() => void | Promise<unknown>> = []; + /** Mirrors Session.holdForGuardedLease, without the cap (a test replaces it to model a full queue). */ + holdForGuardedLease(run: () => void | Promise<unknown>, _bytes = 0, _data?: string): 'free' | 'held' | 'full' { + if (!this.guardedInputHeld) return 'free'; + this.heldGuardedInput.push(run); + return 'held'; + } + /** Ends the stand-in lease and runs the held input in arrival order, as Session does. */ + releaseGuardedLease(): void { + this.guardedInputHeld = false; + for (const run of this.heldGuardedInput.splice(0)) void run(); + } + private _muxName: string | null = null; constructor(id: string = 'mock-session-id') { @@ -134,9 +150,14 @@ export class MockSession extends EventEmitter { /** Write via mux (used by respawn controller) */ async writeViaMux(data: string): Promise<boolean> { - if (this.failWrites) return false; - this.writeBuffer.push(data); - return true; + const write = (): boolean => { + if (this.failWrites) return false; + this.writeBuffer.push(data); + return true; + }; + // Like Session.writeViaMux, it waits its turn behind a stand-in guarded lease. + if (!this.guardedInputHeld) return write(); + return new Promise<boolean>((resolve) => this.heldGuardedInput.push(() => resolve(write()))); } /** diff --git a/test/routes/session-routes.test.ts b/test/routes/session-routes.test.ts index cb9d48915..39940a962 100644 --- a/test/routes/session-routes.test.ts +++ b/test/routes/session-routes.test.ts @@ -1224,7 +1224,7 @@ describe('session-routes', () => { expect(session.writeBuffer).toEqual(['prompt', '\r']); }); - it('refuses with 409 and gives the seq back while a guarded submit holds the input lease', async () => { + it('refuses tagged input with 409 and gives the seq back while a guarded submit holds the input lease', async () => { const url = `/api/sessions/${harness.ctx._sessionId}/input`; // eslint-disable-next-line @typescript-eslint/no-explicit-any const session = harness.ctx.sessions.get(harness.ctx._sessionId) as any; @@ -1232,10 +1232,8 @@ describe('session-routes', () => { const post = (payload: unknown) => harness.app.inject({ method: 'POST', url, payload }); session.guardedInputHeld = true; for (const payload of [ - { input: 'x' }, - { input: 'x', useMux: true }, - { input: 'prompt\r' }, { input: 'tagged', seq: 7, clientId: 'cid-guard' }, + { input: 'prompt\r', useMux: true, seq: 1, clientId: 'cid-guard-mux' }, ]) { const refused = await post(payload); expect(refused.statusCode, JSON.stringify(payload)).toBe(409); @@ -1243,13 +1241,76 @@ describe('session-routes', () => { expect(refused.json().error).toMatch(/guarded submit is in progress.*nothing was written/); } expect(session.writeBuffer).toEqual([]); + expect(session.heldGuardedInput).toEqual([]); // Once the lease is released the browser's retry of the same seq is applied, not // rejected as a duplicate. - session.guardedInputHeld = false; + session.releaseGuardedLease(); expect((await post({ input: 'tagged', seq: 7, clientId: 'cid-guard' })).statusCode).toBe(200); expect(session.writeBuffer).toEqual(['tagged']); }); + it('holds an untagged raw POST during a guarded submit and writes it once the lease is released', async () => { + // The phone accessory keys and the Stop button's Ctrl+C are untagged, fire-and-forget + // POSTs that never read the answer, so a 409 here silently lost the tap. + const url = `/api/sessions/${harness.ctx._sessionId}/input`; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const session = harness.ctx.sessions.get(harness.ctx._sessionId) as any; + session.writeBuffer.length = 0; + session.guardedInputHeld = true; + try { + for (const input of ['\x03', '\x1b[A']) { + const res = await harness.app.inject({ method: 'POST', url, payload: { input } }); + expect(res.statusCode, JSON.stringify(input)).toBe(200); + expect(res.json()).toMatchObject({ success: true }); + } + expect(session.writeBuffer).toEqual([]); + session.releaseGuardedLease(); + expect(session.writeBuffer).toEqual(['\x03', '\x1b[A']); + } finally { + session.releaseGuardedLease(); + } + }); + + it('lets untagged mux input wait its own turn behind the lease instead of refusing it', async () => { + const url = `/api/sessions/${harness.ctx._sessionId}/input`; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const session = harness.ctx.sessions.get(harness.ctx._sessionId) as any; + session.writeBuffer.length = 0; + const post = (payload: unknown) => harness.app.inject({ method: 'POST', url, payload }); + session.guardedInputHeld = true; + try { + // Fire-and-forget `useMux`: answered at once, written in its turn. + expect((await post({ input: 'x', useMux: true })).statusCode).toBe(200); + // A plain prompt's mux write is awaited, so it answers once written. + const prompt = post({ input: 'prompt\r' }); + await vi.waitFor(() => expect(session.heldGuardedInput).toHaveLength(2)); + expect(session.writeBuffer).toEqual([]); + session.releaseGuardedLease(); + expect((await prompt).statusCode).toBe(200); + expect(session.writeBuffer).toEqual(['x', 'prompt\r']); + } finally { + session.releaseGuardedLease(); + } + }); + + it('refuses an untagged raw POST with 409 only when the hold queue is full', async () => { + const url = `/api/sessions/${harness.ctx._sessionId}/input`; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const session = harness.ctx.sessions.get(harness.ctx._sessionId) as any; + session.writeBuffer.length = 0; + session.guardedInputHeld = true; + session.holdForGuardedLease = () => 'full'; + try { + const res = await harness.app.inject({ method: 'POST', url, payload: { input: '\x03' } }); + expect(res.statusCode).toBe(409); + expect(res.json()).toMatchObject({ success: false, errorCode: 'CONFLICT' }); + expect(session.writeBuffer).toEqual([]); + } finally { + delete session.holdForGuardedLease; + session.releaseGuardedLease(); + } + }); + it('always applies untagged input (curl/legacy, no dedup)', async () => { const url = `/api/sessions/${harness.ctx._sessionId}/input`; // eslint-disable-next-line @typescript-eslint/no-explicit-any From da740d4b004c33dec53a3b5028a86ab6ab227066 Mon Sep 17 00:00:00 2001 From: Codeman maintainer <noreply@anthropic.com> Date: Tue, 27 Oct 2026 02:53:08 +0100 Subject: [PATCH 58/68] fix: stop logging settings.json contents from the git-fetch tick, and log an unreadable file once (release review) The settings readers for git fetch and keep-warm no longer repeat V8's JSON.parse message, which quotes the text around the fault and could print voiceSettings.apiKey. The 15 s scheduler tick now logs an unreadable settings.json once, and again only after a successful read in between. Stale comments that said settings.json is rewritten in place now say writes are atomic. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- src/git-fetch-scheduler.ts | 21 +++++++++++---- src/hooks-config.ts | 24 +++++++++++++---- test/git-fetch-scheduler.test.ts | 45 ++++++++++++++++++++++++++++++-- test/keep-warm-launch.test.ts | 4 +-- 4 files changed, 80 insertions(+), 14 deletions(-) diff --git a/src/git-fetch-scheduler.ts b/src/git-fetch-scheduler.ts index 5f8b1dd27..a6d00578e 100644 --- a/src/git-fetch-scheduler.ts +++ b/src/git-fetch-scheduler.ts @@ -45,8 +45,10 @@ * `DEFAULT_GIT_TIMEOUT_MS`): both settings are per device and never reach the server, so the server * cannot know what a device chose. * - A failure is reported, not retried harder: the folder keeps its schedule and gets its next run at its - * own interval. A settings read that fails skips this tick only, because settings.json is rewritten in - * place and a kept fallback would hold the feature off until the next settings change. + * own interval. A settings read that fails skips this tick only, rather than deciding from defaults. + * settings.json is replaced atomically, so such a failure is a file that does not parse (a hand edit) or + * a real read error, which lasts until someone fixes it: it is logged once, and again only after a read + * has succeeded in between, not every 15 s tick. * * @module git-fetch-scheduler */ @@ -130,6 +132,8 @@ export class GitFetchScheduler { private readonly folderOf = new Map<string, string>(); private running = false; private stopped = false; + /** The last settings read failed and was logged; cleared by the next read that succeeds. */ + private settingsUnreadable = false; constructor(ports: GitFetchPorts) { this.ports = ports; @@ -144,11 +148,18 @@ export class GitFetchScheduler { try { settings = await this.ports.readSettings(); } catch (err) { - // A read can land mid-write. Skipping this tick costs one interval; deciding from a fallback would - // hold the feature off until the next settings change. - this.ports.log?.(`git fetch: settings unreadable, tick skipped: ${getErrorMessage(err)}`); + // Writes are atomic, so this is a real read error or a file that does not parse (a hand edit), and it + // stays that way until it is fixed: logged once, not on every tick. The reader never puts the file's + // text in its error (readGitFetchSettings), since settings.json can hold an API key. + if (!this.settingsUnreadable) { + this.settingsUnreadable = true; + this.ports.log?.( + `git fetch: settings unreadable, ticks skipped until it reads again: ${getErrorMessage(err)}` + ); + } return 0; } + this.settingsUnreadable = false; // Pass 1 — who is on what, before anything is decided. Every session open on a folder is recorded here, // not only the one that would get picked, so `remove()` can still see who is open on it when the session diff --git a/src/hooks-config.ts b/src/hooks-config.ts index 28559ca5c..3c995e133 100644 --- a/src/hooks-config.ts +++ b/src/hooks-config.ts @@ -1171,16 +1171,17 @@ export async function readKeepWarmSettingsOrThrow(): Promise<KeepWarmSettings> { if ((err as NodeJS.ErrnoException).code === 'ENOENT') return keepWarmSettingsFrom({}); throw err; } - return keepWarmSettingsFrom(JSON.parse(text) as Record<string, unknown>); + return keepWarmSettingsFrom(parseSettingsText(text)); } /** * The Git fetch interval's app-wide default (`gitFetchEnabled`, absent = OFF) and its cap * (`gitFetchIntervalSeconds`, seconds between runs). Read FRESH on every tick by the scheduler, so a live * toggle takes effect on the next tick and turning it off actually stops the fetching. A missing file is - * the defaults — OFF — but any other read or parse failure rejects, for the same reason as - * {@link readKeepWarmSettingsOrThrow}: settings.json is rewritten in place, so a read can land mid-write, - * and a fallback kept from it would hold the feature off until the next settings change. + * the defaults (OFF), but any other read or parse failure rejects and the scheduler skips that tick rather + * than deciding from defaults. settings.json is replaced atomically (a temp file renamed into place, see + * settings-writer.ts), so such a failure is a real read error or a file that does not parse (a hand edit), + * never a write in progress, and the scheduler logs it once rather than every tick. */ export async function readGitFetchSettings(): Promise<GitFetchSettings> { let text: string; @@ -1190,7 +1191,20 @@ export async function readGitFetchSettings(): Promise<GitFetchSettings> { if ((err as NodeJS.ErrnoException).code === 'ENOENT') return gitFetchSettingsFrom({}); throw err; } - return gitFetchSettingsFrom(JSON.parse(text) as Record<string, unknown>); + return gitFetchSettingsFrom(parseSettingsText(text)); +} + +/** + * settings.json's text for the two readers above, which reject a file that does not parse. The parser's + * own message is never repeated, as in settings-writer.ts: V8 quotes the text around the fault, settings.json + * can hold an API key (`voiceSettings.apiKey`), and both callers log the error they get. + */ +function parseSettingsText(text: string): Record<string, unknown> { + try { + return JSON.parse(text) as Record<string, unknown>; + } catch { + throw new Error(`${SETTINGS_PATH} is not valid JSON`); + } } function keepWarmSettingsFrom(settings: Record<string, unknown>): KeepWarmSettings { diff --git a/test/git-fetch-scheduler.test.ts b/test/git-fetch-scheduler.test.ts index e38d9e1fd..cd936c3cb 100644 --- a/test/git-fetch-scheduler.test.ts +++ b/test/git-fetch-scheduler.test.ts @@ -11,6 +11,8 @@ * * Port: N/A. */ +import { mkdir, rm, writeFile } from 'node:fs/promises'; +import { dirname } from 'node:path'; import { describe, expect, it, vi } from 'vitest'; import { GitFetchScheduler, @@ -29,6 +31,8 @@ import { } from '../src/git-fetch.js'; import { DEFAULT_GIT_TIMEOUT_MS, MAX_REPOS } from '../src/git-workspace-status.js'; import type { PathProbeKind } from '../src/utils/bounded-path-probe.js'; +import { readGitFetchSettings, readKeepWarmSettingsOrThrow } from '../src/hooks-config.js'; +import { SETTINGS_PATH } from '../src/web/route-helpers.js'; const SETTINGS: GitFetchSettings = { enabled: true, intervalSeconds: 300 }; const VIEW = (id: string, extra: Partial<GitFetchSessionView> = {}): GitFetchSessionView => ({ @@ -109,7 +113,7 @@ function harness( sessionIds: () => sessions.filter((s) => !gone.has(s.id)).map((s) => s.id), getSession: (id) => (gone.has(id) ? undefined : sessions.find((s) => s.id === id)), readSettings: async () => { - if (h.settings === null) throw new Error('settings.json is mid-write'); + if (h.settings === null) throw new Error('settings.json is not valid JSON'); return h.settings; }, dockerWorkspaces: async () => { @@ -155,7 +159,7 @@ describe('a quiet tick', () => { expect(h.calls.length).toBe(1); }); - it('skips this tick only when a settings read fails, because settings.json is rewritten in place', async () => { + it('skips this tick only when a settings read fails, rather than deciding from defaults', async () => { const h = harness([VIEW('a')]); h.settings = null as unknown as GitFetchSettings; const logged = vi.fn(); @@ -168,6 +172,43 @@ describe('a quiet tick', () => { // Nothing was recorded for the failed tick, so the folder is still due at once. expect(await h.scheduler.tick(1000)).toBe(1); }); + + /** + * The tick runs every 15 s whether or not the feature is on, so an unreadable settings.json would be logged + * every 15 s, and V8's parse message quotes the text around the fault: a hand edit with single quotes put + * the API key it held into the log on every tick. + */ + it('logs an unreadable settings.json once until it reads again, and never with the text of the file', async () => { + const secret = 'sk-leak42'; + const broken = `{"voiceSettings":{"apiKey":'${secret}'}}`; + await mkdir(dirname(SETTINGS_PATH), { recursive: true }); + await writeFile(SETTINGS_PATH, broken); + try { + // The real readers: what they reject with is what gets logged. + await expect(readGitFetchSettings()).rejects.toThrow(/not valid JSON/); + await expect(readGitFetchSettings()).rejects.not.toThrow(secret); + await expect(readKeepWarmSettingsOrThrow()).rejects.not.toThrow(secret); + + const h = harness([VIEW('a')]); + h.ports.readSettings = readGitFetchSettings; + const logged = vi.fn(); + h.ports.log = logged; + for (const now of [0, 15_000, 30_000]) expect(await h.scheduler.tick(now)).toBe(0); + expect(h.calls).toEqual([]); + expect(logged).toHaveBeenCalledTimes(1); + + // A read that succeeds in between re-arms the warning for the next failure. + await writeFile(SETTINGS_PATH, JSON.stringify({ gitFetchEnabled: true })); + expect(await h.scheduler.tick(45_000)).toBe(1); + await writeFile(SETTINGS_PATH, broken); + expect(await h.scheduler.tick(400_000)).toBe(0); + expect(await h.scheduler.tick(415_000)).toBe(0); + expect(logged).toHaveBeenCalledTimes(2); + expect(JSON.stringify(logged.mock.calls)).not.toContain(secret); + } finally { + await rm(SETTINGS_PATH, { force: true }); + } + }); }); describe('the bounded probe', () => { diff --git a/test/keep-warm-launch.test.ts b/test/keep-warm-launch.test.ts index 03eb819bc..1c08aa6c1 100644 --- a/test/keep-warm-launch.test.ts +++ b/test/keep-warm-launch.test.ts @@ -159,8 +159,8 @@ describe('readKeepWarmSettings', () => { /** * The service keeps what it reads until the next keep-warm settings change, so its reader must - * never hand back a fallback for a file it could not read: settings.json is rewritten in place - * by several routes, and defaults kept from a mid-write read would hold keep-warm off. + * never hand back a fallback for a file it could not read: defaults kept from a file that does + * not parse (a hand edit; writes are atomic) would hold keep-warm off after it is fixed. */ it('the service reader rejects an unreadable file instead of falling back, and the server wires it', async () => { expect(await readKeepWarmSettingsOrThrow()).toEqual({ keepWarmDefault: false, maxHours: 2 }); From b89f15c12f855ae63c14d9446cdddfbab783232a Mon Sep 17 00:00:00 2001 From: Codeman maintainer <noreply@anthropic.com> Date: Tue, 27 Oct 2026 02:54:19 +0100 Subject: [PATCH 59/68] fix: make the tunnel-guard test fail on a settings.json write that skips the temp file and rename (release review) settingsWrites() now requires every writeFile to be a settings.json temp file that a rename put in place, with one rename per write, and the concurrent-stop harness stores a direct write to settings.json at once. A stray fs.writeFile(SETTINGS_PATH) in the PUT handler passed all 34 tests before and now fails 20 of them. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- .../routes/system-routes-tunnel-guard.test.ts | 36 ++++++++++++++----- 1 file changed, 27 insertions(+), 9 deletions(-) diff --git a/test/routes/system-routes-tunnel-guard.test.ts b/test/routes/system-routes-tunnel-guard.test.ts index 4e556670a..8c46ed7b2 100644 --- a/test/routes/system-routes-tunnel-guard.test.ts +++ b/test/routes/system-routes-tunnel-guard.test.ts @@ -36,12 +36,24 @@ const mockedRename = vi.mocked(fs.rename); /** * settings.json writes as [path, contents]. The writer (settings-writer.ts) writes each one to a - * temp file and renames it over settings.json, so a write counts once its rename lands. + * temp file and renames it over settings.json, so a write counts once its rename lands. Every + * writeFile must be such a temp file and every one must be renamed into place: a direct write to + * settings.json, or any other write that was never renamed, fails here rather than going unseen. */ function settingsWrites(): [string, string][] { - return mockedRename.mock.calls.map(([from, to]) => { - const write = mockedWriteFile.mock.calls.find(([path]) => path === from); - return [String(to), String(write?.[1])]; + const writes = mockedWriteFile.mock.calls.map(([path, contents]) => [String(path), String(contents)]); + const renames = mockedRename.mock.calls.map(([from, to]) => [String(from), String(to)]); + const isTemp = (path: string) => path.startsWith(`${SETTINGS_PATH}.`) && path.endsWith('.tmp'); + const notRenamed = writes.filter(([path]) => !isTemp(path) || !renames.some(([from]) => from === path)); + expect( + notRenamed.map(([path]) => path), + 'writes that were not a temp file renamed into place' + ).toEqual([]); + expect(renames.length, 'one rename per write').toBe(writes.length); + return renames.map(([from, to]) => { + const write = writes.find(([path]) => path === from); + expect(write, `rename of ${from}, which was never written`).toBeDefined(); + return [to, String(write?.[1])]; }); } @@ -228,15 +240,21 @@ describe('tunnel enable authorization across a concurrent stop', () => { return JSON.stringify(storedSettings); }); const pending = new Map<string, string>(); + const store = (contents: string) => { + storedSettings = JSON.parse(contents); + // The stop becomes visible during the awaited write, regardless of isRunning() call count. + if (stopDuringWrite) running = false; + afterWrite?.(); + }; mockedWriteFile.mockImplementation(async (path, contents) => { - pending.set(String(path), contents as string); + // A direct write to settings.json lands at once, so the stored file is what the route really left; + // settingsWrites() then fails it for not going through a temp file and a rename. + if (path === SETTINGS_PATH) store(contents as string); + else pending.set(String(path), contents as string); }); mockedRename.mockImplementation(async (from, to) => { expect(to).toBe(SETTINGS_PATH); - storedSettings = JSON.parse(pending.get(String(from)) as string); - // The stop becomes visible during the awaited write, regardless of isRunning() call count. - if (stopDuringWrite) running = false; - afterWrite?.(); + store(pending.get(String(from)) as string); }); }); From 1752d7ee2cb35e76d119b105047819a7a06cc0aa Mon Sep 17 00:00:00 2001 From: Codeman maintainer <noreply@anthropic.com> Date: Tue, 27 Oct 2026 02:58:37 +0100 Subject: [PATCH 60/68] fix: refuse to git fetch while the Docker case list cannot be read (release review) readDockerCases() reads an unreadable or half-written docker-cases.json as [], so a fetch (which runs repository hooks, and runs unattended on the interval) could run inside a Docker workspace. The fetch paths now read it through a strict reader: the Fetch button answers a failed run (state 'error') and the scheduler skips the tick, undoes its picks and logs once. The status and diff routes are unchanged. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- docs/api-reference.md | 2 +- src/docker-hosts.ts | 26 ++++++++++ src/git-fetch-scheduler.ts | 38 +++++++++++++- src/web/routes/git-status-routes.ts | 34 +++++++++++-- src/web/server.ts | 3 +- test/docker-hosts.test.ts | 23 +++++++++ test/git-fetch-scheduler.test.ts | 40 ++++++++++++++- test/routes/git-status-routes.test.ts | 73 ++++++++++++++++++++++++--- 8 files changed, 221 insertions(+), 18 deletions(-) diff --git a/docs/api-reference.md b/docs/api-reference.md index d8b0152dd..2c7ef1d6a 100644 --- a/docs/api-reference.md +++ b/docs/api-reference.md @@ -1340,7 +1340,7 @@ Lists are capped (300 files and 50 commits per repository) while the counts stay `data` is `{ state, repos, reposTruncated, repoLimit, fetchedAt }`, `repos[]` each `{ name, path, ok }` and, when it failed, `failure` (`{ code, message, stderr }`: the codes as in the clone failures, plus `NO_REMOTE`). A repository that could not be fetched keeps its row rather than being dropped. The panel shows the result under its list and then re-reads, so `behind` reflects what this fetch brought. -The interval is two **synced** settings in `PUT /api/settings` (`src/git-fetch-scheduler.ts`): `gitFetchEnabled` (default `false`) and `gitFetchIntervalSeconds` (30 to 3600, default 300, the gap between runs timed from the start of a run so a slow repository does not push the next one out). Synced, not per-device, because the server is what acts on them; the scheduler reads them fresh on each 15 s tick, so a change works without a restart. A session can override both, and the override wins even while the app-wide switch is off: `POST /api/sessions/:id/git-fetch-settings` takes `{ "enabled": true | false | null, "intervalSeconds": number | null }` — `null` on a field clears that half so the app value applies again, a field left out of the body leaves the other half as it was — is `400 INVALID_INPUT` for a remote or Docker session, and answers `200` with `{ gitFetchSettings: { enabled, intervalSeconds } }` where `null` means the session follows the app. What the last run did rides the session state as `gitFetch` (`{ ranAt, trigger, state, repos, failed, firstFailure? }`, `trigger` is `click` or `interval`), on the existing `SessionUpdated` event: no new event. Sessions on the same folder share one run and both see its result, the schedule is keyed by folder, and a folder is fetched by at most one run at a time; a folder is due when ANY session open on it is due, so the order the sessions were read cannot decide which interval wins, and the folder is probed once for the whole group rather than once per session on it; every session open on a folder is remembered, so closing the one that holds the override does not stop that folder's fetching while another session is still open on it. The tick skips anything its bounded probe does not confirm is a directory, stamps each folder's schedule when it picks it, and does not run a second tick over a selection still going. Remote and Docker sessions are never fetched on a timer, and the Docker rule is re-checked against the CURRENT Docker roots every tick. +The interval is two **synced** settings in `PUT /api/settings` (`src/git-fetch-scheduler.ts`): `gitFetchEnabled` (default `false`) and `gitFetchIntervalSeconds` (30 to 3600, default 300, the gap between runs timed from the start of a run so a slow repository does not push the next one out). Synced, not per-device, because the server is what acts on them; the scheduler reads them fresh on each 15 s tick, so a change works without a restart. A session can override both, and the override wins even while the app-wide switch is off: `POST /api/sessions/:id/git-fetch-settings` takes `{ "enabled": true | false | null, "intervalSeconds": number | null }` — `null` on a field clears that half so the app value applies again, a field left out of the body leaves the other half as it was — is `400 INVALID_INPUT` for a remote or Docker session, and answers `200` with `{ gitFetchSettings: { enabled, intervalSeconds } }` where `null` means the session follows the app. What the last run did rides the session state as `gitFetch` (`{ ranAt, trigger, state, repos, failed, firstFailure? }`, `trigger` is `click` or `interval`), on the existing `SessionUpdated` event: no new event. Sessions on the same folder share one run and both see its result, the schedule is keyed by folder, and a folder is fetched by at most one run at a time; a folder is due when ANY session open on it is due, so the order the sessions were read cannot decide which interval wins, and the folder is probed once for the whole group rather than once per session on it; every session open on a folder is remembered, so closing the one that holds the override does not stop that folder's fetching while another session is still open on it. The tick skips anything its bounded probe does not confirm is a directory, stamps each folder's schedule when it picks it, and does not run a second tick over a selection still going. Remote and Docker sessions are never fetched on a timer, and the Docker rule is re-checked against the CURRENT Docker roots every tick. Both fetch paths fail closed on a Docker case list (`docker-cases.json`) that exists but cannot be read or parsed: a click answers `200` with `state: 'error'` and publishes that as its `gitFetch`, and a tick fetches nothing and leaves its folders due for the next tick, since without the list nothing tells which folders a container can write to (the read-only status and diff routes keep reading such a list as no Docker workspaces). ## Git worktrees of a case diff --git a/src/docker-hosts.ts b/src/docker-hosts.ts index fbafb4600..f2ce722da 100644 --- a/src/docker-hosts.ts +++ b/src/docker-hosts.ts @@ -130,6 +130,32 @@ export async function readDockerCases(configDir: string): Promise<DockerCase[]> return readJsonArray<DockerCase>(dockerCasesPath(configDir)); } +/** + * The Docker cases for a caller that must fail CLOSED (the git fetch paths): a missing file is no cases, + * but a file that cannot be read, does not parse or is not an array rejects, where {@link readDockerCases} + * reads all of those as `[]`. The writer truncates and rewrites the file in place, so a read can land on an + * empty or half-written file, and to a caller deciding where git may run that must never look like "no + * Docker workspaces". The parser's own message is not repeated (V8 quotes the text around the fault). + */ +export async function readDockerCasesOrThrow(configDir: string): Promise<DockerCase[]> { + const path = dockerCasesPath(configDir); + let raw: string; + try { + raw = await fs.readFile(path, 'utf-8'); + } catch (err) { + if ((err as NodeJS.ErrnoException).code === 'ENOENT') return []; + throw err; + } + let parsed: unknown; + try { + parsed = JSON.parse(raw); + } catch { + parsed = undefined; + } + if (!Array.isArray(parsed)) throw new Error(`${path} is not a valid JSON array`); + return parsed as DockerCase[]; +} + export async function writeDockerCases(configDir: string, cases: DockerCase[]): Promise<void> { await writeJsonArray(configDir, dockerCasesPath(configDir), cases); } diff --git a/src/git-fetch-scheduler.ts b/src/git-fetch-scheduler.ts index a6d00578e..a946a8748 100644 --- a/src/git-fetch-scheduler.ts +++ b/src/git-fetch-scheduler.ts @@ -18,7 +18,10 @@ * app-wide switch is off — that is what a per-session switch has to mean. * - Remote and Docker sessions are never fetched, exactly as the Git panel excludes them, and the Docker * workspace roots are checked again every tick, so a session whose case became a Docker case stops - * fetching at the next tick. + * fetching at the next tick. A Docker case list that cannot be read fails CLOSED: the tick fetches + * nothing (a fetch runs a repository's hooks, and without the list nothing tells which folders a + * container can write to), the folders it picked stay due for the next tick, and it is logged once + * until a read succeeds again. * - A folder is fetched by at most one run at a time, and the run belongs to the FOLDER, not to one * session: two sessions open on the same folder are the same clone, so they share one run and both get * its result. State is keyed by `resolve(workingDir)` — deliberately not `realpath`, because `realpath` @@ -91,6 +94,10 @@ export interface GitFetchPorts { getSession(id: string): GitFetchSessionView | undefined; /** Read FRESH every tick; that is the whole point of the tick-driven shape. */ readSettings(): Promise<GitFetchSettings>; + /** + * Host paths of every Docker case workspace. REJECTS when the list exists but cannot be read or parsed; the + * tick then fetches nothing and the folders it picked are due again on the next tick. + */ dockerWorkspaces(): Promise<string[]>; /** * Bounded probe (rule #516), default options: a bulk scan keeps the stall cap, so this must not be called @@ -134,6 +141,8 @@ export class GitFetchScheduler { private stopped = false; /** The last settings read failed and was logged; cleared by the next read that succeeds. */ private settingsUnreadable = false; + /** The same for the Docker case list. */ + private dockerCasesUnreadable = false; constructor(ports: GitFetchPorts) { this.ports = ports; @@ -185,6 +194,9 @@ export class GitFetchScheduler { // group ordered [short, long] and a group ordered [long, short] would have behaved differently, and the // run belongs to the folder, so no session in the group should. const due = new Map<string, GroupMember[]>(); + // What each picked folder's clock said before this tick stamped it, so a tick that ends up fetching + // nothing (an unreadable Docker case list, below) can leave the folders due again. + const stampedOver = new Map<string, number | undefined>(); for (const [folder, group] of groups) { if (this.inFlight.has(folder)) continue; const started = this.lastRunAt.get(folder); @@ -198,13 +210,35 @@ export class GitFetchScheduler { } this.inFlight.add(folder); // The clock starts at the selection, so a slow folder does not shift its own schedule. + stampedOver.set(folder, started); this.lastRunAt.set(folder, now); due.set(folder, group); } if (!due.size) return 0; // Only now, because nothing above needs it: a quiet tick does not read the Docker case list. - const dockerWorkspaces = await this.ports.dockerWorkspaces(); + let dockerWorkspaces: string[]; + try { + dockerWorkspaces = await this.ports.dockerWorkspaces(); + } catch (err) { + // Fail CLOSED: a fetch runs the repository's hooks, and without the list nothing tells which folders a + // container can write to. Nothing ran, so the picks are undone (a stamp a `remove()` already cleared + // stays cleared) and the folders are due again on the next tick instead of after their interval. + for (const [folder, started] of stampedOver) { + this.inFlight.delete(folder); + if (this.lastRunAt.get(folder) !== now) continue; + if (started === undefined) this.lastRunAt.delete(folder); + else this.lastRunAt.set(folder, started); + } + if (!this.dockerCasesUnreadable) { + this.dockerCasesUnreadable = true; + this.ports.log?.( + `git fetch: Docker case list unreadable, nothing fetched until it reads again: ${getErrorMessage(err)}` + ); + } + return 0; + } + this.dockerCasesUnreadable = false; // ONE bound for the git processes of the whole tick, handed to every run so no folder gets its own: // each fetch is one git process, and a folder can hold up to `MAX_REPOS_LIMIT` of them, so a bound per // run would let `FETCH_CONCURRENCY` folders × `MAX_REPOS_LIMIT` processes run at once. diff --git a/src/web/routes/git-status-routes.ts b/src/web/routes/git-status-routes.ts index 4bc9200ad..1480b1cc4 100644 --- a/src/web/routes/git-status-routes.ts +++ b/src/web/routes/git-status-routes.ts @@ -17,7 +17,7 @@ import type { FastifyInstance } from 'fastify'; import { ApiErrorCode, createErrorResponse, getErrorMessage, type ApiResponse } from '../../types.js'; import { redactGitCredentials } from '../../git-clone.js'; import { emptyReport, fetchWorkspaceOnce, gitFetchStatusFrom, type GitFetchReport } from '../../git-fetch.js'; -import { readDockerCases } from '../../docker-hosts.js'; +import { readDockerCasesOrThrow } from '../../docker-hosts.js'; import { getDataDir } from '../../config/instance.js'; import { findSessionOrFail, persistAndBroadcastSession } from '../route-helpers.js'; import { @@ -38,9 +38,18 @@ import type { EventPort, SessionPort } from '../ports/index.js'; * Host paths of every Docker case workspace: repositories at or inside these are never inspected. * Exported so the interval scheduler (src/git-fetch-scheduler.ts) uses the SAME list — the Docker rule has * to be one rule, checked by every path that could run git. + * + * REJECTS when `docker-cases.json` exists but cannot be read or parsed (its writer truncates and rewrites it + * in place, so a read can land mid-write). The fetch paths fail closed on that: a fetch runs a repository's + * own hooks, and without the list nothing tells which folders a container can write to. The read-only + * status and diff routes read it as no Docker workspaces, as they always have. */ export const defaultDockerWorkspaces = async (): Promise<string[]> => - (await readDockerCases(getDataDir()).catch(() => [])).map((c) => c.hostWorkspacePath).filter(Boolean); + (await readDockerCasesOrThrow(getDataDir())).map((c) => c.hostWorkspacePath).filter(Boolean); + +/** The read-only routes' reading of the list: unchanged, an unreadable list is no Docker workspaces. */ +const dockerWorkspacesOrNone = (dockerWorkspaces: () => Promise<string[]>): Promise<string[]> => + dockerWorkspaces().catch(() => []); /** * The repository limit and git timeout a request asks for (Settings → Bottom bar, per device): @@ -78,7 +87,7 @@ export function registerGitStatusRoutes( git, fresh: fresh === '1', ...limits, - dockerWorkspaces: await dockerWorkspaces(), + dockerWorkspaces: await dockerWorkspacesOrNone(dockerWorkspaces), }), }; }); @@ -102,7 +111,7 @@ export function registerGitStatusRoutes( ? await findWorkspaceRepo(session.workingDir, repo, { git, ...limits, - dockerWorkspaces: await dockerWorkspaces(), + dockerWorkspaces: await dockerWorkspacesOrNone(dockerWorkspaces), }) : null; const status = repoRoot @@ -150,9 +159,24 @@ 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' }) }; + // Fail CLOSED on a Docker case list that cannot be read: a fetch runs the repository's hooks, so it never + // runs without knowing which folders a container can write to. Answered as a failed run (`state: 'error'`), + // published like one, so the Session Options Git row shows it. + let dockerRoots: string[]; + try { + dockerRoots = await dockerWorkspaces(); + } catch (err) { + console.warn(`[git fetch] Docker case list unreadable, nothing fetched: ${getErrorMessage(err)}`); + const report = emptyReport('error', { + error: + 'Nothing was fetched: Codeman could not read its Docker case list (docker-cases.json), so it cannot tell which folders a container can write to.', + }); + if (session.setGitFetchStatus(gitFetchStatusFrom(report, 'click'))) persistAndBroadcastSession(ctx, session); + return { success: true, data: report }; + } const run = await fetchWorkspaceOnce( session.workingDir, - { git, ...limits, dockerWorkspaces: await dockerWorkspaces() }, + { git, ...limits, dockerWorkspaces: dockerRoots }, 'click' ); // The response answers the click, and the run is also the session's current truth: what the panel's diff --git a/src/web/server.ts b/src/web/server.ts index 7d890281b..c0799d46d 100644 --- a/src/web/server.ts +++ b/src/web/server.ts @@ -406,7 +406,8 @@ export class WebServer extends EventEmitter { sessionIds: () => this.sessions.keys(), getSession: (id) => this.sessions.get(id), readSettings: readGitFetchSettings, - // The same Docker list the panel's routes use: the rule has to be one rule. + // The same Docker list the panel's Fetch button uses: the rule has to be one rule. It rejects on a list + // that cannot be read, and the tick then fetches nothing (fail closed). dockerWorkspaces: defaultDockerWorkspaces, // Bounded probe, DEFAULT options (rule #516): a tick is a bulk scan, so it keeps the stall cap and must // never be handed `pastCap`. A folder on a mount that stopped answering is skipped, not fetched. diff --git a/test/docker-hosts.test.ts b/test/docker-hosts.test.ts index 123c73ec5..8913b64a8 100644 --- a/test/docker-hosts.test.ts +++ b/test/docker-hosts.test.ts @@ -28,6 +28,7 @@ import { persistDockerCaseClaudeSessionId, probeDockerCliVersion, readDockerCases, + readDockerCasesOrThrow, readDockerHosts, resolveClaudeJsonSeedMount, resolveDockerClaudeArtifacts, @@ -70,6 +71,28 @@ describe('docker-hosts storage', () => { expect(await readDockerCases(dir)).toEqual([]); }); + /** + * The git fetch paths read the list through the strict reader: the writer truncates and rewrites the file in + * place, and a list read as `[]` there would let a fetch run git inside a Docker workspace. + */ + it('has a strict reader that rejects a list it cannot parse, where the plain reader returns []', async () => { + expect(await readDockerCasesOrThrow(dir)).toEqual([]); // missing: no cases + await writeDockerCases(dir, [CASE]); + expect(await readDockerCasesOrThrow(dir)).toEqual([CASE]); + const path = join(dir, 'docker-cases.json'); + for (const text of ['', '[{"name":"myproj","hostWorkspacePath":"/home/arkon/cas', '{"name":"myproj"}', 'null']) { + writeFileSync(path, text); + expect(await readDockerCases(dir)).toEqual([]); + const err = await readDockerCasesOrThrow(dir).then( + () => null, + (e: Error) => e + ); + expect(err?.message).toMatch(/not a valid JSON array/); + // The parser's message quotes the file; the strict reader's never does. + expect(err?.message).not.toContain('/home/arkon/cas'); + } + }); + it('persists the last Claude conversation id keyed by container name', async () => { await writeDockerCases(dir, [CASE, { ...CASE, name: 'other', container: 'custom-name' }]); await persistDockerCaseClaudeSessionId(dir, dockerContainerName(CASE.name), 'conv-1'); diff --git a/test/git-fetch-scheduler.test.ts b/test/git-fetch-scheduler.test.ts index cd936c3cb..01f1eaeef 100644 --- a/test/git-fetch-scheduler.test.ts +++ b/test/git-fetch-scheduler.test.ts @@ -12,7 +12,8 @@ * Port: N/A. */ import { mkdir, rm, writeFile } from 'node:fs/promises'; -import { dirname } from 'node:path'; +import { readFileSync } from 'node:fs'; +import { dirname, resolve } from 'node:path'; import { describe, expect, it, vi } from 'vitest'; import { GitFetchScheduler, @@ -279,6 +280,43 @@ describe('who runs', () => { expect(h.calls.length).toBe(1); }); + /** + * The Docker case list is rewritten in place, so a read can land on a half-written file. Read as "no Docker + * workspaces", a tick would run git (and the repository's hooks) inside a folder a container can write to. + */ + it('fetches nothing when the Docker case list cannot be read, keeps the folders due, and logs it once', async () => { + const h = harness([VIEW('a'), VIEW('b')]); + let unreadable = true; + h.ports.dockerWorkspaces = async () => { + h.dockerReads++; + if (unreadable) throw new Error('docker-cases.json is not a valid JSON array'); + return []; + }; + const logged = vi.fn(); + h.ports.log = logged; + expect(await h.scheduler.tick(0)).toBe(0); + expect(await h.scheduler.tick(15_000)).toBe(0); + expect(h.calls).toEqual([]); + expect(h.dockerReads).toBe(2); // the picks were undone, so the next tick picked them again + expect(logged).toHaveBeenCalledTimes(1); + expect(h.scheduler.tracked()).toBe(0); // nothing left in flight or stamped + + // The list reads again: both folders run at once, not an interval after the failed ticks. + unreadable = false; + expect(await h.scheduler.tick(30_000)).toBe(2); + expect(h.calls.map((c) => c.cwd).sort()).toEqual(['/w/a', '/w/b']); + // And their interval is timed from that run. + expect(await h.scheduler.tick(30_000 + 299_000)).toBe(0); + expect(await h.scheduler.tick(30_000 + 300_000)).toBe(2); + }); + + it('is wired to the strict Docker reader the Fetch button uses', () => { + const server = readFileSync(resolve(import.meta.dirname, '../src/web/server.ts'), 'utf8'); + const block = server.slice(server.indexOf('new GitFetchScheduler(')); + // defaultDockerWorkspaces is the strict one: test/routes/git-status-routes.test.ts pins that it rejects. + expect(block.slice(0, block.indexOf('});'))).toContain('dockerWorkspaces: defaultDockerWorkspaces,'); + }); + it('uses the module defaults for the limit and the timeout, because those settings are per device and never reach the server', async () => { const h = harness([VIEW('a')]); await h.scheduler.tick(0); diff --git a/test/routes/git-status-routes.test.ts b/test/routes/git-status-routes.test.ts index fc5d345ff..7e8269fd6 100644 --- a/test/routes/git-status-routes.test.ts +++ b/test/routes/git-status-routes.test.ts @@ -9,7 +9,8 @@ import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { createRouteTestHarness } from './_route-test-utils.js'; -import { registerGitStatusRoutes } from '../../src/web/routes/git-status-routes.js'; +import { defaultDockerWorkspaces, registerGitStatusRoutes } from '../../src/web/routes/git-status-routes.js'; +import { getDataDir } from '../../src/config/instance.js'; import { clearGitStatusCache, runGit, type GitRunner } from '../../src/git-workspace-status.js'; import { clearGitFetchLane, fetchWorkspaceOnce } from '../../src/git-fetch.js'; @@ -30,14 +31,18 @@ let dir: string; let session: Record<string, unknown>; async function setup( - opts: { git?: GitRunner; authUser?: { username: string; role: 'admin' | 'user' }; dockerWorkspaces?: string[] } = {} + opts: { + git?: GitRunner; + authUser?: { username: string; role: 'admin' | 'user' }; + /** A fixed list, a reader of the test's own, or `'real'` for the route's default (the data dir's file). */ + dockerWorkspaces?: string[] | (() => Promise<string[]>) | 'real'; + } = {} ) { - const h = await createRouteTestHarness( - (app, ctx) => registerGitStatusRoutes(app, ctx, opts.git, async () => opts.dockerWorkspaces ?? []), - { - authUser: opts.authUser, - } - ); + const docker = opts.dockerWorkspaces; + const reader = docker === 'real' ? undefined : typeof docker === 'function' ? docker : async () => docker ?? []; + const h = await createRouteTestHarness((app, ctx) => registerGitStatusRoutes(app, ctx, opts.git, reader), { + authUser: opts.authUser, + }); session = h.ctx._session as unknown as Record<string, unknown>; session.workingDir = dir; return h; @@ -461,6 +466,58 @@ describe('POST /api/sessions/:id/git-fetch', () => { expect(calls).toEqual([]); }); + /** + * docker-cases.json is rewritten in place (truncate, then write), so a read can land on an empty or + * half-written file. Read as "no Docker workspaces", a fetch would run git, and with it the repository's + * hooks, inside a folder a container can write to. + */ + it('fetches nothing when the Docker case list cannot be read, and answers a failed run', async () => { + git(dir, 'init', '-q', '-b', 'main'); + const { calls, runner } = recordingRunner(); + const { app, ctx } = await setup({ + git: runner, + dockerWorkspaces: async () => { + throw new Error('docker-cases.json is not a valid JSON array'); + }, + }); + const res = await app.inject({ method: 'POST', url: '/api/sessions/test-session-1/git-fetch' }); + expect(res.statusCode).toBe(200); + expect(res.json().data).toMatchObject({ state: 'error', repos: [] }); + expect(res.json().data.error).toMatch(/Docker case list/); + expect(calls).toEqual([]); // not even the resolver's rev-parse + expect(session.gitFetch).toMatchObject({ trigger: 'click', state: 'error', repos: 0, failed: 0 }); + expect(ctx.broadcast).toHaveBeenCalled(); + }); + + it('reads the real Docker case list strictly for a fetch, and leaves the status route as it was', async () => { + git(dir, 'init', '-q', '-b', 'main'); + mkdirSync(getDataDir(), { recursive: true }); + const listPath = join(getDataDir(), 'docker-cases.json'); + try { + writeFileSync(listPath, '[{"name":"c","hostWorkspacePath":"/x"'); + await expect(defaultDockerWorkspaces()).rejects.toThrow(/not a valid JSON array/); + const { calls, fetches, runner } = recordingRunner(); + const { app } = await setup({ git: runner, dockerWorkspaces: 'real' }); + const fetched = await app.inject({ method: 'POST', url: '/api/sessions/test-session-1/git-fetch' }); + expect(fetched.json().data.state).toBe('error'); + expect(calls).toEqual([]); + // The read-only status route still reads an unreadable list as none, as it always has. + const status = await app.inject({ method: 'GET', url: '/api/sessions/test-session-1/git-status' }); + expect(status.json().data.state).toBe('ok'); + expect(fetches()).toEqual([]); + + // A readable list that names this folder refuses the fetch as a Docker workspace, so the strict + // reader is the one the route really reads. + writeFileSync(listPath, JSON.stringify([{ name: 'c', hostWorkspacePath: realpathSync(dir) }])); + expect(await defaultDockerWorkspaces()).toEqual([realpathSync(dir)]); + const refused = await app.inject({ method: 'POST', url: '/api/sessions/test-session-1/git-fetch' }); + expect(refused.json().data).toMatchObject({ state: 'unsupported', reason: 'docker' }); + expect(fetches()).toEqual([]); + } finally { + rmSync(listPath, { force: true }); + } + }); + it('answers not-a-repo for a folder that is not a repository, running no fetch', async () => { const { fetches, runner } = recordingRunner(); const { app } = await setup({ git: runner }); From 3fdba31e36bd8ed249e9186441dab5048d553280 Mon Sep 17 00:00:00 2001 From: Codeman maintainer <noreply@anthropic.com> Date: Tue, 27 Oct 2026 03:00:44 +0100 Subject: [PATCH 61/68] fix: keep a shell prompt whose spaced working directory ends in $ out of a spaced path link (release review) spacedFilePathOnLine() refused only a FIRST word ending in `$`, so `/mnt/c/My Docs$ ls notes.md` and `/mnt/c/Users/me/OneDrive - Contoso/proj$ code notes.md` linked the whole line. Any earlier word ending in `$` now refuses the candidate, and both lines link what master links. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- src/web/public/constants.js | 12 +++++++----- test/spaced-file-path-links.test.ts | 8 +++++--- 2 files changed, 12 insertions(+), 8 deletions(-) diff --git a/src/web/public/constants.js b/src/web/public/constants.js index 7fa14ee86..062cd139c 100644 --- a/src/web/public/constants.js +++ b/src/web/public/constants.js @@ -2266,8 +2266,8 @@ const SPACED_FILE_PATH_LABEL_PATTERN = * or a bare `=` makes the candidate a sentence (`/tmp/out has the logs in run.log`), * so a name such as `Pros and Cons.md` splits as master does. A command line is * refused: an executable under `bin/`, `.bin/`, `scripts/` or `tools/` as the - * first word, a first word ending in `$` (a shell prompt, `/home/me/proj$ cat a.md`), - * or any `-x`/`--x` flag word. + * first word, any earlier word ending in `$` (a shell prompt, `/home/me/proj$ cat a.md`, + * `/mnt/c/My Docs$ ls a.md`), or any `-x`/`--x` flag word. * * A candidate that is one path but not a linkable one (an unknown root such as * `/etc`, a `.`/`..`/empty segment) comes back with `link: false` when its last @@ -2302,11 +2302,13 @@ function spacedFilePathOnLine(line) { if (/\/(?:\.?bin|scripts?|tools)\/[^/]+$/.test(words[0]) || words.some((word) => /^-{1,2}\w/.test(word))) { return null; } - // A shell prompt that prints its working directory in full (`/home/me/proj$ cat - // notes.md`; WSL's `/mnt/c/...` is outside `$HOME`) is a command line too. - if (words[0].endsWith('$')) return null; for (const word of words.slice(0, -1)) { if (word.endsWith('/') || word.endsWith(':') || /^(?:—|–|→|=>|->|<-|=)$/.test(word)) return null; + // A shell prompt that prints its working directory in full (`/home/me/proj$ cat + // notes.md`; WSL's `/mnt/c/...` is outside `$HOME`) is a command line too, and so is + // one whose directory has a space in it (`/mnt/c/My Docs$ ls notes.md`): the `$` + // can end any earlier word, not only the first. + if (word.endsWith('$')) return null; // A word with no letter or digit (a prompt glyph `❯` or `$`, a `✓`) is never // part of a file name, apart from a bare `-` (`OneDrive - Contoso`). if (word !== '-' && !/[\p{L}\p{N}]/u.test(word)) return null; diff --git a/test/spaced-file-path-links.test.ts b/test/spaced-file-path-links.test.ts index 73a6fe8fe..4c9c41fcd 100644 --- a/test/spaced-file-path-links.test.ts +++ b/test/spaced-file-path-links.test.ts @@ -337,13 +337,15 @@ describe('spaced file paths: shapes that keep master links', () => { }); // A shell prompt that prints its working directory in full (WSL's `/mnt/c/...` is - // outside `$HOME`): a word with no letter or digit, or a first word ending in `$`, - // is never part of a file name, so every earlier command in the scrollback keeps - // master's links. + // outside `$HOME`): a word with no letter or digit, or any earlier word ending in `$` + // (the first, or a later one when the directory has a space in it), is never part of + // a file name, so every earlier command in the scrollback keeps master's links. it.each([ ['/mnt/c/proj ❯ code notes.md', ['/mnt/c/proj', true], ['notes.md']], ['/home/me/proj $ cat notes.md', ['/home/me/proj', true], ['notes.md']], ['/home/me/proj$ cat notes.md', ['/home/me/proj', true], ['notes.md']], + ['/mnt/c/Users/me/OneDrive - Contoso/proj$ code notes.md', ['/mnt/c/Users/me/OneDrive', true], ['notes.md']], + ['/mnt/c/My Docs$ ls notes.md', ['/mnt/c/My', true], ['notes.md']], ['/home/me/proj ❯ git add src/a.ts', ['/home/me/proj', true], ['src/a.ts']], ['/mnt/c/Users/me/OneDrive - Contoso/proj ❯ code notes.md', ['/mnt/c/Users/me/OneDrive', true], ['notes.md']], ['/home/me/proj/out ✓ report.pdf', ['/home/me/proj/out', true], ['report.pdf']], From 93691e60967d3ff4586f2326d9f9986f28947da1 Mon Sep 17 00:00:00 2001 From: Codeman maintainer <noreply@anthropic.com> Date: Tue, 27 Oct 2026 03:04:14 +0100 Subject: [PATCH 62/68] fix: bring the skill, plugin, README and API docs in line with the merged agent behaviour (release review) The self-gate now says the skill reads saved Codex conversations offline outside a session; the staging reference says only tagged input gets 409 during a guarded submit (the rest is held), and the API reference notes a managed write past a full hold queue falls back to a direct write. Also: missing reference rows, agent --help naming inspect/stage/submit, the tunnel opt-in in Remote Access, MCP wording. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- .claude-plugin/marketplace.json | 2 +- README.md | 8 ++++++-- README.zh-CN.md | 8 ++++++-- docs/api-reference.md | 6 +++++- docs/wiki/Codeman-Skill.md | 1 + plugins/codeman/.claude-plugin/plugin.json | 2 +- plugins/codeman/README.md | 2 +- .../codeman/reference/inspection-and-staging.md | 11 ++++++++--- skills/codeman/reference/inspection-and-staging.md | 11 ++++++++--- src/cli-agent.ts | 2 +- 10 files changed, 38 insertions(+), 15 deletions(-) diff --git a/.claude-plugin/marketplace.json b/.claude-plugin/marketplace.json index 14e5712dc..cebbe8fcc 100644 --- a/.claude-plugin/marketplace.json +++ b/.claude-plugin/marketplace.json @@ -9,7 +9,7 @@ { "name": "codeman", "source": "./plugins/codeman", - "description": "Drive Codeman from inside a Claude Code session: spawn worker sessions, prompt them, wait for them, read their answers, clean up. Acts only inside a Codeman-managed session.", + "description": "Drive Codeman from inside a Claude Code session: spawn worker sessions, prompt them, wait for them, read their answers, clean up. Acts inside a Codeman-managed session; outside one it only reads saved Codex conversations offline.", "version": "1.45.0", "author": { "name": "Ark0N", diff --git a/README.md b/README.md index b45a39504..3546ab39a 100644 --- a/README.md +++ b/README.md @@ -584,6 +584,8 @@ loginctl enable-linger $USER **Always set `CODEMAN_PASSWORD`** before exposing via tunnel — without it, anyone with the URL has full access to your sessions. +The tunnel Codeman manages (App Settings → System → Remote access) refuses to start without a login (`CODEMAN_PASSWORD` or multi-user mode) unless `CODEMAN_ALLOW_UNAUTHENTICATED_TUNNEL=1` or `codeman web --allow-unauthenticated-tunnel` acknowledges the exposure, or you accept the browser's confirm, which it asks before every enable while no login is set. `CODEMAN_ALLOW_UNAUTHENTICATED_NETWORK` covers only the network bind: it does not let the tunnel start. + </details> ### QR Code Authentication @@ -825,12 +827,14 @@ Those `DONE_<task>_<random>` strings are the skill's **split marker** trick, and | [`reference/recipes.md`](skills/codeman/reference/recipes.md) | 8 worked flows: claude, DeepSeek Harness and shell workers, fan-out, blocked-worker watch, messaging fan-out. On demand. | | [`reference/endpoints.md`](skills/codeman/reference/endpoints.md) | Full endpoint tables, error codes, per-mode signal table, capacity limits. On demand. | | [`reference/messaging.md`](skills/codeman/reference/messaging.md) | Talking to claude workers directly via Claude Code cross-session messaging. On demand. | +| [`reference/history.md`](skills/codeman/reference/history.md) | Reading saved Codex conversations offline with `codeman history read`: selectors, filters, forks, budgets. On demand. | +| [`reference/inspection-and-staging.md`](skills/codeman/reference/inspection-and-staging.md) | Experimental and opt-in: bounded pane inspection and guarded submission with `codeman agent inspect`, `stage` and `submit`. On demand. | Every recipe in there was verified against a live server, and the comments record the failure modes that were measured rather than guessed. #### Two things worth knowing -- **It self-gates.** Outside a Codeman session (`CODEMAN_MUX` unset) the skill refuses to act and does not guess an API URL, so a global install costs an unrelated Claude Code session nothing. +- **It self-gates.** It acts inside a Codeman-managed session. Outside one (`CODEMAN_MUX` unset) it only reads saved Codex conversations offline (`codeman history read`) and refuses everything else, and it never guesses an API URL, so a global install costs an unrelated Claude Code session nothing. - **It is deliberately conservative.** Unprompted, it may only spawn sessions, prompt them, and delete ones **it created in that same conversation, by exact id**, through a fail-closed guard that refuses to delete the agent's own session. Deleting a case (which erases a real directory of your code), bulk kills, respawn/ralph/cron/orchestrator changes and settings writes all require you to ask, naming the target. ⚠️ Turning `agentSkillEnabled` back off **does not remove already-injected copies** (a create-time sweep would yank the skill out from under other live sessions sharing that workspace). Remove them per case with `codeman skill uninstall --case <name>`. @@ -1014,7 +1018,7 @@ Filters keep whole user/assistant turns (`--topic` is a literal, case-insensitiv ### MCP server (`codeman mcp`): drive Codeman from any MCP client -`codeman mcp` is a [Model Context Protocol](https://modelcontextprotocol.io) server on stdio, so any MCP client (Claude Code, Claude Desktop, Codex, Gemini CLI, Cursor, ...) can start Codeman sessions in any installed agent CLI, prompt them, wait for them, read their answers and clean up, from inside Codeman or from outside it. Like `codeman agent`, it is a client of the HTTP API: no new route, auth and ownership unchanged. +`codeman mcp` is a [Model Context Protocol](https://modelcontextprotocol.io) server on stdio, so any MCP client (Claude Code, Claude Desktop, Codex, Gemini CLI, Cursor, ...) can start Codeman sessions in any installed agent CLI, prompt them, wait for them, read their answers and clean up, from inside Codeman or from outside it. Like `codeman agent`, it is a client of the HTTP API. The MCP server adds no routes of its own, and auth and ownership are unchanged. ```bash # Claude Code diff --git a/README.zh-CN.md b/README.zh-CN.md index 6e78e0005..add70ccf5 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -585,6 +585,8 @@ loginctl enable-linger $USER 通过隧道暴露前**务必设置 `CODEMAN_PASSWORD`** —— 否则任何拿到 URL 的人都能完全访问你的会话。 +Codeman 自己管理的隧道(App Settings → System → Remote access)在没有登录(`CODEMAN_PASSWORD` 或多用户模式)时拒绝启动,除非用 `CODEMAN_ALLOW_UNAUTHENTICATED_TUNNEL=1` 或 `codeman web --allow-unauthenticated-tunnel` 确认这种暴露,或者你在浏览器的确认框里点了同意;没有登录时,每次开启前浏览器都会先问。`CODEMAN_ALLOW_UNAUTHENTICATED_NETWORK` 只管网络绑定,并不能让隧道启动。 + </details> ### 二维码认证 @@ -826,12 +828,14 @@ deleted 9f2d8e5f, aff9c691, be9f1f15 标签消失 | [`reference/recipes.md`](skills/codeman/reference/recipes.md) | 8 个完整流程:claude、DeepSeek Harness 与 shell 工作会话、扇出、盯住被卡住的工作会话、消息扇出。按需加载。 | | [`reference/endpoints.md`](skills/codeman/reference/endpoints.md) | 完整端点表、错误码、各模式的信号表、容量限制。按需加载。 | | [`reference/messaging.md`](skills/codeman/reference/messaging.md) | 通过 Claude Code 跨会话消息直接和 claude 工作会话对话。按需加载。 | +| [`reference/history.md`](skills/codeman/reference/history.md) | 用 `codeman history read` 离线读取已保存的 Codex 对话:选择器、过滤条件、分叉、预算。按需加载。 | +| [`reference/inspection-and-staging.md`](skills/codeman/reference/inspection-and-staging.md) | 实验性功能,需要手动开启:用 `codeman agent inspect`、`stage` 和 `submit` 做有界的窗格检查和受保护的提交。按需加载。 | 里面的每一个配方都在真实服务器上验证过,注释记录的是实测出来而不是猜出来的失败模式。 #### 两件值得知道的事 -- **它会自我门禁。** 不在 Codeman 会话里(`CODEMAN_MUX` 未设置)时,技能拒绝动作,也不去猜 API 地址,所以全局安装对无关的 Claude Code 会话没有任何代价。 +- **它会自我门禁。** 它在 Codeman 管理的会话里工作。不在这样的会话里(`CODEMAN_MUX` 未设置)时,它只会离线读取已保存的 Codex 对话(`codeman history read`),其他一律拒绝,也从不去猜 API 地址,所以全局安装对无关的 Claude Code 会话没有任何代价。 - **它刻意保守。** 未经提示,它只会拉起会话、给它们发提示,并删除**它在同一段对话里自己创建的**会话(按精确 id,经由一个拒绝删除智能体自身会话的失败即关闭守卫)。删除 case(会抹掉一个真实的代码目录)、批量杀会话、改动 respawn/ralph/cron/orchestrator 以及写设置,都需要你开口并指名目标。 ⚠️ 把 `agentSkillEnabled` 关回去**不会删掉已经注入的副本**(在创建时做清扫,会把技能从共用同一个工作区的其他活动会话脚下抽走)。要删就按 case 删:`codeman skill uninstall --case <name>`。 @@ -993,7 +997,7 @@ codeman agent rm "$SID" # 除当前会话外 ### MCP 服务器(`codeman mcp`):从任意 MCP 客户端驱动 Codeman -`codeman mcp` 是一个走 stdio 的 [Model Context Protocol](https://modelcontextprotocol.io) 服务器,因此任何 MCP 客户端(Claude Code、Claude Desktop、Codex、Gemini CLI、Cursor 等)都能在任意已安装的智能体 CLI 里启动 Codeman 会话、给它们发提示、等它们完成、读取回答并清理,无论是在 Codeman 内部还是外部。和 `codeman agent` 一样,它是 HTTP API 的客户端:没有新路由,认证和归属规则不变。 +`codeman mcp` 是一个走 stdio 的 [Model Context Protocol](https://modelcontextprotocol.io) 服务器,因此任何 MCP 客户端(Claude Code、Claude Desktop、Codex、Gemini CLI、Cursor 等)都能在任意已安装的智能体 CLI 里启动 Codeman 会话、给它们发提示、等它们完成、读取回答并清理,无论是在 Codeman 内部还是外部。和 `codeman agent` 一样,它是 HTTP API 的客户端。MCP 服务器自己不新增任何路由,认证和归属规则不变。 ```bash # Claude Code diff --git a/docs/api-reference.md b/docs/api-reference.md index 2c7ef1d6a..2c840c87e 100644 --- a/docs/api-reference.md +++ b/docs/api-reference.md @@ -717,7 +717,11 @@ outcome. That covers terminal WebSocket keystrokes (each frame is acknowledged only once it is written, so a frame typed during the lease lands before the one typed after it), Shift+Enter and Ctrl+Enter from `POST .../send-key`, approval answers and Codeman's own managed writes. A held write that is still being sent -holds everything behind it. A tagged `POST /api/v1/sessions/:id/input` (one +holds everything behind it, with one exception: when the hold queue is full (256 +inputs or 1 MiB), a managed write (`writeViaMux`, which `POST .../input` uses for a plain +prompt or `useMux`) is not held and that route falls back to a direct write, which +is refused during the lease itself but can land ahead of input still being written +after it. A tagged `POST /api/v1/sessions/:id/input` (one carrying `clientId` and `seq`) instead answers `409 CONFLICT` ("A guarded submit is in progress on this session; nothing was written") for that span, the lease plus the held input's drain, rather than a `200` for bytes that were never diff --git a/docs/wiki/Codeman-Skill.md b/docs/wiki/Codeman-Skill.md index 6417606e2..0033abd5f 100644 --- a/docs/wiki/Codeman-Skill.md +++ b/docs/wiki/Codeman-Skill.md @@ -157,6 +157,7 @@ The skill checks for both and says so instead of acting. | [`reference/endpoints.md`](https://github.com/Ark0N/Codeman/blob/master/skills/codeman/reference/endpoints.md) | Endpoint tables, error codes, the signals each mode emits, capacity limits. Loaded on demand. | | [`reference/messaging.md`](https://github.com/Ark0N/Codeman/blob/master/skills/codeman/reference/messaging.md) | Talking to claude workers directly through Claude Code's cross-session messaging. Loaded on demand. | | [`reference/history.md`](https://github.com/Ark0N/Codeman/blob/master/skills/codeman/reference/history.md) | Reading saved Codex conversations offline with `codeman history read`: selectors, filters, forks, budgets. Loaded on demand. | +| [`reference/inspection-and-staging.md`](https://github.com/Ark0N/Codeman/blob/master/skills/codeman/reference/inspection-and-staging.md) | Experimental and opt-in (App Settings, Experimental agent coordination): bounded pane inspection and guarded submission with `codeman agent inspect`, `stage` and `submit`. Loaded on demand. | Only `SKILL.md` sits in the agent's context; the references load when a job needs them, so a quick run stays quick. diff --git a/plugins/codeman/.claude-plugin/plugin.json b/plugins/codeman/.claude-plugin/plugin.json index 8b0133e31..d6c0fc838 100644 --- a/plugins/codeman/.claude-plugin/plugin.json +++ b/plugins/codeman/.claude-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "codeman", - "description": "Drive Codeman, the self-hosted session manager for AI coding agents, from inside a Claude Code session: spawn worker sessions, prompt them, wait for them, read their answers, clean up. Acts only inside a Codeman-managed session.", + "description": "Drive Codeman, the self-hosted session manager for AI coding agents, from inside a Claude Code session: spawn worker sessions, prompt them, wait for them, read their answers, clean up. Acts inside a Codeman-managed session; outside one it only reads saved Codex conversations offline.", "version": "1.45.0", "author": { "name": "Ark0N", diff --git a/plugins/codeman/README.md b/plugins/codeman/README.md index 9ea8f05e8..06d502227 100644 --- a/plugins/codeman/README.md +++ b/plugins/codeman/README.md @@ -7,7 +7,7 @@ The agent skill for [Codeman](https://getcodeman.com), the self-hosted mission c /plugin install codeman@codeman ``` -The skill acts only inside a Codeman-managed session (`CODEMAN_MUX=1`) and refuses everywhere else, so installing it globally costs nothing for unrelated sessions. +The skill acts inside a Codeman-managed session (`CODEMAN_MUX=1`). Outside one it only reads saved Codex conversations offline (`codeman history read`) and refuses everything else, so installing it globally costs nothing for unrelated sessions. Pick one install route. Codeman can inject the skill into each case itself (App Settings, Agent Skill), and `codeman skill install` writes a user-level copy; a Claude Code that has one of those AND this plugin lists the skill twice, as `codeman` and `codeman:codeman`. Both work, the second is just noise. diff --git a/plugins/codeman/skills/codeman/reference/inspection-and-staging.md b/plugins/codeman/skills/codeman/reference/inspection-and-staging.md index 630088c23..8f454748f 100644 --- a/plugins/codeman/skills/codeman/reference/inspection-and-staging.md +++ b/plugins/codeman/skills/codeman/reference/inspection-and-staging.md @@ -96,14 +96,19 @@ failed or was ambiguous, the pane or input changed, or no frame verified in time says the message may already be in the composer: no receipt was issued. Do not stage again on top of it; follow [Recover a stranded draft](#recover-a-stranded-draft) below first. -While a submit's Enter is in flight (milliseconds), `send` and other input to that -session answer `409 CONFLICT` with nothing written; retry the input. +While a submit's Enter is in flight (milliseconds, plus the moment it takes to +write input held behind it), TAGGED input to that session (`clientId` and `seq`, +which `codeman agent send` and `interrupt` always carry) answers `409 CONFLICT` +with nothing written; retry it. Untagged input, terminal keystrokes, `send-key` +and Codeman's own managed writes are held instead and written in arrival order +once the Enter is out. Submit checks the caller, session, hash, expiry, pane lifecycle, input revision, geometry, exact composer and caret, then sends one conditional Enter at the session's input boundary. It first waits until 250 ms have passed since stage typed (Codex turns an Enter that follows typed text within 120 ms into a newline), so submitting right -after stage is fine. Competing Codeman writes are refused during dispatch. Browser/API/key +after stage is fine. Competing Codeman writes are held during dispatch and written +after it, except tagged input, which is refused with `409`. Browser/API/key input and managed restarts invalidate receipts even when the visible text or pid is restored. Direct tmux edits are outside that input serialization; captures remain evidence at the time observed, rather than terminal isolation. diff --git a/skills/codeman/reference/inspection-and-staging.md b/skills/codeman/reference/inspection-and-staging.md index 630088c23..8f454748f 100644 --- a/skills/codeman/reference/inspection-and-staging.md +++ b/skills/codeman/reference/inspection-and-staging.md @@ -96,14 +96,19 @@ failed or was ambiguous, the pane or input changed, or no frame verified in time says the message may already be in the composer: no receipt was issued. Do not stage again on top of it; follow [Recover a stranded draft](#recover-a-stranded-draft) below first. -While a submit's Enter is in flight (milliseconds), `send` and other input to that -session answer `409 CONFLICT` with nothing written; retry the input. +While a submit's Enter is in flight (milliseconds, plus the moment it takes to +write input held behind it), TAGGED input to that session (`clientId` and `seq`, +which `codeman agent send` and `interrupt` always carry) answers `409 CONFLICT` +with nothing written; retry it. Untagged input, terminal keystrokes, `send-key` +and Codeman's own managed writes are held instead and written in arrival order +once the Enter is out. Submit checks the caller, session, hash, expiry, pane lifecycle, input revision, geometry, exact composer and caret, then sends one conditional Enter at the session's input boundary. It first waits until 250 ms have passed since stage typed (Codex turns an Enter that follows typed text within 120 ms into a newline), so submitting right -after stage is fine. Competing Codeman writes are refused during dispatch. Browser/API/key +after stage is fine. Competing Codeman writes are held during dispatch and written +after it, except tagged input, which is refused with `409`. Browser/API/key input and managed restarts invalidate receipts even when the visible text or pid is restored. Direct tmux edits are outside that input serialization; captures remain evidence at the time observed, rather than terminal isolation. diff --git a/src/cli-agent.ts b/src/cli-agent.ts index 6c75e7370..7886924d3 100644 --- a/src/cli-agent.ts +++ b/src/cli-agent.ts @@ -1272,7 +1272,7 @@ export function registerAgentCommands(program: Command): Command { const agent = program .command('agent') .description( - 'Talk to other sessions from inside one (any CLI mode): list, spawn, send, wait, read, interrupt, rm, and the inbox: post, inbox, ack' + 'Talk to other sessions from inside one (any CLI mode): list, spawn, send, wait, read, interrupt, rm, the inbox: post, inbox, ack, and the experimental, opt-in inspect, stage, submit' ); agent From cc08016a76bbd64451be1dbdc36050d894f3af29 Mon Sep 17 00:00:00 2001 From: Codeman maintainer <noreply@anthropic.com> Date: Tue, 27 Oct 2026 02:58:26 +0100 Subject: [PATCH 63/68] fix: keep links in the columns right of a wrapped URL's first row (release review) terminalLinkLine() cut the URL's first row at its cell to stitch the URL, so a path in a later column of that row was never scanned. The cut-off columns now follow the stitched rows as a segment of their own, mapped back to their cells. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- docs/architecture-invariants.md | 2 +- src/web/public/constants.js | 26 +++++- test/terminal-url-continuation.test.ts | 118 ++++++++++++++++++++++++- 3 files changed, 139 insertions(+), 7 deletions(-) diff --git a/docs/architecture-invariants.md b/docs/architecture-invariants.md index e9b72a0a3..d4484540f 100644 --- a/docs/architecture-invariants.md +++ b/docs/architecture-invariants.md @@ -413,7 +413,7 @@ A file path an agent prints is a link on both surfaces it can appear on, and cli Spaced paths are considered first by `findFilePathLinks()`: accepted candidates reserve their entire span even when unlinkable, so later detectors cannot link fragments. Relative and `~/` spaced paths require an explicit path label (`Path:`, `File:`, `Files:`, `Saved to:`, `Saved as:`, `Wrote:`, `Written to:`, `Output:`, `Created:`, `Location:`, `Attachment:`), optionally preceded by a marker. Generic labels and bare bullets defer to ordinary detection, including slash-containing prose such as `CI/CD` or `45/45`. A later `/`, `~/`, `./` or `../` word starts a separate path; an earlier word ending in `/` is a separate folder. Absolute paths keep the own-line and marker forms plus the same path labels; any other label (`Error:`, `Note:`, `Saved the report to:`) defers an absolute path too. A rooted candidate needs a directory before its first space, so `/review the changes in src/a.ts` is prose. An earlier word holding a known extension anywhere (`a.ts#L12 foo.ts`) ends the path at that word, a `:` followed by a digit inside an earlier word is a line suffix (`a.ts:42:See b.md`, grep and compiler output) and ends the path there, a run of spaces is a column gap rather than a name, and a leading run of `./` or `../` is not a segment (`Saved to: ../My Docs/plan.md` links whole; an interior `..` is still refused). An unlinkable candidate reserves its span only while its last word holds a `/`, so `/usr/bin/env node build.js` keeps its ordinary links while `/etc/secret files/passwords.txt` links nothing. A path label vouches that a path follows, not for the words before it: a relative candidate whose only `/` word is its last is prose after the label (`Wrote: 120 lines to src/foo.ts`, `Path: see src/foo.ts`) and defers to the ordinary detectors. The cost is a labelled relative path whose only space is in its top directory, `Saved to: My Docs/plan.md` (and `Saved to: 03 Attachments/plan.pptx`), which splits as on master; `03 Attachments/Build SIPOC Design/Build-SIPOC.pptx`, `notes/a b/c.md`, `~/`, `./` and `../` forms and absolute paths still link whole. An earlier word that is a lowercase connective or verb (`SPACED_PATH_PROSE_WORD`: `and`, `or`, `to`, `in`, `the`, `is`, `has`, `contains`, `then`, `as` and a few more) or a bare `=` makes the candidate a sentence, in every shape: `/home/me/proj contains a.md`, `- /tmp/out has the logs in run.log`, `Path: /home/me/proj/out contains a.md`, `Files: src/components and utils/x.ts`, `Wrote: 3 files to docs/design notes/overview.md`. The cost is a name that uses one of those words (`Pros and Cons.md`, `How to deploy.md`, `Documents and Settings`), which splits as on master; `at` (`Screenshot 2026-10-24 at 10.00.00.png`) and `of` (`Copy of report.docx`) are left out of the list on purpose. What remains ambiguous is an own-line absolute path followed by words that hold none of them (`/home/me/proj/out report.pdf` reads like `/home/me/docs/Read Me.txt`), which links as one path. A command line is refused too: a first word that is an executable under `bin/`, `.bin/`, `scripts/` or `tools/` (`/opt/homebrew/bin/node server.js`, `/home/me/proj/scripts/deploy prod.yml`) or any `-x`/`--x` flag word (`/home/me/proj/node_modules/.bin/tsc -p tsconfig.json`); a bare `-` (`Docs - Copy`, `OneDrive - Contoso`) is not a flag. So is a shell prompt that prints an absolute working directory (WSL's `/mnt/c/...` is outside `$HOME`, so every earlier command in a shell's scrollback has one): a first word ending in `$` (`/home/me/proj$ cat notes.md`) and any earlier word with no letter or digit other than a bare `-` (`/mnt/c/proj ❯ code notes.md`, `/home/me/proj $ cat notes.md`, `/home/me/proj/out ✓ report.pdf`) refuse the candidate, while `Program Files (x86)` and `Docs - Copy` still link whole. The response viewer carries any leading `<strong>` label in a paragraph or list item, or one starting a later line of a paragraph (after a `<br>`, since chat renders with `breaks: true`), into detection without changing the label or displayed text, and `spacedFilePathOnLine()` judges it as it judges the same line in the terminal (`**Note:** /home/me/proj/out report.pdf` splits). A text node that follows any other inline element starts mid-line, so a placeholder word stands in for what precedes it (``Use `--config` /home/me/proj/cfg data.json`` splits as in the terminal), while a path inside inline code still links whole; paths split across inline elements remain detected per text node. Tests feed vendored marked output into the jsdom linkifier, so consumed bullets and bold labels are covered. Trailing whitespace uses linear `trimEnd()` before the length cap. -The terminal provider scans `terminalLinkLine()`, which extends `terminalPathLine()` for URL continuations in indented paragraphs and table cells. List markers, bare numbers and version tokens never continue a URL. A token with only hyphens, underscores or dots must end its cell (row end or a gap of at least two spaces), so prose such as `self-hosted runners` is not appended. A row end counts as a cell end, so `⏺ Docs live at https://example.com/guide/` followed by ` self-hosted` still links `.../guide/self-hosted`, and an indented `and/or the API reference covers it.` still links `.../guide/and/or`; both are pinned as known trade-offs. Such a token is still refused when it is code with a call paren (`client.get()`, `expect(x).toBe(y)`; a wiki title like `Mercury_(planet)` keeps its paren) or a word ending a sentence (`ready.`, `Done.`, `self-hosted.`; a file name keeps its period, so `install.html.` still joins). Whatever its punctuation, a token never continues a URL when it ends with a colon (a label such as `Failure/Error:` or `prometheus.io/scrape:`), when it is a measurement or clock time (`45%`, `45%|####`, `12.3MB/45.0MB`, `12.3ms`, `10:00:00`; percent-encoding such as `2026%20Plan` still joins), or when it is a `name=value` field (`API_KEY=...`, `level=info`) under a URL ending in `/`, since a query needs a `?` first (after `?`, `&` or `_` it is a query parameter and joins). A continuation must be indented (a column-0 row is new output, which keeps `status=200`, `24/7` and `really?` out), and a shell prompt (`user@host:`), a home path (`~/`), a whole email address or an HTTP status line never continues a URL. A URL in the last column of a space-aligned table never takes the next row's first cell: a continuation row must start inside the URL's own cell, so a gap of two or more spaces between its indent and where the URL starts on the row above means it is the next record. A divider row never continues the row above it, so a URL that reaches the right edge does not absorb a full-width `────` row below. A real plain slug followed by prose falls back to the original fragment link; `%`, `&`, `=`, `/` or `?` still qualify as stronger URL punctuation. `terminalCellWrappedPaths()` follows each table cell independently: a fragment carries on into the next row only when it ends its cell on `/` or `-` (where a wrapper breaks a path; codex's cell wrap never breaks at `_`), the continuation row is blank to the left of the cell (a wrapper wraps every column of a table row together, so a label there such as `Entry` or `│ Log │` is the next record, unless a left column's path continued on that row too; a left column of wrapped prose, as in a `Description | Path` table, fills that row as well, so the path beside it keeps its per-row fragment links, the safe direction), the next row's token passes `pathContinuation()`, and the cell is no wider than the one above. So a directory that is a whole cell value (`/home/u/proj` over `config.yaml`, or `/home/u/proj/app/` over a labelled `main.py`) keeps its folder link and the next row keeps its own file link. With no label to tell them apart, a directory written with its trailing slash over a relative path in the same column (`/home/u/proj/src/` over `lib/util.ts`) still joins, a known trade-off: it reads exactly like a wrap at `/`. Accepted paths displace other links on their cells. Full-width divider rows stop `terminalLogicalLine()` stitching for both links and touch selection. OSC 8 links confirm the real target URI before the shared opener runs. +The terminal provider scans `terminalLinkLine()`, which extends `terminalPathLine()` for URL continuations in indented paragraphs and table cells. List markers, bare numbers and version tokens never continue a URL. A token with only hyphens, underscores or dots must end its cell (row end or a gap of at least two spaces), so prose such as `self-hosted runners` is not appended. A row end counts as a cell end, so `⏺ Docs live at https://example.com/guide/` followed by ` self-hosted` still links `.../guide/self-hosted`, and an indented `and/or the API reference covers it.` still links `.../guide/and/or`; both are pinned as known trade-offs. Such a token is still refused when it is code with a call paren (`client.get()`, `expect(x).toBe(y)`; a wiki title like `Mercury_(planet)` keeps its paren) or a word ending a sentence (`ready.`, `Done.`, `self-hosted.`; a file name keeps its period, so `install.html.` still joins). Whatever its punctuation, a token never continues a URL when it ends with a colon (a label such as `Failure/Error:` or `prometheus.io/scrape:`), when it is a measurement or clock time (`45%`, `45%|####`, `12.3MB/45.0MB`, `12.3ms`, `10:00:00`; percent-encoding such as `2026%20Plan` still joins), or when it is a `name=value` field (`API_KEY=...`, `level=info`) under a URL ending in `/`, since a query needs a `?` first (after `?`, `&` or `_` it is a query parameter and joins). A continuation must be indented (a column-0 row is new output, which keeps `status=200`, `24/7` and `really?` out), and a shell prompt (`user@host:`), a home path (`~/`), a whole email address or an HTTP status line never continues a URL. A URL in the last column of a space-aligned table never takes the next row's first cell: a continuation row must start inside the URL's own cell, so a gap of two or more spaces between its indent and where the URL starts on the row above means it is the next record. When other columns follow a wrapped URL on its first row (a codex table's path or date column), the URL is stitched without them, and they still link on that row: `terminalLinkLine()` scans them after the stitched rows as a segment of their own, mapped back to their cells. A URL in such a column is the row's last URL, and only the last URL on a row is continued, so that row is never stitched. A divider row never continues the row above it, so a URL that reaches the right edge does not absorb a full-width `────` row below. A real plain slug followed by prose falls back to the original fragment link; `%`, `&`, `=`, `/` or `?` still qualify as stronger URL punctuation. `terminalCellWrappedPaths()` follows each table cell independently: a fragment carries on into the next row only when it ends its cell on `/` or `-` (where a wrapper breaks a path; codex's cell wrap never breaks at `_`), the continuation row is blank to the left of the cell (a wrapper wraps every column of a table row together, so a label there such as `Entry` or `│ Log │` is the next record, unless a left column's path continued on that row too; a left column of wrapped prose, as in a `Description | Path` table, fills that row as well, so the path beside it keeps its per-row fragment links, the safe direction), the next row's token passes `pathContinuation()`, and the cell is no wider than the one above. So a directory that is a whole cell value (`/home/u/proj` over `config.yaml`, or `/home/u/proj/app/` over a labelled `main.py`) keeps its folder link and the next row keeps its own file link. With no label to tell them apart, a directory written with its trailing slash over a relative path in the same column (`/home/u/proj/src/` over `lib/util.ts`) still joins, a known trade-off: it reads exactly like a wrap at `/`. Accepted paths displace other links on their cells. Full-width divider rows stop `terminalLogicalLine()` stitching for both links and touch selection. OSC 8 links confirm the real target URI before the shared opener runs. **Folder grid: a folder is granted, then served by bare file name.** A folder path (rooted or `~/` whose last segment has no extension, relative only with a trailing `/`; `FOLDER_PATH_LINK_PATTERN` + `folderLinkPath()`) opens `openFolderGrid()` (panels-ui.js), as does the preview's "Show folder" button. `POST /api/sessions/:id/folders {path}` (`src/folder-listing.ts`) resolves the folder (locally a bounded `probePathKind(…, { pastCap: true })` first, then async `fs.realpath`; remotely one ssh round trip via `remoteListFolder`, through `runWithRemoteSshLimit`), runs the attachment guard on it (blocklist, `confineToWorkspace`, and in multi-user mode `folderAllowedForUser`: a non-admin only inside their own `userSpacePath` or the session's `workingDir`, as the filesystem path picker), locally BEFORE reading it, lists non-hidden subfolders and files of a supported attachment type (each file also blocklist-checked, newest first, capped at `MAX_FOLDER_ENTRIES`), and GRANTS it an id. Files are then fetched as `GET …/folders/:folderId/files/:name/(raw|preview|thumbnail)`: `:name` must be a bare file name of a supported type (no `/`, `\`, NUL, no leading `.`, since the listing hides those), the local file is probed bounded (`unknown` → 503, never 404), and the joined path goes through the SAME `resolveServableAttachmentPath` as an attachment by id (symlink re-resolution, remote re-probe, guard), then the same gate registration applies to what a symlink RESOLVES to (a supported extension on the resolved path and a regular file: locally `fs.stat`, remotely the probe kind; else 400, so `shot.png -> ../secret.bin` is not served and a FIFO or device behind a supported name never reaches `open`), then `folderAllowedForUser` once more on the resolved file, before `serveServableRaw` (the attachment raw route's body, shared) streams it. So browser requests still never carry a path. ⚠️ Grants live in their own registry (`folderGrants`, 32 per session, cleared with the session's attachments), NOT the attachment registry: that one is capped at 200 per session, and paging a folder of 300 renders would evict the session's real attachments. ⚠️ A local folder is never touched unbounded (#516 rule): a folder on a network mount that stopped answering answers 503 with `describeUnknownPath()`, not 404 and not a frozen server. ⚠️ The remote listing script tells "missing" (`E|n`, 404) from "is a file" (`E|f`, 400 `Not a folder`), because the client falls back to the file preview on `Not a folder`; a remote extensionless file otherwise answered "Folder not found". ⚠️ `.folder-grid-overlay` sits at `z-index: 5090`, one step BELOW the preview (5100) and above the response viewer (5000): a tile's preview opens on top and closing it lands back on the grid. ⚠️ ←/→/Esc are handled in a document CAPTURE listener with `stopPropagation`, because the terminal keeps keyboard focus under the overlays and an arrow that reached xterm would be typed into the session; inputs other than xterm's helper textarea keep their keys. ⚠️ A route PARAMETER is capped by the router (Fastify's default is 100 characters, answered as 404 "Route not found"), and a file name goes up to 255, percent-encoded: the server sets `routerOptions.maxParamLength` to `MAX_ROUTE_PARAM_LENGTH` (route-helpers.ts), the route test harness uses the same constant, and a wiring guard pins it. ⚠️ `~/` in a Docker case is refused with 400 (attachment POST and `POST …/folders`): the session is local for these routes, but `~` inside the container is the container's own home, and expanding it on the host would open a different file than the one the agent printed. Tests: `test/routes/folder-grid-routes.test.ts` (incl. the listing script on a real `sh`), `test/folder-grid-ui.test.ts`. diff --git a/src/web/public/constants.js b/src/web/public/constants.js index 062cd139c..742ac363a 100644 --- a/src/web/public/constants.js +++ b/src/web/public/constants.js @@ -2859,7 +2859,8 @@ function terminalUrlContinuationExtent(buffer, row, cols, maxRows) { * continues from, which fills the width), a HARD continuation's indent is * dropped (the cell mapping keeps the cells), and the first row stops at the * URL's cell when other table columns follow it, so those columns never sit - * between the URL's pieces. + * between the URL's pieces. Those columns still link: they are moved after the + * stitched rows as a segment of their own, mapped back to their cells. * * @returns Same shape as {@link terminalPathLine}; null when the row does not exist. */ @@ -2872,12 +2873,16 @@ function terminalLinkLine(buffer, row, cols, maxRows) { const endRow = Math.max(extent.endRow, base.endRow); const segments = []; let text = ''; + let columnsAfterUrl = ''; for (let r = startRow; r <= endRow; r++) { const line = buffer.getLine(r); if (!line) break; const next = r < endRow ? buffer.getLine(r + 1) : null; let rowText = line.translateToString(!(next && next.isWrapped)) || ''; - if (r === extent.startRow && extent.firstRowUrlEnd != null) rowText = rowText.slice(0, extent.firstRowUrlEnd); + if (r === extent.startRow && extent.firstRowUrlEnd != null) { + columnsAfterUrl = rowText.slice(extent.firstRowUrlEnd); + rowText = rowText.slice(0, extent.firstRowUrlEnd); + } let colStart = 0; if (r > startRow && !line.isWrapped) { colStart = rowText.length - rowText.replace(/^\s+/, '').length; @@ -2886,6 +2891,17 @@ function terminalLinkLine(buffer, row, cols, maxRows) { segments.push({ row: r, textStart: text.length, colStart }); text += rowText; } + const lastRow = segments[segments.length - 1].row; + // The columns cut off the URL's first row (a path or date column of a table) + // are scanned after the stitched rows, so they keep their links on that row. + // They open with the cell gap, which keeps them apart from the URL; the one + // space before them belongs to the last row, so an end offset there still + // maps onto that row and never onto their first cell. + if (columnsAfterUrl.trim()) { + text += ' '; + segments.push({ row: extent.startRow, textStart: text.length, colStart: extent.firstRowUrlEnd }); + text += columnsAfterUrl; + } const offsetToCell = (offset) => { for (let i = segments.length - 1; i >= 0; i--) { const seg = segments[i]; @@ -2893,11 +2909,13 @@ function terminalLinkLine(buffer, row, cols, maxRows) { } return { row: startRow, col: offset }; }; + // The cut row has two segments: the one starting at or left of the cell owns it. const cellToOffset = (targetRow, targetCol) => { - const seg = segments.find((s) => s.row === targetRow); + let seg = null; + for (const s of segments) if (s.row === targetRow && (!seg || s.colStart <= targetCol)) seg = s; return seg ? seg.textStart + Math.max(0, targetCol - seg.colStart) : -1; }; - return { startRow, endRow: segments[segments.length - 1].row, text, offsetToCell, cellToOffset }; + return { startRow, endRow: lastRow, text, offsetToCell, cellToOffset }; } /** diff --git a/test/terminal-url-continuation.test.ts b/test/terminal-url-continuation.test.ts index e91c58609..3d03c8800 100644 --- a/test/terminal-url-continuation.test.ts +++ b/test/terminal-url-continuation.test.ts @@ -30,7 +30,7 @@ type Link = { function loadPure() { const context = vm.createContext({ console, window: undefined }); vm.runInContext( - `${CONSTANTS}\nglobalThis.__urls = findTerminalUrls; globalThis.__extent = terminalUrlContinuationExtent; globalThis.__logical = terminalLogicalLine;`, + `${CONSTANTS}\nglobalThis.__urls = findTerminalUrls; globalThis.__extent = terminalUrlContinuationExtent; globalThis.__logical = terminalLogicalLine; globalThis.__linkLine = terminalLinkLine;`, context, { filename: 'constants.js' } ); @@ -38,14 +38,24 @@ function loadPure() { __urls: (text: string) => UrlRef[]; __extent: (buffer: unknown, row: number, cols: number, maxRows?: number) => Extent; __logical: (buffer: unknown, row: number, cols: number, maxRows?: number) => Extent; + __linkLine: (buffer: unknown, row: number, cols: number, maxRows?: number) => LinkLine | null; }; return { findTerminalUrls: ctx.__urls, terminalUrlContinuationExtent: ctx.__extent, terminalLogicalLine: ctx.__logical, + terminalLinkLine: ctx.__linkLine, }; } +type LinkLine = { + startRow: number; + endRow: number; + text: string; + offsetToCell: (offset: number) => { row: number; col: number }; + cellToOffset: (row: number, col: number) => number; +}; + function fakeBuffer(rows: Row[], cols: number) { const lines = rows.map((row) => ({ isWrapped: row.isWrapped ?? false, @@ -94,6 +104,32 @@ const TABLE_WITH_TRAILING_COLUMNS: Row[] = [ TABLE[4], ]; const TABLE_COLS = 220; +// A wrapped URL with another column to its right. The URL's first row is cut at +// its cell to stitch the URL, and the column after it must still link on that +// row (a release-review regression: only the URL's pieces were offered there). +const WIKI_URL = 'https://wiki.example.com/spaces/ENG/pages/12345/Design-Spec-v2'; +const URL_THEN_PATH_COLS = 140; +const URL_THEN_PATH_ROWS: Row[] = [ + { text: ' 1 Design spec https://wiki.example.com/spaces/ENG/pages/12345/ /home/me/proj/docs/design.md' }, + { text: ' Design-Spec-v2' }, + { text: ' 2 Notes n/a /home/me/proj/notes.md' }, +]; +// The same shape with a relative and a home path in the columns after the URL. +const URL_THEN_PATHS_ROWS: Row[] = [ + { + text: ' 1 Design spec https://wiki.example.com/spaces/ENG/pages/12345/ docs/design.md ~/notes/todo.txt', + }, + { text: ' Design-Spec-v2' }, +]; +// A URL in the column after the wrapped one is the row's LAST URL, and only the +// last URL on a row is continued, so this row is never stitched or cut: both URLs +// link on row 1 as single-row links, as before the wrap joining existed. +const URL_THEN_URL_ROWS: Row[] = [ + { + text: ' 1 Design spec https://wiki.example.com/spaces/ENG/pages/12345/ https://other.example.com/notes.md', + }, + { text: ' Design-Spec-v2' }, +]; const PROSE_ROWS = [ [ '⏺ The guide lives at https://example.com/guide/', @@ -457,6 +493,32 @@ describe('terminalUrlContinuationExtent (wrap joining)', () => { }); }); +describe('terminalLinkLine (the text the provider scans)', () => { + const { terminalLinkLine } = loadPure(); + + it('keeps the columns after a wrapped URL, mapped back to their own cells', () => { + const buffer = fakeBuffer(URL_THEN_PATH_ROWS, URL_THEN_PATH_COLS); + const row = URL_THEN_PATH_ROWS[0].text; + const pathCol = row.indexOf('/home/'); + const urlCol = row.indexOf('https://'); + for (const hovered of [0, 1]) { + const line = terminalLinkLine(buffer, hovered, URL_THEN_PATH_COLS, 12)!; + expect(line).toMatchObject({ startRow: 0, endRow: 1 }); + // The URL reads whole, and the path column follows it. + expect(line.text).toContain(WIKI_URL); + const pathOffset = line.text.indexOf('/home/me/proj/docs/design.md'); + expect(pathOffset).toBeGreaterThan(line.text.indexOf(WIKI_URL)); + expect(line.offsetToCell(pathOffset)).toEqual({ row: 0, col: pathCol }); + expect(line.cellToOffset(0, pathCol)).toBe(pathOffset); + expect(line.cellToOffset(0, urlCol)).toBe(line.text.indexOf(WIKI_URL)); + // The end of the URL's last row still maps onto that row, never onto the + // first cell of the column moved after it. + const urlEnd = line.text.indexOf(WIKI_URL) + WIKI_URL.length; + expect(line.offsetToCell(urlEnd)).toEqual({ row: 1, col: URL_THEN_PATH_ROWS[1].text.length }); + } + }); +}); + describe('native OSC 8 terminal URLs', () => { it('opens complete HTTP links through the terminal opener and rejects unsafe schemes', () => { const open = vi.fn(); @@ -538,7 +600,7 @@ describe('terminal link provider with wrapped table URLs', () => { }); return links ?? []; }; - return { provide, openSpy }; + return { provide, openSpy, app }; } it.each(PROSE_ROWS)('preserves the original clickable URL before prose: %s', (first, next, url) => { @@ -668,6 +730,58 @@ describe('terminal link provider with wrapped table URLs', () => { } }); + it('links the path in the column right of a wrapped URL on the URL first row', () => { + const { provide, app } = harness(URL_THEN_PATH_ROWS, URL_THEN_PATH_COLS); + const pathStart = URL_THEN_PATH_ROWS[0].text.indexOf('/home/'); + const links = provide(1); + const path = links.find((link) => link.text === '/home/me/proj/docs/design.md'); + expect(path).toBeDefined(); + expect(path!.range.start).toEqual({ x: pathStart + 1, y: 1 }); + expect(path!.range.end.y).toBe(1); + // The stitched URL keeps one link per row, and its first-row piece ends + // before the path's column. + const urlPieces = links.filter((link) => /^https?:/.test(link.text)); + expect(new Set(urlPieces.map((link) => link.text))).toEqual(new Set([WIKI_URL])); + expect(urlPieces.map((link) => link.range.start.y)).toEqual([1, 2]); + expect(urlPieces[0].range.end.x).toBeLessThan(pathStart + 1); + // Nothing else is linked, so nothing glues the column onto the URL. + expect(links.map((link) => link.text).sort()).toEqual([WIKI_URL, WIKI_URL, '/home/me/proj/docs/design.md'].sort()); + path!.activate(null, path!.text); + expect(app.openFilePreview).toHaveBeenCalledExactlyOnceWith('/home/me/proj/docs/design.md', 's1'); + // The URL's second row still opens the whole URL, and the next record keeps its path. + expect( + new Set( + provide(2) + .filter((link) => /^https?:/.test(link.text)) + .map((link) => link.text) + ) + ).toEqual(new Set([WIKI_URL])); + expect(provide(3).map((link) => link.text)).toContain('/home/me/proj/notes.md'); + }); + + it('links a relative and a home path in the columns right of a wrapped URL', () => { + const { provide } = harness(URL_THEN_PATHS_ROWS, URL_THEN_PATH_COLS); + const row = URL_THEN_PATHS_ROWS[0].text; + const links = provide(1); + const startOf = (text: string) => links.find((link) => link.text === text)?.range.start; + expect(startOf('docs/design.md')).toEqual({ x: row.indexOf('docs/design.md') + 1, y: 1 }); + expect(startOf('~/notes/todo.txt')).toEqual({ x: row.indexOf('~/notes/todo.txt') + 1, y: 1 }); + expect(new Set(links.filter((link) => /^https?:/.test(link.text)).map((link) => link.text))).toEqual( + new Set([WIKI_URL]) + ); + }); + + it('links a URL in the column right of another URL whole, without stitching either', () => { + const { provide } = harness(URL_THEN_URL_ROWS, URL_THEN_PATH_COLS); + const row = URL_THEN_URL_ROWS[0].text; + const links = provide(1).filter((link) => /^https?:/.test(link.text)); + expect(links.map((link) => [link.text, link.range.start.x, link.range.start.y])).toEqual([ + ['https://wiki.example.com/spaces/ENG/pages/12345/', row.indexOf('https://wiki') + 1, 1], + ['https://other.example.com/notes.md', row.indexOf('https://other') + 1, 1], + ]); + expect(provide(2).filter((link) => /^https?:/.test(link.text))).toEqual([]); + }); + it('underlines only the URL cells on each row, never the indent between pieces', () => { const { provide } = harness(TABLE, COLS); const segments = provide(3).filter((link) => link.text === URL); From de1aab9b8691a5a52032da0cbf18e16bb98f1cc0 Mon Sep 17 00:00:00 2001 From: Codeman maintainer <noreply@anthropic.com> Date: Tue, 27 Oct 2026 03:11:52 +0100 Subject: [PATCH 64/68] chore: one changeset for 1.46.0 Replaces the six contributor changesets (#694, #707, #708, #709, #718, #713) with one consolidated minor changeset covering all 14 PRs, with the Thanks section first and the release-review fixes. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- .changeset/bounded-agent-coordination.md | 17 ---------- .changeset/deba6392.md | 40 +++++++++++++++++++++++ .changeset/offline-codex-history.md | 5 --- .changeset/project-worker-directories.md | 7 ---- .changeset/tidy-workers-return.md | 5 --- .changeset/tunnel-own-acknowledgement.md | 5 --- .changeset/zerolag-overlay-render-cost.md | 6 ---- 7 files changed, 40 insertions(+), 45 deletions(-) delete mode 100644 .changeset/bounded-agent-coordination.md create mode 100644 .changeset/deba6392.md delete mode 100644 .changeset/offline-codex-history.md delete mode 100644 .changeset/project-worker-directories.md delete mode 100644 .changeset/tidy-workers-return.md delete mode 100644 .changeset/tunnel-own-acknowledgement.md delete mode 100644 .changeset/zerolag-overlay-render-cost.md diff --git a/.changeset/bounded-agent-coordination.md b/.changeset/bounded-agent-coordination.md deleted file mode 100644 index 62724678b..000000000 --- a/.changeset/bounded-agent-coordination.md +++ /dev/null @@ -1,17 +0,0 @@ ---- -"aicodeman": minor ---- - -Add experimental bounded agent inspection with expiring scoped cursors (the newest 8 kept per caller and session) and explicit pane snapshots, plus staged local Codex input and single-use hash-confirmed submission receipts. Verify the exact composer, caret, lifecycle and input revision before sending Enter, and serialize dispatch against managed input and auxiliary key operations. - -The three new CLI verbs (`codeman agent inspect`, `stage`, `submit`) and their HTTP routes (`/api/v1` and `/api`) are labeled experimental, so the versioning policy's rule for experimental surfaces applies: their shape and behavior may change in minor or patch releases. They are opt-in behind a new synced setting, `agentCoordinationEnabled` (App Settings, Cross-session features, "Experimental agent coordination", default OFF, translated for zh-CN): while it is off all three routes answer `403 FORBIDDEN` with a message naming it, and the CLI verbs print that refusal verbatim. - -Codex rollout attribution remembers the attributed file per session and re-verifies only that file's head on later calls, rescanning only the date directories not older than it for a codex `/new`, so a large never-pruned `~/.codex/sessions` store no longer falls back to a pane snapshot or costs a full walk per call. An unexpected inspection read error answers `422 OPERATION_FAILED` and is logged with the session id and error code only, instead of a retryable `409`. - -The guarded composer read takes its glyph and footer from the CLI registry: codex's `workDetect.promptGlyph` and a new `workDetect.composerFooterLine` capability (config-supplied regex, compiled through the ReDoS guard). Only a CLI declaring it takes guarded input. Codex's footer effort words are now one shared list for this and `modelDetect.screenLine`. - -Stage waits for the pane to show what it typed: it re-reads the composer every 50 ms for up to one second and issues a receipt only for a frame showing exactly the message (Codex repaints 30 to 60 ms after the keys, so the first capture was often stale and stage refused with the text already typed). Every refusal after typing now says the message may be in the composer and points at the Ctrl+U recovery. A visible-frame pane capture reads the screen and the caret in one tmux invocation, falling back to the two separate calls only for output it cannot split; a call that fails is not repeated. Stage refuses messages that would change what Enter does (codex: a leading `/` or `!`, or a last word starting with `@` or `$`, which open a command or completion popup) and frames with a suggestion popup above the composer, from three new registry fields (`workDetect.composerPopupLine`, `composerCommandPrefixes`, `composerCompletionSigils`). Submit waits until 250 ms after the staged keys before sending Enter, since codex turns an Enter within 120 ms of typed text into a newline. Input that arrives while a guarded submit holds its input lease (the few milliseconds of its Enter) is held and written in arrival order once the lease is released, so a browser keystroke, a Shift+Enter or Ctrl+Enter, or an approval answer typed in that window is no longer lost; a WebSocket frame is acknowledged only once it is written. `POST /api/sessions/:id/input` answers `409 CONFLICT` with its seq returned for that span instead of a `200` for input that was never written, and a lease that would hold more than 256 inputs or 1 MiB closes the terminal WebSocket (code 1013) so the browser resends in order, and answers `send-key` with `409 CONFLICT`. - -Document the initial local Codex support and conversation-attribution limits in the API, CLI and packaged skill. Other modes retain labeled snapshots or refuse unverifiable guarded input. - -SubmitVerifier Enter re-presses now use the shared input boundary and update `lastSubmitAt` for each retry on every supported CLI. This keeps submission timestamps consistent with delivered Enter attempts while preventing a stale retry from bypassing a guarded submission. diff --git a/.changeset/deba6392.md b/.changeset/deba6392.md new file mode 100644 index 000000000..c0835570d --- /dev/null +++ b/.changeset/deba6392.md @@ -0,0 +1,40 @@ +--- +"aicodeman": minor +"xterm-zerolag-input": patch +--- + +### Thanks + +- @JDProfresh for five PRs: a phone keyboard that no longer costs Claude its transcript (#711, measured in #672), a + on each session tab that starts a session beside it (#712, shaped in Discussion #700), a tunnel that needs its own opt-in instead of the LAN one (#718, from their report #706), a settings.json that two writers can no longer tear (#719), and a DeepSeek test that passes on current Node (#710). +- @aakhter for six PRs: `codeman agent spawn` in a project directory (#708), worker ids kept when the readiness check fails (#709), the offline Codex history reader (#707), experimental bounded inspection and guarded submission for agents (#694, from Discussion #695), links that follow URLs and paths across wrapped rows and table cells (#686), and file paths with spaces linked as one path (#687). +- @opticon454 for the Fetch button and fetch interval in the Git status window (#714). +- @johoja12 for Cmd+K arrows that switch the CLI both ways after you type a case name (#717). + +**Fetch from the Git status window (#714).** The Git status window has a Fetch button next to Refresh. It runs `git fetch` for the repositories it lists and shows each one's result. An optional interval runs it for you: off by default, app-wide in App Settings, Bottom bar, with a per-session override in Session Options, Git. A fetch only moves remote-tracking refs (HEAD, the index and the working tree stay as they are), it never runs for a Docker or remote session, and it is bounded in time, output and concurrency. + +**Start a session beside any tab (#712, opt-in).** Turn on **New Session Button on Tabs** (App Settings, Tabs) and each session tab gets a +: pick any CLI its case can run (a Docker case offers what its container has) and the new session starts in that case or worktree and that tab's group, without touching the toolbar's case or Run mode. The row menu offers the same as "New session here…" for the keyboard. + +**The phone keyboard no longer costs Claude its transcript (#711).** Every keyboard open and close used to resize the PTY, and Claude Code answers each height change by reprinting only the tail that fits, so lines above it were lost from tmux for good (25 of 100 in five keyboard cycles). A local Claude session now keeps its rows while the keyboard is up and slides the screen so the composer stays above the keyboard. On by default as **Keep Size Under Keyboard** (App Settings, per device); off restores the old resize. Fullscreen Claude (mouse tracking on) still resizes as before. + +**Typing stays smooth in a long prompt (#713).** The local echo overlay (the instant preview of what you type) rebuilt every character of the prompt on each keystroke, so on a phone-class CPU a 4,000-character prompt took most of a second per key. It now rebuilds only the line that changed, with one paint layer per line, so a keystroke costs the same however long the prompt is. Every character still sits on its terminal cell. This ships in `xterm-zerolag-input` too. + +**Links survive wrapping and spaces (#686, #687).** A URL or path a CLI wrapped inside a table cell or an indented paragraph now links whole instead of in fragments, URLs keep balanced parentheses (`…/Team-Space(QA)Archive/…`), a full-width table divider no longer glues onto the row below, and OSC 8 hyperlinks open through Codeman's own opener after a confirmation. A file path with spaces (`/mnt/c/OneDrive - Contoso/report.docx`, `Saved to: ~/My Docs/plan.md`) links as one path in the terminal and the response viewer when the line bounds it, and a shell prompt that prints its working directory no longer turns the whole command line into one link. + +**`codeman agent spawn` in a project directory (#708).** Without a case name, `spawn` starts the worker in the current directory, or in an existing directory given with `--working-dir`, through `POST /api/v1/sessions` and the mode's start route. Named cases still use quick-start. `--json` prints `{ sessionId, workingDir, created: true }` plus `ready`, `readinessError` or `startupError`. An empty `--working-dir`, a bare spawn from your home directory (it would install Codeman's hooks into `~/.claude`; pass `--working-dir ~` to mean it) and an unknown `--mode` are refused with exit 4 before any request. + +**Read saved Codex conversations offline (#707).** `codeman history read` reads Codex's saved rollouts without a Codeman server: by file, by Codex session id, or a whole conversation with its sub-agents and forks, filtered by topic or UTC date with whole turns kept. Reads and output have separate, raisable budgets, and anything it could not read or attribute (a compressed `.jsonl.zst` rollout, a file still being written) is reported through `complete: false` instead of failing the lookup. The codeman skill routes to it. + +**Experimental: bounded inspection and guarded submission for agents (#694, opt-in).** `codeman agent inspect` reads bounded, cursor-paged evidence from another session (a local Codex session's attributed rollout records, otherwise a labelled pane snapshot), and `agent stage` / `agent submit` type one line into a verified local Codex composer and later send exactly one Enter against a hash-confirmed receipt. Off by default behind **Experimental agent coordination** (App Settings, Cross-session features); while off the three routes answer `403 FORBIDDEN` naming the setting. These routes and verbs are experimental under the versioning policy and may change in a minor or patch release. During the few milliseconds of a guarded submit, other input is held and written in order afterwards; a tagged input POST in that window answers `409 CONFLICT` so its sender retries, and an untagged one (the phone keys, the Stop button) is held and answered once written. + +**Cmd+K arrows switch the CLI both ways (#717).** After typing a case name in Cmd+K, plain Left and Right now both change the New session row's CLI whenever the caret is at either end of the query (only Right did before). Inside the query the arrows move the caret as usual. + +**Fixes.** + +- **Security: a LAN install's acknowledgement no longer opens a public tunnel (#718).** `CODEMAN_ALLOW_UNAUTHENTICATED_NETWORK` (and `--allow-unauthenticated-network`), which the installer writes for every LAN install without a password, also let the Cloudflare tunnel start, so one click on the welcome screen's tunnel link published full terminal control to a public URL with no login and no confirm. It now covers the network bind only. An unauthenticated tunnel has its own opt-in, `CODEMAN_ALLOW_UNAUTHENTICATED_TUNNEL=1` or `codeman web --allow-unauthenticated-tunnel` (carried by `codeman web -d` and `codeman service install`), which the installer never writes; if you relied on the network variable to start the tunnel without a password, set one of those. With no password and no multi-user mode, the browser now asks before every tunnel enable, using the new `loginConfigured` field of `GET /api/tunnel/status`. +- **settings.json can no longer be torn (#719).** A quick-start's `lastUsedCase` write was not awaited and not atomic, so it could overlap the next settings save and leave one write's bytes over the tail of the other, after which every reader saw `{}`. Every write to settings.json now goes through one serialized writer that writes a temp file and renames it into place, and a file that does not parse is refused with `409 CONFLICT` instead of being overwritten with only the writer's own keys. A case rename checks this before it ends any session. +- **A worker id is never lost (#709).** When `codeman agent spawn` created a worker but its readiness request failed, it exited without the new session id. It now prints the id (and `--json` carries it) and exits 1, so the caller can inspect or delete the worker. `codeman agent` and `codeman mcp` also name the error code (such as `ECONNREFUSED`) when a request fails with an empty message. +- **The DeepSeek transcript test passes on Node 24.22+ and 26.10+ (#710).** It pinned Node's old zstd decoder, which stopped at the first frame until nodejs/node#65865. + +**Fixes applied while landing.** The Git fetch interval field is no longer reset by a session update while you type in it, a session holding only half an override shows the app's value for the other half, reading a repository's `core.sshCommand` no longer doubles the fetch timeout, a `GIT_SSH` in the server's environment is honoured, and the Fetch button's disabled and busy styles no longer reach other icon buttons and stop pulsing under reduced motion (#714). A + launch keeps the toolbar on its own case, and pressing the + again closes its picker (#712). The fetch scheduler no longer logs the text of a malformed settings.json on every 15-second tick (the parser's message could quote an API key; it now says the file is not valid JSON, once), and a fetch is skipped while the Docker case list cannot be read, so it can never run inside a Docker workspace (#714). A case rename answers `409 CONFLICT` for a settings.json it cannot write before it ends any session (#719). The unauthenticated-tunnel warning no longer fires in multi-user mode, where the tunnel has a login in front of it (#718). Untagged input during a guarded submit is held instead of refused, and the skill's staging guide says so (#694). A shell prompt line (a spaced working directory followed by `$` included) and a labelled `../` spaced path link as they should, and a bold label or inline code before a path in the response viewer splits it as the terminal does (#687); a URL whose row soft-wraps no longer glues a trailing column onto the next path, and in a table a link in the column right of a wrapped URL stays clickable on the URL's first row (#686). The keyboard hold reads the terminal's height only while rows are held (#711). The README, the plugin's README and its marketplace description now say the codeman skill reads saved Codex conversations outside a Codeman session and refuses everything else there (#707). + +**Docs and tests.** New sections for the keyboard hold (next to the terminal geometry rule it is an exception to), the tab +, Git fetch, the settings writer, the tunnel opt-in, the directory spawn, the offline history reader and the experimental agent routes, in CLAUDE.md, the API reference, the architecture notes, the wiki and the codeman skill. diff --git a/.changeset/offline-codex-history.md b/.changeset/offline-codex-history.md deleted file mode 100644 index f0a2eb0c4..000000000 --- a/.changeset/offline-codex-history.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -'aicodeman': minor ---- - -Add offline `codeman history read` for saved Codex transcripts by file, native session ID, or conversation family (spawned sub-agents and forks included), with whole-turn topic and UTC date filters. A forked or spawned thread's copy of its parent's history is dropped, so every record belongs to the thread that wrote it. Store attribution has its own budget (`--metadata-bytes`), separate from the transcript budget (`--scan-bytes`), and every bound is raisable (`--max-entries` too), so id lookups keep working on stores of thousands of rollouts. A store file that cannot be attributed, an unreadable directory, or a rollout a running Codex is still appending to no longer fails the lookup; each is reported through `complete: false`, as is a Codex rollout compressed to `.jsonl.zst` (counted in `compressedFiles` and named when a lookup misses). A parent file read to prove where a forked copy ends is charged to `--scan-bytes` once, and a parent that cannot be read leaves that boundary unproven instead of failing the child. Assistant records carry Codex's own phase and user records none. The Past Sessions scanner and the offline reader now share one Codex rollout format module, including the injected-context rule. diff --git a/.changeset/project-worker-directories.md b/.changeset/project-worker-directories.md deleted file mode 100644 index 594ccef06..000000000 --- a/.changeset/project-worker-directories.md +++ /dev/null @@ -1,7 +0,0 @@ ---- -"aicodeman": minor ---- - -Allow `codeman agent spawn` to start workers in the current project directory or an existing directory selected with `--working-dir`, while preserving explicit case quick-start behavior. A directory launch uses `POST /api/v1/sessions` followed by the mode's start route (`/interactive` or `/shell`), and its `--json` output is `{ sessionId, workingDir, created: true }`, plus `ready` when a readiness wait ran, `readinessError` when that call failed, and `startupError` when the start failed (exit 1). Only an omitted case means "use a directory": an empty case name still goes to quick-start, which refuses it, and an empty or whitespace-only `--working-dir` is refused locally (exit 4) before any request instead of resolving to the cwd. A cwd that resolves to the home directory is refused the same way, because the server would install its workspace hooks (and an injected skill) into `~/.claude`; an explicit `--working-dir ~` is still accepted. - -One change for named-case spawns too: an unknown `--mode` is now refused locally (exit 4) before any request, where it used to reach quick-start and come back as the server's 400 (exit 1). diff --git a/.changeset/tidy-workers-return.md b/.changeset/tidy-workers-return.md deleted file mode 100644 index 812810a03..000000000 --- a/.changeset/tidy-workers-return.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -'aicodeman': patch ---- - -Preserve the created session ID in agent spawn output when the readiness request fails with a transport error. diff --git a/.changeset/tunnel-own-acknowledgement.md b/.changeset/tunnel-own-acknowledgement.md deleted file mode 100644 index 1b8858ad0..000000000 --- a/.changeset/tunnel-own-acknowledgement.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -'aicodeman': patch ---- - -Security fix: `CODEMAN_ALLOW_UNAUTHENTICATED_NETWORK` (and `--allow-unauthenticated-network`) no longer lets the Cloudflare tunnel start without a password. It now acknowledges only an unauthenticated network bind, which is what the installer writes it for on a LAN install without a password, so on those installs one click on the welcome screen's tunnel link published full terminal control to a public URL with no login and no confirm. An unauthenticated tunnel has its own opt-in now, `CODEMAN_ALLOW_UNAUTHENTICATED_TUNNEL=1` or `codeman web --allow-unauthenticated-tunnel` (carried by `codeman web -d` and `codeman service install`), which the installer never writes. If you relied on the network variable to start the tunnel without a password, set one of those instead. Without it the server still accepts an enable that carries `acknowledgeUnauthTunnel: true`, as before. With no password and no multi-user mode, the browser now asks before every tunnel enable from the welcome link, the tunnel panel and App Settings, whatever the server would accept, using the new `loginConfigured` field of `GET /api/tunnel/status`. diff --git a/.changeset/zerolag-overlay-render-cost.md b/.changeset/zerolag-overlay-render-cost.md deleted file mode 100644 index 64216ecc5..000000000 --- a/.changeset/zerolag-overlay-render-cost.md +++ /dev/null @@ -1,6 +0,0 @@ ---- -"aicodeman": patch -"xterm-zerolag-input": patch ---- - -**Typing stays smooth in a long prompt.** The local echo overlay (the instant preview of what you type before the session echoes it back) used to throw away and rebuild every character of the prompt on each keystroke, so the longer the prompt, the slower each key. On a phone-class CPU it dropped below 60 fps at about 300 characters, and at about 4,000 characters a single keystroke took most of a second to appear. It now rebuilds only the line that changed, and each line is one paint layer instead of one layer per character, so a keystroke costs the same however long the prompt is. What you see is unchanged: every character still sits on its terminal cell, within 1/8 px. This ships in `xterm-zerolag-input` as well: the README's rendering section describes the new layout. From c04fa97f78d4c888b627614142632c338d8fc8af Mon Sep 17 00:00:00 2001 From: Codeman maintainer <noreply@anthropic.com> Date: Tue, 27 Oct 2026 09:11:19 +0100 Subject: [PATCH 65/68] chore: replace the em-dashes this release added with plain punctuation The merged PRs (mostly #714's git fetch) added about 120 em-dashes in comments, docs, test names and three settings strings. Each one is now a comma, colon, semicolon or parentheses. The English settings strings in index.html and their i18n.js keys change together, and the zh-CN value for the fetch interval uses full-width parentheses instead of a double dash. The one left is the regex in constants.js that matches a dash typed as a word, which is behaviour, not punctuation. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- CLAUDE.md | 2 +- docs/api-reference.md | 6 ++-- docs/wiki/Settings-Reference.md | 8 ++--- docs/wiki/Working-With-Files.md | 6 ++-- src/cli-agent.ts | 2 +- src/deepseek-transcript.ts | 2 +- src/git-fetch-scheduler.ts | 20 ++++++------ src/git-fetch.ts | 32 +++++++++---------- src/git-workspace-status.ts | 2 +- src/web/public/git-status-ui.js | 8 ++--- src/web/public/i18n.js | 8 ++--- src/web/public/index.html | 6 ++-- src/web/public/session-ui.js | 2 +- src/web/public/settings-ui.js | 2 +- src/web/routes/git-status-routes.ts | 6 ++-- src/web/routes/session-routes.ts | 2 +- src/web/schemas.ts | 4 +-- src/web/server.ts | 8 ++--- test/app-settings-structure.test.ts | 2 +- test/git-fetch-i18n.test.ts | 4 +-- test/git-fetch-scheduler.test.ts | 12 +++---- test/git-fetch.test.ts | 22 ++++++------- test/routes/git-status-routes.test.ts | 6 ++-- .../session-routes-git-fetch-settings.test.ts | 2 +- test/session-options-structure.test.ts | 2 +- test/spaced-file-path-links.test.ts | 2 +- test/terminal-cell-wrapped-paths.test.ts | 2 +- test/terminal-url-continuation.test.ts | 2 +- 28 files changed, 91 insertions(+), 91 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 442fcbf23..d17f63b49 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -238,7 +238,7 @@ The terminal provider scans `terminalLinkLine()`, which extends `terminalPathLin **Git status indicator** (`showGitStatus`, per-device, default OFF; `src/git-workspace-status.ts`, `routes/git-status-routes.ts`, `git-status-ui.js`): the bottom-bar indicator and its panel read `GET /api/sessions/:id/git-status` and `/git-diff`, read-only and offline on its own (it never fetches; fetching is the separate path below). ⚠️ git never runs on a repository at or inside a Docker case workspace (walk-up, scan and diff alike, and a cached repository list is re-checked against the CURRENT Docker roots), since a container could plant a clean filter that runs on the host; remote and Docker sessions answer `unsupported`. ⚠️ `git-diff` takes `repo`/`path`/`kind` only as keys matched against the workspace's own repository list (`findWorkspaceRepo()`) and that repository's current status, never as paths. ⚠️ Keep `--no-optional-locks`, `core.fsmonitor=false` and `log.showSignature=false` on every call and `--no-ext-diff --no-textconv` on diffs; clean filters still run, which is why the Docker rule exists. ⚠️ An enclosing repository at `$HOME` or above is ignored (`isUnrelatedAncestor()`), and is identified by one cached `rev-parse` before any full status runs. Everything git supplies renders via `textContent`. Tests: `test/git-workspace-status.test.ts`, `test/routes/git-status-routes.test.ts`, and `test/git-status.browser.test.ts` (browser suite, not in the gate). -**Git fetch** (`src/git-fetch.ts`, `src/git-fetch-scheduler.ts`, `routes/git-status-routes.ts`, the panel's **Fetch** button): `POST /api/sessions/:id/git-fetch` is the ONLY git command the Git PANEL runs that WRITES — cloning and worktree management write too, but only when the user asked for them at that moment — and it is the only one that can run later by itself, on a timer, and the only one that can run a repository's own hooks (`reference-transaction`, `pre-auto-gc` through the automatic maintenance a fetch triggers, config commands such as `credential.helper`) — which is why it is opt-in and never runs on a timer until the user turns it on. ⚠️ Fetch updates remote-tracking refs only; it must never commit, check out, merge or touch the working tree. ⚠️ It is not as harmless as a read: a fetch holds git's ref lock, so an agent's own `git pull` in the same clone can hit the same `cannot lock ref` — hence one folder is fetched by at most one run at a time (`fetchWorkspaceOnce` joins a run already going), and `FETCH_CONFIG_ARGS` (`-c core.fsmonitor=false -c maintenance.auto=false -c gc.auto=0`) keeps an unattended fetch from starting a gc in a repository the agent is using. ⚠️ `buildFetchArgs()` is `['fetch', '--quiet']` and must stay verb-invariant (`test/git-fetch.test.ts`); those `-c` overrides are added by the runner, not by `buildFetchArgs()`. ⚠️ The git timeout must reach the whole process TREE, not just the `git` parent: `execFile` signals the parent only, so `runFetch` spawns `detached` and signals the negative pid (SIGTERM, then SIGKILL `FETCH_KILL_ESCALATION_MS` later), and the caller is answered AT the timeout while the group is still dying — that is the point. `test/git-fetch.test.ts` proves it against a local server that never answers, by group membership rather than parentage. A non-zero exit must still reject the way `execFile` would, or a real failure reads as a clean run with no reason. ⚠️ The same Docker rule as above applies BEFORE any git runs (a repository at or inside a Docker case workspace is refused), and remote sessions answer `unsupported`. ⚠️ Keep it non-interactive (`gitNonInteractiveEnv()`: `GIT_TERMINAL_PROMPT=0` + BatchMode ssh) or a fetch hangs on an invisible login prompt — but that BatchMode ssh is a DEFAULT, never an override: `fetchEnvFor` must not overwrite the repository's `core.sshCommand`, the user's own `GIT_SSH_COMMAND` or a `GIT_SSH` (`readRepoSshCommand` reads it inside the same limiter slot and the same wall-clock bound as the fetch it serves, and the spawn gets only what the read left), and `StrictHostKeyChecking=accept-new` writes an unknown host key into `~/.ssh/known_hosts` without asking — BatchMode stops a prompt, not a write. Keep it bounded: `MAX_REPOS`/`MAX_REPOS_LIMIT` (12/50), `MAX_FETCH_OUTPUT_BYTES` (2 MB), the git timeout per spawn, and `FETCH_CONCURRENCY` (4) as ONE `Limiter` shared by the fetches of the whole tick — `createLimiter` keeps its count in the closure, so a limiter that delegated to `mapLimited` per call bounds each call, and 4 folders × 4 repositories would start 16 git processes. ⚠️ One semaphore cannot bound both the folders and the fetches: a folder holding a slot would then wait for a slot its own fetch needs, so the tick bounds its folders with `mapLimited` and hands the shared bound to every run. ⚠️ Failure text reaching a client is control-stripped and credential-redacted (`sanitizeGitOutput`) and the browser writes it with `textContent`. Scheduler semantics: settings are read fresh each tick (`GIT_FETCH_TICK_INTERVAL_MS`, 15 s), a per-session override wins even while the app-wide switch is off, the interval is timed from the run START, state is keyed by FOLDER (sessions sharing one folder are the same clone, so one run serves them all, the schedule is stamped at selection, and every session on a folder is remembered, a folder is due when ANY session open on it is due — so the order the sessions were read cannot decide which interval wins (both orders are tested) — and the folder is probed once for the whole group rather than once per session on it — closing the one that holds the override must not stop the folder's fetching while another session is still open on it), a tick is skipped while the previous one is still going, and a user-chosen folder is probed BOUNDED with the default options before anything runs (rule #516 — `unknown` is never read as `absent`). ⚠️ The two settings (`gitFetchEnabled`, `gitFetchIntervalSeconds`, off/300 by default, clamped 30–3600) are **synced**, not per-device: they must stay out of `displayKeys` and out of the strip destructure in settings-ui.js, or the scheduler reads nobody's value. Per-session state rides `SessionUpdated` (no new SSE event) as `gitFetch`, and the click route publishes the run's trigger, not the request's: `trigger: 'click'` only when the click was the run, because a click that joined a tick already going fetched nothing and cannot report itself as the fetch (`GitFetchRun` carries it, and the scheduler publishes the run's trigger for the same reason — its tick can join a click too). The UI's "by click" text depends on it. Tests: `test/git-fetch.test.ts`, `test/git-fetch-scheduler.test.ts`, `test/routes/session-routes-git-fetch-settings.test.ts`, `test/git-fetch-i18n.test.ts`, `test/app-settings-structure.test.ts`, `test/session-options-structure.test.ts`, `test/git-fetch-session-options-ui.test.ts`, and the Fetch block of `test/git-status.browser.test.ts` (browser suite, not in the gate). +**Git fetch** (`src/git-fetch.ts`, `src/git-fetch-scheduler.ts`, `routes/git-status-routes.ts`, the panel's **Fetch** button): `POST /api/sessions/:id/git-fetch` is the ONLY git command the Git PANEL runs that WRITES (cloning and worktree management write too, but only when the user asked for them at that moment), and it is the only one that can run later by itself, on a timer, and the only one that can run a repository's own hooks (`reference-transaction`, `pre-auto-gc` through the automatic maintenance a fetch triggers, config commands such as `credential.helper`), which is why it is opt-in and never runs on a timer until the user turns it on. ⚠️ Fetch updates remote-tracking refs only; it must never commit, check out, merge or touch the working tree. ⚠️ It is not as harmless as a read: a fetch holds git's ref lock, so an agent's own `git pull` in the same clone can hit the same `cannot lock ref`, hence one folder is fetched by at most one run at a time (`fetchWorkspaceOnce` joins a run already going), and `FETCH_CONFIG_ARGS` (`-c core.fsmonitor=false -c maintenance.auto=false -c gc.auto=0`) keeps an unattended fetch from starting a gc in a repository the agent is using. ⚠️ `buildFetchArgs()` is `['fetch', '--quiet']` and must stay verb-invariant (`test/git-fetch.test.ts`); those `-c` overrides are added by the runner, not by `buildFetchArgs()`. ⚠️ The git timeout must reach the whole process TREE, not just the `git` parent: `execFile` signals the parent only, so `runFetch` spawns `detached` and signals the negative pid (SIGTERM, then SIGKILL `FETCH_KILL_ESCALATION_MS` later), and the caller is answered AT the timeout while the group is still dying: that is the point. `test/git-fetch.test.ts` proves it against a local server that never answers, by group membership rather than parentage. A non-zero exit must still reject the way `execFile` would, or a real failure reads as a clean run with no reason. ⚠️ The same Docker rule as above applies BEFORE any git runs (a repository at or inside a Docker case workspace is refused), and remote sessions answer `unsupported`. ⚠️ Keep it non-interactive (`gitNonInteractiveEnv()`: `GIT_TERMINAL_PROMPT=0` + BatchMode ssh) or a fetch hangs on an invisible login prompt, but that BatchMode ssh is a DEFAULT, never an override: `fetchEnvFor` must not overwrite the repository's `core.sshCommand`, the user's own `GIT_SSH_COMMAND` or a `GIT_SSH` (`readRepoSshCommand` reads it inside the same limiter slot and the same wall-clock bound as the fetch it serves, and the spawn gets only what the read left), and `StrictHostKeyChecking=accept-new` writes an unknown host key into `~/.ssh/known_hosts` without asking: BatchMode stops a prompt, not a write. Keep it bounded: `MAX_REPOS`/`MAX_REPOS_LIMIT` (12/50), `MAX_FETCH_OUTPUT_BYTES` (2 MB), the git timeout per spawn, and `FETCH_CONCURRENCY` (4) as ONE `Limiter` shared by the fetches of the whole tick: `createLimiter` keeps its count in the closure, so a limiter that delegated to `mapLimited` per call bounds each call, and 4 folders × 4 repositories would start 16 git processes. ⚠️ One semaphore cannot bound both the folders and the fetches: a folder holding a slot would then wait for a slot its own fetch needs, so the tick bounds its folders with `mapLimited` and hands the shared bound to every run. ⚠️ Failure text reaching a client is control-stripped and credential-redacted (`sanitizeGitOutput`) and the browser writes it with `textContent`. Scheduler semantics: settings are read fresh each tick (`GIT_FETCH_TICK_INTERVAL_MS`, 15 s), a per-session override wins even while the app-wide switch is off, the interval is timed from the run START, state is keyed by FOLDER (sessions sharing one folder are the same clone, so one run serves them all, the schedule is stamped at selection, and every session on a folder is remembered, a folder is due when ANY session open on it is due, so the order the sessions were read cannot decide which interval wins (both orders are tested), and the folder is probed once for the whole group rather than once per session on it: closing the one that holds the override must not stop the folder's fetching while another session is still open on it), a tick is skipped while the previous one is still going, and a user-chosen folder is probed BOUNDED with the default options before anything runs (rule #516: `unknown` is never read as `absent`). ⚠️ The two settings (`gitFetchEnabled`, `gitFetchIntervalSeconds`, off/300 by default, clamped 30–3600) are **synced**, not per-device: they must stay out of `displayKeys` and out of the strip destructure in settings-ui.js, or the scheduler reads nobody's value. Per-session state rides `SessionUpdated` (no new SSE event) as `gitFetch`, and the click route publishes the run's trigger, not the request's: `trigger: 'click'` only when the click was the run, because a click that joined a tick already going fetched nothing and cannot report itself as the fetch (`GitFetchRun` carries it, and the scheduler publishes the run's trigger for the same reason: its tick can join a click too). The UI's "by click" text depends on it. Tests: `test/git-fetch.test.ts`, `test/git-fetch-scheduler.test.ts`, `test/routes/session-routes-git-fetch-settings.test.ts`, `test/git-fetch-i18n.test.ts`, `test/app-settings-structure.test.ts`, `test/session-options-structure.test.ts`, `test/git-fetch-session-options-ui.test.ts`, and the Fetch block of `test/git-status.browser.test.ts` (browser suite, not in the gate). **Raw file bodies are streamed and range-aware**: `file-raw`, the attachments `/raw` route, `GET /api/download` and the rendered-HTML route `GET /html-view/<cap>/...` share `sendFileBody()`, advertise `Accept-Ranges: bytes` and answer `Range` with `206` + `Content-Range` (single-range, parser in `src/web/http-range.ts`); without it `<video>` cannot seek. The size cap (`MAX_FILE_DOWNLOAD_BYTES`, default 2GB, env `CODEMAN_MAX_DOWNLOAD_BYTES`, `0` = unlimited) is a sanity bound, not memory protection; never reintroduce a whole-file buffer. ⚠️ Bodies go out via `reply.hijack()`, so `sendRawStream` must copy the status onto `reply.raw` by hand or a partial body ships as `200`. ⚠️ Closing the preview must pause and unload media (`_stopFilePreviewMedia`), since a detached `HTMLMediaElement` keeps playing. → [architecture-invariants#raw-file-bodies-streamed-and-range-aware](docs/architecture-invariants.md#raw-file-bodies-streamed-and-range-aware) diff --git a/docs/api-reference.md b/docs/api-reference.md index 2c840c87e..4a0cb6b72 100644 --- a/docs/api-reference.md +++ b/docs/api-reference.md @@ -1317,7 +1317,7 @@ Admin only in multi-user mode (`403`), like `POST /api/cases/link`: it writes ou ## Git status -`GET /api/sessions/:id/git-status` is what the bottom-bar Git indicator and its panel read (Settings → Header & Panels → Bottom bar, per-device, default off). It reports what the session's workspace has not committed or pushed. **Read-only and offline:** it never fetches, pulls, commits or writes (it runs `git status` with `--no-optional-locks`, so it does not even refresh the index), which is why `behind` is as of the last `git fetch` — the panel's Fetch button is a separate route, below. The session is resolved like every session route (ownership via `findSessionOrFail`; another user's session is `404`). A repository whose root is, or is inside, a Docker case workspace is dropped (from the walk-up, the scan below a folder, and the diff route): a container can write there, and a repository's own clean filter or signature program would run on the host. When a branch's upstream does not exist on the remote (deleted and pruned, or never pushed, as after cloning an empty repository and committing), `upstreamGone` is `true` and the unpushed list falls back to commits on no remote-tracking ref at all. +`GET /api/sessions/:id/git-status` is what the bottom-bar Git indicator and its panel read (Settings → Header & Panels → Bottom bar, per-device, default off). It reports what the session's workspace has not committed or pushed. **Read-only and offline:** it never fetches, pulls, commits or writes (it runs `git status` with `--no-optional-locks`, so it does not even refresh the index), which is why `behind` is as of the last `git fetch` (the panel's Fetch button is a separate route, below). The session is resolved like every session route (ownership via `findSessionOrFail`; another user's session is `404`). A repository whose root is, or is inside, a Docker case workspace is dropped (from the walk-up, the scan below a folder, and the diff route): a container can write there, and a repository's own clean filter or signature program would run on the host. When a branch's upstream does not exist on the remote (deleted and pruned, or never pushed, as after cloning an empty repository and committing), `upstreamGone` is `true` and the unpushed list falls back to commits on no remote-tracking ref at all. `GET /api/sessions/:id/git-diff?repo=<repoRoot>&path=<path>&kind=staged|unstaged|untracked|conflicted` returns the unified diff of one file the panel lists (`{ diff, truncated, binary }`; staged is index vs HEAD, unstaged is working tree vs index, untracked is the whole file as additions). It is what opens when you click a file in the Git panel. `repo` and `path` are matched against the current status rather than trusted, so anything the status does not list is `404`. Read-only: it passes `--no-ext-diff --no-textconv` (no external diff or textconv driver runs), but a repository's clean filters still run, as they do for any `git diff`, which is why a repository a container can write to is never inspected (below). Capped at 400 KB, and refused (`400`) for remote and Docker sessions; a repository at or inside a Docker case workspace is not in the status, so it is `404` here. @@ -1340,11 +1340,11 @@ Both routes accept two optional query parameters, which the UI sends from its pe Lists are capped (300 files and 50 commits per repository) while the counts stay exact. A branch with no upstream reports the commits no remote has (`HEAD --not --remotes`); a repository with no remote reports `unpushedCount: 0`, since there is nothing to push to. Concurrent polls of one folder share a single git invocation; `?fresh=1` (what the panel's Refresh button and opening the panel send) skips the short-lived caches, though it still joins a computation already running. -`POST /api/sessions/:id/git-fetch` is the panel's **Fetch** button: one `git fetch --quiet` per repository the panel would show, through the same resolver so the two cannot disagree about which repositories belong to a workspace, with the same `maxRepos` and `timeout` clamps. It is the only git command the Git panel runs that writes — cloning and worktree management write too, but only when you asked for them at the moment you asked — and it writes remote-tracking refs only — nothing commits, checks out, merges or touches the working tree. It is not quite as harmless as a read: a fetch holds git's ref lock, so an agent's own `git pull` in the same clone can hit the same `cannot lock ref` while a fetch is running, which is why one folder is fetched by one run at a time. It runs on that click, or on the interval below and never otherwise, and `git fetch` is exactly the command that can run a repository's own hooks (`reference-transaction`, `pre-auto-gc` through the automatic maintenance a fetch triggers, and config commands such as `credential.helper`), which is why nothing runs on a timer until you opt in, and why a repository at or inside a Docker case workspace is refused before any git runs. Remote and Docker sessions answer `state: 'unsupported'` like the status route. Bounded: 4 repositories at a time across the whole run (one shared bound, not one per repository group — a scheduled tick keeps that one bound for all its folders, so 4 folders do not mean 4 × 4, and works at most 4 folders at a time), one spawn per repository with the same per-command timeout, which reaches the whole process group rather than only the `git` parent (a fetch over http spawns helper processes, and a timeout that signals the parent alone can leave one running after the answer goes out), `GIT_TERMINAL_PROMPT=0` plus BatchMode ssh so a repository that needs a login fails fast instead of hanging the request on an invisible prompt — Codeman supplies no credentials, so a repository your own git is already signed in to works because that is your existing setup. That BatchMode ssh is a default, never an override: your own `GIT_SSH_COMMAND` and the repository's `core.sshCommand` are honoured, and ⚠️ the default's `StrictHostKeyChecking=accept-new` records an unknown host key in `~/.ssh/known_hosts` without asking — BatchMode stops a prompt, not a write. A click joins a run already going for the same folder, and the click publishes the trigger of the run it actually did — `click` when the click was the run, `interval` when it joined a tick already going, because a click that fetched nothing cannot report itself as the fetch — as the session's `gitFetch` state (below), so another tab open on the same folder sees the same run. +`POST /api/sessions/:id/git-fetch` is the panel's **Fetch** button: one `git fetch --quiet` per repository the panel would show, through the same resolver so the two cannot disagree about which repositories belong to a workspace, with the same `maxRepos` and `timeout` clamps. It is the only git command the Git panel runs that writes (cloning and worktree management write too, but only when you asked for them at the moment you asked), and it writes remote-tracking refs only: nothing commits, checks out, merges or touches the working tree. It is not quite as harmless as a read: a fetch holds git's ref lock, so an agent's own `git pull` in the same clone can hit the same `cannot lock ref` while a fetch is running, which is why one folder is fetched by one run at a time. It runs on that click, or on the interval below and never otherwise, and `git fetch` is exactly the command that can run a repository's own hooks (`reference-transaction`, `pre-auto-gc` through the automatic maintenance a fetch triggers, and config commands such as `credential.helper`), which is why nothing runs on a timer until you opt in, and why a repository at or inside a Docker case workspace is refused before any git runs. Remote and Docker sessions answer `state: 'unsupported'` like the status route. Bounded: 4 repositories at a time across the whole run (one shared bound, not one per repository group; a scheduled tick keeps that one bound for all its folders, so 4 folders do not mean 4 × 4, and works at most 4 folders at a time), one spawn per repository with the same per-command timeout, which reaches the whole process group rather than only the `git` parent (a fetch over http spawns helper processes, and a timeout that signals the parent alone can leave one running after the answer goes out), `GIT_TERMINAL_PROMPT=0` plus BatchMode ssh so a repository that needs a login fails fast instead of hanging the request on an invisible prompt. Codeman supplies no credentials, so a repository your own git is already signed in to works because that is your existing setup. That BatchMode ssh is a default, never an override: your own `GIT_SSH_COMMAND` and the repository's `core.sshCommand` are honoured, and ⚠️ the default's `StrictHostKeyChecking=accept-new` records an unknown host key in `~/.ssh/known_hosts` without asking: BatchMode stops a prompt, not a write. A click joins a run already going for the same folder, and the click publishes the trigger of the run it actually did (`click` when the click was the run, `interval` when it joined a tick already going, because a click that fetched nothing cannot report itself as the fetch) as the session's `gitFetch` state (below), so another tab open on the same folder sees the same run. `data` is `{ state, repos, reposTruncated, repoLimit, fetchedAt }`, `repos[]` each `{ name, path, ok }` and, when it failed, `failure` (`{ code, message, stderr }`: the codes as in the clone failures, plus `NO_REMOTE`). A repository that could not be fetched keeps its row rather than being dropped. The panel shows the result under its list and then re-reads, so `behind` reflects what this fetch brought. -The interval is two **synced** settings in `PUT /api/settings` (`src/git-fetch-scheduler.ts`): `gitFetchEnabled` (default `false`) and `gitFetchIntervalSeconds` (30 to 3600, default 300, the gap between runs timed from the start of a run so a slow repository does not push the next one out). Synced, not per-device, because the server is what acts on them; the scheduler reads them fresh on each 15 s tick, so a change works without a restart. A session can override both, and the override wins even while the app-wide switch is off: `POST /api/sessions/:id/git-fetch-settings` takes `{ "enabled": true | false | null, "intervalSeconds": number | null }` — `null` on a field clears that half so the app value applies again, a field left out of the body leaves the other half as it was — is `400 INVALID_INPUT` for a remote or Docker session, and answers `200` with `{ gitFetchSettings: { enabled, intervalSeconds } }` where `null` means the session follows the app. What the last run did rides the session state as `gitFetch` (`{ ranAt, trigger, state, repos, failed, firstFailure? }`, `trigger` is `click` or `interval`), on the existing `SessionUpdated` event: no new event. Sessions on the same folder share one run and both see its result, the schedule is keyed by folder, and a folder is fetched by at most one run at a time; a folder is due when ANY session open on it is due, so the order the sessions were read cannot decide which interval wins, and the folder is probed once for the whole group rather than once per session on it; every session open on a folder is remembered, so closing the one that holds the override does not stop that folder's fetching while another session is still open on it. The tick skips anything its bounded probe does not confirm is a directory, stamps each folder's schedule when it picks it, and does not run a second tick over a selection still going. Remote and Docker sessions are never fetched on a timer, and the Docker rule is re-checked against the CURRENT Docker roots every tick. Both fetch paths fail closed on a Docker case list (`docker-cases.json`) that exists but cannot be read or parsed: a click answers `200` with `state: 'error'` and publishes that as its `gitFetch`, and a tick fetches nothing and leaves its folders due for the next tick, since without the list nothing tells which folders a container can write to (the read-only status and diff routes keep reading such a list as no Docker workspaces). +The interval is two **synced** settings in `PUT /api/settings` (`src/git-fetch-scheduler.ts`): `gitFetchEnabled` (default `false`) and `gitFetchIntervalSeconds` (30 to 3600, default 300, the gap between runs timed from the start of a run so a slow repository does not push the next one out). Synced, not per-device, because the server is what acts on them; the scheduler reads them fresh on each 15 s tick, so a change works without a restart. A session can override both, and the override wins even while the app-wide switch is off: `POST /api/sessions/:id/git-fetch-settings` takes `{ "enabled": true | false | null, "intervalSeconds": number | null }` (`null` on a field clears that half so the app value applies again, a field left out of the body leaves the other half as it was), is `400 INVALID_INPUT` for a remote or Docker session, and answers `200` with `{ gitFetchSettings: { enabled, intervalSeconds } }` where `null` means the session follows the app. What the last run did rides the session state as `gitFetch` (`{ ranAt, trigger, state, repos, failed, firstFailure? }`, `trigger` is `click` or `interval`), on the existing `SessionUpdated` event: no new event. Sessions on the same folder share one run and both see its result, the schedule is keyed by folder, and a folder is fetched by at most one run at a time; a folder is due when ANY session open on it is due, so the order the sessions were read cannot decide which interval wins, and the folder is probed once for the whole group rather than once per session on it; every session open on a folder is remembered, so closing the one that holds the override does not stop that folder's fetching while another session is still open on it. The tick skips anything its bounded probe does not confirm is a directory, stamps each folder's schedule when it picks it, and does not run a second tick over a selection still going. Remote and Docker sessions are never fetched on a timer, and the Docker rule is re-checked against the CURRENT Docker roots every tick. Both fetch paths fail closed on a Docker case list (`docker-cases.json`) that exists but cannot be read or parsed: a click answers `200` with `state: 'error'` and publishes that as its `gitFetch`, and a tick fetches nothing and leaves its folders due for the next tick, since without the list nothing tells which folders a container can write to (the read-only status and diff routes keep reading such a list as no Docker workspaces). ## Git worktrees of a case diff --git a/docs/wiki/Settings-Reference.md b/docs/wiki/Settings-Reference.md index f65b1bf34..1d4aa1294 100644 --- a/docs/wiki/Settings-Reference.md +++ b/docs/wiki/Settings-Reference.md @@ -90,7 +90,7 @@ bounds the Git window's Fetch button; raise it for repositories on a slow networ **Git fetch on an interval** (synced, off by default) runs `git fetch` on a timer for the repositories the Git window lists, so the `behind` number is current without a click, and **Git fetch: how often** (synced, -30 to 3600 seconds, default 300) is the gap between runs for a session that has no override of its own — timed from +30 to 3600 seconds, default 300) is the gap between runs for a session that has no override of its own, timed from the start of a run, so a slow repository does not push the next one out. These are the only Git settings that are synced rather than per device, because the server's scheduler is what acts on them: kept per device, the scheduler would read nobody's number. **Session Options → Git** overrides both the switch and @@ -98,13 +98,13 @@ the interval for one session, and that override wins even while the app switch i the app shows the app's values and its rows go inert until you turn **Follow the app setting** off. Nothing is ever fetched for a Docker or remote (SSH) session, and a repository a container can write to is refused before any git runs. Sessions open on the same folder share one run, and a folder is fetched when ANY session open on it is due, so the order the sessions were read cannot decide which interval wins. Both default to off because `git fetch` -is the one git command the Git window runs that writes — cloning and worktree management write too, but only -when you asked for them at the moment you asked — and the one that can run a repository's own hooks +is the one git command the Git window runs that writes (cloning and worktree management write too, but only +when you asked for them at the moment you asked), and the one that can run a repository's own hooks (`reference-transaction`, `pre-auto-gc` through the maintenance a fetch triggers, and config commands such as `credential.helper`), and the one that can run later by itself, on a timer. The ssh it runs is BatchMode so nothing waits for a login, and that is a default, never an override: your own `GIT_SSH_COMMAND`, or the repository's `core.sshCommand`, is used when either is set. ⚠️ The default's `StrictHostKeyChecking=accept-new` -records an unknown host key in `~/.ssh/known_hosts` without asking — BatchMode stops a prompt, not a write. +records an unknown host key in `~/.ssh/known_hosts` without asking: BatchMode stops a prompt, not a write. Most default to off. The stock desktop header is system stats, File Viewer, Tiles, and the gear. **Header Stats Style** picks how the system stats and plan usage are drawn: *Compact* diff --git a/docs/wiki/Working-With-Files.md b/docs/wiki/Working-With-Files.md index 1783c7063..eb5611b8a 100644 --- a/docs/wiki/Working-With-Files.md +++ b/docs/wiki/Working-With-Files.md @@ -249,12 +249,12 @@ Click it for a draggable window, in the style of the File Viewer: window then re-reads, so "behind" is as of that fetch. A repository that could not be fetched keeps its row and shows why. **Git fetch on an interval** (Settings → Bottom bar, off by default) runs the same fetch on a timer for every session that is on, and **Session Options → Git** overrides both the - switch and the interval for one session — a session that turned itself on runs even while the app-wide + switch and the interval for one session: a session that turned itself on runs even while the app-wide switch is off, sessions open on the same folder share one run, and nothing is ever fetched for Docker or remote (SSH) sessions. It is read-only apart from that fetch: Codeman never commits, checks out or changes the -repository, and `git fetch` writes remote-tracking refs only — it is the only git command the Git window +repository, and `git fetch` writes remote-tracking refs only; it is the only git command the Git window runs that writes, the only one that can run a repository's own hooks (`reference-transaction`, `pre-auto-gc` through the maintenance a fetch triggers, and config commands such as `credential.helper`), and the only one that can run later by itself, on a timer: cloning and worktree management write too, but @@ -266,7 +266,7 @@ and git would run that repository's own configuration on the host). Fetch asks n uses the sign-in your own git already has, and it gives up quickly rather than waiting on an invisible login prompt. That BatchMode ssh is a default, never an override: your own `GIT_SSH_COMMAND`, or the repository's `core.sshCommand`, is used when either is set, and ⚠️ the default's -`StrictHostKeyChecking=accept-new` records an unknown host key in `~/.ssh/known_hosts` without asking — +`StrictHostKeyChecking=accept-new` records an unknown host key in `~/.ssh/known_hosts` without asking: BatchMode stops a prompt, not a write. The data comes from `GET /api/sessions/:id/git-status`, `GET /api/sessions/:id/git-diff` and, on that click or the interval you turned on, `POST /api/sessions/:id/git-fetch` diff --git a/src/cli-agent.ts b/src/cli-agent.ts index 7886924d3..bc8625be0 100644 --- a/src/cli-agent.ts +++ b/src/cli-agent.ts @@ -594,7 +594,7 @@ export async function isHomeDirectory(directory: string): Promise<boolean> { return a === b; } -/** `agent spawn` — start in a project directory or named case, preserving lineage and mode readiness. */ +/** `agent spawn`: start in a project directory or named case, preserving lineage and mode readiness. */ export async function agentSpawn(deps: AgentDeps, options: SpawnOptions): Promise<number> { const cli = getCli(options.mode); if (!cli) return fail(deps, `Unknown mode: ${options.mode}`, EXIT.refused); diff --git a/src/deepseek-transcript.ts b/src/deepseek-transcript.ts index 525188f76..f32139cca 100644 --- a/src/deepseek-transcript.ts +++ b/src/deepseek-transcript.ts @@ -31,7 +31,7 @@ * stop at the first frame end, and the supported range still includes those * versions: measured on a real 56-line transcript, Node returned 158 bytes / * 1 line where the CLI returned 43,747 bytes / 56 lines. That is a silent - * truncation to the session header — every call would have reported "no answer + * truncation to the session header: every call would have reported "no answer * yet" forever. `decodeZstdFrames()` below walks the frame headers itself and * decompresses each frame, and `test/deepseek-transcript.test.ts` pins it * against multi-frame fixtures. diff --git a/src/git-fetch-scheduler.ts b/src/git-fetch-scheduler.ts index a946a8748..a563fd1b5 100644 --- a/src/git-fetch-scheduler.ts +++ b/src/git-fetch-scheduler.ts @@ -3,8 +3,8 @@ * (`src/git-fetch-scheduler.ts`, wired in `src/web/server.ts`). * * The fetch module (src/git-fetch.ts) is the only git command the Git panel runs that writes, and on a timer - * it is the only one that can run without being asked — cloning and worktree management write too, but those - * happen when the user asks for them. Which is why the whole feature is off until the user turns it on, in + * it is the only one that can run without being asked (cloning and worktree management write too, but those + * happen when the user asks for them). Which is why the whole feature is off until the user turns it on, in * Settings → Bottom bar, per session in Session Options → Git. * * The scheduler holds no timers of its own. The server ticks it (`GIT_FETCH_TICK_INTERVAL_MS`) and every @@ -15,7 +15,7 @@ * Rules the code keeps and the tests pin: * - Nothing runs when nothing is on: no repository set is read and nothing is spawned, so a quiet * install costs one settings read per tick. A session that turned itself on still runs even while the - * app-wide switch is off — that is what a per-session switch has to mean. + * app-wide switch is off: that is what a per-session switch has to mean. * - Remote and Docker sessions are never fetched, exactly as the Git panel excludes them, and the Docker * workspace roots are checked again every tick, so a session whose case became a Docker case stops * fetching at the next tick. A Docker case list that cannot be read fails CLOSED: the tick fetches @@ -24,7 +24,7 @@ * until a read succeeds again. * - A folder is fetched by at most one run at a time, and the run belongs to the FOLDER, not to one * session: two sessions open on the same folder are the same clone, so they share one run and both get - * its result. State is keyed by `resolve(workingDir)` — deliberately not `realpath`, because `realpath` + * its result. State is keyed by `resolve(workingDir)`, deliberately not `realpath`, because `realpath` * is itself one of the unbounded fs calls rule #516 bounds; the bounded probe below is the fs read. * Every session open on a folder is remembered, so closing the one holding the override does not stop * the folder's fetching while another session is still on it. A folder is due when ANY session open on it @@ -101,7 +101,7 @@ export interface GitFetchPorts { dockerWorkspaces(): Promise<string[]>; /** * Bounded probe (rule #516), default options: a bulk scan keeps the stall cap, so this must not be called - * with `pastCap`. Anything that is not `directory` — including `unknown`, which is never `absent` — is + * with `pastCap`. Anything that is not `directory` (including `unknown`, which is never `absent`) is * skipped, not fetched. */ probePath(path: string): Promise<PathProbeKind>; @@ -170,7 +170,7 @@ export class GitFetchScheduler { } this.settingsUnreadable = false; - // Pass 1 — who is on what, before anything is decided. Every session open on a folder is recorded here, + // Pass 1: who is on what, before anything is decided. Every session open on a folder is recorded here, // not only the one that would get picked, so `remove()` can still see who is open on it when the session // holding the override is the one that closes. const groups = new Map<string, GroupMember[]>(); @@ -187,10 +187,10 @@ export class GitFetchScheduler { else groups.set(folder, [member]); } - // Pass 2 — the folder's turn, decided from the whole group rather than from whichever session happened to + // Pass 2: the folder's turn, decided from the whole group rather than from whichever session happened to // be read first. A folder is due when ANY session open on it is due: a session on a 30 s interval on a // folder a 3600 s session last fetched ten minutes ago has to be fetched now, because that same run - // satisfies the long interval too. Deciding in one pass means the session read first owns the clock — a + // satisfies the long interval too. Deciding in one pass means the session read first owns the clock: a // group ordered [short, long] and a group ordered [long, short] would have behaved differently, and the // run belongs to the folder, so no session in the group should. const due = new Map<string, GroupMember[]>(); @@ -244,7 +244,7 @@ export class GitFetchScheduler { // run would let `FETCH_CONCURRENCY` folders × `MAX_REPOS_LIMIT` processes run at once. const fetches = createLimiter(FETCH_CONCURRENCY); // The folders get their OWN bound, because the same semaphore cannot serve both levels: a folder that - // holds a slot and then waits for a slot for its own fetch is waiting on itself — once + // holds a slot and then waits for a slot for its own fetch is waiting on itself: once // `FETCH_CONCURRENCY` folders fill the bound, the ones holding slots are exactly the ones that can // never get one back. Both bounds are `FETCH_CONCURRENCY`, so the tick still never runs more git // processes than the module's ceiling. @@ -280,7 +280,7 @@ export class GitFetchScheduler { /** * A session that closed or was detached: its schedule goes with it, but the FOLDER's stays while another - * session is still open on it — the run belongs to the clone, not to the session that asked for it. + * session is still open on it: the run belongs to the clone, not to the session that asked for it. */ remove(id: string): void { const folder = this.folderOf.get(id); diff --git a/src/git-fetch.ts b/src/git-fetch.ts index b12537226..a2f4b2ba4 100644 --- a/src/git-fetch.ts +++ b/src/git-fetch.ts @@ -22,7 +22,7 @@ * - The repository set is `resolveWorkspaceRepos`, the single source the panel and the diff route * already use, so Fetch cannot reach a repository the panel would not show. The Docker rule is * checked before any git runs: a repository a container can write to is refused outright, because a - * planted config would run on the host — and `git fetch` is exactly the command that can run a + * planted config would run on the host, and `git fetch` is exactly the command that can run a * repository's own hooks (`reference-transaction`, and `pre-auto-gc` through the automatic maintenance * that follows a fetch) and its config commands such as `credential.helper`, which is why a timer only * runs it after the user opts in. @@ -30,7 +30,7 @@ * instead of starting a second one, because two fetches in one clone collide on the ref lock. * - Bounded: the git timeout from Settings → Bottom bar per repository, `MAX_REPOS_LIMIT` repositories at * most, a hard wall-clock bound on each spawn, and `FETCH_CONCURRENCY` git processes at a time for the - * whole run — one `Limiter`, shared by the scheduler's tick, not one bound per folder. + * whole run: one `Limiter`, shared by the scheduler's tick, not one bound per folder. * - Non-interactive: `GIT_TERMINAL_PROMPT=0` and BatchMode ssh, so a private repository that needs a * login fails fast instead of hanging the request on an invisible username prompt. Codeman collects * no credentials; a repository the user's own git is already signed in to works because that is the @@ -38,11 +38,11 @@ * a default, never an override: `fetchEnvFor` keeps whatever the repository's `core.sshCommand`, the * user's own `GIT_SSH_COMMAND` or a `GIT_SSH` already says, because on a machine with several ssh setups * that choice is usually the one that works. ⚠️ The default still leaves a record: `StrictHostKeyChecking=accept-new` - * writes an unknown host key into `~/.ssh/known_hosts` without asking — BatchMode stops a prompt, not a + * writes an unknown host key into `~/.ssh/known_hosts` without asking: BatchMode stops a prompt, not a * write. * - The timeout kills the PROCESS GROUP, not just the `git` parent. A fetch over http spawns * `git-remote-http` children, and `execFile`'s `timeout` signals the parent only, so the answer can go - * out while a helper is still running — and on a timer that repeats every tick with nobody watching. + * out while a helper is still running, and on a timer that repeats every tick with nobody watching. * `spawn` with `detached: true` puts the tree in its own group so the escalation (SIGTERM, then SIGKILL * `FETCH_KILL_ESCALATION_MS` later) reaches the children too, which is why the negative pid is used * rather than `child.kill()`. Same shape as `runGit` in git-clone.ts, without that module's slot queue: @@ -69,10 +69,10 @@ import { const execFileAsync = promisify(execFile); /** - * Git processes in flight at once, for one run — and for a whole scheduler tick, because the tick hands ONE + * Git processes in flight at once, for one run, and for a whole scheduler tick, because the tick hands ONE * bound to every run (see `createLimiter`): each fetch is one git process, and a folder can hold up to * `MAX_REPOS_LIMIT`. The tick bounds its folders separately, because the same semaphore cannot serve both - * levels — a folder holding a slot would then wait for a slot its own fetch needs. + * levels: a folder holding a slot would then wait for a slot its own fetch needs. */ export const FETCH_CONCURRENCY = 4; /** A fetch on a busy remote can print a lot; a bound on what we will hold. */ @@ -123,7 +123,7 @@ export function gitFetchSettingsFrom(settings: Record<string, unknown> | undefin /** * The only git command this module runs. Kept as a function so a test can pin the verb: nothing here - * commits, checks out, merges or prunes, and no operand is interpolated — the repository is the process + * commits, checks out, merges or prunes, and no operand is interpolated: the repository is the process * `cwd` and the argv is fixed. */ export function buildFetchArgs(): string[] { @@ -135,8 +135,8 @@ export function buildFetchArgs(): string[] { * so pinning the verb here does not have to say anything about maintenance, and so both can be tested * without spawning. * - * A repository with no remote is a fact, not a fault. Git's wordings for it differ by version — 2.47 - * exits quietly and this code is never reached, older git says "no default remote repository" — so the + * A repository with no remote is a fact, not a fault. Git's wordings for it differ by version (2.47 + * exits quietly and this code is never reached, older git says "no default remote repository"), so the * branch is pinned on literal fixtures and the code the panel shows is the code this writes. */ export type FetchFailureCode = GitFailureCode | 'NO_REMOTE'; @@ -151,7 +151,7 @@ export interface FetchFailure { /** * A thrown git error as the panel should show it. `classifyGitFailure` (git-clone.ts) already knows the - * network reasons — auth, an unreachable host, a missing git — so they are reused here; only its timeout + * network reasons (auth, an unreachable host, a missing git), so they are reused here; only its timeout * wording is written for clones, so the fetch path replaces it with what the user can actually do. */ export function describeFetchFailure(err: unknown): FetchFailure { @@ -179,7 +179,7 @@ export function describeFetchFailure(err: unknown): FetchFailure { * * `gitNonInteractiveEnv` supplies the BatchMode default, and the default is only a default: git reads * `GIT_SSH_COMMAND` from the environment ahead of the repository's `core.sshCommand`, so setting it would - * override a repository that already chose its own ssh — an alternative host, an identity file, a jump box — + * override a repository that already chose its own ssh (an alternative host, an identity file, a jump box) * and break a setup that works today. The server's own `GIT_SSH` is the same kind of answer: git reads it * after both of those (`GIT_SSH_COMMAND`, then `core.sshCommand`, then `GIT_SSH`), so a written default would * hide it too. That is the only case where this module makes an ssh choice on the user's behalf, so it is the @@ -202,7 +202,7 @@ export function fetchEnvFor(base: NodeJS.ProcessEnv, repoSshCommand: string | nu * hook or filter runs on the way to it. * * The read happens inside the same slot and the same wall-clock bound as the fetch it serves, so it cannot - * add a fifth git process or push a fetch past the user's timeout — it spends part of it. + * add a fifth git process or push a fetch past the user's timeout: it spends part of it. */ export async function readRepoSshCommand(cwd: string, timeoutMs: number): Promise<string | null> { try { @@ -232,7 +232,7 @@ export async function readRepoSshCommand(cwd: string, timeoutMs: number): Promis * * The thrown error keeps the shape `describeFetchFailure` reads: `stderr` for the reason, `message` for a * spawn failure (`spawn git ENOENT` → GIT_MISSING), `killed` and `code` for the timeout. A non-zero exit - * rejects the way `execFile` would — `code` is then the exit code — because resolving it would report a real + * rejects the way `execFile` would (`code` is then the exit code), because resolving it would report a real * failure as a clean run with an empty reason. * * `timeoutMs` bounds the `core.sshCommand` read and the fetch TOGETHER: the spawn is armed with what the read @@ -320,7 +320,7 @@ export const runFetch: GitRunner = async (cwd, args, opts) => { if (settled) return; // execFile's contract, which `describeFetchFailure` depends on: a non-zero exit rejects, so a real // git failure keeps its reason in `stderr` instead of becoming a green row with an empty one. `null` - // means a signal, reachable only when something outside this runner killed the group — our own timeout + // means a signal, reachable only when something outside this runner killed the group: our own timeout // has already answered by then, and `settled` above is what keeps that answer the answer. if (code !== 0) return fail(undefined, code); settled = true; @@ -468,14 +468,14 @@ export async function fetchGitWorkspaceOverview(cwd: string, opts: GitFetchOptio * Runs already going, keyed by the resolved folder. A request for a folder another run is already working on * JOINS that run instead of starting a second `git fetch` in the same clone: two fetches there collide on * git's ref lock (`cannot lock ref 'refs/remotes/origin/main'`), which is what a click during a tick would - * otherwise do. A joiner gets the run already going's answer, including its limits and its trigger — the point + * otherwise do. A joiner gets the run already going's answer, including its limits and its trigger: the point * is not to fetch twice, not to fetch with the freshest options, and a click that fetched nothing cannot * report itself as the fetch. * * Keyed by `resolve`, not `realpath`, because `realpath` is itself one of the unbounded fs calls rule #516 * bounds for user-chosen paths; the scheduler probes the folder bounded and calls this. Two paths that * resolve the same are the same folder here; a workspace reached through a symlink resolves to the link, so - * a link and its target can be fetched by two runs — the accepted trade for keeping the probe bounded. + * a link and its target can be fetched by two runs, the accepted trade for keeping the probe bounded. */ const lane = new Map<string, Promise<GitFetchRun>>(); diff --git a/src/git-workspace-status.ts b/src/git-workspace-status.ts index a5b030b9a..2a5a8230d 100644 --- a/src/git-workspace-status.ts +++ b/src/git-workspace-status.ts @@ -600,7 +600,7 @@ const discoveryCache = new Map<string, { at: number; value: { dirs: string[]; tr /** * A slot count fixed ONCE, so callers share it instead of each getting its own bound (see the fetch path): * the count lives in the closure, so `limit` is the ceiling for everything going through THIS limiter at - * once, not for each call to it — which is what makes a scheduler tick's bound honest. + * once, not for each call to it, which is what makes a scheduler tick's bound honest. * ⚠️ One limiter cannot bound two levels of the same work: a caller that holds a slot and then waits for * another slot for the work IT starts cannot get one, because the holders are exactly the ones waiting. The * outer level needs its own bound (`mapLimited`), which is why the tick shares one for fetches and keeps a diff --git a/src/web/public/git-status-ui.js b/src/web/public/git-status-ui.js index c0843aa5d..599e4a990 100644 --- a/src/web/public/git-status-ui.js +++ b/src/web/public/git-status-ui.js @@ -13,8 +13,8 @@ * repository, so the "behind" number reflects the last `git fetch`, which the panel footer says. Fetching * happens when the user clicks Fetch in that window's header (`POST /api/sessions/:id/git-fetch`, * src/git-fetch.ts), or on the interval when the session is on: this is the only git command the Git panel - * runs that writes, and the only one Codeman runs on a timer — cloning and worktree management write too, - * but only when the user asked for them at that moment. Remote (SSH) + * runs that writes, and the only one Codeman runs on a timer (cloning and worktree management write too, + * but only when the user asked for them at that moment). Remote (SSH) * and Docker sessions answer `unsupported`, show no indicator, and cannot be fetched. * * Everything that comes from git (file names, commit subjects, author names) is untrusted text: it is @@ -273,7 +273,7 @@ Object.assign(CodemanApp.prototype, { * Fetch, on this click only: one `git fetch --quiet` per repository the panel shows * (`POST /api/sessions/:id/git-fetch`). It is the only git command the Git panel runs that writes, and the * only one Codeman runs on a timer, and it updates remote-tracking refs only: nothing here commits or - * touches the working tree, so an agent's own work is left alone — though a fetch does hold git's ref lock, + * touches the working tree, so an agent's own work is left alone, though a fetch does hold git's ref lock, * so an agent's `git pull` in the same repository can hit the same `cannot lock ref` while it runs. One * click means one fetch per repository: the button is busy and a second click is ignored while it runs, and * a click that joins a fetch already running reports that run, not itself. The panel then re-reads, so the @@ -532,7 +532,7 @@ Object.assign(CodemanApp.prototype, { if (data.behind) { const behind = el('span', 'git-status-chip', `↓ ${data.behind} behind`); behind.title = - 'As of the last git fetch — click Fetch in this window, or turn on the fetch interval in Settings, to bring it current.'; + 'As of the last git fetch. Click Fetch in this window, or turn on the fetch interval in Settings, to bring it current.'; line.append(behind); } } else if (data.hasRemote) { diff --git a/src/web/public/i18n.js b/src/web/public/i18n.js index 26dbfe5b2..dc02f5e55 100644 --- a/src/web/public/i18n.js +++ b/src/web/public/i18n.js @@ -533,7 +533,7 @@ // scope tag. The Session Options → Git strings are in that group's own block further down. 'Bottom bar': '底部栏', 'Git status': 'Git 状态', - "Shows, at the right of the bottom bar, when the active session's repository (or each repository inside its folder, up to two levels down) has uncommitted files or commits that are not pushed. Click it for the list. Read-only apart from Fetch: Codeman never commits or changes the repository, and `git fetch` — the only git command the Git window runs that writes, and the only one Codeman runs on a timer (cloning and worktree management write too, but only when you asked for them at the moment you asked) — runs when you click Fetch in that window, or on the interval below if you turned it on. Not shown for Docker or remote sessions. Off by default.": + "Shows, at the right of the bottom bar, when the active session's repository (or each repository inside its folder, up to two levels down) has uncommitted files or commits that are not pushed. Click it for the list. Read-only apart from Fetch: Codeman never commits or changes the repository, and `git fetch` runs only when you click Fetch in that window, or on the interval below if you turned it on. It is the only git command the Git window runs that writes, and the only one Codeman runs on a timer (cloning and worktree management write too, but only when you asked for them at the moment you asked). Not shown for Docker or remote sessions. Off by default.": '在底部栏右侧显示当前会话的仓库(或其文件夹内向下两层以内的每个仓库)是否有未提交的文件或未推送的提交。点击可查看列表。除拉取外只读:{name} 从不提交或更改仓库,而拉取是 Git 窗口运行的唯一会写入的 git 命令,也是 {name} 唯一会按间隔自行运行的命令(克隆和工作树管理也会写入,但只在您当时要求时才运行),它只在您点击该窗口中的拉取按钮时运行,或在您开启了下方的定时后按间隔运行。Docker 和远程会话不显示。默认关闭。', 'Git status: group files by folder': 'Git 状态:按文件夹分组显示文件', 'In the Git window, show changed files under their folders, collapsed until you click a folder. Off lists every file by its full path. On by default.': @@ -548,8 +548,8 @@ 'Runs `git fetch` for the repositories the Git window lists on a timer, so the "behind" number is current without a click. Off by default: this is the only git command the Git panel runs that writes, and the only one Codeman runs on a timer (cloning and worktree management write too, but only when you asked for them at the moment you asked), so nothing runs until you turn it on. A session can override both the switch and the interval in Session Options → Git.': '按间隔为 Git 窗口列出的仓库运行拉取,这样“落后”的数字无需点击就是最新的。默认关闭:这是 Git 窗口运行的唯一会写入的 git 命令,也是 {name} 唯一会按间隔自行运行的命令(克隆和工作树管理也会写入,但只在您当时要求时才运行),所以在您开启之前不会有任何运行。会话可以在“会话选项 → Git”中同时覆盖开关和间隔。', 'Git fetch: how often': 'Git 定时拉取:多久一次', - 'Seconds between runs for a session that has no override of its own (30 to 3600, default 300). Only a session that is on — here, or in Session Options → Git — is fetched, and sessions sharing one folder are fetched by one run.': - '没有自己覆盖设置的会话,两次运行之间的秒数(30 到 3600,默认 300)。只有已开启的会话——这里,或在“会话选项 → Git”——才会被拉取,共用一个文件夹的会话由一次运行拉取。', + 'Seconds between runs for a session that has no override of its own (30 to 3600, default 300). Only a session that is on (here, or in Session Options → Git) is fetched, and sessions sharing one folder are fetched by one run.': + '没有自己覆盖设置的会话,两次运行之间的秒数(30 到 3600,默认 300)。只有已开启的会话(这里,或在“会话选项 → Git”)才会被拉取,共用一个文件夹的会话由一次运行拉取。', 'Fetch git status': '拉取 Git 状态', 'Refresh git status': '刷新 Git 状态', 'Close git status': '关闭 Git 状态', @@ -860,7 +860,7 @@ // by updateGitFetchStatus, and 'Git' stays a technical name. "Whether Codeman fetches this session's repositories on a timer.": '{name} 是否按间隔拉取此会话的仓库。', 'Fetch on an interval': '按间隔拉取', - "Fetching is the only git command the Git panel runs that writes, and the only one Codeman runs on a timer, so nothing runs until you turn it on — here, or in App Settings → Bottom bar. Cloning and worktree management write too, but only when you asked for them at the moment you asked. The panel's Fetch button is always just a click.": + "Fetching is the only git command the Git panel runs that writes, and the only one Codeman runs on a timer, so nothing runs until you turn it on (here, or in App Settings → Bottom bar). Cloning and worktree management write too, but only when you asked for them at the moment you asked. The panel's Fetch button is always just a click.": '拉取是 Git 窗口运行的唯一会写入的 git 命令,也是 {name} 唯一会按间隔自行运行的命令,所以在您开启之前(这里,或“应用设置 → 底部栏”)不会有任何运行。克隆和工作树管理也会写入,但只在您当时要求时才运行。该窗口中的拉取按钮始终只是一次点击。', 'Follow the app setting': '跟随应用设置', 'Uses the app-wide switch and interval for this session. Switch it off to pick your own.': diff --git a/src/web/public/index.html b/src/web/public/index.html index 235952328..6611e6c32 100644 --- a/src/web/public/index.html +++ b/src/web/public/index.html @@ -1738,7 +1738,7 @@ <h2>Git</h2> <div class="set-group"> <div class="set-group-head"><h4>Fetch on an interval</h4></div> - <p class="set-group-hint">Fetching is the only git command the Git panel runs that writes, and the only one Codeman runs on a timer, so nothing runs until you turn it on — here, or in App Settings → Bottom bar. Cloning and worktree management write too, but only when you asked for them at the moment you asked. The panel's Fetch button is always just a click.</p> + <p class="set-group-hint">Fetching is the only git command the Git panel runs that writes, and the only one Codeman runs on a timer, so nothing runs until you turn it on (here, or in App Settings → Bottom bar). Cloning and worktree management write too, but only when you asked for them at the moment you asked. The panel's Fetch button is always just a click.</p> <div class="set-group-body"> <!-- Live from the session's own last run (session-ui.js: updateGitFetchStatus), which is the run its folder got: several sessions on one folder share one run and read the same row. --> @@ -2266,7 +2266,7 @@ <h2>Header & Panels</h2> <div class="set-row" data-search="git status uncommitted unpushed commit push indicator bottom bar toolbar"> <div class="set-row-text"> <span class="set-row-label">Git status <span class="set-scope">device</span></span> - <span class="set-row-desc">Shows, at the right of the bottom bar, when the active session's repository (or each repository inside its folder, up to two levels down) has uncommitted files or commits that are not pushed. Click it for the list. Read-only apart from Fetch: Codeman never commits or changes the repository, and `git fetch` — the only git command the Git window runs that writes, and the only one Codeman runs on a timer (cloning and worktree management write too, but only when you asked for them at the moment you asked) — runs when you click Fetch in that window, or on the interval below if you turned it on. Not shown for Docker or remote sessions. Off by default.</span> + <span class="set-row-desc">Shows, at the right of the bottom bar, when the active session's repository (or each repository inside its folder, up to two levels down) has uncommitted files or commits that are not pushed. Click it for the list. Read-only apart from Fetch: Codeman never commits or changes the repository, and `git fetch` runs only when you click Fetch in that window, or on the interval below if you turned it on. It is the only git command the Git window runs that writes, and the only one Codeman runs on a timer (cloning and worktree management write too, but only when you asked for them at the moment you asked). Not shown for Docker or remote sessions. Off by default.</span> </div> <label class="switch switch-sm"><input type="checkbox" id="appSettingsShowGitStatus"><span class="slider"></span></label> </div> @@ -2301,7 +2301,7 @@ <h2>Header & Panels</h2> <div class="set-row" data-search="git fetch how often seconds between runs interval case override session options"> <div class="set-row-text"> <span class="set-row-label">Git fetch: how often <span class="set-scope">synced</span></span> - <span class="set-row-desc">Seconds between runs for a session that has no override of its own (30 to 3600, default 300). Only a session that is on — here, or in Session Options → Git — is fetched, and sessions sharing one folder are fetched by one run.</span> + <span class="set-row-desc">Seconds between runs for a session that has no override of its own (30 to 3600, default 300). Only a session that is on (here, or in Session Options → Git) is fetched, and sessions sharing one folder are fetched by one run.</span> </div> <input type="number" id="appSettingsGitFetchIntervalSeconds" class="set-num" value="300" min="30" max="3600" step="1"> </div> diff --git a/src/web/public/session-ui.js b/src/web/public/session-ui.js index 152f80e8c..1842cca23 100644 --- a/src/web/public/session-ui.js +++ b/src/web/public/session-ui.js @@ -3101,7 +3101,7 @@ Object.assign(CodemanApp.prototype, { statusEl.hidden = false; }, - /** 'just now', '4m', '2h' — the short stamps the tab rows use, measured from a run's start. */ + /** 'just now', '4m', '2h': the short stamps the tab rows use, measured from a run's start. */ _gitFetchAgo(ts) { const seconds = Math.max(0, Math.round((Date.now() - ts) / 1000)); if (seconds < 60) return seconds < 10 ? 'just now' : `${seconds}s`; diff --git a/src/web/public/settings-ui.js b/src/web/public/settings-ui.js index 084a511ed..0bf5025c0 100644 --- a/src/web/public/settings-ui.js +++ b/src/web/public/settings-ui.js @@ -606,7 +606,7 @@ Object.assign(CodemanApp.prototype, { document.getElementById('appSettingsGitStatusTimeout').value = settings.gitStatusTimeoutSeconds ?? defaults.gitStatusTimeoutSeconds ?? 30; // Git fetch on an interval: synced, default OFF, interval default 300 s (DEFAULT_GIT_FETCH_INTERVAL_SECONDS). // Synced means the SERVER decides whether to fetch, so these keys stay out of the per-device display list - // below and are not stripped from a save — a stripped key would never reach the scheduler. + // below and are not stripped from a save: a stripped key would never reach the scheduler. document.getElementById('appSettingsGitFetchEnabled').checked = settings.gitFetchEnabled === true; document.getElementById('appSettingsGitFetchIntervalSeconds').value = settings.gitFetchIntervalSeconds ?? 300; // Gesture control lives in the Input section (alongside Local Echo / CJK Input) diff --git a/src/web/routes/git-status-routes.ts b/src/web/routes/git-status-routes.ts index 1480b1cc4..aff43d23a 100644 --- a/src/web/routes/git-status-routes.ts +++ b/src/web/routes/git-status-routes.ts @@ -36,7 +36,7 @@ import type { EventPort, SessionPort } from '../ports/index.js'; /** * Host paths of every Docker case workspace: repositories at or inside these are never inspected. - * Exported so the interval scheduler (src/git-fetch-scheduler.ts) uses the SAME list — the Docker rule has + * Exported so the interval scheduler (src/git-fetch-scheduler.ts) uses the SAME list: the Docker rule has * to be one rule, checked by every path that could run git. * * REJECTS when `docker-cases.json` exists but cannot be read or parsed (its writer truncates and rewrites it @@ -142,11 +142,11 @@ export function registerGitStatusRoutes( // Fetch, and only because the user clicked Fetch in the panel (src/git-fetch.ts): one // `git fetch --quiet` per repository the panel would show, through the same resolver so the two cannot // disagree about which repositories belong to the workspace. This is the only git command the Git panel runs - // that writes, and it writes remote-tracking refs only — nothing commits, checks out or merges. Cloning and + // that writes, and it writes remote-tracking refs only: nothing commits, checks out or merges. Cloning and // worktree management write too, but those happen when the user asked for them; this route is the one that // can also run later, on a timer. It is not as harmless as a read though: a fetch holds git's ref lock, so // an agent's own `git pull` in the same clone can collide with it, which is why the run goes through - // `fetchWorkspaceOnce` — a click while a tick is already fetching this folder joins that run instead of + // `fetchWorkspaceOnce`: a click while a tick is already fetching this folder joins that run instead of // starting a second one. It can also run a // repository's own hooks (`reference-transaction`, `pre-auto-gc` through the maintenance git runs after a // fetch) and its config commands such as `credential.helper`, so the `FETCH_CONFIG_ARGS` overrides turn diff --git a/src/web/routes/session-routes.ts b/src/web/routes/session-routes.ts index 9d71d204c..a2bf2d57c 100644 --- a/src/web/routes/session-routes.ts +++ b/src/web/routes/session-routes.ts @@ -3682,7 +3682,7 @@ export function registerSessionRoutes( // fetched when the first of them is due. `null` on either field clears this session's choice so the // app-wide setting applies again, and a field left out of the body leaves the other half as it was: the // Session Options rows save on their own. Refused for a remote or Docker session, exactly as the Git - // panel excludes them — a container can write to the repository it mounts, so git there would run that + // panel excludes them: a container can write to the repository it mounts, so git there would run that // repository's own configuration on the host. app.post('/api/sessions/:id/git-fetch-settings', async (req) => { const { id } = req.params as { id: string }; diff --git a/src/web/schemas.ts b/src/web/schemas.ts index f11e0a969..6c1ce4746 100644 --- a/src/web/schemas.ts +++ b/src/web/schemas.ts @@ -1491,8 +1491,8 @@ export const SettingsUpdateSchema = z /** * Git fetch on an interval (src/git-fetch-scheduler.ts): `git fetch` for the repositories the Git panel * lists, run on a timer instead of only on the panel's Fetch click. SYNCED, default OFF, because the - * server is what runs it: `git fetch` is the only git command the Git panel runs that writes — cloning - * and worktree management write too, but only when the user asked for them at the moment they asked — and + * server is what runs it: `git fetch` is the only git command the Git panel runs that writes (cloning + * and worktree management write too, but only when the user asked for them at the moment they asked), and * the only one that can run later by itself, on a timer, and the only one that can run a repository's own * hooks (`reference-transaction`, `pre-auto-gc` through the automatic maintenance a fetch triggers, config * commands such as `credential.helper`), so nothing runs on a timer until the user turns this on. Cap in seconds between runs (30 to 3600, default 300). A diff --git a/src/web/server.ts b/src/web/server.ts index c0799d46d..8942c0d7d 100644 --- a/src/web/server.ts +++ b/src/web/server.ts @@ -399,8 +399,8 @@ export class WebServer extends EventEmitter { }); // Git fetch on an interval (git-fetch-scheduler.ts): the due-tracking lives in the service; the server // lends it the facts a decision needs and stores what it reports on the session. A field initializer for - // the same reason as the keep-warm one above, and the scheduler reads the app setting FRESH on every tick - // — that is what makes a live toggle take effect on the next tick, and what makes turning it off actually + // the same reason as the keep-warm one above, and the scheduler reads the app setting FRESH on every tick: + // that is what makes a live toggle take effect on the next tick, and what makes turning it off actually // stop the fetching. Default OFF, so an install that never enabled it spawns nothing at all. private readonly gitFetchScheduler = new GitFetchScheduler({ sessionIds: () => this.sessions.keys(), @@ -413,7 +413,7 @@ export class WebServer extends EventEmitter { // never be handed `pastCap`. A folder on a mount that stopped answering is skipped, not fetched. probePath: (path) => probePathKind(path), // Through the join, so the tick and the panel's Fetch button are the same run: a click on a folder the - // tick is already fetching joins it rather than starting a second `git fetch` in the same clone — and the + // tick is already fetching joins it rather than starting a second `git fetch` in the same clone, and the // run that gets published is the one that ran, with its own trigger. fetch: (cwd, opts, trigger) => fetchWorkspaceOnce(cwd, opts, trigger), publish: (id, status) => { @@ -1287,7 +1287,7 @@ export class WebServer extends EventEmitter { registerReadMyMindRoutes(this.app, ctx); registerGitStatusRoutes(this.app, ctx); // Git fetch on an interval: this is only the wake-up, the scheduler itself decides what is due. Off in - // tests for the same reason as the polls below — a test drives `tick()` directly instead of waiting on + // tests for the same reason as the polls below: a test drives `tick()` directly instead of waiting on // a timer the harness would have to keep alive. The setting is read FRESH inside `tick()`, so turning // it on takes effect on the next tick and turning it off actually stops the fetching. if (!this.testMode) { diff --git a/test/app-settings-structure.test.ts b/test/app-settings-structure.test.ts index 316d621b6..cdd39caf4 100644 --- a/test/app-settings-structure.test.ts +++ b/test/app-settings-structure.test.ts @@ -222,7 +222,7 @@ describe('App Settings modal structure', () => { // The strip destructure is the other half: an aliased key here never reaches the settings PUT. expect(settingsUi).not.toMatch(/gitFetch(?:Enabled|IntervalSeconds):\s*_\w+/); - // Declared in the .strict() schema — an undeclared key 400s the whole settings PUT. + // Declared in the .strict() schema: an undeclared key 400s the whole settings PUT. expect(SettingsUpdateSchema.safeParse({ gitFetchEnabled: true, gitFetchIntervalSeconds: 300 }).success).toBe(true); // Off by default (only an explicit true loads as on), and an empty box falls back to the default interval. diff --git a/test/git-fetch-i18n.test.ts b/test/git-fetch-i18n.test.ts index 0d9cfff08..71ecdbefb 100644 --- a/test/git-fetch-i18n.test.ts +++ b/test/git-fetch-i18n.test.ts @@ -2,11 +2,11 @@ * @fileoverview The Git fetch interval rows (App Settings → Bottom bar, Session Options → Git) in Chinese * (zh-CN): each label and description runs through the real translator over the real index.html in JSDOM and * reads in Chinese with no English left beyond the product name and the technical names Git / git fetch, and - * every new key — the save toast in session-ui.js included — is in the dictionary exactly once (a repeated key + * every new key (the save toast in session-ui.js included) is in the dictionary exactly once (a repeated key * in the object literal silently replaces the first). * * The Git tab's Status row is not here: its text is written at runtime by updateGitFetchStatus and carries - * data-i18n-skip, so translating it would rewrite the stamp the code builds. 'sec' is not a key either — only + * data-i18n-skip, so translating it would rewrite the stamp the code builds. 'sec' is not a key either: only * whole unit words are ('hours'), so 'sec' stays as written. * * Port: N/A. diff --git a/test/git-fetch-scheduler.test.ts b/test/git-fetch-scheduler.test.ts index 01f1eaeef..3c7add065 100644 --- a/test/git-fetch-scheduler.test.ts +++ b/test/git-fetch-scheduler.test.ts @@ -1,12 +1,12 @@ /** * @fileoverview The Git fetch interval scheduler (src/git-fetch-scheduler.ts): which sessions a tick runs, * and which it must not. Every rule is checked by calling `tick(now)` with an explicit clock, so nothing here - * depends on fake timers or on how long a real tick takes — the scheduler owns no timers itself. + * depends on fake timers or on how long a real tick takes: the scheduler owns no timers itself. * * The run belongs to the FOLDER, so these tests put two sessions on one folder and expect one fetch that * both see, and they check the tick's two bounds separately: one shared bound for the fetches of the whole * tick, and the folders bounded by a bound that cannot be the same semaphore. A folder is due when ANY session - * open on it is due, so the order the sessions were read cannot decide it — that is checked in both orders. + * open on it is due, so the order the sessions were read cannot decide it, and that is checked in both orders. * A run carries the trigger that caused it, and what is published says the run's, not the tick's. * * Port: N/A. @@ -62,7 +62,7 @@ type Harness = { /** The folders the tick actually ran, in the order they were picked, with the trigger the tick passed. */ calls: Array<{ cwd: string; opts: GitFetchOptions; trigger: GitFetchTrigger }>; published: Array<{ id: string; status: GitFetchStatus }>; - /** Every path the tick probed, in order — the probe has to happen before anything is fetched. */ + /** Every path the tick probed, in order: the probe has to happen before anything is fetched. */ probed: string[]; settings: GitFetchSettings; dockerReads: number; @@ -247,7 +247,7 @@ describe('who runs', () => { expect(h.calls.length).toBe(4); }); - it('runs a session that turned itself on even while the app-wide switch is off — that is what a per-session switch has to mean', async () => { + it('runs a session that turned itself on even while the app-wide switch is off: that is what a per-session switch has to mean', async () => { const h = harness([VIEW('plain'), VIEW('own', { gitFetchEnabled: true, gitFetchIntervalSeconds: 30 })], { settings: { enabled: false, intervalSeconds: 300 }, }); @@ -355,7 +355,7 @@ describe('one folder, one run', () => { expect(await h.scheduler.tick(15_000)).toBe(0); expect(h.published).toEqual([]); - // The short session is past its own interval, so the folder is fetched — once, for both. The run that + // The short session is past its own interval, so the folder is fetched (once, for both). The run that // happens covers the long session's interval too, and both still get it because both are open on the // folder: publishing only the session that made the folder due would leave the other tab on a stale row. h.published.length = 0; @@ -410,7 +410,7 @@ describe('one folder, one run', () => { { // Every folder is handed the SAME limiter for its work, so that work cannot get its own bound: a // per-call bound would let 5 folders × 6 items run at once. The folders themselves are bounded by a - // DIFFERENT bound, because one semaphore cannot serve both levels — a folder holding a slot and then + // DIFFERENT bound, because one semaphore cannot serve both levels: a folder holding a slot and then // waiting for one for its own fetch is waiting on itself, and the folders that fill the bound are // exactly the ones blocking it. If the tick used one semaphore for both, this test would hang and fail // as a timeout rather than as an assertion. diff --git a/test/git-fetch.test.ts b/test/git-fetch.test.ts index 918214eeb..26e6dbdd1 100644 --- a/test/git-fetch.test.ts +++ b/test/git-fetch.test.ts @@ -1,24 +1,24 @@ /** * @fileoverview Tests for the Git panel's Fetch button (src/git-fetch.ts): the only git command the Git panel - * runs that writes, and the only one that can run without being asked — which is why it is a click, or an + * runs that writes, and the only one that can run without being asked, which is why it is a click, or an * interval the user turned on, and why the writing and the hooks it can run are pinned here. Cloning and * worktree management write too, but they write when the user asked. * * Four halves, mirroring the module: * - * 1. The PURE half — the fixed argv, the `-c` overrides the runner adds ahead of it, the ssh environment the + * 1. The PURE half: the fixed argv, the `-c` overrides the runner adds ahead of it, the ssh environment the * runner refuses to override (`fetchEnvFor`), the failure classification, and the two reads the interval * scheduler depends on: what the settings say (`gitFetchSettingsFrom`) and what a run did * (`gitFetchStatusFrom`). No spawning. - * 2. The IO half — a REAL git fetching a REAL local bare repo, so that "remote-tracking refs only" is + * 2. The IO half: a REAL git fetching a REAL local bare repo, so that "remote-tracking refs only" is * proven against git's actual behavior: HEAD, the index and the working tree must be exactly what they * were while the remote-tracking ref moves. The rest of the half uses an injected runner over real * repositories on disk, because what is being pinned is WHICH repositories git is allowed to touch. - * 3. The process group — a timed-out fetch has to take its `git-remote-http` helper with it, against a + * 3. The process group: a timed-out fetch has to take its `git-remote-http` helper with it, against a * server that accepts and never answers, so the assertion is about what is still RUNNING after the answer * went out. A test that only checked the returned error would pass with the old `execFile` runner, which * signals the parent only. - * 4. The join (`fetchWorkspaceOnce`) — one run per folder at a time, because two fetches in one clone + * 4. The join (`fetchWorkspaceOnce`): one run per folder at a time, because two fetches in one clone * collide on git's ref lock, the bound is ONE limiter shared with the scheduler's tick rather than a bound * each run gets for itself, and the trigger a join reports is the joined run's, not the joiner's. * @@ -311,7 +311,7 @@ describe.skipIf(!gitPresent)('fetchGitWorkspaceOverview (real git)', () => { git(work, 'push', '-q', 'origin', 'main'); // A commit made elsewhere and pushed: work's origin/main cannot hear about it until a fetch runs. - // This has to be a clone of origin, not a fresh init — a commit on its own root commit is no + // This has to be a clone of origin, not a fresh init: a commit on its own root commit is no // descendant of what origin already holds, so pushing it is rejected as non-fast-forward. const other = join(dir, 'other'); git(dir, 'clone', '-q', origin, 'other'); @@ -441,7 +441,7 @@ interface Proc { } /** - * Every process on the machine, as /proc reports it — so this half can only run on Linux, and the fields are + * Every process on the machine, as /proc reports it, so this half can only run on Linux, and the fields are * counted from the closing parenthesis because `comm` can hold spaces. * * Membership of the GROUP is what is checked, not parentage: a helper that outlives its parent stops being a @@ -496,8 +496,8 @@ describe.skipIf(!gitPresent)('runFetch (the whole tree, not just the git parent) const answered = runFetch(repo, buildFetchArgs(), { timeoutMs: 1_500 }).catch((e) => e); // The tree has to be a tree before the timeout, or nothing is being proven: git itself, and the helper - // it spawns for an http remote. The helper is a grandchild — `git fetch` runs `git remote-http`, which - // spawns `git-remote-http` — so the group, not the parentage, is what proves it. Each process is + // it spawns for an http remote. The helper is a grandchild (`git fetch` runs `git remote-http`, which + // spawns `git-remote-http`), so the group, not the parentage, is what proves it. Each process is // remembered once found rather than re-scanned: after the kill the group is empty, and a test that kept // looking would report a tree it had already destroyed. The `git config` read the runner does first is // a `git` too, so the fetch is found by what it was asked to do. @@ -527,7 +527,7 @@ describe.skipIf(!gitPresent)('runFetch (the whole tree, not just the git parent) expect(err.code).toBe('ETIMEDOUT'); expect(err.killed).toBe(true); - // The answer went out before the tree died — that is the whole point of the fix, and it is why the + // The answer went out before the tree died: that is the whole point of the fix, and it is why the // escalation is given its own time here instead of being cancelled when the promise settles. const deadline = Date.now() + FETCH_KILL_ESCALATION_MS + 2_000; while (Date.now() < deadline && scanProc().some((p) => p.pgrp === parent!.pgrp)) await sleep(50); @@ -631,7 +631,7 @@ describe.skipIf(!gitPresent)('fetchWorkspaceOnce (the join)', () => { const first = fetchWorkspaceOnce(folder, { git: firstRunner }, 'interval'); await runStarted; // the run is going, and still going - // A click while that run is working the folder: it joins, so it never touches git at all — not even the + // A click while that run is working the folder: it joins, so it never touches git at all, not even the // resolver's own probe, because it does not resolve anything for itself. let joinerCalls = 0; const joinerRunner: GitRunner = async () => { diff --git a/test/routes/git-status-routes.test.ts b/test/routes/git-status-routes.test.ts index 7e8269fd6..ec92c81d3 100644 --- a/test/routes/git-status-routes.test.ts +++ b/test/routes/git-status-routes.test.ts @@ -523,7 +523,7 @@ describe('POST /api/sessions/:id/git-fetch', () => { const { app } = await setup({ git: runner }); const data = (await app.inject({ method: 'POST', url: '/api/sessions/test-session-1/git-fetch' })).json().data; expect(data.state).toBe('not-a-repo'); - // The resolver looked once and found nothing, so nothing was fetched — and never guessed a repository. + // The resolver looked once and found nothing, so nothing was fetched, and it never guessed a repository. expect(fetches()).toEqual([]); }); @@ -564,7 +564,7 @@ describe('POST /api/sessions/:id/git-fetch', () => { const second = app.inject({ method: 'POST', url: '/api/sessions/test-session-1/git-fetch' }); // Assert the join BEFORE releasing the run: the lane entry goes away when the run finishes, so releasing // first would let the run complete before this request reached the lane, and it would then be entitled to - // start its own fetch — a test that passes for the wrong reason. One tick of the loop is enough for both + // start its own fetch: a test that passes for the wrong reason. One tick of the loop is enough for both // handlers to reach the lane while the run is still held open. await new Promise((resolve) => setImmediate(resolve)); expect(fetches).toHaveLength(1); // one run, and the second request is waiting on it, not working @@ -593,7 +593,7 @@ describe('POST /api/sessions/:id/git-fetch', () => { const { app, ctx } = await setup({ git: runner }); // The interval tick got to this clone first. The click joins it, so the row the other tabs see has to name - // the interval — the user clicked, but nothing this click did was a fetch. The request is left open, not + // the interval: the user clicked, but nothing this click did was a fetch. The request is left open, not // awaited: awaiting it here would wait on the run this test is holding, and the test would wait on itself. const intervalRun = fetchWorkspaceOnce(dir, { git: runner }, 'interval'); await runStarted; diff --git a/test/routes/session-routes-git-fetch-settings.test.ts b/test/routes/session-routes-git-fetch-settings.test.ts index 4a7111bc3..a1d40120e 100644 --- a/test/routes/session-routes-git-fetch-settings.test.ts +++ b/test/routes/session-routes-git-fetch-settings.test.ts @@ -1,6 +1,6 @@ /** * `POST /api/sessions/:id/git-fetch-settings` (session-routes.ts): the per-case half of the fetch interval - * (src/git-fetch-scheduler.ts) — the case's own switch and its own interval. `null` on either half clears + * (src/git-fetch-scheduler.ts): the case's own switch and its own interval. `null` on either half clears * that half so the app-wide setting applies again, and a body that names one row leaves the other as it was, * because the Session Options rows save on their own. Refused for remote and Docker cases, the same rule the * Git panel and the scheduler use. app.inject(), no port. diff --git a/test/session-options-structure.test.ts b/test/session-options-structure.test.ts index 58a8c5aad..e59ea7c71 100644 --- a/test/session-options-structure.test.ts +++ b/test/session-options-structure.test.ts @@ -105,7 +105,7 @@ describe('Session Options modal structure', () => { expect(update).toContain('set-row-disabled'); expect(update).toContain('el.disabled = follows'); // Decided from BOTH fields: a half-choice means the session is not following, and the half that still - // follows shows the app's value while the other shows its own — a switch-only check would gray out a live row. + // follows shows the app's value while the other shows its own; a switch-only check would gray out a live row. expect(update).toMatch( /follows = session\.gitFetchEnabled === undefined && session\.gitFetchIntervalSeconds === undefined/ ); diff --git a/test/spaced-file-path-links.test.ts b/test/spaced-file-path-links.test.ts index 4c9c41fcd..ead0aaf94 100644 --- a/test/spaced-file-path-links.test.ts +++ b/test/spaced-file-path-links.test.ts @@ -1,4 +1,4 @@ -// Port: none (a pure function in a vm context — no browser, no server). +// Port: none (a pure function in a vm context: no browser, no server). // // File paths with spaces in them (`OneDrive - Contoso/…`, `90 Sensitive/…`) were // cut at the first space by every path pattern, so the terminal and the response diff --git a/test/terminal-cell-wrapped-paths.test.ts b/test/terminal-cell-wrapped-paths.test.ts index 765ec3dfa..b2939794b 100644 --- a/test/terminal-cell-wrapped-paths.test.ts +++ b/test/terminal-cell-wrapped-paths.test.ts @@ -1,4 +1,4 @@ -// Port: none (pure functions + the link provider in a vm context — no browser, no server). +// Port: none (pure functions + the link provider in a vm context: no browser, no server). // // Absolute file paths codex word-wrapped inside a markdown table cell were not // clickable: the first fragment has no extension and the continuation has no diff --git a/test/terminal-url-continuation.test.ts b/test/terminal-url-continuation.test.ts index 3d03c8800..fb20f2c3d 100644 --- a/test/terminal-url-continuation.test.ts +++ b/test/terminal-url-continuation.test.ts @@ -1,4 +1,4 @@ -// Port: none (pure functions + the link provider in a vm context — no browser, no server). +// Port: none (pure functions + the link provider in a vm context: no browser, no server). // // Terminal URL links that stopped short. // From 8a6e34bece555b762a1ae95bedf989fc15af7c52 Mon Sep 17 00:00:00 2001 From: Codeman maintainer <noreply@anthropic.com> Date: Tue, 27 Oct 2026 09:36:15 +0100 Subject: [PATCH 66/68] docs: list both unauthenticated-exposure acknowledgements as stable CODEMAN_ALLOW_UNAUTHENTICATED_NETWORK and the new CODEMAN_ALLOW_UNAUTHENTICATED_TUNNEL (#718), with their CLI flags, join the documented environment variables the versioning policy covers, from 1.46.0, the release that gave each its own meaning. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- .changeset/deba6392.md | 2 +- docs/versioning-policy.md | 9 +++++++-- 2 files changed, 8 insertions(+), 3 deletions(-) diff --git a/.changeset/deba6392.md b/.changeset/deba6392.md index c0835570d..6d8a7ea27 100644 --- a/.changeset/deba6392.md +++ b/.changeset/deba6392.md @@ -30,7 +30,7 @@ **Fixes.** -- **Security: a LAN install's acknowledgement no longer opens a public tunnel (#718).** `CODEMAN_ALLOW_UNAUTHENTICATED_NETWORK` (and `--allow-unauthenticated-network`), which the installer writes for every LAN install without a password, also let the Cloudflare tunnel start, so one click on the welcome screen's tunnel link published full terminal control to a public URL with no login and no confirm. It now covers the network bind only. An unauthenticated tunnel has its own opt-in, `CODEMAN_ALLOW_UNAUTHENTICATED_TUNNEL=1` or `codeman web --allow-unauthenticated-tunnel` (carried by `codeman web -d` and `codeman service install`), which the installer never writes; if you relied on the network variable to start the tunnel without a password, set one of those. With no password and no multi-user mode, the browser now asks before every tunnel enable, using the new `loginConfigured` field of `GET /api/tunnel/status`. +- **Security: a LAN install's acknowledgement no longer opens a public tunnel (#718).** `CODEMAN_ALLOW_UNAUTHENTICATED_NETWORK` (and `--allow-unauthenticated-network`), which the installer writes for every LAN install without a password, also let the Cloudflare tunnel start, so one click on the welcome screen's tunnel link published full terminal control to a public URL with no login and no confirm. It now covers the network bind only. An unauthenticated tunnel has its own opt-in, `CODEMAN_ALLOW_UNAUTHENTICATED_TUNNEL=1` or `codeman web --allow-unauthenticated-tunnel` (carried by `codeman web -d` and `codeman service install`), which the installer never writes; if you relied on the network variable to start the tunnel without a password, set one of those. With no password and no multi-user mode, the browser now asks before every tunnel enable, using the new `loginConfigured` field of `GET /api/tunnel/status`. Both acknowledgement variables and their flags are now listed as stable in the versioning policy. - **settings.json can no longer be torn (#719).** A quick-start's `lastUsedCase` write was not awaited and not atomic, so it could overlap the next settings save and leave one write's bytes over the tail of the other, after which every reader saw `{}`. Every write to settings.json now goes through one serialized writer that writes a temp file and renames it into place, and a file that does not parse is refused with `409 CONFLICT` instead of being overwritten with only the writer's own keys. A case rename checks this before it ends any session. - **A worker id is never lost (#709).** When `codeman agent spawn` created a worker but its readiness request failed, it exited without the new session id. It now prints the id (and `--json` carries it) and exits 1, so the caller can inspect or delete the worker. `codeman agent` and `codeman mcp` also name the error code (such as `ECONNREFUSED`) when a request fails with an empty message. - **The DeepSeek transcript test passes on Node 24.22+ and 26.10+ (#710).** It pinned Node's old zstd decoder, which stopped at the first frame until nodejs/node#65865. diff --git a/docs/versioning-policy.md b/docs/versioning-policy.md index 8f8581a58..d049ae5f6 100644 --- a/docs/versioning-policy.md +++ b/docs/versioning-policy.md @@ -30,8 +30,13 @@ A **MAJOR** bump is required to break any of these after 1.0: 3. **Documented environment variables** that configure deployment: `CODEMAN_PASSWORD`, `CODEMAN_USERNAME`, `CODEMAN_HOST`, `CODEMAN_PORT`, `CODEMAN_INSTANCE`, `CODEMAN_ALLOWED_HOSTS`, `CODEMAN_DATA_DIR`, - `CODEMAN_TMUX_SOCKET`, and the `--host` / `--port` / `--https` CLI flags. - Removing or changing the meaning of one of these is breaking. + `CODEMAN_TMUX_SOCKET`, `CODEMAN_ALLOW_UNAUTHENTICATED_NETWORK`, + `CODEMAN_ALLOW_UNAUTHENTICATED_TUNNEL`, and the `--host` / `--port` / + `--https` / `--allow-unauthenticated-network` / + `--allow-unauthenticated-tunnel` CLI flags. Removing or changing the meaning + of one of these is breaking. The two acknowledgements are listed from 1.46.0, + the release that split them: the network one covers the non-loopback bind + only, and an unauthenticated tunnel needs the tunnel one. 4. **The HTTP API and SSE event channel**, served under **`/api/v1`** with the uniform `{success:true,data}` / `{success:false,error,errorCode}` envelope and conventional HTTP status codes. Endpoint paths, the response envelope, error From ecf28fa3bedaa7faaa8eaaa7fba265aca27f6d1d Mon Sep 17 00:00:00 2001 From: Codeman maintainer <noreply@anthropic.com> Date: Tue, 27 Oct 2026 09:37:13 +0100 Subject: [PATCH 67/68] chore: version packages Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- .changeset/deba6392.md | 40 ---------------------- .claude-plugin/marketplace.json | 2 +- CHANGELOG.md | 38 ++++++++++++++++++++ CLAUDE.md | 2 +- config/mcp-registry/server.json | 4 +-- package-lock.json | 6 ++-- package.json | 2 +- packages/xterm-zerolag-input/CHANGELOG.md | 38 ++++++++++++++++++++ packages/xterm-zerolag-input/package.json | 2 +- plugins/codeman/.claude-plugin/plugin.json | 2 +- src/web/public/whats-new.json | 18 +++++----- 11 files changed, 95 insertions(+), 59 deletions(-) delete mode 100644 .changeset/deba6392.md diff --git a/.changeset/deba6392.md b/.changeset/deba6392.md deleted file mode 100644 index 6d8a7ea27..000000000 --- a/.changeset/deba6392.md +++ /dev/null @@ -1,40 +0,0 @@ ---- -"aicodeman": minor -"xterm-zerolag-input": patch ---- - -### Thanks - -- @JDProfresh for five PRs: a phone keyboard that no longer costs Claude its transcript (#711, measured in #672), a + on each session tab that starts a session beside it (#712, shaped in Discussion #700), a tunnel that needs its own opt-in instead of the LAN one (#718, from their report #706), a settings.json that two writers can no longer tear (#719), and a DeepSeek test that passes on current Node (#710). -- @aakhter for six PRs: `codeman agent spawn` in a project directory (#708), worker ids kept when the readiness check fails (#709), the offline Codex history reader (#707), experimental bounded inspection and guarded submission for agents (#694, from Discussion #695), links that follow URLs and paths across wrapped rows and table cells (#686), and file paths with spaces linked as one path (#687). -- @opticon454 for the Fetch button and fetch interval in the Git status window (#714). -- @johoja12 for Cmd+K arrows that switch the CLI both ways after you type a case name (#717). - -**Fetch from the Git status window (#714).** The Git status window has a Fetch button next to Refresh. It runs `git fetch` for the repositories it lists and shows each one's result. An optional interval runs it for you: off by default, app-wide in App Settings, Bottom bar, with a per-session override in Session Options, Git. A fetch only moves remote-tracking refs (HEAD, the index and the working tree stay as they are), it never runs for a Docker or remote session, and it is bounded in time, output and concurrency. - -**Start a session beside any tab (#712, opt-in).** Turn on **New Session Button on Tabs** (App Settings, Tabs) and each session tab gets a +: pick any CLI its case can run (a Docker case offers what its container has) and the new session starts in that case or worktree and that tab's group, without touching the toolbar's case or Run mode. The row menu offers the same as "New session here…" for the keyboard. - -**The phone keyboard no longer costs Claude its transcript (#711).** Every keyboard open and close used to resize the PTY, and Claude Code answers each height change by reprinting only the tail that fits, so lines above it were lost from tmux for good (25 of 100 in five keyboard cycles). A local Claude session now keeps its rows while the keyboard is up and slides the screen so the composer stays above the keyboard. On by default as **Keep Size Under Keyboard** (App Settings, per device); off restores the old resize. Fullscreen Claude (mouse tracking on) still resizes as before. - -**Typing stays smooth in a long prompt (#713).** The local echo overlay (the instant preview of what you type) rebuilt every character of the prompt on each keystroke, so on a phone-class CPU a 4,000-character prompt took most of a second per key. It now rebuilds only the line that changed, with one paint layer per line, so a keystroke costs the same however long the prompt is. Every character still sits on its terminal cell. This ships in `xterm-zerolag-input` too. - -**Links survive wrapping and spaces (#686, #687).** A URL or path a CLI wrapped inside a table cell or an indented paragraph now links whole instead of in fragments, URLs keep balanced parentheses (`…/Team-Space(QA)Archive/…`), a full-width table divider no longer glues onto the row below, and OSC 8 hyperlinks open through Codeman's own opener after a confirmation. A file path with spaces (`/mnt/c/OneDrive - Contoso/report.docx`, `Saved to: ~/My Docs/plan.md`) links as one path in the terminal and the response viewer when the line bounds it, and a shell prompt that prints its working directory no longer turns the whole command line into one link. - -**`codeman agent spawn` in a project directory (#708).** Without a case name, `spawn` starts the worker in the current directory, or in an existing directory given with `--working-dir`, through `POST /api/v1/sessions` and the mode's start route. Named cases still use quick-start. `--json` prints `{ sessionId, workingDir, created: true }` plus `ready`, `readinessError` or `startupError`. An empty `--working-dir`, a bare spawn from your home directory (it would install Codeman's hooks into `~/.claude`; pass `--working-dir ~` to mean it) and an unknown `--mode` are refused with exit 4 before any request. - -**Read saved Codex conversations offline (#707).** `codeman history read` reads Codex's saved rollouts without a Codeman server: by file, by Codex session id, or a whole conversation with its sub-agents and forks, filtered by topic or UTC date with whole turns kept. Reads and output have separate, raisable budgets, and anything it could not read or attribute (a compressed `.jsonl.zst` rollout, a file still being written) is reported through `complete: false` instead of failing the lookup. The codeman skill routes to it. - -**Experimental: bounded inspection and guarded submission for agents (#694, opt-in).** `codeman agent inspect` reads bounded, cursor-paged evidence from another session (a local Codex session's attributed rollout records, otherwise a labelled pane snapshot), and `agent stage` / `agent submit` type one line into a verified local Codex composer and later send exactly one Enter against a hash-confirmed receipt. Off by default behind **Experimental agent coordination** (App Settings, Cross-session features); while off the three routes answer `403 FORBIDDEN` naming the setting. These routes and verbs are experimental under the versioning policy and may change in a minor or patch release. During the few milliseconds of a guarded submit, other input is held and written in order afterwards; a tagged input POST in that window answers `409 CONFLICT` so its sender retries, and an untagged one (the phone keys, the Stop button) is held and answered once written. - -**Cmd+K arrows switch the CLI both ways (#717).** After typing a case name in Cmd+K, plain Left and Right now both change the New session row's CLI whenever the caret is at either end of the query (only Right did before). Inside the query the arrows move the caret as usual. - -**Fixes.** - -- **Security: a LAN install's acknowledgement no longer opens a public tunnel (#718).** `CODEMAN_ALLOW_UNAUTHENTICATED_NETWORK` (and `--allow-unauthenticated-network`), which the installer writes for every LAN install without a password, also let the Cloudflare tunnel start, so one click on the welcome screen's tunnel link published full terminal control to a public URL with no login and no confirm. It now covers the network bind only. An unauthenticated tunnel has its own opt-in, `CODEMAN_ALLOW_UNAUTHENTICATED_TUNNEL=1` or `codeman web --allow-unauthenticated-tunnel` (carried by `codeman web -d` and `codeman service install`), which the installer never writes; if you relied on the network variable to start the tunnel without a password, set one of those. With no password and no multi-user mode, the browser now asks before every tunnel enable, using the new `loginConfigured` field of `GET /api/tunnel/status`. Both acknowledgement variables and their flags are now listed as stable in the versioning policy. -- **settings.json can no longer be torn (#719).** A quick-start's `lastUsedCase` write was not awaited and not atomic, so it could overlap the next settings save and leave one write's bytes over the tail of the other, after which every reader saw `{}`. Every write to settings.json now goes through one serialized writer that writes a temp file and renames it into place, and a file that does not parse is refused with `409 CONFLICT` instead of being overwritten with only the writer's own keys. A case rename checks this before it ends any session. -- **A worker id is never lost (#709).** When `codeman agent spawn` created a worker but its readiness request failed, it exited without the new session id. It now prints the id (and `--json` carries it) and exits 1, so the caller can inspect or delete the worker. `codeman agent` and `codeman mcp` also name the error code (such as `ECONNREFUSED`) when a request fails with an empty message. -- **The DeepSeek transcript test passes on Node 24.22+ and 26.10+ (#710).** It pinned Node's old zstd decoder, which stopped at the first frame until nodejs/node#65865. - -**Fixes applied while landing.** The Git fetch interval field is no longer reset by a session update while you type in it, a session holding only half an override shows the app's value for the other half, reading a repository's `core.sshCommand` no longer doubles the fetch timeout, a `GIT_SSH` in the server's environment is honoured, and the Fetch button's disabled and busy styles no longer reach other icon buttons and stop pulsing under reduced motion (#714). A + launch keeps the toolbar on its own case, and pressing the + again closes its picker (#712). The fetch scheduler no longer logs the text of a malformed settings.json on every 15-second tick (the parser's message could quote an API key; it now says the file is not valid JSON, once), and a fetch is skipped while the Docker case list cannot be read, so it can never run inside a Docker workspace (#714). A case rename answers `409 CONFLICT` for a settings.json it cannot write before it ends any session (#719). The unauthenticated-tunnel warning no longer fires in multi-user mode, where the tunnel has a login in front of it (#718). Untagged input during a guarded submit is held instead of refused, and the skill's staging guide says so (#694). A shell prompt line (a spaced working directory followed by `$` included) and a labelled `../` spaced path link as they should, and a bold label or inline code before a path in the response viewer splits it as the terminal does (#687); a URL whose row soft-wraps no longer glues a trailing column onto the next path, and in a table a link in the column right of a wrapped URL stays clickable on the URL's first row (#686). The keyboard hold reads the terminal's height only while rows are held (#711). The README, the plugin's README and its marketplace description now say the codeman skill reads saved Codex conversations outside a Codeman session and refuses everything else there (#707). - -**Docs and tests.** New sections for the keyboard hold (next to the terminal geometry rule it is an exception to), the tab +, Git fetch, the settings writer, the tunnel opt-in, the directory spawn, the offline history reader and the experimental agent routes, in CLAUDE.md, the API reference, the architecture notes, the wiki and the codeman skill. diff --git a/.claude-plugin/marketplace.json b/.claude-plugin/marketplace.json index cebbe8fcc..6bee8a401 100644 --- a/.claude-plugin/marketplace.json +++ b/.claude-plugin/marketplace.json @@ -10,7 +10,7 @@ "name": "codeman", "source": "./plugins/codeman", "description": "Drive Codeman from inside a Claude Code session: spawn worker sessions, prompt them, wait for them, read their answers, clean up. Acts inside a Codeman-managed session; outside one it only reads saved Codex conversations offline.", - "version": "1.45.0", + "version": "1.46.0", "author": { "name": "Ark0N", "url": "https://github.com/Ark0N" diff --git a/CHANGELOG.md b/CHANGELOG.md index 4e08fd259..787666a88 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,43 @@ # aicodeman +## 1.46.0 + +### Minor Changes + +- de1aab9: ### Thanks + - @JDProfresh for five PRs: a phone keyboard that no longer costs Claude its transcript (#711, measured in #672), a + on each session tab that starts a session beside it (#712, shaped in Discussion #700), a tunnel that needs its own opt-in instead of the LAN one (#718, from their report #706), a settings.json that two writers can no longer tear (#719), and a DeepSeek test that passes on current Node (#710). + - @aakhter for six PRs: `codeman agent spawn` in a project directory (#708), worker ids kept when the readiness check fails (#709), the offline Codex history reader (#707), experimental bounded inspection and guarded submission for agents (#694, from Discussion #695), links that follow URLs and paths across wrapped rows and table cells (#686), and file paths with spaces linked as one path (#687). + - @opticon454 for the Fetch button and fetch interval in the Git status window (#714). + - @johoja12 for Cmd+K arrows that switch the CLI both ways after you type a case name (#717). + + **Fetch from the Git status window (#714).** The Git status window has a Fetch button next to Refresh. It runs `git fetch` for the repositories it lists and shows each one's result. An optional interval runs it for you: off by default, app-wide in App Settings, Bottom bar, with a per-session override in Session Options, Git. A fetch only moves remote-tracking refs (HEAD, the index and the working tree stay as they are), it never runs for a Docker or remote session, and it is bounded in time, output and concurrency. + + **Start a session beside any tab (#712, opt-in).** Turn on **New Session Button on Tabs** (App Settings, Tabs) and each session tab gets a +: pick any CLI its case can run (a Docker case offers what its container has) and the new session starts in that case or worktree and that tab's group, without touching the toolbar's case or Run mode. The row menu offers the same as "New session here…" for the keyboard. + + **The phone keyboard no longer costs Claude its transcript (#711).** Every keyboard open and close used to resize the PTY, and Claude Code answers each height change by reprinting only the tail that fits, so lines above it were lost from tmux for good (25 of 100 in five keyboard cycles). A local Claude session now keeps its rows while the keyboard is up and slides the screen so the composer stays above the keyboard. On by default as **Keep Size Under Keyboard** (App Settings, per device); off restores the old resize. Fullscreen Claude (mouse tracking on) still resizes as before. + + **Typing stays smooth in a long prompt (#713).** The local echo overlay (the instant preview of what you type) rebuilt every character of the prompt on each keystroke, so on a phone-class CPU a 4,000-character prompt took most of a second per key. It now rebuilds only the line that changed, with one paint layer per line, so a keystroke costs the same however long the prompt is. Every character still sits on its terminal cell. This ships in `xterm-zerolag-input` too. + + **Links survive wrapping and spaces (#686, #687).** A URL or path a CLI wrapped inside a table cell or an indented paragraph now links whole instead of in fragments, URLs keep balanced parentheses (`…/Team-Space(QA)Archive/…`), a full-width table divider no longer glues onto the row below, and OSC 8 hyperlinks open through Codeman's own opener after a confirmation. A file path with spaces (`/mnt/c/OneDrive - Contoso/report.docx`, `Saved to: ~/My Docs/plan.md`) links as one path in the terminal and the response viewer when the line bounds it, and a shell prompt that prints its working directory no longer turns the whole command line into one link. + + **`codeman agent spawn` in a project directory (#708).** Without a case name, `spawn` starts the worker in the current directory, or in an existing directory given with `--working-dir`, through `POST /api/v1/sessions` and the mode's start route. Named cases still use quick-start. `--json` prints `{ sessionId, workingDir, created: true }` plus `ready`, `readinessError` or `startupError`. An empty `--working-dir`, a bare spawn from your home directory (it would install Codeman's hooks into `~/.claude`; pass `--working-dir ~` to mean it) and an unknown `--mode` are refused with exit 4 before any request. + + **Read saved Codex conversations offline (#707).** `codeman history read` reads Codex's saved rollouts without a Codeman server: by file, by Codex session id, or a whole conversation with its sub-agents and forks, filtered by topic or UTC date with whole turns kept. Reads and output have separate, raisable budgets, and anything it could not read or attribute (a compressed `.jsonl.zst` rollout, a file still being written) is reported through `complete: false` instead of failing the lookup. The codeman skill routes to it. + + **Experimental: bounded inspection and guarded submission for agents (#694, opt-in).** `codeman agent inspect` reads bounded, cursor-paged evidence from another session (a local Codex session's attributed rollout records, otherwise a labelled pane snapshot), and `agent stage` / `agent submit` type one line into a verified local Codex composer and later send exactly one Enter against a hash-confirmed receipt. Off by default behind **Experimental agent coordination** (App Settings, Cross-session features); while off the three routes answer `403 FORBIDDEN` naming the setting. These routes and verbs are experimental under the versioning policy and may change in a minor or patch release. During the few milliseconds of a guarded submit, other input is held and written in order afterwards; a tagged input POST in that window answers `409 CONFLICT` so its sender retries, and an untagged one (the phone keys, the Stop button) is held and answered once written. + + **Cmd+K arrows switch the CLI both ways (#717).** After typing a case name in Cmd+K, plain Left and Right now both change the New session row's CLI whenever the caret is at either end of the query (only Right did before). Inside the query the arrows move the caret as usual. + + **Fixes.** + - **Security: a LAN install's acknowledgement no longer opens a public tunnel (#718).** `CODEMAN_ALLOW_UNAUTHENTICATED_NETWORK` (and `--allow-unauthenticated-network`), which the installer writes for every LAN install without a password, also let the Cloudflare tunnel start, so one click on the welcome screen's tunnel link published full terminal control to a public URL with no login and no confirm. It now covers the network bind only. An unauthenticated tunnel has its own opt-in, `CODEMAN_ALLOW_UNAUTHENTICATED_TUNNEL=1` or `codeman web --allow-unauthenticated-tunnel` (carried by `codeman web -d` and `codeman service install`), which the installer never writes; if you relied on the network variable to start the tunnel without a password, set one of those. With no password and no multi-user mode, the browser now asks before every tunnel enable, using the new `loginConfigured` field of `GET /api/tunnel/status`. Both acknowledgement variables and their flags are now listed as stable in the versioning policy. + - **settings.json can no longer be torn (#719).** A quick-start's `lastUsedCase` write was not awaited and not atomic, so it could overlap the next settings save and leave one write's bytes over the tail of the other, after which every reader saw `{}`. Every write to settings.json now goes through one serialized writer that writes a temp file and renames it into place, and a file that does not parse is refused with `409 CONFLICT` instead of being overwritten with only the writer's own keys. A case rename checks this before it ends any session. + - **A worker id is never lost (#709).** When `codeman agent spawn` created a worker but its readiness request failed, it exited without the new session id. It now prints the id (and `--json` carries it) and exits 1, so the caller can inspect or delete the worker. `codeman agent` and `codeman mcp` also name the error code (such as `ECONNREFUSED`) when a request fails with an empty message. + - **The DeepSeek transcript test passes on Node 24.22+ and 26.10+ (#710).** It pinned Node's old zstd decoder, which stopped at the first frame until nodejs/node#65865. + + **Fixes applied while landing.** The Git fetch interval field is no longer reset by a session update while you type in it, a session holding only half an override shows the app's value for the other half, reading a repository's `core.sshCommand` no longer doubles the fetch timeout, a `GIT_SSH` in the server's environment is honoured, and the Fetch button's disabled and busy styles no longer reach other icon buttons and stop pulsing under reduced motion (#714). A + launch keeps the toolbar on its own case, and pressing the + again closes its picker (#712). The fetch scheduler no longer logs the text of a malformed settings.json on every 15-second tick (the parser's message could quote an API key; it now says the file is not valid JSON, once), and a fetch is skipped while the Docker case list cannot be read, so it can never run inside a Docker workspace (#714). A case rename answers `409 CONFLICT` for a settings.json it cannot write before it ends any session (#719). The unauthenticated-tunnel warning no longer fires in multi-user mode, where the tunnel has a login in front of it (#718). Untagged input during a guarded submit is held instead of refused, and the skill's staging guide says so (#694). A shell prompt line (a spaced working directory followed by `$` included) and a labelled `../` spaced path link as they should, and a bold label or inline code before a path in the response viewer splits it as the terminal does (#687); a URL whose row soft-wraps no longer glues a trailing column onto the next path, and in a table a link in the column right of a wrapped URL stays clickable on the URL's first row (#686). The keyboard hold reads the terminal's height only while rows are held (#711). The README, the plugin's README and its marketplace description now say the codeman skill reads saved Codex conversations outside a Codeman session and refuses everything else there (#707). + + **Docs and tests.** New sections for the keyboard hold (next to the terminal geometry rule it is an exception to), the tab +, Git fetch, the settings writer, the tunnel opt-in, the directory spawn, the offline history reader and the experimental agent routes, in CLAUDE.md, the API reference, the architecture notes, the wiki and the codeman skill. + ## 1.45.0 ### Minor Changes diff --git a/CLAUDE.md b/CLAUDE.md index d17f63b49..d0766a670 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -52,7 +52,7 @@ Uses [Semantic Versioning](https://semver.org/) (`MAJOR.MINOR.PATCH`) via `@chan When the user says **COM** (`COM` = patch, `COM minor`, `COM major`): **Read [`docs/releasing.md`](docs/releasing.md) and follow every step in order**; never improvise a release from memory. It covers the changeset, `npm run version-packages`, the `**Version**` line below, the branch check and explicit-path commit, deploy, the getcodeman.com badge, watching BOTH CI workflows, the MCP registry publish, and the Discussions announcement. -**Version**: 1.45.0 (must match `package.json`) +**Version**: 1.46.0 (must match `package.json`) ## Project Overview diff --git a/config/mcp-registry/server.json b/config/mcp-registry/server.json index 5ce1fc247..4f3e23ce0 100644 --- a/config/mcp-registry/server.json +++ b/config/mcp-registry/server.json @@ -3,7 +3,7 @@ "name": "io.github.Ark0N/codeman", "title": "Codeman", "description": "Start, prompt and read AI coding agent sessions (Claude Code, Codex, Gemini CLI and more) in Codeman", - "version": "1.45.0", + "version": "1.46.0", "websiteUrl": "https://getcodeman.com", "repository": { "url": "https://github.com/Ark0N/Codeman", @@ -13,7 +13,7 @@ { "registryType": "npm", "identifier": "aicodeman", - "version": "1.45.0", + "version": "1.46.0", "transport": { "type": "stdio" }, diff --git a/package-lock.json b/package-lock.json index 89c76245f..a2f7b9bba 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "aicodeman", - "version": "1.45.0", + "version": "1.46.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "aicodeman", - "version": "1.45.0", + "version": "1.46.0", "hasInstallScript": true, "license": "MIT", "workspaces": [ @@ -13389,7 +13389,7 @@ } }, "packages/xterm-zerolag-input": { - "version": "0.4.1", + "version": "0.4.2", "license": "MIT", "devDependencies": { "@xterm/headless": "^6.0.0", diff --git a/package.json b/package.json index ec697e969..372f857ee 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "aicodeman", - "version": "1.45.0", + "version": "1.46.0", "description": "Self-hosted mission control for AI coding agents: run Claude Code, Codex, OpenCode, Gemini, DeepSeek, Grok and more 24/7 in tmux, from any device.", "mcpName": "io.github.Ark0N/codeman", "type": "module", diff --git a/packages/xterm-zerolag-input/CHANGELOG.md b/packages/xterm-zerolag-input/CHANGELOG.md index 016f936fd..627449b94 100644 --- a/packages/xterm-zerolag-input/CHANGELOG.md +++ b/packages/xterm-zerolag-input/CHANGELOG.md @@ -1,5 +1,43 @@ # xterm-zerolag-input +## 0.4.2 + +### Patch Changes + +- de1aab9: ### Thanks + - @JDProfresh for five PRs: a phone keyboard that no longer costs Claude its transcript (#711, measured in #672), a + on each session tab that starts a session beside it (#712, shaped in Discussion #700), a tunnel that needs its own opt-in instead of the LAN one (#718, from their report #706), a settings.json that two writers can no longer tear (#719), and a DeepSeek test that passes on current Node (#710). + - @aakhter for six PRs: `codeman agent spawn` in a project directory (#708), worker ids kept when the readiness check fails (#709), the offline Codex history reader (#707), experimental bounded inspection and guarded submission for agents (#694, from Discussion #695), links that follow URLs and paths across wrapped rows and table cells (#686), and file paths with spaces linked as one path (#687). + - @opticon454 for the Fetch button and fetch interval in the Git status window (#714). + - @johoja12 for Cmd+K arrows that switch the CLI both ways after you type a case name (#717). + + **Fetch from the Git status window (#714).** The Git status window has a Fetch button next to Refresh. It runs `git fetch` for the repositories it lists and shows each one's result. An optional interval runs it for you: off by default, app-wide in App Settings, Bottom bar, with a per-session override in Session Options, Git. A fetch only moves remote-tracking refs (HEAD, the index and the working tree stay as they are), it never runs for a Docker or remote session, and it is bounded in time, output and concurrency. + + **Start a session beside any tab (#712, opt-in).** Turn on **New Session Button on Tabs** (App Settings, Tabs) and each session tab gets a +: pick any CLI its case can run (a Docker case offers what its container has) and the new session starts in that case or worktree and that tab's group, without touching the toolbar's case or Run mode. The row menu offers the same as "New session here…" for the keyboard. + + **The phone keyboard no longer costs Claude its transcript (#711).** Every keyboard open and close used to resize the PTY, and Claude Code answers each height change by reprinting only the tail that fits, so lines above it were lost from tmux for good (25 of 100 in five keyboard cycles). A local Claude session now keeps its rows while the keyboard is up and slides the screen so the composer stays above the keyboard. On by default as **Keep Size Under Keyboard** (App Settings, per device); off restores the old resize. Fullscreen Claude (mouse tracking on) still resizes as before. + + **Typing stays smooth in a long prompt (#713).** The local echo overlay (the instant preview of what you type) rebuilt every character of the prompt on each keystroke, so on a phone-class CPU a 4,000-character prompt took most of a second per key. It now rebuilds only the line that changed, with one paint layer per line, so a keystroke costs the same however long the prompt is. Every character still sits on its terminal cell. This ships in `xterm-zerolag-input` too. + + **Links survive wrapping and spaces (#686, #687).** A URL or path a CLI wrapped inside a table cell or an indented paragraph now links whole instead of in fragments, URLs keep balanced parentheses (`…/Team-Space(QA)Archive/…`), a full-width table divider no longer glues onto the row below, and OSC 8 hyperlinks open through Codeman's own opener after a confirmation. A file path with spaces (`/mnt/c/OneDrive - Contoso/report.docx`, `Saved to: ~/My Docs/plan.md`) links as one path in the terminal and the response viewer when the line bounds it, and a shell prompt that prints its working directory no longer turns the whole command line into one link. + + **`codeman agent spawn` in a project directory (#708).** Without a case name, `spawn` starts the worker in the current directory, or in an existing directory given with `--working-dir`, through `POST /api/v1/sessions` and the mode's start route. Named cases still use quick-start. `--json` prints `{ sessionId, workingDir, created: true }` plus `ready`, `readinessError` or `startupError`. An empty `--working-dir`, a bare spawn from your home directory (it would install Codeman's hooks into `~/.claude`; pass `--working-dir ~` to mean it) and an unknown `--mode` are refused with exit 4 before any request. + + **Read saved Codex conversations offline (#707).** `codeman history read` reads Codex's saved rollouts without a Codeman server: by file, by Codex session id, or a whole conversation with its sub-agents and forks, filtered by topic or UTC date with whole turns kept. Reads and output have separate, raisable budgets, and anything it could not read or attribute (a compressed `.jsonl.zst` rollout, a file still being written) is reported through `complete: false` instead of failing the lookup. The codeman skill routes to it. + + **Experimental: bounded inspection and guarded submission for agents (#694, opt-in).** `codeman agent inspect` reads bounded, cursor-paged evidence from another session (a local Codex session's attributed rollout records, otherwise a labelled pane snapshot), and `agent stage` / `agent submit` type one line into a verified local Codex composer and later send exactly one Enter against a hash-confirmed receipt. Off by default behind **Experimental agent coordination** (App Settings, Cross-session features); while off the three routes answer `403 FORBIDDEN` naming the setting. These routes and verbs are experimental under the versioning policy and may change in a minor or patch release. During the few milliseconds of a guarded submit, other input is held and written in order afterwards; a tagged input POST in that window answers `409 CONFLICT` so its sender retries, and an untagged one (the phone keys, the Stop button) is held and answered once written. + + **Cmd+K arrows switch the CLI both ways (#717).** After typing a case name in Cmd+K, plain Left and Right now both change the New session row's CLI whenever the caret is at either end of the query (only Right did before). Inside the query the arrows move the caret as usual. + + **Fixes.** + - **Security: a LAN install's acknowledgement no longer opens a public tunnel (#718).** `CODEMAN_ALLOW_UNAUTHENTICATED_NETWORK` (and `--allow-unauthenticated-network`), which the installer writes for every LAN install without a password, also let the Cloudflare tunnel start, so one click on the welcome screen's tunnel link published full terminal control to a public URL with no login and no confirm. It now covers the network bind only. An unauthenticated tunnel has its own opt-in, `CODEMAN_ALLOW_UNAUTHENTICATED_TUNNEL=1` or `codeman web --allow-unauthenticated-tunnel` (carried by `codeman web -d` and `codeman service install`), which the installer never writes; if you relied on the network variable to start the tunnel without a password, set one of those. With no password and no multi-user mode, the browser now asks before every tunnel enable, using the new `loginConfigured` field of `GET /api/tunnel/status`. Both acknowledgement variables and their flags are now listed as stable in the versioning policy. + - **settings.json can no longer be torn (#719).** A quick-start's `lastUsedCase` write was not awaited and not atomic, so it could overlap the next settings save and leave one write's bytes over the tail of the other, after which every reader saw `{}`. Every write to settings.json now goes through one serialized writer that writes a temp file and renames it into place, and a file that does not parse is refused with `409 CONFLICT` instead of being overwritten with only the writer's own keys. A case rename checks this before it ends any session. + - **A worker id is never lost (#709).** When `codeman agent spawn` created a worker but its readiness request failed, it exited without the new session id. It now prints the id (and `--json` carries it) and exits 1, so the caller can inspect or delete the worker. `codeman agent` and `codeman mcp` also name the error code (such as `ECONNREFUSED`) when a request fails with an empty message. + - **The DeepSeek transcript test passes on Node 24.22+ and 26.10+ (#710).** It pinned Node's old zstd decoder, which stopped at the first frame until nodejs/node#65865. + + **Fixes applied while landing.** The Git fetch interval field is no longer reset by a session update while you type in it, a session holding only half an override shows the app's value for the other half, reading a repository's `core.sshCommand` no longer doubles the fetch timeout, a `GIT_SSH` in the server's environment is honoured, and the Fetch button's disabled and busy styles no longer reach other icon buttons and stop pulsing under reduced motion (#714). A + launch keeps the toolbar on its own case, and pressing the + again closes its picker (#712). The fetch scheduler no longer logs the text of a malformed settings.json on every 15-second tick (the parser's message could quote an API key; it now says the file is not valid JSON, once), and a fetch is skipped while the Docker case list cannot be read, so it can never run inside a Docker workspace (#714). A case rename answers `409 CONFLICT` for a settings.json it cannot write before it ends any session (#719). The unauthenticated-tunnel warning no longer fires in multi-user mode, where the tunnel has a login in front of it (#718). Untagged input during a guarded submit is held instead of refused, and the skill's staging guide says so (#694). A shell prompt line (a spaced working directory followed by `$` included) and a labelled `../` spaced path link as they should, and a bold label or inline code before a path in the response viewer splits it as the terminal does (#687); a URL whose row soft-wraps no longer glues a trailing column onto the next path, and in a table a link in the column right of a wrapped URL stays clickable on the URL's first row (#686). The keyboard hold reads the terminal's height only while rows are held (#711). The README, the plugin's README and its marketplace description now say the codeman skill reads saved Codex conversations outside a Codeman session and refuses everything else there (#707). + + **Docs and tests.** New sections for the keyboard hold (next to the terminal geometry rule it is an exception to), the tab +, Git fetch, the settings writer, the tunnel opt-in, the directory spawn, the offline history reader and the experimental agent routes, in CLAUDE.md, the API reference, the architecture notes, the wiki and the codeman skill. + ## 0.4.1 ### Patch Changes diff --git a/packages/xterm-zerolag-input/package.json b/packages/xterm-zerolag-input/package.json index 955a1be8b..ac77f48e5 100644 --- a/packages/xterm-zerolag-input/package.json +++ b/packages/xterm-zerolag-input/package.json @@ -1,6 +1,6 @@ { "name": "xterm-zerolag-input", - "version": "0.4.1", + "version": "0.4.2", "description": "Instant keystroke feedback overlay for xterm.js: Mosh-inspired local echo that removes perceived input latency over SSH, tunnels and other high-RTT connections", "type": "module", "main": "dist/index.cjs", diff --git a/plugins/codeman/.claude-plugin/plugin.json b/plugins/codeman/.claude-plugin/plugin.json index d6c0fc838..d300e12ca 100644 --- a/plugins/codeman/.claude-plugin/plugin.json +++ b/plugins/codeman/.claude-plugin/plugin.json @@ -1,7 +1,7 @@ { "name": "codeman", "description": "Drive Codeman, the self-hosted session manager for AI coding agents, from inside a Claude Code session: spawn worker sessions, prompt them, wait for them, read their answers, clean up. Acts inside a Codeman-managed session; outside one it only reads saved Codex conversations offline.", - "version": "1.45.0", + "version": "1.46.0", "author": { "name": "Ark0N", "url": "https://github.com/Ark0N" diff --git a/src/web/public/whats-new.json b/src/web/public/whats-new.json index a38515afa..925887ee0 100644 --- a/src/web/public/whats-new.json +++ b/src/web/public/whats-new.json @@ -1,13 +1,13 @@ { - "version": "1.45.0", + "version": "1.46.0", "highlights": [ - "Rename a case and its folder (#680)", - "A per-session agent inbox (#690)", - "Pick the agent from Cmd+K (#685)", - "A resizable header toolbar (#689)", - "GPU and CPU clock readings (#692, opt-in)", - "A tunnel no longer locks everyone out after ten typos (#684, multi-user mode)", - "The first wheel notch moves a Codex transcript (#699)", - "The wheel no longer moves a Codex menu's selection (#698)" + "Fetch from the Git status window (#714)", + "Start a session beside any tab (#712, opt-in)", + "The phone keyboard no longer costs Claude its transcript (#711)", + "Typing stays smooth in a long prompt (#713)", + "Links survive wrapping and spaces (#686, #687)", + "`codeman agent spawn` in a project directory (#708)", + "Read saved Codex conversations offline (#707)", + "Experimental: bounded inspection and guarded submission for agents (#694, opt-in)" ] } From dd09a383f191d68c8a702c1f48593b162667b3a3 Mon Sep 17 00:00:00 2001 From: DDT <141296842+Hugo-DDT@users.noreply.github.com> Date: Tue, 27 Oct 2026 12:06:45 +0000 Subject: [PATCH 68/68] fix: stop desktop dead-key doubling and bracket Ctrl+V paste KeyCode 229 recovery forwarded a composed character that xterm had already delivered, so a desktop dead-key backtick arrived twice. create() now attaches only when the primary pointer is coarse. Ctrl+V text paste went through terminal.paste(), which drops the CSI 200~/201~ markers after a replay clears xterm's DECSET 2004 mirror. Plain text is now a hand-built bracketed frame on the session input path. Fixes #716 Co-authored-by: DDT <1786035110@qq.com> --- docs/architecture-invariants.md | 2 +- src/web/CLAUDE.md | 4 +- src/web/public/image-input.js | 60 +++++++++-- .../public/terminal-keycode229-recovery.js | 20 ++++ src/web/public/terminal-tile.js | 7 +- src/web/public/terminal-ui.js | 1 + test/image-paste-trap.test.ts | 102 ++++++++++++++++-- ...rminal-keycode229-recovery.browser.test.ts | 35 ++++++ test/terminal-keycode229-recovery.test.ts | 46 +++++++- test/terminal-tile-input.test.ts | 19 ++++ 10 files changed, 274 insertions(+), 22 deletions(-) diff --git a/docs/architecture-invariants.md b/docs/architecture-invariants.md index d4484540f..d8e4c1bc5 100644 --- a/docs/architecture-invariants.md +++ b/docs/architecture-invariants.md @@ -817,7 +817,7 @@ Feedback is silent on success except ONCE per page load (a feature that works by ### Terminal paste (Ctrl+V) -**The Ctrl+V paste trap consumes exactly ONE paste event.** `_handleImagePaste()` (image-input.js) appends a hidden `contenteditable` div, focuses it, and reads the clipboard out of the paste event that lands there. An image becomes an upload whose saved path is typed into the session; text goes through `terminal.paste()`, so the bracketed-paste markers survive. Two independent routes deliver that event for one keypress, and a browser may fire both: +**The Ctrl+V paste trap consumes exactly ONE paste event.** `_handleImagePaste()` (image-input.js) appends a hidden `contenteditable` div, focuses it, and reads the clipboard out of the paste event that lands there. An image becomes an upload whose saved path is typed into the session; plain text is a hand-built bracketed paste (`\x1b[200~` … `\x1b[201~`, nested markers stripped, newlines as `\r`) on the session input path (`_deliverBracketedPaste`). `terminal.paste()` only emits those markers while xterm's DECSET 2004 mirror is on, and a terminal replay clears that mirror (#716), so Ctrl+V builds the frame itself, the same way the mobile composer does. When the local-echo overlay owns that session's prompt, the unsent prefix is flushed first and the frame follows on a delay, which is the onData bracketed-paste ordering (#219). Two independent routes deliver that event for one keypress, and a browser may fire both: 1. **`document.execCommand('paste')`**, which the function calls itself. ⚠️ Its return value proves nothing about whether it fired. Firefox dispatches a trusted `paste` event carrying the real clipboard and still returns `false`, because the trap cancels the event and the command therefore never completes. Chromium refuses the command outright and dispatches nothing. 2. **The keydown's own default action.** The `Ctrl+V` branch in `attachCustomKeyEventHandler` returns `false`, which stops xterm from evaluating the key into `^V` but does not cancel the DOM event, for the same reason rule 1 of smart copy above spells out. `trap.focus()` has already run by then, so the browser sends its own paste to the trap as well. diff --git a/src/web/CLAUDE.md b/src/web/CLAUDE.md index e2c9cd1ed..c6090e222 100644 --- a/src/web/CLAUDE.md +++ b/src/web/CLAUDE.md @@ -20,7 +20,7 @@ **OSC 52 clipboard** (`_installOsc52Clipboard`, terminal-ui.js; main terminal + every `TerminalTile`): a program in the PTY (Claude Code's `/copy`, tmux yanks, vim) sets the browser clipboard with `ESC ] 52`. ⚠️ Writes only: a `52;?` read query is always refused, never answered. ⚠️ A replay must never copy: `chunkedTerminalWrite` strips every OSC 52 (`OSC52_STRIP_RE`, constants.js) because xterm parses after `_isLoadingBuffer` is cleared, so the flag alone does not hold; a tile gates on its own `_bufferLoading`, which does. ⚠️ One copy arrives up to three times (Claude's own OSC 52, `tmux load-buffer -w`, set-clipboard forwarding), so identical text within `OSC52_DEDUPE_MS` is dropped. ⚠️ Every tab on the session receives it; only the focused one opens the manual-copy fallback. ⚠️ Restore prior focus after `_copyText` (its execCommand fallback focuses a temp textarea). Tests: `test/terminal-osc52-clipboard.test.ts` (replays drive the real `chunkedTerminalWrite` over `@xterm/headless`). -**Ctrl+V paste trap** (`image-input.js`): `Ctrl+V` routes through `_handleImagePaste()`, which focuses a hidden `contenteditable` trap and reads the clipboard from the paste event landing there; images upload and their paths are typed in, text goes through `terminal.paste()` so bracketed-paste markers survive. ⚠️ **The trap must consume exactly ONE paste event** (Firefox delivers two per keypress: the `execCommand('paste')` event and the keydown's default action); the one-shot flag lives on the trap, never on a browser check. ⚠️ Do not remove the `execCommand('paste')` call: on some mobile engines it is the only route into the trap, and the trap is the only place image blobs are read. Tests: `test/image-paste-trap.test.ts`. → [architecture-invariants#terminal-paste-ctrlv](../../docs/architecture-invariants.md#terminal-paste-ctrlv) +**Ctrl+V paste trap** (`image-input.js`): `Ctrl+V` routes through `_handleImagePaste()`, which focuses a hidden `contenteditable` trap and reads the clipboard from the paste event landing there; images upload and their paths are typed in, plain text is a hand-built bracketed paste on the session input path (`_deliverBracketedPaste`: 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). When local echo owns the prompt, flush the unsent prefix first and delay the frame (#219). ⚠️ **The trap must consume exactly ONE paste event** (Firefox delivers two per keypress: the `execCommand('paste')` event and the keydown's default action); the one-shot flag lives on the trap, never on a browser check. ⚠️ Do not remove the `execCommand('paste')` call: on some mobile engines it is the only route into the trap, and the trap is the only place image blobs are read. Tests: `test/image-paste-trap.test.ts`. → [architecture-invariants#terminal-paste-ctrlv](../../docs/architecture-invariants.md#terminal-paste-ctrlv) **File Viewer text view: rendered markdown + Lines/Wrap toggles** (`_renderFilePreviewText()` in panels-ui.js): a `.md`/`.markdown` opens RENDERED by default with an `MD` pill back to source; the plain-text view has `Lines` (CSS-counter gutter) and `Wrap` toggles. ⚠️ ONE markdown pipeline: the viewer calls `_renderMarkdown(text, { breaks: false })` (marked + the DOMPurify allowlist, the Response Viewer's; chat keeps the default `breaks: true`, a file must not turn every hard wrap into a `<br>`) and binds the Response Viewer's click delegate (`_bindResponseViewerInteractions`) on the preview body for code-copy buttons and path links; never a second parser or handler. ⚠️ The document is built inside a `<template>` (a detached div with `innerHTML` set starts fetching every `<img src>` before the rewrite), then `_rebaseFilePreviewMarkdownRefs()` points relative images at the workspace-confined `file-raw` under the document's directory and root-relative ones under the workspace root (never a widened route; a failed load degrades to alt text), after `decodeURIComponent`ing the ref and dropping `?query`/`#fragment` (marked percent-encodes destinations, and the route encodes again), and turns workspace links into `a.rv-path` carrying `data-session-id` for the delegate (the linkifier's absolute paths get it too), stripping the `target` marked gave them. ⚠️ A preview opened by attachment id under a bare file name (attachment cards, history drawer: the registry keeps no relative path) has no directory, so its workspace refs degrade (images to alt text, links to their text), never resolve against the workspace root. ⚠️ The container carries `data-i18n-skip` or the translator rewrites the document's prose. ⚠️ Toggles are per-device localStorage keys (`codeman:filePreview*`), never `SettingsUpdateSchema`; Lines/Wrap are class flips on the ONE `<pre>`, with rules scoped `.file-preview-body > pre.file-preview-text` so they never leak into the document's code blocks. Markdown fetches `lines=10000` (the route ceiling), other text keeps 500. ⚠️ `md`/`markdown` are IN `FILE_PREVIEW_EXTENSIONS` (owner decision 2026-10-13): a markdown path clicked or tapped in the TERMINAL (main pane, grid tiles and split Pane B share `registerFilePathLinkProvider`) opens this rendered view like every other surface, never the streaming log viewer; other in-workspace text keeps the tail viewer (live follow). The terminal pattern links `.markdown` and `.htm` too. Tests: `test/file-preview-markdown.test.ts`. → [architecture-invariants#file-viewer-text-view-rendered-markdown-and-text-toggles](../../docs/architecture-invariants.md#file-viewer-text-view-rendered-markdown-and-text-toggles) @@ -30,7 +30,7 @@ ## Frontend modules and UI subsystems -Frontend JS modules have `@fileoverview` with `@dependency`/`@loadorder` tags. Load order is the `<script>` order in `index.html` (each module's `@loadorder` tag mirrors it). `i18n.js` translates static + newly inserted application DOM while skipping terminal/response/file/user-name surfaces; `input-cjk.js` handles CJK IME composition via an always-visible textarea below the terminal (`window.cjkActive` blocks xterm's onData). `terminal-keycode229-recovery.js` forwards a committed `input` event that xterm's `_inputEvent` guard drops (Chrome-on-Android soft keyboards send `composed: true` after a keydown), and only when xterm emitted no canonical data for that keystroke. ⚠️ **That decision is settled at the NEXT keydown as well as on its own zero-delay timer** (#441): the drain runs from xterm's custom key handler, which fires BEFORE xterm processes that key, so a soft keyboard that commits the last character and sends Enter in one InputConnection transaction puts the character on the wire ahead of the `\r`. On the timer alone that character is not merely late, it is LOST: xterm emits the `\r` first and bumps the canonical counter past the candidate's snapshot, so the candidate stands down (measured, `hell\r` where the user typed `hello`). The edit-based diff described below is settled first at that keydown, so the drain decides with the evidence the timer had and Enter's textarea clear cannot turn the pending line into DELs. Ordering is pinned by `test/terminal-keycode229-recovery.browser.test.ts`, which the CI gate does NOT run. The same module replaces xterm's `_handleAnyTextareaChanges` (an append-only `newValue.replace(oldValue, '')` diff) with an edit-based one, so an Android autocorrect on space (delete + insert) reaches the PTY once instead of duplicating the line; ⚠️ that diff is settled at the NEXT keydown, before xterm handles that key, because xterm clears the textarea for Enter and a pending diff would then send one DEL per character ahead of the submitted line (except a composition xterm finalizes synchronously at that key, which stays xterm's). ⚠️ Every xterm that takes keyboard input wires its OWN controller from this module: the primary pane (terminal-ui.js `initTerminal()`) and each `TerminalTile` (terminal-tile.js `_createKeyCode229Recovery()`, so every grid tile and the split's Pane B, which a wide Android tablet reaches), each on its own textarea, composition helper and session; in both, `handleKeyEvent` must run ABOVE the custom key handler's keyCode-229 early return, and `notifyCanonicalData` sits in the onData lambda, never in the send path the recovered bytes also take. `mobile-ime-preview.js` (iOS WebKit only) paints the text an IME is composing: an iOS IME commit is routed into the local-echo overlay through the ordinary printable/paste branch and then `_transferMobileImeCommitToLocalEcho`, and without local echo the preview clears only on output parsed AFTER the commit (or its 2 s fallback). ⚠️ It watches keydown in the capture phase on `terminal.element`, never on the textarea, because xterm finalizes the composition and emits the commit in its own capture listener on the textarea. +Frontend JS modules have `@fileoverview` with `@dependency`/`@loadorder` tags. Load order is the `<script>` order in `index.html` (each module's `@loadorder` tag mirrors it). `i18n.js` translates static + newly inserted application DOM while skipping terminal/response/file/user-name surfaces; `input-cjk.js` handles CJK IME composition via an always-visible textarea below the terminal (`window.cjkActive` blocks xterm's onData). `terminal-keycode229-recovery.js` forwards a committed `input` event that xterm's `_inputEvent` guard drops (Chrome-on-Android soft keyboards send `composed: true` after a keydown), and only when xterm emitted no canonical data for that keystroke. ⚠️ **That decision is settled at the NEXT keydown as well as on its own zero-delay timer** (#441): the drain runs from xterm's custom key handler, which fires BEFORE xterm processes that key, so a soft keyboard that commits the last character and sends Enter in one InputConnection transaction puts the character on the wire ahead of the `\r`. On the timer alone that character is not merely late, it is LOST: xterm emits the `\r` first and bumps the canonical counter past the candidate's snapshot, so the candidate stands down (measured, `hell\r` where the user typed `hello`). The edit-based diff described below is settled first at that keydown, so the drain decides with the evidence the timer had and Enter's textarea clear cannot turn the pending line into DELs. Ordering is pinned by `test/terminal-keycode229-recovery.browser.test.ts`, which the CI gate does NOT run. The same module replaces xterm's `_handleAnyTextareaChanges` (an append-only `newValue.replace(oldValue, '')` diff) with an edit-based one, so an Android autocorrect on space (delete + insert) reaches the PTY once instead of duplicating the line; ⚠️ that diff is settled at the NEXT keydown, before xterm handles that key, because xterm clears the textarea for Enter and a pending diff would then send one DEL per character ahead of the submitted line (except a composition xterm finalizes synchronously at that key, which stays xterm's). ⚠️ Every xterm that takes keyboard input wires its OWN controller from this module (`create()` returns null unless `matchMedia('(pointer: coarse)')` matches, so a desktop dead key is not forwarded twice, #716): the primary pane (terminal-ui.js `initTerminal()`) and each `TerminalTile` (terminal-tile.js `_createKeyCode229Recovery()`, so every grid tile and the split's Pane B, which a wide Android tablet reaches), each on its own textarea, composition helper and session; in both, `handleKeyEvent` must run ABOVE the custom key handler's keyCode-229 early return, and `notifyCanonicalData` sits in the onData lambda, never in the send path the recovered bytes also take. `mobile-ime-preview.js` (iOS WebKit only) paints the text an IME is composing: an iOS IME commit is routed into the local-echo overlay through the ordinary printable/paste branch and then `_transferMobileImeCommitToLocalEcho`, and without local echo the preview clears only on output parsed AFTER the commit (or its 2 s fallback). ⚠️ It watches keydown in the capture phase on `terminal.element`, never on the textarea, because xterm finalizes the composition and emits the commit in its own capture listener on the textarea. **Entrance animations** (`entrance-animations.js`, all OFF by default): opt-in animations for tabs, terminal, windows, connection lines and tile-grid tiles, chosen via `data-tab-anim` / `data-term-anim` / `data-win-anim` / `data-line-anim` / `data-tile-anim` on `<html>`; the default `legacy` theme short-circuits every hook (tiles keep the grid's own `settle`). ⚠️ Tabs and lines are destroyed mid-animation on re-render, so re-apply to the fresh element by id with a negative `animation-delay` (resume, never restart). ⚠️ Terminal-pane styles may animate only transform / opacity / clip-path (anything else resizes the PTY via FitAddon); `blur` is the ONE sanctioned `filter` exception, do not generalise it. ⚠️ Line glow lives in `--line-glow` so blur keyframes interpolate. Every animation setting lives in App Settings → **Animations** (`#settings-animations`); put new ones there. ⚠️ Tiles are OFF by default (Animations → Tile Animations, default `settle` = the grid's own fade, no screen beat); a theme only presets it. A tile's FRAME animates transform/opacity only (six at once, no filter) with `tile-enter*`/`tile-leave*` keyframe names (what the mount and the still copy listen for); its SCREEN replays the pane style on `.tile-body.term-enter`; a reload's restore always settles. Persisted per-device in `codeman:*Anim` localStorage keys, never in `SettingsUpdateSchema`; lab at `?animlab=1`. Test: `test/entrance-animations.test.ts`. → [architecture-invariants#entrance-animations](../../docs/architecture-invariants.md#entrance-animations) diff --git a/src/web/public/image-input.js b/src/web/public/image-input.js index a0ec33c32..d2d5fd603 100644 --- a/src/web/public/image-input.js +++ b/src/web/public/image-input.js @@ -244,14 +244,14 @@ Object.assign(CodemanApp.prototype, { if (imageFiles.length > 0) { self._uploadAndInsertImages(imageFiles, { sessionId: sessionId }); - } else { - // No image -- route text through xterm's paste() so bracketed-paste - // markers (CSI 200~ ... CSI 201~) survive when the inner application - // has enabled bracketed-paste mode (Claude Code does). Sending text - // via raw sendInput() strips those markers and makes pasted input - // indistinguishable from typed input, weakening the CLI's - // prompt-injection defenses. - if (text && terminal) terminal.paste(text); + } else if (text) { + // No image. xterm's paste() emits CSI 200~/201~ only while its DECSET + // 2004 mirror is on, and a terminal replay clears that mirror, so the + // markers never arrive and a copied newline submits (#716). Build the + // frame here, the same bytes keyboard-accessory.js sends, and deliver + // it on the session input path. Do not useMux: the mux fallback strips + // every \r, which is the line separator inside the frame. + self._deliverBracketedPaste(sessionId, text); } }); @@ -260,6 +260,50 @@ Object.assign(CodemanApp.prototype, { document.execCommand('paste'); }, + /** + * Plain-text Ctrl+V as one bracketed paste on the session-bound input path. + * Mirrors keyboard-accessory.js `_sendBracketedPaste`: drop nested 200/201 + * markers (a clipboard carrying ESC[201~ would end the bracket early), map + * newlines to CR (what xterm's prepareTextForTerminal did), wrap with + * CSI 200~ / CSI 201~. The composer's length cap is not applied here; + * `_sendInputAsync` already splits a large paste and refuses one past its + * own ceiling. + * + * When the local-echo overlay owns this session's prompt, flush its unsent + * text first and delay the frame. That is the onData bracketed-paste branch + * (#219): Codex drops bytes that share a PTY read with a bracketed paste, + * and the typed prefix has to land ahead of the block. + */ + _deliverBracketedPaste(sessionId, text) { + if (!sessionId || typeof text !== 'string' || text === '' || typeof this._sendInputAsync !== 'function') return; + try { + this._predictiveEcho?.clearPredictions?.(); + } catch { + // Predictions are visual only. + } + const pasteText = text.replace(/\x1b\[20[01]~/g, '').replace(/\r?\n/g, '\r'); + const framed = `\x1b[200~${pasteText}\x1b[201~`; + const echoOwns = + this.activeSessionId === sessionId && + this._localEchoEnabled && + this._localEchoOverlay && + !this._echoPassthroughSessions?.has?.(sessionId) && + !this._tilesOwnTerminal?.() && + typeof this._flushLocalEchoPending === 'function'; + if (echoOwns) { + const hadPending = !!this._localEchoOverlay.pendingText; + this._flushLocalEchoPending(); + if (hadPending) { + const prefix = this._pendingInput || ''; + this._pendingInput = ''; + if (prefix) this._sendInputAsync(sessionId, prefix); + setTimeout(() => this._sendInputAsync(sessionId, framed), 80); + return; + } + } + this._sendInputAsync(sessionId, framed); + }, + /** What a pick or a paste may upload: 'image', 'video' for the camera roll's * clips (the server streams those to disk under their own cap), 'document' * for the office and text files the server admits, else null. The one filter diff --git a/src/web/public/terminal-keycode229-recovery.js b/src/web/public/terminal-keycode229-recovery.js index 22e44cbac..43d84ddb9 100644 --- a/src/web/public/terminal-keycode229-recovery.js +++ b/src/web/public/terminal-keycode229-recovery.js @@ -7,6 +7,13 @@ * that guard, so xterm returns without emitting and the committed character is * silently dropped. * + * ⚠ Desktop fine pointers must not run this (#716). xterm already forwards the + * composed character, and a second forward doubles a dead-key backtick. + * `create()` returns null unless `matchMedia('(pointer: coarse)')` matches, so + * soft keyboards keep the recovery and a desktop does not attach it. A missing + * or throwing `matchMedia` stands down: a missed recovery is acceptable, a + * duplicated keystroke is not. + * * ⚠ The gap is NARROWER than "keyCode 229", and assuming otherwise produces a * controller that looks useful while doing nothing. For a keydown that really * does report `keyCode: 229`, xterm ALREADY self-rescues: `CompositionHelper @@ -64,7 +71,20 @@ return { deleted: a.length - prefix, inserted: b.slice(prefix).join('') }; } + /** + * Touch / coarse pointer only. See the file header: on a fine pointer this + * forward runs on top of xterm's own composition handling. + */ + function isCoarsePointer() { + try { + return global.matchMedia?.('(pointer: coarse)')?.matches === true; + } catch { + return false; + } + } + function create(options) { + if (!isCoarsePointer()) return null; const textarea = options?.textarea; const emitRecovered = options?.emitRecovered; if (!textarea?.addEventListener || !textarea?.removeEventListener || typeof emitRecovered !== 'function') { diff --git a/src/web/public/terminal-tile.js b/src/web/public/terminal-tile.js index e08e26f6c..ba24ec51d 100644 --- a/src/web/public/terminal-tile.js +++ b/src/web/public/terminal-tile.js @@ -646,9 +646,10 @@ // Recovered text goes straight to _onTerminalData(), never through xterm's // onData, so it is not counted as xterm's own (see connect()'s onData). // Created after terminal.open(): xterm's capture `input` listener on the - // textarea is registered there, and must run before the controller's. No - // device or mode gate, as in the primary pane: with a hardware keyboard it - // costs one assignment per keydown. A failure leaves xterm's own handling. + // textarea is registered there, and must run before the controller's. + // create() returns null on a fine pointer (#716): the recovery is for soft + // keyboards, and on a desktop it delivers a dead-key character twice. + // A failure leaves xterm's own handling. _createKeyCode229Recovery() { this._destroyKeyCode229Recovery(); if (!this.terminal) return; diff --git a/src/web/public/terminal-ui.js b/src/web/public/terminal-ui.js index deb704af4..0a37bc46e 100644 --- a/src/web/public/terminal-ui.js +++ b/src/web/public/terminal-ui.js @@ -2108,6 +2108,7 @@ Object.assign(CodemanApp.prototype, { // Twin: TerminalTile (terminal-tile.js _createKeyCode229Recovery and its // connect() key handler and onData) wires its own controller the same way // for every grid tile and the split's Pane B; keep the two in step. + // create() returns null unless the primary pointer is coarse (#716). try { this._keyCode229Recovery = window.CodemanKeyCode229Recovery?.create?.({ textarea: this.terminal.textarea, diff --git a/test/image-paste-trap.test.ts b/test/image-paste-trap.test.ts index 93370ae3e..238a0d4c2 100644 --- a/test/image-paste-trap.test.ts +++ b/test/image-paste-trap.test.ts @@ -33,11 +33,20 @@ interface FakeTrap { addEventListener: (ev: string, fn: TrapListener) => void; } +interface SentInput { + sessionId: string; + data: string; +} + interface Harness { /** Fire a paste event on the trap the last _handleImagePaste() call created. */ firePaste: (payload: { text?: string; images?: string[] }) => void; /** Text handed to xterm's terminal.paste(), one entry per call. */ pastedText: string[]; + /** Frames handed to _sendInputAsync(), in order. */ + sentInputs: SentInput[]; + /** How many times predictive-echo predictions were cleared. */ + predictionsCleared: () => number; /** Image batches handed to _uploadAndInsertImages(), one entry per call. */ uploadedBatches: Array<Array<{ type: string }>>; /** Messages handed to showToast(), one entry per call. */ @@ -47,7 +56,14 @@ interface Harness { runTimers: () => void; } -function loadPasteHarness(): Harness { +interface PasteHarnessOptions { + /** Session the paste trap is aimed at. Defaults to the active session. */ + sessionId?: string; + /** Unsent overlay text. Enables the local-echo flush path when set. */ + localEchoPending?: string; +} + +function loadPasteHarness(options: PasteHarnessOptions = {}): Harness { const traps: FakeTrap[] = []; const listeners: TrapListener[] = []; const attached = new Set<FakeTrap>(); @@ -99,20 +115,51 @@ function loadPasteHarness(): Harness { const CodemanApp = vm.runInContext('CodemanApp', context) as new () => Record<string, unknown>; const pastedText: string[] = []; + const sentInputs: SentInput[] = []; const uploadedBatches: Array<Array<{ type: string }>> = []; + let predictionsCleared = 0; const app = new CodemanApp(); app.activeSessionId = 'session-1'; + // Replay leaves xterm's DECSET 2004 mirror off. Text must still go out bracketed, + // and must not ask terminal.paste() to consult that mirror (#716). app.terminal = { paste: (text: string) => pastedText.push(text), focus: () => {}, + modes: { bracketedPasteMode: false }, }; + app._sendInputAsync = (sessionId: string, data: string) => { + sentInputs.push({ sessionId, data }); + }; + app._predictiveEcho = { clearPredictions: () => { predictionsCleared += 1; } }; app._uploadAndInsertImages = (files: Array<{ type: string }>) => { uploadedBatches.push(Array.from(files)); }; + if (options.localEchoPending !== undefined) { + app._localEchoEnabled = true; + app._pendingInput = ''; + app._localEchoOverlay = { + pendingText: options.localEchoPending, + clear() { + this.pendingText = ''; + }, + suppressBufferDetection() {}, + }; + app._flushedOffsets = new Map(); + app._flushedTexts = new Map(); + app._flushLocalEchoPending = function (this: Record<string, any>) { + const pending = this._localEchoOverlay?.pendingText || ''; + this._localEchoOverlay.clear(); + this._localEchoOverlay.suppressBufferDetection(); + this._flushedOffsets?.delete(this.activeSessionId); + this._flushedTexts?.delete(this.activeSessionId); + if (pending) this._pendingInput += pending; + }; + } const toasts: string[] = []; app.showToast = (message: string) => toasts.push(message); - (app._handleImagePaste as () => void).call(app); + const target = options.sessionId ? { terminal: app.terminal, sessionId: options.sessionId } : undefined; + (app._handleImagePaste as (t?: object) => void).call(app, target); return { firePaste({ text = '', images = [] }) { @@ -128,6 +175,8 @@ function loadPasteHarness(): Harness { for (const fn of listeners) fn(event); }, pastedText, + sentInputs, + predictionsCleared: () => predictionsCleared, uploadedBatches, toasts, attachedTraps: () => attached.size, @@ -154,12 +203,50 @@ function loadImageInputApp() { } describe('Ctrl+V paste trap', () => { - it('sends clipboard text to the terminal once for a single paste event', () => { + it('sends clipboard text once as a bracketed paste, not through xterm paste()', () => { const h = loadPasteHarness(); h.firePaste({ text: 'hello world' }); - expect(h.pastedText).toEqual(['hello world']); + // terminal.paste() would drop the markers: the harness mirror is off, which + // is what a replay leaves behind. + expect(h.pastedText).toEqual([]); + expect(h.sentInputs).toEqual([{ sessionId: 'session-1', data: '\x1b[200~hello world\x1b[201~' }]); + expect(h.predictionsCleared()).toBe(1); + }); + + it('brackets a multi-line paste and strips nested paste markers', () => { + const h = loadPasteHarness(); + + h.firePaste({ text: 'line one\nline two\r\n\x1b[200~nested\x1b[201~\n' }); + + expect(h.pastedText).toEqual([]); + expect(h.sentInputs).toEqual([ + { sessionId: 'session-1', data: '\x1b[200~line one\rline two\rnested\r\x1b[201~' }, + ]); + }); + + it('addresses the bracketed paste to the pane that received Ctrl+V', () => { + const h = loadPasteHarness({ sessionId: 'pane-b' }); + + h.firePaste({ text: 'into pane b' }); + + expect(h.sentInputs).toEqual([{ sessionId: 'pane-b', data: '\x1b[200~into pane b\x1b[201~' }]); + expect(h.pastedText).toEqual([]); + }); + + it('flushes unsent local-echo text ahead of the bracketed paste', () => { + const h = loadPasteHarness({ localEchoPending: 'hi' }); + + h.firePaste({ text: 'world\n' }); + + expect(h.sentInputs).toEqual([{ sessionId: 'session-1', data: 'hi' }]); + h.runTimers(); + expect(h.sentInputs).toEqual([ + { sessionId: 'session-1', data: 'hi' }, + { sessionId: 'session-1', data: '\x1b[200~world\r\x1b[201~' }, + ]); + expect(h.pastedText).toEqual([]); }); it('ignores a second paste event for the same Ctrl+V', () => { @@ -170,7 +257,8 @@ describe('Ctrl+V paste trap', () => { h.firePaste({ text: 'hello world' }); h.firePaste({ text: 'hello world' }); - expect(h.pastedText).toEqual(['hello world']); + expect(h.sentInputs).toEqual([{ sessionId: 'session-1', data: '\x1b[200~hello world\x1b[201~' }]); + expect(h.pastedText).toEqual([]); }); it('uploads a pasted image once when the trap sees two paste events', () => { @@ -182,6 +270,7 @@ describe('Ctrl+V paste trap', () => { expect(h.uploadedBatches).toHaveLength(1); expect(h.uploadedBatches[0]).toEqual([{ type: 'image/png' }]); expect(h.pastedText).toEqual([]); + expect(h.sentInputs).toEqual([]); }); it('a paste mixing an image with a refused file uploads the image and says what it skipped', () => { @@ -199,7 +288,8 @@ describe('Ctrl+V paste trap', () => { // Copying from a document can carry both: the text is what the user gets. h.firePaste({ text: 'echo hi', images: ['application/x-sh'] }); - expect(h.pastedText).toEqual(['echo hi']); + expect(h.sentInputs).toEqual([{ sessionId: 'session-1', data: '\x1b[200~echo hi\x1b[201~' }]); + expect(h.pastedText).toEqual([]); expect(h.uploadedBatches).toEqual([]); expect(h.toasts).toEqual([]); }); diff --git a/test/terminal-keycode229-recovery.browser.test.ts b/test/terminal-keycode229-recovery.browser.test.ts index c17186b10..e0883b651 100644 --- a/test/terminal-keycode229-recovery.browser.test.ts +++ b/test/terminal-keycode229-recovery.browser.test.ts @@ -40,6 +40,29 @@ describe('orphaned terminal input recovery wiring', () => { baseUrl = `http://localhost:${server.boundPort}`; browser = await chromium.launch({ headless: true }); page = await browser.newPage(); + // Headless Chromium is a fine pointer, and create() then returns null (#716). + // The recovery cases below are the soft-keyboard path, so this page reports + // a coarse pointer. Other media queries stay native. + await page.addInitScript(() => { + const native = window.matchMedia.bind(window); + window.matchMedia = (query: string) => { + if (query === '(pointer: coarse)') { + return { + matches: true, + media: query, + onchange: null, + addListener() {}, + removeListener() {}, + addEventListener() {}, + removeEventListener() {}, + dispatchEvent() { + return false; + }, + }; + } + return native(query); + }; + }); await page.goto(baseUrl, { waitUntil: 'domcontentloaded' }); await page.waitForFunction(() => (window as any).app?.terminal, null, { timeout: 30000 }); await page.waitForFunction(() => (window as any).app?._keyCode229Recovery, null, { timeout: 30000 }); @@ -50,6 +73,18 @@ describe('orphaned terminal input recovery wiring', () => { if (server) await server.stop(); }, 60000); + it('does not install recovery when the primary pointer is fine', async () => { + const fine = await browser.newPage(); + try { + await fine.goto(baseUrl, { waitUntil: 'domcontentloaded' }); + await fine.waitForFunction(() => (window as any).app?.terminal?.textarea, null, { timeout: 30000 }); + const installed = await fine.evaluate(() => (window as any).app?._keyCode229Recovery ?? null); + expect(installed).toBeNull(); + } finally { + await fine.close(); + } + }); + /** * Drive one keystroke through the real textarea and report what reached the * PTY send path. `dispatchInput` mirrors GBoard: a keydown with no usable key diff --git a/test/terminal-keycode229-recovery.test.ts b/test/terminal-keycode229-recovery.test.ts index 777fc0d76..57bb72ccb 100644 --- a/test/terminal-keycode229-recovery.test.ts +++ b/test/terminal-keycode229-recovery.test.ts @@ -46,9 +46,19 @@ function inputEvent(data: string, overrides: Record<string, unknown> = {}) { return { data, inputType: 'insertText', isComposing: false, ...overrides }; } -function harness({ screenReader = false } = {}) { +function harness({ screenReader = false, coarse = true, matchMedia = true } = {}) { const source = readFileSync(new URL('../src/web/public/terminal-keycode229-recovery.js', import.meta.url), 'utf8'); const exposed: Record<string, any> = {}; + if (matchMedia) { + // create() attaches only for a coarse pointer (#716). The recovery cases + // below are the soft-keyboard behaviour, so they opt in. A fine pointer + // (or no matchMedia at all) must get null and no listeners. + exposed.matchMedia = (query: string) => ({ + matches: query === '(pointer: coarse)' && coarse, + addEventListener() {}, + removeEventListener() {}, + }); + } vm.runInNewContext(source, { window: exposed, globalThis: exposed }, { filename: 'terminal-keycode229-recovery.js' }); const textarea = makeTextarea(); @@ -111,6 +121,31 @@ function loadTerminalInput() { } describe('orphaned terminal input recovery', () => { + it('does not attach on a fine pointer, so a desktop dead key is not forwarded twice', () => { + const h = harness({ coarse: false }); + expect(h.controller).toBeNull(); + expect(h.textarea.listenerCount()).toBe(0); + expect(h.textarea.registrations()).toEqual([]); + }); + + it('does not attach when matchMedia is missing', () => { + // Fail closed. Treating "unknown" as coarse would put the recovery back on + // a desktop whose matchMedia threw, which is the doubled backtick. + const h = harness({ matchMedia: false }); + expect(h.controller).toBeNull(); + expect(h.textarea.listenerCount()).toBe(0); + }); + + it('still recovers a dropped insertText on a coarse pointer', () => { + const h = harness({ coarse: true }); + h.keydown(); + h.input('`'); + expect(h.emitted).toEqual([]); + h.flushTimers(); + expect(h.emitted).toEqual(['`']); + expect(h.textarea.listenerCount()).toBeGreaterThan(0); + }); + it('forwards the committed text when xterm stayed silent', () => { const h = harness(); h.keydown(); @@ -432,7 +467,14 @@ describe('terminal-ui wiring: what counts as "xterm spoke for this keystroke"', */ function editSyncHarness() { const source = readFileSync(new URL('../src/web/public/terminal-keycode229-recovery.js', import.meta.url), 'utf8'); - const exposed: Record<string, any> = {}; + const exposed: Record<string, any> = { + // Autocorrect recovery is the coarse-pointer (soft keyboard) path. + matchMedia: (query: string) => ({ + matches: query === '(pointer: coarse)', + addEventListener() {}, + removeEventListener() {}, + }), + }; vm.runInNewContext(source, { window: exposed, globalThis: exposed }, { filename: 'terminal-keycode229-recovery.js' }); const textarea = Object.assign(makeTextarea(), { value: '' }); diff --git a/test/terminal-tile-input.test.ts b/test/terminal-tile-input.test.ts index a670228c4..4df588d3f 100644 --- a/test/terminal-tile-input.test.ts +++ b/test/terminal-tile-input.test.ts @@ -48,6 +48,17 @@ function loadContext() { // would run against no controller at all. setTimeout: (fn: () => void, ms?: number) => globalThis.setTimeout(fn, ms), clearTimeout: (id: ReturnType<typeof setTimeout>) => globalThis.clearTimeout(id), + // The real controller attaches only for a coarse pointer (#716). Tile tests + // that exercise soft-keyboard recovery opt in; a test sets this false to + // prove a desktop fine pointer installs nothing. + __coarsePointer: true, + matchMedia(query: string) { + return { + matches: query === '(pointer: coarse)' && windowStub.__coarsePointer !== false, + addEventListener() {}, + removeEventListener() {}, + }; + }, }; const context = vm.createContext({ console: { ...console, log: vi.fn(), debug: vi.fn() }, @@ -151,6 +162,7 @@ afterEach(() => { }); beforeEach(() => { + windowStub.__coarsePointer = true; FakeTerminal.coreFactory = null; FakeFit.proposed = { cols: 80, rows: 24 }; FakeSocket.instances = []; @@ -1183,6 +1195,13 @@ function lineOf(frames: Array<{ d?: string }>) { type ControllerTile = Tile & { _keyCode229Recovery: unknown }; describe("TerminalTile wires the primary pane's soft-keyboard controller (#441, #541)", () => { + it('does not install the controller on a fine pointer', async () => { + windowStub.__coarsePointer = false; + const { tile, term } = await connectTile(makeApp()); + expect((tile as ControllerTile)._keyCode229Recovery).toBeNull(); + expect(term.textareaListeners).toEqual([]); + }); + it("installs on THIS tile's composition helper and textarea, and destroy() restores xterm's own", async () => { const helpers = withCompositionHelpers(); const { tile, term } = await connectTile(makeApp());