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.
Files changed (185) hide show
  1. package/README.md +7 -6
  2. package/dist/config/cli-registry/schema.d.ts +8 -0
  3. package/dist/config/cli-registry/schema.d.ts.map +1 -1
  4. package/dist/config/cli-registry/schema.js +29 -0
  5. package/dist/config/cli-registry/schema.js.map +1 -1
  6. package/dist/config/cli-registry/stock.d.ts.map +1 -1
  7. package/dist/config/cli-registry/stock.js +62 -7
  8. package/dist/config/cli-registry/stock.js.map +1 -1
  9. package/dist/config/cli-registry/types.d.ts +62 -0
  10. package/dist/config/cli-registry/types.d.ts.map +1 -1
  11. package/dist/config/remote-wake-limits.d.ts +23 -0
  12. package/dist/config/remote-wake-limits.d.ts.map +1 -0
  13. package/dist/config/remote-wake-limits.js +23 -0
  14. package/dist/config/remote-wake-limits.js.map +1 -0
  15. package/dist/custom-model-hosts.d.ts +32 -0
  16. package/dist/custom-model-hosts.d.ts.map +1 -1
  17. package/dist/custom-model-hosts.js.map +1 -1
  18. package/dist/custom-model-injection-apply.d.ts +29 -1
  19. package/dist/custom-model-injection-apply.d.ts.map +1 -1
  20. package/dist/custom-model-injection-apply.js +191 -5
  21. package/dist/custom-model-injection-apply.js.map +1 -1
  22. package/dist/custom-model-injection.d.ts +35 -9
  23. package/dist/custom-model-injection.d.ts.map +1 -1
  24. package/dist/custom-model-injection.js +37 -16
  25. package/dist/custom-model-injection.js.map +1 -1
  26. package/dist/mux-interface.d.ts +12 -0
  27. package/dist/mux-interface.d.ts.map +1 -1
  28. package/dist/remote-hosts.d.ts +20 -0
  29. package/dist/remote-hosts.d.ts.map +1 -1
  30. package/dist/remote-hosts.js +33 -0
  31. package/dist/remote-hosts.js.map +1 -1
  32. package/dist/remote-wake.d.ts +446 -0
  33. package/dist/remote-wake.d.ts.map +1 -0
  34. package/dist/remote-wake.js +880 -0
  35. package/dist/remote-wake.js.map +1 -0
  36. package/dist/session-env-clamp.d.ts +12 -7
  37. package/dist/session-env-clamp.d.ts.map +1 -1
  38. package/dist/session-env-clamp.js +12 -7
  39. package/dist/session-env-clamp.js.map +1 -1
  40. package/dist/session-submit-verifier.d.ts +44 -0
  41. package/dist/session-submit-verifier.d.ts.map +1 -0
  42. package/dist/session-submit-verifier.js +122 -0
  43. package/dist/session-submit-verifier.js.map +1 -0
  44. package/dist/session.d.ts +9 -0
  45. package/dist/session.d.ts.map +1 -1
  46. package/dist/session.js +42 -1
  47. package/dist/session.js.map +1 -1
  48. package/dist/tmux-manager.d.ts.map +1 -1
  49. package/dist/tmux-manager.js +9 -0
  50. package/dist/tmux-manager.js.map +1 -1
  51. package/dist/types/session.d.ts +26 -0
  52. package/dist/types/session.d.ts.map +1 -1
  53. package/dist/types/session.js.map +1 -1
  54. package/dist/web/public/admin-ui.js.gz +0 -0
  55. package/dist/web/public/api-client.c9b1cddc.js.gz +0 -0
  56. package/dist/web/public/app.a792bf71.js +38 -0
  57. package/dist/web/public/app.a792bf71.js.br +0 -0
  58. package/dist/web/public/app.a792bf71.js.gz +0 -0
  59. package/dist/web/public/approvals-ui.js.gz +0 -0
  60. package/dist/web/public/{constants.258b140f.js → constants.b26748e9.js} +123 -0
  61. package/dist/web/public/constants.b26748e9.js.br +0 -0
  62. package/dist/web/public/constants.b26748e9.js.gz +0 -0
  63. package/dist/web/public/cron-ui.js.gz +0 -0
  64. package/dist/web/public/entrance-animations.js.gz +0 -0
  65. package/dist/web/public/home-sessions.js.gz +0 -0
  66. package/dist/web/public/host-wake-ui.js +440 -0
  67. package/dist/web/public/host-wake-ui.js.br +0 -0
  68. package/dist/web/public/host-wake-ui.js.gz +0 -0
  69. package/dist/web/public/i18n.d22be5c4.js +1 -0
  70. package/dist/web/public/i18n.d22be5c4.js.br +0 -0
  71. package/dist/web/public/i18n.d22be5c4.js.gz +0 -0
  72. package/dist/web/public/{image-input.cd4b97c4.js → image-input.a216cf9e.js} +8 -4
  73. package/dist/web/public/image-input.a216cf9e.js.br +0 -0
  74. package/dist/web/public/image-input.a216cf9e.js.gz +0 -0
  75. package/dist/web/public/index.html +191 -12
  76. package/dist/web/public/index.html.br +0 -0
  77. package/dist/web/public/index.html.gz +0 -0
  78. package/dist/web/public/input-cjk.8bc46081.js.gz +0 -0
  79. package/dist/web/public/{keyboard-accessory.2cf04f17.js → keyboard-accessory.c1e7c7b7.js} +280 -15
  80. package/dist/web/public/keyboard-accessory.c1e7c7b7.js.br +0 -0
  81. package/dist/web/public/keyboard-accessory.c1e7c7b7.js.gz +0 -0
  82. package/dist/web/public/mobile-handlers.6f354a87.js.gz +0 -0
  83. package/dist/web/public/mobile-overview.js.gz +0 -0
  84. package/dist/web/public/mobile.6caaa28a.css.gz +0 -0
  85. package/dist/web/public/notification-manager.36ea4624.js.gz +0 -0
  86. package/dist/web/public/orchestrator-panel.js.gz +0 -0
  87. package/dist/web/public/{panels-ui.5b07ad14.js → panels-ui.28df5053.js} +5 -5
  88. package/dist/web/public/panels-ui.28df5053.js.br +0 -0
  89. package/dist/web/public/panels-ui.28df5053.js.gz +0 -0
  90. package/dist/web/public/ralph-panel.6de2d0f8.js.gz +0 -0
  91. package/dist/web/public/ralph-wizard.13a1831e.js.gz +0 -0
  92. package/dist/web/public/readmymind-ui.js.gz +0 -0
  93. package/dist/web/public/reboot-restore-ui.js.gz +0 -0
  94. package/dist/web/public/respawn-ui.ff0dae4c.js.gz +0 -0
  95. package/dist/web/public/sanitize-html.bc7078d6.js.gz +0 -0
  96. package/dist/web/public/session-lineage.js.gz +0 -0
  97. package/dist/web/public/session-ui.7df17fd3.js +88 -0
  98. package/dist/web/public/session-ui.7df17fd3.js.br +0 -0
  99. package/dist/web/public/session-ui.7df17fd3.js.gz +0 -0
  100. package/dist/web/public/settings-ui.df5ffb68.js +78 -0
  101. package/dist/web/public/settings-ui.df5ffb68.js.br +0 -0
  102. package/dist/web/public/settings-ui.df5ffb68.js.gz +0 -0
  103. package/dist/web/public/styles.74f312e5.css +1 -0
  104. package/dist/web/public/styles.74f312e5.css.br +0 -0
  105. package/dist/web/public/styles.74f312e5.css.gz +0 -0
  106. package/dist/web/public/subagent-windows.e6ca799f.js.gz +0 -0
  107. package/dist/web/public/sw.js.gz +0 -0
  108. package/dist/web/public/tab-rail-resize.42c24949.js.gz +0 -0
  109. package/dist/web/public/terminal-keycode229-recovery.fb91b25b.js.gz +0 -0
  110. package/dist/web/public/terminal-split.js +804 -0
  111. package/dist/web/public/terminal-split.js.br +0 -0
  112. package/dist/web/public/terminal-split.js.gz +0 -0
  113. package/dist/web/public/terminal-ui.32fbeaf6.js +2 -0
  114. package/dist/web/public/terminal-ui.32fbeaf6.js.br +0 -0
  115. package/dist/web/public/terminal-ui.32fbeaf6.js.gz +0 -0
  116. package/dist/web/public/ultracode-panel.js.gz +0 -0
  117. package/dist/web/public/ultracode-windows.js.gz +0 -0
  118. package/dist/web/public/upload.html.gz +0 -0
  119. package/dist/web/public/vendor/dompurify.min.js.gz +0 -0
  120. package/dist/web/public/vendor/marked.min.js.gz +0 -0
  121. package/dist/web/public/vendor/xterm-addon-fit.min.js.gz +0 -0
  122. package/dist/web/public/vendor/xterm-addon-serialize.min.js.gz +0 -0
  123. package/dist/web/public/vendor/xterm-addon-unicode11.min.js.gz +0 -0
  124. package/dist/web/public/vendor/xterm-addon-webgl.min.js.gz +0 -0
  125. package/dist/web/public/vendor/xterm-predictive-echo.bd6882b8.js.gz +0 -0
  126. package/dist/web/public/vendor/xterm-zerolag-input.6fee72f2.js.gz +0 -0
  127. package/dist/web/public/vendor/xterm.css.gz +0 -0
  128. package/dist/web/public/vendor/xterm.min.js.gz +0 -0
  129. package/dist/web/public/voice-input.c4b51eb6.js.gz +0 -0
  130. package/dist/web/public/voice-pcm-worklet.js.gz +0 -0
  131. package/dist/web/public/webview-tabs.js.gz +0 -0
  132. package/dist/web/routes/custom-model-routes.d.ts +173 -0
  133. package/dist/web/routes/custom-model-routes.d.ts.map +1 -1
  134. package/dist/web/routes/custom-model-routes.js +628 -11
  135. package/dist/web/routes/custom-model-routes.js.map +1 -1
  136. package/dist/web/routes/index.d.ts +1 -1
  137. package/dist/web/routes/index.d.ts.map +1 -1
  138. package/dist/web/routes/index.js +1 -1
  139. package/dist/web/routes/index.js.map +1 -1
  140. package/dist/web/routes/session-routes.d.ts +6 -1
  141. package/dist/web/routes/session-routes.d.ts.map +1 -1
  142. package/dist/web/routes/session-routes.js +448 -17
  143. package/dist/web/routes/session-routes.js.map +1 -1
  144. package/dist/web/schemas.d.ts +15 -0
  145. package/dist/web/schemas.d.ts.map +1 -1
  146. package/dist/web/schemas.js +76 -0
  147. package/dist/web/schemas.js.map +1 -1
  148. package/dist/web/server.d.ts +21 -0
  149. package/dist/web/server.d.ts.map +1 -1
  150. package/dist/web/server.js +142 -9
  151. package/dist/web/server.js.map +1 -1
  152. package/dist/web/sse-events.d.ts +25 -2
  153. package/dist/web/sse-events.d.ts.map +1 -1
  154. package/dist/web/sse-events.js +30 -3
  155. package/dist/web/sse-events.js.map +1 -1
  156. package/package.json +1 -1
  157. package/skills/codeman/SKILL.md +75 -26
  158. package/skills/codeman/preamble.sh +66 -19
  159. package/skills/codeman/reference/recipes.md +1 -1
  160. package/dist/web/public/app.6d2dc4e8.js +0 -38
  161. package/dist/web/public/app.6d2dc4e8.js.br +0 -0
  162. package/dist/web/public/app.6d2dc4e8.js.gz +0 -0
  163. package/dist/web/public/constants.258b140f.js.br +0 -0
  164. package/dist/web/public/constants.258b140f.js.gz +0 -0
  165. package/dist/web/public/i18n.5f897ed5.js +0 -1
  166. package/dist/web/public/i18n.5f897ed5.js.br +0 -0
  167. package/dist/web/public/i18n.5f897ed5.js.gz +0 -0
  168. package/dist/web/public/image-input.cd4b97c4.js.br +0 -0
  169. package/dist/web/public/image-input.cd4b97c4.js.gz +0 -0
  170. package/dist/web/public/keyboard-accessory.2cf04f17.js.br +0 -0
  171. package/dist/web/public/keyboard-accessory.2cf04f17.js.gz +0 -0
  172. package/dist/web/public/panels-ui.5b07ad14.js.br +0 -0
  173. package/dist/web/public/panels-ui.5b07ad14.js.gz +0 -0
  174. package/dist/web/public/session-ui.42b81477.js +0 -77
  175. package/dist/web/public/session-ui.42b81477.js.br +0 -0
  176. package/dist/web/public/session-ui.42b81477.js.gz +0 -0
  177. package/dist/web/public/settings-ui.58f1d756.js +0 -67
  178. package/dist/web/public/settings-ui.58f1d756.js.br +0 -0
  179. package/dist/web/public/settings-ui.58f1d756.js.gz +0 -0
  180. package/dist/web/public/styles.e6edfb0b.css +0 -1
  181. package/dist/web/public/styles.e6edfb0b.css.br +0 -0
  182. package/dist/web/public/styles.e6edfb0b.css.gz +0 -0
  183. package/dist/web/public/terminal-ui.4f8d5820.js +0 -2
  184. package/dist/web/public/terminal-ui.4f8d5820.js.br +0 -0
  185. 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
- export function registerSessionRoutes(app, ctx) {
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, isFullReload
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
- return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'envOverrides, effort, modelOverride, and per-CLI config are not supported for remote cases (they do not cross ssh). Configure the remote command via the host command override instead.');
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
- return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'envOverrides, effort, and per-CLI config are not supported for docker cases (they do not cross into the container). Configure the container via the docker host command override instead.');
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 session = new Session({
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: resolveOmpConfigForCreate(mode, resolvedCasePath, ompConfig),
2959
- envOverrides: qsGatedEnvOverrides,
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