@neuralnomads/codenomad-dev 0.18.0-dev-20260721-d74a8a39 → 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.
Files changed (64) hide show
  1. package/dist/background-processes/manager.js +15 -30
  2. package/dist/background-processes/manager.test.js +123 -0
  3. package/dist/config/schema.js +2 -1
  4. package/dist/config/schema.test.js +13 -0
  5. package/dist/filesystem/__tests__/search-cache.test.js +19 -7
  6. package/dist/filesystem/__tests__/search.test.js +53 -0
  7. package/dist/filesystem/search-cache.js +5 -9
  8. package/dist/filesystem/search.js +22 -9
  9. package/dist/server/routes/auth-pages/login.html +3 -3
  10. package/dist/server/routes/auth.js +1 -1
  11. package/dist/server/routes/auth.test.js +21 -0
  12. package/dist/workspaces/instance-client.js +6 -5
  13. package/dist/workspaces/instance-client.test.js +149 -0
  14. package/dist/workspaces/instance-events.js +2 -2
  15. package/dist/workspaces/loopback.js +8 -0
  16. package/dist/workspaces/manager.js +3 -2
  17. package/dist/workspaces/process-identity.js +28 -10
  18. package/dist/workspaces/process-identity.test.js +29 -0
  19. package/dist/workspaces/runtime.js +1 -1
  20. package/dist/workspaces/runtime.test.js +8 -0
  21. package/package.json +1 -1
  22. package/public/assets/{FilesTab-DRWiHv0M.js → FilesTab-pl_3_Ppx.js} +2 -2
  23. package/public/assets/{GitChangesTab-Bby-dRmu.js → GitChangesTab-COJ_xDva.js} +2 -2
  24. package/public/assets/{SplitFilePanel-C1aKE1hq.js → SplitFilePanel-B5nrgBs3.js} +1 -1
  25. package/public/assets/StatusTab-rXgEwwYW.js +1 -0
  26. package/public/assets/{align-justify-Ce1uZY4E.js → align-justify-CKDczyVy.js} +1 -1
  27. package/public/assets/{bundle-full-CWkY9-v4.js → bundle-full-CuWt9dTK.js} +1 -1
  28. package/public/assets/{diff-viewer-ChdWzmb2.js → diff-viewer-drQ_Yje8.js} +1 -1
  29. package/public/assets/index-Bt9PdFAD.css +1 -0
  30. package/public/assets/index-BymyGk2C.js +1 -0
  31. package/public/assets/index-CEIEYgCg.js +1 -0
  32. package/public/assets/{index-DOprq6Gt.js → index-CozaqOFM.js} +1 -1
  33. package/public/assets/index-Cqo6ftJT.js +1 -0
  34. package/public/assets/index-Cs1fQOLl.js +1 -0
  35. package/public/assets/{index-DM1cpVNo.js → index-Cs2gBaWC.js} +1 -1
  36. package/public/assets/index-CwhvpTeE.js +1 -0
  37. package/public/assets/index-D0xuNxlo.js +2 -0
  38. package/public/assets/index-D1HW4nyh.js +1 -0
  39. package/public/assets/index-DBw0YE-2.js +1 -0
  40. package/public/assets/index-OYNGtp3z.js +1 -0
  41. package/public/assets/{loading-B8b-5OAQ.js → loading-Cz4dKVP8.js} +1 -1
  42. package/public/assets/main-BEYytlgD.js +68 -0
  43. package/public/assets/{markdown-DfOGkX-q.js → markdown-DUHZyBXo.js} +3 -3
  44. package/public/assets/{monaco-viewer-DM35aobI.js → monaco-viewer-CiFg18Ak.js} +11 -11
  45. package/public/assets/tool-call-C7FlzFBf.js +59 -0
  46. package/public/assets/unified-picker-CpU6qugd.js +1 -0
  47. package/public/assets/{wrap-text-D7C2RpdK.js → wrap-text-CS5K8Uhi.js} +1 -1
  48. package/public/index.html +4 -4
  49. package/public/loading.html +4 -4
  50. package/public/sw.js +1 -1
  51. package/public/assets/StatusTab-DGAe2jhy.js +0 -1
  52. package/public/assets/index-60Fu5kUB.js +0 -1
  53. package/public/assets/index-BhJHlWzk.js +0 -1
  54. package/public/assets/index-Cce2Hk2L.js +0 -1
  55. package/public/assets/index-CpB67B7z.js +0 -1
  56. package/public/assets/index-D3fvGcC7.js +0 -1
  57. package/public/assets/index-D8WMdSS9.js +0 -2
  58. package/public/assets/index-D_qqDz2d.js +0 -1
  59. package/public/assets/index-E0A5Kg-d.js +0 -1
  60. package/public/assets/index-L7fX3W8k.css +0 -1
  61. package/public/assets/index-j4B2FQw9.js +0 -1
  62. package/public/assets/main-B9uXiXu7.js +0 -68
  63. package/public/assets/tool-call-BpefPuGQ.js +0 -59
  64. package/public/assets/unified-picker-CXhvV1sd.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
+ });
@@ -19,8 +19,9 @@ const PreferencesSchema = z
19
19
  modelThinkingSelections: z.record(z.string(), z.string()).default({}),
20
20
  diffViewMode: z.enum(["split", "unified"]).default("split"),
21
21
  toolOutputExpansion: z.enum(["expanded", "collapsed"]).default("expanded"),
22
- diagnosticsExpansion: z.enum(["expanded", "collapsed"]).default("expanded"),
22
+ diagnosticsExpansion: z.enum(["hidden", "expanded", "collapsed"]).default("expanded"),
23
23
  showUsageMetrics: z.boolean().default(true),
24
+ usageMetricsExpansion: z.enum(["expanded", "collapsed"]).default("collapsed"),
24
25
  autoCleanupBlankSessions: z.boolean().default(true),
25
26
  listeningMode: z.enum(["local", "all"]).default("local"),
26
27
  logLevel: z.enum(["DEBUG", "INFO", "WARN", "ERROR"]).default("DEBUG"),
@@ -0,0 +1,13 @@
1
+ import assert from "node:assert/strict";
2
+ import { describe, it } from "node:test";
3
+ import { PreferencesSchema } from "./schema";
4
+ describe("chat visibility preferences", () => {
5
+ it("accepts hidden diagnostics and collapsed usage metrics", () => {
6
+ const preferences = PreferencesSchema.parse({
7
+ diagnosticsExpansion: "hidden",
8
+ usageMetricsExpansion: "collapsed",
9
+ });
10
+ assert.equal(preferences.diagnosticsExpansion, "hidden");
11
+ assert.equal(preferences.usageMetricsExpansion, "collapsed");
12
+ });
13
+ });
@@ -8,25 +8,37 @@ describe("workspace search cache", () => {
8
8
  it("expires cached candidates after the TTL", () => {
9
9
  const workspacePath = "/tmp/workspace";
10
10
  const startTime = 1_000;
11
- refreshWorkspaceCandidates(workspacePath, () => [createEntry("file-a")], startTime);
12
- const beforeExpiry = getWorkspaceCandidates(workspacePath, startTime + WORKSPACE_CANDIDATE_CACHE_TTL_MS - 1);
11
+ refreshWorkspaceCandidates(workspacePath, "query-a", () => [createEntry("file-a")], startTime);
12
+ const beforeExpiry = getWorkspaceCandidates(workspacePath, "query-a", startTime + WORKSPACE_CANDIDATE_CACHE_TTL_MS - 1);
13
13
  assert.ok(beforeExpiry);
14
14
  assert.equal(beforeExpiry.length, 1);
15
15
  assert.equal(beforeExpiry[0].name, "file-a");
16
- const afterExpiry = getWorkspaceCandidates(workspacePath, startTime + WORKSPACE_CANDIDATE_CACHE_TTL_MS + 1);
16
+ const afterExpiry = getWorkspaceCandidates(workspacePath, "query-a", startTime + WORKSPACE_CANDIDATE_CACHE_TTL_MS + 1);
17
17
  assert.equal(afterExpiry, undefined);
18
18
  });
19
19
  it("replaces cached entries when manually refreshed", () => {
20
20
  const workspacePath = "/tmp/workspace";
21
- refreshWorkspaceCandidates(workspacePath, () => [createEntry("file-a")], 5_000);
22
- const initial = getWorkspaceCandidates(workspacePath, 5_001);
21
+ refreshWorkspaceCandidates(workspacePath, "query-a", () => [createEntry("file-a")], 5_000);
22
+ const initial = getWorkspaceCandidates(workspacePath, "query-a", 5_001);
23
23
  assert.ok(initial);
24
24
  assert.equal(initial[0].name, "file-a");
25
- refreshWorkspaceCandidates(workspacePath, () => [createEntry("file-b")], 6_000);
26
- const refreshed = getWorkspaceCandidates(workspacePath, 6_001);
25
+ refreshWorkspaceCandidates(workspacePath, "query-a", () => [createEntry("file-b")], 6_000);
26
+ const refreshed = getWorkspaceCandidates(workspacePath, "query-a", 6_001);
27
27
  assert.ok(refreshed);
28
28
  assert.equal(refreshed[0].name, "file-b");
29
29
  });
30
+ it("does not reuse candidates across query scopes", () => {
31
+ const workspacePath = "/tmp/workspace";
32
+ refreshWorkspaceCandidates(workspacePath, "query-a", () => [createEntry("file-a")], 5_000);
33
+ assert.equal(getWorkspaceCandidates(workspacePath, "query-a", 5_001)?.[0].name, "file-a");
34
+ assert.equal(getWorkspaceCandidates(workspacePath, "query-b", 5_001), undefined);
35
+ refreshWorkspaceCandidates(workspacePath, "query-b", () => [createEntry("file-b")], 5_000);
36
+ assert.equal(getWorkspaceCandidates(workspacePath, "query-a", 5_001), undefined);
37
+ assert.equal(getWorkspaceCandidates(workspacePath, "query-b", 5_001)?.[0].name, "file-b");
38
+ clearWorkspaceSearchCache(workspacePath);
39
+ assert.equal(getWorkspaceCandidates(workspacePath, "query-a", 5_001), undefined);
40
+ assert.equal(getWorkspaceCandidates(workspacePath, "query-b", 5_001), undefined);
41
+ });
30
42
  });
31
43
  function createEntry(name) {
32
44
  return {
@@ -0,0 +1,53 @@
1
+ import assert from "node:assert/strict";
2
+ import fs from "node:fs";
3
+ import os from "node:os";
4
+ import path from "node:path";
5
+ import { after, test } from "node:test";
6
+ import { searchWorkspaceFiles } from "../search";
7
+ import { getWorkspaceCandidates } from "../search-cache";
8
+ const workspace = fs.mkdtempSync(path.join(os.tmpdir(), "codenomad-search-"));
9
+ after(() => fs.rmSync(workspace, { recursive: true, force: true }));
10
+ test("finds a matching file after more than 8000 non-matching entries", () => {
11
+ fs.mkdirSync(path.join(workspace, "a"));
12
+ fs.mkdirSync(path.join(workspace, "b"));
13
+ const [targetDirName, fillerDirName] = fs.readdirSync(workspace);
14
+ const fillerDir = path.join(workspace, fillerDirName);
15
+ const targetDir = path.join(workspace, targetDirName);
16
+ for (let index = 0; index < 8_001; index += 1) {
17
+ fs.writeFileSync(path.join(fillerDir, `filler-${index}.txt`), "");
18
+ }
19
+ fs.writeFileSync(path.join(targetDir, "unique-search-target.txt"), "");
20
+ const results = searchWorkspaceFiles(workspace, "unique-search-target", {
21
+ type: "file",
22
+ refresh: true,
23
+ });
24
+ assert.equal(results.some((entry) => entry.name === "unique-search-target.txt"), true);
25
+ searchWorkspaceFiles(workspace, "filler", { type: "file", refresh: true });
26
+ assert.equal(getWorkspaceCandidates(workspace, "file\0filler")?.length, 8_000);
27
+ });
28
+ test("does not revisit directory links", () => {
29
+ const cyclicWorkspace = fs.mkdtempSync(path.join(os.tmpdir(), "codenomad-search-cycle-"));
30
+ try {
31
+ fs.symlinkSync(cyclicWorkspace, path.join(cyclicWorkspace, "cycle"), process.platform === "win32" ? "junction" : "dir");
32
+ assert.deepEqual(searchWorkspaceFiles(cyclicWorkspace, "not-present", { refresh: true }), []);
33
+ }
34
+ finally {
35
+ fs.rmSync(cyclicWorkspace, { recursive: true, force: true });
36
+ }
37
+ });
38
+ test("indexes both real and linked directory paths", () => {
39
+ const linkedWorkspace = fs.mkdtempSync(path.join(os.tmpdir(), "codenomad-search-link-"));
40
+ try {
41
+ const realDirectory = path.join(linkedWorkspace, "b");
42
+ fs.mkdirSync(realDirectory);
43
+ fs.writeFileSync(path.join(realDirectory, "needle.txt"), "");
44
+ fs.symlinkSync(realDirectory, path.join(linkedWorkspace, "a"), process.platform === "win32" ? "junction" : "dir");
45
+ const linkedResults = searchWorkspaceFiles(linkedWorkspace, "a/needle", { type: "file", refresh: true });
46
+ const realResults = searchWorkspaceFiles(linkedWorkspace, "b/needle", { type: "file", refresh: true });
47
+ assert.equal(linkedResults.some((entry) => entry.path === "a/needle.txt"), true);
48
+ assert.equal(realResults.some((entry) => entry.path === "b/needle.txt"), true);
49
+ }
50
+ finally {
51
+ fs.rmSync(linkedWorkspace, { recursive: true, force: true });
52
+ }
53
+ });
@@ -1,10 +1,10 @@
1
1
  import path from "path";
2
2
  export const WORKSPACE_CANDIDATE_CACHE_TTL_MS = 30_000;
3
3
  const workspaceCandidateCache = new Map();
4
- export function getWorkspaceCandidates(rootDir, now = Date.now()) {
4
+ export function getWorkspaceCandidates(rootDir, scope, now = Date.now()) {
5
5
  const key = normalizeKey(rootDir);
6
6
  const cached = workspaceCandidateCache.get(key);
7
- if (!cached) {
7
+ if (!cached || cached.scope !== scope) {
8
8
  return undefined;
9
9
  }
10
10
  if (cached.expiresAt <= now) {
@@ -13,15 +13,12 @@ export function getWorkspaceCandidates(rootDir, now = Date.now()) {
13
13
  }
14
14
  return cloneEntries(cached.candidates);
15
15
  }
16
- export function refreshWorkspaceCandidates(rootDir, builder, now = Date.now()) {
16
+ export function refreshWorkspaceCandidates(rootDir, scope, builder, now = Date.now()) {
17
17
  const key = normalizeKey(rootDir);
18
18
  const freshCandidates = builder();
19
- if (!freshCandidates || freshCandidates.length === 0) {
20
- workspaceCandidateCache.delete(key);
21
- return [];
22
- }
23
19
  const storedCandidates = cloneEntries(freshCandidates);
24
20
  workspaceCandidateCache.set(key, {
21
+ scope,
25
22
  expiresAt: now + WORKSPACE_CANDIDATE_CACHE_TTL_MS,
26
23
  candidates: storedCandidates,
27
24
  });
@@ -32,8 +29,7 @@ export function clearWorkspaceSearchCache(rootDir) {
32
29
  workspaceCandidateCache.clear();
33
30
  return;
34
31
  }
35
- const key = normalizeKey(rootDir);
36
- workspaceCandidateCache.delete(key);
32
+ workspaceCandidateCache.delete(normalizeKey(rootDir));
37
33
  }
38
34
  function cloneEntries(entries) {
39
35
  return entries.map((entry) => ({ ...entry }));
@@ -15,13 +15,14 @@ export function searchWorkspaceFiles(rootDir, query, options = {}) {
15
15
  const limit = normalizeLimit(options.limit);
16
16
  const typeFilter = options.type ?? "all";
17
17
  const refreshRequested = options.refresh === true;
18
+ const cacheScope = `${typeFilter}\0${trimmedQuery.toLowerCase()}`;
18
19
  let entries;
19
20
  try {
20
21
  if (!refreshRequested) {
21
- entries = getWorkspaceCandidates(normalizedRoot);
22
+ entries = getWorkspaceCandidates(normalizedRoot, cacheScope);
22
23
  }
23
24
  if (!entries) {
24
- entries = refreshWorkspaceCandidates(normalizedRoot, () => collectCandidates(normalizedRoot));
25
+ entries = refreshWorkspaceCandidates(normalizedRoot, cacheScope, () => collectCandidates(normalizedRoot, trimmedQuery, typeFilter));
25
26
  }
26
27
  }
27
28
  catch (error) {
@@ -29,7 +30,6 @@ export function searchWorkspaceFiles(rootDir, query, options = {}) {
29
30
  throw error;
30
31
  }
31
32
  if (!entries || entries.length === 0) {
32
- clearWorkspaceSearchCache(normalizedRoot);
33
33
  return [];
34
34
  }
35
35
  const candidates = buildCandidateEntries(entries, typeFilter);
@@ -45,14 +45,23 @@ export function searchWorkspaceFiles(rootDir, query, options = {}) {
45
45
  }
46
46
  return matches.map((match) => match.obj.entry);
47
47
  }
48
- function collectCandidates(rootDir) {
49
- const queue = [""];
48
+ function collectCandidates(rootDir, query, filter) {
49
+ const queue = [
50
+ { relativeDir: "", ancestors: new Set() },
51
+ ];
50
52
  const entries = [];
51
53
  while (queue.length > 0 && entries.length < MAX_CANDIDATES) {
52
- const relativeDir = queue.pop() || "";
54
+ const queuedDirectory = queue.pop();
55
+ const { relativeDir, ancestors } = queuedDirectory;
53
56
  const absoluteDir = relativeDir ? path.join(rootDir, relativeDir) : rootDir;
54
57
  let dirents;
58
+ let branchAncestors;
55
59
  try {
60
+ const realDir = normalizeDirectoryIdentity(fs.realpathSync.native(absoluteDir));
61
+ if (ancestors.has(realDir)) {
62
+ continue;
63
+ }
64
+ branchAncestors = new Set([...ancestors, realDir]);
56
65
  dirents = fs.readdirSync(absoluteDir, { withFileTypes: true });
57
66
  }
58
67
  catch {
@@ -75,9 +84,7 @@ function collectCandidates(rootDir) {
75
84
  }
76
85
  const isDirectory = stats.isDirectory();
77
86
  if (isDirectory && !IGNORED_DIRECTORIES.has(lowerName)) {
78
- if (entries.length < MAX_CANDIDATES) {
79
- queue.push(relativePath);
80
- }
87
+ queue.push({ relativeDir: relativePath, ancestors: branchAncestors });
81
88
  }
82
89
  const entryType = isDirectory ? "directory" : "file";
83
90
  const normalizedPath = normalizeRelativeEntryPath(relativePath);
@@ -89,6 +96,9 @@ function collectCandidates(rootDir) {
89
96
  size: entryType === "file" ? stats.size : undefined,
90
97
  modifiedAt: stats.mtime.toISOString(),
91
98
  };
99
+ if (!shouldInclude(entry.type, filter) || !fuzzysort.single(query, buildSearchKey(entry))) {
100
+ continue;
101
+ }
92
102
  entries.push(entry);
93
103
  if (entries.length >= MAX_CANDIDATES) {
94
104
  break;
@@ -133,3 +143,6 @@ function normalizeRelativeEntryPath(relativePath) {
133
143
  function buildSearchKey(entry) {
134
144
  return entry.path.toLowerCase();
135
145
  }
146
+ function normalizeDirectoryIdentity(directoryPath) {
147
+ return process.platform === "win32" ? directoryPath.toLowerCase() : directoryPath;
148
+ }
@@ -72,10 +72,10 @@
72
72
  <p>This CodeNomad server is protected. Enter your credentials to continue.</p>
73
73
 
74
74
  <label for="username">Username</label>
75
- <input id="username" autocomplete="username" placeholder="{{DEFAULT_USERNAME}}" value="" />
75
+ <input id="username" autocomplete="username" autocapitalize="none" autocorrect="off" spellcheck="false" placeholder="{{DEFAULT_USERNAME}}" value="{{DEFAULT_USERNAME}}" />
76
76
 
77
77
  <label for="password">Password</label>
78
- <input id="password" type="password" autocomplete="current-password" value="" />
78
+ <input id="password" type="password" autocomplete="current-password" autocapitalize="none" autocorrect="off" spellcheck="false" value="" />
79
79
 
80
80
  <button id="submit" type="button">Continue</button>
81
81
  <div id="error" class="error" style="display: none"></div>
@@ -95,7 +95,7 @@
95
95
 
96
96
  async function submit() {
97
97
  hideError()
98
- const username = $("username").value.trim()
98
+ const username = $("username").value
99
99
  const password = $("password").value
100
100
  if (!username || !password) {
101
101
  showError("Username and password are required.")
@@ -26,7 +26,7 @@ function getLoginHtml(defaultUsername) {
26
26
  cachedLoginTemplate = readTemplate(LOGIN_TEMPLATE_URL, null);
27
27
  }
28
28
  const escapedUsername = escapeHtml(defaultUsername);
29
- return cachedLoginTemplate.replace(/\{\{DEFAULT_USERNAME\}\}/g, escapedUsername);
29
+ return cachedLoginTemplate.replace(/\{\{DEFAULT_USERNAME\}\}/g, () => escapedUsername);
30
30
  }
31
31
  function getTokenHtml() {
32
32
  if (!cachedTokenTemplate) {
@@ -0,0 +1,21 @@
1
+ import assert from "node:assert/strict";
2
+ import { it } from "node:test";
3
+ import Fastify from "fastify";
4
+ import { registerAuthRoutes } from "./auth";
5
+ it("renders the escaped default username literally as the login field value", async () => {
6
+ const app = Fastify({ logger: false });
7
+ const username = " code$&\"nomad ";
8
+ const authManager = {
9
+ getSessionFromRequest: () => null,
10
+ getStatus: () => ({ username }),
11
+ };
12
+ registerAuthRoutes(app, { authManager });
13
+ const response = await app.inject({ method: "GET", url: "/login" });
14
+ assert.equal(response.statusCode, 200);
15
+ assert.ok(response.body.includes('value=" code$&amp;&quot;nomad "'));
16
+ assert.match(response.body, /const username = \$\("username"\)\.value\s*\n/);
17
+ assert.equal(response.body.match(/autocapitalize="none"/g)?.length, 2);
18
+ assert.equal(response.body.match(/autocorrect="off"/g)?.length, 2);
19
+ assert.equal(response.body.match(/spellcheck="false"/g)?.length, 2);
20
+ await app.close();
21
+ });
@@ -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
  }