@theronap/agnoclast-mcp 0.9.174 → 0.9.176

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.
@@ -58,6 +58,33 @@ if (process.env.CORTEX_SUMMARIZING && CONTEXT_HOOK_COMMANDS.has(cmd)) {
58
58
  process.exit(0)
59
59
  }
60
60
 
61
+ // ADR-0020 Stage 2, amended 2026-09-25 — stamp THIS session's identity on the read-only context
62
+ // hooks. Until now setSessionKey was called ONLY in server.mjs, so a one-shot hook sent no
63
+ // x-cortex-session-key header, /api/mcp-context resolved the person-wide pointer, and the settling
64
+ // feed — which is session-scoped by definition — rendered nothing at session start no matter how
65
+ // many arrivals were asking this session to judge them. 269 units sat in the settle window on the
66
+ // day this was found; the block had been silently empty through three separate attempts to fix it
67
+ // further upstream, because every one of them fixed the server while the HOOK stayed anonymous.
68
+ //
69
+ // Placed at the dispatcher for the same reason the guard above is: it is one rule about what a hook
70
+ // process IS, and putting it in four subcommands is how the fifth gets forgotten.
71
+ //
72
+ // env ONLY, deliberately never stdin: arrivals-notice and hydrate consume stdin themselves with
73
+ // readFileSync(0), which DRAINS the pipe. A read here would starve them of their own payload —
74
+ // fixing one notice by breaking another.
75
+ //
76
+ // hookSessionKey never invents a random id (see its note in session_key.mjs): no id -> no header ->
77
+ // exactly the previous behavior, rather than a key that matches nothing and prints a confident
78
+ // "nothing is waiting for you".
79
+ if (CONTEXT_HOOK_COMMANDS.has(cmd)) {
80
+ const { hookSessionKey } = await import('../lib/session_key.mjs')
81
+ const forSession = hookSessionKey(process.env)
82
+ if (forSession) {
83
+ const { setSessionKey } = await import('../lib/diagnose.mjs')
84
+ setSessionKey(forSession)
85
+ }
86
+ }
87
+
61
88
  if (cmd === '--version' || cmd === '-v') {
62
89
  process.stdout.write(`cortex-mcp ${VERSION}\n`)
63
90
  process.exit(0)
package/lib/diagnose.mjs CHANGED
@@ -542,6 +542,26 @@ export function outageReport(outcome, { tool, requestId = null, version = CLIENT
542
542
  return null
543
543
  }
544
544
 
545
+ // ⚠ "COULD NOT CONNECT" IS ALSO WHAT A LAPTOP WITH NO NETWORK LOOKS LIKE. The first real report (2026-09-25)
546
+ // was ×12 `connect` outages from one Mac between 09:34 and 11:39 — hooks running during DarkWake /
547
+ // Maintenance Sleep with the network half up, while production was healthy. So a thrown connect/timeout
548
+ // is only filed when the INTERNET is reachable and Agnoclast is not. The probe hits a neutral anycast host
549
+ // with its own short budget; if that fails too, the fault is local and nothing is filed. A response of any
550
+ // kind (infra 4xx/5xx, 503) proves the network worked, so those paths never probe.
551
+ const REACHABILITY_URL = 'https://1.1.1.1/cdn-cgi/trace'
552
+ const REACHABILITY_TIMEOUT_MS = 2500
553
+ let reachabilityProbe = async () => {
554
+ try {
555
+ const r = await fetch(REACHABILITY_URL, { method: 'GET', signal: AbortSignal.timeout(REACHABILITY_TIMEOUT_MS) })
556
+ try { await r.body?.cancel() } catch { /* nothing to drain */ }
557
+ return true
558
+ } catch {
559
+ return false
560
+ }
561
+ }
562
+ /** Tests: replace the internet-reachability probe. Returns the previous one. */
563
+ export function setReachabilityProbe(fn) { const prev = reachabilityProbe; reachabilityProbe = fn; return prev }
564
+
545
565
  /** Auto-filing is off under `bun test` unless the test points the spool somewhere (never the real home). */
546
566
  function autofileEnabled(env = process.env) {
547
567
  if (env.CORTEX_AUTOFILE === '0') return false
@@ -601,7 +621,9 @@ export async function fetchCortex(url, opts = {}, policy = {}) {
601
621
  } catch (e) {
602
622
  if (hook && e?.retryClass) {
603
623
  const r = outageReport({ thrownClass: e.retryClass }, { tool: toolForUrl(url) })
604
- if (r) spoolReport(r)
624
+ // Awaited, not fire-and-forget: hook processes are short-lived and would exit before a detached
625
+ // probe answered. Bounded by REACHABILITY_TIMEOUT_MS, and only on a path that is already failing.
626
+ try { if (r && await reachabilityProbe()) spoolReport(r) } catch { /* filing must never change the error */ }
605
627
  }
606
628
  throw e
607
629
  }
package/lib/server.mjs CHANGED
@@ -231,25 +231,13 @@ export async function runServer(version) {
231
231
  const body = await res.text()
232
232
  throw new Error(classify(res.status, res.headers.get('content-type'), body, res.headers.get('x-vercel-id')).message)
233
233
  }
234
- const { context, brainRefs, settling } = await res.json()
235
- // ⭐ THE SETTLING FEED, APPENDED TO THE SESSION-START BLOCK — the thing it has never carried.
236
- //
237
- // Everything else in that block is PERSON-scoped, so /api/mcp-context never needed to know which
238
- // session was asking and never read the `x-cortex-session-key` header it has been receiving on
239
- // every request since fetchCortex started sending it. The settling feed is the only inherently
240
- // SESSION-scoped signal in the block, which is why it was structurally excluded rather than
241
- // forgotten — and why a NEW SESSION has never once been told which arrivals are asking it a
242
- // question. That is the resume case: start a session, and the offers are waiting.
243
- //
244
- // ⚠ NOT CACHED, and the distinction matters. The block is cached for 5 minutes; an OFFER is
245
- // live state that another session can claim out from under this one. Baking it into the cached
246
- // string would serve an offer already taken, or hide one that just arrived — and a feed whose
247
- // whole contract is "this persists until you answer it" cannot afford to be five minutes wrong.
248
- // So the cache keeps the person-scoped context exactly as before, and the session-scoped feed is
249
- // appended only on a FRESH fetch. Session start and resume are always fresh fetches, which is
250
- // the case this exists for; mid-session cache hits are covered by the tools.
234
+ const { context, brainRefs } = await res.json()
235
+ // ⚠ THE FEED IS RENDERED SERVER-SIDE NOW, inside assembleMcpContext, NOT appended here.
236
+ // Appending client-side only reached the MCP tool path: the session-start block is printed by a
237
+ // CLI subcommand that takes `payload.context` verbatim, and the hooks run `cortex-mcp@stable` —
238
+ // six versions behind. Server-side rendering reaches every client including the pinned one.
251
239
  cache = { text: context, ts: now, repo }
252
- const withSettling = (context ?? '') + renderSettling({ settling })
240
+
253
241
  // T11: stash the digest node refs surfaced this fetch so capture.mjs can forward them as
254
242
  // hydrated_from at the session's ingest (feedback-loop guard). Keyed by cwd so the matching
255
243
  // session picks them up. Best-effort + inert when brainRefs is empty (digest flag off).
@@ -261,8 +249,7 @@ export async function runServer(version) {
261
249
  writeFileSync(join(dir, `${key}.json`), JSON.stringify({ refs: brainRefs, ts: now }))
262
250
  }
263
251
  } catch { /* best-effort — never break context serving */ }
264
- // The fresh block carries the offers; the cached one deliberately does not (see above).
265
- return withSettling
252
+ return context
266
253
  }
267
254
 
268
255
  const server = new McpServer({ name: 'cortex', version })
@@ -35,3 +35,27 @@ export function resolveLogSessionId(explicit, env) {
35
35
  if (typeof fromEnv === 'string' && fromEnv.trim()) return fromEnv.trim()
36
36
  return undefined
37
37
  }
38
+
39
+ // Which session a one-shot CONTEXT HOOK process is acting FOR. Pure — see session_key.test.mjs.
40
+ //
41
+ // Third session-id question in this file, and like resolveLogSessionId it is deliberately NOT built
42
+ // on resolveSessionKey. That one answers "which session is this process" for the WRITE POINTER, and
43
+ // may invent a randomUUID() because a write pointer is only ever compared to ITSELF — any stable
44
+ // value works.
45
+ //
46
+ // This key is compared to OTHER sessions' keys: the server uses it to decide which settling offers
47
+ // are asking THIS session to judge an arrival. A random value there matches no offer, so the block
48
+ // renders EMPTY rather than absent — "nothing is waiting for you" when 269 units were. An empty
49
+ // queue and an unidentified process MUST NOT look the same, so there is no random fallback here.
50
+ //
51
+ // So: the conversation id Claude Code names, else NOTHING. Never a random.
52
+ //
53
+ // Why a hook has an identity at all, given the ADR-0020 note in diagnose.mjs says these processes
54
+ // "have no session of their own": true, and unchanged for writes. But a SessionStart hook runs ON
55
+ // BEHALF of a session, and CLAUDE_CODE_SESSION_ID names it. Read-only context commands may therefore
56
+ // speak for it; capture and the other writers still fall back to the account pointer as intended.
57
+ export function hookSessionKey(env) {
58
+ const fromEnv = env?.CLAUDE_CODE_SESSION_ID
59
+ if (typeof fromEnv === 'string' && fromEnv.trim()) return fromEnv.trim()
60
+ return null
61
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@theronap/agnoclast-mcp",
3
- "version": "0.9.174",
3
+ "version": "0.9.176",
4
4
  "description": "Connect your AI assistant to Cortex — your org's projects, activity, gaps, and directives, scoped to you.",
5
5
  "type": "module",
6
6
  "bin": {