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 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. `CONSULT_TIMEOUT_SEC` (default 900) caps each advisor call, and `CONSULT_TASK_TIMEOUT_SEC` (default 3600) each dispatched task.
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
 
@@ -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(event) {
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
  };
@@ -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(event) {
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}), CONSULT_TASK_TIMEOUT_SEC per task (default ${DEFAULT_TASK_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 result = await consult().ask({
121
- agent: agent,
122
- question: words.join(" "),
123
- cwd: values.cwd ?? process.cwd(),
124
- model: values.model,
125
- effort: values.effort,
126
- sessionId: values.resume,
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 res = await run(invocation.command, invocation.args, { cwd: request.cwd, timeoutMs, signal });
48
- assertExitedCleanly(adapter.name, res, timeoutMs);
49
- return { agent: adapter.name, ...adapter.parse(res.stdout), durationMs: Date.now() - started };
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
- async function ask(request, signal) {
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 { parseJsonLines } from "./jsonl.js";
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
- // A malformed event must never break the run, so narration failures are dropped.
44
- try {
45
- const [event] = parseJsonLines(line);
46
- const text = event ? worker.describe(event)?.trim() : undefined;
47
- if (!text)
48
- return;
49
- change(id, (view) => {
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "consult-mcp",
3
- "version": "0.4.0",
3
+ "version": "0.4.1",
4
4
  "description": "MCP server that lets Claude Code, Codex and Grok ask each other for a second opinion through the CLIs already signed in on your machine",
5
5
  "keywords": [
6
6
  "mcp",