@theronap/cortex-mcp 0.9.162 → 0.9.164
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/bin/cortex-mcp.mjs +7 -3
- package/lib/arrivals_notice.mjs +75 -5
- package/lib/editors/claude.mjs +37 -1
- package/package.json +1 -1
package/bin/cortex-mcp.mjs
CHANGED
|
@@ -85,7 +85,7 @@ if (cmd === '--help' || cmd === '-h' || cmd === 'help') {
|
|
|
85
85
|
` docs-scan detect new/changed local docs pending Agnoclast authoring (used by /cortex-author-docs)\n` +
|
|
86
86
|
` graphify-sync [path] [--brain <name-or-id>] rebuild the local code graph + log a timeline event\n` +
|
|
87
87
|
` snapshot-context save the exact startup context Agnoclast served to a local snapshot\n` +
|
|
88
|
-
` arrivals-notice
|
|
88
|
+
` arrivals-notice hook pair: detect incoming-record offers (PostToolUse) and surface them (--emit, UserPromptSubmit)\n` +
|
|
89
89
|
` hydrate UserPromptSubmit hook — inject query-centered context on the first substantive turn\n` +
|
|
90
90
|
` migrate-key [--dry-run] move the MCP config key to its new name, allow rules first\n` +
|
|
91
91
|
` with-token -- <cmd> run <cmd> with your token in its environment (for cron/launchd wrappers)\n` +
|
|
@@ -198,8 +198,12 @@ if (cmd === 'login') {
|
|
|
198
198
|
// RESULTS, which only the agent reads — and an audit on 2026-09-21 found the agent acting on it
|
|
199
199
|
// once in a day. Pure text extraction from the payload it is handed: no network, no token, always
|
|
200
200
|
// exit 0. It reports; it never blocks a tool call.
|
|
201
|
-
|
|
202
|
-
|
|
201
|
+
// Two halves, one subcommand. `--emit` is the UserPromptSubmit side (drain + print); without it
|
|
202
|
+
// this is the PostToolUse side (detect + spool, prints nothing). Neither can do the job alone:
|
|
203
|
+
// PostToolUse sees tool output but its stdout is not injected; UserPromptSubmit is injected but
|
|
204
|
+
// never sees tool output. See lib/arrivals_notice.mjs's header.
|
|
205
|
+
const mod = await import('../lib/arrivals_notice.mjs')
|
|
206
|
+
process.exitCode = rest.includes('--emit') ? await mod.runArrivalsDrain() : await mod.runArrivalsNotice()
|
|
203
207
|
} else if (cmd === 'hydrate') {
|
|
204
208
|
// UserPromptSubmit hook (① discovery): hydrate the model with query-centered Agnoclast context on the
|
|
205
209
|
// FIRST substantive turn, before it answers — then never again this session (topic-shift refresh stays
|
package/lib/arrivals_notice.mjs
CHANGED
|
@@ -13,14 +13,29 @@
|
|
|
13
13
|
// coverage. So this does not ask the agent to be more diligent; it removes the agent from the
|
|
14
14
|
// delivery path.
|
|
15
15
|
//
|
|
16
|
-
// ⚠ IT IS NOT A GATE.
|
|
17
|
-
//
|
|
18
|
-
//
|
|
16
|
+
// ⚠ IT IS NOT A GATE. Exit is always 0: this reports, it never blocks a tool call, and it never
|
|
17
|
+
// fails one. A notifier that can break the thing it observes gets disabled, and then it observes
|
|
18
|
+
// nothing.
|
|
19
|
+
//
|
|
20
|
+
// 🔴 TWO EVENTS, BECAUSE NEITHER ONE CAN DO THIS ALONE. Measured 2026-09-21, after shipping a
|
|
21
|
+
// version that could not work:
|
|
22
|
+
//
|
|
23
|
+
// PostToolUse has the arrivals block — but its stdout is NOT injected into the conversation
|
|
24
|
+
// UserPromptSubmit IS injected (proven) — but never receives tool output at all
|
|
25
|
+
//
|
|
26
|
+
// The first version wired only PostToolUse and printed to stdout. It ran perfectly: correct parse,
|
|
27
|
+
// correct ids, state file written seconds after the block appeared. And NOBODY SAW ANY OF IT —
|
|
28
|
+
// which is precisely the failure this file exists to fix, reproduced by the fix. The assumption came
|
|
29
|
+
// from seeing SessionStart and UserPromptSubmit output every turn and generalising to all hooks;
|
|
30
|
+
// those two are context-INJECTING events by design.
|
|
31
|
+
//
|
|
32
|
+
// So: PostToolUse DETECTS and spools. UserPromptSubmit DRAINS and surfaces. Arrivals appear at the
|
|
33
|
+
// top of the person's next turn, which is when they are actually looking at the screen.
|
|
19
34
|
//
|
|
20
35
|
// ⚠ NO NETWORK, NO TOKEN, NO DECRYPT. Everything printed is already in the payload the hook was
|
|
21
36
|
// handed. That keeps it fast enough to run after every tool call and means it cannot leak anything
|
|
22
37
|
// the agent was not already shown.
|
|
23
|
-
import { readFileSync, writeFileSync, mkdirSync } from 'node:fs'
|
|
38
|
+
import { readFileSync, writeFileSync, mkdirSync, unlinkSync } from 'node:fs'
|
|
24
39
|
import { homedir } from 'node:os'
|
|
25
40
|
import { join } from 'node:path'
|
|
26
41
|
|
|
@@ -108,6 +123,20 @@ export function formatNotice(arrivals) {
|
|
|
108
123
|
return lines.join('\n')
|
|
109
124
|
}
|
|
110
125
|
|
|
126
|
+
/** The handoff between the two hook events. One file per session, drained on the next prompt. */
|
|
127
|
+
function spoolPath(sessionKey) {
|
|
128
|
+
const dir = join(homedir(), '.cortex', 'arrivals-spool')
|
|
129
|
+
try { mkdirSync(dir, { recursive: true }) } catch { /* best effort */ }
|
|
130
|
+
return join(dir, `${(sessionKey || 'nosession').replace(/[^a-zA-Z0-9_-]/g, '_')}.json`)
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/**
|
|
134
|
+
* PostToolUse: detect, de-duplicate, SPOOL. Prints nothing — its stdout goes nowhere a person reads.
|
|
135
|
+
*
|
|
136
|
+
* ⚠ The spool ACCUMULATES across tool calls rather than overwriting. Several tool calls commonly
|
|
137
|
+
* land between two prompts, and only the first carries a given arrival; overwriting would mean the
|
|
138
|
+
* last tool call before the prompt decides what the person sees, which is usually nothing.
|
|
139
|
+
*/
|
|
111
140
|
export async function runArrivalsNotice() {
|
|
112
141
|
try {
|
|
113
142
|
const raw = readStdin()
|
|
@@ -127,7 +156,21 @@ export async function runArrivalsNotice() {
|
|
|
127
156
|
// change, so it is reported whenever present.
|
|
128
157
|
if (fresh.length === 0 && !arrivals.delta) return 0
|
|
129
158
|
|
|
130
|
-
|
|
159
|
+
// Merge into whatever is already spooled for this session.
|
|
160
|
+
const sp = spoolPath(sessionKey)
|
|
161
|
+
let pending = { delta: 0, items: [] }
|
|
162
|
+
try { pending = JSON.parse(readFileSync(sp, 'utf8')) } catch { /* first one this turn */ }
|
|
163
|
+
const byId = new Map((pending.items ?? []).map((it) => [it.id, it]))
|
|
164
|
+
for (const it of fresh) byId.set(it.id, it)
|
|
165
|
+
try {
|
|
166
|
+
writeFileSync(sp, JSON.stringify({
|
|
167
|
+
// The server watermarks the delta, so the freshest sighting is the right one — not a sum,
|
|
168
|
+
// which would double-count the same pile across two tool calls in one turn.
|
|
169
|
+
delta: arrivals.delta || pending.delta || 0,
|
|
170
|
+
items: [...byId.values()].slice(-40),
|
|
171
|
+
}))
|
|
172
|
+
} catch { /* best effort */ }
|
|
173
|
+
|
|
131
174
|
for (const it of fresh) seen.add(it.id)
|
|
132
175
|
saveSeen(p, seen)
|
|
133
176
|
return 0
|
|
@@ -136,3 +179,30 @@ export async function runArrivalsNotice() {
|
|
|
136
179
|
return 0
|
|
137
180
|
}
|
|
138
181
|
}
|
|
182
|
+
|
|
183
|
+
/**
|
|
184
|
+
* UserPromptSubmit: DRAIN the spool and print. This is the half the person actually sees.
|
|
185
|
+
*
|
|
186
|
+
* ⚠ CLEAR THE SPOOL BEFORE PRINTING FAILS, not after. A crash between print and clear would repeat
|
|
187
|
+
* the same arrivals on every subsequent prompt forever, and a notifier that nags is one that gets
|
|
188
|
+
* removed. Losing one notice is recoverable — the record is still unclaimed and will be offered
|
|
189
|
+
* again by the server.
|
|
190
|
+
*/
|
|
191
|
+
export async function runArrivalsDrain() {
|
|
192
|
+
try {
|
|
193
|
+
const raw = readStdin()
|
|
194
|
+
let sessionKey = ''
|
|
195
|
+
try { sessionKey = String(JSON.parse(raw)?.session_id ?? '') } catch { /* fine */ }
|
|
196
|
+
|
|
197
|
+
const sp = spoolPath(sessionKey)
|
|
198
|
+
let pending = null
|
|
199
|
+
try { pending = JSON.parse(readFileSync(sp, 'utf8')) } catch { return 0 }
|
|
200
|
+
try { unlinkSync(sp) } catch { /* best effort */ }
|
|
201
|
+
|
|
202
|
+
if (!pending || (!pending.delta && !(pending.items ?? []).length)) return 0
|
|
203
|
+
process.stdout.write(formatNotice({ delta: pending.delta ?? 0, items: pending.items ?? [] }) + '\n')
|
|
204
|
+
return 0
|
|
205
|
+
} catch {
|
|
206
|
+
return 0
|
|
207
|
+
}
|
|
208
|
+
}
|
package/lib/editors/claude.mjs
CHANGED
|
@@ -114,7 +114,25 @@ export function mergeClaudeSettings(existing, spec) {
|
|
|
114
114
|
// ⚠ Matcher is '' (every tool). The block only appears on three surfaces, so the filtering is
|
|
115
115
|
// server-side and this stays a cheap no-op elsewhere: it reads its stdin, finds no marker, exits 0.
|
|
116
116
|
s.hooks.PostToolUse = Array.isArray(s.hooks.PostToolUse) ? s.hooks.PostToolUse : []
|
|
117
|
-
|
|
117
|
+
// 🔴 THIS HOOK DOES NOT FOLLOW `spec`, AND THE REASON IS A DEFECT THIS FILE SHIPPED ONCE.
|
|
118
|
+
//
|
|
119
|
+
// `spec` pins @stable, which is what desktop-wired seats deliberately follow. But a hook wired at
|
|
120
|
+
// a version that does not HAVE its subcommand does not fail quietly: an older client given an
|
|
121
|
+
// unknown subcommand falls through to the MCP-server default and prints
|
|
122
|
+
// `cortex-mcp: CORTEX_TOKEN is required`. Wired under `spec` on 2026-09-21, when @stable was
|
|
123
|
+
// 0.9.156 and this subcommand landed in 0.9.162, that meant an ERROR after EVERY TOOL CALL —
|
|
124
|
+
// noise rather than silence, which is worse than the feature simply being absent.
|
|
125
|
+
//
|
|
126
|
+
// So the rule, stated because it generalises to the next hook somebody adds: A HOOK MUST NEVER BE
|
|
127
|
+
// WIRED AT A SPEC THAT CANNOT EXECUTE IT. A version floor is the narrowest way to hold that, and
|
|
128
|
+
// npx resolves a semver range (verified 2026-09-21 against a real invocation, not assumed).
|
|
129
|
+
//
|
|
130
|
+
// ⚠ And it uses the CANONICAL name. Every other command here carries `cortex-mcp` for
|
|
131
|
+
// compatibility with frozen launchd/cron scripts on unreachable machines (ADR-0033) — but a
|
|
132
|
+
// BRAND-NEW hook has no frozen scripts to be compatible with, so there is no reason to mint more
|
|
133
|
+
// references to the retiring name. New wiring uses the name the product actually has.
|
|
134
|
+
const ARRIVALS_MIN_VERSION = '0.9.162'
|
|
135
|
+
const arrivalsCmd = markCommand(`npx -y @theronap/agnoclast-mcp@^${ARRIVALS_MIN_VERSION} arrivals-notice`)
|
|
118
136
|
for (const pg of s.hooks.PostToolUse) {
|
|
119
137
|
if (Array.isArray(pg.hooks)) pg.hooks = pg.hooks.filter((h) => !isManagedSubcommand(h.command, 'arrivals-notice'))
|
|
120
138
|
}
|
|
@@ -144,6 +162,24 @@ export function mergeClaudeSettings(existing, spec) {
|
|
|
144
162
|
hgrp.hooks = hgrp.hooks ?? []
|
|
145
163
|
hgrp.hooks.push({ type: 'command', command: hydrateCmd })
|
|
146
164
|
|
|
165
|
+
// UserPromptSubmit — arrivals-notice --emit, THE HALF THE PERSON ACTUALLY SEES.
|
|
166
|
+
//
|
|
167
|
+
// 🔴 THE POSTTOOLUSE HOOK ALONE COULD NOT WORK, and it shipped that way in 0.9.162/0.9.163.
|
|
168
|
+
// Measured 2026-09-21: it ran correctly — right parse, right ids, state file written seconds after
|
|
169
|
+
// the block appeared — and its output reached nobody, because PostToolUse stdout is NOT injected
|
|
170
|
+
// into the conversation. The assumption came from seeing SessionStart and UserPromptSubmit output
|
|
171
|
+
// every turn; those are context-INJECTING events by design and PostToolUse is not one.
|
|
172
|
+
//
|
|
173
|
+
// ⚠ So do not "move" it here — UserPromptSubmit never receives tool output, which is where the
|
|
174
|
+
// arrivals block lives. The two events hold complementary halves: PostToolUse DETECTS and spools
|
|
175
|
+
// to ~/.cortex/arrivals-spool, this one DRAINS and prints. Removing either leaves a hook that runs
|
|
176
|
+
// and reports nothing, which is the exact failure the feature exists to fix.
|
|
177
|
+
const arrivalsEmitCmd = markCommand(`npx -y @theronap/agnoclast-mcp@^${ARRIVALS_MIN_VERSION} arrivals-notice --emit`)
|
|
178
|
+
for (const ug of s.hooks.UserPromptSubmit ?? []) {
|
|
179
|
+
if (Array.isArray(ug.hooks)) ug.hooks = ug.hooks.filter((h) => !isManagedSubcommand(h.command, 'arrivals-notice'))
|
|
180
|
+
}
|
|
181
|
+
hgrp.hooks.push({ type: 'command', command: arrivalsEmitCmd })
|
|
182
|
+
|
|
147
183
|
// PreCompact — REMOVED, and this block is the migration that unwires existing seats.
|
|
148
184
|
//
|
|
149
185
|
// The "author now" reminder wrote to stdout on the assumption the harness surfaced it as context for
|
package/package.json
CHANGED