consult-mcp 0.3.0 → 0.4.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 CHANGED
@@ -4,7 +4,7 @@
4
4
 
5
5
  consult is an MCP server that lets Claude Code, Codex and Grok ask each other 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. With [task dispatch](#task-dispatch) on, an agent can also hand a coding task to a Codex or Grok worker and follow its progress.
8
8
 
9
9
  [consult.n3el.dev](https://consult.n3el.dev) · [npm](https://www.npmjs.com/package/consult-mcp)
10
10
 
@@ -54,6 +54,8 @@ To pin to a specific build instead of following `@latest`, install globally with
54
54
 
55
55
  **`list_sessions`** and **`ask_session`** `{session_id, question, effort?}` appear when [live sessions](#live-sessions) are on.
56
56
 
57
+ **`dispatch_task`**, **`task_status`** and **`cancel_task`** appear when [task dispatch](#task-dispatch) is on.
58
+
57
59
  While an advisor is working, the server sends MCP progress notifications (`codex is thinking (40s)`), so hosts that show progress don't look stuck.
58
60
 
59
61
  ## When agents consult
@@ -78,7 +80,7 @@ Turn it on when you install, or later:
78
80
 
79
81
  ```bash
80
82
  npx -y consult-mcp@latest install --sessions=repo # or off, machine; without the flag, install asks when run in a terminal
81
- npx -y consult-mcp@latest settings # interactive: live sessions, and which agents use consult
83
+ npx -y consult-mcp@latest settings # interactive: live sessions, task dispatch, and which agents use consult
82
84
  ```
83
85
 
84
86
  | Setting | Who can see and ask whom |
@@ -91,10 +93,22 @@ Settings are stored in `$XDG_CONFIG_HOME/consult/settings.json` (default `~/.con
91
93
 
92
94
  | CLI | How consult finds the session |
93
95
  | --- | --- |
94
- | claude | `CLAUDE_CODE_SESSION_ID`. After `/clear`, the entry still points at the earlier conversation until the session restarts. |
96
+ | claude | the record Claude Code keeps for each running process (`~/.claude/sessions/<pid>.json`), so it follows `/resume` and `/clear`; `CLAUDE_CODE_SESSION_ID` as a fallback. Listed once the conversation is saved, after its first message |
95
97
  | codex | the `rollout-*.jsonl` file the codex process holds open, from the session's first turn on |
96
98
  | grok | the `~/.grok/sessions/<cwd>/<session id>/events.jsonl` file the interactive grok process holds open, from the session's first turn on; not identified if several sessions are open |
97
99
 
100
+ ## Task dispatch
101
+
102
+ Off by default. When on, an agent can hand a well-defined coding task to a Codex or Grok worker, keep working, and check in on it. For example, Claude Code can have Codex write the tests for a module while it builds the next one.
103
+
104
+ - **`dispatch_task`** `{agent, task, model?, effort?, cwd?}` starts the worker in the background and returns a `task_id` at once. `agent` is `codex` or `grok`. The worker runs headless in `cwd` (default: the host's directory) and can edit files and run commands there. It can't see the host's conversation or ask questions, so the task must be self-contained.
105
+ - **`task_status`** `{task_id?, wait_sec?, since?}` returns the task's state (`running`, `succeeded`, `failed`, `cancelled`), its latest updates (commands run, files edited, messages), and the worker's final report once it finishes. `wait_sec` (up to 600) waits for the task to finish, sending each update as an MCP progress notification as it happens. `since` returns only updates after the `last_seq` of an earlier call (the server keeps the last 200; `earlier_updates_dropped` says when you missed some). Without `task_id` it lists all tasks.
106
+ - **`cancel_task`** `{task_id}` stops the worker. Files it already changed stay changed.
107
+
108
+ Tasks live in the consult server's memory: they end with the agent session that started them, and the server stops any still running when it exits. `CONSULT_TASK_TIMEOUT_SEC` (default 3600) caps each task. The report includes the worker's `session_id`; pass it to `ask_agent` with the same agent to ask a read-only follow-up about the work.
109
+
110
+ Turn it on with `npx -y consult-mcp@latest settings`; it applies to agent sessions started afterwards. Workers change files, so review their diff before building on it, and don't point two workers at the same files.
111
+
98
112
  ## How advisors are contained
99
113
 
100
114
  Every advisor runs headless and read-only, and has no MCP servers. So an advisor can't call consult again, which prevents loops.
@@ -111,6 +125,15 @@ consult doesn't use Grok's kernel sandbox (`--sandbox read-only`), because it re
111
125
 
112
126
  The first message of each session also tells the advisor that another agent is consulting it. It should answer directly, skip approval and brainstorming workflows, and not modify anything.
113
127
 
128
+ [Task dispatch](#task-dispatch) workers are the one exception to read-only, and only when you turn it on. They stay headless and have no MCP servers, so they can't call consult either:
129
+
130
+ | CLI | Worker flags |
131
+ | --- | --- |
132
+ | codex | `exec`, `sandbox_mode="workspace-write"` with `network_access=false` and `writable_roots=[]` (writes confined to the cwd and temp dirs, no network), `approval_policy="never"`, plugins off, and every enabled MCP server disabled by name |
133
+ | grok | `--tools read_file,grep,list_dir,search_replace,write_file,run_terminal_cmd --always-approve --disallowed-tools Agent,search_tool,use_tool`. Without Grok's kernel sandbox (see above), its shell commands run unconfined as your user, with network access. Its shell could even start another agent CLI, so for grok "no MCP servers" stops loops through consult but is not a hard boundary |
134
+
135
+ Their first message tells them another agent dispatched them, to finish the task without asking questions, to stay within the task and the directory, and not to commit or push unless the task says so.
136
+
114
137
  Advisors send what they read to their own provider. [SECURITY.md](SECURITY.md) covers read scope and the threat model.
115
138
 
116
139
  ## CLI
@@ -124,7 +147,7 @@ consult agents
124
147
  consult settings # live sessions, and which agents use consult
125
148
  ```
126
149
 
127
- Use `npx -y consult-mcp@latest <command>` if it isn't installed globally. `CONSULT_TIMEOUT_SEC` (default 900) caps each advisor call.
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.
128
151
 
129
152
  ## Development
130
153
 
@@ -1,9 +1,18 @@
1
1
  import { AdvisorError } from "../types.js";
2
2
  import { parseJsonLines } from "../jsonl.js";
3
+ import { unwrapShell } from "../narrate.js";
3
4
  const PLUGINS_OFF = ["-c", "features.plugins=false"];
4
- // Codex runs MCP tools outside its shell sandbox, so the advisor gets none: plugins are switched off
5
+ const READ_ONLY = ["-c", 'sandbox_mode="read-only"'];
6
+ // Workers may edit files under the cwd (and temp dirs) and run commands there, without network access. Extra
7
+ // writable roots from the user's config are dropped.
8
+ const WORKSPACE_WRITE = [
9
+ "-c", 'sandbox_mode="workspace-write"',
10
+ "-c", "sandbox_workspace_write.network_access=false",
11
+ "-c", "sandbox_workspace_write.writable_roots=[]",
12
+ ];
13
+ // Codex runs MCP tools outside its shell sandbox, so advisors and workers get none: plugins are switched off
5
14
  // (they bring their own MCP servers) and every server still enabled is disabled by name.
6
- // That includes consult itself, which is what stops advisors from consulting each other in a loop.
15
+ // That includes consult itself, which is what stops agents from consulting each other in a loop.
7
16
  async function listEnabledMcpServers(run, cwd, signal) {
8
17
  const res = await run("codex", ["mcp", "list", "--json", ...PLUGINS_OFF], { cwd, timeoutMs: 30_000, signal });
9
18
  if (res.code !== 0) {
@@ -12,49 +21,78 @@ async function listEnabledMcpServers(run, cwd, signal) {
12
21
  const servers = JSON.parse(res.stdout);
13
22
  return servers.filter((s) => s.enabled).map((s) => s.name);
14
23
  }
15
- function configArgs(request, extraArgs) {
24
+ const prepare = async (run, cwd, signal) => {
25
+ const names = await listEnabledMcpServers(run, cwd, signal);
26
+ return [...PLUGINS_OFF, ...names.flatMap((n) => ["-c", `mcp_servers.${n}.enabled=false`])];
27
+ };
28
+ function configArgs(sandbox, request, extraArgs) {
16
29
  return [
17
- "-c", 'sandbox_mode="read-only"',
30
+ ...sandbox,
18
31
  "-c", 'approval_policy="never"',
19
32
  ...(request.model ? ["-c", `model=${JSON.stringify(request.model)}`] : []),
20
33
  ...(request.effort ? ["-c", `model_reasoning_effort="${request.effort}"`] : []),
21
34
  ...extraArgs,
22
35
  ];
23
36
  }
37
+ function parse(stdout) {
38
+ const events = parseJsonLines(stdout);
39
+ const failure = events.find((e) => e.type === "turn.failed" || e.type === "error");
40
+ if (failure) {
41
+ const message = failure.error?.message ?? failure.message;
42
+ throw new AdvisorError(`codex reported an error: ${String(message ?? "unknown error")}`);
43
+ }
44
+ const threadId = events.find((e) => e.type === "thread.started")?.thread_id;
45
+ const answer = events
46
+ .filter((e) => e.type === "item.completed")
47
+ .map((e) => e.item)
48
+ .filter((item) => item.type === "agent_message" && item.text)
49
+ .at(-1)?.text;
50
+ if (!answer || typeof threadId !== "string")
51
+ throw new AdvisorError("codex returned no answer");
52
+ return { answer, sessionId: threadId };
53
+ }
24
54
  export const codex = {
25
55
  name: "codex",
26
56
  command: "codex",
27
- async prepare(run, cwd, signal) {
28
- const names = await listEnabledMcpServers(run, cwd, signal);
29
- return [...PLUGINS_OFF, ...names.flatMap((n) => ["-c", `mcp_servers.${n}.enabled=false`])];
30
- },
57
+ prepare,
31
58
  build(request, prompt, extraArgs) {
32
59
  const head = request.sessionId ? ["exec", "resume", "--json"] : ["exec", "--json"];
33
60
  const tail = request.sessionId ? [request.sessionId, prompt] : [prompt];
34
- return { command: "codex", args: [...head, "--skip-git-repo-check", ...configArgs(request, extraArgs), ...tail] };
61
+ return { command: "codex", args: [...head, "--skip-git-repo-check", ...configArgs(READ_ONLY, request, extraArgs), ...tail] };
35
62
  },
36
63
  // exec fork starts a new thread from the source's history; the source rollout is only read.
37
64
  fork(sourceSessionId, request, prompt, extraArgs) {
38
65
  return {
39
66
  command: "codex",
40
- args: ["exec", "fork", "--json", "--skip-git-repo-check", ...configArgs(request, extraArgs), sourceSessionId, prompt],
67
+ args: ["exec", "fork", "--json", "--skip-git-repo-check", ...configArgs(READ_ONLY, request, extraArgs), sourceSessionId, prompt],
41
68
  };
42
69
  },
43
- parse(stdout) {
44
- const events = parseJsonLines(stdout);
45
- const failure = events.find((e) => e.type === "turn.failed" || e.type === "error");
46
- if (failure) {
47
- const message = failure.error?.message ?? failure.message;
48
- throw new AdvisorError(`codex reported an error: ${String(message ?? "unknown error")}`);
70
+ parse,
71
+ };
72
+ export const codexWorker = {
73
+ name: "codex",
74
+ prepare,
75
+ build(request, prompt, extraArgs) {
76
+ return { command: "codex", args: ["exec", "--json", "--skip-git-repo-check", ...configArgs(WORKSPACE_WRITE, request, extraArgs), prompt] };
77
+ },
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;
49
95
  }
50
- const threadId = events.find((e) => e.type === "thread.started")?.thread_id;
51
- const answer = events
52
- .filter((e) => e.type === "item.completed")
53
- .map((e) => e.item)
54
- .filter((item) => item.type === "agent_message" && item.text)
55
- .at(-1)?.text;
56
- if (!answer || typeof threadId !== "string")
57
- throw new AdvisorError("codex returned no answer");
58
- return { answer, sessionId: threadId };
59
96
  },
97
+ parse,
60
98
  };
@@ -1,43 +1,76 @@
1
1
  import { randomUUID } from "node:crypto";
2
2
  import { AdvisorError } from "../types.js";
3
3
  import { parseJsonLines } from "../jsonl.js";
4
+ import { unwrapShell } from "../narrate.js";
4
5
  // --tools keeps only read-only built-ins, but grok still adds its MCP meta-tools (search_tool/use_tool),
5
6
  // which can reach write-capable MCP servers, so those are removed too. Grok's kernel sandbox is not used:
6
7
  // it refuses to start on machines where /var/run/docker.sock is a symlink.
7
- function invocation(request, prompt, session) {
8
+ const ADVISOR_TOOLS = ["--tools", "read_file,grep,list_dir"];
9
+ const NO_MCP = ["--disallowed-tools", "Agent,search_tool,use_tool"];
10
+ // Workers also edit files and run shell commands, approved up front because nobody is there to approve.
11
+ // Without the kernel sandbox (see above), those commands are not confined to the cwd.
12
+ const WORKER_TOOLS = ["--tools", "read_file,grep,list_dir,search_replace,write_file,run_terminal_cmd", "--always-approve"];
13
+ function invocation(tools, request, prompt, session) {
8
14
  return {
9
15
  command: "grok",
10
16
  args: [
11
17
  "--single", prompt,
12
18
  "--output-format", "streaming-messages-json",
13
- "--tools", "read_file,grep,list_dir",
14
- "--disallowed-tools", "Agent,search_tool,use_tool",
19
+ ...tools,
20
+ ...NO_MCP,
15
21
  ...session,
16
22
  ...(request.model ? ["--model", request.model] : []),
17
23
  ...(request.effort ? ["--reasoning-effort", request.effort] : []),
18
24
  ],
19
25
  };
20
26
  }
27
+ function parse(stdout) {
28
+ const events = parseJsonLines(stdout);
29
+ const result = events.findLast((e) => e.type === "result");
30
+ if (!result)
31
+ throw new AdvisorError(`grok returned no result: ${stdout.slice(0, 500)}`);
32
+ if (result.is_error)
33
+ throw new AdvisorError(`grok reported an error: ${String(result.result ?? "unknown error")}`);
34
+ const model = events.find((e) => e.type === "system")?.model;
35
+ if (typeof result.result !== "string" || !result.result || typeof result.session_id !== "string") {
36
+ throw new AdvisorError("grok returned no answer");
37
+ }
38
+ return { answer: result.result, sessionId: result.session_id, ...(typeof model === "string" ? { model } : {}) };
39
+ }
21
40
  export const grok = {
22
41
  name: "grok",
23
42
  command: "grok",
24
43
  build(request, prompt) {
25
- return invocation(request, prompt, request.sessionId ? ["--resume", request.sessionId] : ["--session-id", randomUUID()]);
44
+ return invocation(ADVISOR_TOOLS, request, prompt, request.sessionId ? ["--resume", request.sessionId] : ["--session-id", randomUUID()]);
26
45
  },
27
46
  fork(sourceSessionId, request, prompt) {
28
- return invocation(request, prompt, ["--resume", sourceSessionId, "--fork-session"]);
47
+ return invocation(ADVISOR_TOOLS, request, prompt, ["--resume", sourceSessionId, "--fork-session"]);
48
+ },
49
+ parse,
50
+ };
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
+ export const grokWorker = {
58
+ name: "grok",
59
+ build(request, prompt) {
60
+ return invocation(WORKER_TOOLS, request, prompt, ["--session-id", randomUUID()]);
29
61
  },
30
- parse(stdout) {
31
- const events = parseJsonLines(stdout);
32
- const result = events.findLast((e) => e.type === "result");
33
- if (!result)
34
- throw new AdvisorError(`grok returned no result: ${stdout.slice(0, 500)}`);
35
- if (result.is_error)
36
- throw new AdvisorError(`grok reported an error: ${String(result.result ?? "unknown error")}`);
37
- const model = events.find((e) => e.type === "system")?.model;
38
- if (typeof result.result !== "string" || !result.result || typeof result.session_id !== "string") {
39
- throw new AdvisorError("grok returned no answer");
40
- }
41
- return { answer: result.result, sessionId: result.session_id, ...(typeof model === "string" ? { model } : {}) };
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;
42
74
  },
75
+ parse,
43
76
  };
package/dist/cli.js CHANGED
@@ -4,8 +4,8 @@ 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 { claude } from "./adapters/claude.js";
7
- import { codex } from "./adapters/codex.js";
8
- import { grok } from "./adapters/grok.js";
7
+ import { codex, codexWorker } from "./adapters/codex.js";
8
+ import { grok, grokWorker } from "./adapters/grok.js";
9
9
  import { listAgents } from "./agents.js";
10
10
  import { createConsult } from "./consult.js";
11
11
  import { findOnPath, install, isRegistered, serverCommand, uninstall } from "./install.js";
@@ -15,10 +15,12 @@ import { createServer } from "./server.js";
15
15
  import { listLive, registryDir } from "./sessions/registry.js";
16
16
  import { assertNotLiveSession } from "./sessions/service.js";
17
17
  import { sessionTools } from "./sessions/wire.js";
18
+ import { createTaskManager } from "./tasks.js";
18
19
  import { chooseInstallMode, isInteractive, parseSessionsFlag, promptSessionsMode, runSettings } from "./tui.js";
19
20
  import { AGENTS, EFFORTS, SESSION_ID_PATTERN } from "./types.js";
20
21
  import { VERSION } from "./version.js";
21
22
  const DEFAULT_TIMEOUT_SEC = 900;
23
+ const DEFAULT_TASK_TIMEOUT_SEC = 3600;
22
24
  const USAGE = `consult ${VERSION} - let coding agents ask each other for advice
23
25
 
24
26
  consult serve run the MCP server (stdio)
@@ -28,23 +30,34 @@ const USAGE = `consult ${VERSION} - let coding agents ask each other for advice
28
30
  consult install [--sessions off|repo|machine]
29
31
  register the MCP server with claude, codex and grok
30
32
  consult uninstall remove it from all three
31
- consult settings live sessions, and which agents use consult (interactive)
33
+ consult settings live sessions, task dispatch, and which agents use consult (interactive)
32
34
 
33
- agents: ${AGENTS.join(", ")} timeout: CONSULT_TIMEOUT_SEC (default ${DEFAULT_TIMEOUT_SEC})`;
34
- function timeoutMs() {
35
- const raw = process.env.CONSULT_TIMEOUT_SEC;
36
- const sec = raw ? Number(raw) : DEFAULT_TIMEOUT_SEC;
35
+ 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})`;
37
+ function secondsFromEnv(name, defaultSec) {
38
+ const raw = process.env[name];
39
+ const sec = raw ? Number(raw) : defaultSec;
37
40
  if (!Number.isFinite(sec) || sec <= 0)
38
- throw new Error(`CONSULT_TIMEOUT_SEC must be a positive number, got ${raw}`);
41
+ throw new Error(`${name} must be a positive number, got ${raw}`);
39
42
  return sec * 1000;
40
43
  }
44
+ const timeoutMs = () => secondsFromEnv("CONSULT_TIMEOUT_SEC", DEFAULT_TIMEOUT_SEC);
45
+ /** Invalid settings mean off; sessionTools already warns about them. */
46
+ function tasksEnabled() {
47
+ try {
48
+ return loadSettings(settingsPath()).tasks.enabled;
49
+ }
50
+ catch {
51
+ return false;
52
+ }
53
+ }
41
54
  /** Resuming a live session in place would write its transcript; those are reachable only through ask_session. */
42
55
  const refuseLive = (sessionId) => {
43
56
  if (sessionId)
44
57
  assertNotLiveSession(sessionId, listLive(registryDir()));
45
58
  };
46
59
  const consult = () => createConsult({ run, adapters: { claude, codex, grok }, timeoutMs: timeoutMs() });
47
- // Advisors run in their own process groups, so they would outlive the server. Stop them when the host
60
+ // Advisors and workers run in their own process groups, so they would outlive the server. Stop them when the host
48
61
  // disconnects or signals us.
49
62
  async function serve() {
50
63
  const shutdown = new AbortController();
@@ -59,6 +72,14 @@ async function serve() {
59
72
  askFork: (target, question, effort, signal) => askSession(target, question, effort, withShutdown(signal)),
60
73
  warn: (message) => process.stderr.write(`consult: ${message}\n`),
61
74
  });
75
+ const tasks = tasksEnabled()
76
+ ? createTaskManager({
77
+ run,
78
+ workers: { codex: codexWorker, grok: grokWorker },
79
+ timeoutMs: secondsFromEnv("CONSULT_TASK_TIMEOUT_SEC", DEFAULT_TASK_TIMEOUT_SEC),
80
+ signal: shutdown.signal,
81
+ })
82
+ : undefined;
62
83
  const server = createServer({
63
84
  ask: (request, signal) => {
64
85
  refuseLive(request.sessionId);
@@ -67,6 +88,7 @@ async function serve() {
67
88
  listAgents: () => listAgents(run),
68
89
  defaultCwd: process.cwd(),
69
90
  ...(sessions ? { sessions } : {}),
91
+ ...(tasks ? { tasks } : {}),
70
92
  });
71
93
  const transport = new StdioServerTransport();
72
94
  transport.onclose = () => shutdown.abort();
package/dist/consult.js CHANGED
@@ -2,18 +2,21 @@ import { statSync } from "node:fs";
2
2
  import { frameForkPrompt, framePrompt } from "./prompt.js";
3
3
  import { AdvisorError, } from "./types.js";
4
4
  const STDERR_TAIL = 2000;
5
- function validate(request) {
6
- if (!request.question.trim())
7
- throw new AdvisorError("question must not be empty");
5
+ export function assertDirectory(cwd) {
8
6
  let isDir = false;
9
7
  try {
10
- isDir = statSync(request.cwd).isDirectory();
8
+ isDir = statSync(cwd).isDirectory();
11
9
  }
12
10
  catch {
13
11
  // Reported below.
14
12
  }
15
13
  if (!isDir)
16
- throw new AdvisorError(`cwd is not a directory: ${request.cwd}`);
14
+ throw new AdvisorError(`cwd is not a directory: ${cwd}`);
15
+ }
16
+ function validate(request) {
17
+ if (!request.question.trim())
18
+ throw new AdvisorError("question must not be empty");
19
+ assertDirectory(request.cwd);
17
20
  }
18
21
  function stoppedMessage(agent, reason, timeoutMs) {
19
22
  switch (reason) {
@@ -25,6 +28,15 @@ function stoppedMessage(agent, reason, timeoutMs) {
25
28
  return `${agent} produced too much output and was stopped`;
26
29
  }
27
30
  }
31
+ /** Throws when the CLI was stopped or exited non-zero. */
32
+ export function assertExitedCleanly(agent, res, timeoutMs) {
33
+ if (res.stopped)
34
+ throw new AdvisorError(stoppedMessage(agent, res.stopped, timeoutMs));
35
+ if (res.code !== 0) {
36
+ const detail = (res.stderr.trim() || res.stdout.trim()).slice(-STDERR_TAIL);
37
+ throw new AdvisorError(`${agent} exited with code ${res.code}: ${detail}`);
38
+ }
39
+ }
28
40
  export function createConsult({ run, adapters, timeoutMs }) {
29
41
  async function execute(adapter, request, invoke, signal) {
30
42
  validate(request);
@@ -33,12 +45,7 @@ export function createConsult({ run, adapters, timeoutMs }) {
33
45
  const extraArgs = adapter.prepare ? await adapter.prepare(run, request.cwd, signal) : [];
34
46
  const invocation = invoke(extraArgs);
35
47
  const res = await run(invocation.command, invocation.args, { cwd: request.cwd, timeoutMs, signal });
36
- if (res.stopped)
37
- throw new AdvisorError(stoppedMessage(adapter.name, res.stopped, timeoutMs));
38
- if (res.code !== 0) {
39
- const detail = (res.stderr.trim() || res.stdout.trim()).slice(-STDERR_TAIL);
40
- throw new AdvisorError(`${adapter.name} exited with code ${res.code}: ${detail}`);
41
- }
48
+ assertExitedCleanly(adapter.name, res, timeoutMs);
42
49
  return { agent: adapter.name, ...adapter.parse(res.stdout), durationMs: Date.now() - started };
43
50
  }
44
51
  async function ask(request, signal) {
@@ -0,0 +1,10 @@
1
+ const MAX_UPDATE_CHARS = 200;
2
+ /** Collapses whitespace and shortens text to a single progress line. */
3
+ export function oneLine(text, max = MAX_UPDATE_CHARS) {
4
+ const flat = text.replace(/\s+/g, " ").trim();
5
+ return flat.length > max ? `${flat.slice(0, max - 1)}…` : flat;
6
+ }
7
+ /** `/bin/zsh -lc 'npm test'` → `npm test`: CLIs wrap every command in a login shell. */
8
+ export function unwrapShell(command) {
9
+ return /^\S*sh -lc '(.*)'$/s.exec(command)?.[1] ?? command;
10
+ }
package/dist/progress.js CHANGED
@@ -1,22 +1,30 @@
1
1
  /**
2
- * Sends a progress notification every `intervalMs` while `work` runs, if the client asked for progress.
3
- * Long advisor calls otherwise look hung, and some hosts reset their tool timeout on progress.
2
+ * Returns a function that sends one progress notification, or undefined if the client did not ask for progress.
3
+ * Progress values only go up, as the spec requires, however many sources share the function.
4
4
  */
5
- export async function withProgress(extra, intervalMs, describe, work) {
5
+ export function progressSender(extra) {
6
6
  const progressToken = extra._meta?.progressToken;
7
7
  if (progressToken === undefined)
8
- return work;
9
- const started = Date.now();
10
- let tick = 0;
11
- const timer = setInterval(() => {
12
- tick++;
13
- const message = describe(Math.round((Date.now() - started) / 1000));
8
+ return undefined;
9
+ let progress = 0;
10
+ return (message) => {
11
+ progress++;
14
12
  extra
15
- .sendNotification({ method: "notifications/progress", params: { progressToken, progress: tick, message } })
13
+ .sendNotification({ method: "notifications/progress", params: { progressToken, progress, message } })
16
14
  .catch(() => {
17
15
  // The client went away; the call itself will be cancelled.
18
16
  });
19
- }, intervalMs);
17
+ };
18
+ }
19
+ /**
20
+ * Sends a progress notification every `intervalMs` while `work` runs, if the client asked for progress.
21
+ * Long advisor calls otherwise look hung, and some hosts reset their tool timeout on progress.
22
+ */
23
+ export async function withProgress(extra, intervalMs, describe, work, send = progressSender(extra)) {
24
+ if (!send)
25
+ return work;
26
+ const started = Date.now();
27
+ const timer = setInterval(() => send(describe(Math.round((Date.now() - started) / 1000))), intervalMs);
20
28
  try {
21
29
  return await work;
22
30
  }
package/dist/prompt.js CHANGED
@@ -18,3 +18,11 @@ Be concrete and concise, and cite files and lines when relevant.`;
18
18
  export function frameForkPrompt(question) {
19
19
  return `${FORK_BRIEFING}\n\nQuestion from the other agent:\n\n${question}`;
20
20
  }
21
+ const TASK_BRIEFING = `Another AI coding agent working in this repository has dispatched you to complete a task on its behalf. Work in your current directory.
22
+ Nobody can answer questions or approve plans: do not follow brainstorming, planning or approval workflows. Make reasonable assumptions, note them, and finish the task.
23
+ Keep changes to what the task asks for. Do not commit, push, or touch anything outside this directory unless the task says to. Verify your work (build, tests) where you can.
24
+ End with a short report: what you changed (files), how you verified it, and anything left undone.`;
25
+ /** Builds the text sent to a worker. Like framePrompt, it starts with a letter. */
26
+ export function frameTaskPrompt(task) {
27
+ return `${TASK_BRIEFING}\n\nTask:\n\n${task}`;
28
+ }
package/dist/run.js CHANGED
@@ -1,15 +1,61 @@
1
1
  import { spawn } from "node:child_process";
2
+ import { StringDecoder } from "node:string_decoder";
2
3
  const KILL_GRACE_MS = 3000;
3
4
  const DEFAULT_MAX_OUTPUT_BYTES = 64 * 1024 * 1024;
5
+ // A signal handler that calls process.exit() skips the SIGKILL grace timers, so groups still running then are
6
+ // killed synchronously on the way out.
7
+ const liveGroups = new Set();
8
+ let exitHookInstalled = false;
9
+ function trackGroup(pid) {
10
+ if (!exitHookInstalled) {
11
+ exitHookInstalled = true;
12
+ process.once("exit", () => {
13
+ for (const group of liveGroups) {
14
+ try {
15
+ process.kill(-group, "SIGKILL");
16
+ }
17
+ catch {
18
+ // Group already exited.
19
+ }
20
+ }
21
+ });
22
+ }
23
+ liveGroups.add(pid);
24
+ }
25
+ /** Calls `onLine` for each complete line, without rescanning text already split. */
26
+ function lineSplitter(onLine) {
27
+ const decoder = new StringDecoder("utf8");
28
+ let partial = "";
29
+ return {
30
+ write(chunk) {
31
+ const text = decoder.write(chunk);
32
+ let start = 0;
33
+ for (let nl = text.indexOf("\n"); nl !== -1; nl = text.indexOf("\n", start)) {
34
+ onLine(partial + text.slice(start, nl));
35
+ partial = "";
36
+ start = nl + 1;
37
+ }
38
+ partial += text.slice(start);
39
+ },
40
+ end() {
41
+ const rest = partial + decoder.end();
42
+ partial = "";
43
+ if (rest)
44
+ onLine(rest);
45
+ },
46
+ };
47
+ }
4
48
  /**
5
49
  * Runs a command with stdin closed, in its own process group so that a timeout, an abort or runaway
6
50
  * output kills everything it started.
7
51
  */
8
- export function run(command, args, { cwd, timeoutMs, signal, maxOutputBytes = DEFAULT_MAX_OUTPUT_BYTES }) {
52
+ export function run(command, args, { cwd, timeoutMs, signal, maxOutputBytes = DEFAULT_MAX_OUTPUT_BYTES, onStdoutLine }) {
9
53
  if (signal?.aborted)
10
54
  return Promise.resolve({ stdout: "", stderr: "", code: null, stopped: "aborted" });
11
55
  return new Promise((resolve, reject) => {
12
56
  const child = spawn(command, args, { cwd, stdio: ["ignore", "pipe", "pipe"], detached: true });
57
+ if (child.pid)
58
+ trackGroup(child.pid);
13
59
  const stdout = [];
14
60
  const stderr = [];
15
61
  let outputBytes = 0;
@@ -33,6 +79,8 @@ export function run(command, args, { cwd, timeoutMs, signal, maxOutputBytes = DE
33
79
  };
34
80
  const onAbort = () => stop("aborted");
35
81
  const cleanup = () => {
82
+ if (child.pid)
83
+ liveGroups.delete(child.pid);
36
84
  clearTimeout(timer);
37
85
  clearTimeout(graceTimer);
38
86
  signal?.removeEventListener("abort", onAbort);
@@ -45,10 +93,15 @@ export function run(command, args, { cwd, timeoutMs, signal, maxOutputBytes = DE
45
93
  outputBytes += chunk.length;
46
94
  if (outputBytes > maxOutputBytes)
47
95
  return stop("output_limit");
48
- into.push(chunk);
96
+ into(chunk);
49
97
  };
50
- child.stdout.on("data", collect(stdout));
51
- child.stderr.on("data", collect(stderr));
98
+ const lines = onStdoutLine ? lineSplitter(onStdoutLine) : undefined;
99
+ child.stdout.on("data", collect((chunk) => (lines ? lines.write(chunk) : stdout.push(chunk))));
100
+ child.stderr.on("data", collect((chunk) => stderr.push(chunk)));
101
+ child.stdout.on("end", () => {
102
+ if (!stopped)
103
+ lines?.end();
104
+ });
52
105
  child.on("error", (err) => {
53
106
  cleanup();
54
107
  reject(err.code === "ENOENT" ? new Error(`${command} CLI not found on PATH`) : err);
package/dist/server.js CHANGED
@@ -2,6 +2,7 @@ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
2
2
  import { z } from "zod";
3
3
  import { withProgress } from "./progress.js";
4
4
  import { registerSessionTools, SESSIONS_INSTRUCTIONS } from "./session-tools.js";
5
+ import { registerTaskTools, TASKS_INSTRUCTIONS } from "./task-tools.js";
5
6
  import { AGENTS, EFFORTS, SESSION_ID_PATTERN } from "./types.js";
6
7
  import { VERSION } from "./version.js";
7
8
  const DEFAULT_PROGRESS_INTERVAL_MS = 10_000;
@@ -56,8 +57,8 @@ function formatPanel(entries) {
56
57
  const sections = entries.map((e) => "result" in e ? `## ${e.agent}\n\n${e.result.answer}\n\n${footer(e.result)}` : `## ${e.agent}\n\nFailed: ${e.error}`);
57
58
  return `${sections.join("\n\n")}\n\n---\nPass an advisor's session_id to ask_agent for a follow-up.`;
58
59
  }
59
- export function createServer({ ask, listAgents, defaultCwd, progressIntervalMs = DEFAULT_PROGRESS_INTERVAL_MS, sessions, }) {
60
- const instructions = sessions ? `${INSTRUCTIONS}\n${SESSIONS_INSTRUCTIONS}` : INSTRUCTIONS;
60
+ export function createServer({ ask, listAgents, defaultCwd, progressIntervalMs = DEFAULT_PROGRESS_INTERVAL_MS, sessions, tasks, }) {
61
+ const instructions = [INSTRUCTIONS, ...(sessions ? [SESSIONS_INSTRUCTIONS] : []), ...(tasks ? [TASKS_INSTRUCTIONS] : [])].join("\n");
61
62
  const server = new McpServer({ name: "consult", version: VERSION }, { instructions });
62
63
  server.registerTool("ask_agent", {
63
64
  title: "Ask another agent",
@@ -133,5 +134,7 @@ export function createServer({ ask, listAgents, defaultCwd, progressIntervalMs =
133
134
  });
134
135
  if (sessions)
135
136
  registerSessionTools(server, sessions, progressIntervalMs);
137
+ if (tasks)
138
+ registerTaskTools(server, tasks, defaultCwd, progressIntervalMs);
136
139
  return server;
137
140
  }
@@ -1,4 +1,4 @@
1
- import { existsSync, readdirSync, readlinkSync } from "node:fs";
1
+ import { existsSync, readdirSync, readFileSync, readlinkSync } from "node:fs";
2
2
  import { homedir } from "node:os";
3
3
  import { basename, join } from "node:path";
4
4
  import { AGENTS, SESSION_ID_PATTERN } from "../types.js";
@@ -23,11 +23,14 @@ export async function detectHost(deps) {
23
23
  export async function currentSession(host, deps) {
24
24
  switch (host.agent) {
25
25
  case "claude": {
26
- const sessionId = deps.env.CLAUDE_CODE_SESSION_ID;
26
+ // CLAUDE_CODE_SESSION_ID is fixed when this server starts, so it goes stale after /resume or /clear.
27
+ // Claude Code's per-process record follows the conversation, so it wins when there is one.
28
+ const sessionId = (host.pid === undefined ? undefined : deps.claudeSessionOf(host.pid)) ?? deps.env.CLAUDE_CODE_SESSION_ID;
27
29
  if (!sessionId || !SAFE_ID.test(sessionId))
28
30
  return undefined;
31
+ // A fork resumes the saved conversation, so the session can only be asked once its transcript exists.
29
32
  const transcriptPath = deps.claudeTranscript(sessionId);
30
- return { sessionId, ...(transcriptPath ? { transcriptPath } : {}) };
33
+ return transcriptPath ? { sessionId, transcriptPath } : undefined;
31
34
  }
32
35
  case "codex": {
33
36
  if (host.pid === undefined)
@@ -71,6 +74,7 @@ function procFds(pid) {
71
74
  }
72
75
  }
73
76
  export function systemIdentityDeps(run, env = process.env, platform = process.platform, startPid = process.ppid) {
77
+ const claudeDir = env.CLAUDE_CONFIG_DIR || join(homedir(), ".claude");
74
78
  return {
75
79
  env,
76
80
  async ancestors() {
@@ -94,8 +98,20 @@ export function systemIdentityDeps(run, env = process.env, platform = process.pl
94
98
  .filter((line) => line.startsWith("n"))
95
99
  .map((line) => line.slice(1));
96
100
  },
101
+ claudeSessionOf(pid) {
102
+ try {
103
+ const record = JSON.parse(readFileSync(join(claudeDir, "sessions", `${pid}.json`), "utf8"));
104
+ return record.pid === pid && typeof record.sessionId === "string" && SAFE_ID.test(record.sessionId)
105
+ ? record.sessionId
106
+ : undefined;
107
+ }
108
+ catch {
109
+ // No record (older Claude Code, or not a claude process) or one being rewritten: use the env var.
110
+ return undefined;
111
+ }
112
+ },
97
113
  claudeTranscript(sessionId) {
98
- const projects = join(env.CLAUDE_CONFIG_DIR || join(homedir(), ".claude"), "projects");
114
+ const projects = join(claudeDir, "projects");
99
115
  let dirs;
100
116
  try {
101
117
  dirs = readdirSync(projects);
package/dist/settings.js CHANGED
@@ -6,9 +6,11 @@ export const SESSIONS_MODES = ["off", "repo", "machine"];
6
6
  const schema = z.object({
7
7
  version: z.literal(1),
8
8
  sessions: z.object({ enabled: z.boolean(), scope: z.enum(["repo", "machine"]) }),
9
+ // Added after the first release, so files written before it have no tasks entry.
10
+ tasks: z.object({ enabled: z.boolean() }).default({ enabled: false }),
9
11
  });
10
- /** Live sessions stay off until the user turns them on. */
11
- export const DEFAULT_SETTINGS = { version: 1, sessions: { enabled: false, scope: "repo" } };
12
+ /** Live sessions and task dispatch stay off until the user turns them on. */
13
+ export const DEFAULT_SETTINGS = { version: 1, sessions: { enabled: false, scope: "repo" }, tasks: { enabled: false } };
12
14
  export class SettingsError extends Error {
13
15
  name = "SettingsError";
14
16
  }
@@ -56,3 +58,4 @@ export function withSessionsMode(settings, mode) {
56
58
  sessions: mode === "off" ? { ...settings.sessions, enabled: false } : { enabled: true, scope: mode },
57
59
  };
58
60
  }
61
+ export const withTasksEnabled = (settings, enabled) => ({ ...settings, tasks: { enabled } });
@@ -0,0 +1,151 @@
1
+ import { z } from "zod";
2
+ import { oneLine } from "./narrate.js";
3
+ import { progressSender, withProgress } from "./progress.js";
4
+ import { EFFORTS, WORKERS } from "./types.js";
5
+ export const TASKS_INSTRUCTIONS = `Task dispatch is on. dispatch_task hands a coding task to a Codex or Grok worker that edits files in the working directory in the background; task_status reports its progress and final report, and cancel_task stops it.
6
+ Dispatch work that is well defined and separable from yours, such as tests for a module or a mechanical refactor, and keep working meanwhile. Do not edit the files a running worker is changing.
7
+ The worker cannot see your conversation or ask you questions: state the goal, the files involved, the constraints and how to verify. Review its changes (git diff) before building on them.`;
8
+ const DISPATCH_DESCRIPTION = `Start a Codex or Grok agent on a coding task in the background, using the CLIs signed in on this machine. Returns a task_id at once.
9
+ The worker runs headless in the working directory and can edit files and run commands there (codex in its workspace-write sandbox without network; grok without a sandbox). It has no MCP servers and cannot ask you anything, so make the task self-contained: goal, relevant files, constraints, how to verify.
10
+ Follow it with task_status: pass wait_sec to block until it finishes, with live progress updates meanwhile.`;
11
+ const STATUS_DESCRIPTION = `Show a dispatched task's state, its latest progress updates (commands run, files edited, messages) and, once finished, the worker's final report. Without task_id, lists all tasks.
12
+ Pass wait_sec to wait for the task to finish (returns early when it does), and since (the last update seq you saw) to get only newer updates.`;
13
+ const CANCEL_DESCRIPTION = "Stop a running task and its worker process. Files it already changed stay changed.";
14
+ const MAX_WAIT_SEC = 600;
15
+ const DEFAULT_UPDATES = 20;
16
+ const taskIdSchema = z.string().min(1).describe("task_id returned by dispatch_task");
17
+ const errorMessage = (err) => (err instanceof Error ? err.message : String(err));
18
+ const seconds = (ms) => `${Math.round(ms / 1000)}s`;
19
+ const elapsed = (view) => (view.finishedAt ?? Date.now()) - view.startedAt;
20
+ const lastSeq = (view) => view.updates.at(-1)?.seq ?? 0;
21
+ function selectUpdates(view, since) {
22
+ return since === undefined ? view.updates.slice(-DEFAULT_UPDATES) : view.updates.filter((u) => u.seq > since);
23
+ }
24
+ /** Whether updates after `since` were already dropped from the task's history. */
25
+ const missedUpdates = (view, since) => since !== undefined && (view.updates[0]?.seq ?? 0) > since + 1;
26
+ const formatUpdate = (u) => `[${seconds(u.atMs)}] ${u.text}`;
27
+ const headline = (view) => `${view.id} · ${view.agent} · ${view.state} · ${seconds(elapsed(view))}`;
28
+ function structured(view, updates, since) {
29
+ return {
30
+ task_id: view.id,
31
+ agent: view.agent,
32
+ state: view.state,
33
+ cwd: view.cwd,
34
+ elapsed_ms: elapsed(view),
35
+ updates: updates.map((u) => ({ seq: u.seq, at_ms: u.atMs, text: u.text })),
36
+ last_seq: lastSeq(view),
37
+ ...(missedUpdates(view, since) ? { earlier_updates_dropped: true } : {}),
38
+ ...(view.result
39
+ ? { result: { report: view.result.answer, session_id: view.result.sessionId, ...(view.result.model ? { model: view.result.model } : {}) } }
40
+ : {}),
41
+ ...(view.error ? { error: view.error } : {}),
42
+ };
43
+ }
44
+ function formatTask(view, updates, since) {
45
+ const lines = [headline(view), `cwd: ${view.cwd}`, `task: ${oneLine(view.task, 120)}`];
46
+ if (updates.length > 0) {
47
+ const note = missedUpdates(view, since) ? " (some earlier ones were dropped)" : "";
48
+ lines.push("", `Updates${note}:`, ...updates.map(formatUpdate));
49
+ }
50
+ if (view.state === "running") {
51
+ lines.push("", `Still running. Call task_status again with since=${lastSeq(view)} for newer updates, or wait_sec to wait.`);
52
+ }
53
+ else if (view.result) {
54
+ const model = view.result.model ? ` · model: ${view.result.model}` : "";
55
+ lines.push("", "Report:", view.result.answer, "", "---", `session_id: ${view.result.sessionId}${model}`);
56
+ lines.push("Review the changes before relying on them. For a read-only follow-up, pass this agent and session_id to ask_agent.");
57
+ }
58
+ else if (view.error) {
59
+ lines.push("", `Failed: ${view.error}`);
60
+ }
61
+ return lines.join("\n");
62
+ }
63
+ function formatList(views) {
64
+ if (views.length === 0)
65
+ return "No tasks yet.";
66
+ return views.map((v) => `${headline(v)} · ${oneLine(v.task, 80)}`).join("\n");
67
+ }
68
+ export function registerTaskTools(server, tasks, defaultCwd, progressIntervalMs) {
69
+ server.registerTool("dispatch_task", {
70
+ title: "Dispatch a task",
71
+ description: DISPATCH_DESCRIPTION,
72
+ inputSchema: {
73
+ agent: z.enum(WORKERS).describe("Which CLI does the work"),
74
+ task: z.string().min(1).describe("The task, self-contained: goal, relevant files, constraints, how to verify"),
75
+ model: z.string().optional().describe("Model for that CLI. Omit for the CLI's default; list_agents shows options"),
76
+ effort: z.enum(EFFORTS).optional().describe("Reasoning effort. Omit for the CLI's default"),
77
+ cwd: z.string().optional().describe("Directory the worker works in. Defaults to the directory this server was started in"),
78
+ },
79
+ // Workers write files and run commands; each dispatch starts new work.
80
+ annotations: { readOnlyHint: false, destructiveHint: true, idempotentHint: false, openWorldHint: true },
81
+ }, async (args) => {
82
+ try {
83
+ const view = tasks.start({ agent: args.agent, task: args.task, cwd: args.cwd ?? defaultCwd, model: args.model, effort: args.effort });
84
+ const text = `Dispatched ${view.id} to ${view.agent} in ${view.cwd}.\nCall task_status with task_id "${view.id}" for progress; pass wait_sec (up to ${MAX_WAIT_SEC}) to wait for the report.`;
85
+ return { content: [{ type: "text", text }], structuredContent: structured(view, []) };
86
+ }
87
+ catch (err) {
88
+ return { isError: true, content: [{ type: "text", text: errorMessage(err) }] };
89
+ }
90
+ });
91
+ server.registerTool("task_status", {
92
+ title: "Task status",
93
+ description: STATUS_DESCRIPTION,
94
+ inputSchema: {
95
+ task_id: taskIdSchema.optional(),
96
+ wait_sec: z.number().int().min(0).max(MAX_WAIT_SEC).optional().describe("Wait up to this many seconds for the task to finish. Default 0"),
97
+ since: z.number().int().min(0).optional().describe("Only return updates after this seq (last_seq from an earlier call)"),
98
+ },
99
+ annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: false, openWorldHint: false },
100
+ }, async (args, extra) => {
101
+ try {
102
+ if (args.task_id === undefined) {
103
+ const views = tasks.list();
104
+ return { content: [{ type: "text", text: formatList(views) }], structuredContent: { tasks: views.map((v) => structured(v, [])) } };
105
+ }
106
+ const initial = tasks.get(args.task_id);
107
+ const send = progressSender(extra);
108
+ // Relay each new update as it arrives, alongside withProgress's heartbeat.
109
+ let relayed = args.since ?? lastSeq(initial);
110
+ const unsubscribe = send
111
+ ? tasks.subscribe(initial.id, (view) => {
112
+ for (const u of view.updates.filter((u) => u.seq > relayed))
113
+ send(formatUpdate(u));
114
+ relayed = Math.max(relayed, lastSeq(view));
115
+ })
116
+ : () => { };
117
+ const heartbeat = () => `${initial.id} (${initial.agent}) working, ${seconds(elapsed(initial))}`;
118
+ let view;
119
+ try {
120
+ const work = tasks.wait(initial.id, (args.wait_sec ?? 0) * 1000, extra.signal);
121
+ view = await withProgress(extra, progressIntervalMs, heartbeat, work, send);
122
+ }
123
+ finally {
124
+ unsubscribe();
125
+ }
126
+ const updates = selectUpdates(view, args.since);
127
+ return {
128
+ content: [{ type: "text", text: formatTask(view, updates, args.since) }],
129
+ structuredContent: structured(view, updates, args.since),
130
+ };
131
+ }
132
+ catch (err) {
133
+ return { isError: true, content: [{ type: "text", text: errorMessage(err) }] };
134
+ }
135
+ });
136
+ server.registerTool("cancel_task", {
137
+ title: "Cancel a task",
138
+ description: CANCEL_DESCRIPTION,
139
+ inputSchema: { task_id: taskIdSchema },
140
+ annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: true, openWorldHint: false },
141
+ }, async (args) => {
142
+ try {
143
+ const view = await tasks.cancel(args.task_id);
144
+ const updates = selectUpdates(view, undefined);
145
+ return { content: [{ type: "text", text: formatTask(view, updates) }], structuredContent: structured(view, updates) };
146
+ }
147
+ catch (err) {
148
+ return { isError: true, content: [{ type: "text", text: errorMessage(err) }] };
149
+ }
150
+ });
151
+ }
package/dist/tasks.js ADDED
@@ -0,0 +1,148 @@
1
+ import { assertDirectory, assertExitedCleanly } from "./consult.js";
2
+ import { parseJsonLines } from "./jsonl.js";
3
+ import { oneLine } from "./narrate.js";
4
+ import { frameTaskPrompt } from "./prompt.js";
5
+ import { AdvisorError } from "./types.js";
6
+ const MAX_UPDATES = 200;
7
+ const MAX_FINISHED = 50;
8
+ // Workers can stream far more than an advisor answers with (codex events carry each command's full output), so
9
+ // they get a larger cap, and only lines short enough to hold a final message are kept for parse.
10
+ const MAX_TASK_OUTPUT_BYTES = 1024 * 1024 * 1024;
11
+ const MAX_KEPT_LINE_CHARS = 256 * 1024;
12
+ const errorMessage = (err) => (err instanceof Error ? err.message : String(err));
13
+ /**
14
+ * Runs worker tasks in the background. Tasks live in memory only, so they end with the server process,
15
+ * which stops their process groups on the way out.
16
+ */
17
+ export function createTaskManager({ run, workers, timeoutMs, signal, now = Date.now }) {
18
+ const entries = new Map();
19
+ let count = 0;
20
+ function entry(id) {
21
+ const found = entries.get(id);
22
+ if (!found) {
23
+ throw new AdvisorError(`unknown task_id ${id}: tasks last as long as this consult server, which keeps the last ${MAX_FINISHED} finished ones`);
24
+ }
25
+ return found;
26
+ }
27
+ function change(id, update) {
28
+ const current = entries.get(id);
29
+ if (!current)
30
+ return;
31
+ const view = update(current.view);
32
+ entries.set(id, { ...current, view });
33
+ for (const listener of current.listeners) {
34
+ try {
35
+ listener(view);
36
+ }
37
+ catch {
38
+ // A failing listener must not change the task's outcome.
39
+ }
40
+ }
41
+ }
42
+ 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
+ }
58
+ }
59
+ function finish(id, outcome) {
60
+ change(id, (view) => ({ ...view, ...outcome, finishedAt: now() }));
61
+ }
62
+ async function execute(id, request, abort) {
63
+ const worker = workers[request.agent];
64
+ try {
65
+ // Computed per task, never cached: containment must reflect the config as it is right now.
66
+ const extraArgs = worker.prepare ? await worker.prepare(run, request.cwd, abort) : [];
67
+ const { command, args } = worker.build(request, frameTaskPrompt(request.task), extraArgs);
68
+ const kept = [];
69
+ const res = await run(command, args, {
70
+ cwd: request.cwd,
71
+ timeoutMs,
72
+ signal: abort,
73
+ maxOutputBytes: MAX_TASK_OUTPUT_BYTES,
74
+ onStdoutLine: (line) => {
75
+ if (line.length <= MAX_KEPT_LINE_CHARS)
76
+ kept.push(line);
77
+ narrate(id, worker, line);
78
+ },
79
+ });
80
+ const stdout = kept.join("\n");
81
+ assertExitedCleanly(worker.name, { ...res, stdout }, timeoutMs);
82
+ finish(id, { state: "succeeded", result: worker.parse(stdout) });
83
+ }
84
+ catch (err) {
85
+ finish(id, abort.aborted ? { state: "cancelled" } : { state: "failed", error: errorMessage(err) });
86
+ }
87
+ }
88
+ function prune() {
89
+ const finished = [...entries.values()].filter((e) => e.view.state !== "running");
90
+ for (const e of finished.slice(0, -MAX_FINISHED))
91
+ entries.delete(e.view.id);
92
+ }
93
+ /** Starts a task and returns at once; the worker keeps running in the background. */
94
+ function start(request) {
95
+ if (!request.task.trim())
96
+ throw new AdvisorError("task must not be empty");
97
+ assertDirectory(request.cwd);
98
+ if (signal?.aborted)
99
+ throw new AdvisorError("consult is shutting down");
100
+ const id = `task-${++count}`;
101
+ const controller = new AbortController();
102
+ const view = { id, agent: request.agent, task: request.task, cwd: request.cwd, state: "running", startedAt: now(), updates: [] };
103
+ entries.set(id, { view, controller, listeners: new Set() });
104
+ prune();
105
+ void execute(id, request, signal ? AbortSignal.any([controller.signal, signal]) : controller.signal);
106
+ return view;
107
+ }
108
+ const get = (id) => entry(id).view;
109
+ /** Newest first. */
110
+ const list = () => [...entries.values()].map((e) => e.view).reverse();
111
+ /** Calls `listener` with the new view on every update and when the task finishes. */
112
+ function subscribe(id, listener) {
113
+ const { listeners } = entry(id);
114
+ listeners.add(listener);
115
+ return () => listeners.delete(listener);
116
+ }
117
+ /** Resolves when the task finishes, `ms` passes (if given) or `abort` fires, whichever is first. */
118
+ function wait(id, ms, abort) {
119
+ const current = get(id);
120
+ if (current.state !== "running" || ms === 0 || abort?.aborted)
121
+ return Promise.resolve(current);
122
+ return new Promise((resolve) => {
123
+ let latest = current;
124
+ const done = () => {
125
+ clearTimeout(timer);
126
+ unsubscribe();
127
+ abort?.removeEventListener("abort", done);
128
+ resolve(latest);
129
+ };
130
+ const timer = ms === undefined ? undefined : setTimeout(done, ms);
131
+ const unsubscribe = subscribe(id, (view) => {
132
+ latest = view;
133
+ if (view.state !== "running")
134
+ done();
135
+ });
136
+ abort?.addEventListener("abort", done, { once: true });
137
+ });
138
+ }
139
+ /** Stops a running task and resolves once its process has exited. */
140
+ function cancel(id) {
141
+ const { view, controller } = entry(id);
142
+ if (view.state !== "running")
143
+ return Promise.resolve(view);
144
+ controller.abort();
145
+ return wait(id);
146
+ }
147
+ return { start, get, list, subscribe, wait, cancel };
148
+ }
package/dist/tui.js CHANGED
@@ -1,5 +1,5 @@
1
1
  import { cancel, confirm, intro, isCancel, log, multiselect, outro, select } from "@clack/prompts";
2
- import { DEFAULT_SETTINGS, SESSIONS_MODES, sessionsMode, withSessionsMode, } from "./settings.js";
2
+ import { DEFAULT_SETTINGS, SESSIONS_MODES, sessionsMode, withSessionsMode, withTasksEnabled, } from "./settings.js";
3
3
  const MODE_OPTIONS = [
4
4
  { value: "off", label: "Off", hint: "default: agents can't see each other's sessions" },
5
5
  { value: "repo", label: "Same repo", hint: "sessions in the same git repository and its worktrees" },
@@ -23,7 +23,7 @@ export async function chooseInstallMode(opts) {
23
23
  }
24
24
  export function planSettings(current, registered, chosen) {
25
25
  return {
26
- settings: withSessionsMode(current, chosen.mode),
26
+ settings: withTasksEnabled(withSessionsMode(current, chosen.mode), chosen.tasks),
27
27
  register: chosen.hosts.filter((agent) => !registered.includes(agent)),
28
28
  unregister: registered.filter((agent) => !chosen.hosts.includes(agent)),
29
29
  };
@@ -56,6 +56,12 @@ export async function runSettings(io) {
56
56
  const mode = await promptSessionsMode(sessionsMode(current));
57
57
  if (mode === undefined)
58
58
  return cancel("No changes made.");
59
+ const tasks = await confirm({
60
+ message: "Task dispatch: let agents hand tasks to Codex and Grok workers that edit files and run commands?",
61
+ initialValue: current.tasks.enabled,
62
+ });
63
+ if (isCancel(tasks))
64
+ return cancel("No changes made.");
59
65
  const hosts = io.installed.length === 0
60
66
  ? []
61
67
  : await multiselect({
@@ -66,7 +72,7 @@ export async function runSettings(io) {
66
72
  });
67
73
  if (isCancel(hosts))
68
74
  return cancel("No changes made.");
69
- const plan = planSettings(current, registered, { mode, hosts });
75
+ const plan = planSettings(current, registered, { mode, tasks, hosts });
70
76
  io.save(plan.settings);
71
77
  const changed = [...plan.register, ...plan.unregister];
72
78
  const results = [
@@ -75,5 +81,5 @@ export async function runSettings(io) {
75
81
  ].filter((r) => changed.includes(r.agent));
76
82
  for (const r of results)
77
83
  (r.ok ? log.success : log.error)(r.message);
78
- outro(`Live sessions: ${mode}. Restart running agent sessions to apply.`);
84
+ outro(`Live sessions: ${mode}. Task dispatch: ${tasks ? "on" : "off"}. Restart running agent sessions to apply.`);
79
85
  }
package/dist/types.js CHANGED
@@ -2,6 +2,7 @@ export const AGENTS = ["claude", "codex", "grok"];
2
2
  /** Session ids reach CLI argv after `--resume`; a leading `-` could be parsed as a flag. */
3
3
  export const SESSION_ID_PATTERN = /^[A-Za-z0-9][A-Za-z0-9-]*$/;
4
4
  export const EFFORTS = ["low", "medium", "high"];
5
+ export const WORKERS = ["codex", "grok"];
5
6
  export class AdvisorError extends Error {
6
7
  name = "AdvisorError";
7
8
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "consult-mcp",
3
- "version": "0.3.0",
3
+ "version": "0.4.0",
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",