consult-mcp 0.6.1 → 1.0.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.
- package/README.md +44 -10
- package/dist/cli.js +155 -10
- package/dist/consult.js +18 -0
- package/dist/doctor.js +87 -0
- package/dist/hook.js +11 -0
- package/dist/hooks-install.js +53 -10
- package/dist/install.js +36 -6
- package/dist/narrate.js +1 -1
- package/dist/platform.js +129 -0
- package/dist/review.js +140 -0
- package/dist/run.js +45 -19
- package/dist/server.js +53 -21
- package/dist/session-tools.js +10 -4
- package/dist/sessions/inbox.js +27 -2
- package/dist/sessions/service.js +10 -4
- package/dist/sessions/wire.js +12 -0
- package/dist/tui.js +9 -4
- package/package.json +3 -2
package/README.md
CHANGED
|
@@ -4,7 +4,14 @@
|
|
|
4
4
|
|
|
5
5
|
consult is an MCP server that lets Claude Code, Codex and Grok ask each other, and Google's Antigravity CLI, for advice through the CLIs already signed in on your machine. No API keys: each advisor runs on the subscription its CLI already uses.
|
|
6
6
|
|
|
7
|
-
Claude Code on Opus can ask Codex on GPT-6 Astra to review a plan, Codex can ask Grok why a test is flaky, or any of them can put one question to all the others at once.
|
|
7
|
+
Claude Code on Opus can ask Codex on GPT-6 Astra to review a plan, Codex can ask Grok why a test is flaky, or any of them can put one question to all the others at once. Before calling work done, an agent can have the other vendors [review its diff](#review-your-changes) in one call, and so can you, from the terminal:
|
|
8
|
+
|
|
9
|
+
```bash
|
|
10
|
+
npx -y consult-mcp@latest review # every installed advisor reviews your uncommitted changes
|
|
11
|
+
npx -y consult-mcp@latest review --base main
|
|
12
|
+
```
|
|
13
|
+
|
|
14
|
+
With [live sessions](#live-sessions) on, agents can also ask the sessions you already have running and message them, and with [task dispatch](#task-dispatch) on, hand a coding task to a Codex or Grok worker and follow its progress.
|
|
8
15
|
|
|
9
16
|
[consult.n3el.dev](https://consult.n3el.dev) · [npm](https://www.npmjs.com/package/consult-mcp)
|
|
10
17
|
|
|
@@ -26,7 +33,11 @@ To remove it:
|
|
|
26
33
|
npx -y consult-mcp@latest uninstall
|
|
27
34
|
```
|
|
28
35
|
|
|
29
|
-
Requirements: Node 22 or later
|
|
36
|
+
Requirements: Node 22 or later on macOS, Linux or Windows, and at least one of the `claude`, `codex` or `grok` CLIs, signed in.
|
|
37
|
+
|
|
38
|
+
On Windows, everything except [live sessions](#live-sessions) and [session messages](#session-messages) works: those find sessions with `ps` and `lsof` and deliver messages through POSIX shell hooks, so they stay off there. consult never runs an advisor through `cmd.exe`, which would reinterpret characters like `&` and `%` in a question: for a CLI installed as an npm `.cmd` shim it runs the script or binary the shim points at directly.
|
|
39
|
+
|
|
40
|
+
Check the setup with `npx -y consult-mcp@latest doctor`. It reports each agent CLI and its version, whether consult is registered with it, your settings, the message hooks and whether agy's config is read-only, with a fix for each problem. `doctor --ask` also sends each advisor a one-word question, which shows that its CLI is signed in and can answer.
|
|
30
41
|
|
|
31
42
|
<details>
|
|
32
43
|
<summary>What install changes</summary>
|
|
@@ -37,7 +48,7 @@ Requirements: Node 22 or later, macOS or Linux, and at least one of the `claude`
|
|
|
37
48
|
|
|
38
49
|
Each entry sets `PATH` to the directories holding the launcher (`npx` or `node`) and the agent CLIs, because some hosts start MCP servers with a minimal environment.
|
|
39
50
|
|
|
40
|
-
With [session messages](#session-messages) on, install also adds
|
|
51
|
+
With [session messages](#session-messages) on, install also adds consult's hooks to each CLI's own hook file: `~/.claude/settings.json`, `~/.codex/hooks.json` and `~/.grok/hooks/consult.json`. Other hooks in those files are left as they are, and `uninstall` or turning messages off removes consult's. The server started through `npx consult-mcp@latest` also rewrites consult's own Claude Code entries when they differ from the current release's, so hook fixes reach you without reinstalling.
|
|
41
52
|
|
|
42
53
|
To pin to a specific build instead of following `@latest`, install globally with `npm i -g consult-mcp` and run `consult install`, or run `node dist/cli.js install` from a checkout. Hosts then launch that exact script.
|
|
43
54
|
</details>
|
|
@@ -54,6 +65,8 @@ To pin to a specific build instead of following `@latest`, install globally with
|
|
|
54
65
|
|
|
55
66
|
**`ask_agents`** `{agents, question, effort?, cwd?}` asks several advisors the same question in parallel and returns `{results: [...]}`, one entry per advisor. Each entry is either an answer with its own `session_id` or an `error`. One advisor failing doesn't fail the others.
|
|
56
67
|
|
|
68
|
+
**`review_changes`** `{agents, base?, focus?, effort?, cwd?}` gets a code review of the git changes in `cwd` from each advisor in parallel. See [Review your changes](#review-your-changes).
|
|
69
|
+
|
|
57
70
|
**`list_agents`** shows the installed CLIs, their versions and the model IDs each account can use.
|
|
58
71
|
|
|
59
72
|
**`list_sessions`** and **`ask_session`** `{session_id, question, effort?}` appear when [live sessions](#live-sessions) are on.
|
|
@@ -75,6 +88,25 @@ The server's MCP instructions, which hosts add to the agent's context, tell it t
|
|
|
75
88
|
|
|
76
89
|
The instructions also tell it to prefer an advisor from a different vendor, to write self-contained questions (the advisor can read the repo but not the conversation), and to check advice against the code before acting on it.
|
|
77
90
|
|
|
91
|
+
## Review your changes
|
|
92
|
+
|
|
93
|
+
consult collects the diff itself and sends it to each advisor with review instructions: find real bugs, regressions, security problems and broken contracts with the rest of the code, give severity, `file:line`, the failing scenario and a fix, skip style nits, and say so plainly when nothing is worth fixing. Advisors read the surrounding code to check their findings before reporting them, and stay read-only like any other advisor.
|
|
94
|
+
|
|
95
|
+
- Without `base`, it reviews everything not yet committed: staged and unstaged changes against `HEAD`, plus the names of new untracked files, which advisors read directly.
|
|
96
|
+
- With `base` (e.g. `main`), it reviews the branch since it left `base`, including uncommitted work (`git diff --merge-base main`).
|
|
97
|
+
- `focus` adds what to look at especially, e.g. `"error handling in the retry path"`.
|
|
98
|
+
|
|
99
|
+
It always reviews the whole repository, even from a subdirectory, and advisors run at the repository root. The question reaches each CLI as one command-line argument, so the diff is cut to fit: up to about 110 KB on Linux, 190 KB on macOS and 25 KB on Windows, whose command lines are shortest. The advisors are told to read the changed files for the rest. External diff, textconv, fsmonitor and clean/process filter commands are off, so collecting the diff runs nothing but git.
|
|
100
|
+
|
|
101
|
+
From the shell, `consult review` uses every advisor CLI on your `PATH` unless you pass `--agents codex,grok`:
|
|
102
|
+
|
|
103
|
+
```bash
|
|
104
|
+
consult review --base main --focus "the new caching layer"
|
|
105
|
+
consult review --agents codex --effort high > review.md
|
|
106
|
+
```
|
|
107
|
+
|
|
108
|
+
Progress goes to stderr and the reviews to stdout, one section per advisor, each with the command for a follow-up question in the same conversation. The command exits 1 only if every advisor failed.
|
|
109
|
+
|
|
78
110
|
## Live sessions
|
|
79
111
|
|
|
80
112
|
Off by default. When on, agents can ask the agent sessions you already have running, not only fresh advisors. For example, the Codex session working on your API can ask the Claude Code session that wrote the client what it changed.
|
|
@@ -109,15 +141,15 @@ Off by default, and only available with live sessions on. `ask_session` asks a c
|
|
|
109
141
|
|
|
110
142
|
- **`send_message`** `{session_id, message}` leaves a message of up to 4,000 characters for a session from `list_sessions`. The receiving agent reads it in its own conversation, marked with the sending agent, session id, directory and branch, and with a note that it comes from a peer agent and is not an instruction from you. It can reply with its own `send_message`.
|
|
111
143
|
|
|
112
|
-
Delivery uses each CLI's hooks, which `consult install` adds when messages are on:
|
|
144
|
+
Delivery uses each CLI's hooks, which `consult install` adds when messages are on, and for Codex its own `codex queue` command:
|
|
113
145
|
|
|
114
|
-
| CLI |
|
|
115
|
-
| --- | --- |
|
|
116
|
-
| claude |
|
|
117
|
-
| codex |
|
|
118
|
-
| grok | after its next tool call
|
|
146
|
+
| CLI | While it is working | While it is idle |
|
|
147
|
+
| --- | --- | --- |
|
|
148
|
+
| claude | read after its next tool call or when it finishes its turn | woken within a few seconds: a background hook (`asyncRewake`) started at the end of each turn waits for a message |
|
|
149
|
+
| codex | read as its next prompt, after the current turn | woken at once: the message is queued with `codex queue` and starts a turn. If that command fails, the message waits for Codex's hooks, which only run once you have trusted them |
|
|
150
|
+
| grok | read after its next tool call or when it finishes its turn | not woken: Grok has no way to start a turn from outside, so it reads the message after you prompt it |
|
|
119
151
|
|
|
120
|
-
|
|
152
|
+
Waking a session starts a turn there without you, which uses that session's tokens and lets its agent act on the message. Nothing tells the sender that a message was read. A session without the hook still gets its messages with the reply to its next `list_sessions`, `ask_session` or `send_message` call. Unread messages are dropped after 24 hours; a session holds at most 20, and one session can send another at most 10 an hour.
|
|
121
153
|
|
|
122
154
|
Turn it on when you install, or later:
|
|
123
155
|
|
|
@@ -185,6 +217,8 @@ The same calls are available from the shell, which is handy for debugging:
|
|
|
185
217
|
consult ask codex --model gpt-6-astra "Is the retry logic in src/sync.ts safe under concurrent calls?"
|
|
186
218
|
consult ask codex --resume <session_id> "What would you change first?"
|
|
187
219
|
consult ask grok - < question.md # long questions from a file or pipe
|
|
220
|
+
consult review --base main # every installed advisor reviews the branch
|
|
221
|
+
consult doctor --ask # check the setup and that each advisor answers
|
|
188
222
|
consult agents
|
|
189
223
|
consult settings # live sessions, messages, task dispatch, and which agents use consult
|
|
190
224
|
```
|
package/dist/cli.js
CHANGED
|
@@ -4,16 +4,20 @@ import { fileURLToPath } from "node:url";
|
|
|
4
4
|
import { parseArgs } from "node:util";
|
|
5
5
|
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
|
6
6
|
import { agy } from "./adapters/agy.js";
|
|
7
|
+
import { homedir } from "node:os";
|
|
7
8
|
import { claude } from "./adapters/claude.js";
|
|
8
9
|
import { codex, codexWorker } from "./adapters/codex.js";
|
|
9
10
|
import { grok, grokWorker } from "./adapters/grok.js";
|
|
10
11
|
import { listAgents } from "./agents.js";
|
|
11
12
|
import { createConsult } from "./consult.js";
|
|
12
13
|
import { progressLog } from "./narrate.js";
|
|
13
|
-
import { hookReply } from "./hook.js";
|
|
14
|
-
import {
|
|
15
|
-
import {
|
|
14
|
+
import { hookReply, rewakeText } from "./hook.js";
|
|
15
|
+
import { diagnose, formatChecks } from "./doctor.js";
|
|
16
|
+
import { applyHooks, hasConsultHook } from "./hooks-install.js";
|
|
17
|
+
import { findOnPath, install, isRegistered, PACKAGE_NAME, serverCommand, uninstall } from "./install.js";
|
|
16
18
|
import { DEFAULT_SETTINGS, loadSettings, messagesOn, saveSettings, sessionsMode, settingsPath, withMessagesEnabled, withSessionsMode, } from "./settings.js";
|
|
19
|
+
import { collectChanges, reviewQuestion } from "./review.js";
|
|
20
|
+
import { sessionsSupported, SESSIONS_UNSUPPORTED } from "./platform.js";
|
|
17
21
|
import { run } from "./run.js";
|
|
18
22
|
import { createServer } from "./server.js";
|
|
19
23
|
import { claim, inboxDir } from "./sessions/inbox.js";
|
|
@@ -34,6 +38,11 @@ const USAGE = `consult ${VERSION} - let coding agents ask each other for advice
|
|
|
34
38
|
ask an advisor; it can read --cwd but never change anything
|
|
35
39
|
--model <id> --effort low|medium|high --resume <session_id> --cwd <dir>
|
|
36
40
|
a question of "-" is read from stdin; progress goes to stderr, the answer alone to stdout
|
|
41
|
+
consult review [--agents a,b] [--base <branch>] [--focus <text>] [--effort low|medium|high] [--cwd <dir>]
|
|
42
|
+
review the uncommitted changes (or the branch since --base) with
|
|
43
|
+
every installed advisor in parallel; findings go to stdout
|
|
44
|
+
consult doctor [--ask] check node, the agent CLIs, registrations, settings and hooks;
|
|
45
|
+
--ask also sends each advisor a one-word question to prove it is signed in
|
|
37
46
|
consult agents list installed CLIs and models
|
|
38
47
|
consult install [--sessions off|repo|machine] [--messages on|off]
|
|
39
48
|
register the MCP server with claude, codex and grok
|
|
@@ -82,6 +91,7 @@ async function serve() {
|
|
|
82
91
|
askFork: (target, question, effort, signal) => askSession(target, question, effort, withShutdown(signal)),
|
|
83
92
|
warn: (message) => process.stderr.write(`consult: ${message}\n`),
|
|
84
93
|
});
|
|
94
|
+
refreshClaudeHooks();
|
|
85
95
|
const tasks = tasksEnabled()
|
|
86
96
|
? createTaskManager({
|
|
87
97
|
run,
|
|
@@ -97,6 +107,7 @@ async function serve() {
|
|
|
97
107
|
},
|
|
98
108
|
listAgents: () => listAgents(run),
|
|
99
109
|
defaultCwd: process.cwd(),
|
|
110
|
+
collectChanges: (cwd, base, signal) => collectChanges(run, cwd, base, withShutdown(signal)),
|
|
100
111
|
...(sessions ? { sessions } : {}),
|
|
101
112
|
...(tasks ? { tasks } : {}),
|
|
102
113
|
});
|
|
@@ -145,7 +156,7 @@ async function askCommand(argv) {
|
|
|
145
156
|
model: values.model,
|
|
146
157
|
effort: values.effort,
|
|
147
158
|
sessionId: values.resume,
|
|
148
|
-
},
|
|
159
|
+
}, interruptSignal(), progress.update);
|
|
149
160
|
}
|
|
150
161
|
finally {
|
|
151
162
|
progress.stop();
|
|
@@ -153,12 +164,107 @@ async function askCommand(argv) {
|
|
|
153
164
|
process.stdout.write(`${result.answer}\n`);
|
|
154
165
|
process.stderr.write(`\n[${result.agent}${result.model ? ` ${result.model}` : ""} · session ${result.sessionId} · ${(result.durationMs / 1000).toFixed(1)}s]\n`);
|
|
155
166
|
}
|
|
167
|
+
/** Every advisor CLI on PATH, for commands that default to "all of them". */
|
|
168
|
+
const installedAgents = () => AGENTS.filter((agent) => findOnPath(agent, process.env.PATH ?? ""));
|
|
169
|
+
function parseAgents(list) {
|
|
170
|
+
if (list === undefined) {
|
|
171
|
+
const found = installedAgents();
|
|
172
|
+
if (found.length === 0)
|
|
173
|
+
throw new Error(`no advisor CLI found on PATH (${AGENTS.join(", ")})`);
|
|
174
|
+
return found;
|
|
175
|
+
}
|
|
176
|
+
const agents = [...new Set(list.split(",").map((a) => a.trim()).filter(Boolean))];
|
|
177
|
+
const unknown = agents.filter((a) => !AGENTS.includes(a));
|
|
178
|
+
if (agents.length === 0 || unknown.length > 0)
|
|
179
|
+
throw new Error(`--agents must be a comma-separated list of ${AGENTS.join(", ")}`);
|
|
180
|
+
return agents;
|
|
181
|
+
}
|
|
182
|
+
/** `value` quoted so a pasted command takes it literally: single quotes for POSIX shells, double on Windows. */
|
|
183
|
+
const shellLiteral = (value) => process.platform === "win32" ? `"${value.replaceAll('"', '""')}"` : `'${value.replaceAll("'", "'\\''")}'`;
|
|
184
|
+
/** Aborts on Ctrl-C or SIGTERM, so advisors in their own process groups stop with the command. */
|
|
185
|
+
function interruptSignal() {
|
|
186
|
+
const controller = new AbortController();
|
|
187
|
+
for (const sig of ["SIGINT", "SIGTERM"]) {
|
|
188
|
+
process.once(sig, () => {
|
|
189
|
+
controller.abort();
|
|
190
|
+
process.exitCode = 130;
|
|
191
|
+
});
|
|
192
|
+
}
|
|
193
|
+
return controller.signal;
|
|
194
|
+
}
|
|
195
|
+
async function reviewCommand(argv) {
|
|
196
|
+
const { values } = parseArgs({
|
|
197
|
+
args: [...argv],
|
|
198
|
+
options: { agents: { type: "string" }, base: { type: "string" }, focus: { type: "string" }, effort: { type: "string" }, cwd: { type: "string" } },
|
|
199
|
+
});
|
|
200
|
+
if (values.effort && !EFFORTS.includes(values.effort))
|
|
201
|
+
throw new Error(`effort must be one of ${EFFORTS.join(", ")}`);
|
|
202
|
+
const agents = parseAgents(values.agents);
|
|
203
|
+
const { ask } = consult();
|
|
204
|
+
const signal = interruptSignal();
|
|
205
|
+
const changes = await collectChanges(run, values.cwd ?? process.cwd(), values.base, signal);
|
|
206
|
+
const question = reviewQuestion(changes, values.focus);
|
|
207
|
+
const pending = new Set(agents);
|
|
208
|
+
const progress = progressLog((line) => process.stderr.write(line), () => `waiting on ${[...pending].join(", ")}`, HEARTBEAT_MS);
|
|
209
|
+
progress.update(`reviewing ${changes.base ? `changes since ${changes.base}` : "uncommitted changes"} with ${agents.join(", ")} (read-only)`);
|
|
210
|
+
let entries;
|
|
211
|
+
try {
|
|
212
|
+
entries = await Promise.all(agents.map(async (agent) => {
|
|
213
|
+
try {
|
|
214
|
+
const request = { agent, question, cwd: changes.root, effort: values.effort };
|
|
215
|
+
const result = await ask(request, signal, (text) => progress.update(`${agent}: ${text}`));
|
|
216
|
+
progress.update(`${agent} finished in ${(result.durationMs / 1000).toFixed(0)}s`);
|
|
217
|
+
return { agent, result };
|
|
218
|
+
}
|
|
219
|
+
catch (err) {
|
|
220
|
+
progress.update(`${agent} failed`);
|
|
221
|
+
return { agent, error: errorMessage(err) };
|
|
222
|
+
}
|
|
223
|
+
finally {
|
|
224
|
+
pending.delete(agent);
|
|
225
|
+
}
|
|
226
|
+
}));
|
|
227
|
+
}
|
|
228
|
+
finally {
|
|
229
|
+
progress.stop();
|
|
230
|
+
}
|
|
231
|
+
const sections = entries.map((e) => "result" in e && e.result
|
|
232
|
+
? `## ${e.agent}${e.result.model ? ` (${e.result.model})` : ""}\n\n${e.result.answer}\n\n[session ${e.result.sessionId}: consult ask ${e.agent} --resume ${e.result.sessionId} --cwd ${shellLiteral(changes.root)} "..."]`
|
|
233
|
+
: `## ${e.agent}\n\nFailed: ${"error" in e ? e.error : "unknown error"}`);
|
|
234
|
+
process.stdout.write(`${sections.join("\n\n")}\n`);
|
|
235
|
+
if (entries.every((e) => "error" in e))
|
|
236
|
+
process.exitCode = 1;
|
|
237
|
+
}
|
|
238
|
+
async function doctorCommand(argv) {
|
|
239
|
+
const { values } = parseArgs({ args: [...argv], options: { ask: { type: "boolean" } } });
|
|
240
|
+
const { ask } = consult();
|
|
241
|
+
const checks = await diagnose({
|
|
242
|
+
nodeVersion: process.version,
|
|
243
|
+
agents: await listAgents(run),
|
|
244
|
+
isRegistered: (agent) => isRegistered(run, agent),
|
|
245
|
+
loadSettings: () => loadSettings(settingsPath()),
|
|
246
|
+
hasMessageHook: (agent) => hasConsultHook(agent),
|
|
247
|
+
sessionsSupported: sessionsSupported(),
|
|
248
|
+
agyContainment: async () => {
|
|
249
|
+
await agy.prepare?.(run, process.cwd());
|
|
250
|
+
},
|
|
251
|
+
...(values.ask ? { ping: (agent) => ask({ agent, question: "Reply with the single word OK.", cwd: homedir(), effort: "low" }) } : {}),
|
|
252
|
+
});
|
|
253
|
+
process.stdout.write(`consult ${VERSION}\n\n${formatChecks(checks)}\n`);
|
|
254
|
+
if (checks.some((c) => c.status === "fail"))
|
|
255
|
+
process.exitCode = 1;
|
|
256
|
+
}
|
|
156
257
|
const installedHosts = () => HOSTS.filter((agent) => findOnPath(agent, process.env.PATH ?? ""));
|
|
157
258
|
const scriptPath = () => realpathSync(fileURLToPath(import.meta.url));
|
|
158
259
|
const searchPath = () => process.env.PATH ?? "";
|
|
159
260
|
const errorMessage = (err) => (err instanceof Error ? err.message : String(err));
|
|
160
261
|
/** Applies --sessions, or asks in a terminal. With neither, the setting stays as it is. */
|
|
161
262
|
async function configureSessions(flag) {
|
|
263
|
+
if (!sessionsSupported()) {
|
|
264
|
+
if (flag && flag !== "off")
|
|
265
|
+
process.stderr.write(`consult: ${SESSIONS_UNSUPPORTED}\n`);
|
|
266
|
+
return;
|
|
267
|
+
}
|
|
162
268
|
const path = settingsPath();
|
|
163
269
|
let current = DEFAULT_SETTINGS;
|
|
164
270
|
try {
|
|
@@ -177,6 +283,8 @@ async function configureSessions(flag) {
|
|
|
177
283
|
}
|
|
178
284
|
/** Applies --messages, or asks in a terminal while live sessions are on. With neither, the setting stays as it is. */
|
|
179
285
|
async function configureMessages(flag) {
|
|
286
|
+
if (!sessionsSupported())
|
|
287
|
+
return false;
|
|
180
288
|
const path = settingsPath();
|
|
181
289
|
let current;
|
|
182
290
|
try {
|
|
@@ -199,20 +307,52 @@ async function configureMessages(flag) {
|
|
|
199
307
|
return messagesOn(next);
|
|
200
308
|
}
|
|
201
309
|
/** Puts the message hook in `agents` and removes it from every other installed CLI. Reports only what changed. */
|
|
202
|
-
function setMessageHooks(
|
|
203
|
-
|
|
310
|
+
function setMessageHooks(wanted) {
|
|
311
|
+
// The hooks are POSIX shell; on Windows there is only ever something to remove.
|
|
312
|
+
const agents = sessionsSupported() ? wanted : [];
|
|
313
|
+
const cmd = agents.length > 0 ? serverCommand(scriptPath(), searchPath()) : undefined;
|
|
204
314
|
const hosts = installedHosts();
|
|
205
315
|
return [
|
|
206
|
-
...applyHooks(hosts.filter((agent) => agents.includes(agent)),
|
|
316
|
+
...applyHooks(hosts.filter((agent) => agents.includes(agent)), cmd),
|
|
207
317
|
...applyHooks(hosts.filter((agent) => !agents.includes(agent)), undefined),
|
|
208
318
|
].filter((r) => r.changed || !r.ok);
|
|
209
319
|
}
|
|
320
|
+
/**
|
|
321
|
+
* Hook commands are written into the hosts' settings at install time, so a fix to one would only reach users who
|
|
322
|
+
* reinstall. Each server start rewrites consult's own Claude Code entries where they already are; Codex's are
|
|
323
|
+
* left alone because changing them makes Codex ask the user to trust them again.
|
|
324
|
+
*/
|
|
325
|
+
function refreshClaudeHooks() {
|
|
326
|
+
try {
|
|
327
|
+
if (!sessionsSupported() || !messagesOn(loadSettings(settingsPath())) || !hasConsultHook("claude"))
|
|
328
|
+
return;
|
|
329
|
+
const cmd = serverCommand(scriptPath(), searchPath());
|
|
330
|
+
// Only a server started through `npx consult-mcp@latest`, as install sets up; a checkout or a pinned global
|
|
331
|
+
// install must not repoint the user's hooks at itself.
|
|
332
|
+
if (cmd.args.includes(`${PACKAGE_NAME}@latest`))
|
|
333
|
+
applyHooks(["claude"], cmd);
|
|
334
|
+
}
|
|
335
|
+
catch {
|
|
336
|
+
// Reinstalling fixes it; the server works either way.
|
|
337
|
+
}
|
|
338
|
+
}
|
|
210
339
|
/** Never fails and never prints anything but a hook reply: the host runs this on every tool call. */
|
|
211
|
-
async function hookCommandMain() {
|
|
340
|
+
async function hookCommandMain(argv) {
|
|
212
341
|
try {
|
|
213
342
|
if (!messagesOn(loadSettings(settingsPath())))
|
|
214
343
|
return;
|
|
215
|
-
const
|
|
344
|
+
const payload = JSON.parse(await readStdin());
|
|
345
|
+
const take = (sessionId) => claim(inboxDir(), sessionId);
|
|
346
|
+
if (argv.includes("--rewake")) {
|
|
347
|
+
// Claude Code wakes an idle session when its background hook exits 2, and shows it what stderr holds.
|
|
348
|
+
const text = rewakeText(payload, take);
|
|
349
|
+
if (!text)
|
|
350
|
+
return;
|
|
351
|
+
process.stderr.write(`${text}\n`);
|
|
352
|
+
process.exitCode = 2;
|
|
353
|
+
return;
|
|
354
|
+
}
|
|
355
|
+
const reply = hookReply(payload, take);
|
|
216
356
|
if (reply)
|
|
217
357
|
process.stdout.write(`${reply}\n`);
|
|
218
358
|
}
|
|
@@ -235,6 +375,10 @@ async function main(argv) {
|
|
|
235
375
|
return serve();
|
|
236
376
|
case "ask":
|
|
237
377
|
return askCommand(rest);
|
|
378
|
+
case "doctor":
|
|
379
|
+
return doctorCommand(rest);
|
|
380
|
+
case "review":
|
|
381
|
+
return reviewCommand(rest);
|
|
238
382
|
case "agents":
|
|
239
383
|
process.stdout.write(`${JSON.stringify(await listAgents(run), null, 2)}\n`);
|
|
240
384
|
return;
|
|
@@ -255,7 +399,7 @@ async function main(argv) {
|
|
|
255
399
|
return;
|
|
256
400
|
}
|
|
257
401
|
case "hook":
|
|
258
|
-
return hookCommandMain();
|
|
402
|
+
return hookCommandMain(rest);
|
|
259
403
|
case "settings": {
|
|
260
404
|
if (!isInteractive()) {
|
|
261
405
|
throw new Error("consult settings needs an interactive terminal; in scripts use `consult install --sessions=off|repo|machine`");
|
|
@@ -270,6 +414,7 @@ async function main(argv) {
|
|
|
270
414
|
register: (agents) => install(run, cmd, agents),
|
|
271
415
|
unregister: (agents) => uninstall(run, agents),
|
|
272
416
|
setMessageHooks,
|
|
417
|
+
sessionsSupported: sessionsSupported(),
|
|
273
418
|
});
|
|
274
419
|
return;
|
|
275
420
|
}
|
package/dist/consult.js
CHANGED
|
@@ -1,8 +1,25 @@
|
|
|
1
1
|
import { statSync } from "node:fs";
|
|
2
2
|
import { describeLine } from "./narrate.js";
|
|
3
|
+
import { commandLineSize, maxCommandLine, resolveForSpawn } from "./platform.js";
|
|
3
4
|
import { frameForkPrompt, framePrompt } from "./prompt.js";
|
|
4
5
|
import { AdvisorError, } from "./types.js";
|
|
5
6
|
const STDERR_TAIL = 2000;
|
|
7
|
+
/** The question reaches each CLI as one argument, so it must fit the platform's command-line limit. */
|
|
8
|
+
function assertFitsCommandLine(agent, invocation) {
|
|
9
|
+
// Measured as it will be spawned: on Windows a .cmd shim becomes node plus the script path.
|
|
10
|
+
let spawned = invocation;
|
|
11
|
+
try {
|
|
12
|
+
spawned = resolveForSpawn(invocation.command, invocation.args);
|
|
13
|
+
}
|
|
14
|
+
catch {
|
|
15
|
+
// run() reports the missing CLI.
|
|
16
|
+
}
|
|
17
|
+
const size = commandLineSize(spawned.command, spawned.args);
|
|
18
|
+
const max = maxCommandLine();
|
|
19
|
+
if (size > max) {
|
|
20
|
+
throw new AdvisorError(`the question for ${agent} is too long (${Math.round(size / 1000)} KB, limit ${Math.round(max / 1000)} KB on this platform): point the advisor at files instead of pasting them`);
|
|
21
|
+
}
|
|
22
|
+
}
|
|
6
23
|
export function assertDirectory(cwd) {
|
|
7
24
|
let isDir = false;
|
|
8
25
|
try {
|
|
@@ -45,6 +62,7 @@ export function createConsult({ run, adapters, timeoutMs }) {
|
|
|
45
62
|
// Computed per call, never cached: containment must reflect the config as it is right now.
|
|
46
63
|
const extraArgs = adapter.prepare ? await adapter.prepare(run, request.cwd, signal) : [];
|
|
47
64
|
const invocation = invoke(extraArgs);
|
|
65
|
+
assertFitsCommandLine(adapter.name, invocation);
|
|
48
66
|
const { describe } = adapter;
|
|
49
67
|
const lines = [];
|
|
50
68
|
const onStdoutLine = onUpdate && describe
|
package/dist/doctor.js
ADDED
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
import { messagesOn } from "./settings.js";
|
|
2
|
+
import { HOSTS } from "./types.js";
|
|
3
|
+
const MIN_NODE_MAJOR = 22;
|
|
4
|
+
const INSTALL_FIX = "run `npx -y consult-mcp@latest install`";
|
|
5
|
+
const errorMessage = (err) => (err instanceof Error ? err.message : String(err));
|
|
6
|
+
const isHost = (agent) => HOSTS.includes(agent);
|
|
7
|
+
function nodeCheck(version) {
|
|
8
|
+
const major = Number(/^v?(\d+)/.exec(version)?.[1]);
|
|
9
|
+
return major >= MIN_NODE_MAJOR
|
|
10
|
+
? { status: "ok", label: `node ${version}` }
|
|
11
|
+
: { status: "fail", label: `node ${version}`, fix: `install Node ${MIN_NODE_MAJOR} or later` };
|
|
12
|
+
}
|
|
13
|
+
function settingsChecks(deps, hosts) {
|
|
14
|
+
let settings;
|
|
15
|
+
try {
|
|
16
|
+
settings = deps.loadSettings();
|
|
17
|
+
}
|
|
18
|
+
catch (err) {
|
|
19
|
+
return [{ status: "fail", label: "settings file invalid", detail: errorMessage(err), fix: "fix or delete the file, then run `consult settings`" }];
|
|
20
|
+
}
|
|
21
|
+
const features = [
|
|
22
|
+
`live sessions ${settings.sessions.enabled ? settings.sessions.scope : "off"}`,
|
|
23
|
+
`messages ${messagesOn(settings) ? "on" : "off"}`,
|
|
24
|
+
`task dispatch ${settings.tasks.enabled ? "on" : "off"}`,
|
|
25
|
+
];
|
|
26
|
+
const summary = { status: "ok", label: "settings", detail: features.join(", ") };
|
|
27
|
+
if (deps.sessionsSupported === false) {
|
|
28
|
+
return settings.sessions.enabled
|
|
29
|
+
? [summary, { status: "warn", label: "live sessions and messages are off on this platform", fix: "they need macOS or Linux" }]
|
|
30
|
+
: [summary];
|
|
31
|
+
}
|
|
32
|
+
if (!messagesOn(settings))
|
|
33
|
+
return [summary];
|
|
34
|
+
const hooks = hosts.map((agent) => deps.hasMessageHook(agent)
|
|
35
|
+
? { status: "ok", label: `${agent} message hook installed` }
|
|
36
|
+
: { status: "fail", label: `${agent} message hook missing`, fix: INSTALL_FIX });
|
|
37
|
+
return [summary, ...hooks];
|
|
38
|
+
}
|
|
39
|
+
async function pingCheck(ping, agent) {
|
|
40
|
+
try {
|
|
41
|
+
const result = await ping(agent);
|
|
42
|
+
const detail = [...(result.model ? [result.model] : []), `${(result.durationMs / 1000).toFixed(1)}s`].join(", ");
|
|
43
|
+
return { status: "ok", label: `${agent} answered`, detail };
|
|
44
|
+
}
|
|
45
|
+
catch (err) {
|
|
46
|
+
return { status: "fail", label: `${agent} did not answer`, detail: errorMessage(err), fix: `check that \`${agent}\` works and is signed in when you run it yourself` };
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
/** Checks the setup consult depends on: node, the agent CLIs, registrations, settings, hooks and agy's containment. */
|
|
50
|
+
export async function diagnose(deps) {
|
|
51
|
+
const installed = deps.agents.filter((a) => a.installed).map((a) => a.agent);
|
|
52
|
+
const agentChecks = deps.agents.map((a) => a.installed ? { status: "ok", label: `${a.agent} ${a.version ?? ""}`.trim() } : { status: "warn", label: `${a.agent} not installed` });
|
|
53
|
+
if (installed.length === 0) {
|
|
54
|
+
return [nodeCheck(deps.nodeVersion), ...agentChecks, { status: "fail", label: "no agent CLI installed", fix: "install and sign in to claude, codex, grok or agy" }];
|
|
55
|
+
}
|
|
56
|
+
const hosts = installed.filter(isHost);
|
|
57
|
+
// `consult settings` can leave a host out on purpose, so only a setup with no host at all is a failure.
|
|
58
|
+
const registered = (await Promise.all(hosts.map(async (agent) => ((await deps.isRegistered(agent)) ? [agent] : [])))).flat();
|
|
59
|
+
const registrations = hosts.map((agent) => registered.includes(agent)
|
|
60
|
+
? { status: "ok", label: `${agent} registered` }
|
|
61
|
+
: { status: registered.length === 0 ? "fail" : "warn", label: `${agent} not registered`, fix: INSTALL_FIX });
|
|
62
|
+
let agyOk = true;
|
|
63
|
+
const agy = [];
|
|
64
|
+
if (installed.includes("agy")) {
|
|
65
|
+
try {
|
|
66
|
+
await deps.agyContainment();
|
|
67
|
+
agy.push({ status: "ok", label: "agy is read-only" });
|
|
68
|
+
}
|
|
69
|
+
catch (err) {
|
|
70
|
+
agyOk = false;
|
|
71
|
+
agy.push({ status: "warn", label: "agy is not read-only", detail: errorMessage(err), fix: "consult refuses to run agy until this is fixed; the other advisors are unaffected" });
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
const { ping } = deps;
|
|
75
|
+
const pings = ping ? await Promise.all(installed.filter((a) => a !== "agy" || agyOk).map((a) => pingCheck(ping, a))) : [];
|
|
76
|
+
return [nodeCheck(deps.nodeVersion), ...agentChecks, ...registrations, ...settingsChecks(deps, registered), ...agy, ...pings];
|
|
77
|
+
}
|
|
78
|
+
const MARKS = { ok: "✓", warn: "!", fail: "✗" };
|
|
79
|
+
export function formatChecks(checks) {
|
|
80
|
+
const lines = checks.flatMap((c) => [
|
|
81
|
+
`${MARKS[c.status]} ${c.label}${c.detail ? `: ${c.detail}` : ""}`,
|
|
82
|
+
...(c.fix && c.status !== "ok" ? [` → ${c.fix}`] : []),
|
|
83
|
+
]);
|
|
84
|
+
const problems = checks.filter((c) => c.status === "fail").length;
|
|
85
|
+
const summary = problems === 0 ? "No problems found." : `${problems} problem${problems === 1 ? "" : "s"} found.`;
|
|
86
|
+
return `${lines.join("\n")}\n\n${summary}`;
|
|
87
|
+
}
|
package/dist/hook.js
CHANGED
|
@@ -5,6 +5,17 @@ function eventOf(payload) {
|
|
|
5
5
|
const raw = text(payload.hook_event_name) ?? text(payload.hookEventName);
|
|
6
6
|
return raw ? EVENTS[raw.toLowerCase().replaceAll("_", "")] : undefined;
|
|
7
7
|
}
|
|
8
|
+
/** For the idle watcher: this session's waiting messages as plain text, whatever event started the watcher. */
|
|
9
|
+
export function rewakeText(payload, claim) {
|
|
10
|
+
if (typeof payload !== "object" || payload === null)
|
|
11
|
+
return undefined;
|
|
12
|
+
const event = payload;
|
|
13
|
+
const sessionId = text(event.session_id) ?? text(event.sessionId);
|
|
14
|
+
if (!sessionId || event.agent_id || event.agentId)
|
|
15
|
+
return undefined;
|
|
16
|
+
const messages = claim(sessionId);
|
|
17
|
+
return messages.length > 0 ? frameMessages(messages) : undefined;
|
|
18
|
+
}
|
|
8
19
|
/**
|
|
9
20
|
* What `consult hook` prints for one host hook event: this session's waiting messages in the host's reply
|
|
10
21
|
* format, or nothing. Claude Code and Codex send snake_case fields; Grok sends camelCase ones as well.
|
package/dist/hooks-install.js
CHANGED
|
@@ -4,6 +4,9 @@ import { dirname, join } from "node:path";
|
|
|
4
4
|
/** Marks consult's handlers in hook files that other tools also write to. */
|
|
5
5
|
const MARKER = "CONSULT_HOOK=1";
|
|
6
6
|
const TIMEOUT_SEC = 10;
|
|
7
|
+
/** How long Claude Code lets the idle watcher wait for a message before it stops it. */
|
|
8
|
+
const WATCH_TIMEOUT_SEC = 24 * 60 * 60;
|
|
9
|
+
const WATCH_POLL_SEC = 3;
|
|
7
10
|
/** Grok discards what a UserPromptSubmit hook prints, so it gets no hook there. */
|
|
8
11
|
const EVENTS = {
|
|
9
12
|
claude: ["UserPromptSubmit", "PostToolUse", "Stop"],
|
|
@@ -24,6 +27,28 @@ export function hookCommand(cmd) {
|
|
|
24
27
|
"done; exit 0",
|
|
25
28
|
].join(" ");
|
|
26
29
|
}
|
|
30
|
+
/**
|
|
31
|
+
* For Claude Code's `asyncRewake`: started in the background when a turn ends, it waits for a message and then
|
|
32
|
+
* exits 2 with the message on stderr, which wakes the idle session. One watcher runs per session, and it stops
|
|
33
|
+
* when the session's process is gone. Headless sessions get none. Grok also reads Claude Code's hook file but runs every handler in the
|
|
34
|
+
* foreground, so the command leaves at once when the event comes from Grok.
|
|
35
|
+
*/
|
|
36
|
+
export function watchCommand(cmd) {
|
|
37
|
+
const launcher = [cmd.command, ...cmd.args.slice(0, -1), "hook", "--rewake"].map(shellQuote).join(" ");
|
|
38
|
+
return [
|
|
39
|
+
// Headless runs (claude -p, the Agent SDK) wait for background hooks before they exit, so the watcher would
|
|
40
|
+
// keep them alive. They can't be woken anyway.
|
|
41
|
+
`i=$(cat); case "$CLAUDE_CODE_ENTRYPOINT" in sdk-*) exit 0;; esac; case "$i" in *'"hookEventName"'*) exit 0;; esac;`,
|
|
42
|
+
`s=$(printf '%s' "$i" | sed -n 's/.*"session_id" *: *"\\([A-Za-z0-9-]*\\)".*/\\1/p'); [ -n "$s" ] || exit 0;`,
|
|
43
|
+
'b="${XDG_STATE_HOME:-$HOME/.local/state}/consult"; w="$b/watch-$s.pid";',
|
|
44
|
+
'p=$(cat "$w" 2>/dev/null); [ -n "$p" ] && kill -0 "$p" 2>/dev/null && exit 0;',
|
|
45
|
+
'mkdir -p "$b"; echo $$ > "$w";',
|
|
46
|
+
"while kill -0 $PPID 2>/dev/null; do",
|
|
47
|
+
`if [ -d "$b/inbox/$s" ]; then printf '%s' "$i" | ${MARKER} PATH=${shellQuote(cmd.path)}:"$PATH" ${launcher}; [ $? -eq 2 ] && { rm -f "$w"; exit 2; }; fi;`,
|
|
48
|
+
`sleep ${WATCH_POLL_SEC};`,
|
|
49
|
+
'done; rm -f "$w"; exit 0',
|
|
50
|
+
].join(" ");
|
|
51
|
+
}
|
|
27
52
|
export function hookFilePath(agent, env = process.env) {
|
|
28
53
|
switch (agent) {
|
|
29
54
|
case "claude":
|
|
@@ -48,10 +73,10 @@ function withoutOurs(groups) {
|
|
|
48
73
|
});
|
|
49
74
|
}
|
|
50
75
|
/**
|
|
51
|
-
* A hook file's content with consult's handlers set to `command` on `events
|
|
52
|
-
* command. Everything else in the file is kept.
|
|
76
|
+
* A hook file's content with consult's handlers set to `command` on `events` plus the `extra` ones, or removed
|
|
77
|
+
* when there is no command. Everything else in the file is kept.
|
|
53
78
|
*/
|
|
54
|
-
export function withConsultHooks(config, command, events) {
|
|
79
|
+
export function withConsultHooks(config, command, events, extra = []) {
|
|
55
80
|
const base = isObject(config) ? config : {};
|
|
56
81
|
const existing = isObject(base.hooks) ? base.hooks : {};
|
|
57
82
|
const cleaned = Object.entries(existing).flatMap(([event, groups]) => {
|
|
@@ -60,15 +85,19 @@ export function withConsultHooks(config, command, events) {
|
|
|
60
85
|
});
|
|
61
86
|
const hooks = Object.fromEntries(cleaned);
|
|
62
87
|
if (command) {
|
|
63
|
-
const
|
|
64
|
-
|
|
65
|
-
|
|
88
|
+
const entries = [
|
|
89
|
+
...events.map((event) => ({ event, handler: { type: "command", command, timeout: TIMEOUT_SEC } })),
|
|
90
|
+
...extra,
|
|
91
|
+
];
|
|
92
|
+
for (const { event, handler } of entries) {
|
|
93
|
+
hooks[event] = [...(Array.isArray(hooks[event]) ? hooks[event] : []), { hooks: [handler] }];
|
|
94
|
+
}
|
|
66
95
|
}
|
|
67
96
|
const { hooks: _dropped, ...rest } = base;
|
|
68
97
|
return Object.keys(hooks).length > 0 ? { ...rest, hooks } : rest;
|
|
69
98
|
}
|
|
70
99
|
/** Writes or removes consult's hooks in one file. Returns whether the file changed. */
|
|
71
|
-
export function applyHookFile(path, command, events) {
|
|
100
|
+
export function applyHookFile(path, command, events, extra = []) {
|
|
72
101
|
const exists = existsSync(path);
|
|
73
102
|
if (!exists && !command)
|
|
74
103
|
return false;
|
|
@@ -84,7 +113,7 @@ export function applyHookFile(path, command, events) {
|
|
|
84
113
|
if (!isObject(config))
|
|
85
114
|
throw new Error(`${path} does not hold a JSON object`);
|
|
86
115
|
}
|
|
87
|
-
const next = withConsultHooks(config, command, events);
|
|
116
|
+
const next = withConsultHooks(config, command, events, extra);
|
|
88
117
|
if (exists && Object.keys(next).length === 0 && Object.keys(config).length > 0) {
|
|
89
118
|
// Only consult's hooks were in it.
|
|
90
119
|
rmSync(path);
|
|
@@ -101,12 +130,26 @@ export function applyHookFile(path, command, events) {
|
|
|
101
130
|
renameSync(tmp, target);
|
|
102
131
|
return true;
|
|
103
132
|
}
|
|
133
|
+
/** Whether this CLI's hook file holds consult's message hook. */
|
|
134
|
+
export function hasConsultHook(agent, env = process.env) {
|
|
135
|
+
try {
|
|
136
|
+
return readFileSync(hookFilePath(agent, env), "utf8").includes(MARKER);
|
|
137
|
+
}
|
|
138
|
+
catch {
|
|
139
|
+
return false;
|
|
140
|
+
}
|
|
141
|
+
}
|
|
104
142
|
/** Installs consult's message hook for each CLI, or removes it when there is no command. One failing does not stop the others. */
|
|
105
|
-
export function applyHooks(agents,
|
|
143
|
+
export function applyHooks(agents, cmd, env = process.env) {
|
|
144
|
+
const command = cmd ? hookCommand(cmd) : undefined;
|
|
106
145
|
return agents.map((agent) => {
|
|
107
146
|
const path = hookFilePath(agent, env);
|
|
108
147
|
try {
|
|
109
|
-
|
|
148
|
+
// Only Claude Code can be woken by a hook; Codex is woken through `codex queue` and Grok not at all.
|
|
149
|
+
const watcher = cmd && agent === "claude"
|
|
150
|
+
? [{ event: "Stop", handler: { type: "command", command: watchCommand(cmd), asyncRewake: true, timeout: WATCH_TIMEOUT_SEC } }]
|
|
151
|
+
: [];
|
|
152
|
+
const changed = applyHookFile(path, command, EVENTS[agent], watcher);
|
|
110
153
|
const what = command ? (changed ? "message hook written to" : "message hook already in") : changed ? "message hook removed from" : "no message hook in";
|
|
111
154
|
const trust = command && agent === "codex" ? "; codex may need you to trust the hook before it runs" : "";
|
|
112
155
|
return { agent, ok: true, changed, message: `${agent}: ${what} ${path}${trust}` };
|