Merge remote-tracking branch 'origin/master' into relprep/pr-677

# Conflicts:
#	src/tmux-manager.ts
This commit is contained in:
Codeman maintainer committed 2026-10-24 13:54:44 +02:00
commit effb1ef7a5
185 files changed
+13452 -644

No files matched your search

+8 -6
View File
@@ -129,16 +129,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
+948
View File
@@ -0,0 +1,948 @@
/**
* @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',
'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',
'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 };
}
+8 -3
View File
@@ -329,7 +329,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',
@@ -768,8 +773,8 @@ 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
// ...but it DOES page its transcript on PageUp/PageDown. Measured on 0.157.1 (local,
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.
+2 -2
View File
@@ -557,8 +557,8 @@ export interface CliCapabilities {
*
* ⚠ 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 ignores SGR wheel reports (so its
* `wheelForward` stays 'never') yet pages on PageUp/PageDown.
* 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.
*/
+30 -1
View File
@@ -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
+6 -6
View File
@@ -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.
*/
+16
View File
@@ -61,6 +61,22 @@ export interface CustomModelHost {
* has no entry for 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`
* and populated on the same loaded-models-only basis. 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
+36
View File
@@ -41,6 +41,7 @@ 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';
/**
* Existence check for a WRITER. Unlike the bounded read-side probe (`probePath`),
@@ -1144,6 +1145,41 @@ export async function readPinCliSessionNameEnabled(): Promise<boolean> {
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 rewritten in place, so a read can land mid-write,
* and a fallback kept from it would hold keep-warm off until the next keep-warm settings change.
*/
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(JSON.parse(text) as Record<string, unknown>);
}
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
+9
View File
@@ -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`).
+19 -4
View File
@@ -61,9 +61,15 @@ export interface MuxSession {
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
@@ -123,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 */
@@ -176,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 */
@@ -415,11 +429,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;
}
+438
View File
@@ -0,0 +1,438 @@
/**
* @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 read that lands mid-write is retried. A settings.json that stays broken (a hand edit
// with a trailing comma) fails every read until it is fixed, so it is logged once rather
// than once a minute per session, and only a session that could nudge retries.
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);
}
}
}
+12 -5
View File
@@ -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)}...`);
}
+6 -1
View File
@@ -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,
+29 -3
View File
@@ -68,6 +68,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;
}
/**
@@ -168,6 +174,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
@@ -208,10 +228,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,
@@ -219,6 +244,7 @@ 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);
}
+79
View File
@@ -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;
+126 -6
View File
@@ -65,6 +65,8 @@ import {
type SessionWriteOptions,
type PaneExit,
type PromptCacheStatus,
type StatusLineExporter,
type KeepWarmStatus,
} from './types.js';
import { resolveAndClaimOmpSessionId } from './utils/omp-session-resolver.js';
import { claudeTranscriptExists } from './utils/claude-transcript.js';
@@ -127,6 +129,7 @@ import { DEFAULT_TMUX_HISTORY_LIMIT } from './config/terminal-history.js';
import { EXEC_TIMEOUT_MS } from './config/exec-timeout.js';
import { getCli } from './config/cli-registry/registry.js';
import { SubmitVerifier } from './session-submit-verifier.js';
import { KEEP_WARM_NUDGE } from './prompt-cache-keepwarm.js';
import { compileVersionRegex } from './config/cli-registry/patterns.js';
import { resolveSessionCliVersion } from './utils/cli-resolver.js';
import {
@@ -453,6 +456,15 @@ export interface ClaudeMessage {
duration_ms?: number;
}
/**
* Whether a programmatic write leaves the keep-alive turn running: the nudge itself, or a bare
* Enter (which submits nothing new). Anything else carrying an Enter starts a turn of its own.
*/
function isKeepAliveWrite(data: string): boolean {
const text = data.replace(/[\r\n]/g, '').trim();
return text === '' || text === KEEP_WARM_NUDGE;
}
/**
* Event signatures emitted by the Session class.
* Subscribe using `session.on('eventName', handler)`.
@@ -597,6 +609,16 @@ export class Session extends EventEmitter {
private _watching: string | null = null;
/** Last `prompt_cache` report from the statusline exporter; null until Claude sends one. */
private _promptCache: PromptCacheStatus | null = null;
/** Prompt-cache keep-warm opt-in; undefined = follow the app-wide default. Persisted. */
private _keepWarmEnabled: boolean | undefined;
/** Last status the keep-warm service published for this session; ephemeral. */
private _keepWarm: KeepWarmStatus | null = null;
/** Until when the current turn counts as Codeman's own keep-alive (hooks open pre-acknowledged). */
private _keepAliveTurnUntil = 0;
/** Last keystroke a person sent through a `fromUser` write. */
private _lastHumanInputAt = 0;
/** Last submit that was not the keep-alive's own; the keep-warm cap's clock. Persisted. */
private _lastRealPromptAt = 0;
/** Lazily compiled `capabilities.workDetect.workingLine`. See _workingLinePattern(). */
private _workingLineRe: RegExp | undefined = undefined;
/** Lazily compiled `capabilities.workDetect.watchingLine`. See _watchingLinePattern(). */
@@ -804,7 +826,7 @@ export class Session extends EventEmitter {
// The session that spawned this one (tab lineage lines). Resolved by the create
// route before it reaches here, so this is always either an id that existed at
// create time or undefined. Decoration only — see SessionState.parentSessionId.
// create time or undefined. Decoration only (plus the keep-warm default); see SessionState.parentSessionId.
private readonly _parentSessionId?: string;
// Session color for visual differentiation
@@ -899,6 +921,10 @@ export class Session extends EventEmitter {
attachmentHistory?: SessionAttachmentHistoryItem[];
/** Restored wall-clock ms of the pane's last Enter (see `lastSubmitAt`). */
lastSubmitAt?: number;
/** Restored last non-keep-alive submit (see `lastRealPromptAt`). */
lastRealPromptAt?: number;
/** Restored prompt-cache keep-warm choice (see `keepWarmEnabled`). */
keepWarmEnabled?: boolean;
/** Restored conversation chain, oldest first (see `claudeSessionChain`). */
claudeSessionChain?: string[];
/** Restored agent-exit observation for this session's pane (see `paneExit`). */
@@ -975,6 +1001,10 @@ export class Session extends EventEmitter {
// response viewer re-derive the live conversation without waiting for the
// user to type again.
this._lastSubmitAt = config.lastSubmitAt ?? 0;
// Restored so a restart neither reopens the keep-warm window nor drops the choice. A state.json
// from before the field has only `lastSubmitAt`, and every submit then was a real one.
this._lastRealPromptAt = config.lastRealPromptAt ?? config.lastSubmitAt ?? 0;
this._keepWarmEnabled = config.keepWarmEnabled;
// Restored chain: its tail is the conversation the CLI was actually on when
// the server stopped, which outranks the launch id seeded just above. The
// FIRST-HAND flag is deliberately NOT restored — a persisted claim is not a
@@ -1479,12 +1509,17 @@ export class Session extends EventEmitter {
* report. It rides `toState()` for the Session Options readout and is restored on a
* Codeman restart (constructor `promptCache`) so the row is not blank until the pane's
* next render; the readout is time-aware, so a value whose `expiresAt` has passed renders
* as cold rather than as a stale warm.
* as cold rather than as a stale warm, and a future one lets the keep-warm re-arm without a turn.
*/
get promptCache(): PromptCacheStatus | null {
return this._promptCache;
}
/** Whether the current launch carries the statusLine exporter (`MuxSession.statusLine`); undefined when unknown. */
get statusLineExporter(): StatusLineExporter | undefined {
return this._muxSession?.statusLine;
}
/**
* Replace the prompt-cache report; true when it differs from the previous one. Stamps
* `compactedAt` when this report ENTERS the window in which Claude Code reads
@@ -1513,6 +1548,66 @@ export class Session extends EventEmitter {
return changed;
}
/** Prompt-cache keep-warm opt-in; undefined means the app-wide default applies. */
get keepWarmEnabled(): boolean | undefined {
return this._keepWarmEnabled;
}
setKeepWarmEnabled(enabled: boolean | undefined): void {
this._keepWarmEnabled = enabled;
}
/** What the keep-warm service last published for this session, or null before its first evaluation. */
get keepWarm(): KeepWarmStatus | null {
return this._keepWarm;
}
/** Replace the keep-warm status; true when it differs from the previous one. */
setKeepWarmStatus(status: KeepWarmStatus): boolean {
const changed = JSON.stringify(status) !== JSON.stringify(this._keepWarm);
this._keepWarm = status;
return changed;
}
/**
* True while the running turn is Codeman's own keep-alive nudge: its stop hook and idle
* prompt open pre-acknowledged, and its submit moves `lastSubmitAt` (it IS a submit, and the
* response viewer's conversation correlation needs it) but not `lastRealPromptAt`. The turn's
* own idle prompt ends it (`endKeepAliveTurn()`), and so does any other submit: a `fromUser`
* write, a programmatic write of anything but the nudge, or a `prompt_submitted` hook for
* another prompt, so a prompt right after a nudge counts and alerts normally.
*/
get keepAliveTurnActive(): boolean {
return Date.now() < this._keepAliveTurnUntil;
}
beginKeepAliveTurn(windowMs: number): void {
this._keepAliveTurnUntil = Date.now() + windowMs;
}
endKeepAliveTurn(): void {
this._keepAliveTurnUntil = 0;
}
/** Last keystroke a person sent (`fromUser` write), epoch ms; 0 when none this process. */
get lastHumanInputAt(): number {
return this._lastHumanInputAt;
}
/**
* Last submit that was not the keep-alive's own, from any writer or the UserPromptSubmit hook
* (a person typing in an attached tmux client reaches Codeman only through the hook); epoch
* ms, 0 if none. The keep-warm cap counts from it; persisted so a restart keeps it.
*/
get lastRealPromptAt(): number {
return this._lastRealPromptAt;
}
/** Whether this launch carries `idleCompaction: false` (`MuxSession.idleCompactionOff`). */
get idleCompactionOptOut(): boolean {
return this._muxSession?.idleCompactionOff === true;
}
/**
* Check if the session's process tree has active child processes beyond Claude itself.
* Detects running bash tools, test suites, builds, servers, etc. that Claude spawned.
@@ -2008,7 +2103,9 @@ export class Session extends EventEmitter {
autoResumeEnabled: this._autoOps.autoResumeEnabled,
autoResumeAt: this._autoOps.autoResumeAt ?? undefined,
promptCache: this._promptCache ?? undefined,
statusLineExporter: this._muxSession?.statusLine,
statusLineExporter: this.statusLineExporter,
keepWarmEnabled: this._keepWarmEnabled,
keepWarm: this._keepWarm ?? undefined,
imageWatcherEnabled: this._imageWatcherEnabled,
pinned: this._pinned || undefined,
pinnedAt: this._pinned ? (this._pinnedAt ?? undefined) : undefined,
@@ -2057,6 +2154,7 @@ export class Session extends EventEmitter {
paneExit: this._paneExit ?? undefined,
attachmentHistory: this.attachmentHistory.length > 0 ? this.attachmentHistory : undefined,
lastSubmitAt: this._lastSubmitAt || undefined,
lastRealPromptAt: this._lastRealPromptAt || undefined,
// Only a chain the CLI's own hooks vouched for is persisted, and only when
// the pane actually moved conversation. Its LAST entry is the live one, so
// it is also what restores `claudeSessionId` across a restart — `start()`
@@ -2425,6 +2523,8 @@ export class Session extends EventEmitter {
unsetEnvKeys: this._pendingEnvUnsets.size > 0 ? [...this._pendingEnvUnsets] : undefined,
effort: this._effort,
advisorModel: this._advisorModel,
keepWarm: this._keepWarmEnabled,
spawnedByAgent: !!this._parentSessionId,
historyLimit: this._tmuxHistoryLimit,
remote: this._remote,
docker: this._docker,
@@ -2904,6 +3004,8 @@ export class Session extends EventEmitter {
envOverrides: this._envOverrides,
effort: this._effort,
advisorModel: this._advisorModel,
keepWarm: this._keepWarmEnabled,
spawnedByAgent: !!this._parentSessionId,
historyLimit: this._tmuxHistoryLimit,
remote: this._remote,
docker: this._docker,
@@ -4650,9 +4752,16 @@ export class Session extends EventEmitter {
*/
private _trackSubmit(data: string, options: SessionWriteOptions): string[] {
const submitted = options.fromUser && this._acceptsPrompts ? this._submittedPromptTracker.feed(data) : [];
if (data.includes('\r') || data.includes('\n')) {
this._lastSubmitAt = Date.now();
const hasEnter = data.includes('\r') || data.includes('\n');
if (options.fromUser) {
this._lastHumanInputAt = Date.now();
// A person is here: whatever turn follows is theirs, not the keep-alive's.
this._keepAliveTurnUntil = 0;
} else if (hasEnter && !isKeepAliveWrite(data)) {
// Anything else submitted (an agent, respawn, auto-resume) starts a turn of its own.
this._keepAliveTurnUntil = 0;
}
if (hasEnter) this._stampSubmit();
return submitted;
}
@@ -4678,9 +4787,20 @@ export class Session extends EventEmitter {
* `_trackSubmit` only sees input that flows through Codeman's write path, so
* a pane the user drives by attaching to tmux directly never stamped this and
* `lastSubmitAt` stayed 0 for its whole life.
*
* `prompt` is the hook's own prompt text: anything but the keep-alive's ends the keep-alive
* window, because a person typing in an attached tmux client reaches Codeman only here, and
* their prompt must count as real and alert normally.
*/
markPromptSubmitted(): void {
markPromptSubmitted(prompt?: string): void {
if (prompt !== undefined && prompt.trim() !== KEEP_WARM_NUDGE) this._keepAliveTurnUntil = 0;
this._stampSubmit();
}
/** Every submit moves `lastSubmitAt`; only one outside a keep-alive turn moves `lastRealPromptAt`. */
private _stampSubmit(): void {
this._lastSubmitAt = Date.now();
if (!this.keepAliveTurnActive) this._lastRealPromptAt = this._lastSubmitAt;
}
/**
+35
View File
@@ -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();
+91 -3
View File
@@ -50,6 +50,11 @@ interface TabLayoutServiceDeps {
* 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;
}
@@ -78,6 +83,17 @@ export interface RemovedTabLayoutSession {
/** 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 }>;
}
/**
@@ -158,6 +174,20 @@ function pinCreatedChild(layout: TabLayout, sessionId: string, metadata: readonl
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,
@@ -550,21 +580,79 @@ export class TabLayoutService {
): 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)
sessionId && (groupId || !follow || typeof caseDefault === 'string')
? (layout: TabLayout, metadata: readonly TabRefMetadata[]) => {
const placed = groupId ? placeCreatedSession(layout, sessionId, groupId, metadata) : layout;
// 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;
return await this.getUnlocked(owner, adjust);
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);
});
}
+173 -33
View File
@@ -27,7 +27,7 @@ import { execSync, exec, execFile } from 'node:child_process';
import { promisify } from 'node:util';
const execAsync = promisify(exec);
import { existsSync, readFileSync, mkdirSync } from 'node:fs';
import { existsSync, readFileSync, mkdirSync, realpathSync } from 'node:fs';
import { writeFile, rename } from 'node:fs/promises';
import { dirname } from 'node:path';
import { homedir } from 'node:os';
@@ -67,12 +67,15 @@ import { missingCliMessage, resolveCliBinDir } from './utils/cli-resolver.js';
import {
buildSpawnCommandFromRegistry,
configSetenvValues,
launchGatePasses,
legacyConfigForMode,
} from './session-cli-registry-bridge.js';
import { keepWarmOn } from './prompt-cache-keepwarm.js';
import type { CliEntry } from './config/cli-registry/types.js';
import {
resolveStatusLineCliCommand,
readPlanUsageTelemetryEnabled,
readKeepWarmSettings,
findEffectiveUserStatusLineCommand,
} from './hooks-config.js';
import {
@@ -125,6 +128,7 @@ import {
import { EXEC_TIMEOUT_MS } from './config/exec-timeout.js';
import { ensureDeepSeekStatusShim } from './deepseek-status-shim.js';
import { restrictTmuxPrefixTable } from './tmux-prefix-table.js';
/** How long a cached process snapshot stays usable. */
const PROC_SNAPSHOT_TTL_MS = 2000;
@@ -183,6 +187,22 @@ const DEFAULT_REMOTE_RECONNECT_INTERVAL_MS = 5000;
/** Stable cwd for tmux server/pane launch; actual session cwd is reached inside the pane. */
const TMUX_LAUNCH_CWD = '/tmp';
/**
* TMUX_LAUNCH_CWD in every spelling a pane can report it. tmux reads a pane's
* directory from its process, which comes back resolved (`/private/tmp` on
* macOS, where `/tmp` is a symlink), so the literal and the resolved spelling
* both count. Computed once.
*/
const TMUX_LAUNCH_CWD_SPELLINGS: ReadonlySet<string> = (() => {
const spellings = new Set([TMUX_LAUNCH_CWD]);
try {
spellings.add(realpathSync(TMUX_LAUNCH_CWD));
} catch {
// Unresolvable launch directory: the literal spelling is all there is.
}
return spellings;
})();
/** Claude Code native macOS recommendation for avoiding low nofile startup failures. */
export const CLAUDE_CODE_NOFILE_LIMIT = 2147483646;
@@ -252,10 +272,14 @@ const PANE_LIST_SEP = '|';
* know a variable substitutes the empty string rather than failing, which is how
* tmux 3.2a answers `#{pane_dead_signal}` (added in 3.4), and the parser reads a
* short row as "pid known, deadness unknown" rather than discarding it.
*
* `#{pane_current_path}` comes last on purpose: a directory name may contain the
* separator, and only the final field can absorb it without ambiguity.
*/
const PANE_LIST_FORMAT =
`#{session_name}${PANE_LIST_SEP}#{pane_pid}` +
`${PANE_LIST_SEP}#{pane_dead}${PANE_LIST_SEP}#{pane_dead_status}${PANE_LIST_SEP}#{pane_dead_signal}`;
`${PANE_LIST_SEP}#{pane_dead}${PANE_LIST_SEP}#{pane_dead_status}${PANE_LIST_SEP}#{pane_dead_signal}` +
`${PANE_LIST_SEP}#{pane_current_path}`;
/**
* 构建 pane 启动前的 nofile 修复命令。
@@ -282,6 +306,8 @@ export interface PaneRow {
exitStatus?: number;
/** `#{pane_dead_signal}` — the killing signal, absent before tmux 3.4 and when unsignalled. */
exitSignal?: number;
/** `#{pane_current_path}` — the pane's current directory, absent when tmux reported none. */
currentPath?: string;
}
/**
@@ -347,6 +373,8 @@ export function parsePaneRows(output: string): PaneRow[] {
dead: deadFlag === '1' ? true : deadFlag === '0' ? false : undefined,
exitStatus: paneField(fields, 3),
exitSignal: paneField(fields, 4),
// Everything after the fifth separator, so a path containing `|` stays whole.
currentPath: fields.slice(5).join(PANE_LIST_SEP) || undefined,
});
}
return rows;
@@ -1070,6 +1098,28 @@ export function buildCodexCommand(config?: CodexConfig): string {
return buildSpawnCommandFromRegistry(entry, { mode: 'codex', sessionId: '', codexConfig: config }) ?? 'codex';
}
/**
* Whether a launch carries `idleCompaction: false` in its one `--settings` JSON: keep-warm on
* at launch (`keepWarmOn`), the statusLine exporter injected (without its `prompt_cache` reports
* keep-warm cannot run, and the opt-out alone would only cost the user Claude Code's own idle
* compaction), and the CLI's `idleCompactionSetting` gate passed, fail-closed like `--name`:
* an unknown version means no key. Pure; `claudeCliVersion` as in buildSpawnCommand.
*/
export function decideIdleCompactionOptOut(o: {
mode: SessionMode;
statusLine: StatusLineExporter | undefined;
keepWarm: boolean | undefined;
keepWarmDefault: boolean;
spawnedByAgent: boolean | undefined;
claudeCliVersion?: string | null;
}): boolean {
return (
o.statusLine === 'injected' &&
keepWarmOn(o.keepWarm, o.keepWarmDefault, !!o.spawnedByAgent) &&
launchGatePasses(o.mode, 'idleCompactionSetting', o.claudeCliVersion)
);
}
export function buildSpawnCommand(options: {
mode: SessionMode;
sessionId: string;
@@ -1091,6 +1141,8 @@ export function buildSpawnCommand(options: {
advisorModel?: string;
/** Resolved by resolveStatusLineCliCommand (hooks-config.ts) — undefined skips the exporter. Claude only. */
statusLineCommand?: string;
/** Add `idleCompaction: false` to the launch's one `--settings` JSON (see decideIdleCompactionOptOut). */
idleCompactionOff?: boolean;
/** Name pinned on claude as `--name` (version-gated, sanitized; local spawns only). By default only a user-chosen name; `pinCliSessionName` pins every tab name: see `Session.cliPinnedName`. */
sessionName?: string;
/**
@@ -2266,6 +2318,28 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
return { command, state: command ? 'injected' : enabled ? 'workspace-statusline' : 'collection-off' };
}
/**
* Whether this launch carries `idleCompaction: false` (decideIdleCompactionOptOut), with the
* keep-warm app default read fresh like the plan-usage switch above. Logged when keep-warm is
* on but the version gate keeps the key off, so a session that may still idle-compact says why.
*/
private async _resolveIdleCompactionOptOut(
mode: SessionMode,
statusLine: StatusLineExporter | undefined,
keepWarm: boolean | undefined,
spawnedByAgent: boolean | undefined
): Promise<boolean> {
if (statusLine !== 'injected') return false; // no settings read for a launch that can never carry it
const { keepWarmDefault } = await readKeepWarmSettings();
const off = decideIdleCompactionOptOut({ mode, statusLine, keepWarm, keepWarmDefault, spawnedByAgent });
if (!off && keepWarmOn(keepWarm, keepWarmDefault, !!spawnedByAgent)) {
console.log(
`[TmuxManager] Keep-warm is on but Claude Code's version is unknown or predates the idleCompaction setting; launching without the opt-out`
);
}
return off;
}
/**
* Export the user's own REAL statusLine command (found by
* findEffectiveUserStatusLineCommand) via tmux setenv, so the shared
@@ -2326,6 +2400,8 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
envOverrides,
effort,
advisorModel,
keepWarm,
spawnedByAgent,
historyLimit = DEFAULT_TMUX_HISTORY_LIMIT,
remote,
docker,
@@ -2389,6 +2465,7 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
workingDir,
!!(remote || docker)
);
const idleCompactionOff = await this._resolveIdleCompactionOptOut(mode, statusLine, keepWarm, spawnedByAgent);
// The user's own REAL statusLine, if any (walked via Claude Code's own
// settings precedence) — exported below so the shared exporter script
// can wrap it. Only worth discovering when we're actually injecting.
@@ -2413,6 +2490,7 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
effort,
advisorModel,
statusLineCommand,
idleCompactionOff,
sessionName: cliName,
});
@@ -2467,6 +2545,10 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
/* Non-critical */
}
// Browser keystrokes reach the attach client, so tmux's stock prefix table would
// let Ctrl+B ( or s move a tab onto another session. Before the agent starts.
restrictTmuxPrefixTable(this.tmux());
// Per-CLI env: API keys, config blobs, config-sourced vars, status bridges. All of
// it is registry data, so this is one unconditional call rather than a per-mode ladder.
this._configureCliEnv(
@@ -2554,6 +2636,7 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
attached: false,
name,
statusLine,
idleCompactionOff,
};
this.sessions.set(sessionId, session);
@@ -2664,6 +2747,8 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
unsetEnvKeys,
effort,
advisorModel,
keepWarm,
spawnedByAgent,
remote,
docker,
cliName,
@@ -2684,6 +2769,7 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
workingDir,
!!(remote || docker)
);
const idleCompactionOff = await this._resolveIdleCompactionOptOut(mode, statusLine, keepWarm, spawnedByAgent);
const userStatusLineCommand = statusLineCommand ? await findEffectiveUserStatusLineCommand(workingDir) : undefined;
const baseCmd = buildSpawnCommand({
@@ -2705,6 +2791,7 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
effort,
advisorModel,
statusLineCommand,
idleCompactionOff,
sessionName: cliName,
});
const config = niceConfig || DEFAULT_NICE_CONFIG;
@@ -2747,8 +2834,9 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
this.clearPaneExit(muxName);
const pid = this.getPanePid(muxName);
if (pid) session.pid = pid;
if (session.statusLine !== statusLine) {
if (session.statusLine !== statusLine || session.idleCompactionOff !== idleCompactionOff) {
session.statusLine = statusLine;
session.idleCompactionOff = idleCompactionOff;
this.saveSessions();
}
return pid;
@@ -3115,13 +3203,32 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
// socket tag to reconcile and no cross-socket ambiguity that could mark a
// live session dead (the root cause of vanished/duplicate tabs).
let active: Map<string, number>;
const paneDirs = new Map<string, string>();
try {
const output = execSync(`${this.tmux()} list-panes -a -F '${PANE_LIST_FORMAT}' 2>/dev/null || true`, {
encoding: 'utf-8',
timeout: EXEC_TIMEOUT_MS,
}).trim();
});
// Not trimmed: the directory is the last field, so trimming would cut a
// trailing space off the last row's path. parsePaneRows skips empty lines.
const rows = parsePaneRows(output);
active = new Map(rows.map((row) => [row.sessionName, row.pid]));
// A discovered session's directory, from its first pane with a usable path.
// The path is admitted only if it passes the same check createSession and
// respawnPane apply, since it was set by whoever owns the pane, not by us.
// The launch directory counts as no answer: every pane starts there, and a
// remote or docker pane never cds out of it, so for such a session it is
// not the workspace — and a rediscovered record cannot tell it is one.
for (const row of rows) {
if (
row.currentPath &&
!TMUX_LAUNCH_CWD_SPELLINGS.has(row.currentPath) &&
!paneDirs.has(row.sessionName) &&
isValidPath(row.currentPath)
) {
paneDirs.set(row.sessionName, row.currentPath);
}
}
// The same read answers both questions, so recovery starts with a pane-exit
// reading rather than waiting for the first stats tick — which may never
// come, since the collector only starts when boot found a live session.
@@ -3133,6 +3240,11 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
active = new Map();
}
// Sessions that survived an upgrade may sit on a server whose prefix table was never
// reduced (createSession does it for new ones), so cover them at boot too. Only with
// panes listed: on an empty socket `list-keys` spins up a throwaway server instead.
if (active.size > 0) restrictTmuxPrefixTable(this.tmux());
// Check tracked sessions against the live pane list.
for (const [sessionId, session] of this.sessions) {
const pid = active.get(session.muxName);
@@ -3175,11 +3287,13 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
muxName: sessionName,
pid,
createdAt: Date.now(),
workingDir: process.cwd(),
// Where the pane actually works, when tmux said; the server's cwd is only a fallback.
workingDir: paneDirs.get(sessionName) ?? process.cwd(),
mode: 'claude',
attached: false,
name: `Restored: ${sessionName}`,
// Every field above except the name and the pid is a guess: this record
// Every field above except the name, the pid and, when tmux reported a
// usable one, the directory is a guess: this record
// was rebuilt from the socket because Codeman's own bookkeeping did not
// have it. The synthetic id also cannot find the session's state.json
// entry, so a remote or docker session rediscovered this way arrives
@@ -3827,7 +3941,12 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
* - Text followed by `\r` goes in as ONE bracketed paste (buildPasteTextCommands),
* so Codex cannot fold the Enter into a paste burst; a single character is typed
* with `send-keys -l` instead, so a dialog answer selects (submitsAsKeys)
* - Text without `\r` is typed literally with `send-keys -l` (no key interpretation)
* - Single-line text without `\r` is typed literally with `send-keys -l` (no key
* interpretation)
* - A `\n` inside the text is kept (`\r\n` loses only its `\r`), and multi-line text
* always goes in as ONE bracketed paste, with or without a following Enter: inside
* the paste markers a CLI that enabled bracketed paste receives the line breaks as
* pasted text, where typed as keys each one could submit a line on its own
* - `Enter` key is sent as a SEPARATE tmux invocation after a small delay
* - Ink (Claude CLI) needs text and Enter split to avoid treating Enter as a newline
*/
@@ -3840,8 +3959,10 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
return false;
}
// TEST MODE: No-op — don't send input to real tmux sessions
if (IS_TEST_MODE) {
// TEST MODE: No-op — don't send input to real tmux sessions. The gate lives in
// inputRunner() so a test subclass can record what WOULD reach tmux.
const run = this.inputRunner();
if (!run) {
return true;
}
@@ -3856,7 +3977,14 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
try {
const hasCarriageReturn = input.includes('\r');
const textPart = input.replace(/\r/g, '').replace(/\n/g, '').trimEnd();
// Leading and trailing blank lines are dropped, so neither `"\nfix it\r"` nor
// `" \nfix it\r"` pastes an empty first line (nor `"fix it\n\r"` an empty last
// one). The leading match ends at a line break, so the first real line keeps its
// indentation.
const textPart = input
.replace(/\r/g, '')
.replace(/^\s*\n/, '')
.trimEnd();
if (textPart && hasCarriageReturn) {
// Send text first, then Enter as a SEPARATE tmux command after a short delay.
@@ -3868,31 +3996,29 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
// selection dialog ignores a pasted `3` and Enter then confirms the
// highlighted option (see submitsAsKeys).
if (submitsAsKeys(textPart)) {
await execAsync(
`${this.tmux()} send-keys -t "${session.muxName}" -l -- ${shellescape(escapeTmuxTrailingSemicolon(textPart))}`,
{ timeout: EXEC_TIMEOUT_MS }
await run(
`${this.tmux()} send-keys -t "${session.muxName}" -l -- ${shellescape(escapeTmuxTrailingSemicolon(textPart))}`
);
} else {
await pasteText(
(cmd) => execAsync(cmd, { timeout: EXEC_TIMEOUT_MS }),
buildPasteTextCommands(this.tmux(), session.muxName, textPart)
);
await pasteText(run, buildPasteTextCommands(this.tmux(), session.muxName, textPart));
}
await new Promise((resolve) => setTimeout(resolve, 50));
await execAsync(`${this.tmux()} send-keys -t "${session.muxName}" Enter`, {
timeout: EXEC_TIMEOUT_MS,
});
await run(`${this.tmux()} send-keys -t "${session.muxName}" Enter`);
} else if (textPart.includes('\n')) {
// Multi-line text, no Enter: pasted, because typed as keys every `\n` would
// reach the pane as a key the CLI may read as submit. A pane that has not
// enabled bracketed paste (POSIX sh, macOS /bin/bash 3.2) still gets every line
// break as a CR, so there every line but the last runs; before line breaks were
// kept, the joined text was typed and nothing ran.
await pasteText(run, buildPasteTextCommands(this.tmux(), session.muxName, textPart));
} else if (textPart) {
// Text only, no Enter
await execAsync(
`${this.tmux()} send-keys -t "${session.muxName}" -l -- ${shellescape(escapeTmuxTrailingSemicolon(textPart))}`,
{ timeout: EXEC_TIMEOUT_MS }
await run(
`${this.tmux()} send-keys -t "${session.muxName}" -l -- ${shellescape(escapeTmuxTrailingSemicolon(textPart))}`
);
} else if (hasCarriageReturn) {
// Enter only
await execAsync(`${this.tmux()} send-keys -t "${session.muxName}" Enter`, {
timeout: EXEC_TIMEOUT_MS,
});
await run(`${this.tmux()} send-keys -t "${session.muxName}" Enter`);
}
return true;
@@ -3902,6 +4028,16 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
}
}
/**
* How {@link sendInput} reaches tmux, or null where it must not (vitest). Split
* out like {@link readPaneRows}: with the test-mode gate at the top of sendInput,
* no test could see which commands a submit actually sends.
*/
protected inputRunner(): ((cmd: string) => Promise<unknown>) | null {
if (IS_TEST_MODE) return null;
return (cmd) => execAsync(cmd, { timeout: EXEC_TIMEOUT_MS });
}
// ========== Pane Methods (for Agent Team teammate panes) ==========
/**
@@ -4007,7 +4143,9 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
/**
* Send input to a specific pane within a tmux session.
* Uses the same literal text approach as sendInput() but targets a specific pane.
* Types the text literally with `send-keys -l`, line breaks stripped, then Enter
* when the input carried a `\r`. Unlike sendInput(), it neither pastes nor keeps
* the line breaks of multi-line text.
*/
sendInputToPane(muxName: string, paneTarget: string, input: string): boolean {
if (IS_TEST_MODE) return true;
@@ -4084,17 +4222,19 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
/**
* Plain visible-frame text for the working/idle probe (see `session.ts`).
*
* One `capture-pane` and nothing else: no `-e` styles, no `display-message`
* cursor query, no repaint reconstruction: this feeds a regex, not a
* terminal. Returns null in tests (no tmux) so callers fall back to their
* stream heuristics rather than reading an empty screen as "not working".
* One `capture-pane` and nothing else: no `display-message` cursor query, no
* repaint reconstruction: this feeds a regex, not a terminal. `styles` adds
* `-e` for a reader that must tell faint text from typed text (the keep-warm
* composer check); lines stay `\n`-separated either way. Returns null in tests
* (no tmux) so callers fall back to their stream heuristics rather than
* reading an empty screen as "not working".
*/
capturePaneText(muxName: string, paneTarget?: string): string | null {
capturePaneText(muxName: string, paneTarget?: string, opts?: { styles?: boolean }): string | null {
if (IS_TEST_MODE) return null;
const target = resolveTmuxPaneTarget(muxName, paneTarget);
if (!target) return null;
try {
return execSync(`${this.tmux()} capture-pane -p -t ${shellescape(target)}`, {
return execSync(`${this.tmux()} capture-pane -p${opts?.styles ? ' -e' : ''} -t ${shellescape(target)}`, {
encoding: 'utf-8',
timeout: EXEC_TIMEOUT_MS,
});
+138
View File
@@ -0,0 +1,138 @@
/**
* @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. The same goes
* for the `root` keys an attach claims, including Alt+1..9, which run
* `switch-client`: a tab that receives Esc then a digit within tmux's
* `escape-time` is moved onto another session for as long as those bindings
* exist. Leaving `root` alone keeps the attach working; it does not close that.
*
* @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';
/** 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;
}
}
+22 -9
View File
@@ -317,6 +317,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 +367,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]`
@@ -624,7 +636,7 @@ export async function beginAttachHandoff(
if (target === muxName) await client.followAttachingClient(target);
}
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);
if (oneKey) await client.unbindDetachKey(ONE_KEY_DETACH, 'root');
@@ -702,7 +714,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 +773,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 +1121,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;
+3 -2
View File
@@ -704,8 +704,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.
*/
+6
View File
@@ -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 ==========
+41 -1
View File
@@ -797,6 +797,33 @@ export interface PromptCacheStatus {
*/
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;
@@ -819,7 +846,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 */
@@ -858,6 +887,10 @@ export interface SessionState {
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;
/** 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 */
@@ -972,6 +1005,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 —
+66 -2
View File
@@ -40,12 +40,20 @@ export interface UsageWindow {
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). */
@@ -196,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
@@ -286,10 +339,21 @@ export function formatSessionStatusText(s: SessionStatus | null, now: number = D
* 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);
}
+117
View File
@@ -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);
}
+2
View File
@@ -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 {
@@ -75,6 +76,7 @@ export {
getCopilotNotFoundMessage,
getCopilotCliVersion,
} from './copilot-cli-resolver.js';
export { readCopilotPlanUsage, resolveCopilotToken } from './copilot-plan-usage.js';
export {
boundedPathExists,
describeUnknownPath,
+29
View File
@@ -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(' ');
}
+3 -3
View File
@@ -14,7 +14,7 @@
**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. 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)
**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)
@@ -70,13 +70,13 @@ Frontend JS modules have `@fileoverview` with `@dependency`/`@loadorder` tags. L
**Shell keyboard accessory bar + one-shot Ctrl** (`keyboard-accessory.js`): a shell-mode session swaps the mobile accessory bar for terminal controls; `setMode()` records `extendedKeyboardBar` as the base layout and `refreshForActiveSession()` resolves base-vs-shell. ⚠️ Ctrl is a one-shot modifier applied in `terminal.onData` (after `shouldSuppressTerminalQueryResponse`, before every send path), and must skip `isTerminalFocusOrMouseReport()` chunks. ⚠️ It must disarm on use, second tap, any other accessory key, session switch, keyboard dismissal and layout swap. ⚠️ `_handleCjkInput()` must apply it too (the CJK textarea bypasses onData). Mapping: `ctrlByteFor()`. ⚠️ mobile.css's light-skin repaint must keep excluding `.accessory-btn:not(.armed)` or the armed state is invisible. → [architecture-invariants#shell-keyboard-accessory-bar-and-one-shot-ctrl](../../docs/architecture-invariants.md#shell-keyboard-accessory-bar-and-one-shot-ctrl)
**Mobile prompt composer** (`keyboard-accessory.js`): the agent bars' Paste key is **Compose**, a native multiline dialog where only **Send** submits (the shell bar keeps plain Paste). Opening it adopts the whole terminal prompt (`_takePendingLocalEcho`), erasing the flushed prefix with backspaces counted in code points. ⚠️ Drafts are per-session and in memory only (`_composerDrafts`), never persisted (prompts carry secrets). ⚠️ Delivery is a hand-built bracketed-paste frame via `_sendInputAsync` WITHOUT `useMux`, then a separate delayed Enter WITH it; never `terminal.paste()`, and the frame must never take the mux fallback (it strips newlines). ⚠️ `_composerMaxLength` must stay derived from `MAX_INPUT_LENGTH` minus the markers, or an oversized frame wedges the durable queue. ⚠️ The composer overlay needs its own gutter restatement after the fold rules. Test: `test/mobile-prompt-composer.test.ts`. → [architecture-invariants#mobile-prompt-composer](../../docs/architecture-invariants.md#mobile-prompt-composer)
**Mobile prompt composer** (`keyboard-accessory.js`): the agent bars' Paste key is **Compose**, a native multiline dialog where only **Send** submits (the shell bar keeps plain Paste). Opening it adopts the whole terminal prompt (`_takePendingLocalEcho`), erasing the flushed prefix with backspaces counted in code points. ⚠️ Drafts are per-session and in memory only (`_composerDrafts`), never persisted (prompts carry secrets). ⚠️ Delivery is a hand-built bracketed-paste frame via `_sendInputAsync` WITHOUT `useMux`, then a separate delayed Enter WITH it; never `terminal.paste()`, and the frame must never take the mux fallback (`TmuxManager.sendInput()` strips every `\r`, the frame's line separator, so the lines weld into one inside a second pair of markers and get submitted). ⚠️ `_composerMaxLength` must stay derived from `MAX_INPUT_LENGTH` minus the markers, or an oversized frame wedges the durable queue. ⚠️ The composer overlay needs its own gutter restatement after the fold rules. 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. ⚠️ 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)
**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.
+8 -2
View File
@@ -123,6 +123,12 @@ interface NotePromptArgs {
* `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;
/**
@@ -285,9 +291,9 @@ export class ApprovalInbox {
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);
+13 -2
View File
@@ -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;
}
+1
View File
@@ -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';
+10
View File
@@ -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;
}
+3 -1
View File
@@ -13,7 +13,9 @@ export interface SessionPort {
/**
* 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). Resolves to the committed layout.
* 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>;
+80 -12
View File
@@ -194,6 +194,7 @@ const _SSE_HANDLER_MAP = [
[SSE_EVENTS.SESSION_CLI_INFO, '_onSessionCliInfo'],
[SSE_EVENTS.SESSION_STATUS_TELEMETRY, '_onSessionStatusTelemetry'],
[SSE_EVENTS.SESSION_PROMPT_CACHE, '_onSessionPromptCache'],
[SSE_EVENTS.SESSION_KEEP_WARM, '_onSessionKeepWarm'],
// Scheduled runs
[SSE_EVENTS.SCHEDULED_CREATED, '_onScheduledCreated'],
@@ -685,6 +686,14 @@ const DEFAULT_SHORTCUTS = [
label: 'Close Panels',
displayBindings: ['Escape'],
},
// Handled by the File Viewer's own capture-phase listener while it is open
// (_bindFilePreviewChrome, panels-ui.js), never through the registry.
{
id: 'file-preview-history',
group: 'Panels',
label: 'File Viewer Back / Forward',
displayBindings: ['Alt/Option+ArrowLeft', 'Alt/Option+ArrowRight'],
},
];
@@ -2959,6 +2968,12 @@ class CodemanApp {
* output. Subtrees already inside an `<a>` are skipped so an autolinked URL
* is never re-cut, and the anchor's textContent is the path verbatim, so
* "copy code" still yields exactly what the agent printed.
*
* Before that walk it also rewrites an existing anchor whose href is a path
* (no scheme, not `//host`, not a bare `#fragment`) into the same `a.rv-path`
* shape, on purpose WITHOUT `FILE_PATH_LINK_PATTERN`'s root and extension
* filter: the agent chose that href as a link target, and left alone it opens
* as a same-origin URL (a 404 page in a new tab).
*/
_linkifyFilePaths(root) {
if (!root || typeof document === 'undefined') return;
@@ -2966,6 +2981,36 @@ class CodemanApp {
// out of the middle of rendering a message.
if (typeof findFilePathLinks !== 'function') return;
// A markdown link whose destination is a path (`[report](/home/me/run/x.json)`,
// Codex's usual way of citing a file) resolved against Codeman's own origin
// and opened a 404 in a new tab. Give it the linkified-path shape instead;
// links with a scheme or `//host` keep their own behaviour.
for (const a of root.querySelectorAll('a[href]')) {
// The URL parser drops tabs and newlines, so `http&#9;://` is still http.
const href = (a.getAttribute('href') || '').replace(/[\t\n\r]/g, '');
if (/^[a-z][a-z0-9+.-]*:/i.test(href) || href.startsWith('//')) continue;
// `#L42`, `?plain=1` and a `:42:7` suffix name a spot in the file, not the
// file. The suffix is cut before decoding, so an encoded `%3A` stays a name.
let path = href.split('#')[0].split('?')[0].replace(/:\d+(?::\d+)?$/, '');
try {
path = decodeURIComponent(path);
} catch {
/* malformed escape: keep the path as written */
}
// A bare `#fragment` (and every `.rv-path`, whose href is `#`) ends up empty.
if (!path) continue;
// rv-path-link keeps the label in the prose font (styles.css): it is a label,
// not a path, and break-all would split it mid-word on a phone.
a.className = 'rv-path rv-path-link';
a.dataset.path = path;
a.dataset.linkified = '1';
if (path.endsWith('/') || findFilePathLinks(path).some((f) => f.folder && f.path === path)) a.dataset.folder = '1';
a.title = path;
a.setAttribute('href', '#');
a.removeAttribute('target');
a.removeAttribute('rel');
}
// Collect first: replacing a node while the walker is positioned on it
// invalidates the traversal.
const walker = document.createTreeWalker(root, NodeFilter.SHOW_TEXT);
@@ -3469,8 +3514,10 @@ class CodemanApp {
this.sendPendingCtrlL(data.id);
if (data.id === this.activeSessionId) this._updateLocalEchoState();
}
// Start stuck detection timer (only if no respawn running)
if (!this.respawnStatus[data.id]?.enabled) {
// Start stuck detection timer (only if no respawn running). Not at the end of Codeman's
// own keep-alive turn (`keepAlive`): the session has been waiting since the last real
// turn, whose timer is still running or has already fired.
if (!data.keepAlive && !this.respawnStatus[data.id]?.enabled) {
const threshold = this.notificationManager?.preferences?.stuckThresholdMs || 600000;
clearTimeout(this.idleTimers.get(data.id));
this.idleTimers.set(data.id, setTimeout(() => {
@@ -3492,8 +3539,9 @@ class CodemanApp {
this.sendPendingCtrlL(data.id);
if (data.id === this.activeSessionId) this._updateLocalEchoState();
}
// Clear stuck detection timer
const timer = this.idleTimers.get(data.id);
// Clear stuck detection timer, unless this is Codeman's own keep-alive turn, which is
// no sign of life from the agent's side.
const timer = data.keepAlive ? null : this.idleTimers.get(data.id);
if (timer) {
clearTimeout(timer);
this.idleTimers.delete(data.id);
@@ -3557,7 +3605,7 @@ class CodemanApp {
}
}
// Claude + Codex plan usage limits — account-global, so the latest sample
// Claude, Codex and Copilot plan usage limits — account-global, so the latest sample
// drives the shared header chip.
_onSessionStatusTelemetry(data) {
this.updatePlanUsageChip(data);
@@ -3617,19 +3665,27 @@ class CodemanApp {
};
// The provider label only earns its space when there is more than one
// provider to tell apart: a machine with Claude alone shows bare windows.
const hasWindows = (usage) => pct(usage?.fiveHour) !== null || pct(usage?.sevenDay) !== null;
const labelled = hasWindows(data) && hasWindows(data.codex);
const hasWindows = (usage) =>
pct(usage?.fiveHour) !== null || pct(usage?.sevenDay) !== null || pct(usage?.monthly) !== null;
const labelled = [data, data.codex, data.copilot].filter(hasWindows).length > 1;
const row = (provider, usage, idle) => {
// hasWindows() gates the row, so a placeholder can only ever appear
// ALONGSIDE a real reading — a provider reporting nothing still renders
// nothing, never a row of em dashes.
if (!hasWindows(usage)) return '';
const windows = [seg('5h', pct(usage?.fiveHour), idle), seg('7d', pct(usage?.sevenDay), idle)].filter(Boolean);
// `mo` is Copilot's calendar-month premium-request quota; it is the only window that provider has.
const windows = [
seg('5h', pct(usage?.fiveHour), idle),
seg('7d', pct(usage?.sevenDay), idle),
seg('mo', pct(usage?.monthly), false),
].filter(Boolean);
if (!windows.length) return '';
const label = labelled ? `<span class="pu-provider">${provider}</span>` : '';
return `<span class="pu-row">${label}<span class="pu-windows">${windows.join('<span class="pu-sep">·</span>')}</span></span>`;
};
const rows = [row('Claude', data, true), row('Codex', data.codex, false)].filter(Boolean);
const rows = [row('Claude', data, true), row('Codex', data.codex, false), row('Copilot', data.copilot, false)].filter(
Boolean
);
chip.innerHTML = rows.length ? rows.join('') : '—';
const resetStr = (w) => (w && w.resetAt ? new Date(w.resetAt).toLocaleString() : '—');
const details = (provider, usage, idle) => {
@@ -3639,10 +3695,20 @@ class CodemanApp {
if (five !== null) lines.push(`5-hour limit: ${five}% used (resets ${resetStr(usage.fiveHour)})`);
else if (idle && seven !== null) lines.push('5-hour limit: no active session window');
if (seven !== null) lines.push(`Weekly limit: ${seven}% used (resets ${resetStr(usage.sevenDay)})`);
const month = pct(usage?.monthly);
if (month !== null) {
// Counts are coerced finite numbers, so interpolating them stays XSS-safe like the rest.
const m = usage.monthly;
const counts =
Number.isFinite(m.used) && Number.isFinite(m.limit) ? `${Math.round(m.used)} of ${Math.round(m.limit)} requests, ` : '';
lines.push(`Premium requests this month: ${month}% used (${counts}resets ${resetStr(m)})`);
}
return lines.length ? `${provider} plan usage\n${lines.join('\n')}` : '';
};
chip.title =
[details('Claude', data, true), details('Codex', data.codex, false)].filter(Boolean).join('\n\n') ||
[details('Claude', data, true), details('Codex', data.codex, false), details('Copilot', data.copilot, false)]
.filter(Boolean)
.join('\n\n') ||
'Plan usage limits';
}
@@ -3938,8 +4004,8 @@ class CodemanApp {
// Both transports refuse a frame over the server's limit (issue #484), and a
// refused frame used to sit at the head of the durable queue for good. So an
// oversized paste goes out as several in-limit frames, delivered in seq order
// as one contiguous stream. A mux write is line-oriented (it strips newlines
// and sends Enter on its own), so it is never split: refuse it instead.
// as one contiguous stream. A mux write is one paste that sends Enter on its
// own, so it is never split: refuse it instead.
const limit = window.CodemanInputLimit;
if (limit && input.length > limit.FRAME_MAX_CHARS) {
if (useMux || input.length > limit.PASTE_MAX_CHARS) {
@@ -7692,6 +7758,8 @@ class CodemanApp {
this._tabLayoutEditor = null;
}
this.tabLayout = next;
// An open Case settings popover lists the groups as its Default session group options.
this._refreshCaseDefaultGroupOptions?.(next);
const storage = this._getTabCollapseStorage();
const collapsed = storage && next
? window.CodemanTabLayout.loadCollapsedGroupIds(storage, next.groups.map((group) => group.id))
+49 -1
View File
@@ -733,7 +733,7 @@ function computeConnectionLossUi(input) {
// it says why instead of "not reported yet" forever.
const PROMPT_CACHE_NOT_COLLECTED = {
'collection-off':
'Not collected: Plan Usage was off when this session started. Turn it on in App Settings; sessions started after that report it.',
'Not collected: Plan Usage was off when this session started. Turn it on in App Settings → Header & Panels, then start a new session; this one cannot pick it up.',
'workspace-statusline':
"Not collected: this workspace's .claude/settings.local.json sets its own statusLine, which Codeman never replaces.",
remote: 'Not collected for remote and Docker sessions.',
@@ -763,6 +763,51 @@ function formatPromptCacheStatus(cache, now, statusLine) {
return parts.join(' ');
}
// Session Options → Respawn → Prompt cache → Status: the keep-warm line under the cache
// readout, saying what the keep-warm service is doing (`session.keepWarm`, published by the
// server on `session:keepWarm`), compact and dot-separated. Empty when there is nothing to
// say: switched off, not published yet, or a launch without the statusLine exporter, where
// the readout's own "Not collected" sentence on the same row already says why and
// session-ui.js greys the switch. Pure: no DOM, `now` is passed in.
function formatKeepWarmStatus(status, now, statusLine) {
if (PROMPT_CACHE_NOT_COLLECTED[statusLine]) return '';
if (!status || typeof status.state !== 'string') return '';
const clock = (ms) => new Date(ms).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
const keepWarmWindow = Number.isFinite(status.budgetEndsAt)
? status.budgetEndsAt > now
? `stops ${clock(status.budgetEndsAt)}`
: 'window closed'
: '';
// Keep-warm switched on or off after launch: the `idleCompaction` opt-out rides only a launch.
const idleCompaction =
status.enabled && status.idleCompactionOff === false
? 'idle compaction on until relaunch'
: !status.enabled && status.idleCompactionOff === true
? 'idle compaction off until relaunch'
: '';
let parts;
switch (status.state) {
case 'off': {
const reason = status.reason === 'off' ? '' : status.reason;
if (!reason && !idleCompaction) return '';
parts = ['off', reason, idleCompaction];
break;
}
case 'armed':
parts = [`next ${clock(status.nextCheckAt)}`, keepWarmWindow, idleCompaction];
break;
case 'waiting':
parts = ['waiting', status.reason || 'retrying', `retry ${clock(status.nextCheckAt)}`, keepWarmWindow, idleCompaction];
break;
case 'nudged':
parts = [`sent ${clock(status.lastNudgeAt)} (${status.nudges}\u00d7)`, keepWarmWindow, idleCompaction];
break;
default:
parts = ['stopped', status.reason || 'nothing to do', idleCompaction];
}
return `Keep-alive: ${parts.filter(Boolean).join(' \u00b7 ')}`;
}
// SSE staleness policy: is this stream a zombie?
//
// An EventSource that stops delivering does not always error. A proxy that
@@ -1503,6 +1548,8 @@ if (typeof window !== 'undefined') {
};
window.CodemanPromptCache = {
format: formatPromptCacheStatus,
formatKeepWarm: formatKeepWarmStatus,
keepWarmUnavailable: (statusLine) => !!PROMPT_CACHE_NOT_COLLECTED[statusLine],
};
window.CodemanSessionOrder = {
RANK: SESSION_ACTIVITY_RANK,
@@ -1699,6 +1746,7 @@ const SSE_EVENTS = {
SESSION_RUNNING: 'session:running',
SESSION_STATUS_TELEMETRY: 'session:statusTelemetry',
SESSION_PROMPT_CACHE: 'session:promptCache',
SESSION_KEEP_WARM: 'session:keepWarm',
// Scheduled runs
SCHEDULED_CREATED: 'scheduled:created',
+23
View File
@@ -678,6 +678,9 @@
'Agent Teams': '智能体团队',
'Claude Model': 'Claude 模型',
'1M Opus Context': 'Opus 100 万上下文',
'Default session group': '默认会话分组',
'New sessions for this case land in this group': '此案例的新会话将放入此分组',
'Could not save the default session group.': '无法保存默认会话分组。',
'Remote auto-reconnect': '远程自动重连',
'Pin tab names on the CLI': '在 CLI 上固定标签名',
"Launch every Claude session with its tab name, so the CLI's prompt label and /resume list match your tabs. Claude then stops generating its own titles for those sessions, and a rename in Codeman holds only until the next turn. Applies from the next session start or respawn, no server restart needed.":
@@ -962,6 +965,19 @@
'Enable Respawn': '启用重生',
'Stop Respawn': '停止重生',
'Auto-resume when usage limit resets': '使用限制重置后自动继续',
// Prompt-cache keep-warm (Session Options → Respawn, App Settings → System → Automation, session-ui.js toasts).
'Keep the cache warm while idle': '空闲时保持缓存预热',
"Sends a one-line keep-alive just before the cache expires, so your next prompt still reads from cache. Runs only while idle, up to the limit in App Settings → System → Automation; switch on before launch to also stop Claude Code's own idle compaction.":
'在缓存即将过期前发送一行保活消息,让您的下一条提示仍能从缓存读取。仅在空闲时运行,最长不超过“应用设置 → 系统 → 自动化”中的上限;在启动前开启还会关闭 Claude Code 自带的空闲压缩。',
'Keep prompt caches warm': '保持提示缓存预热',
"Default for Claude sessions that have not chosen on their Respawn tab (a session an agent started needs its own switch): shortly before an idle session's prompt cache would expire, send a one-line keep-alive turn so the next prompt still reads the conversation from cache instead of re-processing it.":
'未在“重生”标签页中单独设置的 Claude 会话的默认值(由智能体启动的会话需单独开启):在空闲会话的提示缓存即将过期前发送一行保活消息,让下一条提示仍从缓存读取对话,而不必重新处理。',
'Keep warm for at most': '最长保持预热',
'Hours after your last prompt in a session during which keep-alives are sent; past that the cache is left to expire. A keep-alive costs a small fraction of the re-write it prevents, so a few hours of them pay off, an overnight gap does not.':
'会话中最后一条提示之后发送保活消息的小时数;超过之后任由缓存过期。一次保活的开销只是它所避免的重新写入的一小部分,因此几个小时的保活是值得的,整夜的空档则不值得。',
hours: '小时',
'Prompt-cache keep-warm enabled': '提示缓存预热已开启',
'Prompt-cache keep-warm disabled': '提示缓存预热已关闭',
'Auto-restart sessions when context fills up (usually not needed)': '上下文已满时自动重启会话(通常不需要)',
'Auto-Compact': '自动压缩',
'Auto-Clear': '自动清空',
@@ -1039,6 +1055,12 @@
'Source type filter': '来源类型筛选',
'Copy content': '复制内容',
'Edit file': '编辑文件',
'Back (Alt+←)': '后退 (Alt+←)',
'Forward (Alt+→)': '前进 (Alt+→)',
'Back to the previous file': '返回上一个文件',
'Forward to the next file': '前进到下一个文件',
Expand: '展开',
'Drag to resize': '拖动以调整大小',
'Rendered markdown': '渲染 Markdown',
'Line numbers': '行号',
'Wrap lines': '自动换行',
@@ -1235,6 +1257,7 @@
'Voice input (Ctrl+Shift+V)': '语音输入(Ctrl+Shift+V)',
'Insert Newline': '插入换行',
'Close Panels': '关闭面板',
'File Viewer Back / Forward': '文件查看器后退 / 前进',
'Previous / Next Session': '上一个 / 下一个会话',
'Next Session': '下一个会话',
'Previous Session': '上一个会话',
+86 -1
View File
@@ -197,7 +197,7 @@
<button class="btn-icon-header btn-split btn-split--hidden" onclick="app.openSplitPicker(event)" title="Split: open a second session beside this one" aria-label="Split: open a second session beside this one" aria-pressed="false"><svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><rect x="2" y="3" width="20" height="18" rx="2"/><line x1="12" y1="3" x2="12" y2="21"/></svg></button>
<button class="btn-icon-header btn-tile-grid btn-tile-grid--hidden" onclick="app.toggleTileGrid()" oncontextmenu="app.openTileCountMenu(event)" aria-describedby="tileGridHint" aria-label="Tiles: show several sessions side by side (right-click for how many)" aria-pressed="false"><svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><rect x="3" y="3" width="8" height="8" rx="1"/><rect x="13" y="3" width="8" height="8" rx="1"/><rect x="3" y="13" width="8" height="8" rx="1"/><rect x="13" y="13" width="8" height="8" rx="1"/></svg></button>
<button class="btn-icon-header btn-ultracode-agents btn-ultracode-agents--hidden" onclick="app.toggleUltracodeAgentsPanel()" title="Ultracode / Workflow agents" aria-label="Open ultracode workflow agents"><svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="6" cy="6" r="2.5"/><circle cx="6" cy="18" r="2.5"/><circle cx="18" cy="12" r="2.5"/><path d="M8.2 7.2 15.6 11M8.2 16.8 15.6 13"/></svg></button>
<div class="header-plan-usage header-plan-usage--hidden" id="planUsageChip" title="Claude and Codex plan usage limits">—</div>
<div class="header-plan-usage header-plan-usage--hidden" id="planUsageChip" title="Claude, Codex and Copilot plan usage limits">—</div>
<button class="btn-icon-header btn-notifications" onclick="app.toggleNotifications()" title="Notifications" aria-label="Toggle notifications" style="display:none;">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M18 8A6 6 0 0 0 6 8c0 7-3 9-3 9h18s-3-2-3-9"/><path d="M13.73 21a2 2 0 0 1-3.46 0"/></svg>
<span class="notification-badge" id="notifBadge" style="display:none;">0</span>
@@ -614,6 +614,8 @@
<div class="file-preview-header">
<span class="file-preview-title" id="filePreviewTitle">file.ts</span>
<div class="file-preview-actions">
<button class="btn-icon-sm" id="filePreviewBackBtn" onclick="app.stepFilePreviewHistory(-1)" title="Back (Alt+←)" aria-label="Back to the previous file" hidden disabled>&larr;</button>
<button class="btn-icon-sm" id="filePreviewForwardBtn" onclick="app.stepFilePreviewHistory(1)" title="Forward (Alt+→)" aria-label="Forward to the next file" hidden disabled>&rarr;</button>
<button class="btn-icon-sm" id="filePreviewPrevBtn" onclick="app.stepFolderPreview(-1)" title="Previous file in folder (←)" aria-label="Previous file in folder" hidden>&lsaquo;</button>
<button class="btn-icon-sm" id="filePreviewNextBtn" onclick="app.stepFolderPreview(1)" title="Next file in folder (→)" aria-label="Next file in folder" hidden>&rsaquo;</button>
<button class="btn-icon-sm" id="filePreviewFolderBtn" onclick="app.openFolderOfPreview()" title="Show folder" aria-label="Show folder" hidden><svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z"/></svg></button>
@@ -624,6 +626,7 @@
<button class="btn-icon-sm file-preview-edit-btn" id="filePreviewEditBtn" onclick="app.enterFilePreviewEdit()" title="Edit file" aria-label="Edit file" hidden><svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M17 3a2.85 2.83 0 1 1 4 4L7.5 20.5 2 22l1.5-5.5z"/></svg></button>
<button class="btn-icon-sm" onclick="app.copyFilePreviewContent()" title="Copy content">&#x2398;</button>
<button class="btn-icon-sm" id="filePreviewDetachBtn" onclick="app.detachFilePreview()" title="Open in new tab" aria-label="Open in new tab" hidden><svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6"/><polyline points="15 3 21 3 21 9"/><line x1="10" y1="14" x2="21" y2="3"/></svg></button>
<button class="btn-icon-sm" id="filePreviewExpandBtn" onclick="app.toggleFilePreviewExpanded()" title="Expand" aria-label="Expand" aria-pressed="false"><svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><polyline points="15 3 21 3 21 9"/><polyline points="9 21 3 21 3 15"/><line x1="21" y1="3" x2="14" y2="10"/><line x1="3" y1="21" x2="10" y2="14"/></svg></button>
<button class="btn-icon-sm" onclick="app.closeFilePreview()" title="Close">&times;</button>
</div>
</div>
@@ -635,6 +638,7 @@
<button class="file-preview-editbar-btn file-preview-editbar-btn--save" id="filePreviewSaveBtn" onclick="app.saveFilePreviewEdit()" disabled>Save</button>
</div>
<div class="file-preview-footer" id="filePreviewFooter"></div>
<div class="file-preview-resize" id="filePreviewResize" title="Drag to resize" aria-hidden="true"></div>
</div>
</div>
@@ -774,6 +778,11 @@
<span>1M Opus Context</span>
</label>
<span class="form-hint">Use 1M token context window for new sessions</span>
<label class="case-default-group-label" for="caseDefaultGroup">Default session group</label>
<select id="caseDefaultGroup" class="case-default-group-select" onchange="app.onCaseDefaultGroupChanged(this)">
<option value="">Ungrouped</option>
</select>
<span class="form-hint">New sessions for this case land in this group</span>
</div>
</div>
<!-- Mobile-only case button + gear -->
@@ -818,6 +827,11 @@
<span>1M Opus Context</span>
</label>
<span class="form-hint">Use 1M token context window</span>
<label class="case-default-group-label" for="caseDefaultGroupMobile">Default session group</label>
<select id="caseDefaultGroupMobile" class="case-default-group-select" onchange="app.onCaseDefaultGroupChanged(this)">
<option value="">Ungrouped</option>
</select>
<span class="form-hint">New sessions for this case land in this group</span>
</div>
</div>
@@ -922,6 +936,7 @@
<div><kbd>Ctrl</kbd>+<kbd>?</kbd></div><div>Show Shortcuts</div>
<div><kbd>Alt/Option</kbd>+<kbd>?</kbd></div><div>Show Shortcuts</div>
<div><kbd>Escape</kbd></div><div>Close Panels</div>
<div><kbd>Alt/Option</kbd>+<kbd>ArrowLeft</kbd> / <kbd>Alt/Option</kbd>+<kbd>ArrowRight</kbd></div><div>File Viewer Back / Forward</div>
</div>
</section>
</div>
@@ -1334,8 +1349,19 @@
<div class="set-row-text">
<span class="set-row-label">Status</span>
<span class="set-row-desc" id="promptCacheStatus" data-i18n-skip>Not reported yet.</span>
<span class="set-row-desc" id="keepWarmStatus" data-i18n-skip hidden></span>
</div>
</div>
<!-- Per-session opt-in for the keep-alive nudge (prompt-cache-keepwarm.ts);
the default for sessions that have not chosen lives in App Settings →
Automation. Its status shares the Status row above: session-ui.js updateKeepWarmStatus. -->
<div class="set-row">
<div class="set-row-text">
<span class="set-row-label">Keep the cache warm while idle</span>
<span class="set-row-desc">Sends a one-line keep-alive just before the cache expires, so your next prompt still reads from cache. Runs only while idle, up to the limit in App Settings &rarr; System &rarr; Automation; switch on before launch to also stop Claude Code's own idle compaction.</span>
</div>
<label class="switch switch-sm"><input type="checkbox" id="modalKeepWarmEnabled" onchange="app.autoSaveKeepWarm()"><span class="slider"></span></label>
</div>
</div>
</div>
@@ -3242,6 +3268,48 @@
</div>
</div>
<div class="set-group" id="configBackupGroup">
<div class="set-group-head"><h4>Config backups</h4><span class="set-scope">server</span></div>
<div class="set-group-body">
<div class="set-row" data-search="config backup snapshot settings clis restore retention keep">
<div class="set-row-text">
<span class="set-row-label">Back up my configuration</span>
<span class="set-row-desc">Keeps timestamped copies of your settings, CLI overrides, custom endpoints, webhook, linked cases and (in multi-user mode) accounts. A copy is made at startup and whenever one of them changes. Some of these files hold credentials, so each copy is private to this user (mode 0700/0600).</span>
</div>
<label class="switch switch-sm"><input type="checkbox" id="appSettingsConfigBackup"><span class="slider"></span></label>
</div>
<div class="set-row has-field" data-search="config backup folder location path directory">
<div class="set-row-text">
<span class="set-row-label">Backup folder</span>
<span class="set-row-desc">An absolute path (a leading <code>~</code> is expanded). Leave empty for the default shown. Only folders Codeman created there are ever pruned.</span>
</div>
<input type="text" id="appSettingsConfigBackupDir" class="set-input" placeholder="default: ~/.codeman/backups/config" data-i18n-skip>
</div>
<div class="set-row has-field" data-search="config backup retention keep count snapshots">
<div class="set-row-text">
<span class="set-row-label">Keep this many backups</span>
<span class="set-row-desc">2 to 500 (with 1, every change would delete the state before it). The newest backup is always kept.</span>
</div>
<input type="number" id="appSettingsConfigBackupKeepCount" oninput="this.dataset.touched=1" class="set-input" min="2" max="500" step="1" placeholder="20">
</div>
<div class="set-row has-field" data-search="config backup retention keep days age">
<div class="set-row-text">
<span class="set-row-label">Delete backups older than (days)</span>
<span class="set-row-desc">A backup is removed once a newer one has existed this many days. 0 keeps backups until the count above pushes them out.</span>
</div>
<input type="number" id="appSettingsConfigBackupKeepDays" oninput="this.dataset.touched=1" class="set-input" min="0" max="3650" step="1" placeholder="30">
</div>
<div class="set-row" data-search="config backup now restore list">
<div class="set-row-text">
<span class="set-row-label">Backups</span>
<span class="set-row-desc">Folder and retention changes apply after you save. Restoring puts a backup's files back and makes a copy of the current ones first. Compare shows what changed between two backups, or between a backup and the running config; credential values are never shown.</span>
</div>
<button class="btn-toolbar btn-sm btn-primary" id="configBackupNowBtn" onclick="app.backupConfigNow()">Back up now</button>
</div>
<div id="configBackupList" class="set-note" data-i18n-skip>Loading…</div>
</div>
</div>
<div class="set-group">
<div class="set-group-head"><h4>Automation</h4><span class="set-scope">synced</span></div>
<div class="set-group-body">
@@ -3252,6 +3320,23 @@
</div>
<label class="switch switch-sm"><input type="checkbox" id="appSettingsImageWatcherEnabled"><span class="slider"></span></label>
</div>
<div class="set-row" data-search="prompt cache keep warm keep-alive nudge idle expire">
<div class="set-row-text">
<span class="set-row-label">Keep prompt caches warm</span>
<span class="set-row-desc">Default for Claude sessions that have not chosen on their Respawn tab (a session an agent started needs its own switch): shortly before an idle session's prompt cache would expire, send a one-line keep-alive turn so the next prompt still reads the conversation from cache instead of re-processing it.</span>
</div>
<label class="switch switch-sm"><input type="checkbox" id="appSettingsPromptCacheKeepWarm"><span class="slider"></span></label>
</div>
<div class="set-row" data-search="prompt cache keep warm hours limit cap window">
<div class="set-row-text">
<span class="set-row-label">Keep warm for at most</span>
<span class="set-row-desc">Hours after your last prompt in a session during which keep-alives are sent; past that the cache is left to expire. A keep-alive costs a small fraction of the re-write it prevents, so a few hours of them pay off, an overnight gap does not.</span>
</div>
<div class="set-row-actions">
<input type="number" id="appSettingsPromptCacheKeepWarmMaxHours" class="set-num" min="0.5" max="24" step="0.5" value="2">
<span class="set-unit">hours</span>
</div>
</div>
</div>
</div>
+23 -5
View File
@@ -647,6 +647,9 @@ const COMPOSER_INPUT_FRAME_LIMIT = 64 * 1024;
const COMPOSER_PASTE_START = '\x1b[200~';
const COMPOSER_PASTE_END = '\x1b[201~';
// Accessory keys that act on the terminal and leave the keyboard up.
const ACCESSORY_REFOCUS_ACTIONS = new Set(['scroll-up', 'scroll-down', 'arrow-left', 'arrow-right', 'tab', 'shift-tab', 'shift-left', 'shift-right', 'ctrl', 'ctrl-o', 'opt-enter', 'esc', 'effort-max', 'clear-input']);
/**
* KeyboardAccessoryBar - Quick action buttons shown above keyboard when typing.
*/
@@ -802,6 +805,21 @@ const KeyboardAccessoryBar = {
this.syncCodexKeys();
this._syncComposerDraftIndicator();
// A key that keeps the keyboard up must not take focus on press at all.
// Letting the tap blur the terminal and refocusing it in the click handler
// (kept below as the fallback) made every such key start a keyboard
// dismissal: a blur/focus pair per tap. Same pattern as the Attach input
// (image-input.js). A first tap on clear/compact only arms the confirm.
this.element.addEventListener('mousedown', (e) => {
const btn = e.target.closest('.accessory-btn');
if (!btn) return;
const action = btn.dataset.action;
if (ACCESSORY_REFOCUS_ACTIONS.has(action) ||
((action === 'clear' || action === 'compact') && this._confirmAction !== action)) {
e.preventDefault();
}
});
// Add click handlers — preventDefault stops event from reaching terminal
this.element.addEventListener('click', (e) => {
const btn = e.target.closest('.accessory-btn');
@@ -813,8 +831,7 @@ const KeyboardAccessoryBar = {
this.handleAction(action, btn);
// Refocus terminal so keyboard stays open (tap blurs terminal → keyboard dismisses → toolbar shifts)
const refocusActions = new Set(['scroll-up', 'scroll-down', 'arrow-left', 'arrow-right', 'tab', 'shift-tab', 'shift-left', 'shift-right', 'ctrl', 'ctrl-o', 'opt-enter', 'esc', 'effort-max', 'clear-input']);
if (refocusActions.has(action) ||
if (ACCESSORY_REFOCUS_ACTIONS.has(action) ||
((action === 'clear' || action === 'compact') && this._confirmAction)) {
if (typeof app !== 'undefined' && app.terminal) {
app.terminal.focus();
@@ -1415,9 +1432,10 @@ const KeyboardAccessoryBar = {
overlay.querySelector('.prompt-composer-terminal').addEventListener('click', () => {
// Hand the text BACK to the terminal prompt so both surfaces hold the same
// prompt, the mirror of the adoption composePrompt() does on open. Only a
// single-line draft can make the trip: the prompt transport is single-line
// and strips embedded newlines, so a multi-line draft would arrive mangled
// and instead stays parked as a draft (the dot on the Compose key shows it).
// single-line draft can make the trip: the terminal prompt is one line and
// its text reaches the PTY as typed input, where an embedded newline is a
// key, so a multi-line draft would arrive mangled and instead stays parked
// as a draft (the dot on the Compose key shows it).
const text = textarea.value;
if (!text || text.includes('\n')) {
close({ focusTerminal: true });
+48 -16
View File
@@ -9,7 +9,7 @@
* toolbar/accessory bar repositioning, terminal resize on keyboard open/close,
* and input scroll-into-view. Uses 100px threshold for iOS address bar drift.
* - SwipeHandler — Horizontal swipe detection on the terminal area for session switching.
* 80px minimum distance, 300ms maximum time, 100px max vertical drift.
* 80px minimum distance, 500ms maximum time, 50px max vertical drift.
*
* All three have init()/cleanup() lifecycle methods. They are re-initialized after SSE
* reconnect (in handleInit) to prevent stale closures.
@@ -758,14 +758,24 @@ const SwipeHandler = {
startY: 0,
startTime: 0,
minSwipeDistance: 80, // Minimum pixels for a valid swipe
maxSwipeTime: 300, // Maximum ms for a swipe gesture
maxVerticalDrift: 100, // Max vertical movement allowed
// 300ms rejected an unhurried but deliberate swipe outright, which read as an
// unresponsive gesture rather than as "too slow".
maxSwipeTime: 500, // Maximum ms for a swipe gesture
maxVerticalDrift: 50, // Max vertical movement allowed, at any point of the gesture
_touchStartHandler: null,
_touchMoveHandler: null,
_touchEndHandler: null,
_element: null,
_ignoreGesture: false,
/**
* Surfaces inside `.main` that own their own gestures or controls: a swipe
* there must not switch sessions. The phone overview and welcome screen are
* deliberately NOT listed: swiping from home into a session is a feature.
*/
IGNORE_SELECTOR: '.tile-grid, .term-select-bar, #cjkInput, .history-trunc-bar',
/** Initialize swipe handling */
init() {
// Only on touch devices
@@ -776,8 +786,10 @@ const SwipeHandler = {
this._element = terminal;
this._touchStartHandler = (e) => this.onTouchStart(e);
this._touchMoveHandler = (e) => this.onTouchMove(e);
this._touchEndHandler = (e) => this.onTouchEnd(e);
terminal.addEventListener('touchstart', this._touchStartHandler, { passive: true });
terminal.addEventListener('touchmove', this._touchMoveHandler, { passive: true });
terminal.addEventListener('touchend', this._touchEndHandler, { passive: true });
},
@@ -785,9 +797,11 @@ const SwipeHandler = {
cleanup() {
if (this._element && this._touchStartHandler) {
this._element.removeEventListener('touchstart', this._touchStartHandler);
this._element.removeEventListener('touchmove', this._touchMoveHandler);
this._element.removeEventListener('touchend', this._touchEndHandler);
}
this._touchStartHandler = null;
this._touchMoveHandler = null;
this._touchEndHandler = null;
this._element = null;
},
@@ -797,20 +811,43 @@ const SwipeHandler = {
// here. Swiping across the open session drawer — the natural "dismiss it"
// gesture — would otherwise fire nextSession() and drop the user into a
// session they never tapped.
// The terminal's touch handler publishes the axis it locks for a gesture
// that starts on it; a touch that starts elsewhere in `.main` (the home
// screen) must not inherit the last one, or a terminal scroll a moment
// earlier vetoes a swipe from home. Reset before any early return.
if (typeof app !== 'undefined') app._terminalGestureAxis = null;
this._ignoreGesture = !!e.target?.closest?.('.session-sidebar');
if (this._ignoreGesture) return;
if (!e.touches || e.touches.length !== 1) return;
// A second finger makes this a different gesture (pinch, two-finger scroll):
// never measure a later lift against the first finger's start. Stays set
// until the next touchstart, so the first finger's lift cannot clear it.
if (!e.touches || e.touches.length !== 1) {
this._ignoreGesture = true;
return;
}
this._ignoreGesture = !!e.target?.closest?.(this.IGNORE_SELECTOR);
this.startX = e.touches[0].clientX;
this.startY = e.touches[0].clientY;
this.startTime = Date.now();
},
onTouchMove(e) {
if (this._ignoreGesture || !e.touches || e.touches.length !== 1) return;
if (Math.abs(e.touches[0].clientY - this.startY) > this.maxVerticalDrift) this._ignoreGesture = true;
},
onTouchEnd(e) {
if (this._ignoreGesture) {
this._ignoreGesture = false;
return;
}
if (this._ignoreGesture) return;
if (!e.changedTouches || e.changedTouches.length !== 1) return;
if (typeof app === 'undefined') return;
// The terminal's own touch handler (terminal-ui.js) runs first, on the same
// gesture: a drag it locked as a scroll or a pan is not a session swipe,
// and neither is a lift it consumed (its long-press selection gesture
// preventDefaults the touchend, even on blank space where no selection
// survives the lift). A swipe must not extend or drop a touch selection.
if (e.defaultPrevented) return;
if (app._terminalGestureAxis === 'y' || app._terminalGestureAxis === 'x') return;
if (app._touchSelecting || app._touchSelectionActive) return;
const endX = e.changedTouches[0].clientX;
const endY = e.changedTouches[0].clientY;
@@ -824,13 +861,8 @@ const SwipeHandler = {
if (deltaY > this.maxVerticalDrift) return; // Too much vertical movement
if (Math.abs(deltaX) < this.minSwipeDistance) return; // Too short
// Valid swipe detected
if (deltaX > 0) {
// Swipe right -> previous session
if (typeof app !== 'undefined') app.prevSession();
} else {
// Swipe left -> next session
if (typeof app !== 'undefined') app.nextSession();
}
// Valid swipe detected. Swipe right -> previous session, left -> next.
if (deltaX > 0) app.prevSession();
else app.nextSession();
},
};
+13
View File
@@ -2298,6 +2298,19 @@ html.mobile-init .file-browser-panel {
border-right: none;
}
/* Expand and the resize grip would only shrink the full-screen window (96vw x 94vh,
or a px size left by a drag in landscape), so a phone ignores both. */
.file-preview-window,
.file-preview-window.expanded {
width: 100vw !important;
height: var(--app-height, 100vh) !important;
}
#filePreviewExpandBtn,
.file-preview-resize {
display: none;
}
.file-preview-editbar {
padding-bottom: calc(0.4rem + var(--safe-area-bottom));
}
+152 -1
View File
@@ -33,8 +33,11 @@ const FILE_PREVIEW_PREF_KEYS = {
htmlRendered: 'codeman:filePreviewHtmlRendered',
lineNumbers: 'codeman:filePreviewLineNumbers',
wrap: 'codeman:filePreviewWrap',
expanded: 'codeman:filePreviewExpanded',
};
const FILE_PREVIEW_PREF_DEFAULTS = { mdRendered: true, htmlRendered: true, lineNumbers: false, wrap: true };
const FILE_PREVIEW_PREF_DEFAULTS = { mdRendered: true, htmlRendered: true, lineNumbers: false, wrap: true, expanded: false };
// Back/forward list of previews opened from one overlay session (links between documents).
const FILE_PREVIEW_HISTORY_MAX = 50;
const AWAY_DIGEST_SECTIONS = [
['needsAttention', 'Needs Attention'],
['completed', 'Completed'],
@@ -4108,6 +4111,9 @@ Object.assign(CodemanApp.prototype, {
if (!overlay || !bodyEl) return;
this._recordFilePreviewHistory(filePath, sessionId, attachmentId, options);
this._bindFilePreviewChrome();
// Only the newest load may write: paging ←/→ through a folder starts the next
// load while the previous one still awaits its fetch, and its late answer would
// put the previous file's text under the next file's title.
@@ -4133,6 +4139,7 @@ Object.assign(CodemanApp.prototype, {
// Show overlay with loading state
overlay.classList.add('visible');
this._applyFilePreviewExpanded();
titleEl.textContent = filePath;
bodyEl.innerHTML = '<div class="binary-message">Loading...</div>';
footerEl.textContent = '';
@@ -4537,6 +4544,148 @@ Object.assign(CodemanApp.prototype, {
this._openFolderGridFile(next);
},
// ═══════════════════════════════════════════════════════════════
// File Viewer window: back/forward between previews, expand, resize
// ═══════════════════════════════════════════════════════════════
/**
* Remember a preview in the back/forward list. A navigation that came FROM the
* list (stepFilePreviewHistory) is not recorded again, and neither is a reload
* of the file already showing (the MD/Page toggles reopen the same file). Opening
* something new while standing mid-list drops the forward entries, as a browser does.
*/
_recordFilePreviewHistory(filePath, sessionId, attachmentId, options) {
const h = (this.filePreviewHistory = this.filePreviewHistory || { entries: [], index: -1 });
if (!this._filePreviewHistoryStepping) {
const cur = h.entries[h.index];
const same =
cur && cur.filePath === filePath && cur.sessionId === sessionId && (cur.attachmentId || null) === (attachmentId || null);
if (!same) {
h.entries.length = h.index + 1;
h.entries.push({ filePath, sessionId, attachmentId: attachmentId || null, options });
if (h.entries.length > FILE_PREVIEW_HISTORY_MAX) h.entries.shift();
h.index = h.entries.length - 1;
}
}
this._updateFilePreviewHistoryNav();
},
/** Back (-1) / forward (+1) through the previews opened in this overlay session. */
stepFilePreviewHistory(delta) {
const h = this.filePreviewHistory;
if (!h) return;
const target = h.index + delta;
if (target < 0 || target >= h.entries.length) return;
if (this.filePreviewEdit?.dirty && !confirm('Discard unsaved changes?')) return;
h.index = target;
const e = h.entries[target];
// openFilePreview records synchronously, before its first await, so the flag
// only needs to cover the call itself.
this._filePreviewHistoryStepping = true;
try {
// A folder-grid page goes through the grid so index, lastIndex and the highlighted
// tile follow; otherwise the next ‹ › would continue from the newer file.
const folderFile = e.options?.folderFile;
if (folderFile && folderFile.folderId === this.folderGrid?.folderId) {
const index = this.folderGrid.files.findIndex((f) => f.name === folderFile.name);
if (index !== -1) return this._openFolderGridFile(index);
}
return this.openFilePreview(e.filePath, e.sessionId, e.attachmentId, e.options || {});
} finally {
this._filePreviewHistoryStepping = false;
}
},
/** Buttons show once there is somewhere to go; each is disabled at its end of the list. */
_updateFilePreviewHistoryNav() {
const h = this.filePreviewHistory;
const multi = !!h && h.entries.length > 1;
const back = this.$('filePreviewBackBtn');
const fwd = this.$('filePreviewForwardBtn');
if (back) {
back.hidden = !multi;
back.disabled = !multi || h.index <= 0;
}
if (fwd) {
fwd.hidden = !multi;
fwd.disabled = !multi || h.index >= h.entries.length - 1;
}
},
/** Expand button: fill the screen, remembered across opens. */
toggleFilePreviewExpanded() {
this._setFilePreviewPref('expanded', !this._filePreviewPref('expanded'));
this._applyFilePreviewExpanded();
},
_applyFilePreviewExpanded() {
const on = this._filePreviewPref('expanded');
const win = this.$('filePreviewOverlay')?.querySelector?.('.file-preview-window');
win?.classList?.toggle('expanded', on);
// The label stays "Expand" and aria-pressed carries the state, like MD, Lines and
// Wrap, so a screen reader never announces "Restore size, pressed".
this.$('filePreviewExpandBtn')?.setAttribute('aria-pressed', on ? 'true' : 'false');
},
/**
* One-time wiring for the window chrome: Alt+←/→ for back/forward (capture phase
* so the terminal behind the overlay never types the arrow) and the corner grip.
*/
_bindFilePreviewChrome() {
if (this._filePreviewChromeBound) return;
this._filePreviewChromeBound = true;
document.addEventListener(
'keydown',
(ev) => {
if (!ev.altKey || ev.ctrlKey || ev.metaKey || ev.shiftKey) return;
if (ev.key !== 'ArrowLeft' && ev.key !== 'ArrowRight') return;
if (!this.$('filePreviewOverlay')?.classList.contains('visible')) return;
const t = ev.target;
if (t && (t.isContentEditable || (/^(INPUT|TEXTAREA|SELECT)$/.test(t.tagName) && !t.classList?.contains('xterm-helper-textarea')))) return;
// Swallowed even at the ends of the list: otherwise the terminal behind the overlay
// types it as word-left/right, and on a header button the browser's own Alt+← goes Back.
ev.preventDefault();
ev.stopPropagation();
const h = this.filePreviewHistory;
const delta = ev.key === 'ArrowLeft' ? -1 : 1;
if (!h || h.index + delta < 0 || h.index + delta >= h.entries.length) return;
this.stepFilePreviewHistory(delta);
},
true
);
this._bindFilePreviewResize();
},
/** Drag the corner grip to size the window; the window is centred, so the delta counts twice. */
_bindFilePreviewResize() {
const grip = this.$('filePreviewResize');
const win = grip?.parentElement;
if (!grip || !win) return;
grip.addEventListener('pointerdown', (ev) => {
if (ev.button !== 0) return; // a right-click on the grip must not start a resize
ev.preventDefault();
const startX = ev.clientX;
const startY = ev.clientY;
const startW = win.offsetWidth;
const startH = win.offsetHeight;
grip.setPointerCapture?.(ev.pointerId);
const move = (e) => {
const w = Math.max(320, Math.min(window.innerWidth * 0.96, startW + 2 * (e.clientX - startX)));
const h = Math.max(240, Math.min(window.innerHeight * 0.94, startH + 2 * (e.clientY - startY)));
win.style.width = `${Math.round(w)}px`;
win.style.height = `${Math.round(h)}px`;
};
const up = () => {
grip.removeEventListener('pointermove', move);
grip.removeEventListener('pointerup', up);
grip.removeEventListener('pointercancel', up);
};
grip.addEventListener('pointermove', move);
grip.addEventListener('pointerup', up);
grip.addEventListener('pointercancel', up);
});
},
/** "Show folder" in the preview: the grid of the previewed file's folder. */
openFolderOfPreview() {
const target = this.filePreviewFolderTarget;
@@ -4617,6 +4766,8 @@ Object.assign(CodemanApp.prototype, {
// Back on the grid (if one is open underneath): its current tile stays marked.
if (this.folderGrid) this.folderGrid.index = null;
this._updateFolderPreviewNav(null);
this.filePreviewHistory = null;
this._updateFilePreviewHistoryNav();
const overlay = this.$('filePreviewOverlay');
if (overlay) {
overlay.classList.remove('visible');
+193 -5
View File
@@ -1114,11 +1114,33 @@ Object.assign(CodemanApp.prototype, {
* history yet for compaction to trim. Same promise-based pattern as
* `_confirmModelSwap`; `_resolveContextWarningConfirm` settles it.
*/
_confirmContextWarning(modelId, contextLength, minSafeContextTokens) {
_confirmContextWarning(modelId, contextLength, minSafeContextTokens, slotCount) {
const modal = document.getElementById('customModelContextWarningModal');
const messageEl = document.getElementById('customModelContextWarningMessage');
if (messageEl) {
const known = typeof contextLength === 'number';
const slots = Number.isFinite(slotCount) && slotCount > 1 ? Math.floor(slotCount) : 1;
// Enough total context, but split across parallel server slots: llama-server shares its
// context between --parallel slots, and this CLI sends concurrent requests (the main
// session plus each parallel subagent), so several 40k-token requests spend the shared
// pool and the server answers HTTP 500 "Context size has been exceeded".
if (known && slots > 1 && contextLength >= minSafeContextTokens) {
messageEl.textContent =
`${modelId}'s ${contextLength.toLocaleString()} tokens of context are split across ${slots} parallel ` +
`server slots, about ${Math.floor(contextLength / slots).toLocaleString()} each. This CLI ` +
`needs roughly ${minSafeContextTokens.toLocaleString()}+ tokens just for its own system prompt and tools on ` +
`every request, and it sends several requests at once (the main session plus each parallel subagent). ` +
`Concurrent requests can use up the shared context, and the server then fails them with ` +
`"Context size has been exceeded".\n\n` +
`To fix this, start the model with a single slot: add "--parallel 1" to its llama-swap cmd. The active ` +
`conversation then gets the whole context, other requests queue instead of failing, and the CLI's ` +
`repeated prompt prefix stays cached. Alternatively give the model a larger context (raise --fit-ctx or ` +
`--ctx-size) so every slot has at least ${minSafeContextTokens.toLocaleString()} tokens.`;
modal?.classList.add('active');
return new Promise((resolve) => {
this._resolveContextWarningConfirmPromise = resolve;
});
}
messageEl.textContent =
`${modelId} is configured with ` +
(known ? `only ${contextLength.toLocaleString()} tokens of` : 'an unknown (too small)') +
@@ -1129,7 +1151,11 @@ Object.assign(CodemanApp.prototype, {
`instead of relying on auto-fit (--fit-ctx), which optimizes for the biggest MODEL that fits, not the ` +
`biggest CONTEXT — e.g. add "-c 65536" (or as large a --ctx-size as your hardware holds) to its llama-swap ` +
`config entry. A smaller model at a much larger explicit context often fits in the same VRAM a bigger ` +
`model's auto-fit context gets shrunk to make room for.`;
`model's auto-fit context gets shrunk to make room for.` +
(slots > 1
? `\n\nThis server also runs ${slots} parallel slots that share that context; add "--parallel 1" to ` +
`the same cmd so a single conversation gets all of it.`
: '');
}
modal?.classList.add('active');
return new Promise((resolve) => {
@@ -1254,8 +1280,13 @@ Object.assign(CodemanApp.prototype, {
let answered = {};
let data = await post(bodyObj);
if (data?.data?.requiresContextWarning) {
const { modelId, contextLength, minSafeContextTokens } = data.data;
const proceed = await this._confirmContextWarning(modelId, contextLength, minSafeContextTokens);
const { modelId, contextLength, minSafeContextTokens, slotCount } = data.data;
const proceed = await this._confirmContextWarning(
modelId,
contextLength,
minSafeContextTokens,
slotCount
);
if (!proceed) {
this._lastCustomModelLaunchResult = undefined;
return { success: false, error: 'Launch cancelled — context window too small' };
@@ -1371,7 +1402,8 @@ Object.assign(CodemanApp.prototype, {
const proceed = await this._confirmContextWarning(
payload.modelId,
payload.contextLength,
payload.minSafeContextTokens
payload.minSafeContextTokens,
payload.slotCount
);
if (!proceed) {
switchingToast?.dismiss();
@@ -2631,6 +2663,10 @@ Object.assign(CodemanApp.prototype, {
document.getElementById('modalAutoResumeEnabled').checked = session.autoResumeEnabled ?? false;
this.updateAutoResumeStatus(sessionId);
this.updatePromptCacheStatus(sessionId);
// Effective opt-in: the server publishes it in the status (session flag, else the app default).
document.getElementById('modalKeepWarmEnabled').checked =
session.keepWarm?.enabled ?? session.keepWarmEnabled ?? false;
this.updateKeepWarmStatus(sessionId);
document.getElementById('modalImageWatcherEnabled').checked = session.imageWatcherEnabled ?? true;
document.getElementById('modalFlickerFilterEnabled').checked = session.flickerFilterEnabled ?? false;
@@ -2915,6 +2951,59 @@ Object.assign(CodemanApp.prototype, {
this.updatePromptCacheStatus(sessionId);
},
/**
* Session Options → Respawn → Prompt cache: the keep-warm line on the Status row (what the
* keep-warm service is doing, `session.keepWarm`, worded by `CodemanPromptCache.formatKeepWarm`,
* hidden when it has nothing to say) and the state of the "Keep the cache warm while idle" switch.
*/
updateKeepWarmStatus(sessionId) {
const el = document.getElementById('keepWarmStatus');
if (!el || this.editingSessionId !== sessionId) return;
const session = this.sessions.get(sessionId);
const status = session?.keepWarm;
const text = window.CodemanPromptCache.formatKeepWarm(status, Date.now(), session?.statusLineExporter);
el.textContent = text;
el.hidden = !text;
// A launch without cache reports can never keep warm: grey the row and refuse to switch it on,
// but a switch that is already on stays reachable so it can be turned off. Same inert-switch
// pattern as the context switch in settings-ui.js _applyModelSelection().
const box = document.getElementById('modalKeepWarmEnabled');
if (!box) return;
const inert = window.CodemanPromptCache.keepWarmUnavailable(session?.statusLineExporter);
box.closest('.set-row')?.classList.toggle('set-row-disabled', inert);
box.disabled = inert && !box.checked;
},
_onSessionKeepWarm(data) {
const { sessionId, ...status } = data;
const session = this.sessions.get(sessionId);
if (!session) return;
session.keepWarm = status;
if (this.editingSessionId === sessionId) {
const box = document.getElementById('modalKeepWarmEnabled');
if (box) box.checked = status.enabled;
}
this.updateKeepWarmStatus(sessionId);
},
async autoSaveKeepWarm() {
if (!this.editingSessionId) return;
const enabled = document.getElementById('modalKeepWarmEnabled').checked;
try {
// _apiPost unwraps the envelope; the route answers with the status for the NEW choice.
const data = await this._apiPost(`/api/sessions/${this.editingSessionId}/keep-warm`, { enabled });
const session = this.sessions.get(this.editingSessionId);
if (session) {
session.keepWarmEnabled = enabled;
if (data?.keepWarm?.status) session.keepWarm = data.keepWarm.status;
}
this.updateKeepWarmStatus(this.editingSessionId);
this.showToast(`Prompt-cache keep-warm ${enabled ? 'enabled' : 'disabled'}`, 'success');
} catch (err) {
this.showToast('Failed to toggle keep-warm: ' + err.message, 'error');
}
},
async toggleSessionImageWatcher() {
if (!this.editingSessionId) return;
const enabled = document.getElementById('modalImageWatcherEnabled').checked;
@@ -3272,6 +3361,7 @@ Object.assign(CodemanApp.prototype, {
document.getElementById('caseAgentTeams').checked = settings.agentTeams;
document.getElementById('caseOpusContext1m').checked = settings.opusContext1m;
popover.classList.remove('hidden');
this._loadCaseDefaultGroup();
// Close on outside click (one-shot listener)
const closeHandler = (e) => {
@@ -3320,6 +3410,7 @@ Object.assign(CodemanApp.prototype, {
document.getElementById('caseAgentTeamsMobile').checked = settings.agentTeams;
document.getElementById('caseOpusContext1mMobile').checked = settings.opusContext1m;
popover.classList.remove('hidden');
this._loadCaseDefaultGroup();
const closeHandler = (e) => {
if (!popover.contains(e.target) && !e.target.classList.contains('btn-case-settings-mobile')) {
@@ -3346,6 +3437,103 @@ Object.assign(CodemanApp.prototype, {
if (desktopOpusCheckbox) desktopOpusCheckbox.checked = settings.opusContext1m;
},
// ── Default session group (server-side, per owner + case) ──
//
// Unlike the two checkboxes above (this browser's localStorage), the default
// group lives on the server, because the server places a new session in it in
// the same layout write that registers it. The value is a group ID; '' in the
// <select> is Ungrouped (null on the wire). Desktop and phone popovers mirror.
_caseDefaultGroupSelects() {
return ['caseDefaultGroup', 'caseDefaultGroupMobile']
.map((id) => document.getElementById(id))
.filter(Boolean);
},
/** Rebuild both selectors from `groups`, keeping `groupId` if it still exists. */
_renderCaseDefaultGroupSelects(groups, groupId) {
const known = groups.some((group) => group.id === groupId);
const value = known ? groupId : '';
for (const select of this._caseDefaultGroupSelects()) {
const ungrouped = document.createElement('option');
ungrouped.value = '';
ungrouped.textContent = 'Ungrouped';
const options = groups.map((group) => {
const option = document.createElement('option');
option.value = group.id;
option.textContent = group.name;
option.setAttribute('data-i18n-skip', '');
return option;
});
select.replaceChildren(ungrouped, ...options);
select.value = value;
}
return value;
},
/** Read the selected case's default group from the server (popover open). */
async _loadCaseDefaultGroup() {
const caseName = document.getElementById('quickStartCase')?.value;
const selects = this._caseDefaultGroupSelects();
if (!caseName || selects.length === 0) return;
for (const select of selects) select.disabled = true;
const data = await this._apiJson(`/api/cases/${encodeURIComponent(caseName)}/default-group`);
if (document.getElementById('quickStartCase')?.value !== caseName) return; // case switched meanwhile
const groups = Array.isArray(data?.groups) ? data.groups : [];
const groupId = typeof data?.groupId === 'string' ? data.groupId : null;
this._caseDefaultGroupState = { caseName, groupId, groups };
this._renderCaseDefaultGroupSelects(groups, groupId);
for (const select of selects) select.disabled = !data;
},
/** Save a selector change; on refusal restore the last confirmed value. */
async onCaseDefaultGroupChanged(select) {
const state = this._caseDefaultGroupState;
if (!state) return;
const groupId = select.value || null;
const selects = this._caseDefaultGroupSelects();
for (const other of selects) {
other.value = select.value;
other.disabled = true;
}
const res = await this._apiPut(`/api/cases/${encodeURIComponent(state.caseName)}/default-group`, { groupId });
let saved = null;
if (res?.ok) {
try {
saved = (await res.json())?.data ?? null;
} catch {
saved = null;
}
}
if (saved) {
state.groupId = typeof saved.groupId === 'string' ? saved.groupId : null;
if (Array.isArray(saved.groups)) state.groups = saved.groups;
} else {
this.showToast?.('Could not save the default session group.', 'error');
}
this._renderCaseDefaultGroupSelects(state.groups, state.groupId);
for (const other of selects) other.disabled = false;
},
/**
* The tab layout changed (a group was created, renamed or deleted): redraw the
* options from it. A deleted selection shows as Ungrouped, which is where the
* server now places new sessions (it forgets the stale ID at the next creation).
* Every create, delete and reorder broadcasts a layout, so an unchanged group
* list is left alone: rebuilding it would reset a dropdown the user has open.
*/
_refreshCaseDefaultGroupOptions(layout) {
const state = this._caseDefaultGroupState;
if (!state || !layout || !Array.isArray(layout.groups)) return;
const groups = layout.groups.map((group) => ({ id: group.id, name: group.name }));
const unchanged = groups.length === state.groups.length
&& groups.every((group, i) => group.id === state.groups[i].id && group.name === state.groups[i].name);
if (unchanged) return;
state.groups = groups;
const value = this._renderCaseDefaultGroupSelects(state.groups, state.groupId);
if (!value) state.groupId = null;
},
// ═══════════════════════════════════════════════════════════════
// Create Case Modal
// ═══════════════════════════════════════════════════════════════
+191 -3
View File
@@ -72,6 +72,10 @@ Object.assign(CodemanApp.prototype, {
},
_onHookStop(data) {
// The end of Codeman's own keep-alive turn (prompt-cache keep-warm): nothing finished
// that a person asked for, and an idle alert still pending is theirs to spend, so this
// neither clears it nor raises "Response Complete".
if (data.acknowledgedReason) return;
// Clear all pending hooks when Claude finishes responding
if (data.sessionId) {
this.clearPendingHooks(data.sessionId);
@@ -551,6 +555,12 @@ Object.assign(CodemanApp.prototype, {
this._mcpSyncSavedOn = settings.mcpSyncEnabled === true;
document.getElementById('appSettingsMcpSync').checked = this._mcpSyncSavedOn;
this.applyMcpSyncVisibility();
// Config backups: absent means ON, 20 copies, 30 days.
document.getElementById('appSettingsConfigBackup').checked = settings.configBackupEnabled !== false;
document.getElementById('appSettingsConfigBackupDir').value = settings.configBackupDir || '';
document.getElementById('appSettingsConfigBackupKeepCount').value = settings.configBackupKeepCount ?? 20;
document.getElementById('appSettingsConfigBackupKeepDays').value = settings.configBackupKeepDays ?? 30;
this.loadConfigBackups();
this._applyDoctorAdminGate();
this.loadWebhook();
// Read My Mind: synced, default OFF (opt-in; capture + prediction cost real tokens).
@@ -597,6 +607,9 @@ Object.assign(CodemanApp.prototype, {
document.getElementById('appSettingsSubagentTracking').checked = settings.subagentTrackingEnabled ?? defaults.subagentTrackingEnabled ?? true;
document.getElementById('appSettingsSubagentActiveTabOnly').checked = settings.subagentActiveTabOnly ?? defaults.subagentActiveTabOnly ?? true;
document.getElementById('appSettingsImageWatcherEnabled').checked = settings.imageWatcherEnabled ?? defaults.imageWatcherEnabled ?? false;
// Prompt-cache keep-warm: synced, default OFF, cap default 2 h (DEFAULT_KEEP_WARM_MAX_HOURS).
document.getElementById('appSettingsPromptCacheKeepWarm').checked = settings.promptCacheKeepWarm === true;
document.getElementById('appSettingsPromptCacheKeepWarmMaxHours').value = settings.promptCacheKeepWarmMaxHours ?? 2;
document.getElementById('appSettingsTunnelEnabled').checked = settings.tunnelEnabled ?? false;
this.loadTunnelStatus();
document.getElementById('appSettingsLocalEcho').checked = settings.localEchoEnabled ?? MobileDetection.isTouchDevice();
@@ -1358,10 +1371,178 @@ Object.assign(CodemanApp.prototype, {
* wired to `codeman:me` for the same late-resolving role as the groups above.
*/
_applyDoctorAdminGate() {
const group = document.getElementById('doctorGroup');
if (!group) return;
const me = window.__codemanUser || {};
group.style.display = me.multiUser && me.role !== 'admin' ? 'none' : '';
for (const id of ['doctorGroup', 'configBackupGroup']) {
const group = document.getElementById(id);
if (group) group.style.display = me.multiUser && me.role !== 'admin' ? 'none' : '';
}
},
// ── Config backups ──────────────────────────────────────────────────────────
// Server-side files, so these read the SAVED settings: a folder or retention edit applies after Save.
/**
* The retention keys a Save sends: a value only once it was saved before or edited here. After a reset
* the form merely DISPLAYS what the passes use (the retention the backups recorded), and writing that back
* would replace it with whatever a different folder or an older default says.
*/
_configBackupRetentionPayload(prev) {
const count = document.getElementById('appSettingsConfigBackupKeepCount');
const days = document.getElementById('appSettingsConfigBackupKeepDays');
const out = {};
if (prev.configBackupKeepCount !== undefined || count.dataset.touched) {
out.configBackupKeepCount = Math.min(500, Math.max(2, parseInt(count.value, 10) || 20));
}
if (prev.configBackupKeepDays !== undefined || days.dataset.touched) {
const n = parseInt(days.value, 10);
out.configBackupKeepDays = Math.min(3650, Math.max(0, Number.isNaN(n) ? 30 : n));
}
return out;
},
/** Fill the backup list, and show the default folder as the input's placeholder. */
async loadConfigBackups() {
const out = this.$('configBackupList');
if (!out) return;
const res = await this._api('/api/config-backups');
let body = null;
try { body = res ? await res.json() : null; } catch { /* fall through */ }
if (!res || !res.ok || !body || body.success === false) {
out.textContent = body?.error || 'Backups are not available here (admin only in multi-user mode).';
return;
}
const data = body.data;
const dirInput = this.$('appSettingsConfigBackupDir') || document.getElementById('appSettingsConfigBackupDir');
if (dirInput && data.defaultDir) dirInput.placeholder = `default: ${data.defaultDir}`;
// A reset settings.json has none of the four keys, but the passes keep the retention the newest backup
// recorded: show that, or a Save would write 20 / 30 over it.
const saved = this.loadAppSettingsFromStorage();
const countInput = document.getElementById('appSettingsConfigBackupKeepCount');
const daysInput = document.getElementById('appSettingsConfigBackupKeepDays');
if (countInput && saved.configBackupKeepCount === undefined && data.settings) countInput.value = data.settings.keepCount;
if (daysInput && saved.configBackupKeepDays === undefined && data.settings) daysInput.value = data.settings.keepDays;
if (!data.backups.length) {
out.innerHTML = `No backups yet in <code>${escapeHtml(data.dir)}</code>.`;
return;
}
const kb = (n) => (n < 1024 ? `${n} B` : `${(n / 1024).toFixed(1)} KB`);
this._configBackups = data.backups;
const rows = data.backups.map((b, i) => {
const when = new Date(b.createdAt).toLocaleString();
const names = b.files.map((f) => escapeHtml(f.name)).join(', ');
const id = escapeHtml(b.id);
// The list is newest first, so the next row is the backup this one followed.
const prev = data.backups[i + 1];
const vsPrev = prev ? `<button class="btn-toolbar btn-sm" onclick="app.showConfigDiff('${escapeHtml(prev.id)}','${id}')">Changes in this backup</button>` : '';
return `<li><b>${escapeHtml(when)}</b> <span class="set-row-desc">(${escapeHtml(b.reason)}, ${kb(b.bytes)})</span>
<button class="btn-toolbar btn-sm" onclick="app.restoreConfigBackup('${id}')">Restore</button>
<button class="btn-toolbar btn-sm" onclick="app.showConfigDiff('${id}','current')">Compare with current</button>
${vsPrev}
<div class="set-row-desc">${names}</div></li>`;
});
const options = (sel) => [`<option value="current"${sel === 'current' ? ' selected' : ''}>Current config</option>`]
.concat(data.backups.map((b) => `<option value="${escapeHtml(b.id)}"${sel === b.id ? ' selected' : ''}>${escapeHtml(new Date(b.createdAt).toLocaleString())} (${escapeHtml(b.reason)})</option>`))
.join('');
const picker = data.backups.length
? `<div class="set-row-desc">Compare <select id="configDiffFrom">${options(data.backups[0].id)}</select> with <select id="configDiffTo">${options('current')}</select>
<button class="btn-toolbar btn-sm" onclick="app.showConfigDiff(app.$('configDiffFrom').value, app.$('configDiffTo').value)">Show changes</button></div>`
: '';
out.innerHTML = `<div>${data.backups.length} backup${data.backups.length === 1 ? '' : 's'} in <code>${escapeHtml(data.dir)}</code></div>${picker}<ul>${rows.join('')}</ul><div id="configBackupDiff"></div>`;
},
/** What differs between two states of the config files: a backup id or `current` (the running config). */
async showConfigDiff(from, to) {
const out = this.$('configBackupDiff');
if (!out) return;
out.textContent = 'Comparing…';
const res = await this._api(`/api/config-backups/diff?from=${encodeURIComponent(from)}&to=${encodeURIComponent(to)}`);
let body = null;
try { body = res ? await res.json() : null; } catch { /* fall through */ }
if (!res || !res.ok || !body || body.success === false) {
out.textContent = body?.error || 'Could not compare these';
return;
}
const label = (id) => {
if (id === 'current') return 'the current config';
const b = (this._configBackups || []).find((x) => x.id === id);
return b ? new Date(b.createdAt).toLocaleString() : id;
};
const d = body.data;
const verb = { added: 'added', removed: 'removed', changed: 'changed' };
const files = d.files.filter((f) => f.status !== 'unchanged').map((f) => {
let detail;
if (f.detail === 'bytes') {
detail = '<div class="set-row-desc">Differs (not a key/value file, so no detail).</div>';
} else {
const rows = f.changes.map((c) => {
const val = (v) => `<code>${escapeHtml(v ?? '')}</code>`;
const text = c.redacted ? `${verb[c.kind]} <span class="set-row-desc">(value hidden: credential)</span>`
: c.kind === 'added' ? `added ${val(c.after)}`
: c.kind === 'removed' ? `removed (was ${val(c.before)})`
: `${val(c.before)} → ${val(c.after)}`;
return `<li><code>${escapeHtml(c.path)}</code>: ${text}</li>`;
});
const more = f.truncated ? '<li class="set-row-desc">More changes not shown.</li>' : '';
detail = f.changes.length ? `<ul>${rows.join('')}${more}</ul>` : '';
}
const whole = f.status === 'added' ? ' (new file)' : f.status === 'removed' ? ' (file gone)' : '';
return `<div><b>${escapeHtml(f.name)}</b>${whole}${detail}</div>`;
});
const same = d.files.filter((f) => f.status === 'unchanged').map((f) => escapeHtml(f.name));
out.innerHTML = `<div><b>${escapeHtml(label(d.from))}</b> → <b>${escapeHtml(label(d.to))}</b>: ${
d.changed ? `${d.changed} file${d.changed === 1 ? '' : 's'} differ` : 'no differences'}</div>${files.join('')}${
same.length ? `<div class="set-row-desc">Unchanged: ${same.join(', ')}</div>` : ''}`;
},
async backupConfigNow() {
const btn = this.$('configBackupNowBtn');
if (btn) btn.disabled = true;
try {
const res = await this._apiPost('/api/config-backups', {});
let body = null;
try { body = res ? await res.json() : null; } catch { /* fall through */ }
if (!res || !res.ok || !body || body.success === false) {
this.showToast(body?.error || 'Backup failed', 'error');
} else if (body.data.status === 'empty') {
this.showToast('Nothing to back up yet', 'warning');
} else {
this.showToast(`Backed up ${body.data.files} file${body.data.files === 1 ? '' : 's'}`, 'success');
}
} finally {
if (btn) btn.disabled = false;
this.loadConfigBackups();
}
},
async restoreConfigBackup(id) {
const names = ((this._configBackups || []).find((b) => b.id === id)?.files || []).map((f) => f.name);
const secret = names.filter((n) => ['.env', 'users.json', 'push-keys.json', 'custom-model-hosts.json', 'webhook.json'].includes(n));
const list = names.length ? `\n\nFiles replaced: ${names.join(', ')}` : '';
const warn = secret.length ? `\n\nThis rolls back credentials and accounts (${secret.join(', ')}): passwords, disabled accounts and keys return to how they were then.` : '';
if (!confirm(`Restore this backup? Its files replace the current ones (a copy of the current files is made first). Some files only take effect after a restart.${list}${warn}`)) return;
const res = await this._apiPost(`/api/config-backups/${encodeURIComponent(id)}/restore`, {});
let body = null;
try { body = res ? await res.json() : null; } catch { /* fall through */ }
const ok = res && res.ok && body && body.success !== false;
const d = body?.data;
// A restore that failed partway still answers with what it wrote (`restored`, `failed`, `safetySnapshot`):
// those files are the backup's now and need the same refill as a full restore.
if (!ok && !Array.isArray(d?.restored)) {
this.showToast(body?.error || 'Restore failed', 'error');
return;
}
const restart = d.restartRequired?.length ? ` Restart Codeman to apply: ${d.restartRequired.join(', ')}.` : '';
// The form still holds the pre-restore values, and Save would PUT them over the restored file.
if (d.restored.includes('settings.json')) {
await this.loadAppSettingsFromServer();
this.openAppSettings();
}
if (ok) {
this.showToast(`Restored ${d.restored.length} file${d.restored.length === 1 ? '' : 's'}.${restart}`, 'success');
} else {
this.showToast(`${body.error || 'Restore failed partway'}.${restart}`, 'error');
}
this.loadConfigBackups();
},
/** Preview (apply=false) or run (apply=true) the MCP server sync across enabled CLIs. */
@@ -2737,6 +2918,9 @@ Object.assign(CodemanApp.prototype, {
customModelEndpointsEnabled: document.getElementById('appSettingsCustomModelEndpoints').checked,
cliManagementEnabled: document.getElementById('appSettingsCliManagement').checked,
mcpSyncEnabled: document.getElementById('appSettingsMcpSync').checked,
configBackupEnabled: document.getElementById('appSettingsConfigBackup').checked,
configBackupDir: document.getElementById('appSettingsConfigBackupDir').value.trim(),
...this._configBackupRetentionPayload(_prev),
readMyMindEnabled: document.getElementById('appSettingsReadMyMind').checked,
ultracodeFloatingWindows: document.getElementById('appSettingsUltracodeFloatingWindows').checked,
showMultiMonitorButton: document.getElementById('appSettingsShowMultiMonitorButton').checked,
@@ -2760,6 +2944,10 @@ Object.assign(CodemanApp.prototype, {
subagentTrackingEnabled: document.getElementById('appSettingsSubagentTracking').checked,
subagentActiveTabOnly: document.getElementById('appSettingsSubagentActiveTabOnly').checked,
imageWatcherEnabled: document.getElementById('appSettingsImageWatcherEnabled').checked,
promptCacheKeepWarm: document.getElementById('appSettingsPromptCacheKeepWarm').checked,
promptCacheKeepWarmMaxHours:
// Clamped to the schema's range: one value outside it fails the whole save.
Math.min(24, Math.max(0.5, parseFloat(document.getElementById('appSettingsPromptCacheKeepWarmMaxHours').value) || 2)),
tunnelEnabled: document.getElementById('appSettingsTunnelEnabled').checked,
localEchoEnabled: document.getElementById('appSettingsLocalEcho').checked,
autoCopySelection: document.getElementById('appSettingsAutoCopySelection').checked,
+68 -4
View File
@@ -6669,6 +6669,27 @@ html[data-skin="og"] .welcome-primary:hover {
display: none;
}
/* Default session group selector (Case settings, desktop and phone popovers) */
.case-default-group-label {
display: block;
margin-top: 8px;
font-size: 0.75rem;
color: var(--text);
}
.case-default-group-select {
display: block;
width: 100%;
margin-top: 4px;
padding: 3px 6px;
font: inherit;
font-size: 0.75rem;
color: var(--text);
background: var(--bg-input, var(--bg-card));
border: 1px solid var(--control-border);
border-radius: 6px;
}
.case-settings-popover .form-hint {
margin-top: 0.2rem;
font-size: 0.6rem;
@@ -12223,10 +12244,15 @@ html:is([data-skin="paper-gray"], [data-skin="solarized-light"], [data-skin="cat
}
.file-preview-window {
width: 80vw;
max-width: 900px;
height: 80vh;
max-height: 700px;
position: relative;
width: min(80vw, 900px);
height: min(80vh, 700px);
/* A dragged size (inline width/height) and the expanded state may exceed the
default 900x700, but never the screen. */
max-width: 96vw;
max-height: 94vh;
min-width: min(320px, 96vw);
min-height: min(240px, 94vh);
background: var(--floating-bg);
backdrop-filter: blur(20px);
-webkit-backdrop-filter: blur(20px);
@@ -12238,6 +12264,36 @@ html:is([data-skin="paper-gray"], [data-skin="solarized-light"], [data-skin="cat
overflow: hidden;
}
/* Expanded: fills the screen (a dragged size is ignored while it is on). */
.file-preview-window.expanded {
width: 96vw !important;
height: 94vh !important;
}
/* Corner grip (panels-ui.js _bindFilePreviewResize). The window is centred, so a
drag moves both edges: the handler doubles the pointer delta to keep the corner
under the pointer. Only phones hide it (with Expand, mobile.css under 600px), since
the window is full screen there; tablets show and use it. */
.file-preview-resize {
position: absolute;
right: 0;
bottom: 0;
width: 16px;
height: 16px;
cursor: nwse-resize;
touch-action: none;
background: linear-gradient(135deg, transparent 50%, var(--text-muted, #888) 50%, var(--text-muted, #888) 58%, transparent 58%, transparent 72%, var(--text-muted, #888) 72%, var(--text-muted, #888) 80%, transparent 80%);
opacity: 0.6;
}
.file-preview-resize:hover {
opacity: 1;
}
.file-preview-window.expanded .file-preview-resize {
display: none;
}
.file-preview-header {
display: flex;
align-items: center;
@@ -14926,6 +14982,14 @@ html:is([data-skin="paper-gray"], [data-skin="solarized-light"], [data-skin="cat
cursor: pointer;
}
/* A markdown link whose target is a path (`[Our incident record](/tmp/x.json)`)
takes the rv-path shape for the click delegate, but its text is a label, not a
path: keep it in the prose font and never break it mid-word. */
.rv-text a.rv-path.rv-path-link {
font: inherit;
word-break: normal;
}
/* Tables — scroll wrapper keeps table proper while allowing horizontal overflow */
.rv-table-wrap {
margin: 1em 0;
+41 -22
View File
@@ -18,13 +18,14 @@
* What it does carry over from the primary pane, through the primary pane's
* own code aimed at THIS pane (its terminal, its session, never the active
* one):
* - SGR wheel forwarding (_maybeForwardWheelToCli): Claude's fullscreen
* renderer scrolls its own transcript on SGR wheel reports, while this
* xterm holds only replayed repaint frames, so the wheel goes to the CLI
* as reports at the pointer's cell in this pane, through the primary
* pane's forwarding gate and its encoding (sgrWheelReports). Shift+wheel
* scrolls the local scrollback itself (_maybeScrollLocalOnShift), as the
* primary pane does, since xterm turns it into a horizontal no-op.
* - SGR wheel forwarding (_maybeForwardWheelToCli): a tracked CLI
* (fullscreen Claude, tracked Codex) scrolls its own transcript on SGR
* wheel reports, while this xterm holds only replayed repaint frames, so
* the wheel goes to the CLI as reports at the pointer's cell in this pane,
* through the primary pane's forwarding gate and its encoding
* (sgrWheelReports). Shift+wheel scrolls the local scrollback itself
* (_maybeScrollLocalOnShift), as the primary pane does, since xterm turns
* it into a horizontal no-op.
* - Hollow-buffer paging (#555): a CLI that draws in place (opencode and codex
* on the alternate screen, Claude's repaint mode) leaves the xterm no
* scrollback, so the wheel pages the CLI's own transcript with
@@ -51,7 +52,7 @@
*
* @dependency vendor/xterm.js, vendor/xterm-addon-fit.js
* @dependency constants.js (window.CodemanTerminalFont, window.CodemanFetchDeadline, DEFAULT_SCROLLBACK, TERMINAL_TAIL_SIZE, SHELL_LOAD_BASE_QUERY, SHELL_LOAD_QUERY, TERMINAL_CHUNK_SIZE)
* @dependency terminal-ui.js (codemanCurrentXtermTheme, codemanCurrentSkinIsLight, CodemanTerminalInput.shouldSuppressTerminalQueryResponse/isTerminalFocusOrMouseReport/wheelDeltaLines/wheelDeltaWholeLines/sgrWheelReports/pageKeysForGesture, app._shouldForwardWheelToApp/_localScrollbackIsHollow/_terminalViewportAtBottom/_clientPointToCell/_handleDesktopTerminalClick, app.tabAlerts)
* @dependency terminal-ui.js (codemanCurrentXtermTheme, codemanCurrentSkinIsLight, CodemanTerminalInput.shouldSuppressTerminalQueryResponse/isTerminalFocusOrMouseReport/wheelDeltaLines/wheelTicksForGesture/coalesceScrollBytes/sgrWheelReports/pageKeysForGesture, app._shouldForwardWheelToApp/_localScrollbackIsHollow/_terminalViewportAtBottom/_clientPointToCell/_handleDesktopTerminalClick, app.tabAlerts)
* @dependency terminal-keycode229-recovery.js (window.CodemanKeyCode229Recovery, optional: absent, xterm's own textarea handling stands)
* @loadorder 7.4 of 16, loaded after terminal-ui.js and before terminal-split.js
*/
@@ -229,6 +230,7 @@
}
async connect() {
this._wheelNativeStarted = false;
const savedFontSize = this.fontSize ?? parseInt(localStorage.getItem('codeman-font-size'), 10);
this.terminal = new Terminal({
theme: { ...global.codemanCurrentXtermTheme() },
@@ -1051,7 +1053,7 @@
// Capture phase, because xterm's own wheel handler stopPropagation()s every
// event it consumes, so a bubbling listener here would never see the wheel
// while the pane still has scrollback to scroll. Not passive: the three
// routes this pane takes over, forwarding the wheel to Claude's fullscreen
// routes this pane takes over, forwarding the wheel to a tracked CLI's
// renderer (_maybeForwardWheelToCli), paging a hollow buffer's CLI
// transcript (_maybePageCliTranscript) and Shift+wheel's local scrollback
// (_maybeScrollLocalOnShift), are consumed right here (preventDefault plus
@@ -1073,11 +1075,11 @@
// SGR wheel forwarding, the twin of the primary pane's capture-phase wheel
// handler and _forwardScrollToApp (terminal-ui.js; keep them in step).
// Claude's fullscreen renderer (claude 2.1.187+ while its mouse tracking is
// on, cliMouseTracking) scrolls its own transcript on SGR wheel reports,
// Fullscreen Claude (2.1.187+) and measured tracked Codex scroll their own
// transcripts on SGR wheel reports while cliMouseTracking is on,
// while this xterm holds only Codeman's replayed repaint frames (tmux keeps
// no history for such a pane). Left to xterm, the wheel dragged those stale
// frames, Claude's pinned input box with them, up the tile, or scrolled
// frames, including the CLI's pinned input box, up the tile, or scrolled
// nothing at all. The gate is the primary pane's own, asked for THIS pane
// (its terminal, its session, never the active one), so the CLI rules stay
// in terminal-ui.js and this file names no CLI; the reports go to this
@@ -1090,19 +1092,24 @@
if (this._destroyed || !this.terminal || !ev) return false;
const app = global.app;
const input = global.CodemanTerminalInput;
if (!app?._shouldForwardWheelToApp || !input?.sgrWheelReports || !input.wheelDeltaWholeLines) return false;
if (!app?._shouldForwardWheelToApp || !input?.sgrWheelReports || !input.wheelTicksForGesture) return false;
// xterm's own encoder forwards the wheel while the CLI's tracking reaches
// it, and its alt-scroll owns the alternate buffer, as in the primary pane.
const tracking = this.terminal.modes?.mouseTrackingMode;
if (tracking && tracking !== 'none') return false;
if (this.terminal.buffer?.active?.type === 'alternate') return false;
if (!app._shouldForwardWheelToApp(ev, { terminal: this.terminal, sessionId: this.sessionId })) return false;
if (ev.ctrlKey || Math.abs(ev.deltaX || 0) > Math.abs(ev.deltaY || 0)) return true;
// SGR coordinates address the live screen, so a report from a scrolled-up
// viewport would hit-test another row: snap home first (_forwardScrollToApp).
if (!app._terminalViewportAtBottom?.(this.terminal)) this.terminal.scrollToBottom?.();
const lines = input.wheelDeltaWholeLines(ev, this.terminal.rows);
const lines = input.wheelDeltaLines(ev, this.terminal.rows);
if (!lines) return true;
if (this._wheelTickGesture?.dir && this._wheelTickGesture.dir !== Math.sign(lines)) this._scrollBytes = '';
const step = input.wheelTicksForGesture(this._wheelTickGesture, lines, performance.now());
this._wheelTickGesture = step.state;
const pos = app._clientPointToCell?.(ev.clientX, ev.clientY, this.terminal);
const bytes = input.sgrWheelReports(lines, pos);
const bytes = input.sgrWheelReports(step.ticks, pos);
if (bytes) this._queueScrollBytes(bytes);
return true;
}
@@ -1180,9 +1187,9 @@
const tracking = this.terminal.modes?.mouseTrackingMode;
if (tracking && tracking !== 'none') return false;
const target = { terminal: this.terminal, sessionId: this.sessionId };
// A wheel for Claude's fullscreen renderer was forwarded as SGR reports
// before this ran (_maybeForwardWheelToCli); the gate is repeated so a
// forwarding session is never paged.
// A wheel for any tracked CLI (a session the forwarding gate accepts) was
// forwarded as SGR reports before this ran (_maybeForwardWheelToCli); the
// gate is repeated so a forwarding session is never paged.
if (app._shouldForwardWheelToApp?.(ev, target)) return false;
if (!app._localScrollbackIsHollow?.({ ...target, localRows: this._localRows() })) return false;
// Only from the live screen. The one gate the primary pane never needs: a
@@ -1202,6 +1209,8 @@
// session, which is usually not the active one: a page key would move its
// selector. Consumed, so xterm does not scroll the rows under it either.
if (app.tabAlerts?.get(this.sessionId) === 'action') return true;
if (ev.ctrlKey || Math.abs(ev.deltaX || 0) > Math.abs(ev.deltaY || 0)) return true;
if (this._pageKeyGesture?.dir && this._pageKeyGesture.dir !== Math.sign(lines)) this._scrollBytes = '';
const step = input.pageKeysForGesture(this._pageKeyGesture, ev, lines, this.terminal.rows, performance.now());
this._pageKeyGesture = step.state;
if (step.keys) this._queueScrollBytes(step.keys);
@@ -1209,16 +1218,26 @@
}
// Coalesces the scroll bytes (SGR wheel reports and page keys alike) into
// one send per 40 ms, bounded at 512 bytes so a fling cannot build a backlog
// that keeps scrolling after it stops. A narrow
// one send per 40 ms after the leading native report, preserving upstream
// report density and cancelling only unsent movement on reversal. A narrow
// twin of the primary pane's _queueScrollBytes / _flushWheelSgrQueue
// (terminal-ui.js; keep the two in step), which flushes to the active
// session only. Sent ephemeral (no seq, never persisted) to THIS pane's
// session, over this pane's socket while it is open.
_queueScrollBytes(data) {
if (!data || this._destroyed) return;
if (this._scrollBytes.length > 512) return;
this._scrollBytes += data;
this._scrollBytes = global.CodemanTerminalInput.coalesceScrollBytes(this._scrollBytes, data);
// Tiles have a fixed session: the leading report is once per connect(),
// unlike the primary pane, which resets it on every tab selection.
if (data.startsWith('\x1b[<') && !this._wheelNativeStarted) {
this._wheelNativeStarted = true;
if (this._scrollFlushTimer) clearTimeout(this._scrollFlushTimer);
this._scrollFlushTimer = null;
const bytes = this._scrollBytes;
this._scrollBytes = '';
global.app?._sendInputEphemeral?.(this.sessionId, bytes);
return;
}
if (this._scrollFlushTimer) return;
this._scrollFlushTimer = setTimeout(() => {
this._scrollFlushTimer = null;
+147 -84
View File
@@ -121,17 +121,7 @@
: delta / 25; // DOM_DELTA_PIXEL (Chrome/WebKit, and every trackpad)
}
// The same travel rounded to whole lines for the SGR wheel reports: a pure
// horizontal swipe is 0 (nothing to send), and anything else moves at least
// one line, so the small pixel deltas of a precision touchpad still scroll.
// The body of the primary pane's _wheelScrollLines.
function wheelDeltaWholeLines(ev, rows) {
const lines = wheelDeltaLines(ev, rows);
if (!lines) return 0;
return Math.round(lines) || (lines > 0 ? 1 : -1);
}
// Ticks one gesture batch may report: Claude applies its own scroll-speed
// Ticks one input event may report: Claude applies its own scroll-speed
// multiplier and acceleration on top, so a bigger batch only overshoots.
const SGR_WHEEL_MAX_TICKS = 5;
@@ -147,6 +137,43 @@
return `\x1b[<${btn};${pos.col};${pos.row}M`.repeat(ticks);
}
// A deliberate opening event preserves upstream rounding and pre-pays at
// least one tick, so isolated clicks retain their travel. The signed remainder
// is repaid by subsequent same-direction travel: ten 0.1-line events still total one tick. Debt and
// fractions expire with a 150ms gesture gap or reversal; a reversal within
// the gesture accumulates without another pre-payment (like page gestures).
function wheelTicksForGesture(state, lines, now) {
if (!Number.isFinite(lines) || !lines) return { state: state || {}, ticks: 0 };
const dir = Math.sign(lines);
const fresh = !state || typeof state.lastAt !== 'number' || now - state.lastAt > PAGE_KEY_GESTURE_GAP_MS;
const reversed = state?.dir && state.dir !== dir;
if (fresh && Math.abs(lines) >= PAGE_KEY_MIN_START_ROWS) {
const rounded = dir * Math.max(1, Math.round(Math.abs(lines)));
return {
state: { dir, lastAt: now, pending: lines - rounded },
ticks: dir * Math.min(Math.abs(rounded), SGR_WHEEL_MAX_TICKS),
};
}
const total = (fresh || reversed ? 0 : state.pending || 0) + lines;
const whole = Math.trunc(total + Math.sign(total) * 1e-9);
return {
state: { dir, lastAt: now, pending: Math.abs(total - whole) < 1e-9 ? 0 : total - whole },
ticks: whole === 0 ? 0 : Math.sign(whole) * Math.min(Math.abs(whole), SGR_WHEEL_MAX_TICKS),
};
}
// Only unsent scroll input can be cancelled. Preserve upstream report density
// within each batch, and replace an obsolete direction immediately.
// Local and remote transports share the same direction-aware gesture queue.
function coalesceScrollBytes(queued, data) {
const direction = (bytes) => bytes?.match(/\x1b\[<(64|65);|\x1b\[(5|6)~/)?.[0];
const previous = direction(queued);
const next = direction(data);
const combined = previous && previous === next ? queued + data : data;
const reports = combined.match(/\x1b\[<(?:64|65);\d+;\d+M|\x1b\[[56]~/g) || [];
return reports.join('');
}
// Rows of travel per page key for a terminal `rows` tall (half a screen).
function pageKeyRows(rows) {
return Math.max(2, Math.round((rows || 24) * PAGE_KEY_SCREEN_FRACTION));
@@ -388,9 +415,10 @@
PAGE_KEY_SCREEN_FRACTION,
PAGE_KEY_MAX_PER_BATCH,
wheelDeltaLines,
wheelDeltaWholeLines,
SGR_WHEEL_MAX_TICKS,
sgrWheelReports,
wheelTicksForGesture,
coalesceScrollBytes,
pageKeysForTravel,
pageKeysForGesture,
PAGE_KEY_GESTURE_GAP_MS,
@@ -1098,7 +1126,7 @@ Object.assign(CodemanApp.prototype, {
// and stops propagation, keeping xterm's scroller out of it entirely.
// Local scrolling goes through terminal.scrollLines() — buffer-level, so
// it keeps working after resets — with our own deltaMode normalization
// (_wheelScrollLines) covering Firefox's line-unit wheels. Two cases still
// (_wheelScrollLinesFloat) covering Firefox's line-unit wheels. Two cases still
// belong to xterm and are passed through untouched:
// - mouseTrackingMode active: xterm's own encoder forwards the wheel to
// the PTY (htop/vim with mouse on in a shell pane);
@@ -1114,8 +1142,9 @@ Object.assign(CodemanApp.prototype, {
ev.preventDefault();
ev.stopPropagation();
if (this._shouldForwardWheelToApp(ev)) {
if (ev.ctrlKey || Math.abs(ev.deltaX || 0) > Math.abs(ev.deltaY || 0)) return;
this._logScrollRouting('forward-sgr');
this._forwardScrollToApp(ev.clientX, ev.clientY, this._wheelScrollLines(ev));
this._forwardScrollToApp(ev.clientX, ev.clientY, this._wheelScrollLinesFloat(ev));
return;
}
// Local scrolling accumulates FRACTIONAL lines: a macOS trackpad emits
@@ -1141,9 +1170,13 @@ Object.assign(CodemanApp.prototype, {
// (_shouldForwardWheelToApp), a touch drag must go there too, or every
// phone/tablet swipe scrolls the local buffer of stale repaint frames and
// drags the CLI's pinned input box off the screen (issue #205's mobile
// half). Same gate, so Shift has no touch analog but the local-scrollback
// opt-out setting and the version/tracking gate apply to touch exactly as they
// do to the wheel — including the PageUp/PageDown fallback the wheel uses
// half). Touch asks _shouldForwardTouchToApp, which is the wheel's gate
// plus one branch of its own: a drag also forwards as SGR wheel reports
// while xterm itself tracks the mouse with SGR encoding (an app in the
// pane asked for it), since the wheel needs no help there (xterm encodes
// it) but a drag does. Shift has no touch analog, but the local-scrollback
// opt-out setting and the version/tracking gate apply to touch as they
// do to the wheel, including the PageUp/PageDown fallback the wheel uses
// when that gate is false and there is no local scrollback to scroll
// (_maybePageCliTranscript), which is what keeps a swipe from being a
// complete no-op on a phone.
@@ -1168,7 +1201,7 @@ Object.assign(CodemanApp.prototype, {
// Momentum phase — convert pixel velocity to lines
const lines = Math.round(velocity / cellHeight());
if (lines !== 0) {
if (this._shouldForwardWheelToApp({ shiftKey: false })) {
if (this._shouldForwardTouchToApp()) {
// Flick momentum keeps feeding the CLI's transcript from the last
// touch point; the 40ms coalescer batches the per-frame reports.
this._forwardScrollToApp(touchLastX, touchLastY, lines);
@@ -1196,8 +1229,14 @@ Object.assign(CodemanApp.prototype, {
let longPressStartY = 0;
let touchStartY = 0;
let touchStartX = 0;
// 'x' | 'y' | null — locked on the first travel past the tap slop, so a
// diagonal drag cannot pan and scroll at the same time.
// 'x' | 'y' | 'swipe' | null — locked on the first travel past the tap
// slop, so a diagonal drag cannot pan and scroll at the same time. 'swipe'
// is a sideways drag on a terminal that cannot pan: it is the session
// swipe's (SwipeHandler, mobile-handlers.js), so it neither scrolls nor
// ends as a tap on the session being left, until it drifts past the
// swipe's vertical limit and becomes a 'y' scroll. Published per gesture as
// `this._terminalGestureAxis`, which SwipeHandler reads to stand aside
// for a scroll or a pan.
let panAxis = null;
/**
* Can this gesture pan sideways? Only while the terminal is wider than
@@ -1242,6 +1281,7 @@ Object.assign(CodemanApp.prototype, {
touchStartY = touchLastY;
touchStartX = touchLastX;
panAxis = null;
this._terminalGestureAxis = null;
velocity = 0;
pixelAccum = 0;
isTouching = true;
@@ -1315,10 +1355,10 @@ Object.assign(CodemanApp.prototype, {
if (!didScroll) {
const travelY = Math.abs(touchY - touchStartY);
const travelX = Math.abs(touchX - touchStartX);
const sideways = canPanHorizontally() && travelX >= TAP_THRESHOLD;
if (travelY >= TAP_THRESHOLD || sideways) {
if (travelY >= TAP_THRESHOLD || travelX >= TAP_THRESHOLD) {
didScroll = true;
panAxis = sideways && travelX > travelY ? 'x' : 'y';
panAxis = travelX > travelY ? (canPanHorizontally() ? 'x' : 'swipe') : 'y';
this._terminalGestureAxis = panAxis;
}
}
// Below the tap threshold, treat the gesture as a potential tap:
@@ -1329,6 +1369,18 @@ Object.assign(CodemanApp.prototype, {
// fling, so a jittery tap would both position the cursor AND scroll.
if (!didScroll) return;
ev.preventDefault();
if (panAxis === 'swipe') {
// Past the swipe's own vertical drift limit the session swipe has
// given up, so the drag is a scroll after all (a thumb arc whose
// first move leaned sideways): hand it to 'y'.
const drift = typeof SwipeHandler !== 'undefined' ? SwipeHandler.maxVerticalDrift : 50;
if (Math.abs(touchY - touchStartY) <= drift) return;
panAxis = 'y';
this._terminalGestureAxis = 'y';
touchLastX = touchX;
touchLastY = touchY;
return;
}
if (panAxis === 'x') {
// Pan the container, and touch nothing the vertical path owns —
// no pixelAccum, no velocity, so touchend cannot turn a sideways
@@ -1347,7 +1399,7 @@ Object.assign(CodemanApp.prototype, {
const ch = cellHeight();
const lines = Math.trunc(pixelAccum / ch);
if (lines !== 0) {
if (this._shouldForwardWheelToApp({ shiftKey: false })) {
if (this._shouldForwardTouchToApp()) {
this._logScrollRouting('forward-sgr');
this._forwardScrollToApp(touchLastX, touchLastY, lines);
} else if (!this._maybePageCliTranscript({ shiftKey: false, touchId: touchContactId }, lines)) {
@@ -5786,41 +5838,6 @@ Object.assign(CodemanApp.prototype, {
return true;
},
// Wheel forwarding gate for the container wheel handler: no Shift override,
// xterm's own encoder dormant, viewport at the bottom, and a TUI VERIFIED to
// scroll its transcript on SGR wheel reports, which today is claude 2.1.187+
// with mouse tracking on (fullscreen) and nothing else (older Claude Code
// captures wheel as select-menu option navigation, an unknown version is
// treated as older, and inline Claude ignores it). Gemini and codex are
// strip modes too but keep the local wheel — taps/clicks are still forwarded
// for them (harmless no-ops at worst).
//
// Codex USED to forward here and was the #227 regression (DodgyBadger, Codex
// latest / Chrome / Win11: dead wheel in codex, working scrollbar drag).
// Measured on codex-cli 0.147.0 in a bare tmux: it never enables mouse
// tracking (`mouse_any_flag=0`) and SGR wheel reports fed to its PTY change
// NOTHING on screen — it runs an inline viewport (`alternate_on=0`) and pushes
// its transcript into the terminal's own scrollback (tmux `history_size`
// grows), so there is no in-app pager to drive and local scrollback IS the
// codex transcript. Forwarding therefore swallowed every tick.
// Wheel delta → whole scroll lines. macOS trackpads turn Shift+two-finger
// scroll into a HORIZONTAL wheel (deltaY≈0, deltaX carries the magnitude), and
// Shift routes the wheel to local scrollback (_shouldForwardWheelToApp returns
// false on Shift). So under Shift, read whichever axis dominates — otherwise
// deltaY≈0 collapses to a fixed ±1 line/tick and the gesture can't page through
// history on a trackpad (issue #154). Non-Shift and mouse-wheel paths are
// unchanged (they carry deltaY). The `|| ±1` keeps sub-25px deltas moving.
//
// `deltaMode` says what UNIT the delta is in, and ignoring it made every
// non-pixel browser scroll ~4x too slowly: Firefox reports DOM_DELTA_LINE (1)
// with deltaY≈3 per notch, so the pixel math rounded to 0 and fell through to
// the ±1 fallback — one line per notch, versus 4-5 for Chrome's ~110px. In
// Claude mode the same value also capped the forwarded SGR report at one tick.
_wheelScrollLines(ev) {
// Pure horizontal swipe: 0, never the ±1 fallback (wheelDeltaWholeLines).
return window.CodemanTerminalInput.wheelDeltaWholeLines(ev, this.terminal?.rows);
},
/** Unrounded variant for the smooth local-scroll path, which accumulates
* sub-line fractions across events instead of forcing every tiny trackpad
* delta to a whole ±1 line. Same unit handling and Shift-axis trap. The
@@ -5830,6 +5847,24 @@ Object.assign(CodemanApp.prototype, {
return window.CodemanTerminalInput.wheelDeltaLines(ev, this.terminal?.rows);
},
// The touch listeners' forwarding gate: the wheel's gate below, plus one
// branch of its own. While xterm itself tracks the mouse (an app in the pane
// asked for it, a shell's htop or vim), xterm encodes a native wheel event on
// its own, but the touch listeners consume finger drags themselves, so those
// are forwarded explicitly. Scrolling locally instead shows nothing useful in
// either kind of session: on a direct PTY the app draws on xterm's alternate
// screen, which scrollLines() cannot move, and in a tmux-backed session xterm
// stays in the normal buffer, so a local scroll only drags stale tmux
// repaints up the screen.
_shouldForwardTouchToApp() {
if (this.loadAppSettingsFromStorage?.()?.terminalWheelLocalScrollback) return false;
const mode = this.terminal?.modes?.mouseTrackingMode;
// The existing synthetic sender uses SGR; tracking alone does not imply
// that encoding (legacy/X10 applications must not receive SGR bytes).
const sgr = this.terminal?._core?.coreMouseService?.activeEncoding === 'SGR';
return !!(mode && mode !== 'none' && mode !== 'x10' && sgr) || this._shouldForwardWheelToApp({ shiftKey: false });
},
// `target` ({ terminal, sessionId }) asks the question for a TerminalTile:
// its own terminal's tracking mode and its own session, never the active one.
// Either field left out means the primary pane's.
@@ -5860,11 +5895,11 @@ Object.assign(CodemanApp.prototype, {
// same reports by hand, but only while the CLI is actually tracking the mouse. Not gated on a
// version; a stale-false flag falls through to the PageUp/PageDown fallback like claude's.
if (sessionMode === 'copilot') return session?.cliMouseTracking === true;
if (sessionMode !== 'claude') return false;
if (!this._cliVersionAtLeast(session?.cliVersion, '2.1.187')) return false;
// Only while Claude is actually listening for the mouse. In its default
// inline renderer (2.1.280 measured: alternate_on=0, mouse_any_flag=0) the
// transcript lives in real scrollback, like codex, and SGR wheel reports are
if (sessionMode !== 'claude' && sessionMode !== 'codex') return false;
if (sessionMode === 'claude' && !this._cliVersionAtLeast(session?.cliVersion, '2.1.187')) return false;
// Only while Claude or Codex is actually listening for the mouse. Claude's
// inline renderer (2.1.280 measured: alternate_on=0, mouse_any_flag=0), like
// older inline Codex, keeps its transcript in real scrollback; SGR reports are
// ignored, so forwarding made every swipe and wheel tick dead. Fullscreen
// (CLAUDE_CODE_NO_FLICKER=1, or "tui": "fullscreen" in ~/.claude/settings.json)
// turns on alt-screen + mode 1003/1006, which the server records as
@@ -5895,34 +5930,54 @@ Object.assign(CodemanApp.prototype, {
// trackpad emits dozens of wheel events per second and each send becomes a
// tmux send-keys on the server — unbatched, a single flick would spawn a
// process storm. Per-event tick count is capped (Claude applies its own
// scroll-speed multiplier and acceleration on top), and the queue is bounded
// so a wild scroll can't build a backlog that keeps scrolling after the finger
// stops. Flushed via _sendInputEphemeral — loss-tolerant, off the durable queue.
// scroll-speed multiplier and acceleration on top). Batching is bounded by
// the 40ms window and the per-event cap, not an aggregate byte/tick limit.
// Flushed via _sendInputEphemeral — loss-tolerant, off the durable queue.
// The encoding is the pure CodemanTerminalInput.sgrWheelReports, which a
// TerminalTile calls with its own cell (TerminalTile._maybeForwardWheelToCli).
_sendSyntheticSgrWheel(clientX, clientY, lines) {
if (!this.activeSessionId || !lines) return;
const pos = this._clientPointToCell(clientX, clientY);
if (!pos) return;
this._queueScrollBytes(window.CodemanTerminalInput.sgrWheelReports(lines, pos));
const input = window.CodemanTerminalInput;
const state = this._wheelTickSession === this.activeSessionId ? this._wheelTickGesture : null;
if (state?.dir && state.dir !== Math.sign(lines)) this._wheelSgrQueue = '';
const step = input.wheelTicksForGesture(state, lines, performance.now());
this._wheelTickSession = this.activeSessionId;
this._wheelTickGesture = step.state;
this._queueScrollBytes(input.sgrWheelReports(step.ticks, pos));
},
/**
* Shared 40ms coalescer for every byte a scroll gesture sends to the PTY (SGR
* Coalesces scroll input into 40ms batches after the first native report (SGR
* wheel reports and the PageUp/PageDown fallback alike). Each flush becomes a
* tmux send-keys server-side, so per-event writes would spawn a process storm
* on a single flick; the queue is bounded so a wild scroll can't build a
* backlog that keeps scrolling after the finger stops.
* on a single flick. Per-event report limits preserve the upstream wheel/touch
* scaling; a change of direction discards only movement that is still unsent.
*
* A TerminalTile keeps its own narrow twin (TerminalTile._queueScrollBytes,
* terminal-tile.js: same 40ms window, same 512-byte bound) because this queue
* terminal-tile.js: same 40ms window and report limits) because this queue
* flushes to the active session only; keep the two in step.
*/
_queueScrollBytes(data) {
if (!data || !this.activeSessionId) return;
const queued = this._wheelSgrQueue || '';
if (queued.length > 512) return;
this._wheelSgrQueue = queued + data;
// The first native report of a newly selected tab must not wait behind
// replay/atlas work for a 40ms timer (measured 60-250ms in practice).
// Selection generation also covers A -> B -> A when B was never scrolled.
const firstNativeForVisit = data.startsWith('\x1b[<') &&
(this._wheelNativeStartSession !== this.activeSessionId ||
this._wheelNativeStartGeneration !== this._selectGeneration);
const queued = !firstNativeForVisit && this._wheelQueueSession === this.activeSessionId
? this._wheelSgrQueue || '' : '';
this._wheelQueueSession = this.activeSessionId;
this._wheelSgrQueue = window.CodemanTerminalInput.coalesceScrollBytes(queued, data);
if (firstNativeForVisit) {
this._wheelNativeStartSession = this.activeSessionId;
this._wheelNativeStartGeneration = this._selectGeneration;
if (this._wheelSgrFlushTimer) clearTimeout(this._wheelSgrFlushTimer);
this._flushWheelSgrQueue();
return;
}
if (this._wheelSgrFlushTimer) return;
this._wheelSgrFlushTimer = setTimeout(() => this._flushWheelSgrQueue(), 40);
},
@@ -5942,9 +5997,8 @@ Object.assign(CodemanApp.prototype, {
* PageUp/PageDown keys (`messages_page_up/down`) but IGNORES SGR wheel
* reports — six `\x1b[<64;…M` reports against an idle pane left the capture
* byte-identical — so paging is the only gesture that reaches it;
* - `codex`, which draws on tmux's alternate screen too (0.157.1, 0.160), which
* the full strip hides from xterm, and pages on PageUp/PageDown while SGR
* wheel reports do nothing.
* - `codex` with no observed mouse tracking. Native tracked fullscreen
* Codex uses fractional SGR reports; this remains its fallback capability.
*
* ⚠️ Which modes page is read from `window.__codemanTranscriptPageKeys`, the
* list the server builds from the `transcriptPageKeys` CAPABILITY, never an id
@@ -5976,8 +6030,8 @@ Object.assign(CodemanApp.prototype, {
* unset (the inline renderer, or fullscreen right after a server restart), the
* user turned on "Wheel scrolls local history" (which pins the wheel to a buffer
* that, for a repaint-mode CLI, is empty: the setting's footgun), or the CLI is
* one that never fills the buffer and never accepts the wheel (opencode, codex:
* both ignore SGR wheel reports, #227, and page on PageUp/PageDown). Before
* one that never fills the buffer and does not accept the wheel (OpenCode,
* or Codex without observed mouse tracking). Those modes page instead. Before
* this, all of those produced a completely dead gesture; the #205 reporter
* proved the keyboard route works by paging back through intact text with Fn+Up.
* This is not wheel forwarding: it sends plain keys, and only over a hollow buffer.
@@ -6021,6 +6075,7 @@ Object.assign(CodemanApp.prototype, {
*/
_maybePageCliTranscript(ev, lines) {
if (!lines || ev?.shiftKey || !this.activeSessionId) return false;
if (ev?.ctrlKey || Math.abs(ev?.deltaX || 0) > Math.abs(ev?.deltaY || 0)) return this._localScrollbackIsHollow();
if (!this._localScrollbackIsHollow()) return false;
// An open dialog in THIS pane's session (the active one): a page key would
// move its selector. Consumed, so the buffer does not scroll under it either.
@@ -6033,6 +6088,7 @@ Object.assign(CodemanApp.prototype, {
this._pageKeySession = this.activeSessionId;
this._pageKeyGesture = null;
}
if (this._pageKeyGesture?.dir && this._pageKeyGesture.dir !== Math.sign(lines)) this._wheelSgrQueue = '';
const step = window.CodemanTerminalInput.pageKeysForGesture(
this._pageKeyGesture,
ev,
@@ -6060,19 +6116,24 @@ Object.assign(CodemanApp.prototype, {
const session = this.sessions?.get(sessionId);
const optOut = !!this.loadAppSettingsFromStorage?.()?.terminalWheelLocalScrollback;
const tracking = this.terminal?.modes?.mouseTrackingMode || 'none';
// The encoding xterm would report in (DEFAULT, SGR, SGR_PIXELS): touch
// forwards to xterm's own tracking only when it is SGR
// (_shouldForwardTouchToApp).
const encoding = this.terminal?._core?.coreMouseService?.activeEncoding || 'unknown';
// xterm's own mode above stays 'none' for a strip mode (the server removes
// the DECSETs), so the CLI's real tracking state is reported separately.
const cliTracking = session?.cliMouseTracking === true;
const baseY = this.terminal?.buffer?.active?.baseY ?? -1;
const signature =
`${decision}|${session?.mode}|${session?.cliVersion}|${optOut}|${tracking}|${cliTracking}|${baseY > 0}`;
`${decision}|${session?.mode}|${session?.cliVersion}|${optOut}|` +
`${tracking}|${encoding}|${cliTracking}|${baseY > 0}`;
if (!this._scrollRoutingLogged) this._scrollRoutingLogged = new Map();
if (this._scrollRoutingLogged.get(sessionId) === signature) return;
this._scrollRoutingLogged.set(sessionId, signature);
console.log(
`[scroll] ${sessionId} → ${decision} (mode=${session?.mode || '?'}, cliVersion=${session?.cliVersion || 'unknown'}, ` +
`localScrollbackOptOut=${optOut}, mouseTracking=${tracking}, cliMouseTracking=${cliTracking}, ` +
`localScrollbackRows=${baseY})`
`localScrollbackOptOut=${optOut}, mouseTracking=${tracking}, mouseEncoding=${encoding}, ` +
`cliMouseTracking=${cliTracking}, localScrollbackRows=${baseY})`
);
},
@@ -6083,7 +6144,9 @@ Object.assign(CodemanApp.prototype, {
// Ephemeral (fire-and-forget): wheel reports are loss-tolerant, so they skip
// the durable seq/ACK queue — no localStorage churn, no "Nb queued" flicker
// in the connection indicator on every scroll tick.
if (data && this.activeSessionId) this._sendInputEphemeral(this.activeSessionId, data);
if (data && this.activeSessionId && this._wheelQueueSession === this.activeSessionId) {
this._sendInputEphemeral(this.activeSessionId, data);
}
},
// Desktop counterpart of the touchend tap branch: hand-encode an SGR report
+9 -9
View File
@@ -1,13 +1,13 @@
{
"version": "1.43.0",
"version": "1.44.0",
"highlights": [
"Security notices for the CLIs you run",
"GitHub Copilot CLI is a run mode (#592)",
"Pin tab names on the CLI (#646, optional)",
"Antigravity tabs show when they are working (#648)",
"Custom-folder cases below the cases folder (#567)",
"A steady WS pill (#647)",
"Clickable diff and patch files (#644)",
"Mixed uploads say what they skipped (#651)"
"Config backups (#661)",
"Keep an idle Claude session's prompt cache warm (#666, opt-in)",
"A default tab group per case (#676)",
"A File Viewer you can resize, with Back and Forward (#659)",
"Copilot's premium-request quota in the plan-usage chip (#662)",
"More (or fewer) sessions with `CODEMAN_MAX_SESSIONS` (#668)",
"Multi-line prompts keep their line breaks (#670)",
"A warning for llama.cpp slots that split the context too thin (#658)"
]
}
+8 -1
View File
@@ -42,6 +42,8 @@ import { SseEvent } from '../sse-events.js';
import type { AuthPort } from '../ports/auth-port.js';
import type { SessionPort } from '../ports/session-port.js';
import type { EventPort } from '../ports/event-port.js';
import type { TabLayoutPort } from '../ports/tab-layout-port.js';
import { ownerLayoutKey } from '../../tab-layout-persistence.js';
const CreateUserSchema = z.object({
username: z.string().min(1).max(64),
@@ -73,7 +75,10 @@ function storeError(reply: FastifyReply, err: unknown): ReturnType<typeof create
return createErrorResponse(ApiErrorCode.INTERNAL_ERROR, err instanceof Error ? err.message : 'error');
}
export function registerAdminRoutes(app: FastifyInstance, ctx: SessionPort & AuthPort & EventPort): void {
export function registerAdminRoutes(
app: FastifyInstance,
ctx: SessionPort & AuthPort & EventPort & TabLayoutPort
): void {
// Gate: admin routes exist only in multi-user mode, and only for admins.
const gate = (req: FastifyRequest, reply: FastifyReply): boolean => {
if (!isMultiUserMode()) {
@@ -299,6 +304,8 @@ export function registerAdminRoutes(app: FastifyInstance, ctx: SessionPort & Aut
);
}
await fsp.rm(realTarget, { recursive: true, force: true });
// The user's default session group for that case goes with it.
await ctx.tabLayouts.forgetCaseDefaultGroup(ownerLayoutKey(username), params.caseName).catch(() => undefined);
audit(req, 'user.case-delete', username, { caseName: params.caseName });
ctx.broadcast(SseEvent.AdminUsersChanged, {});
return { success: true, data: { username, caseName: params.caseName } };
+87 -4
View File
@@ -29,6 +29,7 @@ import {
ClonePreflightSchema,
LinkCaseSchema,
CaseOrderSchema,
CaseDefaultGroupSchema,
RemoteCaseLinkSchema,
RemoteHostSchema,
DockerCaseLinkSchema,
@@ -51,7 +52,7 @@ import {
import type { GitRemoteProbe, GitUrlParse } from '../../git-clone.js';
import { generateClaudeMd } from '../../templates/claude-md.js';
import { prepareNewCasePath } from '../case-path.js';
import { boundedPathExists, describeUnknownPath, probePath } from '../../utils/index.js';
import { boundedPathExists, describeUnknownPath, probePath, probePathKind } from '../../utils/index.js';
import { readAgentCaseMarker, type AgentCaseMarker } from '../../agent-case-marker.js';
import { settingsWriteBlocker, writeHooksConfig } from '../../hooks-config.js';
import {
@@ -70,7 +71,9 @@ import {
import { isMultiUserMode } from '../../config/multiuser.js';
import type { AuthUser } from '../../types.js';
import { SseEvent } from '../sse-events.js';
import type { EventPort, ConfigPort, SessionPort } from '../ports/index.js';
import type { EventPort, ConfigPort, SessionPort, TabLayoutPort } from '../ports/index.js';
import { ownerLayoutKey } from '../../tab-layout-persistence.js';
import { TabLayoutValidationError } from '../../tab-layout.js';
import type { FastifyRequest } from 'fastify';
import { dataPath, getDataDir } from '../../config/instance.js';
import {
@@ -246,7 +249,33 @@ async function ensureCaseImage(
return { ok: true, imageBuilding: true };
}
export function registerCaseRoutes(app: FastifyInstance, ctx: EventPort & ConfigPort & SessionPort): void {
/**
* Is `name` a case the caller can see (the same rules as GET /api/cases and
* resolveCasePath): an owned remote or Docker case, a linked case (admin or
* single-user only), or a directory in the caller's own case space. Bounded:
* a case folder on a mount that stopped answering still counts as there, like
* the case list keeps listing it.
*/
async function caseVisibleToCaller(name: string, req: FastifyRequest): Promise<boolean> {
if (!SAFE_CASE_NAME.test(name)) return false;
const user = getAuthUser(req);
if ((await readRemoteCases(CODEMAN_CONFIG_DIR)).some((c) => c.name === name && canAccessOwned(user, c.owner))) {
return true;
}
if ((await readDockerCases(CODEMAN_CONFIG_DIR)).some((c) => c.name === name && canAccessOwned(user, c.owner))) {
return true;
}
const linkedCases = await readLinkedCases();
if (linkedCases[name] && (!isMultiUserMode() || isAdmin(req)))
return (await probePath(linkedCases[name])) !== 'absent';
const kind = await probePathKind(join(resolveCasesDir(user), name));
return kind === 'directory' || kind === 'unknown';
}
export function registerCaseRoutes(
app: FastifyInstance,
ctx: EventPort & ConfigPort & SessionPort & TabLayoutPort
): void {
// ═══════════════════════════════════════════════════════════════
// Case CRUD (list, create, link, detail, fix-plan)
// ═══════════════════════════════════════════════════════════════
@@ -1584,6 +1613,19 @@ export function registerCaseRoutes(app: FastifyInstance, ctx: EventPort & Config
app.delete('/api/cases/:name', async (req): Promise<ApiResponse<{ name: string }>> => {
const { name } = req.params as { name: string };
const user = getAuthUser(req);
// The caller's default session group for this case goes with it (best-effort:
// a stale value is ignored at creation anyway).
// An admin deleting another user's remote/Docker case clears that owner's too.
const forgetCaseDefault = async (caseOwner?: string): Promise<void> => {
const owners = new Set([ownerLayoutKey(ownerFor(req)), ...(caseOwner ? [ownerLayoutKey(caseOwner)] : [])]);
for (const owner of owners) {
try {
await ctx.tabLayouts.forgetCaseDefaultGroup(owner, name);
} catch {
/* never blocks the delete */
}
}
};
if (!validatePathWithinBase(name, resolveCasesDir(user))) {
return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Invalid case name');
@@ -1593,12 +1635,14 @@ export function registerCaseRoutes(app: FastifyInstance, ctx: EventPort & Config
// docker case is skipped so control falls through to the caller's own local delete.
// canAccessOwned is all-true for admins/single-user, so flag-OFF stays byte-identical.
const remoteCases = await readRemoteCases(CODEMAN_CONFIG_DIR);
if (remoteCases.some((item) => item.name === name && canAccessOwned(user, item.owner))) {
const remoteCase = remoteCases.find((item) => item.name === name && canAccessOwned(user, item.owner));
if (remoteCase) {
await writeRemoteCases(
CODEMAN_CONFIG_DIR,
remoteCases.filter((item) => !(item.name === name && canAccessOwned(user, item.owner)))
);
ctx.broadcast(SseEvent.CaseDeleted, { name, type: 'remote-unlinked' });
await forgetCaseDefault(remoteCase.owner);
return { success: true, data: { name } };
}
@@ -1631,6 +1675,7 @@ export function registerCaseRoutes(app: FastifyInstance, ctx: EventPort & Config
.catch(() => {});
}
ctx.broadcast(SseEvent.CaseDeleted, { name, type: 'docker-unlinked' });
await forgetCaseDefault(dockerCase.owner);
return { success: true, data: { name } };
}
@@ -1643,6 +1688,7 @@ export function registerCaseRoutes(app: FastifyInstance, ctx: EventPort & Config
try {
await fs.writeFile(LINKED_CASES_FILE, JSON.stringify(linkedCases, null, 2));
ctx.broadcast(SseEvent.CaseDeleted, { name, type: 'unlinked' });
await forgetCaseDefault();
return { success: true, data: { name } };
} catch (err) {
return createErrorResponse(ApiErrorCode.OPERATION_FAILED, getErrorMessage(err));
@@ -1658,12 +1704,49 @@ export function registerCaseRoutes(app: FastifyInstance, ctx: EventPort & Config
try {
await fs.rm(casePath, { recursive: true, force: true });
ctx.broadcast(SseEvent.CaseDeleted, { name, type: 'deleted' });
await forgetCaseDefault();
return { success: true, data: { name } };
} catch (err) {
return createErrorResponse(ApiErrorCode.OPERATION_FAILED, getErrorMessage(err));
}
});
// ========== Default session group ==========
/**
* The tab group new sessions of this case land in when the create request names
* no group and no parent places them. Scoped to the caller's own tab layout: the
* options are their groups, and a stored id that is no longer one of them reads
* as Ungrouped (null); the read writes nothing (the next creation for the case
* forgets it). Not admin-gated in multi-user mode: the
* value is per owner (like the layout it points into), never server-wide.
*/
app.get('/api/cases/:name/default-group', async (req, reply) => {
const { name } = req.params as { name: string };
if (!(await caseVisibleToCaller(name, req))) {
return reply.code(404).send(createErrorResponse(ApiErrorCode.NOT_FOUND, `Case "${name}" not found`));
}
const view = await ctx.tabLayouts.caseDefaultGroup(ownerLayoutKey(ownerFor(req)), name);
return { success: true, data: { caseName: name, ...view } };
});
app.put('/api/cases/:name/default-group', async (req, reply) => {
const { name } = req.params as { name: string };
const { groupId } = parseBody(CaseDefaultGroupSchema, req.body, 'Invalid default group');
if (!(await caseVisibleToCaller(name, req))) {
return reply.code(404).send(createErrorResponse(ApiErrorCode.NOT_FOUND, `Case "${name}" not found`));
}
try {
const view = await ctx.tabLayouts.setCaseDefaultGroup(ownerLayoutKey(ownerFor(req)), name, groupId);
return { success: true, data: { caseName: name, ...view } };
} catch (err) {
if (err instanceof TabLayoutValidationError) {
return reply.code(400).send(createErrorResponse(ApiErrorCode.INVALID_INPUT, 'That tab group does not exist'));
}
throw err;
}
});
// ========== Reorder Cases ==========
app.put('/api/cases/order', async (req, reply): Promise<ApiResponse<{ order: string[] }> | undefined> => {
+298
View File
@@ -0,0 +1,298 @@
/**
* @fileoverview Config backups (src/config-backup.ts).
*
* GET /api/config-backups — settings in effect, the folder, and the snapshots in it.
* POST /api/config-backups — snapshot now (a manual request always makes one).
* POST /api/config-backups/:id/restore — put a snapshot's files back (after a `pre-restore` snapshot).
* GET /api/config-backups/diff?from=&to= — what differs between two snapshots, or a snapshot and the
* running config (`current`, the default for `to`).
*
* The files include credentials (`.env`, `users.json`, endpoint keys), so in multi-user mode all four
* are admin only. Responses carry file NAMES and sizes, never file content, except the diff, which shows
* changed values but never a credential's (`.env`, `users.json`, `push-keys.json`, `webhook.json`, `intents.json` and any key that looks
* like a token/secret/password are reported as "changed" only). Restore replaces files the running server
* may have cached: `restartRequired` says which ones only apply after a restart. A restore that could not
* write every file answers 500 with the error envelope plus `data` (`restored`, `failed`, `untouched`,
* `safetySnapshot`, `restartRequired`): the files it wrote stay restored and are reloaded like a full one.
*/
import { readFile } from 'node:fs/promises';
import { createRequire } from 'node:module';
import type { FastifyInstance, FastifyReply, FastifyRequest } from 'fastify';
import { ApiErrorCode, createErrorResponse, getErrorMessage, type ApiResponse } from '../../types.js';
import { getAuthUser, isAdmin, readJsonConfig, SETTINGS_PATH } from '../route-helpers.js';
import { appendAdminAudit } from '../admin-audit.js';
import { reloadCliRegistry } from '../../config/cli-registry/registry.js';
import { isMultiUserMode } from '../../config/multiuser.js';
import { getDataDir } from '../../config/instance.js';
import { intentStore } from '../../intent-store.js';
import { describeUnknownPath, probePathKind } from '../../utils/bounded-path-probe.js';
import { dirname } from 'node:path';
import {
CONFIG_BACKUP_FILES,
CONFIG_DIFF_CURRENT,
ConfigBackupCorruptError,
ConfigBackupNotFoundError,
ConfigBackupPartialRestoreError,
defaultConfigBackupDir,
diffConfigBackups,
listConfigBackups,
readConfigBackupSettings,
resolveConfigBackupDir,
restoreConfigBackup,
runConfigBackupPass,
withRecordedRetention,
readConfigBackupOrigin,
type ConfigBackupResult,
type ConfigBackupSettings,
type ConfigBackupSummary,
type ConfigDiff,
} from '../../config-backup.js';
// Only recorded in each manifest; a missing package.json just leaves it blank.
const APP_VERSION: string = (() => {
try {
return (createRequire(import.meta.url)('../../../package.json') as { version?: string }).version ?? '';
} catch {
return '';
}
})();
/** Files whose running-process copy only reloads on restart (the rest are read per request; `users.json`
* is re-read within a second, `clis.json` is reloaded below, `intents.json` by `reload()`). */
const RESTART_REQUIRED = new Set<string>(['.env', 'push-keys.json', 'settings.json']);
export interface ConfigBackupStatus {
settings: ConfigBackupSettings;
dir: string;
defaultDir: string;
tracked: readonly string[];
backups: ConfigBackupSummary[];
}
/**
* The settings in effect and the folder they name. A custom folder is probed (bounded, #516) BEFORE
* the recorded-retention fallback reads it, so this throws `BackupDirUnavailableError` for a folder
* that does not answer and never lists it; `present` is false for a custom folder that does not exist yet.
*/
export async function readEffectiveBackupSettings(): Promise<{
settings: ConfigBackupSettings;
dir: string;
present: boolean;
}> {
const raw = await readJsonConfig<Record<string, unknown>>(SETTINGS_PATH, 'settings.json', {});
const base = readConfigBackupSettings(raw);
const dir = resolveConfigBackupDir(base, getDataDir());
const present = await probeBackupDir(base, dir);
// The same fallback the timer pass uses, so the form never shows 20 / 30 while a pass keeps 100 / 0.
const settings = present ? withRecordedRetention(base, raw, dir, readConfigBackupOrigin(getDataDir())) : base;
return { settings, dir, present };
}
/** A user-chosen backup folder that is not safe to touch (bounded probe, as for case paths, #516). */
export class BackupDirUnavailableError extends Error {}
/**
* Probe a custom backup folder without blocking the event loop on a dead mount. The default folder
* needs no probe. Returns whether the folder exists; throws when it did not answer, or is not a
* folder. A missing custom folder is only creatable when its parent is present.
*/
export async function probeBackupDir(settings: ConfigBackupSettings, dir: string): Promise<boolean> {
if (!settings.dir || dir === defaultConfigBackupDir(getDataDir())) return true;
const kind = await probePathKind(dir, { pastCap: true });
if (kind === 'unknown')
throw new BackupDirUnavailableError(describeUnknownPath('Backup folder', dir, { pastCap: true }));
if (kind === 'file') throw new BackupDirUnavailableError(`Backup folder is a file, not a folder: ${dir}`);
if (kind === 'directory') return true;
const parent = await probePathKind(dirname(dir), { pastCap: true });
if (parent === 'unknown') {
throw new BackupDirUnavailableError(describeUnknownPath('Backup folder', dirname(dir), { pastCap: true }));
}
if (parent !== 'directory') throw new BackupDirUnavailableError(`Backup folder's parent is missing: ${dirname(dir)}`);
return false;
}
async function readSettingsForBackup(): Promise<Record<string, unknown> | 'unreadable'> {
try {
const parsed: unknown = JSON.parse(await readFile(SETTINGS_PATH, 'utf8'));
return parsed && typeof parsed === 'object' && !Array.isArray(parsed)
? (parsed as Record<string, unknown>)
: 'unreadable';
} catch (err) {
return (err as NodeJS.ErrnoException).code === 'ENOENT' ? {} : 'unreadable';
}
}
/** Snapshot (when changed) and apply retention; shared by the timer and the manual route. */
export async function runConfigBackup(
reason: string,
opts: { force?: boolean } = {}
): Promise<{ result: ConfigBackupResult; removed: string[] } | null> {
// Read settings.json directly: readJsonConfig answers {} for ANY failure, so a syntax error or a read
// that lands inside PUT /api/settings's non-atomic write used to prune with the defaults (20 / 30
// days) and drop the longer history the user asked to keep.
const raw = await readSettingsForBackup();
const unreadable = raw === 'unreadable';
const fromFile = readConfigBackupSettings(unreadable ? {} : raw);
if (!fromFile.enabled && !opts.force) return null;
const dir = resolveConfigBackupDir(fromFile, getDataDir());
await probeBackupDir(fromFile, dir);
return runConfigBackupPass({
dataDir: getDataDir(),
backupDir: dir,
reason,
appVersion: APP_VERSION,
force: opts.force,
settings: fromFile,
raw: unreadable ? {} : raw,
// An unreadable settings.json still gets its snapshot, but no pass deletes anything on its say-so.
prune: !unreadable,
});
}
export function registerConfigBackupRoutes(app: FastifyInstance): void {
const deny = (req: FastifyRequest, reply: FastifyReply): ApiResponse<never> | null => {
if (isMultiUserMode() && !isAdmin(req)) {
reply.code(403);
return createErrorResponse(ApiErrorCode.FORBIDDEN, 'Admin only in multi-user mode');
}
return null;
};
app.get('/api/config-backups', async (req, reply): Promise<ApiResponse<ConfigBackupStatus>> => {
const denied = deny(req, reply);
if (denied) return denied;
let eff: Awaited<ReturnType<typeof readEffectiveBackupSettings>>;
try {
eff = await readEffectiveBackupSettings();
} catch (err) {
reply.code(503);
return createErrorResponse(ApiErrorCode.OPERATION_FAILED, getErrorMessage(err));
}
const { settings, dir, present } = eff;
// Read-only: a listing must not create config-backup-id, and without one nothing can be ours.
const origin = readConfigBackupOrigin(getDataDir());
return {
success: true,
data: {
settings,
dir,
defaultDir: defaultConfigBackupDir(getDataDir()),
tracked: CONFIG_BACKUP_FILES,
backups: present && origin ? listConfigBackups(dir, origin) : [],
},
};
});
app.post('/api/config-backups', async (req, reply) => {
const denied = deny(req, reply);
if (denied) return denied;
try {
const out = await runConfigBackup('manual', { force: true });
return { success: true, data: { ...out!.result, removed: out!.removed } };
} catch (err) {
reply.code(err instanceof BackupDirUnavailableError ? 503 : 500);
return createErrorResponse(ApiErrorCode.OPERATION_FAILED, `Backup failed: ${getErrorMessage(err)}`);
}
});
app.get('/api/config-backups/diff', async (req, reply): Promise<ApiResponse<ConfigDiff>> => {
const denied = deny(req, reply);
if (denied) return denied;
const q = req.query as { from?: string; to?: string };
if (typeof q.from !== 'string' || !q.from) {
reply.code(400);
return createErrorResponse(ApiErrorCode.INVALID_INPUT, '`from` is required (a backup id or "current")');
}
try {
const { dir } = await readEffectiveBackupSettings();
const data = diffConfigBackups({
dataDir: getDataDir(),
backupDir: dir,
from: q.from,
to: typeof q.to === 'string' && q.to ? q.to : CONFIG_DIFF_CURRENT,
});
return { success: true, data };
} catch (err) {
if (err instanceof ConfigBackupNotFoundError) {
reply.code(404);
return createErrorResponse(ApiErrorCode.NOT_FOUND, err.message);
}
if (err instanceof ConfigBackupCorruptError) {
reply.code(409);
return createErrorResponse(ApiErrorCode.CONFLICT, err.message);
}
reply.code(err instanceof BackupDirUnavailableError ? 503 : 500);
return createErrorResponse(ApiErrorCode.OPERATION_FAILED, `Diff failed: ${getErrorMessage(err)}`);
}
});
/**
* What a restore that wrote files must do, whether or not every file made it: reload what the running
* server caches, audit it, and name the files that only apply after a restart.
*/
const afterRestore = (req: FastifyRequest, id: string, restored: string[], failed: string[] = []): string[] => {
// IntentStore caches the file and rewrites it whole on the next prompt, which would undo the restore.
if (restored.includes('intents.json')) intentStore.reload();
// restoreConfigBackup writes clis.json directly (the one writer besides mutateRegistryFile).
if (restored.includes('clis.json')) reloadCliRegistry();
// A restore of users.json replaces accounts, password hashes, roles and bypass grants.
if (isMultiUserMode()) {
void appendAdminAudit({
admin: getAuthUser(req).username,
action: 'config_backup_restore',
target: id,
ip: req.ip,
detail: { restored, ...(failed.length > 0 && { failed }) },
});
}
return restored.filter((n) => RESTART_REQUIRED.has(n));
};
app.post('/api/config-backups/:id/restore', async (req, reply) => {
const denied = deny(req, reply);
if (denied) return denied;
const { id } = req.params as { id: string };
try {
const { dir } = await readEffectiveBackupSettings();
const out = restoreConfigBackup({ dataDir: getDataDir(), backupDir: dir, id, appVersion: APP_VERSION });
return {
success: true,
data: {
restored: out.restored,
untouched: out.untouched,
safetySnapshot: out.safetySnapshot,
restartRequired: afterRestore(req, id, out.restored),
},
};
} catch (err) {
if (err instanceof ConfigBackupNotFoundError) {
reply.code(404);
return createErrorResponse(ApiErrorCode.NOT_FOUND, err.message);
}
if (err instanceof ConfigBackupCorruptError) {
reply.code(409);
return createErrorResponse(ApiErrorCode.CONFLICT, `${err.message}. Nothing was changed.`);
}
if (err instanceof ConfigBackupPartialRestoreError) {
// Some files are the backup's now: the client must learn which (Settings re-reads a restored
// settings.json, or its next Save writes the old values back) and where the undo copy is.
const failedNames = err.failed.map((f) => f.name);
const restartRequired = afterRestore(req, id, err.restored, failedNames);
reply.code(500);
return {
...createErrorResponse(ApiErrorCode.OPERATION_FAILED, err.message),
data: {
restored: err.restored,
failed: err.failed,
untouched: err.untouched,
safetySnapshot: err.safetySnapshot,
restartRequired,
},
};
}
reply.code(err instanceof BackupDirUnavailableError ? 503 : 500);
return createErrorResponse(ApiErrorCode.OPERATION_FAILED, `Restore failed: ${getErrorMessage(err)}`);
}
});
}
+131 -15
View File
@@ -56,11 +56,31 @@ export const CLAUDE_MIN_SAFE_CONTEXT_TOKENS = 40000;
*/
export function exceedsSafeContextFloor(
entry: Pick<CliEntry, 'capabilities'>,
contextLength: number | undefined
contextLength: number | undefined,
slotCount?: number
): boolean {
const cap = entry.capabilities.customModelInjection;
if (cap.kind !== 'env' || !cap.contextLengthVar) return false;
return typeof contextLength === 'number' && contextLength < CLAUDE_MIN_SAFE_CONTEXT_TOKENS;
if (typeof contextLength !== 'number') return false;
if (contextLength < CLAUDE_MIN_SAFE_CONTEXT_TOKENS) return true;
return perSlotContext(contextLength, slotCount) < CLAUDE_MIN_SAFE_CONTEXT_TOKENS;
}
/**
* The context one request can rely on when the server runs `slotCount` parallel slots.
* llama-server divides its context across `--parallel` slots (by default they share ONE
* unified KV pool), and Claude Code sends concurrent requests: the main session plus every
* parallel subagent, each carrying the full `CLAUDE_MIN_SAFE_CONTEXT_TOKENS` of its own
* system prompt and tools. With N busy slots each request therefore gets roughly
* `contextLength / N`, and once the pool is spent llama.cpp answers HTTP 500 "Context size
* has been exceeded" (measured: 4 slots, ~131k pool, four simultaneous 40k-token requests
* all failed; the same requests against one slot all succeeded). An unknown or single slot
* count leaves the context whole.
*/
export function perSlotContext(contextLength: number, slotCount: number | undefined): number {
const slots =
typeof slotCount === 'number' && Number.isFinite(slotCount) && slotCount > 1 ? Math.floor(slotCount) : 1;
return Math.floor(contextLength / slots);
}
function adminOnly(req: FastifyRequest, reply: { code: (n: number) => unknown }): ApiResponse<never> | null {
@@ -109,7 +129,7 @@ function applyStoredApiKey(incoming: CustomModelHost, existing: CustomModelHost)
}
/**
* `modelContextLengths`/`modelSizesGB` are server-populated by discovery, never
* `modelContextLengths`/`modelSlotCounts`/`modelSizesGB` are server-populated by discovery, never
* user-entered, and PUT replaces the whole record — so merge them back in from the
* stored host rather than trust whatever the editor's body carried (or omitted).
* The editor only ever sends `models`/`lastDiscoveredAt` verbatim from its cached
@@ -117,7 +137,12 @@ function applyStoredApiKey(incoming: CustomModelHost, existing: CustomModelHost)
* future caller forgets, same class of bug `applyStoredApiKey` exists to prevent.
*/
function applyDiscoveredFields(incoming: CustomModelHost, existing: CustomModelHost): CustomModelHost {
return { ...incoming, modelContextLengths: existing.modelContextLengths, modelSizesGB: existing.modelSizesGB };
return {
...incoming,
modelContextLengths: existing.modelContextLengths,
modelSlotCounts: existing.modelSlotCounts,
modelSizesGB: existing.modelSizesGB,
};
}
function authHeaders(host: Pick<CustomModelHost, 'apiKey' | 'authStyle'>): Record<string, string> {
@@ -135,6 +160,8 @@ export interface DiscoveryResult {
models: string[];
/** See `CustomModelHost.modelContextLengths` — only ever populated for models already loaded. */
contextLengths: Record<string, number>;
/** See `CustomModelHost.modelSlotCounts`; populated on the same loaded-models-only basis as `contextLengths`. */
slotCounts: Record<string, number>;
/** See `CustomModelHost.modelSizesGB` — populated for every model whose own listing states one. */
sizesGB: Record<string, number>;
}
@@ -157,7 +184,7 @@ function parseSizeGB(description: unknown): number | undefined {
/**
* Best-effort: fetches `GET /props?model=<id>` (llama.cpp-native, llama-swap-proxied) for
* ONE already-loaded model and pulls its real `n_ctx` out. Never called for a model that
* ONE already-loaded model and pulls its `n_ctx` and `total_slots` out. Never called for a model that
* isn't already loaded — see the caller and `CustomModelHost.modelContextLengths` for why
* that's a hard safety requirement, not just a nicety: llama-swap treats this endpoint's
* `?model=` as a routing hint, and asking it about an unloaded model risks triggering an
@@ -173,20 +200,39 @@ function parseSizeGB(description: unknown): number | undefined {
* (below), which reads the actual launch flag `/running` reports, is the primary source;
* this is only used when that parse comes up empty (no recognized flag in `cmd`, or `cmd`
* itself unavailable).
*
* ⚠️ `/props` only ever RAISES the slot warning, never skips it: its `n_ctx` is the context
* fallback when `cmd` states no context flag at all, and `total_slots` is the slot count when
* `cmd` has no `--parallel`. It never widens the pool the slot check divides. And it is not
* free: llama-swap routes it through the model's process handler, which refreshes the model's
* last-use time, so probing a model with a `ttl` on every 5-minute sweep would keep it loaded
* forever. `discoverModels` therefore probes once per launch command (`propsByCmd`).
*
* Returns `undefined` when the request itself failed (network error, timeout, unreadable body),
* which is retried next sweep; a non-2xx status, or a 2xx without the fields, gives an object with
* them unset, which `propsForLaunch` remembers.
*/
async function fetchContextLength(
async function fetchServerProps(
host: Pick<CustomModelHost, 'baseUrl'>,
modelId: string,
headers: Record<string, string>
): Promise<number | undefined> {
): Promise<{ nCtx?: number; totalSlots?: number } | undefined> {
try {
const url = new URL(`${host.baseUrl.replace(/\/+$/, '')}/props`);
url.searchParams.set('model', modelId);
const res = await webviewFetch(url, { headers, signal: AbortSignal.timeout(PROPS_TIMEOUT_MS) });
if (!res.ok) return undefined;
const body = (await res.json()) as { n_ctx?: unknown; default_generation_settings?: { n_ctx?: unknown } };
// A non-2xx answer (an upstream that does not serve /props) is a real answer: empty, so it is
// remembered per launch command instead of being re-probed every sweep.
if (!res.ok) return {};
const body = (await res.json()) as {
n_ctx?: unknown;
total_slots?: unknown;
default_generation_settings?: { n_ctx?: unknown };
};
const nCtx = body.n_ctx ?? body.default_generation_settings?.n_ctx;
return typeof nCtx === 'number' && Number.isFinite(nCtx) && nCtx > 0 ? nCtx : undefined;
const positive = (v: unknown): number | undefined =>
typeof v === 'number' && Number.isFinite(v) && v > 0 ? v : undefined;
return { nCtx: positive(nCtx), totalSlots: positive(body.total_slots) };
} catch {
return undefined;
}
@@ -196,7 +242,7 @@ async function fetchContextLength(
* Parses the REAL configured context size out of llama-swap's own launch command for a
* model (`/running`'s `cmd` field, e.g. `"llama-server -m ... --fit-ctx 16384 ..."`) —
* the primary source for `modelContextLengths`, preferred over `/props`'s `n_ctx` (see
* `fetchContextLength`'s own doc comment for why that field is unreliable here). Checks
* `fetchServerProps`'s own doc comment for why that field is unreliable here). Checks
* `--fit-ctx` first (llama-swap's own auto-fit flag), then the plain llama.cpp
* `-c`/`--ctx-size`/`--ctx_size` flags a hand-written launch command might use instead.
* Returns `undefined` when `cmd` has none of these — not every launch command needs to
@@ -211,6 +257,51 @@ function parseCtxFromCmd(cmd: unknown): number | undefined {
return Number.isFinite(value) && value > 0 ? value : undefined;
}
/**
* Parses an explicit `--parallel N` / `-np N` (or `--parallel=N`) out of a launch command.
* Returns `undefined` when the command states none, including `-np -1` (llama.cpp's "pick
* automatically"), because a llama-server started without it chooses the slot count itself
* (4 on the build this was measured on) and only `/props`'s `total_slots` can say what it chose.
*/
function parseSlotsFromCmd(cmd: unknown): number | undefined {
if (typeof cmd !== 'string') return undefined;
const match = /(?:^|\s)(?:-np|--parallel)(?:\s+|=)(\d+)(?=\s|$)/.exec(cmd);
if (!match) return undefined;
const value = Number(match[1]);
return Number.isFinite(value) && value > 0 ? value : undefined;
}
/** The `/props` answer for the launch command a model was last probed under, by host and model id. */
const propsByCmd = new Map<string, { cmd: string; props: { nCtx?: number; totalSlots?: number } }>();
/** Forget what was probed (tests only: a changed launch command already probes again). */
export function clearPropsProbeCache(): void {
propsByCmd.clear();
}
/**
* `/props` for one loaded model, but only once per launch command: the probe refreshes the model's
* last-use time in llama-swap, so repeating it on every rediscovery sweep would stop a model with a
* `ttl` from ever unloading. A changed `cmd` (a reloaded or reconfigured model) probes again; a
* `cmd` that is not a string cannot be told apart, so it is probed each time; a probe that did not
* answer (network error, timeout) is not remembered; a non-2xx status is. Held in memory only, so a restart probes once more.
*/
async function propsForLaunch(
host: Pick<CustomModelHost, 'baseUrl'>,
modelId: string,
cmd: unknown,
headers: Record<string, string>
): Promise<{ nCtx?: number; totalSlots?: number } | undefined> {
const key = `${host.baseUrl}\0${modelId}`;
if (typeof cmd === 'string') {
const cached = propsByCmd.get(key);
if (cached?.cmd === cmd) return cached.props;
}
const props = await fetchServerProps(host, modelId, headers);
if (props && typeof cmd === 'string') propsByCmd.set(key, { cmd, props });
return props;
}
async function discoverModels(
host: Pick<CustomModelHost, 'baseUrl' | 'apiKey' | 'authStyle'>
): Promise<DiscoveryResult> {
@@ -239,6 +330,7 @@ async function discoverModels(
// fetchContextLength must only ever run against a model this server itself calls loaded.
const hasStatusField = entries.some((m) => m && typeof m === 'object' && 'status' in m);
const contextLengths: Record<string, number> = {};
const slotCounts: Record<string, number> = {};
if (hasStatusField) {
const loadedIds = entries
.filter((m) => m.status && typeof m.status === 'object' && (m.status as { value?: unknown }).value === 'loaded')
@@ -252,13 +344,32 @@ async function discoverModels(
const swapStatus = await getLlamaSwapStatus(host);
const cmdById = new Map(swapStatus.running.map((r) => [r.model, r.cmd]));
for (const id of loadedIds) {
const fromCmd = parseCtxFromCmd(cmdById.get(id));
const ctx = fromCmd ?? (await fetchContextLength(host, id, headers));
const cmd = cmdById.get(id);
let ctx = parseCtxFromCmd(cmd);
const cmdSlots = parseSlotsFromCmd(cmd);
let slots = cmdSlots;
// /props is only needed for whatever the launch command did not answer. The context
// still comes from the command whenever it states one (see fetchServerProps's doc
// comment for why /props's n_ctx is only a fallback); the slot count usually cannot
// come from the command at all, since an unset --parallel is chosen by the server.
if (ctx === undefined || slots === undefined) {
const props = await propsForLaunch(host, id, cmd, headers);
const ctxFromProps = ctx === undefined && props?.nCtx !== undefined;
ctx ??= props?.nCtx;
slots ??= props?.totalSlots;
// With an explicit --parallel N, llama.cpp reports /props's n_ctx PER SLOT (the unified
// KV pool is only the default when the count is auto), so it is already the figure one
// request can use, so the count is 1: recording N would divide it a second time, and
// recording nothing would leave an earlier round's count in place (applyDiscoveredModels
// merges onto what was learned before).
if (cmdSlots !== undefined && ctxFromProps) slots = 1;
}
if (ctx !== undefined) contextLengths[id] = ctx;
if (slots !== undefined) slotCounts[id] = slots;
}
}
}
return { models, contextLengths, sizesGB };
return { models, contextLengths, slotCounts, sizesGB };
}
/**
@@ -573,7 +684,7 @@ export function triggerLlamaSwapLoad(
}
function applyDiscoveredModels(host: CustomModelHost, result: DiscoveryResult): CustomModelHost {
const { models, contextLengths, sizesGB } = result;
const { models, contextLengths, slotCounts, sizesGB } = result;
const defaultModelId = host.defaultModelId && models.includes(host.defaultModelId) ? host.defaultModelId : undefined;
// Merge onto what's already known rather than replacing: a model not probed this round
// (not currently loaded) keeps whatever context length an earlier round already learned
@@ -581,6 +692,10 @@ function applyDiscoveredModels(host: CustomModelHost, result: DiscoveryResult):
const merged = { ...host.modelContextLengths, ...contextLengths };
const kept = Object.fromEntries(Object.entries(merged).filter(([id]) => models.includes(id)));
const modelContextLengths = Object.keys(kept).length > 0 ? kept : undefined;
// Slot counts follow the same loaded-only, keep-what-an-earlier-round-learned rule.
const mergedSlots = { ...host.modelSlotCounts, ...slotCounts };
const keptSlots = Object.fromEntries(Object.entries(mergedSlots).filter(([id]) => models.includes(id)));
const modelSlotCounts = Object.keys(keptSlots).length > 0 ? keptSlots : undefined;
// sizesGB, unlike contextLengths, is populated for every model in the SAME pass (no
// loaded-only restriction — see parseSizeGB), so this is closer to a plain replace, but
// still merges onto the previous round rather than dropping a size for a model whose
@@ -593,6 +708,7 @@ function applyDiscoveredModels(host: CustomModelHost, result: DiscoveryResult):
models,
defaultModelId,
modelContextLengths,
modelSlotCounts,
modelSizesGB,
lastDiscoveredAt: new Date().toISOString(),
};
+26 -5
View File
@@ -143,7 +143,8 @@ export function registerHookEventRoutes(
// bumped only by input that flowed through Codeman's own write path, so
// it read 0 forever for those panes and every consumer of "when did this
// pane last submit" silently degraded.
session?.markPromptSubmitted();
// The prompt text tells a person's prompt from the keep-alive's (prompt-cache-keepwarm.ts).
session?.markPromptSubmitted(typeof data.prompt === 'string' ? data.prompt : undefined);
}
// Persist when the conversation actually moved: `/clear` emits no
// completion event, so without this the successor id is lost on restart
@@ -183,9 +184,21 @@ export function registerHookEventRoutes(
// seeds from /api/approvals), and it suppresses the push: an alert nobody can answer
// is worth even less on a phone than in a tab.
let acknowledgedReason: string | undefined;
// A turn Codeman started itself (the prompt-cache keep-alive, prompt-cache-keepwarm.ts)
// ends with the same stop hook and idle prompt as a real one. Both open pre-acknowledged,
// so no tab, toast or push asks a person to look at a session that wants nothing from them.
const keepAliveReason =
session?.keepAliveTurnActive && (event === 'stop' || event === 'idle_prompt') ? 'cache keep-alive' : undefined;
const approvalKind = APPROVAL_KIND_BY_EVENT[event];
// Only a human spends an idle alert, so a keep-alive turn leaves a pending idle item exactly
// as it found it: its stop does not resolve it, and its idle prompt does not replace it (a
// replacement would forget the item was unseen, or that a person had seen it). With no idle
// item pending, the keep-alive's idle prompt opens a pre-acknowledged one.
const keepsPendingIdle = keepAliveReason !== undefined && approvalInbox.getForSession(sessionId)?.kind === 'idle';
if (session && hooksAvailableForMode(session.mode, sessionHookOptions(session))) {
if (approvalKind) {
if (approvalKind === 'idle' && keepsPendingIdle) {
// Left in place: no new item, and the broadcast below carries the keep-alive reason.
} else if (approvalKind) {
const toolInput =
safeData.tool_input && typeof safeData.tool_input === 'object'
? (safeData.tool_input as Record<string, unknown>)
@@ -209,6 +222,7 @@ export function registerHookEventRoutes(
// session that is watching its own work opens acknowledged, so it never arms an
// alert nobody can answer; notePrompt() carries the whole reasoning.
watching: session.watching,
preAcknowledged: keepAliveReason,
// Visible tmux frame first (it IS the dialog); raw byte-buffer tail as
// the fallback for direct-PTY sessions and the no-op test mux.
capture: () => {
@@ -219,11 +233,16 @@ export function registerHookEventRoutes(
});
approvalId = item.id;
acknowledgedReason = item.acknowledgedReason;
} else if (APPROVAL_RESOLVING_EVENTS.has(event)) {
} else if (APPROVAL_RESOLVING_EVENTS.has(event) && !keepsPendingIdle) {
approvalInbox.resolveForSession(sessionId, 'resolved_in_terminal');
}
}
acknowledgedReason ??= keepAliveReason;
// The keep-alive turn's own idle prompt is the last thing it sends, so the window ends here
// rather than on a fixed clock that a slow turn could outlast.
if (keepAliveReason && event === 'idle_prompt') session?.endKeepAliveTurn();
ctx.broadcast(`hook:${event}`, {
sessionId,
timestamp: Date.now(),
@@ -259,9 +278,11 @@ export function registerHookEventRoutes(
// Claude pane; only the ones where the conversation actually moved (a /clear
// successor) carry information, and recording the rest would push a row into
// the Summary timeline and /api/search per turn and evict useful rows from
// the 1000-event FIFO (#367 merge-time fix).
// the 1000-event FIFO (#367 merge-time fix). A keep-alive turn's stop and idle prompt are
// left out too: the summary files a `stop` as a warning, which the Away digest lists under
// Needs Attention, and that turn is Codeman's own, not the agent's.
const summaryTracker = ctx.runSummaryTrackers.get(sessionId);
if (summaryTracker && (event !== 'prompt_submitted' || conversationChanged)) {
if (summaryTracker && !keepAliveReason && (event !== 'prompt_submitted' || conversationChanged)) {
summaryTracker.recordHookEvent(event, safeData);
}
+1
View File
@@ -32,6 +32,7 @@ export { registerVoiceRoutes } from './voice-routes.js';
export { registerWebviewRoutes, tryWebviewRefererFallback } from './webview-routes.js';
export { registerTabLayoutRoutes } from './tab-layout-routes.js';
export { registerMcpSyncRoutes } from './mcp-sync-routes.js';
export { registerConfigBackupRoutes, runConfigBackup } from './config-backup-routes.js';
export { registerWebhookRoutes } from './webhook-routes.js';
export { registerDoctorRoutes } from './doctor-routes.js';
export {
+2 -2
View File
@@ -413,8 +413,8 @@ export function registerRalphRoutes(
const promptPath = join(casePath, '@ralph_prompt.md');
writeFileSync(promptPath, fullPrompt, 'utf-8');
// Register session
await ctx.addSession(session);
// Register session (in its case's default session group, if one is set)
await ctx.addSession(session, { caseName });
ctx.store.incrementSessionsCreated();
ctx.persistSessionState(session);
await ctx.setupSessionListeners(session);
+2
View File
@@ -201,6 +201,8 @@ export function registerRebootRestoreRoutes(app: FastifyInstance, ctx: RebootRes
(saved as { __attachmentHistory?: SessionAttachmentHistoryItem[] }).__attachmentHistory ??
saved.attachmentHistory,
lastSubmitAt: saved.lastSubmitAt,
lastRealPromptAt: saved.lastRealPromptAt,
keepWarmEnabled: saved.keepWarmEnabled,
claudeSessionChain: saved.claudeSessionChain,
lastActivityAt: saved.lastActivityAt,
owner: saved.owner,
+107 -18
View File
@@ -4,9 +4,9 @@
* auto-clear, auto-compact, image watcher, flicker filter, and logout.
*/
import { FastifyInstance, type FastifyReply } from 'fastify';
import { FastifyInstance, type FastifyReply, type FastifyRequest } from 'fastify';
import { z } from 'zod';
import { join, dirname, extname, basename } from 'node:path';
import { join, dirname, extname, basename, resolve as resolvePath } from 'node:path';
import { homedir } from 'node:os';
import { existsSync, statSync, mkdirSync, writeFileSync } from 'node:fs';
import { execFile } from 'node:child_process';
@@ -55,6 +55,7 @@ import {
AutoClearSchema,
AutoCompactSchema,
AutoResumeSchema,
KeepWarmSchema,
PinSessionSchema,
ImageWatcherSchema,
FlickerFilterSchema,
@@ -76,7 +77,7 @@ import {
} from './custom-model-routes.js';
import { matchesPattern } from '../../config/cli-registry/patterns.js';
import { ownerLayoutKey } from '../../tab-layout-persistence.js';
import { TabLayoutValidationError } from '../../tab-layout.js';
import { TabLayoutValidationError, type TabLayout } from '../../tab-layout.js';
import {
sessionWaits,
resolveWaitSignals,
@@ -107,6 +108,7 @@ import {
ownerFor,
parseBody,
persistAndBroadcastSession,
readJsonConfig,
resolveAgentCaseOrigin,
resolveCasesDir,
resolveParentSessionId,
@@ -155,7 +157,15 @@ import {
setHistoryIndexRefresher,
setHistorySessionIndex,
} from '../session-history-index.js';
import type { SessionPort, EventPort, ConfigPort, InfraPort, AuthPort, TabLayoutPort } from '../ports/index.js';
import type {
SessionPort,
EventPort,
ConfigPort,
InfraPort,
AuthPort,
TabLayoutPort,
KeepWarmPort,
} from '../ports/index.js';
import { RunSummaryTracker } from '../../run-summary.js';
import { MAX_INPUT_LENGTH, MAX_SESSION_NAME_LENGTH } from '../../config/terminal-limits.js';
@@ -204,6 +214,42 @@ import { UPLOADS_DIR } from '../paste-image-gc.js';
const LINKED_CASES_FILE = dataPath('linked-cases.json');
const CODEMAN_CONFIG_DIR = getDataDir();
const SAFE_CASE_NAME = /^[a-zA-Z0-9_-]+$/;
/** `<repo>/.claude/worktrees/<name>`: the layout Claude Code's own worktree feature creates. */
const CLAUDE_WORKTREE_PATH = /^(.*)\/\.claude\/worktrees\/([^/]+)\/?$/;
/** Did the committed layout put this session in a named group (not Ungrouped)? */
function landedInGroup(layout: TabLayout | undefined, sessionId: string): boolean {
return !!layout?.groups.some((group) => group.refs.some((ref) => ref.kind === 'session' && ref.id === sessionId));
}
/**
* The case a POST /api/sessions `workingDir` belongs to, for its default session
* group: a direct child of the caller's case space, or (admin / single-user, as
* GET /api/cases lists them) a linked case's exact path, or a Claude worktree
* (`<case>/.claude/worktrees/<name>`) of either. Lexical only: the browser sends
* the path the case list gave it, and this must never touch a possibly-dead
* mount. Anything else is no case, which keeps today's placement.
*/
async function inferLocalCaseName(req: FastifyRequest, workingDir: string): Promise<string | undefined> {
// The Git worktrees modal launches Claude through this route in the worktree, and every other CLI
// through quick-start with the case name: both are sessions of that case.
const resolved = resolvePath(workingDir);
const target = CLAUDE_WORKTREE_PATH.exec(resolved)?.[1] || resolved;
const user = getAuthUser(req);
if (dirname(target) === resolvePath(resolveCasesDir(user))) {
const name = basename(target);
if (SAFE_CASE_NAME.test(name)) return name;
}
if (isMultiUserMode() && !isAdmin(req)) return undefined;
const linked = await readJsonConfig<Record<string, string>>(LINKED_CASES_FILE, 'linked cases', {});
for (const [name, path] of Object.entries(linked)) {
if (SAFE_CASE_NAME.test(name) && typeof path === 'string' && resolvePath(path) === target) return name;
}
return undefined;
}
// Pre-compiled regex for terminal buffer cleaning (avoids per-request compilation)
// eslint-disable-next-line no-control-regex
const CLAUDE_BANNER_PATTERN = /\x1b\[1mClaud/;
@@ -1024,7 +1070,7 @@ function wakeableHost(host: RemoteHost): WakeableRemote {
export function registerSessionRoutes(
app: FastifyInstance,
ctx: SessionPort & EventPort & ConfigPort & InfraPort & AuthPort & TabLayoutPort,
ctx: SessionPort & EventPort & ConfigPort & InfraPort & AuthPort & TabLayoutPort & KeepWarmPort,
/** Test seam: inject a registry with fake IO instead of the real TCP/WoL probes. */
options: { remoteWake?: RemoteWakeRegistry } = {}
): RemoteWakeRegistry {
@@ -1412,9 +1458,14 @@ export function registerSessionRoutes(
parentSessionId: resolveParentSessionId(ctx, req, body.parentSessionId, owner),
});
const tabLayout = body.tabGroupId
? await ctx.addSession(session, { tabGroupId: body.tabGroupId })
: await ctx.addSession(session);
// Only a named workingDir: without one the session starts in the server's own cwd, which is no case.
const placementCase = remote || !body.workingDir ? undefined : await inferLocalCaseName(req, workingDir);
const placement = {
...(body.tabGroupId ? { tabGroupId: body.tabGroupId } : {}),
...(placementCase ? { caseName: placementCase } : {}),
};
const tabLayout =
Object.keys(placement).length > 0 ? await ctx.addSession(session, placement) : await ctx.addSession(session);
ctx.store.incrementSessionsCreated();
ctx.persistSessionState(session);
await ctx.setupSessionListeners(session);
@@ -1432,9 +1483,13 @@ export function registerSessionRoutes(
// Avoids serializing 2-3MB of terminal+text buffers per session creation.
const lightState = ctx.getSessionStateWithRespawn(session);
ctx.broadcast(SseEvent.SessionCreated, lightState);
// A grouped creation also returns the layout that placed it, so the browser
// draws the tab inside its group at once instead of after a re-read.
return { session: lightState, ...(body.tabGroupId ? { tabLayout } : {}) };
// A grouped creation (asked for, or its case's default group) also returns the
// layout that placed it, so the browser draws the tab inside its group at once
// instead of after a re-read.
return {
session: lightState,
...(body.tabGroupId || landedInGroup(tabLayout, session.id) ? { tabLayout } : {}),
};
});
// ========== Rename Session ==========
@@ -1526,6 +1581,7 @@ export function registerSessionRoutes(
return createErrorResponse(ApiErrorCode.NOT_FOUND, 'Model endpoint not found');
}
const contextLength = endpoint.modelContextLengths?.[body.modelId];
const slotCount = endpoint.modelSlotCounts?.[body.modelId];
// Some CLIs (today: only claude) carry enough of their own fixed system-prompt/tool-
// schema overhead that a small enough real context guarantees a first-message failure
@@ -1536,12 +1592,13 @@ export function registerSessionRoutes(
// `confirmed`, which still means both) — NOT by `confirmedSwap`: this warning is
// about the caller's own session, and the swap warning below is about someone
// else's, so an answer to one is not consent to the other.
if (!(body.confirmed || body.confirmedContext) && exceedsSafeContextFloor(entry, contextLength)) {
if (!(body.confirmed || body.confirmedContext) && exceedsSafeContextFloor(entry, contextLength, slotCount)) {
return {
requiresContextWarning: true,
modelId: body.modelId,
contextLength,
minSafeContextTokens: CLAUDE_MIN_SAFE_CONTEXT_TOKENS,
...(slotCount && slotCount > 1 ? { slotCount } : {}),
};
}
@@ -3556,6 +3613,39 @@ export function registerSessionRoutes(
};
});
// ========== Prompt-cache keep-warm ==========
// Per-session opt-in for the keep-alive nudge (prompt-cache-keepwarm.ts). `enabled: null`
// clears the choice so the app-wide default applies again. Refused for a CLI that reports
// no cache telemetry on its statusline: without `expires_at` there is nothing to schedule.
app.post('/api/sessions/:id/keep-warm', async (req) => {
const { id } = req.params as { id: string };
const body = parseBody(KeepWarmSchema, req.body, 'Invalid request body');
const session = findSessionOrFail(ctx, id, req);
if (!getCli(session.mode)?.capabilities.statusLineTelemetry) {
return createErrorResponse(
ApiErrorCode.INVALID_INPUT,
'Prompt-cache keep-warm needs the cache telemetry this CLI does not report'
);
}
session.setKeepWarmEnabled(body.enabled ?? undefined);
// Re-evaluate BEFORE the state broadcast and the response: both carry `keepWarm`, and a
// page that received the previous status would overwrite the one the toggle just produced.
await ctx.keepWarm.schedule(id);
persistAndBroadcastSession(ctx, session);
return {
success: true,
data: {
keepWarm: {
enabled: session.keepWarmEnabled ?? null,
status: session.keepWarm,
},
},
};
});
// ========== Auto-Resume (usage-limit pause) ==========
app.post('/api/sessions/:id/auto-resume', async (req) => {
@@ -4290,6 +4380,7 @@ export function registerSessionRoutes(
const cmEndpoint = cmHosts.find((h) => h.id === customModel.endpointId);
if (!cmEndpoint) return createErrorResponse(ApiErrorCode.NOT_FOUND, 'Model endpoint not found');
const cmContextLength = cmEndpoint.modelContextLengths?.[customModel.modelId];
const cmSlotCount = cmEndpoint.modelSlotCounts?.[customModel.modelId];
// See the dedicated route's own comment for the full reasoning: some CLIs' own fixed
// overhead can exceed a small enough real context on the very first message,
@@ -4299,13 +4390,14 @@ export function registerSessionRoutes(
// `confirmedSwap`.
if (
!(customModel.confirmed || customModel.confirmedContext) &&
exceedsSafeContextFloor(cmEntry, cmContextLength)
exceedsSafeContextFloor(cmEntry, cmContextLength, cmSlotCount)
) {
return {
requiresContextWarning: true,
modelId: customModel.modelId,
contextLength: cmContextLength,
minSafeContextTokens: CLAUDE_MIN_SAFE_CONTEXT_TOKENS,
...(cmSlotCount && cmSlotCount > 1 ? { slotCount: cmSlotCount } : {}),
};
}
@@ -4461,7 +4553,7 @@ export function registerSessionRoutes(
}
}
const tabLayout = tabGroupId ? await ctx.addSession(session, { tabGroupId }) : await ctx.addSession(session);
const tabLayout = await ctx.addSession(session, { caseName, ...(tabGroupId ? { tabGroupId } : {}) });
ctx.store.incrementSessionsCreated();
ctx.persistSessionState(session);
await ctx.setupSessionListeners(session);
@@ -4553,7 +4645,7 @@ export function registerSessionRoutes(
caseName,
...(worktree ? { worktree, workingDir: workspacePath } : {}),
...(customModel ? { modelSwapInProgress: qsCustomModelSwapInProgress } : {}),
...(tabGroupId ? { tabLayout } : {}),
...(tabGroupId || landedInGroup(tabLayout, session.id) ? { tabLayout } : {}),
};
} catch (err) {
// Clean up session on error to prevent orphaned resources
@@ -4684,9 +4776,6 @@ export function registerSessionRoutes(
worktreeRepo?: string;
};
/** `<repo>/.claude/worktrees/<name>` — the layout Claude Code's own worktree feature creates. */
const CLAUDE_WORKTREE_PATH = /^(.*)\/\.claude\/worktrees\/([^/]+)\/?$/;
/**
* Recover cwd / branch / worktree from a transcript chunk.
*
+10 -6
View File
@@ -29,10 +29,10 @@ import {
} from '../../usage-telemetry.js';
import { SessionPromptCache, SessionStatusTelemetry } from '../sse-events.js';
import { setLatestPlanUsage } from '../plan-usage-latest.js';
import type { SessionPort, EventPort } from '../ports/index.js';
import type { SessionPort, EventPort, KeepWarmPort } from '../ports/index.js';
import { getCli } from '../../config/cli-registry/index.js';
export function registerStatusTelemetryRoutes(app: FastifyInstance, ctx: SessionPort & EventPort): void {
export function registerStatusTelemetryRoutes(app: FastifyInstance, ctx: SessionPort & EventPort & KeepWarmPort): void {
// Last broadcast telemetry signature per session — the statusline fires on
// every assistant message, so we only rebroadcast when the value changes.
const lastSig = new Map<string, string>();
@@ -74,10 +74,14 @@ export function registerStatusTelemetryRoutes(app: FastifyInstance, ctx: Session
// arrive out of order; a stale one then briefly overwrites newer state until the next
// render corrects it. Accepted for a display-only readout rather than ordered here.
const promptCache = reportsStatusLine ? parsePromptCache(payload) : null;
if (promptCache && session?.setPromptCache(promptCache)) {
ctx.broadcast(SessionPromptCache, { sessionId, ...session.promptCache });
// Persisted on change, like a model change, so a crash restores the latest report.
ctx.persistSessionState(session);
if (promptCache) {
if (session?.setPromptCache(promptCache)) {
ctx.broadcast(SessionPromptCache, { sessionId, ...session.promptCache });
// Persisted on change, like a model change, so a crash restores the latest report.
ctx.persistSessionState(session);
}
// Every report moves `expires_at`, so the keep-warm re-evaluates on each one.
void ctx.keepWarm.schedule(sessionId);
}
// Plan-usage limits (account-wide) → broadcast to the header chip, when
+19 -2
View File
@@ -51,7 +51,15 @@ import { SseEvent } from '../sse-events.js';
import { getInstallInfo, checkForUpdate, startUpdate, getUpdateStatusForApi } from '../self-update.js';
import { getRepositoryStatus } from '../repo-status.js';
import type { SessionPort, EventPort, ConfigPort, InfraPort, AuthPort, TabLayoutPort } from '../ports/index.js';
import type {
SessionPort,
EventPort,
ConfigPort,
InfraPort,
AuthPort,
TabLayoutPort,
KeepWarmPort,
} from '../ports/index.js';
import { AUTH_COOKIE_NAME } from '../middleware/auth.js';
import { QR_AUTH_FAILURE_MAX } from '../../config/tunnel-config.js';
import { AUTH_SESSION_TTL_MS } from '../../config/auth-config.js';
@@ -168,7 +176,7 @@ export function settingsForNonAdmin(settings: unknown): unknown {
export function registerSystemRoutes(
app: FastifyInstance,
ctx: SessionPort & EventPort & ConfigPort & InfraPort & AuthPort & TabLayoutPort
ctx: SessionPort & EventPort & ConfigPort & InfraPort & AuthPort & TabLayoutPort & KeepWarmPort
): void {
const windowStatesPath = dataPath('subagent-window-states.json');
const parentMapPath = dataPath('subagent-parents.json');
@@ -1103,6 +1111,15 @@ export function registerSystemRoutes(
// drift. Same convention as the tmuxHistoryLimit block above.
// Handle subagent tracking toggle dynamically
toggleService((merged.subagentTrackingEnabled as boolean) ?? true, subagentWatcher, 'Subagent watcher');
// The prompt-cache keep-warm keeps its default and cap in memory; a changed default or cap
// makes it re-read them and re-evaluate every session now rather than at its next cache report.
// Compared on `merged`, not tested for in the body: the browser sends both keys on every save.
if (
merged.promptCacheKeepWarm !== existing.promptCacheKeepWarm ||
merged.promptCacheKeepWarmMaxHours !== existing.promptCacheKeepWarmMaxHours
) {
ctx.keepWarm.rescheduleAll();
}
// Handle ultracode/workflow run watcher toggle dynamically (default OFF).
// Either the docked panel OR the floating windows keep the watcher running.
+1
View File
@@ -473,6 +473,7 @@ async function proxyRequest(
sessionCookieName: AUTH_COOKIE_NAME,
refererPath:
typeof req.headers.referer === 'string' ? stripProxyPrefix(req.headers.referer, cap, basePath) : undefined,
relayBodyLength: hasBody && req.body !== undefined,
});
// #237: the timeout bounds TIME-TO-HEADERS only. A plain AbortSignal.timeout on
+33 -2
View File
@@ -1483,6 +1483,10 @@ export const SettingsUpdateSchema = z
* broadcast and /api/search, which is the user's choice to make.
*/
autoNameSessions: z.boolean().optional(),
/** Prompt-cache keep-warm default for sessions that have not chosen (Session Options overrides it). */
promptCacheKeepWarm: z.boolean().optional(),
/** Hours after the last real prompt during which keep-alives are sent (default 2, DEFAULT_KEEP_WARM_MAX_HOURS). */
promptCacheKeepWarmMaxHours: z.number().min(0.5).max(24).optional(),
/**
* Spawned tabs follow their parent: a session created by another one keeps
* following its parent's tab (into its group, moving with it) until moved by
@@ -1521,6 +1525,18 @@ export const SettingsUpdateSchema = z
* opt-in. While OFF, GET/POST /api/mcp-sync answer 403 and the Settings controls are hidden.
*/
mcpSyncEnabled: z.boolean().optional(),
/**
* Config backups (src/config-backup.ts): timestamped snapshots of the customized config files,
* taken at startup and whenever their content changes. SYNCED. Absent means ON (the feature
* protects against an event nobody schedules); the folder defaults to `<data dir>/backups/config`.
*/
configBackupEnabled: z.boolean().optional(),
/** Absolute folder (a leading `~` is expanded); empty or absent = the default folder. */
configBackupDir: z.string().max(1024).optional(),
/** Keep at most this many snapshots. */
configBackupKeepCount: z.number().int().min(2).max(500).optional(),
/** Also drop snapshots older than this many days; 0 = no age limit. */
configBackupKeepDays: z.number().int().min(0).max(3650).optional(),
/**
* Read My Mind predictor model override. Empty/absent = the AI-checker
* default (opus: prediction quality is the product and it runs only on an
@@ -1871,6 +1887,9 @@ export const AutoResumeSchema = z.object({
enabled: z.boolean(),
});
/** `POST /api/sessions/:id/keep-warm`: `null` clears the session's choice so the app-wide default applies. */
export const KeepWarmSchema = z.object({ enabled: z.boolean().nullable() });
/** POST /api/sessions/:id/pin (COD-139) — explicit pin state for idempotency. */
export const PinSessionSchema = z.object({
pinned: z.boolean(),
@@ -1905,7 +1924,7 @@ export const ScheduledRunSchema = z.object({
/** 'HH:MM' 24-hour time. */
const hhmmSchema = z.string().regex(/^([01]?\d|2[0-3]):[0-5]\d$/, 'Time must be HH:MM (24-hour)');
/** Prompt delivery is single-line only (writeViaMux/Ink constraint) — reject newlines outright. */
/** Prompt delivery is single-line only (the submit check cannot reliably recognise a multi-line paste) — reject newlines outright. */
const noNewlines = (v: string) => !/[\r\n]/.test(v);
/** Shared field shape for creating/updating a scheduled job. */
@@ -1978,10 +1997,20 @@ export const CaseOrderSchema = z.object({
order: z.array(z.string().regex(/^[a-zA-Z0-9_-]+$/, 'Invalid case name format')),
});
/**
* PUT /api/cases/:name/default-group — the tab group new sessions of this case
* land in: one of the caller's own group ids, or null for Ungrouped. Same bounds
* as tabGroupIdSchema; the route checks the id against the caller's layout.
*/
export const CaseDefaultGroupSchema = z.object({
groupId: z.string().min(1).max(100).nullable(),
});
/** PUT /api/session-order — global tab order (ordered sessionIds), COD-131 */
export const SessionOrderUpdateSchema = z.object({
// Bounded defensively: ids are uuid-ish (<=100 chars) and the client pushes only
// its open-tab order (max sessions is 50) — 500 leaves ample headroom while
// its open-tab order (max sessions is 50 by default, at most 462 via CODEMAN_MAX_SESSIONS,
// see MAX_CONCURRENT_SESSIONS_CEILING) — 500 leaves enough room while
// keeping a hostile/buggy client from persisting megabytes into state.json.
order: z.array(z.string().max(100)).max(500),
});
@@ -2266,6 +2295,8 @@ export const CustomModelHostSchema = z.object({
// round-tripping the GET response back through PUT (edit-save) doesn't drop it.
modelContextLengths: z.record(z.string().max(200), z.number().int().positive().max(100_000_000)).optional(),
// Same reasoning as modelContextLengths above.
modelSlotCounts: z.record(z.string().max(200), z.number().int().positive().max(4096)).optional(),
// Same reasoning as modelContextLengths above.
modelSizesGB: z.record(z.string().max(200), z.number().positive().max(100_000)).optional(),
});
+130 -4
View File
@@ -96,7 +96,13 @@ import { RunSummaryTracker } from '../run-summary.js';
import { PlanOrchestrator } from '../plan-orchestrator.js';
import { OrchestratorLoop } from '../orchestrator-loop.js';
import { getLifecycleLog } from '../session-lifecycle-log.js';
import { applyWorkspaceHooks, pruneAgentSessionPreambles, removeAgentSessionPreamble } from '../hooks-config.js';
import {
applyWorkspaceHooks,
pruneAgentSessionPreambles,
readKeepWarmSettingsOrThrow,
readPlanUsageTelemetryEnabled,
removeAgentSessionPreamble,
} from '../hooks-config.js';
import { PushSubscriptionStore } from '../push-store.js';
import webpush from 'web-push';
import { SseStreamManager } from './sse-stream-manager.js';
@@ -113,6 +119,9 @@ import { intentStore } from '../intent-store.js';
import { AI_CHECK_MODEL } from '../config/ai-defaults.js';
import { approvalInbox } from './approval-inbox.js';
import { SCREEN_BLOCKED_SOURCE } from './screen-blocked.js';
import { PromptCacheKeepWarmService } from '../prompt-cache-keepwarm.js';
import { composerIsEmpty, plainFrame } from '../session-submit-verifier.js';
import { detectUsageLimitPause } from '../usage-limit-patterns.js';
import { stopDeepSeekWeb } from '../deepseek-web-server.js';
import {
wireRespawnListeners,
@@ -162,9 +171,15 @@ import { MAX_PASTE_IMAGE_BYTES } from '../config/buffer-limits.js';
import { resolveTerminalHistoryConfig } from '../config/terminal-history.js';
import { SseEvent } from './sse-events.js';
import { MAX_ROUTE_PARAM_LENGTH } from './route-helpers.js';
import { getLatestPlanUsage, setLatestCodexPlanUsage } from './plan-usage-latest.js';
import { getLatestPlanUsage, setLatestCodexPlanUsage, setLatestCopilotPlanUsage } from './plan-usage-latest.js';
import { telemetrySignature } from '../usage-telemetry.js';
import { readCodexPlanUsage, resolveCodexBinaryPath } from '../utils/codex-cli-resolver.js';
import { isCopilotAvailable } from '../utils/copilot-cli-resolver.js';
import {
readCopilotPlanUsage,
readCopilotPlanUsageIfEnabled,
resolveCopilotToken,
} from '../utils/copilot-plan-usage.js';
import type { ScheduledRun } from './ports/index.js';
import {
registerAuthMiddleware,
@@ -208,6 +223,8 @@ import {
registerWebviewRoutes,
registerTabLayoutRoutes,
registerMcpSyncRoutes,
registerConfigBackupRoutes,
runConfigBackup,
registerWebhookRoutes,
registerDoctorRoutes,
registerCustomModelRoutes,
@@ -229,6 +246,12 @@ const __dirname = dirname(fileURLToPath(import.meta.url));
// while capping growth of `sseClientsById` and blocking pathological inputs.
const SSE_CLIENT_ID_RE = /^[A-Za-z0-9_-]{8,64}$/;
const CODEX_USAGE_POLL_INTERVAL_MS = 5 * 60_000;
// Copilot's quota is read over the network from GitHub, and a monthly figure moves slowly.
const COPILOT_USAGE_POLL_INTERVAL_MS = 10 * 60_000;
// Config backups: one cheap read-and-hash of a handful of small files; a snapshot is written only when
// their content changed since the latest one, so this is one entry per real change, not per tick.
const CONFIG_BACKUP_INTERVAL_MS = 5 * 60_000;
const CUSTOM_MODEL_REDISCOVER_INTERVAL_MS = 5 * 60_000;
// Much shorter than the model-LIST refresh above on purpose: this catches an actual
// eviction (a session's model no longer loaded, silently swapped out by another
@@ -317,6 +340,50 @@ export class WebServer extends EventEmitter {
private _customModelDisplacedNotified: Set<string> = new Set();
/** Cron service (assigned in setupRoutes). */
private cronService!: CronService;
// Prompt-cache keep-warm (prompt-cache-keepwarm.ts): the rulebook and timers live in the
// service; the server only lends it the facts a decision needs. A field initializer, not a
// setupRoutes() step: cleanup and listener wiring reach it on servers that never set up routes
// (the pane-exit and discard tests build a bare WebServer), and every port reads `this.*` lazily.
private readonly keepWarmService = new PromptCacheKeepWarmService({
getSession: (id) => this.sessions.get(id),
sessionIds: () => this.sessions.keys(),
readSettings: readKeepWarmSettingsOrThrow,
isAutomationActive: (id) => {
const session = this.sessions.get(id);
if (!session) return true;
return (
(this.respawnControllers.get(id)?.isRunning ?? false) ||
session.ralphTracker.loopState.active ||
session.currentTaskId !== null ||
sessionWaits.signalWaiterCount(id) > 0
);
},
hasPendingApproval: (id) =>
approvalInbox.listPending().some((item) => item.sessionId === id && item.kind !== 'idle'),
readPane: (view) => {
const session = view as Session;
const muxName = session.muxName;
// `capture-pane -p -e`: the visible frame with its styles, which is how `composerIsEmpty()`
// tells Claude Code's faint prompt suggestion from a typed draft.
const text = muxName ? (this.mux.capturePaneText?.(muxName, undefined, { styles: true }) ?? null) : null;
if (!text) return { composerEmpty: null, limitShown: false };
const limit = detectUsageLimitPause(plainFrame(text));
return {
composerEmpty: composerIsEmpty(text, getCli(session.mode)?.capabilities.workDetect?.promptGlyph ?? '') ?? null,
limitShown: limit !== null && limit.resetAt > Date.now(),
};
},
publish: (id, status) => {
const session = this.sessions.get(id);
if (!session?.setKeepWarmStatus(status)) return;
this.broadcast(SseEvent.SessionKeepWarm, { sessionId: id, ...status });
// Change-only, so one line per transition names whichever guard holds it now.
if (status.state !== 'off' || status.reason !== 'off') {
console.log(`[KeepWarm] ${id.slice(0, 8)}: ${status.state}${status.reason ? ` (${status.reason})` : ''}`);
}
},
log: (message) => console.log(message),
});
/**
* Wake-on-LAN registry, returned by `registerSessionRoutes`. Held for its LIFETIME
* only — `drop()` on session cleanup, `stop()` on shutdown. Waking from here would
@@ -354,6 +421,8 @@ export class WebServer extends EventEmitter {
private lastRecordedTokens: Map<string, { input: number; output: number }> = new Map();
private codexUsageRefreshInFlight = false;
private lastCodexUsageSignature: string | null = null;
private copilotUsageRefreshInFlight = false;
private lastCopilotUsageSignature: string | null = null;
// Server startup time for respawn grace period calculation
private readonly serverStartTime: number = Date.now();
// Pending respawn start timers (for cleanup on shutdown)
@@ -470,6 +539,7 @@ export class WebServer extends EventEmitter {
// Fresh, not the 2s cache: the settings PUT does not invalidate it, and a
// flip must apply to the very next spawn.
childrenFollowParent: async () => (await this.readSettings(true)).spawnedTabsFollowParent === true,
caseDefaults: this.store,
});
if (this.testMode) this.tabLayouts.markRestorationSkipped();
@@ -725,8 +795,8 @@ export class WebServer extends EventEmitter {
/**
* Add a tentative session only after its owner layout accepts the creation.
* The session enters the live map inside the layout service's owner lock, so
* the placement it asked for (`tabGroupId`) lands in the same versioned write
* that first records it.
* the placement it asked for (`tabGroupId`, else its case's default group)
* lands in the same versioned write that first records it.
*/
private async registerSessionWithLayout(session: Session, placement?: SessionPlacementHint): Promise<TabLayout> {
return this.tabLayouts.sessionCreated(ownerLayoutKey(session.owner), session.id, placement, () => {
@@ -747,6 +817,7 @@ export class WebServer extends EventEmitter {
return {
// SessionPort
sessions: this.sessions as ReadonlyMap<string, Session>,
keepWarm: this.keepWarmService,
addSession: this.registerSessionWithLayout.bind(this),
tabLayouts: this.tabLayouts,
cleanupSession: this.cleanupSession.bind(this),
@@ -1179,6 +1250,7 @@ export class WebServer extends EventEmitter {
registerWebviewRoutes(this.app, ctx, this.basePath);
registerTabLayoutRoutes(this.app, ctx);
registerMcpSyncRoutes(this.app);
registerConfigBackupRoutes(this.app);
registerWebhookRoutes(this.app, {
notifier: this.webhookNotifier,
configDir: getDataDir(),
@@ -1416,6 +1488,7 @@ export class WebServer extends EventEmitter {
coordinateLayout = true
): Promise<void> {
const session = this.sessions.get(sessionId);
this.keepWarmService.remove(sessionId);
const pinned = session?.pinned === true || this.store.getSession(sessionId)?.pinned === true;
if (coordinateLayout && session && killMux && !pinned) {
return this.tabLayouts.runSessionDeletion([{ id: sessionId, owner: session.owner }], () =>
@@ -1886,6 +1959,9 @@ export class WebServer extends EventEmitter {
}
private async setupSessionListeners(session: Session): Promise<void> {
// First keep-warm evaluation: publishes the effective opt-in for the Session Options
// readout even before any cache report arrives.
void this.keepWarmService.schedule(session.id);
// Idempotent: the wiring exit handler detaches ALL listeners on every PTY exit
// (removeSessionListenerRefs), so the re-attach routes (/interactive,
// /interactive-respawn, /shell) call this again to restore observability
@@ -1918,6 +1994,7 @@ export class WebServer extends EventEmitter {
private buildSessionListenerDeps() {
return {
broadcast: this.broadcast.bind(this),
keepWarmSchedule: (id: string) => void this.keepWarmService.schedule(id),
batchTerminalData: this.batchTerminalData.bind(this),
batchTaskUpdate: this.batchTaskUpdate.bind(this),
broadcastSessionStateDebounced: this.broadcastSessionStateDebounced.bind(this),
@@ -2900,6 +2977,34 @@ export class WebServer extends EventEmitter {
}
}
/**
* Copilot's monthly premium-request quota for the shared plan-usage chip. Host-scoped and read-only
* like the Codex poll, and additionally gated by `readCopilotPlanUsageIfEnabled()`: only while
* plan-usage display is on (so turning the chip off also stops the outbound request), only with a
* sign-in token and only with the Copilot CLI installed; nothing is sent to GitHub otherwise.
*/
private async refreshCopilotPlanUsage(): Promise<void> {
if (this.copilotUsageRefreshInFlight) return;
this.copilotUsageRefreshInFlight = true;
try {
// A null clears the row, so switching the chip off also takes the last quota off the page.
const usage = await readCopilotPlanUsageIfEnabled({
planUsageEnabled: readPlanUsageTelemetryEnabled,
resolveToken: () => resolveCopilotToken(),
copilotInstalled: isCopilotAvailable,
read: (token) => readCopilotPlanUsage({ token }),
});
const signature = usage ? telemetrySignature(usage) : '';
if (signature === this.lastCopilotUsageSignature) return;
this.lastCopilotUsageSignature = signature;
const snapshot = setLatestCopilotPlanUsage(usage);
this.cachedLightState = null;
this.broadcast(SseEvent.SessionStatusTelemetry, snapshot);
} finally {
this.copilotUsageRefreshInFlight = false;
}
}
async start(): Promise<void> {
// Multi-user first boot: create the initial admin from CODEMAN_USERNAME/PASSWORD
// if there are no users yet, else refuse to start (there would be no way in).
@@ -3067,6 +3172,18 @@ export class WebServer extends EventEmitter {
// $CODEMAN_HOOK_SECRET_FILE — hook curls cat that path at execution time.
getHookSecret();
// Config backups: a snapshot at start (the state the process found) and then on change.
if (!this.testMode) {
const backup = (reason: string) =>
void runConfigBackup(reason).catch((err) =>
console.warn('[config-backup] snapshot failed:', err instanceof Error ? err.message : err)
);
backup('startup');
this.cleanup.setInterval(() => backup('auto'), CONFIG_BACKUP_INTERVAL_MS, {
description: 'Config backup',
});
}
// Main Codex subscription limits come from the signed-in local CLI. Keep
// this read-only and host-scoped; multi-user SSE routing makes it admin-only.
if (!this.testMode) {
@@ -3074,6 +3191,10 @@ export class WebServer extends EventEmitter {
this.cleanup.setInterval(() => void this.refreshCodexPlanUsage(), CODEX_USAGE_POLL_INTERVAL_MS, {
description: 'Codex plan-usage refresh',
});
void this.refreshCopilotPlanUsage();
this.cleanup.setInterval(() => void this.refreshCopilotPlanUsage(), COPILOT_USAGE_POLL_INTERVAL_MS, {
description: 'Copilot plan-usage refresh',
});
}
// Custom Model Endpoint Profiles (docs/custom-model-endpoints-plan.md): keeps
@@ -3611,6 +3732,10 @@ export class WebServer extends EventEmitter {
// the launch conversation until the user types again, even though
// the re-attached CLI is on a post-`/clear` one.
lastSubmitAt: savedState?.lastSubmitAt,
// The keep-warm choice and its cap's clock; with the restored `promptCache`
// below, a future `expiresAt` re-arms the timer once the pane is attached.
lastRealPromptAt: savedState?.lastRealPromptAt,
keepWarmEnabled: savedState?.keepWarmEnabled,
// Conversations this pane provably owned, oldest first. Its tail is
// the conversation the CLI was on when the server stopped, which is
// what a re-attach must point the viewer at instead of the launch id.
@@ -4063,6 +4188,7 @@ export class WebServer extends EventEmitter {
// response), so without this a 10-minute wait holds shutdown open.
sessionWaits.cancelEverything();
approvalInbox.stop();
this.keepWarmService.stop();
// Same reason as `cancelEverything` above: an in-flight wake is awaited by a request,
// and `app.close()` (the last line of this method) does not abort in-flight requests —
// so without this a restart during a wake waits out the readiness poll.
+15 -3
View File
@@ -93,6 +93,12 @@ interface SessionListenerDeps {
updateSessionName(sessionId: string, name: string): boolean;
/** The synced `autoNameSessions` setting, read fresh so a flip applies to the next prompt. */
isAutoNameEnabled(): Promise<boolean>;
/**
* Re-evaluate the prompt-cache keep-warm when the session goes idle: a turn just ended
* (its cache report may already be in), and after a restart this is the first moment the
* recovered pane is attached, so the restored report can arm its timer.
*/
keepWarmSchedule?(sessionId: string): void;
}
/**
@@ -234,7 +240,8 @@ export function createSessionListeners(session: Session, deps: SessionListenerDe
// kind: `working` is heuristic and can flap mid-turn, so clearing a
// pending permission/question dialog on the signal ALONE would
// false-clear real approvals.
approvalInbox.resolveForSession(session.id, 'resolved_in_terminal', ['idle']);
// Not for Codeman's own keep-alive turn: only a human spends an idle alert.
if (!session.keepAliveTurnActive) approvalInbox.resolveForSession(session.id, 'resolved_in_terminal', ['idle']);
// A permission/question dialog gets the pane-VERIFIED variant instead:
// the signal only decides when to look, `verifyStillAnswerable` re-reads
// the screen and resolves only when the dialog is really gone. Without
@@ -244,7 +251,11 @@ export function createSessionListeners(session: Session, deps: SessionListenerDe
// open. `stop` was the first thing to clear it, which on a long turn is
// minutes away.
approvalInbox.resolveIfDialogGone(session.id);
deps.broadcast(SseEvent.SessionWorking, { id: session.id });
// `keepAlive` tells the page this turn is Codeman's own keep-alive (stuck detection ignores it).
deps.broadcast(SseEvent.SessionWorking, {
id: session.id,
...(session.keepAliveTurnActive && { keepAlive: true }),
});
// Full state ride-along: the home screens sort the running group on
// lastSubmitAt, and without this the browser keeps the stamp it loaded
// with (a turn started after page load ranks by the PREVIOUS turn's
@@ -260,8 +271,9 @@ export function createSessionListeners(session: Session, deps: SessionListenerDe
/** Broadcasts `session:idle` — Claude finished processing, waiting for input */
idle: () => {
sessionWaits.notifySignal(session.id, 'idle');
deps.broadcast(SseEvent.SessionIdle, { id: session.id });
deps.broadcast(SseEvent.SessionIdle, { id: session.id, ...(session.keepAliveTurnActive && { keepAlive: true }) });
deps.broadcastSessionStateDebounced(session.id);
deps.keepWarmSchedule?.(session.id);
const tracker = deps.getRunSummaryTracker(session.id);
if (tracker) {
tracker.recordIdle();
+5 -2
View File
@@ -5,10 +5,10 @@
* and referenced by the frontend (`SSE_EVENTS` in `constants.js`).
* Both files MUST be kept in sync.
*
* 162 event constants organized by category:
* 163 event constants organized by category:
* - **Core** (1): init
* - **Transport** (1): sse:heartbeat
* - **Session lifecycle** (24): created, updated, deleted, terminal, idle, working, ...
* - **Session lifecycle** (25): created, updated, deleted, terminal, idle, working, ...
* - **Session: Ralph** (6): ralphLoopUpdate, todoUpdate, completionDetected, ...
* - **Session: Bash tools** (3): bashToolStart, bashToolEnd, bashToolsUpdate
* - **Session: Plan** (4): planTaskUpdate, planCheckpoint, planRollback, planTaskAdded
@@ -122,6 +122,8 @@ export const SessionRunning = 'session:running' as const;
export const SessionStatusTelemetry = 'session:statusTelemetry' as const;
/** Prompt-cache state of one session's main conversation changed (`{ sessionId, ...PromptCacheStatus }`). */
export const SessionPromptCache = 'session:promptCache' as const;
/** The prompt-cache keep-warm's status for one session changed (`{ sessionId, ...KeepWarmStatus }`). */
export const SessionKeepWarm = 'session:keepWarm' as const;
// ─── Session: Ralph ──────────────────────────────────────────────────────────
@@ -534,6 +536,7 @@ export const SseEvent = {
SessionRunning,
SessionStatusTelemetry,
SessionPromptCache,
SessionKeepWarm,
// Session: Ralph
SessionRalphLoopUpdate,
+27 -6
View File
@@ -21,9 +21,13 @@
*
* - `x-frame-options` / CSP `frame-ancestors`: dropped, else the browser refuses
* to render the frame. This is the whole point of the proxy.
* - `content-encoding` / `content-length`: dropped, because undici's `fetch`
* already decoded the body. Forwarding them makes the browser try to gunzip
* plaintext.
* - `content-encoding` / `content-length`: dropped from responses, because
* undici's `fetch` already decoded the body. Forwarding them makes the browser
* try to gunzip plaintext.
* - Request `content-length`: RELAYED with a body. The body reaches undici as a
* stream, which it sends chunked unless told the length, and a server that
* reads only Content-Length (Python's `http.server`, many small Go/C ones)
* then sees an EMPTY body: every POST to such a dashboard arrived blank.
* - `authorization` + the `codeman_session` cookie: stripped on the way OUT. In
* trusted mode the iframe is same-origin, so the browser attaches Codeman's own
* Basic-auth header and session cookie to every proxied request. Forwarding
@@ -57,8 +61,10 @@ const HOP_BY_HOP = new Set([
/**
* Request headers dropped on the way to the upstream. `authorization` and `cookie`
* carry Codeman's own credentials on a same-origin (trusted) frame; `host`,
* `content-length` and `accept-encoding` are recomputed by undici.
* carry Codeman's own credentials on a same-origin (trusted) frame; `host` and
* `accept-encoding` are recomputed by undici. `content-length` is NOT recomputed
* for a streamed body; it is listed here only so the copy loop never relays it
* blindly, and `buildUpstreamRequestHeaders` puts it back when a body goes with it.
*/
const DROP_REQUEST_HEADERS = new Set([
...HOP_BY_HOP,
@@ -305,7 +311,7 @@ export function filterCookieHeader(cookie: string | undefined, drop: string[]):
export function buildUpstreamRequestHeaders(
incoming: Record<string, string | string[] | undefined>,
upstream: URL,
opts: { forwardCookies: boolean; sessionCookieName: string; refererPath?: string }
opts: { forwardCookies: boolean; sessionCookieName: string; refererPath?: string; relayBodyLength?: boolean }
): Record<string, string> {
const headers: Record<string, string> = {};
@@ -316,6 +322,21 @@ export function buildUpstreamRequestHeaders(
headers[lower] = Array.isArray(value) ? value.join(', ') : value;
}
// The body is relayed as a stream, and undici frames a stream it has no length
// for as `Transfer-Encoding: chunked`. Hand it the browser's own length so the
// upstream sees the framing the browser sent. Only alongside a body (a length
// with none would leave the upstream waiting for bytes that never come), and
// never against a transfer-encoding, which would make the length a lie.
const length = incoming['content-length'];
if (
opts.relayBodyLength &&
typeof length === 'string' &&
/^\d+$/.test(length) &&
incoming['transfer-encoding'] === undefined
) {
headers['content-length'] = length;
}
if (opts.forwardCookies) {
const raw = incoming['cookie'];
const cookie = filterCookieHeader(Array.isArray(raw) ? raw.join('; ') : raw, [opts.sessionCookieName]);