Merge remote-tracking branch 'origin/master' into pr/cod-455-xlsx-preview

# Conflicts:
#	CLAUDE.md
#	config/test-suites.ts
This commit is contained in:
Aamer Akhter
2026-10-04 20:08:01 -04:00
95 changed files with 11228 additions and 274 deletions
+29
View File
@@ -15,6 +15,7 @@
import { z } from 'zod';
import { compileVersionRegex, TOKEN_PATTERNS } from './patterns.js';
import { isKnownLauncherProfile, isKnownSetenvProfile } from './profiles.js';
import type { McpConfigFormat } from './types.js';
/** A bare CLI id: lowercase, starts with a letter, at most 24 chars. Also used as a CSS/URL token. */
const cliId = z
@@ -27,6 +28,14 @@ const envName = z
.regex(/^[A-Z_][A-Z0-9_]*$/, 'env var name must be UPPER_SNAKE_CASE')
.max(64);
/** A relative file path with no traversal or odd characters (MCP sync writes to it). */
const mcpRelativePath = z
.string()
.min(1)
.max(100)
.regex(/^[A-Za-z0-9._-]+(\/[A-Za-z0-9._-]+)*$/)
.refine((v) => !v.split('/').includes('..'), 'must not contain ..');
/**
* A shell-safe bare word: no space, quote, backtick, `$`, `;`, `&`, `|`, `<`, `>`, parens,
* braces, newline or backslash. Every LITERAL in the launch spec (base command, flag names,
@@ -377,6 +386,26 @@ const capabilitiesSchema = z
privilegedEnvKeys: z.array(envName).max(8),
gates: z.record(z.string(), z.object({ minVersion: z.string().max(20), failClosed: z.boolean() }).strict()),
maxFrameBytes: z.number().int().positive().optional(),
newline: z.enum(['line-feed', 'esc-enter']).optional(),
mcpConfig: z
.object({
// Home-relative, no traversal: sync writes to this path.
path: mcpRelativePath,
// Every value must be a known McpConfigFormat (types.ts); mcp-sync.ts's dialect table is
// keyed by the same type, so an adapter-less format fails to compile there.
format: z.enum([
'claude-json',
'gemini-json',
'codex-toml',
'opencode-json',
'antigravity-json',
] as const satisfies readonly McpConfigFormat[]),
// The env var the CLI reads to move the file, and the path under it (same no-traversal
// rule: sync writes there too). Resolved from the server env at call time, never here.
relocation: z.object({ envVar: envName, path: mcpRelativePath }).strict().optional(),
})
.strict()
.optional(),
customModelInjection: z.discriminatedUnion('kind', [
z
.object({
+35
View File
@@ -11,6 +11,7 @@
*/
import type { CliEntry } from './types.js';
import { CODEX_REASONING_EFFORTS } from '../../types/session.js';
const HOME_DIRS = {
local: '~/.local/bin',
@@ -306,6 +307,12 @@ const CLAUDE: CliEntry = {
'CLAUDE_CONFIG_DIR',
],
gates: { nameFlag: { minVersion: '2.1.224', failClosed: true } },
// claude reads `$CLAUDE_CONFIG_DIR/.claude.json` when that is set (checked in 2.1.289).
mcpConfig: {
path: '.claude.json',
format: 'claude-json',
relocation: { envVar: 'CLAUDE_CONFIG_DIR', path: '.claude.json' },
},
// Custom Model Endpoint Profiles (docs/custom-model-endpoints-plan.md) — verified by hand against a real
// llama.cpp server. Claude reads these at process start only, so switching requires a
// respawn, never a live hot-swap.
@@ -486,6 +493,12 @@ const OPENCODE: CliEntry = {
...agentDefaults(),
altScreen: 'strip-mux-only',
echo: { policy: 'buffer', anchor: { kind: 'cursor' }, predictProfile: undefined },
// opencode's global config dir is xdg-basedir's `$XDG_CONFIG_HOME/opencode`.
mcpConfig: {
path: '.config/opencode/opencode.json',
format: 'opencode-json',
relocation: { envVar: 'XDG_CONFIG_HOME', path: 'opencode/opencode.json' },
},
// Verified by hand against a real llama.cpp server. Reuses the SAME env var opencode's
// own `env.configContentVar` already declares — the builder in custom-model-injection.ts
// must merge into whatever opencode config Codeman would otherwise send, not clobber it.
@@ -532,6 +545,7 @@ const CODEX: CliEntry = {
bypassApprovals: { type: 'bool' },
animations: { type: 'bool' },
model: { type: 'token', pattern: 'model' },
reasoningEffort: { type: 'enum', values: [...CODEX_REASONING_EFFORTS] },
resumeId: { type: 'token', pattern: 'id' },
},
variants: [
@@ -543,6 +557,14 @@ const CODEX: CliEntry = {
{ flag: '--config', value: 'tui.animations=true', when: { param: 'animations', is: true } },
{ flag: '--config', value: 'tui.animations=false', when: { param: 'animations', is: false } },
{ flag: '--model', valueFrom: 'model', when: { param: 'model', state: 'set' } },
// One literal per level: an argv token cannot splice a value into a literal, and
// `model_reasoning_effort=<level>` is a single `--config` value. The enum above is
// what admits a level, so an unknown one emits nothing.
...CODEX_REASONING_EFFORTS.map((level) => ({
flag: '--config',
value: `model_reasoning_effort=${level}`,
when: { param: 'reasoningEffort', is: level },
})),
{ lit: 'resume', when: { param: 'resumeId', state: 'set' } },
{ valueFrom: 'resumeId', when: { param: 'resumeId', state: 'set' } },
],
@@ -620,6 +642,11 @@ const CODEX: CliEntry = {
// `dangerouslyBypassApprovals` on the wire), so it is the one that would have caught a
// regression; `schema.ts` now rejects a name that is not a declared param.
privilegedParams: [{ param: 'bypassApprovals', clampTo: false }],
mcpConfig: {
path: '.codex/config.toml',
format: 'codex-toml',
relocation: { envVar: 'CODEX_HOME', path: 'config.toml' },
},
// Verified by hand against a real llama.cpp server. Written to an isolated CODEX_HOME
// so the user's real ~/.codex/config.toml is never touched.
customModelInjection: {
@@ -721,6 +748,12 @@ const GEMINI: CliEntry = {
// MATERIALIZE a config (not just touch an already-sent one) or a non-granted owner who
// sends no geminiConfig at all would still get yolo for free.
privilegedParams: [{ param: 'approvalMode', clampTo: 'auto_edit', materializeWhenAbsent: true }],
// gemini-cli's `homedir()` returns `GEMINI_CLI_HOME` when set (packages/core/src/utils/paths.ts).
mcpConfig: {
path: '.gemini/settings.json',
format: 'gemini-json',
relocation: { envVar: 'GEMINI_CLI_HOME', path: '.gemini/settings.json' },
},
// Web-researched, unverified — needs a restart to pick up (CLI reads these at process
// start). Confirm the exact model-override env var name against the installed
// gemini-cli version before shipping.
@@ -800,6 +833,8 @@ const ANTIGRAVITY: CliEntry = {
// Like codex: an ABSENT config already defaults safe (no bypass flag), so only a
// SENT config needs the flag forced off — nothing is materialized.
privilegedParams: [{ param: 'dangerouslySkipPermissions', clampTo: false }],
// No relocation var: `agy` 1.1.12 resolves `~/.gemini/config` from $HOME only.
mcpConfig: { path: '.gemini/config/mcp_config.json', format: 'antigravity-json' },
// No known CLI/env/config mechanism — Antigravity's own docs describe a GUI-only
// custom-endpoint setting and explicitly say it "cannot currently" become the core
// reasoning model. Toolbar entry stays disabled for this mode.
+28
View File
@@ -90,6 +90,12 @@ export interface CliVariant {
args: ArgSpec[];
}
/** The newline chord a CLI's composer reads as "insert a line break" (see `CliCapabilities.newline`). */
export type NewlineSequence = 'line-feed' | 'esc-enter';
/** The MCP config dialects `src/mcp-sync.ts` has an adapter for. */
export type McpConfigFormat = 'claude-json' | 'gemini-json' | 'codex-toml' | 'opencode-json' | 'antigravity-json';
export interface CliLaunch {
params: Record<string, ParamSpec>;
/**
@@ -511,6 +517,28 @@ export interface CliCapabilities {
gates: Record<string, { minVersion: string; failClosed: boolean }>;
/** Cap on a single terminal frame, when this CLI needs a tighter one than the default. */
maxFrameBytes?: number;
/**
* The bytes the web UI types into this CLI's pane for Shift+Enter (the `send-key` route).
* `line-feed` (`0x0a`, also what Ctrl+Enter sends) is what Claude Code's Ink input and most TUIs
* read as "insert a newline"; `esc-enter` (`ESC` `CR`, the same chord as Option/Alt+Enter and
* the mobile ⌥Enter key) is for a TUI that ignores a bare line feed. Absent = `line-feed`.
* Data, not a branch on the CLI id, so supporting another CLI's quirk is one line here.
*/
newline?: NewlineSequence;
/**
* Where this CLI keeps its user-level MCP server list, for MCP sync (`src/mcp-sync.ts`).
* `path` is relative to the home directory. `format` names the file dialect the sync
* adapter reads and writes. Absent = no known/verified MCP config file, so the CLI is
* skipped by sync rather than guessed at.
*
* `relocation` names the env var the CLI itself reads to move that file (codex's
* `CODEX_HOME`, claude's `CLAUDE_CONFIG_DIR`, opencode's `XDG_CONFIG_HOME`). When the SERVER
* process env (what the CLIs Codeman spawns inherit) sets it to an absolute directory, the
* file is `<that dir>/<relocation.path>` instead; set to anything else, the target is
* reported `skipped` rather than written somewhere the CLI never reads. Absent = the file
* only follows `$HOME`.
*/
mcpConfig?: { path: string; format: McpConfigFormat; relocation?: { envVar: string; path: string } };
/**
* How this CLI is pointed at a user-supplied custom OpenAI-compatible
* endpoint (local, e.g. llama.cpp, or cloud, e.g. Azure AI Foundry) — the
+5 -1
View File
@@ -1031,7 +1031,11 @@ export async function ensureStatusLineExporterScript(): Promise<string> {
// render, and a truncate-then-write (plus a chmod AFTER the write) opened two
// windows in which Claude Code could run an empty or non-executable file.
// rename() swaps the complete, already-executable file in atomically.
const tmpPath = `${scriptPath}.${process.pid}.${Date.now()}.tmp`;
// ⚠️ The temp name must be unique per CALL, not per millisecond: sessions created
// concurrently (spawn_workers, a multi-tab Run) refresh this together, a shared
// name let the first rename consume the others' temp file, and their ENOENT
// dropped those sessions from tmux to the direct-PTY fallback.
const tmpPath = `${scriptPath}.${process.pid}.${randomBytes(6).toString('hex')}.tmp`;
await writeFile(tmpPath, desired);
await chmod(tmpPath, 0o755);
await rename(tmpPath, scriptPath);
+679
View File
@@ -0,0 +1,679 @@
/**
* @fileoverview MCP server sync between the enabled agent CLIs.
*
* Each CLI keeps its own user-level MCP list in its own dialect (`CliEntry.capabilities.mcpConfig`
* names the file and the dialect). This module reads every participating CLI's list into one
* neutral shape, and adds any server a CLI is missing from the others. The whole feature is
* opt-in (`mcpSyncEnabled`, default OFF; the route enforces it) because it writes OTHER tools'
* own user config.
*
* Deliberately conservative:
* - ADDITIVE only. A server already present under a name (in ANY shape, even one this module
* does not understand) is never rewritten and nothing is ever removed. Same name with a
* different definition is reported as a conflict and left alone.
* - A server the user has switched off in its own CLI (codex `enabled = false`, opencode
* `enabled: false`, antigravity `disabled: true`) is not propagated: copying it would
* switch it on in every other CLI.
* - A file that does not parse (e.g. opencode JSONC with comments, a TOML file with a
* duplicate table) is never written, and a write is only made after the NEW text has been
* parsed again and every added server comes back as intended.
* - Only the MCP table is touched; every other key in the file is preserved. Files are
* re-read immediately before the write and replaced via tmp+rename next to the REAL target
* (a symlinked dotfile stays a symlink), with the old file kept as `<file>.codeman-bak`
* (overwritten by each sync).
* - Copied servers can carry secrets in `env`/`headers`: a file that receives any is left
* readable by its owner only.
* - Servers a dialect cannot express (SSE for codex) are skipped and reported.
* - Only one apply runs at a time.
* - A CLI whose file was moved by its own env var (`mcpConfig.relocation`: `CODEX_HOME`,
* `CLAUDE_CONFIG_DIR`, ...) is followed there, as the SERVER env sets it; a relative value
* cannot be located safely, so that target is reported `skipped` and never written.
*
* The result types (src/types/mcp-sync.ts) never carry env values or headers: those commonly
* hold secrets and the result is returned over HTTP. For the same reason a parse failure is
* reported by position only (`describeMcpSyncError`): parsers quote the offending source.
*
* @module mcp-sync
*/
import { promises as fs } from 'node:fs';
import { randomBytes } from 'node:crypto';
import { homedir } from 'node:os';
import { dirname, isAbsolute, join } from 'node:path';
import { parse as parseToml, TomlError } from 'smol-toml';
import type { McpConfigFormat } from './config/cli-registry/types.js';
import type { McpSyncResult, McpSyncTargetResult } from './types/mcp-sync.js';
export type McpFormat = McpConfigFormat;
export interface McpServer {
transport: 'stdio' | 'http' | 'sse';
command?: string;
args?: string[];
env?: Record<string, string>;
cwd?: string;
url?: string;
headers?: Record<string, string>;
/** Switched off in the CLI that defines it. Never propagated. */
disabled?: boolean;
}
export type McpServerMap = Record<string, McpServer>;
export interface McpSyncTarget {
id: string;
label: string;
/** Home-relative default location of the config file. */
path: string;
format: McpFormat;
/** The env var the CLI reads to move the file, and the path under it (`mcpConfig.relocation`). */
relocation?: { envVar: string; path: string };
/** The CLI's binary resolves on this machine. A CLI that is not installed and has no config file is left alone. */
installed: boolean;
}
/** A second apply was requested while one was running. */
export class McpSyncBusyError extends Error {
constructor() {
super('An MCP sync is already running');
this.name = 'McpSyncBusyError';
}
}
/**
* An error whose message this module wrote itself. It names keys Codeman chose and server names
* (which the result reports anyway), never a value from the file, so it may be shown as is.
*/
class McpConfigError extends Error {
constructor(message: string) {
super(message);
this.name = 'McpConfigError';
}
}
/**
* What a target's `error` may say. A parser's own message can quote the file: smol-toml's
* `TomlError` carries a code frame of the offending line and the one before it, and V8's JSON
* "Unexpected token" errors quote about ten characters of source. These files hold env values
* and headers and the result goes over HTTP, so a parse failure is reported by position only,
* an errno failure by Node's own message (code, syscall and path: no file content), and anything
* else by a fixed category.
*/
function describeMcpSyncError(err: unknown): string {
if (err instanceof McpConfigError) return err.message;
if (err instanceof TomlError) return `not valid TOML (line ${err.line}, column ${err.column})`;
if (err instanceof SyntaxError) {
const lc = /\(line (\d+) column (\d+)\)/.exec(err.message);
if (lc) return `not valid JSON (line ${lc[1]}, column ${lc[2]})`;
const pos = /at position (\d+)/.exec(err.message);
return pos ? `not valid JSON (position ${pos[1]})` : 'not valid JSON';
}
const code = (err as NodeJS.ErrnoException | null)?.code;
if (err instanceof Error && typeof code === 'string' && /^E[A-Z0-9]+$/.test(code)) return err.message;
return 'unexpected error';
}
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
const isRecord = (v: unknown): v is Record<string, unknown> => typeof v === 'object' && v !== null && !Array.isArray(v);
/** Names that would reach Object.prototype through a plain-object table (`out[name] = ...`). */
const UNSAFE_NAMES = new Set(['__proto__', 'constructor', 'prototype']);
/** A table keyed by untrusted names: no prototype, so `toString`/`hasOwnProperty` are ordinary keys. */
function dict<T>(): Record<string, T> {
return Object.create(null) as Record<string, T>;
}
/** Own, safe keys of an untrusted table. */
function safeKeys(table: Record<string, unknown>): string[] {
return Object.keys(table).filter((k) => !UNSAFE_NAMES.has(k));
}
function strMap(v: unknown): Record<string, string> | undefined {
if (!isRecord(v)) return undefined;
const out = dict<string>();
for (const k of safeKeys(v)) if (typeof v[k] === 'string') out[k] = v[k] as string;
return Object.keys(out).length ? out : undefined;
}
function strArr(v: unknown): string[] | undefined {
return Array.isArray(v) && v.every((x) => typeof x === 'string') ? (v as string[]) : undefined;
}
/** Drop undefined/empty fields so equal servers compare equal. */
function clean(s: McpServer): McpServer {
const out: McpServer = { transport: s.transport };
if (s.command) out.command = s.command;
if (s.args?.length) out.args = s.args;
if (s.env && Object.keys(s.env).length) out.env = s.env;
if (s.cwd) out.cwd = s.cwd;
if (s.url) out.url = s.url;
if (s.headers && Object.keys(s.headers).length) out.headers = s.headers;
if (s.disabled) out.disabled = true;
return out;
}
const sortedEntries = (m: Record<string, string> | undefined): [string, string][] =>
Object.entries(m ?? {}).sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0));
/** Identity for conflict detection: what the server runs/connects to, not how it is spelled. */
function fingerprint(s: McpServer): string {
const t = s.transport === 'stdio' ? 'stdio' : 'url';
return JSON.stringify([t, s.command ?? null, s.args ?? [], s.url ?? null]);
}
/** Fingerprint plus the secrets-bearing maps: what must survive a write unchanged. */
function fullIdentity(s: McpServer): string {
return JSON.stringify([fingerprint(s), sortedEntries(s.env), sortedEntries(s.headers)]);
}
const carriesSecrets = (m: McpServerMap): boolean => Object.values(m).some((s) => s.env || s.headers);
// ---------------------------------------------------------------------------
// JSON dialects
// ---------------------------------------------------------------------------
function fromClaude(raw: unknown): McpServer | null {
if (!isRecord(raw)) return null;
const type = raw.type;
if ((type === 'http' || type === 'sse') && typeof raw.url === 'string') {
return clean({ transport: type, url: raw.url, headers: strMap(raw.headers) });
}
if (typeof raw.command === 'string') {
return clean({ transport: 'stdio', command: raw.command, args: strArr(raw.args), env: strMap(raw.env) });
}
return null;
}
function toClaude(s: McpServer): Record<string, unknown> {
if (s.transport === 'stdio') return { type: 'stdio', command: s.command, args: s.args ?? [], env: s.env ?? {} };
return { type: s.transport, url: s.url, ...(s.headers ? { headers: s.headers } : {}) };
}
function fromGemini(raw: unknown): McpServer | null {
if (!isRecord(raw)) return null;
// `httpUrl` is the legacy streamable-http key; `url` + `type` is what `gemini mcp add` writes
// today, and a bare `url` with no type is the legacy SSE form.
if (typeof raw.httpUrl === 'string')
return clean({ transport: 'http', url: raw.httpUrl, headers: strMap(raw.headers) });
if (typeof raw.url === 'string') {
return clean({ transport: raw.type === 'http' ? 'http' : 'sse', url: raw.url, headers: strMap(raw.headers) });
}
if (typeof raw.command === 'string') {
return clean({
transport: 'stdio',
command: raw.command,
args: strArr(raw.args),
env: strMap(raw.env),
cwd: typeof raw.cwd === 'string' ? raw.cwd : undefined,
});
}
return null;
}
function toGemini(s: McpServer): Record<string, unknown> {
if (s.transport === 'stdio') {
return {
command: s.command,
args: s.args ?? [],
...(s.env ? { env: s.env } : {}),
...(s.cwd ? { cwd: s.cwd } : {}),
};
}
return { url: s.url, type: s.transport, ...(s.headers ? { headers: s.headers } : {}) };
}
/** Antigravity (`agy mcp add`): stdio or http only; http servers use `serverUrl`. */
function fromAntigravity(raw: unknown): McpServer | null {
if (!isRecord(raw)) return null;
const disabled = raw.disabled === true;
if (typeof raw.serverUrl === 'string')
return clean({ transport: 'http', url: raw.serverUrl, headers: strMap(raw.headers), disabled });
if (typeof raw.command === 'string') {
return clean({
transport: 'stdio',
command: raw.command,
args: strArr(raw.args),
env: strMap(raw.env),
disabled,
});
}
return null;
}
function toAntigravity(s: McpServer): Record<string, unknown> | null {
if (s.transport === 'sse') return null;
if (s.transport === 'stdio') {
return { command: s.command, args: s.args ?? [], ...(s.env ? { env: s.env } : {}), disabled: false };
}
return { serverUrl: s.url, ...(s.headers ? { headers: s.headers } : {}), disabled: false };
}
function fromOpencode(raw: unknown): McpServer | null {
if (!isRecord(raw)) return null;
const disabled = raw.enabled === false;
if (raw.type === 'remote' && typeof raw.url === 'string') {
return clean({ transport: 'http', url: raw.url, headers: strMap(raw.headers), disabled });
}
if (raw.type === 'local') {
const cmd = strArr(raw.command);
if (!cmd?.length) return null;
return clean({
transport: 'stdio',
command: cmd[0],
args: cmd.slice(1),
env: strMap(raw.environment),
disabled,
});
}
return null;
}
function toOpencode(s: McpServer): Record<string, unknown> {
if (s.transport === 'stdio') {
return {
type: 'local',
command: [s.command, ...(s.args ?? [])],
...(s.env ? { environment: s.env } : {}),
enabled: true,
};
}
return { type: 'remote', url: s.url, ...(s.headers ? { headers: s.headers } : {}), enabled: true };
}
interface JsonDialect {
/** Key holding the server table. */
key: string;
from(raw: unknown): McpServer | null;
to(s: McpServer): Record<string, unknown> | null;
/** Top-level keys to seed when creating the file from nothing. */
seed?: Record<string, unknown>;
}
const JSON_DIALECTS: Record<Exclude<McpFormat, 'codex-toml'>, JsonDialect> = {
'claude-json': { key: 'mcpServers', from: fromClaude, to: toClaude },
'gemini-json': { key: 'mcpServers', from: fromGemini, to: toGemini },
'antigravity-json': { key: 'mcpServers', from: fromAntigravity, to: toAntigravity },
'opencode-json': {
key: 'mcp',
from: fromOpencode,
to: toOpencode,
seed: { $schema: 'https://opencode.ai/config.json' },
},
};
// ---------------------------------------------------------------------------
// Codex TOML (the `[mcp_servers.*]` tables only)
// ---------------------------------------------------------------------------
function fromCodex(t: Record<string, unknown>): McpServer | null {
const disabled = t.enabled === false;
if (typeof t.url === 'string') {
return clean({ transport: 'http', url: t.url, headers: strMap(t.http_headers), disabled });
}
if (typeof t.command === 'string') {
return clean({ transport: 'stdio', command: t.command, args: strArr(t.args), env: strMap(t.env), disabled });
}
return null;
}
const tomlStr = (v: string): string => JSON.stringify(v);
const tomlKey = (k: string): string => (/^[A-Za-z0-9_-]+$/.test(k) ? k : tomlStr(k));
function toCodexToml(name: string, s: McpServer): string {
const head = `[mcp_servers.${tomlKey(name)}]`;
const lines = [head];
if (s.transport === 'stdio') {
lines.push(`command = ${tomlStr(s.command ?? '')}`);
lines.push(`args = [${(s.args ?? []).map(tomlStr).join(', ')}]`);
if (s.env) {
lines.push('', `[mcp_servers.${tomlKey(name)}.env]`);
for (const [k, v] of Object.entries(s.env)) lines.push(`${tomlKey(k)} = ${tomlStr(v)}`);
}
} else {
lines.push(`url = ${tomlStr(s.url ?? '')}`);
if (s.headers) {
lines.push('', `[mcp_servers.${tomlKey(name)}.http_headers]`);
for (const [k, v] of Object.entries(s.headers)) lines.push(`${tomlKey(k)} = ${tomlStr(v)}`);
}
}
return lines.join('\n') + '\n';
}
// ---------------------------------------------------------------------------
// Dialect entry points
// ---------------------------------------------------------------------------
export interface ParsedConfig {
/** Servers this module understands. */
servers: McpServerMap;
/** Every name defined under the MCP table, in any shape: these are never appended over. */
names: Set<string>;
}
/** The MCP table of a config file's text (null = file absent). Throws if it cannot be read safely. */
function mcpTable(format: McpFormat, text: string | null): Record<string, unknown> {
if (text === null || !text.trim()) return dict<unknown>();
if (format === 'codex-toml') {
const doc = parseToml(text);
const table = doc.mcp_servers;
if (table === undefined) return dict<unknown>();
if (!isRecord(table)) throw new McpConfigError('"mcp_servers" is not a table');
return table;
}
const dialect = JSON_DIALECTS[format];
const doc: unknown = JSON.parse(text);
if (!isRecord(doc)) throw new McpConfigError('top level is not a JSON object');
const table = doc[dialect.key];
if (table === undefined) return dict<unknown>();
if (!isRecord(table)) throw new McpConfigError(`"${dialect.key}" is not an object`);
return table;
}
/** Parse a config file's text (null = file absent). Throws if it cannot be read safely. */
export function parseConfig(format: McpFormat, text: string | null): ParsedConfig {
const table = mcpTable(format, text);
const servers = dict<McpServer>();
const names = new Set<string>();
for (const name of safeKeys(table)) {
names.add(name);
const raw = table[name];
const s =
format === 'codex-toml'
? isRecord(raw)
? fromCodex(raw)
: null
: JSON_DIALECTS[format as Exclude<McpFormat, 'codex-toml'>].from(raw);
if (s) servers[name] = s;
}
return { servers, names };
}
/** The servers of a config file's text. */
export function parseServers(format: McpFormat, text: string | null): McpServerMap {
return parseConfig(format, text).servers;
}
/** Whether this dialect can express the server. */
function canExpress(format: McpFormat, s: McpServer): boolean {
if (format === 'codex-toml' || format === 'antigravity-json') return s.transport !== 'sse';
return true;
}
/**
* Add servers to a config file's text and return the new text. A name already defined under the
* MCP table (in any shape) is skipped; the new text is parsed again and every added server must
* come back as intended, otherwise this throws and nothing should be written.
*/
export function addServers(format: McpFormat, text: string | null, add: McpServerMap): string {
const before = parseConfig(format, text);
const todo = dict<McpServer>();
for (const n of safeKeys(add)) if (!before.names.has(n) && canExpress(format, add[n])) todo[n] = add[n];
const names = Object.keys(todo);
if (names.length === 0) return text ?? '';
let out: string;
if (format === 'codex-toml') {
const base = text ?? '';
const eol = base.includes('\r\n') ? '\r\n' : '\n';
const sep =
base.length === 0
? ''
: base.endsWith('\n\n') || base.endsWith('\r\n\r\n')
? ''
: base.endsWith('\n')
? eol
: eol + eol;
const blocks = names.map((n) => toCodexToml(n, todo[n]).replace(/\n/g, eol));
out = base + sep + blocks.join(eol);
} else {
const dialect = JSON_DIALECTS[format];
const doc: Record<string, unknown> =
text && text.trim() ? (JSON.parse(text) as Record<string, unknown>) : { ...dialect.seed };
const existing = doc[dialect.key];
const table: Record<string, unknown> = isRecord(existing) ? existing : {};
for (const n of names) {
const entry = dialect.to(todo[n]);
if (entry) table[n] = entry;
}
doc[dialect.key] = table;
out = JSON.stringify(doc, null, 2) + '\n';
}
// Re-read what we are about to write.
const after = parseConfig(format, out);
for (const n of before.names) {
if (!after.names.has(n)) throw new McpConfigError(`refusing to write: "${n}" would be lost`);
}
for (const n of names) {
const got = after.servers[n];
if (!got || fullIdentity(got) !== fullIdentity(todo[n])) {
throw new McpConfigError(`refusing to write: "${n}" does not read back as written`);
}
}
return out;
}
// ---------------------------------------------------------------------------
// Orchestration
// ---------------------------------------------------------------------------
async function readText(file: string): Promise<string | null> {
try {
return await fs.readFile(file, 'utf8');
} catch (err) {
if ((err as NodeJS.ErrnoException).code === 'ENOENT') return null;
throw err;
}
}
async function exists(file: string): Promise<boolean> {
try {
await fs.access(file);
return true;
} catch {
return false;
}
}
/**
* Write `text` over `file`, keeping the old content as `<file>.codeman-bak`. Follows a symlink
* to the real file so a symlinked dotfile stays a symlink. When `secret` is set the result is
* readable by its owner only.
*/
async function writeAtomic(file: string, text: string, secret: boolean): Promise<void> {
let target = file;
try {
if ((await fs.lstat(file)).isSymbolicLink()) target = await fs.realpath(file);
} catch (err) {
if ((err as NodeJS.ErrnoException).code !== 'ENOENT') throw err;
// ENOENT from realpath on a dangling link, or lstat on a missing file: tell them apart.
try {
await fs.lstat(file);
throw new McpConfigError('config path is a dangling symlink');
} catch (inner) {
if ((inner as NodeJS.ErrnoException).code !== 'ENOENT') throw inner;
}
}
let mode = 0o600;
try {
mode = (await fs.stat(target)).mode & 0o777;
await fs.copyFile(target, `${target}.codeman-bak`);
await fs.chmod(`${target}.codeman-bak`, 0o600);
} catch (err) {
if ((err as NodeJS.ErrnoException).code !== 'ENOENT') throw err;
}
if (secret) mode &= ~0o077;
await fs.mkdir(dirname(target), { recursive: true });
const tmp = `${target}.codeman-tmp-${process.pid}-${randomBytes(4).toString('hex')}`;
try {
await fs.writeFile(tmp, text, { mode });
// writeFile's mode is masked by the umask; the mode we computed is the one we mean.
await fs.chmod(tmp, mode);
await fs.rename(tmp, target);
} catch (err) {
await fs.unlink(tmp).catch(() => undefined);
throw err;
}
}
export interface McpSyncOptions {
/** false = report what would change without writing. */
apply: boolean;
home?: string;
/**
* Where relocation env vars (`McpSyncTarget.relocation`) are read from: the env the CLIs
* Codeman spawns would inherit. Defaults to `process.env`, except when `home` is overridden
* (tests, throwaway homes): then it defaults to none, so a relocation var in the caller's own
* env can never aim a write outside that home.
*/
env?: Record<string, string | undefined>;
}
/**
* The config file a target means, honouring its relocation env var. `skip` is set when the var
* holds something that cannot be located safely (a relative path resolves against the CLI's
* working directory, which differs per session), so the target is neither read nor written.
*/
function resolveFile(
t: McpSyncTarget,
home: string,
env: Record<string, string | undefined>
): { file: string; skip?: string } {
const rel = t.relocation;
const dir = rel ? env[rel.envVar] : undefined;
// Every CLI declared today treats an empty value as unset (`||` / a non-empty filter).
if (!rel || dir === undefined || dir === '') return { file: join(home, t.path) };
if (!isAbsolute(dir)) {
return {
file: `$${rel.envVar}/${rel.path}`,
skip: `${rel.envVar} is set to a relative path, so the file ${t.label} reads cannot be located safely`,
};
}
return { file: join(dir, rel.path) };
}
let applying = false;
/**
* Sync across `targets` (already filtered to enabled CLIs with an `mcpConfig`, in priority
* order: when two CLIs define a name differently, the first one's definition is the one copied).
* Throws `McpSyncBusyError` if another apply is running.
*/
export async function syncMcpServers(
targets: McpSyncTarget[],
opts: McpSyncOptions,
unsupported: string[] = []
): Promise<McpSyncResult> {
if (opts.apply) {
if (applying) throw new McpSyncBusyError();
applying = true;
}
try {
return await run(targets, opts, unsupported);
} finally {
if (opts.apply) applying = false;
}
}
async function run(targets: McpSyncTarget[], opts: McpSyncOptions, unsupported: string[]): Promise<McpSyncResult> {
const home = opts.home ?? homedir();
const env = opts.env ?? (opts.home === undefined ? process.env : {});
const seen = new Set<string>();
const live = targets
.map((t) => ({ t, ...resolveFile(t, home, env) }))
.filter(({ file }) => (seen.has(file) ? false : (seen.add(file), true)));
const state = live.map(({ t, file, skip }) => {
const res: McpSyncTargetResult = {
id: t.id,
label: t.label,
file,
status: skip ? 'skipped' : 'ok',
...(skip ? { error: skip } : {}),
servers: [],
added: [],
skipped: [],
};
return { t, file, res, servers: dict<McpServer>(), names: new Set<string>() };
});
for (const s of state) {
if (s.res.status !== 'ok') continue;
try {
if (!s.t.installed && !(await exists(s.file))) {
s.res.status = 'absent';
continue;
}
const parsed = parseConfig(s.t.format, await readText(s.file));
s.servers = parsed.servers;
s.names = parsed.names;
s.res.servers = [...parsed.names];
} catch (err) {
s.res.status = 'unreadable';
s.res.error = describeMcpSyncError(err);
}
}
// Union, first enabled definition wins; a later, different definition of the same name is a conflict.
const union = dict<McpServer>();
const conflicts = new Set<string>();
const switchedOff = new Set<string>();
for (const s of state) {
if (s.res.status !== 'ok') continue;
for (const name of Object.keys(s.servers)) {
const def = s.servers[name];
if (def.disabled) {
switchedOff.add(name);
continue;
}
if (!(name in union)) union[name] = def;
else if (fingerprint(union[name]) !== fingerprint(def)) conflicts.add(name);
}
}
const disabled = [...switchedOff].filter((n) => !(n in union)).sort();
for (const s of state) {
if (s.res.status !== 'ok') continue;
const add = dict<McpServer>();
for (const name of Object.keys(union)) {
if (s.names.has(name)) continue;
if (canExpress(s.t.format, union[name])) add[name] = union[name];
else s.res.skipped.push(name);
}
s.res.added = Object.keys(add);
if (!opts.apply || s.res.added.length === 0) continue;
try {
// Re-read right before writing: claude rewrites ~/.claude.json constantly.
const fresh = await readText(s.file);
const out = addServers(s.t.format, fresh, add);
const current = parseConfig(s.t.format, fresh);
const written = Object.keys(add).filter((n) => !current.names.has(n));
if (written.length === 0) {
s.res.added = [];
continue;
}
const subset = dict<McpServer>();
for (const n of written) subset[n] = add[n];
await writeAtomic(s.file, out, carriesSecrets(subset));
s.res.added = written;
} catch (err) {
s.res.status = 'failed';
s.res.error = describeMcpSyncError(err);
s.res.added = [];
}
}
return {
applied: opts.apply,
targets: state.map((s) => s.res),
conflicts: [...conflicts].sort(),
disabled,
unsupported,
};
}
+4
View File
@@ -115,6 +115,8 @@ export interface CreateSessionOptions {
envOverrides?: Record<string, string>;
/** Claude CLI effort level, injected as a `--settings` soft default (overridable via /effort in-session) */
effort?: EffortLevel;
/** Claude advisor model, merged into the same `--settings` JSON (overridable via /advisor in-session) */
advisorModel?: string;
/** tmux history-limit (scrollback lines) allocated when this session is created. */
historyLimit?: number;
/** Remote execution metadata for local tmux sessions wrapping SSH */
@@ -164,6 +166,8 @@ export interface RespawnPaneOptions {
unsetEnvKeys?: string[];
/** Claude CLI effort level (preserved across respawns, injected via `--settings`) */
effort?: EffortLevel;
/** Claude advisor model (preserved across respawns, merged into the same `--settings` JSON) */
advisorModel?: string;
/** 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 */
+33 -3
View File
@@ -9,7 +9,7 @@
*/
import type { ClaudeMode, EffortLevel } from './types.js';
import { isEffortLevel } from './types.js';
import { isAdvisorModel, isEffortLevel } from './types.js';
import { getAugmentedPath } from './utils/index.js';
import { compareVersions } from './utils/dependency-checker.js';
import { dataPath } from './config/instance.js';
@@ -54,6 +54,25 @@ export function buildEffortCliArgs(effort?: EffortLevel): string[] {
return effort === 'ultracode' ? ['--settings', '{"ultracode":true}'] : ['--effort', effort];
}
/**
* The `--settings` keys that switch on Claude Code's advisor tool for one session: a
* stronger model the main model consults at decision points (code.claude.com/docs/en/advisor).
* Returns `{}` for an absent or non-allowlisted value, so callers can spread it unconditionally.
*
* ⚠️ Carried as the `advisorModel` SETTINGS key, never the `--advisor` flag. The flag EXITS at
* launch on any pairing the CLI refuses (`claude --advisor haiku` prints "cannot be used as an
* advisor" and exits 1, as does a Fable advisor still awaiting usage-credit consent), which
* would leave a dead pane on every spawn and respawn. The settings key degrades instead: the
* CLI simply does not attach an advisor it cannot use. It is a SOFT default either way:
* `/advisor` still switches or turns it off inside the running session.
*
* ⚠️ Claude Code reads only ONE `--settings` flag per invocation, so this must be merged into
* the same JSON object as ultracode and the statusLine exporter, never rendered on its own.
*/
export function buildAdvisorSettings(advisorModel?: string): { advisorModel?: string } {
return isAdvisorModel(advisorModel) ? { advisorModel } : {};
}
/**
* Minimum Claude CLI version for passing `--name` at spawn. 2.1.224 is the release
* that ships cross-session messaging (the feature that makes the peer name matter),
@@ -111,6 +130,7 @@ export function buildNameCliArgs(sessionName: string | undefined, cliVersion: st
* @param effort - Optional effort level, injected via --settings (overridable in-session)
* @param sessionName - Optional Codeman session name, passed as `--name` (version-gated)
* @param cliVersion - Installed Claude CLI version for the `--name` gate (null = omit the flag)
* @param advisorModel - Optional advisor model, merged into the one `--settings` JSON (see buildAdvisorSettings)
* @returns Array of CLI arguments
*/
export function buildInteractiveArgs(
@@ -120,11 +140,21 @@ export function buildInteractiveArgs(
allowedTools?: string,
effort?: EffortLevel,
sessionName?: string,
cliVersion?: string | null
cliVersion?: string | null,
advisorModel?: string
): string[] {
const args = [...buildPermissionArgs(claudeMode, allowedTools), '--session-id', sessionId];
if (model) args.push('--model', model);
args.push(...buildEffortCliArgs(effort));
const effortArgs = buildEffortCliArgs(effort);
const advisor = buildAdvisorSettings(advisorModel);
if (advisor.advisorModel === undefined) {
args.push(...effortArgs);
} else if (effortArgs[0] === '--settings') {
// One --settings flag only: fold the advisor into ultracode's JSON object.
args.push('--settings', JSON.stringify({ ...JSON.parse(effortArgs[1]), ...advisor }));
} else {
args.push(...effortArgs, '--settings', JSON.stringify(advisor));
}
args.push(...buildNameCliArgs(sessionName, cliVersion));
return args;
}
+17 -9
View File
@@ -20,7 +20,7 @@
import type { CliEntry } from './config/cli-registry/types.js';
import { renderLaunch, type EngineValues, type ParamValues } from './config/cli-registry/argv.js';
import { matchesPattern } from './config/cli-registry/patterns.js';
import { buildEffortCliArgs, sanitizeCliSessionName } from './session-cli-builder.js';
import { buildAdvisorSettings, buildEffortCliArgs, sanitizeCliSessionName } from './session-cli-builder.js';
import { compareVersions } from './utils/dependency-checker.js';
import { getClaudeCliVersion } from './utils/claude-cli-resolver.js';
import { launcherDefaultTarget } from './utils/cli-launcher.js';
@@ -54,6 +54,8 @@ export interface SpawnBridgeOptions {
ompConfig?: OmpConfig;
resumeSessionId?: string;
effort?: EffortLevel;
/** Claude advisor model; rides the same `--settings` JSON as ultracode (see buildAdvisorSettings). */
advisorModel?: string;
sessionName?: string;
claudeCliVersion?: string | null;
/**
@@ -198,14 +200,20 @@ export function buildSpawnCommandFromRegistry(entry: CliEntry, options: SpawnBri
engineValues.effortLevel = effortValue;
}
// Fold the ephemeral plan-usage statusLine exporter (see resolveStatusLineCliCommand in
// hooks-config.ts) into the SAME `--settings` JSON object as ultracode/ effort, since Claude
// Code accepts only one `--settings` flag per invocation — rendering them as two independent
// params would let the second one silently win. Claude-only in practice (statusLineCommand
// is resolved claude-mode-only upstream), but this merge is mode-agnostic.
if ((effortFlag === '--settings' && effortValue) || options.statusLineCommand) {
const settingsObj: Record<string, unknown> =
effortFlag === '--settings' && effortValue ? JSON.parse(effortValue) : {};
// Fold the advisor model and the ephemeral plan-usage statusLine exporter (see
// resolveStatusLineCliCommand in hooks-config.ts) into the SAME `--settings` JSON object as
// ultracode/ effort, since Claude Code accepts only one `--settings` flag per invocation:
// 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.
const advisorSettings = buildAdvisorSettings(options.advisorModel);
if ((effortFlag === '--settings' && effortValue) || advisorSettings.advisorModel || options.statusLineCommand) {
const settingsObj: Record<string, unknown> = {
...(effortFlag === '--settings' && effortValue ? JSON.parse(effortValue) : {}),
...advisorSettings,
};
if (options.statusLineCommand) {
settingsObj.statusLine = { type: 'command', command: options.statusLineCommand };
}
+34 -1
View File
@@ -42,6 +42,7 @@ import {
NiceConfig,
DEFAULT_NICE_CONFIG,
getErrorMessage,
isAdvisorModel,
isEffortLevel,
type ClaudeMode,
type SessionMode,
@@ -212,6 +213,21 @@ export function isExternalCliMode(mode: SessionMode): boolean {
return getCli(mode)?.capabilities.external ?? true;
}
/**
* Does this CLI take the top-level session `model` (claude's per-session `--model`)?
*
* Read off the registry's model-source capability: only a `claude-settings-file` CLI
* (claude) launches on that field. Every other CLI takes its model in its own config object
* (`codexConfig.model` and so on), so for them the field is inert, and cron hands the
* app-wide default (always a Claude id) to any CLI that has a model at all. `toState()`
* publishes and persists the field only where this holds, so a codex cron session never
* reports a Claude model it did not run on, and `POST /api/sessions` refuses a `model` for
* any CLI where it does not.
*/
export function cliTakesSessionModel(mode: SessionMode): boolean {
return getCli(mode)?.capabilities.model.source === 'claude-settings-file';
}
/** Display name for a run mode. Falls back to the raw id for an unregistered one. */
function getModeLabel(mode: SessionMode): string {
return getCli(mode)?.label ?? mode;
@@ -658,6 +674,11 @@ export class Session extends EventEmitter {
// the CLAUDE_CODE_EFFORT_LEVEL env var, which would hard-lock the session.
private _effort: EffortLevel | undefined;
// Claude advisor model (code.claude.com/docs/en/advisor), merged into the same launch
// `--settings` JSON as ultracode, never the `--advisor` flag (which exits on a refused
// pairing). A soft default: /advisor still switches or disables it in-session.
private _advisorModel: string | undefined;
// Custom Model Endpoint Profiles (docs/custom-model-endpoints-plan.md). `envKeys`,
// `configDir` and `launchModel` are internal bookkeeping ONLY (never surfaced via
// toState()/the customModel getter): they are what setCustomModel() needs to undo a
@@ -774,6 +795,8 @@ export class Session extends EventEmitter {
envOverrides?: Record<string, string>;
/** Claude CLI effort level (soft default via --settings, switchable in-session via /effort) */
effort?: EffortLevel;
/** Claude advisor model (soft default via --settings, switchable in-session via /advisor) */
advisorModel?: string;
/** tmux history-limit (scrollback lines) allocated when this session's pane is created. */
tmuxHistoryLimit?: number;
/** Restored per-session attachment history. May include server-private external paths. */
@@ -934,6 +957,9 @@ export class Session extends EventEmitter {
if (config.effort && isEffortLevel(config.effort)) {
this._effort = config.effort;
}
if (isAdvisorModel(config.advisorModel)) {
this._advisorModel = config.advisorModel;
}
this._tmuxHistoryLimit = config.tmuxHistoryLimit ?? DEFAULT_TMUX_HISTORY_LIMIT;
this._remote = config.remote;
this._docker = config.docker;
@@ -1827,6 +1853,10 @@ export class Session extends EventEmitter {
ompConfig: this._ompConfig,
resumeSessionId: this._resumeSessionId,
effort: this._effort,
// Claude only: for any other CLI `_model` is inert (its model lives in its own config
// object) and may be the app-wide Claude default cron handed it.
model: cliTakesSessionModel(this.mode) ? this._model : undefined,
advisorModel: this._advisorModel,
customModel: this.customModel,
// COD-118: runtime-only — surfaced so the frontend can require explicit user
// intent before restarting a crash-looped session. Deliberately NOT restored
@@ -2205,6 +2235,7 @@ export class Session extends EventEmitter {
envOverrides: this._envOverrides,
unsetEnvKeys: this._pendingEnvUnsets.size > 0 ? [...this._pendingEnvUnsets] : undefined,
effort: this._effort,
advisorModel: this._advisorModel,
historyLimit: this._tmuxHistoryLimit,
remote: this._remote,
docker: this._docker,
@@ -2667,6 +2698,7 @@ export class Session extends EventEmitter {
resumeSessionId: this._resumeSessionId,
envOverrides: this._envOverrides,
effort: this._effort,
advisorModel: this._advisorModel,
historyLimit: this._tmuxHistoryLimit,
remote: this._remote,
docker: this._docker,
@@ -2791,7 +2823,8 @@ export class Session extends EventEmitter {
this._allowedTools,
this._effort,
this.cliPinnedName,
getClaudeCliVersion()
getClaudeCliVersion(),
this._advisorModel
);
this.ptyProcess = spawnPtyWithHelperRepair(() =>
pty.spawn(getClaudeBinaryPath(), args, {
+6
View File
@@ -882,6 +882,8 @@ export function buildSpawnCommand(options: {
ompConfig?: OmpConfig;
resumeSessionId?: string;
effort?: EffortLevel;
/** Claude advisor model, merged into the launch's one `--settings` JSON (see buildAdvisorSettings). */
advisorModel?: string;
/** Resolved by resolveStatusLineCliCommand (hooks-config.ts) — undefined skips the exporter. Claude only. */
statusLineCommand?: string;
/** Name pinned on claude as `--name` (version-gated, sanitized; local spawns only). Only a user-chosen name: see `Session.cliPinnedName`. */
@@ -2083,6 +2085,7 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
resumeSessionId,
envOverrides,
effort,
advisorModel,
historyLimit = DEFAULT_TMUX_HISTORY_LIMIT,
remote,
docker,
@@ -2170,6 +2173,7 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
ompConfig,
resumeSessionId,
effort,
advisorModel,
statusLineCommand,
sessionName: cliName,
});
@@ -2397,6 +2401,7 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
envOverrides,
unsetEnvKeys,
effort,
advisorModel,
remote,
docker,
cliName,
@@ -2435,6 +2440,7 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
ompConfig,
resumeSessionId,
effort,
advisorModel,
statusLineCommand,
sessionName: cliName,
});
+3 -1
View File
@@ -24,9 +24,10 @@
* | run-summary | RunSummary, RunSummaryEvent, RunSummaryStats | In-memory → `GET /api/sessions/:id/run-summary` |
* | tools | ActiveBashTool, ImageDetectedEvent | In-memory, broadcast via SSE |
* | teams | TeamConfig, TeamMember, TeamTask, InboxMessage, PaneInfo | `~/.claude/teams/`, `~/.claude/tasks/` → `GET /api/teams` |
* | push | PushSubscriptionRecord, VapidKeys | `~/.codeman/push-keys.json`, `~/.codeman/push-subscriptions.json` |
* | push | PushSubscriptionRecord, VapidKeys, WebhookConfig, WebhookStatus, WebhookResult | `~/.codeman/push-keys.json`, `~/.codeman/push-subscriptions.json`, `~/.codeman/webhook.json` |
* | plan | PlanItem, PlanTaskStatus, TddPhase | In-memory → `GET /api/sessions/:id/plan/tasks` |
* | orchestrator | OrchestratorState, OrchestratorPlan, OrchestratorConfig, OrchestratorPersistState | `~/.codeman/state.json` → `GET /api/orchestrator/status` |
* | mcp-sync | McpSyncResult, McpSyncTargetResult | Other CLIs' own config files → `GET`/`POST /api/mcp-sync` |
*
* ## Cross-domain relationship map
*
@@ -72,3 +73,4 @@ export * from './search.js';
export * from './user.js';
export * from './webview.js';
export * from './intent.js';
export * from './mcp-sync.js';
+41
View File
@@ -0,0 +1,41 @@
/**
* @fileoverview Response types for MCP server sync (`GET`/`POST /api/mcp-sync`, src/mcp-sync.ts).
*
* These are returned over HTTP, so they carry server NAMES only: never env values or headers,
* and never file content (a parse failure is reported by position, see `describeMcpSyncError`).
*/
/** One participating CLI in a sync result. */
export interface McpSyncTargetResult {
id: string;
label: string;
/** The config file read (and written). For a `skipped` target, the unresolved location. */
file: string;
/**
* `absent`: not installed and no config file, so neither read nor created.
* `skipped`: the CLI's config location could not be resolved safely (e.g. its relocation env
* var is a relative path), so it is neither read nor written; `error` says why.
* `unreadable`: the file exists but cannot be parsed safely, so it is not written.
* `failed`: a read or write error (the file may be unchanged).
*/
status: 'ok' | 'absent' | 'skipped' | 'unreadable' | 'failed';
/** Why the target is not `ok`. Position or category only, never file content. */
error?: string;
servers: string[];
/** Servers added (apply) or that would be added (plan). */
added: string[];
/** Missing servers this dialect cannot express. */
skipped: string[];
}
/** The `data` of `GET`/`POST /api/mcp-sync`. */
export interface McpSyncResult {
applied: boolean;
targets: McpSyncTargetResult[];
/** Names defined differently by different CLIs; existing definitions are left untouched. */
conflicts: string[];
/** Names left out because the only definitions are switched off in their own CLI. */
disabled: string[];
/** Installed, enabled agent CLIs with no known MCP config file, so sync cannot touch them. */
unsupported: string[];
}
+43 -2
View File
@@ -6,13 +6,17 @@
* Key exports:
* - PushSubscriptionRecord — a registered push endpoint with per-event preferences
* - VapidKeys — VAPID key pair (public + private) for Web Push authentication
* - WebhookConfig, WebhookStatus, WebhookResult (+ the kind/scope lists): the webhook channel
* (ntfy, Slack, Discord, generic JSON) that carries the same events as Web Push
*
* Persistence:
* - VAPID keys: `~/.codeman/push-keys.json` (auto-generated on first use)
* - Subscriptions: `~/.codeman/push-subscriptions.json` (expired auto-cleaned on 410/404)
* - Webhook: `~/.codeman/webhook.json` (mode 0600; the URL is a bearer secret)
*
* Managed by PushStore (`src/push-store.ts`). Served at `GET /api/push/vapid-key`,
* `POST /api/push/subscribe`. No dependencies on other domain modules.
* Push is managed by PushStore (`src/push-store.ts`), served at `GET /api/push/vapid-key`,
* `POST /api/push/subscribe`. The webhook is managed by `src/webhook-notify.ts`, served at
* `GET`/`PUT /api/webhook` and `POST /api/webhook/test`. No dependencies on other domain modules.
*/
/** A registered push subscription */
@@ -32,3 +36,40 @@ export interface VapidKeys {
privateKey: string;
generatedAt: number;
}
/** Services the webhook channel can format a message for. */
export const WEBHOOK_KINDS = ['ntfy', 'slack', 'discord', 'generic'] as const;
export type WebhookKind = (typeof WEBHOOK_KINDS)[number];
/** `attention`: only events that need a human (critical / warning). `all`: also "response complete". */
export const WEBHOOK_SCOPES = ['attention', 'all'] as const;
export type WebhookScope = (typeof WEBHOOK_SCOPES)[number];
export type WebhookUrgency = 'critical' | 'warning' | 'info';
/** The stored webhook config (`~/.codeman/webhook.json`). `url` is a secret and is never returned. */
export interface WebhookConfig {
enabled: boolean;
kind: WebhookKind;
url: string;
scope: WebhookScope;
}
/** One delivery attempt. `error` never contains the URL. */
export interface WebhookResult {
ok: boolean;
status?: number;
error?: string;
at: number;
}
/** `GET /api/webhook`: the config without its URL, plus the last delivery result. */
export interface WebhookStatus {
enabled: boolean;
kind: WebhookKind;
scope: WebhookScope;
hasUrl: boolean;
/** Scheme + host only; the path and query are the secret. */
urlMasked: string;
lastResult: WebhookResult | null;
}
+44 -1
View File
@@ -12,7 +12,7 @@
* - ClaudeMode — CLI permission mode ('dangerously-skip-permissions' | 'auto' | 'normal' | 'allowedTools')
* - SessionColor — visual differentiation color
* - OpenCodeConfig — OpenCode-specific settings (model, autoAllowTools, continueSession)
* - CodexConfig — Codex (OpenAI CLI)-specific settings (model, resumeSessionId)
* - CodexConfig — Codex (OpenAI CLI)-specific settings (model, reasoningEffort, resumeSessionId, bypass, animations, renderMode)
* - GeminiConfig — Gemini CLI-specific settings (model, approvalMode, resumeSession)
* - AntigravityConfig — Antigravity CLI (agy) settings (model, dangerouslySkipPermissions, resumeConversationId)
* - PiConfig — Pi CLI (pi.dev) settings (model, provider, thinking, resume/continue, project trust)
@@ -377,6 +377,36 @@ export function isEffortLevel(value: string | undefined): value is EffortLevel {
return value !== undefined && (EFFORT_LEVELS as readonly string[]).includes(value);
}
/**
* Reasoning effort levels codex accepts as `model_reasoning_effort` (codex-cli 0.154.0).
* Which of them a given model honours is codex's business; Codeman only keeps the value
* to a known word, since it lands in the launch argv.
*/
export const CODEX_REASONING_EFFORTS = ['none', 'minimal', 'low', 'medium', 'high', 'xhigh', 'max', 'ultra'] as const;
/** Codex reasoning effort for a session, passed as `--config model_reasoning_effort=<level>` */
export type CodexReasoningEffort = (typeof CODEX_REASONING_EFFORTS)[number];
/**
* Model aliases Claude Code accepts for its advisor tool (a stronger model the session's
* main model consults at decision points; code.claude.com/docs/en/advisor). Haiku is left
* out on purpose: it can call an advisor but never act as one.
*/
export const ADVISOR_MODEL_ALIASES = ['fable', 'opus', 'sonnet'] as const;
/** A full model id in one of the advisor-capable families, e.g. `claude-opus-5-5`. */
const ADVISOR_MODEL_ID_PATTERN = /^claude-(?:fable|opus|sonnet)-[a-z0-9]+(?:-[a-z0-9]+)*$/;
/**
* Type guard: is the value an advisor model Codeman will pass to claude? An alias from
* ADVISOR_MODEL_ALIASES or a full fable/opus/sonnet model id. ⚠️ This allowlist is also the
* injection guard: the value is rendered inside the single-quoted `--settings` JSON argument.
*/
export function isAdvisorModel(value: unknown): value is string {
if (typeof value !== 'string' || value.length > 64) return false;
return (ADVISOR_MODEL_ALIASES as readonly string[]).includes(value) || ADVISOR_MODEL_ID_PATTERN.test(value);
}
/** OpenCode session configuration */
export interface OpenCodeConfig {
/** Model identifier (e.g., "anthropic/claude-sonnet-4-5", "openai/gpt-5.2", "ollama/codellama") */
@@ -398,6 +428,8 @@ export type CodexRenderMode = 'hybrid';
export interface CodexConfig {
/** Model identifier (e.g., "gpt-5", "o4-mini"). Passed via --model. */
model?: string;
/** Reasoning effort for this session. Passed via --config model_reasoning_effort=<level>. */
reasoningEffort?: CodexReasoningEffort;
/** Resume a previous codex conversation by session id (passed via --resume) */
resumeSessionId?: string;
/** Bypass approval prompts (passes --dangerously-bypass-approvals-and-sandbox) */
@@ -795,6 +827,17 @@ export interface SessionState {
resumeSessionId?: string;
/** Claude CLI effort level (soft default via --settings, switchable in-session via /effort) */
effort?: EffortLevel;
/** Claude advisor model (`advisorModel` in the launch `--settings`, switchable in-session via /advisor) */
advisorModel?: string;
/**
* The model the session was LAUNCHED with (`--model`): the caller's per-session `model`, or
* the app-wide default when there was none. Persisted so a recovered session relaunches on
* the same model rather than whatever the default is by then. Not `cliModel`, which is what
* the CLI's banner reports. Claude sessions only (`cliTakesSessionModel()`): every other CLI
* keeps its model in its own config object (`codexConfig.model` and so on), and this is
* absent for them.
*/
model?: string;
/**
* Custom Model Endpoint Profiles (docs/custom-model-endpoints-plan.md): the custom
* OpenAI-compatible endpoint (local or cloud) this session's CLI is currently pointed
+499 -11
View File
@@ -319,6 +319,8 @@ const _SSE_HANDLER_MAP = [
// Session order (global tab order sync, COD-131)
[SSE_EVENTS.SESSION_ORDER_CHANGED, '_onSessionOrderChanged'],
// Owner tab layout (grouped vertical rail)
[SSE_EVENTS.TAB_LAYOUT_CHANGED, '_onTabLayoutChanged'],
// Web tabs (dashboard URLs)
[SSE_EVENTS.WEBVIEW_CHANGED, '_onWebviewChanged'],
@@ -581,6 +583,12 @@ class CodemanApp {
this._shortIdCache = new Map(); // Cache session ID .slice(0, 8) results
this.sessionOrder = []; // Track tab order for drag-and-drop reordering
this.draggedTabId = null; // Currently dragged tab session ID
// Owner tab layout (GET /api/tab-layout), read-only here: it only changes how
// the vertical rail GROUPS rows. sessionOrder above stays the tab order.
this.tabLayout = null;
this.collapsedTabGroupIds = new Set(); // per-device, localStorage-backed
this._hiddenTabGroupByRef = new Map(); // 'session:<id>' -> collapsed group id
this._lastTabGroupStructureKey = null;
this.cases = [];
this.currentRun = null;
this.totalTokens = 0;
@@ -1242,6 +1250,12 @@ class CodemanApp {
// Use capture to handle before terminal
document.addEventListener('keydown', (e) => {
// A field that exists to show what a key does (Settings → Key tester, `data-raw-keys`) must
// receive every chord untouched. Without this, probing Ctrl+W killed the active session,
// Ctrl+L cleared the terminal and Escape closed Settings: this listener runs in the capture
// phase, before the field's own handler. Must stay the first statement.
if (e.target?.closest?.('[data-raw-keys]')) return;
// Don't intercept keys during CJK IME composition
if (e.isComposing || e.keyCode === 229) return;
@@ -4361,6 +4375,10 @@ class CodemanApp {
// Sync sessionOrder with current sessions (preserve order, add new, remove stale)
this.syncSessionOrder();
// (Re)read the owner tab layout on every init, including SSE reconnects: a
// tab:layoutChanged sent while this client was disconnected is never replayed.
this._loadTabLayout();
if (data.respawnStatus) {
this.respawnStatus = data.respawnStatus;
} else {
@@ -5183,6 +5201,12 @@ class CodemanApp {
_updateActiveTabImmediate(sessionId) {
const container = this.$('sessionTabs');
if (!container) return;
// Grouped rail: selecting a session hidden in a collapsed group must show it
// (and re-hide the previous exception), which a class toggle cannot do.
if (this._isTabGroupStructureStale()) {
this._fullRenderSessionTabs();
return;
}
const tabs = container.querySelectorAll('.session-tab[data-id]');
for (const tab of tabs) {
if (tab.dataset.id === sessionId) {
@@ -5191,6 +5215,7 @@ class CodemanApp {
tab.classList.remove('active');
}
}
this._syncTabTreeSelection(container);
// #257: selection used to stop at the class toggle. On phones/tablets the
// strip scrolls horizontally, so a tab selected from the palette, a swipe,
// Alt+N or a push notification could stay parked off-screen.
@@ -5342,7 +5367,12 @@ class CodemanApp {
const container = this.$('sessionTabs');
const existingTabs = container.querySelectorAll('.session-tab[data-id]');
const existingIds = new Set([...existingTabs].map(t => t.dataset.id));
const currentIds = new Set(this.sessions.keys());
// Grouped rail: a collapsed group keeps its rows out of the DOM, so compare
// against the rows the projection SHOWS, not every live session.
const groupProjection = this._projectTabGroups();
const currentIds = groupProjection
? new Set(groupProjection.visibleRefs.filter((ref) => ref.kind === 'session').map((ref) => ref.id))
: new Set(this.sessions.keys());
// Web tabs live in the same strip but are not in this.sessions, so they need
// their own change check. Without it, the session-only comparison below is
@@ -5351,14 +5381,20 @@ class CodemanApp {
const existingWebIds = [...container.querySelectorAll('.session-tab[data-webview-id]')].map(
t => t.dataset.webviewId
);
const wantedWebIds = (this.webviewOrder || []).filter(id => this.webviews?.has(id));
const wantedWebIds = groupProjection
? groupProjection.visibleRefs.filter((ref) => ref.kind === 'webview').map((ref) => ref.id)
: (this.webviewOrder || []).filter(id => this.webviews?.has(id));
const webTabsUnchanged =
existingWebIds.length === wantedWebIds.length && existingWebIds.every((id, i) => id === wantedWebIds[i]);
// Check if we can do incremental update (same session IDs and same web tabs)
// The grouped rail's structure (sections, collapse, the shown exception) can
// change while the id sets stay equal; the in-place patch below cannot move
// or hide a row, so any structural change takes the full rebuild.
const canIncremental = existingIds.size === currentIds.size &&
[...existingIds].every(id => currentIds.has(id)) &&
webTabsUnchanged;
webTabsUnchanged &&
!this._isTabGroupStructureStale(groupProjection);
if (canIncremental) {
// Read once for the whole pass, like the full-rebuild path: this touches
@@ -5545,6 +5581,15 @@ class CodemanApp {
subagentBadgeEl.remove();
}
}
// Grouped tree: the loop above can re-sort rows (`style.order`), move the
// highlight, and change the alerts a collapsed header stands in for, none
// of which rebuilds the rail. Keep what assistive tech and the headers
// report in step. The flat list (role=tablist) never takes this branch.
if (groupProjection && container.getAttribute('role') === 'tree') {
this._syncTabTreeSelection(container);
this._applyTabTreePositions(container);
this._syncTabGroupHeaderAlerts(container, groupProjection);
}
} else {
// Full rebuild needed (sessions added/removed)
this._fullRenderSessionTabs();
@@ -5676,6 +5721,12 @@ class CodemanApp {
const prevScrollTop = container.scrollTop;
const prevActiveTabId = this._lastRenderedActiveTabId;
const isFirstRender = !container.querySelector('.session-tab');
// The rebuild below destroys the focused row. In the grouped tree, put focus
// back on the same item (by identity) so a background render or a keyboard
// collapse does not drop a keyboard user to <body>.
const focusWasInside = container.contains(document.activeElement);
const focusIdentity = this._tabFocusIdentity || (focusWasInside ? this._tabTreeIdentity(document.activeElement) : null);
this._tabFocusIdentity = null;
// Build tabs HTML using array for better string concatenation performance.
// Iterate in sessionOrder to respect the user's custom tab arrangement, on
@@ -5696,6 +5747,10 @@ class CodemanApp {
// layout, and the tabs then carry no inline order at all — the header
// strip's markup is byte-identical to before.
const railSortOrder = this._tabRailSortOrder(tabOrder.filter((id) => this.sessions.has(id)));
// One row per session, in tab order. The flat strip emits them as-is; the
// grouped rail places the SAME markup into its sections, so a row never
// differs between the two (badge = Alt+N slot in sessionOrder either way).
const rowHtml = new Map();
let _tabIdx = 0;
for (const id of tabOrder) {
const session = this.sessions.get(id);
@@ -5759,7 +5814,7 @@ class CodemanApp {
const inlineSessionActions = this.shouldInlineSessionActions();
const tabActionsHtml = `<span class="tab-actions"><span class="tab-gear" onclick="event.stopPropagation(); app.openSessionOptions(${escapeHtml(JSON.stringify(id))})" title="Session options" aria-label="Session options" tabindex="0">&#x2699;</span><span class="tab-detach" onclick="event.stopPropagation(); app.detachSession(${escapeHtml(JSON.stringify(id))})" title="Open in a new window" aria-label="Open session in a new window" tabindex="0">&#x29C9;</span><span class="tab-close" onclick="event.stopPropagation(); app.requestCloseSession(${escapeHtml(JSON.stringify(id))})" title="Close session" aria-label="Close session" tabindex="0">&times;</span><button type="button" class="tab-more" onclick="event.stopPropagation(); app.openTabRailActionMenu(event, ${escapeHtml(JSON.stringify(id))})" title="Session actions" aria-label="Session actions">&#x22EF;</button></span>`;
parts.push(`<div class="session-tab ${isActive ? 'active' : ''}${alertClass}${richClass}${paneExitBadge ? ' tab-agent-exited' : ''}${loadState ? ' tab-loading' : ''}${this.hasTabDetachOverride(id) ? ' tab-show-detach' : ''}"${richData}${railOrderStyle} data-id="${id}" data-color="${color}" ${loadState ? `data-load-phase="${escapeHtml(loadState.phase)}"` : ''} onclick="app.handleSessionTabClick(event, ${escapeHtml(JSON.stringify(id))})" oncontextmenu="event.preventDefault(); app.startInlineRename(${escapeHtml(JSON.stringify(id))})" tabindex="0" role="tab" aria-selected="${isActive ? 'true' : 'false'}" aria-busy="${loadState ? 'true' : 'false'}" aria-label="${escapeHtml(paneExitAriaLabel(name, paneExitBadge))}" ${tabTooltip ? `title="${escapeHtml(tabTooltip)}"` : ''}>
rowHtml.set(id, `<div class="session-tab ${isActive ? 'active' : ''}${alertClass}${richClass}${paneExitBadge ? ' tab-agent-exited' : ''}${loadState ? ' tab-loading' : ''}${this.hasTabDetachOverride(id) ? ' tab-show-detach' : ''}"${richData}${railOrderStyle} data-id="${id}" data-color="${color}" ${loadState ? `data-load-phase="${escapeHtml(loadState.phase)}"` : ''} onclick="app.handleSessionTabClick(event, ${escapeHtml(JSON.stringify(id))})" oncontextmenu="event.preventDefault(); app.startInlineRename(${escapeHtml(JSON.stringify(id))})" tabindex="0" role="tab" aria-selected="${isActive ? 'true' : 'false'}" aria-busy="${loadState ? 'true' : 'false'}" aria-label="${escapeHtml(paneExitAriaLabel(name, paneExitBadge))}" ${tabTooltip ? `title="${escapeHtml(tabTooltip)}"` : ''}>
${_tabIdx < 9 ? '<span class="tab-number">' + (_tabIdx + 1) + '</span>' : ''}
${loadState ? '<span class="tab-load-spinner" aria-hidden="true"></span>' : ''}
<span class="tab-status ${status}" aria-hidden="true"></span>
@@ -5782,12 +5837,41 @@ class CodemanApp {
_tabIdx++;
}
// Web tabs (dashboard URLs) render after the session tabs, continuing the
// Alt+N numbering. They carry data-webview-id instead of data-id, so every
// session-tab code path above (drag-and-drop, alerts, badges) skips them.
parts.push(this.renderWebviewTabs ? this.renderWebviewTabs(_tabIdx) : '');
const groupProjection = this._projectTabGroups();
if (groupProjection) {
// Grouped vertical rail. Web tabs keep their flat-strip Alt+N slot (after
// every session), wherever their group puts them.
const webviewSlots = new Map(
(this.webviewOrder || []).filter((wid) => this.webviews?.has(wid)).map((wid, i) => [wid, _tabIdx + i])
);
parts.push(
window.CodemanTabLayout.renderProjection(
groupProjection,
(ref) =>
ref.kind === 'session'
? rowHtml.get(ref.id) || ''
: this.renderWebviewTab?.(ref.id, webviewSlots.get(ref.id) ?? Infinity) || '',
escapeHtml
)
);
this._hiddenTabGroupByRef = new Map(Object.entries(groupProjection.hiddenTabGroupByRef));
} else {
parts.push(...rowHtml.values());
// Web tabs (dashboard URLs) render after the session tabs, continuing the
// Alt+N numbering. They carry data-webview-id instead of data-id, so every
// session-tab code path above (drag-and-drop, alerts, badges) skips them.
parts.push(this.renderWebviewTabs ? this.renderWebviewTabs(_tabIdx) : '');
this._hiddenTabGroupByRef = new Map();
}
this._lastTabGroupStructureKey = this._tabGroupStructureKey(groupProjection);
container.innerHTML = parts.join('');
container.classList.toggle('session-tabs--grouped', !!groupProjection);
this._applyTabListRole(container, !!groupProjection);
if (groupProjection) {
this._applyTabTreeSemantics(container, { identity: focusIdentity, refocus: focusWasInside });
this._syncTabGroupHeaderAlerts(container, groupProjection);
}
// Put the strip back where the user left it, then reveal the active tab
// only when it CHANGED (or on the first paint). Restoring unconditionally
@@ -5838,6 +5922,12 @@ class CodemanApp {
}
this._tabKeydownHandler = (e) => {
// The grouped rail is a tree with its own key model; everything else
// (header strip, sidebar, flat rail) keeps the tab-strip walk below.
if (container.getAttribute('role') === 'tree') {
this._handleTabTreeKeydown(e, container);
return;
}
// Up/Down are aliases of Left/Right, not replacements: the strip stays
// arrow-key navigable exactly as before, the vertical sidebar just gains
// the axis a user reaches for there.
@@ -5862,8 +5952,7 @@ class CodemanApp {
// Enter or Space activates the tab
if ((e.key === 'Enter' || e.key === ' ') && currentIndex >= 0) {
e.preventDefault();
const sessionId = tabs[currentIndex].dataset.id;
this.selectSession(sessionId, { forceReload: true });
this._activateTabRow(tabs[currentIndex]);
return;
}
@@ -5894,6 +5983,250 @@ class CodemanApp {
};
container.addEventListener('keydown', this._tabKeydownHandler);
// Grouped tree: a row or header focused by pointer becomes the tab stop, so
// Tab-ing away and back returns to it and there is still exactly one stop.
if (!this._tabTreeFocusinHandler) {
this._tabTreeFocusinHandler = (e) => {
if (container.getAttribute('role') !== 'tree') return;
const item = e.target?.closest?.('[role="treeitem"]');
if (item && container.contains(item)) this._setTabTreeStop(container, item);
};
container.addEventListener('focusin', this._tabTreeFocusinHandler);
}
}
/** Select a session row or open a web-tab row (Enter/Space, either layout). */
_activateTabRow(row) {
if (row?.dataset.webviewId) return this.openWebview(row.dataset.webviewId);
if (row?.dataset.id) return this.selectSession(row.dataset.id, { forceReload: true });
return undefined;
}
// ═══════════════════════════════════════════════════════════════
// Grouped rail: tree semantics and keyboard model
// ═══════════════════════════════════════════════════════════════
//
// Only the GROUPED vertical rail is a tree. #sessionTabs becomes role=tree;
// named-group headers are level-1 treeitems that own their rows (level 2);
// ungrouped rows and a collapsed group's kept selection are level 1. One item
// carries tabindex=0 (roving), every control inside a row is removed from the
// tab order, and focus is restored by identity across full re-renders. The
// flat rail and the header strip keep role=tablist / role=tab untouched.
/** #sessionTabs is a tablist (index.html) except while it holds the grouped tree. */
_applyTabListRole(container, grouped) {
if (grouped) {
container.setAttribute('role', 'tree');
container.setAttribute('aria-label', 'Sessions');
} else if (container.getAttribute('role') === 'tree') {
container.setAttribute('role', 'tablist');
container.setAttribute('aria-label', 'Session tabs');
}
}
/** Stable identity of a tree item (or anything inside one) across re-renders. */
_tabTreeIdentity(element) {
const item = element?.closest?.('[data-tab-group-header], .session-tab');
if (!item) return null;
if (item.dataset.tabGroupHeader) return `group:${item.dataset.tabGroupHeader}`;
if (item.dataset.webviewId) return `webview:${item.dataset.webviewId}`;
if (item.dataset.id) return `session:${item.dataset.id}`;
return null;
}
/**
* Visible tree items in the order the eye reads them: each section's header,
* then its rows. A sorted rail paints rows with the flex `order` property
* inside their own group column, so rows are ordered by COMPUTED order within
* a section (stable, so the unsorted rail keeps DOM order).
*/
_tabTreeItems(container) {
const sorted = this.isTabRailSorted();
const orderOf = (el) => Number(getComputedStyle(el).order) || 0;
const items = [];
for (const section of container.querySelectorAll('.tab-layout-group')) {
const header = section.querySelector(':scope > [role="treeitem"]');
if (header) items.push(header);
const rows = [...section.querySelectorAll('.session-tab[role="treeitem"]:not(.tab-filtered-out)')];
if (sorted) rows.sort((a, b) => orderOf(a) - orderOf(b));
items.push(...rows);
}
return items;
}
/** Move the single tab stop to `item` (every other tree item gets -1). */
_setTabTreeStop(container, item) {
for (const el of container.querySelectorAll('[role="treeitem"]')) el.tabIndex = el === item ? 0 : -1;
}
/**
* Turn freshly rendered rows into tree items and place the roving tab stop.
* Rows arrive as the flat strip's markup (role=tab, tabindex=0 on the row and
* its controls); only their semantics change here, never their content.
*/
_applyTabTreeSemantics(container, { identity = null, refocus = false } = {}) {
for (const row of container.querySelectorAll('.session-tab')) {
row.setAttribute('role', 'treeitem');
row.setAttribute('aria-selected', row.classList.contains('active') ? 'true' : 'false');
row.setAttribute('aria-level', row.closest('[role="group"]') ? '2' : '1');
// Controls stay clickable, but leave the tab order: the tree has ONE stop,
// and a row's actions are reachable from it with Shift+F10 / ContextMenu.
for (const control of row.querySelectorAll('[tabindex], button, a[href], input, select, textarea')) {
control.tabIndex = -1;
}
}
for (const header of container.querySelectorAll('[data-tab-group-header]')) header.setAttribute('aria-level', '1');
const items = this._applyTabTreePositions(container);
const byIdentity = (id) => (id ? items.find((item) => this._tabTreeIdentity(item) === id) : null);
// A focused row that a collapse just hid hands focus to its group header.
const hiddenIn = identity ? this._hiddenTabGroupByRef?.get(identity) : null;
const target =
byIdentity(identity) ||
(hiddenIn ? byIdentity(`group:${hiddenIn}`) : null) ||
items.find((item) => item.getAttribute('aria-selected') === 'true') ||
items[0];
if (!target) return;
this._setTabTreeStop(container, target);
if (refocus && document.activeElement !== target) target.focus();
}
/**
* aria-posinset / aria-setsize within each level, in PAINTED order: the
* level-1 run (headers, ungrouped rows, a collapsed group's kept row) and each
* group's own rows. Runs after every full render AND after an incremental pass,
* because the activity-sorted rail re-sorts rows in place. Returns the items.
*/
_applyTabTreePositions(container) {
const items = this._tabTreeItems(container);
const sets = new Map();
for (const item of items) {
const owner = item.getAttribute('aria-level') === '2' ? item.closest('[role="group"]') : container;
if (!sets.has(owner)) sets.set(owner, []);
sets.get(owner).push(item);
}
for (const members of sets.values()) {
members.forEach((item, index) => {
const setsize = String(members.length);
const posinset = String(index + 1);
if (item.getAttribute('aria-setsize') !== setsize) item.setAttribute('aria-setsize', setsize);
if (item.getAttribute('aria-posinset') !== posinset) item.setAttribute('aria-posinset', posinset);
});
}
return items;
}
/**
* A collapsed group hides its rows, including ones that need the user. Its
* header takes the most urgent hidden alert in the tab alert language
* (`tab-alert-action` red, `tab-alert-idle` yellow), so a permission prompt
* behind a collapse is never invisible. Patched in place on both render paths:
* alerts change without a rebuild.
*/
_syncTabGroupHeaderAlerts(container, projection) {
const alerts = window.CodemanTabLayout?.hiddenGroupAlerts(projection, (id) => this.tabAlerts?.get(id)) || {};
for (const header of container.querySelectorAll('[data-tab-group-header]')) {
const alert = alerts[header.dataset.tabGroupHeader];
header.classList.toggle('tab-alert-action', alert === 'action');
header.classList.toggle('tab-alert-idle', alert === 'idle');
}
}
/** Keep aria-selected on the grouped tree in step with the .active class. */
_syncTabTreeSelection(container) {
if (container?.getAttribute('role') !== 'tree') return;
for (const row of container.querySelectorAll('.session-tab')) {
row.setAttribute('aria-selected', row.classList.contains('active') ? 'true' : 'false');
}
}
/**
* Keyboard model of the grouped tree (WAI-ARIA tree view): Up/Down walk the
* visible items, Home/End jump, Right expands a header or enters it, Left
* collapses a header or climbs from a row to its header, Enter/Space select a
* row or toggle a header, Shift+F10 / ContextMenu open a row's actions.
*/
_handleTabTreeKeydown(e, container) {
if (e.target?.closest?.('input, textarea, select, [contenteditable="true"]')) return;
const items = this._tabTreeItems(container);
const current = e.target?.closest?.('[role="treeitem"]');
// A key pressed on a control INSIDE a row (its close or overflow button,
// focused by a click or handed focus back by the action menu) belongs to
// that control: Enter there must reopen the menu, not re-select the row.
// Same contract as the flat list, which acts only on a focused row.
if (!current || current !== e.target) return;
const index = items.indexOf(current);
if (index < 0) return;
const groupId = current.dataset.tabGroupHeader || null;
const expanded = current.getAttribute('aria-expanded') === 'true';
// A group with no open rows is a leaf (no aria-expanded): nothing to open.
const expandable = current.hasAttribute('aria-expanded');
const focusAt = (next) => {
if (!next) return;
this._setTabTreeStop(container, next);
next.focus();
};
const toggle = (collapse) => {
// The toggle re-renders the rail; keep focus on this header through it.
this._tabFocusIdentity = `group:${groupId}`;
this.toggleTabGroupCollapsed(groupId, collapse);
};
switch (e.key) {
case 'ArrowDown':
case 'ArrowUp': {
const step = e.key === 'ArrowDown' ? 1 : -1;
focusAt(items[(index + step + items.length) % items.length]);
break;
}
case 'Home':
focusAt(items[0]);
break;
case 'End':
focusAt(items[items.length - 1]);
break;
case 'ArrowRight':
if (!groupId || !expandable) return;
if (!expanded) toggle(false);
else {
const child = items.find((item) => item.closest('[role="group"]')?.id === current.getAttribute('aria-owns'));
if (!child) return;
focusAt(child);
}
break;
case 'ArrowLeft':
if (groupId) {
if (!expandable || !expanded) return;
toggle(true);
} else {
const group = current.closest('[role="group"]');
const header = group ? container.querySelector(`[aria-owns="${CSS.escape(group.id)}"]`) : null;
if (!header) return;
focusAt(header);
}
break;
case 'Enter':
case ' ':
if (groupId) toggle();
else this._activateTabRow(current);
break;
case 'F10':
case 'ContextMenu':
if (e.key === 'F10' && !e.shiftKey) return;
if (current.dataset.id) {
this.openTabRailActionMenu?.(
{ preventDefault() {}, stopPropagation() {}, currentTarget: current },
current.dataset.id
);
} else if (current.dataset.webviewId) {
this.showWebviewModal?.(current.dataset.webviewId);
} else return;
break;
default:
return;
}
e.preventDefault();
}
handleSessionTabClick(event, sessionId) {
@@ -5978,6 +6311,141 @@ class CodemanApp {
}
}
// ═══════════════════════════════════════════════════════════════
// Owner tab layout: grouped vertical rail (read-only)
// ═══════════════════════════════════════════════════════════════
//
// The server owns named tab groups (GET /api/tab-layout, tab-layout*.ts) and
// already projects them onto the global session order, so sessionOrder, Alt+N,
// Ctrl+Tab and every other order consumer are untouched here. This layer only
// decides how the VERTICAL rail draws rows: in sections, with per-device
// collapse. With no groups (or any read failure) the rail is the flat list it
// has always been.
_ensureTabLayoutCoordinator() {
if (this._tabLayoutCoordinator) return this._tabLayoutCoordinator;
if (!window.CodemanTabLayout) return null;
this._tabLayoutCoordinator = window.CodemanTabLayout.createLoadCoordinator({
fetchLayout: async () => {
const data = await this._apiJson('/api/tab-layout');
if (!data?.layout) throw new Error('Tab layout unavailable');
return data.layout;
},
applyLayout: (layout) => this._applyTabLayout(layout),
applyFallback: () => this._applyTabLayout(null),
// 5 s, 10 s, 20 s, 40 s, then stop until the next SSE init or
// tab:layoutChanged asks again: an unreachable server must not cost a
// fetch every 5 s for as long as the page stays open.
retryDelayMs: 5000,
maxRetryDelayMs: 60000,
maxRetries: 4,
scheduleRetry: (retry, delayMs) => setTimeout(retry, delayMs),
cancelRetry: (timer) => clearTimeout(timer),
});
return this._tabLayoutCoordinator;
}
_loadTabLayout() {
return this._ensureTabLayoutCoordinator()?.load() ?? Promise.resolve(false);
}
/** SSE tab:layoutChanged carries `{ owner, version }`; the layout itself is re-read. */
_onTabLayoutChanged(data) {
const me = window.__codemanUser;
// Another user's layout changed: nothing of ours moved. (The GET is
// owner-scoped server-side, so this is a saved request, not a guard.)
if (me?.multiUser && typeof data?.owner === 'string' && data.owner !== me.username) return;
if (Number.isSafeInteger(data?.version) && this.tabLayout && data.version <= this.tabLayout.version) return;
this._loadTabLayout();
}
/** Adopt a layout read (or null after a failed read, which renders flat). */
_applyTabLayout(layout) {
let next = null;
if (layout) {
try {
next = window.CodemanTabLayout.normalizeLayout(layout);
} catch {
next = null;
}
}
// An overtaken response is already dropped by the coordinator; this guards a
// reordering between the coordinator and an SSE-triggered reload.
if (next && this.tabLayout && next.version < this.tabLayout.version) return;
this.tabLayout = next;
const storage = this._getTabCollapseStorage();
const collapsed = storage && next
? window.CodemanTabLayout.loadCollapsedGroupIds(storage, next.groups.map((group) => group.id))
: { ids: [], ok: !next };
if (!collapsed.ok) this._tabCollapseStorageFailed = true;
this.collapsedTabGroupIds = new Set(collapsed.ids);
// The server announces a layout change on every session create/close, web
// tab create/delete and order PUT, and most of those move nothing on this
// rail (always so on the flat rail, which is every owner without groups).
// Rebuild only when what the rail would draw actually changed.
if (this._isTabGroupStructureStale()) this._fullRenderSessionTabs();
}
/** localStorage, or null once it has failed (collapse then stays all-expanded). */
_getTabCollapseStorage() {
if (this._tabCollapseStorageFailed) return null;
try {
return window.localStorage;
} catch {
this._tabCollapseStorageFailed = true;
return null;
}
}
/**
* The grouped projection for the CURRENT render, or null when the rail should
* render flat: horizontal strip (incl. phones and the sidebar, which force it),
* no layout yet, a failed read, or a layout without groups.
*/
_projectTabGroups() {
if (!this.tabLayout || this._tabOrientation() !== 'vertical' || !window.CodemanTabLayout) return null;
return window.CodemanTabLayout.project(this.tabLayout, {
// sessionOrder, not the sessions Map: rows are built in that order, so a
// session the layout has not placed yet lands where the flat strip has it.
liveSessionIds: this.sessionOrder.filter((id) => this.sessions.has(id)),
openWebviewIds: (this.webviewOrder || []).filter((id) => this.webviews?.has(id)),
collapsedGroupIds: [...this.collapsedTabGroupIds],
activeSessionId: this.activeSessionId,
activeWebviewId: this.activeWebviewId,
});
}
_tabGroupStructureKey(projection) {
return window.CodemanTabLayout?.structureKey(this.tabLayout, projection, [...this.collapsedTabGroupIds]) ?? null;
}
/** True when the DOM's grouping no longer matches what a render would draw. */
_isTabGroupStructureStale(projection = this._projectTabGroups()) {
return this._tabGroupStructureKey(projection) !== this._lastTabGroupStructureKey;
}
/**
* Collapse/expand one group (header click). Per-device: stored in localStorage,
* never sent to the server, so collapsing on a laptop leaves the desktop alone.
* A storage failure leaves every group expanded rather than half-remembered.
*/
toggleTabGroupCollapsed(groupId, forceCollapsed) {
if (!this.tabLayout?.groups?.some((group) => group.id === groupId)) return false;
const next = new Set(this.collapsedTabGroupIds);
const shouldCollapse = forceCollapsed === undefined ? !next.has(groupId) : forceCollapsed === true;
if (shouldCollapse) next.add(groupId);
else next.delete(groupId);
const storage = this._getTabCollapseStorage();
const saved = storage
? window.CodemanTabLayout.saveCollapsedGroupIds(storage, [...next])
: { ids: [], ok: false };
if (!saved.ok) this._tabCollapseStorageFailed = true;
this.collapsedTabGroupIds = new Set(saved.ids);
// The full render also redraws connectors anchored to rows that just moved.
this._fullRenderSessionTabs();
return this.collapsedTabGroupIds.has(groupId) === shouldCollapse;
}
// Set up drag-and-drop handlers on tab elements
setupTabDragHandlers() {
const container = this.$('sessionTabs');
@@ -5989,7 +6457,10 @@ class CodemanApp {
// affordance instead of lying about it — `tabRailSort: 'manual'` is the way
// back to drag-reordering, and Alt+N / Ctrl+Shift+{ } still walk the strip
// order this list is no longer showing.
if (this.isTabRailSorted()) {
// The grouped rail is read-only for now: a flat-order drag cannot express
// "move into that group", and the server would re-rank it within its old
// group anyway. Grouped editing comes with its own drag model.
if (this.isTabRailSorted() || container.classList.contains('session-tabs--grouped')) {
tabs.forEach((tab) => tab.setAttribute('draggable', 'false'));
return;
}
@@ -6080,10 +6551,26 @@ class CodemanApp {
});
}
/**
* Grouped rail: Ctrl+Shift+{ / } may only swap the active session with a
* neighbour in its OWN section. Across a group boundary the server re-ranks
* each group on its own (`putLegacyOrder`), so nothing moves there, no
* session:orderChanged comes back, and this client would keep a swapped
* sessionOrder (and Alt+N targets) that no other device shares. Same reason
* drag is off in the grouped rail. Any other layout: always allowed.
*/
_canSwapActiveTabWith(neighbourId) {
const projection = this._projectTabGroups();
if (!projection) return true;
const sectionOf = (id) => projection.sectionByRef[`session:${id}`];
return sectionOf(this.activeSessionId) === sectionOf(neighbourId);
}
moveActiveTabLeft() {
if (!this.activeSessionId) return;
const idx = this.sessionOrder.indexOf(this.activeSessionId);
if (idx <= 0) return;
if (!this._canSwapActiveTabWith(this.sessionOrder[idx - 1])) return;
[this.sessionOrder[idx - 1], this.sessionOrder[idx]] = [this.sessionOrder[idx], this.sessionOrder[idx - 1]];
this.saveSessionOrder();
this._fullRenderSessionTabs();
@@ -6093,6 +6580,7 @@ class CodemanApp {
if (!this.activeSessionId) return;
const idx = this.sessionOrder.indexOf(this.activeSessionId);
if (idx === -1 || idx >= this.sessionOrder.length - 1) return;
if (!this._canSwapActiveTabWith(this.sessionOrder[idx + 1])) return;
[this.sessionOrder[idx], this.sessionOrder[idx + 1]] = [this.sessionOrder[idx + 1], this.sessionOrder[idx]];
this.saveSessionOrder();
this._fullRenderSessionTabs();
+1
View File
@@ -67,6 +67,7 @@
'Open away digest': '打开离开期间摘要',
'Session Manager': '会话管理器',
'Session actions': '会话操作',
Ungrouped: '未分组',
'Open session manager': '打开会话管理器',
Attachments: '附件',
'Open attachment history': '打开附件历史',
+108
View File
@@ -1822,6 +1822,22 @@
</div>
</div>
</div>
<div class="set-group">
<div class="set-group-head"><h4>Key tester</h4><span class="set-scope">device</span></div>
<div class="set-group-body">
<div class="set-row has-field" data-search="key tester keyboard shift enter newline diagnose keydown keypress">
<div class="set-row-text">
<span class="set-row-label">Key tester</span>
<span class="set-row-desc">Click the box and press keys to see what this browser reports (key, code, modifiers) for keydown, keypress and keyup. Useful when a shortcut such as Shift+Enter behaves differently on one device. Nothing is sent to a session.</span>
</div>
<input type="text" id="keyTesterInput" class="set-input" data-raw-keys readonly autocomplete="off" spellcheck="false"
placeholder="Click here, then press keys"
onkeydown="app.keyTesterEvent(event)" onkeypress="app.keyTesterEvent(event)" onkeyup="app.keyTesterEvent(event)">
</div>
<pre id="keyTesterLog" class="set-note mono" style="display:none;white-space:pre-wrap" data-i18n-skip></pre>
</div>
</div>
</section>
<!-- ══ Header &amp; Panels ═══════════════════════════════════════ -->
@@ -2172,6 +2188,19 @@
<option value="ultracode">Ultracode</option>
</select>
</div>
<div class="set-row set-row-block" data-search="advisor fable opus sonnet second opinion review consult">
<div class="set-row-text">
<span class="set-row-label">Advisor</span>
<span class="set-row-desc">A stronger model Claude consults before big decisions, on repeated errors and before calling a task done. Uses extra tokens. Switchable in-session with /advisor.</span>
</div>
<div class="set-segment" id="appSettingsAdvisorSegment" role="radiogroup" aria-label="Advisor"></div>
<select id="appSettingsClaudeAdvisor" class="set-select set-field-hidden" aria-hidden="true" tabindex="-1">
<option value="">Default</option>
<option value="sonnet">Sonnet</option>
<option value="opus">Opus</option>
<option value="fable">Fable</option>
</select>
</div>
</div>
</div>
@@ -2474,6 +2503,30 @@
</div>
</div>
</div>
<div class="set-group" id="mcpSyncGroup">
<div class="set-group-head"><h4>MCP servers</h4><span class="set-scope">synced</span></div>
<div class="set-group-body">
<div class="set-row" data-search="mcp server sync enable claude codex gemini opencode antigravity">
<div class="set-row-text">
<span class="set-row-label">Enable MCP server sync</span>
<span class="set-row-desc">Adds a control that copies MCP servers between your enabled CLIs by writing their own config files. Off by default: this changes other tools' configuration, not just Codeman's.</span>
</div>
<label class="switch switch-sm"><input type="checkbox" id="appSettingsMcpSync" onchange="app.applyMcpSyncVisibility()"><span class="slider"></span></label>
</div>
<div class="set-row" id="mcpSyncActionRow" style="display:none" data-search="mcp server sync preview">
<div class="set-row-text">
<span class="set-row-label">Sync MCP servers across CLIs</span>
<span class="set-row-desc">Copies each installed, enabled CLI's MCP servers into the others. Only adds missing servers; never edits, removes or copies a server you switched off. Env values and headers are copied too, so a file that receives them is left readable by you only. The previous file is kept as <code>.codeman-bak</code> (overwritten by each sync). A config dir moved by the CLI's own env var (<code>CODEX_HOME</code>, <code>CLAUDE_CONFIG_DIR</code>, <code>XDG_CONFIG_HOME</code>, <code>GEMINI_CLI_HOME</code>) is followed as Codeman's server sees it; a per-session override is not.</span>
</div>
<span>
<button class="btn-toolbar btn-sm" id="mcpSyncPreviewBtn" onclick="app.mcpSync(false)">Preview</button>
<button class="btn-toolbar btn-sm btn-primary" id="mcpSyncApplyBtn" onclick="app.mcpSync(true)">Sync now</button>
</span>
</div>
<div id="mcpSyncResult" class="set-note" style="display:none"></div>
</div>
</div>
</section>
<!-- ══ Notifications ════════════════════════════════════════════ -->
@@ -2601,6 +2654,57 @@
</div>
</div>
</div>
<div class="set-group" id="webhookGroup" style="display:none">
<div class="set-group-head"><h4>Webhook (ntfy, Slack, Discord)</h4><span class="set-scope">server</span></div>
<div class="set-group-body">
<div class="set-row" data-search="webhook ntfy slack discord notification phone headless">
<div class="set-row-text">
<span class="set-row-label">Send alerts to a webhook</span>
<span class="set-row-desc">Posts the same events as push notifications (permission prompts, questions, errors, idle) to ntfy, Slack, Discord or any URL, so a server with no browser open can still reach your phone. The URL is a secret: it is stored on the server only and is never shown again once saved. On public ntfy.sh anyone who guesses the topic can read it, so pick a long random one.</span>
</div>
<label class="switch switch-sm"><input type="checkbox" id="webhookEnabled"><span class="slider"></span></label>
</div>
<div class="set-row has-field">
<div class="set-row-text"><span class="set-row-label">Service</span></div>
<select id="webhookKind" class="set-select">
<option value="ntfy">ntfy</option>
<option value="slack">Slack</option>
<option value="discord">Discord</option>
<option value="generic">Generic JSON</option>
</select>
</div>
<div class="set-row has-field">
<div class="set-row-text">
<span class="set-row-label">Webhook URL</span>
<span class="set-row-desc" id="webhookUrlHint">Nothing saved yet.</span>
</div>
<input type="password" id="webhookUrl" class="set-input" autocomplete="off" spellcheck="false" placeholder="https://ntfy.sh/your-topic">
</div>
<div class="set-row has-field">
<div class="set-row-text">
<span class="set-row-label">Which events</span>
<span class="set-row-desc">"Needs attention" skips the routine "response complete" message.</span>
</div>
<select id="webhookScope" class="set-select">
<option value="attention">Needs attention</option>
<option value="all">Everything</option>
</select>
</div>
<div class="set-row">
<div class="set-row-text">
<span class="set-row-label">Save and test</span>
<span class="set-row-desc">The main Settings Save saves this group too. Send test saves pending edits first.</span>
</div>
<span>
<button class="btn-toolbar btn-sm btn-primary" id="webhookSaveBtn" onclick="app.saveWebhook()">Save</button>
<button class="btn-toolbar btn-sm" id="webhookTestBtn" onclick="app.testWebhook()">Send test</button>
<button class="btn-toolbar btn-sm" id="webhookClearBtn" onclick="app.clearWebhook()" style="display:none">Remove URL</button>
</span>
</div>
<div id="webhookResult" class="set-note" style="display:none" data-i18n-skip></div>
</div>
</div>
</section>
<!-- ══ Voice ════════════════════════════════════════════════════ -->
@@ -3738,10 +3842,14 @@
<script defer src="notification-manager.js"></script>
<script defer src="keyboard-accessory.js"></script>
<script defer src="input-cjk.js"></script>
<!-- Shows iOS Safari IME composition text inside the terminal. Must precede terminal-ui.js. -->
<script defer src="mobile-ime-preview.js"></script>
<!-- Forwards committed input events that xterm drops on Android/GBoard soft keyboards. Must precede terminal-ui.js. -->
<script defer src="terminal-keycode229-recovery.js"></script>
<!-- Hardened markdown HTML sanitizer (wires DOMPurify). Must precede app.js. -->
<script defer src="sanitize-html.js"></script>
<!-- Owner tab layout projection (grouped vertical rail); pure, read by app.js. -->
<script defer src="tab-layout-browser.js"></script>
<script defer src="app.js"></script>
<script defer src="tab-rail-resize.js"></script>
<script defer src="terminal-ui.js"></script>
+297
View File
@@ -0,0 +1,297 @@
/**
* @fileoverview In-terminal preview of IME composition text on iOS Safari.
*
* On iOS WebKit touch devices the text an IME is composing (Japanese, Chinese,
* Korean, dictation) is not visible inside the terminal until it commits, so
* the user types blind. The controller listens to the helper textarea's
* composition events and asks the caller to render the latest composition
* (`phase: 'provisional'`), coalesced to one render per animation frame and
* capped at 2048 characters. When xterm emits the committed text through
* onData, the caller hands it to `consumeTerminalData()`, which switches the
* preview to `phase: 'committed'` until something else shows the text: the
* local echo overlay or a prediction (`completeCommit`), authoritative
* terminal output (`noteAuthoritativeOutput`), or a 2 s fallback timer. The
* same 2 s bound applies while waiting for a commit that never reaches onData
* (the user deleted the whole composition), so a later unrelated chunk is never
* mistaken for it.
*
* Keydown ordering: xterm registers its textarea keydown listener in the
* capture phase inside terminal.open() and finalizes the composition there
* (CompositionHelper.keydown), emitting the commit through onData
* synchronously. The controller therefore observes keydown in the capture
* phase on an ANCESTOR (`keydownTarget`, the terminal element), which runs
* before any listener on the textarea itself, and finalizes on exactly the
* keys xterm does.
*
* VISUAL ONLY: the controller never sends, consumes or reorders input bytes,
* and every callback is wrapped so a failing render cannot block the wire.
* `isIosWebKitTouch()` gates creation; other platforms keep xterm's own
* composition view untouched.
*
* @dependency none (standalone IIFE; consumed by terminal-ui.js)
* @loadorder 5.52 (before app.js/terminal-ui.js, which create the controller)
*/
(function (global) {
'use strict';
const COMMITTED_VISUAL_TTL = 2000;
const PREVIEW_CAP = 2048;
// keyCodes on which xterm 6's CompositionHelper.keydown keeps composing
// (CapsLock, the IME "composition character", Shift/Ctrl/Alt). Any other
// keydown during a composition finalizes it.
const KEEP_COMPOSING_KEYCODES = new Set([20, 229, 16, 17, 18]);
const CONTROL_OR_LINE_BREAK = /[\u0000-\u001f\u007f-\u009f\u2028\u2029]/;
function isIosWebKitTouch(nav = navigator) {
const userAgent = String(nav && nav.userAgent ? nav.userAgent : '');
const platform = String(nav && nav.platform ? nav.platform : '');
const touchPoints = Number(nav && nav.maxTouchPoints ? nav.maxTouchPoints : 0);
const iosDevice = /iPhone|iPad|iPod/.test(userAgent);
const desktopIpad = platform === 'MacIntel' && touchPoints > 1;
return touchPoints > 0 && /AppleWebKit/.test(userAgent) && (iosDevice || desktopIpad);
}
function create(options) {
const textarea = options.textarea;
// Must be the textarea or an ancestor of it, so its capture listener runs
// before xterm's capture listener on the textarea.
const keydownTarget = options.keydownTarget || textarea;
const render = typeof options.render === 'function' ? options.render : function () {};
const clear = typeof options.clear === 'function' ? options.clear : function () {};
const onCommit = typeof options.onCommit === 'function' ? options.onCommit : function () {};
const scheduleFrame = options.scheduleFrame || global.requestAnimationFrame.bind(global);
const cancelFrame = options.cancelFrame || global.cancelAnimationFrame.bind(global);
const setTimer = options.setTimer || global.setTimeout.bind(global);
const clearTimer = options.clearTimer || global.clearTimeout.bind(global);
let generation = 0;
let composing = false;
let awaitingCommit = false;
let committed = false;
let latestValue = '';
let renderPhase = null;
let frameToken = null;
let timerToken = null;
let finalizedByKeydown = false;
let destroyed = false;
let invokingClear = false;
function safely(callback, ...args) {
try {
return callback(...args);
} catch (_error) {
return undefined;
}
}
function cancelScheduledFrame() {
const token = frameToken;
frameToken = null;
if (token && token.id !== undefined) safely(cancelFrame, token.id);
}
function cancelCommittedTimer() {
const token = timerToken;
timerToken = null;
if (token && token.id !== undefined) safely(clearTimer, token.id);
}
function clearVisual() {
if (invokingClear) return;
invokingClear = true;
safely(clear);
invokingClear = false;
}
function cleanup() {
generation += 1;
cancelScheduledFrame();
cancelCommittedTimer();
composing = false;
awaitingCommit = false;
committed = false;
latestValue = '';
renderPhase = null;
finalizedByKeydown = false;
clearVisual();
}
function scheduleLatestPreview(phase) {
if (destroyed) return;
renderPhase = phase;
if (frameToken) return;
const token = { generation, id: undefined };
frameToken = token;
const callback = function () {
if (destroyed || frameToken !== token || token.generation !== generation || renderPhase === null) return;
frameToken = null;
const value = latestValue.slice(0, PREVIEW_CAP);
const phaseToRender = renderPhase;
safely(render, { text: value, phase: phaseToRender });
};
const id = safely(scheduleFrame, callback);
if (frameToken === token) {
if (id === undefined) frameToken = null;
else token.id = id;
}
}
function beginComposition() {
cleanup();
if (destroyed) return;
composing = true;
}
function updateComposition(event) {
if (!composing) return;
latestValue = event.data == null ? '' : String(event.data);
scheduleLatestPreview('provisional');
}
function onComposingInput(event) {
if (!event.isComposing) return;
updateComposition({ data: event.data == null ? textarea.value : event.data });
}
function armFallbackTimer(isCurrent) {
const token = { generation, id: undefined };
timerToken = token;
const callback = function () {
if (destroyed || timerToken !== token || token.generation !== generation || !isCurrent()) return;
timerToken = null;
cleanup();
};
const id = safely(setTimer, callback, COMMITTED_VISUAL_TTL);
if (timerToken === token) {
if (id === undefined) {
timerToken = null;
return false;
}
token.id = id;
}
return true;
}
function finalizeComposition(value, fromKeydown) {
if (!composing) return;
composing = false;
awaitingCommit = true;
committed = false;
finalizedByKeydown = fromKeydown;
latestValue = value == null ? latestValue : String(value);
scheduleLatestPreview('provisional');
// A commit that never reaches onData (the composition was deleted, so
// xterm emits nothing) must not leave the controller waiting forever.
cancelCommittedTimer();
const owner = generation;
armFallbackTimer(function () {
return awaitingCommit && generation === owner;
});
}
function onCompositionEnd(event) {
if (finalizedByKeydown) {
finalizedByKeydown = false;
return;
}
finalizeComposition(event.data, false);
}
// Mirrors CompositionHelper.keydown in @xterm/xterm 6.0.0
// (src/browser/input/CompositionHelper.ts:94-108): while composing, keyCode
// 20/229 and 16/17/18 keep the composition open and every other keyCode
// finalizes it. `isComposing` and `key` are deliberately not consulted,
// because xterm does not consult them.
function onKeydown(event) {
if (keydownTarget !== textarea && event.target !== textarea) return;
if (!composing || KEEP_COMPOSING_KEYCODES.has(event.keyCode)) return;
finalizeComposition(latestValue, true);
}
function reset() {
if (destroyed) return;
cleanup();
}
function consumeTerminalData(data) {
if (
destroyed ||
!awaitingCommit ||
typeof data !== 'string' ||
data.length === 0 ||
CONTROL_OR_LINE_BREAK.test(data)
) {
return false;
}
generation += 1;
const owner = generation;
cancelScheduledFrame();
cancelCommittedTimer();
composing = false;
awaitingCommit = false;
committed = true;
latestValue = data;
renderPhase = 'committed';
safely(onCommit, data);
if (destroyed || generation !== owner || !committed) return true;
scheduleLatestPreview('committed');
if (destroyed || generation !== owner || !committed) return true;
const armed = armFallbackTimer(function () {
return committed;
});
if (!armed && !destroyed && generation === owner && committed) cleanup();
return true;
}
function completeCommit(result) {
if (destroyed || !result || result.predicted !== true || !committed) return;
cleanup();
}
function noteAuthoritativeOutput() {
if (destroyed || !committed) return;
cleanup();
}
const listeners = [
[textarea, 'compositionstart', beginComposition],
[textarea, 'compositionupdate', updateComposition],
[textarea, 'input', onComposingInput],
[textarea, 'compositionend', onCompositionEnd],
[keydownTarget, 'keydown', onKeydown, true],
[textarea, 'blur', reset],
];
for (const [target, type, listener, capture] of listeners) target.addEventListener(type, listener, capture);
function destroy() {
if (destroyed) return;
destroyed = true;
for (const [target, type, listener, capture] of listeners) target.removeEventListener(type, listener, capture);
cleanup();
}
return {
consumeTerminalData,
completeCommit,
noteAuthoritativeOutput,
reset,
destroy,
get state() {
return {
generation,
composing,
awaitingCommit,
committed,
latest: latestValue,
framePending: frameToken !== null,
timerPending: timerToken !== null,
};
},
};
}
global.MobileImePreview = { create, isIosWebKitTouch };
})(globalThis);
+2
View File
@@ -1038,6 +1038,7 @@ Object.assign(CodemanApp.prototype, {
const ralphGlobalSettings = this.loadAppSettingsFromStorage();
const envOverrides = this.buildEnvOverrides(this.getCaseSettings(config.caseName), ralphGlobalSettings);
const effort = this.getEffortSetting(ralphGlobalSettings);
const advisorModel = this.getAdvisorSetting(ralphGlobalSettings);
const res = await fetch('/api/ralph-loop/start', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
@@ -1050,6 +1051,7 @@ Object.assign(CodemanApp.prototype, {
planItems: enabledItems?.length ? enabledItems : undefined,
...(Object.keys(envOverrides).length > 0 ? { envOverrides } : {}),
...(effort ? { effort } : {}),
...(advisorModel ? { advisorModel } : {}),
}),
});
const data = await res.json();
+27 -11
View File
@@ -153,7 +153,6 @@ Object.assign(CodemanApp.prototype, {
const edges = this._collectLineageEdges();
if (edges.length === 0) return;
this._lineageEdgeCount = edges.length;
if (!rects) rects = new Map();
// PHASE 1 — reads.
@@ -162,19 +161,35 @@ Object.assign(CodemanApp.prototype, {
const stripRect = strip.getBoundingClientRect();
const orientation =
document.documentElement.getAttribute('data-tab-orientation') === 'vertical' ? 'vertical' : 'horizontal';
// A session hidden inside a collapsed group of the grouped rail has no row
// to anchor to, so its end of the arc moves to that group's header (a
// "proxied" endpoint, drawn quieter). Two endpoints proxied to the SAME
// header would be an arc from a row to itself: skipped.
const resolveEndpoint = (id) => {
const tab = strip.querySelector(`.session-tab[data-id="${CSS.escape(id)}"]`);
if (tab) return { key: 'tab:' + id, element: tab, proxied: false };
const groupId = this._hiddenTabGroupByRef?.get('session:' + id);
if (!groupId) return { key: 'tab:' + id, element: null, proxied: false };
const header = strip.querySelector(`[data-tab-group-header="${CSS.escape(groupId)}"]`);
return { key: 'group:' + groupId, element: header, proxied: !!header };
};
const resolvedEdges = [];
for (const edge of edges) {
for (const id of [edge.parentId, edge.childId]) {
const key = 'tab:' + id;
if (rects.has(key)) continue;
const tab = strip.querySelector(`.session-tab[data-id="${CSS.escape(id)}"]`);
rects.set(key, tab ? tab.getBoundingClientRect() : null);
const parentEndpoint = resolveEndpoint(edge.parentId);
const childEndpoint = resolveEndpoint(edge.childId);
if (parentEndpoint.key === childEndpoint.key) continue;
resolvedEdges.push({ edge, parentEndpoint, childEndpoint });
for (const endpoint of [parentEndpoint, childEndpoint]) {
if (rects.has(endpoint.key)) continue;
rects.set(endpoint.key, endpoint.element ? endpoint.element.getBoundingClientRect() : null);
}
}
this._lineageEdgeCount = resolvedEdges.length;
// PHASE 2 — writes, from the cache only.
for (const edge of edges) {
const parentRect = rects.get('tab:' + edge.parentId);
const childRect = rects.get('tab:' + edge.childId);
for (const { edge, parentEndpoint, childEndpoint } of resolvedEdges) {
const parentRect = rects.get(parentEndpoint.key);
const childRect = rects.get(childEndpoint.key);
if (!parentRect || !childRect) continue;
const geom = compute({
@@ -191,7 +206,8 @@ Object.assign(CodemanApp.prototype, {
// The working class marches the dashes, so an active worker is visible along
// the line itself. `status` is the CHILD's, which is the interesting end.
const working = edge.status === 'working' ? ' lineage-line--working' : '';
line.setAttribute('class', 'connection-line lineage-line' + working);
const proxied = parentEndpoint.proxied || childEndpoint.proxied;
line.setAttribute('class', 'connection-line lineage-line' + working + (proxied ? ' lineage-line--proxied' : ''));
// The PARENT's colour rides a CSS custom property so the stylesheet keeps owning
// opacity, glow and dash; an empty colour leaves the --session-blue fallback.
// Every arc out of one tab shares it — see _lineageColorFor().
@@ -211,7 +227,7 @@ Object.assign(CodemanApp.prototype, {
// Resting radius; `lineage-dot-pulse` breathes it 3.5 → 4.5 while the child
// works, so the two have to be changed together.
dot.setAttribute('r', '3.5');
dot.setAttribute('class', 'lineage-line-dot' + working);
dot.setAttribute('class', 'lineage-line-dot' + working + (proxied ? ' lineage-line-dot--proxied' : ''));
dot.setAttribute('data-child-tab', edge.childId);
if (color) dot.style.setProperty('--lineage-color', color);
svg.appendChild(dot);
+13
View File
@@ -169,6 +169,17 @@ Object.assign(CodemanApp.prototype, {
return valid.includes(effort) ? effort : undefined;
},
/**
* Resolve the advisor model for new Claude sessions from global settings.
* Returns 'fable' | 'opus' | 'sonnet', or undefined (= leave it to the CLI's own
* /advisor choice). Sent as the `advisorModel` payload field; the backend merges it
* into the launch's `claude --settings` JSON, so /advisor still switches it in-session.
*/
getAdvisorSetting(globalSettings) {
const advisor = globalSettings?.claudeAdvisorModel;
return ['fable', 'opus', 'sonnet'].includes(advisor) ? advisor : undefined;
},
// ═══════════════════════════════════════════════════════════════
// Quick Start
// ═══════════════════════════════════════════════════════════════
@@ -1965,6 +1976,7 @@ Object.assign(CodemanApp.prototype, {
const envOverrides = this.buildEnvOverrides(caseSettings, globalSettings);
const hasEnvOverrides = Object.keys(envOverrides).length > 0;
const effort = this.getEffortSetting(globalSettings);
const advisorModel = this.getAdvisorSetting(globalSettings);
// Explicit Claude Model choice (App Settings) wins over the legacy 1M Opus
// toggles; both flow as `modelOverride` → the case's .claude/settings.local.json
const useOpus1m = caseSettings.opusContext1m || globalSettings.opusContext1mEnabled;
@@ -1980,6 +1992,7 @@ Object.assign(CodemanApp.prototype, {
workingDir, name,
...(hasEnvOverrides ? { envOverrides } : {}),
...(effort ? { effort } : {}),
...(advisorModel ? { advisorModel } : {}),
...(modelOverride !== undefined ? { modelOverride } : {}),
})
}).then(r => r.json())
+275 -8
View File
@@ -416,6 +416,13 @@ Object.assign(CodemanApp.prototype, {
// .checked fires no onchange, so the list's visibility (and lazy load)
// needs an explicit sync on every open, not just a save.
this.applyCliManagementVisibility();
// MCP server sync: synced, default OFF; same explicit-sync reasoning as above.
// The routes read the SAVED setting, so remember what it was on open: switching it on
// here does nothing server-side until Save (see mcpSync()).
this._mcpSyncSavedOn = settings.mcpSyncEnabled === true;
document.getElementById('appSettingsMcpSync').checked = this._mcpSyncSavedOn;
this.applyMcpSyncVisibility();
this.loadWebhook();
// Read My Mind: synced, default OFF (opt-in; capture + prediction cost real tokens).
document.getElementById('appSettingsReadMyMind').checked = settings.readMyMindEnabled === true;
document.getElementById('appSettingsUltracodeFloatingWindows').checked =
@@ -527,6 +534,7 @@ Object.assign(CodemanApp.prototype, {
document.getElementById('appSettingsOpusContext1m').checked = settings.opusContext1mEnabled ?? false;
document.getElementById('appSettingsRemoteAutoReconnect').checked = settings.remoteAutoReconnect ?? true;
document.getElementById('appSettingsThinkingEffort').value = settings.thinkingEffort ?? '';
document.getElementById('appSettingsClaudeAdvisor').value = settings.claudeAdvisorModel ?? '';
// CPU Priority settings
const niceSettings = settings.nice || {};
document.getElementById('appSettingsNiceEnabled').checked = niceSettings.enabled ?? false;
@@ -627,6 +635,7 @@ Object.assign(CodemanApp.prototype, {
this._syncSettingsChips();
this._syncModelCards();
this._syncEffortSegment();
this._syncAdvisorSegment();
// Back to the top of the document (one scroll, not a tab reset). Updates is
// first now: the version this install is running, and whether a newer one is
// waiting, are the two things worth seeing before any preference. The rest of
@@ -718,6 +727,7 @@ Object.assign(CodemanApp.prototype, {
if (!modal || !doc || typeof modal.querySelectorAll !== 'function') return;
this._buildModelCards();
this._buildEffortSegment();
this._buildAdvisorSegment();
// Rebuilt on every open: admin-ui.js appends its Users entry to the rail
// after the first open, and the menu must not drift from the rail.
this._buildSettingsJumpMenu();
@@ -993,8 +1003,28 @@ Object.assign(CodemanApp.prototype, {
},
_buildEffortSegment() {
const select = document.getElementById('appSettingsThinkingEffort');
const seg = document.getElementById('appSettingsEffortSegment');
this._buildSelectSegment('appSettingsThinkingEffort', 'appSettingsEffortSegment');
},
_syncEffortSegment() {
this._syncSelectSegment('appSettingsThinkingEffort', 'appSettingsEffortSegment');
},
_buildAdvisorSegment() {
this._buildSelectSegment('appSettingsClaudeAdvisor', 'appSettingsAdvisorSegment');
},
_syncAdvisorSegment() {
this._syncSelectSegment('appSettingsClaudeAdvisor', 'appSettingsAdvisorSegment');
},
/**
* Build a radio segment as a view over a hidden <select>, which stays the single
* source of truth for load/save (the same contract as the model cards).
*/
_buildSelectSegment(selectId, segId) {
const select = document.getElementById(selectId);
const seg = document.getElementById(segId);
if (!select || !seg || seg.dataset.built === '1' || !select.options) return;
seg.innerHTML = '';
[...select.options].forEach(opt => {
@@ -1005,16 +1035,16 @@ Object.assign(CodemanApp.prototype, {
btn.textContent = opt.textContent;
btn.addEventListener('click', () => {
select.value = opt.value;
this._syncEffortSegment();
this._syncSelectSegment(selectId, segId);
});
seg.appendChild(btn);
});
seg.dataset.built = '1';
},
_syncEffortSegment() {
const select = document.getElementById('appSettingsThinkingEffort');
const seg = document.getElementById('appSettingsEffortSegment');
_syncSelectSegment(selectId, segId) {
const select = document.getElementById(selectId);
const seg = document.getElementById(segId);
if (!select || !seg) return;
seg.querySelectorAll('button').forEach(btn => {
const on = btn.dataset.value === (select.value || '');
@@ -1114,6 +1144,226 @@ Object.assign(CodemanApp.prototype, {
this._updateCheck = null;
},
/**
* Settings → Terminal & Input → Key tester: prints what the browser reports for each key event.
* Read-only and local; it never reaches a session. keypress is shown on purpose: that event is
* why a Shift-only Enter used to submit (xterm drops Ctrl/Alt keypresses, not Shift ones).
*/
keyTesterEvent(ev) {
const log = document.getElementById('keyTesterLog');
if (!log) return;
// Never preventDefault on keydown: that suppresses the keypress this panel exists to show.
// The field is readonly, so nothing is typed into it either way.
const mods = ['ctrlKey', 'shiftKey', 'altKey', 'metaKey'].filter((m) => ev[m]).map((m) => m.replace('Key', ''));
const line =
`${ev.type.padEnd(8)} key=${JSON.stringify(ev.key)} code=${ev.code || '-'} ` +
`mods=${mods.join('+') || 'none'}` +
(ev.type === 'keypress' ? ` charCode=${ev.charCode}` : '') +
(ev.repeat ? ' (repeat)' : '');
const lines = (log.textContent ? log.textContent.split('\n') : []).concat(line);
log.textContent = lines.slice(-14).join('\n');
log.style.display = 'block';
},
/**
* MCP sync is opt-in (`mcpSyncEnabled`): with the flag off the action row is hidden rather than
* shown disabled, because both endpoints would only answer 403. Called on open and from the
* checkbox's own onchange (assigning .checked fires no change event).
*/
applyMcpSyncVisibility() {
const on = document.getElementById('appSettingsMcpSync')?.checked ?? false;
const row = document.getElementById('mcpSyncActionRow');
if (row) row.style.display = on ? '' : 'none';
const out = this.$('mcpSyncResult');
if (!on && out) { out.style.display = 'none'; out.innerHTML = ''; }
this._applyMcpSyncAdminGate();
},
/**
* Both /api/mcp-sync verbs are admin-only in multi-user mode (they write files in the server
* user's home), so a non-admin gets no MCP group at all, switch included, the same way
* _applyCliManagementAdminGate hides the CLI list. Also wired to `codeman:me`, because
* `window.__codemanUser`'s real role can resolve after settings were opened once.
*/
_applyMcpSyncAdminGate() {
const group = document.getElementById('mcpSyncGroup');
if (!group) return;
const me = window.__codemanUser || {};
group.style.display = me.multiUser && me.role !== 'admin' ? 'none' : '';
},
/** Preview (apply=false) or run (apply=true) the MCP server sync across enabled CLIs. */
async mcpSync(apply) {
const out = this.$('mcpSyncResult');
const show = (html) => {
if (out) { out.style.display = 'block'; out.innerHTML = html; }
};
// Switched on in this modal but not saved yet: the routes would only answer "disabled".
if (!this._mcpSyncSavedOn) {
show('Save settings to turn MCP sync on first, then reopen Settings to preview or sync.');
return;
}
if (apply && !confirm('Add missing MCP servers to every installed, enabled CLI\'s config file? Env values and headers on those servers are copied too.')) return;
show('Working…');
const res = apply ? await this._apiPost('/api/mcp-sync', {}) : await this._api('/api/mcp-sync');
let body = null;
try { body = res ? await res.json() : null; } catch { /* fall through */ }
if (!res || !res.ok || !body || body.success === false) {
show(escapeHtml(body?.error || 'MCP sync failed.'));
return;
}
const data = body.data;
const rows = data.targets.map((t) => {
if (t.status === 'absent') return `<li><b>${escapeHtml(t.label)}</b>: not installed, skipped</li>`;
if (t.status === 'skipped') return `<li><b>${escapeHtml(t.label)}</b>: not touched (${escapeHtml(t.error || 'config location unknown')})</li>`;
if (t.status === 'unreadable') return `<li><b>${escapeHtml(t.label)}</b>: not touched, file can't be read safely (${escapeHtml(t.error || 'unreadable')})</li>`;
if (t.status === 'failed') return `<li><b>${escapeHtml(t.label)}</b>: failed (${escapeHtml(t.error || 'error')}); the file may be unchanged</li>`;
const verb = data.applied ? 'added' : 'would add';
const parts = [t.added.length ? `${verb} ${t.added.map(escapeHtml).join(', ')}` : 'up to date'];
if (t.skipped.length) parts.push(`can't express ${t.skipped.map(escapeHtml).join(', ')}`);
const count = `${t.servers.length} server${t.servers.length === 1 ? '' : 's'}`;
return `<li><b>${escapeHtml(t.label)}</b> (${count}): ${parts.join('; ')}</li>`;
});
const conflicts = data.conflicts.length
? `<p>Defined differently across CLIs (each existing definition is kept; the first CLI's is copied where the name is missing): ${data.conflicts.map(escapeHtml).join(', ')}</p>`
: '';
const disabled = data.disabled?.length
? `<p>Switched off in their own CLI, so not copied: ${data.disabled.map(escapeHtml).join(', ')}</p>`
: '';
const unsupported = data.unsupported?.length
? `<p>No MCP config support for: ${data.unsupported.map(escapeHtml).join(', ')}</p>`
: '';
show(`<ul>${rows.join('')}</ul>${conflicts}${disabled}${unsupported}`);
},
/**
* Webhook notifications (Settings → Notifications). Server-side config behind /api/webhook, not a
* settings-payload field: the URL is a secret, so it never round-trips through settings.json or
* this page. The URL box is write-only; the status line shows scheme + host only.
*
* Three ways to save, one PUT: the group's own Save, Send test (saves pending edits first, so it
* never tests the old URL while the box shows a new one), and the modal's main Save, which calls
* saveWebhook() beside the settings PUT the same way it saves the model config
* (saveModelConfigFromSettings). `_webhookLoaded` is what loadWebhook() put on screen, so
* `_webhookPending()` can tell an edited group from an untouched one.
*/
_webhookSay(text, bad = false) {
const out = document.getElementById('webhookResult');
if (!out) return;
out.textContent = text;
out.style.display = text ? 'block' : 'none';
out.style.color = bad ? 'var(--danger, #e5534b)' : '';
},
async loadWebhook() {
const group = document.getElementById('webhookGroup');
if (!group) return;
const res = await this._api('/api/webhook');
if (!res || !res.ok) {
this._webhookLoaded = null;
group.style.display = 'none'; // not an admin in multi-user mode, or the server predates the route
return;
}
let body = null;
try { body = await res.json(); } catch { /* leave hidden */ }
if (!body || body.success === false) { this._webhookLoaded = null; group.style.display = 'none'; return; }
const d = body.data;
group.style.display = '';
document.getElementById('webhookEnabled').checked = d.enabled === true;
document.getElementById('webhookKind').value = d.kind;
document.getElementById('webhookScope').value = d.scope;
const url = document.getElementById('webhookUrl');
url.value = '';
url.placeholder = d.hasUrl ? 'Saved. Paste a new URL to replace it' : 'https://ntfy.sh/your-topic';
document.getElementById('webhookUrlHint').textContent = d.hasUrl ? `Saved: ${d.urlMasked}` : 'Nothing saved yet.';
const clearBtn = document.getElementById('webhookClearBtn');
if (clearBtn) clearBtn.style.display = d.hasUrl ? '' : 'none';
// Read back from the controls, so a value the <select> does not offer compares as what is shown.
this._webhookLoaded = {
enabled: document.getElementById('webhookEnabled').checked,
kind: document.getElementById('webhookKind').value,
scope: document.getElementById('webhookScope').value,
};
if (d.lastResult) {
const when = new Date(d.lastResult.at).toLocaleString();
this._webhookSay(
d.lastResult.ok ? `Last delivery succeeded (${when}).` : `Last delivery failed (${when}): ${d.lastResult.error}`,
!d.lastResult.ok
);
} else {
this._webhookSay('');
}
},
/** True when the visible webhook group differs from what loadWebhook() last showed. */
_webhookPending() {
const group = document.getElementById('webhookGroup');
const loaded = this._webhookLoaded;
if (!group || group.style.display === 'none' || !loaded) return false;
return (
document.getElementById('webhookUrl').value.trim() !== '' ||
document.getElementById('webhookEnabled').checked !== loaded.enabled ||
document.getElementById('webhookKind').value !== loaded.kind ||
document.getElementById('webhookScope').value !== loaded.scope
);
},
/** PUT the group's state. Resolves to '' on success, else the error (also shown in the group). */
async saveWebhook() {
const payload = {
enabled: document.getElementById('webhookEnabled').checked,
kind: document.getElementById('webhookKind').value,
scope: document.getElementById('webhookScope').value,
};
const url = document.getElementById('webhookUrl').value.trim();
if (url) payload.url = url; // blank = keep the saved one (Remove URL is the way to clear it)
const res = await this._api('/api/webhook', { method: 'PUT', body: payload });
let body = null;
try { body = res ? await res.json() : null; } catch { /* fall through */ }
if (!res || !res.ok || !body || body.success === false) {
const error = body?.error || 'Could not save the webhook.';
this._webhookSay(error, true);
return error;
}
await this.loadWebhook();
this._webhookSay('Saved.');
return '';
},
/** Delete the saved URL from the server (the API clears on `url: ""`), which also turns the channel off. */
async clearWebhook() {
if (!confirm('Remove the saved webhook URL from the server? Webhook alerts stop until you save a new one.')) return;
const res = await this._api('/api/webhook', { method: 'PUT', body: { url: '', enabled: false } });
let body = null;
try { body = res ? await res.json() : null; } catch { /* fall through */ }
if (!res || !res.ok || !body || body.success === false) {
this._webhookSay(body?.error || 'Could not remove the webhook URL.', true);
return;
}
await this.loadWebhook();
this._webhookSay('Webhook URL removed.');
},
async testWebhook() {
const btn = document.getElementById('webhookTestBtn');
if (btn) btn.disabled = true;
try {
if (this._webhookPending() && (await this.saveWebhook())) return; // the save's error is already shown
this._webhookSay('Sending…');
const res = await this._apiPost('/api/webhook/test', {});
let body = null;
try { body = res ? await res.json() : null; } catch { /* fall through */ }
if (!res || !res.ok || !body || body.success === false) {
this._webhookSay(body?.error || 'Could not send the test.', true);
return;
}
const r = body.data;
this._webhookSay(r.ok ? 'Test sent. Check your phone or channel.' : `Delivery failed: ${r.error}`, !r.ok);
} finally {
if (btn) btn.disabled = false;
}
},
_setUpdateResult(html) {
const el = this.$('updateResult');
if (el) { el.style.display = 'block'; el.innerHTML = html; }
@@ -2159,6 +2409,7 @@ Object.assign(CodemanApp.prototype, {
approvalsInboxEnabled: document.getElementById('appSettingsApprovalsInbox').checked,
customModelEndpointsEnabled: document.getElementById('appSettingsCustomModelEndpoints').checked,
cliManagementEnabled: document.getElementById('appSettingsCliManagement').checked,
mcpSyncEnabled: document.getElementById('appSettingsMcpSync').checked,
readMyMindEnabled: document.getElementById('appSettingsReadMyMind').checked,
ultracodeFloatingWindows: document.getElementById('appSettingsUltracodeFloatingWindows').checked,
showMultiMonitorButton: document.getElementById('appSettingsShowMultiMonitorButton').checked,
@@ -2214,6 +2465,7 @@ Object.assign(CodemanApp.prototype, {
opusContext1mEnabled: document.getElementById('appSettingsOpusContext1m').checked,
remoteAutoReconnect: document.getElementById('appSettingsRemoteAutoReconnect').checked,
thinkingEffort: document.getElementById('appSettingsThinkingEffort').value,
claudeAdvisorModel: document.getElementById('appSettingsClaudeAdvisor').value,
// CPU Priority settings
nice: {
enabled: document.getElementById('appSettingsNiceEnabled').checked,
@@ -2431,6 +2683,7 @@ Object.assign(CodemanApp.prototype, {
sessionLineageLines: _sll,
...serverSettings
} = settings;
let webhookError = '';
try {
const res = await this._apiPut('/api/settings', {
...serverSettings,
@@ -2455,7 +2708,16 @@ Object.assign(CodemanApp.prototype, {
// Save model configuration separately
await this.saveModelConfigFromSettings();
this.showToast('Settings saved', 'success');
// The webhook is server state in its own 0600 file (its URL is a secret, kept out of
// settings.json), so like the model config above it is saved beside the settings PUT, not in
// it. Only when the group was edited: an untouched group must not re-PUT. A refusal (bad URL,
// enabled with no URL) keeps the modal open below, with the pasted URL still in the box.
webhookError = this._webhookPending() ? await this.saveWebhook() : '';
if (webhookError) {
this.showToast(`Settings saved, but not the webhook: ${webhookError}`, 'warning');
} else {
this.showToast('Settings saved', 'success');
}
// Show tunnel-specific feedback if toggled on
if (settings.tunnelEnabled) {
@@ -2466,7 +2728,11 @@ Object.assign(CodemanApp.prototype, {
this.showToast('Settings saved locally', 'warning');
}
this.closeAppSettings();
if (webhookError) {
document.getElementById('webhookGroup')?.scrollIntoView({ block: 'center' });
} else {
this.closeAppSettings();
}
// Voice availability is a server-side answer, so re-probe after a save:
// otherwise the mic keeps using the pre-save provider until the next reload.
@@ -4106,4 +4372,5 @@ Object.assign(CodemanApp.prototype, {
document.addEventListener?.('codeman:me', () => {
window.app?._applyCustomModelAdminGate?.();
window.app?._applyCliManagementAdminGate?.();
window.app?._applyMcpSyncAdminGate?.();
});
+124 -3
View File
@@ -460,6 +460,31 @@ textarea:focus-visible {
font-size: 16px !important; /* prevent iOS auto-zoom on focus */
}
.xterm-helpers .codeman-ime-preview {
position: absolute;
left: var(--xterm-helper-left, 0px);
top: var(--xterm-helper-top, 0px);
z-index: 6;
pointer-events: none;
white-space: pre;
color: var(--terminal-foreground, var(--text, #fff));
font-family: var(--font-mono, monospace);
font-size: 14px;
font-weight: 400;
font-style: normal;
line-height: 1.2;
height: 1.2em;
}
.xterm-helpers .codeman-ime-preview[data-phase='provisional'] {
text-decoration: underline;
text-decoration-style: dotted;
}
.touch-device .xterm-helpers.codeman-ime-preview-owned .composition-view.active {
display: none;
}
/* Session tab focus */
.session-tab:focus-visible {
outline: 2px solid var(--accent);
@@ -634,6 +659,83 @@ html[data-tab-orientation='vertical'] .tab-rail .session-tab .tab-info {
min-width: 0;
}
/* --- Grouped rail (owner tab layout) ---------------------------------- */
/* Only rendered when the owner has at least one group (tab-layout-browser.js).
Each group's rows sit in their OWN flex column, so the activity sort's inline
`order` (and web tabs' `order: 9999`) rank rows within a group, never across. */
html[data-tab-orientation='vertical'] .tab-rail .tab-layout-group,
html[data-tab-orientation='vertical'] .tab-rail .tab-layout-group-refs {
display: flex;
flex: 0 0 auto;
flex-direction: column;
gap: 2px;
min-width: 0;
}
html[data-tab-orientation='vertical'] .tab-rail .tab-layout-group + .tab-layout-group {
margin-top: 4px;
}
html[data-tab-orientation='vertical'] .tab-rail .tab-layout-group-header {
/* Positioned so a collapsed header can carry the tab alert ring (::before). */
position: relative;
display: flex;
align-items: center;
gap: 6px;
width: 100%;
min-width: 0;
padding: 4px 6px 2px;
border: 0;
border-radius: 4px;
color: var(--text-muted);
background: transparent;
font: inherit;
font-size: 10px;
font-weight: 700;
letter-spacing: 0.08em;
text-align: left;
}
html[data-tab-orientation='vertical'] .tab-rail .tab-layout-group-toggle {
cursor: pointer;
user-select: none;
}
html[data-tab-orientation='vertical'] .tab-rail .tab-layout-group-toggle:hover {
color: var(--text);
background: var(--bg-hover);
}
html[data-tab-orientation='vertical'] .tab-rail .tab-layout-group-toggle:focus-visible {
outline: 2px solid var(--accent);
outline-offset: -2px;
}
.tab-layout-group-name {
flex: 1 1 auto;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.tab-layout-group-count {
flex: 0 0 auto;
font-variant-numeric: tabular-nums;
opacity: 0.75;
}
.tab-layout-group-chevron::before {
content: '\25BE';
display: inline-block;
}
/* Keyed on the section, not aria-expanded: a group with no open rows is a tree
leaf with no aria-expanded at all, and its chevron must still show its state. */
.tab-layout-group--collapsed > .tab-layout-group-toggle .tab-layout-group-chevron::before {
content: '\25B8';
}
html[data-tab-orientation='vertical'] .header-right {
margin-left: auto;
}
@@ -1707,7 +1809,9 @@ html[data-tab-orientation='vertical'] .tab-rail .session-tab .tab-name-prefix {
line-height: 1;
color: var(--text-muted);
overflow: hidden;
cursor: default;
/* A control, like its gear/detach siblings: the pointer must not read as
"nothing here" over the one button that closes the session. */
cursor: pointer;
border-radius: 3px;
/* Performance: use transform for GPU acceleration */
transition: opacity 0.05s ease-out, width 0.05s ease-out, padding 0.05s ease-out;
@@ -1760,7 +1864,8 @@ html[data-tab-orientation='vertical'] .tab-rail .session-tab .tab-name-prefix {
The overlay paints above the tab's inline content (positioned vs flow), which
is fine at these alphas and is exactly what keeps it visible over the active
tab's opaque-ish background. */
.session-tabs .session-tab.tab-alert-action::before {
.session-tabs .session-tab.tab-alert-action::before,
.session-tabs .tab-layout-group-header.tab-alert-action::before {
content: '';
position: absolute;
inset: -2px;
@@ -1785,7 +1890,8 @@ html[data-tab-orientation='vertical'] .tab-rail .session-tab .tab-name-prefix {
box-shadow: 0 0 6px rgba(239, 68, 68, 0.7);
}
.session-tabs .session-tab.tab-alert-idle::before {
.session-tabs .session-tab.tab-alert-idle::before,
.session-tabs .tab-layout-group-header.tab-alert-idle::before {
content: '';
position: absolute;
inset: -2px;
@@ -10510,6 +10616,21 @@ kbd {
drop-shadow(0 0 9px var(--lineage-color, var(--session-blue, #2b8fd9)));
}
/* An arc re-anchored to a collapsed group's header (its row is hidden): quieter
and dotted, so it reads as "somewhere in there" rather than a real row. */
.connection-line.lineage-line--proxied {
stroke-dasharray: 2 7;
opacity: 0.46;
}
.connection-line.lineage-line--proxied.lineage-line--working {
opacity: 0.62;
}
.lineage-line-dot.lineage-line-dot--proxied {
opacity: 0.46;
}
/* The child end marches while that worker is actually working, so the line
itself carries the signal. Motion is opt-out-able at the OS level. */
@media (prefers-reduced-motion: no-preference) {
+354
View File
@@ -0,0 +1,354 @@
/**
* @fileoverview Read-only browser projection of the owner tab layout.
*
* `GET /api/tab-layout` returns the owner's named tab GROUPS (`src/tab-layout.ts`
* is the server model). Browser assets cannot import that TypeScript, so this
* module is a small, dependency-free mirror that owns three things:
*
* 1. Projection: which live sessions and open web tabs land in which group,
* and which rows a collapsed group hides.
* 2. Rendering: the grouped markup for the vertical tab rail. Rows themselves
* are rendered by the caller (app.js, webview-tabs.js), so a grouped row is
* byte-identical to the flat rail's row.
* 3. Load sequencing: concurrent layout reads settle newest-wins, and a failed
* read degrades to the flat rail with a capped, backed-off retry.
*
* The server stays the only authority for layout content. Collapse is a
* per-device view preference and lives in localStorage only.
*
* Grouped rendering is opt-in by construction: a layout with no groups (every
* owner until they create one) projects to `null`, and the caller keeps the flat
* rail exactly as it was.
*
* @dependency none
* @loadorder 5.9 (before app.js, which reads window.CodemanTabLayout)
*/
(function initCodemanTabLayout(global) {
'use strict';
const COLLAPSED_STORAGE_KEY = 'codeman:tab-groups-collapsed';
const refKey = (ref) => `${ref.kind}:${ref.id}`;
const validRef = (ref) =>
!!ref && (ref.kind === 'session' || ref.kind === 'webview') && typeof ref.id === 'string' && ref.id.length > 0;
const asIds = (value) => (Array.isArray(value) ? value.filter((id) => typeof id === 'string' && id) : []);
const stableIds = (value) => [...new Set(asIds(value))];
const copyRefs = (value) =>
Array.isArray(value) ? value.filter(validRef).map((r) => ({ kind: r.kind, id: r.id })) : [];
/**
* Defensive copy of a server layout. Unknown fields are dropped, so a newer
* server adding model fields cannot leak half-understood state into the view.
*/
function normalizeLayout(value) {
if (!value || typeof value !== 'object') throw new Error('Invalid tab layout');
const groups = Array.isArray(value.groups) ? value.groups : [];
return {
version: Number.isSafeInteger(value.version) && value.version >= 0 ? value.version : 0,
groups: groups
.filter((group) => group && typeof group.id === 'string' && group.id.length > 0)
.map((group) => ({
id: group.id,
name: typeof group.name === 'string' ? group.name : '',
refs: copyRefs(group.refs),
})),
ungrouped: copyRefs(value.ungrouped),
};
}
function hasGroups(layout) {
return !!layout && Array.isArray(layout.groups) && layout.groups.length > 0;
}
/** Stored collapse ids, or null when the stored value is not a JSON array. */
function parseCollapsedIds(raw) {
if (raw === null) return [];
try {
const parsed = JSON.parse(raw);
return Array.isArray(parsed) ? stableIds(parsed) : null;
} catch (_error) {
return null;
}
}
/**
* Read the per-device collapse ids. `ok: false` means the STORE failed (a read
* or write threw), and the caller then keeps every group expanded. A malformed
* VALUE is not a store failure: it reads as "nothing collapsed" and is
* rewritten, or a shape left behind by another build (a rollback) would leave
* collapse disabled on this device for good.
*/
function loadCollapsedGroupIds(storage, validGroupIds) {
try {
const parsed = parseCollapsedIds(storage.getItem(COLLAPSED_STORAGE_KEY));
const loaded = parsed || [];
if (validGroupIds === undefined) return { ids: loaded, ok: true };
// Garbage-collect ids of groups that no longer exist, so a deleted group's
// id cannot silently collapse a future group that reuses it.
const valid = new Set(stableIds(validGroupIds));
const kept = loaded.filter((id) => valid.has(id));
if (!parsed || kept.length !== loaded.length) storage.setItem(COLLAPSED_STORAGE_KEY, JSON.stringify(kept));
return { ids: kept, ok: true };
} catch (_error) {
return { ids: [], ok: false };
}
}
function saveCollapsedGroupIds(storage, groupIds) {
const ids = stableIds(groupIds);
try {
storage.setItem(COLLAPSED_STORAGE_KEY, JSON.stringify(ids));
return { ids, ok: true };
} catch (_error) {
return { ids: [], ok: false };
}
}
/**
* Project a layout onto what is live in this browser.
*
* Every live session and open web tab appears exactly once: stored refs keep
* their group and stored order; anything the layout has not caught up with yet
* (a session created a moment ago, a web tab opened on this device only) is
* appended to the ungrouped section in the caller's order. Saved web tabs that
* are not open here are skipped, as are refs to sessions that are gone.
*
* A collapsed group hides its rows, EXCEPT the highlighted one (the active web
* tab, else the active session), so selecting a hidden session by keyboard,
* palette or Alt+N never leaves the user with no visible selection.
*
* @returns {null | { sections, visibleRefs, hiddenTabGroupByRef, sectionByRef }}
* null when the layout has no groups: the caller renders the flat rail
* unchanged. Each section lists the rows it shows (`refs`) and the rows its
* collapse hides (`hidden`); `sectionByRef` maps every placed row
* (`<kind>:<id>`) to its section id (null = Ungrouped), shown or hidden.
*/
function project(layoutInput, options = {}) {
if (!layoutInput) return null;
const layout = normalizeLayout(layoutInput);
if (!hasGroups(layout)) return null;
const liveSessionIds = stableIds(options.liveSessionIds);
const openWebviewIds = stableIds(options.openWebviewIds);
const live = new Set(liveSessionIds);
const open = new Set(openWebviewIds);
const collapsed = new Set(asIds(options.collapsedGroupIds));
const highlighted = options.activeWebviewId
? `webview:${options.activeWebviewId}`
: options.activeSessionId
? `session:${options.activeSessionId}`
: '';
const renderable = (ref) => (ref.kind === 'session' ? live.has(ref.id) : open.has(ref.id));
const placed = new Set();
const visibleRefs = [];
const hiddenTabGroupByRef = {};
const sectionByRef = {};
const sections = [];
const place = (refs, sectionId, isCollapsed) => {
const shown = [];
const hidden = [];
let count = 0;
for (const ref of refs) {
const key = refKey(ref);
if (placed.has(key) || !renderable(ref)) continue;
placed.add(key);
sectionByRef[key] = sectionId;
count++;
const copy = { kind: ref.kind, id: ref.id };
if (isCollapsed && key !== highlighted) {
hiddenTabGroupByRef[key] = sectionId;
hidden.push(copy);
continue;
}
shown.push(copy);
visibleRefs.push(copy);
}
return { shown, hidden, count };
};
for (const group of layout.groups) {
const isCollapsed = collapsed.has(group.id);
const { shown, hidden, count } = place(group.refs, group.id, isCollapsed);
sections.push({ id: group.id, name: group.name, refs: shown, hidden, count, collapsed: isCollapsed });
}
const omissions = [
...liveSessionIds.map((id) => ({ kind: 'session', id })),
...openWebviewIds.map((id) => ({ kind: 'webview', id })),
];
const ungrouped = place([...layout.ungrouped, ...omissions], null, false);
if (ungrouped.count > 0) {
sections.push({
id: null,
name: '',
refs: ungrouped.shown,
hidden: [],
count: ungrouped.count,
collapsed: false,
});
}
return { sections, visibleRefs, hiddenTabGroupByRef, sectionByRef };
}
const ALERT_RANK = { action: 2, idle: 1 };
/**
* The most urgent alert behind each COLLAPSED header: `{ [groupId]: 'action' |
* 'idle' }` over the session rows the collapse hides. A shown row (the kept
* selection, any expanded group) draws its own alert, so it is not counted
* here. `alertOf(sessionId)` is the caller's tab alert lookup.
*/
function hiddenGroupAlerts(projection, alertOf) {
const result = {};
const sections = projection && Array.isArray(projection.sections) ? projection.sections : [];
for (const section of sections) {
if (section.id === null || !Array.isArray(section.hidden)) continue;
let best = null;
for (const ref of section.hidden) {
if (ref.kind !== 'session') continue;
const alert = alertOf(ref.id);
if (ALERT_RANK[alert] && (!best || ALERT_RANK[alert] > ALERT_RANK[best])) best = alert;
}
if (best) result[section.id] = best;
}
return result;
}
/**
* Everything that changes the grouped rail's STRUCTURE (which rows exist and
* where, the headers' names, what a collapse hides), as opposed to a row's own
* status/name/badges. The incremental render path only patches rows in place,
* so a change here forces a full rebuild.
*
* Deliberately NOT the layout version: the server bumps it on every session
* create/close and order PUT, and a bump that moves nothing visible must not
* cost every client a full tab-strip rebuild. `layout` is accepted for
* signature stability only.
*/
function structureKey(_layout, projection, collapsedGroupIds) {
if (!projection) return null;
return JSON.stringify({
collapsed: stableIds(collapsedGroupIds).sort(),
sections: projection.sections.map((section) => [
section.id,
section.name,
section.count,
section.refs.map(refKey),
(section.hidden || []).map(refKey),
]),
});
}
/**
* Grouped rail markup. `renderRef(ref)` returns one row's HTML ('' to skip it);
* `escapeHtml` is the caller's escaper. Group names are user content, so they
* are escaped and marked `data-i18n-skip`.
*
* The caller makes the list itself the `tree` and marks rows up as treeitems
* (app.js `_applyTabTreeSemantics`); this markup supplies the structure:
* - a named group's header is a level-1 `treeitem` carrying `aria-expanded`.
* Its rows are a sibling `group`, so the header OWNS it via `aria-owns`
* (the rows sit below the header visually, not inside it).
* - a COLLAPSED group owns nothing: the one row it still shows (the
* selection) is a level-1 sibling, never the child of a closed node.
* - a group with NO open rows is a leaf: no `aria-expanded`, no owned group,
* so it is not announced as an expanded parent of an empty group.
* - Ungrouped rows are level-1 items. Their "Ungrouped" heading is a visual
* divider only, hidden from assistive tech, and its rows are not a group.
*/
function renderProjection(projection, renderRef, escapeHtml) {
const sections = projection && Array.isArray(projection.sections) ? projection.sections : [];
return sections
.map((section, index) => {
const rows = section.refs.map((ref) => renderRef(ref)).join('');
if (section.id === null) {
return (
'<section class="tab-layout-group tab-layout-ungrouped" role="presentation" data-tab-group-id="">' +
`<div class="tab-layout-group-header tab-layout-ungrouped-header" aria-hidden="true"><span class="tab-layout-group-name">Ungrouped</span><span class="tab-layout-group-count">${section.count}</span></div>` +
`<div class="tab-layout-group-refs" role="presentation">${rows}</div></section>`
);
}
const id = escapeHtml(section.id);
const refsId = `tab-layout-group-refs-${index}`;
const nameId = `tab-layout-group-name-${index}`;
const leaf = section.count === 0;
const expanded = !section.collapsed && !leaf;
const expandedAttr = leaf ? '' : ` aria-expanded="${expanded ? 'true' : 'false'}"`;
return (
`<section class="tab-layout-group${section.collapsed ? ' tab-layout-group--collapsed' : ''}" role="presentation" data-tab-group-id="${id}">` +
`<div class="tab-layout-group-header tab-layout-group-toggle" role="treeitem" tabindex="-1" data-tab-group-header="${id}"${expandedAttr}${expanded ? ` aria-owns="${refsId}"` : ''} onclick="app.toggleTabGroupCollapsed(this.dataset.tabGroupHeader)">` +
'<span class="tab-layout-group-chevron" aria-hidden="true"></span>' +
`<span class="tab-layout-group-name" id="${nameId}" data-i18n-skip>${escapeHtml(section.name)}</span>` +
`<span class="tab-layout-group-count">${section.count}</span></div>` +
`<div class="tab-layout-group-refs" id="${refsId}" ${expanded ? `role="group" aria-labelledby="${nameId}"` : 'role="presentation"'}>${rows}</div></section>`
);
})
.join('');
}
/**
* Newest-wins layout loading. A response that was overtaken by a later load is
* dropped; a failure applies the fallback (the flat rail) and schedules ONE
* retry, replacing any retry already pending.
*
* Retries back off and stop: the delay doubles from `retryDelayMs` up to
* `maxRetryDelayMs`, and after `maxRetries` consecutive failures nothing more
* is scheduled (`scheduleRetry(fn, delayMs)`). The next outside load (an SSE
* reconnect re-runs init, a `tab:layoutChanged` re-reads) tries again, and
* any success resets the count.
*/
function createLoadCoordinator(options) {
const baseDelay = Number.isFinite(options.retryDelayMs) ? options.retryDelayMs : 5000;
const maxDelay = Number.isFinite(options.maxRetryDelayMs) ? options.maxRetryDelayMs : 60000;
const maxRetries = Number.isSafeInteger(options.maxRetries) ? options.maxRetries : 4;
let generation = 0;
let disposed = false;
let retryHandle = null;
let failures = 0;
const clearRetry = () => {
if (retryHandle !== null && options.cancelRetry) options.cancelRetry(retryHandle);
retryHandle = null;
};
const load = async () => {
if (disposed) return false;
const requestGeneration = ++generation;
clearRetry();
try {
const layout = await options.fetchLayout();
if (disposed || requestGeneration !== generation) return false;
failures = 0;
options.applyLayout(layout);
return true;
} catch (_error) {
if (disposed || requestGeneration !== generation) return false;
failures++;
options.applyFallback();
if (failures <= maxRetries) {
const delay = Math.min(maxDelay, baseDelay * 2 ** (failures - 1));
retryHandle = options.scheduleRetry(() => load(), delay);
}
return false;
}
};
return {
load,
dispose() {
disposed = true;
generation++;
clearRetry();
},
};
}
global.CodemanTabLayout = {
normalizeLayout,
hasGroups,
project,
hiddenGroupAlerts,
structureKey,
renderProjection,
createLoadCoordinator,
loadCollapsedGroupIds,
saveCollapsedGroupIds,
};
})(typeof window !== 'undefined' ? window : globalThis);
+82 -34
View File
@@ -77,11 +77,14 @@
this._bufferLoading = false;
this._bufferRefreshPending = false;
// Scroll-to-top history pull (shell panes only), see _maybeLoadMoreHistory().
// `_liveQueue` is non-null exactly while a pull is replaying: live frames
// are held there with their arrival time instead of written under it.
// `_liveQueue` is non-null from the pull's response until its finally
// block: live frames are held there with their arrival time instead of
// written under the replay. `_markerOwed` is the "disconnected" marker a
// load still has to write (see _onSocketClosed()/_stampMarkerIfOwed()).
this._historyPullAt = 0;
this._historyPullUseless = false;
this._liveQueue = null;
this._markerOwed = false;
this._onWheel = null;
}
@@ -169,14 +172,17 @@
// session (this.sessionId), never the primary pane's
// activeSessionId, and has no local-echo overlay of its own to flush
// first (Pane B is deliberately plainer — see the fileoverview).
if (ev.key === 'Enter' && (ev.shiftKey || ev.ctrlKey) && ev.type === 'keydown') {
fetch(`/api/sessions/${this.sessionId}/send-key`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ key: ev.ctrlKey ? 'C-Enter' : 'S-Enter' }),
}).catch(() => {
/* Best-effort, matching this pane's tolerance elsewhere. */
});
// Swallow keypress/keyup too (xterm would send \r for a Shift-only keypress); only keydown sends.
if (ev.key === 'Enter' && (ev.shiftKey || ev.ctrlKey)) {
if (ev.type === 'keydown') {
fetch(`/api/sessions/${this.sessionId}/send-key`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ key: ev.ctrlKey ? 'C-Enter' : 'S-Enter' }),
}).catch(() => {
/* Best-effort, matching this pane's tolerance elsewhere. */
});
}
return false;
}
// Smart copy (mirrors terminal-ui.js's Ctrl+C gate, #211): with a
@@ -301,18 +307,37 @@
}
// The socket's close, split out of connect() so the tests can drive it.
// While a history pull is running the marker waits for the pull's finally
// block: written now, it would sit above the output the pull is still
// holding (flushed after it on a skip, a downgrade or a failed fetch) or
// land in the middle of a chunked replay.
// While any load runs (a history pull or a `{t:'r'}` refresh) the marker is
// only owed, and that load's finally block settles it (_stampMarkerIfOwed()):
// written now, it would sit above the output a pull is still holding (flushed
// after it on a skip, a downgrade or a failed fetch), above a refresh's
// replay, or in the middle of a chunked replay. A pull still waiting for its
// response holds the marker too, for as long as the request takes (up to its
// budget, see _pullHistory()).
_onSocketClosed() {
this._wsReady = false;
this._wsClosed = true;
if (!this._liveQueue) this._writeDisconnectedMarker();
if (this._bufferLoading) this._markerOwed = true;
else this._writeDisconnectedMarker();
}
// Extracted so both _onSocketClosed() and a history pull that ends on a
// closed socket can write it (see _pullHistory()'s finally block).
// Settles a marker the pane owes: set when a close lands during a load (the
// replay would otherwise sit below it) or when a load wipes the terminal on
// a closed socket. Called from each load's own finally, just before
// _endBufferLoad() starts any trailing refresh.
_stampMarkerIfOwed() {
// A trailing refresh is about to clear() synchronously, while xterm parses
// a write() on a later tick: a marker written here would land in the
// freshly cleared buffer ABOVE that refresh's replay, a second, stale copy.
// The refresh re-owes the marker on a closed socket and stamps it itself.
if (this._bufferRefreshPending && !this._destroyed) return;
const owed = this._markerOwed;
this._markerOwed = false;
if (owed && this._wsClosed && !this._destroyed) this._writeDisconnectedMarker();
}
// Extracted so both _onSocketClosed() and a load that ends owing it on a
// closed socket can write it (see _stampMarkerIfOwed()).
_writeDisconnectedMarker() {
this.terminal?.write('\r\n\x1b[2m[Pane B disconnected — close and reopen the split to reconnect]\x1b[0m\r\n');
}
@@ -351,6 +376,7 @@
} catch {
/* Best-effort — live output still arrives once the socket connects. */
} finally {
this._stampMarkerIfOwed();
this._endBufferLoad();
}
}
@@ -430,27 +456,44 @@
// main thread) and replays it under the reader's current place. Holds the
// single-flight flag across the fetch AND the replay, like _loadBuffer().
async _pullHistory() {
// A close before the pull already wrote its marker; one during it did not.
const closedBefore = this._wsClosed;
this._bufferLoading = true;
this._liveQueue = [];
let replayed = false;
let capturedAt = 0;
// Two budgets on one signal. The request itself gets the primary pane's
// (CodemanFetchDeadline, constants.js): live output is not held while it
// runs, but the single-flight flag is, so a coalesced `{t:'r'}` refresh and
// the marker owed by a close (_onSocketClosed()) both wait for it, at worst
// for that whole budget. Once the headers land live output IS held, so the
// body read gets the short one instead: a body that hangs would otherwise
// freeze the pane for the long budget. Aborting lands in the catch below,
// which releases the flag and the queue. AbortSignal.timeout() alone cannot
// be re-armed, hence the controller; without AbortController the pull
// simply has no deadline.
const controller = global.AbortController ? new global.AbortController() : null;
let abortTimer = null;
const armDeadline = (ms) => {
if (!controller) return;
clearTimeout(abortTimer);
abortTimer = setTimeout(() => controller.abort(), ms);
};
try {
// A deadline, because live output is held for as long as this runs: a
// request that hangs would otherwise freeze the whole pane. Aborting
// lands in the catch below, which releases the flag and the queue. It
// covers the body read too, not just the headers.
armDeadline(global.CodemanFetchDeadline?.terminalFetchDeadlineMs?.({ full: true }) ?? HISTORY_PULL_TIMEOUT_MS);
const res = await fetch(`/api/sessions/${this.sessionId}/terminal?full=1&tail=${TERMINAL_TAIL_SIZE}`, {
signal: global.AbortSignal?.timeout?.(HISTORY_PULL_TIMEOUT_MS),
signal: controller?.signal,
});
armDeadline(HISTORY_PULL_TIMEOUT_MS);
// The cutoff below is the response's arrival, the same `since` rule the
// primary pane uses (_finishBufferLoad). It is a client clock standing in
// for the instant tmux took the capture, which lies somewhere in the
// round trip, so a frame in that window can be lost or doubled. Bounded
// by one round trip and not closable without a server-side capture time.
capturedAt = performance.now();
// Opened only now: a frame from before the response is either replaced by
// the capture or written unchanged, so holding it for the round trip
// bought nothing and froze the pane for as long as the fetch took.
this._liveQueue = [];
const payload = (await res.json())?.data;
clearTimeout(abortTimer);
const buffer = payload?.terminalBuffer;
const term = this.terminal;
if (!buffer || !term || this._destroyed) return;
@@ -476,6 +519,7 @@
this._historyPullUseless = false;
term.write('\x1bc');
replayed = true;
if (this._wsClosed) this._markerOwed = true;
await writeChunked(term, buffer, () => this._destroyed);
if (this._destroyed || !this.terminal) return;
// xterm parses asynchronously: an empty write's callback fires only
@@ -490,6 +534,7 @@
} catch {
/* Best-effort — live output keeps arriving whatever happens here. */
} finally {
clearTimeout(abortTimer);
const queued = this._liveQueue ?? [];
this._liveQueue = null;
// After a replay, only frames that arrived after the capture are news;
@@ -500,15 +545,14 @@
if (entry.clear) this.terminal?.clear();
else this.terminal?.write(entry.data);
}
// A replay's own `\x1bc` wipes a marker written before the pull,
// painting a fresh, current-looking history while onData keeps
// silently dropping every keystroke on the dead socket, so re-stamp it
// after a replay. A close DURING the pull wrote no marker at all
// (_onSocketClosed() defers it while the queue is live), so write it
// whether or not this pull replayed. Checked after the queue flush so
// it is the last thing on screen, matching what the close would have
// left had the pull never run.
if (this._wsClosed && (replayed || !closedBefore)) this._writeDisconnectedMarker();
// Settled after the queue flush so the marker is the last thing on
// screen: a close during the pull wrote nothing (_onSocketClosed() defers
// it while a load runs), and a replay's own `\x1bc` (flagged above) wipes
// one written before it, which would paint a fresh, current-looking
// history while onData keeps silently dropping every keystroke on the
// dead socket. With a trailing refresh pending (_endBufferLoad) the marker
// is left to that refresh, which writes it below its own replay.
this._stampMarkerIfOwed();
this._endBufferLoad();
}
}
@@ -525,6 +569,10 @@
return;
}
this.terminal?.clear();
// The clear wipes a "disconnected" marker (a `{t:'r'}` frame can queue a
// trailing refresh behind a pull that the socket's close then interrupts),
// so a refresh on a closed socket owes it back once its replay is written.
if (this._wsClosed) this._markerOwed = true;
void this._loadBuffer();
}
+274 -8
View File
@@ -55,6 +55,9 @@
// (_installMobileKeyboardDismiss). Two groups: anything that is about to take
// focus itself, and the accessory bar, which is built to be used while the
// keyboard is open.
// ⚠️ A roving-tabindex widget parks every item but one at tabindex=-1, so the
// `[tabindex]` arm cannot see its items: the grouped tab rail's rows and
// headers are listed by role instead, or tapping one would drop the keyboard.
const MOBILE_KEYBOARD_DISMISS_EXEMPT_SELECTOR = [
'input',
'textarea',
@@ -64,6 +67,7 @@
'[contenteditable=""]',
'[contenteditable="true"]',
'[tabindex]:not([tabindex="-1"])',
'[role="treeitem"]',
'.keyboard-accessory-bar',
'.path-picker-overlay',
].join(',');
@@ -250,6 +254,235 @@ Object.assign(CodemanApp.prototype, {
this._keyCode229Recovery = null;
},
_destroyMobileImePreview() {
try {
this._mobileImePreview?.destroy?.();
} catch {
// The preview is visual-only; terminal replacement must continue.
}
this._mobileImePreview = null;
this._mobileImePreviewSessionId = null;
this._mobileImeCommitOutputSeq = null;
try {
this._mobileImePreviewNode?.remove?.();
} catch {
// Best-effort node cleanup only.
}
try {
this._mobileImePreviewHelpers?.classList?.remove('codeman-ime-preview-owned');
} catch {
// Best-effort ownership cleanup only.
}
this._mobileImePreviewNode = null;
this._mobileImePreviewHelpers = null;
try {
if (this._mobileImePreviewOfflineHandler) {
window.removeEventListener('offline', this._mobileImePreviewOfflineHandler);
}
if (this._mobileImePreviewPagehideHandler) {
window.removeEventListener('pagehide', this._mobileImePreviewPagehideHandler);
}
} catch {
// Best-effort listener cleanup only.
}
this._mobileImePreviewOfflineHandler = null;
this._mobileImePreviewPagehideHandler = null;
},
/**
* iOS Safari IME preview (mobile-ime-preview.js). WebKit does not show the
* text an IME is composing inside the terminal, so the user types blind.
*
* Two homes, chosen per render:
* - Local echo on: typed text sits in the LocalEchoOverlay and the PTY
* cursor stays at the prompt start, under the overlay's opaque text (z 7,
* `.xterm-screen`). So the overlay draws the composition itself, as an
* underlined tail after its pending text (`setComposition`).
* - Otherwise (a shell, or the overlay could not place it): a span inside
* `.xterm-helpers`, positioned by the same --xterm-helper-left/top vars as
* the helper textarea, which follow the PTY cursor.
*
* Visual only: nothing here touches the input path, and every failure
* leaves no DOM behind.
*/
_initMobileImePreview() {
this._destroyMobileImePreview();
let preview = null;
let helpers = null;
try {
if (typeof MobileImePreview === 'undefined' || !MobileImePreview?.isIosWebKitTouch?.()) return;
const textarea = this.terminal?.textarea;
helpers = this.terminal?.element?.querySelector?.('.xterm-helpers');
if (!textarea || !helpers) return;
preview = document.createElement('span');
this._mobileImePreviewNode = preview;
this._mobileImePreviewHelpers = helpers;
preview.className = 'codeman-ime-preview';
preview.setAttribute('aria-hidden', 'true');
preview.hidden = true;
helpers.appendChild(preview);
const syncPreviewTypography = () => {
try {
const compositionView =
helpers.querySelector?.('.composition-view') || this.terminal?.element?.querySelector?.('.composition-view');
if (!compositionView || !preview.style) return;
const style = typeof getComputedStyle === 'function' ? getComputedStyle(compositionView) : compositionView.style;
for (const property of ['fontFamily', 'fontSize', 'fontWeight', 'fontStyle', 'lineHeight', 'height']) {
const value = style?.[property] || compositionView.style?.[property];
if (value) preview.style[property] = value;
}
const theme = this.terminal?.options?.theme;
let foreground = theme?.foreground;
let background = theme?.background;
if (!foreground || !background) {
try {
const current = window.codemanCurrentXtermTheme?.();
foreground = foreground || current?.foreground;
background = background || current?.background;
} catch {
// Theme lookup is best-effort; retain the safe terminal fallback.
}
}
preview.style.color = foreground || '#e0e0e0';
// Opaque, like xterm's own composition view, so the preview does not
// overprint whatever sits at the cursor (a dim composer placeholder).
preview.style.backgroundColor = background || '#0d0d0d';
} catch {
// Typography matching is visual-only and must not block input.
}
};
// The overlay only when it is what shows typed text right now (local echo
// on, and not handed back to plain PTY echo by a composer nav key).
const localEchoOverlay = () =>
this._localEchoEnabled && !this._echoPassthroughSessions?.has(this.activeSessionId)
? this._localEchoOverlay || null
: null;
const clearOverlayComposition = () => {
try {
if (this._localEchoOverlay?.composition) this._localEchoOverlay.setComposition('');
} catch {}
};
const hideSpan = () => {
try {
preview.hidden = true;
} catch {}
try {
preview.textContent = '';
} catch {}
try {
delete preview.dataset.phase;
} catch {}
try {
helpers.classList.remove('codeman-ime-preview-owned');
} catch {}
};
const clearPreview = () => {
clearOverlayComposition();
hideSpan();
};
const controller = MobileImePreview.create({
textarea,
// An ancestor of the textarea: its capture-phase keydown listener runs
// before xterm's capture listener on the textarea, which finalizes the
// composition and emits the commit synchronously.
keydownTarget: this.terminal.element,
render: ({ text, phase }) => {
try {
const overlay = localEchoOverlay();
if (overlay && typeof overlay.setComposition === 'function') {
overlay.setComposition(text);
// No prompt found = nothing drawn: fall back to the span.
if (!text || overlay.state?.visible) {
hideSpan();
helpers.classList.toggle('codeman-ime-preview-owned', !!text);
return;
}
overlay.setComposition('');
} else {
clearOverlayComposition();
}
syncPreviewTypography();
preview.textContent = text;
preview.dataset.phase = phase;
preview.hidden = !text;
helpers.classList.toggle('codeman-ime-preview-owned', !!text);
} catch {
clearPreview();
}
},
clear: clearPreview,
});
this._mobileImePreview = controller;
this._mobileImePreviewSessionId = this.activeSessionId;
// Offline and pagehide only reset: initTerminal() runs once per page
// load, so destroying on pagehide would leave the preview off for good
// after a back-forward cache restore (iOS Safari keeps pages there).
this._mobileImePreviewOfflineHandler = () => {
try {
this._mobileImePreview?.reset?.();
} catch {
// Disconnect cleanup is visual-only.
}
};
this._mobileImePreviewPagehideHandler = this._mobileImePreviewOfflineHandler;
window.addEventListener('offline', this._mobileImePreviewOfflineHandler);
window.addEventListener('pagehide', this._mobileImePreviewPagehideHandler);
} catch {
this._destroyMobileImePreview();
}
},
/**
* Tell the IME preview about a chunk xterm emitted through onData. Returns
* true when the chunk is the IME's committed text, in which case the preview
* holds it (phase 'committed') until something else shows it. Never throws.
*/
_consumeMobileImeTerminalData(data) {
let isImeCommit = false;
try {
isImeCommit = this._mobileImePreview?.consumeTerminalData?.(data) === true;
} catch {
// The preview is visual-only; normal terminal input must continue.
}
// Output accepted from here on can carry the echo of this commit.
if (isImeCommit) this._mobileImeCommitOutputSeq = this._terminalOutputSeq || 0;
return isImeCommit;
},
/**
* Clear a committed IME preview once terminal output accepted AFTER the
* commit has been parsed. `flushedOutputSeq` is the output sequence a fully
* written flush covered (null when part of it was deferred), so output that
* was already queued before the commit can never clear it early.
*/
_noteMobileImeAuthoritativeOutput(flushedOutputSeq, sessionId) {
try {
const commitSeq = this._mobileImeCommitOutputSeq;
if (commitSeq === null || commitSeq === undefined || flushedOutputSeq === null) return;
if (sessionId !== this.activeSessionId || !(flushedOutputSeq > commitSeq)) return;
this._mobileImeCommitOutputSeq = null;
this._mobileImePreview?.noteAuthoritativeOutput?.();
} catch {
// Authoritative output is never delayed or consumed by the preview.
}
},
/**
* The local echo overlay has just taken a committed IME chunk through the
* ordinary printable/paste branch, so it now shows the text: release the
* preview instead of waiting for terminal output.
*/
_transferMobileImeCommitToLocalEcho() {
this._mobileImeCommitOutputSeq = null;
try {
this._mobileImePreview?.completeCommit?.({ predicted: true });
} catch {
// Ownership transfer is visual-only.
}
},
initTerminal() {
// Load scrollback setting from localStorage, treating DEFAULT_SCROLLBACK as a floor
// so users who picked up the previous (smaller) default get the new minimum on upgrade.
@@ -307,6 +540,7 @@ Object.assign(CodemanApp.prototype, {
const container = document.getElementById('terminalContainer');
this.terminal.open(container);
this._initMobileImePreview();
this._installMobileTapMouseGuard();
this._installShiftDragSelection();
this._installTouchSelectionFocusGuard();
@@ -447,8 +681,11 @@ Object.assign(CodemanApp.prototype, {
// xterm.js sends plain \r for all Enter variants, so Claude Code (Ink) can't
// distinguish them. We use tmux send-keys -H to send a line feed byte (0x0a)
// which the inner application recognizes as "insert newline" vs carriage return.
if (ev.key === 'Enter' && (ev.shiftKey || ev.ctrlKey) && ev.type === 'keydown') {
if (this.activeSessionId) {
// This handler also runs for keypress/keyup: xterm drops a keypress carrying Ctrl/Alt
// but NOT one carrying only Shift, so unless every event type is swallowed here,
// Shift+Enter's keypress sends a bare \r (submit) after the newline. Only keydown sends.
if (ev.key === 'Enter' && (ev.shiftKey || ev.ctrlKey)) {
if (ev.type === 'keydown' && this.activeSessionId) {
if (this._localEchoEnabled) {
const text = this._localEchoOverlay?.pendingText || '';
this._localEchoOverlay?.clear();
@@ -1170,9 +1407,10 @@ Object.assign(CodemanApp.prototype, {
// has to re-resolve their gate before the redraw, not just move them.
this.applyLineageLineSettings?.();
this.updateConnectionLines();
if (this._localEchoOverlay?.hasPending) {
this._localEchoOverlay.rerender();
}
// Unguarded on purpose: hasPending excludes an IME composition, so a
// composition-only overlay would stay on the old prompt row. rerender()
// is a no-op when the overlay has nothing to draw.
this._localEchoOverlay?.rerender();
// Pane B (split view) has its own container and its own fit()/resize
// frame — this observer only ever measured Pane A's container, so
// without this call Pane B never learned about a window resize, an
@@ -1212,6 +1450,8 @@ Object.assign(CodemanApp.prototype, {
// survives tab switches and reconnects.
const handleTerminalData = (data) => {
// Before anything can rewrite `data`: is this chunk the IME's commit?
const isImeCommit = this._consumeMobileImeTerminalData(data);
// Mouse SGR reports (tap-to-position) are NOT IME input — they must reach
// the PTY even while the CJK input field owns focus. Without this exception
// tapping to move the cursor silently does nothing whenever Chinese input
@@ -1289,6 +1529,8 @@ Object.assign(CodemanApp.prototype, {
// When enabled, keystrokes are buffered locally in the overlay for
// instant visual feedback. Nothing is sent to the PTY until Enter
// (or a control char) is pressed — avoids out-of-order char delivery.
// An IME commit takes the same printable/paste branch as typed text,
// and the overlay then shows it in place of the preview.
if (this._localEchoEnabled && !echoPassthrough) {
if (data === '\x7f') {
const source = this._localEchoOverlay?.removeChar();
@@ -1345,6 +1587,7 @@ Object.assign(CodemanApp.prototype, {
if (data.length > 1 && data.charCodeAt(0) >= 32) {
// Paste: append to overlay only (sent on Enter)
this._localEchoOverlay?.appendText(data);
if (isImeCommit && this._localEchoOverlay) this._transferMobileImeCommitToLocalEcho();
return;
}
if (data.charCodeAt(0) < 32) {
@@ -1490,6 +1733,7 @@ Object.assign(CodemanApp.prototype, {
if (data.length === 1 && data.charCodeAt(0) >= 32) {
// Printable char: add to overlay only (sent on Enter)
this._localEchoOverlay?.addChar(data);
if (isImeCommit && this._localEchoOverlay) this._transferMobileImeCommitToLocalEcho();
return;
}
}
@@ -3155,6 +3399,7 @@ Object.assign(CodemanApp.prototype, {
const globalSettings = this.loadAppSettingsFromStorage();
const envOverrides = this.buildEnvOverrides(this.getCaseSettings(caseName), globalSettings);
const effort = this.getEffortSetting(globalSettings);
const advisorModel = this.getAdvisorSetting(globalSettings);
// `resumeSessionId` is a Claude conversation UUID (server reads it from
// ~/.claude/projects); an external-CLI row has no such thing, so sending
// it there gets silently ignored while the OMITTED `mode` field defaults
@@ -3204,6 +3449,8 @@ Object.assign(CodemanApp.prototype, {
...modeConfig,
...(Object.keys(envOverrides).length > 0 ? { envOverrides } : {}),
...(effort ? { effort } : {}),
// The advisor is a claude-only feature; other CLIs would carry it inertly.
...(advisorModel && effectiveMode === 'claude' ? { advisorModel } : {}),
}),
});
const createData = await createRes.json();
@@ -3522,6 +3769,9 @@ Object.assign(CodemanApp.prototype, {
},
batchTerminalWrite(data) {
// Arrival order of output, so the IME preview can tell output that
// followed a commit from output that was already queued before it.
this._terminalOutputSeq = (this._terminalOutputSeq || 0) + 1;
// Feed the renderer watchdog. Recorded before the buffer-load early return
// below: a write that is queued rather than written still means the pipeline
// owes us a frame once it drains.
@@ -3585,6 +3835,7 @@ Object.assign(CodemanApp.prototype, {
// Accumulate raw data (may contain DEC 2026 markers)
this.pendingWrites.push(data);
this._pendingWritesOutputSeq = this._terminalOutputSeq;
this._scheduleTerminalWriteFlush();
},
@@ -3616,6 +3867,7 @@ Object.assign(CodemanApp.prototype, {
// Transfer buffered data to normal pending writes
this.pendingWrites.push(this.flickerFilterBuffer);
this._pendingWritesOutputSeq = this._terminalOutputSeq;
this.flickerFilterBuffer = '';
this.flickerFilterActive = false;
@@ -3646,6 +3898,15 @@ Object.assign(CodemanApp.prototype, {
* Position is tracked dynamically by _findPrompt() on every render.
*/
_updateLocalEchoState() {
if (this._mobileImePreviewSessionId !== this.activeSessionId) {
this._mobileImePreviewSessionId = this.activeSessionId;
this._mobileImeCommitOutputSeq = null;
try {
this._mobileImePreview?.reset?.();
} catch {
// The preview is visual-only; session switching must continue.
}
}
const settings = this.loadAppSettingsFromStorage();
const session = this.activeSessionId ? this.sessions.get(this.activeSessionId) : null;
const echoEnabled = settings.localEchoEnabled ?? MobileDetection.isTouchDevice();
@@ -3851,6 +4112,9 @@ Object.assign(CodemanApp.prototype, {
this.pendingWrites.push(joined.slice(MAX_FRAME_BYTES));
deferred = true;
}
// Newest output this chunk fully contains, for the IME preview. A split
// chunk may not hold that output yet, so it reports nothing.
const flushedOutputSeq = deferred ? null : (this._pendingWritesOutputSeq ?? null);
this._terminalWriteInFlight = true;
this._terminalWriteInFlightBytes = writeChunk.length;
try {
@@ -3868,6 +4132,7 @@ Object.assign(CodemanApp.prototype, {
// because the test's write mock moved the viewport synchronously.)
this._restoreTerminalViewport(preserveViewportY, flushSessionId);
this._scheduleTerminalWriteFlush();
this._noteMobileImeAuthoritativeOutput(flushedOutputSeq, flushSessionId);
});
} catch (err) {
this._terminalWriteInFlight = false;
@@ -3897,9 +4162,10 @@ Object.assign(CodemanApp.prototype, {
// Re-position local echo overlay after terminal writes — Ink redraws can
// move the ❯ prompt to a different row, making the overlay invisible.
if (this._localEchoOverlay?.hasPending) {
this._localEchoOverlay.rerender();
}
// Unguarded on purpose: hasPending excludes an IME composition, so a
// composition-only overlay (the first word of a prompt) would otherwise
// stay on the old row. rerender() is a no-op when there is nothing to draw.
this._localEchoOverlay?.rerender();
// After Tab completion: detect the completed text in the overlay.
// Use terminal.write('', callback) to defer detection until xterm.js
+21 -10
View File
@@ -304,13 +304,26 @@ Object.assign(CodemanApp.prototype, {
let idx = startIndex;
for (const id of this.webviewOrder) {
const webview = this.webviews.get(id);
if (!webview) continue;
const isActive = id === this.activeWebviewId;
const jsonId = escapeHtml(JSON.stringify(id));
const icon = webview.icon ? escapeHtml(webview.icon) : '';
if (!this.webviews.get(id)) continue;
parts.push(this.renderWebviewTab(id, idx));
idx++;
}
return parts.join('');
},
parts.push(`<div class="session-tab session-tab--web ${isActive ? 'active' : ''}" data-webview-id="${escapeHtml(id)}"
/**
* One web tab's HTML; `idx` is its zero-based Alt+N slot (no badge from 9 up).
* The grouped vertical rail places single web tabs into their group with this,
* so a web tab's markup is the same in every layout.
*/
renderWebviewTab(id, idx) {
const webview = this.webviews.get(id);
if (!webview) return '';
const isActive = id === this.activeWebviewId;
const jsonId = escapeHtml(JSON.stringify(id));
const icon = webview.icon ? escapeHtml(webview.icon) : '';
return `<div class="session-tab session-tab--web ${isActive ? 'active' : ''}" data-webview-id="${escapeHtml(id)}"
onclick="app.handleWebviewTabClick(event, ${jsonId})"
tabindex="0" role="tab" aria-selected="${isActive ? 'true' : 'false'}"
aria-label="${escapeHtml(webview.name)} web tab" title="${escapeHtml(webview.url)}">
@@ -322,10 +335,7 @@ Object.assign(CodemanApp.prototype, {
</span>
</span>
<span class="tab-actions"><span class="tab-gear" onclick="event.stopPropagation(); app.showWebviewModal(${jsonId})" title="URL settings" aria-label="URL settings" tabindex="0">&#x2699;</span><span class="tab-close" onclick="event.stopPropagation(); app.closeWebviewTab(${jsonId})" title="Close tab" aria-label="Close web tab" tabindex="0">&times;</span></span>
</div>`);
idx++;
}
return parts.join('');
</div>`;
},
_webviewGlobeIcon() {
@@ -348,6 +358,7 @@ Object.assign(CodemanApp.prototype, {
// A web tab is active, so no session tab may also look active.
for (const tab of container.querySelectorAll('.session-tab[data-id]')) tab.classList.remove('active');
}
this._syncTabTreeSelection?.(container);
},
// ── Opening / closing ─────────────────────────────────────────────────────
+2 -1
View File
@@ -18,7 +18,8 @@
* session record involved. A dropped plan therefore returns the user to
* resuming by hand, one at a time, which is where they are without this
* feature. What the plan held that a transcript does not is the owner, the
* name, the env overrides, the effort and the lineage.
* name, the env overrides, the effort, the model, the advisor model and the
* lineage.
* - Module-level singleton in the style of `web/approval-inbox.ts`: no `Session`
* import and no IO, which keeps it unit-testable and cycle-free.
* - Spending is take-then-build: `take()` removes entries synchronously, before
+2
View File
@@ -28,6 +28,8 @@ export { registerWsRoutes } from './ws-routes.js';
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 { registerWebhookRoutes } from './webhook-routes.js';
export {
registerCustomModelRoutes,
refreshAllCustomModelHosts,
+97
View File
@@ -0,0 +1,97 @@
/**
* @fileoverview MCP server sync (src/mcp-sync.ts).
*
* GET /api/mcp-sync — dry run: per participating CLI, which servers it has and which it would gain.
* POST /api/mcp-sync — apply: add the missing servers to each CLI's own config file.
*
* Opt-in: both verbs answer 403 until `mcpSyncEnabled` is on (default OFF), because this writes
* OTHER tools' own user config. Writes files in the SERVER user's home, so in multi-user mode it
* is admin only. A second apply while one is running answers 409. Responses carry server names
* only, never env values, headers or file content (a parse failure is reported by position).
*
* A CLI takes part when it is ENABLED in the registry, declares an `mcpConfig`, and is installed
* or already has its config file; one that is enabled but absent from the machine is reported
* `absent` and never created. Its file is located with this process's env (the env the CLIs
* Codeman spawns inherit), so a relocation var such as `CODEX_HOME` is followed.
*/
import type { FastifyInstance, FastifyReply, FastifyRequest } from 'fastify';
import {
ApiErrorCode,
createErrorResponse,
getErrorMessage,
type ApiResponse,
type McpSyncResult,
} from '../../types.js';
import { isAdmin, readJsonConfig, SETTINGS_PATH } from '../route-helpers.js';
import { isMultiUserMode } from '../../config/multiuser.js';
import { enabledClis } from '../../config/cli-registry/registry.js';
import { isCliEntryInstalled, probeStockCliAvailability } from '../../utils/cli-installed-probes.js';
import { McpSyncBusyError, syncMcpServers, type McpSyncTarget } from '../../mcp-sync.js';
/** Default OFF, same shape as `readCliManagementEnabled`: read fresh so a toggle applies at once. */
export async function readMcpSyncEnabled(): Promise<boolean> {
const settings = await readJsonConfig<Record<string, unknown>>(SETTINGS_PATH, 'settings.json', {});
return settings.mcpSyncEnabled === true;
}
/** Enabled CLIs that declare an MCP config file, in registry order (first definition wins). */
export function mcpSyncTargets(availability: Record<string, boolean>): McpSyncTarget[] {
return enabledClis()
.filter((e) => e.capabilities.mcpConfig)
.sort((a, b) => a.order - b.order)
.map((e) => ({
id: e.id,
label: e.label,
...e.capabilities.mcpConfig!,
installed: isCliEntryInstalled(e, availability),
}));
}
/**
* Installed, enabled agent CLIs with no known MCP config file (sync cannot touch them). One that
* is not installed is left out, the same way a supported one that is not installed reads `absent`.
*/
export function mcpUnsupportedLabels(availability: Record<string, boolean>): string[] {
return enabledClis()
.filter((e) => e.kind === 'agent' && !e.capabilities.mcpConfig && isCliEntryInstalled(e, availability))
.map((e) => e.label);
}
async function gate(req: FastifyRequest): Promise<ApiResponse<never> | null> {
if (isMultiUserMode() && !isAdmin(req)) {
return createErrorResponse(ApiErrorCode.FORBIDDEN, 'Admin only in multi-user mode');
}
if (!(await readMcpSyncEnabled())) {
return createErrorResponse(
ApiErrorCode.FORBIDDEN,
'MCP sync is disabled. Turn on "Enable MCP server sync" in Settings and save first.'
);
}
return null;
}
export function registerMcpSyncRoutes(app: FastifyInstance): void {
const run = async (req: FastifyRequest, reply: FastifyReply, apply: boolean): Promise<ApiResponse<McpSyncResult>> => {
const denied = await gate(req);
if (denied) {
reply.code(403);
return denied;
}
try {
const availability = await probeStockCliAvailability();
const targets = mcpSyncTargets(availability);
const data = await syncMcpServers(targets, { apply, env: process.env }, mcpUnsupportedLabels(availability));
return { success: true, data };
} catch (err) {
if (err instanceof McpSyncBusyError) {
reply.code(409);
return createErrorResponse(ApiErrorCode.CONFLICT, err.message);
}
reply.code(500);
return createErrorResponse(ApiErrorCode.OPERATION_FAILED, getErrorMessage(err));
}
};
app.get('/api/mcp-sync', (req, reply) => run(req, reply, false));
app.post('/api/mcp-sync', (req, reply) => run(req, reply, true));
}
+2
View File
@@ -293,6 +293,7 @@ export function registerRalphRoutes(
planItems,
envOverrides,
effort,
advisorModel,
} = parseBody(RalphLoopStartSchema, req.body);
// Multi-user: cases live in the requesting user's space.
@@ -343,6 +344,7 @@ export function registerRalphRoutes(
allowedTools: rlClaudeModeConfig.allowedTools,
envOverrides,
effort,
advisorModel,
owner: rlOwner,
});
+2
View File
@@ -195,6 +195,8 @@ export function registerRebootRestoreRoutes(app: FastifyInstance, ctx: RebootRes
(saved as { __envOverrides?: Record<string, string> }).__envOverrides
),
effort: saved.effort,
model: saved.model,
advisorModel: saved.advisorModel,
attachmentHistory:
(saved as { __attachmentHistory?: SessionAttachmentHistoryItem[] }).__attachmentHistory ??
saved.attachmentHistory,
+55 -16
View File
@@ -30,7 +30,13 @@ import {
type OmpConfig,
type RemoteHost,
} from '../../types.js';
import { Session, isAltScreenStripMode, isExternalCliMode, isMuxAltScreenOnlyStripMode } from '../../session.js';
import {
Session,
cliTakesSessionModel,
isAltScreenStripMode,
isExternalCliMode,
isMuxAltScreenOnlyStripMode,
} from '../../session.js';
import type { PaneCaptureOptions } from '../../mux-interface.js';
import { SseEvent } from '../sse-events.js';
import { webviewCapabilities } from '../../webview-capabilities.js';
@@ -107,6 +113,7 @@ import { buildAgentCaseMarker, writeAgentCaseMarker } from '../../agent-case-mar
import { canUsernameRunPrivilegedCommands, resolveClaudeModeForUsername } from '../../user-store.js';
import { clampEnvOverridesForOwner } from '../../session-env-clamp.js';
import { enabledClis, getCli } from '../../config/cli-registry/registry.js';
import type { NewlineSequence } from '../../config/cli-registry/types.js';
import { resolveCliLaunchError } from '../../utils/cli-launcher.js';
import { legacyConfigForMode } from '../../session-cli-registry-bridge.js';
import { isMultiUserMode } from '../../config/multiuser.js';
@@ -888,6 +895,25 @@ export function registerSessionRoutes(
if (capMsg) return createErrorResponse(ApiErrorCode.OPERATION_FAILED, capMsg);
const body = parseBody(CreateSessionSchema, req.body);
// The top-level `model` is Claude's per-session `--model`. Every other CLI takes its model
// in its own config object (`codexConfig.model` and so on), so a `model` here would be
// dropped without a word; refuse it before anything is written for the session.
if (body.model && !cliTakesSessionModel(body.mode ?? 'claude')) {
return createErrorResponse(
ApiErrorCode.INVALID_INPUT,
'model applies to claude sessions only; other CLIs take their model in their own config object, such as codexConfig.model'
);
}
// An attach launches nothing (the remote agent is already running), so a launch model
// or advisor would be dropped the same way, so both are refused, as they have been since
// they were added. The older launch fields (effort, envOverrides) predate this and keep
// their silent ignore here, since refusing them now would break existing callers.
if (body.attachRemoteSession && (body.model || body.advisorModel)) {
return createErrorResponse(
ApiErrorCode.INVALID_INPUT,
'model and advisorModel shape a new launch, and attachRemoteSession launches nothing; leave them out when attaching'
);
}
let workingDir = body.workingDir || process.cwd();
let remote = undefined;
@@ -1073,9 +1099,10 @@ export function registerSessionRoutes(
// genuinely different mechanisms:
// 'flag' — the CLI takes --model, so read the value the caller sent
// in that CLI's own config object.
// 'claude-settings-file' — claude alone, whose model is written to
// <case>/.claude/settings.local.json rather than passed as
// a flag, so the app-wide default applies here.
// 'claude-settings-file' — claude alone, whose persistent model is written to
// <case>/.claude/settings.local.json (`modelOverride`). A
// per-session `model` from the caller goes out as --model and
// wins; without one, the app-wide default applies.
// 'none' — shell has no model; deepseek's is a composition entry in
// the profile's config tree, not a session field
// (docs/deepseek-integration.md). Both get nothing.
@@ -1086,7 +1113,7 @@ export function registerSessionRoutes(
| string
| undefined)
: modelSource?.source === 'claude-settings-file'
? modelConfig?.defaultModel || undefined
? body.model || modelConfig?.defaultModel || undefined
: undefined;
const claudeModeConfig = await ctx.getClaudeModeConfig();
// Section 6.3: force non-granted users to a classifier-guarded mode.
@@ -1130,6 +1157,7 @@ export function registerSessionRoutes(
resumeSessionId: validatedResumeId,
envOverrides: await clampEnvOverridesForOwner(owner, body.envOverrides),
effort: body.effort,
advisorModel: body.advisorModel,
tmuxHistoryLimit: terminalHistoryConfig.tmuxHistoryLimit,
remote,
owner,
@@ -2092,21 +2120,16 @@ export function registerSessionRoutes(
// ========== Send Named Key (tmux send-keys -H) ==========
// Sends raw hex bytes to tmux pane for keys like Shift+Enter / Ctrl+Enter.
// Uses send-keys -H (hex) to inject 0x0a (line feed) which Claude Code's
// Ink input recognizes as "insert newline" vs 0x0d (carriage return = submit).
// Uses send-keys -H (hex) to inject a newline chord: 0x0a (line feed) by default, or the CLI's
// own `capabilities.newline`. Claude Code's Ink input recognizes 0x0a as "insert newline" vs
// 0x0d (carriage return = submit).
app.post('/api/sessions/:id/send-key', async (req) => {
const { id } = req.params as { id: string };
const body = req.body as Record<string, unknown>;
const key = typeof body?.key === 'string' ? body.key : '';
// Map key names to hex byte sequences
const KEY_HEX_MAP: Record<string, string[]> = {
'S-Enter': ['0a'], // \n (line feed)
'C-Enter': ['0a'], // \n (line feed)
};
const hex = KEY_HEX_MAP[key];
if (!hex) {
if (key !== 'S-Enter' && key !== 'C-Enter') {
return createErrorResponse(ApiErrorCode.INVALID_INPUT, `Key not allowed: ${key}`);
}
@@ -2116,6 +2139,18 @@ export function registerSessionRoutes(
return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'No tmux session');
}
// Key names map to hex byte sequences. Ctrl+Enter is always a line feed; Shift+Enter is the
// CLI's own newline chord (`capabilities.newline`, default line feed), so a CLI that wants
// Esc+Enter declares it in the registry instead of being special-cased here.
const NEWLINE_HEX: Record<NewlineSequence, string[]> = {
'line-feed': ['0a'], // \n
'esc-enter': ['1b', '0d'], // ESC CR, the Alt/Option+Enter chord
};
const hex =
key === 'C-Enter'
? NEWLINE_HEX['line-feed']
: NEWLINE_HEX[getCli(session.mode)?.capabilities.newline ?? 'line-feed'];
try {
// Route through the dedicated Codeman socket — bare `tmux` would target the
// user's default server and never find this session (same #80 regression class).
@@ -3393,6 +3428,7 @@ export function registerSessionRoutes(
ompConfig,
envOverrides,
effort,
advisorModel,
parentSessionId,
agentOrigin,
customModel,
@@ -3440,6 +3476,7 @@ export function registerSessionRoutes(
if (
(envOverrides && Object.keys(envOverrides).length > 0) ||
effort ||
advisorModel ||
modelOverride !== undefined ||
codexConfig ||
geminiConfig ||
@@ -3453,7 +3490,7 @@ export function registerSessionRoutes(
) {
return createErrorResponse(
ApiErrorCode.INVALID_INPUT,
'envOverrides, effort, modelOverride, per-CLI config, and custom model endpoints are not supported for remote cases (they do not cross ssh). Configure the remote command via the host command override instead.'
'envOverrides, effort, advisorModel, modelOverride, per-CLI config, and custom model endpoints are not supported for remote cases (they do not cross ssh). Configure the remote command via the host command override instead.'
);
}
@@ -3510,6 +3547,7 @@ export function registerSessionRoutes(
if (
(envOverrides && Object.keys(envOverrides).length > 0) ||
effort ||
advisorModel ||
codexConfig ||
geminiConfig ||
antigravityConfig ||
@@ -3522,7 +3560,7 @@ export function registerSessionRoutes(
) {
return createErrorResponse(
ApiErrorCode.INVALID_INPUT,
'envOverrides, effort, per-CLI config, and custom model endpoints are not supported for docker cases (they do not cross into the container). Configure the container via the docker host command override instead.'
'envOverrides, effort, advisorModel, per-CLI config, and custom model endpoints are not supported for docker cases (they do not cross into the container). Configure the container via the docker host command override instead.'
);
}
@@ -3977,6 +4015,7 @@ export function registerSessionRoutes(
ompConfig: qsResolvedOmpConfig,
envOverrides: qsCustomModelEnvOverrides,
effort,
advisorModel,
remote,
docker,
resumeSessionId: dockerResumeId,
+109
View File
@@ -0,0 +1,109 @@
/**
* @fileoverview Webhook notification settings (src/webhook-notify.ts).
*
* GET /api/webhook — the config WITHOUT its URL (scheme + host only), and the last delivery result
* PUT /api/webhook — change enabled / kind / url / scope; an empty `url` clears it
* POST /api/webhook/test — send one test message with the saved config
*
* The URL is a bearer secret (anyone holding a Slack/Discord webhook URL can post as it), so it is
* stored in its own 0600 file and never returned. In multi-user mode all three routes are admin only:
* the channel receives every session's events, the same reach an admin's own Web Push has.
*/
import type { FastifyInstance, FastifyReply, FastifyRequest } from 'fastify';
import {
ApiErrorCode,
createErrorResponse,
getErrorMessage,
type ApiResponse,
type WebhookResult,
type WebhookStatus,
} from '../../types.js';
import { isAdmin, parseBody } from '../route-helpers.js';
import { isMultiUserMode } from '../../config/multiuser.js';
import { WebhookUpdateSchema } from '../schemas.js';
import {
maskWebhookUrl,
readWebhookConfig,
webhookUrlProblem,
writeWebhookConfig,
type WebhookNotifier,
} from '../../webhook-notify.js';
export interface WebhookRouteDeps {
notifier: WebhookNotifier;
configDir: string;
/** The instance's window title, so a test message says which machine sent it. */
hostTitle: () => string;
}
export function registerWebhookRoutes(app: FastifyInstance, deps: WebhookRouteDeps): void {
const denied = (req: FastifyRequest, reply: FastifyReply): ApiResponse<never> | null => {
if (isMultiUserMode() && !isAdmin(req)) {
reply.code(403);
return createErrorResponse(ApiErrorCode.FORBIDDEN, 'Admin only in multi-user mode');
}
return null;
};
const status = async (): Promise<WebhookStatus> => {
const cfg = await readWebhookConfig(deps.configDir);
return {
enabled: cfg.enabled,
kind: cfg.kind,
scope: cfg.scope,
hasUrl: cfg.url !== '',
urlMasked: maskWebhookUrl(cfg.url),
lastResult: deps.notifier.lastResult,
};
};
app.get('/api/webhook', async (req, reply): Promise<ApiResponse<WebhookStatus>> => {
const no = denied(req, reply);
if (no) return no;
return { success: true, data: await status() };
});
app.put('/api/webhook', async (req, reply): Promise<ApiResponse<WebhookStatus>> => {
const no = denied(req, reply);
if (no) return no;
const patch = parseBody(WebhookUpdateSchema, req.body, 'Invalid webhook settings');
const current = await readWebhookConfig(deps.configDir);
const next = {
enabled: patch.enabled ?? current.enabled,
kind: patch.kind ?? current.kind,
scope: patch.scope ?? current.scope,
url: patch.url !== undefined ? patch.url.trim() : current.url,
};
if (next.url) {
const problem = webhookUrlProblem(next.url);
if (problem) {
reply.code(400);
return createErrorResponse(ApiErrorCode.INVALID_INPUT, problem);
}
}
if (next.enabled && !next.url) {
reply.code(400);
return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Add a webhook URL before enabling notifications');
}
try {
await writeWebhookConfig(deps.configDir, next);
} catch (err) {
reply.code(500);
return createErrorResponse(ApiErrorCode.OPERATION_FAILED, getErrorMessage(err));
}
return { success: true, data: await status() };
});
app.post('/api/webhook/test', async (req, reply): Promise<ApiResponse<WebhookResult>> => {
const no = denied(req, reply);
if (no) return no;
const cfg = await readWebhookConfig(deps.configDir);
if (!cfg.url) {
reply.code(400);
return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Save a webhook URL first');
}
// 200 even when delivery failed: the request to Codeman worked, `data.ok` says whether the webhook did.
return { success: true, data: await deps.notifier.sendTest(cfg, deps.hostTitle()) };
});
}
+66
View File
@@ -18,11 +18,14 @@ import {
MIN_TERMINAL_SCROLLBACK_LINES,
} from '../config/terminal-history.js';
import { MAX_EDITABLE_BYTES } from '../config/file-editing.js';
import { CODEX_REASONING_EFFORTS } from '../types/session.js';
import { WEBHOOK_KINDS, WEBHOOK_SCOPES } from '../types/push.js';
import { MIN_MATCH_LENGTH, MAX_MATCH_LENGTH } from '../config/agent-wait.js';
import { MAX_WAKE_MACS } from '../config/remote-wake-limits.js';
import { MAX_INPUT_LENGTH } from '../config/terminal-limits.js';
import { enabledCliIds, enabledClis } from '../config/cli-registry/registry.js';
import type { SessionMode } from '../types.js';
import { isAdvisorModel } from '../types/session.js';
// ========== Path Validation ==========
@@ -251,6 +254,20 @@ const safeEnvOverridesSchema = z
*/
const effortLevelSchema = z.enum(['low', 'medium', 'high', 'xhigh', 'max', 'ultracode']).optional();
/**
* Claude advisor model for new sessions: `fable`/`opus`/`sonnet` or a full model id in one of
* those families (isAdvisorModel). Merged into the launch `--settings` JSON as `advisorModel`,
* a soft default that /advisor still switches in-session. The allowlist is also the injection
* guard for the single-quoted `--settings` argument.
*/
const advisorModelSchema = z
.string()
.max(64)
.refine((value) => isAdvisorModel(value), {
message: 'advisorModel must be fable, opus, sonnet or a full claude-fable/opus/sonnet model id',
})
.optional();
// ========== Session Routes ==========
/**
@@ -298,6 +315,7 @@ const CodexConfigSchema = z
.max(100)
.regex(/^[a-zA-Z0-9._\-/]+$/)
.optional(),
reasoningEffort: z.enum(CODEX_REASONING_EFFORTS).optional(),
resumeSessionId: z
.string()
.max(100)
@@ -524,8 +542,25 @@ export const CreateSessionSchema = z.object({
envOverrides: safeEnvOverridesSchema,
/** Claude CLI effort level (soft default via --settings, switchable in-session via /effort) */
effort: effortLevelSchema,
/** Claude advisor model (soft default via --settings, switchable in-session via /advisor) */
advisorModel: advisorModelSchema,
/** Model override to write to .claude/settings.local.json (e.g., "opus[1m]"). Empty string clears. */
modelOverride: z.string().max(50).optional(),
/**
* Claude model for THIS session only, passed as `claude --model <id>`; nothing is written to
* disk. Wins over the app-wide default model. A subset of the registry's `model-claude`
* pattern, so a value accepted here is never rejected at launch. The first character must be
* a letter or digit: the value lands in argv, and no model id opens with `-`, so a
* flag-shaped value is refused here rather than left to the launch quoting. An empty string
* means no per-session model, as it does for `modelOverride`. Claude only: the route refuses
* it for any other CLI and on a remote attach.
*/
model: z
.string()
.max(100)
.regex(/^[a-zA-Z0-9][a-zA-Z0-9._\-[\]]*$/)
.or(z.literal(''))
.optional(),
openCodeConfig: OpenCodeConfigSchema,
codexConfig: CodexConfigSchema,
geminiConfig: GeminiConfigSchema,
@@ -1055,6 +1090,8 @@ export const QuickStartSchema = z.object({
envOverrides: safeEnvOverridesSchema,
/** Claude CLI effort level (soft default via --settings, switchable in-session via /effort) */
effort: effortLevelSchema,
/** Claude advisor model (soft default via --settings, switchable in-session via /advisor) */
advisorModel: advisorModelSchema,
/**
* Who is spawning this worker (`codeman-skill` from the packaged agent skill), or,
* equivalently, the `X-Codeman-Agent-Origin` header; the body wins when both are
@@ -1327,6 +1364,13 @@ export const SettingsUpdateSchema = z
* endpoints (PUT/POST/DELETE /api/clis...) answer instead of refusing outright.
*/
cliManagementEnabled: z.boolean().optional(),
/**
* MCP server sync (src/mcp-sync.ts): copies each enabled CLI's user-level MCP servers into
* the other CLIs' own config files. SYNCED, default OFF: it writes other tools' config in
* the server user's home (including any env values and headers on the servers), so it is
* opt-in. While OFF, GET/POST /api/mcp-sync answer 403 and the Settings controls are hidden.
*/
mcpSyncEnabled: z.boolean().optional(),
/**
* Read My Mind predictor model override. Empty/absent = the AI-checker
* default (opus: prediction quality is the product and it runs only on an
@@ -1374,6 +1418,12 @@ export const SettingsUpdateSchema = z
// auto-reattached.
remoteAutoReconnect: z.boolean().optional(),
thinkingEffort: z.string().max(20).optional(),
/** Advisor model for new Claude sessions ('' = leave it to the CLI's own /advisor choice). */
claudeAdvisorModel: z
.string()
.max(64)
.refine((value) => value === '' || isAdvisorModel(value), { message: 'Invalid advisor model' })
.optional(),
// UI visibility
showFontControls: z.boolean().optional(),
showSystemStats: z.boolean().optional(),
@@ -1846,6 +1896,20 @@ export const PushPreferencesUpdateSchema = z.object({
pushPreferences: z.record(z.string(), z.boolean()),
});
/**
* PUT /api/webhook. `.strict()` like every settings-shaped schema; `url` is optional so a change of
* kind or scope never needs the secret re-sent, and an empty string clears it. The kind and scope
* lists are the store's own, so the schema can never accept a value the store would coerce away.
*/
export const WebhookUpdateSchema = z
.object({
enabled: z.boolean().optional(),
kind: z.enum(WEBHOOK_KINDS).optional(),
scope: z.enum(WEBHOOK_SCOPES).optional(),
url: z.string().max(2048).optional(),
})
.strict();
// ========== Ralph Loop ==========
/** POST /api/ralph-loop/start */
@@ -1862,6 +1926,8 @@ export const RalphLoopStartSchema = z.object({
envOverrides: safeEnvOverridesSchema,
/** Claude CLI effort level (soft default via --settings, switchable in-session via /effort) */
effort: effortLevelSchema,
/** Claude advisor model (soft default via --settings, switchable in-session via /advisor) */
advisorModel: advisorModelSchema,
planItems: z
.array(
z.object({
+56 -22
View File
@@ -44,6 +44,9 @@ import { hostname as getHostname, uptime as osUptime } from 'node:os';
import { looksLikeHostReboot, newestPersistedActivity, planRebootRestore } from '../reboot-restore.js';
import { rebootRestoreRegistry } from './reboot-restore-registry.js';
import { dataPath, getDataDir, CODEMAN_INSTANCE } from '../config/instance.js';
import { WebhookNotifier, readWebhookConfig } from '../webhook-notify.js';
import type { WebhookUrgency } from '../types/push.js';
import { webviewFetch } from './webview-egress.js';
import { readRemoteHosts, rehydrateRemoteHostFields } from '../remote-hosts.js';
import type { RemoteWakeRegistry } from '../remote-wake.js';
import { normalizeBasePath, stripBasePath, joinBasePath } from '../config/base-path.js';
@@ -197,6 +200,8 @@ import {
registerVoiceRoutes,
registerWebviewRoutes,
registerTabLayoutRoutes,
registerMcpSyncRoutes,
registerWebhookRoutes,
registerCustomModelRoutes,
refreshAllCustomModelHosts,
readCustomModelEndpointsEnabled,
@@ -368,6 +373,8 @@ export class WebServer extends EventEmitter {
private hookSecretFailures: StaleExpirationMap<string, number> | null = null;
private userFailures: StaleExpirationMap<string, number> | null = null;
private pushStore: PushSubscriptionStore = new PushSubscriptionStore();
/** ntfy / Slack / Discord / generic webhook for the push events; config in webhook.json (0600). */
private webhookNotifier = new WebhookNotifier(() => readWebhookConfig(getDataDir()), webviewFetch);
private teamWatcher: TeamWatcher = new TeamWatcher();
private _orchestratorLoop: import('../orchestrator-loop.js').OrchestratorLoop | null = null;
private readonly titleHostname: string;
@@ -1130,6 +1137,12 @@ export class WebServer extends EventEmitter {
registerOrchestratorRoutes(this.app, ctx);
registerWebviewRoutes(this.app, ctx, this.basePath);
registerTabLayoutRoutes(this.app, ctx);
registerMcpSyncRoutes(this.app);
registerWebhookRoutes(this.app, {
notifier: this.webhookNotifier,
configDir: getDataDir(),
hostTitle: () => this.windowTitle,
});
registerCustomModelRoutes(this.app);
registerCliRegistryRoutes(this.app);
@@ -2652,6 +2665,25 @@ export class WebServer extends EventEmitter {
const template = WebServer.PUSH_EVENT_MAP[event];
if (!template) return;
const sessionName = (data.sessionName as string) || '';
const sessionId = (data.sessionId as string) || '';
const body = WebServer.pushBodyText(event, data, sessionName);
// Webhook channel (ntfy / Slack / Discord / generic): independent of Web Push, so it runs BEFORE
// the "no subscriptions" return below, which is exactly the headless-server case it exists for.
// Fire-and-forget; WebhookNotifier dedupes, caps what is in flight and never throws.
void this.webhookNotifier
.notify({
event,
title: template.title,
body,
urgency: template.urgency as WebhookUrgency,
sessionId: sessionId || undefined,
sessionName: sessionName || undefined,
host: this.windowTitle,
})
.catch(() => undefined);
const subscriptions = this.pushStore.getAll();
if (subscriptions.length === 0) return;
@@ -2670,9 +2702,6 @@ export class WebServer extends EventEmitter {
const vapidKeys = this.pushStore.getVapidKeys();
webpush.setVapidDetails('mailto:codeman@localhost', vapidKeys.publicKey, vapidKeys.privateKey);
const sessionName = (data.sessionName as string) || '';
const sessionId = (data.sessionId as string) || '';
// Multi-user: a session-scoped push (all PUSH_EVENT_MAP events carry a sessionId)
// must reach only the owner's devices (+ admins) — the body embeds the session
// name + activity, so cross-user delivery would leak it. Resolved once here; the
@@ -2680,25 +2709,6 @@ export class WebServer extends EventEmitter {
const multiUserPush = isMultiUserMode();
const pushSessionOwner = sessionId ? this.sessions.get(sessionId)?.owner : undefined;
// Build body text from event data
let body = sessionName ? `[${sessionName}]` : '';
if (event === SseEvent.SessionError && data.error) {
body += body ? ' ' : '';
body += String(data.error).slice(0, 200);
} else if (event === SseEvent.RespawnBlocked && data.reason) {
body += body ? ' ' : '';
body += String(data.reason);
} else if (event === SseEvent.SessionRalphCompletionDetected && data.phrase) {
body += body ? ' ' : '';
body += String(data.phrase);
} else if (event === SseEvent.SessionRespawnBreakerTripped && data.count) {
body += body ? ' ' : '';
body += `Stopped after ${Number(data.count)} rapid crashes — restart the session to retry`;
} else if (event === SseEvent.HookPermissionPrompt && data.tool_name) {
body += body ? ' ' : '';
body += `Tool: ${String(data.tool_name)}`;
}
const payload = JSON.stringify({
title: template.title,
// Hostname-aware prefix so OS-level notifications from multiple Codeman
@@ -2752,6 +2762,28 @@ export class WebServer extends EventEmitter {
}
}
/** The notification body for an event (shared by Web Push and the webhook channel). */
private static pushBodyText(event: string, data: Record<string, unknown>, sessionName: string): string {
let body = sessionName ? `[${sessionName}]` : '';
if (event === SseEvent.SessionError && data.error) {
body += body ? ' ' : '';
body += String(data.error).slice(0, 200);
} else if (event === SseEvent.RespawnBlocked && data.reason) {
body += body ? ' ' : '';
body += String(data.reason);
} else if (event === SseEvent.SessionRalphCompletionDetected && data.phrase) {
body += body ? ' ' : '';
body += String(data.phrase);
} else if (event === SseEvent.SessionRespawnBreakerTripped && data.count) {
body += body ? ' ' : '';
body += `Stopped after ${Number(data.count)} rapid crashes — restart the session to retry`;
} else if (event === SseEvent.HookPermissionPrompt && data.tool_name) {
body += body ? ' ' : '';
body += `Tool: ${String(data.tool_name)}`;
}
return body;
}
private cleanupDeadSSEClients(): void {
this.sse.cleanupDeadClients();
}
@@ -3489,6 +3521,8 @@ export class WebServer extends EventEmitter {
ompConfig: muxSession.mode === 'omp' ? savedState?.ompConfig : undefined,
envOverrides: savedEnvOverrides,
effort: savedState?.effort,
model: savedState?.model,
advisorModel: savedState?.advisorModel,
attachmentHistory: savedAttachmentHistory,
// The pane's last Enter. Without it the response viewer would show
// the launch conversation until the user types again, even though
+319
View File
@@ -0,0 +1,319 @@
/**
* @fileoverview Webhook notifications (ntfy, Slack, Discord, generic JSON) for the events that
* already trigger Web Push, so a headless server can reach a phone without a browser tab or a
* push subscription.
*
* Split in three, so the parts that matter are testable without a network:
* - pure: `webhookUrlProblem`, `maskWebhookUrl`, `shouldSendWebhook`, `buildWebhookRequest`
* - store: `~/.codeman/webhook.json`, written 0600 via tmp+rename (the URL is a bearer secret:
* anyone holding a Slack/Discord webhook URL can post as it)
* - IO: `sendWebhook` (injected fetch) and `WebhookNotifier` (dedupe, in-flight cap, last result)
*
* Rules the code keeps and the tests pin:
* - The URL is configured only through the admin-only `/api/webhook` routes and kept OUT of
* `settings.json`, which every logged-in user can read through `GET /api/settings`.
* - Delivery goes through `webviewFetch`: link-local and cloud-metadata targets are refused on
* the RESOLVED address at connect time, redirects are not followed, and the call is bounded
* by a timeout. Loopback and LAN stay allowed on purpose (a local ntfy is the feature).
* - The URL never appears in a log line, a result, or an error message.
* - Session names and error text are user/agent-controlled, so they cannot ping a channel:
* Discord gets `allowed_mentions: { parse: [] }` and Slack control characters are escaped.
*
* @module webhook-notify
*/
import { existsSync, mkdirSync } from 'node:fs';
import fs from 'node:fs/promises';
import { join } from 'node:path';
import { blockedWebviewHostReason } from './web/webview-egress-policy.js';
import { isEgressBlockedError } from './web/webview-egress.js';
import {
WEBHOOK_KINDS,
WEBHOOK_SCOPES,
type WebhookConfig,
type WebhookKind,
type WebhookResult,
type WebhookScope,
type WebhookUrgency,
} from './types/push.js';
const WEBHOOK_FILE = 'webhook.json';
const MAX_URL_LENGTH = 2048;
const SEND_TIMEOUT_MS = 5000;
const MAX_BODY_CHARS = 500;
/** Same event + session within this window is sent once: a flapping prompt must not flood a channel. */
const DEDUPE_WINDOW_MS = 3000;
const MAX_IN_FLIGHT = 5;
export const DEFAULT_WEBHOOK_CONFIG: WebhookConfig = { enabled: false, kind: 'ntfy', url: '', scope: 'attention' };
export interface WebhookMessage {
event: string;
title: string;
body: string;
urgency: WebhookUrgency;
sessionId?: string;
sessionName?: string;
/** The Codeman instance's window title, so several machines are told apart. */
host?: string;
}
// ---------------------------------------------------------------------------
// Pure
// ---------------------------------------------------------------------------
/** Why `raw` cannot be a webhook URL, or null. Used at save time; delivery re-checks the resolved address. */
export function webhookUrlProblem(raw: string): string | null {
if (raw.length > MAX_URL_LENGTH) return 'URL is too long';
let url: URL;
try {
url = new URL(raw);
} catch {
return 'Not a valid URL';
}
if (url.protocol !== 'https:' && url.protocol !== 'http:') return 'Only http and https URLs are allowed';
if (url.username || url.password) return 'Put credentials in the path or a header-less token, not user:password@';
const blocked = blockedWebviewHostReason(url.hostname);
if (blocked) return `Refused: ${blocked}`;
return null;
}
/** Scheme + host only: the path and query of a webhook URL are the secret. */
export function maskWebhookUrl(raw: string): string {
try {
const url = new URL(raw);
return `${url.protocol}//${url.host}/•••`;
} catch {
return '';
}
}
export function shouldSendWebhook(cfg: WebhookConfig, urgency: WebhookUrgency): boolean {
if (!cfg.enabled || !cfg.url) return false;
return cfg.scope === 'all' || urgency !== 'info';
}
const clip = (s: string, n: number): string => (s.length > n ? `${s.slice(0, n - 1)}…` : s);
/** A header value must be single-line printable ASCII; anything else goes out RFC 2047 encoded. */
function headerSafe(value: string): string {
const oneLine = value.replace(/[\r\n]+/g, ' ').trim();
return /^[\x20-\x7e]*$/.test(oneLine) ? oneLine : `=?UTF-8?B?${Buffer.from(oneLine, 'utf8').toString('base64')}?=`;
}
/** Slack parses `<!channel>`, `<@U123>` and `<url|text>`; escaping the three control characters turns them to text. */
const slackEscape = (s: string): string => s.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
const NTFY_PRIORITY: Record<WebhookUrgency, string> = { critical: '5', warning: '4', info: '3' };
const NTFY_TAGS: Record<WebhookUrgency, string> = {
critical: 'rotating_light',
warning: 'bell',
info: 'white_check_mark',
};
export interface WebhookRequest {
method: 'POST';
headers: Record<string, string>;
body: string;
}
export function buildWebhookRequest(kind: WebhookKind, msg: WebhookMessage, now: Date = new Date()): WebhookRequest {
const title = clip(msg.title, 120);
const body = clip(msg.body, MAX_BODY_CHARS);
const prefix = msg.host ? `${clip(msg.host, 60)}: ` : '';
switch (kind) {
case 'ntfy':
return {
method: 'POST',
headers: {
'Content-Type': 'text/plain; charset=utf-8',
Title: headerSafe(`${prefix}${title}`),
Priority: NTFY_PRIORITY[msg.urgency],
Tags: NTFY_TAGS[msg.urgency],
},
body: body || title,
};
case 'slack':
return {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ text: `*${slackEscape(`${prefix}${title}`)}*${body ? `\n${slackEscape(body)}` : ''}` }),
};
case 'discord':
return {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
content: clip(`**${prefix}${title}**${body ? `\n${body}` : ''}`, 1900),
// Agent output and session names are not trusted to @everyone a channel.
allowed_mentions: { parse: [] },
}),
};
case 'generic':
return {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
event: msg.event,
title,
body,
urgency: msg.urgency,
sessionId: msg.sessionId ?? null,
sessionName: msg.sessionName ?? null,
host: msg.host ?? null,
at: now.toISOString(),
}),
};
}
}
// ---------------------------------------------------------------------------
// Store
// ---------------------------------------------------------------------------
export function webhookConfigPath(configDir: string): string {
return join(configDir, WEBHOOK_FILE);
}
function coerce(raw: unknown): WebhookConfig {
const r = (typeof raw === 'object' && raw !== null ? raw : {}) as Record<string, unknown>;
return {
enabled: r.enabled === true,
kind: (WEBHOOK_KINDS as readonly unknown[]).includes(r.kind)
? (r.kind as WebhookKind)
: DEFAULT_WEBHOOK_CONFIG.kind,
url: typeof r.url === 'string' ? r.url : '',
scope: (WEBHOOK_SCOPES as readonly unknown[]).includes(r.scope)
? (r.scope as WebhookScope)
: DEFAULT_WEBHOOK_CONFIG.scope,
};
}
export async function readWebhookConfig(configDir: string): Promise<WebhookConfig> {
try {
return coerce(JSON.parse(await fs.readFile(webhookConfigPath(configDir), 'utf-8')));
} catch {
return { ...DEFAULT_WEBHOOK_CONFIG };
}
}
/** 0600 via tmp+rename: `mode` on writeFile only applies to a file being created. */
export async function writeWebhookConfig(configDir: string, cfg: WebhookConfig): Promise<void> {
if (!existsSync(configDir)) mkdirSync(configDir, { recursive: true });
const target = webhookConfigPath(configDir);
const tmp = `${target}.${process.pid}.tmp`;
try {
await fs.writeFile(tmp, JSON.stringify(coerce(cfg), null, 2), { mode: 0o600 });
await fs.rename(tmp, target);
} catch (err) {
await fs.unlink(tmp).catch(() => undefined);
throw err;
}
}
// ---------------------------------------------------------------------------
// IO
// ---------------------------------------------------------------------------
export type WebhookFetch = (target: URL, init: RequestInit) => Promise<Response>;
/**
* What went wrong, without the URL: blocked / timed out / refused / an HTTP status. An egress
* refusal is recognised by its `CODEMAN_EGRESS_BLOCKED` code anywhere in the cause chain (undici
* wraps the lookup's error as `TypeError('fetch failed', { cause })`), never by message text.
*/
function describeError(err: unknown): string {
const e = err as { name?: string; cause?: { code?: string } };
if (e?.name === 'TimeoutError' || e?.name === 'AbortError') return 'Timed out';
if (isEgressBlockedError(err)) return 'Refused: target is a link-local or cloud-metadata address';
if (e?.cause?.code === 'ENOTFOUND') return 'Host not found';
if (e?.cause?.code === 'ECONNREFUSED') return 'Connection refused';
return 'Network error';
}
export async function sendWebhook(
cfg: Pick<WebhookConfig, 'kind' | 'url'>,
msg: WebhookMessage,
fetchImpl: WebhookFetch
): Promise<WebhookResult> {
const at = Date.now();
const problem = webhookUrlProblem(cfg.url);
if (problem) return { ok: false, error: problem, at };
const req = buildWebhookRequest(cfg.kind, msg);
try {
const res = await fetchImpl(new URL(cfg.url), {
method: req.method,
headers: req.headers,
body: req.body,
redirect: 'manual',
signal: AbortSignal.timeout(SEND_TIMEOUT_MS),
});
void res.body?.cancel().catch(() => undefined);
if (res.status >= 300 && res.status < 400) {
return { ok: false, status: res.status, error: 'The URL redirects; use the final URL', at };
}
return res.ok
? { ok: true, status: res.status, at }
: { ok: false, status: res.status, error: `HTTP ${res.status}`, at };
} catch (err) {
return { ok: false, error: describeError(err), at };
}
}
/**
* Sends the notifications the server decides on. Fire-and-forget by design (a slow webhook must
* never delay Web Push or a request), so it dedupes, caps what is in flight, and remembers only
* the last result for the Settings status line.
*/
export class WebhookNotifier {
private lastSent = new Map<string, number>();
private inFlight = 0;
private last: WebhookResult | null = null;
constructor(
private readonly load: () => Promise<WebhookConfig>,
private readonly fetchImpl: WebhookFetch,
private readonly now: () => number = Date.now
) {}
get lastResult(): WebhookResult | null {
return this.last;
}
async notify(msg: WebhookMessage): Promise<void> {
const cfg = await this.load();
if (!shouldSendWebhook(cfg, msg.urgency)) return;
const key = `${msg.event}:${msg.sessionId ?? ''}`;
const t = this.now();
const prev = this.lastSent.get(key);
if (prev !== undefined && t - prev < DEDUPE_WINDOW_MS) return;
if (this.inFlight >= MAX_IN_FLIGHT) return;
this.lastSent.set(key, t);
if (this.lastSent.size > 256) {
for (const [k, v] of this.lastSent) if (t - v > DEDUPE_WINDOW_MS) this.lastSent.delete(k);
}
this.inFlight++;
try {
this.last = await sendWebhook(cfg, msg, this.fetchImpl);
} finally {
this.inFlight--;
}
}
/** A deliberate test send: bypasses `enabled`, scope and dedupe, and records the result. */
async sendTest(cfg: Pick<WebhookConfig, 'kind' | 'url'>, host?: string): Promise<WebhookResult> {
const result = await sendWebhook(
cfg,
{
event: 'webhook:test',
title: 'Codeman test notification',
body: 'If you can read this, webhook notifications are working.',
urgency: 'info',
host,
},
this.fetchImpl
);
this.last = result;
return result;
}
}