aicodeman 1.30.0 → 1.32.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +7 -6
- package/dist/config/cli-registry/schema.d.ts +8 -0
- package/dist/config/cli-registry/schema.d.ts.map +1 -1
- package/dist/config/cli-registry/schema.js +29 -0
- package/dist/config/cli-registry/schema.js.map +1 -1
- package/dist/config/cli-registry/stock.d.ts.map +1 -1
- package/dist/config/cli-registry/stock.js +62 -7
- package/dist/config/cli-registry/stock.js.map +1 -1
- package/dist/config/cli-registry/types.d.ts +62 -0
- package/dist/config/cli-registry/types.d.ts.map +1 -1
- package/dist/config/remote-wake-limits.d.ts +23 -0
- package/dist/config/remote-wake-limits.d.ts.map +1 -0
- package/dist/config/remote-wake-limits.js +23 -0
- package/dist/config/remote-wake-limits.js.map +1 -0
- package/dist/custom-model-hosts.d.ts +32 -0
- package/dist/custom-model-hosts.d.ts.map +1 -1
- package/dist/custom-model-hosts.js.map +1 -1
- package/dist/custom-model-injection-apply.d.ts +29 -1
- package/dist/custom-model-injection-apply.d.ts.map +1 -1
- package/dist/custom-model-injection-apply.js +191 -5
- package/dist/custom-model-injection-apply.js.map +1 -1
- package/dist/custom-model-injection.d.ts +35 -9
- package/dist/custom-model-injection.d.ts.map +1 -1
- package/dist/custom-model-injection.js +37 -16
- package/dist/custom-model-injection.js.map +1 -1
- package/dist/mux-interface.d.ts +12 -0
- package/dist/mux-interface.d.ts.map +1 -1
- package/dist/remote-hosts.d.ts +20 -0
- package/dist/remote-hosts.d.ts.map +1 -1
- package/dist/remote-hosts.js +33 -0
- package/dist/remote-hosts.js.map +1 -1
- package/dist/remote-wake.d.ts +446 -0
- package/dist/remote-wake.d.ts.map +1 -0
- package/dist/remote-wake.js +880 -0
- package/dist/remote-wake.js.map +1 -0
- package/dist/session-env-clamp.d.ts +12 -7
- package/dist/session-env-clamp.d.ts.map +1 -1
- package/dist/session-env-clamp.js +12 -7
- package/dist/session-env-clamp.js.map +1 -1
- package/dist/session-submit-verifier.d.ts +44 -0
- package/dist/session-submit-verifier.d.ts.map +1 -0
- package/dist/session-submit-verifier.js +122 -0
- package/dist/session-submit-verifier.js.map +1 -0
- package/dist/session.d.ts +9 -0
- package/dist/session.d.ts.map +1 -1
- package/dist/session.js +42 -1
- package/dist/session.js.map +1 -1
- package/dist/tmux-manager.d.ts.map +1 -1
- package/dist/tmux-manager.js +9 -0
- package/dist/tmux-manager.js.map +1 -1
- package/dist/types/session.d.ts +26 -0
- package/dist/types/session.d.ts.map +1 -1
- package/dist/types/session.js.map +1 -1
- package/dist/web/public/admin-ui.js.gz +0 -0
- package/dist/web/public/api-client.c9b1cddc.js.gz +0 -0
- package/dist/web/public/app.a792bf71.js +38 -0
- package/dist/web/public/app.a792bf71.js.br +0 -0
- package/dist/web/public/app.a792bf71.js.gz +0 -0
- package/dist/web/public/approvals-ui.js.gz +0 -0
- package/dist/web/public/{constants.258b140f.js → constants.b26748e9.js} +123 -0
- package/dist/web/public/constants.b26748e9.js.br +0 -0
- package/dist/web/public/constants.b26748e9.js.gz +0 -0
- package/dist/web/public/cron-ui.js.gz +0 -0
- package/dist/web/public/entrance-animations.js.gz +0 -0
- package/dist/web/public/home-sessions.js.gz +0 -0
- package/dist/web/public/host-wake-ui.js +440 -0
- package/dist/web/public/host-wake-ui.js.br +0 -0
- package/dist/web/public/host-wake-ui.js.gz +0 -0
- package/dist/web/public/i18n.d22be5c4.js +1 -0
- package/dist/web/public/i18n.d22be5c4.js.br +0 -0
- package/dist/web/public/i18n.d22be5c4.js.gz +0 -0
- package/dist/web/public/{image-input.cd4b97c4.js → image-input.a216cf9e.js} +8 -4
- package/dist/web/public/image-input.a216cf9e.js.br +0 -0
- package/dist/web/public/image-input.a216cf9e.js.gz +0 -0
- package/dist/web/public/index.html +191 -12
- package/dist/web/public/index.html.br +0 -0
- package/dist/web/public/index.html.gz +0 -0
- package/dist/web/public/input-cjk.8bc46081.js.gz +0 -0
- package/dist/web/public/{keyboard-accessory.2cf04f17.js → keyboard-accessory.c1e7c7b7.js} +280 -15
- package/dist/web/public/keyboard-accessory.c1e7c7b7.js.br +0 -0
- package/dist/web/public/keyboard-accessory.c1e7c7b7.js.gz +0 -0
- package/dist/web/public/mobile-handlers.6f354a87.js.gz +0 -0
- package/dist/web/public/mobile-overview.js.gz +0 -0
- package/dist/web/public/mobile.6caaa28a.css.gz +0 -0
- package/dist/web/public/notification-manager.36ea4624.js.gz +0 -0
- package/dist/web/public/orchestrator-panel.js.gz +0 -0
- package/dist/web/public/{panels-ui.5b07ad14.js → panels-ui.28df5053.js} +5 -5
- package/dist/web/public/panels-ui.28df5053.js.br +0 -0
- package/dist/web/public/panels-ui.28df5053.js.gz +0 -0
- package/dist/web/public/ralph-panel.6de2d0f8.js.gz +0 -0
- package/dist/web/public/ralph-wizard.13a1831e.js.gz +0 -0
- package/dist/web/public/readmymind-ui.js.gz +0 -0
- package/dist/web/public/reboot-restore-ui.js.gz +0 -0
- package/dist/web/public/respawn-ui.ff0dae4c.js.gz +0 -0
- package/dist/web/public/sanitize-html.bc7078d6.js.gz +0 -0
- package/dist/web/public/session-lineage.js.gz +0 -0
- package/dist/web/public/session-ui.7df17fd3.js +88 -0
- package/dist/web/public/session-ui.7df17fd3.js.br +0 -0
- package/dist/web/public/session-ui.7df17fd3.js.gz +0 -0
- package/dist/web/public/settings-ui.df5ffb68.js +78 -0
- package/dist/web/public/settings-ui.df5ffb68.js.br +0 -0
- package/dist/web/public/settings-ui.df5ffb68.js.gz +0 -0
- package/dist/web/public/styles.74f312e5.css +1 -0
- package/dist/web/public/styles.74f312e5.css.br +0 -0
- package/dist/web/public/styles.74f312e5.css.gz +0 -0
- package/dist/web/public/subagent-windows.e6ca799f.js.gz +0 -0
- package/dist/web/public/sw.js.gz +0 -0
- package/dist/web/public/tab-rail-resize.42c24949.js.gz +0 -0
- package/dist/web/public/terminal-keycode229-recovery.fb91b25b.js.gz +0 -0
- package/dist/web/public/terminal-split.js +804 -0
- package/dist/web/public/terminal-split.js.br +0 -0
- package/dist/web/public/terminal-split.js.gz +0 -0
- package/dist/web/public/terminal-ui.32fbeaf6.js +2 -0
- package/dist/web/public/terminal-ui.32fbeaf6.js.br +0 -0
- package/dist/web/public/terminal-ui.32fbeaf6.js.gz +0 -0
- package/dist/web/public/ultracode-panel.js.gz +0 -0
- package/dist/web/public/ultracode-windows.js.gz +0 -0
- package/dist/web/public/upload.html.gz +0 -0
- package/dist/web/public/vendor/dompurify.min.js.gz +0 -0
- package/dist/web/public/vendor/marked.min.js.gz +0 -0
- package/dist/web/public/vendor/xterm-addon-fit.min.js.gz +0 -0
- package/dist/web/public/vendor/xterm-addon-serialize.min.js.gz +0 -0
- package/dist/web/public/vendor/xterm-addon-unicode11.min.js.gz +0 -0
- package/dist/web/public/vendor/xterm-addon-webgl.min.js.gz +0 -0
- package/dist/web/public/vendor/xterm-predictive-echo.bd6882b8.js.gz +0 -0
- package/dist/web/public/vendor/xterm-zerolag-input.6fee72f2.js.gz +0 -0
- package/dist/web/public/vendor/xterm.css.gz +0 -0
- package/dist/web/public/vendor/xterm.min.js.gz +0 -0
- package/dist/web/public/voice-input.c4b51eb6.js.gz +0 -0
- package/dist/web/public/voice-pcm-worklet.js.gz +0 -0
- package/dist/web/public/webview-tabs.js.gz +0 -0
- package/dist/web/routes/custom-model-routes.d.ts +173 -0
- package/dist/web/routes/custom-model-routes.d.ts.map +1 -1
- package/dist/web/routes/custom-model-routes.js +628 -11
- package/dist/web/routes/custom-model-routes.js.map +1 -1
- package/dist/web/routes/index.d.ts +1 -1
- package/dist/web/routes/index.d.ts.map +1 -1
- package/dist/web/routes/index.js +1 -1
- package/dist/web/routes/index.js.map +1 -1
- package/dist/web/routes/session-routes.d.ts +6 -1
- package/dist/web/routes/session-routes.d.ts.map +1 -1
- package/dist/web/routes/session-routes.js +448 -17
- package/dist/web/routes/session-routes.js.map +1 -1
- package/dist/web/schemas.d.ts +15 -0
- package/dist/web/schemas.d.ts.map +1 -1
- package/dist/web/schemas.js +76 -0
- package/dist/web/schemas.js.map +1 -1
- package/dist/web/server.d.ts +21 -0
- package/dist/web/server.d.ts.map +1 -1
- package/dist/web/server.js +142 -9
- package/dist/web/server.js.map +1 -1
- package/dist/web/sse-events.d.ts +25 -2
- package/dist/web/sse-events.d.ts.map +1 -1
- package/dist/web/sse-events.js +30 -3
- package/dist/web/sse-events.js.map +1 -1
- package/package.json +1 -1
- package/skills/codeman/SKILL.md +75 -26
- package/skills/codeman/preamble.sh +66 -19
- package/skills/codeman/reference/recipes.md +1 -1
- package/dist/web/public/app.6d2dc4e8.js +0 -38
- package/dist/web/public/app.6d2dc4e8.js.br +0 -0
- package/dist/web/public/app.6d2dc4e8.js.gz +0 -0
- package/dist/web/public/constants.258b140f.js.br +0 -0
- package/dist/web/public/constants.258b140f.js.gz +0 -0
- package/dist/web/public/i18n.5f897ed5.js +0 -1
- package/dist/web/public/i18n.5f897ed5.js.br +0 -0
- package/dist/web/public/i18n.5f897ed5.js.gz +0 -0
- package/dist/web/public/image-input.cd4b97c4.js.br +0 -0
- package/dist/web/public/image-input.cd4b97c4.js.gz +0 -0
- package/dist/web/public/keyboard-accessory.2cf04f17.js.br +0 -0
- package/dist/web/public/keyboard-accessory.2cf04f17.js.gz +0 -0
- package/dist/web/public/panels-ui.5b07ad14.js.br +0 -0
- package/dist/web/public/panels-ui.5b07ad14.js.gz +0 -0
- package/dist/web/public/session-ui.42b81477.js +0 -77
- package/dist/web/public/session-ui.42b81477.js.br +0 -0
- package/dist/web/public/session-ui.42b81477.js.gz +0 -0
- package/dist/web/public/settings-ui.58f1d756.js +0 -67
- package/dist/web/public/settings-ui.58f1d756.js.br +0 -0
- package/dist/web/public/settings-ui.58f1d756.js.gz +0 -0
- package/dist/web/public/styles.e6edfb0b.css +0 -1
- package/dist/web/public/styles.e6edfb0b.css.br +0 -0
- package/dist/web/public/styles.e6edfb0b.css.gz +0 -0
- package/dist/web/public/terminal-ui.4f8d5820.js +0 -2
- package/dist/web/public/terminal-ui.4f8d5820.js.br +0 -0
- package/dist/web/public/terminal-ui.4f8d5820.js.gz +0 -0
|
@@ -8,7 +8,7 @@ import { homedir } from 'node:os';
|
|
|
8
8
|
import { existsSync, statSync, mkdirSync, writeFileSync } from 'node:fs';
|
|
9
9
|
import { execFile } from 'node:child_process';
|
|
10
10
|
import fs from 'node:fs/promises';
|
|
11
|
-
import { randomBytes } from 'node:crypto';
|
|
11
|
+
import { randomBytes, randomUUID } from 'node:crypto';
|
|
12
12
|
import { performance } from 'node:perf_hooks';
|
|
13
13
|
import { ApiErrorCode, createErrorResponse, getErrorMessage, } from '../../types.js';
|
|
14
14
|
import { Session, isAltScreenStripMode, isExternalCliMode, isMuxAltScreenOnlyStripMode } from '../../session.js';
|
|
@@ -17,10 +17,12 @@ import { webviewCapabilities } from '../../webview-capabilities.js';
|
|
|
17
17
|
import { CreateSessionSchema, SessionNameSchema, SessionColorSchema, RunPromptSchema, SessionInputWithLimitSchema, ResizeSchema, AutoClearSchema, AutoCompactSchema, AutoResumeSchema, PinSessionSchema, ImageWatcherSchema, FlickerFilterSchema, QuickRunSchema, QuickStartSchema, InteractiveStartSchema, SessionOrderUpdateSchema, SessionWaitQuerySchema, SessionWaitOutputQuerySchema, CustomModelSelectionSchema, } from '../schemas.js';
|
|
18
18
|
import { readCustomModelHosts } from '../../custom-model-hosts.js';
|
|
19
19
|
import { applyCustomModelInjection, removeConfigDir } from '../../custom-model-injection-apply.js';
|
|
20
|
+
import { getLlamaSwapStatus, triggerLlamaSwapLoad, exceedsSafeContextFloor, CLAUDE_MIN_SAFE_CONTEXT_TOKENS, } from './custom-model-routes.js';
|
|
20
21
|
import { matchesPattern } from '../../config/cli-registry/patterns.js';
|
|
21
22
|
import { ownerLayoutKey } from '../../tab-layout-persistence.js';
|
|
22
23
|
import { TabLayoutValidationError } from '../../tab-layout.js';
|
|
23
24
|
import { sessionWaits, resolveWaitSignals, sessionHookOptions, signalForStatus, WaitCapacityError, } from '../session-wait-registry.js';
|
|
25
|
+
import { RemoteWakeRegistry, REMOTE_WAKE_REQUEST_READY_TIMEOUT_MS, createDefaultRemoteWakeDeps, isProbeable, } from '../../remote-wake.js';
|
|
24
26
|
import { clampWaitMs, MAX_BUFFER_SCAN_BYTES } from '../../config/agent-wait.js';
|
|
25
27
|
import { autoConfigureRalph, canAccessOwned, CASES_DIR, findPersistedSessionOrFail, findSessionOrFail, getAuthUser, isAdmin, isWorkingDirAllowed, ownerFor, parseBody, persistAndBroadcastSession, resolveAgentCaseOrigin, resolveCasesDir, resolveParentSessionId, sessionCapacityMessage, SETTINGS_PATH, validatePathWithinBase, } from '../route-helpers.js';
|
|
26
28
|
import { buildAgentCaseMarker, writeAgentCaseMarker } from '../../agent-case-marker.js';
|
|
@@ -42,7 +44,7 @@ import { RunSummaryTracker } from '../../run-summary.js';
|
|
|
42
44
|
import { MAX_INPUT_LENGTH, MAX_SESSION_NAME_LENGTH } from '../../config/terminal-limits.js';
|
|
43
45
|
import { MAX_PASTE_IMAGE_BYTES } from '../../config/buffer-limits.js';
|
|
44
46
|
import { dataPath, getDataDir } from '../../config/instance.js';
|
|
45
|
-
import { checkRemoteTmuxAvailable, readRemoteCases, readRemoteHosts, toAttachedSessionRemote, toSessionRemote, } from '../../remote-hosts.js';
|
|
47
|
+
import { checkRemoteTmuxAvailable, readRemoteCases, readRemoteHosts, rehydrateRemoteHostFields, toAttachedSessionRemote, toSessionRemote, } from '../../remote-hosts.js';
|
|
46
48
|
import { checkDockerAvailable, checkDockerConfigDrift, probeAdoptableContainer, checkDockerTmuxAvailable, ensureAgentBaseImage, DEFAULT_AGENT_IMAGE, persistDockerCaseClaudeSessionId, readDockerCases, readDockerHosts, toSessionDocker, } from '../../docker-hosts.js';
|
|
47
49
|
import { LRUMap } from '../../utils/lru-map.js';
|
|
48
50
|
import { findLatestOmpSessionId } from '../../utils/omp-session-resolver.js';
|
|
@@ -589,7 +591,59 @@ export function resolveOmpConfigForCreate(mode, workingDir, ompConfig) {
|
|
|
589
591
|
}
|
|
590
592
|
return resolvedId ? { ...ompConfig, resumeSessionId: resolvedId } : ompConfig;
|
|
591
593
|
}
|
|
592
|
-
|
|
594
|
+
/**
|
|
595
|
+
* `RemoteHost` → the wake registry's host shape. They differ in one field name only
|
|
596
|
+
* (`id` in host config vs `hostId` on a session's `remote`), but the rename is load-
|
|
597
|
+
* bearing: the registry keys its per-host wake state on `hostId`. The proxy fields
|
|
598
|
+
* travel too: they are what tells the registry its probe cannot reach this host.
|
|
599
|
+
*/
|
|
600
|
+
function wakeableHost(host) {
|
|
601
|
+
return {
|
|
602
|
+
hostId: host.id,
|
|
603
|
+
label: host.label,
|
|
604
|
+
host: host.host,
|
|
605
|
+
port: host.port,
|
|
606
|
+
wakeMac: host.wakeMac,
|
|
607
|
+
wakeCommand: host.wakeCommand,
|
|
608
|
+
jumpHost: host.jumpHost,
|
|
609
|
+
socksProxy: host.socksProxy,
|
|
610
|
+
extraSshOptions: host.extraSshOptions,
|
|
611
|
+
};
|
|
612
|
+
}
|
|
613
|
+
export function registerSessionRoutes(app, ctx,
|
|
614
|
+
/** Test seam: inject a registry with fake IO instead of the real TCP/WoL probes. */
|
|
615
|
+
options = {}) {
|
|
616
|
+
// Wake-on-LAN for sleeping remote hosts (see remote-wake.ts). One registry per
|
|
617
|
+
// route registration (= one web server) — the same shape as the process-wide
|
|
618
|
+
// `sessionWaits` singleton, but without the global.
|
|
619
|
+
//
|
|
620
|
+
// ⚠️ The ONLY caller that may wake a host is the input route below. The
|
|
621
|
+
// auto-reconnect watcher and boot recovery deliberately have no access to this
|
|
622
|
+
// registry: waking there would re-wake the host seconds after every suspend, so
|
|
623
|
+
// it could never stay asleep.
|
|
624
|
+
const remoteWake = options.remoteWake ??
|
|
625
|
+
new RemoteWakeRegistry(createDefaultRemoteWakeDeps({
|
|
626
|
+
noteReconnected: (sessionId, success) => {
|
|
627
|
+
// Duck-typed exactly like server.ts: TmuxManager owns the COD-108 backoff
|
|
628
|
+
// state, and the port interface does not expose it.
|
|
629
|
+
const mux = ctx.mux;
|
|
630
|
+
mux.noteRemoteReconnect?.(sessionId, success);
|
|
631
|
+
},
|
|
632
|
+
broadcast: (event, payload) => ctx.broadcast(event, payload),
|
|
633
|
+
log: (message) => console.log(message),
|
|
634
|
+
// The session's `remote` block is a launch-time snapshot, so a wake target
|
|
635
|
+
// configured later (banner's config dialog, or a hand-edited remote-hosts.json)
|
|
636
|
+
// is resolved here — throttled by the registry, and the host config is
|
|
637
|
+
// authoritative in BOTH directions (removing the field turns the feature off
|
|
638
|
+
// for a live session too).
|
|
639
|
+
resolveRemote: async (session) => {
|
|
640
|
+
const remote = session.remote;
|
|
641
|
+
if (!remote)
|
|
642
|
+
return undefined;
|
|
643
|
+
const hosts = await readRemoteHosts(CODEMAN_CONFIG_DIR);
|
|
644
|
+
return rehydrateRemoteHostFields(remote, new Map(hosts.map((host) => [host.id, host])));
|
|
645
|
+
},
|
|
646
|
+
}));
|
|
593
647
|
// ═══════════════════════════════════════════════════════════════
|
|
594
648
|
// Auth
|
|
595
649
|
// ═══════════════════════════════════════════════════════════════
|
|
@@ -651,10 +705,31 @@ export function registerSessionRoutes(app, ctx) {
|
|
|
651
705
|
// creation (owned durable sessions) is handled by the dedicated case-create
|
|
652
706
|
// endpoint below, which #145 consolidated remote-host resolution into.
|
|
653
707
|
if (body.attachRemoteSession) {
|
|
708
|
+
// Remote hosts are admin-only infrastructure everywhere else (the list answers
|
|
709
|
+
// `[]` to a non-admin; write and discovery routes are `adminOnly`), and the wake
|
|
710
|
+
// below spawns the host's `wakeCommand` or broadcasts a packet. So the gate comes
|
|
711
|
+
// FIRST — before the host is even looked up — or an unprivileged account could
|
|
712
|
+
// invoke that executable for any configured `hostId` and only then be told the
|
|
713
|
+
// workingDir was outside its workspace (reproduced upstream: wake spy fired, 403).
|
|
714
|
+
if (isMultiUserMode() && !isAdmin(req)) {
|
|
715
|
+
return createErrorResponse(ApiErrorCode.FORBIDDEN, 'Remote hosts are admin-only in multi-user mode');
|
|
716
|
+
}
|
|
654
717
|
const { hostId, remoteSessionName } = body.attachRemoteSession;
|
|
655
718
|
const host = (await readRemoteHosts(CODEMAN_CONFIG_DIR)).find((item) => item.id === hostId);
|
|
656
719
|
if (!host)
|
|
657
720
|
return createErrorResponse(ApiErrorCode.NOT_FOUND, 'Remote host not found');
|
|
721
|
+
// An explicit wake request is the only thing that may wake a host, and the user
|
|
722
|
+
// pressing Attach IS one (see quick-start for the same gate, and
|
|
723
|
+
// `remote-wake.ts` for what must never call this). Without it a sleeping host
|
|
724
|
+
// answers with an ssh failure that blames anything but the machine being asleep.
|
|
725
|
+
const hostWake = await remoteWake.ensureHostAwake(wakeableHost(host), {
|
|
726
|
+
timeoutMs: REMOTE_WAKE_REQUEST_READY_TIMEOUT_MS,
|
|
727
|
+
// No session yet, so the wake events name their requester (multi-user routing).
|
|
728
|
+
requestedBy: ownerFor(req),
|
|
729
|
+
});
|
|
730
|
+
if (hostWake === 'failed') {
|
|
731
|
+
return createErrorResponse(ApiErrorCode.OPERATION_FAILED, `${host.label} did not come back after a wake-on-LAN request — nothing was attached`);
|
|
732
|
+
}
|
|
658
733
|
workingDir = `${host.username}@${host.host}:${remoteSessionName}`;
|
|
659
734
|
remote = toAttachedSessionRemote(host, remoteSessionName, workingDir);
|
|
660
735
|
}
|
|
@@ -921,12 +996,69 @@ export function registerSessionRoutes(app, ctx) {
|
|
|
921
996
|
if (!endpoint) {
|
|
922
997
|
return createErrorResponse(ApiErrorCode.NOT_FOUND, 'Model endpoint not found');
|
|
923
998
|
}
|
|
999
|
+
const contextLength = endpoint.modelContextLengths?.[body.modelId];
|
|
1000
|
+
// Some CLIs (today: only claude) carry enough of their own fixed system-prompt/tool-
|
|
1001
|
+
// schema overhead that a small enough real context guarantees a first-message failure
|
|
1002
|
+
// no matter what CLAUDE_CODE_MAX_CONTEXT_TOKENS says — confirmed live at ~36.4K tokens
|
|
1003
|
+
// against a model configured with a real 16384-token context. Warn before committing
|
|
1004
|
+
// to a restart that's certain to fail, rather than letting the user discover it via a
|
|
1005
|
+
// cryptic 400 from the CLI itself. Answered by `confirmedContext` (or the legacy
|
|
1006
|
+
// `confirmed`, which still means both) — NOT by `confirmedSwap`: this warning is
|
|
1007
|
+
// about the caller's own session, and the swap warning below is about someone
|
|
1008
|
+
// else's, so an answer to one is not consent to the other.
|
|
1009
|
+
if (!(body.confirmed || body.confirmedContext) && exceedsSafeContextFloor(entry, contextLength)) {
|
|
1010
|
+
return {
|
|
1011
|
+
requiresContextWarning: true,
|
|
1012
|
+
modelId: body.modelId,
|
|
1013
|
+
contextLength,
|
|
1014
|
+
minSafeContextTokens: CLAUDE_MIN_SAFE_CONTEXT_TOKENS,
|
|
1015
|
+
};
|
|
1016
|
+
}
|
|
1017
|
+
// llama.cpp runs exactly one model at a time; llama-swap unloads and reloads it on
|
|
1018
|
+
// demand, which can take anywhere from a few seconds to over a minute — long enough
|
|
1019
|
+
// that a session mid-swap looks indistinguishable from one that never left the native
|
|
1020
|
+
// backend. Feature-detected via llama-swap's own `GET /running` (a plain llama.cpp
|
|
1021
|
+
// server has no such endpoint and reads as `isLlamaSwap: false` — nothing to check).
|
|
1022
|
+
const swapStatus = await getLlamaSwapStatus(endpoint);
|
|
1023
|
+
const currentlyLoaded = swapStatus.running.find((r) => r.state === 'ready')?.model ?? swapStatus.running[0]?.model;
|
|
1024
|
+
// Distinct from targetReady below: this is ONLY about whether proceeding would evict a
|
|
1025
|
+
// model another session is actively using — true even if nothing is loaded at all yet
|
|
1026
|
+
// would be wrong here (nothing to evict), so this stays narrowly "a DIFFERENT model is
|
|
1027
|
+
// currently ready".
|
|
1028
|
+
const swapNeeded = swapStatus.isLlamaSwap && !!currentlyLoaded && currentlyLoaded !== body.modelId;
|
|
1029
|
+
// Whether the TARGET model itself is already the one loaded and ready — false whether
|
|
1030
|
+
// nothing is loaded yet, a different model is loaded, or this one is loaded but still
|
|
1031
|
+
// mid-load. Drives both the actual load trigger below and modelSwapInProgress in the
|
|
1032
|
+
// response; deliberately broader than swapNeeded, which only gates the confirmation ask.
|
|
1033
|
+
const targetReady = swapStatus.running.some((r) => r.model === body.modelId && r.state === 'ready');
|
|
1034
|
+
// Only ask when switching would actually take the model away from another session
|
|
1035
|
+
// that is currently using it — never just because a swap is needed at all. Answered
|
|
1036
|
+
// by `confirmedSwap` (or the legacy `confirmed`). ⚠ It must NOT read
|
|
1037
|
+
// `confirmedContext`: this check runs second, and while the two shared one flag a
|
|
1038
|
+
// user who clicked past a too-small-context warning had already, silently, agreed to
|
|
1039
|
+
// evict another session's model.
|
|
1040
|
+
if (swapNeeded && !(body.confirmed || body.confirmedSwap)) {
|
|
1041
|
+
const conflicting = [...ctx.sessions.values()].filter((s) => s.id !== session.id && s.customModel?.endpointId === endpoint.id && s.customModel?.modelId === currentlyLoaded);
|
|
1042
|
+
if (conflicting.length > 0) {
|
|
1043
|
+
// Applying a custom model is ungated for any session owner, so in multi-user
|
|
1044
|
+
// mode a non-admin pointing their own session at a shared endpoint must not
|
|
1045
|
+
// learn another user's session names in the confirm dialog — with
|
|
1046
|
+
// autoNameSessions on, those names are that user's own prompts. The swap is
|
|
1047
|
+
// still blocked pending confirmation regardless of ownership (a foreign
|
|
1048
|
+
// session is just as real a disruption); only which ones get NAMED is scoped.
|
|
1049
|
+
const requestUser = getAuthUser(req);
|
|
1050
|
+
const affectedSessions = conflicting
|
|
1051
|
+
.filter((s) => canAccessOwned(requestUser, s.owner))
|
|
1052
|
+
.map((s) => ({ id: s.id, name: s.name }));
|
|
1053
|
+
return { requiresConfirmation: true, currentlyLoadedModel: currentlyLoaded, affectedSessions };
|
|
1054
|
+
}
|
|
1055
|
+
}
|
|
924
1056
|
// A CLI whose config alone cannot select the model also gets its `model` launch param
|
|
925
1057
|
// forced (pi/omp `custom/<id>`, grok's block name). The argv engine DROPS a token that
|
|
926
1058
|
// fails its pattern rather than quoting it, which would silently launch the CLI on its
|
|
927
1059
|
// own default provider again, so refuse an id the pattern cannot carry up front.
|
|
928
1060
|
const modelSpec = entry.launch.params.model;
|
|
929
|
-
const applied = applyCustomModelInjection(entry, endpoint, body.modelId, session.id);
|
|
1061
|
+
const applied = applyCustomModelInjection(entry, endpoint, body.modelId, session.id, contextLength, session.workingDir);
|
|
930
1062
|
if (!applied) {
|
|
931
1063
|
return createErrorResponse(ApiErrorCode.OPERATION_FAILED, `${session.mode} has no known custom-model mechanism`);
|
|
932
1064
|
}
|
|
@@ -949,9 +1081,16 @@ export function registerSessionRoutes(app, ctx) {
|
|
|
949
1081
|
if (previousConfigDir && previousConfigDir !== applied.configDir) {
|
|
950
1082
|
removeConfigDir(previousConfigDir);
|
|
951
1083
|
}
|
|
1084
|
+
// Actually kick off llama-swap's load now, rather than waiting on the restarted CLI's
|
|
1085
|
+
// own first prompt to do it — confirmed live that applying a selection alone never
|
|
1086
|
+
// reached the llama-swap server at all (nothing in its own logs), since llama-swap has
|
|
1087
|
+
// no "switch model" admin call, only a real inference request naming the model.
|
|
1088
|
+
if (swapStatus.isLlamaSwap && !targetReady) {
|
|
1089
|
+
triggerLlamaSwapLoad(endpoint, body.modelId);
|
|
1090
|
+
}
|
|
952
1091
|
const restarted = await session.restartCli();
|
|
953
1092
|
persistAndBroadcastSession(ctx, session);
|
|
954
|
-
return { customModel: session.customModel, restarted };
|
|
1093
|
+
return { customModel: session.customModel, restarted, modelSwapInProgress: swapStatus.isLlamaSwap && !targetReady };
|
|
955
1094
|
});
|
|
956
1095
|
// ========== Delete Session ==========
|
|
957
1096
|
app.delete('/api/sessions/:id', async (req) => {
|
|
@@ -977,6 +1116,8 @@ export function registerSessionRoutes(app, ctx) {
|
|
|
977
1116
|
return {};
|
|
978
1117
|
}
|
|
979
1118
|
const session = findSessionOrFail(ctx, id, req);
|
|
1119
|
+
// Wake state is dropped by `cleanupSession` itself (server.ts), on EVERY cleanup
|
|
1120
|
+
// path — not here: the scheduled-run and admin paths clean up without this route.
|
|
980
1121
|
await ctx.cleanupSession(session.id, killMux, 'user_delete');
|
|
981
1122
|
return {};
|
|
982
1123
|
});
|
|
@@ -1172,6 +1313,61 @@ export function registerSessionRoutes(app, ctx) {
|
|
|
1172
1313
|
// ═══════════════════════════════════════════════════════════════
|
|
1173
1314
|
// Terminal I/O (input, resize, buffer)
|
|
1174
1315
|
// ═══════════════════════════════════════════════════════════════
|
|
1316
|
+
// ========== Wake-on-LAN: state + manual trigger ==========
|
|
1317
|
+
//
|
|
1318
|
+
// Both routes are session-scoped (not host-scoped) because the wake flow needs the
|
|
1319
|
+
// SESSION: a woken host whose pane is not reattached is still a dead terminal, and an
|
|
1320
|
+
// exhausted COD-108 backoff never retries on its own. The probe in `/reachability` is
|
|
1321
|
+
// the same cheap TCP connect the input path uses and it NEVER wakes a host — the UI
|
|
1322
|
+
// decides that, with the button.
|
|
1323
|
+
app.get('/api/sessions/:id/reachability', async (req) => {
|
|
1324
|
+
const { id } = req.params;
|
|
1325
|
+
const session = findSessionOrFail(ctx, id, req);
|
|
1326
|
+
const remote = session.remote;
|
|
1327
|
+
if (!remote) {
|
|
1328
|
+
return { success: true, data: { reachable: true, probeable: true, wakeConfigured: 'none' } };
|
|
1329
|
+
}
|
|
1330
|
+
const force = req.query?.force === '1';
|
|
1331
|
+
// `reachable: null` + `probeable: false` for a host behind a jump host / SOCKS proxy:
|
|
1332
|
+
// the probe cannot reach it, so the UI shows no banner and stops polling.
|
|
1333
|
+
const reachable = await remoteWake.checkReachable(session, { force });
|
|
1334
|
+
return {
|
|
1335
|
+
success: true,
|
|
1336
|
+
data: {
|
|
1337
|
+
reachable,
|
|
1338
|
+
probeable: isProbeable(remote),
|
|
1339
|
+
wakeConfigured: await remoteWake.wakeConfigured(session),
|
|
1340
|
+
host: remote.host,
|
|
1341
|
+
label: remote.label,
|
|
1342
|
+
},
|
|
1343
|
+
};
|
|
1344
|
+
});
|
|
1345
|
+
app.post('/api/sessions/:id/wake', async (req) => {
|
|
1346
|
+
const { id } = req.params;
|
|
1347
|
+
const session = findSessionOrFail(ctx, id, req);
|
|
1348
|
+
if (!session.remote) {
|
|
1349
|
+
return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Not a remote session');
|
|
1350
|
+
}
|
|
1351
|
+
// The UI uses this to route to the host config dialog instead of a dead button.
|
|
1352
|
+
if (!(await remoteWake.hasWakeTarget(session))) {
|
|
1353
|
+
return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'No wake-on-LAN target configured for this host (set a MAC address or a wake command)');
|
|
1354
|
+
}
|
|
1355
|
+
// The button is pressed from the SAME dashboard the create/attach paths are, under
|
|
1356
|
+
// the same reverse proxy — so it holds the request open the same way and needs the
|
|
1357
|
+
// same request budget, not the 90 s session default (see remote-wake.ts).
|
|
1358
|
+
const woke = await remoteWake.ensureAwake(session, {
|
|
1359
|
+
force: true,
|
|
1360
|
+
timeoutMs: REMOTE_WAKE_REQUEST_READY_TIMEOUT_MS,
|
|
1361
|
+
});
|
|
1362
|
+
return {
|
|
1363
|
+
success: true,
|
|
1364
|
+
data: {
|
|
1365
|
+
woke,
|
|
1366
|
+
reachable: await remoteWake.checkReachable(session),
|
|
1367
|
+
wakeConfigured: await remoteWake.wakeConfigured(session),
|
|
1368
|
+
},
|
|
1369
|
+
};
|
|
1370
|
+
});
|
|
1175
1371
|
// ========== Send Input ==========
|
|
1176
1372
|
app.post('/api/sessions/:id/input', async (req, reply) => {
|
|
1177
1373
|
const { id } = req.params;
|
|
@@ -1205,6 +1401,41 @@ export function registerSessionRoutes(app, ctx) {
|
|
|
1205
1401
|
if (duplicate && !wantsWait) {
|
|
1206
1402
|
return {};
|
|
1207
1403
|
}
|
|
1404
|
+
// Wake-on-LAN (remote-wake.ts): a wake-enabled remote host that suspended leaves
|
|
1405
|
+
// the local ssh pane STALLED, and `send-keys` succeeds against it — the bytes
|
|
1406
|
+
// would vanish with no error anywhere. Give the registry the chance to probe the
|
|
1407
|
+
// host, wake it, reattach, and own delivery before we write into nothing.
|
|
1408
|
+
//
|
|
1409
|
+
// Costs nothing for non-wake hosts (the `wakeCommand` guard) or while the host is
|
|
1410
|
+
// known reachable inside the probe throttle window; the probe itself is a bare
|
|
1411
|
+
// TCP connect on wake-enabled hosts only, at most once per
|
|
1412
|
+
// REMOTE_WAKE_PROBE_MIN_INTERVAL_MS.
|
|
1413
|
+
if (!duplicate && (await remoteWake.hasWakeTarget(session))) {
|
|
1414
|
+
if (wantsWait) {
|
|
1415
|
+
// Send-and-wait keeps the response open anyway, so blocking on the wake is
|
|
1416
|
+
// simpler and more correct than buffering (buffering would break the wait).
|
|
1417
|
+
// A host that never comes back is an error here, as on the create/attach
|
|
1418
|
+
// paths: writing into the stalled pane would answer `delivered:true` plus a
|
|
1419
|
+
// timeout, which is the combination the API docs send callers to the wrong
|
|
1420
|
+
// recovery for.
|
|
1421
|
+
if (!(await remoteWake.ensureAwake(session))) {
|
|
1422
|
+
return createErrorResponse(ApiErrorCode.OPERATION_FAILED, `${session.remote?.label ?? 'the remote host'} did not come back after a wake-on-LAN request — nothing was sent`);
|
|
1423
|
+
}
|
|
1424
|
+
}
|
|
1425
|
+
else {
|
|
1426
|
+
const outcome = await remoteWake.handleInput(session, inputStr);
|
|
1427
|
+
// The registry holds the bytes and flushes them in order once the pane is
|
|
1428
|
+
// reattached. The client's ACK is this 200 — a tagged retry is deduped
|
|
1429
|
+
// (`shouldApplyInput` above already consumed the seq), so nothing is lost.
|
|
1430
|
+
// `buffered` is additive to the historical bare `{}`; `dropped` says the chunk
|
|
1431
|
+
// was over the wake buffer's cap and is GONE (a 200 with no field could not
|
|
1432
|
+
// tell delivered from buffered from dropped).
|
|
1433
|
+
if (outcome === 'buffered')
|
|
1434
|
+
return { buffered: true };
|
|
1435
|
+
if (outcome === 'dropped')
|
|
1436
|
+
return { buffered: true, dropped: true };
|
|
1437
|
+
}
|
|
1438
|
+
}
|
|
1208
1439
|
// Only a waiting request pays for the tmux probe: the browser's plain input path
|
|
1209
1440
|
// (thousands of calls per session) must stay exec-free.
|
|
1210
1441
|
const workerDead = wantsWait && workerIsDead(ctx.mux, session);
|
|
@@ -2273,10 +2504,15 @@ export function registerSessionRoutes(app, ctx) {
|
|
|
2273
2504
|
// returns null when unavailable, in which case we fall back to history.
|
|
2274
2505
|
const muxName = session.muxName;
|
|
2275
2506
|
const captureStartedAt = performance.now();
|
|
2507
|
+
// The visible path used to pass no options at all. It passes one now for a
|
|
2508
|
+
// single reason: `capturedGeometry` comes BACK on it, and the response has
|
|
2509
|
+
// to tell the client what size the frame it is about to render was built
|
|
2510
|
+
// for. See PaneCaptureOptions.capturedGeometry.
|
|
2511
|
+
const captureOpts = isFullReload
|
|
2512
|
+
? { fullHistory: true, historyLimitLines: tmuxHistoryLimit, maxCaptureBytes: terminalBufferMaxBytes }
|
|
2513
|
+
: {};
|
|
2276
2514
|
const liveMuxBuffer = muxName && typeof ctx.mux.captureActivePaneBuffer === 'function'
|
|
2277
|
-
? ctx.mux.captureActivePaneBuffer(muxName,
|
|
2278
|
-
? { fullHistory: true, historyLimitLines: tmuxHistoryLimit, maxCaptureBytes: terminalBufferMaxBytes }
|
|
2279
|
-
: undefined)
|
|
2515
|
+
? ctx.mux.captureActivePaneBuffer(muxName, captureOpts)
|
|
2280
2516
|
: null;
|
|
2281
2517
|
const captureFinishedAt = performance.now();
|
|
2282
2518
|
const hasLiveMuxBuffer = liveMuxBuffer !== null && liveMuxBuffer.length > 0;
|
|
@@ -2414,6 +2650,25 @@ export function registerSessionRoutes(app, ctx) {
|
|
|
2414
2650
|
// what existed before the cut. The gap is what the indicator reports.
|
|
2415
2651
|
retainedBytes: cleanBuffer.length,
|
|
2416
2652
|
source,
|
|
2653
|
+
// The pane geometry this frame was drawn for. A visible-frame capture
|
|
2654
|
+
// positions every row absolutely, so a client whose terminal has fewer
|
|
2655
|
+
// rows than this overwrites its last line with the overflow and loses
|
|
2656
|
+
// the rows underneath. The client compares these against its own size.
|
|
2657
|
+
//
|
|
2658
|
+
// BOTH FIELDS ARE ABSENT unless this response really carries a capture,
|
|
2659
|
+
// and that is the honest answer rather than a gap to paper over. Two
|
|
2660
|
+
// separate things can leave a frame unpositioned. The cursor query is
|
|
2661
|
+
// what produces the absolute addressing in the first place, so a capture
|
|
2662
|
+
// that lost it returned a raw frame with no row positioning in it. And a
|
|
2663
|
+
// capture can report geometry and STILL hand back nothing: the
|
|
2664
|
+
// full-history path returns '' for a pane holding nothing visible, which
|
|
2665
|
+
// drops `source` to `history` while `capturedGeometry` is already
|
|
2666
|
+
// written, so the geometry has to be suppressed HERE rather than trusted
|
|
2667
|
+
// to be missing. Naming a size for a body that is the byte stream would
|
|
2668
|
+
// describe a frame that was never drawn and invite the client to repair
|
|
2669
|
+
// damage that does not exist.
|
|
2670
|
+
captureCols: hasLiveMuxBuffer ? captureOpts.capturedGeometry?.cols : undefined,
|
|
2671
|
+
captureRows: hasLiveMuxBuffer ? captureOpts.capturedGeometry?.rows : undefined,
|
|
2417
2672
|
};
|
|
2418
2673
|
});
|
|
2419
2674
|
// ═══════════════════════════════════════════════════════════════
|
|
@@ -2616,7 +2871,7 @@ export function registerSessionRoutes(app, ctx) {
|
|
|
2616
2871
|
const capMsg = sessionCapacityMessage(ctx.sessions, owner);
|
|
2617
2872
|
if (capMsg)
|
|
2618
2873
|
return createErrorResponse(ApiErrorCode.SESSION_BUSY, capMsg);
|
|
2619
|
-
const { caseName = 'testcase', sessionName, mode = 'claude', modelOverride, openCodeConfig, codexConfig, geminiConfig, antigravityConfig, piConfig, grokConfig, deepSeekConfig, ompConfig, envOverrides, effort, parentSessionId, agentOrigin, } = parseBody(QuickStartSchema, req.body);
|
|
2874
|
+
const { caseName = 'testcase', sessionName, mode = 'claude', modelOverride, openCodeConfig, codexConfig, geminiConfig, antigravityConfig, piConfig, grokConfig, deepSeekConfig, ompConfig, envOverrides, effort, parentSessionId, agentOrigin, customModel, } = parseBody(QuickStartSchema, req.body);
|
|
2620
2875
|
// Resolved ONCE here: the same value labels a case directory this request creates
|
|
2621
2876
|
// (agent-case-marker.ts) and draws the tab lineage line on the session below.
|
|
2622
2877
|
const qsParentSessionId = resolveParentSessionId(ctx, req, parentSessionId, owner);
|
|
@@ -2660,14 +2915,41 @@ export function registerSessionRoutes(app, ctx) {
|
|
|
2660
2915
|
grokConfig ||
|
|
2661
2916
|
deepSeekConfig ||
|
|
2662
2917
|
ompConfig ||
|
|
2663
|
-
openCodeConfig
|
|
2664
|
-
|
|
2918
|
+
openCodeConfig ||
|
|
2919
|
+
customModel) {
|
|
2920
|
+
return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'envOverrides, effort, modelOverride, per-CLI config, and custom model endpoints are not supported for remote cases (they do not cross ssh). Configure the remote command via the host command override instead.');
|
|
2921
|
+
}
|
|
2922
|
+
// The user pressing "Run" on a case whose host is asleep IS an explicit wake
|
|
2923
|
+
// request (docs/remote-sessions.md §Wake-on-LAN), and the tmux probe below would
|
|
2924
|
+
// otherwise fail with "could not verify tmux on remote host …" — an ssh failure
|
|
2925
|
+
// that blames tmux for a machine that is merely suspended. Wired HERE, in the HTTP
|
|
2926
|
+
// route, and deliberately NOT in the shared session service: `cron-service.ts`
|
|
2927
|
+
// builds sessions through the service, and a wake down there would re-wake the
|
|
2928
|
+
// host on every schedule (the failure invariant #1 exists to prevent).
|
|
2929
|
+
const hostWake = await remoteWake.ensureHostAwake(wakeableHost(host), {
|
|
2930
|
+
timeoutMs: REMOTE_WAKE_REQUEST_READY_TIMEOUT_MS,
|
|
2931
|
+
// No session yet, so the wake events name their requester (multi-user routing).
|
|
2932
|
+
requestedBy: ownerFor(req),
|
|
2933
|
+
});
|
|
2934
|
+
if (hostWake === 'failed') {
|
|
2935
|
+
return createErrorResponse(ApiErrorCode.OPERATION_FAILED, `${host.label} did not come back after a wake-on-LAN request — the session was not started`);
|
|
2665
2936
|
}
|
|
2666
2937
|
// tmux is a hard prerequisite on the remote host (the agent runs inside a remote
|
|
2667
2938
|
// tmux server so it survives ssh drops). Probe before spawning so a missing tmux
|
|
2668
2939
|
// surfaces a clear, structured error instead of a dead "tmux: command not found" pane.
|
|
2669
2940
|
const tmuxCheck = await checkRemoteTmuxAvailable(host);
|
|
2670
2941
|
if (!tmuxCheck.ok) {
|
|
2942
|
+
// An unreachable host and a host without tmux fail the same way over ssh, so the
|
|
2943
|
+
// probe's own message would send the user hunting for a tmux install. Ask the
|
|
2944
|
+
// registry (which just probed, when it woke the host) which of the two it is.
|
|
2945
|
+
// `=== false` on purpose: a proxied host answers `null` (the probe cannot reach
|
|
2946
|
+
// it), and an unknown verdict must not replace the real ssh error with
|
|
2947
|
+
// "not reachable" over a host that is fine.
|
|
2948
|
+
if ((await remoteWake.checkHostReachable(wakeableHost(host))) === false) {
|
|
2949
|
+
return createErrorResponse(ApiErrorCode.OPERATION_FAILED, hostWake === 'no-target'
|
|
2950
|
+
? `${host.label} (${host.host}) is not reachable, and this host has no wake-on-LAN target — configure a MAC address or a wake command first`
|
|
2951
|
+
: `${host.label} (${host.host}) is not reachable`);
|
|
2952
|
+
}
|
|
2671
2953
|
return createErrorResponse(ApiErrorCode.OPERATION_FAILED, tmuxCheck.error || 'remote host is missing tmux');
|
|
2672
2954
|
}
|
|
2673
2955
|
casePath = remoteCase.remotePath;
|
|
@@ -2690,8 +2972,9 @@ export function registerSessionRoutes(app, ctx) {
|
|
|
2690
2972
|
grokConfig ||
|
|
2691
2973
|
deepSeekConfig ||
|
|
2692
2974
|
ompConfig ||
|
|
2693
|
-
openCodeConfig
|
|
2694
|
-
|
|
2975
|
+
openCodeConfig ||
|
|
2976
|
+
customModel) {
|
|
2977
|
+
return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'envOverrides, effort, per-CLI config, and custom model endpoints are not supported for docker cases (they do not cross into the container). Configure the container via the docker host command override instead.');
|
|
2695
2978
|
}
|
|
2696
2979
|
const availability = await checkDockerAvailable(host.engine);
|
|
2697
2980
|
if (!availability.ok) {
|
|
@@ -2937,7 +3220,122 @@ export function registerSessionRoutes(app, ctx) {
|
|
|
2937
3220
|
const { codexConfig: qsGatedCodexConfig, geminiConfig: qsGatedGeminiConfig, antigravityConfig: qsGatedAntigravityConfig, piConfig: qsGatedPiConfig, grokConfig: qsGatedGrokConfig, deepSeekConfig: qsGatedDeepSeekConfig, } = await _clampExternalCliBypassForOwner(owner, codexConfig, geminiConfig, antigravityConfig, piConfig, grokConfig, deepSeekConfig);
|
|
2938
3221
|
const qsTerminalHistoryConfig = await ctx.getTerminalHistoryConfig();
|
|
2939
3222
|
const qsGatedEnvOverrides = await clampEnvOverridesForOwner(owner, envOverrides);
|
|
2940
|
-
const
|
|
3223
|
+
const qsResolvedOmpConfig = resolveOmpConfigForCreate(mode, resolvedCasePath, ompConfig);
|
|
3224
|
+
// Custom Model Endpoint Profiles, applied AT CREATE TIME (docs/custom-model-endpoints-plan.md)
|
|
3225
|
+
// rather than via the dedicated restart-in-place route (POST /api/sessions/:id/custom-
|
|
3226
|
+
// model, still what an ALREADY-RUNNING session uses to switch later): computing the
|
|
3227
|
+
// injection before the process exists and launching directly on it avoids the visible
|
|
3228
|
+
// native-boot-then-restart the restart-after-launch design otherwise shows on every
|
|
3229
|
+
// custom-model run — most jarring on a CLI like Codex whose TUI fully reinitializes.
|
|
3230
|
+
// Mirrors the dedicated route's own checks (llama-swap conflict, unsupported CLI,
|
|
3231
|
+
// unknown endpoint, a model id the CLI's argv pattern can't carry) rather than trusting
|
|
3232
|
+
// a lighter version of them, since this is the same server-side authority reached a
|
|
3233
|
+
// different way, not a separate, less-checked path.
|
|
3234
|
+
let qsCustomModelEnvOverrides = qsGatedEnvOverrides;
|
|
3235
|
+
// Only the INJECTED keys (never the caller's envOverrides merged in) — this is what
|
|
3236
|
+
// setCustomModel() bookkeeping must be given below. The Session constructor already
|
|
3237
|
+
// applies qsCustomModelEnvOverrides (the full merged set) directly; re-merging that
|
|
3238
|
+
// full set into setCustomModel() would put CLAUDE_CODE_EFFORT_LEVEL back after the
|
|
3239
|
+
// constructor stripped it (see setCustomModel()'s own doc comment in session.ts).
|
|
3240
|
+
let qsCustomModelAppliedEnvOverrides;
|
|
3241
|
+
let qsCustomModelLaunchModel;
|
|
3242
|
+
let qsCustomModelSessionId;
|
|
3243
|
+
let qsCustomModelSwapInProgress = false;
|
|
3244
|
+
let qsCustomModelBookkeeping;
|
|
3245
|
+
if (customModel) {
|
|
3246
|
+
const cmEntry = getCli(mode);
|
|
3247
|
+
if (!cmEntry)
|
|
3248
|
+
return createErrorResponse(ApiErrorCode.INVALID_INPUT, `No CLI registry entry for mode ${mode}`);
|
|
3249
|
+
if (cmEntry.capabilities.customModelInjection.kind === 'unsupported') {
|
|
3250
|
+
return createErrorResponse(ApiErrorCode.OPERATION_FAILED, `${mode} has no known custom-model mechanism`);
|
|
3251
|
+
}
|
|
3252
|
+
const cmHosts = await readCustomModelHosts(CODEMAN_CONFIG_DIR);
|
|
3253
|
+
const cmEndpoint = cmHosts.find((h) => h.id === customModel.endpointId);
|
|
3254
|
+
if (!cmEndpoint)
|
|
3255
|
+
return createErrorResponse(ApiErrorCode.NOT_FOUND, 'Model endpoint not found');
|
|
3256
|
+
const cmContextLength = cmEndpoint.modelContextLengths?.[customModel.modelId];
|
|
3257
|
+
// See the dedicated route's own comment for the full reasoning: some CLIs' own fixed
|
|
3258
|
+
// overhead can exceed a small enough real context on the very first message,
|
|
3259
|
+
// regardless of contextLengthVar. Warn before creating a session that's certain to
|
|
3260
|
+
// fail immediately.
|
|
3261
|
+
// See the dedicated route above for why this reads `confirmedContext` and never
|
|
3262
|
+
// `confirmedSwap`.
|
|
3263
|
+
if (!(customModel.confirmed || customModel.confirmedContext) &&
|
|
3264
|
+
exceedsSafeContextFloor(cmEntry, cmContextLength)) {
|
|
3265
|
+
return {
|
|
3266
|
+
requiresContextWarning: true,
|
|
3267
|
+
modelId: customModel.modelId,
|
|
3268
|
+
contextLength: cmContextLength,
|
|
3269
|
+
minSafeContextTokens: CLAUDE_MIN_SAFE_CONTEXT_TOKENS,
|
|
3270
|
+
};
|
|
3271
|
+
}
|
|
3272
|
+
// See the dedicated route's own comment for the full reasoning: llama.cpp runs one
|
|
3273
|
+
// model at a time, llama-swap swaps on demand, and switching away from what another
|
|
3274
|
+
// live session is actively using deserves a warning, not a silent switch. There is no
|
|
3275
|
+
// "self" to exclude from the affected-sessions scan here — this session doesn't exist
|
|
3276
|
+
// yet.
|
|
3277
|
+
const cmSwapStatus = await getLlamaSwapStatus(cmEndpoint);
|
|
3278
|
+
const cmCurrentlyLoaded = cmSwapStatus.running.find((r) => r.state === 'ready')?.model ?? cmSwapStatus.running[0]?.model;
|
|
3279
|
+
const cmSwapNeeded = cmSwapStatus.isLlamaSwap && !!cmCurrentlyLoaded && cmCurrentlyLoaded !== customModel.modelId;
|
|
3280
|
+
// Broader than cmSwapNeeded (which only gates the confirmation ask above): true
|
|
3281
|
+
// whenever the TARGET model isn't already loaded and ready, including when nothing
|
|
3282
|
+
// is loaded at all yet. Drives the actual load trigger below.
|
|
3283
|
+
const cmTargetReady = cmSwapStatus.running.some((r) => r.model === customModel.modelId && r.state === 'ready');
|
|
3284
|
+
qsCustomModelSwapInProgress = cmSwapStatus.isLlamaSwap && !cmTargetReady;
|
|
3285
|
+
if (cmSwapNeeded && !(customModel.confirmed || customModel.confirmedSwap)) {
|
|
3286
|
+
const cmConflicting = [...ctx.sessions.values()].filter((s) => s.customModel?.endpointId === cmEndpoint.id && s.customModel?.modelId === cmCurrentlyLoaded);
|
|
3287
|
+
if (cmConflicting.length > 0) {
|
|
3288
|
+
// Same reasoning as the dedicated /custom-model route above: the swap is
|
|
3289
|
+
// still blocked pending confirmation regardless of ownership, but a
|
|
3290
|
+
// non-admin caller only learns the names of sessions they can access.
|
|
3291
|
+
const cmRequestUser = getAuthUser(req);
|
|
3292
|
+
const cmAffectedSessions = cmConflicting
|
|
3293
|
+
.filter((s) => canAccessOwned(cmRequestUser, s.owner))
|
|
3294
|
+
.map((s) => ({ id: s.id, name: s.name }));
|
|
3295
|
+
return {
|
|
3296
|
+
requiresConfirmation: true,
|
|
3297
|
+
currentlyLoadedModel: cmCurrentlyLoaded,
|
|
3298
|
+
affectedSessions: cmAffectedSessions,
|
|
3299
|
+
};
|
|
3300
|
+
}
|
|
3301
|
+
}
|
|
3302
|
+
// Minted ourselves (rather than left to Session's own default) so the injection
|
|
3303
|
+
// below — and any configDir it writes — can target the REAL id the session launches
|
|
3304
|
+
// with, not a placeholder: `new Session({ id: ... })` accepts an explicit id for
|
|
3305
|
+
// exactly this reason.
|
|
3306
|
+
qsCustomModelSessionId = randomUUID();
|
|
3307
|
+
const cmApplied = applyCustomModelInjection(cmEntry, cmEndpoint, customModel.modelId, qsCustomModelSessionId, cmContextLength, resolvedCasePath);
|
|
3308
|
+
if (!cmApplied) {
|
|
3309
|
+
return createErrorResponse(ApiErrorCode.OPERATION_FAILED, `${mode} has no known custom-model mechanism`);
|
|
3310
|
+
}
|
|
3311
|
+
const cmModelSpec = cmEntry.launch.params.model;
|
|
3312
|
+
if (cmApplied.launchModel !== undefined &&
|
|
3313
|
+
cmModelSpec?.type === 'token' &&
|
|
3314
|
+
!matchesPattern(cmModelSpec.pattern, cmApplied.launchModel)) {
|
|
3315
|
+
removeConfigDir(cmApplied.configDir);
|
|
3316
|
+
return createErrorResponse(ApiErrorCode.INVALID_INPUT, `Model id ${JSON.stringify(customModel.modelId)} cannot be passed to ${mode} on its command line`);
|
|
3317
|
+
}
|
|
3318
|
+
qsCustomModelEnvOverrides = { ...qsGatedEnvOverrides, ...cmApplied.envOverrides };
|
|
3319
|
+
qsCustomModelAppliedEnvOverrides = cmApplied.envOverrides;
|
|
3320
|
+
qsCustomModelLaunchModel = cmApplied.launchModel;
|
|
3321
|
+
qsCustomModelBookkeeping = {
|
|
3322
|
+
endpointId: cmEndpoint.id,
|
|
3323
|
+
modelId: customModel.modelId,
|
|
3324
|
+
label: cmEndpoint.label,
|
|
3325
|
+
envKeys: cmApplied.envKeys,
|
|
3326
|
+
configDir: cmApplied.configDir,
|
|
3327
|
+
launchModel: cmApplied.launchModel,
|
|
3328
|
+
};
|
|
3329
|
+
// Actually kick off llama-swap's load now — see the dedicated apply route's own
|
|
3330
|
+
// comment on triggerLlamaSwapLoad for why this can't just wait on the launched CLI's
|
|
3331
|
+
// first prompt. Fired here, before the session is even created, so the load starts
|
|
3332
|
+
// concurrently with Claude/Codex/etc. booting rather than after.
|
|
3333
|
+
if (qsCustomModelSwapInProgress) {
|
|
3334
|
+
triggerLlamaSwapLoad(cmEndpoint, customModel.modelId);
|
|
3335
|
+
}
|
|
3336
|
+
}
|
|
3337
|
+
const qsSessionOptions = {
|
|
3338
|
+
id: qsCustomModelSessionId,
|
|
2941
3339
|
workingDir: resolvedCasePath,
|
|
2942
3340
|
name: sessionName ? sessionName.slice(0, MAX_SESSION_NAME_LENGTH) : '',
|
|
2943
3341
|
mux: ctx.mux,
|
|
@@ -2955,15 +3353,42 @@ export function registerSessionRoutes(app, ctx) {
|
|
|
2955
3353
|
piConfig: mode === 'pi' ? qsGatedPiConfig : undefined,
|
|
2956
3354
|
grokConfig: mode === 'grok' ? qsGatedGrokConfig : undefined,
|
|
2957
3355
|
deepSeekConfig: mode === 'deepseek' ? qsGatedDeepSeekConfig : undefined,
|
|
2958
|
-
ompConfig:
|
|
2959
|
-
envOverrides:
|
|
3356
|
+
ompConfig: qsResolvedOmpConfig,
|
|
3357
|
+
envOverrides: qsCustomModelEnvOverrides,
|
|
2960
3358
|
effort,
|
|
2961
3359
|
remote,
|
|
2962
3360
|
docker,
|
|
2963
3361
|
resumeSessionId: dockerResumeId,
|
|
2964
3362
|
tmuxHistoryLimit: qsTerminalHistoryConfig.tmuxHistoryLimit,
|
|
2965
3363
|
parentSessionId: qsParentSessionId,
|
|
2966
|
-
}
|
|
3364
|
+
};
|
|
3365
|
+
// Force the custom-model selection's launchModel (pi/omp `custom/<id>`, grok's
|
|
3366
|
+
// `[model.<name>]` block name) onto whichever config field the registry says the
|
|
3367
|
+
// CLI's `model` launch param lives in — mirrors Session._withCustomModelLaunchModel,
|
|
3368
|
+
// which the restart-in-place path already uses, rather than a hardcoded per-CLI
|
|
3369
|
+
// branch here that a CLI landing its injection recipe later would silently miss.
|
|
3370
|
+
if (qsCustomModelLaunchModel !== undefined) {
|
|
3371
|
+
const qsCustomModelField = getCli(mode)?.launch.legacyConfigField;
|
|
3372
|
+
if (qsCustomModelField) {
|
|
3373
|
+
const qsSessionOptionsBag = qsSessionOptions;
|
|
3374
|
+
qsSessionOptionsBag[qsCustomModelField] = {
|
|
3375
|
+
...(qsSessionOptionsBag[qsCustomModelField] ?? {}),
|
|
3376
|
+
model: qsCustomModelLaunchModel,
|
|
3377
|
+
};
|
|
3378
|
+
}
|
|
3379
|
+
else {
|
|
3380
|
+
qsSessionOptions.model = qsCustomModelLaunchModel;
|
|
3381
|
+
}
|
|
3382
|
+
}
|
|
3383
|
+
const session = new Session(qsSessionOptions);
|
|
3384
|
+
// Records the selection for session.customModel/getCustomModelForPersist() and future
|
|
3385
|
+
// clear/switch calls — the actual env vars and launch-model config are already part of
|
|
3386
|
+
// the launch above (constructor envOverrides, piConfig/grokConfig/ompConfig.model), so
|
|
3387
|
+
// this is bookkeeping only, never a restart: setCustomModel() is synchronous state, no
|
|
3388
|
+
// tmux IO of its own (see its own doc comment in session.ts).
|
|
3389
|
+
if (qsCustomModelBookkeeping) {
|
|
3390
|
+
session.setCustomModel(qsCustomModelBookkeeping, qsCustomModelAppliedEnvOverrides);
|
|
3391
|
+
}
|
|
2967
3392
|
// Auto-detect completion phrase from CLAUDE.md BEFORE broadcasting
|
|
2968
3393
|
// so the initial state already has the phrase configured (only if globally enabled)
|
|
2969
3394
|
if (getCli(mode)?.capabilities.ralph && !remote && !docker && ctx.store.getConfig().ralphEnabled) {
|
|
@@ -3051,6 +3476,7 @@ export function registerSessionRoutes(app, ctx) {
|
|
|
3051
3476
|
sessionId: session.id,
|
|
3052
3477
|
casePath: resolvedCasePath,
|
|
3053
3478
|
caseName,
|
|
3479
|
+
...(customModel ? { modelSwapInProgress: qsCustomModelSwapInProgress } : {}),
|
|
3054
3480
|
};
|
|
3055
3481
|
}
|
|
3056
3482
|
catch (err) {
|
|
@@ -4122,5 +4548,10 @@ export function registerSessionRoutes(app, ctx) {
|
|
|
4122
4548
|
}
|
|
4123
4549
|
return { path: filepath, filename };
|
|
4124
4550
|
});
|
|
4551
|
+
// Returned so the server can own the registry's LIFETIME (drop state when a session is
|
|
4552
|
+
// cleaned up on any of its paths, resolve in-flight wakes on shutdown). The wake-CAPABLE
|
|
4553
|
+
// code stays here: `test/remote-wake.test.ts` pins that `server.ts` calls nothing but
|
|
4554
|
+
// `drop`/`stop` on this handle, so no timer path can reach a wake through it.
|
|
4555
|
+
return remoteWake;
|
|
4125
4556
|
}
|
|
4126
4557
|
//# sourceMappingURL=session-routes.js.map
|