/** * @fileoverview Quick start (case loading, session spawning for Claude/Shell/OpenCode/Codex/Gemini/Antigravity/Pi/Grok/DeepSeek), * 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 */ /** * PR B2: the single source for every non-Claude, non-Shell run mode's launch * shape, consumed by `_runCliMode()` below. Before this table existed, each of * `runOpenCode`/`runCodex`/`runGemini`/`runAntigravity`/`runPi`/`runOmp`/ * `runGrok`/`runDeepSeek`/`runCopilot` was a ~45-line copy of the same probe/launch/select * skeleton with only the CLI-specific pieces below actually differing — eight * near-identical bodies guaranteed to drift, exactly what the CLI registry's * own no-id-branching rule exists to prevent server-side. * * Deliberately a LOCAL table rather than a server-injected catalogue: several * unit tests exercise these run*() methods inside a bare `vm.createContext()` * sandbox with no `window` global at all (see test/run-mode-ui.test.ts) — * referencing `window` there unguarded would throw, not degrade. `buildConfig` * returns the CLI's top-level legacy config field for a LOCAL launch, or * `null` for a CLI that sends none (pi: no bypass flag exists, so there is * nothing to send — see runPi's own history below for why that must stay * true). */ const RUN_MODE_LAUNCH = { opencode: { label: 'OpenCode', installHint: 'OpenCode CLI not found. Install with: curl -fsSL https://opencode.ai/install | bash', supportsCustomModel: true, buildConfig: () => ({ openCodeConfig: { autoAllowTools: true } }), }, codex: { label: 'Codex', installHint: 'Codex CLI not found. Install with: npm install -g @openai/codex', supportsCustomModel: true, buildConfig: (globalSettings) => ({ codexConfig: { dangerouslyBypassApprovals: globalSettings.codexDangerouslyBypassApprovals ?? false, animations: globalSettings.codexAnimationsEnabled ?? false, renderMode: 'hybrid', }, }), }, gemini: { label: 'Gemini', installHint: 'Gemini CLI not found. Install with: npm install -g @google/gemini-cli', supportsCustomModel: true, buildConfig: () => ({ geminiConfig: { approvalMode: 'yolo' } }), }, antigravity: { label: 'Antigravity', installHint: 'Antigravity CLI not found. Install with: curl -fsSL https://antigravity.google/cli/install.sh | bash', // antigravity has no customModelInjection recipe (docs/custom-model-endpoints-plan.md // calls it `unsupported`) — never fold a pending pick into its launch body. supportsCustomModel: false, buildConfig: () => ({ antigravityConfig: { dangerouslySkipPermissions: true } }), }, pi: { label: 'Pi', installHint: 'Pi CLI not found. Install with: npm install -g --ignore-scripts @earendil-works/pi-coding-agent', supportsCustomModel: true, // Deliberately NO piConfig: pi has no permission prompts, so there is no // bypass to opt into, and project trust is pi's own `defaultProjectTrust` // decision (an interactive prompt the user answers in the terminal). // Sending `approveProjectTrust: true` here would silently opt every // browser-launched pi session into executing repo-supplied TypeScript. buildConfig: () => null, }, omp: { label: 'OMP', installHint: 'OMP CLI not found. Install with: curl -fsSL https://omp.sh/install | sh', supportsCustomModel: true, buildConfig: () => null, }, copilot: { label: 'GitHub Copilot', installHint: 'GitHub Copilot CLI not found. Install with: npm install -g @github/copilot', // Custom endpoints go in through Copilot's BYOK env vars (COPILOT_PROVIDER_*). supportsCustomModel: true, // Sends `copilotConfig: { allowAll: true }` the way grok sends `alwaysApprove: true`: // Codeman sessions exist for autonomous work, so the Run button opts into `--yolo`. // The multi-user clamp forces it back off for non-granted owners server-side. buildConfig: () => ({ copilotConfig: { allowAll: true } }), }, grok: { label: 'Grok', installHint: 'Grok CLI not found. Install with: curl -fsSL https://x.ai/cli/install.sh | bash', supportsCustomModel: true, // Sends `grokConfig: { alwaysApprove: true }` the way antigravity sends // `dangerouslySkipPermissions: true`: Codeman sessions exist for autonomous // work, so the Run button opts into grok's bypassPermissions mode // (`--always-approve`; config-level deny rules still apply on top). The // multi-user clamp forces it back off for non-granted owners server-side. buildConfig: () => ({ grokConfig: { alwaysApprove: true } }), }, deepseek: { label: 'DeepSeek', installHint: 'DeepSeek Harness CLI (dsh) not found. Install with: npm install -g @deepseek-ai/dsh && (command -v pnpm >/dev/null || npm install -g pnpm)', // The two-part availability check is deliberate. `dsh` being installed is // not enough — DeepSeek ships no terminal front door, so a box can have a // perfect binary and nothing a pane can run. unrunnableHint: 'No interactive DeepSeek Harness profile is installed. DeepSeek ships only web and headless ' + 'profiles, so the terminal agent comes from a plugin. Install one from the Run menu, or run: ' + 'dsh plugin --profile dsh-tui add @deepseek-harness-tui/dsh-tui', supportsCustomModel: true, // Sends `permissionMode: 'danger-full-access'` for the same reason every // sibling Run button sends its bypass switch. The harness has no bypass // FLAG, so this rides the `DSH_PERMISSION_MODE` export instead, and the // multi-user clamp forces it back down to `workspace-write` server-side. // // `statusReporting` is deliberately LEFT UNSET, i.e. ON: it is what upgrades // this mode from output-stabilization guessing to definitive idle/blocked // hook events (the harness reports to Codeman as its supervisor, see // deepseek-status-shim.ts). Never send `statusReporting: false` from here. buildConfig: () => ({ deepSeekConfig: { permissionMode: 'danger-full-access' } }), }, }; /** * External (non-Claude, non-Shell) CLI run modes — the keys of RUN_MODE_LAUNCH * above, kept as its own Set (`EXTERNAL_CLI_MODES.has(mode)`) rather than an * array recomputed per call. Single source for what used to be two hand-copied * 8-way `session.mode === '' || ...` chains inside one function * (`openSessionOptions`), guaranteed to drift from each other the moment a * ninth CLI landed in one and not the other. */ /** How often the OPEN case picker re-reads /api/cases (it also refreshes once on open). */ const CASE_PICKER_REFRESH_MS = 5000; /** * Action rows at the bottom of the toolbar case picker. They replaced the "+" * and gear buttons that sat beside the picker (owner, 1.36.0 beta): the same two * actions, one click away, without two extra controls in the toolbar, and the git * worktrees of the selected case joined them. The arrow keys reach them after the * last case; Enter or a click runs `run`. */ const CASE_PICKER_ACTIONS = [ { id: 'add', icon: '+', label: 'New or link a case…', run: (app) => app.showCreateCaseModal() }, { id: 'settings', icon: '\u2699', label: 'Case settings…', run: (app) => app.toggleCaseSettings() }, // The selected case's git worktrees (openWorktreeModal below): a row here rather than a // toolbar button, for the same reason the two above replaced theirs. { id: 'worktrees', icon: '⑂', label: 'Git worktrees…', run: (app) => app.openWorktreeModal() }, ]; /** A worktree name the server accepts (src/git-worktrees.ts `worktreeNameError`), checked before the POST. */ const WORKTREE_NAME_RE = /^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$/; /** * The Codeman-managed worktree a working directory IS: `{ caseName, name, path }` when it is * exactly `/.claude/worktrees/` of a local case in `cases`, else null. Lexical * on purpose (the browser cannot resolve links): the server re-checks every removal against the * case's own repository, so a stale or wrong match can only be refused, never act elsewhere. */ function managedWorktreeOfDir(workingDir, cases) { if (typeof workingDir !== 'string') return null; const m = /^(.+)\/\.claude\/worktrees\/([^/]+)\/?$/.exec(workingDir); if (!m || !WORKTREE_NAME_RE.test(m[2]) || m[2].includes('..')) return null; const owner = (cases || []).find( (c) => c && c.path === m[1] && c.location !== 'remote' && c.location !== 'docker' ); return owner ? { caseName: owner.name, name: m[2], path: workingDir.replace(/\/$/, '') } : null; } const EXTERNAL_CLI_MODES = new Set(Object.keys(RUN_MODE_LAUNCH)); const BUILT_IN_RUN_MODES = new Set(['claude', 'shell', ...Object.keys(RUN_MODE_LAUNCH)]); function registryCliCatalog() { return typeof window !== 'undefined' && Array.isArray(window.__codemanCliCatalog) ? window.__codemanCliCatalog : []; } function registryCliById(id) { return registryCliCatalog().find((entry) => entry.id === id); } function isExternalCliRunMode(mode) { return EXTERNAL_CLI_MODES.has(mode) || registryCliById(mode)?.kind === 'agent'; } // Does this session lack the Claude-only features (Respawn, Ralph)? The registry's // `capabilities.external`, the flag the server's isExternalCliMode() reads. Not // isExternalCliRunMode(): that picks a launch path, and claude is `kind: 'agent'` too. function isExternalCliSession(mode) { return registryCliById(mode)?.external ?? isExternalCliRunMode(mode); } 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; }, /** * Resolve the advisor model for new Claude sessions from global settings. * Returns 'fable' | 'opus' | 'sonnet', or undefined (= leave it to the CLI's own * /advisor choice). Sent as the `advisorModel` payload field; the backend merges it * into the launch's `claude --settings` JSON, so /advisor still switches it in-session. */ getAdvisorSetting(globalSettings) { const advisor = globalSettings?.claudeAdvisorModel; return ['fable', 'opus', 'sonnet'].includes(advisor) ? advisor : undefined; }, // ═══════════════════════════════════════════════════════════════ // Quick Start // ═══════════════════════════════════════════════════════════════ 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-' 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 => ``) .join(''); }, openCasePicker(filter = '') { const input = document.getElementById('quickStartCaseSearch'); const list = document.getElementById('quickStartCaseList'); if (!input || !list) return; const wasOpen = this._casePickerOpen === true; this._casePickerOpen = true; this._casePickerFilter = filter; this._casePickerActiveIndex = 0; input.setAttribute('aria-expanded', 'true'); this.renderCasePickerList(); // Every keystroke re-enters here, so only the closed -> open transition // refreshes and arms the timer; typing must not fire a fetch per key. if (!wasOpen) this._startCasePickerRefresh(); }, /** Re-read the case list while the picker is open, so folders deleted or created on disk show up without a page reload. */ _startCasePickerRefresh() { void this.refreshCasePickerCases(); if (this._casePickerRefreshTimer) return; this._casePickerRefreshTimer = setInterval(() => void this.refreshCasePickerCases(), CASE_PICKER_REFRESH_MS); }, _stopCasePickerRefresh() { if (this._casePickerRefreshTimer) clearInterval(this._casePickerRefreshTimer); this._casePickerRefreshTimer = null; }, /** * Lighter than loadQuickStartCases(): that one closes the picker and re-picks a * selection, which would yank the list away from someone mid-browse. This only * swaps the data and repaints, and does nothing when the list is unchanged. */ async refreshCasePickerCases() { if (this._casePickerRefreshInFlight) return; this._casePickerRefreshInFlight = true; try { const res = await fetch('/api/cases'); if (!res.ok) return; const cases = (await res.json()).data; if (!Array.isArray(cases) || !this._casePickerOpen) return; const signature = list => JSON.stringify((list || []).map(c => [c.name, c.path, c.location])); if (signature(cases) === signature(this.cases)) return; this.cases = cases; const select = document.getElementById('quickStartCase'); if (select) { const previous = select.value; this.renderQuickStartCaseSelectOptions(select, this.getCasePickerOptions()); if (cases.some(c => c.name === previous)) { select.value = previous; } else if (cases.length > 0) { // The selected case was removed on disk: fall back the way the initial // load does, without saving it as the user's last-used case. const fallback = cases.find(c => c.name === 'testcase') || cases[0]; select.value = fallback.name; this.updateDirDisplayForCase(fallback.name); this.updateMobileCaseLabel(fallback.name); this.updateCasePickerInput(fallback.name); } } this.renderCasePickerList(); } catch { // A failed poll leaves the list as it was; the next tick retries. } finally { this._casePickerRefreshInFlight = false; } }, closeCasePicker() { const input = document.getElementById('quickStartCaseSearch'); const list = document.getElementById('quickStartCaseList'); this._stopCasePickerRefresh(); 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'; // The arrow keys walk the cases, then the action rows under them. const maxIndex = options.length + CASE_PICKER_ACTIONS.length - 1; this._casePickerActiveIndex = Math.min(Math.max(this._casePickerActiveIndex || 0, 0), maxIndex); const actionRows = CASE_PICKER_ACTIONS.map((action, i) => { const index = options.length + i; const active = index === this._casePickerActiveIndex; return ` `; }).join(''); const actionsBlock = ``; if (options.length === 0) { list.innerHTML = '
No cases match
' + actionsBlock; list.classList.remove('hidden'); input.setAttribute('aria-activedescendant', `quickStartCaseOption-${this._casePickerActiveIndex}`); return; } list.innerHTML = options .map((option, index) => { const active = index === this._casePickerActiveIndex; const selected = option.name === selectedName; const id = `quickStartCaseOption-${index}`; return ` `; }) .join('') + actionsBlock; list.classList.remove('hidden'); input.setAttribute('aria-activedescendant', `quickStartCaseOption-${this._casePickerActiveIndex}`); }, /** Run a case picker action row (`CASE_PICKER_ACTIONS`): close the list first, then act. */ runCasePickerAction(id) { const action = CASE_PICKER_ACTIONS.find((a) => a.id === id); if (!action) return; const select = document.getElementById('quickStartCase'); if (select) this.updateCasePickerInput(select.value); this.closeCasePicker(); document.getElementById('quickStartCaseSearch')?.blur?.(); action.run(this); }, 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); // Warm the container's CLI list HERE rather than when the run menu opens. // The probe is a `docker exec` round trip, so gating it on the menu meant the // menu painted every mode first and only narrowed a moment later — which // reads as "it shows all of them" and lets a mode be picked that the // container does not have. const picked = (this.cases || []).find((c) => c.name === select.value); if (picked?.location === 'docker') void this._probeDockerCaseModes(picked, null); 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, options.length + CASE_PICKER_ACTIONS.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 index = this._casePickerActiveIndex || 0; const option = options[index]; const action = this._casePickerOpen ? CASE_PICKER_ACTIONS[index - options.length] : undefined; if (option) { event.preventDefault(); this.selectQuickStartCase(option.name); this.run?.(); } else if (action) { event.preventDefault(); this.runCasePickerAction(action.id); } } 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 action = event.target.closest?.('.case-combobox-action'); if (action?.dataset?.caseAction) { this.runCasePickerAction(action.dataset.caseAction); return; } 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) { // Multi-user non-admin: lastUsedCase is a key of the server-wide settings.json, whose // PUT is admin-only (403), so it is not sent (_settingsReadOnly, settings-ui.js). if (this._settingsReadOnly?.()) return; 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. * `tabLayout` is the layout a grouped creation returns (see run()'s * `tabGroupId`): adopting it first draws the new tab inside its group. */ async _ensureCreatedSessionVisible(sessionId, sessionSnapshot, tabLayout) { if (!sessionId) return; if (tabLayout) this._applyTabLayout?.(tabLayout); 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); // A session this tab's Run created joins an open tile grid (tile-grid.js), // so Run's selectSession() below focuses its tile instead of leaving the // grid. Only here: sessions created elsewhere arrive by session:created. this._joinTileGridFromRun?.(sessionId); // 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). * `options` comes from the tab group menu's "New session", `_launchOnce` (a * worktree, a tab's "+"): `count` overrides the instance steppers for this * launch only, `tabGroupId` rides every create request so the server places the * session in that group, `caseName` launches in that case instead of the * toolbar's selected one, and `mode` starts that CLI instead of the Run * button's mode, which is never touched. */ async run(options = {}) { 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 = options.mode || this._runMode || 'claude'; if (mode === 'shell') { return await this.runShell(options); } if (mode === 'claude' || !isExternalCliRunMode(mode)) { return await this.runClaude(options); } return await this._runCliMode(mode, options); } 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. Not for a non-admin in multi-user mode, // where settings.json is server-wide and its PUT admin-only: the mode is kept as their own // on this device and wins over the server's on the next load (_rememberOwnSetting, // settings-ui.js). A PUT refused that way (sent before the role was known) marks the page // read-only for the writes after it (_noteSettingsWriteRefusal); any other failure is toasted. if (this._settingsReadOnly?.()) { this._rememberOwnSetting?.('runMode', mode); } else { this._apiPut('/api/settings', { runMode: mode }) .then((res) => this._noteSettingsWriteResult?.(res, 'Run mode')) .catch(() => {}); } // Close menu document.getElementById('runModeMenu')?.classList.remove('active'); }, toggleRunModeMenu(e) { e?.stopPropagation(); const menu = document.getElementById('runModeMenu'); if (!menu) return; this.renderRegistryRunOptions(); 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); this._refreshCustomModelRunOptions(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 and Pi are in this list even though #201 predates them — they are * run modes like the rest, and neither `agy` nor `pi` is likely to be installed. */ _refreshRunModeAvailability(menu) { // Same source every run* path reads the selected case from. const caseName = document.getElementById('quickStartCase')?.value; const activeCase = caseName ? (this.cases || []).find((c) => c.name === caseName) : null; const isDocker = activeCase?.location === 'docker'; if (isDocker && !this._dockerCaseModes?.[caseName]) void this._probeDockerCaseModes(activeCase, menu); // An unreachable (adopted) container hides every agent mode and explains why, // instead of silently offering modes that cannot start; see _runModeAvailableForCase. const probeError = isDocker ? this._dockerCaseProbeError?.[caseName] : null; for (const option of menu.querySelectorAll('.run-mode-option[data-mode]')) { const mode = option.dataset.mode; if (!mode || mode === 'shell') continue; option.style.display = this._runModeAvailableForCase(mode, caseName) ? 'flex' : 'none'; } this._renderRunModeNotice(menu, probeError); // DeepSeek is the one mode whose availability has two halves: `dsh` can be // perfectly installed while no pane-capable profile exists, because DeepSeek // ships no terminal front door. In that state the honest offer is "add one", // not a hidden entry with no explanation anywhere. const avail = window.__codemanCliAvailable || {}; const dsInstall = menu.querySelector('#runModeDeepSeekInstall'); if (dsInstall) { dsInstall.style.display = !avail.deepseek && avail.deepseekBinary ? 'flex' : 'none'; } // The web UI needs only the BINARY: it is the one interactive surface // DeepSeek ships itself, so it works on a box with no terminal profile at // all (and is the honest thing to offer there). const dsWeb = menu.querySelector('#runModeDeepSeekWeb'); if (dsWeb) dsWeb.style.display = avail.deepseekBinary ? 'flex' : 'none'; }, /** * Does the case named `caseName` offer agent run mode `mode`? The ONE rule * behind the Run menu's entries (_refreshRunModeAvailability) and the Cmd+K * new-session row's agents (_commandPaletteAgentChoices, panels-ui.js), so the * two never disagree on what a case can start. Shell needs no CLI and is * never gated; both callers skip it before asking. * * Reads only what is already known and never starts a container probe: the * Run menu and a tab's "+" (openTabNewSessionMenu, app.js) start one * (_probeDockerCaseModes), and its answer lands in * _dockerCaseModes, which this reads. Until then (a probe still in flight) * the stored availableModes decide, else nothing is gated. */ _runModeAvailableForCase(mode, caseName) { const activeCase = caseName ? (this.cases || []).find((c) => c.name === caseName) : null; if (activeCase?.location === 'docker') { // A DOCKER case runs its agents INSIDE the container, so host CLI // availability answers the wrong question: the host may have no claude at // all while the container ships one, and gating on the host hides a mode // that would have worked. Adoption records what the container really has // (`availableModes`); an owned container runs our base image, which ships // every CLI, so an absent list means "do not gate" rather than "nothing". // // An unreachable container offers no agent mode. ⚠️ Only an ADOPTED case // ever records that error: for an OWNED case a missing container is the // normal state before the first session (the launch chain creates and // starts it), so reporting it as a fault hid every agent mode on a freshly // linked Docker case behind "start it yourself first", for a container // Codeman was about to create. if (this._dockerCaseProbeError?.[caseName]) return false; // Prefer a LIVE probe over the value stored at attach time: a container's // CLIs can be installed or removed long after the case was linked, and a // case linked before that field existed has none at all. const containerModes = this._dockerCaseModes?.[caseName] || activeCase.docker?.availableModes || null; return containerModes ? containerModes.includes(mode) : true; } return this.isCliAvailable(mode); }, /** Render every enabled agent entry from the server's registry projection. */ renderRegistryRunOptions() { const container = document.getElementById('runModeCliOptions'); if (!container) return; const catalog = registryCliCatalog(); if (catalog.length === 0) return; // cached pages from before the catalog keep their static fallback. container.replaceChildren(); for (const cli of catalog) { if (cli.kind !== 'agent' || !cli.enabled) continue; const option = document.createElement('button'); option.type = 'button'; option.className = 'run-mode-option'; option.dataset.mode = cli.id; option.onclick = () => this.setRunMode(cli.id); const dot = document.createElement('span'); dot.className = `run-mode-dot ${cli.id}`; dot.setAttribute('aria-hidden', 'true'); option.appendChild(dot); option.append(cli.label); container.appendChild(option); } }, /** * Generates the Run menu's Custom Model Endpoint entries * (docs/custom-model-endpoints-plan.md): one button per (capable harness, saved * endpoint) pair, e.g. "Claude Code (llama.cpp)". Hidden entirely when the * feature is off, no endpoint has a usable default model, or the active case is * remote/docker (the apply route refuses both — see session-routes.ts). * * `window.__codemanCustomModelClis` is server-injected at render time from the * CLI registry's own `capabilities.customModelInjection` (never a hardcoded id * list here), so a CLI gaining or losing the capability shows up with no * frontend change. */ async _refreshCustomModelRunOptions(menu) { const sep = menu.querySelector('#runModeCustomModelSep'); const header = menu.querySelector('#runModeCustomModelHeader'); const container = menu.querySelector('#runModeCustomModels'); if (!container) return; const hide = () => { if (sep) sep.style.display = 'none'; if (header) header.style.display = 'none'; container.innerHTML = ''; }; const settings = this.loadAppSettingsFromStorage(); // Matches _refreshRunModeAvailability's own gate: a stock entry for an // uninstalled CLI is hidden, so a generated one must be too, or a box with // no codex still offers "Codex (llama.cpp)" and fails at launch. const capableClis = (window.__codemanCustomModelClis || []).filter((cli) => this.isCliAvailable(cli.id)); if (!settings.customModelEndpointsEnabled || capableClis.length === 0) return hide(); const caseName = document.getElementById('quickStartCase')?.value; const activeCase = caseName ? (this.cases || []).find((c) => c.name === caseName) : null; if (activeCase?.location === 'remote' || activeCase?.location === 'docker') return hide(); // GET /api/model-endpoints wraps its body in the { success, data } envelope // like every other /api route (server.ts's preSerialization hook applies to // arrays too) — _apiJson() unwraps it. A raw fetch().json() here would // silently see the envelope object instead of the array and hide this // section unconditionally. const hosts = await this._apiJson('/api/model-endpoints'); if (!Array.isArray(hosts) || hosts.length === 0) return hide(); const rows = []; for (const host of hosts) { const models = host.models || []; if (models.length === 0) continue; // nothing discovered yet — the settings panel explains why const modelId = host.defaultModelId || models[0]; for (const cli of capableClis) { // escapeHtml(JSON.stringify(...)) on EVERY arg, not just the untrusted // one: JSON.stringify's own double quotes would otherwise terminate this // double-quoted attribute at the first one, and everything after parses // as raw tag content rather than a quoted string — which is what turns // modelId (server-controlled, from the endpoint's own /v1/models reply, // not this box's) into markup instead of inert data. Same idiom as // deleteCase's onclick a few hundred lines down. const args = [cli.id, host.id].map((v) => escapeHtml(JSON.stringify(v))).join(', '); rows.push(` `); } } if (rows.length === 0) return hide(); if (sep) sep.style.display = ''; if (header) header.style.display = ''; container.innerHTML = rows.join(''); }, /** * Decides whether picking a Run-menu Custom Endpoint entry can launch * straight away or needs to ask which model first. Re-fetches the endpoint * rather than trusting anything cached from the menu render: the models * list (or the default) could have changed — a re-discovery cycle running * every 5 minutes in the background, or an edit in the settings panel — * between opening the dropdown and clicking a row. */ async selectCustomModelEntry(mode, endpointId) { document.getElementById('runModeMenu')?.classList.remove('active'); const hosts = await this._apiJson('/api/model-endpoints'); const host = (hosts || []).find((h) => h.id === endpointId); if (!host) { this.showToast('That endpoint no longer exists', 'error'); return; } const models = host.models || []; if (models.length === 0) { this.showToast('No models discovered for this endpoint yet', 'warning'); return; } // Exactly one model: nothing to choose, so asking would just be an extra // click for the same answer every time. Two or more: always ask, even // with a defaultModelId set — the point of asking is letting THIS launch // differ from the default, not just confirming it. if (models.length === 1) { return this.runCustomModelEntry(mode, endpointId, models[0]); } await this._openCustomModelPickModal(mode, host); }, /** localStorage key for the last model launched on a given (harness, endpoint) pair — per-device by design, like every other `codeman:*` UI preference, never synced. */ _customModelLastUsedKey(mode, endpointId) { return `codeman:customModelLastUsed:${mode}:${endpointId}`; }, /** Reads the last model chosen for this (harness, endpoint) pair, or null. Never throws — a blocked/full localStorage just means no promotion, not a broken picker. */ _getCustomModelLastUsed(mode, endpointId) { try { return localStorage.getItem(this._customModelLastUsedKey(mode, endpointId)); } catch { return null; } }, /** Remembers `modelId` as the last one launched for this (harness, endpoint) pair. */ _setCustomModelLastUsed(mode, endpointId, modelId) { try { localStorage.setItem(this._customModelLastUsedKey(mode, endpointId), modelId); } catch { // best-effort — losing the "last used" hint is cosmetic, never worth surfacing } }, /** * Best-effort lookup of the model llama-swap currently has loaded and ready on this * endpoint, so the picker can offer it first instead of making the user remember what * they picked last time it mattered. Mirrors `_watchLlamaSwapLoading`'s own * `state === 'ready'` check. Returns null for a plain (non-llama-swap) server, an * unreachable endpoint, or a loaded model this host no longer lists as discovered — * never throws, since a failed probe should just skip promotion, not break the picker. * * ⚠️ Client-side bounded to ~800ms via Promise.race, on top of (never instead of) the * route's own 5s server-side timeout (`RUNNING_TIMEOUT_MS`, custom-model-routes.ts) — * a saved endpoint keeps its discovered models cached, so "the box behind this endpoint * is asleep or firewalled" is a normal way to reach this path, not an exotic one, and * the modal must not sit invisible (Run menu already closed, nothing else on screen) * for the full 5s a slow/dead endpoint can take. The losing side of the race is left to * resolve on its own — `.catch(() => null)` only stops an unhandled-rejection warning * when it eventually fails, it never cancels the in-flight fetch. * * `timeoutMs` exists to let a test drive this in milliseconds instead of the real * 800 — same reasoning as `_watchLlamaSwapLoading`'s own `pollIntervalMs`: this code * runs inside a JSDOM window's own realm, whose `setTimeout` is not the one * `vi.useFakeTimers()` patches, so a param is the only way to test the timeout without * actually waiting on it. Real callers never pass it. */ async _getCustomModelCurrentlyLoaded(host, timeoutMs = 800) { const probe = this._apiJson(`/api/model-endpoints/${encodeURIComponent(host.id)}/running-status`).catch( () => null ); const timeout = new Promise((resolve) => setTimeout(() => resolve(null), timeoutMs)); const status = await Promise.race([probe, timeout]); if (!status?.isLlamaSwap) return null; const ready = (status.running || []).find((r) => r.state === 'ready' && (host.models || []).includes(r.model)); return ready?.model || null; }, /** * Renders the "which model" picker for a (harness, endpoint) pair with more than one * discovered model. Async since it now awaits the currently-loaded-model probe below, * so a SECOND call (a different custom-model entry clicked while the first one's probe * is still in flight — the probe has its own 5s timeout) must not let the first call's * later-arriving response clobber the second's already-rendered, already-correct modal. * `_customModelPickGeneration` is the same guard-a-mutable-counter pattern * `_watchLlamaSwapLoading` uses for the same reason: every DOM write below, including * `_pendingCustomModelPick` itself, stays deferred until after the await, and a call * that finds a newer generation already claimed bails out untouched rather than only * skipping the model-list write and leaving title/hint/`_pendingCustomModelPick` * inconsistent with what's on screen. */ async _openCustomModelPickModal(mode, host) { const modal = document.getElementById('customModelPickModal'); const list = document.getElementById('customModelPickList'); if (!modal || !list) return; const generation = (this._customModelPickGeneration = (this._customModelPickGeneration || 0) + 1); const isCurrent = () => this._customModelPickGeneration === generation; // Whichever model llama-swap actually has loaded right now beats a merely // remembered choice — it's what a launch would attach to with zero wait, while // "last used" might have been swapped out by another session since. Neither // reorders past the top: exactly one model is promoted, everything else keeps // its discovery order. const currentlyLoaded = await this._getCustomModelCurrentlyLoaded(host); if (!isCurrent()) return; // a newer pick opened (and possibly already rendered) while this probe was in flight const lastUsed = currentlyLoaded ? null : this._getCustomModelLastUsed(mode, host.id); const promoted = currentlyLoaded || lastUsed; const models = [...(host.models || [])]; if (promoted && models.includes(promoted)) { models.splice(models.indexOf(promoted), 1); models.unshift(promoted); } this._pendingCustomModelPick = { mode, endpointId: host.id }; const cliLabel = (window.__codemanCustomModelClis || []).find((c) => c.id === mode)?.label || mode; // A static title (translatable by i18n.js's exact-string walker) plus a // dynamic hint carrying the specifics — same split webviewModalTitle uses, // since the walker cannot i18n a string a variable is already spliced into. document.getElementById('customModelPickTitle').textContent = 'Choose a model'; document.getElementById('customModelPickHint').textContent = `${cliLabel} → ${host.label} — ${(host.models || []).length} models discovered.`; list.innerHTML = models .map((m) => { // Two independent tags, never one exclusive slot: the promotion tag says what // llama-swap (or this device's history) knows about the model, the Default pill // says what the saved endpoint says about it, and on a single-purpose GPU box the // promoted model IS the default more often than not. One slot holding whichever // applied first silently dropped the Default marking for exactly that row. const promotion = m === currentlyLoaded ? 'Currently loaded' : m === lastUsed ? 'Last used' : null; const tags = [promotion, m === host.defaultModelId ? 'Default' : null].filter(Boolean); const arg = escapeHtml(JSON.stringify(m)); // data-model-id is the filter's haystack (escapeHtml covers `"` as well as // `<>&`, so a hostile model id cannot break out of the attribute); the // filter reads it back through dataset, which decodes it. return ` `; }) .join(''); modal.classList.add('active'); // A fresh open always starts unfiltered with the caret in the search box — the // list was just rebuilt, so clearing the input is the whole reset; focus is the // user's next keystroke away. Inside the generation guard above, so a // superseded probe can neither focus nor clear a modal that now shows another // endpoint's models. const search = document.getElementById('customModelPickSearch'); if (search) { search.value = ''; // readmymind-ui.js's rule: on touch devices never focus an input unprompted, or // the OS keyboard pops over the very list the picker just opened to show. Desktop // keeps the caret in the box, one keystroke from filtering. if (!(typeof MobileDetection !== 'undefined' && MobileDetection.isTouchDevice())) { search.focus(); } } this.filterCustomModelPick(''); }, /** * View filter for the open "Choose a model" picker: case-insensitive substring * over each row's model id, driven from the search input's own `oninput`. * Deliberately toggles `hidden` on the already-rendered buttons rather than * re-rendering from a second copy of the model list: the loaded/last-used * promotion, the Default pill, the escaped inline click argument and the async * generation guard all then stay exactly where they are, and clearing the * query restores the list byte for byte. ⚠️ `.run-mode-option` carries its own * `display: flex` (styles.css, restated in mobile.css), which outranks the UA * `[hidden]` rule, so styles.css re-asserts `display: none !important` for * these rows — without it the filter would silently do nothing. */ filterCustomModelPick(value) { const list = document.getElementById('customModelPickList'); if (!list) return; const query = String(value || '').trim().toLowerCase(); let visible = 0; for (const btn of list.querySelectorAll('button[data-model-id]')) { const match = !query || btn.dataset.modelId.toLowerCase().includes(query); btn.hidden = !match; if (match) visible += 1; } const noMatches = document.getElementById('customModelPickNoMatches'); if (noMatches) noMatches.hidden = visible > 0; }, closeCustomModelPickModal() { document.getElementById('customModelPickModal')?.classList.remove('active'); this._pendingCustomModelPick = null; }, /** * In-app replacement for a native `confirm()` popup, used specifically for the * llama-swap "this will unload it for session X" warning (both launch paths below) — * a browser-chrome dialog there looked out of place next to the rest of the app's own * modals. Resolves true/false the same way `confirm()` would; `_resolveModelSwapConfirm` * (the modal's own Cancel/Switch-anyway buttons, and its backdrop click) is what settles * the returned promise. */ _confirmModelSwap(message) { const modal = document.getElementById('customModelSwapConfirmModal'); const messageEl = document.getElementById('customModelSwapConfirmMessage'); if (messageEl) messageEl.textContent = message; modal?.classList.add('active'); return new Promise((resolve) => { this._resolveModelSwapConfirmPromise = resolve; }); }, /** Called by the modal's Cancel/Switch-anyway buttons and its backdrop click. */ _resolveModelSwapConfirm(proceed) { document.getElementById('customModelSwapConfirmModal')?.classList.remove('active'); const resolve = this._resolveModelSwapConfirmPromise; this._resolveModelSwapConfirmPromise = null; resolve?.(proceed); }, /** * In-app warning shown when the apply route reports `requiresContextWarning`: this * model's real discovered context is smaller than the CLI's own fixed system-prompt/ * tool-schema overhead, which guarantees the very first message fails outright — no * `CLAUDE_CODE_MAX_CONTEXT_TOKENS` value fixes that, since there is no conversation * history yet for compaction to trim. Same promise-based pattern as * `_confirmModelSwap`; `_resolveContextWarningConfirm` settles it. */ _confirmContextWarning(modelId, contextLength, minSafeContextTokens, slotCount) { const modal = document.getElementById('customModelContextWarningModal'); const messageEl = document.getElementById('customModelContextWarningMessage'); if (messageEl) { const known = typeof contextLength === 'number'; const slots = Number.isFinite(slotCount) && slotCount > 1 ? Math.floor(slotCount) : 1; // Enough total context, but split across parallel server slots: llama-server shares its // context between --parallel slots, and this CLI sends concurrent requests (the main // session plus each parallel subagent), so several 40k-token requests spend the shared // pool and the server answers HTTP 500 "Context size has been exceeded". if (known && slots > 1 && contextLength >= minSafeContextTokens) { messageEl.textContent = `${modelId}'s ${contextLength.toLocaleString()} tokens of context are split across ${slots} parallel ` + `server slots, about ${Math.floor(contextLength / slots).toLocaleString()} each. This CLI ` + `needs roughly ${minSafeContextTokens.toLocaleString()}+ tokens just for its own system prompt and tools on ` + `every request, and it sends several requests at once (the main session plus each parallel subagent). ` + `Concurrent requests can use up the shared context, and the server then fails them with ` + `"Context size has been exceeded".\n\n` + `To fix this, start the model with a single slot: add "--parallel 1" to its llama-swap cmd. The active ` + `conversation then gets the whole context, other requests queue instead of failing, and the CLI's ` + `repeated prompt prefix stays cached. Alternatively give the model a larger context (raise --fit-ctx or ` + `--ctx-size) so every slot has at least ${minSafeContextTokens.toLocaleString()} tokens.`; modal?.classList.add('active'); return new Promise((resolve) => { this._resolveContextWarningConfirmPromise = resolve; }); } messageEl.textContent = `${modelId} is configured with ` + (known ? `only ${contextLength.toLocaleString()} tokens of` : 'an unknown (too small)') + ` context, but this CLI needs roughly ${minSafeContextTokens.toLocaleString()}+ tokens just for its own ` + `system prompt and tools — before any conversation history. Its very first message will fail outright, ` + `no matter what context size Codeman tells it to expect.\n\n` + `To fix this, reconfigure llama-swap to give this model (or a smaller one) an explicit larger context ` + `instead of relying on auto-fit (--fit-ctx), which optimizes for the biggest MODEL that fits, not the ` + `biggest CONTEXT — e.g. add "-c 65536" (or as large a --ctx-size as your hardware holds) to its llama-swap ` + `config entry. A smaller model at a much larger explicit context often fits in the same VRAM a bigger ` + `model's auto-fit context gets shrunk to make room for.` + (slots > 1 ? `\n\nThis server also runs ${slots} parallel slots that share that context; add "--parallel 1" to ` + `the same cmd so a single conversation gets all of it.` : ''); } modal?.classList.add('active'); return new Promise((resolve) => { this._resolveContextWarningConfirmPromise = resolve; }); }, /** Called by the modal's Cancel/Launch-anyway buttons and its backdrop click. */ _resolveContextWarningConfirm(proceed) { document.getElementById('customModelContextWarningModal')?.classList.remove('active'); const resolve = this._resolveContextWarningConfirmPromise; this._resolveContextWarningConfirmPromise = null; resolve?.(proceed); }, /** A model row in the picker modal was clicked: close it and launch with that choice. */ chooseCustomModelAndRun(modelId) { const pending = this._pendingCustomModelPick; this.closeCustomModelPickModal(); if (!pending) return; // modal reopened/closed from elsewhere between render and click void this.runCustomModelEntry(pending.mode, pending.endpointId, modelId); }, /** * Runs a session on `mode` and immediately applies `endpointId`/`modelId` to it * via POST /api/sessions/:id/custom-model (see session-routes.ts) — the same * restart-in-place apply path the (not-yet-built) endpoint-management surface * would use for an already-running session. A custom-model run is a one-off * "try this endpoint" action, not a sticky mode. * * Routes through run() itself, via a temporary `_runMode` swap, rather than a * parallel dispatch table: that is what gives this the same in-flight lock * every other Run click gets (src/web/CLAUDE.md, Run launch synchronization — the lock * exists so a double click cannot create duplicate sessions with the same * `w-` name, and it guards the OTHER direction too: without it, the * main Run button could start a second concurrent launch while this one was * still resolving), and it means a CLI whose customModelInjection recipe * lands later needs no update here, only in run()'s own dispatch. The swap * never persists — setRunMode() would sync it to the server as the user's new * default, which a one-off endpoint run must not do — and is restored in * `finally` even if run() throws. */ /** * Dispatches to the ONE-SHOT launch path (below) for every custom-model-eligible CLI * except claude, which still goes through the restart-after-native-boot path * (`_runCustomModelEntryViaRestart`): claude's own `runClaude()` carries multi-tab * launch and a docker-config-drift confirm/retry loop neither of the other seven * functions has, and folding those into the one-shot flow is unstarted, separate work. * The other seven (opencode/codex/gemini/pi/grok/deepseek/omp) are each a single, * simple launch, so they get the one-shot path — the one visibly worth it, since a * native-boot-then-restart is far more jarring on a CLI whose TUI fully reinitializes * (Codex, confirmed live) than on claude's own `--resume`-based restart. */ async runCustomModelEntry(mode, endpointId, modelId) { // "Last used" is recorded by each path itself, ONLY once the model is actually // applied — never here, unconditionally, on the mere attempt. A context-window // warning or a swap-conflict question can still say no after this call, and the // context-warning case is the one that bites: declining it means this exact // model cannot work with this CLI at all, so promoting it as "Last used" next // time the picker opens would be actively wrong, not just premature. if (mode === 'claude') { return this._runCustomModelEntryViaRestart(mode, endpointId, modelId); } return this._runCustomModelEntryOneShot(mode, endpointId, modelId); }, /** * Launches directly on the endpoint — no restart, so no visible relaunch. Stashes the * pick on `_pendingCustomModelForLaunch` for the targeted run() function to read * and fold into its own /api/quick-start body (see `_quickStartWithCustomModelConfirm`); * cleared in `finally` the same way `_runMode`'s temporary swap is, even if run() throws. */ async _runCustomModelEntryOneShot(mode, endpointId, modelId) { document.getElementById('runModeMenu')?.classList.remove('active'); const previousRunMode = this._runMode; const tabCountEl = document.getElementById('tabCount'); const prevTabCount = tabCountEl?.value; this._runMode = mode; this._pendingCustomModelForLaunch = { endpointId, modelId }; if (tabCountEl) tabCountEl.value = '1'; try { await this.run(); } finally { this._runMode = previousRunMode; this._pendingCustomModelForLaunch = undefined; if (tabCountEl && prevTabCount !== undefined) tabCountEl.value = prevTabCount; } // run() (via _quickStartWithCustomModelConfirm) reports its own launch error or // cancellation via toast and leaves this unset — nothing more to do here then. const result = this._lastCustomModelLaunchResult; this._lastCustomModelLaunchResult = undefined; if (result?.modelSwapInProgress) { void this._watchLlamaSwapLoading(endpointId, modelId, result.sessionId); } }, /** * POSTs a /api/quick-start body already carrying `customModel` (see the run() * call sites below), showing the same llama-swap "this will unload it for session X" * warning the restart path's `_applyCustomModelToSession` shows when the route asks * for confirmation, and retrying with that question's own flag on accept. Stashes the final * response's payload on `_lastCustomModelLaunchResult` for * `_runCustomModelEntryOneShot` to read `modelSwapInProgress` off afterward — run()'s * eleven per-mode dispatch targets have no shared return-value contract of their own, * so a side channel here is simpler than threading one through every one of them. */ async _quickStartWithCustomModelConfirm(bodyObj) { const post = async (body) => { const res = await fetch('/api/quick-start', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body), }); return res.json(); }; // Each question is answered with its OWN flag, and the answer accumulates, so the // second POST still carries the first answer. Never the blanket `confirmed`: the two // questions are about different people (a context window too small is the caller's // problem, unloading a model is another session's), and while they shared one flag // clicking past the context warning silently answered the swap question too. let answered = {}; let data = await post(bodyObj); if (data?.data?.requiresContextWarning) { const { modelId, contextLength, minSafeContextTokens, slotCount } = data.data; const proceed = await this._confirmContextWarning( modelId, contextLength, minSafeContextTokens, slotCount ); if (!proceed) { this._lastCustomModelLaunchResult = undefined; return { success: false, error: 'Launch cancelled — context window too small' }; } answered = { ...answered, confirmedContext: true }; data = await post({ ...bodyObj, customModel: { ...bodyObj.customModel, ...answered } }); } if (data?.data?.requiresConfirmation) { const { currentlyLoadedModel, affectedSessions } = data.data; // The swap is blocked regardless of ownership, but multi-user mode scopes which // sessions get NAMED, so this list can be empty while the conflict is real. const names = affectedSessions.map((s) => s.name || s.id).join(', '); const who = names ? `${names} ${affectedSessions.length === 1 ? 'is' : 'are'} currently using` : 'Another session on this endpoint is currently using'; const them = names && affectedSessions.length > 1 ? 'those sessions' : 'that session'; const proceed = await this._confirmModelSwap( `${who} ${currentlyLoadedModel} on this endpoint. Switching will unload it for ` + `${them} too. Continue?` ); if (!proceed) { this._lastCustomModelLaunchResult = undefined; return { success: false, error: 'Model switch cancelled' }; } answered = { ...answered, confirmedSwap: true }; data = await post({ ...bodyObj, customModel: { ...bodyObj.customModel, ...answered } }); } const launched = data?.success !== false; this._lastCustomModelLaunchResult = launched ? data?.data : undefined; // Only once actually launched, and only for a call that carried a custom-model pick // at all — `_launchQuickStartInstances` runs every quick-start body (custom-model or // not) through this same function, so a plain launch must not fall through here with // an undefined endpointId/modelId that quietly no-ops the (mode, endpointId) key. if (launched && bodyObj.customModel) { this._setCustomModelLastUsed(bodyObj.mode, bodyObj.customModel.endpointId, bodyObj.customModel.modelId); } return data; }, /** The restart-after-native-boot path — see `runCustomModelEntry`'s own comment for * which CLIs still use this one. */ async _runCustomModelEntryViaRestart(mode, endpointId, modelId) { document.getElementById('runModeMenu')?.classList.remove('active'); const previousRunMode = this._runMode; const before = this.activeSessionId; const tabCountEl = document.getElementById('tabCount'); const prevTabCount = tabCountEl?.value; this._runMode = mode; if (tabCountEl) tabCountEl.value = '1'; try { await this.run(); } finally { this._runMode = previousRunMode; if (tabCountEl && prevTabCount !== undefined) tabCountEl.value = prevTabCount; } // run() reports its own errors via toast. Every run*() function handles its // own failure internally and returns normally rather than throwing or // leaving activeSessionId null, so a declined/failed launch (missing CLI, a // caught exception, isBusy on the session the launch would have targeted) // falls through to here with the PREVIOUSLY active session still active. // Requiring the id to have actually changed — not just to be non-null — is // what stops that case from silently re-pointing and restarting whatever // session the user was already looking at. const sessionId = this.activeSessionId; if (!sessionId || sessionId === before) return; // Claude just launched on the NATIVE backend and is about to be restarted onto // the endpoint — without something saying so, that native boot (which can talk // to Opus for a moment) reads as "the endpoint didn't apply" rather than "the // switch hasn't happened yet". Prominent and screen-centred (not a corner toast) // since this can sit on screen for a while; sticky until the apply below settles // one way or the other, or hands off to _watchLlamaSwapLoading's own banner. const switchingToast = this._showCenterStatus(`Claude started — switching to ${endpointId}…`); // A freshly launched CLI reports its OWN startup as 'busy' (spinner, the // workspace-trust check, whatever else it does before its first prompt) — // measured landing well before this line reliably reaches it — and the // apply route's isBusy() guard correctly refuses to restart a session // mid-turn, "mid-turn" included, which this fresh boot looks exactly // like from the outside. Give it a bounded chance to settle first rather // than raising a false "Session is busy" on every single launch. Per the // wait contract a timeout here is a normal 200, never an error — a // session still busy after 20s just reaches the apply call below and // gets the route's own honest, now-visible SESSION_BUSY error instead of // this guessing about it. await this._apiJson(`/api/sessions/${sessionId}/wait?until=idle&timeout=20000`); // _apiJson() (used everywhere else in this file) unwraps a success body to // its `data`, but on failure it swallows the response entirely and returns // null — exactly the `error` text a caller needs to tell "the endpoint is // unreachable" apart from "the CLI can't be redirected" or "this is a // Docker/remote session". Go through the // raw response here instead so a failure is diagnosable, not just present. let { ok, data, res } = await this._applyCustomModelToSession(sessionId, endpointId, modelId); // A success body comes back as {success:true, data:{...}} (server.ts's preSerialization // envelope), but a route-level error is {success:false, error, errorCode} with no nested // data — createErrorResponse() never wraps one. `payload` below is only ever meaningful // once `data.success !== false`. let payload = data?.success !== false ? data?.data : undefined; // Accumulates the questions the user has answered, so a second retry still carries // the first answer. See _applyCustomModelToSession for why these are per-question. let answered = {}; // This CLI's own fixed overhead (system prompt + tool schemas) may exceed the // model's real discovered context outright — no context-length declaration can // fix that, since compaction only trims conversation history and there is none // on message 1. Warn and let the user decide whether to launch anyway, same // re-send pattern as the swap check below. if (ok && payload?.requiresContextWarning) { const proceed = await this._confirmContextWarning( payload.modelId, payload.contextLength, payload.minSafeContextTokens, payload.slotCount ); if (!proceed) { switchingToast?.dismiss(); this.showToast('Kept the native backend — context window too small', 'info'); return; } answered = { ...answered, confirmedContext: true }; ({ ok, data, res } = await this._applyCustomModelToSession(sessionId, endpointId, modelId, answered)); payload = data?.success !== false ? data?.data : undefined; } // llama-swap runs one model at a time: switching would unload it out from under // another session actively using it. The route only asks when that's actually true // (never just because a swap is needed at all) — confirming re-sends the same call // with `confirmedSwap` so the route skips THIS check, and only this one, next time. if (ok && payload?.requiresConfirmation) { // See the one-shot path above: an empty list means the conflicting sessions are // ones this caller may not be told about, not that there is no conflict. const names = payload.affectedSessions.map((s) => s.name || s.id).join(', '); const who = names ? `${names} ${payload.affectedSessions.length === 1 ? 'is' : 'are'} currently using` : 'Another session on this endpoint is currently using'; const them = names && payload.affectedSessions.length > 1 ? 'those sessions' : 'that session'; const proceed = await this._confirmModelSwap( `${who} ${payload.currentlyLoadedModel} on this endpoint. Switching to ${modelId} will unload it ` + `for ${them} too. Continue?` ); if (!proceed) { switchingToast?.dismiss(); this.showToast('Kept the native backend — model switch cancelled', 'info'); return; } answered = { ...answered, confirmedSwap: true }; ({ ok, data, res } = await this._applyCustomModelToSession(sessionId, endpointId, modelId, answered)); payload = data?.success !== false ? data?.data : undefined; } if (!ok || !data || data.success === false) { switchingToast?.dismiss(); const detail = data?.error ? `: ${data.error}` : res ? ` (HTTP ${res.status})` : ' (request failed)'; this.showToast(`Session started on the native backend — could not apply the custom endpoint${detail}`, 'error', { duration: 0, }); return; } // The apply has actually succeeded and both questions (if asked) are answered // yes — only now is this a real "last used" for the picker's next open, not // before either confirmation had a chance to decline it. this._setCustomModelLastUsed(mode, endpointId, modelId); // The apply above already succeeded — the session IS pointed at the endpoint — but // llama-swap itself may still be unloading the old model and loading this one, which // can take well over a minute. Without this, a prompt sent during that window either // hangs silently or (the bug this whole feature exists to fix) gets answered by // whatever was loaded a moment ago, reading as "it's still using the wrong model." // Hand off to its own sticky toast rather than stacking a second one on top. if (payload?.modelSwapInProgress) { switchingToast?.dismiss(); void this._watchLlamaSwapLoading(endpointId, modelId, sessionId); return; } switchingToast?.setMessage(`Pointed at ${endpointId} — restarting the session...`); setTimeout(() => switchingToast?.dismiss(), 3000); }, /** POST /api/sessions/:id/custom-model, returning {ok, data, res} rather than throwing — * see runCustomModelEntry's own comment for why this goes through `_api()` (raw fetch) * rather than `_apiJson()`: a failure's `error` detail must survive to the caller. */ /** * `answered` carries the questions the user has ALREADY said yes to, as the route's own * per-question flags (`confirmedContext`, `confirmedSwap`). Never the blanket * `confirmed`: the two questions are about different people, so answering one must not * answer the other. It accumulates, so the second retry still carries the first answer. */ async _applyCustomModelToSession(sessionId, endpointId, modelId, answered) { const res = await this._api(`/api/sessions/${sessionId}/custom-model`, { method: 'POST', body: { endpointId, modelId, ...(answered || {}) }, }); const data = res ? await res.json().catch(() => null) : null; return { ok: !!res, data, res }; }, /** * Best-effort: looks up `modelId`'s discovered file size (GB) off the endpoint's own * saved host record (`CustomModelHost.modelSizesGB`, populated during discovery by * parsing llama-swap's own `description` field for an auto-discovered model). Returns * `undefined` for a hand-configured profile with no parseable size, an unreachable * server, or any other failure — never a guess. */ async _lookupModelSizeGB(endpointId, modelId) { const hosts = await this._apiJson('/api/model-endpoints').catch(() => null); if (!Array.isArray(hosts)) return undefined; const host = hosts.find((h) => h.id === endpointId); const size = host?.modelSizesGB?.[modelId]; return typeof size === 'number' && Number.isFinite(size) && size > 0 ? size : undefined; }, /** * Strips llama.cpp's own bootlog prefix (` `, e.g. * `0.31.428.568 I srv llama_server: model loaded`) for display, leaving just * `llama_server: model loaded` — the raw line from the server is kept as-is * (`GET .../running-status`'s `logLine` field), this trims it only for the loading * banner's second line. Defensive: a line that doesn't match this shape (a different * llama.cpp build, or llama-swap's own format changing) is shown verbatim rather than * mangled or dropped. */ _formatLlamaLogLine(line) { return typeof line === 'string' ? line.replace(/^[\d.]+\s+[IWE]\s+\S+\s+/, '') : line; }, /** * Polls llama-swap's own `/running` (via the read-only running-status route) until * `modelId` reports `state: 'ready'`, showing a sticky banner the whole time so a slow * unload/reload (measured well over a minute for a large model) reads as "loading, * still working on it", never as silence or a wrong answer from whatever was loaded * before. Checks immediately (a fast load, or a re-apply onto an already-ready model, * shouldn't wait a full interval to say so), then every `pollIntervalMs`. * * Deliberately UNBOUNDED — no estimate, no countdown, no automatic give-up. An earlier * version scaled a timeout off the model's discovered file size and auto-closed the * session when it elapsed, but a real load's actual duration depends on hardware this * feature has no way to know (VRAM, storage speed, what else is contending for the * GPU), so any fixed number was a guess dressed up as a fact — the banner now says so * outright instead of pretending to a precision it doesn't have, and a Cancel button on * the banner itself (`_showCenterStatus`'s `onCancel`) is how the user ends it if it's * taking too long, closing `sessionId` the same way the old timeout used to. * * `_watchLlamaSwapGeneration` guards against two overlapping calls (a second launch * started before the first one's loop finished) clobbering each other's banner: * `_showCenterStatus` reuses one shared DOM node, so an older loop's `dismiss()`/message * update firing after a newer one has already taken over the banner would otherwise hide * or overwrite the WRONG one, or close the WRONG session. Each call claims the counter * as its own "generation" and checks it still owns it before touching either. * * `pollIntervalMs` exists to let a test drive this in milliseconds instead of seconds — * real callers never pass it. */ async _watchLlamaSwapLoading(endpointId, modelId, sessionId, pollIntervalMs = 1000) { const generation = (this._watchLlamaSwapGeneration = (this._watchLlamaSwapGeneration || 0) + 1); const isCurrent = () => this._watchLlamaSwapGeneration === generation; const sizeGB = await this._lookupModelSizeGB(endpointId, modelId); if (!isCurrent()) return; // a newer launch already took over before the lookup even finished const sizeSuffix = sizeGB ? ` (${sizeGB.toFixed(1)} GB)` : ''; const baseMessage = `Loading ${modelId}${sizeSuffix} on ${endpointId} — this can take a while depending on ` + `your hardware and the model size.`; // Second line, when llama-swap's own event feed actually gives us one: the real // backend llama-server process's own latest log line (load_model:/llama_server: ..., // see getLatestLlamaSwapLogLine) — a bare "please wait" says nothing is broken, this // says what's actually happening. Absent on the very first render (no poll has // landed yet) and whenever the endpoint doesn't expose it at all — never fabricated, // and never cleared back to blank once seen (stays on the last real thing llama.cpp // said if a later poll comes back with nothing new). const buildMessage = (logLine) => { const line = this._formatLlamaLogLine(logLine); return baseMessage + (line ? `\nllama.cpp: ${line}` : ''); }; let cancelled = false; // Prominent and screen-centred, not a corner toast — a real llama-swap model load can // sit on screen for well over a minute, easy to mistake for nothing happening there. const toast = this._showCenterStatus(buildMessage(), { onCancel: () => { cancelled = true; }, }); while (!cancelled) { const status = await this._apiJson(`/api/model-endpoints/${encodeURIComponent(endpointId)}/running-status`); if (!isCurrent()) return; // a newer launch took over the banner — this loop is done if (cancelled) break; if (!status) { // transient failure — keep waiting rather than giving up early } else if (!status.isLlamaSwap) { // Endpoint changed under us, or wasn't llama-swap after all — nothing more to // watch for, and not a failure worth a toast of its own. toast?.dismiss(); return; } else if (status.running.some((r) => r.model === modelId && r.state === 'ready')) { toast?.dismiss(); this.showToast(`${modelId} is ready`, 'success', { duration: 2500 }); return; } if (!isCurrent() || cancelled) break; toast?.setMessage(buildMessage(status?.logLine)); await new Promise((resolve) => setTimeout(resolve, pollIntervalMs)); } if (!isCurrent()) return; // Cancelled by the user, not a timeout — an ordinary info toast, not a scary error // banner, since this was deliberate rather than something going wrong. toast?.dismiss(); this.showToast( `Cancelled loading ${modelId} on ${endpointId}` + (sessionId ? ' — the session has been closed.' : '.'), 'info' ); if (sessionId) { try { await this.closeSession(sessionId); } catch { // closeSession already reports its own failure via toast — nothing more to do here } } }, /** * Start the DeepSeek Harness browser UI and open it as a Codeman web tab. * * The server is a background child process owned by * `deepseek-web-server.ts`, NOT a shell session. It was a shell session first, * on the reasoning that Codeman already supervises those, and that version * worked - it just put a terminal tab on screen beside the web tab the user * actually asked for, on every click. Opening a dashboard should open one tab. * * `--trusted-host` is the load-bearing flag: dsh fences its `/api` behind a * browser-trust check on the request authority, and a Codeman web tab reaches * it through Codeman's own origin via the webview proxy, not directly. Without * passing Codeman's authority the page renders and every API call fails. * * The tab is saved `trusted: true`, and that is REQUIRED rather than a * convenience: an untrusted webview is sandboxed without `allow-same-origin`, * which breaks this dashboard twice over. The dsh client-runtime reads * `localStorage` while loading its plugins and dies there ("the document is * sandboxed and lacks the 'allow-same-origin' flag"), and an opaque-origin * frame sends `Origin: null`, so dsh's own trust check 403s every `/api` call * no matter which authority `--trusted-host` names. Passing `location.host` * only means anything once the frame actually carries that origin. * * The trade this makes is real and worth stating: a trusted proxied frame is * same-origin with Codeman and can therefore reach Codeman's own API. It is * defensible only because of what this specific dashboard already is - an * agent harness Codeman just started itself, on loopback, which can run code * as the user regardless. It is not a precedent for trusting third-party * dashboards generally, which is why it is set here rather than defaulted. */ async runDeepSeekWeb() { document.getElementById('runModeMenu')?.classList.remove('active'); const ownsLaunchTerminal = this._beginSessionLaunchStatus('Starting the DeepSeek web UI...'); try { // One request, and the server owns everything behind it: picking a free // port, spawning, waiting for the port to answer, and reusing an already // running server instead of racing it. This used to start the server in a // shell SESSION, which worked but put a terminal tab on screen next to the // web tab actually asked for, every single time. // // `authority` is what dsh fences its own `/api` behind (`--trusted-host`), // so it must be the origin this page is loaded from rather than anything // the server could guess: a Codeman reachable at both loopback and a // tailnet name has two, and only the browser knows which one is in play. const startRes = await fetch('/api/deepseek/web', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ authority: location.host }), }); const startData = await startRes.json(); if (!startData.success) throw new Error(startData.error || 'Failed to start the DeepSeek web UI'); const url = startData.data.url; // One managed record, repointed rather than duplicated: the port is chosen // per launch, so creating a fresh row each time would stack a dashboard // per restart, each pointing at a port nothing serves any more. let webview = [...(this.webviews?.values() || [])].find((w) => w.managed === 'deepseek-web'); if (webview) { const patchRes = await fetch(`/api/webviews/${webview.id}`, { method: 'PATCH', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ url, trusted: true }), }); const patchData = await patchRes.json(); if (!patchData.success) throw new Error(patchData.error || 'Failed to update the web tab'); webview = patchData.data.webview || patchData.data; } else { const wvRes = await fetch('/api/webviews', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ name: 'DeepSeek Harness', url, icon: '\u{1F433}', managed: 'deepseek-web', trusted: true, }), }); const wvData = await wvRes.json(); if (!wvData.success) throw new Error(wvData.error || 'Failed to save the web tab'); webview = wvData.data.webview || wvData.data; } // refreshWebviews, not a hopeful optional-chain: openWebview() reads // this.webviews and silently no-ops on an id it has not loaded, so // skipping the refresh made the FIRST click create the record but open // nothing (the SSE round-trip had not landed yet). await this.refreshWebviews?.(); this._appendSessionLaunchStatus(ownsLaunchTerminal, `Serving on ${url} - opening it as a tab.`); if (webview?.id) await this.openWebview(webview.id); } catch (err) { this._reportSessionLaunchError(ownsLaunchTerminal, err.message); } }, /** * Install a DeepSeek Harness terminal profile from the run menu. * * Held open for as long as the package manager takes (the endpoint bounds it), * so the button reports progress rather than appearing to do nothing. On * success the availability map is patched in place, which is what makes the * real DeepSeek entry appear without a reload. */ async installDeepSeekProfile() { const label = 'Installing a DeepSeek terminal profile (this can take a minute)...'; const ownsLaunchTerminal = this._beginSessionLaunchStatus(label); try { const res = await fetch('/api/deepseek/install-profile', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({}), }); const data = await res.json(); if (!data.success) throw new Error(data.error || 'Failed to install the profile'); window.__codemanCliAvailable = { ...(window.__codemanCliAvailable || {}), deepseek: !!data.data.runnable }; this._appendSessionLaunchStatus(ownsLaunchTerminal, `Installed ${data.data.package} into profile "${data.data.profile}".`); this.showToast?.(`DeepSeek profile "${data.data.profile}" installed`, 'success'); const menu = document.getElementById('runModeMenu'); if (menu) this._refreshRunModeAvailability(menu); } catch (err) { this._reportSessionLaunchError(ownsLaunchTerminal, err.message); } }, /** * One-line explanation at the top of the run menu. Only a container that could * not be read produces one; everything else removes it, so a stale reason can * never outlive the condition that caused it. */ _renderRunModeNotice(menu, message) { if (!menu) return; let el = menu.querySelector('.run-mode-notice'); if (!message) { el?.remove(); return; } if (!el) { el = document.createElement('div'); el.className = 'run-mode-notice'; menu.prepend(el); } // Server-supplied text: set it, never parse it as markup. el.textContent = message; }, /** * Ask the container which CLIs it actually has, and re-gate the menu once the * answer lands. Cached per case for the page's lifetime: the menu re-opens * often and the probe is a `docker exec` round trip. * * Best-effort by design — an unreachable daemon or a stopped container leaves * the cache empty, which the caller reads as "unknown" and therefore does not * gate. Hiding every mode because a probe failed would be worse than showing * one that turns out to be missing, which the launch path already refuses with * a specific message. */ async _probeDockerCaseModes(activeCase, menu) { const name = activeCase?.name; const container = activeCase?.docker?.container; const hostId = activeCase?.docker?.hostId; if (!name || !container || !hostId) return; this._dockerCaseModes = this._dockerCaseModes || {}; if (this._dockerModeProbeInFlight?.[name]) return; this._dockerModeProbeInFlight = this._dockerModeProbeInFlight || {}; this._dockerModeProbeInFlight[name] = true; try { // ⚠️ _api serializes `body` and sets Content-Type itself. Passing an // already-stringified body double-encodes it and the server rejects a // JSON string where it expects an object (400 INVALID_INPUT). const probe = await this._apiJson('/api/docker-cases/adopt-preflight', { method: 'POST', body: { hostId, container }, }); if (probe?.ok && Array.isArray(probe.availableModes)) { this._dockerCaseModes[name] = probe.availableModes; delete this._dockerCaseProbeError?.[name]; } else { // An ADOPTED container that cannot be probed — recreated, stopped, engine // down — must NOT fall through to "show everything". Offering claude on a // container that is not running is a click that can only fail, with the // reason visible nowhere. Record the reason and say it in the menu. // // ⚠️ An OWNED container gets no error: it does not exist until the first // session launches it, so "not found" is the expected answer for every // newly linked Docker case, and gating on it made those cases unusable. // Leaving the cache empty reads as "unknown", which does not gate. if (activeCase?.docker?.owned === false) { this._dockerCaseProbeError = this._dockerCaseProbeError || {}; this._dockerCaseProbeError[name] = probe?.error || `Could not read container "${container}".`; } delete this._dockerCaseModes[name]; } // Only repaint while the menu the user opened is still on screen. if (menu?.classList.contains('active')) this._refreshRunModeAvailability(menu); } finally { delete this._dockerModeProbeInFlight[name]; } }, async _loadRunModeHistory() { const container = document.getElementById('runModeHistory'); if (!container) return; container.innerHTML = '
Loading...
'; try { const display = await this._fetchHistorySessions(10); if (display.length === 0) { container.innerHTML = '
No history
'; 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 }); // Shared helper, not a local regex: the copy that used to live here // matched `/home//` only, so on macOS (`/Users//`) nothing was // stripped and every row spent its first ~19 characters on an identical // prefix — with the tail ellipsized, all rows rendered as // `/Users/jordanryan/co…` and became indistinguishable (#273). const shortDir = this._shortenHomePath(s.workingDir); // Lead with the folder that identifies the row; the parent path trails and // is what gets truncated. Truncation must never eat the identity. const lastSlash = shortDir.lastIndexOf('/'); const leafName = lastSlash === -1 ? shortDir : shortDir.slice(lastSlash + 1); // `/.claude/worktrees` in the parent path is pure noise once the pill // says which worktree it is — drop it so the repo stays visible instead. const parentDir = (lastSlash === -1 ? '' : shortDir.slice(0, lastSlash)).replace(/\/\.claude\/worktrees$/, ''); const btn = document.createElement('button'); btn.className = 'run-mode-option run-mode-hist-row'; btn.title = s.workingDir; btn.dataset.sessionId = s.sessionId; btn.dataset.workingDir = s.workingDir; const nameSpan = document.createElement('span'); nameSpan.className = 'hist-name'; nameSpan.textContent = leafName; const parts = [nameSpan]; // Worktree pill, same data the session rows use (#266). A worktree's // directory basename is often just the worktree name, so without this two // worktrees of one repo still read alike. const wt = this._worktreeLabel ? this._worktreeLabel(s) : ''; if (wt) { const wtSpan = document.createElement('span'); wtSpan.className = 'hist-wt'; wtSpan.textContent = wt; parts.push(wtSpan); } if (parentDir) { const dirSpan = document.createElement('span'); dirSpan.className = 'hist-dir'; dirSpan.textContent = parentDir; parts.push(dirSpan); } const metaSpan = document.createElement('span'); metaSpan.className = 'hist-meta'; metaSpan.textContent = timeStr; parts.push(metaSpan); btn.append(...parts); btn.addEventListener('click', (e) => { e.stopPropagation(); this.resumeHistorySession(s.sessionId, s.workingDir, s.name, s.mode, s.resumeId); }); container.appendChild(btn); } } catch (err) { container.innerHTML = '
Failed to load
'; } }, _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) { const registryEntry = registryCliById(mode); label.textContent = mode === 'opencode' ? 'Run OC' : mode === 'codex' ? 'Run CX' : mode === 'gemini' ? 'Run GM' : mode === 'antigravity' ? 'Run AG' : mode === 'pi' ? 'Run PI' : mode === 'grok' ? 'Run GK' : mode === 'deepseek' ? 'Run DS' : mode === 'omp' ? 'Run OMP' : mode === 'shell' ? 'Run SH' : registryEntry ? `Run ${registryEntry.shortBadge}` : '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() { this.renderRegistryRunOptions(); let savedMode = 'claude'; try { savedMode = localStorage.getItem('codeman_runMode') || 'claude'; } catch { /* localStorage unavailable */ } // Go through the setter so a CLI disabled after the previous visit, or a // removed custom CLI, cannot survive in localStorage as a runnable mode. this.runMode = savedMode; 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); }, // Next free index for a case's session tabs (e.g. w1-, // w2- for agents, s1- 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'); } }, /** The case a launch runs in: its worktree's, the one it names (a tab's "+"), else the toolbar's. */ _runCaseName(options) { if (options.worktree) return options.worktree.caseName; return options.caseName || document.getElementById('quickStartCase').value || 'testcase'; }, /** * Re-reads the case list after a launch, keeping the toolbar on its own case. * The no-argument reload would re-select the server's `lastUsedCase`, and a * tab's "+" launching through quick-start sets that to the tab's case while the * toolbar stays where it was, so every later launch (a worktree's, a plain Run, * a group's "New session") would otherwise move the toolbar there. */ _reloadCasesAfterRun() { this.loadQuickStartCases(document.getElementById('quickStartCase').value || null); }, async runClaude(options = {}) { // `options.worktree` ({ caseName, name, path }, from the worktree modal): the session runs in // that git worktree of the case and its tab is named after the worktree. const wt = options.worktree || null; const caseName = this._runCaseName(options); const nameBase = wt ? wt.name : caseName; const tabCount = this._readTabCount(options.count); const group = options.tabGroupId ? { tabGroupId: options.tabGroupId } : {}; const ownsLaunchTerminal = this._beginSessionLaunchStatus( `Starting ${tabCount} Claude session(s) in ${wt ? `worktree ${wt.name} of ` : ''}${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 { let caseData; if (wt) { // A worktree is local by construction (the server refuses one for a remote or Docker // case) and its folder came from the worktree list: no case lookup, never a scaffold. caseData = { path: wt.path, location: 'local' }; } else { // Get case path first const caseRes = await fetch(`/api/cases/${caseName}`); const caseLookup = await caseRes.json(); caseData = caseLookup?.data ?? {}; // Create the case only when the server says it does not exist. Any other // failure (a linked folder on a mount that is not answering) must not // scaffold a same-name local case that would then shadow the real one. if (!caseData.path) { if (caseLookup?.errorCode !== 'NOT_FOUND') throw new Error(caseLookup?.error || 'Case lookup failed'); 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- convention as local // sessions (quick-start would otherwise auto-generate codeman-). 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}`, ...group, ...(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, data.data.tabLayout); remoteIds.push(data.data.sessionId); } this._appendSessionLaunchStatus(ownsLaunchTerminal, `All ${tabCount} remote session(s) ready`); if (remoteIds[0]) { await this.selectSession(remoteIds[0]); this._reloadCasesAfterRun(); } this.terminal.focus(); return; } let firstSessionId = null; // Find the highest existing w-number for THIS case (or worktree) to avoid duplicates const startNumber = this._nextCaseSessionStartNumber(nameBase); // 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}-${nameBase}`); } // 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); const advisorModel = this.getAdvisorSetting(globalSettings); // Explicit Claude Model choice (App Settings) wins over the legacy 1M Opus // toggles; both flow as `modelOverride` → the case's .claude/settings.local.json const useOpus1m = caseSettings.opusContext1m || globalSettings.opusContext1mEnabled; 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, ...group, ...(hasEnvOverrides ? { envOverrides } : {}), ...(effort ? { effort } : {}), ...(advisorModel ? { advisorModel } : {}), ...(modelOverride !== undefined ? { modelOverride } : {}), }) }).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, result.data.tabLayout); 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._reloadCasesAfterRun(); } 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(options = {}) { // `options.worktree`: see runClaude. const wt = options.worktree || null; const caseName = this._runCaseName(options); const nameBase = wt ? wt.name : caseName; // Run Shell reads the toolbar's one instance stepper, like every other run*(); // its own second `− 1 +` group (#shellCount) was removed (#428). const shellCount = this._readTabCount(options.count); const group = options.tabGroupId ? { tabGroupId: options.tabGroupId } : {}; const ownsLaunchTerminal = this._beginSessionLaunchStatus( `Starting ${shellCount} Shell session(s) in ${wt ? `worktree ${wt.name} of ` : ''}${caseName}...`, '1;33' ); try { let caseData; if (wt) { // Local by construction, folder from the worktree list (see runClaude). caseData = { path: wt.path, location: 'local' }; } else { // Get the case path const caseRes = await fetch(`/api/cases/${caseName}`); const caseLookup = await caseRes.json(); caseData = caseLookup?.data ?? {}; // Create the case only when the server says it does not exist. Any other // failure (a linked folder on a mount that is not answering) must not // scaffold a same-name local case that would then shadow the real one. if (!caseData.path) { if (caseLookup?.errorCode !== 'NOT_FOUND') throw new Error(caseLookup?.error || 'Case lookup failed'); 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 = wt ? null : (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}`, ...group }) }); 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, data.data.tabLayout); 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 (or worktree) to avoid duplicates const startNumber = this._nextCaseSessionStartNumber(nameBase, 's'); // Create all shell sessions in parallel const sessionNames = []; for (let i = 0; i < shellCount; i++) { sessionNames.push(`s${startNumber + i}-${nameBase}`); } // 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, ...group }) }).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, result.data.tabLayout); 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); } }, /** * Reads the "Instance count" stepper, clamped to 1..20. Single source for every * run*(), runClaude() included. Optional-chained because callers read it BEFORE * their try block, to put the count in the opening banner: `#tabCount` ships * unconditionally today, but a throw here would escape the launch-error path. */ _readTabCount(override) { return Math.min(20, Math.max(1, override ?? (parseInt(document.getElementById('tabCount')?.value) || 1))); }, /** * Launches `tabCount` quick-start sessions of one non-Claude mode * sequentially, selecting the first once all are up. Shared by every * run*() below except runClaude() (which has its own remote/docker * branching and parallel-create path) — before this helper existed, each * of them ignored the "Instance count" stepper entirely and always * launched exactly one session, with no error, just the wrong count. * `buildBody(sessionName)` returns that mode's quick-start POST body. */ async _launchQuickStartInstances(caseName, tabCount, label, buildBody, ownsLaunchTerminal) { const startNumber = this._nextCaseSessionStartNumber(caseName); let firstSessionId = null; // A custom-model launch can ask up to two questions before it starts anything: the // endpoint's context window is too small for this model, and loading it will unload // the model another session is using. Both are decisions about the ENDPOINT, not // about each session, and every instance in this batch targets the same one, so the // answer is taken once and carried to the rest. Without this a 20-instance launch // asks the same question 20 times, which is the interaction between the Instance // count stepper and the custom-model picker that neither feature had on its own. let customModelAnswered = false; for (let i = 0; i < tabCount; i++) { const sessionName = `w${startNumber + i}-${caseName}`; const body = buildBody(sessionName); const data = await this._quickStartWithCustomModelConfirm( customModelAnswered && body.customModel ? { ...body, customModel: { ...body.customModel, confirmedContext: true, confirmedSwap: true } } : body ); if (!data.success) throw new Error(data.error || `Failed to start ${label}`); customModelAnswered = true; await this._ensureCreatedSessionVisible(data.data.sessionId, data.data.session, data.data.tabLayout); if (!firstSessionId) firstSessionId = data.data.sessionId; } if (tabCount > 1) { this._appendSessionLaunchStatus(ownsLaunchTerminal, `All ${tabCount} ${label} session(s) ready`); } return firstSessionId; }, /** * Shared launcher for every RUN_MODE_LAUNCH entry (every run mode except * claude/shell, which have their own flows — claude for its remote/docker * branching and parallel-create path, shell for needing no CLI probe at * all). The eight run() methods below are thin named wrappers: their * names stay because index.html's welcome-screen buttons and the run-mode * menu call them directly by name (`app.runOpenCode()` etc.), and several * tests assert on that name directly too. */ async _runCliMode(mode, options = {}) { const catalogEntry = registryCliById(mode); const entry = RUN_MODE_LAUNCH[mode] || (catalogEntry && { label: catalogEntry.label, installHint: `${catalogEntry.label} is not available on this host.`, supportsCustomModel: false, buildConfig: () => null, }); if (!entry) throw new Error(`Unknown run mode: ${mode}`); // `options.worktree`: see runClaude. Quick-start resolves it server-side (`worktree`), so the // session starts through the same path as any other, only in the worktree's folder. const wt = options.worktree || null; const caseName = this._runCaseName(options); // Remote/docker cases run the CLI on the OTHER side — the local status // probe and the local-only config/env below don't apply (quick-start // rejects them for remote cases). const _runLoc = wt ? 'local' : (this.cases || []).find(c => c.name === caseName)?.location; const isRemote = _runLoc === 'remote' || _runLoc === 'docker'; const tabCount = this._readTabCount(options.count); const ownsLaunchTerminal = this._beginSessionLaunchStatus( `Starting ${tabCount} ${entry.label} session(s) in ${wt ? `worktree ${wt.name} of ` : ''}${caseName}...` ); // Focus in sync gesture context (see runClaude comment) this.terminal.focus(); try { if (!isRemote && RUN_MODE_LAUNCH[mode]) { const statusRes = await fetch(`/api/${mode}/status`); const status = (await statusRes.json()).data; if (!status.available) { this._reportSessionLaunchError(ownsLaunchTerminal, entry.installHint); return; } if (entry.unrunnableHint && !status.runnable) { this._reportSessionLaunchError(ownsLaunchTerminal, entry.unrunnableHint); return; } } else if (!isRemote && !this.isCliAvailable(mode)) { this._reportSessionLaunchError(ownsLaunchTerminal, entry.installHint); return; } const globalSettings = this.loadAppSettingsFromStorage(); const envOverrides = this.buildEnvOverrides(this.getCaseSettings(caseName), globalSettings); // No `effort` field for ANY entry in RUN_MODE_LAUNCH: effort is // Claude-specific (runClaude() alone sends it, and the backend turns it // into `claude --settings`); none of these CLIs has an /effort. Each of // the eight bodies this launcher replaced carried that rule as a comment. const firstSessionId = await this._launchQuickStartInstances( // Only names the tabs (`w-`): a worktree session is named after the worktree. wt ? wt.name : caseName, tabCount, entry.label, (sessionName) => ({ caseName, mode, sessionName, ...(wt ? { worktree: wt.name } : {}), ...(options.tabGroupId ? { tabGroupId: options.tabGroupId } : {}), ...(isRemote ? {} : { ...(entry.buildConfig(globalSettings) || {}), ...(Object.keys(envOverrides).length > 0 ? { envOverrides } : {}), ...(entry.supportsCustomModel && this._pendingCustomModelForLaunch ? { customModel: this._pendingCustomModelForLaunch } : {}), }), }), ownsLaunchTerminal ); // Switch to the new session (don't pre-set activeSessionId — selectSession // early-returns when IDs match, skipping buffer load and sendResize) if (firstSessionId) { await this.selectSession(firstSessionId); } this.terminal.focus(); } catch (err) { this._reportSessionLaunchError(ownsLaunchTerminal, err.message); } }, async runOpenCode() { return this._runCliMode('opencode'); }, async runCodex() { return this._runCliMode('codex'); }, async runGemini() { return this._runCliMode('gemini'); }, async runAntigravity() { return this._runCliMode('antigravity'); }, async runPi() { return this._runCliMode('pi'); }, async runOmp() { return this._runCliMode('omp'); }, async runGrok() { return this._runCliMode('grok'); }, async runDeepSeek() { return this._runCliMode('deepseek'); }, async runCopilot() { return this._runCliMode('copilot'); }, // ═══════════════════════════════════════════════════════════════ // Session Options Modal // ═══════════════════════════════════════════════════════════════ /** * Per-TAB pop-out button override (Session Options → Session → Identity). The * general `showTabDetachButton` App Setting stays the per-device default for ALL * tabs; this map whitelists single sessions on top of it, so one tab can carry * the ⧉ button while the general toggle stays off. Per-device on purpose, like * the general setting: it is a display choice, so it lives in localStorage and * never touches the server schema. Rendered as the `tab-show-detach` class on * the tab (see _fullRenderSessionTabs), which styles.css exempts from the * global `display: none` gate; the active-tab reveal rules stay shared, so an * overridden tab behaves exactly like a tab under the general toggle. */ _tabDetachOverrides() { if (this._tabDetachOverrideMap === undefined) { try { this._tabDetachOverrideMap = JSON.parse(localStorage.getItem('codeman:tab-detach-overrides') || '{}') || {}; } catch (_e) { this._tabDetachOverrideMap = {}; } } return this._tabDetachOverrideMap; }, hasTabDetachOverride(sessionId) { return !!this._tabDetachOverrides()[sessionId]; }, onSessionTabDetachToggle(on) { const id = this.editingSessionId; if (!id) return; const map = this._tabDetachOverrides(); if (on) map[id] = 1; else delete map[id]; // Prune ids whose sessions are gone, so closed sessions cannot grow the map. for (const key of Object.keys(map)) { if (key !== id && this.sessions && !this.sessions.has(key)) delete map[key]; } try { localStorage.setItem('codeman:tab-detach-overrides', JSON.stringify(map)); } catch (_e) { /* storage full/blocked: the in-memory map still applies this page load */ } // Apply to the LIVE tab directly: the debounced render may take the // incremental path (same session set), which patches rather than rebuilds, // so the template's class would only land on the next full render. Future // full renders re-emit it from _fullRenderSessionTabs. const tab = document.querySelector(`.session-tab[data-id="${CSS.escape(id)}"]`); if (tab) tab.classList.toggle('tab-show-detach', !!on); }, /** Copy the open session's working folder (Session Options > Session > Working folder). */ async copySessionPath() { const session = this.sessions.get(this.editingSessionId); if (!session?.workingDir) return; const ok = await this._copyText(session.workingDir); this.showToast(ok ? 'Path copied' : 'Copy failed', ok ? 'success' : 'error'); }, openSessionOptions(sessionId) { const session = this.sessions.get(sessionId); if (!session) return; this.editingSessionId = sessionId; // Per-tab pop-out override state (see _tabDetachOverrides above). const detachToggle = document.getElementById('sessionOptShowTabDetach'); if (detachToggle) detachToggle.checked = this.hasTabDetachOverride(sessionId); // Reset to an appropriate tab — Summary for external CLIs (Respawn/Ralph are Claude-only) const isAltMode = isExternalCliSession(session.mode); 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 = isAltMode; 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); this.updatePromptCacheStatus(sessionId); // Effective opt-in: the server publishes it in the status (session flag, else the app default). document.getElementById('modalKeepWarmEnabled').checked = session.keepWarm?.enabled ?? session.keepWarmEnabled ?? false; this.updateKeepWarmStatus(sessionId); // Git: the session's own choice if it has one, otherwise the app setting. updateGitFetchStatus is also // what SSE calls when a scheduled run lands, so the rows stay current while the modal is open. this.updateGitFetchStatus(sessionId); const imageWatcherToggle = document.getElementById('modalImageWatcherEnabled'); imageWatcherToggle.checked = session.imageWatcherEnabled ?? true; // The server never watches a remote session's directory (it is on another host), so the row is hidden. imageWatcherToggle.closest('.set-row').style.display = session.remote ? 'none' : ''; 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)'; } // Working folder: the path the CLI was launched in on the Codeman host const _pathEl = document.getElementById('modalSessionPath'); _pathEl.textContent = session.workingDir || ''; _pathEl.title = session.workingDir || ''; document.getElementById('modalSessionPathCopy').disabled = !session.workingDir; // A remote session's workingDir is the REMOTE path; a docker session's is the // host bind-mount source the container sees. document.getElementById('modalSessionPathDesc').textContent = session.remote ? "Where this session's CLI runs, on the remote host it reaches over SSH, not on this machine." : session.docker ? "On the machine hosting Codeman, mounted into the Docker container this session's CLI runs in." : "Where this session's CLI runs, on the machine hosting Codeman."; // 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 .set-rail-item[data-tab="ralph"]'); const respawnTabBtn = document.querySelector('#sessionOptionsModal .set-rail-item[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 = ''; } // Git is local git only: a remote (SSH) or Docker case answers 'unsupported' on both git routes and is // never fetched by the interval scheduler, so its Git tab has nothing to say and its rail entry goes // hidden the same way. const gitTabBtn = document.querySelector('#sessionOptionsModal .set-rail-item[data-tab="git"]'); if (gitTabBtn) gitTabBtn.style.display = session.remote || session.docker ? 'none' : ''; // 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'); // Chips mirror their checkbox onto the label, the same way App Settings does // (settings-ui.js: _syncSettingsChips). Registered once per page, never per // open, or a long-lived tab accumulates one listener per visit. if (modal.dataset.chipsReady !== '1') { modal.dataset.chipsReady = '1'; modal.addEventListener('change', e => { if (e.target?.closest?.('.set-chip')) this._syncSettingsChips(); }); } this._syncSettingsChips(); modal.classList.add('active'); // Activate focus trap this.activeFocusTrap = new FocusTrap(modal); this.activeFocusTrap.activate(); }, /** * Write a name the server has just confirmed into the local session map. * * Both rename surfaces re-render the tab strip from `this.sessions` right * after their PUT, so without this they depended on the `session:updated` SSE * frame to carry their own write back. On a page whose SSE stream has gone * quiet without erroring (a proxy that idle-closed it, a laptop resumed from * sleep) that frame never lands: the PUT stores the new name, the re-render * repaints the stale one, and the rename looks like it did nothing until a * full page reload. The response body is authoritative, so apply it directly. * The SSE frame, when it does arrive, replaces the object with the same name. */ _applyLocalSessionName(sessionId, name) { if (typeof name !== 'string') return; const session = this.sessions.get(sessionId); if (!session) return; session.name = name; this.sessions.set(sessionId, session); // Mirrors _onSessionUpdated: subagent windows cache their parent's name. this.updateSubagentParentNames?.(sessionId); }, /** * PUT a session name and return the name the server stored, or null if the * request failed. `_apiPut` swallows network errors into a null Response and * an API-level failure arrives as a non-ok status or `{success:false}`, so a * rename that silently did nothing has to be detected here, not thrown. */ async _putSessionName(sessionId, name) { const res = await this._apiPut(`/api/sessions/${sessionId}/name`, { name }); if (!res || !res.ok) return null; let payload = null; try { payload = await res.json(); } catch { return null; } if (payload && payload.success === false) return null; const confirmed = payload?.data?.name; return typeof confirmed === 'string' ? confirmed : name; }, /** * Write an inline rename, one PUT per session at a time, in the order the * user made them. The editor can be reopened (or cancelled, or replaced by a * group rename) while a PUT is in flight, so the write lives here rather than * in the editor: a confirmed name is applied locally even after its editor is * gone, and the "already that name" check runs only once the earlier writes * have landed, so confirming the name still on screen is a real write. * Resolves { status: 'confirmed' | 'failed' | 'deleted' }; never rejects, * and reports a failed write itself, since its editor may be gone by then. * `_inlineRenamePending` holds the newest queued name per session, so an * editor reopened over a write in flight starts from that name rather than * the one the server has not replaced yet. */ _queueInlineSessionName(sessionId, desiredName) { this._inlineRenameWrites ??= new Map(); this._inlineRenamePending ??= new Map(); const writes = this._inlineRenameWrites; const pending = this._inlineRenamePending; pending.set(sessionId, desiredName); // Chained from a settled promise, so one rejected write cannot stop the // writes queued behind it. const prev = (writes.get(sessionId) || Promise.resolve()).catch(() => {}); const task = prev.then(async () => { const session = this.sessions.get(sessionId); if (!session) return { status: 'deleted' }; if (session.name === desiredName) return { status: 'confirmed' }; let confirmed = null; try { confirmed = await this._putSessionName(sessionId, desiredName); } catch { // A failure is a value, so a later write in the chain still runs. } if (!this.sessions.has(sessionId)) return { status: 'deleted' }; if (confirmed === null) { this.showToast('Failed to rename', 'error'); return { status: 'failed' }; } try { this._applyLocalSessionName(sessionId, confirmed); this.renderSessionTabs(); } catch (err) { // The server holds the name; a local repaint failing is not a failed write. console.error('[rename] applying the confirmed name failed', err); } return { status: 'confirmed' }; }); writes.set(sessionId, task); const cleanup = () => { if (writes.get(sessionId) !== task) return; writes.delete(sessionId); pending.delete(sessionId); }; task.then(cleanup, cleanup); return task; }, async saveSessionName() { if (!this.editingSessionId) return; // Captured: the modal can be closed (or switched to another session) while // the PUT is in flight, and the name belongs to the session that was open. const sessionId = this.editingSessionId; const session = this.sessions.get(sessionId); 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; } const confirmed = await this._putSessionName(sessionId, name); if (confirmed === null) { this.showToast('Failed to save session name', 'error'); return; } this._applyLocalSessionName(sessionId, confirmed); this.renderSessionTabs(); }, 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'); } }, /** * Session Options → Respawn → "Prompt cache": the main conversation's cache state as * Claude Code reports it on the statusline (`session.promptCache`, v2.1.251+), worded by * the pure `CodemanPromptCache.format` (constants.js). Refreshed on open and on the * `session:promptCache` SSE event while the modal shows that session. */ updatePromptCacheStatus(sessionId) { const el = document.getElementById('promptCacheStatus'); if (!el || this.editingSessionId !== sessionId) return; const session = this.sessions.get(sessionId); el.textContent = window.CodemanPromptCache.format(session?.promptCache, Date.now(), session?.statusLineExporter); }, _onSessionPromptCache(data) { const { sessionId, ...promptCache } = data; const session = this.sessions.get(sessionId); if (!session) return; session.promptCache = promptCache; this.updatePromptCacheStatus(sessionId); }, /** * Session Options → Respawn → Prompt cache: the keep-warm line on the Status row (what the * keep-warm service is doing, `session.keepWarm`, worded by `CodemanPromptCache.formatKeepWarm`, * hidden when it has nothing to say) and the state of the "Keep the cache warm while idle" switch. */ updateKeepWarmStatus(sessionId) { const el = document.getElementById('keepWarmStatus'); if (!el || this.editingSessionId !== sessionId) return; const session = this.sessions.get(sessionId); const status = session?.keepWarm; const text = window.CodemanPromptCache.formatKeepWarm(status, Date.now(), session?.statusLineExporter); el.textContent = text; el.hidden = !text; // A launch without cache reports can never keep warm: grey the row and refuse to switch it on, // but a switch that is already on stays reachable so it can be turned off. Same inert-switch // pattern as the context switch in settings-ui.js _applyModelSelection(). const box = document.getElementById('modalKeepWarmEnabled'); if (!box) return; const inert = window.CodemanPromptCache.keepWarmUnavailable(session?.statusLineExporter); box.closest('.set-row')?.classList.toggle('set-row-disabled', inert); box.disabled = inert && !box.checked; }, _onSessionKeepWarm(data) { const { sessionId, ...status } = data; const session = this.sessions.get(sessionId); if (!session) return; session.keepWarm = status; if (this.editingSessionId === sessionId) { const box = document.getElementById('modalKeepWarmEnabled'); if (box) box.checked = status.enabled; } this.updateKeepWarmStatus(sessionId); }, async autoSaveKeepWarm() { if (!this.editingSessionId) return; const enabled = document.getElementById('modalKeepWarmEnabled').checked; try { // _apiPost unwraps the envelope; the route answers with the status for the NEW choice. const data = await this._apiPost(`/api/sessions/${this.editingSessionId}/keep-warm`, { enabled }); const session = this.sessions.get(this.editingSessionId); if (session) { session.keepWarmEnabled = enabled; if (data?.keepWarm?.status) session.keepWarm = data.keepWarm.status; } this.updateKeepWarmStatus(this.editingSessionId); this.showToast(`Prompt-cache keep-warm ${enabled ? 'enabled' : 'disabled'}`, 'success'); } catch (err) { this.showToast('Failed to toggle keep-warm: ' + err.message, 'error'); } }, /** * The Git tab: the session's last scheduled run, and which values its rows show. A session with no choice of * its own follows the app setting, so its switch and interval display the app's numbers and go inert (the * same inert-row pattern as updateKeepWarmStatus above), because changing them here is what makes the session * stop following. The app numbers come from the settings cache, so what this shows can be one settings load * behind; the server decides from a fresh read, not from what the page displays. * * SSE calls this on every `session:updated` for the open session (app.js `_onSessionUpdated`), and a working * session sends one on each working/idle change, so a control the user is in (`document.activeElement`) is * left as it is: rewriting it put a half-typed interval back to the stored one before `onchange` could save it. */ updateGitFetchStatus(sessionId) { const statusEl = document.getElementById('gitFetchStatus'); const followBox = document.getElementById('modalGitFetchFollow'); const box = document.getElementById('modalGitFetchEnabled'); const num = document.getElementById('modalGitFetchInterval'); if (!statusEl || !followBox || !box || !num || this.editingSessionId !== sessionId) return; const session = this.sessions.get(sessionId); if (!session) return; const app = this.loadAppSettingsFromStorage() || {}; // No choice of its own on either field means the session follows the app. A partial one (its own switch, // the app's interval) means it does not, and the row that still follows shows the app's value, which is // also the value the scheduler runs it with (a default here would show one number and fetch at another). const follows = session.gitFetchEnabled === undefined && session.gitFetchIntervalSeconds === undefined; const enabled = session.gitFetchEnabled ?? (app.gitFetchEnabled === true); const interval = session.gitFetchIntervalSeconds ?? app.gitFetchIntervalSeconds ?? 300; const focused = document.activeElement; if (followBox !== focused) followBox.checked = follows; if (box !== focused) box.checked = enabled; if (num !== focused) num.value = interval; for (const el of [box, num]) { el.closest('.set-row')?.classList.toggle('set-row-disabled', follows); el.disabled = follows; } const run = session.gitFetch; const parts = run ? [ `Last run ${this._gitFetchAgo(run.ranAt)}${run.trigger === 'interval' ? ' on the interval' : ' by click'}`, run.state === 'ok' ? `${run.repos} repositories` : run.state, run.failed ? `${run.failed} failed` : '', ] : [follows ? 'Nothing fetched on a timer yet, and this session follows the app setting.' : 'Nothing fetched on a timer yet for this session.']; statusEl.textContent = parts.filter(Boolean).join(' · '); statusEl.hidden = false; }, /** 'just now', '4m', '2h': the short stamps the tab rows use, measured from a run's start. */ _gitFetchAgo(ts) { const seconds = Math.max(0, Math.round((Date.now() - ts) / 1000)); if (seconds < 60) return seconds < 10 ? 'just now' : `${seconds}s`; if (seconds < 3600) return `${Math.round(seconds / 60)}m`; return `${Math.round(seconds / 3600)}h`; }, async autoSaveGitFetch() { if (!this.editingSessionId) return; const follow = document.getElementById('modalGitFetchFollow').checked; const num = document.getElementById('modalGitFetchInterval'); // `null` on the route means "no choice of your own", which is what following the app setting is. const body = follow ? { enabled: null, intervalSeconds: null } : { enabled: document.getElementById('modalGitFetchEnabled').checked, intervalSeconds: Math.min(3600, Math.max(30, parseInt(num.value, 10) || 300)), }; // Enter fires `onchange` with the field still focused, and updateGitFetchStatus leaves a focused field // alone, so the clamped number is written back here or the field would keep showing what was typed. if (!follow) num.value = body.intervalSeconds; try { // `_apiPost` hands back the Response without checking it, so a refused body (a 400) still read as a // save. `_apiJson` is the one that says whether the call went through, and null here means it did not. const data = await this._apiJson(`/api/sessions/${this.editingSessionId}/git-fetch-settings`, { method: 'POST', body, }); if (!data) { this.showToast('Failed to save Git fetch settings', 'error'); return; } const session = this.sessions.get(this.editingSessionId); const saved = data.gitFetchSettings; if (session && saved) { session.gitFetchEnabled = saved.enabled ?? undefined; session.gitFetchIntervalSeconds = saved.intervalSeconds ?? undefined; } this.updateGitFetchStatus(this.editingSessionId); this.showToast('Git fetch settings saved', 'success'); } catch (err) { this.showToast('Failed to save Git fetch settings: ' + err.message, 'error'); } }, async toggleSessionImageWatcher() { if (!this.editingSessionId) return; const toggle = document.getElementById('modalImageWatcherEnabled'); const enabled = toggle.checked; try { const res = await this._apiPost(`/api/sessions/${this.editingSessionId}/image-watcher`, { enabled }); // `_apiPost` hands back the Response without checking it, so a refused request (a 400) must not // read as a save: put the switch back and say so. if (!res?.ok) { toggle.checked = !enabled; this.showToast('Failed to toggle image watcher', 'error'); return; } // 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 // ═══════════════════════════════════════════════════════════════ /** * Show one section of the Session Options modal. * * The chrome is the shared `set-*` settings surface, but unlike App Settings * (whose rail is a table of contents over one scrolling document) this rail * is a real switcher: exactly one `.set-section` is visible and the rest * carry `.hidden`. Summary owns its own scroller and Respawn is long, so * stacking them into a single document would bury both. */ switchOptionsTab(tabName) { // Toggle active class on rail entries document.querySelectorAll('#sessionOptionsModal .set-rail-item').forEach(btn => { btn.classList.toggle('active', btn.dataset.tab === tabName); }); // Toggle hidden class on the sections 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'); document.getElementById('git-tab').classList.toggle('hidden', tabName !== 'git'); // A switched-to section starts at its own top, not at the scroll offset the // previous one was left at. const doc = document.getElementById('sessionOptionsDoc'); if (doc) doc.scrollTop = 0; // 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; this._activeRename?.cancel(); 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; tabName.classList.add('tab-name-renaming'); const currentName = this.getSessionName(session); // A rename still in flight is the user's last word, not the name the // server has yet to replace: start from it, and compare against it below. const shownName = this._inlineRenamePending?.get(sessionId) ?? session.name; const renameInFlight = shownName !== session.name; const parsed = parseSessionPrefix(shownName); const originalContent = tabName.textContent; const originalChildren = [...tabName.childNodes].map((node) => node.cloneNode(true)); const restoreOriginalChildren = () => { tabName.replaceChildren(...originalChildren.map((node) => node.cloneNode(true))); }; // 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.className = 'tab-rename-prefix'; 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 : (shownName || ''); input.placeholder = parsed ? 'Add description...' : currentName; input.className = 'tab-rename-input'; // 80px is tuned for the narrow header tab; a full-width sidebar row can and // should give the whole line to the input. The header editor may shrink to // nothing, while a rail or sidebar row always keeps room to type. const inRail = !!tabName.closest('.tab-rail'); const inSidebar = !inRail && !!this.isSessionSidebarActive?.(); const renameWidth = inRail ? 'auto' : inSidebar ? '100%' : '80px'; const renameMinWidth = inRail || inSidebar ? '4rem' : '0'; input.style.cssText = `width: ${renameWidth}; min-width: ${renameMinWidth}; 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(); let editSettled = false; let invalidated = false; let completed = false; const releaseRenderGuard = () => { if (this._activeRename !== renameHandle) return; this._inlineRenameActive = false; }; const completeCurrentRename = () => { if (this._activeRename !== renameHandle) return; completed = true; releaseRenderGuard(); this._activeRename = null; this.renderSessionTabs(); }; const cancelRename = () => { if (invalidated || completed) return; invalidated = true; editSettled = true; tabName.classList.remove('tab-name-renaming'); restoreOriginalChildren(); completeCurrentRename(); }; const finishRename = async ({ commit }) => { if (editSettled || invalidated) return; editSettled = true; tabName.classList.remove('tab-name-renaming'); // 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) { cancelRename(); return; } if (this._activeRename !== renameHandle) return; releaseRenderGuard(); const suffix = input.value.trim(); const fullName = parsed ? parsed.prefix + (suffix ? ': ' + suffix : '') : suffix; // An unchanged confirm puts the old label back, unless the editor opened // over a rename in flight: that label was repainted from the server's // older name, so show the in-flight name rather than make it look lost. if (fullName === shownName && !renameInFlight) restoreOriginalChildren(); else tabName.textContent = fullName || originalContent; // Skip the API call if the session vanished between focus and blur. The // queue applies the confirmed name to this.sessions before the re-render // below repaints from it (see _applyLocalSessionName()). if (this.sessions.has(sessionId)) { const result = await this._queueInlineSessionName(sessionId, fullName); if (invalidated || this._activeRename !== renameHandle || !this.sessions.has(sessionId)) return; // The queue reports a failure itself; the editor only puts its label back. if (result.status === 'failed') restoreOriginalChildren(); } // Re-render tabs to restore full tab structure completeCurrentRename(); }; // Register only after the input is wired so a throw above can't strand state. const renameHandle = { sessionId, cancel: cancelRename, }; this._activeRename = renameHandle; 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') { // Cancel, never commit. This used to clear the field and blur, and the // blur handler commits — so Escape RENAMED the session to an empty // string (measured: the tab fell back to its folder name and the server // stored ""), in every layout. cancelRename() marks the edit // invalidated, so the blur that follows the input's removal is a no-op. e.preventDefault(); cancelRename(); } }); }, // ═══════════════════════════════════════════════════════════════ // 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'); this._loadCaseDefaultGroup(); // Close on outside click (one-shot listener) const closeHandler = (e) => { if (!popover.contains(e.target) && !e.target.closest?.('.case-combobox-action')) { 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'); this._loadCaseDefaultGroup(); 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; }, // ── Default session group (server-side, per owner + case) ── // // Unlike the two checkboxes above (this browser's localStorage), the default // group lives on the server, because the server places a new session in it in // the same layout write that registers it. The value is a group ID; '' in the //