@neuralnomads/codenomad-dev 0.18.0-dev-20260715-07e8ce68 → 0.18.0-dev-20260716-40cee071

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 (81) hide show
  1. package/README.md +12 -0
  2. package/THIRD_PARTY_NOTICES.md +26 -0
  3. package/dist/events/bus.js +13 -0
  4. package/dist/events/bus.test.js +37 -0
  5. package/dist/index.js +33 -56
  6. package/dist/index.test.js +32 -0
  7. package/dist/server/__tests__/remote-proxy.test.js +102 -6
  8. package/dist/server/http-server.js +2 -0
  9. package/dist/server/remote-proxy.js +96 -9
  10. package/dist/server/routes/usage.js +18 -0
  11. package/dist/server/routes/usage.test.js +26 -0
  12. package/dist/server/routes/workspaces.js +32 -1
  13. package/dist/server/routes/workspaces.test.js +75 -0
  14. package/dist/settings/binaries.js +4 -1
  15. package/dist/settings/binaries.test.js +24 -0
  16. package/dist/shutdown.js +75 -0
  17. package/dist/shutdown.test.js +111 -0
  18. package/dist/usage/providers/api-key.js +171 -0
  19. package/dist/usage/providers/google.js +171 -0
  20. package/dist/usage/providers/minimax.js +109 -0
  21. package/dist/usage/providers/oauth.js +111 -0
  22. package/dist/usage/providers/special.js +148 -0
  23. package/dist/usage/service.js +83 -0
  24. package/dist/usage/service.test.js +59 -0
  25. package/dist/usage/shared.js +130 -0
  26. package/dist/usage/shared.test.js +33 -0
  27. package/dist/usage/types.js +1 -0
  28. package/dist/workspaces/__tests__/spawn.test.js +64 -7
  29. package/dist/workspaces/__tests__/workspace-identity.test.js +177 -25
  30. package/dist/workspaces/launch-cleanup.js +132 -0
  31. package/dist/workspaces/launch-cleanup.test.js +33 -0
  32. package/dist/workspaces/manager.js +393 -135
  33. package/dist/workspaces/manager.test.js +318 -0
  34. package/dist/workspaces/process-identity.js +408 -0
  35. package/dist/workspaces/process-identity.test.js +165 -0
  36. package/dist/workspaces/runtime.js +567 -122
  37. package/dist/workspaces/runtime.test.js +603 -0
  38. package/dist/workspaces/spawn.js +86 -15
  39. package/package.json +1 -1
  40. package/public/assets/{FilesTab-EPvldvn1.js → FilesTab-BjztpSdj.js} +2 -2
  41. package/public/assets/{GitChangesTab-ofjovmv2.js → GitChangesTab-B9XraSoR.js} +2 -2
  42. package/public/assets/{SplitFilePanel-CyoPkcLd.js → SplitFilePanel-Cu_5-Z-E.js} +1 -1
  43. package/public/assets/StatusTab-D_IE79r-.js +1 -0
  44. package/public/assets/{align-justify-Bcf1-ZOl.js → align-justify-BKS9gyRk.js} +1 -1
  45. package/public/assets/{bundle-full-D5dzvToA.js → bundle-full-CXGXXlzR.js} +1 -1
  46. package/public/assets/{diff-viewer-DViXCI-4.js → diff-viewer-CSNOYoEB.js} +1 -1
  47. package/public/assets/{git-diff-vendor-CSgooKT_.js → git-diff-vendor-CYS3ApzA.js} +1 -1
  48. package/public/assets/index-B3YjNCh4.js +1 -0
  49. package/public/assets/index-B3ZD2_SU.js +1 -0
  50. package/public/assets/index-Bh2ofaQo.js +1 -0
  51. package/public/assets/index-CM0c9xRZ.js +1 -0
  52. package/public/assets/index-CdDxouJl.js +2 -0
  53. package/public/assets/index-D06Y2cFq.js +1 -0
  54. package/public/assets/{index-lf4nUOU1.js → index-DVc4ls8c.js} +1 -1
  55. package/public/assets/index-PmE51vPh.js +1 -0
  56. package/public/assets/{index-BMvFMsuy.css → index-Qj63IiSj.css} +1 -1
  57. package/public/assets/index-VY_zwmsG.js +1 -0
  58. package/public/assets/index-qiiH7egV.js +1 -0
  59. package/public/assets/{index-UbL8tz_V.js → index-sLe9hEfQ.js} +1 -1
  60. package/public/assets/{loading-CZ_tt3h0.js → loading-CDmt9OJU.js} +1 -1
  61. package/public/assets/main-CTN6Wwz1.js +68 -0
  62. package/public/assets/{markdown-G11aN6hz.js → markdown-1ZLSxCFT.js} +55 -44
  63. package/public/assets/monaco-viewer-D6JgH76W.js +26 -0
  64. package/public/assets/{tool-call-BsSXWErm.js → tool-call-CyXb63LH.js} +32 -32
  65. package/public/assets/{unified-picker-BRNI3UvE.js → unified-picker-DCCdSVwI.js} +1 -1
  66. package/public/assets/{wrap-text-B7up-uv7.js → wrap-text-pNMdTIq3.js} +1 -1
  67. package/public/index.html +5 -5
  68. package/public/loading.html +5 -5
  69. package/public/sw.js +1 -1
  70. package/public/assets/StatusTab-Bgu1Fh24.js +0 -1
  71. package/public/assets/index-3KsjbWGP.js +0 -1
  72. package/public/assets/index-B0WK8-cF.js +0 -1
  73. package/public/assets/index-BDv_iVtj.js +0 -1
  74. package/public/assets/index-BjxZvyb9.js +0 -2
  75. package/public/assets/index-BleKf9Nj.js +0 -1
  76. package/public/assets/index-CiIQ43NW.js +0 -1
  77. package/public/assets/index-DBquqL6Q.js +0 -1
  78. package/public/assets/index-yf9zolv4.js +0 -1
  79. package/public/assets/index-ypaL7i_s.js +0 -1
  80. package/public/assets/main-CAkg_fp_.js +0 -68
  81. package/public/assets/monaco-viewer-CZefBfct.js +0 -26
package/README.md CHANGED
@@ -217,3 +217,15 @@ When running as a server CodeNomad can also be installed as a PWA from any suppo
217
217
 
218
218
  - **Config**: `~/.config/codenomad/config.json`
219
219
  - **Instance Data**: `~/.config/codenomad/instances` (chat history, etc.)
220
+
221
+ ### Provider Plan Usage
222
+
223
+ The Status panel automatically displays quota information for the provider used by the active session. CodeNomad reads existing OpenCode credentials and never returns provider secrets through its API.
224
+
225
+ Some optional usage integrations require credentials that OpenCode does not expose. They can be enabled without UI configuration through these environment variables:
226
+
227
+ - Google token refresh: `GOOGLE_OAUTH_CLIENT_ID` and `GOOGLE_OAUTH_CLIENT_SECRET`
228
+ - Antigravity token refresh: `ANTIGRAVITY_OAUTH_CLIENT_ID` and `ANTIGRAVITY_OAUTH_CLIENT_SECRET`
229
+ - Cursor: `CURSOR_ACCESS_TOKEN` or `CURSOR_TOKEN`, with optional `CURSOR_REFRESH_TOKEN`
230
+ - Ollama Cloud: `OLLAMA_CLOUD_COOKIE`
231
+ - OpenCode Go: `OPENCODE_GO_WORKSPACE_ID` and `OPENCODE_GO_AUTH_COOKIE`
@@ -0,0 +1,26 @@
1
+ # Third-Party Notices
2
+
3
+ The provider usage adapters are derived from OpenChamber:
4
+ https://github.com/openchamber/openchamber
5
+
6
+ MIT License
7
+
8
+ Copyright (c) 2025 Bohdan Triapitsyn
9
+
10
+ Permission is hereby granted, free of charge, to any person obtaining a copy
11
+ of this software and associated documentation files (the "Software"), to deal
12
+ in the Software without restriction, including without limitation the rights
13
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
14
+ copies of the Software, and to permit persons to whom the Software is
15
+ furnished to do so, subject to the following conditions:
16
+
17
+ The above copyright notice and this permission notice shall be included in all
18
+ copies or substantial portions of the Software.
19
+
20
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
21
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
22
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
23
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
24
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
25
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
26
+ SOFTWARE.
@@ -3,8 +3,19 @@ export class EventBus extends EventEmitter {
3
3
  constructor(logger) {
4
4
  super();
5
5
  this.logger = logger;
6
+ this.instanceStatuses = new Map();
6
7
  }
7
8
  publish(event) {
9
+ if (event.type === "instance.eventStatus") {
10
+ const terminal = event.status === "disconnected"
11
+ && (event.reason === "workspace stopped" || event.reason === "workspace error");
12
+ if (terminal) {
13
+ this.instanceStatuses.delete(event.instanceId);
14
+ }
15
+ else {
16
+ this.instanceStatuses.set(event.instanceId, event);
17
+ }
18
+ }
8
19
  if (event.type !== "instance.event" && event.type !== "instance.eventStatus") {
9
20
  this.logger?.debug({ type: event.type }, "Publishing workspace event");
10
21
  if (this.logger?.isLevelEnabled("trace")) {
@@ -29,6 +40,8 @@ export class EventBus extends EventEmitter {
29
40
  this.on("instance.eventStatus", handler);
30
41
  this.on("yolo.stateChanged", handler);
31
42
  this.on("yolo.autoAccepted", handler);
43
+ for (const status of this.instanceStatuses.values())
44
+ listener(status);
32
45
  return () => {
33
46
  this.off("workspace.created", handler);
34
47
  this.off("workspace.started", handler);
@@ -0,0 +1,37 @@
1
+ import assert from "node:assert/strict";
2
+ import { describe, it } from "node:test";
3
+ import { EventBus } from "./bus";
4
+ describe("event bus instance status replay", () => {
5
+ it("replays the latest instance status to a late subscriber", () => {
6
+ const bus = new EventBus();
7
+ bus.publish({ type: "instance.eventStatus", instanceId: "workspace-1", status: "connecting" });
8
+ bus.publish({ type: "instance.eventStatus", instanceId: "workspace-1", status: "connected" });
9
+ const received = [];
10
+ bus.onEvent((event) => received.push(event));
11
+ assert.deepEqual(received, [
12
+ { type: "instance.eventStatus", instanceId: "workspace-1", status: "connected" },
13
+ ]);
14
+ });
15
+ it("delivers terminal disconnects live without replaying stopped workspaces", () => {
16
+ const bus = new EventBus();
17
+ bus.publish({ type: "instance.eventStatus", instanceId: "workspace-1", status: "connected" });
18
+ const live = [];
19
+ bus.onEvent((event) => live.push(event));
20
+ live.length = 0;
21
+ bus.publish({
22
+ type: "instance.eventStatus",
23
+ instanceId: "workspace-1",
24
+ status: "disconnected",
25
+ reason: "workspace stopped",
26
+ });
27
+ assert.deepEqual(live, [{
28
+ type: "instance.eventStatus",
29
+ instanceId: "workspace-1",
30
+ status: "disconnected",
31
+ reason: "workspace stopped",
32
+ }]);
33
+ const replayed = [];
34
+ bus.onEvent((event) => replayed.push(event));
35
+ assert.deepEqual(replayed, []);
36
+ });
37
+ });
package/dist/index.js CHANGED
@@ -31,6 +31,7 @@ import { ClientConnectionManager } from "./clients/connection-manager";
31
31
  import { PluginChannelManager } from "./plugins/channel";
32
32
  import { VoiceModeManager } from "./plugins/voice-mode";
33
33
  import { runCliUpgrade } from "./cli-upgrade";
34
+ import { createServerShutdownHandler, orchestrateServerShutdown } from "./shutdown";
34
35
  const require = createRequire(import.meta.url);
35
36
  const packageJson = require("../package.json");
36
37
  const __filename = fileURLToPath(import.meta.url);
@@ -40,6 +41,10 @@ const DEFAULT_HOST = "127.0.0.1";
40
41
  const DEFAULT_CONFIG_PATH = "~/.config/codenomad/config.json";
41
42
  const DEFAULT_HTTPS_PORT = 9898;
42
43
  const DEFAULT_HTTP_PORT = 9899;
44
+ export function installShutdownSignalHandlers(source, shutdown) {
45
+ source.on("SIGINT", () => void shutdown("SIGINT"));
46
+ source.on("SIGTERM", () => void shutdown("SIGTERM"));
47
+ }
43
48
  function parseCliOptions(argv) {
44
49
  const program = new Command()
45
50
  .name("codenomad")
@@ -424,61 +429,33 @@ async function main() {
424
429
  if (options.launch) {
425
430
  await launchInBrowser(serverMeta.localUrl, logger.child({ component: "launcher" }));
426
431
  }
427
- let shuttingDown = false;
428
- const shutdown = async () => {
429
- if (shuttingDown) {
430
- logger.info("Shutdown already in progress, ignoring signal");
431
- return;
432
- }
433
- shuttingDown = true;
434
- logger.info("Received shutdown signal, stopping workspaces and server");
435
- const shutdownWorkspaces = (async () => {
436
- try {
437
- instanceEventBridge.shutdown();
438
- }
439
- catch (error) {
440
- logger.warn({ err: error }, "Instance event bridge shutdown failed");
441
- }
442
- try {
443
- await sidecarManager.shutdown();
444
- }
445
- catch (error) {
446
- logger.error({ err: error }, "SideCar manager shutdown failed");
447
- }
448
- try {
449
- clientConnectionManager.shutdown();
450
- }
451
- catch (error) {
452
- logger.warn({ err: error }, "Client connection manager shutdown failed");
453
- }
454
- try {
455
- await workspaceManager.shutdown();
456
- logger.info("Workspace manager shutdown complete");
457
- }
458
- catch (error) {
459
- logger.error({ err: error }, "Workspace manager shutdown failed");
460
- }
461
- })();
462
- const shutdownHttp = (async () => {
463
- try {
464
- await Promise.allSettled(servers.map((srv) => srv.stop()));
432
+ const shutdown = createServerShutdownHandler({
433
+ logger,
434
+ shutdown: () => orchestrateServerShutdown({
435
+ stopInstanceEventBridge: () => instanceEventBridge.shutdown(),
436
+ stopSidecars: () => sidecarManager.shutdown(),
437
+ stopClientConnections: () => clientConnectionManager.shutdown(),
438
+ stopRemoteProxySessions: () => remoteProxySessionManager.shutdown(),
439
+ stopWorkspaces: () => workspaceManager.shutdown(),
440
+ stopHttpServers: async () => {
441
+ const results = await Promise.allSettled(servers.map((srv) => srv.stop()));
442
+ const failures = results.flatMap((result) => (result.status === "rejected" ? [result.reason] : []));
443
+ if (failures.length > 0) {
444
+ const error = new Error("One or more HTTP servers failed to stop");
445
+ error.failures = failures;
446
+ throw error;
447
+ }
465
448
  logger.info("HTTP server(s) stopped");
466
- }
467
- catch (error) {
468
- logger.error({ err: error }, "Failed to stop HTTP server");
469
- }
470
- })();
471
- await Promise.allSettled([shutdownWorkspaces, shutdownHttp]);
472
- // no-op: remote UI manifest replaces GitHub release monitor
473
- devReleaseMonitor?.stop();
474
- logger.info("Exiting process");
475
- process.exit(0);
476
- };
477
- process.on("SIGINT", shutdown);
478
- process.on("SIGTERM", shutdown);
449
+ },
450
+ stopReleaseMonitor: () => devReleaseMonitor?.stop(),
451
+ }, logger),
452
+ });
453
+ installShutdownSignalHandlers(process, shutdown);
454
+ }
455
+ if (path.resolve(process.argv[1] ?? "") === __filename) {
456
+ main().catch((error) => {
457
+ const logger = createLogger({ component: "app" });
458
+ logger.error({ err: error }, "CLI server crashed");
459
+ process.exit(1);
460
+ });
479
461
  }
480
- main().catch((error) => {
481
- const logger = createLogger({ component: "app" });
482
- logger.error({ err: error }, "CLI server crashed");
483
- process.exit(1);
484
- });
@@ -0,0 +1,32 @@
1
+ import assert from "node:assert/strict";
2
+ import { describe, it } from "node:test";
3
+ import { installShutdownSignalHandlers } from "./index";
4
+ import { createServerShutdownHandler } from "./shutdown";
5
+ describe("CLI shutdown signal registration", () => {
6
+ it("routes a second process signal to forced nonzero escalation", async () => {
7
+ const listeners = new Map();
8
+ let finishCleanup;
9
+ const cleanup = new Promise((resolve) => {
10
+ finishCleanup = resolve;
11
+ });
12
+ const exits = [];
13
+ const shutdown = createServerShutdownHandler({
14
+ shutdown: () => cleanup,
15
+ logger: {
16
+ info: () => undefined,
17
+ warn: () => undefined,
18
+ error: () => undefined,
19
+ },
20
+ forceExit: (code) => exits.push(code),
21
+ setExitCode: () => undefined,
22
+ });
23
+ installShutdownSignalHandlers({
24
+ on: (signal, listener) => listeners.set(signal, listener),
25
+ }, shutdown);
26
+ listeners.get("SIGINT")?.();
27
+ listeners.get("SIGTERM")?.();
28
+ assert.deepEqual(exits, [1]);
29
+ finishCleanup();
30
+ await cleanup;
31
+ });
32
+ });
@@ -125,18 +125,118 @@ describe("RemoteProxySessionManager", () => {
125
125
  res.end("ok");
126
126
  });
127
127
  });
128
+ it("closes every session listener during shutdown", async () => {
129
+ await withUpstreamServer(async (upstreamBaseUrl) => {
130
+ const manager = createSessionManager();
131
+ const first = await createSession(manager, `${upstreamBaseUrl}/base`);
132
+ await createSession(manager, `${upstreamBaseUrl}/other`);
133
+ await manager.shutdown();
134
+ assert.equal(manager.sessions.size, 0);
135
+ await assert.rejects(proxyFetch(`${first.proxyOrigin}/status`));
136
+ }, (_req, res) => {
137
+ res.writeHead(200).end("ok");
138
+ });
139
+ });
140
+ it("waits for in-flight idle cleanup during shutdown", async () => {
141
+ await withUpstreamServer(async (upstreamBaseUrl) => {
142
+ const manager = createSessionManager({ disposalTimeoutMs: 1000 });
143
+ const session = await createSession(manager, `${upstreamBaseUrl}/base`);
144
+ const internalSession = manager.sessions.get(session.sessionId);
145
+ const closeGate = deferred();
146
+ const originalClose = internalSession.app.close.bind(internalSession.app);
147
+ internalSession.app.close = async () => {
148
+ await closeGate.promise;
149
+ return originalClose();
150
+ };
151
+ internalSession.lastAccessAt = Date.now() - 31 * 60000;
152
+ const cleanup = manager.cleanupExpiredSessions();
153
+ assert.equal(manager.pendingDisposals.size, 1);
154
+ let shutdownSettled = false;
155
+ const shutdown = manager.shutdown().then(() => {
156
+ shutdownSettled = true;
157
+ });
158
+ await new Promise((resolve) => setImmediate(resolve));
159
+ assert.equal(shutdownSettled, false);
160
+ closeGate.resolve();
161
+ await cleanup;
162
+ await shutdown;
163
+ assert.equal(manager.pendingDisposals.size, 0);
164
+ }, (_req, res) => {
165
+ res.writeHead(200).end("ok");
166
+ });
167
+ });
168
+ it("aborts a stalled event stream during bounded shutdown", async () => {
169
+ await withUpstreamServer(async (upstreamBaseUrl) => {
170
+ const manager = createSessionManager({ disposalTimeoutMs: 100 });
171
+ const session = await createSession(manager, `${upstreamBaseUrl}/base`);
172
+ await activateSession(session);
173
+ const response = await proxyFetch(`${session.proxyOrigin}/events`);
174
+ assert.equal(response.status, 200);
175
+ await Promise.race([
176
+ manager.shutdown(),
177
+ new Promise((_resolve, reject) => setTimeout(() => reject(new Error("shutdown stalled")), 500)),
178
+ ]);
179
+ assert.equal(manager.sessions.size, 0);
180
+ assert.equal(manager.pendingDisposals.size, 0);
181
+ }, (req, res) => {
182
+ if (req.url === "/base/events") {
183
+ res.writeHead(200, { "content-type": "text/event-stream" });
184
+ res.write("data: connected\n\n");
185
+ return;
186
+ }
187
+ res.writeHead(200).end("ok");
188
+ });
189
+ });
190
+ it("surfaces listener disposal failures", async () => {
191
+ await withUpstreamServer(async (upstreamBaseUrl) => {
192
+ const manager = createSessionManager();
193
+ const session = await createSession(manager, `${upstreamBaseUrl}/base`);
194
+ const internalSession = manager.sessions.get(session.sessionId);
195
+ const originalClose = internalSession.app.close.bind(internalSession.app);
196
+ internalSession.app.close = async () => {
197
+ await originalClose();
198
+ throw new Error("close failed");
199
+ };
200
+ await assert.rejects(manager.deleteSession(session.sessionId), /Remote proxy disposal failed/);
201
+ await assert.rejects(manager.shutdown(), /Remote proxy shutdown failed/);
202
+ }, (_req, res) => {
203
+ res.writeHead(200).end("ok");
204
+ });
205
+ });
206
+ it("waits for in-flight creation and rejects sessions that cross shutdown", async () => {
207
+ await withUpstreamServer(async (upstreamBaseUrl) => {
208
+ const manager = createSessionManager();
209
+ const creation = manager.createSession(`${upstreamBaseUrl}/base`, false);
210
+ const shutdown = manager.shutdown();
211
+ await assert.rejects(creation, /shutting down/);
212
+ await shutdown;
213
+ assert.equal(manager.pendingCreations.size, 0);
214
+ assert.equal(manager.sessions.size, 0);
215
+ await assert.rejects(manager.createSession(`${upstreamBaseUrl}/base`, false), /shutting down/);
216
+ }, (_req, res) => {
217
+ res.writeHead(200).end("ok");
218
+ });
219
+ });
128
220
  });
129
- function createSessionManager() {
221
+ function createSessionManager(options = {}) {
130
222
  const manager = new RemoteProxySessionManager({
131
223
  authManager: {
132
224
  isLoopbackRequest: () => true,
133
225
  },
134
226
  logger: createStubLogger(),
135
227
  httpsOptions: sharedHttpsOptions,
228
+ ...options,
136
229
  });
137
230
  managers.add(manager);
138
231
  return manager;
139
232
  }
233
+ function deferred() {
234
+ let resolve;
235
+ const promise = new Promise((resolvePromise) => {
236
+ resolve = resolvePromise;
237
+ });
238
+ return { promise, resolve };
239
+ }
140
240
  async function createSession(manager, baseUrl) {
141
241
  const created = await manager.createSession(baseUrl, false);
142
242
  const windowUrl = new URL(created.windowUrl);
@@ -171,11 +271,7 @@ async function proxyFetch(url, init) {
171
271
  return fetch(url, { dispatcher: httpsDispatcher, ...init });
172
272
  }
173
273
  async function disposeManager(manager) {
174
- const sessions = Array.from(manager.sessions.keys());
175
- for (const sessionId of sessions) {
176
- await manager.deleteSession(sessionId);
177
- }
178
- clearInterval(manager.cleanupTimer);
274
+ await manager.shutdown().catch(() => undefined);
179
275
  }
180
276
  async function withUpstreamServer(callback, handler) {
181
277
  const server = http.createServer(handler);
@@ -23,6 +23,7 @@ import { registerRemoteServerRoutes } from "./routes/remote-servers";
23
23
  import { registerRemoteProxyRoutes } from "./routes/remote-proxy";
24
24
  import { registerSideCarRoutes } from "./routes/sidecars";
25
25
  import { registerPreviewRoutes } from "./routes/previews";
26
+ import { registerUsageRoutes } from "./routes/usage";
26
27
  import { BackgroundProcessManager } from "../background-processes/manager";
27
28
  import { AutoAcceptManager } from "../permissions/auto-accept-manager";
28
29
  import { createOpencodePermissionReplier } from "../permissions/opencode-replier";
@@ -224,6 +225,7 @@ export function createHttpServer(deps) {
224
225
  registerSpeechRoutes(app, { speechService: deps.speechService });
225
226
  registerSideCarRoutes(app, { sidecarManager: deps.sidecarManager });
226
227
  registerPreviewRoutes(app, { previewManager: deps.previewManager });
228
+ registerUsageRoutes(app);
227
229
  registerSideCarProxyRoutes(app, { sidecarManager: deps.sidecarManager, logger: proxyLogger });
228
230
  registerPreviewProxyRoutes(app, { previewManager: deps.previewManager, logger: proxyLogger });
229
231
  setupSideCarWebSocketProxy(app, {
@@ -7,24 +7,46 @@ const LOOPBACK_HOST = "127.0.0.1";
7
7
  const BOOTSTRAP_PAGE_PATH = "/__codenomad/auth/token";
8
8
  const BOOTSTRAP_EXCHANGE_PATH = "/__codenomad/api/auth/token";
9
9
  const SESSION_IDLE_TTL_MS = 30 * 60000;
10
+ const SESSION_DISPOSAL_TIMEOUT_MS = 5000;
10
11
  export class RemoteProxySessionManager {
11
12
  constructor(options) {
12
13
  this.options = options;
13
14
  this.sessions = new Map();
15
+ this.pendingCreations = new Set();
16
+ this.pendingDisposals = new Set();
17
+ this.disposalFailures = [];
18
+ this.shuttingDown = false;
19
+ this.disposalTimeoutMs = Math.max(1, options.disposalTimeoutMs ?? SESSION_DISPOSAL_TIMEOUT_MS);
14
20
  this.cleanupTimer = setInterval(() => {
15
- void this.cleanupExpiredSessions();
21
+ void this.cleanupExpiredSessions().catch((error) => {
22
+ this.options.logger.error({ err: error }, "Failed to dispose expired remote proxy session");
23
+ });
16
24
  }, 60000);
17
25
  this.cleanupTimer.unref();
18
26
  }
19
27
  async createSession(baseUrl, skipTlsVerify) {
28
+ if (this.shuttingDown) {
29
+ throw new Error("Remote proxy session manager is shutting down");
30
+ }
31
+ const creation = this.createSessionInternal(baseUrl, skipTlsVerify);
32
+ this.pendingCreations.add(creation);
33
+ try {
34
+ return await creation;
35
+ }
36
+ finally {
37
+ this.pendingCreations.delete(creation);
38
+ }
39
+ }
40
+ async createSessionInternal(baseUrl, skipTlsVerify) {
20
41
  if (!this.options.httpsOptions) {
21
42
  throw new Error("Local HTTPS is required for remote proxy sessions");
22
43
  }
23
44
  const targetBaseUrl = normalizeBaseUrl(baseUrl);
24
45
  const sessionId = randomUUID();
25
46
  const bootstrapToken = randomBytes(32).toString("base64url");
26
- const dispatcher = skipTlsVerify ? new Agent({ connect: { rejectUnauthorized: false } }) : undefined;
27
- const app = Fastify({ logger: false, https: this.options.httpsOptions });
47
+ const dispatcher = new Agent(skipTlsVerify ? { connect: { rejectUnauthorized: false } } : {});
48
+ const abortController = new AbortController();
49
+ const app = Fastify({ logger: false, https: this.options.httpsOptions, forceCloseConnections: true });
28
50
  let session = null;
29
51
  app.removeAllContentTypeParsers();
30
52
  // Preserve raw request bodies for proxying while still letting token JSON parse from Buffer.
@@ -82,6 +104,17 @@ export class RemoteProxySessionManager {
82
104
  await proxyRequest({ request, reply, session, logger: this.options.logger });
83
105
  });
84
106
  const addressInfo = await app.listen({ host: LOOPBACK_HOST, port: 0 });
107
+ if (this.shuttingDown) {
108
+ abortController.abort();
109
+ try {
110
+ await this.disposeResources(app, dispatcher);
111
+ }
112
+ catch (error) {
113
+ this.disposalFailures.push(error);
114
+ throw error;
115
+ }
116
+ throw new Error("Remote proxy session manager is shutting down");
117
+ }
85
118
  const address = new URL(addressInfo);
86
119
  const localBaseUrl = new URL(`https://${LOOPBACK_HOST}:${address.port}`);
87
120
  const entryUrl = new URL(targetBaseUrl.pathname || "/", localBaseUrl);
@@ -98,6 +131,7 @@ export class RemoteProxySessionManager {
98
131
  cookiePrefix: `cnrp_${randomBytes(6).toString("hex")}_`,
99
132
  app,
100
133
  dispatcher,
134
+ abortController,
101
135
  createdAt: Date.now(),
102
136
  lastAccessAt: Date.now(),
103
137
  };
@@ -108,6 +142,27 @@ export class RemoteProxySessionManager {
108
142
  async deleteSession(sessionId) {
109
143
  return this.disposeSession(sessionId);
110
144
  }
145
+ async shutdown() {
146
+ this.shuttingDown = true;
147
+ clearInterval(this.cleanupTimer);
148
+ const disposals = new Set(this.pendingDisposals);
149
+ await Promise.allSettled(Array.from(this.pendingCreations));
150
+ for (const sessionId of this.sessions.keys())
151
+ disposals.add(this.disposeSession(sessionId));
152
+ while (this.pendingDisposals.size > 0) {
153
+ const pending = Array.from(this.pendingDisposals);
154
+ for (const disposal of pending)
155
+ disposals.add(disposal);
156
+ await Promise.allSettled(pending);
157
+ }
158
+ const results = await Promise.allSettled(disposals);
159
+ const failures = [
160
+ ...this.disposalFailures,
161
+ ...results.flatMap((result) => result.status === "rejected" ? [result.reason] : []),
162
+ ];
163
+ if (failures.length > 0)
164
+ throw createDisposalError("Remote proxy shutdown failed", failures);
165
+ }
111
166
  async cleanupExpiredSessions() {
112
167
  const now = Date.now();
113
168
  for (const session of Array.from(this.sessions.values())) {
@@ -117,17 +172,48 @@ export class RemoteProxySessionManager {
117
172
  await this.disposeSession(session.id);
118
173
  }
119
174
  }
120
- async disposeSession(sessionId) {
175
+ disposeSession(sessionId) {
121
176
  const session = this.sessions.get(sessionId);
122
177
  if (!session) {
123
- return false;
178
+ return Promise.resolve(false);
124
179
  }
125
180
  this.sessions.delete(sessionId);
126
- session.dispatcher?.close().catch(() => { });
127
- await session.app.close().catch(() => { });
128
- this.options.logger.info({ sessionId }, "Disposed remote proxy session");
129
- return true;
181
+ session.abortController.abort();
182
+ const disposal = this.disposeResources(session.app, session.dispatcher)
183
+ .then(() => {
184
+ this.options.logger.info({ sessionId }, "Disposed remote proxy session");
185
+ return true;
186
+ })
187
+ .catch((error) => {
188
+ this.disposalFailures.push(error);
189
+ throw error;
190
+ });
191
+ this.pendingDisposals.add(disposal);
192
+ void disposal.then(() => this.pendingDisposals.delete(disposal), () => this.pendingDisposals.delete(disposal));
193
+ return disposal;
130
194
  }
195
+ async disposeResources(app, dispatcher) {
196
+ app.server.closeAllConnections?.();
197
+ const disposal = Promise.allSettled([app.close(), dispatcher.destroy()]);
198
+ let timeout;
199
+ const deadline = new Promise((_resolve, reject) => {
200
+ timeout = setTimeout(() => reject(new Error(`Remote proxy disposal timed out after ${this.disposalTimeoutMs}ms`)), this.disposalTimeoutMs);
201
+ timeout.unref();
202
+ });
203
+ try {
204
+ const results = await Promise.race([disposal, deadline]);
205
+ const failures = results.flatMap((result) => result.status === "rejected" ? [result.reason] : []);
206
+ if (failures.length > 0)
207
+ throw createDisposalError("Remote proxy disposal failed", failures);
208
+ }
209
+ finally {
210
+ if (timeout)
211
+ clearTimeout(timeout);
212
+ }
213
+ }
214
+ }
215
+ function createDisposalError(message, failures) {
216
+ return Object.assign(new Error(message), { failures });
131
217
  }
132
218
  function normalizeBaseUrl(input) {
133
219
  const parsed = new URL(input.trim());
@@ -253,6 +339,7 @@ async function proxyRequest(args) {
253
339
  method: request.method,
254
340
  headers,
255
341
  dispatcher: session.dispatcher,
342
+ signal: session.abortController.signal,
256
343
  redirect: "manual",
257
344
  };
258
345
  if (request.method !== "GET" && request.method !== "HEAD") {
@@ -0,0 +1,18 @@
1
+ import { z } from "zod";
2
+ import { getProviderUsage } from "../../usage/service";
3
+ const UsageParamsSchema = z.object({ providerId: z.string().trim().min(1) });
4
+ const UsageQuerySchema = z.object({ modelId: z.string().trim().optional() });
5
+ export function registerUsageRoutes(app) {
6
+ app.get("/api/usage/:providerId", async (request, reply) => {
7
+ try {
8
+ const params = UsageParamsSchema.parse(request.params);
9
+ const query = UsageQuerySchema.parse(request.query ?? {});
10
+ return await getProviderUsage(params.providerId, { modelId: query.modelId });
11
+ }
12
+ catch (error) {
13
+ request.log.error({ err: error }, "Failed to fetch provider usage");
14
+ reply.code(400);
15
+ return { error: error instanceof Error ? error.message : "Failed to fetch provider usage" };
16
+ }
17
+ });
18
+ }
@@ -0,0 +1,26 @@
1
+ import assert from "node:assert/strict";
2
+ import test from "node:test";
3
+ import Fastify from "fastify";
4
+ import { registerUsageRoutes } from "./usage";
5
+ test("returns a typed unsupported result without exposing server details", async () => {
6
+ const app = Fastify();
7
+ registerUsageRoutes(app);
8
+ try {
9
+ const response = await app.inject({ method: "GET", url: "/api/usage/unknown-provider?modelId=test-model" });
10
+ assert.equal(response.statusCode, 200);
11
+ assert.deepEqual(response.json(), {
12
+ requestedProviderId: "unknown-provider",
13
+ providerId: null,
14
+ providerName: "unknown-provider",
15
+ modelId: "test-model",
16
+ supported: false,
17
+ configured: false,
18
+ ok: false,
19
+ windows: {},
20
+ fetchedAt: response.json().fetchedAt,
21
+ });
22
+ }
23
+ finally {
24
+ await app.close();
25
+ }
26
+ });