consult-mcp 0.2.1 → 0.3.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 +33 -0
- package/dist/adapters/claude.js +20 -15
- package/dist/adapters/codex.js +17 -8
- package/dist/adapters/grok.js +18 -13
- package/dist/cli.js +79 -8
- package/dist/codex-config.js +4 -0
- package/dist/consult.js +16 -6
- package/dist/install.js +16 -2
- package/dist/prompt.js +7 -0
- package/dist/server.js +8 -4
- package/dist/session-tools.js +76 -0
- package/dist/sessions/describe.js +72 -0
- package/dist/sessions/identity.js +125 -0
- package/dist/sessions/live.js +54 -0
- package/dist/sessions/registry.js +81 -0
- package/dist/sessions/scope.js +27 -0
- package/dist/sessions/service.js +43 -0
- package/dist/sessions/wire.js +50 -0
- package/dist/settings.js +58 -0
- package/dist/tui.js +79 -0
- package/dist/types.js +2 -0
- package/package.json +2 -1
package/README.md
CHANGED
|
@@ -52,6 +52,8 @@ To pin to a specific build instead of following `@latest`, install globally with
|
|
|
52
52
|
|
|
53
53
|
**`list_agents`** shows the installed CLIs, their versions and the model IDs each account can use.
|
|
54
54
|
|
|
55
|
+
**`list_sessions`** and **`ask_session`** `{session_id, question, effort?}` appear when [live sessions](#live-sessions) are on.
|
|
56
|
+
|
|
55
57
|
While an advisor is working, the server sends MCP progress notifications (`codex is thinking (40s)`), so hosts that show progress don't look stuck.
|
|
56
58
|
|
|
57
59
|
## When agents consult
|
|
@@ -65,6 +67,34 @@ The server's MCP instructions, which hosts add to the agent's context, tell it t
|
|
|
65
67
|
|
|
66
68
|
The instructions also tell it to prefer an advisor from a different vendor, to write self-contained questions (the advisor can read the repo but not the conversation), and to check advice against the code before acting on it.
|
|
67
69
|
|
|
70
|
+
## Live sessions
|
|
71
|
+
|
|
72
|
+
Off by default. When on, agents can ask the agent sessions you already have running, not only fresh advisors. For example, the Codex session working on your API can ask the Claude Code session that wrote the client what it changed.
|
|
73
|
+
|
|
74
|
+
- **`list_sessions`** lists other live sessions: agent, session id, directory, git branch, title, and when each started and was last active. Nothing else from their conversations.
|
|
75
|
+
- **`ask_session`** `{session_id, question, effort?}` asks a read-only **copy** of that session. consult forks it (`claude --resume <id> --fork-session`, `codex exec fork <id>`, `grok --resume <id> --fork-session`) with the same containment as any advisor. The copy knows everything the session has seen, while the session itself is not interrupted and never sees the question. Pass the reply's `session_id` and `cwd` to `ask_agent` for follow-ups.
|
|
76
|
+
|
|
77
|
+
Turn it on when you install, or later:
|
|
78
|
+
|
|
79
|
+
```bash
|
|
80
|
+
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
|
|
82
|
+
```
|
|
83
|
+
|
|
84
|
+
| Setting | Who can see and ask whom |
|
|
85
|
+
| --- | --- |
|
|
86
|
+
| `off` (default) | Nobody. The tools don't appear. |
|
|
87
|
+
| `repo` | Sessions in the same git repository, including its worktrees (outside git: the same directory) |
|
|
88
|
+
| `machine` | Every consult session you run on this machine |
|
|
89
|
+
|
|
90
|
+
Settings are stored in `$XDG_CONFIG_HOME/consult/settings.json` (default `~/.config/consult/`). Running sessions register in `$XDG_STATE_HOME/consult/sessions/` (default `~/.local/state/consult/`), and only you can read those files. Turning sessions on takes effect in new agent sessions; turning them off, or changing the scope, applies immediately.
|
|
91
|
+
|
|
92
|
+
| CLI | How consult finds the session |
|
|
93
|
+
| --- | --- |
|
|
94
|
+
| 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
|
+
| codex | the `rollout-*.jsonl` file the codex process holds open, from the session's first turn on |
|
|
96
|
+
| 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
|
+
|
|
68
98
|
## How advisors are contained
|
|
69
99
|
|
|
70
100
|
Every advisor runs headless and read-only, and has no MCP servers. So an advisor can't call consult again, which prevents loops.
|
|
@@ -75,6 +105,8 @@ Every advisor runs headless and read-only, and has no MCP servers. So an advisor
|
|
|
75
105
|
| codex | `exec`, `sandbox_mode="read-only"`, `approval_policy="never"`, plugins off, and every enabled MCP server disabled by name (Codex runs MCP tools outside its sandbox) |
|
|
76
106
|
| grok | `--tools read_file,grep,list_dir --disallowed-tools Agent,search_tool,use_tool` (the MCP helper tools can reach servers that write files) |
|
|
77
107
|
|
|
108
|
+
`ask_session` copies use the same flags, plus `--resume <id> --fork-session` (claude, grok) or `exec fork <id>` (codex), so the live session's own transcript is only read.
|
|
109
|
+
|
|
78
110
|
consult doesn't use Grok's kernel sandbox (`--sandbox read-only`), because it refuses to start on machines where `/var/run/docker.sock` is a symlink, as it often is on Macs with Docker installed.
|
|
79
111
|
|
|
80
112
|
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.
|
|
@@ -89,6 +121,7 @@ The same calls are available from the shell, which is handy for debugging:
|
|
|
89
121
|
consult ask codex --model gpt-6-astra "Is the retry logic in src/sync.ts safe under concurrent calls?"
|
|
90
122
|
consult ask codex --resume <session_id> "What would you change first?"
|
|
91
123
|
consult agents
|
|
124
|
+
consult settings # live sessions, and which agents use consult
|
|
92
125
|
```
|
|
93
126
|
|
|
94
127
|
Use `npx -y consult-mcp@latest <command>` if it isn't installed globally. `CONSULT_TIMEOUT_SEC` (default 900) caps each advisor call.
|
package/dist/adapters/claude.js
CHANGED
|
@@ -2,25 +2,30 @@ import { randomUUID } from "node:crypto";
|
|
|
2
2
|
import { AdvisorError } from "../types.js";
|
|
3
3
|
// --restricted drops every tool that runs commands or code and confines file tools to the cwd;
|
|
4
4
|
// --strict-mcp-config with no --mcp-config loads no MCP servers, so the advisor cannot call consult back.
|
|
5
|
+
const CONTAINMENT = ["--restricted", "--tools", "Read,Grep,Glob", "--strict-mcp-config"];
|
|
6
|
+
function invocation(request, prompt, session) {
|
|
7
|
+
return {
|
|
8
|
+
command: "claude",
|
|
9
|
+
args: [
|
|
10
|
+
"-p",
|
|
11
|
+
"--output-format", "json",
|
|
12
|
+
...CONTAINMENT,
|
|
13
|
+
...session,
|
|
14
|
+
...(request.model ? ["--model", request.model] : []),
|
|
15
|
+
...(request.effort ? ["--effort", request.effort] : []),
|
|
16
|
+
prompt,
|
|
17
|
+
],
|
|
18
|
+
};
|
|
19
|
+
}
|
|
5
20
|
export const claude = {
|
|
6
21
|
name: "claude",
|
|
7
22
|
command: "claude",
|
|
8
23
|
build(request, prompt) {
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
"--output-format", "json",
|
|
15
|
-
"--restricted",
|
|
16
|
-
"--tools", "Read,Grep,Glob",
|
|
17
|
-
"--strict-mcp-config",
|
|
18
|
-
...session,
|
|
19
|
-
...(request.model ? ["--model", request.model] : []),
|
|
20
|
-
...(request.effort ? ["--effort", request.effort] : []),
|
|
21
|
-
prompt,
|
|
22
|
-
],
|
|
23
|
-
};
|
|
24
|
+
return invocation(request, prompt, request.sessionId ? ["--resume", request.sessionId] : ["--session-id", randomUUID()]);
|
|
25
|
+
},
|
|
26
|
+
// --fork-session copies the source into a new session id, so the live session's transcript is never written.
|
|
27
|
+
fork(sourceSessionId, request, prompt) {
|
|
28
|
+
return invocation(request, prompt, ["--resume", sourceSessionId, "--fork-session"]);
|
|
24
29
|
},
|
|
25
30
|
parse(stdout) {
|
|
26
31
|
let out;
|
package/dist/adapters/codex.js
CHANGED
|
@@ -12,6 +12,15 @@ async function listEnabledMcpServers(run, cwd, signal) {
|
|
|
12
12
|
const servers = JSON.parse(res.stdout);
|
|
13
13
|
return servers.filter((s) => s.enabled).map((s) => s.name);
|
|
14
14
|
}
|
|
15
|
+
function configArgs(request, extraArgs) {
|
|
16
|
+
return [
|
|
17
|
+
"-c", 'sandbox_mode="read-only"',
|
|
18
|
+
"-c", 'approval_policy="never"',
|
|
19
|
+
...(request.model ? ["-c", `model=${JSON.stringify(request.model)}`] : []),
|
|
20
|
+
...(request.effort ? ["-c", `model_reasoning_effort="${request.effort}"`] : []),
|
|
21
|
+
...extraArgs,
|
|
22
|
+
];
|
|
23
|
+
}
|
|
15
24
|
export const codex = {
|
|
16
25
|
name: "codex",
|
|
17
26
|
command: "codex",
|
|
@@ -20,16 +29,16 @@ export const codex = {
|
|
|
20
29
|
return [...PLUGINS_OFF, ...names.flatMap((n) => ["-c", `mcp_servers.${n}.enabled=false`])];
|
|
21
30
|
},
|
|
22
31
|
build(request, prompt, extraArgs) {
|
|
23
|
-
const config = [
|
|
24
|
-
"-c", 'sandbox_mode="read-only"',
|
|
25
|
-
"-c", 'approval_policy="never"',
|
|
26
|
-
...(request.model ? ["-c", `model=${JSON.stringify(request.model)}`] : []),
|
|
27
|
-
...(request.effort ? ["-c", `model_reasoning_effort="${request.effort}"`] : []),
|
|
28
|
-
...extraArgs,
|
|
29
|
-
];
|
|
30
32
|
const head = request.sessionId ? ["exec", "resume", "--json"] : ["exec", "--json"];
|
|
31
33
|
const tail = request.sessionId ? [request.sessionId, prompt] : [prompt];
|
|
32
|
-
return { command: "codex", args: [...head, "--skip-git-repo-check", ...
|
|
34
|
+
return { command: "codex", args: [...head, "--skip-git-repo-check", ...configArgs(request, extraArgs), ...tail] };
|
|
35
|
+
},
|
|
36
|
+
// exec fork starts a new thread from the source's history; the source rollout is only read.
|
|
37
|
+
fork(sourceSessionId, request, prompt, extraArgs) {
|
|
38
|
+
return {
|
|
39
|
+
command: "codex",
|
|
40
|
+
args: ["exec", "fork", "--json", "--skip-git-repo-check", ...configArgs(request, extraArgs), sourceSessionId, prompt],
|
|
41
|
+
};
|
|
33
42
|
},
|
|
34
43
|
parse(stdout) {
|
|
35
44
|
const events = parseJsonLines(stdout);
|
package/dist/adapters/grok.js
CHANGED
|
@@ -4,23 +4,28 @@ import { parseJsonLines } from "../jsonl.js";
|
|
|
4
4
|
// --tools keeps only read-only built-ins, but grok still adds its MCP meta-tools (search_tool/use_tool),
|
|
5
5
|
// which can reach write-capable MCP servers, so those are removed too. Grok's kernel sandbox is not used:
|
|
6
6
|
// it refuses to start on machines where /var/run/docker.sock is a symlink.
|
|
7
|
+
function invocation(request, prompt, session) {
|
|
8
|
+
return {
|
|
9
|
+
command: "grok",
|
|
10
|
+
args: [
|
|
11
|
+
"--single", prompt,
|
|
12
|
+
"--output-format", "streaming-messages-json",
|
|
13
|
+
"--tools", "read_file,grep,list_dir",
|
|
14
|
+
"--disallowed-tools", "Agent,search_tool,use_tool",
|
|
15
|
+
...session,
|
|
16
|
+
...(request.model ? ["--model", request.model] : []),
|
|
17
|
+
...(request.effort ? ["--reasoning-effort", request.effort] : []),
|
|
18
|
+
],
|
|
19
|
+
};
|
|
20
|
+
}
|
|
7
21
|
export const grok = {
|
|
8
22
|
name: "grok",
|
|
9
23
|
command: "grok",
|
|
10
24
|
build(request, prompt) {
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
"--single", prompt,
|
|
16
|
-
"--output-format", "streaming-messages-json",
|
|
17
|
-
"--tools", "read_file,grep,list_dir",
|
|
18
|
-
"--disallowed-tools", "Agent,search_tool,use_tool",
|
|
19
|
-
...session,
|
|
20
|
-
...(request.model ? ["--model", request.model] : []),
|
|
21
|
-
...(request.effort ? ["--reasoning-effort", request.effort] : []),
|
|
22
|
-
],
|
|
23
|
-
};
|
|
25
|
+
return invocation(request, prompt, request.sessionId ? ["--resume", request.sessionId] : ["--session-id", randomUUID()]);
|
|
26
|
+
},
|
|
27
|
+
fork(sourceSessionId, request, prompt) {
|
|
28
|
+
return invocation(request, prompt, ["--resume", sourceSessionId, "--fork-session"]);
|
|
24
29
|
},
|
|
25
30
|
parse(stdout) {
|
|
26
31
|
const events = parseJsonLines(stdout);
|
package/dist/cli.js
CHANGED
|
@@ -8,10 +8,15 @@ import { codex } from "./adapters/codex.js";
|
|
|
8
8
|
import { grok } from "./adapters/grok.js";
|
|
9
9
|
import { listAgents } from "./agents.js";
|
|
10
10
|
import { createConsult } from "./consult.js";
|
|
11
|
-
import { findOnPath, install, serverCommand, uninstall } from "./install.js";
|
|
11
|
+
import { findOnPath, install, isRegistered, serverCommand, uninstall } from "./install.js";
|
|
12
|
+
import { DEFAULT_SETTINGS, loadSettings, saveSettings, sessionsMode, settingsPath, withSessionsMode } from "./settings.js";
|
|
12
13
|
import { run } from "./run.js";
|
|
13
14
|
import { createServer } from "./server.js";
|
|
14
|
-
import {
|
|
15
|
+
import { listLive, registryDir } from "./sessions/registry.js";
|
|
16
|
+
import { assertNotLiveSession } from "./sessions/service.js";
|
|
17
|
+
import { sessionTools } from "./sessions/wire.js";
|
|
18
|
+
import { chooseInstallMode, isInteractive, parseSessionsFlag, promptSessionsMode, runSettings } from "./tui.js";
|
|
19
|
+
import { AGENTS, EFFORTS, SESSION_ID_PATTERN } from "./types.js";
|
|
15
20
|
import { VERSION } from "./version.js";
|
|
16
21
|
const DEFAULT_TIMEOUT_SEC = 900;
|
|
17
22
|
const USAGE = `consult ${VERSION} - let coding agents ask each other for advice
|
|
@@ -20,8 +25,10 @@ const USAGE = `consult ${VERSION} - let coding agents ask each other for advice
|
|
|
20
25
|
consult ask <agent> [options] <question...>
|
|
21
26
|
--model <id> --effort low|medium|high --resume <session_id> --cwd <dir>
|
|
22
27
|
consult agents list installed CLIs and models
|
|
23
|
-
consult install
|
|
28
|
+
consult install [--sessions off|repo|machine]
|
|
29
|
+
register the MCP server with claude, codex and grok
|
|
24
30
|
consult uninstall remove it from all three
|
|
31
|
+
consult settings live sessions, and which agents use consult (interactive)
|
|
25
32
|
|
|
26
33
|
agents: ${AGENTS.join(", ")} timeout: CONSULT_TIMEOUT_SEC (default ${DEFAULT_TIMEOUT_SEC})`;
|
|
27
34
|
function timeoutMs() {
|
|
@@ -31,16 +38,35 @@ function timeoutMs() {
|
|
|
31
38
|
throw new Error(`CONSULT_TIMEOUT_SEC must be a positive number, got ${raw}`);
|
|
32
39
|
return sec * 1000;
|
|
33
40
|
}
|
|
41
|
+
/** Resuming a live session in place would write its transcript; those are reachable only through ask_session. */
|
|
42
|
+
const refuseLive = (sessionId) => {
|
|
43
|
+
if (sessionId)
|
|
44
|
+
assertNotLiveSession(sessionId, listLive(registryDir()));
|
|
45
|
+
};
|
|
34
46
|
const consult = () => createConsult({ run, adapters: { claude, codex, grok }, timeoutMs: timeoutMs() });
|
|
35
47
|
// Advisors run in their own process groups, so they would outlive the server. Stop them when the host
|
|
36
48
|
// disconnects or signals us.
|
|
37
49
|
async function serve() {
|
|
38
50
|
const shutdown = new AbortController();
|
|
39
|
-
const
|
|
51
|
+
const withShutdown = (signal) => (signal ? AbortSignal.any([signal, shutdown.signal]) : shutdown.signal);
|
|
52
|
+
const { ask, askSession } = consult();
|
|
53
|
+
const sessions = await sessionTools({
|
|
54
|
+
run,
|
|
55
|
+
env: process.env,
|
|
56
|
+
cwd: process.cwd(),
|
|
57
|
+
pid: process.pid,
|
|
58
|
+
signal: shutdown.signal,
|
|
59
|
+
askFork: (target, question, effort, signal) => askSession(target, question, effort, withShutdown(signal)),
|
|
60
|
+
warn: (message) => process.stderr.write(`consult: ${message}\n`),
|
|
61
|
+
});
|
|
40
62
|
const server = createServer({
|
|
41
|
-
ask: (request, signal) =>
|
|
63
|
+
ask: (request, signal) => {
|
|
64
|
+
refuseLive(request.sessionId);
|
|
65
|
+
return ask(request, withShutdown(signal));
|
|
66
|
+
},
|
|
42
67
|
listAgents: () => listAgents(run),
|
|
43
68
|
defaultCwd: process.cwd(),
|
|
69
|
+
...(sessions ? { sessions } : {}),
|
|
44
70
|
});
|
|
45
71
|
const transport = new StdioServerTransport();
|
|
46
72
|
transport.onclose = () => shutdown.abort();
|
|
@@ -65,6 +91,10 @@ async function askCommand(argv) {
|
|
|
65
91
|
throw new Error(`agent must be one of ${AGENTS.join(", ")}`);
|
|
66
92
|
if (values.effort && !EFFORTS.includes(values.effort))
|
|
67
93
|
throw new Error(`effort must be one of ${EFFORTS.join(", ")}`);
|
|
94
|
+
if (values.resume !== undefined && !SESSION_ID_PATTERN.test(values.resume)) {
|
|
95
|
+
throw new Error("--resume must be a session id: letters, digits and hyphens, not starting with a hyphen");
|
|
96
|
+
}
|
|
97
|
+
refuseLive(values.resume);
|
|
68
98
|
const result = await consult().ask({
|
|
69
99
|
agent: agent,
|
|
70
100
|
question: words.join(" "),
|
|
@@ -77,6 +107,27 @@ async function askCommand(argv) {
|
|
|
77
107
|
process.stderr.write(`\n[${result.agent}${result.model ? ` ${result.model}` : ""} · session ${result.sessionId} · ${(result.durationMs / 1000).toFixed(1)}s]\n`);
|
|
78
108
|
}
|
|
79
109
|
const installedAgents = () => AGENTS.filter((agent) => findOnPath(agent, process.env.PATH ?? ""));
|
|
110
|
+
const scriptPath = () => realpathSync(fileURLToPath(import.meta.url));
|
|
111
|
+
const searchPath = () => process.env.PATH ?? "";
|
|
112
|
+
const errorMessage = (err) => (err instanceof Error ? err.message : String(err));
|
|
113
|
+
/** Applies --sessions, or asks in a terminal. With neither, the setting stays as it is. */
|
|
114
|
+
async function configureSessions(flag) {
|
|
115
|
+
const path = settingsPath();
|
|
116
|
+
let current = DEFAULT_SETTINGS;
|
|
117
|
+
try {
|
|
118
|
+
current = loadSettings(path);
|
|
119
|
+
}
|
|
120
|
+
catch (err) {
|
|
121
|
+
if (!flag) {
|
|
122
|
+
process.stderr.write(`consult: ${errorMessage(err)}\n`);
|
|
123
|
+
return;
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
const mode = await chooseInstallMode({ flag, current: sessionsMode(current), interactive: isInteractive(), prompt: promptSessionsMode });
|
|
127
|
+
if (mode !== undefined)
|
|
128
|
+
saveSettings(path, withSessionsMode(current, mode));
|
|
129
|
+
process.stdout.write(`Live sessions: ${mode ?? sessionsMode(current)} (change with \`consult settings\`)\n`);
|
|
130
|
+
}
|
|
80
131
|
/** Prints one line per CLI and returns whether every step succeeded. */
|
|
81
132
|
function report(results) {
|
|
82
133
|
process.stdout.write(`${results.map((r) => r.message).join("\n")}\n`);
|
|
@@ -96,11 +147,31 @@ async function main(argv) {
|
|
|
96
147
|
process.stdout.write(`${JSON.stringify(await listAgents(run), null, 2)}\n`);
|
|
97
148
|
return;
|
|
98
149
|
case "install": {
|
|
99
|
-
const
|
|
100
|
-
const
|
|
101
|
-
if (report(await install(run, serverCommand(
|
|
150
|
+
const { values } = parseArgs({ args: [...rest], options: { sessions: { type: "string" } } });
|
|
151
|
+
const flag = parseSessionsFlag(values.sessions);
|
|
152
|
+
if (report(await install(run, serverCommand(scriptPath(), searchPath()), installedAgents()))) {
|
|
153
|
+
await configureSessions(flag);
|
|
102
154
|
process.stdout.write("Restart running agent sessions to pick up the new tools.\n");
|
|
103
155
|
}
|
|
156
|
+
else if (flag) {
|
|
157
|
+
process.stderr.write("consult: live sessions setting left unchanged because registration failed\n");
|
|
158
|
+
}
|
|
159
|
+
return;
|
|
160
|
+
}
|
|
161
|
+
case "settings": {
|
|
162
|
+
if (!isInteractive()) {
|
|
163
|
+
throw new Error("consult settings needs an interactive terminal; in scripts use `consult install --sessions=off|repo|machine`");
|
|
164
|
+
}
|
|
165
|
+
const cmd = serverCommand(scriptPath(), searchPath());
|
|
166
|
+
const path = settingsPath();
|
|
167
|
+
await runSettings({
|
|
168
|
+
load: () => loadSettings(path),
|
|
169
|
+
save: (settings) => saveSettings(path, settings),
|
|
170
|
+
installed: installedAgents(),
|
|
171
|
+
isRegistered: (agent) => isRegistered(run, agent),
|
|
172
|
+
register: (agents) => install(run, cmd, agents),
|
|
173
|
+
unregister: (agents) => uninstall(run, agents),
|
|
174
|
+
});
|
|
104
175
|
return;
|
|
105
176
|
}
|
|
106
177
|
case "uninstall":
|
package/dist/codex-config.js
CHANGED
|
@@ -56,6 +56,10 @@ function withoutConsult(config) {
|
|
|
56
56
|
return Object.keys(otherServers).length > 0 ? { ...rest, mcp_servers: otherServers } : rest;
|
|
57
57
|
}
|
|
58
58
|
const hasConsult = (config) => CODEX_SERVER in (config.mcp_servers ?? {});
|
|
59
|
+
/** Whether the codex config has a consult entry. A missing file means not registered. */
|
|
60
|
+
export function isCodexRegistered(path) {
|
|
61
|
+
return existsSync(path) && hasConsult(parseConfig(readFileSync(path, "utf8"), path));
|
|
62
|
+
}
|
|
59
63
|
function writeAtomically(path, text) {
|
|
60
64
|
mkdirSync(dirname(path), { recursive: true });
|
|
61
65
|
if (existsSync(path))
|
package/dist/consult.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { statSync } from "node:fs";
|
|
2
|
-
import { framePrompt } from "./prompt.js";
|
|
3
|
-
import { AdvisorError } from "./types.js";
|
|
2
|
+
import { frameForkPrompt, framePrompt } from "./prompt.js";
|
|
3
|
+
import { AdvisorError, } from "./types.js";
|
|
4
4
|
const STDERR_TAIL = 2000;
|
|
5
5
|
function validate(request) {
|
|
6
6
|
if (!request.question.trim())
|
|
@@ -26,13 +26,12 @@ function stoppedMessage(agent, reason, timeoutMs) {
|
|
|
26
26
|
}
|
|
27
27
|
}
|
|
28
28
|
export function createConsult({ run, adapters, timeoutMs }) {
|
|
29
|
-
async function
|
|
29
|
+
async function execute(adapter, request, invoke, signal) {
|
|
30
30
|
validate(request);
|
|
31
|
-
const adapter = adapters[request.agent];
|
|
32
31
|
const started = Date.now();
|
|
33
32
|
// Computed per call, never cached: containment must reflect the config as it is right now.
|
|
34
33
|
const extraArgs = adapter.prepare ? await adapter.prepare(run, request.cwd, signal) : [];
|
|
35
|
-
const invocation =
|
|
34
|
+
const invocation = invoke(extraArgs);
|
|
36
35
|
const res = await run(invocation.command, invocation.args, { cwd: request.cwd, timeoutMs, signal });
|
|
37
36
|
if (res.stopped)
|
|
38
37
|
throw new AdvisorError(stoppedMessage(adapter.name, res.stopped, timeoutMs));
|
|
@@ -42,5 +41,16 @@ export function createConsult({ run, adapters, timeoutMs }) {
|
|
|
42
41
|
}
|
|
43
42
|
return { agent: adapter.name, ...adapter.parse(res.stdout), durationMs: Date.now() - started };
|
|
44
43
|
}
|
|
45
|
-
|
|
44
|
+
async function ask(request, signal) {
|
|
45
|
+
const adapter = adapters[request.agent];
|
|
46
|
+
const prompt = framePrompt(request.question, Boolean(request.sessionId));
|
|
47
|
+
return execute(adapter, request, (extra) => adapter.build(request, prompt, extra), signal);
|
|
48
|
+
}
|
|
49
|
+
/** Asks a read-only fork of a live session. It runs in that session's directory, where its CLI can find it. */
|
|
50
|
+
async function askSession(target, question, effort, signal) {
|
|
51
|
+
const adapter = adapters[target.agent];
|
|
52
|
+
const request = { agent: target.agent, question, cwd: target.cwd, ...(effort ? { effort } : {}) };
|
|
53
|
+
return execute(adapter, request, (extra) => adapter.fork(target.sessionId, request, frameForkPrompt(question), extra), signal);
|
|
54
|
+
}
|
|
55
|
+
return { ask, askSession };
|
|
46
56
|
}
|
package/dist/install.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { accessSync, constants } from "node:fs";
|
|
2
2
|
import { homedir } from "node:os";
|
|
3
3
|
import { delimiter, dirname, join } from "node:path";
|
|
4
|
-
import { codexConfigPath, editConfig } from "./codex-config.js";
|
|
4
|
+
import { codexConfigPath, editConfig, isCodexRegistered } from "./codex-config.js";
|
|
5
5
|
import { AGENTS } from "./types.js";
|
|
6
6
|
const SYSTEM_PATH = ["/usr/bin", "/bin", "/usr/sbin", "/sbin"];
|
|
7
7
|
export const SERVER_NAME = "consult";
|
|
@@ -10,7 +10,7 @@ export const PACKAGE_NAME = "consult-mcp";
|
|
|
10
10
|
export const CODEX_TOOL_TIMEOUT_SEC = 960;
|
|
11
11
|
/** Codex's default 10s startup timeout is too short for npx fetching a new release. */
|
|
12
12
|
export const CODEX_STARTUP_TIMEOUT_SEC = 60;
|
|
13
|
-
export { codexConfigPath, removeTomlTable } from "./codex-config.js";
|
|
13
|
+
export { codexConfigPath, isCodexRegistered, removeTomlTable } from "./codex-config.js";
|
|
14
14
|
// Package-runner caches that get cleaned up, so a host must never be pointed at a script inside one.
|
|
15
15
|
const NPX_CACHE = /[\\/]_npx[\\/]/;
|
|
16
16
|
const OTHER_RUNNER_CACHE = /[\\/](bunx-[^\\/]*|dlx-[^\\/]*|\.pnpm-store|\.yarn[\\/]berry)[\\/]/;
|
|
@@ -84,6 +84,20 @@ async function unregisterFromCli(run, agent) {
|
|
|
84
84
|
const res = await run(agent, ["mcp", "remove", SERVER_NAME, "-s", "user"], CLI_OPTS);
|
|
85
85
|
return res.code === 0 ? `${agent}: removed` : `${agent}: not registered (${res.stderr.trim()})`;
|
|
86
86
|
}
|
|
87
|
+
const GROK_ENTRY = new RegExp(`^\\s*${SERVER_NAME}:`, "m");
|
|
88
|
+
/** Whether consult is currently registered with this CLI. */
|
|
89
|
+
export async function isRegistered(run, agent, codexPath = codexConfigPath()) {
|
|
90
|
+
switch (agent) {
|
|
91
|
+
case "claude":
|
|
92
|
+
return (await run("claude", ["mcp", "get", SERVER_NAME], CLI_OPTS)).code === 0;
|
|
93
|
+
case "grok": {
|
|
94
|
+
const res = await run("grok", ["mcp", "list"], CLI_OPTS);
|
|
95
|
+
return res.code === 0 && GROK_ENTRY.test(res.stdout);
|
|
96
|
+
}
|
|
97
|
+
case "codex":
|
|
98
|
+
return isCodexRegistered(codexPath);
|
|
99
|
+
}
|
|
100
|
+
}
|
|
87
101
|
/** Runs one step per installed CLI. One CLI failing does not stop the others. */
|
|
88
102
|
async function forEachInstalled(installed, steps) {
|
|
89
103
|
const results = [];
|
package/dist/prompt.js
CHANGED
|
@@ -11,3 +11,10 @@ export function framePrompt(question, isFollowUp) {
|
|
|
11
11
|
? `Follow-up from the consulting agent:\n\n${question}`
|
|
12
12
|
: `${BRIEFING}\n\nQuestion from the consulting agent:\n\n${question}`;
|
|
13
13
|
}
|
|
14
|
+
const FORK_BRIEFING = `You are a read-only copy of this session, made so that another AI coding agent on this machine can ask you a question. The user is not here, and the original session carries on without you.
|
|
15
|
+
Answer from what you know of this session and the files you can read. Do not continue or redo your earlier task, do not try to modify files or run commands that change state, and do not follow brainstorming, planning or approval workflows.
|
|
16
|
+
Be concrete and concise, and cite files and lines when relevant.`;
|
|
17
|
+
/** Builds the question sent to a fork of a live session. Like framePrompt, it starts with a letter. */
|
|
18
|
+
export function frameForkPrompt(question) {
|
|
19
|
+
return `${FORK_BRIEFING}\n\nQuestion from the other agent:\n\n${question}`;
|
|
20
|
+
}
|
package/dist/server.js
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
2
2
|
import { z } from "zod";
|
|
3
3
|
import { withProgress } from "./progress.js";
|
|
4
|
-
import {
|
|
4
|
+
import { registerSessionTools, SESSIONS_INSTRUCTIONS } from "./session-tools.js";
|
|
5
|
+
import { AGENTS, EFFORTS, SESSION_ID_PATTERN } from "./types.js";
|
|
5
6
|
import { VERSION } from "./version.js";
|
|
6
7
|
const DEFAULT_PROGRESS_INTERVAL_MS = 10_000;
|
|
7
8
|
// Hosts put this in the agent's context, so it is where the agent learns when consulting is worth a call.
|
|
@@ -55,8 +56,9 @@ function formatPanel(entries) {
|
|
|
55
56
|
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}`);
|
|
56
57
|
return `${sections.join("\n\n")}\n\n---\nPass an advisor's session_id to ask_agent for a follow-up.`;
|
|
57
58
|
}
|
|
58
|
-
export function createServer({ ask, listAgents, defaultCwd, progressIntervalMs = DEFAULT_PROGRESS_INTERVAL_MS, }) {
|
|
59
|
-
const
|
|
59
|
+
export function createServer({ ask, listAgents, defaultCwd, progressIntervalMs = DEFAULT_PROGRESS_INTERVAL_MS, sessions, }) {
|
|
60
|
+
const instructions = sessions ? `${INSTRUCTIONS}\n${SESSIONS_INSTRUCTIONS}` : INSTRUCTIONS;
|
|
61
|
+
const server = new McpServer({ name: "consult", version: VERSION }, { instructions });
|
|
60
62
|
server.registerTool("ask_agent", {
|
|
61
63
|
title: "Ask another agent",
|
|
62
64
|
description: ASK_DESCRIPTION,
|
|
@@ -65,7 +67,7 @@ export function createServer({ ask, listAgents, defaultCwd, progressIntervalMs =
|
|
|
65
67
|
question: questionSchema,
|
|
66
68
|
model: z.string().optional().describe("Model for that CLI, e.g. gpt-6-astra, opus, grok-4.7. Omit for the CLI's default; list_agents shows options"),
|
|
67
69
|
effort: effortSchema,
|
|
68
|
-
session_id: z.string().optional().describe("session_id from an earlier reply, to ask a follow-up"),
|
|
70
|
+
session_id: z.string().regex(SESSION_ID_PATTERN).optional().describe("session_id from an earlier reply, to ask a follow-up"),
|
|
69
71
|
cwd: cwdSchema,
|
|
70
72
|
},
|
|
71
73
|
annotations: ADVISOR_HINTS,
|
|
@@ -129,5 +131,7 @@ export function createServer({ ask, listAgents, defaultCwd, progressIntervalMs =
|
|
|
129
131
|
const agents = await listAgents();
|
|
130
132
|
return { content: [{ type: "text", text: JSON.stringify(agents, null, 2) }], structuredContent: { agents } };
|
|
131
133
|
});
|
|
134
|
+
if (sessions)
|
|
135
|
+
registerSessionTools(server, sessions, progressIntervalMs);
|
|
132
136
|
return server;
|
|
133
137
|
}
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
import { withProgress } from "./progress.js";
|
|
3
|
+
import { EFFORTS, SESSION_ID_PATTERN } from "./types.js";
|
|
4
|
+
export const SESSIONS_INSTRUCTIONS = `Live sessions are on. list_sessions shows other agent sessions running on this machine; ask_session asks a read-only copy of one of them, which knows that session's whole conversation.
|
|
5
|
+
Ask one when another agent has context you need: it wrote the code you are changing, or it works on the other side of an interface you share. The original session is not interrupted and never sees your question.`;
|
|
6
|
+
const LIST_DESCRIPTION = `List other AI coding agent sessions (Claude Code, Codex, Grok) running live on this machine that you can ask with ask_session: agent, session_id, directory, git branch, title, and when each started and was last active. Only sessions allowed by the user's live-sessions scope are shown.`;
|
|
7
|
+
const ASK_DESCRIPTION = `Ask a question of a live session from list_sessions. consult forks that session into a read-only copy, so the answer draws on everything the session has seen and done; the original keeps working and never sees your question.
|
|
8
|
+
The copy runs headless and read-only: it cannot edit files or change anything. It replays the session's conversation, so it can take longer than ask_agent. The reply includes the copy's session_id and cwd: pass both, with the same agent, to ask_agent for a follow-up.`;
|
|
9
|
+
// Forks only read files; replies differ between calls and come from the session's model provider.
|
|
10
|
+
const FORK_HINTS = { readOnlyHint: true, destructiveHint: false, idempotentHint: false, openWorldHint: true };
|
|
11
|
+
const errorMessage = (err) => (err instanceof Error ? err.message : String(err));
|
|
12
|
+
function footer(result) {
|
|
13
|
+
return [
|
|
14
|
+
`agent: ${result.agent}`,
|
|
15
|
+
...(result.model ? [`model: ${result.model}`] : []),
|
|
16
|
+
`forked from: ${result.source}`,
|
|
17
|
+
`session_id: ${result.sessionId}`,
|
|
18
|
+
`cwd: ${result.cwd}`,
|
|
19
|
+
`${(result.durationMs / 1000).toFixed(1)}s`,
|
|
20
|
+
].join(" · ");
|
|
21
|
+
}
|
|
22
|
+
export function registerSessionTools(server, deps, progressIntervalMs) {
|
|
23
|
+
server.registerTool("list_sessions", {
|
|
24
|
+
title: "List live sessions",
|
|
25
|
+
description: LIST_DESCRIPTION,
|
|
26
|
+
inputSchema: {},
|
|
27
|
+
annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: false, openWorldHint: false },
|
|
28
|
+
}, async () => {
|
|
29
|
+
try {
|
|
30
|
+
const sessions = deps.list();
|
|
31
|
+
const body = sessions.length > 0 ? JSON.stringify(sessions, null, 2) : "No other live sessions in scope.";
|
|
32
|
+
return { content: [{ type: "text", text: body }], structuredContent: { sessions } };
|
|
33
|
+
}
|
|
34
|
+
catch (err) {
|
|
35
|
+
return { isError: true, content: [{ type: "text", text: errorMessage(err) }] };
|
|
36
|
+
}
|
|
37
|
+
});
|
|
38
|
+
server.registerTool("ask_session", {
|
|
39
|
+
title: "Ask a live session",
|
|
40
|
+
description: ASK_DESCRIPTION,
|
|
41
|
+
inputSchema: {
|
|
42
|
+
session_id: z.string().regex(SESSION_ID_PATTERN).describe("session_id from list_sessions"),
|
|
43
|
+
question: z
|
|
44
|
+
.string()
|
|
45
|
+
.min(1)
|
|
46
|
+
.describe("The question. The copy knows its own session's conversation but not yours, so include what it needs to know about your side"),
|
|
47
|
+
effort: z.enum(EFFORTS).optional().describe("Reasoning effort. Omit for the CLI's default"),
|
|
48
|
+
},
|
|
49
|
+
annotations: FORK_HINTS,
|
|
50
|
+
}, async (args, extra) => {
|
|
51
|
+
try {
|
|
52
|
+
const work = deps.ask(args.session_id, args.question, args.effort, extra.signal);
|
|
53
|
+
const result = await withProgress(extra, progressIntervalMs, (s) => `asking a copy of ${args.session_id} (${s}s)`, work);
|
|
54
|
+
return {
|
|
55
|
+
content: [
|
|
56
|
+
{
|
|
57
|
+
type: "text",
|
|
58
|
+
text: `${result.answer}\n\n---\n${footer(result)}\nFor a follow-up, call ask_agent with this agent, session_id and cwd.`,
|
|
59
|
+
},
|
|
60
|
+
],
|
|
61
|
+
structuredContent: {
|
|
62
|
+
agent: result.agent,
|
|
63
|
+
answer: result.answer,
|
|
64
|
+
session_id: result.sessionId,
|
|
65
|
+
source_session_id: result.source,
|
|
66
|
+
cwd: result.cwd,
|
|
67
|
+
...(result.model ? { model: result.model } : {}),
|
|
68
|
+
duration_ms: result.durationMs,
|
|
69
|
+
},
|
|
70
|
+
};
|
|
71
|
+
}
|
|
72
|
+
catch (err) {
|
|
73
|
+
return { isError: true, content: [{ type: "text", text: errorMessage(err) }] };
|
|
74
|
+
}
|
|
75
|
+
});
|
|
76
|
+
}
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
import { closeSync, fstatSync, openSync, readSync, statSync } from "node:fs";
|
|
2
|
+
import { dirname, join } from "node:path";
|
|
3
|
+
import { codexConfigPath } from "../codex-config.js";
|
|
4
|
+
const TAIL_BYTES = 2 * 1024 * 1024;
|
|
5
|
+
const MAX_TITLE = 120;
|
|
6
|
+
export function codexIndexPath(env = process.env) {
|
|
7
|
+
return join(dirname(codexConfigPath(env)), "session_index.jsonl");
|
|
8
|
+
}
|
|
9
|
+
/** The last `bytes` of a file, without the partial line it may start in. */
|
|
10
|
+
export function readTail(path, bytes = TAIL_BYTES) {
|
|
11
|
+
let fd;
|
|
12
|
+
try {
|
|
13
|
+
fd = openSync(path, "r");
|
|
14
|
+
const size = fstatSync(fd).size;
|
|
15
|
+
const start = Math.max(0, size - bytes);
|
|
16
|
+
const buffer = Buffer.alloc(size - start);
|
|
17
|
+
readSync(fd, buffer, 0, buffer.length, start);
|
|
18
|
+
const text = buffer.toString("utf8");
|
|
19
|
+
return start === 0 ? text : text.slice(text.indexOf("\n") + 1);
|
|
20
|
+
}
|
|
21
|
+
catch {
|
|
22
|
+
return undefined;
|
|
23
|
+
}
|
|
24
|
+
finally {
|
|
25
|
+
if (fd !== undefined)
|
|
26
|
+
closeSync(fd);
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
/** The string `pick` returns for the last JSON line that contains `needle` (checked before parsing, to stay cheap). */
|
|
30
|
+
function lastMatch(text, needle, pick) {
|
|
31
|
+
const lines = text?.split("\n") ?? [];
|
|
32
|
+
for (let i = lines.length - 1; i >= 0; i--) {
|
|
33
|
+
const line = lines[i];
|
|
34
|
+
if (!line.includes(needle))
|
|
35
|
+
continue;
|
|
36
|
+
try {
|
|
37
|
+
const value = pick(JSON.parse(line));
|
|
38
|
+
if (typeof value === "string" && value)
|
|
39
|
+
return value;
|
|
40
|
+
}
|
|
41
|
+
catch {
|
|
42
|
+
// Not JSON, or cut by the tail.
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
return undefined;
|
|
46
|
+
}
|
|
47
|
+
function mtime(path) {
|
|
48
|
+
try {
|
|
49
|
+
return statSync(path).mtime.toISOString();
|
|
50
|
+
}
|
|
51
|
+
catch {
|
|
52
|
+
return undefined;
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
function title(entry, codexIndex) {
|
|
56
|
+
switch (entry.agent) {
|
|
57
|
+
case "claude":
|
|
58
|
+
return entry.transcriptPath
|
|
59
|
+
? lastMatch(readTail(entry.transcriptPath), '"ai-title"', (row) => (row.type === "ai-title" ? row.aiTitle : undefined))
|
|
60
|
+
: undefined;
|
|
61
|
+
case "codex":
|
|
62
|
+
return lastMatch(readTail(codexIndex), entry.sessionId, (row) => (row.id === entry.sessionId ? row.thread_name : undefined));
|
|
63
|
+
case "grok":
|
|
64
|
+
return undefined;
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
/** The session's title and last activity, from the host's own files. Nothing else from the conversation is read. */
|
|
68
|
+
export function describeSession(entry, codexIndex) {
|
|
69
|
+
const name = title(entry, codexIndex)?.slice(0, MAX_TITLE);
|
|
70
|
+
const lastActive = entry.transcriptPath ? mtime(entry.transcriptPath) : undefined;
|
|
71
|
+
return { ...(name ? { title: name } : {}), ...(lastActive ? { lastActive } : {}) };
|
|
72
|
+
}
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
import { existsSync, readdirSync, readFileSync, readlinkSync } from "node:fs";
|
|
2
|
+
import { homedir } from "node:os";
|
|
3
|
+
import { basename, join } from "node:path";
|
|
4
|
+
import { AGENTS, SESSION_ID_PATTERN } from "../types.js";
|
|
5
|
+
const MAX_DEPTH = 6;
|
|
6
|
+
const PROC_OPTS = { cwd: "/", timeoutMs: 5_000 };
|
|
7
|
+
const PS_LINE = /^\s*(\d+)\s+(\S.*?)\s*$/;
|
|
8
|
+
const SAFE_ID = SESSION_ID_PATTERN;
|
|
9
|
+
const ROLLOUT = /\/sessions\/(?:.*\/)?rollout-[^/]*-([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})\.jsonl$/;
|
|
10
|
+
const GROK_EVENTS = /\/\.grok\/sessions\/[^/]+\/([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})\/events\.jsonl$/;
|
|
11
|
+
const isAgent = (name) => AGENTS.includes(name);
|
|
12
|
+
/**
|
|
13
|
+
* The nearest agent CLI among this server's ancestors. An agent started from another agent's terminal inherits
|
|
14
|
+
* that agent's environment, so the process tree decides; CLAUDE_CODE_SESSION_ID is only a fallback for claude
|
|
15
|
+
* builds that run under a different process name.
|
|
16
|
+
*/
|
|
17
|
+
export async function detectHost(deps) {
|
|
18
|
+
const nearest = (await deps.ancestors()).find((p) => isAgent(p.name));
|
|
19
|
+
if (nearest)
|
|
20
|
+
return { agent: nearest.name, pid: nearest.pid };
|
|
21
|
+
return deps.env.CLAUDE_CODE_SESSION_ID ? { agent: "claude" } : undefined;
|
|
22
|
+
}
|
|
23
|
+
export async function currentSession(host, deps) {
|
|
24
|
+
switch (host.agent) {
|
|
25
|
+
case "claude": {
|
|
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;
|
|
29
|
+
if (!sessionId || !SAFE_ID.test(sessionId))
|
|
30
|
+
return undefined;
|
|
31
|
+
// A fork resumes the saved conversation, so the session can only be asked once its transcript exists.
|
|
32
|
+
const transcriptPath = deps.claudeTranscript(sessionId);
|
|
33
|
+
return transcriptPath ? { sessionId, transcriptPath } : undefined;
|
|
34
|
+
}
|
|
35
|
+
case "codex": {
|
|
36
|
+
if (host.pid === undefined)
|
|
37
|
+
return undefined;
|
|
38
|
+
// Subagent threads open rollouts too. The main thread opened its rollout first, and the name starts
|
|
39
|
+
// with that timestamp, so it sorts first.
|
|
40
|
+
const rollouts = (await deps.openFiles(host.pid))
|
|
41
|
+
.filter((f) => ROLLOUT.test(f))
|
|
42
|
+
.sort((a, b) => basename(a).localeCompare(basename(b)));
|
|
43
|
+
const transcriptPath = rollouts[0];
|
|
44
|
+
const sessionId = transcriptPath ? ROLLOUT.exec(transcriptPath)?.[1] : undefined;
|
|
45
|
+
return transcriptPath && sessionId ? { sessionId, transcriptPath } : undefined;
|
|
46
|
+
}
|
|
47
|
+
case "grok": {
|
|
48
|
+
if (host.pid === undefined)
|
|
49
|
+
return undefined;
|
|
50
|
+
// An interactive grok holds ~/.grok/sessions/<encoded cwd>/<session id>/events.jsonl open.
|
|
51
|
+
// Several distinct ids (subagent, /new) are ambiguous, so refuse rather than risk advertising the wrong one.
|
|
52
|
+
const matches = (await deps.openFiles(host.pid)).flatMap((f) => {
|
|
53
|
+
const id = GROK_EVENTS.exec(f)?.[1];
|
|
54
|
+
return id ? [{ sessionId: id, transcriptPath: f }] : [];
|
|
55
|
+
});
|
|
56
|
+
const ids = new Set(matches.map((m) => m.sessionId));
|
|
57
|
+
return ids.size === 1 ? matches[0] : undefined;
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
function procFds(pid) {
|
|
62
|
+
try {
|
|
63
|
+
return readdirSync(`/proc/${pid}/fd`).flatMap((fd) => {
|
|
64
|
+
try {
|
|
65
|
+
return [readlinkSync(`/proc/${pid}/fd/${fd}`)];
|
|
66
|
+
}
|
|
67
|
+
catch {
|
|
68
|
+
return [];
|
|
69
|
+
}
|
|
70
|
+
});
|
|
71
|
+
}
|
|
72
|
+
catch {
|
|
73
|
+
return [];
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
export function systemIdentityDeps(run, env = process.env, platform = process.platform, startPid = process.ppid) {
|
|
77
|
+
const claudeDir = env.CLAUDE_CONFIG_DIR || join(homedir(), ".claude");
|
|
78
|
+
return {
|
|
79
|
+
env,
|
|
80
|
+
async ancestors() {
|
|
81
|
+
const chain = [];
|
|
82
|
+
for (let pid = startPid; pid > 1 && chain.length < MAX_DEPTH;) {
|
|
83
|
+
const res = await run("ps", ["-o", "ppid=,comm=", "-p", String(pid)], PROC_OPTS).catch(() => undefined);
|
|
84
|
+
const match = res?.code === 0 ? PS_LINE.exec(res.stdout) : null;
|
|
85
|
+
if (!match)
|
|
86
|
+
break;
|
|
87
|
+
chain.push({ pid, name: basename(match[2].split(/\s+/)[0]) });
|
|
88
|
+
pid = Number(match[1]);
|
|
89
|
+
}
|
|
90
|
+
return chain;
|
|
91
|
+
},
|
|
92
|
+
async openFiles(pid) {
|
|
93
|
+
if (platform === "linux")
|
|
94
|
+
return procFds(pid);
|
|
95
|
+
const res = await run("lsof", ["-p", String(pid), "-Fn"], PROC_OPTS).catch(() => undefined);
|
|
96
|
+
return (res?.stdout ?? "")
|
|
97
|
+
.split("\n")
|
|
98
|
+
.filter((line) => line.startsWith("n"))
|
|
99
|
+
.map((line) => line.slice(1));
|
|
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
|
+
},
|
|
113
|
+
claudeTranscript(sessionId) {
|
|
114
|
+
const projects = join(claudeDir, "projects");
|
|
115
|
+
let dirs;
|
|
116
|
+
try {
|
|
117
|
+
dirs = readdirSync(projects);
|
|
118
|
+
}
|
|
119
|
+
catch {
|
|
120
|
+
return undefined;
|
|
121
|
+
}
|
|
122
|
+
return dirs.map((dir) => join(projects, dir, `${sessionId}.jsonl`)).find((path) => existsSync(path));
|
|
123
|
+
},
|
|
124
|
+
};
|
|
125
|
+
}
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import { currentSession, detectHost } from "./identity.js";
|
|
2
|
+
import { removeEntry, writeEntry } from "./registry.js";
|
|
3
|
+
import { gitBranch, repoKey } from "./scope.js";
|
|
4
|
+
const errorMessage = (err) => (err instanceof Error ? err.message : String(err));
|
|
5
|
+
/**
|
|
6
|
+
* Registers the host session this server runs in, so other sessions can find it, and keeps the entry current:
|
|
7
|
+
* codex opens its rollout only after the first turn, `/new` starts a new thread, and the branch can change.
|
|
8
|
+
* The entry is removed when `signal` aborts. Readers prune the entries of servers that crash.
|
|
9
|
+
*/
|
|
10
|
+
export async function startLiveSession(opts) {
|
|
11
|
+
const key = await repoKey(opts.run, opts.cwd);
|
|
12
|
+
const startedAt = (opts.now ?? (() => new Date()))().toISOString();
|
|
13
|
+
const host = await detectHost(opts.identity);
|
|
14
|
+
let written;
|
|
15
|
+
async function refresh() {
|
|
16
|
+
if (!host || opts.signal.aborted)
|
|
17
|
+
return;
|
|
18
|
+
const [identity, branch] = await Promise.all([
|
|
19
|
+
currentSession(host, opts.identity).catch(() => undefined),
|
|
20
|
+
gitBranch(opts.run, opts.cwd),
|
|
21
|
+
]);
|
|
22
|
+
if (!identity || opts.signal.aborted)
|
|
23
|
+
return;
|
|
24
|
+
const entry = {
|
|
25
|
+
version: 1,
|
|
26
|
+
pid: opts.pid,
|
|
27
|
+
agent: host.agent,
|
|
28
|
+
sessionId: identity.sessionId,
|
|
29
|
+
cwd: opts.cwd,
|
|
30
|
+
repoKey: key,
|
|
31
|
+
startedAt,
|
|
32
|
+
...(branch ? { branch } : {}),
|
|
33
|
+
...(identity.transcriptPath ? { transcriptPath: identity.transcriptPath } : {}),
|
|
34
|
+
};
|
|
35
|
+
if (written && JSON.stringify(written) === JSON.stringify(entry))
|
|
36
|
+
return;
|
|
37
|
+
writeEntry(opts.dir, entry);
|
|
38
|
+
written = entry;
|
|
39
|
+
}
|
|
40
|
+
const safeRefresh = () => refresh().catch((err) => opts.warn(`could not register this session: ${errorMessage(err)}`));
|
|
41
|
+
await safeRefresh();
|
|
42
|
+
const timer = setInterval(() => void safeRefresh(), opts.refreshMs);
|
|
43
|
+
timer.unref();
|
|
44
|
+
opts.signal.addEventListener("abort", () => {
|
|
45
|
+
clearInterval(timer);
|
|
46
|
+
try {
|
|
47
|
+
removeEntry(opts.dir, opts.pid);
|
|
48
|
+
}
|
|
49
|
+
catch (err) {
|
|
50
|
+
opts.warn(`could not remove this session's registry entry: ${errorMessage(err)}`);
|
|
51
|
+
}
|
|
52
|
+
}, { once: true });
|
|
53
|
+
return { self: () => ({ repoKey: key, ...(written ? { sessionId: written.sessionId } : {}) }) };
|
|
54
|
+
}
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
import { mkdirSync, readdirSync, readFileSync, renameSync, rmSync, writeFileSync } from "node:fs";
|
|
2
|
+
import { homedir } from "node:os";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
import { z } from "zod";
|
|
5
|
+
import { AGENTS, SESSION_ID_PATTERN } from "../types.js";
|
|
6
|
+
const entrySchema = z.object({
|
|
7
|
+
version: z.literal(1),
|
|
8
|
+
/** The consult server's pid; it names the file and tells readers whether the entry is stale. */
|
|
9
|
+
pid: z.number().int().positive(),
|
|
10
|
+
agent: z.enum(AGENTS),
|
|
11
|
+
sessionId: z.string().regex(SESSION_ID_PATTERN),
|
|
12
|
+
cwd: z.string().min(1),
|
|
13
|
+
repoKey: z.string().min(1),
|
|
14
|
+
branch: z.string().optional(),
|
|
15
|
+
startedAt: z.string(),
|
|
16
|
+
transcriptPath: z.string().optional(),
|
|
17
|
+
});
|
|
18
|
+
const ENTRY_FILE = /^(\d+)\.json$/;
|
|
19
|
+
/** `$XDG_STATE_HOME/consult/sessions`, else `~/.local/state/consult/sessions`. */
|
|
20
|
+
export function registryDir(env = process.env) {
|
|
21
|
+
return join(env.XDG_STATE_HOME || join(homedir(), ".local", "state"), "consult", "sessions");
|
|
22
|
+
}
|
|
23
|
+
const fileFor = (dir, pid) => join(dir, `${pid}.json`);
|
|
24
|
+
export function writeEntry(dir, entry) {
|
|
25
|
+
mkdirSync(dir, { recursive: true, mode: 0o700 });
|
|
26
|
+
const tmp = join(dir, `.${entry.pid}.tmp`);
|
|
27
|
+
writeFileSync(tmp, JSON.stringify(entrySchema.parse(entry)), { mode: 0o600 });
|
|
28
|
+
renameSync(tmp, fileFor(dir, entry.pid));
|
|
29
|
+
}
|
|
30
|
+
export function removeEntry(dir, pid) {
|
|
31
|
+
rmSync(fileFor(dir, pid), { force: true });
|
|
32
|
+
}
|
|
33
|
+
export function pidAlive(pid) {
|
|
34
|
+
try {
|
|
35
|
+
process.kill(pid, 0);
|
|
36
|
+
return true;
|
|
37
|
+
}
|
|
38
|
+
catch (err) {
|
|
39
|
+
// EPERM: the process exists but belongs to someone else.
|
|
40
|
+
return err.code === "EPERM";
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
function readEntry(path) {
|
|
44
|
+
try {
|
|
45
|
+
const parsed = entrySchema.safeParse(JSON.parse(readFileSync(path, "utf8")));
|
|
46
|
+
return parsed.success ? parsed.data : undefined;
|
|
47
|
+
}
|
|
48
|
+
catch {
|
|
49
|
+
return undefined;
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
/**
|
|
53
|
+
* Entries of running consult servers, newest first and one per session. Files left by servers that died are
|
|
54
|
+
* deleted; unreadable files of live servers are skipped, since they may be mid-write.
|
|
55
|
+
*/
|
|
56
|
+
export function listLive(dir, isAlive = pidAlive) {
|
|
57
|
+
let names;
|
|
58
|
+
try {
|
|
59
|
+
names = readdirSync(dir);
|
|
60
|
+
}
|
|
61
|
+
catch {
|
|
62
|
+
return [];
|
|
63
|
+
}
|
|
64
|
+
const bySession = new Map();
|
|
65
|
+
for (const name of names) {
|
|
66
|
+
const pid = Number(ENTRY_FILE.exec(name)?.[1]);
|
|
67
|
+
if (!pid)
|
|
68
|
+
continue;
|
|
69
|
+
if (!isAlive(pid)) {
|
|
70
|
+
removeEntry(dir, pid);
|
|
71
|
+
continue;
|
|
72
|
+
}
|
|
73
|
+
const entry = readEntry(join(dir, name));
|
|
74
|
+
if (!entry || entry.pid !== pid)
|
|
75
|
+
continue;
|
|
76
|
+
const seen = bySession.get(entry.sessionId);
|
|
77
|
+
if (!seen || seen.startedAt < entry.startedAt)
|
|
78
|
+
bySession.set(entry.sessionId, entry);
|
|
79
|
+
}
|
|
80
|
+
return [...bySession.values()].sort((a, b) => b.startedAt.localeCompare(a.startedAt));
|
|
81
|
+
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import { realpathSync } from "node:fs";
|
|
2
|
+
import { resolve } from "node:path";
|
|
3
|
+
const GIT_TIMEOUT_MS = 5_000;
|
|
4
|
+
const real = (path) => {
|
|
5
|
+
try {
|
|
6
|
+
return realpathSync(path);
|
|
7
|
+
}
|
|
8
|
+
catch {
|
|
9
|
+
return path;
|
|
10
|
+
}
|
|
11
|
+
};
|
|
12
|
+
async function git(run, cwd, args) {
|
|
13
|
+
const res = await run("git", args, { cwd, timeoutMs: GIT_TIMEOUT_MS }).catch(() => undefined);
|
|
14
|
+
return res?.code === 0 ? res.stdout.trim() : "";
|
|
15
|
+
}
|
|
16
|
+
/** Identifies a repository by its git common dir, which all its worktrees share. Outside git: the directory. */
|
|
17
|
+
export async function repoKey(run, cwd) {
|
|
18
|
+
const commonDir = await git(run, cwd, ["rev-parse", "--git-common-dir"]);
|
|
19
|
+
return real(commonDir ? resolve(cwd, commonDir) : resolve(cwd));
|
|
20
|
+
}
|
|
21
|
+
export async function gitBranch(run, cwd) {
|
|
22
|
+
const branch = await git(run, cwd, ["rev-parse", "--abbrev-ref", "HEAD"]);
|
|
23
|
+
return branch && branch !== "HEAD" ? branch : undefined;
|
|
24
|
+
}
|
|
25
|
+
export function inScope(self, other, scope) {
|
|
26
|
+
return scope === "machine" || self.repoKey === other.repoKey;
|
|
27
|
+
}
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import { AdvisorError } from "../types.js";
|
|
2
|
+
import { inScope } from "./scope.js";
|
|
3
|
+
/** A live session is the user's own: resuming it in place would write its transcript. */
|
|
4
|
+
export function assertNotLiveSession(sessionId, live) {
|
|
5
|
+
if (live.some((e) => e.sessionId === sessionId)) {
|
|
6
|
+
throw new AdvisorError(`session ${sessionId} is a live session; use ask_session to ask a read-only copy of it`);
|
|
7
|
+
}
|
|
8
|
+
}
|
|
9
|
+
const MAX_BRANCH = 120;
|
|
10
|
+
function toView(entry, details) {
|
|
11
|
+
return {
|
|
12
|
+
session_id: entry.sessionId,
|
|
13
|
+
agent: entry.agent,
|
|
14
|
+
cwd: entry.cwd,
|
|
15
|
+
...(entry.branch ? { branch: entry.branch.slice(0, MAX_BRANCH) } : {}),
|
|
16
|
+
...(details.title ? { title: details.title } : {}),
|
|
17
|
+
started_at: entry.startedAt,
|
|
18
|
+
...(details.lastActive ? { last_active: details.lastActive } : {}),
|
|
19
|
+
};
|
|
20
|
+
}
|
|
21
|
+
export function createSessions(deps) {
|
|
22
|
+
function visible(self) {
|
|
23
|
+
const { sessions } = deps.settings();
|
|
24
|
+
if (!sessions.enabled)
|
|
25
|
+
throw new AdvisorError("live sessions are turned off; run `consult settings` to turn them on");
|
|
26
|
+
return deps.listLive().filter((e) => e.sessionId !== self.sessionId && inScope(self, e, sessions.scope));
|
|
27
|
+
}
|
|
28
|
+
function list() {
|
|
29
|
+
return visible(deps.self()).map((entry) => toView(entry, deps.describe(entry)));
|
|
30
|
+
}
|
|
31
|
+
async function ask(sessionId, question, effort, signal) {
|
|
32
|
+
const self = deps.self();
|
|
33
|
+
const target = visible(self).find((e) => e.sessionId === sessionId);
|
|
34
|
+
if (!target) {
|
|
35
|
+
throw new AdvisorError(sessionId === self.sessionId
|
|
36
|
+
? "that is your own session; ask a different one"
|
|
37
|
+
: `no live session ${sessionId} in scope; call list_sessions to see the current ones`);
|
|
38
|
+
}
|
|
39
|
+
const result = await deps.askFork({ agent: target.agent, sessionId: target.sessionId, cwd: target.cwd }, question, effort, signal);
|
|
40
|
+
return { ...result, cwd: target.cwd, source: target.sessionId };
|
|
41
|
+
}
|
|
42
|
+
return { list, ask };
|
|
43
|
+
}
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import { loadSettings, settingsPath } from "../settings.js";
|
|
2
|
+
import { codexIndexPath, describeSession } from "./describe.js";
|
|
3
|
+
import { systemIdentityDeps } from "./identity.js";
|
|
4
|
+
import { startLiveSession } from "./live.js";
|
|
5
|
+
import { listLive, registryDir } from "./registry.js";
|
|
6
|
+
import { createSessions } from "./service.js";
|
|
7
|
+
const REFRESH_MS = 15_000;
|
|
8
|
+
/**
|
|
9
|
+
* For `consult serve`: registers this session and returns the session tools when live sessions are on.
|
|
10
|
+
* Without valid settings it returns nothing, so the rest of consult works as before.
|
|
11
|
+
*/
|
|
12
|
+
export async function sessionTools(opts) {
|
|
13
|
+
const path = settingsPath(opts.env);
|
|
14
|
+
let settings;
|
|
15
|
+
try {
|
|
16
|
+
settings = loadSettings(path);
|
|
17
|
+
}
|
|
18
|
+
catch (err) {
|
|
19
|
+
opts.warn(`${err instanceof Error ? err.message : String(err)}; live sessions are off for this session`);
|
|
20
|
+
return undefined;
|
|
21
|
+
}
|
|
22
|
+
if (!settings.sessions.enabled)
|
|
23
|
+
return undefined;
|
|
24
|
+
try {
|
|
25
|
+
const dir = registryDir(opts.env);
|
|
26
|
+
const codexIndex = codexIndexPath(opts.env);
|
|
27
|
+
const live = await startLiveSession({
|
|
28
|
+
run: opts.run,
|
|
29
|
+
dir,
|
|
30
|
+
cwd: opts.cwd,
|
|
31
|
+
pid: opts.pid,
|
|
32
|
+
identity: opts.identity ?? systemIdentityDeps(opts.run, opts.env),
|
|
33
|
+
refreshMs: opts.refreshMs ?? REFRESH_MS,
|
|
34
|
+
signal: opts.signal,
|
|
35
|
+
warn: opts.warn,
|
|
36
|
+
});
|
|
37
|
+
const { list, ask } = createSessions({
|
|
38
|
+
self: live.self,
|
|
39
|
+
settings: () => loadSettings(path),
|
|
40
|
+
listLive: () => listLive(dir),
|
|
41
|
+
describe: (entry) => describeSession(entry, codexIndex),
|
|
42
|
+
askFork: opts.askFork,
|
|
43
|
+
});
|
|
44
|
+
return { list, ask };
|
|
45
|
+
}
|
|
46
|
+
catch (err) {
|
|
47
|
+
opts.warn(`could not start live sessions: ${err instanceof Error ? err.message : String(err)}; live sessions are off for this session`);
|
|
48
|
+
return undefined;
|
|
49
|
+
}
|
|
50
|
+
}
|
package/dist/settings.js
ADDED
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import { mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs";
|
|
2
|
+
import { homedir } from "node:os";
|
|
3
|
+
import { dirname, join } from "node:path";
|
|
4
|
+
import { z } from "zod";
|
|
5
|
+
export const SESSIONS_MODES = ["off", "repo", "machine"];
|
|
6
|
+
const schema = z.object({
|
|
7
|
+
version: z.literal(1),
|
|
8
|
+
sessions: z.object({ enabled: z.boolean(), scope: z.enum(["repo", "machine"]) }),
|
|
9
|
+
});
|
|
10
|
+
/** Live sessions stay off until the user turns them on. */
|
|
11
|
+
export const DEFAULT_SETTINGS = { version: 1, sessions: { enabled: false, scope: "repo" } };
|
|
12
|
+
export class SettingsError extends Error {
|
|
13
|
+
name = "SettingsError";
|
|
14
|
+
}
|
|
15
|
+
/** `$XDG_CONFIG_HOME/consult/settings.json`, else `~/.config/consult/settings.json`. */
|
|
16
|
+
export function settingsPath(env = process.env) {
|
|
17
|
+
return join(env.XDG_CONFIG_HOME || join(homedir(), ".config"), "consult", "settings.json");
|
|
18
|
+
}
|
|
19
|
+
/** Returns the defaults when the file does not exist; throws SettingsError when it exists but is not valid. */
|
|
20
|
+
export function loadSettings(path) {
|
|
21
|
+
let text;
|
|
22
|
+
try {
|
|
23
|
+
text = readFileSync(path, "utf8");
|
|
24
|
+
}
|
|
25
|
+
catch (err) {
|
|
26
|
+
if (err.code === "ENOENT")
|
|
27
|
+
return DEFAULT_SETTINGS;
|
|
28
|
+
throw new SettingsError(`could not read ${path}: ${err instanceof Error ? err.message : String(err)}`);
|
|
29
|
+
}
|
|
30
|
+
let json;
|
|
31
|
+
try {
|
|
32
|
+
json = JSON.parse(text);
|
|
33
|
+
}
|
|
34
|
+
catch {
|
|
35
|
+
throw new SettingsError(`${path} is not valid JSON; run \`consult settings\` to reset it`);
|
|
36
|
+
}
|
|
37
|
+
const parsed = schema.safeParse(json);
|
|
38
|
+
if (!parsed.success) {
|
|
39
|
+
const reason = parsed.error.issues[0]?.message ?? "unknown problem";
|
|
40
|
+
throw new SettingsError(`${path} has invalid settings (${reason}); run \`consult settings\` to reset it`);
|
|
41
|
+
}
|
|
42
|
+
return parsed.data;
|
|
43
|
+
}
|
|
44
|
+
export function saveSettings(path, settings) {
|
|
45
|
+
const valid = schema.parse(settings);
|
|
46
|
+
mkdirSync(dirname(path), { recursive: true, mode: 0o700 });
|
|
47
|
+
const tmp = `${path}.${process.pid}.tmp`;
|
|
48
|
+
writeFileSync(tmp, `${JSON.stringify(valid, null, 2)}\n`, { mode: 0o600 });
|
|
49
|
+
renameSync(tmp, path);
|
|
50
|
+
}
|
|
51
|
+
export const sessionsMode = (settings) => settings.sessions.enabled ? settings.sessions.scope : "off";
|
|
52
|
+
/** Turning sessions off keeps the scope, so turning them back on restores it. */
|
|
53
|
+
export function withSessionsMode(settings, mode) {
|
|
54
|
+
return {
|
|
55
|
+
...settings,
|
|
56
|
+
sessions: mode === "off" ? { ...settings.sessions, enabled: false } : { enabled: true, scope: mode },
|
|
57
|
+
};
|
|
58
|
+
}
|
package/dist/tui.js
ADDED
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
import { cancel, confirm, intro, isCancel, log, multiselect, outro, select } from "@clack/prompts";
|
|
2
|
+
import { DEFAULT_SETTINGS, SESSIONS_MODES, sessionsMode, withSessionsMode, } from "./settings.js";
|
|
3
|
+
const MODE_OPTIONS = [
|
|
4
|
+
{ value: "off", label: "Off", hint: "default: agents can't see each other's sessions" },
|
|
5
|
+
{ value: "repo", label: "Same repo", hint: "sessions in the same git repository and its worktrees" },
|
|
6
|
+
{ value: "machine", label: "Whole machine", hint: "every consult session you run" },
|
|
7
|
+
];
|
|
8
|
+
const errorMessage = (err) => (err instanceof Error ? err.message : String(err));
|
|
9
|
+
export function parseSessionsFlag(value) {
|
|
10
|
+
if (value === undefined)
|
|
11
|
+
return undefined;
|
|
12
|
+
if (!SESSIONS_MODES.includes(value)) {
|
|
13
|
+
throw new Error(`--sessions must be one of ${SESSIONS_MODES.join(", ")}`);
|
|
14
|
+
}
|
|
15
|
+
return value;
|
|
16
|
+
}
|
|
17
|
+
export const isInteractive = (stdin = process.stdin, stdout = process.stdout) => Boolean(stdin.isTTY && stdout.isTTY);
|
|
18
|
+
/** The flag wins; otherwise ask, but only in a terminal. Undefined means leave the setting as it is. */
|
|
19
|
+
export async function chooseInstallMode(opts) {
|
|
20
|
+
if (opts.flag)
|
|
21
|
+
return opts.flag;
|
|
22
|
+
return opts.interactive ? opts.prompt(opts.current) : undefined;
|
|
23
|
+
}
|
|
24
|
+
export function planSettings(current, registered, chosen) {
|
|
25
|
+
return {
|
|
26
|
+
settings: withSessionsMode(current, chosen.mode),
|
|
27
|
+
register: chosen.hosts.filter((agent) => !registered.includes(agent)),
|
|
28
|
+
unregister: registered.filter((agent) => !chosen.hosts.includes(agent)),
|
|
29
|
+
};
|
|
30
|
+
}
|
|
31
|
+
/** Asks for the live-sessions mode. Undefined when the user cancels. */
|
|
32
|
+
export async function promptSessionsMode(current) {
|
|
33
|
+
const mode = await select({
|
|
34
|
+
message: "Live sessions: let agents see and ask each other's running sessions?",
|
|
35
|
+
options: [...MODE_OPTIONS],
|
|
36
|
+
initialValue: current,
|
|
37
|
+
});
|
|
38
|
+
return isCancel(mode) ? undefined : mode;
|
|
39
|
+
}
|
|
40
|
+
async function loadOrReset(io) {
|
|
41
|
+
try {
|
|
42
|
+
return io.load();
|
|
43
|
+
}
|
|
44
|
+
catch (err) {
|
|
45
|
+
log.warn(errorMessage(err));
|
|
46
|
+
const reset = await confirm({ message: "Reset settings to the defaults?", initialValue: true });
|
|
47
|
+
return !isCancel(reset) && reset ? DEFAULT_SETTINGS : undefined;
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
export async function runSettings(io) {
|
|
51
|
+
intro("consult settings");
|
|
52
|
+
const current = await loadOrReset(io);
|
|
53
|
+
if (!current)
|
|
54
|
+
return cancel("Left settings unchanged.");
|
|
55
|
+
const registered = (await Promise.all(io.installed.map(async (agent) => ((await io.isRegistered(agent)) ? [agent] : [])))).flat();
|
|
56
|
+
const mode = await promptSessionsMode(sessionsMode(current));
|
|
57
|
+
if (mode === undefined)
|
|
58
|
+
return cancel("No changes made.");
|
|
59
|
+
const hosts = io.installed.length === 0
|
|
60
|
+
? []
|
|
61
|
+
: await multiselect({
|
|
62
|
+
message: "Use consult in",
|
|
63
|
+
options: io.installed.map((agent) => ({ value: agent, label: agent })),
|
|
64
|
+
initialValues: [...registered],
|
|
65
|
+
required: false,
|
|
66
|
+
});
|
|
67
|
+
if (isCancel(hosts))
|
|
68
|
+
return cancel("No changes made.");
|
|
69
|
+
const plan = planSettings(current, registered, { mode, hosts });
|
|
70
|
+
io.save(plan.settings);
|
|
71
|
+
const changed = [...plan.register, ...plan.unregister];
|
|
72
|
+
const results = [
|
|
73
|
+
...(plan.register.length > 0 ? await io.register(plan.register) : []),
|
|
74
|
+
...(plan.unregister.length > 0 ? await io.unregister(plan.unregister) : []),
|
|
75
|
+
].filter((r) => changed.includes(r.agent));
|
|
76
|
+
for (const r of results)
|
|
77
|
+
(r.ok ? log.success : log.error)(r.message);
|
|
78
|
+
outro(`Live sessions: ${mode}. Restart running agent sessions to apply.`);
|
|
79
|
+
}
|
package/dist/types.js
CHANGED
|
@@ -1,4 +1,6 @@
|
|
|
1
1
|
export const AGENTS = ["claude", "codex", "grok"];
|
|
2
|
+
/** Session ids reach CLI argv after `--resume`; a leading `-` could be parsed as a flag. */
|
|
3
|
+
export const SESSION_ID_PATTERN = /^[A-Za-z0-9][A-Za-z0-9-]*$/;
|
|
2
4
|
export const EFFORTS = ["low", "medium", "high"];
|
|
3
5
|
export class AdvisorError extends Error {
|
|
4
6
|
name = "AdvisorError";
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "consult-mcp",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.3.1",
|
|
4
4
|
"description": "MCP server that lets Claude Code, Codex and Grok ask each other for a second opinion through the CLIs already signed in on your machine",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"mcp",
|
|
@@ -46,6 +46,7 @@
|
|
|
46
46
|
"linux"
|
|
47
47
|
],
|
|
48
48
|
"dependencies": {
|
|
49
|
+
"@clack/prompts": "^1.8.1",
|
|
49
50
|
"@modelcontextprotocol/sdk": "^1.30.0",
|
|
50
51
|
"smol-toml": "^1.9.0",
|
|
51
52
|
"zod": "^4.6.5"
|