severally-mcp 2.4.0 → 2.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CONFIG.md +1 -0
- package/README.ja.md +44 -19
- package/README.md +44 -18
- package/config.example.json +1 -1
- package/docs/how-it-works.ja.md +2 -0
- package/docs/how-it-works.md +2 -0
- package/package.json +3 -1
- package/plugins/severally/.claude-plugin/plugin.json +1 -1
- package/plugins/severally/.codex-plugin/plugin.json +1 -1
- package/plugins/severally/dist/severally-mcp.mjs +630 -271
- package/plugins/severally/skills/_template/SKILL.md.tmpl +2 -1
- package/plugins/severally/skills/_template/reference.md.tmpl +2 -2
- package/plugins/severally/skills/antigravity/severally/SKILL.md +2 -1
- package/plugins/severally/skills/antigravity/severally/reference.md +2 -2
- package/plugins/severally/skills/claude/severally/SKILL.md +2 -1
- package/plugins/severally/skills/claude/severally/reference.md +2 -2
- package/plugins/severally/skills/codex/severally/SKILL.md +2 -1
- package/plugins/severally/skills/codex/severally/reference.md +2 -2
- package/plugins/severally/skills/opencode/severally/SKILL.md +2 -1
- package/plugins/severally/skills/opencode/severally/reference.md +2 -2
- package/scripts/build.mjs +57 -54
- package/scripts/doctor.mjs +15 -4
- package/scripts/init-config.mjs +2 -1
- package/scripts/install.mjs +73 -9
- package/scripts/live-check.mjs +11 -5
- package/scripts/live-mcp-check.mjs +10 -4
- package/src/adapters/antigravity-sandbox.mjs +24 -6
- package/src/adapters/antigravity.mjs +80 -17
- package/src/adapters/claude-code.mjs +49 -8
- package/src/adapters/codex.mjs +56 -6
- package/src/adapters/opencode-sandbox.mjs +20 -2
- package/src/adapters/opencode.mjs +115 -19
- package/src/brief.mjs +84 -7
- package/src/consultants.mjs +193 -27
- package/src/cross-spawn-escape.d.ts +7 -0
- package/src/export.mjs +50 -6
- package/src/expose-paths.mjs +149 -7
- package/src/failures.mjs +8 -1
- package/src/jobs.mjs +632 -152
- package/src/jsonc.mjs +11 -3
- package/src/parse-result.mjs +91 -14
- package/src/platform.mjs +52 -2
- package/src/policy.mjs +151 -28
- package/src/redact.mjs +22 -3
- package/src/result-schema.mjs +13 -0
- package/src/run.mjs +158 -12
- package/src/schema.mjs +84 -11
- package/src/server.mjs +49 -14
- package/src/status.mjs +16 -4
- package/src/store.mjs +287 -6
|
@@ -50,7 +50,8 @@ actually reach. When the user names one, pass **the word the user used** as `tar
|
|
|
50
50
|
|
|
51
51
|
**Do not pick a model yourself.** When the user named no one, ask everyone; overriding the operator's
|
|
52
52
|
consultants on your own judgement — including after a `usage_limit` failure — substitutes a different mind
|
|
53
|
-
for the one the user asked for.
|
|
53
|
+
for the one the user asked for. The operator may have set a fallback model for that case; a caveat reading
|
|
54
|
+
"answered by X: Y's quota was used up" means it answered, and your report names X as the one who answered.
|
|
54
55
|
|
|
55
56
|
Consult your own CLI (`target: "{{SELF_TARGET}}"`) freely, alone or alongside the peers, and the same goes
|
|
56
57
|
for a **different model of your own lineage** when the user wants one (an Opus lead asking Fable). Either is a
|
|
@@ -29,8 +29,8 @@ gets a vague answer.
|
|
|
29
29
|
already in the question.
|
|
30
30
|
2. **Write down what a useful answer looks like** (`success_criteria`) — e.g. "a concrete failure scenario with
|
|
31
31
|
numbers, or a clear all-clear with its conditions".
|
|
32
|
-
3. **Supply the evidence**: `context.expose_paths` for the files (copied read-only under `./workspace
|
|
33
|
-
the
|
|
32
|
+
3. **Supply the evidence**: `context.expose_paths` for the files (copied read-only under `./workspace`; not a read
|
|
33
|
+
boundary -- apart from the protected credential paths, most consultants can still read other files on the disk), `context.facts` for the load-bearing facts,
|
|
34
34
|
`context.artifacts` for what is not a file here. For a change you are making,
|
|
35
35
|
`context.expose_diff: { repo, base? }` puts the diff in the brief and copies every file it touches, untracked
|
|
36
36
|
ones included, under `./workspace/repo/` at their repository paths; add `expose_paths` for a caller or the
|
|
@@ -61,7 +61,8 @@ actually reach. When the user names one, pass **the word the user used** as `tar
|
|
|
61
61
|
|
|
62
62
|
**Do not pick a model yourself.** When the user named no one, ask everyone; overriding the operator's
|
|
63
63
|
consultants on your own judgement — including after a `usage_limit` failure — substitutes a different mind
|
|
64
|
-
for the one the user asked for.
|
|
64
|
+
for the one the user asked for. The operator may have set a fallback model for that case; a caveat reading
|
|
65
|
+
"answered by X: Y's quota was used up" means it answered, and your report names X as the one who answered.
|
|
65
66
|
|
|
66
67
|
Consult your own CLI (`target: "antigravity"`) freely, alone or alongside the peers, and the same goes
|
|
67
68
|
for a **different model of your own lineage** when the user wants one (an Opus lead asking Fable). Either is a
|
|
@@ -29,8 +29,8 @@ gets a vague answer.
|
|
|
29
29
|
already in the question.
|
|
30
30
|
2. **Write down what a useful answer looks like** (`success_criteria`) — e.g. "a concrete failure scenario with
|
|
31
31
|
numbers, or a clear all-clear with its conditions".
|
|
32
|
-
3. **Supply the evidence**: `context.expose_paths` for the files (copied read-only under `./workspace
|
|
33
|
-
the
|
|
32
|
+
3. **Supply the evidence**: `context.expose_paths` for the files (copied read-only under `./workspace`; not a read
|
|
33
|
+
boundary -- apart from the protected credential paths, most consultants can still read other files on the disk), `context.facts` for the load-bearing facts,
|
|
34
34
|
`context.artifacts` for what is not a file here. For a change you are making,
|
|
35
35
|
`context.expose_diff: { repo, base? }` puts the diff in the brief and copies every file it touches, untracked
|
|
36
36
|
ones included, under `./workspace/repo/` at their repository paths; add `expose_paths` for a caller or the
|
|
@@ -62,7 +62,8 @@ actually reach. When the user names one, pass **the word the user used** as `tar
|
|
|
62
62
|
|
|
63
63
|
**Do not pick a model yourself.** When the user named no one, ask everyone; overriding the operator's
|
|
64
64
|
consultants on your own judgement — including after a `usage_limit` failure — substitutes a different mind
|
|
65
|
-
for the one the user asked for.
|
|
65
|
+
for the one the user asked for. The operator may have set a fallback model for that case; a caveat reading
|
|
66
|
+
"answered by X: Y's quota was used up" means it answered, and your report names X as the one who answered.
|
|
66
67
|
|
|
67
68
|
Consult your own CLI (`target: "claude-code"`) freely, alone or alongside the peers, and the same goes
|
|
68
69
|
for a **different model of your own lineage** when the user wants one (an Opus lead asking Fable). Either is a
|
|
@@ -29,8 +29,8 @@ gets a vague answer.
|
|
|
29
29
|
already in the question.
|
|
30
30
|
2. **Write down what a useful answer looks like** (`success_criteria`) — e.g. "a concrete failure scenario with
|
|
31
31
|
numbers, or a clear all-clear with its conditions".
|
|
32
|
-
3. **Supply the evidence**: `context.expose_paths` for the files (copied read-only under `./workspace
|
|
33
|
-
the
|
|
32
|
+
3. **Supply the evidence**: `context.expose_paths` for the files (copied read-only under `./workspace`; not a read
|
|
33
|
+
boundary -- apart from the protected credential paths, most consultants can still read other files on the disk), `context.facts` for the load-bearing facts,
|
|
34
34
|
`context.artifacts` for what is not a file here. For a change you are making,
|
|
35
35
|
`context.expose_diff: { repo, base? }` puts the diff in the brief and copies every file it touches, untracked
|
|
36
36
|
ones included, under `./workspace/repo/` at their repository paths; add `expose_paths` for a caller or the
|
|
@@ -61,7 +61,8 @@ actually reach. When the user names one, pass **the word the user used** as `tar
|
|
|
61
61
|
|
|
62
62
|
**Do not pick a model yourself.** When the user named no one, ask everyone; overriding the operator's
|
|
63
63
|
consultants on your own judgement — including after a `usage_limit` failure — substitutes a different mind
|
|
64
|
-
for the one the user asked for.
|
|
64
|
+
for the one the user asked for. The operator may have set a fallback model for that case; a caveat reading
|
|
65
|
+
"answered by X: Y's quota was used up" means it answered, and your report names X as the one who answered.
|
|
65
66
|
|
|
66
67
|
Consult your own CLI (`target: "codex"`) freely, alone or alongside the peers, and the same goes
|
|
67
68
|
for a **different model of your own lineage** when the user wants one (an Opus lead asking Fable). Either is a
|
|
@@ -29,8 +29,8 @@ gets a vague answer.
|
|
|
29
29
|
already in the question.
|
|
30
30
|
2. **Write down what a useful answer looks like** (`success_criteria`) — e.g. "a concrete failure scenario with
|
|
31
31
|
numbers, or a clear all-clear with its conditions".
|
|
32
|
-
3. **Supply the evidence**: `context.expose_paths` for the files (copied read-only under `./workspace
|
|
33
|
-
the
|
|
32
|
+
3. **Supply the evidence**: `context.expose_paths` for the files (copied read-only under `./workspace`; not a read
|
|
33
|
+
boundary -- apart from the protected credential paths, most consultants can still read other files on the disk), `context.facts` for the load-bearing facts,
|
|
34
34
|
`context.artifacts` for what is not a file here. For a change you are making,
|
|
35
35
|
`context.expose_diff: { repo, base? }` puts the diff in the brief and copies every file it touches, untracked
|
|
36
36
|
ones included, under `./workspace/repo/` at their repository paths; add `expose_paths` for a caller or the
|
|
@@ -61,7 +61,8 @@ actually reach. When the user names one, pass **the word the user used** as `tar
|
|
|
61
61
|
|
|
62
62
|
**Do not pick a model yourself.** When the user named no one, ask everyone; overriding the operator's
|
|
63
63
|
consultants on your own judgement — including after a `usage_limit` failure — substitutes a different mind
|
|
64
|
-
for the one the user asked for.
|
|
64
|
+
for the one the user asked for. The operator may have set a fallback model for that case; a caveat reading
|
|
65
|
+
"answered by X: Y's quota was used up" means it answered, and your report names X as the one who answered.
|
|
65
66
|
|
|
66
67
|
Consult your own CLI (`target: "opencode"`) freely, alone or alongside the peers, and the same goes
|
|
67
68
|
for a **different model of your own lineage** when the user wants one (an Opus lead asking Fable). Either is a
|
|
@@ -29,8 +29,8 @@ gets a vague answer.
|
|
|
29
29
|
already in the question.
|
|
30
30
|
2. **Write down what a useful answer looks like** (`success_criteria`) — e.g. "a concrete failure scenario with
|
|
31
31
|
numbers, or a clear all-clear with its conditions".
|
|
32
|
-
3. **Supply the evidence**: `context.expose_paths` for the files (copied read-only under `./workspace
|
|
33
|
-
the
|
|
32
|
+
3. **Supply the evidence**: `context.expose_paths` for the files (copied read-only under `./workspace`; not a read
|
|
33
|
+
boundary -- apart from the protected credential paths, most consultants can still read other files on the disk), `context.facts` for the load-bearing facts,
|
|
34
34
|
`context.artifacts` for what is not a file here. For a change you are making,
|
|
35
35
|
`context.expose_diff: { repo, base? }` puts the diff in the brief and copies every file it touches, untracked
|
|
36
36
|
ones included, under `./workspace/repo/` at their repository paths; add `expose_paths` for a caller or the
|
package/scripts/build.mjs
CHANGED
|
@@ -13,6 +13,7 @@ const skillsDir = path.join(root, 'plugins', 'severally', 'skills');
|
|
|
13
13
|
// `en`/`jp` are the everyday name a user would actually type when asking for
|
|
14
14
|
// this peer by alias ("ask GPT", "gptと相談して") -- English keeps the
|
|
15
15
|
// acronym capitalised, casual Japanese usually doesn't.
|
|
16
|
+
/** @type {Record<string, { label: string, short: string, reach: string, en: string, jp: string, jpVerb: string }>} */
|
|
16
17
|
const PEERS = {
|
|
17
18
|
codex: {
|
|
18
19
|
label: 'Codex CLI',
|
|
@@ -70,8 +71,8 @@ const EVERYONE_TRIGGERS = [
|
|
|
70
71
|
|
|
71
72
|
// All four everyday-alias shapes for one peer: "ask X", "get X to review
|
|
72
73
|
// this", the natural Japanese "XにVERBして" form, and "Xにレビューしてもらって".
|
|
73
|
-
|
|
74
|
-
|
|
74
|
+
/** @param {{ en: string, jp: string, jpVerb: string }} p */
|
|
75
|
+
function triggersFor(p) {
|
|
75
76
|
return [`"ask ${p.en}"`, `"get ${p.en} to review this"`, `"${p.jp}${p.jpVerb}"`, `"${p.jp}にレビューしてもらって"`];
|
|
76
77
|
}
|
|
77
78
|
|
|
@@ -83,6 +84,7 @@ function triggersFor(peerId) {
|
|
|
83
84
|
// one turn and the Skill itself then needs approving once (checked
|
|
84
85
|
// 2026-10-18 with `claude -p --permission-mode default`).
|
|
85
86
|
const QUIET_TOOLS = ['consult_get', 'consult_list', 'consult_record', 'consult_export', 'consult_cancel'];
|
|
87
|
+
/** @param {string} text */
|
|
86
88
|
function withAllowedTools(text) {
|
|
87
89
|
const names = QUIET_TOOLS.flatMap((t) => [`mcp__plugin_severally_severally__${t}`, `mcp__severally__${t}`]);
|
|
88
90
|
// A one-line description is assumed; a folded one would swallow the new key.
|
|
@@ -96,65 +98,63 @@ function withAllowedTools(text) {
|
|
|
96
98
|
// then file name. Writes nothing, so it is safe to call from a test without
|
|
97
99
|
// mutating the working tree.
|
|
98
100
|
export function renderSkills() {
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
const texts = renderTemplate(`${name}.tmpl`);
|
|
103
|
-
for (const host of HOSTS) rendered[host.dir][name] = texts[host.dir];
|
|
104
|
-
}
|
|
105
|
-
return rendered;
|
|
101
|
+
return Object.fromEntries(
|
|
102
|
+
HOSTS.map((host) => [host.dir, Object.fromEntries(SKILL_FILES.map((name) => [name, renderTemplate(`${name}.tmpl`, host)]))]),
|
|
103
|
+
);
|
|
106
104
|
}
|
|
107
105
|
|
|
108
106
|
const SKILL_FILES = ['SKILL.md', 'reference.md'];
|
|
109
107
|
|
|
110
|
-
|
|
108
|
+
/**
|
|
109
|
+
* @param {string} file
|
|
110
|
+
* @param {{ dir: string, self: string }} host
|
|
111
|
+
*/
|
|
112
|
+
function renderTemplate(file, host) {
|
|
111
113
|
const tmpl = fs.readFileSync(path.join(skillsDir, '_template', file), 'utf8');
|
|
112
|
-
const
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
return rendered;
|
|
114
|
+
const peers = Object.entries(PEERS).filter(([id]) => id !== host.self);
|
|
115
|
+
const [first, second] = peers;
|
|
116
|
+
// Every host has three peers, so both exist.
|
|
117
|
+
if (!first || !second) throw new Error(`${host.self}: expected at least two peers to consult`);
|
|
118
|
+
const table = [
|
|
119
|
+
'| target | Consultant | Reach for it when |',
|
|
120
|
+
'|---|---|---|',
|
|
121
|
+
...peers.map(([id, p]) => `| \`${id}\` | ${p.label} | ${p.reach} |`),
|
|
122
|
+
].join('\n');
|
|
123
|
+
// Every consultant can read files; what keeps the answer brief-only is
|
|
124
|
+
// that the child starts in an empty working directory and is never told
|
|
125
|
+
// where the repository is. Codex additionally keeps a read-only shell, so
|
|
126
|
+
// "they cannot run commands" would be false about it.
|
|
127
|
+
const codexCaveat = peers.some(([id]) => id === 'codex')
|
|
128
|
+
? ' The Codex consultant additionally has a read-only shell, so it can run commands that only read.'
|
|
129
|
+
: '';
|
|
130
|
+
const executionCaveat =
|
|
131
|
+
'\n\nOne caveat on that list: a consultant can read local files. Its writes are blocked,' +
|
|
132
|
+
'\nits network reaches only web search and browsing (none with `network: "off"`), credential and history' +
|
|
133
|
+
'\npaths are refused where its CLI allows, and by default it starts in an empty working directory without being' +
|
|
134
|
+
'\ntold where your repository is, so it answers from the brief and from the files you hand it:' +
|
|
135
|
+
'\n`context.expose_paths` (absolute paths) or `context.expose_diff` (a change in progress), which are' +
|
|
136
|
+
'\ncopied read-only into its working directory.' +
|
|
137
|
+
codexCaveat;
|
|
138
|
+
const text = tmpl
|
|
139
|
+
.replaceAll('{{EXECUTION_CAVEAT}}', executionCaveat)
|
|
140
|
+
.replaceAll('{{DESCRIPTION_PEERS}}', peers.map(([, p]) => p.short).join(' or '))
|
|
141
|
+
.replaceAll('{{TRIGGERS}}', [...peers.flatMap(([, p]) => triggersFor(p)), ...EVERYONE_TRIGGERS].join(', '))
|
|
142
|
+
.replaceAll('{{PEER_TABLE}}', table)
|
|
143
|
+
.replaceAll('{{DEFAULT_TARGET}}', first[0])
|
|
144
|
+
// The second peer, for the fan-out example of two named consultants.
|
|
145
|
+
.replaceAll('{{SECOND_TARGET}}', second[0])
|
|
146
|
+
.replaceAll('{{SELF_TARGET}}', host.self);
|
|
147
|
+
return host.self === 'claude-code' && file === 'SKILL.md.tmpl' ? withAllowedTools(text) : text;
|
|
147
148
|
}
|
|
148
149
|
|
|
149
150
|
// Thin writer: renders and writes each host's SKILL.md. Stays silent --
|
|
150
151
|
// logging belongs to the CLI entry point below, not to code that may be
|
|
151
152
|
// imported (e.g. by a test).
|
|
152
153
|
export function generateSkills() {
|
|
153
|
-
const
|
|
154
|
-
|
|
155
|
-
const out = path.join(skillsDir, host.dir, 'severally');
|
|
154
|
+
for (const [dir, files] of Object.entries(renderSkills())) {
|
|
155
|
+
const out = path.join(skillsDir, dir, 'severally');
|
|
156
156
|
fs.mkdirSync(out, { recursive: true });
|
|
157
|
-
for (const name of
|
|
157
|
+
for (const [name, text] of Object.entries(files)) fs.writeFileSync(path.join(out, name), text);
|
|
158
158
|
}
|
|
159
159
|
}
|
|
160
160
|
|
|
@@ -184,15 +184,18 @@ export async function bundle() {
|
|
|
184
184
|
* THIRD_PARTY_NOTICES beside the bundle: each package the bundle actually
|
|
185
185
|
* contains, with its LICENSE file verbatim. esbuild's legalComments cannot do
|
|
186
186
|
* this -- these packages state their licence only in the LICENSE file.
|
|
187
|
+
* @param {string[]} inputs
|
|
188
|
+
* @param {string} outDir
|
|
187
189
|
*/
|
|
188
190
|
function writeNotices(inputs, outDir) {
|
|
189
191
|
const pkgDirs = new Set();
|
|
190
192
|
for (const input of inputs) {
|
|
191
|
-
const
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
193
|
+
const [, pkgPath] =
|
|
194
|
+
input
|
|
195
|
+
.split('/')
|
|
196
|
+
.join(path.sep)
|
|
197
|
+
.match(/^(.*node_modules[\\/](?:@[^\\/]+[\\/])?[^\\/]+)/) ?? [];
|
|
198
|
+
if (pkgPath) pkgDirs.add(path.resolve(process.cwd(), pkgPath));
|
|
196
199
|
}
|
|
197
200
|
const sections = [...pkgDirs]
|
|
198
201
|
.map((dir) => {
|
package/scripts/doctor.mjs
CHANGED
|
@@ -27,6 +27,7 @@ import fs from 'node:fs';
|
|
|
27
27
|
import os from 'node:os';
|
|
28
28
|
import path from 'node:path';
|
|
29
29
|
import { JobManager } from '../src/jobs.mjs';
|
|
30
|
+
/** @typedef {import('../src/jobs.mjs').JobView} JobView */
|
|
30
31
|
import { spawnSync } from 'node:child_process';
|
|
31
32
|
import { availableConsultants, POLICY } from '../src/policy.mjs';
|
|
32
33
|
import { checkTargets } from '../src/consultants.mjs';
|
|
@@ -40,6 +41,11 @@ if (!consultants) {
|
|
|
40
41
|
process.exit(2);
|
|
41
42
|
}
|
|
42
43
|
|
|
44
|
+
/**
|
|
45
|
+
* @param {string} dir
|
|
46
|
+
* @param {string} secret
|
|
47
|
+
* @param {string} rootCanary
|
|
48
|
+
*/
|
|
43
49
|
function probe(dir, secret, rootCanary) {
|
|
44
50
|
const written = path.join(dir, 'written-by-consultant.txt');
|
|
45
51
|
const touched = path.join(dir, 'touched-by-command.txt');
|
|
@@ -63,6 +69,10 @@ function probe(dir, secret, rootCanary) {
|
|
|
63
69
|
// A consultant that was told a path is denied may simply not try it, so an
|
|
64
70
|
// unreturned canary does not show the sandbox stopped anything. For Codex the
|
|
65
71
|
// sandbox can be asked directly, with no model and no quota.
|
|
72
|
+
/**
|
|
73
|
+
* @param {string} file
|
|
74
|
+
* @param {string} word
|
|
75
|
+
*/
|
|
66
76
|
function codexSandboxRead(file, word) {
|
|
67
77
|
const r = spawnSync(
|
|
68
78
|
POLICY.clis.codex.cli,
|
|
@@ -87,6 +97,7 @@ function codexSandboxRead(file, word) {
|
|
|
87
97
|
|
|
88
98
|
const SUB_AGENT_TOOL = /subagent|spawn_agent|collab_tool_call|^task(:|$)|^agent(:|$)/i;
|
|
89
99
|
|
|
100
|
+
/** @param {JobView} view */
|
|
90
101
|
function subAgentRow(view) {
|
|
91
102
|
if (!view.tool_calls) return `not observable (no tool-call stream); tools offered: ${view.boundary?.tools ?? 'unknown'}`;
|
|
92
103
|
if (Object.keys(view.tool_calls).length === 0) return 'inconclusive: the stream shows no tool calls at all';
|
|
@@ -119,17 +130,17 @@ for (const { name: target, cli } of consultants) {
|
|
|
119
130
|
const { _secret, ...request } = probe(dir, secret, rootCanary);
|
|
120
131
|
|
|
121
132
|
const mgr = new JobManager();
|
|
122
|
-
/** @type {Record<string, any>} */
|
|
123
133
|
const started = mgr.start({ target, ...request });
|
|
124
|
-
if (!started
|
|
134
|
+
if (!('job_id' in started)) {
|
|
125
135
|
rows.push({ target, error: JSON.stringify(started) });
|
|
126
136
|
failed = true;
|
|
127
137
|
for (const d of [dir, rootDir]) fs.rmSync(d, { recursive: true, force: true });
|
|
128
138
|
continue;
|
|
129
139
|
}
|
|
130
140
|
process.stderr.write(`${target}: running ${started.job_id}\n`);
|
|
131
|
-
await mgr.jobs.get(started.job_id)
|
|
132
|
-
|
|
141
|
+
await mgr.jobs.get(started.job_id)?.promise;
|
|
142
|
+
// The job was started above in this process, so it exists.
|
|
143
|
+
const view = /** @type {JobView} */ (mgr.view(started.job_id));
|
|
133
144
|
|
|
134
145
|
if (view.status !== 'completed') {
|
|
135
146
|
rows.push({ target, error: `${view.status}: ${view.failure?.kind} ${view.failure?.message ?? ''}`.trim() });
|
package/scripts/init-config.mjs
CHANGED
|
@@ -47,6 +47,7 @@ export function renderConfig() {
|
|
|
47
47
|
' // effort reasoning effort (default high); antigravity takes it from the model name instead',
|
|
48
48
|
" // timeout_ms this consultant's own budget in ms (default: server timeout_ms, max 7200000)",
|
|
49
49
|
' // max_budget_usd claude-code only: spending cap per consultation (default 10, 0.05-20)',
|
|
50
|
+
" // fallback_model a model to run instead, once, when model's quota is used up (usage_limit)",
|
|
50
51
|
' // vendor the lineage, when the model name does not say (e.g. "nvidia")',
|
|
51
52
|
' // enabled, note as for clis, for this consultant alone',
|
|
52
53
|
' //',
|
|
@@ -117,7 +118,7 @@ function main() {
|
|
|
117
118
|
}
|
|
118
119
|
console.log('');
|
|
119
120
|
console.log('clis.<cli>: enabled note bin args env');
|
|
120
|
-
console.log('consultants.<name>: cli model effort timeout_ms max_budget_usd vendor enabled note');
|
|
121
|
+
console.log('consultants.<name>: cli model effort timeout_ms max_budget_usd fallback_model vendor enabled note');
|
|
121
122
|
console.log(' more: CONFIG.md (what each key does), config.example.json (a working example).');
|
|
122
123
|
console.log(' Restart the client after editing.');
|
|
123
124
|
console.log(' comments and trailing commas are fine; name it config.jsonc if your editor prefers that');
|
package/scripts/install.mjs
CHANGED
|
@@ -80,9 +80,16 @@ const backupDir = path.join(home, '.severally', 'backups', stamp);
|
|
|
80
80
|
let keptRegistrations = false;
|
|
81
81
|
let lastListOutput = '';
|
|
82
82
|
|
|
83
|
+
/** @param {...unknown} a */
|
|
83
84
|
const log = (...a) => console.log(...a);
|
|
85
|
+
/** @param {string} s */
|
|
84
86
|
const step = (s) => log(`\n== ${s}`);
|
|
85
87
|
|
|
88
|
+
/**
|
|
89
|
+
* @param {string} cmd
|
|
90
|
+
* @param {string[]} args
|
|
91
|
+
* @param {import('../src/platform.mjs').ExecOptions & { real?: boolean }} [opts]
|
|
92
|
+
*/
|
|
86
93
|
function run(cmd, args, opts = {}) {
|
|
87
94
|
const { real, ...spawnOpts } = opts;
|
|
88
95
|
if (dryRun && !real) {
|
|
@@ -92,16 +99,25 @@ function run(cmd, args, opts = {}) {
|
|
|
92
99
|
return execCommandSync(cmd, args, { stdio: ['ignore', 'pipe', 'pipe'], ...spawnOpts });
|
|
93
100
|
}
|
|
94
101
|
|
|
102
|
+
/**
|
|
103
|
+
* @param {string} cmd
|
|
104
|
+
* @param {string[]} args
|
|
105
|
+
* @param {import('../src/platform.mjs').ExecOptions & { real?: boolean }} [opts]
|
|
106
|
+
* @returns {RunResult}
|
|
107
|
+
*/
|
|
95
108
|
function tryRun(cmd, args, opts) {
|
|
96
109
|
try {
|
|
97
110
|
return { ok: true, out: run(cmd, args, opts) };
|
|
98
|
-
} catch (
|
|
111
|
+
} catch (e) {
|
|
112
|
+
// execFileSync failures carry the child's stdout and stderr.
|
|
113
|
+
const err = /** @type {{ stdout?: unknown, stderr?: unknown, message?: unknown }} */ (e);
|
|
99
114
|
return { ok: false, out: `${err.stdout ?? ''}${err.stderr ?? ''}`.trim() || String(err.message) };
|
|
100
115
|
}
|
|
101
116
|
}
|
|
102
117
|
|
|
103
118
|
// Backup names are derived from the whole path: two different clients both
|
|
104
119
|
// hold a "severally" directory, and one must not overwrite the other.
|
|
120
|
+
/** @param {string} target */
|
|
105
121
|
function backupName(target) {
|
|
106
122
|
return path
|
|
107
123
|
.relative(home, target)
|
|
@@ -110,6 +126,7 @@ function backupName(target) {
|
|
|
110
126
|
.replace(/^\./, '');
|
|
111
127
|
}
|
|
112
128
|
|
|
129
|
+
/** @param {string} file */
|
|
113
130
|
function backup(file) {
|
|
114
131
|
if (!fs.existsSync(file)) return null;
|
|
115
132
|
if (dryRun) {
|
|
@@ -125,6 +142,7 @@ function backup(file) {
|
|
|
125
142
|
return dest;
|
|
126
143
|
}
|
|
127
144
|
|
|
145
|
+
/** @param {string} dir */
|
|
128
146
|
function backupTree(dir) {
|
|
129
147
|
if (!fs.existsSync(dir)) return null;
|
|
130
148
|
if (dryRun) {
|
|
@@ -138,6 +156,7 @@ function backupTree(dir) {
|
|
|
138
156
|
}
|
|
139
157
|
|
|
140
158
|
// Read-only probes run even under --dry-run, so the plan it prints is accurate.
|
|
159
|
+
/** @param {string} bin */
|
|
141
160
|
function which(bin) {
|
|
142
161
|
return findCommand(bin);
|
|
143
162
|
}
|
|
@@ -198,6 +217,7 @@ let globalRoot = null;
|
|
|
198
217
|
if (mode === 'plugin' && !skipGlobal) {
|
|
199
218
|
const npmRoot = tryRun('npm', ['root', '-g'], { real: true });
|
|
200
219
|
globalRoot = npmRoot.ok ? npmRoot.out.trim() || null : null;
|
|
220
|
+
/** @param {string} dir */
|
|
201
221
|
const inside = (dir) => {
|
|
202
222
|
try {
|
|
203
223
|
return fs.realpathSync(root).startsWith(fs.realpathSync(dir) + path.sep);
|
|
@@ -214,6 +234,7 @@ if (mode === 'plugin' && !skipGlobal) {
|
|
|
214
234
|
}
|
|
215
235
|
|
|
216
236
|
step(`Installing the standalone MCP server (mode: ${mode})`);
|
|
237
|
+
/** @type {[string, string]} */
|
|
217
238
|
const serverCommand = [process.execPath, installRuntime()];
|
|
218
239
|
const runtimeDir = path.dirname(serverCommand[1]);
|
|
219
240
|
const marketplaceRoot = path.join(home, '.severally', 'marketplace');
|
|
@@ -322,17 +343,20 @@ const agyConfig = path.join(home, '.gemini', 'config', 'mcp_config.json');
|
|
|
322
343
|
const CODEX_TIMEOUT_SEC = 660;
|
|
323
344
|
const codexConfig = path.join(codexHome, 'config.toml');
|
|
324
345
|
// The user's own tool_timeout_sec, read before --force removes the table that holds it.
|
|
346
|
+
/** @type {number | undefined} */
|
|
325
347
|
let priorCodexTimeout;
|
|
326
348
|
|
|
327
349
|
// Where [mcp_servers.severally] sits in config.toml, and what it holds.
|
|
328
350
|
// A minimal text read, not a TOML parser: the table runs to the next header
|
|
329
351
|
// that is not one of its own sub-tables (codex may put env in one).
|
|
352
|
+
/** @returns {{ reason: string, lines?: undefined, start?: undefined, timeout?: undefined, wait?: undefined } | { reason?: undefined, lines: string[], start: number, timeout: number | undefined, wait: boolean }} */
|
|
330
353
|
function codexTable() {
|
|
354
|
+
/** @type {string[]} */
|
|
331
355
|
let lines;
|
|
332
356
|
try {
|
|
333
357
|
lines = fs.readFileSync(codexConfig, 'utf8').split('\n');
|
|
334
358
|
} catch (err) {
|
|
335
|
-
return { reason: `cannot read ${codexConfig}: ${err.message}` };
|
|
359
|
+
return { reason: `cannot read ${codexConfig}: ${/** @type {Error} */ (err).message}` };
|
|
336
360
|
}
|
|
337
361
|
const start = lines.findIndex((l) => /^\s*\[\s*mcp_servers\.severally\s*\]/.test(l));
|
|
338
362
|
if (start < 0) return { reason: `${codexConfig} has no [mcp_servers.severally] table` };
|
|
@@ -357,15 +381,16 @@ function codexPlan() {
|
|
|
357
381
|
|
|
358
382
|
// Inserts tool_timeout_sec right after the table header. Returns why it could
|
|
359
383
|
// not, or null.
|
|
384
|
+
/** @param {number} sec */
|
|
360
385
|
function writeCodexTimeout(sec) {
|
|
361
386
|
const t = codexTable();
|
|
362
|
-
if (t.reason) return t.reason;
|
|
387
|
+
if (t.reason !== undefined) return t.reason;
|
|
363
388
|
if (t.timeout !== undefined) return null; // never overwrite the user's value
|
|
364
389
|
t.lines.splice(t.start + 1, 0, `tool_timeout_sec = ${sec}`);
|
|
365
390
|
try {
|
|
366
391
|
fs.writeFileSync(codexConfig, t.lines.join('\n'));
|
|
367
392
|
} catch (err) {
|
|
368
|
-
return `cannot write ${codexConfig}: ${err.message}`;
|
|
393
|
+
return `cannot write ${codexConfig}: ${/** @type {Error} */ (err).message}`;
|
|
369
394
|
}
|
|
370
395
|
return null;
|
|
371
396
|
}
|
|
@@ -373,12 +398,28 @@ function writeCodexTimeout(sec) {
|
|
|
373
398
|
const claudeSkillsDir = path.join(home, '.claude', 'skills');
|
|
374
399
|
|
|
375
400
|
// ---------------------------------------------------------------- clients
|
|
401
|
+
/**
|
|
402
|
+
* @typedef {{ ok: boolean, out: string }} RunResult
|
|
403
|
+
* @typedef {object} Client
|
|
404
|
+
* @property {string} bin
|
|
405
|
+
* @property {string} label
|
|
406
|
+
* @property {string[]} backup
|
|
407
|
+
* @property {() => boolean} isRegistered
|
|
408
|
+
* @property {() => RunResult} remove
|
|
409
|
+
* @property {(bin: string[]) => RunResult} add
|
|
410
|
+
* @property {() => void} [keptNote]
|
|
411
|
+
* @property {boolean} [verifyAdd]
|
|
412
|
+
* @property {string[]} [registrationConfig]
|
|
413
|
+
* @property {string} skillFrom
|
|
414
|
+
* @property {string} skillTo
|
|
415
|
+
*/
|
|
376
416
|
// Each row fully describes how to register a client's MCP server directly
|
|
377
417
|
// (through the client's own `mcp add`) and place its skill. This is the one
|
|
378
418
|
// place that knows how severally is installed outside of a bundled
|
|
379
419
|
// plugin: --manual mode installs every row this way, and the default plugin
|
|
380
420
|
// mode falls back to the Antigravity and OpenCode rows alone, since those
|
|
381
421
|
// two have no verified plugin-install path.
|
|
422
|
+
/** @type {Client[]} */
|
|
382
423
|
const CLIENTS = [
|
|
383
424
|
{
|
|
384
425
|
bin: 'claude',
|
|
@@ -509,14 +550,24 @@ const CLIENTS = [
|
|
|
509
550
|
skillTo: path.join(xdgConfig, 'opencode', 'skills', 'severally'),
|
|
510
551
|
},
|
|
511
552
|
];
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
const
|
|
553
|
+
/** @param {string} bin */
|
|
554
|
+
function clientFor(bin) {
|
|
555
|
+
const client = CLIENTS.find((c) => c.bin === bin);
|
|
556
|
+
if (!client) throw new Error(`no client row for ${bin}`);
|
|
557
|
+
return client;
|
|
558
|
+
}
|
|
559
|
+
const codexClient = clientFor('codex');
|
|
560
|
+
const antigravityClient = clientFor('agy');
|
|
561
|
+
const opencodeClient = clientFor('opencode');
|
|
515
562
|
|
|
516
563
|
// opencode re-adds in place and has no `mcp remove`, so --force over an
|
|
517
564
|
// existing registration can silently keep the OLD command while the
|
|
518
565
|
// name-only list check passes. The global config is where the CLI records
|
|
519
566
|
// the server command; confirm it matches what this install registers.
|
|
567
|
+
/**
|
|
568
|
+
* @param {Client} client
|
|
569
|
+
* @param {string[]} bin
|
|
570
|
+
*/
|
|
520
571
|
function registrationCommandMatches(client, bin) {
|
|
521
572
|
for (const file of client.registrationConfig ?? []) {
|
|
522
573
|
let cfg;
|
|
@@ -534,6 +585,7 @@ function registrationCommandMatches(client, bin) {
|
|
|
534
585
|
|
|
535
586
|
// The list right after an add can be briefly empty, so it is asked up to
|
|
536
587
|
// three times, 1.5 s apart, before the add is judged.
|
|
588
|
+
/** @param {Client} client */
|
|
537
589
|
function listedAfterAdd(client) {
|
|
538
590
|
for (let i = 0; i < 3; i++) {
|
|
539
591
|
if (i) Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, 1500);
|
|
@@ -545,6 +597,10 @@ function listedAfterAdd(client) {
|
|
|
545
597
|
// Registers one client's MCP server directly (through its own `mcp add`) and
|
|
546
598
|
// copies its skill, backing up everything it touches first. Shared by plugin
|
|
547
599
|
// mode (Antigravity only) and --manual mode (all three clients).
|
|
600
|
+
/**
|
|
601
|
+
* @param {Client} client
|
|
602
|
+
* @param {string[]} bin
|
|
603
|
+
*/
|
|
548
604
|
function installClientDirectly(client, bin) {
|
|
549
605
|
step(`Registering with ${client.label}`);
|
|
550
606
|
if (!which(client.bin)) {
|
|
@@ -609,7 +665,12 @@ function claudeHasMarketplacePlugin() {
|
|
|
609
665
|
if (!which('claude')) return false;
|
|
610
666
|
const r = tryRun('claude', ['plugin', 'list', '--json'], { real: true });
|
|
611
667
|
try {
|
|
612
|
-
return (
|
|
668
|
+
return (
|
|
669
|
+
(r.ok &&
|
|
670
|
+
JSON.parse(r.out).find((/** @type {{ id: string }} */ p) => /^severally@/.test(p.id) && p.id !== 'severally@skills-dir')
|
|
671
|
+
?.id) ||
|
|
672
|
+
false
|
|
673
|
+
);
|
|
613
674
|
} catch {
|
|
614
675
|
return false;
|
|
615
676
|
}
|
|
@@ -751,10 +812,13 @@ async function allowLocalTools() {
|
|
|
751
812
|
if (!which('claude')) return;
|
|
752
813
|
const prefix = mode === 'plugin' || claudeMarketplace ? 'mcp__plugin_severally_severally__' : 'mcp__severally__';
|
|
753
814
|
const settingsFile = path.join(home, '.claude', 'settings.json');
|
|
815
|
+
/** @type {{ permissions?: { allow?: string[] } & Record<string, unknown> } & Record<string, unknown>} */
|
|
754
816
|
let settings = {};
|
|
755
817
|
try {
|
|
756
818
|
settings = JSON.parse(fs.readFileSync(settingsFile, 'utf8'));
|
|
757
|
-
} catch (
|
|
819
|
+
} catch (e) {
|
|
820
|
+
// readFileSync and JSON.parse throw Errors; only fs ones carry a code.
|
|
821
|
+
const err = /** @type {NodeJS.ErrnoException} */ (e);
|
|
758
822
|
if (err.code !== 'ENOENT') {
|
|
759
823
|
log(` could not read ${settingsFile} (${err.message}); left as it is`);
|
|
760
824
|
return;
|