@alphacoder-v0/pi-loops 0.17.3
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/CHANGELOG.md +1989 -0
- package/LICENSE +21 -0
- package/README.md +370 -0
- package/README.zh-CN.md +606 -0
- package/docs/cli.md +252 -0
- package/docs/configuration.md +110 -0
- package/docs/design.md +155 -0
- package/docs/goal.md +59 -0
- package/docs/hooks.md +110 -0
- package/docs/loops.md +315 -0
- package/docs/mcp.md +90 -0
- package/docs/screenshot.png +0 -0
- package/docs/session-archive.md +64 -0
- package/docs/triggers.md +140 -0
- package/docs/troubleshooting.md +111 -0
- package/docs/web-ui-parity.md +305 -0
- package/examples/README.md +18 -0
- package/examples/mcp-notify-server.mjs +35 -0
- package/examples/mcp.toml +8 -0
- package/package.json +71 -0
- package/skills/pi-loops/SKILL.md +50 -0
- package/src/archive.ts +476 -0
- package/src/args.ts +217 -0
- package/src/cli-entry.mjs +23 -0
- package/src/cli.ts +610 -0
- package/src/config.ts +103 -0
- package/src/danger.ts +128 -0
- package/src/goal.ts +178 -0
- package/src/hooks.ts +436 -0
- package/src/host-control-channel.ts +243 -0
- package/src/host-control.ts +203 -0
- package/src/host-entry.mjs +14 -0
- package/src/host-runtime.ts +222 -0
- package/src/host.ts +329 -0
- package/src/inbox.ts +232 -0
- package/src/job-edit.ts +113 -0
- package/src/job-health.ts +29 -0
- package/src/lock.ts +193 -0
- package/src/log.ts +108 -0
- package/src/mcp-pool.ts +141 -0
- package/src/mcp.ts +885 -0
- package/src/paths.ts +33 -0
- package/src/pi-loops.ts +2292 -0
- package/src/pi-resolver.mjs +49 -0
- package/src/presence.ts +143 -0
- package/src/protocol.ts +198 -0
- package/src/redact.ts +61 -0
- package/src/register-pi.mjs +3 -0
- package/src/runner.ts +149 -0
- package/src/schedule.ts +431 -0
- package/src/scheduler.ts +981 -0
- package/src/sdk-runner.ts +536 -0
- package/src/share.ts +121 -0
- package/src/slots.ts +76 -0
- package/src/snapshot.ts +40 -0
- package/src/store.ts +599 -0
- package/src/subagent-guard.ts +47 -0
- package/src/thinking.ts +30 -0
- package/src/toml.ts +244 -0
- package/src/tools.ts +455 -0
- package/src/transcript.ts +84 -0
- package/src/trigger-runtime.ts +711 -0
- package/src/triggers.ts +558 -0
- package/src/trust.ts +47 -0
- package/src/ts-entry.mjs +67 -0
- package/src/ui-prefs.ts +84 -0
- package/src/version.ts +14 -0
- package/src/web.mjs +4484 -0
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Which loops have stopped working, and the one line that says so.
|
|
3
|
+
*
|
|
4
|
+
* `consecutiveFailures` drives the scheduler's backoff and nothing else reads it, so a loop that has
|
|
5
|
+
* failed forty nights in a row looks exactly like a healthy one until someone types `/cron`. This is
|
|
6
|
+
* what the badge, the session-start line and `/cron` print instead — a question about a list of jobs,
|
|
7
|
+
* answerable without a scheduler, a session or a clock.
|
|
8
|
+
*/
|
|
9
|
+
import { FAILURE_BACKOFF_AFTER } from "./scheduler.ts";
|
|
10
|
+
import type { LoopJob } from "./store.ts";
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Jobs failing on `hostName`, worst first. The threshold is the scheduler's backoff threshold, which
|
|
14
|
+
* is the honest place to draw the line: below it a failure is a bad night, at it the scheduler has
|
|
15
|
+
* already started widening the gap. A job pinned to another host (a shared $HOME) is that host's to
|
|
16
|
+
* report, not ours.
|
|
17
|
+
*/
|
|
18
|
+
export function failingJobs(jobs: LoopJob[], hostName: string): LoopJob[] {
|
|
19
|
+
return jobs.filter((j) => j.enabled && (!j.host || j.host === hostName) && (j.consecutiveFailures ?? 0) >= FAILURE_BACKOFF_AFTER).sort((a, b) => (b.consecutiveFailures ?? 0) - (a.consecutiveFailures ?? 0));
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/** `2 job(s) failing (check-issues ×7)` — one clause, the worst one named, whatever the count. */
|
|
23
|
+
export function failingSummary(jobs: LoopJob[], hostName: string): string | undefined {
|
|
24
|
+
const failing = failingJobs(jobs, hostName);
|
|
25
|
+
const worst = failing[0];
|
|
26
|
+
if (!worst) return undefined;
|
|
27
|
+
const label = worst.name ?? worst.id;
|
|
28
|
+
return `${failing.length} job(s) failing (${label.length > 24 ? `${label.slice(0, 23)}…` : label} ×${worst.consecutiveFailures})`;
|
|
29
|
+
}
|
package/src/lock.ts
ADDED
|
@@ -0,0 +1,193 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Small cross-process primitives: a mkdir-based file lock, atomic writes,
|
|
3
|
+
* and pid liveness. No native deps, works on any POSIX fs (and NTFS).
|
|
4
|
+
*/
|
|
5
|
+
import { randomBytes } from "node:crypto";
|
|
6
|
+
import * as fs from "node:fs";
|
|
7
|
+
import * as path from "node:path";
|
|
8
|
+
|
|
9
|
+
const sleep = (ms: number) => new Promise<void>((r) => setTimeout(r, ms));
|
|
10
|
+
|
|
11
|
+
export interface LockOptions {
|
|
12
|
+
/** A lock older than this is considered abandoned and is broken. */
|
|
13
|
+
staleMs?: number;
|
|
14
|
+
/** Give up after this long. */
|
|
15
|
+
timeoutMs?: number;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* A token identifying who holds a lock. Breaking a stale lock and creating your own is not enough:
|
|
20
|
+
* the original holder's `finally` would delete *your* directory and let a third caller in. Each
|
|
21
|
+
* holder writes its own token and only removes the lock while that token is still there.
|
|
22
|
+
*/
|
|
23
|
+
function writeOwner(lockPath: string): string | undefined {
|
|
24
|
+
const token = `${process.pid}.${randomBytes(6).toString("hex")}`;
|
|
25
|
+
try {
|
|
26
|
+
fs.writeFileSync(path.join(lockPath, "owner"), token, "utf8");
|
|
27
|
+
return token;
|
|
28
|
+
} catch {
|
|
29
|
+
/* an unwritable lock dir still serialises via mkdir; the token is the extra safety */
|
|
30
|
+
return undefined;
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function releaseOwned(lockPath: string, token: string | undefined): void {
|
|
35
|
+
// No token means `writeOwner` could not write one (a full or read-only filesystem). Removing the
|
|
36
|
+
// directory blind is the same cross-holder deletion from the other side, so leave it: the stale
|
|
37
|
+
// window is a few seconds and whoever is waiting breaks it themselves.
|
|
38
|
+
if (!token) return;
|
|
39
|
+
let current: string;
|
|
40
|
+
try {
|
|
41
|
+
current = fs.readFileSync(path.join(lockPath, "owner"), "utf8");
|
|
42
|
+
} catch {
|
|
43
|
+
// We wrote a token and it is gone, so this directory is no longer the one we created:
|
|
44
|
+
// someone broke our lock as stale and has not written their token yet. Removing it here
|
|
45
|
+
// would delete their lock and reopen the very race the token exists to close.
|
|
46
|
+
return;
|
|
47
|
+
}
|
|
48
|
+
if (current !== token) return; // someone broke our lock and took it: theirs to release
|
|
49
|
+
try {
|
|
50
|
+
fs.rmSync(lockPath, { recursive: true, force: true });
|
|
51
|
+
} catch {
|
|
52
|
+
/* the stale window will break it */
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Synchronous sibling of `withFileLock`, for the append paths that must stay synchronous
|
|
58
|
+
* (`Inbox.append` is called from hook callbacks that cannot await). The critical section is a
|
|
59
|
+
* single `appendFileSync`, so the spin never lasts more than a few milliseconds.
|
|
60
|
+
*/
|
|
61
|
+
export function withFileLockSync<T>(lockPath: string, fn: () => T, opts: LockOptions = {}): T {
|
|
62
|
+
const staleMs = opts.staleMs ?? 10_000;
|
|
63
|
+
// Longer than `staleMs` on purpose: a lock left by a process killed between mkdir and rm can
|
|
64
|
+
// only be broken after it goes stale, and a shorter deadline would spin and then throw instead.
|
|
65
|
+
const deadline = Date.now() + (opts.timeoutMs ?? staleMs + 5_000);
|
|
66
|
+
fs.mkdirSync(path.dirname(lockPath), { recursive: true });
|
|
67
|
+
const spin = new Int32Array(new SharedArrayBuffer(4));
|
|
68
|
+
for (;;) {
|
|
69
|
+
try {
|
|
70
|
+
fs.mkdirSync(lockPath);
|
|
71
|
+
break;
|
|
72
|
+
} catch (err: any) {
|
|
73
|
+
if (err?.code !== "EEXIST") throw err;
|
|
74
|
+
// The deadline is checked on every path: a lock directory we can neither stat nor remove
|
|
75
|
+
// (EACCES, or a Windows EPERM) must not turn into an event-loop-blocking hot spin.
|
|
76
|
+
if (Date.now() > deadline) throw new Error(`timed out waiting for lock ${lockPath}`);
|
|
77
|
+
try {
|
|
78
|
+
if (Date.now() - fs.statSync(lockPath).mtimeMs > staleMs) {
|
|
79
|
+
fs.rmSync(lockPath, { recursive: true, force: true });
|
|
80
|
+
continue;
|
|
81
|
+
}
|
|
82
|
+
} catch {
|
|
83
|
+
/* vanished, or unreadable: wait and try again */
|
|
84
|
+
}
|
|
85
|
+
Atomics.wait(spin, 0, 0, 5 + Math.floor(Math.random() * 10));
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
const token = writeOwner(lockPath);
|
|
89
|
+
try {
|
|
90
|
+
return fn();
|
|
91
|
+
} finally {
|
|
92
|
+
releaseOwned(lockPath, token);
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/** Run `fn` while holding `<lockPath>` (a directory created with mkdir, which is atomic). */
|
|
97
|
+
export async function withFileLock<T>(lockPath: string, fn: () => Promise<T> | T, opts: LockOptions = {}): Promise<T> {
|
|
98
|
+
const staleMs = opts.staleMs ?? 10_000;
|
|
99
|
+
// Longer than `staleMs`, for the same reason as the synchronous sibling: a lock left behind by
|
|
100
|
+
// a killed process can only be broken once it goes stale, and a shorter deadline would give up
|
|
101
|
+
// just before that moment and throw instead.
|
|
102
|
+
const timeoutMs = opts.timeoutMs ?? staleMs + 5_000;
|
|
103
|
+
const deadline = Date.now() + timeoutMs;
|
|
104
|
+
fs.mkdirSync(path.dirname(lockPath), { recursive: true });
|
|
105
|
+
for (;;) {
|
|
106
|
+
try {
|
|
107
|
+
fs.mkdirSync(lockPath);
|
|
108
|
+
break;
|
|
109
|
+
} catch (err: any) {
|
|
110
|
+
if (err?.code !== "EEXIST") throw err;
|
|
111
|
+
// The deadline is checked on every path, as in the synchronous sibling: a lock path that
|
|
112
|
+
// exists for mkdir and not for stat — a dangling symlink is the way this happens — would
|
|
113
|
+
// otherwise loop with neither a sleep nor a way out, at 100 % of a core, forever.
|
|
114
|
+
if (Date.now() > deadline) throw new Error(`timed out waiting for lock ${lockPath}`);
|
|
115
|
+
try {
|
|
116
|
+
const age = Date.now() - fs.statSync(lockPath).mtimeMs;
|
|
117
|
+
if (age > staleMs) {
|
|
118
|
+
fs.rmSync(lockPath, { recursive: true, force: true });
|
|
119
|
+
continue;
|
|
120
|
+
}
|
|
121
|
+
} catch {
|
|
122
|
+
/* vanished between EEXIST and stat, or unreadable: wait and try again */
|
|
123
|
+
}
|
|
124
|
+
await sleep(20 + Math.floor(Math.random() * 30));
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
const token = writeOwner(lockPath);
|
|
128
|
+
try {
|
|
129
|
+
return await fn();
|
|
130
|
+
} finally {
|
|
131
|
+
releaseOwned(lockPath, token);
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/** Write via temp file + rename so readers never see a torn file. */
|
|
136
|
+
export function writeFileAtomic(file: string, data: string): void {
|
|
137
|
+
fs.mkdirSync(path.dirname(file), { recursive: true });
|
|
138
|
+
const tmp = `${file}.${process.pid}.${Date.now().toString(36)}.tmp`;
|
|
139
|
+
// fsync before the rename: without it a crash can leave the rename applied and the data not,
|
|
140
|
+
// i.e. an empty file where the store used to be. The directory is synced too so the rename
|
|
141
|
+
// itself survives. Both are best-effort — a filesystem that refuses them is not a reason to fail.
|
|
142
|
+
let fd: number | undefined;
|
|
143
|
+
try {
|
|
144
|
+
fd = fs.openSync(tmp, "w");
|
|
145
|
+
fs.writeFileSync(fd, data, "utf8");
|
|
146
|
+
try {
|
|
147
|
+
fs.fsyncSync(fd);
|
|
148
|
+
} catch {
|
|
149
|
+
/* not supported here */
|
|
150
|
+
}
|
|
151
|
+
} catch (err) {
|
|
152
|
+
try {
|
|
153
|
+
fs.rmSync(tmp, { force: true });
|
|
154
|
+
} catch {
|
|
155
|
+
/* nothing more to do */
|
|
156
|
+
}
|
|
157
|
+
throw err;
|
|
158
|
+
} finally {
|
|
159
|
+
if (fd !== undefined) fs.closeSync(fd);
|
|
160
|
+
}
|
|
161
|
+
try {
|
|
162
|
+
fs.renameSync(tmp, file);
|
|
163
|
+
} catch (err) {
|
|
164
|
+
// ENOSPC/EACCES here leaves the temp behind; on a full disk that is one abandoned file per
|
|
165
|
+
// process per tick, eating inodes long after the write itself was reported and handled.
|
|
166
|
+
try {
|
|
167
|
+
fs.rmSync(tmp, { force: true });
|
|
168
|
+
} catch {
|
|
169
|
+
/* nothing more to do */
|
|
170
|
+
}
|
|
171
|
+
throw err;
|
|
172
|
+
}
|
|
173
|
+
try {
|
|
174
|
+
const dir = fs.openSync(path.dirname(file), "r");
|
|
175
|
+
try {
|
|
176
|
+
fs.fsyncSync(dir);
|
|
177
|
+
} finally {
|
|
178
|
+
fs.closeSync(dir);
|
|
179
|
+
}
|
|
180
|
+
} catch {
|
|
181
|
+
/* directory fsync is not portable; the file fsync above is the important half */
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
export function pidAlive(pid: number): boolean {
|
|
186
|
+
if (!Number.isInteger(pid) || pid <= 0) return false;
|
|
187
|
+
try {
|
|
188
|
+
process.kill(pid, 0);
|
|
189
|
+
return true;
|
|
190
|
+
} catch (err: any) {
|
|
191
|
+
return err?.code === "EPERM";
|
|
192
|
+
}
|
|
193
|
+
}
|
package/src/log.ts
ADDED
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A file every process writes its diagnostics to.
|
|
3
|
+
*
|
|
4
|
+
* Everything except the headless host went to `ctx.ui.notify`, which is not written to the
|
|
5
|
+
* session file — so `/new`, `/resume` or a crash erased every warning the automation had produced.
|
|
6
|
+
* A loop that failed at 03:00 left nothing behind to read at 09:00.
|
|
7
|
+
*
|
|
8
|
+
* Rotated in place past `MAX_BYTES`, like the run log and the audit.
|
|
9
|
+
*/
|
|
10
|
+
import * as fs from "node:fs";
|
|
11
|
+
import * as path from "node:path";
|
|
12
|
+
import { pidAlive } from "./lock.ts";
|
|
13
|
+
import { capRedacted } from "./redact.ts";
|
|
14
|
+
import { stamp } from "./schedule.ts";
|
|
15
|
+
|
|
16
|
+
export const MAX_LOG_BYTES = 2_000_000;
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* What one log line may say, in characters — the same bound `hooks.ts` puts on a hook's captured
|
|
20
|
+
* output, for the same reason: the text usually comes from something else's stdout, and a diagnostic
|
|
21
|
+
* nobody bounded is how a 2 MB log becomes one line.
|
|
22
|
+
*/
|
|
23
|
+
export const MAX_LOG_LINE_CHARS = 4000;
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Halve a log file in place once it passes `maxBytes`. One home for "rotated at 2 MB": the headless
|
|
27
|
+
* host writes its own file (its stdout and stderr go there too, so an MCP server's chatter lands in
|
|
28
|
+
* it) and used to carry a second copy of this, free to drift from the rule every other log follows.
|
|
29
|
+
*/
|
|
30
|
+
export function rotateInPlace(file: string, maxBytes: number = MAX_LOG_BYTES): void {
|
|
31
|
+
try {
|
|
32
|
+
if (fs.statSync(file).size < maxBytes) return;
|
|
33
|
+
const lines = fs.readFileSync(file, "utf8").split("\n").filter(Boolean);
|
|
34
|
+
const keep = Math.floor(lines.length / 2);
|
|
35
|
+
// One line can be over the limit on its own, and `slice(-0)` is the whole array — so without
|
|
36
|
+
// this the file would be rewritten identical forever, and every later write would pay a full
|
|
37
|
+
// read and write of it. The same guard the run log and the inbox carry.
|
|
38
|
+
fs.writeFileSync(file, keep === 0 ? "" : `${lines.slice(-keep).join("\n")}\n`);
|
|
39
|
+
} catch {
|
|
40
|
+
/* best effort */
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export type LogLevel = "info" | "warn" | "error";
|
|
45
|
+
|
|
46
|
+
export class LoopsLog {
|
|
47
|
+
readonly file: string;
|
|
48
|
+
private failed = false;
|
|
49
|
+
|
|
50
|
+
constructor(dir: string, name: string) {
|
|
51
|
+
this.file = path.join(dir, "logs", name);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
write(level: LogLevel, message: string): void {
|
|
55
|
+
if (this.failed) return; // a log that cannot be written must never become the loudest problem
|
|
56
|
+
const line = `${stamp()} ${level.padEnd(5)} ${capRedacted(message, MAX_LOG_LINE_CHARS)}\n`;
|
|
57
|
+
try {
|
|
58
|
+
fs.mkdirSync(path.dirname(this.file), { recursive: true });
|
|
59
|
+
fs.appendFileSync(this.file, line);
|
|
60
|
+
rotateInPlace(this.file);
|
|
61
|
+
} catch {
|
|
62
|
+
this.failed = true;
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
info(message: string): void {
|
|
67
|
+
this.write("info", message);
|
|
68
|
+
}
|
|
69
|
+
warn(message: string): void {
|
|
70
|
+
this.write("warn", message);
|
|
71
|
+
}
|
|
72
|
+
error(message: string): void {
|
|
73
|
+
this.write("error", message);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** The last `n` lines, for `/cron scheduler` and a future bug report. */
|
|
77
|
+
tail(n = 40): string[] {
|
|
78
|
+
try {
|
|
79
|
+
return fs.readFileSync(this.file, "utf8").split("\n").filter(Boolean).slice(-n);
|
|
80
|
+
} catch {
|
|
81
|
+
return [];
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* Old per-process logs are not interesting once their process is gone; keep the newest few. A log
|
|
89
|
+
* whose pid is still running is never deleted, however old it looks: a headless host can sit idle
|
|
90
|
+
* for days while newer sessions come and go, and its log is the one a user goes looking for.
|
|
91
|
+
*/
|
|
92
|
+
export function pruneLogs(dir: string, keep = 5): void {
|
|
93
|
+
const logsDir = path.join(dir, "logs");
|
|
94
|
+
try {
|
|
95
|
+
const files = fs
|
|
96
|
+
.readdirSync(logsDir)
|
|
97
|
+
.filter((f) => f.startsWith("pi-") && f.endsWith(".log"))
|
|
98
|
+
.map((f) => ({ f, mtime: fs.statSync(path.join(logsDir, f)).mtimeMs }))
|
|
99
|
+
.sort((a, b) => b.mtime - a.mtime);
|
|
100
|
+
for (const { f } of files.slice(keep)) {
|
|
101
|
+
const pid = Number(f.slice(3, -4));
|
|
102
|
+
if (Number.isFinite(pid) && pid > 0 && pidAlive(pid)) continue;
|
|
103
|
+
fs.rmSync(path.join(logsDir, f), { force: true });
|
|
104
|
+
}
|
|
105
|
+
} catch {
|
|
106
|
+
/* nothing to prune */
|
|
107
|
+
}
|
|
108
|
+
}
|
package/src/mcp-pool.ts
ADDED
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Project-level MCP servers for runs outside this process's own project.
|
|
3
|
+
*
|
|
4
|
+
* A pi process loads `<its cwd>/.pi/mcp.toml` once and shares those clients. But a loop belongs to
|
|
5
|
+
* a project, not to whichever pi owns the clock: a job in project B run by a pi open in project A
|
|
6
|
+
* used to get A's servers and never B's, and the headless host (no project at all) got none. A
|
|
7
|
+
* single-project process never has this problem — one process, one cwd, one project; this pool is
|
|
8
|
+
* what gives a run its own project's servers when the two differ. It connects a project's servers
|
|
9
|
+
* lazily, keyed by directory, and hands their tools to runs in that directory.
|
|
10
|
+
*
|
|
11
|
+
* User-level servers are not pooled: those are shared and already connected by the process.
|
|
12
|
+
*/
|
|
13
|
+
import type { ToolDefinition } from "@earendil-works/pi-coding-agent";
|
|
14
|
+
import { McpSource, loadProjectMcpConfig, mcpToolDefinitions } from "./mcp.ts";
|
|
15
|
+
import { previewRedacted } from "./redact.ts";
|
|
16
|
+
|
|
17
|
+
export interface McpPoolOptions {
|
|
18
|
+
/** Whether project-local config in `cwd` may be loaded at all (pi's saved trust decisions). */
|
|
19
|
+
isTrusted: (cwd: string) => boolean;
|
|
20
|
+
/** Where an untrusted project or a failed connection is reported (one line, not one per run). */
|
|
21
|
+
log?: (message: string) => void;
|
|
22
|
+
resolveToken?: (ref: string) => string | undefined;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
interface Entry {
|
|
26
|
+
sources: McpSource[];
|
|
27
|
+
defs: Array<{ name: string; def: ToolDefinition<any, any>; server: string }>;
|
|
28
|
+
ready: Promise<void>;
|
|
29
|
+
lastUsed: number;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** Projects kept connected at once; the least recently used is dropped past this. */
|
|
33
|
+
const MAX_POOLED_PROJECTS = 8;
|
|
34
|
+
|
|
35
|
+
export class McpPool {
|
|
36
|
+
private readonly entries = new Map<string, Entry>();
|
|
37
|
+
/** Projects we already said were untrusted: say it once, not once per run. */
|
|
38
|
+
private readonly warned = new Set<string>();
|
|
39
|
+
private readonly opts: McpPoolOptions;
|
|
40
|
+
constructor(opts: McpPoolOptions) {
|
|
41
|
+
this.opts = opts;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* The project-level MCP tools for a run in `cwd`, connecting that project's servers on first use.
|
|
46
|
+
* `taken` is the tool names already in use for that run (built-ins, user-level MCP tools, the
|
|
47
|
+
* automation tools), so a project's server cannot shadow one of them.
|
|
48
|
+
*/
|
|
49
|
+
async toolsFor(cwd: string, taken: Set<string>): Promise<ToolDefinition<any, any>[]> {
|
|
50
|
+
if (!cwd) return [];
|
|
51
|
+
// Trust is re-checked every time: a decision the user revoked must stop lending servers,
|
|
52
|
+
// not keep the ones an earlier run happened to connect.
|
|
53
|
+
if (!this.opts.isTrusted(cwd)) {
|
|
54
|
+
const stale = this.entries.get(cwd);
|
|
55
|
+
if (stale) {
|
|
56
|
+
this.entries.delete(cwd);
|
|
57
|
+
void Promise.all(stale.sources.map((s) => s.stop())).catch(() => undefined);
|
|
58
|
+
}
|
|
59
|
+
if (!this.warned.has(cwd)) {
|
|
60
|
+
this.warned.add(cwd);
|
|
61
|
+
this.opts.log?.(`project MCP config in ${cwd} ignored: project is not trusted`);
|
|
62
|
+
}
|
|
63
|
+
return [];
|
|
64
|
+
}
|
|
65
|
+
const entry = this.entries.get(cwd) ?? this.connect(cwd);
|
|
66
|
+
entry.lastUsed = Date.now();
|
|
67
|
+
await entry.ready;
|
|
68
|
+
// Names are settled per run: a project tool that collides with a built-in or a user-level
|
|
69
|
+
// server's tool is offered under `<server>_<tool>` rather than dropped, exactly as the
|
|
70
|
+
// interactive path does — a project exposing `read` used to silently lose it here.
|
|
71
|
+
const out: ToolDefinition<any, any>[] = [];
|
|
72
|
+
for (const { name, def, server } of entry.defs) {
|
|
73
|
+
const unique = taken.has(name) ? `${server}_${name}` : name;
|
|
74
|
+
if (taken.has(unique)) {
|
|
75
|
+
this.opts.log?.(`tool ${name} from ${server} (${cwd}) is not available: both ${name} and ${unique} are taken`);
|
|
76
|
+
continue;
|
|
77
|
+
}
|
|
78
|
+
taken.add(unique);
|
|
79
|
+
out.push(unique === name ? def : { ...def, name: unique });
|
|
80
|
+
}
|
|
81
|
+
return out;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
private connect(cwd: string): Entry {
|
|
85
|
+
const entry: Entry = { sources: [], defs: [], ready: Promise.resolve(), lastUsed: Date.now() };
|
|
86
|
+
this.warned.delete(cwd);
|
|
87
|
+
this.evictIfFull();
|
|
88
|
+
this.entries.set(cwd, entry);
|
|
89
|
+
const loaded = loadProjectMcpConfig(cwd);
|
|
90
|
+
for (const d of loaded.diagnostics) this.opts.log?.(d);
|
|
91
|
+
if (!loaded.servers.length) return entry;
|
|
92
|
+
const waits: Promise<void>[] = [];
|
|
93
|
+
for (const cfg of loaded.servers) {
|
|
94
|
+
let settle!: () => void;
|
|
95
|
+
waits.push(new Promise<void>((r) => (settle = r)));
|
|
96
|
+
const source = new McpSource(cfg, {
|
|
97
|
+
onConnected: async (src) => {
|
|
98
|
+
try {
|
|
99
|
+
const tools = await src.listTools();
|
|
100
|
+
const taken = new Set(entry.defs.map((d) => d.name));
|
|
101
|
+
entry.defs.push(...mcpToolDefinitions(src, tools, taken, []).map((d) => ({ ...d, server: cfg.name })));
|
|
102
|
+
this.opts.log?.(`mcp ${cfg.name} (${cwd}): ${tools.length} tool(s)`);
|
|
103
|
+
} catch (err: any) {
|
|
104
|
+
src.status.lastError = `tools/list failed: ${err?.message ?? err}`;
|
|
105
|
+
} finally {
|
|
106
|
+
settle();
|
|
107
|
+
}
|
|
108
|
+
},
|
|
109
|
+
// A project server's pushes belong to the process that owns that project's rules;
|
|
110
|
+
// the pool exists to lend tools to a run, not to open a second notification path.
|
|
111
|
+
onNotification: () => undefined,
|
|
112
|
+
resolveToken: this.opts.resolveToken,
|
|
113
|
+
log: (msg) => this.opts.log?.(`mcp ${cfg.name} (${cwd}): ${previewRedacted(msg, 200)}`),
|
|
114
|
+
});
|
|
115
|
+
entry.sources.push(source);
|
|
116
|
+
source.start();
|
|
117
|
+
// Never hold a run hostage to a server that will not come up.
|
|
118
|
+
const timer = setTimeout(settle, 10_000);
|
|
119
|
+
timer.unref?.();
|
|
120
|
+
}
|
|
121
|
+
entry.ready = Promise.all(waits).then(() => undefined);
|
|
122
|
+
return entry;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/** Keep the pool bounded: one loop per project on a busy machine must not pin every project. */
|
|
126
|
+
private evictIfFull(): void {
|
|
127
|
+
while (this.entries.size >= MAX_POOLED_PROJECTS) {
|
|
128
|
+
const oldest = [...this.entries.entries()].sort((a, b) => a[1].lastUsed - b[1].lastUsed)[0];
|
|
129
|
+
if (!oldest) return;
|
|
130
|
+
this.entries.delete(oldest[0]);
|
|
131
|
+
this.opts.log?.(`disconnected ${oldest[0]}'s MCP servers (pool limit ${MAX_POOLED_PROJECTS})`);
|
|
132
|
+
void Promise.all(oldest[1].sources.map((s) => s.stop())).catch(() => undefined);
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
async stopAll(): Promise<void> {
|
|
137
|
+
const sources = [...this.entries.values()].flatMap((e) => e.sources);
|
|
138
|
+
this.entries.clear();
|
|
139
|
+
await Promise.all(sources.map((s) => s.stop()));
|
|
140
|
+
}
|
|
141
|
+
}
|