@neuralnomads/codenomad-dev 0.18.0-dev-20260727-269cff64 → 0.18.0-dev-20260804-c16cc005

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 (50) hide show
  1. package/dist/background-processes/manager.js +15 -30
  2. package/dist/background-processes/manager.test.js +123 -0
  3. package/dist/index.js +2 -2
  4. package/dist/server/__tests__/listener-retry.test.js +15 -0
  5. package/dist/server/http-server.js +7 -7
  6. package/dist/workspaces/instance-client.js +6 -5
  7. package/dist/workspaces/instance-client.test.js +149 -0
  8. package/dist/workspaces/instance-events.js +2 -2
  9. package/dist/workspaces/loopback.js +8 -0
  10. package/dist/workspaces/manager.js +24 -8
  11. package/dist/workspaces/manager.test.js +55 -10
  12. package/package.json +1 -1
  13. package/public/assets/FilesTab-PxqWVPyo.js +2 -0
  14. package/public/assets/{GitChangesTab-COJ_xDva.js → GitChangesTab-CBKL5IdG.js} +1 -1
  15. package/public/assets/{SplitFilePanel-B5nrgBs3.js → SplitFilePanel-DaqtU5jw.js} +1 -1
  16. package/public/assets/StatusTab-B-z6yiGA.js +1 -0
  17. package/public/assets/{align-justify-CKDczyVy.js → align-justify-D8zzAQxs.js} +1 -1
  18. package/public/assets/{diff-viewer-drQ_Yje8.js → diff-viewer-BU6juksj.js} +1 -1
  19. package/public/assets/index-7b0Woxv7.js +1 -0
  20. package/public/assets/{index-Bt9PdFAD.css → index-B7bZsUV8.css} +1 -1
  21. package/public/assets/index-BnIGFV9i.js +1 -0
  22. package/public/assets/index-Bwil2k-o.js +1 -0
  23. package/public/assets/index-Cct6SfHO.js +1 -0
  24. package/public/assets/index-CfcsvGZu.js +1 -0
  25. package/public/assets/index-CzxPTjaX.js +1 -0
  26. package/public/assets/index-DMYv1E9L.js +2 -0
  27. package/public/assets/index-FXydT52m.js +1 -0
  28. package/public/assets/index-ZBLywF20.js +1 -0
  29. package/public/assets/{loading-Cz4dKVP8.js → loading-Bdrg1dVr.js} +1 -1
  30. package/public/assets/main-oNL0VKfF.js +69 -0
  31. package/public/assets/{markdown-DUHZyBXo.js → markdown-BwpQkztY.js} +1 -1
  32. package/public/assets/{tool-call-C7FlzFBf.js → tool-call-BOKqxL44.js} +3 -3
  33. package/public/assets/unified-picker-DoMT5Ia8.js +1 -0
  34. package/public/assets/{wrap-text-CS5K8Uhi.js → wrap-text-CJPfwppv.js} +1 -1
  35. package/public/index.html +3 -3
  36. package/public/loading.html +3 -3
  37. package/public/sw.js +1 -1
  38. package/public/assets/FilesTab-pl_3_Ppx.js +0 -2
  39. package/public/assets/StatusTab-rXgEwwYW.js +0 -1
  40. package/public/assets/index-BymyGk2C.js +0 -1
  41. package/public/assets/index-CEIEYgCg.js +0 -1
  42. package/public/assets/index-Cqo6ftJT.js +0 -1
  43. package/public/assets/index-Cs1fQOLl.js +0 -1
  44. package/public/assets/index-CwhvpTeE.js +0 -1
  45. package/public/assets/index-D0xuNxlo.js +0 -2
  46. package/public/assets/index-D1HW4nyh.js +0 -1
  47. package/public/assets/index-DBw0YE-2.js +0 -1
  48. package/public/assets/index-OYNGtp3z.js +0 -1
  49. package/public/assets/main-BEYytlgD.js +0 -68
  50. package/public/assets/unified-picker-CpU6qugd.js +0 -1
@@ -2,6 +2,7 @@ import { spawn, spawnSync } from "child_process";
2
2
  import { createWriteStream, existsSync, promises as fs } from "fs";
3
3
  import path from "path";
4
4
  import { randomBytes } from "crypto";
5
+ import { createInstanceClient } from "../workspaces/instance-client";
5
6
  const ROOT_DIR = ".codenomad/background_processes";
6
7
  const INDEX_FILE = "index.json";
7
8
  const OUTPUT_FILE = "output.txt";
@@ -511,38 +512,22 @@ export class BackgroundProcessManager {
511
512
  const notify = record.notify;
512
513
  if (!notify || !record.terminalReason)
513
514
  return;
514
- if (!this.deps.workspaceManager.get(workspaceId)) {
515
- throw new Error("Workspace not found");
516
- }
517
- const port = this.deps.workspaceManager.getInstancePort(workspaceId);
518
- if (!port) {
519
- throw new Error("Workspace instance is not ready");
520
- }
521
- const targetUrl = `http://127.0.0.1:${port}/session/${encodeURIComponent(notify.sessionID)}/prompt_async`;
522
- const headers = {
523
- "content-type": "application/json",
524
- };
525
- const authorization = this.deps.workspaceManager.getInstanceAuthorizationHeader(workspaceId);
526
- if (authorization) {
527
- headers.authorization = authorization;
528
- }
529
- const response = await fetch(targetUrl, {
530
- method: "POST",
531
- headers,
532
- body: JSON.stringify({
533
- parts: [
534
- {
535
- type: "text",
536
- text: this.buildSyntheticCompletionPrompt(record),
537
- synthetic: true,
538
- },
539
- ],
540
- }),
515
+ const client = createInstanceClient(this.deps.workspaceManager, workspaceId, {
516
+ directory: notify.directory,
541
517
  });
542
- if (!response.ok) {
543
- const message = await response.text().catch(() => "");
544
- throw new Error(message || `Prompt request failed with ${response.status}`);
518
+ if (!client) {
519
+ throw new Error("Workspace instance is not ready");
545
520
  }
521
+ await client.session.promptAsync({
522
+ sessionID: notify.sessionID,
523
+ parts: [
524
+ {
525
+ type: "text",
526
+ text: this.buildSyntheticCompletionPrompt(record),
527
+ synthetic: true,
528
+ },
529
+ ],
530
+ }, { throwOnError: true });
546
531
  }
547
532
  buildCompletionPrompt(record) {
548
533
  const ref = `Background process "${record.title}" (${record.id})`;
@@ -0,0 +1,123 @@
1
+ import assert from "node:assert/strict";
2
+ import { describe, it } from "node:test";
3
+ import { promises as fs } from "node:fs";
4
+ import path from "node:path";
5
+ import os from "node:os";
6
+ import { BackgroundProcessManager } from "./manager";
7
+ const WORKSPACE_ID = "ws-test";
8
+ const SESSION_ID = "sess-1";
9
+ const INSTANCE_PORT = 9999;
10
+ const AUTH_HEADER = "Basic test-auth";
11
+ const TERMINAL_TIMEOUT_MS = 3000;
12
+ /**
13
+ * Drives the real {@link BackgroundProcessManager} lifecycle (spawn a
14
+ * fast-exiting command with notify enabled) against a mocked transport, so the
15
+ * migrated `sendCompletionPrompt` path — factory + SDK client + `fetch` — is
16
+ * exercised end to end without touching production wiring.
17
+ *
18
+ * The workspace temp directory is intentionally left in place (under
19
+ * `os.tmpdir()`, OS-reaped): removing it from the test races the manager's
20
+ * asynchronous finalization writes, which intermittently fail with ENOENT.
21
+ */
22
+ async function runCompletionPrompt(fetchImpl) {
23
+ const requests = [];
24
+ const originalFetch = globalThis.fetch;
25
+ // Captured now but swapped in only inside the try below, so a failure during
26
+ // setup (mkdtemp, manager construction) can't leak the mocked fetch.
27
+ const fetchMock = (async (input, init) => {
28
+ const req = input instanceof Request ? input : new Request(String(input), init);
29
+ requests.push({
30
+ method: req.method,
31
+ url: req.url,
32
+ headers: req.headers,
33
+ body: await req.text(),
34
+ });
35
+ return fetchImpl(input instanceof Request ? input : req, init);
36
+ });
37
+ let warned = false;
38
+ const logger = {
39
+ warn: () => { warned = true; },
40
+ debug: () => { },
41
+ trace: () => { },
42
+ info: () => { },
43
+ error: () => { },
44
+ fatal: () => { },
45
+ isLevelEnabled: () => false,
46
+ level: "info",
47
+ child: () => logger,
48
+ };
49
+ const workspacePath = await fs.mkdtemp(path.join(os.tmpdir(), "bp-test-"));
50
+ // Distinct from the workspace root so the directory-override assertion is
51
+ // discriminating: if `sendCompletionPrompt` stops passing `notify.directory`,
52
+ // the factory would fall back to `workspacePath` and the header check fails.
53
+ const sessionDir = path.join(workspacePath, "session-worktree");
54
+ // Resolve once the manager publishes a terminal (non-running) status update.
55
+ let resolveTerminal = () => { };
56
+ const terminal = new Promise((resolve) => { resolveTerminal = resolve; });
57
+ const eventBus = {
58
+ on: () => { },
59
+ publish: (event) => {
60
+ if (event?.type === "instance.event") {
61
+ const type = event?.event?.type;
62
+ const status = event?.event?.properties?.process?.status;
63
+ if (type === "background.process.removed" || (status && status !== "running"))
64
+ resolveTerminal();
65
+ }
66
+ return true;
67
+ },
68
+ };
69
+ const workspaceManager = {
70
+ get: () => ({ path: workspacePath }),
71
+ getInstancePort: () => INSTANCE_PORT,
72
+ getInstanceAuthorizationHeader: () => AUTH_HEADER,
73
+ };
74
+ const manager = new BackgroundProcessManager({ workspaceManager, eventBus, logger });
75
+ try {
76
+ globalThis.fetch = fetchMock;
77
+ await manager.start(WORKSPACE_ID, "test-proc", "true", {
78
+ notify: true,
79
+ notification: { sessionID: SESSION_ID, directory: sessionDir },
80
+ });
81
+ // The terminal status update is published at the very end of finalize, so
82
+ // resolving on it is a deterministic completion signal. Fail loudly rather
83
+ // than racing a silent timeout that could mask a hang.
84
+ let timeoutHandle;
85
+ const reachedTerminal = await Promise.race([
86
+ terminal.then(() => true),
87
+ new Promise((resolve) => {
88
+ timeoutHandle = setTimeout(() => resolve(false), TERMINAL_TIMEOUT_MS);
89
+ }),
90
+ ]);
91
+ if (timeoutHandle)
92
+ clearTimeout(timeoutHandle);
93
+ if (!reachedTerminal) {
94
+ throw new Error("background process did not reach a terminal state in time");
95
+ }
96
+ }
97
+ finally {
98
+ globalThis.fetch = originalFetch;
99
+ }
100
+ return { requests, warned, directory: sessionDir };
101
+ }
102
+ describe("BackgroundProcessManager.sendCompletionPrompt", () => {
103
+ it("posts the synthetic completion prompt to the instance via the SDK route", async () => {
104
+ const { requests, directory } = await runCompletionPrompt(async () => new Response("{}", { status: 200, headers: { "content-type": "application/json" } }));
105
+ const promptCall = requests.find((r) => r.url.includes("/prompt_async"));
106
+ assert.ok(promptCall, "expected a prompt_async request");
107
+ assert.equal(promptCall.method, "POST");
108
+ assert.equal(promptCall.url, `http://127.0.0.1:${INSTANCE_PORT}/session/${SESSION_ID}/prompt_async`);
109
+ assert.equal(promptCall.headers.get("authorization"), AUTH_HEADER);
110
+ // The prompt is scoped to the session's directory (a POST keeps the
111
+ // directory as a header — the SDK only rewrites header→query for GET/HEAD).
112
+ assert.equal(promptCall.headers.get("x-opencode-directory"), encodeURIComponent(directory));
113
+ const body = JSON.parse(promptCall.body);
114
+ assert.equal(body.parts.length, 1);
115
+ assert.equal(body.parts[0].type, "text");
116
+ assert.equal(body.parts[0].synthetic, true);
117
+ assert.match(body.parts[0].text, /test-proc/);
118
+ });
119
+ it("swallows a failed prompt and logs it without aborting finalization", async () => {
120
+ const { warned } = await runCompletionPrompt(async () => new Response("boom", { status: 500 }));
121
+ assert.equal(warned, true);
122
+ });
123
+ });
package/dist/index.js CHANGED
@@ -171,8 +171,8 @@ function resolveHost(input) {
171
171
  }
172
172
  return trimmed;
173
173
  }
174
- function programHasArg(argv, flag) {
175
- return argv.includes(flag);
174
+ export function programHasArg(argv, flag) {
175
+ return argv.some((argument) => argument === flag || argument.startsWith(`${flag}=`));
176
176
  }
177
177
  async function main() {
178
178
  const options = parseCliOptions(process.argv.slice(2));
@@ -0,0 +1,15 @@
1
+ import assert from "node:assert/strict";
2
+ import test from "node:test";
3
+ import { programHasArg } from "../../index";
4
+ import { shouldRetryPreferredPort } from "../http-server";
5
+ test("automatic listeners retry Windows reserved ports without masking explicit failures", () => {
6
+ assert.equal(shouldRetryPreferredPort({ code: "EADDRINUSE" }, true, "linux"), true);
7
+ assert.equal(shouldRetryPreferredPort({ code: "EACCES" }, true, "win32"), true);
8
+ assert.equal(shouldRetryPreferredPort({ code: "EACCES" }, true, "linux"), false);
9
+ assert.equal(shouldRetryPreferredPort({ code: "EACCES" }, false, "win32"), false);
10
+ });
11
+ test("explicit listener ports are detected in both supported CLI forms", () => {
12
+ assert.equal(programHasArg(["--http-port", "9899"], "--http-port"), true);
13
+ assert.equal(programHasArg(["--https-port=9898"], "--https-port"), true);
14
+ assert.equal(programHasArg(["--http-porter=9899"], "--http-port"), false);
15
+ });
@@ -29,6 +29,12 @@ import { BackgroundProcessManager } from "../background-processes/manager";
29
29
  import { registerAuthRoutes } from "./routes/auth";
30
30
  import { sendUnauthorized, wantsHtml } from "../auth/http-auth";
31
31
  import { createOpenCodeUpdateService } from "../opencode-update/service";
32
+ export function shouldRetryPreferredPort(error, autoPortRequested, platform = process.platform) {
33
+ if (!autoPortRequested)
34
+ return false;
35
+ const code = error?.code;
36
+ return code === "EADDRINUSE" || (platform === "win32" && code === "EACCES");
37
+ }
32
38
  export function createHttpServer(deps) {
33
39
  // Fastify's type-level RawServer inference gets noisy when toggling HTTP vs HTTPS.
34
40
  // We keep the runtime behavior correct and cast the instance to a generic FastifyInstance.
@@ -261,18 +267,12 @@ export function createHttpServer(deps) {
261
267
  };
262
268
  const autoPortRequested = deps.bindPort === 0;
263
269
  const primaryPort = autoPortRequested ? deps.defaultPort : deps.bindPort;
264
- const shouldRetryWithEphemeral = (error) => {
265
- if (!autoPortRequested)
266
- return false;
267
- const err = error;
268
- return Boolean(err && err.code === "EADDRINUSE");
269
- };
270
270
  let listenResult;
271
271
  try {
272
272
  listenResult = await attemptListen(primaryPort);
273
273
  }
274
274
  catch (error) {
275
- if (!shouldRetryWithEphemeral(error)) {
275
+ if (!shouldRetryPreferredPort(error, autoPortRequested)) {
276
276
  throw error;
277
277
  }
278
278
  deps.logger.warn({ err: error, port: primaryPort }, "Preferred port unavailable, retrying on ephemeral port");
@@ -1,5 +1,5 @@
1
1
  import { createOpencodeClient } from "@opencode-ai/sdk/v2/client";
2
- const INSTANCE_HOST = "127.0.0.1";
2
+ import { LOOPBACK_HOST } from "./loopback";
3
3
  const LOOPBACK_TIMEOUT_MS = 10_000;
4
4
  /**
5
5
  * Creates an OpenCode SDK client for direct loopback communication with a
@@ -11,8 +11,8 @@ const LOOPBACK_TIMEOUT_MS = 10_000;
11
11
  * OpenCode instance directly should use this factory rather than building
12
12
  * `http://127.0.0.1:{port}/...` URLs by hand.
13
13
  *
14
- * All requests carry a 10-second timeout loopback calls should be
15
- * near-instant; a hang indicates a stuck instance.
14
+ * Requests carry a 10-second timeout (configurable via `timeoutMs`)
15
+ * loopback calls should be near-instant; a hang indicates a stuck instance.
16
16
  *
17
17
  * The client is cheap to create (object only, no connection); create one per
18
18
  * call or cache per instance as needed. Returns `null` when the instance has
@@ -29,13 +29,14 @@ export function createInstanceClient(workspaceManager, instanceId, options = {})
29
29
  }
30
30
  const workspace = workspaceManager.get(instanceId);
31
31
  const timeoutMs = options.timeoutMs ?? LOOPBACK_TIMEOUT_MS;
32
+ const directory = options.directory ?? workspace?.path;
32
33
  return createOpencodeClient({
33
- baseUrl: `http://${INSTANCE_HOST}:${port}/`,
34
+ baseUrl: `http://${LOOPBACK_HOST}:${port}/`,
34
35
  headers,
35
36
  fetch: (url, init) => fetch(url, {
36
37
  ...init,
37
38
  signal: init?.signal ?? AbortSignal.timeout(timeoutMs),
38
39
  }),
39
- ...(workspace?.path ? { directory: workspace.path } : {}),
40
+ ...(directory ? { directory } : {}),
40
41
  });
41
42
  }
@@ -0,0 +1,149 @@
1
+ import assert from "node:assert/strict";
2
+ import { describe, it } from "node:test";
3
+ import { createInstanceClient } from "./instance-client";
4
+ function makeManager(overrides = {}) {
5
+ return {
6
+ getInstancePort: overrides.getInstancePort ?? (() => undefined),
7
+ getInstanceAuthorizationHeader: overrides.getInstanceAuthorizationHeader ?? (() => undefined),
8
+ get: overrides.get ?? (() => undefined),
9
+ };
10
+ }
11
+ /**
12
+ * Installs a global `fetch` stub that records every outgoing request and
13
+ * answers a minimal healthy JSON body. Returns the capture buffer and a
14
+ * restore function. The stub tolerates both `fetch(url, init)` and
15
+ * `fetch(Request)` invocation styles so it is independent of the SDK's
16
+ * internal call convention.
17
+ */
18
+ function installRecordingFetch() {
19
+ const requests = [];
20
+ const original = globalThis.fetch;
21
+ globalThis.fetch = (async (input, init) => {
22
+ if (input instanceof Request) {
23
+ requests.push({ url: input.url, headers: new Headers(init?.headers ?? input.headers) });
24
+ }
25
+ else {
26
+ requests.push({ url: String(input), headers: new Headers(init?.headers) });
27
+ }
28
+ return new Response(JSON.stringify({ healthy: true }), {
29
+ status: 200,
30
+ headers: { "content-type": "application/json" },
31
+ });
32
+ });
33
+ return { requests, restore: () => { globalThis.fetch = original; } };
34
+ }
35
+ describe("createInstanceClient", () => {
36
+ it("returns null when the instance has no open port", () => {
37
+ const manager = makeManager({ getInstancePort: () => undefined });
38
+ assert.equal(createInstanceClient(manager, "ws-1"), null);
39
+ });
40
+ it("targets the loopback host and port on outgoing requests", async () => {
41
+ const { requests, restore } = installRecordingFetch();
42
+ try {
43
+ const manager = makeManager({ getInstancePort: () => 4321, get: () => ({ path: "/repo" }) });
44
+ const client = createInstanceClient(manager, "ws-1");
45
+ assert.ok(client, "expected a client when the instance has a port");
46
+ await client.global.health();
47
+ const parsed = new URL(requests[0].url);
48
+ assert.equal(parsed.hostname, "127.0.0.1");
49
+ assert.equal(parsed.port, "4321");
50
+ }
51
+ finally {
52
+ restore();
53
+ }
54
+ });
55
+ it("attaches the authorization header when one is configured", async () => {
56
+ const { requests, restore } = installRecordingFetch();
57
+ try {
58
+ const manager = makeManager({
59
+ getInstancePort: () => 4321,
60
+ getInstanceAuthorizationHeader: () => "Basic abc",
61
+ get: () => ({ path: "/repo" }),
62
+ });
63
+ const client = createInstanceClient(manager, "ws-1");
64
+ await client.global.health();
65
+ assert.equal(requests[0].headers.get("authorization"), "Basic abc");
66
+ }
67
+ finally {
68
+ restore();
69
+ }
70
+ });
71
+ it("omits the authorization header when none is configured", async () => {
72
+ const { requests, restore } = installRecordingFetch();
73
+ try {
74
+ const manager = makeManager({ getInstancePort: () => 4321, get: () => ({ path: "/repo" }) });
75
+ const client = createInstanceClient(manager, "ws-1");
76
+ await client.global.health();
77
+ assert.equal(requests[0].headers.get("authorization"), null);
78
+ }
79
+ finally {
80
+ restore();
81
+ }
82
+ });
83
+ it("scopes requests to the workspace directory", async () => {
84
+ const { requests, restore } = installRecordingFetch();
85
+ try {
86
+ const manager = makeManager({ getInstancePort: () => 4321, get: () => ({ path: "/repo" }) });
87
+ const client = createInstanceClient(manager, "ws-1");
88
+ await client.global.health();
89
+ // GET requests carry directory as a query parameter (see SDK rewrite).
90
+ assert.equal(new URL(requests[0].url).searchParams.get("directory"), "/repo");
91
+ }
92
+ finally {
93
+ restore();
94
+ }
95
+ });
96
+ it("does not scope requests when the workspace has no path", async () => {
97
+ const { requests, restore } = installRecordingFetch();
98
+ try {
99
+ const manager = makeManager({ getInstancePort: () => 4321, get: () => undefined });
100
+ const client = createInstanceClient(manager, "ws-1");
101
+ await client.global.health();
102
+ assert.equal(new URL(requests[0].url).searchParams.get("directory"), null);
103
+ }
104
+ finally {
105
+ restore();
106
+ }
107
+ });
108
+ it("honours an explicit directory override over the workspace root", async () => {
109
+ const { requests, restore } = installRecordingFetch();
110
+ try {
111
+ const manager = makeManager({ getInstancePort: () => 4321, get: () => ({ path: "/workspace-root" }) });
112
+ const client = createInstanceClient(manager, "ws-1", {
113
+ directory: "/explicit/session-dir",
114
+ });
115
+ await client.global.health();
116
+ assert.equal(new URL(requests[0].url).searchParams.get("directory"), "/explicit/session-dir");
117
+ }
118
+ finally {
119
+ restore();
120
+ }
121
+ });
122
+ it("applies the loopback timeout and aborts a stuck instance", async () => {
123
+ const original = globalThis.fetch;
124
+ // Never resolves on its own; only settles when the passed signal aborts,
125
+ // mirroring how a real fetch honours an AbortSignal. Without the factory
126
+ // timeout this call would hang forever and time the test out.
127
+ globalThis.fetch = (async (_input, init) => {
128
+ return new Promise((_resolve, reject) => {
129
+ const signal = init?.signal;
130
+ if (!signal)
131
+ return;
132
+ if (signal.aborted)
133
+ reject(signal.reason ?? new Error("aborted"));
134
+ else
135
+ signal.addEventListener("abort", () => reject(signal.reason ?? new Error("aborted")));
136
+ });
137
+ });
138
+ try {
139
+ const manager = makeManager({ getInstancePort: () => 4321, get: () => ({ path: "/repo" }) });
140
+ const client = createInstanceClient(manager, "ws-1", { timeoutMs: 10 });
141
+ // SDK methods resolve with { error } rather than throwing by default.
142
+ const result = await client.global.health();
143
+ assert.ok(result.error, "expected the stuck-instance call to surface an error");
144
+ }
145
+ finally {
146
+ globalThis.fetch = original;
147
+ }
148
+ });
149
+ });
@@ -1,6 +1,6 @@
1
1
  import { fetch } from "undici";
2
2
  import { Agent as UndiciAgent } from "undici";
3
- const INSTANCE_HOST = "127.0.0.1";
3
+ import { LOOPBACK_HOST } from "./loopback";
4
4
  const STREAM_AGENT = new UndiciAgent({ bodyTimeout: 0, headersTimeout: 0 });
5
5
  const RECONNECT_DELAY_MS = 1000;
6
6
  export class InstanceEventBridge {
@@ -70,7 +70,7 @@ export class InstanceEventBridge {
70
70
  }
71
71
  }
72
72
  async consumeStream(workspaceId, port, signal) {
73
- const url = `http://${INSTANCE_HOST}:${port}/global/event`;
73
+ const url = `http://${LOOPBACK_HOST}:${port}/global/event`;
74
74
  const headers = { Accept: "text/event-stream" };
75
75
  const authHeader = this.options.workspaceManager.getInstanceAuthorizationHeader(workspaceId);
76
76
  if (authHeader) {
@@ -0,0 +1,8 @@
1
+ /**
2
+ * Loopback host used for direct in-process communication with a running
3
+ * OpenCode workspace instance (the server and the instance share a machine).
4
+ *
5
+ * Shared by the workspace-instance loopback callers so they agree on the host
6
+ * instead of each hardcoding their own `127.0.0.1` literal.
7
+ */
8
+ export const LOOPBACK_HOST = "127.0.0.1";
@@ -11,6 +11,7 @@ import { buildOpencodeConfigContent, getCodeNomadPluginUrl, resolveExistingOpenc
11
11
  import { OPENCODE_SERVER_BASE_URL_ENV, buildOpencodeBasicAuthHeader, OPENCODE_SERVER_PASSWORD_ENV, OPENCODE_SERVER_USERNAME_ENV, resolveOpencodeServerAuth, } from "./opencode-auth";
12
12
  import { resolveWorkspaceIdentity } from "./workspace-identity";
13
13
  import { parseWslUncPath } from "./spawn";
14
+ import { LOOPBACK_HOST } from "./loopback";
14
15
  const STARTUP_STABILITY_DELAY_MS = 1500;
15
16
  const DEFAULT_LAUNCH_TIMEOUT_MS = 30_000;
16
17
  const ORDINARY_CREATION_OWNER = "";
@@ -579,6 +580,10 @@ export class WorkspaceManager {
579
580
  this.exitDuringStartup(params, "exited before becoming ready"),
580
581
  ]);
581
582
  const version = await this.waitForInstanceHealth(params);
583
+ await Promise.race([
584
+ this.validateInstanceConfiguration(params),
585
+ this.exitDuringStartup(params, "exited during configuration validation"),
586
+ ]);
582
587
  await Promise.race([
583
588
  delay(STARTUP_STABILITY_DELAY_MS, undefined, { signal: params.signal }),
584
589
  this.exitDuringStartup(params, "exited shortly after start"),
@@ -606,14 +611,9 @@ export class WorkspaceManager {
606
611
  });
607
612
  }
608
613
  async probeInstance(workspaceId, port, signal) {
609
- const url = `http://127.0.0.1:${port}/global/health`;
614
+ const url = `http://${LOOPBACK_HOST}:${port}/global/health`;
610
615
  try {
611
- const headers = {};
612
- const authHeader = this.opencodeAuth.get(workspaceId)?.authorization;
613
- if (authHeader) {
614
- headers["Authorization"] = authHeader;
615
- }
616
- const response = await fetch(url, { headers, signal });
616
+ const response = await fetch(url, { headers: this.getInstanceRequestHeaders(workspaceId), signal });
617
617
  if (!response.ok) {
618
618
  const reason = `/global/health returned HTTP ${response.status}`;
619
619
  this.options.logger.debug({ workspaceId, status: response.status }, "Health probe returned server error");
@@ -635,6 +635,22 @@ export class WorkspaceManager {
635
635
  return { ok: false, reason };
636
636
  }
637
637
  }
638
+ async validateInstanceConfiguration(params) {
639
+ const response = await fetch(`http://${LOOPBACK_HOST}:${params.port}/config`, {
640
+ headers: this.getInstanceRequestHeaders(params.workspaceId),
641
+ signal: params.signal,
642
+ });
643
+ if (response.ok) {
644
+ await response.body?.cancel();
645
+ return;
646
+ }
647
+ const body = (await response.text()).trim();
648
+ throw new Error(body || `OpenCode /config returned HTTP ${response.status}`);
649
+ }
650
+ getInstanceRequestHeaders(workspaceId) {
651
+ const authorization = this.opencodeAuth.get(workspaceId)?.authorization;
652
+ return authorization ? { Authorization: authorization } : {};
653
+ }
638
654
  buildStartupError(workspaceId, phase, exitInfo, lastOutput) {
639
655
  const exitDetails = this.describeExit(exitInfo);
640
656
  const trimmedOutput = lastOutput.trim();
@@ -656,7 +672,7 @@ export class WorkspaceManager {
656
672
  const tryConnect = () => {
657
673
  if (settled)
658
674
  return;
659
- const socket = connect({ port, host: "127.0.0.1", signal }, () => {
675
+ const socket = connect({ port, host: LOOPBACK_HOST, signal }, () => {
660
676
  cleanup();
661
677
  socket.end();
662
678
  resolve();
@@ -44,6 +44,7 @@ class ControlledRuntime {
44
44
  }
45
45
  }
46
46
  function createHarness(options = {}) {
47
+ const { stubReadiness = true, ...managerOptions } = options;
47
48
  const eventBus = new EventBus();
48
49
  const runtime = new ControlledRuntime();
49
50
  const readiness = deferred();
@@ -59,17 +60,20 @@ function createHarness(options = {}) {
59
60
  logger: pino({ level: "silent" }),
60
61
  getServerBaseUrl: () => "http://127.0.0.1:4000",
61
62
  runtime,
62
- ...options,
63
+ ...managerOptions,
63
64
  });
64
- manager.waitForWorkspaceReadiness = ({ signal }) => Promise.race([
65
- readiness.promise,
66
- new Promise((_resolve, reject) => {
67
- const cancel = () => reject(signal?.reason);
68
- signal?.addEventListener("abort", cancel, { once: true });
69
- if (signal?.aborted)
70
- cancel();
71
- }),
72
- ]);
65
+ if (stubReadiness) {
66
+ ;
67
+ manager.waitForWorkspaceReadiness = ({ signal }) => Promise.race([
68
+ readiness.promise,
69
+ new Promise((_resolve, reject) => {
70
+ const cancel = () => reject(signal?.reason);
71
+ signal?.addEventListener("abort", cancel, { once: true });
72
+ if (signal?.aborted)
73
+ cancel();
74
+ }),
75
+ ]);
76
+ }
73
77
  return { manager, runtime, readiness, started, stopped };
74
78
  }
75
79
  async function createReady(harness) {
@@ -81,6 +85,47 @@ async function createReady(harness) {
81
85
  return workspaceId;
82
86
  }
83
87
  describe("workspace manager lifecycle", () => {
88
+ it("rejects a healthy workspace whose OpenCode configuration is invalid", async () => {
89
+ const originalFetch = globalThis.fetch;
90
+ const requests = [];
91
+ const configError = JSON.stringify({
92
+ name: "ConfigInvalidError",
93
+ data: {
94
+ path: "C:\\Users\\dev\\.config\\opencode\\agents\\invalid.md",
95
+ issues: [{ path: ["tools", "bash"], message: 'Expected boolean, got "ask"' }],
96
+ },
97
+ });
98
+ globalThis.fetch = (async (input) => {
99
+ const url = String(input);
100
+ requests.push(url);
101
+ if (url.includes("/global/health")) {
102
+ return new Response(JSON.stringify({ healthy: true, version: "1.18.5" }), {
103
+ headers: { "Content-Type": "application/json" },
104
+ });
105
+ }
106
+ return new Response(configError, { status: 400, headers: { "Content-Type": "application/json" } });
107
+ });
108
+ try {
109
+ const harness = createHarness({ stubReadiness: false });
110
+ harness.manager.waitForPortAvailability = async () => undefined;
111
+ const creation = harness.manager.create(process.cwd());
112
+ const workspaceId = await harness.runtime.launchCalled.promise;
113
+ harness.runtime.resolveLaunch();
114
+ await assert.rejects(creation, (error) => {
115
+ assert.ok(error instanceof Error);
116
+ assert.equal(error.message, configError);
117
+ return true;
118
+ });
119
+ assert.deepEqual(requests.map((url) => new URL(url).pathname), ["/global/health", "/config"]);
120
+ assert.equal(new URL(requests[1]).search, "");
121
+ assert.equal(harness.runtime.active.has(workspaceId), false);
122
+ assert.deepEqual(harness.started, []);
123
+ assert.deepEqual(harness.manager.list(), []);
124
+ }
125
+ finally {
126
+ globalThis.fetch = originalFetch;
127
+ }
128
+ });
84
129
  for (const boundary of ["launch", "readiness", "shutdown"]) {
85
130
  it(`cancels and cleans a workspace during ${boundary}`, async () => {
86
131
  const harness = createHarness();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@neuralnomads/codenomad-dev",
3
- "version": "0.18.0-dev-20260727-269cff64",
3
+ "version": "0.18.0-dev-20260804-c16cc005",
4
4
  "description": "CodeNomad Server",
5
5
  "license": "MIT",
6
6
  "author": {
@@ -0,0 +1,2 @@
1
+ const __vite__mapDeps=(i,m=__vite__mapDeps,d=(m.f||(m.f=["assets/monaco-viewer-CiFg18Ak.js","assets/git-diff-vendor-CYS3ApzA.js","assets/fast-diff-vendor-DgdwVvTQ.js","assets/highlight-vendor-8FKMu9os.js","assets/git-diff-vendor-HAZkIolJ.css"])))=>i.map(i=>d[i]);
2
+ import{a8 as J,m,t as h,i as s,d as u,a as C,u as U,_ as X,f as Y}from"./monaco-viewer-CiFg18Ak.js";import{d as K,b as P,c as $,n as o,S as g,a as w,z as Z,A as p,F as ee}from"./git-diff-vendor-CYS3ApzA.js";import{S as te}from"./SplitFilePanel-DaqtU5jw.js";import{R as V,ay as le,M as ne,az as re,C as ae,e as ie,aA as se}from"./main-oNL0VKfF.js";import{W as oe}from"./wrap-text-CJPfwppv.js";import"./fast-diff-vendor-DgdwVvTQ.js";import"./highlight-vendor-8FKMu9os.js";import"./index-DMYv1E9L.js";var ce=h('<div class="px-2 py-2 border-b border-base"><div class=selector-input-group><div class="flex items-center gap-2 px-3 text-muted"></div><input type=text class=selector-input>'),de=h("<div class=file-list-header><span class=file-list-title></span><span class=file-list-count>"),O=h('<div class="p-3 text-xs text-secondary">'),he=h("<div class=file-list-item><div class=file-list-item-content><div class=file-list-item-path><span class=file-path-text>.."),ue=h('<div class="p-3 text-xs text-error">'),ve=h('<div><div class=file-list-item-content><div class=file-list-item-path><span class=file-path-text></span></div><div class="flex items-center gap-2 shrink-0"><div class=file-list-item-stats><span class="text-[10px] text-secondary"></span></div><button type=button class=git-change-row-action>'),x=h("<div class=file-viewer-empty><span class=file-viewer-empty-text>"),fe=h('<div class="file-viewer-panel flex-1"><div>'),ge=h('<div class="h-full outline-none"tabindex=0>'),we=h("<span>"),be=h("<div class=files-tab-stats><span class=files-tab-stat><span class=files-tab-selected-path><span class=file-path-text>"),Se=h("<button type=button style=margin-inline-start:auto>"),me=h("<button type=button>"),q=h("<button type=button class=files-header-icon-button>"),$e=h("<span class=text-error>");const ye=p(()=>X(()=>import("./monaco-viewer-CiFg18Ak.js").then(e=>e.at),__vite__mapDeps([0,1,2,3,4])).then(e=>({default:e.MonacoFileViewer})));function _e(e){return e?/\.(md|markdown|mdown|mkdn)$/i.test(e):!1}const Te=e=>{const[E,L]=K(""),{isDark:Q}=J(),[N,M]=K(!1);let b;P(()=>{e.browserPath(),L("")});const H=$(()=>[...e.browserEntries()||[]].sort((i,d)=>{const l=i.type==="directory"?0:1,t=d.type==="directory"?0:1;return l!==t?l-t:String(i.name||"").localeCompare(String(d.name||""))})),W=$(()=>E().trim().toLowerCase()),k=$(()=>{const n=W(),i=H();return n?i.filter(d=>String(d.name||"").toLowerCase().includes(n)):i}),y=()=>e.browserLoading()&&e.browserEntries()===null,j=()=>W()?e.t("instanceShell.filesShell.search.empty"):e.t("instanceShell.filesShell.listEmpty"),_=$(()=>_e(e.browserSelectedPath())),S=$(()=>_()&&N());P(()=>{_()||M(!1)});const D=()=>{const n=e.browserSelectedContent();n!=null&&e.onSave(n)},B=async(n,i)=>{i==null||i.stopPropagation();const d=await ie(n);se({message:d?e.t("instanceShell.filesShell.toast.copyPathSuccess"):e.t("instanceShell.filesShell.toast.copyPathError"),variant:d?"success":"error"})};P(()=>{S()&&requestAnimationFrame(()=>b==null?void 0:b.focus())});const T=()=>[(()=>{var n=ce(),i=n.firstChild,d=i.firstChild,l=d.nextSibling;return s(d,o(re,{class:"w-4 h-4"})),l.$$input=t=>L(t.currentTarget.value),w(t=>{var a=e.t("instanceShell.filesShell.search.placeholder"),r=e.t("instanceShell.filesShell.search.ariaLabel");return a!==t.e&&u(l,"placeholder",t.e=a),r!==t.t&&u(l,"aria-label",t.t=r),t},{e:void 0,t:void 0}),w(()=>l.value=E()),n})(),(()=>{var n=de(),i=n.firstChild,d=i.nextSibling;return s(i,()=>e.t("instanceShell.filesShell.fileListTitle")),s(d,()=>k().length),n})(),o(g,{get when(){return e.parentPath()},children:n=>(()=>{var i=he(),d=i.firstChild,l=d.firstChild;return i.$$click=()=>e.onLoadEntries(n()),w(()=>u(l,"title",n())),i})()}),o(g,{get when(){return y()},get children(){var n=O();return s(n,()=>e.t("instanceInfo.loading")),n}}),o(g,{get when(){return m(()=>!e.browserError()&&!y())()&&k().length>0},get fallback(){return m(()=>!y())()?m(()=>!!e.browserError())()?(()=>{var n=ue();return s(n,()=>e.browserError()),n})():(()=>{var n=O();return s(n,j),n})():void 0},get children(){return o(ee,{get each(){return k()},children:n=>(()=>{var i=ve(),d=i.firstChild,l=d.firstChild,t=l.firstChild,a=l.nextSibling,r=a.firstChild,c=r.firstChild,f=r.nextSibling;return i.$$click=()=>{if(n.type==="directory"){e.onLoadEntries(n.path);return}e.onRequestOpenFile(n.path)},s(t,()=>n.name),s(c,()=>n.type),f.$$click=v=>void B(n.path,v),s(f,o(ae,{class:"w-3 h-3"})),w(v=>{var F=`file-list-item ${e.browserSelectedPath()===n.path?"file-list-item-active":""}`,z=n.path,R=n.path,A=e.t("instanceShell.filesShell.actions.copyPath"),I=e.t("instanceShell.filesShell.actions.copyPath");return F!==v.e&&C(i,v.e=F),z!==v.t&&u(i,"title",v.t=z),R!==v.a&&u(l,"title",v.a=R),A!==v.o&&u(f,"title",v.o=A),I!==v.i&&u(f,"aria-label",v.i=I),v},{e:void 0,t:void 0,a:void 0,o:void 0,i:void 0}),i})()})}})],G=n=>{!(n.ctrlKey||n.metaKey)||n.key.toLowerCase()!=="s"||e.browserSelectedSaving()||!e.browserSelectedDirty()||(n.preventDefault(),D())};return m(()=>{const n=()=>e.browserSelectedPath()||e.browserPath(),i=()=>y()?e.t("instanceInfo.loading"):e.t("instanceShell.filesShell.viewerEmpty"),d=()=>(()=>{var l=fe(),t=l.firstChild;return s(t,o(g,{get when(){return e.browserSelectedLoading()},get fallback(){return o(g,{get when(){return e.browserSelectedError()},get fallback(){return o(g,{get when(){return m(()=>!!(e.browserSelectedPath()&&e.browserSelectedContent()!==null))()?{path:e.browserSelectedPath(),content:e.browserSelectedContent()}:null},get fallback(){return(()=>{var a=x(),r=a.firstChild;return s(r,i),a})()},children:a=>o(g,{get when(){return S()},get fallback(){return o(Z,{get fallback(){return(()=>{var r=x(),c=r.firstChild;return s(c,()=>e.t("instanceInfo.loading")),r})()},get children(){return o(ye,{get scopeKey(){return e.scopeKey()},get path(){return a().path},get content(){return a().content},get wordWrap(){return e.wordWrapMode()},get onSave(){return e.onSave},get onContentChange(){return e.onContentChange}})}})},get children(){var r=ge();r.$$mousedown=()=>b==null?void 0:b.focus(),r.$$keydown=G;var c=b;return typeof c=="function"?U(c,r):b=r,s(r,o(ne,{get part(){return{type:"text",text:a().content}},get isDark(){return Q()},escapeRawHtml:!0})),r}})})},children:a=>(()=>{var r=x(),c=r.firstChild;return s(c,a),r})()})},get children(){var a=x(),r=a.firstChild;return s(r,()=>e.t("instanceInfo.loading")),a}})),w(()=>C(t,S()?"file-viewer-content":"file-viewer-content file-viewer-content--monaco")),l})();return o(te,{get header(){return[(()=>{var l=be(),t=l.firstChild,a=t.firstChild,r=a.firstChild;return s(r,n),s(l,o(g,{get when(){return e.browserLoading()},get children(){var c=we();return s(c,()=>e.t("instanceInfo.loading")),c}}),null),s(l,o(g,{get when(){return e.browserError()},children:c=>(()=>{var f=$e();return s(f,c),f})()}),null),w(()=>u(a,"title",n())),l})(),(()=>{var l=Se();return l.$$click=()=>_()&&M(t=>!t),s(l,(()=>{var t=m(()=>!!S());return()=>t()?e.t("instanceShell.filesShell.showSource"):e.t("instanceShell.filesShell.previewMarkdown")})()),w(t=>{var a=`file-viewer-toolbar-button${S()?" active":""}`,r=!_();return a!==t.e&&C(l,t.e=a),r!==t.t&&(l.disabled=t.t=r),t},{e:void 0,t:void 0}),l})(),(()=>{var l=me();return l.$$click=()=>e.onWordWrapModeChange(e.wordWrapMode()==="on"?"off":"on"),s(l,o(oe,{class:"h-4 w-4"})),w(t=>{var a=`file-viewer-toolbar-icon-button${e.wordWrapMode()==="on"?" active":""}`,r=e.wordWrapMode()==="on"?e.t("instanceShell.filesShell.disableWordWrap"):e.t("instanceShell.filesShell.enableWordWrap"),c=e.wordWrapMode()==="on"?e.t("instanceShell.filesShell.disableWordWrap"):e.t("instanceShell.filesShell.enableWordWrap"),f=S();return a!==t.e&&C(l,t.e=a),r!==t.t&&u(l,"title",t.t=r),c!==t.a&&u(l,"aria-label",t.a=c),f!==t.o&&(l.disabled=t.o=f),t},{e:void 0,t:void 0,a:void 0,o:void 0}),l})(),(()=>{var l=q();return l.$$click=D,s(l,o(g,{get when(){return e.browserSelectedSaving()},get fallback(){return o(le,{class:"h-4 w-4"})},get children(){return o(V,{class:"h-4 w-4 animate-spin"})}})),w(t=>{var a=e.t("instanceShell.rightPanel.actions.save")||"Save (Ctrl+S)",r=e.t("instanceShell.rightPanel.actions.save")||"Save",c=e.browserSelectedSaving()||!e.browserSelectedDirty();return a!==t.e&&u(l,"title",t.e=a),r!==t.t&&u(l,"aria-label",t.t=r),c!==t.a&&(l.disabled=t.a=c),t},{e:void 0,t:void 0,a:void 0}),l})(),(()=>{var l=q();return l.$$click=()=>e.onRefresh(),s(l,o(V,{get class(){return`h-4 w-4${e.browserLoading()?" animate-spin":""}`}})),w(t=>{var a=e.t("instanceShell.rightPanel.actions.refresh"),r=e.t("instanceShell.rightPanel.actions.refresh"),c=e.browserLoading();return a!==t.e&&u(l,"title",t.e=a),r!==t.t&&u(l,"aria-label",t.t=r),c!==t.a&&(l.disabled=t.a=c),t},{e:void 0,t:void 0,a:void 0}),l})()]},list:{panel:()=>o(T,{}),overlay:()=>o(T,{})},get viewer(){return d()},get listOpen(){return e.listOpen()},get onToggleList(){return e.onToggleList},get splitWidth(){return e.splitWidth()},get onResizeMouseDown(){return e.onResizeMouseDown},get onResizeTouchStart(){return e.onResizeTouchStart},get isPhoneLayout(){return e.isPhoneLayout()},get overlayAriaLabel(){return e.t("instanceShell.rightPanel.tabs.files")}})})};Y(["input","click","keydown","mousedown"]);export{Te as default};