consult-mcp 0.3.1 → 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 +30 -4
- package/dist/adapters/codex.js +66 -26
- package/dist/adapters/grok.js +52 -17
- package/dist/cli.js +59 -17
- package/dist/consult.js +36 -16
- package/dist/narrate.js +42 -0
- package/dist/progress.js +19 -11
- package/dist/prompt.js +8 -0
- package/dist/run.js +57 -4
- package/dist/server.js +5 -2
- package/dist/settings.js +5 -2
- package/dist/task-tools.js +151 -0
- package/dist/tasks.js +139 -0
- package/dist/tui.js +10 -4
- package/dist/types.js +1 -0
- package/package.json +1 -1
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 |
|
|
@@ -95,6 +97,18 @@ Settings are stored in `$XDG_CONFIG_HOME/consult/settings.json` (default `~/.con
|
|
|
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
|
|
@@ -120,11 +143,14 @@ The same calls are available from the shell, which is handy for debugging:
|
|
|
120
143
|
```bash
|
|
121
144
|
consult ask codex --model gpt-6-astra "Is the retry logic in src/sync.ts safe under concurrent calls?"
|
|
122
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
|
|
123
147
|
consult agents
|
|
124
|
-
consult settings # live sessions, and which agents use consult
|
|
148
|
+
consult settings # live sessions, task dispatch, and which agents use consult
|
|
125
149
|
```
|
|
126
150
|
|
|
127
|
-
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.
|
|
128
154
|
|
|
129
155
|
## Development
|
|
130
156
|
|
package/dist/adapters/codex.js
CHANGED
|
@@ -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
|
-
|
|
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
|
|
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,80 @@ 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
|
-
|
|
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
|
-
|
|
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
|
+
}
|
|
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
|
+
}
|
|
24
73
|
export const codex = {
|
|
25
74
|
name: "codex",
|
|
26
75
|
command: "codex",
|
|
27
|
-
|
|
28
|
-
const names = await listEnabledMcpServers(run, cwd, signal);
|
|
29
|
-
return [...PLUGINS_OFF, ...names.flatMap((n) => ["-c", `mcp_servers.${n}.enabled=false`])];
|
|
30
|
-
},
|
|
76
|
+
prepare,
|
|
31
77
|
build(request, prompt, extraArgs) {
|
|
32
78
|
const head = request.sessionId ? ["exec", "resume", "--json"] : ["exec", "--json"];
|
|
33
79
|
const tail = request.sessionId ? [request.sessionId, prompt] : [prompt];
|
|
34
|
-
return { command: "codex", args: [...head, "--skip-git-repo-check", ...configArgs(request, extraArgs), ...tail] };
|
|
80
|
+
return { command: "codex", args: [...head, "--skip-git-repo-check", ...configArgs(READ_ONLY, request, extraArgs), ...tail] };
|
|
35
81
|
},
|
|
36
82
|
// exec fork starts a new thread from the source's history; the source rollout is only read.
|
|
37
83
|
fork(sourceSessionId, request, prompt, extraArgs) {
|
|
38
84
|
return {
|
|
39
85
|
command: "codex",
|
|
40
|
-
args: ["exec", "fork", "--json", "--skip-git-repo-check", ...configArgs(request, extraArgs), sourceSessionId, prompt],
|
|
86
|
+
args: ["exec", "fork", "--json", "--skip-git-repo-check", ...configArgs(READ_ONLY, request, extraArgs), sourceSessionId, prompt],
|
|
41
87
|
};
|
|
42
88
|
},
|
|
43
|
-
parse
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
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 };
|
|
89
|
+
parse,
|
|
90
|
+
describe,
|
|
91
|
+
};
|
|
92
|
+
export const codexWorker = {
|
|
93
|
+
name: "codex",
|
|
94
|
+
prepare,
|
|
95
|
+
build(request, prompt, extraArgs) {
|
|
96
|
+
return { command: "codex", args: ["exec", "--json", "--skip-git-repo-check", ...configArgs(WORKSPACE_WRITE, request, extraArgs), prompt] };
|
|
59
97
|
},
|
|
98
|
+
describe,
|
|
99
|
+
parse,
|
|
60
100
|
};
|
package/dist/adapters/grok.js
CHANGED
|
@@ -1,43 +1,78 @@
|
|
|
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
|
-
|
|
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
|
-
|
|
14
|
-
|
|
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
|
+
}
|
|
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
|
+
}
|
|
21
59
|
export const grok = {
|
|
22
60
|
name: "grok",
|
|
23
61
|
command: "grok",
|
|
24
62
|
build(request, prompt) {
|
|
25
|
-
return invocation(request, prompt, request.sessionId ? ["--resume", request.sessionId] : ["--session-id", randomUUID()]);
|
|
63
|
+
return invocation(ADVISOR_TOOLS, request, prompt, request.sessionId ? ["--resume", request.sessionId] : ["--session-id", randomUUID()]);
|
|
26
64
|
},
|
|
27
65
|
fork(sourceSessionId, request, prompt) {
|
|
28
|
-
return invocation(request, prompt, ["--resume", sourceSessionId, "--fork-session"]);
|
|
66
|
+
return invocation(ADVISOR_TOOLS, request, prompt, ["--resume", sourceSessionId, "--fork-session"]);
|
|
29
67
|
},
|
|
30
|
-
parse
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
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 } : {}) };
|
|
68
|
+
parse,
|
|
69
|
+
describe,
|
|
70
|
+
};
|
|
71
|
+
export const grokWorker = {
|
|
72
|
+
name: "grok",
|
|
73
|
+
build(request, prompt) {
|
|
74
|
+
return invocation(WORKER_TOOLS, request, prompt, ["--session-id", randomUUID()]);
|
|
42
75
|
},
|
|
76
|
+
describe,
|
|
77
|
+
parse,
|
|
43
78
|
};
|
package/dist/cli.js
CHANGED
|
@@ -4,10 +4,11 @@ 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
|
+
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";
|
|
@@ -15,36 +16,53 @@ import { createServer } from "./server.js";
|
|
|
15
16
|
import { listLive, registryDir } from "./sessions/registry.js";
|
|
16
17
|
import { assertNotLiveSession } from "./sessions/service.js";
|
|
17
18
|
import { sessionTools } from "./sessions/wire.js";
|
|
19
|
+
import { createTaskManager } from "./tasks.js";
|
|
18
20
|
import { chooseInstallMode, isInteractive, parseSessionsFlag, promptSessionsMode, runSettings } from "./tui.js";
|
|
19
21
|
import { AGENTS, EFFORTS, SESSION_ID_PATTERN } from "./types.js";
|
|
20
22
|
import { VERSION } from "./version.js";
|
|
21
23
|
const DEFAULT_TIMEOUT_SEC = 900;
|
|
24
|
+
const DEFAULT_TASK_TIMEOUT_SEC = 3600;
|
|
25
|
+
const HEARTBEAT_MS = 30_000;
|
|
22
26
|
const USAGE = `consult ${VERSION} - let coding agents ask each other for advice
|
|
23
27
|
|
|
24
28
|
consult serve run the MCP server (stdio)
|
|
25
29
|
consult ask <agent> [options] <question...>
|
|
30
|
+
ask an advisor; it can read --cwd but never change anything
|
|
26
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
|
|
27
33
|
consult agents list installed CLIs and models
|
|
28
34
|
consult install [--sessions off|repo|machine]
|
|
29
35
|
register the MCP server with claude, codex and grok
|
|
30
36
|
consult uninstall remove it from all three
|
|
31
|
-
consult settings live sessions, and which agents use consult (interactive)
|
|
37
|
+
consult settings live sessions, task dispatch, and which agents use consult (interactive)
|
|
32
38
|
|
|
33
|
-
agents: ${AGENTS.join(", ")}
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
39
|
+
agents: ${AGENTS.join(", ")}
|
|
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})`;
|
|
42
|
+
function secondsFromEnv(name, defaultSec) {
|
|
43
|
+
const raw = process.env[name];
|
|
44
|
+
const sec = raw ? Number(raw) : defaultSec;
|
|
37
45
|
if (!Number.isFinite(sec) || sec <= 0)
|
|
38
|
-
throw new Error(
|
|
46
|
+
throw new Error(`${name} must be a positive number, got ${raw}`);
|
|
39
47
|
return sec * 1000;
|
|
40
48
|
}
|
|
49
|
+
const timeoutMs = () => secondsFromEnv("CONSULT_TIMEOUT_SEC", DEFAULT_TIMEOUT_SEC);
|
|
50
|
+
/** Invalid settings mean off; sessionTools already warns about them. */
|
|
51
|
+
function tasksEnabled() {
|
|
52
|
+
try {
|
|
53
|
+
return loadSettings(settingsPath()).tasks.enabled;
|
|
54
|
+
}
|
|
55
|
+
catch {
|
|
56
|
+
return false;
|
|
57
|
+
}
|
|
58
|
+
}
|
|
41
59
|
/** Resuming a live session in place would write its transcript; those are reachable only through ask_session. */
|
|
42
60
|
const refuseLive = (sessionId) => {
|
|
43
61
|
if (sessionId)
|
|
44
62
|
assertNotLiveSession(sessionId, listLive(registryDir()));
|
|
45
63
|
};
|
|
46
64
|
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
|
|
65
|
+
// Advisors and workers run in their own process groups, so they would outlive the server. Stop them when the host
|
|
48
66
|
// disconnects or signals us.
|
|
49
67
|
async function serve() {
|
|
50
68
|
const shutdown = new AbortController();
|
|
@@ -59,6 +77,14 @@ async function serve() {
|
|
|
59
77
|
askFork: (target, question, effort, signal) => askSession(target, question, effort, withShutdown(signal)),
|
|
60
78
|
warn: (message) => process.stderr.write(`consult: ${message}\n`),
|
|
61
79
|
});
|
|
80
|
+
const tasks = tasksEnabled()
|
|
81
|
+
? createTaskManager({
|
|
82
|
+
run,
|
|
83
|
+
workers: { codex: codexWorker, grok: grokWorker },
|
|
84
|
+
timeoutMs: secondsFromEnv("CONSULT_TASK_TIMEOUT_SEC", DEFAULT_TASK_TIMEOUT_SEC),
|
|
85
|
+
signal: shutdown.signal,
|
|
86
|
+
})
|
|
87
|
+
: undefined;
|
|
62
88
|
const server = createServer({
|
|
63
89
|
ask: (request, signal) => {
|
|
64
90
|
refuseLive(request.sessionId);
|
|
@@ -67,6 +93,7 @@ async function serve() {
|
|
|
67
93
|
listAgents: () => listAgents(run),
|
|
68
94
|
defaultCwd: process.cwd(),
|
|
69
95
|
...(sessions ? { sessions } : {}),
|
|
96
|
+
...(tasks ? { tasks } : {}),
|
|
70
97
|
});
|
|
71
98
|
const transport = new StdioServerTransport();
|
|
72
99
|
transport.onclose = () => shutdown.abort();
|
|
@@ -80,6 +107,12 @@ async function serve() {
|
|
|
80
107
|
}
|
|
81
108
|
await server.connect(transport);
|
|
82
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
|
+
}
|
|
83
116
|
async function askCommand(argv) {
|
|
84
117
|
const { values, positionals } = parseArgs({
|
|
85
118
|
args: [...argv],
|
|
@@ -95,14 +128,23 @@ async function askCommand(argv) {
|
|
|
95
128
|
throw new Error("--resume must be a session id: letters, digits and hyphens, not starting with a hyphen");
|
|
96
129
|
}
|
|
97
130
|
refuseLive(values.resume);
|
|
98
|
-
const
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
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
|
+
}
|
|
106
148
|
process.stdout.write(`${result.answer}\n`);
|
|
107
149
|
process.stderr.write(`\n[${result.agent}${result.model ? ` ${result.model}` : ""} · session ${result.sessionId} · ${(result.durationMs / 1000).toFixed(1)}s]\n`);
|
|
108
150
|
}
|
package/dist/consult.js
CHANGED
|
@@ -1,19 +1,23 @@
|
|
|
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;
|
|
5
|
-
function
|
|
6
|
-
if (!request.question.trim())
|
|
7
|
-
throw new AdvisorError("question must not be empty");
|
|
6
|
+
export function assertDirectory(cwd) {
|
|
8
7
|
let isDir = false;
|
|
9
8
|
try {
|
|
10
|
-
isDir = statSync(
|
|
9
|
+
isDir = statSync(cwd).isDirectory();
|
|
11
10
|
}
|
|
12
11
|
catch {
|
|
13
12
|
// Reported below.
|
|
14
13
|
}
|
|
15
14
|
if (!isDir)
|
|
16
|
-
throw new AdvisorError(`cwd is not a directory: ${
|
|
15
|
+
throw new AdvisorError(`cwd is not a directory: ${cwd}`);
|
|
16
|
+
}
|
|
17
|
+
function validate(request) {
|
|
18
|
+
if (!request.question.trim())
|
|
19
|
+
throw new AdvisorError("question must not be empty");
|
|
20
|
+
assertDirectory(request.cwd);
|
|
17
21
|
}
|
|
18
22
|
function stoppedMessage(agent, reason, timeoutMs) {
|
|
19
23
|
switch (reason) {
|
|
@@ -25,26 +29,42 @@ function stoppedMessage(agent, reason, timeoutMs) {
|
|
|
25
29
|
return `${agent} produced too much output and was stopped`;
|
|
26
30
|
}
|
|
27
31
|
}
|
|
32
|
+
/** Throws when the CLI was stopped or exited non-zero. */
|
|
33
|
+
export function assertExitedCleanly(agent, res, timeoutMs) {
|
|
34
|
+
if (res.stopped)
|
|
35
|
+
throw new AdvisorError(stoppedMessage(agent, res.stopped, timeoutMs));
|
|
36
|
+
if (res.code !== 0) {
|
|
37
|
+
const detail = (res.stderr.trim() || res.stdout.trim()).slice(-STDERR_TAIL);
|
|
38
|
+
throw new AdvisorError(`${agent} exited with code ${res.code}: ${detail}`);
|
|
39
|
+
}
|
|
40
|
+
}
|
|
28
41
|
export function createConsult({ run, adapters, timeoutMs }) {
|
|
29
|
-
async function execute(adapter, request, invoke, signal) {
|
|
42
|
+
async function execute(adapter, request, invoke, signal, onUpdate) {
|
|
30
43
|
validate(request);
|
|
31
44
|
const started = Date.now();
|
|
32
45
|
// Computed per call, never cached: containment must reflect the config as it is right now.
|
|
33
46
|
const extraArgs = adapter.prepare ? await adapter.prepare(run, request.cwd, signal) : [];
|
|
34
47
|
const invocation = invoke(extraArgs);
|
|
35
|
-
const
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
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 };
|
|
43
62
|
}
|
|
44
|
-
|
|
63
|
+
/** `onUpdate` receives one-line progress updates for CLIs that stream their work (codex, grok). */
|
|
64
|
+
async function ask(request, signal, onUpdate) {
|
|
45
65
|
const adapter = adapters[request.agent];
|
|
46
66
|
const prompt = framePrompt(request.question, Boolean(request.sessionId));
|
|
47
|
-
return execute(adapter, request, (extra) => adapter.build(request, prompt, extra), signal);
|
|
67
|
+
return execute(adapter, request, (extra) => adapter.build(request, prompt, extra), signal, onUpdate);
|
|
48
68
|
}
|
|
49
69
|
/** Asks a read-only fork of a live session. It runs in that session's directory, where its CLI can find it. */
|
|
50
70
|
async function askSession(target, question, effort, signal) {
|
package/dist/narrate.js
ADDED
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import { parseJsonLines } from "./jsonl.js";
|
|
2
|
+
const MAX_UPDATE_CHARS = 200;
|
|
3
|
+
/** Collapses whitespace and shortens text to a single progress line. */
|
|
4
|
+
export function oneLine(text, max = MAX_UPDATE_CHARS) {
|
|
5
|
+
const flat = text.replace(/\s+/g, " ").trim();
|
|
6
|
+
return flat.length > max ? `${flat.slice(0, max - 1)}…` : flat;
|
|
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
|
+
}
|
|
22
|
+
/** `/bin/zsh -lc 'npm test'` → `npm test`: CLIs wrap every command in a login shell. */
|
|
23
|
+
export function unwrapShell(command) {
|
|
24
|
+
return /^\S*sh -lc '(.*)'$/s.exec(command)?.[1] ?? command;
|
|
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/progress.js
CHANGED
|
@@ -1,22 +1,30 @@
|
|
|
1
1
|
/**
|
|
2
|
-
*
|
|
3
|
-
*
|
|
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
|
|
5
|
+
export function progressSender(extra) {
|
|
6
6
|
const progressToken = extra._meta?.progressToken;
|
|
7
7
|
if (progressToken === undefined)
|
|
8
|
-
return
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
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
|
|
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
|
-
}
|
|
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
|
|
96
|
+
into(chunk);
|
|
49
97
|
};
|
|
50
|
-
|
|
51
|
-
child.
|
|
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 ?
|
|
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
|
}
|
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,139 @@
|
|
|
1
|
+
import { assertDirectory, assertExitedCleanly } from "./consult.js";
|
|
2
|
+
import { describeLine } from "./narrate.js";
|
|
3
|
+
import { frameTaskPrompt } from "./prompt.js";
|
|
4
|
+
import { AdvisorError } from "./types.js";
|
|
5
|
+
const MAX_UPDATES = 200;
|
|
6
|
+
const MAX_FINISHED = 50;
|
|
7
|
+
// Workers can stream far more than an advisor answers with (codex events carry each command's full output), so
|
|
8
|
+
// they get a larger cap, and only lines short enough to hold a final message are kept for parse.
|
|
9
|
+
const MAX_TASK_OUTPUT_BYTES = 1024 * 1024 * 1024;
|
|
10
|
+
const MAX_KEPT_LINE_CHARS = 256 * 1024;
|
|
11
|
+
const errorMessage = (err) => (err instanceof Error ? err.message : String(err));
|
|
12
|
+
/**
|
|
13
|
+
* Runs worker tasks in the background. Tasks live in memory only, so they end with the server process,
|
|
14
|
+
* which stops their process groups on the way out.
|
|
15
|
+
*/
|
|
16
|
+
export function createTaskManager({ run, workers, timeoutMs, signal, now = Date.now }) {
|
|
17
|
+
const entries = new Map();
|
|
18
|
+
let count = 0;
|
|
19
|
+
function entry(id) {
|
|
20
|
+
const found = entries.get(id);
|
|
21
|
+
if (!found) {
|
|
22
|
+
throw new AdvisorError(`unknown task_id ${id}: tasks last as long as this consult server, which keeps the last ${MAX_FINISHED} finished ones`);
|
|
23
|
+
}
|
|
24
|
+
return found;
|
|
25
|
+
}
|
|
26
|
+
function change(id, update) {
|
|
27
|
+
const current = entries.get(id);
|
|
28
|
+
if (!current)
|
|
29
|
+
return;
|
|
30
|
+
const view = update(current.view);
|
|
31
|
+
entries.set(id, { ...current, view });
|
|
32
|
+
for (const listener of current.listeners) {
|
|
33
|
+
try {
|
|
34
|
+
listener(view);
|
|
35
|
+
}
|
|
36
|
+
catch {
|
|
37
|
+
// A failing listener must not change the task's outcome.
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
function narrate(id, worker, line) {
|
|
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
|
+
});
|
|
49
|
+
}
|
|
50
|
+
function finish(id, outcome) {
|
|
51
|
+
change(id, (view) => ({ ...view, ...outcome, finishedAt: now() }));
|
|
52
|
+
}
|
|
53
|
+
async function execute(id, request, abort) {
|
|
54
|
+
const worker = workers[request.agent];
|
|
55
|
+
try {
|
|
56
|
+
// Computed per task, never cached: containment must reflect the config as it is right now.
|
|
57
|
+
const extraArgs = worker.prepare ? await worker.prepare(run, request.cwd, abort) : [];
|
|
58
|
+
const { command, args } = worker.build(request, frameTaskPrompt(request.task), extraArgs);
|
|
59
|
+
const kept = [];
|
|
60
|
+
const res = await run(command, args, {
|
|
61
|
+
cwd: request.cwd,
|
|
62
|
+
timeoutMs,
|
|
63
|
+
signal: abort,
|
|
64
|
+
maxOutputBytes: MAX_TASK_OUTPUT_BYTES,
|
|
65
|
+
onStdoutLine: (line) => {
|
|
66
|
+
if (line.length <= MAX_KEPT_LINE_CHARS)
|
|
67
|
+
kept.push(line);
|
|
68
|
+
narrate(id, worker, line);
|
|
69
|
+
},
|
|
70
|
+
});
|
|
71
|
+
const stdout = kept.join("\n");
|
|
72
|
+
assertExitedCleanly(worker.name, { ...res, stdout }, timeoutMs);
|
|
73
|
+
finish(id, { state: "succeeded", result: worker.parse(stdout) });
|
|
74
|
+
}
|
|
75
|
+
catch (err) {
|
|
76
|
+
finish(id, abort.aborted ? { state: "cancelled" } : { state: "failed", error: errorMessage(err) });
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
function prune() {
|
|
80
|
+
const finished = [...entries.values()].filter((e) => e.view.state !== "running");
|
|
81
|
+
for (const e of finished.slice(0, -MAX_FINISHED))
|
|
82
|
+
entries.delete(e.view.id);
|
|
83
|
+
}
|
|
84
|
+
/** Starts a task and returns at once; the worker keeps running in the background. */
|
|
85
|
+
function start(request) {
|
|
86
|
+
if (!request.task.trim())
|
|
87
|
+
throw new AdvisorError("task must not be empty");
|
|
88
|
+
assertDirectory(request.cwd);
|
|
89
|
+
if (signal?.aborted)
|
|
90
|
+
throw new AdvisorError("consult is shutting down");
|
|
91
|
+
const id = `task-${++count}`;
|
|
92
|
+
const controller = new AbortController();
|
|
93
|
+
const view = { id, agent: request.agent, task: request.task, cwd: request.cwd, state: "running", startedAt: now(), updates: [] };
|
|
94
|
+
entries.set(id, { view, controller, listeners: new Set() });
|
|
95
|
+
prune();
|
|
96
|
+
void execute(id, request, signal ? AbortSignal.any([controller.signal, signal]) : controller.signal);
|
|
97
|
+
return view;
|
|
98
|
+
}
|
|
99
|
+
const get = (id) => entry(id).view;
|
|
100
|
+
/** Newest first. */
|
|
101
|
+
const list = () => [...entries.values()].map((e) => e.view).reverse();
|
|
102
|
+
/** Calls `listener` with the new view on every update and when the task finishes. */
|
|
103
|
+
function subscribe(id, listener) {
|
|
104
|
+
const { listeners } = entry(id);
|
|
105
|
+
listeners.add(listener);
|
|
106
|
+
return () => listeners.delete(listener);
|
|
107
|
+
}
|
|
108
|
+
/** Resolves when the task finishes, `ms` passes (if given) or `abort` fires, whichever is first. */
|
|
109
|
+
function wait(id, ms, abort) {
|
|
110
|
+
const current = get(id);
|
|
111
|
+
if (current.state !== "running" || ms === 0 || abort?.aborted)
|
|
112
|
+
return Promise.resolve(current);
|
|
113
|
+
return new Promise((resolve) => {
|
|
114
|
+
let latest = current;
|
|
115
|
+
const done = () => {
|
|
116
|
+
clearTimeout(timer);
|
|
117
|
+
unsubscribe();
|
|
118
|
+
abort?.removeEventListener("abort", done);
|
|
119
|
+
resolve(latest);
|
|
120
|
+
};
|
|
121
|
+
const timer = ms === undefined ? undefined : setTimeout(done, ms);
|
|
122
|
+
const unsubscribe = subscribe(id, (view) => {
|
|
123
|
+
latest = view;
|
|
124
|
+
if (view.state !== "running")
|
|
125
|
+
done();
|
|
126
|
+
});
|
|
127
|
+
abort?.addEventListener("abort", done, { once: true });
|
|
128
|
+
});
|
|
129
|
+
}
|
|
130
|
+
/** Stops a running task and resolves once its process has exited. */
|
|
131
|
+
function cancel(id) {
|
|
132
|
+
const { view, controller } = entry(id);
|
|
133
|
+
if (view.state !== "running")
|
|
134
|
+
return Promise.resolve(view);
|
|
135
|
+
controller.abort();
|
|
136
|
+
return wait(id);
|
|
137
|
+
}
|
|
138
|
+
return { start, get, list, subscribe, wait, cancel };
|
|
139
|
+
}
|
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