Files
Codeman/src/web/public/session-ui.js
T
Codeman maintainer 5d2899907e fix(cli-gating): gate the tunnel button instead of deleting it, and cover antigravity
Follow-up to #200 and #201, which gate the welcome buttons and the run-mode
dropdown on whether the CLI is actually installed. Four corrections:

1. #200 also DELETED the Cloudflare Tunnel welcome button and the QR widget
   outright. Its rationale is right (offering a tunnel where cloudflared is not
   installed is a bad default) but the conclusion overshoots: the welcome QR is
   the whole scan-to-connect-from-your-phone flow, and deleting it left a large
   block of live tunnel code in settings-ui.js driving elements that no longer
   existed. Both are restored and the button is gated on cloudflared, which is
   what the stated rationale actually asks for. New cloudflared-resolver.ts
   mirrors the CLI resolvers, and TunnelManager now shares its search path so
   the button and the spawn can never disagree about where cloudflared lives.

2. Antigravity was missing from the run-mode gating, the one run mode LEAST
   likely to be installed. It slipped past because #201 predates it. Covered
   now, plus a static test that fails if a sixth mode reaches the dropdown
   without being gated, so the next one cannot slip the same way.

3. The per-surface fetches are replaced by the injected availability object
   already used for the Codex settings tab, so the codebase has one mechanism
   rather than two. The status routes buy nothing as a gating source: every
   resolver memoizes its PATH probe server-side, so a fetch is exactly as stale
   as an injected value while costing a round trip every time the dropdown opens
   and leaving the welcome buttons to flicker in after paint. The routes
   themselves stay, including the /api/claude/status that #200 adds.

4. Unknown availability now reads as AVAILABLE for run buttons. Both PRs hid the
   button on a failed fetch, so a blip left a working install with nothing to
   click; a genuinely missing CLI only ever produced an error toast. The Codex
   settings TAB keeps the opposite default, since hiding it costs nothing.

The dropdown query is also scoped to the menu: `.run-mode-option` is the class
the saved-dashboard and history rows use too, and a document-wide querySelector
would have found whichever came first in the DOM.

Fixes a latent environment-sensitivity in 816d900 while here: the index-title
test asserted the template was untouched apart from the title, which held only
on a machine with no codex installed.

Verified end-to-end against a real server on an isolated instance+socket, with
Playwright: gemini/codex hidden and claude/opencode/antigravity/shell shown,
matching this host, tunnel button back, Codex settings tab still hidden, no
console errors. Full test:ci sweep green (3902 tests).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-05 01:53:43 +02:00

2655 lines
109 KiB
JavaScript

/**
* @fileoverview Quick start (case loading, session spawning for Claude/Shell/OpenCode/Codex/Gemini),
* session options modal (per-session settings, color picker, rename),
* session options tabs (Ralph config tab), case settings (CRUD, links),
* create case modal, and mobile case picker.
*
* @mixin Extends CodemanApp.prototype via Object.assign
* @dependency app.js (CodemanApp class, this.sessions, this.cases, this.activeSessionId)
* @dependency constants.js (escapeHtml)
* @dependency mobile-handlers.js (MobileDetection)
* @loadorder 12 of 15 — loaded after panels-ui.js, before ralph-wizard.js
*/
Object.assign(CodemanApp.prototype, {
/**
* Build envOverrides payload from case + global settings.
* Single source of truth for the server-side tmux setenv values.
* Keys omitted when value is default/falsy — backend treats unset as "no override".
*/
buildEnvOverrides(caseSettings, globalSettings) {
const env = {};
if (caseSettings?.agentTeams || globalSettings?.agentTeamsEnabled) {
env.CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS = '1';
}
// NOTE: thinkingEffort is intentionally NOT emitted as CLAUDE_CODE_EFFORT_LEVEL —
// the env var hard-locks effort and blocks in-session /effort switching (e.g.,
// ultracode). It flows as the dedicated `effort` payload field instead, which the
// backend injects as a `--settings` soft default. See getEffortSetting().
return env;
},
/**
* Resolve the effort level for new sessions from global settings.
* Returns a valid effort string or undefined (= no override, CLI default).
* Sent as the `effort` payload field — backend turns it into `claude --settings ...`.
*/
getEffortSetting(globalSettings) {
const effort = globalSettings?.thinkingEffort;
const valid = ['low', 'medium', 'high', 'xhigh', 'max', 'ultracode'];
return valid.includes(effort) ? effort : undefined;
},
// ═══════════════════════════════════════════════════════════════
// Quick Start
// ═══════════════════════════════════════════════════════════════
formatCasePickerLabel(c) {
if (c?.location === 'remote' && c.remote?.hostId) return `${c.name} @ ${c.remote.hostId}`;
if (c?.location === 'docker') return `${c.name} (${this.dockerCaseTag(c.docker?.hostId)})`;
return c?.name || '';
},
// Short parenthetical tag for a dockerized case: '(docker)' for the default /
// auto-provisioned host (one-click "Run in Docker", the Docker-tab 'local'
// default, or a per-case 'q-<name>' resource-override host), otherwise the custom
// docker host id the user named (e.g. '(gpu-box)'). Keeps the case name short.
dockerCaseTag(hostId) {
if (!hostId || hostId === 'default' || hostId === 'local' || /^q-/.test(hostId)) return 'docker';
return hostId;
},
buildCasePickerOptions(cases = []) {
const normalized = [];
const seen = new Set();
for (const c of cases) {
if (!c?.name || seen.has(c.name)) continue;
seen.add(c.name);
normalized.push(c);
}
if (!seen.has('testcase')) {
normalized.push({ name: 'testcase' });
}
return normalized
.map(c => {
const label = this.formatCasePickerLabel(c);
const searchText = [
c.name,
label,
c.path,
c.location,
c.remote?.hostId,
c.remote?.label,
c.remote?.path,
c.docker?.container,
c.docker?.image,
c.docker?.path
].filter(Boolean).join(' ').toLowerCase();
return { name: c.name, label, case: c, searchText };
})
.sort((a, b) => a.label.localeCompare(b.label, undefined, { sensitivity: 'base', numeric: true }));
},
filterCasePickerOptions(options, query) {
const terms = String(query || '').trim().toLowerCase().split(/\s+/).filter(Boolean);
if (terms.length === 0) return options;
return options.filter(option => terms.every(term => option.searchText.includes(term)));
},
getCasePickerOptions() {
return this.buildCasePickerOptions(this.cases || []);
},
updateCasePickerInput(caseName) {
const input = document.getElementById('quickStartCaseSearch');
if (!input) return;
const option = this.getCasePickerOptions().find(item => item.name === caseName);
input.value = option?.label || caseName || 'testcase';
input.title = option?.label || input.value;
},
renderQuickStartCaseSelectOptions(select, options) {
if (!select) return;
select.innerHTML = options
.map(option => `<option value="${escapeHtml(option.name)}">${escapeHtml(option.label)}</option>`)
.join('');
},
openCasePicker(filter = '') {
const input = document.getElementById('quickStartCaseSearch');
const list = document.getElementById('quickStartCaseList');
if (!input || !list) return;
this._casePickerOpen = true;
this._casePickerFilter = filter;
this._casePickerActiveIndex = 0;
input.setAttribute('aria-expanded', 'true');
this.renderCasePickerList();
},
closeCasePicker() {
const input = document.getElementById('quickStartCaseSearch');
const list = document.getElementById('quickStartCaseList');
this._casePickerOpen = false;
this._casePickerFilter = '';
input?.setAttribute('aria-expanded', 'false');
input?.removeAttribute('aria-activedescendant');
list?.classList.add('hidden');
},
renderCasePickerList() {
const input = document.getElementById('quickStartCaseSearch');
const list = document.getElementById('quickStartCaseList');
const select = document.getElementById('quickStartCase');
if (!input || !list || !select) return;
const options = this.filterCasePickerOptions(this.getCasePickerOptions(), this._casePickerFilter || '');
const selectedName = select.value || 'testcase';
const maxIndex = Math.max(0, options.length - 1);
this._casePickerActiveIndex = Math.min(Math.max(this._casePickerActiveIndex || 0, 0), maxIndex);
if (options.length === 0) {
list.innerHTML = '<div class="case-combobox-empty">No cases match</div>';
list.classList.remove('hidden');
input.removeAttribute('aria-activedescendant');
return;
}
list.innerHTML = options
.map((option, index) => {
const active = index === this._casePickerActiveIndex;
const selected = option.name === selectedName;
const id = `quickStartCaseOption-${index}`;
return `
<button
type="button"
id="${id}"
class="case-combobox-option ${active ? 'active' : ''} ${selected ? 'selected' : ''}"
role="option"
aria-selected="${selected ? 'true' : 'false'}"
data-case="${escapeHtml(option.name)}"
title="${escapeHtml(option.label)}">
<span class="case-combobox-check">${selected ? '✓' : ''}</span>
<span class="case-combobox-option-label">${escapeHtml(option.label)}</span>
</button>
`;
})
.join('');
list.classList.remove('hidden');
input.setAttribute('aria-activedescendant', `quickStartCaseOption-${this._casePickerActiveIndex}`);
},
selectQuickStartCase(caseName, { save = true } = {}) {
const select = document.getElementById('quickStartCase');
if (!select) return;
select.value = caseName || 'testcase';
this.updateCasePickerInput(select.value);
this.closeCasePicker();
this.updateDirDisplayForCase(select.value);
this.updateMobileCaseLabel(select.value);
if (save) {
this.saveLastUsedCase(select.value);
}
},
setupQuickStartCasePicker() {
const select = document.getElementById('quickStartCase');
const input = document.getElementById('quickStartCaseSearch');
const list = document.getElementById('quickStartCaseList');
const picker = document.getElementById('quickStartCasePicker');
if (!select || !input || !list || !picker || input.dataset.listenerAdded) return;
input.addEventListener('focus', () => {
input.select?.();
this.openCasePicker('');
});
input.addEventListener('click', () => {
input.select?.();
this.openCasePicker('');
});
input.addEventListener('input', () => {
this.openCasePicker(input.value);
});
input.addEventListener('keydown', event => {
const options = this.filterCasePickerOptions(this.getCasePickerOptions(), this._casePickerFilter || input.value);
if (event.key === 'ArrowDown') {
event.preventDefault();
this._casePickerActiveIndex = Math.min((this._casePickerActiveIndex || 0) + 1, Math.max(0, options.length - 1));
this._casePickerOpen ? this.renderCasePickerList() : this.openCasePicker(input.value);
} else if (event.key === 'ArrowUp') {
event.preventDefault();
this._casePickerActiveIndex = Math.max((this._casePickerActiveIndex || 0) - 1, 0);
this._casePickerOpen ? this.renderCasePickerList() : this.openCasePicker(input.value);
} else if (event.key === 'Enter') {
const option = options[this._casePickerActiveIndex || 0];
if (option) {
event.preventDefault();
this.selectQuickStartCase(option.name);
this.run?.();
}
} else if (event.key === 'Escape') {
event.preventDefault();
this.updateCasePickerInput(select.value);
this.closeCasePicker();
} else if (event.key === 'Tab') {
this.updateCasePickerInput(select.value);
this.closeCasePicker();
}
});
list.addEventListener('mousedown', event => event.preventDefault());
list.addEventListener('click', event => {
const option = event.target.closest?.('.case-combobox-option');
if (option?.dataset?.case) {
this.selectQuickStartCase(option.dataset.case);
}
});
if (document.addEventListener && !this._casePickerDocumentListenerAdded) {
document.addEventListener('pointerdown', event => {
if (!picker.contains(event.target)) {
this.updateCasePickerInput(select.value);
this.closeCasePicker();
}
});
this._casePickerDocumentListenerAdded = true;
}
input.dataset.listenerAdded = 'true';
},
async loadQuickStartCases(selectCaseName = null, settingsPromise = null) {
try {
// Load settings to get lastUsedCase (reuse shared promise if provided)
let lastUsedCase = null;
try {
const settings = settingsPromise ? await settingsPromise : await fetch('/api/settings').then(r => r.ok ? r.json() : null).then(env => env?.data ?? null);
if (settings) {
lastUsedCase = settings.lastUsedCase || null;
}
} catch {
// Ignore settings load errors
}
const res = await fetch('/api/cases');
const cases = (await res.json()).data;
this.cases = cases;
console.log('[loadQuickStartCases] Loaded cases:', cases.map(c => c.name), 'lastUsedCase:', lastUsedCase);
const select = document.getElementById('quickStartCase');
const options = this.getCasePickerOptions();
this.renderQuickStartCaseSelectOptions(select, options);
console.log('[loadQuickStartCases] Set options:', select.innerHTML.substring(0, 200));
// If a specific case was requested, select it
if (selectCaseName) {
select.value = selectCaseName;
this.updateDirDisplayForCase(selectCaseName);
this.updateMobileCaseLabel(selectCaseName);
} else if (lastUsedCase && cases.some(c => c.name === lastUsedCase)) {
// Use lastUsedCase if available and exists
select.value = lastUsedCase;
this.updateDirDisplayForCase(lastUsedCase);
this.updateMobileCaseLabel(lastUsedCase);
} else if (cases.length > 0) {
// Fallback to testcase or first case
const firstCase = cases.find(c => c.name === 'testcase') || cases[0];
select.value = firstCase.name;
this.updateDirDisplayForCase(firstCase.name);
this.updateMobileCaseLabel(firstCase.name);
} else {
// No cases exist yet - show the default case name as directory
select.value = 'testcase';
document.getElementById('dirDisplay').textContent = '~/codeman-cases/testcase';
this.updateMobileCaseLabel('testcase');
}
this.updateCasePickerInput(select.value);
this.renderCasePickerList();
this.closeCasePicker();
// Only add event listener once (on first load)
if (!select.dataset.listenerAdded) {
select.addEventListener('change', () => {
this.updateDirDisplayForCase(select.value);
this.saveLastUsedCase(select.value);
this.updateMobileCaseLabel(select.value);
this.updateCasePickerInput(select.value);
});
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);
}
},
async updateDirDisplayForCase(caseName) {
try {
const res = await fetch(`/api/cases/${caseName}`);
const data = (await res.json()).data;
if (data.path) {
document.getElementById('dirDisplay').textContent = data.path;
document.getElementById('dirInput').value = data.path;
}
} catch (err) {
document.getElementById('dirDisplay').textContent = caseName;
}
},
async saveLastUsedCase(caseName) {
try {
await fetch('/api/settings', {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ lastUsedCase: caseName })
});
} catch (err) {
console.error('Failed to save last used case:', err);
}
},
async quickStart() {
return this.run();
},
/** Ensure a newly-created session is visible without waiting for the SSE event.
* The POST response and session:created can arrive in either order, so the
* normal idempotent SSE handler remains the single state-upsert path. */
async _ensureCreatedSessionVisible(sessionId, sessionSnapshot) {
if (!sessionId) return;
let session = sessionSnapshot;
if (!session && !this.sessions?.has(sessionId)) {
const res = await fetch(`/api/sessions/${encodeURIComponent(sessionId)}`);
const data = await res.json();
if (!data.success) throw new Error(data.error || 'Failed to load the new session');
session = data.data?.session || data.data;
}
if (session?.id) this._onSessionCreated(session);
// session:created normally uses the debounced renderer. The direct POST path
// needs the tab in the DOM before selectSession() marks it active.
this._renderSessionTabsImmediate?.();
},
/** Run using the selected mode (Claude Code, OpenCode, Codex, Gemini, or Antigravity) */
async run() {
if (this._runInFlight) return;
const startedAt = Date.now();
const minLockMs = Number.isFinite(this._runMinLockMs) ? this._runMinLockMs : 500;
const runBtn = document.getElementById('runBtn');
this._runInFlight = true;
if (runBtn) {
runBtn.disabled = true;
runBtn.setAttribute('aria-busy', 'true');
}
try {
const mode = this._runMode || 'claude';
if (mode === 'opencode') {
return await this.runOpenCode();
}
if (mode === 'codex') {
return await this.runCodex();
}
if (mode === 'gemini') {
return await this.runGemini();
}
if (mode === 'antigravity') {
return await this.runAntigravity();
}
if (mode === 'shell') {
return await this.runShell();
}
return await this.runClaude();
} finally {
const remaining = minLockMs - (Date.now() - startedAt);
if (remaining > 0) await new Promise(resolve => setTimeout(resolve, remaining));
this._runInFlight = false;
if (runBtn) {
runBtn.disabled = false;
runBtn.removeAttribute('aria-busy');
}
}
},
// Note: `runMode` is an accessor defined via Object.defineProperty at the bottom of
// this file — an object-literal getter here would be flattened to a static value by
// Object.assign (it copies values, not accessor descriptors).
setRunMode(mode) {
this._runMode = mode;
try { localStorage.setItem('codeman_runMode', mode); } catch {}
this._applyRunMode();
// Sync to server for cross-device persistence
this._apiPut('/api/settings', { runMode: mode }).catch(() => {});
// Close menu
document.getElementById('runModeMenu')?.classList.remove('active');
},
toggleRunModeMenu(e) {
e?.stopPropagation();
const menu = document.getElementById('runModeMenu');
if (!menu) return;
menu.classList.toggle('active');
// Update selected state
menu.querySelectorAll('.run-mode-option').forEach(btn => {
btn.classList.toggle('selected', btn.dataset.mode === this.runMode);
});
// Load history sessions when menu opens
if (menu.classList.contains('active')) {
this._loadRunModeHistory();
this._refreshRunModeAvailability(menu);
const close = (ev) => {
if (!menu.contains(ev.target)) {
menu.classList.remove('active');
document.removeEventListener('click', close);
}
};
setTimeout(() => document.addEventListener('click', close), 0);
}
},
/**
* #201: hides run-mode dropdown entries for CLIs that aren't installed, so
* picking one doesn't spawn a session that immediately errors out.
*
* Shell has no external CLI dependency and is never gated, which is also what
* guarantees the menu is never empty. Scoped to `menu` rather than the document:
* `.run-mode-option` is also the class the saved-dashboard rows and the history
* rows use, and a bare querySelector would find whichever came first in the DOM.
*
* Antigravity is in this list even though #201 predates it — it is a run mode
* like the rest, and `agy` is the LEAST likely of the five to be installed.
*/
_refreshRunModeAvailability(menu) {
for (const mode of ['claude', 'opencode', 'codex', 'gemini', 'antigravity']) {
const btn = menu.querySelector(`.run-mode-option[data-mode="${mode}"]`);
if (btn) btn.style.display = this.isCliAvailable(mode) ? 'flex' : 'none';
}
},
async _loadRunModeHistory() {
const container = document.getElementById('runModeHistory');
if (!container) return;
container.innerHTML = '<div class="run-mode-hist-empty">Loading...</div>';
try {
const display = await this._fetchHistorySessions(10);
if (display.length === 0) {
container.innerHTML = '<div class="run-mode-hist-empty">No history</div>';
return;
}
// Build items using DOM API for reliable mobile touch handling
container.replaceChildren();
for (const s of display) {
const date = new Date(s.lastModified);
const timeStr = date.toLocaleDateString('en', { month: 'short', day: 'numeric' })
+ ' ' + date.toLocaleTimeString('en', { hour: '2-digit', minute: '2-digit', hour12: false });
const shortDir = s.workingDir.replace(/^\/home\/[^/]+\//, '~/');
const btn = document.createElement('button');
btn.className = 'run-mode-option';
btn.title = s.workingDir;
btn.dataset.sessionId = s.sessionId;
btn.dataset.workingDir = s.workingDir;
const dirSpan = document.createElement('span');
dirSpan.className = 'hist-dir';
dirSpan.textContent = shortDir;
const metaSpan = document.createElement('span');
metaSpan.className = 'hist-meta';
metaSpan.textContent = timeStr;
btn.append(dirSpan, metaSpan);
btn.addEventListener('click', (e) => {
e.stopPropagation();
this.resumeHistorySession(s.sessionId, s.workingDir, s.name);
});
container.appendChild(btn);
}
} catch (err) {
container.innerHTML = '<div class="run-mode-hist-empty">Failed to load</div>';
}
},
_applyRunMode() {
const mode = this.runMode;
const runBtn = document.getElementById('runBtn');
const gearBtn = runBtn?.nextElementSibling;
const label = document.getElementById('runBtnLabel');
if (runBtn) {
runBtn.className = `btn-toolbar btn-run mode-${mode}`;
}
if (gearBtn) {
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 === 'antigravity' ? 'Run AG' : mode === 'shell' ? 'Run SH' : 'Run';
}
},
/** Send Enter to the active session (phone toolbar button).
*
* MUST go through xterm's onData path, NOT straight to sendInput()/the API.
* With local echo on (the mobile default) the characters you typed are still
* buffered in the LocalEchoOverlay and have NEVER reached the PTY. The onData
* Enter branch (terminal-ui.js) is what flushes that pending text and only
* then sends \r. Send a bare \r instead and you submit an empty line while the
* typed text stays stranded on screen — which reads as "the button does
* nothing". triggerDataEvent replays it exactly as if the key were pressed,
* so overlay flush, flushed-offset cleanup and ordering are all reused. */
sendEnterKey() {
if (!this.activeSessionId) return;
const coreService = this.terminal?._core?.coreService;
if (coreService && typeof coreService.triggerDataEvent === 'function') {
coreService.triggerDataEvent('\r', true);
return;
}
// Fallback only if xterm's private core API moves: correct when local echo
// is off, and still better than doing nothing.
this.sendInput('\r');
},
_initRunMode() {
try { this._runMode = localStorage.getItem('codeman_runMode') || 'claude'; } catch { this._runMode = 'claude'; }
this._applyRunMode();
},
// Tab count stepper functions
incrementTabCount() {
const input = document.getElementById('tabCount');
const current = parseInt(input.value) || 1;
input.value = Math.min(20, current + 1);
},
decrementTabCount() {
const input = document.getElementById('tabCount');
const current = parseInt(input.value) || 1;
input.value = Math.max(1, current - 1);
},
// Shell count stepper functions
incrementShellCount() {
const input = document.getElementById('shellCount');
const current = parseInt(input.value) || 1;
input.value = Math.min(20, current + 1);
},
decrementShellCount() {
const input = document.getElementById('shellCount');
const current = parseInt(input.value) || 1;
input.value = Math.max(1, current - 1);
},
// Next free <prefix><n> index for a case's session tabs (e.g. w1-<case>,
// w2-<case> for agents, s1-<case> for shells), shared by the local and
// remote/docker launch paths so all tabs follow the same naming convention.
_nextCaseSessionStartNumber(caseName, prefix = 'w') {
const re = new RegExp(`^${prefix}(\\d+)-([a-zA-Z0-9_-]+)`);
let startNumber = 1;
for (const [, session] of this.sessions || []) {
const match = session.name && session.name.match(re);
if (match && match[2] === caseName) {
const num = parseInt(match[1]);
if (num >= startNumber) startNumber = num + 1;
}
}
return startNumber;
},
/**
* Launch progress may use the terminal only on the session-less home screen.
* When another session is active, mutating the shared xterm would serialize
* launch chrome into that session's snapshot during the subsequent switch.
*/
_beginSessionLaunchStatus(message, ansiColor = '1;32') {
const ownsTerminal = !this.activeSessionId;
if (ownsTerminal) {
this.terminal.clear();
this.terminal.writeln(`\x1b[${ansiColor}m ${message}\x1b[0m`);
this.terminal.writeln('');
} else {
this.showToast?.(message, 'info');
}
return ownsTerminal;
},
_appendSessionLaunchStatus(ownsTerminal, message, ansiColor = '90') {
if (!ownsTerminal || this.activeSessionId) return;
this.terminal.writeln(`\x1b[${ansiColor}m ${message}\x1b[0m`);
},
_reportSessionLaunchError(ownsTerminal, message) {
if (ownsTerminal && !this.activeSessionId) {
this.terminal.writeln(`\x1b[1;31m Error: ${message}\x1b[0m`);
} else {
this.showToast?.(message, 'error');
}
},
async runClaude() {
const caseName = document.getElementById('quickStartCase').value || 'testcase';
const tabCount = Math.min(20, Math.max(1, parseInt(document.getElementById('tabCount').value) || 1));
const ownsLaunchTerminal = this._beginSessionLaunchStatus(
`Starting ${tabCount} Claude session(s) in ${caseName}...`
);
// Focus terminal NOW, in the synchronous user-gesture context (button click).
// iOS Safari ignores programmatic focus() after any await, so this must happen
// before the first async call. The keyboard opens here and stays open through
// the session creation flow; selectSession at the end inherits the focus state.
this.terminal.focus();
try {
// Get case path first
const caseRes = await fetch(`/api/cases/${caseName}`);
let caseData = (await caseRes.json())?.data ?? {};
// Create the case if it doesn't exist
if (!caseData.path) {
const createCaseRes = await fetch('/api/cases', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name: caseName, description: '' })
});
const createCaseData = await createCaseRes.json();
if (!createCaseData.success) throw new Error(createCaseData.error || 'Failed to create case');
// API returns { success, data: { case: { name, path } } }
caseData = createCaseData.data.case;
}
const workingDir = caseData.path;
if (!workingDir) throw new Error('Case path not found');
// Remote cases run over ssh — POST /api/sessions stat-validates workingDir on
// the LOCAL fs (a remote user@host:/path never exists locally), so route them
// through /api/quick-start, which resolves the remote case + launches via ssh.
if (caseData.location === 'remote' || caseData.location === 'docker') {
// Name remote/docker tabs with the same w<n>-<case> convention as local
// sessions (quick-start would otherwise auto-generate codeman-<id>).
const startNumber = this._nextCaseSessionStartNumber(caseName);
// Docker (NOT remote): the App Settings Claude Model choice applies — the
// workspace is a real host dir, so quick-start writes it to the case's
// .claude/settings.local.json and the in-container claude reads it.
// Remote quick-starts REJECT modelOverride (the file would land on the
// wrong machine), so never send it there.
let dockerModelOverride;
if (caseData.location === 'docker') {
const dockerGlobalSettings = this.loadAppSettingsFromStorage();
const dockerCaseSettings = this.getCaseSettings(caseName);
const dockerUseOpus1m = dockerCaseSettings.opusContext1m || dockerGlobalSettings.opusContext1mEnabled;
dockerModelOverride = dockerGlobalSettings.claudeModel || (dockerUseOpus1m ? 'opus[1m]' : '');
}
const remoteIds = [];
let driftHandled = false;
for (let i = 0; i < tabCount; i++) {
const quickStartBody = JSON.stringify({
caseName, mode: 'claude', sessionName: `w${startNumber + i}-${caseName}`,
...(dockerModelOverride !== undefined ? { modelOverride: dockerModelOverride } : {})
});
const doQuickStart = async () => {
const res = await fetch('/api/quick-start', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: quickStartBody
});
return res.json();
};
let data = await doQuickStart();
// Docker config drift: the host config changed since the container was
// created (CONFLICT from quick-start). Confirm once, recreate, retry.
if (!data.success && data.errorCode === 'CONFLICT' && caseData.location === 'docker' && !driftHandled) {
driftHandled = true;
const recreate = confirm(
`Container config for "${caseName}" changed since its container was created.\n\n` +
'Recreate the container to apply the new config? Workspace files and the ' +
'conversation survive (the conversation resumes on launch).'
);
if (recreate) {
const recRes = await fetch(`/api/docker-cases/${encodeURIComponent(caseName)}/recreate`, { method: 'POST' });
const recData = await recRes.json();
if (!recData.success) throw new Error(recData.error || 'Failed to recreate container');
data = await doQuickStart();
}
}
if (!data.success) throw new Error(data.error || 'Failed to start remote Claude session');
await this._ensureCreatedSessionVisible(data.data.sessionId, data.data.session);
remoteIds.push(data.data.sessionId);
}
this._appendSessionLaunchStatus(ownsLaunchTerminal, `All ${tabCount} remote session(s) ready`);
if (remoteIds[0]) {
await this.selectSession(remoteIds[0]);
this.loadQuickStartCases();
}
this.terminal.focus();
return;
}
let firstSessionId = null;
// Find the highest existing w-number for THIS case to avoid duplicates
const startNumber = this._nextCaseSessionStartNumber(caseName);
// Get global Ralph tracker setting
const ralphEnabled = this.isRalphTrackerEnabledByDefault();
// Create all sessions in parallel for speed
const sessionNames = [];
for (let i = 0; i < tabCount; i++) {
sessionNames.push(`w${startNumber + i}-${caseName}`);
}
// Build env overrides from global + case settings (case overrides global)
const caseSettings = this.getCaseSettings(caseName);
const globalSettings = this.loadAppSettingsFromStorage();
const envOverrides = this.buildEnvOverrides(caseSettings, globalSettings);
const hasEnvOverrides = Object.keys(envOverrides).length > 0;
const effort = this.getEffortSetting(globalSettings);
// Explicit Claude Model choice (App Settings) wins over the legacy 1M Opus
// toggles; both flow as `modelOverride` → the case's .claude/settings.local.json
const useOpus1m = caseSettings.opusContext1m || globalSettings.opusContext1mEnabled;
const modelOverride = globalSettings.claudeModel || (useOpus1m ? 'opus[1m]' : '');
// Step 1: Create all sessions in parallel
this._appendSessionLaunchStatus(ownsLaunchTerminal, `Creating ${tabCount} session(s)...`);
const createPromises = sessionNames.map(name =>
fetch('/api/sessions', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
workingDir, name,
...(hasEnvOverrides ? { envOverrides } : {}),
...(effort ? { effort } : {}),
...(modelOverride !== undefined ? { modelOverride } : {}),
// Plan-usage statusLine exporter (App Settings → Display). The server
// ADDS our exporter on create when true; when false it intentionally
// leaves any existing exporter in place (a per-repo settings.local.json
// 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: this.planUsageChipEnabled(globalSettings),
})
}).then(r => r.json())
);
const createResults = await Promise.all(createPromises);
// Collect created session IDs
const sessionIds = [];
for (const result of createResults) {
if (!result.success) throw new Error(result.error);
await this._ensureCreatedSessionVisible(result.data.session.id, result.data.session);
sessionIds.push(result.data.session.id);
}
firstSessionId = sessionIds[0];
// Step 2: Configure Ralph for all sessions in parallel
await Promise.all(sessionIds.map(id =>
fetch(`/api/sessions/${id}/ralph-config`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ enabled: ralphEnabled, disableAutoEnable: !ralphEnabled })
})
));
// Step 3: Start all sessions in parallel (biggest speedup)
this._appendSessionLaunchStatus(ownsLaunchTerminal, `Starting ${tabCount} session(s) in parallel...`);
await Promise.all(sessionIds.map(id =>
fetch(`/api/sessions/${id}/interactive`, { method: 'POST' })
));
this._appendSessionLaunchStatus(ownsLaunchTerminal, `All ${tabCount} sessions ready`);
// Auto-switch to the new session using selectSession (does proper refresh)
if (firstSessionId) {
await this.selectSession(firstSessionId);
this.loadQuickStartCases();
}
this.terminal.focus();
} catch (err) {
this._reportSessionLaunchError(ownsLaunchTerminal, err.message);
}
},
/** Send Ctrl+C to the active session to stop the current operation.
* Requires double-tap: first tap turns button amber, second tap within 2s sends Ctrl+C. */
stopClaude() {
if (!this.activeSessionId) return;
const btn = document.querySelector('.btn-toolbar.btn-stop');
if (!btn) return;
if (this._stopConfirmTimer) {
// Second tap — send Ctrl+C
clearTimeout(this._stopConfirmTimer);
this._stopConfirmTimer = null;
btn.innerHTML = btn.dataset.origHtml;
delete btn.dataset.origHtml;
btn.classList.remove('confirming');
fetch(`/api/sessions/${this.activeSessionId}/input`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ input: '\x03' })
});
} else {
// First tap — enter confirm state
btn.dataset.origHtml = btn.innerHTML;
btn.textContent = 'Tap again';
btn.classList.add('confirming');
this._stopConfirmTimer = setTimeout(() => {
this._stopConfirmTimer = null;
if (btn.dataset.origHtml) {
btn.innerHTML = btn.dataset.origHtml;
delete btn.dataset.origHtml;
}
btn.classList.remove('confirming');
}, 2000);
}
},
async runShell() {
const caseName = document.getElementById('quickStartCase').value || 'testcase';
const shellCount = Math.min(20, Math.max(1, parseInt(document.getElementById('shellCount').value) || 1));
const ownsLaunchTerminal = this._beginSessionLaunchStatus(
`Starting ${shellCount} Shell session(s) in ${caseName}...`,
'1;33'
);
try {
// Get the case path
const caseRes = await fetch(`/api/cases/${caseName}`);
let caseData = (await caseRes.json())?.data ?? {};
// Create the case if it doesn't exist
if (!caseData.path) {
const createCaseRes = await fetch('/api/cases', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name: caseName, description: '' })
});
const createCaseData = await createCaseRes.json();
if (!createCaseData.success) throw new Error(createCaseData.error || 'Failed to create case');
// API returns { success, data: { case: { name, path } } }
caseData = createCaseData.data.case;
}
const selectedCase = (this.cases || []).find(c => c.name === caseName);
const isRemoteCase =
caseData.location === 'remote' ||
caseData.location === 'docker' ||
selectedCase?.location === 'remote' ||
selectedCase?.location === 'docker';
const workingDir = caseData.path;
if (!workingDir) throw new Error('Case path not found');
// Remote cases run over ssh — route through /api/quick-start (see runClaude).
if (caseData.location === 'remote' || caseData.location === 'docker') {
const startNumber = this._nextCaseSessionStartNumber(caseName, 's');
const remoteIds = [];
for (let i = 0; i < shellCount; i++) {
const res = await fetch('/api/quick-start', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ caseName, mode: 'shell', sessionName: `s${startNumber + i}-${caseName}` })
});
const data = await res.json();
if (!data.success) throw new Error(data.error || 'Failed to start remote shell session');
await this._ensureCreatedSessionVisible(data.data.sessionId, data.data.session);
remoteIds.push(data.data.sessionId);
}
if (remoteIds[0]) {
// Don't pre-set activeSessionId — selectSession early-returns when the
// IDs match, skipping the buffer load, tab activation, and focus (see runCodex).
await this.selectSession(remoteIds[0]);
}
this.terminal.focus();
return;
}
// Find the highest existing s-number for THIS case to avoid duplicates
const startNumber = this._nextCaseSessionStartNumber(caseName, 's');
// Create all shell sessions in parallel
const sessionNames = [];
for (let i = 0; i < shellCount; i++) {
sessionNames.push(`s${startNumber + i}-${caseName}`);
}
// Step 1: Create all sessions in parallel
const createPromises = sessionNames.map(name =>
fetch('/api/sessions', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ ...(isRemoteCase ? { caseName } : { workingDir }), mode: 'shell', name })
}).then(r => r.json())
);
const createResults = await Promise.all(createPromises);
const sessionIds = [];
for (const result of createResults) {
if (!result.success) throw new Error(result.error);
await this._ensureCreatedSessionVisible(result.data.session.id, result.data.session);
sessionIds.push(result.data.session.id);
}
// Step 2: Start all shells in parallel
await Promise.all(sessionIds.map(id =>
fetch(`/api/sessions/${id}/shell`, { method: 'POST' })
));
// Step 3: Resize all in parallel (with minimum dimension enforcement)
const dims = this.getTerminalDimensions();
if (dims) {
await Promise.all(sessionIds.map(id =>
fetch(`/api/sessions/${id}/resize`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(dims)
})
));
}
// Switch to first session. Don't pre-set activeSessionId — selectSession
// early-returns when the IDs match, skipping the buffer load, tab
// activation, and focus (see runCodex), which left the new shell tab
// created but not shown until the user manually clicked it.
if (sessionIds.length > 0) {
await this.selectSession(sessionIds[0]);
}
this.terminal.focus();
} catch (err) {
this._reportSessionLaunchError(ownsLaunchTerminal, err.message);
}
},
async runOpenCode() {
const caseName = document.getElementById('quickStartCase').value || 'testcase';
// Remote cases run the CLI on the REMOTE host — the local /api/opencode/status
// probe and the local-only config/env below don't apply (quick-start rejects them).
const _runLoc = (this.cases || []).find(c => c.name === caseName)?.location;
const isRemote = _runLoc === 'remote' || _runLoc === 'docker';
const ownsLaunchTerminal = this._beginSessionLaunchStatus(`Starting OpenCode session in ${caseName}...`);
// Focus in sync gesture context (see runClaude comment)
this.terminal.focus();
try {
// Check if OpenCode is available (local sessions only)
if (!isRemote) {
const statusRes = await fetch('/api/opencode/status');
const status = (await statusRes.json()).data;
if (!status.available) {
this._reportSessionLaunchError(
ownsLaunchTerminal,
'OpenCode CLI not found. Install with: curl -fsSL https://opencode.ai/install | bash'
);
return;
}
}
// Quick-start with opencode mode (auto-allow tools by default).
// No `effort` field — it's Claude-specific (OpenCode has no /effort).
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: 'opencode',
sessionName: `w${this._nextCaseSessionStartNumber(caseName)}-${caseName}`,
...(isRemote ? {} : {
openCodeConfig: { autoAllowTools: true },
...(Object.keys(envOverrides).length > 0 ? { envOverrides } : {}),
}),
})
});
const data = await res.json();
if (!data.success) throw new Error(data.error || 'Failed to start OpenCode');
await this._ensureCreatedSessionVisible(data.data.sessionId, data.data.session);
// Switch to the new session (don't pre-set activeSessionId — selectSession
// early-returns when IDs match, skipping buffer load and sendResize)
if (data.data.sessionId) {
await this.selectSession(data.data.sessionId);
}
this.terminal.focus();
} catch (err) {
this._reportSessionLaunchError(ownsLaunchTerminal, err.message);
}
},
async runCodex() {
const caseName = document.getElementById('quickStartCase').value || 'testcase';
// Remote cases run Codex on the REMOTE host — 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';
const ownsLaunchTerminal = this._beginSessionLaunchStatus(`Starting Codex session in ${caseName}...`);
this.terminal.focus();
try {
if (!isRemote) {
const statusRes = await fetch('/api/codex/status');
const status = (await statusRes.json()).data;
if (!status.available) {
this._reportSessionLaunchError(
ownsLaunchTerminal,
'Codex CLI not found. Install with: npm install -g @openai/codex'
);
return;
}
}
const globalSettings = this.loadAppSettingsFromStorage();
const envOverrides = this.buildEnvOverrides(this.getCaseSettings(caseName), globalSettings);
const res = await fetch('/api/quick-start', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
caseName,
mode: 'codex',
sessionName: `w${this._nextCaseSessionStartNumber(caseName)}-${caseName}`,
...(isRemote ? {} : {
codexConfig: {
dangerouslyBypassApprovals: globalSettings.codexDangerouslyBypassApprovals ?? false,
animations: globalSettings.codexAnimationsEnabled ?? false,
renderMode: 'hybrid',
},
...(Object.keys(envOverrides).length > 0 ? { envOverrides } : {}),
}),
})
});
const data = await res.json();
if (!data.success) throw new Error(data.error || 'Failed to start Codex');
await this._ensureCreatedSessionVisible(data.data.sessionId, data.data.session);
// Switch to the new session (don't pre-set activeSessionId — selectSession
// early-returns when IDs match, skipping buffer load and sendResize)
if (data.data.sessionId) {
await this.selectSession(data.data.sessionId);
}
this.terminal.focus();
} catch (err) {
this._reportSessionLaunchError(ownsLaunchTerminal, err.message);
}
},
async runGemini() {
const caseName = document.getElementById('quickStartCase').value || 'testcase';
// Remote cases run Gemini on the REMOTE host — 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';
const ownsLaunchTerminal = this._beginSessionLaunchStatus(`Starting Gemini session in ${caseName}...`);
this.terminal.focus();
try {
if (!isRemote) {
const statusRes = await fetch('/api/gemini/status');
const status = (await statusRes.json()).data;
if (!status.available) {
this._reportSessionLaunchError(
ownsLaunchTerminal,
'Gemini CLI not found. Install with: npm install -g @google/gemini-cli'
);
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: 'gemini',
sessionName: `w${this._nextCaseSessionStartNumber(caseName)}-${caseName}`,
...(isRemote ? {} : {
geminiConfig: { approvalMode: 'yolo' },
...(Object.keys(envOverrides).length > 0 ? { envOverrides } : {}),
}),
})
});
const data = await res.json();
if (!data.success) throw new Error(data.error || 'Failed to start Gemini');
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._reportSessionLaunchError(ownsLaunchTerminal, err.message);
}
},
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';
const ownsLaunchTerminal = this._beginSessionLaunchStatus(`Starting Antigravity session in ${caseName}...`);
this.terminal.focus();
try {
if (!isRemote) {
const statusRes = await fetch('/api/antigravity/status');
const status = (await statusRes.json()).data;
if (!status.available) {
this._reportSessionLaunchError(
ownsLaunchTerminal,
'Antigravity CLI not found. Install with: curl -fsSL https://antigravity.google/cli/install.sh | bash'
);
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._reportSessionLaunchError(ownsLaunchTerminal, err.message);
}
},
// ═══════════════════════════════════════════════════════════════
// Session Options Modal
// ═══════════════════════════════════════════════════════════════
openSessionOptions(sessionId) {
const session = this.sessions.get(sessionId);
if (!session) return;
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' || session.mode === 'antigravity';
this.switchOptionsTab(isAltMode ? 'summary' : 'respawn');
// Update respawn status display and buttons
const respawnStatus = document.getElementById('sessionRespawnStatus');
const enableBtn = document.getElementById('modalEnableRespawnBtn');
const stopBtn = document.getElementById('modalStopRespawnBtn');
if (this.respawnStatus[sessionId]) {
respawnStatus.classList.add('active');
respawnStatus.querySelector('.respawn-status-text').textContent =
this.respawnStatus[sessionId].state || 'Active';
enableBtn.style.display = 'none';
stopBtn.style.display = '';
} else {
respawnStatus.classList.remove('active');
respawnStatus.querySelector('.respawn-status-text').textContent = 'Not active';
enableBtn.style.display = '';
stopBtn.style.display = 'none';
}
// Only show respawn section for claude mode sessions with a running process
const respawnSection = document.getElementById('sessionRespawnSection');
if (session.mode === 'claude' && session.pid) {
respawnSection.style.display = '';
} else {
respawnSection.style.display = 'none';
}
// Hide Claude-specific options for external CLI sessions
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' : ''; });
// Reset duration presets to default (unlimited)
this.selectDurationPreset('');
// Populate respawn config from saved state
this.loadSavedRespawnConfig(sessionId);
// Populate auto-compact/clear from session state
document.getElementById('modalAutoCompactEnabled').checked = session.autoCompactEnabled ?? false;
document.getElementById('modalAutoCompactThreshold').value = session.autoCompactThreshold ?? 110000;
document.getElementById('modalAutoCompactPrompt').value = session.autoCompactPrompt ?? '';
document.getElementById('modalAutoClearEnabled').checked = session.autoClearEnabled ?? false;
document.getElementById('modalAutoClearThreshold').value = session.autoClearThreshold ?? 140000;
// Populate auto-resume on usage limit (token pause control)
document.getElementById('modalAutoResumeEnabled').checked = session.autoResumeEnabled ?? false;
this.updateAutoResumeStatus(sessionId);
document.getElementById('modalImageWatcherEnabled').checked = session.imageWatcherEnabled ?? true;
document.getElementById('modalFlickerFilterEnabled').checked = session.flickerFilterEnabled ?? false;
// Populate session name input with prefix/suffix split
const _modalParsed = parseSessionPrefix(session.name);
const _prefixEl = document.getElementById('modalSessionPrefix');
if (_modalParsed) {
_prefixEl.textContent = _modalParsed.prefix + ': ';
_prefixEl.style.display = '';
document.getElementById('modalSessionName').value = _modalParsed.suffix;
document.getElementById('modalSessionName').placeholder = 'Add description...';
} else {
_prefixEl.style.display = 'none';
_prefixEl.textContent = '';
document.getElementById('modalSessionName').value = session.name || '';
document.getElementById('modalSessionName').placeholder = 'Auto (directory name)';
}
// Initialize color picker with current session color
const currentColor = session.color || 'default';
const colorPicker = document.getElementById('sessionColorPicker');
colorPicker?.querySelectorAll('.color-swatch').forEach(s => {
s.classList.toggle('selected', s.dataset.color === currentColor);
});
// Initialize respawn preset dropdown
this.renderPresetDropdown();
document.getElementById('respawnPresetSelect').value = '';
document.getElementById('presetDescriptionHint').textContent = '';
// Hide Ralph/Todo tab and Respawn tab for external CLI sessions (not supported)
const ralphTabBtn = document.querySelector('#sessionOptionsModal .modal-tab-btn[data-tab="ralph"]');
const respawnTabBtn = document.querySelector('#sessionOptionsModal .modal-tab-btn[data-tab="respawn"]');
if (isExternalCli) {
if (ralphTabBtn) ralphTabBtn.style.display = 'none';
if (respawnTabBtn) respawnTabBtn.style.display = 'none';
// Default to Context tab for external CLI sessions since Respawn is hidden
this.switchOptionsTab('context');
} else {
if (ralphTabBtn) ralphTabBtn.style.display = '';
if (respawnTabBtn) respawnTabBtn.style.display = '';
}
// Populate Ralph Wiggum form with current session values (skip for external CLI sessions)
if (!isExternalCli) {
const ralphState = this.ralphStates.get(sessionId);
this.populateRalphForm({
enabled: ralphState?.loop?.enabled ?? session.ralphLoop?.enabled ?? false,
completionPhrase: ralphState?.loop?.completionPhrase || session.ralphLoop?.completionPhrase || '',
maxIterations: ralphState?.loop?.maxIterations || session.ralphLoop?.maxIterations || 0,
maxTodos: ralphState?.loop?.maxTodos || session.ralphLoop?.maxTodos,
todoExpirationMinutes: ralphState?.loop?.todoExpirationMinutes || session.ralphLoop?.todoExpirationMinutes,
});
}
const modal = document.getElementById('sessionOptionsModal');
modal.classList.add('active');
// Activate focus trap
this.activeFocusTrap = new FocusTrap(modal);
this.activeFocusTrap.activate();
},
async saveSessionName() {
if (!this.editingSessionId) return;
const session = this.sessions.get(this.editingSessionId);
const parsed = session ? parseSessionPrefix(session.name) : null;
const inputVal = document.getElementById('modalSessionName').value.trim();
let name;
if (parsed) {
name = parsed.prefix + (inputVal ? ': ' + inputVal : '');
} else {
name = inputVal;
}
try {
await this._apiPut(`/api/sessions/${this.editingSessionId}/name`, { name });
} catch (err) {
this.showToast('Failed to save session name: ' + err.message, 'error');
}
},
async autoSaveAutoCompact() {
if (!this.editingSessionId) return;
try {
await this._apiPost(`/api/sessions/${this.editingSessionId}/auto-compact`, {
enabled: document.getElementById('modalAutoCompactEnabled').checked,
threshold: parseInt(document.getElementById('modalAutoCompactThreshold').value) || 110000,
prompt: document.getElementById('modalAutoCompactPrompt').value.trim() || undefined
});
} catch { /* silent */ }
},
async autoSaveAutoClear() {
if (!this.editingSessionId) return;
try {
await this._apiPost(`/api/sessions/${this.editingSessionId}/auto-clear`, {
enabled: document.getElementById('modalAutoClearEnabled').checked,
threshold: parseInt(document.getElementById('modalAutoClearThreshold').value) || 140000
});
} catch { /* silent */ }
},
async autoSaveAutoResume() {
if (!this.editingSessionId) return;
const enabled = document.getElementById('modalAutoResumeEnabled').checked;
try {
await this._apiPost(`/api/sessions/${this.editingSessionId}/auto-resume`, { enabled });
const session = this.sessions.get(this.editingSessionId);
if (session) {
session.autoResumeEnabled = enabled;
if (!enabled) session.autoResumeAt = undefined;
}
this.updateAutoResumeStatus(this.editingSessionId);
this.showToast(`Auto-resume on usage limit ${enabled ? 'enabled' : 'disabled'}`, 'success');
} catch (err) {
this.showToast('Failed to toggle auto-resume: ' + err.message, 'error');
}
},
// Show "resumes at HH:MM" in the session options modal while a usage-limit
// pause is armed for the session being edited
updateAutoResumeStatus(sessionId) {
const el = document.getElementById('autoResumeStatus');
if (!el || this.editingSessionId !== sessionId) return;
const session = this.sessions.get(sessionId);
if (session?.autoResumeAt && session.autoResumeAt > Date.now()) {
const at = new Date(session.autoResumeAt).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
el.textContent = `Usage limit pause active — resumes at ${at}`;
el.classList.add('active');
} else {
el.textContent = '';
el.classList.remove('active');
}
},
async toggleSessionImageWatcher() {
if (!this.editingSessionId) return;
const enabled = document.getElementById('modalImageWatcherEnabled').checked;
try {
await this._apiPost(`/api/sessions/${this.editingSessionId}/image-watcher`, { enabled });
// Update local session state
const session = this.sessions.get(this.editingSessionId);
if (session) {
session.imageWatcherEnabled = enabled;
}
this.showToast(`Image watcher ${enabled ? 'enabled' : 'disabled'}`, 'success');
} catch (err) {
this.showToast('Failed to toggle image watcher', 'error');
}
},
async toggleFlickerFilter() {
if (!this.editingSessionId) return;
const enabled = document.getElementById('modalFlickerFilterEnabled').checked;
try {
await this._apiPost(`/api/sessions/${this.editingSessionId}/flicker-filter`, { enabled });
// Update local session state
const session = this.sessions.get(this.editingSessionId);
if (session) {
session.flickerFilterEnabled = enabled;
}
this.showToast(`Flicker filter ${enabled ? 'enabled' : 'disabled'}`, 'success');
} catch (err) {
this.showToast('Failed to toggle flicker filter', 'error');
}
},
async autoSaveRespawnConfig() {
if (!this.editingSessionId) return;
const config = {
updatePrompt: document.getElementById('modalRespawnPrompt').value,
sendClear: document.getElementById('modalRespawnSendClear').checked,
sendInit: document.getElementById('modalRespawnSendInit').checked,
kickstartPrompt: document.getElementById('modalRespawnKickstart').value.trim() || undefined,
autoAcceptPrompts: document.getElementById('modalRespawnAutoAccept').checked,
};
try {
await this._apiPut(`/api/sessions/${this.editingSessionId}/respawn/config`, config);
} catch {
// Silent save - don't interrupt user
}
},
async loadSavedRespawnConfig(sessionId) {
try {
const res = await fetch(`/api/sessions/${sessionId}/respawn/config`);
const data = await res.json();
if (data.success && data.data && data.data.config) {
const c = data.data.config;
document.getElementById('modalRespawnPrompt').value = c.updatePrompt || 'update all the docs and CLAUDE.md';
document.getElementById('modalRespawnSendClear').checked = c.sendClear ?? true;
document.getElementById('modalRespawnSendInit').checked = c.sendInit ?? true;
document.getElementById('modalRespawnKickstart').value = c.kickstartPrompt || '';
document.getElementById('modalRespawnAutoAccept').checked = c.autoAcceptPrompts ?? true;
// Restore duration if set
if (c.durationMinutes) {
const presetBtn = document.querySelector(`.duration-preset-btn[data-minutes="${c.durationMinutes}"]`);
if (presetBtn) {
this.selectDurationPreset(String(c.durationMinutes));
} else {
this.selectDurationPreset('custom');
document.getElementById('modalRespawnDuration').value = c.durationMinutes;
}
}
}
} catch {
// Ignore - use defaults
}
},
// Handle duration preset selection
selectDurationPreset(value) {
// Remove active from all buttons
document.querySelectorAll('.duration-preset-btn').forEach(btn => btn.classList.remove('active'));
// Find and activate the clicked button
const btn = document.querySelector(`.duration-preset-btn[data-minutes="${value}"]`);
if (btn) btn.classList.add('active');
// Show/hide custom input
const customInput = document.querySelector('.duration-custom-input');
const durationInput = document.getElementById('modalRespawnDuration');
if (value === 'custom') {
customInput.classList.add('visible');
durationInput.focus();
} else {
customInput.classList.remove('visible');
durationInput.value = ''; // Clear custom value when using preset
}
},
// Get selected duration from preset buttons or custom input
getSelectedDuration() {
const customInput = document.querySelector('.duration-custom-input');
const durationInput = document.getElementById('modalRespawnDuration');
if (customInput.classList.contains('visible')) {
// Custom mode - use input value
return durationInput.value ? parseInt(durationInput.value) : null;
} else {
// Preset mode - get from active button
const activeBtn = document.querySelector('.duration-preset-btn.active');
const minutes = activeBtn?.dataset.minutes;
return minutes ? parseInt(minutes) : null;
}
},
// ═══════════════════════════════════════════════════════════════
// Session Options Modal Tabs
// ═══════════════════════════════════════════════════════════════
switchOptionsTab(tabName) {
// Toggle active class on tab buttons
document.querySelectorAll('#sessionOptionsModal .modal-tab-btn').forEach(btn => {
btn.classList.toggle('active', btn.dataset.tab === tabName);
});
// Toggle hidden class on tab content
document.getElementById('respawn-tab').classList.toggle('hidden', tabName !== 'respawn');
document.getElementById('context-tab').classList.toggle('hidden', tabName !== 'context');
document.getElementById('ralph-tab').classList.toggle('hidden', tabName !== 'ralph');
document.getElementById('summary-tab').classList.toggle('hidden', tabName !== 'summary');
// Load run summary data when switching to summary tab
if (tabName === 'summary' && this.editingSessionId) {
this.loadRunSummary(this.editingSessionId);
}
},
getRalphConfig() {
return {
enabled: document.getElementById('modalRalphEnabled').checked,
completionPhrase: document.getElementById('modalRalphPhrase').value.trim(),
maxIterations: parseInt(document.getElementById('modalRalphMaxIterations').value) || 0,
maxTodos: parseInt(document.getElementById('modalRalphMaxTodos').value) || 50,
todoExpirationMinutes: parseInt(document.getElementById('modalRalphTodoExpiration').value) || 60
};
},
populateRalphForm(config) {
document.getElementById('modalRalphEnabled').checked = config?.enabled ?? false;
document.getElementById('modalRalphPhrase').value = config?.completionPhrase || '';
document.getElementById('modalRalphMaxIterations').value = config?.maxIterations || 0;
document.getElementById('modalRalphMaxTodos').value = config?.maxTodos || 50;
document.getElementById('modalRalphTodoExpiration').value = config?.todoExpirationMinutes || 60;
},
async saveRalphConfig() {
if (!this.editingSessionId) {
this.showToast('No session selected', 'warning');
return;
}
const config = this.getRalphConfig();
// If user is enabling Ralph, clear from closed set
if (config.enabled) {
this.ralphClosedSessions.delete(this.editingSessionId);
}
try {
const res = await fetch(`/api/sessions/${this.editingSessionId}/ralph-config`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(config)
});
const data = await res.json();
if (data.error) throw new Error(data.error);
this.showToast('Ralph config saved', 'success');
} catch (err) {
this.showToast('Failed to save Ralph config: ' + err.message, 'error');
}
},
// Inline rename on right-click
startInlineRename(sessionId) {
const session = this.sessions.get(sessionId);
if (!session) return;
const tabName = document.querySelector(`.tab-name[data-session-id="${sessionId}"]`);
if (!tabName) return;
// Prevent tab re-renders from destroying the input while renaming
this._inlineRenameActive = true;
const currentName = this.getSessionName(session);
const parsed = parseSessionPrefix(session.name);
const originalContent = tabName.textContent;
// Clear existing content to make room for the input element
tabName.textContent = '';
while (tabName.firstChild) tabName.removeChild(tabName.firstChild);
// If prefix detected, show it as non-editable label
if (parsed) {
const prefixLabel = document.createElement('span');
prefixLabel.textContent = parsed.prefix + ': ';
prefixLabel.style.cssText = 'color: var(--text-muted); font-size: 0.75rem; white-space: nowrap;';
tabName.appendChild(prefixLabel);
}
const input = document.createElement('input');
input.type = 'text';
input.value = parsed ? parsed.suffix : (session.name || '');
input.placeholder = parsed ? 'Add description...' : currentName;
input.className = 'tab-rename-input';
input.style.cssText = 'width: 80px; font-size: 0.75rem; padding: 2px 4px; background: var(--bg-input); border: 1px solid var(--accent); border-radius: 3px; color: var(--text); outline: none;';
tabName.appendChild(input);
input.focus();
input.select();
const finishRename = async ({ commit }) => {
if (!this._inlineRenameActive) return; // prevent double-fire
this._inlineRenameActive = false;
this._activeRename = null;
// Aborted (e.g. the session was deleted mid-rename, or Escape): re-render
// so any ghost DOM is replaced with the canonical tab list, and skip the
// API call — a cancel must not fire a stale rename PUT.
if (!commit) {
this.renderSessionTabs();
return;
}
const suffix = input.value.trim();
const fullName = parsed ? parsed.prefix + (suffix ? ': ' + suffix : '') : suffix;
tabName.textContent = fullName || originalContent;
// Skip the API call if the session vanished between focus and blur.
const stillExists = this.sessions.has(sessionId);
if (stillExists && fullName !== session.name) {
try {
await fetch(`/api/sessions/${sessionId}/name`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name: fullName })
});
} catch (err) {
tabName.textContent = originalContent;
this.showToast('Failed to rename', 'error');
}
}
// Re-render tabs to restore full tab structure
this.renderSessionTabs();
};
// Register only after the input is wired so a throw above can't strand state.
this._activeRename = {
sessionId,
cancel: () => finishRename({ commit: false }),
};
input.addEventListener('blur', () => finishRename({ commit: true }));
input.addEventListener('keydown', (e) => {
// Enter/Escape during IME composition belong to the IME (e.g. confirming
// a Chinese pinyin candidate). keyCode 229 is the legacy signal for the
// same condition on browsers that don't set isComposing reliably.
if (e.isComposing || e.keyCode === 229) return;
if (e.key === 'Enter') {
e.preventDefault();
input.blur();
} else if (e.key === 'Escape') {
input.value = '';
input.blur();
}
});
},
// ═══════════════════════════════════════════════════════════════
// Case Settings
// ═══════════════════════════════════════════════════════════════
toggleCaseSettings() {
const popover = document.getElementById('caseSettingsPopover');
if (popover.classList.contains('hidden')) {
// Load settings for current case
const caseName = document.getElementById('quickStartCase').value || 'testcase';
const settings = this.getCaseSettings(caseName);
document.getElementById('caseAgentTeams').checked = settings.agentTeams;
document.getElementById('caseOpusContext1m').checked = settings.opusContext1m;
popover.classList.remove('hidden');
// Close on outside click (one-shot listener)
const closeHandler = (e) => {
if (!popover.contains(e.target) && !e.target.classList.contains('btn-case-settings')) {
popover.classList.add('hidden');
document.removeEventListener('click', closeHandler);
}
};
// Defer to avoid catching the current click
setTimeout(() => document.addEventListener('click', closeHandler), 0);
} else {
popover.classList.add('hidden');
}
},
getCaseSettings(caseName) {
try {
const stored = localStorage.getItem('caseSettings_' + caseName);
if (stored) return JSON.parse(stored);
} catch { /* ignore */ }
return { agentTeams: false, opusContext1m: true };
},
saveCaseSettings(caseName, settings) {
localStorage.setItem('caseSettings_' + caseName, JSON.stringify(settings));
},
onCaseSettingChanged() {
const caseName = document.getElementById('quickStartCase').value || 'testcase';
const settings = this.getCaseSettings(caseName);
settings.agentTeams = document.getElementById('caseAgentTeams').checked;
settings.opusContext1m = document.getElementById('caseOpusContext1m').checked;
this.saveCaseSettings(caseName, settings);
// Sync mobile checkboxes
const mobileCheckbox = document.getElementById('caseAgentTeamsMobile');
if (mobileCheckbox) mobileCheckbox.checked = settings.agentTeams;
const mobileOpusCheckbox = document.getElementById('caseOpusContext1mMobile');
if (mobileOpusCheckbox) mobileOpusCheckbox.checked = settings.opusContext1m;
},
toggleCaseSettingsMobile() {
const popover = document.getElementById('caseSettingsPopoverMobile');
if (popover.classList.contains('hidden')) {
const caseName = document.getElementById('quickStartCase').value || 'testcase';
const settings = this.getCaseSettings(caseName);
document.getElementById('caseAgentTeamsMobile').checked = settings.agentTeams;
document.getElementById('caseOpusContext1mMobile').checked = settings.opusContext1m;
popover.classList.remove('hidden');
const closeHandler = (e) => {
if (!popover.contains(e.target) && !e.target.classList.contains('btn-case-settings-mobile')) {
popover.classList.add('hidden');
document.removeEventListener('click', closeHandler);
}
};
setTimeout(() => document.addEventListener('click', closeHandler), 0);
} else {
popover.classList.add('hidden');
}
},
onCaseSettingChangedMobile() {
const caseName = document.getElementById('quickStartCase').value || 'testcase';
const settings = this.getCaseSettings(caseName);
settings.agentTeams = document.getElementById('caseAgentTeamsMobile').checked;
settings.opusContext1m = document.getElementById('caseOpusContext1mMobile').checked;
this.saveCaseSettings(caseName, settings);
// Sync desktop checkboxes
const desktopCheckbox = document.getElementById('caseAgentTeams');
if (desktopCheckbox) desktopCheckbox.checked = settings.agentTeams;
const desktopOpusCheckbox = document.getElementById('caseOpusContext1m');
if (desktopOpusCheckbox) desktopOpusCheckbox.checked = settings.opusContext1m;
},
// ═══════════════════════════════════════════════════════════════
// Create Case Modal
// ═══════════════════════════════════════════════════════════════
showCreateCaseModal() {
document.getElementById('newCaseName').value = '';
document.getElementById('newCaseDescription').value = '';
document.getElementById('linkCaseName').value = '';
document.getElementById('linkCasePath').value = '';
const remoteFields = [
'remoteCaseName',
'remoteCasePath',
'remoteHostId',
'remoteHostAddress',
'remoteHostUsername',
'remoteHostPort',
'remoteHostCodexCommand',
'remoteHostIdentityFile',
'remoteHostSocksProxy',
'remoteHostJumpHost',
'remoteHostExtraSshOptions',
];
remoteFields.forEach(id => {
const el = document.getElementById(id);
if (el) el.value = '';
});
// Reset to first tab
this.caseModalTab = 'case-create';
this.switchCaseModalTab('case-create');
// Wire up tab buttons
const modal = document.getElementById('createCaseModal');
modal.querySelectorAll('.modal-tabs .modal-tab-btn').forEach(btn => {
btn.onclick = () => this.switchCaseModalTab(btn.dataset.tab);
});
// Scroll-into-view on focus for mobile keyboard visibility
modal.querySelectorAll('input[type="text"]').forEach(input => {
if (!input._mobileScrollWired) {
input._mobileScrollWired = true;
input.addEventListener('focus', () => {
if (window.innerWidth <= 430) {
setTimeout(() => input.scrollIntoView({ behavior: 'smooth', block: 'center' }), 300);
}
});
}
});
modal.classList.add('active');
document.getElementById('newCaseName').focus();
},
switchCaseModalTab(tabName) {
this.caseModalTab = tabName;
const modal = document.getElementById('createCaseModal');
// Toggle active class on tab buttons
modal.querySelectorAll('.modal-tabs .modal-tab-btn').forEach(btn => {
btn.classList.toggle('active', btn.dataset.tab === tabName);
});
// Toggle hidden class on tab content
modal.querySelectorAll('.modal-tab-content').forEach(content => {
content.classList.toggle('hidden', content.id !== tabName);
});
// Update submit button (hide for manage tab)
const submitBtn = document.getElementById('caseModalSubmit');
if (tabName === 'case-manage') {
submitBtn.style.display = 'none';
this.renderCaseManageList();
this.refreshDockerExports();
} else {
submitBtn.style.display = '';
submitBtn.textContent =
tabName === 'case-create'
? 'Create'
: tabName === 'case-remote'
? 'Link Remote'
: tabName === 'case-docker'
? 'Link Docker'
: 'Link';
}
// Focus appropriate input
if (tabName === 'case-create') {
document.getElementById('newCaseName').focus();
} else if (tabName === 'case-link') {
document.getElementById('linkCaseName').focus();
} else if (tabName === 'case-remote') {
document.getElementById('remoteCaseName').focus();
} else if (tabName === 'case-docker') {
document.getElementById('dockerCaseName').focus();
}
},
closeCreateCaseModal() {
document.getElementById('createCaseModal').classList.remove('active');
},
async submitCaseModal() {
const btn = document.getElementById('caseModalSubmit');
const originalText = btn.textContent;
btn.classList.add('loading');
btn.textContent = this.caseModalTab === 'case-create' ? 'Creating...' : 'Linking...';
try {
if (this.caseModalTab === 'case-create') {
await this.createCase();
} else if (this.caseModalTab === 'case-remote') {
await this.linkRemoteCase();
} else if (this.caseModalTab === 'case-docker') {
await this.linkDockerCase();
} else {
await this.linkCase();
}
} finally {
btn.classList.remove('loading');
btn.textContent = originalText;
}
},
async createCase() {
const name = document.getElementById('newCaseName').value.trim();
const description = document.getElementById('newCaseDescription').value.trim();
if (!name) {
this.showToast('Please enter a case name', 'error');
return;
}
if (!/^[a-zA-Z0-9_-]+$/.test(name)) {
this.showToast('Invalid name. Use only letters, numbers, hyphens, underscores.', 'error');
return;
}
// One-click "Run in Docker": create the case folder AND a container, then start
// a session inside it. Optional expandable settings override the defaults.
const inDocker = document.getElementById('newCaseDocker')?.checked;
const endpoint = inDocker ? '/api/cases/docker-quickcreate' : '/api/cases';
const payload = inDocker
? { name, description, ...this._collectDockerQuickSettings() }
: { name, description };
try {
const res = await fetch(endpoint, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload)
});
const data = await res.json();
if (data.success) {
this.closeCreateCaseModal();
// Reload cases and select the new one
await this.loadQuickStartCases(name);
// Save as last used case
await this.saveLastUsedCase(name);
if (inDocker) {
const caps = data.data?.capsEnforced === false ? ' (resource caps advisory on this engine)' : '';
this.showToast(`Docker case "${name}" created${caps} — starting session…`, 'success');
// Start a session INSIDE the container (routes through quick-start).
await this.runClaude();
} else {
this.showToast(`Case "${name}" created`, 'success');
}
} else {
this.showToast(data.error || 'Failed to create case', 'error');
}
} catch (err) {
console.error('Failed to create case:', err);
this.showToast('Failed to create case: ' + err.message, 'error');
}
},
// Fill the memory/cpu/gpu fields from a resource template. `medium` clears them so
// the server uses its defaults (no per-case host); `custom` leaves them editable.
applyDockerTemplate() {
const t = document.getElementById('quickDockerTemplate')?.value;
const presets = {
small: { m: '2g', c: '1', g: '' },
medium: { m: '', c: '', g: '' },
large: { m: '8g', c: '4', g: '' },
gpu: { m: '8g', c: '4', g: 'all' },
};
const p = presets[t];
if (!p) return; // 'custom' — leave fields as-is
const set = (id, v) => {
const el = document.getElementById(id);
if (el) el.value = v;
};
set('quickDockerMemory', p.m);
set('quickDockerCpus', p.c);
set('quickDockerGpus', p.g);
},
// Collect only the non-default docker overrides (empty fields fall back to defaults
// server-side; sent as undefined, never null, per the Zod .optional() gotcha).
_collectDockerQuickSettings() {
const val = (id) => (document.getElementById(id)?.value || '').trim();
const o = {};
const mem = val('quickDockerMemory');
if (mem) o.memory = mem;
const cpus = val('quickDockerCpus');
if (cpus) o.cpus = cpus;
const gpus = val('quickDockerGpus');
if (gpus && gpus.toLowerCase() !== 'none') o.gpus = gpus;
const net = document.getElementById('quickDockerNetwork')?.value;
if (net && net !== 'bridge') o.network = net;
const img = val('quickDockerImage');
if (img) o.image = img;
const mc = document.getElementById('quickDockerMountCreds');
if (mc && !mc.checked) o.mountCredentials = false;
return o;
},
async linkCase() {
const name = document.getElementById('linkCaseName').value.trim();
const path = document.getElementById('linkCasePath').value.trim();
if (!name) {
this.showToast('Please enter a case name', 'error');
return;
}
if (!/^[a-zA-Z0-9_-]+$/.test(name)) {
this.showToast('Invalid name. Use only letters, numbers, hyphens, underscores.', 'error');
return;
}
if (!path) {
this.showToast('Please enter a folder path', 'error');
return;
}
try {
const res = await fetch('/api/cases/link', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name, path })
});
const data = await res.json();
if (data.success) {
this.closeCreateCaseModal();
this.showToast(`Case "${name}" linked to ${path}`, 'success');
// Reload cases and select the new one
await this.loadQuickStartCases(name);
// Save as last used case
await this.saveLastUsedCase(name);
} else {
this.showToast(data.error || 'Failed to link case', 'error');
}
} catch (err) {
console.error('Failed to link case:', err);
this.showToast('Failed to link case: ' + err.message, 'error');
}
},
openLinkCasePathPicker() {
const pathInput = document.getElementById('linkCasePath');
PathPicker.open({
title: 'Select Existing Project Folder',
initialPath: pathInput.value.trim(),
directoriesOnly: true,
onSelect: (path) => {
pathInput.value = path;
const nameInput = document.getElementById('linkCaseName');
if (!nameInput.value.trim()) {
const folderName = path.split('/').filter(Boolean).pop() || '';
if (/^[\p{L}\p{N}_-]+$/u.test(folderName)) nameInput.value = folderName;
}
pathInput.focus();
pathInput.setSelectionRange(path.length, path.length);
},
});
},
async linkRemoteCase() {
const name = document.getElementById('remoteCaseName').value.trim();
const remotePath = document.getElementById('remoteCasePath').value.trim();
const hostId = document.getElementById('remoteHostId').value.trim();
const host = document.getElementById('remoteHostAddress').value.trim();
const username = document.getElementById('remoteHostUsername').value.trim();
const codexCommand = document.getElementById('remoteHostCodexCommand').value.trim();
// COD-107 — port + advanced SSH connection options.
const portRaw = document.getElementById('remoteHostPort').value.trim();
const identityFile = document.getElementById('remoteHostIdentityFile').value.trim();
const socksProxy = document.getElementById('remoteHostSocksProxy').value.trim();
const jumpHost = document.getElementById('remoteHostJumpHost').value.trim();
const extraSshOptions = document.getElementById('remoteHostExtraSshOptions').value
.split('\n')
.map(line => line.trim())
.filter(line => line.length > 0);
if (!name || !remotePath || !hostId || !host || !username) {
this.showToast('Please complete all required remote fields', 'error');
return;
}
if (!/^[a-zA-Z0-9_-]+$/.test(name) || !/^[a-zA-Z0-9_-]+$/.test(hostId)) {
this.showToast('Invalid name. Use only letters, numbers, hyphens, underscores.', 'error');
return;
}
if (!remotePath.startsWith('/')) {
this.showToast('Remote path must be absolute', 'error');
return;
}
let port;
if (portRaw) {
port = Number(portRaw);
if (!Number.isInteger(port) || port < 1 || port > 65535) {
this.showToast('SSH port must be a number between 1 and 65535', 'error');
return;
}
}
try {
const hostPayload = {
id: hostId,
label: hostId,
host,
username,
...(port ? { port } : {}),
...(identityFile ? { identityFile } : {}),
...(socksProxy ? { socksProxy } : {}),
...(jumpHost ? { jumpHost } : {}),
...(extraSshOptions.length ? { extraSshOptions } : {}),
...(codexCommand ? { commands: { codex: codexCommand } } : {}),
};
const hostRes = await fetch('/api/remote-hosts', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(hostPayload)
});
const hostData = await hostRes.json();
if (!hostData.success && hostData.errorCode !== 'ALREADY_EXISTS') {
throw new Error(hostData.error || 'Failed to save remote host');
}
const caseRes = await fetch('/api/cases/remote-link', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name, hostId, remotePath })
});
const caseData = await caseRes.json();
if (caseData.success) {
this.closeCreateCaseModal();
this.showToast(`Remote case "${name}" linked`, 'success');
await this.loadQuickStartCases(name);
await this.saveLastUsedCase(name);
} else {
this.showToast(caseData.error || 'Failed to link remote case', 'error');
}
} catch (err) {
console.error('Failed to link remote case:', err);
this.showToast('Failed to link remote case: ' + err.message, 'error');
}
},
async linkDockerCase() {
const name = document.getElementById('dockerCaseName').value.trim();
const hostWorkspacePath = document.getElementById('dockerWorkspacePath').value.trim();
const hostId = document.getElementById('dockerHostId').value.trim() || 'local';
const image = document.getElementById('dockerImage').value.trim() || 'codeman/agent:base';
const network = document.getElementById('dockerNetwork').value;
const memory = document.getElementById('dockerMemory').value.trim();
const cpus = document.getElementById('dockerCpus').value.trim();
const mountCredentials = document.getElementById('dockerMountCredentials').checked;
const resumeOnStart = document.getElementById('dockerResumeOnStart').checked;
const statusEl = document.getElementById('dockerLinkStatus');
if (!name || !hostWorkspacePath) {
this.showToast('Please enter a case name and workspace path', 'error');
return;
}
if (!/^[a-zA-Z0-9_-]+$/.test(name) || !/^[a-zA-Z0-9_-]+$/.test(hostId)) {
this.showToast('Invalid name. Use only letters, numbers, hyphens, underscores.', 'error');
return;
}
if (!hostWorkspacePath.startsWith('/')) {
this.showToast('Workspace path must be absolute', 'error');
return;
}
try {
if (statusEl) statusEl.textContent = 'Checking docker daemon + base image...';
// omitted optionals sent as UNDEFINED (never null — Zod .optional() rejects null)
const resources = {};
if (memory) resources.memory = memory;
if (cpus) resources.cpus = cpus;
const hostPayload = {
id: hostId,
label: hostId,
image,
network,
mountCredentials,
resumeOnStart,
...(Object.keys(resources).length ? { resources } : {}),
};
// PUT (update-or-create) so re-linking with the same host id refreshes its settings.
let hostRes = await fetch('/api/docker-hosts', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(hostPayload),
});
let hostData = await hostRes.json();
if (!hostData.success && hostData.errorCode === 'ALREADY_EXISTS') {
hostRes = await fetch(`/api/docker-hosts/${encodeURIComponent(hostId)}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(hostPayload),
});
hostData = await hostRes.json();
}
if (!hostData.success) throw new Error(hostData.error || 'Failed to save docker host');
const caseRes = await fetch('/api/cases/docker-link', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name, hostId, hostWorkspacePath }),
});
const caseData = await caseRes.json();
if (caseData.success) {
this.closeCreateCaseModal();
const caps = caseData.data?.capsEnforced === false ? ' (resource caps are advisory on this engine)' : '';
this.showToast(`Docker case "${name}" linked${caps}`, 'success');
await this.loadQuickStartCases(name);
await this.saveLastUsedCase(name);
} else {
if (statusEl) statusEl.textContent = caseData.error || 'Failed to link docker case';
this.showToast(caseData.error || 'Failed to link docker case', 'error');
}
} catch (err) {
console.error('Failed to link docker case:', err);
if (statusEl) statusEl.textContent = err.message;
this.showToast('Failed to link docker case: ' + err.message, 'error');
}
},
// ═══════════════════════════════════════════════════════════════
// Docker export / import UI
// ═══════════════════════════════════════════════════════════════
async refreshDockerExports() {
const listEl = document.getElementById('dockerExportsList');
if (!listEl) return;
try {
const res = await fetch('/api/docker-exports');
const data = await res.json();
const exports = data?.data?.exports || [];
if (exports.length === 0) {
listEl.innerHTML = '<span class="form-hint">No exports yet. Export a docker case from its tab.</span>';
return;
}
listEl.innerHTML = exports
.map(e => {
const mb = (e.sizeBytes / 1e6).toFixed(1);
// escapeHtml is the free function from constants.js (never a method on `this`)
const nm = escapeHtml(e.name);
return `<div class="case-manage-item" style="display:flex; align-items:center; gap:8px; justify-content:space-between;">
<span style="overflow:hidden; text-overflow:ellipsis; white-space:nowrap;" title="${nm}">${nm} <span class="form-hint">(${mb} MB)</span></span>
<span style="flex-shrink:0;">
<a class="btn-toolbar" href="/api/docker-exports/${encodeURIComponent(e.name)}" download>Download</a>
<button class="btn-toolbar" onclick="app.importDockerBundle('${nm.replace(/'/g, "\\'")}')">Import</button>
<button class="btn-toolbar" onclick="app.deleteDockerExport('${nm.replace(/'/g, "\\'")}')">Delete</button>
</span>
</div>`;
})
.join('');
} catch (err) {
listEl.innerHTML = `<span class="form-hint">Failed to load exports: ${err.message}</span>`;
}
},
async exportDockerCaseBundle(caseName, mode = 'full') {
try {
const res = await fetch(`/api/docker-cases/${encodeURIComponent(caseName)}/export`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ mode }),
});
const data = await res.json();
if (data.success) {
this.showToast(`Exporting "${caseName}" (${mode})... you'll be notified when the bundle is ready`, 'info');
} else {
this.showToast(data.error || 'Export failed', 'error');
}
} catch (err) {
this.showToast('Export failed: ' + err.message, 'error');
}
},
async importDockerBundle(bundle) {
const newCaseName = prompt('New case name for the imported bundle:', bundle.split('-')[0] + '-imported');
if (!newCaseName) return;
const destWorkspacePath = prompt('Absolute host directory to restore the workspace into:', '');
if (!destWorkspacePath) return;
try {
const res = await fetch('/api/docker-cases/import', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ bundle, newCaseName, destWorkspacePath }),
});
const data = await res.json();
if (data.success) {
this.showToast(`Imported as "${newCaseName}"`, 'success');
await this.loadQuickStartCases(newCaseName);
} else {
this.showToast(data.error || 'Import failed', 'error');
}
} catch (err) {
this.showToast('Import failed: ' + err.message, 'error');
}
},
async deleteDockerExport(filename) {
if (!confirm(`Delete export bundle "${filename}"?`)) return;
try {
const res = await fetch(`/api/docker-exports/${encodeURIComponent(filename)}`, { method: 'DELETE' });
const data = await res.json();
if (data.success) {
this.showToast('Export deleted', 'success');
this.refreshDockerExports();
} else {
this.showToast(data.error || 'Delete failed', 'error');
}
} catch (err) {
this.showToast('Delete failed: ' + err.message, 'error');
}
},
// ═══════════════════════════════════════════════════════════════
// COD-105 — Discover + attach existing remote tmux sessions
// ═══════════════════════════════════════════════════════════════
/** Read the remote-host fields from the remote-case form into a host payload. */
_readRemoteHostFromForm() {
const hostId = document.getElementById('remoteHostId').value.trim();
const host = document.getElementById('remoteHostAddress').value.trim();
const username = document.getElementById('remoteHostUsername').value.trim();
const portRaw = document.getElementById('remoteHostPort').value.trim();
const identityFile = document.getElementById('remoteHostIdentityFile').value.trim();
const socksProxy = document.getElementById('remoteHostSocksProxy').value.trim();
const jumpHost = document.getElementById('remoteHostJumpHost').value.trim();
const codexCommand = document.getElementById('remoteHostCodexCommand').value.trim();
const extraSshOptions = document.getElementById('remoteHostExtraSshOptions').value
.split('\n')
.map(line => line.trim())
.filter(line => line.length > 0);
let port;
if (portRaw) {
const n = Number(portRaw);
if (Number.isInteger(n) && n >= 1 && n <= 65535) port = n;
}
return {
id: hostId,
label: hostId,
host,
username,
...(port ? { port } : {}),
...(identityFile ? { identityFile } : {}),
...(socksProxy ? { socksProxy } : {}),
...(jumpHost ? { jumpHost } : {}),
...(extraSshOptions.length ? { extraSshOptions } : {}),
...(codexCommand ? { commands: { codex: codexCommand } } : {}),
};
},
/**
* Explicit Discover action (Decision A — never auto-runs on host select).
* Saves the host config (idempotent), then queries the host for `codeman-*`
* tmux sessions it didn't create and renders an Attach action per session.
*/
async discoverRemoteSessions() {
const results = document.getElementById('remoteDiscoverResults');
const btn = document.getElementById('remoteDiscoverBtn');
const hostPayload = this._readRemoteHostFromForm();
if (!hostPayload.id || !hostPayload.host || !hostPayload.username) {
this.showToast('Fill in Host ID, address, and username first', 'error');
return;
}
if (!/^[a-zA-Z0-9_-]+$/.test(hostPayload.id)) {
this.showToast('Invalid Host ID. Use letters, numbers, hyphens, underscores.', 'error');
return;
}
if (btn) btn.disabled = true;
if (results) results.innerHTML = '<div class="form-hint">Discovering…</div>';
try {
// Persist the host so the discovery endpoint can resolve it by id (idempotent).
const hostRes = await fetch('/api/remote-hosts', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(hostPayload)
});
const hostData = await hostRes.json();
if (!hostData.success && hostData.errorCode !== 'ALREADY_EXISTS') {
throw new Error(hostData.error || 'Failed to save remote host');
}
const res = await fetch(`/api/remote-hosts/${encodeURIComponent(hostPayload.id)}/sessions`);
const data = await res.json();
if (!data.success) throw new Error(data.error || 'Discovery failed');
this._renderDiscoveredSessions(hostPayload.id, data.data.sessions || []);
} catch (err) {
console.error('Discover remote sessions failed:', err);
if (results) results.innerHTML = `<div class="form-hint" style="color: var(--error, #e06c75);">${escapeHtml(err.message)}</div>`;
} finally {
if (btn) btn.disabled = false;
}
},
/** Render the discovered remote sessions with an Attach action each. */
_renderDiscoveredSessions(hostId, sessions) {
const results = document.getElementById('remoteDiscoverResults');
if (!results) return;
if (!sessions.length) {
results.innerHTML = '<div class="form-hint">No <code>codeman-*</code> sessions running on this host (or it is unreachable).</div>';
return;
}
const now = Math.floor(Date.now() / 1000);
const rows = sessions.map(s => {
const ageSecs = Math.max(0, now - (s.created || 0));
const age = ageSecs < 3600 ? `${Math.floor(ageSecs / 60)}m` : ageSecs < 86400 ? `${Math.floor(ageSecs / 3600)}h` : `${Math.floor(ageSecs / 86400)}d`;
// COD-106 — show "shared · N clients" when more than one client is attached
// (genuinely collaborative), else a plain "attached" badge for a single client.
const clients = s.attachedClients != null ? s.attachedClients : s.attached ? 1 : 0;
const attachedBadge =
clients > 1
? `<span class="case-location-badge" style="background: var(--warning, #e5c07b); color: #000;">shared · ${clients} clients</span>`
: clients === 1
? '<span class="case-location-badge" style="background: var(--accent, #61afef);">attached</span>'
: '';
return `
<div class="remote-discover-item">
<div class="remote-discover-info">
<span class="remote-discover-name">${escapeHtml(s.name)} ${attachedBadge}</span>
<span class="form-hint">age ${age} · ${s.windows || 1} window(s)</span>
</div>
<button type="button" class="btn-toolbar" onclick="app.attachDiscoveredSession('${escapeHtml(hostId)}', '${escapeHtml(s.name)}')">Attach</button>
</div>`;
}).join('');
results.innerHTML = rows;
},
/**
* Create a NON-owned session that attaches to a discovered remote tmux session.
* Closing this tab detaches — it never kills the remote session.
*/
async attachDiscoveredSession(hostId, remoteSessionName) {
try {
const createRes = await fetch('/api/sessions', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
mode: 'shell',
name: remoteSessionName,
attachRemoteSession: { hostId, remoteSessionName },
})
});
const createData = await createRes.json();
if (!createData.success) throw new Error(createData.error || 'Failed to create session');
const id = createData.data.session.id;
await fetch(`/api/sessions/${id}/shell`, { method: 'POST' });
const dims = this.getTerminalDimensions();
if (dims) {
await fetch(`/api/sessions/${id}/resize`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(dims)
});
}
this.closeCreateCaseModal();
this.showToast(`Attached to ${remoteSessionName} (detach on close)`, 'success');
this.activeSessionId = id;
await this.selectSession(id);
if (this.terminal && typeof this.terminal.focus === 'function') this.terminal.focus();
} catch (err) {
console.error('Attach discovered session failed:', err);
this.showToast('Failed to attach: ' + err.message, 'error');
}
},
// ═══════════════════════════════════════════════════════════════
// Case Management (reorder + delete)
// ═══════════════════════════════════════════════════════════════
renderCaseManageList() {
const container = document.getElementById('caseManageList');
const cases = this.cases || [];
if (cases.length === 0) {
container.innerHTML = '<div class="form-hint" style="text-align: center; padding: 2rem 0;">No cases yet</div>';
return;
}
let html = '';
cases.forEach((c, idx) => {
const isFirst = idx === 0;
const isLast = idx === cases.length - 1;
const pathDisplay = c.path ? c.path.replace(/^\/Users\/[^/]+/, '~') : '';
html += `
<div class="case-manage-item" data-case="${escapeHtml(c.name)}">
<div class="case-manage-info">
<span class="case-manage-name">${escapeHtml(c.name)}</span>
<span class="case-manage-path">${escapeHtml(pathDisplay)}</span>
</div>
<div class="case-manage-actions">
${
c.location === 'docker'
? `<button class="case-manage-btn" onclick="app.exportDockerCaseBundle(${escapeHtml(JSON.stringify(c.name))}, 'full')"
title="Export container (full image + workspace) to move to another machine">&#x1F4E6;</button>`
: ''
}
<button class="case-manage-btn" onclick="app.moveCaseUp(${escapeHtml(JSON.stringify(c.name))})"
title="Move up" ${isFirst ? 'disabled' : ''}>&#x25B2;</button>
<button class="case-manage-btn" onclick="app.moveCaseDown(${escapeHtml(JSON.stringify(c.name))})"
title="Move down" ${isLast ? 'disabled' : ''}>&#x25BC;</button>
<button class="case-manage-btn case-manage-btn-delete" onclick="app.deleteCase(${escapeHtml(JSON.stringify(c.name))})"
title="Delete case">&#x2715;</button>
</div>
</div>
`;
});
container.innerHTML = html;
},
async moveCaseUp(name) {
const cases = this.cases || [];
const idx = cases.findIndex(c => c.name === name);
if (idx <= 0) return;
// Swap positions (immutable)
const reordered = [...cases];
[reordered[idx - 1], reordered[idx]] = [reordered[idx], reordered[idx - 1]];
this.cases = reordered;
this.renderCaseManageList();
await this.saveCaseOrder(reordered.map(c => c.name));
},
async moveCaseDown(name) {
const cases = this.cases || [];
const idx = cases.findIndex(c => c.name === name);
if (idx < 0 || idx >= cases.length - 1) return;
const reordered = [...cases];
[reordered[idx], reordered[idx + 1]] = [reordered[idx + 1], reordered[idx]];
this.cases = reordered;
this.renderCaseManageList();
await this.saveCaseOrder(reordered.map(c => c.name));
},
async deleteCase(name) {
if (!confirm(`Delete case "${name}"? Linked cases will only be unlinked (folder preserved). Created cases will be permanently deleted.`)) {
return;
}
try {
const res = await fetch(`/api/cases/${encodeURIComponent(name)}`, { method: 'DELETE' });
const data = await res.json();
if (data.success) {
this.showToast(`Case "${name}" ${data.data?.type === 'unlinked' ? 'unlinked' : 'deleted'}`, 'success');
// Remove from current list and refresh
this.cases = (this.cases || []).filter(c => c.name !== name);
this.renderCaseManageList();
// Refresh the dropdown
const select = document.getElementById('quickStartCase');
const currentCase = select.value;
if (currentCase === name) {
// Blur the native picker before reload so it doesn't show the stale value
select.blur?.();
}
await this.loadQuickStartCases(currentCase === name ? null : currentCase);
if (currentCase === name) {
await this.saveLastUsedCase(document.getElementById('quickStartCase')?.value || 'testcase');
}
} else {
this.showToast(data.error || 'Failed to delete case', 'error');
}
} catch (err) {
this.showToast('Failed to delete case: ' + err.message, 'error');
}
},
async saveCaseOrder(order) {
try {
await fetch('/api/cases/order', {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ order })
});
// Refresh dropdown to reflect new order
const select = document.getElementById('quickStartCase');
const currentCase = select.value;
await this.loadQuickStartCases(currentCase);
} catch (err) {
this.showToast('Failed to save case order: ' + err.message, 'error');
}
},
// ═══════════════════════════════════════════════════════════════
// Mobile Case Picker
// ═══════════════════════════════════════════════════════════════
showMobileCasePicker() {
const modal = document.getElementById('mobileCasePickerModal');
const listContainer = document.getElementById('mobileCaseList');
const select = document.getElementById('quickStartCase');
const currentCase = select.value;
// Build case list HTML
let html = '';
const allCases = this.getCasePickerOptions();
for (const c of allCases) {
const isSelected = c.name === currentCase;
html += `
<button class="mobile-case-item ${isSelected ? 'selected' : ''}"
onclick="app.selectMobileCase(${escapeHtml(JSON.stringify(c.name))})">
<span class="mobile-case-item-icon">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z"/>
</svg>
</span>
<span class="mobile-case-item-name">${escapeHtml(c.label)}</span>
<span class="mobile-case-item-delete" onclick="event.stopPropagation(); app.deleteCaseMobile(${escapeHtml(JSON.stringify(c.name))})" title="Delete">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/>
</svg>
</span>
<span class="mobile-case-item-check">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5">
<polyline points="20 6 9 17 4 12"/>
</svg>
</span>
</button>
`;
}
listContainer.innerHTML = html;
modal.classList.add('active');
},
closeMobileCasePicker() {
document.getElementById('mobileCasePickerModal').classList.remove('active');
},
selectMobileCase(caseName) {
// Update the desktop select (source of truth)
const select = document.getElementById('quickStartCase');
select.value = caseName;
// Update mobile button label
this.updateMobileCaseLabel(caseName);
// Update directory display
this.updateDirDisplayForCase(caseName);
// Save as last used
this.saveLastUsedCase(caseName);
// Close the picker
this.closeMobileCasePicker();
this.showToast(`Selected: ${caseName}`, 'success');
},
updateMobileCaseLabel(caseName) {
const label = document.getElementById('mobileCaseName');
if (label) {
// Let CSS handle truncation via text-overflow: ellipsis
label.textContent = caseName;
}
},
async deleteCaseMobile(name) {
if (!confirm(`Delete case "${name}"?`)) return;
try {
const res = await fetch(`/api/cases/${encodeURIComponent(name)}`, { method: 'DELETE' });
const data = await res.json();
if (data.success) {
this.showToast(`Case "${name}" ${data.data?.type === 'unlinked' ? 'unlinked' : 'deleted'}`, 'success');
this.cases = (this.cases || []).filter(c => c.name !== name);
// Refresh mobile picker and dropdown
this.closeMobileCasePicker();
await this.loadQuickStartCases();
} else {
this.showToast(data.error || 'Failed to delete case', 'error');
}
} catch (err) {
this.showToast('Failed to delete case: ' + err.message, 'error');
}
},
showCreateCaseFromMobile() {
// Close mobile picker first
this.closeMobileCasePicker();
// Open the create case modal with slide-up animation
this.showCreateCaseModal();
const modal = document.getElementById('createCaseModal');
modal.classList.add('from-mobile');
// Remove animation class after it plays
setTimeout(() => modal.classList.remove('from-mobile'), 300);
},
});
Object.defineProperty(CodemanApp.prototype, 'runMode', {
configurable: true,
enumerable: true,
get() {
return this._runMode || 'claude';
},
set(mode) {
this._runMode =
mode === 'opencode' || mode === 'codex' || mode === 'gemini' || mode === 'antigravity' || mode === 'claude'
? mode
: 'claude';
},
});