mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-10-09 08:59:40 +02:00
Merge pull request #357 from dignfei/feat/docker-adopt-existing-container
feat(docker): attach a case to an already-running container Conflicts came from work that landed after the PR was opened, and each is resolved onto the newer abstraction rather than by keeping the older code: - `defaultDockerCommandForMode` is registry-driven since #347, so the PR's `runsAsRoot` arm became `overlays.docker.rootCommand` (claude only). Claude Code still refuses `--dangerously-skip-permissions` as root in 2.1.261 and the refusal is visible only inside the container, so an adopted root container otherwise just shows a dead pane. Which flag to drop is a per-CLI fact, and `test/cli-registry-no-id-branching.test.ts` forbids expressing it as a branch. - The probe's mode list and its mode -> binary table both duplicated the registry. They now read `enabledCliIds()` / `discovery.binaries[0]`, which is also what fixes the merge's silent regression: the hand-written list predates `omp`, and the run menu gates every docker case on this probe, so owned containers would have lost that mode. `shell` needs no arm — it declares no binary, so it is dropped from the lookup and reported available regardless. - The per-mode `mode === 'claude' && !cliDir` chain in `tmux-manager.ts` is one `missingCliMessage(mode)` gate since #347; the PR's docker exemption moved onto it. Its test now pins the single gate instead of counting seven arms. - The create arm keeps #349's swap-limit warning filter, which the adopted arm never reaches; the run-mode list gains `omp` from #353. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TecFD9hvPYJ1mkkMtBQbT1
This commit is contained in:
+291
-252
@@ -20,15 +20,18 @@ import {
|
||||
type ApiResponse,
|
||||
type SessionColor,
|
||||
type SessionStatus,
|
||||
type SessionMode,
|
||||
type CodexConfig,
|
||||
type GeminiConfig,
|
||||
type AntigravityConfig,
|
||||
type PiConfig,
|
||||
type GrokConfig,
|
||||
type DeepSeekConfig,
|
||||
type OmpConfig,
|
||||
} from '../../types.js';
|
||||
import { Session, isAltScreenStripMode, isMuxAltScreenOnlyStripMode } from '../../session.js';
|
||||
import { Session, isAltScreenStripMode, isExternalCliMode, isMuxAltScreenOnlyStripMode } from '../../session.js';
|
||||
import { SseEvent } from '../sse-events.js';
|
||||
import { webviewCapabilities } from '../../webview-capabilities.js';
|
||||
import {
|
||||
CreateSessionSchema,
|
||||
SessionNameSchema,
|
||||
@@ -65,6 +68,7 @@ import {
|
||||
autoConfigureRalph,
|
||||
canAccessOwned,
|
||||
CASES_DIR,
|
||||
findPersistedSessionOrFail,
|
||||
findSessionOrFail,
|
||||
getAuthUser,
|
||||
isAdmin,
|
||||
@@ -79,6 +83,9 @@ import {
|
||||
validatePathWithinBase,
|
||||
} from '../route-helpers.js';
|
||||
import { canUsernameRunPrivilegedCommands, resolveClaudeModeForUsername } from '../../user-store.js';
|
||||
import { enabledClis, getCli } from '../../config/cli-registry/registry.js';
|
||||
import { resolveCliLaunchError } from '../../utils/cli-launcher.js';
|
||||
import { legacyConfigForMode } from '../../session-cli-registry-bridge.js';
|
||||
import { isMultiUserMode } from '../../config/multiuser.js';
|
||||
import { AUTH_COOKIE_NAME } from '../middleware/auth.js';
|
||||
import {
|
||||
@@ -137,6 +144,8 @@ import {
|
||||
toSessionDocker,
|
||||
} from '../../docker-hosts.js';
|
||||
import { LRUMap } from '../../utils/lru-map.js';
|
||||
import { findLatestOmpSessionId } from '../../utils/omp-session-resolver.js';
|
||||
import { scanOmpSessionsHistory } from '../../omp-transcript.js';
|
||||
import {
|
||||
getLastTranscriptResponse,
|
||||
isExternalCliTranscriptMode,
|
||||
@@ -352,12 +361,54 @@ export function _resetPasteRateBuckets(): void {
|
||||
*/
|
||||
async function clampExternalCliBypassForOwner(
|
||||
owner: string | undefined,
|
||||
codexConfig: CodexConfig | undefined,
|
||||
geminiConfig: GeminiConfig | undefined,
|
||||
antigravityConfig: AntigravityConfig | undefined,
|
||||
piConfig: PiConfig | undefined,
|
||||
grokConfig: GrokConfig | undefined,
|
||||
deepSeekConfig: DeepSeekConfig | undefined
|
||||
configs: Record<string, unknown>
|
||||
): Promise<Record<string, unknown>> {
|
||||
if (await canUsernameRunPrivilegedCommands(owner)) return configs;
|
||||
|
||||
const out = { ...configs };
|
||||
for (const entry of enabledClis()) {
|
||||
const field = entry.launch.legacyConfigField;
|
||||
if (!field) continue;
|
||||
// `privilegedParams[].param` names the REGISTRY param, so it has to be translated to the
|
||||
// legacy wire field on the way out — the same `legacyConfigAliases` hop `configSetenvValues`
|
||||
// already makes. Writing `param` straight through would put it in a DIFFERENT namespace
|
||||
// from every other `param` in the schema, and a name that is right in one and wrong in the
|
||||
// other is a SILENT no-op: no load error, no failing test, the clamp simply stops clamping.
|
||||
// Codex is where the two names differ (`bypassApprovals` vs `dangerouslyBypassApprovals`),
|
||||
// and `schema.ts` refuses an entry naming a param it never declared.
|
||||
const aliases = entry.launch.legacyConfigAliases ?? {};
|
||||
const existing = out[field] as Record<string, unknown> | undefined;
|
||||
let next = existing;
|
||||
for (const { param, clampTo, materializeWhenAbsent } of entry.capabilities.privilegedParams) {
|
||||
// MATERIALIZE vs ONLY-IF-SENT is the whole design of this clamp, and the two are not
|
||||
// interchangeable — see CliCapabilities.privilegedParams. Materialize where the CLI's
|
||||
// own absent-config default is ITSELF unsafe (gemini defaults to yolo; pi's default is
|
||||
// an interactive trust prompt the session user could just answer "yes" to), so a
|
||||
// caller who sends no config at all still gets clamped.
|
||||
if (next === undefined && !materializeWhenAbsent) continue;
|
||||
next = { ...(next ?? {}), [aliases[param] ?? param]: clampTo };
|
||||
}
|
||||
if (next !== existing) out[field] = next;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* Test hook, and the positional shape the clamp has always been called with in tests.
|
||||
*
|
||||
* The clamp itself is now generic over the registry, which is what makes a CUSTOM CLI's
|
||||
* privileged flag clampable with no code here — previously the five config objects were
|
||||
* named individually, so `privilegedParams` on anything outside that list was declared but
|
||||
* unreachable.
|
||||
*/
|
||||
export async function _clampExternalCliBypassForOwner(
|
||||
owner: string | undefined,
|
||||
codexConfig?: CodexConfig,
|
||||
geminiConfig?: GeminiConfig,
|
||||
antigravityConfig?: AntigravityConfig,
|
||||
piConfig?: PiConfig,
|
||||
grokConfig?: GrokConfig,
|
||||
deepSeekConfig?: DeepSeekConfig
|
||||
): Promise<{
|
||||
codexConfig: CodexConfig | undefined;
|
||||
geminiConfig: GeminiConfig | undefined;
|
||||
@@ -366,40 +417,30 @@ async function clampExternalCliBypassForOwner(
|
||||
grokConfig: GrokConfig | undefined;
|
||||
deepSeekConfig: DeepSeekConfig | undefined;
|
||||
}> {
|
||||
const granted = await canUsernameRunPrivilegedCommands(owner);
|
||||
if (granted) return { codexConfig, geminiConfig, antigravityConfig, piConfig, grokConfig, deepSeekConfig };
|
||||
// Non-granted: force codex/antigravity bypass off (only meaningful when a config was
|
||||
// sent) and materialize gemini to auto_edit (clamps an explicit 'yolo' and the yolo default)
|
||||
// and pi to --no-approve (clamps an explicit true AND pi's own "ask" default).
|
||||
const clampedCodex = codexConfig ? { ...codexConfig, dangerouslyBypassApprovals: false } : codexConfig;
|
||||
const clampedGemini: GeminiConfig = { ...(geminiConfig ?? {}), approvalMode: 'auto_edit' };
|
||||
const clampedAntigravity = antigravityConfig
|
||||
? { ...antigravityConfig, dangerouslySkipPermissions: false }
|
||||
: antigravityConfig;
|
||||
const clampedPi: PiConfig = { ...(piConfig ?? {}), approveProjectTrust: false };
|
||||
const clampedGrok = grokConfig ? { ...grokConfig, alwaysApprove: false } : grokConfig;
|
||||
const clampedDeepSeek = deepSeekConfig
|
||||
? { ...deepSeekConfig, permissionMode: 'workspace-write' as const }
|
||||
: deepSeekConfig;
|
||||
return {
|
||||
codexConfig: clampedCodex,
|
||||
geminiConfig: clampedGemini,
|
||||
antigravityConfig: clampedAntigravity,
|
||||
piConfig: clampedPi,
|
||||
grokConfig: clampedGrok,
|
||||
deepSeekConfig: clampedDeepSeek,
|
||||
const out = await clampExternalCliBypassForOwner(owner, {
|
||||
codexConfig,
|
||||
geminiConfig,
|
||||
antigravityConfig,
|
||||
piConfig,
|
||||
grokConfig,
|
||||
deepSeekConfig,
|
||||
});
|
||||
return out as {
|
||||
codexConfig: CodexConfig | undefined;
|
||||
geminiConfig: GeminiConfig | undefined;
|
||||
antigravityConfig: AntigravityConfig | undefined;
|
||||
piConfig: PiConfig | undefined;
|
||||
grokConfig: GrokConfig | undefined;
|
||||
deepSeekConfig: DeepSeekConfig | undefined;
|
||||
};
|
||||
}
|
||||
|
||||
/** Test hook: the clamp is the multi-user safety gate for the external CLIs' privileged flags. */
|
||||
export const _clampExternalCliBypassForOwner = clampExternalCliBypassForOwner;
|
||||
|
||||
/**
|
||||
* Env-var keys a non-granted owner must not be able to set, because each one
|
||||
* hands back privilege the config clamp above just removed — or, for the last,
|
||||
* redirects a credential the server injects.
|
||||
* hands back privilege the config clamp above just removed, or redirects a
|
||||
* credential-resolution endpoint.
|
||||
*
|
||||
* All are DeepSeek's, and all are reachable because `DSH_*` and `DEEPSEEK_*` are
|
||||
* The DeepSeek three are reachable because `DSH_*` and `DEEPSEEK_*` are
|
||||
* allowlisted `envOverrides` prefixes (schemas.ts) — which they have to be, since
|
||||
* that is also how a user configures the harness's non-privileged knobs.
|
||||
*
|
||||
@@ -410,26 +451,38 @@ export const _clampExternalCliBypassForOwner = clampExternalCliBypassForOwner;
|
||||
* - `DSH_HOME` points the launcher at a profile tree, and a profile's plugin code
|
||||
* executes at BOOT, before any approval row can apply. A user who can write a
|
||||
* workspace can put a profile in it, so this is the wider of the two.
|
||||
* - `DEEPSEEK_BASE_URL` aims the provider endpoint, and `_configureDeepSeek()`
|
||||
* - `DEEPSEEK_BASE_URL` aims the provider endpoint, and `_configureCliEnv()`
|
||||
* forwards the SERVER's own `DEEPSEEK_API_KEY` into every dsh pane before
|
||||
* `applyEnvOverrides()` runs — so a non-granted owner who could set the base
|
||||
* URL would have the operator's API key sent as a bearer credential to a host
|
||||
* of their choosing. (`DEEPSEEK_API_KEY` itself stays overridable: supplying
|
||||
* your OWN key removes privilege rather than granting it.)
|
||||
* - `OMP_AUTH_BROKER_URL`/`OMP_AUTH_BROKER_TOKEN` are where omp resolves
|
||||
* credentials from — the same shape as `DEEPSEEK_BASE_URL` above, reachable
|
||||
* because `OMP_*` is an allowlisted prefix. Unlike DeepSeek, Codeman does not
|
||||
* forward any operator-held key into an omp pane today (omp's provider
|
||||
* credentials live in `~/.omp` config files, not env vars), so there is no
|
||||
* known concrete exfiltration path yet — clamped defensively anyway, since a
|
||||
* non-granted owner redirecting where a shared multi-tenant deployment
|
||||
* resolves auth from is not something to allow silently (found in
|
||||
* Ark0N/Codeman#353 review; omp's own knobs are otherwise mostly `PI_*`,
|
||||
* already allowlisted for pi and not addressed here — see resolveOmpHome()).
|
||||
*/
|
||||
const OWNER_CLAMPED_ENV_KEYS = ['DSH_PERMISSION_MODE', 'DSH_HOME', 'DEEPSEEK_BASE_URL'] as const;
|
||||
function ownerClampedEnvKeys(): string[] {
|
||||
return enabledClis().flatMap((entry) => entry.capabilities.privilegedEnvKeys);
|
||||
}
|
||||
|
||||
/**
|
||||
* Env-var half of the multi-user bypass clamp.
|
||||
*
|
||||
* `clampExternalCliBypassForOwner()` clamps the per-CLI CONFIG, and for every CLI
|
||||
* but DeepSeek that is the whole story. Here it is not: `applyEnvOverrides()` runs
|
||||
* AFTER `_configureDeepSeek()` in tmux-manager, so an override sent on the SAME
|
||||
* AFTER `_configureCliEnv()` in tmux-manager, so an override sent on the SAME
|
||||
* request lands last and wins, and a non-granted owner could restore
|
||||
* `danger-full-access` on the very request the config clamp downgraded.
|
||||
*
|
||||
* Keys are DROPPED rather than rewritten: dropping falls through to what
|
||||
* `_configureDeepSeek()` exports, which is the clamped config and the server's own
|
||||
* `_configureCliEnv()` exports, which is the clamped config and the server's own
|
||||
* `DSH_HOME`, i.e. exactly the intended state. No-op in single-user mode and for a
|
||||
* granted owner, like every other clamp here
|
||||
* (`canUsernameRunPrivilegedCommands()` returns true when `!isMultiUserMode()`),
|
||||
@@ -440,38 +493,17 @@ async function clampEnvOverridesForOwner(
|
||||
envOverrides: Record<string, string> | undefined
|
||||
): Promise<Record<string, string> | undefined> {
|
||||
if (!envOverrides) return envOverrides;
|
||||
if (!OWNER_CLAMPED_ENV_KEYS.some((key) => key in envOverrides)) return envOverrides;
|
||||
const keys = ownerClampedEnvKeys();
|
||||
if (!keys.some((key) => key in envOverrides)) return envOverrides;
|
||||
if (await canUsernameRunPrivilegedCommands(owner)) return envOverrides;
|
||||
const clamped = { ...envOverrides };
|
||||
for (const key of OWNER_CLAMPED_ENV_KEYS) delete clamped[key];
|
||||
for (const key of keys) delete clamped[key];
|
||||
return clamped;
|
||||
}
|
||||
|
||||
/** Test hook: the env-var half of the same multi-user safety gate. */
|
||||
export const _clampEnvOverridesForOwner = clampEnvOverridesForOwner;
|
||||
|
||||
/**
|
||||
* Why a DeepSeek session cannot start, or null when it can.
|
||||
*
|
||||
* Availability for this mode is TWO questions, not one, because `dsh` is a
|
||||
* profile launcher rather than an agent: the binary must resolve (and prove it
|
||||
* is the harness and not Debian's dancer's shell), AND a profile that can occupy
|
||||
* a pane must exist. Reporting only the first would let the Run button spawn a
|
||||
* pane that dies instantly, which is the single most confusing failure this mode
|
||||
* can produce, so each half gets its own actionable message.
|
||||
*
|
||||
* A profile named EXPLICITLY is checked on both counts: existence, and whether
|
||||
* it is pane-capable — `web` serves a browser UI and `headless` answers one task
|
||||
* and exits, so both would present as "the tab immediately died".
|
||||
*/
|
||||
async function resolveDeepSeekLaunchError(requestedProfile?: string): Promise<string | null> {
|
||||
// Thin async wrapper: the implementation moved into the resolver module so
|
||||
// CRON fires can ask the same question before constructing a Session; the
|
||||
// dynamic import keeps this file's startup free of the probe machinery.
|
||||
const { resolveDeepSeekLaunchError: impl } = await import('../../utils/deepseek-cli-resolver.js');
|
||||
return impl(requestedProfile);
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
// Agent wait helpers (shared by GET /wait, GET /wait-output, POST /input)
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
@@ -745,6 +777,36 @@ async function injectAgentSkill(casePath: string): Promise<void> {
|
||||
// bypassing the `workspaceHooksEnabled` setting. Route handlers here resolve the
|
||||
// setting through the ConfigPort (tests stub it) and pass it as the second arg.
|
||||
|
||||
/**
|
||||
* A "Resume"/"continue" request for a NEW omp-mode session (the frontend's
|
||||
* resumeHistorySession(), or anyone hitting the API directly) carries
|
||||
* `continueSession: true` but no id — omp has none to give it, since Codeman
|
||||
* has never tracked its own conversation UUID. Left as `--continue`, that
|
||||
* picks whichever session file in the directory is newest, which silently
|
||||
* drifts to the WRONG conversation the moment a second omp session (this
|
||||
* one, a sibling worker, a stray manual run) has touched the same directory
|
||||
* more recently. Resolve the real id up front instead, same as the
|
||||
* dead-pane-respawn path in session.ts does, so even the FIRST relaunch of a
|
||||
* resumed conversation is pinned rather than guessed.
|
||||
*/
|
||||
export function resolveOmpConfigForCreate(
|
||||
mode: SessionMode,
|
||||
workingDir: string,
|
||||
ompConfig: OmpConfig | undefined
|
||||
): OmpConfig | undefined {
|
||||
if (mode !== 'omp') return undefined;
|
||||
if (!ompConfig || ompConfig.resumeSessionId || !ompConfig.continueSession) {
|
||||
return ompConfig;
|
||||
}
|
||||
const resolvedId = findLatestOmpSessionId(workingDir);
|
||||
if (!resolvedId) {
|
||||
console.warn(
|
||||
`[Session] OMP: no session file found under ${workingDir} to pin --resume; falling back to ambiguous --continue`
|
||||
);
|
||||
}
|
||||
return resolvedId ? { ...ompConfig, resumeSessionId: resolvedId } : ompConfig;
|
||||
}
|
||||
|
||||
export function registerSessionRoutes(
|
||||
app: FastifyInstance,
|
||||
ctx: SessionPort & EventPort & ConfigPort & InfraPort & AuthPort & TabLayoutPort
|
||||
@@ -761,6 +823,10 @@ export function registerSessionRoutes(
|
||||
if (sessionToken) {
|
||||
ctx.authSessions?.delete(sessionToken);
|
||||
}
|
||||
// The web-tab proxy authenticates on capabilities, not on this cookie, so a
|
||||
// logout has to retire them too or every dashboard URL opened during this
|
||||
// login keeps relaying without one (WebviewCapabilityStore.revokeOwner).
|
||||
webviewCapabilities.revokeOwner(ownerFor(req));
|
||||
reply.clearCookie(AUTH_COOKIE_NAME, { path: '/' });
|
||||
return {};
|
||||
});
|
||||
@@ -826,7 +892,10 @@ export function registerSessionRoutes(
|
||||
// Multi-user: shell mode is arbitrary command execution as the host account,
|
||||
// gated behind the same grant as bypass (section 6.3). Resolve the owner's grant
|
||||
// from the store so a GRANTED regular user is not wrongly denied (AuthUser role alone can't tell).
|
||||
if (body.mode === 'shell' && !(await canUsernameRunPrivilegedCommands(owner))) {
|
||||
if (
|
||||
getCli(body.mode ?? 'claude')?.capabilities.privilegedCommandGate &&
|
||||
!(await canUsernameRunPrivilegedCommands(owner))
|
||||
) {
|
||||
return createErrorResponse(ApiErrorCode.FORBIDDEN, 'Shell sessions require the can-bypass-permissions grant');
|
||||
}
|
||||
|
||||
@@ -859,14 +928,11 @@ export function registerSessionRoutes(
|
||||
// repos that POST /api/sessions can target, as those may have hand-authored
|
||||
// values).
|
||||
const managedCasesBase = resolveCasesDir(getAuthUser(req));
|
||||
// `!isExternalCliMode()` is byte-identical to the eight-mode `!==` chain it replaces
|
||||
// (claude and shell are the two non-external modes) and, unlike the chain, cannot fall
|
||||
// behind the next CLI added.
|
||||
const canStripDisk =
|
||||
body.mode !== 'opencode' &&
|
||||
body.mode !== 'codex' &&
|
||||
body.mode !== 'gemini' &&
|
||||
body.mode !== 'antigravity' &&
|
||||
body.mode !== 'pi' &&
|
||||
body.mode !== 'grok' &&
|
||||
body.mode !== 'deepseek' &&
|
||||
!isExternalCliMode(body.mode ?? 'claude') &&
|
||||
body.envOverrides &&
|
||||
Object.keys(body.envOverrides).length > 0 &&
|
||||
(workingDir.startsWith(CASES_DIR + '/') || workingDir.startsWith(managedCasesBase + '/'));
|
||||
@@ -918,52 +984,24 @@ export function registerSessionRoutes(
|
||||
}
|
||||
}
|
||||
|
||||
// Check OpenCode availability if requested. The error text comes from the
|
||||
// resolver (formatCliNotFoundMessage) so it names where resolution looked —
|
||||
// server PATH, login shell, common directories — same for the modes below.
|
||||
if (body.mode === 'opencode') {
|
||||
const { isOpenCodeAvailable, getOpenCodeNotFoundMessage } = await import('../../utils/opencode-cli-resolver.js');
|
||||
if (!isOpenCodeAvailable()) {
|
||||
return createErrorResponse(ApiErrorCode.OPERATION_FAILED, getOpenCodeNotFoundMessage());
|
||||
}
|
||||
}
|
||||
|
||||
// Check Codex availability if requested
|
||||
if (body.mode === 'codex') {
|
||||
const { isCodexAvailable, getCodexNotFoundMessage } = await import('../../utils/codex-cli-resolver.js');
|
||||
if (!isCodexAvailable()) {
|
||||
return createErrorResponse(ApiErrorCode.OPERATION_FAILED, getCodexNotFoundMessage());
|
||||
}
|
||||
}
|
||||
|
||||
// Check Gemini availability if requested
|
||||
if (body.mode === 'gemini') {
|
||||
const { isGeminiAvailable, getGeminiNotFoundMessage } = await import('../../utils/gemini-cli-resolver.js');
|
||||
if (!isGeminiAvailable()) {
|
||||
return createErrorResponse(ApiErrorCode.OPERATION_FAILED, getGeminiNotFoundMessage());
|
||||
}
|
||||
}
|
||||
if (body.mode === 'antigravity') {
|
||||
const { isAntigravityAvailable, getAntigravityNotFoundMessage } =
|
||||
await import('../../utils/antigravity-cli-resolver.js');
|
||||
if (!isAntigravityAvailable()) {
|
||||
return createErrorResponse(ApiErrorCode.OPERATION_FAILED, getAntigravityNotFoundMessage());
|
||||
}
|
||||
}
|
||||
if (body.mode === 'pi') {
|
||||
const { isPiAvailable, getPiNotFoundMessage } = await import('../../utils/pi-cli-resolver.js');
|
||||
if (!isPiAvailable()) {
|
||||
return createErrorResponse(ApiErrorCode.OPERATION_FAILED, getPiNotFoundMessage());
|
||||
}
|
||||
}
|
||||
if (body.mode === 'deepseek') {
|
||||
const err = await resolveDeepSeekLaunchError(body.deepSeekConfig?.profile);
|
||||
if (err) return createErrorResponse(ApiErrorCode.OPERATION_FAILED, err);
|
||||
}
|
||||
if (body.mode === 'grok') {
|
||||
const { isGrokAvailable, getGrokNotFoundMessage } = await import('../../utils/grok-cli-resolver.js');
|
||||
if (!isGrokAvailable()) {
|
||||
return createErrorResponse(ApiErrorCode.OPERATION_FAILED, getGrokNotFoundMessage());
|
||||
// Refuse up front if the requested CLI cannot start, rather than spawning a pane that
|
||||
// dies on `command not found`. The message comes from the resolver, so it names where
|
||||
// resolution actually looked (server PATH, login shell, the entry's search dirs); a
|
||||
// LAUNCHER CLI answers with its own more specific reason instead — for dsh, whether the
|
||||
// binary is missing, no pane-capable profile exists, or the profile the caller NAMED
|
||||
// cannot drive a pane, which are three different things to go and fix.
|
||||
//
|
||||
// Scoped to EXTERNAL CLIs, matching what this route has always pre-flighted: claude and
|
||||
// shell deliberately fall through to tmux-manager's own not-found throw instead, and
|
||||
// pulling them forward here would change which error a missing claude produces.
|
||||
const requestedMode = body.mode ?? 'claude';
|
||||
if (getCli(requestedMode)?.capabilities.external) {
|
||||
const cliLaunchError = await resolveCliLaunchError(
|
||||
requestedMode,
|
||||
legacyConfigForMode(requestedMode, body as unknown as Record<string, unknown>)
|
||||
);
|
||||
if (cliLaunchError) {
|
||||
return createErrorResponse(ApiErrorCode.OPERATION_FAILED, cliLaunchError);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1000,25 +1038,25 @@ export function registerSessionRoutes(
|
||||
const globalNice = await ctx.getGlobalNiceConfig();
|
||||
const modelConfig = await ctx.getModelConfig();
|
||||
const mode = body.mode || 'claude';
|
||||
// Where a model override comes from is a capability, and the three answers are
|
||||
// genuinely different mechanisms:
|
||||
// 'flag' — the CLI takes --model, so read the value the caller sent
|
||||
// in that CLI's own config object.
|
||||
// 'claude-settings-file' — claude alone, whose model is written to
|
||||
// <case>/.claude/settings.local.json rather than passed as
|
||||
// a flag, so the app-wide default applies here.
|
||||
// 'none' — shell has no model; deepseek's is a composition entry in
|
||||
// the profile's config tree, not a session field
|
||||
// (docs/deepseek-integration.md). Both get nothing.
|
||||
const modelSource = getCli(mode)?.capabilities.model;
|
||||
const model =
|
||||
mode === 'opencode'
|
||||
? body.openCodeConfig?.model
|
||||
: mode === 'codex'
|
||||
? body.codexConfig?.model
|
||||
: mode === 'gemini'
|
||||
? body.geminiConfig?.model
|
||||
: mode === 'antigravity'
|
||||
? body.antigravityConfig?.model
|
||||
: mode === 'pi'
|
||||
? body.piConfig?.model
|
||||
: mode === 'grok'
|
||||
? body.grokConfig?.model
|
||||
: // DeepSeek's model is a composition entry in the profile's config
|
||||
// tree, not a session flag, so there is deliberately nothing to
|
||||
// read here (see docs/deepseek-integration.md).
|
||||
mode !== 'shell' && mode !== 'deepseek'
|
||||
? modelConfig?.defaultModel || undefined
|
||||
: undefined;
|
||||
modelSource?.source === 'flag'
|
||||
? (legacyConfigForMode(mode, body as unknown as Record<string, unknown>)?.[modelSource.param ?? 'model'] as
|
||||
| string
|
||||
| undefined)
|
||||
: modelSource?.source === 'claude-settings-file'
|
||||
? modelConfig?.defaultModel || undefined
|
||||
: undefined;
|
||||
const claudeModeConfig = await ctx.getClaudeModeConfig();
|
||||
// Section 6.3: force non-granted users to a classifier-guarded mode.
|
||||
const effectiveClaudeMode = await resolveClaudeModeForUsername(claudeModeConfig.claudeMode, owner);
|
||||
@@ -1030,7 +1068,7 @@ export function registerSessionRoutes(
|
||||
piConfig: gatedPiConfig,
|
||||
grokConfig: gatedGrokConfig,
|
||||
deepSeekConfig: gatedDeepSeekConfig,
|
||||
} = await clampExternalCliBypassForOwner(
|
||||
} = await _clampExternalCliBypassForOwner(
|
||||
owner,
|
||||
body.codexConfig,
|
||||
body.geminiConfig,
|
||||
@@ -1057,6 +1095,7 @@ export function registerSessionRoutes(
|
||||
piConfig: mode === 'pi' ? gatedPiConfig : undefined,
|
||||
grokConfig: mode === 'grok' ? gatedGrokConfig : undefined,
|
||||
deepSeekConfig: mode === 'deepseek' ? gatedDeepSeekConfig : undefined,
|
||||
ompConfig: resolveOmpConfigForCreate(mode, workingDir, body.ompConfig),
|
||||
resumeSessionId: validatedResumeId,
|
||||
envOverrides: await clampEnvOverridesForOwner(owner, body.envOverrides),
|
||||
effort: body.effort,
|
||||
@@ -1072,7 +1111,7 @@ export function registerSessionRoutes(
|
||||
await ctx.setupSessionListeners(session);
|
||||
// Pre-seed the agent skill's preamble cache so its §0 bootstrap is a two-line
|
||||
// loader (see seedAgentSessionPreamble). Local claude sessions only; best-effort.
|
||||
if (mode === 'claude' && !remote && (await ctx.getAgentSkillEnabled())) {
|
||||
if (getCli(mode)?.capabilities.agentSkillInjection && !remote && (await ctx.getAgentSkillEnabled())) {
|
||||
await seedAgentSessionPreamble(session.id).catch((err: unknown) =>
|
||||
console.warn(`[agent-skill] preamble seed failed for ${session.id}: ${getErrorMessage(err)}`)
|
||||
);
|
||||
@@ -1125,9 +1164,26 @@ export function registerSessionRoutes(
|
||||
const query = req.query as { killMux?: string };
|
||||
const killMux = query.killMux !== 'false'; // Default to true
|
||||
|
||||
// Security: owner-scoped lookup 404s foreign/missing sessions uniformly (no existence leak, no cross-user kill).
|
||||
const session = findSessionOrFail(ctx, id, req);
|
||||
// A resumed/detached-but-never-live row (e.g. a non-claude "Resume" that
|
||||
// relaunched into a NEW session and wants to retire the old one it can no
|
||||
// longer reattach to) has no entry in ctx.sessions at all — only in
|
||||
// persisted state. Fall back to removing that persisted record directly
|
||||
// rather than 404ing: the caller means "make this row go away", and a
|
||||
// stale duplicate row is exactly what's left behind otherwise. Pinned
|
||||
// sessions keep their existing demote-not-delete protection.
|
||||
if (!ctx.sessions.has(id)) {
|
||||
// Called for its existence/ownership 404 side effect only — demoteOrRemoveSession
|
||||
// below re-looks-up the record by id, so the returned SessionState is unused here.
|
||||
findPersistedSessionOrFail(ctx.store, id, req);
|
||||
ctx.store.demoteOrRemoveSession(id);
|
||||
// Mirrors the broadcast at the tail of the live-session cleanup path
|
||||
// (_doCleanupSession in server.ts) — without it, other open tabs keep
|
||||
// showing the retired row until their next unrelated fetch.
|
||||
ctx.broadcast(SseEvent.SessionDeleted, { id });
|
||||
return {};
|
||||
}
|
||||
|
||||
const session = findSessionOrFail(ctx, id, req);
|
||||
await ctx.cleanupSession(session.id, killMux, 'user_delete');
|
||||
return {};
|
||||
});
|
||||
@@ -1277,19 +1333,21 @@ export function registerSessionRoutes(
|
||||
}
|
||||
|
||||
try {
|
||||
// Auto-detect completion phrase from CLAUDE.md BEFORE starting (only if globally enabled and not explicitly disabled by user)
|
||||
// Ralph tracker is not supported for opencode / codex / gemini / antigravity / pi sessions.
|
||||
// Keep this list in step with isExternalCliMode(): _processExpensiveParsers() returns early
|
||||
// for those modes, so a tracker enabled here would never be fed, and the session would
|
||||
// still report ralphEnabled + Ralph UI state that no other external CLI shows.
|
||||
// Auto-detect completion phrase from CLAUDE.md BEFORE starting (only if globally
|
||||
// enabled and not explicitly disabled by user).
|
||||
//
|
||||
// `isExternalCliMode()` is what the eight-mode `!==` chain this replaces was FOR: its
|
||||
// own comment asked the next person to keep the list in step with that predicate by
|
||||
// hand. Calling it instead is byte-identical today (claude and shell are the two
|
||||
// non-external modes, exactly what the chain admitted) and cannot drift.
|
||||
//
|
||||
// ⚠️ Deliberately NOT `capabilities.ralph`, which the quick-start path below reads:
|
||||
// that capability is claude-only, so using it here would stop auto-enabling Ralph for
|
||||
// SHELL sessions, which this path has always done. The two paths genuinely disagree
|
||||
// about shell, and they disagree upstream too — reconciling them is a behaviour change
|
||||
// and belongs in its own PR, not in a refactor that is meant to change nothing.
|
||||
if (
|
||||
session.mode !== 'opencode' &&
|
||||
session.mode !== 'codex' &&
|
||||
session.mode !== 'gemini' &&
|
||||
session.mode !== 'antigravity' &&
|
||||
session.mode !== 'pi' &&
|
||||
session.mode !== 'grok' &&
|
||||
session.mode !== 'deepseek' &&
|
||||
!isExternalCliMode(session.mode) &&
|
||||
ctx.store.getConfig().ralphEnabled &&
|
||||
!session.ralphTracker.autoEnableDisabled
|
||||
) {
|
||||
@@ -2028,7 +2086,7 @@ export function registerSessionRoutes(
|
||||
// Codex sessions don't write to ~/.claude/projects — their transcripts
|
||||
// live in ~/.codex/sessions/**. Branch to a Codex-specific reader so the
|
||||
// response-viewer works for Codex panes too.
|
||||
if (session.mode === 'codex') {
|
||||
if (getCli(session.mode)?.capabilities.transcript === 'codex-rollout') {
|
||||
const codexQuery = req.query as { context?: string };
|
||||
return await readCodexLastResponse(session, codexQuery.context === 'full');
|
||||
}
|
||||
@@ -2048,7 +2106,7 @@ export function registerSessionRoutes(
|
||||
// and return "nothing said yet" forever — an agent polling that worker
|
||||
// would starve on an answer that exists. Those configurations keep the
|
||||
// pane segmenter below: coarse, but the real conversation.
|
||||
if (session.mode === 'deepseek' && !session.docker && !session.remote) {
|
||||
if (getCli(session.mode)?.capabilities.transcript === 'deepseek-zstd' && !session.docker && !session.remote) {
|
||||
const deepSeekQuery = req.query as { context?: string };
|
||||
const full = deepSeekQuery.context === 'full';
|
||||
const transcript = await readDeepSeekLastResponse(session, { blocks: full });
|
||||
@@ -2191,7 +2249,7 @@ export function registerSessionRoutes(
|
||||
const WINDOW_MS = 15_000;
|
||||
const otherSubmits: number[] = [];
|
||||
for (const s of ctx.sessions.values()) {
|
||||
if (s.id !== session.id && s.mode === 'codex' && s.lastSubmitAt) {
|
||||
if (s.id !== session.id && getCli(s.mode)?.capabilities.transcript === 'codex-rollout' && s.lastSubmitAt) {
|
||||
otherSubmits.push(s.lastSubmitAt);
|
||||
}
|
||||
}
|
||||
@@ -2536,7 +2594,8 @@ export function registerSessionRoutes(
|
||||
// During long thinking phases, Ink rewrites the same rows thousands of times
|
||||
// (500KB+). Without stripping, tail mode returns only spinner frames and
|
||||
// the terminal appears empty when switching tabs.
|
||||
let strippedBuffer = session.mode === 'shell' ? rawBuffer : stripInkRedrawBloat(rawBuffer);
|
||||
let strippedBuffer =
|
||||
getCli(session.mode)?.capabilities.stripInkBloat === false ? rawBuffer : stripInkRedrawBloat(rawBuffer);
|
||||
|
||||
// Strip alt-screen toggles and scrollback-erase from Codex/Claude byte
|
||||
// streams. xterm.js obeys them by switching to its scrollback-less alt
|
||||
@@ -2858,6 +2917,7 @@ export function registerSessionRoutes(
|
||||
piConfig,
|
||||
grokConfig,
|
||||
deepSeekConfig,
|
||||
ompConfig,
|
||||
envOverrides,
|
||||
effort,
|
||||
parentSessionId,
|
||||
@@ -2865,7 +2925,7 @@ export function registerSessionRoutes(
|
||||
|
||||
// Multi-user: shell mode is arbitrary host-account execution, gated by the grant.
|
||||
// Resolve the owner's grant from the store so a GRANTED regular user is not wrongly denied.
|
||||
if (mode === 'shell' && !(await canUsernameRunPrivilegedCommands(owner))) {
|
||||
if (getCli(mode)?.capabilities.privilegedCommandGate && !(await canUsernameRunPrivilegedCommands(owner))) {
|
||||
return createErrorResponse(ApiErrorCode.FORBIDDEN, 'Shell sessions require the can-bypass-permissions grant');
|
||||
}
|
||||
|
||||
@@ -2908,6 +2968,7 @@ export function registerSessionRoutes(
|
||||
piConfig ||
|
||||
grokConfig ||
|
||||
deepSeekConfig ||
|
||||
ompConfig ||
|
||||
openCodeConfig
|
||||
) {
|
||||
return createErrorResponse(
|
||||
@@ -2942,6 +3003,7 @@ export function registerSessionRoutes(
|
||||
piConfig ||
|
||||
grokConfig ||
|
||||
deepSeekConfig ||
|
||||
ompConfig ||
|
||||
openCodeConfig
|
||||
) {
|
||||
return createErrorResponse(
|
||||
@@ -2971,7 +3033,9 @@ export function registerSessionRoutes(
|
||||
// The probe already exec'd into the container; carry its facts onto the
|
||||
// live session so the launch chain does not have to re-ask.
|
||||
sessionDocker.runsAsRoot = probe.runsAsRoot;
|
||||
if (mode !== 'shell' && !probe.availableModes?.includes(mode)) {
|
||||
// No `mode !== 'shell'` arm: a mode with no binary of its own is reported
|
||||
// available by the probe unconditionally, so this reads the same answer for it.
|
||||
if (!probe.availableModes?.includes(mode)) {
|
||||
return createErrorResponse(
|
||||
ApiErrorCode.OPERATION_FAILED,
|
||||
`"${mode}" is not installed in container "${sessionDocker.containerName}". Adoption never modifies the container — install it inside, or pick another mode.`
|
||||
@@ -3016,69 +3080,35 @@ export function registerSessionRoutes(
|
||||
|
||||
casePath = dockerCase.hostWorkspacePath; // a REAL host dir (bind-mounted into the container)
|
||||
docker = sessionDocker;
|
||||
// Seed resume so a relaunch resumes the case's last conversation from the
|
||||
// bind-mounted transcript (decision: resume-on-start default ON).
|
||||
if (sessionDocker.resumeOnStart && dockerCase.lastClaudeSessionId) {
|
||||
// Seed only Claude's resume id. Codex, Gemini, and the other CLIs have
|
||||
// separate conversation stores and must never receive a Claude UUID.
|
||||
if (mode === 'claude' && sessionDocker.resumeOnStart && dockerCase.lastClaudeSessionId) {
|
||||
dockerResumeId = dockerCase.lastClaudeSessionId;
|
||||
}
|
||||
} else {
|
||||
// Check OpenCode availability if requested. Error text comes from the
|
||||
// resolver so it carries the resolution diagnostics; same for the modes below.
|
||||
if (mode === 'opencode') {
|
||||
const { isOpenCodeAvailable, getOpenCodeNotFoundMessage } =
|
||||
await import('../../utils/opencode-cli-resolver.js');
|
||||
if (!isOpenCodeAvailable()) {
|
||||
return createErrorResponse(ApiErrorCode.OPERATION_FAILED, getOpenCodeNotFoundMessage());
|
||||
// Same pre-flight as POST /api/sessions: refuse before spawning a pane that would die
|
||||
// on `command not found`, with the resolver's own diagnostics, and a launcher CLI's
|
||||
// more specific reason (dsh: binary vs no pane-capable profile vs the profile the
|
||||
// caller named). External CLIs only — claude and shell fall through to tmux-manager's
|
||||
// own not-found throw, exactly as before.
|
||||
if (getCli(mode)?.capabilities.external) {
|
||||
const qsLaunchError = await resolveCliLaunchError(
|
||||
mode,
|
||||
legacyConfigForMode(mode, {
|
||||
openCodeConfig,
|
||||
codexConfig,
|
||||
geminiConfig,
|
||||
antigravityConfig,
|
||||
piConfig,
|
||||
grokConfig,
|
||||
deepSeekConfig,
|
||||
} as unknown as Record<string, unknown>)
|
||||
);
|
||||
if (qsLaunchError) {
|
||||
return createErrorResponse(ApiErrorCode.OPERATION_FAILED, qsLaunchError);
|
||||
}
|
||||
}
|
||||
|
||||
// Check Codex availability if requested
|
||||
if (mode === 'codex') {
|
||||
const { isCodexAvailable, getCodexNotFoundMessage } = await import('../../utils/codex-cli-resolver.js');
|
||||
if (!isCodexAvailable()) {
|
||||
return createErrorResponse(ApiErrorCode.OPERATION_FAILED, getCodexNotFoundMessage());
|
||||
}
|
||||
}
|
||||
|
||||
// Check Gemini availability if requested
|
||||
if (mode === 'gemini') {
|
||||
const { isGeminiAvailable, getGeminiNotFoundMessage } = await import('../../utils/gemini-cli-resolver.js');
|
||||
if (!isGeminiAvailable()) {
|
||||
return createErrorResponse(ApiErrorCode.OPERATION_FAILED, getGeminiNotFoundMessage());
|
||||
}
|
||||
}
|
||||
|
||||
// Check Antigravity availability if requested
|
||||
if (mode === 'antigravity') {
|
||||
const { isAntigravityAvailable, getAntigravityNotFoundMessage } =
|
||||
await import('../../utils/antigravity-cli-resolver.js');
|
||||
if (!isAntigravityAvailable()) {
|
||||
return createErrorResponse(ApiErrorCode.OPERATION_FAILED, getAntigravityNotFoundMessage());
|
||||
}
|
||||
}
|
||||
|
||||
// Check Pi availability if requested
|
||||
if (mode === 'pi') {
|
||||
const { isPiAvailable, getPiNotFoundMessage } = await import('../../utils/pi-cli-resolver.js');
|
||||
if (!isPiAvailable()) {
|
||||
return createErrorResponse(ApiErrorCode.OPERATION_FAILED, getPiNotFoundMessage());
|
||||
}
|
||||
}
|
||||
|
||||
// Check Grok availability if requested
|
||||
if (mode === 'grok') {
|
||||
const { isGrokAvailable, getGrokNotFoundMessage } = await import('../../utils/grok-cli-resolver.js');
|
||||
if (!isGrokAvailable()) {
|
||||
return createErrorResponse(ApiErrorCode.OPERATION_FAILED, getGrokNotFoundMessage());
|
||||
}
|
||||
}
|
||||
|
||||
// Check DeepSeek Harness availability if requested (binary AND a pane-capable profile).
|
||||
if (mode === 'deepseek') {
|
||||
const err = await resolveDeepSeekLaunchError(deepSeekConfig?.profile);
|
||||
if (err) return createErrorResponse(ApiErrorCode.OPERATION_FAILED, err);
|
||||
}
|
||||
|
||||
// Resolve case path: check linked-cases registry first, then fall back to CASES_DIR.
|
||||
// This mirrors the behaviour of resolveCasePath() in case-routes so that linked
|
||||
// external project directories are honoured by quick-start just like regular case routes.
|
||||
@@ -3125,14 +3155,15 @@ export function registerSessionRoutes(
|
||||
writeFileSync(join(resolvedCasePath, 'CLAUDE.md'), claudeMd);
|
||||
|
||||
// Write .claude/settings.local.json with hooks for desktop notifications
|
||||
// (Claude-specific — OpenCode, Codex, Gemini, Antigravity, Pi and Grok use their own systems)
|
||||
// (Claude-specific — OpenCode, Codex, Gemini, Antigravity, Pi, Grok, DeepSeek and OMP use their own systems)
|
||||
if (
|
||||
mode !== 'opencode' &&
|
||||
mode !== 'codex' &&
|
||||
mode !== 'gemini' &&
|
||||
mode !== 'antigravity' &&
|
||||
mode !== 'pi' &&
|
||||
mode !== 'grok'
|
||||
mode !== 'grok' &&
|
||||
mode !== 'omp'
|
||||
) {
|
||||
await writeHooksConfig(resolvedCasePath);
|
||||
}
|
||||
@@ -3148,7 +3179,7 @@ export function registerSessionRoutes(
|
||||
// reads `.claude` hooks, so a shell/codex quick-start should not author a block
|
||||
// of its own. Skipped for remote cases — resolvedCasePath is a REMOTE path that
|
||||
// doesn't exist on the local filesystem.
|
||||
if (mode === 'claude') {
|
||||
if (getCli(mode)?.capabilities.hooks === 'always') {
|
||||
await applyWorkspaceHooks(resolvedCasePath, await ctx.getWorkspaceHooksEnabled());
|
||||
} else {
|
||||
await refreshStaleCodemanHooks(resolvedCasePath).catch(() => {});
|
||||
@@ -3160,7 +3191,7 @@ export function registerSessionRoutes(
|
||||
// (`.claude/skills/` is a Claude Code surface); skipped for remote cases, whose
|
||||
// casePath lives on another host. Docker cases qualify: hostWorkspacePath is a
|
||||
// real host dir and the skill crosses the bind mount like the rest of `.claude/`.
|
||||
if (!remote && mode === 'claude' && (await ctx.getAgentSkillEnabled())) {
|
||||
if (!remote && getCli(mode)?.capabilities.agentSkillInjection && (await ctx.getAgentSkillEnabled())) {
|
||||
await injectAgentSkill(resolvedCasePath);
|
||||
}
|
||||
|
||||
@@ -3171,7 +3202,7 @@ export function registerSessionRoutes(
|
||||
// shell or external-CLI quick-start must not author a block of its own (the same
|
||||
// rule the existing-case branch above states; this branch used to exclude just
|
||||
// the five external CLIs and let `shell` through).
|
||||
if (docker && docker.hooksEnabled && mode === 'claude') {
|
||||
if (docker && docker.hooksEnabled && getCli(mode)?.capabilities.hooks === 'always') {
|
||||
try {
|
||||
if (!existsSync(join(resolvedCasePath, 'CLAUDE.md'))) {
|
||||
const templatePath = await ctx.getDefaultClaudeMdPath();
|
||||
@@ -3193,24 +3224,14 @@ export function registerSessionRoutes(
|
||||
// Model override → <case>/.claude/settings.local.json (claude-mode; local AND
|
||||
// docker — the docker workspace is a real host dir, so the settings file crosses
|
||||
// the bind mount and the in-container claude reads it). Remote was rejected above.
|
||||
if (mode === 'claude' && modelOverride !== undefined) {
|
||||
if (getCli(mode)?.capabilities.model.source === 'claude-settings-file' && modelOverride !== undefined) {
|
||||
await updateCaseModel(resolvedCasePath, modelOverride || null);
|
||||
}
|
||||
|
||||
// Strip stale disk entries for keys this request is actively setting (Claude only —
|
||||
// see POST /api/sessions for full rationale).
|
||||
if (
|
||||
mode !== 'opencode' &&
|
||||
mode !== 'codex' &&
|
||||
mode !== 'gemini' &&
|
||||
mode !== 'antigravity' &&
|
||||
mode !== 'pi' &&
|
||||
mode !== 'grok' &&
|
||||
mode !== 'deepseek' &&
|
||||
!remote &&
|
||||
envOverrides &&
|
||||
Object.keys(envOverrides).length > 0
|
||||
) {
|
||||
// Same chain, same replacement as the create path above: byte-identical, drift-proof.
|
||||
if (!isExternalCliMode(mode) && !remote && envOverrides && Object.keys(envOverrides).length > 0) {
|
||||
await stripCaseEnvKeys(resolvedCasePath, Object.keys(envOverrides));
|
||||
}
|
||||
|
||||
@@ -3218,23 +3239,22 @@ export function registerSessionRoutes(
|
||||
// Apply global Nice priority config and model config from settings
|
||||
const niceConfig = await ctx.getGlobalNiceConfig();
|
||||
const qsModelConfig = await ctx.getModelConfig();
|
||||
// See the create path for why this is a capability rather than a mode ladder.
|
||||
const qsModelSource = getCli(mode)?.capabilities.model;
|
||||
const qsModel =
|
||||
mode === 'opencode'
|
||||
? openCodeConfig?.model
|
||||
: mode === 'codex'
|
||||
? codexConfig?.model
|
||||
: mode === 'gemini'
|
||||
? geminiConfig?.model
|
||||
: mode === 'antigravity'
|
||||
? antigravityConfig?.model
|
||||
: mode === 'pi'
|
||||
? piConfig?.model
|
||||
: mode === 'grok'
|
||||
? grokConfig?.model
|
||||
: // DeepSeek's model lives in the profile's config tree, not here.
|
||||
mode !== 'shell' && mode !== 'deepseek'
|
||||
? qsModelConfig?.defaultModel || undefined
|
||||
: undefined;
|
||||
qsModelSource?.source === 'flag'
|
||||
? (legacyConfigForMode(mode, {
|
||||
openCodeConfig,
|
||||
codexConfig,
|
||||
geminiConfig,
|
||||
antigravityConfig,
|
||||
piConfig,
|
||||
grokConfig,
|
||||
deepSeekConfig,
|
||||
} as unknown as Record<string, unknown>)?.[qsModelSource.param ?? 'model'] as string | undefined)
|
||||
: qsModelSource?.source === 'claude-settings-file'
|
||||
? qsModelConfig?.defaultModel || undefined
|
||||
: undefined;
|
||||
const qsClaudeModeConfig = await ctx.getClaudeModeConfig();
|
||||
const qsEffectiveClaudeMode = await resolveClaudeModeForUsername(qsClaudeModeConfig.claudeMode, owner);
|
||||
// Section 6.3: clamp Codex/Gemini/Antigravity bypass switches for a non-granted owner (no-op single-user/granted).
|
||||
@@ -3245,7 +3265,7 @@ export function registerSessionRoutes(
|
||||
piConfig: qsGatedPiConfig,
|
||||
grokConfig: qsGatedGrokConfig,
|
||||
deepSeekConfig: qsGatedDeepSeekConfig,
|
||||
} = await clampExternalCliBypassForOwner(
|
||||
} = await _clampExternalCliBypassForOwner(
|
||||
owner,
|
||||
codexConfig,
|
||||
geminiConfig,
|
||||
@@ -3274,6 +3294,7 @@ export function registerSessionRoutes(
|
||||
piConfig: mode === 'pi' ? qsGatedPiConfig : undefined,
|
||||
grokConfig: mode === 'grok' ? qsGatedGrokConfig : undefined,
|
||||
deepSeekConfig: mode === 'deepseek' ? qsGatedDeepSeekConfig : undefined,
|
||||
ompConfig: resolveOmpConfigForCreate(mode, resolvedCasePath, ompConfig),
|
||||
envOverrides: qsGatedEnvOverrides,
|
||||
effort,
|
||||
remote,
|
||||
@@ -3285,7 +3306,7 @@ export function registerSessionRoutes(
|
||||
|
||||
// Auto-detect completion phrase from CLAUDE.md BEFORE broadcasting
|
||||
// so the initial state already has the phrase configured (only if globally enabled)
|
||||
if (mode === 'claude' && !remote && !docker && ctx.store.getConfig().ralphEnabled) {
|
||||
if (getCli(mode)?.capabilities.ralph && !remote && !docker && ctx.store.getConfig().ralphEnabled) {
|
||||
autoConfigureRalph(session, resolvedCasePath, ctx);
|
||||
if (!session.ralphTracker.enabled) {
|
||||
session.ralphTracker.enable();
|
||||
@@ -3299,7 +3320,7 @@ export function registerSessionRoutes(
|
||||
await ctx.setupSessionListeners(session);
|
||||
// Pre-seed the agent skill's preamble cache so its §0 bootstrap is a two-line
|
||||
// loader (see seedAgentSessionPreamble). Local claude sessions only; best-effort.
|
||||
if (mode === 'claude' && !remote && !docker && (await ctx.getAgentSkillEnabled())) {
|
||||
if (getCli(mode)?.capabilities.agentSkillInjection && !remote && !docker && (await ctx.getAgentSkillEnabled())) {
|
||||
await seedAgentSessionPreamble(session.id).catch((err: unknown) =>
|
||||
console.warn(`[agent-skill] preamble seed failed for ${session.id}: ${getErrorMessage(err)}`)
|
||||
);
|
||||
@@ -3314,7 +3335,7 @@ export function registerSessionRoutes(
|
||||
|
||||
// Start in the appropriate mode
|
||||
try {
|
||||
if (mode === 'shell') {
|
||||
if (getCli(mode)?.capabilities.startMode === 'shell') {
|
||||
await session.startShell();
|
||||
getLifecycleLog().log({
|
||||
event: 'started',
|
||||
@@ -4122,6 +4143,24 @@ export function registerSessionRoutes(
|
||||
// Projects dir may not exist.
|
||||
}
|
||||
|
||||
// OMP's own session files (~/.omp/agent/sessions) — the non-claude twin
|
||||
// of the scan above; see omp-transcript.ts for why this exists at all.
|
||||
try {
|
||||
for (const h of scanOmpSessionsHistory()) {
|
||||
history.push({
|
||||
sessionId: h.sessionId,
|
||||
workingDir: h.workingDir,
|
||||
sizeBytes: h.sizeBytes,
|
||||
lastModified: h.lastModified,
|
||||
firstPrompt: h.firstPrompt,
|
||||
lastPrompt: h.lastPrompt,
|
||||
mode: 'omp',
|
||||
});
|
||||
}
|
||||
} catch {
|
||||
// Best-effort, same as the claude scan above.
|
||||
}
|
||||
|
||||
// Mux process stats (best-effort; guard against mocks lacking the method).
|
||||
let mux: MuxStatInput[] = [];
|
||||
try {
|
||||
|
||||
Reference in New Issue
Block a user