mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-11-14 02:29:33 +01:00
Merge remote-tracking branch 'origin/master' into relprep/pr-614
This commit is contained in:
commit
0d42fdc0cf
635 files changed
+97889
-3794
No files matched your search
@@ -0,0 +1,275 @@
|
||||
/**
|
||||
* @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 { 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;
|
||||
const PREFIX_SCAN_BYTES = 8 * 1024 * 1024;
|
||||
export interface CodexAttributionOptions {
|
||||
codexHome: string;
|
||||
sessionId: string;
|
||||
workingDir: string;
|
||||
resumeSessionId?: string;
|
||||
}
|
||||
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;
|
||||
}
|
||||
async function canonical(path: string): Promise<string | undefined> {
|
||||
if ((await probePathKind(path, { pastCap: true })) !== 'directory') return undefined;
|
||||
try {
|
||||
return await realpath(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<Record<string, string | undefined>> = process.env
|
||||
): string {
|
||||
const path = resolve(workingDir, sessionHome || env.CODEX_HOME || join(env.HOME || tmpdir(), '.codex'));
|
||||
return 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;
|
||||
}
|
||||
/**
|
||||
* 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. 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;
|
||||
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<string, AttributionMemo>({ 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
|
||||
* 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 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 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 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);
|
||||
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<boolean> {
|
||||
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<Array<{ id: string; path: string; meta: Metadata }> | 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;
|
||||
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()!;
|
||||
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;
|
||||
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;
|
||||
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 || meta.id !== id) continue;
|
||||
if (id === explicit || meta.originator === originator) candidates.push({ id, path, meta });
|
||||
} finally {
|
||||
await file.close();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return candidates;
|
||||
}
|
||||
@@ -0,0 +1,208 @@
|
||||
/**
|
||||
* @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;
|
||||
/** Owner and session only: the unit the per-scope cap counts. */
|
||||
holder: 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<string> {
|
||||
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');
|
||||
}
|
||||
|
||||
/** 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 perScope = INSPECT_CURSORS_PER_SCOPE
|
||||
) {}
|
||||
|
||||
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) {
|
||||
if (consumed === 0) 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.
|
||||
// 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,
|
||||
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();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,356 @@
|
||||
/**
|
||||
* @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';
|
||||
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, 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 {
|
||||
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);
|
||||
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|$)`);
|
||||
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;
|
||||
/** 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>;
|
||||
}
|
||||
interface Receipt extends GuardedInputSnapshot {
|
||||
owner: string;
|
||||
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
|
||||
* an unknown shell prompt, or interpret dialog options as authority. An unsupported
|
||||
* frame yields no proof.
|
||||
*/
|
||||
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) => 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) => !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;
|
||||
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;
|
||||
}
|
||||
// The glyph and the one space after it are the prefix, not input.
|
||||
return stripAnsi(visible)
|
||||
.trimStart()
|
||||
.slice(chrome.promptGlyph.length + 1)
|
||||
.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;
|
||||
}
|
||||
/** Exact input-row prefix width, shared by proof and preflight wrapping checks. */
|
||||
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
|
||||
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 = readGuardedComposer(rows.join('\n'), chrome);
|
||||
if (composer === null) return null;
|
||||
const isComposer = composerRow(chrome);
|
||||
let row = -1;
|
||||
for (let i = rows.length - 1; i >= 0; i--)
|
||||
if (isComposer.test(stripAnsi(rows[i]))) {
|
||||
row = i;
|
||||
break;
|
||||
}
|
||||
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]) };
|
||||
}
|
||||
|
||||
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,
|
||||
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);
|
||||
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 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);
|
||||
try {
|
||||
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.
|
||||
// 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 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, 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)
|
||||
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 quiet = receipt.typedAt + SUBMIT_QUIET_MS - this.now();
|
||||
if (quiet > 0) await this.sleep(quiet);
|
||||
const current = target.snapshot();
|
||||
if (
|
||||
!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');
|
||||
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();
|
||||
}
|
||||
}
|
||||
@@ -9,7 +9,8 @@
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import { realpathSync } from 'node:fs';
|
||||
import fs from 'node:fs/promises';
|
||||
import { basename, extname, isAbsolute } from 'node:path';
|
||||
import { homedir } from 'node:os';
|
||||
import { basename, extname, isAbsolute, join } from 'node:path';
|
||||
import { isBlockedAttachmentPath, isUnderTree, loadAttachmentGuardConfig } from './config/attachment-guard.js';
|
||||
import { EDITABLE_EXTENSIONS } from './config/file-editing.js';
|
||||
import { validateSessionFilePath } from './web/route-helpers.js';
|
||||
@@ -148,6 +149,11 @@ class AttachmentRegistry {
|
||||
|
||||
export const attachmentRegistry = new AttachmentRegistry();
|
||||
|
||||
/** Every extension {@link isSupportedAttachmentExtension} accepts (lowercase, no dot). */
|
||||
export function supportedAttachmentExtensions(): string[] {
|
||||
return [...SUPPORTED_ATTACHMENT_EXTENSIONS];
|
||||
}
|
||||
|
||||
export function isSupportedAttachmentExtension(extension: string): boolean {
|
||||
return SUPPORTED_ATTACHMENT_EXTENSIONS.has(extension.toLowerCase().replace(/^\./, ''));
|
||||
}
|
||||
@@ -330,13 +336,19 @@ export async function registerExternalAttachment(
|
||||
requestedPath: string,
|
||||
options: RegisterExternalAttachmentOptions = {}
|
||||
): Promise<AttachmentRegistrationResult> {
|
||||
if (!requestedPath || !isAbsolute(requestedPath)) {
|
||||
// `~/…` is the home of the session's HOST: expanded here for a local session,
|
||||
// and by the probe on the remote host for a remote one (`buildRemoteProbeCommand`),
|
||||
// whose home this server does not know. A clicked terminal link sends it as is
|
||||
// (Claude Code echoes attachments as `~/…`). Every guard below runs on the
|
||||
// resolved absolute path either way.
|
||||
const homeRelative = typeof requestedPath === 'string' && requestedPath.startsWith('~/');
|
||||
if (!requestedPath || (!isAbsolute(requestedPath) && !homeRelative)) {
|
||||
throw new AttachmentRegistrationError('Attachment path must be an absolute local path');
|
||||
}
|
||||
|
||||
const resolved = await (options.remote
|
||||
? resolveRemoteAttachment(requestedPath, options.remote, options.sessionWorkingDir, options.remoteProbes)
|
||||
: resolveLocalAttachment(requestedPath));
|
||||
: resolveLocalAttachment(homeRelative ? join(homedir(), requestedPath.slice(2)) : requestedPath));
|
||||
|
||||
// COD-53: enforce the active attachment-guard policy on the symlink-resolved
|
||||
// path before doing anything else.
|
||||
|
||||
@@ -0,0 +1,226 @@
|
||||
/**
|
||||
* @fileoverview Renaming a case (its name and its folder) without losing what points at the folder.
|
||||
*
|
||||
* A case folder is held in more places than an open file. A running agent has it as its working
|
||||
* directory and its tmux pane was started `cd`-ed into it; Claude Code keys its conversation history
|
||||
* by the folder's path (`<config>/projects/<path with every non-alphanumeric as "-">`), so a moved
|
||||
* folder silently orphans every conversation; on Windows, SMB and some FUSE mounts a directory with an
|
||||
* open handle simply cannot be renamed (EBUSY / EPERM / EACCES). This module owns the three parts of
|
||||
* that which are not route logic:
|
||||
*
|
||||
* - the in-process lock (`withCaseLock` / `isCasePathLocked`) that keeps a session from starting in a
|
||||
* folder while it is being moved, and two renames from overlapping;
|
||||
* - the record of sessions constructed but not yet registered (`isCaseSessionStarting`), which a
|
||||
* rename refuses to move a folder under, since it can neither list nor stop them;
|
||||
* - the move itself, retried a few times for a transient lock and turned into a message that says
|
||||
* what to do when it is a real one;
|
||||
* - carrying Claude Code's history for the folder over to the new path.
|
||||
*
|
||||
* Pure of the server: every function takes the paths it works on.
|
||||
*
|
||||
* @module case-rename
|
||||
*/
|
||||
|
||||
import fs from 'node:fs/promises';
|
||||
import { dirname, join, resolve, sep } from 'node:path';
|
||||
import { claudeProjectsDir } from './utils/claude-transcript.js';
|
||||
|
||||
export type CaseRenameErrorCode =
|
||||
| 'INVALID' // a name that is not allowed, or the same name
|
||||
| 'EXISTS' // the new name or folder is already taken
|
||||
| 'BUSY' // another rename (or a session being stopped) holds the folder
|
||||
| 'LOCKED' // the OS refused the move: something still has the folder open
|
||||
| 'CROSS_DEVICE' // the move would cross filesystems
|
||||
| 'UNSUPPORTED'; // remote and Docker cases
|
||||
|
||||
export class CaseRenameError extends Error {
|
||||
constructor(
|
||||
readonly code: CaseRenameErrorCode,
|
||||
message: string
|
||||
) {
|
||||
super(message);
|
||||
this.name = 'CaseRenameError';
|
||||
}
|
||||
}
|
||||
|
||||
const locked = new Set<string>();
|
||||
|
||||
const within = (child: string, parent: string): boolean => child === parent || child.startsWith(parent + sep);
|
||||
|
||||
/** True when `path` is, or is inside, a folder that is being renamed right now. */
|
||||
export function isCasePathLocked(path: string): boolean {
|
||||
const target = resolve(path);
|
||||
for (const held of locked) {
|
||||
if (within(target, held)) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sessions constructed but not yet in the server's session map, where the rename route looks for the
|
||||
* sessions it must list or stop. A create awaits a few things between `new Session` and that map (the
|
||||
* owner's layout lock, a settings read), and a rename that ran in that gap neither saw nor stopped the
|
||||
* session, moved the folder, and left it to start in a path that no longer existed.
|
||||
*/
|
||||
const starting = new Map<object, { path: string; until: number }>();
|
||||
/** A session that never reaches the map (a create that failed, a one-shot prompt) stops counting after this. */
|
||||
const STARTING_TTL_MS = 30000;
|
||||
|
||||
const dropExpiredStarts = (now: number): void => {
|
||||
for (const [session, entry] of starting) {
|
||||
if (entry.until <= now) starting.delete(session);
|
||||
}
|
||||
};
|
||||
|
||||
/** `session` (any object standing for it) is being created with `workingDir`; `Session`'s constructor calls this. */
|
||||
export function noteCaseSessionStarting(session: object, workingDir: string, now = Date.now()): void {
|
||||
dropExpiredStarts(now);
|
||||
starting.set(session, { path: resolve(workingDir), until: now + STARTING_TTL_MS });
|
||||
}
|
||||
|
||||
/** `session` is in the server's session map (or was stopped): the rename route sees it from here on. */
|
||||
export function caseSessionSettled(session: object): void {
|
||||
starting.delete(session);
|
||||
}
|
||||
|
||||
/** True when a session is being created at or inside `path`, so a rename of it must wait. */
|
||||
export function isCaseSessionStarting(path: string, now = Date.now()): boolean {
|
||||
dropExpiredStarts(now);
|
||||
const root = resolve(path);
|
||||
for (const entry of starting.values()) {
|
||||
if (within(entry.path, root)) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Hold `from` and `to` for the length of `fn`. A second caller touching either (or anything inside
|
||||
* them) gets `BUSY` instead of racing the move; `Session`'s constructor refuses a working directory
|
||||
* inside a held folder, so nothing starts in a case that is half-moved.
|
||||
*/
|
||||
export async function withCaseLock<T>(from: string, to: string, fn: () => Promise<T>): Promise<T> {
|
||||
const held = [resolve(from), resolve(to)];
|
||||
for (const p of held) {
|
||||
for (const other of locked) {
|
||||
if (within(p, other) || within(other, p)) {
|
||||
throw new CaseRenameError('BUSY', 'Another rename is already working on this case. Try again in a moment.');
|
||||
}
|
||||
}
|
||||
}
|
||||
for (const p of held) locked.add(p);
|
||||
try {
|
||||
return await fn();
|
||||
} finally {
|
||||
for (const p of held) locked.delete(p);
|
||||
}
|
||||
}
|
||||
|
||||
const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms));
|
||||
const LOCK_CODES = new Set(['EBUSY', 'EPERM', 'EACCES']);
|
||||
|
||||
/**
|
||||
* `rename(from, to)` for a folder that must not already exist at `to`. A lock error is retried (a
|
||||
* handle another process is closing, an antivirus scan) before it is reported as locked.
|
||||
* `fs.rename` onto an EMPTY existing directory replaces it on POSIX, so the target is checked first.
|
||||
*/
|
||||
export async function moveCaseFolder(from: string, to: string, attempts = 4, delayMs = 150): Promise<void> {
|
||||
if (
|
||||
await fs.lstat(to).then(
|
||||
() => true,
|
||||
() => false
|
||||
)
|
||||
) {
|
||||
throw new CaseRenameError('EXISTS', `A folder named ${to} already exists.`);
|
||||
}
|
||||
for (let i = 0; ; i++) {
|
||||
try {
|
||||
await fs.rename(from, to);
|
||||
return;
|
||||
} catch (err) {
|
||||
const code = (err as NodeJS.ErrnoException).code ?? '';
|
||||
if (code === 'EXDEV') {
|
||||
throw new CaseRenameError(
|
||||
'CROSS_DEVICE',
|
||||
'The new folder would be on a different drive or mount; it is not moved.'
|
||||
);
|
||||
}
|
||||
if (LOCK_CODES.has(code)) {
|
||||
if (i < attempts - 1) {
|
||||
await sleep(delayMs * (i + 1));
|
||||
continue;
|
||||
}
|
||||
throw new CaseRenameError(
|
||||
'LOCKED',
|
||||
`The folder is in use by another program (${code}), so it could not be renamed. Close anything that has it open ` +
|
||||
'(an editor, a file browser or a sync client) and try again.'
|
||||
);
|
||||
}
|
||||
if (code === 'ENOTEMPTY' || code === 'EEXIST') {
|
||||
throw new CaseRenameError('EXISTS', `A folder named ${to} already exists.`);
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** The directory name Claude Code uses for a project path: every non-alphanumeric becomes `-`. */
|
||||
export function claudeProjectDirName(path: string): string {
|
||||
return resolve(path).replace(/[^a-zA-Z0-9]/g, '-');
|
||||
}
|
||||
|
||||
/**
|
||||
* Move Claude Code's history for the old folder to the new one, so `--resume` and the past-sessions list
|
||||
* still find the conversations. Returns whether anything moved.
|
||||
*
|
||||
* The new path can already have history: Claude Code never removes a project directory, and deleting a
|
||||
* case does not either, so a case deleted earlier under the new name leaves one behind. The old history is
|
||||
* then merged into it entry by entry (conversations are UUID-named, so they do not collide), never
|
||||
* overwriting: an entry both directories have (a `memory/` folder, say) stays at the old path. A merge
|
||||
* only goes between two real directories, never through a symlink.
|
||||
*/
|
||||
export async function moveClaudeHistory(oldPath: string, newPath: string, configDir?: string): Promise<boolean> {
|
||||
const projects = claudeProjectsDir(configDir);
|
||||
const from = join(projects, claudeProjectDirName(oldPath));
|
||||
const to = join(projects, claudeProjectDirName(newPath));
|
||||
if (from === to) return false;
|
||||
const fromStat = await fs.lstat(from).catch(() => null);
|
||||
if (!fromStat) return false;
|
||||
const toStat = await fs.lstat(to).catch(() => null);
|
||||
if (!toStat) {
|
||||
try {
|
||||
await fs.rename(from, to);
|
||||
return true;
|
||||
} catch {
|
||||
return false; // history is a convenience; the folder move already succeeded
|
||||
}
|
||||
}
|
||||
if (!fromStat.isDirectory() || !toStat.isDirectory()) return false;
|
||||
let moved = false;
|
||||
for (const entry of await fs.readdir(from).catch(() => [] as string[])) {
|
||||
const target = join(to, entry);
|
||||
if (
|
||||
await fs.lstat(target).then(
|
||||
() => true,
|
||||
() => false
|
||||
)
|
||||
)
|
||||
continue;
|
||||
try {
|
||||
await fs.rename(join(from, entry), target);
|
||||
moved = true;
|
||||
} catch {
|
||||
/* left at the old path, like an entry both have */
|
||||
}
|
||||
}
|
||||
await fs.rmdir(from).catch(() => {}); // succeeds only once nothing was left behind
|
||||
return moved;
|
||||
}
|
||||
|
||||
/** Replace `from` by `to` in a list, keeping its position (the case order). */
|
||||
export function renameInOrder(order: unknown, from: string, to: string): string[] | null {
|
||||
if (!Array.isArray(order) || !order.includes(from)) return null;
|
||||
return order.map((n) => (n === from ? to : n)) as string[];
|
||||
}
|
||||
|
||||
/** The folder a linked case moves to: its own parent, the new name. */
|
||||
export const siblingPath = (path: string, newName: string): string => join(dirname(path), newName);
|
||||
@@ -7,10 +7,12 @@
|
||||
* used to change only the tab, so `/resume` kept listing the old name.
|
||||
*
|
||||
* Appending the row is enough for a pane that was spawned WITHOUT `--name`
|
||||
* (every placeholder- or auto-named tab, see `Session.cliPinnedName`): that
|
||||
* process holds no title of its own and never writes one back. A process that
|
||||
* WAS spawned with `--name` re-appends its in-memory title after each turn, so
|
||||
* there the new title holds from the next spawn, which pins the new name.
|
||||
* (every placeholder- or auto-named tab, unless the opt-in `pinCliSessionName`
|
||||
* setting is on, see `Session.cliPinnedName`): that process holds no title of
|
||||
* its own and never writes one back. A process that WAS spawned with `--name`
|
||||
* (every tab with `pinCliSessionName` on) re-appends its in-memory title after
|
||||
* each turn, so there the new title holds only until the next turn, then from
|
||||
* the next spawn, which pins the new name.
|
||||
*
|
||||
* @module claude-session-title
|
||||
*/
|
||||
|
||||
+601
-62
@@ -2,15 +2,21 @@
|
||||
* @fileoverview `codeman agent …` — session-to-session verbs for the agent running
|
||||
* inside a Codeman session, in every CLI mode.
|
||||
*
|
||||
* A thin HTTP client over endpoints that already exist (`quick-start`, `input`,
|
||||
* `wait`, `wait-output`, `last-response`, `terminal`, `DELETE sessions/:id`). It
|
||||
* invents no route and no transport: everything goes through `CODEMAN_API_URL`, so
|
||||
* auth, ownership and the per-session waiter cap apply unchanged. The behaviour is
|
||||
* A thin HTTP client over the server's endpoints (`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`, 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.
|
||||
*
|
||||
* 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
|
||||
@@ -22,14 +28,26 @@
|
||||
* 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.
|
||||
*
|
||||
* @module cli-agent
|
||||
*/
|
||||
import { open, 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';
|
||||
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,
|
||||
@@ -129,16 +147,18 @@ export function sendPromptFromArgs(words: readonly string[]): { text: string } |
|
||||
}
|
||||
|
||||
/**
|
||||
* Why `send` refuses this text, or `undefined` when it is printable. The composer
|
||||
* takes one line; the server strips `\r`/`\n` but everything else below 0x20 (and
|
||||
* DEL) reaches the pane as a keypress. None of that is a prompt.
|
||||
* Why `send` refuses this text, or `undefined` when it is printable. A line break is
|
||||
* refused for the reason below; everything else below 0x20 (and DEL) reaches the pane
|
||||
* as a keypress. None of that is a prompt.
|
||||
*/
|
||||
export function inputRefusal(text: string): string | undefined {
|
||||
if (text.length === 0) return 'refusing: empty input (use `interrupt` for ESC, `send <id> ""` is never a prompt)';
|
||||
// The composer is one line: the server strips newlines, which silently joins the
|
||||
// lines into one prompt, and a tab reaches the pane as a keypress (claude: mode toggle).
|
||||
// The server pastes a multi-line prompt as one block, but the check that re-presses
|
||||
// a stranded Enter cannot reliably recognise that block in the composer, so a wait
|
||||
// could burn its timeout on a prompt that never went out. A tab reaches the pane as
|
||||
// a keypress (claude: mode toggle).
|
||||
if (/[\n\r\t]/.test(text)) {
|
||||
return 'refusing: input must be a single line (the composer strips newlines and would join your lines) — join them yourself, or write a file into the workspace and send its path';
|
||||
return 'refusing: input must be a single line (a multi-line paste cannot be confirmed as submitted) — join them yourself, or write a file into the workspace and send its path';
|
||||
}
|
||||
// C0, DEL and C1 (U+0080–U+009F: an 8-bit CSI is still a CSI to a terminal).
|
||||
// eslint-disable-next-line no-control-regex
|
||||
@@ -188,6 +208,22 @@ export function stripAnsi(text: string): string {
|
||||
return stripAnsiSequences(text).replace(/\x1b[()][AB0]/g, '');
|
||||
}
|
||||
|
||||
/**
|
||||
* Inbox message text for a terminal. Another session wrote it, so an escape sequence
|
||||
* in it would otherwise reach the reader's terminal (retitle it, or move the cursor
|
||||
* over the `from` line above it). ANSI sequences go as in `read --tail`, and every
|
||||
* control character left (C0 other than newline and tab, DEL, C1) is shown as `\xHH`,
|
||||
* so a bare `\r` or backspace cannot overwrite the header either. A CRLF line end
|
||||
* stays a line end. `--json` prints the text as stored.
|
||||
*/
|
||||
export function terminalSafeText(text: string): string {
|
||||
return (
|
||||
stripAnsi(text.replace(/\r\n/g, '\n'))
|
||||
// eslint-disable-next-line no-control-regex
|
||||
.replace(/[\x00-\x08\x0b-\x1f\x7f-\x9f]/g, (c) => `\\x${c.charCodeAt(0).toString(16).padStart(2, '0')}`)
|
||||
);
|
||||
}
|
||||
|
||||
/** Parse a positive-integer option (`--timeout` ms, `--tail` bytes); the server rejects anything else. */
|
||||
export function parsePositiveInt(raw: string | undefined, fallback: number, flag = '--timeout'): number {
|
||||
if (raw === undefined) return fallback;
|
||||
@@ -253,20 +289,21 @@ export interface RequestOptions {
|
||||
headers?: Record<string, string>;
|
||||
/** Socket timeout; long-polls pass their own timeout plus headroom. */
|
||||
timeoutMs?: number;
|
||||
/** Aborts the request (an MCP client cancelling a long wait); the promise rejects. */
|
||||
signal?: AbortSignal;
|
||||
}
|
||||
|
||||
export type ApiRequest = (ctx: AgentContext, options: RequestOptions) => Promise<ApiResponse>;
|
||||
|
||||
/** Every request carries these; they are ignored on endpoints that do not read them. */
|
||||
export function baseHeaders(ctx: AgentContext): Record<string, string> {
|
||||
const headers: Record<string, string> = {
|
||||
Accept: 'application/json',
|
||||
// Tags sessions this caller spawns as its children (lineage in the web UI).
|
||||
// Cosmetic, never fails a call. NOT X-Codeman-Agent-Origin: that one marks a case
|
||||
// directory as deletable agent scratch, so it rides only the spawn request that
|
||||
// may create one (see agentSpawn), never anything else.
|
||||
'X-Codeman-Parent-Session': ctx.selfId,
|
||||
};
|
||||
const headers: Record<string, string> = { Accept: 'application/json' };
|
||||
// Tags sessions this caller spawns as its children (lineage in the web UI).
|
||||
// Cosmetic, never fails a call. NOT X-Codeman-Agent-Origin: that one marks a case
|
||||
// directory as deletable agent scratch, so it rides only the spawn request that
|
||||
// may create one (see agentSpawn), never anything else. Absent for a caller outside
|
||||
// any session (`codeman mcp` launched by a desktop MCP client), which has no self.
|
||||
if (ctx.selfId) headers['X-Codeman-Parent-Session'] = ctx.selfId;
|
||||
const authorization = ctx.auth ? basicAuthHeader(ctx.auth) : undefined;
|
||||
if (authorization) headers.Authorization = authorization;
|
||||
return headers;
|
||||
@@ -296,10 +333,14 @@ export const httpRequest: ApiRequest = (ctx, options) => {
|
||||
rejectUnauthorized: false,
|
||||
headers,
|
||||
timeout: options.timeoutMs ?? 30_000,
|
||||
signal: options.signal,
|
||||
},
|
||||
(res) => {
|
||||
const chunks: Buffer[] = [];
|
||||
res.on('data', (chunk: Buffer) => chunks.push(chunk));
|
||||
// A connection dropped mid-body emits 'error' and never 'end'; without this the
|
||||
// promise never settles (and a long-lived `codeman mcp` never answers the call).
|
||||
res.on('error', reject);
|
||||
res.on('end', () => {
|
||||
const text = Buffer.concat(chunks).toString('utf-8');
|
||||
let json: ApiEnvelope | undefined;
|
||||
@@ -319,6 +360,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) {
|
||||
@@ -348,7 +401,13 @@ 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 }` 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));
|
||||
}
|
||||
@@ -409,15 +468,55 @@ export async function resolveSessionId(
|
||||
return { error: `"${id}" is ambiguous: ${matches.map((s) => s.id.slice(0, 13)).join(', ')}`, code: EXIT.error };
|
||||
}
|
||||
|
||||
/** `agent ls` — every session the caller can see, self marked. */
|
||||
/** One session's row in `GET /api/agent-inbox/summary`. */
|
||||
export interface InboxSummaryRow {
|
||||
pending: number;
|
||||
unseen: number;
|
||||
oldestUnseenAt: number | null;
|
||||
parked: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* The inbox summary, or undefined when it cannot be had (a server without the route,
|
||||
* a transient error). `ls` must still list sessions then, so this never fails the verb.
|
||||
*/
|
||||
async function fetchInboxSummary(deps: AgentDeps): Promise<Record<string, InboxSummaryRow> | undefined> {
|
||||
try {
|
||||
const res = await deps.request(deps.ctx, { method: 'GET', path: '/api/v1/agent-inbox/summary' });
|
||||
if (!res.json?.success) return undefined;
|
||||
return (res.json.data as { sessions?: Record<string, InboxSummaryRow> } | undefined)?.sessions ?? {};
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
/** The INBOX cell of `ls`: `3 (1 new)`, `parked`, both, or empty. */
|
||||
export function inboxCell(row: InboxSummaryRow | undefined): string {
|
||||
if (!row) return '';
|
||||
const parts: string[] = [];
|
||||
if (row.pending > 0) parts.push(row.unseen > 0 ? `${row.pending} (${row.unseen} new)` : String(row.pending));
|
||||
if (row.parked) parts.push('parked');
|
||||
return parts.join(', ');
|
||||
}
|
||||
|
||||
/** `agent ls` — every session the caller can see, self marked, with its inbox counts. */
|
||||
export async function agentLs(deps: AgentDeps): Promise<number> {
|
||||
const res = await deps.request(deps.ctx, { method: 'GET', path: '/api/v1/sessions' });
|
||||
const [res, inbox] = await Promise.all([
|
||||
deps.request(deps.ctx, { method: 'GET', path: '/api/v1/sessions' }),
|
||||
fetchInboxSummary(deps),
|
||||
]);
|
||||
if (!res.json?.success) return fail(deps, describeFailure(res));
|
||||
const sessions = (res.json.data as SessionRow[] | undefined) ?? [];
|
||||
if (deps.json) {
|
||||
emitJson(
|
||||
deps,
|
||||
sessions.map((s) => ({ ...s, self: isSelfSession(deps.ctx.selfId, s.id) }))
|
||||
sessions.map((s) => ({
|
||||
...s,
|
||||
self: isSelfSession(deps.ctx.selfId, s.id),
|
||||
// null = empty inbox and not parked. The key is absent when the server did
|
||||
// not answer the summary, so "no data" never reads as "no mail".
|
||||
...(inbox ? { inbox: inbox[s.id] ?? null } : {}),
|
||||
}))
|
||||
);
|
||||
return EXIT.ok;
|
||||
}
|
||||
@@ -425,22 +524,28 @@ export async function agentLs(deps: AgentDeps): Promise<number> {
|
||||
deps.io.out(palette.muted('(no sessions)'));
|
||||
return EXIT.ok;
|
||||
}
|
||||
const rows = sessions.map((s) => [
|
||||
isSelfSession(deps.ctx.selfId, s.id) ? '*' : ' ',
|
||||
s.id.slice(0, 8),
|
||||
s.mode ?? '?',
|
||||
s.status ?? '?',
|
||||
s.name || s.workingDir || '',
|
||||
]);
|
||||
deps.io.out(table([[' ', 'ID', 'MODE', 'STATUS', 'NAME'], ...rows], { gap: 2 }));
|
||||
const rows = sessions.map((s) => {
|
||||
const row = [isSelfSession(deps.ctx.selfId, s.id) ? '*' : ' ', s.id.slice(0, 8), s.mode ?? '?', s.status ?? '?'];
|
||||
if (inbox) row.push(inboxCell(inbox[s.id]));
|
||||
row.push(s.name || s.workingDir || '');
|
||||
return row;
|
||||
});
|
||||
const header = inbox ? [' ', 'ID', 'MODE', 'STATUS', 'INBOX', 'NAME'] : [' ', 'ID', 'MODE', 'STATUS', 'NAME'];
|
||||
deps.io.out(table([header, ...rows], { gap: 2 }));
|
||||
deps.io.out(
|
||||
palette.muted(`* = this session (${deps.ctx.selfId.slice(0, 8)}). status is a UI hint, never a sync signal.`)
|
||||
);
|
||||
if (inbox) {
|
||||
deps.io.out(
|
||||
palette.muted('INBOX: pending (unseen), parked = the session is waiting on `agent inbox --wait` right now.')
|
||||
);
|
||||
}
|
||||
return EXIT.ok;
|
||||
}
|
||||
|
||||
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. */
|
||||
@@ -463,44 +568,135 @@ 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. */
|
||||
|
||||
/** 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);
|
||||
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());
|
||||
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);
|
||||
}
|
||||
// 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> = {
|
||||
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
|
||||
| { session?: { id?: string }; sessionId?: string; caseName?: string; casePath?: string }
|
||||
| undefined;
|
||||
// `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;
|
||||
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;
|
||||
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 = describeRequestError(error);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -509,7 +705,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) {
|
||||
@@ -688,6 +884,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<number> {
|
||||
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<number> {
|
||||
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<number> {
|
||||
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. */
|
||||
@@ -783,6 +1076,158 @@ export async function agentRm(deps: AgentDeps, options: { id: string }): Promise
|
||||
return EXIT.ok;
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Agent inbox
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
/** A message as `GET …/inbox` hands it out. */
|
||||
export interface InboxMessageRow {
|
||||
id: string;
|
||||
from: string | null;
|
||||
text: string;
|
||||
createdAt: number;
|
||||
seen: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* The message `post` was given, which must be ONE argument, as with `send`: several
|
||||
* would be an unquoted `$(cat notes.md)` split by the shell, and joining the pieces
|
||||
* with spaces loses its newlines. Unlike `send`, newlines inside the one argument are
|
||||
* fine: the inbox stores text as data and never types it.
|
||||
*/
|
||||
export function postTextFromArgs(words: readonly string[]): { text: string } | { error: string } {
|
||||
if (words.length === 1) return { text: words[0] };
|
||||
return { error: `refusing: the message must be ONE argument, got ${words.length} — quote it` };
|
||||
}
|
||||
|
||||
/** `agent post` — store a message in another session's inbox. Never types into its pane. */
|
||||
export async function agentPost(deps: AgentDeps, options: { id: string; text: string }): Promise<number> {
|
||||
if (options.text.length === 0) return fail(deps, 'refusing: empty message', EXIT.refused);
|
||||
const target = await resolveSessionId(deps, options.id);
|
||||
if ('error' in target) return fail(deps, target.error, target.code);
|
||||
const res = await deps.request(deps.ctx, {
|
||||
method: 'POST',
|
||||
path: `/api/v1/sessions/${encodeURIComponent(target.id)}/inbox`,
|
||||
body: { text: options.text },
|
||||
});
|
||||
// 409 = the receiver's inbox is full; nothing was stored. Passed through as an error.
|
||||
if (!res.json?.success) return fail(deps, describeFailure(res));
|
||||
const data = res.json.data as { message?: InboxMessageRow; pending?: number; unseen?: number } | undefined;
|
||||
if (deps.json) emitJson(deps, data ?? {});
|
||||
else
|
||||
deps.io.out(
|
||||
palette.ok(
|
||||
`${GLYPH.ok} posted to ${target.id.slice(0, 8)} (${data?.pending ?? '?'} pending, ${data?.unseen ?? '?'} unseen)`
|
||||
)
|
||||
);
|
||||
return EXIT.ok;
|
||||
}
|
||||
|
||||
export interface InboxReadOptions {
|
||||
/** Another session's inbox (a peek); omitted = this session's own. */
|
||||
id?: string;
|
||||
/** Long-poll budget in ms; the read parks while nothing is unseen. */
|
||||
waitMs?: number;
|
||||
}
|
||||
|
||||
/** True only when the session list was read and does not contain `id`. */
|
||||
async function sessionIsGone(deps: AgentDeps, id: string): Promise<boolean> {
|
||||
try {
|
||||
const res = await deps.request(deps.ctx, { method: 'GET', path: '/api/v1/sessions' });
|
||||
if (!res.json?.success) return false;
|
||||
return !((res.json.data as SessionRow[] | undefined) ?? []).some((s) => s.id === id);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/** Relative age for humans: `12s`, `4m`, `3h`. */
|
||||
function age(createdAt: number, now: number): string {
|
||||
const s = Math.max(0, Math.round((now - createdAt) / 1000));
|
||||
if (s < 60) return `${s}s`;
|
||||
if (s < 3600) return `${Math.round(s / 60)}m`;
|
||||
return `${Math.round(s / 3600)}h`;
|
||||
}
|
||||
|
||||
/**
|
||||
* `agent inbox` — read this session's inbox (or peek at another's). Removes nothing:
|
||||
* a message stays until `agent ack <id>`. Only reading your OWN inbox marks messages
|
||||
* seen. Exit 0 with mail, 2 when a `--wait` ran out with nothing unseen or the server
|
||||
* cut it short (restart, detach), 3 when the session is gone from the list.
|
||||
*/
|
||||
export async function agentInboxRead(deps: AgentDeps, options: InboxReadOptions): Promise<number> {
|
||||
const target = await resolveSessionId(deps, options.id ?? deps.ctx.selfId);
|
||||
if ('error' in target) return fail(deps, target.error, target.code);
|
||||
const res = await deps.request(deps.ctx, {
|
||||
method: 'GET',
|
||||
path: `/api/v1/sessions/${encodeURIComponent(target.id)}/inbox`,
|
||||
query: { wait: options.waitMs },
|
||||
timeoutMs: options.waitMs !== undefined ? options.waitMs + 10_000 : undefined,
|
||||
});
|
||||
if (!res.json?.success) return fail(deps, describeFailure(res));
|
||||
const data = res.json.data as
|
||||
| {
|
||||
receiver?: boolean;
|
||||
messages?: InboxMessageRow[];
|
||||
pending?: number;
|
||||
unseen?: number;
|
||||
wait?: { timedOut?: boolean; ended?: boolean };
|
||||
}
|
||||
| undefined;
|
||||
const messages = data?.messages ?? [];
|
||||
// `ended` is also what a server restart or a detach answers. Exit 3 tells a receiver
|
||||
// loop to stop, so it needs the session list to confirm the session is gone; while
|
||||
// it is still listed (or the list cannot be had, as during a restart) the wait was
|
||||
// only cut short, which is exit 2: loop again.
|
||||
const gone = data?.wait?.ended ? await sessionIsGone(deps, target.id) : false;
|
||||
const code = gone ? EXIT.dead : data?.wait?.timedOut || data?.wait?.ended ? EXIT.timeout : EXIT.ok;
|
||||
if (deps.json) {
|
||||
emitJson(deps, data ?? {});
|
||||
return code;
|
||||
}
|
||||
const now = (deps.now ?? Date.now)();
|
||||
for (const m of messages) {
|
||||
const sender = m.from ? m.from.slice(0, 8) : 'user';
|
||||
const tag = m.seen ? palette.muted('seen') : palette.emph('new');
|
||||
deps.io.out(palette.muted(`── ${m.id} from ${sender}, ${age(m.createdAt, now)} ago, `) + tag);
|
||||
deps.io.out(terminalSafeText(m.text));
|
||||
}
|
||||
if (gone) {
|
||||
deps.io.err(palette.err(`${GLYPH.fail} the session went away during the wait`));
|
||||
} else if (data?.wait?.ended) {
|
||||
deps.io.err(palette.muted('(the server ended the wait early, e.g. a restart; exit 2, wait again)'));
|
||||
} else if (messages.length === 0) {
|
||||
deps.io.err(palette.muted(data?.wait?.timedOut ? '(no mail; wait timed out, exit 2)' : '(no mail)'));
|
||||
} else if (data?.receiver) {
|
||||
deps.io.err(
|
||||
palette.muted(
|
||||
`${messages.length} pending. Nothing is removed by reading: when done, \`codeman agent ack <id>…\` (full ids above).`
|
||||
)
|
||||
);
|
||||
} else {
|
||||
deps.io.err(palette.muted(`${messages.length} pending (peek: nothing was marked seen).`));
|
||||
}
|
||||
return code;
|
||||
}
|
||||
|
||||
/** `agent ack` — remove messages from THIS session's inbox by id. Idempotent. */
|
||||
export async function agentAck(deps: AgentDeps, options: { ids: string[] }): Promise<number> {
|
||||
const ids = options.ids.map((id) => id.trim()).filter(Boolean);
|
||||
if (ids.length === 0) return fail(deps, 'refusing: no message ids', EXIT.refused);
|
||||
const self = await resolveSessionId(deps, deps.ctx.selfId);
|
||||
if ('error' in self) return fail(deps, self.error, self.code);
|
||||
const res = await deps.request(deps.ctx, {
|
||||
method: 'POST',
|
||||
path: `/api/v1/sessions/${encodeURIComponent(self.id)}/inbox/ack`,
|
||||
body: { ids },
|
||||
});
|
||||
if (!res.json?.success) return fail(deps, describeFailure(res));
|
||||
const data = res.json.data as { removed?: number; pending?: number } | undefined;
|
||||
if (deps.json) emitJson(deps, data ?? {});
|
||||
else deps.io.out(palette.ok(`${GLYPH.ok} acked ${data?.removed ?? 0} (${data?.pending ?? '?'} still pending)`));
|
||||
return EXIT.ok;
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Commander wiring
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
@@ -817,7 +1262,7 @@ async function run(json: boolean, verb: (deps: AgentDeps) => Promise<number>): 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;
|
||||
}
|
||||
}
|
||||
@@ -826,7 +1271,9 @@ async function run(json: boolean, verb: (deps: AgentDeps) => Promise<number>): P
|
||||
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');
|
||||
.description(
|
||||
'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
|
||||
.command('ls')
|
||||
@@ -836,20 +1283,26 @@ export function registerAgentCommands(program: Command): Command {
|
||||
.action((options: { json?: boolean }) => run(Boolean(options.json), agentLs));
|
||||
|
||||
agent
|
||||
.command('spawn <case>')
|
||||
.description(
|
||||
'Start a worker session in a case (created if missing) and wait for its composer where the mode draws one'
|
||||
)
|
||||
.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 <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; 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')
|
||||
.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,
|
||||
@@ -909,7 +1362,7 @@ export function registerAgentCommands(program: Command): Command {
|
||||
)
|
||||
.option(
|
||||
'-u, --until <signals>',
|
||||
'Comma list: stop,idle,exit,working,blocked (stop/blocked need hook signals for the session; where there are none the server answers 400, passed through)'
|
||||
'Comma list: stop,idle,exit,working,blocked (stop needs hook signals for the session, blocked hooks or a dialog Codeman reads off the screen, as for codex; where there are none the server answers 400, passed through)'
|
||||
)
|
||||
.option(
|
||||
'-m, --match <marker>',
|
||||
@@ -946,6 +1399,49 @@ export function registerAgentCommands(program: Command): Command {
|
||||
)
|
||||
);
|
||||
|
||||
agent
|
||||
.command('inspect <id>')
|
||||
.description(
|
||||
'Experimental, opt-in (agentCoordinationEnabled): bounded transcript evidence or a labeled pane snapshot; opaque incremental cursors'
|
||||
)
|
||||
.option('--max-bytes <n>', 'UTF8 evidence budget (1–65536)', '8192')
|
||||
.option('--since <cursor>', '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 <id>')
|
||||
.description(
|
||||
'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')
|
||||
.action((id: string, options: { messageFile: string; json?: boolean }) =>
|
||||
run(Boolean(options.json), (deps) => agentStage(deps, { id, messageFile: options.messageFile }))
|
||||
);
|
||||
|
||||
agent
|
||||
.command('submit <id>')
|
||||
.description(
|
||||
'Experimental, opt-in (agentCoordinationEnabled): send Enter once only when the staged composer, pane and hash still match'
|
||||
)
|
||||
.requiredOption('--stage-id <receipt>', 'One-shot stage receipt')
|
||||
.requiredOption('--confirm-sha <hash>', '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 <id>')
|
||||
.description("Print a session's last answer (as the server reads it for that mode) or, with --tail, its terminal")
|
||||
@@ -970,6 +1466,49 @@ export function registerAgentCommands(program: Command): Command {
|
||||
run(Boolean(options.json), (deps) => agentInterrupt(deps, { id }))
|
||||
);
|
||||
|
||||
agent
|
||||
.command('post <id> <text...>')
|
||||
.description(
|
||||
"Leave a message in another session's inbox (ONE quoted argument; multi-line is fine). Nothing is typed into its pane: it reads with `agent inbox`"
|
||||
)
|
||||
.option('--json', 'Machine-readable output')
|
||||
// A message is often a file (`"$(cat task.md)"`) and markdown starts with `- ` or
|
||||
// `---` often enough; without this commander takes it for an unknown option.
|
||||
// `--json` and `-h` are still options: text that is exactly one goes after `--`.
|
||||
.allowUnknownOption()
|
||||
.action((id: string, words: string[], options: { json?: boolean }) =>
|
||||
run(Boolean(options.json), (deps) => {
|
||||
const message = postTextFromArgs(words);
|
||||
if ('error' in message) return Promise.resolve(fail(deps, message.error, EXIT.refused));
|
||||
return agentPost(deps, { id, text: message.text });
|
||||
})
|
||||
);
|
||||
|
||||
agent
|
||||
.command('inbox [id]')
|
||||
.description(
|
||||
"Read this session's inbox (or peek at another's with [id]). Reading removes nothing; `agent ack` does. --wait blocks while nothing is new: exit 2 on timeout"
|
||||
)
|
||||
.option('-w, --wait', 'Block until a new (unseen) message arrives')
|
||||
.option('-t, --timeout <ms>', 'Wait budget in ms (with --wait)', String(DEFAULT_WAIT_MS))
|
||||
.option('--json', 'Machine-readable output')
|
||||
.action((id: string | undefined, options: { wait?: boolean; timeout?: string; json?: boolean }) =>
|
||||
run(Boolean(options.json), (deps) =>
|
||||
agentInboxRead(deps, {
|
||||
id,
|
||||
waitMs: options.wait ? parsePositiveInt(options.timeout, DEFAULT_WAIT_MS) : undefined,
|
||||
})
|
||||
)
|
||||
);
|
||||
|
||||
agent
|
||||
.command('ack <ids...>')
|
||||
.description("Remove messages from this session's inbox by id, once handled (unknown ids are not an error)")
|
||||
.option('--json', 'Machine-readable output')
|
||||
.action((ids: string[], options: { json?: boolean }) =>
|
||||
run(Boolean(options.json), (deps) => agentAck(deps, { ids }))
|
||||
);
|
||||
|
||||
agent
|
||||
.command('rm <id>')
|
||||
.description('Delete any session except this one (refuses your own id)')
|
||||
|
||||
@@ -0,0 +1,109 @@
|
||||
/**
|
||||
* @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_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,
|
||||
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')
|
||||
.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 <path>', 'Explicit local JSONL transcript (regular file only)')
|
||||
.option('--session-id <id>', 'Native Codex conversation UUID or unique prefix (at least 8 characters)')
|
||||
.option('--root-session-id <uuid>', 'Native root UUID, including transitive child conversations')
|
||||
.option(
|
||||
'--sessions-root <path>',
|
||||
'Local Codex sessions directory (default: $CODEX_HOME/sessions, else ~/.codex/sessions)'
|
||||
)
|
||||
.option('--topic <text>', 'Case-insensitive literal match, preserving whole matching turns')
|
||||
.option('--after <date>', 'Inclusive YYYY-MM-DD lower bound, as a UTC date; preserves whole matching turns')
|
||||
.option('--before <date>', 'Inclusive YYYY-MM-DD upper bound, as a UTC date; preserves whole matching turns')
|
||||
.option(
|
||||
'--max-bytes <n>',
|
||||
`Maximum complete output bytes (1-${HISTORY_MAX_OUTPUT_BYTES})`,
|
||||
String(HISTORY_DEFAULT_OUTPUT_BYTES)
|
||||
)
|
||||
.option(
|
||||
'--scan-bytes <n>',
|
||||
`Read budget for the selected transcripts (1-${HISTORY_MAX_SCAN_BYTES})`,
|
||||
String(HISTORY_DEFAULT_SCAN_BYTES)
|
||||
)
|
||||
.option(
|
||||
'--metadata-bytes <n>',
|
||||
`Read budget for attributing every rollout in the store (1-${HISTORY_MAX_METADATA_BYTES})`,
|
||||
String(HISTORY_DEFAULT_METADATA_BYTES)
|
||||
)
|
||||
.option(
|
||||
'--max-entries <n>',
|
||||
`Directory entries visited in the store (1-${HISTORY_MAX_ENTRIES})`,
|
||||
String(HISTORY_DEFAULT_MAX_ENTRIES)
|
||||
)
|
||||
.option('--format <format>', 'json or text', 'json')
|
||||
.option('--json', 'JSON output (the default; conflicts with --format text)')
|
||||
.action(async (options: Record<string, string | boolean | undefined>, command: Command) => {
|
||||
try {
|
||||
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;
|
||||
// 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,
|
||||
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: budgetFlag(options.scanBytes),
|
||||
metadataBytes: budgetFlag(options.metadataBytes),
|
||||
maxEntries: budgetFlag(options.maxEntries),
|
||||
});
|
||||
program.configureOutput().writeOut?.(`${formatLocalHistory(result, format, maxBytes)}\n`);
|
||||
} catch (error) {
|
||||
program.error(error instanceof Error ? error.message : String(error), { exitCode: 1, code: 'history.read' });
|
||||
}
|
||||
});
|
||||
return history;
|
||||
}
|
||||
@@ -0,0 +1,129 @@
|
||||
/**
|
||||
* @fileoverview `codeman notify`: post to this tab's Discord thread (POST /api/sessions/:id/discord-notify).
|
||||
*
|
||||
* A CLI that is blocked on something only the user can do (a physical step, a blocker, a timed-out
|
||||
* wait) runs this. It reads CODEMAN_SESSION_ID and CODEMAN_API_URL, which Codeman sets in every
|
||||
* session, and the shared credentials, so the caller handles no auth. `runNotify` returns what to
|
||||
* print and the exit code instead of exiting, so a test can drive it.
|
||||
*
|
||||
* @module cli-notify
|
||||
*/
|
||||
|
||||
import http from 'node:http';
|
||||
import https from 'node:https';
|
||||
import { isAbsolute, join } from 'node:path';
|
||||
import { readCodemanCredentials } from './codeman-credentials.js';
|
||||
|
||||
const REQUEST_TIMEOUT_MS = 30_000;
|
||||
|
||||
export interface NotifyOptions {
|
||||
message: string;
|
||||
urgent?: boolean;
|
||||
file?: string;
|
||||
session?: string;
|
||||
url?: string;
|
||||
}
|
||||
|
||||
export interface NotifyResult {
|
||||
code: number;
|
||||
out?: string;
|
||||
err?: string;
|
||||
}
|
||||
|
||||
export type JsonPost = (
|
||||
apiUrl: string,
|
||||
apiPath: string,
|
||||
payload: unknown,
|
||||
timeoutMs?: number
|
||||
) => Promise<{ status: number; error?: string; delivery?: { ok: boolean; error?: string } }>;
|
||||
|
||||
/** POST JSON to the Codeman API with the same credentials `codeman attach` uses. Status 0 means no answer. */
|
||||
export const postJson: JsonPost = (apiUrl, apiPath, payload, timeoutMs = REQUEST_TIMEOUT_MS) => {
|
||||
const { username, password } = readCodemanCredentials();
|
||||
const url = new URL(apiPath, apiUrl);
|
||||
const body = JSON.stringify(payload);
|
||||
const transport = url.protocol === 'https:' ? https : http;
|
||||
return new Promise((resolveOnce) => {
|
||||
// Settle once: a response that stalls after its headers ends in 'close' without 'end'.
|
||||
let settled = false;
|
||||
const resolve: typeof resolveOnce = (v) => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
resolveOnce(v);
|
||||
};
|
||||
const headers: Record<string, string | number> = {
|
||||
Accept: 'application/json',
|
||||
'Content-Type': 'application/json',
|
||||
'Content-Length': Buffer.byteLength(body),
|
||||
};
|
||||
if (password) headers.Authorization = `Basic ${Buffer.from(`${username}:${password}`).toString('base64')}`;
|
||||
const req = transport.request(
|
||||
{
|
||||
protocol: url.protocol,
|
||||
hostname: url.hostname,
|
||||
port: url.port,
|
||||
method: 'POST',
|
||||
path: `${url.pathname}${url.search}`,
|
||||
rejectUnauthorized: false,
|
||||
headers,
|
||||
timeout: timeoutMs,
|
||||
},
|
||||
(res) => {
|
||||
let text = '';
|
||||
res.on('data', (c) => (text += c));
|
||||
res.on('error', () => resolve({ status: 0 }));
|
||||
res.on('close', () => resolve({ status: 0 }));
|
||||
res.on('end', () => {
|
||||
let error: string | undefined;
|
||||
let delivery: { ok: boolean; error?: string } | undefined;
|
||||
try {
|
||||
const body = JSON.parse(text) as { error?: string; data?: { ok?: unknown; error?: string } };
|
||||
error = body.error;
|
||||
// A 200 only says Codeman took the request; `data.ok` says whether Discord took the post.
|
||||
if (typeof body.data?.ok === 'boolean') delivery = { ok: body.data.ok, error: body.data.error };
|
||||
} catch {
|
||||
/* not JSON */
|
||||
}
|
||||
resolve({ status: res.statusCode ?? 0, error, delivery });
|
||||
});
|
||||
}
|
||||
);
|
||||
// A server that takes the connection and never answers must not hang the agent: status 0 is "no answer".
|
||||
req.on('timeout', () => req.destroy());
|
||||
req.on('error', () => resolve({ status: 0 }));
|
||||
req.write(body);
|
||||
req.end();
|
||||
});
|
||||
};
|
||||
|
||||
export async function runNotify(
|
||||
opts: NotifyOptions,
|
||||
env: NodeJS.ProcessEnv,
|
||||
cwd: string,
|
||||
post: JsonPost = postJson
|
||||
): Promise<NotifyResult> {
|
||||
const sessionId = opts.session || env.CODEMAN_SESSION_ID;
|
||||
const apiUrl = opts.url || env.CODEMAN_API_URL;
|
||||
if (!sessionId || !apiUrl) {
|
||||
return { code: 1, err: 'notify must run inside a Codeman session (CODEMAN_SESSION_ID and CODEMAN_API_URL)' };
|
||||
}
|
||||
const payload: { message: string; urgent?: boolean; file?: string } = { message: opts.message };
|
||||
if (opts.urgent) payload.urgent = true;
|
||||
if (opts.file) payload.file = isAbsolute(opts.file) ? opts.file : join(cwd, opts.file);
|
||||
const { status, error, delivery } = await post(
|
||||
apiUrl,
|
||||
`/api/sessions/${encodeURIComponent(sessionId)}/discord-notify`,
|
||||
payload
|
||||
);
|
||||
if (status >= 200 && status < 300) {
|
||||
if (delivery?.ok === false) {
|
||||
return { code: 1, err: `Could not post to Discord${delivery.error ? `: ${delivery.error}` : ''}` };
|
||||
}
|
||||
return { code: 0, out: 'Posted to Discord' };
|
||||
}
|
||||
// The bridge being off is not a failure: the caller just says it in the terminal.
|
||||
if (status === 409) return { code: 0, out: 'The Discord bridge is off. Say it in the terminal instead.' };
|
||||
if (status === 429)
|
||||
return { code: 1, err: 'Too many messages to Discord from this tab. Wait a minute, then send one.' };
|
||||
return { code: 1, err: `Could not post to Discord${error ? `: ${error}` : status ? ` (HTTP ${status})` : ''}` };
|
||||
}
|
||||
+159
-14
@@ -16,10 +16,22 @@ import { isAbsolute, join } from 'node:path';
|
||||
import { homedir } from 'node:os';
|
||||
import { dataPath } from './config/instance.js';
|
||||
import { readCodemanCredentials } from './codeman-credentials.js';
|
||||
import { runNotify } from './cli-notify.js';
|
||||
import { casePath } from './config/cases-dir.js';
|
||||
import { assertValidBasePath } from './config/base-path.js';
|
||||
import { installAgentSkillInto, removeAgentSkillFrom, type AgentSkillApplyResult } from './hooks-config.js';
|
||||
import {
|
||||
agentSkillDir,
|
||||
agentSkillDirFor,
|
||||
CLAUDE_AGENT_SKILL_DIR,
|
||||
installAgentSkillInto,
|
||||
removeAgentSkillFrom,
|
||||
type AgentSkillApplyResult,
|
||||
} from './hooks-config.js';
|
||||
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';
|
||||
import { getRalphLoop } from './ralph-loop.js';
|
||||
@@ -81,6 +93,22 @@ async function postAttachment(apiUrl: string, sessionId: string, filePath: strin
|
||||
});
|
||||
}
|
||||
|
||||
program
|
||||
.command('notify <message>')
|
||||
.description(
|
||||
"Post a message to this tab's Discord thread (needs the user: a physical step, a blocker, a timed-out wait)"
|
||||
)
|
||||
.option('--urgent', 'Ping the user')
|
||||
.option('--file <path>', 'Send a file from the session folder with the message')
|
||||
.option('-s, --session <id>', 'Codeman session ID (defaults to CODEMAN_SESSION_ID)')
|
||||
.option('--url <url>', 'Codeman API URL (defaults to CODEMAN_API_URL)')
|
||||
.action(async (message: string, options) => {
|
||||
const { code, out, err } = await runNotify({ message, ...options }, process.env, process.cwd());
|
||||
if (out) console.log(code === 0 && out.startsWith('Posted') ? palette.ok(`✓ ${out}`) : out);
|
||||
if (err) console.error(palette.err(`✗ ${err}`));
|
||||
process.exit(code);
|
||||
});
|
||||
|
||||
program
|
||||
.command('attach <path>')
|
||||
.description('Show an attachment card for a local file')
|
||||
@@ -140,7 +168,8 @@ export function resolveCliCasePath(name: string): string {
|
||||
/**
|
||||
* Resolve where `skill install` / `skill uninstall` operate. Global is
|
||||
* `~/.claude/skills/codeman` (Claude Code's user-scope skill dir, read by every new
|
||||
* session); `--case <name>` targets `<case>/.claude/skills/codeman`, resolved through
|
||||
* session); `--case <name>` targets `<case>/<skillDir>/codeman` for the given skills
|
||||
* directory (default: Claude Code's, `<case>/.claude/skills/codeman`), resolved through
|
||||
* `resolveCliCasePath()` above. The web server's automatic per-case injection
|
||||
* (`agentSkillEnabled`) covers multi-user spaces; this CLI is a local operator tool
|
||||
* and stays single-user.
|
||||
@@ -151,17 +180,18 @@ export function resolveCliCasePath(name: string): string {
|
||||
*/
|
||||
export function resolveSkillTargetPath(options: {
|
||||
case?: string;
|
||||
skillDir?: AgentSkillDir;
|
||||
}): { target: string; missingCase?: undefined } | { target?: undefined; missingCase: string } {
|
||||
if (options.case) {
|
||||
const casePath = resolveCliCasePath(options.case);
|
||||
if (!existsSync(casePath)) return { missingCase: casePath };
|
||||
return { target: join(casePath, '.claude', 'skills', 'codeman') };
|
||||
return { target: agentSkillDirFor(casePath, options.skillDir ?? CLAUDE_AGENT_SKILL_DIR) };
|
||||
}
|
||||
return { target: join(homedir(), '.claude', 'skills', 'codeman') };
|
||||
}
|
||||
|
||||
/** Exit-owning wrapper around `resolveSkillTargetPath()` for the two commands below. */
|
||||
function resolveSkillTarget(options: { case?: string }): string {
|
||||
function resolveSkillTarget(options: { case?: string; skillDir?: AgentSkillDir }): string {
|
||||
const resolved = resolveSkillTargetPath(options);
|
||||
if (resolved.missingCase !== undefined) {
|
||||
console.error(palette.err(`✗ Case not found: ${resolved.missingCase}`));
|
||||
@@ -170,8 +200,44 @@ function resolveSkillTarget(options: { case?: string }): string {
|
||||
return resolved.target;
|
||||
}
|
||||
|
||||
/** Print an AgentSkillApplyResult for humans; exit non-zero when nothing was done. */
|
||||
function reportSkillResult(result: AgentSkillApplyResult, target: string): void {
|
||||
/**
|
||||
* The skills directory `skill install --mode <mode>` writes into, or why it cannot: a
|
||||
* mode the registry gives no skill, or a user-level install for a CLI other than Claude
|
||||
* Code (each CLI keeps its user-level skills somewhere else, and `npx skills add`
|
||||
* already knows every one of those places).
|
||||
*/
|
||||
export function resolveSkillInstallDir(options: {
|
||||
mode?: string;
|
||||
case?: string;
|
||||
}): { skillDir: AgentSkillDir; error?: undefined } | { skillDir?: undefined; error: string } {
|
||||
const mode = options.mode ?? 'claude';
|
||||
const cli = getCli(mode);
|
||||
if (!cli) return { error: `Unknown mode: ${mode}` };
|
||||
const skillDir = agentSkillDir(cli.capabilities);
|
||||
if (!skillDir) return { error: `Mode ${mode} takes no Codeman agent skill` };
|
||||
if (!options.case && skillDir !== CLAUDE_AGENT_SKILL_DIR) {
|
||||
return {
|
||||
error: `A user-level install for ${mode} goes through the skills installer: npx skills add Ark0N/Codeman --skill codeman -g (or pass --case <name>)`,
|
||||
};
|
||||
}
|
||||
return { skillDir };
|
||||
}
|
||||
|
||||
/**
|
||||
* Every distinct skills directory the registry can inject into, so
|
||||
* `skill uninstall --case` removes whatever any CLI's session left there.
|
||||
*/
|
||||
export function registryAgentSkillDirs(): AgentSkillDir[] {
|
||||
const dirs = new Set<AgentSkillDir>();
|
||||
for (const cli of listClis()) {
|
||||
const dir = agentSkillDir(cli.capabilities);
|
||||
if (dir) dirs.add(dir);
|
||||
}
|
||||
return [...dirs];
|
||||
}
|
||||
|
||||
/** An AgentSkillApplyResult in words, and whether it counts as success. */
|
||||
function describeSkillResult(result: AgentSkillApplyResult, target: string): { ok: boolean; text: string } {
|
||||
const messages: Record<AgentSkillApplyResult, { ok: boolean; text: string }> = {
|
||||
installed: { ok: true, text: `Agent skill installed: ${target}` },
|
||||
refreshed: { ok: true, text: `Agent skill refreshed (was stale): ${target}` },
|
||||
@@ -187,7 +253,12 @@ function reportSkillResult(result: AgentSkillApplyResult, target: string): void
|
||||
text: `${target} (or its parent) is a symlink, refusing to write through it.`,
|
||||
},
|
||||
};
|
||||
const message = messages[result];
|
||||
return messages[result];
|
||||
}
|
||||
|
||||
/** Print an AgentSkillApplyResult for humans; exit non-zero when nothing was done. */
|
||||
function reportSkillResult(result: AgentSkillApplyResult, target: string): void {
|
||||
const message = describeSkillResult(result, target);
|
||||
if (message.ok) {
|
||||
console.log(palette.ok(`✓ ${message.text}`));
|
||||
} else {
|
||||
@@ -205,9 +276,19 @@ skillCmd
|
||||
.description('Install the agent skill globally (~/.claude/skills/codeman) or into one case')
|
||||
.option('-g, --global', 'Install into ~/.claude/skills/codeman, picked up by every new session (the default)')
|
||||
.option('-c, --case <name>', 'Install into <case>/.claude/skills/codeman instead (linked cases resolve too)')
|
||||
.action(async (options: { global?: boolean; case?: string }) => {
|
||||
.option(
|
||||
'-m, --mode <mode>',
|
||||
'With --case: install where that run mode looks for skills (codex: <case>/.agents/skills/codeman)',
|
||||
'claude'
|
||||
)
|
||||
.action(async (options: { global?: boolean; case?: string; mode?: string }) => {
|
||||
try {
|
||||
const target = resolveSkillTarget(options);
|
||||
const resolved = resolveSkillInstallDir(options);
|
||||
if (resolved.error !== undefined) {
|
||||
console.error(palette.err(`✗ ${resolved.error}`));
|
||||
process.exit(1);
|
||||
}
|
||||
const target = resolveSkillTarget({ case: options.case, skillDir: resolved.skillDir });
|
||||
reportSkillResult(await installAgentSkillInto(target), target);
|
||||
} catch (err) {
|
||||
console.error(palette.err(`✗ Failed to install agent skill: ${getErrorMessage(err)}`));
|
||||
@@ -217,13 +298,41 @@ skillCmd
|
||||
|
||||
skillCmd
|
||||
.command('uninstall')
|
||||
.description('Remove a Codeman-managed agent skill copy (never touches a user-authored one)')
|
||||
.description('Remove Codeman-managed agent skill copies (never touches a user-authored one)')
|
||||
.option('-g, --global', 'Remove from ~/.claude/skills/codeman (the default)')
|
||||
.option('-c, --case <name>', 'Remove from <case>/.claude/skills/codeman instead (linked cases resolve too)')
|
||||
.option(
|
||||
'-c, --case <name>',
|
||||
"Remove every Codeman-managed copy from that case instead, whichever CLI's directory it is in (linked cases resolve too)"
|
||||
)
|
||||
.action(async (options: { global?: boolean; case?: string }) => {
|
||||
try {
|
||||
const target = resolveSkillTarget(options);
|
||||
reportSkillResult(await removeAgentSkillFrom(target), target);
|
||||
if (!options.case) {
|
||||
const target = resolveSkillTarget(options);
|
||||
reportSkillResult(await removeAgentSkillFrom(target), target);
|
||||
return;
|
||||
}
|
||||
let failed = false;
|
||||
let removedAny = false;
|
||||
for (const skillDir of registryAgentSkillDirs()) {
|
||||
const target = resolveSkillTarget({ case: options.case, skillDir });
|
||||
const result = await removeAgentSkillFrom(target);
|
||||
if (result === 'absent') continue;
|
||||
if (result === 'symlink') {
|
||||
// A shared-skills layout (`.agents/skills -> ../.claude/skills`) is deliberate:
|
||||
// skip it rather than fail the whole sweep.
|
||||
console.log(palette.warn(`- Skipped ${target}: it (or its parent) is a symlink, so it was not touched`));
|
||||
continue;
|
||||
}
|
||||
removedAny = true;
|
||||
const message = describeSkillResult(result, target);
|
||||
if (message.ok) console.log(palette.ok(`✓ ${message.text}`));
|
||||
else {
|
||||
console.error(palette.err(`✗ ${message.text}`));
|
||||
failed = true;
|
||||
}
|
||||
}
|
||||
if (!removedAny) console.log(palette.ok(`✓ No Codeman-managed agent skill in case ${options.case}`));
|
||||
if (failed) process.exit(1);
|
||||
} catch (err) {
|
||||
console.error(palette.err(`✗ Failed to remove agent skill: ${getErrorMessage(err)}`));
|
||||
process.exit(1);
|
||||
@@ -233,6 +342,26 @@ skillCmd
|
||||
// ============ Agent Commands (session-to-session, any CLI mode) ============
|
||||
|
||||
registerAgentCommands(program);
|
||||
registerHistoryCommands(program);
|
||||
|
||||
// ============ MCP server (any MCP client drives Codeman) ============
|
||||
|
||||
program
|
||||
.command('mcp')
|
||||
.description(
|
||||
'Run a Model Context Protocol server on stdio, so MCP clients (Claude Code, Claude Desktop, Codex, Gemini CLI, Cursor, ...) can start, prompt, wait on, read and delete Codeman sessions'
|
||||
)
|
||||
.option('--url <url>', 'Codeman server URL (default: $CODEMAN_API_URL; never guessed)')
|
||||
.option(
|
||||
'--allow-any-session',
|
||||
'Let the tools prompt, interrupt and delete sessions this server did not start (default: only its own)'
|
||||
)
|
||||
.action(async (options: { url?: string; allowAnySession?: boolean }) => {
|
||||
await runMcpStdio(
|
||||
resolveMcpConfig({ url: options.url, allowAnySession: options.allowAnySession, version: pkg.version })
|
||||
);
|
||||
process.exit(0);
|
||||
});
|
||||
|
||||
// ============ Session Commands ============
|
||||
|
||||
@@ -840,6 +969,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)'
|
||||
@@ -854,6 +987,7 @@ function toWebLaunchOptions(options: {
|
||||
baseUrl?: string;
|
||||
titleHostname?: string;
|
||||
allowUnauthenticatedNetwork?: boolean;
|
||||
allowUnauthenticatedTunnel?: boolean;
|
||||
multiuser?: boolean;
|
||||
}): WebLaunchOptions {
|
||||
const port = parseInt(options.port, 10);
|
||||
@@ -875,6 +1009,7 @@ function toWebLaunchOptions(options: {
|
||||
basePath,
|
||||
titleHostname: options.titleHostname,
|
||||
allowUnauthenticatedNetwork: !!options.allowUnauthenticatedNetwork,
|
||||
allowUnauthenticatedTunnel: !!options.allowUnauthenticatedTunnel,
|
||||
multiuser: !!options.multiuser,
|
||||
};
|
||||
}
|
||||
@@ -962,6 +1097,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;
|
||||
@@ -976,7 +1112,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'));
|
||||
}
|
||||
|
||||
@@ -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
|
||||
* (`<environment_context>`, `<recommended_plugins>`, `<subagent_notification>`,
|
||||
* `<skill>`, `<turn_aborted>`, `<image ...>`, `<codex_internal_context ...>`).
|
||||
* 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 (`<recommended_plugins>`
|
||||
* 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<string, unknown> {
|
||||
return value && typeof value === 'object' && !Array.isArray(value) ? (value as Record<string, unknown>) : {};
|
||||
}
|
||||
|
||||
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' };
|
||||
}
|
||||
+16
-53
@@ -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<string> {
|
||||
const fh = await open(path, 'r').catch(() => null);
|
||||
@@ -176,55 +178,16 @@ async function readTail(path: string, size: number, bytes: number): Promise<stri
|
||||
}
|
||||
}
|
||||
|
||||
/** Flatten codex's message content, which is a string or an array of text blocks. */
|
||||
function contentText(content: unknown): string {
|
||||
if (typeof content === 'string') return content.trim();
|
||||
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)
|
||||
.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. */
|
||||
|
||||
@@ -0,0 +1,950 @@
|
||||
/**
|
||||
* @fileoverview Config backups: timestamped snapshots of the files that hold what a user has
|
||||
* customized, kept in a default-but-configurable folder with configurable retention.
|
||||
*
|
||||
* Why it exists: those files are plain JSON in the data dir, rewritten by the server, the Settings UI,
|
||||
* the CLI and (once, by a mis-configured test run) by things that should never touch them, and there
|
||||
* was no copy to go back to. A snapshot is taken at startup and whenever the tracked files' content
|
||||
* changes, so the history is one entry per actual change rather than one per timer tick.
|
||||
*
|
||||
* Safety rules the code enforces rather than documents:
|
||||
* - The tracked file list is a fixed allowlist, never "everything in the data dir".
|
||||
* - Retention only ever deletes folders this module made (name pattern AND a parseable manifest), so
|
||||
* pointing the backup folder at a directory that holds other things cannot delete them.
|
||||
* - The newest snapshot is never pruned, whatever the retention numbers say.
|
||||
* - A snapshot is built in a temp folder and renamed into place, so a crash leaves no half snapshot.
|
||||
* - Restore verifies each file against the manifest's SHA-256 and takes a `pre-restore` snapshot first.
|
||||
* - Snapshot folders are 0700 and their files 0600: some tracked files carry credentials.
|
||||
*
|
||||
* Pure of the server: every function takes the directories it works in.
|
||||
*
|
||||
* @module config-backup
|
||||
*/
|
||||
|
||||
import { createHash, randomUUID } from 'node:crypto';
|
||||
import {
|
||||
chmodSync,
|
||||
existsSync,
|
||||
lstatSync,
|
||||
mkdirSync,
|
||||
readdirSync,
|
||||
readFileSync,
|
||||
renameSync,
|
||||
rmSync,
|
||||
statSync,
|
||||
writeFileSync,
|
||||
} from 'node:fs';
|
||||
import { homedir } from 'node:os';
|
||||
import { isAbsolute, join, resolve, sep } from 'node:path';
|
||||
|
||||
/**
|
||||
* The files (relative to the data dir) a snapshot holds: what a user configured, not what the server
|
||||
* regenerates (session state, logs, the hook secret, docker seeds, caches).
|
||||
*
|
||||
* ⚠️ Not tracked: `state.json`. It also holds cron jobs and respawn presets, but it is rewritten with
|
||||
* every session event, so tracking it would make a snapshot per event and bury the real changes.
|
||||
*
|
||||
* ⚠️ `.env`, `users.json`, `custom-model-hosts.json` and `push-keys.json` carry credentials or keys.
|
||||
* They are already in the data dir with the same permissions; a snapshot folder is 0700 with 0600
|
||||
* files, so the default location widens nothing. A custom location is the user's own choice.
|
||||
*/
|
||||
export const CONFIG_BACKUP_FILES = [
|
||||
'settings.json',
|
||||
'clis.json',
|
||||
'custom-model-hosts.json',
|
||||
'webhook.json',
|
||||
'discord.json',
|
||||
'intents.json',
|
||||
'linked-cases.json',
|
||||
'remote-hosts.json',
|
||||
'remote-cases.json',
|
||||
'docker-hosts.json',
|
||||
'docker-cases.json',
|
||||
'webviews.json',
|
||||
'push-keys.json',
|
||||
'users.json',
|
||||
'.env',
|
||||
] as const;
|
||||
|
||||
export const CONFIG_BACKUP_DEFAULTS = { enabled: true, keepCount: 20, keepDays: 30 } as const;
|
||||
/** 1 would delete the state before every change, so a bad write could never be undone. */
|
||||
export const CONFIG_BACKUP_MIN_KEEP_COUNT = 2;
|
||||
export const CONFIG_BACKUP_MAX_KEEP_COUNT = 500;
|
||||
export const CONFIG_BACKUP_MAX_KEEP_DAYS = 3650;
|
||||
|
||||
/** `cfg-YYYYMMDD-HHMMSS`, plus `-2`, `-3`… if two land in one second. */
|
||||
const SNAPSHOT_ID_RE = /^cfg-\d{8}-\d{6}(?:-\d{1,3})?$/;
|
||||
const MANIFEST = 'manifest.json';
|
||||
|
||||
export interface ConfigBackupSettings {
|
||||
enabled: boolean;
|
||||
/** Absolute folder, or null for the default (`<data dir>/backups/config`). */
|
||||
dir: string | null;
|
||||
/** Keep at most this many snapshots (newest first). 2–500. */
|
||||
keepCount: number;
|
||||
/** Also drop snapshots older than this many days; 0 = no age limit. */
|
||||
keepDays: number;
|
||||
}
|
||||
|
||||
export interface ConfigBackupFile {
|
||||
name: string;
|
||||
bytes: number;
|
||||
sha256: string;
|
||||
}
|
||||
|
||||
export interface ConfigBackupManifest {
|
||||
id: string;
|
||||
createdAt: number;
|
||||
reason: string;
|
||||
appVersion: string;
|
||||
/** Digest of the set of files, so an unchanged state is not snapshotted twice. */
|
||||
hash: string;
|
||||
/** Id of the data dir that made it (see {@link configBackupOrigin}), so instances sharing one folder keep to their own snapshots. */
|
||||
origin?: string;
|
||||
/**
|
||||
* The retention in force when it was made. If `settings.json` later fails to parse or is reset,
|
||||
* the next pass prunes with these instead of the defaults, so one bad write cannot shorten a
|
||||
* history the user asked to keep.
|
||||
*/
|
||||
keepCount?: number;
|
||||
keepDays?: number;
|
||||
files: ConfigBackupFile[];
|
||||
}
|
||||
|
||||
export interface ConfigBackupSummary {
|
||||
id: string;
|
||||
createdAt: number;
|
||||
reason: string;
|
||||
appVersion: string;
|
||||
files: Array<{ name: string; bytes: number }>;
|
||||
bytes: number;
|
||||
}
|
||||
|
||||
const intIn = (value: unknown, min: number, max: number, fallback: number): number => {
|
||||
const n = typeof value === 'number' ? value : typeof value === 'string' && value.trim() ? Number(value) : NaN;
|
||||
if (!Number.isFinite(n)) return fallback;
|
||||
return Math.min(max, Math.max(min, Math.floor(n)));
|
||||
};
|
||||
|
||||
/** `~`-expand and require an absolute path; anything else means "use the default". */
|
||||
export function normalizeBackupDir(raw: unknown, home: string = homedir()): string | null {
|
||||
if (typeof raw !== 'string') return null;
|
||||
let value = raw.trim();
|
||||
if (!value || value.includes('\0')) return null;
|
||||
if (value === '~') value = home;
|
||||
else if (value.startsWith('~/')) value = join(home, value.slice(2));
|
||||
if (!isAbsolute(value)) return null;
|
||||
return resolve(value);
|
||||
}
|
||||
|
||||
/** Read the four `configBackup*` settings out of a settings.json object, clamped and defaulted. */
|
||||
export function readConfigBackupSettings(raw: Record<string, unknown> | null | undefined): ConfigBackupSettings {
|
||||
const s = raw ?? {};
|
||||
return {
|
||||
// Absent means ON: the feature protects against an event nobody schedules.
|
||||
enabled: s.configBackupEnabled !== false,
|
||||
dir: normalizeBackupDir(s.configBackupDir),
|
||||
keepCount: intIn(
|
||||
s.configBackupKeepCount,
|
||||
CONFIG_BACKUP_MIN_KEEP_COUNT,
|
||||
CONFIG_BACKUP_MAX_KEEP_COUNT,
|
||||
CONFIG_BACKUP_DEFAULTS.keepCount
|
||||
),
|
||||
keepDays: intIn(s.configBackupKeepDays, 0, CONFIG_BACKUP_MAX_KEEP_DAYS, CONFIG_BACKUP_DEFAULTS.keepDays),
|
||||
};
|
||||
}
|
||||
|
||||
export function defaultConfigBackupDir(dataDir: string): string {
|
||||
return join(dataDir, 'backups', 'config');
|
||||
}
|
||||
|
||||
/**
|
||||
* The folder snapshots go to: the configured one, or the default. A custom folder that IS the data dir
|
||||
* falls back to the default, because snapshot folders and the live files must not share a directory.
|
||||
*/
|
||||
export function resolveConfigBackupDir(settings: ConfigBackupSettings, dataDir: string): string {
|
||||
const fallback = defaultConfigBackupDir(dataDir);
|
||||
const dir = settings.dir;
|
||||
if (!dir) return fallback;
|
||||
const data = resolve(dataDir);
|
||||
if (dir === data) return fallback;
|
||||
return dir;
|
||||
}
|
||||
|
||||
const ORIGIN_FILE = 'config-backup-id';
|
||||
const ORIGIN_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/;
|
||||
|
||||
/**
|
||||
* Who owns a snapshot: a random id made once and kept in the data dir. A hostname would not do (a
|
||||
* Docker container gets a new one on every recreate, which orphaned every earlier snapshot), nor
|
||||
* would the path alone (two machines can share a `$HOME` path). It is a file of its own, not the
|
||||
* hook secret, and is not tracked or restored. If it cannot be written the id is not persisted, so
|
||||
* snapshots made meanwhile are not listed later; the data dir being unwritable breaks far more.
|
||||
*/
|
||||
export function configBackupOrigin(dataDir: string): string {
|
||||
const path = join(dataDir, ORIGIN_FILE);
|
||||
try {
|
||||
const existing = readFileSync(path, 'utf8').trim();
|
||||
if (ORIGIN_RE.test(existing)) return existing;
|
||||
} catch {
|
||||
/* missing: made below */
|
||||
}
|
||||
const id = randomUUID();
|
||||
try {
|
||||
mkdirSync(dataDir, { recursive: true });
|
||||
writeFileSync(path, id + '\n', { mode: 0o600 });
|
||||
} catch {
|
||||
/* see above */
|
||||
}
|
||||
return id;
|
||||
}
|
||||
|
||||
/** The id already made for this data dir, or null. Read-only: listing must not write a file. */
|
||||
export function readConfigBackupOrigin(dataDir: string): string | null {
|
||||
try {
|
||||
const existing = readFileSync(join(dataDir, ORIGIN_FILE), 'utf8').trim();
|
||||
return ORIGIN_RE.test(existing) ? existing : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
const sha256 = (data: Buffer | string): string => createHash('sha256').update(data).digest('hex');
|
||||
|
||||
/**
|
||||
* Keys a tracked file rewrites on its own schedule, with no change to anything the user set, so
|
||||
* they must not make a snapshot look different. `custom-model-hosts.json` carries a
|
||||
* `lastDiscoveredAt` per host that the model-discovery poll refreshes every 5 minutes: before this,
|
||||
* the change check never matched and the snapshot folder filled with copies that differed by one
|
||||
* timestamp, evicting every older snapshot within about 100 minutes at the default keep count.
|
||||
* The snapshot itself still stores the file exactly as it was, and restore still verifies against
|
||||
* the file's own digest; only the "has anything changed?" comparison looks through these keys.
|
||||
*/
|
||||
const VOLATILE_KEYS: Readonly<Record<string, readonly string[]>> = {
|
||||
'custom-model-hosts.json': ['lastDiscoveredAt'],
|
||||
// Rewritten by activity, not by anything the user set: a Basic-auth login (at most once a minute
|
||||
// per user), a session started in another case, every captured prompt, and a Docker case's
|
||||
// conversation id after a /clear, and a web tab being opened.
|
||||
'users.json': ['lastLoginAt'],
|
||||
'settings.json': ['lastUsedCase'],
|
||||
'intents.json': ['recentPrompts', 'updatedAt'],
|
||||
'docker-cases.json': ['lastClaudeSessionId'],
|
||||
'webviews.json': ['lastOpenedAt'],
|
||||
};
|
||||
|
||||
function withoutKeys(value: unknown, drop: ReadonlySet<string>): unknown {
|
||||
if (Array.isArray(value)) return value.map((v) => withoutKeys(v, drop));
|
||||
if (value && typeof value === 'object') {
|
||||
const out: Record<string, unknown> = {};
|
||||
for (const [k, v] of Object.entries(value as Record<string, unknown>)) {
|
||||
if (!drop.has(k)) out[k] = withoutKeys(v, drop);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
/** Digest used only to decide whether two states are the same: the file's own, minus its volatile keys. */
|
||||
function changeDigest(name: string, data: Buffer, rawSha: string): string {
|
||||
const volatile = VOLATILE_KEYS[name];
|
||||
if (!volatile) return rawSha;
|
||||
try {
|
||||
return sha256(JSON.stringify(withoutKeys(JSON.parse(data.toString('utf8')), new Set(volatile))));
|
||||
} catch {
|
||||
return rawSha; // not JSON (or unreadable as such): compare the bytes, as for any other file
|
||||
}
|
||||
}
|
||||
|
||||
function stamp(now: number): string {
|
||||
const d = new Date(now);
|
||||
const p = (n: number, w = 2) => String(n).padStart(w, '0');
|
||||
return `${d.getFullYear()}${p(d.getMonth() + 1)}${p(d.getDate())}-${p(d.getHours())}${p(d.getMinutes())}${p(d.getSeconds())}`;
|
||||
}
|
||||
|
||||
function readManifest(dir: string, id: string, origin?: string): ConfigBackupManifest | null {
|
||||
if (!SNAPSHOT_ID_RE.test(id)) return null;
|
||||
try {
|
||||
const m = JSON.parse(readFileSync(join(dir, id, MANIFEST), 'utf8')) as ConfigBackupManifest;
|
||||
if (!m || m.id !== id || !Array.isArray(m.files) || typeof m.createdAt !== 'number' || typeof m.hash !== 'string') {
|
||||
return null;
|
||||
}
|
||||
if (origin !== undefined && m.origin !== origin) return null;
|
||||
return m;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/** A folder's entry names; none when it cannot be read (absent, or not a folder). */
|
||||
function folderEntries(dir: string): string[] {
|
||||
try {
|
||||
return readdirSync(dir);
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The manifests of the snapshots this module made among `names` in `dir`, newest first. Foreign
|
||||
* folders and files are ignored, and so are other instances' snapshots when `origin` is given (see
|
||||
* {@link configBackupOrigin}). Each one is a file read, so a pass does this once and passes the
|
||||
* result on (see {@link runConfigBackupPass}).
|
||||
*/
|
||||
function manifestsIn(dir: string, names: readonly string[], origin?: string): ConfigBackupManifest[] {
|
||||
const out: ConfigBackupManifest[] = [];
|
||||
for (const id of names) {
|
||||
const m = readManifest(dir, id, origin);
|
||||
if (m) out.push(m);
|
||||
}
|
||||
return out.sort((a, b) => b.createdAt - a.createdAt || (a.id < b.id ? 1 : -1));
|
||||
}
|
||||
|
||||
/**
|
||||
* Snapshots this module made in `dir`, newest first. Foreign folders and files are ignored, and so
|
||||
* are other instances' snapshots when `origin` is given (see {@link configBackupOrigin}).
|
||||
*/
|
||||
export function listConfigBackups(dir: string, origin?: string): ConfigBackupSummary[] {
|
||||
return manifestsIn(dir, folderEntries(dir), origin).map((m) => {
|
||||
const files = m.files.map((f) => ({ name: f.name, bytes: f.bytes }));
|
||||
return {
|
||||
id: m.id,
|
||||
createdAt: m.createdAt,
|
||||
reason: m.reason,
|
||||
appVersion: m.appVersion,
|
||||
files,
|
||||
bytes: files.reduce((n, f) => n + f.bytes, 0),
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
export type ConfigBackupResult =
|
||||
| { status: 'created'; id: string; files: number; bytes: number }
|
||||
| { status: 'unchanged'; id: string }
|
||||
| { status: 'empty' };
|
||||
|
||||
export interface CreateConfigBackupOptions {
|
||||
dataDir: string;
|
||||
backupDir: string;
|
||||
reason: string;
|
||||
appVersion?: string;
|
||||
/** Snapshot even if nothing changed (a manual request, a pre-restore safety copy). */
|
||||
force?: boolean;
|
||||
/** Recorded in the manifest, see {@link ConfigBackupManifest.keepCount}. */
|
||||
keepCount?: number;
|
||||
keepDays?: number;
|
||||
now?: number;
|
||||
}
|
||||
|
||||
/** Snapshot the tracked files that exist, unless the latest snapshot already holds exactly this state. */
|
||||
export function createConfigBackup(opts: CreateConfigBackupOptions): ConfigBackupResult {
|
||||
return snapshotConfig(opts).result;
|
||||
}
|
||||
|
||||
/**
|
||||
* {@link createConfigBackup}, also returning the manifest it wrote, over this instance's snapshots
|
||||
* when the caller has already listed them (`listed`, newest first): one walk of the folder per pass.
|
||||
*/
|
||||
function snapshotConfig(
|
||||
opts: CreateConfigBackupOptions,
|
||||
listed?: { origin: string; existing: ConfigBackupManifest[] }
|
||||
): { result: ConfigBackupResult; manifest: ConfigBackupManifest | null } {
|
||||
const now = opts.now ?? Date.now();
|
||||
const present: Array<{ name: string; data: Buffer; sha: string; change: string }> = [];
|
||||
for (const name of CONFIG_BACKUP_FILES) {
|
||||
try {
|
||||
const path = join(opts.dataDir, name);
|
||||
if (!statSync(path).isFile()) continue;
|
||||
const data = readFileSync(path);
|
||||
const sha = sha256(data);
|
||||
present.push({ name, data, sha, change: changeDigest(name, data, sha) });
|
||||
} catch {
|
||||
/* absent or unreadable: simply not part of this snapshot */
|
||||
}
|
||||
}
|
||||
if (present.length === 0) return { result: { status: 'empty' }, manifest: null };
|
||||
|
||||
const hash = sha256(present.map((f) => `${f.name}\0${f.change}`).join('\n'));
|
||||
const origin = listed?.origin ?? configBackupOrigin(opts.dataDir);
|
||||
const existing = listed?.existing ?? manifestsIn(opts.backupDir, folderEntries(opts.backupDir), origin);
|
||||
if (!opts.force && existing[0] && existing[0].hash === hash) {
|
||||
return { result: { status: 'unchanged', id: existing[0].id }, manifest: null };
|
||||
}
|
||||
|
||||
// Never dated earlier than this instance's newest snapshot. One written while the clock read ahead
|
||||
// (a dual-boot machine, a VM resumed with a stale clock) would otherwise stay newest after the
|
||||
// correction, differ from every later pass, and have each one write a copy until real time caught up.
|
||||
const createdAt = existing[0] ? Math.max(now, existing[0].createdAt + 1) : now;
|
||||
mkdirSync(opts.backupDir, { recursive: true, mode: 0o700 });
|
||||
const base = `cfg-${stamp(createdAt)}`;
|
||||
let id = base;
|
||||
for (let n = 2; existsSync(join(opts.backupDir, id)); n++) id = `${base}-${n}`;
|
||||
|
||||
const tmp = join(opts.backupDir, `.tmp-${id}-${process.pid}`);
|
||||
rmSync(tmp, { recursive: true, force: true });
|
||||
mkdirSync(tmp, { mode: 0o700 });
|
||||
const manifest: ConfigBackupManifest = {
|
||||
id,
|
||||
createdAt,
|
||||
reason: opts.reason,
|
||||
appVersion: opts.appVersion ?? '',
|
||||
hash,
|
||||
origin,
|
||||
...(opts.keepCount !== undefined && { keepCount: opts.keepCount }),
|
||||
...(opts.keepDays !== undefined && { keepDays: opts.keepDays }),
|
||||
files: present.map((f) => ({ name: f.name, bytes: f.data.length, sha256: f.sha })),
|
||||
};
|
||||
try {
|
||||
for (const f of present) writeFileSync(join(tmp, f.name), f.data, { mode: 0o600 });
|
||||
writeFileSync(join(tmp, MANIFEST), JSON.stringify(manifest, null, 2) + '\n', { mode: 0o600 });
|
||||
renameSync(tmp, join(opts.backupDir, id));
|
||||
try {
|
||||
chmodSync(join(opts.backupDir, id), 0o700);
|
||||
} catch {
|
||||
/* best effort on filesystems without modes */
|
||||
}
|
||||
} catch (err) {
|
||||
rmSync(tmp, { recursive: true, force: true });
|
||||
throw err;
|
||||
}
|
||||
const bytes = present.reduce((n, f) => n + f.data.length, 0);
|
||||
return { result: { status: 'created', id, files: present.length, bytes }, manifest };
|
||||
}
|
||||
|
||||
/** Remove `.tmp-cfg-*` build folders a crashed snapshot left behind (older than an hour, so a live one on a shared folder is safe). */
|
||||
export function sweepConfigBackupTemps(dir: string, now: number = Date.now()): void {
|
||||
sweepTemps(dir, folderEntries(dir), now);
|
||||
}
|
||||
|
||||
function sweepTemps(dir: string, names: readonly string[], now: number): void {
|
||||
for (const n of names) {
|
||||
if (!/^\.tmp-cfg-\d{8}-\d{6}(?:-\d{1,3})?-\d+$/.test(n)) continue;
|
||||
const st = lstatSync(join(dir, n), { throwIfNoEntry: false });
|
||||
if (st && now - st.mtimeMs > 3_600_000) rmSync(join(dir, n), { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The retention recorded in this instance's newest snapshot, or null when it recorded none. `listed`
|
||||
* is this instance's manifests in `dir`, newest first, when the caller already read them.
|
||||
*/
|
||||
export function latestBackupRetention(
|
||||
dir: string,
|
||||
origin: string,
|
||||
listed?: readonly ConfigBackupManifest[]
|
||||
): { keepCount: number; keepDays: number } | null {
|
||||
// The newest manifest that recorded one: a snapshot from before this field existed must not hide it.
|
||||
for (const m of listed ?? manifestsIn(dir, folderEntries(dir), origin)) {
|
||||
if (typeof m.keepCount === 'number' && typeof m.keepDays === 'number') {
|
||||
return { keepCount: m.keepCount, keepDays: m.keepDays };
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* The settings in effect, with each retention key that `raw` (the parsed settings.json) lacks, as
|
||||
* after a reset, taken from what the newest backup recorded in `dir` instead of the defaults. It
|
||||
* looks at the retention keys only (not the folder), so re-entering a custom folder after a reset
|
||||
* finds that folder's own manifests. One place for the timer pass and for what the GET route
|
||||
* reports, so they cannot disagree.
|
||||
*/
|
||||
export function withRecordedRetention(
|
||||
settings: ConfigBackupSettings,
|
||||
raw: Record<string, unknown>,
|
||||
dir: string,
|
||||
origin: string | null,
|
||||
listed?: readonly ConfigBackupManifest[]
|
||||
): ConfigBackupSettings {
|
||||
const lacksCount = !('configBackupKeepCount' in raw);
|
||||
const lacksDays = !('configBackupKeepDays' in raw);
|
||||
if ((!lacksCount && !lacksDays) || !origin) return settings;
|
||||
const kept = latestBackupRetention(dir, origin, listed);
|
||||
if (!kept) return settings;
|
||||
return {
|
||||
...settings,
|
||||
...(lacksCount && { keepCount: kept.keepCount }),
|
||||
...(lacksDays && { keepDays: kept.keepDays }),
|
||||
};
|
||||
}
|
||||
|
||||
export interface PruneOptions {
|
||||
keepCount: number;
|
||||
keepDays: number;
|
||||
/** Only this instance's snapshots are pruned; omitted = every snapshot in the folder. */
|
||||
origin?: string;
|
||||
now?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply retention: keep the newest `keepCount`, and (when `keepDays` > 0) drop anything older than
|
||||
* that. The newest snapshot always survives. Returns the ids removed.
|
||||
*/
|
||||
export function pruneConfigBackups(dir: string, opts: PruneOptions): string[] {
|
||||
return pruneListed(dir, manifestsIn(dir, folderEntries(dir), opts.origin), opts);
|
||||
}
|
||||
|
||||
/** {@link pruneConfigBackups} over a listing already made (`all`, newest first). */
|
||||
function pruneListed(dir: string, all: ReadonlyArray<{ id: string; createdAt: number }>, opts: PruneOptions): string[] {
|
||||
const now = opts.now ?? Date.now();
|
||||
const cutoff = opts.keepDays > 0 ? now - opts.keepDays * 86_400_000 : null;
|
||||
const removed: string[] = [];
|
||||
all.forEach((b, index) => {
|
||||
if (index === 0) return;
|
||||
const tooMany = index >= Math.max(1, opts.keepCount);
|
||||
// A snapshot's state was live until the next one was made: age it from then. Aging by its own
|
||||
// createdAt deleted the last good copy on the first change after a quiet month.
|
||||
const tooOld = cutoff !== null && all[index - 1].createdAt < cutoff;
|
||||
if (!tooMany && !tooOld) return;
|
||||
// The listing already proved this is one of ours (name pattern + manifest).
|
||||
const target = join(dir, b.id);
|
||||
if (!target.startsWith(resolve(dir) + sep)) return;
|
||||
try {
|
||||
rmSync(target, { recursive: true, force: true });
|
||||
removed.push(b.id);
|
||||
} catch {
|
||||
/* leave it; the next pass retries */
|
||||
}
|
||||
});
|
||||
return removed;
|
||||
}
|
||||
|
||||
export interface ConfigBackupPassOptions {
|
||||
dataDir: string;
|
||||
backupDir: string;
|
||||
reason: string;
|
||||
appVersion?: string;
|
||||
/** Snapshot even if nothing changed (Back up now). */
|
||||
force?: boolean;
|
||||
/** As read from settings.json ({@link readConfigBackupSettings}), before the recorded-retention fallback. */
|
||||
settings: ConfigBackupSettings;
|
||||
/** The parsed settings.json (`{}` when it did not parse): the fallback tells an absent key from a set one. */
|
||||
raw: Record<string, unknown>;
|
||||
/** False when settings.json did not parse: the pass still snapshots, but deletes nothing on its say-so. */
|
||||
prune: boolean;
|
||||
now?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* One pass of the timer or of Back up now: sweep stale temp folders, snapshot when the state changed,
|
||||
* apply retention. The folder is read and each manifest parsed ONCE, and that listing feeds the
|
||||
* recorded-retention fallback, the change check and the prune. It used to be listed for each of the
|
||||
* three, which on a network folder is every manifest read three times every five minutes.
|
||||
*/
|
||||
export function runConfigBackupPass(opts: ConfigBackupPassOptions): {
|
||||
result: ConfigBackupResult;
|
||||
removed: string[];
|
||||
} {
|
||||
const names = folderEntries(opts.backupDir);
|
||||
sweepTemps(opts.backupDir, names, Date.now());
|
||||
const origin = configBackupOrigin(opts.dataDir);
|
||||
const existing = manifestsIn(opts.backupDir, names, origin);
|
||||
// No retention in the file (reset, or unreadable): keep what the newest snapshot recorded.
|
||||
const { keepCount, keepDays } = withRecordedRetention(opts.settings, opts.raw, opts.backupDir, origin, existing);
|
||||
const { result, manifest } = snapshotConfig(
|
||||
{
|
||||
dataDir: opts.dataDir,
|
||||
backupDir: opts.backupDir,
|
||||
reason: opts.reason,
|
||||
appVersion: opts.appVersion,
|
||||
force: opts.force,
|
||||
keepCount,
|
||||
keepDays,
|
||||
now: opts.now,
|
||||
},
|
||||
{ origin, existing }
|
||||
);
|
||||
if (!opts.prune) return { result, removed: [] };
|
||||
// A snapshot made now is dated after existing[0], so it heads the listing retention counts.
|
||||
const all = manifest ? [manifest, ...existing] : existing;
|
||||
return { result, removed: pruneListed(opts.backupDir, all, { keepCount, keepDays, now: opts.now }) };
|
||||
}
|
||||
|
||||
export class ConfigBackupNotFoundError extends Error {}
|
||||
export class ConfigBackupCorruptError extends Error {}
|
||||
|
||||
/**
|
||||
* A restore that wrote some files and could not write others (a full disk, a directory where a file
|
||||
* should be). Every file passed verification first, so the ones written are the backup's: the caller
|
||||
* still has to reload what they feed and tell the user which files are which.
|
||||
*/
|
||||
export class ConfigBackupPartialRestoreError extends Error {
|
||||
constructor(
|
||||
readonly restored: string[],
|
||||
readonly failed: Array<{ name: string; error: string }>,
|
||||
readonly untouched: string[],
|
||||
readonly safetySnapshot: string | null
|
||||
) {
|
||||
const done = restored.length ? `${restored.join(', ')} restored` : 'nothing was restored';
|
||||
const undo = safetySnapshot ? `; the files from before the restore are in backup ${safetySnapshot}` : '';
|
||||
super(
|
||||
`Restore incomplete: could not write ${failed.map((f) => f.name).join(', ')} (${failed[0]?.error}); ${done}${undo}`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Restore refuses what this module never writes. Snapshots are 0700/0600 real files, and a restore
|
||||
* writes the file as 0600: a symlink, or a file with a group/world bit, was planted or loosened
|
||||
* after the snapshot, and rewriting it as 0600 would turn it into trusted config (`clis.json`
|
||||
* selects the binaries Codeman spawns; `isUnsafePermissions` refuses the same bits there).
|
||||
* POSIX only, like `isUnsafePermissions`. The mode test is skipped (the symlink test is not) on a
|
||||
* filesystem that does not keep modes, see {@link folderKeepsModes}.
|
||||
*/
|
||||
function assertTrustedSnapshotEntry(path: string, label: string, checkMode: boolean): void {
|
||||
if (process.platform === 'win32') return;
|
||||
const st = lstatSync(path, { throwIfNoEntry: false });
|
||||
if (!st) return; // missing is reported by the read that follows
|
||||
if (st.isSymbolicLink()) throw new ConfigBackupCorruptError(`${label} is a symlink, so it is not used`);
|
||||
if (checkMode && (st.mode & 0o077) !== 0) {
|
||||
throw new ConfigBackupCorruptError(`${label} is readable or writable by group or others, so it is not used`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether the backup folder's filesystem keeps a file's mode: vfat/exFAT, CIFS shares and WSL's
|
||||
* `/mnt/d` report 0755 or 0777 for everything, so the group/world test would refuse every backup
|
||||
* there even though snapshots succeed (`createConfigBackup` already expects mode-less filesystems).
|
||||
* Probed with a 0600 temp file made with `wx` under a random name; if the probe cannot run, the
|
||||
* strict test stays on.
|
||||
*/
|
||||
function folderKeepsModes(dir: string): boolean {
|
||||
if (process.platform === 'win32') return false;
|
||||
// A random name and 'wx' (fail if anything is there): a planted symlink or directory with a
|
||||
// guessable name must neither be written through nor break the probe. Only a file this call
|
||||
// created is removed.
|
||||
const probe = join(dir, `.mode-probe-${randomUUID()}`);
|
||||
let created = false;
|
||||
try {
|
||||
writeFileSync(probe, '', { mode: 0o600, flag: 'wx' });
|
||||
created = true;
|
||||
chmodSync(probe, 0o600);
|
||||
return (lstatSync(probe).mode & 0o777 & 0o077) === 0;
|
||||
} catch {
|
||||
return true;
|
||||
} finally {
|
||||
if (created) rmSync(probe, { force: true });
|
||||
}
|
||||
}
|
||||
|
||||
/** The folder's own backup settings stay as they are now: restoring an old `configBackupDir` hides the `pre-restore` copy. */
|
||||
const BACKUP_SETTING_KEYS = ['configBackupEnabled', 'configBackupDir', 'configBackupKeepCount', 'configBackupKeepDays'];
|
||||
|
||||
function keepCurrentBackupSettings(snapshot: Buffer, livePath: string): Buffer {
|
||||
try {
|
||||
const restored = JSON.parse(snapshot.toString('utf8')) as Record<string, unknown>;
|
||||
const live = JSON.parse(readFileSync(livePath, 'utf8')) as Record<string, unknown>;
|
||||
if (!restored || typeof restored !== 'object' || !live || typeof live !== 'object') return snapshot;
|
||||
let changed = false;
|
||||
// A reset live file lacks the retention keys. Taking "absent" for them would bring the defaults
|
||||
// back (20 copies, 30 days) on the next pass, so the backup's own retention stays.
|
||||
for (const key of BACKUP_SETTING_KEYS) {
|
||||
if (!(key in live) && (key === 'configBackupKeepCount' || key === 'configBackupKeepDays')) continue;
|
||||
if (JSON.stringify(restored[key]) === JSON.stringify(live[key])) continue;
|
||||
changed = true;
|
||||
if (key in live) restored[key] = live[key];
|
||||
else delete restored[key];
|
||||
}
|
||||
if (!changed) return snapshot; // byte-identical to the snapshot when the folder settings did not move
|
||||
return Buffer.from(JSON.stringify(restored, null, 2) + '\n');
|
||||
} catch {
|
||||
return snapshot; // not JSON, or no live file to take the values from
|
||||
}
|
||||
}
|
||||
|
||||
export interface RestoreResult {
|
||||
restored: string[];
|
||||
/** Tracked files present now that the snapshot did not hold; a restore leaves them as they are. */
|
||||
untouched: string[];
|
||||
/**
|
||||
* The snapshot holding the live files as they were before the restore: the `pre-restore` copy, or the
|
||||
* newest backup when it already held exactly that state. Null only when no tracked file existed.
|
||||
*/
|
||||
safetySnapshot: string | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Put a snapshot's files back into the data dir. Every file is checked against the manifest's digest
|
||||
* BEFORE anything is written, a `pre-restore` snapshot of the live files is taken first (unless the
|
||||
* newest backup already holds that state, which is then reported instead), and each file is replaced
|
||||
* atomically (temp + rename) with mode 0600. A file that cannot be written does not stop the others;
|
||||
* it throws {@link ConfigBackupPartialRestoreError} at the end, naming both lists.
|
||||
*/
|
||||
export function restoreConfigBackup(opts: {
|
||||
dataDir: string;
|
||||
backupDir: string;
|
||||
id: string;
|
||||
appVersion?: string;
|
||||
now?: number;
|
||||
/** Override the probe of whether the backup folder keeps file modes (tests; a mode-less filesystem). */
|
||||
keepsModes?: boolean;
|
||||
}): RestoreResult {
|
||||
const origin = readConfigBackupOrigin(opts.dataDir);
|
||||
const manifest = origin ? readManifest(opts.backupDir, opts.id, origin) : null;
|
||||
if (!manifest || !origin) throw new ConfigBackupNotFoundError(`No config backup named ${opts.id}`);
|
||||
|
||||
const allowed = new Set<string>(CONFIG_BACKUP_FILES);
|
||||
const loaded: Array<{ name: string; data: Buffer }> = [];
|
||||
const checkMode = opts.keepsModes ?? folderKeepsModes(opts.backupDir);
|
||||
assertTrustedSnapshotEntry(join(opts.backupDir, opts.id), `Backup ${opts.id}`, checkMode);
|
||||
for (const f of manifest.files) {
|
||||
if (!allowed.has(f.name)) continue;
|
||||
let data: Buffer;
|
||||
assertTrustedSnapshotEntry(join(opts.backupDir, opts.id, f.name), `${f.name} in ${opts.id}`, checkMode);
|
||||
try {
|
||||
data = readFileSync(join(opts.backupDir, opts.id, f.name));
|
||||
} catch {
|
||||
throw new ConfigBackupCorruptError(`${f.name} is missing from ${opts.id}`);
|
||||
}
|
||||
if (sha256(data) !== f.sha256)
|
||||
throw new ConfigBackupCorruptError(`${f.name} in ${opts.id} does not match its checksum`);
|
||||
loaded.push({ name: f.name, data });
|
||||
}
|
||||
if (loaded.length === 0) throw new ConfigBackupCorruptError(`${opts.id} holds no restorable files`);
|
||||
|
||||
// One walk of the folder serves both the recorded retention and the snapshot below.
|
||||
const existing = manifestsIn(opts.backupDir, folderEntries(opts.backupDir), origin);
|
||||
const { result: safety } = snapshotConfig(
|
||||
{
|
||||
dataDir: opts.dataDir,
|
||||
backupDir: opts.backupDir,
|
||||
reason: 'pre-restore',
|
||||
appVersion: opts.appVersion,
|
||||
// Not forced: when the live files equal the newest backup, that backup already is the undo copy,
|
||||
// and a forced duplicate would spend a retention slot on nothing.
|
||||
now: opts.now,
|
||||
// The retention in force, so this newest snapshot does not hide it from the next pass.
|
||||
...(latestBackupRetention(opts.backupDir, origin, existing) ?? {}),
|
||||
},
|
||||
{ origin, existing }
|
||||
);
|
||||
|
||||
mkdirSync(opts.dataDir, { recursive: true });
|
||||
const restored: string[] = [];
|
||||
const failed: Array<{ name: string; error: string }> = [];
|
||||
for (const f of loaded) {
|
||||
const target = join(opts.dataDir, f.name);
|
||||
const tmp = `${target}.restore-${process.pid}`;
|
||||
try {
|
||||
writeFileSync(tmp, f.name === 'settings.json' ? keepCurrentBackupSettings(f.data, target) : f.data, {
|
||||
mode: 0o600,
|
||||
});
|
||||
renameSync(tmp, target);
|
||||
restored.push(f.name);
|
||||
} catch (err) {
|
||||
try {
|
||||
rmSync(tmp, { force: true });
|
||||
} catch {
|
||||
/* the temp is a leftover at worst; the failure below is what matters */
|
||||
}
|
||||
// Keep going: each file is replaced on its own, and the files written before this one are the
|
||||
// backup's already. Stopping here used to answer a bare 500 that named neither list.
|
||||
failed.push({ name: f.name, error: err instanceof Error ? err.message : String(err) });
|
||||
}
|
||||
}
|
||||
// A restore never deletes: tracked files the snapshot did not hold stay as they are.
|
||||
const untouched = CONFIG_BACKUP_FILES.filter(
|
||||
(n) =>
|
||||
!restored.includes(n) &&
|
||||
!failed.some((x) => x.name === n) &&
|
||||
lstatSync(join(opts.dataDir, n), { throwIfNoEntry: false })?.isFile()
|
||||
);
|
||||
const safetySnapshot = safety.status === 'empty' ? null : safety.id;
|
||||
if (failed.length) throw new ConfigBackupPartialRestoreError(restored, failed, untouched, safetySnapshot);
|
||||
return { restored, untouched, safetySnapshot };
|
||||
}
|
||||
|
||||
/** The live files in the data dir, as a side of a diff. */
|
||||
export const CONFIG_DIFF_CURRENT = 'current';
|
||||
|
||||
export interface ConfigDiffChange {
|
||||
/** Dotted path inside the file (`a.b[0].c`), or the variable name for `.env`. */
|
||||
path: string;
|
||||
kind: 'added' | 'removed' | 'changed';
|
||||
/** JSON text of the value; absent when it did not exist on that side or `redacted` is set. */
|
||||
before?: string;
|
||||
after?: string;
|
||||
/** The value is a credential, so only the fact that it differs is reported. */
|
||||
redacted?: boolean;
|
||||
}
|
||||
|
||||
export interface ConfigDiffFile {
|
||||
name: string;
|
||||
/** `added` = only in `to`, `removed` = only in `from`. */
|
||||
status: 'added' | 'removed' | 'changed' | 'unchanged';
|
||||
changes: ConfigDiffChange[];
|
||||
/** More changes than `MAX_DIFF_CHANGES`; the rest are not listed. */
|
||||
truncated: boolean;
|
||||
/** `keys` = per-key changes; `bytes` = the file is not JSON/env, so only "differs" is known. */
|
||||
detail: 'keys' | 'bytes';
|
||||
}
|
||||
|
||||
export interface ConfigDiff {
|
||||
from: string;
|
||||
to: string;
|
||||
files: ConfigDiffFile[];
|
||||
/** Files whose status is not `unchanged`. */
|
||||
changed: number;
|
||||
}
|
||||
|
||||
const MAX_DIFF_CHANGES = 200;
|
||||
const MAX_DIFF_VALUE_CHARS = 200;
|
||||
|
||||
/** Files whose every value is a credential or key: only names are ever shown. */
|
||||
const REDACT_ALL_FILES: ReadonlySet<string> = new Set([
|
||||
'.env',
|
||||
'users.json',
|
||||
'push-keys.json',
|
||||
'webhook.json',
|
||||
'discord.json',
|
||||
'intents.json',
|
||||
]);
|
||||
/** Elsewhere a value is hidden when its own key looks like a credential. */
|
||||
const SECRET_KEY_RE = /(token|secret|passw|credential|auth|key$)/i;
|
||||
|
||||
type Leaves = Map<string, string>;
|
||||
|
||||
function flattenJson(value: unknown, path: string, out: Leaves): void {
|
||||
if (Array.isArray(value) && value.length > 0) {
|
||||
value.forEach((v, i) => flattenJson(v, `${path}[${i}]`, out));
|
||||
} else if (value && typeof value === 'object' && Object.keys(value).length > 0) {
|
||||
for (const [k, v] of Object.entries(value as Record<string, unknown>)) {
|
||||
flattenJson(v, path ? `${path}.${k}` : k, out);
|
||||
}
|
||||
} else {
|
||||
out.set(path || '(root)', JSON.stringify(value) ?? 'null');
|
||||
}
|
||||
}
|
||||
|
||||
function parseEnv(text: string): Leaves {
|
||||
const out: Leaves = new Map();
|
||||
for (const line of text.split(/\r?\n/)) {
|
||||
const m = /^\s*(?:export\s+)?([A-Za-z_][A-Za-z0-9_.-]*)\s*=(.*)$/.exec(line);
|
||||
if (m) out.set(m[1], m[2].trim());
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** Per-key view of a tracked file, or null when it is not JSON/env text. */
|
||||
function leavesOf(name: string, data: Buffer): Leaves | null {
|
||||
const text = data.toString('utf8');
|
||||
if (name === '.env') return parseEnv(text);
|
||||
try {
|
||||
const parsed = JSON.parse(text) as unknown;
|
||||
const volatile = VOLATILE_KEYS[name];
|
||||
const out: Leaves = new Map();
|
||||
flattenJson(volatile ? withoutKeys(parsed, new Set(volatile)) : parsed, '', out);
|
||||
return out;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/** A URL value loses its userinfo, query and fragment: they carry passwords and tokens. */
|
||||
function scrubUrl(jsonText: string): string {
|
||||
try {
|
||||
const v = JSON.parse(jsonText) as unknown;
|
||||
if (typeof v !== 'string' || !/^[a-z][a-z0-9+.-]*:\/\//i.test(v)) return jsonText;
|
||||
const u = new URL(v);
|
||||
if (!u.username && !u.password && !u.search && !u.hash) return jsonText;
|
||||
return JSON.stringify(`${u.protocol}//${u.host}${u.pathname}`);
|
||||
} catch {
|
||||
return jsonText;
|
||||
}
|
||||
}
|
||||
|
||||
const clip = (s: string): string => (s.length > MAX_DIFF_VALUE_CHARS ? `${s.slice(0, MAX_DIFF_VALUE_CHARS)}…` : s);
|
||||
|
||||
function diffLeaves(name: string, a: Leaves, b: Leaves): { changes: ConfigDiffChange[]; truncated: boolean } {
|
||||
const all = REDACT_ALL_FILES.has(name);
|
||||
const changes: ConfigDiffChange[] = [];
|
||||
for (const path of [...new Set([...a.keys(), ...b.keys()])].sort()) {
|
||||
const before = a.get(path);
|
||||
const after = b.get(path);
|
||||
if (before === after) continue;
|
||||
const kind = before === undefined ? 'added' : after === undefined ? 'removed' : 'changed';
|
||||
if (all || SECRET_KEY_RE.test(path.split('.').pop() ?? '')) {
|
||||
changes.push({ path, kind, redacted: true });
|
||||
} else {
|
||||
changes.push({
|
||||
path,
|
||||
kind,
|
||||
...(before !== undefined && { before: clip(scrubUrl(before)) }),
|
||||
...(after !== undefined && { after: clip(scrubUrl(after)) }),
|
||||
});
|
||||
}
|
||||
}
|
||||
return { changes: changes.slice(0, MAX_DIFF_CHANGES), truncated: changes.length > MAX_DIFF_CHANGES };
|
||||
}
|
||||
|
||||
/** The tracked files of one side, verified: a snapshot's against its manifest, the live ones as they are. */
|
||||
function loadDiffSide(side: string, dataDir: string, backupDir: string): Map<string, Buffer> {
|
||||
const out = new Map<string, Buffer>();
|
||||
if (side === CONFIG_DIFF_CURRENT) {
|
||||
for (const name of CONFIG_BACKUP_FILES) {
|
||||
try {
|
||||
const path = join(dataDir, name);
|
||||
if (statSync(path).isFile()) out.set(name, readFileSync(path));
|
||||
} catch {
|
||||
/* absent or unreadable: not part of this side */
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
const origin = readConfigBackupOrigin(dataDir);
|
||||
const manifest = origin ? readManifest(backupDir, side, origin) : null;
|
||||
if (!manifest) throw new ConfigBackupNotFoundError(`No config backup named ${side}`);
|
||||
const allowed = new Set<string>(CONFIG_BACKUP_FILES);
|
||||
const checkMode = folderKeepsModes(backupDir);
|
||||
assertTrustedSnapshotEntry(join(backupDir, side), `Backup ${side}`, checkMode);
|
||||
for (const f of manifest.files) {
|
||||
if (!allowed.has(f.name)) continue;
|
||||
let data: Buffer;
|
||||
assertTrustedSnapshotEntry(join(backupDir, side, f.name), `${f.name} in ${side}`, checkMode);
|
||||
try {
|
||||
data = readFileSync(join(backupDir, side, f.name));
|
||||
} catch {
|
||||
throw new ConfigBackupCorruptError(`${f.name} is missing from ${side}`);
|
||||
}
|
||||
if (sha256(data) !== f.sha256)
|
||||
throw new ConfigBackupCorruptError(`${f.name} in ${side} does not match its checksum`);
|
||||
out.set(f.name, data);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* What differs between two states of the tracked files; a state is a snapshot id or `current`
|
||||
* (the live files). Never returns the value of a credential: see REDACT_ALL_FILES and SECRET_KEY_RE.
|
||||
*/
|
||||
export function diffConfigBackups(opts: { dataDir: string; backupDir: string; from: string; to: string }): ConfigDiff {
|
||||
const a = loadDiffSide(opts.from, opts.dataDir, opts.backupDir);
|
||||
const b = loadDiffSide(opts.to, opts.dataDir, opts.backupDir);
|
||||
const files: ConfigDiffFile[] = [];
|
||||
for (const name of CONFIG_BACKUP_FILES) {
|
||||
const before = a.get(name);
|
||||
const after = b.get(name);
|
||||
if (!before && !after) continue;
|
||||
const base = { name, changes: [] as ConfigDiffChange[], truncated: false };
|
||||
if (before && after && sha256(before) === sha256(after)) {
|
||||
files.push({ ...base, status: 'unchanged', detail: 'keys' });
|
||||
continue;
|
||||
}
|
||||
const la = before ? leavesOf(name, before) : new Map<string, string>();
|
||||
const lb = after ? leavesOf(name, after) : new Map<string, string>();
|
||||
const status = !before ? 'added' : !after ? 'removed' : 'changed';
|
||||
if (!la || !lb) {
|
||||
files.push({ ...base, status, detail: 'bytes' });
|
||||
continue;
|
||||
}
|
||||
const { changes, truncated } = diffLeaves(name, la, lb);
|
||||
// Only volatile keys differed (a discovery timestamp): the same state as far as the user is concerned.
|
||||
files.push({
|
||||
name,
|
||||
status: changes.length || status !== 'changed' ? status : 'unchanged',
|
||||
changes,
|
||||
truncated,
|
||||
detail: 'keys',
|
||||
});
|
||||
}
|
||||
return { from: opts.from, to: opts.to, files, changed: files.filter((f) => f.status !== 'unchanged').length };
|
||||
}
|
||||
@@ -21,9 +21,25 @@ export const MAX_AUTH_SESSIONS = 100;
|
||||
// Rate Limiting
|
||||
// ============================================================================
|
||||
|
||||
/** Max failed auth attempts per IP before 429 rejection */
|
||||
/**
|
||||
* Max failed auth attempts per bucket before 429 rejection. Applies to the
|
||||
* single-user per-IP bucket, the hook-secret bucket and two multi-user buckets:
|
||||
* the (username, IP) gate, checked before the scrypt verify, and the
|
||||
* per-username bucket, checked after it.
|
||||
*/
|
||||
export const AUTH_FAILURE_MAX = 10;
|
||||
|
||||
/**
|
||||
* Max failed login attempts per client IP for the multi-user pre-gate, which
|
||||
* runs before the scrypt verify and so refuses even a correct password once
|
||||
* reached. It bounds scrypt cost from one source and is wider than
|
||||
* AUTH_FAILURE_MAX because one egress IP (NAT, a tunnel) can carry many users'
|
||||
* failures. Verified guesses against any one account are capped by the
|
||||
* (username, IP) gate at AUTH_FAILURE_MAX, not by this. Single-user mode does
|
||||
* not use this.
|
||||
*/
|
||||
export const AUTH_IP_FAILURE_MAX = 50;
|
||||
|
||||
/** Failed auth attempt tracking window (ms) */
|
||||
export const AUTH_FAILURE_WINDOW_MS = 15 * 60 * 1000;
|
||||
|
||||
|
||||
@@ -113,6 +113,16 @@ export const FILE_PEEK_BYTES = 8 * 1024 - 1; // 8KB (inclusive end offset)
|
||||
*/
|
||||
export const MAX_PASTE_IMAGE_BYTES = parseInt(process.env.CODEMAN_MAX_PASTE_IMAGE_BYTES || '') || 50 * 1024 * 1024; // 50MB
|
||||
|
||||
/**
|
||||
* Maximum size (bytes) of a single video uploaded via POST
|
||||
* /api/sessions/:id/paste-image?kind=video (the phone toolbar's Attach button
|
||||
* offers the camera roll's videos beside its photos). Videos stream to disk
|
||||
* rather than through memory, so this is a disk-fill sanity bound, not a memory
|
||||
* bound: a minute of 4K60 from an iPhone is about 400MB, so the image cap would
|
||||
* refuse most clips. Override: CODEMAN_MAX_PASTE_VIDEO_BYTES (bytes)
|
||||
*/
|
||||
export const MAX_PASTE_VIDEO_BYTES = parseInt(process.env.CODEMAN_MAX_PASTE_VIDEO_BYTES || '') || 1024 * 1024 * 1024; // 1GB
|
||||
|
||||
// ============================================================================
|
||||
// File Download Limits
|
||||
// ============================================================================
|
||||
|
||||
@@ -20,6 +20,8 @@
|
||||
export type TokenPattern =
|
||||
| 'model'
|
||||
| 'model-claude'
|
||||
| 'model-plain'
|
||||
| 'model-plain-colon'
|
||||
| 'model-pi'
|
||||
| 'id'
|
||||
| 'id-dotted'
|
||||
@@ -43,6 +45,13 @@ const PATTERNS: Record<TokenPattern, RegExp> = {
|
||||
model: /^[a-zA-Z0-9._\-/]+$/,
|
||||
// buildSpawnCommand's claude branch — `[` and `]` for bracketed model aliases
|
||||
'model-claude': /^[a-zA-Z0-9._\-[\]]+$/,
|
||||
// copilot --model. Same characters as `model`, but a leading alphanumeric: the value follows a
|
||||
// flag, so `--yolo` must not pass as a model id and become its own flag (the allowAll clamp bypass).
|
||||
'model-plain': /^[a-zA-Z0-9][a-zA-Z0-9._\-/]*$/,
|
||||
// codex --model, and the value a custom-model `launchModel` is forced onto it. Same
|
||||
// shape as `model-plain` (leading alphanumeric, so a flag cannot ride the flag) plus
|
||||
// `:`, since Ollama-style ids like `qwen3:32b` must survive onto the command line.
|
||||
'model-plain-colon': /^[a-zA-Z0-9][a-zA-Z0-9._\-/:]*$/,
|
||||
// buildPiCommand — `:` for a thinking suffix (`sonnet:high`), `/` for `provider/id`
|
||||
'model-pi': /^[a-zA-Z0-9._\-/:]+$/,
|
||||
// opencode --session, codex resume
|
||||
|
||||
@@ -15,7 +15,10 @@
|
||||
import { z } from 'zod';
|
||||
import { compileVersionRegex, countCaptureGroups, TOKEN_PATTERNS } from './patterns.js';
|
||||
import { isKnownLauncherProfile, isKnownSetenvProfile } from './profiles.js';
|
||||
import type { LaunchDefaultSettingKey, McpConfigFormat, ModelConfigResolverName } from './types.js';
|
||||
import type { AgentSkillDir, LaunchDefaultSettingKey, McpConfigFormat, ModelConfigResolverName } from './types.js';
|
||||
|
||||
/** `capabilities.agentSkillDir`: every value must stay a fixed, relative, `..`-free path. */
|
||||
export const AGENT_SKILL_DIRS = ['.claude/skills', '.agents/skills'] as const satisfies readonly AgentSkillDir[];
|
||||
|
||||
/** A bare CLI id: lowercase, starts with a letter, at most 24 chars. Also used as a CSS/URL token. */
|
||||
const cliId = z
|
||||
@@ -77,6 +80,7 @@ const paramSpecSchema = z.union([
|
||||
'muxName',
|
||||
'effortLevel',
|
||||
'effortSettingsJson',
|
||||
'appendSystemPrompt',
|
||||
'codemanPrefixedSessionId',
|
||||
'launcherDefaultTarget',
|
||||
]),
|
||||
@@ -221,6 +225,7 @@ const envExportSchema = z
|
||||
'muxName',
|
||||
'effortLevel',
|
||||
'effortSettingsJson',
|
||||
'appendSystemPrompt',
|
||||
'codemanPrefixedSessionId',
|
||||
'launcherDefaultTarget',
|
||||
]),
|
||||
@@ -283,6 +288,14 @@ const launchModelTemplate = z
|
||||
.regex(/^[a-zA-Z0-9._\-/:{}]+$/)
|
||||
.optional();
|
||||
|
||||
/** One entry name under a CLI's real home, for `linkFromRealHome`: single path segment,
|
||||
* leading alphanumeric, no traversal or hidden-dot escape. */
|
||||
const homeSegmentName = z
|
||||
.string()
|
||||
.min(1)
|
||||
.max(40)
|
||||
.regex(/^[a-zA-Z0-9][a-zA-Z0-9._-]*$/);
|
||||
|
||||
const capabilitiesSchema = z
|
||||
.object({
|
||||
external: z.boolean(),
|
||||
@@ -292,8 +305,15 @@ const capabilitiesSchema = z
|
||||
altScreen: z.enum(['strip-full', 'strip-mux-only', 'strip-mux-and-mouse', 'preserve']),
|
||||
echo: echoSchema,
|
||||
wheelForward: z
|
||||
.object({ mode: z.enum(['never', 'version-gated']), minVersion: z.string().max(20).optional() })
|
||||
.object({ mode: z.enum(['never', 'version-gated', 'always']), minVersion: z.string().max(20).optional() })
|
||||
.strict(),
|
||||
// Whether a hollow pane's wheel/touch scroll may be turned into PageUp/PageDown for
|
||||
// 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']),
|
||||
@@ -302,6 +322,9 @@ const capabilitiesSchema = z
|
||||
respawn: z.boolean(),
|
||||
effort: z.boolean(),
|
||||
agentSkillInjection: z.boolean(),
|
||||
// A closed list on purpose: it decides where the server WRITES, and this file is
|
||||
// user-editable, so it can never be a free path (`..`, absolute).
|
||||
agentSkillDir: z.enum(AGENT_SKILL_DIRS).optional(),
|
||||
statusLineTelemetry: z.boolean(),
|
||||
// How many columns this CLI indents its transcript body by, so a copy can take
|
||||
// that much off the clipboard. Bounded, because it is the whole strip: a copy
|
||||
@@ -358,6 +381,51 @@ const capabilitiesSchema = z
|
||||
'awaitingLine must be a regex compileVersionRegex() accepts: at most 200 characters, no nested quantifiers'
|
||||
)
|
||||
.optional(),
|
||||
// Same guard again: run over the foot of a pane capture every time a session
|
||||
// settles and on a timer while a dialog is up, and ~/.codeman/clis.json can set it.
|
||||
blockedLine: z
|
||||
.string()
|
||||
.min(1)
|
||||
.refine(
|
||||
(src) => compileVersionRegex(src) !== null,
|
||||
'blockedLine must be a regex compileVersionRegex() accepts: at most 200 characters, no nested quantifiers'
|
||||
)
|
||||
.optional(),
|
||||
// Wider than `watchingLines` because a dialog's options wrap at narrow widths (codex
|
||||
// needs 20 at 36 columns). That is safe only for a pattern that ends on the dialog's
|
||||
// own footer: the rows above it, which the agent may write, can then supply a
|
||||
// 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(),
|
||||
// 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
|
||||
@@ -368,6 +436,10 @@ const capabilitiesSchema = z
|
||||
(v) => v.watchingLines === undefined || v.watchingLine !== undefined,
|
||||
'watchingLines has nothing to bound without a watchingLine'
|
||||
)
|
||||
.refine(
|
||||
(v) => v.blockedLines === undefined || v.blockedLine !== undefined,
|
||||
'blockedLines has nothing to bound without a blockedLine'
|
||||
)
|
||||
.optional(),
|
||||
model: z
|
||||
.object({ source: z.enum(['flag', 'claude-settings-file', 'none']), param: z.string().optional() })
|
||||
@@ -434,8 +506,10 @@ const capabilitiesSchema = z
|
||||
)
|
||||
.max(8),
|
||||
// Exact env var NAMES, not prefixes: this list is a targeted deny, and a prefix here
|
||||
// would let one entry silently strip a whole namespace off every owner's overrides.
|
||||
privilegedEnvKeys: z.array(envName).max(8),
|
||||
// would let one entry silently strip a whole namespace off every owner's overrides. The
|
||||
// bound is generous because a longer list only takes more away from non-granted owners:
|
||||
// copilot's COPILOT_ prefix covers dozens of variables that run code or redirect its token.
|
||||
privilegedEnvKeys: z.array(envName).max(48),
|
||||
gates: z.record(z.string(), z.object({ minVersion: z.string().max(20), failClosed: z.boolean() }).strict()),
|
||||
maxFrameBytes: z.number().int().positive().optional(),
|
||||
newline: z.enum(['line-feed', 'esc-enter']).optional(),
|
||||
@@ -443,6 +517,9 @@ const capabilitiesSchema = z
|
||||
.object({
|
||||
// Home-relative, no traversal: sync writes to this path.
|
||||
path: mcpRelativePath,
|
||||
// Home-relative fallback used when `path` is absent. Same no-traversal rule as `path`:
|
||||
// sync reads it and, when it parses as plain JSON, writes to it.
|
||||
altPath: mcpRelativePath.optional(),
|
||||
// Every value must be a known McpConfigFormat (types.ts); mcp-sync.ts's dialect table is
|
||||
// keyed by the same type, so an adapter-less format fails to compile there.
|
||||
format: z.enum([
|
||||
@@ -513,10 +590,61 @@ const capabilitiesSchema = z
|
||||
kind: z.literal('configDir'),
|
||||
dirEnvVar: envName,
|
||||
fileName: z.string().min(1).max(80),
|
||||
template: z.enum(['codex-toml', 'pi-models-json', 'omp-models-yml', 'grok-toml']),
|
||||
template: z.enum(['codex-toml', 'pi-models-json', 'omp-models-yml', 'grok-toml', 'deepseek-patch-json']),
|
||||
launchModel: launchModelTemplate,
|
||||
// State that must survive the isolated-directory redirect, symlinked by name from
|
||||
// the CLI's real home (deepseek: profiles/sessions/settings.yaml). Single path
|
||||
// segments only: the consumer joins them, so a traversal here would escape the
|
||||
// real home. See the field's comment in cli-registry/types.ts.
|
||||
linkFromRealHome: z.array(homeSegmentName).max(8).optional(),
|
||||
// Which of `linkFromRealHome` are directories (missing ones are created before
|
||||
// linking; missing files are skipped). Same segment pattern, see types.ts.
|
||||
linkFromRealHomeDirs: z.array(homeSegmentName).max(8).optional(),
|
||||
// The real home's fallback location, relative to the user's home, ONE segment:
|
||||
// a leading dot is legitimate (`.dsh` is what ships) but separators, `..` and
|
||||
// bare `.` are not; the consumer joins it under the user's home, a traversal
|
||||
// here escapes that home and `.` IS that home (linking THROUGH it unshielded).
|
||||
realHomeFallback: z
|
||||
.string()
|
||||
.min(1)
|
||||
.max(80)
|
||||
.regex(/^(?!\.\.?$)[^/\\]+$/)
|
||||
.optional(),
|
||||
})
|
||||
.strict(),
|
||||
.strict()
|
||||
.superRefine((recipe, ctx) => {
|
||||
// Both rules were comments before; the schema is the security boundary, so
|
||||
// enforce them at load time (see the header of this file).
|
||||
if (recipe.linkFromRealHome?.length && !recipe.realHomeFallback) {
|
||||
ctx.addIssue({
|
||||
code: 'custom',
|
||||
message: 'realHomeFallback is required whenever linkFromRealHome is declared',
|
||||
path: ['realHomeFallback'],
|
||||
});
|
||||
}
|
||||
for (const name of recipe.linkFromRealHome ?? []) {
|
||||
if (name === recipe.fileName) {
|
||||
ctx.addIssue({
|
||||
code: 'custom',
|
||||
message: `linkFromRealHome must never include fileName (${JSON.stringify(name)}): the generated file owns that name in the isolated directory, a link there would write THROUGH into the user's real file`,
|
||||
path: ['linkFromRealHome'],
|
||||
});
|
||||
}
|
||||
}
|
||||
// types.ts documents the dirs list as "must be a subset of linkFromRealHome";
|
||||
// a name absent from it names nothing (missing-file entries are skipped as
|
||||
// files), so the recipe would silently stop creating a directory it declares.
|
||||
const linkedNames = new Set(recipe.linkFromRealHome ?? []);
|
||||
for (const dir of recipe.linkFromRealHomeDirs ?? []) {
|
||||
if (!linkedNames.has(dir)) {
|
||||
ctx.addIssue({
|
||||
code: 'custom',
|
||||
message: `linkFromRealHomeDirs must be a subset of linkFromRealHome: ${JSON.stringify(dir)} is not linked, so the directory rule for it can never apply`,
|
||||
path: ['linkFromRealHomeDirs'],
|
||||
});
|
||||
}
|
||||
}
|
||||
}),
|
||||
z.object({ kind: z.literal('unsupported') }).strict(),
|
||||
]),
|
||||
})
|
||||
|
||||
@@ -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',
|
||||
@@ -76,6 +84,21 @@ function agentDefaults(): Pick<
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* The agent skill in `<workspace>/.agents/skills/codeman`, the one project skills
|
||||
* directory every CLI that spreads this scans. Measured per CLI on
|
||||
* 2026-10-11 against the installed builds or their source: codex 0.162 (every dir from
|
||||
* the git root down to cwd), gemini (cwd only, behind folder trust, which `--skip-trust`
|
||||
* already answers), opencode 1.3 (cwd up to the worktree), agy 1.3.2 (its `.agents/`
|
||||
* customization root), grok 1.0.5 (each tier from cwd up to the repo root), dsh 0.1.1
|
||||
* (the git root, else cwd) and omp (cwd up to the git root). Claude Code does NOT read
|
||||
* `.agents/skills`, so a case shared with a claude session lists each skill once there.
|
||||
*/
|
||||
const AGENTS_DIR_SKILL: Pick<CliEntry['capabilities'], 'agentSkillInjection' | 'agentSkillDir'> = {
|
||||
agentSkillInjection: true,
|
||||
agentSkillDir: '.agents/skills',
|
||||
};
|
||||
|
||||
// `accent` on every entry below (except SHELL, which the frontend renders no
|
||||
// distinct color for) is measured from the actual `.btn-toolbar.btn-run.mode-<id>`
|
||||
// CSS rule's `border-color` on the OG skin (styles.css) — the single cleanest
|
||||
@@ -129,6 +152,7 @@ const CLAUDE: CliEntry = {
|
||||
// from the EFFORT_LEVELS allowlist upstream, same as every other engine value).
|
||||
effortLevel: { type: 'engine', source: 'effortLevel' },
|
||||
effortJson: { type: 'engine', source: 'effortSettingsJson' },
|
||||
discordHint: { type: 'engine', source: 'appendSystemPrompt' },
|
||||
sessionId: { type: 'engine', source: 'sessionId' },
|
||||
sessionName: { type: 'engine', source: 'sessionName' },
|
||||
},
|
||||
@@ -156,6 +180,12 @@ const CLAUDE: CliEntry = {
|
||||
{ flag: '--effort', valueFrom: 'effortLevel', quote: 'single', when: { param: 'effortLevel', state: 'set' } },
|
||||
{ flag: '--settings', valueFrom: 'effortJson', quote: 'single', when: { param: 'effortJson', state: 'set' } },
|
||||
{ flag: '--name', valueFrom: 'sessionName', quote: 'double', when: { capabilityGate: 'nameFlag' } },
|
||||
{
|
||||
flag: '--append-system-prompt',
|
||||
valueFrom: 'discordHint',
|
||||
quote: 'single',
|
||||
when: { param: 'discordHint', state: 'set' },
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
@@ -180,6 +210,12 @@ const CLAUDE: CliEntry = {
|
||||
{ flag: '--effort', valueFrom: 'effortLevel', quote: 'single', when: { param: 'effortLevel', state: 'set' } },
|
||||
{ flag: '--settings', valueFrom: 'effortJson', quote: 'single', when: { param: 'effortJson', state: 'set' } },
|
||||
{ flag: '--name', valueFrom: 'sessionName', quote: 'double', when: { capabilityGate: 'nameFlag' } },
|
||||
{
|
||||
flag: '--append-system-prompt',
|
||||
valueFrom: 'discordHint',
|
||||
quote: 'single',
|
||||
when: { param: 'discordHint', state: 'set' },
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
@@ -270,6 +306,14 @@ const CLAUDE: CliEntry = {
|
||||
// Declared-for-later: the live rule (`_shouldForwardWheelToApp`, terminal-ui.js) is this version
|
||||
// AND the server-published `cliMouseTracking` flag (#498), so wiring this field up needs both.
|
||||
wheelForward: { mode: 'version-gated', minVersion: '2.1.187' },
|
||||
// A repaint-mode Claude pane keeps no local history, so when the wheel is not
|
||||
// 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',
|
||||
@@ -278,6 +322,7 @@ const CLAUDE: CliEntry = {
|
||||
respawn: true,
|
||||
effort: true,
|
||||
agentSkillInjection: true,
|
||||
agentSkillDir: '.claude/skills',
|
||||
statusLineTelemetry: true,
|
||||
model: { source: 'claude-settings-file' },
|
||||
privilegedParams: [],
|
||||
@@ -309,7 +354,12 @@ const CLAUDE: CliEntry = {
|
||||
'CLAUDE_CODE_MAX_CONTEXT_TOKENS',
|
||||
'CLAUDE_CONFIG_DIR',
|
||||
],
|
||||
gates: { nameFlag: { minVersion: '2.1.224', failClosed: true } },
|
||||
// idleCompactionSetting: the `idleCompaction` settings key first ships in 2.1.290 (2.1.289
|
||||
// already idle-compacts with no opt-out); see TmuxManager._resolveIdleCompactionOptOut.
|
||||
gates: {
|
||||
nameFlag: { minVersion: '2.1.224', failClosed: true },
|
||||
idleCompactionSetting: { minVersion: '2.1.290', failClosed: true },
|
||||
},
|
||||
// claude reads `$CLAUDE_CONFIG_DIR/.claude.json` when that is set (checked in 2.1.289).
|
||||
mcpConfig: {
|
||||
path: '.claude.json',
|
||||
@@ -494,8 +544,14 @@ const OPENCODE: CliEntry = {
|
||||
},
|
||||
capabilities: {
|
||||
...agentDefaults(),
|
||||
...AGENTS_DIR_SKILL,
|
||||
altScreen: 'strip-mux-and-mouse',
|
||||
echo: { policy: 'buffer', anchor: { kind: 'cursor' }, predictProfile: undefined },
|
||||
// Pages its own transcript on PageUp/PageDown (`messages_page_up/down`) and ignores
|
||||
// SGR wheel reports, and its TUI runs on the alternate screen (1.18.31: tmux
|
||||
// `alternate_on=1`, `history_size=0`), so a pane keeps no local history and the
|
||||
// wheel is turned into page keys or it does nothing at all (#555).
|
||||
transcriptPageKeys: true,
|
||||
// Measured on a live opencode 1.3.0 pane (capture-pane every 250-300 ms through real
|
||||
// turns at 40, 60, 120 and 200 columns, plus the raw PTY stream, 2026-10-09). Every
|
||||
// composer row starts with a `┃` bar, and the submitted prompt lands in the transcript
|
||||
@@ -536,6 +592,7 @@ const OPENCODE: CliEntry = {
|
||||
// opencode's global config dir is xdg-basedir's `$XDG_CONFIG_HOME/opencode`.
|
||||
mcpConfig: {
|
||||
path: '.config/opencode/opencode.json',
|
||||
altPath: '.config/opencode/opencode.jsonc',
|
||||
format: 'opencode-json',
|
||||
relocation: { envVar: 'XDG_CONFIG_HOME', path: 'opencode/opencode.json' },
|
||||
},
|
||||
@@ -584,7 +641,10 @@ const CODEX: CliEntry = {
|
||||
params: {
|
||||
bypassApprovals: { type: 'bool' },
|
||||
animations: { type: 'bool' },
|
||||
model: { type: 'token', pattern: 'model' },
|
||||
// `model-plain-colon`, not `model`: the custom-model feature forces the selected id
|
||||
// onto this param (`launchModel: '{modelId}'` below), and Ollama-style ids carry a
|
||||
// `:` (`qwen3:32b`). A leading alphanumeric still keeps `--yolo` from riding a flag.
|
||||
model: { type: 'token', pattern: 'model-plain-colon' },
|
||||
reasoningEffort: { type: 'enum', values: [...CODEX_REASONING_EFFORTS] },
|
||||
resumeId: { type: 'token', pattern: 'id' },
|
||||
},
|
||||
@@ -627,6 +687,7 @@ const CODEX: CliEntry = {
|
||||
},
|
||||
capabilities: {
|
||||
...agentDefaults(),
|
||||
...AGENTS_DIR_SKILL,
|
||||
// Codex draws `› Ask Codex to do anything` on its composer row and
|
||||
// `Working (2m 49s • esc to interrupt)` above it while a turn runs. It animates no
|
||||
// braille spinner, and it never prints `esc to interrupt` at rest, so that phrase
|
||||
@@ -649,10 +710,11 @@ const CODEX: CliEntry = {
|
||||
// which the agent writes (and while a prompt is typed, the last two). Matching the complete row raises the bar — an assistant
|
||||
// message has to end with this exact line, to the character — but nothing here makes
|
||||
// forging it impossible, so do not read the Claude comment above as applying here.
|
||||
// What contains it is that codex declares `hooks: 'none'`: no hook event from a codex
|
||||
// session ever reaches `notePrompt()`, so there is no idle item to pre-acknowledge
|
||||
// and a forged label costs a wrong badge and nothing else. A CLI that gains hook
|
||||
// signals must not keep a pattern this soft.
|
||||
// What contains it is that codex declares `hooks: 'none'`: no IDLE prompt from a codex
|
||||
// session ever reaches `notePrompt()` (its screen-read dialogs below open PERMISSION
|
||||
// items, which a watching label never acknowledges), so there is no idle item to
|
||||
// pre-acknowledge and a forged label costs a wrong badge and nothing else. A CLI that
|
||||
// gains idle signals must not keep a pattern this soft.
|
||||
// ⚠️ Background TERMINALS are the only background work codex advertises on screen.
|
||||
// A sub-agent started without waiting outlives the turn just as a terminal does —
|
||||
// measured 2026-09-22, the sandboxed process was still running — and the pane shows
|
||||
@@ -661,11 +723,73 @@ const CODEX: CliEntry = {
|
||||
// composer. So a codex session waiting on a sub-agent reads as plainly idle here.
|
||||
// Nothing is misfiled by that (codex raises no idle prompts), and there is no row to
|
||||
// match until codex pins one.
|
||||
// Codex is the one CLI Codeman launches with its approvals on by default, and it has no
|
||||
// hook to say it stopped for one, so its dialogs are read off the screen. Measured on a
|
||||
// live codex-cli 0.162.0 pane on 2026-10-14 (`-a on-request -s read-only`, widths 200,
|
||||
// 80, 60, 44 and 36): every dialog it blocks on REPLACES the composer and its footer
|
||||
// with a numbered option list, the selected one marked `›`, over a key-hint row:
|
||||
// command approval (a network `› 1. Yes, proceed (y)` ...
|
||||
// escalation too), edit approval
|
||||
// ` Press enter to confirm or esc to cancel`
|
||||
// question (request_user_input) ` › 1. Blue Write the color blue.` ...
|
||||
// ` tab to add notes | enter to submit answer | esc to interrupt`
|
||||
// (its notes field open) ` tab or esc to clear notes | enter to submit answer`
|
||||
// workspace trust, at startup `› 1. Trust and continue`
|
||||
// ` enter continue · esc quit`
|
||||
// (the same prompt reached after the ` 2. Back to Agent Command Center`
|
||||
// update prompt, 0.162.0, 2026-10-16) ` enter continue · esc back`
|
||||
// The pattern needs both: a `› N.` option row, then the key-hint row, which must END
|
||||
// the window. Below about 60 columns that hint wraps onto rows of its own
|
||||
// (` Press enter to confirm or esc to` / ` cancel`, or `tab to add notes` /
|
||||
// `enter to submit answer` / `esc to interrupt` one per row), so the hint may run on
|
||||
// over rows that start with two spaces and a lowercase word or arrow, and must end on
|
||||
// one of its own closing words. The transcript can quote every one of those strings
|
||||
// (measured: an answer that printed them verbatim) and still not match, because while
|
||||
// no dialog is up codex keeps its composer row (`› ...`, column 0, which ends the
|
||||
// run) and its footer below anything the agent prints. The longest option list
|
||||
// measured, a question at 36 columns, put the `›` row 16 non-blank rows up; 20 leaves
|
||||
// room for longer option text, and a dialog whose marker scrolls out of the window is
|
||||
// simply not seen (the behaviour before this field). Not matched on purpose:
|
||||
// `Implement this plan?` after a plan turn, whose `enter select · esc back` footer is
|
||||
// the same as `/model`, `/permissions` and `/review`, menus the user opened at the
|
||||
// keyboard; the plan turn has ended there, and the session reads idle as before.
|
||||
// ⚠️ The question dialog's hint contains `esc to interrupt`, the working line, so
|
||||
// such a pane read as WORKING before this field: a blocked screen now wins over it.
|
||||
workDetect: {
|
||||
promptGlyph: '›',
|
||||
workingLine: '[Ee]sc to interrupt',
|
||||
watchingLine: String.raw`^\s{0,4}(\d+ background terminals?) running · /ps to view · /stop to close$`,
|
||||
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}) · )`,
|
||||
// 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,
|
||||
@@ -689,7 +813,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
|
||||
@@ -705,7 +829,12 @@ const CODEX: CliEntry = {
|
||||
transcript: 'codex-rollout',
|
||||
altScreen: 'strip-full',
|
||||
echo: { policy: 'predict', anchor: { kind: 'cursor' }, predictProfile: 'codex' },
|
||||
wheelForward: { mode: 'never' }, // #227: codex ignores SGR wheel reports, never forward
|
||||
wheelForward: { mode: 'always' }, // 0.163.0 fullscreen: measured SGR scrolling; require observed tracking
|
||||
// Without observed mouse tracking, retain PageUp/PageDown fallback. Measured on 0.157.1 (local,
|
||||
// alternate screen inside tmux, so xterm holds no history) and 0.160 (remote): PageUp
|
||||
// moved the visible transcript while SGR wheel reports and local scrolling did nothing.
|
||||
// This is the page-key fallback for a hollow pane, not wheel forwarding.
|
||||
transcriptPageKeys: true,
|
||||
maxFrameBytes: 32 * 1024,
|
||||
// codex's own bare-spawn default (no config sent) is already safe (no bypass flag), so
|
||||
// the multi-user clamp only needs to force an EXPLICITLY-SENT bypass back off.
|
||||
@@ -728,6 +857,7 @@ const CODEX: CliEntry = {
|
||||
dirEnvVar: 'CODEX_HOME',
|
||||
fileName: 'config.toml',
|
||||
template: 'codex-toml',
|
||||
launchModel: '{modelId}',
|
||||
},
|
||||
// CODEX_HOME already matches the CODEX_ allowedPrefix above, so it was ALREADY
|
||||
// reachable via plain envOverrides before this feature existed. It is arguably
|
||||
@@ -816,6 +946,7 @@ const GEMINI: CliEntry = {
|
||||
},
|
||||
capabilities: {
|
||||
...agentDefaults(),
|
||||
...AGENTS_DIR_SKILL,
|
||||
altScreen: 'strip-full',
|
||||
echo: { policy: 'buffer', anchor: { kind: 'cursor' } },
|
||||
// Measured on a live Gemini CLI 0.63.0 pane (capture-pane every 300 ms through real
|
||||
@@ -924,8 +1055,33 @@ const ANTIGRAVITY: CliEntry = {
|
||||
},
|
||||
capabilities: {
|
||||
...agentDefaults(),
|
||||
...AGENTS_DIR_SKILL,
|
||||
altScreen: 'strip-mux-only',
|
||||
echo: { policy: 'buffer', anchor: { kind: 'cursor' } },
|
||||
// Measured on a live `agy` 1.3.3 pane (capture-pane every second through a prose turn
|
||||
// and a shell-call turn, plus the raw PTY stream, 2026-10-11). A running turn animates
|
||||
// `⣯ Generating...` (the label also reads `Working...` and `Loading...`) one row above
|
||||
// the composer, and the footer's left end reads `esc to cancel`; at rest the footer
|
||||
// reads `? for shortcuts` and nothing else on screen matches. A shell call replaces the
|
||||
// composer with a `Run this command?` list, the spinner stops, and the pane goes
|
||||
// silent. Nothing arms the idle check while the list is up (the footer hint is drawn only
|
||||
// at rest), so a pending approval reads as working until it is answered. That differs from
|
||||
// gemini, whose `▀` bar comes back on every spinner repaint.
|
||||
// ⚠️ The frames are the 8-dot braille set (`⣯ ⡿ ⣻ ⣾ ⣷ ⣟ ⢿ ⣽`), none of which is in
|
||||
// SPINNER_PATTERN's Claude set, and this entry used to declare nothing, so no frame
|
||||
// ever marked an antigravity tab working and it never showed the activity spinner.
|
||||
// The composer's `>` is no arming mark: the submitted prompt is echoed as `> <text>`
|
||||
// too. The footer hint is drawn only at rest, and the turn's last repaint carries it.
|
||||
// The schema caps a glyph at 8 characters, so this is the head of `? for shortcuts`. It also
|
||||
// keeps the submit verifier inert (no row starts with it and holds prompt text).
|
||||
// The working line is anchored to the start of a row, like gemini's braille branch: the
|
||||
// idle probe tests it against the whole pane, and an answer that QUOTES the spinner row
|
||||
// (an agent explaining this entry) would otherwise hold the tab busy while it is on screen.
|
||||
// Every `agy` capture draws the spinner at column 0.
|
||||
workDetect: {
|
||||
promptGlyph: '? for sh',
|
||||
workingLine: String.raw`(?:^|\n)[⠀-⣿] [A-Z][A-Za-z ]{2,30}(?:\.\.\.|…)`,
|
||||
},
|
||||
// Like codex: an ABSENT config already defaults safe (no bypass flag), so only a
|
||||
// SENT config needs the flag forced off — nothing is materialized.
|
||||
privilegedParams: [{ param: 'dangerouslySkipPermissions', clampTo: false }],
|
||||
@@ -1019,6 +1175,11 @@ const PI: CliEntry = {
|
||||
},
|
||||
capabilities: {
|
||||
...agentDefaults(),
|
||||
// No agent skill (agentSkillInjection stays false): any project-level `.agents/skills`
|
||||
// or `.pi/skills` makes pi ask "Trust project folder?" at startup
|
||||
// (hasTrustRequiringProjectResources, pi 1.1.0), which would stall a spawned worker on
|
||||
// a dialog. pi reads the user-level `~/.agents/skills` without that prompt, so a pi
|
||||
// user installs it there (`npx skills add Ark0N/Codeman --skill codeman -g`).
|
||||
altScreen: 'preserve', // pi's TUI renders into the main screen with terminal-owned scrollback
|
||||
echo: { policy: 'buffer', anchor: { kind: 'cursor' } },
|
||||
// Measured on a live pi 1.1.0 pane (capture-pane every 250 ms through a turn,
|
||||
@@ -1169,6 +1330,7 @@ const GROK: CliEntry = {
|
||||
},
|
||||
capabilities: {
|
||||
...agentDefaults(),
|
||||
...AGENTS_DIR_SKILL,
|
||||
// Fullscreen alt-screen TUI with mouse support, same strip as antigravity until measured
|
||||
// (opencode's mouse strip is #443): only the tmux-attach-time smcup strip, not Ink's full
|
||||
// erase-scrollback+DECSET strip.
|
||||
@@ -1259,8 +1421,14 @@ const DEEPSEEK: CliEntry = {
|
||||
launcherTargetParam: 'profile',
|
||||
install: {
|
||||
command: {
|
||||
linux: 'npm install -g @deepseek-ai/dsh',
|
||||
darwin: 'npm install -g @deepseek-ai/dsh',
|
||||
// pnpm rides along: `dsh plugin` spawns a literal `pnpm` with no npm fallback, so the
|
||||
// terminal profile this CLI needs before it can run cannot be installed without it (#352).
|
||||
// ⚠️ Only when none is on PATH. `corepack enable` puts a `pnpm` shim in node's own bin
|
||||
// directory, which is npm's global bin for nvm and Homebrew, and `npm install -g pnpm`
|
||||
// refuses to overwrite it (EEXIST). Asked for in the same command as dsh, that failure
|
||||
// took dsh down with it, on exactly the machines that already had a working pnpm.
|
||||
linux: 'npm install -g @deepseek-ai/dsh && (command -v pnpm >/dev/null || npm install -g pnpm)',
|
||||
darwin: 'npm install -g @deepseek-ai/dsh && (command -v pnpm >/dev/null || npm install -g pnpm)',
|
||||
},
|
||||
npmPackage: '@deepseek-ai/dsh',
|
||||
docsUrl: 'https://github.com/deepseek-ai/deepseek-harness',
|
||||
@@ -1341,6 +1509,7 @@ const DEEPSEEK: CliEntry = {
|
||||
},
|
||||
capabilities: {
|
||||
...agentDefaults(),
|
||||
...AGENTS_DIR_SKILL,
|
||||
// Definitive rather than inferred: the harness TUI reports idle/working/blocked to a
|
||||
// supervisor and Codeman is that supervisor. 'supervised' rather than 'always' because
|
||||
// the session can disarm the bridge, and docker/remote cannot reach it at all.
|
||||
@@ -1393,30 +1562,30 @@ const DEEPSEEK: CliEntry = {
|
||||
// SERVER's own key into every dsh pane, so DEEPSEEK_BASE_URL is the exfiltration
|
||||
// vector, not the key itself — a non-granted owner supplying THEIR OWN key removes
|
||||
// privilege rather than granting it, and clamping it here was a real regression
|
||||
// (test/deepseek-mode.test.ts) fixed before this shipped.
|
||||
privilegedEnvKeys: ['DSH_PERMISSION_MODE', 'DSH_HOME', 'DEEPSEEK_BASE_URL'],
|
||||
// Reuses the already-existing DEEPSEEK_BASE_URL/DEEPSEEK_API_KEY keys above. No
|
||||
// modelVars — dsh's model is a profile-composition entry (see `model: { source: 'none'
|
||||
// }` above), not an env var, so forcing a specific model name may not fully work;
|
||||
// verify against a real profile before shipping.
|
||||
//
|
||||
// ⚠️ appendV1Suffix is REQUIRED, not optional-nice-to-have: without it every request
|
||||
// 404s. Confirmed live and by reading dsh's own bundled source
|
||||
// (@deepseek-ai/dsh-llm-deepseek): it builds the request URL as
|
||||
// `${DEEPSEEK_BASE_URL}/chat/completions` with no "/v1" of its own (its real public
|
||||
// API, https://api.deepseek.com, expects the caller's base URL to already carry any
|
||||
// needed prefix), while llama-swap/llama.cpp only serves the OpenAI-conventional
|
||||
// "/v1/chat/completions" — a bare POST to ".../chat/completions" 404s live, and the
|
||||
// 404 reported here originally ("dsh: HTTP_404: DeepSeek API error (HTTP 404)")
|
||||
// matches dsh's own error-message template for exactly this failure. See the
|
||||
// customModelInjection doc comment in cli-registry/types.ts for the full reasoning,
|
||||
// including why claude/gemini must NOT get this.
|
||||
// (test/deepseek-mode.test.ts) fixed before this shipped. CODEMAN_CUSTOM_MODEL_API_KEY
|
||||
// joins it (like codex's entry): the dedicated custom-endpoint key rides the same
|
||||
// allowlisted-envOverrides path as the redirect itself.
|
||||
privilegedEnvKeys: ['DSH_PERMISSION_MODE', 'DSH_HOME', 'DEEPSEEK_BASE_URL', 'CODEMAN_CUSTOM_MODEL_API_KEY'],
|
||||
// The installed profile supplies the transport; a per-session patch selects its exact model.
|
||||
customModelInjection: {
|
||||
kind: 'env',
|
||||
baseUrlVar: 'DEEPSEEK_BASE_URL',
|
||||
apiKeyVar: 'DEEPSEEK_API_KEY',
|
||||
modelVars: [],
|
||||
appendV1Suffix: true,
|
||||
kind: 'configDir',
|
||||
dirEnvVar: 'DSH_HOME',
|
||||
fileName: 'cordis.patch.yml',
|
||||
template: 'deepseek-patch-json',
|
||||
// dsh keeps state in its home that the redirect must NOT swallow: `sessions` is the
|
||||
// session-log root (deleting it would take the user's transcripts with it on clear/
|
||||
// close), `profiles` are the installed bundles the patch composes over, and
|
||||
// `settings.yaml` must keep applying (read through the link only until the session's
|
||||
// first settings write: dsh saves by rename, which replaces the link with a local file
|
||||
// that goes away with the session). The generated home-level `cordis.patch.yml`
|
||||
// deliberately replaces the user's own for the session rather than linking it.
|
||||
// `sessions` and `profiles` are directories: dsh creates them itself on a first
|
||||
// turn, and a fresh/relocated $DSH_HOME that never ran one would otherwise get a
|
||||
// dangling link whose every write fails ENOENT: the consumer creates missing
|
||||
// directory targets and skips missing file targets like `settings.yaml`.
|
||||
linkFromRealHome: ['profiles', 'sessions', 'settings.yaml'],
|
||||
linkFromRealHomeDirs: ['profiles', 'sessions'],
|
||||
realHomeFallback: '.dsh',
|
||||
},
|
||||
},
|
||||
overlays: {
|
||||
@@ -1506,6 +1675,7 @@ const OMP: CliEntry = {
|
||||
},
|
||||
capabilities: {
|
||||
...agentDefaults(),
|
||||
...AGENTS_DIR_SKILL,
|
||||
// Fullscreen alt-screen TUI, same shape as antigravity/grok: only the
|
||||
// tmux-attach-time smcup strip, not Ink's full erase-scrollback+DECSET strip.
|
||||
altScreen: 'strip-mux-only',
|
||||
@@ -1568,5 +1738,236 @@ const OMP: CliEntry = {
|
||||
},
|
||||
};
|
||||
|
||||
// GitHub Copilot CLI (`copilot`, github/copilot-cli, npm @github/copilot). Measured on a live
|
||||
// 1.0.94 pane inside tmux (capture-pane through real turns, a shell tool call and /model,
|
||||
// 2026-10-09). It is an ALTERNATE-SCREEN TUI with mouse tracking on (tmux `alternate_on=1`,
|
||||
// `mouse_any_flag=1`, `history_size=0`), so like opencode its mouse DECSETs are stripped
|
||||
// (`altScreen: 'strip-mux-and-mouse'` below): a drag selects locally and the wheel is forwarded.
|
||||
// Keystrokes sent with `tmux send-keys` reach its composer and a shell tool call completes
|
||||
// inside tmux, which github/copilot-cli#4180 (a driven PTY ignoring input) and #4223 (a shell
|
||||
// command never reported done under tmux) say they do not on 1.0.70 to 1.0.74: neither
|
||||
// reproduces on 1.0.94, and the register of `version` below is what would show a regression.
|
||||
const COPILOT: CliEntry = {
|
||||
id: 'copilot' as CliEntry['id'],
|
||||
label: 'GitHub Copilot',
|
||||
shortBadge: 'CP',
|
||||
accent: '#a371f7',
|
||||
enabled: true,
|
||||
stock: true,
|
||||
order: 100,
|
||||
kind: 'agent',
|
||||
discovery: {
|
||||
binaries: ['copilot'],
|
||||
searchDirs: [HOME_DIRS.local, HOME_DIRS.usrLocal, HOME_DIRS.bunBin, HOME_DIRS.npmGlobal, HOME_DIRS.homeBin],
|
||||
// `copilot --version` prints `GitHub Copilot CLI 1.0.94.`; anchoring on the product name
|
||||
// keeps an unrelated `copilot` binary on PATH from passing the probe.
|
||||
version: { arg: '--version', regex: 'GitHub Copilot CLI (\\d+\\.\\d+\\.\\d+)', requireVersionMatch: true },
|
||||
install: {
|
||||
command: { linux: 'npm install -g @github/copilot', darwin: 'npm install -g @github/copilot' },
|
||||
npmPackage: '@github/copilot',
|
||||
docsUrl: 'https://github.com/github/copilot-cli',
|
||||
},
|
||||
},
|
||||
// `--yolo` (allow every tool, path and URL) is opt-in through `allowAll`, which the Run button
|
||||
// sends like the other agent CLIs' bypass switches; an absent config spawns a bare `copilot`
|
||||
// in its own Manual Approval mode. `--model` and `--resume=<id>` are Copilot's own flags
|
||||
// (`copilot --help`, 1.0.94). `--resume` takes an id, id prefix or session NAME and its value
|
||||
// is optional, so the token must be a single plain word that cannot start with `-`: a value
|
||||
// like `--yolo` would otherwise be parsed as its own flag and get around the `allowAll` clamp.
|
||||
launch: {
|
||||
params: {
|
||||
allowAll: { type: 'bool' },
|
||||
// `model-plain`, not `model`: the leading alphanumeric keeps a flag-shaped value out.
|
||||
model: { type: 'token', pattern: 'model-plain' },
|
||||
// `path-segment`, not `id-dotted`: the leading alphanumeric keeps a flag-shaped value out.
|
||||
resumeId: { type: 'token', pattern: 'path-segment' },
|
||||
continueSession: { type: 'bool' },
|
||||
// The tab's name, so the CLI's own session list reads the same as the tab.
|
||||
sessionName: { type: 'engine', source: 'sessionName' },
|
||||
},
|
||||
variants: [
|
||||
{
|
||||
id: 'default',
|
||||
args: [
|
||||
{ lit: 'copilot' },
|
||||
{ flag: '--yolo', when: { param: 'allowAll', is: true } },
|
||||
// `--name` is refused next to `--resume` and `--continue` (copilot 1.0.95: "cannot be used
|
||||
// with"), and a resumed session keeps the name it was created with.
|
||||
{
|
||||
flag: '--name',
|
||||
valueFrom: 'sessionName',
|
||||
quote: 'double',
|
||||
when: {
|
||||
allOf: [
|
||||
{ param: 'sessionName', state: 'set' },
|
||||
{ param: 'resumeId', state: 'unset' },
|
||||
{ not: { param: 'continueSession', is: true } },
|
||||
],
|
||||
},
|
||||
},
|
||||
{ flag: '--model', valueFrom: 'model', when: { param: 'model', state: 'set' } },
|
||||
{ flag: '--resume', valueFrom: 'resumeId', when: { param: 'resumeId', state: 'set' } },
|
||||
{
|
||||
lit: '--continue',
|
||||
when: {
|
||||
allOf: [
|
||||
{ param: 'continueSession', is: true },
|
||||
{ param: 'resumeId', state: 'unset' },
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
legacyConfigAliases: { resumeId: 'resumeSessionId' },
|
||||
legacyConfigField: 'copilotConfig',
|
||||
resumeAppend: { style: 'flag', flag: '--resume' },
|
||||
},
|
||||
env: {
|
||||
exports: [{ name: 'COLORTERM', value: 'truecolor' }],
|
||||
unset: ['NO_COLOR'],
|
||||
// Sign-in is the CLI's own (`copilot login`, kept under ~/.copilot). A headless host sets
|
||||
// COPILOT_GITHUB_TOKEN, which the COPILOT_ prefix already admits. GH_TOKEN / GITHUB_TOKEN are
|
||||
// deliberately NOT allowlisted: the env allowlist is one global list with no mode context, so
|
||||
// admitting them would make them settable on every session (see allowedEnvPrefixes()).
|
||||
tmuxSetenvKeys: [],
|
||||
dockerExecEnvNames: [],
|
||||
allowedPrefixes: ['COPILOT_'],
|
||||
allowedKeys: [],
|
||||
},
|
||||
capabilities: {
|
||||
...agentDefaults(),
|
||||
// A full-screen TUI that turns mouse tracking on itself (measured on 1.0.94: alt screen,
|
||||
// mouse reporting on). Kept, xterm reports a plain drag to the TUI instead of selecting, so
|
||||
// Auto Copy and "mark text, copy on select" silently do nothing; stripped, a drag is a local
|
||||
// selection and clicks still reach the CLI through the browser's hand-encoded tap
|
||||
// (`cliMouseTracking`). Same shape and reason as opencode. The wheel keeps working because
|
||||
// `_shouldForwardWheelToApp` forwards it as SGR reports while the CLI is tracking.
|
||||
altScreen: 'strip-mux-and-mouse',
|
||||
// Forwards the wheel as SGR reports while the CLI is tracking the mouse, at any version
|
||||
// (`_shouldForwardWheelToApp` in terminal-ui.js has no version check for copilot).
|
||||
wheelForward: { mode: 'always' },
|
||||
// Pages its own transcript on PageUp/PageDown (1.0.95, measured in tmux: three PageUp moved
|
||||
// lines 109-140 to 010-042 and PageDown returned). This is the fallback when the
|
||||
// wheel-forward flag is stale after a server restart, so the wheel and a swipe never go dead.
|
||||
transcriptPageKeys: true,
|
||||
echo: { policy: 'buffer', anchor: { kind: 'cursor' } },
|
||||
// What `codeman agent spawn` waits for. The composer's footer hint, NOT the `❯` glyph: the
|
||||
// folder-trust dialog draws `❯ 1. Yes` for its selected row, so the glyph would call a worker
|
||||
// ready while it is still asking for trust (measured on 1.0.95: the dialog's footer reads
|
||||
// `↑/↓ to navigate`, the composer's `/ commands · ? help`).
|
||||
composerReadyMark: '/ commands · ? help',
|
||||
// The composer row is a `❯` between two rules, and the submitted prompt is echoed as
|
||||
// ` ❯ <text> <time>` above it. While a turn runs the footer's left end reads
|
||||
// `◉ Working esc edit prompt` (the dot alternates `◉` and `◎`); at rest it reads
|
||||
// `← open sidebar · Interactive · Manual Approval · / commands · ? help · tab next tab`.
|
||||
// A tool call that is waiting for approval replaces the composer and the footer's
|
||||
// `Working` goes with it, so it reads as idle (waiting on the user), like gemini.
|
||||
workDetect: {
|
||||
promptGlyph: '❯',
|
||||
workingLine: String.raw`[◉◎] Working\b`,
|
||||
},
|
||||
// The model is the right-hand field of the last row, both at rest and mid-turn
|
||||
// (`... tab next tab Claude Sonnet 5.5`, `◎ Working esc edit prompt GPT-6 Luna`).
|
||||
// Anchored on the footer's own words so a model-looking string in the transcript cannot match.
|
||||
modelDetect: {
|
||||
screenLine: String.raw`(?:tab next tab|\? help|esc edit prompt|github-mcp-server) {3,}([A-Za-z0-9][\w.:/@ -]{1,60}?) *(?:\n|$)`,
|
||||
screenLines: 2,
|
||||
},
|
||||
// `copilot mcp add` writes `~/.copilot/mcp-config.json`; COPILOT_HOME replaces ~/.copilot
|
||||
// (checked: `COPILOT_HOME=<dir> copilot mcp list` reads <dir>).
|
||||
mcpConfig: {
|
||||
path: '.copilot/mcp-config.json',
|
||||
format: 'copilot-json',
|
||||
relocation: { envVar: 'COPILOT_HOME', path: 'mcp-config.json' },
|
||||
},
|
||||
// Every one of these matches the COPILOT_ allowedPrefix, so a non-granted multi-user owner
|
||||
// must not be able to set it through envOverrides. Read from the 1.0.98 bundle (copilot reads
|
||||
// far more COPILOT_* variables than `copilot help environment` lists), by what each hands back:
|
||||
// - approval: COPILOT_ALLOW_ALL turns every tool on; COPILOT_PLAN_THEN_AUTOPILOT switches a
|
||||
// plan into autopilot; COPILOT_ASSISTED_APPROVAL; COPILOT_HOME restates the permission config.
|
||||
// - code at boot or without an approval row: COPILOT_CLI_DIST_DIR runs `<dir>/index.js` in place
|
||||
// of the CLI, the package caches pick which CLI build runs, COPILOT_EDITOR,
|
||||
// COPILOT_PROVIDER_API_KEY_COMMAND and COPILOT_AHP_HOST_BIN are run as commands, skills and
|
||||
// provider configs are loaded from the named paths, and the hook switches widen what hooks run.
|
||||
// - credential redirects: the Copilot and GitHub API bases receive the CLI's sign-in token, which
|
||||
// in multi-user mode is the operator's (every user shares the OS account and ~/.copilot), and
|
||||
// the BYOK provider family aims a Codeman custom endpoint elsewhere.
|
||||
// The prefix stays because COPILOT_GITHUB_TOKEN and the harmless knobs live in it; a variable a
|
||||
// later release adds is NOT covered until it is listed here.
|
||||
privilegedEnvKeys: [
|
||||
'COPILOT_HOME',
|
||||
'COPILOT_ALLOW_ALL',
|
||||
'COPILOT_PLAN_THEN_AUTOPILOT',
|
||||
'COPILOT_ASSISTED_APPROVAL',
|
||||
'COPILOT_CLI_DIST_DIR',
|
||||
'COPILOT_CLI_RESOLVED_DIST_DIR',
|
||||
'COPILOT_PKG_CACHE_HOME',
|
||||
'COPILOT_CACHE_HOME',
|
||||
'COPILOT_EDITOR',
|
||||
'COPILOT_AHP_HOST_BIN',
|
||||
'COPILOT_DEBUG_VOICE_ENGINE_SCRIPT',
|
||||
'COPILOT_SKILLS_DIRS',
|
||||
'COPILOT_PROVIDERS_CONFIG',
|
||||
'COPILOT_HOOK_ALLOW_HTTP_AUTH_HOOKS',
|
||||
'COPILOT_HOOK_ALLOW_LOCALHOST',
|
||||
'COPILOT_E2E_TEST_HOOKS',
|
||||
'COPILOT_API_URL',
|
||||
'COPILOT_DEBUG_GITHUB_API_URL',
|
||||
'COPILOT_GH_HOST',
|
||||
'COPILOT_MC_BASE_URL',
|
||||
'COPILOT_AHP_URL',
|
||||
'COPILOT_DEBUG_ENTRA_BROKER_URL',
|
||||
'COPILOT_CAFE_METRICS_URL',
|
||||
'COPILOT_PROVIDER_BASE_URL',
|
||||
'COPILOT_PROVIDER_API_KEY',
|
||||
'COPILOT_PROVIDER_API_KEY_COMMAND',
|
||||
'COPILOT_PROVIDER_BEARER_TOKEN',
|
||||
'COPILOT_PROVIDER_HEADERS',
|
||||
'COPILOT_PROVIDER_TYPE',
|
||||
'COPILOT_PROVIDER_WIRE_MODEL',
|
||||
'COPILOT_PROVIDER_MODEL_ID',
|
||||
'COPILOT_PROVIDER_GHES_HOST',
|
||||
'COPILOT_PROVIDER_GHES_TOKEN',
|
||||
'COPILOT_MODEL',
|
||||
],
|
||||
// Grok/codex-shaped clamp: a bare spawn is already Manual Approval, so the multi-user clamp
|
||||
// only forces an EXPLICITLY-SENT `--yolo` back off; nothing is materialized when absent.
|
||||
privilegedParams: [{ param: 'allowAll', clampTo: false }],
|
||||
// BYOK (`copilot help environment`, 1.0.95): COPILOT_PROVIDER_BASE_URL switches the CLI to a
|
||||
// custom provider and drops the GitHub sign-in requirement; COPILOT_PROVIDER_TYPE defaults to
|
||||
// "openai", which is what a Codeman custom endpoint is (llama.cpp, vLLM, Ollama).
|
||||
// COPILOT_MODEL names the model and COPILOT_PROVIDER_MODEL_ID / _WIRE_MODEL default to it.
|
||||
// The OpenAI-style route is `<base>/v1/chat/completions`, so the base gets the `/v1`
|
||||
// suffix via appendV1Suffix below. All four already match the COPILOT_ prefix allowlist, so
|
||||
// they are privileged env keys below: a non-granted multi-user owner must not be able to
|
||||
// redirect the session's endpoint or credentials through plain envOverrides.
|
||||
customModelInjection: {
|
||||
kind: 'env',
|
||||
baseUrlVar: 'COPILOT_PROVIDER_BASE_URL',
|
||||
apiKeyVar: 'COPILOT_PROVIDER_API_KEY',
|
||||
modelVars: ['COPILOT_MODEL'],
|
||||
appendV1Suffix: true,
|
||||
},
|
||||
},
|
||||
overlays: {
|
||||
// No credStore: Docker seeding is CRED_STORES in docker-hosts.ts, which has no `.copilot` row,
|
||||
// and the sign-in token sits in plain text in config.json on a host without a keyring, so it
|
||||
// is not copied into a container by default. Sign in inside the container instead.
|
||||
},
|
||||
};
|
||||
|
||||
/** The full stock catalog, in the order the run menu shows by default. */
|
||||
export const STOCK_CLIS: CliEntry[] = [CLAUDE, SHELL, OPENCODE, CODEX, GEMINI, ANTIGRAVITY, PI, GROK, DEEPSEEK, OMP];
|
||||
export const STOCK_CLIS: CliEntry[] = [
|
||||
CLAUDE,
|
||||
SHELL,
|
||||
OPENCODE,
|
||||
CODEX,
|
||||
GEMINI,
|
||||
ANTIGRAVITY,
|
||||
PI,
|
||||
GROK,
|
||||
DEEPSEEK,
|
||||
OMP,
|
||||
COPILOT,
|
||||
];
|
||||
@@ -29,6 +29,8 @@ export type EngineValue =
|
||||
| 'muxName'
|
||||
| 'effortLevel'
|
||||
| 'effortSettingsJson'
|
||||
/** The Discord bridge's startup hint, passed to Claude as `--append-system-prompt`. */
|
||||
| 'appendSystemPrompt'
|
||||
/** `sessionId` prefixed `codeman_<id>` — codex's unique per-pane rollout originator. */
|
||||
| 'codemanPrefixedSessionId'
|
||||
/**
|
||||
@@ -69,6 +71,14 @@ export type Cond =
|
||||
*/
|
||||
export type QuoteStyle = 'auto' | 'bare' | 'double' | 'single';
|
||||
|
||||
/**
|
||||
* Workspace-relative directories an agent CLI scans for project skills. A closed list
|
||||
* rather than a free path: the value decides where the server WRITES, and
|
||||
* `~/.codeman/clis.json` can set it, so it must never be able to name `..` or an
|
||||
* absolute path.
|
||||
*/
|
||||
export type AgentSkillDir = '.claude/skills' | '.agents/skills';
|
||||
|
||||
/** One argv element. */
|
||||
export type ArgSpec =
|
||||
/** A bare literal word, e.g. the base binary or codex's `resume` subcommand. */
|
||||
@@ -398,6 +408,65 @@ export interface CliCapabilities {
|
||||
* tested. See `isAwaitingWorkers()` in `session-activity.ts`.
|
||||
*/
|
||||
awaitingLine?: string;
|
||||
/**
|
||||
* Source of a regex matching the dialog this CLI draws when it stops and waits for the
|
||||
* user to answer something (an approval, a question, a trust prompt): Codeman reads
|
||||
* such a pane as BLOCKED, with the same red "needs you" alert, push, webhook and
|
||||
* `blocked` wait signal a Claude `permission_prompt` hook raises. It is the source of
|
||||
* that state for a CLI with no hook of its own (`hooks: 'none'`); a CLI whose hooks
|
||||
* report it never consults the screen for it.
|
||||
*
|
||||
* Tested against the last `blockedLines` non-blank rows of a pane capture, joined
|
||||
* with `\n`, so the pattern can require several markers at once and anchor on the
|
||||
* end of the window (no `m` flag: `$` is the last row). It must anchor on chrome
|
||||
* only that CLI draws, at the FOOT of the screen, where a dialog replaces the
|
||||
* composer: the text above is the agent's own, and it can print anything. A forged
|
||||
* match may cost a false alert and nothing else, because Codeman never answers such a
|
||||
* dialog. See `isBlockedScreen()` in `session-activity.ts`.
|
||||
*
|
||||
* A CLI that omits it is never blocked from the screen, which is what every CLI was
|
||||
* before the field existed.
|
||||
*/
|
||||
blockedLine?: string;
|
||||
/**
|
||||
* How many non-blank rows at the foot of the screen `blockedLine` sees. A dialog's
|
||||
* options wrap at narrow widths, which is what pushes its markers up the screen, so
|
||||
* 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;
|
||||
/**
|
||||
* 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
|
||||
@@ -491,7 +560,7 @@ export interface CliCapabilities {
|
||||
* own mouse support must work in the pane (htop/vim in a shell).
|
||||
*
|
||||
* Stock CLIs: `strip-full` = claude, codex, gemini (Ink TUIs); `strip-mux-and-mouse` =
|
||||
* opencode (a full-screen TUI that enables tracking itself); `strip-mux-only` =
|
||||
* opencode and copilot (full-screen TUIs that enable tracking themselves); `strip-mux-only` =
|
||||
* antigravity, grok, deepseek, omp; `preserve` = shell, pi.
|
||||
*
|
||||
* A fourth combination is the point to split this into flags; three is still cheaper
|
||||
@@ -505,8 +574,54 @@ export interface CliCapabilities {
|
||||
/** Names a PREDICT_PROFILES key. Unknown or absent degrades to 'buffer', never to broken. */
|
||||
predictProfile?: string;
|
||||
};
|
||||
/** Forwarding the wheel to the CLI's own transcript. 'never' keeps local scrollback. */
|
||||
wheelForward: { mode: 'never' | 'version-gated'; minVersion?: string };
|
||||
/**
|
||||
* Forwarding the wheel to the CLI's own transcript. 'never' keeps local scrollback;
|
||||
* 'version-gated' forwards from `minVersion` on; 'always' forwards at any version. Either
|
||||
* forwarding mode applies only while the CLI has mouse tracking on (`cliMouseTracking`).
|
||||
*
|
||||
* ⚠️ Declared only: nothing reads this field yet (see DECLARED-FOR-LATER below). The browser's
|
||||
* gate is `_shouldForwardWheelToApp` in terminal-ui.js, which keeps its own hand-written
|
||||
* per-CLI rules, so changing a value here changes nothing until that gate is changed too.
|
||||
*/
|
||||
wheelForward: { mode: 'never' | 'version-gated' | 'always'; minVersion?: string };
|
||||
/**
|
||||
* The CLI pages its OWN transcript on PageUp/PageDown, so a wheel or touch scroll over
|
||||
* a pane with no local history can be turned into those keys instead of doing nothing
|
||||
* (`_maybePageCliTranscript` in terminal-ui.js). Read by the frontend through
|
||||
* `window.__codemanTranscriptPageKeys`, which the server builds from this field.
|
||||
*
|
||||
* ⚠ NOT wheel forwarding. `wheelForward` above sends SGR mouse wheel REPORTS; this sends
|
||||
* plain keys, and only when the local buffer is hollow (normal buffer, `baseY === 0`).
|
||||
* A CLI can ignore one and honour the other: Codex without observed mouse tracking
|
||||
* uses this fallback, while fullscreen Codex 0.163.0 accepts SGR wheel reports.
|
||||
*
|
||||
* 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;
|
||||
@@ -515,7 +630,20 @@ export interface CliCapabilities {
|
||||
ralph: boolean;
|
||||
respawn: boolean;
|
||||
effort: boolean;
|
||||
/**
|
||||
* Codeman may write its agent skill (`skills/codeman`) into this CLI's workspace and
|
||||
* seed its per-session preamble (behind the synced `agentSkillEnabled` setting,
|
||||
* add-only, marker-owned). Where it lands comes from `agentSkillDir` below.
|
||||
*/
|
||||
agentSkillInjection: boolean;
|
||||
/**
|
||||
* The workspace-relative directory this CLI scans for project skills, so the skill
|
||||
* lands at `<workspace>/<agentSkillDir>/codeman/SKILL.md` where the CLI discovers it.
|
||||
* Measured per CLI. Absent with `agentSkillInjection: true` means Claude Code's
|
||||
* `.claude/skills`, which is what the boolean meant before this field existed, so a
|
||||
* `~/.codeman/clis.json` written against the old schema keeps its behaviour.
|
||||
*/
|
||||
agentSkillDir?: AgentSkillDir;
|
||||
statusLineTelemetry: boolean;
|
||||
/** Where a model override is delivered. Claude uniquely writes settings.local.json. */
|
||||
model: { source: 'flag' | 'claude-settings-file' | 'none'; param?: string };
|
||||
@@ -644,7 +772,12 @@ export interface CliCapabilities {
|
||||
* reported `skipped` rather than written somewhere the CLI never reads. Absent = the file
|
||||
* only follows `$HOME`.
|
||||
*/
|
||||
mcpConfig?: { path: string; format: McpConfigFormat; relocation?: { envVar: string; path: string } };
|
||||
mcpConfig?: {
|
||||
path: string;
|
||||
altPath?: string;
|
||||
format: McpConfigFormat;
|
||||
relocation?: { envVar: string; path: string };
|
||||
};
|
||||
/**
|
||||
* How this CLI is pointed at a user-supplied custom OpenAI-compatible
|
||||
* endpoint (local, e.g. llama.cpp, or cloud, e.g. Azure AI Foundry) — the
|
||||
@@ -681,9 +814,11 @@ export interface CliCapabilities {
|
||||
* for pi and omp (`--model custom/<id>`, or the CLI stays on its own default
|
||||
* provider and reports "No API key found for the selected model") or for
|
||||
* grok (`--model codeman-custom`, the `[model.<name>]` block the config
|
||||
* declares). Absent = the config alone selects the model (claude's env vars,
|
||||
* opencode's blob, codex's top-level `model` key). Applied by the session's
|
||||
* respawn options through the entry's `legacyConfigField`, never by id.
|
||||
* declares), and codex carries its bare selected id (`{modelId}`, an id the
|
||||
* entry's `model` token pattern must be able to carry). Absent = the config
|
||||
* alone selects the model (claude's env vars, opencode's blob). Applied by
|
||||
* the session's respawn options through the entry's `legacyConfigField`,
|
||||
* never by id.
|
||||
*
|
||||
* `contextLengthVar` (env kind only): the env var a discovered per-model context-window
|
||||
* size is written to when known (claude's `CLAUDE_CODE_MAX_CONTEXT_TOKENS`) — without it,
|
||||
@@ -725,19 +860,13 @@ export interface CliCapabilities {
|
||||
* reuses that file.
|
||||
*
|
||||
* `appendV1Suffix` (env kind only): the raw `endpoint.baseUrl` gets `withV1Suffix()`
|
||||
* applied before being written to `baseUrlVar`, instead of being used verbatim.
|
||||
* DeepSeek needs this and claude/gemini must NOT get it — a per-CLI asymmetry confirmed
|
||||
* by reading each SDK's own request-building source, not assumed: DeepSeek Harness's
|
||||
* bundled `@deepseek-ai/dsh-llm-deepseek` concatenates `${connection.baseURL}/chat/
|
||||
* completions` with no `/v1` insertion of its own (its real public API base,
|
||||
* `https://api.deepseek.com`, expects the caller's base URL to already carry any
|
||||
* needed prefix), while llama-swap/llama.cpp only ever serves the OpenAI-conventional
|
||||
* `/v1/chat/completions` — confirmed live: a bare `POST <baseUrl>/chat/completions`
|
||||
* 404s, `POST <baseUrl>/v1/chat/completions` succeeds, and the harness's own error
|
||||
* message template (`DeepSeek API error (HTTP ${status})`) reproduces the exact
|
||||
* `HTTP_404` this feature originally shipped with unexplained. Claude Code's own SDK,
|
||||
* by contrast, was already confirmed working end-to-end against the RAW `baseUrl` with
|
||||
* no suffix — appending one there would be wrong, not just redundant.
|
||||
* applied before being written to `baseUrlVar`, instead of being used verbatim. Only
|
||||
* copilot declares it: its OpenAI-style route is `<base>/v1/chat/completions`, and a
|
||||
* gateway saves the bare root. claude/gemini must NOT get it: Claude Code's own SDK was
|
||||
* confirmed working end-to-end against the RAW `baseUrl`, so appending a suffix there
|
||||
* would be wrong, not just redundant. The per-session config-file recipes do not need
|
||||
* this flag at all: `renderConfigFile()` applies the suffix inside every template
|
||||
* (deepseek's patch included), so no configDir entry carries a raw base URL.
|
||||
*/
|
||||
customModelInjection:
|
||||
| {
|
||||
@@ -757,8 +886,40 @@ export interface CliCapabilities {
|
||||
kind: 'configDir';
|
||||
dirEnvVar: string;
|
||||
fileName: string;
|
||||
template: 'codex-toml' | 'pi-models-json' | 'omp-models-yml' | 'grok-toml';
|
||||
template: 'codex-toml' | 'pi-models-json' | 'omp-models-yml' | 'grok-toml' | 'deepseek-patch-json';
|
||||
launchModel?: string;
|
||||
/**
|
||||
* Names of entries under this CLI's REAL home that must be symlinked into the
|
||||
* isolated directory after the generated file is written: the same trick
|
||||
* `linkSharedProjectsDir()` plays for claude's `projects`. Declared for CLIs whose
|
||||
* home carries state that must survive the redirect (deepseek's `sessions` transcript
|
||||
* root and `profiles` bundles, plus `settings.yaml`, which would otherwise stop
|
||||
* applying and, for `sessions`, get deleted by `removeConfigDir` on clear/close).
|
||||
* A CLI that saves a linked file by rename replaces the link: dsh does, so the first
|
||||
* in-session settings write turns `settings.yaml` into a local file.
|
||||
* Never include `fileName` itself: the generated file owns that name in the isolated
|
||||
* directory, and a link there would write THROUGH into the user's real file.
|
||||
* Consumed generically by custom-model-injection-apply.ts.
|
||||
*/
|
||||
linkFromRealHome?: string[];
|
||||
/**
|
||||
* Which of `linkFromRealHome` are DIRECTORIES (`sessions`, `profiles`) rather than
|
||||
* files (`settings.yaml`). A missing directory target is created (mode 0700, what
|
||||
* dsh does itself on a first turn) before linking: linking a nonexistent directory
|
||||
* leaves a dangling symlink the CLI's own `mkdir` under it fails on with ENOENT on
|
||||
* EVERY turn. A missing FILE target is skipped instead of linked (dangling-file
|
||||
* links serve no purpose the CLI could not do itself). Must be a subset of
|
||||
* `linkFromRealHome`; entries absent from it are treated as files.
|
||||
* Consumed generically by custom-model-injection-apply.ts.
|
||||
*/
|
||||
linkFromRealHomeDirs?: string[];
|
||||
/**
|
||||
* Fallback location of the CLI's real home, relative to the user's home directory
|
||||
* (deepseek's `.dsh`), used only when neither the session's own `dirEnvVar` override
|
||||
* nor the server's environment sets it. Required whenever `linkFromRealHome` is
|
||||
* declared, so no consumer has to hardcode a per-CLI default of its own.
|
||||
*/
|
||||
realHomeFallback?: string;
|
||||
}
|
||||
| { kind: 'unsupported' };
|
||||
}
|
||||
|
||||
@@ -18,11 +18,40 @@
|
||||
// Session Tracking Limits
|
||||
// ============================================================================
|
||||
|
||||
/** Default cap on concurrent sessions when `CODEMAN_MAX_SESSIONS` is not set. */
|
||||
const DEFAULT_MAX_CONCURRENT_SESSIONS = 50;
|
||||
|
||||
/**
|
||||
* Highest value `CODEMAN_MAX_SESSIONS` can raise the cap to. An owner's tab layout
|
||||
* holds at most `MAX_TAB_REFS` (512) refs, and those count saved web tabs as well
|
||||
* as sessions; an owner can save up to `MAX_WEBVIEWS` (50 by default) web tabs.
|
||||
* 512 - 50 = 462 keeps session creation answering with the capacity message
|
||||
* instead of failing with a 500 on the layout limit, at the default `MAX_WEBVIEWS`
|
||||
* and with no pinned closed sessions: a pinned session stays in the layout as a
|
||||
* stopped record after it is closed, and it shares the same 512-ref budget.
|
||||
* (`PUT /api/session-order` accepts up to 500 ids, which stays above this.)
|
||||
*/
|
||||
export const MAX_CONCURRENT_SESSIONS_CEILING = 462;
|
||||
|
||||
/**
|
||||
* Resolve the session cap from `CODEMAN_MAX_SESSIONS` (a positive integer,
|
||||
* clamped to {@link MAX_CONCURRENT_SESSIONS_CEILING}). Anything else (unset,
|
||||
* empty, 0, negative, fractional, not a number) falls back to
|
||||
* {@link DEFAULT_MAX_CONCURRENT_SESSIONS}, so a typo never locks session creation.
|
||||
*/
|
||||
function resolveMaxConcurrentSessions(): number {
|
||||
const raw = process.env.CODEMAN_MAX_SESSIONS?.trim();
|
||||
if (!raw || !/^\d+$/.test(raw)) return DEFAULT_MAX_CONCURRENT_SESSIONS;
|
||||
const n = Number(raw);
|
||||
return n >= 1 ? Math.min(n, MAX_CONCURRENT_SESSIONS_CEILING) : DEFAULT_MAX_CONCURRENT_SESSIONS;
|
||||
}
|
||||
|
||||
/**
|
||||
* Maximum concurrent sessions allowed.
|
||||
* Each session consumes significant resources (PTY, buffers, watchers).
|
||||
* 50 unless `CODEMAN_MAX_SESSIONS` says otherwise (1 to 462); read once at startup.
|
||||
*/
|
||||
export const MAX_CONCURRENT_SESSIONS = 50;
|
||||
export const MAX_CONCURRENT_SESSIONS = resolveMaxConcurrentSessions();
|
||||
|
||||
// ============================================================================
|
||||
// SSE Client Limits
|
||||
|
||||
@@ -46,3 +46,10 @@ export const MAX_STALLED_PATH_PROBES = Math.min(
|
||||
PATH_PROBE_STALL_CEILING,
|
||||
envInt('CODEMAN_PATH_PROBE_MAX_STALLED', Math.max(1, PATH_PROBE_STALL_CEILING - 1), 1, 64)
|
||||
);
|
||||
|
||||
/**
|
||||
* How long a session waits before trying again to resolve its workspace's symlinks
|
||||
* after the folder did not answer, so a mount that recovers is picked up (the pane
|
||||
* directory reading stays in the resolved spelling meanwhile).
|
||||
*/
|
||||
export const WORKSPACE_REALPATH_RETRY_MS = 60_000;
|
||||
@@ -70,6 +70,17 @@ export const SESSION_LIMIT_WAIT_MS = 5000;
|
||||
/** Pause between scheduled run iterations (ms) */
|
||||
export const ITERATION_PAUSE_MS = 2000;
|
||||
|
||||
// ============================================================================
|
||||
// Git Fetch Interval
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
export const GIT_FETCH_TICK_INTERVAL_MS = 15 * 1000;
|
||||
|
||||
// ============================================================================
|
||||
// Mux Stats
|
||||
// ============================================================================
|
||||
|
||||
@@ -97,7 +97,7 @@ const CRON_PSEUDO_FS_TREES: readonly string[] = ['/proc', '/sys', '/dev'];
|
||||
/** Sync blocklist for the create/update workingDir gate (no settings extras). */
|
||||
const CRON_WORKING_DIR_BLOCKED_TREES: readonly string[] = [...DEFAULT_BLOCKED_TREES, ...CRON_PSEUDO_FS_TREES];
|
||||
|
||||
/** Prompt delivery is single-line only (writeViaMux/Ink constraint). */
|
||||
/** Prompt delivery is single-line only: the submit check cannot reliably recognise a multi-line paste. */
|
||||
const HAS_NEWLINE = /[\r\n]/;
|
||||
|
||||
/** The three session calls prompt delivery needs, so it can be tested without a PTY. */
|
||||
@@ -536,11 +536,11 @@ export class CronService {
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves the prompt text and enforces the single-line constraint: prompt
|
||||
* delivery rides writeViaMux/PTY writes where a newline is Enter, so a
|
||||
* multi-line prompt would be silently corrupted (typed mode fuses lines,
|
||||
* paste mode submits the first line and dribbles the rest in as separate
|
||||
* messages). Rather than mangle an unattended agent's instructions, fail the
|
||||
* Resolves the prompt text and enforces the single-line constraint: a job
|
||||
* fires unattended, and the submit check that re-presses an ignored Enter
|
||||
* cannot reliably recognise a multi-line paste in the composer (typed mode), so a
|
||||
* stranded prompt would go unnoticed; in paste mode the newlines reach the
|
||||
* CLI as keys. Rather than risk an unattended agent's instructions, fail the
|
||||
* run with a clear error. A prompt FILE may end with trailing newline(s)
|
||||
* (every editor writes one) — those are stripped before the check.
|
||||
*/
|
||||
|
||||
@@ -52,15 +52,35 @@ export interface CustomModelHost {
|
||||
defaultModelId?: string;
|
||||
/**
|
||||
* Discovered context-window size (tokens) per model id, keyed by the same strings as
|
||||
* `models`. Populated opportunistically during discovery (`custom-model-routes.ts`) from
|
||||
* llama.cpp/llama-swap's `GET /props?model=<id>` — the plain OpenAI-shaped `/v1/models`
|
||||
* response has no such field. Only ever probed for a model the server already reports as
|
||||
* loaded (llama-swap's `status.value === 'loaded'`); an unloaded one is deliberately never
|
||||
* probed, since llama-swap treats `/props?model=` as a routing hint that can trigger an
|
||||
* actual (slow, GPU-swapping) model load as a side effect of merely asking. A model this
|
||||
* has no entry for simply gets no context-length env override applied — never a guess.
|
||||
* `models`. Populated only by discovery (`custom-model-routes.ts`), from the strongest
|
||||
* source the server offers: the llama-swap launch command reported by `GET /running`
|
||||
* (the source of truth for a loaded model), then llama.cpp's `GET /props?model=<id>`
|
||||
* `n_ctx` (only ever probed for a model the server already reports as loaded, since
|
||||
* llama-swap treats `/props?model=` as a routing hint that can trigger an actual
|
||||
* (slow, GPU-swapping) model load as a side effect of merely asking), then the model's
|
||||
* own `GET /v1/models` entry (`context_length`, else `context_window`, which current
|
||||
* llama-swap, Bifrost and OpenRouter-style servers each state to varying degrees). A
|
||||
* model with no entry here falls back to the endpoint's `defaultContextLength`; with
|
||||
* neither, it simply gets no context-length env override applied, never a guess.
|
||||
*/
|
||||
modelContextLengths?: Record<string, number>;
|
||||
/**
|
||||
* Discovered number of parallel server slots per model id, keyed like `modelContextLengths`
|
||||
* but populated only for models the server reports loaded (a listing never states a slot
|
||||
* count, unlike `contextLengths`, which every entry can supply). Read from the launch command's
|
||||
* `--parallel`/`-np` when it states one, otherwise from llama.cpp's own `/props`
|
||||
* `total_slots` (a llama-server started without `--parallel` picks the count itself, so
|
||||
* the command cannot say). llama-server splits its context across these slots (by default
|
||||
* they share one unified KV pool) and Claude Code sends several requests at once (the
|
||||
* main session plus each parallel subagent), so with N slots each request can only rely
|
||||
* on roughly `contextLength / N` tokens. Used only by the context warning; a model this
|
||||
* has no entry for is treated as a single slot, never a guess. The division always uses
|
||||
* `modelContextLengths` (the launch command's figure, or `/props` only when the command states
|
||||
* none): `/props` never widens the pool. An explicit `--parallel N` whose context came from
|
||||
* `/props` is recorded as 1 slot, because that figure is already per slot, and that also
|
||||
* overwrites any count an earlier round stored.
|
||||
*/
|
||||
modelSlotCounts?: Record<string, number>;
|
||||
/**
|
||||
* Discovered file size (GB) per model id, keyed by the same strings as `models`.
|
||||
* Populated during discovery by parsing llama-swap's own `description` field for an
|
||||
@@ -72,6 +92,29 @@ export interface CustomModelHost {
|
||||
* server-side check relies on.
|
||||
*/
|
||||
modelSizesGB?: Record<string, number>;
|
||||
/**
|
||||
* User-set fallback context length (tokens), applied via
|
||||
* `resolveCustomModelContextLength()` at every read site (the swap route, the
|
||||
* quick-start route, boot recovery) only when `modelContextLengths` has no entry for
|
||||
* the selected model (a gateway that states no per-model figure, a model never loaded
|
||||
* yet). It is the model's WHOLE window, never a per-slot figure: the Claude floor
|
||||
* check divides it by any stored slot count exactly like a discovered figure. Never
|
||||
* written by discovery, so a value set in the endpoint editor survives every
|
||||
* re-discovery sweep; unset keeps the long-standing behaviour of letting each CLI
|
||||
* assume its own default.
|
||||
*/
|
||||
defaultContextLength?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* The context window to apply for `modelId` on `host`: the discovered per-model figure when
|
||||
* discovery has one, else the endpoint's user-set `defaultContextLength`, else undefined
|
||||
* (no override applied, never a guess). ONE helper for every read site: the swap route,
|
||||
* the quickstart route and boot recovery each re-write the CLI's config files, and a site
|
||||
* that forgot the fallback silently loses the endpoint default on the next respawn.
|
||||
*/
|
||||
export function resolveCustomModelContextLength(host: CustomModelHost, modelId: string): number | undefined {
|
||||
return host.modelContextLengths?.[modelId] ?? host.defaultContextLength;
|
||||
}
|
||||
|
||||
export function customModelHostsPath(configDir: string): string {
|
||||
|
||||
@@ -10,9 +10,9 @@
|
||||
* cli-registry changes" requirement it was written against.
|
||||
*/
|
||||
|
||||
import { chmodSync, existsSync, mkdirSync, readFileSync, writeFileSync, rmSync, symlinkSync } from 'node:fs';
|
||||
import { chmodSync, existsSync, lstatSync, mkdirSync, readFileSync, writeFileSync, rmSync, symlinkSync } from 'node:fs';
|
||||
import { homedir, platform } from 'node:os';
|
||||
import { join, dirname } from 'node:path';
|
||||
import { join, dirname, isAbsolute } from 'node:path';
|
||||
import { dataPath } from './config/instance.js';
|
||||
import type { CliEntry } from './config/cli-registry/types.js';
|
||||
import {
|
||||
@@ -30,25 +30,99 @@ export function customModelConfigDir(sessionId: string): string {
|
||||
* Writes a `ConfigDirInjection`'s files under `baseDir` and returns the full
|
||||
* envOverrides object a caller should merge into the session/process env
|
||||
* (the dir-redirect var plus any `extraEnv` the config file references by
|
||||
* name). Never touches anything outside `baseDir` — the caller is
|
||||
* responsible for choosing an isolated directory (never the user's real
|
||||
* `~/.codex`, `~/.pi`, etc.).
|
||||
* name). The generated files stay under `baseDir`, but a recipe that declares
|
||||
* `linkFromRealHome` additionally links (and, for missing directory targets,
|
||||
* creates) entries in the CLI's REAL home: the caller is still responsible
|
||||
* for choosing an isolated `baseDir` (never the user's real `~/.codex`,
|
||||
* `~/.pi`, etc.), it is just no longer the only directory touched.
|
||||
*
|
||||
* pi and omp embed the API key literally in the file, so the tree is written
|
||||
* 0700/0600 like every other secret-bearing file under `~/.codeman`; the chmod
|
||||
* covers a re-apply onto a file that already exists (`mode` only applies at
|
||||
* creation).
|
||||
*/
|
||||
export function applyConfigDirInjection(baseDir: string, injection: ConfigDirInjection): Record<string, string> {
|
||||
export function applyConfigDirInjection(
|
||||
baseDir: string,
|
||||
injection: ConfigDirInjection,
|
||||
/**
|
||||
* The value this SESSION's own env overrides set for `injection.dirEnvVar`, when it
|
||||
* set one: a user who relocated the CLI's real home (an allowlisted key like
|
||||
* DSH_HOME) must have their state linked from THAT directory, not the server's.
|
||||
*/
|
||||
sessionHomeOverride?: string
|
||||
): Record<string, string> {
|
||||
for (const file of injection.files) {
|
||||
const filePath = join(baseDir, file.relPath);
|
||||
mkdirSync(dirname(filePath), { recursive: true, mode: 0o700 });
|
||||
writeFileSync(filePath, file.content, { encoding: 'utf8', mode: 0o600 });
|
||||
chmodSync(filePath, 0o600);
|
||||
}
|
||||
linkRealHomeEntries(baseDir, injection, sessionHomeOverride);
|
||||
return { [injection.dirEnvVar]: baseDir, ...injection.extraEnv };
|
||||
}
|
||||
|
||||
/**
|
||||
* Symlinks the registry recipe's `linkFromRealHome` entries out of the CLI's real home
|
||||
* into the otherwise-isolated `baseDir`, so redirecting `dirEnvVar` moves only the
|
||||
* generated config file and not the state that has to survive the session (deepseek:
|
||||
* the `sessions` transcript root `removeConfigDir` would otherwise delete, the `profiles`
|
||||
* bundles the patch composes over, and `settings.yaml`). Declared per entry in stock.ts:
|
||||
* this consumer is recipe-driven and names no CLI itself.
|
||||
*
|
||||
* Home resolution: the session's own override first (unless it IS `baseDir`, a re-apply
|
||||
* onto a session already holding a previous injection's value must not self-link), then
|
||||
* the server's environment, then the recipe's `realHomeFallback`. Only ABSOLUTE values
|
||||
* are considered: a set-but-relative `DSH_HOME`-style home resolves against the CLI's
|
||||
* cwd, and joining it here would mkdir/symlink under the SERVER's working directory and
|
||||
* leave relative links that dangle. No home resolvable =
|
||||
* no links, same best-effort posture as `linkSharedProjectsDir()`.
|
||||
*/
|
||||
function linkRealHomeEntries(baseDir: string, injection: ConfigDirInjection, sessionHomeOverride?: string): void {
|
||||
const names = injection.linkFromRealHome;
|
||||
if (!names || names.length === 0) return;
|
||||
const absoluteOnly = (value: string | undefined): string | undefined =>
|
||||
value && isAbsolute(value) ? value : undefined;
|
||||
const fromSession = sessionHomeOverride?.trim();
|
||||
const realHome =
|
||||
absoluteOnly(fromSession && fromSession !== baseDir ? fromSession : undefined) ||
|
||||
absoluteOnly(process.env[injection.dirEnvVar]?.trim()) ||
|
||||
(injection.realHomeFallback ? join(homedir(), injection.realHomeFallback) : undefined);
|
||||
if (!realHome) return;
|
||||
const dirs = new Set(injection.linkFromRealHomeDirs ?? []);
|
||||
for (const name of names) {
|
||||
linkRealHomeEntry(join(realHome, name), join(baseDir, name), dirs.has(name));
|
||||
}
|
||||
}
|
||||
|
||||
function linkRealHomeEntry(target: string, link: string, isDir: boolean): void {
|
||||
// lstat, never existsSync: existsSync is false for a DANGLING link, and recovery
|
||||
// re-applies on every restart, so the second pass would try to recreate the link and
|
||||
// throw EEXIST. lstat answers for real entries, good links and dangling links alike.
|
||||
try {
|
||||
if (lstatSync(link)) return; // already linked (idempotent re-apply) or a real dir wrote one
|
||||
} catch {
|
||||
// ENOENT: nothing is here yet, the only state in which a link gets created
|
||||
}
|
||||
if (!existsSync(target)) {
|
||||
if (!isDir) return; // missing FILE target: skip, never plant a dangling file link
|
||||
// Missing DIRECTORY target: a dangling directory link is not inert, since the CLI's own
|
||||
// recursive mkdir THROUGH it fails ENOENT on every turn (dsh writing under
|
||||
// `$DSH_HOME/sessions` on a fresh/relocated home). Create it first, 0700, exactly
|
||||
// what dsh itself does on a first turn.
|
||||
try {
|
||||
mkdirSync(target, { recursive: true, mode: 0o700 });
|
||||
} catch {
|
||||
return; // e.g. the target's parent refuses writes: no link rather than a dangling one
|
||||
}
|
||||
}
|
||||
try {
|
||||
symlinkSync(target, link, platform() === 'win32' ? 'junction' : 'dir');
|
||||
} catch {
|
||||
// best-effort only: a platform that refuses symlinks keeps the documented
|
||||
// side effect for this session rather than failing the whole apply over it
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Real, shared Claude config directory Codeman's own host process runs under — honors
|
||||
* `CLAUDE_CONFIG_DIR` the same way `claude-credentials.ts`'s `claudeCredentialsPath()`
|
||||
@@ -250,7 +324,13 @@ export function applyCustomModelInjection(
|
||||
* trust-dialog seed, and only when provided (boot recovery, which has no reason to
|
||||
* re-answer a dialog that already fired once, omits it rather than re-deriving it).
|
||||
*/
|
||||
workingDir?: string
|
||||
workingDir?: string,
|
||||
/**
|
||||
* Reads one env var from the SESSION's own env overrides (single-key on purpose: the
|
||||
* map carries credentials). Only the `configDir` kind uses it, to resolve the CLI's real
|
||||
* home for `linkFromRealHome` from the session's value before the server's.
|
||||
*/
|
||||
sessionEnvLookup?: (envVar: string) => string | undefined
|
||||
): AppliedCustomModel | undefined {
|
||||
const injection = buildCustomModelInjection(entry, endpoint, modelId, contextLength);
|
||||
if (injection.kind === 'unsupported') return undefined;
|
||||
@@ -283,6 +363,6 @@ export function applyCustomModelInjection(
|
||||
};
|
||||
}
|
||||
const configDir = customModelConfigDir(sessionId);
|
||||
const envOverrides = applyConfigDirInjection(configDir, injection);
|
||||
const envOverrides = applyConfigDirInjection(configDir, injection, sessionEnvLookup?.(injection.dirEnvVar));
|
||||
return { envOverrides, envKeys: Object.keys(envOverrides), configDir, launchModel: injection.launchModel };
|
||||
}
|
||||
+104
-11
@@ -87,6 +87,12 @@ export interface ConfigDirInjection {
|
||||
* `customModelInjection.launchModel` template, never hand-built per CLI.
|
||||
*/
|
||||
launchModel?: string;
|
||||
/** Carried from the registry recipe, see `cli-registry/types.ts` `linkFromRealHome`. */
|
||||
linkFromRealHome?: string[];
|
||||
/** Carried from the registry recipe, see `cli-registry/types.ts` `linkFromRealHomeDirs`. */
|
||||
linkFromRealHomeDirs?: string[];
|
||||
/** Carried from the registry recipe, see `cli-registry/types.ts` `realHomeFallback`. */
|
||||
realHomeFallback?: string;
|
||||
}
|
||||
|
||||
export interface UnsupportedInjection {
|
||||
@@ -136,14 +142,22 @@ export function buildCustomModelInjection(
|
||||
}
|
||||
|
||||
case 'configContentEnv': {
|
||||
const content = renderConfigContent(cap.template, endpoint, modelId, apiKey);
|
||||
const content = renderConfigContent(cap.template, endpoint, modelId, apiKey, contextLength);
|
||||
return withLaunchModel({ kind: 'env', envOverrides: { [cap.envVar]: content } }, cap.launchModel, modelId);
|
||||
}
|
||||
|
||||
case 'configDir': {
|
||||
const { content, extraEnv } = renderConfigFile(cap.template, endpoint, modelId, apiKey);
|
||||
const { content, extraEnv } = renderConfigFile(cap.template, endpoint, modelId, apiKey, contextLength);
|
||||
return withLaunchModel(
|
||||
{ kind: 'configDir', dirEnvVar: cap.dirEnvVar, files: [{ relPath: cap.fileName, content }], extraEnv },
|
||||
{
|
||||
kind: 'configDir',
|
||||
dirEnvVar: cap.dirEnvVar,
|
||||
files: [{ relPath: cap.fileName, content }],
|
||||
extraEnv,
|
||||
...(cap.linkFromRealHome ? { linkFromRealHome: cap.linkFromRealHome } : {}),
|
||||
...(cap.linkFromRealHomeDirs ? { linkFromRealHomeDirs: cap.linkFromRealHomeDirs } : {}),
|
||||
...(cap.realHomeFallback ? { realHomeFallback: cap.realHomeFallback } : {}),
|
||||
},
|
||||
cap.launchModel,
|
||||
modelId
|
||||
);
|
||||
@@ -164,41 +178,114 @@ function withLaunchModel<T extends EnvInjection | ConfigDirInjection>(
|
||||
return { ...result, launchModel: template.split('{modelId}').join(modelId) };
|
||||
}
|
||||
|
||||
/**
|
||||
* One quarter of the window is held back for the reply, capped at 8192 tokens (a small local
|
||||
* model's window must not lose a disproportionate slice to the reservation). Returns undefined
|
||||
* for an unknown or non-finite size: opencode then keeps its own default guess, which is the
|
||||
* pre-fix behaviour; never invent a limit here.
|
||||
*/
|
||||
function opencodeModelLimit(contextLength: number | undefined): { context: number; output: number } | undefined {
|
||||
if (contextLength === undefined || !Number.isFinite(contextLength)) return undefined;
|
||||
const total = Math.trunc(contextLength);
|
||||
if (total <= 0) return undefined;
|
||||
const output = Math.max(1, Math.min(8192, Math.floor(total / 4)));
|
||||
// `limit.context` is the FULL window as far as opencode is concerned: it reserves for
|
||||
// the reply itself, so the discovered total is passed through, never a shrunk figure.
|
||||
return { context: total, output };
|
||||
}
|
||||
|
||||
function renderConfigContent(
|
||||
template: 'opencode-json',
|
||||
endpoint: CustomModelEndpoint,
|
||||
modelId: string,
|
||||
apiKey: string
|
||||
apiKey: string,
|
||||
contextLength?: number
|
||||
): string {
|
||||
switch (template) {
|
||||
case 'opencode-json':
|
||||
case 'opencode-json': {
|
||||
// `limit` carries the discovered context window (issue #749): without it opencode assumes
|
||||
// a large default window for an unrecognized model id and eventually overflows a much
|
||||
// smaller local server. `context` is the FULL window and opencode reserves for the reply
|
||||
// inside it itself, so `opencodeModelLimit` passes the real total through and only
|
||||
// `output` gets the capped reservation.
|
||||
const limit = opencodeModelLimit(contextLength);
|
||||
return JSON.stringify({
|
||||
$schema: 'https://opencode.ai/config.json',
|
||||
provider: {
|
||||
custom: {
|
||||
options: { baseURL: withV1Suffix(endpoint.baseUrl), apiKey },
|
||||
models: { [modelId]: {} },
|
||||
models: { [modelId]: limit ? { limit } : {} },
|
||||
},
|
||||
},
|
||||
model: `custom/${modelId}`,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const CODEX_API_KEY_ENV_VAR = 'CODEMAN_CUSTOM_MODEL_API_KEY';
|
||||
/** The DEDICATED env var carrying a custom endpoint's API key for config-file recipes that
|
||||
* reference keys by name (codex's `env_key`, deepseek's `apiKeyEnv`). Never a CLI-native
|
||||
* name: dsh's own `llm-deepseek`/`web-search-deepseek` rows read `DEEPSEEK_API_KEY` too,
|
||||
* so an in-session `/model` switch back to `deepseek-official` (or a profile with web
|
||||
* search on) would otherwise send the custom endpoint's key to api.deepseek.com. Both
|
||||
* entries list it in `privilegedEnvKeys` (stock.ts) like each other's redirect vars.
|
||||
*/
|
||||
const CUSTOM_MODEL_API_KEY_ENV_VAR = 'CODEMAN_CUSTOM_MODEL_API_KEY';
|
||||
|
||||
/** The `[model.<name>]` block name grok's config.toml uses for the injected model — also
|
||||
* what `-m <name>` in the standalone script's ONE_SHOT argv must reference to select it. */
|
||||
export const GROK_CUSTOM_MODEL_NAME = 'codeman-custom';
|
||||
|
||||
function renderConfigFile(
|
||||
template: 'codex-toml' | 'pi-models-json' | 'omp-models-yml' | 'grok-toml',
|
||||
template: 'codex-toml' | 'pi-models-json' | 'omp-models-yml' | 'grok-toml' | 'deepseek-patch-json',
|
||||
endpoint: CustomModelEndpoint,
|
||||
modelId: string,
|
||||
apiKey: string
|
||||
apiKey: string,
|
||||
contextLength?: number
|
||||
): { content: string; extraEnv?: Record<string, string> } {
|
||||
const baseUrl = withV1Suffix(endpoint.baseUrl);
|
||||
switch (template) {
|
||||
case 'deepseek-patch-json': {
|
||||
const route = { provider: 'codeman-custom', model: modelId };
|
||||
return {
|
||||
content: JSON.stringify(
|
||||
[
|
||||
{
|
||||
id: 'llm-pi-ai',
|
||||
name: '@deepseek-ai/dsh-llm-pi-ai',
|
||||
config: {
|
||||
providers: {
|
||||
'codeman-custom': {
|
||||
// By NAME only (never the literal key, see CUSTOM_MODEL_API_KEY_ENV_VAR).
|
||||
apiKeyEnv: CUSTOM_MODEL_API_KEY_ENV_VAR,
|
||||
api: 'openai-completions',
|
||||
baseURL: baseUrl,
|
||||
models: [
|
||||
{
|
||||
id: modelId,
|
||||
name: modelId,
|
||||
...(contextLength !== undefined && Number.isFinite(contextLength) && contextLength > 0
|
||||
? {
|
||||
contextWindow: Math.trunc(contextLength),
|
||||
// A quarter of the window keeps the reply inside the prompt's share; 8192 caps it on a large window.
|
||||
maxTokens: Math.max(1, Math.min(8192, Math.floor(contextLength / 4))),
|
||||
}
|
||||
: {}),
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
{ id: 'dsh-tui', name: '@deepseek-harness-tui/dsh-tui', config: route },
|
||||
{ id: 'agent-default-model', name: '@deepseek-ai/dsh-agent-default-model', config: route },
|
||||
],
|
||||
null,
|
||||
2
|
||||
),
|
||||
extraEnv: { [CUSTOM_MODEL_API_KEY_ENV_VAR]: apiKey },
|
||||
};
|
||||
}
|
||||
case 'codex-toml': {
|
||||
// Verified against real codex (>= Feb 2026): `model` is a top-level STRING, never
|
||||
// a `[model].default` table — codex rejects that with "invalid type: map, expected
|
||||
@@ -217,15 +304,21 @@ function renderConfigFile(
|
||||
const content = [
|
||||
`model = ${quoted(modelId)}`,
|
||||
`model_provider = "custom"`,
|
||||
...(contextLength !== undefined && Number.isFinite(contextLength) && contextLength > 0
|
||||
? [
|
||||
`model_context_window = ${Math.trunc(contextLength)}`,
|
||||
`model_auto_compact_token_limit = ${Math.max(1, Math.floor(contextLength * 0.75))}`,
|
||||
]
|
||||
: []),
|
||||
'',
|
||||
'[model_providers.custom]',
|
||||
`name = "Custom Endpoint"`,
|
||||
`base_url = ${quoted(baseUrl)}`,
|
||||
`env_key = ${quoted(CODEX_API_KEY_ENV_VAR)}`,
|
||||
`env_key = ${quoted(CUSTOM_MODEL_API_KEY_ENV_VAR)}`,
|
||||
`wire_api = "responses"`,
|
||||
'',
|
||||
].join('\n');
|
||||
return { content, extraEnv: { [CODEX_API_KEY_ENV_VAR]: apiKey } };
|
||||
return { content, extraEnv: { [CUSTOM_MODEL_API_KEY_ENV_VAR]: apiKey } };
|
||||
}
|
||||
case 'pi-models-json':
|
||||
// Verified against pi's OWN bundled docs (models.md): `models` is an ARRAY of
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -26,13 +26,15 @@
|
||||
* **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 —
|
||||
* 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.
|
||||
* 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.
|
||||
*
|
||||
* **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
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
@@ -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);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,242 @@
|
||||
/**
|
||||
* @fileoverview Reads a local claude session's transcripts for its edit history: the main
|
||||
* transcript and its subagent transcripts, each from a byte offset.
|
||||
*
|
||||
* WHICH FILE (`resolveClaudeTranscript`). The transcript watcher's path, which the session's
|
||||
* hooks report, so a session is read from wherever its claude really writes. Before the first
|
||||
* hook after a server restart the watcher has none, and `findClaudeTranscript` looks for
|
||||
* `<id>.jsonl` under the session's own `CLAUDE_CONFIG_DIR` override, else the server's
|
||||
* (`claudeProjectsDir()`, as the relaunch pin does). A config dir set outside Codeman is not
|
||||
* guessed; it is found with the next hook. Subagents live in
|
||||
* `<main path without .jsonl>/subagents/agent-<id>.jsonl`.
|
||||
*
|
||||
* HOW MUCH. A requested file is read from its `offset` (clamped to its size), an unrequested
|
||||
* (new) one from `budget` bytes before its end, and a new subagent's `.meta.json` (≤ 8 KB)
|
||||
* comes along. `budgetBytes` is shared by all files, main first; once spent, files still
|
||||
* report their size with an empty chunk. A request with `len` is a block that must end at
|
||||
* `offset + len` (an "older" block ends at the floor): short of budget, its end is read.
|
||||
* With `onlyRequested` ("older" reads) unrequested files are left out.
|
||||
*
|
||||
* Tests: `test/edit-history-local-source.test.ts`.
|
||||
*
|
||||
* @module edit-history-local-source
|
||||
*/
|
||||
|
||||
import { open, readdir, realpath, stat } from 'node:fs/promises';
|
||||
import { homedir } from 'node:os';
|
||||
import { basename, isAbsolute, join } from 'node:path';
|
||||
import { probePathKind, type PathProbeKind } from './utils/bounded-path-probe.js';
|
||||
import { claudeProjectsDir } from './utils/claude-transcript.js';
|
||||
|
||||
const ID_PATTERN = /^[A-Za-z0-9._-]+$/;
|
||||
const REL_PATH_PATTERN = /^(main|subagents\/agent-[A-Za-z0-9_-]+\.jsonl)$/;
|
||||
const AGENT_FILE_PATTERN = /^agent-([A-Za-z0-9_-]+)\.jsonl$/;
|
||||
const META_MAX_BYTES = 8192;
|
||||
|
||||
/** One file to read from where the last read stopped (`len` only for an "older" block). */
|
||||
export interface EditSourceRequest {
|
||||
/** `main` or `subagents/agent-<id>.jsonl`. */
|
||||
relPath: string;
|
||||
offset: number;
|
||||
len?: number;
|
||||
}
|
||||
|
||||
export interface EditSourceFile {
|
||||
relPath: string;
|
||||
/** The file's size when it was read. */
|
||||
size: number;
|
||||
startOffset: number;
|
||||
chunk: Buffer;
|
||||
}
|
||||
|
||||
export interface EditSources {
|
||||
/** The conversation id: the main transcript's file name without `.jsonl`. */
|
||||
transcriptId: string;
|
||||
files: EditSourceFile[];
|
||||
/** `agent-<id>.meta.json` text of each subagent read for the first time, by agent id. */
|
||||
metas: Record<string, string>;
|
||||
}
|
||||
|
||||
export interface EditSourceReadOptions {
|
||||
/** Read only the requested files; the others are not listed. */
|
||||
onlyRequested?: boolean;
|
||||
}
|
||||
|
||||
/** What a path turned out to be (for Bash image candidates). */
|
||||
export interface PathProbe {
|
||||
/** The path with symlinks resolved. */
|
||||
realPath: string;
|
||||
kind: 'file' | 'directory' | 'other';
|
||||
/** Size in bytes (0 for anything that is not a regular file). */
|
||||
size: number;
|
||||
mtimeMs: number;
|
||||
}
|
||||
|
||||
async function readRange(path: string, offset: number, len: number): Promise<Buffer> {
|
||||
if (len <= 0) return Buffer.alloc(0);
|
||||
const fh = await open(path, 'r');
|
||||
try {
|
||||
const buf = Buffer.alloc(len);
|
||||
const { bytesRead } = await fh.read(buf, 0, len, offset);
|
||||
return buf.subarray(0, bytesRead);
|
||||
} catch {
|
||||
return Buffer.alloc(0);
|
||||
} finally {
|
||||
await fh.close();
|
||||
}
|
||||
}
|
||||
|
||||
async function fileSize(path: string): Promise<number | null> {
|
||||
try {
|
||||
const s = await stat(path);
|
||||
return s.isFile() ? s.size : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/** The newest `<id>.jsonl` under any project dir, for the first id that has one; null when none does. */
|
||||
export async function findClaudeTranscript(
|
||||
ids: readonly string[],
|
||||
projectsDir: string = claudeProjectsDir()
|
||||
): Promise<string | null> {
|
||||
let dirs: string[];
|
||||
try {
|
||||
dirs = await readdir(projectsDir);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
for (const id of new Set(ids)) {
|
||||
if (!ID_PATTERN.test(id)) continue;
|
||||
let best: { path: string; mtime: number } | null = null;
|
||||
for (const dir of dirs) {
|
||||
const path = join(projectsDir, dir, `${id}.jsonl`);
|
||||
try {
|
||||
const s = await stat(path);
|
||||
if (s.isFile() && (!best || s.mtimeMs > best.mtime)) best = { path, mtime: s.mtimeMs };
|
||||
} catch {
|
||||
// not in this project dir
|
||||
}
|
||||
}
|
||||
if (best) return best.path;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* The session's main transcript: the transcript watcher's (hook-reported) path when there is
|
||||
* one, else the newest `<id>.jsonl` under `configDir` (the session's `CLAUDE_CONFIG_DIR`
|
||||
* override; the server's config dir when unset). Null when neither finds one.
|
||||
*/
|
||||
export async function resolveClaudeTranscript(
|
||||
watcherPath: string | null | undefined,
|
||||
ids: readonly string[],
|
||||
configDir?: string
|
||||
): Promise<string | null> {
|
||||
return watcherPath || findClaudeTranscript(ids, claudeProjectsDir(configDir));
|
||||
}
|
||||
|
||||
/**
|
||||
* Read the main transcript at `mainPath` and its subagent transcripts. Null when `mainPath`
|
||||
* is not an absolute `<id>.jsonl` path or the file does not exist.
|
||||
*/
|
||||
export async function readClaudeEditSources(
|
||||
mainPath: string,
|
||||
requests: readonly EditSourceRequest[],
|
||||
budgetBytes: number,
|
||||
opts: EditSourceReadOptions = {}
|
||||
): Promise<EditSources | null> {
|
||||
if (!isAbsolute(mainPath) || !mainPath.endsWith('.jsonl')) return null;
|
||||
const transcriptId = basename(mainPath, '.jsonl');
|
||||
if (!ID_PATTERN.test(transcriptId)) return null;
|
||||
|
||||
const byPath = new Map<string, EditSourceRequest>();
|
||||
for (const r of requests) {
|
||||
if (!REL_PATH_PATTERN.test(r.relPath) || byPath.has(r.relPath)) continue;
|
||||
if (!Number.isSafeInteger(r.offset) || r.offset < 0) continue;
|
||||
if (r.len !== undefined && (!Number.isSafeInteger(r.len) || r.len < 0)) continue;
|
||||
byPath.set(r.relPath, r);
|
||||
}
|
||||
|
||||
const subDir = join(mainPath.slice(0, -'.jsonl'.length), 'subagents');
|
||||
const sources: Array<{ relPath: string; path: string; agentId?: string }> = [{ relPath: 'main', path: mainPath }];
|
||||
try {
|
||||
for (const name of (await readdir(subDir)).sort()) {
|
||||
const m = AGENT_FILE_PATTERN.exec(name);
|
||||
if (m) sources.push({ relPath: `subagents/${name}`, path: join(subDir, name), agentId: m[1] });
|
||||
}
|
||||
} catch {
|
||||
// no subagents yet
|
||||
}
|
||||
|
||||
let remaining = Math.max(0, Math.floor(budgetBytes));
|
||||
const files: EditSourceFile[] = [];
|
||||
const metas: Record<string, string> = {};
|
||||
for (const src of sources) {
|
||||
const size = await fileSize(src.path);
|
||||
if (size === null) {
|
||||
if (src.relPath === 'main') return null;
|
||||
continue;
|
||||
}
|
||||
const req = byPath.get(src.relPath);
|
||||
if (!req && opts.onlyRequested) continue;
|
||||
let start: number;
|
||||
let len: number;
|
||||
if (req?.len !== undefined) {
|
||||
const blockStart = Math.min(req.offset, size);
|
||||
const blockEnd = Math.min(req.offset + req.len, size);
|
||||
len = Math.min(blockEnd - blockStart, remaining);
|
||||
start = blockEnd - len;
|
||||
} else if (req) {
|
||||
start = Math.min(req.offset, size);
|
||||
len = Math.min(remaining, size - start);
|
||||
} else {
|
||||
len = Math.min(size, remaining);
|
||||
start = size - len;
|
||||
}
|
||||
const chunk = await readRange(src.path, start, len);
|
||||
remaining -= chunk.length;
|
||||
files.push({ relPath: src.relPath, size, startOffset: start, chunk });
|
||||
if (!req && src.agentId) {
|
||||
const metaPath = join(subDir, `agent-${src.agentId}.meta.json`);
|
||||
const metaSize = await fileSize(metaPath);
|
||||
if (metaSize !== null) {
|
||||
metas[src.agentId] = (await readRange(metaPath, 0, Math.min(metaSize, META_MAX_BYTES))).toString('utf8');
|
||||
}
|
||||
}
|
||||
}
|
||||
return { transcriptId, files, metas };
|
||||
}
|
||||
|
||||
/**
|
||||
* Per path its resolved path, kind, size and mtime, or null when it is not a regular file
|
||||
* (missing, a directory, or on a filesystem that did not answer). `~/` means this user's home.
|
||||
*
|
||||
* The paths come from Bash output, so the agent chose them: each one first goes through the
|
||||
* bounded probe (`probePathKind`, no `pastCap`, this is a bulk scan), and only a path it saw
|
||||
* as a file is touched by `realpath` + `stat`. A path on a mount that stopped answering
|
||||
* then costs one stalled stat at most, never a sync that cannot finish (#516 rule).
|
||||
* Sequential, so one sync holds at most one threadpool worker.
|
||||
*/
|
||||
export async function probeLocalPaths(
|
||||
paths: readonly string[],
|
||||
home: string = homedir(),
|
||||
probeKind: (path: string) => Promise<PathProbeKind> = probePathKind
|
||||
): Promise<Array<PathProbe | null>> {
|
||||
const out: Array<PathProbe | null> = [];
|
||||
for (const p of paths) {
|
||||
const path = p.startsWith('~/') ? join(home, p.slice(2)) : p;
|
||||
if (!isAbsolute(path) || (await probeKind(path)) !== 'file') {
|
||||
out.push(null);
|
||||
continue;
|
||||
}
|
||||
try {
|
||||
const real = await realpath(path);
|
||||
const s = await stat(real);
|
||||
out.push(s.isFile() ? { realPath: real, kind: 'file', size: s.size, mtimeMs: Math.floor(s.mtimeMs) } : null);
|
||||
} catch {
|
||||
out.push(null);
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
@@ -0,0 +1,352 @@
|
||||
/**
|
||||
* @fileoverview Edit history parser: claude JSONL transcript lines → the files the agent
|
||||
* edited (with their diff) and the images its Bash calls may have produced.
|
||||
*
|
||||
* Every `Edit`/`Write` result line carries `toolUseResult.filePath` and a
|
||||
* `structuredPatch` (hunks with ` `/`+`/`-` prefixed lines), so a per-edit diff needs no
|
||||
* git. The whole-file fields next to it (`originalFile`, `content`) are never kept.
|
||||
*
|
||||
* Images rarely come from `Write`; they come from scripts run through Bash. A Bash call
|
||||
* yields a CANDIDATE: the image-looking paths in its command and output, with the call's
|
||||
* start and end time. Whether a candidate file was actually produced by the call (its
|
||||
* mtime falls inside that window) is decided later by whoever can stat it, locally or on
|
||||
* the remote host: this module does no IO.
|
||||
*
|
||||
* Chunked input: a transcript is read in byte ranges, so a Bash `tool_use` and its result
|
||||
* can land in different chunks. Unanswered calls are handed back as `pending` and passed
|
||||
* into the next call.
|
||||
*
|
||||
* Tests: `test/edit-history-parse.test.ts`.
|
||||
*
|
||||
* @module edit-history-parse
|
||||
*/
|
||||
|
||||
import { posix } from 'node:path';
|
||||
|
||||
/** Per-record budget for kept hunk lines; a larger patch is cut and marked `truncated`. */
|
||||
export const MAX_HUNK_BYTES = 64 * 1024;
|
||||
/** Image paths kept per Bash call. */
|
||||
export const MAX_IMAGE_CANDIDATES = 20;
|
||||
/** Unanswered Bash calls carried between chunks (an interrupted call never gets a result). */
|
||||
export const MAX_PENDING_BASH = 200;
|
||||
/**
|
||||
* Command text kept per unanswered Bash call. Pending calls are persisted with the store, so
|
||||
* this bounds it; an image path past this point of a command is still found in the output.
|
||||
*/
|
||||
export const MAX_PENDING_COMMAND_CHARS = 8192;
|
||||
/** Longest id, cwd or agent id of an unanswered Bash call; a call with a longer one is not kept. */
|
||||
export const MAX_PENDING_FIELD_CHARS = 4096;
|
||||
|
||||
export type EditKind = 'code' | 'image';
|
||||
/** `edit` = a result with a path and a patch from a tool other than Edit/Write. */
|
||||
export type EditTool = 'Edit' | 'Write' | 'edit';
|
||||
|
||||
export interface EditHunk {
|
||||
oldStart: number;
|
||||
oldLines: number;
|
||||
newStart: number;
|
||||
newLines: number;
|
||||
lines: string[];
|
||||
}
|
||||
|
||||
export interface EditRecord {
|
||||
/** The `tool_use_id` of the edit: unique per edit, used to deduplicate. */
|
||||
id: string;
|
||||
/** ms since epoch, from the result line's `timestamp` (0 when missing). */
|
||||
ts: number;
|
||||
path: string;
|
||||
kind: EditKind;
|
||||
tool: EditTool;
|
||||
/** A `Write` that created the file. */
|
||||
created: boolean;
|
||||
/** Counted over the whole patch, also when `hunks` was cut. */
|
||||
added: number;
|
||||
removed: number;
|
||||
hunks: EditHunk[];
|
||||
truncated: boolean;
|
||||
/** Set for lines of a subagent transcript. */
|
||||
agentId?: string;
|
||||
}
|
||||
|
||||
export interface PendingBash {
|
||||
id: string;
|
||||
ts: number;
|
||||
command: string;
|
||||
cwd: string;
|
||||
agentId?: string;
|
||||
}
|
||||
|
||||
export interface ImageCandidate {
|
||||
toolUseId: string;
|
||||
start: number;
|
||||
end: number;
|
||||
paths: string[];
|
||||
agentId?: string;
|
||||
/**
|
||||
* Files claude handed to the user (`SendUserFile`): shown on purpose, so no mtime window
|
||||
* applies; the file must still exist, and a version already listed is not listed again.
|
||||
*/
|
||||
sent?: boolean;
|
||||
}
|
||||
|
||||
export interface EditParseResult {
|
||||
records: EditRecord[];
|
||||
candidates: ImageCandidate[];
|
||||
pending: PendingBash[];
|
||||
}
|
||||
|
||||
const IMAGE_EXT = '(?:png|jpe?g|gif|webp|avif|svg)';
|
||||
const IMAGE_PATH_RE = new RegExp(`\\.${IMAGE_EXT}$`, 'i');
|
||||
// After the extension: no further name character, and no `.ext` (a.png.bak).
|
||||
const EXT_END = '(?![\\w-])(?!\\.\\w)';
|
||||
// Characters that end an unquoted path token.
|
||||
const TOKEN_CHAR = '[^\\s\'"`<>|;&()=,:*?]';
|
||||
const PATH_TOKEN_RE = new RegExp(
|
||||
[
|
||||
// 1. file:// URL (Codex: `Saved to: file:///…`), which may contain spaces up to the extension.
|
||||
// The lazy scan stops at the next `file://`: otherwise it runs to the line end from
|
||||
// every `file://` of a line that repeats it (quadratic). `:` stays allowed, so a
|
||||
// timestamped name (`plot-12:30.png`) is still found.
|
||||
`file://(/(?:(?!file://)[^\\n])*?\\.${IMAGE_EXT})${EXT_END}`,
|
||||
// 2./3. Quoted paths, spaces allowed.
|
||||
`"([^"\\n]*\\.${IMAGE_EXT})"`,
|
||||
`'([^'\\n]*\\.${IMAGE_EXT})'`,
|
||||
// 4. Unquoted token. A match starts only at a token boundary (line start or a character
|
||||
// that ends a token), never inside a URL (`:` is not a boundary), a glob or a longer
|
||||
// name. Anchoring at boundaries keeps this linear: a lookbehind that only excluded
|
||||
// some characters let a match start at every other character of a long token
|
||||
// (`####…`, a base64 blob) and rescan the token from each, quadratic in the line
|
||||
// length. The name before the extension must not be empty.
|
||||
`(?<=^|[\\s'"\`<>|;&()=,*?])((?:${TOKEN_CHAR}*/)?[^\\s'"\`<>|;&()=,:*?/]+\\.${IMAGE_EXT})${EXT_END}`,
|
||||
].join('|'),
|
||||
'gi'
|
||||
);
|
||||
|
||||
/** `image` for the image extensions the panel can thumbnail, else `code`. */
|
||||
export function classifyEditPath(path: string): EditKind {
|
||||
return IMAGE_PATH_RE.test(path) ? 'image' : 'code';
|
||||
}
|
||||
|
||||
/**
|
||||
* Image-looking paths in a Bash call's command and output, in order of appearance,
|
||||
* deduplicated and capped at {@link MAX_IMAGE_CANDIDATES}. Absolute paths are normalised,
|
||||
* `~/` paths stay literal (the host that stats them expands `~`), relative ones are
|
||||
* resolved against `cwd` and dropped when `cwd` is not absolute. URLs and globs are
|
||||
* skipped.
|
||||
*/
|
||||
export function extractImagePathCandidates(command: string, stdout: string, stderr: string, cwd: string): string[] {
|
||||
const text = [command, stdout, stderr].join('\n');
|
||||
const out: string[] = [];
|
||||
const seen = new Set<string>();
|
||||
for (const m of text.matchAll(PATH_TOKEN_RE)) {
|
||||
const raw = m[1] ?? m[2] ?? m[3] ?? m[4];
|
||||
if (!raw || raw.includes('://') || /[*?]/.test(raw)) continue;
|
||||
let resolved: string;
|
||||
if (raw.startsWith('/')) resolved = posix.normalize(raw);
|
||||
else if (raw.startsWith('~/')) resolved = raw;
|
||||
else if (cwd.startsWith('/')) resolved = posix.resolve(cwd, raw);
|
||||
else continue;
|
||||
if (seen.has(resolved)) continue;
|
||||
seen.add(resolved);
|
||||
out.push(resolved);
|
||||
if (out.length >= MAX_IMAGE_CANDIDATES) break;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function isRecord(v: unknown): v is Record<string, unknown> {
|
||||
return typeof v === 'object' && v !== null && !Array.isArray(v);
|
||||
}
|
||||
|
||||
/**
|
||||
* The hunks of `patch` with their string lines, cut once the lines reach {@link MAX_HUNK_BYTES}
|
||||
* (`truncated`). Also applied to hunks loaded from a store file.
|
||||
*/
|
||||
export function capHunks(patch: readonly unknown[]): { hunks: EditHunk[]; truncated: boolean } {
|
||||
const hunks: EditHunk[] = [];
|
||||
let budget = MAX_HUNK_BYTES;
|
||||
let truncated = false;
|
||||
for (const h of patch) {
|
||||
if (!isRecord(h) || !Array.isArray(h.lines)) continue;
|
||||
if (truncated) break;
|
||||
const kept: string[] = [];
|
||||
for (const l of h.lines) {
|
||||
if (typeof l !== 'string') continue;
|
||||
const cost = Buffer.byteLength(l) + 3;
|
||||
if (cost > budget) {
|
||||
truncated = true;
|
||||
break;
|
||||
}
|
||||
budget -= cost;
|
||||
kept.push(l);
|
||||
}
|
||||
hunks.push({
|
||||
oldStart: Number(h.oldStart) || 0,
|
||||
oldLines: Number(h.oldLines) || 0,
|
||||
newStart: Number(h.newStart) || 0,
|
||||
newLines: Number(h.newLines) || 0,
|
||||
lines: kept,
|
||||
});
|
||||
}
|
||||
return { hunks, truncated };
|
||||
}
|
||||
|
||||
function toHunks(patch: unknown[]): { hunks: EditHunk[]; added: number; removed: number; truncated: boolean } {
|
||||
let added = 0;
|
||||
let removed = 0;
|
||||
for (const h of patch) {
|
||||
if (!isRecord(h) || !Array.isArray(h.lines)) continue;
|
||||
for (const l of h.lines) {
|
||||
if (typeof l !== 'string') continue;
|
||||
if (l.startsWith('+')) added++;
|
||||
else if (l.startsWith('-')) removed++;
|
||||
}
|
||||
}
|
||||
return { ...capHunks(patch), added, removed };
|
||||
}
|
||||
|
||||
/** A new file's content as one all-added hunk, in `structuredPatch` form. */
|
||||
function createdFileHunk(content: string): Record<string, unknown> {
|
||||
const lines = content.split('\n');
|
||||
if (lines.length > 0 && lines[lines.length - 1] === '') lines.pop();
|
||||
return { oldStart: 0, oldLines: 0, newStart: 1, newLines: lines.length, lines: lines.map((l) => `+${l}`) };
|
||||
}
|
||||
|
||||
function toEditRecord(id: string, ts: number, result: unknown, agentId: string | undefined): EditRecord | null {
|
||||
if (!isRecord(result)) return null;
|
||||
const { filePath, structuredPatch } = result;
|
||||
if (typeof filePath !== 'string' || !filePath || !Array.isArray(structuredPatch)) return null;
|
||||
const tool: EditTool = typeof result.type === 'string' ? 'Write' : 'oldString' in result ? 'Edit' : 'edit';
|
||||
const kind = classifyEditPath(filePath);
|
||||
// A Write that created a file reports an empty patch; its diff is the whole content.
|
||||
const patch =
|
||||
result.type === 'create' && structuredPatch.length === 0 && kind !== 'image' && typeof result.content === 'string'
|
||||
? [createdFileHunk(result.content)]
|
||||
: structuredPatch;
|
||||
const { hunks, added, removed, truncated } = toHunks(patch);
|
||||
const record: EditRecord = {
|
||||
id,
|
||||
ts,
|
||||
path: filePath,
|
||||
kind,
|
||||
tool,
|
||||
created: result.type === 'create',
|
||||
added,
|
||||
removed,
|
||||
hunks,
|
||||
truncated,
|
||||
};
|
||||
if (agentId) record.agentId = agentId;
|
||||
return record;
|
||||
}
|
||||
|
||||
/**
|
||||
* Image paths of a `SendUserFile` result (`{ caption, attachments: [{ path }] }`): the
|
||||
* files claude delivered to the user, which is how a session shows generated images.
|
||||
*/
|
||||
function sentImagePaths(result: unknown): string[] {
|
||||
if (!isRecord(result) || !Array.isArray(result.attachments)) return [];
|
||||
const out: string[] = [];
|
||||
for (const a of result.attachments) {
|
||||
if (!isRecord(a) || typeof a.path !== 'string' || !a.path.startsWith('/')) continue;
|
||||
if (IMAGE_PATH_RE.test(a.path) && !out.includes(a.path)) out.push(a.path);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** The text of a `tool_result` block's content (a string or a list of text blocks). */
|
||||
function blockText(content: unknown): string {
|
||||
if (typeof content === 'string') return content;
|
||||
if (!Array.isArray(content)) return '';
|
||||
return content
|
||||
.map((b) => (isRecord(b) && typeof b.text === 'string' ? b.text : ''))
|
||||
.filter(Boolean)
|
||||
.join('\n');
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse transcript lines into edit records and Bash image candidates.
|
||||
*
|
||||
* @param text complete JSONL lines; a first line cut mid-record (a byte range starting
|
||||
* inside it) and anything else that does not parse is skipped.
|
||||
* @param pending unanswered Bash calls from the previous chunk.
|
||||
*/
|
||||
export function parseClaudeEditLines(text: string, pending: readonly PendingBash[] = []): EditParseResult {
|
||||
const open = new Map<string, PendingBash>(pending.map((p) => [p.id, p]));
|
||||
const records: EditRecord[] = [];
|
||||
const candidates: ImageCandidate[] = [];
|
||||
|
||||
for (const rawLine of text.split('\n')) {
|
||||
const line = rawLine.trim();
|
||||
if (!line) continue;
|
||||
let rec: unknown;
|
||||
try {
|
||||
rec = JSON.parse(line);
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
if (!isRecord(rec)) continue;
|
||||
const message = rec.message;
|
||||
const content = isRecord(message) ? message.content : undefined;
|
||||
if (!Array.isArray(content)) continue;
|
||||
const parsedTs = typeof rec.timestamp === 'string' ? Date.parse(rec.timestamp) : NaN;
|
||||
const ts = Number.isFinite(parsedTs) ? parsedTs : 0;
|
||||
const agentId = typeof rec.agentId === 'string' && rec.agentId ? rec.agentId : undefined;
|
||||
const cwd = typeof rec.cwd === 'string' ? rec.cwd : '';
|
||||
|
||||
if (rec.type === 'assistant') {
|
||||
for (const block of content) {
|
||||
if (!isRecord(block) || block.type !== 'tool_use' || block.name !== 'Bash') continue;
|
||||
if (typeof block.id !== 'string' || !isRecord(block.input)) continue;
|
||||
const command = block.input.command;
|
||||
if (typeof command !== 'string') continue;
|
||||
if ([block.id, cwd, agentId ?? ''].some((f) => f.length > MAX_PENDING_FIELD_CHARS)) continue;
|
||||
const entry: PendingBash = { id: block.id, ts, command: command.slice(0, MAX_PENDING_COMMAND_CHARS), cwd };
|
||||
if (agentId) entry.agentId = agentId;
|
||||
open.delete(block.id);
|
||||
open.set(block.id, entry);
|
||||
while (open.size > MAX_PENDING_BASH) open.delete(open.keys().next().value as string);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if (rec.type !== 'user') continue;
|
||||
const results = content.filter(
|
||||
(b): b is Record<string, unknown> => isRecord(b) && b.type === 'tool_result' && typeof b.tool_use_id === 'string'
|
||||
);
|
||||
// `toolUseResult` describes the line's result; it is unambiguous only for one block.
|
||||
const single = results.length === 1;
|
||||
for (const block of results) {
|
||||
const id = block.tool_use_id as string;
|
||||
if (single) {
|
||||
const edit = toEditRecord(id, ts, rec.toolUseResult, agentId);
|
||||
if (edit) records.push(edit);
|
||||
}
|
||||
const sent = single ? sentImagePaths(rec.toolUseResult) : [];
|
||||
if (sent.length > 0) {
|
||||
const candidate: ImageCandidate = { toolUseId: id, start: ts, end: ts, paths: sent, sent: true };
|
||||
if (agentId) candidate.agentId = agentId;
|
||||
candidates.push(candidate);
|
||||
}
|
||||
const call = open.get(id);
|
||||
if (!call) continue;
|
||||
open.delete(id);
|
||||
const r = single ? rec.toolUseResult : undefined;
|
||||
let stdout: string;
|
||||
let stderr = '';
|
||||
if (isRecord(r) && (typeof r.stdout === 'string' || typeof r.stderr === 'string')) {
|
||||
stdout = typeof r.stdout === 'string' ? r.stdout : '';
|
||||
stderr = typeof r.stderr === 'string' ? r.stderr : '';
|
||||
} else {
|
||||
stdout = blockText(block.content);
|
||||
}
|
||||
const paths = extractImagePathCandidates(call.command, stdout, stderr, call.cwd);
|
||||
if (paths.length === 0) continue;
|
||||
const candidate: ImageCandidate = { toolUseId: id, start: call.ts, end: ts, paths };
|
||||
if (call.agentId) candidate.agentId = call.agentId;
|
||||
candidates.push(candidate);
|
||||
}
|
||||
}
|
||||
|
||||
return { records, candidates, pending: [...open.values()] };
|
||||
}
|
||||
@@ -0,0 +1,678 @@
|
||||
/**
|
||||
* @fileoverview Per-session edit history: the parsed edits and Bash images of one claude
|
||||
* session, plus where in each transcript file reading stopped.
|
||||
*
|
||||
* Sources are the main transcript (`main`) and each subagent transcript
|
||||
* (`subagents/agent-<id>.jsonl`). Each has a cursor:
|
||||
* - `offset`: the next byte to read forward; always a line boundary.
|
||||
* - `floor`: the lowest byte already parsed; > 0 while older lines are unread
|
||||
* ("ältere laden" reads `[floor − budget, floor)`, see `olderRange`).
|
||||
* The reader (`edit-history-local-source.ts`) reads forward from each `offset` of the
|
||||
* current conversation, a file without one from `budget` bytes before its end, and hands the
|
||||
* bytes to `apply`. Cuts happen only at `\n`, which never occurs inside a UTF-8 sequence, so
|
||||
* multibyte characters split by a chunk boundary are never decoded half.
|
||||
*
|
||||
* A first read of a file starts `budget` bytes before its end (transcripts reach 100 MB+),
|
||||
* drops the cut first line and leaves `floor` there. A line longer than the budget is
|
||||
* skipped instead of blocking the cursor, reading forward (`midLine`) and backward
|
||||
* (`floorMidLine`); the line after it (forward) or before it (backward) is read whole.
|
||||
* That needs every chunk to be a full budget unless it reaches the file's end (forward) or
|
||||
* start (older): a chunk with no line end then means one line longer than the budget. The
|
||||
* caller does not hand over a chunk that a budget shared by several files cut short.
|
||||
*
|
||||
* A new conversation (`/clear`, resume) arrives as a new `transcriptId`: cursors and
|
||||
* pending Bash calls start over, entries stay. Entries are capped at {@link MAX_ENTRIES}
|
||||
* and {@link MAX_STORE_BYTES} of diff text; the oldest by time are dropped. Besides them
|
||||
* the file holds one cursor per transcript file of the current conversation, at most
|
||||
* {@link MAX_PENDING_BASH} unanswered Bash calls and at most {@link MAX_AGENTS} subagent labels.
|
||||
*
|
||||
* ⚠️ The file holds secrets: a diff carries whatever the agent wrote, keys and `.env`
|
||||
* contents included. So, like `intents.json`, it is written 0600 via tmp+rename, bounded,
|
||||
* deleted with the session (`remove()`; `sweep()` at startup removes files no session owns),
|
||||
* never fed to `/api/search`, and refused by the attachment, folder and download routes through
|
||||
* `isSensitivePath` (`web/sensitive-path.ts`). `load()` takes the file as untrusted: past
|
||||
* {@link MAX_FILE_BYTES} it is not parsed, and every part is rebuilt under the same bounds.
|
||||
*
|
||||
* Persisted as JSON under `dataPath('edit-history', '<sessionId>.json')`, written debounced.
|
||||
*
|
||||
* Tests: `test/edit-history-store.test.ts`.
|
||||
*
|
||||
* @module edit-history-store
|
||||
*/
|
||||
|
||||
import { randomBytes } from 'node:crypto';
|
||||
import { mkdir, readdir, readFile, rename, stat, unlink, writeFile } from 'node:fs/promises';
|
||||
import { join } from 'node:path';
|
||||
import { dataPath } from './config/instance.js';
|
||||
import {
|
||||
capHunks,
|
||||
MAX_HUNK_BYTES,
|
||||
MAX_PENDING_BASH,
|
||||
MAX_PENDING_COMMAND_CHARS,
|
||||
MAX_PENDING_FIELD_CHARS,
|
||||
parseClaudeEditLines,
|
||||
type EditRecord,
|
||||
type EditTool,
|
||||
type ImageCandidate,
|
||||
type PendingBash,
|
||||
} from './edit-history-parse.js';
|
||||
|
||||
export const MAX_ENTRIES = 500;
|
||||
/** Diff text (and paths) kept per session; see {@link entryBytes}. */
|
||||
export const MAX_STORE_BYTES = 4 * 1024 * 1024;
|
||||
/** Longest string kept from a subagent's `meta.json` field. */
|
||||
export const MAX_AGENT_META_CHARS = 1024;
|
||||
/**
|
||||
* Subagent labels kept; past it the oldest one no entry uses is dropped. One more than
|
||||
* {@link MAX_ENTRIES}, so besides the newest label there always is one.
|
||||
*/
|
||||
export const MAX_AGENTS = MAX_ENTRIES + 1;
|
||||
/** Bytes read per file and pass (first read, forward step, "older" step). */
|
||||
export const READ_BUDGET_BYTES = 8 * 1024 * 1024;
|
||||
/**
|
||||
* A store file larger than this is not parsed (starts empty). One written within the caps
|
||||
* above stays far below it; JSON escaping at most doubles ordinary text.
|
||||
*/
|
||||
export const MAX_FILE_BYTES = 32 * 1024 * 1024;
|
||||
export const MAIN_SOURCE = 'main';
|
||||
const SAVE_DEBOUNCE_MS = 1000;
|
||||
const FILE_VERSION = 1;
|
||||
const NL = 0x0a;
|
||||
/** A transcript id as the reader takes it (`<id>.jsonl`): no path separators. */
|
||||
const TRANSCRIPT_ID = /^[A-Za-z0-9._-]+$/;
|
||||
/**
|
||||
* Names this process's tmp files (`<id>.json.<tag>.tmp`); any other tmp file is left over from
|
||||
* an earlier run (`sweep`), even one whose pid this process has again (a container's pid 1).
|
||||
*/
|
||||
export const TMP_TAG = `${process.pid}-${randomBytes(4).toString('hex')}`;
|
||||
|
||||
/** `bash`: an image a Bash call wrote; `sent`: an image claude delivered to the user. */
|
||||
export type EditHistoryTool = EditTool | 'bash' | 'sent';
|
||||
|
||||
export interface EditHistoryEntry extends Omit<EditRecord, 'tool'> {
|
||||
tool: EditHistoryTool;
|
||||
}
|
||||
|
||||
export interface SourceCursor {
|
||||
offset: number;
|
||||
floor: number;
|
||||
/** The next forward read starts inside a line longer than the budget; skip to its end. */
|
||||
midLine?: boolean;
|
||||
/** `floor` lies inside a line longer than the budget; the next older read drops its tail. */
|
||||
floorMidLine?: boolean;
|
||||
}
|
||||
|
||||
export interface AgentMeta {
|
||||
description: string;
|
||||
agentType: string;
|
||||
worktreePath?: string;
|
||||
worktreeBranch?: string;
|
||||
}
|
||||
|
||||
export interface ByteRange {
|
||||
offset: number;
|
||||
len: number;
|
||||
}
|
||||
|
||||
export interface ApplyInput {
|
||||
transcriptId: string;
|
||||
/** `main` or `subagents/agent-<id>.jsonl`. */
|
||||
source: string;
|
||||
startOffset: number;
|
||||
chunk: Buffer;
|
||||
/** Size of the file when the chunk was read. */
|
||||
size: number;
|
||||
}
|
||||
|
||||
export interface ApplyResult {
|
||||
added: number;
|
||||
candidates: ImageCandidate[];
|
||||
}
|
||||
|
||||
export interface ConfirmedImage {
|
||||
toolUseId: string;
|
||||
path: string;
|
||||
ts: number;
|
||||
agentId?: string;
|
||||
tool?: 'bash' | 'sent';
|
||||
}
|
||||
|
||||
export interface EditHistoryStoreOptions {
|
||||
/** Directory holding `<sessionId>.json`; default `dataPath('edit-history')`. */
|
||||
dir?: string;
|
||||
debounceMs?: number;
|
||||
}
|
||||
|
||||
interface StoreFile {
|
||||
version: number;
|
||||
sessionId: string;
|
||||
transcriptId: string | null;
|
||||
cursors: Record<string, SourceCursor>;
|
||||
pendingBash: PendingBash[];
|
||||
agents: Record<string, AgentMeta>;
|
||||
entries: EditHistoryEntry[];
|
||||
}
|
||||
|
||||
function isObj(v: unknown): v is Record<string, unknown> {
|
||||
return typeof v === 'object' && v !== null && !Array.isArray(v);
|
||||
}
|
||||
|
||||
/** What an entry costs against {@link MAX_STORE_BYTES}: its strings and diff lines, plus a fixed overhead. */
|
||||
export function entryBytes(e: EditHistoryEntry): number {
|
||||
let n = 128 + Buffer.byteLength(e.id) + Buffer.byteLength(e.path) + Buffer.byteLength(e.agentId ?? '');
|
||||
for (const h of e.hunks) for (const l of h.lines) n += Buffer.byteLength(l) + 3;
|
||||
return n;
|
||||
}
|
||||
|
||||
function metaString(v: unknown): string | undefined {
|
||||
return typeof v === 'string' && v ? v.slice(0, MAX_AGENT_META_CHARS) : undefined;
|
||||
}
|
||||
|
||||
/** Parse an `agent-<id>.meta.json` text; null when it is not an object. */
|
||||
export function parseAgentMeta(text: string): AgentMeta | null {
|
||||
try {
|
||||
return agentMetaFrom(JSON.parse(text));
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function isOffset(v: unknown): v is number {
|
||||
return Number.isSafeInteger(v) && (v as number) >= 0;
|
||||
}
|
||||
|
||||
// The loaders below take the persisted file as untrusted: a hand-edited or damaged store
|
||||
// loses the broken parts instead of failing every request or stalling a cursor.
|
||||
|
||||
function agentMetaFrom(raw: unknown): AgentMeta | null {
|
||||
if (!isObj(raw)) return null;
|
||||
const meta: AgentMeta = {
|
||||
description: metaString(raw.description) ?? '',
|
||||
agentType: metaString(raw.agentType) ?? '',
|
||||
};
|
||||
const worktreePath = metaString(raw.worktreePath);
|
||||
const worktreeBranch = metaString(raw.worktreeBranch);
|
||||
if (worktreePath) meta.worktreePath = worktreePath;
|
||||
if (worktreeBranch) meta.worktreeBranch = worktreeBranch;
|
||||
return meta;
|
||||
}
|
||||
|
||||
function cursorFrom(raw: unknown): SourceCursor | null {
|
||||
if (!isObj(raw) || !isOffset(raw.offset) || !isOffset(raw.floor) || raw.floor > raw.offset) return null;
|
||||
const cursor: SourceCursor = { offset: raw.offset, floor: raw.floor };
|
||||
if (raw.midLine === true) cursor.midLine = true;
|
||||
if (raw.floorMidLine === true) cursor.floorMidLine = true;
|
||||
return cursor;
|
||||
}
|
||||
|
||||
const loadedField = (v: unknown): v is string => typeof v === 'string' && v.length <= MAX_PENDING_FIELD_CHARS;
|
||||
|
||||
function pendingBashFrom(raw: unknown): PendingBash | null {
|
||||
if (!isObj(raw)) return null;
|
||||
const { id, ts, command, cwd, agentId } = raw;
|
||||
if (!loadedField(id) || !Number.isFinite(ts) || typeof command !== 'string' || !loadedField(cwd)) return null;
|
||||
if (agentId !== undefined && !loadedField(agentId)) return null;
|
||||
const pending: PendingBash = { id, ts: ts as number, command: command.slice(0, MAX_PENDING_COMMAND_CHARS), cwd };
|
||||
if (agentId) pending.agentId = agentId;
|
||||
return pending;
|
||||
}
|
||||
|
||||
const ENTRY_KINDS = new Set(['code', 'image']);
|
||||
const ENTRY_TOOLS = new Set(['Edit', 'Write', 'edit', 'bash', 'sent']);
|
||||
const countFrom = (v: unknown): number => (Number.isSafeInteger(v) && (v as number) >= 0 ? (v as number) : 0);
|
||||
|
||||
/**
|
||||
* A loaded entry rebuilt from its known fields: unknown fields and non-string diff lines are
|
||||
* dropped and the per-edit {@link MAX_HUNK_BYTES} is applied again; null when it is unusable.
|
||||
*/
|
||||
function entryFrom(raw: unknown): EditHistoryEntry | null {
|
||||
if (!isObj(raw) || typeof raw.id !== 'string' || !raw.id || typeof raw.path !== 'string' || !raw.path) return null;
|
||||
if (!Number.isFinite(raw.ts) || !ENTRY_KINDS.has(raw.kind as string) || !ENTRY_TOOLS.has(raw.tool as string)) {
|
||||
return null;
|
||||
}
|
||||
if (!Array.isArray(raw.hunks)) return null;
|
||||
const { hunks, truncated } = capHunks(raw.hunks);
|
||||
const entry: EditHistoryEntry = {
|
||||
id: raw.id,
|
||||
ts: raw.ts as number,
|
||||
path: raw.path,
|
||||
kind: raw.kind as EditHistoryEntry['kind'],
|
||||
tool: raw.tool as EditHistoryTool,
|
||||
created: raw.created === true,
|
||||
added: countFrom(raw.added),
|
||||
removed: countFrom(raw.removed),
|
||||
hunks,
|
||||
truncated: truncated || raw.truncated === true,
|
||||
};
|
||||
if (typeof raw.agentId === 'string' && raw.agentId) entry.agentId = raw.agentId;
|
||||
return entry;
|
||||
}
|
||||
|
||||
export class EditHistoryStore {
|
||||
readonly sessionId: string;
|
||||
private readonly file: string;
|
||||
private readonly debounceMs: number;
|
||||
private transcriptId: string | null = null;
|
||||
private cursors = new Map<string, SourceCursor>();
|
||||
private pendingBash: PendingBash[] = [];
|
||||
private agents = new Map<string, AgentMeta>();
|
||||
private entries = new Map<string, EditHistoryEntry>();
|
||||
/** Sum of {@link entryBytes} over `entries`. */
|
||||
private bytes = 0;
|
||||
private saveTimer: NodeJS.Timeout | null = null;
|
||||
/** The last queued write; `remove()` waits for it. */
|
||||
private writing: Promise<void> | null = null;
|
||||
private removed = false;
|
||||
|
||||
constructor(sessionId: string, opts: EditHistoryStoreOptions = {}) {
|
||||
this.sessionId = sessionId;
|
||||
this.file = join(opts.dir ?? dataPath('edit-history'), `${sessionId}.json`);
|
||||
this.debounceMs = opts.debounceMs ?? SAVE_DEBOUNCE_MS;
|
||||
}
|
||||
|
||||
/**
|
||||
* A store with the persisted state of `sessionId`; empty when the file is missing,
|
||||
* unreadable or larger than {@link MAX_FILE_BYTES}.
|
||||
*/
|
||||
static async load(sessionId: string, opts: EditHistoryStoreOptions = {}): Promise<EditHistoryStore> {
|
||||
const store = new EditHistoryStore(sessionId, opts);
|
||||
let data: unknown;
|
||||
try {
|
||||
if ((await stat(store.file)).size > MAX_FILE_BYTES) return store;
|
||||
data = JSON.parse(await readFile(store.file, 'utf-8'));
|
||||
} catch {
|
||||
return store;
|
||||
}
|
||||
if (!isObj(data)) return store;
|
||||
if (data.version !== FILE_VERSION) return store;
|
||||
const f = data as unknown as StoreFile;
|
||||
// The sync builds the previous transcript's path from it (`/clear`), so only a plain id counts.
|
||||
store.transcriptId =
|
||||
typeof f.transcriptId === 'string' && TRANSCRIPT_ID.test(f.transcriptId) ? f.transcriptId : null;
|
||||
// Cursors are offsets into that conversation's files; without it they mean nothing.
|
||||
const cursors = store.transcriptId !== null && isObj(f.cursors) ? f.cursors : {};
|
||||
for (const [k, raw] of Object.entries(cursors)) {
|
||||
const cursor = cursorFrom(raw);
|
||||
if (cursor) store.cursors.set(k, cursor);
|
||||
}
|
||||
if (Array.isArray(f.pendingBash)) {
|
||||
store.pendingBash = f.pendingBash
|
||||
.map(pendingBashFrom)
|
||||
.filter((p): p is PendingBash => p !== null)
|
||||
.slice(-MAX_PENDING_BASH);
|
||||
}
|
||||
if (Array.isArray(f.entries)) {
|
||||
store.addRecords(f.entries.map(entryFrom).filter((e): e is EditHistoryEntry => e !== null));
|
||||
}
|
||||
for (const [k, raw] of Object.entries(isObj(f.agents) ? f.agents : {})) {
|
||||
const meta = k.length <= MAX_AGENT_META_CHARS ? agentMetaFrom(raw) : null;
|
||||
if (meta) store.putAgent(k, meta);
|
||||
}
|
||||
return store;
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete the stored history of every session `keep` rejects, and tmp files other processes
|
||||
* left behind (startup: a crash between removing a session's record and deleting its file,
|
||||
* or mid-write; an older build).
|
||||
* Returns how many sessions' files were deleted.
|
||||
*/
|
||||
static async sweep(
|
||||
keep: (sessionId: string) => boolean,
|
||||
opts: Pick<EditHistoryStoreOptions, 'dir'> = {}
|
||||
): Promise<number> {
|
||||
const dir = opts.dir ?? dataPath('edit-history');
|
||||
let names: string[];
|
||||
try {
|
||||
names = await readdir(dir);
|
||||
} catch {
|
||||
return 0;
|
||||
}
|
||||
let removed = 0;
|
||||
for (const name of names) {
|
||||
const tmp = /\.json\.[\w-]+\.tmp$/.test(name);
|
||||
const stale = tmp
|
||||
? !name.endsWith(`.json.${TMP_TAG}.tmp`)
|
||||
: name.endsWith('.json') && !keep(name.slice(0, -'.json'.length));
|
||||
if (!stale) continue;
|
||||
await unlink(join(dir, name)).catch(() => undefined);
|
||||
if (!tmp) removed++;
|
||||
}
|
||||
return removed;
|
||||
}
|
||||
|
||||
/** Delete the stored history of `sessionId` without loading it. */
|
||||
static async delete(sessionId: string, opts: Pick<EditHistoryStoreOptions, 'dir'> = {}): Promise<void> {
|
||||
await new EditHistoryStore(sessionId, opts).remove();
|
||||
}
|
||||
|
||||
getTranscriptId(): string | null {
|
||||
return this.transcriptId;
|
||||
}
|
||||
|
||||
getCursor(source: string): SourceCursor | undefined {
|
||||
const c = this.cursors.get(source);
|
||||
return c ? { ...c } : undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* True while any source has unread older lines that could still be shown. At a cap (no
|
||||
* room left for another entry) an older edit would be dropped the moment it is read, so
|
||||
* there is nothing more to load.
|
||||
*/
|
||||
get backfillTruncated(): boolean {
|
||||
if (this.entries.size >= MAX_ENTRIES || this.bytes > MAX_STORE_BYTES - MAX_HUNK_BYTES) return false;
|
||||
for (const c of this.cursors.values()) if (c.floor > 0) return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
/** Entries newest first. */
|
||||
list(): EditHistoryEntry[] {
|
||||
return [...this.entries.values()].sort((a, b) => b.ts - a.ts);
|
||||
}
|
||||
|
||||
get(id: string): EditHistoryEntry | undefined {
|
||||
return this.entries.get(id);
|
||||
}
|
||||
|
||||
getAgents(): Record<string, AgentMeta> {
|
||||
return Object.fromEntries(this.agents);
|
||||
}
|
||||
|
||||
hasAgentMeta(agentId: string): boolean {
|
||||
return this.agents.has(agentId);
|
||||
}
|
||||
|
||||
/** Record a subagent's `meta.json`; ignored when it does not parse. */
|
||||
setAgentMeta(agentId: string, metaText: string): void {
|
||||
const meta = this.removed ? null : parseAgentMeta(metaText);
|
||||
if (!meta) return;
|
||||
this.putAgent(agentId, meta);
|
||||
this.scheduleSave();
|
||||
}
|
||||
|
||||
/** The next older range of `source` ("ältere laden"), or null when it is read to its start. */
|
||||
olderRange(source: string, budget = READ_BUDGET_BYTES): ByteRange | null {
|
||||
const cursor = this.cursors.get(source);
|
||||
if (!cursor || cursor.floor <= 0) return null;
|
||||
const offset = Math.max(0, cursor.floor - budget);
|
||||
return { offset, len: cursor.floor - offset };
|
||||
}
|
||||
|
||||
/** Every source with a cursor in the current conversation. */
|
||||
knownSources(): string[] {
|
||||
return [...this.cursors.keys()];
|
||||
}
|
||||
|
||||
/** Sources with older lines left. */
|
||||
sourcesWithOlder(): string[] {
|
||||
return [...this.cursors.entries()].filter(([, c]) => c.floor > 0).map(([s]) => s);
|
||||
}
|
||||
|
||||
/**
|
||||
* A source whose file is gone (a deleted subagent transcript): its cursor goes, so it no
|
||||
* longer offers older lines. Its entries stay.
|
||||
*/
|
||||
dropSource(source: string): void {
|
||||
if (this.removed || !this.cursors.delete(source)) return;
|
||||
this.scheduleSave();
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply a forward chunk: read from the source's cursor, or, without one, its last `budget`
|
||||
* bytes; a full budget unless it reaches the file's end. A file that shrank below its cursor
|
||||
* (replaced) loses the cursor and is read from its end next time. Returns new entries and
|
||||
* Bash image candidates.
|
||||
*/
|
||||
apply(input: ApplyInput): ApplyResult {
|
||||
if (this.removed) return { added: 0, candidates: [] };
|
||||
const { transcriptId, source, startOffset, chunk, size } = input;
|
||||
if (transcriptId !== this.transcriptId) {
|
||||
this.transcriptId = transcriptId;
|
||||
this.cursors.clear();
|
||||
this.pendingBash = [];
|
||||
}
|
||||
let cursor = this.cursors.get(source);
|
||||
if (cursor && size < cursor.offset) {
|
||||
this.cursors.delete(source);
|
||||
cursor = undefined;
|
||||
}
|
||||
if (cursor && startOffset !== cursor.offset) return { added: 0, candidates: [] };
|
||||
|
||||
const atEof = startOffset + chunk.length >= size;
|
||||
// Bytes before `from` belong to a line that started before this chunk.
|
||||
let from = 0;
|
||||
if (cursor ? cursor.midLine : startOffset > 0) {
|
||||
const nl = chunk.indexOf(NL);
|
||||
if (nl === -1) {
|
||||
// Still inside that line: move past it unless it is the line being written. A first
|
||||
// read began inside it, so the floor does too.
|
||||
if (!atEof) this.setCursor(source, cursor, startOffset + chunk.length, startOffset, true, true);
|
||||
return { added: 0, candidates: [] };
|
||||
}
|
||||
from = nl + 1;
|
||||
}
|
||||
const lastNl = chunk.lastIndexOf(NL);
|
||||
if (lastNl < from) {
|
||||
if (from > 0) {
|
||||
// The skipped (or cut first) line ended here; the next one is read from its start.
|
||||
this.setCursor(source, cursor, startOffset + from, startOffset + from, false);
|
||||
} else if (!atEof && chunk.length > 0) {
|
||||
// One line longer than the budget: skip it instead of re-reading it forever.
|
||||
this.setCursor(source, cursor, startOffset + chunk.length, startOffset, true);
|
||||
} else if (!cursor) {
|
||||
this.setCursor(source, undefined, startOffset, startOffset, false);
|
||||
}
|
||||
return { added: 0, candidates: [] };
|
||||
}
|
||||
const end = lastNl + 1;
|
||||
this.setCursor(source, cursor, startOffset + end, startOffset + from, false);
|
||||
const parsed = parseClaudeEditLines(chunk.subarray(from, end).toString('utf8'), this.pendingBash);
|
||||
this.pendingBash = parsed.pending;
|
||||
const added = this.addRecords(parsed.records);
|
||||
this.scheduleSave();
|
||||
return { added, candidates: parsed.candidates };
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply a chunk read at `olderRange`: a full budget unless it reaches the file's start. It
|
||||
* ends at the floor, which is a line start, or with `floorMidLine` lies inside a line longer
|
||||
* than the budget (its part in this chunk is dropped).
|
||||
*/
|
||||
applyOlder(input: ApplyInput): ApplyResult {
|
||||
const { transcriptId, source, startOffset, chunk } = input;
|
||||
const cursor = this.removed ? undefined : this.cursors.get(source);
|
||||
if (transcriptId !== this.transcriptId || !cursor || startOffset + chunk.length !== cursor.floor) {
|
||||
return { added: 0, candidates: [] };
|
||||
}
|
||||
const none = { added: 0, candidates: [] };
|
||||
let end = chunk.length;
|
||||
if (cursor.floorMidLine) {
|
||||
const lastNl = chunk.lastIndexOf(NL);
|
||||
if (lastNl === -1 && startOffset > 0) {
|
||||
// Still inside the long line.
|
||||
cursor.floor = startOffset;
|
||||
this.scheduleSave();
|
||||
return none;
|
||||
}
|
||||
// The long line starts at `end`: drop its part.
|
||||
end = lastNl + 1;
|
||||
delete cursor.floorMidLine;
|
||||
}
|
||||
let from = 0;
|
||||
if (startOffset > 0) {
|
||||
// The newline at `end - 1` closes a line that may have started before this chunk.
|
||||
const nl = chunk.subarray(0, end).indexOf(NL);
|
||||
if (nl === -1 || nl === end - 1) {
|
||||
if (end < chunk.length) {
|
||||
// It ends where the long line starts: the next block, ending there, holds it whole.
|
||||
cursor.floor = startOffset + end;
|
||||
} else {
|
||||
// No line starts in this chunk: it lies inside one line longer than the budget. Skip it.
|
||||
cursor.floor = startOffset;
|
||||
cursor.floorMidLine = true;
|
||||
}
|
||||
this.scheduleSave();
|
||||
return none;
|
||||
}
|
||||
from = nl + 1;
|
||||
}
|
||||
cursor.floor = startOffset + from;
|
||||
// Calls whose results lie in already-read newer lines cannot be paired here.
|
||||
const parsed = parseClaudeEditLines(chunk.subarray(from, end).toString('utf8'));
|
||||
const added = this.addRecords(parsed.records);
|
||||
this.scheduleSave();
|
||||
return { added, candidates: parsed.candidates };
|
||||
}
|
||||
|
||||
/** Add Bash images whose mtime was confirmed inside their call's window. */
|
||||
addImageEntries(images: readonly ConfirmedImage[]): number {
|
||||
if (this.removed) return 0;
|
||||
const records: EditHistoryEntry[] = images.map((img) => {
|
||||
const entry: EditHistoryEntry = {
|
||||
id: `${img.toolUseId}:${img.path}`,
|
||||
ts: img.ts,
|
||||
path: img.path,
|
||||
kind: 'image',
|
||||
tool: img.tool ?? 'bash',
|
||||
created: false,
|
||||
added: 0,
|
||||
removed: 0,
|
||||
hunks: [],
|
||||
truncated: false,
|
||||
};
|
||||
if (img.agentId) entry.agentId = img.agentId;
|
||||
return entry;
|
||||
});
|
||||
const added = this.addRecords(records);
|
||||
if (added) this.scheduleSave();
|
||||
return added;
|
||||
}
|
||||
|
||||
/**
|
||||
* Write pending changes, if any, and wait for a write under way (the store is dropped from
|
||||
* memory and loaded again later). Unlike `flush()`, a store without changes writes nothing.
|
||||
*/
|
||||
async settle(): Promise<void> {
|
||||
if (this.saveTimer) return this.flush();
|
||||
await this.writing;
|
||||
}
|
||||
|
||||
/** Write now (cancels a pending debounced write). Writes run one after another. */
|
||||
async flush(): Promise<void> {
|
||||
if (this.saveTimer) {
|
||||
clearTimeout(this.saveTimer);
|
||||
this.saveTimer = null;
|
||||
}
|
||||
const run = (this.writing ?? Promise.resolve()).then(() => this.write());
|
||||
this.writing = run.catch(() => undefined);
|
||||
await run;
|
||||
}
|
||||
|
||||
/** True once {@link remove} ran: the store ignores every later write. */
|
||||
isRemoved(): boolean {
|
||||
return this.removed;
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete the persisted file (session deleted) and forget the content. The store takes no
|
||||
* changes afterwards, so a read still in flight neither writes nor refills it.
|
||||
*/
|
||||
async remove(): Promise<void> {
|
||||
this.removed = true;
|
||||
if (this.saveTimer) {
|
||||
clearTimeout(this.saveTimer);
|
||||
this.saveTimer = null;
|
||||
}
|
||||
this.entries.clear();
|
||||
this.bytes = 0;
|
||||
this.cursors.clear();
|
||||
this.pendingBash = [];
|
||||
this.agents.clear();
|
||||
// A write already under way ends first (or sees `removed`), so it cannot rename its
|
||||
// tmp file over the deleted store afterwards.
|
||||
await this.writing;
|
||||
await unlink(this.file).catch(() => undefined);
|
||||
}
|
||||
|
||||
private async write(): Promise<void> {
|
||||
if (this.removed) return;
|
||||
const data: StoreFile = {
|
||||
version: FILE_VERSION,
|
||||
sessionId: this.sessionId,
|
||||
transcriptId: this.transcriptId,
|
||||
cursors: Object.fromEntries(this.cursors),
|
||||
pendingBash: this.pendingBash,
|
||||
agents: Object.fromEntries(this.agents),
|
||||
entries: this.list(),
|
||||
};
|
||||
const dir = join(this.file, '..');
|
||||
await mkdir(dir, { recursive: true, mode: 0o700 });
|
||||
// `mode` applies only to a file being created: the fresh tmp file is 0600, and the rename
|
||||
// makes it the store, so an existing store never keeps wider bits.
|
||||
const tmp = `${this.file}.${TMP_TAG}.tmp`;
|
||||
await writeFile(tmp, JSON.stringify(data), { mode: 0o600 });
|
||||
if (this.removed) {
|
||||
await unlink(tmp).catch(() => undefined);
|
||||
return;
|
||||
}
|
||||
await rename(tmp, this.file);
|
||||
}
|
||||
|
||||
private putAgent(agentId: string, meta: AgentMeta): void {
|
||||
this.agents.delete(agentId);
|
||||
this.agents.set(agentId, meta);
|
||||
if (this.agents.size <= MAX_AGENTS) return;
|
||||
const used = new Set<string>();
|
||||
for (const e of this.entries.values()) if (e.agentId) used.add(e.agentId);
|
||||
for (const id of this.agents.keys()) {
|
||||
if (this.agents.size <= MAX_AGENTS) break;
|
||||
// The newest label has no entries yet: they come with its transcript.
|
||||
if (id !== agentId && !used.has(id)) this.agents.delete(id);
|
||||
}
|
||||
}
|
||||
|
||||
private setCursor(
|
||||
source: string,
|
||||
cursor: SourceCursor | undefined,
|
||||
offset: number,
|
||||
firstFloor: number,
|
||||
midLine: boolean,
|
||||
firstFloorMidLine = false
|
||||
): void {
|
||||
const next: SourceCursor = { offset, floor: cursor ? cursor.floor : firstFloor };
|
||||
if (midLine) next.midLine = true;
|
||||
if (cursor ? cursor.floorMidLine : firstFloorMidLine) next.floorMidLine = true;
|
||||
this.cursors.set(source, next);
|
||||
}
|
||||
|
||||
private addRecords(records: readonly EditHistoryEntry[]): number {
|
||||
const fresh: string[] = [];
|
||||
for (const r of records) {
|
||||
if (this.entries.has(r.id)) continue;
|
||||
this.entries.set(r.id, r);
|
||||
this.bytes += entryBytes(r);
|
||||
fresh.push(r.id);
|
||||
}
|
||||
if (this.entries.size > MAX_ENTRIES || this.bytes > MAX_STORE_BYTES) {
|
||||
for (const e of [...this.entries.values()].sort((a, b) => a.ts - b.ts)) {
|
||||
if (this.entries.size <= MAX_ENTRIES && this.bytes <= MAX_STORE_BYTES) break;
|
||||
this.entries.delete(e.id);
|
||||
this.bytes -= entryBytes(e);
|
||||
}
|
||||
}
|
||||
// Only what is still there counts: an "older" block past the cap adds nothing.
|
||||
return fresh.filter((id) => this.entries.has(id)).length;
|
||||
}
|
||||
|
||||
private scheduleSave(): void {
|
||||
if (this.removed || this.saveTimer) return;
|
||||
this.saveTimer = setTimeout(() => {
|
||||
this.saveTimer = null;
|
||||
void this.flush().catch((err) => {
|
||||
console.warn(`[EditHistory] Save failed for session ${this.sessionId}:`, err);
|
||||
});
|
||||
}, this.debounceMs);
|
||||
this.saveTimer.unref?.();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,284 @@
|
||||
/**
|
||||
* @fileoverview Folder listings for the file-preview folder grid.
|
||||
*
|
||||
* A folder the user opens (a clicked folder path, or "Folder" in the file
|
||||
* preview) is resolved, checked against the same attachment guard a single file
|
||||
* goes through, and GRANTED an id. The grid then asks for files by
|
||||
* `folderId + file name` only, so browser requests never carry arbitrary paths —
|
||||
* the rule the attachment routes follow for single files.
|
||||
*
|
||||
* Grants live in their own small registry, not in the attachment registry: that one
|
||||
* is capped at 200 records per session, and a folder of 300 renders would evict the
|
||||
* session's real attachments.
|
||||
*
|
||||
* Listings contain non-hidden subfolders and files whose type the preview supports
|
||||
* (`isSupportedAttachmentExtension`), newest first, capped at
|
||||
* {@link MAX_FOLDER_ENTRIES}. Local folders are read with `fs`; a remote (SSH) case's
|
||||
* folder is listed on its host in one ssh round trip (`remoteListFolder`).
|
||||
*/
|
||||
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import fs from 'node:fs/promises';
|
||||
import { homedir } from 'node:os';
|
||||
import { extname, isAbsolute, join, posix } from 'node:path';
|
||||
import {
|
||||
getAttachmentType,
|
||||
isSupportedAttachmentExtension,
|
||||
supportedAttachmentExtensions,
|
||||
} from './attachment-registry.js';
|
||||
import { isBlockedAttachmentPath, isUnderTree, loadAttachmentGuardConfig } from './config/attachment-guard.js';
|
||||
import { isMultiUserMode, userSpacePath } from './config/multiuser.js';
|
||||
import { remoteListFolder, RemoteFileAccessError, type RemoteFolderEntry } from './remote-files.js';
|
||||
import type { AttachmentDetectedType, AuthUser } from './types.js';
|
||||
import type { SessionRemote } from './types/session.js';
|
||||
import { describeUnknownPath, probePathKind } from './utils/bounded-path-probe.js';
|
||||
import { validateSessionFilePath } from './web/route-helpers.js';
|
||||
|
||||
/** Most entries (files + subfolders) one listing returns. */
|
||||
export const MAX_FOLDER_ENTRIES = 1000;
|
||||
|
||||
/** Folder grants kept per session; the oldest is dropped beyond this. */
|
||||
const MAX_FOLDER_GRANTS_PER_SESSION = 32;
|
||||
|
||||
export interface FolderFileEntry {
|
||||
name: string;
|
||||
extension: string;
|
||||
attachmentType: AttachmentDetectedType;
|
||||
size: number;
|
||||
mtimeMs: number;
|
||||
}
|
||||
|
||||
export interface FolderListing {
|
||||
folderId: string;
|
||||
/** The folder with symlinks resolved (on the session's host). */
|
||||
path: string;
|
||||
files: FolderFileEntry[];
|
||||
/** Non-hidden subfolder names, sorted. */
|
||||
folders: string[];
|
||||
truncated: boolean;
|
||||
}
|
||||
|
||||
export class FolderListingError extends Error {
|
||||
constructor(
|
||||
message: string,
|
||||
readonly statusCode: number = 400
|
||||
) {
|
||||
super(message);
|
||||
}
|
||||
}
|
||||
|
||||
interface FolderGrant {
|
||||
folderId: string;
|
||||
dir: string;
|
||||
}
|
||||
|
||||
class FolderGrants {
|
||||
private bySession = new Map<string, Map<string, FolderGrant>>();
|
||||
|
||||
grant(sessionId: string, dir: string): string {
|
||||
let grants = this.bySession.get(sessionId);
|
||||
if (!grants) {
|
||||
grants = new Map();
|
||||
this.bySession.set(sessionId, grants);
|
||||
}
|
||||
for (const existing of grants.values()) {
|
||||
if (existing.dir === dir) {
|
||||
// Refresh its position so an often-used folder is the last to be dropped.
|
||||
grants.delete(existing.folderId);
|
||||
grants.set(existing.folderId, existing);
|
||||
return existing.folderId;
|
||||
}
|
||||
}
|
||||
const folderId = `fld_${randomUUID()}`;
|
||||
grants.set(folderId, { folderId, dir });
|
||||
while (grants.size > MAX_FOLDER_GRANTS_PER_SESSION) {
|
||||
const oldest = grants.keys().next().value;
|
||||
if (oldest === undefined) break;
|
||||
grants.delete(oldest);
|
||||
}
|
||||
return folderId;
|
||||
}
|
||||
|
||||
get(sessionId: string, folderId: string): FolderGrant | undefined {
|
||||
return this.bySession.get(sessionId)?.get(folderId);
|
||||
}
|
||||
|
||||
clearSession(sessionId: string): void {
|
||||
this.bySession.delete(sessionId);
|
||||
}
|
||||
}
|
||||
|
||||
export const folderGrants = new FolderGrants();
|
||||
|
||||
/**
|
||||
* A file name inside a granted folder, or null when it is not one: no path
|
||||
* separator, not hidden (which also rules out `.`/`..`; the listing leaves hidden
|
||||
* entries out, so the routes do too), no NUL, and a type the preview supports.
|
||||
*/
|
||||
export function validFolderFileName(name: unknown): string | null {
|
||||
if (typeof name !== 'string' || !name || name.length > 255) return null;
|
||||
if (name.startsWith('.') || name.includes('/') || name.includes('\\') || name.includes('\0')) return null;
|
||||
const extension = extname(name).toLowerCase().replace(/^\./, '');
|
||||
return isSupportedAttachmentExtension(extension) ? name : null;
|
||||
}
|
||||
|
||||
/** Where a granted folder's file lives (on the session's host). */
|
||||
export function folderFilePath(dir: string, name: string): string {
|
||||
return posix.join(dir, name);
|
||||
}
|
||||
|
||||
interface ListFolderOptions {
|
||||
sessionWorkingDir?: string;
|
||||
remote?: SessionRemote;
|
||||
/** The requesting identity; in multi-user mode a regular user is held to {@link folderAllowedForUser}. */
|
||||
user?: AuthUser;
|
||||
}
|
||||
|
||||
/**
|
||||
* Multi-user rule for folder grants, the same one the filesystem path picker follows:
|
||||
* a regular user reaches only their own space (`userSpacePath`) or the session's
|
||||
* `workingDir`. User spaces live inside `homedir()`, so without it a user could list
|
||||
* and read another user's space. Always true in single-user mode and for admins; a
|
||||
* remote session's folders are on its host, where only the `workingDir` applies.
|
||||
*
|
||||
* @param path Already resolved (realpath on the session's host).
|
||||
*/
|
||||
export function folderAllowedForUser(
|
||||
path: string,
|
||||
user: AuthUser | undefined,
|
||||
scope: { workingDir?: string; remote?: SessionRemote }
|
||||
): boolean {
|
||||
if (!isMultiUserMode()) return true;
|
||||
if (!user) return false;
|
||||
if (user.role === 'admin') return true;
|
||||
if (scope.remote) return !!scope.workingDir && isUnderTree(path, scope.workingDir);
|
||||
return [userSpacePath(user.username), scope.workingDir].some(
|
||||
(root) => !!root && !!validateSessionFilePath(root, path)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve, guard, list and grant a folder for `sessionId`.
|
||||
*
|
||||
* A local folder is probed bounded first (`probePathKind`): one on a network mount
|
||||
* that stopped answering must not freeze the server, and "did not answer" is never
|
||||
* reported as "not found". The guard runs on the resolved folder before it is read.
|
||||
*
|
||||
* @param requestedPath Absolute, or `~/…` (the home of the session's host).
|
||||
* @throws FolderListingError 400 not absolute / not a folder, 403 blocked or outside the
|
||||
* user's space, 404 missing, 502 remote unreachable, 503 local folder not answering.
|
||||
*/
|
||||
export async function listFolder(
|
||||
sessionId: string,
|
||||
requestedPath: string,
|
||||
options: ListFolderOptions = {}
|
||||
): Promise<FolderListing> {
|
||||
const homeRelative = typeof requestedPath === 'string' && (requestedPath === '~' || requestedPath.startsWith('~/'));
|
||||
if (!requestedPath || typeof requestedPath !== 'string' || (!isAbsolute(requestedPath) && !homeRelative)) {
|
||||
throw new FolderListingError('Folder path must be absolute');
|
||||
}
|
||||
|
||||
const guard = await loadAttachmentGuardConfig();
|
||||
const workingDir = options.sessionWorkingDir;
|
||||
const assertAllowed = (dir: string): void => {
|
||||
const confined = !guard.confineToWorkspace
|
||||
? true
|
||||
: options.remote
|
||||
? !!workingDir && isUnderTree(dir, workingDir)
|
||||
: !!workingDir && !!validateSessionFilePath(workingDir, dir);
|
||||
if (!confined || isBlockedAttachmentPath(dir, guard.blockedTrees)) {
|
||||
throw new FolderListingError('Access to this folder is blocked', 403);
|
||||
}
|
||||
if (!folderAllowedForUser(dir, options.user, { workingDir, remote: options.remote })) {
|
||||
throw new FolderListingError('Folder is outside your space', 403);
|
||||
}
|
||||
};
|
||||
|
||||
let dir: string;
|
||||
let entries: RemoteFolderEntry[];
|
||||
let truncated = false;
|
||||
if (options.remote) {
|
||||
const remotePath = requestedPath === '~' ? '~/' : requestedPath;
|
||||
let listing;
|
||||
try {
|
||||
listing = await remoteListFolder(options.remote, remotePath, supportedAttachmentExtensions(), MAX_FOLDER_ENTRIES);
|
||||
} catch (err) {
|
||||
throw new FolderListingError(err instanceof RemoteFileAccessError ? err.message : 'remote host unreachable', 502);
|
||||
}
|
||||
if (!listing) throw new FolderListingError('Folder not found', 404);
|
||||
if (listing === 'not-a-folder') throw new FolderListingError('Not a folder');
|
||||
({ dir, entries, truncated } = listing);
|
||||
// One ssh call lists and resolves together, so the guard can only run after it;
|
||||
// nothing of a refused listing leaves this function.
|
||||
assertAllowed(dir);
|
||||
} else {
|
||||
const expanded = homeRelative ? join(homedir(), requestedPath.slice(2)) : requestedPath;
|
||||
const kind = await probePathKind(expanded, { pastCap: true });
|
||||
if (kind === 'unknown') {
|
||||
throw new FolderListingError(describeUnknownPath('Folder', expanded, { pastCap: true }), 503);
|
||||
}
|
||||
if (kind === 'absent') throw new FolderListingError('Folder not found', 404);
|
||||
if (kind !== 'directory') throw new FolderListingError('Not a folder');
|
||||
try {
|
||||
dir = await fs.realpath(expanded);
|
||||
} catch {
|
||||
throw new FolderListingError('Folder not found', 404);
|
||||
}
|
||||
assertAllowed(dir);
|
||||
({ entries, truncated } = await listLocalFolder(dir));
|
||||
}
|
||||
|
||||
const files: FolderFileEntry[] = [];
|
||||
const folders: string[] = [];
|
||||
for (const entry of entries) {
|
||||
if (entry.name.startsWith('.')) continue;
|
||||
// A secret that happens to sit in an open folder stays out of the grid too.
|
||||
if (isBlockedAttachmentPath(folderFilePath(dir, entry.name), guard.blockedTrees)) continue;
|
||||
if (entry.kind === 'directory') {
|
||||
folders.push(entry.name);
|
||||
continue;
|
||||
}
|
||||
const extension = extname(entry.name).toLowerCase().replace(/^\./, '');
|
||||
if (!isSupportedAttachmentExtension(extension)) continue;
|
||||
files.push({
|
||||
name: entry.name,
|
||||
extension,
|
||||
attachmentType: getAttachmentType(extension),
|
||||
size: entry.size,
|
||||
mtimeMs: entry.mtimeMs,
|
||||
});
|
||||
}
|
||||
files.sort((a, b) => b.mtimeMs - a.mtimeMs || a.name.localeCompare(b.name));
|
||||
folders.sort((a, b) => a.localeCompare(b));
|
||||
|
||||
return { folderId: folderGrants.grant(sessionId, dir), path: dir, files, folders, truncated };
|
||||
}
|
||||
|
||||
/** Local counterpart of the remote listing script: subfolders + supported files, stat'ed through symlinks. */
|
||||
async function listLocalFolder(dir: string): Promise<{ entries: RemoteFolderEntry[]; truncated: boolean }> {
|
||||
const dirents = await fs.readdir(dir, { withFileTypes: true });
|
||||
const entries: RemoteFolderEntry[] = [];
|
||||
let truncated = false;
|
||||
for (const dirent of dirents) {
|
||||
if (dirent.name.startsWith('.')) continue;
|
||||
if (entries.length >= MAX_FOLDER_ENTRIES) {
|
||||
truncated = true;
|
||||
break;
|
||||
}
|
||||
const extension = extname(dirent.name).toLowerCase().replace(/^\./, '');
|
||||
const maybeFile = isSupportedAttachmentExtension(extension);
|
||||
if (!dirent.isDirectory() && !maybeFile && !dirent.isSymbolicLink()) continue;
|
||||
let stat;
|
||||
try {
|
||||
stat = await fs.stat(join(dir, dirent.name));
|
||||
} catch {
|
||||
continue; // dangling symlink, or gone since readdir
|
||||
}
|
||||
if (stat.isDirectory()) {
|
||||
entries.push({ kind: 'directory', name: dirent.name, size: 0, mtimeMs: 0 });
|
||||
} else if (stat.isFile() && maybeFile) {
|
||||
entries.push({ kind: 'file', name: dirent.name, size: stat.size, mtimeMs: stat.mtimeMs });
|
||||
}
|
||||
}
|
||||
return { entries, truncated };
|
||||
}
|
||||
+90
-15
@@ -50,9 +50,10 @@
|
||||
import { spawn, execFileSync } from 'node:child_process';
|
||||
import { randomBytes } from 'node:crypto';
|
||||
import { existsSync } from 'node:fs';
|
||||
import { rename, rm } from 'node:fs/promises';
|
||||
import { lstat, readdir, rename, rm } from 'node:fs/promises';
|
||||
import { basename, dirname, join } from 'node:path';
|
||||
import { EXEC_TIMEOUT_MS } from './config/exec-timeout.js';
|
||||
import { probePathKind } from './utils/bounded-path-probe.js';
|
||||
|
||||
// ─── Tunables ────────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -188,8 +189,13 @@ export interface GitFailure {
|
||||
export interface CloneOptions {
|
||||
/** Pre-validated operand from `parseGitRepositoryUrl`. */
|
||||
repository: string;
|
||||
/** Absolute destination directory. Must NOT exist; created by git. */
|
||||
/** Absolute destination directory. Must NOT exist (created by git), unless `intoEmptyDir` is set. */
|
||||
destination: string;
|
||||
/**
|
||||
* Clone straight into `destination` when it already exists as an EMPTY directory (the folder a user
|
||||
* picked), instead of refusing it. A folder with anything in it, or a symlink, is still refused.
|
||||
*/
|
||||
intoEmptyDir?: boolean;
|
||||
/** Optional branch or tag (`--branch <ref> --single-branch`). */
|
||||
ref?: string;
|
||||
/** `--depth 1`: history-less but much faster on large repos. */
|
||||
@@ -590,6 +596,11 @@ export function classifyGitFailure(stderr: string, timedOut: boolean, spawnError
|
||||
stderr: clean,
|
||||
};
|
||||
}
|
||||
// First: git's refusal quotes the destination path, so a folder named openssl, rustls or "not found" would
|
||||
// otherwise match the host and repository patterns below, and a wrong code here decides a deletion.
|
||||
if (/already exists and is not an empty directory|destination path .* already exists/.test(lower)) {
|
||||
return { code: 'DESTINATION_EXISTS', message: 'The destination directory already exists.', stderr: clean };
|
||||
}
|
||||
if (timedOut) {
|
||||
return {
|
||||
code: 'TIMEOUT',
|
||||
@@ -625,12 +636,13 @@ export function classifyGitFailure(stderr: string, timedOut: boolean, spawnError
|
||||
stderr: clean,
|
||||
};
|
||||
}
|
||||
if (/could not resolve host|connection refused|connection timed out|network is unreachable|ssl|tls/.test(lower)) {
|
||||
if (
|
||||
/could not resolve host|connection refused|connection timed out|network is unreachable|\bssl(?:\b|_)|\btls\b|certificate|handshake/.test(
|
||||
lower
|
||||
)
|
||||
) {
|
||||
return { code: 'HOST_UNREACHABLE', message: 'Could not reach that host from this machine.', stderr: clean };
|
||||
}
|
||||
if (/already exists and is not an empty directory|destination path .* already exists/.test(lower)) {
|
||||
return { code: 'DESTINATION_EXISTS', message: 'The destination directory already exists.', stderr: clean };
|
||||
}
|
||||
return { code: 'FAILED', message: clean ? `git failed: ${firstLine(clean)}` : 'git failed.', stderr: clean };
|
||||
}
|
||||
|
||||
@@ -701,6 +713,8 @@ interface GitRun {
|
||||
stderr: string;
|
||||
code: number | null;
|
||||
timedOut: boolean;
|
||||
/** False when git never ran (queue full, slot wait timed out, spawn failed): nothing was written. */
|
||||
started: boolean;
|
||||
spawnError?: string;
|
||||
}
|
||||
|
||||
@@ -720,10 +734,17 @@ async function runGit(args: string[], timeoutMs: number, maxStdoutBytes: number)
|
||||
const queuedAt = Date.now();
|
||||
const slot = await acquireGitSlot(timeoutMs);
|
||||
if (slot === 'queue-full') {
|
||||
return { stdout: '', stderr: '', code: null, timedOut: false, spawnError: 'EBUSY: git operation queue is full' };
|
||||
return {
|
||||
stdout: '',
|
||||
stderr: '',
|
||||
code: null,
|
||||
timedOut: false,
|
||||
spawnError: 'EBUSY: git operation queue is full',
|
||||
started: false,
|
||||
};
|
||||
}
|
||||
if (slot === 'timed-out') {
|
||||
return { stdout: '', stderr: '', code: null, timedOut: true };
|
||||
return { stdout: '', stderr: '', code: null, timedOut: true, started: false };
|
||||
}
|
||||
const remainingMs = Math.max(1, timeoutMs - (Date.now() - queuedAt));
|
||||
try {
|
||||
@@ -736,7 +757,7 @@ async function runGit(args: string[], timeoutMs: number, maxStdoutBytes: number)
|
||||
detached: true,
|
||||
});
|
||||
} catch (err) {
|
||||
resolve({ stdout: '', stderr: '', code: null, timedOut: false, spawnError: String(err) });
|
||||
resolve({ stdout: '', stderr: '', code: null, timedOut: false, started: false, spawnError: String(err) });
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -783,8 +804,10 @@ async function runGit(args: string[], timeoutMs: number, maxStdoutBytes: number)
|
||||
resolve(result);
|
||||
};
|
||||
|
||||
child.on('error', (err) => finish({ stdout, stderr, code: null, timedOut, spawnError: String(err) }));
|
||||
child.on('close', (code) => finish({ stdout, stderr, code, timedOut }));
|
||||
child.on('error', (err) =>
|
||||
finish({ stdout, stderr, code: null, timedOut, started: false, spawnError: String(err) })
|
||||
);
|
||||
child.on('close', (code) => finish({ stdout, stderr, code, timedOut, started: true }));
|
||||
});
|
||||
} finally {
|
||||
releaseGitSlot();
|
||||
@@ -847,6 +870,35 @@ export async function probeGitRemote(
|
||||
};
|
||||
}
|
||||
|
||||
/** Destinations with a direct (`intoEmptyDir`) clone in flight. */
|
||||
const directClones = new Set<string>();
|
||||
|
||||
function destinationExists(): CloneResult {
|
||||
return {
|
||||
ok: false,
|
||||
failure: { code: 'DESTINATION_EXISTS', message: 'The destination directory already exists.', stderr: '' },
|
||||
};
|
||||
}
|
||||
|
||||
/** True for a real (not symlinked) directory with nothing in it. Any read error counts as not empty. */
|
||||
async function isEmptyDirectory(path: string): Promise<boolean> {
|
||||
try {
|
||||
const st = await lstat(path);
|
||||
return st.isDirectory() && (await readdir(path)).length === 0;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/** Remove everything inside `path`, keeping `path` itself. Best effort. */
|
||||
async function emptyDirectory(path: string): Promise<void> {
|
||||
try {
|
||||
for (const entry of await readdir(path)) await rm(join(path, entry), { recursive: true, force: true });
|
||||
} catch {
|
||||
/* nothing to clean, or it vanished */
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Clone `repository` into `destination`.
|
||||
*
|
||||
@@ -872,10 +924,33 @@ export async function cloneRepository(opts: CloneOptions): Promise<CloneResult>
|
||||
};
|
||||
}
|
||||
if (existsSync(opts.destination)) {
|
||||
return {
|
||||
ok: false,
|
||||
failure: { code: 'DESTINATION_EXISTS', message: 'The destination directory already exists.', stderr: '' },
|
||||
};
|
||||
// One direct clone per folder at a time: once two gits have written into the same folder their
|
||||
// files cannot be told apart, so the second caller is refused before it spawns anything. Taken
|
||||
// before the emptiness check (which can precede a long wait for a git slot).
|
||||
if (!opts.intoEmptyDir || directClones.has(opts.destination)) return destinationExists();
|
||||
directClones.add(opts.destination);
|
||||
try {
|
||||
if (!(await isEmptyDirectory(opts.destination))) return destinationExists();
|
||||
// The folder is the user's own, so there is no temporary sibling to rename into place: git fills it.
|
||||
const direct = await runGit(buildCloneArgs(opts), opts.timeoutMs ?? GIT_CLONE_TIMEOUT_MS, MAX_STDERR_BYTES);
|
||||
if (direct.code === 0 && !direct.spawnError) return { ok: true, stderr: sanitizeGitOutput(direct.stderr) };
|
||||
const failure = classifyGitFailure(direct.stderr, direct.timedOut, direct.spawnError);
|
||||
// The folder was empty when this attempt took it, so what is in it is this attempt's partial tree, but
|
||||
// only if git actually ran: a refused folder, a full queue or a slot wait that timed out wrote nothing,
|
||||
// and anything in the folder by then (files the user copied in, another case created there) is not ours.
|
||||
// A mount that stopped answering is left alone too, so a libuv worker is not held on it.
|
||||
// git's own refusal is tested on its text, not on the classifier's code, so the deletion never depends on
|
||||
// which pattern matched first.
|
||||
const refused = /already exists and is not an empty directory/i.test(direct.stderr);
|
||||
if (direct.started && !refused && failure.code !== 'DESTINATION_EXISTS') {
|
||||
if ((await probePathKind(opts.destination, { pastCap: true })) === 'directory') {
|
||||
await emptyDirectory(opts.destination);
|
||||
}
|
||||
}
|
||||
return { ok: false, failure };
|
||||
} finally {
|
||||
directClones.delete(opts.destination);
|
||||
}
|
||||
}
|
||||
|
||||
// Sibling of the destination (same filesystem), so the rename is atomic.
|
||||
|
||||
@@ -0,0 +1,307 @@
|
||||
/**
|
||||
* @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
|
||||
* 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
|
||||
* turning the feature off actually stops the fetching. A session can override both the switch and the
|
||||
* interval; a session with no override follows the app.
|
||||
*
|
||||
* 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.
|
||||
* - 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
|
||||
* 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`
|
||||
* 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
|
||||
* 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".
|
||||
* - A schedule is stamped at SELECTION, when a session goes into `due`, not when its run starts, so a
|
||||
* tick that is still running cannot pick the same folder again; and a tick is skipped while the previous
|
||||
* one is still going, so the bound below is the bound for the whole tick, not per tick.
|
||||
* - At most `FETCH_CONCURRENCY` git processes at a time across the whole tick, because each is a git
|
||||
* process: ONE bound is shared by every run of the tick, so `FETCH_CONCURRENCY` is the tick's ceiling
|
||||
* rather than each folder's own. The folders get their OWN bound (`mapLimited`), because one semaphore
|
||||
* cannot serve both levels: a folder that holds a slot and then waits for one for its own fetch is
|
||||
* waiting on itself, and the folders that fill the bound are exactly the ones blocking it.
|
||||
* - The interval is timed from the START of a run, so a run that takes three minutes does not push the
|
||||
* next one out by three minutes; it lands on the tick after its own interval elapses.
|
||||
* - The per-repository limit and the git timeout stay the module's defaults (`MAX_REPOS`,
|
||||
* `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, 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
|
||||
*/
|
||||
|
||||
import { resolve } from 'node:path';
|
||||
import { getErrorMessage } from './types.js';
|
||||
import {
|
||||
FETCH_CONCURRENCY,
|
||||
MAX_GIT_FETCH_INTERVAL_SECONDS,
|
||||
MIN_GIT_FETCH_INTERVAL_SECONDS,
|
||||
gitFetchStatusFrom,
|
||||
type GitFetchOptions,
|
||||
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';
|
||||
|
||||
/** What the scheduler needs from a session: the facts a decision needs, and nothing else. */
|
||||
export interface GitFetchSessionView {
|
||||
id: string;
|
||||
workingDir: string;
|
||||
/** `unknown`, not `boolean`: a session's remote/Docker markers are objects, and the only question here is whether either is set at all. */
|
||||
remote?: unknown;
|
||||
docker?: unknown;
|
||||
gitFetchEnabled?: boolean;
|
||||
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;
|
||||
/** 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
|
||||
* with `pastCap`. Anything that is not `directory` (including `unknown`, which is never `absent`) is
|
||||
* skipped, not fetched.
|
||||
*/
|
||||
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, trigger: GitFetchTrigger): Promise<GitFetchRun>;
|
||||
publish(id: string, status: GitFetchStatus): void;
|
||||
log?(message: string): void;
|
||||
}
|
||||
|
||||
/**
|
||||
* What one session will actually do: its own override wins, otherwise the app-wide setting. The interval is
|
||||
* clamped again here because a persisted value can predate the schema's bounds.
|
||||
*/
|
||||
export function effectiveGitFetch(
|
||||
view: GitFetchSessionView,
|
||||
settings: GitFetchSettings
|
||||
): { enabled: boolean; intervalSeconds: number } {
|
||||
return {
|
||||
enabled: view.gitFetchEnabled ?? settings.enabled,
|
||||
intervalSeconds: clampInt(
|
||||
view.gitFetchIntervalSeconds ?? settings.intervalSeconds,
|
||||
MIN_GIT_FETCH_INTERVAL_SECONDS,
|
||||
MAX_GIT_FETCH_INTERVAL_SECONDS,
|
||||
MIN_GIT_FETCH_INTERVAL_SECONDS
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
export class GitFetchScheduler {
|
||||
private readonly ports: GitFetchPorts;
|
||||
/** When each FOLDER last started a run; timed from the start, not the end. */
|
||||
private readonly lastRunAt = new Map<string, number>();
|
||||
private readonly inFlight = new Set<string>();
|
||||
/** Session → folder, so `remove()` can keep the folder's schedule while other sessions are still on it. */
|
||||
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;
|
||||
/** The same for the Docker case list. */
|
||||
private dockerCasesUnreadable = false;
|
||||
|
||||
constructor(ports: GitFetchPorts) {
|
||||
this.ports = ports;
|
||||
}
|
||||
|
||||
/** One tick: fetch every folder whose sessions are on and due. Returns how many folders ran. */
|
||||
async tick(now = Date.now()): Promise<number> {
|
||||
if (this.stopped || this.running) return 0;
|
||||
this.running = true;
|
||||
try {
|
||||
let settings: GitFetchSettings;
|
||||
try {
|
||||
settings = await this.ports.readSettings();
|
||||
} catch (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
|
||||
// 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);
|
||||
this.folderOf.set(id, folder);
|
||||
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[]>();
|
||||
// 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);
|
||||
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: ${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.
|
||||
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.
|
||||
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.
|
||||
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
|
||||
// `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.
|
||||
await mapLimited([...due.entries()], FETCH_CONCURRENCY, async ([folder, group]) => {
|
||||
try {
|
||||
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.
|
||||
this.ports.log?.(`git fetch: ${folder} could not run: ${getErrorMessage(err)}`);
|
||||
} finally {
|
||||
this.inFlight.delete(folder);
|
||||
}
|
||||
});
|
||||
return due.size;
|
||||
} finally {
|
||||
this.running = false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
remove(id: string): void {
|
||||
const folder = this.folderOf.get(id);
|
||||
this.folderOf.delete(id);
|
||||
if (!folder) return;
|
||||
const stillOpen = [...this.folderOf.values()].includes(folder);
|
||||
if (!stillOpen) {
|
||||
this.lastRunAt.delete(folder);
|
||||
this.inFlight.delete(folder);
|
||||
}
|
||||
}
|
||||
|
||||
stop(): void {
|
||||
this.stopped = true;
|
||||
this.lastRunAt.clear();
|
||||
this.inFlight.clear();
|
||||
this.folderOf.clear();
|
||||
}
|
||||
|
||||
/** Folders the scheduler still remembers a run for, so a test can see a `remove()` actually took. */
|
||||
tracked(): number {
|
||||
return new Set([...this.lastRunAt.keys(), ...this.inFlight]).size;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,506 @@
|
||||
/**
|
||||
* @fileoverview `git fetch` for the repositories the Git panel shows, on the user's click
|
||||
* (`POST /api/sessions/:id/git-fetch`) or on an interval the user turned on
|
||||
* (`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 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`, `fetchEnvFor`,
|
||||
* `gitFetchSettingsFrom`, `gitFetchStatusFrom`) holds every decision and tests without spawning, and the IO
|
||||
* half runs one bounded `git fetch --quiet` per repository.
|
||||
*
|
||||
* Rules the code keeps and the tests pin:
|
||||
* - 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
|
||||
* 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.
|
||||
* - 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 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. That BatchMode ssh is
|
||||
* 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
|
||||
* `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, 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';
|
||||
import {
|
||||
createLimiter,
|
||||
DEFAULT_GIT_TIMEOUT_MS,
|
||||
type Limiter,
|
||||
resolveOverviewLimits,
|
||||
resolveWorkspaceRepos,
|
||||
type GitRunner,
|
||||
} from './git-workspace-status.js';
|
||||
|
||||
const execFileAsync = promisify(execFile);
|
||||
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
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
|
||||
* fsmonitor hook, and no automatic maintenance or gc. A fetch is the git command that can run a
|
||||
* repository's own hooks (`reference-transaction`, and `pre-auto-gc` through the maintenance that follows a
|
||||
* fetch), and a gc started by a timer in a repository an agent is working in is exactly the kind of extra
|
||||
* write this module must not do. Kept as a const so a test can pin it without spawning git.
|
||||
*/
|
||||
export const FETCH_CONFIG_ARGS = ['-c', 'core.fsmonitor=false', '-c', 'maintenance.auto=false', '-c', 'gc.auto=0'];
|
||||
|
||||
/** The interval the user can turn on (Settings → Bottom bar, and Session Options → Git): bounds and default. */
|
||||
export const DEFAULT_GIT_FETCH_INTERVAL_SECONDS = 300;
|
||||
export const MIN_GIT_FETCH_INTERVAL_SECONDS = 30;
|
||||
export const MAX_GIT_FETCH_INTERVAL_SECONDS = 3600;
|
||||
|
||||
export interface GitFetchSettings {
|
||||
enabled: boolean;
|
||||
intervalSeconds: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* A settings object as the scheduler should read it: absent means OFF, because nothing runs on a timer
|
||||
* until the user turns this on, and a number outside the schema's bounds is clamped rather than trusted.
|
||||
* Pure, so the reading itself is testable without a settings file.
|
||||
*/
|
||||
export function gitFetchSettingsFrom(settings: Record<string, unknown> | undefined): GitFetchSettings {
|
||||
const raw = settings?.gitFetchIntervalSeconds;
|
||||
const seconds =
|
||||
typeof raw === 'number' && Number.isFinite(raw) ? Math.round(raw) : DEFAULT_GIT_FETCH_INTERVAL_SECONDS;
|
||||
return {
|
||||
enabled: settings?.gitFetchEnabled === true,
|
||||
intervalSeconds: Math.min(MAX_GIT_FETCH_INTERVAL_SECONDS, Math.max(MIN_GIT_FETCH_INTERVAL_SECONDS, seconds)),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 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
|
||||
* `cwd` and the argv is fixed.
|
||||
*/
|
||||
export function buildFetchArgs(): string[] {
|
||||
return ['fetch', '--quiet'];
|
||||
}
|
||||
|
||||
/**
|
||||
* The `git` process also gets the `FETCH_CONFIG_ARGS` overrides ahead of this argv; they are kept separate
|
||||
* 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
|
||||
* branch is pinned on literal fixtures and the code the panel shows is the code this writes.
|
||||
*/
|
||||
export type FetchFailureCode = GitFailureCode | 'NO_REMOTE';
|
||||
|
||||
export interface FetchFailure {
|
||||
code: FetchFailureCode;
|
||||
/** User-facing summary. */
|
||||
message: string;
|
||||
/** Tail of git's own stderr, control-stripped and credential-redacted. */
|
||||
stderr: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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
|
||||
* wording is written for clones, so the fetch path replaces it with what the user can actually do.
|
||||
*/
|
||||
export function describeFetchFailure(err: unknown): FetchFailure {
|
||||
const e = err as { code?: unknown; stderr?: unknown; message?: string; killed?: boolean };
|
||||
const stderr = typeof e.stderr === 'string' ? e.stderr : '';
|
||||
const spawnError = typeof e.message === 'string' ? e.message : undefined;
|
||||
const clean = sanitizeGitOutput(stderr);
|
||||
const lower = `${clean}\n${spawnError ?? ''}`.toLowerCase();
|
||||
if (/no default remote repository|no remote configured|nothing to fetch/.test(lower)) {
|
||||
return {
|
||||
code: 'NO_REMOTE',
|
||||
message: 'This repository has no remote, so there is nothing to fetch.',
|
||||
stderr: clean,
|
||||
};
|
||||
}
|
||||
const timedOut = e.code === 'ETIMEDOUT' || e.killed === true;
|
||||
const failure = classifyGitFailure(stderr, timedOut, spawnError);
|
||||
if (failure.code === 'TIMEOUT')
|
||||
failure.message = 'Git timed out. Raise “Git status: git timeout” in Settings → Bottom bar, or fetch from a shell.';
|
||||
return failure;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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. 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, 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;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.
|
||||
*
|
||||
* `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>;
|
||||
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();
|
||||
}, remainingMs);
|
||||
|
||||
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));
|
||||
});
|
||||
};
|
||||
|
||||
export interface GitFetchEntry {
|
||||
/** Folder name of the repository (its root's basename), as the panel names it. */
|
||||
name: string;
|
||||
/** The repository root relative to the working directory: `.`, `..`, `api`, `apps/web`. */
|
||||
path: string;
|
||||
ok: boolean;
|
||||
failure?: FetchFailure;
|
||||
}
|
||||
|
||||
export interface GitFetchReport {
|
||||
/** `ok` when at least one repository was fetched (or attempted); the other states are as in the overview. */
|
||||
state: 'ok' | 'not-a-repo' | 'unsupported' | 'error';
|
||||
reason?: 'remote' | 'docker';
|
||||
error?: string;
|
||||
/** One entry per repository fetched, in the same order the panel lists them. */
|
||||
repos: GitFetchEntry[];
|
||||
/** More than `repoLimit` repositories were found; only the first were fetched. */
|
||||
reposTruncated: boolean;
|
||||
/** The most repositories this fetch would list (the caller's setting, or `MAX_REPOS`). */
|
||||
repoLimit?: number;
|
||||
/** When the last repository finished, so the panel can say what the "behind" number is now based on. */
|
||||
fetchedAt: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* What one run did, in the shape a session keeps and the Session Options Git row shows. `trigger` says
|
||||
* 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: GitFetchTrigger;
|
||||
/** As in the report: `ok`, or the state that stopped the run. */
|
||||
state: GitFetchReport['state'];
|
||||
/** Repositories this run covered. */
|
||||
repos: number;
|
||||
/** How many of them could not be fetched. */
|
||||
failed: number;
|
||||
/** The first failure, named: enough for one row without reprinting the whole list. */
|
||||
firstFailure?: { name: string; code: FetchFailureCode };
|
||||
}
|
||||
|
||||
/**
|
||||
* 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,
|
||||
trigger,
|
||||
state: report.state,
|
||||
repos: report.repos.length,
|
||||
failed: failed.length,
|
||||
};
|
||||
const first = failed[0];
|
||||
if (first?.failure) status.firstFailure = { name: first.name, code: first.failure.code };
|
||||
return status;
|
||||
}
|
||||
|
||||
/** Options for the fetch path: the same repository rules and limits as an overview, without its cache clock. */
|
||||
export interface GitFetchOptions {
|
||||
git?: GitRunner;
|
||||
home?: string;
|
||||
dockerWorkspaces?: string[];
|
||||
maxRepos?: number;
|
||||
timeoutMs?: number;
|
||||
/**
|
||||
* The shared slot count for a whole scheduler tick. Passed in so `FETCH_CONCURRENCY` bounds the tick
|
||||
* rather than each folder separately; without it, a tick over more than `FETCH_CONCURRENCY` folders could
|
||||
* have that many git processes going at once inside each one.
|
||||
*/
|
||||
limiter?: Limiter;
|
||||
}
|
||||
|
||||
export const emptyReport = (state: GitFetchReport['state'], extra: Partial<GitFetchReport> = {}): GitFetchReport => ({
|
||||
state,
|
||||
repos: [],
|
||||
reposTruncated: false,
|
||||
fetchedAt: Date.now(),
|
||||
...extra,
|
||||
});
|
||||
|
||||
async function fetchOne(dir: string, git: GitRunner, timeoutMs: number): Promise<FetchFailure | null> {
|
||||
try {
|
||||
await git(dir, buildFetchArgs(), { timeoutMs });
|
||||
return null;
|
||||
} catch (err) {
|
||||
return describeFetchFailure(err);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch every repository the panel would show for `cwd`, through the same resolver so the two cannot
|
||||
* disagree. A repository that failed keeps its row with the reason, exactly as an unreadable repository
|
||||
* stays in the status list: the panel must show that something was not fetched rather than quietly skip it.
|
||||
*/
|
||||
export async function fetchGitWorkspaceOverview(cwd: string, opts: GitFetchOptions = {}): Promise<GitFetchReport> {
|
||||
const where = await resolveWorkspaceRepos(cwd, opts);
|
||||
if (where.kind === 'docker') return emptyReport('unsupported', { reason: 'docker' });
|
||||
if (where.kind === 'error') return emptyReport('error', { error: where.error });
|
||||
const dirs = where.kind === 'enclosing' ? [where.root] : where.dirs;
|
||||
if (!dirs.length) return emptyReport('not-a-repo');
|
||||
const { timeoutMs } = resolveOverviewLimits(opts);
|
||||
const runner = opts.git ?? runFetch;
|
||||
const run = opts.limiter ?? createLimiter(FETCH_CONCURRENCY);
|
||||
const failures = await run(dirs, (dir) => fetchOne(dir, runner, timeoutMs));
|
||||
const repos: GitFetchEntry[] = dirs.map((dir, i) => {
|
||||
const base = { name: basename(dir), path: relative(cwd, dir) || '.' };
|
||||
const failure = failures[i];
|
||||
return failure ? { ...base, ok: false, failure } : { ...base, ok: true };
|
||||
});
|
||||
return {
|
||||
state: 'ok',
|
||||
repos,
|
||||
reposTruncated: where.kind === 'children' ? where.truncated : false,
|
||||
repoLimit: resolveOverviewLimits(opts).maxRepos,
|
||||
fetchedAt: Date.now(),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 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
|
||||
* 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<GitFetchRun>>();
|
||||
|
||||
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: 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;
|
||||
}
|
||||
|
||||
/** Test seam: an in-memory lane is not cleared by stopping the scheduler, so a test has to clear it itself. */
|
||||
export function clearGitFetchLane(): void {
|
||||
lane.clear();
|
||||
}
|
||||
@@ -22,7 +22,8 @@
|
||||
*
|
||||
* Rules the code keeps and the tests pin:
|
||||
* - READ-ONLY and OFFLINE. It never fetches, pulls, commits or writes. "Behind" therefore reflects
|
||||
* the last fetch (the UI says so); "ahead" and the unpushed list are exact against the
|
||||
* the last fetch (the UI says so, and the Fetch button is a separate user-triggered path in
|
||||
* `src/git-fetch.ts`, never a poll); "ahead" and the unpushed list are exact against the
|
||||
* remote-tracking refs already on disk. `--no-optional-locks` keeps `git status` from even
|
||||
* refreshing the index, so polling cannot contend with the agent's own git commands.
|
||||
* - Every call is async (`execFile`), bounded by a timeout, and never interpolates a path into a
|
||||
@@ -596,8 +597,55 @@ export async function discoverChildRepos(
|
||||
|
||||
const discoveryCache = new Map<string, { at: number; value: { dirs: string[]; truncated: boolean } }>();
|
||||
|
||||
/** Run `fn` over `items` with at most `limit` in flight, keeping the input order. */
|
||||
async function mapLimited<T, R>(items: T[], limit: number, fn: (item: T) => Promise<R>): Promise<R[]> {
|
||||
/**
|
||||
* 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.
|
||||
* ⚠️ 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
|
||||
* separate one for folders.
|
||||
*/
|
||||
export type Limiter = <T, R>(items: T[], fn: (item: T) => Promise<R>) => Promise<R[]>;
|
||||
|
||||
export function createLimiter(limit: number): Limiter {
|
||||
let inUse = 0;
|
||||
const waiting: Array<() => void> = [];
|
||||
const acquire = (): Promise<void> => {
|
||||
if (inUse < limit) {
|
||||
inUse++;
|
||||
return Promise.resolve();
|
||||
}
|
||||
return new Promise<void>((done) => {
|
||||
waiting.push(() => {
|
||||
inUse++;
|
||||
done();
|
||||
});
|
||||
});
|
||||
};
|
||||
const release = () => {
|
||||
inUse--;
|
||||
const next = waiting.shift();
|
||||
if (next) next();
|
||||
};
|
||||
return async <T, R>(items: T[], fn: (item: T) => Promise<R>): Promise<R[]> => {
|
||||
const out: R[] = new Array(items.length);
|
||||
await Promise.all(
|
||||
items.map(async (item, i) => {
|
||||
await acquire();
|
||||
try {
|
||||
out[i] = await fn(item);
|
||||
} finally {
|
||||
release();
|
||||
}
|
||||
})
|
||||
);
|
||||
return out;
|
||||
};
|
||||
}
|
||||
|
||||
/** Run `fn` over `items` with at most `limit` in flight, keeping the input order. Per call; `createLimiter` shares one bound across calls. */
|
||||
export async function mapLimited<T, R>(items: T[], limit: number, fn: (item: T) => Promise<R>): Promise<R[]> {
|
||||
const out: R[] = new Array(items.length);
|
||||
let next = 0;
|
||||
const worker = async () => {
|
||||
@@ -634,7 +682,7 @@ export function resolveOverviewLimits(opts: { maxRepos?: unknown; timeoutMs?: un
|
||||
};
|
||||
}
|
||||
|
||||
type WorkspaceRepos =
|
||||
export type WorkspaceRepos =
|
||||
| { kind: 'docker' }
|
||||
| { kind: 'error'; error: string }
|
||||
| { kind: 'enclosing'; root: string }
|
||||
@@ -643,9 +691,10 @@ type WorkspaceRepos =
|
||||
/**
|
||||
* WHICH repositories belong to the workspace (the module header has the rules), without a full
|
||||
* status of any of them: one cached `rev-parse` for the enclosing repository, else the cached scan
|
||||
* below the folder. The overview and the diff route both go through here, so they cannot disagree.
|
||||
* below the folder. The overview, the fetch route and the diff route all go through here, so they cannot
|
||||
* disagree about which repositories belong to a workspace.
|
||||
*/
|
||||
async function resolveWorkspaceRepos(cwd: string, opts: GitOverviewOptions): Promise<WorkspaceRepos> {
|
||||
export async function resolveWorkspaceRepos(cwd: string, opts: GitOverviewOptions): Promise<WorkspaceRepos> {
|
||||
const now = opts.now ?? Date.now;
|
||||
const { maxRepos, timeoutMs } = resolveOverviewLimits(opts);
|
||||
const dockerRoots = await realAll(opts.dockerWorkspaces ?? []);
|
||||
|
||||
@@ -0,0 +1,953 @@
|
||||
/**
|
||||
* @fileoverview Git worktrees for a LOCAL case: list, create and remove the worktrees a
|
||||
* repository-backed case keeps under `<repo>/.claude/worktrees/<name>`, so several agents working
|
||||
* on one repository each get their own working tree instead of sharing the case folder (sessions
|
||||
* in one case share its folder, and two agents editing, building and committing in one checkout
|
||||
* step on each other). Routes: `src/web/routes/worktree-routes.ts`; a session starts in one
|
||||
* through the existing create paths (`POST /api/quick-start` with `worktree`, or
|
||||
* `POST /api/sessions` with the worktree as `workingDir`).
|
||||
*
|
||||
* WHERE. `<repo>/.claude/worktrees/<name>` on a new branch `worktree-<name>` is Claude Code's own
|
||||
* `--worktree` layout (measured in Claude Code 2.1.296: path `<repo>/.claude/worktrees/<name>`,
|
||||
* branch `worktree-<name>`, names of letters, digits, `.`, `_`, `-`, at most 64 characters), and
|
||||
* Codeman already recognizes it (the worktree pill, `CLAUDE_WORKTREE_PATH` in session-routes.ts).
|
||||
* Names here are a strict subset of Claude's (one segment, starting with a letter or digit), so a
|
||||
* worktree made here is one `claude --worktree <name>` would also find. The main checkout must not
|
||||
* show the worktrees as untracked, so `/.claude/worktrees/` is appended to the repository's LOCAL
|
||||
* `info/exclude` (in the real common git dir, never a tracked `.gitignore`) when git does not
|
||||
* already ignore it. `/.claude/settings.local.json` gets the same treatment: Codeman installs its
|
||||
* hooks there for every claude session, and an untracked copy in a worktree makes
|
||||
* `git worktree remove` refuse forever (Claude Code itself ignores that file through the global
|
||||
* excludes file, which is not guaranteed to be set up on a given machine). The copy of Codeman's
|
||||
* agent skill (`<skills dir>/codeman/`, injected while `agentSkillEnabled` is on) is excluded for
|
||||
* the same reason, for every skills folder the registry names (`WorktreeOptions.agentSkillDirs`).
|
||||
*
|
||||
* Split like the other git modules: a PURE half (name and branch validation, `worktree list`
|
||||
* parsing, exclude text, the managed-path rule) unit-tested without git, and an IO half that
|
||||
* spawns git through an injectable runner.
|
||||
*
|
||||
* Rules the code keeps and the tests pin:
|
||||
* - git is spawned with an argv array, never a shell, with `--` before every path operand, the
|
||||
* non-interactive environment of `gitNonInteractiveEnv()`, a timeout that kills the whole
|
||||
* process group, and capped output. Read-only calls add `--no-optional-locks`.
|
||||
* - Names and branches are validated BEFORE they reach git (charset, length, then
|
||||
* `git check-ref-format --branch`, whose answer must equal the input: `@{-1}` style shorthands
|
||||
* are refused up front, since `--branch` would expand them).
|
||||
* - Only `<repo>/.claude/worktrees/<valid-name>` is ever created or removed. Every component of
|
||||
* that path is `lstat`ed (a symlinked `.claude` or `worktrees` directory, or a symlink at the
|
||||
* worktree itself, is refused) and its real path must be exactly the expected one, so a
|
||||
* repository cannot redirect a create or a remove outside itself. What is left is a same-user
|
||||
* race (an agent swapping a directory between the check and git), which is accepted: that
|
||||
* agent already runs code as this user.
|
||||
* - Removal is `git worktree remove` WITHOUT `--force` (git refuses a tree with modified or
|
||||
* untracked files, and that refusal is the answer), refused while a live session runs inside
|
||||
* the worktree, and the optional branch delete is `git branch -d` (never `-D`, so an unmerged
|
||||
* branch survives).
|
||||
* - git never runs for a case at or inside a Docker case workspace (or whose common git dir is
|
||||
* there): a container could plant a checkout filter or a hook, and `git worktree add` runs the
|
||||
* repository's post-checkout hook and filters on the HOST. On a local case that is acceptable,
|
||||
* since the user's agent runs code there anyway. A managed worktree that is, sits inside, or
|
||||
* holds a Docker case workspace is never removed either: `git worktree remove` runs a status
|
||||
* inside the tree, then deletes it under the container.
|
||||
* - A repository that merely encloses the case and is the home folder or above (a dotfiles repo
|
||||
* in `$HOME`) is not the case's repository, and a case folder below a repository's top level
|
||||
* is refused: the worktrees would not be where the case is.
|
||||
*
|
||||
* @module git-worktrees
|
||||
*/
|
||||
|
||||
import { spawn } from 'node:child_process';
|
||||
import { promises as fs } from 'node:fs';
|
||||
import { homedir } from 'node:os';
|
||||
import { join, resolve, sep } from 'node:path';
|
||||
import { gitNonInteractiveEnv, isSafeGitRef, sanitizeGitOutput } from './git-clone.js';
|
||||
import { isInsideAny, isUnrelatedAncestor } from './git-workspace-status.js';
|
||||
|
||||
// ─── Limits ──────────────────────────────────────────────────────────────────
|
||||
|
||||
/** Claude Code's own limit for a `--worktree` name (2.1.296). */
|
||||
export const MAX_WORKTREE_NAME_LENGTH = 64;
|
||||
/** Longest accepted branch name. git allows more; 200 covers every real one. */
|
||||
export const MAX_WORKTREE_BRANCH_LENGTH = 200;
|
||||
/** A read-only git call (rev-parse, worktree list, check-ignore, check-ref-format). */
|
||||
export const WORKTREE_READ_TIMEOUT_MS = 15_000;
|
||||
/**
|
||||
* `git worktree add` checks out a whole tree and runs the repository's post-checkout hook and
|
||||
* smudge filters (Git LFS downloads objects there), so it gets the longest budget.
|
||||
*/
|
||||
export const WORKTREE_ADD_TIMEOUT_MS = 180_000;
|
||||
/** `git worktree remove` runs a status of the tree first, then deletes it. */
|
||||
export const WORKTREE_REMOVE_TIMEOUT_MS = 60_000;
|
||||
/** Captured stdout. `worktree list` is one short block per worktree. */
|
||||
const MAX_STDOUT_BYTES = 1_000_000;
|
||||
/** Captured stderr tail. */
|
||||
const MAX_STDERR_BYTES = 8_192;
|
||||
/** Worktrees listed. A repository with more is unusual; the count says when it was cut. */
|
||||
export const MAX_WORKTREES_LISTED = 200;
|
||||
|
||||
/** Path segments of the managed worktrees folder, relative to the repository root. */
|
||||
export const WORKTREES_DIR_SEGMENTS = ['.claude', 'worktrees'] as const;
|
||||
/** The `info/exclude` line that hides the managed worktrees from the main checkout. */
|
||||
export const WORKTREES_EXCLUDE_LINE = '/.claude/worktrees/';
|
||||
/** The `info/exclude` line for the hooks file Codeman writes into every claude workspace. */
|
||||
export const SETTINGS_LOCAL_EXCLUDE_LINE = '/.claude/settings.local.json';
|
||||
|
||||
/** A workspace-relative skills folder (`.claude/skills`, `.agents/skills`): plain segments only. */
|
||||
const SKILLS_DIR_RE = /^[A-Za-z0-9._-]+(?:\/[A-Za-z0-9._-]+)*$/;
|
||||
|
||||
/**
|
||||
* The `info/exclude` line for the copy of Codeman's agent skill in `skillsDir` of a workspace
|
||||
* (`<skillsDir>/codeman/`, written into the folder a session runs in while `agentSkillEnabled`
|
||||
* is on), or null for a folder that is not a plain relative path.
|
||||
*/
|
||||
export function agentSkillExcludeLine(skillsDir: string): string | null {
|
||||
if (!SKILLS_DIR_RE.test(skillsDir) || skillsDir.split('/').some((s) => s === '.' || s === '..')) return null;
|
||||
return `/${skillsDir}/codeman/`;
|
||||
}
|
||||
|
||||
// ─── Pure: names ─────────────────────────────────────────────────────────────
|
||||
|
||||
const WORKTREE_NAME_RE = /^[A-Za-z0-9][A-Za-z0-9._-]*$/;
|
||||
const BRANCH_NAME_RE = /^[A-Za-z0-9][A-Za-z0-9._/-]*$/;
|
||||
|
||||
/**
|
||||
* Why `name` cannot be a worktree name, or null when it can. One path segment of letters, digits,
|
||||
* `.`, `_` and `-`, starting with a letter or digit, at most 64 characters, never `..` (the
|
||||
* session path schema refuses any path containing it), and not ending in `.` or `.lock` (git's
|
||||
* own ref rules, since the default branch is derived from the name).
|
||||
*/
|
||||
export function worktreeNameError(name: unknown): string | null {
|
||||
if (typeof name !== 'string' || name.length === 0) return 'A worktree name is required.';
|
||||
if (name.length > MAX_WORKTREE_NAME_LENGTH)
|
||||
return `A worktree name is at most ${MAX_WORKTREE_NAME_LENGTH} characters.`;
|
||||
if (!WORKTREE_NAME_RE.test(name))
|
||||
return 'A worktree name may contain only letters, digits, ".", "_" and "-", and must start with a letter or digit.';
|
||||
if (name.includes('..')) return 'A worktree name may not contain "..".';
|
||||
if (name.endsWith('.') || name.toLowerCase().endsWith('.lock'))
|
||||
return 'A worktree name may not end with "." or ".lock".';
|
||||
return null;
|
||||
}
|
||||
|
||||
/** The branch a new worktree gets when the caller names none: Claude Code's `worktree-<name>`. */
|
||||
export function defaultWorktreeBranch(name: string): string {
|
||||
return `worktree-${name}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Why `branch` cannot be a new branch name, or null when it passes the charset gate. The final
|
||||
* word is `git check-ref-format --branch` (see `createCaseWorktree`); this gate exists so nothing
|
||||
* option-shaped, `@{...}`-shaped or otherwise surprising ever reaches git at all.
|
||||
*/
|
||||
export function worktreeBranchError(branch: unknown): string | null {
|
||||
if (typeof branch !== 'string' || branch.length === 0) return 'A branch name is required.';
|
||||
if (branch.length > MAX_WORKTREE_BRANCH_LENGTH)
|
||||
return `A branch name is at most ${MAX_WORKTREE_BRANCH_LENGTH} characters.`;
|
||||
if (!BRANCH_NAME_RE.test(branch))
|
||||
return 'A branch name may contain only letters, digits, ".", "_", "-" and "/", and must start with a letter or digit.';
|
||||
if (branch.includes('..') || branch.includes('//')) return 'A branch name may not contain ".." or "//".';
|
||||
if (branch.endsWith('/') || branch.endsWith('.') || branch.toLowerCase().endsWith('.lock'))
|
||||
return 'A branch name may not end with "/", "." or ".lock".';
|
||||
if (branch.split('/').some((part) => part.startsWith('.'))) return 'No part of a branch name may start with ".".';
|
||||
return null;
|
||||
}
|
||||
|
||||
/** Why `base` cannot be a start point, or null. The same gate Clone Repo uses for a ref. */
|
||||
export function worktreeBaseError(base: unknown): string | null {
|
||||
if (typeof base !== 'string' || !isSafeGitRef(base))
|
||||
return 'The base must be a branch, tag or commit (letters, digits, ".", "_", "-", "/" and "+").';
|
||||
return null;
|
||||
}
|
||||
|
||||
// ─── Pure: paths ─────────────────────────────────────────────────────────────
|
||||
|
||||
/** `<repo>/.claude/worktrees`. */
|
||||
export function managedWorktreesDir(repoRoot: string): string {
|
||||
return join(repoRoot, ...WORKTREES_DIR_SEGMENTS);
|
||||
}
|
||||
|
||||
/** `<repo>/.claude/worktrees/<name>`. The caller validates `name` first. */
|
||||
export function managedWorktreePath(repoRoot: string, name: string): string {
|
||||
return join(managedWorktreesDir(repoRoot), name);
|
||||
}
|
||||
|
||||
/**
|
||||
* The managed worktree name of `path`, or null when it is not exactly
|
||||
* `<repoRoot>/.claude/worktrees/<valid-name>`. Lexical: both sides must already be real paths.
|
||||
*/
|
||||
export function managedWorktreeName(repoRoot: string, path: string): string | null {
|
||||
const dir = managedWorktreesDir(repoRoot) + sep;
|
||||
const clean = path.endsWith(sep) && path.length > 1 ? path.slice(0, -1) : path;
|
||||
if (!clean.startsWith(dir)) return null;
|
||||
const name = clean.slice(dir.length);
|
||||
if (!name || name.includes(sep)) return null;
|
||||
return worktreeNameError(name) === null ? name : null;
|
||||
}
|
||||
|
||||
/** True when `child` is `root` or inside it (lexical, on resolved paths). */
|
||||
export function isPathWithin(child: string, root: string): boolean {
|
||||
const c = resolve(child);
|
||||
const r = resolve(root);
|
||||
return c === r || c.startsWith(r === sep ? r : r + sep);
|
||||
}
|
||||
|
||||
/**
|
||||
* `path` as the repository spells it: a path under `alias` (the case folder as its case resolves
|
||||
* it, which can run through a symlink) moved under `root` (the repository's real top level), else
|
||||
* `path` unchanged. Lexical, so it costs no filesystem call per session. Sessions keep the
|
||||
* `workingDir` they were created with, while the worktree paths git lists are real, so without
|
||||
* this a session spelled through the case's link counted for no worktree and did not block its
|
||||
* removal.
|
||||
*/
|
||||
export function respellUnder(path: string, alias: string, root: string): string {
|
||||
const p = resolve(path);
|
||||
const a = resolve(alias);
|
||||
if (a === resolve(root) || !isPathWithin(p, a)) return p;
|
||||
return join(root, p.slice(a.length));
|
||||
}
|
||||
|
||||
// ─── Pure: `git worktree list --porcelain` ───────────────────────────────────
|
||||
|
||||
export interface ParsedWorktree {
|
||||
path: string;
|
||||
/** Commit checked out (absent for a bare entry). */
|
||||
head: string | null;
|
||||
/** Short branch name (`refs/heads/` stripped), null when detached or bare. */
|
||||
branch: string | null;
|
||||
detached: boolean;
|
||||
bare: boolean;
|
||||
locked: boolean;
|
||||
lockReason?: string;
|
||||
prunable: boolean;
|
||||
prunableReason?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Undo git's C-style path quoting (`"a\\"b\\303\\251"` → `a"bé`). Paths are printed quoted when
|
||||
* they hold a `"`, a `\` or a control character even with `core.quotePath=false`; octal escapes are
|
||||
* raw bytes, decoded as UTF-8. Text that is not quoted comes back unchanged.
|
||||
*/
|
||||
export function unquoteGitPath(text: string): string {
|
||||
if (text.length < 2 || !text.startsWith('"') || !text.endsWith('"')) return text;
|
||||
const body = text.slice(1, -1);
|
||||
const bytes: number[] = [];
|
||||
const simple: Record<string, number> = { a: 7, b: 8, f: 12, n: 10, r: 13, t: 9, v: 11, '"': 34, '\\': 92 };
|
||||
for (let i = 0; i < body.length; i++) {
|
||||
const ch = body[i];
|
||||
if (ch !== '\\') {
|
||||
for (const b of Buffer.from(ch, 'utf-8')) bytes.push(b);
|
||||
continue;
|
||||
}
|
||||
const next = body[i + 1];
|
||||
if (next !== undefined && /[0-7]/.test(next)) {
|
||||
const oct = /^[0-7]{1,3}/.exec(body.slice(i + 1))![0];
|
||||
bytes.push(parseInt(oct, 8) & 0xff);
|
||||
i += oct.length;
|
||||
} else if (next !== undefined && next in simple) {
|
||||
bytes.push(simple[next]);
|
||||
i += 1;
|
||||
} else {
|
||||
bytes.push(92);
|
||||
}
|
||||
}
|
||||
return Buffer.from(bytes).toString('utf-8');
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse `git worktree list --porcelain` (newline-terminated form: `-z` needs git 2.36, and Ubuntu
|
||||
* 22.04 ships 2.34). Records are separated by blank lines; the first is the main worktree. Paths
|
||||
* are read through `unquoteGitPath`, which together with `-c core.quotePath=false` keeps non-ASCII
|
||||
* repository paths readable.
|
||||
*/
|
||||
export function parseWorktreeList(text: string): ParsedWorktree[] {
|
||||
const out: ParsedWorktree[] = [];
|
||||
let cur: ParsedWorktree | null = null;
|
||||
const flush = () => {
|
||||
if (cur) out.push(cur);
|
||||
cur = null;
|
||||
};
|
||||
for (const raw of text.split('\n')) {
|
||||
const line = raw.replace(/\r$/, '');
|
||||
if (!line) {
|
||||
flush();
|
||||
continue;
|
||||
}
|
||||
const space = line.indexOf(' ');
|
||||
const key = space === -1 ? line : line.slice(0, space);
|
||||
const value = space === -1 ? '' : line.slice(space + 1);
|
||||
if (key === 'worktree') {
|
||||
flush();
|
||||
cur = {
|
||||
path: unquoteGitPath(value),
|
||||
head: null,
|
||||
branch: null,
|
||||
detached: false,
|
||||
bare: false,
|
||||
locked: false,
|
||||
prunable: false,
|
||||
};
|
||||
continue;
|
||||
}
|
||||
if (!cur) continue;
|
||||
const entry: ParsedWorktree = cur;
|
||||
if (key === 'HEAD') entry.head = value || null;
|
||||
else if (key === 'branch') entry.branch = value.startsWith('refs/heads/') ? value.slice(11) : value || null;
|
||||
else if (key === 'detached') entry.detached = true;
|
||||
else if (key === 'bare') entry.bare = true;
|
||||
else if (key === 'locked') {
|
||||
entry.locked = true;
|
||||
if (value) entry.lockReason = value;
|
||||
} else if (key === 'prunable') {
|
||||
entry.prunable = true;
|
||||
if (value) entry.prunableReason = value;
|
||||
}
|
||||
}
|
||||
flush();
|
||||
return out;
|
||||
}
|
||||
|
||||
// ─── Pure: exclude file ──────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* What to append to an `info/exclude` holding `existing` so that every line of `lines` is in it:
|
||||
* the text ('' when nothing is missing) and the lines it adds. A file that does not end in a
|
||||
* newline gets one first, so an appended pattern never fuses with the user's last line.
|
||||
*/
|
||||
export function excludeAppendText(existing: string, lines: readonly string[]): { text: string; added: string[] } {
|
||||
const present = new Set(existing.split(/\r?\n/).map((l) => l.trim()));
|
||||
const added = lines.filter((l) => !present.has(l));
|
||||
if (added.length === 0) return { text: '', added };
|
||||
const lead = existing.length > 0 && !existing.endsWith('\n') ? '\n' : '';
|
||||
return {
|
||||
text: `${lead}# Added by Codeman: git worktrees and the files Codeman writes into each workspace stay local\n${added.join('\n')}\n`,
|
||||
added,
|
||||
};
|
||||
}
|
||||
|
||||
// ─── Pure: git's answers ─────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* The part of git's stderr a user should see: its `fatal:`/`error:` lines (with any `hint:` lines
|
||||
* dropped, they repeat what the UI says), sanitized and capped. Shown through `textContent` only.
|
||||
*/
|
||||
export function gitRefusalText(stderr: string): string {
|
||||
const clean = sanitizeGitOutput(stderr, 2000);
|
||||
const lines = clean
|
||||
.split('\n')
|
||||
.map((l) => l.trim())
|
||||
.filter((l) => l && !/^hint:/i.test(l));
|
||||
const diagnostic = lines.filter((l) => /^(fatal|error|warning):/i.test(l));
|
||||
const text = (diagnostic.length ? diagnostic : lines).join('\n');
|
||||
return text.length > 1000 ? `${text.slice(0, 1000)}…` : text;
|
||||
}
|
||||
|
||||
// ─── IO: git runner ──────────────────────────────────────────────────────────
|
||||
|
||||
export interface WorktreeGitResult {
|
||||
code: number | null;
|
||||
stdout: string;
|
||||
stderr: string;
|
||||
timedOut: boolean;
|
||||
spawnError?: string;
|
||||
}
|
||||
|
||||
/** Runs `git <args>` in `cwd`; never throws. Injected so tests can refuse to run git at all. */
|
||||
export type WorktreeGitRunner = (
|
||||
cwd: string,
|
||||
args: string[],
|
||||
opts?: { timeoutMs?: number }
|
||||
) => Promise<WorktreeGitResult>;
|
||||
|
||||
/**
|
||||
* Global options for a call that only reads: no optional index refresh, no filesystem monitor,
|
||||
* no path quoting of non-ASCII names. Must precede the subcommand.
|
||||
*/
|
||||
const READ_ONLY_GIT_OPTIONS = ['--no-optional-locks', '-c', 'core.fsmonitor=false', '-c', 'core.quotePath=false'];
|
||||
|
||||
/**
|
||||
* The production runner: argv only (no shell), the non-interactive environment, captured output
|
||||
* caps, and a timeout that signals the whole process group (`detached: true`), because
|
||||
* `git worktree add` fans out into checkout children, hooks and filters that a signal to the
|
||||
* parent alone would leave running.
|
||||
*/
|
||||
export const runWorktreeGit: WorktreeGitRunner = (cwd, args, opts) =>
|
||||
new Promise<WorktreeGitResult>((resolveRun) => {
|
||||
let child: ReturnType<typeof spawn>;
|
||||
try {
|
||||
child = spawn('git', args, {
|
||||
cwd,
|
||||
env: { ...gitNonInteractiveEnv(), GIT_OPTIONAL_LOCKS: '0' },
|
||||
stdio: ['ignore', 'pipe', 'pipe'],
|
||||
detached: true,
|
||||
});
|
||||
} catch (err) {
|
||||
resolveRun({ code: null, stdout: '', stderr: '', timedOut: false, spawnError: String(err) });
|
||||
return;
|
||||
}
|
||||
let stdout = '';
|
||||
let stdoutBytes = 0;
|
||||
let stderr = '';
|
||||
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 timer = setTimeout(() => {
|
||||
timedOut = true;
|
||||
killTree('SIGTERM');
|
||||
killTimer = setTimeout(() => killTree('SIGKILL'), 3_000);
|
||||
}, opts?.timeoutMs ?? WORKTREE_READ_TIMEOUT_MS);
|
||||
child.stdout?.on('data', (chunk: Buffer) => {
|
||||
stdoutBytes += chunk.length;
|
||||
if (stdoutBytes <= MAX_STDOUT_BYTES) stdout += chunk.toString('utf-8');
|
||||
});
|
||||
child.stderr?.on('data', (chunk: Buffer) => {
|
||||
stderr += chunk.toString('utf-8');
|
||||
if (stderr.length > MAX_STDERR_BYTES * 2) stderr = stderr.slice(-MAX_STDERR_BYTES);
|
||||
});
|
||||
const finish = (result: WorktreeGitResult) => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
clearTimeout(timer);
|
||||
if (killTimer) clearTimeout(killTimer);
|
||||
resolveRun(result);
|
||||
};
|
||||
child.on('error', (err) => finish({ code: null, stdout, stderr, timedOut, spawnError: String(err) }));
|
||||
child.on('close', (code) => finish({ code, stdout, stderr, timedOut }));
|
||||
});
|
||||
|
||||
// ─── IO: results ─────────────────────────────────────────────────────────────
|
||||
|
||||
/** Failure codes, named after the API error codes the routes map them to. */
|
||||
export type WorktreeFailureCode = 'INVALID_INPUT' | 'NOT_FOUND' | 'ALREADY_EXISTS' | 'CONFLICT' | 'OPERATION_FAILED';
|
||||
|
||||
export interface WorktreeFailure {
|
||||
ok: false;
|
||||
code: WorktreeFailureCode;
|
||||
message: string;
|
||||
/** git's own words, when git refused (sanitized; render as text). */
|
||||
gitMessage?: string;
|
||||
}
|
||||
|
||||
const fail = (code: WorktreeFailureCode, message: string, gitMessage?: string): WorktreeFailure => ({
|
||||
ok: false,
|
||||
code,
|
||||
message,
|
||||
...(gitMessage ? { gitMessage } : {}),
|
||||
});
|
||||
|
||||
function gitFailure(run: WorktreeGitResult, what: string): WorktreeFailure {
|
||||
if (run.spawnError && /enoent/i.test(run.spawnError))
|
||||
return fail('OPERATION_FAILED', 'git is not installed on this machine (or not on the server’s PATH).');
|
||||
if (run.timedOut) return fail('OPERATION_FAILED', `git timed out while ${what}.`);
|
||||
const text = gitRefusalText(run.stderr);
|
||||
return fail('OPERATION_FAILED', text ? `git failed while ${what}: ${text}` : `git failed while ${what}.`, text);
|
||||
}
|
||||
|
||||
// ─── IO: the case's repository ───────────────────────────────────────────────
|
||||
|
||||
export interface CaseRepository {
|
||||
/** The repository's top level, which is the case folder (real path). */
|
||||
repoRoot: string;
|
||||
/** The real common git dir (`.git` of the main checkout), where `info/exclude` lives. */
|
||||
commonDir: string;
|
||||
}
|
||||
|
||||
export interface WorktreeOptions {
|
||||
git?: WorktreeGitRunner;
|
||||
/** Host paths of every Docker case workspace: git never runs at or inside one. */
|
||||
dockerWorkspaces?: string[];
|
||||
/** Overrides `os.homedir()` for the unrelated-ancestor rule (tests). */
|
||||
home?: string;
|
||||
/**
|
||||
* Every workspace skills folder a session can get Codeman's agent skill in (the registry's
|
||||
* `capabilities.agentSkillDir` values, passed in as data by the caller). A create excludes
|
||||
* `<dir>/codeman/` for each, like the hooks file: a session in a worktree gets an untracked
|
||||
* copy there, and `git worktree remove` would refuse that worktree forever.
|
||||
*/
|
||||
agentSkillDirs?: readonly string[];
|
||||
}
|
||||
|
||||
const realOr = async (p: string): Promise<string> => {
|
||||
try {
|
||||
return await fs.realpath(p);
|
||||
} catch {
|
||||
return resolve(p);
|
||||
}
|
||||
};
|
||||
|
||||
const DOCKER_REFUSAL =
|
||||
'Worktrees are not available for a folder inside a Docker case workspace: a container can write there, and git would run its hooks and filters on this machine.';
|
||||
|
||||
const DOCKER_WORKTREE_REFUSAL =
|
||||
'This worktree is, or holds, a Docker case workspace: git would run inside a folder a container can write, and removing it would delete that workspace. Remove the Docker case first.';
|
||||
|
||||
/**
|
||||
* The repository a case folder IS, or why it is not one Codeman makes worktrees for. Never runs
|
||||
* git when the folder (or the repository's common git dir) is at or inside a Docker case workspace.
|
||||
*/
|
||||
export async function resolveCaseRepository(
|
||||
casePath: string,
|
||||
opts: WorktreeOptions = {}
|
||||
): Promise<{ ok: true; repo: CaseRepository } | WorktreeFailure> {
|
||||
const git = opts.git ?? runWorktreeGit;
|
||||
const dockerRoots = await Promise.all((opts.dockerWorkspaces ?? []).filter(Boolean).map(realOr));
|
||||
if (await isInsideAny(casePath, dockerRoots)) return fail('INVALID_INPUT', DOCKER_REFUSAL);
|
||||
|
||||
const run = await git(casePath, [...READ_ONLY_GIT_OPTIONS, 'rev-parse', '--show-toplevel', '--git-common-dir'], {
|
||||
timeoutMs: WORKTREE_READ_TIMEOUT_MS,
|
||||
});
|
||||
if (run.code !== 0 || run.spawnError || run.timedOut) {
|
||||
if (/must be run in a work tree/i.test(run.stderr)) {
|
||||
return fail('INVALID_INPUT', 'This case folder is a bare git repository, which has no work tree to branch from.');
|
||||
}
|
||||
if (/not a git repository/i.test(run.stderr)) {
|
||||
return fail('INVALID_INPUT', 'This case folder is not a git repository.');
|
||||
}
|
||||
return gitFailure(run, 'reading the repository');
|
||||
}
|
||||
const [top, common] = run.stdout.split('\n').map((l) => l.trim());
|
||||
if (!top || !common) return fail('OPERATION_FAILED', 'git did not report the repository root.');
|
||||
const [repoRoot, caseReal] = await Promise.all([realOr(top), realOr(casePath)]);
|
||||
const commonDir = await realOr(resolve(casePath, common));
|
||||
if (await isInsideAny(commonDir, dockerRoots)) return fail('INVALID_INPUT', DOCKER_REFUSAL);
|
||||
if (repoRoot !== caseReal) {
|
||||
if (await isUnrelatedAncestor(repoRoot, caseReal, opts.home ?? homedir())) {
|
||||
return fail('INVALID_INPUT', 'This case folder is not a git repository.');
|
||||
}
|
||||
return fail(
|
||||
'INVALID_INPUT',
|
||||
`Worktrees need the case folder to be the top of a git repository; this folder is inside ${repoRoot}.`
|
||||
);
|
||||
}
|
||||
return { ok: true, repo: { repoRoot, commonDir } };
|
||||
}
|
||||
|
||||
// ─── IO: listing ─────────────────────────────────────────────────────────────
|
||||
|
||||
export interface CaseWorktree extends ParsedWorktree {
|
||||
/** The repository's main checkout (git lists it first). */
|
||||
main: boolean;
|
||||
/** The case folder itself (the main checkout, or a linked worktree linked as the case). */
|
||||
isCase: boolean;
|
||||
/** Under `<repo>/.claude/worktrees/<valid-name>`: Codeman may start sessions in it and remove it. */
|
||||
managed: boolean;
|
||||
/** The managed name, when `managed`. */
|
||||
name?: string;
|
||||
}
|
||||
|
||||
export async function listCaseWorktrees(
|
||||
repo: CaseRepository,
|
||||
opts: WorktreeOptions = {}
|
||||
): Promise<{ ok: true; worktrees: CaseWorktree[]; truncated: boolean } | WorktreeFailure> {
|
||||
const git = opts.git ?? runWorktreeGit;
|
||||
const run = await git(repo.repoRoot, [...READ_ONLY_GIT_OPTIONS, 'worktree', 'list', '--porcelain'], {
|
||||
timeoutMs: WORKTREE_READ_TIMEOUT_MS,
|
||||
});
|
||||
if (run.code !== 0 || run.spawnError || run.timedOut) return gitFailure(run, 'listing worktrees');
|
||||
const parsed = parseWorktreeList(run.stdout);
|
||||
const truncated = parsed.length > MAX_WORKTREES_LISTED;
|
||||
const worktrees = parsed.slice(0, MAX_WORKTREES_LISTED).map((wt, index): CaseWorktree => {
|
||||
const name = index === 0 ? null : managedWorktreeName(repo.repoRoot, wt.path);
|
||||
return {
|
||||
...wt,
|
||||
main: index === 0,
|
||||
isCase: wt.path === repo.repoRoot,
|
||||
managed: name !== null,
|
||||
...(name ? { name } : {}),
|
||||
};
|
||||
});
|
||||
return { ok: true, worktrees, truncated };
|
||||
}
|
||||
|
||||
// ─── IO: path guards ─────────────────────────────────────────────────────────
|
||||
|
||||
type DirState = 'dir' | 'absent' | 'symlink' | 'other';
|
||||
|
||||
async function lstatState(path: string): Promise<DirState> {
|
||||
try {
|
||||
const st = await fs.lstat(path);
|
||||
if (st.isSymbolicLink()) return 'symlink';
|
||||
return st.isDirectory() ? 'dir' : 'other';
|
||||
} catch (err) {
|
||||
const code = (err as NodeJS.ErrnoException).code;
|
||||
if (code === 'ENOENT') return 'absent';
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Make sure `<repo>/.claude` and `<repo>/.claude/worktrees` are real directories (created when
|
||||
* absent, refused when a symlink or a file) whose real path is exactly the lexical one.
|
||||
*/
|
||||
async function prepareWorktreesDir(repoRoot: string): Promise<{ ok: true; dir: string } | WorktreeFailure> {
|
||||
let cur = repoRoot;
|
||||
for (const segment of WORKTREES_DIR_SEGMENTS) {
|
||||
cur = join(cur, segment);
|
||||
const state = await lstatState(cur);
|
||||
if (state === 'symlink') {
|
||||
return fail(
|
||||
'INVALID_INPUT',
|
||||
`${cur} is a symlink. Codeman will not create worktrees through a link that could point outside the repository.`
|
||||
);
|
||||
}
|
||||
if (state === 'other') return fail('INVALID_INPUT', `${cur} exists and is not a directory.`);
|
||||
if (state === 'absent') {
|
||||
try {
|
||||
await fs.mkdir(cur);
|
||||
} catch (err) {
|
||||
if ((err as NodeJS.ErrnoException).code !== 'EEXIST') {
|
||||
return fail('OPERATION_FAILED', `Could not create ${cur}: ${(err as Error).message}`);
|
||||
}
|
||||
if ((await lstatState(cur)) !== 'dir') return fail('INVALID_INPUT', `${cur} is not a plain directory.`);
|
||||
}
|
||||
}
|
||||
}
|
||||
if ((await realOr(cur)) !== cur) {
|
||||
return fail('INVALID_INPUT', `${cur} does not resolve to itself; refusing to create a worktree there.`);
|
||||
}
|
||||
return { ok: true, dir: cur };
|
||||
}
|
||||
|
||||
/**
|
||||
* The managed worktree `name` of `repo` as it is on disk now: every component a real directory
|
||||
* (no symlinks), the real path the expected one, and git listing it as a worktree of this
|
||||
* repository. Used by removal and by starting a session in it.
|
||||
*
|
||||
* `allowMissing` (removal only, never a session start) also accepts a worktree whose folder is
|
||||
* GONE (deleted by hand, or by an agent) while git still lists it, as prunable, at exactly the
|
||||
* managed path: no component on the way may be a symlink or a file, and `missing` says so.
|
||||
* Without it such a worktree was a dead end: it could not be removed, and its name could not be
|
||||
* used again (the branch exists, then git refuses "a missing but already registered worktree").
|
||||
*/
|
||||
export async function resolveManagedWorktree(
|
||||
repo: CaseRepository,
|
||||
name: string,
|
||||
opts: WorktreeOptions & { allowMissing?: boolean } = {}
|
||||
): Promise<{ ok: true; path: string; worktree: CaseWorktree; missing: boolean } | WorktreeFailure> {
|
||||
const nameError = worktreeNameError(name);
|
||||
if (nameError) return fail('INVALID_INPUT', nameError);
|
||||
const target = managedWorktreePath(repo.repoRoot, name);
|
||||
let cur = repo.repoRoot;
|
||||
let missing = false;
|
||||
for (const segment of [...WORKTREES_DIR_SEGMENTS, name]) {
|
||||
cur = join(cur, segment);
|
||||
const state = await lstatState(cur);
|
||||
if (state === 'absent') {
|
||||
if (!opts.allowMissing) return fail('NOT_FOUND', `No worktree named "${name}" in this case.`);
|
||||
// Nothing below an absent folder exists, so nothing below it can redirect anything.
|
||||
missing = true;
|
||||
break;
|
||||
}
|
||||
if (state === 'symlink') {
|
||||
return fail('INVALID_INPUT', `${cur} is a symlink. Codeman only manages worktrees that are real directories.`);
|
||||
}
|
||||
if (state === 'other') return fail('INVALID_INPUT', `${cur} is not a directory.`);
|
||||
}
|
||||
if (!missing && (await realOr(target)) !== target) {
|
||||
return fail('INVALID_INPUT', `${target} does not resolve to itself; refusing to use it.`);
|
||||
}
|
||||
const listed = await listCaseWorktrees(repo, opts);
|
||||
if (!listed.ok) return listed;
|
||||
for (const wt of listed.worktrees) {
|
||||
if (wt.main) continue;
|
||||
if (missing) {
|
||||
// Lexical only: the folder is gone, so there is no real path to compare. git never calls a
|
||||
// LOCKED worktree prunable (prune skips it), so a locked one is taken too, and the caller's
|
||||
// lock check then refuses it with git's lock reason.
|
||||
if (wt.path === target && (wt.prunable || wt.locked)) {
|
||||
return { ok: true, path: target, worktree: { ...wt, managed: true, name }, missing: true };
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if (wt.path === target || (await realOr(wt.path)) === target) {
|
||||
return { ok: true, path: target, worktree: { ...wt, managed: true, name }, missing: false };
|
||||
}
|
||||
}
|
||||
if (missing) return fail('NOT_FOUND', `No worktree named "${name}" in this case.`);
|
||||
return fail(
|
||||
'NOT_FOUND',
|
||||
`${target} is not a registered git worktree of this repository (a leftover folder, or one git no longer knows about).`
|
||||
);
|
||||
}
|
||||
|
||||
// ─── IO: excludes ────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Append each of `lines` that git does not already ignore to `<commonDir>/info/exclude`. Returns
|
||||
* the lines written. A symlinked `info` dir or `exclude` file is left alone (reported, never
|
||||
* written through).
|
||||
*/
|
||||
async function ensureExcluded(
|
||||
repo: CaseRepository,
|
||||
probes: Array<{ line: string; probe: string }>,
|
||||
git: WorktreeGitRunner
|
||||
): Promise<{ added: string[]; warning?: string }> {
|
||||
const needed: string[] = [];
|
||||
for (const { line, probe } of probes) {
|
||||
const run = await git(repo.repoRoot, [...READ_ONLY_GIT_OPTIONS, 'check-ignore', '-q', '--', probe], {
|
||||
timeoutMs: WORKTREE_READ_TIMEOUT_MS,
|
||||
});
|
||||
// 0: ignored. 1: not ignored. Anything else (128) is a git error: assume not ignored, since an
|
||||
// extra exclude line is harmless and a missing one leaves the worktrees untracked.
|
||||
if (run.code !== 0) needed.push(line);
|
||||
}
|
||||
if (needed.length === 0) return { added: [] };
|
||||
const infoDir = join(repo.commonDir, 'info');
|
||||
const excludePath = join(infoDir, 'exclude');
|
||||
try {
|
||||
const infoState = await lstatState(infoDir);
|
||||
if (infoState === 'absent') await fs.mkdir(infoDir);
|
||||
else if (infoState !== 'dir')
|
||||
return { added: [], warning: `${infoDir} is not a plain directory; nothing was excluded.` };
|
||||
const fileState = await lstatState(excludePath);
|
||||
if (fileState === 'symlink' || fileState === 'dir') {
|
||||
return { added: [], warning: `${excludePath} is not a plain file; nothing was excluded.` };
|
||||
}
|
||||
const existing = fileState === 'absent' ? '' : await fs.readFile(excludePath, 'utf-8');
|
||||
const { text, added } = excludeAppendText(existing, needed);
|
||||
if (text) await fs.appendFile(excludePath, text, { mode: 0o644 });
|
||||
return { added };
|
||||
} catch (err) {
|
||||
return { added: [], warning: `Could not update ${excludePath}: ${(err as Error).message}` };
|
||||
}
|
||||
}
|
||||
|
||||
// ─── IO: one operation per repository at a time ──────────────────────────────
|
||||
|
||||
const repoLocks = new Map<string, Promise<unknown>>();
|
||||
|
||||
/** Serialize create/remove per repository: two creates must not both pass the "absent" check. */
|
||||
async function withRepoLock<T>(repoRoot: string, fn: () => Promise<T>): Promise<T> {
|
||||
const prev = repoLocks.get(repoRoot) ?? Promise.resolve();
|
||||
const next = prev.then(fn, fn);
|
||||
const tail = next.catch(() => undefined);
|
||||
repoLocks.set(repoRoot, tail);
|
||||
try {
|
||||
return await next;
|
||||
} finally {
|
||||
if (repoLocks.get(repoRoot) === tail) repoLocks.delete(repoRoot);
|
||||
}
|
||||
}
|
||||
|
||||
// ─── IO: create ──────────────────────────────────────────────────────────────
|
||||
|
||||
export interface CreateWorktreeRequest {
|
||||
name: string;
|
||||
/** New branch; default `worktree-<name>`. */
|
||||
branch?: string;
|
||||
/** Start point; default `HEAD` (the case's current commit). */
|
||||
base?: string;
|
||||
}
|
||||
|
||||
export interface CreatedWorktree {
|
||||
name: string;
|
||||
path: string;
|
||||
branch: string;
|
||||
base: string;
|
||||
/** The commit the new branch starts at. */
|
||||
head: string;
|
||||
/** Lines appended to the repository's `info/exclude` (empty when git already ignored them). */
|
||||
excludeAdded: string[];
|
||||
warnings: string[];
|
||||
}
|
||||
|
||||
export async function createCaseWorktree(
|
||||
repo: CaseRepository,
|
||||
req: CreateWorktreeRequest,
|
||||
opts: WorktreeOptions = {}
|
||||
): Promise<{ ok: true; worktree: CreatedWorktree } | WorktreeFailure> {
|
||||
const git = opts.git ?? runWorktreeGit;
|
||||
const nameError = worktreeNameError(req.name);
|
||||
if (nameError) return fail('INVALID_INPUT', nameError);
|
||||
const branch = req.branch || defaultWorktreeBranch(req.name);
|
||||
const branchError = worktreeBranchError(branch);
|
||||
if (branchError) return fail('INVALID_INPUT', branchError);
|
||||
const base = req.base || 'HEAD';
|
||||
const baseError = worktreeBaseError(base);
|
||||
if (baseError) return fail('INVALID_INPUT', baseError);
|
||||
|
||||
return withRepoLock(repo.repoRoot, async () => {
|
||||
// A name already taken says so in those words, before the branch check would report the
|
||||
// derived branch instead. Rechecked after the folders are prepared, below.
|
||||
try {
|
||||
if ((await lstatState(managedWorktreePath(repo.repoRoot, req.name))) !== 'absent') {
|
||||
return fail('ALREADY_EXISTS', `A worktree named "${req.name}" already exists in this case.`);
|
||||
}
|
||||
} catch {
|
||||
/* unreadable: the check after the folders are prepared reports it */
|
||||
}
|
||||
// git's own word on the branch name. The output must be the input: `--branch` expands
|
||||
// shorthands, and anything it rewrote is not the name the user typed.
|
||||
const refFormat = await git(repo.repoRoot, [...READ_ONLY_GIT_OPTIONS, 'check-ref-format', '--branch', branch], {
|
||||
timeoutMs: WORKTREE_READ_TIMEOUT_MS,
|
||||
});
|
||||
if (refFormat.code !== 0 || refFormat.stdout.trim() !== branch) {
|
||||
return fail('INVALID_INPUT', `"${branch}" is not a valid branch name.`, gitRefusalText(refFormat.stderr));
|
||||
}
|
||||
const existingBranch = await git(
|
||||
repo.repoRoot,
|
||||
[...READ_ONLY_GIT_OPTIONS, 'show-ref', '--verify', '--quiet', `refs/heads/${branch}`],
|
||||
{ timeoutMs: WORKTREE_READ_TIMEOUT_MS }
|
||||
);
|
||||
if (existingBranch.code === 0) {
|
||||
return fail('ALREADY_EXISTS', `A branch named "${branch}" already exists. Pick another worktree name or branch.`);
|
||||
}
|
||||
const resolved = await git(
|
||||
repo.repoRoot,
|
||||
// No `--end-of-options` (git 2.30+): the base already passed the ref gate, which refuses a
|
||||
// leading `-`, so it cannot be read as an option.
|
||||
[...READ_ONLY_GIT_OPTIONS, 'rev-parse', '--verify', '--quiet', `${base}^{commit}`],
|
||||
{ timeoutMs: WORKTREE_READ_TIMEOUT_MS }
|
||||
);
|
||||
const head = resolved.stdout.trim();
|
||||
if (resolved.code !== 0 || !/^[0-9a-f]{40,64}$/.test(head)) {
|
||||
return fail('INVALID_INPUT', `"${base}" is not a commit in this repository (an empty repository has none yet).`);
|
||||
}
|
||||
|
||||
const prepared = await prepareWorktreesDir(repo.repoRoot);
|
||||
if (!prepared.ok) return prepared;
|
||||
const target = join(prepared.dir, req.name);
|
||||
try {
|
||||
if ((await lstatState(target)) !== 'absent') {
|
||||
return fail('ALREADY_EXISTS', `${target} already exists. Pick another worktree name.`);
|
||||
}
|
||||
} catch (err) {
|
||||
return fail('OPERATION_FAILED', `Could not inspect ${target}: ${(err as Error).message}`);
|
||||
}
|
||||
|
||||
const skillProbes: Array<{ line: string; probe: string }> = [];
|
||||
for (const dir of new Set(opts.agentSkillDirs ?? [])) {
|
||||
const line = agentSkillExcludeLine(dir);
|
||||
if (line) skillProbes.push({ line, probe: `${dir}/codeman` });
|
||||
}
|
||||
const excluded = await ensureExcluded(
|
||||
repo,
|
||||
[
|
||||
{ line: WORKTREES_EXCLUDE_LINE, probe: `${WORKTREES_DIR_SEGMENTS.join('/')}/${req.name}` },
|
||||
{ line: SETTINGS_LOCAL_EXCLUDE_LINE, probe: '.claude/settings.local.json' },
|
||||
...skillProbes,
|
||||
],
|
||||
git
|
||||
);
|
||||
|
||||
// The resolved commit, not the ref text, after `--`: nothing here can be read as an option,
|
||||
// and `--no-track` keeps a worktree started from `origin/main` from tracking (and pulling) main.
|
||||
const add = await git(repo.repoRoot, ['worktree', 'add', '--no-track', '-b', branch, '--', target, head], {
|
||||
timeoutMs: WORKTREE_ADD_TIMEOUT_MS,
|
||||
});
|
||||
if (add.code !== 0 || add.spawnError || add.timedOut) {
|
||||
const text = gitRefusalText(add.stderr);
|
||||
if (/already exists/i.test(text)) return fail('ALREADY_EXISTS', `git refused: ${text}`, text);
|
||||
return gitFailure(add, 'creating the worktree');
|
||||
}
|
||||
return {
|
||||
ok: true,
|
||||
worktree: {
|
||||
name: req.name,
|
||||
path: target,
|
||||
branch,
|
||||
base,
|
||||
head,
|
||||
excludeAdded: excluded.added,
|
||||
warnings: excluded.warning ? [excluded.warning] : [],
|
||||
},
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
// ─── IO: remove ──────────────────────────────────────────────────────────────
|
||||
|
||||
export interface RemoveWorktreeRequest {
|
||||
name: string;
|
||||
/** Also `git branch -d` the worktree's branch (never `-D`). */
|
||||
deleteBranch?: boolean;
|
||||
/** True when a live session runs at or inside `path`: the removal is refused. */
|
||||
isInUse: (path: string) => boolean | Promise<boolean>;
|
||||
}
|
||||
|
||||
export interface RemovedWorktree {
|
||||
name: string;
|
||||
path: string;
|
||||
branch: string | null;
|
||||
branchDeleted: boolean;
|
||||
/** git's refusal to delete the branch (typically "not fully merged"); the worktree is gone regardless. */
|
||||
branchError?: string;
|
||||
}
|
||||
|
||||
export async function removeCaseWorktree(
|
||||
repo: CaseRepository,
|
||||
req: RemoveWorktreeRequest,
|
||||
opts: WorktreeOptions = {}
|
||||
): Promise<{ ok: true; removed: RemovedWorktree } | WorktreeFailure> {
|
||||
const git = opts.git ?? runWorktreeGit;
|
||||
return withRepoLock(repo.repoRoot, async () => {
|
||||
// A worktree whose folder is gone is removable too: `git worktree remove` then only drops
|
||||
// git's record of it (measured on git 2.43: exit 0, nothing on disk touched).
|
||||
const found = await resolveManagedWorktree(repo, req.name, { ...opts, allowMissing: true });
|
||||
if (!found.ok) return found;
|
||||
// The case passed the Docker rule, but the worktree folder itself can be a Docker case's
|
||||
// workspace (a Docker case linked to it), or hold one. `git worktree remove` runs a status
|
||||
// INSIDE the tree first (container-written files, checkout filters on this machine), then
|
||||
// deletes the whole tree, ignored files included, under a container that still mounts it.
|
||||
// A folder that is gone has neither problem: git only drops its record.
|
||||
if (!found.missing) {
|
||||
const dockerRoots = await Promise.all((opts.dockerWorkspaces ?? []).filter(Boolean).map(realOr));
|
||||
if (dockerRoots.some((root) => isPathWithin(found.path, root) || isPathWithin(root, found.path))) {
|
||||
return fail('INVALID_INPUT', DOCKER_WORKTREE_REFUSAL);
|
||||
}
|
||||
}
|
||||
if (await req.isInUse(found.path)) {
|
||||
return fail(
|
||||
'CONFLICT',
|
||||
`A live session is still running in worktree "${req.name}". Close it first, then remove the worktree.`
|
||||
);
|
||||
}
|
||||
if (found.worktree.locked) {
|
||||
return fail(
|
||||
'CONFLICT',
|
||||
`Worktree "${req.name}" is locked${found.worktree.lockReason ? ` (${found.worktree.lockReason})` : ''}. Unlock it with git first.`
|
||||
);
|
||||
}
|
||||
const removed = await git(repo.repoRoot, ['worktree', 'remove', '--', found.path], {
|
||||
timeoutMs: WORKTREE_REMOVE_TIMEOUT_MS,
|
||||
});
|
||||
if (removed.code !== 0 || removed.spawnError || removed.timedOut) {
|
||||
if (removed.timedOut || removed.spawnError) return gitFailure(removed, 'removing the worktree');
|
||||
const text = gitRefusalText(removed.stderr);
|
||||
// git's own refusal (modified or untracked files, a lock): Codeman never forces, so this is
|
||||
// the answer, in git's words.
|
||||
return fail(
|
||||
'CONFLICT',
|
||||
text ? `git refused to remove the worktree: ${text}` : 'git refused to remove the worktree.',
|
||||
text
|
||||
);
|
||||
}
|
||||
const branch = found.worktree.branch;
|
||||
let branchDeleted = false;
|
||||
let branchError: string | undefined;
|
||||
if (req.deleteBranch && branch) {
|
||||
const del = await git(repo.repoRoot, ['branch', '-d', '--', branch], { timeoutMs: WORKTREE_READ_TIMEOUT_MS });
|
||||
if (del.code === 0) branchDeleted = true;
|
||||
else branchError = gitRefusalText(del.stderr) || 'git refused to delete the branch.';
|
||||
}
|
||||
return {
|
||||
ok: true,
|
||||
removed: {
|
||||
name: req.name,
|
||||
path: found.path,
|
||||
branch,
|
||||
branchDeleted,
|
||||
...(branchError ? { branchError } : {}),
|
||||
},
|
||||
};
|
||||
});
|
||||
}
|
||||
+138
-20
@@ -36,10 +36,13 @@ import { join, dirname } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
import type { HookEventType } from './types.js';
|
||||
import type { AgentSkillDir, CliCapabilities } from './config/cli-registry/types.js';
|
||||
import { HOOK_TIMEOUT_SECONDS } from './config/auth-config.js';
|
||||
import { dataPath } from './config/instance.js';
|
||||
import { readJsonConfig, SETTINGS_PATH } from './web/route-helpers.js';
|
||||
import { isNearStalledPath, probePath } from './utils/index.js';
|
||||
import { DEFAULT_KEEP_WARM_MAX_HOURS, type KeepWarmSettings } from './prompt-cache-keepwarm.js';
|
||||
import { gitFetchSettingsFrom, type GitFetchSettings } from './git-fetch.js';
|
||||
|
||||
/**
|
||||
* Existence check for a WRITER. Unlike the bounded read-side probe (`probePath`),
|
||||
@@ -1130,6 +1133,88 @@ export async function readPlanUsageTelemetryEnabled(): Promise<boolean> {
|
||||
return settings.showPlanUsageLimits !== false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether every Claude session is launched with `--name` set to its tab name
|
||||
* (the `pinCliSessionName` setting). Read FRESH from settings.json at every
|
||||
* claude create and respawn, like {@link readPlanUsageTelemetryEnabled}, so a
|
||||
* toggle applies from the next spawn with no server restart. An ABSENT key
|
||||
* means OFF: only a name the user chose is pinned, and Claude titles the
|
||||
* placeholder- and auto-named conversations itself (see `Session.cliPinnedName`).
|
||||
*/
|
||||
export async function readPinCliSessionNameEnabled(): Promise<boolean> {
|
||||
const settings = await readJsonConfig<Record<string, unknown>>(SETTINGS_PATH, 'settings.json', {});
|
||||
return settings.pinCliSessionName === true;
|
||||
}
|
||||
|
||||
/**
|
||||
* The prompt-cache keep-warm's app-wide default (`promptCacheKeepWarm`, absent = OFF) and its
|
||||
* cap (`promptCacheKeepWarmMaxHours`, hours after the last real prompt). Read FRESH like
|
||||
* {@link readPinCliSessionNameEnabled} at every claude create and respawn, which decides the
|
||||
* launch's `idleCompaction` opt-out.
|
||||
*/
|
||||
export async function readKeepWarmSettings(): Promise<KeepWarmSettings> {
|
||||
return keepWarmSettingsFrom(await readJsonConfig<Record<string, unknown>>(SETTINGS_PATH, 'settings.json', {}));
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 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;
|
||||
try {
|
||||
text = await readFile(SETTINGS_PATH, 'utf-8');
|
||||
} catch (err) {
|
||||
if ((err as NodeJS.ErrnoException).code === 'ENOENT') return keepWarmSettingsFrom({});
|
||||
throw err;
|
||||
}
|
||||
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 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;
|
||||
try {
|
||||
text = await readFile(SETTINGS_PATH, 'utf-8');
|
||||
} catch (err) {
|
||||
if ((err as NodeJS.ErrnoException).code === 'ENOENT') return gitFetchSettingsFrom({});
|
||||
throw err;
|
||||
}
|
||||
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 {
|
||||
const hours = settings?.promptCacheKeepWarmMaxHours;
|
||||
return {
|
||||
keepWarmDefault: settings?.promptCacheKeepWarm === true,
|
||||
maxHours: typeof hours === 'number' && Number.isFinite(hours) && hours > 0 ? hours : DEFAULT_KEEP_WARM_MAX_HOURS,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the statusLine command to pass as an EPHEMERAL `claude --settings`
|
||||
* CLI flag for this one process (see buildSpawnCommandFromRegistry in
|
||||
@@ -1228,8 +1313,8 @@ interface AgentSkillFile {
|
||||
*/
|
||||
async function readAgentSkillSource(): Promise<AgentSkillFile[]> {
|
||||
const src = agentSkillSourceDir();
|
||||
const skill = await readFile(join(src, 'SKILL.md'), 'utf-8');
|
||||
const files: AgentSkillFile[] = [{ relPath: 'SKILL.md', content: `${skill.trimEnd()}\n\n${AGENT_SKILL_MARKER}\n` }];
|
||||
const skillMd = await readFile(join(src, 'SKILL.md'), 'utf-8');
|
||||
const files: AgentSkillFile[] = [{ relPath: 'SKILL.md', content: `${skillMd.trimEnd()}\n\n${AGENT_SKILL_MARKER}\n` }];
|
||||
let referenceNames: string[] = [];
|
||||
try {
|
||||
referenceNames = (await readdir(join(src, 'reference'))).filter((name) => name.endsWith('.md')).sort();
|
||||
@@ -1285,7 +1370,8 @@ export type AgentSkillApplyResult =
|
||||
|
||||
/**
|
||||
* Install or refresh the Codeman agent skill into `skillDir` (a `.../codeman`
|
||||
* directory, e.g. `<case>/.claude/skills/codeman` or `~/.claude/skills/codeman`).
|
||||
* directory, e.g. `<case>/.claude/skills/codeman`, `~/.claude/skills/codeman` or
|
||||
* `<case>/.agents/skills/codeman`).
|
||||
*
|
||||
* Refuses two shapes rather than writing through them:
|
||||
* - a SYMLINK at the skill dir or its `skills/` parent: this repo's own dogfooding
|
||||
@@ -1336,16 +1422,20 @@ export async function installAgentSkillInto(skillDir: string): Promise<AgentSkil
|
||||
}
|
||||
|
||||
/**
|
||||
* Seed a claude session's agent preamble file (`$XDG_CACHE_HOME/codeman-agent-<id>.sh`,
|
||||
* Seed a session's agent preamble file (`$XDG_CACHE_HOME/codeman-agent-<id>.sh`,
|
||||
* default `~/.cache/`) from the packaged `skills/codeman/preamble.sh`, so the agent
|
||||
* skill's §0 bootstrap collapses to a two-line loader instead of a ~150-line block the
|
||||
* model has to type out (measured live: that paste alone cost a spawn run ~47 s of
|
||||
* generation time). The path formula must match the skill's
|
||||
* `${XDG_CACHE_HOME:-$HOME/.cache}` exactly; sessions inherit the server's env, so
|
||||
* reading the server's own XDG_CACHE_HOME keeps the two in agreement (`||` mirrors the
|
||||
* shell's `:-`, treating empty as unset). Callers gate to LOCAL claude sessions (a
|
||||
* remote or in-container HOME is not this filesystem) and treat it as best-effort: the
|
||||
* skill's §0 fallback block self-heals a missing or stale file.
|
||||
* shell's `:-`, treating empty as unset). Callers gate to LOCAL sessions whose CLI gets
|
||||
* the skill (`agentSkillDir()` non-null, any CLI: the preamble is bash + curl + jq, nothing
|
||||
* claude-specific; a remote or in-container HOME is not this filesystem) and treat it as
|
||||
* best-effort: the skill's §0 fallback block self-heals a missing or stale file. Codex
|
||||
* caveat: codex 0.162 runs tool commands in a shared app-server daemon whose
|
||||
* `CODEMAN_SESSION_ID` is the starting session's, so its loader looks for that session's
|
||||
* file and its self id is wrong; the skill tells the agent how to notice.
|
||||
*/
|
||||
export async function seedAgentSessionPreamble(sessionId: string): Promise<void> {
|
||||
const content = await readFile(join(agentSkillSourceDir(), 'preamble.sh'), 'utf-8');
|
||||
@@ -1478,31 +1568,59 @@ export async function removeAgentSkillFrom(skillDir: string): Promise<AgentSkill
|
||||
}
|
||||
await rmdir(join(skillDir, 'reference')).catch(() => {}); // fails when non-empty, fine
|
||||
await rmdir(skillDir).catch(() => {});
|
||||
await rmdir(dirname(skillDir)).catch(() => {}); // prune `.claude/skills` if now empty
|
||||
await rmdir(dirname(skillDir)).catch(() => {}); // prune `.claude/skills` (or the CLI's own dir) if now empty
|
||||
return 'removed';
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Add or remove the Codeman agent skill in `<case>/.claude/skills/codeman`,
|
||||
* mirroring `applyStatusLineConfig`'s shape. Gated by the synced `agentSkillEnabled`
|
||||
* app setting (default OFF); callers gate on Claude mode, since the skill is discovered
|
||||
* via `.claude/skills/`, which only Claude Code reads.
|
||||
* Claude Code's project skills directory, and the meaning `agentSkillInjection: true`
|
||||
* had before `capabilities.agentSkillDir` existed.
|
||||
*/
|
||||
export const CLAUDE_AGENT_SKILL_DIR: AgentSkillDir = '.claude/skills';
|
||||
|
||||
/**
|
||||
* The workspace-relative skills directory this CLI scans, i.e. where its sessions get
|
||||
* the skill, or `null` when they get none. Pure registry data, so no call site
|
||||
* branches on a CLI id.
|
||||
*/
|
||||
export function agentSkillDir(
|
||||
capabilities: Pick<CliCapabilities, 'agentSkillInjection' | 'agentSkillDir'> | undefined
|
||||
): AgentSkillDir | null {
|
||||
if (!capabilities?.agentSkillInjection) return null;
|
||||
return capabilities.agentSkillDir ?? CLAUDE_AGENT_SKILL_DIR;
|
||||
}
|
||||
|
||||
/** `<casePath>/<dir>/codeman`, the directory the skill's SKILL.md lives in. */
|
||||
export function agentSkillDirFor(casePath: string, dir: AgentSkillDir): string {
|
||||
return join(casePath, dir, 'codeman');
|
||||
}
|
||||
|
||||
/**
|
||||
* Add or remove the Codeman agent skill in `<case>/<dir>/codeman`, where `dir` is the
|
||||
* skills directory the session's CLI scans (default: Claude Code's `.claude/skills`;
|
||||
* callers resolve it with `agentSkillDir()`), mirroring `applyStatusLineConfig`'s
|
||||
* shape. Gated by the synced `agentSkillEnabled` app setting (default OFF).
|
||||
*
|
||||
* Call-site policy is ADD-ONLY on session create (callers pass `enabled: true` or
|
||||
* skip the call), for the statusLine reason: sessions in a repo share one `.claude/`
|
||||
* dir, so a single create while the setting is off must not yank the skill out from
|
||||
* skip the call), for the statusLine reason: sessions in a repo share one workspace,
|
||||
* so a single create while the setting is off must not yank the skill out from
|
||||
* under other live sessions.
|
||||
*
|
||||
* ⚠️ Consequence: turning `agentSkillEnabled` OFF sweeps nothing. There is deliberately
|
||||
* no server-side toggle-off sweep (it would have to walk every case, including ones
|
||||
* with live sessions, and would hit exactly the shared-`.claude/` hazard above), so
|
||||
* with live sessions, and would hit exactly the shared-workspace hazard above), so
|
||||
* already-injected copies stay on disk until removed per case with
|
||||
* `codeman skill uninstall --case <name>`. The `enabled: false` branch here backs that
|
||||
* CLI and the tests; it has no server call site. Keep the README's Agent Skill note in
|
||||
* sync if this ever changes.
|
||||
* `codeman skill uninstall --case <name>`, which removes the Codeman-managed copy from
|
||||
* every skills directory the registry could have written. The `enabled: false` branch
|
||||
* here backs that CLI and the tests; it has no server call site. Keep the README's
|
||||
* Agent Skill note in sync if this ever changes.
|
||||
*/
|
||||
export async function applyAgentSkill(casePath: string, enabled: boolean): Promise<AgentSkillApplyResult> {
|
||||
const skillDir = join(casePath, '.claude', 'skills', 'codeman');
|
||||
export async function applyAgentSkill(
|
||||
casePath: string,
|
||||
enabled: boolean,
|
||||
dir: AgentSkillDir = CLAUDE_AGENT_SKILL_DIR
|
||||
): Promise<AgentSkillApplyResult> {
|
||||
const skillDir = agentSkillDirFor(casePath, dir);
|
||||
return enabled ? installAgentSkillInto(skillDir) : removeAgentSkillFrom(skillDir);
|
||||
}
|
||||
@@ -0,0 +1,510 @@
|
||||
/**
|
||||
* @fileoverview Optional host readings for the header stats: GPU load and memory, and the live CPU and GPU clock
|
||||
* speeds.
|
||||
*
|
||||
* Every reader is opt-in per request (the route only calls the ones the browser asks for, and the browser only
|
||||
* asks when the matching setting is on), answers `undefined` when the host has no such sensor, and never
|
||||
* throws. Reads are cached for a couple of seconds so several open tabs cost one read.
|
||||
*
|
||||
* Sources, with no extra dependency:
|
||||
* - GPU: `nvidia-smi` (NVIDIA), else the amdgpu sysfs files (`gpu_busy_percent`, `mem_info_vram_*`,
|
||||
* `pp_dpm_sclk`), else, inside WSL, the Windows performance counters through `typeperf.exe` (any vendor,
|
||||
* Intel included: load and memory in use, but Windows publishes no GPU clock for them).
|
||||
* - CPU clock: inside WSL the Windows `Processor Frequency` counter (WSL's own cpuinfo is a fixed number),
|
||||
* else the mean of `scaling_cur_freq` across cores, else the `cpu MHz` lines of /proc/cpuinfo.
|
||||
*
|
||||
* The parsers are pure and exported so the tests need no hardware.
|
||||
*
|
||||
* @module host-sensors
|
||||
*/
|
||||
|
||||
import { execFile, spawn, type ChildProcess } from 'node:child_process';
|
||||
import { existsSync } from 'node:fs';
|
||||
import { readFile, readdir } from 'node:fs/promises';
|
||||
import { join } from 'node:path';
|
||||
|
||||
export interface GpuReading {
|
||||
/** Core utilisation, 0 to 100. */
|
||||
percent: number;
|
||||
/** Graphics memory in use as a share of what is available, 0 to 100, when that is known. */
|
||||
memPercent?: number;
|
||||
memUsedMB?: number;
|
||||
memTotalMB?: number;
|
||||
/** Current graphics clock in MHz, when known. */
|
||||
mhz?: number;
|
||||
}
|
||||
|
||||
export interface HostSensors {
|
||||
gpu?: GpuReading;
|
||||
cpuMhz?: number;
|
||||
}
|
||||
|
||||
export interface SensorRequest {
|
||||
/** GPU load, graphics memory and clock. */
|
||||
gpu?: boolean;
|
||||
cpuMhz?: boolean;
|
||||
}
|
||||
|
||||
const CACHE_MS = 2000;
|
||||
/** A counter sample older than this (a few sampling intervals) is no longer a live reading. */
|
||||
const FRESH_MS = 10000;
|
||||
/** After a reader fails, leave it alone this long: nvidia-smi absent or timing out, PowerShell failing. */
|
||||
const READER_BACKOFF_MS = 60000;
|
||||
/** The GPU counters stay in the sampler this long after the last GPU read that needed them. */
|
||||
const GPU_COUNTERS_HOLD_MS = 120000;
|
||||
const EXEC_TIMEOUT_MS = 1500;
|
||||
const SYS_DRM = '/sys/class/drm';
|
||||
const SYS_CPU = '/sys/devices/system/cpu';
|
||||
const TYPEPERF = '/mnt/c/Windows/System32/typeperf.exe';
|
||||
const POWERSHELL = '/mnt/c/Windows/System32/WindowsPowerShell/v1.0/powershell.exe';
|
||||
const MB = 1048576;
|
||||
|
||||
const clampPercent = (n: number) => Math.min(100, Math.max(0, Math.round(n)));
|
||||
const finite = (n: number) => (Number.isFinite(n) ? n : undefined);
|
||||
|
||||
/** Parse `nvidia-smi --query-gpu=utilization.gpu,memory.used,memory.total,clocks.gr --format=csv,noheader,nounits`. */
|
||||
export function parseNvidiaSmi(output: string): GpuReading | undefined {
|
||||
const line = output.split('\n').find((l) => l.trim());
|
||||
if (!line) return undefined;
|
||||
const [util, used, total, clock] = line.split(',').map((s) => finite(Number(s.trim())));
|
||||
if (util === undefined) return undefined;
|
||||
const reading: GpuReading = { percent: clampPercent(util) };
|
||||
if (used !== undefined && total !== undefined && total > 0) {
|
||||
reading.memUsedMB = Math.round(used);
|
||||
reading.memTotalMB = Math.round(total);
|
||||
reading.memPercent = clampPercent((used / total) * 100);
|
||||
}
|
||||
if (clock !== undefined && clock > 0) reading.mhz = Math.round(clock);
|
||||
return reading;
|
||||
}
|
||||
|
||||
/** The active line of an amdgpu `pp_dpm_sclk` table (`1: 1800Mhz *`). */
|
||||
export function parseDpmClock(text: string): number | undefined {
|
||||
const active = text.split('\n').find((l) => l.trim().endsWith('*'));
|
||||
const mhz = active ? /(\d+)\s*mhz/i.exec(active)?.[1] : undefined;
|
||||
return mhz ? Number(mhz) : undefined;
|
||||
}
|
||||
|
||||
/** Mean of per-core `scaling_cur_freq` values (kHz) as MHz. */
|
||||
export function meanKhzAsMhz(values: number[]): number | undefined {
|
||||
const good = values.filter((v) => Number.isFinite(v) && v > 0);
|
||||
if (!good.length) return undefined;
|
||||
return Math.round(good.reduce((a, b) => a + b, 0) / good.length / 1000);
|
||||
}
|
||||
|
||||
/** Mean of the `cpu MHz : 3412.5` lines of /proc/cpuinfo. */
|
||||
export function parseCpuInfoMhz(text: string): number | undefined {
|
||||
const values = [...text.matchAll(/^cpu MHz\s*:\s*([\d.]+)/gim)].map((m) => Number(m[1]));
|
||||
return meanKhzAsMhz(values.map((v) => v * 1000));
|
||||
}
|
||||
|
||||
export interface WindowsCounters {
|
||||
gpu?: { percent: number; memUsedMB: number; dedicated: boolean };
|
||||
cpuMhz?: number;
|
||||
}
|
||||
|
||||
const ENGINE =
|
||||
/GPU Engine\(.*?_luid_(0x[0-9a-f]+_0x[0-9a-f]+)_phys_\d+_eng_\d+_engtype_([^)]*)\)\\Utilization Percentage/i;
|
||||
const ADAPTER_MEMORY = /GPU Adapter Memory\(luid_(0x[0-9a-f]+_0x[0-9a-f]+)_phys_\d+\)\\(Shared|Dedicated) Usage/i;
|
||||
const CPU_FREQUENCY = /Processor Information\(_Total\)\\Processor Frequency/i;
|
||||
|
||||
/**
|
||||
* Parse one `typeperf.exe -sc 1 ...` sample: a quoted CSV header of counter paths and one row of values.
|
||||
* Task Manager's GPU figure is its busiest engine, so per adapter the load is the largest per-engine-type sum
|
||||
* (3D, copy, video decode...), capped at 100. The adapter that holds the most graphics memory is the real one
|
||||
* (Windows also lists software adapters that hold a few kilobytes). A `Shared Usage` only means an integrated
|
||||
* GPU, whose memory is carved out of system RAM.
|
||||
*/
|
||||
export function parseTypeperf(output: string): WindowsCounters {
|
||||
const rows = output
|
||||
.split(/\r?\n/)
|
||||
.filter((l) => l.startsWith('"'))
|
||||
.map((l) => l.slice(1, l.lastIndexOf('"')).split('","'));
|
||||
if (rows.length < 2) return {};
|
||||
const [names, values] = rows;
|
||||
const engines = new Map<string, Map<string, number>>();
|
||||
const memory = new Map<string, { shared: number; dedicated: number }>();
|
||||
let cpuMhz: number | undefined;
|
||||
names.forEach((name, i) => {
|
||||
const value = finite(Number(values[i]));
|
||||
if (value === undefined || values[i].trim() === '') return;
|
||||
const engine = ENGINE.exec(name);
|
||||
if (engine) {
|
||||
const byType = engines.get(engine[1].toLowerCase()) ?? new Map<string, number>();
|
||||
byType.set(engine[2], (byType.get(engine[2]) ?? 0) + value);
|
||||
engines.set(engine[1].toLowerCase(), byType);
|
||||
return;
|
||||
}
|
||||
const mem = ADAPTER_MEMORY.exec(name);
|
||||
if (mem) {
|
||||
const luid = mem[1].toLowerCase();
|
||||
const entry = memory.get(luid) ?? { shared: 0, dedicated: 0 };
|
||||
if (mem[2].toLowerCase() === 'shared') entry.shared = value;
|
||||
else entry.dedicated = value;
|
||||
memory.set(luid, entry);
|
||||
return;
|
||||
}
|
||||
if (CPU_FREQUENCY.test(name) && value > 0) cpuMhz = Math.round(value);
|
||||
});
|
||||
const result: WindowsCounters = {};
|
||||
if (cpuMhz) result.cpuMhz = cpuMhz;
|
||||
const luids = new Set([...engines.keys(), ...memory.keys()]);
|
||||
let best: { luid: string; bytes: number; load: number } | undefined;
|
||||
for (const luid of luids) {
|
||||
const m = memory.get(luid);
|
||||
const bytes = (m?.shared ?? 0) + (m?.dedicated ?? 0);
|
||||
const load = Math.max(0, ...(engines.get(luid)?.values() ?? []));
|
||||
if (!best || bytes > best.bytes || (bytes === best.bytes && load > best.load)) best = { luid, bytes, load };
|
||||
}
|
||||
if (best && (engines.size > 0 || memory.size > 0)) {
|
||||
result.gpu = {
|
||||
percent: clampPercent(best.load),
|
||||
memUsedMB: Math.round(best.bytes / MB),
|
||||
dedicated: (memory.get(best.luid)?.dedicated ?? 0) > 0,
|
||||
};
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Run a reader, answering undefined on failure or once `timeout` has passed. execFile's own `timeout` only
|
||||
* sends the child a signal, and its callback waits for the child to exit: an nvidia-smi stuck on a hung
|
||||
* driver (uninterruptible sleep) never does, and every stats request would wait on it with CPU and MEM. So
|
||||
* the deadline here answers by itself; the child is left to the signal.
|
||||
*/
|
||||
const run = (cmd: string, args: string[], timeout = EXEC_TIMEOUT_MS) =>
|
||||
new Promise<string | undefined>((resolve) => {
|
||||
const deadline = setTimeout(() => resolve(undefined), timeout);
|
||||
deadline.unref();
|
||||
execFile(cmd, args, { timeout, windowsHide: true, maxBuffer: 4 * MB }, (err, stdout) => {
|
||||
clearTimeout(deadline);
|
||||
resolve(err ? undefined : stdout);
|
||||
});
|
||||
});
|
||||
|
||||
const readText = (path: string) =>
|
||||
readFile(path, 'utf8').then(
|
||||
(t) => t.trim(),
|
||||
() => undefined
|
||||
);
|
||||
const readNumber = async (path: string) => {
|
||||
const text = await readText(path);
|
||||
return text === undefined ? undefined : finite(Number(text));
|
||||
};
|
||||
|
||||
/** Linux running under WSL: Windows programs can be started, and the GPU belongs to Windows. */
|
||||
let wsl: boolean | undefined;
|
||||
function inWsl(): boolean {
|
||||
wsl ??=
|
||||
process.platform === 'linux' &&
|
||||
(Boolean(process.env.WSL_INTEROP) ||
|
||||
existsSync('/proc/sys/fs/binfmt_misc/WSLInterop') ||
|
||||
existsSync('/proc/sys/fs/binfmt_misc/WSLInterop-late'));
|
||||
return wsl;
|
||||
}
|
||||
|
||||
let windowsRamMB: number | undefined;
|
||||
let ramProbe: Promise<void> | undefined;
|
||||
let ramRetryAt = 0;
|
||||
|
||||
/**
|
||||
* Windows' installed RAM in MB once PowerShell has answered, else undefined. The probe (about a second, 8 s
|
||||
* timeout) is started here but never awaited: the stats route answers CPU and MEM only after the sensors, so
|
||||
* the GPU reading carries the memory in use alone until the answer lands, and the total from then on. A
|
||||
* failure is not retried on every GPU poll: it waits READER_BACKOFF_MS first.
|
||||
*/
|
||||
function windowsRamMBIfKnown(): number | undefined {
|
||||
if (windowsRamMB !== undefined || ramProbe || Date.now() < ramRetryAt) return windowsRamMB;
|
||||
ramProbe = run(
|
||||
POWERSHELL,
|
||||
['-NoProfile', '-NonInteractive', '-Command', '(Get-CimInstance Win32_ComputerSystem).TotalPhysicalMemory'],
|
||||
8000
|
||||
).then((out) => {
|
||||
const bytes = out ? finite(Number(out.trim())) : undefined;
|
||||
if (bytes && bytes > 0) windowsRamMB = Math.round(bytes / MB);
|
||||
else ramRetryAt = Date.now() + READER_BACKOFF_MS;
|
||||
ramProbe = undefined;
|
||||
});
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/** What the sampler needs from a child process; `ChildProcess` fits, and tests pass a fake. */
|
||||
export type SamplerChild = Pick<ChildProcess, 'stdout' | 'kill' | 'once'>;
|
||||
|
||||
export interface CounterStreamOptions {
|
||||
start: (counters: string[]) => SamplerChild;
|
||||
now?: () => number;
|
||||
/**
|
||||
* How long a read waits for a sample when it has none fresh (0, the default, never waits: the read
|
||||
* answers undefined and the next poll shows the reading). The stats route awaits the sensors before it
|
||||
* answers CPU and MEM, so a cold sampler must not hold it up.
|
||||
*/
|
||||
firstSampleMs?: number;
|
||||
/** A sampler older than this is replaced, so counters of processes that started since are picked up. */
|
||||
recycleMs?: number;
|
||||
/** A sampler nobody has read for this long is stopped. */
|
||||
idleMs?: number;
|
||||
/** After the sampler fails to start or exits at once, do not try again for this long. */
|
||||
backoffMs?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* One long-running `typeperf.exe -si 2` instead of a process per read: every `typeperf` sample takes over a
|
||||
* second (rate counters need two collections), which would keep one running back to back. The newest row is kept
|
||||
* and handed out; the process is replaced every `recycleMs` (its wildcard counters are expanded once, at
|
||||
* start) and stopped after `idleMs` without a read, so nothing runs while the settings are off.
|
||||
*/
|
||||
export function createCounterStream(options: CounterStreamOptions) {
|
||||
const now = options.now ?? Date.now;
|
||||
const firstSampleMs = options.firstSampleMs ?? 0;
|
||||
const recycleMs = options.recycleMs ?? 30000;
|
||||
const idleMs = options.idleMs ?? 30000;
|
||||
const backoffMs = options.backoffMs ?? 60000;
|
||||
let child: SamplerChild | undefined;
|
||||
let key = '';
|
||||
let startedAt = 0;
|
||||
let lastRead = 0;
|
||||
let blockedUntil = 0;
|
||||
let latest: { at: number; value: WindowsCounters } | undefined;
|
||||
let waiting: Array<() => void> = [];
|
||||
|
||||
const stop = () => {
|
||||
const old = child;
|
||||
child = undefined;
|
||||
if (old) {
|
||||
try {
|
||||
old.kill();
|
||||
} catch {
|
||||
/* already gone */
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const begin = (counters: string[]) => {
|
||||
stop();
|
||||
const nextKey = counters.join('\n');
|
||||
// A sample of other counters is no answer; one from an older sampler of the same counters still is.
|
||||
if (nextKey !== key) latest = undefined;
|
||||
key = nextKey;
|
||||
startedAt = now();
|
||||
// The header row and the partial line belong to this process alone: a late row from a replaced sampler
|
||||
// must never become the next one's header.
|
||||
let header: string | undefined;
|
||||
let buffer = '';
|
||||
let sampled = false;
|
||||
let started: SamplerChild;
|
||||
try {
|
||||
started = options.start(counters);
|
||||
} catch {
|
||||
blockedUntil = now() + backoffMs;
|
||||
return;
|
||||
}
|
||||
child = started;
|
||||
started.stdout?.on('data', (chunk: Buffer | string) => {
|
||||
if (child !== started) return;
|
||||
buffer += chunk.toString();
|
||||
let at: number;
|
||||
while ((at = buffer.indexOf('\n')) >= 0) {
|
||||
const line = buffer.slice(0, at).replace(/\r$/, '');
|
||||
buffer = buffer.slice(at + 1);
|
||||
if (!line.startsWith('"')) continue;
|
||||
if (!header) {
|
||||
header = line;
|
||||
continue;
|
||||
}
|
||||
sampled = true;
|
||||
latest = { at: now(), value: parseTypeperf(`${header}\n${line}`) };
|
||||
const release = waiting;
|
||||
waiting = [];
|
||||
release.forEach((fn) => fn());
|
||||
}
|
||||
});
|
||||
const ended = () => {
|
||||
if (child !== started) return;
|
||||
child = undefined;
|
||||
// It ended without a sample (typeperf missing, counters unknown): leave it alone for a while.
|
||||
if (!sampled) blockedUntil = now() + backoffMs;
|
||||
const release = waiting;
|
||||
waiting = [];
|
||||
release.forEach((fn) => fn());
|
||||
};
|
||||
started.once('exit', ended);
|
||||
started.once('error', ended);
|
||||
};
|
||||
|
||||
return {
|
||||
/** The newest fresh sample for these counters, or undefined (a cold sampler is not waited for unless `firstSampleMs` says so). */
|
||||
async read(counters: string[]): Promise<WindowsCounters | undefined> {
|
||||
lastRead = now();
|
||||
if (now() < blockedUntil) return undefined;
|
||||
const wanted = counters.join('\n');
|
||||
if (!child || key !== wanted || now() - startedAt > recycleMs) begin(counters);
|
||||
if (!latest || key !== wanted || now() - latest.at >= FRESH_MS) {
|
||||
if (!child) return undefined;
|
||||
if (firstSampleMs > 0) {
|
||||
await new Promise<void>((resolve) => {
|
||||
waiting.push(resolve);
|
||||
setTimeout(resolve, firstSampleMs).unref();
|
||||
});
|
||||
}
|
||||
}
|
||||
return latest && now() - latest.at < FRESH_MS ? latest.value : undefined;
|
||||
},
|
||||
/** Stop the sampler when nobody has read it lately. */
|
||||
sweep() {
|
||||
if (child && now() - lastRead > idleMs) {
|
||||
stop();
|
||||
latest = undefined;
|
||||
}
|
||||
},
|
||||
stop,
|
||||
};
|
||||
}
|
||||
|
||||
const GPU_COUNTERS = [
|
||||
'\\GPU Engine(*)\\Utilization Percentage',
|
||||
'\\GPU Adapter Memory(*)\\Shared Usage',
|
||||
'\\GPU Adapter Memory(*)\\Dedicated Usage',
|
||||
];
|
||||
const CPU_COUNTERS = ['\\Processor Information(_Total)\\Processor Frequency'];
|
||||
|
||||
/**
|
||||
* The counters one sampler reads. They do not depend on which reading a browser asked for (those are per
|
||||
* device, so two browsers can differ): the CPU clock is always sampled, and the GPU counters are added on a
|
||||
* machine with no native GPU reader (and dropped again once nothing has needed them for a while). A request then only picks its fields from the sample.
|
||||
*/
|
||||
export function windowsCounterList(needGpu: boolean): string[] {
|
||||
return needGpu ? [...GPU_COUNTERS, ...CPU_COUNTERS] : [...CPU_COUNTERS];
|
||||
}
|
||||
|
||||
export function createWindowsSource(
|
||||
stream: Pick<ReturnType<typeof createCounterStream>, 'read' | 'stop'>,
|
||||
now: () => number = Date.now
|
||||
) {
|
||||
let gpuUntil = 0;
|
||||
return {
|
||||
/**
|
||||
* A GPU reading is wanted and no native reader answered: sample the GPU counters too, for
|
||||
* GPU_COUNTERS_HOLD_MS after the last such read. A single failed nvidia-smi therefore costs a sampler
|
||||
* restart, not a GPU sampler for the life of the server.
|
||||
*/
|
||||
needGpuCounters() {
|
||||
gpuUntil = now() + GPU_COUNTERS_HOLD_MS;
|
||||
},
|
||||
read: () => stream.read(windowsCounterList(now() < gpuUntil)),
|
||||
stop: () => stream.stop(),
|
||||
};
|
||||
}
|
||||
|
||||
const windowsCounters = createCounterStream({
|
||||
// Spawn the Windows program directly: if it is missing, the error lands in the stream's back-off.
|
||||
start: (counters) =>
|
||||
spawn(TYPEPERF, ['-si', '2', ...counters], { stdio: ['ignore', 'pipe', 'ignore'], windowsHide: true }),
|
||||
});
|
||||
const windowsSource = createWindowsSource(windowsCounters);
|
||||
let sweepTimer: ReturnType<typeof setInterval> | undefined;
|
||||
|
||||
/** Stop the background sampler and its idle sweep (the server calls this on shutdown). */
|
||||
export function stopHostSensors(): void {
|
||||
if (sweepTimer) clearInterval(sweepTimer);
|
||||
sweepTimer = undefined;
|
||||
windowsSource.stop();
|
||||
}
|
||||
|
||||
async function readAmdGpu(): Promise<GpuReading | undefined> {
|
||||
const cards = (await readdir(SYS_DRM).catch(() => [] as string[])).filter((n) => /^card\d+$/.test(n)).sort();
|
||||
for (const card of cards) {
|
||||
const dev = join(SYS_DRM, card, 'device');
|
||||
const busy = await readNumber(join(dev, 'gpu_busy_percent'));
|
||||
if (busy === undefined) continue;
|
||||
const reading: GpuReading = { percent: clampPercent(busy) };
|
||||
const used = await readNumber(join(dev, 'mem_info_vram_used'));
|
||||
const total = await readNumber(join(dev, 'mem_info_vram_total'));
|
||||
if (used !== undefined && total !== undefined && total > 0) {
|
||||
reading.memUsedMB = Math.round(used / MB);
|
||||
reading.memTotalMB = Math.round(total / MB);
|
||||
reading.memPercent = clampPercent((used / total) * 100);
|
||||
}
|
||||
const sclk = await readText(join(dev, 'pp_dpm_sclk'));
|
||||
const mhz = sclk ? parseDpmClock(sclk) : undefined;
|
||||
if (mhz) reading.mhz = mhz;
|
||||
return reading;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/** nvidia-smi is not run again before this time: it failed or timed out, and waits READER_BACKOFF_MS. */
|
||||
let nvidiaSkipUntil = 0;
|
||||
|
||||
/** NVIDIA, then AMD. A host without a working nvidia-smi is not asked again on every poll. */
|
||||
async function readNativeGpu(): Promise<GpuReading | undefined> {
|
||||
if (Date.now() >= nvidiaSkipUntil) {
|
||||
const out = await run('nvidia-smi', [
|
||||
'--query-gpu=utilization.gpu,memory.used,memory.total,clocks.gr',
|
||||
'--format=csv,noheader,nounits',
|
||||
]);
|
||||
const reading = out ? parseNvidiaSmi(out) : undefined;
|
||||
if (reading) return reading;
|
||||
nvidiaSkipUntil = Date.now() + READER_BACKOFF_MS;
|
||||
}
|
||||
return readAmdGpu();
|
||||
}
|
||||
|
||||
/** A GPU reading from the Windows counters (WSL only): load and memory in use, but no clock. */
|
||||
function gpuFromWindows(windows: WindowsCounters | undefined): GpuReading | undefined {
|
||||
if (!windows?.gpu) return undefined;
|
||||
const { percent, memUsedMB, dedicated } = windows.gpu;
|
||||
const reading: GpuReading = { percent, memUsedMB };
|
||||
// An integrated GPU draws its memory from system RAM, and Windows lets it use up to half of it. Until
|
||||
// Windows has said how much RAM it has, the reading is the memory in use alone (the header shows no ring).
|
||||
const ram = dedicated ? undefined : windowsRamMBIfKnown();
|
||||
if (ram) {
|
||||
reading.memTotalMB = Math.round(ram / 2);
|
||||
reading.memPercent = clampPercent((memUsedMB / reading.memTotalMB) * 100);
|
||||
}
|
||||
return reading;
|
||||
}
|
||||
|
||||
async function readCpuMhz(windows: WindowsCounters | undefined): Promise<number | undefined> {
|
||||
// Under WSL the Linux side only has a fixed figure, so until Windows answers there is no reading.
|
||||
if (windows) return windows.cpuMhz;
|
||||
const cores = (await readdir(SYS_CPU).catch(() => [] as string[])).filter((n) => /^cpu\d+$/.test(n));
|
||||
const khz = await Promise.all(cores.map((c) => readNumber(join(SYS_CPU, c, 'cpufreq', 'scaling_cur_freq'))));
|
||||
const fromSysfs = meanKhzAsMhz(khz.filter((v): v is number => v !== undefined));
|
||||
if (fromSysfs) return fromSysfs;
|
||||
const info = await readText('/proc/cpuinfo');
|
||||
return info ? parseCpuInfoMhz(info) : undefined;
|
||||
}
|
||||
|
||||
const cache = new Map<string, { at: number; value: HostSensors }>();
|
||||
const inFlight = new Map<string, Promise<HostSensors>>();
|
||||
|
||||
/** Read only what was asked for. Missing sensors are simply absent from the result. */
|
||||
export async function readHostSensors(request: SensorRequest): Promise<HostSensors> {
|
||||
const key = `${request.gpu ? 'g' : ''}${request.cpuMhz ? 'c' : ''}`;
|
||||
if (!key) return {};
|
||||
sweepTimer ??= setInterval(() => windowsCounters.sweep(), 10000).unref();
|
||||
const hit = cache.get(key);
|
||||
if (hit && Date.now() - hit.at < CACHE_MS) return hit.value;
|
||||
const running = inFlight.get(key);
|
||||
if (running) return running;
|
||||
const promise = (async () => {
|
||||
// Native GPU readers first: a machine with a working nvidia-smi never starts the GPU counters.
|
||||
const nativeGpu = request.gpu ? await readNativeGpu() : undefined;
|
||||
const wsl = inWsl();
|
||||
const windowsGpu = wsl && request.gpu && !nativeGpu;
|
||||
if (windowsGpu) windowsSource.needGpuCounters();
|
||||
const windows = wsl && (request.cpuMhz || windowsGpu) ? await windowsSource.read() : undefined;
|
||||
const [gpu, cpuMhz] = await Promise.all([
|
||||
request.gpu ? (nativeGpu ?? gpuFromWindows(windows)) : undefined,
|
||||
request.cpuMhz ? readCpuMhz(wsl ? (windows ?? {}) : undefined) : undefined,
|
||||
]);
|
||||
const value: HostSensors = {};
|
||||
if (gpu) value.gpu = gpu;
|
||||
if (cpuMhz) value.cpuMhz = cpuMhz;
|
||||
cache.set(key, { at: Date.now(), value });
|
||||
return value;
|
||||
})().finally(() => inFlight.delete(key));
|
||||
inFlight.set(key, promise);
|
||||
return promise;
|
||||
}
|
||||
@@ -0,0 +1,122 @@
|
||||
/**
|
||||
* @fileoverview Capability tokens for rendering a local HTML file.
|
||||
*
|
||||
* `file-raw` never serves HTML as HTML: a page rendered from Codeman's own origin
|
||||
* could read the Codeman document and drive the agent-spawning API. So an HTML
|
||||
* file is rendered from `/html-view/:cap/*` instead, with a `Content-Security-
|
||||
* Policy: sandbox` header that puts the document in an OPAQUE origin no matter how
|
||||
* it is opened (iframe or top-level tab). That opaque origin is also why the route
|
||||
* cannot authenticate on the session cookie: its subresource requests are
|
||||
* cross-site, so the `SameSite=lax` cookie is never attached (the same reason the
|
||||
* web-tab proxy uses capabilities, see webview-capabilities.ts).
|
||||
*
|
||||
* A capability is minted by an authenticated `POST /api/sessions/:id/html-view` and
|
||||
* grants exactly one thing: GET access to the files in ONE directory (the HTML
|
||||
* file's own), so a page's relative `style.css` / `chart.js` / images resolve.
|
||||
*
|
||||
* - 192 bits of `randomBytes` entropy, base64url.
|
||||
* - Held in memory only. A restart invalidates every outstanding capability.
|
||||
* - Rolling TTL, revoked on logout, admin logout and user deletion (`revokeOwner`).
|
||||
*/
|
||||
|
||||
import { randomBytes } from 'node:crypto';
|
||||
import { StaleExpirationMap } from './utils/index.js';
|
||||
import { MAX_WEBVIEW_CAPABILITIES, WEBVIEW_CAPABILITY_TTL_MS } from './config/webview-limits.js';
|
||||
|
||||
/** URL prefix the rendered files are served under. */
|
||||
export const HTML_VIEW_PREFIX = '/html-view';
|
||||
|
||||
export interface HtmlViewCapabilityRecord {
|
||||
/** Realpath of the directory the capability serves. */
|
||||
rootDir: string;
|
||||
/** Username that minted it (multi-user); undefined in single-user mode. */
|
||||
owner?: string;
|
||||
/**
|
||||
* Whether the directory lies outside the minting session's workspace (admitted
|
||||
* under the attachment guard). Recorded so the serving route can refuse it the
|
||||
* moment workspace confinement is switched on, as the attachment routes do.
|
||||
*/
|
||||
outsideWorkspace: boolean;
|
||||
createdAt: number;
|
||||
}
|
||||
|
||||
export class HtmlViewCapabilityStore {
|
||||
private readonly capabilities: StaleExpirationMap<string, HtmlViewCapabilityRecord>;
|
||||
/** Reverse index so re-opening a page reuses its capability instead of leaking one per click. */
|
||||
private readonly byRoot = new Map<string, string>();
|
||||
|
||||
constructor(ttlMs: number = WEBVIEW_CAPABILITY_TTL_MS) {
|
||||
this.capabilities = new StaleExpirationMap<string, HtmlViewCapabilityRecord>({
|
||||
ttlMs,
|
||||
refreshOnGet: true,
|
||||
onExpire: (token, record) => {
|
||||
const key = rootKey(record.rootDir, record.owner, record.outsideWorkspace);
|
||||
if (this.byRoot.get(key) === token) this.byRoot.delete(key);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/** Mint (or reuse) a capability for a directory. Returns the token. */
|
||||
mint(rootDir: string, owner?: string, outsideWorkspace = false): string {
|
||||
// The flag is part of the key: an out-of-workspace mint must never reuse a
|
||||
// capability recorded as in-workspace, which confinement would not stop.
|
||||
const key = rootKey(rootDir, owner, outsideWorkspace);
|
||||
const existing = this.byRoot.get(key);
|
||||
if (existing && this.capabilities.get(existing)) return existing;
|
||||
if (existing) this.byRoot.delete(key);
|
||||
|
||||
if (this.capabilities.size >= MAX_WEBVIEW_CAPABILITIES) this.capabilities.cleanup();
|
||||
|
||||
const token = randomBytes(24).toString('base64url');
|
||||
this.capabilities.set(token, { rootDir, owner, outsideWorkspace, createdAt: Date.now() });
|
||||
this.byRoot.set(key, token);
|
||||
return token;
|
||||
}
|
||||
|
||||
/** Resolve a capability, refreshing its TTL. Returns undefined when unknown or expired. */
|
||||
resolve(token: string): HtmlViewCapabilityRecord | undefined {
|
||||
if (!token) return undefined;
|
||||
return this.capabilities.get(token);
|
||||
}
|
||||
|
||||
/** Revoke every capability bound to an identity (logout, admin logout, user deletion). */
|
||||
revokeOwner(owner: string | undefined): number {
|
||||
let revoked = 0;
|
||||
for (const [key, token] of [...this.byRoot]) {
|
||||
const record = this.capabilities.peek(token);
|
||||
if (!record || record.owner === owner) {
|
||||
this.capabilities.delete(token);
|
||||
this.byRoot.delete(key);
|
||||
if (record) revoked++;
|
||||
}
|
||||
}
|
||||
return revoked;
|
||||
}
|
||||
|
||||
get size(): number {
|
||||
return this.capabilities.size;
|
||||
}
|
||||
|
||||
dispose(): void {
|
||||
this.capabilities.dispose();
|
||||
this.byRoot.clear();
|
||||
}
|
||||
}
|
||||
|
||||
function rootKey(rootDir: string, owner: string | undefined, outsideWorkspace: boolean): string {
|
||||
return `${owner ?? ''}\0${outsideWorkspace ? 'out' : 'in'}\0${rootDir}`;
|
||||
}
|
||||
|
||||
/** The capability token in an `/html-view/<cap>/...` path, or null. */
|
||||
export function capabilityFromHtmlViewPath(pathname: string): string | null {
|
||||
if (typeof pathname !== 'string') return null;
|
||||
const prefix = `${HTML_VIEW_PREFIX}/`;
|
||||
if (!pathname.startsWith(prefix)) return null;
|
||||
const rest = pathname.slice(prefix.length);
|
||||
const slash = rest.indexOf('/');
|
||||
const cap = slash === -1 ? rest : rest.slice(0, slash);
|
||||
return /^[A-Za-z0-9_-]{16,128}$/.test(cap) ? cap : null;
|
||||
}
|
||||
|
||||
/** Process-wide store, shared by the routes and the auth middleware (see webviewCapabilities). */
|
||||
export const htmlViewCapabilities = new HtmlViewCapabilityStore();
|
||||
@@ -14,7 +14,7 @@ import { basename, extname, relative } from 'node:path';
|
||||
import { statSync } from 'node:fs';
|
||||
import type { AttachmentDetectedEvent, AttachmentDetectedType, ImageDetectedEvent } from './types.js';
|
||||
import { KeyedDebouncer } from './utils/index.js';
|
||||
import { UPLOAD_DIR_NAMES } from './web/paste-image-gc.js';
|
||||
import { DISCORD_RECEIVED_DIR, UPLOAD_DIR_NAMES } from './web/paste-image-gc.js';
|
||||
|
||||
// ========== Types ==========
|
||||
|
||||
@@ -159,14 +159,14 @@ export class ImageWatcher extends EventEmitter {
|
||||
// Ignore common heavy directories for performance
|
||||
ignored: (path: string) => {
|
||||
// Skip node_modules, .git, and other heavy directories, and Codeman's
|
||||
// own upload folders: a pdf the user handed to the agent is not a file
|
||||
// the agent produced.
|
||||
// own upload folders: a pdf the user handed to the agent, by upload or
|
||||
// from Discord, is not a file the agent produced.
|
||||
if (
|
||||
path.includes('/node_modules/') ||
|
||||
path.includes('/.git/') ||
|
||||
path.includes('/dist/') ||
|
||||
path.includes('/.next/') ||
|
||||
UPLOAD_DIR_NAMES.some((name) => path.includes(`/${name}/`))
|
||||
[...UPLOAD_DIR_NAMES, DISCORD_RECEIVED_DIR].some((name) => path.includes(`/${name}/`))
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -24,6 +24,7 @@ import { createHash } from 'node:crypto';
|
||||
import { existsSync, mkdirSync, readFileSync, realpathSync, renameSync, writeFileSync } from 'node:fs';
|
||||
import { dirname } from 'node:path';
|
||||
import { dataPath } from './config/instance.js';
|
||||
import { KEEP_WARM_NUDGE } from './prompt-cache-keepwarm.js';
|
||||
import type { IntentProfile, IntentPromptEntry } from './types/index.js';
|
||||
|
||||
// ========== Limits ==========
|
||||
@@ -62,6 +63,9 @@ export function isCapturablePrompt(text: string): boolean {
|
||||
if (text.startsWith('<system-reminder>')) return false;
|
||||
if (text.startsWith('Caveat: The messages below')) return false;
|
||||
if (text.startsWith('[Request interrupted')) return false;
|
||||
// Codeman's own prompt-cache keep-alive turn is not something the user meant. The exact
|
||||
// text, so a prompt of the user's that merely mentions it is still captured.
|
||||
if (text.trim() === KEEP_WARM_NUDGE) return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -108,6 +112,11 @@ export class IntentStore {
|
||||
|
||||
// ----- Public API -----
|
||||
|
||||
/** Drop the in-memory copy so the next call re-reads intents.json (after a config restore replaced it). */
|
||||
reload(): void {
|
||||
this.profiles = null;
|
||||
}
|
||||
|
||||
/**
|
||||
* The profile for a session's case. Never persists on read: an absent
|
||||
* profile returns an empty transient one (`updatedAt: 0`).
|
||||
|
||||
@@ -0,0 +1,842 @@
|
||||
/**
|
||||
* @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 (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
|
||||
* 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, stat, type FileHandle } from 'node:fs/promises';
|
||||
import { basename, join, resolve } from 'node:path';
|
||||
|
||||
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'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;
|
||||
|
||||
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;
|
||||
type Json = Record<string, unknown>;
|
||||
|
||||
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 {
|
||||
rootSessionId: string | null;
|
||||
sessionId: string;
|
||||
timestamp: string;
|
||||
role: 'user' | 'assistant';
|
||||
/** 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;
|
||||
/** `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';
|
||||
complete: boolean;
|
||||
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;
|
||||
/**
|
||||
* 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;
|
||||
/** 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 {
|
||||
transcriptRemaining: number;
|
||||
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. */
|
||||
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 ||
|
||||
(/^\d{4}-\d{2}-\d{2}$/.test(value) &&
|
||||
Number.isFinite(Date.parse(value)) &&
|
||||
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');
|
||||
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, UTC)');
|
||||
if (options.topic !== undefined && !options.topic.trim()) throw new Error('Invalid empty topic');
|
||||
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<FileHandle> {
|
||||
// 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 readAt(handle: FileHandle, position: number, size: number): Promise<Buffer> {
|
||||
const buffer = Buffer.alloc(size);
|
||||
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<Buffer> {
|
||||
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;
|
||||
}
|
||||
|
||||
type Identity = Omit<Source, 'identity' | 'size'>;
|
||||
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 = 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,
|
||||
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<Source | undefined> {
|
||||
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 limit = Math.min(before.size, METADATA_HEAD_BYTES);
|
||||
let buffer = Buffer.alloc(0);
|
||||
let parsed = 0;
|
||||
while (buffer.length < limit) {
|
||||
const chunk = await readMetadataChunk(
|
||||
handle,
|
||||
buffer.length,
|
||||
Math.min(METADATA_CHUNK_BYTES, limit - buffer.length),
|
||||
budget
|
||||
);
|
||||
if (!chunk.length) break;
|
||||
if (!buffer.length && chunk.subarray(0, ZSTD_MAGIC.length).equals(ZSTD_MAGIC))
|
||||
throw new UnattributableFileError(
|
||||
'History source is Zstandard-compressed (a Codex .jsonl.zst rollout); only plain .jsonl is read'
|
||||
);
|
||||
buffer = Buffer.concat([buffer, chunk]);
|
||||
let end = buffer.indexOf(10, parsed);
|
||||
while (end >= 0) {
|
||||
const line = buffer.subarray(parsed, end);
|
||||
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 (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<Source[]> {
|
||||
// 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<void> => {
|
||||
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 HistoryBudgetError('History directory scan entry budget exhausted; raise --max-entries');
|
||||
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.
|
||||
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')) {
|
||||
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++;
|
||||
} 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);
|
||||
}
|
||||
}
|
||||
};
|
||||
await visit(root, 0);
|
||||
return sources;
|
||||
}
|
||||
|
||||
interface Message {
|
||||
representation: 'event' | 'modern' | 'response';
|
||||
role: 'user' | 'assistant';
|
||||
message: 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;
|
||||
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: 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) ?? '',
|
||||
};
|
||||
}
|
||||
/** Native events and response_item commonly duplicate one accepted message. */
|
||||
function collapse(items: Message[]): Message[] {
|
||||
const records: Message[] = [];
|
||||
let representations = new Set<Message['representation']>();
|
||||
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 or could not be read for any reason other than a budget.
|
||||
*/
|
||||
ancestorMessages(id: string): Promise<Message[] | undefined>;
|
||||
}
|
||||
type Piece = Message | { boundary: true };
|
||||
interface LegacyPrefix {
|
||||
ancestorId: string;
|
||||
/** 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 parseSource(source: Source, budget: Budget): Promise<ParsedSource> {
|
||||
const handle = await regularFile(source.path);
|
||||
let buffer: Buffer;
|
||||
try {
|
||||
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');
|
||||
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 raw: Message[] = [];
|
||||
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 (inherited || legacy) continue;
|
||||
budget.scan.inheritedPrefixes++;
|
||||
legacy = { ancestorId: declared.sessionId, pieces: [] };
|
||||
continue;
|
||||
}
|
||||
if (legacy && entry.type === 'event_msg' && object(entry.payload).type === 'thread_settings_applied') {
|
||||
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 (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++;
|
||||
}
|
||||
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<Message[]> {
|
||||
const own = [...parsed.own];
|
||||
if (parsed.legacy) own.push(...(await afterLegacyPrefix(parsed.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<Message[]> {
|
||||
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) {
|
||||
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 = utcDate(item.timestamp);
|
||||
return (
|
||||
date !== undefined &&
|
||||
(!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<string>();
|
||||
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];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The not-found error, naming what the store held but could not attribute.
|
||||
* Codex names a rollout `rollout-<timestamp>-<thread id>.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 <path> > 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<LocalHistoryResult> {
|
||||
validate(options);
|
||||
const scan: LocalHistoryResult['scan'] = {
|
||||
bytesRead: 0,
|
||||
metadataBytesRead: 0,
|
||||
entries: 0,
|
||||
malformedRecords: 0,
|
||||
partialRecords: 0,
|
||||
skippedSymlinks: 0,
|
||||
unattributedFiles: 0,
|
||||
compressedFiles: 0,
|
||||
unreadableDirectories: 0,
|
||||
inheritedPrefixes: 0,
|
||||
incompletePrefixes: 0,
|
||||
unverifiedPrefixes: 0,
|
||||
};
|
||||
const budget: Budget = {
|
||||
transcriptRemaining: options.scanBytes ?? HISTORY_DEFAULT_SCAN_BYTES,
|
||||
metadataRemaining: options.metadataBytes ?? HISTORY_DEFAULT_METADATA_BYTES,
|
||||
entriesRemaining: options.maxEntries ?? HISTORY_DEFAULT_MAX_ENTRIES,
|
||||
scan,
|
||||
compressedPaths: [],
|
||||
};
|
||||
let sources: Source[];
|
||||
let attributionSources: Source[];
|
||||
if (options.file !== undefined) {
|
||||
let source: Source | undefined;
|
||||
try {
|
||||
source = await readMetadata(resolve(options.file), budget);
|
||||
} catch (error) {
|
||||
throw error instanceof UnattributableFileError ? new Error(error.message) : error;
|
||||
}
|
||||
if (!source) throw new Error('History source is missing valid native session metadata');
|
||||
sources = [source];
|
||||
attributionSources = sources;
|
||||
} else {
|
||||
const root = options.sessionsRoot ?? codexSessionsRoot();
|
||||
const all = await findSources(resolve(root), budget);
|
||||
const wanted = (options.sessionId ?? options.rootSessionId)!.toLowerCase();
|
||||
const matches = all.filter((source) => source.sessionId.startsWith(wanted));
|
||||
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;
|
||||
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)]));
|
||||
// Each selected file is read once, whether the main loop or a child's boundary proof asks first.
|
||||
const parsedCache = new Map<Source, Promise<ParsedSource>>();
|
||||
const parse = (source: Source) => {
|
||||
let parsed = parsedCache.get(source);
|
||||
if (!parsed) {
|
||||
parsed = parseSource(source, budget);
|
||||
parsedCache.set(source, parsed);
|
||||
}
|
||||
return parsed;
|
||||
};
|
||||
const context: ReadContext = {
|
||||
// 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 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(await parse(source), budget, context), 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 &&
|
||||
scan.compressedFiles === 0 &&
|
||||
scan.unreadableDirectories === 0 &&
|
||||
scan.incompletePrefixes === 0 &&
|
||||
scan.unverifiedPrefixes === 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,
|
||||
};
|
||||
}
|
||||
/** The `--max-bytes` check, exported so the CLI can make it before any file is read. */
|
||||
export function validateOutputBytes(maxBytes: number): void {
|
||||
checkBudget(maxBytes, HISTORY_MAX_OUTPUT_BYTES, '--max-bytes');
|
||||
}
|
||||
/** Output never splits a JSON document, UTF8 character, record, or selected turn. */
|
||||
export function formatLocalHistory(
|
||||
result: LocalHistoryResult,
|
||||
format: 'json' | 'text' = 'json',
|
||||
maxBytes = HISTORY_DEFAULT_OUTPUT_BYTES
|
||||
): string {
|
||||
validateOutputBytes(maxBytes);
|
||||
const body =
|
||||
format === 'json'
|
||||
? JSON.stringify(result)
|
||||
: [
|
||||
`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 ? ` ${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;
|
||||
}
|
||||
+1092
File diff suppressed because it is too large.
Load diff
@@ -4,9 +4,12 @@
|
||||
* `mcpSyncTargets()` (routes/mcp-sync-routes.ts) takes the registry's enabled CLIs that declare an
|
||||
* `mcpConfig`. Some tools read an MCP server list worth keeping in step with the others but are not
|
||||
* something Codeman launches, so they have no registry entry (and no id to branch on): GitHub
|
||||
* Copilot CLI is the first. They are plain data here, take part only when installed or when their
|
||||
* config file already exists (an absent tool is reported `absent`, never created), and sort after
|
||||
* the registry CLIs, so when two definitions of a name differ the registry CLI's is the one copied.
|
||||
* Copilot CLI is the first. It is also a run mode that declares its own `mcpConfig`, so while that
|
||||
* entry is enabled this row is skipped (`mcpSyncOnlyTargets` drops ids the registry already
|
||||
* supplies); it applies when the run mode is disabled but the tool is still on the machine. They are
|
||||
* plain data here, take part only when installed or when their config file already exists (an
|
||||
* absent tool is reported `absent`, never created), and sort after the registry CLIs, so when two
|
||||
* definitions of a name differ the registry CLI's is the one copied.
|
||||
*
|
||||
* @module mcp-sync-targets
|
||||
*/
|
||||
|
||||
+57
-5
@@ -40,7 +40,7 @@
|
||||
import { promises as fs } from 'node:fs';
|
||||
import { randomBytes } from 'node:crypto';
|
||||
import { homedir } from 'node:os';
|
||||
import { dirname, isAbsolute, join } from 'node:path';
|
||||
import { basename, dirname, isAbsolute, join } from 'node:path';
|
||||
import { parse as parseToml, TomlError } from 'smol-toml';
|
||||
import type { McpConfigFormat } from './config/cli-registry/types.js';
|
||||
import type { McpSyncResult, McpSyncTargetResult } from './types/mcp-sync.js';
|
||||
@@ -66,6 +66,11 @@ export interface McpSyncTarget {
|
||||
label: string;
|
||||
/** Home-relative default location of the config file. */
|
||||
path: string;
|
||||
/**
|
||||
* Home-relative fallback file used when `path` is absent: read, and written in place when it
|
||||
* parses (a JSONC file with comments fails the parse and is never written).
|
||||
*/
|
||||
altPath?: string;
|
||||
format: McpFormat;
|
||||
/** The env var the CLI reads to move the file, and the path under it (`mcpConfig.relocation`). */
|
||||
relocation?: { envVar: string; path: string };
|
||||
@@ -197,11 +202,12 @@ function toClaude(s: McpServer): Record<string, unknown> {
|
||||
function fromGemini(raw: unknown): McpServer | null {
|
||||
if (!isRecord(raw)) return null;
|
||||
// `httpUrl` is the legacy streamable-http key; `url` + `type` is what `gemini mcp add` writes
|
||||
// today, and a bare `url` with no type is the legacy SSE form.
|
||||
// today. A bare `url` with no type used to be the legacy SSE form, but gemini-cli v0.63.0 tries
|
||||
// it as streamable HTTP first (and logs `httpUrl` as deprecated), so map it to 'http'.
|
||||
if (typeof raw.httpUrl === 'string')
|
||||
return clean({ transport: 'http', url: raw.httpUrl, headers: strMap(raw.headers) });
|
||||
if (typeof raw.url === 'string') {
|
||||
return clean({ transport: raw.type === 'http' ? 'http' : 'sse', url: raw.url, headers: strMap(raw.headers) });
|
||||
return clean({ transport: raw.type === 'sse' ? 'sse' : 'http', url: raw.url, headers: strMap(raw.headers) });
|
||||
}
|
||||
if (typeof raw.command === 'string') {
|
||||
return clean({
|
||||
@@ -255,7 +261,7 @@ function toAntigravity(s: McpServer): Record<string, unknown> | null {
|
||||
|
||||
function fromOpencode(raw: unknown): McpServer | null {
|
||||
if (!isRecord(raw)) return null;
|
||||
const disabled = raw.enabled === false;
|
||||
const disabled = raw.enabled === false || raw.disabled === true;
|
||||
if (raw.type === 'remote' && typeof raw.url === 'string') {
|
||||
return clean({ transport: 'http', url: raw.url, headers: strMap(raw.headers), disabled });
|
||||
}
|
||||
@@ -311,6 +317,8 @@ function toCopilot(s: McpServer): Record<string, unknown> {
|
||||
interface JsonDialect {
|
||||
/** Key holding the server table. */
|
||||
key: string;
|
||||
/** Servers also read from under `key.nestedKey` (OpenCode V2 nests them under `mcp.servers`). */
|
||||
nestedKey?: string;
|
||||
from(raw: unknown): McpServer | null;
|
||||
to(s: McpServer): Record<string, unknown> | null;
|
||||
/** Top-level keys to seed when creating the file from nothing. */
|
||||
@@ -334,6 +342,7 @@ const JSON_DIALECTS: Record<Exclude<McpFormat, 'codex-toml'>, JsonDialect> = {
|
||||
},
|
||||
'opencode-json': {
|
||||
key: 'mcp',
|
||||
nestedKey: 'servers',
|
||||
from: fromOpencode,
|
||||
to: toOpencode,
|
||||
seed: { $schema: 'https://opencode.ai/config.json' },
|
||||
@@ -405,7 +414,17 @@ function mcpTable(format: McpFormat, text: string | null): Record<string, unknow
|
||||
const table = doc[dialect.key];
|
||||
if (table === undefined) return dict<unknown>();
|
||||
if (!isRecord(table)) throw new McpConfigError(`"${dialect.key}" is not an object`);
|
||||
return table;
|
||||
if (!dialect.nestedKey) return table;
|
||||
const nested = table[dialect.nestedKey];
|
||||
// OpenCode reads `mcp.servers` as a V1 server named "servers" when it carries a V1 `type`.
|
||||
if (nested === undefined || (isRecord(nested) && (nested.type === 'local' || nested.type === 'remote'))) return table;
|
||||
if (!isRecord(nested)) throw new McpConfigError(`"${dialect.key}.${dialect.nestedKey}" is not an object`);
|
||||
// Merge the direct (V1) table with the nested (V2) one. Like OpenCode, the native V2 entry wins a
|
||||
// name collision; sync never writes a name already defined, so this does not affect the read-back.
|
||||
const merged = dict<unknown>();
|
||||
for (const k of safeKeys(table)) if (k !== dialect.nestedKey) merged[k] = table[k];
|
||||
for (const k of safeKeys(nested)) merged[k] = nested[k];
|
||||
return merged;
|
||||
}
|
||||
|
||||
/** Parse a config file's text (null = file absent). Throws if it cannot be read safely. */
|
||||
@@ -593,6 +612,30 @@ function resolveFile(
|
||||
return { file: join(dir, rel.path) };
|
||||
}
|
||||
|
||||
/**
|
||||
* The alternate config file for a target (`McpSyncTarget.altPath`), honouring the same relocation
|
||||
* as the primary file: the alt lives in the same directory, so the relocated path is the primary's
|
||||
* directory plus the alt filename. Once chosen it is read and written exactly like the primary; a
|
||||
* JSONC file with comments fails the parse guard and is never written.
|
||||
*/
|
||||
function resolveAltFile(
|
||||
t: McpSyncTarget,
|
||||
altPath: string,
|
||||
home: string,
|
||||
env: Record<string, string | undefined>
|
||||
): { file: string; skip?: string } {
|
||||
const rel = t.relocation;
|
||||
const dir = rel ? env[rel.envVar] : undefined;
|
||||
if (!rel || dir === undefined || dir === '') return { file: join(home, altPath) };
|
||||
if (!isAbsolute(dir)) {
|
||||
return {
|
||||
file: `$${rel.envVar}/…`,
|
||||
skip: `${rel.envVar} is set to a relative path, so the alt file cannot be located safely`,
|
||||
};
|
||||
}
|
||||
return { file: join(dir, dirname(rel.path), basename(altPath)) };
|
||||
}
|
||||
|
||||
/**
|
||||
* Mark the servers a CLI keeps switched off in a companion file (`JsonDialect.disabledIn`) as
|
||||
* disabled, so they are not copied. If that file cannot be read as intended the target is
|
||||
@@ -667,6 +710,15 @@ async function run(targets: McpSyncTarget[], opts: McpSyncOptions, unsupported:
|
||||
for (const s of state) {
|
||||
if (s.res.status !== 'ok') continue;
|
||||
try {
|
||||
// A config kept under an alternate name (OpenCode's opencode.jsonc) is read when the primary
|
||||
// file is absent, so its servers are synced rather than silently treated as "not installed".
|
||||
if (s.t.altPath && !(await exists(s.file))) {
|
||||
const alt = resolveAltFile(s.t, s.t.altPath, home, env);
|
||||
if (!alt.skip && (await exists(alt.file))) {
|
||||
s.file = alt.file;
|
||||
s.res.file = alt.file;
|
||||
}
|
||||
}
|
||||
if (!s.t.installed && !(await exists(s.file))) {
|
||||
s.res.status = 'absent';
|
||||
continue;
|
||||
|
||||
+76
-9
@@ -20,11 +20,13 @@ import type {
|
||||
AntigravityConfig,
|
||||
PiConfig,
|
||||
GrokConfig,
|
||||
CopilotConfig,
|
||||
DeepSeekConfig,
|
||||
OmpConfig,
|
||||
SessionRemote,
|
||||
SessionDocker,
|
||||
PaneExit,
|
||||
StatusLineExporter,
|
||||
} from './types.js';
|
||||
|
||||
/**
|
||||
@@ -57,9 +59,17 @@ export interface MuxSession {
|
||||
respawnConfig?: PersistedRespawnConfig;
|
||||
/** Whether Ralph / Todo tracking is enabled */
|
||||
ralphEnabled?: boolean;
|
||||
/** Whether the current launch carries the statusLine exporter; set on every create and respawn. */
|
||||
statusLine?: StatusLineExporter;
|
||||
/**
|
||||
* Whether the current launch carries `idleCompaction: false` in its `--settings` JSON (keep-warm
|
||||
* on at launch, the exporter injected, Claude Code new enough); set on every create and respawn.
|
||||
*/
|
||||
idleCompactionOff?: boolean;
|
||||
/**
|
||||
* This record was rebuilt from the tmux socket rather than from Codeman's own
|
||||
* bookkeeping, so everything on it but the name and the pid is a guess. Its
|
||||
* bookkeeping, so everything on it but the name, the pid and, when tmux
|
||||
* reported a usable one, the directory is a guess. Its
|
||||
* synthetic `restored-<fragment>` id cannot find the session's `state.json`
|
||||
* entry either, which means a remote or docker session rediscovered this way
|
||||
* arrives with no `remote`/`docker` metadata and looks local. Anything that
|
||||
@@ -94,7 +104,8 @@ export interface CreateSessionOptions {
|
||||
* Name pinned on a claude spawn as `--name` (version-gated, sanitized, local only).
|
||||
* Deliberately NOT `name`: `--name` owns the prompt-box label, the `/resume` picker
|
||||
* entry and the terminal title, and a pinned title stops Claude generating its own,
|
||||
* so only a user-chosen name belongs here (see `Session.cliPinnedName`).
|
||||
* so by default only a user-chosen name belongs here; the opt-in `pinCliSessionName`
|
||||
* setting pins every tab name (see `Session.cliPinnedName`).
|
||||
*/
|
||||
cliName?: string;
|
||||
niceConfig?: NiceConfig;
|
||||
@@ -107,6 +118,7 @@ export interface CreateSessionOptions {
|
||||
antigravityConfig?: AntigravityConfig;
|
||||
piConfig?: PiConfig;
|
||||
grokConfig?: GrokConfig;
|
||||
copilotConfig?: CopilotConfig;
|
||||
deepSeekConfig?: DeepSeekConfig;
|
||||
ompConfig?: OmpConfig;
|
||||
/** When restoring after reboot, resume a previous Claude conversation by its session ID */
|
||||
@@ -117,6 +129,10 @@ export interface CreateSessionOptions {
|
||||
effort?: EffortLevel;
|
||||
/** Claude advisor model, merged into the same `--settings` JSON (overridable via /advisor in-session) */
|
||||
advisorModel?: string;
|
||||
/** The session's own prompt-cache keep-warm choice (undefined = app default); decides the `idleCompaction` opt-out. */
|
||||
keepWarm?: boolean;
|
||||
/** Spawned by another session: it does not inherit the keep-warm app default (`keepWarmOn`). */
|
||||
spawnedByAgent?: boolean;
|
||||
/** tmux history-limit (scrollback lines) allocated when this session is created. */
|
||||
historyLimit?: number;
|
||||
/** Remote execution metadata for local tmux sessions wrapping SSH */
|
||||
@@ -138,7 +154,8 @@ export interface RespawnPaneOptions {
|
||||
* Name pinned on a respawned claude as `--name` (version-gated, sanitized, local only).
|
||||
* Deliberately NOT `name`: `--name` owns the prompt-box label, the `/resume` picker
|
||||
* entry and the terminal title, and a pinned title stops Claude generating its own,
|
||||
* so only a user-chosen name belongs here (see `Session.cliPinnedName`).
|
||||
* so by default only a user-chosen name belongs here; the opt-in `pinCliSessionName`
|
||||
* setting pins every tab name (see `Session.cliPinnedName`).
|
||||
*/
|
||||
cliName?: string;
|
||||
niceConfig?: NiceConfig;
|
||||
@@ -151,6 +168,7 @@ export interface RespawnPaneOptions {
|
||||
antigravityConfig?: AntigravityConfig;
|
||||
piConfig?: PiConfig;
|
||||
grokConfig?: GrokConfig;
|
||||
copilotConfig?: CopilotConfig;
|
||||
deepSeekConfig?: DeepSeekConfig;
|
||||
ompConfig?: OmpConfig;
|
||||
/** Resume a previous Claude conversation when respawning */
|
||||
@@ -168,6 +186,10 @@ export interface RespawnPaneOptions {
|
||||
effort?: EffortLevel;
|
||||
/** Claude advisor model (preserved across respawns, merged into the same `--settings` JSON) */
|
||||
advisorModel?: string;
|
||||
/** The session's own prompt-cache keep-warm choice, re-read at every relaunch (see CreateSessionOptions). */
|
||||
keepWarm?: boolean;
|
||||
/** Spawned by another session (see CreateSessionOptions). */
|
||||
spawnedByAgent?: boolean;
|
||||
/** Original tmux history-limit retained for config parity; respawn cannot resize the existing pane. */
|
||||
historyLimit?: number;
|
||||
/** Remote execution metadata for local tmux sessions wrapping SSH */
|
||||
@@ -189,6 +211,21 @@ export interface PaneCaptureOptions {
|
||||
fullHistory?: boolean;
|
||||
/** Bound the full-history capture to this many scrollback lines (`-S -<N>`). */
|
||||
historyLimitLines?: number;
|
||||
/**
|
||||
* Full-history only: scrollback rows read from somewhere other than this pane
|
||||
* (raw `capture-pane -p -e -J` text, oldest first). The pane then contributes
|
||||
* only its visible frame and caret, and its own history is not read. A remote
|
||||
* session's real history lives in the REMOTE tmux (`remote-pane-history.ts`).
|
||||
*/
|
||||
scrollbackOverride?: string;
|
||||
/**
|
||||
* With `scrollbackOverride`: the screen rows of the pane that history came
|
||||
* from, read right after it. The frame this pane captures later can have
|
||||
* scrolled past some of them, or show only part of a taller screen, so the
|
||||
* rows above where the frame's top overlaps them are kept between the
|
||||
* scrollback and the frame (`joinScrollbackAndFrame`).
|
||||
*/
|
||||
scrollbackOverrideScreen?: string;
|
||||
/**
|
||||
* Byte cap the consumer will keep from the capture. Sizes the child-process
|
||||
* stdout buffer (with slack) so multi-MB scrollback dumps aren't killed by
|
||||
@@ -204,6 +241,24 @@ export interface PaneCaptureOptions {
|
||||
* rendering it needs the real height to know the frame fits.
|
||||
*/
|
||||
capturedGeometry?: { cols: number; rows: number };
|
||||
/**
|
||||
* Filled in by the implementation with the number of rows the pane holds in
|
||||
* scrollback ABOVE the visible frame (tmux `#{history_size}`), read in the
|
||||
* same query as the geometry. 0 means a full-history capture can return
|
||||
* nothing beyond the visible frame: a pane in the alternate screen (a
|
||||
* fullscreen CLI that keeps its transcript itself) never accumulates any.
|
||||
* A full-history capture bounded by `historyLimitLines` below this depth
|
||||
* left older lines behind in the multiplexer, which the caller must report
|
||||
* as recoverable. Absent when the pane could not be queried.
|
||||
*/
|
||||
capturedHistoryLines?: number;
|
||||
/**
|
||||
* Filled in by the implementation with whether the pane was in the alternate
|
||||
* screen at capture time, when it can tell. tmux keeps no history for such a
|
||||
* pane (a nested tmux client puts it there), so a full-history capture of it
|
||||
* holds one screen plus whatever the pane held before.
|
||||
*/
|
||||
capturedAlternateOn?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -216,7 +271,7 @@ export interface PaneCaptureOptions {
|
||||
* - `sessionKilled` (data: { sessionId: string }) - Session terminated
|
||||
* - `sessionDied` (data: { sessionId: string }) - Session died unexpectedly
|
||||
* - `statsUpdated` (sessions: MuxSessionWithStats[]) - Stats refreshed
|
||||
* - `paneExitsUpdated` () - A pane read finished; ask `getPaneExit()` per session
|
||||
* - `paneExitsUpdated` () - A pane read finished; ask `getPaneExit()` (and `getPaneCurrentPath()`) per session
|
||||
*/
|
||||
export interface TerminalMultiplexer extends EventEmitter {
|
||||
/** Which backend this instance uses */
|
||||
@@ -259,9 +314,10 @@ 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.
|
||||
*/
|
||||
sendInput(sessionId: string, input: string): Promise<boolean>;
|
||||
sendInput(sessionId: string, input: string, options?: { beforeDispatch?: () => boolean }): Promise<boolean>;
|
||||
|
||||
// ========== Metadata ==========
|
||||
|
||||
@@ -340,6 +396,16 @@ export interface TerminalMultiplexer extends EventEmitter {
|
||||
*/
|
||||
getPaneExit?(muxName: string): PaneExit | undefined;
|
||||
|
||||
/**
|
||||
* The directory the session's pane was in at the last pane read
|
||||
* (`#{pane_current_path}`), or `undefined` when unknown. Read from the same
|
||||
* batched watcher as {@link getPaneExit}, so it costs no tmux call. A relative
|
||||
* path printed in the terminal resolves against it; it is never a
|
||||
* confinement root. A multiplexer without it leaves relative paths resolving
|
||||
* against `workingDir`.
|
||||
*/
|
||||
getPaneCurrentPath?(muxName: string): string | undefined;
|
||||
|
||||
/**
|
||||
* How many authoritative pane reads have agreed on the exit `getPaneExit()`
|
||||
* reports, or 0 when it reports none. The exited-agent sweep closes a session
|
||||
@@ -374,11 +440,12 @@ export interface TerminalMultiplexer extends EventEmitter {
|
||||
captureActivePaneBuffer?(muxName: string, opts?: PaneCaptureOptions): string | null;
|
||||
|
||||
/**
|
||||
* Plain text of the visible frame: no styles, no cursor query, no repaint
|
||||
* reconstruction. Deliberately cheaper than `capturePaneBuffer` because idle
|
||||
* Plain text of the visible frame: no cursor query, no repaint
|
||||
* reconstruction, and no styles unless `styles` asks for them (SGR only, lines
|
||||
* still `\n`-separated). Deliberately cheaper than `capturePaneBuffer` because idle
|
||||
* detection calls it on a timer: it only needs to read what the CLI is
|
||||
* currently rendering, never to replay it into an xterm. Returns null when the
|
||||
* pane cannot be read.
|
||||
*/
|
||||
capturePaneText?(muxName: string, paneTarget?: string): string | null;
|
||||
capturePaneText?(muxName: string, paneTarget?: string, opts?: { styles?: boolean }): string | null;
|
||||
}
|
||||
@@ -0,0 +1,440 @@
|
||||
/**
|
||||
* @fileoverview Prompt-cache keep-warm: a one-line keep-alive turn shortly before an idle
|
||||
* claude session's prompt cache would expire, so the next real prompt reads the conversation
|
||||
* from cache instead of re-processing it at the cache-write rate.
|
||||
*
|
||||
* Why it exists: Claude Code gives a subscription's main conversation a one-hour cache and
|
||||
* lets it expire after an hour idle. Measured on real sessions, nearly every gap over an
|
||||
* hour went cold and the first turn back re-wrote the whole context at 2x; a keep-alive turn
|
||||
* costs a cache read plus a one-word reply, a small fraction of the miss it prevents, and the
|
||||
* payoff is concentrated in gaps of one to four hours, which is why there is a cap after the
|
||||
* last real prompt rather than an indefinite loop.
|
||||
*
|
||||
* Codeman owns the idle policy for these sessions (Discussion #554): a launch with keep-warm
|
||||
* on also carries `idleCompaction: false` in its one `--settings` JSON, so Claude Code's own
|
||||
* idle compaction (which fires at 90% of the TTL, a minute before the nudge) cannot replace
|
||||
* the conversation with a summary first. That is decided once per launch in
|
||||
* `TmuxManager._resolveIdleCompactionOptOut()` and recorded on the mux record; a session
|
||||
* switched on after launch is kept warm best-effort and says so.
|
||||
*
|
||||
* Where the facts come from: `Session.promptCache` (the statusline's `prompt_cache` object,
|
||||
* see usage-telemetry.ts) carries `expires_at`, the TTL and the tokens at stake, so nothing
|
||||
* here infers the lifetime from idle detection or the plan tier.
|
||||
*
|
||||
* Shape: `decideKeepWarm()` is the pure rulebook (every guard in one place, tested as a
|
||||
* table); `PromptCacheKeepWarmService` owns the per-session timer and the ports it needs
|
||||
* from the server. It never touches the session except through `beginKeepAliveTurn()` and
|
||||
* `writeViaMux()`: the window `beginKeepAliveTurn()` opens is what keeps the nudge off the
|
||||
* real-prompt clock and opens its stop hook and idle prompt pre-acknowledged
|
||||
* (hook-event-routes.ts). The nudge is still a submit, so it moves `lastSubmitAt`.
|
||||
*
|
||||
* @module prompt-cache-keepwarm
|
||||
*/
|
||||
|
||||
import type { KeepWarmStatus, PromptCacheStatus, StatusLineExporter } from './types/index.js';
|
||||
|
||||
/** The nudge. Under 100 chars on purpose: a longer burst reads as a paste to Claude Code. */
|
||||
export const KEEP_WARM_NUDGE = 'Codeman cache keep-alive: no action needed, reply with just "ok".';
|
||||
/** Nudge this long before `expires_at`. The nudge's own request is what refreshes the timer. */
|
||||
export const KEEP_WARM_LEAD_MS = 5 * 60_000;
|
||||
/** Re-check cadence while a transient guard (working, dialog, draft in the composer) defers. */
|
||||
export const KEEP_WARM_RETRY_MS = 60_000;
|
||||
/** Give up this close to expiry: a turn takes seconds to land and the Enter may be re-pressed. */
|
||||
export const KEEP_WARM_LAST_CHANCE_MS = 60_000;
|
||||
/** Below this many tokens at stake a cold rewrite is cheap and a turn is not worth it. */
|
||||
export const KEEP_WARM_MIN_TOKENS = 20_000;
|
||||
/** A person typed this recently: they may be about to submit, which refreshes the cache for free. */
|
||||
export const KEEP_WARM_RECENT_INPUT_MS = 3 * 60_000;
|
||||
/**
|
||||
* Ceiling on the keep-alive window. The window normally closes on the turn's own idle prompt
|
||||
* (Claude Code sends it about a minute after the turn ends, so a slow turn must not outlast a
|
||||
* short fixed window) or earlier, at any other submit; the ceiling covers a turn whose idle
|
||||
* prompt never arrives, such as a workspace without Codeman's hooks.
|
||||
*/
|
||||
export const KEEP_WARM_TURN_WINDOW_MS = 10 * 60_000;
|
||||
/** Default cap: hours after the last real prompt during which nudges are sent. */
|
||||
export const DEFAULT_KEEP_WARM_MAX_HOURS = 2;
|
||||
/** Longest delay `setTimeout` honours; Node fires anything longer after 1 ms. */
|
||||
const MAX_TIMER_MS = 2 ** 31 - 1;
|
||||
|
||||
/**
|
||||
* Whether keep-warm is on for a session: its own choice, else the app-wide default. A session
|
||||
* an agent spawned (`parentSessionId`) does not inherit the default: a worker that finished
|
||||
* its task is idle exactly like one that will be prompted again, and most never are, so only
|
||||
* its own switch keeps it warm. One function because the launch (the `idleCompaction`
|
||||
* opt-out) and the service must agree.
|
||||
*/
|
||||
export function keepWarmOn(choice: boolean | undefined, appDefault: boolean, spawnedByAgent: boolean): boolean {
|
||||
return choice ?? (appDefault && !spawnedByAgent);
|
||||
}
|
||||
|
||||
export interface KeepWarmFacts {
|
||||
now: number;
|
||||
/** The session's own choice; undefined = the app-wide default applies. */
|
||||
choice: boolean | undefined;
|
||||
appDefault: boolean;
|
||||
/** Spawned by another session (`parentSessionId`); see `keepWarmOn`. */
|
||||
spawnedByAgent: boolean;
|
||||
/** Whether this launch carries the statusLine exporter; undefined when unknown. */
|
||||
statusLine?: StatusLineExporter;
|
||||
cache: PromptCacheStatus | null | undefined;
|
||||
/** Idle by Codeman's own probe and not mid-turn. */
|
||||
idle: boolean;
|
||||
/** A pid and no recorded agent exit (`paneExit`: an exited agent's pane keeps its pid). */
|
||||
paneAlive: boolean;
|
||||
/** Auto-resume's pause, or a usage-limit message on screen whose reset is still ahead. */
|
||||
limitPaused: boolean;
|
||||
/** Respawn running, a Ralph loop active, a task assigned, or an agent long-polling the session. */
|
||||
automationActive: boolean;
|
||||
/** A permission, question or elicitation dialog is open (never the idle item). */
|
||||
pendingApproval: boolean;
|
||||
/** The composer line holds no text; null when the pane could not be read. */
|
||||
composerEmpty: boolean | null;
|
||||
/** Last keystroke a person sent, epoch ms; 0 when none seen. */
|
||||
lastHumanInputAt: number;
|
||||
/** Start of the keep-warm window: the last real (non-keep-alive) prompt, else the session start. */
|
||||
budgetAnchorAt: number;
|
||||
maxKeepWarmMs: number;
|
||||
}
|
||||
|
||||
export type KeepWarmDecision =
|
||||
| { action: 'nudge' }
|
||||
| { action: 'wait'; until: number; reason: string }
|
||||
| { action: 'stop'; reason: string };
|
||||
|
||||
const stop = (reason: string): KeepWarmDecision => ({ action: 'stop', reason });
|
||||
const wait = (until: number, reason: string): KeepWarmDecision => ({ action: 'wait', until, reason });
|
||||
|
||||
/**
|
||||
* The rulebook. Hard stops first (nothing to do until the next cache report or a flag
|
||||
* change), then the schedule, then the transient guards that defer by `KEEP_WARM_RETRY_MS`
|
||||
* and never past the last-chance line. Pure: `now` is passed in.
|
||||
*/
|
||||
export function decideKeepWarm(f: KeepWarmFacts): KeepWarmDecision {
|
||||
if (!keepWarmOn(f.choice, f.appDefault, f.spawnedByAgent)) {
|
||||
return stop(
|
||||
f.choice === undefined && f.appDefault
|
||||
? 'an agent-spawned session follows the app-wide default only when switched on for itself'
|
||||
: 'off'
|
||||
);
|
||||
}
|
||||
// Only a launch that carries the exporter ever reports `prompt_cache`; the readout names the
|
||||
// cause from `statusLineExporter`. This is also what stops a CLI without cache telemetry.
|
||||
if (f.statusLine !== 'injected') return stop(`no cache reports on this launch (${f.statusLine ?? 'unknown'})`);
|
||||
const c = f.cache;
|
||||
if (!c) return stop('no cache report yet');
|
||||
if (!c.warm || !c.expiresAt) return stop('cache is cold; the next prompt rebuilds it');
|
||||
// Compacted this gap (`/compact`, or Claude Code's own idle compaction on a launch without
|
||||
// the opt-out): the full context is already gone, so keeping the summary warm buys little.
|
||||
// The stamp is dropped at the next request, which re-opens the next gap.
|
||||
if (c.compactedAt != null) return stop('the context was compacted; resumes after the next prompt');
|
||||
if (c.ttl === '5m') {
|
||||
return stop('5-minute cache lifetime; a nudge every four minutes costs more than the miss it prevents');
|
||||
}
|
||||
if ((c.recacheTokensIfCold ?? 0) < KEEP_WARM_MIN_TOKENS) return stop('context too small to be worth a turn');
|
||||
if (!f.paneAlive) return stop('no live pane');
|
||||
if (f.limitPaused) return stop('usage-limit pause');
|
||||
if (f.now >= f.budgetAnchorAt + f.maxKeepWarmMs) return stop('keep-warm window after the last prompt has passed');
|
||||
const dueAt = c.expiresAt - KEEP_WARM_LEAD_MS;
|
||||
if (f.now < dueAt) return wait(dueAt, 'not due yet');
|
||||
const lastChance = c.expiresAt - KEEP_WARM_LAST_CHANCE_MS;
|
||||
if (f.now >= lastChance) return stop('too close to expiry to land a turn');
|
||||
const retryAt = Math.min(f.now + KEEP_WARM_RETRY_MS, lastChance);
|
||||
if (!f.idle) return wait(retryAt, 'session is working');
|
||||
if (f.pendingApproval) return wait(retryAt, 'a dialog is open');
|
||||
// Transient, and nothing else re-triggers an idle session: an agent's long-poll waiter ends
|
||||
// without a turn, and so does a capture that failed once.
|
||||
if (f.automationActive) return wait(retryAt, 'respawn, Ralph or an agent is driving the session');
|
||||
if (f.composerEmpty === null) return wait(retryAt, 'cannot read the composer');
|
||||
if (!f.composerEmpty) return wait(retryAt, 'the composer holds text');
|
||||
if (f.lastHumanInputAt && f.now - f.lastHumanInputAt < KEEP_WARM_RECENT_INPUT_MS) {
|
||||
return wait(retryAt, 'someone typed a moment ago');
|
||||
}
|
||||
return { action: 'nudge' };
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether a session could be nudged once the settings are known: its own switch is not off, its
|
||||
* launch reports the cache, and the last report is warm. Only such a session has anything to
|
||||
* lose while the settings cannot be read, so only it retries on its own.
|
||||
*/
|
||||
function couldNudge(s: KeepWarmSessionView): boolean {
|
||||
return (
|
||||
s.keepWarmEnabled !== false &&
|
||||
s.statusLineExporter === 'injected' &&
|
||||
!!s.promptCache?.warm &&
|
||||
!!s.promptCache.expiresAt
|
||||
);
|
||||
}
|
||||
|
||||
/** What the service needs from a session; `Session` satisfies it structurally. */
|
||||
export interface KeepWarmSessionView {
|
||||
readonly id: string;
|
||||
readonly createdAt: number;
|
||||
readonly pid: number | null;
|
||||
/** Set when the agent exited in a pane that stays open (`remain-on-exit`); the pid stays. */
|
||||
readonly paneExit?: unknown;
|
||||
readonly parentSessionId?: string;
|
||||
readonly promptCache: PromptCacheStatus | null;
|
||||
readonly statusLineExporter?: StatusLineExporter;
|
||||
/** Whether this launch carries `idleCompaction: false` (`MuxSession.idleCompactionOff`). */
|
||||
readonly idleCompactionOptOut: boolean;
|
||||
readonly keepWarmEnabled: boolean | undefined;
|
||||
readonly isWorking: boolean;
|
||||
readonly isLimitPaused: boolean;
|
||||
readonly lastHumanInputAt: number;
|
||||
/** Last submit that was not the keep-alive's own (persisted): the cap's clock. */
|
||||
readonly lastRealPromptAt: number;
|
||||
isIdle(): boolean;
|
||||
beginKeepAliveTurn(windowMs: number): void;
|
||||
/** Close the window `beginKeepAliveTurn()` opened, when the nudge never went out. */
|
||||
endKeepAliveTurn(): void;
|
||||
writeViaMux(data: string): Promise<boolean>;
|
||||
}
|
||||
|
||||
export interface KeepWarmSettings {
|
||||
/** App-wide default for sessions that have not chosen (`promptCacheKeepWarm`). */
|
||||
keepWarmDefault: boolean;
|
||||
/** Cap in hours after the last real prompt (`promptCacheKeepWarmMaxHours`). */
|
||||
maxHours: number;
|
||||
}
|
||||
|
||||
export interface KeepWarmPorts {
|
||||
getSession(id: string): KeepWarmSessionView | undefined;
|
||||
sessionIds(): Iterable<string>;
|
||||
readSettings(): Promise<KeepWarmSettings>;
|
||||
isAutomationActive(id: string): boolean;
|
||||
hasPendingApproval(id: string): boolean;
|
||||
/**
|
||||
* Read the pane once a nudge is otherwise due: whether the composer is empty (null when it
|
||||
* cannot be read), and whether a usage-limit message with a reset still ahead is on screen
|
||||
* (auto-resume's own pause tracking only runs while auto-resume is on).
|
||||
*/
|
||||
readPane(session: KeepWarmSessionView): { composerEmpty: boolean | null; limitShown: boolean };
|
||||
/** Store and broadcast the status (the server dedupes against the session's last one). */
|
||||
publish(id: string, status: KeepWarmStatus): void;
|
||||
log?(message: string): void;
|
||||
}
|
||||
|
||||
/** The scheduler the routes see: one method per trigger, nothing else. */
|
||||
export interface KeepWarmScheduler {
|
||||
/** Re-evaluate a session: after a cache report, a flag change, or a settings change. Resolves once the status is published. */
|
||||
schedule(sessionId: string): Promise<void>;
|
||||
/** Re-read the app-wide default and cap, then re-evaluate every session (either one changed). */
|
||||
rescheduleAll(): void;
|
||||
/** The session is gone. */
|
||||
remove(sessionId: string): void;
|
||||
}
|
||||
|
||||
export class PromptCacheKeepWarmService implements KeepWarmScheduler {
|
||||
private readonly timers = new Map<string, NodeJS.Timeout>();
|
||||
private readonly tallies = new Map<string, { nudges: number; lastNudgeAt?: number }>();
|
||||
/** One evaluation per session; `remove()` and `stop()` cancel it at its next await. */
|
||||
private readonly inFlight = new Map<string, symbol>();
|
||||
/** The evaluation running per session, and the one follow-up queued behind it. */
|
||||
private readonly running = new Map<string, Promise<void>>();
|
||||
private readonly followUps = new Map<string, Promise<void>>();
|
||||
/**
|
||||
* The default and cap, read once and kept: every statusline report schedules an evaluation,
|
||||
* and reading settings.json on each would cost every install, the feature off included.
|
||||
* `rescheduleAll()` drops it, and the settings PUT calls that whenever either key changes.
|
||||
*/
|
||||
private settings: Promise<KeepWarmSettings> | undefined;
|
||||
/** The last settings read failure logged, so one that persists is logged once; cleared by a good read. */
|
||||
private settingsFailure: string | undefined;
|
||||
private stopped = false;
|
||||
|
||||
constructor(private readonly ports: KeepWarmPorts) {}
|
||||
|
||||
schedule(sessionId: string): Promise<void> {
|
||||
if (this.stopped) return Promise.resolve();
|
||||
const running = this.running.get(sessionId);
|
||||
if (running) {
|
||||
// A trigger landing mid-evaluation (a toggle while the nudge is written, a settings change)
|
||||
// carries what that evaluation read too early, so one follow-up runs after it, shared by
|
||||
// every trigger that arrives meanwhile.
|
||||
let next = this.followUps.get(sessionId);
|
||||
if (!next) {
|
||||
next = running.then(() => {
|
||||
// `remove()` drops the queued follow-up: a deleted session gets no evaluation.
|
||||
if (this.followUps.get(sessionId) !== next) return;
|
||||
this.followUps.delete(sessionId);
|
||||
return this.schedule(sessionId);
|
||||
});
|
||||
this.followUps.set(sessionId, next);
|
||||
}
|
||||
return next;
|
||||
}
|
||||
const run = this.evaluate(sessionId)
|
||||
.catch((err) => {
|
||||
this.ports.log?.(`[KeepWarm] ${sessionId.slice(0, 8)}: ${err instanceof Error ? err.message : String(err)}`);
|
||||
// A failed write or pane read is transient, and an idle session gets no other trigger
|
||||
// until its next turn. (A failed settings read is handled in evaluate().)
|
||||
// Not after `remove()`, which drops this run: the session is being deleted.
|
||||
if (!this.stopped && this.running.get(sessionId) === run) this.arm(sessionId, Date.now() + KEEP_WARM_RETRY_MS);
|
||||
})
|
||||
.finally(() => {
|
||||
if (this.running.get(sessionId) === run) this.running.delete(sessionId);
|
||||
});
|
||||
this.running.set(sessionId, run);
|
||||
return run;
|
||||
}
|
||||
|
||||
rescheduleAll(): void {
|
||||
this.settings = undefined;
|
||||
for (const id of this.ports.sessionIds()) void this.schedule(id);
|
||||
}
|
||||
|
||||
remove(sessionId: string): void {
|
||||
this.clearTimer(sessionId);
|
||||
this.tallies.delete(sessionId);
|
||||
this.inFlight.delete(sessionId);
|
||||
this.running.delete(sessionId);
|
||||
this.followUps.delete(sessionId);
|
||||
}
|
||||
|
||||
stop(): void {
|
||||
this.stopped = true;
|
||||
for (const id of [...this.timers.keys()]) this.clearTimer(id);
|
||||
}
|
||||
|
||||
private readSettings(): Promise<KeepWarmSettings> {
|
||||
if (!this.settings) {
|
||||
const read = this.ports.readSettings();
|
||||
this.settings = read;
|
||||
// A failed read is not kept: the next evaluation tries again.
|
||||
read.catch(() => {
|
||||
if (this.settings === read) this.settings = undefined;
|
||||
});
|
||||
}
|
||||
return this.settings;
|
||||
}
|
||||
|
||||
private clearTimer(id: string): void {
|
||||
const t = this.timers.get(id);
|
||||
if (t) clearTimeout(t);
|
||||
this.timers.delete(id);
|
||||
}
|
||||
|
||||
private arm(id: string, at: number): void {
|
||||
this.clearTimer(id);
|
||||
const t = setTimeout(() => void this.schedule(id), Math.min(Math.max(0, at - Date.now()), MAX_TIMER_MS));
|
||||
t.unref?.();
|
||||
this.timers.set(id, t);
|
||||
}
|
||||
|
||||
private async evaluate(id: string): Promise<void> {
|
||||
if (this.stopped || this.inFlight.has(id)) return;
|
||||
const token = Symbol(id);
|
||||
this.inFlight.set(id, token);
|
||||
// A session removed (or the service stopped) during an await gets nothing after it: no
|
||||
// nudge, no tally, no timer, no status.
|
||||
const cancelled = () => this.stopped || this.inFlight.get(id) !== token;
|
||||
try {
|
||||
this.clearTimer(id);
|
||||
const session = this.ports.getSession(id);
|
||||
if (!session) return;
|
||||
let settings: KeepWarmSettings;
|
||||
try {
|
||||
settings = await this.readSettings();
|
||||
} catch (err) {
|
||||
// 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;
|
||||
this.ports.log?.(`[KeepWarm] ${id.slice(0, 8)}: cannot read the settings: ${message}`);
|
||||
}
|
||||
if (!cancelled() && couldNudge(session)) this.arm(id, Date.now() + KEEP_WARM_RETRY_MS);
|
||||
return;
|
||||
}
|
||||
this.settingsFailure = undefined;
|
||||
if (cancelled()) return;
|
||||
const tally = this.tallies.get(id) ?? { nudges: 0 };
|
||||
const facts: KeepWarmFacts = {
|
||||
now: Date.now(),
|
||||
choice: session.keepWarmEnabled,
|
||||
appDefault: settings.keepWarmDefault,
|
||||
spawnedByAgent: !!session.parentSessionId,
|
||||
statusLine: session.statusLineExporter,
|
||||
cache: session.promptCache,
|
||||
idle: session.isIdle() && !session.isWorking,
|
||||
paneAlive: session.pid !== null && !session.paneExit,
|
||||
limitPaused: session.isLimitPaused,
|
||||
automationActive: this.ports.isAutomationActive(id),
|
||||
pendingApproval: this.ports.hasPendingApproval(id),
|
||||
// Optimistic until everything else says "due": the pane read is the one costly fact.
|
||||
composerEmpty: true,
|
||||
lastHumanInputAt: session.lastHumanInputAt,
|
||||
budgetAnchorAt: session.lastRealPromptAt || session.createdAt,
|
||||
maxKeepWarmMs: settings.maxHours * 3_600_000,
|
||||
};
|
||||
let decision = decideKeepWarm(facts);
|
||||
if (decision.action === 'nudge') {
|
||||
const pane = this.ports.readPane(session);
|
||||
decision = decideKeepWarm({
|
||||
...facts,
|
||||
composerEmpty: pane.composerEmpty,
|
||||
limitPaused: facts.limitPaused || pane.limitShown,
|
||||
});
|
||||
}
|
||||
const enabled = keepWarmOn(facts.choice, facts.appDefault, facts.spawnedByAgent);
|
||||
const base = {
|
||||
enabled,
|
||||
idleCompactionOff: session.idleCompactionOptOut,
|
||||
nudges: tally.nudges,
|
||||
lastNudgeAt: tally.lastNudgeAt,
|
||||
// Off, the window means nothing, and it moves with every real prompt: carrying it would
|
||||
// broadcast a change on every turn of every session.
|
||||
...(enabled && { budgetEndsAt: facts.budgetAnchorAt + facts.maxKeepWarmMs }),
|
||||
};
|
||||
if (decision.action === 'nudge') {
|
||||
session.beginKeepAliveTurn(KEEP_WARM_TURN_WINDOW_MS);
|
||||
const ok = await session.writeViaMux(`${KEEP_WARM_NUDGE}\r`);
|
||||
if (cancelled()) return;
|
||||
if (ok) {
|
||||
tally.nudges += 1;
|
||||
tally.lastNudgeAt = Date.now();
|
||||
this.tallies.set(id, tally);
|
||||
}
|
||||
this.ports.log?.(
|
||||
`[KeepWarm] ${ok ? 'nudged' : 'failed to nudge'} ${id.slice(0, 8)} with ${facts.cache?.recacheTokensIfCold ?? '?'} tokens at stake (nudge ${tally.nudges})`
|
||||
);
|
||||
// Sent: the nudge's own cache report re-schedules, and this timer is the fallback if none
|
||||
// arrives. Not sent: no turn is running, so the window closes now rather than at its
|
||||
// ceiling, and the retry lands inside the last minutes before the expiry, where a nudge
|
||||
// can still go out (a lead time later would be past it).
|
||||
let nextCheckAt = Date.now() + KEEP_WARM_LEAD_MS;
|
||||
if (!ok) {
|
||||
session.endKeepAliveTurn();
|
||||
const expiresAt = facts.cache?.expiresAt ?? 0;
|
||||
nextCheckAt = Math.min(Date.now() + KEEP_WARM_RETRY_MS, expiresAt - KEEP_WARM_LAST_CHANCE_MS);
|
||||
}
|
||||
this.arm(id, nextCheckAt);
|
||||
this.ports.publish(id, {
|
||||
...base,
|
||||
nudges: tally.nudges,
|
||||
lastNudgeAt: tally.lastNudgeAt,
|
||||
state: ok ? 'nudged' : 'waiting',
|
||||
nextCheckAt,
|
||||
reason: ok ? undefined : 'the keep-alive could not be written',
|
||||
});
|
||||
} else if (decision.action === 'wait') {
|
||||
this.arm(id, decision.until);
|
||||
this.ports.publish(id, {
|
||||
...base,
|
||||
state: decision.reason === 'not due yet' ? 'armed' : 'waiting',
|
||||
nextCheckAt: decision.until,
|
||||
reason: decision.reason === 'not due yet' ? undefined : decision.reason,
|
||||
});
|
||||
} else {
|
||||
this.ports.publish(id, { ...base, state: enabled ? 'stopped' : 'off', reason: decision.reason });
|
||||
}
|
||||
} finally {
|
||||
if (this.inFlight.get(id) === token) this.inFlight.delete(id);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -203,6 +203,9 @@ export class RalphFixPlanWatcher extends EventEmitter {
|
||||
/** Path to the @fix_plan.md file being watched */
|
||||
private _fixPlanPath: string | null = null;
|
||||
|
||||
/** The path is on a REMOTE host (`setRemoteWorkingDir`): never read or watch it locally. */
|
||||
private _remote = false;
|
||||
|
||||
/** File watcher for @fix_plan.md */
|
||||
private _fixPlanWatcher: FSWatcher | null = null;
|
||||
|
||||
@@ -238,6 +241,7 @@ export class RalphFixPlanWatcher extends EventEmitter {
|
||||
* @param workingDir - The session's working directory
|
||||
*/
|
||||
setWorkingDir(workingDir: string): void {
|
||||
this._remote = false;
|
||||
this._workingDir = workingDir;
|
||||
this._fixPlanPath = join(workingDir, '@fix_plan.md');
|
||||
|
||||
@@ -248,12 +252,28 @@ export class RalphFixPlanWatcher extends EventEmitter {
|
||||
this.startWatchingFixPlan();
|
||||
}
|
||||
|
||||
/**
|
||||
* Record a REMOTE session's working directory (a path on the remote host) without reading or
|
||||
* watching it: the local disk holds nothing of it. The fix plan stays authoritative exactly as for a
|
||||
* local session, so the output-based completion guessers stay off (they are off for every session
|
||||
* with a working directory); the file reaches the tracker only through an explicit import
|
||||
* (`POST .../fix-plan/read`, which reads it over ssh).
|
||||
* @param workingDir - The session's working directory on the remote host
|
||||
*/
|
||||
setRemoteWorkingDir(workingDir: string): void {
|
||||
this.stopWatchingFixPlan();
|
||||
this._remote = true;
|
||||
this._workingDir = workingDir;
|
||||
this._fixPlanPath = join(workingDir, '@fix_plan.md');
|
||||
}
|
||||
|
||||
/**
|
||||
* Load @fix_plan.md from disk if it exists.
|
||||
* Called on initialization and when file changes are detected.
|
||||
* Called on initialization and when file changes are detected. A remote path is never read here:
|
||||
* the local disk would hold a missing path or an unrelated same-named file.
|
||||
*/
|
||||
async loadFixPlanFromDisk(): Promise<number> {
|
||||
if (!this._fixPlanPath) return 0;
|
||||
if (!this._fixPlanPath || this._remote) return 0;
|
||||
|
||||
try {
|
||||
if (!existsSync(this._fixPlanPath)) {
|
||||
@@ -281,7 +301,7 @@ export class RalphFixPlanWatcher extends EventEmitter {
|
||||
* Reloads todos when the file is modified.
|
||||
*/
|
||||
private startWatchingFixPlan(): void {
|
||||
if (!this._fixPlanPath || !this._workingDir) return;
|
||||
if (!this._fixPlanPath || !this._workingDir || this._remote) return;
|
||||
|
||||
// Stop existing watcher if any
|
||||
this.stopWatchingFixPlan();
|
||||
|
||||
@@ -717,6 +717,15 @@ export class RalphTracker extends EventEmitter {
|
||||
this.fixPlanWatcher.setWorkingDir(workingDir);
|
||||
}
|
||||
|
||||
/**
|
||||
* Record a remote session's working directory without touching the local disk (see
|
||||
* `RalphFixPlanWatcher.setRemoteWorkingDir`): the fix plan stays authoritative, as for a local session.
|
||||
* @param workingDir - The session's working directory on the remote host
|
||||
*/
|
||||
setRemoteWorkingDir(workingDir: string): void {
|
||||
this.fixPlanWatcher.setRemoteWorkingDir(workingDir);
|
||||
}
|
||||
|
||||
/**
|
||||
* Load @fix_plan.md from disk if it exists.
|
||||
*/
|
||||
|
||||
@@ -135,6 +135,10 @@ export function buildRemoteFileCommand(remote: SessionRemote, shellCommand: stri
|
||||
* `echo` (which land before the script runs) from the records without any "last N
|
||||
* lines" guesswork. `realPath` is the last field, so a `|` in a path still parses.
|
||||
*
|
||||
* A path starting with `~/` is taken relative to the remote user's `$HOME`: this
|
||||
* server does not know that home, and a clicked `~/…` link means the host the
|
||||
* session runs on. The record's `realPath` is absolute, as for any other path.
|
||||
*
|
||||
* Symlink resolution is portable AND fails closed. `readlink -f` where available
|
||||
* (Linux, macOS >= 12.3); otherwise the fallback canonicalizes the directory chain
|
||||
* with `cd -P`/`pwd -P` and then follows the LAST component with plain `readlink`
|
||||
@@ -166,6 +170,8 @@ export function buildRemoteProbeCommand(paths: readonly string[]): string {
|
||||
'probe() {',
|
||||
' i=$1',
|
||||
' p=$2',
|
||||
// `~/…` from a clicked link: THIS host's home (assignment, so no word splitting).
|
||||
" case $p in '~/'*) p=$HOME/${p#??} ;; esac",
|
||||
` if [ ! -e "$p" ]; then printf '%s|${NOT_FOUND_MARKER}\\0' "$i"; return; fi`,
|
||||
` r=$(readlink -f "$p" 2>/dev/null) || r=$(resolve_last "$p") || { printf '%s|${UNRESOLVABLE_MARKER}\\0' "$i"; return; }`,
|
||||
` [ -n "$r" ] || { printf '%s|${UNRESOLVABLE_MARKER}\\0' "$i"; return; }`,
|
||||
@@ -269,6 +275,126 @@ export async function remoteProbePaths(
|
||||
return results;
|
||||
}
|
||||
|
||||
/** One entry of a remote folder listing: a file (with size/mtime) or a subfolder. */
|
||||
export interface RemoteFolderEntry {
|
||||
kind: 'file' | 'directory';
|
||||
name: string;
|
||||
size: number;
|
||||
mtimeMs: number;
|
||||
}
|
||||
|
||||
/** A remote folder listing; `dir` is the folder with symlinks resolved on the remote host. */
|
||||
export interface RemoteFolderListing {
|
||||
dir: string;
|
||||
entries: RemoteFolderEntry[];
|
||||
truncated: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* List one remote folder in a SINGLE ssh round trip: its canonical path, then every
|
||||
* non-hidden subfolder and every regular file (symlinks followed) whose name ends in
|
||||
* one of `extensions` (any case), up to `maxEntries`.
|
||||
*
|
||||
* Same output discipline as {@link buildRemoteProbeCommand}: a lone NUL first (so rc
|
||||
* chatter before it is discarded), then NUL-terminated records — `E|n` (no such
|
||||
* folder), `E|x` (cannot be canonicalized), `D|<dir>`, `d|0|0|<name>`,
|
||||
* `f|<size>|<mtime>|<name>`, and `T|` when the cap cut the listing. The name is the
|
||||
* last field, so a `|` in it still parses, and NUL is the one byte a filename cannot
|
||||
* hold. A leading `~/` is the remote user's `$HOME`, as in the probe.
|
||||
*/
|
||||
export function buildRemoteFolderListCommand(dir: string, extensions: readonly string[], maxEntries: number): string {
|
||||
// `[pP][nN][gG]`: any case, as the local listing compares lowercased (`shot.Png`).
|
||||
const patterns = [...new Set(extensions.map((ext) => ext.toLowerCase()))]
|
||||
.map((ext) => `*.${ext.replace(/[a-z]/g, (c) => `[${c}${c.toUpperCase()}]`)}`)
|
||||
.join('|');
|
||||
const script = [
|
||||
`d=${shellescape(dir)}`,
|
||||
"case $d in '~/'*) d=$HOME/${d#??} ;; esac",
|
||||
"printf '\\0'",
|
||||
'[ -d "$d" ] || { [ -e "$d" ] && printf \'E|f\\0\' || printf \'E|n\\0\'; exit 0; }',
|
||||
'r=$(cd -P "$d" 2>/dev/null && pwd -P) || { printf \'E|x\\0\'; exit 0; }',
|
||||
'printf \'D|%s\\0\' "$r"',
|
||||
'n=0',
|
||||
'for f in "$r"/*; do',
|
||||
' [ -e "$f" ] || continue',
|
||||
` [ "$n" -lt ${Math.max(1, Math.floor(maxEntries))} ] || { printf 'T|\\0'; break; }`,
|
||||
' b=${f##*/}',
|
||||
' if [ -d "$f" ]; then n=$((n + 1)); printf \'d|0|0|%s\\0\' "$b"; continue; fi',
|
||||
' [ -f "$f" ] || continue',
|
||||
` case $b in ${patterns || '*.__none__'}) ;; *) continue ;; esac`,
|
||||
" sm=$(stat -L -c '%s %Y' \"$f\" 2>/dev/null || stat -L -f '%z %m' \"$f\" 2>/dev/null) || sm='0 0'",
|
||||
' n=$((n + 1))',
|
||||
' printf \'f|%s|%s|%s\\0\' "${sm% *}" "${sm#* }" "$b"',
|
||||
'done',
|
||||
].join('\n');
|
||||
// ⚠️ sshd runs the command in the remote user's LOGIN shell, rc aliases included.
|
||||
// On Hufflepuff `cd` is an alias that echoes the new directory (`$(cd -P "$d" &&
|
||||
// pwd -P)` came back as two lines, then zsh aborted on the glob: "no matches found:
|
||||
// /x\n/x/*"), so no folder listing worked over ssh. An explicit `sh` reads no rc
|
||||
// file — the same wrapper the transcript read uses.
|
||||
return `sh -c ${shellescape(script)}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse {@link buildRemoteFolderListCommand} output: null when nothing exists there
|
||||
* (or it cannot be resolved), `'not-a-folder'` when it is a file.
|
||||
*/
|
||||
export function parseRemoteFolderListOutput(stdout: string): RemoteFolderListing | 'not-a-folder' | null {
|
||||
const records = stdout.split('\0').slice(1);
|
||||
let dir: string | null = null;
|
||||
let truncated = false;
|
||||
const entries: RemoteFolderEntry[] = [];
|
||||
for (const record of records) {
|
||||
if (record === 'E|f') return 'not-a-folder';
|
||||
if (record.startsWith('E|')) return null;
|
||||
if (record.startsWith('D|')) {
|
||||
dir = record.slice(2);
|
||||
continue;
|
||||
}
|
||||
if (record === 'T|') {
|
||||
truncated = true;
|
||||
continue;
|
||||
}
|
||||
const match = /^([fd])\|(\d*)\|(\d*)\|([\s\S]+)$/.exec(record);
|
||||
if (!match) continue;
|
||||
const size = Number.parseInt(match[2], 10);
|
||||
const mtime = Number.parseInt(match[3], 10);
|
||||
entries.push({
|
||||
kind: match[1] === 'd' ? 'directory' : 'file',
|
||||
name: match[4],
|
||||
size: Number.isFinite(size) && size > 0 ? size : 0,
|
||||
mtimeMs: Number.isFinite(mtime) && mtime > 0 ? mtime * 1000 : 0,
|
||||
});
|
||||
}
|
||||
if (!dir) {
|
||||
throw new RemoteFileAccessError('remote host returned no usable folder listing');
|
||||
}
|
||||
return { dir, entries, truncated };
|
||||
}
|
||||
|
||||
/** List a remote folder (see {@link buildRemoteFolderListCommand}). */
|
||||
export async function remoteListFolder(
|
||||
remote: SessionRemote,
|
||||
dir: string,
|
||||
extensions: readonly string[],
|
||||
maxEntries: number
|
||||
): Promise<RemoteFolderListing | 'not-a-folder' | null> {
|
||||
assertNotUnderTest();
|
||||
const command = buildRemoteFileCommand(remote, buildRemoteFolderListCommand(dir, extensions, maxEntries));
|
||||
let stdout: string;
|
||||
try {
|
||||
const result = await runWithRemoteSshLimit(() =>
|
||||
execAsync(command, { timeout: REMOTE_PROBE_TIMEOUT_MS, maxBuffer: 4 * 1024 * 1024 })
|
||||
);
|
||||
stdout = result.stdout;
|
||||
} catch (err) {
|
||||
throw new RemoteFileAccessError(
|
||||
`remote host ${remote.label || remote.host} unreachable: ${describeExecError(err)}`
|
||||
);
|
||||
}
|
||||
return parseRemoteFolderListOutput(stdout);
|
||||
}
|
||||
|
||||
/** Read a whole remote file into memory, capped by `maxBytes`. */
|
||||
export async function remoteReadFile(remote: SessionRemote, remotePath: string, maxBytes: number): Promise<Buffer> {
|
||||
assertNotUnderTest();
|
||||
|
||||
+20
-3
@@ -89,6 +89,19 @@ export function remoteLoginShellCommand(command: string): string {
|
||||
return `exec ${REMOTE_LOGIN_SHELL} -i -l -c ${shellescape(command)}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Remote tmux runs in sshd's non-interactive shell, which may omit Homebrew and
|
||||
* per-user binaries. Keep every remote tmux operation on the same PATH. Quoting
|
||||
* the entire assignment preserves spaces in HOME/inherited PATH under dash too.
|
||||
* CLI commands still use their existing login-shell/configuration handling.
|
||||
*/
|
||||
export function withRemoteTmuxPath(command: string): string {
|
||||
return (
|
||||
'export PATH="$HOME/.opencode/bin:$HOME/.local/bin:$HOME/bin:/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin:$PATH"; ' +
|
||||
command
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* The CLI text a location overlay should launch for `mode`, or null when this build has no
|
||||
* entry for it. `overlays.<location>.command` when the entry names one, otherwise the bare
|
||||
@@ -228,7 +241,11 @@ export function buildRemoteTmuxCheckCommand(
|
||||
host: Pick<RemoteHost, 'username' | 'host' | 'port'> & RemoteSshOptions
|
||||
): string {
|
||||
// ConnectTimeout is now a default of buildSshConnectionArgs (shared with the launch).
|
||||
return [...buildSshConnectionArgs(host), remoteSshTarget(host), "'command -v tmux'"].join(' ');
|
||||
return [
|
||||
...buildSshConnectionArgs(host),
|
||||
remoteSshTarget(host),
|
||||
shellescape(withRemoteTmuxPath('command -v tmux')),
|
||||
].join(' ');
|
||||
}
|
||||
|
||||
export interface RemoteTmuxCheckResult {
|
||||
@@ -368,7 +385,7 @@ export function buildRemoteSessionAliveCommand(
|
||||
): string {
|
||||
const [ssh, ...connectionArgs] = buildSshConnectionArgs(host);
|
||||
const remoteCmd = `tmux -L codeman-remote has-session -t ${shellescape(remoteSessionName)} 2>/dev/null`;
|
||||
return [ssh, ...connectionArgs, remoteSshTarget(host), shellescape(remoteCmd)].join(' ');
|
||||
return [ssh, ...connectionArgs, remoteSshTarget(host), shellescape(withRemoteTmuxPath(remoteCmd))].join(' ');
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -453,7 +470,7 @@ export function buildRemoteListSessionsCommand(
|
||||
// separators (tmux expands them); `2>/dev/null` is inside the quoted command.
|
||||
const remoteCmd =
|
||||
'tmux -L codeman list-sessions -F "#{session_name}\\t#{session_attached}\\t#{session_created}\\t#{session_windows}" 2>/dev/null';
|
||||
parts.push(remoteSshTarget(host), shellescape(remoteCmd));
|
||||
parts.push(remoteSshTarget(host), shellescape(withRemoteTmuxPath(remoteCmd)));
|
||||
return parts.join(' ');
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,307 @@
|
||||
/**
|
||||
* @fileoverview Scrollback of a remote SSH session, read from the REMOTE tmux.
|
||||
*
|
||||
* A remote session's local pane runs `ssh -t … tmux new-session -A` (or `attach`),
|
||||
* so the local tmux only ever sees what the remote tmux CLIENT draws: full-screen
|
||||
* repaints, not scrolling output. The local pane therefore keeps a fraction of the
|
||||
* history (measured: 48 of 279 lines while output streamed, none after a reattach),
|
||||
* while the remote pane, where the shell really runs, keeps all of it. A full-history
|
||||
* load of a remote session reads the scrollback from there instead, and the caller
|
||||
* splices it above the LOCAL visible frame (`PaneCaptureOptions.scrollbackOverride`):
|
||||
* the local pane is what the live stream keeps drawing into, so its frame and caret
|
||||
* position are the ones the browser must end up with.
|
||||
*
|
||||
* ONE ssh round trip reads the remote pane's scrollback rows (`capture-pane -S -<N>
|
||||
* -E -1`), its `#{history_size}` and its screen. The size is load-bearing: with no
|
||||
* history at all, tmux clamps `-E -1` to the first VISIBLE row and would return it
|
||||
* as history. The screen closes the seam between the remote read and the local
|
||||
* frame, which is read only after the round trip: rows that scrolled in between,
|
||||
* and the top of a remote screen taller than the local pane, are on that screen
|
||||
* alone, and the caller keeps the screen rows above where the local frame overlaps
|
||||
* it (`joinScrollbackAndFrame`).
|
||||
*
|
||||
* ⚠️ The three reads are ONE tmux command list (`a \; b \; c`), not three tmux
|
||||
* runs. tmux runs a client's command list back to back without reading pane output
|
||||
* in between, so the history ends exactly where the screen begins. As separate runs
|
||||
* the shell kept writing between them, and the rows that scrolled in those
|
||||
* milliseconds were in neither capture (measured on a busy pane: every read lost
|
||||
* rows). This rests on how tmux's command queue behaves, not on a documented
|
||||
* guarantee. tmux output cannot carry the NUL markers, so a random marker line
|
||||
* printed by `display-message` separates the history from the size and screen.
|
||||
* tmux before 3.2 prints no newline after `capture-pane -p`, so the marker can be
|
||||
* glued onto an unterminated last history row; the parser accepts it there too.
|
||||
*
|
||||
* Bounded and fail-soft. Any failure (unreachable host, auth, missing session, odd
|
||||
* output) returns null and the caller keeps the local capture, so a remote load is
|
||||
* never worse than before. A transport failure also starts a short per-host back-off,
|
||||
* so a host that is asleep costs one timeout, not one per tab switch. The child runs
|
||||
* under the shared remote-ssh limiter (`remote-ssh-limiter.ts`) like every other
|
||||
* short-lived remote ssh, but gives up on a slot it waited too long for (`maxWaitMs`,
|
||||
* enforced in the queue), and the connection options come from
|
||||
* `buildSshConnectionArgs()`.
|
||||
*
|
||||
* How much history exists is the REMOTE tmux's `history-limit` (2000 lines unless
|
||||
* the remote user's tmux.conf raises it); the launch command does not set it.
|
||||
*/
|
||||
|
||||
import { exec } from 'node:child_process';
|
||||
import { randomBytes } from 'node:crypto';
|
||||
import { promisify } from 'node:util';
|
||||
import type { SessionRemote } from './types/session.js';
|
||||
import {
|
||||
buildSshConnectionArgs,
|
||||
classifyRemoteAliveExit,
|
||||
remoteSshTarget,
|
||||
shellescape,
|
||||
withRemoteTmuxPath,
|
||||
} from './remote-hosts.js';
|
||||
import { RemoteSshSlotTimeoutError, runWithRemoteSshLimit } from './remote-ssh-limiter.js';
|
||||
import { remoteTmuxLocation } from './tmux-manager.js';
|
||||
|
||||
const execAsync = promisify(exec);
|
||||
|
||||
/**
|
||||
* Bound on the whole round trip. A full load waits on it, so it is far below the
|
||||
* 10 s `ConnectTimeout` the shared ssh options carry: past this the local capture
|
||||
* is the better answer.
|
||||
*/
|
||||
const REMOTE_HISTORY_TIMEOUT_MS = 5_000;
|
||||
|
||||
/**
|
||||
* Longest a load waits for a slot in the shared ssh pool, which file previews and
|
||||
* attachment probes (20 s each) also hold. Past it the local capture is used.
|
||||
*/
|
||||
const REMOTE_HISTORY_SLOT_WAIT_MS = 1_500;
|
||||
|
||||
/** After a failed read, skip that host for this long and use the local capture. */
|
||||
const REMOTE_HISTORY_BACKOFF_MS = 30_000;
|
||||
|
||||
/**
|
||||
* Headroom over the remote `tail -c` bound for what it does not cover: the two NUL
|
||||
* markers and anything a login banner or rc file prints before the script.
|
||||
*/
|
||||
const REMOTE_HISTORY_SLACK_BYTES = 64 * 1024;
|
||||
|
||||
/**
|
||||
* Added to the configured byte cap for the remote `tail -c` bound. That bound
|
||||
* covers the whole tmux output, and the marker, the size and the screen come last
|
||||
* (a wide, tall screen of colored rows runs to a few hundred KB); without this, a
|
||||
* small cap would cut into the screen and lose the read. The route trims what it
|
||||
* returns to the configured cap anyway, keeping the newest bytes.
|
||||
*/
|
||||
const REMOTE_SCREEN_ALLOWANCE_BYTES = 1024 * 1024;
|
||||
|
||||
/** The remote `tail -c` bound for a configured byte cap; 0 (unbounded) stays 0. */
|
||||
export function remoteTailBytes(maxBytes: number): number {
|
||||
return maxBytes > 0 ? Math.trunc(maxBytes) + REMOTE_SCREEN_ALLOWANCE_BYTES : 0;
|
||||
}
|
||||
|
||||
/** Read ceiling when the configured byte cap is 0 (unbounded) and no `tail -c` runs. */
|
||||
const UNBOUNDED_READ_MAX_BYTES = 64 * 1024 * 1024;
|
||||
|
||||
/**
|
||||
* Host id → time the back-off ends. Keyed by HOST, so a sleeping host with five
|
||||
* sessions costs one timeout, and the map is bounded by the configured hosts.
|
||||
*/
|
||||
const backoffUntil = new Map<string, number>();
|
||||
|
||||
/**
|
||||
* A fresh marker line for one read. Random, so no row the pane really holds can be
|
||||
* taken for it.
|
||||
*/
|
||||
export function newScrollbackMarker(): string {
|
||||
return `codeman-history-${randomBytes(8).toString('hex')}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* The remote shell script. It prints a NUL, the tmux output, then a second NUL:
|
||||
* the first separates a login banner or an rc-file `echo` printed before the
|
||||
* script runs without guessing (the same marker `buildRemoteProbeCommand` uses),
|
||||
* and both are printed whether or not tmux succeeds, so their presence says the
|
||||
* script ran on the host. The tmux output is the history rows, `marker`, the
|
||||
* history size, then the screen rows.
|
||||
*/
|
||||
export function buildRemoteScrollbackScript(
|
||||
socket: string,
|
||||
sessionName: string,
|
||||
historyLines: number,
|
||||
tailBytes: number,
|
||||
marker: string
|
||||
): string {
|
||||
// `=name:` is an EXACT session match, then its active pane. A bare name is
|
||||
// read as a pane index or window name first, so a discovered session named
|
||||
// `0` would capture some other session's pane.
|
||||
const target = shellescape(`=${sessionName}:`);
|
||||
const lines = Math.max(1, Math.trunc(historyLines));
|
||||
// The newest rows are the ones worth keeping, so the byte bound is applied on
|
||||
// the remote side (`tail -c`) rather than by killing the read at maxBuffer.
|
||||
// It covers the whole tmux output, history first, so with the screen
|
||||
// allowance in `remoteTailBytes()` a cut lands in the history. 0 means
|
||||
// unbounded, as in the route.
|
||||
const bound = tailBytes > 0 ? ` | tail -c ${Math.trunc(tailBytes)}` : '';
|
||||
// ONE tmux command list, so no pane output is read between the history and
|
||||
// the screen (see the @fileoverview). `\;` reaches tmux as a lone `;`.
|
||||
return (
|
||||
`printf '\\0'; tmux -L ${shellescape(socket)} ` +
|
||||
`capture-pane -p -e -J -S -${lines} -E -1 -t ${target} \\; ` +
|
||||
`display-message -p -t ${target} ${shellescape(marker)} \\; ` +
|
||||
`display-message -p -t ${target} '#{history_size}' \\; ` +
|
||||
`capture-pane -p -e -J -t ${target}${bound}; printf '\\0'`
|
||||
);
|
||||
}
|
||||
|
||||
/** The full ssh command line for {@link buildRemoteScrollbackScript}. */
|
||||
export function buildRemoteScrollbackCommand(
|
||||
remote: SessionRemote,
|
||||
sessionId: string,
|
||||
historyLines: number,
|
||||
tailBytes: number,
|
||||
marker: string
|
||||
): string {
|
||||
const { socket, sessionName } = remoteTmuxLocation(remote, sessionId);
|
||||
const script = withRemoteTmuxPath(buildRemoteScrollbackScript(socket, sessionName, historyLines, tailBytes, marker));
|
||||
// `exec` so a timeout kill reaches ssh itself, not only the local `sh -c`.
|
||||
return ['exec', ...buildSshConnectionArgs(remote), remoteSshTarget(remote), shellescape(script)].join(' ');
|
||||
}
|
||||
|
||||
/** What one round trip read from the remote pane: its scrollback rows, then its screen. */
|
||||
export interface RemoteScrollback {
|
||||
/** History rows above the screen, oldest first; '' when the pane has none. */
|
||||
scrollback: string;
|
||||
/** The screen's rows, read in the same tmux command list, so they begin where the scrollback ends. */
|
||||
screen: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* The scrollback and screen rows from the script's stdout, or null when the
|
||||
* output is not the script's or tmux printed nothing usable (no such session,
|
||||
* or a byte cap so small it cut into the screen). The marker may end an
|
||||
* unterminated history row (tmux before 3.2); when the output reached
|
||||
* `tailBytes`, the history's partial first row is dropped.
|
||||
*/
|
||||
export function parseRemoteScrollbackOutput(
|
||||
stdout: string,
|
||||
marker: string,
|
||||
tailBytes: number
|
||||
): RemoteScrollback | null {
|
||||
const start = stdout.indexOf('\0');
|
||||
if (start === -1) return null;
|
||||
const end = stdout.indexOf('\0', start + 1);
|
||||
if (end === -1) return null;
|
||||
// The marker ends a row, after the history's last row. Usually it is a row of
|
||||
// its own, but tmux before 3.2 prints no newline after `capture-pane -p`, so
|
||||
// when the history ends inside a wrapped line (`-J` leaves it unterminated)
|
||||
// the marker is glued onto that row. It is random per read, so no row the
|
||||
// pane really holds ends with it.
|
||||
const markerRow = `${marker}\n`;
|
||||
const raw = stdout.slice(start + 1, end);
|
||||
const at = raw.indexOf(markerRow);
|
||||
if (at === -1) return null;
|
||||
const sizeStart = at + markerRow.length;
|
||||
const sizeEnd = raw.indexOf('\n', sizeStart);
|
||||
if (sizeEnd === -1) return null;
|
||||
const sizeText = raw.slice(sizeStart, sizeEnd).trim();
|
||||
if (!/^\d+$/.test(sizeText)) return null;
|
||||
const screen = raw.slice(sizeEnd + 1);
|
||||
// With no history at all, tmux clamps `-E -1` to the first visible row.
|
||||
if (Number(sizeText) === 0) return { scrollback: '', screen };
|
||||
// A tail -c cut lands in the history (the bound leaves room for the rest),
|
||||
// mid-row and possibly mid-escape, so the first, partial row is dropped. A
|
||||
// cut exactly on the marker row leaves no history at all.
|
||||
let scrollback = raw.slice(0, at);
|
||||
if (tailBytes > 0 && Buffer.byteLength(raw, 'utf-8') >= tailBytes) {
|
||||
const newline = scrollback.indexOf('\n');
|
||||
scrollback = newline === -1 ? '' : scrollback.slice(newline + 1);
|
||||
}
|
||||
if (scrollback !== '' && !scrollback.endsWith('\n')) scrollback += '\n';
|
||||
return { scrollback, screen };
|
||||
}
|
||||
|
||||
/** Runs the ssh command line and resolves its stdout; rejects like `exec` does. */
|
||||
export type RemoteScrollbackRunner = (command: string, opts: { timeout: number; maxBuffer: number }) => Promise<string>;
|
||||
|
||||
const execRunner: RemoteScrollbackRunner = async (command, opts) =>
|
||||
(await execAsync(command, { encoding: 'utf-8', ...opts })).stdout;
|
||||
|
||||
/**
|
||||
* Read a remote session's scrollback from the remote tmux, or null to keep the
|
||||
* local capture. Never throws.
|
||||
*
|
||||
* Only a TRANSPORT failure backs the host off (ssh's own exit 255, a timeout, a
|
||||
* spawn error, output that is not the script's): those say the host is
|
||||
* unreachable for every session on it. A remote command failing (a session
|
||||
* that is gone: tmux answers nothing between the NULs, and the closing `printf`
|
||||
* hides its exit status) is about this session only, so it returns
|
||||
* null and the next session on that host still tries. Same split as
|
||||
* `classifyRemoteAliveExit()`.
|
||||
*
|
||||
* `run` is a test seam: without it nothing runs under vitest, as in
|
||||
* remote-files.ts / remote-hosts.ts.
|
||||
*/
|
||||
export async function fetchRemoteScrollback(
|
||||
remote: SessionRemote,
|
||||
sessionId: string,
|
||||
historyLines: number,
|
||||
maxBytes: number,
|
||||
run?: RemoteScrollbackRunner
|
||||
): Promise<RemoteScrollback | null> {
|
||||
if (!run && process.env.VITEST) return null;
|
||||
const runner = run ?? execRunner;
|
||||
const until = backoffUntil.get(remote.hostId);
|
||||
if (until !== undefined) {
|
||||
if (Date.now() < until) return null;
|
||||
backoffUntil.delete(remote.hostId);
|
||||
}
|
||||
const marker = newScrollbackMarker();
|
||||
const tailBytes = remoteTailBytes(maxBytes);
|
||||
const command = buildRemoteScrollbackCommand(remote, sessionId, historyLines, tailBytes, marker);
|
||||
try {
|
||||
const stdout = await runWithRemoteSshLimit(
|
||||
() =>
|
||||
runner(command, {
|
||||
timeout: REMOTE_HISTORY_TIMEOUT_MS,
|
||||
maxBuffer: (tailBytes > 0 ? tailBytes : UNBOUNDED_READ_MAX_BYTES) + REMOTE_HISTORY_SLACK_BYTES,
|
||||
}),
|
||||
// The pool is shared with 20 s file probes; past this the local capture
|
||||
// is the better answer than waiting behind them.
|
||||
{ maxWaitMs: REMOTE_HISTORY_SLOT_WAIT_MS }
|
||||
);
|
||||
const parsed = parseRemoteScrollbackOutput(stdout, marker, tailBytes);
|
||||
if (parsed === null) {
|
||||
// Both NULs arrived, so the script ran on the host and only tmux had
|
||||
// nothing to say: no such session. The script's last command is the
|
||||
// closing `printf`, so this comes back as exit 0 rather than tmux's
|
||||
// exit 1, and it is about this session alone. Output without the NULs
|
||||
// is not the script's at all, which says the host is not answering as
|
||||
// expected.
|
||||
const parts = stdout.split('\0');
|
||||
const ranOnHost = parts.length >= 3;
|
||||
if (!ranOnHost) backoffUntil.set(remote.hostId, Date.now() + REMOTE_HISTORY_BACKOFF_MS);
|
||||
// tmux printed something, but not the marker, size and screen in order.
|
||||
const unreadable = ranOnHost && parts[1] !== '';
|
||||
const what = !ranOnHost
|
||||
? 'unexpected output'
|
||||
: unreadable
|
||||
? 'unreadable remote tmux output'
|
||||
: 'no such remote tmux session';
|
||||
console.warn(`[remote-history] ${remote.label}: ${what}; using the local capture`);
|
||||
return null;
|
||||
}
|
||||
return parsed;
|
||||
} catch (err) {
|
||||
// A busy pool says nothing about the host.
|
||||
if (err instanceof RemoteSshSlotTimeoutError) return null;
|
||||
const e = err as { killed?: boolean; code?: unknown };
|
||||
const code = typeof e.code === 'number' ? e.code : null;
|
||||
const transport = classifyRemoteAliveExit(code, e.killed === true) === undefined;
|
||||
if (transport) backoffUntil.set(remote.hostId, Date.now() + REMOTE_HISTORY_BACKOFF_MS);
|
||||
const reason = e.killed ? 'timed out' : code !== null ? `exit ${code}` : 'failed';
|
||||
console.warn(`[remote-history] ${remote.label}: remote scrollback read ${reason}; using the local capture`);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/** Test hook: forget every host back-off. */
|
||||
export function resetRemoteScrollbackBackoff(): void {
|
||||
backoffUntil.clear();
|
||||
}
|
||||
@@ -1,7 +1,9 @@
|
||||
/**
|
||||
* @fileoverview Global concurrency limiter for the short-lived `ssh` children that
|
||||
* remote-case file access spawns (`src/remote-files.ts`: the realpath+stat probe and
|
||||
* the buffered text read).
|
||||
* the buffered text read). A remote session's full-history load reads its scrollback
|
||||
* from the remote tmux through the same pool (`src/remote-pane-history.ts`), with a
|
||||
* bounded wait (`maxWaitMs`) because it has the local capture to fall back on.
|
||||
*
|
||||
* Two paths can fan those out without a human behind each one:
|
||||
*
|
||||
@@ -60,12 +62,35 @@ export function getRemoteSshLimit(): number {
|
||||
return MAX_CONCURRENT_REMOTE_SSH;
|
||||
}
|
||||
|
||||
function acquire(): Promise<void> {
|
||||
/** Thrown by {@link runWithRemoteSshLimit} when `maxWaitMs` passed before a slot freed up. */
|
||||
export class RemoteSshSlotTimeoutError extends Error {
|
||||
constructor(maxWaitMs: number) {
|
||||
super(`No remote ssh slot within ${maxWaitMs} ms`);
|
||||
this.name = 'RemoteSshSlotTimeoutError';
|
||||
}
|
||||
}
|
||||
|
||||
function acquire(maxWaitMs?: number): Promise<void> {
|
||||
if (active < MAX_CONCURRENT_REMOTE_SSH) {
|
||||
active++;
|
||||
return Promise.resolve();
|
||||
}
|
||||
return new Promise<void>((resolve) => waiters.push(resolve));
|
||||
if (maxWaitMs === undefined) return new Promise<void>((resolve) => waiters.push(resolve));
|
||||
return new Promise<void>((resolve, reject) => {
|
||||
const waiter = () => {
|
||||
clearTimeout(timer);
|
||||
resolve();
|
||||
};
|
||||
// A timed-out waiter leaves the queue and is never handed a slot, so it
|
||||
// owns nothing to give back: `active` is untouched on this path.
|
||||
const timer = setTimeout(() => {
|
||||
const index = waiters.indexOf(waiter);
|
||||
if (index === -1) return; // already handed a slot by release()
|
||||
waiters.splice(index, 1);
|
||||
reject(new RemoteSshSlotTimeoutError(maxWaitMs));
|
||||
}, maxWaitMs);
|
||||
waiters.push(waiter);
|
||||
});
|
||||
}
|
||||
|
||||
function release(): void {
|
||||
@@ -78,9 +103,14 @@ function release(): void {
|
||||
}
|
||||
}
|
||||
|
||||
/** Run `task` once an ssh slot is free, releasing the slot afterward. */
|
||||
export async function runWithRemoteSshLimit<T>(task: () => Promise<T>): Promise<T> {
|
||||
await acquire();
|
||||
/**
|
||||
* Run `task` once an ssh slot is free, releasing the slot afterward. With
|
||||
* `maxWaitMs`, give up instead (rejecting with {@link RemoteSshSlotTimeoutError},
|
||||
* `task` never runs) when no slot frees up in time: for a caller that has a
|
||||
* cheaper answer than waiting behind the queue.
|
||||
*/
|
||||
export async function runWithRemoteSshLimit<T>(task: () => Promise<T>, opts: { maxWaitMs?: number } = {}): Promise<T> {
|
||||
await acquire(opts.maxWaitMs);
|
||||
try {
|
||||
return await task();
|
||||
} finally {
|
||||
|
||||
@@ -49,7 +49,13 @@ import { Session } from './session.js';
|
||||
import { AiIdleChecker, type AiCheckState } from './ai-idle-checker.js';
|
||||
import { AiPlanChecker } from './ai-plan-checker.js';
|
||||
import type { TeamWatcher } from './team-watcher.js';
|
||||
import { BufferAccumulator, ANSI_ESCAPE_PATTERN_SIMPLE, assertNever, CleanupManager } from './utils/index.js';
|
||||
import {
|
||||
BufferAccumulator,
|
||||
ANSI_ESCAPE_PATTERN_SIMPLE,
|
||||
assertNever,
|
||||
CleanupManager,
|
||||
toSingleLine,
|
||||
} from './utils/index.js';
|
||||
import { MAX_RESPAWN_BUFFER_SIZE, TRIM_RESPAWN_BUFFER_TO as RESPAWN_BUFFER_TRIM_SIZE } from './config/buffer-limits.js';
|
||||
import {
|
||||
isCompletionMessage,
|
||||
@@ -1621,7 +1627,7 @@ export class RespawnController extends EventEmitter {
|
||||
this.config.interStepDelayMs,
|
||||
async () => {
|
||||
if (this._state === 'stopped') return;
|
||||
const prompt = this.config.kickstartPrompt!;
|
||||
const prompt = toSingleLine(this.config.kickstartPrompt!);
|
||||
this.logAction('command', `Sending kickstart: "${prompt.substring(0, 40)}..."`);
|
||||
await this.session.writeViaMux(prompt + '\r'); // \r triggers key.return in Ink/Claude CLI
|
||||
// COD-51: stop() may have run during the await; re-check before reviving the
|
||||
@@ -2826,12 +2832,13 @@ export class RespawnController extends EventEmitter {
|
||||
|
||||
// Use RALPH_STATUS RECOMMENDATION if available, otherwise fall back to config
|
||||
const statusBlock = this.session.ralphTracker?.lastStatusBlock;
|
||||
let updatePrompt = this.config.updatePrompt;
|
||||
let updatePrompt = toSingleLine(this.config.updatePrompt);
|
||||
|
||||
if (statusBlock?.recommendation) {
|
||||
// Append RECOMMENDATION to the update prompt (single-line — writeViaMux breaks on newlines)
|
||||
// Append RECOMMENDATION to the update prompt, on the same single line
|
||||
// (see toSingleLine)
|
||||
const rec = statusBlock.recommendation.replace(/\n/g, ' ').substring(0, 200);
|
||||
updatePrompt = `${this.config.updatePrompt} (Claude's last recommendation: ${rec})`;
|
||||
updatePrompt = `${updatePrompt} (Claude's last recommendation: ${rec})`;
|
||||
this.logAction('ralph', `Using RECOMMENDATION: ${rec.substring(0, 50)}...`);
|
||||
}
|
||||
|
||||
|
||||
@@ -167,6 +167,18 @@ function overwrite<K extends keyof UnifiedSessionItem>(
|
||||
if (value !== undefined) item[key] = value;
|
||||
}
|
||||
|
||||
/** `<repo>/.claude/worktrees/<name>`: Claude Code's `--worktree` layout, which Codeman's own worktrees use too. */
|
||||
const WORKTREE_PATH = /^(.+)\/\.claude\/worktrees\/([^/]+)\/?$/;
|
||||
|
||||
/**
|
||||
* The worktree a working directory is, by its layout alone: `{ repo, name }` for
|
||||
* `<repo>/.claude/worktrees/<name>` (the folder itself, not a subfolder of it), else null.
|
||||
*/
|
||||
export function worktreeFromPath(workingDir: string): { repo: string; name: string } | null {
|
||||
const m = WORKTREE_PATH.exec(workingDir);
|
||||
return m ? { repo: m[1], name: m[2] } : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Merge all source views into one list, applying precedence
|
||||
* (history → lifecycle → persisted → live) and the meaningfulness floor.
|
||||
@@ -326,6 +338,17 @@ export function mergeUnifiedSessions(sources: UnifiedSources): UnifiedSessionIte
|
||||
if (byDir) item.lastPrompt = byDir.prompt;
|
||||
}
|
||||
}
|
||||
// A live or persisted session started in a worktree Codeman created (or Claude Code's own
|
||||
// `--worktree`) has no transcript record naming it until claude writes one, and a codex or
|
||||
// shell session never gets one, so its row read like any other session of the repo. The
|
||||
// folder layout says which worktree it is; a transcript record, when there is one, still wins.
|
||||
if (!item.worktreeName && item.workingDir) {
|
||||
const wt = worktreeFromPath(item.workingDir);
|
||||
if (wt) {
|
||||
item.worktreeName = wt.name;
|
||||
item.worktreeRepo ??= wt.repo;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Meaningfulness floor: keep real rows, drop bare lifecycle/mux-only noise.
|
||||
|
||||
@@ -159,6 +159,77 @@ export function watchingLabel(
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* How many non-blank rows at the foot of a pane capture `isBlockedScreen()` reads for a
|
||||
* CLI that declares no number of its own (`capabilities.workDetect.blockedLines`).
|
||||
*/
|
||||
export const BLOCKED_TAIL_LINES = 1;
|
||||
|
||||
/** Hard cap on that window, whatever a registry entry asks for (the schema's own max). */
|
||||
export const BLOCKED_MAX_TAIL_LINES = 24;
|
||||
|
||||
/**
|
||||
* How often a session that is showing a blocking dialog re-reads its screen to learn the
|
||||
* dialog went away. The raise needs no timer, because a dialog is drawn at the END of a
|
||||
* burst of output and the idle confirmation reads the screen once the pane goes quiet. The
|
||||
* clear does: answering resumes the turn, and a running turn keeps the pane busy, so the
|
||||
* settle path would not look again until the whole turn ended, and the red alert would
|
||||
* outlive the dialog by minutes. One `capture-pane` per interval, and only while a dialog
|
||||
* is up.
|
||||
*/
|
||||
export const BLOCKED_RECHECK_MS = 2000;
|
||||
|
||||
/**
|
||||
* The longest a session showing a blocking dialog goes without re-reading a QUIET pane.
|
||||
* A dialog leaves the screen only through a repaint, and every repaint reaches the
|
||||
* session as PTY output, so while nothing has been drawn since the last reading that saw
|
||||
* the dialog it is still up, and the BLOCKED_RECHECK_MS read is skipped. That read is a
|
||||
* synchronous `tmux capture-pane`, about 8 ms of blocked event loop (measured 2026-10-14),
|
||||
* and a dialog can sit unanswered overnight: one per 2 s per waiting session for hours.
|
||||
* This bound is only the safety net under that reasoning, never the clear's latency.
|
||||
*/
|
||||
export const BLOCKED_QUIET_RECHECK_MS = 30_000;
|
||||
|
||||
/**
|
||||
* Consecutive readable captures without the dialog before the blocked state clears. One
|
||||
* read can land mid-repaint (a resize redraws the whole frame), and a clear followed by a
|
||||
* re-raise would send the alert, the push and the webhook a second time for the same
|
||||
* dialog. Two reads at BLOCKED_RECHECK_MS clear 2 to 4 s after the answer.
|
||||
*/
|
||||
export const BLOCKED_CLEAR_READS = 2;
|
||||
|
||||
/**
|
||||
* Whether the foot of a pane shows a dialog this CLI blocks on (an approval, a question,
|
||||
* a trust prompt), read with the CLI's `capabilities.workDetect.blockedLine`.
|
||||
*
|
||||
* The last `tailLines` non-blank rows are joined with `\n` and tested once, so the pattern
|
||||
* can demand several markers and anchor its footer on the end of the window. Blank rows
|
||||
* are dropped first, like `watchingLabel()`, because a TUI pads its dialog with them.
|
||||
*
|
||||
* ⚠️ The text is pane-derived and the agent writes most of the pane, so a pattern must
|
||||
* end on chrome only the CLI draws (a dialog's key-hint footer replaces the composer and
|
||||
* its footer, which nothing the agent prints can sit below). Even so, a match is only
|
||||
* ever an ALERT: nothing keys a keystroke off it.
|
||||
*
|
||||
* @param tailLines how many non-blank rows from the bottom the pattern sees, defaulting to
|
||||
* `BLOCKED_TAIL_LINES` and capped at `BLOCKED_MAX_TAIL_LINES`
|
||||
*/
|
||||
export function isBlockedScreen(
|
||||
paneText: string | null | undefined,
|
||||
pattern: RegExp,
|
||||
tailLines: number = BLOCKED_TAIL_LINES
|
||||
): boolean {
|
||||
if (!paneText) return false;
|
||||
const rows = stripAnsi(paneText)
|
||||
.split('\n')
|
||||
.map((row) => row.trimEnd())
|
||||
.filter((row) => row !== '');
|
||||
const window = rows.slice(-Math.max(1, Math.min(tailLines, BLOCKED_MAX_TAIL_LINES))).join('\n');
|
||||
// Same reasoning as watchingLabel(): a caller's `g` flag must not make this flap.
|
||||
pattern.lastIndex = 0;
|
||||
return pattern.test(window);
|
||||
}
|
||||
|
||||
/**
|
||||
* How many rows above the composer the turn's closing row may sit. Between the two Claude
|
||||
* draws only its composer border and, sometimes, a right-aligned hint
|
||||
|
||||
@@ -20,6 +20,7 @@
|
||||
|
||||
import { EventEmitter } from 'node:events';
|
||||
import { detectUsageLimitPause } from './usage-limit-patterns.js';
|
||||
import { toSingleLine } from './utils/index.js';
|
||||
|
||||
// ============================================================================
|
||||
// Timing Constants
|
||||
@@ -408,7 +409,11 @@ export class SessionAutoOps extends EventEmitter {
|
||||
);
|
||||
|
||||
const action = async () => {
|
||||
const compactCmd = this._autoCompactPrompt ? `/compact ${this._autoCompactPrompt}\r` : '/compact\r';
|
||||
// One line: this fires unattended, and a multi-line focus prompt would go out
|
||||
// as a multi-line paste (see toSingleLine).
|
||||
const compactCmd = this._autoCompactPrompt
|
||||
? `/compact ${toSingleLine(this._autoCompactPrompt)}\r`
|
||||
: '/compact\r';
|
||||
await this.callbacks.writeCommand(compactCmd);
|
||||
this.emit('autoCompact', {
|
||||
tokens: totalTokens,
|
||||
|
||||
@@ -17,6 +17,7 @@
|
||||
* @module session-cli-registry-bridge
|
||||
*/
|
||||
|
||||
import { getDiscordStartupHint } from './discord-bridge.js';
|
||||
import type { CliEntry } from './config/cli-registry/types.js';
|
||||
import { renderLaunch, type EngineValues, type ParamValues } from './config/cli-registry/argv.js';
|
||||
import { matchesPattern } from './config/cli-registry/patterns.js';
|
||||
@@ -33,6 +34,7 @@ import type {
|
||||
EffortLevel,
|
||||
GeminiConfig,
|
||||
GrokConfig,
|
||||
CopilotConfig,
|
||||
OmpConfig,
|
||||
OpenCodeConfig,
|
||||
PiConfig,
|
||||
@@ -50,6 +52,7 @@ export interface SpawnBridgeOptions {
|
||||
antigravityConfig?: AntigravityConfig;
|
||||
piConfig?: PiConfig;
|
||||
grokConfig?: GrokConfig;
|
||||
copilotConfig?: CopilotConfig;
|
||||
deepSeekConfig?: DeepSeekConfig;
|
||||
ompConfig?: OmpConfig;
|
||||
resumeSessionId?: string;
|
||||
@@ -66,6 +69,12 @@ export interface SpawnBridgeOptions {
|
||||
* rendered as two independent params.
|
||||
*/
|
||||
statusLineCommand?: string;
|
||||
/**
|
||||
* Add `idleCompaction: false` to the same `--settings` JSON: Codeman's prompt-cache keep-warm
|
||||
* owns the idle policy for this launch. Decided (keep-warm on, exporter injected, version gate
|
||||
* passed) by TmuxManager._resolveIdleCompactionOptOut, which records it on the mux record.
|
||||
*/
|
||||
idleCompactionOff?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -166,6 +175,20 @@ function resolveGatesPassed(entry: CliEntry, resolveVersion: () => string | null
|
||||
return passed;
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether `mode`'s `capabilities.gates[gate]` passes, through the same fail-closed resolution
|
||||
* the launch template uses (an unknown version passes no gate). For a launch-time decision that
|
||||
* is also recorded, like the `idleCompaction` opt-out. `claudeCliVersion` as in
|
||||
* SpawnBridgeOptions: undefined probes the local CLI, null means unknown.
|
||||
*/
|
||||
export function launchGatePasses(mode: string, gate: string, claudeCliVersion?: string | null): boolean {
|
||||
const entry = getCli(mode);
|
||||
if (!entry?.capabilities.gates[gate]) return false;
|
||||
return resolveGatesPassed(entry, () =>
|
||||
claudeCliVersion !== undefined ? claudeCliVersion : getClaudeCliVersion()
|
||||
).has(gate);
|
||||
}
|
||||
|
||||
/**
|
||||
* Render the spawn command for `entry` from the legacy option bag. Returns `undefined` for a
|
||||
* `shell`-kind entry (or any entry declaring no launch variants), which callers take as "fall
|
||||
@@ -206,10 +229,15 @@ export function buildSpawnCommandFromRegistry(entry: CliEntry, options: SpawnBri
|
||||
// rendering them as independent params would let the last one silently win. Claude-only in
|
||||
// practice: only claude's launch template renders this engine value, so another CLI's
|
||||
// session carrying an advisorModel launches exactly as before.
|
||||
// Key order (ultracode, advisorModel, statusLine) keeps a launch without an advisor
|
||||
// byte-identical to one from before the advisor existed.
|
||||
// Key order (ultracode, advisorModel, statusLine, idleCompaction) keeps a launch without an
|
||||
// advisor or keep-warm byte-identical to one from before either existed.
|
||||
const advisorSettings = buildAdvisorSettings(options.advisorModel);
|
||||
if ((effortFlag === '--settings' && effortValue) || advisorSettings.advisorModel || options.statusLineCommand) {
|
||||
if (
|
||||
(effortFlag === '--settings' && effortValue) ||
|
||||
advisorSettings.advisorModel ||
|
||||
options.statusLineCommand ||
|
||||
options.idleCompactionOff
|
||||
) {
|
||||
const settingsObj: Record<string, unknown> = {
|
||||
...(effortFlag === '--settings' && effortValue ? JSON.parse(effortValue) : {}),
|
||||
...advisorSettings,
|
||||
@@ -217,9 +245,14 @@ export function buildSpawnCommandFromRegistry(entry: CliEntry, options: SpawnBri
|
||||
if (options.statusLineCommand) {
|
||||
settingsObj.statusLine = { type: 'command', command: options.statusLineCommand };
|
||||
}
|
||||
if (options.idleCompactionOff) settingsObj.idleCompaction = false;
|
||||
engineValues.effortSettingsJson = JSON.stringify(settingsObj);
|
||||
}
|
||||
|
||||
// The Discord bridge's startup hint: undefined (so the flag is omitted) unless the bridge and its
|
||||
// `startupHint` setting are on. Claude-only in practice, like the settings object above.
|
||||
engineValues.appendSystemPrompt = getDiscordStartupHint();
|
||||
|
||||
// Preserves buildSpawnCommand's original fallback exactly: an EXPLICIT `undefined` probes
|
||||
// the local claude CLI (getClaudeCliVersion, null under vitest); an explicit `null` means
|
||||
// "known to be unresolvable" and must not probe. The probe only ever runs from
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
*
|
||||
* Claude Code 2.1.277 (auto-installed 2026-09-18) takes typed text the moment its
|
||||
* composer paints but ignores Enter for the first 30 to 50 seconds after it, so the
|
||||
* `send-keys -l <text>` + `send-keys Enter` pair `TmuxManager.sendInput()` sends 50 ms
|
||||
* text + `send-keys Enter` pair `TmuxManager.sendInput()` sends 50 ms
|
||||
* apart leaves the prompt sitting on the composer with `0 tokens`, and every caller
|
||||
* that then waits for the turn (send-and-wait, the agent skill, the maintainer bot,
|
||||
* cron, Ralph) burns its whole timeout on a turn that never started. Measured through
|
||||
@@ -65,6 +65,85 @@ export function promptStillInComposer(screen: string, prompt: string, glyph: str
|
||||
return head.length > 0 && composer.startsWith(head);
|
||||
}
|
||||
|
||||
/**
|
||||
* A styled frame as plain text. SGR goes first with its own pattern because tmux writes
|
||||
* colon sub-parameters (`ESC[4:3m`, a curly underline), which `stripAnsi` leaves behind.
|
||||
*/
|
||||
export function plainFrame(styled: string): string {
|
||||
// eslint-disable-next-line no-control-regex
|
||||
return stripAnsi(styled.replace(/\x1b\[[0-9;:]*m/g, ''));
|
||||
}
|
||||
|
||||
/**
|
||||
* `screen` (a `capture-pane -p -e` frame) without the text Claude Code draws in an empty
|
||||
* composer that nobody typed: its prompt suggestion, drawn faint (SGR 2), and the suggestion's
|
||||
* first letter, which sits in the reverse-video cursor cell (SGR 7) right before the faint rest.
|
||||
* A typed draft is never faint, so a cursor cell followed by anything else is kept. Every line
|
||||
* break is kept, so line `n` here is line `n` of the plain frame, and plain text passes through
|
||||
* unchanged. Other escapes are left for `stripAnsi`.
|
||||
*/
|
||||
function withoutSuggestion(screen: string): string {
|
||||
let faint = false;
|
||||
let inverse = false;
|
||||
let cursorCell = '';
|
||||
let out = '';
|
||||
// eslint-disable-next-line no-control-regex
|
||||
for (const m of screen.matchAll(/\x1b\[([0-9;:]*)m|[\s\S]/gu)) {
|
||||
if (m[1] !== undefined) {
|
||||
const params = m[1].split(';');
|
||||
for (let i = 0; i < params.length; i++) {
|
||||
const p = params[i];
|
||||
if (p === '' || p === '0') faint = inverse = false;
|
||||
else if (p === '2') faint = true;
|
||||
else if (p === '22') faint = false;
|
||||
else if (p === '7') inverse = true;
|
||||
else if (p === '27') inverse = false;
|
||||
// An extended colour's own arguments (`38;5;n`, `38;2;r;g;b`) are not attributes.
|
||||
else if (p === '38' || p === '48' || p === '58') i += params[i + 1] === '5' ? 2 : params[i + 1] === '2' ? 4 : 0;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
const ch = m[0];
|
||||
if (ch !== '\n' && faint) {
|
||||
cursorCell = '';
|
||||
continue;
|
||||
}
|
||||
out += cursorCell;
|
||||
cursorCell = '';
|
||||
if (ch !== '\n' && inverse) cursorCell = ch;
|
||||
else out += ch;
|
||||
}
|
||||
return out + cursorCell;
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether the composer holds nothing: the LAST `glyph` line after the glyph, and every
|
||||
* continuation line below it down to the composer's bottom rule (a draft that opens with a
|
||||
* newline leaves the glyph line itself blank). Undefined when there is no composer line or no
|
||||
* rule under it to bound the read (a shell, a dialog, a direct-PTY session): nothing can be
|
||||
* said. The prompt-cache keep-warm refuses to type unless this is true: its paste would land
|
||||
* in whatever draft sits there, and the Enter would submit both.
|
||||
*
|
||||
* Pass a STYLED frame (`capture-pane -p -e`): Claude Code's prompt suggestion (on by default)
|
||||
* fills an empty composer with faint text, and only the styling tells it from a typed draft.
|
||||
* The paste replaces a suggestion rather than joining it (measured on 2.1.300). A plain frame
|
||||
* still parses, but reads a suggestion as a draft.
|
||||
*/
|
||||
export function composerIsEmpty(screen: string, glyph: string): boolean | undefined {
|
||||
if (!glyph) return undefined;
|
||||
// The structure is read at column 0, before any trimming: Claude Code draws the glyph line and
|
||||
// both rules there and indents every continuation line, so a draft line that holds the glyph or
|
||||
// starts with a rule (` ❯`, ` ───`) can never pass for the composer's own edges.
|
||||
const lines = plainFrame(screen).split('\n');
|
||||
let at = lines.length - 1;
|
||||
while (at >= 0 && !lines[at].startsWith(glyph)) at--;
|
||||
if (at < 0) return undefined;
|
||||
const end = lines.findIndex((l, i) => i > at && l.startsWith('\u2500'));
|
||||
if (end < 0) return undefined;
|
||||
const typed = plainFrame(withoutSuggestion(screen)).split('\n');
|
||||
return compact(typed[at].slice(glyph.length) + typed.slice(at + 1, end).join('')).length === 0;
|
||||
}
|
||||
|
||||
export interface SubmitVerifierDeps {
|
||||
/** The rendered pane, or null when there is none to read. */
|
||||
capture: () => string | null | undefined;
|
||||
|
||||
+937
-40
File diff suppressed because it is too large.
Load diff
@@ -286,6 +286,9 @@ export class StateStore {
|
||||
if (this.state.tabLayouts !== undefined) {
|
||||
parts.push(`"tabLayouts":${JSON.stringify(this.state.tabLayouts)}`);
|
||||
}
|
||||
if (this.state.caseDefaultGroups !== undefined) {
|
||||
parts.push(`"caseDefaultGroups":${JSON.stringify(this.state.caseDefaultGroups)}`);
|
||||
}
|
||||
|
||||
return `{${parts.join(',')}}`;
|
||||
}
|
||||
@@ -710,6 +713,38 @@ export class StateStore {
|
||||
return { layouts: structuredClone(validated), sessionOrder: [...sessionOrder] };
|
||||
}
|
||||
|
||||
/**
|
||||
* An owner's default session group for a case: a group id, `null` for an
|
||||
* intentional Ungrouped choice, or `undefined` when unset. state.json is read
|
||||
* unvalidated, so anything but a string or null reads as unset.
|
||||
*/
|
||||
getCaseDefaultGroup(owner: string, caseName: string): string | null | undefined {
|
||||
const all = this.state.caseDefaultGroups;
|
||||
if (!all || typeof all !== 'object' || !Object.hasOwn(all, owner)) return undefined;
|
||||
const forOwner = all[owner];
|
||||
if (!forOwner || typeof forOwner !== 'object' || !Object.hasOwn(forOwner, caseName)) return undefined;
|
||||
const value = forOwner[caseName];
|
||||
return typeof value === 'string' || value === null ? value : undefined;
|
||||
}
|
||||
|
||||
/** Persist an owner's default session group for a case (`null` = Ungrouped). */
|
||||
setCaseDefaultGroup(owner: string, caseName: string, groupId: string | null): void {
|
||||
const all = this.state.caseDefaultGroups ?? {};
|
||||
const forOwner = Object.hasOwn(all, owner) ? all[owner] : {};
|
||||
this.state.caseDefaultGroups = { ...all, [owner]: { ...forOwner, [caseName]: groupId } };
|
||||
this.save();
|
||||
}
|
||||
|
||||
/** Forget an owner's default session group for a case (unset, not Ungrouped). */
|
||||
deleteCaseDefaultGroup(owner: string, caseName: string): void {
|
||||
if (this.getCaseDefaultGroup(owner, caseName) === undefined) return;
|
||||
const all = this.state.caseDefaultGroups ?? {};
|
||||
const forOwner = { ...all[owner] };
|
||||
delete forOwner[caseName];
|
||||
this.state.caseDefaultGroups = { ...all, [owner]: forOwner };
|
||||
this.save();
|
||||
}
|
||||
|
||||
/** Resets all state to initial values and saves immediately. */
|
||||
reset(): void {
|
||||
this.state = createInitialState();
|
||||
|
||||
@@ -32,29 +32,85 @@ export function applyLegacySessionRank(
|
||||
|
||||
const isRanked = (ref: TabRef): boolean =>
|
||||
ref.kind === 'session' && sessionMetadata.has(ref.id) && requestedRank.has(ref.id);
|
||||
const prepare = (ref: TabRef): TabRef => {
|
||||
if (ref.kind !== 'session') return { ...ref };
|
||||
const item = sessionMetadata.get(ref.id);
|
||||
const ownerValidParent = item?.parentSessionId && sessionMetadata.has(item.parentSessionId);
|
||||
return ownerValidParent ? { ...ref, placement: 'manual' } : { ...ref };
|
||||
};
|
||||
const rankContainer = (refs: readonly TabRef[]): TabRef[] => {
|
||||
const ranked = refs
|
||||
.filter(isRanked)
|
||||
.map(prepare)
|
||||
.sort((a, b) => requestedRank.get(a.id)! - requestedRank.get(b.id)!);
|
||||
const ranked = refs.filter(isRanked).sort((a, b) => requestedRank.get(a.id)! - requestedRank.get(b.id)!);
|
||||
let rankedIndex = 0;
|
||||
return refs.map((ref) => (isRanked(ref) ? ranked[rankedIndex++] : { ...ref }));
|
||||
return refs.map((ref) => ({ ...(isRanked(ref) ? ranked[rankedIndex++] : ref) }));
|
||||
};
|
||||
|
||||
const transformed: TabLayout = {
|
||||
const ranked: TabLayout = {
|
||||
...layout,
|
||||
groups: layout.groups.map((group) => ({ ...group, refs: rankContainer(group.refs) })),
|
||||
ungrouped: rankContainer(layout.ungrouped),
|
||||
};
|
||||
const manual = childrenSplitFromParent(ranked, sessionMetadata);
|
||||
const pin = (ref: TabRef): TabRef =>
|
||||
ref.kind === 'session' && manual.has(ref.id) ? { ...ref, placement: 'manual' } : ref;
|
||||
const transformed: TabLayout = {
|
||||
...ranked,
|
||||
groups: ranked.groups.map((group) => ({ ...group, refs: group.refs.map(pin) })),
|
||||
ungrouped: ranked.ungrouped.map(pin),
|
||||
};
|
||||
return normalizeTabLayout(transformed, metadata);
|
||||
}
|
||||
|
||||
/**
|
||||
* The following children (no `manual` placement, an owner-valid parent) that a
|
||||
* ranked layout separates from their parent's block. A parent's block is the
|
||||
* parent and, right after it in the same container, its following descendants;
|
||||
* sibling order inside the block is free, and a web tab the ranking left in a
|
||||
* slot between them does not split it (normalization closes the block again). Only these children become `manual`:
|
||||
* a PUT of the current order, or one that moves a family together, leaves every
|
||||
* stored placement alone. Pinning one child shrinks its parent's block, so the
|
||||
* check repeats until nothing more is pinned.
|
||||
*/
|
||||
function childrenSplitFromParent(ranked: TabLayout, sessionMetadata: ReadonlyMap<string, TabRefMetadata>): Set<string> {
|
||||
const containers = [...ranked.groups.map((group) => group.refs), ranked.ungrouped];
|
||||
const following = new Map<string, string>();
|
||||
for (const refs of containers) {
|
||||
for (const ref of refs) {
|
||||
if (ref.kind !== 'session' || ref.placement === 'manual') continue;
|
||||
const parentId = sessionMetadata.get(ref.id)?.parentSessionId;
|
||||
if (parentId && sessionMetadata.has(parentId)) following.set(ref.id, parentId);
|
||||
}
|
||||
}
|
||||
const descendsFrom = (id: string, ancestorId: string): boolean => {
|
||||
let current = following.get(id);
|
||||
for (let hops = 0; current !== undefined && hops <= following.size; hops++) {
|
||||
if (current === ancestorId) return true;
|
||||
current = following.get(current);
|
||||
}
|
||||
return false;
|
||||
};
|
||||
const manual = new Set<string>();
|
||||
for (let changed = true; changed; ) {
|
||||
changed = false;
|
||||
for (const refs of containers) {
|
||||
const index = new Map<string, number>();
|
||||
refs.forEach((ref, at) => {
|
||||
if (ref.kind === 'session') index.set(ref.id, at);
|
||||
});
|
||||
refs.forEach((ref, at) => {
|
||||
if (ref.kind !== 'session') return;
|
||||
const parentId = following.get(ref.id);
|
||||
if (parentId === undefined) return;
|
||||
const parentAt = index.get(parentId);
|
||||
const inBlock =
|
||||
parentAt !== undefined &&
|
||||
parentAt < at &&
|
||||
refs
|
||||
.slice(parentAt + 1, at)
|
||||
.every((between) => between.kind !== 'session' || descendsFrom(between.id, parentId));
|
||||
if (inBlock) return;
|
||||
following.delete(ref.id);
|
||||
manual.add(ref.id);
|
||||
changed = true;
|
||||
});
|
||||
}
|
||||
}
|
||||
return manual;
|
||||
}
|
||||
|
||||
export function recomposeGlobalSessionOrder(
|
||||
current: readonly string[],
|
||||
projections: readonly OwnerOrderProjection[],
|
||||
|
||||
+215
-6
@@ -11,7 +11,9 @@ import { applyLegacySessionRank, recomposeGlobalSessionOrder } from './tab-layou
|
||||
import {
|
||||
flattenOwnerSessionOrder,
|
||||
materializeOrphans,
|
||||
moveRef,
|
||||
normalizeTabLayout,
|
||||
setManualPlacement,
|
||||
TabLayoutValidationError,
|
||||
validateTabLayout,
|
||||
type TabLayout,
|
||||
@@ -42,6 +44,17 @@ interface TabLayoutServiceDeps {
|
||||
readWebviews(): Promise<readonly TabLayoutWebviewRecord[]>;
|
||||
broadcast(event: string, data: unknown): void;
|
||||
broadcastSessionOrder(change: SessionOrderProjectionChange): void;
|
||||
/**
|
||||
* The synced `spawnedTabsFollowParent` setting, read at every creation. Off (or
|
||||
* absent), a spawned session lands after its parent once and is hand-placed
|
||||
* from then on; on, it keeps following its parent until moved by hand.
|
||||
*/
|
||||
childrenFollowParent?(): boolean | Promise<boolean>;
|
||||
/**
|
||||
* Where per-case default session groups live (StateStore). Optional so a
|
||||
* harness without it simply never applies a case default.
|
||||
*/
|
||||
caseDefaults?: Pick<StateStore, 'getCaseDefaultGroup' | 'setCaseDefaultGroup' | 'deleteCaseDefaultGroup'>;
|
||||
now?: () => string;
|
||||
}
|
||||
|
||||
@@ -67,6 +80,29 @@ export interface RemovedTabLayoutSession {
|
||||
owner?: string;
|
||||
}
|
||||
|
||||
/** Where a newly created session asked to be placed. Advisory: an unknown group is ignored. */
|
||||
export interface SessionPlacementHint {
|
||||
tabGroupId?: string;
|
||||
/**
|
||||
* The case the session was started for. Its default session group (if any)
|
||||
* is the fallback when neither `tabGroupId` nor a parent places the session.
|
||||
*/
|
||||
caseName?: string;
|
||||
}
|
||||
|
||||
/** A case's default session group plus the owner's current named groups to choose from. */
|
||||
export interface CaseDefaultGroupView {
|
||||
groupId: string | null;
|
||||
groups: Array<{ id: string; name: string }>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Adds the created session to the live map. Runs INSIDE the owner lock, so no
|
||||
* other layout mutation can observe (and commit) the session first; the returned
|
||||
* function undoes it when the layout write fails.
|
||||
*/
|
||||
export type RegisterCreatedSession = () => void | (() => void);
|
||||
|
||||
interface PreparedOwnerLayout {
|
||||
current: TabLayout | null;
|
||||
authoritative: TabLayout;
|
||||
@@ -97,6 +133,80 @@ const sameLayout = (a: TabLayout, b: TabLayout): boolean => JSON.stringify(a) ==
|
||||
const sameOrder = (a: readonly string[], b: readonly string[]): boolean =>
|
||||
a.length === b.length && a.every((id, index) => id === b[index]);
|
||||
|
||||
/**
|
||||
* Hand-place a just-created child where normalization put it (right after its
|
||||
* parent), so it stops following. A session that does not follow anything is
|
||||
* left alone.
|
||||
*/
|
||||
function pinCreatedChild(layout: TabLayout, sessionId: string, metadata: readonly TabRefMetadata[]): TabLayout {
|
||||
const item = metadata.find((fact) => fact.kind === 'session' && fact.id === sessionId);
|
||||
const parentId = item?.parentSessionId;
|
||||
if (!item?.ownerValid || !parentId) return layout;
|
||||
const refs = [...layout.groups.flatMap((group) => group.refs), ...layout.ungrouped];
|
||||
const ref = refs.find((candidate) => candidate.kind === 'session' && candidate.id === sessionId);
|
||||
const parentStored = refs.some((candidate) => candidate.kind === 'session' && candidate.id === parentId);
|
||||
const parentValid = metadata.some((fact) => fact.kind === 'session' && fact.id === parentId && fact.ownerValid);
|
||||
if (!ref || ref.placement === 'manual' || !parentStored || !parentValid) return layout;
|
||||
const pinned = setManualPlacement(layout, ref, true);
|
||||
// Normalization put the child right after its parent's FOLLOWING block, i.e.
|
||||
// ahead of the siblings pinned before it. Step past those (and their
|
||||
// descendants) so a fan-out keeps creation order.
|
||||
const parentOf = new Map<string, string>();
|
||||
for (const fact of metadata) {
|
||||
if (fact.kind === 'session' && fact.ownerValid && fact.parentSessionId) parentOf.set(fact.id, fact.parentSessionId);
|
||||
}
|
||||
const descendsFromParent = (id: string): boolean => {
|
||||
let current = parentOf.get(id);
|
||||
for (let hops = 0; current !== undefined && hops <= parentOf.size; hops++) {
|
||||
if (current === parentId) return true;
|
||||
current = parentOf.get(current);
|
||||
}
|
||||
return false;
|
||||
};
|
||||
const isChild = (candidate: TabRef): boolean => candidate.kind === 'session' && candidate.id === sessionId;
|
||||
const group = pinned.groups.find((candidate) => candidate.refs.some(isChild));
|
||||
const container = group ? group.refs : pinned.ungrouped;
|
||||
const from = container.findIndex(isChild);
|
||||
let to = from;
|
||||
while (to + 1 < container.length && container[to + 1].kind === 'session' && descendsFromParent(container[to + 1].id))
|
||||
to++;
|
||||
if (to === from) return pinned;
|
||||
return moveRef(pinned, { kind: 'session', id: sessionId }, { groupId: group?.id ?? null, index: to }, metadata);
|
||||
}
|
||||
|
||||
/**
|
||||
* Does the just-created session have a parent that places it (right after that
|
||||
* parent)? Mirrors the conditions under which normalization and
|
||||
* {@link pinCreatedChild} treat it as a child.
|
||||
*/
|
||||
function hasPlacingParent(layout: TabLayout, sessionId: string, metadata: readonly TabRefMetadata[]): boolean {
|
||||
const item = metadata.find((fact) => fact.kind === 'session' && fact.id === sessionId);
|
||||
const parentId = item?.parentSessionId;
|
||||
if (!item?.ownerValid || !parentId) return false;
|
||||
const parentValid = metadata.some((fact) => fact.kind === 'session' && fact.id === parentId && fact.ownerValid);
|
||||
const refs = [...layout.groups.flatMap((group) => group.refs), ...layout.ungrouped];
|
||||
return parentValid && refs.some((ref) => ref.kind === 'session' && ref.id === parentId);
|
||||
}
|
||||
|
||||
/** Move a just-created session to the end of `groupId`, or leave the layout alone. */
|
||||
function placeCreatedSession(
|
||||
layout: TabLayout,
|
||||
sessionId: string,
|
||||
groupId: string,
|
||||
metadata: readonly TabRefMetadata[]
|
||||
): TabLayout {
|
||||
const group = layout.groups.find((candidate) => candidate.id === groupId);
|
||||
if (!group) return layout;
|
||||
const index = group.refs.filter((ref) => ref.kind !== 'session' || ref.id !== sessionId).length;
|
||||
try {
|
||||
return moveRef(layout, { kind: 'session', id: sessionId }, { groupId, index }, metadata);
|
||||
} catch (error) {
|
||||
// Not an owner-valid ref of this layout: the hint does not apply.
|
||||
if (error instanceof TabLayoutValidationError) return layout;
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
export class TabLayoutService {
|
||||
private restorationState: 'pending' | 'complete' | 'failed' | 'skipped' = 'pending';
|
||||
private readonly ownerQueues = new Map<string, Promise<void>>();
|
||||
@@ -320,9 +430,13 @@ export class TabLayoutService {
|
||||
};
|
||||
}
|
||||
|
||||
private async getUnlocked(owner: string): Promise<TabLayout> {
|
||||
private async getUnlocked(
|
||||
owner: string,
|
||||
adjust?: (layout: TabLayout, metadata: readonly TabRefMetadata[]) => TabLayout
|
||||
): Promise<TabLayout> {
|
||||
const prepared = await this.prepareUnlocked(owner);
|
||||
if (!prepared.needsReconciliationCommit) {
|
||||
const next = adjust ? adjust(prepared.authoritative, prepared.metadata) : prepared.authoritative;
|
||||
if (!prepared.needsReconciliationCommit && sameLayout(next, prepared.authoritative)) {
|
||||
const publication = {
|
||||
owner,
|
||||
previous: prepared.current,
|
||||
@@ -335,7 +449,7 @@ export class TabLayoutService {
|
||||
return prepared.authoritative;
|
||||
}
|
||||
const base = prepared.current ?? { ...prepared.authoritative, version: -1 };
|
||||
return this.commit(owner, base, prepared.authoritative, prepared.metadata);
|
||||
return this.commit(owner, base, next, prepared.metadata, prepared.current);
|
||||
}
|
||||
|
||||
async get(owner: string): Promise<TabLayout> {
|
||||
@@ -442,9 +556,104 @@ export class TabLayoutService {
|
||||
}
|
||||
}
|
||||
|
||||
/** Reconcile one completed session creation into one versioned mutation. */
|
||||
async sessionCreated(owner: string): Promise<TabLayout> {
|
||||
return this.get(owner);
|
||||
/**
|
||||
* Reconcile one completed session creation into one versioned mutation.
|
||||
*
|
||||
* `placement.tabGroupId` puts the new session at the end of that group in the
|
||||
* same write. The group is looked up in THIS owner's layout only, so a group id
|
||||
* from another owner (or one deleted meanwhile) is simply not found and the
|
||||
* session keeps its normal placement: after its parent when it has one,
|
||||
* otherwise at the end of Ungrouped. An explicit group is a hand placement, so
|
||||
* a child session placed this way is marked `manual` and stops following.
|
||||
*
|
||||
* Whether a child placed by its lineage KEEPS following is the synced
|
||||
* `spawnedTabsFollowParent` setting, read here at creation: off (the default),
|
||||
* the child lands after its parent once and is marked `manual` in the same
|
||||
* write; on, it follows its parent until moved by hand. Flipping the setting
|
||||
* later leaves existing tabs as they are.
|
||||
*/
|
||||
async sessionCreated(
|
||||
owner: string,
|
||||
sessionId?: string,
|
||||
placement: SessionPlacementHint = {},
|
||||
register?: RegisterCreatedSession
|
||||
): Promise<TabLayout> {
|
||||
return this.withOwner(owner, async () => {
|
||||
const follow = sessionId ? (await this.deps.childrenFollowParent?.()) === true : true;
|
||||
const caseName = sessionId ? placement.caseName : undefined;
|
||||
const caseDefault = caseName ? this.deps.caseDefaults?.getCaseDefaultGroup(owner, caseName) : undefined;
|
||||
let staleCaseDefault = false;
|
||||
const rollback = register?.();
|
||||
let layout: TabLayout;
|
||||
try {
|
||||
const groupId = placement.tabGroupId;
|
||||
const adjust =
|
||||
sessionId && (groupId || !follow || typeof caseDefault === 'string')
|
||||
? (layout: TabLayout, metadata: readonly TabRefMetadata[]) => {
|
||||
// Precedence: explicit group > parent placement > case default > none.
|
||||
const explicit = groupId && layout.groups.some((group) => group.id === groupId) ? groupId : undefined;
|
||||
let placed = explicit ? placeCreatedSession(layout, sessionId, explicit, metadata) : layout;
|
||||
if (!explicit && typeof caseDefault === 'string') {
|
||||
if (!layout.groups.some((group) => group.id === caseDefault)) staleCaseDefault = true;
|
||||
else if (!hasPlacingParent(layout, sessionId, metadata))
|
||||
placed = placeCreatedSession(layout, sessionId, caseDefault, metadata);
|
||||
}
|
||||
return follow ? placed : pinCreatedChild(placed, sessionId, metadata);
|
||||
}
|
||||
: undefined;
|
||||
layout = await this.getUnlocked(owner, adjust);
|
||||
} catch (error) {
|
||||
if (typeof rollback === 'function') rollback();
|
||||
throw error;
|
||||
}
|
||||
// A default whose group was deleted is ignored above and forgotten here, only once the
|
||||
// creation's own write has landed, and outside the rollback: the session is committed by now,
|
||||
// and nothing this cleanup does may drop it from the live map.
|
||||
if (staleCaseDefault && caseName) this.deps.caseDefaults?.deleteCaseDefaultGroup(owner, caseName);
|
||||
return layout;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* A case's default session group and the owner's named groups to choose from.
|
||||
* A stored id that is no longer one of this owner's groups is reported as
|
||||
* `null` (Ungrouped), which is also where creation would put it. Read-only:
|
||||
* the next creation for the case forgets the stale id.
|
||||
*/
|
||||
async caseDefaultGroup(owner: string, caseName: string): Promise<CaseDefaultGroupView> {
|
||||
return this.withOwner(owner, async () => {
|
||||
const { authoritative } = await this.prepareUnlocked(owner);
|
||||
const groups = authoritative.groups.map((group) => ({ id: group.id, name: group.name }));
|
||||
const stored = this.deps.caseDefaults?.getCaseDefaultGroup(owner, caseName);
|
||||
const known = typeof stored === 'string' && groups.some((group) => group.id === stored);
|
||||
return { groupId: known ? stored : null, groups };
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Set a case's default session group: one of this owner's current groups, or
|
||||
* `null` for Ungrouped. Only future creations read it; nothing is moved.
|
||||
* Throws {@link TabLayoutValidationError} for an id not in the owner's layout.
|
||||
*/
|
||||
async setCaseDefaultGroup(owner: string, caseName: string, groupId: string | null): Promise<CaseDefaultGroupView> {
|
||||
return this.withOwner(owner, async () => {
|
||||
const caseDefaults = this.deps.caseDefaults;
|
||||
if (!caseDefaults) throw new Error('Case default groups are not available');
|
||||
const { authoritative } = await this.prepareUnlocked(owner);
|
||||
const groups = authoritative.groups.map((group) => ({ id: group.id, name: group.name }));
|
||||
if (groupId !== null && !groups.some((group) => group.id === groupId)) {
|
||||
throw new TabLayoutValidationError('Unknown tab group');
|
||||
}
|
||||
caseDefaults.setCaseDefaultGroup(owner, caseName, groupId);
|
||||
return { groupId, groups };
|
||||
});
|
||||
}
|
||||
|
||||
/** Forget a case's default session group (the case was deleted or unlinked). */
|
||||
async forgetCaseDefaultGroup(owner: string, caseName: string): Promise<void> {
|
||||
await this.withOwner(owner, async () => {
|
||||
this.deps.caseDefaults?.deleteCaseDefaultGroup(owner, caseName);
|
||||
});
|
||||
}
|
||||
|
||||
/** Reconcile one completed saved-webview creation into one versioned mutation. */
|
||||
|
||||
+21
-2
@@ -15,6 +15,13 @@ export interface TabRef {
|
||||
kind: TabRefKind;
|
||||
id: string;
|
||||
placement?: 'manual';
|
||||
/**
|
||||
* Pinned to the vertical rail's Focus section. The browser draws a SHORTCUT to
|
||||
* the ref there; the ref itself stays in its group, so the flag never moves
|
||||
* anything and nothing that moves a ref may drop it. Only `true` is stored:
|
||||
* an unfocused ref carries no key at all.
|
||||
*/
|
||||
focus?: true;
|
||||
}
|
||||
|
||||
export interface TabGroup {
|
||||
@@ -104,7 +111,13 @@ function parseRef(value: unknown, label: string): TabRef {
|
||||
if (value.placement !== undefined && value.placement !== 'manual') {
|
||||
throw new TabLayoutValidationError(`${label}.placement must be manual when present`);
|
||||
}
|
||||
return value.placement === 'manual' ? { kind: value.kind, id, placement: 'manual' } : { kind: value.kind, id };
|
||||
if (value.focus !== undefined && value.focus !== true) {
|
||||
throw new TabLayoutValidationError(`${label}.focus must be true when present`);
|
||||
}
|
||||
const ref: TabRef = { kind: value.kind, id };
|
||||
if (value.placement === 'manual') ref.placement = 'manual';
|
||||
if (value.focus === true) ref.focus = true;
|
||||
return ref;
|
||||
}
|
||||
|
||||
function parseTabLayout(input: unknown, repairDuplicates: boolean): TabLayout {
|
||||
@@ -395,6 +408,11 @@ export function setManualPlacement(input: TabLayout, target: TabRef, manual: boo
|
||||
return mapRef(input, target, (ref) => ({ ...ref, placement: 'manual' }));
|
||||
}
|
||||
|
||||
/** Pin `target` to the Focus section, or unpin it. Moves nothing; idempotent both ways. */
|
||||
export function setTabFocus(input: TabLayout, target: TabRef, focused: boolean): TabLayout {
|
||||
return mapRef(input, target, ({ focus: _focus, ...ref }) => (focused ? { ...ref, focus: true } : ref));
|
||||
}
|
||||
|
||||
export function followParent(input: TabLayout, target: TabRef, metadata: readonly TabRefMetadata[]): TabLayout {
|
||||
const normalized = normalizeTabLayout(input, metadata);
|
||||
if (target.kind !== 'session') {
|
||||
@@ -420,7 +438,8 @@ export function followParent(input: TabLayout, target: TabRef, metadata: readonl
|
||||
throw new TabLayoutValidationError(`session parent is not represented: ${targetMetadata.parentSessionId}`);
|
||||
}
|
||||
|
||||
const cleared = mapRef(normalized, target, (ref) => ({ kind: ref.kind, id: ref.id }));
|
||||
// Clear the manual placement only: a focused ref stays focused.
|
||||
const cleared = mapRef(normalized, target, ({ placement: _placement, ...ref }) => ref);
|
||||
return normalizeTabLayout(cleared, metadata);
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
+644
-79
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,175 @@
|
||||
/**
|
||||
* @fileoverview Keep tmux's prefix table on Codeman's socket down to `send-prefix`.
|
||||
*
|
||||
* Codeman drives tmux through commands, never through key bindings, but every
|
||||
* keystroke typed into a browser terminal reaches the `tmux attach-session`
|
||||
* client behind it. With tmux's stock prefix table that client obeys the whole
|
||||
* menu: Ctrl+B then `(`, `)`, `L`, `s` or `w` runs `switch-client` /
|
||||
* `choose-tree` and re-attaches the tab's client to a DIFFERENT session, so the
|
||||
* tab shows, and types into, another session's agent while Codeman still labels
|
||||
* it as the original. Ctrl+B `d` detaches the client, `$` renames the session
|
||||
* under Codeman's feet, `:` opens tmux's command prompt. Ctrl+B is not a rare
|
||||
* key in a terminal (readline backward-char, Claude Code's background chord),
|
||||
* and whatever is typed next lands in that menu.
|
||||
*
|
||||
* The prefix itself stays: Claude Code running inside tmux asks for
|
||||
* "ctrl+b ctrl+b" to background a command, which only works through
|
||||
* `send-prefix`. So the table keeps exactly that one binding, on whatever key
|
||||
* the socket's prefix is (the socket reads the user's `~/.tmux.conf`).
|
||||
*
|
||||
* `codeman tui` shares the socket (src/tui/tui-app.ts, beginAttachHandoff). For
|
||||
* the length of an attach it claims a held-Ctrl detach alias (`C-d`) in the
|
||||
* prefix table, and its one-key way out (F1) and Alt+1..9 in the `root` table.
|
||||
* `root` is never touched here, and a `C-<letter> detach-client` binding is left
|
||||
* alone (and put back when a reset clears the rest of the table around it), so
|
||||
* creating a session while a TUI attach is open does not take the attach's way
|
||||
* out away. The reset only runs while the table holds anything else, i.e. once
|
||||
* per tmux server in practice.
|
||||
*
|
||||
* That tolerance is server-wide like every key table: while such an alias is
|
||||
* bound, a browser tab's client can detach itself with it too. F1 in `root`
|
||||
* is the same kind of way out. Alt+1..9 are not: `codeman tui` binds those
|
||||
* through `if-shell -F` so `switch-client` runs only for the TUI's own client
|
||||
* (see `bindSwitchKey` in src/tui/tui-client.ts). This file still does not
|
||||
* touch `root`.
|
||||
*
|
||||
* Docker cases and owned remote sessions run a second tmux (`-L codeman-docker`,
|
||||
* `-L codeman-remote`) with `prefix C-q`. That server is not this socket, so
|
||||
* the reset above never sees it, and its stock prefix table still switches
|
||||
* the inner client. `nestedPrefixTableResetCommands` is the same reduction,
|
||||
* appended to those create chains. A non-owned remote attach joins another
|
||||
* Codeman's `-L codeman` and must not run it.
|
||||
*
|
||||
* @module tmux-prefix-table
|
||||
*/
|
||||
|
||||
import { execSync } from 'node:child_process';
|
||||
import { EXEC_TIMEOUT_MS } from './config/exec-timeout.js';
|
||||
import { shellescape } from './remote-hosts.js';
|
||||
|
||||
/** tmux's stock prefix, used when `show-options` cannot be read. */
|
||||
const DEFAULT_PREFIX = 'C-b';
|
||||
|
||||
/**
|
||||
* Prefix on the dedicated tmux inside a Docker case and an owned remote
|
||||
* session. Codeman's own socket keeps the user's prefix; these nested servers
|
||||
* always use C-q so it does not collide with the outer prefix.
|
||||
*/
|
||||
export const NESTED_TMUX_PREFIX = 'C-q';
|
||||
|
||||
/**
|
||||
* Commands that reduce a nested tmux's prefix table to `C-q send-prefix`.
|
||||
*
|
||||
* Joined with `\;` onto the `new-session` that creates the server
|
||||
* (`buildRemoteLaunchCommand`, `buildDockerLaunchCommand`). No `tmux` binary
|
||||
* and no `-g`: the table is server-global, which is what we want on these
|
||||
* dedicated sockets and nowhere else.
|
||||
*
|
||||
* Those servers load the user's tmux config (no `-f /dev/null`). A config that
|
||||
* leaves the prefix table empty makes `unbind-key -a -T prefix` fail with
|
||||
* "table prefix doesn't exist", and tmux then drops every later command in the
|
||||
* `\;` chain, including the bind and `remain-on-exit failed` (#210). The same
|
||||
* empty table is what #669 handled on the local socket. Binding `send-prefix`
|
||||
* first creates the table so the unbind can run; the unbind clears stock
|
||||
* bindings (and that first bind); the second bind puts `send-prefix` back.
|
||||
* Put them BEFORE `remain-on-exit failed` so a remote tmux that rejects
|
||||
* `failed` does not drop the reduction.
|
||||
*/
|
||||
export function nestedPrefixTableResetCommands(): readonly [string, string, string] {
|
||||
const sendPrefix = `bind-key -T prefix ${NESTED_TMUX_PREFIX} send-prefix`;
|
||||
return [sendPrefix, 'unbind-key -a -T prefix', sendPrefix];
|
||||
}
|
||||
|
||||
/** One `list-keys -T prefix` line: key and command. */
|
||||
const PREFIX_LINE = /^bind-key\s+(?:-\S+\s+)*?-T\s+prefix\s+(\S+)\s+(.+)$/;
|
||||
|
||||
/** The held-Ctrl detach alias `codeman tui` claims during an attach (heldCtrlAlias). */
|
||||
const TUI_DETACH_ALIAS = /^C-[a-z]$/;
|
||||
|
||||
/**
|
||||
* A key as `list-keys` prints it, without tmux's backslash escaping: `show-options`
|
||||
* prints the prefix `C-\` as is, while the listing shows `C-\\` (and `\#`, `\$`, `\"`).
|
||||
*/
|
||||
function unescapeListedKey(key: string): string {
|
||||
return key.replace(/\\(.)/g, '$1');
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether the prefix table (as printed by `tmux list-keys -T prefix`) still holds
|
||||
* a binding other than `<prefix> send-prefix` or a TUI detach alias, or lacks
|
||||
* `<prefix> send-prefix`. Unparseable output counts as "needs a reset":
|
||||
* resetting is idempotent, skipping one leaves `switch-client` reachable from
|
||||
* the browser. A prefix of `None` expects no `send-prefix` binding at all.
|
||||
*/
|
||||
export function prefixTableNeedsReset(listKeysOutput: string, prefix: string = DEFAULT_PREFIX): boolean {
|
||||
let hasSendPrefix = false;
|
||||
for (const raw of listKeysOutput.split('\n')) {
|
||||
const line = raw.trim();
|
||||
if (!line) continue;
|
||||
const match = PREFIX_LINE.exec(line);
|
||||
if (!match) return true;
|
||||
const key = unescapeListedKey(match[1]);
|
||||
const command = match[2].trim();
|
||||
if (command === 'send-prefix' && key === prefix) {
|
||||
hasSendPrefix = true;
|
||||
continue;
|
||||
}
|
||||
if (command === 'detach-client' && TUI_DETACH_ALIAS.test(key) && key !== prefix) continue;
|
||||
return true;
|
||||
}
|
||||
return prefix === 'None' ? false : !hasSendPrefix;
|
||||
}
|
||||
|
||||
/** The `C-<letter> detach-client` bindings in a `list-keys -T prefix` listing (see prefixTableNeedsReset). */
|
||||
function tuiDetachAliases(listKeysOutput: string, prefix: string): string[] {
|
||||
const keys: string[] = [];
|
||||
for (const raw of listKeysOutput.split('\n')) {
|
||||
const match = PREFIX_LINE.exec(raw.trim());
|
||||
if (!match) continue;
|
||||
const key = unescapeListedKey(match[1]);
|
||||
if (match[2].trim() === 'detach-client' && TUI_DETACH_ALIAS.test(key) && key !== prefix) keys.push(key);
|
||||
}
|
||||
return keys;
|
||||
}
|
||||
|
||||
/** Runs one tmux command line and returns its stdout. */
|
||||
export type TmuxRunner = (command: string) => string;
|
||||
|
||||
const defaultRunner: TmuxRunner = (command) =>
|
||||
execSync(command, { encoding: 'utf8', timeout: EXEC_TIMEOUT_MS, stdio: ['ignore', 'pipe', 'ignore'] });
|
||||
|
||||
/**
|
||||
* Reduce the prefix table of the tmux server behind `tmux` (e.g. `tmux -L 'codeman'`)
|
||||
* to `<prefix> send-prefix`, when it holds anything more. Never throws: a failure
|
||||
* only leaves tmux's stock bindings in place. Returns whether a reset ran.
|
||||
*/
|
||||
export function restrictTmuxPrefixTable(tmux: string, run: TmuxRunner = defaultRunner): boolean {
|
||||
let prefix = DEFAULT_PREFIX;
|
||||
try {
|
||||
prefix = run(`${tmux} show-options -gv prefix`).trim() || DEFAULT_PREFIX;
|
||||
} catch {
|
||||
/* keep tmux's stock prefix */
|
||||
}
|
||||
let listing: string | null;
|
||||
try {
|
||||
listing = run(`${tmux} list-keys -T prefix`);
|
||||
} catch {
|
||||
listing = null; // an empty table makes list-keys fail on some tmux builds
|
||||
}
|
||||
if (listing !== null && !prefixTableNeedsReset(listing, prefix)) return false;
|
||||
try {
|
||||
run(`${tmux} unbind-key -a -T prefix`);
|
||||
} catch {
|
||||
/* an empty table makes unbind-key -a fail too ("table prefix doesn't exist"); nothing to clear */
|
||||
}
|
||||
try {
|
||||
if (prefix !== 'None') run(`${tmux} bind-key -T prefix ${shellescape(prefix)} send-prefix`);
|
||||
// `unbind-key -a` also took a running TUI attach's held-Ctrl way out; give it back.
|
||||
for (const key of tuiDetachAliases(listing ?? '', prefix)) {
|
||||
run(`${tmux} bind-key -T prefix ${shellescape(key)} detach-client`);
|
||||
}
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
+65
-17
@@ -56,6 +56,8 @@ import { composerScroll, composerStep, composerText, createComposer, type TuiCom
|
||||
import { formatAwayDigest } from './tui-digest.js';
|
||||
import {
|
||||
ATTACH_BANNER_MARKER,
|
||||
isTuiClientTty,
|
||||
readControllingTty,
|
||||
TuiClient,
|
||||
type TuiSessionOptions,
|
||||
type TuiWindowSizing,
|
||||
@@ -317,6 +319,18 @@ export function detachChord(prefix?: string, key?: string): string {
|
||||
return `${formatPrefixKey(prefix)} then ${key || DEFAULT_DETACH_KEY}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* The chord an attach advertises when it could not claim the one-key way out.
|
||||
*
|
||||
* With no key bound to `detach-client` in the prefix table there is no stock
|
||||
* `d` to name: Codeman keeps only `send-prefix` there (src/tmux-prefix-table.ts),
|
||||
* so the held-Ctrl alias the attach claimed is the chord that works.
|
||||
*/
|
||||
export function fallbackDetachChord(prefix?: string, detachKey?: string, heldAlias?: string): string {
|
||||
if (!detachKey && heldAlias) return detachChord(prefix, formatPrefixKey(heldAlias));
|
||||
return detachChord(prefix, detachKey);
|
||||
}
|
||||
|
||||
/** `#` opens `#[…]`/`#{…}` in a tmux format, so a name carrying one must double it. */
|
||||
function escapeTmuxFormat(value: string): string {
|
||||
return value.replace(/#/g, '##');
|
||||
@@ -355,10 +369,10 @@ export function buildAttachBanner(options: {
|
||||
/** Alt+1..9 really switch sessions, so the bar may say so. */
|
||||
switchKeys?: boolean;
|
||||
}): Record<string, string> {
|
||||
const chord = escapeTmuxFormat(detachChord(options.prefix, options.detachKey));
|
||||
const chord = escapeTmuxFormat(fallbackDetachChord(options.prefix, options.detachKey, options.heldAlias));
|
||||
// Named on the bar because it is what people actually type: keeping Ctrl held
|
||||
// is the common way to press this, and the bar has to say that it works.
|
||||
const held = options.heldAlias ? escapeTmuxFormat(formatPrefixKey(options.heldAlias)) : '';
|
||||
const held = options.heldAlias && options.detachKey ? escapeTmuxFormat(formatPrefixKey(options.heldAlias)) : '';
|
||||
const alias = held ? ` (or ${held})` : '';
|
||||
const label = escapeTmuxFormat(truncateLabel((options.label ?? '').trim(), ATTACH_BANNER_LABEL_MAX));
|
||||
// ONE option, not `status-left`/`status-right`/`status-style`: `status-format[0]`
|
||||
@@ -538,7 +552,14 @@ export async function beginAttachHandoff(
|
||||
label: string,
|
||||
tabs: readonly TuiAttachTab[] = [],
|
||||
cols?: number,
|
||||
rows?: number
|
||||
rows?: number,
|
||||
/**
|
||||
* Tty the attach client will inherit. Omit to read this process's terminal.
|
||||
* `null` means there is no usable tty: switch keys are not bound (an unscoped
|
||||
* `switch-client` would move every client on the socket, including a browser
|
||||
* tab). Tests pass a tty so they do not depend on stdin.
|
||||
*/
|
||||
clientTty?: string | null
|
||||
): Promise<TuiAttachHandoff> {
|
||||
const prefix = (await client.readPrefixKey(muxName)) ?? undefined;
|
||||
// Read, not assumed: see detachChord() for the `d` vs `D` mix-up this closes.
|
||||
@@ -572,13 +593,29 @@ export async function beginAttachHandoff(
|
||||
// usable rather than decorative. Only the first nine, only sessions that
|
||||
// really have a pane, and only keys tmux reports as free — the same rule the
|
||||
// way-out key follows, so a binding of the user's own is never shadowed.
|
||||
//
|
||||
// The key table is server-global. The command is guarded to this terminal's
|
||||
// client (see bindSwitchKey): without a tty we bind nothing, and a leaked
|
||||
// unscoped `switch-client` from an older TUI is removed so it cannot keep
|
||||
// moving browser tabs. When we do claim the keys, drop whatever previous
|
||||
// attach left on M-1..M-9 first — including keys this strip will not reuse.
|
||||
const switchKeys: string[] = [];
|
||||
for (const tab of tabs.slice(0, 9)) {
|
||||
if (!tab.muxName) continue;
|
||||
const key = `M-${tab.index}`;
|
||||
const bound = await client.readPrefixBinding(key, 'root');
|
||||
if (bound !== null && !bound.startsWith('switch-client')) continue;
|
||||
if (await client.bindSwitchKey(key, tab.muxName)) switchKeys.push(key);
|
||||
const tty = clientTty === undefined ? readControllingTty() : clientTty || null;
|
||||
const switchTabs = tabs.slice(0, 9).filter((tab) => tab.index >= 1 && tab.index <= 9 && tab.muxName);
|
||||
const guarded = Boolean(tty && isTuiClientTty(tty));
|
||||
// The keys are bound here but stay inert until the attach hook records this
|
||||
// client's pid, and that hook is armed at the very end (see below).
|
||||
if (guarded && switchTabs.length > 0) {
|
||||
for (let index = 1; index <= 9; index++) await client.unbindSwitchKey(`M-${index}`);
|
||||
for (const tab of switchTabs) {
|
||||
const key = `M-${tab.index}`;
|
||||
const bound = await client.readPrefixBinding(key, 'root');
|
||||
// Takeover just cleared ours. Anything still here belongs to the user.
|
||||
if (bound !== null) continue;
|
||||
if (await client.bindSwitchKey(key, tab.muxName as string, tty as string)) switchKeys.push(key);
|
||||
}
|
||||
} else {
|
||||
for (let index = 1; index <= 9; index++) await client.unbindSwitchKey(`M-${index}`, '');
|
||||
}
|
||||
const bannerFor = (activeMux: string, ownLabel: string): Record<string, string> =>
|
||||
buildAttachBanner({
|
||||
@@ -623,10 +660,20 @@ export async function beginAttachHandoff(
|
||||
if (cols && rows && rows > 1) await client.presizeWindow(target, cols, rows - 1);
|
||||
if (target === muxName) await client.followAttachingClient(target);
|
||||
}
|
||||
// ⚠️ Armed LAST, as the final tmux call before the caller's `spawnSync`. The
|
||||
// hook is one-shot for this tty, so a TUI killed between arming and attaching
|
||||
// leaves it armed, and Linux hands the lowest free pty to the next opener: a
|
||||
// browser tab's attach client could land on this tty and have its pid
|
||||
// recorded, which would make these keys move that tab. Arming after the
|
||||
// dressing loop above shrinks that window to the attach's own startup. A
|
||||
// failed arm leaves the keys inert (the closed direction), and `restore()`
|
||||
// still unbinds them.
|
||||
if (switchKeys.length > 0) await client.armTuiSwitchGuard(tty as string);
|
||||
return {
|
||||
chord: oneKey ? ONE_KEY_DETACH : detachChord(prefix, detachKey),
|
||||
chord: oneKey ? ONE_KEY_DETACH : fallbackDetachChord(prefix, detachKey, claimed && alias ? alias : undefined),
|
||||
async restore(): Promise<void> {
|
||||
for (const key of switchKeys) await client.unbindSwitchKey(key);
|
||||
for (const key of switchKeys) await client.unbindSwitchKey(key, tty ?? '');
|
||||
if (tty && isTuiClientTty(tty)) await client.disarmTuiSwitchGuard(tty);
|
||||
if (oneKey) await client.unbindDetachKey(ONE_KEY_DETACH, 'root');
|
||||
if (claimed && alias) await client.unbindDetachKey(alias);
|
||||
// Options first, then the size: dropping the status bar gives its row
|
||||
@@ -702,7 +749,7 @@ export interface TuiKeymapContext {
|
||||
/** False in degraded mode, where the only verb that works is attach. */
|
||||
server: boolean;
|
||||
approval?: TuiApprovalKeys;
|
||||
/** tmux's detach chord as this socket reports it. Defaults to the stock `Ctrl+B D`. */
|
||||
/** How the last attach said to leave it. Defaults to F1, the key an attach claims. */
|
||||
detach?: string;
|
||||
/** A dead-row card is offering `r` to resume, so the footer has to say so. */
|
||||
resumeOffer?: boolean;
|
||||
@@ -761,7 +808,7 @@ export function helpKeysFor(glyphs: TuiGlyphSet, context: TuiKeymapContext): Arr
|
||||
['tab', 'next session'],
|
||||
// The one key that is not the TUI's: an attach hands the terminal to tmux,
|
||||
// and leaving it is the question every first attach asks.
|
||||
[context.detach ?? detachChord(), 'detach from an attached session, back to here'],
|
||||
[context.detach ?? ONE_KEY_DETACH, 'detach from an attached session, back to here'],
|
||||
];
|
||||
if (context.server) {
|
||||
keys.push(
|
||||
@@ -1109,11 +1156,12 @@ class TuiApp {
|
||||
private readonly glyphs: TuiGlyphSet;
|
||||
private readonly socket = resolveTmuxSocketName();
|
||||
/**
|
||||
* How to leave an attach, in the local tmux's own prefix. Read per attach
|
||||
* (a session can override the prefix) and remembered so the help overlay
|
||||
* names the real chord even before the first attach.
|
||||
* How to leave an attach. Read per attach (F1 may be the user's, a session
|
||||
* can override the prefix) and remembered for the help overlay; until the
|
||||
* first attach it names F1, what an attach claims unless the user's config
|
||||
* already holds it.
|
||||
*/
|
||||
private detachChordLabel = detachChord();
|
||||
private detachChordLabel: string = ONE_KEY_DETACH;
|
||||
/** True for the length of one resume. The only thing standing between a resume and a loop. */
|
||||
private resuming = false;
|
||||
|
||||
|
||||
+204
-28
@@ -41,7 +41,7 @@
|
||||
* @module tui/tui-client
|
||||
*/
|
||||
|
||||
import { execFile as execFileCb } from 'node:child_process';
|
||||
import { execFile as execFileCb, execFileSync } from 'node:child_process';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import http from 'node:http';
|
||||
import https from 'node:https';
|
||||
@@ -445,6 +445,91 @@ export function parsePrefixBinding(stdout: string, key: string, table: TuiKeyTab
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* User option holding the tmux client pid of the TUI attach that owns M-1..M-9.
|
||||
* Set by `TUI_SWITCH_ATTACH_HOOK` when that client attaches, read by the
|
||||
* `if-shell -F` guard on those keys. A dead pid matches no live client.
|
||||
*/
|
||||
export const TUI_SWITCH_CLIENT_PID = '@codeman-tui-pid';
|
||||
|
||||
/**
|
||||
* Indexed so it does not replace a user's own `client-attached` hook. High
|
||||
* index to stay clear of `[0]`. One-shot: the hook unsets itself after it
|
||||
* records the pid, so a later client that reuses the pty does not re-arm it.
|
||||
*/
|
||||
export const TUI_SWITCH_ATTACH_HOOK = 'client-attached[90]';
|
||||
|
||||
/** The keys `bindSwitchKey` binds. The key is interpolated into the binding's `send-keys` else command. */
|
||||
const TUI_SWITCH_KEY = /^M-[1-9]$/;
|
||||
|
||||
/** Linux `/dev/pts/N` and macOS `/dev/ttysNNN`. Anything else is not a client tty we will interpolate into a format. */
|
||||
const TUI_CLIENT_TTY = /^\/dev\/(?:pts\/[0-9]+|ttys[0-9]+)$/;
|
||||
|
||||
export function isTuiClientTty(value: string): boolean {
|
||||
return TUI_CLIENT_TTY.test(value);
|
||||
}
|
||||
|
||||
/**
|
||||
* The terminal this process is attached to, when it is a safe tmux client tty.
|
||||
* `codeman tui` inherits that tty into `tmux attach-session`, which is how the
|
||||
* guard knows its own client before the client exists. A pipe (tests, no TTY)
|
||||
* is null: callers then must not bind `switch-client` for every client.
|
||||
*/
|
||||
export function readControllingTty(): string | null {
|
||||
try {
|
||||
const out = execFileSync('tty', { encoding: 'utf8', stdio: ['inherit', 'pipe', 'ignore'] }).trim();
|
||||
return isTuiClientTty(out) ? out : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* `if-shell -F` guard for a TUI switch key. True only for the client whose tty
|
||||
* is `tty` AND whose pid is the one the attach hook stored. Evaluated when the
|
||||
* key is pressed, against the client that pressed it.
|
||||
*/
|
||||
export function tuiSwitchGuard(tty: string): string {
|
||||
return `#{&&:#{==:#{client_tty},${tty}},#{==:#{client_pid},#{${TUI_SWITCH_CLIENT_PID}}}}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* `set-hook` command for `TUI_SWITCH_ATTACH_HOOK`. `\#{client_pid}` is
|
||||
* defensive. On tmux 3.4 both this form and a bare `#{client_pid}` are stored
|
||||
* verbatim and both record the attaching client's pid. The escape stays so a
|
||||
* tmux that expands formats while the hook is stored (no client exists yet)
|
||||
* cannot save an empty pid.
|
||||
*/
|
||||
export function tuiSwitchAttachHook(tty: string): string {
|
||||
return (
|
||||
`if-shell -F "#{==:#{client_tty},${tty}}" ` +
|
||||
`"set -Fg ${TUI_SWITCH_CLIENT_PID} \\"\\#{client_pid}\\"; set-hook -gu ${TUI_SWITCH_ATTACH_HOOK}"`
|
||||
);
|
||||
}
|
||||
|
||||
const LEGACY_TUI_SWITCH = /^switch-client -t (\S+?)(?: \\; set-window-option -t \1 window-size latest)?$/;
|
||||
|
||||
/**
|
||||
* A root binding this TUI installed: the guarded `if-shell` form, or the
|
||||
* pre-guard `switch-client -t <mux>` form a killed TUI can leave behind.
|
||||
* A user's own `switch-client -n` or a target that is not a Codeman session
|
||||
* is not ours.
|
||||
*/
|
||||
export function isTuiSwitchBinding(command: string): boolean {
|
||||
const text = command.trim();
|
||||
if (text.includes(TUI_SWITCH_CLIENT_PID) && text.includes('#{client_tty}') && text.includes('switch-client -t ')) {
|
||||
return true;
|
||||
}
|
||||
const legacy = LEGACY_TUI_SWITCH.exec(text);
|
||||
return legacy !== null && MUX_NAME_PATTERN.test(legacy[1] ?? '');
|
||||
}
|
||||
|
||||
/** The tty embedded in a guarded switch binding, or null for a legacy one. */
|
||||
export function tuiSwitchBindingTty(command: string): string | null {
|
||||
const match = /#\{==:#\{client_tty\},(\/dev\/(?:pts\/[0-9]+|ttys[0-9]+))\}/.exec(command);
|
||||
return match?.[1] ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* The phrase the attach status bar carries. It doubles as the marker that tells
|
||||
* OUR bar apart from a user's own when sweeping one that leaked, so
|
||||
@@ -704,8 +789,9 @@ export class TuiClient {
|
||||
*
|
||||
* Two hard rules from CLAUDE.md, both enforced here so no caller can get them
|
||||
* wrong: the payload must END with `\r` or the server never issues Enter and
|
||||
* the text sits unsubmitted, and embedded newlines are stripped rather than
|
||||
* sent (multi-line input breaks Ink, and the server would join the lines).
|
||||
* the text sits unsubmitted, and embedded newlines become spaces rather than
|
||||
* being sent (the server would paste them as one block, which its submit
|
||||
* check cannot reliably recognise in the composer).
|
||||
* The `clientId`/`seq` pair makes delivery exactly-once, so a retry after a
|
||||
* dropped connection cannot type the prompt twice.
|
||||
*/
|
||||
@@ -958,21 +1044,33 @@ export class TuiClient {
|
||||
* `switch-client` rather than detach-then-attach: it keeps the terminal, so
|
||||
* the move is instant and the TUI stays blocked in its `spawnSync` exactly as
|
||||
* before, still holding the restore it owes.
|
||||
*
|
||||
* The binding is NOT server-wide in effect. Key tables are, so the key is
|
||||
* bound for every client, but the command is `if-shell -F` and runs only
|
||||
* when the client that pressed it is this TUI (`clientTty` plus the pid the
|
||||
* attach hook stored). A browser tab's Esc-then-digit, which tmux reads as
|
||||
* the same `M-<digit>` inside `escape-time`, does not match. An unsafe tty
|
||||
* refuses the bind rather than falling back to an unscoped `switch-client`.
|
||||
*
|
||||
* A client that fails the guard gets the key back: the if-shell else
|
||||
* argument is `send-keys <key>`, which writes it into that client's own pane
|
||||
* exactly as an unbound key would arrive. Without it the root binding ate
|
||||
* the key, so a browser tab's vim `<Esc>2j` lost its Esc and its digit for
|
||||
* the whole attach, and for good after a TUI killed before `restore()`. The
|
||||
* key is interpolated into that command string, so only `M-1`..`M-9` are
|
||||
* accepted.
|
||||
*
|
||||
* The two commands live in the if-shell argument, where `;` is a separator
|
||||
* inside that string. A bare `;` as its own argv element would end the
|
||||
* `bind-key` and run `window-size latest` immediately, which is the bug the
|
||||
* old `\;` argument existed to avoid. The second command is `window-size
|
||||
* latest`: the window is already the right size on arrival (see
|
||||
* presizeWindow), so it changes nothing then, and a resize while sitting in
|
||||
* that session follows this terminal.
|
||||
*/
|
||||
async bindSwitchKey(key: string, target: string): Promise<boolean> {
|
||||
if (!MUX_NAME_PATTERN.test(target)) return false;
|
||||
async bindSwitchKey(key: string, target: string, clientTty: string): Promise<boolean> {
|
||||
if (!TUI_SWITCH_KEY.test(key) || !MUX_NAME_PATTERN.test(target) || !isTuiClientTty(clientTty)) return false;
|
||||
try {
|
||||
// Two commands: go there, then let that window follow this terminal again.
|
||||
// It is already the right size (see presizeWindow), so `latest` changes
|
||||
// nothing on arrival and costs no repaint — it matters only if the
|
||||
// terminal is resized while sitting in that session.
|
||||
//
|
||||
// ⚠️ The separator MUST be an escaped `\;`. A bare `;` is a command
|
||||
// separator to tmux's own parser, so it ends the `bind-key` and RUNS
|
||||
// what follows immediately: the binding kept only `switch-client`, and
|
||||
// every session got `window-size latest` executed on it at attach time.
|
||||
// That is what left sessions on `latest` after a detach, since the
|
||||
// snapshot was then taken from already-corrupted state.
|
||||
await this.exec('tmux', [
|
||||
'-L',
|
||||
this.socket,
|
||||
@@ -980,15 +1078,11 @@ export class TuiClient {
|
||||
'-T',
|
||||
'root',
|
||||
key,
|
||||
'switch-client',
|
||||
'-t',
|
||||
target,
|
||||
'\\;',
|
||||
'set-window-option',
|
||||
'-t',
|
||||
target,
|
||||
'window-size',
|
||||
'latest',
|
||||
'if-shell',
|
||||
'-F',
|
||||
tuiSwitchGuard(clientTty),
|
||||
`switch-client -t ${target} ; set-window-option -t ${target} window-size latest`,
|
||||
`send-keys ${key}`,
|
||||
]);
|
||||
return true;
|
||||
} catch {
|
||||
@@ -996,10 +1090,92 @@ export class TuiClient {
|
||||
}
|
||||
}
|
||||
|
||||
/** Give back a switch key, but only while it still points at a session. */
|
||||
async unbindSwitchKey(key: string): Promise<void> {
|
||||
/**
|
||||
* Record this TUI's client pid when it attaches, then drop the hook so a
|
||||
* later client on a reused pty does not take the binding over. Best effort:
|
||||
* a failure means Alt+1..9 never switch (they reach the pane as Esc then
|
||||
* the digit), which is the closed direction.
|
||||
*/
|
||||
async armTuiSwitchGuard(clientTty: string): Promise<boolean> {
|
||||
if (!isTuiClientTty(clientTty)) return false;
|
||||
try {
|
||||
await this.exec('tmux', [
|
||||
'-L',
|
||||
this.socket,
|
||||
'set-hook',
|
||||
'-g',
|
||||
TUI_SWITCH_ATTACH_HOOK,
|
||||
tuiSwitchAttachHook(clientTty),
|
||||
]);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Undo `armTuiSwitchGuard` for this tty. The hook is removed only while it
|
||||
* still names `clientTty` (a second TUI may have replaced it). The pid option
|
||||
* is removed only when no live client still has it, so a second attach that
|
||||
* already recorded its own pid is left alone.
|
||||
*/
|
||||
async disarmTuiSwitchGuard(clientTty: string): Promise<void> {
|
||||
if (!isTuiClientTty(clientTty)) return;
|
||||
try {
|
||||
const { stdout } = await this.exec('tmux', ['-L', this.socket, 'show-hooks', '-g']);
|
||||
const hook = stdout.split('\n').find((line) => line.startsWith(`${TUI_SWITCH_ATTACH_HOOK} `));
|
||||
// The closed form, not the bare tty: `/dev/pts/4` is a substring of a
|
||||
// hook armed for `/dev/pts/41`, and removing that one would leave the
|
||||
// second TUI's Alt keys dead for its whole attach.
|
||||
if (hook?.includes(`#{client_tty},${clientTty}}`)) {
|
||||
await this.exec('tmux', ['-L', this.socket, 'set-hook', '-gu', TUI_SWITCH_ATTACH_HOOK]);
|
||||
}
|
||||
} catch {
|
||||
/* no server, or the hook is already gone */
|
||||
}
|
||||
let pid = '';
|
||||
try {
|
||||
const { stdout } = await this.exec('tmux', ['-L', this.socket, 'show-options', '-gv', TUI_SWITCH_CLIENT_PID]);
|
||||
pid = stdout.trim();
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
if (!pid) return;
|
||||
let live = '';
|
||||
try {
|
||||
({ stdout: live } = await this.exec('tmux', ['-L', this.socket, 'list-clients', '-F', '#{client_pid}']));
|
||||
} catch {
|
||||
live = '';
|
||||
}
|
||||
const pids = new Set(
|
||||
live
|
||||
.split('\n')
|
||||
.map((line) => line.trim())
|
||||
.filter((line) => line.length > 0)
|
||||
);
|
||||
if (pids.has(pid)) return;
|
||||
try {
|
||||
await this.exec('tmux', ['-L', this.socket, 'set-option', '-gu', TUI_SWITCH_CLIENT_PID]);
|
||||
} catch {
|
||||
/* already unset */
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Give back a switch key, but only while it is still one of ours.
|
||||
*
|
||||
* `ownerTty` set: keep a binding that names a different tty, so this attach's
|
||||
* restore does not take a second TUI's keys. `ownerTty` omitted: any of our
|
||||
* bindings on the key goes (the attaching TUI is taking the table over).
|
||||
* Pass `''` to drop only a legacy unscoped binding.
|
||||
*/
|
||||
async unbindSwitchKey(key: string, ownerTty?: string): Promise<void> {
|
||||
const bound = await this.readPrefixBinding(key, 'root');
|
||||
if (!bound || !bound.startsWith('switch-client')) return;
|
||||
if (!bound || !isTuiSwitchBinding(bound)) return;
|
||||
if (ownerTty !== undefined) {
|
||||
const boundTty = tuiSwitchBindingTty(bound);
|
||||
if (boundTty !== null && boundTty !== ownerTty) return;
|
||||
}
|
||||
try {
|
||||
await this.exec('tmux', ['-L', this.socket, 'unbind-key', '-T', 'root', key]);
|
||||
} catch {
|
||||
|
||||
@@ -121,6 +121,12 @@ export interface AppState {
|
||||
sessionOrder?: string[];
|
||||
/** Owner-scoped authoritative grouped tab layouts. */
|
||||
tabLayouts?: Record<string, TabLayout>;
|
||||
/**
|
||||
* Per-case default session group, keyed by the tab-layout owner key, then case
|
||||
* name. A group id from that owner's layout, or null for an intentional
|
||||
* Ungrouped choice. Read and written only through TabLayoutService.
|
||||
*/
|
||||
caseDefaultGroups?: Record<string, Record<string, string | null>>;
|
||||
}
|
||||
|
||||
// ========== Default Configuration ==========
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
/**
|
||||
* @fileoverview Discord bridge type definitions.
|
||||
*
|
||||
* The option lists and the config, result and status shapes of the two-way Discord bridge, kept apart
|
||||
* from the IO module (`src/discord-bridge.ts`, which pulls in `ws`) so the request schemas and the
|
||||
* route can import them alone, as the webhook's equivalents live in `push.ts`.
|
||||
*
|
||||
* Key exports:
|
||||
* - DISCORD_THREAD_CREATION, DISCORD_ON_DELETE, DISCORD_PING_MODES (+ their types): the option lists
|
||||
* - DiscordConfig: `~/.codeman/discord.json` (mode 0600; the bot token is a secret)
|
||||
* - DiscordResult, DiscordGatewayState, DiscordStatus: what `GET /api/discord` reports
|
||||
*
|
||||
* No dependencies on other domain modules but `push.ts` (the shared scope list).
|
||||
*/
|
||||
|
||||
import type { WebhookScope } from './push.js';
|
||||
|
||||
export const DISCORD_THREAD_CREATION = ['lazy', 'onSessionStart'] as const;
|
||||
export type DiscordThreadCreation = (typeof DISCORD_THREAD_CREATION)[number];
|
||||
export const DISCORD_ON_DELETE = ['keep', 'archive', 'delete'] as const;
|
||||
export type DiscordOnDelete = (typeof DISCORD_ON_DELETE)[number];
|
||||
export const DISCORD_PING_MODES = ['off', 'critical', 'all'] as const;
|
||||
export type DiscordPingMode = (typeof DISCORD_PING_MODES)[number];
|
||||
|
||||
export interface DiscordConfig {
|
||||
enabled: boolean;
|
||||
token: string;
|
||||
guildId: string;
|
||||
parentChannelId: string;
|
||||
allowedUserIds: string[];
|
||||
threadCreation: DiscordThreadCreation;
|
||||
onSessionDelete: DiscordOnDelete;
|
||||
/** What to do with threads the bridge made that no tab maps to any more: checked after boot, then daily. */
|
||||
staleThreads: DiscordOnDelete;
|
||||
pingMode: DiscordPingMode;
|
||||
/** `attention` skips info-level events (a finished response); `all` sends them too. */
|
||||
scope: WebhookScope;
|
||||
/** Tell each session how to reach the user (see DISCORD_STARTUP_HINT). On by default while the bridge is on. */
|
||||
startupHint: boolean;
|
||||
}
|
||||
|
||||
export interface DiscordResult {
|
||||
ok: boolean;
|
||||
/** True when the same event for the same tab was posted within the last few seconds, so nothing was sent. */
|
||||
deduped?: boolean;
|
||||
status?: number;
|
||||
error?: string;
|
||||
at: number;
|
||||
}
|
||||
|
||||
export type DiscordGatewayState = 'off' | 'connecting' | 'connected' | 'error';
|
||||
|
||||
/** `GET /api/discord`: the config without its token, plus what the bridge is doing. */
|
||||
export type DiscordStatus = Omit<DiscordConfig, 'token'> & {
|
||||
hasToken: boolean;
|
||||
tokenMasked: string;
|
||||
gateway: DiscordGatewayState;
|
||||
gatewayError: string | null;
|
||||
lastResult: DiscordResult | null;
|
||||
};
|
||||
@@ -25,6 +25,7 @@
|
||||
* | tools | ActiveBashTool, ImageDetectedEvent | In-memory, broadcast via SSE |
|
||||
* | teams | TeamConfig, TeamMember, TeamTask, InboxMessage, PaneInfo | `~/.claude/teams/`, `~/.claude/tasks/` → `GET /api/teams` |
|
||||
* | push | PushSubscriptionRecord, VapidKeys, WebhookConfig, WebhookStatus, WebhookResult | `~/.codeman/push-keys.json`, `~/.codeman/push-subscriptions.json`, `~/.codeman/webhook.json` |
|
||||
* | discord | DiscordConfig, DiscordResult, DiscordStatus, DiscordGatewayState | `~/.codeman/discord.json` → `GET`/`PUT /api/discord` |
|
||||
* | plan | PlanItem, PlanTaskStatus, TddPhase | In-memory → `GET /api/sessions/:id/plan/tasks` |
|
||||
* | orchestrator | OrchestratorState, OrchestratorPlan, OrchestratorConfig, OrchestratorPersistState | `~/.codeman/state.json` → `GET /api/orchestrator/status` |
|
||||
* | mcp-sync | McpSyncResult, McpSyncTargetResult | Other CLIs' own config files → `GET`/`POST /api/mcp-sync` |
|
||||
@@ -65,6 +66,7 @@ export * from './run-summary.js';
|
||||
export * from './tools.js';
|
||||
export * from './teams.js';
|
||||
export * from './push.js';
|
||||
export * from './discord.js';
|
||||
export * from './plan.js';
|
||||
export * from './orchestrator.js';
|
||||
export * from './update.js';
|
||||
|
||||
+158
-5
@@ -8,7 +8,7 @@
|
||||
* - SessionConfig — creation-time config (id, workingDir, createdAt)
|
||||
* - SessionOutput — captured stdout/stderr/exitCode
|
||||
* - SessionStatus — 'idle' | 'busy' | 'stopped' | 'error'
|
||||
* - SessionMode — 'claude' | 'shell' | 'opencode' | 'codex' | 'gemini' | 'antigravity' | 'pi' | 'grok' | 'deepseek' | 'omp' (which CLI backend)
|
||||
* - SessionMode — 'claude' | 'shell' | 'opencode' | 'codex' | 'gemini' | 'antigravity' | 'pi' | 'grok' | 'deepseek' | 'omp' | 'copilot' (which CLI backend)
|
||||
* - ClaudeMode — CLI permission mode ('dangerously-skip-permissions' | 'auto' | 'normal' | 'allowedTools')
|
||||
* - SessionColor — visual differentiation color
|
||||
* - OpenCodeConfig — OpenCode-specific settings (model, autoAllowTools, continueSession)
|
||||
@@ -17,6 +17,7 @@
|
||||
* - AntigravityConfig — Antigravity CLI (agy) settings (model, dangerouslySkipPermissions, resumeConversationId)
|
||||
* - PiConfig — Pi CLI (pi.dev) settings (model, provider, thinking, resume/continue, project trust)
|
||||
* - GrokConfig — Grok Build CLI (xAI `grok`) settings (model, alwaysApprove, resume/continue)
|
||||
* - CopilotConfig — GitHub Copilot CLI settings (model, allowAll, resume/continue)
|
||||
* - DeepSeekConfig — DeepSeek Harness (`dsh`) settings (profile, permissionMode, resume, status bridge)
|
||||
*
|
||||
* Cross-domain relationships:
|
||||
@@ -32,6 +33,7 @@
|
||||
|
||||
import type { RespawnConfig } from './respawn.js';
|
||||
import type { AttachmentDetectedType } from './tools.js';
|
||||
import type { GitFetchStatus } from '../git-fetch.js';
|
||||
|
||||
/** Status of a Claude session */
|
||||
export type SessionStatus = 'idle' | 'busy' | 'stopped' | 'error';
|
||||
@@ -56,7 +58,8 @@ export type SessionMode =
|
||||
| 'pi'
|
||||
| 'grok'
|
||||
| 'deepseek'
|
||||
| 'omp';
|
||||
| 'omp'
|
||||
| 'copilot';
|
||||
|
||||
/**
|
||||
* Who owns a session's name. `placeholder`: Codeman's own `w<n>-<case>` (or no
|
||||
@@ -78,7 +81,7 @@ export interface SessionWriteOptions {
|
||||
|
||||
export type RemoteCommandMode = Extract<
|
||||
SessionMode,
|
||||
'shell' | 'claude' | 'opencode' | 'codex' | 'gemini' | 'antigravity' | 'pi' | 'grok' | 'deepseek' | 'omp'
|
||||
'shell' | 'claude' | 'opencode' | 'codex' | 'gemini' | 'antigravity' | 'pi' | 'grok' | 'deepseek' | 'omp' | 'copilot'
|
||||
>;
|
||||
|
||||
/**
|
||||
@@ -213,7 +216,7 @@ export interface RemoteSessionInfo {
|
||||
/** Which CLI backends a Docker case can run (same set as remote). */
|
||||
export type DockerCommandMode = Extract<
|
||||
SessionMode,
|
||||
'shell' | 'claude' | 'opencode' | 'codex' | 'gemini' | 'antigravity' | 'pi' | 'grok' | 'deepseek' | 'omp'
|
||||
'shell' | 'claude' | 'opencode' | 'codex' | 'gemini' | 'antigravity' | 'pi' | 'grok' | 'deepseek' | 'omp' | 'copilot'
|
||||
>;
|
||||
|
||||
/** Container engine. Docker and Podman differ in the uid/userns + host-gateway alias. */
|
||||
@@ -525,6 +528,32 @@ export interface GrokConfig {
|
||||
resumeSessionId?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* GitHub Copilot CLI (`copilot`) session configuration.
|
||||
*
|
||||
* Copilot asks before running tools ("Manual Approval") and before touching paths
|
||||
* outside the trusted folder. `--yolo` is its allow-all switch (tools, paths and URLs),
|
||||
* the one the Run button opts into like the other agent CLIs' bypass switches. Verified
|
||||
* against copilot 1.0.94.
|
||||
*/
|
||||
export interface CopilotConfig {
|
||||
/** Model ID passed via --model (e.g. "claude-sonnet-5.5", or "auto"). */
|
||||
model?: string;
|
||||
/**
|
||||
* Allow every tool, path and URL without asking (passes --yolo). Absent = Copilot's own
|
||||
* Manual Approval mode. Multi-user: forced off for non-granted owners by the only-if-sent
|
||||
* clamp branch, like codex/antigravity/grok.
|
||||
*/
|
||||
allowAll?: boolean;
|
||||
/** Resume the most recent session (--continue). Skipped when resumeSessionId is set. */
|
||||
continueSession?: boolean;
|
||||
/**
|
||||
* Resume a specific session (--resume <value>): an id, id prefix or session name, as ONE plain word
|
||||
* starting with a letter or digit (a leading `-` would make the value its own flag).
|
||||
*/
|
||||
resumeSessionId?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* DeepSeek Harness (`dsh`) session configuration.
|
||||
*
|
||||
@@ -724,6 +753,78 @@ export interface PaneExit {
|
||||
at: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalized prompt-cache state of a session's main conversation, from the statusline's
|
||||
* `prompt_cache` object (`parsePromptCache` in usage-telemetry.ts). `warm` is the only
|
||||
* field Claude always reports: once the prefix has gone cold it sends `warm:false` with a
|
||||
* null `expires_at`.
|
||||
*/
|
||||
export interface PromptCacheStatus {
|
||||
warm: boolean;
|
||||
/** Cache lifetime of the current prefix; Claude Code only ever reports these two. */
|
||||
ttl?: '5m' | '1h';
|
||||
/** Epoch MILLISECONDS when the cached prefix goes cold (the statusline reports seconds). */
|
||||
expiresAt?: number;
|
||||
/** Tokens the next request re-writes if the cache has gone cold by then. */
|
||||
recacheTokensIfCold?: number;
|
||||
/** Requests this session that re-processed content the cache already held. */
|
||||
misses?: number;
|
||||
/** Cache rebuilds that followed a compaction or a clearing of old tool results. */
|
||||
expectedRebuilds?: number;
|
||||
/** Cache read tokens as a fraction of all input tokens this session, 0-1. */
|
||||
hitRatio?: number;
|
||||
/** Claude Code's diagnosis of the last miss, e.g. `ttl_expired_1h` or `tools_changed`. */
|
||||
lastMissCauses?: string[];
|
||||
/**
|
||||
* Epoch MS when Codeman saw the context compacted (`/compact`, Claude Code's own idle
|
||||
* compaction, or a clearing of old tool results): the first warm report whose
|
||||
* `recacheTokensIfCold` is null after one that carried it. Carried while the null window holds,
|
||||
* dropped at the next request. Derived in `Session.setPromptCache`, not reported by the
|
||||
* statusline, so it is never set by `parsePromptCache`. Lets the readout say "Context compacted
|
||||
* at HH:MM"; it is never an alert.
|
||||
*/
|
||||
compactedAt?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether this launch carries Codeman's statusLine exporter, the only source of
|
||||
* `promptCache`, and why not when it does not. Decided once per claude spawn
|
||||
* (`TmuxManager`), so a setting flipped later does not change it until the next launch.
|
||||
* - `injected`: the exporter is on the launch.
|
||||
* - `collection-off`: plan-usage collection (`showPlanUsageLimits`) was off at launch.
|
||||
* - `workspace-statusline`: the workspace's own `.claude/settings.local.json` sets a
|
||||
* statusLine (or could not be read), and Codeman never replaces one there.
|
||||
* - `remote`: remote and Docker launches never carry the exporter.
|
||||
*/
|
||||
export type StatusLineExporter = 'injected' | 'collection-off' | 'workspace-statusline' | 'remote';
|
||||
|
||||
/**
|
||||
* What the prompt-cache keep-warm (`prompt-cache-keepwarm.ts`) is doing for a session, for
|
||||
* the Session Options readout. Ephemeral: published through `toState()` and the
|
||||
* `session:keepWarm` event, never restored.
|
||||
*/
|
||||
export interface KeepWarmStatus {
|
||||
/** Effective opt-in: the session's own flag, else the app-wide default (see `keepWarmOn`). */
|
||||
enabled: boolean;
|
||||
/**
|
||||
* Whether this launch carries `idleCompaction: false` (`MuxSession.idleCompactionOff`). False
|
||||
* on a session switched on after launch: Claude Code's own idle compaction may still run
|
||||
* until its next launch.
|
||||
*/
|
||||
idleCompactionOff: boolean;
|
||||
/** `armed` = waiting for the due time, `waiting` = due but deferred by a guard, `nudged` = sent, waiting for the next cache report. */
|
||||
state: 'off' | 'armed' | 'waiting' | 'nudged' | 'stopped';
|
||||
/** Next evaluation, epoch ms (armed, waiting, nudged). */
|
||||
nextCheckAt?: number;
|
||||
/** Why it is waiting or stopped, in words. */
|
||||
reason?: string;
|
||||
/** Nudges sent this session so far. */
|
||||
nudges: number;
|
||||
lastNudgeAt?: number;
|
||||
/** When the keep-warm window after the last real prompt closes, epoch ms. */
|
||||
budgetEndsAt?: number;
|
||||
}
|
||||
|
||||
export interface SessionState {
|
||||
/** Unique session identifier */
|
||||
id: string;
|
||||
@@ -733,6 +834,19 @@ export interface SessionState {
|
||||
status: SessionStatus;
|
||||
/** Working directory path */
|
||||
workingDir: string;
|
||||
/**
|
||||
* The directory the session's local tmux pane is in NOW (`#{pane_current_path}`),
|
||||
* when known. `workingDir` is fixed at create time and never follows a `cd`;
|
||||
* this is what a relative path printed in the terminal is relative to.
|
||||
*
|
||||
* Runtime-only: refreshed from the pane-exit watcher's read and never restored
|
||||
* from disk, so a restart starts blank. Absent for remote and docker sessions
|
||||
* (their local pane runs the ssh / docker-exec client) and for direct-PTY or
|
||||
* rediscovered sessions. NOT a confinement root: the file routes stay confined
|
||||
* to `workingDir`, and a path resolved outside it goes through the attachment
|
||||
* routes like any other out-of-workspace path.
|
||||
*/
|
||||
paneCwd?: string;
|
||||
/** Remote execution metadata, present when this session runs over SSH through local tmux */
|
||||
remote?: SessionRemote;
|
||||
/** Docker execution metadata, present when this session runs inside a container via local tmux + docker exec */
|
||||
@@ -746,7 +860,9 @@ export interface SessionState {
|
||||
*
|
||||
* ⚠️ UI DECORATION ONLY — it draws the lineage lines between tabs. It is never an
|
||||
* ownership, permission, or lifecycle signal: a child outlives its parent, and an
|
||||
* unresolvable value is dropped rather than failing the spawn.
|
||||
* unresolvable value is dropped rather than failing the spawn. Its one non-visual reader
|
||||
* picks a default, nothing more: a session an agent spawned does not inherit the app-wide
|
||||
* prompt-cache keep-warm default (`keepWarmOn`), though its own switch still works.
|
||||
*/
|
||||
parentSessionId?: string;
|
||||
/** ID of currently assigned task, null if none */
|
||||
@@ -775,6 +891,34 @@ export interface SessionState {
|
||||
autoResumeEnabled?: boolean;
|
||||
/** Pending usage-limit auto-resume fire time (epoch ms), if armed */
|
||||
autoResumeAt?: number;
|
||||
/**
|
||||
* Prompt-cache state of the main conversation as Claude Code last reported it on the
|
||||
* statusline (v2.1.251+), fed by `POST /api/status-telemetry`. Absent until the first
|
||||
* report. Restored on a restart (the Session constructor's `promptCache`) so the readout
|
||||
* is not blank until the pane's next render; the readout is time-aware, so a value whose
|
||||
* `expiresAt` has passed reads as cold rather than as a stale warm.
|
||||
*/
|
||||
promptCache?: PromptCacheStatus;
|
||||
/** Why a cache report will or will not arrive (see `StatusLineExporter`); absent when unknown. */
|
||||
statusLineExporter?: StatusLineExporter;
|
||||
/** Prompt-cache keep-warm opt-in; absent = follow the app-wide `promptCacheKeepWarm` default. */
|
||||
keepWarmEnabled?: boolean;
|
||||
/** What the keep-warm is doing right now; ephemeral, never restored. */
|
||||
keepWarm?: KeepWarmStatus;
|
||||
/**
|
||||
* Git fetch on an interval for this session's repositories; absent = follow the app-wide
|
||||
* `gitFetchEnabled` default. Restored on a restart, like `keepWarmEnabled`, because the choice is the
|
||||
* user's and outlives the run.
|
||||
*/
|
||||
gitFetchEnabled?: boolean;
|
||||
/** Seconds between this session's runs; absent = follow the app-wide `gitFetchIntervalSeconds` default. */
|
||||
gitFetchIntervalSeconds?: number;
|
||||
/**
|
||||
* What this session's last fetch did. 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, so it is the panel's truth for the
|
||||
* life of the session, not across a restart.
|
||||
*/
|
||||
gitFetch?: GitFetchStatus;
|
||||
/** Pinned to the top of the session manager list (COD-139) */
|
||||
pinned?: boolean;
|
||||
/** When the session was pinned (epoch ms) — orders the pinned group, most-recent-first */
|
||||
@@ -838,6 +982,8 @@ export interface SessionState {
|
||||
piConfig?: PiConfig;
|
||||
/** Grok-specific configuration (only for mode === 'grok') */
|
||||
grokConfig?: GrokConfig;
|
||||
/** GitHub Copilot CLI configuration (only for mode === 'copilot') */
|
||||
copilotConfig?: CopilotConfig;
|
||||
/** DeepSeek Harness configuration (only for mode === 'deepseek') */
|
||||
deepSeekConfig?: DeepSeekConfig;
|
||||
/** OMP-specific configuration (only for mode === 'omp') */
|
||||
@@ -887,6 +1033,13 @@ export interface SessionState {
|
||||
* again until the pane's own Enter is known.
|
||||
*/
|
||||
lastSubmitAt?: number;
|
||||
/**
|
||||
* Wall-clock ms of the last submit that was NOT the prompt-cache keep-alive
|
||||
* (Session.lastRealPromptAt): the keep-warm cap's clock. Persisted so a restart
|
||||
* does not reopen the window, which `lastSubmitAt` cannot do since every nudge
|
||||
* moves it.
|
||||
*/
|
||||
lastRealPromptAt?: number;
|
||||
/**
|
||||
* Claude conversations this pane has been on, oldest first, current last.
|
||||
* Written ONLY from a first-hand `UserPromptSubmit`/`Stop` hook payload —
|
||||
|
||||
+158
-10
@@ -15,6 +15,13 @@
|
||||
* Only those two windows exist (no Opus-weekly field). `rate_limits` is absent
|
||||
* before the first API response and for non-subscriber auth — both yield null.
|
||||
*
|
||||
* Since Claude Code v2.1.251 the same blob carries a `prompt_cache` object for the
|
||||
* main conversation (`warm`, `ttl`, `expires_at`, `recache_tokens_if_cold`,
|
||||
* `misses`, `last_miss_cause`, ...). `parsePromptCache` normalizes the subset
|
||||
* Codeman reads, and the footer shows it as `cache:until HH:MM` while warm and
|
||||
* `cache:cold` otherwise. Claude re-runs the statusline when `expires_at` passes,
|
||||
* so the footer flips to cold on its own.
|
||||
*
|
||||
* The Codex parser consumes the read-only `account/rateLimits/read` app-server
|
||||
* response and selects only the main `codex` bucket, excluding model-specific
|
||||
* buckets. All functions are pure for testability. See
|
||||
@@ -23,18 +30,30 @@
|
||||
* @module usage-telemetry
|
||||
*/
|
||||
|
||||
import type { PromptCacheStatus } from './types/index.js';
|
||||
|
||||
export type { PromptCacheStatus };
|
||||
|
||||
/** A single normalized plan-usage window. */
|
||||
export interface UsageWindow {
|
||||
/** Percent of the window consumed, 0–100. */
|
||||
usedPercentage: number;
|
||||
/** Epoch MILLISECONDS when the window resets (statusline reports seconds). */
|
||||
resetAt: number;
|
||||
/**
|
||||
* Absolute counts, for a window measured in requests rather than as a bare percent (Copilot's
|
||||
* monthly premium-request quota). Absent for the Claude and Codex windows.
|
||||
*/
|
||||
used?: number;
|
||||
limit?: number;
|
||||
}
|
||||
|
||||
/** Normalized telemetry Codeman broadcasts to the UI. */
|
||||
export interface StatusTelemetry {
|
||||
fiveHour?: UsageWindow;
|
||||
sevenDay?: UsageWindow;
|
||||
/** A calendar-month quota (GitHub Copilot's premium requests); not a rolling 5h/7d window. */
|
||||
monthly?: UsageWindow;
|
||||
/** Context-window percent used, 0–100 (bonus field from the same payload). */
|
||||
contextUsedPercentage?: number;
|
||||
/** Session cost in USD (bonus field). */
|
||||
@@ -52,6 +71,24 @@ export interface RawStatuslinePayload {
|
||||
context_window?: { used_percentage?: number; total_input_tokens?: number; total_output_tokens?: number };
|
||||
cost?: { total_cost_usd?: number };
|
||||
model?: { display_name?: string };
|
||||
/** Main-conversation prompt-cache statistics (Claude Code v2.1.251+); absent before the first API response. */
|
||||
prompt_cache?: RawPromptCache | null;
|
||||
}
|
||||
|
||||
/** Raw `prompt_cache` object as Claude emits it (the subset Codeman reads; every field null-tolerant). */
|
||||
export interface RawPromptCache {
|
||||
warm?: boolean | null;
|
||||
ttl?: string | null;
|
||||
/** Epoch SECONDS when the cached prefix goes cold; null once the last response reported no cache tokens. */
|
||||
expires_at?: number | null;
|
||||
/** Null right after a compaction or tool-result clearing, until the next request records the rewritten size. */
|
||||
recache_tokens_if_cold?: number | null;
|
||||
misses?: number | null;
|
||||
/** Cache rebuilds that followed a compaction or a clearing of old tool results (counted at the request after one; not read for the compaction stamp). */
|
||||
expected_rebuilds?: number | null;
|
||||
/** Cache read tokens as a fraction of all input tokens this session, 0-1; null while those counts are all zero. */
|
||||
hit_ratio?: number | null;
|
||||
last_miss_cause?: { causes?: unknown } | null;
|
||||
}
|
||||
|
||||
interface RawCodexRateLimitWindow {
|
||||
@@ -107,6 +144,43 @@ export function parseStatusTelemetry(data: RawStatuslinePayload | undefined): St
|
||||
return t;
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalize the statusline's `prompt_cache` object. Null when the payload has none
|
||||
* (pre-first-response, or a Claude Code older than v2.1.251) or when `warm` is not
|
||||
* a boolean, so the caller can treat the cache state as unknown rather than cold.
|
||||
*/
|
||||
export function parsePromptCache(data: RawStatuslinePayload | undefined): PromptCacheStatus | null {
|
||||
const pc = data?.prompt_cache;
|
||||
if (!pc || typeof pc.warm !== 'boolean') return null;
|
||||
const s: PromptCacheStatus = { warm: pc.warm };
|
||||
if (pc.ttl === '5m' || pc.ttl === '1h') s.ttl = pc.ttl;
|
||||
if (typeof pc.expires_at === 'number' && Number.isFinite(pc.expires_at) && pc.expires_at > 0) {
|
||||
// Validate the CONVERTED ms, not just the seconds: a huge seconds value overflows to
|
||||
// Infinity or lands outside the Date range after *1000, which renders "cache:until
|
||||
// NaN:NaN". 8.64e15 is the max absolute epoch ms a Date can represent.
|
||||
const ms = Math.round(pc.expires_at * 1000);
|
||||
if (Number.isFinite(ms) && Math.abs(ms) <= 8.64e15) s.expiresAt = ms;
|
||||
}
|
||||
if (typeof pc.recache_tokens_if_cold === 'number' && Number.isFinite(pc.recache_tokens_if_cold)) {
|
||||
s.recacheTokensIfCold = Math.max(0, Math.round(pc.recache_tokens_if_cold));
|
||||
}
|
||||
if (typeof pc.misses === 'number' && Number.isFinite(pc.misses)) {
|
||||
s.misses = Math.max(0, Math.round(pc.misses));
|
||||
}
|
||||
if (typeof pc.expected_rebuilds === 'number' && Number.isFinite(pc.expected_rebuilds)) {
|
||||
s.expectedRebuilds = Math.max(0, Math.round(pc.expected_rebuilds));
|
||||
}
|
||||
if (typeof pc.hit_ratio === 'number' && Number.isFinite(pc.hit_ratio)) {
|
||||
s.hitRatio = Math.min(1, Math.max(0, pc.hit_ratio));
|
||||
}
|
||||
const causes = pc.last_miss_cause?.causes;
|
||||
if (Array.isArray(causes)) {
|
||||
const names = causes.filter((c): c is string => typeof c === 'string' && c.length > 0).map((c) => c.slice(0, 60));
|
||||
if (names.length) s.lastMissCauses = names;
|
||||
}
|
||||
return s;
|
||||
}
|
||||
|
||||
/** Normalize the main Codex app-server bucket into the chip's two known windows. */
|
||||
export function parseCodexRateLimitsResponse(value: unknown): StatusTelemetry | null {
|
||||
if (!value || typeof value !== 'object') return null;
|
||||
@@ -130,6 +204,51 @@ export function parseCodexRateLimitsResponse(value: unknown): StatusTelemetry |
|
||||
return telemetry.fiveHour || telemetry.sevenDay ? telemetry : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalize GitHub Copilot's quota snapshot (`GET /copilot_internal/user`) into the chip's monthly
|
||||
* window. Only `premium_interactions` is a metered quota: `chat` and `completions` report
|
||||
* `unlimited: true` on every plan seen, and an unlimited premium quota has nothing to meter, so it
|
||||
* yields null and the chip shows no Copilot row rather than a bar stuck at 0%.
|
||||
*/
|
||||
export function parseCopilotQuotaResponse(value: unknown): StatusTelemetry | null {
|
||||
if (!value || typeof value !== 'object') return null;
|
||||
const raw = value as {
|
||||
quota_snapshots?: {
|
||||
premium_interactions?: {
|
||||
entitlement?: unknown;
|
||||
remaining?: unknown;
|
||||
percent_remaining?: unknown;
|
||||
unlimited?: unknown;
|
||||
};
|
||||
};
|
||||
quota_reset_date_utc?: unknown;
|
||||
quota_reset_date?: unknown;
|
||||
};
|
||||
const snap = raw.quota_snapshots?.premium_interactions;
|
||||
if (!snap || typeof snap !== 'object' || snap.unlimited === true) return null;
|
||||
// A zero (or negative) quota that is not 'unlimited' would show a stuck 0% or 100% row.
|
||||
if (typeof snap.entitlement === 'number' && snap.entitlement <= 0) return null;
|
||||
|
||||
const finite = (v: unknown): v is number => typeof v === 'number' && Number.isFinite(v);
|
||||
const limit = finite(snap.entitlement) && snap.entitlement > 0 ? snap.entitlement : undefined;
|
||||
let usedPct: number | null = null;
|
||||
if (finite(snap.percent_remaining)) usedPct = 100 - snap.percent_remaining;
|
||||
else if (limit !== undefined && finite(snap.remaining)) usedPct = (1 - snap.remaining / limit) * 100;
|
||||
if (usedPct === null) return null;
|
||||
|
||||
const resetRaw = typeof raw.quota_reset_date_utc === 'string' ? raw.quota_reset_date_utc : raw.quota_reset_date;
|
||||
const parsedReset = typeof resetRaw === 'string' ? Date.parse(resetRaw) : NaN;
|
||||
const monthly: UsageWindow = {
|
||||
usedPercentage: clampPct(usedPct),
|
||||
resetAt: Number.isFinite(parsedReset) && parsedReset > 0 ? parsedReset : 0,
|
||||
};
|
||||
if (limit !== undefined && finite(snap.remaining)) {
|
||||
monthly.limit = limit;
|
||||
monthly.used = Math.max(0, Math.round(limit - snap.remaining));
|
||||
}
|
||||
return { monthly };
|
||||
}
|
||||
|
||||
/**
|
||||
* Current-session status for the in-terminal statusline footer. This is the
|
||||
* "status of the current session" the user sees in Claude's footer — distinct
|
||||
@@ -140,6 +259,8 @@ export interface SessionStatus {
|
||||
inputTokens?: number;
|
||||
outputTokens?: number;
|
||||
contextUsedPercentage?: number;
|
||||
/** Prompt-cache state of the main conversation, when the statusline reports it. */
|
||||
cache?: PromptCacheStatus;
|
||||
}
|
||||
|
||||
/** Group a non-negative integer with thousands separators: 562411 → "562,411". */
|
||||
@@ -166,19 +287,34 @@ export function parseSessionStatus(data: RawStatuslinePayload | undefined): Sess
|
||||
if (typeof cw?.used_percentage === 'number') {
|
||||
s.contextUsedPercentage = clampPct(cw.used_percentage);
|
||||
}
|
||||
const cache = parsePromptCache(data);
|
||||
if (cache) s.cache = cache;
|
||||
return Object.keys(s).length ? s : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Format the in-terminal statusline footer: the CURRENT SESSION's status —
|
||||
* `Opus 4.8 (1M context) in:562,411 out:1,188 ctx:56%` — NOT the plan limits,
|
||||
* which live in the Codeman header chip. Claude requires a statusLine command to
|
||||
* emit the rate_limits JSON at all, so this is what that command prints back
|
||||
* when it has no statusline of the user's own to wrap. With nothing to show it
|
||||
* returns '' rather than a brand word: a bare `codeman` on the statusline is
|
||||
* the symptom discussion #405 opened with.
|
||||
* `cache:until 19:00` (server-local clock) while the prefix is warm, `cache:cold`
|
||||
* once Claude reports it cold or the render happened past `expires_at`. A clock
|
||||
* time rather than a countdown because the footer only re-renders on statusline
|
||||
* triggers, so a countdown would sit stale on screen between turns.
|
||||
*/
|
||||
export function formatSessionStatusText(s: SessionStatus | null): string {
|
||||
function formatCacheGroup(c: PromptCacheStatus, now: number): string {
|
||||
if (!c.warm || c.expiresAt == null || c.expiresAt <= now) return 'cache:cold';
|
||||
const d = new Date(c.expiresAt);
|
||||
return `cache:until ${String(d.getHours()).padStart(2, '0')}:${String(d.getMinutes()).padStart(2, '0')}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Format the in-terminal statusline footer: the CURRENT SESSION's status —
|
||||
* `Opus 4.8 (1M context) in:562,411 out:1,188 ctx:56% cache:until 19:00` — NOT
|
||||
* the plan limits, which live in the Codeman header chip. Claude requires a
|
||||
* statusLine command to emit the rate_limits JSON at all, so this is what that
|
||||
* command prints back when it has no statusline of the user's own to wrap. With
|
||||
* nothing to show it returns '' rather than a brand word: a bare `codeman` on the
|
||||
* statusline is the symptom discussion #405 opened with. `now` is injectable so
|
||||
* the cache group is testable.
|
||||
*/
|
||||
export function formatSessionStatusText(s: SessionStatus | null, now: number = Date.now()): string {
|
||||
if (!s) return '';
|
||||
const groups: string[] = [];
|
||||
if (s.modelDisplayName) groups.push(s.modelDisplayName);
|
||||
@@ -187,6 +323,7 @@ export function formatSessionStatusText(s: SessionStatus | null): string {
|
||||
if (s.outputTokens != null) tok.push(`out:${withCommas(s.outputTokens)}`);
|
||||
if (tok.length) groups.push(tok.join(' '));
|
||||
if (s.contextUsedPercentage != null) groups.push(`ctx:${Math.round(clampPct(s.contextUsedPercentage))}%`);
|
||||
if (s.cache) groups.push(formatCacheGroup(s.cache, now));
|
||||
return groups.length ? groups.join(' ') : '';
|
||||
}
|
||||
|
||||
@@ -202,10 +339,21 @@ export function formatSessionStatusText(s: SessionStatus | null): string {
|
||||
* localStorage write + identical chip re-render each time.
|
||||
*/
|
||||
export function telemetrySignature(t: StatusTelemetry): string {
|
||||
return JSON.stringify([
|
||||
const parts: Array<number | null> = [
|
||||
t.fiveHour ? Math.round(t.fiveHour.usedPercentage) : null,
|
||||
t.fiveHour?.resetAt ?? null,
|
||||
t.sevenDay ? Math.round(t.sevenDay.usedPercentage) : null,
|
||||
t.sevenDay?.resetAt ?? null,
|
||||
]);
|
||||
];
|
||||
// Appended only when present, so the Claude and Codex signatures are byte-identical to before.
|
||||
// The limit is part of it: a plan change mid-month (300 to 1500 with 100 used) keeps `used` and the reset.
|
||||
if (t.monthly) {
|
||||
parts.push(
|
||||
Math.round(t.monthly.usedPercentage),
|
||||
t.monthly.used ?? null,
|
||||
t.monthly.limit ?? null,
|
||||
t.monthly.resetAt
|
||||
);
|
||||
}
|
||||
return JSON.stringify(parts);
|
||||
}
|
||||
@@ -7,7 +7,7 @@
|
||||
* and the badge could disagree with the Run menu.
|
||||
*
|
||||
* Every probe is a memoized resolver, so this is cheap to call per request. Dynamic imports
|
||||
* keep the nine resolvers out of any module that never asks.
|
||||
* keep the ten resolvers out of any module that never asks.
|
||||
*/
|
||||
|
||||
import type { CliEntry } from '../config/cli-registry/types.js';
|
||||
@@ -30,6 +30,7 @@ export async function probeStockCliAvailability(): Promise<Record<string, boolea
|
||||
{ isGrokAvailable },
|
||||
{ isDeepSeekRunnable },
|
||||
{ isOmpAvailable },
|
||||
{ isCopilotAvailable },
|
||||
] = await Promise.all([
|
||||
import('./claude-cli-resolver.js'),
|
||||
import('./opencode-cli-resolver.js'),
|
||||
@@ -40,6 +41,7 @@ export async function probeStockCliAvailability(): Promise<Record<string, boolea
|
||||
import('./grok-cli-resolver.js'),
|
||||
import('./deepseek-cli-resolver.js'),
|
||||
import('./omp-cli-resolver.js'),
|
||||
import('./copilot-cli-resolver.js'),
|
||||
]);
|
||||
return {
|
||||
claude: isClaudeAvailable(),
|
||||
@@ -51,6 +53,7 @@ export async function probeStockCliAvailability(): Promise<Record<string, boolea
|
||||
grok: isGrokAvailable(),
|
||||
deepseek: isDeepSeekRunnable(),
|
||||
omp: isOmpAvailable(),
|
||||
copilot: isCopilotAvailable(),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,139 @@
|
||||
/**
|
||||
* @fileoverview Resolve the GitHub Copilot CLI binary across common install paths.
|
||||
*
|
||||
* Uses the shared `createCliExecutableResolver` (cli-executable-resolver.ts),
|
||||
* same as the sibling claude/opencode/codex/gemini/antigravity/pi resolvers:
|
||||
* server process PATH first, then common install directories, then — last,
|
||||
* because it is the only step that spawns anything — an interactive login
|
||||
* shell, which is what finds nvm/Homebrew/user-npm installs when Codeman runs
|
||||
* as a systemd/launchd service with a minimal PATH.
|
||||
*
|
||||
* Provides an augmented PATH directory for tmux sessions.
|
||||
*
|
||||
* @module utils/copilot-cli-resolver
|
||||
*/
|
||||
|
||||
import { execFileSync } from 'node:child_process';
|
||||
import { EXEC_TIMEOUT_MS } from '../config/exec-timeout.js';
|
||||
import { getCli } from '../config/cli-registry/registry.js';
|
||||
import { expandHome } from './cli-resolver.js';
|
||||
import {
|
||||
createCliExecutableResolver,
|
||||
formatCliNotFoundMessage,
|
||||
type CliResolverHost,
|
||||
} from './cli-executable-resolver.js';
|
||||
|
||||
/**
|
||||
* Directories probed after `which`, read from this CLI's registry entry so the spawn
|
||||
* path, `codeman doctor` and this resolver cannot disagree about where to look.
|
||||
* `~` is expanded by `expandHome`; nothing else is interpreted.
|
||||
*/
|
||||
const COPILOT_SEARCH_DIRS = (): string[] => (getCli('copilot')?.discovery.searchDirs ?? []).map(expandHome);
|
||||
|
||||
/**
|
||||
* A real `copilot --version` prints `GitHub Copilot CLI 1.0.94.`. Anchoring on the product
|
||||
* name keeps an unrelated `copilot` binary on PATH from being accepted.
|
||||
*/
|
||||
export const COPILOT_VERSION_REGEX = /GitHub Copilot CLI (\d+\.\d+\.\d+)/;
|
||||
|
||||
const COPILOT_NOT_FOUND = 'GitHub Copilot CLI not found. Install with: npm install -g @github/copilot';
|
||||
|
||||
/**
|
||||
* Run `copilot --version` on a candidate path and return the trimmed version when
|
||||
* it is the GitHub Copilot CLI. Returns null for anything else — a missing
|
||||
* binary, a non-zero exit, a hang (timeout), or output that is not
|
||||
* `GitHub Copilot CLI <semver>`-shaped (which is how an unrelated `copilot` on PATH gets rejected).
|
||||
*
|
||||
* Never runs under vitest: the suites must stay hermetic and must not depend on
|
||||
* whether the dev box happens to have copilot installed. The shared resolver host
|
||||
* is already inert under vitest, so this gate is defense in depth for any
|
||||
* opted-in host that still carries the default probe.
|
||||
*/
|
||||
function probeCopilotVersion(binPath: string): string | null {
|
||||
if (process.env.VITEST) return null;
|
||||
try {
|
||||
const out = execFileSync(binPath, ['--version'], {
|
||||
encoding: 'utf-8',
|
||||
timeout: EXEC_TIMEOUT_MS,
|
||||
stdio: ['ignore', 'pipe', 'ignore'],
|
||||
// A stuck or hostile `copilot` that ignores SIGTERM would survive the timeout
|
||||
// and block the server (execFileSync keeps waiting after the signal).
|
||||
killSignal: 'SIGKILL',
|
||||
}).trim();
|
||||
const candidate = COPILOT_VERSION_REGEX.exec(out)?.[1];
|
||||
if (candidate) return candidate;
|
||||
console.warn(
|
||||
`[CopilotResolver] Ignoring ${binPath}: "copilot --version" printed ${JSON.stringify(out.slice(0, 80))}`
|
||||
);
|
||||
} catch (err) {
|
||||
console.warn(`[CopilotResolver] Ignoring ${binPath}: "copilot --version" failed (${(err as Error).message})`);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
type CopilotVersionProbe = (binPath: string) => string | null;
|
||||
|
||||
function createCopilotResolver(
|
||||
host?: CliResolverHost,
|
||||
versionProbe: CopilotVersionProbe = probeCopilotVersion,
|
||||
now?: () => number
|
||||
) {
|
||||
return createCliExecutableResolver<string>(
|
||||
{
|
||||
binary: 'copilot',
|
||||
searchDirs: COPILOT_SEARCH_DIRS,
|
||||
validateCandidate: (binPath) => {
|
||||
const version = versionProbe(binPath);
|
||||
return version ? { accepted: true, metadata: version } : { accepted: false };
|
||||
},
|
||||
now,
|
||||
},
|
||||
host
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates an isolated Copilot wrapper around an injected host, version probe and
|
||||
* clock. Omitting `versionProbe` keeps the ambient (VITEST-gated) probe, which
|
||||
* is exactly what the hermeticity test exercises.
|
||||
*/
|
||||
export function createCopilotResolverForTest(
|
||||
host: CliResolverHost,
|
||||
versionProbe?: CopilotVersionProbe,
|
||||
now?: () => number
|
||||
) {
|
||||
return createCopilotResolver(host, versionProbe ?? probeCopilotVersion, now);
|
||||
}
|
||||
|
||||
const copilotResolver = createCopilotResolver();
|
||||
|
||||
/**
|
||||
* Finds the directory containing a verified `copilot` binary.
|
||||
* Checks `which copilot` first, then falls back to common install locations. Every
|
||||
* candidate must pass the `copilot --version` sanity probe before it is accepted.
|
||||
*
|
||||
* @returns Directory path, or null if not found
|
||||
*/
|
||||
export function resolveCopilotDir(): string | null {
|
||||
return copilotResolver.resolve()?.directory ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if the GitHub Copilot CLI is available on the system.
|
||||
*/
|
||||
export function isCopilotAvailable(): boolean {
|
||||
return resolveCopilotDir() !== null;
|
||||
}
|
||||
|
||||
export function getCopilotNotFoundMessage(): string {
|
||||
return formatCliNotFoundMessage(COPILOT_NOT_FOUND, copilotResolver.diagnostics());
|
||||
}
|
||||
|
||||
/**
|
||||
* Version reported by the resolved `copilot` binary, or null when copilot is
|
||||
* unavailable. Surfaced through `GET /api/copilot/status` so a misresolution is
|
||||
* diagnosable from the UI.
|
||||
*/
|
||||
export function getCopilotCliVersion(): string | null {
|
||||
return copilotResolver.resolve()?.metadata ?? null;
|
||||
}
|
||||
@@ -0,0 +1,117 @@
|
||||
/**
|
||||
* @fileoverview GitHub Copilot's monthly premium-request quota, for the header plan-usage chip.
|
||||
*
|
||||
* The Copilot CLI has no scriptable usage command (`copilot billing` exists only inside the TUI), but
|
||||
* the same quota snapshot the editors show is `GET https://api.github.com/copilot_internal/user`,
|
||||
* readable with the CLI's own sign-in token. The endpoint is not part of GitHub's documented REST API,
|
||||
* so this is read-only, best-effort and silent: any failure (offline, 401, a changed shape) yields
|
||||
* `null` and the chip simply has no Copilot row.
|
||||
*
|
||||
* ⚠️ The token is never logged, never put in an error and only ever sent to api.github.com over HTTPS
|
||||
* (`redirect: 'error'`, so a redirect cannot carry it elsewhere).
|
||||
*
|
||||
* @module utils/copilot-plan-usage
|
||||
*/
|
||||
|
||||
import { readFile } from 'node:fs/promises';
|
||||
import { homedir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { parseCopilotQuotaResponse, type StatusTelemetry } from '../usage-telemetry.js';
|
||||
|
||||
export const COPILOT_QUOTA_URL = 'https://api.github.com/copilot_internal/user';
|
||||
const REQUEST_TIMEOUT_MS = 10_000;
|
||||
|
||||
/** Env names Copilot itself reads for a headless sign-in, in its own precedence order. */
|
||||
const TOKEN_ENV_KEYS = ['COPILOT_GITHUB_TOKEN', 'GH_TOKEN', 'GITHUB_TOKEN'] as const;
|
||||
|
||||
/** The CLI writes `// ...` header comments above the JSON in config.json. */
|
||||
function parseCommentedJson(raw: string): unknown {
|
||||
return JSON.parse(
|
||||
raw
|
||||
.split('\n')
|
||||
.filter((line) => !line.trimStart().startsWith('//'))
|
||||
.join('\n')
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* The sign-in token Copilot would use: a token in the environment first, then the plain-text
|
||||
* `authTokens` entry the CLI keeps in `<COPILOT_HOME or ~/.copilot>/config.json` when no OS keychain
|
||||
* is available. Only github.com sign-ins are read from the file (the quota URL is github.com's).
|
||||
*/
|
||||
export async function resolveCopilotToken(
|
||||
env: Record<string, string | undefined> = process.env,
|
||||
home: string = homedir()
|
||||
): Promise<string | null> {
|
||||
for (const key of TOKEN_ENV_KEYS) {
|
||||
const value = env[key]?.trim();
|
||||
if (value) return value;
|
||||
}
|
||||
const dir = env.COPILOT_HOME?.trim() || join(home, '.copilot');
|
||||
try {
|
||||
const config = parseCommentedJson(await readFile(join(dir, 'config.json'), 'utf8')) as {
|
||||
lastLoggedInUser?: { host?: string; login?: string };
|
||||
authTokens?: Record<string, { token?: unknown } | undefined>;
|
||||
};
|
||||
const { host, login } = config.lastLoggedInUser ?? {};
|
||||
if (host !== 'https://github.com' || !login || !config.authTokens) return null;
|
||||
for (const key of [`${host}:${login}`, `${host}:${login}:github`]) {
|
||||
const token = config.authTokens[key]?.token;
|
||||
if (typeof token === 'string' && token.trim()) return token.trim();
|
||||
}
|
||||
} catch {
|
||||
/* no config, unreadable, or not JSON: no token */
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export interface ReadCopilotPlanUsageOptions {
|
||||
/** Defaults to `resolveCopilotToken()`. */
|
||||
token?: string | null;
|
||||
/** Injectable for tests. */
|
||||
fetchFn?: typeof fetch;
|
||||
}
|
||||
|
||||
/** The monthly premium-request window, or null when there is no token, no meter or any failure. */
|
||||
export async function readCopilotPlanUsage(opts: ReadCopilotPlanUsageOptions = {}): Promise<StatusTelemetry | null> {
|
||||
const token = opts.token === undefined ? await resolveCopilotToken() : opts.token;
|
||||
if (!token) return null;
|
||||
const doFetch = opts.fetchFn ?? fetch;
|
||||
try {
|
||||
const response = await doFetch(COPILOT_QUOTA_URL, {
|
||||
headers: {
|
||||
Authorization: `Bearer ${token}`,
|
||||
Accept: 'application/json',
|
||||
'User-Agent': 'codeman',
|
||||
},
|
||||
redirect: 'error',
|
||||
signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
|
||||
});
|
||||
if (!response.ok) return null;
|
||||
return parseCopilotQuotaResponse(await response.json());
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export interface CopilotPlanUsageGates {
|
||||
/** `showPlanUsageLimits` as the server reads it (`readPlanUsageTelemetryEnabled()`: absent = on). */
|
||||
planUsageEnabled: () => Promise<boolean>;
|
||||
/** `resolveCopilotToken()`: env vars and a file read, nothing spawned. */
|
||||
resolveToken: () => Promise<string | null>;
|
||||
/** `isCopilotAvailable()`: its resolver may run a synchronous login-shell probe. */
|
||||
copilotInstalled: () => boolean;
|
||||
read: (token: string) => Promise<StatusTelemetry | null>;
|
||||
}
|
||||
|
||||
/**
|
||||
* The server's poll: Copilot's quota, or null when Plan Usage Limits is off, there is no sign-in
|
||||
* token or the CLI is not installed. Cheapest gate first, so a host with no Copilot sign-in never
|
||||
* runs the CLI resolver on each tick, and nothing at all is read while Plan Usage Limits is off.
|
||||
*/
|
||||
export async function readCopilotPlanUsageIfEnabled(gates: CopilotPlanUsageGates): Promise<StatusTelemetry | null> {
|
||||
if (!(await gates.planUsageEnabled())) return null;
|
||||
const token = await gates.resolveToken();
|
||||
if (!token || !gates.copilotInstalled()) return null;
|
||||
return gates.read(token);
|
||||
}
|
||||
@@ -84,7 +84,8 @@ export const DEEPSEEK_VERSION_REGEX = /(?:^|\s)v?(\d+\.\d+\.\d+(?:-[0-9A-Za-z][0
|
||||
*/
|
||||
export const DEEPSEEK_IDENTITY_REGEX = /DeepSeek\s+Harness/i;
|
||||
|
||||
const DEEPSEEK_NOT_FOUND = 'DeepSeek Harness CLI (dsh) not found. Install with: npm install -g @deepseek-ai/dsh';
|
||||
const DEEPSEEK_NOT_FOUND =
|
||||
'DeepSeek Harness CLI (dsh) not found. Install with: npm install -g @deepseek-ai/dsh && (command -v pnpm >/dev/null || npm install -g pnpm)';
|
||||
|
||||
/** Where profiles live: `$DSH_HOME/profiles`, defaulting to `~/.dsh/profiles`. */
|
||||
export function resolveDshHome(): string {
|
||||
|
||||
@@ -27,6 +27,7 @@ export { MAX_SESSION_TOKENS } from './token-validation.js';
|
||||
export { isSafePushEndpoint } from './push-endpoint-validation.js';
|
||||
export { stringSimilarity, fuzzyPhraseMatch, todoContentHash } from './string-similarity.js';
|
||||
export { assertNever } from './type-safety.js';
|
||||
export { toSingleLine } from './single-line.js';
|
||||
export { wrapWithNice } from './nice-wrapper.js';
|
||||
export { resolveLocalShell, loginShellArgs } from './shell-resolver.js';
|
||||
export {
|
||||
@@ -69,6 +70,13 @@ export type { DeepSeekProfile, DeepSeekProfileKind } from './deepseek-cli-resolv
|
||||
export { compileFileQuery, matchFileQuery } from './file-query.js';
|
||||
export type { FileQueryMatcher } from './file-query.js';
|
||||
export { resolveOmpDir, isOmpAvailable, getOmpNotFoundMessage, getOmpCliVersion } from './omp-cli-resolver.js';
|
||||
export {
|
||||
resolveCopilotDir,
|
||||
isCopilotAvailable,
|
||||
getCopilotNotFoundMessage,
|
||||
getCopilotCliVersion,
|
||||
} from './copilot-cli-resolver.js';
|
||||
export { readCopilotPlanUsage, resolveCopilotToken } from './copilot-plan-usage.js';
|
||||
export {
|
||||
boundedPathExists,
|
||||
describeUnknownPath,
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
/**
|
||||
* @fileoverview Collapse a prompt onto the one line an unattended sender writes.
|
||||
*
|
||||
* @module utils/single-line
|
||||
*/
|
||||
|
||||
/**
|
||||
* A prompt as one line: each line is trimmed, blank lines are dropped, and the rest
|
||||
* are joined with a single space; a prompt without a line break is returned as is.
|
||||
*
|
||||
* For the senders that run unattended through `writeViaMux` (respawn's update and
|
||||
* kickstart steps, auto-compact). The mux pastes a multi-line prompt as one block,
|
||||
* and the check that re-presses a stranded Enter cannot reliably recognise a
|
||||
* multi-line paste in the composer, so nobody would notice a prompt left unsubmitted.
|
||||
* Cron rejects multi-line prompts for the same reason; these senders join them
|
||||
* instead, so a saved multi-line setting is still sent rather than refused.
|
||||
*
|
||||
* Split and trim, which is linear, rather than one regex replace that folds the
|
||||
* whitespace around each break: that regex backtracks quadratically over a long
|
||||
* whitespace run (50,000 spaces took over a second).
|
||||
*/
|
||||
export function toSingleLine(prompt: string): string {
|
||||
if (!/[\r\n]/.test(prompt)) return prompt;
|
||||
return prompt
|
||||
.split(/[\r\n]+/)
|
||||
.map((line) => line.trim())
|
||||
.filter(Boolean)
|
||||
.join(' ');
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
# CLAUDE.md (src/web: frontend invariants)
|
||||
|
||||
> Loaded on demand: Claude Code reads this when a file under `src/web/` is opened with the Read tool, and the root `CLAUDE.md` tells sessions to Read it before editing `src/web/public/`. These rules were moved here from the root file verbatim. **All paths below are relative to the repo root**, like the root file (only the markdown link targets are relative to this file, so they resolve on GitHub). The root `CLAUDE.md` still holds every cross-cutting rule (session safety, testing, security, server-side patterns).
|
||||
>
|
||||
> **This file is in `.prettierignore`** for the same reason as the root one: Prettier escapes underscores in glob-heavy paths.
|
||||
|
||||
## Frontend key patterns
|
||||
|
||||
**Run launch synchronization**: the Run entrypoint holds an in-flight lock and disables `#runBtn` for the whole launch (≥500ms) so a double click cannot create duplicate `w<n>-<case>` sessions; `_ensureCreatedSessionVisible()` runs before `selectSession()` and `_onSessionCreated()` stays an idempotent upsert, so POST-first and SSE-first both render exactly one tab. ⚠️ **Closing has the mirror-image race**: `closeSession()` must read `wasActive` BEFORE its `await` and announce the delete via `_closingSessions`, and `_onSessionDeleted` skips the active-session handoff for ids in that set; never read `activeSessionId` after the fact. The fallback picks the first `sessionOrder` entry still in `sessions`. ⚠️ The close is OPTIMISTIC: the tab goes and the next one is selected BEFORE the DELETE is sent (upserts skip ids in `_closingSessions`), and only a delete the server refused (checked with a GET) puts the row back; never await the server before hiding the tab. Tests: `test/session-close-fallback.test.ts`. → [architecture-invariants#run-launch-synchronization](../../docs/architecture-invariants.md#run-launch-synchronization)
|
||||
|
||||
**Session lineage lines** (tab → tab it spawned, `sessionLineageLines`, per-device, desktop default ON): a create request may name its spawner via a `parentSessionId` body field or the `X-Codeman-Parent-Session` header; `resolveParentSessionId()` (route-helpers.ts) resolves it (exact id or unique ≥8-char prefix, live, visible, same owner) and ⚠️ anything unresolvable is DROPPED, never a 400. Rides `toState()`, no new SSE event. ⚠️ Rendering is a LAYER on the existing SVG pass (`_appendLineageConnectionLines` at the tail of `_updateConnectionLinesImmediate()`) and draws EVERY family, emphasizing the selected tab's (what it spawned, and the family it was spawned into: `lineage-family--focus`, thicker, opaque, drawn last; lanes stay in strip order), so a selection change must redraw (`_updateActiveTabImmediate`, gated on `_lineageTotalEdges`), and again on the strip's size `transitionend` (the active tab widens for ~150ms after the redraw). Geometry is pure in `computeLineageTree()`: one rounded orthogonal tree per spawning tab, every route starting at the PARENT (siblings share the trunk exactly), routed ONLY through the gaps between tab rows plus a spine left of every row, so it never crosses a tab or the terminal. ⚠️ That room is reserved in CSS by `.session-tabs.lineage-tree` (`_syncLineageGutter()`, called before the wrap is measured), keyed on whether ANY family exists, never on the selection, or every tab switch would resize the header and the PTY. Colors are keyed on the SPAWNING tab, claimed in strip order for every family, and memoized (never by draw index or selection order). ⚠️ Desktop only (z-index vs the fixed mobile header). ⚠️ Paths must keep `data-agent-id="lineage:<childId>"` (the entrance animation queries it); skip endpoints scrolled out of the strip. → [architecture-invariants#session-lineage-lines-tab--tab-it-spawned](../../docs/architecture-invariants.md#session-lineage-lines-tab--tab-it-spawned)
|
||||
|
||||
**Split-pane sessions** (`showSplitButton`, header button, default OFF, desktop-only at 1180px (width alone, so a wide Android tablet clears it), per-device): a second live session ("Pane B") beside the active one, in a `TerminalTile` (terminal-tile.js; the picker, divider and auto-collapse stay in terminal-split.js) with its own xterm + WebSocket, resizable via a draggable divider. Pane B reconnects after a drop, sends input through the exactly-once queue over its own socket (`_registerInputSocket`), has clickable paths and image paste, and owns its geometry (no 40x10 floor, `zc` columns adopted, font changes call `tile.fit()`). ⚠️ Only typed input enters that persisted queue: xterm's query replies are dropped and focus/mouse reports go out ephemeral. ⚠️ App-level terminal actions find their pane through `_focusedPane()` (the terminal focused last), never `this.terminal`. Still plainer than the primary pane (no local-echo overlay, CJK IME textarea or touch handlers), though it wires the same keyCode-229 soft-keyboard controller (Android autocorrect, #441/#541), and NOT persisted across reloads. The tile grid (below) reuses `TerminalTile`, and the two are never open together. → [architecture-invariants#split-pane-sessions](../../docs/architecture-invariants.md#split-pane-sessions)
|
||||
|
||||
**Tile grid** (`showTileGridButton`, header Tiles button + `Ctrl+Shift+G`, default ON on desktop and OFF on handhelds and touch-primary tablets (primary pointer coarse, `getDefaultSettings()`; a touchscreen laptop keeps ON), desktop-only at 1180px, per-device; tile-grid.js, design `docs/tile-grid-plan.md`): 1 to 6 live sessions side by side (the cap is ONE constant, `TILE_GRID_MAX` in constants.js, owner decision; the layout table still covers 7 to 9, unreachable), each a `TerminalTile` with a header (state dot, harness logo, name, model, menu, zoom, ×; no +, owner decision), laid out by count (`CodemanTileGrid`, constants.js) with draggable column/row dividers, zoom (tmux-style), an Attach overlay, and per-browser persistence (`codeman:tile-grid`, localStorage only: cells with `null` holes, `count`, focus, user zoom, divider fractions, never content; written on every change, kept by EVERY close, restored INSIDE `handleInit` in place of the single-view select, so the main terminal never loads on that page load). While the grid is open the main terminal is PARKED: `activeSessionId` is the focused tile's session, and every main-terminal path that would write, fetch, resize or reconnect stands aside through `_tilesOwnTerminal()` (the WebGL long-task observer included). ⚠️ Every capture a tile fetches (initial, reconnect refresh, `{t:'r'}`, history pull) goes through ONE `TileLoadQueue` (concurrency 1, a deadline covering the body), because each is a synchronous tmux call on the server. ⚠️ Only a USER-initiated pick of a non-tiled session (or `leaveTiles`, a followed link) leaves the grid; an `auto` selection never collapses it, and every app-driven fallback (close, delete, restore) picks a tile. A caller of `closeTileGrid({ reselect: false })` must null `activeSessionId` before any `_cleanupPreviousSession`, or the parked terminal's stale content is saved as a snapshot. ⚠️ The grid and the split are never open together. ⚠️ Tile chords go through `tileShortcutFor` and are swallowed in every xterm key handler BEFORE the Shift+Enter gate; the toggle follows `showTileGridButton` (owner decision: OFF makes the chord inert, it passes through like any unbound key). ⚠️ The Tiles button's click and Ctrl+Shift+G are ONE function, `toggleTileGrid` (owner decision): they open the grid AT ONCE on `tileGridOpenSet` (constants.js): the stored grid EXACTLY as the user left it (owner decision 11: `restoreTileGridCells`, never filled to the count nor trimmed to the window; a cell whose session is gone since is filled from the ranking, a hole the user made stays), else an open split's two, else the open sessions as `rankTileSessions` orders them (working first, keyed off `lastSubmitAt` ONLY, then needing input, then most recent; `_mobileOverviewState`'s states), the active one focused, filled to the remembered count (`codeman:tile-count`, default 6, at most `_tileGridLimit()`) by `tileGridSetForCount`, never a menu. ⚠️ `removeTile(..., { gone: true })` (a session deleted, popped out or refused) keeps the stored `count` so the next open refills that cell; a removal by hand lowers it. Right-click is the 2 / 4 / 6 count menu (`openTileCountMenu`, owner decision 10), which owns its Escape. The button has no native title: its hover card (`_installTileGridHint`, `#tileGridHint`) says it, is its `aria-describedby` (always present, hidden, kept current) and hides in the capture phase on any press, click or right-click, so the menu never opens beside it. ⚠️ The open, the count menu and the toggle's close animate opacity and transform only (the close leaves an inert cloned still copy until the single view's selection settles, at most 700 ms); nothing moves under `prefers-reduced-motion`. ⚠️ Opening builds one tile terminal per frame (`_connectTilesPaced`), so `openTileGrid` returns before the terminals exist: focus is handed over in `_connectTile` (`focusOnConnect`). ⚠️ A divider drag reflows locally per frame and sends ONE resize per affected tile at pointer-up. ⚠️ The grid is CELLS (owner: an empty cell can be any cell): `grid.cells` (id or `null`) is the one source of truth, `grid.ids` a derived getter, never written; the shape still comes from the tile count, a shape change goes through `fitTileCells`, and the stored `ids` carry the cells with `null` holes. ⚠️ A tile moves (its header dragged or its tab dropped onto another tile or an empty cell, `Ctrl+Shift+Arrows`) ONLY through `_reorderTiles`: never a remount, reconnect or reload, and since sizes belong to the cells only a tile whose cell size changed fits; off while a tile is zoomed. The header drag carries its own type, never text, and is not `draggedTabId`; the header focuses on click, never on press, so a cancelled drag changes nothing. The arrow chords skip text fields. ⚠️ Sessions Run from this tab join the open grid (`_joinTileGridFromRun`, called from `_ensureCreatedSessionVisible`); sessions created elsewhere never do. SuchLine truncated
|
||||
|
||||
**Terminal touch gestures: link taps and text selection**: on touch devices xterm's linkifier and SelectionService never see the gesture, so both are driven explicitly (terminal-ui.js). ⚠️ A tap activates the link under it through the SAME provider as the hover linkifier (`_terminalLinkAtPoint`), synchronously inside `touchend` (keeps the user gesture `window.open` needs) and BEFORE any mouse report; the caret's logical line (`_tapIsOnCaretLine`) and TUI-owned rows (`_isActionableMobileTerminalTap`) keep their meaning. ⚠️ Gate on the caret line, never on tap intent (a shell calls every tap `'input'`). ⚠️ Long-press selects via xterm's public `select()`; keep the three guards: suppress the compat mouse pair after `touchend`, the bounded focus guard + `contextmenu` suppression for the platform long-press, and no closing `terminal.focus()` on phones. ⚠️ A sideways drag belongs to the session swipe (`SwipeHandler`, mobile-handlers.js): the touch handler locks the dominant axis on the first travel past the tap slop (`panAxis`: `y` scrolls, `x` pans a terminal wider than its box, `swipe` otherwise) and publishes it as `app._terminalGestureAxis`; a `swipe` drag neither scrolls nor ends as a tap on the session being left until it passes the swipe's own vertical drift limit (`SwipeHandler.maxVerticalDrift`), where it is handed back to the scroll as `y` (a scroll that opens on a thumb arc), and the swipe stands aside for `x`/`y`, a touch selection, a second finger and surfaces with controls of their own (`IGNORE_SELECTOR`; the phone overview and welcome screen are deliberately not listed). A swipe is 80 px within 500 ms with at most 50 px of vertical drift at any point; a touch starting outside the terminal (the home screen) resets the published axis, and a lift the terminal consumed (`defaultPrevented`, its selection gesture) is never a swipe (`test/session-swipe-gesture.test.ts`). Tests: `test/terminal-touch-tap.test.ts`. → [architecture-invariants#terminal-touch-gestures-link-taps-and-text-selection](../../docs/architecture-invariants.md#terminal-touch-gestures-link-taps-and-text-selection)
|
||||
|
||||
**Auto Copy (copy-on-select)** (`autoCopySelection`, per-device, default OFF): a finished terminal selection lands on the clipboard with no keystroke. ⚠️ Copy at the END of a gesture, never in `onSelectionChange` (per-cell); it only arms `_autoCopyPending` and a document-level `mouseup` flushes. ⚠️ The flush must be SYNCHRONOUS in the handler (both clipboard paths need user activation); never defer it to a timer. ⚠️ Touch needs its own calls from `_endTouchSelectionGesture()`/`_selectTouchSelectionLine()` (no mouseup arrives). ⚠️ Unlike `copyTerminalSelection()`, never clear the selection or focus the terminal; restore prior focus. Guards are pure in `decideAutoCopy()` (constants.js, 1M-char cap, refused not truncated). Tests: `test/terminal-auto-copy.test.ts`. → [architecture-invariants#auto-copy-copy-on-select](../../docs/architecture-invariants.md#auto-copy-copy-on-select)
|
||||
|
||||
**OSC 52 clipboard** (`_installOsc52Clipboard`, terminal-ui.js; main terminal + every `TerminalTile`): a program in the PTY (Claude Code's `/copy`, tmux yanks, vim) sets the browser clipboard with `ESC ] 52`. ⚠️ Writes only: a `52;?` read query is always refused, never answered. ⚠️ A replay must never copy: `chunkedTerminalWrite` strips every OSC 52 (`OSC52_STRIP_RE`, constants.js) because xterm parses after `_isLoadingBuffer` is cleared, so the flag alone does not hold; a tile gates on its own `_bufferLoading`, which does. ⚠️ One copy arrives up to three times (Claude's own OSC 52, `tmux load-buffer -w`, set-clipboard forwarding), so identical text within `OSC52_DEDUPE_MS` is dropped. ⚠️ Every tab on the session receives it; only the focused one opens the manual-copy fallback. ⚠️ Restore prior focus after `_copyText` (its execCommand fallback focuses a temp textarea). Tests: `test/terminal-osc52-clipboard.test.ts` (replays drive the real `chunkedTerminalWrite` over `@xterm/headless`).
|
||||
|
||||
**Ctrl+V paste trap** (`image-input.js`): `Ctrl+V` routes through `_handleImagePaste()`, which focuses a hidden `contenteditable` trap and reads the clipboard from the paste event landing there; images upload and their paths are typed in, plain text is a Codeman-built bracketed paste handed to that pane's terminal via `terminal.input(framed)` (strip nested 200/201 markers, newlines to `\r`, wrap CSI 200~/201~) because `terminal.paste()` drops those markers after a replay clears xterm's DECSET 2004 mirror (#716). `input()` fires onData as user input, so the main pane's bracketed-paste branch and a tile's `_onTerminalData` deliver the frame, including the local-echo flush (#219). The terminal is only refocused. ⚠️ **The trap must consume exactly ONE paste event** (Firefox delivers two per keypress: the `execCommand('paste')` event and the keydown's default action); the one-shot flag lives on the trap, never on a browser check. ⚠️ Do not remove the `execCommand('paste')` call: on some mobile engines it is the only route into the trap, and the trap is the only place image blobs are read. Tests: `test/image-paste-trap.test.ts`. → [architecture-invariants#terminal-paste-ctrlv](../../docs/architecture-invariants.md#terminal-paste-ctrlv)
|
||||
|
||||
**File Viewer text view: rendered markdown + Lines/Wrap toggles** (`_renderFilePreviewText()` in panels-ui.js): a `.md`/`.markdown` opens RENDERED by default with an `MD` pill back to source; the plain-text view has `Lines` (CSS-counter gutter) and `Wrap` toggles. ⚠️ ONE markdown pipeline: the viewer calls `_renderMarkdown(text, { breaks: false })` (marked + the DOMPurify allowlist, the Response Viewer's; chat keeps the default `breaks: true`, a file must not turn every hard wrap into a `<br>`) and binds the Response Viewer's click delegate (`_bindResponseViewerInteractions`) on the preview body for code-copy buttons and path links; never a second parser or handler. ⚠️ The document is built inside a `<template>` (a detached div with `innerHTML` set starts fetching every `<img src>` before the rewrite), then `_rebaseFilePreviewMarkdownRefs()` points relative images at the workspace-confined `file-raw` under the document's directory and root-relative ones under the workspace root (never a widened route; a failed load degrades to alt text), after `decodeURIComponent`ing the ref and dropping `?query`/`#fragment` (marked percent-encodes destinations, and the route encodes again), and turns workspace links into `a.rv-path` carrying `data-session-id` for the delegate (the linkifier's absolute paths get it too), stripping the `target` marked gave them. ⚠️ A preview opened by attachment id under a bare file name (attachment cards, history drawer: the registry keeps no relative path) has no directory, so its workspace refs degrade (images to alt text, links to their text), never resolve against the workspace root. ⚠️ The container carries `data-i18n-skip` or the translator rewrites the document's prose. ⚠️ Toggles are per-device localStorage keys (`codeman:filePreview*`), never `SettingsUpdateSchema`; Lines/Wrap are class flips on the ONE `<pre>`, with rules scoped `.file-preview-body > pre.file-preview-text` so they never leak into the document's code blocks. Markdown fetches `lines=10000` (the route ceiling), other text keeps 500. ⚠️ `md`/`markdown` are IN `FILE_PREVIEW_EXTENSIONS` (owner decision 2026-10-13): a markdown path clicked or tapped in the TERMINAL (main pane, grid tiles and split Pane B share `registerFilePathLinkProvider`) opens this rendered view like every other surface, never the streaming log viewer; other in-workspace text keeps the tail viewer (live follow). The terminal pattern links `.markdown` and `.htm` too. Tests: `test/file-preview-markdown.test.ts`. → [architecture-invariants#file-viewer-text-view-rendered-markdown-and-text-toggles](../../docs/architecture-invariants.md#file-viewer-text-view-rendered-markdown-and-text-toggles)
|
||||
|
||||
**Spreadsheet preview (XLSX)** (`spreadsheet-preview.js` + `spreadsheet-preview-worker.js`; moved from File-path links in the root file): ⚠️ **XLSX previews parse in the BROWSER**, never on the server: `spreadsheet-preview.js` fetches the raw route with `?preview=true` (413 above `MAX_XLSX_BROWSER_PREVIEW_BYTES`, 10 MB) and hands the bytes to `spreadsheet-preview-worker.js`, the only place the pinned `exceljs`/`fflate` vendor bundles load (never on page load). `admitXlsx()` caps the ZIP before ExcelJS runs, counting what ExcelJS will EXPAND as well as what it reads (a merge costs its area, a `<col>` past 16384 is refused, validations are never parsed via `ignoreNodes`, and defined names are never expanded: the worker stubs `_definedNames.model`). ⚠️ The INDEX a row or sheet claims is bounded too, since ExcelJS allocates and walks up to it: a `<row r>` outside 1-1048576 and an `xl/workbook.xml` `<sheet sheetId>` above `LIMITS.maxSheetId` (65535) are refused, and `sendTile` reuses the merges read at load (`mergesById`), never `sheet.model`, which rebuilds the whole sheet. ⚠️ The COLUMN index costs the same way (a row's cells live at `_cells[col - 1]`, so one `XFD` cell makes ExcelJS's `eachRow`/`eachCell`/`hasValues` visit 16,384 slots per row): `worksheetMetadata` builds each sheet's row and cell index from the keys that exist (`Object.keys` of `_rows` and `_cells`, skipping falsy and `Null`-type cells as `eachCell({ includeEmpty: false })` does), reads row heights in that pass and merges from `sheet._merges`, and `sendTile` reads cells from that index (`populatedRowsById`); never call ExcelJS's dense `eachRow`/`eachCell` there. `parseThemePalette` returns the default palette for a theme above 64 KB (64 * 1024 characters), since its patterns are quadratic on unclosed tags. ⚠️ Merges are capped per sheet (`LIMITS.maxMergesPerSheet`, 2,000) AND workbook-wide (`LIMITS.maxMerges`, 10,000), refused as `merge-limit` before ExcelJS loads, since ExcelJS's `_mergeCellsInternal` checks each merge against every earlier one on its sheet (quadratic). Every `<numFmt>` in `xl/styles.xml` is read with `readTagAttributes()` and refused (`number-format`) when its decoded `formatCode` is over 255 characters or has a `[` after its last `]`: ExcelJS's `isDateFmt` rescans to the end of the code per unclosed `[` once per numeric cell, and the code is echoed into the notice bar. ⚠️ `formatCellValue` caps every cell's display text at `LIMITS.maxCellTextChars` (1,000, ellipsis, never splitting a surrogate pair) for every value shape (rich text, hyperlink text, formula source, errors), since structured clone copies each tile cell's whole string to the page and the load timeout no longer covers tiles. `richTextPrefix` also visits at most `maxCellTextChars + 1` runs, not only keeps that much text: an empty `<r/>` adds nothing, so a shared string of a million empty runs was walked whole per cell, per tile (56 s a tile). ⚠️ Every part ExcelJS parses (all but the pure-bytes `xl/media/<name>.<ext>`) is also charged per START TAG against `LIMITS.maxElements` (2,000,000, refused as `element-limit`): ExcelJS builds an object per element in sharedStrings, styles, comments, drawings, VML and tables too, and empty stored blocks pad a deflate stream past the ratio cap, so 8.3M empty `<r/>` runs in a 375 KB file cost 570 MB of heap. ⚠️ Admission keys every entry on the name ExcelJS will SEE (`excelJsEntryName()`: JSZip's `.`/`..`/empty-segment resolution, then one leading `/` stripped), refuses two entries that land on one name, and treats anything matching ExcelJS's UNANCHORED `xl/worksheets/sheet<N>.xml` as a worksheet, so `/xl/...` or `xl/./...` cannot skip a counter, and ExcelJS parses only a STORE-only archive rebuilt from the entries admission inflated (`buildAdmittedArchive()`), never the fetched bytes; cell text goes through `textContent`, formulas are never evaluated. Bumping either package or editing the worker/core changes `SPREADSHEET_ASSET_VERSION`, which `npm run check:public-assets` pins. xls/ods stay download-only.
|
||||
|
||||
**Security notices** (`src/web/public/security-notices.json`; placed by `checkWhatsNew()` in settings-ui.js through the pure `CodemanSecurityNotices.select()`/`.plan()` in constants.js; drawn by `showSecurityNotice()`/`showUpdateNotice()` in panels-ui.js, CSS `.security-notice*` beside the toast rules): a designed warning card (shield, "Security notice" eyebrow, title, message, the notice's link, Dismiss, ×) about something outside Codeman its users should act on (the first: OpenCode's Console breach, Oct 2026). ⚠️ The file is HAND-maintained and ships with a release; never put a notice in `whats-new.json`, which `npm run version-packages` rewrites. Adding and retiring one: `docs/releasing.md`. ⚠️ A notice naming a `cli` shows ONLY when `window.__codemanCliAvailable[cli] === true`: absent, unknown and false all mean "not installed", the opposite of `isCliAvailable()` (which offers a launch on a doubt), because a warning about software you do not have is noise. The id lives only in the JSON as data; no code branches on it (`test/frontend-cli-no-id-branching.test.ts` scans constants.js). ⚠️ ONE notification per notice per load, decided only by `plan()`: when the once-per-release update toast shows (an existing install on a new version), every selected notice rides INSIDE it as a warning section beside "See what's new"; otherwise (a fresh install, a same-version reload) each gets its own card. With no notice selected the update toast is the plain `showToast` it always was, pinned byte for byte by the test. ⚠️ A detached session window (`/session/:id`) is rendered WITHOUT `__codemanCliAvailable`, so it cannot select a CLI notice: while any notice is pending (every CLI the file names counted as installed), it shows and records nothing and leaves the update toast and the notice to the dashboard, or it would record the version and the dashboard would show the notice as a separate card; with none pending it shows the plain toast as before. ⚠️ A notice is recorded as seen (`codeman-security-notices-seen`, a JSON array) ONLY on a user action (×, Dismiss, its link), never when it appears; "See what's new" opens the release notes and leaves an unanswered warning up. Storage blocked shows neither the notices nor the update toast. ⚠️ The cards sit in the SAME `.toast-container` (never their own layer), use theme tokens only (small amber text and the primary button use `--notice-ink`, which the light skins darken a step because their `--yellow` is under 4.5:1 for that text on white; the primary button's label is `--bg-card`), are capped to the room below the stack's top and scroll inside themselves (the container is fixed and cannot scroll, so the accent bar is a background layer, never a positioned child), animate opacity and transform only, and nothing moves under `prefers-reduced-motion`. A closing card is inert and its buttons do nothing during the fade; links open with `noopener,noreferrer` and the https rule is checked again at the button (`CodemanSecurityNotices.urlOk`). The notice's own text is textContent with `data-i18n-skip` (it carries its own zh-CN); the card's own copy translates as usual. The server serves `.json` from `public/` as `no-cache`, never `immutable` (`test/static-cache-headers.test.ts`). Tests: `test/security-notices.test.ts`.
|
||||
|
||||
## Frontend modules and UI subsystems
|
||||
|
||||
Frontend JS modules have `@fileoverview` with `@dependency`/`@loadorder` tags. Load order is the `<script>` order in `index.html` (each module's `@loadorder` tag mirrors it). `i18n.js` translates static + newly inserted application DOM while skipping terminal/response/file/user-name surfaces; `input-cjk.js` handles CJK IME composition via an always-visible textarea below the terminal (`window.cjkActive` blocks xterm's onData). `terminal-keycode229-recovery.js` forwards a committed `input` event that xterm's `_inputEvent` guard drops (Chrome-on-Android soft keyboards send `composed: true` after a keydown), and only when xterm emitted no canonical data for that keystroke. ⚠️ **That decision is settled at the NEXT keydown as well as on its own zero-delay timer** (#441): the drain runs from xterm's custom key handler, which fires BEFORE xterm processes that key, so a soft keyboard that commits the last character and sends Enter in one InputConnection transaction puts the character on the wire ahead of the `\r`. On the timer alone that character is not merely late, it is LOST: xterm emits the `\r` first and bumps the canonical counter past the candidate's snapshot, so the candidate stands down (measured, `hell\r` where the user typed `hello`). The edit-based diff described below is settled first at that keydown, so the drain decides with the evidence the timer had and Enter's textarea clear cannot turn the pending line into DELs. Ordering is pinned by `test/terminal-keycode229-recovery.browser.test.ts`, which the CI gate does NOT run. The same module replaces xterm's `_handleAnyTextareaChanges` (an append-only `newValue.replace(oldValue, '')` diff) with an edit-based one, so an Android autocorrect on space (delete + insert) reaches the PTY once instead of duplicating the line; ⚠️ that diff is settled at the NEXT keydown, before xterm handles that key, because xterm clears the textarea for Enter and a pending diff would then send one DEL per character ahead of the submitted line (except a composition xterm finalizes synchronously at that key, which stays xterm's). ⚠️ Every xterm that takes keyboard input wires its OWN controller from this module (`create()` returns null unless `matchMedia('(pointer: coarse)')` matches or the user agent is Android, so a desktop dead key is not forwarded twice, #716, while an Android device whose mouse, trackpad cover or stylus Chromium reports as the fine primary pointer keeps the soft-keyboard recovery): the primary pane (terminal-ui.js `initTerminal()`) and each `TerminalTile` (terminal-tile.js `_createKeyCode229Recovery()`, so every grid tile and the split's Pane B, which a wide Android tablet reaches), each on its own textarea, composition helper and session; in both, `handleKeyEvent` must run ABOVE the custom key handler's keyCode-229 early return, and `notifyCanonicalData` sits in the onData lambda, never in the send path the recovered bytes also take. `mobile-ime-preview.js` (iOS WebKit only) paints the text an IME is composing: an iOS IME commit is routed into the local-echo overlay through the ordinary printable/paste branch and then `_transferMobileImeCommitToLocalEcho`, and without local echo the preview clears only on output parsed AFTER the commit (or its 2 s fallback). ⚠️ It watches keydown in the capture phase on `terminal.element`, never on the textarea, because xterm finalizes the composition and emits the commit in its own capture listener on the textarea.
|
||||
|
||||
**Entrance animations** (`entrance-animations.js`, all OFF by default): opt-in animations for tabs, terminal, windows, connection lines and tile-grid tiles, chosen via `data-tab-anim` / `data-term-anim` / `data-win-anim` / `data-line-anim` / `data-tile-anim` on `<html>`; the default `legacy` theme short-circuits every hook (tiles keep the grid's own `settle`). ⚠️ Tabs and lines are destroyed mid-animation on re-render, so re-apply to the fresh element by id with a negative `animation-delay` (resume, never restart). ⚠️ Terminal-pane styles may animate only transform / opacity / clip-path (anything else resizes the PTY via FitAddon); `blur` is the ONE sanctioned `filter` exception, do not generalise it. ⚠️ Line glow lives in `--line-glow` so blur keyframes interpolate. Every animation setting lives in App Settings → **Animations** (`#settings-animations`); put new ones there. ⚠️ Tiles are OFF by default (Animations → Tile Animations, default `settle` = the grid's own fade, no screen beat); a theme only presets it. A tile's FRAME animates transform/opacity only (six at once, no filter) with `tile-enter*`/`tile-leave*` keyframe names (what the mount and the still copy listen for); its SCREEN replays the pane style on `.tile-body.term-enter`; a reload's restore always settles. Persisted per-device in `codeman:*Anim` localStorage keys, never in `SettingsUpdateSchema`; lab at `?animlab=1`. Test: `test/entrance-animations.test.ts`. → [architecture-invariants#entrance-animations](../../docs/architecture-invariants.md#entrance-animations)
|
||||
|
||||
**Mobile tab strip scrolling** (issue #257): under 768px the tab strip scrolls horizontally, so the active tab must be kept reachable. `_updateActiveTabImmediate()` reveals it via `computeTabScrollLeft()` (constants.js, rect math on the strip's own `scrollLeft`, never `scrollIntoView()`, which scrolls the document under the fixed header); `_fullRenderSessionTabs()` must restore `scrollLeft` across rebuilds and re-reveal only when the active tab changed (`_lastRenderedActiveTabId`) or, grouped by state, the ACTIVE tab moved to another state band (`_noteActiveTabBand()`, both render paths, scrolling row only; another tab's move never scrolls). ⚠️ The phone-block `min-width` on `.session-tab.active .tab-name` keeps the tab's centre off the gear/close icons, sized for numberless tabs 10+ (floor 40px); do not shrink it. With the opt-in tab "+" on (`showTabNewButton`, `html.tabs-show-new`) a third icon joins them and the reserve grows to 68px, still under the 80px `max-width`. ⚠️ Never reintroduce hoisting the active session to the front of the strip. Tests: `test/mobile-tab-tap-zones.test.ts`, `test/tab-triage.test.ts`. → [architecture-invariants#mobile-tab-strip-scrolling](../../docs/architecture-invariants.md#mobile-tab-strip-scrolling)
|
||||
|
||||
**Session list layout: header strip or left sidebar** (`sessionListLayout`, default `header`; per-device via `displayKeys`, also in `SettingsUpdateSchema`): the list can move into a collapsible `<aside>` (Alt+B, `toggleSessionSidebar`) or, via `tabOrientation`, a resizable vertical `#tabRail` (desktop/tablet only). ⚠️ There is ONE `#sessionTabs`, MOVED between hosts, never a second list: `applySessionListLayout()` runs first, then `applyTabOrientation()`, both BEFORE `applyTabWrapSettings()`, and both arm/disarm `_startSidebarRichClock()`. ⚠️ Axis decisions use `_isVerticalTabList()`, never `isSessionSidebarActive()` alone. ⚠️ Rich rows share one gate, `isRichTabRows()`; rich CSS pairs sidebar+rail with comma-grouped selectors, never `:is()`, and card rules stay rail-scoped. ⚠️ Rail sort (`tabRailSort`, default `activity`) is the flex `order` property only, never a DOM reorder; the arrow-key walk and the phone swipe follow computed `order`. ⚠️ Leaving sidebar mode clears `_sidebarFilter`; the handheld overlay drawer is `inert` when closed, the docked rail never. ⚠️ The rail's **Search sessions** box (`setTabRailSearch`, in memory only) and the sidebar filter share ONE row filter, `_applyTabListFilter()` over the pure `CodemanTabSearch` (constants.js): classes only, never a reorder or layout write; the rail matches the name, the sidebar name + folder. ⚠️ A session row with a tab alert (`tabAlerts`, red or yellow) is never hidden by either box: it goes in as `keep: true`, counts toward its group (which stays on screen) but not toward `matchCount`. ⚠️ Escape in the rail box is claimed by the global capture-phase Escape branch (the box's own `onkeydown` runs after it, too late to stop `closeAllPanels()`), and a filter change that shows or hides anything redraws the connector lines, since a keystroke re-renders nothing; a floating window finds its parent tab only through `_paintedSessionTab()` (a hidden row's rect is all zero but truthy, which drew connectors from the viewport's corner). While it runs `_projectTabGroups()` projects every group open (a collapsed group's rows are not in the DOM) and `toggleTabGroupCollapsed()` refuses, leaving the stored collapse untouched; drag stays on during a search (owner decision), a drop landing between the visible neighbours; `applyTabOrientation()` clears it off the rail. Tests: `test/tab-rail-search.test.ts`, `test/tab-rail-search.browser.test.ts`. → [architecture-invariants#session-list-layout-header-strip-vs-left-sidebar](../../docs/architecture-invariants.md#session-list-layout-header-strip-vs-left-sidebar)
|
||||
|
||||
**Tab layouts** (`tabArrangement`, per-device, default `classic`; `state`/`case`/`ledger` are opt-in; Discussion #426): `state` (option C) splits the list into needs you / waiting / working / idle (idle is `quiet`: its heading stays as the row's anchor but draws no label; `tabStateOrder: 'urgent-last'` flips the order, needs you at the bottom): rows in the desktop header strip with left-aligned labels in a measured column (the brand sits over the strip's corner, so the rows after the first start under "Codeman"), sections in the flat rail and the sidebar, inline dividers on the tablet strip, group order with no headings on phones. `case` (option A) wraps each case's tabs in one `.tab-cluster` box and hides the `-<case>` of a generated name (`.tab-name-case`). `ledger` (option B) is a CSS-only grid on the desktop header strip. `classic` is the old strip; once it wraps on desktop it is L-shaped via `_syncTabStripCorner()` (the toolbar sits over its top-right corner and later rows run the full width under it, a strip already past its max-height keeps the plain wrap): ⚠️ `.tabs-corner` comes off before the wrap is measured, and the strip is `display: block` in that state, so flex-only rules do not apply to it there (`test/tab-strip-corner.browser.test.ts`). Pure cores: `CodemanTabTriage` and `CodemanTabClusters` (constants.js). ⚠️ `state` is the flex `order` property plus `aria-hidden` heading/break elements reconciled in place by `_syncTabTriageChrome()` after BOTH render paths, never a DOM reorder (a state change is an incremental pass). ⚠️ `case` boxes ARE a DOM grouping, so the incremental path rebuilds whenever `_tabClusterLayout().key` changes; membership follows `_mobileOverviewCaseFor()` like the home screens. ⚠️ Drag only reorders within a group or box (`_isTabDropAcrossGroups()`). ⚠️ Named groups in the vertical rail win over both groupings. ⚠️ `classic` must leave no trace (no headings, no inline order, no boxes, no name split), and the ledger must not change the markup. ⚠️ Grouping is a render layer: `sessionOrder`, Alt+[ / Alt+], Ctrl+Tab and Alt+1..9 keep the canonical order, and only the phone swipe walks the drawn order (`nextSession({ visual: true })` through `_stripSessionOrder()`, the same `_visibleTabRows()` walk the strip's arrow keys use); an active session the list does not draw steps in at the first or last row, and an empty drawn list goes nowhere rather than falling back to a hidden session (`test/session-swipe-gesture.test.ts`). Tests: `test/tab-triage.test.ts`, `test/tab-clusters.test.ts`. → [architecture-invariants#tab-layouts-by-state-by-case-ledger](../../docs/architecture-invariants.md#tab-layouts-by-state-by-case-ledger)
|
||||
|
||||
**Header stats styles** (`headerStatsStyle`, per-device, desktop only, default `compact`; Discussion #426 option G): `tiles` / `compact` (two pills, a ring beside every value) / `classic` (as before), keyed on `data-header-stats` (pre-paint + `applyHeaderStatsStyle()`), forced to `classic` below 768px and in a solo window. ⚠️ The clustered styles MOVE `#connectionIndicator` into `#headerSystemStats` and `#planUsageChip` right after it; `classic` moves them back to comment anchors. ⚠️ WS joins the pill only while System Stats is shown. ⚠️ The extra parts (stat rings, tile words, plan rings, meters) are always rendered and hidden by default in CSS, which is what keeps `classic` unchanged; tile words are derived beside the connection descriptor, never added to it (`test/connection-indicator.test.ts` pins its shape). ⚠️ A tile is a three-row grid (label, value, bar) with pixel line-heights and the bundled JetBrains Mono; never lay the bar over a fixed-height tile (a taller system mono put the value on top of it). The WS tile's grid lives on the inner `.connection-tile`, because JS writes the indicator's `display` inline. Tests: `test/header-stats-style.test.ts`. → [architecture-invariants#header-stats-styles](../../docs/architecture-invariants.md#header-stats-styles)
|
||||
|
||||
**Phone overview home screen** (`mobile-overview.js`, per-device `mobileOverviewEnabled`, default ON): under 600px the "C" logo shows NEEDS YOU / CURRENT / PAST SESSIONS instead of the welcome overlay, branched in `showWelcome()`/`hideWelcome()` via width-driven `shouldUseMobileOverview()`. ⚠️ The container ships `hidden` and only this module removes it: never give `.mobile-overview` a bare `display` rule (desktop does not load `mobile.css`). ⚠️ The split Run button must carry the toolbar's own classes (`btn-toolbar btn-run mode-<backend>` / `btn-run-gear`) and mobile.css must set no `background`/`color` on it; row status must mirror the session-tab alert language. PAST rows resume through the shared `resumeHistorySession()`. Status pills carry `data-i18n-skip`. → [architecture-invariants#phone-overview-home-screen](../../docs/architecture-invariants.md#phone-overview-home-screen)
|
||||
|
||||
**Desktop home tab rail** (`home-sessions.js`, desktop only): the welcome overlay's left gutter carries the open tabs as a rail docked flush left, full height, in overview order, each row showing `created … · <state> <duration>` from `_mobileOverviewSince()`; state classification is reused from mobile-overview.js (so it loads after it). ⚠️ The number badge is the Alt+1..9 tab-strip index, never renumber it to row position. ⚠️ The width gate lives in two places that must stay equal: `HOME_SESSIONS_MIN_WIDTH` (1180) and a `max-width: 1179px` media query. ⚠️ `.home-sessions[hidden]` must re-assert `display: none`. ⚠️ Size all children in `em` off the one `clamp()` knob, never `rem`/px. Age stamps tick in place (`_tickHomeSessionsTimes()`), never by re-render. Test: `test/home-sessions.test.ts`. → [architecture-invariants#desktop-home-tab-rail](../../docs/architecture-invariants.md#desktop-home-tab-rail)
|
||||
|
||||
**Home-screen session order** (`CodemanSessionOrder` in constants.js, pure): BOTH home screens (phone overview, desktop rail) must order rows through this ONE comparator. Rank `needs` → `error` → `waiting` → `working` → `idle` → `done`. ⚠️ The tiebreak flips: states a session is still IN sort oldest-first, states it has STOPPED sort newest-first. ⚠️ The running group keys off `lastSubmitAt`, never `lastActivityAt` (a working pane repaints constantly). ⚠️ A 0 stamp means unknown and sorts last within its state. Final tiebreak is `orderIndex`, so the list never shuffles. The tab strip itself is NOT sorted by this. Test: `test/session-overview-order.test.ts`. → [architecture-invariants#home-screen-session-order](../../docs/architecture-invariants.md#home-screen-session-order)
|
||||
|
||||
**Welcome "Resume Conversation" list** (terminal-ui.js): `loadHistorySessions()` fetches once and caches the corpus on `_historyAll`/`_historyCases`; every subsequent view (filter box, sort select, expand, the periodic refresh in panels-ui.js) goes through `_renderHistoryList()`, so never append rows to `#historyList` directly or re-fetch to re-sort. ⚠️ The box height is **class-driven**: expanding the list without `.history-list.expanded` leaves the collapsed `max-height` in place and just deepens a scroll well, which is the bug #260 reported (35 sessions in a ~4-row box). ⚠️ The A–Z sort keys off `_historyRowLabel()`, the SAME string the row renders (`name || firstPrompt || path`), most rows are transcript-backed and have no session name, so sorting on `name` alone silently does nothing. ⚠️ A filter implies expansion, and `_renderSearch()` hides `#historyHeader` (title + controls) as one unit while a search is active. Tests: `test/history-list-controls.test.ts`.
|
||||
|
||||
**Command palette + shortcut registry**: `Ctrl/Cmd/Alt+K` opens the session palette; shortcuts live in a rebindable registry (`DEFAULT_SHORTCUTS`/`getShortcutRegistry()`/`matchesShortcutEvent()` in app.js, overrides in `settings.shortcutOverrides`). ⚠️ Palette-chord keys must ALSO be swallowed in `attachCustomKeyEventHandler` (terminal-ui.js) or xterm writes the control byte into the PTY. ⚠️ The global Escape handler (app.js) calls EVERY close method on every Escape, in the capture phase, so a close method that does more than hide (the palette and Session Manager restore focus) must return early when its overlay is not open. ⚠️ `saveAppSettings()` rebuilds settings from the DOM, so keys edited elsewhere (`shortcutOverrides`, `showTokenCount`, `showCost`) need explicit `_prev` carry-over. ⚠️ **Smart copy (`Ctrl+C`)**: with no selection it must `return true` without `preventDefault()` or the interrupt is lost; keep `copyTerminalSelection` out of `SHORTCUT_ACTIONS`. The gate tests the CLEANED selection (`CodemanCopySelection.clean`: trailing padding, plus a LEADING margin only up to the width the CLI declares in `capabilities.transcriptGutter`, never one derived from the pane); the strip is not idempotent, so clean once and pass the RAW selection on, and leave Alt+drag column selections untouched. → [architecture-invariants#command-palette-and-shortcut-registry](../../docs/architecture-invariants.md#command-palette-and-shortcut-registry)
|
||||
|
||||
**Per-device vs synced settings**: the `displayKeys` set in settings-ui.js is a **client-side merge policy**, not a wire filter. A display key seeds from the server only when localStorage has no value for it, which is what prevents one device overwriting another; `showPlanUsageLimits` is additionally `delete`d from the incoming payload outright. Separately, `SettingsUpdateSchema` is `.strict()` and simply **does not declare** `skin`, `showFileViewerButton`, `showCronButton`, `webglRendererEnabled`, `localEchoEnabled`, `keyboardHoldsRows`, `cjkInputEnabled`, or `extendedKeyboardBar`, so sending one of those is a validation error. The rest (`showResponseViewer`, `showPlanUsageLimits`, `language`, and most `show*` keys) ARE in the schema and do persist server-side; they are per-device by client policy only. ⚠️ Adding a new per-device setting means deciding **both** questions: membership in `displayKeys`, and presence in the schema.
|
||||
|
||||
**Settings surface** (`#appSettingsModal` + `#sessionOptionsModal` + `#createCaseModal`): one `set-*` language shared through a single `:is(...)` id scope in styles.css. App Settings' rail is a table of contents over ONE scrolling document (`switchSettingsTab` scrolls); Session Options and Add Case really switch (`switchOptionsTab` / `switchCaseModalTab`), and their larger per-modal size blocks are the design, not drift. ⚠️ **The load/save contract is `getElementById` by id**: renaming or dropping a control id silently stops it loading or saving. ⚠️ The Session Options "Session" entry still keys off `context` (label-only rename). ⚠️ Add Case keeps its legacy `.form-row` markup via an adapter; every `<details>` there needs `.set-adv-chev` plus both marker suppressions. ⚠️ Model cards and the effort segment are views over hidden `<select>`s, which stay the source of truth. ⚠️ `.modal-tabs*` classes are retired; `admin-ui.js` needs `.set-rail-items` + `.set-doc` to survive any restructure. Guard: `test/app-settings-structure.test.ts`. → [architecture-invariants#settings-surface-app-settings-session-options-add-case](../../docs/architecture-invariants.md#settings-surface-app-settings-session-options-add-case)
|
||||
|
||||
**Header button visibility**: most header controls are opt-in and hidden by a marker class (`btn-multimonitor--hidden`, `btn-response-viewer-header--hidden`, `btn-file-viewer--hidden`, `btn-cron--hidden`, and the desktop-gated `btn-split--hidden` / `btn-tile-grid--hidden`, which also need a `@media (max-width: 1179px)` backstop) that `applyHeaderVisibilitySettings()` (settings-ui.js) toggles after settings load; the multi-monitor button is instead stripped at render by `renderIndexHtml`. ⚠️ Hiding must go through the marker class: the base rules are `display:inline-flex !important`, so an inline style cannot override them. Current desktop default is WS/CPU/MEM + File Viewer + Tiles (1180px and wider) + gear, with the token chip and lifecycle-log button OFF. ⚠️ New header controls must not leak onto phones; `test/mobile-header-buttons-policy.test.ts` is the static guard. → [architecture-invariants#header-button-visibility-multi-monitor-response-viewer-file-viewer-cron](../../docs/architecture-invariants.md#header-button-visibility-multi-monitor-response-viewer-file-viewer-cron)
|
||||
|
||||
**Terminal font weight** (`terminalFontWeight` / `terminalFontWeightBold`, per-device, default = xterm's own `normal`/`bold`): Claude Code's markdown bold is a bare `ESC[1m`, so the weight step is its only cue. `CodemanTerminalFont.resolveWeights()` (constants.js, pure) resolves each slot against **its own** xterm default. ⚠️ The `@font-face` for `fonts/jetbrains-mono-variable.woff2` must stay declared `100 800` (the browser synthesizes from the descriptor, not the file); narrowing it silently makes the setting a no-op. ⚠️ A live save must reach both echo overlays (`refreshFont()`) and open Agent Teams panes. ⚠️ Leave `_awaitTerminalFont()` untouched. Test: `test/terminal-font-weight.test.ts`. → [architecture-invariants#terminal-font-weight](../../docs/architecture-invariants.md#terminal-font-weight)
|
||||
|
||||
**Theme skins / branding / i18n**: `skin` selects a palette via `data-skin` on `<html>`, applied by an **inline pre-paint script** in `index.html` reading `localStorage['codeman:skin']` to avoid a flash of wrong theme. ⚠️ A skin is **four things that must stay in sync**, and missing any one degrades silently: the `html[data-skin="…"]` token block in `styles.css`, the xterm ANSI palette in `terminal-ui.js`, the pre-paint allowlist, and the Settings picker (both in `index.html`). `test/skin-themes.test.ts` is the static guard. Light skins additionally need `color-scheme: light` and xterm `minimumContrastRatio: 4.5`, and `applyTerminalSkin()` must call the local-echo overlay's `refreshFont()` because it caches the terminal fg/bg. `displayName` changes user-facing browser branding only and must NEVER rename npm package, CLI, API, storage, CSS, or protocol identifiers. `language` (`en`/`zh-CN`) keeps English as the canonical source so live switching stays reversible. User display names flow through `textContent`/attribute APIs and the server title's HTML escaper, never `innerHTML`. → [architecture-invariants#theme-skins](../../docs/architecture-invariants.md#theme-skins)
|
||||
|
||||
**Foldable settings identity**: responsive layout is width-driven via `MobileDetection.getDeviceType()`, but the localStorage namespace uses `MobileDetection.isHandheldDevice()` so an unfolded Android foldable keeps `codeman-app-settings-mobile`. ⚠️ Do not switch per-device settings namespaces from instantaneous viewport width: a posture-triggered WebView reload would lose opt-in UI. Regression profile: `OPPO Find N5 (unfolded)` in `test/mobile/devices.ts`. → [architecture-invariants#foldable-settings-identity](../../docs/architecture-invariants.md#foldable-settings-identity)
|
||||
|
||||
**Folding devices: a fold is not a keyboard, and dialogs avoid the hinge**: ⚠️ in `handleViewportResize()`, a visual-viewport resize that changes the WIDTH is a shape change (rotation, fold) and must never be read as the keyboard; it re-baselines instead, or `keyboardVisible` latches with no keyboard. ⚠️ `init()` must seed `lastViewportWidth`. ⚠️ With the keyboard up, a shape change baselines to `window.innerHeight`, never the shrunk visual height. ⚠️ The hinge is reserved via `--fold-inline-end`/`--fold-block-end` (0px when unfolded): each overlay fold rule must re-state its own gutter, a base gutter overridden by a later `@media` block needs its own fold restatement there on a zero base, and dialogs use physical sides (left/top segment) in every language. Guard: `test/foldable-layout.test.ts`. → [architecture-invariants#folding-devices](../../docs/architecture-invariants.md#folding-devices)
|
||||
|
||||
**Rows held under the on-screen keyboard** (#672, `keyboardHoldsRows`, per-device, default ON): while the keyboard is up, an eligible session keeps its PTY rows (`app._keyboardHeldRows`, captured by `KeyboardHandler` on the show transition and read through `getTerminalDimensions()`) and `.xterm-screen` is translated up by `--kb-clip` (`_applyKeyboardClip()`, `keyboardClipPx()`), with the local-echo overlay bounded through `setRowLimit()`. ⚠️ Eligibility is `_keyboardHoldEligible()` only (the published `window.__codemanKeyboardHoldModes`, never an id; never with `cliMouseTracking`, remote or docker). ⚠️ Every path that can change eligibility calls `_reconcileKeyboardHold()` (tab switch, `session:updated`, reconnect, settings save), keyboard hide and a shape change release it (a shape change also arms the viewport settle, the only refit while the keyboard is up), and nothing re-holds with the keyboard already up. → [architecture-invariants#pty-and-browser-terminal-geometry](../../docs/architecture-invariants.md#pty-and-browser-terminal-geometry)
|
||||
|
||||
**WebGL renderer toggle** (`webglRendererEnabled`, per-device): the GPU-stall watchdog's sticky `codeman-webgl-disabled` marker survives page loads and is cleared only by an explicit OFF→ON save or `?webgl=force`. `?nowebgl` forces the DOM renderer per-load. → [architecture-invariants#webgl-renderer-toggle](../../docs/architecture-invariants.md#webgl-renderer-toggle)
|
||||
|
||||
**Shell keyboard accessory bar + one-shot Ctrl** (`keyboard-accessory.js`): a shell-mode session swaps the mobile accessory bar for terminal controls; `setMode()` records `extendedKeyboardBar` as the base layout and `refreshForActiveSession()` resolves base-vs-shell. ⚠️ Ctrl is a one-shot modifier applied in `terminal.onData` (after `shouldSuppressTerminalQueryResponse`, before every send path), and must skip `isTerminalFocusOrMouseReport()` chunks. ⚠️ It must disarm on use, second tap, any other accessory key, session switch, keyboard dismissal and layout swap. ⚠️ `_handleCjkInput()` must apply it too (the CJK textarea bypasses onData). Mapping: `ctrlByteFor()`. ⚠️ mobile.css's light-skin repaint must keep excluding `.accessory-btn:not(.armed)` or the armed state is invisible. → [architecture-invariants#shell-keyboard-accessory-bar-and-one-shot-ctrl](../../docs/architecture-invariants.md#shell-keyboard-accessory-bar-and-one-shot-ctrl)
|
||||
|
||||
**Mobile prompt composer** (`keyboard-accessory.js`): the agent bars' Paste key is **Compose**, a native multiline dialog where only **Send** submits (the shell bar keeps plain Paste). Opening it adopts the whole terminal prompt (`_takePendingLocalEcho`), erasing the flushed prefix with backspaces counted in code points. ⚠️ Drafts are per-session and in memory only (`_composerDrafts`), never persisted (prompts carry secrets). ⚠️ Delivery is a hand-built bracketed-paste frame via `_sendInputAsync` WITHOUT `useMux`, then a separate delayed Enter WITH it; never `terminal.paste()`, and the frame must never take the mux fallback (`TmuxManager.sendInput()` strips every `\r`, the frame's line separator, so the lines weld into one inside a second pair of markers and get submitted). ⚠️ `_composerMaxLength` must stay derived from `MAX_INPUT_LENGTH` minus the markers, or an oversized frame wedges the durable queue. ⚠️ The composer overlay needs its own gutter restatement after the fold rules. Test: `test/mobile-prompt-composer.test.ts`. → [architecture-invariants#mobile-prompt-composer](../../docs/architecture-invariants.md#mobile-prompt-composer)
|
||||
|
||||
**Terminal resilience**: a replay clear is the queued in-stream `\x1bc` in `_resetTerminalForReplay()`, never `reset()`/`clear()` (queued bytes fuse into the snapshot); the renderer watchdog `_kickRenderer()` reads xterm privates, pinned by `test/xterm-private-api.test.ts` against the resolved lockfile version; every terminal capture fetch has a deadline that covers the BODY (`_fetchTerminalCapture`). → [architecture-invariants#terminal-resilience-replay-clears-renderer-liveness-fetch-deadlines](../../docs/architecture-invariants.md#terminal-resilience-replay-clears-renderer-liveness-fetch-deadlines)
|
||||
|
||||
**WebSocket output-gap reconcile** (`_wsOutputGapSession`, app.js): output frames carry no sequence number, so an unintentional WS close while SSE stays up marks the session and the next open reconciles. ⚠️ The marker is cleared only once a repaint actually happened (`_markTerminalBufferReconciled()`, never in a `finally`). → [architecture-invariants#websocket-output-gap-reconcile](../../docs/architecture-invariants.md#websocket-output-gap-reconcile)
|
||||
|
||||
**Dismissing the on-screen keyboard** (`terminal-ui.js`): two gestures blur the terminal's hidden textarea. (1) `_installMobileKeyboardDismiss()`, a document `touchend` that must never fire inside `#terminalContainer` or on a control (`MOBILE_KEYBOARD_DISMISS_EXEMPT_SELECTOR`, via `closest()`; roving-tabindex items sit at `tabindex=-1`, so the grouped rail's are listed as `[role="treeitem"]`). (2) In `_handleMobileTerminalTap`, a second tap on inert `content` blurs; the prompt row keeps focus-then-position. ⚠️ A scroll also ends in `touchend`: both classifiers must share one threshold (`TAP_THRESHOLD` reads `MOBILE_KEYBOARD_DISMISS_TAP_SLOP`), and multi-touch is never a tap. ⚠️ An accessory key that keeps the keyboard up (`ACCESSORY_REFOCUS_ACTIONS`, keyboard-accessory.js) cancels its `mousedown` default, so the press does not blur the terminal; the click-time `terminal.focus()` stays as the fallback. Letting the tap blur and refocusing afterwards made every such key start a dismissal, a blur/focus pair per tap. A first tap on clear/compact only arms the confirm and is treated the same (`test/accessory-keeps-terminal-focus.test.ts`). ⚠️ CI cannot see the only test for (1): run `npm run test:mobile -- test/mobile/keyboard.test.ts` by hand and diff the FAIL list against master. → [architecture-invariants#dismissing-the-on-screen-keyboard](../../docs/architecture-invariants.md#dismissing-the-on-screen-keyboard)
|
||||
|
||||
**Phone toolbar: Enter replaces Shell** (post-1.8.0): inside `@media (max-width: 599px)` `btn-shell` is `display:none` and `btn-enter` takes its slot (`order: 4`); starting a shell moved into the Run dropdown (`Terminal / Shell` → `setRunMode('shell')` → `run()` → `runShell()`, button label "Run SH"). `runMode` is `z.string().max(20)` server-side, so new modes need no schema change. Desktop and tablet keep the green Run Shell button unchanged.
|
||||
|
||||
⚠️ **`sendEnterKey()` MUST go through `terminal._core.coreService.triggerDataEvent('\r', true)`** — not `sendInput()`, and never a raw POST to `/api/sessions/:id/input`. `localEchoEnabled` defaults to `MobileDetection.isTouchDevice()`, so on every phone the characters you type are buffered in the `LocalEchoOverlay` and have **never reached the PTY**; the `onData` Enter branch in terminal-ui.js is what flushes `pendingText` first and only then sends `\r` (after an 80ms delay so text lands first). Sending a bare `\r` submits an empty line and strands the typed text on screen, so the button looks dead. Replaying the keypress reuses the overlay flush, the flushed-offset cleanup and the ordering instead of reimplementing them. `KeyboardAccessory.sendKey()` is for escape sequences (arrows/Esc) and is the WRONG template to copy for input.
|
||||
|
||||
⚠️ **Skin overrides outrank plain class rules.** `styles.css` nests its skin block inside `html:not([data-skin="og"]) { … }`, so a bare `.btn-toolbar` rule in there resolves to specificity **(0,2,1)** and beats a `.btn-toolbar.btn-x` rule **(0,2,0)** in `mobile.css` regardless of load order. Toolbar-button colors set from mobile.css therefore need `!important`: that is why mobile.css leans on it so heavily. Symptom: only your `!important` properties land and everything else silently renders in generic toolbar grey. Press feedback on the phone bars sidesteps this: the `:active` rules for `.btn-toolbar`, the mobile icon buttons and `.accessory-btn` (mobile.css) use `transform` and `filter` only, which nothing on these buttons sets at a higher specificity, so one rule reads the same on every skin; a background `:active` lost to the skin block on all but og, and an inset `box-shadow` lost to the skin hover rules, which is why neither is used. The light skins darken instead of brightening, and `prefers-reduced-motion` drops the scale.
|
||||
|
||||
**Connection-loss UI** (`computeConnectionLossUi()` in constants.js, writer `_updateConnectionLossUi()` in app.js): the service worker serves the cached app shell, so an unreachable server (phone off the tailnet, VPN down, server stopped) used to render a normal-looking empty dashboard whose only tell was the 8px header dot, which reads as "no sessions", not "no connection". Two surfaces now: a full-screen **overlay** while no server state has loaded this page load (nothing behind it is worth preserving), and a non-blocking **banner** once it has (the terminal scrollback stays readable). ⚠️ A **2.5s grace** is load-bearing: a COM deploy restarts the server and SSE is back in ~200ms, and a banner on every deploy trains the user to ignore it. `navigator.onLine === false` skips the grace, since that is never a blip. Retry re-arms SSE **and** the terminal WS (`planWsReconnect` can 'give-up', and the SSE backoff caps at 30s).
|
||||
|
||||
**SSE staleness watchdog** (`computeSseStale()` in constants.js, `_checkSseStale()` + a 5s interval in app.js): an `EventSource` can stop delivering without erroring, so the client forces a reconnect when nothing arrives. ⚠️ The server keepalive must stay the named `sse:heartbeat` event (`cleanupDeadClients()`, sse-stream-manager.ts), never an SSE comment, which `EventSource` cannot observe; its no-op client listener must stay registered. ⚠️ Judge staleness only while `connected` and online (the loop breaker). ⚠️ The liveness stamp lives inside `addListener`. ⚠️ Clear the interval only at the top of `connectSSE()`, or intervals stack. ⚠️ The watchdog cannot see an attempt that never OPENS (no response headers: no `open`, no `error`, never `connected`), so each attempt is bounded by `SSE_CONNECT_TIMEOUT_MS` (15s, `CodemanSseStale.CONNECT_TIMEOUT_MS`) and then retried through `_handleSseConnectionFailure()`, the same backoff `onerror` uses. Its timer is cleared at the top of `connectSSE()` BEFORE the offline early return, on `open`, and in `_handleSseConnectionFailure()`; the timer and the handler both ignore an `EventSource` that has been replaced. The window `offline` handler leaves it running on purpose (`navigator.onLine` can report offline on a working network). Tests: `test/sse-connect-timeout.test.ts`. → [architecture-invariants#sse-staleness-watchdog](../../docs/architecture-invariants.md#sse-staleness-watchdog)
|
||||
|
||||
**Z-index layers** (keep new overlays consistent with this stack): local echo overlay (7; with local echo on it also draws the iOS IME composition preview, as an underlined tail after its pending text via `setComposition`), iOS IME composition preview span when local echo is off (6 inside `.xterm-helpers`, whose own z-index 5 is its EFFECTIVE layer, so it sits UNDER the overlay and must never be used while the overlay shows text), terminal touch-selection bar (900, below floating agent windows), subagent windows + split picker menu + Tiles count menu (1000), plan agents (1100), mobile/tablet fixed header (1200), modals on ≤768px (1300, must beat the fixed header), log viewers (2000), connection-loss overlay (2500), image popups (3000), response viewer (5000, backdrop 4999), folder grid (5090, above the response viewer it opens from, below the file preview its tiles open), file-preview overlay (5100, must outrank the response viewer that launches it), toasts/path picker (10000+), custom-model center-status banner (10001; its `[hidden]` must re-assert `display: none` or `dismiss()` leaves an invisible click-blocker), custom-model swap-confirm/context-warning modals (10010). → [architecture-invariants#z-index-layers](../../docs/architecture-invariants.md#z-index-layers)
|
||||
|
||||
**Keyboard shortcuts**: Escape (close), Ctrl+? (shortcut overlay), Ctrl/Cmd/Alt+K (session palette), Ctrl+Tab (next), Alt+[/] (prev/next tab), Alt+1-9 (switch tab), Ctrl+Shift+{/} (move tab left/right), Shift+Enter or Ctrl+Enter (newline), Ctrl+C (copy selection, else interrupt) / Ctrl+Shift+C (copy, never interrupts), Ctrl+L (clear), Ctrl+Shift+R (restore size), Ctrl+Shift+V (voice input), Ctrl/Cmd +/- (font), Shift+Wheel (local scrollback when mouse passthrough is active), Shift+drag (start a selection in a stripped-DECSET pane, where xterm's own Shift branch is unreachable and a Shift+drag used to select nothing; `_installShiftDragSelection`), right-click (copy the selection, the mintty/PuTTY convention, since xterm paints into a canvas and the native menu has no Copy for it; with nothing selected the native menu is left alone). Rebindable via the registry. ⚠️ **Ctrl+W is deliberately NOT bound** (owner decision): it is delete-word in every shell and agent CLI, and as Close Session it killed sessions with no confirm; `close-session` keeps `bindings: []` and stays bindable (`test/ctrl-w-never-closes.test.ts`).
|
||||
@@ -0,0 +1,138 @@
|
||||
/**
|
||||
* @fileoverview Opt-in snapshot of the agent inbox, so pending mail between agents
|
||||
* survives a server restart (the agents themselves keep running in tmux across it).
|
||||
*
|
||||
* Off by default: an install that never turns on `agentInboxPersistEnabled` writes
|
||||
* nothing new to disk. The rules (discussion #558):
|
||||
* - Off means no file. Switching it off deletes the snapshot; the in-memory inboxes
|
||||
* stay as they are.
|
||||
* - Its own file under `dataPath()`, written 0600 through tmp + rename like
|
||||
* `intents.json`, since messages carry the same kind of text prompts do. It is never
|
||||
* read by `/api/search` and `sensitive-path.ts` keeps the file viewer off it.
|
||||
* - Writes are debounced (a burst of posts is one write) and flushed on shutdown.
|
||||
* - Boot pruning is NOT done here: the server calls `agentInbox.pruneExcept()` only
|
||||
* after the session restore succeeded, so a failed restore leaves the mail alone.
|
||||
*
|
||||
* @module web/agent-inbox-persistence
|
||||
*/
|
||||
|
||||
import { existsSync, mkdirSync, readFileSync, renameSync, rmSync, writeFileSync } from 'node:fs';
|
||||
import { dirname } from 'node:path';
|
||||
import { dataPath } from '../config/instance.js';
|
||||
import { agentInbox, type AgentInbox } from './agent-inbox.js';
|
||||
|
||||
/** A burst of posts (a coordinator fanning out to five workers) becomes one write. */
|
||||
const WRITE_DEBOUNCE_MS = 250;
|
||||
|
||||
export class AgentInboxPersistence {
|
||||
private enabled = false;
|
||||
private timer: NodeJS.Timeout | null = null;
|
||||
|
||||
constructor(
|
||||
private readonly inbox: AgentInbox = agentInbox,
|
||||
/** Resolved per call: test HOMEs change between files. */
|
||||
private readonly filePath: () => string = () => dataPath('agent-inbox.json'),
|
||||
private readonly debounceMs: number = WRITE_DEBOUNCE_MS
|
||||
) {
|
||||
this.inbox.onChange = () => this.schedule();
|
||||
}
|
||||
|
||||
get isEnabled(): boolean {
|
||||
return this.enabled;
|
||||
}
|
||||
|
||||
/**
|
||||
* Boot: apply the setting and, when it is on, load the snapshot into the store.
|
||||
* Returns how many messages were restored. A missing or unreadable file is an
|
||||
* empty inbox, never a failed boot.
|
||||
*/
|
||||
load(enabled: boolean): number {
|
||||
this.enabled = enabled;
|
||||
if (!enabled) {
|
||||
// A file left from an earlier "on" is removed, so off really means no file.
|
||||
this.removeFile();
|
||||
return 0;
|
||||
}
|
||||
const path = this.filePath();
|
||||
if (!existsSync(path)) return 0;
|
||||
try {
|
||||
return this.inbox.restore(JSON.parse(readFileSync(path, 'utf-8')));
|
||||
} catch (err) {
|
||||
console.warn(`[AgentInbox] Failed to load ${path}, starting empty:`, err);
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The setting changed. On: write the current inboxes now. Off: drop any pending
|
||||
* write and delete the snapshot; memory is untouched.
|
||||
*/
|
||||
setEnabled(enabled: boolean): void {
|
||||
if (enabled === this.enabled) return;
|
||||
this.enabled = enabled;
|
||||
if (enabled) {
|
||||
this.writeNow();
|
||||
} else {
|
||||
this.cancel();
|
||||
this.removeFile();
|
||||
}
|
||||
}
|
||||
|
||||
/** Shutdown: write a pending change now instead of losing it with the timer. */
|
||||
flush(): void {
|
||||
if (!this.timer) return;
|
||||
this.cancel();
|
||||
this.writeNow();
|
||||
}
|
||||
|
||||
/** Test hook: drop a pending write and detach from the store. */
|
||||
dispose(): void {
|
||||
this.cancel();
|
||||
if (this.inbox.onChange) this.inbox.onChange = undefined;
|
||||
}
|
||||
|
||||
private schedule(): void {
|
||||
if (!this.enabled || this.timer) return;
|
||||
this.timer = setTimeout(() => {
|
||||
this.timer = null;
|
||||
this.writeNow();
|
||||
}, this.debounceMs);
|
||||
// A pending write must not hold the process open; shutdown flushes it explicitly.
|
||||
this.timer.unref();
|
||||
}
|
||||
|
||||
private cancel(): void {
|
||||
if (this.timer) clearTimeout(this.timer);
|
||||
this.timer = null;
|
||||
}
|
||||
|
||||
private writeNow(): void {
|
||||
if (!this.enabled) return;
|
||||
const path = this.filePath();
|
||||
const tmpPath = `${path}.tmp`;
|
||||
try {
|
||||
mkdirSync(dirname(path), { recursive: true });
|
||||
// 0600: agent messages can carry anything a prompt can, secrets included.
|
||||
// `mode` applies only when the file is created, and the rename makes every
|
||||
// write a fresh file, so the final file is always 0600.
|
||||
rmSync(tmpPath, { force: true });
|
||||
writeFileSync(tmpPath, JSON.stringify(this.inbox.snapshot()), { mode: 0o600 });
|
||||
renameSync(tmpPath, path);
|
||||
} catch (err) {
|
||||
console.warn(`[AgentInbox] Failed to persist ${path}:`, err);
|
||||
}
|
||||
}
|
||||
|
||||
private removeFile(): void {
|
||||
const path = this.filePath();
|
||||
try {
|
||||
rmSync(path, { force: true });
|
||||
rmSync(`${path}.tmp`, { force: true });
|
||||
} catch (err) {
|
||||
console.warn(`[AgentInbox] Failed to remove ${path}:`, err);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Process-wide instance wired to `agentInbox`; the server and the settings route use it. */
|
||||
export const agentInboxPersistence = new AgentInboxPersistence();
|
||||
@@ -0,0 +1,307 @@
|
||||
/**
|
||||
* @fileoverview Agent inbox: a per-session mailbox other agents (or the user) post to,
|
||||
* and the receiving agent reads when it chooses to.
|
||||
*
|
||||
* The other agent-to-agent channel, `POST /api/sessions/:id/input`, types into the
|
||||
* receiver's composer. That is right for a prompt and wrong for a message: every
|
||||
* delivery starts or interrupts a turn, the receiver cannot defer it, and it is one
|
||||
* line only. The inbox stores instead of typing. Nothing here ever reaches a pane.
|
||||
*
|
||||
* Invariants (each pinned in `test/agent-inbox.test.ts`):
|
||||
* - **Read until ack.** A read hands out every pending message and removes nothing.
|
||||
* A message goes only when the receiver acknowledges its id. "Read and dropped"
|
||||
* and "done" must not look the same to the sender (discussion #558, the 09-23 case).
|
||||
* - **Seen is the receiver's alone.** `read(…, { receiver: true })` marks what it
|
||||
* handed out as seen; any other read is a peek and changes nothing. The route
|
||||
* decides who the receiver is; this module only takes the flag.
|
||||
* - **Bounded, and full refuses.** `MAX_MESSAGES_PER_INBOX`, `MAX_MESSAGE_BYTES` and
|
||||
* `MAX_INBOX_BYTES`. A post that would cross any of them is refused so the sender
|
||||
* learns. The oldest message is never dropped to make room.
|
||||
* - **TTL from the message's own timestamp** (`MESSAGE_TTL_MS`). A restore keeps
|
||||
* `createdAt`, so a restart does not restart the clock. Expiry is lazy: every
|
||||
* accessor sweeps the inbox it touches, `summary()` and `snapshot()` sweep all.
|
||||
* - **No IO, no `Session` import.** Persistence (`agent-inbox-persistence.ts`) hooks
|
||||
* `onChange`; long-poll waiters live in `session-wait-registry.ts`.
|
||||
*
|
||||
* @consumedby web/routes/inbox-routes, web/agent-inbox-persistence, web/server
|
||||
* @module web/agent-inbox
|
||||
*/
|
||||
|
||||
import { randomUUID } from 'node:crypto';
|
||||
|
||||
// ─── Bounds ──────────────────────────────────────────────────────────────────
|
||||
|
||||
/** Messages one inbox holds before posts are refused. */
|
||||
export const MAX_MESSAGES_PER_INBOX = 200;
|
||||
/** UTF-8 bytes per message. A bigger payload belongs in the workspace; post its path. */
|
||||
export const MAX_MESSAGE_BYTES = 16_384;
|
||||
/** UTF-8 bytes of message text one inbox holds before posts are refused. */
|
||||
export const MAX_INBOX_BYTES = 512 * 1024;
|
||||
/** A message older than this is gone, read or not (same span as the approvals inbox). */
|
||||
export const MESSAGE_TTL_MS = 12 * 60 * 60 * 1000;
|
||||
|
||||
// ─── Types ───────────────────────────────────────────────────────────────────
|
||||
|
||||
/** A message as it is stored (and persisted). */
|
||||
interface StoredMessage {
|
||||
id: string;
|
||||
/** The posting session's full id, or null when no session could be resolved (the user). */
|
||||
from: string | null;
|
||||
text: string;
|
||||
/** Epoch ms. The TTL counts from here. */
|
||||
createdAt: number;
|
||||
/** Epoch ms of the first receiver read that handed this message out; null while unseen. */
|
||||
seenAt: number | null;
|
||||
}
|
||||
|
||||
/** A message as a read hands it out. */
|
||||
export interface InboxMessage {
|
||||
id: string;
|
||||
from: string | null;
|
||||
text: string;
|
||||
createdAt: number;
|
||||
/** True when a receiver read had handed this message out BEFORE this read. */
|
||||
seen: boolean;
|
||||
}
|
||||
|
||||
export interface InboxReadResult {
|
||||
/** Every pending message, oldest first: unseen and seen-but-unacked alike. */
|
||||
messages: InboxMessage[];
|
||||
pending: number;
|
||||
/** Unseen after this read (0 after a receiver read; unchanged by a peek). */
|
||||
unseen: number;
|
||||
}
|
||||
|
||||
export type PostResult =
|
||||
| { ok: true; message: InboxMessage; pending: number; unseen: number }
|
||||
| { ok: false; reason: 'empty' | 'message-too-large' | 'inbox-full-count' | 'inbox-full-bytes' };
|
||||
|
||||
/** What `summary()` reports for one session. Counts and times only, never text. */
|
||||
export interface InboxCounts {
|
||||
pending: number;
|
||||
unseen: number;
|
||||
/** `createdAt` of the oldest unseen message, null when nothing is unseen. */
|
||||
oldestUnseenAt: number | null;
|
||||
}
|
||||
|
||||
/** On-disk shape. `version` lets a later format refuse an older file instead of misreading it. */
|
||||
export interface InboxSnapshot {
|
||||
version: 1;
|
||||
inboxes: Record<string, StoredMessage[]>;
|
||||
}
|
||||
|
||||
// ─── Store ───────────────────────────────────────────────────────────────────
|
||||
|
||||
export class AgentInbox {
|
||||
private readonly inboxes = new Map<string, StoredMessage[]>();
|
||||
|
||||
/** Any mutation (post, seen, ack, expiry, drop, prune, restore excluded). Persistence hooks this. */
|
||||
onChange: (() => void) | undefined;
|
||||
|
||||
constructor(private readonly now: () => number = Date.now) {}
|
||||
|
||||
/**
|
||||
* Store a message for `sessionId`. `from` is the RESOLVED sender (the route's job),
|
||||
* never a client-supplied label.
|
||||
*/
|
||||
post(sessionId: string, from: string | null, text: string): PostResult {
|
||||
if (text.length === 0) return { ok: false, reason: 'empty' };
|
||||
const bytes = Buffer.byteLength(text, 'utf8');
|
||||
if (bytes > MAX_MESSAGE_BYTES) return { ok: false, reason: 'message-too-large' };
|
||||
const queue = this.live(sessionId);
|
||||
if (queue.length >= MAX_MESSAGES_PER_INBOX) return { ok: false, reason: 'inbox-full-count' };
|
||||
if (inboxBytes(queue) + bytes > MAX_INBOX_BYTES) return { ok: false, reason: 'inbox-full-bytes' };
|
||||
const stored: StoredMessage = { id: randomUUID(), from, text, createdAt: this.now(), seenAt: null };
|
||||
queue.push(stored);
|
||||
this.inboxes.set(sessionId, queue);
|
||||
this.onChange?.();
|
||||
return { ok: true, message: view(stored), pending: queue.length, unseen: countUnseen(queue) };
|
||||
}
|
||||
|
||||
/**
|
||||
* Every pending message, oldest first. Removes nothing. With `receiver`, the
|
||||
* messages handed out are marked seen (each keeps its first `seenAt`); without it
|
||||
* this is a peek and changes nothing at all.
|
||||
*/
|
||||
read(sessionId: string, options: { receiver: boolean }): InboxReadResult {
|
||||
const queue = this.live(sessionId);
|
||||
const messages = queue.map(view);
|
||||
if (options.receiver) {
|
||||
let marked = false;
|
||||
const at = this.now();
|
||||
for (const m of queue) {
|
||||
if (m.seenAt !== null) continue;
|
||||
m.seenAt = at;
|
||||
marked = true;
|
||||
}
|
||||
if (marked) this.onChange?.();
|
||||
}
|
||||
return { messages, pending: queue.length, unseen: countUnseen(queue) };
|
||||
}
|
||||
|
||||
/** Messages no receiver read has handed out yet. A `?wait=` read parks while this is 0. */
|
||||
unseenCount(sessionId: string): number {
|
||||
return countUnseen(this.live(sessionId));
|
||||
}
|
||||
|
||||
pendingCount(sessionId: string): number {
|
||||
return this.live(sessionId).length;
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove the given messages. Idempotent: an id that is unknown or already gone is
|
||||
* not an error, so a retry after a lost response succeeds. Returns how many went.
|
||||
*/
|
||||
ack(sessionId: string, ids: readonly string[]): number {
|
||||
const queue = this.live(sessionId);
|
||||
if (queue.length === 0 || ids.length === 0) return 0;
|
||||
const drop = new Set(ids);
|
||||
const kept = queue.filter((m) => !drop.has(m.id));
|
||||
const removed = queue.length - kept.length;
|
||||
if (removed === 0) return 0;
|
||||
this.store(sessionId, kept);
|
||||
this.onChange?.();
|
||||
return removed;
|
||||
}
|
||||
|
||||
/** Per-session counts for every inbox that holds anything. Never blocks. */
|
||||
summary(): Map<string, InboxCounts> {
|
||||
const out = new Map<string, InboxCounts>();
|
||||
for (const sessionId of [...this.inboxes.keys()]) {
|
||||
const queue = this.live(sessionId);
|
||||
if (queue.length === 0) continue;
|
||||
const unseen = queue.filter((m) => m.seenAt === null);
|
||||
out.set(sessionId, {
|
||||
pending: queue.length,
|
||||
unseen: unseen.length,
|
||||
oldestUnseenAt: unseen.length > 0 ? unseen[0].createdAt : null,
|
||||
});
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** The session was DELETED: its mail goes with it. Not called on detach or shutdown. */
|
||||
drop(sessionId: string): void {
|
||||
if (this.inboxes.delete(sessionId)) this.onChange?.();
|
||||
}
|
||||
|
||||
/**
|
||||
* Boot-time sweep, run only after the session restore succeeded: discard inboxes
|
||||
* whose session did not come back. Every route needs a live session, so an orphan
|
||||
* could otherwise never be removed. Returns how many inboxes were dropped.
|
||||
*/
|
||||
pruneExcept(keep: Iterable<string>): number {
|
||||
const alive = new Set(keep);
|
||||
let dropped = 0;
|
||||
for (const sessionId of [...this.inboxes.keys()]) {
|
||||
if (alive.has(sessionId)) continue;
|
||||
this.inboxes.delete(sessionId);
|
||||
dropped++;
|
||||
}
|
||||
if (dropped > 0) this.onChange?.();
|
||||
return dropped;
|
||||
}
|
||||
|
||||
/** Whole-store snapshot for persistence, expired messages already swept. */
|
||||
snapshot(): InboxSnapshot {
|
||||
const inboxes: Record<string, StoredMessage[]> = {};
|
||||
for (const sessionId of [...this.inboxes.keys()]) {
|
||||
const queue = this.live(sessionId);
|
||||
if (queue.length > 0) inboxes[sessionId] = queue.map((m) => ({ ...m }));
|
||||
}
|
||||
return { version: 1, inboxes };
|
||||
}
|
||||
|
||||
/**
|
||||
* Replace the store from a snapshot (server boot). Malformed input is skipped entry
|
||||
* by entry rather than thrown: a corrupt file must not stop the server. Expired
|
||||
* messages are not restored, and the caps hold for what is. Does not fire
|
||||
* `onChange`: writing back what was just read would be a no-op at best.
|
||||
*/
|
||||
restore(snapshot: unknown): number {
|
||||
const data = snapshot as Partial<InboxSnapshot> | null;
|
||||
this.inboxes.clear();
|
||||
if (!data || data.version !== 1 || typeof data.inboxes !== 'object' || data.inboxes === null) return 0;
|
||||
const cutoff = this.now() - MESSAGE_TTL_MS;
|
||||
let restored = 0;
|
||||
for (const [sessionId, raw] of Object.entries(data.inboxes)) {
|
||||
if (!Array.isArray(raw)) continue;
|
||||
const queue: StoredMessage[] = [];
|
||||
let bytes = 0;
|
||||
// Oldest first whatever order the file has, so the caps keep the oldest mail,
|
||||
// which is the rule for posts too.
|
||||
const entries = raw
|
||||
.filter((entry): entry is StoredMessage => isStoredMessage(entry) && entry.createdAt > cutoff)
|
||||
.sort((a, b) => a.createdAt - b.createdAt);
|
||||
for (const entry of entries) {
|
||||
const size = Buffer.byteLength(entry.text, 'utf8');
|
||||
if (queue.length >= MAX_MESSAGES_PER_INBOX || bytes + size > MAX_INBOX_BYTES) break;
|
||||
queue.push({ ...entry });
|
||||
bytes += size;
|
||||
}
|
||||
if (queue.length === 0) continue;
|
||||
this.inboxes.set(sessionId, queue);
|
||||
restored += queue.length;
|
||||
}
|
||||
return restored;
|
||||
}
|
||||
|
||||
/** Test hook: back to an empty store with no listener. */
|
||||
resetForTests(): void {
|
||||
this.inboxes.clear();
|
||||
this.onChange = undefined;
|
||||
}
|
||||
|
||||
/** The inbox with expired messages swept out (fires `onChange` when it swept any). */
|
||||
private live(sessionId: string): StoredMessage[] {
|
||||
const queue = this.inboxes.get(sessionId);
|
||||
if (!queue) return [];
|
||||
const cutoff = this.now() - MESSAGE_TTL_MS;
|
||||
// Every message, not only queue[0]: a clock stepping back can put an older
|
||||
// createdAt behind a newer one. At most 200 entries, so the scan is cheap.
|
||||
if (!queue.some((m) => m.createdAt <= cutoff)) return queue;
|
||||
const kept = queue.filter((m) => m.createdAt > cutoff);
|
||||
this.store(sessionId, kept);
|
||||
this.onChange?.();
|
||||
return kept;
|
||||
}
|
||||
|
||||
private store(sessionId: string, queue: StoredMessage[]): void {
|
||||
if (queue.length === 0) this.inboxes.delete(sessionId);
|
||||
else this.inboxes.set(sessionId, queue);
|
||||
}
|
||||
}
|
||||
|
||||
function view(m: StoredMessage): InboxMessage {
|
||||
return { id: m.id, from: m.from, text: m.text, createdAt: m.createdAt, seen: m.seenAt !== null };
|
||||
}
|
||||
|
||||
function countUnseen(queue: readonly StoredMessage[]): number {
|
||||
let n = 0;
|
||||
for (const m of queue) if (m.seenAt === null) n++;
|
||||
return n;
|
||||
}
|
||||
|
||||
function inboxBytes(queue: readonly StoredMessage[]): number {
|
||||
let total = 0;
|
||||
for (const m of queue) total += Buffer.byteLength(m.text, 'utf8');
|
||||
return total;
|
||||
}
|
||||
|
||||
function isStoredMessage(value: unknown): value is StoredMessage {
|
||||
const m = value as Partial<StoredMessage> | null;
|
||||
return (
|
||||
!!m &&
|
||||
typeof m.id === 'string' &&
|
||||
(m.from === null || typeof m.from === 'string') &&
|
||||
typeof m.text === 'string' &&
|
||||
m.text.length > 0 &&
|
||||
Buffer.byteLength(m.text, 'utf8') <= MAX_MESSAGE_BYTES &&
|
||||
typeof m.createdAt === 'number' &&
|
||||
Number.isFinite(m.createdAt) &&
|
||||
(m.seenAt === null || typeof m.seenAt === 'number')
|
||||
);
|
||||
}
|
||||
|
||||
/** Process-wide store used by the routes and the server wiring. Tests construct their own. */
|
||||
export const agentInbox = new AgentInbox();
|
||||
@@ -15,7 +15,9 @@
|
||||
* `Session` import, no IO; the server injects emit callbacks (`onPending`/
|
||||
* `onUpdated`/`onResolved`), which keeps this unit-testable and cycle-free.
|
||||
* - Items are in-memory only. A server restart drops them; the next prompt
|
||||
* re-fires the hook. Claude-mode sessions only (hooks fire for nothing else).
|
||||
* re-fires the hook. Answerable items are Claude's alone; the DeepSeek status
|
||||
* bridge and the screen-read dialogs of a hook-less CLI (codex, see
|
||||
* `web/screen-blocked.ts`) open alert-only items (`answerInTerminal`).
|
||||
* - Answer flow is take-then-write: `take()` removes the item BEFORE keystrokes
|
||||
* are sent so a double-tap cannot double-send; `restore()` re-inserts on a
|
||||
* failed write unless a newer prompt arrived meanwhile.
|
||||
@@ -24,7 +26,8 @@
|
||||
*
|
||||
* @dependencies utils (stripAnsi, CLAUDE_WORKING_LINE_PATTERN)
|
||||
* @consumedby web/routes/hook-event-routes (notePrompt/resolve), web/routes/approval-routes,
|
||||
* web/session-listener-wiring (working/exit resolution), web/server (emit callbacks + stop)
|
||||
* web/session-listener-wiring (working/exit resolution), web/screen-blocked (screen-read
|
||||
* dialogs), web/server (emit callbacks + stop)
|
||||
*
|
||||
* @module web/approval-inbox
|
||||
*/
|
||||
@@ -85,6 +88,13 @@ export interface ApprovalItem {
|
||||
* answer endpoint accepts; absent → only approve('1')/deny(Esc) are allowed.
|
||||
*/
|
||||
options?: ApprovalOption[];
|
||||
/**
|
||||
* When true the dialog belongs to a third-party TUI whose keystrokes Codeman
|
||||
* cannot verify (today: DeepSeek Harness / `hooks: 'supervised'`). The item
|
||||
* is an ALERT only: the answer route refuses keystrokes, push omits Approve/
|
||||
* Deny actions, and the UI offers "Open session" instead of answer buttons.
|
||||
*/
|
||||
answerInTerminal?: boolean;
|
||||
}
|
||||
|
||||
export interface ApprovalResolvedInfo {
|
||||
@@ -108,8 +118,26 @@ interface NotePromptArgs {
|
||||
* opens ALREADY acknowledged: see `notePrompt()`.
|
||||
*/
|
||||
watching?: string | null;
|
||||
/**
|
||||
* Alert-only item: answer in the terminal, not via Approve/Deny. See
|
||||
* `ApprovalItem.answerInTerminal`.
|
||||
*/
|
||||
answerInTerminal?: boolean;
|
||||
/**
|
||||
* An idle prompt that should open ALREADY acknowledged for a reason other than
|
||||
* `watching`, e.g. `cache keep-alive` when the turn that just ended was Codeman's own
|
||||
* prompt-cache nudge. Same consequence as `watching`: the item exists, only its alert is spent.
|
||||
*/
|
||||
preAcknowledged?: string;
|
||||
/** Returns the raw (ANSI-bearing) pane frame, or null when unavailable. */
|
||||
capture?: () => string | null;
|
||||
/**
|
||||
* A frame the caller already read, kept as the card's context when there is no
|
||||
* `capture`. Never parsed for options and never re-read: an item raised from the
|
||||
* screen (`web/screen-blocked.ts`) belongs to the session that keeps reading it, and
|
||||
* the option parser and staleness sweep here are shaped for Claude's dialogs.
|
||||
*/
|
||||
context?: string;
|
||||
}
|
||||
|
||||
// ─── Tunables ────────────────────────────────────────────────────────────────
|
||||
@@ -261,11 +289,13 @@ export class ApprovalInbox {
|
||||
toolSummary: args.toolSummary,
|
||||
message: args.message,
|
||||
cwd: args.cwd,
|
||||
...(args.answerInTerminal ? { answerInTerminal: true } : {}),
|
||||
};
|
||||
if (args.kind === 'idle' && args.watching) {
|
||||
if (args.kind === 'idle' && (args.preAcknowledged || args.watching)) {
|
||||
item.acknowledgedAt = item.createdAt;
|
||||
item.acknowledgedReason = `watching ${args.watching}`;
|
||||
item.acknowledgedReason = args.preAcknowledged ?? `watching ${args.watching}`;
|
||||
}
|
||||
if (!args.capture && args.context) item.context = normalizeCapturedFrame(args.context);
|
||||
this.applyCapture(item, args.capture);
|
||||
this.items.set(args.sessionId, item);
|
||||
if (args.capture) this.captures.set(args.sessionId, args.capture);
|
||||
|
||||
+16
-5
@@ -12,8 +12,10 @@
|
||||
* its symlink-resolved form, against both the given and the symlink-resolved roots (a home reached
|
||||
* through a link, macOS's `/etc` -> `/private/etc`), so a link into a blocked tree is not a way
|
||||
* around it;
|
||||
* - it must not be, or be inside, a cases directory: a case there is a plain Create New, and the same
|
||||
* folder listed both as a local case and as a linked one would make deleting it remove files;
|
||||
* - it must not be a cases directory or a direct child of one: those are what `GET /api/cases` lists
|
||||
* as local cases, so a case there is a plain Create New, and the same folder listed both as a local
|
||||
* case and as a linked one would make deleting it remove files. A folder further down is not
|
||||
* listed and is allowed;
|
||||
* - its parent must already exist (one folder is created, never a whole chain), and the folder
|
||||
* itself must not exist or must be an EMPTY directory (a folder with contents is Link Existing's
|
||||
* job, and silently scaffolding into someone's project is the one thing this must never do);
|
||||
@@ -58,6 +60,8 @@ export interface NewCasePathContext {
|
||||
* listed as a local case, so it must not be registered as a linked one too.
|
||||
*/
|
||||
casesDirs?: readonly string[];
|
||||
/** What to do instead when the path is the cases folder or directly in it. Defaults to Create New's wording. */
|
||||
casesDirAdvice?: string;
|
||||
}
|
||||
|
||||
export type NewCasePathResult =
|
||||
@@ -98,9 +102,16 @@ export function blockedReason(
|
||||
const firstSegment = absPath.slice(ctx.home.length + 1).split(sep)[0];
|
||||
if (/^\.codeman/.test(firstSegment)) return "Codeman's own data folder cannot be a case";
|
||||
}
|
||||
// Only the cases folder itself and its DIRECT children are listed as local cases (GET /api/cases reads
|
||||
// that one level), so only those would be registered twice. A deeper folder is not listed and is a
|
||||
// perfectly good linked case: with CODEMAN_CASES_PATH pointing at a broad root such as /mnt/user/Scripts,
|
||||
// refusing everything below it blocked most of the tree.
|
||||
for (const dir of ctx.casesDirs ?? []) {
|
||||
if (isWithin(absPath, dir)) {
|
||||
return 'That folder is inside the cases folder; create a case there with plain Create New (no custom folder)';
|
||||
if (absPath === dir) {
|
||||
return `That is the cases folder itself (${dir}); ${ctx.casesDirAdvice ?? 'create a case inside it with plain Create New (no custom folder)'}`;
|
||||
}
|
||||
if (dirname(absPath) === dir) {
|
||||
return `That folder is directly in the cases folder (${dir}), where it is listed as a local case; ${ctx.casesDirAdvice ?? 'create it there with plain Create New (no custom folder)'}`;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
@@ -123,7 +134,7 @@ async function resolvedPolicy(ctx: NewCasePathContext): Promise<[NewCasePathCont
|
||||
Promise.all((ctx.casesDirs ?? []).map(realpathOr)),
|
||||
Promise.all(BLOCKED_SYSTEM_ROOTS.map(realpathOr)),
|
||||
]);
|
||||
return [{ home, dataDir, casesDirs }, systemRoots];
|
||||
return [{ home, dataDir, casesDirs, casesDirAdvice: ctx.casesDirAdvice }, systemRoots];
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -0,0 +1,497 @@
|
||||
/**
|
||||
* @fileoverview Reads a claude session's edit history on request: the new bytes of its
|
||||
* transcripts (main + subagents) go into its `EditHistoryStore`, and the Bash image
|
||||
* candidates that came with them are confirmed against the files on disk.
|
||||
*
|
||||
* WHEN. Only when the panel asks (`GET …/edits?sync=1` on open and while it follows the
|
||||
* session, `POST …/edits/older`): no timer, no hook, no background read for sessions nobody
|
||||
* looks at. One sync per session runs at a time; requests during a run are folded into one
|
||||
* more forward read and/or one more older read afterwards.
|
||||
*
|
||||
* HOW MUCH. A forward read shares one budget over all files, main first. A file whose chunk
|
||||
* the shared budget cut short is not applied (the store would take a chunk with no line end
|
||||
* for a line longer than the budget) but read in the next pass; up to
|
||||
* {@link MAX_FORWARD_PASSES} passes per sync, the rest with the next one.
|
||||
*
|
||||
* NEW CONVERSATION. When the transcript path names another conversation (`/clear`, resume),
|
||||
* the previous transcript (same directory) is first read to its end, so edits it got after
|
||||
* the last read are not lost with its cursors; then the new one is read from its end.
|
||||
* "Load older" reaches back within the current conversation only.
|
||||
*
|
||||
* MEMORY. At most {@link MAX_LOADED_STORES} stores stay loaded; past that the least recently
|
||||
* used one without a run is written (if it changed) and dropped, and loaded again on demand.
|
||||
*
|
||||
* GONE. `remove()` (the session's record is deleted) marks the session gone, deletes the file
|
||||
* and then drops the session's slot: a run in flight stops after its current read and its
|
||||
* store no longer writes; a request during the removal reads nothing (`unavailable`, reason
|
||||
* `removed`). A session that comes back with the same id (reboot restore) starts afresh.
|
||||
* `sweep()` deletes, at startup, the stored history of sessions that no longer exist. A
|
||||
* subagent transcript that vanished loses its cursor (`dropSource`), not its entries.
|
||||
*
|
||||
* OLDER. "Load older" reads a full budget block of up to {@link MAX_OLDER_SOURCES} sources
|
||||
* with older lines per click; with more sources they take turns.
|
||||
*
|
||||
* WHICH SESSIONS. Local claude sessions. Remote, Docker and non-claude sessions answer
|
||||
* `unsupported` and are never read.
|
||||
*
|
||||
* IMAGES. A Bash candidate counts only when the file's mtime lies in the call's window
|
||||
* (start − 2 s … end + 2 s), so `ls *.png` lists nothing.
|
||||
*
|
||||
* Tests: `test/edit-history-sync.test.ts`.
|
||||
*
|
||||
* @module web/edit-history-sync
|
||||
*/
|
||||
|
||||
import { basename, dirname, join } from 'node:path';
|
||||
import type { SessionRemote } from '../types/session.js';
|
||||
import type { EditSourceReadOptions, EditSourceRequest, EditSources, PathProbe } from '../edit-history-local-source.js';
|
||||
import type { ImageCandidate } from '../edit-history-parse.js';
|
||||
import { EditHistoryStore, READ_BUDGET_BYTES, type ConfirmedImage } from '../edit-history-store.js';
|
||||
import { getCli } from '../config/cli-registry/registry.js';
|
||||
|
||||
/** Slack around a Bash call's window for the image mtime check. */
|
||||
export const IMAGE_MTIME_SLACK_MS = 2000;
|
||||
/** Forward passes per sync when files grew by more than one budget. */
|
||||
export const MAX_FORWARD_PASSES = 3;
|
||||
/** Sources read back per "Load older" click, a full budget each. */
|
||||
export const MAX_OLDER_SOURCES = 3;
|
||||
/** Stores kept in memory; the least recently used one past this is dropped. */
|
||||
export const MAX_LOADED_STORES = 3;
|
||||
/** `reason` when the session has no transcript yet (no prompt yet, or not found). */
|
||||
export const NO_TRANSCRIPT = 'no-transcript';
|
||||
/** `reason` once the session's history was deleted. */
|
||||
export const REMOVED = 'removed';
|
||||
|
||||
export type EditHistoryState = 'ok' | 'unavailable' | 'unsupported';
|
||||
|
||||
export interface EditHistoryStatus {
|
||||
state: EditHistoryState;
|
||||
reason?: string;
|
||||
}
|
||||
|
||||
/** The slice of a session this needs. */
|
||||
export interface EditHistorySession {
|
||||
id: string;
|
||||
mode: string;
|
||||
remote: SessionRemote | undefined;
|
||||
docker?: unknown;
|
||||
claudeSessionId: string | null;
|
||||
/** The session's own `CLAUDE_CONFIG_DIR` (Codeman env override), if it has one. */
|
||||
claudeConfigDir?: string;
|
||||
}
|
||||
|
||||
export interface EditHistorySyncDeps {
|
||||
/** The session's main transcript path, or null when it has none yet. */
|
||||
transcriptPath(session: EditHistorySession): Promise<string | null>;
|
||||
readSources(
|
||||
mainPath: string,
|
||||
requests: readonly EditSourceRequest[],
|
||||
budget: number,
|
||||
opts?: EditSourceReadOptions
|
||||
): Promise<EditSources | null>;
|
||||
probe(paths: readonly string[]): Promise<Array<PathProbe | null>>;
|
||||
loadStore(sessionId: string): Promise<EditHistoryStore>;
|
||||
/** Delete a session's stored history without loading it. */
|
||||
deleteStore(sessionId: string): Promise<void>;
|
||||
/** Delete the stored history of every session `keep` rejects; returns how many. */
|
||||
sweepStores(keep: (sessionId: string) => boolean): Promise<number>;
|
||||
budget?: number;
|
||||
}
|
||||
|
||||
export interface SyncOptions {
|
||||
older?: boolean;
|
||||
}
|
||||
|
||||
/** Why a session has no edit history, or undefined when it can have one. */
|
||||
export function unsupportedReason(session: EditHistorySession): string | undefined {
|
||||
if (session.remote) return 'remote';
|
||||
if (session.docker) return 'docker';
|
||||
if (getCli(session.mode)?.capabilities.transcript !== 'claude-jsonl') return 'mode';
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/** One turn of the event loop, between synchronous parses of up to one budget each. */
|
||||
function breathe(): Promise<void> {
|
||||
return new Promise((resolve) => setImmediate(resolve));
|
||||
}
|
||||
|
||||
/**
|
||||
* Bash candidates whose probed file is a regular file with an mtime inside the call's window
|
||||
* (files claude sent to the user skip the window: they exist and were shown on purpose).
|
||||
* A candidate whose file version is already recorded is skipped: `known(path)` is the newest
|
||||
* entry time for that path, and an entry at or after `mtime − slack` saw this version: the
|
||||
* `ls *.png` right after the render, or a Write followed by a listing. Candidates are taken
|
||||
* in call order so a batch dedupes itself the same way.
|
||||
*/
|
||||
export function confirmImages(
|
||||
candidates: readonly ImageCandidate[],
|
||||
probe: (path: string) => PathProbe | null | undefined,
|
||||
known: (path: string) => number | undefined = () => undefined
|
||||
): ConfirmedImage[] {
|
||||
const out: ConfirmedImage[] = [];
|
||||
const seen = new Map<string, number>();
|
||||
for (const c of [...candidates].sort((a, b) => a.end - b.end)) {
|
||||
for (const path of c.paths) {
|
||||
const p = probe(path);
|
||||
if (!p || p.kind !== 'file' || !p.mtimeMs) continue;
|
||||
const inWindow = p.mtimeMs >= c.start - IMAGE_MTIME_SLACK_MS && p.mtimeMs <= c.end + IMAGE_MTIME_SLACK_MS;
|
||||
if (!c.sent && !inWindow) continue;
|
||||
const entryPath = path.startsWith('~/') ? p.realPath : path;
|
||||
const last = Math.max(seen.get(entryPath) ?? -Infinity, known(entryPath) ?? -Infinity);
|
||||
if (last >= p.mtimeMs - IMAGE_MTIME_SLACK_MS) continue;
|
||||
seen.set(entryPath, c.end);
|
||||
const img: ConfirmedImage = {
|
||||
toolUseId: c.toolUseId,
|
||||
path: entryPath,
|
||||
ts: c.end,
|
||||
tool: c.sent ? 'sent' : 'bash',
|
||||
};
|
||||
if (c.agentId) img.agentId = c.agentId;
|
||||
out.push(img);
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
interface SyncWant {
|
||||
forward: boolean;
|
||||
older: boolean;
|
||||
}
|
||||
|
||||
interface SessionSlot {
|
||||
store?: Promise<EditHistoryStore>;
|
||||
status: EditHistoryStatus;
|
||||
running?: Promise<void>;
|
||||
again?: SyncWant;
|
||||
/** The history was deleted: nothing reads for this session any more. */
|
||||
gone?: boolean;
|
||||
/** The last source the previous "Load older" read; the next one starts after it. */
|
||||
olderAfter?: string;
|
||||
}
|
||||
|
||||
export class EditHistorySync {
|
||||
/** By session, least recently used first. */
|
||||
private readonly slots = new Map<string, SessionSlot>();
|
||||
/** Writes of dropped stores; loading that session again waits for its write. */
|
||||
private readonly unloading = new Map<string, Promise<void>>();
|
||||
private readonly budget: number;
|
||||
|
||||
constructor(private readonly deps: EditHistorySyncDeps) {
|
||||
this.budget = deps.budget ?? READ_BUDGET_BYTES;
|
||||
}
|
||||
|
||||
/** The session's store, loaded from disk on first use (one load for concurrent callers). */
|
||||
getStore(sessionId: string): Promise<EditHistoryStore> {
|
||||
return this.storeOf(sessionId, this.slot(sessionId));
|
||||
}
|
||||
|
||||
getStatus(sessionId: string): EditHistoryStatus {
|
||||
return { ...this.slot(sessionId).status };
|
||||
}
|
||||
|
||||
/**
|
||||
* Read what is new (or, with `older`, the next older block). Resolves when this
|
||||
* request's run is done; never rejects. A call during a run queues one more run.
|
||||
*/
|
||||
async sync(session: EditHistorySession, opts: SyncOptions = {}): Promise<EditHistoryStatus> {
|
||||
const why = unsupportedReason(session);
|
||||
const slot = this.slot(session.id);
|
||||
if (slot.gone) return { ...slot.status };
|
||||
if (why) {
|
||||
slot.status = { state: 'unsupported', reason: why };
|
||||
return { ...slot.status };
|
||||
}
|
||||
const want: SyncWant = { forward: !opts.older, older: !!opts.older };
|
||||
if (slot.running) {
|
||||
slot.again = {
|
||||
forward: (slot.again?.forward ?? false) || want.forward,
|
||||
older: (slot.again?.older ?? false) || want.older,
|
||||
};
|
||||
await slot.running;
|
||||
// The queued run starts when the current one ends; wait for it too.
|
||||
if (slot.running) await slot.running;
|
||||
return { ...slot.status };
|
||||
}
|
||||
slot.running = this.runLoop(session, slot, want);
|
||||
try {
|
||||
await slot.running;
|
||||
} finally {
|
||||
slot.running = undefined;
|
||||
this.unloadIdle(slot);
|
||||
}
|
||||
return { ...slot.status };
|
||||
}
|
||||
|
||||
/** Write every loaded store's pending changes (server shutdown). */
|
||||
async flushAll(): Promise<void> {
|
||||
await Promise.all([
|
||||
...[...this.slots.values()].map(async (slot) => (await slot.store?.catch(() => undefined))?.settle()),
|
||||
...this.unloading.values(),
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete a session's stored history (its record is gone). While it runs the session is
|
||||
* marked, so a run in flight or a request meanwhile cannot load and write a new store for
|
||||
* it; then its slot goes, and a session that comes back with this id starts afresh.
|
||||
*/
|
||||
async remove(sessionId: string): Promise<void> {
|
||||
const slot = this.slot(sessionId);
|
||||
slot.gone = true;
|
||||
slot.again = undefined;
|
||||
slot.status = { state: 'unavailable', reason: REMOVED };
|
||||
try {
|
||||
const store = await slot.store?.catch(() => undefined);
|
||||
if (store) {
|
||||
await store.remove();
|
||||
} else {
|
||||
await this.unloading.get(sessionId);
|
||||
await this.deps.deleteStore(sessionId);
|
||||
}
|
||||
await slot.running;
|
||||
} finally {
|
||||
if (this.slots.get(sessionId) === slot) this.slots.delete(sessionId);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete the stored history of sessions that no longer exist (startup: left behind by a
|
||||
* crash during deletion, or by an older build). A session in use here is kept.
|
||||
*/
|
||||
sweep(exists: (sessionId: string) => boolean): Promise<number> {
|
||||
return this.deps.sweepStores((id) => this.slots.has(id) || exists(id));
|
||||
}
|
||||
|
||||
/** The session's slot, now the most recently used. */
|
||||
private slot(sessionId: string): SessionSlot {
|
||||
const slot = this.slots.get(sessionId) ?? { status: { state: 'ok' } };
|
||||
this.slots.delete(sessionId);
|
||||
this.slots.set(sessionId, slot);
|
||||
return slot;
|
||||
}
|
||||
|
||||
private storeOf(sessionId: string, slot: SessionSlot): Promise<EditHistoryStore> {
|
||||
if (!slot.store) {
|
||||
const loading = (async () => {
|
||||
await this.unloading.get(sessionId);
|
||||
return this.deps.loadStore(sessionId);
|
||||
})();
|
||||
slot.store = loading;
|
||||
loading.catch(() => {
|
||||
if (slot.store === loading) slot.store = undefined;
|
||||
});
|
||||
this.unloadIdle(slot);
|
||||
}
|
||||
return slot.store;
|
||||
}
|
||||
|
||||
/**
|
||||
* Past {@link MAX_LOADED_STORES}, drop the least recently used stores no run uses: each is
|
||||
* written if it changed, and a later request loads it again (after that write).
|
||||
*/
|
||||
private unloadIdle(keep?: SessionSlot): void {
|
||||
let loaded = 0;
|
||||
for (const slot of this.slots.values()) if (slot.store) loaded++;
|
||||
for (const [sessionId, slot] of this.slots) {
|
||||
if (loaded <= MAX_LOADED_STORES) break;
|
||||
if (!slot.store || slot.running || slot.gone || slot === keep) continue;
|
||||
this.slots.delete(sessionId);
|
||||
loaded--;
|
||||
const writing: Promise<void> = slot.store
|
||||
.then((store) => store.settle())
|
||||
.catch((err) => {
|
||||
console.warn(`[EditHistory] Saving the history of ${sessionId} failed:`, err);
|
||||
})
|
||||
.finally(() => {
|
||||
if (this.unloading.get(sessionId) === writing) this.unloading.delete(sessionId);
|
||||
});
|
||||
this.unloading.set(sessionId, writing);
|
||||
}
|
||||
}
|
||||
|
||||
private async runLoop(session: EditHistorySession, slot: SessionSlot, want: SyncWant): Promise<void> {
|
||||
let next: SyncWant | undefined = want;
|
||||
while (next && !slot.gone) {
|
||||
if (next.forward) await this.runOnce(session, slot, false);
|
||||
if (next.older && !slot.gone) await this.runOnce(session, slot, true);
|
||||
next = slot.again;
|
||||
slot.again = undefined;
|
||||
}
|
||||
}
|
||||
|
||||
private async runOnce(session: EditHistorySession, slot: SessionSlot, older: boolean): Promise<void> {
|
||||
let status: EditHistoryStatus;
|
||||
try {
|
||||
// This slot's store: a run still in flight after `remove()` cannot reach a later one.
|
||||
const store = await this.storeOf(session.id, slot);
|
||||
const path = slot.gone ? null : await this.deps.transcriptPath(session);
|
||||
let found: boolean;
|
||||
if (!path) {
|
||||
found = false;
|
||||
} else if (older) {
|
||||
found = await this.readOlder(path, store, slot);
|
||||
} else {
|
||||
found = true;
|
||||
for (let pass = 0; pass < MAX_FORWARD_PASSES && found && !slot.gone; pass++) {
|
||||
const r = await this.readForward(path, store);
|
||||
found = r.found;
|
||||
if (!r.more) break;
|
||||
}
|
||||
}
|
||||
status = found ? { state: 'ok' } : { state: 'unavailable', reason: NO_TRANSCRIPT };
|
||||
} catch (err) {
|
||||
status = { state: 'unavailable', reason: err instanceof Error ? err.message : String(err) };
|
||||
}
|
||||
if (!slot.gone) slot.status = status;
|
||||
}
|
||||
|
||||
private forwardRequests(store: EditHistoryStore): EditSourceRequest[] {
|
||||
const out: EditSourceRequest[] = [];
|
||||
for (const relPath of store.knownSources()) {
|
||||
const c = store.getCursor(relPath);
|
||||
if (c) out.push({ relPath, offset: c.offset });
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* One forward read of all sources. `more` = some file has unread bytes after it, or was cut
|
||||
* short by the shared budget and left for the next pass. A transcript other than the stored
|
||||
* one (`/clear`, resume) is read like a first read, once the stored one is read to its end.
|
||||
*/
|
||||
private async readForward(path: string, store: EditHistoryStore): Promise<{ found: boolean; more: boolean }> {
|
||||
const own = this.storedTranscript(path, store);
|
||||
if (own !== path && store.knownSources().length > 0) {
|
||||
const previous = await this.readFrom(own, store, this.forwardRequests(store));
|
||||
if (previous.more) return { found: true, more: true };
|
||||
}
|
||||
return this.readFrom(path, store, own === path ? this.forwardRequests(store) : []);
|
||||
}
|
||||
|
||||
/**
|
||||
* The path of the conversation the store holds cursors for: `path` itself, or the previous
|
||||
* conversation's transcript next to it (`/clear` and resume keep the project directory).
|
||||
*/
|
||||
private storedTranscript(path: string, store: EditHistoryStore): string {
|
||||
const known = store.getTranscriptId();
|
||||
return known === null || basename(path) === `${known}.jsonl` ? path : join(dirname(path), `${known}.jsonl`);
|
||||
}
|
||||
|
||||
private async readFrom(
|
||||
path: string,
|
||||
store: EditHistoryStore,
|
||||
requests: EditSourceRequest[]
|
||||
): Promise<{ found: boolean; more: boolean }> {
|
||||
const res = await this.deps.readSources(path, requests, this.budget);
|
||||
if (!res) return { found: false, more: false };
|
||||
this.dropMissing(store, requests, res);
|
||||
for (const [agentId, text] of Object.entries(res.metas)) {
|
||||
if (!store.hasAgentMeta(agentId)) store.setAgentMeta(agentId, text);
|
||||
}
|
||||
const requested = new Set(requests.map((r) => r.relPath));
|
||||
let more = false;
|
||||
const candidates: ImageCandidate[] = [];
|
||||
let parsed = false;
|
||||
for (const f of res.files) {
|
||||
// Each file is up to one budget (8 MiB) of synchronous parsing: yield between them.
|
||||
if (parsed) await breathe();
|
||||
if (store.isRemoved()) break;
|
||||
// A requested file reads on from its cursor, a new one its tail.
|
||||
const available = requested.has(f.relPath) ? f.size - f.startOffset : f.size;
|
||||
if (f.chunk.length < Math.min(this.budget, available)) {
|
||||
more = true;
|
||||
continue;
|
||||
}
|
||||
parsed = true;
|
||||
const r = store.apply({
|
||||
transcriptId: res.transcriptId,
|
||||
source: f.relPath,
|
||||
startOffset: f.startOffset,
|
||||
chunk: f.chunk,
|
||||
size: f.size,
|
||||
});
|
||||
candidates.push(...r.candidates);
|
||||
if (f.startOffset + f.chunk.length < f.size) more = true;
|
||||
}
|
||||
await this.addImages(store, candidates);
|
||||
return { found: true, more };
|
||||
}
|
||||
|
||||
private async readOlder(path: string, store: EditHistoryStore, slot: SessionSlot): Promise<boolean> {
|
||||
if (!store.backfillTruncated) return true;
|
||||
// Older lines belong to the stored conversation, even before a forward read moved on.
|
||||
path = this.storedTranscript(path, store);
|
||||
const sources = store.sourcesWithOlder().sort();
|
||||
if (sources.length === 0) return true;
|
||||
// Take turns: start after the last source the previous click read.
|
||||
const after = slot.olderAfter;
|
||||
const first =
|
||||
after === undefined
|
||||
? 0
|
||||
: Math.max(
|
||||
0,
|
||||
sources.findIndex((s) => s > after)
|
||||
);
|
||||
const picked = [...sources.slice(first), ...sources.slice(0, first)].slice(0, MAX_OLDER_SOURCES);
|
||||
const requests: EditSourceRequest[] = [];
|
||||
for (const relPath of picked) {
|
||||
const range = store.olderRange(relPath, this.budget);
|
||||
if (range) requests.push({ relPath, offset: range.offset, len: range.len });
|
||||
}
|
||||
if (requests.length === 0) return true;
|
||||
slot.olderAfter = picked[picked.length - 1];
|
||||
const res = await this.deps.readSources(path, requests, this.budget * requests.length, { onlyRequested: true });
|
||||
if (!res) return false;
|
||||
if (res.transcriptId !== store.getTranscriptId()) return true;
|
||||
this.dropMissing(store, requests, res);
|
||||
const wanted = new Set(requests.map((r) => r.relPath));
|
||||
const candidates: ImageCandidate[] = [];
|
||||
let parsed = false;
|
||||
for (const f of res.files) {
|
||||
if (!wanted.has(f.relPath)) continue;
|
||||
// Each file is up to one budget (8 MiB) of synchronous parsing: yield between them.
|
||||
if (parsed) await breathe();
|
||||
parsed = true;
|
||||
if (slot.gone) return true;
|
||||
const r = store.applyOlder({
|
||||
transcriptId: res.transcriptId,
|
||||
source: f.relPath,
|
||||
startOffset: f.startOffset,
|
||||
chunk: f.chunk,
|
||||
size: f.size,
|
||||
});
|
||||
candidates.push(...r.candidates);
|
||||
}
|
||||
await this.addImages(store, candidates);
|
||||
return true;
|
||||
}
|
||||
|
||||
/** Requested files the reader no longer lists are gone: their cursors go too. */
|
||||
private dropMissing(store: EditHistoryStore, requests: readonly EditSourceRequest[], res: EditSources): void {
|
||||
const listed = new Set(res.files.map((f) => f.relPath));
|
||||
for (const r of requests) if (!listed.has(r.relPath)) store.dropSource(r.relPath);
|
||||
}
|
||||
|
||||
private async addImages(store: EditHistoryStore, candidates: readonly ImageCandidate[]): Promise<void> {
|
||||
const paths = [...new Set(candidates.flatMap((c) => c.paths))];
|
||||
// A removed store keeps nothing: do not probe agent-printed paths for it.
|
||||
if (paths.length === 0 || store.isRemoved()) return;
|
||||
let probes: Array<PathProbe | null>;
|
||||
try {
|
||||
probes = await this.deps.probe(paths);
|
||||
} catch {
|
||||
// The edits of this read are in; only its images are lost. The read itself succeeded.
|
||||
return;
|
||||
}
|
||||
const byPath = new Map(paths.map((p, i) => [p, probes[i]]));
|
||||
const newest = new Map<string, number>();
|
||||
for (const e of store.list()) newest.set(e.path, Math.max(newest.get(e.path) ?? -Infinity, e.ts));
|
||||
store.addImageEntries(
|
||||
confirmImages(
|
||||
candidates,
|
||||
(p) => byPath.get(p),
|
||||
(p) => newest.get(p)
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
+204
-50
@@ -9,7 +9,7 @@
|
||||
*/
|
||||
|
||||
import type { FastifyInstance, FastifyReply, FastifyRequest } from 'fastify';
|
||||
import { randomBytes, timingSafeEqual } from 'node:crypto';
|
||||
import { createHash, randomBytes, timingSafeEqual } from 'node:crypto';
|
||||
import { StaleExpirationMap } from '../../utils/index.js';
|
||||
import type { AuthSessionRecord } from '../ports/auth-port.js';
|
||||
import { isAllowedRequestHost, isAllowedRequestOrigin, type HostPolicy } from '../network-auth-policy.js';
|
||||
@@ -17,12 +17,14 @@ import {
|
||||
AUTH_SESSION_TTL_MS,
|
||||
MAX_AUTH_SESSIONS,
|
||||
AUTH_FAILURE_MAX,
|
||||
AUTH_IP_FAILURE_MAX,
|
||||
AUTH_FAILURE_WINDOW_MS,
|
||||
} from '../../config/auth-config.js';
|
||||
import { getHookSecret, HOOK_SECRET_HEADER } from '../../config/hook-secret.js';
|
||||
import { isMultiUserMode } from '../../config/multiuser.js';
|
||||
import { findUser, setPassword, touchLastLogin, verifyPassword } from '../../user-store.js';
|
||||
import { webviewCapabilities } from '../../webview-capabilities.js';
|
||||
import { capabilityFromHtmlViewPath, htmlViewCapabilities } from '../../html-view-capabilities.js';
|
||||
import {
|
||||
capabilityFromProxyPath,
|
||||
capabilityFromReferer,
|
||||
@@ -31,7 +33,7 @@ import {
|
||||
lostWebviewFramePage,
|
||||
LOST_FRAME_PAGE_CSP,
|
||||
} from '../webview-proxy.js';
|
||||
import { ApiErrorCode, createErrorResponse, type AuthUser } from '../../types.js';
|
||||
import { ApiErrorCode, createErrorResponse, type AuthUser, type UserRecord } from '../../types.js';
|
||||
|
||||
// Request-scoped identity (multi-user). Single-user leaves it undefined and the
|
||||
// ownership helpers default to a synthetic admin (see route-helpers).
|
||||
@@ -52,6 +54,8 @@ interface AuthState {
|
||||
hookSecretFailures: StaleExpirationMap<string, number> | null;
|
||||
/** Per-username Basic-auth failure bucket (multi-user only). */
|
||||
userFailures: StaleExpirationMap<string, number> | null;
|
||||
/** Per-(username, client IP) Basic-auth failure bucket (multi-user only). */
|
||||
userIpFailures: StaleExpirationMap<string, number> | null;
|
||||
}
|
||||
|
||||
/** Rate-limit response for a client that exceeded the failure cap. */
|
||||
@@ -75,6 +79,22 @@ function parseBasicAuth(header?: string): { username: string; password: string }
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Key of the multi-user in-flight verify map: a SHA-256 digest of (client IP,
|
||||
* normalized username, password), so the plaintext password is never held as a key.
|
||||
* The JSON array keeps the three parts unambiguous for any strings (a NUL-joined
|
||||
* string is not, since a username may carry a NUL). Never logged or persisted, and
|
||||
* gone from the map once its verify settles.
|
||||
*/
|
||||
function verifyFlightKey(clientIp: string, normUser: string, password: string): string {
|
||||
return createHash('sha256')
|
||||
.update(JSON.stringify([clientIp, normUser, password]))
|
||||
.digest('hex');
|
||||
}
|
||||
|
||||
/** What one Basic verify decided, shared by the request that ran it and every joiner. */
|
||||
type BasicVerifyOutcome = { user: UserRecord } | { user: null; limited: boolean };
|
||||
|
||||
/**
|
||||
* The `/api/hook-event` + `/api/status-telemetry` localhost bypass, shared by the
|
||||
* single-user and multi-user auth hooks so the security-critical logic has ONE
|
||||
@@ -183,6 +203,20 @@ function hasValidWebviewCapability(req: FastifyRequest, basePath = ''): boolean
|
||||
return !!fromReferer && webviewCapabilities.resolve(fromReferer) !== undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether a request is a GET for a rendered HTML file (`/html-view/<cap>/...`) with
|
||||
* a live capability. Same reasoning as the web-tab proxy: the page is sandboxed into
|
||||
* an opaque origin, so its asset requests carry no session cookie. Read-only by
|
||||
* construction (safe methods only), and the Host allowlist still applies. OPTIONS
|
||||
* is the page's CORS preflight, which registerSecurityHeaders answers itself and
|
||||
* never hands to a route.
|
||||
*/
|
||||
function hasValidHtmlViewCapability(req: FastifyRequest): boolean {
|
||||
if (req.method !== 'GET' && req.method !== 'HEAD' && req.method !== 'OPTIONS') return false;
|
||||
const cap = capabilityFromHtmlViewPath((req.url ?? '').split('?')[0]);
|
||||
return !!cap && htmlViewCapabilities.resolve(cap) !== undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* A web-tab frame that navigated itself off the proxy prefix (see
|
||||
* isLostWebviewFrameNavigation). It cannot authenticate: opaque origin, no cookie,
|
||||
@@ -288,6 +322,7 @@ export function registerAuthMiddleware(app: FastifyInstance, https: boolean, bas
|
||||
qrAuthFailures: null,
|
||||
hookSecretFailures: null,
|
||||
userFailures: null,
|
||||
userIpFailures: null,
|
||||
};
|
||||
|
||||
// Always declare req.authUser so downstream reads are safe (single-user leaves it
|
||||
@@ -335,13 +370,33 @@ export function registerAuthMiddleware(app: FastifyInstance, https: boolean, bas
|
||||
const hookSecretFailures = state.hookSecretFailures;
|
||||
|
||||
if (multiUser) {
|
||||
// Per-username failure bucket: a botnet can't brute-force one account across
|
||||
// many IPs, and one user behind a NAT can't lock out everyone else.
|
||||
// Per-username failure bucket: failed guesses against one account answer 429
|
||||
// from any IP once it fills, and one user behind a NAT can't lock out everyone
|
||||
// else. Checked after the verify, so it never denies a correct password.
|
||||
state.userFailures = new StaleExpirationMap<string, number>({
|
||||
ttlMs: AUTH_FAILURE_WINDOW_MS,
|
||||
refreshOnGet: false,
|
||||
});
|
||||
registerMultiUserAuthHook(app, https, authSessions, authFailures, hookSecretFailures, state.userFailures, basePath);
|
||||
// Per-(username, IP) failure bucket: checked BEFORE the verify, so it is what
|
||||
// caps verified guesses against one account from one source. Keyed on the pair,
|
||||
// so other IPs can't lock the account out and two users behind one shared IP
|
||||
// don't lock each other out. Same decay as the other buckets: it holds one key
|
||||
// per (IP, username) pair whose verify failed or is still in flight, swept by
|
||||
// the same TTL, so like them it grows only with scrypt verifies.
|
||||
state.userIpFailures = new StaleExpirationMap<string, number>({
|
||||
ttlMs: AUTH_FAILURE_WINDOW_MS,
|
||||
refreshOnGet: false,
|
||||
});
|
||||
registerMultiUserAuthHook(
|
||||
app,
|
||||
https,
|
||||
authSessions,
|
||||
authFailures,
|
||||
hookSecretFailures,
|
||||
state.userFailures,
|
||||
state.userIpFailures,
|
||||
basePath
|
||||
);
|
||||
return state;
|
||||
}
|
||||
|
||||
@@ -368,6 +423,11 @@ export function registerAuthMiddleware(app: FastifyInstance, https: boolean, bas
|
||||
done();
|
||||
return;
|
||||
}
|
||||
// Rendered HTML file, likewise authenticated by its capability.
|
||||
if (hasValidHtmlViewCapability(req)) {
|
||||
done();
|
||||
return;
|
||||
}
|
||||
// A web-tab frame that lost its prefix: hand it back to its tab, no credentials involved.
|
||||
if (serveLostWebviewFrame(req, reply)) return;
|
||||
|
||||
@@ -445,8 +505,9 @@ export function registerAuthMiddleware(app: FastifyInstance, https: boolean, bas
|
||||
/**
|
||||
* Multi-user auth hook (async, because password verification runs scrypt). Verifies
|
||||
* `username:password` against the user store, mints an identity-carrying cookie,
|
||||
* decorates `req.authUser`, enforces the per-IP + per-username rate limits, and the
|
||||
* `mustChangePassword` lockbox. The single-user hook above is left untouched.
|
||||
* decorates `req.authUser`, enforces the per-IP, per-(username, IP) and per-username
|
||||
* rate limits, and the `mustChangePassword` lockbox. Identical logins in flight at once
|
||||
* share one verify. The single-user hook above is left untouched.
|
||||
*/
|
||||
function registerMultiUserAuthHook(
|
||||
app: FastifyInstance,
|
||||
@@ -455,6 +516,7 @@ function registerMultiUserAuthHook(
|
||||
authFailures: StaleExpirationMap<string, number>,
|
||||
hookSecretFailures: StaleExpirationMap<string, number>,
|
||||
userFailures: StaleExpirationMap<string, number>,
|
||||
userIpFailures: StaleExpirationMap<string, number>,
|
||||
basePath = ''
|
||||
): void {
|
||||
const setSessionCookie = (reply: FastifyReply, token: string) =>
|
||||
@@ -495,6 +557,75 @@ function registerMultiUserAuthHook(
|
||||
return false;
|
||||
};
|
||||
|
||||
// Basic verifies in flight, by verifyFlightKey(). A request whose identical
|
||||
// credentials from the same IP are already being verified awaits that verify
|
||||
// instead of starting its own. Each entry is removed when its verify settles, so the
|
||||
// map never holds more than the distinct verifies in flight.
|
||||
const verifiesInFlight = new Map<string, Promise<BasicVerifyOutcome>>();
|
||||
|
||||
// The verify plus its once-only side effects, run by the first request for a key.
|
||||
// A success clears the three buckets and records the login; a failure is counted
|
||||
// here once (the per-IP and pair buckets already hold it: the caller reserved them).
|
||||
const verifyOnce = async (
|
||||
username: string,
|
||||
password: string,
|
||||
normUser: string,
|
||||
clientIp: string,
|
||||
pairKey: string
|
||||
): Promise<BasicVerifyOutcome> => {
|
||||
const result = await verifyPassword(username, password);
|
||||
if (result) {
|
||||
const { user, needsRehash: rehash } = result;
|
||||
if (rehash) void setPassword(user.username, password).catch(() => {});
|
||||
void touchLastLogin(user.username).catch(() => {});
|
||||
authFailures.delete(clientIp);
|
||||
userFailures.delete(normUser);
|
||||
userIpFailures.delete(pairKey);
|
||||
return { user };
|
||||
}
|
||||
// Failed guess: only the per-username bucket counts it here. Once that bucket
|
||||
// reaches the cap, further FAILED attempts get 429 from any IP, but this path is
|
||||
// only reached on a wrong password, so it can never deny a correct one. Its count
|
||||
// is re-read after the await, so concurrent failures against one account each add one.
|
||||
const uFail = (userFailures.get(normUser) ?? 0) + 1;
|
||||
userFailures.set(normUser, uFail);
|
||||
return { user: null, limited: uFail >= AUTH_FAILURE_MAX };
|
||||
};
|
||||
|
||||
// Answer one request from a shared outcome. Every successful request, joiners
|
||||
// included, gets its own cookie session under the usual capacity rules.
|
||||
const sendBasicOutcome = (
|
||||
req: FastifyRequest,
|
||||
reply: FastifyReply,
|
||||
outcome: BasicVerifyOutcome,
|
||||
normUser: string
|
||||
): void => {
|
||||
if (outcome.user) {
|
||||
const { user } = outcome;
|
||||
const token = randomBytes(32).toString('hex');
|
||||
if (authSessions.size >= MAX_AUTH_SESSIONS) evictForCapacity(user.username);
|
||||
authSessions.set(token, {
|
||||
ip: req.ip,
|
||||
ua: req.headers['user-agent'] ?? '',
|
||||
createdAt: Date.now(),
|
||||
method: 'basic',
|
||||
username: user.username,
|
||||
role: user.role,
|
||||
mustChangePassword: !!user.mustChangePassword,
|
||||
});
|
||||
req.authUser = { username: user.username, role: user.role };
|
||||
setSessionCookie(reply, token);
|
||||
enforcePasswordChange(req, reply, !!user.mustChangePassword);
|
||||
return;
|
||||
}
|
||||
if (outcome.limited) {
|
||||
sendAuthRateLimit(reply, userFailures, normUser);
|
||||
return;
|
||||
}
|
||||
reply.header('WWW-Authenticate', 'Basic realm="Codeman"');
|
||||
reply.code(401).send('Unauthorized');
|
||||
};
|
||||
|
||||
app.addHook('onRequest', async (req, reply) => {
|
||||
const bypass = checkHookSecretBypass(req, reply, hookSecretFailures);
|
||||
if (bypass === 'bypass' || bypass === 'rejected') return;
|
||||
@@ -507,6 +638,8 @@ function registerMultiUserAuthHook(
|
||||
// ownership against the identity BOUND TO THE CAPABILITY, which is stricter
|
||||
// than re-deriving it from a request that carries no credentials.
|
||||
if (hasValidWebviewCapability(req, basePath)) return;
|
||||
// Rendered HTML file, likewise authenticated by its capability.
|
||||
if (hasValidHtmlViewCapability(req)) return;
|
||||
// A web-tab frame that lost its prefix: hand it back to its tab, no credentials involved.
|
||||
if (serveLostWebviewFrame(req, reply)) return;
|
||||
|
||||
@@ -547,58 +680,69 @@ function registerMultiUserAuthHook(
|
||||
return;
|
||||
}
|
||||
|
||||
// 2. Basic Auth against the user store (scrypt verify).
|
||||
// Per-IP pre-gate bounds scrypt CPU cost from one source (does NOT gate on the
|
||||
// per-username bucket here; see below).
|
||||
// 2. Basic Auth against the user store (scrypt verify). Three failure buckets:
|
||||
// - per IP (AUTH_IP_FAILURE_MAX), checked first: bounds scrypt CPU cost from one
|
||||
// source. It refuses even a correct password, so it is wider than
|
||||
// AUTH_FAILURE_MAX: behind a shared egress IP (NAT, a tunnel) a narrow cap
|
||||
// would let a few wrong guesses lock everyone out.
|
||||
// - per (username, IP) (AUTH_FAILURE_MAX), checked BEFORE the verify: caps
|
||||
// verified guesses against one account from one source. Only that IP is
|
||||
// refused for that account; the same account from another IP still logs in.
|
||||
// - per username (AUTH_FAILURE_MAX), checked AFTER the verify; see below.
|
||||
// Neither pre-verify refusal is counted, since nothing was verified (and counting
|
||||
// a locked pair's retries would fill the shared per-IP bucket for everyone).
|
||||
// A request that passes both gates RESERVES its slot in both buckets before the
|
||||
// scrypt await, as if it had already failed. Counting only after the verify let a
|
||||
// concurrent burst pass the gates on the same stale read: 29 wrong guesses plus the
|
||||
// right password, all in flight at once from one IP, logged in. A success deletes
|
||||
// the reservation with the rest of the buckets; a failure is not counted again.
|
||||
// A request whose IDENTICAL credentials from the same IP are already being verified
|
||||
// joins that verify instead: it skips both gates, takes no slot, runs no scrypt and
|
||||
// gets the same outcome, since it adds no guess and no CPU. Reserving for each of
|
||||
// them refused a correct password with the slots that password itself had filled,
|
||||
// which is every reload after a restart: cookie sessions live in memory, so the
|
||||
// browser replays its cached Basic header on many parallel requests at once. A
|
||||
// request with no verify in flight for its credentials is gated as normal.
|
||||
const creds = parseBasicAuth(req.headers.authorization);
|
||||
const normUser = creds ? creds.username.trim().toLowerCase() : '';
|
||||
const flightKey = creds ? verifyFlightKey(clientIp, normUser, creds.password) : '';
|
||||
const shared = creds ? verifiesInFlight.get(flightKey) : undefined;
|
||||
if (shared) {
|
||||
sendBasicOutcome(req, reply, await shared, normUser);
|
||||
return;
|
||||
}
|
||||
const ipFail = authFailures.get(clientIp) ?? 0;
|
||||
if (ipFail >= AUTH_FAILURE_MAX) {
|
||||
if (ipFail >= AUTH_IP_FAILURE_MAX) {
|
||||
sendAuthRateLimit(reply, authFailures, clientIp);
|
||||
return;
|
||||
}
|
||||
const creds = parseBasicAuth(req.headers.authorization);
|
||||
if (creds) {
|
||||
const normUser = creds.username.trim().toLowerCase();
|
||||
// Security: VERIFY FIRST, then throttle only FAILED attempts. Consulting the
|
||||
// per-username bucket before verifying let throwaway IPs lock out a known account
|
||||
// (incl. admin) even with the correct password. A correct password must always
|
||||
// win and self-heal both buckets, regardless of the username-failure count.
|
||||
const result = await verifyPassword(creds.username, creds.password);
|
||||
if (result) {
|
||||
const { user, needsRehash: rehash } = result;
|
||||
if (rehash) void setPassword(user.username, creds.password).catch(() => {});
|
||||
void touchLastLogin(user.username).catch(() => {});
|
||||
authFailures.delete(clientIp);
|
||||
userFailures.delete(normUser);
|
||||
|
||||
const token = randomBytes(32).toString('hex');
|
||||
if (authSessions.size >= MAX_AUTH_SESSIONS) evictForCapacity(user.username);
|
||||
authSessions.set(token, {
|
||||
ip: clientIp,
|
||||
ua: req.headers['user-agent'] ?? '',
|
||||
createdAt: Date.now(),
|
||||
method: 'basic',
|
||||
username: user.username,
|
||||
role: user.role,
|
||||
mustChangePassword: !!user.mustChangePassword,
|
||||
});
|
||||
req.authUser = { username: user.username, role: user.role };
|
||||
setSessionCookie(reply, token);
|
||||
enforcePasswordChange(req, reply, !!user.mustChangePassword);
|
||||
// IPs never contain a newline, so this key is unambiguous for any username.
|
||||
const pairKey = `${clientIp}\n${normUser}`;
|
||||
const pairFail = userIpFailures.get(pairKey) ?? 0;
|
||||
if (pairFail >= AUTH_FAILURE_MAX) {
|
||||
sendAuthRateLimit(reply, userIpFailures, pairKey);
|
||||
return;
|
||||
}
|
||||
// Failed guess: count it against BOTH buckets. Once the per-username bucket
|
||||
// reaches the cap, further FAILED attempts get 429 (throttles distributed
|
||||
// brute-force), but this path is only reached on a wrong password, so it can
|
||||
// never deny a correct one.
|
||||
const uFail = (userFailures.get(normUser) ?? 0) + 1;
|
||||
userFailures.set(normUser, uFail);
|
||||
// Reserve both slots and publish this verify for joiners, from the values the
|
||||
// gates just read. Correct only while nothing between those reads and these
|
||||
// writes awaits: keep it that way.
|
||||
authFailures.set(clientIp, ipFail + 1);
|
||||
if (uFail >= AUTH_FAILURE_MAX) {
|
||||
sendAuthRateLimit(reply, userFailures, normUser);
|
||||
return;
|
||||
userIpFailures.set(pairKey, pairFail + 1);
|
||||
// Security: the per-username bucket is consulted only AFTER the verify, and only
|
||||
// FAILED attempts are throttled by it. Consulting it before verifying let
|
||||
// throwaway IPs lock out a known account (incl. admin) even with the correct
|
||||
// password. A correct password that passes the two gates above always wins and
|
||||
// self-heals the per-IP, per-username and this pair's buckets.
|
||||
const verify = verifyOnce(creds.username, creds.password, normUser, clientIp, pairKey);
|
||||
verifiesInFlight.set(flightKey, verify);
|
||||
let outcome: BasicVerifyOutcome;
|
||||
try {
|
||||
outcome = await verify;
|
||||
} finally {
|
||||
verifiesInFlight.delete(flightKey);
|
||||
}
|
||||
reply.header('WWW-Authenticate', 'Basic realm="Codeman"');
|
||||
reply.code(401).send('Unauthorized');
|
||||
sendBasicOutcome(req, reply, outcome, normUser);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -711,6 +855,16 @@ export function registerSecurityHeaders(app: FastifyInstance, https: boolean, ba
|
||||
// are not CORS-checked). Falling through lets the proxy route reply with the
|
||||
// right headers.
|
||||
if (req.method === 'OPTIONS' && !hasValidWebviewCapability(req, basePath)) {
|
||||
// A rendered HTML page (`/html-view/<cap>/...`) is opaque-origin for the same
|
||||
// reason, so a fetch() of its own `data.json` with a non-safelisted header
|
||||
// preflights with `Origin: null`. Answer it here, read-only: the route serves
|
||||
// GET and HEAD only, and no credentials are involved (`*` is valid for both).
|
||||
if (hasValidHtmlViewCapability(req)) {
|
||||
reply.header('Access-Control-Allow-Origin', '*');
|
||||
reply.header('Access-Control-Allow-Methods', 'GET, HEAD');
|
||||
reply.header('Access-Control-Allow-Headers', '*');
|
||||
reply.header('Access-Control-Max-Age', '600');
|
||||
}
|
||||
reply.code(204).send();
|
||||
done();
|
||||
return;
|
||||
|
||||
@@ -7,15 +7,28 @@ export function isExplicitlyEnabled(value: string | undefined): boolean {
|
||||
}
|
||||
|
||||
/**
|
||||
* True when unauthenticated network exposure is acceptable: either a password is
|
||||
* set (auth active) or the operator explicitly acknowledged it. Used by the
|
||||
* tunnel-enable guard (COD-55) to refuse publishing an unauthenticated public URL.
|
||||
* True when an unauthenticated non-loopback BIND is acceptable: either a password is
|
||||
* set (auth active) or the operator explicitly acknowledged it. Quiets the startup
|
||||
* warning only; it does not cover the Cloudflare tunnel (#706), which has its own
|
||||
* acknowledgement below.
|
||||
*/
|
||||
export function isUnauthenticatedNetworkAcknowledged(allowFlag = false): boolean {
|
||||
if (process.env.CODEMAN_PASSWORD) return true;
|
||||
return allowFlag || isExplicitlyEnabled(process.env.CODEMAN_ALLOW_UNAUTHENTICATED_NETWORK);
|
||||
}
|
||||
|
||||
/**
|
||||
* True when the operator opted in to an unauthenticated public Cloudflare tunnel:
|
||||
* `--allow-unauthenticated-tunnel` (passed in as `allowFlag`) or
|
||||
* CODEMAN_ALLOW_UNAUTHENTICATED_TUNNEL. Used by the tunnel-enable guard (COD-55) in
|
||||
* place of a login. Kept apart from the network acknowledgement on purpose (#706):
|
||||
* install.sh writes that one for every passwordless LAN install, and it must never
|
||||
* also publish the machine to the internet. install.sh never writes this one.
|
||||
*/
|
||||
export function isUnauthenticatedTunnelAcknowledged(allowFlag = false): boolean {
|
||||
return allowFlag || isExplicitlyEnabled(process.env.CODEMAN_ALLOW_UNAUTHENTICATED_TUNNEL);
|
||||
}
|
||||
|
||||
export function isLoopbackBindHost(host: string): boolean {
|
||||
const normalized = host
|
||||
.trim()
|
||||
|
||||
@@ -38,6 +38,12 @@ export const UPLOADS_DIR = '.codeman-uploads';
|
||||
export const LEGACY_UPLOADS_DIR = '.claude-images';
|
||||
/** Every upload dir name, current first. Retiring the legacy one here retires it for every reader. */
|
||||
export const UPLOAD_DIR_NAMES = [UPLOADS_DIR, LEGACY_UPLOADS_DIR];
|
||||
/**
|
||||
* Where files a person sent from Discord land (discord-bridge.ts). Named here with the others so the
|
||||
* image watcher can skip it without importing the bridge. Not an upload dir: neither the sweep nor a
|
||||
* session delete touches it.
|
||||
*/
|
||||
export const DISCORD_RECEIVED_DIR = '.codeman-discord';
|
||||
|
||||
/**
|
||||
* Every directory a session's uploads sit in, current first. Both consumers
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
/**
|
||||
* @fileoverview Process-wide last-known plan-usage telemetry (account-global).
|
||||
*
|
||||
* The Claude status-telemetry route and host Codex poll merge their latest
|
||||
* The Claude status-telemetry route and the host Codex and Copilot polls merge their latest
|
||||
* values here. The SSE init snapshot (`getLightState`) replays the combined
|
||||
* value so the header "Plan Usage Limits" chip shows immediately on a fresh
|
||||
* page load / SSE reconnect — before either source emits another sample, and
|
||||
@@ -15,8 +15,14 @@
|
||||
let latest: Record<string, unknown> | null = null;
|
||||
|
||||
export function setLatestPlanUsage(value: Record<string, unknown>): Record<string, unknown> {
|
||||
// The Claude status route replaces the Claude part; the host-polled providers ride along.
|
||||
const codex = latest?.codex;
|
||||
latest = { ...value, ...(codex !== undefined ? { codex } : {}) };
|
||||
const copilot = latest?.copilot;
|
||||
latest = {
|
||||
...value,
|
||||
...(codex !== undefined ? { codex } : {}),
|
||||
...(copilot !== undefined ? { copilot } : {}),
|
||||
};
|
||||
return latest;
|
||||
}
|
||||
|
||||
@@ -25,6 +31,11 @@ export function setLatestCodexPlanUsage(value: object | null): Record<string, un
|
||||
return latest;
|
||||
}
|
||||
|
||||
export function setLatestCopilotPlanUsage(value: object | null): Record<string, unknown> {
|
||||
latest = { ...(latest ?? {}), copilot: value };
|
||||
return latest;
|
||||
}
|
||||
|
||||
export function getLatestPlanUsage(): Record<string, unknown> | null {
|
||||
return latest;
|
||||
}
|
||||
@@ -15,3 +15,4 @@ export type { AuthPort } from './auth-port.js';
|
||||
export type { OrchestratorPort } from './orchestrator-port.js';
|
||||
export type { CronPort } from './cron-port.js';
|
||||
export type { TabLayoutPort } from './tab-layout-port.js';
|
||||
export type { KeepWarmPort } from './keep-warm-port.js';
|
||||
@@ -35,6 +35,8 @@ export interface InfraPort {
|
||||
readonly scheduledRuns: Map<string, ScheduledRun>;
|
||||
readonly teamWatcher: TeamWatcher;
|
||||
readonly tunnelManager: TunnelManager;
|
||||
/** `--allow-unauthenticated-tunnel` was passed to `codeman web` (the env var is read per request, #706). */
|
||||
readonly allowUnauthenticatedTunnel: boolean;
|
||||
readonly pushStore: PushSubscriptionStore;
|
||||
startScheduledRun(prompt: string, workingDir: string, durationMinutes: number, owner?: string): Promise<ScheduledRun>;
|
||||
stopScheduledRun(id: string): Promise<void>;
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
/**
|
||||
* @fileoverview Port for the prompt-cache keep-warm scheduler (`prompt-cache-keepwarm.ts`).
|
||||
* Routes only ever trigger a re-evaluation; the rulebook and the timers stay in the service.
|
||||
*/
|
||||
|
||||
import type { KeepWarmScheduler } from '../../prompt-cache-keepwarm.js';
|
||||
|
||||
export interface KeepWarmPort {
|
||||
readonly keepWarm: KeepWarmScheduler;
|
||||
}
|
||||
@@ -5,11 +5,30 @@
|
||||
|
||||
import type { Session } from '../../session.js';
|
||||
import type { SessionState } from '../../types.js';
|
||||
import type { TabLayout } from '../../tab-layout.js';
|
||||
import type { SessionPlacementHint } from '../../tab-layout-service.js';
|
||||
|
||||
export interface SessionPort {
|
||||
readonly sessions: ReadonlyMap<string, Session>;
|
||||
addSession(session: Session): Promise<void>;
|
||||
/**
|
||||
* The last-response reader (the body of `GET /api/sessions/:id/last-response`), filled in when the
|
||||
* session routes register, so other server code reads the same per-CLI transcript the viewer does.
|
||||
*/
|
||||
lastResponseFor?: (session: Session, context?: string) => Promise<{ text: string; timestamp?: string }>;
|
||||
/**
|
||||
* Register a created session with the owner's tab layout. `placement.tabGroupId`
|
||||
* puts it at the end of that group in the same layout write (ignored when the
|
||||
* group is not in the owner's layout); without one (and without a parent that
|
||||
* places it), `placement.caseName`'s default session group does the same.
|
||||
* Resolves to the committed layout.
|
||||
*/
|
||||
addSession(session: Session, placement?: SessionPlacementHint): Promise<TabLayout>;
|
||||
cleanupSession(sessionId: string, killMux?: boolean, reason?: string): Promise<void>;
|
||||
/**
|
||||
* Delete a session's stored edit history once its record is gone. `cleanupSession` does it
|
||||
* itself; this is for removing a record without a live session (DELETE route).
|
||||
*/
|
||||
forgetEditHistory(sessionId: string): void;
|
||||
setupSessionListeners(session: Session): Promise<void>;
|
||||
persistSessionState(session: Session): void;
|
||||
persistSessionStateNow(session: Session): void;
|
||||
|
||||
+1225
-182
File diff suppressed because it is too large.
Load diff
@@ -261,6 +261,10 @@ Object.assign(CodemanApp.prototype, {
|
||||
`onkeydown="if(event.key==='Enter')app.answerApprovalIdleText('${id}')">` +
|
||||
`<button class="approval-btn approval-btn-primary" onclick="app.answerApprovalIdleText('${id}')">Send</button>` +
|
||||
`</div>`;
|
||||
} else if (item.answerInTerminal) {
|
||||
// Supervised-hook CLIs (DeepSeek Harness): keystrokes are unverifiable, so
|
||||
// Approve/Deny would hit the answer route and fail. Offer only Open session.
|
||||
actions = `<span class="approval-terminal-hint" data-i18n-skip>Answer in the terminal</span>`;
|
||||
} else if (item.options && item.options.length) {
|
||||
actions = item.options
|
||||
.map(
|
||||
|
||||
+1298
-9
File diff suppressed because it is too large.
Load diff
Loaded 100 of 635 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user