@memorax/memorax-code 0.1.3 → 0.1.5

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.
package/README.md CHANGED
@@ -5,7 +5,7 @@ Harness, and OpenCode.
5
5
 
6
6
  ## Requirements
7
7
 
8
- - Node.js 24 or newer and npm.
8
+ - Node.js 20 or newer (Node.js 24 LTS recommended) and npm.
9
9
  - At least one of Codex, Claude Code, DeepSeek Harness, OpenCode Desktop, or
10
10
  the OpenCode CLI.
11
11
  - A MemoraX account, Base User ID, and API key for memory features.
@@ -96,7 +96,11 @@ $DSH_HOME/profiles/<profile-name>/
96
96
  under `$MEMORAX_CODE_HOME/adapters/dsh/runtime/generations/` and installed into
97
97
  Profiles through DSH's native plugin command. The globally installed npm
98
98
  package remains immutable; do not copy or edit the generated state or runtime
99
- directories by hand.
99
+ directories by hand. When at least one valid Profile exists but none provides
100
+ a loadable `@deepseek-ai/dsh-headless` bundle, MemoraX Code asks the same native
101
+ plugin command to initialize the standard `headless` Profile when that name is
102
+ available. Stop and uninstall remove the MemoraX Code adapter from this Profile
103
+ but preserve the Profile and its native data.
100
104
 
101
105
  MemoraX Code is tested with DSH `0.1.0-rc.6`. Other valid semantic versions are
102
106
  accepted but appear as untested in status output; compatibility is not
@@ -1,4 +1,5 @@
1
1
  import { spawnSync } from "node:child_process";
2
+ import { readFileSync } from "node:fs";
2
3
  import { join } from "node:path";
3
4
  const POSIX_PROCESS_PROBE_TIMEOUT_MS = 2_000;
4
5
  const WINDOWS_PROCESS_PROBE_TIMEOUT_MS = 10_000;
@@ -48,6 +49,26 @@ export function probeProcessCommandLine(pid, runtime = {}) {
48
49
  if (!Number.isSafeInteger(pid) || pid <= 0) {
49
50
  return { status: "inconclusive", reason: "invalid_pid", timeoutMs };
50
51
  }
52
+ if (platform === "linux") {
53
+ try {
54
+ const rawCommandLine = (runtime.readFileSync ?? readFileSync)(`/proc/${pid}/cmdline`);
55
+ const commandLine = rawCommandLine.toString("utf8").replaceAll("\0", " ").trim();
56
+ return commandLine
57
+ ? { status: "ok", commandLine }
58
+ : { status: "not_found" };
59
+ }
60
+ catch (error) {
61
+ const code = error instanceof Error
62
+ ? error.code
63
+ : undefined;
64
+ return {
65
+ status: "inconclusive",
66
+ reason: "read_error",
67
+ timeoutMs,
68
+ ...(code ? { code } : {}),
69
+ };
70
+ }
71
+ }
51
72
  const run = runtime.spawnSync ?? runProbeCommand;
52
73
  let command;
53
74
  let args;
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@memorax-code/backend",
3
- "version": "0.1.3",
3
+ "version": "0.1.5",
4
4
  "private": true,
5
5
  "type": "module",
6
6
  "description": "Local memory integration, lifecycle, trace, and diagnostics for MemoraX Code.",
@@ -17,7 +17,7 @@
17
17
  "test:memory-viewer": "npm run build && node --test --test-timeout=5000 --test-force-exit test/viewer/http/memory-viewer-routes.test.mjs"
18
18
  },
19
19
  "devDependencies": {
20
- "@types/node": "^24.13.2",
20
+ "@types/node": "^20.0.0",
21
21
  "typescript": "^5.9.0"
22
22
  },
23
23
  "dependencies": {
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "memorax-code-claude-adapter",
3
- "version": "0.1.3",
3
+ "version": "0.1.5",
4
4
  "description": "Memory skill and local hooks for MemoraX Code in Claude Code.",
5
5
  "author": {
6
6
  "name": "MemoraX Code Maintainers"
@@ -1,5 +1,5 @@
1
1
  {
2
2
  "version": 1,
3
- "shellVersion": "0.1.3",
3
+ "shellVersion": "0.1.5",
4
4
  "runtimeAbi": 1
5
5
  }
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@memorax-code/claude-adapter",
3
- "version": "0.1.3",
3
+ "version": "0.1.5",
4
4
  "private": true,
5
5
  "type": "module",
6
6
  "description": "Claude Code Hook adapter for MemoraX Code memory services.",
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "memorax-code-claude-adapter",
3
- "version": "0.1.3",
3
+ "version": "0.1.5",
4
4
  "description": "Memory skill and local hooks for MemoraX Code in Claude Code.",
5
5
  "author": {
6
6
  "name": "MemoraX Code Maintainers"
@@ -1,5 +1,5 @@
1
1
  {
2
2
  "version": 1,
3
- "shellVersion": "0.1.3",
3
+ "shellVersion": "0.1.5",
4
4
  "runtimeAbi": 1
5
5
  }
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@memorax-code/claude-adapter",
3
- "version": "0.1.3",
3
+ "version": "0.1.5",
4
4
  "private": true,
5
5
  "type": "module",
6
6
  "description": "Claude Code Hook adapter for MemoraX Code memory services.",
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "memorax-code-codex-adapter",
3
- "version": "0.1.3",
3
+ "version": "0.1.5",
4
4
  "description": "Codex adapter, memory skill, and local hooks for MemoraX Code.",
5
5
  "author": {
6
6
  "name": "MemoraX AI"
@@ -1,5 +1,5 @@
1
1
  {
2
2
  "version": 1,
3
- "shellVersion": "0.1.3",
3
+ "shellVersion": "0.1.5",
4
4
  "runtimeAbi": 1
5
5
  }
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@memorax-code/codex-adapter",
3
- "version": "0.1.3",
3
+ "version": "0.1.5",
4
4
  "private": true,
5
5
  "type": "module",
6
6
  "description": "Codex Hook and memory integration for MemoraX Code.",
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@memorax-code/dsh-memorax-code",
3
- "version": "0.1.3",
3
+ "version": "0.1.5",
4
4
  "private": true,
5
5
  "type": "module",
6
6
  "description": "DeepSeek Harness native memory adapter for MemoraX Code.",
@@ -50,6 +50,8 @@ const { resolveWindowsCliInvocation } = await import(
50
50
  const STATE_VERSION = 1;
51
51
  const RUNTIME = "dsh";
52
52
  const ADAPTER_PACKAGE_NAME = "@memorax-code/dsh-memorax-code";
53
+ const HEADLESS_PROFILE_NAME = "headless";
54
+ const HEADLESS_BUNDLE_NAME = "@deepseek-ai/dsh-headless";
53
55
  const LEGACY_ADAPTER_PACKAGE_NAMES = Object.freeze(["@memorax-code/dsh-adapter"]);
54
56
  const MANAGED_ADAPTER_PACKAGE_NAMES = Object.freeze([
55
57
  ADAPTER_PACKAGE_NAME,
@@ -142,7 +144,8 @@ export function collectDshAdapterStatus(options = {}) {
142
144
  );
143
145
  const managed = Boolean(state) && !stateProblem;
144
146
  const installed = profiles.length > 0
145
- && profiles.every((profile) => profile.managed && profile.exists && profile.installed);
147
+ && profiles.every((profile) => profile.managed && profile.exists && profile.installed)
148
+ && managedProfilesHaveInstalledHeadlessBundle(discoveredProfiles, managedNames);
146
149
  const base = {
147
150
  integration: "plugin",
148
151
  managed,
@@ -283,7 +286,8 @@ function readDshPluginStatusUnlocked(paths, options) {
283
286
  state,
284
287
  );
285
288
  const installed = state.profiles.length > 0
286
- && managedProfiles.every((profile) => profile.installed);
289
+ && managedProfiles.every((profile) => profile.installed)
290
+ && managedProfilesHaveInstalledHeadlessBundle(profiles, new Set(state.profiles));
287
291
  return {
288
292
  ok: true,
289
293
  action: "dsh-plugin-status",
@@ -358,6 +362,38 @@ function ensureDshPluginInstalledUnlocked(paths, options) {
358
362
  };
359
363
  }
360
364
 
365
+ let workerProfileName = profiles.find(profileHasInstalledHeadlessBundle)?.name;
366
+ let targetProfiles = profiles;
367
+ let initializeWorkerProfile = false;
368
+ if (!workerProfileName) {
369
+ const headlessPath = join(paths.profilesRoot, HEADLESS_PROFILE_NAME);
370
+ const headless = inspectProfile(HEADLESS_PROFILE_NAME, headlessPath);
371
+ if (headless.status === "manifest_unreadable") {
372
+ return {
373
+ ok: false,
374
+ action: "dsh-plugin-install",
375
+ runtime: RUNTIME,
376
+ reason: "profile_manifest_unreadable",
377
+ profiles: [HEADLESS_PROFILE_NAME],
378
+ };
379
+ }
380
+ if (headless.status === "valid") {
381
+ return {
382
+ ok: false,
383
+ action: "dsh-plugin-install",
384
+ runtime: RUNTIME,
385
+ reason: "headless_profile_not_capable",
386
+ profiles: [HEADLESS_PROFILE_NAME],
387
+ };
388
+ }
389
+ workerProfileName = HEADLESS_PROFILE_NAME;
390
+ initializeWorkerProfile = true;
391
+ targetProfiles = [
392
+ ...profiles,
393
+ { name: HEADLESS_PROFILE_NAME, path: headlessPath },
394
+ ].sort((left, right) => left.name.localeCompare(right.name));
395
+ }
396
+
361
397
  const memoraxCodeCommand = resolveMemoraxCodeCommand(options.memoraxCodeCommand);
362
398
  const metadata = {
363
399
  version: 1,
@@ -387,7 +423,7 @@ function ensureDshPluginInstalledUnlocked(paths, options) {
387
423
  // interrupted or partial native add remains repairable and removable.
388
424
  profiles: [...new Set([
389
425
  ...previouslyManaged,
390
- ...profiles.map((profile) => profile.name),
426
+ ...targetProfiles.map((profile) => profile.name),
391
427
  ])].sort(),
392
428
  updatedAt: now,
393
429
  };
@@ -396,9 +432,12 @@ function ensureDshPluginInstalledUnlocked(paths, options) {
396
432
  const installedProfiles = [];
397
433
  const failedProfiles = [];
398
434
  const mutatedProfiles = [];
399
- for (const profile of profiles) {
435
+ for (const profile of targetProfiles) {
400
436
  let current = inspectProfile(profile.name, profile.path);
401
- if (current.status !== "valid") {
437
+ if (current.status !== "valid"
438
+ && !(initializeWorkerProfile
439
+ && profile.name === workerProfileName
440
+ && current.status === "directory_missing")) {
402
441
  failedProfiles.push(current.status === "directory_missing"
403
442
  ? { name: profile.name, reason: "profile_disappeared" }
404
443
  : profileManifestFailure(profile.name));
@@ -420,12 +459,16 @@ function ensureDshPluginInstalledUnlocked(paths, options) {
420
459
  if (result.status !== 0
421
460
  || result.error
422
461
  || current.status !== "valid"
423
- || !profileHasInstalledAdapter(current.profile, runtimeBundleRoot, pendingState)) {
462
+ || !profileHasInstalledAdapter(current.profile, runtimeBundleRoot, pendingState)
463
+ || (profile.name === workerProfileName
464
+ && !profileHasInstalledHeadlessBundle(current.profile))) {
424
465
  failedProfiles.push(profileMutationFailure(
425
466
  profile.name,
426
467
  result,
427
468
  current,
428
- "dsh_bundle_not_activated",
469
+ profile.name === workerProfileName
470
+ ? "headless_profile_not_capable"
471
+ : "dsh_bundle_not_activated",
429
472
  ));
430
473
  continue;
431
474
  }
@@ -450,7 +493,9 @@ function ensureDshPluginInstalledUnlocked(paths, options) {
450
493
  }
451
494
 
452
495
  if (current.status === "valid"
453
- && profileHasInstalledAdapter(current.profile, runtimeBundleRoot, pendingState)) {
496
+ && profileHasInstalledAdapter(current.profile, runtimeBundleRoot, pendingState)
497
+ && (profile.name !== workerProfileName
498
+ || profileHasInstalledHeadlessBundle(current.profile))) {
454
499
  installedProfiles.push(profile.name);
455
500
  } else {
456
501
  failedProfiles.push(profileMutationFailure(
@@ -474,6 +519,11 @@ function ensureDshPluginInstalledUnlocked(paths, options) {
474
519
  failedProfiles.push({ name: profile.name, reason: "dsh_bundle_not_activated" });
475
520
  }
476
521
  }
522
+ if (!finalProfiles.some((profile) => (
523
+ profile.status === "valid" && profileHasInstalledHeadlessBundle(profile.profile)
524
+ )) && !failedProfiles.some((failure) => failure.name === workerProfileName)) {
525
+ failedProfiles.push({ name: workerProfileName, reason: "headless_profile_not_capable" });
526
+ }
477
527
  const managedProfiles = finalProfiles
478
528
  .filter((profile) => profile.status !== "directory_missing")
479
529
  .map((profile) => profile.name);
@@ -644,7 +694,8 @@ function activateDshPluginInstallationUnlocked(paths, options) {
644
694
  if (state.profiles.length === 0
645
695
  || state.profiles.some((name) => (
646
696
  !profileHasInstalledAdapter(profileByName.get(name), state.runtimeBundleRoot, state)
647
- ))) {
697
+ ))
698
+ || !managedProfilesHaveInstalledHeadlessBundle(profiles, new Set(state.profiles))) {
648
699
  return {
649
700
  ok: false,
650
701
  action: "dsh-plugin-activate",
@@ -1162,6 +1213,24 @@ function profileHasAdapter(profile, packageName = ADAPTER_PACKAGE_NAME) {
1162
1213
  && profile.bundles.includes(packageName));
1163
1214
  }
1164
1215
 
1216
+ function managedProfilesHaveInstalledHeadlessBundle(profiles, managedNames) {
1217
+ return profiles.some((profile) => (
1218
+ managedNames.has(profile.name) && profileHasInstalledHeadlessBundle(profile)
1219
+ ));
1220
+ }
1221
+
1222
+ function profileHasInstalledHeadlessBundle(profile) {
1223
+ if (!profile?.bundles.includes(HEADLESS_BUNDLE_NAME)) return false;
1224
+ try {
1225
+ // DSH exposes built-in bundles through the Profile's shared resolution tree.
1226
+ const requireFromProfile = createRequire(join(profile.path, "package.json"));
1227
+ readFileSync(requireFromProfile.resolve(HEADLESS_BUNDLE_NAME), "utf8");
1228
+ return true;
1229
+ } catch {
1230
+ return false;
1231
+ }
1232
+ }
1233
+
1165
1234
  function profileHasInstalledAdapter(
1166
1235
  profile,
1167
1236
  runtimeBundleRoot,
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@memorax-code/opencode-adapter",
3
- "version": "0.1.3",
3
+ "version": "0.1.5",
4
4
  "private": true,
5
5
  "type": "module",
6
6
  "description": "OpenCode plugin adapter for MemoraX Code memory services.",
@@ -254,6 +254,8 @@ export function createMemoraxOpenCodePlugin(options = {}) {
254
254
  output.env.MEMORAX_CODE_MEMORY_CLI_TRACE_CLIENT = "opencode";
255
255
  if (openCodeServerUrl) {
256
256
  output.env.MEMORAX_CODE_OPENCODE_SERVER_URL = openCodeServerUrl;
257
+ output.env.MEMORAX_CODE_OPENCODE_COMMAND = stringValue(options.openCodeCommand)
258
+ ?? process.execPath;
257
259
  }
258
260
  const sessionId = stringValue(input?.sessionID);
259
261
  delete output.env.MEMORAX_CODE_MEMORY_CLI_TRACE_SESSION_ID;
@@ -441,6 +443,7 @@ function openCodeRepoMemoryEnv(options, serverUrl, sessionId) {
441
443
  ...process.env,
442
444
  MEMORAX_CODE_HOME: memoraxCodeHome,
443
445
  MEMORAX_CODE_MEMORY_CLI_SESSION_ID: sessionId,
446
+ MEMORAX_CODE_OPENCODE_COMMAND: stringValue(options.openCodeCommand) ?? process.execPath,
444
447
  MEMORAX_CODE_OPENCODE_SERVER_URL: serverUrl,
445
448
  };
446
449
  }
@@ -1,39 +1,103 @@
1
1
  #!/usr/bin/env node
2
+ import { spawn } from "node:child_process";
3
+ import { randomBytes } from "node:crypto";
2
4
  import { resolve } from "node:path";
3
5
  import { fileURLToPath } from "node:url";
4
6
 
5
7
  export const OPENCODE_REPO_MEMORY_AGENT = "memorax-code-repo-memory";
6
8
 
9
+ const OWNED_SERVER_USERNAME = "memorax-code";
10
+ const DEFAULT_SERVER_START_TIMEOUT_MS = 20_000;
11
+ const DEFAULT_SERVER_STOP_TIMEOUT_MS = 5_000;
12
+ const MAX_SERVER_START_OUTPUT = 8_192;
13
+
14
+ class OpenCodeTransportError extends Error {
15
+ constructor(operation, cause) {
16
+ super(`OpenCode ${operation} request failed: ${errorMessage(cause)}`);
17
+ this.name = "OpenCodeTransportError";
18
+ this.operation = operation;
19
+ this.cause = cause;
20
+ }
21
+ }
22
+
7
23
  export async function runOpenCodeRepoMemory(input, options = {}) {
8
24
  const env = options.env ?? process.env;
9
25
  const managedServerUrl = stringValue(env.MEMORAX_CODE_OPENCODE_SERVER_URL);
10
26
  const serverUrl = stringValue(input?.serverUrl) ?? managedServerUrl;
11
- if (!serverUrl) {
12
- throw new Error(
13
- "OpenCode repo memory runner requires --server-url or MEMORAX_CODE_OPENCODE_SERVER_URL",
14
- );
15
- }
16
27
  const repo = stringValue(input?.repo);
17
28
  if (!repo) throw new Error("OpenCode repo memory runner requires --repo");
18
29
  const prompt = stringValue(input?.prompt);
19
30
  if (!prompt) throw new Error("OpenCode repo memory runner requires --prompt");
20
31
 
21
- const baseUrl = normalizedServerUrl(serverUrl);
22
- const authorization = serverAuthorization(env, baseUrl, managedServerUrl);
23
32
  const directory = resolve(repo);
24
33
  const parentID = stringValue(input?.parentID)
25
34
  ?? stringValue(env.MEMORAX_CODE_MEMORY_CLI_SESSION_ID);
26
35
  const fetchImpl = options.fetchImpl ?? fetch;
36
+ let inheritedError;
37
+ if (serverUrl) {
38
+ const baseUrl = normalizedServerUrl(serverUrl);
39
+ try {
40
+ return await runOpenCodeRepoMemorySession({
41
+ authorization: serverAuthorization(env, baseUrl, managedServerUrl),
42
+ baseUrl,
43
+ directory,
44
+ fetchImpl,
45
+ parentID,
46
+ prompt,
47
+ });
48
+ } catch (error) {
49
+ if (!isSessionCreationTransportError(error)) throw error;
50
+ inheritedError = error;
51
+ }
52
+ } else {
53
+ inheritedError = new Error(
54
+ "OpenCode repo memory runner requires --server-url or MEMORAX_CODE_OPENCODE_SERVER_URL",
55
+ );
56
+ }
57
+
58
+ const openCodeCommand = stringValue(env.MEMORAX_CODE_OPENCODE_COMMAND);
59
+ if (!openCodeCommand) throw inheritedError;
60
+
61
+ let ownedServer;
62
+ try {
63
+ ownedServer = await startOwnedOpenCodeServer({
64
+ command: openCodeCommand,
65
+ directory,
66
+ env,
67
+ options,
68
+ });
69
+ } catch (error) {
70
+ throw new Error(
71
+ `${errorMessage(inheritedError)}; managed OpenCode server fallback failed: ${errorMessage(error)}`,
72
+ );
73
+ }
74
+
75
+ try {
76
+ return await runOpenCodeRepoMemorySession({
77
+ authorization: ownedServer.authorization,
78
+ baseUrl: ownedServer.baseUrl,
79
+ directory,
80
+ fetchImpl,
81
+ parentID,
82
+ prompt,
83
+ signal: ownedServer.signal,
84
+ });
85
+ } finally {
86
+ await ownedServer.close();
87
+ }
88
+ }
89
+
90
+ async function runOpenCodeRepoMemorySession(input) {
27
91
  let sessionID;
28
92
  try {
29
- const session = await requestJson(fetchImpl, endpoint(baseUrl, "/session", directory), {
93
+ const session = await requestJson(input.fetchImpl, endpoint(input.baseUrl, "/session", input.directory), {
30
94
  method: "POST",
31
95
  headers: {
32
96
  "content-type": "application/json",
33
- ...(authorization ? { authorization } : {}),
97
+ ...(input.authorization ? { authorization: input.authorization } : {}),
34
98
  },
35
99
  body: JSON.stringify({
36
- ...(parentID ? { parentID } : {}),
100
+ ...(input.parentID ? { parentID: input.parentID } : {}),
37
101
  title: "MemoraX Code Repo Memory",
38
102
  permission: [
39
103
  { permission: "edit", pattern: "*", action: "allow" },
@@ -43,23 +107,25 @@ export async function runOpenCodeRepoMemory(input, options = {}) {
43
107
  { permission: "external_directory", pattern: "*", action: "allow" },
44
108
  ],
45
109
  }),
110
+ ...(input.signal ? { signal: input.signal } : {}),
46
111
  }, "session creation");
47
112
  sessionID = stringValue(session?.id);
48
113
  if (!sessionID) throw new Error("OpenCode session creation returned no session id");
49
114
 
50
115
  const message = await requestJson(
51
- fetchImpl,
52
- endpoint(baseUrl, `/session/${encodeURIComponent(sessionID)}/message`, directory),
116
+ input.fetchImpl,
117
+ endpoint(input.baseUrl, `/session/${encodeURIComponent(sessionID)}/message`, input.directory),
53
118
  {
54
119
  method: "POST",
55
120
  headers: {
56
121
  "content-type": "application/json",
57
- ...(authorization ? { authorization } : {}),
122
+ ...(input.authorization ? { authorization: input.authorization } : {}),
58
123
  },
59
124
  body: JSON.stringify({
60
125
  agent: OPENCODE_REPO_MEMORY_AGENT,
61
- parts: [{ type: "text", text: prompt }],
126
+ parts: [{ type: "text", text: input.prompt }],
62
127
  }),
128
+ ...(input.signal ? { signal: input.signal } : {}),
63
129
  },
64
130
  "blocking prompt",
65
131
  );
@@ -69,14 +135,170 @@ export async function runOpenCodeRepoMemory(input, options = {}) {
69
135
  } finally {
70
136
  if (sessionID) {
71
137
  await bestEffortDelete(
72
- fetchImpl,
73
- endpoint(baseUrl, `/session/${encodeURIComponent(sessionID)}`, directory),
74
- authorization,
138
+ input.fetchImpl,
139
+ endpoint(input.baseUrl, `/session/${encodeURIComponent(sessionID)}`, input.directory),
140
+ input.authorization,
75
141
  );
76
142
  }
77
143
  }
78
144
  }
79
145
 
146
+ async function startOwnedOpenCodeServer(input) {
147
+ const username = OWNED_SERVER_USERNAME;
148
+ const password = randomBytes(24).toString("base64url");
149
+ const spawnImpl = input.options.spawnImpl ?? spawn;
150
+ const child = spawnImpl(input.command, [
151
+ "serve",
152
+ "--hostname=127.0.0.1",
153
+ "--port=0",
154
+ ], {
155
+ cwd: input.directory,
156
+ env: {
157
+ ...input.env,
158
+ OPENCODE_SERVER_USERNAME: username,
159
+ OPENCODE_SERVER_PASSWORD: password,
160
+ },
161
+ stdio: ["ignore", "pipe", "pipe"],
162
+ windowsHide: true,
163
+ });
164
+ const lifecycle = ownedServerLifecycle(child);
165
+ let closePromise;
166
+ const close = () => {
167
+ closePromise ??= stopOwnedServer(
168
+ child,
169
+ positiveInteger(input.options.serverStopTimeoutMs, DEFAULT_SERVER_STOP_TIMEOUT_MS),
170
+ ).finally(lifecycle.dispose);
171
+ return closePromise;
172
+ };
173
+ try {
174
+ const baseUrl = await waitForOwnedServer(
175
+ child,
176
+ positiveInteger(input.options.serverStartTimeoutMs, DEFAULT_SERVER_START_TIMEOUT_MS),
177
+ );
178
+ return {
179
+ authorization: basicAuthorization(username, password),
180
+ baseUrl,
181
+ close,
182
+ signal: lifecycle.signal,
183
+ };
184
+ } catch (error) {
185
+ await close();
186
+ throw error;
187
+ }
188
+ }
189
+
190
+ function waitForOwnedServer(child, timeoutMs) {
191
+ return new Promise((resolveReady, rejectReady) => {
192
+ let output = "";
193
+ let settled = false;
194
+ let timer;
195
+ const finish = (error, value) => {
196
+ if (settled) return;
197
+ settled = true;
198
+ clearTimeout(timer);
199
+ child.off("error", onError);
200
+ child.off("exit", onExit);
201
+ child.stdout?.off("data", onOutput);
202
+ child.stderr?.off("data", onOutput);
203
+ child.stdout?.resume();
204
+ child.stderr?.resume();
205
+ if (error) rejectReady(error);
206
+ else resolveReady(value);
207
+ };
208
+ const onOutput = (chunk) => {
209
+ output = `${output}${String(chunk)}`.slice(-MAX_SERVER_START_OUTPUT);
210
+ const match = output.match(/opencode server listening on (https?:\/\/[^\s\u001b]+)/i);
211
+ if (!match) return;
212
+ let url;
213
+ try {
214
+ url = normalizedOwnedServerUrl(match[1]);
215
+ } catch (error) {
216
+ finish(error);
217
+ return;
218
+ }
219
+ finish(undefined, url);
220
+ };
221
+ const onError = (error) => finish(error);
222
+ const onExit = (code, signal) => finish(new Error(
223
+ `OpenCode managed server exited before readiness${code === null ? "" : ` with code ${code}`}`
224
+ + `${signal ? ` after ${signal}` : ""}${output.trim() ? `: ${output.trim().slice(-500)}` : ""}`,
225
+ ));
226
+ timer = setTimeout(() => finish(new Error(
227
+ `timed out waiting for OpenCode managed server readiness after ${timeoutMs} ms`,
228
+ )), timeoutMs);
229
+ child.once("error", onError);
230
+ child.once("exit", onExit);
231
+ child.stdout?.on("data", onOutput);
232
+ child.stderr?.on("data", onOutput);
233
+ });
234
+ }
235
+
236
+ function normalizedOwnedServerUrl(value) {
237
+ const url = normalizedServerUrl(value);
238
+ if (url.protocol !== "http:" || url.hostname !== "127.0.0.1" || !url.port) {
239
+ throw new Error("OpenCode managed server did not bind to the requested loopback address");
240
+ }
241
+ return url;
242
+ }
243
+
244
+ function ownedServerLifecycle(child) {
245
+ const controller = new AbortController();
246
+ const handlers = new Map();
247
+ const onExit = (code, signal) => {
248
+ if (controller.signal.aborted) return;
249
+ controller.abort(new Error(
250
+ `OpenCode managed server exited${code === null ? "" : ` with code ${code}`}`
251
+ + `${signal ? ` after ${signal}` : ""}`,
252
+ ));
253
+ };
254
+ child.once("exit", onExit);
255
+ for (const signal of ["SIGINT", "SIGTERM"]) {
256
+ const handler = () => {
257
+ controller.abort(new Error(`OpenCode managed server interrupted by ${signal}`));
258
+ if (!childStopped(child)) child.kill(signal);
259
+ };
260
+ handlers.set(signal, handler);
261
+ process.once(signal, handler);
262
+ }
263
+ return {
264
+ signal: controller.signal,
265
+ dispose() {
266
+ child.off("exit", onExit);
267
+ for (const [signal, handler] of handlers) process.off(signal, handler);
268
+ },
269
+ };
270
+ }
271
+
272
+ async function stopOwnedServer(child, timeoutMs) {
273
+ if (childStopped(child)) return;
274
+ const exited = new Promise((resolveExit) => child.once("exit", resolveExit));
275
+ child.kill("SIGTERM");
276
+ if (await settlesWithin(exited, timeoutMs) || childStopped(child)) return;
277
+ child.kill("SIGKILL");
278
+ if (await settlesWithin(exited, timeoutMs) || childStopped(child)) return;
279
+ child.stdout?.destroy();
280
+ child.stderr?.destroy();
281
+ child.unref?.();
282
+ }
283
+
284
+ async function settlesWithin(promise, timeoutMs) {
285
+ let timer;
286
+ try {
287
+ return await Promise.race([
288
+ promise.then(() => true, () => true),
289
+ new Promise((resolveTimeout) => {
290
+ timer = setTimeout(() => resolveTimeout(false), timeoutMs);
291
+ }),
292
+ ]);
293
+ } finally {
294
+ clearTimeout(timer);
295
+ }
296
+ }
297
+
298
+ function childStopped(child) {
299
+ return child.exitCode !== null || child.signalCode !== null;
300
+ }
301
+
80
302
  function parseArgs(values) {
81
303
  const result = {};
82
304
  for (let index = 0; index < values.length; index += 1) {
@@ -131,7 +353,7 @@ async function requestJson(fetchImpl, url, init, operation) {
131
353
  try {
132
354
  response = await fetchImpl(url, init);
133
355
  } catch (error) {
134
- throw new Error(`OpenCode ${operation} request failed: ${errorMessage(error)}`);
356
+ throw new OpenCodeTransportError(operation, error);
135
357
  }
136
358
  const body = await response.text();
137
359
  if (!response.ok) {
@@ -148,6 +370,19 @@ async function requestJson(fetchImpl, url, init, operation) {
148
370
  }
149
371
  }
150
372
 
373
+ function isSessionCreationTransportError(error) {
374
+ return error instanceof OpenCodeTransportError && error.operation === "session creation";
375
+ }
376
+
377
+ function basicAuthorization(username, password) {
378
+ return `Basic ${Buffer.from(`${username}:${password}`).toString("base64")}`;
379
+ }
380
+
381
+ function positiveInteger(value, fallback) {
382
+ const parsed = Number(value);
383
+ return Number.isInteger(parsed) && parsed > 0 ? parsed : fallback;
384
+ }
385
+
151
386
  async function bestEffortDelete(fetchImpl, url, authorization) {
152
387
  try {
153
388
  await fetchImpl(url, {
@@ -1,4 +1,4 @@
1
- export const MINIMUM_NODE_MAJOR = 24;
1
+ export const MINIMUM_NODE_MAJOR = 20;
2
2
 
3
3
  export function unsupportedNodeVersionMessage(version = process.versions.node) {
4
4
  const value = String(version ?? "").trim();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@memorax/memorax-code",
3
- "version": "0.1.3",
3
+ "version": "0.1.5",
4
4
  "description": "Persistent coding memory for Codex, Claude Code, DeepSeek Harness, and OpenCode.",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -37,7 +37,7 @@
37
37
  "README.md"
38
38
  ],
39
39
  "engines": {
40
- "node": ">=24"
40
+ "node": ">=20"
41
41
  },
42
42
  "dependencies": {
43
43
  "smol-toml": "^1.7.0"