Merge branch 'master' into pr180

This commit is contained in:
Codeman maintainer
2026-08-04 23:37:59 +02:00
77 changed files with 7995 additions and 541 deletions
+7 -2
View File
@@ -31,5 +31,10 @@ export const AUTH_FAILURE_WINDOW_MS = 15 * 60 * 1000;
// Hooks
// ============================================================================
/** Timeout for Claude Code hook curl commands (ms) */
export const HOOK_TIMEOUT_MS = 10000;
/**
* Timeout for Claude Code hook curl commands, in SECONDS: the hook `timeout`
* field is seconds (the CLI multiplies by 1000). The predecessor constant
* `HOOK_TIMEOUT_MS = 10000` fed the same field, so those hooks effectively had a
* ~2.8-hour timeout; 10 seconds is the originally intended budget.
*/
export const HOOK_TIMEOUT_SECONDS = 10;
+8
View File
@@ -98,6 +98,14 @@ export const DEPENDENCY_REGISTRY: ToolDependency[] = [
usedBy: ['Gemini sessions'],
resolvers: [{ match: ALL, resolver: { kind: 'path', bins: ['gemini'], versionArg: '--version' } }],
},
{
id: 'antigravity',
label: 'Antigravity CLI',
category: 'core',
required: false,
usedBy: ['Antigravity sessions'],
resolvers: [{ match: ALL, resolver: { kind: 'path', bins: ['agy'], versionArg: '--version' } }],
},
{
id: 'libreoffice',
label: 'LibreOffice',
+1
View File
@@ -143,6 +143,7 @@ export function defaultDockerCommandForMode(mode: SessionMode): string {
opencode: 'exec opencode',
codex: 'exec codex',
gemini: 'exec gemini',
antigravity: 'exec agy',
};
return commands[mode as DockerCommandMode] || commands.shell;
}
+208 -21
View File
@@ -16,9 +16,9 @@
* `stop`, `teammate_idle`, `task_completed`
*
* Hook categories: `Notification` (3 matchers), `Stop` (1), `TeammateIdle` (1),
* `TaskCompleted` (1)
* `TaskCompleted` (1), `PostToolUse` (1 self-contained background Bash rewake)
*
* @dependencies types (HookEventType), config/auth-config (HOOK_TIMEOUT_MS)
* @dependencies types (HookEventType), config/auth-config (HOOK_TIMEOUT_SECONDS)
* @consumedby web/server (session creation), session-cli-builder (env setup)
*
* @module hooks-config
@@ -29,7 +29,7 @@ import { readFile, writeFile, mkdir } from 'node:fs/promises';
import { join } from 'node:path';
import type { HookEventType } from './types.js';
import { HOOK_TIMEOUT_MS } from './config/auth-config.js';
import { HOOK_TIMEOUT_SECONDS } from './config/auth-config.js';
/**
* Serializes read-modify-write access to a `settings.local.json` path. Every
@@ -40,6 +40,104 @@ import { HOOK_TIMEOUT_MS } from './config/auth-config.js';
* are independent; the map self-prunes when a path's chain goes idle.
*/
const settingsWriteLocks = new Map<string, Promise<unknown>>();
/**
* Version-agnostic ownership prefix: every rewake script version embeds a marker
* starting with this, and `isCodemanHookHandler` matches on the prefix. That way a
* version bump replaces the old handler instead of duplicating it (matching on the
* full versioned marker would disown every older script).
*/
const BACKGROUND_WAKE_MARKER_PREFIX = 'CODEMAN_BACKGROUND_REWAKE_V';
/**
* Current script version. Bump the suffix whenever `generateBackgroundWakeScript`
* changes: `refreshStaleCodemanHooks` treats the absence of the CURRENT marker as
* stale, so healed cases pick up the new script on next launch.
*/
const BACKGROUND_WAKE_MARKER = `${BACKGROUND_WAKE_MARKER_PREFIX}2`;
const BACKGROUND_WAKE_TIMEOUT_SECONDS = 6 * 60 * 60;
/**
* Inline Node helper for Claude Code's `asyncRewake` hook.
*
* A background Bash tool returns immediately with a task ID, then Claude writes
* its completion as a queue-operation in the transcript. Watching that durable
* record avoids injecting terminal input (which could submit a user's draft).
* The helper is embedded in settings via `node -e`, so it has no script path
* that can go stale after an install or plugin-cache cleanup.
*
* Self-terminating: Claude Code enforces the hook timeout, but the helper does not
* rely on it. It exits on its own deadline (same budget) and when orphaned
* (`ppid === 1`), so a dead session cannot leave a poller stat-ing the transcript
* forever. The ppid check misses subreaper setups; the deadline is the backstop.
*/
export function generateBackgroundWakeScript(): string {
return [
"const fs = require('node:fs');",
`const ${BACKGROUND_WAKE_MARKER} = true;`,
`const deadline = Date.now() + ${BACKGROUND_WAKE_TIMEOUT_SECONDS} * 1000;`,
'let input = {};',
"try { input = JSON.parse(fs.readFileSync(0, 'utf8') || '{}'); } catch { process.exit(0); }",
'function findTaskId(value) {',
" const idKeys = new Set(['taskId', 'task_id', 'shellId', 'shell_id', 'backgroundTaskId', 'background_task_id']);",
' const stack = [value];',
' const seen = new Set();',
' while (stack.length > 0) {',
' const current = stack.pop();',
" if (!current || typeof current !== 'object' || seen.has(current)) continue;",
' seen.add(current);',
' for (const [key, nested] of Object.entries(current)) {',
" if (idKeys.has(key) && typeof nested === 'string' && /^[A-Za-z0-9_-]+$/.test(nested)) return nested;",
" if (nested && typeof nested === 'object') stack.push(nested);",
' }',
' }',
" const serialized = JSON.stringify(value ?? '');",
' const messageMatch = serialized.match(/Command running in background with ID:\\s*([A-Za-z0-9_-]+)/i);',
' if (messageMatch) return messageMatch[1];',
' const pathMatch = serialized.match(/[\\\\/]tasks[\\\\/]([A-Za-z0-9_-]+)\\.output/i);',
' return pathMatch ? pathMatch[1] : null;',
'}',
'const taskId = findTaskId(input.tool_response);',
"const transcriptPath = typeof input.transcript_path === 'string' ? input.transcript_path : '';",
'if (!taskId || !transcriptPath) process.exit(0);',
'let position = 0;',
'try { position = Math.max(0, fs.statSync(transcriptPath).size - 262144); } catch { process.exit(0); }',
"let carry = '';",
'function inspect(text) {',
' for (const line of text.split(/\\r?\\n/)) {',
' if (!line.includes(taskId)) continue;',
' let entry;',
' try { entry = JSON.parse(line); } catch { continue; }',
" if (entry.type !== 'queue-operation' || typeof entry.content !== 'string') continue;",
" if (!entry.content.includes('<task-id>' + taskId + '</task-id>')) continue;",
' const status = entry.content.match(/<status>(completed|failed|killed|error)<\\/status>/i);',
' if (!status) continue;',
' const output = entry.content.match(/<output-file>([^<]+)<\\/output-file>/i);',
" const location = output ? ' Read ' + output[1] + ' and' : '';",
" console.error('Background command ' + taskId + ' ' + status[1].toLowerCase() + '.' + location + ' continue the task.');",
' process.exit(2);',
' }',
'}',
'function poll() {',
' if (Date.now() > deadline || process.ppid === 1) process.exit(0);',
' try {',
' const size = fs.statSync(transcriptPath).size;',
" if (size < position) { position = 0; carry = ''; }",
' if (size > position) {',
' const length = Math.min(size - position, 1048576);',
' const buffer = Buffer.allocUnsafe(length);',
" const fd = fs.openSync(transcriptPath, 'r');",
' const bytes = fs.readSync(fd, buffer, 0, length, position);',
' fs.closeSync(fd);',
' position += bytes;',
" carry = (carry + buffer.subarray(0, bytes).toString('utf8')).slice(-262144);",
' inspect(carry);',
' }',
' } catch {}',
' setTimeout(poll, 1000);',
'}',
'poll();',
].join('\n');
}
function withSettingsLock<T>(path: string, fn: () => Promise<T>): Promise<T> {
const prev = settingsWriteLocks.get(path) ?? Promise.resolve();
const run = prev.then(fn, fn); // run after the prior writer, regardless of its outcome
@@ -86,36 +184,118 @@ export function generateHooksConfig(): { hooks: Record<string, unknown[]> } {
Notification: [
{
matcher: 'idle_prompt',
hooks: [{ type: 'command', command: curlCmd('idle_prompt'), timeout: HOOK_TIMEOUT_MS }],
hooks: [{ type: 'command', command: curlCmd('idle_prompt'), timeout: HOOK_TIMEOUT_SECONDS }],
},
{
matcher: 'permission_prompt',
hooks: [{ type: 'command', command: curlCmd('permission_prompt'), timeout: HOOK_TIMEOUT_MS }],
hooks: [{ type: 'command', command: curlCmd('permission_prompt'), timeout: HOOK_TIMEOUT_SECONDS }],
},
{
matcher: 'elicitation_dialog',
hooks: [{ type: 'command', command: curlCmd('elicitation_dialog'), timeout: HOOK_TIMEOUT_MS }],
hooks: [{ type: 'command', command: curlCmd('elicitation_dialog'), timeout: HOOK_TIMEOUT_SECONDS }],
},
],
Stop: [
{
hooks: [{ type: 'command', command: curlCmd('stop'), timeout: HOOK_TIMEOUT_MS }],
hooks: [{ type: 'command', command: curlCmd('stop'), timeout: HOOK_TIMEOUT_SECONDS }],
},
],
TeammateIdle: [
{
hooks: [{ type: 'command', command: curlCmd('teammate_idle'), timeout: HOOK_TIMEOUT_MS }],
hooks: [{ type: 'command', command: curlCmd('teammate_idle'), timeout: HOOK_TIMEOUT_SECONDS }],
},
],
TaskCompleted: [
{
hooks: [{ type: 'command', command: curlCmd('task_completed'), timeout: HOOK_TIMEOUT_MS }],
hooks: [{ type: 'command', command: curlCmd('task_completed'), timeout: HOOK_TIMEOUT_SECONDS }],
},
],
PostToolUse: [
{
matcher: 'Bash',
hooks: [
{
type: 'command',
command: 'node',
args: ['-e', generateBackgroundWakeScript()],
asyncRewake: true,
timeout: BACKGROUND_WAKE_TIMEOUT_SECONDS,
},
],
},
],
},
};
}
function isCodemanHookHandler(value: unknown): boolean {
try {
const serialized = JSON.stringify(value);
// Prefix, not the versioned marker: older script versions must still be ours.
return serialized.includes('/api/hook-event') || serialized.includes(BACKGROUND_WAKE_MARKER_PREFIX);
} catch {
return false;
}
}
/**
* Replace only Codeman-owned command handlers while preserving user events,
* matcher entries, and sibling handlers in mixed entries.
*/
function mergeCodemanHooks(existingValue: unknown, generated: Record<string, unknown[]>): Record<string, unknown[]> {
const existing =
existingValue && typeof existingValue === 'object' && !Array.isArray(existingValue)
? (existingValue as Record<string, unknown>)
: {};
const merged: Record<string, unknown[]> = {};
for (const eventName of new Set([...Object.keys(existing), ...Object.keys(generated)])) {
const existingEntries = Array.isArray(existing[eventName]) ? (existing[eventName] as unknown[]) : [];
const generatedEntries = generated[eventName];
if (!generatedEntries) {
merged[eventName] = existingEntries;
continue;
}
const entries: unknown[] = [];
let insertedGenerated = false;
for (const entry of existingEntries) {
if (!entry || typeof entry !== 'object' || Array.isArray(entry)) {
if (!isCodemanHookHandler(entry)) entries.push(entry);
continue;
}
const record = entry as Record<string, unknown>;
if (!Array.isArray(record.hooks)) {
if (isCodemanHookHandler(record)) {
if (!insertedGenerated) {
entries.push(...generatedEntries);
insertedGenerated = true;
}
} else {
entries.push(entry);
}
continue;
}
const retainedHandlers = record.hooks.filter((handler) => !isCodemanHookHandler(handler));
const removedCodemanHandler = retainedHandlers.length !== record.hooks.length;
if (removedCodemanHandler && !insertedGenerated) {
entries.push(...generatedEntries);
insertedGenerated = true;
}
if (retainedHandlers.length > 0 || !removedCodemanHandler) {
entries.push(retainedHandlers.length === record.hooks.length ? entry : { ...record, hooks: retainedHandlers });
}
}
if (!insertedGenerated) entries.push(...generatedEntries);
merged[eventName] = entries;
}
return merged;
}
/**
* Remove a subset of env keys from .claude/settings.local.json.env if present.
* Used during the disk→tmux-setenv migration: when the caller is actively setting
@@ -237,29 +417,31 @@ export async function writeHooksConfig(casePath: string): Promise<void> {
}
const hooksConfig = generateHooksConfig();
const merged = { ...existing, ...hooksConfig };
const merged = {
...existing,
hooks: mergeCodemanHooks(existing.hooks, hooksConfig.hooks),
};
await writeFile(settingsPath, JSON.stringify(merged, null, 2) + '\n');
});
}
/**
* Self-heal a case's hooks block so the COD-91 unconditional hook-secret gate keeps
* accepting its hook events.
* Self-heal a case's Codeman-owned hooks block.
*
* `writeHooksConfig` only runs when a case is first CREATED. Cases created before the
* X-Codeman-Hook-Secret header was added (COD-54, 2026-06-10) keep hook curls in their
* settings.local.json that POST to /api/hook-event WITHOUT the secret — which, once the
* gate requires it unconditionally (COD-91), silently 401 on a password-protected
* install. This refreshes the hooks block so those stale curls regain the header.
* gate requires it unconditionally (COD-91), silently 401 on a password-protected install.
* Older Codeman blocks also lack the background Bash async-rewake hook. Refresh either
* stale shape on launch so existing cases gain both current behaviors.
*
* Deliberately surgical: regenerates ONLY when settings.local.json already contains
* Codeman's own hook curls (they target `/api/hook-event`) that lack the secret header.
* No-op when the file/hooks are absent (we never impose hooks on a user who removed
* them), when the hooks aren't ours, or when the secret is already present — so it never
* clobbers a user's customizations and is cheap enough to call on every Claude spawn.
* Codeman's own hook curls (they target `/api/hook-event`) and they are stale. No-op
* when the file/hooks are absent (we never impose hooks on a user who removed them) or
* when the hooks aren't ours, so it is cheap enough to call on every Claude spawn.
*/
export async function refreshStaleHookSecret(casePath: string): Promise<void> {
export async function refreshStaleCodemanHooks(casePath: string): Promise<void> {
const settingsPath = join(casePath, '.claude', 'settings.local.json');
if (!existsSync(settingsPath)) return;
await withSettingsLock(settingsPath, async () => {
@@ -274,8 +456,13 @@ export async function refreshStaleHookSecret(casePath: string): Promise<void> {
// The generated curl carries this header literal (see generateHooksConfig); its
// absence on our own hooks means they predate COD-54 and need regenerating.
const hasSecret = hooksJson.includes('X-Codeman-Hook-Secret');
if (!isOurs || hasSecret) return;
const merged = { ...existing, ...generateHooksConfig() };
const hasBackgroundWake = hooksJson.includes(BACKGROUND_WAKE_MARKER);
if (!isOurs || (hasSecret && hasBackgroundWake)) return;
const generated = generateHooksConfig();
const merged = {
...existing,
hooks: mergeCodemanHooks(existing.hooks, generated.hooks),
};
await writeFile(settingsPath, JSON.stringify(merged, null, 2) + '\n');
});
}
+3
View File
@@ -17,6 +17,7 @@ import type {
CodexConfig,
EffortLevel,
GeminiConfig,
AntigravityConfig,
SessionRemote,
SessionDocker,
} from './types.js';
@@ -74,6 +75,7 @@ export interface CreateSessionOptions {
openCodeConfig?: OpenCodeConfig;
codexConfig?: CodexConfig;
geminiConfig?: GeminiConfig;
antigravityConfig?: AntigravityConfig;
/** When restoring after reboot, resume a previous Claude conversation by its session ID */
resumeSessionId?: string;
/** Extra env vars exported before launching the CLI (e.g., CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS). Ephemeral — not written to disk. */
@@ -102,6 +104,7 @@ export interface RespawnPaneOptions {
openCodeConfig?: OpenCodeConfig;
codexConfig?: CodexConfig;
geminiConfig?: GeminiConfig;
antigravityConfig?: AntigravityConfig;
/** Resume a previous Claude conversation when respawning */
resumeSessionId?: string;
/** Extra env vars exported before launching the CLI (preserved across respawns). */
+1
View File
@@ -68,6 +68,7 @@ export function defaultRemoteCommandForMode(mode: SessionMode): string {
opencode: 'exec opencode',
codex: 'exec codex',
gemini: 'exec gemini',
antigravity: 'exec agy',
};
return commands[mode as RemoteCommandMode] || commands.shell;
}
+86 -41
View File
@@ -49,6 +49,7 @@ import {
type CodexConfig,
type EffortLevel,
type GeminiConfig,
type AntigravityConfig,
type SessionRemote,
type SessionDocker,
} from './types.js';
@@ -65,6 +66,9 @@ import {
MAX_SESSION_TOKENS,
execPattern,
getClaudeCliVersion,
getClaudeBinaryPath,
spawnPtyWithHelperRepair,
resolveLocalShell,
} from './utils/index.js';
import {
MAX_TERMINAL_BUFFER_SIZE,
@@ -140,7 +144,7 @@ const NEWLINE_SPLIT_PATTERN = /\r?\n/;
/** True for external-CLI run modes (non-Claude) that use their own TUI and output format. */
export function isExternalCliMode(mode: SessionMode): boolean {
return mode === 'opencode' || mode === 'codex' || mode === 'gemini';
return mode === 'opencode' || mode === 'codex' || mode === 'gemini' || mode === 'antigravity';
}
function getModeLabel(mode: SessionMode): string {
@@ -151,6 +155,8 @@ function getModeLabel(mode: SessionMode): string {
return 'Codex';
case 'gemini':
return 'Gemini';
case 'antigravity':
return 'Antigravity';
case 'shell':
return 'Shell';
case 'claude':
@@ -180,6 +186,13 @@ export function isAltScreenStripMode(mode: SessionMode): boolean {
const DEFAULT_PTY_COLS = 120;
const DEFAULT_PTY_ROWS = 40;
const TMUX_DISPLAY_TIMEOUT_MS = 2000;
const IS_TEST_MODE = !!process.env.VITEST;
/**
* Echo transport for the test-mode PTY attach. Raw mode disables the tty line
* discipline, so each input byte flows back exactly once and immediately; without
* it, tty echo doubles every line and canonical buffering holds bytes until Enter.
*/
const TEST_PTY_SCRIPT = 'if (process.stdin.isTTY) process.stdin.setRawMode(true); process.stdin.pipe(process.stdout);';
/** Delay before the in-container Claude CLI version probe (lets the container start). */
const DOCKER_CLI_VERSION_PROBE_DELAY_MS = 3000;
@@ -396,6 +409,8 @@ export class Session extends EventEmitter {
private _codexConfig: CodexConfig | undefined;
// Gemini configuration (only for mode === 'gemini')
private _geminiConfig: GeminiConfig | undefined;
// Antigravity configuration (only for mode === 'antigravity')
private _antigravityConfig: AntigravityConfig | undefined;
private _resumeSessionId: string | undefined;
// Ephemeral env overrides (e.g., CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS). Exported by tmux
@@ -482,6 +497,8 @@ export class Session extends EventEmitter {
codexConfig?: CodexConfig;
/** Gemini configuration (only for mode === 'gemini') */
geminiConfig?: GeminiConfig;
/** Antigravity configuration (only for mode === 'antigravity') */
antigravityConfig?: AntigravityConfig;
/** Resume a previous Claude conversation (used after server reboot) */
resumeSessionId?: string;
/** Extra env vars exported to the CLI at spawn time (no disk persistence) */
@@ -555,6 +572,11 @@ export class Session extends EventEmitter {
this._geminiConfig = config.geminiConfig;
}
// Apply Antigravity configuration
if (config.antigravityConfig) {
this._antigravityConfig = config.antigravityConfig;
}
// Apply env overrides (exported at spawn, not persisted to disk).
// Legacy migration: pre-0.7.2 carried effort as the CLAUDE_CODE_EFFORT_LEVEL env var,
// which hard-locks /effort switching. Extract it into _effort (--settings soft default)
@@ -1104,6 +1126,7 @@ export class Session extends EventEmitter {
openCodeConfig: this._openCodeConfig,
codexConfig: this._codexConfig,
geminiConfig: this._geminiConfig,
antigravityConfig: this._antigravityConfig,
resumeSessionId: this._resumeSessionId,
effort: this._effort,
// COD-118: runtime-only — surfaced so the frontend can require explicit user
@@ -1248,24 +1271,33 @@ export class Session extends EventEmitter {
// No extra sleep — createSession() already waits for tmux readiness
}
// Attach to the mux session via PTY
// Prevent tmux from letting the newest browser attach dictate global window
// size; accepted Codeman resize events update it explicitly below.
mux.setManualWindowSize?.(this._muxSession!.muxName);
// Integration tests need a live input/output transport without attaching to
// the host's tmux server or agent CLI. Production still uses the real mux.
if (!IS_TEST_MODE) {
// Prevent tmux from letting the newest browser attach dictate global window
// size; accepted Codeman resize events update it explicitly below.
mux.setManualWindowSize?.(this._muxSession!.muxName);
}
// Query existing tmux window size so re-attach matches (avoids flicker from 120x40 default).
// MUST go through the dedicated socket (mux.muxSocket); a bare `tmux display` hits the
// default server, always fails for our socketed sessions, and silently falls back to 120x40.
const { cols: ptyCols, rows: ptyRows } = queryTmuxWindowSize(this._muxSession!.muxName, mux.muxSocket);
const { cols: ptyCols, rows: ptyRows } = IS_TEST_MODE
? { cols: DEFAULT_PTY_COLS, rows: DEFAULT_PTY_ROWS }
: queryTmuxWindowSize(this._muxSession!.muxName, mux.muxSocket);
const attachCommand = IS_TEST_MODE ? process.execPath : mux.getAttachCommand();
const attachArgs = IS_TEST_MODE ? ['-e', TEST_PTY_SCRIPT] : mux.getAttachArgs(this._muxSession!.muxName);
try {
this.ptyProcess = pty.spawn(mux.getAttachCommand(), mux.getAttachArgs(this._muxSession!.muxName), {
name: 'xterm-256color',
cols: ptyCols,
rows: ptyRows,
cwd: resolveMuxAttachCwd(this.workingDir, this._remote, this._docker),
// COD-75: codex/gemini get COLORTERM=truecolor — mirrors buildEnvExports()
// in tmux-manager.ts so the attach client and the tmux session agree.
env: buildMuxAttachEnv(this.mode === 'codex' || this.mode === 'gemini'),
});
this.ptyProcess = spawnPtyWithHelperRepair(() =>
pty.spawn(attachCommand, attachArgs, {
name: 'xterm-256color',
cols: ptyCols,
rows: ptyRows,
cwd: resolveMuxAttachCwd(this.workingDir, this._remote, this._docker),
// COD-75: codex/gemini/antigravity get COLORTERM=truecolor — mirrors buildEnvExports()
// in tmux-manager.ts so the attach client and the tmux session agree.
env: buildMuxAttachEnv(this.mode === 'codex' || this.mode === 'gemini' || this.mode === 'antigravity'),
})
);
} catch (spawnErr) {
console.error(`[Session] Failed to spawn PTY for ${options.spawnErrLabel}:`, spawnErr);
this.emit('error', `Failed to attach to mux session: ${spawnErr}`);
@@ -1329,6 +1361,7 @@ export class Session extends EventEmitter {
openCodeConfig: this._openCodeConfig,
codexConfig: this._codexConfig,
geminiConfig: this._geminiConfig,
antigravityConfig: this._antigravityConfig,
resumeSessionId: this._resumeSessionId,
envOverrides: this._envOverrides,
effort: this._effort,
@@ -1501,6 +1534,7 @@ export class Session extends EventEmitter {
openCodeConfig: this._openCodeConfig,
codexConfig: this._codexConfig,
geminiConfig: this._geminiConfig,
antigravityConfig: this._antigravityConfig,
resumeSessionId: this._resumeSessionId,
envOverrides: this._envOverrides,
effort: this._effort,
@@ -1582,18 +1616,24 @@ export class Session extends EventEmitter {
if (this.mode === 'gemini') {
throw new Error('Gemini sessions require tmux. Direct PTY fallback is not supported.');
}
// Antigravity sessions require tmux for env override injection via setenv
if (this.mode === 'antigravity') {
throw new Error('Antigravity sessions require tmux. Direct PTY fallback is not supported.');
}
try {
// Pass --session-id to use the SAME ID as the Codeman session
// This ensures subagents can be directly matched to the correct tab
const args = buildInteractiveArgs(this.id, this._claudeMode, this._model, this._allowedTools, this._effort);
this.ptyProcess = pty.spawn('claude', args, {
name: 'xterm-256color',
cols: 120,
rows: 40,
cwd: this.workingDir,
// Merge envOverrides after buildClaudeEnv so user settings shadow defaults.
env: { ...buildClaudeEnv(this.id), ...(this._envOverrides ?? {}) },
});
this.ptyProcess = spawnPtyWithHelperRepair(() =>
pty.spawn(getClaudeBinaryPath(), args, {
name: 'xterm-256color',
cols: 120,
rows: 40,
cwd: this.workingDir,
// Merge envOverrides after buildClaudeEnv so user settings shadow defaults.
env: { ...buildClaudeEnv(this.id), ...(this._envOverrides ?? {}) },
})
);
} catch (spawnErr) {
console.error('[Session] Failed to spawn Claude PTY:', spawnErr);
this._status = 'stopped';
@@ -1859,8 +1899,9 @@ export class Session extends EventEmitter {
this._resetBuffers();
// Use user's default shell or bash
const shell = process.env.SHELL || '/bin/bash';
// Use user's default shell, falling back to a shell that actually exists.
// Shared with the tmux pane command so both paths launch the same binary.
const shell = resolveLocalShell();
console.log(
'[Session] Starting shell session with:',
shell + (this._useMux ? ` (with ${this._mux!.backend})` : '')
@@ -1916,13 +1957,15 @@ export class Session extends EventEmitter {
// Fallback to direct PTY if mux is not used
if (!this.ptyProcess) {
try {
this.ptyProcess = pty.spawn(shell, [], {
name: 'xterm-256color',
cols: 120,
rows: 40,
cwd: this.workingDir,
env: buildShellEnv(this.id),
});
this.ptyProcess = spawnPtyWithHelperRepair(() =>
pty.spawn(shell, [], {
name: 'xterm-256color',
cols: 120,
rows: 40,
cwd: this.workingDir,
env: buildShellEnv(this.id),
})
);
} catch (spawnErr) {
console.error('[Session] Failed to spawn shell PTY:', spawnErr);
this._status = 'stopped';
@@ -2022,14 +2065,16 @@ export class Session extends EventEmitter {
const args = buildPromptArgs(prompt, model, this._claudeMode, this._allowedTools);
try {
this.ptyProcess = pty.spawn('claude', args, {
name: 'xterm-256color',
cols: 120,
rows: 40,
cwd: this.workingDir,
// Merge envOverrides after buildClaudeEnv so user settings shadow defaults.
env: { ...buildClaudeEnv(this.id), ...(this._envOverrides ?? {}) },
});
this.ptyProcess = spawnPtyWithHelperRepair(() =>
pty.spawn(getClaudeBinaryPath(), args, {
name: 'xterm-256color',
cols: 120,
rows: 40,
cwd: this.workingDir,
// Merge envOverrides after buildClaudeEnv so user settings shadow defaults.
env: { ...buildClaudeEnv(this.id), ...(this._envOverrides ?? {}) },
})
);
} catch (spawnErr) {
console.error('[Session] Failed to spawn Claude PTY for runPrompt:', spawnErr);
this.emit(
@@ -2683,7 +2728,7 @@ export class Session extends EventEmitter {
if (this.ptyProcess && (dimsChanged || options.force)) {
this._ptyCols = cols;
this._ptyRows = rows;
if (this._mux && this._muxSession) {
if (!IS_TEST_MODE && this._mux && this._muxSession) {
this._mux.resizeWindow?.(this._muxSession.muxName, cols, rows);
}
this.ptyProcess.resize(cols, rows);
+61 -3
View File
@@ -43,6 +43,7 @@ import {
type CodexConfig,
type EffortLevel,
type GeminiConfig,
type AntigravityConfig,
type SessionRemote,
type SessionDocker,
type DockerCommandMode,
@@ -69,6 +70,8 @@ import {
resolveOpenCodeDir,
resolveCodexDir,
resolveGeminiDir,
resolveAntigravityDir,
resolveLocalShell,
} from './utils/index.js';
import type {
TerminalMultiplexer,
@@ -682,6 +685,34 @@ function buildGeminiCommand(config?: GeminiConfig): string {
return parts.join(' ');
}
/**
* Build the Antigravity CLI (agy) command with appropriate flags.
*
* Unlike gemini's yolo default, `--dangerously-skip-permissions` is only added
* when the config explicitly asks for it (the frontend sends it for parity with
* Codeman's Claude default; the multi-user clamp strips it for non-granted owners,
* and an ABSENT config stays at agy's own prompting default — safe like Codex).
*/
function buildAntigravityCommand(config?: AntigravityConfig): string {
const parts = ['agy'];
if (config?.dangerouslySkipPermissions) {
parts.push('--dangerously-skip-permissions');
}
if (config?.model) {
const safeModel = /^[a-zA-Z0-9._\-/]+$/.test(config.model) ? config.model : undefined;
if (safeModel) parts.push('--model', safeModel);
}
if (config?.resumeConversationId) {
const safeId = /^[a-zA-Z0-9._-]+$/.test(config.resumeConversationId) ? config.resumeConversationId : undefined;
if (safeId) parts.push('--conversation', safeId);
}
return parts.join(' ');
}
/**
* Build the spawn command for any session mode.
* Shared by createSession() and respawnPane() to avoid duplication.
@@ -709,6 +740,7 @@ export function buildSpawnCommand(options: {
openCodeConfig?: OpenCodeConfig;
codexConfig?: CodexConfig;
geminiConfig?: GeminiConfig;
antigravityConfig?: AntigravityConfig;
resumeSessionId?: string;
effort?: EffortLevel;
}): string {
@@ -739,7 +771,16 @@ export function buildSpawnCommand(options: {
if (options.mode === 'gemini') {
return buildGeminiCommand(options.geminiConfig);
}
return '$SHELL';
if (options.mode === 'antigravity') {
return buildAntigravityCommand(options.antigravityConfig);
}
// #208: NOT the literal '$SHELL'. This string is embedded in the `bash -c "…"`
// argument of the respawn-pane line, which execSync runs through `/bin/sh -c`,
// so a `$SHELL` here is expanded by the SERVER process's shell against the
// SERVER process's env — empty in containers and system systemd units, leaving
// the pane command ending in a dangling `&&` ("syntax error: unexpected end of
// file", pane dead on arrival). Resolve it in Node and quote the result.
return shellescape(resolveLocalShell());
}
/**
@@ -917,6 +958,8 @@ function appendResumeFlag(modeCommand: string, mode: SessionMode, resumeId: stri
return `${modeCommand} --resume ${resumeId}`;
case 'codex':
return `${modeCommand} resume ${resumeId}`;
case 'antigravity':
return `${modeCommand} --conversation ${resumeId}`;
default:
return modeCommand; // shell / opencode: no resume
}
@@ -1485,8 +1528,10 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
const exports = [
'export LANG=en_US.UTF-8',
'export LC_ALL=en_US.UTF-8',
mode === 'codex' || mode === 'gemini' ? 'export COLORTERM=truecolor' : 'unset COLORTERM',
...(mode === 'codex' || mode === 'gemini' ? ['unset NO_COLOR'] : []),
mode === 'codex' || mode === 'gemini' || mode === 'antigravity'
? 'export COLORTERM=truecolor'
: 'unset COLORTERM',
...(mode === 'codex' || mode === 'gemini' || mode === 'antigravity' ? ['unset NO_COLOR'] : []),
// Stamp each Codex pane with a unique originator so the response-viewer
// can locate THIS pane's rollout exactly — codex writes the value into
// session_meta.originator of every rollout it creates. Without it,
@@ -1569,6 +1614,10 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
const dir = resolveGeminiDir();
return { pathExport: dir ? `export PATH="${dir}:$PATH" && ` : '', dir };
}
if (mode === 'antigravity') {
const dir = resolveAntigravityDir();
return { pathExport: dir ? `export PATH="${dir}:$PATH" && ` : '', dir };
}
return { pathExport: '', dir: null };
}
@@ -1616,6 +1665,7 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
openCodeConfig,
codexConfig,
geminiConfig,
antigravityConfig,
resumeSessionId,
envOverrides,
effort,
@@ -1667,6 +1717,11 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
if (mode === 'gemini' && !cliDir) {
throw new Error('Gemini CLI not found. Install with: npm install -g @google/gemini-cli');
}
if (mode === 'antigravity' && !cliDir) {
throw new Error(
'Antigravity CLI not found. Install with: curl -fsSL https://antigravity.google/cli/install.sh | bash'
);
}
const envExportsStr = this.buildEnvExports(sessionId, muxName, mode).join(' && ');
@@ -1679,6 +1734,7 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
openCodeConfig,
codexConfig,
geminiConfig,
antigravityConfig,
resumeSessionId,
effort,
});
@@ -1901,6 +1957,7 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
openCodeConfig,
codexConfig,
geminiConfig,
antigravityConfig,
resumeSessionId,
envOverrides,
effort,
@@ -1938,6 +1995,7 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
openCodeConfig,
codexConfig,
geminiConfig,
antigravityConfig,
resumeSessionId,
effort,
});
+33 -16
View File
@@ -40,6 +40,7 @@ interface TranscriptContentBlock {
text?: string;
name?: string;
input?: Record<string, unknown>;
tool_use_id?: string;
content?: string;
is_error?: boolean;
}
@@ -328,10 +329,7 @@ export class TranscriptWatcher extends EventEmitter {
this.handleResultEntry(entry);
break;
case 'user':
// User message means new turn, reset some state
this.state.isComplete = false;
this.state.hasError = false;
this.state.errorMessage = null;
this.handleUserEntry(entry);
break;
case 'system':
// System messages are informational
@@ -360,23 +358,42 @@ export class TranscriptWatcher extends EventEmitter {
this.state.currentTool = block.name;
this.emit('transcript:tool_start', block.name);
} else if (block.type === 'tool_result') {
// Tool completed
const wasError = block.is_error === true;
const toolName = this.state.currentTool;
this.state.toolExecuting = false;
this.state.currentTool = null;
if (toolName) {
this.emit('transcript:tool_end', toolName, wasError);
}
if (wasError && block.content) {
this.state.hasError = true;
this.state.errorMessage = String(block.content).slice(0, 200);
}
this.handleToolResult(block);
}
}
}
}
private handleUserEntry(entry: TranscriptEntry): void {
// A user-authored prompt starts a turn, while Claude tool results also use
// user entries. Reset turn state first, then close any completed tool.
this.state.isComplete = false;
this.state.hasError = false;
this.state.errorMessage = null;
const content = entry.message?.content;
if (!Array.isArray(content)) return;
for (const block of content) {
if (block.type === 'tool_result') {
this.handleToolResult(block);
}
}
}
private handleToolResult(block: TranscriptContentBlock): void {
const wasError = block.is_error === true;
const toolName = this.state.currentTool;
this.state.toolExecuting = false;
this.state.currentTool = null;
if (toolName) {
this.emit('transcript:tool_end', toolName, wasError);
}
if (wasError && block.content) {
this.state.hasError = true;
this.state.errorMessage = String(block.content).slice(0, 200);
}
}
private handleResultEntry(entry: TranscriptEntry): void {
// Result entry indicates completion
this.state.isComplete = true;
+23 -4
View File
@@ -8,12 +8,13 @@
* - SessionConfig — creation-time config (id, workingDir, createdAt)
* - SessionOutput — captured stdout/stderr/exitCode
* - SessionStatus — 'idle' | 'busy' | 'stopped' | 'error'
* - SessionMode — 'claude' | 'shell' | 'opencode' | 'codex' | 'gemini' (which CLI backend)
* - SessionMode — 'claude' | 'shell' | 'opencode' | 'codex' | 'gemini' | 'antigravity' (which CLI backend)
* - 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)
* - GeminiConfig — Gemini CLI-specific settings (model, approvalMode, resumeSession)
* - AntigravityConfig — Antigravity CLI (agy) settings (model, dangerouslySkipPermissions, resumeConversationId)
*
* Cross-domain relationships:
* - SessionState.respawnConfig embeds RespawnConfig (respawn domain)
@@ -42,9 +43,12 @@ export type SessionStatus = 'idle' | 'busy' | 'stopped' | 'error';
export type ClaudeMode = 'dangerously-skip-permissions' | 'auto' | 'normal' | 'allowedTools';
/** Session mode: which CLI backend a session runs */
export type SessionMode = 'claude' | 'shell' | 'opencode' | 'codex' | 'gemini';
export type SessionMode = 'claude' | 'shell' | 'opencode' | 'codex' | 'gemini' | 'antigravity';
export type RemoteCommandMode = Extract<SessionMode, 'shell' | 'claude' | 'opencode' | 'codex' | 'gemini'>;
export type RemoteCommandMode = Extract<
SessionMode,
'shell' | 'claude' | 'opencode' | 'codex' | 'gemini' | 'antigravity'
>;
/**
* Advanced SSH connection options shared by RemoteHost and SessionRemote.
@@ -150,7 +154,10 @@ export interface RemoteSessionInfo {
// into the same long-lived container. See `docs/docker-cases-plan.md`.
/** Which CLI backends a Docker case can run (same set as remote). */
export type DockerCommandMode = Extract<SessionMode, 'shell' | 'claude' | 'opencode' | 'codex' | 'gemini'>;
export type DockerCommandMode = Extract<
SessionMode,
'shell' | 'claude' | 'opencode' | 'codex' | 'gemini' | 'antigravity'
>;
/** Container engine. Docker and Podman differ in the uid/userns + host-gateway alias. */
export type DockerEngine = 'docker' | 'podman';
@@ -312,6 +319,16 @@ export interface GeminiConfig {
resumeSession?: string;
}
/** Antigravity CLI (agy) session configuration */
export interface AntigravityConfig {
/** Model identifier. Passed via --model. */
model?: string;
/** Auto-approve all tool permission requests (passes --dangerously-skip-permissions). Absent = agy's default prompting. */
dangerouslySkipPermissions?: boolean;
/** Resume a previous conversation by ID (passed via --conversation). */
resumeConversationId?: string;
}
/**
* Configuration for creating a new session
*/
@@ -453,6 +470,8 @@ export interface SessionState {
codexConfig?: CodexConfig;
/** Gemini-specific configuration (only for mode === 'gemini') */
geminiConfig?: GeminiConfig;
/** Antigravity-specific configuration (only for mode === 'antigravity') */
antigravityConfig?: AntigravityConfig;
/** Claude conversation session ID to resume after reboot (set by restore script) */
resumeSessionId?: string;
/** Claude CLI effort level (soft default via --settings, switchable in-session via /effort) */
+65
View File
@@ -0,0 +1,65 @@
/**
* @fileoverview Resolve the Antigravity CLI (`agy`) binary across common install paths.
*
* Mirrors gemini-cli-resolver.ts. Google's installer (antigravity.google/cli/install.sh)
* places the binary at ~/.local/bin/agy; the other locations cover manual installs.
*
* @module utils/antigravity-cli-resolver
*/
import { execSync } from 'node:child_process';
import { existsSync } from 'node:fs';
import { dirname, join } from 'node:path';
import { homedir } from 'node:os';
import { EXEC_TIMEOUT_MS } from '../config/exec-timeout.js';
/** Common directories where the Antigravity CLI binary may be installed */
const ANTIGRAVITY_SEARCH_DIRS = [
join(homedir(), '.local', 'bin'),
join(homedir(), '.antigravity', 'bin'),
'/usr/local/bin',
join(homedir(), 'bin'),
];
/** Cached directory containing the agy binary (empty string = searched but not found) */
let _antigravityDir: string | null = null;
/**
* Finds the directory containing the `agy` binary.
* Checks `which agy` first, then falls back to common install locations.
*
* @returns Directory path, or null if not found
*/
export function resolveAntigravityDir(): string | null {
if (_antigravityDir !== null) return _antigravityDir || null;
try {
const result = execSync('which agy', {
encoding: 'utf-8',
timeout: EXEC_TIMEOUT_MS,
}).trim();
if (result && existsSync(result)) {
_antigravityDir = dirname(result);
return _antigravityDir;
}
} catch {
// agy not in PATH, will check common locations
}
for (const dir of ANTIGRAVITY_SEARCH_DIRS) {
if (existsSync(join(dir, 'agy'))) {
_antigravityDir = dir;
return _antigravityDir;
}
}
_antigravityDir = '';
return null;
}
/**
* Check if the Antigravity CLI is available on the system.
*/
export function isAntigravityAvailable(): boolean {
return resolveAntigravityDir() !== null;
}
+15
View File
@@ -59,6 +59,21 @@ export function findClaudeDir(): string | null {
return null;
}
/**
* Returns an absolute path to the `claude` binary, falling back to the bare
* name `'claude'` when it cannot be located (so PATH resolution still gets a
* chance).
*
* Preferred over passing `'claude'` to `pty.spawn()`: a PTY child resolves the
* command against the environment it is handed, and an install that lives in
* `~/.local/bin` or `~/.claude/local` is frequently absent from the PATH the
* server process inherited (issue #6).
*/
export function getClaudeBinaryPath(): string {
const dir = findClaudeDir();
return dir ? join(dir, 'claude') : 'claude';
}
/** Cached augmented PATH string */
let _augmentedPath: string | null = null;
+4 -1
View File
@@ -26,7 +26,10 @@ export { isSafePushEndpoint } from './push-endpoint-validation.js';
export { stringSimilarity, fuzzyPhraseMatch, todoContentHash } from './string-similarity.js';
export { assertNever } from './type-safety.js';
export { wrapWithNice } from './nice-wrapper.js';
export { findClaudeDir, getAugmentedPath, getClaudeCliVersion } from './claude-cli-resolver.js';
export { resolveLocalShell } from './shell-resolver.js';
export { findClaudeDir, getAugmentedPath, getClaudeCliVersion, getClaudeBinaryPath } from './claude-cli-resolver.js';
export { spawnPtyWithHelperRepair } from './node-pty-repair.js';
export { resolveOpenCodeDir } from './opencode-cli-resolver.js';
export { resolveCodexDir, isCodexAvailable } from './codex-cli-resolver.js';
export { resolveGeminiDir, isGeminiAvailable } from './gemini-cli-resolver.js';
export { resolveAntigravityDir, isAntigravityAvailable } from './antigravity-cli-resolver.js';
+153
View File
@@ -0,0 +1,153 @@
/**
* @fileoverview Runtime self-heal for node-pty's macOS `spawn-helper`.
*
* node-pty@1.1.0 ships its macOS prebuilt helper as
* `prebuilds/darwin-<arch>/spawn-helper` with mode 0644 (no execute bit). On
* macOS every PTY is launched through that helper via posix_spawnp, so a
* non-executable helper turns every session start into
* `Error: posix_spawnp failed.` (issues #6 and #204). The bug is macOS-only:
* `spawn-helper` is an `OS=="mac"` gyp target and pty.cc only spawns it under
* `#if defined(__APPLE__)`, and node-pty ships no Linux prebuild, so Linux always
* compiles a correctly-permissioned helper from source.
*
* `scripts/fix-node-pty.mjs` fixes this at install time. This module is the
* safety net for installs that are already broken: the first PTY spawn that
* fails this way is repaired and retried in-process, so the user never sees a
* dead session. If the retry still fails, the thrown error carries the manual
* repair command instead of a bare "posix_spawnp failed".
*
* @module utils/node-pty-repair
*/
import { chmodSync, existsSync, readdirSync, statSync } from 'node:fs';
import { createRequire } from 'node:module';
import { dirname, join } from 'node:path';
const require = createRequire(import.meta.url);
/** The one-line fix appended to errors we could not repair automatically. */
export const SPAWN_HELPER_FIX_HINT =
'node-pty cannot execute its spawn-helper. Repair it with: npm run fix:node-pty ' +
'(or: chmod +x node_modules/node-pty/prebuilds/*/spawn-helper)';
/** Set once a repair has been attempted, so a genuinely broken install cannot chmod-storm. */
let repairAttempted = false;
/**
* True when an error is node-pty failing to launch its spawn-helper.
*
* The native throw site is `throw Napi::Error::New(napiEnv, "posix_spawnp failed.")`
* in pty.cc, reached only on Apple platforms.
*/
export function isSpawnHelperFailure(err: unknown): boolean {
const message = err instanceof Error ? err.message : String(err ?? '');
return /posix_spawnp|spawn-helper/i.test(message);
}
/** Locates the installed node-pty package root, or null when it can't be resolved. */
export function findNodePtyDir(): string | null {
// node-pty declares no "exports" map, so the package.json subpath resolves and
// lands on the package root. require.resolve('node-pty') would return
// <pkg>/lib/index.js, one level deeper than callers need.
try {
return dirname(require.resolve('node-pty/package.json'));
} catch {
/* fall through */
}
try {
return join(dirname(require.resolve('node-pty')), '..');
} catch {
return null;
}
}
/**
* Lists every `spawn-helper` present in a node-pty install.
*
* node-pty's loader checks `build/Release`, `build/Debug`, then
* `prebuilds/<platform>-<arch>`, and takes the helper from whichever directory
* the native module loaded out of, so every copy has to be executable, not just
* the one this machine happens to use.
*/
export function listSpawnHelpers(ptyDir: string): string[] {
const dirs = [join(ptyDir, 'build', 'Release'), join(ptyDir, 'build', 'Debug')];
const prebuilds = join(ptyDir, 'prebuilds');
if (existsSync(prebuilds)) {
try {
for (const entry of readdirSync(prebuilds, { withFileTypes: true })) {
if (entry.isDirectory()) dirs.push(join(prebuilds, entry.name));
}
} catch {
/* unreadable prebuilds dir: nothing to repair there */
}
}
return dirs.map((d) => join(d, 'spawn-helper')).filter((p) => existsSync(p));
}
/**
* Adds the execute bit to every `spawn-helper` missing it.
*
* @param ptyDir - node-pty package root; resolved automatically when omitted.
* @returns Paths actually changed (empty when nothing needed repair, or node-pty
* is missing, or the files are not writable).
*/
export function repairSpawnHelperPermissions(ptyDir?: string): string[] {
const dir = ptyDir ?? findNodePtyDir();
if (!dir) return [];
const repaired: string[] = [];
for (const helper of listSpawnHelpers(dir)) {
try {
const mode = statSync(helper).mode & 0o777;
if ((mode & 0o111) === 0o111) continue;
chmodSync(helper, mode | 0o755);
repaired.push(helper);
} catch {
// Read-only install (or not ours to chmod): fall through to the hint.
}
}
return repaired;
}
/** Wraps an error so the message carries the actionable repair command. */
function withFixHint(err: unknown): Error {
const message = err instanceof Error ? err.message : String(err);
return new Error(`${message}. ${SPAWN_HELPER_FIX_HINT}`, { cause: err });
}
/**
* Runs a `pty.spawn()` call, repairing a non-executable spawn-helper and
* retrying once if that is why it failed.
*
* Any unrelated spawn error is rethrown untouched, so this stays invisible on
* every platform but a broken macOS install.
*
* @param spawn - The `pty.spawn(...)` call to run.
* @param ptyDir - node-pty package root; resolved automatically when omitted.
*/
export function spawnPtyWithHelperRepair<T>(spawn: () => T, ptyDir?: string): T {
try {
return spawn();
} catch (err) {
if (!isSpawnHelperFailure(err)) throw err;
if (repairAttempted) throw withFixHint(err);
repairAttempted = true;
const repaired = repairSpawnHelperPermissions(ptyDir);
if (repaired.length === 0) throw withFixHint(err);
console.warn(`[node-pty] spawn-helper was not executable, repaired ${repaired.join(', ')} and retrying`);
try {
return spawn();
} catch (retryErr) {
throw withFixHint(retryErr);
}
}
}
/** Test seam: forget that a repair was already attempted in this process. */
export function resetSpawnHelperRepairState(): void {
repairAttempted = false;
}
+78
View File
@@ -0,0 +1,78 @@
/**
* @fileoverview Resolve a real, launchable login shell for `mode: 'shell'` sessions.
*
* The tmux pane command for a local shell session used to be the literal string
* `$SHELL`. That string is embedded in the `bash -c "…"` argument of the
* `respawn-pane` line, which `execSync` hands to `/bin/sh -c` — so `$SHELL` was
* expanded by the SERVER process's shell (not the pane's), against the SERVER
* process's env. Containers and system-level systemd units do not set `SHELL`,
* so the expansion produced an empty string and the pane command ended in a
* dangling `&&`:
*
* bash -c "cd \"/case\" && ulimit … && export … && "
* -> bash: -c: line 1: syntax error: unexpected end of file
*
* The pane then died instantly (status 2) while tmux creation itself reported
* success, which is exactly what issue #208 saw. Resolving the shell HERE, in
* Node, removes the shell-expansion layer entirely and guarantees a non-empty
* absolute path.
*
* @module utils/shell-resolver
*/
import { accessSync, constants } from 'node:fs';
import { userInfo } from 'node:os';
/** Last-resort shells, in preference order. `/bin/sh` exists on every POSIX host. */
const FALLBACK_SHELLS = ['/bin/bash', '/bin/zsh', '/bin/sh'];
/**
* Shells that exist and are executable but immediately exit — a service account's
* passwd entry commonly points at one, which would look identical to the crash
* this module exists to prevent.
*/
const NON_INTERACTIVE_SHELLS = new Set(['nologin', 'false', 'true', 'sync']);
function isUsableShell(candidate: string): boolean {
if (!candidate.startsWith('/')) return false;
const base = candidate.slice(candidate.lastIndexOf('/') + 1);
if (NON_INTERACTIVE_SHELLS.has(base)) return false;
try {
accessSync(candidate, constants.X_OK);
return true;
} catch {
return false;
}
}
/**
* Resolve an absolute path to an interactive shell, preferring the user's own.
*
* Order: `$SHELL` -> the passwd entry -> `/bin/bash` -> `/bin/zsh` -> `/bin/sh`.
* Every candidate must be an absolute path to an executable that is not a
* nologin-style stub. Always returns a non-empty string.
*/
export function resolveLocalShell(): string {
const candidates: string[] = [];
const envShell = process.env.SHELL?.trim();
if (envShell) candidates.push(envShell);
try {
// Throws when the uid has no /etc/passwd entry (common for `--user` containers).
const passwdShell = userInfo().shell?.trim();
if (passwdShell) candidates.push(passwdShell);
} catch {
/* no passwd entry — fall through to the static fallbacks */
}
candidates.push(...FALLBACK_SHELLS);
for (const candidate of candidates) {
if (isUsableShell(candidate)) return candidate;
}
// Nothing was verifiable (exotic/read-restricted image). /bin/sh is still the
// best guess and is far better than emitting an empty command.
return '/bin/sh';
}
+37 -1
View File
@@ -159,12 +159,48 @@ function hasValidWebviewCapability(req: FastifyRequest): boolean {
// capability already implies an authenticated `POST /api/webviews/:id/open`, but
// the exemption should stay no wider than the problem it solves.
if (req.method !== 'GET' && req.method !== 'HEAD') return false;
if (url.startsWith('/api/') || url.startsWith('/ws/') || url.startsWith('/q/')) return false;
if (url.startsWith('/ws/') || url.startsWith('/q/')) return false;
// Anything that resolves to a REAL Codeman route is refused, which is the fence
// that keeps this from being an auth bypass. `/api/` used to be refused by prefix
// instead, but dashboards legitimately serve assets from their own `/api/...`
// namespace (`<img src="/api/hero?slug=x">`), and those requests were the one
// class the 404 relay could never rescue. See matchesRegisteredRoute.
if (matchesRegisteredRoute(req, url)) return false;
const fromReferer = capabilityFromReferer(typeof req.headers.referer === 'string' ? req.headers.referer : undefined);
return !!fromReferer && webviewCapabilities.resolve(fromReferer) !== undefined;
}
/**
* Whether `url` resolves to a route Codeman actually registered.
*
* `hasRoute()` is the wrong tool: it matches the registered PATTERN literally, so
* `/api/sessions/abc` reports false against a registered `/api/sessions/:id` and
* would hand out an exemption on a live API route. `findRoute()` performs the real
* radix-tree lookup and fills in `params`, which is what this needs.
*
* The one complication is `@fastify/static`, mounted at `/`, which registers a
* root-level catch-all that matches EVERY path. A match on that means "no real
* route, this is heading for the 404 handler", and it is distinguishable because a
* root catch-all is the only route whose `*` param comes back equal to the entire
* request path. `test/webview-auth-exemption.test.ts` pins both halves of that.
*
* Fails CLOSED: anything unexpected counts as a real route, which merely denies the
* exemption and restores the previous behavior.
*/
function matchesRegisteredRoute(req: FastifyRequest, url: string): boolean {
try {
const found = req.server.findRoute({ method: req.method as 'GET' | 'HEAD', url });
if (!found) return false;
const params = found.params ?? {};
const keys = Object.keys(params);
const isRootCatchAll = keys.length === 1 && keys[0] === '*' && `/${params['*']}` === url;
return !isRootCatchAll;
} catch {
return true;
}
}
/**
* Register HTTP Basic Auth middleware with session cookies and rate limiting.
* Only active when CODEMAN_PASSWORD is set.
+36 -3
View File
@@ -800,6 +800,10 @@ class CodemanApp {
this.applyLocalization();
this.applyTabWrapSettings();
this.applyMonitorVisibility();
// Must run before the first session:created can arrive: markSessionTabEntering()
// ignores ids until this sets up its state, which is what keeps the tabs
// restored on page load from animating.
this.initEntranceAnimations?.();
// Remove mobile-init class now that JS has applied visibility settings.
// The inline <script> in <head> added this to prevent flash-of-content on mobile.
document.documentElement.classList.remove('mobile-init');
@@ -1563,6 +1567,12 @@ class CodemanApp {
this.sessionOrder.push(data.id);
this.saveSessionOrder();
}
// Idempotent per id: the POST response and the session:created event both
// land here, and a batch launched together cascades in creation order.
this.markSessionTabEntering?.(data.id);
// The pane is one shared element, so it is only marked here and played when
// this session is actually selected (see selectSession).
this.markTerminalEntering?.(data.id);
this.renderSessionTabs();
this.updateCost();
// Start stats polling when first session appears
@@ -1974,7 +1984,7 @@ class CodemanApp {
// Render conversation thread
const mode = this.sessions.get(this.activeSessionId)?.mode;
const agentLabel =
mode === 'codex' ? 'Codex' : mode === 'gemini' ? 'Gemini' : mode === 'opencode' ? 'OpenCode' : 'Claude';
mode === 'codex' ? 'Codex' : mode === 'gemini' ? 'Gemini' : mode === 'antigravity' ? 'Antigravity' : mode === 'opencode' ? 'OpenCode' : 'Claude';
body.innerHTML = '';
for (const msg of messages) {
const div = document.createElement('div');
@@ -3280,6 +3290,13 @@ class CodemanApp {
}
_renderSessionTabsImmediate() {
// Same guard as renderSessionTabs()/_fullRenderSessionTabs(): the incremental
// branch below rewrites .tab-name's innerHTML, which destroys the inline rename
// <input> mid-keystroke. Guarding only the scheduler is not enough: a render
// debounced just BEFORE the rename opened still fires ~100ms later and lands
// here directly. finishRename() re-renders on both commit and cancel, so a
// render dropped here is picked back up when the rename settles.
if (this._inlineRenameActive) return;
const container = this.$('sessionTabs');
const existingTabs = container.querySelectorAll('.session-tab[data-id]');
const existingIds = new Set([...existingTabs].map(t => t.dataset.id));
@@ -3443,6 +3460,13 @@ class CodemanApp {
}
this.updateTabOverflowMode();
// After the wrap measurement: the `unroll` style starts tabs at max-width 0,
// so measuring mid-animation would decide the wrap on collapsed widths.
this._applyTabEntrances?.();
// Phone overview rides on this one call: every state change it cares about
// (create, delete, idle, working, exit, hook alerts via updateTabAlertFromHooks)
// already funnels through here. No-ops unless that surface is showing.
this._refreshMobileOverviewIfVisible?.();
}
// Auto-wrap desktop session tabs to a second row when they overflow one row,
@@ -3532,7 +3556,7 @@ class CodemanApp {
<span class="tab-status ${status}" aria-hidden="true"></span>
<span class="tab-info">
<span class="tab-name-row">
${mode === 'shell' ? '<span class="tab-mode shell" aria-hidden="true">sh</span>' : mode === 'opencode' ? '<span class="tab-mode opencode" aria-hidden="true">oc</span>' : mode === 'codex' ? '<span class="tab-mode codex" aria-hidden="true">cx</span>' : mode === 'gemini' ? '<span class="tab-mode gemini" aria-hidden="true">gm</span>' : ''}
${mode === 'shell' ? '<span class="tab-mode shell" aria-hidden="true">sh</span>' : mode === 'opencode' ? '<span class="tab-mode opencode" aria-hidden="true">oc</span>' : mode === 'codex' ? '<span class="tab-mode codex" aria-hidden="true">cx</span>' : mode === 'gemini' ? '<span class="tab-mode gemini" aria-hidden="true">gm</span>' : mode === 'antigravity' ? '<span class="tab-mode antigravity" aria-hidden="true">ag</span>' : ''}
<span class="tab-name" data-session-id="${id}">${(() => { const p = parseSessionPrefix(name); return p && p.suffix ? '<span class="tab-prefix">' + escapeHtml(p.prefix) + '</span><span class="tab-suffix">: ' + escapeHtml(p.suffix) + '</span>' : escapeHtml(name); })()}</span>
<span class="tab-detached-badge" aria-hidden="true">detached</span>
</span>
@@ -3569,6 +3593,9 @@ class CodemanApp {
// toggle (applyTabWrapSettings calls this) which would otherwise leave a stale
// tabs-auto-wrap class until the next content render.
this.updateTabOverflowMode();
// Newly created tabs animate in; a re-render mid-cascade resumes them rather
// than restarting, since this rebuild just destroyed the animating elements.
this._applyTabEntrances?.();
}
// Set up arrow key navigation for session tabs (accessibility)
@@ -4100,6 +4127,10 @@ class CodemanApp {
// selectSession or reconnect catches up.
this._updateSseSubscription(sessionId);
this.hideWelcome();
// Terminal-pane entrance: plays for a freshly created session, and on every
// switch when that option is on. Transform/opacity/clip-path only, xterm's
// FitAddon reads the untransformed layout box, so this cannot reach the PTY.
this.playTerminalEntrance?.(sessionId);
// Clear idle hooks on view, but keep action hooks until user interacts
this.clearPendingHooks(sessionId, 'idle_prompt');
// Instant active-class toggle (no 100ms debounce), then schedule full render for badges/status
@@ -4610,7 +4641,9 @@ class CodemanApp {
? 'Kill Tmux & Codex'
: session.mode === 'gemini'
? 'Kill Tmux & Gemini'
: 'Kill Tmux & Claude Code';
: session.mode === 'antigravity'
? 'Kill Tmux & Antigravity'
: 'Kill Tmux & Claude Code';
}
document.getElementById('closeConfirmModal').classList.add('active');
+754
View File
@@ -0,0 +1,754 @@
/**
* @fileoverview Entrance animations for the four things that appear when work
* starts: session TABS, the main TERMINAL pane a session's CLI runs in, floating
* agent WINDOWS, and the CONNECTION LINES tying a window back to its parent tab.
* One picker per surface, plus themes that set all four to a matching look.
*
* Everything is OFF by default (the `legacy` theme), so an untouched install
* behaves exactly as it did before this module existed. Opt in via App Settings
* → Appearance → Entrance Animations.
*
* Four constraints shape the design:
*
* 1. `_fullRenderSessionTabs()` replaces the tab strip's entire innerHTML, and
* `_updateConnectionLinesImmediate()` does `svg.innerHTML = ''` and rebuilds
* every path. Both run constantly while sessions and agents are spawning, so
* an animating tab or line element is DESTROYED mid-flight. Those two are
* therefore tracked by id in `_tabEnterActive` / `_lineEnterActive` and
* re-applied to the fresh element with a NEGATIVE animation-delay, resuming at
* the same offset instead of restarting or snapping to the end. Windows are
* stable DOM and need none of this.
* 2. Connection-line geometry comes from `getBoundingClientRect()` on the window.
* A window entrance that starts with a transform would move that rect, so the
* `beam` style (which must hold still while its line draws toward it) animates
* opacity and filter only. Every other style refreshes the lines when it ends.
* 3. The terminal pane is ONE shared element, so its entrance is marked at
* session creation but played at selection: a session created in the
* background must not animate the pane the user is currently looking at. Its
* styles are also restricted to transform/opacity/clip-path (see below).
* 4. Nothing may animate on page load or reconnect replay. Only ids that pass
* through `markSessionTabEntering()` animate, and `_tabEnterSeen` makes that
* once-per-id even though the POST response and the SSE event both call
* `_onSessionCreated`.
*
* Styles are selected by `data-tab-anim` / `data-term-anim` / `data-win-anim` /
* `data-line-anim` on <html>; the keyframes live in styles.css. `?animlab=1`
* opens a floating picker that fakes tabs, a pane replay, a window and a line,
* so styles can be compared without spawning real sessions or agents.
*
* @mixin Extends CodemanApp.prototype via Object.assign
* @dependency app.js (tab render pipeline), subagent-windows.js (window + line hooks)
* @dependency constants.js (escapeHtml)
* @loadorder 12.6 of 16, after webview-tabs.js, before ralph-wizard.js
*/
/** Tab entrance styles. `key` doubles as the `data-tab-anim` value. */
const TAB_ANIM_STYLES = [
{ key: 'slide', label: 'Slide', blurb: 'Drifts in from the right.', duration: 380 },
{ key: 'pop', label: 'Pop', blurb: 'Springs past full size, then settles.', duration: 460 },
{ key: 'crt', label: 'CRT', blurb: 'Snaps open as a hot line, then unfolds.', duration: 520 },
{ key: 'unroll', label: 'Unroll', blurb: 'The strip makes room and the tab widens in.', duration: 480 },
{ key: 'boot', label: 'Boot', blurb: 'Flickers on under a green scan sweep.', duration: 720 },
{ key: 'flip', label: 'Flip', blurb: 'Drops in as a card hinged on its top edge.', duration: 520 },
{ key: 'off', label: 'Off', blurb: 'Tabs just appear.', duration: 0 },
];
/**
* Window entrance styles. `fly` is the pre-existing behaviour (the window flies
* out of its parent tab via a JS transition in subagent-windows.js); every other
* style positions the window at its resting spot and runs a CSS animation there.
*/
const WIN_ANIM_STYLES = [
{ key: 'fly', label: 'Fly from tab', blurb: 'Current behaviour: flies out of the tab, scaling up.', duration: 400 },
{ key: 'crt', label: 'CRT', blurb: 'Bursts open as a hot line, then unfolds vertically.', duration: 560 },
{ key: 'materialize', label: 'Materialize', blurb: 'Resolves out of a blur with a short glitch.', duration: 620 },
{ key: 'unfold', label: 'Unfold', blurb: 'Hinges down from its top edge in 3D.', duration: 560 },
{ key: 'beam', label: 'Beam down', blurb: 'Waits for its line to reach it, then materializes.', duration: 620 },
{ key: 'pop', label: 'Pop', blurb: 'Springs open from its centre.', duration: 460 },
{ key: 'off', label: 'Off', blurb: 'Windows just appear.', duration: 0 },
];
/** Connection-line entrance styles. `key` doubles as the `data-line-anim` value. */
const LINE_ANIM_STYLES = [
{ key: 'draw', label: 'Draw', blurb: 'Draws itself from the tab down to the window.', duration: 420 },
{ key: 'packet', label: 'Packet', blurb: 'Line fades in, then a bright packet runs down it.', duration: 700 },
{ key: 'fade', label: 'Fade', blurb: 'Simply fades in.', duration: 300 },
{ key: 'off', label: 'Off', blurb: 'Lines just appear.', duration: 0 },
];
/**
* Main-terminal entrance styles: the pane a session's CLI actually runs in.
*
* ⚠ These may only animate transform, opacity and clip-path. xterm's FitAddon
* derives rows/cols from `getComputedStyle(parent).width/height`, which reports
* the untransformed layout box, so transforms are safe, but animating width,
* height or padding would feed wrong dimensions into `resize()` and through to
* the PTY. Colour washes go on `.terminal-container::before`, never a `filter`
* on the container: that would blur a full-screen WebGL canvas every frame.
*/
const TERM_ANIM_STYLES = [
{ key: 'crt', label: 'CRT', blurb: 'Power-on: a hot line that expands to full height.', duration: 560 },
{ key: 'boot', label: 'Boot', blurb: 'Flickers on under a green scan sweep.', duration: 760 },
{ key: 'wipe', label: 'Wipe', blurb: 'Reveals top-to-bottom behind a bright edge.', duration: 520 },
{ key: 'slide', label: 'Slide up', blurb: 'Rises into place from below.', duration: 420 },
{ key: 'fade', label: 'Fade', blurb: 'Quiet fade with a touch of scale.', duration: 340 },
{ key: 'off', label: 'Off', blurb: 'Current behaviour: the pane just appears.', duration: 0 },
];
/** How long a `beam` window waits before materializing. Just under the line draw. */
const BEAM_HOLD_MS = 360;
/** One-click combinations that read as a single look. */
const ANIM_THEMES = [
{ key: 'terminal', label: 'Terminal', tab: 'crt', win: 'crt', line: 'draw', term: 'crt' },
{ key: 'beamdown', label: 'Beam down', tab: 'crt', win: 'beam', line: 'draw', term: 'wipe' },
{ key: 'quiet', label: 'Quiet', tab: 'slide', win: 'materialize', line: 'fade', term: 'fade' },
{ key: 'playful', label: 'Playful', tab: 'pop', win: 'pop', line: 'packet', term: 'slide' },
{ key: 'legacy', label: 'Legacy', tab: 'off', win: 'fly', line: 'off', term: 'off' },
];
/**
* Defaults are the `legacy` theme: every entrance OFF, and agent windows on the
* `fly` behaviour Codeman already had before this module existed. So a user who
* never opens the picker sees exactly the pre-existing UI, and each mark/apply
* hook short-circuits on its first line. Opt in via App Settings → Appearance →
* Entrance Animations, which persists to the localStorage keys below.
*/
const TAB_ANIM_DEFAULT = 'off';
const WIN_ANIM_DEFAULT = 'fly';
const LINE_ANIM_DEFAULT = 'off';
const TERM_ANIM_DEFAULT = 'off';
const TAB_ANIM_STAGGER_DEFAULT = 90;
/** A new id joins the current cascade if it arrives within this of the last one. */
const TAB_ANIM_BATCH_WINDOW_MS = 600;
const ANIM_KEYS = {
tab: 'codeman:tabAnim',
win: 'codeman:winAnim',
line: 'codeman:lineAnim',
term: 'codeman:termAnim',
termSwitch: 'codeman:termAnimOnSwitch',
stagger: 'codeman:tabAnimStagger',
speed: 'codeman:tabAnimSpeed',
};
Object.assign(CodemanApp.prototype, {
// ── Setup ─────────────────────────────────────────────────────────────────
/** Resolve every style/timing and stamp them on <html>. Called once at startup. */
initEntranceAnimations() {
this._tabEnterSeen = new Set();
this._tabEnterActive = new Map();
this._lineEnterActive = new Map();
this._tabEnterBatchIndex = 0;
this._tabEnterLastMarkTs = 0;
const params = new URLSearchParams(location.search);
// ?tabanim= / ?winanim= / ?lineanim= win for a single load, so a style can be
// tried without persisting over whatever is saved.
const pick = (param, styles, storeKey, fallback) => {
const fromUrl = params.get(param);
if (styles.some((s) => s.key === fromUrl)) return fromUrl;
return this._animRead(storeKey, fallback);
};
this.setTabAnimStyle(pick('tabanim', TAB_ANIM_STYLES, ANIM_KEYS.tab, TAB_ANIM_DEFAULT), { persist: false });
this.setWinAnimStyle(pick('winanim', WIN_ANIM_STYLES, ANIM_KEYS.win, WIN_ANIM_DEFAULT), { persist: false });
this.setLineAnimStyle(pick('lineanim', LINE_ANIM_STYLES, ANIM_KEYS.line, LINE_ANIM_DEFAULT), { persist: false });
this.setTermAnimStyle(pick('termanim', TERM_ANIM_STYLES, ANIM_KEYS.term, TERM_ANIM_DEFAULT), { persist: false });
this.setTermAnimOnSwitch(this._animRead(ANIM_KEYS.termSwitch, '0') === '1', { persist: false });
this.setTabAnimStagger(Number(this._animRead(ANIM_KEYS.stagger, TAB_ANIM_STAGGER_DEFAULT)), { persist: false });
this.setAnimSpeed(Number(this._animRead(ANIM_KEYS.speed, 1)), { persist: false });
if (params.get('animlab') === '1' || params.get('tabanimlab') === '1') this.openAnimLab();
},
_animRead(key, fallback) {
try {
const raw = localStorage.getItem(key);
return raw === null || raw === '' ? fallback : raw;
} catch {
return fallback;
}
},
_animWrite(key, value) {
try {
localStorage.setItem(key, String(value));
} catch {
/* private mode / quota, the in-memory value still applies for this load */
}
},
_setAnimStyle(prop, key, styles, fallback, attr, storeKey, persist) {
const style = styles.some((s) => s.key === key) ? key : fallback;
this[prop] = style;
document.documentElement.setAttribute(attr, style);
if (persist) this._animWrite(storeKey, style);
// Keep the lab's highlight honest when a style is set from anywhere other
// than the lab's own buttons (a theme, a URL param, the console).
this._syncAnimLab?.();
},
setTabAnimStyle(key, { persist = true } = {}) {
// prettier-ignore
this._setAnimStyle('_tabAnimStyle', key, TAB_ANIM_STYLES, TAB_ANIM_DEFAULT, 'data-tab-anim', ANIM_KEYS.tab, persist);
},
setWinAnimStyle(key, { persist = true } = {}) {
// prettier-ignore
this._setAnimStyle('_winAnimStyle', key, WIN_ANIM_STYLES, WIN_ANIM_DEFAULT, 'data-win-anim', ANIM_KEYS.win, persist);
},
setLineAnimStyle(key, { persist = true } = {}) {
// prettier-ignore
this._setAnimStyle('_lineAnimStyle', key, LINE_ANIM_STYLES, LINE_ANIM_DEFAULT, 'data-line-anim', ANIM_KEYS.line, persist);
},
setTermAnimStyle(key, { persist = true } = {}) {
// prettier-ignore
this._setAnimStyle('_termAnimStyle', key, TERM_ANIM_STYLES, TERM_ANIM_DEFAULT, 'data-term-anim', ANIM_KEYS.term, persist);
},
/** Replay the terminal entrance on every tab switch, not just on a new session. */
setTermAnimOnSwitch(on, { persist = true } = {}) {
this._termAnimOnSwitch = !!on;
if (persist) this._animWrite(ANIM_KEYS.termSwitch, on ? '1' : '0');
this._syncAnimLab?.();
},
/** Apply a theme: one look across tabs, windows, lines and the terminal. */
setAnimTheme(themeKey) {
const theme = ANIM_THEMES.find((t) => t.key === themeKey);
if (!theme) return;
this.setTabAnimStyle(theme.tab);
this.setWinAnimStyle(theme.win);
this.setLineAnimStyle(theme.line);
this.setTermAnimStyle(theme.term);
this._syncEntranceAnimSetting?.();
},
/** The theme matching the four current styles, or 'custom' for a lab mix. */
currentAnimTheme() {
const match = ANIM_THEMES.find(
(t) =>
t.tab === this._tabAnimStyle &&
t.win === this._winAnimStyle &&
t.line === this._lineAnimStyle &&
t.term === this._termAnimStyle
);
return match ? match.key : 'custom';
},
// ── App Settings picker ───────────────────────────────────────────────────
//
// Wired straight to setAnimTheme() rather than through saveAppSettings(): the
// styles live in their own localStorage keys, so they stay per-device and never
// reach `PUT /api/settings`, whose schema is .strict() and would reject them.
_syncEntranceAnimSetting() {
const sel = document.getElementById('appSettingsEntranceAnim');
if (!sel) return;
sel.value = this.currentAnimTheme();
if (!sel.dataset.bound) {
sel.dataset.bound = '1';
sel.addEventListener('change', () => {
// 'custom' is a readout of a lab mix, not something you can select into.
if (sel.value === 'custom') sel.value = this.currentAnimTheme();
else this.setAnimTheme(sel.value);
});
}
},
setTabAnimStagger(ms, { persist = true } = {}) {
const value = Number.isFinite(ms) ? Math.max(0, Math.min(400, Math.round(ms))) : TAB_ANIM_STAGGER_DEFAULT;
this._tabAnimStagger = value;
if (persist) this._animWrite(ANIM_KEYS.stagger, value);
},
setAnimSpeed(multiplier, { persist = true } = {}) {
const value = Number.isFinite(multiplier) ? Math.max(0.25, Math.min(3, multiplier)) : 1;
this._animSpeed = value;
// Every keyframe block reads this, so one variable retimes all of them.
document.documentElement.style.setProperty('--anim-enter-scale', String(1 / value));
if (persist) this._animWrite(ANIM_KEYS.speed, value);
},
_styleDuration(styles, key) {
return (styles.find((s) => s.key === key)?.duration || 0) / (this._animSpeed || 1);
},
_tabAnimDuration() {
return this._styleDuration(TAB_ANIM_STYLES, this._tabAnimStyle);
},
_winAnimDuration() {
return this._styleDuration(WIN_ANIM_STYLES, this._winAnimStyle);
},
_lineAnimDuration() {
return this._styleDuration(LINE_ANIM_STYLES, this._lineAnimStyle);
},
_termAnimDuration() {
return this._styleDuration(TERM_ANIM_STYLES, this._termAnimStyle);
},
// ── Tabs ──────────────────────────────────────────────────────────────────
/** Queue a session id to animate on its next render. Idempotent per id. */
markSessionTabEntering(id) {
if (!id || this._tabAnimStyle === 'off') return;
if (!this._tabEnterSeen) return; // initEntranceAnimations() has not run yet
if (this._tabEnterSeen.has(id)) return;
this._tabEnterSeen.add(id);
const now = performance.now();
// A launch landing well after the previous one starts its own cascade rather
// than inheriting a large stale offset.
if (now - this._tabEnterLastMarkTs > TAB_ANIM_BATCH_WINDOW_MS) this._tabEnterBatchIndex = 0;
this._tabEnterLastMarkTs = now;
this._tabEnterActive.set(id, {
startTs: now,
staggerMs: this._tabEnterBatchIndex * this._tabAnimStagger,
});
this._tabEnterBatchIndex += 1;
},
/** Attach (or resume) the entrance animation on freshly rendered tabs. */
_applyTabEntrances() {
const active = this._tabEnterActive;
if (!active || active.size === 0) return;
if (this._tabAnimStyle === 'off') {
active.clear();
return;
}
const container = this.$('sessionTabs');
if (!container) return;
const now = performance.now();
const duration = this._tabAnimDuration();
for (const [id, state] of active) {
const elapsed = now - state.startTs;
// Ran to completion while the element was detached, nothing left to show.
if (elapsed > state.staggerMs + duration + 50) {
active.delete(id);
continue;
}
const tab = container.querySelector(`.session-tab[data-id="${CSS.escape(id)}"]`);
if (!tab) continue; // not rendered yet; a later render picks it up
// Already running on this element. Re-stamping the delay would jump it, and
// the incremental render path can reach here for the same element.
if (tab.classList.contains('tab-enter')) continue;
// Negative delay resumes mid-animation, so an unrelated re-render mid-cascade
// does not restart the tab or make it snap.
tab.style.setProperty('--tab-enter-delay', `${state.staggerMs - elapsed}ms`);
tab.classList.add('tab-enter');
const done = () => {
tab.classList.remove('tab-enter');
tab.style.removeProperty('--tab-enter-delay');
this._tabEnterActive?.delete(id);
};
tab.addEventListener('animationend', done, { once: true });
tab.addEventListener('animationcancel', done, { once: true });
}
},
// ── Main terminal ─────────────────────────────────────────────────────────
/**
* Queue the terminal pane to animate the first time this session is shown.
* Marked at creation but PLAYED at selection, because the pane is one shared
* element: a session created in the background must not animate the pane the
* user is currently looking at.
*/
markTerminalEntering(id) {
if (!id || this._termAnimStyle === 'off') return;
if (!this._termEnterPending) this._termEnterPending = new Set();
this._termEnterPending.add(id);
},
/** Play the terminal entrance for `sessionId`, if it is owed one. */
playTerminalEntrance(sessionId) {
const style = this._termAnimStyle || TERM_ANIM_DEFAULT;
if (style === 'off') return;
const owed = sessionId && this._termEnterPending?.delete(sessionId);
if (!owed && !this._termAnimOnSwitch) return;
const el = this.$('terminalContainer');
if (!el) return;
// Restart cleanly when switching tabs faster than the animation runs.
el.classList.remove('term-enter');
void el.offsetWidth;
el.classList.add('term-enter');
clearTimeout(this._termEnterTimer);
const done = () => {
el.classList.remove('term-enter');
clearTimeout(this._termEnterTimer);
};
el.addEventListener('animationend', done, { once: true });
el.addEventListener('animationcancel', done, { once: true });
// Backstop: a backgrounded tab never fires animationend, which would leave
// the pane stuck at its 0% keyframe (invisible) when you come back to it.
this._termEnterTimer = setTimeout(done, this._termAnimDuration() + 900);
},
// ── Windows ───────────────────────────────────────────────────────────────
/**
* True when the window should be parked on its parent tab and flown to its
* resting spot by subagent-windows.js. False for every CSS-animated style,
* which needs the window to start at its final position.
*/
windowEntranceFliesFromTab() {
return (this._winAnimStyle || WIN_ANIM_DEFAULT) === 'fly';
},
/**
* Run the window entrance on an already-positioned window.
* @param {HTMLElement} win a .subagent-window or .ultracode-window
*/
applyWindowEntrance(win) {
if (!win) return;
const style = this._winAnimStyle || WIN_ANIM_DEFAULT;
if (style === 'off' || style === 'fly') return;
// `beam` holds the window still (opacity/filter only) while its line draws
// toward it, then materializes. Everything else starts immediately.
if (style === 'beam') win.style.setProperty('--win-enter-delay', `${BEAM_HOLD_MS / (this._animSpeed || 1)}ms`);
win.classList.add('win-enter');
const done = () => {
win.classList.remove('win-enter');
win.style.removeProperty('--win-enter-delay');
// A transformed window reports a transformed rect, so lines drawn while it
// was animating are slightly off. Redraw once it has settled.
this.updateConnectionLines?.();
};
win.addEventListener('animationend', done, { once: true });
win.addEventListener('animationcancel', done, { once: true });
},
// ── Connection lines ──────────────────────────────────────────────────────
/** Queue an agent's connection line to draw itself on the next line rebuild. */
markConnectionLineEntering(agentId) {
if (!agentId || this._lineAnimStyle === 'off') return;
if (!this._lineEnterActive) return;
if (this._lineEnterActive.has(agentId)) return;
this._lineEnterActive.set(agentId, { startTs: performance.now() });
},
/**
* Attach (or resume) the draw-in animation on freshly rebuilt paths. Called at
* the end of `_updateConnectionLinesImmediate()`, which has just thrown away
* and recreated every path element.
*/
_applyLineEntrances(svg) {
const active = this._lineEnterActive;
if (!active || active.size === 0 || !svg) return;
const style = this._lineAnimStyle || LINE_ANIM_DEFAULT;
if (style === 'off') {
active.clear();
return;
}
const now = performance.now();
const duration = this._lineAnimDuration();
for (const [agentId, state] of active) {
const elapsed = now - state.startTs;
if (elapsed > duration + 50) {
active.delete(agentId);
continue;
}
const path = svg.querySelector(`path[data-agent-id="${CSS.escape(agentId)}"]`);
if (!path) continue;
const len = Math.max(1, Math.round(path.getTotalLength()));
path.style.setProperty('--line-len', `${len}px`);
path.style.setProperty('--line-enter-delay', `${-elapsed}ms`);
path.classList.add('line-enter');
// `packet` rides a bright dash ON TOP of the normal dashed line, so the base
// line keeps its look instead of being taken over by the animation.
if (style === 'packet') {
const packet = path.cloneNode(false);
packet.removeAttribute('data-agent-id');
packet.setAttribute('class', 'connection-line-packet');
packet.style.setProperty('--line-len', `${len}px`);
packet.style.setProperty('--line-enter-delay', `${-elapsed}ms`);
svg.appendChild(packet);
}
}
},
// ── Lab (compare styles without spawning sessions or agents) ───────────────
/** Floating picker: switch styles per surface and replay fake entrances. */
openAnimLab() {
if (document.getElementById('animLab')) return;
const group = (title, styles, attr) => `
<div class="anim-lab-group">
<div class="anim-lab-group-title">${title}</div>
${styles
.map(
(s) => `<button type="button" class="anim-lab-style" data-attr="${attr}" data-style="${s.key}">
<strong>${escapeHtml(s.label)}</strong><em>${escapeHtml(s.blurb)}</em>
</button>`
)
.join('')}
</div>`;
const panel = document.createElement('div');
panel.id = 'animLab';
panel.className = 'anim-lab';
panel.innerHTML = `
<div class="anim-lab-head">
<span>Entrance lab</span>
<button type="button" class="anim-lab-close" aria-label="Close">&times;</button>
</div>
<div class="anim-lab-themes">
${ANIM_THEMES.map((t) => `<button type="button" data-theme="${t.key}">${escapeHtml(t.label)}</button>`).join('')}
</div>
<div class="anim-lab-scroll">
${group('Tabs', TAB_ANIM_STYLES, 'tab')}
${group('Terminal pane', TERM_ANIM_STYLES, 'term')}
<label class="anim-lab-check">
<input type="checkbox" data-check="termSwitch"> Also on every tab switch
</label>
${group('Agent windows', WIN_ANIM_STYLES, 'win')}
${group('Connection lines', LINE_ANIM_STYLES, 'line')}
</div>
<label class="anim-lab-range">Tab stagger <output data-out="stagger"></output>
<input type="range" data-range="stagger" min="0" max="260" step="10">
</label>
<label class="anim-lab-range">Speed <output data-out="speed"></output>
<input type="range" data-range="speed" min="0.5" max="2" step="0.1">
</label>
<div class="anim-lab-demo">
<span>Replay</span>
<button type="button" data-demo="tabs">Tabs</button>
<button type="button" data-demo="term">Pane</button>
<button type="button" data-demo="window">Window</button>
<button type="button" data-demo="all">All</button>
</div>
<p class="anim-lab-hint">Fake tabs, window and line, removed after the run. Real launches use the same timing.</p>
`;
document.body.appendChild(panel);
panel.querySelector('.anim-lab-close').addEventListener('click', () => this.closeAnimLab());
panel.querySelectorAll('button[data-theme]').forEach((btn) => {
btn.addEventListener('click', () => {
this.setAnimTheme(btn.dataset.theme);
this._syncAnimLab();
this.demoEntrance('all');
});
});
panel.querySelectorAll('.anim-lab-style').forEach((btn) => {
btn.addEventListener('click', () => {
const { attr, style } = btn.dataset;
if (attr === 'tab') this.setTabAnimStyle(style);
else if (attr === 'win') this.setWinAnimStyle(style);
else if (attr === 'term') this.setTermAnimStyle(style);
else this.setLineAnimStyle(style);
this._syncAnimLab();
this.demoEntrance({ tab: 'tabs', term: 'term' }[attr] || 'all');
});
});
panel.querySelector('input[data-check="termSwitch"]').addEventListener('change', (e) => {
this.setTermAnimOnSwitch(e.target.checked);
});
panel.querySelectorAll('input[data-range]').forEach((input) => {
input.addEventListener('input', () => {
if (input.dataset.range === 'stagger') this.setTabAnimStagger(Number(input.value));
else this.setAnimSpeed(Number(input.value));
this._syncAnimLab();
});
input.addEventListener('change', () => this.demoEntrance('all'));
});
panel.querySelectorAll('button[data-demo]').forEach((btn) => {
btn.addEventListener('click', () => this.demoEntrance(btn.dataset.demo));
});
this._syncAnimLab();
},
closeAnimLab() {
document.getElementById('animLab')?.remove();
this._clearEntranceDemo();
},
_syncAnimLab() {
const panel = document.getElementById('animLab');
if (!panel) return;
const current = {
tab: this._tabAnimStyle,
win: this._winAnimStyle,
line: this._lineAnimStyle,
term: this._termAnimStyle,
};
panel.querySelectorAll('.anim-lab-style').forEach((btn) => {
btn.classList.toggle('selected', current[btn.dataset.attr] === btn.dataset.style);
});
panel.querySelectorAll('button[data-theme]').forEach((btn) => {
const t = ANIM_THEMES.find((x) => x.key === btn.dataset.theme);
btn.classList.toggle('selected', !!t && ['tab', 'win', 'line', 'term'].every((k) => t[k] === current[k]));
});
const check = panel.querySelector('input[data-check="termSwitch"]');
if (check) check.checked = !!this._termAnimOnSwitch;
panel.querySelector('input[data-range="stagger"]').value = String(this._tabAnimStagger);
panel.querySelector('output[data-out="stagger"]').textContent = `${this._tabAnimStagger}ms`;
panel.querySelector('input[data-range="speed"]').value = String(this._animSpeed);
panel.querySelector('output[data-out="speed"]').textContent = `${this._animSpeed.toFixed(1)}x`;
},
_clearEntranceDemo() {
clearTimeout(this._animDemoTimer);
clearTimeout(this._animDemoWindowTimer);
document.querySelectorAll('.session-tab[data-demo]').forEach((el) => el.remove());
document.querySelectorAll('.subagent-window[data-demo]').forEach((el) => el.remove());
document.getElementById('animLabLines')?.remove();
},
/**
* The demo line gets its OWN svg overlay rather than sharing #connectionLines.
* That overlay is rebuilt from real windows via `svg.innerHTML = ''`, so a fake
* path dropped into it is erased the moment anything triggers a redraw -
* including the demo window's own entrance finishing.
*/
_demoLineSvg() {
let svg = document.getElementById('animLabLines');
if (!svg) {
svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg');
svg.id = 'animLabLines';
svg.setAttribute('class', 'connection-lines-svg');
document.body.appendChild(svg);
}
return svg;
},
/** @param {'tabs'|'term'|'window'|'all'} what */
demoEntrance(what = 'all') {
this._clearEntranceDemo();
// The pane is a real, shared element rather than a throwaway, so replay it
// through the same entry point a real launch uses (bypassing the owed-id
// check, which only exists to keep background sessions from hijacking it).
if (what === 'term' || what === 'all') {
const wasOnSwitch = this._termAnimOnSwitch;
this._termAnimOnSwitch = true;
this.playTerminalEntrance(null);
this._termAnimOnSwitch = wasOnSwitch;
}
if (what === 'term') return;
const tabCount = what === 'window' ? 1 : 4;
const tabs = this._demoTabs(tabCount);
let total = Math.max(this._tabAnimDuration() + tabCount * this._tabAnimStagger, this._termAnimDuration());
if (what !== 'tabs') {
// Give the tab cascade a beat, so the window reads as coming out of it.
const lead = what === 'all' ? Math.min(320, this._tabAnimStagger * 2) : 0;
this._animDemoWindowTimer = setTimeout(() => this._demoWindow(tabs[0]), lead);
total = Math.max(total, lead + this._winAnimDuration() + this._lineAnimDuration() + BEAM_HOLD_MS);
}
this._animDemoTimer = setTimeout(() => this._clearEntranceDemo(), total + 1800);
},
/** Append `count` throwaway tabs and run the tab entrance on them. */
_demoTabs(count) {
const container = this.$('sessionTabs');
if (!container) return [];
const made = [];
const base = this.sessions?.size || 0;
for (let i = 0; i < count; i++) {
const tab = document.createElement('div');
tab.className = 'session-tab';
tab.dataset.demo = '1';
tab.dataset.color = ['green', 'blue', 'purple', 'orange', 'pink', 'yellow', 'red'][i % 7];
tab.innerHTML = `
<span class="tab-number">${base + i + 1}</span>
<span class="tab-status idle" aria-hidden="true"></span>
<span class="tab-info"><span class="tab-name-row">
<span class="tab-name">w${base + i + 1}-demo</span>
</span></span>`;
container.appendChild(tab);
made.push(tab);
if (this._tabAnimStyle !== 'off') {
tab.style.setProperty('--tab-enter-delay', `${i * this._tabAnimStagger}ms`);
void tab.offsetWidth; // force layout so the class add starts a fresh run
tab.classList.add('tab-enter');
}
}
return made;
},
/** Append a throwaway agent window under `originTab`, plus its connection line. */
_demoWindow(originTab) {
const tabRect = originTab?.getBoundingClientRect();
const win = document.createElement('div');
win.className = 'subagent-window';
win.dataset.demo = '1';
win.style.width = '360px';
win.style.height = '220px';
win.style.left = `${Math.max(24, (tabRect?.left ?? 120) - 40)}px`;
win.style.top = `${(tabRect?.bottom ?? 60) + 160}px`;
win.style.zIndex = '1001';
win.innerHTML = `
<div class="subagent-window-header">
<div class="subagent-window-title"><span class="icon">&#129302;</span><span class="id">demo-agent</span>
<span class="status running">running</span></div>
</div>
<div class="subagent-window-body"><div class="subagent-empty">Preview window</div></div>`;
document.body.appendChild(win);
this.applyWindowEntrance(win);
this._demoLine(tabRect, win);
},
/** Draw a fake tab→window line into the lab's own overlay and animate it. */
_demoLine(tabRect, win) {
if (!tabRect) return;
const svg = this._demoLineSvg();
const winRect = win.getBoundingClientRect();
const x1 = tabRect.left + tabRect.width / 2;
const y1 = tabRect.bottom;
const x2 = winRect.left + winRect.width / 2;
const y2 = winRect.top;
const midY = (y1 + y2) / 2;
const path = document.createElementNS('http://www.w3.org/2000/svg', 'path');
path.setAttribute('d', `M ${x1} ${y1} C ${x1} ${midY}, ${x2} ${midY}, ${x2} ${y2}`);
path.setAttribute('class', 'connection-line');
path.dataset.demo = '1';
svg.appendChild(path);
if ((this._lineAnimStyle || LINE_ANIM_DEFAULT) === 'off') return;
const len = Math.max(1, Math.round(path.getTotalLength()));
path.style.setProperty('--line-len', `${len}px`);
path.style.setProperty('--line-enter-delay', '0ms');
path.classList.add('line-enter');
if (this._lineAnimStyle === 'packet') {
const packet = path.cloneNode(false);
packet.setAttribute('class', 'connection-line-packet');
packet.dataset.demo = '1';
packet.style.setProperty('--line-len', `${len}px`);
packet.style.setProperty('--line-enter-delay', '0ms');
svg.appendChild(packet);
}
},
});
+21
View File
@@ -103,6 +103,7 @@
'Run Claude Code': '运行 Claude Code',
'Run OpenCode': '运行 OpenCode',
'Run Gemini': '运行 Gemini',
'Run Antigravity': '运行 Antigravity',
'Run Shell': '运行 Shell',
'Select AI backend': '选择 AI 后端',
'Create New Case': '新建案例',
@@ -349,6 +350,26 @@
'Show Shortcuts': '显示快捷键',
'Full shortcut reference': '完整快捷键参考',
// Mobile overview (phone home screen)
'Needs you': '需要你',
'Current sessions': '当前会话',
'Past sessions': '历史会话',
'Show all past sessions': '显示全部历史会话',
'Show fewer': '收起',
'Choose what to run': '选择运行方式',
'Web / URL': '网页 / 链接',
'Add URL…': '添加链接…',
'Nothing running. Hit Run to start something.': '当前没有运行中的会话。点击“运行”开始。',
'No past conversations yet': '尚无历史对话',
'Loading…': '加载中…',
// Status pills are deliberately NOT listed: they are single generic words
// ("idle", "done", "error") that also appear as state strings elsewhere, so
// they carry data-i18n-skip in the DOM instead of a translation entry here.
'Overview Home Screen': '概览主页',
'On phones, the C logo opens a session overview (needs you / spaces / idle) instead of the welcome screen':
'在手机上,点击 C 图标打开会话概览(需要你 / 空间 / 空闲),而不是欢迎页',
Phone: '手机',
// Session/case dialogs
'Session Options': '会话选项',
'Session Name': '会话名称',
+37 -1
View File
@@ -379,6 +379,12 @@
<button class="welcome-ralph-link" onclick="app.showRalphWizard()">Start Ralph Loop &rarr;</button>
</div>
</div>
<!-- Mobile Overview (phone home screen, shown in place of the welcome
overlay when the "C" logo is tapped). Ships `hidden`: only
mobile-overview.js removes it, and only when the phone gate passes, so
desktop (which never loads mobile.css) can never render it. -->
<div class="mobile-overview" id="mobileOverview" hidden></div>
</main>
<!-- Project Insights Panel (shows file-viewing Bash commands) -->
@@ -464,6 +470,9 @@
<button class="run-mode-option" data-mode="gemini" onclick="app.setRunMode('gemini')">
<span class="run-mode-dot gemini"></span>Gemini
</button>
<button class="run-mode-option" data-mode="antigravity" onclick="app.setRunMode('antigravity')">
<span class="run-mode-dot antigravity"></span>Antigravity
</button>
<div class="run-mode-sep"></div>
<button class="run-mode-option" data-mode="shell" onclick="app.setRunMode('shell')">
<span class="run-mode-dot shell"></span>Terminal / Shell
@@ -733,6 +742,7 @@
<option value="opencode">OpenCode</option>
<option value="codex">Codex</option>
<option value="gemini">Gemini</option>
<option value="antigravity">Antigravity</option>
</select>
</div>
<div class="form-row"><label>Working Directory</label><input type="text" id="schWorkingDir" placeholder="/absolute/path"></div>
@@ -1276,6 +1286,20 @@
</optgroup>
</select>
</div>
<div class="settings-item settings-item-multiline" title="How new session tabs, the terminal pane, agent windows and their connection lines appear. This device only. For per-surface control and a live preview, add ?animlab=1 to the URL.">
<div class="settings-item-text">
<span class="settings-item-label">Entrance Animations</span>
<span class="settings-item-desc">How new tabs, panes and agent windows arrive</span>
</div>
<select id="appSettingsEntranceAnim" class="form-select settings-inline-select">
<option value="legacy">Off (default)</option>
<option value="terminal">Terminal (CRT)</option>
<option value="beamdown">Beam down</option>
<option value="quiet">Quiet</option>
<option value="playful">Playful</option>
<option value="custom">Custom (set in the lab)</option>
</select>
</div>
<div class="settings-item" id="appSettingsWebglRendererItem" title="Use the GPU-accelerated WebGL terminal renderer (desktop only). Turn off to force the DOM renderer if you hit GPU glitches. Codeman also auto-falls-back to the DOM renderer after repeated GPU stalls.">
<span class="settings-item-label">WebGL Renderer</span>
<label class="switch switch-sm">
@@ -1424,6 +1448,16 @@
</label>
</div>
<!-- Phone Section (only meaningful under 430px, hidden elsewhere by settings-ui.js) -->
<div class="settings-section-header" id="appSettingsPhoneSection">Phone</div>
<div class="settings-item" id="appSettingsMobileOverviewItem" title="On phones, the C logo opens a session overview (needs you / spaces / idle) instead of the welcome screen">
<span class="settings-item-label">Overview Home Screen</span>
<label class="switch switch-sm">
<input type="checkbox" id="appSettingsMobileOverview" checked>
<span class="slider"></span>
</label>
</div>
<!-- Tab Bar Section -->
<div class="settings-section-header">Tab Bar</div>
<div class="settings-item" title="Show folder path below tab name and allow tab bar to wrap into multiple rows">
@@ -1848,7 +1882,7 @@
<input type="checkbox" id="eventIdleAudio">
<div class="event-label">Response complete</div>
<input type="checkbox" id="eventStopEnabled" checked>
<input type="checkbox" id="eventStopEnabled">
<input type="checkbox" id="eventStopBrowser">
<input type="checkbox" id="eventStopPush">
<input type="checkbox" id="eventStopAudio">
@@ -2626,6 +2660,8 @@
<script defer src="admin-ui.js"></script>
<script defer src="session-ui.js"></script>
<script defer src="webview-tabs.js"></script>
<script defer src="mobile-overview.js"></script>
<script defer src="entrance-animations.js"></script>
<script defer src="ralph-wizard.js"></script>
<script defer src="api-client.js"></script>
<script defer src="subagent-windows.js"></script>
+684
View File
@@ -0,0 +1,684 @@
/**
* @fileoverview Phone home screen: a scrolling overview of what every session is
* doing, shown instead of the welcome overlay when the "C" logo is tapped.
*
* The welcome screen answers "how do I start something"; on a phone the more
* urgent question is "which of my sessions is blocked on me". This surface
* answers that first: NEEDS YOU (pending permission/question/idle hooks and
* errored sessions), then SPACES (cases, expandable to their sessions), then
* WORKING and IDLE / DONE.
*
* PHONE ONLY. The gate is `shouldUseMobileOverview()` (viewport < 430px, not a
* popped-out solo window, per-device setting on). Tablet and desktop keep the
* welcome overlay untouched. The container ships with the `hidden` attribute and
* only this module removes it, so desktop (which never loads mobile.css) cannot
* render an unstyled overview even if a class rule leaked.
*
* Everything renders from state the page already holds (`this.sessions`,
* `this.cases`, `this.pendingHooks`) — no endpoint, no SSE event, no schema.
* `buildMobileOverviewModel()` is pure and unit-tested (test/mobile-overview.test.ts).
*
* @mixin Extends CodemanApp.prototype via Object.assign
* @dependency app.js (this.sessions, this.cases, this.pendingHooks, selectSession, run)
* @dependency mobile-handlers.js (MobileDetection)
* @dependency session-ui.js (selectQuickStartCase for "New session here")
* @loadorder 12.55 of 16, after webview-tabs.js, before entrance-animations.js
*/
/** Viewport width that counts as a phone. Matches the mobile.css phone block. */
const MOBILE_OVERVIEW_PHONE_QUERY = '(max-width: 430px)';
/** Sort rank per state: the most demanding thing sorts first inside a section. */
const MOBILE_OVERVIEW_STATE_RANK = {
needs: 0,
error: 1,
waiting: 2,
working: 3,
idle: 4,
done: 5,
};
/** How many past conversations show before the "Show all" toggle. */
const MOBILE_OVERVIEW_PAST_LIMIT = 8;
/**
* Backends offered by the Run picker, mirroring the toolbar's run-mode menu
* (`#runModeMenu` in index.html). `short` is the badge on the Run button itself.
*/
const MOBILE_OVERVIEW_RUN_MODES = [
{ mode: 'claude', label: 'Claude Code', short: 'Claude' },
{ mode: 'opencode', label: 'OpenCode', short: 'OpenCode' },
{ mode: 'codex', label: 'Codex', short: 'Codex' },
{ mode: 'gemini', label: 'Gemini', short: 'Gemini' },
{ mode: 'antigravity', label: 'Antigravity', short: 'Antigravity' },
{ mode: 'shell', label: 'Terminal / Shell', short: 'Shell' },
];
/** Pill copy per state. Kept short: a phone row has ~90px for it. */
const MOBILE_OVERVIEW_PILL_LABEL = {
needs: 'needs you',
error: 'error',
waiting: 'waiting',
working: 'working',
idle: 'idle',
done: 'done',
};
Object.assign(CodemanApp.prototype, {
// ═══════════════════════════════════════════════════════════════
// Model (pure)
// ═══════════════════════════════════════════════════════════════
/**
* Classify one session.
* Order matters: an action hook outranks everything (it is literally blocking
* the agent), and a pending idle_prompt outranks a stale 'busy' status because
* the hook is the newer signal.
* @param {object} session session state from this.sessions
* @param {Set<string>|undefined} hooks pending hook types for that session
* @returns {'needs'|'error'|'waiting'|'working'|'idle'|'done'}
*/
_mobileOverviewState(session, hooks) {
if (hooks && (hooks.has('permission_prompt') || hooks.has('elicitation_dialog'))) return 'needs';
if (session.status === 'error') return 'error';
if (hooks && hooks.has('idle_prompt')) return 'waiting';
if (session.status === 'busy') return 'working';
if (session.status === 'stopped') return 'done';
return 'idle';
},
/**
* Longest-prefix match of a workingDir against the case list, so a session
* started in a subdirectory still belongs to its case. Mirrors the matching in
* `_resolveCaseLabel()` (terminal-ui.js) but returns the case itself.
* @returns {object|null} the matching case, or null when the dir is outside every case
*/
_mobileOverviewCaseFor(workingDir, cases) {
if (!workingDir) return null;
let best = null;
for (const c of cases || []) {
if (!c || !c.path) continue;
if (workingDir === c.path) return c;
if (workingDir.startsWith(c.path + '/') && (!best || c.path.length > best.path.length)) {
best = c;
}
}
return best;
},
/**
* Build the whole overview model. PURE: reads only its argument, touches no DOM
* and no `this` state, so it can be unit-tested against plain objects.
*
* @param {object} input
* @param {Map<string, object>|Array} input.sessions live sessions (this.sessions)
* @param {Array} input.cases case list (this.cases)
* @param {Array<string>} [input.sessionOrder] the user's tab order, used as the tiebreak
* @param {Map<string, Set<string>>} [input.pendingHooks] this.pendingHooks
* @param {Array} [input.history] unified session items (GET /api/sessions/unified)
* @returns {{needsYou: Array, current: Array, past: Array, sessionCount: number}}
*/
buildMobileOverviewModel(input) {
const cases = Array.isArray(input && input.cases) ? input.cases : [];
const order = Array.isArray(input && input.sessionOrder) ? input.sessionOrder : [];
const pendingHooks = (input && input.pendingHooks) || new Map();
const raw = (input && input.sessions) || [];
const sessions = typeof raw.values === 'function' ? Array.from(raw.values()) : Array.from(raw);
const rows = sessions.map((session) => {
const matched = this._mobileOverviewCaseFor(session.workingDir, cases);
const state = this._mobileOverviewState(session, pendingHooks.get && pendingHooks.get(session.id));
const orderIndex = order.indexOf(session.id);
return {
id: session.id,
name: this.getSessionName ? this.getSessionName(session) : session.name || session.id.slice(0, 8),
mode: session.mode || 'claude',
caseName: matched ? matched.name : '',
dir: this._shortenHomePath ? this._shortenHomePath(session.workingDir) : session.workingDir || '',
state,
pill: MOBILE_OVERVIEW_PILL_LABEL[state] || state,
orderIndex: orderIndex === -1 ? Number.MAX_SAFE_INTEGER : orderIndex,
};
});
const bySeverityThenOrder = (a, b) => {
const rank = MOBILE_OVERVIEW_STATE_RANK[a.state] - MOBILE_OVERVIEW_STATE_RANK[b.state];
return rank !== 0 ? rank : a.orderIndex - b.orderIndex;
};
const inSection = (states) => rows.filter((r) => states.includes(r.state)).sort(bySeverityThenOrder);
// Past = conversations from the unified list that are not currently live.
// The endpoint already folds a transcript into its owning session (via the
// claudeSessionId alias map), so a plain id check is enough to avoid listing
// a running session twice.
const liveIds = new Set(rows.map((r) => r.id));
const past = (Array.isArray(input && input.history) ? input.history : [])
.filter((item) => item && item.sessionId && !liveIds.has(item.sessionId))
.map((item) => {
const matched = this._mobileOverviewCaseFor(item.workingDir, cases);
const dir = item.workingDir || '';
// The transcript reader emits the literal "(no content)" for a
// conversation it could not pull a prompt from; that is not a title.
const prompt = (item.firstPrompt || '').trim();
const title = prompt && prompt !== '(no content)' ? prompt : '';
return {
id: item.sessionId,
claudeSessionId: item.claudeSessionId || '',
workingDir: dir,
name: item.name || '',
title: title || item.name || dir.split('/').pop() || item.sessionId.slice(0, 8),
mode: item.mode || 'claude',
caseName: matched ? matched.name : '',
dir: this._shortenHomePath ? this._shortenHomePath(dir) : dir,
at: item.lastActivityAt || item.createdAt || 0,
};
})
.sort((a, b) => b.at - a.at);
return {
needsYou: inSection(['needs', 'error', 'waiting']),
current: inSection(['working', 'idle', 'done']),
past,
sessionCount: rows.length,
};
},
// ═══════════════════════════════════════════════════════════════
// Gate + visibility
// ═══════════════════════════════════════════════════════════════
/**
* Phone-only gate. Width-driven (not `isHandheldDevice()`): this is a LAYOUT
* decision, and an unfolded foldable with a tablet-width viewport should get
* the tablet welcome screen. Per-device settings identity is a separate
* question and deliberately stays handheld-based.
*/
shouldUseMobileOverview() {
if (this.isSoloWindow) return false;
const settings = this.loadAppSettingsFromStorage ? this.loadAppSettingsFromStorage() : {};
if (settings.mobileOverviewEnabled === false) return false;
if (typeof MobileDetection !== 'undefined' && MobileDetection.getDeviceType) {
return MobileDetection.getDeviceType() === 'mobile';
}
return !!(window.matchMedia && window.matchMedia(MOBILE_OVERVIEW_PHONE_QUERY).matches);
},
/** True while the overview is the visible home surface. */
isMobileOverviewVisible() {
const el = document.getElementById('mobileOverview');
return !!el && el.classList.contains('visible');
},
showMobileOverview() {
const el = document.getElementById('mobileOverview');
if (!el) return;
el.hidden = false;
el.classList.add('visible');
this._wireMobileOverview(el);
this.renderMobileOverview();
void this.loadMobileOverviewHistory();
},
hideMobileOverview() {
const el = document.getElementById('mobileOverview');
if (!el) return;
this._closeMobileOverviewRunMenu();
el.classList.remove('visible');
el.hidden = true;
},
/**
* Past conversations, fetched once per home-screen visit. The unified list is
* the same source the welcome screen resumes from, so a row resumed here and a
* row resumed there behave identically. Failures leave the section out rather
* than showing an error: the live sessions above it are the important part.
*/
async loadMobileOverviewHistory() {
if (this._mobileOverviewHistoryLoading) return;
this._mobileOverviewHistoryLoading = true;
try {
this._mobileOverviewHistory = await this._fetchUnifiedSessions(60);
} catch (err) {
console.warn('[mobile-overview] history load failed:', err);
this._mobileOverviewHistory = this._mobileOverviewHistory || [];
} finally {
this._mobileOverviewHistoryLoading = false;
if (this.isMobileOverviewVisible()) this.renderMobileOverview();
}
},
/** Re-render only when the surface is actually showing (called from the tab renderer). */
_refreshMobileOverviewIfVisible() {
if (!this.isMobileOverviewVisible()) return;
this._debouncedCall('mobileOverview', () => this.renderMobileOverview(), 150);
},
/**
* One delegated click listener for every row, plus a breakpoint listener so
* rotating or unfolding while on the home screen swaps to the right surface
* instead of stranding a phone layout on a tablet-width viewport.
*/
_wireMobileOverview(el) {
if (this._mobileOverviewWired) return;
this._mobileOverviewWired = true;
el.addEventListener('click', (event) => {
const target = event.target && event.target.closest && event.target.closest('[data-mo-action]');
if (!target) return;
const action = target.dataset.moAction;
if (action === 'session') {
this._closeMobileOverviewRunMenu();
void this.selectSession(target.dataset.moSession);
} else if (action === 'resume') {
this._closeMobileOverviewRunMenu();
void this.resumeMobileOverviewSession(target.dataset.moSession);
} else if (action === 'more-past') {
this._mobileOverviewShowAllPast = !this._mobileOverviewShowAllPast;
this.renderMobileOverview();
} else if (action === 'run') {
this._closeMobileOverviewRunMenu();
void this.run();
} else if (action === 'run-menu') {
this._toggleMobileOverviewRunMenu();
} else if (action === 'run-mode') {
// Picking a backend both selects it (so the Run button keeps meaning what
// you last chose, exactly like the toolbar) and launches it: on a phone
// the pick IS the intent to start.
this._closeMobileOverviewRunMenu();
this.setRunMode(target.dataset.moMode);
void this.run();
} else if (action === 'run-webview') {
this._closeMobileOverviewRunMenu();
void this.openWebviewFromMenu(target.dataset.moWebview);
} else if (action === 'run-add-url') {
this._closeMobileOverviewRunMenu();
this.showWebviewModal();
}
});
if (window.matchMedia) {
const mq = window.matchMedia(MOBILE_OVERVIEW_PHONE_QUERY);
const onChange = () => {
// Only relevant while a home surface is up; entering a session re-decides
// through hideWelcome()/showWelcome() anyway.
if (this.activeSessionId) return;
if (typeof this.showWelcome === 'function') this.showWelcome();
};
if (mq.addEventListener) mq.addEventListener('change', onChange);
else if (mq.addListener) mq.addListener(onChange);
}
},
_toggleMobileOverviewRunMenu() {
this._mobileOverviewRunMenuOpen = !this._mobileOverviewRunMenuOpen;
this.renderMobileOverview();
},
_closeMobileOverviewRunMenu() {
if (!this._mobileOverviewRunMenuOpen) return;
this._mobileOverviewRunMenuOpen = false;
if (this.isMobileOverviewVisible()) this.renderMobileOverview();
},
/**
* Resume a past conversation. Delegates to the same resumeHistorySession() the
* welcome screen's Resume list uses, so name synthesis, envOverrides and the
* resumeSessionId wiring stay in one place.
*/
async resumeMobileOverviewSession(sessionId) {
const row = (this._mobileOverviewPastRows || []).find((r) => r.id === sessionId);
if (!row || !row.workingDir) return;
await this.resumeHistorySession(row.claudeSessionId || row.id, row.workingDir, row.name || undefined);
},
// ═══════════════════════════════════════════════════════════════
// Render
// ═══════════════════════════════════════════════════════════════
renderMobileOverview() {
const el = document.getElementById('mobileOverview');
if (!el) return;
const model = this.buildMobileOverviewModel({
sessions: this.sessions,
cases: this.cases,
sessionOrder: this.sessionOrder,
pendingHooks: this.pendingHooks,
history: this._mobileOverviewHistory,
});
// Resume needs the workingDir/claudeSessionId off the row the user tapped.
this._mobileOverviewPastRows = model.past;
el.replaceChildren();
el.appendChild(this._buildMobileOverviewTop());
if (model.needsYou.length) {
el.appendChild(
this._buildMobileOverviewSection(
'Needs you',
model.needsYou.length,
model.needsYou.map((r) => this._buildMobileOverviewRow(r))
)
);
}
el.appendChild(
this._buildMobileOverviewSection(
'Current sessions',
model.current.length,
model.current.map((r) => this._buildMobileOverviewRow(r)),
'Nothing running. Hit Run to start something.'
)
);
el.appendChild(
this._buildMobileOverviewSection(
'Past sessions',
model.past.length,
this._buildMobileOverviewPast(model),
this._mobileOverviewHistory ? 'No past conversations yet' : 'Loading…'
)
);
},
/**
* Past conversations, newest first and capped: the unified list can run to
* dozens, and this section sits below the live ones on purpose.
*/
_buildMobileOverviewPast(model) {
const showAll = !!this._mobileOverviewShowAllPast;
const visible = showAll ? model.past : model.past.slice(0, MOBILE_OVERVIEW_PAST_LIMIT);
const children = visible.map((r) => this._buildMobileOverviewPastRow(r));
const hiddenCount = model.past.length - visible.length;
if (hiddenCount > 0 || showAll) {
const toggle = document.createElement('button');
toggle.type = 'button';
toggle.className = 'mobile-overview-more';
toggle.dataset.moAction = 'more-past';
const label = document.createElement('span');
label.textContent = showAll ? 'Show fewer' : 'Show all past sessions';
toggle.appendChild(label);
if (!showAll) {
const count = document.createElement('span');
count.className = 'mobile-overview-more-count';
count.setAttribute('data-i18n-skip', '');
count.textContent = String(hiddenCount);
toggle.appendChild(count);
}
children.push(toggle);
}
return children;
},
_buildMobileOverviewTop() {
const wrap = document.createElement('div');
wrap.className = 'mobile-overview-header';
const top = document.createElement('div');
top.className = 'mobile-overview-top';
const brand = document.createElement('span');
brand.className = 'mobile-overview-brand';
brand.textContent = (window.CodemanI18n && window.CodemanI18n.displayName) || 'Codeman';
brand.setAttribute('data-i18n-skip', '');
top.appendChild(brand);
// Split button carrying the TOOLBAR's own classes (`btn-toolbar btn-run
// mode-<mode>` / `btn-run-gear`), so the per-backend gradient, border and
// text color come from the same rules as the Run button in the toolbar and
// stay in sync with it for free. mobile.css only sizes it.
const group = document.createElement('div');
group.className = 'mobile-overview-run-group';
const mode = this.runMode || 'claude';
const run = document.createElement('button');
run.className = `btn-toolbar btn-run mode-${mode} mobile-overview-run`;
run.type = 'button';
run.dataset.moAction = 'run';
const runLabel = document.createElement('span');
runLabel.textContent = 'Run';
run.appendChild(runLabel);
const runMode = document.createElement('span');
runMode.className = 'mobile-overview-run-mode';
runMode.setAttribute('data-i18n-skip', '');
runMode.textContent = MOBILE_OVERVIEW_RUN_MODES.find((m) => m.mode === mode)?.short || mode;
run.appendChild(runMode);
group.appendChild(run);
const caret = document.createElement('button');
caret.className = `btn-toolbar btn-run-gear mode-${mode} mobile-overview-run-caret`;
caret.type = 'button';
caret.dataset.moAction = 'run-menu';
caret.setAttribute('aria-label', 'Choose what to run');
caret.setAttribute('aria-expanded', String(!!this._mobileOverviewRunMenuOpen));
// An SVG chevron, not a "⌄" glyph: the character carries its own baseline
// offset, so it sits visibly low in a flex-centered box no matter what the
// line-height says. A path is centered by geometry. Same shape the toolbar's
// run-mode gear uses.
caret.appendChild(this._buildMobileOverviewChevron());
group.appendChild(caret);
top.appendChild(group);
wrap.appendChild(top);
if (this._mobileOverviewRunMenuOpen) wrap.appendChild(this._buildMobileOverviewRunMenu());
return wrap;
},
/** Down chevron as SVG (see the note at its call site). */
_buildMobileOverviewChevron() {
const svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg');
svg.setAttribute('viewBox', '0 0 24 24');
svg.setAttribute('fill', 'none');
svg.setAttribute('stroke', 'currentColor');
svg.setAttribute('stroke-width', '2.5');
svg.setAttribute('stroke-linecap', 'round');
svg.setAttribute('stroke-linejoin', 'round');
svg.setAttribute('aria-hidden', 'true');
const path = document.createElementNS('http://www.w3.org/2000/svg', 'path');
path.setAttribute('d', 'M6 9l6 6 6-6');
svg.appendChild(path);
return svg;
},
/**
* The Run picker: the same backends as the toolbar's run-mode menu, plus saved
* web tabs. Deliberately no "Recent Sessions" block, unlike the toolbar menu:
* past conversations have their own section further down this screen.
*/
_buildMobileOverviewRunMenu() {
const menu = document.createElement('div');
menu.className = 'mobile-overview-run-menu';
const current = this.runMode || 'claude';
for (const entry of MOBILE_OVERVIEW_RUN_MODES) {
const option = document.createElement('button');
option.type = 'button';
option.className = 'mobile-overview-run-option' + (entry.mode === current ? ' selected' : '');
option.dataset.moAction = 'run-mode';
option.dataset.moMode = entry.mode;
const dot = document.createElement('span');
dot.className = 'run-mode-dot ' + entry.mode;
dot.setAttribute('aria-hidden', 'true');
option.appendChild(dot);
const label = document.createElement('span');
label.textContent = entry.label;
option.appendChild(label);
menu.appendChild(option);
}
const header = document.createElement('div');
header.className = 'mobile-overview-run-header';
header.textContent = 'Web / URL';
menu.appendChild(header);
for (const webview of this.webviews ? this.webviews.values() : []) {
const option = document.createElement('button');
option.type = 'button';
option.className = 'mobile-overview-run-option';
option.dataset.moAction = 'run-webview';
option.dataset.moWebview = webview.id;
const dot = document.createElement('span');
dot.className = 'run-mode-dot web';
dot.setAttribute('aria-hidden', 'true');
option.appendChild(dot);
const label = document.createElement('span');
// A dashboard name is user content.
label.className = 'case-name';
label.textContent = webview.name;
option.appendChild(label);
menu.appendChild(option);
}
const add = document.createElement('button');
add.type = 'button';
add.className = 'mobile-overview-run-option mobile-overview-run-option--add';
add.dataset.moAction = 'run-add-url';
const addDot = document.createElement('span');
addDot.className = 'run-mode-dot web';
addDot.setAttribute('aria-hidden', 'true');
add.appendChild(addDot);
const addLabel = document.createElement('span');
addLabel.textContent = 'Add URL…';
add.appendChild(addLabel);
menu.appendChild(add);
return menu;
},
_buildMobileOverviewSection(title, count, children, emptyText) {
const section = document.createElement('section');
section.className = 'mobile-overview-section';
const heading = document.createElement('h2');
heading.className = 'mobile-overview-heading';
const label = document.createElement('span');
label.textContent = title;
heading.appendChild(label);
const badge = document.createElement('span');
badge.className = 'mobile-overview-heading-count';
badge.textContent = String(count);
badge.setAttribute('data-i18n-skip', '');
heading.appendChild(badge);
section.appendChild(heading);
if (!children.length && emptyText) {
const empty = document.createElement('p');
empty.className = 'mobile-overview-empty';
empty.textContent = emptyText;
section.appendChild(empty);
return section;
}
for (const child of children) section.appendChild(child);
return section;
},
/**
* A session row. The state class drives the same visual language as the
* session tabs: green dot when it is fine (pulsing while working), a yellow
* blinking row when it wants input, a red blinking row when it asked a
* question. Anything else here would mean two different meanings for the same
* colors on one screen.
*/
_buildMobileOverviewRow(row) {
const item = document.createElement('button');
item.type = 'button';
item.className = 'mobile-overview-row mobile-overview-row--' + row.state;
item.dataset.moAction = 'session';
item.dataset.moSession = row.id;
const dot = document.createElement('span');
dot.className = 'mobile-overview-dot mobile-overview-dot--' + row.state;
dot.setAttribute('aria-hidden', 'true');
item.appendChild(dot);
const body = document.createElement('span');
body.className = 'mobile-overview-row-body';
const line1 = document.createElement('span');
line1.className = 'mobile-overview-row-title';
const name = document.createElement('span');
// .session-name is in the i18n skip list: a session name is user content.
name.className = 'session-name';
name.textContent = row.name;
line1.appendChild(name);
if (row.caseName) {
const meta = document.createElement('span');
meta.className = 'mobile-overview-row-case case-name';
meta.textContent = ' · ' + row.caseName;
line1.appendChild(meta);
}
body.appendChild(line1);
const line2 = document.createElement('span');
line2.className = 'mobile-overview-row-sub';
line2.setAttribute('data-i18n-skip', '');
line2.textContent = row.mode + (row.dir ? ' · ' + row.dir : '');
body.appendChild(line2);
item.appendChild(body);
const pill = document.createElement('span');
pill.className = 'mobile-overview-pill mobile-overview-pill--' + row.state;
// Skipped by i18n on purpose: the labels are generic single words ("idle",
// "done", "error") that collide with state strings on other surfaces.
pill.setAttribute('data-i18n-skip', '');
pill.textContent = row.pill;
item.appendChild(pill);
const chevron = document.createElement('span');
chevron.className = 'mobile-overview-chevron';
chevron.setAttribute('aria-hidden', 'true');
chevron.textContent = '›';
item.appendChild(chevron);
return item;
},
/** A past conversation. Tapping it resumes, which creates a fresh session. */
_buildMobileOverviewPastRow(row) {
const item = document.createElement('button');
item.type = 'button';
item.className = 'mobile-overview-row mobile-overview-row--past';
item.dataset.moAction = 'resume';
item.dataset.moSession = row.id;
const dot = document.createElement('span');
dot.className = 'mobile-overview-dot mobile-overview-dot--past';
dot.setAttribute('aria-hidden', 'true');
item.appendChild(dot);
const body = document.createElement('span');
body.className = 'mobile-overview-row-body';
const title = document.createElement('span');
// A first prompt is user content, never app copy.
title.className = 'mobile-overview-row-title session-name';
title.textContent = row.title;
body.appendChild(title);
const sub = document.createElement('span');
sub.className = 'mobile-overview-row-sub';
sub.setAttribute('data-i18n-skip', '');
const when = row.at && this._formatTimeAgo ? this._formatTimeAgo(row.at) : '';
sub.textContent = [row.caseName || row.dir, when].filter(Boolean).join(' · ');
body.appendChild(sub);
item.appendChild(body);
const pill = document.createElement('span');
pill.className = 'mobile-overview-pill mobile-overview-pill--past';
pill.textContent = 'resume';
pill.setAttribute('data-i18n-skip', '');
item.appendChild(pill);
const chevron = document.createElement('span');
chevron.className = 'mobile-overview-chevron';
chevron.setAttribute('aria-hidden', 'true');
chevron.textContent = '›';
item.appendChild(chevron);
return item;
},
});
+458
View File
@@ -839,6 +839,20 @@ html.mobile-init .file-browser-panel {
border-color: rgba(96, 165, 250, 0.5);
}
/* Antigravity mode colors on mobile */
.btn-toolbar.btn-run.mode-antigravity,
.btn-toolbar.btn-run-gear.mode-antigravity {
background: #0b2b33;
border-color: rgba(34, 211, 238, 0.3);
color: #cffafe;
}
.btn-toolbar.btn-run.mode-antigravity:active,
.btn-toolbar.btn-run-gear.mode-antigravity:active {
background: #0e7490;
border-color: rgba(34, 211, 238, 0.5);
}
/* Run mode dropdown menu — positioned above toolbar on mobile */
.run-mode-menu {
bottom: 100%;
@@ -855,6 +869,17 @@ html.mobile-init .file-browser-panel {
-webkit-tap-highlight-color: rgba(255, 255, 255, 0.1);
}
/* Per-URL edit/delete in the Web / URL list need a real touch target, and they
sit next to the row's own tap area, so they get sized up rather than relying
on the 24px desktop hit box. */
.run-mode-row-btn {
width: 34px;
height: 34px;
font-size: 1rem;
-webkit-tap-highlight-color: rgba(255, 255, 255, 0.1);
}
.run-mode-webview-delete { font-size: 1.2rem; }
.run-mode-history {
-webkit-overflow-scrolling: touch;
touch-action: manipulation;
@@ -2234,6 +2259,433 @@ html.mobile-init .file-browser-panel {
border-radius: 8px;
transform: none !important;
}
/* ---- Mobile Overview (phone home screen) ----
Shown in place of the welcome overlay when the C logo is tapped. Styled only
with :root tokens so every skin, including the four light ones, works with
no override block. Never give .mobile-overview itself a display value: the
element ships with [hidden] and only .visible may turn it on. */
.mobile-overview.visible {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
z-index: 10;
display: flex;
flex-direction: column;
gap: 0.75rem;
padding: 0.75rem 0.6rem calc(1rem + var(--safe-area-bottom));
background: var(--bg-dark);
overflow-y: auto;
-webkit-overflow-scrolling: touch;
overscroll-behavior: contain;
}
/* No side padding: the wordmark's left edge lines up with the session cards
below it, and the Run button's right edge with theirs. */
.mobile-overview-top {
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.5rem;
padding: 0;
}
/* Same accent as the "C" in the header (`.logo` uses --accent-hover), and the
same 48px box as the Run button opposite it, so the two are centered on one
line by construction rather than by eye. */
.mobile-overview-brand {
display: inline-flex;
align-items: center;
min-height: 48px;
font-size: 1.5rem;
font-weight: 800;
line-height: 1;
color: var(--accent-hover);
letter-spacing: -0.02em;
}
/* Split Run button. Colors come from the toolbar's own
`.btn-toolbar.btn-run.mode-<backend>` rules in styles.css (the element
carries those classes), so this button and the one in the toolbar are the
same control in two places. Only size/shape is set here, and NOTHING that
would override the mode gradient. */
.mobile-overview-header {
position: relative;
display: flex;
flex-direction: column;
}
.mobile-overview-run-group {
display: flex;
align-items: stretch;
flex-shrink: 0;
}
/* Sized well past the 44px touch minimum: this is the primary action on the
home screen, and the caret is a separate target right next to it.
⚠️ The !important is required, not decorative: the phone block clamps every
.btn-toolbar to `height/min-height/max-height: 26px !important`, and this
button deliberately carries .btn-toolbar to inherit the run-mode gradient.
Without matching !important (max-height included) it renders 26px tall. */
.mobile-overview-run-group .mobile-overview-run {
display: inline-flex;
align-items: center;
gap: 0.4rem;
min-height: 48px !important;
height: 48px !important;
max-height: 48px !important;
padding: 0 1.15rem !important;
font-size: 0.95rem !important;
line-height: 1;
font-weight: 700;
font-family: inherit;
border-radius: 10px 0 0 10px !important;
}
.mobile-overview-run-mode {
font-weight: 600;
font-size: 0.85rem;
opacity: 0.8;
}
.mobile-overview-run-group .mobile-overview-run-caret {
display: inline-flex;
align-items: center;
justify-content: center;
min-height: 48px !important;
height: 48px !important;
max-height: 48px !important;
min-width: 48px !important;
width: 48px !important;
padding: 0 !important;
line-height: 1;
font-family: inherit;
border-radius: 0 10px 10px 0 !important;
}
/* The phone block shrinks every run-gear glyph to 10px; this one is a 48px
tap target, so it gets a proportionate chevron. `display:block` drops the
inline-baseline gap that would otherwise push it a pixel low. */
.mobile-overview-run-group .mobile-overview-run-caret svg {
display: block;
width: 20px !important;
height: 20px !important;
margin: 0 !important;
}
.mobile-overview-run-menu {
display: flex;
flex-direction: column;
gap: 0.15rem;
margin: 0.5rem 0 0;
padding: 0.35rem;
border: 1px solid var(--border);
border-radius: 12px;
background: var(--floating-bg);
box-shadow: var(--elevated-shadow);
}
.mobile-overview-run-option {
display: flex;
align-items: center;
gap: 0.5rem;
min-height: 44px;
padding: 0 0.6rem;
border: none;
border-radius: 8px;
background: transparent;
color: var(--text);
font-family: inherit;
font-size: 0.82rem;
text-align: left;
}
.mobile-overview-run-option.selected {
background: var(--control-bg-hover);
}
.mobile-overview-run-option:active {
background: var(--bg-hover);
}
.mobile-overview-run-option--add {
color: var(--accent);
}
.mobile-overview-run-header {
padding: 0.4rem 0.6rem 0.2rem;
font-size: 0.6rem;
font-weight: 700;
letter-spacing: 0.09em;
text-transform: uppercase;
color: var(--text-muted);
}
.mobile-overview-section {
display: flex;
flex-direction: column;
gap: 0.4rem;
}
/* Flush left with the wordmark and the cards: one left edge down the screen. */
.mobile-overview-heading {
display: flex;
align-items: center;
gap: 0.35rem;
margin: 0.35rem 0 0;
padding: 0;
font-size: 0.65rem;
font-weight: 700;
letter-spacing: 0.09em;
text-transform: uppercase;
color: var(--text-muted);
}
.mobile-overview-heading-count {
color: var(--text-dim);
font-weight: 600;
}
.mobile-overview-empty {
margin: 0;
padding: 0.5rem 0;
font-size: 0.75rem;
color: var(--text-muted);
}
/* Row: 56px tap target, dot + two-line body + pill + chevron */
.mobile-overview-row {
display: flex;
align-items: center;
gap: 0.6rem;
width: 100%;
min-height: 56px;
padding: 0.5rem 0.7rem;
border: 1px solid var(--border);
border-radius: 12px;
background: var(--bg-card);
color: var(--text);
font-family: inherit;
text-align: left;
}
.mobile-overview-row:active {
background: var(--bg-hover);
}
/* Attention states mirror the session tabs exactly: red blink when the agent
asked something (permission / question), yellow blink when it is waiting for
a prompt. Same hues and same cadence as tab-blink-red / tab-blink-yellow in
styles.css; the keyframes are re-declared here only because a tab's resting
background is transparent while a row's is the card color. */
.mobile-overview-row--needs {
border-color: var(--red);
animation: mobile-overview-blink-red 2.5s ease-in-out infinite;
}
.mobile-overview-row--waiting {
border-color: var(--yellow);
animation: mobile-overview-blink-yellow 3.5s ease-in-out infinite;
}
.mobile-overview-row--error {
border-color: var(--red);
}
@keyframes mobile-overview-blink-red {
0%,
100% {
background: var(--bg-card);
border-color: var(--border);
}
50% {
background: rgba(239, 68, 68, 0.14);
border-color: var(--red);
}
}
@keyframes mobile-overview-blink-yellow {
0%,
100% {
background: var(--bg-card);
border-color: var(--border);
}
50% {
background: rgba(234, 179, 8, 0.12);
border-color: var(--yellow);
}
}
.mobile-overview-row-body {
display: flex;
flex-direction: column;
gap: 0.15rem;
flex: 1;
min-width: 0;
}
.mobile-overview-row-title {
display: block;
font-size: 0.9rem;
font-weight: 600;
color: var(--text);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.mobile-overview-row-case {
color: var(--text-dim);
font-weight: 500;
}
.mobile-overview-row-sub {
display: block;
font-size: 0.68rem;
color: var(--text-muted);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.mobile-overview-dot {
flex-shrink: 0;
width: 9px;
height: 9px;
border-radius: 50%;
background: var(--text-muted);
}
.mobile-overview-dot--needs,
.mobile-overview-dot--error {
background: var(--red);
}
.mobile-overview-dot--waiting {
background: var(--yellow);
}
/* Same as .session-tab .tab-status: green when the session is fine, and the
shared `pulse` keyframes while it is working. */
.mobile-overview-dot--working {
background: var(--green);
animation: pulse 1.5s infinite;
will-change: opacity;
}
.mobile-overview-dot--idle {
background: var(--green);
}
.mobile-overview-dot--done {
background: var(--text-muted);
opacity: 0.5;
}
.mobile-overview-pill {
flex-shrink: 0;
display: inline-flex;
align-items: center;
gap: 0.25rem;
padding: 0.2rem 0.45rem;
border: 1px solid var(--border-light);
border-radius: 999px;
font-size: 0.62rem;
font-weight: 600;
color: var(--text-dim);
white-space: nowrap;
}
.mobile-overview-pill--needs,
.mobile-overview-pill--error {
border-color: var(--red);
color: var(--red);
}
.mobile-overview-pill--waiting {
border-color: var(--yellow);
color: var(--yellow);
}
.mobile-overview-pill--idle,
.mobile-overview-pill--working {
border-color: var(--green);
color: var(--green);
}
.mobile-overview-chevron {
flex-shrink: 0;
color: var(--text-muted);
font-size: 1rem;
line-height: 1;
}
/* Past conversations: quieter than a live row, since tapping one starts work */
.mobile-overview-row--past {
background: transparent;
border-style: dashed;
}
.mobile-overview-row--past .mobile-overview-row-title {
font-weight: 500;
color: var(--text-dim);
}
.mobile-overview-dot--past {
background: transparent;
border: 1px solid var(--border-light);
}
.mobile-overview-pill--past {
border-color: var(--border-light);
color: var(--text-muted);
}
.mobile-overview-more {
display: flex;
align-items: center;
justify-content: center;
gap: 0.35rem;
min-height: 40px;
border: none;
border-radius: 10px;
background: var(--control-bg);
color: var(--text-dim);
font-family: inherit;
font-size: 0.72rem;
font-weight: 600;
}
.mobile-overview-more-count {
padding: 0.05rem 0.35rem;
border-radius: 999px;
background: var(--control-bg-hover);
color: var(--text-muted);
}
}
/* The overview's attention blink is an alert, so it stays visible without
motion: hold the alert color instead of animating to it. */
@media (prefers-reduced-motion: reduce) {
.mobile-overview-row--needs,
.mobile-overview-row--waiting {
animation: none;
}
.mobile-overview-row--needs {
background: rgba(239, 68, 68, 0.14);
}
.mobile-overview-row--waiting {
background: rgba(234, 179, 8, 0.12);
}
.mobile-overview-dot--working {
animation: none;
}
}
/* Light-skin compatibility for mobile-only chrome. These components predate
@@ -2275,6 +2727,12 @@ html:is([data-skin="paper-gray"], [data-skin="solarized-light"], [data-skin="cat
color: #ffffff;
}
html:is([data-skin="paper-gray"], [data-skin="solarized-light"], [data-skin="catppuccin-latte"], [data-skin="rose-pine-dawn"]) :is(.btn-toolbar.btn-run.mode-antigravity, .btn-toolbar.btn-run-gear.mode-antigravity) {
background: linear-gradient(135deg, #0e7490, #0891b2);
border-color: #155e75;
color: #ffffff;
}
html:is([data-skin="paper-gray"], [data-skin="solarized-light"], [data-skin="catppuccin-latte"], [data-skin="rose-pine-dawn"]) .btn-toolbar.btn-run-gear {
border-left-color: var(--control-border-hover) !important;
}
+97 -42
View File
@@ -9,7 +9,7 @@
* 5. Audio alerts (Web Audio API beep, user-opt-in)
*
* Features:
* - Per-event-type preferences (enabled, browser, audio, push) with v1→v4 migration
* - Per-event-type preferences (enabled, browser, audio, push) with v1→v5 migration
* - Device-specific defaults (notifications disabled on mobile by default)
* - 5s notification grouping window to batch rapid-fire events
* - 100-notification cap with oldest eviction
@@ -21,7 +21,7 @@
* @param {CodemanApp} app - Reference to the main app instance
*
* @dependency constants.js (STUCK_THRESHOLD_DEFAULT_MS, timing constants)
* @dependency mobile-handlers.js (MobileDetection.getDeviceType for device-specific defaults)
* @dependency mobile-handlers.js (MobileDetection stable handheld identity/device type)
* @loadorder 4 of 15 — loaded after voice-input.js, before keyboard-accessory.js
*/
@@ -65,12 +65,19 @@ class NotificationManager {
});
}
loadPreferences() {
_usesMobilePreferences() {
return (
MobileDetection.isHandheldDevice?.() ??
MobileDetection.getDeviceType() === 'mobile'
);
}
getDefaultPreferences() {
const defaultEventTypes = {
permission_prompt: { enabled: true, browser: true, audio: true, push: false },
elicitation_dialog: { enabled: true, browser: true, audio: true, push: false },
idle_prompt: { enabled: true, browser: true, audio: false, push: false },
stop: { enabled: true, browser: false, audio: false, push: false },
stop: { enabled: false, browser: false, audio: false, push: false },
session_error: { enabled: true, browser: true, audio: false, push: false },
respawn_cycle: { enabled: true, browser: false, audio: false, push: false },
token_milestone: { enabled: true, browser: false, audio: false, push: false },
@@ -80,8 +87,8 @@ class NotificationManager {
};
// Device-specific defaults: mobile has notifications disabled by default
const isMobile = MobileDetection.getDeviceType() === 'mobile';
const defaults = {
const isMobile = this._usesMobilePreferences();
return {
enabled: !isMobile, // Disabled on mobile by default
browserNotifications: !isMobile,
audioAlerts: false,
@@ -92,51 +99,97 @@ class NotificationManager {
muteInfo: false,
// Per-event-type preferences
eventTypes: defaultEventTypes,
_version: 4,
_version: 5,
};
}
/**
* Apply the complete v1→v5 migration to either local or server-hydrated
* preferences. Keeping one normalization path prevents fresh browsers from
* reviving retired drawer-only hook defaults.
*/
normalizePreferences(rawPreferences) {
const defaults = this.getDefaultPreferences();
if (
!rawPreferences ||
typeof rawPreferences !== 'object' ||
Array.isArray(rawPreferences)
) {
return defaults;
}
const prefs = {
...rawPreferences,
eventTypes:
rawPreferences.eventTypes &&
typeof rawPreferences.eventTypes === 'object' &&
!Array.isArray(rawPreferences.eventTypes)
? Object.fromEntries(
Object.entries(rawPreferences.eventTypes).map(([key, value]) => [
key,
value && typeof value === 'object' ? { ...value } : value,
])
)
: undefined,
};
const version = Number.isInteger(prefs._version) ? prefs._version : 0;
// Migrate: v1 had browserNotifications defaulting to false
if (version < 2) {
prefs.browserNotifications = true;
}
// Migrate: v2 -> v3 adds eventTypes
if (version < 3) {
prefs.eventTypes = { ...defaults.eventTypes };
}
// Migrate: v3 -> v4 adds push field to all eventTypes
if (version < 4 && prefs.eventTypes) {
for (const key of Object.keys(prefs.eventTypes)) {
if (prefs.eventTypes[key] && prefs.eventTypes[key].push === undefined) {
prefs.eventTypes[key].push = false;
}
}
}
// Migrate: v4 -> v5 removes the drawer-only Response Complete default.
// Preserve users who opted into any external delivery channel.
if (version < 5) {
const stopPref = prefs.eventTypes?.stop;
if (
stopPref?.enabled === true &&
!stopPref.browser &&
!stopPref.audio &&
!stopPref.push
) {
stopPref.enabled = false;
}
}
return {
...defaults,
...prefs,
eventTypes: { ...defaults.eventTypes, ...prefs.eventTypes },
_version: 5,
};
}
loadPreferences() {
try {
const storageKey = this.getStorageKey();
const saved = localStorage.getItem(storageKey);
if (saved) {
const prefs = JSON.parse(saved);
// Migrate: v1 had browserNotifications defaulting to false
if (!prefs._version || prefs._version < 2) {
prefs.browserNotifications = true;
prefs._version = 2;
}
// Migrate: v2 -> v3 adds eventTypes
if (prefs._version < 3) {
prefs.eventTypes = defaultEventTypes;
prefs._version = 3;
localStorage.setItem(storageKey, JSON.stringify(prefs));
}
// Migrate: v3 -> v4 adds push field to all eventTypes
if (prefs._version < 4) {
if (prefs.eventTypes) {
for (const key of Object.keys(prefs.eventTypes)) {
if (prefs.eventTypes[key] && prefs.eventTypes[key].push === undefined) {
prefs.eventTypes[key].push = false;
}
}
}
prefs._version = 4;
localStorage.setItem(storageKey, JSON.stringify(prefs));
}
// Merge with defaults to ensure all eventTypes exist
return {
...defaults,
...prefs,
eventTypes: { ...defaultEventTypes, ...prefs.eventTypes },
};
const normalized = this.normalizePreferences(JSON.parse(saved));
localStorage.setItem(storageKey, JSON.stringify(normalized));
return normalized;
}
} catch (_e) { /* ignore */ }
return defaults;
return this.getDefaultPreferences();
}
// Get storage key for notification prefs (device-specific)
getStorageKey() {
const isMobile = MobileDetection.getDeviceType() === 'mobile';
return isMobile ? 'codeman-notification-prefs-mobile' : 'codeman-notification-prefs';
return this._usesMobilePreferences()
? 'codeman-notification-prefs-mobile'
: 'codeman-notification-prefs';
}
savePreferences() {
@@ -163,8 +216,10 @@ class NotificationManager {
'exit-gate': 'ralph_complete',
'subagent-spawn': 'subagent_spawn',
'subagent-complete': 'subagent_complete',
'hook-teammate-idle': 'idle_prompt',
'hook-task-completed': 'stop',
// Team lifecycle hooks are agent activity, not session-idle/stop alerts.
// Reuse the existing opt-in agent categories instead of making them noisy.
'hook-teammate-idle': 'subagent_spawn',
'hook-task-completed': 'subagent_complete',
};
const eventTypeKey = categoryToEventType[category] || category;
+1 -1
View File
@@ -426,7 +426,7 @@ Object.assign(CodemanApp.prototype, {
_buildCommandPaletteNewSessionItem(query = '') {
const mode = this.runMode || this._runMode || 'claude';
const labels = { claude: 'Claude', opencode: 'OpenCode', codex: 'Codex', gemini: 'Gemini' };
const labels = { claude: 'Claude', opencode: 'OpenCode', codex: 'Codex', gemini: 'Gemini', antigravity: 'Antigravity' };
const caseName = this._findCommandPaletteCaseMatch(query) || document.getElementById('quickStartCase')?.value || 'testcase';
return {
id: 'new-session',
+12
View File
@@ -848,6 +848,18 @@ Object.assign(CodemanApp.prototype, {
},
closeSessionOptions() {
// Commit the field the user was still editing BEFORE editingSessionId is
// cleared. The Session Name input saves on blur (and the auto-compact prompt
// on change), and every autosave handler bails out on `!this.editingSessionId`.
// Hiding the modal blurs the focused input on its own, but that happens after
// the id is gone, so Escape / backdrop-click silently dropped what was typed.
// (Clicking the X worked only because mousedown blurs the input first.)
const modal = document.getElementById('sessionOptionsModal');
const focused = document.activeElement;
if (focused && modal && modal.contains(focused) && typeof focused.blur === 'function') {
focused.blur();
}
this.editingSessionId = null;
// Stop run summary auto-refresh if it was running
this.stopRunSummaryAutoRefresh();
+65 -6
View File
@@ -316,6 +316,9 @@ Object.assign(CodemanApp.prototype, {
select.dataset.listenerAdded = 'true';
}
this.setupQuickStartCasePicker();
// The phone overview labels rows with their case name, and a case rename or
// link does not go through the session-tab renderer.
this._refreshMobileOverviewIfVisible?.();
} catch (err) {
console.error('Failed to load cases:', err);
}
@@ -370,7 +373,7 @@ Object.assign(CodemanApp.prototype, {
this._renderSessionTabsImmediate?.();
},
/** Run using the selected mode (Claude Code, OpenCode, Codex, or Gemini) */
/** Run using the selected mode (Claude Code, OpenCode, Codex, Gemini, or Antigravity) */
async run() {
if (this._runInFlight) return;
@@ -394,6 +397,9 @@ Object.assign(CodemanApp.prototype, {
if (mode === 'gemini') {
return await this.runGemini();
}
if (mode === 'antigravity') {
return await this.runAntigravity();
}
if (mode === 'shell') {
return await this.runShell();
}
@@ -503,7 +509,7 @@ Object.assign(CodemanApp.prototype, {
gearBtn.className = `btn-toolbar btn-run-gear mode-${mode}`;
}
if (label) {
label.textContent = mode === 'opencode' ? 'Run OC' : mode === 'codex' ? 'Run CX' : mode === 'gemini' ? 'Run GM' : mode === 'shell' ? 'Run SH' : 'Run';
label.textContent = mode === 'opencode' ? 'Run OC' : mode === 'codex' ? 'Run CX' : mode === 'gemini' ? 'Run GM' : mode === 'antigravity' ? 'Run AG' : mode === 'shell' ? 'Run SH' : 'Run';
}
},
@@ -746,7 +752,7 @@ Object.assign(CodemanApp.prototype, {
// is shared by sibling sessions, so create-with-false must not yank it
// — see the comment in session-routes create). Disabling the setting
// removes it via the App Settings toggle path (system-routes), not here.
statusLineTelemetry: globalSettings.showPlanUsageLimits === true,
statusLineTelemetry: this.planUsageChipEnabled(globalSettings),
})
}).then(r => r.json())
);
@@ -1107,6 +1113,57 @@ Object.assign(CodemanApp.prototype, {
}
},
async runAntigravity() {
const caseName = document.getElementById('quickStartCase').value || 'testcase';
// Remote/docker cases run agy on the OTHER side — skip the local status probe and the
// local-only config/env below (quick-start rejects them for remote cases).
const _runLoc = (this.cases || []).find(c => c.name === caseName)?.location;
const isRemote = _runLoc === 'remote' || _runLoc === 'docker';
this.terminal.clear();
this.terminal.writeln(`\x1b[1;32m Starting Antigravity session in ${caseName}...\x1b[0m`);
this.terminal.writeln('');
this.terminal.focus();
try {
if (!isRemote) {
const statusRes = await fetch('/api/antigravity/status');
const status = (await statusRes.json()).data;
if (!status.available) {
this.terminal.writeln('\x1b[1;31m Antigravity CLI not found.\x1b[0m');
this.terminal.writeln('\x1b[90m Install with: curl -fsSL https://antigravity.google/cli/install.sh | bash\x1b[0m');
return;
}
}
const envOverrides = this.buildEnvOverrides(this.getCaseSettings(caseName), this.loadAppSettingsFromStorage());
const res = await fetch('/api/quick-start', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
caseName,
mode: 'antigravity',
sessionName: `w${this._nextCaseSessionStartNumber(caseName)}-${caseName}`,
...(isRemote ? {} : {
antigravityConfig: { dangerouslySkipPermissions: true },
...(Object.keys(envOverrides).length > 0 ? { envOverrides } : {}),
}),
})
});
const data = await res.json();
if (!data.success) throw new Error(data.error || 'Failed to start Antigravity');
await this._ensureCreatedSessionVisible(data.data.sessionId, data.data.session);
if (data.data.sessionId) {
await this.selectSession(data.data.sessionId);
}
this.terminal.focus();
} catch (err) {
this.terminal.writeln(`\x1b[1;31m Error: ${err.message}\x1b[0m`);
}
},
// ═══════════════════════════════════════════════════════════════
// Session Options Modal
@@ -1119,7 +1176,7 @@ Object.assign(CodemanApp.prototype, {
this.editingSessionId = sessionId;
// Reset to an appropriate tab — Summary for external CLIs (Respawn/Ralph are Claude-only)
const isAltMode = session.mode === 'opencode' || session.mode === 'codex' || session.mode === 'gemini';
const isAltMode = session.mode === 'opencode' || session.mode === 'codex' || session.mode === 'gemini' || session.mode === 'antigravity';
this.switchOptionsTab(isAltMode ? 'summary' : 'respawn');
// Update respawn status display and buttons
@@ -1149,7 +1206,7 @@ Object.assign(CodemanApp.prototype, {
}
// Hide Claude-specific options for external CLI sessions
const isExternalCli = session.mode === 'opencode' || session.mode === 'codex' || session.mode === 'gemini';
const isExternalCli = session.mode === 'opencode' || session.mode === 'codex' || session.mode === 'gemini' || session.mode === 'antigravity';
const claudeOnlyEls = document.querySelectorAll('[data-claude-only]');
claudeOnlyEls.forEach(el => { el.style.display = isExternalCli ? 'none' : ''; });
@@ -2569,6 +2626,8 @@ Object.defineProperty(CodemanApp.prototype, 'runMode', {
},
set(mode) {
this._runMode =
mode === 'opencode' || mode === 'codex' || mode === 'gemini' || mode === 'claude' ? mode : 'claude';
mode === 'opencode' || mode === 'codex' || mode === 'gemini' || mode === 'antigravity' || mode === 'claude'
? mode
: 'claude';
},
});
+58 -13
View File
@@ -312,6 +312,10 @@ Object.assign(CodemanApp.prototype, {
document.getElementById('appSettingsShowFileViewerButton').checked = settings.showFileViewerButton ?? defaults.showFileViewerButton ?? true;
document.getElementById('appSettingsShowAttachmentsButton').checked = settings.showAttachmentsButton ?? defaults.showAttachmentsButton ?? false;
document.getElementById('appSettingsSkin').value = settings.skin ?? defaults.skin ?? 'daylight-blue';
// Entrance animations. Deliberately NOT part of the settings payload: the
// styles persist to their own localStorage keys via setAnimTheme(), which
// keeps them per-device without touching the .strict() SettingsUpdateSchema.
this._syncEntranceAnimSetting?.();
// WebGL renderer (desktop only — mobile always uses the DOM renderer, so hide
// the toggle there so it can't promise something that won't apply).
document.getElementById('appSettingsWebglRenderer').checked = settings.webglRendererEnabled ?? defaults.webglRendererEnabled ?? true;
@@ -325,9 +329,19 @@ Object.assign(CodemanApp.prototype, {
document.getElementById('appSettingsUltracodeFloatingWindows').checked =
settings.ultracodeFloatingWindows ?? defaults.ultracodeFloatingWindows ?? false;
document.getElementById('appSettingsShowMultiMonitorButton').checked = settings.showMultiMonitorButton ?? defaults.showMultiMonitorButton ?? false;
document.getElementById('appSettingsShowPlanUsageLimits').checked = settings.showPlanUsageLimits ?? defaults.showPlanUsageLimits ?? false;
document.getElementById('appSettingsShowPlanUsageLimits').checked = this.planUsageChipEnabled(settings);
document.getElementById('appSettingsShowRedrawButton').checked = settings.showRedrawButton ?? defaults.showRedrawButton ?? false;
// Session Manager + Away Digest buttons default OFF; Cron button defaults ON.
// Phone overview home screen: only meaningful under 430px, so the row is
// hidden elsewhere rather than offering a toggle that changes nothing.
document.getElementById('appSettingsMobileOverview').checked = settings.mobileOverviewEnabled ?? defaults.mobileOverviewEnabled ?? false;
const phoneOnly = MobileDetection.getDeviceType() === 'mobile' ? '' : 'none';
const mobileOverviewItem = document.getElementById('appSettingsMobileOverviewItem');
if (mobileOverviewItem) mobileOverviewItem.style.display = phoneOnly;
const phoneSection = document.getElementById('appSettingsPhoneSection');
if (phoneSection) phoneSection.style.display = phoneOnly;
// Session Manager, Away Digest and Cron buttons all default OFF (opt-in under
// Display → Header Displays; the Cron button also ships with btn-cron--hidden
// in the template, so an unchecked box and a hidden button stay consistent).
document.getElementById('appSettingsShowSessionButton').checked = settings.showSessionButton ?? defaults.showSessionButton ?? false;
document.getElementById('appSettingsShowAwayDigestButton').checked = settings.showAwayDigestButton ?? defaults.showAwayDigestButton ?? false;
document.getElementById('appSettingsShowCronButton').checked = settings.showCronButton ?? defaults.showCronButton ?? false;
@@ -408,7 +422,7 @@ Object.assign(CodemanApp.prototype, {
document.getElementById('eventIdleAudio').checked = idlePref.audio ?? false;
// Response complete (stop)
const stopPref = eventTypes.stop || {};
document.getElementById('eventStopEnabled').checked = stopPref.enabled ?? true;
document.getElementById('eventStopEnabled').checked = stopPref.enabled ?? false;
document.getElementById('eventStopBrowser').checked = stopPref.browser ?? false;
document.getElementById('eventStopPush').checked = stopPref.push ?? false;
document.getElementById('eventStopAudio').checked = stopPref.audio ?? false;
@@ -1447,6 +1461,7 @@ Object.assign(CodemanApp.prototype, {
showMultiMonitorButton: document.getElementById('appSettingsShowMultiMonitorButton').checked,
showPlanUsageLimits: document.getElementById('appSettingsShowPlanUsageLimits').checked,
showRedrawButton: document.getElementById('appSettingsShowRedrawButton').checked,
mobileOverviewEnabled: document.getElementById('appSettingsMobileOverview').checked,
showSessionButton: document.getElementById('appSettingsShowSessionButton').checked,
showAwayDigestButton: document.getElementById('appSettingsShowAwayDigestButton').checked,
showCronButton: document.getElementById('appSettingsShowCronButton').checked,
@@ -1587,7 +1602,7 @@ Object.assign(CodemanApp.prototype, {
audio: document.getElementById('eventSubagentAudio').checked,
},
},
_version: 4,
_version: 5,
};
if (this.notificationManager) {
this.notificationManager.preferences = notifPrefsToSave;
@@ -1610,6 +1625,10 @@ Object.assign(CodemanApp.prototype, {
// Apply CJK input visibility immediately
this._updateCjkInputState();
// The phone home surface (overview vs welcome) may have just been toggled.
// Only re-decide while a home screen is actually up.
if (!this.activeSessionId) this.showWelcome();
// Apply keyboard bar mode
KeyboardAccessoryBar.setMode(settings.extendedKeyboardBar ? 'extended' : 'simple');
@@ -1640,6 +1659,8 @@ Object.assign(CodemanApp.prototype, {
showSessionButton: _ssb,
showAwayDigestButton: _adb,
showCronButton: _crb,
// Phone-only home surface, and absent from SettingsUpdateSchema (.strict()).
mobileOverviewEnabled: _mov,
...serverSettings
} = settings;
try {
@@ -1798,6 +1819,9 @@ Object.assign(CodemanApp.prototype, {
showUltracodeAgents: false,
ultracodeFloatingWindows: false,
showMultiMonitorButton: false,
// Desktop defaults this ON (see planUsageChipEnabled); handhelds keep it
// OFF so the phone header stays minimal and the mobile-header-buttons
// policy guard keeps passing.
showPlanUsageLimits: false,
showAttachmentsButton: false,
showFileViewerButton: false,
@@ -1805,6 +1829,10 @@ Object.assign(CodemanApp.prototype, {
showSessionButton: false,
showAwayDigestButton: false,
showCronButton: false,
// Phone home screen: the C logo opens the session overview instead of the
// welcome screen. ON by default here, and the escape hatch if it ever
// misbehaves on a device (the gate treats only an explicit false as off).
mobileOverviewEnabled: true,
// Remote auto-reconnect (COD-108) — on by default
remoteAutoReconnect: true,
// Input
@@ -1889,6 +1917,18 @@ Object.assign(CodemanApp.prototype, {
}
},
// Resolved per-device state of the plan-usage chip. Desktop defaults ON,
// handhelds default OFF (the mobile block in getDefaultSettings() sets false,
// and the mobile-header-buttons-policy guard depends on that staying false).
// Single source of truth for THREE call sites that must never disagree: the
// App Settings checkbox, the chip's visibility, and the statusLineTelemetry
// flag sent on session create. A chip shown without telemetry renders "—"
// forever, which is exactly the drift this helper prevents.
planUsageChipEnabled(settings = null) {
const s = settings ?? this.loadAppSettingsFromStorage();
return s.showPlanUsageLimits ?? this.getDefaultSettings().showPlanUsageLimits ?? true;
},
applyHeaderVisibilitySettings() {
const settings = this.loadAppSettingsFromStorage();
const defaults = this.getDefaultSettings();
@@ -1967,11 +2007,13 @@ Object.assign(CodemanApp.prototype, {
ultracodeBtn.classList.toggle('btn-ultracode-agents--hidden', !showUltracodeAgents);
}
// Plan-usage chip — hidden by default (App Settings → Display → "Plan Usage
// Limits"). Server renders the initial state on reload; this handles a live
// toggle from a settings save. Marker class (base is display:inline-flex
// !important), matching the response-viewer/multimonitor pattern.
const showPlanUsageLimits = settings.showPlanUsageLimits ?? defaults.showPlanUsageLimits ?? false;
// Plan-usage chip — shown by default on desktop, OFF on handhelds (App
// Settings → Display → "Plan Usage Limits"). The template always ships it
// hidden because display is per-device and the server cannot know a
// localStorage value, so THIS is what reveals it on every load as well as
// on a live toggle. Marker class (base is display:inline-flex !important),
// matching the response-viewer/multimonitor pattern.
const showPlanUsageLimits = this.planUsageChipEnabled(settings);
const planUsageChip = document.getElementById('planUsageChip');
if (planUsageChip) {
planUsageChip.classList.toggle('header-plan-usage--hidden', !showPlanUsageLimits);
@@ -2246,10 +2288,12 @@ Object.assign(CodemanApp.prototype, {
'language',
'terminalWheelLocalScrollback',
'showSessionButton', 'showAwayDigestButton', 'showCronButton',
'mobileOverviewEnabled',
]);
// The plan-usage chip is a PER-DEVICE display setting (default OFF): desktop
// can show it while mobile stays hidden. It used to sync, so an older
// server.json may still carry `true` — drop it so the server value is NEVER
// The plan-usage chip is a PER-DEVICE display setting (desktop default ON,
// handheld default OFF): desktop can show it while mobile stays hidden. It
// used to sync, so an older server.json may still carry a value — drop it
// so the server value is NEVER
// seeded into a device that didn't explicitly enable it (collection is handled
// separately via the statusLineTelemetry action, not this display flag).
delete appSettings.showPlanUsageLimits;
@@ -2275,7 +2319,8 @@ Object.assign(CodemanApp.prototype, {
if (notificationPreferences && this.notificationManager) {
const localNotifPrefs = localStorage.getItem(this.notificationManager.getStorageKey());
if (!localNotifPrefs) {
this.notificationManager.preferences = notificationPreferences;
this.notificationManager.preferences =
this.notificationManager.normalizePreferences(notificationPreferences);
this.notificationManager.savePreferences();
}
}
+760 -5
View File
@@ -328,7 +328,8 @@ html:is([data-skin="paper-gray"], [data-skin="solarized-light"], [data-skin="cat
.search-filter-chip.active,
.search-badge-session,
.history-view-all-btn,
.session-tab .tab-mode.gemini
.session-tab .tab-mode.gemini,
.session-tab .tab-mode.antigravity
) {
color: var(--accent-d);
}
@@ -638,6 +639,674 @@ body {
100% { box-shadow: 0 0 0px 0px rgba(34, 197, 94, 0); background: transparent; }
}
/* ── Tab entrance animations (tab-entrance.js) ─────────────────────────────
Style is picked by html[data-tab-anim]; the stagger arrives as an inline
--tab-enter-delay per tab (negative when an entrance is resuming after a
re-render). Duration is scaled by --anim-enter-scale so one slider retimes
every style.
Colour/glow effects live on ::before, never on the tab itself: .session-tab.active
sets background/border/box-shadow with !important (and a newly created session is
normally the active one), and !important beats an animation. Transform and opacity
are unaffected, so the tab keeps those. */
.session-tab.tab-enter {
animation-delay: var(--tab-enter-delay, 0ms);
animation-fill-mode: both;
will-change: transform, opacity;
}
.session-tab.tab-enter::before {
content: "";
position: absolute;
inset: -1px;
border-radius: inherit;
pointer-events: none;
opacity: 0;
animation-delay: var(--tab-enter-delay, 0ms);
animation-fill-mode: both;
}
/* Slide, drifts in from the right and settles. */
html[data-tab-anim="slide"] .session-tab.tab-enter {
animation-name: tab-enter-slide;
animation-duration: calc(380ms * var(--anim-enter-scale, 1));
animation-timing-function: cubic-bezier(0.22, 1, 0.36, 1);
}
@keyframes tab-enter-slide {
from { opacity: 0; transform: translate3d(24px, 3px, 0) scale(0.96); }
to { opacity: 1; transform: none; }
}
/* Pop, springs past full size, then settles back. */
html[data-tab-anim="pop"] .session-tab.tab-enter {
animation-name: tab-enter-pop;
animation-duration: calc(460ms * var(--anim-enter-scale, 1));
animation-timing-function: ease-out;
}
@keyframes tab-enter-pop {
0% { opacity: 0; transform: scale(0.4); }
55% { opacity: 1; transform: scale(1.13); }
76% { opacity: 1; transform: scale(0.965); }
100% { opacity: 1; transform: scale(1); }
}
/* CRT, snaps open as a hot line, then unfolds vertically. */
html[data-tab-anim="crt"] .session-tab.tab-enter {
animation-name: tab-enter-crt;
animation-duration: calc(520ms * var(--anim-enter-scale, 1));
animation-timing-function: cubic-bezier(0.3, 0.9, 0.3, 1);
}
html[data-tab-anim="crt"] .session-tab.tab-enter::before {
animation-name: tab-enter-crt-flash;
animation-duration: calc(520ms * var(--anim-enter-scale, 1));
animation-timing-function: ease-out;
}
@keyframes tab-enter-crt {
0% { opacity: 0; transform: scale3d(0.04, 0.09, 1); }
20% { opacity: 1; transform: scale3d(1, 0.09, 1); }
56% { opacity: 1; transform: scale3d(1, 1.14, 1); }
78% { opacity: 1; transform: scale3d(1, 0.95, 1); }
100% { opacity: 1; transform: none; }
}
@keyframes tab-enter-crt-flash {
0% { opacity: 1; background: rgba(205, 255, 225, 0.95); box-shadow: 0 0 26px 8px rgba(0, 255, 102, 0.6); }
20% { opacity: 0.9; background: rgba(150, 255, 195, 0.7); box-shadow: 0 0 20px 6px rgba(0, 255, 102, 0.45); }
60% { opacity: 0.32; background: rgba(0, 255, 102, 0.14); box-shadow: 0 0 12px 2px rgba(0, 255, 102, 0.22); }
100% { opacity: 0; background: transparent; box-shadow: none; }
}
/* Unroll, the strip makes room and the tab widens into it. */
html[data-tab-anim="unroll"] .session-tab.tab-enter {
animation-name: tab-enter-unroll;
animation-duration: calc(480ms * var(--anim-enter-scale, 1));
animation-timing-function: cubic-bezier(0.22, 1, 0.36, 1);
overflow: hidden;
}
@keyframes tab-enter-unroll {
0% { max-width: 0; opacity: 0; padding-left: 0; padding-right: 0; transform: translateY(3px); }
30% { opacity: 1; }
100% { max-width: 900px; opacity: 1; padding-left: 0.6rem; padding-right: 0.6rem; transform: none; }
}
/* Boot, flickers on under a green scan sweep, like a display warming up. */
html[data-tab-anim="boot"] .session-tab.tab-enter {
animation-name: tab-enter-boot;
animation-duration: calc(720ms * var(--anim-enter-scale, 1));
animation-timing-function: linear;
}
html[data-tab-anim="boot"] .session-tab.tab-enter::before {
background: linear-gradient(
100deg,
transparent 0%,
rgba(0, 255, 102, 0.06) 34%,
rgba(200, 255, 220, 0.5) 50%,
rgba(0, 255, 102, 0.06) 66%,
transparent 100%
);
background-size: 280% 100%;
animation-name: tab-enter-boot-scan;
animation-duration: calc(720ms * var(--anim-enter-scale, 1));
animation-timing-function: ease-in-out;
}
@keyframes tab-enter-boot {
0% { opacity: 0; transform: translate3d(0, 5px, 0); }
8% { opacity: 0.85; }
15% { opacity: 0.12; }
23% { opacity: 1; }
31% { opacity: 0.35; }
42% { opacity: 1; transform: none; }
55% { opacity: 0.72; }
66% { opacity: 1; }
100% { opacity: 1; transform: none; }
}
@keyframes tab-enter-boot-scan {
0% { opacity: 0; background-position: 150% 0; }
12% { opacity: 1; }
86% { opacity: 1; }
100% { opacity: 0; background-position: -50% 0; }
}
/* Flip, drops in as a card hinged on its top edge. */
html[data-tab-anim="flip"] .session-tab.tab-enter {
animation-name: tab-enter-flip;
animation-duration: calc(520ms * var(--anim-enter-scale, 1));
animation-timing-function: cubic-bezier(0.3, 0.9, 0.3, 1);
transform-origin: 50% 0%;
}
@keyframes tab-enter-flip {
0% { opacity: 0; transform: perspective(700px) rotateX(-92deg); }
55% { opacity: 1; transform: perspective(700px) rotateX(13deg); }
78% { opacity: 1; transform: perspective(700px) rotateX(-5deg); }
100% { opacity: 1; transform: perspective(700px) rotateX(0deg); }
}
/* ── Entrance lab (?animlab=1) ───────────────────────────────────────────── */
.anim-lab {
position: fixed;
right: 14px;
bottom: 14px;
z-index: 3100;
width: 300px;
max-height: 88vh;
display: flex;
flex-direction: column;
padding: 10px 12px 12px;
border: 1px solid var(--border-light);
border-radius: 10px;
background: rgba(12, 16, 14, 0.96);
box-shadow: 0 12px 40px rgba(0, 0, 0, 0.55);
color: var(--text);
font-size: 0.72rem;
backdrop-filter: blur(6px);
}
.anim-lab-head {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 8px;
font-weight: 600;
letter-spacing: 0.02em;
}
.anim-lab-close {
background: none;
border: none;
color: var(--text-dim);
font-size: 1.1rem;
line-height: 1;
cursor: pointer;
}
.anim-lab-close:hover { color: var(--text); }
.anim-lab-themes {
display: flex;
flex-wrap: wrap;
gap: 4px;
margin-bottom: 10px;
}
.anim-lab-themes button {
padding: 3px 8px;
border: 1px solid var(--border-light);
border-radius: 999px;
background: rgba(255, 255, 255, 0.04);
color: var(--text-dim);
font-size: 0.66rem;
cursor: pointer;
}
.anim-lab-themes button:hover { background: rgba(34, 197, 94, 0.16); color: var(--text); }
.anim-lab-themes button.selected {
border-color: rgba(0, 255, 102, 0.7);
background: rgba(34, 197, 94, 0.2);
color: #fff;
}
.anim-lab-scroll {
flex: 1;
min-height: 0;
overflow-y: auto;
margin-bottom: 8px;
}
.anim-lab-scroll::-webkit-scrollbar { width: 5px; }
.anim-lab-scroll::-webkit-scrollbar-thumb { background: var(--border); border-radius: 3px; }
.anim-lab-group { margin-bottom: 10px; }
.anim-lab-group-title {
margin-bottom: 4px;
font-size: 0.62rem;
font-weight: 700;
letter-spacing: 0.08em;
text-transform: uppercase;
color: var(--text-muted);
}
.anim-lab-style {
display: flex;
flex-direction: column;
gap: 1px;
width: 100%;
margin-bottom: 3px;
padding: 5px 8px;
border: 1px solid transparent;
border-radius: 6px;
background: rgba(255, 255, 255, 0.03);
color: var(--text-dim);
text-align: left;
cursor: pointer;
}
.anim-lab-style:hover { background: rgba(34, 197, 94, 0.09); color: var(--text); }
.anim-lab-style.selected {
border-color: rgba(0, 255, 102, 0.6);
background: rgba(34, 197, 94, 0.14);
color: #fff;
}
.anim-lab-style strong { font-size: 0.73rem; font-weight: 600; }
.anim-lab-style em { font-style: normal; font-size: 0.64rem; opacity: 0.7; }
.anim-lab-range {
display: grid;
grid-template-columns: auto 1fr;
align-items: center;
gap: 4px 8px;
margin-bottom: 6px;
color: var(--text-dim);
}
.anim-lab-range output { justify-self: end; color: var(--text); font-variant-numeric: tabular-nums; }
.anim-lab-range input { grid-column: 1 / -1; width: 100%; accent-color: #00ff66; }
.anim-lab-demo {
display: flex;
align-items: center;
gap: 6px;
margin-top: 8px;
color: var(--text-dim);
}
.anim-lab-demo button {
flex: 1;
padding: 4px 0;
border: 1px solid var(--border-light);
border-radius: 6px;
background: rgba(255, 255, 255, 0.04);
color: var(--text);
cursor: pointer;
}
.anim-lab-demo button:hover { background: rgba(34, 197, 94, 0.16); border-color: rgba(0, 255, 102, 0.5); }
.anim-lab-hint { margin: 8px 0 0; font-size: 0.64rem; opacity: 0.55; }
/* ── Window entrance animations ────────────────────────────────────────────
Applied to a window already sitting at its resting position. The `fly` style
is NOT here: it is the pre-existing JS transition in subagent-windows.js that
flies the window out of its parent tab, and it skips this class entirely. */
.subagent-window.win-enter,
.ultracode-window.win-enter {
animation-delay: var(--win-enter-delay, 0ms);
animation-fill-mode: both;
will-change: transform, opacity, filter;
}
.subagent-window.win-enter::before,
.ultracode-window.win-enter::before {
content: "";
position: absolute;
inset: 0;
z-index: 5;
border-radius: inherit;
pointer-events: none;
opacity: 0;
animation-delay: var(--win-enter-delay, 0ms);
animation-fill-mode: both;
}
/* CRT, bursts open as a hot line, then unfolds vertically. */
html[data-win-anim="crt"] .subagent-window.win-enter,
html[data-win-anim="crt"] .ultracode-window.win-enter {
animation-name: win-enter-crt;
animation-duration: calc(560ms * var(--anim-enter-scale, 1));
animation-timing-function: cubic-bezier(0.3, 0.9, 0.3, 1);
}
html[data-win-anim="crt"] .subagent-window.win-enter::before,
html[data-win-anim="crt"] .ultracode-window.win-enter::before {
animation-name: win-enter-crt-flash;
animation-duration: calc(560ms * var(--anim-enter-scale, 1));
animation-timing-function: ease-out;
}
@keyframes win-enter-crt {
0% { opacity: 0; transform: scale3d(0.55, 0.008, 1); }
22% { opacity: 1; transform: scale3d(1, 0.01, 1); }
58% { opacity: 1; transform: scale3d(1, 1.05, 1); }
80% { opacity: 1; transform: scale3d(1, 0.98, 1); }
100% { opacity: 1; transform: none; }
}
@keyframes win-enter-crt-flash {
0% { opacity: 1; background: rgba(205, 255, 225, 0.9); box-shadow: 0 0 40px 14px rgba(0, 255, 102, 0.55); }
22% { opacity: 0.8; background: rgba(120, 255, 180, 0.5); box-shadow: 0 0 30px 10px rgba(0, 255, 102, 0.4); }
60% { opacity: 0.25; background: rgba(0, 255, 102, 0.1); box-shadow: 0 0 14px 3px rgba(0, 255, 102, 0.18); }
100% { opacity: 0; background: transparent; box-shadow: none; }
}
/* Materialize, resolves out of a blur with a short glitch. */
html[data-win-anim="materialize"] .subagent-window.win-enter,
html[data-win-anim="materialize"] .ultracode-window.win-enter {
animation-name: win-enter-materialize;
animation-duration: calc(620ms * var(--anim-enter-scale, 1));
animation-timing-function: cubic-bezier(0.22, 1, 0.36, 1);
}
@keyframes win-enter-materialize {
0% { opacity: 0; transform: scale(0.94); filter: blur(16px) brightness(1.7); }
35% { opacity: 0.85; filter: blur(6px) brightness(1.3); }
45% { opacity: 0.4; }
55% { opacity: 0.95; filter: blur(3px) brightness(1.15); }
70% { opacity: 0.75; }
100% { opacity: 1; transform: none; filter: none; }
}
/* Unfold, hinges down from the top edge. */
html[data-win-anim="unfold"] .subagent-window.win-enter,
html[data-win-anim="unfold"] .ultracode-window.win-enter {
animation-name: win-enter-unfold;
animation-duration: calc(560ms * var(--anim-enter-scale, 1));
animation-timing-function: cubic-bezier(0.3, 0.9, 0.3, 1);
transform-origin: 50% 0%;
}
@keyframes win-enter-unfold {
0% { opacity: 0; transform: perspective(1600px) rotateX(-88deg); }
55% { opacity: 1; transform: perspective(1600px) rotateX(9deg); }
80% { opacity: 1; transform: perspective(1600px) rotateX(-3deg); }
100% { opacity: 1; transform: perspective(1600px) rotateX(0deg); }
}
/* Beam down, holds still (so its connection line can draw toward a stable rect),
then materializes out of a green wash. NO transform: a transformed window
reports a transformed rect and the line would aim at the wrong place. */
html[data-win-anim="beam"] .subagent-window.win-enter,
html[data-win-anim="beam"] .ultracode-window.win-enter {
animation-name: win-enter-beam;
animation-duration: calc(620ms * var(--anim-enter-scale, 1));
animation-timing-function: ease-out;
}
html[data-win-anim="beam"] .subagent-window.win-enter::before,
html[data-win-anim="beam"] .ultracode-window.win-enter::before {
animation-name: win-enter-beam-wash;
animation-duration: calc(620ms * var(--anim-enter-scale, 1));
animation-timing-function: ease-out;
}
@keyframes win-enter-beam {
0% { opacity: 0; filter: brightness(2.6) saturate(0.4); }
20% { opacity: 0.55; }
32% { opacity: 0.2; }
48% { opacity: 0.9; filter: brightness(1.5) saturate(0.8); }
100% { opacity: 1; filter: none; }
}
@keyframes win-enter-beam-wash {
0% { opacity: 0.9; background: linear-gradient(180deg, rgba(190, 255, 215, 0.85), rgba(0, 255, 102, 0)); }
45% { opacity: 0.5; background: linear-gradient(180deg, rgba(0, 255, 102, 0.35), rgba(0, 255, 102, 0)); }
100% { opacity: 0; background: transparent; }
}
/* Pop, springs open from the centre. */
html[data-win-anim="pop"] .subagent-window.win-enter,
html[data-win-anim="pop"] .ultracode-window.win-enter {
animation-name: win-enter-pop;
animation-duration: calc(460ms * var(--anim-enter-scale, 1));
animation-timing-function: ease-out;
}
@keyframes win-enter-pop {
0% { opacity: 0; transform: scale(0.55); }
58% { opacity: 1; transform: scale(1.045); }
80% { opacity: 1; transform: scale(0.99); }
100% { opacity: 1; transform: scale(1); }
}
/* ── Main terminal pane entrance animations ────────────────────────────────
⚠ transform / opacity / clip-path ONLY. xterm's FitAddon derives rows+cols
from getComputedStyle(parent).width/height, the untransformed layout box -
so these are invisible to it, but animating width/height/padding here would
resize the PTY mid-animation. Colour washes go on ::before, never as a
`filter` on the container: that would blur a full-screen WebGL canvas every
frame. `will-change` is deliberately not set, the base rule needs its
`will-change: contents` for terminal compositing. */
.terminal-container.term-enter {
animation-fill-mode: both;
}
.terminal-container.term-enter::before {
content: "";
position: absolute;
inset: 0;
z-index: 4;
pointer-events: none;
opacity: 0;
animation-fill-mode: both;
}
/* CRT, power-on: a hot line that expands to full height. */
html[data-term-anim="crt"] .terminal-container.term-enter {
animation-name: term-enter-crt;
animation-duration: calc(560ms * var(--anim-enter-scale, 1));
animation-timing-function: cubic-bezier(0.3, 0.9, 0.3, 1);
}
html[data-term-anim="crt"] .terminal-container.term-enter::before {
animation-name: term-enter-crt-flash;
animation-duration: calc(560ms * var(--anim-enter-scale, 1));
animation-timing-function: ease-out;
}
@keyframes term-enter-crt {
0% { opacity: 0; transform: scale3d(0.6, 0.004, 1); }
20% { opacity: 1; transform: scale3d(1, 0.006, 1); }
58% { opacity: 1; transform: scale3d(1, 1.03, 1); }
80% { opacity: 1; transform: scale3d(1, 0.99, 1); }
100% { opacity: 1; transform: none; }
}
@keyframes term-enter-crt-flash {
0% { opacity: 1; background: rgba(210, 255, 230, 0.85); }
20% { opacity: 0.7; background: rgba(120, 255, 180, 0.4); }
60% { opacity: 0.2; background: rgba(0, 255, 102, 0.08); }
100% { opacity: 0; background: transparent; }
}
/* Boot, flickers on under a green scan sweep. */
html[data-term-anim="boot"] .terminal-container.term-enter {
animation-name: term-enter-boot;
animation-duration: calc(760ms * var(--anim-enter-scale, 1));
animation-timing-function: linear;
}
html[data-term-anim="boot"] .terminal-container.term-enter::before {
background: linear-gradient(
180deg,
transparent 0%,
rgba(0, 255, 102, 0.05) 40%,
rgba(200, 255, 220, 0.28) 50%,
rgba(0, 255, 102, 0.05) 60%,
transparent 100%
);
background-size: 100% 300%;
animation-name: term-enter-boot-scan;
animation-duration: calc(760ms * var(--anim-enter-scale, 1));
animation-timing-function: ease-in-out;
}
@keyframes term-enter-boot {
0% { opacity: 0; transform: scale(0.995); }
8% { opacity: 0.85; }
15% { opacity: 0.1; }
24% { opacity: 1; }
33% { opacity: 0.35; }
45% { opacity: 1; transform: none; }
58% { opacity: 0.7; }
70% { opacity: 1; }
100% { opacity: 1; transform: none; }
}
@keyframes term-enter-boot-scan {
0% { opacity: 0; background-position: 0 -150%; }
12% { opacity: 1; }
86% { opacity: 1; }
100% { opacity: 0; background-position: 0 150%; }
}
/* Wipe, reveals top-to-bottom behind a bright edge. */
html[data-term-anim="wipe"] .terminal-container.term-enter {
animation-name: term-enter-wipe;
animation-duration: calc(520ms * var(--anim-enter-scale, 1));
animation-timing-function: cubic-bezier(0.35, 0.85, 0.3, 1);
}
html[data-term-anim="wipe"] .terminal-container.term-enter::before {
background: linear-gradient(180deg, rgba(0, 255, 102, 0.16) 0%, rgba(190, 255, 215, 0.5) 82%, transparent 100%);
animation-name: term-enter-wipe-edge;
animation-duration: calc(520ms * var(--anim-enter-scale, 1));
animation-timing-function: cubic-bezier(0.35, 0.85, 0.3, 1);
}
@keyframes term-enter-wipe {
0% { opacity: 1; clip-path: inset(0 0 100% 0); }
100% { opacity: 1; clip-path: inset(0 0 0 0); }
}
@keyframes term-enter-wipe-edge {
0% { opacity: 0.9; clip-path: inset(0 0 100% 0); }
80% { opacity: 0.45; }
100% { opacity: 0; clip-path: inset(0 0 0 0); }
}
/* Slide up, rises into place from below. */
html[data-term-anim="slide"] .terminal-container.term-enter {
animation-name: term-enter-slide;
animation-duration: calc(420ms * var(--anim-enter-scale, 1));
animation-timing-function: cubic-bezier(0.22, 1, 0.36, 1);
}
@keyframes term-enter-slide {
0% { opacity: 0; transform: translate3d(0, 34px, 0); }
100% { opacity: 1; transform: none; }
}
/* Fade, quiet, with a touch of scale. */
html[data-term-anim="fade"] .terminal-container.term-enter {
animation-name: term-enter-fade;
animation-duration: calc(340ms * var(--anim-enter-scale, 1));
animation-timing-function: ease-out;
}
@keyframes term-enter-fade {
0% { opacity: 0; transform: scale(0.985); }
100% { opacity: 1; transform: none; }
}
/* ── Connection-line entrance animations ───────────────────────────────────
`--line-len` is the measured path length, stamped inline by
_applyLineEntrances(); `--line-enter-delay` is negative when an entrance is
resuming after the SVG was rebuilt underneath it. */
.connection-line.line-enter {
animation-delay: var(--line-enter-delay, 0ms);
animation-fill-mode: both;
}
/* Draw, the line paints itself from the tab down to the window. Overrides the
resting 5 3 dash to a single full-length dash for the duration. */
html[data-line-anim="draw"] .connection-line.line-enter {
stroke-dasharray: var(--line-len);
animation-name: line-enter-draw;
animation-duration: calc(420ms * var(--anim-enter-scale, 1));
animation-timing-function: cubic-bezier(0.32, 0.8, 0.3, 1);
}
@keyframes line-enter-draw {
from { stroke-dashoffset: var(--line-len); opacity: 1; }
to { stroke-dashoffset: 0; opacity: 0.9; }
}
/* Fade, plain opacity ramp, dashes intact. */
html[data-line-anim="fade"] .connection-line.line-enter {
animation-name: line-enter-fade;
animation-duration: calc(300ms * var(--anim-enter-scale, 1));
animation-timing-function: ease-out;
}
@keyframes line-enter-fade {
from { opacity: 0; }
to { opacity: 0.9; }
}
/* Packet, the base line fades in and a separate bright dash rides down it. The
packet is its own cloned path so the base line keeps its dashed look. */
html[data-line-anim="packet"] .connection-line.line-enter {
animation-name: line-enter-fade;
animation-duration: calc(300ms * var(--anim-enter-scale, 1));
animation-timing-function: ease-out;
}
.connection-line-packet {
fill: none;
stroke: #cdfbe3;
stroke-width: 4;
stroke-linecap: round;
pointer-events: none;
stroke-dasharray: 20px 100000px;
filter: drop-shadow(0 0 4px rgba(0, 255, 102, 0.95)) drop-shadow(0 0 10px rgba(59, 130, 246, 0.7));
animation-name: line-enter-packet;
animation-duration: calc(700ms * var(--anim-enter-scale, 1));
animation-delay: var(--line-enter-delay, 0ms);
animation-timing-function: cubic-bezier(0.4, 0, 0.3, 1);
animation-fill-mode: both;
}
@keyframes line-enter-packet {
0% { stroke-dashoffset: 20px; opacity: 0; }
12% { opacity: 1; }
85% { opacity: 1; }
100% { stroke-dashoffset: calc(-1 * var(--line-len)); opacity: 0; }
}
.anim-lab-check {
display: flex;
align-items: center;
gap: 6px;
margin: -4px 0 12px;
padding: 0 2px;
color: var(--text-dim);
font-size: 0.66rem;
cursor: pointer;
}
.anim-lab-check input { accent-color: #00ff66; }
@media (prefers-reduced-motion: reduce) {
.session-tab.tab-enter,
.session-tab.tab-enter::before,
.subagent-window.win-enter,
.subagent-window.win-enter::before,
.ultracode-window.win-enter,
.ultracode-window.win-enter::before,
.terminal-container.term-enter,
.terminal-container.term-enter::before,
.connection-line.line-enter,
.connection-line-packet {
animation: none !important;
}
}
.session-tab .tab-status {
width: 6px;
height: 6px;
@@ -1447,6 +2116,11 @@ body.solo-mode .btn-lifecycle-log {
color: #8ab4f8;
}
.session-tab .tab-mode.antigravity {
background: rgba(34, 211, 238, 0.2);
color: #22d3ee;
}
/* Timer Banner - Compact */
.timer-banner {
display: flex;
@@ -3531,6 +4205,22 @@ body.touch-device .terminal-container .xterm .xterm-helper-textarea {
color: #eff6ff;
}
/* Antigravity mode colors */
.btn-toolbar.btn-run.mode-antigravity,
.btn-toolbar.btn-run-gear.mode-antigravity {
background: linear-gradient(135deg, #0b2b33 0%, #0e7490 55%, #0891b2 100%);
border-color: rgba(34, 211, 238, 0.5);
color: #cffafe;
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.2), inset 0 1px 0 rgba(255, 255, 255, 0.06);
}
.btn-toolbar.btn-run.mode-antigravity:hover,
.btn-toolbar.btn-run-gear.mode-antigravity:hover {
background: linear-gradient(135deg, #124450 0%, #0891b2 55%, #06b6d4 100%);
box-shadow: 0 0 12px rgba(34, 211, 238, 0.35), 0 2px 8px rgba(8, 145, 178, 0.2), inset 0 1px 0 rgba(255, 255, 255, 0.08);
border-color: rgba(103, 232, 249, 0.6);
color: #ecfeff;
}
/* Dropdown menu */
.run-mode-menu {
display: none;
@@ -3546,7 +4236,12 @@ body.touch-device .terminal-container .xterm .xterm-helper-textarea {
padding: 4px;
z-index: 1000;
min-width: 190px;
max-width: 300px;
/* Every long row inside (recent-session paths, saved URL names) ellipsizes, so
the cap is a deliberate choice rather than a fit-the-content result: the menu
overlays the terminal and does not need to reach across it. 250 rather than a
rounder 240 because it is the width at which the common `~/<dir>/<repo>` +
timestamp recent-session row still fits whole, which is what that list is for. */
max-width: 250px;
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.5), 0 2px 8px rgba(0, 0, 0, 0.3);
}
.run-mode-menu.active {
@@ -3585,6 +4280,7 @@ body.touch-device .terminal-container .xterm .xterm-helper-textarea {
.run-mode-dot.opencode { background: #10b981; }
.run-mode-dot.codex { background: #a855f7; }
.run-mode-dot.gemini { background: #8ab4f8; }
.run-mode-dot.antigravity { background: #22d3ee; }
.run-mode-dot.shell { background: #94a3b8; }
/* Phone-only Enter button (see index.html). Hidden by default at every width;
@@ -3611,8 +4307,19 @@ body.touch-device .terminal-container .xterm .xterm-helper-textarea {
max-height: 200px;
overflow-y: auto;
}
/* `overflow-y: auto` computes overflow-x to auto as well, which makes this a scroll
container and lets a row size itself to its own content. A long path therefore
scrolled sideways instead of ellipsizing, and before the menu was narrowed it
simply pinned the menu at its max-width. */
.run-mode-history .run-mode-option {
max-width: 100%;
}
.run-mode-option .hist-dir {
flex: 1;
/* A flex item's default min-width is auto, so without this the path refuses to
shrink below its own text and pushes the date out of the menu instead of
ellipsizing. Only visible once the menu is narrow enough to force the choice. */
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
@@ -10232,9 +10939,10 @@ kbd {
}
/* Plan-usage chip (App Settings → Display → "Plan Usage Limits"). Shows the
live 5-hour + weekly plan limits parsed from the Claude statusline. Hidden by
default via the marker class below; the server strips it at render when the
setting is on and the client toggles it live on save. */
live 5-hour + weekly plan limits parsed from the Claude statusline. Ships
hidden via the marker class below because display is PER-DEVICE and the
server cannot read localStorage; settings-ui.js reveals it on load (desktop
default ON, handhelds OFF) and on a live toggle. */
.header-plan-usage {
display: inline-flex !important;
align-items: center;
@@ -12561,6 +13269,53 @@ html[data-skin="daylight-blue"] .welcome-btn-tunnel.active:hover {
.run-mode-dot.web { background: #38bdf8; }
.run-mode-webviews { max-height: 180px; overflow-y: auto; }
/* A saved URL is a ROW: open on the left, edit + delete on the right, so a URL can
be changed or removed without first opening it as a tab. The side buttons stay
permanently visible rather than hover-revealed, because this menu is used on
touch devices where there is no hover to reveal them with. */
.run-mode-row--web {
display: flex;
align-items: center;
gap: 2px;
}
.run-mode-row--web .run-mode-option--web {
flex: 1 1 auto;
min-width: 0;
overflow: hidden;
}
/* The label needs its own element: `text-overflow` on the button does nothing,
because the button is a flex container and a bare text node in one becomes an
anonymous flex item that ellipsis cannot reach. Without this a long dashboard
name widens the whole menu instead of truncating. */
.run-mode-web-name {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.run-mode-row-btn {
flex: 0 0 auto;
display: inline-flex;
align-items: center;
justify-content: center;
width: 24px;
height: 24px;
padding: 0;
background: none;
border: none;
border-radius: var(--btn-radius);
color: var(--text-dim);
font-size: 0.9rem;
line-height: 1;
cursor: pointer;
}
.run-mode-row-btn:hover {
background: rgba(255, 255, 255, 0.07);
color: var(--text);
}
.run-mode-webview-delete { font-size: 1.05rem; }
.run-mode-webview-delete:hover { color: var(--danger, #ef4444); }
.run-mode-empty {
padding: 4px 10px 6px;
font-size: 0.75em;
+27 -3
View File
@@ -465,6 +465,10 @@ Object.assign(CodemanApp.prototype, {
if (typeof this._appendUltracodeAgentConnectionLines === 'function') {
this._appendUltracodeAgentConnectionLines(svg, rects);
}
// Every path above was just created from scratch, so any line entrance in
// flight has to be re-attached here (resumed via a negative animation-delay).
this._applyLineEntrances?.(svg);
},
// ═══════════════════════════════════════════════════════════════
@@ -729,12 +733,17 @@ Object.assign(CodemanApp.prototype, {
</div>
`;
// Only the `fly` entrance style parks the window on its parent tab; every
// CSS-animated style (entrance-animations.js) needs it to start at its
// resting position, or the animation would play at the wrong place.
const flyFromTab = !!parentTab && !isMobile && this.windowEntranceFliesFromTab?.() !== false;
// If we have a parent tab, start window at tab position for spawn animation
if (isMobile) {
// Mobile: position using top (keyboard-aware positioning calculated above)
win.style.top = `${finalY}px`;
win.style.bottom = 'auto';
} else if (parentTab) {
} else if (flyFromTab) {
const tabRect = parentTab.getBoundingClientRect();
win.style.left = `${tabRect.left}px`;
win.style.top = `${tabRect.bottom}px`;
@@ -812,7 +821,7 @@ Object.assign(CodemanApp.prototype, {
this.subagentWindows.get(agentId).resizeObserver = resizeObserver;
// Animate to final position if spawning from tab (desktop only)
if (parentTab && !isMobile) {
if (flyFromTab) {
requestAnimationFrame(() => {
win.style.transition = 'all 0.4s cubic-bezier(0.34, 1.56, 0.64, 1)';
win.style.left = `${finalX}px`;
@@ -824,11 +833,26 @@ Object.assign(CodemanApp.prototype, {
setTimeout(() => {
win.style.transition = '';
win.classList.remove('spawning');
// The line only becomes meaningful once the window has landed, so its
// draw-in starts here rather than at spawn.
this.markConnectionLineEntering?.(agentId);
this.updateConnectionLines();
}, 400);
});
} else {
// No animation (mobile uses CSS positioning), just update connection lines
// CSS-animated entrance styles (and mobile) run in place. The window is
// already at its resting position, so the connection line can be drawn
// against a correct rect right away and animate alongside it.
//
// Skipped when the window spawns hidden (its agent belongs to a background
// tab): a display:none element never runs its animation, so `animationend`
// would never fire and the entrance class plus its inline custom property
// would stick to the window forever. Nothing is visible to animate anyway,
// and revealing it later is a tab switch, not a spawn.
if (!shouldHide) {
this.applyWindowEntrance?.(win);
this.markConnectionLineEntering?.(agentId);
}
this.updateConnectionLines();
}
+14
View File
@@ -1178,6 +1178,19 @@ Object.assign(CodemanApp.prototype, {
},
showWelcome() {
// Phones get the session overview instead of the welcome screen: on a small
// screen "which session is blocked on me" beats "how do I start one". The
// gate lives in mobile-overview.js; every other device falls through
// unchanged. Both surfaces are toggled here so a breakpoint change (rotate,
// unfold) swaps cleanly instead of showing both.
if (this.shouldUseMobileOverview?.()) {
const overlay = document.getElementById('welcomeOverlay');
if (overlay) overlay.classList.remove('visible');
this.showMobileOverview();
this._updateCjkInputState?.();
return;
}
this.hideMobileOverview?.();
const overlay = document.getElementById('welcomeOverlay');
if (overlay) {
overlay.classList.add('visible');
@@ -1191,6 +1204,7 @@ Object.assign(CodemanApp.prototype, {
},
hideWelcome() {
this.hideMobileOverview?.();
const overlay = document.getElementById('welcomeOverlay');
if (overlay) {
overlay.classList.remove('visible');
+4
View File
@@ -210,6 +210,9 @@ Object.assign(CodemanApp.prototype, {
document.body.appendChild(win);
// Drop the spawn class on the next frame so the transition runs.
requestAnimationFrame(() => win.classList.remove('spawning'));
// Then hand over to the chosen entrance style (no-op for `fly`/`off`, which
// leave the small scale-in transition above as the whole animation).
this.applyWindowEntrance?.(win);
const header = win.querySelector('.ultracode-window-header');
const dragListeners = this.makeWindowDraggable(win, header);
@@ -586,6 +589,7 @@ Object.assign(CodemanApp.prototype, {
document.body.appendChild(win);
requestAnimationFrame(() => win.classList.remove('spawning'));
this.applyWindowEntrance?.(win);
const header = win.querySelector('.ultracode-window-header');
const dragListeners = this.makeWindowDraggable(win, header);
+43 -13
View File
@@ -290,7 +290,13 @@ Object.assign(CodemanApp.prototype, {
// ── Run-menu entries ──────────────────────────────────────────────────────
/** Saved dashboards listed inside the Run dropdown, under "Web / URL". */
/**
* Saved dashboards listed inside the Run dropdown, under "Web / URL".
*
* Each row carries its own edit and delete buttons. Without them the only way to
* change or remove a saved URL was to open it as a tab first and go through the
* tab's gear, which is a dead end for a URL you no longer want open at all.
*/
renderWebviewMenuItems() {
const container = document.getElementById('runModeWebviews');
if (!container) return;
@@ -300,15 +306,20 @@ Object.assign(CodemanApp.prototype, {
return;
}
container.innerHTML = list
.map(
(w) => `<button class="run-mode-option run-mode-option--web" onclick="app.openWebviewFromMenu(${escapeHtml(
JSON.stringify(w.id)
)})" title="${escapeHtml(w.url)}">
<span class="run-mode-menu-icon">${w.icon ? escapeHtml(w.icon) : '<span class="run-mode-dot web"></span>'}</span>${escapeHtml(
w.name
)}
</button>`
)
.map((w) => {
const jsonId = escapeHtml(JSON.stringify(w.id));
const name = escapeHtml(w.name);
const icon = w.icon ? escapeHtml(w.icon) : '<span class="run-mode-dot web"></span>';
return `<div class="run-mode-row run-mode-row--web">
<button class="run-mode-option run-mode-option--web" onclick="app.openWebviewFromMenu(${jsonId})" title="${escapeHtml(w.url)}">
<span class="run-mode-menu-icon">${icon}</span><span class="run-mode-web-name">${name}</span>
</button>
<button class="run-mode-row-btn run-mode-webview-edit" onclick="event.stopPropagation(); app.showWebviewModal(${jsonId})"
title="Edit URL" aria-label="Edit ${name}">&#x2699;</button>
<button class="run-mode-row-btn run-mode-webview-delete" onclick="event.stopPropagation(); app.deleteWebviewById(${jsonId})"
title="Delete URL" aria-label="Delete ${name}">&times;</button>
</div>`;
})
.join('');
},
@@ -429,17 +440,36 @@ Object.assign(CodemanApp.prototype, {
async deleteWebview() {
const id = this._editingWebviewId;
if (!id) return;
if (await this._confirmAndDeleteWebview(id)) this.closeWebviewModal();
},
/**
* Delete straight from a Run-dropdown row, without opening the editor first.
*
* The dropdown's outside-click handler closes the menu when the click target is
* not inside it, and by the time the delete resolves this row is gone, so the
* menu is re-asserted open: deleting one of several saved URLs should leave you
* looking at the rest of the list.
*/
async deleteWebviewById(id) {
if (!id) return;
if (!(await this._confirmAndDeleteWebview(id))) return;
document.getElementById('runModeMenu')?.classList.add('active');
},
/** Shared by the row button and the editor modal. @returns true when deleted. */
async _confirmAndDeleteWebview(id) {
const webview = this.webviews.get(id);
if (!confirm(`Delete "${webview?.name || id}"?`)) return;
if (!confirm(`Delete "${webview?.name || id}"?`)) return false;
const res = await this._apiDelete(`/api/webviews/${encodeURIComponent(id)}`);
if (!res || !res.ok) {
this.showToast?.('Could not delete URL', 'error');
return;
return false;
}
this._removeWebviewTab(id);
this.webviews.delete(id);
this.closeWebviewModal();
this.renderWebviewMenuItems();
this.renderSessionTabs();
return true;
},
});
+87 -37
View File
@@ -19,6 +19,7 @@ import {
type SessionColor,
type CodexConfig,
type GeminiConfig,
type AntigravityConfig,
} from '../../types.js';
import { Session, isAltScreenStripMode } from '../../session.js';
import { SseEvent } from '../sse-events.js';
@@ -65,7 +66,7 @@ import {
updateCaseModel,
stripCaseEnvKeys,
applyStatusLineConfig,
refreshStaleHookSecret,
refreshStaleCodemanHooks,
} from '../../hooks-config.js';
import { generateClaudeMd } from '../../templates/claude-md.js';
import { imageWatcher } from '../../image-watcher.js';
@@ -282,25 +283,35 @@ export function _resetPasteRateBuckets(): void {
/**
* Security (multi-user §6.3): the Claude-only permission-mode downgrade does not
* cover the other CLIs' bypass switches. Codex `--dangerously-bypass-approvals-and-sandbox`
* and Gemini `--approval-mode yolo` disable the safety classifier the non-granted-user
* downgrade is meant to keep on, so clamp them for a non-granted owner. buildGeminiCommand
* defaults an ABSENT approvalMode to yolo, so the gemini config must be MATERIALIZED
* (auto_edit) even when the request sent none. No-op in single-user mode / for a granted
* cover the other CLIs' bypass switches. Codex `--dangerously-bypass-approvals-and-sandbox`,
* Gemini `--approval-mode yolo`, and Antigravity `--dangerously-skip-permissions` disable
* the safety classifier the non-granted-user downgrade is meant to keep on, so clamp them
* for a non-granted owner. buildGeminiCommand defaults an ABSENT approvalMode to yolo, so
* the gemini config must be MATERIALIZED (auto_edit) even when the request sent none.
* Antigravity is like Codex: an ABSENT config already defaults safe (no bypass flag), so
* only a sent config needs the flag forced off. No-op in single-user mode / for a granted
* owner (canUsernameRunPrivilegedCommands returns true when !isMultiUserMode()).
*/
async function clampExternalCliBypassForOwner(
owner: string | undefined,
codexConfig: CodexConfig | undefined,
geminiConfig: GeminiConfig | undefined
): Promise<{ codexConfig: CodexConfig | undefined; geminiConfig: GeminiConfig | undefined }> {
geminiConfig: GeminiConfig | undefined,
antigravityConfig: AntigravityConfig | undefined
): Promise<{
codexConfig: CodexConfig | undefined;
geminiConfig: GeminiConfig | undefined;
antigravityConfig: AntigravityConfig | undefined;
}> {
const granted = await canUsernameRunPrivilegedCommands(owner);
if (granted) return { codexConfig, geminiConfig };
// Non-granted: force codex bypass off (only meaningful when a config was sent) and
// materialize gemini to auto_edit (clamps an explicit 'yolo' and the yolo default).
if (granted) return { codexConfig, geminiConfig, antigravityConfig };
// Non-granted: force codex/antigravity bypass off (only meaningful when a config was
// sent) and materialize gemini to auto_edit (clamps an explicit 'yolo' and the yolo default).
const clampedCodex = codexConfig ? { ...codexConfig, dangerouslyBypassApprovals: false } : codexConfig;
const clampedGemini: GeminiConfig = { ...(geminiConfig ?? {}), approvalMode: 'auto_edit' };
return { codexConfig: clampedCodex, geminiConfig: clampedGemini };
const clampedAntigravity = antigravityConfig
? { ...antigravityConfig, dangerouslySkipPermissions: false }
: antigravityConfig;
return { codexConfig: clampedCodex, geminiConfig: clampedGemini, antigravityConfig: clampedAntigravity };
}
export function registerSessionRoutes(
@@ -414,6 +425,7 @@ export function registerSessionRoutes(
body.mode !== 'opencode' &&
body.mode !== 'codex' &&
body.mode !== 'gemini' &&
body.mode !== 'antigravity' &&
body.envOverrides &&
Object.keys(body.envOverrides).length > 0 &&
(workingDir.startsWith(CASES_DIR + '/') || workingDir.startsWith(managedCasesBase + '/'));
@@ -445,7 +457,7 @@ export function registerSessionRoutes(
// unconditional hook-secret gate keeps accepting its hook events. No-op for fresh
// cases (writeHooksConfig already wrote the secret) and for non-Codeman/absent hooks.
if ((body.mode ?? 'claude') === 'claude') {
await refreshStaleHookSecret(workingDir).catch(() => {});
await refreshStaleCodemanHooks(workingDir).catch(() => {});
}
// Check OpenCode availability if requested
@@ -480,6 +492,15 @@ export function registerSessionRoutes(
);
}
}
if (body.mode === 'antigravity') {
const { isAntigravityAvailable } = await import('../../utils/antigravity-cli-resolver.js');
if (!isAntigravityAvailable()) {
return createErrorResponse(
ApiErrorCode.OPERATION_FAILED,
'Antigravity CLI not found. Install with: curl -fsSL https://antigravity.google/cli/install.sh | bash'
);
}
}
// Pre-validate resumeSessionId: check that the conversation file actually exists
// in Claude's projects directory. If not, skip resume to avoid confusing
@@ -521,18 +542,20 @@ export function registerSessionRoutes(
? body.codexConfig?.model
: mode === 'gemini'
? body.geminiConfig?.model
: mode !== 'shell'
? modelConfig?.defaultModel || undefined
: undefined;
: mode === 'antigravity'
? body.antigravityConfig?.model
: mode !== 'shell'
? modelConfig?.defaultModel || undefined
: undefined;
const claudeModeConfig = await ctx.getClaudeModeConfig();
// Section 6.3: force non-granted users to a classifier-guarded mode.
const effectiveClaudeMode = await resolveClaudeModeForUsername(claudeModeConfig.claudeMode, owner);
// Section 6.3: clamp Codex/Gemini bypass switches for a non-granted owner (no-op single-user/granted).
const { codexConfig: gatedCodexConfig, geminiConfig: gatedGeminiConfig } = await clampExternalCliBypassForOwner(
owner,
body.codexConfig,
body.geminiConfig
);
// Section 6.3: clamp Codex/Gemini/Antigravity bypass switches for a non-granted owner (no-op single-user/granted).
const {
codexConfig: gatedCodexConfig,
geminiConfig: gatedGeminiConfig,
antigravityConfig: gatedAntigravityConfig,
} = await clampExternalCliBypassForOwner(owner, body.codexConfig, body.geminiConfig, body.antigravityConfig);
const terminalHistoryConfig = await ctx.getTerminalHistoryConfig();
const session = new Session({
workingDir,
@@ -547,6 +570,7 @@ export function registerSessionRoutes(
openCodeConfig: mode === 'opencode' ? body.openCodeConfig : undefined,
codexConfig: mode === 'codex' ? gatedCodexConfig : undefined,
geminiConfig: mode === 'gemini' ? gatedGeminiConfig : undefined,
antigravityConfig: mode === 'antigravity' ? gatedAntigravityConfig : undefined,
resumeSessionId: validatedResumeId,
envOverrides: body.envOverrides,
effort: body.effort,
@@ -760,11 +784,12 @@ export function registerSessionRoutes(
try {
// Auto-detect completion phrase from CLAUDE.md BEFORE starting (only if globally enabled and not explicitly disabled by user)
// Ralph tracker is not supported for opencode / codex / gemini sessions
// Ralph tracker is not supported for opencode / codex / gemini / antigravity sessions
if (
session.mode !== 'opencode' &&
session.mode !== 'codex' &&
session.mode !== 'gemini' &&
session.mode !== 'antigravity' &&
ctx.store.getConfig().ralphEnabled &&
!session.ralphTracker.autoEnableDisabled
) {
@@ -1943,6 +1968,7 @@ export function registerSessionRoutes(
openCodeConfig,
codexConfig,
geminiConfig,
antigravityConfig,
envOverrides,
effort,
} = parseBody(QuickStartSchema, req.body);
@@ -1988,6 +2014,7 @@ export function registerSessionRoutes(
modelOverride !== undefined ||
codexConfig ||
geminiConfig ||
antigravityConfig ||
openCodeConfig
) {
return createErrorResponse(
@@ -2018,6 +2045,7 @@ export function registerSessionRoutes(
effort ||
codexConfig ||
geminiConfig ||
antigravityConfig ||
openCodeConfig
) {
return createErrorResponse(
@@ -2110,6 +2138,17 @@ export function registerSessionRoutes(
}
}
// Check Antigravity availability if requested
if (mode === 'antigravity') {
const { isAntigravityAvailable } = await import('../../utils/antigravity-cli-resolver.js');
if (!isAntigravityAvailable()) {
return createErrorResponse(
ApiErrorCode.OPERATION_FAILED,
'Antigravity CLI not found. Install with: curl -fsSL https://antigravity.google/cli/install.sh | bash'
);
}
}
// Resolve case path: check linked-cases registry first, then fall back to CASES_DIR.
// This mirrors the behaviour of resolveCasePath() in case-routes so that linked
// external project directories are honoured by quick-start just like regular case routes.
@@ -2156,8 +2195,8 @@ export function registerSessionRoutes(
writeFileSync(join(resolvedCasePath, 'CLAUDE.md'), claudeMd);
// Write .claude/settings.local.json with hooks for desktop notifications
// (Claude-specific — OpenCode, Codex, and Gemini use their own systems)
if (mode !== 'opencode' && mode !== 'codex' && mode !== 'gemini') {
// (Claude-specific — OpenCode, Codex, Gemini, and Antigravity use their own systems)
if (mode !== 'opencode' && mode !== 'codex' && mode !== 'gemini' && mode !== 'antigravity') {
await writeHooksConfig(resolvedCasePath);
}
@@ -2170,14 +2209,21 @@ export function registerSessionRoutes(
// now-unconditional hook-secret gate keeps accepting its hook events. No-op when
// the hooks aren't ours or already carry the secret. Skipped for remote cases —
// resolvedCasePath is a REMOTE path that doesn't exist on the local filesystem.
await refreshStaleHookSecret(resolvedCasePath).catch(() => {});
await refreshStaleCodemanHooks(resolvedCasePath).catch(() => {});
}
// Docker cases: the workspace is a REAL host dir bind-mounted into the container.
// Scaffold hooks (+ a CLAUDE.md) if MISSING so in-container permission prompts and
// hook-idle detection fire (decision: wire hooks now). Never clobbers an existing
// configured project. Skipped for external CLIs (they use their own systems).
if (docker && docker.hooksEnabled && mode !== 'opencode' && mode !== 'codex' && mode !== 'gemini') {
if (
docker &&
docker.hooksEnabled &&
mode !== 'opencode' &&
mode !== 'codex' &&
mode !== 'gemini' &&
mode !== 'antigravity'
) {
try {
if (!existsSync(join(resolvedCasePath, 'CLAUDE.md'))) {
const templatePath = await ctx.getDefaultClaudeMdPath();
@@ -2186,7 +2232,7 @@ export function registerSessionRoutes(
if (!existsSync(join(resolvedCasePath, '.claude', 'settings.local.json'))) {
await writeHooksConfig(resolvedCasePath);
} else {
await refreshStaleHookSecret(resolvedCasePath).catch(() => {});
await refreshStaleCodemanHooks(resolvedCasePath).catch(() => {});
}
} catch {
/* non-fatal — the session still runs, hooks may be degraded */
@@ -2206,6 +2252,7 @@ export function registerSessionRoutes(
mode !== 'opencode' &&
mode !== 'codex' &&
mode !== 'gemini' &&
mode !== 'antigravity' &&
!remote &&
envOverrides &&
Object.keys(envOverrides).length > 0
@@ -2224,17 +2271,19 @@ export function registerSessionRoutes(
? codexConfig?.model
: mode === 'gemini'
? geminiConfig?.model
: mode !== 'shell'
? qsModelConfig?.defaultModel || undefined
: undefined;
: mode === 'antigravity'
? antigravityConfig?.model
: mode !== 'shell'
? qsModelConfig?.defaultModel || undefined
: undefined;
const qsClaudeModeConfig = await ctx.getClaudeModeConfig();
const qsEffectiveClaudeMode = await resolveClaudeModeForUsername(qsClaudeModeConfig.claudeMode, owner);
// Section 6.3: clamp Codex/Gemini bypass switches for a non-granted owner (no-op single-user/granted).
const { codexConfig: qsGatedCodexConfig, geminiConfig: qsGatedGeminiConfig } = await clampExternalCliBypassForOwner(
owner,
codexConfig,
geminiConfig
);
// Section 6.3: clamp Codex/Gemini/Antigravity bypass switches for a non-granted owner (no-op single-user/granted).
const {
codexConfig: qsGatedCodexConfig,
geminiConfig: qsGatedGeminiConfig,
antigravityConfig: qsGatedAntigravityConfig,
} = await clampExternalCliBypassForOwner(owner, codexConfig, geminiConfig, antigravityConfig);
const qsTerminalHistoryConfig = await ctx.getTerminalHistoryConfig();
const session = new Session({
workingDir: resolvedCasePath,
@@ -2250,6 +2299,7 @@ export function registerSessionRoutes(
openCodeConfig: mode === 'opencode' ? openCodeConfig : undefined,
codexConfig: mode === 'codex' ? qsGatedCodexConfig : undefined,
geminiConfig: mode === 'gemini' ? qsGatedGeminiConfig : undefined,
antigravityConfig: mode === 'antigravity' ? qsGatedAntigravityConfig : undefined,
envOverrides,
effort,
remote,
+22 -5
View File
@@ -374,7 +374,7 @@ export function registerSystemRoutes(
});
// ═══════════════════════════════════════════════════════════════
// CLI Integrations (OpenCode, Codex, Gemini)
// CLI Integrations (OpenCode, Codex, Gemini, Antigravity)
// ═══════════════════════════════════════════════════════════════
// ========== OpenCode ==========
@@ -405,6 +405,16 @@ export function registerSystemRoutes(
};
});
// ========== Antigravity ==========
app.get('/api/antigravity/status', async () => {
const { isAntigravityAvailable, resolveAntigravityDir } = await import('../../utils/antigravity-cli-resolver.js');
return {
available: isAntigravityAvailable(),
path: resolveAntigravityDir(),
};
});
// ═══════════════════════════════════════════════════════════════
// State & Lifecycle (cleanup, lifecycle log, stats)
// ═══════════════════════════════════════════════════════════════
@@ -692,20 +702,27 @@ export function registerSystemRoutes(
await ctx.mux.setHistoryLimit(resolveTerminalHistoryConfig(merged).tmuxHistoryLimit);
}
// Service toggles resolve from `merged` (existing + incoming), NEVER from the
// raw request body. A PARTIAL PUT omits keys it does not intend to change, and
// reading the body directly turned every omission into "apply the default":
// a body of just `{statusLineTelemetry:true}` would START the subagent watcher
// (`?? true`) and STOP the workflow + image watchers (`?? false`), silently
// undoing the user's persisted config. Reading `merged` makes any PUT reconcile
// services to the effective stored settings instead, which also self-heals
// drift. Same convention as the tmuxHistoryLimit block above.
// Handle subagent tracking toggle dynamically
toggleService((settings.subagentTrackingEnabled as boolean) ?? true, subagentWatcher, 'Subagent watcher');
toggleService((merged.subagentTrackingEnabled as boolean) ?? true, subagentWatcher, 'Subagent watcher');
// Handle ultracode/workflow run watcher toggle dynamically (default OFF).
// Either the docked panel OR the floating windows keep the watcher running.
toggleService(
((settings.showUltracodeAgents as boolean) ?? false) ||
((settings.ultracodeFloatingWindows as boolean) ?? false),
((merged.showUltracodeAgents as boolean) ?? false) || ((merged.ultracodeFloatingWindows as boolean) ?? false),
workflowRunWatcher,
'Workflow run watcher'
);
// Handle image watcher toggle dynamically
toggleService((settings.imageWatcherEnabled as boolean) ?? false, imageWatcher, 'Image watcher', () => {
toggleService((merged.imageWatcherEnabled as boolean) ?? false, imageWatcher, 'Image watcher', () => {
// Re-watch all active sessions that have image watcher enabled
for (const session of ctx.sessions.values()) {
if (session.imageWatcherEnabled) {
+25 -5
View File
@@ -86,7 +86,7 @@ export const FilesystemPreviewQuerySchema = z.object({
// ========== Env Var Allowlist ==========
/** Allowlisted env var key prefixes */
const ALLOWED_ENV_PREFIXES = ['CLAUDE_CODE_', 'OPENCODE_', 'CODEX_', 'GEMINI_', 'GOOGLE_'];
const ALLOWED_ENV_PREFIXES = ['CLAUDE_CODE_', 'OPENCODE_', 'CODEX_', 'GEMINI_', 'GOOGLE_', 'ANTIGRAVITY_'];
/** Env var keys that are always blocked (security-sensitive) */
const BLOCKED_ENV_KEYS = new Set([
@@ -116,7 +116,7 @@ const safeEnvOverridesSchema = z
},
{
message:
'envOverrides contains blocked or disallowed env var keys. Only CLAUDE_CODE_*, OPENCODE_*, CODEX_*, GEMINI_*, and GOOGLE_* keys are allowed.',
'envOverrides contains blocked or disallowed env var keys. Only CLAUDE_CODE_*, OPENCODE_*, CODEX_*, GEMINI_*, GOOGLE_*, and ANTIGRAVITY_* keys are allowed.',
}
);
@@ -206,9 +206,26 @@ const GeminiConfigSchema = z
})
.optional();
/** Schema for Antigravity CLI (agy)-specific configuration */
const AntigravityConfigSchema = z
.object({
model: z
.string()
.max(100)
.regex(/^[a-zA-Z0-9._\-/]+$/)
.optional(),
dangerouslySkipPermissions: z.boolean().optional(),
resumeConversationId: z
.string()
.max(100)
.regex(/^[a-zA-Z0-9._-]+$/)
.optional(),
})
.optional();
export const CreateSessionSchema = z.object({
workingDir: safePathSchema.optional(),
mode: z.enum(['claude', 'shell', 'opencode', 'codex', 'gemini']).optional(),
mode: z.enum(['claude', 'shell', 'opencode', 'codex', 'gemini', 'antigravity']).optional(),
name: z.string().max(100).optional(),
envOverrides: safeEnvOverridesSchema,
/** Claude CLI effort level (soft default via --settings, switchable in-session via /effort) */
@@ -220,6 +237,7 @@ export const CreateSessionSchema = z.object({
openCodeConfig: OpenCodeConfigSchema,
codexConfig: CodexConfigSchema,
geminiConfig: GeminiConfigSchema,
antigravityConfig: AntigravityConfigSchema,
/** Resume a previous Claude conversation by its session ID (used for reboot recovery) */
resumeSessionId: z
.string()
@@ -328,6 +346,7 @@ const RemoteCommandOverridesSchema = z
opencode: z.string().min(1).max(300).optional(),
codex: z.string().min(1).max(300).optional(),
gemini: z.string().min(1).max(300).optional(),
antigravity: z.string().min(1).max(300).optional(),
})
.strict()
.optional();
@@ -600,10 +619,11 @@ export const QuickStartSchema = z.object({
* a real host dir, so the settings file crosses the bind mount); rejected for
* remote cases (the file would be written on the WRONG machine). */
modelOverride: z.string().max(50).optional(),
mode: z.enum(['claude', 'shell', 'opencode', 'codex', 'gemini']).optional(),
mode: z.enum(['claude', 'shell', 'opencode', 'codex', 'gemini', 'antigravity']).optional(),
openCodeConfig: OpenCodeConfigSchema,
codexConfig: CodexConfigSchema,
geminiConfig: GeminiConfigSchema,
antigravityConfig: AntigravityConfigSchema,
envOverrides: safeEnvOverridesSchema,
/** Claude CLI effort level (soft default via --settings, switchable in-session via /effort) */
effort: effortLevelSchema,
@@ -957,7 +977,7 @@ const noNewlines = (v: string) => !/[\r\n]/.test(v);
/** Shared field shape for creating/updating a scheduled job. */
const CronJobBaseSchema = z.object({
name: z.string().min(1).max(200),
agentType: z.enum(['claude', 'shell', 'opencode', 'codex', 'gemini']),
agentType: z.enum(['claude', 'shell', 'opencode', 'codex', 'gemini', 'antigravity']),
workingDir: safePathSchema,
launchCommand: z.string().max(2000).refine(noNewlines, 'launchCommand must be a single line').optional(),
promptMode: z.enum(['inline_text', 'prompt_file_path']),
+13 -5
View File
@@ -855,14 +855,21 @@ export class WebServer extends EventEmitter {
// the envelope hook into a contradictory HTTP 404 {success:true,...}.
this.app.setNotFoundHandler(async (req, reply) => {
const notFound = `Route ${req.method}:${req.url} not found`;
// A web-tab dashboard asking for a root-absolute asset (`fetch('/api/data')`,
// `import('/chunk.js')`, `url(/img.png)` inside a stylesheet) lands here,
// because `<base href>` cannot rewrite a URL built at runtime. Its Referer says
// which dashboard to relay to. Deliberately placed on the 404 path so every
// real Codeman route still wins.
//
// Tried BEFORE the API-shaped 404, because a dashboard's own assets commonly
// live under its `/api/...` namespace and were the one class this could never
// rescue. Reaching this handler at all already proves no Codeman route matched,
// and the relay declines unless the Referer carries a live capability, so
// genuinely unknown `/api` paths still get the envelope below.
if (await tryWebviewRefererFallback(req, reply)) return reply;
if (req.url.startsWith('/api')) {
return reply.code(404).send(createErrorResponse(ApiErrorCode.NOT_FOUND, notFound));
}
// A web-tab dashboard asking for a root-absolute asset (`fetch('/api/data')`,
// `import('/chunk.js')`) lands here, because `<base href>` cannot rewrite a URL
// built at runtime. Its Referer says which dashboard to relay to. Deliberately
// placed on the 404 path so every real Codeman route still wins.
if (await tryWebviewRefererFallback(req, reply)) return reply;
return reply.code(404).send({ message: notFound, error: 'Not Found', statusCode: 404 });
});
@@ -2454,6 +2461,7 @@ export class WebServer extends EventEmitter {
openCodeConfig: muxSession.mode === 'opencode' ? savedState?.openCodeConfig : undefined,
codexConfig: muxSession.mode === 'codex' ? savedState?.codexConfig : undefined,
geminiConfig: muxSession.mode === 'gemini' ? savedState?.geminiConfig : undefined,
antigravityConfig: muxSession.mode === 'antigravity' ? savedState?.antigravityConfig : undefined,
envOverrides: savedEnvOverrides,
effort: savedState?.effort,
attachmentHistory: savedAttachmentHistory,
+154 -6
View File
@@ -366,15 +366,32 @@ export function buildDownstreamResponseHeaders(
* root, where it 404s. That is not a rare shape: it is how most dashboards talk to
* their own backend, and it presents as the dashboard's own "Failed to fetch".
*
* The `Referer`-keyed 404 fallback catches some of these, but deliberately NOT
* paths under `/api`, `/ws` or `/q` (widening it there would let a request-supplied
* header skip auth on Codeman's own API). Rewriting inside the iframe removes the
* whole class instead of trading security for it: the page never emits a
* root-absolute request in the first place.
* The `Referer`-keyed 404 fallback catches some of these, but it is a rescue rather
* than a fix (it only fires for a request that already missed every Codeman route,
* and only when the browser sends a usable `Referer`). Rewriting inside the iframe
* removes the whole class instead: the page never emits a root-absolute request in
* the first place.
*
* ## Why the DOM sinks are patched too, not just fetch/XHR
*
* A dashboard that renders `container.innerHTML = '<img src="/api/hero?slug=x">'`
* or `img.src = '/api/slide?n=01'` produces exactly the same root-absolute request,
* and NONE of the other layers can reach it: `<base>` does not apply to
* root-absolute URLs at all, and `rewriteHtml()` only ever sees the initial
* document, not markup built later by page script. The visible symptom is very
* specific and easy to misread: the dashboard's DATA loads (reads go through
* `fetch`, which was already patched) while every IMAGE stays broken. So the same
* `rw()` is applied to `innerHTML`/`outerHTML`/`insertAdjacentHTML`, to
* `setAttribute`, and to the `src`/`href`/`srcset`/... property setters, with a
* `MutationObserver` as a last net for any sink not patched above (that one costs a
* wasted 404 per node, since the browser starts fetching on insert, so it is a net
* and not the mechanism).
*
* Runs before any page script because it is injected immediately after `<base>`.
* Only same-origin, non-prefixed, root-absolute URLs are touched; relative URLs
* (already handled by `<base>`) and cross-origin URLs are passed through.
* (already handled by `<base>`) and cross-origin URLs are passed through. Every
* rewrite is idempotent, so a value that passes through two layers is unchanged by
* the second.
*/
export function runtimeUrlShim(prefix: string): string {
// Kept dependency-free and defensive: it runs inside a page we do not control,
@@ -420,6 +437,137 @@ if(window.XMLHttpRequest&&XMLHttpRequest.prototype.open){
['CONNECTING','OPEN','CLOSING','CLOSED'].forEach(function(s){if(s in C)W[s]=C[s];});
window[k]=W;
});
var A=['src','href','action','poster','data','formaction','srcset'];
function rwSet(v){
try{
return String(v).split(',').map(function(p){
var t=p.trim();if(!t)return t;
var i=t.search(/\\s/);
return i===-1?rw(t):rw(t.slice(0,i))+t.slice(i);
}).join(', ');
}catch(e){return v;}
}
function rwAttr(n,v){
try{
if(v==null)return v;
var k=String(n).toLowerCase();
if(k==='srcset')return rwSet(v);
return A.indexOf(k)===-1?v:rw(v);
}catch(e){return v;}
}
// CSS built at runtime is the one sink NO relay can rescue: a <style> element has
// no URL of its own, so an opaque-origin document sends an EMPTY Referer with the
// resulting image request, and the 404 fallback has nothing to key on.
function rwCss(s){
try{
return String(s).replace(/url\\(\\s*(['"]?)(\\/(?!\\/)[^'")]*)\\1\\s*\\)/gi,function(m,q,u){return 'url('+q+rw(u)+q+')';});
}catch(e){return s;}
}
// Each value goes through rw() rather than a blind prefix concat, because unlike
// the server-side rewriteHtml() this runs on markup that may ALREADY be proxied
// (a page re-injecting its own outerHTML), and rw() is the idempotent one.
function rwHtml(s){
try{
if(typeof s!=='string')return s;
return s
.replace(/(\\s(?:src|href|action|poster|formaction|data)\\s*=\\s*")([^"]*)(")/gi,function(m,a,v,q){return a+rw(v)+q;})
.replace(/(\\s(?:src|href|action|poster|formaction|data)\\s*=\\s*')([^']*)(')/gi,function(m,a,v,q){return a+rw(v)+q;})
.replace(/(\\ssrcset\\s*=\\s*")([^"]*)(")/gi,function(m,a,v,q){return a+rwSet(v)+q;})
.replace(/(\\ssrcset\\s*=\\s*')([^']*)(')/gi,function(m,a,v,q){return a+rwSet(v)+q;})
.replace(/(<style\\b[^>]*>)([^]*?)(<\\/style>)/gi,function(m,a,b,c){return a+rwCss(b)+c;});
}catch(e){return s;}
}
// Marked with __cmrw so a double injection (a page that re-runs the shim) cannot
// wrap an already-wrapped setter and rewrite twice.
function patchProp(C,prop,conv){
try{
if(!C||!C.prototype)return;
var d=Object.getOwnPropertyDescriptor(C.prototype,prop);
if(!d||!d.set||d.set.__cmrw)return;
var s=d.set;
var ns=function(v){var w=v;try{w=conv(v);}catch(e){}return s.call(this,w);};
ns.__cmrw=1;
Object.defineProperty(C.prototype,prop,{get:d.get,set:ns,configurable:true,enumerable:d.enumerable});
}catch(e){}
}
function patchHtmlProp(O,prop){
try{
if(!O)return;
var d=Object.getOwnPropertyDescriptor(O,prop);
if(!d||!d.set||d.set.__cmrw)return;
var s=d.set;
var ns=function(v){return s.call(this,rwHtml(v));};
ns.__cmrw=1;
Object.defineProperty(O,prop,{get:d.get,set:ns,configurable:true,enumerable:d.enumerable});
}catch(e){}
}
function patchFn(O,name,wrap){
try{
var f=O&&O[name];
if(typeof f!=='function'||f.__cmrw)return;
var nf=wrap(f);nf.__cmrw=1;O[name]=nf;
}catch(e){}
}
[['HTMLImageElement','src'],['HTMLImageElement','srcset'],['HTMLSourceElement','src'],
['HTMLSourceElement','srcset'],['HTMLMediaElement','src'],['HTMLVideoElement','poster'],
['HTMLScriptElement','src'],['HTMLIFrameElement','src'],['HTMLEmbedElement','src'],
['HTMLTrackElement','src'],['HTMLLinkElement','href'],['HTMLAnchorElement','href'],
['HTMLAreaElement','href'],['HTMLObjectElement','data'],['HTMLFormElement','action']
].forEach(function(p){patchProp(window[p[0]],p[1],p[1]==='srcset'?rwSet:rw);});
var EP=window.Element&&window.Element.prototype;
patchHtmlProp(EP,'innerHTML');
patchHtmlProp(EP,'outerHTML');
patchHtmlProp(window.ShadowRoot&&window.ShadowRoot.prototype,'innerHTML');
patchFn(EP,'insertAdjacentHTML',function(f){return function(p,h){return f.call(this,p,rwHtml(h));};});
patchFn(EP,'setAttribute',function(f){return function(n,v){return f.call(this,n,rwAttr(n,v));};});
patchFn(EP,'setAttributeNS',function(f){return function(ns,n,v){
var k=String(n==null?'':n),i=k.indexOf(':');
return f.call(this,ns,n,rwAttr(i===-1?k:k.slice(i+1),v));
};});
// Last net: anything inserted by a sink not patched above still gets corrected.
// setAttribute below is the patched one, so this stays idempotent and terminates.
try{
var doc=window.document,MO=window.MutationObserver;
if(MO&&doc&&doc.documentElement){
var fix=function(el){
try{
if(!el||el.nodeType!==1||!el.hasAttribute)return;
for(var i=0;i<A.length;i++){
var n=A[i];if(!el.hasAttribute(n))continue;
var c=el.getAttribute(n),x=rwAttr(n,c);
if(x!=null&&x!==c)el.setAttribute(n,x);
}
}catch(e){}
};
var fixStyle=function(el){
try{
if(!el||el.tagName!=='STYLE')return;
var t=el.textContent;
if(!t||t.indexOf('url(')===-1)return;
var n=rwCss(t);
if(n!==t)el.textContent=n;
}catch(e){}
};
var scan=function(node){
try{
fix(node);fixStyle(node);
if(node&&node.querySelectorAll){
var l=node.querySelectorAll('[src],[href],[action],[poster],[data],[srcset],[formaction]');
for(var i=0;i<l.length;i++)fix(l[i]);
var st=node.querySelectorAll('style');
for(var j=0;j<st.length;j++)fixStyle(st[j]);
}
}catch(e){}
};
new MO(function(ms){
for(var i=0;i<ms.length;i++){
var m=ms[i];
if(m.type==='attributes')fix(m.target);
else for(var j=0;j<m.addedNodes.length;j++)scan(m.addedNodes[j]);
}
}).observe(doc.documentElement,{subtree:true,childList:true,attributes:true,attributeFilter:A});
}
}catch(e){}
}catch(e){}})();</script>`;
}