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
@@ -42,6 +42,7 @@ import { hostname as getHostname, uptime as osUptime } from 'node:os';
42
42
  import { looksLikeHostReboot, newestPersistedActivity, planRebootRestore } from '../reboot-restore.js';
43
43
  import { rebootRestoreRegistry } from './reboot-restore-registry.js';
44
44
  import { dataPath, getDataDir, CODEMAN_INSTANCE } from '../config/instance.js';
45
+ import { readRemoteHosts, rehydrateRemoteHostFields } from '../remote-hosts.js';
45
46
  import { normalizeBasePath, stripBasePath, joinBasePath } from '../config/base-path.js';
46
47
  import { GLYPH, palette } from '../cli-style.js';
47
48
  import { getHookSecret } from '../config/hook-secret.js';
@@ -58,7 +59,7 @@ import { subagentWatcher, } from '../subagent-watcher.js';
58
59
  import { imageWatcher } from '../image-watcher.js';
59
60
  import { workflowRunWatcher, summarizeRun } from '../workflow-run-watcher.js';
60
61
  import { attachmentRegistry, buildFileThumbnailRoute, registerExternalAttachment } from '../attachment-registry.js';
61
- import { getCli } from '../config/cli-registry/registry.js';
62
+ import { getCli, enabledClis } from '../config/cli-registry/registry.js';
62
63
  import { readCustomModelHosts } from '../custom-model-hosts.js';
63
64
  import { applyCustomModelInjection, customModelConfigDir, removeConfigDir } from '../custom-model-injection-apply.js';
64
65
  import { registerGeneratedArtifactAttachment } from '../generated-artifact-attachments.js';
@@ -113,7 +114,7 @@ import { isMultiUserMode } from '../config/multiuser.js';
113
114
  import { bootstrapInitialAdmin, hasUsers, resolveClaudeModeForUsername } from '../user-store.js';
114
115
  import { installRouteErrorHandler } from './route-error-handler.js';
115
116
  import { isExplicitlyEnabled, isLoopbackBindHost, buildHostPolicy } from './network-auth-policy.js';
116
- import { registerPushRoutes, registerTeamRoutes, registerMuxRoutes, registerFileRoutes, registerScheduledRoutes, registerHookEventRoutes, registerApprovalRoutes, registerRebootRestoreRoutes, registerReadMyMindRoutes, registerStatusTelemetryRoutes, registerSystemRoutes, registerCaseRoutes, registerSessionRoutes, registerRespawnRoutes, registerRalphRoutes, registerPlanRoutes, registerClipboardRoutes, registerSearchRoutes, registerOrchestratorRoutes, registerCronRoutes, registerMeRoutes, registerAdminRoutes, registerWsRoutes, registerVoiceRoutes, registerWebviewRoutes, registerTabLayoutRoutes, registerCustomModelRoutes, tryWebviewRefererFallback, } from './routes/index.js';
117
+ import { registerPushRoutes, registerTeamRoutes, registerMuxRoutes, registerFileRoutes, registerScheduledRoutes, registerHookEventRoutes, registerApprovalRoutes, registerRebootRestoreRoutes, registerReadMyMindRoutes, registerStatusTelemetryRoutes, registerSystemRoutes, registerCaseRoutes, registerSessionRoutes, registerRespawnRoutes, registerRalphRoutes, registerPlanRoutes, registerClipboardRoutes, registerSearchRoutes, registerOrchestratorRoutes, registerCronRoutes, registerMeRoutes, registerAdminRoutes, registerWsRoutes, registerVoiceRoutes, registerWebviewRoutes, registerTabLayoutRoutes, registerCustomModelRoutes, refreshAllCustomModelHosts, readCustomModelEndpointsEnabled, closeAllLlamaSwapLogTails, detectCustomModelSwapDisplacements, pruneIdleLlamaSwapLogTails, tryWebviewRefererFallback, } from './routes/index.js';
117
118
  import { isLostWebviewFrameNavigation } from './webview-proxy.js';
118
119
  import { CronService } from '../cron/cron-service.js';
119
120
  const __dirname = dirname(fileURLToPath(import.meta.url));
@@ -122,9 +123,29 @@ const __dirname = dirname(fileURLToPath(import.meta.url));
122
123
  // while capping growth of `sseClientsById` and blocking pathological inputs.
123
124
  const SSE_CLIENT_ID_RE = /^[A-Za-z0-9_-]{8,64}$/;
124
125
  const CODEX_USAGE_POLL_INTERVAL_MS = 5 * 60_000;
126
+ const CUSTOM_MODEL_REDISCOVER_INTERVAL_MS = 5 * 60_000;
127
+ // Much shorter than the model-LIST refresh above on purpose: this catches an actual
128
+ // eviction (a session's model no longer loaded, silently swapped out by another
129
+ // session's use), which the user wants to know about promptly, not once every 5
130
+ // minutes. Cheap either way — one /running GET per distinct endpoint with at least
131
+ // one live custom-model session, not per session.
132
+ const CUSTOM_MODEL_SWAP_CHECK_INTERVAL_MS = 20_000;
125
133
  function escapeHtmlText(value) {
126
134
  return value.replaceAll('&', '&amp;').replaceAll('<', '&lt;').replaceAll('>', '&gt;');
127
135
  }
136
+ /**
137
+ * Escapes a JSON string for safe embedding as the body of an inline `<script>`
138
+ * tag: `<` becomes the six-character sequence `<`, which both a JSON
139
+ * parser and a plain JS string literal decode back to `<` (both treat
140
+ * `\uXXXX` identically), but which can never itself form the two literal
141
+ * characters `<` `/` a browser's HTML tokenizer looks for to end the tag. A
142
+ * value containing a literal `</script>` would otherwise close the tag early
143
+ * and turn the rest of the document into inert script-body text. Exported so
144
+ * it unit-tests without constructing a WebServer (which needs a real tmux).
145
+ */
146
+ export function escapeScriptJson(json) {
147
+ return json.replace(/</g, '\\u003c');
148
+ }
128
149
  import { SESSIONS_LIST_CACHE_TTL, SCHEDULED_CLEANUP_INTERVAL, SCHEDULED_RUN_MAX_AGE, SSE_HEARTBEAT_INTERVAL, SESSION_LIMIT_WAIT_MS, ITERATION_PAUSE_MS, STATS_COLLECTION_INTERVAL_MS, INACTIVITY_TIMEOUT_MS, CRON_TICK_INTERVAL, } from '../config/server-timing.js';
129
150
  /**
130
151
  * Get or generate a self-signed TLS certificate for HTTPS.
@@ -163,8 +184,18 @@ export class WebServer extends EventEmitter {
163
184
  // Store session listener references for explicit cleanup (prevents memory leaks)
164
185
  sessionListenerRefs = new Map();
165
186
  scheduledRuns = new Map();
187
+ /** De-dupe state for the swap-displacement sweep — see detectCustomModelSwapDisplacements. */
188
+ _customModelDisplacedNotified = new Set();
166
189
  /** Cron service (assigned in setupRoutes). */
167
190
  cronService;
191
+ /**
192
+ * Wake-on-LAN registry, returned by `registerSessionRoutes`. Held for its LIFETIME
193
+ * only — `drop()` on session cleanup, `stop()` on shutdown. Waking from here would
194
+ * re-wake a host on every timer tick (the invariant `remote-wake.ts` documents), so
195
+ * the wiring guard in `test/remote-wake.test.ts` pins that this file calls nothing
196
+ * but `drop`/`stop` on it.
197
+ */
198
+ remoteWake = null;
168
199
  sse;
169
200
  store = getStore();
170
201
  tabLayouts;
@@ -902,7 +933,9 @@ export class WebServer extends EventEmitter {
902
933
  registerStatusTelemetryRoutes(this.app, ctx);
903
934
  registerSystemRoutes(this.app, ctx);
904
935
  registerCaseRoutes(this.app, ctx);
905
- registerSessionRoutes(this.app, ctx);
936
+ // The registry's lifetime is the server's: it drops per-session wake state on every
937
+ // cleanup path and resolves in-flight wakes on shutdown.
938
+ this.remoteWake = registerSessionRoutes(this.app, ctx);
906
939
  registerRespawnRoutes(this.app, ctx);
907
940
  registerRalphRoutes(this.app, ctx);
908
941
  registerPlanRoutes(this.app, ctx);
@@ -1027,7 +1060,7 @@ export class WebServer extends EventEmitter {
1027
1060
  return undefined;
1028
1061
  }
1029
1062
  try {
1030
- return applyCustomModelInjection(entry, endpoint, saved.modelId, session.id)?.envOverrides;
1063
+ return applyCustomModelInjection(entry, endpoint, saved.modelId, session.id, endpoint.modelContextLengths?.[saved.modelId])?.envOverrides;
1031
1064
  }
1032
1065
  catch (err) {
1033
1066
  console.warn('[WebServer] Failed to rebuild custom-model env on recovery:', err);
@@ -1112,6 +1145,9 @@ export class WebServer extends EventEmitter {
1112
1145
  if (session) {
1113
1146
  session.ralphTracker.stopWatchingFixPlan();
1114
1147
  }
1148
+ // Custom Model Endpoint Profiles: drop this session's swap-displacement notify flag
1149
+ // (see _checkCustomModelSwapDisplacements below) so it can't linger in that Set forever.
1150
+ this._customModelDisplacedNotified.delete(sessionId);
1115
1151
  // Kill all subagents spawned by this session (scoped to sessionId to avoid cross-session kills)
1116
1152
  if (session && killMux) {
1117
1153
  try {
@@ -1251,6 +1287,11 @@ export class WebServer extends EventEmitter {
1251
1287
  sessionWaits.notifySignal(sessionId, 'exit');
1252
1288
  sessionWaits.cancelAll(sessionId);
1253
1289
  approvalInbox.resolveForSession(sessionId, 'session_ended');
1290
+ // Wake state goes with the session on EVERY cleanup path (delete routes, the cron
1291
+ // and admin paths, scheduled-run teardown, error paths) — that is why it lives here
1292
+ // rather than in the two delete routes, where it left an entry behind, including up
1293
+ // to 4 KB of the user's buffered keystrokes.
1294
+ this.remoteWake?.drop(sessionId);
1254
1295
  this.broadcast(SseEvent.SessionDeleted, { id: sessionId });
1255
1296
  }
1256
1297
  async renderIndexHtml(soloSessionId) {
@@ -1307,9 +1348,17 @@ export class WebServer extends EventEmitter {
1307
1348
  // the /session/:id URL path; this global is a belt-and-suspenders fallback.
1308
1349
  // The id is gated to JSON + <-escaped so it can't break out of the inline
1309
1350
  // <script> (ids are UUIDs in practice, but defense-in-depth is cheap).
1351
+ //
1352
+ // Every `</head>` injection below passes a replacer FUNCTION, never a
1353
+ // replacement STRING: `String.replace` interprets `$&`, `$'`, `` $` `` and
1354
+ // `$<n>` inside a string replacement, so a payload carrying `$'` would splice
1355
+ // the rest of the document (the whole <body>) into the inline script, past
1356
+ // any escaping applied to the payload itself. The custom-model list below
1357
+ // carries a user-settable `label` (clis.json), which is the site that made
1358
+ // this real; the others follow the same rule so the class of bug stays out.
1310
1359
  if (soloSessionId) {
1311
1360
  const safeId = JSON.stringify(soloSessionId).replace(/</g, '\\u003c');
1312
- html = html.replace('</head>', `<script>window.__CODEMAN_SOLO__=${safeId};</script>\n</head>`);
1361
+ html = html.replace('</head>', () => `<script>window.__CODEMAN_SOLO__=${safeId};</script>\n</head>`);
1313
1362
  }
1314
1363
  // Gesture-control overlay (Phase 5): dashboard only (not solo popups, which
1315
1364
  // have no tab strip). `CODEMAN_GESTURE=1` makes the feature *available* on
@@ -1367,15 +1416,29 @@ export class WebServer extends EventEmitter {
1367
1416
  // keep without git (issue #236), same reasoning as cloudflared above.
1368
1417
  git: isGitAvailable(),
1369
1418
  };
1370
- html = html.replace('</head>', `<script>window.__codemanCliAvailable=${JSON.stringify(available)};</script>\n</head>`);
1419
+ html = html.replace('</head>', () => `<script>window.__codemanCliAvailable=${JSON.stringify(available)};</script>\n</head>`);
1420
+ // Which run modes the Run-menu picker (docs/custom-model-endpoints-plan.md) may
1421
+ // generate an entry for: read generically off the registry's `capabilities`
1422
+ // (never an id list here) so a CLI whose customModelInjection lands later shows
1423
+ // up in the picker with no frontend change, and one that ships `unsupported`
1424
+ // (antigravity, and `shell`'s `kind !== 'agent'`) never does.
1425
+ const customModelClis = enabledClis()
1426
+ .filter((entry) => entry.kind === 'agent' && entry.capabilities.customModelInjection.kind !== 'unsupported')
1427
+ .map((entry) => ({ id: entry.id, label: entry.label }));
1428
+ // Unlike the boolean-only __codemanCliAvailable above, this payload carries
1429
+ // `label`, a string a user's own clis.json can set (CliEntry.label, up to 60
1430
+ // chars) — see escapeScriptJson's own doc comment for why that needs escaping
1431
+ // and __codemanCliAvailable's booleans never did.
1432
+ const customModelClisJson = escapeScriptJson(JSON.stringify(customModelClis));
1433
+ html = html.replace('</head>', () => `<script>window.__codemanCustomModelClis=${customModelClisJson};</script>\n</head>`);
1371
1434
  }
1372
1435
  if (!soloSessionId && process.env.CODEMAN_GESTURE === '1') {
1373
- html = html.replace('</head>', `<script>window.__codemanGestureAvailable=true;</script>\n</head>`);
1436
+ html = html.replace('</head>', () => `<script>window.__codemanGestureAvailable=true;</script>\n</head>`);
1374
1437
  if (settings.gestureControlEnabled === true) {
1375
1438
  const v = this.gestureBundleVersion();
1376
1439
  // Relative src so the injected `<base href>` resolves it under the mount
1377
1440
  // prefix (a root-absolute `/gesture/...` would escape a sub-path mount).
1378
- html = html.replace('</head>', `<script type="module" src="gesture/gesture-codeman.js${v}"></script>\n</head>`);
1441
+ html = html.replace('</head>', () => `<script type="module" src="gesture/gesture-codeman.js${v}"></script>\n</head>`);
1379
1442
  }
1380
1443
  }
1381
1444
  return html;
@@ -2019,11 +2082,20 @@ export class WebServer extends EventEmitter {
2019
2082
  'scheduled:',
2020
2083
  'team:',
2021
2084
  'case:',
2085
+ 'remote:',
2086
+ 'custom-model:',
2022
2087
  ];
2023
2088
  if (SESSION_PREFIXES.some((p) => event.startsWith(p))) {
2024
2089
  const d = (data ?? {});
2025
2090
  const sessionId = d.sessionId ?? d.id ?? d.session?.id;
2026
2091
  const owner = sessionId ? this.sessions.get(sessionId)?.owner : undefined;
2092
+ // `remote:hostWaking` / `remote:hostWakeFailed` for a create/attach wake have no
2093
+ // session yet (nothing exists until the host is up), so the registry names the
2094
+ // requesting user instead; the payload carries `hostId`/`label`, which non-admins
2095
+ // are not shown elsewhere. No session and no requester: admins only (fail closed).
2096
+ if (!sessionId && event.startsWith('remote:') && d.username) {
2097
+ return { username: d.username, sessionScoped: true };
2098
+ }
2027
2099
  return { owner, sessionScoped: true };
2028
2100
  }
2029
2101
  // #20/#38: clipboard:write writes into the receiver's OS clipboard — route it to
@@ -2343,6 +2415,55 @@ export class WebServer extends EventEmitter {
2343
2415
  description: 'Codex plan-usage refresh',
2344
2416
  });
2345
2417
  }
2418
+ // Custom Model Endpoint Profiles (docs/custom-model-endpoints-plan.md): keeps
2419
+ // each saved endpoint's discovered model list current with no manual
2420
+ // "Discover" click, so a model added on the server side (or one that drops
2421
+ // off) shows up in the Run-menu picker within one cycle. Best-effort per
2422
+ // endpoint (refreshAllCustomModelHosts skips one that's unreachable rather
2423
+ // than failing the sweep) and off in tests for the same reason the Codex
2424
+ // poll above is — no real network to hit, no server instance to keep alive.
2425
+ if (!this.testMode) {
2426
+ this.cleanup.setInterval(() => {
2427
+ // Reads the setting fresh on every tick, same reasoning as
2428
+ // readPlanUsageTelemetryEnabled() beside it: a live toggle takes effect
2429
+ // on the very next cycle, not just at server boot, and turning the
2430
+ // feature off actually stops the polling instead of only hiding the UI.
2431
+ void readCustomModelEndpointsEnabled()
2432
+ .then((enabled) => {
2433
+ if (!enabled)
2434
+ return;
2435
+ return refreshAllCustomModelHosts();
2436
+ })
2437
+ .catch((err) => {
2438
+ console.error('[custom-model] periodic re-discovery failed:', getErrorMessage(err));
2439
+ });
2440
+ }, CUSTOM_MODEL_REDISCOVER_INTERVAL_MS, { description: 'custom model endpoint re-discovery' });
2441
+ }
2442
+ // Custom Model Endpoint Profiles: the swap-conflict check on the apply/create routes
2443
+ // only ever runs at THAT session's own launch/apply moment — it cannot catch a LATER
2444
+ // eviction triggered by a different session's normal use, since llama-swap has no push
2445
+ // notification of its own and only swaps in response to a real inference request
2446
+ // (confirmed live: a session created while nothing else conflicted at that instant can
2447
+ // still get silently displaced afterward). This periodic sweep is what catches that
2448
+ // case after the fact and tells the displaced session's user, rather than leaving them
2449
+ // to discover it only when their next prompt behaves unexpectedly.
2450
+ if (!this.testMode) {
2451
+ this.cleanup.setInterval(() => {
2452
+ detectCustomModelSwapDisplacements(this.sessions.values(), this._customModelDisplacedNotified)
2453
+ .then((displacements) => {
2454
+ for (const displacement of displacements) {
2455
+ this.broadcast(SseEvent.CustomModelSwappedOut, displacement);
2456
+ }
2457
+ })
2458
+ .catch((err) => {
2459
+ console.error('[custom-model] swap-displacement check failed:', getErrorMessage(err));
2460
+ });
2461
+ // Same cadence, unrelated concern: close any /api/events tail (see
2462
+ // getLatestLlamaSwapLogLine) nothing has polled in a while, so a loading banner
2463
+ // that finished (or was abandoned) doesn't leave a connection open forever.
2464
+ pruneIdleLlamaSwapLogTails();
2465
+ }, CUSTOM_MODEL_SWAP_CHECK_INTERVAL_MS, { description: 'custom model swap-displacement check' });
2466
+ }
2346
2467
  // Start scheduled runs cleanup timer
2347
2468
  this.cleanup.setInterval(() => {
2348
2469
  this.cleanupScheduledRuns();
@@ -2718,6 +2839,9 @@ export class WebServer extends EventEmitter {
2718
2839
  console.log(`[Server] Found ${alive.length + discovered.length} alive mux session(s) from previous run`);
2719
2840
  // For each alive mux session, create a Session object if it doesn't exist
2720
2841
  const muxSessions = this.mux.getSessions();
2842
+ // Host-level config lives in remote-hosts.json, not in the persisted session
2843
+ // snapshot, so refresh the fields that only exist there (see the helper).
2844
+ const remoteHostsById = new Map((await readRemoteHosts(getDataDir())).map((host) => [host.id, host]));
2721
2845
  for (const muxSession of muxSessions) {
2722
2846
  if (!this.sessions.has(muxSession.sessionId)) {
2723
2847
  // Restore session settings from state.json (single source of truth)
@@ -2792,7 +2916,9 @@ export class WebServer extends EventEmitter {
2792
2916
  // respawn rebuilds a LOCAL command, breaking the pane and silently
2793
2917
  // erasing `remote` from state.json on the next persist. mux-sessions.json
2794
2918
  // round-trips MuxSession.remote; state.json carries SessionState.remote.
2795
- remote: muxSession.remote ?? savedState?.remote,
2919
+ // Host-level fields are refreshed from remote-hosts.json on top, or a
2920
+ // field added to the host config after launch would never arrive.
2921
+ remote: rehydrateRemoteHostFields(muxSession.remote ?? savedState?.remote, remoteHostsById),
2796
2922
  // Docker metadata round-trips the same way (mux-sessions.json carries
2797
2923
  // MuxSession.docker; state.json carries SessionState.docker), so recovery
2798
2924
  // rebuilds the `docker exec` launch instead of a broken local command.
@@ -3137,6 +3263,9 @@ export class WebServer extends EventEmitter {
3137
3263
  // port against the next start — the exact EADDRINUSE this feature already
3138
3264
  // got wrong once.
3139
3265
  void stopDeepSeekWeb();
3266
+ // Same teardown rule: the per-endpoint llama-swap log tails are otherwise closed
3267
+ // only by the periodic idle sweep, whose interval is disposed just below.
3268
+ closeAllLlamaSwapLogTails();
3140
3269
  // Dispose all managed timers (intervals + resettable timeouts)
3141
3270
  this.cleanup.dispose();
3142
3271
  // Gracefully close all SSE connections and clear batching state
@@ -3146,6 +3275,10 @@ export class WebServer extends EventEmitter {
3146
3275
  // response), so without this a 10-minute wait holds shutdown open.
3147
3276
  sessionWaits.cancelEverything();
3148
3277
  approvalInbox.stop();
3278
+ // Same reason as `cancelEverything` above: an in-flight wake is awaited by a request,
3279
+ // and `app.close()` (the last line of this method) does not abort in-flight requests —
3280
+ // so without this a restart during a wake waits out the readiness poll.
3281
+ this.remoteWake?.stop();
3149
3282
  this.lastRecordedTokens.clear();
3150
3283
  // Stop multiplexer and flush pending saves
3151
3284
  this.mux.destroy();