@gamaze/hicortex 0.19.6 → 0.20.1

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/dist/init.js CHANGED
@@ -7,7 +7,9 @@
7
7
  * 2. Remote HC server (HICORTEX_SERVER_URL — any reachable host:port)
8
8
  * 3. OC plugin installed (~/.openclaw/openclaw.json)
9
9
  * 4. CC MCP already registered (~/.claude/settings.json)
10
- * 5. Existing DB (~/.hicortex/ or ~/.openclaw/data/)
10
+ * 5. Hermes present (~/.hermes) / Pi present (~/.pi/agent) /
11
+ * opencode present (~/.config/opencode or ~/.local/share/opencode)
12
+ * 6. Existing DB (~/.hicortex/ or ~/.openclaw/data/)
11
13
  *
12
14
  * Actions:
13
15
  * - Install persistent daemon (launchd/systemd)
@@ -19,6 +21,8 @@
19
21
  Object.defineProperty(exports, "__esModule", { value: true });
20
22
  exports.GENERIC_DEFAULT_DOMAINS = void 0;
21
23
  exports.parseMcpListStatus = parseMcpListStatus;
24
+ exports.readPreviousServerUrl = readPreviousServerUrl;
25
+ exports.setupHermes = setupHermes;
22
26
  exports.parseEnvFile = parseEnvFile;
23
27
  exports.isLlmConfigured = isLlmConfigured;
24
28
  exports.persistLlmConfig = persistLlmConfig;
@@ -80,6 +84,11 @@ const CC_SETTINGS = (0, node_path_1.join)((0, node_os_1.homedir)(), ".claude", "
80
84
  const CC_COMMANDS_DIR = (0, node_path_1.join)((0, node_os_1.homedir)(), ".claude", "commands");
81
85
  const OC_CONFIG = (0, node_path_1.join)((0, node_os_1.homedir)(), ".openclaw", "openclaw.json");
82
86
  const HERMES_HOME = process.env.HERMES_HOME || (0, node_path_1.join)((0, node_os_1.homedir)(), ".hermes");
87
+ /** Pi's agent dir — its presence means Pi is installed and will load extensions. */
88
+ const PI_AGENT_DIR = (0, node_path_1.join)((0, node_os_1.homedir)(), ".pi", "agent");
89
+ /** opencode's config/data dirs — either present means opencode is installed. */
90
+ const OPENCODE_CONFIG_DIR = (0, node_path_1.join)((0, node_os_1.homedir)(), ".config", "opencode");
91
+ const OPENCODE_DATA_DIR = (0, node_path_1.join)((0, node_os_1.homedir)(), ".local", "share", "opencode");
83
92
  const DEFAULT_PORT = 8787;
84
93
  async function detect() {
85
94
  const result = {
@@ -88,6 +97,8 @@ async function detect() {
88
97
  ocPlugin: false,
89
98
  ccMcpRegistered: false,
90
99
  hermesFound: false,
100
+ piFound: false,
101
+ opencodeFound: false,
91
102
  existingDb: false,
92
103
  };
93
104
  // Check local server. /health/detail carries the diagnostics (memories,
@@ -127,6 +138,11 @@ async function detect() {
127
138
  }
128
139
  // Check Hermes
129
140
  result.hermesFound = (0, node_fs_1.existsSync)(HERMES_HOME);
141
+ // Check Pi (~/.pi/agent — the dir Pi loads extensions from)
142
+ result.piFound = (0, node_fs_1.existsSync)(PI_AGENT_DIR);
143
+ // Check opencode (~/.config/opencode or ~/.local/share/opencode — its
144
+ // global plugins dir / session store; it auto-loads ~/.config/opencode/plugins/)
145
+ result.opencodeFound = (0, node_fs_1.existsSync)(OPENCODE_CONFIG_DIR) || (0, node_fs_1.existsSync)(OPENCODE_DATA_DIR);
130
146
  // Check OC plugin
131
147
  try {
132
148
  const raw = (0, node_fs_1.readFileSync)(OC_CONFIG, "utf-8");
@@ -331,9 +347,75 @@ function cleanupLegacyCcCommands() {
331
347
  }
332
348
  }
333
349
  // ---------------------------------------------------------------------------
350
+ // Pi setup
351
+ // ---------------------------------------------------------------------------
352
+ /**
353
+ * Install the bundled Pi extension (#348): one dependency-free .ts file Pi
354
+ * loads from ~/.pi/agent/extensions/. Deliberately NO config write — the
355
+ * extension self-resolves the server from ~/.hicortex/config.json (the file
356
+ * init has already written by this point), so there is nothing to keep in
357
+ * sync and no secret to route elsewhere. Overwrites on re-init so upgrades
358
+ * land; skips gracefully when the bundled source is absent (e.g. a dev
359
+ * checkout without the packaged copy).
360
+ */
361
+ function setupPi() {
362
+ const extensionSource = (0, node_path_1.join)(__dirname, "..", "pi-extension", "hicortex", "index.ts");
363
+ if (!(0, node_fs_1.existsSync)(extensionSource)) {
364
+ console.log(" ⚠ Pi extension not found in package — skipping Pi setup");
365
+ return;
366
+ }
367
+ const extensionsDir = (0, node_path_1.join)(PI_AGENT_DIR, "extensions");
368
+ (0, node_fs_1.mkdirSync)(extensionsDir, { recursive: true });
369
+ const target = (0, node_path_1.join)(extensionsDir, "hicortex.ts");
370
+ (0, node_fs_1.copyFileSync)(extensionSource, target);
371
+ console.log(` ✓ Copied Pi extension to ${target}`);
372
+ console.log(" → Restart Pi sessions to load the extension (recall, identity, lessons, 9 tools)");
373
+ }
374
+ // ---------------------------------------------------------------------------
375
+ // opencode setup
376
+ // ---------------------------------------------------------------------------
377
+ /**
378
+ * Install the bundled opencode plugin (#347): one dependency-free .ts file
379
+ * opencode auto-loads from ~/.config/opencode/plugins/. Deliberately NO
380
+ * write into opencode's own configuration — the plugin self-resolves the
381
+ * server from ~/.hicortex/config.json (the file init has already written by
382
+ * this point), so there is nothing to keep in sync and no secret to route
383
+ * elsewhere. Overwrites on re-init so upgrades land; skips gracefully when
384
+ * the bundled source is absent (e.g. a dev checkout without the packaged
385
+ * copy). The plugins directory may hold third-party files — the copy only
386
+ * ever touches our own hicortex.ts name.
387
+ */
388
+ function setupOpencode() {
389
+ const pluginSource = (0, node_path_1.join)(__dirname, "..", "opencode-plugin", "hicortex", "index.ts");
390
+ if (!(0, node_fs_1.existsSync)(pluginSource)) {
391
+ console.log(" ⚠ opencode plugin not found in package — skipping opencode setup");
392
+ return;
393
+ }
394
+ const pluginsDir = (0, node_path_1.join)(OPENCODE_CONFIG_DIR, "plugins");
395
+ (0, node_fs_1.mkdirSync)(pluginsDir, { recursive: true });
396
+ const target = (0, node_path_1.join)(pluginsDir, "hicortex.ts");
397
+ (0, node_fs_1.copyFileSync)(pluginSource, target);
398
+ console.log(` ✓ Copied opencode plugin to ${target}`);
399
+ console.log(" → Restart opencode sessions to load the plugin (recall, identity, lessons, 9 tools)");
400
+ }
401
+ // ---------------------------------------------------------------------------
334
402
  // Hermes setup
335
403
  // ---------------------------------------------------------------------------
336
- function setupHermes(serverUrl, authToken) {
404
+ /**
405
+ * The client config's serverUrl BEFORE this run re-points it — the value init
406
+ * last wrote into the Hermes plugin's config (so the plugin step can tell an
407
+ * init-owned URL from a user-set one). Exported for tests.
408
+ */
409
+ function readPreviousServerUrl(configPath) {
410
+ try {
411
+ const { config: prev } = loadConfigStrict(configPath);
412
+ if (typeof prev.serverUrl === "string" && prev.serverUrl)
413
+ return prev.serverUrl;
414
+ }
415
+ catch { /* no prior config, or unreadable — nothing init-owned to carry */ }
416
+ return undefined;
417
+ }
418
+ function setupHermes(serverUrl, authToken, previousServerUrl) {
337
419
  const pluginSource = (0, node_path_1.join)(__dirname, "..", "hermes-plugin", "hicortex");
338
420
  if (!(0, node_fs_1.existsSync)(pluginSource)) {
339
421
  console.log(" ⚠ Hermes plugin not found in package — skipping Hermes setup");
@@ -341,23 +423,86 @@ function setupHermes(serverUrl, authToken) {
341
423
  }
342
424
  const pluginsDir = (0, node_path_1.join)(HERMES_HOME, "plugins", "hicortex");
343
425
  (0, node_fs_1.mkdirSync)(pluginsDir, { recursive: true });
344
- // Copy plugin files
345
- const pluginFiles = (0, node_fs_1.readdirSync)(pluginSource);
346
- for (const f of pluginFiles) {
347
- const src = (0, node_path_1.join)(pluginSource, f);
348
- if ((0, node_fs_1.statSync)(src).isFile()) {
349
- (0, node_fs_1.copyFileSync)(src, (0, node_path_1.join)(pluginsDir, f));
350
- }
426
+ // If the plugin was installed via `hermes plugins install` (a git checkout
427
+ // that actually contains the plugin — .git alone could be an empty/partial
428
+ // dir), do NOT copy over it — that would mix a managed checkout with
429
+ // copied files and break its own update path. The config below is still
430
+ // written: Hermes' own `memory setup` writes config.json into that tree
431
+ // too, so the file is expected there regardless of install path.
432
+ const isGitInstall = (0, node_fs_1.existsSync)((0, node_path_1.join)(pluginsDir, ".git")) && (0, node_fs_1.existsSync)((0, node_path_1.join)(pluginsDir, "plugin.yaml"));
433
+ if (isGitInstall) {
434
+ console.log(" ✓ Hermes plugin already installed (git checkout) — leaving its files, updating config only");
351
435
  }
352
- console.log(` ✓ Copied Hermes plugin to ${pluginsDir}`);
353
- // Write plugin config.json (server URL only). The auth token is a SECRET and
354
- // is deliberately NOT written here — `hermes memory setup` routes it to
355
- // $HERMES_HOME/.env, and localhost bypasses auth entirely. Capture threshold
356
- // omitted the plugin's own default applies.
357
- const config = { hicortex_url: serverUrl };
436
+ else {
437
+ const pluginFiles = (0, node_fs_1.readdirSync)(pluginSource);
438
+ for (const f of pluginFiles) {
439
+ // NEVER copy a bundled config.json over the user's: a stray one in a
440
+ // developer tree ships via prepack, and clobbering here would defeat
441
+ // the merge logic below (it runs AFTER this loop).
442
+ if (f === "config.json")
443
+ continue;
444
+ const src = (0, node_path_1.join)(pluginSource, f);
445
+ if ((0, node_fs_1.statSync)(src).isFile()) {
446
+ (0, node_fs_1.copyFileSync)(src, (0, node_path_1.join)(pluginsDir, f));
447
+ }
448
+ }
449
+ console.log(` ✓ Copied Hermes plugin to ${pluginsDir}`);
450
+ }
451
+ // Plugin config.json: MERGE, never blindly clobber. The URL is overwritten
452
+ // when it is INIT-OWNED — unset, a local default (either host spelling:
453
+ // init has written both `localhost` and `127.0.0.1` variants historically),
454
+ // or exactly what init last wrote (previousServerUrl — the client config's
455
+ // serverUrl BEFORE this run re-pointed it). A value the USER set by hand to
456
+ // something else is preserved WITH A LOUD DIVERGENCE WARNING — silently
457
+ // keeping a stale endpoint on a re-point is the failure this guards
458
+ // (CR: re-point must reach the plugin like it reaches CC's MCP entry).
459
+ // The auth token is a SECRET and is deliberately NOT written here —
460
+ // `hermes memory setup` routes it to $HERMES_HOME/.env, and localhost
461
+ // bypasses auth entirely.
462
+ const stripTrailingSlash = (v) => v.replace(/\/+$/, "");
463
+ const localDefaults = [`http://localhost:${DEFAULT_PORT}`, `http://127.0.0.1:${DEFAULT_PORT}`];
464
+ // Normalize before comparing (trailing-slash variants of the same URL must
465
+ // not read as user-set — the client flow strips, the env-var flow does not).
466
+ const normalizedPrevious = previousServerUrl ? stripTrailingSlash(previousServerUrl) : undefined;
467
+ const isInitOwnedUrl = (v) => {
468
+ if (typeof v !== "string" || v === "")
469
+ return true;
470
+ const nv = stripTrailingSlash(v);
471
+ return localDefaults.includes(nv) || nv === normalizedPrevious;
472
+ };
358
473
  const configPath = (0, node_path_1.join)(pluginsDir, "config.json");
359
- (0, node_fs_1.writeFileSync)(configPath, JSON.stringify(config, null, 2));
360
- console.log(` ✓ Plugin config → ${serverUrl}`);
474
+ let config = {};
475
+ if ((0, node_fs_1.existsSync)(configPath)) {
476
+ try {
477
+ config = loadConfigStrict(configPath).config;
478
+ }
479
+ catch {
480
+ // Corrupt/unreadable plugin config: REPLACE (this file holds no secrets
481
+ // by design — only URL/knobs; reset to defaults) — loudly, never
482
+ // silently, never fatally. (The underlying error's copy speaks of
483
+ // ~/.hicortex config semantics that do not apply to this file, so it
484
+ // is deliberately not echoed.)
485
+ console.log(" ⚠ Plugin config.json unreadable — replacing it (URL re-prefilled; optional knobs reset to defaults; no secrets live in this file)");
486
+ config = {};
487
+ }
488
+ }
489
+ const normalizedServerUrl = stripTrailingSlash(serverUrl);
490
+ let keptExistingUrl = false;
491
+ if (isInitOwnedUrl(config.hicortex_url)) {
492
+ config.hicortex_url = normalizedServerUrl;
493
+ }
494
+ else if (stripTrailingSlash(config.hicortex_url) !== normalizedServerUrl) {
495
+ keptExistingUrl = true;
496
+ console.log(` ℹ Kept existing plugin URL ${config.hicortex_url} (differs from this install's ${normalizedServerUrl})`);
497
+ console.log(" Edit ~/.hermes/plugins/hicortex/config.json if that is not what you want.");
498
+ }
499
+ try {
500
+ (0, node_fs_1.writeFileSync)(configPath, JSON.stringify(config, null, 2));
501
+ console.log(keptExistingUrl ? ` ℹ Plugin config stays on ${config.hicortex_url}` : ` ✓ Plugin config → ${config.hicortex_url}`);
502
+ }
503
+ catch (e) {
504
+ console.log(` ⚠ Could not write plugin config (${e instanceof Error ? e.message : e}) — Hermes setup skipped config pre-fill`);
505
+ }
361
506
  // For profile-based setups, symlink the shared plugin into each profile's
362
507
  // plugin dir (non-destructive). Discovery scans $HERMES_HOME/plugins/, so
363
508
  // this is belt-and-suspenders for profile-scoped installs.
@@ -394,14 +539,26 @@ function setupHermes(serverUrl, authToken) {
394
539
  // Activation is left to Hermes' own tooling. We NEVER edit config.yaml —
395
540
  // Hermes' `memory setup` discovers this plugin automatically and writes the
396
541
  // config with its own YAML-aware writer (routing the token to .env).
397
- const isRemote = !(serverUrl.includes("127.0.0.1") || serverUrl.includes("localhost"));
398
- console.log(" → Activate with: hermes memory setup (select 'hicortex')");
542
+ // The token is PRINTED here for the remote case (the user's own terminal —
543
+ // same visibility as `hicortex status`); Hermes has no non-interactive
544
+ // setup path, so one paste is the floor. When a user-set URL was KEPT, the
545
+ // token hint names that server's token requirement instead (this install's
546
+ // token belongs to a different server).
547
+ console.log(" → Activate with: hermes memory setup hicortex");
399
548
  if ((0, node_fs_1.existsSync)(profilesDir)) {
400
549
  console.log(" Run once per profile if you use Hermes profiles.");
401
550
  }
402
- if (isRemote) {
403
- console.log(" Remote server: enter the auth token when prompted (stored in $HERMES_HOME/.env).");
551
+ if (keptExistingUrl) {
552
+ console.log(" The kept URL's server needs ITS token run `hicortex status` on that machine if you don't have it.");
553
+ }
554
+ else if (authToken) {
555
+ console.log(` When prompted for the token, paste: ${authToken}`);
556
+ console.log(" (stored by Hermes in $HERMES_HOME/.env — never in the plugin's config.json)");
404
557
  }
558
+ else {
559
+ console.log(" Local server: leave the token blank — localhost bypasses auth.");
560
+ }
561
+ console.log(" Installed Hermes AFTER Hicortex? Re-running `npx @gamaze/hicortex init` redoes this step.");
405
562
  }
406
563
  /**
407
564
  * Parse a KEY=VALUE env file (e.g. ~/.hermes/.env or ~/.claude/settings.json env block).
@@ -1501,6 +1658,10 @@ async function runInit(options = {}) {
1501
1658
  console.log(" • OpenClaw plugin installed");
1502
1659
  if (d.hermesFound)
1503
1660
  console.log(` • Hermes found at ${HERMES_HOME}`);
1661
+ if (d.piFound)
1662
+ console.log(` • Pi found at ${PI_AGENT_DIR}`);
1663
+ if (d.opencodeFound)
1664
+ console.log(` • opencode found at ${OPENCODE_CONFIG_DIR}`);
1504
1665
  if (d.ccMcpRegistered)
1505
1666
  console.log(" • CC MCP already registered");
1506
1667
  if (d.existingDb)
@@ -1532,6 +1693,10 @@ async function runInit(options = {}) {
1532
1693
  actions.push("Register MCP server in CC settings");
1533
1694
  if (d.hermesFound)
1534
1695
  actions.push("Install Hermes plugin + configure");
1696
+ if (d.piFound)
1697
+ actions.push("Install Pi extension");
1698
+ if (d.opencodeFound)
1699
+ actions.push("Install opencode plugin");
1535
1700
  actions.push("Install SessionStart hook (query-time lessons)");
1536
1701
  if (actions.length === 0) {
1537
1702
  console.log("Everything is already configured. Nothing to do.");
@@ -1649,6 +1814,14 @@ async function runInit(options = {}) {
1649
1814
  const isLocal = serverUrl.includes("127.0.0.1") || serverUrl.includes("localhost");
1650
1815
  setupHermes(serverUrl, isLocal ? "" : authToken);
1651
1816
  }
1817
+ // Setup the Pi extension if detected (self-resolving — no config write)
1818
+ if (d.piFound) {
1819
+ setupPi();
1820
+ }
1821
+ // Setup the opencode plugin if detected (self-resolving — no config write)
1822
+ if (d.opencodeFound) {
1823
+ setupOpencode();
1824
+ }
1652
1825
  // Install CC SessionStart hook for query-time lesson injection.
1653
1826
  // Lessons are now fetched live at session start — no static CLAUDE.md block needed.
1654
1827
  installSessionStartHook();
@@ -1768,6 +1941,11 @@ async function runClientInit(serverUrl, agentName) {
1768
1941
  // writeClientConfig: strict-load → apply overrides → save. Throws on a
1769
1942
  // malformed existing config (0.16.x BLOCKER — never wipe the client's
1770
1943
  // authToken/licenseKey). ENOENT → fresh client config.
1944
+ // The PRE-overwrite serverUrl is captured first: it is what init last
1945
+ // wrote into the Hermes plugin's config, so the plugin step can tell an
1946
+ // init-owned URL (update it — re-point must reach Hermes like it reaches
1947
+ // CC's MCP entry) from a user-set one (keep + warn).
1948
+ const previousServerUrl = readPreviousServerUrl(configPath);
1771
1949
  const { config } = writeClientConfig(configPath, { serverUrl, authToken }, nameDecision);
1772
1950
  console.log(` ✓ Client config saved to ${configPath}`);
1773
1951
  if (typeof config.agentName === "string") {
@@ -1824,7 +2002,17 @@ async function runClientInit(serverUrl, agentName) {
1824
2002
  // Step 8: Setup Hermes if detected
1825
2003
  if ((0, node_fs_1.existsSync)(HERMES_HOME)) {
1826
2004
  console.log("\nHermes detected — installing plugin...");
1827
- setupHermes(serverUrl, authToken);
2005
+ setupHermes(serverUrl, authToken, previousServerUrl);
2006
+ }
2007
+ // Step 8b: Setup the Pi extension if detected (self-resolving — no config write)
2008
+ if ((0, node_fs_1.existsSync)(PI_AGENT_DIR)) {
2009
+ console.log("\nPi detected — installing extension...");
2010
+ setupPi();
2011
+ }
2012
+ // Step 8c: Setup the opencode plugin if detected (self-resolving — no config write)
2013
+ if ((0, node_fs_1.existsSync)(OPENCODE_CONFIG_DIR) || (0, node_fs_1.existsSync)(OPENCODE_DATA_DIR)) {
2014
+ console.log("\nopencode detected — installing plugin...");
2015
+ setupOpencode();
1828
2016
  }
1829
2017
  console.log("\n✓ Hicortex client setup complete!\n");
1830
2018
  // Telemetry disclosure at install time (informed consent, best practice):
@@ -0,0 +1,30 @@
1
+ export type LlmFlightGuard = {
2
+ kind: "acquired";
3
+ release: () => void;
4
+ }
5
+ /** Waited past `waitMs` for the holder — the caller treats this as a
6
+ * total-failure-class error (its message contains "timeout" so the retry
7
+ * ladder and the #337 breaker classify it as endpoint-down). */
8
+ | {
9
+ kind: "timeout";
10
+ }
11
+ /** Filesystem refused the lock — proceed unserialized (fail-open). */
12
+ | {
13
+ kind: "open";
14
+ };
15
+ /**
16
+ * Default staleness floor: a lock older than this is stale REGARDLESS of the
17
+ * recorded pid (guards the recycled-pid case, capture A5 fix 2). The caller
18
+ * passes `staleMs = max(this default, 2× llmTimeoutMs)` so an operator who
19
+ * raises the timeout ceiling can never have a LIVE call's lock reclaimed
20
+ * mid-flight (CR #355 finding 3 — a reclaim-while-running is exactly the
21
+ * two-concurrent-calls crash class this guard exists to prevent).
22
+ */
23
+ export declare const DEFAULT_STALE_MS: number;
24
+ /** Lock path for an endpoint key — exported for tests and diagnostics. */
25
+ export declare function llmFlightLockPath(homeDir: string, endpointKey: string): string;
26
+ /**
27
+ * Acquire the single-flight lock for `endpointKey`, waiting up to `waitMs`.
28
+ * Never throws: every filesystem surprise degrades to `{ kind: "open" }`.
29
+ */
30
+ export declare function acquireLlmFlight(homeDir: string, endpointKey: string, waitMs: number, staleMs?: number): Promise<LlmFlightGuard>;
@@ -0,0 +1,190 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.DEFAULT_STALE_MS = void 0;
4
+ exports.llmFlightLockPath = llmFlightLockPath;
5
+ exports.acquireLlmFlight = acquireLlmFlight;
6
+ /**
7
+ * Single-flight guard for LLM endpoint calls (#355).
8
+ *
9
+ * Local single-user model servers (one big-context model on a personal
10
+ * machine) have a standing failure mode: TWO concurrent large-context
11
+ * requests stall or OOM the server — and take the whole machine with it.
12
+ * One Hicortex server is several LLM callers at once: the daemon distills
13
+ * every inbound /distill concurrently, and the nightly's consolidation is a
14
+ * separate OS process on its own timers. This guard makes "never two
15
+ * in-flight requests to the same endpoint" STRUCTURAL instead of a hope
16
+ * that timers do not overlap.
17
+ *
18
+ * Same idiom as capture.ts's capture.lock (A5): O_EXCL create, dead-pid or
19
+ * TTL staleness with a TOCTOU re-race, and FAIL-OPEN — a filesystem refusal
20
+ * logs a loud warning and lets the call proceed unserialized. The guard must
21
+ * never be the reason recall or distillation stops on a healthy endpoint.
22
+ *
23
+ * The lock file lives in the hicortex home, one file per endpoint
24
+ * (sha1 of `provider@baseUrl`), so an install with multiple endpoints
25
+ * serializes within each endpoint, not across them.
26
+ */
27
+ const node_crypto_1 = require("node:crypto");
28
+ const node_fs_1 = require("node:fs");
29
+ const node_path_1 = require("node:path");
30
+ /**
31
+ * Default staleness floor: a lock older than this is stale REGARDLESS of the
32
+ * recorded pid (guards the recycled-pid case, capture A5 fix 2). The caller
33
+ * passes `staleMs = max(this default, 2× llmTimeoutMs)` so an operator who
34
+ * raises the timeout ceiling can never have a LIVE call's lock reclaimed
35
+ * mid-flight (CR #355 finding 3 — a reclaim-while-running is exactly the
36
+ * two-concurrent-calls crash class this guard exists to prevent).
37
+ */
38
+ exports.DEFAULT_STALE_MS = 30 * 60 * 1000;
39
+ /**
40
+ * A lock file younger than this whose holder is unreadable (empty/invalid
41
+ * JSON) is treated as LIVE: its creator is between the O_EXCL create and the
42
+ * writeSync — stealing in that window is the same crash class. Only an
43
+ * unreadable file OLDER than the grace period is junk to reclaim
44
+ * (CR #355 finding 4).
45
+ */
46
+ const GRACE_MS = 2_000;
47
+ /** LLM-scale polling: waits are seconds-to-minutes, not capture-scale. */
48
+ const POLL_MS = 100;
49
+ const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
50
+ /** Warn at most once per process that the guard failed open (#355 fail-open). */
51
+ let warnedOpen = false;
52
+ /** Lock path for an endpoint key — exported for tests and diagnostics. */
53
+ function llmFlightLockPath(homeDir, endpointKey) {
54
+ const hash = (0, node_crypto_1.createHash)("sha1").update(endpointKey).digest("hex").slice(0, 16);
55
+ return (0, node_path_1.join)(homeDir, `llm-flight-${hash}.lock`);
56
+ }
57
+ /**
58
+ * Acquire the single-flight lock for `endpointKey`, waiting up to `waitMs`.
59
+ * Never throws: every filesystem surprise degrades to `{ kind: "open" }`.
60
+ */
61
+ async function acquireLlmFlight(homeDir, endpointKey, waitMs, staleMs = exports.DEFAULT_STALE_MS) {
62
+ const lockPath = llmFlightLockPath(homeDir, endpointKey);
63
+ try {
64
+ (0, node_fs_1.mkdirSync)(homeDir, { recursive: true });
65
+ }
66
+ catch {
67
+ /* best effort — the create below surfaces real problems */
68
+ }
69
+ const deadline = Date.now() + waitMs;
70
+ for (;;) {
71
+ const attempt = tryAcquireOnce(lockPath, endpointKey, staleMs);
72
+ if (attempt.kind !== "busy")
73
+ return attempt;
74
+ if (Date.now() >= deadline)
75
+ return { kind: "timeout" };
76
+ await sleep(Math.max(1, Math.min(POLL_MS, deadline - Date.now())));
77
+ }
78
+ }
79
+ /** One acquire attempt: create-if-free, else reclaim-if-stale. */
80
+ function tryAcquireOnce(lockPath, endpointKey, staleMs) {
81
+ const release = () => {
82
+ try {
83
+ (0, node_fs_1.unlinkSync)(lockPath);
84
+ }
85
+ catch {
86
+ /* already gone */
87
+ }
88
+ };
89
+ const create = () => {
90
+ try {
91
+ const fd = (0, node_fs_1.openSync)(lockPath, "wx"); // O_CREAT | O_EXCL
92
+ // The lease records how long THIS holder may legitimately run
93
+ // (now + staleMs) — a waiter with a smaller budget judges staleness
94
+ // by the recorded lease, never by its own parameter (2nd-review
95
+ // finding 3: a short-timeout waiter must not TTL-reclaim a live
96
+ // long-timeout holder's lock).
97
+ (0, node_fs_1.writeSync)(fd, JSON.stringify({
98
+ pid: process.pid,
99
+ endpoint: endpointKey,
100
+ leaseUntil: Date.now() + staleMs,
101
+ }));
102
+ (0, node_fs_1.closeSync)(fd);
103
+ return true;
104
+ }
105
+ catch (err) {
106
+ if (err.code === "EEXIST")
107
+ return false;
108
+ throw err;
109
+ }
110
+ };
111
+ try {
112
+ if (create())
113
+ return { kind: "acquired", release };
114
+ const holder = readHolder(lockPath);
115
+ if (!isStale(lockPath, holder, staleMs))
116
+ return { kind: "busy" };
117
+ // Stale. Re-verify the holder has not changed (another reclaimer may
118
+ // have taken it), unlink, re-race the O_EXCL create (capture fix 12).
119
+ // Compare by VALUE (pid/endpoint) — readHolder returns a fresh object
120
+ // per call, so reference equality would always differ.
121
+ const recheck = readHolder(lockPath);
122
+ if (recheck?.pid !== holder?.pid ||
123
+ recheck?.endpoint !== holder?.endpoint) {
124
+ return { kind: "busy" };
125
+ }
126
+ try {
127
+ (0, node_fs_1.unlinkSync)(lockPath);
128
+ }
129
+ catch {
130
+ /* raced with another reclaimer */
131
+ }
132
+ return create() ? { kind: "acquired", release } : { kind: "busy" };
133
+ }
134
+ catch {
135
+ // Filesystem refused the lock op entirely — do not wedge LLM traffic on
136
+ // the guard; proceed unserialized (the behaviour before #355).
137
+ if (!warnedOpen) {
138
+ warnedOpen = true;
139
+ console.warn(`[hicortex] LLM single-flight lock unavailable (${lockPath}) — ` +
140
+ `proceeding WITHOUT serialization. This is safe for recall but ` +
141
+ `concurrent LLM calls can stall a single-user local model server.`);
142
+ }
143
+ return { kind: "open" };
144
+ }
145
+ }
146
+ function readHolder(lockPath) {
147
+ try {
148
+ const parsed = JSON.parse((0, node_fs_1.readFileSync)(lockPath, "utf-8"));
149
+ if (typeof parsed.pid === "number" && Number.isFinite(parsed.pid))
150
+ return parsed;
151
+ return null;
152
+ }
153
+ catch {
154
+ return null;
155
+ }
156
+ }
157
+ /** Stale = dead pid, OR past the HOLDER's recorded lease, OR (lease-less
158
+ * lock files) older than the waiter's `staleMs`. An unreadable holder
159
+ * (mid-write or corrupt) is live within the grace period, junk after it. */
160
+ function isStale(lockPath, holder, staleMs) {
161
+ if (!holder) {
162
+ try {
163
+ return Date.now() - (0, node_fs_1.statSync)(lockPath).mtimeMs > GRACE_MS;
164
+ }
165
+ catch {
166
+ return true;
167
+ }
168
+ }
169
+ if (!isProcessAlive(holder.pid))
170
+ return true;
171
+ if (typeof holder.leaseUntil === "number" && Number.isFinite(holder.leaseUntil)) {
172
+ return Date.now() > holder.leaseUntil;
173
+ }
174
+ try {
175
+ return Date.now() - (0, node_fs_1.statSync)(lockPath).mtimeMs > staleMs;
176
+ }
177
+ catch {
178
+ return true;
179
+ }
180
+ }
181
+ function isProcessAlive(pid) {
182
+ try {
183
+ process.kill(pid, 0);
184
+ return true;
185
+ }
186
+ catch (err) {
187
+ // ESRCH = no such process; EPERM = exists but not ours (still alive).
188
+ return err.code === "EPERM";
189
+ }
190
+ }
package/dist/llm.d.ts CHANGED
@@ -48,6 +48,15 @@ export interface LlmConfig {
48
48
  /** TTL the daemon caches a probe outcome for (#337). Default 300000.
49
49
  * See HicortexConfig.llmProbeTtlMs. */
50
50
  probeTtlMs?: number;
51
+ /** Single-flight serialization: at most ONE in-flight LLM request per
52
+ * endpoint, ever (#355). Default true — a correctness property for local
53
+ * single-user model servers (two concurrent large-context calls stall/OOM
54
+ * the server and the machine under it). See HicortexConfig.llmSingleFlight. */
55
+ singleFlight?: boolean;
56
+ /** How long a queued call waits for the in-flight call before failing as
57
+ * endpoint-down (#355). Default 900000 — the same ceiling as llmTimeoutMs.
58
+ * See HicortexConfig.llmSingleFlightWaitMs. */
59
+ singleFlightWaitMs?: number;
51
60
  }
52
61
  /**
53
62
  * Resolve LLM configuration from explicit config-file overrides or
@@ -155,6 +164,14 @@ export interface LlmResult {
155
164
  export declare class LlmCircuitOpenError extends Error {
156
165
  constructor(endpoint: string, cooldownRemainingMs: number);
157
166
  }
167
+ /**
168
+ * The TOTAL-failure class the retry ladder matches (#337, unchanged strings —
169
+ * this is the same matcher the ladder has always used, now also the breaker's
170
+ * definition of "endpoint may be down"). A fast HTTP 500, a parse error, or a
171
+ * rate limit is NOT in this class: those prove the endpoint ANSWERS.
172
+ */
173
+ declare function isTotalFailure(message: string): boolean;
174
+ export { isTotalFailure };
158
175
  export declare class LlmClient {
159
176
  private config;
160
177
  private ollamaCallCount;
@@ -213,6 +230,21 @@ export declare class LlmClient {
213
230
  probe(timeoutMs?: number): Promise<boolean>;
214
231
  private complete;
215
232
  private completeOnce;
233
+ /** The single-flight wait budget for a call with ceiling `timeoutMs`.
234
+ * An explicit `llmSingleFlightWaitMs` wins; otherwise the default is
235
+ * max(900 s, llmTimeoutMs) so a waiter never gives up before a legitimate
236
+ * in-flight call's own (possibly raised) ceiling expires (2nd-review
237
+ * finding 2 — a hardcoded 900 s made a raised-timeout install treat a
238
+ * healthy-busy endpoint as down). */
239
+ private flightWaitMs;
240
+ /** Resolve the flight guard for this call, or undefined when disabled.
241
+ * `staleMs` is derived from THIS call's timeout ceiling (≥ the 30-min
242
+ * floor, 2× timeout) so a raised `llmTimeoutMs` can never get a live
243
+ * call's lock reclaimed mid-flight (CR #355 finding 3). The lock file
244
+ * records its own lease, so reclaim is judged by the HOLDER's lease, not
245
+ * this waiter's parameter (2nd-review finding 3). */
246
+ private acquireFlightGuard;
247
+ private dispatchOnce;
216
248
  /**
217
249
  * Claude CLI: shell out to `claude -p` for subscription users.
218
250
  * No API key needed — uses CC's authenticated session.