@theronap/agnoclast-mcp 0.9.176 → 0.9.178
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 +23 -11
- package/lib/arrivals_notice.mjs +87 -174
- package/lib/editors/claude.mjs +28 -21
- package/lib/server.mjs +191 -0
- package/package.json +1 -1
package/bin/cortex-mcp.mjs
CHANGED
|
@@ -112,7 +112,7 @@ if (cmd === '--help' || cmd === '-h' || cmd === 'help') {
|
|
|
112
112
|
` docs-scan detect new/changed local docs pending Agnoclast authoring (used by /cortex-author-docs)\n` +
|
|
113
113
|
` graphify-sync [path] [--brain <name-or-id>] rebuild the local code graph + log a timeline event\n` +
|
|
114
114
|
` snapshot-context save the exact startup context Agnoclast served to a local snapshot\n` +
|
|
115
|
-
` arrivals-notice
|
|
115
|
+
` arrivals-notice --emit: surface incoming-record offers to you (UserPromptSubmit hook)\n` +
|
|
116
116
|
` hydrate UserPromptSubmit hook — inject query-centered context on the first substantive turn\n` +
|
|
117
117
|
` migrate-key [--dry-run] move the MCP config key to its new name, allow rules first\n` +
|
|
118
118
|
` with-token -- <cmd> run <cmd> with your token in its environment (for cron/launchd wrappers)\n` +
|
|
@@ -221,16 +221,28 @@ if (cmd === 'login') {
|
|
|
221
221
|
const { closeFetch } = await import('../lib/diagnose.mjs')
|
|
222
222
|
await closeFetch()
|
|
223
223
|
} else if (cmd === 'arrivals-notice') {
|
|
224
|
-
//
|
|
225
|
-
// RESULTS, which only the agent reads — and an audit on 2026-09-21 found the agent acting on
|
|
226
|
-
// once in a day.
|
|
227
|
-
//
|
|
228
|
-
//
|
|
229
|
-
//
|
|
230
|
-
//
|
|
231
|
-
//
|
|
232
|
-
|
|
233
|
-
|
|
224
|
+
// UserPromptSubmit hook: surface the arrivals offer to the PERSON. The signals are appended to
|
|
225
|
+
// TOOL RESULTS, which only the agent reads — and an audit on 2026-09-21 found the agent acting on
|
|
226
|
+
// it once in a day. One cheap authenticated GET, fail-open, always exit 0. It reports; it never
|
|
227
|
+
// blocks a prompt.
|
|
228
|
+
// ONE half now. `--emit` (UserPromptSubmit) ASKS THE SERVER for the two turn-time signals and
|
|
229
|
+
// prints them. The PostToolUse scraper + spool it replaced could never surface anything before the
|
|
230
|
+
// next UserPromptSubmit anyway, because PostToolUse stdout is not injected — so this delivers at
|
|
231
|
+
// the same moment, from the source. See lib/arrivals_notice.mjs's header.
|
|
232
|
+
// ⚠ WITHOUT --emit THIS IS A DELIBERATE NO-OP, not a fallthrough. That was the PostToolUse half,
|
|
233
|
+
// retired 2026-09-25 along with the spool. A seat whose settings.json still has that hook must do
|
|
234
|
+
// NOTHING here: the live path CONSUMES a watermark, so letting a stale PostToolUse call reach it
|
|
235
|
+
// would announce arrivals into a stdout that is never injected and leave the next prompt — the one
|
|
236
|
+
// the person actually reads — correctly reporting nothing. Deleting a hook from one machine's
|
|
237
|
+
// settings does not delete it from anyone else's, so the old invocation has to stay harmless.
|
|
238
|
+
if (rest.includes('--emit')) {
|
|
239
|
+
const mod = await import('../lib/arrivals_notice.mjs')
|
|
240
|
+
process.exitCode = await mod.runArrivalsDrain()
|
|
241
|
+
const { closeFetch } = await import('../lib/diagnose.mjs')
|
|
242
|
+
await closeFetch()
|
|
243
|
+
} else {
|
|
244
|
+
process.exitCode = 0
|
|
245
|
+
}
|
|
234
246
|
} else if (cmd === 'hydrate') {
|
|
235
247
|
// UserPromptSubmit hook (① discovery): hydrate the model with query-centered Agnoclast context on the
|
|
236
248
|
// FIRST substantive turn, before it answers — then never again this session (topic-shift refresh stays
|
package/lib/arrivals_notice.mjs
CHANGED
|
@@ -1,11 +1,10 @@
|
|
|
1
|
-
//
|
|
1
|
+
// UserPromptSubmit hook — make the arrivals offer visible to the PERSON, not just the agent.
|
|
2
2
|
//
|
|
3
|
-
// 🔴 WHY THIS EXISTS, and it is not a nicety. The
|
|
4
|
-
//
|
|
5
|
-
//
|
|
6
|
-
//
|
|
7
|
-
//
|
|
8
|
-
// had simply been let pass.
|
|
3
|
+
// 🔴 WHY THIS EXISTS, and it is not a nicety. The per-turn delta line and the settling feed are
|
|
4
|
+
// appended to TOOL RESULTS, which only the agent reads. Measured on 2026-09-21 against a single
|
|
5
|
+
// session's own record: the block fired repeatedly through the day and the agent acted on it ONCE,
|
|
6
|
+
// and only because the user asked it to prove the mechanism existed. An audit of the four records
|
|
7
|
+
// it named found one that plainly belonged to that session's own work and had simply been let pass.
|
|
9
8
|
//
|
|
10
9
|
// The failure is structural rather than a lapse: the offer arrives MID-TASK, inside a result the
|
|
11
10
|
// agent requested for an unrelated reason, and nothing forces a decision. A signal that only the
|
|
@@ -13,196 +12,110 @@
|
|
|
13
12
|
// coverage. So this does not ask the agent to be more diligent; it removes the agent from the
|
|
14
13
|
// delivery path.
|
|
15
14
|
//
|
|
16
|
-
// ⚠ IT IS NOT A GATE. Exit is always 0
|
|
17
|
-
//
|
|
18
|
-
// nothing.
|
|
15
|
+
// ⚠ IT IS NOT A GATE. Exit is always 0. A notifier that can break the thing it observes gets
|
|
16
|
+
// disabled, and then it observes nothing.
|
|
19
17
|
//
|
|
20
|
-
// 🔴
|
|
21
|
-
// version that could not work:
|
|
18
|
+
// 🔴 IT ASKS THE SERVER. IT USED TO SCRAPE ITS OWN CONVERSATION. (Rewritten 2026-09-25.)
|
|
22
19
|
//
|
|
23
|
-
//
|
|
24
|
-
//
|
|
20
|
+
// The previous design was a two-hook round trip: PostToolUse read the rendered English off tool
|
|
21
|
+
// results with two regexes, spooled the matches to ~/.cortex/arrivals-spool, and this half drained
|
|
22
|
+
// the spool on the next prompt. The server had computed both numbers and written that text itself —
|
|
23
|
+
// the scraper existed only to read them back out of its own output.
|
|
25
24
|
//
|
|
26
|
-
//
|
|
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.
|
|
25
|
+
// It failed in both directions:
|
|
31
26
|
//
|
|
32
|
-
//
|
|
33
|
-
//
|
|
27
|
+
// MISSED — the signal rode on Agnoclast TOOL RESULTS, so a turn of bash and edits produced none,
|
|
28
|
+
// and nothing arrived no matter how much had actually arrived.
|
|
29
|
+
// INVENTED — any text containing the pattern was scraped as if it were a signal. This agent's own
|
|
30
|
+
// Bash output, while quoting the regex to debug it, was ingested as arrivals.
|
|
34
31
|
//
|
|
35
|
-
//
|
|
36
|
-
//
|
|
37
|
-
//
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
//
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
if (depth > 8 || v == null) return
|
|
54
|
-
if (typeof v === 'string') { yield v; return }
|
|
55
|
-
if (Array.isArray(v)) { for (const x of v) yield* strings(x, depth + 1); return }
|
|
56
|
-
if (typeof v === 'object') { for (const x of Object.values(v)) yield* strings(x, depth + 1) }
|
|
57
|
-
}
|
|
32
|
+
// A parser whose input is the conversation it is printing into cannot distinguish the world from
|
|
33
|
+
// its own echo. So it now asks the one party that knows.
|
|
34
|
+
//
|
|
35
|
+
// ⚠ AND IT COSTS NOTHING IN TIMING, which is the part that looks like a regression and is not.
|
|
36
|
+
// PostToolUse stdout is NOT injected into the conversation — that is measured, and it is why the
|
|
37
|
+
// spool existed at all. The spool could never surface anything before the next UserPromptSubmit.
|
|
38
|
+
// Asking at that same moment delivers at the identical time, from the source, with no state on disk.
|
|
39
|
+
//
|
|
40
|
+
// ⚠ THE NETWORK CALL IS NEW, AND DELIBERATE. The old header promised NO NETWORK, NO TOKEN, because
|
|
41
|
+
// it ran after EVERY TOOL CALL. That event is deleted. This runs once per prompt — the same budget
|
|
42
|
+
// SessionStart already spends — and fails open to silence.
|
|
43
|
+
//
|
|
44
|
+
// ⚠ THE SERVER OWNS THE DEDUPE NOW. arrivalDelta advances session_arrival_watermarks, announcing an
|
|
45
|
+
// arrival to a session exactly once, which is what the local `seen` files were approximating with
|
|
46
|
+
// filesystem state that no other party could see. A consequence worth stating: fetching CONSUMES
|
|
47
|
+
// the announcement, so this prints whatever it gets. Losing one notice is recoverable — the record
|
|
48
|
+
// is still unclaimed and the server will offer it again.
|
|
49
|
+
import { fetchCortex, resolveBase, resolveTokenSource } from './diagnose.mjs'
|
|
58
50
|
|
|
59
51
|
/**
|
|
60
|
-
*
|
|
52
|
+
* Render both turn-time signals for a person skimming their terminal.
|
|
61
53
|
*
|
|
62
|
-
*
|
|
63
|
-
*
|
|
64
|
-
*
|
|
54
|
+
* Deliberately shallow: names, ages and ids, never page contents. The settling half is owner-scoped
|
|
55
|
+
* at the query (private_intake_items joined to the caller's own account), so there is nobody to leak
|
|
56
|
+
* to — but this is a hook that prints unprompted, and a notice that dumps content trains its reader
|
|
57
|
+
* to skip it. Exported for the tests.
|
|
65
58
|
*/
|
|
66
|
-
export function
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
} catch {
|
|
72
|
-
// Not JSON — scan the raw text. Fail open toward reporting rather than staying silent.
|
|
73
|
-
}
|
|
74
|
-
if (!blob) return null
|
|
75
|
-
|
|
76
|
-
const deltaMatch = blob.match(DELTA_RE)
|
|
77
|
-
const delta = deltaMatch ? Number(deltaMatch[1]) : 0
|
|
59
|
+
export function formatNotice(signals) {
|
|
60
|
+
const lines = []
|
|
61
|
+
const offers = signals?.settling?.offers ?? []
|
|
62
|
+
const total = Number(signals?.settling?.total ?? offers.length)
|
|
63
|
+
const delta = Number(signals?.arrival?.count ?? 0)
|
|
78
64
|
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
// Only the section AFTER the marker, so an unrelated bulleted list earlier in a page read is
|
|
82
|
-
// not mistaken for arrivals.
|
|
83
|
-
const section = blob.slice(blob.indexOf(MATCHED))
|
|
84
|
-
for (const m of section.matchAll(ITEM_RE)) {
|
|
85
|
-
items.push({ title: m[1].trim(), source: m[2], age: m[3].trim(), id: m[4] })
|
|
86
|
-
}
|
|
65
|
+
if (delta) {
|
|
66
|
+
lines.push(`⚡ ${delta} new arrival${delta === 1 ? '' : 's'} in the pile since this session was last told.`)
|
|
87
67
|
}
|
|
88
|
-
if (
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
}
|
|
107
|
-
|
|
108
|
-
export function formatNotice(arrivals) {
|
|
109
|
-
const lines = []
|
|
110
|
-
if (arrivals.items.length) {
|
|
111
|
-
lines.push(`⚡ ${arrivals.items.length} record${arrivals.items.length === 1 ? '' : 's'} offered to this session:`)
|
|
112
|
-
for (const it of arrivals.items.slice(0, 6)) {
|
|
113
|
-
lines.push(` · ${it.title.slice(0, 92)} (${it.source} · ${it.age})`)
|
|
114
|
-
lines.push(` ${it.id}`)
|
|
68
|
+
if (offers.length) {
|
|
69
|
+
lines.push(`⚡ ${total} arrival${total === 1 ? '' : 's'} asking THIS session to decide:`)
|
|
70
|
+
// ⚠ EVERY OFFER, GROUPED BY CASCADE STAGE — changed 2026-09-25 from the first SIX.
|
|
71
|
+
//
|
|
72
|
+
// Six of a deterministically-ordered twelve showed the SAME handful every turn while the rest of
|
|
73
|
+
// the pile stayed unreachable, and a session cannot claim what it is never shown. via_reason is
|
|
74
|
+
// still carried through verbatim rather than flattened — a reader who learns the stated reason is
|
|
75
|
+
// unreliable stops reading it — but it is printed ONCE PER GROUP instead of on every row, which
|
|
76
|
+
// is what makes listing all of them fit at all.
|
|
77
|
+
const STAGES = [
|
|
78
|
+
['matched', 'matches your work'],
|
|
79
|
+
['orphan', 'nothing matched them anywhere'],
|
|
80
|
+
['declined', 'every other matching session declined them'],
|
|
81
|
+
]
|
|
82
|
+
for (const [key, why] of STAGES) {
|
|
83
|
+
const group = offers.filter((o) => o.via_reason === key)
|
|
84
|
+
if (!group.length) continue
|
|
85
|
+
lines.push(` ${why} (${group.length}):`)
|
|
86
|
+
for (const o of group) lines.push(` ${o.intakeItemId} ${o.source} · ${o.hoursAgo}h`)
|
|
115
87
|
}
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
lines.push(`⚡ ${arrivals.delta} new arrival${arrivals.delta === 1 ? '' : 's'} in the pile since this session was last told.`)
|
|
88
|
+
// Only when the pile genuinely exceeds the server's scan window; `total` is counted before it.
|
|
89
|
+
if (total > offers.length) lines.push(` … and ${total - offers.length} beyond the scan window`)
|
|
90
|
+
lines.push(' (Claim + route them, or say they are not yours — they stay unclaimed until someone decides.)')
|
|
120
91
|
}
|
|
121
|
-
// Said plainly, because the whole point is that the person can tell whether anything happened.
|
|
122
|
-
if (arrivals.items.length) lines.push(` (Claim + route them, or say they are not yours — they stay unclaimed until someone decides.)`)
|
|
123
92
|
return lines.join('\n')
|
|
124
93
|
}
|
|
125
94
|
|
|
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
|
-
*/
|
|
140
|
-
export async function runArrivalsNotice() {
|
|
141
|
-
try {
|
|
142
|
-
const raw = readStdin()
|
|
143
|
-
if (!raw) return 0
|
|
144
|
-
|
|
145
|
-
let sessionKey = ''
|
|
146
|
-
try { sessionKey = String(JSON.parse(raw)?.session_id ?? '') } catch { /* fine */ }
|
|
147
|
-
|
|
148
|
-
const arrivals = extractArrivals(raw)
|
|
149
|
-
if (!arrivals) return 0
|
|
150
|
-
|
|
151
|
-
const p = seenPath(sessionKey)
|
|
152
|
-
const seen = loadSeen(p)
|
|
153
|
-
const fresh = arrivals.items.filter((it) => !seen.has(it.id))
|
|
154
|
-
|
|
155
|
-
// Nothing new to say. The delta line is server-side watermarked and already fires once per
|
|
156
|
-
// change, so it is reported whenever present.
|
|
157
|
-
if (fresh.length === 0 && !arrivals.delta) return 0
|
|
158
|
-
|
|
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
|
-
|
|
174
|
-
for (const it of fresh) seen.add(it.id)
|
|
175
|
-
saveSeen(p, seen)
|
|
176
|
-
return 0
|
|
177
|
-
} catch {
|
|
178
|
-
// ⚠ ALWAYS 0. This observes; it must never be the reason a tool call failed.
|
|
179
|
-
return 0
|
|
180
|
-
}
|
|
181
|
-
}
|
|
182
|
-
|
|
183
95
|
/**
|
|
184
|
-
* UserPromptSubmit:
|
|
96
|
+
* UserPromptSubmit: ask, then print. This is the half the person actually sees.
|
|
185
97
|
*
|
|
186
|
-
*
|
|
187
|
-
*
|
|
188
|
-
*
|
|
189
|
-
*
|
|
98
|
+
* The session key is not read here: bin/cortex-mcp.mjs stamps it for every CONTEXT_HOOK_COMMAND, so
|
|
99
|
+
* fetchCortex already carries x-cortex-session-key. That also means this no longer touches stdin —
|
|
100
|
+
* which matters, because `hydrate` runs on the SAME event and consumes stdin itself, and two hooks
|
|
101
|
+
* racing to drain one pipe is a bug waiting on a scheduler.
|
|
190
102
|
*/
|
|
191
103
|
export async function runArrivalsDrain() {
|
|
192
104
|
try {
|
|
193
|
-
const
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
process.stdout.write(
|
|
105
|
+
const { token } = resolveTokenSource()
|
|
106
|
+
if (!token) return 0
|
|
107
|
+
const base = resolveBase(process.env.CORTEX_URL)
|
|
108
|
+
const res = await fetchCortex(`${base}/api/session-signals`, {
|
|
109
|
+
headers: { Authorization: `Bearer ${token}` },
|
|
110
|
+
})
|
|
111
|
+
if (!res?.ok) return 0
|
|
112
|
+
const signals = await res.json().catch(() => null)
|
|
113
|
+
if (!signals) return 0
|
|
114
|
+
const out = formatNotice(signals)
|
|
115
|
+
if (out) process.stdout.write(out + '\n')
|
|
204
116
|
return 0
|
|
205
117
|
} catch {
|
|
118
|
+
// Fail open and silent: a prompt must never be blocked or slowed by its own notifier.
|
|
206
119
|
return 0
|
|
207
120
|
}
|
|
208
121
|
}
|
package/lib/editors/claude.mjs
CHANGED
|
@@ -105,22 +105,33 @@ export function mergeClaudeSettings(existing, spec) {
|
|
|
105
105
|
}
|
|
106
106
|
sgrp.hooks.push({ type: 'command', command: snapshotCmd })
|
|
107
107
|
|
|
108
|
-
// PostToolUse — arrivals-notice.
|
|
108
|
+
// PostToolUse — arrivals-notice: RETIRED 2026-09-25. This block now REMOVES it and installs nothing.
|
|
109
109
|
//
|
|
110
|
-
//
|
|
111
|
-
//
|
|
112
|
-
//
|
|
113
|
-
// it once, and an audit of the records it named found one belonging to that session's own work
|
|
114
|
-
// that had simply been let pass. A signal only the agent sees, and reliably scrolls past, LOOKS
|
|
115
|
-
// like coverage while providing none.
|
|
110
|
+
// It existed because the arrivals signals are appended to TOOL RESULTS, which only the agent reads,
|
|
111
|
+
// and an audit on 2026-09-21 found the agent acting on them once in a day. The hook scraped those
|
|
112
|
+
// rendered results with two regexes and spooled the matches for the next prompt to drain.
|
|
116
113
|
//
|
|
117
|
-
//
|
|
118
|
-
//
|
|
119
|
-
// agent
|
|
114
|
+
// Deleted because the scraper could not tell the world from its own echo. It MISSED every turn that
|
|
115
|
+
// called no Agnoclast tool (the signal rides on tool results), and it INVENTED arrivals from any
|
|
116
|
+
// text containing the pattern — including an agent's own Bash output quoting the regex to debug it.
|
|
117
|
+
// The UserPromptSubmit half below now ASKS THE SERVER, which is the only party that knows.
|
|
120
118
|
//
|
|
121
|
-
// ⚠
|
|
122
|
-
//
|
|
119
|
+
// ⚠ AND IT COST NOTHING TO DELETE. PostToolUse stdout is not injected into the conversation — the
|
|
120
|
+
// measured fact that forced the spool to exist — so this hook could never surface anything before
|
|
121
|
+
// the next UserPromptSubmit. Asking there delivers at the identical moment.
|
|
122
|
+
//
|
|
123
|
+
// ⚠ THE FILTER STAYS, AND IS NOW THE POINT. Seats wired before today still have this hook. Left in
|
|
124
|
+
// place it would call a subcommand that is a deliberate no-op — harmless only because bin makes it
|
|
125
|
+
// one. Re-running setup RETIRES it, which is the only reach this package has into an existing
|
|
126
|
+
// settings.json.
|
|
123
127
|
s.hooks.PostToolUse = Array.isArray(s.hooks.PostToolUse) ? s.hooks.PostToolUse : []
|
|
128
|
+
for (const pg of s.hooks.PostToolUse) {
|
|
129
|
+
if (Array.isArray(pg.hooks)) pg.hooks = pg.hooks.filter((h) => !isManagedSubcommand(h.command, 'arrivals-notice'))
|
|
130
|
+
}
|
|
131
|
+
// Drop any group this emptied, so retirement does not leave a bare {matcher:'',hooks:[]} behind.
|
|
132
|
+
s.hooks.PostToolUse = s.hooks.PostToolUse.filter((g) => (g.hooks ?? []).length)
|
|
133
|
+
if (!s.hooks.PostToolUse.length) delete s.hooks.PostToolUse
|
|
134
|
+
|
|
124
135
|
// 🔴 THIS HOOK DOES NOT FOLLOW `spec`, AND THE REASON IS A DEFECT THIS FILE SHIPPED ONCE.
|
|
125
136
|
//
|
|
126
137
|
// `spec` pins @stable, which is what desktop-wired seats deliberately follow. But a hook wired at
|
|
@@ -134,19 +145,15 @@ export function mergeClaudeSettings(existing, spec) {
|
|
|
134
145
|
// WIRED AT A SPEC THAT CANNOT EXECUTE IT. A version floor is the narrowest way to hold that, and
|
|
135
146
|
// npx resolves a semver range (verified 2026-09-21 against a real invocation, not assumed).
|
|
136
147
|
//
|
|
148
|
+
// ⚠ FLOOR RAISED TO 0.9.177 ON 2026-09-25, and it is load-bearing rather than tidy. Below it,
|
|
149
|
+
// `--emit` drains a spool that nothing writes any more: it would exit 0, print nothing, and look
|
|
150
|
+
// exactly like a quiet queue. The floor is what makes that impossible instead of silent.
|
|
151
|
+
//
|
|
137
152
|
// ⚠ And it uses the CANONICAL name. Every other command here carries `cortex-mcp` for
|
|
138
153
|
// compatibility with frozen launchd/cron scripts on unreachable machines (ADR-0033) — but a
|
|
139
154
|
// BRAND-NEW hook has no frozen scripts to be compatible with, so there is no reason to mint more
|
|
140
155
|
// references to the retiring name. New wiring uses the name the product actually has.
|
|
141
|
-
const ARRIVALS_MIN_VERSION = '0.9.
|
|
142
|
-
const arrivalsCmd = markCommand(`npx -y @theronap/agnoclast-mcp@^${ARRIVALS_MIN_VERSION} arrivals-notice`)
|
|
143
|
-
for (const pg of s.hooks.PostToolUse) {
|
|
144
|
-
if (Array.isArray(pg.hooks)) pg.hooks = pg.hooks.filter((h) => !isManagedSubcommand(h.command, 'arrivals-notice'))
|
|
145
|
-
}
|
|
146
|
-
let pgrp = s.hooks.PostToolUse.find((g) => (g.matcher ?? '') === '')
|
|
147
|
-
if (!pgrp) { pgrp = { matcher: '', hooks: [] }; s.hooks.PostToolUse.push(pgrp) }
|
|
148
|
-
pgrp.hooks = pgrp.hooks ?? []
|
|
149
|
-
pgrp.hooks.push({ type: 'command', command: arrivalsCmd })
|
|
156
|
+
const ARRIVALS_MIN_VERSION = '0.9.177'
|
|
150
157
|
|
|
151
158
|
// UserPromptSubmit — hydrate (① discovery). Makes the FIRST context load of a session
|
|
152
159
|
// non-discretionary: without it, hydration is a skill the agent may simply forget, and an agent
|
package/lib/server.mjs
CHANGED
|
@@ -1428,6 +1428,197 @@ function renderNudge(payload) {
|
|
|
1428
1428
|
},
|
|
1429
1429
|
)
|
|
1430
1430
|
|
|
1431
|
+
// ── GMAIL WRITES — V5 / W5 of the Google write-scopes plan ───────────────────────────────────────
|
|
1432
|
+
//
|
|
1433
|
+
// Three tools over the W3 engine and its routes: change labels, save a reply draft, ask to send.
|
|
1434
|
+
// Modelled on search_spam_email and send_imessage, as W5 specifies, and they inherit that pair's two
|
|
1435
|
+
// habits — the 409 "which mailbox?" answer, and saying plainly what was NOT done.
|
|
1436
|
+
//
|
|
1437
|
+
// ⚠ THE OWNER CHECK IS THE SERVER'S, NOT OURS (review D14). Every route resolves the mailbox through
|
|
1438
|
+
// loadOwnerGmailConnectors + canAccessMailbox, so being able to READ an email record in a shared brain
|
|
1439
|
+
// never authorizes a write to it. These tools add no authorization of their own and must not imply any.
|
|
1440
|
+
//
|
|
1441
|
+
// ⚠ DARK UNTIL GOOGLE APPROVES THE WRITE SCOPES. In production GOOGLE_WRITE_SCOPES_LIVE is unset and
|
|
1442
|
+
// every Gmail connector is read-only, so these return the auth/upgrade path. That is correct behaviour,
|
|
1443
|
+
// not something to route around — they ship now so they are ready the day the scopes land.
|
|
1444
|
+
//
|
|
1445
|
+
// ⚠ NEVER SAY "NOTHING CHANGED" ON A FAILURE. modifyMessages chunks by BATCH_LIMIT and loops, and a
|
|
1446
|
+
// throw in a later chunk discards the results already collected — so a 5xx can follow real changes. V4
|
|
1447
|
+
// fixed exactly this lie in the web UI; the wording below is what keeps it fixed here.
|
|
1448
|
+
|
|
1449
|
+
/** ONE place a Gmail write failure becomes agent-readable text, so three tools cannot drift on it. */
|
|
1450
|
+
const gmailWriteError = (res, body, tool) => {
|
|
1451
|
+
const msg = typeof body?.error === 'string' ? body.error : null
|
|
1452
|
+
if (res.status === 409 && Array.isArray(body?.accounts) && body.accounts.length) {
|
|
1453
|
+
return toolError(`More than one connected Gmail account. Call ${tool} again with account: ${body.accounts.join(', ')}`)
|
|
1454
|
+
}
|
|
1455
|
+
if (res.status === 401 || res.status === 403) {
|
|
1456
|
+
return toolError(
|
|
1457
|
+
`${msg ?? 'Gmail refused that write.'} Gmail write access may not be granted on this account yet — ` +
|
|
1458
|
+
`the person can check Connectors in Agnoclast. Nothing was changed.`)
|
|
1459
|
+
}
|
|
1460
|
+
if (res.status >= 500) {
|
|
1461
|
+
return toolError(
|
|
1462
|
+
`${msg ?? `Agnoclast could not finish that (${res.status}).`} ⚠ Part of it MAY already have happened. ` +
|
|
1463
|
+
`Do not call ${tool} again — ask the person to check their mailbox first.`)
|
|
1464
|
+
}
|
|
1465
|
+
return toolError(msg ?? `${tool} failed (${res.status}).`)
|
|
1466
|
+
}
|
|
1467
|
+
|
|
1468
|
+
server.registerTool(
|
|
1469
|
+
'gmail_modify',
|
|
1470
|
+
{
|
|
1471
|
+
title: 'Change labels on the user\'s own Gmail messages',
|
|
1472
|
+
description:
|
|
1473
|
+
'Mark read or unread, archive, move back to the inbox, or add/remove a label on messages in the USER\'S OWN connected Gmail. Only the mailbox owner can act on a message — being able to read an email record in a shared brain does NOT authorize changing it, and the server enforces that. Identify messages with recordIds (Agnoclast email record ids) or messageIds (Gmail ids); they must all be in ONE mailbox. For add_label/remove_label pass labelId — one of the user\'s own labels (Label_…), or STARRED or IMPORTANT; call without labelId to get the list. THIS CHANGES THE USER\'S MAILBOX: confirm what they want before calling unless they just asked for it plainly. move_to_inbox is the undo for archive.',
|
|
1474
|
+
inputSchema: {
|
|
1475
|
+
action: z.enum(['mark_read', 'mark_unread', 'archive', 'move_to_inbox', 'add_label', 'remove_label'])
|
|
1476
|
+
.describe('what to do to every message named; move_to_inbox undoes archive'),
|
|
1477
|
+
recordIds: z.array(z.string()).optional().describe('Agnoclast email record ids, all in the same mailbox'),
|
|
1478
|
+
messageIds: z.array(z.string()).optional().describe('Gmail message ids, all in the same mailbox — an alternative to recordIds'),
|
|
1479
|
+
labelId: z.string().optional().describe('required for add_label / remove_label: one of the user\'s own labels (Label_…), or STARRED or IMPORTANT. Omit to be shown the available labels.'),
|
|
1480
|
+
account: z.string().email().optional().describe('which connected Gmail account, when the user has more than one'),
|
|
1481
|
+
},
|
|
1482
|
+
},
|
|
1483
|
+
async ({ action, recordIds, messageIds, labelId, account }) => {
|
|
1484
|
+
if (!recordIds?.length && !messageIds?.length) {
|
|
1485
|
+
return toolError('Name the messages to change: recordIds (Agnoclast email records) or messageIds (Gmail ids).')
|
|
1486
|
+
}
|
|
1487
|
+
// A label action with no id is not an error worth bouncing — answer it with the list, so the agent
|
|
1488
|
+
// can act on the reply instead of guessing an id or asking the person for one.
|
|
1489
|
+
if ((action === 'add_label' || action === 'remove_label') && !labelId) {
|
|
1490
|
+
const lr = await fetchCortex(`${BASE}/api/gmail/labels${account ? `?account=${encodeURIComponent(account)}` : ''}`,
|
|
1491
|
+
{ headers: { Authorization: `Bearer ${TOKEN}` } })
|
|
1492
|
+
const lb = await lr.json().catch(() => null)
|
|
1493
|
+
if (!lr.ok) return gmailWriteError(lr, lb, 'gmail_modify')
|
|
1494
|
+
const labels = Array.isArray(lb?.labels) ? lb.labels : []
|
|
1495
|
+
if (!labels.length) return toolError(`${lb?.account ?? 'That mailbox'} has no labels you can add or remove. Nothing was changed.`)
|
|
1496
|
+
return toolError(
|
|
1497
|
+
`labelId is required for ${action}. Labels in ${lb.account}:\n` +
|
|
1498
|
+
labels.map((l) => `- ${l.name} → ${l.id}${l.type === 'system' ? ' (system)' : ''}`).join('\n') +
|
|
1499
|
+
`\nCall gmail_modify again with one of those ids. Nothing was changed.`)
|
|
1500
|
+
}
|
|
1501
|
+
const res = await fetchCortex(`${BASE}/api/gmail/messages/modify`, {
|
|
1502
|
+
method: 'POST',
|
|
1503
|
+
headers: { Authorization: `Bearer ${TOKEN}`, 'Content-Type': 'application/json' },
|
|
1504
|
+
body: JSON.stringify({
|
|
1505
|
+
action,
|
|
1506
|
+
...(labelId ? { labelId } : {}),
|
|
1507
|
+
...(account ? { account } : {}),
|
|
1508
|
+
...(recordIds?.length ? { recordIds } : { messageIds }),
|
|
1509
|
+
}),
|
|
1510
|
+
})
|
|
1511
|
+
const body = await res.json().catch(() => null)
|
|
1512
|
+
if (!res.ok) return gmailWriteError(res, body, 'gmail_modify')
|
|
1513
|
+
// Per-message results, because a batch can partly fail and the person needs to know which.
|
|
1514
|
+
const results = Array.isArray(body?.results) ? body.results : []
|
|
1515
|
+
const failed = results.filter((r) => r && r.ok === false)
|
|
1516
|
+
const changed = results.length - failed.length
|
|
1517
|
+
const lines = [`${action} in ${body.account}: ${changed} of ${results.length} message${results.length === 1 ? '' : 's'} changed.`]
|
|
1518
|
+
if (failed.length) {
|
|
1519
|
+
lines.push(`⚠ ${failed.length} did NOT change:`)
|
|
1520
|
+
for (const f of failed.slice(0, 10)) lines.push(`- ${f.id}: ${f.error ?? 'unknown error'}`)
|
|
1521
|
+
if (failed.length > 10) lines.push(`- …and ${failed.length - 10} more`)
|
|
1522
|
+
}
|
|
1523
|
+
return { content: [{ type: 'text', text: lines.join('\n') }] }
|
|
1524
|
+
},
|
|
1525
|
+
)
|
|
1526
|
+
|
|
1527
|
+
server.registerTool(
|
|
1528
|
+
'draft_email_reply',
|
|
1529
|
+
{
|
|
1530
|
+
title: 'Save a reply as a draft in the user\'s Gmail',
|
|
1531
|
+
description:
|
|
1532
|
+
'Write a reply to one of the USER\'S OWN emails and save it as a DRAFT in their Gmail, threaded under the original. NOTHING IS SENT — the user reviews and sends it themselves from Gmail, so this is the safe way to help with a reply. Identify the email by recordId (an Agnoclast email record) or messageId (a Gmail id). Only the mailbox owner can draft against a message; the server enforces that. Use request_send_email instead when the user wants it actually sent.',
|
|
1533
|
+
inputSchema: {
|
|
1534
|
+
recordId: z.string().optional().describe('the Agnoclast email record to reply to'),
|
|
1535
|
+
messageId: z.string().optional().describe('the Gmail message id to reply to — an alternative to recordId'),
|
|
1536
|
+
text: z.string().min(1).describe('the reply body, as plain text'),
|
|
1537
|
+
account: z.string().email().optional().describe('which connected Gmail account, when the user has more than one'),
|
|
1538
|
+
},
|
|
1539
|
+
},
|
|
1540
|
+
async ({ recordId, messageId, text, account }) => {
|
|
1541
|
+
if (!recordId && !messageId) return toolError('Name the email to reply to: recordId or messageId.')
|
|
1542
|
+
const res = await fetchCortex(`${BASE}/api/gmail/drafts`, {
|
|
1543
|
+
method: 'POST',
|
|
1544
|
+
headers: { Authorization: `Bearer ${TOKEN}`, 'Content-Type': 'application/json' },
|
|
1545
|
+
body: JSON.stringify({ ...(recordId ? { recordId } : { messageId }), text, ...(account ? { account } : {}) }),
|
|
1546
|
+
})
|
|
1547
|
+
const body = await res.json().catch(() => null)
|
|
1548
|
+
if (!res.ok) return gmailWriteError(res, body, 'draft_email_reply')
|
|
1549
|
+
return { content: [{ type: 'text', text:
|
|
1550
|
+
`Draft saved in ${body.account} — NOT sent. The user reviews and sends it from Gmail: ${body.openUrl}` }] }
|
|
1551
|
+
},
|
|
1552
|
+
)
|
|
1553
|
+
|
|
1554
|
+
// request_send_email — the one tool here that can put mail in front of a person's contacts.
|
|
1555
|
+
//
|
|
1556
|
+
// ⚠ D5: THIS POST IS NOT IDEMPOTENT AND MUST NEVER BE RETRIED. `idempotent: false` is passed
|
|
1557
|
+
// EXPLICITLY rather than relying on fetchCortex's GET/HEAD-only default, so that widening the default
|
|
1558
|
+
// later cannot silently make sending an email retriable. A 5xx here may mean the mail went out.
|
|
1559
|
+
//
|
|
1560
|
+
// ⚠ THE reviewUrl CONTRACT (the V5 hand-off documented in send-requests/route.ts). The route OMITS the
|
|
1561
|
+
// key when there is nothing to approve, so PRESENCE is the signal and `in` must not be used. Present →
|
|
1562
|
+
// pending, and the sentence below is relayed verbatim. ABSENT → the send rule allowed it and the mail
|
|
1563
|
+
// has ALREADY GONE; never invent a link or claim an approval is pending in that case.
|
|
1564
|
+
server.registerTool(
|
|
1565
|
+
'request_send_email',
|
|
1566
|
+
{
|
|
1567
|
+
title: 'Ask to send an email from the user\'s Gmail',
|
|
1568
|
+
description:
|
|
1569
|
+
'Send an email from the USER\'S OWN connected Gmail. By default this does NOT send immediately: it creates a request that WAITS for the user to approve it in Agnoclast, and it goes out at once only if their own send rule allows it. The message is frozen when the request is made, so write the final wording. Always show the user what you are about to send and get their agreement first. Returns either an approval link to relay to them, or confirmation that it already went. ⚠ NEVER call this twice for the same email — it is not idempotent, and a retry can send twice.',
|
|
1570
|
+
inputSchema: {
|
|
1571
|
+
to: z.array(z.string()).min(1).describe('recipient email addresses'),
|
|
1572
|
+
subject: z.string().min(1).describe('the subject line'),
|
|
1573
|
+
text: z.string().min(1).describe('the message body, as plain text'),
|
|
1574
|
+
cc: z.array(z.string()).optional().describe('cc addresses'),
|
|
1575
|
+
bcc: z.array(z.string()).optional().describe('bcc addresses'),
|
|
1576
|
+
replyToRecordId: z.string().optional().describe('reply in the thread of this Agnoclast email record'),
|
|
1577
|
+
replyToMessageId: z.string().optional().describe('reply in the thread of this Gmail message id'),
|
|
1578
|
+
account: z.string().email().optional().describe('which connected Gmail account to send from, when the user has more than one'),
|
|
1579
|
+
},
|
|
1580
|
+
},
|
|
1581
|
+
async ({ to, subject, text, cc, bcc, replyToRecordId, replyToMessageId, account }) => {
|
|
1582
|
+
let res
|
|
1583
|
+
try {
|
|
1584
|
+
res = await fetchCortex(`${BASE}/api/gmail/send-requests`, {
|
|
1585
|
+
method: 'POST',
|
|
1586
|
+
headers: { Authorization: `Bearer ${TOKEN}`, 'Content-Type': 'application/json' },
|
|
1587
|
+
body: JSON.stringify({
|
|
1588
|
+
to, subject, text,
|
|
1589
|
+
...(cc?.length ? { cc } : {}), ...(bcc?.length ? { bcc } : {}),
|
|
1590
|
+
...(replyToRecordId ? { replyToRecordId } : {}),
|
|
1591
|
+
...(replyToMessageId ? { replyToMessageId } : {}),
|
|
1592
|
+
...(account ? { account } : {}),
|
|
1593
|
+
}),
|
|
1594
|
+
}, { idempotent: false, retries: 0 })
|
|
1595
|
+
} catch (e) {
|
|
1596
|
+
// No response at all: the request may still have reached the server. Say so and stop.
|
|
1597
|
+
return toolError(
|
|
1598
|
+
`Could not confirm the send request: ${e.message} ⚠ It may have been created anyway. ` +
|
|
1599
|
+
`Do NOT call request_send_email again — ask the user to check Email in Agnoclast.`)
|
|
1600
|
+
}
|
|
1601
|
+
const body = await res.json().catch(() => null)
|
|
1602
|
+
if (!res.ok) return gmailWriteError(res, body, 'request_send_email')
|
|
1603
|
+
|
|
1604
|
+
// PRESENCE, not `in` — the route omits the key rather than sending null (pinned by
|
|
1605
|
+
// src/send_request_verbs_test.ts). Absent means it already sent.
|
|
1606
|
+
const reviewUrl = body?.reviewUrl
|
|
1607
|
+
const acct = body?.request?.account ?? 'your Gmail'
|
|
1608
|
+
const n = body?.request?.recipientCount ?? to.length
|
|
1609
|
+
if (reviewUrl) {
|
|
1610
|
+
// Relayed VERBATIM — this exact sentence is the contract the create route records.
|
|
1611
|
+
return { content: [{ type: 'text', text:
|
|
1612
|
+
`Send request created from ${acct} to ${n} recipient${n === 1 ? '' : 's'}. It is NOT sent yet. ` +
|
|
1613
|
+
`Tell the user, in these words: It's waiting for your OK: ${reviewUrl}` }] }
|
|
1614
|
+
}
|
|
1615
|
+
const state = body?.request?.state ?? 'sent'
|
|
1616
|
+
return { content: [{ type: 'text', text:
|
|
1617
|
+
`Sent from ${acct} to ${n} recipient${n === 1 ? '' : 's'} — the user's send rule allowed it, so there is ` +
|
|
1618
|
+
`nothing to approve (state: ${state}). Do not offer an approval link; the email has already gone.` }] }
|
|
1619
|
+
},
|
|
1620
|
+
)
|
|
1621
|
+
|
|
1431
1622
|
server.registerTool(
|
|
1432
1623
|
'grep',
|
|
1433
1624
|
{
|
package/package.json
CHANGED