severally-mcp 2.1.0 → 2.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (52) hide show
  1. package/CODE_OF_CONDUCT.md +83 -0
  2. package/CONFIG.md +10 -6
  3. package/README.ja.md +146 -69
  4. package/README.md +150 -73
  5. package/bin/severally-mcp.mjs +17 -2
  6. package/config.example.json +26 -0
  7. package/docs/how-it-works.ja.md +4 -2
  8. package/docs/how-it-works.md +5 -3
  9. package/docs/install.ja.md +59 -24
  10. package/docs/install.md +60 -24
  11. package/package.json +16 -4
  12. package/plugins/severally/.claude-plugin/plugin.json +1 -1
  13. package/plugins/severally/.codex-plugin/plugin.json +1 -1
  14. package/plugins/severally/dist/severally-mcp.mjs +29220 -27764
  15. package/plugins/severally/skills/_template/SKILL.md.tmpl +16 -22
  16. package/plugins/severally/skills/_template/reference.md.tmpl +11 -5
  17. package/plugins/severally/skills/antigravity/severally/SKILL.md +16 -22
  18. package/plugins/severally/skills/antigravity/severally/reference.md +11 -5
  19. package/plugins/severally/skills/claude/severally/SKILL.md +16 -22
  20. package/plugins/severally/skills/claude/severally/reference.md +11 -5
  21. package/plugins/severally/skills/codex/severally/SKILL.md +16 -22
  22. package/plugins/severally/skills/codex/severally/reference.md +11 -5
  23. package/plugins/severally/skills/opencode/severally/SKILL.md +16 -22
  24. package/plugins/severally/skills/opencode/severally/reference.md +11 -5
  25. package/scripts/build.mjs +31 -26
  26. package/scripts/doctor.mjs +52 -17
  27. package/scripts/init-config.mjs +8 -6
  28. package/scripts/install.mjs +342 -84
  29. package/scripts/live-check.mjs +52 -19
  30. package/scripts/live-mcp-check.mjs +42 -12
  31. package/scripts/test.mjs +10 -4
  32. package/src/adapters/antigravity-sandbox.mjs +39 -23
  33. package/src/adapters/antigravity.mjs +34 -16
  34. package/src/adapters/claude-code.mjs +73 -21
  35. package/src/adapters/codex.mjs +69 -27
  36. package/src/adapters/opencode-sandbox.mjs +40 -21
  37. package/src/adapters/opencode.mjs +31 -13
  38. package/src/brief.mjs +65 -41
  39. package/src/consultants.mjs +99 -30
  40. package/src/export.mjs +49 -12
  41. package/src/expose-paths.mjs +162 -44
  42. package/src/failures.mjs +23 -13
  43. package/src/jobs.mjs +420 -140
  44. package/src/parse-result.mjs +85 -30
  45. package/src/platform.mjs +5 -7
  46. package/src/policy.mjs +139 -32
  47. package/src/redact.mjs +38 -14
  48. package/src/run.mjs +104 -22
  49. package/src/schema.mjs +76 -22
  50. package/src/server.mjs +176 -166
  51. package/src/status.mjs +132 -0
  52. package/src/store.mjs +164 -19
package/src/status.mjs ADDED
@@ -0,0 +1,132 @@
1
+ // `severally-mcp status`: what this machine has set up, read without starting a
2
+ // consultation, so it spends no quota. It says what it can see and no more:
3
+ // login is read from each CLI's own status command where it has one, and where
4
+ // it has none it is reported as not checked rather than as working.
5
+ import {
6
+ POLICY,
7
+ configProblem,
8
+ configWarnings,
9
+ unavailableReason,
10
+ consultantAvailable,
11
+ brokenConsultantNames,
12
+ brokenConsultant,
13
+ } from './policy.mjs';
14
+ import { SEVERALLY_VERSION, staleInstall } from './server.mjs';
15
+ import { findCommand, execCommandSync } from './platform.mjs';
16
+
17
+ // How each client lists severally, plugin installs first. A command that exits 0
18
+ // and names severally counts as registered.
19
+ const CLIENTS = [
20
+ {
21
+ label: 'Claude Code',
22
+ cmd: 'claude',
23
+ checks: [
24
+ ['plugin', 'list'],
25
+ ['mcp', 'get', 'severally'],
26
+ ],
27
+ },
28
+ {
29
+ label: 'Codex',
30
+ cmd: 'codex',
31
+ checks: [
32
+ ['plugin', 'list'],
33
+ ['mcp', 'get', 'severally'],
34
+ ],
35
+ },
36
+ { label: 'Antigravity', cmd: 'agy', checks: [['mcp', 'list']] },
37
+ { label: 'OpenCode', cmd: 'opencode', checks: [['mcp', 'list']] },
38
+ ];
39
+
40
+ // "registered": the client's own listing names severally (it may still be
41
+ // turned off there). "not registered": the client answered, without it.
42
+ // No answer at all (a timeout, a failure to start): unknown.
43
+ function registration({ cmd, checks }) {
44
+ if (!findCommand(cmd)) return 'not installed';
45
+ let ran = false;
46
+ for (const args of checks) {
47
+ try {
48
+ const out = execCommandSync(cmd, args, { encoding: 'utf8', timeout: 20_000, stdio: ['ignore', 'pipe', 'pipe'] });
49
+ ran = true;
50
+ if (/(^|[\s@:])severally([\s@:]|$)/m.test(out)) return 'registered (listed by the client)';
51
+ } catch (err) {
52
+ // It ran and said no (`mcp get` of a missing server exits non-zero); a
53
+ // timeout or a failure to start says nothing either way.
54
+ if (typeof err.status === 'number') ran = true;
55
+ }
56
+ }
57
+ return ran ? 'not registered' : `could not tell (${cmd} ${checks.map((a) => a.join(' ')).join(' / ')} failed)`;
58
+ }
59
+
60
+ // Each CLI's own word on its login, read without sending a prompt. Only the
61
+ // verdict is kept: `claude auth status` also prints the account's email.
62
+ // Antigravity has no such command, so it is not checked.
63
+ const LOGIN = {
64
+ 'claude-code': { cmd: 'claude', args: ['auth', 'status', '--json'], read: (out) => JSON.parse(out).loggedIn === true },
65
+ // Codex prints its answer on stderr; the exit status carries the verdict.
66
+ codex: { cmd: 'codex', args: ['login', 'status'], read: (out, ok) => ok },
67
+ // OpenCode can also take keys from the environment, so no saved credential
68
+ // is a warning, not a verdict.
69
+ opencode: {
70
+ cmd: 'opencode',
71
+ args: ['auth', 'list'],
72
+ read: (out) => {
73
+ const n = out.split('\n').filter((l) => /\bstored\b/.test(l)).length;
74
+ return n ? `${n} saved credential${n > 1 ? 's' : ''}` : 'no saved credentials (keys in the environment still work)';
75
+ },
76
+ },
77
+ };
78
+
79
+ function login(cli) {
80
+ const check = LOGIN[cli];
81
+ if (!check) return 'not checked (no login status command)';
82
+ if (!findCommand(check.cmd)) return 'not installed';
83
+ let out;
84
+ let ok = true;
85
+ try {
86
+ out = execCommandSync(check.cmd, check.args, { encoding: 'utf8', timeout: 20_000, stdio: ['ignore', 'pipe', 'pipe'] });
87
+ } catch (err) {
88
+ if (typeof err.status !== 'number') return `could not tell (${check.cmd} ${check.args.join(' ')} failed)`;
89
+ out = `${err.stdout ?? ''}`;
90
+ ok = false;
91
+ }
92
+ try {
93
+ const v = check.read(out, ok);
94
+ return v === true ? 'logged in' : v || 'not logged in';
95
+ } catch {
96
+ return `could not tell (${check.cmd} ${check.args.join(' ')} answered in an unexpected form)`;
97
+ }
98
+ }
99
+
100
+ export function status() {
101
+ const lines = [`severally ${SEVERALLY_VERSION}`, ''];
102
+ const problem = configProblem();
103
+ lines.push(`config: ${problem ? `needs fixing, every consultation is refused until it is -- ${problem}` : 'ok'}`);
104
+ for (const w of configWarnings()) lines.push(` mistake (only that consultant or CLI is out of use): ${w}`);
105
+ lines.push(`network: ${POLICY.network === 'off' ? 'off for every consultation (server.network)' : 'per request'}`, '');
106
+
107
+ lines.push('consultants:');
108
+ for (const c of POLICY.consultants) {
109
+ lines.push(
110
+ ` ${c.name}: ${c.cli}, ${c.model} ${consultantAvailable(c) ? 'available' : `not available: ${unavailableReason(c)}`}`,
111
+ );
112
+ }
113
+ for (const name of brokenConsultantNames()) lines.push(` ${name}: not available: ${brokenConsultant(name)}`);
114
+ lines.push('');
115
+
116
+ const stale = staleInstall();
117
+ lines.push(
118
+ stale
119
+ ? `copies: ${Object.entries(stale.copies)
120
+ .map(([k, v]) => `${k} ${v}${stale.behind.includes(k) ? ' (behind)' : ''}`)
121
+ .join(', ')}\n ${stale.fix}`
122
+ : 'copies: every installed copy is this version',
123
+ '',
124
+ );
125
+
126
+ lines.push('clients:');
127
+ for (const client of CLIENTS) lines.push(` ${client.label.padEnd(12)} ${registration(client)}`);
128
+ lines.push('', 'login (out of quota is not checked; the first consultation shows it):');
129
+ for (const cli of [...new Set(POLICY.consultants.map((c) => c.cli))]) lines.push(` ${cli.padEnd(12)} ${login(cli)}`);
130
+ process.stdout.write(`${lines.join('\n')}\n`);
131
+ return problem ? 1 : 0;
132
+ }
package/src/store.mjs CHANGED
@@ -12,7 +12,11 @@ export function ensureDirs() {
12
12
  fs.mkdirSync(path.join(home, 'history'), { recursive: true, mode: 0o700 });
13
13
  fs.mkdirSync(path.join(home, 'jobs'), { recursive: true, mode: 0o700 });
14
14
  fs.mkdirSync(path.join(home, 'homes'), { recursive: true, mode: 0o700 });
15
- try { fs.chmodSync(home, 0o700); } catch { /* best effort */ }
15
+ try {
16
+ fs.chmodSync(home, 0o700);
17
+ } catch {
18
+ /* best effort */
19
+ }
16
20
  return home;
17
21
  }
18
22
 
@@ -45,7 +49,11 @@ export function writeJobArtifact(jobId, name, content) {
45
49
  }
46
50
 
47
51
  export function readIfExists(p) {
48
- try { return fs.readFileSync(p, 'utf8'); } catch { return ''; }
52
+ try {
53
+ return fs.readFileSync(p, 'utf8');
54
+ } catch {
55
+ return '';
56
+ }
49
57
  }
50
58
 
51
59
  // A consultation from an earlier server session, found through the index and
@@ -58,11 +66,36 @@ function indexRow(jobId) {
58
66
  try {
59
67
  const parsed = JSON.parse(line);
60
68
  if (parsed.job_id === jobId) return parsed;
61
- } catch { /* a damaged line is skipped, not fatal */ }
69
+ } catch {
70
+ /* a damaged line is skipped, not fatal */
71
+ }
62
72
  }
63
73
  return null;
64
74
  }
65
75
 
76
+ /** Every row of the history index, in the order written; a damaged line is skipped. */
77
+ export function indexRows() {
78
+ return readIfExists(path.join(POLICY.home, 'history', 'index.jsonl'))
79
+ .split('\n')
80
+ .flatMap((line) => {
81
+ try {
82
+ const row = line.trim() ? JSON.parse(line) : null;
83
+ return row && typeof row === 'object' && JOB_ID.test(row.job_id) ? [row] : [];
84
+ } catch {
85
+ return [];
86
+ }
87
+ });
88
+ }
89
+
90
+ /** The round file an index row points at, or null. */
91
+ export function loadRoundAt(row) {
92
+ try {
93
+ return JSON.parse(fs.readFileSync(roundPath(row.chain_id, row.round), 'utf8'));
94
+ } catch {
95
+ return null;
96
+ }
97
+ }
98
+
66
99
  export const roundPath = (chainId, round) =>
67
100
  path.join(POLICY.home, 'history', chainId, `round-${String(round).padStart(2, '0')}.json`);
68
101
 
@@ -77,7 +110,11 @@ export function writeDiagnostics(chainId, round, text) {
77
110
  export function loadRound(jobId) {
78
111
  const row = indexRow(jobId);
79
112
  if (!row) return null;
80
- try { return JSON.parse(fs.readFileSync(roundPath(row.chain_id, row.round), 'utf8')); } catch { return null; }
113
+ try {
114
+ return JSON.parse(fs.readFileSync(roundPath(row.chain_id, row.round), 'utf8'));
115
+ } catch {
116
+ return null;
117
+ }
81
118
  }
82
119
 
83
120
  // Every round file of one chain, oldest first: a follow-up to a consultation
@@ -85,9 +122,20 @@ export function loadRound(jobId) {
85
122
  export function loadChain(chainId) {
86
123
  const dir = path.join(POLICY.home, 'history', chainId);
87
124
  let names = [];
88
- try { names = fs.readdirSync(dir).filter((n) => /^round-\d+\.json$/.test(n)).sort(); } catch { return []; }
125
+ try {
126
+ names = fs
127
+ .readdirSync(dir)
128
+ .filter((n) => /^round-\d+\.json$/.test(n))
129
+ .sort();
130
+ } catch {
131
+ return [];
132
+ }
89
133
  return names.flatMap((n) => {
90
- try { return [JSON.parse(fs.readFileSync(path.join(dir, n), 'utf8'))]; } catch { return []; }
134
+ try {
135
+ return [JSON.parse(fs.readFileSync(path.join(dir, n), 'utf8'))];
136
+ } catch {
137
+ return [];
138
+ }
91
139
  });
92
140
  }
93
141
 
@@ -154,18 +202,31 @@ export function writeRunning(record) {
154
202
 
155
203
  export function loadRunning(jobId) {
156
204
  if (!JOB_ID.test(jobId)) return null;
157
- try { return JSON.parse(fs.readFileSync(runningFile(jobId), 'utf8')); } catch { return null; }
205
+ try {
206
+ return JSON.parse(fs.readFileSync(runningFile(jobId), 'utf8'));
207
+ } catch {
208
+ return null;
209
+ }
158
210
  }
159
211
 
160
212
  export function listRunning() {
161
213
  try {
162
- return fs.readdirSync(runningDir()).map((n) => n.replace(/\.json$/, '')).filter((id) => JOB_ID.test(id));
163
- } catch { return []; }
214
+ return fs
215
+ .readdirSync(runningDir())
216
+ .map((n) => n.replace(/\.json$/, ''))
217
+ .filter((id) => JOB_ID.test(id));
218
+ } catch {
219
+ return [];
220
+ }
164
221
  }
165
222
 
166
223
  export function removeRunning(jobId) {
167
224
  if (!JOB_ID.test(jobId)) return;
168
- try { fs.rmSync(runningFile(jobId), { force: true }); } catch { /* best effort */ }
225
+ try {
226
+ fs.rmSync(runningFile(jobId), { force: true });
227
+ } catch {
228
+ /* best effort */
229
+ }
169
230
  }
170
231
 
171
232
  // Seats: the concurrency cap is shared by every severally process on one
@@ -183,28 +244,76 @@ function withSeatLock(fn) {
183
244
  fs.mkdirSync(seatsDir(), { recursive: true, mode: 0o700 });
184
245
  const until = Date.now() + LOCK_WAIT_MS;
185
246
  for (;;) {
186
- try { fs.mkdirSync(lock); break; } catch (err) {
247
+ try {
248
+ fs.mkdirSync(lock);
249
+ break;
250
+ } catch (err) {
187
251
  if (err.code !== 'EEXIST') throw err;
188
252
  // Held for a few file operations at most: one this old was left by a
189
253
  // process that died holding it.
190
- try { if (Date.now() - fs.statSync(lock).mtimeMs > LOCK_STALE_MS) fs.rmdirSync(lock); } catch { /* gone already */ }
254
+ try {
255
+ if (Date.now() - fs.statSync(lock).mtimeMs > LOCK_STALE_MS) fs.rmdirSync(lock);
256
+ } catch {
257
+ /* gone already */
258
+ }
191
259
  if (Date.now() > until) throw new Error('the seat lock stayed taken; try again');
192
260
  Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, 10);
193
261
  }
194
262
  }
195
- try { return fn(); } finally { try { fs.rmdirSync(lock); } catch { /* best effort */ } }
263
+ try {
264
+ return fn();
265
+ } finally {
266
+ try {
267
+ fs.rmdirSync(lock);
268
+ } catch {
269
+ /* best effort */
270
+ }
271
+ }
196
272
  }
197
273
 
198
274
  /** Takes one seat per entry of `seats` ({ job_id, owner }), all or none. */
199
275
  export function takeSeats(seats, cap, held) {
200
276
  return withSeatLock(() => {
201
277
  const live = [];
202
- for (const name of fs.readdirSync(seatsDir())) {
278
+ const names = fs.readdirSync(seatsDir());
279
+ // A cancel asked for after its consultation ended has no seat left to go with.
280
+ for (const name of names.filter((n) => n.endsWith('.cancel'))) {
281
+ if (!names.includes(name.replace(/\.cancel$/, '.json')))
282
+ try {
283
+ fs.rmSync(path.join(seatsDir(), name), { force: true });
284
+ } catch {
285
+ /* best effort */
286
+ }
287
+ }
288
+ for (const name of names) {
203
289
  if (!name.endsWith('.json')) continue;
204
290
  let seat = null;
205
- try { seat = JSON.parse(fs.readFileSync(path.join(seatsDir(), name), 'utf8')); } catch { /* half-written or damaged: free */ }
291
+ try {
292
+ seat = JSON.parse(fs.readFileSync(path.join(seatsDir(), name), 'utf8'));
293
+ } catch {
294
+ /* half-written or damaged: free */
295
+ }
206
296
  if (seat && held(seat)) live.push(seat);
207
- else try { fs.rmSync(path.join(seatsDir(), name), { force: true }); } catch { /* best effort */ }
297
+ else
298
+ try {
299
+ fs.rmSync(path.join(seatsDir(), name), { force: true });
300
+ } catch {
301
+ /* best effort */
302
+ }
303
+ }
304
+ // A chain runs one round at a time, across processes: a live seat on the
305
+ // same chain, or a round file already at the number chosen, means another
306
+ // server continued it first.
307
+ for (const mine of seats.filter((x) => x.chain_id)) {
308
+ let other = live.find((x) => x.chain_id === mine.chain_id);
309
+ if (!other && fs.existsSync(roundPath(mine.chain_id, mine.round))) {
310
+ try {
311
+ other = { job_id: JSON.parse(fs.readFileSync(roundPath(mine.chain_id, mine.round), 'utf8')).job_id, round: mine.round };
312
+ } catch {
313
+ other = { job_id: '(unreadable)', round: mine.round };
314
+ }
315
+ }
316
+ if (other) return { ok: false, chainBusy: { live: other, mine } };
208
317
  }
209
318
  if (live.length + seats.length > cap) {
210
319
  return { ok: false, live: live.length, elsewhere: live.filter((x) => x.owner?.pid !== process.pid).length };
@@ -219,12 +328,44 @@ export function takeSeats(seats, cap, held) {
219
328
 
220
329
  export function releaseSeat(jobId) {
221
330
  if (!JOB_ID.test(jobId)) return;
222
- try { fs.rmSync(seatFile(jobId), { force: true }); } catch { /* best effort */ }
331
+ try {
332
+ fs.rmSync(seatFile(jobId), { force: true });
333
+ } catch {
334
+ /* best effort */
335
+ }
336
+ clearCancel(jobId);
337
+ }
338
+
339
+ // A cancel asked for by a server that does not run the consultation: left
340
+ // beside its seat, for the server that does to find and act on.
341
+ const cancelFile = (jobId) => path.join(seatsDir(), `${jobId}.cancel`);
342
+ export function requestCancel(jobId) {
343
+ if (!JOB_ID.test(jobId)) return false;
344
+ try {
345
+ fs.mkdirSync(seatsDir(), { recursive: true, mode: 0o700 });
346
+ fs.writeFileSync(cancelFile(jobId), '', { mode: 0o600 });
347
+ return true;
348
+ } catch {
349
+ return false;
350
+ }
351
+ }
352
+ export const cancelRequested = (jobId) => JOB_ID.test(jobId) && fs.existsSync(cancelFile(jobId));
353
+ export function clearCancel(jobId) {
354
+ if (JOB_ID.test(jobId))
355
+ try {
356
+ fs.rmSync(cancelFile(jobId), { force: true });
357
+ } catch {
358
+ /* best effort */
359
+ }
223
360
  }
224
361
 
225
362
  export function loadGroup(groupId) {
226
363
  if (!GROUP_ID.test(groupId)) return null;
227
- try { return JSON.parse(fs.readFileSync(path.join(POLICY.home, 'history', 'groups', `${groupId}.json`), 'utf8')); } catch { return null; }
364
+ try {
365
+ return JSON.parse(fs.readFileSync(path.join(POLICY.home, 'history', 'groups', `${groupId}.json`), 'utf8'));
366
+ } catch {
367
+ return null;
368
+ }
228
369
  }
229
370
 
230
371
  // A fan-out is recorded as its own record so a later reader can tell which
@@ -238,6 +379,10 @@ export function persistGroup(group) {
238
379
 
239
380
  export function cleanupJobDir(jobId) {
240
381
  for (const dir of [jobDir(jobId), sandboxHome(jobId)]) {
241
- try { fs.rmSync(dir, { recursive: true, force: true }); } catch { /* best effort */ }
382
+ try {
383
+ fs.rmSync(dir, { recursive: true, force: true });
384
+ } catch {
385
+ /* best effort */
386
+ }
242
387
  }
243
388
  }