consult-mcp 0.4.0 → 0.4.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +5 -2
- package/dist/adapters/codex.js +21 -19
- package/dist/adapters/grok.js +21 -19
- package/dist/cli.js +29 -9
- package/dist/consult.js +19 -6
- package/dist/narrate.js +32 -0
- package/dist/tasks.js +8 -17
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -143,11 +143,14 @@ The same calls are available from the shell, which is handy for debugging:
|
|
|
143
143
|
```bash
|
|
144
144
|
consult ask codex --model gpt-6-astra "Is the retry logic in src/sync.ts safe under concurrent calls?"
|
|
145
145
|
consult ask codex --resume <session_id> "What would you change first?"
|
|
146
|
+
consult ask grok - < question.md # long questions from a file or pipe
|
|
146
147
|
consult agents
|
|
147
|
-
consult settings # live sessions, and which agents use consult
|
|
148
|
+
consult settings # live sessions, task dispatch, and which agents use consult
|
|
148
149
|
```
|
|
149
150
|
|
|
150
|
-
Use `npx -y consult-mcp@latest <command>` if it isn't installed globally.
|
|
151
|
+
Use `npx -y consult-mcp@latest <command>` if it isn't installed globally. Advisors are read-only here too: the same containment flags apply, so there is no need to tell them not to edit files. While one works, `consult ask` prints timestamped progress to stderr (commands run and messages for codex and grok, a heartbeat after 30 quiet seconds for any agent) and only the answer to stdout.
|
|
152
|
+
|
|
153
|
+
`CONSULT_TIMEOUT_SEC` (default 900) caps each advisor call; raise it for long grok runs on large questions. `CONSULT_TASK_TIMEOUT_SEC` (default 3600) caps each dispatched task.
|
|
151
154
|
|
|
152
155
|
## Development
|
|
153
156
|
|
package/dist/adapters/codex.js
CHANGED
|
@@ -51,6 +51,25 @@ function parse(stdout) {
|
|
|
51
51
|
throw new AdvisorError("codex returned no answer");
|
|
52
52
|
return { answer, sessionId: threadId };
|
|
53
53
|
}
|
|
54
|
+
function describe(event) {
|
|
55
|
+
const item = event.item;
|
|
56
|
+
if (!item)
|
|
57
|
+
return undefined;
|
|
58
|
+
if (event.type === "item.started" && item.type === "command_execution" && item.command)
|
|
59
|
+
return `$ ${unwrapShell(item.command)}`;
|
|
60
|
+
if (event.type !== "item.completed")
|
|
61
|
+
return undefined;
|
|
62
|
+
switch (item.type) {
|
|
63
|
+
case "command_execution":
|
|
64
|
+
return item.exit_code && item.command ? `exit ${item.exit_code}: ${unwrapShell(item.command)}` : undefined;
|
|
65
|
+
case "file_change":
|
|
66
|
+
return item.changes?.map((c) => `${c.kind ?? "edit"} ${c.path ?? "?"}`).join(", ");
|
|
67
|
+
case "agent_message":
|
|
68
|
+
return item.text;
|
|
69
|
+
default:
|
|
70
|
+
return undefined;
|
|
71
|
+
}
|
|
72
|
+
}
|
|
54
73
|
export const codex = {
|
|
55
74
|
name: "codex",
|
|
56
75
|
command: "codex",
|
|
@@ -68,6 +87,7 @@ export const codex = {
|
|
|
68
87
|
};
|
|
69
88
|
},
|
|
70
89
|
parse,
|
|
90
|
+
describe,
|
|
71
91
|
};
|
|
72
92
|
export const codexWorker = {
|
|
73
93
|
name: "codex",
|
|
@@ -75,24 +95,6 @@ export const codexWorker = {
|
|
|
75
95
|
build(request, prompt, extraArgs) {
|
|
76
96
|
return { command: "codex", args: ["exec", "--json", "--skip-git-repo-check", ...configArgs(WORKSPACE_WRITE, request, extraArgs), prompt] };
|
|
77
97
|
},
|
|
78
|
-
describe
|
|
79
|
-
const item = event.item;
|
|
80
|
-
if (!item)
|
|
81
|
-
return undefined;
|
|
82
|
-
if (event.type === "item.started" && item.type === "command_execution" && item.command)
|
|
83
|
-
return `$ ${unwrapShell(item.command)}`;
|
|
84
|
-
if (event.type !== "item.completed")
|
|
85
|
-
return undefined;
|
|
86
|
-
switch (item.type) {
|
|
87
|
-
case "command_execution":
|
|
88
|
-
return item.exit_code && item.command ? `exit ${item.exit_code}: ${unwrapShell(item.command)}` : undefined;
|
|
89
|
-
case "file_change":
|
|
90
|
-
return item.changes?.map((c) => `${c.kind ?? "edit"} ${c.path ?? "?"}`).join(", ");
|
|
91
|
-
case "agent_message":
|
|
92
|
-
return item.text;
|
|
93
|
-
default:
|
|
94
|
-
return undefined;
|
|
95
|
-
}
|
|
96
|
-
},
|
|
98
|
+
describe,
|
|
97
99
|
parse,
|
|
98
100
|
};
|
package/dist/adapters/grok.js
CHANGED
|
@@ -37,6 +37,25 @@ function parse(stdout) {
|
|
|
37
37
|
}
|
|
38
38
|
return { answer: result.result, sessionId: result.session_id, ...(typeof model === "string" ? { model } : {}) };
|
|
39
39
|
}
|
|
40
|
+
function describeTool(name, input = {}) {
|
|
41
|
+
if (name === "run_terminal_cmd" && typeof input.command === "string")
|
|
42
|
+
return `$ ${unwrapShell(input.command)}`;
|
|
43
|
+
const target = Object.values(input).find((v) => typeof v === "string");
|
|
44
|
+
return target ? `${name} ${target}` : name;
|
|
45
|
+
}
|
|
46
|
+
function describe(event) {
|
|
47
|
+
if (event.type !== "assistant")
|
|
48
|
+
return undefined;
|
|
49
|
+
const content = event.message?.content ?? [];
|
|
50
|
+
const parts = content.flatMap((block) => {
|
|
51
|
+
if (block.type === "text" && block.text?.trim())
|
|
52
|
+
return [block.text];
|
|
53
|
+
if (block.type === "tool_use" && block.name)
|
|
54
|
+
return [describeTool(block.name, block.input)];
|
|
55
|
+
return [];
|
|
56
|
+
});
|
|
57
|
+
return parts.length > 0 ? parts.join(" · ") : undefined;
|
|
58
|
+
}
|
|
40
59
|
export const grok = {
|
|
41
60
|
name: "grok",
|
|
42
61
|
command: "grok",
|
|
@@ -47,30 +66,13 @@ export const grok = {
|
|
|
47
66
|
return invocation(ADVISOR_TOOLS, request, prompt, ["--resume", sourceSessionId, "--fork-session"]);
|
|
48
67
|
},
|
|
49
68
|
parse,
|
|
69
|
+
describe,
|
|
50
70
|
};
|
|
51
|
-
function describeTool(name, input = {}) {
|
|
52
|
-
if (name === "run_terminal_cmd" && typeof input.command === "string")
|
|
53
|
-
return `$ ${unwrapShell(input.command)}`;
|
|
54
|
-
const target = Object.values(input).find((v) => typeof v === "string");
|
|
55
|
-
return target ? `${name} ${target}` : name;
|
|
56
|
-
}
|
|
57
71
|
export const grokWorker = {
|
|
58
72
|
name: "grok",
|
|
59
73
|
build(request, prompt) {
|
|
60
74
|
return invocation(WORKER_TOOLS, request, prompt, ["--session-id", randomUUID()]);
|
|
61
75
|
},
|
|
62
|
-
describe
|
|
63
|
-
if (event.type !== "assistant")
|
|
64
|
-
return undefined;
|
|
65
|
-
const content = event.message?.content ?? [];
|
|
66
|
-
const parts = content.flatMap((block) => {
|
|
67
|
-
if (block.type === "text" && block.text?.trim())
|
|
68
|
-
return [block.text];
|
|
69
|
-
if (block.type === "tool_use" && block.name)
|
|
70
|
-
return [describeTool(block.name, block.input)];
|
|
71
|
-
return [];
|
|
72
|
-
});
|
|
73
|
-
return parts.length > 0 ? parts.join(" · ") : undefined;
|
|
74
|
-
},
|
|
76
|
+
describe,
|
|
75
77
|
parse,
|
|
76
78
|
};
|
package/dist/cli.js
CHANGED
|
@@ -8,6 +8,7 @@ import { codex, codexWorker } from "./adapters/codex.js";
|
|
|
8
8
|
import { grok, grokWorker } from "./adapters/grok.js";
|
|
9
9
|
import { listAgents } from "./agents.js";
|
|
10
10
|
import { createConsult } from "./consult.js";
|
|
11
|
+
import { progressLog } from "./narrate.js";
|
|
11
12
|
import { findOnPath, install, isRegistered, serverCommand, uninstall } from "./install.js";
|
|
12
13
|
import { DEFAULT_SETTINGS, loadSettings, saveSettings, sessionsMode, settingsPath, withSessionsMode } from "./settings.js";
|
|
13
14
|
import { run } from "./run.js";
|
|
@@ -21,11 +22,14 @@ import { AGENTS, EFFORTS, SESSION_ID_PATTERN } from "./types.js";
|
|
|
21
22
|
import { VERSION } from "./version.js";
|
|
22
23
|
const DEFAULT_TIMEOUT_SEC = 900;
|
|
23
24
|
const DEFAULT_TASK_TIMEOUT_SEC = 3600;
|
|
25
|
+
const HEARTBEAT_MS = 30_000;
|
|
24
26
|
const USAGE = `consult ${VERSION} - let coding agents ask each other for advice
|
|
25
27
|
|
|
26
28
|
consult serve run the MCP server (stdio)
|
|
27
29
|
consult ask <agent> [options] <question...>
|
|
30
|
+
ask an advisor; it can read --cwd but never change anything
|
|
28
31
|
--model <id> --effort low|medium|high --resume <session_id> --cwd <dir>
|
|
32
|
+
a question of "-" is read from stdin; progress goes to stderr, the answer alone to stdout
|
|
29
33
|
consult agents list installed CLIs and models
|
|
30
34
|
consult install [--sessions off|repo|machine]
|
|
31
35
|
register the MCP server with claude, codex and grok
|
|
@@ -33,7 +37,8 @@ const USAGE = `consult ${VERSION} - let coding agents ask each other for advice
|
|
|
33
37
|
consult settings live sessions, task dispatch, and which agents use consult (interactive)
|
|
34
38
|
|
|
35
39
|
agents: ${AGENTS.join(", ")}
|
|
36
|
-
timeouts: CONSULT_TIMEOUT_SEC per advisor call (default ${DEFAULT_TIMEOUT_SEC}
|
|
40
|
+
timeouts: CONSULT_TIMEOUT_SEC per advisor call (default ${DEFAULT_TIMEOUT_SEC}; raise it for long grok runs),
|
|
41
|
+
CONSULT_TASK_TIMEOUT_SEC per task (default ${DEFAULT_TASK_TIMEOUT_SEC})`;
|
|
37
42
|
function secondsFromEnv(name, defaultSec) {
|
|
38
43
|
const raw = process.env[name];
|
|
39
44
|
const sec = raw ? Number(raw) : defaultSec;
|
|
@@ -102,6 +107,12 @@ async function serve() {
|
|
|
102
107
|
}
|
|
103
108
|
await server.connect(transport);
|
|
104
109
|
}
|
|
110
|
+
async function readStdin() {
|
|
111
|
+
const chunks = [];
|
|
112
|
+
for await (const chunk of process.stdin)
|
|
113
|
+
chunks.push(chunk);
|
|
114
|
+
return Buffer.concat(chunks).toString("utf8");
|
|
115
|
+
}
|
|
105
116
|
async function askCommand(argv) {
|
|
106
117
|
const { values, positionals } = parseArgs({
|
|
107
118
|
args: [...argv],
|
|
@@ -117,14 +128,23 @@ async function askCommand(argv) {
|
|
|
117
128
|
throw new Error("--resume must be a session id: letters, digits and hyphens, not starting with a hyphen");
|
|
118
129
|
}
|
|
119
130
|
refuseLive(values.resume);
|
|
120
|
-
const
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
131
|
+
const question = words.length === 1 && words[0] === "-" ? await readStdin() : words.join(" ");
|
|
132
|
+
const progress = progressLog((line) => process.stderr.write(line), `${agent} is still working`, HEARTBEAT_MS);
|
|
133
|
+
progress.update(`asking ${agent} (read-only)`);
|
|
134
|
+
let result;
|
|
135
|
+
try {
|
|
136
|
+
result = await consult().ask({
|
|
137
|
+
agent: agent,
|
|
138
|
+
question,
|
|
139
|
+
cwd: values.cwd ?? process.cwd(),
|
|
140
|
+
model: values.model,
|
|
141
|
+
effort: values.effort,
|
|
142
|
+
sessionId: values.resume,
|
|
143
|
+
}, undefined, progress.update);
|
|
144
|
+
}
|
|
145
|
+
finally {
|
|
146
|
+
progress.stop();
|
|
147
|
+
}
|
|
128
148
|
process.stdout.write(`${result.answer}\n`);
|
|
129
149
|
process.stderr.write(`\n[${result.agent}${result.model ? ` ${result.model}` : ""} · session ${result.sessionId} · ${(result.durationMs / 1000).toFixed(1)}s]\n`);
|
|
130
150
|
}
|
package/dist/consult.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { statSync } from "node:fs";
|
|
2
|
+
import { describeLine } from "./narrate.js";
|
|
2
3
|
import { frameForkPrompt, framePrompt } from "./prompt.js";
|
|
3
4
|
import { AdvisorError, } from "./types.js";
|
|
4
5
|
const STDERR_TAIL = 2000;
|
|
@@ -38,20 +39,32 @@ export function assertExitedCleanly(agent, res, timeoutMs) {
|
|
|
38
39
|
}
|
|
39
40
|
}
|
|
40
41
|
export function createConsult({ run, adapters, timeoutMs }) {
|
|
41
|
-
async function execute(adapter, request, invoke, signal) {
|
|
42
|
+
async function execute(adapter, request, invoke, signal, onUpdate) {
|
|
42
43
|
validate(request);
|
|
43
44
|
const started = Date.now();
|
|
44
45
|
// Computed per call, never cached: containment must reflect the config as it is right now.
|
|
45
46
|
const extraArgs = adapter.prepare ? await adapter.prepare(run, request.cwd, signal) : [];
|
|
46
47
|
const invocation = invoke(extraArgs);
|
|
47
|
-
const
|
|
48
|
-
|
|
49
|
-
|
|
48
|
+
const { describe } = adapter;
|
|
49
|
+
const lines = [];
|
|
50
|
+
const onStdoutLine = onUpdate && describe
|
|
51
|
+
? (line) => {
|
|
52
|
+
lines.push(line);
|
|
53
|
+
const text = describeLine(describe, line);
|
|
54
|
+
if (text)
|
|
55
|
+
onUpdate(text);
|
|
56
|
+
}
|
|
57
|
+
: undefined;
|
|
58
|
+
const res = await run(invocation.command, invocation.args, { cwd: request.cwd, timeoutMs, signal, onStdoutLine });
|
|
59
|
+
const stdout = onStdoutLine ? lines.join("\n") : res.stdout;
|
|
60
|
+
assertExitedCleanly(adapter.name, { ...res, stdout }, timeoutMs);
|
|
61
|
+
return { agent: adapter.name, ...adapter.parse(stdout), durationMs: Date.now() - started };
|
|
50
62
|
}
|
|
51
|
-
|
|
63
|
+
/** `onUpdate` receives one-line progress updates for CLIs that stream their work (codex, grok). */
|
|
64
|
+
async function ask(request, signal, onUpdate) {
|
|
52
65
|
const adapter = adapters[request.agent];
|
|
53
66
|
const prompt = framePrompt(request.question, Boolean(request.sessionId));
|
|
54
|
-
return execute(adapter, request, (extra) => adapter.build(request, prompt, extra), signal);
|
|
67
|
+
return execute(adapter, request, (extra) => adapter.build(request, prompt, extra), signal, onUpdate);
|
|
55
68
|
}
|
|
56
69
|
/** Asks a read-only fork of a live session. It runs in that session's directory, where its CLI can find it. */
|
|
57
70
|
async function askSession(target, question, effort, signal) {
|
package/dist/narrate.js
CHANGED
|
@@ -1,10 +1,42 @@
|
|
|
1
|
+
import { parseJsonLines } from "./jsonl.js";
|
|
1
2
|
const MAX_UPDATE_CHARS = 200;
|
|
2
3
|
/** Collapses whitespace and shortens text to a single progress line. */
|
|
3
4
|
export function oneLine(text, max = MAX_UPDATE_CHARS) {
|
|
4
5
|
const flat = text.replace(/\s+/g, " ").trim();
|
|
5
6
|
return flat.length > max ? `${flat.slice(0, max - 1)}…` : flat;
|
|
6
7
|
}
|
|
8
|
+
/**
|
|
9
|
+
* Turns one line of a CLI's JSONL output into a progress line, if it is worth reporting.
|
|
10
|
+
* Never throws: a malformed event must not break the run it came from.
|
|
11
|
+
*/
|
|
12
|
+
export function describeLine(describe, line) {
|
|
13
|
+
try {
|
|
14
|
+
const [event] = parseJsonLines(line);
|
|
15
|
+
const text = event ? describe(event)?.trim() : undefined;
|
|
16
|
+
return text ? oneLine(text) : undefined;
|
|
17
|
+
}
|
|
18
|
+
catch {
|
|
19
|
+
return undefined;
|
|
20
|
+
}
|
|
21
|
+
}
|
|
7
22
|
/** `/bin/zsh -lc 'npm test'` → `npm test`: CLIs wrap every command in a login shell. */
|
|
8
23
|
export function unwrapShell(command) {
|
|
9
24
|
return /^\S*sh -lc '(.*)'$/s.exec(command)?.[1] ?? command;
|
|
10
25
|
}
|
|
26
|
+
/**
|
|
27
|
+
* A timestamped progress log for the shell: each update as one line, plus a heartbeat whenever nothing has been
|
|
28
|
+
* written for `heartbeatMs`, so a long quiet run still shows it is alive. Call stop() when the run ends.
|
|
29
|
+
*/
|
|
30
|
+
export function progressLog(write, heartbeat, heartbeatMs) {
|
|
31
|
+
const started = Date.now();
|
|
32
|
+
let last = started;
|
|
33
|
+
const update = (text) => {
|
|
34
|
+
last = Date.now();
|
|
35
|
+
write(`[${Math.round((last - started) / 1000)}s] ${text}\n`);
|
|
36
|
+
};
|
|
37
|
+
const timer = setInterval(() => {
|
|
38
|
+
if (Date.now() - last >= heartbeatMs)
|
|
39
|
+
update(heartbeat);
|
|
40
|
+
}, Math.max(1, Math.round(heartbeatMs / 3)));
|
|
41
|
+
return { update, stop: () => clearInterval(timer) };
|
|
42
|
+
}
|
package/dist/tasks.js
CHANGED
|
@@ -1,6 +1,5 @@
|
|
|
1
1
|
import { assertDirectory, assertExitedCleanly } from "./consult.js";
|
|
2
|
-
import {
|
|
3
|
-
import { oneLine } from "./narrate.js";
|
|
2
|
+
import { describeLine } from "./narrate.js";
|
|
4
3
|
import { frameTaskPrompt } from "./prompt.js";
|
|
5
4
|
import { AdvisorError } from "./types.js";
|
|
6
5
|
const MAX_UPDATES = 200;
|
|
@@ -40,21 +39,13 @@ export function createTaskManager({ run, workers, timeoutMs, signal, now = Date.
|
|
|
40
39
|
}
|
|
41
40
|
}
|
|
42
41
|
function narrate(id, worker, line) {
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
const seq = (view.updates.at(-1)?.seq ?? 0) + 1;
|
|
51
|
-
const next = { seq, atMs: now() - view.startedAt, text: oneLine(text) };
|
|
52
|
-
return { ...view, updates: [...view.updates, next].slice(-MAX_UPDATES) };
|
|
53
|
-
});
|
|
54
|
-
}
|
|
55
|
-
catch {
|
|
56
|
-
// Ignored, see above.
|
|
57
|
-
}
|
|
42
|
+
const text = describeLine((event) => worker.describe(event), line);
|
|
43
|
+
if (!text)
|
|
44
|
+
return;
|
|
45
|
+
change(id, (view) => {
|
|
46
|
+
const seq = (view.updates.at(-1)?.seq ?? 0) + 1;
|
|
47
|
+
return { ...view, updates: [...view.updates, { seq, atMs: now() - view.startedAt, text }].slice(-MAX_UPDATES) };
|
|
48
|
+
});
|
|
58
49
|
}
|
|
59
50
|
function finish(id, outcome) {
|
|
60
51
|
change(id, (view) => ({ ...view, ...outcome, finishedAt: now() }));
|
package/package.json
CHANGED