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
@@ -14,7 +14,7 @@
14
14
  * admin-configured, so this is defence in depth rather than the only gate.
15
15
  */
16
16
  import { ApiErrorCode, createErrorResponse } from '../../types.js';
17
- import { isAdmin, parseBody } from '../route-helpers.js';
17
+ import { isAdmin, parseBody, readJsonConfig, SETTINGS_PATH } from '../route-helpers.js';
18
18
  import { isMultiUserMode } from '../../config/multiuser.js';
19
19
  import { getDataDir } from '../../config/instance.js';
20
20
  import { isBlockedWebviewUrl } from '../webview-egress-policy.js';
@@ -23,13 +23,90 @@ import { CustomModelHostSchema } from '../schemas.js';
23
23
  import { readCustomModelHosts, writeCustomModelHosts } from '../../custom-model-hosts.js';
24
24
  const CODEMAN_CONFIG_DIR = getDataDir();
25
25
  const DISCOVER_TIMEOUT_MS = 8000;
26
+ const PROPS_TIMEOUT_MS = 5000;
27
+ /**
28
+ * Claude Code's own system prompt + tool schemas cost roughly this many tokens on EVERY
29
+ * request, before a single character of conversation history — confirmed live, twice, on
30
+ * requests reporting `in:0 out:0` (the very first exchange) failing at ~36.4K tokens. No
31
+ * `CLAUDE_CODE_MAX_CONTEXT_TOKENS` value fixes this: that setting only changes when Claude
32
+ * Code decides to COMPACT conversation history, and there is no history yet on the first
33
+ * message for it to trim. A model whose real context is below this floor will refuse
34
+ * Claude Code's very first message outright, unconditionally.
35
+ *
36
+ * Set well above the ~36.4K actually measured — CLAUDE.md size, active MCP servers, and
37
+ * enabled skills all add to a project's real baseline, so the observed figure is a floor
38
+ * for THAT one workspace, not a ceiling for every one. Erring conservative here means a
39
+ * borderline-safe model still gets warned about (the user can launch anyway), rather than
40
+ * this floor missing a genuinely-too-small one because a smaller test project happened to
41
+ * fit.
42
+ */
43
+ export const CLAUDE_MIN_SAFE_CONTEXT_TOKENS = 40000;
44
+ /**
45
+ * True when applying this model to this CLI is heading for a guaranteed first-message
46
+ * failure per `CLAUDE_MIN_SAFE_CONTEXT_TOKENS` above. Gated on `contextLengthVar` (today,
47
+ * only claude's registry entry declares one) rather than a hardcoded mode check: a CLI
48
+ * with a small enough baseline of its own to never trip this would have no reason to
49
+ * declare the field in the first place, so the check simply never applies to it.
50
+ */
51
+ export function exceedsSafeContextFloor(entry, contextLength) {
52
+ const cap = entry.capabilities.customModelInjection;
53
+ if (cap.kind !== 'env' || !cap.contextLengthVar)
54
+ return false;
55
+ return typeof contextLength === 'number' && contextLength < CLAUDE_MIN_SAFE_CONTEXT_TOKENS;
56
+ }
26
57
  function adminOnly(req, reply) {
27
58
  if (!isMultiUserMode() || isAdmin(req))
28
59
  return null;
29
60
  reply.code(403);
30
61
  return createErrorResponse(ApiErrorCode.FORBIDDEN, 'Admin only in multi-user mode');
31
62
  }
32
- async function discoverModels(host) {
63
+ /**
64
+ * `defaultModelId` names the model the Run-menu picker applies for this endpoint with
65
+ * no further choice, so it must actually be one of the discovered `models` — a schema
66
+ * `.refine()` can't see across the two fields the way this can, and would also run on
67
+ * every unrelated field edit rather than only when either of these two changes.
68
+ */
69
+ function invalidDefaultModel(host) {
70
+ if (host.defaultModelId === undefined)
71
+ return null;
72
+ if ((host.models ?? []).includes(host.defaultModelId))
73
+ return null;
74
+ return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'defaultModelId must be one of the endpoint’s discovered models');
75
+ }
76
+ /**
77
+ * Never hand the stored credential back to the browser, on GET, POST or PUT
78
+ * alike — the file is written 0600 precisely because it holds one. `apiKeySet`
79
+ * is what lets the editor say "unchanged if left blank" without the client
80
+ * ever holding the real value: `applyStoredApiKey()` below is the other half,
81
+ * treating an absent key on PUT as "keep the stored one" rather than clearing
82
+ * it, which is what makes never returning it survivable for the edit flow.
83
+ */
84
+ function redactApiKey(host) {
85
+ const { apiKey, ...rest } = host;
86
+ return { ...rest, apiKeySet: !!apiKey };
87
+ }
88
+ /**
89
+ * A PUT body with no `apiKey` (or a blank one) means "leave it alone", never
90
+ * "clear it": the editor never receives the real value to resend deliberately
91
+ * unchanged (see redactApiKey), so the only way it can tell the two apart is
92
+ * by omission. There is deliberately no way to CLEAR a key back to unset this
93
+ * way — a pre-existing limitation, not something this changes.
94
+ */
95
+ function applyStoredApiKey(incoming, existing) {
96
+ return incoming.apiKey ? incoming : { ...incoming, apiKey: existing.apiKey };
97
+ }
98
+ /**
99
+ * `modelContextLengths`/`modelSizesGB` are server-populated by discovery, never
100
+ * user-entered, and PUT replaces the whole record — so merge them back in from the
101
+ * stored host rather than trust whatever the editor's body carried (or omitted).
102
+ * The editor only ever sends `models`/`lastDiscoveredAt` verbatim from its cached
103
+ * copy; requiring it to also round-trip these two is exactly the kind of thing a
104
+ * future caller forgets, same class of bug `applyStoredApiKey` exists to prevent.
105
+ */
106
+ function applyDiscoveredFields(incoming, existing) {
107
+ return { ...incoming, modelContextLengths: existing.modelContextLengths, modelSizesGB: existing.modelSizesGB };
108
+ }
109
+ function authHeaders(host) {
33
110
  const headers = {};
34
111
  const apiKey = host.apiKey?.trim();
35
112
  // Exactly ONE header, never both — see custom-model-hosts.ts's CustomModelAuthStyle
@@ -39,6 +116,81 @@ async function discoverModels(host) {
39
116
  headers.Authorization = `Bearer ${apiKey}`;
40
117
  if (apiKey && style === 'api-key')
41
118
  headers['api-key'] = apiKey;
119
+ return headers;
120
+ }
121
+ /**
122
+ * Best-effort: pulls a file size in GB out of a model's own `description`, when the
123
+ * server states one. llama-swap writes `"Auto-discovered 16.35 GB - parameters
124
+ * auto-fitted by llama.cpp"` for a model it found on disk itself; a hand-configured
125
+ * profile's own description (e.g. `"General-purpose reasoning model, MoE CPU-offloaded."`)
126
+ * has no such figure and correctly yields no estimate rather than a guess — there is no
127
+ * separate "give me the file size" endpoint to fall back on.
128
+ */
129
+ function parseSizeGB(description) {
130
+ if (typeof description !== 'string')
131
+ return undefined;
132
+ const match = /(\d+(?:\.\d+)?)\s*GB\b/i.exec(description);
133
+ if (!match)
134
+ return undefined;
135
+ const size = Number(match[1]);
136
+ return Number.isFinite(size) && size > 0 ? size : undefined;
137
+ }
138
+ /**
139
+ * Best-effort: fetches `GET /props?model=<id>` (llama.cpp-native, llama-swap-proxied) for
140
+ * ONE already-loaded model and pulls its real `n_ctx` out. Never called for a model that
141
+ * isn't already loaded — see the caller and `CustomModelHost.modelContextLengths` for why
142
+ * that's a hard safety requirement, not just a nicety: llama-swap treats this endpoint's
143
+ * `?model=` as a routing hint, and asking it about an unloaded model risks triggering an
144
+ * actual (slow, GPU-swapping) load as a side effect of what should be read-only discovery.
145
+ * Any failure (unreachable, non-2xx, missing/malformed field) is swallowed — one model's
146
+ * context length is a nice-to-have, never worth failing the whole discovery pass over.
147
+ *
148
+ * ⚠️ FALLBACK ONLY — confirmed live to be actively WRONG for a `--fit-ctx`-launched llama-
149
+ * swap backend: `/props`'s `n_ctx` read 154112 for a model llama-swap itself had launched
150
+ * with `--fit-ctx 16384` (visible in `/running`'s own `cmd`), and the real server then
151
+ * refused a request at the real 16384-token limit — `n_ctx` here appears to report the
152
+ * model's theoretical/trained maximum, not the runtime-configured one. `parseCtxFromCmd`
153
+ * (below), which reads the actual launch flag `/running` reports, is the primary source;
154
+ * this is only used when that parse comes up empty (no recognized flag in `cmd`, or `cmd`
155
+ * itself unavailable).
156
+ */
157
+ async function fetchContextLength(host, modelId, headers) {
158
+ try {
159
+ const url = new URL(`${host.baseUrl.replace(/\/+$/, '')}/props`);
160
+ url.searchParams.set('model', modelId);
161
+ const res = await webviewFetch(url, { headers, signal: AbortSignal.timeout(PROPS_TIMEOUT_MS) });
162
+ if (!res.ok)
163
+ return undefined;
164
+ const body = (await res.json());
165
+ const nCtx = body.n_ctx ?? body.default_generation_settings?.n_ctx;
166
+ return typeof nCtx === 'number' && Number.isFinite(nCtx) && nCtx > 0 ? nCtx : undefined;
167
+ }
168
+ catch {
169
+ return undefined;
170
+ }
171
+ }
172
+ /**
173
+ * Parses the REAL configured context size out of llama-swap's own launch command for a
174
+ * model (`/running`'s `cmd` field, e.g. `"llama-server -m ... --fit-ctx 16384 ..."`) —
175
+ * the primary source for `modelContextLengths`, preferred over `/props`'s `n_ctx` (see
176
+ * `fetchContextLength`'s own doc comment for why that field is unreliable here). Checks
177
+ * `--fit-ctx` first (llama-swap's own auto-fit flag), then the plain llama.cpp
178
+ * `-c`/`--ctx-size`/`--ctx_size` flags a hand-written launch command might use instead.
179
+ * Returns `undefined` when `cmd` has none of these — not every launch command needs to
180
+ * state one explicitly (llama.cpp has its own default), and guessing one would be worse
181
+ * than the "no override applied" the caller already treats an unknown length as.
182
+ */
183
+ function parseCtxFromCmd(cmd) {
184
+ if (typeof cmd !== 'string')
185
+ return undefined;
186
+ const match = /--fit-ctx\s+(\d+)/.exec(cmd) ?? /(?:^|\s)(?:-c|--ctx-size|--ctx_size)\s+(\d+)/.exec(cmd);
187
+ if (!match)
188
+ return undefined;
189
+ const value = Number(match[1]);
190
+ return Number.isFinite(value) && value > 0 ? value : undefined;
191
+ }
192
+ async function discoverModels(host) {
193
+ const headers = authHeaders(host);
42
194
  const res = await webviewFetch(new URL(`${host.baseUrl.replace(/\/+$/, '')}/v1/models`), {
43
195
  headers,
44
196
  signal: AbortSignal.timeout(DISCOVER_TIMEOUT_MS),
@@ -46,7 +198,43 @@ async function discoverModels(host) {
46
198
  if (!res.ok)
47
199
  throw new Error(`HTTP ${res.status}`);
48
200
  const body = (await res.json());
49
- return (body.data ?? []).map((m) => m.id).filter((id) => typeof id === 'string' && id.length > 0);
201
+ const entries = body.data ?? [];
202
+ const models = entries.map((m) => m.id).filter((id) => typeof id === 'string' && id.length > 0);
203
+ const sizesGB = {};
204
+ for (const entry of entries) {
205
+ if (typeof entry.id !== 'string' || !entry.id)
206
+ continue;
207
+ const size = parseSizeGB(entry.description);
208
+ if (size !== undefined)
209
+ sizesGB[entry.id] = size;
210
+ }
211
+ // llama-swap-specific, feature-detected: a server that never mentions `status` on ANY
212
+ // entry gets no context-length enrichment at all, rather than treating "no status field"
213
+ // as "assume unloaded" — either reading is a guess, and skipping is the safe one, since
214
+ // fetchContextLength must only ever run against a model this server itself calls loaded.
215
+ const hasStatusField = entries.some((m) => m && typeof m === 'object' && 'status' in m);
216
+ const contextLengths = {};
217
+ if (hasStatusField) {
218
+ const loadedIds = entries
219
+ .filter((m) => m.status && typeof m.status === 'object' && m.status.value === 'loaded')
220
+ .map((m) => m.id)
221
+ .filter((id) => typeof id === 'string' && id.length > 0);
222
+ if (loadedIds.length > 0) {
223
+ // Primary source: the REAL launch command (see parseCtxFromCmd's own doc comment
224
+ // for why /props's n_ctx cannot be trusted here). One /running call covers every
225
+ // loaded model, so this never costs more requests than the old /props-only path did
226
+ // when the cmd parse succeeds, and exactly one extra when it has to fall back.
227
+ const swapStatus = await getLlamaSwapStatus(host);
228
+ const cmdById = new Map(swapStatus.running.map((r) => [r.model, r.cmd]));
229
+ for (const id of loadedIds) {
230
+ const fromCmd = parseCtxFromCmd(cmdById.get(id));
231
+ const ctx = fromCmd ?? (await fetchContextLength(host, id, headers));
232
+ if (ctx !== undefined)
233
+ contextLengths[id] = ctx;
234
+ }
235
+ }
236
+ }
237
+ return { models, contextLengths, sizesGB };
50
238
  }
51
239
  /**
52
240
  * undici reports every network failure as `TypeError('fetch failed', { cause })`, with the
@@ -63,8 +251,407 @@ function describeFetchError(err) {
63
251
  }
64
252
  return message;
65
253
  }
254
+ /**
255
+ * Merges a fresh `GET /v1/models` result into a host record: stamps
256
+ * `lastDiscoveredAt`, and drops `defaultModelId` if it no longer appears in
257
+ * the fresh list (it would otherwise leave the Run-menu picker applying a
258
+ * model id the endpoint just told us it doesn't serve). Pure — no IO, so the
259
+ * manual route (which reports a fetch failure's *reason* to the caller) and
260
+ * the periodic sweep below (which only cares whether it can move on) can
261
+ * each do their own `discoverModels()` + error handling around one shared
262
+ * "how to apply a successful result" step.
263
+ */
264
+ const RUNNING_TIMEOUT_MS = 5000;
265
+ /**
266
+ * Distinguishes llama-swap from a plain llama.cpp/OpenAI-compatible server, and reports
267
+ * what llama-swap currently has loaded — llama.cpp only ever runs one GGUF at a time, and
268
+ * llama-swap unloads/reloads it on demand when a request asks for a different one, which
269
+ * can take anywhere from a few seconds to over a minute. Read-only: this never triggers a
270
+ * swap itself (unlike `/props?model=`, `/running` takes no `model` parameter to route by).
271
+ * Best-effort like `discoverModels()`'s siblings: any failure (unreachable, non-2xx,
272
+ * unexpected shape) reads as "not llama-swap", never thrown.
273
+ */
274
+ export async function getLlamaSwapStatus(host) {
275
+ try {
276
+ const res = await webviewFetch(new URL(`${host.baseUrl.replace(/\/+$/, '')}/running`), {
277
+ headers: authHeaders(host),
278
+ signal: AbortSignal.timeout(RUNNING_TIMEOUT_MS),
279
+ });
280
+ if (!res.ok)
281
+ return { isLlamaSwap: false, running: [] };
282
+ const body = (await res.json());
283
+ if (!Array.isArray(body.running))
284
+ return { isLlamaSwap: false, running: [] };
285
+ const running = body.running
286
+ .filter((r) => !!r && typeof r === 'object' && typeof r.model === 'string')
287
+ .map((r) => ({
288
+ model: r.model,
289
+ state: typeof r.state === 'string' ? r.state : 'unknown',
290
+ cmd: typeof r.cmd === 'string' ? r.cmd : undefined,
291
+ }));
292
+ return { isLlamaSwap: true, running };
293
+ }
294
+ catch {
295
+ return { isLlamaSwap: false, running: [] };
296
+ }
297
+ }
298
+ /** One open `/api/events` tail per endpoint, keyed by host id — see `getLatestLlamaSwapLogLine`. */
299
+ const llamaSwapLogTails = new Map();
300
+ /** A tail nothing has asked about in this long is closed by the next `pruneIdleLlamaSwapLogTails` sweep. */
301
+ const LOG_TAIL_IDLE_MS = 30_000;
302
+ /**
303
+ * Parses one `data: {...}` payload from llama-swap's `GET /api/events` SSE stream and
304
+ * returns the backend (never llama-swap's own proxy) log text it carries, or `undefined`
305
+ * for anything else (a different event `type`, a malformed frame, a proxy-sourced one).
306
+ *
307
+ * The real shape, confirmed live against a real llama-swap deployment — NOT documented
308
+ * anywhere the plan doc's original research found, and genuinely surprising the first
309
+ * time around: `GET /logs` (the endpoint that name suggests, and this feature's own
310
+ * first cut was built against) turns out to carry ONLY llama-swap's own proxy
311
+ * request-access log — it never once showed a single backend line even seconds after a
312
+ * real, confirmed model swap. The backend llama-server process's actual stdout
313
+ * (`load_model: ...`, `llama_server: model loaded`) only ever showed up in `/api/events`,
314
+ * as `{"type":"logData","data":"<JSON-string>"}` whose OWN `data` field parses to a
315
+ * second object, `{"data": "<newline-joined log text>", "source": "proxy" | "upstream"}`
316
+ * — `source` is the exact, explicit distinguisher (`upstream` = the backend process,
317
+ * `proxy` = llama-swap's own line), not a guessed regex against the text itself.
318
+ */
319
+ function parseBackendLogDataEvent(dataLine) {
320
+ let outer;
321
+ try {
322
+ outer = JSON.parse(dataLine);
323
+ }
324
+ catch {
325
+ return undefined;
326
+ }
327
+ if (!outer ||
328
+ typeof outer !== 'object' ||
329
+ outer.type !== 'logData' ||
330
+ typeof outer.data !== 'string') {
331
+ return undefined;
332
+ }
333
+ let inner;
334
+ try {
335
+ inner = JSON.parse(outer.data);
336
+ }
337
+ catch {
338
+ return undefined;
339
+ }
340
+ if (!inner ||
341
+ typeof inner !== 'object' ||
342
+ inner.source !== 'upstream' ||
343
+ typeof inner.data !== 'string') {
344
+ return undefined;
345
+ }
346
+ return inner.data;
347
+ }
348
+ /**
349
+ * Reads `GET /api/events` forever (until `entry.controller` aborts it), updating
350
+ * `entry.latestLine` with the most recent BACKEND log line seen (see
351
+ * `parseBackendLogDataEvent`). Fire-and-forget: the caller never awaits this — it runs
352
+ * for the tail's whole lifetime in the background, and `getLatestLlamaSwapLogLine` just
353
+ * reads whatever `entry.latestLine` currently holds. SSE frames are separated by a blank
354
+ * line (`\n\n`), buffered the same way `/running`'s NDJSON-shaped siblings buffer partial
355
+ * chunks — a frame split across two `reader.read()` calls must not be parsed early.
356
+ */
357
+ /**
358
+ * Cap on the unparsed remainder held between reads of the backend log stream. One
359
+ * SSE frame is a status line, so this is orders of magnitude more than a real frame
360
+ * needs; it exists so a server that never emits a frame boundary cannot grow the
361
+ * buffer without bound for the life of the connection.
362
+ */
363
+ const MAX_LOG_TAIL_BUFFER_CHARS = 64 * 1024;
364
+ async function pumpLlamaSwapLogTail(host, entry) {
365
+ try {
366
+ const res = await webviewFetch(new URL(`${host.baseUrl.replace(/\/+$/, '')}/api/events`), {
367
+ headers: authHeaders(host),
368
+ signal: entry.controller.signal,
369
+ });
370
+ if (!res.ok || !res.body)
371
+ return;
372
+ const reader = res.body.getReader();
373
+ const decoder = new TextDecoder();
374
+ let buffer = '';
375
+ for (;;) {
376
+ const { done, value } = await reader.read();
377
+ if (done)
378
+ break;
379
+ buffer += decoder.decode(value, { stream: true });
380
+ const frames = buffer.split('\n\n');
381
+ buffer = frames.pop() ?? '';
382
+ // The remainder only shrinks at a frame boundary, so a server that streams
383
+ // without `\n\n` (or one very long frame) would grow it for as long as the
384
+ // connection is held, which is indefinitely by design. Past the cap the
385
+ // partial frame cannot become a useful log line anyway, so drop it and
386
+ // resynchronise on the next boundary rather than buffering forever.
387
+ if (buffer.length > MAX_LOG_TAIL_BUFFER_CHARS)
388
+ buffer = '';
389
+ for (const frame of frames) {
390
+ const dataLine = frame.split('\n').find((l) => l.startsWith('data:'));
391
+ if (!dataLine)
392
+ continue;
393
+ const backendText = parseBackendLogDataEvent(dataLine.slice('data:'.length));
394
+ if (!backendText)
395
+ continue;
396
+ const lines = backendText.split('\n').filter((l) => l.trim());
397
+ if (lines.length > 0)
398
+ entry.latestLine = lines[lines.length - 1].trim();
399
+ }
400
+ }
401
+ }
402
+ catch {
403
+ // connection dropped / aborted / endpoint unreachable — a future access starts fresh
404
+ }
405
+ finally {
406
+ // Delete by IDENTITY, not just by key: an aborted pump can finish after a NEWER
407
+ // entry was already created for the same endpoint id (e.g. abort-then-immediately-
408
+ // re-request), and deleting unconditionally would remove that newer entry and orphan
409
+ // its connection — nothing would ever prune it, since pruneIdleLlamaSwapLogTails only
410
+ // walks entries still present in the map.
411
+ if (llamaSwapLogTails.get(host.id) === entry) {
412
+ llamaSwapLogTails.delete(host.id);
413
+ }
414
+ }
415
+ }
416
+ /**
417
+ * Real-time "what is llama.cpp actually doing right now" for the loading banner
418
+ * (docs/custom-model-endpoints-plan.md): llama-swap's `GET /api/events` SSE stream
419
+ * carries the backend llama-server process's own stdout — `load_model: loading model
420
+ * '<path>'`, `load_model: initializing, n_slots = N, n_ctx_slot = N`, `llama_server:
421
+ * model loaded`, etc — tagged `source: "upstream"`, distinct from llama-swap's own
422
+ * `source: "proxy"` request-access lines (see `parseBackendLogDataEvent`). Confirmed
423
+ * live against a real llama-swap deployment, including through an actual forced model
424
+ * swap end-to-end.
425
+ *
426
+ * Held OPEN per endpoint rather than re-opened on every 1s poll — confirmed live to stay
427
+ * open indefinitely (read past 220KB over 8 seconds with no `done`), unlike `/logs`
428
+ * (see `parseBackendLogDataEvent`'s doc comment), so reconnecting each poll would be
429
+ * pure waste. One connection is reused across every session currently watching a load on
430
+ * that endpoint; since llama.cpp/llama-swap only ever runs one model at a time, a line
431
+ * seen while a load is in flight is safe to attribute to that load (a deployment that
432
+ * could load several models concurrently would need a per-model tag this format doesn't
433
+ * provide).
434
+ *
435
+ * Lazily started on first access and idle-closed rather than left open forever — see
436
+ * `pruneIdleLlamaSwapLogTails`.
437
+ */
438
+ export function getLatestLlamaSwapLogLine(host) {
439
+ let entry = llamaSwapLogTails.get(host.id);
440
+ if (!entry) {
441
+ entry = { lastAccessedAt: Date.now(), controller: new AbortController() };
442
+ llamaSwapLogTails.set(host.id, entry);
443
+ void pumpLlamaSwapLogTail(host, entry);
444
+ }
445
+ entry.lastAccessedAt = Date.now();
446
+ return entry.latestLine;
447
+ }
448
+ /**
449
+ * Closes EVERY open log tail. The idle sweep above only runs on server.ts's periodic
450
+ * interval, and that interval is disposed on shutdown, so without this an outbound
451
+ * stream outlives `WebServer.stop()` against CLAUDE.md's "clear Maps in stop()" rule.
452
+ * Harmless today only because `cli.ts`'s shutdown handler reaches `process.exit(0)`,
453
+ * which is not a property to rely on: tests and any in-process restart do not.
454
+ */
455
+ export function closeAllLlamaSwapLogTails() {
456
+ for (const entry of llamaSwapLogTails.values())
457
+ entry.controller.abort();
458
+ llamaSwapLogTails.clear();
459
+ }
460
+ /**
461
+ * Closes any log tail nothing has called `getLatestLlamaSwapLogLine` about in
462
+ * `LOG_TAIL_IDLE_MS` — a stream nobody is polling is an open connection with nothing to
463
+ * show for it. Called from the same periodic sweep as `detectCustomModelSwapDisplacements`
464
+ * in server.ts, not its own timer.
465
+ */
466
+ export function pruneIdleLlamaSwapLogTails(now = Date.now()) {
467
+ for (const [id, entry] of llamaSwapLogTails) {
468
+ if (now - entry.lastAccessedAt > LOG_TAIL_IDLE_MS) {
469
+ entry.controller.abort();
470
+ llamaSwapLogTails.delete(id);
471
+ }
472
+ }
473
+ }
474
+ /**
475
+ * Actually kicks off llama-swap's lazy model load, rather than waiting for the launched
476
+ * CLI's own first prompt to do it. llama-swap has no separate "switch model" admin
477
+ * endpoint — the ONLY thing that starts a swap is a real inference request naming the
478
+ * model (confirmed live: applying a selection alone never appeared in the llama-swap
479
+ * server's own logs; nothing had actually asked it to load anything). This sends the
480
+ * smallest real request that will — `max_tokens: 1`, one throwaway user message — to
481
+ * `${baseUrl}/v1/chat/completions`, the OpenAI-compatible endpoint every supported
482
+ * harness already points at.
483
+ *
484
+ * Deliberately fire-and-forget: the caller (the apply/create routes) returns to the
485
+ * client immediately, and the frontend's own polling (`GET .../running-status`) is what
486
+ * actually confirms readiness — this call's response is never read, just its side
487
+ * effect. No abort/timeout of its own either: a real load can take well over a minute for
488
+ * a large model, and this is a normal long-running Node process, so there is nothing to
489
+ * clean up by cutting it short. Errors are swallowed for the same reason `discoverModels`'s
490
+ * siblings swallow theirs — one endpoint's hiccup here is a nice-to-have that failed, not
491
+ * something worth surfacing as a request failure four layers up.
492
+ */
493
+ export function triggerLlamaSwapLoad(host, modelId) {
494
+ const url = new URL(`${host.baseUrl.replace(/\/+$/, '')}/v1/chat/completions`);
495
+ webviewFetch(url, {
496
+ method: 'POST',
497
+ headers: { ...authHeaders(host), 'content-type': 'application/json' },
498
+ body: JSON.stringify({
499
+ model: modelId,
500
+ messages: [{ role: 'user', content: 'Hi' }],
501
+ max_tokens: 1,
502
+ stream: false,
503
+ }),
504
+ }).catch(() => {
505
+ // best-effort — see the doc comment above
506
+ });
507
+ }
508
+ function applyDiscoveredModels(host, result) {
509
+ const { models, contextLengths, sizesGB } = result;
510
+ const defaultModelId = host.defaultModelId && models.includes(host.defaultModelId) ? host.defaultModelId : undefined;
511
+ // Merge onto what's already known rather than replacing: a model not probed this round
512
+ // (not currently loaded) keeps whatever context length an earlier round already learned
513
+ // for it, and one no longer in the fresh list is dropped, same reasoning as defaultModelId.
514
+ const merged = { ...host.modelContextLengths, ...contextLengths };
515
+ const kept = Object.fromEntries(Object.entries(merged).filter(([id]) => models.includes(id)));
516
+ const modelContextLengths = Object.keys(kept).length > 0 ? kept : undefined;
517
+ // sizesGB, unlike contextLengths, is populated for every model in the SAME pass (no
518
+ // loaded-only restriction — see parseSizeGB), so this is closer to a plain replace, but
519
+ // still merges onto the previous round rather than dropping a size for a model whose
520
+ // description happened to omit the figure on this particular pass.
521
+ const mergedSizes = { ...host.modelSizesGB, ...sizesGB };
522
+ const keptSizes = Object.fromEntries(Object.entries(mergedSizes).filter(([id]) => models.includes(id)));
523
+ const modelSizesGB = Object.keys(keptSizes).length > 0 ? keptSizes : undefined;
524
+ return {
525
+ ...host,
526
+ models,
527
+ defaultModelId,
528
+ modelContextLengths,
529
+ modelSizesGB,
530
+ lastDiscoveredAt: new Date().toISOString(),
531
+ };
532
+ }
533
+ /**
534
+ * `customModelEndpointsEnabled` defaults OFF (unlike `showPlanUsageLimits`'s
535
+ * absent-means-on in `readPlanUsageTelemetryEnabled`), so mirror the frontend's
536
+ * own gate (`session-ui.js`'s `!settings.customModelEndpointsEnabled`) rather
537
+ * than that reader's default. Exists so the periodic re-discovery sweep in
538
+ * server.ts can skip entirely while the feature is off, instead of polling
539
+ * every saved endpoint forever regardless of the setting.
540
+ */
541
+ export async function readCustomModelEndpointsEnabled() {
542
+ const settings = await readJsonConfig(SETTINGS_PATH, 'settings.json', {});
543
+ return settings.customModelEndpointsEnabled === true;
544
+ }
545
+ /**
546
+ * Re-discovers every saved endpoint's models, best-effort. One endpoint being
547
+ * unreachable (powered off, wrong network) must not stop the others from
548
+ * refreshing, and a read-modify-write per host (rather than one batch write
549
+ * at the end) means a crash or restart mid-sweep loses at most the endpoints
550
+ * not yet reached, never a write already applied. Exported so both the
551
+ * periodic timer (server.ts) and a test can drive it directly.
552
+ */
553
+ export async function refreshAllCustomModelHosts() {
554
+ const dataDir = getDataDir();
555
+ const hosts = await readCustomModelHosts(dataDir);
556
+ for (const host of hosts) {
557
+ if (isBlockedWebviewUrl(host.baseUrl))
558
+ continue;
559
+ let result;
560
+ try {
561
+ result = await discoverModels(host);
562
+ }
563
+ catch {
564
+ continue; // unreachable this cycle — try again next tick, not fatal to the sweep
565
+ }
566
+ // Re-read + splice by id rather than reusing the array captured above: an
567
+ // admin editing or deleting an endpoint via the API mid-sweep must win,
568
+ // not be silently overwritten by a refresh that started before their change.
569
+ const current = await readCustomModelHosts(dataDir);
570
+ const index = current.findIndex((item) => item.id === host.id);
571
+ if (index === -1)
572
+ continue; // deleted mid-sweep
573
+ current[index] = applyDiscoveredModels(current[index], result);
574
+ await writeCustomModelHosts(dataDir, current);
575
+ }
576
+ }
577
+ /**
578
+ * Detects when a live session's own custom-model selection is no longer the model
579
+ * llama-swap actually has loaded — evicted by ANOTHER session's activity on the same
580
+ * endpoint, since llama.cpp/llama-swap runs one model at a time (the apply/create routes'
581
+ * own swap-conflict check only ever runs at THAT session's own launch/apply moment, so it
582
+ * cannot catch a later eviction triggered by a different session's normal use — confirmed
583
+ * live: a session created while nothing else had a live conflict at that instant can still
584
+ * get silently displaced afterward). Read-only, and best-effort per endpoint exactly like
585
+ * `refreshAllCustomModelHosts`'s sibling sweep — one endpoint's hiccup here never blocks
586
+ * checking the others.
587
+ *
588
+ * `notifiedSessionIds` is the caller's own de-dupe state (`server.ts` keeps one `Set` across
589
+ * sweeps), mutated in place: a session id is added once displaced and removed again once its
590
+ * own model is loaded and ready — so a LATER, genuinely new displacement can notify again
591
+ * rather than the session staying silently un-notified forever after the first one.
592
+ */
593
+ export async function detectCustomModelSwapDisplacements(sessions, notifiedSessionIds) {
594
+ const byEndpoint = new Map();
595
+ for (const session of sessions) {
596
+ if (!session.customModel)
597
+ continue;
598
+ const group = byEndpoint.get(session.customModel.endpointId);
599
+ if (group)
600
+ group.push(session);
601
+ else
602
+ byEndpoint.set(session.customModel.endpointId, [session]);
603
+ }
604
+ if (byEndpoint.size === 0)
605
+ return [];
606
+ const hosts = await readCustomModelHosts(getDataDir());
607
+ const displacements = [];
608
+ for (const [endpointId, group] of byEndpoint) {
609
+ const host = hosts.find((h) => h.id === endpointId);
610
+ if (!host)
611
+ continue; // endpoint deleted since these sessions were created — nothing to check
612
+ let status;
613
+ try {
614
+ status = await getLlamaSwapStatus(host);
615
+ }
616
+ catch {
617
+ continue; // unreachable this cycle — try again next tick, not fatal to the sweep
618
+ }
619
+ // Not llama-swap (feature-detected) or nothing loaded at all: nothing has been evicted,
620
+ // by construction — a plain llama.cpp/OpenAI-compatible server only ever runs the one
621
+ // model it was started with, so there is no "current model" to conflict with.
622
+ if (!status.isLlamaSwap || status.running.length === 0)
623
+ continue;
624
+ const currentlyLoaded = status.running.find((r) => r.state === 'ready')?.model ?? status.running[0]?.model;
625
+ if (!currentlyLoaded)
626
+ continue;
627
+ for (const session of group) {
628
+ const modelId = session.customModel.modelId;
629
+ const stillLoaded = status.running.some((r) => r.model === modelId);
630
+ if (stillLoaded) {
631
+ notifiedSessionIds.delete(session.id); // back to normal — a future eviction can notify again
632
+ continue;
633
+ }
634
+ if (notifiedSessionIds.has(session.id))
635
+ continue; // already told them once for this displacement
636
+ notifiedSessionIds.add(session.id);
637
+ displacements.push({
638
+ sessionId: session.id,
639
+ sessionName: session.name,
640
+ endpointId,
641
+ previousModel: modelId,
642
+ currentlyLoadedModel: currentlyLoaded,
643
+ });
644
+ }
645
+ }
646
+ return displacements;
647
+ }
66
648
  export function registerCustomModelRoutes(app) {
67
- app.get('/api/model-endpoints', async (req) => isMultiUserMode() && !isAdmin(req) ? [] : readCustomModelHosts(CODEMAN_CONFIG_DIR));
649
+ app.get('/api/model-endpoints', async (req) => {
650
+ if (isMultiUserMode() && !isAdmin(req))
651
+ return [];
652
+ const hosts = await readCustomModelHosts(CODEMAN_CONFIG_DIR);
653
+ return hosts.map(redactApiKey);
654
+ });
68
655
  app.post('/api/model-endpoints', async (req, reply) => {
69
656
  const denied = adminOnly(req, reply);
70
657
  if (denied)
@@ -73,30 +660,37 @@ export function registerCustomModelRoutes(app) {
73
660
  if (isBlockedWebviewUrl(host.baseUrl)) {
74
661
  return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Endpoint base URL is not allowed');
75
662
  }
663
+ const badDefault = invalidDefaultModel(host);
664
+ if (badDefault)
665
+ return badDefault;
76
666
  const hosts = await readCustomModelHosts(CODEMAN_CONFIG_DIR);
77
667
  if (hosts.some((item) => item.id === host.id)) {
78
668
  return createErrorResponse(ApiErrorCode.ALREADY_EXISTS, 'Model endpoint already exists');
79
669
  }
80
670
  await writeCustomModelHosts(CODEMAN_CONFIG_DIR, [...hosts, host]);
81
- return { success: true, data: { host } };
671
+ return { success: true, data: { host: redactApiKey(host) } };
82
672
  });
83
673
  app.put('/api/model-endpoints/:id', async (req, reply) => {
84
674
  const denied = adminOnly(req, reply);
85
675
  if (denied)
86
676
  return denied;
87
677
  const { id } = req.params;
88
- const host = parseBody(CustomModelHostSchema, { ...req.body, id });
89
- if (isBlockedWebviewUrl(host.baseUrl)) {
678
+ const incoming = parseBody(CustomModelHostSchema, { ...req.body, id });
679
+ if (isBlockedWebviewUrl(incoming.baseUrl)) {
90
680
  return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Endpoint base URL is not allowed');
91
681
  }
682
+ const badDefault = invalidDefaultModel(incoming);
683
+ if (badDefault)
684
+ return badDefault;
92
685
  const hosts = await readCustomModelHosts(CODEMAN_CONFIG_DIR);
93
686
  const index = hosts.findIndex((item) => item.id === id);
94
687
  if (index === -1)
95
688
  return createErrorResponse(ApiErrorCode.NOT_FOUND, 'Model endpoint not found');
689
+ const host = applyDiscoveredFields(applyStoredApiKey(incoming, hosts[index]), hosts[index]);
96
690
  const next = [...hosts];
97
691
  next[index] = host;
98
692
  await writeCustomModelHosts(CODEMAN_CONFIG_DIR, next);
99
- return { success: true, data: { host } };
693
+ return { success: true, data: { host: redactApiKey(host) } };
100
694
  });
101
695
  app.delete('/api/model-endpoints/:id', async (req, reply) => {
102
696
  const denied = adminOnly(req, reply);
@@ -121,16 +715,39 @@ export function registerCustomModelRoutes(app) {
121
715
  return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Endpoint base URL is not allowed');
122
716
  }
123
717
  try {
124
- const models = await discoverModels(host);
718
+ const result = await discoverModels(host);
125
719
  const next = [...hosts];
126
- next[index] = { ...host, models, lastDiscoveredAt: new Date().toISOString() };
720
+ next[index] = applyDiscoveredModels(host, result);
127
721
  await writeCustomModelHosts(CODEMAN_CONFIG_DIR, next);
128
- return { success: true, data: { models } };
722
+ return { success: true, data: { models: result.models } };
129
723
  }
130
724
  catch (err) {
131
725
  const blocked = egressBlockedReason(err);
132
726
  return createErrorResponse(ApiErrorCode.OPERATION_FAILED, blocked ? `Endpoint refused: ${blocked}` : `Could not reach endpoint: ${describeFetchError(err)}`);
133
727
  }
134
728
  });
729
+ // Read-only, no admin gate: any session owner who can already point their own session
730
+ // at this endpoint (POST .../custom-model, ungated by design — see session-routes.ts)
731
+ // can equally ask what it currently has loaded, before or while that apply is pending.
732
+ app.get('/api/model-endpoints/:id/running-status', async (req) => {
733
+ const { id } = req.params;
734
+ const hosts = await readCustomModelHosts(CODEMAN_CONFIG_DIR);
735
+ const host = hosts.find((item) => item.id === id);
736
+ if (!host)
737
+ return createErrorResponse(ApiErrorCode.NOT_FOUND, 'Model endpoint not found');
738
+ if (isBlockedWebviewUrl(host.baseUrl)) {
739
+ return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Endpoint base URL is not allowed');
740
+ }
741
+ const status = await getLlamaSwapStatus(host);
742
+ // Only worth tailing /api/events once llama-swap is actually confirmed — a plain
743
+ // llama.cpp/OpenAI-compatible server has no such endpoint at all.
744
+ const logLine = status.isLlamaSwap ? getLatestLlamaSwapLogLine(host) : undefined;
745
+ // `cmd` (the literal llama-server launch line, which can carry model paths and
746
+ // --api-key) exists only so parseCtxFromCmd() can read it server-side during
747
+ // discovery — this un-gated, polled-every-second route has no reason to hand it
748
+ // to the browser, which only ever reads `model`/`state`.
749
+ const running = status.running.map(({ model, state }) => ({ model, state }));
750
+ return { success: true, data: { isLlamaSwap: status.isLlamaSwap, running, logLine } };
751
+ });
135
752
  }
136
753
  //# sourceMappingURL=custom-model-routes.js.map