crbro-memory 2.10.0 → 2.11.0

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 (205) hide show
  1. package/LICENSE +21 -21
  2. package/README.md +256 -465
  3. package/SECURITY.md +114 -12
  4. package/bin/crbro.mjs +539 -300
  5. package/dist/build-id.json +10 -0
  6. package/dist/cli/common.js +211 -0
  7. package/dist/cli/export.js +522 -0
  8. package/dist/cli/import.js +683 -0
  9. package/dist/cli/markdown.js +313 -0
  10. package/dist/cli/mcpclient.js +107 -0
  11. package/dist/cli/serve.js +453 -0
  12. package/dist/cli/view.js +358 -0
  13. package/dist/cli/viewer-page.js +378 -0
  14. package/dist/daemon/daemon.js +119 -11
  15. package/dist/daemon/endpoint.js +222 -21
  16. package/dist/daemon/lines.js +102 -13
  17. package/dist/daemon/proxy.js +480 -125
  18. package/dist/daemon/surface.js +116 -0
  19. package/dist/daemon/trace.js +73 -0
  20. package/dist/engine/atrest.js +132 -0
  21. package/dist/engine/backup.js +139 -19
  22. package/dist/engine/brain.js +22 -6
  23. package/dist/engine/codexconfig.js +476 -0
  24. package/dist/engine/cortex.js +667 -232
  25. package/dist/engine/doctor.js +312 -14
  26. package/dist/engine/heat.js +81 -37
  27. package/dist/engine/hippocampus.js +18 -7
  28. package/dist/engine/hookrefresh.js +296 -0
  29. package/dist/engine/hookverify.js +15 -3
  30. package/dist/engine/installer.js +1022 -0
  31. package/dist/engine/installio.js +279 -0
  32. package/dist/engine/keychain.js +721 -162
  33. package/dist/engine/maintenance.js +355 -87
  34. package/dist/engine/modinstall.js +98 -45
  35. package/dist/engine/prefrontal.js +29 -6
  36. package/dist/engine/quarantine.js +204 -0
  37. package/dist/engine/secrets.js +41 -5
  38. package/dist/engine/shelf.js +25 -3
  39. package/dist/engine/synapses.js +183 -80
  40. package/dist/engine/triggers.js +141 -11
  41. package/dist/index.js +37 -3
  42. package/dist/mcp-surface.json +1 -0
  43. package/dist/miner/extractor.js +64 -17
  44. package/dist/miner/scheduler.js +144 -68
  45. package/dist/search/index.js +797 -176
  46. package/dist/search/semantic.js +208 -18
  47. package/dist/search/tokenize.js +44 -0
  48. package/dist/server.js +583 -373
  49. package/dist/sync/git.js +203 -72
  50. package/dist/sync/space.js +100 -25
  51. package/dist/sync/validate.js +204 -0
  52. package/dist/utils/budget.js +15 -2
  53. package/dist/utils/fs.js +40 -6
  54. package/dist/utils/ids.js +128 -14
  55. package/dist/utils/lock.js +94 -7
  56. package/hooks/crbro-codex.mjs +284 -0
  57. package/hooks/crbro-guard.mjs +281 -173
  58. package/hooks/crbro-hooks.shipped.json +20 -0
  59. package/hooks/crbro-lifecycle.mjs +16 -4
  60. package/hooks/crbro-subagent.mjs +180 -180
  61. package/package.json +9 -5
  62. package/dist/daemon/daemon.d.ts +0 -25
  63. package/dist/daemon/daemon.d.ts.map +0 -1
  64. package/dist/daemon/daemon.js.map +0 -1
  65. package/dist/daemon/endpoint.d.ts +0 -66
  66. package/dist/daemon/endpoint.d.ts.map +0 -1
  67. package/dist/daemon/endpoint.js.map +0 -1
  68. package/dist/daemon/lines.d.ts +0 -55
  69. package/dist/daemon/lines.d.ts.map +0 -1
  70. package/dist/daemon/lines.js.map +0 -1
  71. package/dist/daemon/proxy.d.ts +0 -55
  72. package/dist/daemon/proxy.d.ts.map +0 -1
  73. package/dist/daemon/proxy.js.map +0 -1
  74. package/dist/engine/backup.d.ts +0 -68
  75. package/dist/engine/backup.d.ts.map +0 -1
  76. package/dist/engine/backup.js.map +0 -1
  77. package/dist/engine/brain.d.ts +0 -79
  78. package/dist/engine/brain.d.ts.map +0 -1
  79. package/dist/engine/brain.js.map +0 -1
  80. package/dist/engine/cortex.d.ts +0 -682
  81. package/dist/engine/cortex.d.ts.map +0 -1
  82. package/dist/engine/cortex.js.map +0 -1
  83. package/dist/engine/dates.d.ts +0 -28
  84. package/dist/engine/dates.d.ts.map +0 -1
  85. package/dist/engine/dates.js.map +0 -1
  86. package/dist/engine/doctor.d.ts +0 -93
  87. package/dist/engine/doctor.d.ts.map +0 -1
  88. package/dist/engine/doctor.js.map +0 -1
  89. package/dist/engine/evidence.d.ts +0 -26
  90. package/dist/engine/evidence.d.ts.map +0 -1
  91. package/dist/engine/evidence.js.map +0 -1
  92. package/dist/engine/heat.d.ts +0 -28
  93. package/dist/engine/heat.d.ts.map +0 -1
  94. package/dist/engine/heat.js.map +0 -1
  95. package/dist/engine/hippocampus.d.ts +0 -60
  96. package/dist/engine/hippocampus.d.ts.map +0 -1
  97. package/dist/engine/hippocampus.js.map +0 -1
  98. package/dist/engine/hookverify.d.ts +0 -42
  99. package/dist/engine/hookverify.d.ts.map +0 -1
  100. package/dist/engine/hookverify.js.map +0 -1
  101. package/dist/engine/keychain.d.ts +0 -32
  102. package/dist/engine/keychain.d.ts.map +0 -1
  103. package/dist/engine/keychain.js.map +0 -1
  104. package/dist/engine/maintenance.d.ts +0 -218
  105. package/dist/engine/maintenance.d.ts.map +0 -1
  106. package/dist/engine/maintenance.js.map +0 -1
  107. package/dist/engine/modinstall.d.ts +0 -215
  108. package/dist/engine/modinstall.d.ts.map +0 -1
  109. package/dist/engine/modinstall.js.map +0 -1
  110. package/dist/engine/postmortem.d.ts +0 -81
  111. package/dist/engine/postmortem.d.ts.map +0 -1
  112. package/dist/engine/postmortem.js.map +0 -1
  113. package/dist/engine/prefrontal.d.ts +0 -64
  114. package/dist/engine/prefrontal.d.ts.map +0 -1
  115. package/dist/engine/prefrontal.js.map +0 -1
  116. package/dist/engine/projects.d.ts +0 -20
  117. package/dist/engine/projects.d.ts.map +0 -1
  118. package/dist/engine/projects.js.map +0 -1
  119. package/dist/engine/secrets.d.ts +0 -30
  120. package/dist/engine/secrets.d.ts.map +0 -1
  121. package/dist/engine/secrets.js.map +0 -1
  122. package/dist/engine/shelf.d.ts +0 -155
  123. package/dist/engine/shelf.d.ts.map +0 -1
  124. package/dist/engine/shelf.js.map +0 -1
  125. package/dist/engine/source.d.ts +0 -6
  126. package/dist/engine/source.d.ts.map +0 -1
  127. package/dist/engine/source.js.map +0 -1
  128. package/dist/engine/synapses.d.ts +0 -82
  129. package/dist/engine/synapses.d.ts.map +0 -1
  130. package/dist/engine/synapses.js.map +0 -1
  131. package/dist/engine/triggers.d.ts +0 -53
  132. package/dist/engine/triggers.d.ts.map +0 -1
  133. package/dist/engine/triggers.js.map +0 -1
  134. package/dist/engine/usage.d.ts +0 -61
  135. package/dist/engine/usage.d.ts.map +0 -1
  136. package/dist/engine/usage.js.map +0 -1
  137. package/dist/index.d.ts +0 -2
  138. package/dist/index.d.ts.map +0 -1
  139. package/dist/index.js.map +0 -1
  140. package/dist/miner/extractor.d.ts +0 -12
  141. package/dist/miner/extractor.d.ts.map +0 -1
  142. package/dist/miner/extractor.js.map +0 -1
  143. package/dist/miner/index.d.ts +0 -61
  144. package/dist/miner/index.d.ts.map +0 -1
  145. package/dist/miner/index.js.map +0 -1
  146. package/dist/miner/scheduler.d.ts +0 -30
  147. package/dist/miner/scheduler.d.ts.map +0 -1
  148. package/dist/miner/scheduler.js.map +0 -1
  149. package/dist/miner/types.d.ts +0 -11
  150. package/dist/miner/types.d.ts.map +0 -1
  151. package/dist/miner/types.js.map +0 -1
  152. package/dist/search/index.d.ts +0 -328
  153. package/dist/search/index.d.ts.map +0 -1
  154. package/dist/search/index.js.map +0 -1
  155. package/dist/search/quality.d.ts +0 -18
  156. package/dist/search/quality.d.ts.map +0 -1
  157. package/dist/search/quality.js.map +0 -1
  158. package/dist/search/semantic.d.ts +0 -80
  159. package/dist/search/semantic.d.ts.map +0 -1
  160. package/dist/search/semantic.js.map +0 -1
  161. package/dist/search/synonyms.d.ts +0 -2
  162. package/dist/search/synonyms.d.ts.map +0 -1
  163. package/dist/search/synonyms.js.map +0 -1
  164. package/dist/search/tokenize.d.ts +0 -39
  165. package/dist/search/tokenize.d.ts.map +0 -1
  166. package/dist/search/tokenize.js.map +0 -1
  167. package/dist/server.d.ts +0 -41
  168. package/dist/server.d.ts.map +0 -1
  169. package/dist/server.js.map +0 -1
  170. package/dist/sync/git.d.ts +0 -36
  171. package/dist/sync/git.d.ts.map +0 -1
  172. package/dist/sync/git.js.map +0 -1
  173. package/dist/sync/materialize.d.ts +0 -36
  174. package/dist/sync/materialize.d.ts.map +0 -1
  175. package/dist/sync/materialize.js.map +0 -1
  176. package/dist/sync/ops.d.ts +0 -147
  177. package/dist/sync/ops.d.ts.map +0 -1
  178. package/dist/sync/ops.js.map +0 -1
  179. package/dist/sync/space.d.ts +0 -118
  180. package/dist/sync/space.d.ts.map +0 -1
  181. package/dist/sync/space.js.map +0 -1
  182. package/dist/types/index.d.ts +0 -369
  183. package/dist/types/index.d.ts.map +0 -1
  184. package/dist/types/index.js.map +0 -1
  185. package/dist/utils/budget.d.ts +0 -44
  186. package/dist/utils/budget.d.ts.map +0 -1
  187. package/dist/utils/budget.js.map +0 -1
  188. package/dist/utils/fs.d.ts +0 -59
  189. package/dist/utils/fs.d.ts.map +0 -1
  190. package/dist/utils/fs.js.map +0 -1
  191. package/dist/utils/hash.d.ts +0 -20
  192. package/dist/utils/hash.d.ts.map +0 -1
  193. package/dist/utils/hash.js.map +0 -1
  194. package/dist/utils/ids.d.ts +0 -47
  195. package/dist/utils/ids.d.ts.map +0 -1
  196. package/dist/utils/ids.js.map +0 -1
  197. package/dist/utils/lock.d.ts +0 -13
  198. package/dist/utils/lock.d.ts.map +0 -1
  199. package/dist/utils/lock.js.map +0 -1
  200. package/dist/utils/transcripts.d.ts +0 -63
  201. package/dist/utils/transcripts.d.ts.map +0 -1
  202. package/dist/utils/transcripts.js.map +0 -1
  203. package/dist/version.d.ts +0 -26
  204. package/dist/version.d.ts.map +0 -1
  205. package/dist/version.js.map +0 -1
@@ -18,8 +18,9 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
18
18
  return (mod && mod.__esModule) ? mod : { "default": mod };
19
19
  };
20
20
  Object.defineProperty(exports, "__esModule", { value: true });
21
- exports.DAEMON_PROTOCOL = void 0;
21
+ exports.BUILD_ID_FILE = exports.DAEMON_PROTOCOL = void 0;
22
22
  exports.packageVersion = packageVersion;
23
+ exports.readBuildId = readBuildId;
23
24
  exports.buildId = buildId;
24
25
  exports.daemonDir = daemonDir;
25
26
  exports.endpointFor = endpointFor;
@@ -37,6 +38,8 @@ exports.writeState = writeState;
37
38
  exports.readState = readState;
38
39
  exports.removeState = removeState;
39
40
  exports.listStates = listStates;
41
+ exports.verifyDaemon = verifyDaemon;
42
+ exports.pruneDaemonDir = pruneDaemonDir;
40
43
  exports.proof = proof;
41
44
  exports.sameProof = sameProof;
42
45
  exports.newNonce = newNonce;
@@ -44,39 +47,76 @@ exports.daemonEnabled = daemonEnabled;
44
47
  exports.setDaemonEnabled = setDaemonEnabled;
45
48
  const node_crypto_1 = require("node:crypto");
46
49
  const node_fs_1 = require("node:fs");
50
+ const node_net_1 = __importDefault(require("node:net"));
47
51
  const node_os_1 = __importDefault(require("node:os"));
48
52
  const node_path_1 = __importDefault(require("node:path"));
49
53
  const shelf_js_1 = require("../engine/shelf.js");
50
54
  const version_js_1 = require("../version.js");
55
+ const lines_js_1 = require("./lines.js");
51
56
  exports.DAEMON_PROTOCOL = 1;
52
57
  const DAEMON_DIR = '.daemon';
53
58
  const ENABLED_FLAG = 'enabled';
54
- /** The package version, read from disk on each call (the same lookup as view=status), '0.0.0' when unreadable. */
59
+ /**
60
+ * The version of the code this process runs, '0.0.0' when unreadable. Read
61
+ * once, when the process loaded (version.ts): npx refreshes its cache in
62
+ * place, and a proxy that re-read package.json at every reconnection computed
63
+ * the NEXT release's build and launched its daemon — served by code it never
64
+ * started.
65
+ */
55
66
  function packageVersion() {
56
- const v = (0, version_js_1.readPackageVersion)();
57
- return v === 'unknown' ? '0.0.0' : v;
67
+ return version_js_1.RUNNING_VERSION === 'unknown' ? '0.0.0' : version_js_1.RUNNING_VERSION;
58
68
  }
69
+ /** Written next to the compiled code by `npm run build` (scripts/build-id.mjs). */
70
+ exports.BUILD_ID_FILE = 'build-id.json';
59
71
  /**
60
- * Version plus the build's own timestamp. The version alone would let a
61
- * developer's half-built dist serve the clients that launched the published
62
- * package; the timestamp alone would not survive a reinstall of the same
63
- * release on another path. Together: you are served by the code you launched.
72
+ * The build id a compiled tree declares for itself: the version plus a hash
73
+ * of every compiled .js file, computed when it was built. Two installs of the
74
+ * same release hold the same bytes and so the same id — one daemon between
75
+ * them, where the old id (version + the mtime of server.js) gave each copy its
76
+ * own ~630 MB daemon. Null when the file is missing, malformed, or no longer
77
+ * describes the code beside it (`tsc --watch` rewrites the code, not the file).
64
78
  */
65
- function buildId() {
66
- let stamp = '0';
79
+ function readBuildId(distDir) {
67
80
  try {
68
- const here = __dirname; // CommonJS output: dist/daemon, or src/daemon under the test runner
69
- for (const f of ['../server.js', '../server.ts']) {
70
- try {
71
- stamp = Math.floor((0, node_fs_1.statSync)(node_path_1.default.join(here, f)).mtimeMs).toString(36);
72
- break;
73
- }
74
- catch { /* next */ }
81
+ const meta = JSON.parse((0, node_fs_1.readFileSync)(node_path_1.default.join(distDir, exports.BUILD_ID_FILE), 'utf8'));
82
+ if (typeof meta?.build !== 'string' || !/^[\w.+-]{1,96}$/.test(meta.build))
83
+ return null;
84
+ const check = meta.check;
85
+ if (typeof check?.file !== 'string' || typeof check?.sha256 !== 'string' || check.file.includes('..'))
86
+ return null;
87
+ const sha = (0, node_crypto_1.createHash)('sha256').update((0, node_fs_1.readFileSync)(node_path_1.default.join(distDir, check.file))).digest('hex');
88
+ return sha === check.sha256 ? meta.build : null;
89
+ }
90
+ catch {
91
+ return null;
92
+ }
93
+ }
94
+ /** A tree with no build-id.json (a developer's src under the test runner, or a dist built by bare tsc): version plus the timestamp of server.js. */
95
+ function devBuildId(here) {
96
+ let stamp = '0';
97
+ for (const f of ['../server.js', '../server.ts']) {
98
+ try {
99
+ stamp = Math.floor((0, node_fs_1.statSync)(node_path_1.default.join(here, f)).mtimeMs).toString(36);
100
+ break;
75
101
  }
102
+ catch { /* next */ }
76
103
  }
77
- catch { /* keep 0 */ }
78
104
  return `${packageVersion()}-${stamp}`;
79
105
  }
106
+ let ownBuild = null;
107
+ /**
108
+ * Which build this process is, decided once (the first time it is asked) and
109
+ * never re-read: you are served by the code you launched. In a published or
110
+ * built tree it is dist/build-id.json; only a tree without one falls back to
111
+ * version plus the mtime of server.js.
112
+ */
113
+ function buildId() {
114
+ if (ownBuild === null) {
115
+ const here = __dirname; // CommonJS output: dist/daemon, or src/daemon under the test runner
116
+ ownBuild = readBuildId(node_path_1.default.join(here, '..')) ?? devBuildId(here);
117
+ }
118
+ return ownBuild;
119
+ }
80
120
  function daemonDir(brainRoot) {
81
121
  return node_path_1.default.join(brainRoot, DAEMON_DIR);
82
122
  }
@@ -232,13 +272,30 @@ async function acquireStartLock(brainRoot, build) {
232
272
  function newToken() {
233
273
  return (0, node_crypto_1.randomBytes)(32).toString('hex');
234
274
  }
235
- async function writeState(brainRoot, state) {
275
+ /**
276
+ * Write the descriptor atomically. On Windows the rename can fail for a moment
277
+ * (EPERM/EBUSY while an antivirus or a reading client holds the target), so it
278
+ * is tried a few times; a temporary that could not become the descriptor is
279
+ * removed, never left behind with a token in it.
280
+ */
281
+ async function writeState(brainRoot, state, attempts = 3) {
236
282
  const dir = daemonDir(brainRoot);
237
283
  await node_fs_1.promises.mkdir(dir, { recursive: true, mode: 0o700 });
238
284
  const file = stateFile(brainRoot, state.build);
239
285
  const tmp = `${file}.${process.pid}.tmp`;
240
- await node_fs_1.promises.writeFile(tmp, JSON.stringify(state), { mode: 0o600 });
241
- await node_fs_1.promises.rename(tmp, file);
286
+ for (let i = 0;; i++) {
287
+ try {
288
+ await node_fs_1.promises.writeFile(tmp, JSON.stringify(state), { mode: 0o600 });
289
+ await node_fs_1.promises.rename(tmp, file);
290
+ return;
291
+ }
292
+ catch (err) {
293
+ await node_fs_1.promises.rm(tmp, { force: true }).catch(() => undefined);
294
+ if (i + 1 >= attempts)
295
+ throw err;
296
+ await new Promise(r => setTimeout(r, 100 * (i + 1)));
297
+ }
298
+ }
242
299
  }
243
300
  async function readState(brainRoot, build = buildId()) {
244
301
  try {
@@ -274,6 +331,150 @@ async function listStates(brainRoot) {
274
331
  catch { /* no folder, no daemons */ }
275
332
  return out;
276
333
  }
334
+ /**
335
+ * Is the daemon a descriptor names really there? Asked of the endpoint, with
336
+ * the descriptor's own token — never of the pid: Windows hands pids out again
337
+ * quickly, and a descriptor weeks old whose pid now belongs to a cmd.exe was
338
+ * reported "alive" for ever.
339
+ *
340
+ * alive it answered and proved it holds the token
341
+ * refused nobody listens there (connection refused / no such pipe)
342
+ * dead something answers, but not the daemon this descriptor names
343
+ * unknown no verdict in time (busy, or closed on us): leave it alone
344
+ */
345
+ function verifyDaemon(state, timeoutMs = 1_500) {
346
+ return new Promise(resolve => {
347
+ let done = false;
348
+ const nonce = newNonce();
349
+ const s = node_net_1.default.connect(state.endpoint);
350
+ const finish = (v) => {
351
+ if (done)
352
+ return;
353
+ done = true;
354
+ clearTimeout(t);
355
+ s.destroy();
356
+ resolve(v);
357
+ };
358
+ // A late answer that arrived while the loop was busy is read before the verdict.
359
+ const t = setTimeout(() => setImmediate(() => finish('unknown')), timeoutMs);
360
+ const reader = new lines_js_1.LineReader(line => {
361
+ let m = null;
362
+ try {
363
+ m = JSON.parse(line);
364
+ }
365
+ catch { /* not a daemon */ }
366
+ finish(m?.crbro === 'hello' && sameProof(String(m.proof || ''), proof(state.token, 'daemon', nonce)) ? 'alive' : 'dead');
367
+ }, () => finish('dead'), 64 * 1024);
368
+ s.on('connect', () => s.write(`${JSON.stringify({ crbro: 'hello', protocol: exports.DAEMON_PROTOCOL, nonce })}\n`));
369
+ s.on('data', chunk => reader.push(typeof chunk === 'string' ? Buffer.from(chunk, 'utf8') : chunk));
370
+ s.on('error', err => {
371
+ const code = err.code;
372
+ finish(code === 'ECONNREFUSED' || code === 'ENOENT' ? 'refused' : 'unknown');
373
+ });
374
+ s.on('close', () => finish('unknown'));
375
+ });
376
+ }
377
+ /** A *.tmp this old was left by a writer that died or failed: nobody will rename it any more. */
378
+ const TMP_STALE_MS = 5 * 60_000;
379
+ /** A start lock is held for the seconds a start takes. */
380
+ const LOCK_STALE_MS = 5 * 60_000;
381
+ /** A blocked marker matters to the proxy waiting right now, not tomorrow. */
382
+ const BLOCKED_STALE_MS = 24 * 3_600_000;
383
+ /**
384
+ * Sweep .daemon: descriptors whose daemon is not there (checked at the
385
+ * endpoint, with the token), temporaries, start locks and blocked markers that
386
+ * nobody will ever come back for. Before 2.11 only `daemon status|stop|off`
387
+ * removed anything, by pid, and nobody runs those: twelve dead descriptors and
388
+ * five token-bearing temporaries were found in one brain. `keep` names a build
389
+ * whose descriptor is not probed (the caller's own).
390
+ */
391
+ async function pruneDaemonDir(brainRoot, opts = {}) {
392
+ const dir = daemonDir(brainRoot);
393
+ const now = opts.now ?? Date.now();
394
+ const verify = opts.verify ?? verifyDaemon;
395
+ const out = { removed: [], alive: [], unknown: [] };
396
+ let names;
397
+ try {
398
+ names = await node_fs_1.promises.readdir(dir);
399
+ }
400
+ catch {
401
+ return out;
402
+ }
403
+ const keepFile = opts.keep ? node_path_1.default.basename(stateFile(brainRoot, opts.keep)) : null;
404
+ const rmIfOlder = async (name, maxAgeMs) => {
405
+ try {
406
+ const st = await node_fs_1.promises.stat(node_path_1.default.join(dir, name));
407
+ if (now - st.mtimeMs <= maxAgeMs)
408
+ return;
409
+ await node_fs_1.promises.rm(node_path_1.default.join(dir, name), { force: true });
410
+ out.removed.push(name);
411
+ }
412
+ catch { /* gone already */ }
413
+ };
414
+ const checks = [];
415
+ for (const name of names) {
416
+ if (/\.json\.\d+\.tmp$/.test(name)) {
417
+ checks.push(rmIfOlder(name, TMP_STALE_MS));
418
+ continue;
419
+ }
420
+ if (/^[0-9a-f]{16}\.lock$/.test(name)) {
421
+ checks.push(rmIfOlder(name, LOCK_STALE_MS));
422
+ continue;
423
+ }
424
+ if (/^[0-9a-f]{16}\.blocked$/.test(name)) {
425
+ checks.push(rmIfOlder(name, BLOCKED_STALE_MS));
426
+ continue;
427
+ }
428
+ if (!/^[0-9a-f]{16}\.json$/.test(name) || name === keepFile)
429
+ continue;
430
+ checks.push((async () => {
431
+ const file = node_path_1.default.join(dir, name);
432
+ let s;
433
+ try {
434
+ s = JSON.parse(await node_fs_1.promises.readFile(file, 'utf8'));
435
+ }
436
+ catch {
437
+ return;
438
+ }
439
+ // Only descriptors that are what their name says: this brain, this build's own endpoint.
440
+ // Anything else is not ours to judge — nor a place to connect to.
441
+ if (s?.protocol !== exports.DAEMON_PROTOCOL || typeof s.build !== 'string' || typeof s.token !== 'string')
442
+ return;
443
+ if (node_path_1.default.basename(stateFile(brainRoot, s.build)) !== name || s.endpoint !== endpointFor(brainRoot, s.build))
444
+ return;
445
+ const v = await verify(s);
446
+ if (v === 'alive') {
447
+ out.alive.push(s);
448
+ return;
449
+ }
450
+ if (v === 'unknown') {
451
+ out.unknown.push(s);
452
+ return;
453
+ }
454
+ // Removed only if it is still the same descriptor: a replacement may have been written meanwhile.
455
+ try {
456
+ const again = JSON.parse(await node_fs_1.promises.readFile(file, 'utf8'));
457
+ if (again?.token !== s.token)
458
+ return;
459
+ await node_fs_1.promises.rm(file, { force: true });
460
+ out.removed.push(name);
461
+ }
462
+ catch {
463
+ return;
464
+ }
465
+ // A socket file nobody listens on (never a pipe, never one somebody answers on).
466
+ if (v === 'refused' && process.platform !== 'win32' && node_path_1.default.dirname(s.endpoint) === dir) {
467
+ try {
468
+ await node_fs_1.promises.rm(s.endpoint, { force: true });
469
+ out.removed.push(node_path_1.default.basename(s.endpoint));
470
+ }
471
+ catch { /* best effort */ }
472
+ }
473
+ })());
474
+ }
475
+ await Promise.all(checks);
476
+ return out;
477
+ }
277
478
  function proof(token, role, nonce) {
278
479
  return (0, node_crypto_1.createHmac)('sha256', token).update(`${role}:${nonce}`).digest('hex');
279
480
  }
@@ -4,18 +4,38 @@
4
4
  // The same framing MCP uses on stdio, so a proxy can hand a client's bytes to
5
5
  // the daemon without understanding them. One JSON value per line, UTF-8.
6
6
  Object.defineProperty(exports, "__esModule", { value: true });
7
- exports.LoopbackTransport = exports.SocketServerTransport = exports.LineReader = exports.MAX_LINE_BYTES = void 0;
7
+ exports.LoopbackTransport = exports.SocketServerTransport = exports.NOT_STARTED = exports.LineReader = exports.MAX_LINE_BYTES = void 0;
8
+ exports.idFromHead = idFromHead;
8
9
  /** A line bigger than this is not a message, it is a mistake or an attack. */
9
10
  exports.MAX_LINE_BYTES = 32 * 1024 * 1024;
10
- /** Splits a byte stream into lines. Tolerates \r\n and lines split across chunks. */
11
+ /** How much of an over-long line is kept to say which request it was. */
12
+ const HEAD_BYTES = 64 * 1024;
13
+ /**
14
+ * Splits a byte stream into lines. Tolerates \r\n and lines split across chunks.
15
+ *
16
+ * A line longer than the limit is reported once, with its first bytes (enough
17
+ * to find its id), and everything up to its newline is thrown away. It used to
18
+ * be cut at the limit and its tail delivered as a line of its own: a broken
19
+ * message, and an id that never got an answer.
20
+ */
11
21
  class LineReader {
12
22
  onLine;
13
23
  onOverflow;
24
+ maxBytes;
14
25
  buf = [];
15
26
  size = 0;
16
- constructor(onLine, onOverflow) {
27
+ discarding = false;
28
+ constructor(onLine, onOverflow, maxBytes = exports.MAX_LINE_BYTES) {
17
29
  this.onLine = onLine;
18
30
  this.onOverflow = onOverflow;
31
+ this.maxBytes = maxBytes;
32
+ }
33
+ overflow(piece) {
34
+ const parts = [...this.buf, piece];
35
+ const head = Buffer.concat(parts).subarray(0, HEAD_BYTES).toString('utf8');
36
+ this.buf = [];
37
+ this.size = 0;
38
+ this.onOverflow(head);
19
39
  }
20
40
  push(chunk) {
21
41
  let start = 0;
@@ -23,32 +43,60 @@ class LineReader {
23
43
  if (chunk[i] !== 0x0a)
24
44
  continue;
25
45
  const piece = chunk.subarray(start, i);
46
+ start = i + 1;
47
+ if (this.discarding) {
48
+ this.discarding = false;
49
+ continue;
50
+ } // the end of a line already refused
51
+ if (this.size + piece.length > this.maxBytes) {
52
+ this.overflow(piece);
53
+ continue;
54
+ }
26
55
  const whole = this.buf.length ? Buffer.concat([...this.buf, piece]) : piece;
27
56
  this.buf = [];
28
57
  this.size = 0;
29
- start = i + 1;
30
58
  const line = whole.toString('utf8').replace(/\r$/, '');
31
59
  if (line)
32
60
  this.onLine(line);
33
61
  }
34
- if (start < chunk.length) {
62
+ if (start < chunk.length && !this.discarding) {
35
63
  const rest = chunk.subarray(start);
64
+ if (this.size + rest.length > this.maxBytes) {
65
+ this.overflow(rest);
66
+ this.discarding = true; // until the next newline, nothing is a line
67
+ return;
68
+ }
36
69
  this.buf.push(rest);
37
70
  this.size += rest.length;
38
- if (this.size > exports.MAX_LINE_BYTES) {
39
- this.buf = [];
40
- this.size = 0;
41
- this.onOverflow();
42
- }
43
71
  }
44
72
  }
45
73
  }
46
74
  exports.LineReader = LineReader;
75
+ /** The id of a request, read from the first bytes of a line too long to parse. Null when it cannot be told. */
76
+ function idFromHead(head) {
77
+ const m = /"id"\s*:\s*("(?:[^"\\]|\\.){0,256}"|-?\d{1,16})/.exec(head);
78
+ if (!m)
79
+ return null;
80
+ try {
81
+ return JSON.parse(m[1]);
82
+ }
83
+ catch {
84
+ return null;
85
+ }
86
+ }
87
+ const isRequest = (m) => !!m && typeof m.method === 'string' && m.id !== undefined && m.id !== null;
88
+ const isResponse = (m) => !!m && m.method === undefined && m.id !== undefined && m.id !== null;
89
+ /** The error a stopping daemon gives a request it never started. The proxy recognises it and sends the request again elsewhere. */
90
+ exports.NOT_STARTED = 'not-started';
47
91
  /**
48
92
  * The daemon's side of one client: an MCP Transport over a socket that has
49
93
  * already passed the handshake. `scope` wraps every incoming message, which
50
94
  * is how per-connection state (the session tally) follows the request through
51
95
  * shared engines without any of them knowing about connections.
96
+ *
97
+ * It counts the requests it has handed to the server and not yet answered, so
98
+ * a daemon that is asked to stop can wait for them instead of cutting a write
99
+ * in half — and, while it waits, refuse new ones in a way the proxy can retry.
52
100
  */
53
101
  class SocketServerTransport {
54
102
  socket;
@@ -59,6 +107,9 @@ class SocketServerTransport {
59
107
  closed = false;
60
108
  /** Lines that arrived before the server finished connecting, in order. */
61
109
  pending = [];
110
+ inflight = new Set();
111
+ draining = false;
112
+ idleWaiters = [];
62
113
  constructor(socket, scope) {
63
114
  this.socket = socket;
64
115
  this.scope = scope;
@@ -74,6 +125,17 @@ class SocketServerTransport {
74
125
  this.socket.on('close', () => this.finish());
75
126
  this.socket.on('error', err => { this.onerror?.(err); this.finish(); });
76
127
  }
128
+ /** Requests handed to the server and not answered yet. */
129
+ inFlight() {
130
+ return this.inflight.size;
131
+ }
132
+ /** From now on new requests are refused (NOT_STARTED); resolves once those already running are answered. */
133
+ drain() {
134
+ this.draining = true;
135
+ if (this.inflight.size === 0 || this.closed)
136
+ return Promise.resolve();
137
+ return new Promise(resolve => this.idleWaiters.push(resolve));
138
+ }
77
139
  /** Fed by the daemon, which owns the reader (the handshake used it first). */
78
140
  receive(line) {
79
141
  if (this.pending) {
@@ -88,15 +150,41 @@ class SocketServerTransport {
88
150
  this.onerror?.(err);
89
151
  return;
90
152
  }
153
+ if (isRequest(message)) {
154
+ const id = message.id;
155
+ if (this.draining) {
156
+ this.write({ jsonrpc: '2.0', id, error: { code: -32000, message: 'CRBRO is restarting: this call was not started.', data: { crbro: exports.NOT_STARTED } } });
157
+ return;
158
+ }
159
+ this.inflight.add(id);
160
+ }
161
+ else if (message.method === 'notifications/cancelled') {
162
+ // A cancelled request is never answered: it is not running any more either.
163
+ if (this.inflight.delete(message.params?.requestId))
164
+ queueMicrotask(() => this.settle());
165
+ }
91
166
  this.scope(() => this.onmessage?.(message));
92
167
  }
93
- async send(message) {
168
+ write(message) {
94
169
  if (this.closed || this.socket.destroyed)
95
- return;
96
- await new Promise(resolve => {
170
+ return Promise.resolve();
171
+ return new Promise(resolve => {
97
172
  this.socket.write(`${JSON.stringify(message)}\n`, () => resolve());
98
173
  });
99
174
  }
175
+ async send(message) {
176
+ await this.write(message);
177
+ if (isResponse(message) && this.inflight.delete(message.id))
178
+ this.settle();
179
+ }
180
+ settle() {
181
+ if (this.inflight.size > 0 && !this.closed)
182
+ return;
183
+ const waiters = this.idleWaiters;
184
+ this.idleWaiters = [];
185
+ for (const w of waiters)
186
+ w();
187
+ }
100
188
  async close() {
101
189
  if (!this.socket.destroyed)
102
190
  this.socket.end();
@@ -106,6 +194,7 @@ class SocketServerTransport {
106
194
  if (this.closed)
107
195
  return;
108
196
  this.closed = true;
197
+ this.settle();
109
198
  this.onclose?.();
110
199
  }
111
200
  }