@neuralnomads/codenomad-dev 0.18.0-dev-20260727-269cff64 → 0.18.0-dev-20260731-1d4f7786

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.
@@ -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
+ });
@@ -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 = "";
@@ -606,7 +607,7 @@ export class WorkspaceManager {
606
607
  });
607
608
  }
608
609
  async probeInstance(workspaceId, port, signal) {
609
- const url = `http://127.0.0.1:${port}/global/health`;
610
+ const url = `http://${LOOPBACK_HOST}:${port}/global/health`;
610
611
  try {
611
612
  const headers = {};
612
613
  const authHeader = this.opencodeAuth.get(workspaceId)?.authorization;
@@ -656,7 +657,7 @@ export class WorkspaceManager {
656
657
  const tryConnect = () => {
657
658
  if (settled)
658
659
  return;
659
- const socket = connect({ port, host: "127.0.0.1", signal }, () => {
660
+ const socket = connect({ port, host: LOOPBACK_HOST, signal }, () => {
660
661
  cleanup();
661
662
  socket.end();
662
663
  resolve();
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-20260731-1d4f7786",
4
4
  "description": "CodeNomad Server",
5
5
  "license": "MIT",
6
6
  "author": {