@alphacoder-v0/pi-loops 0.22.8 → 0.24.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/CHANGELOG.md +664 -0
- package/CONTRACT.md +7 -7
- package/README.md +91 -63
- package/README.zh-CN.md +59 -39
- package/docs/cli.md +272 -83
- package/docs/configuration.md +86 -24
- package/docs/design.md +395 -56
- package/docs/downstream.md +2 -2
- package/docs/goal.md +12 -3
- package/docs/hooks.md +6 -7
- package/docs/loops.md +288 -59
- package/docs/mcp.md +22 -14
- package/docs/recipes.md +6 -0
- package/docs/screenshot.png +0 -0
- package/docs/triggers.md +120 -26
- package/docs/troubleshooting.md +37 -26
- package/docs/web-ui-parity.md +691 -65
- package/package.json +4 -3
- package/recipes/autoresearch/recipe.toml +1 -0
- package/recipes/changelog-draft/recipe.toml +1 -0
- package/recipes/ci-sweeper/recipe.toml +1 -0
- package/recipes/daily-digest/recipe.toml +1 -0
- package/recipes/deps-sweeper/recipe.toml +1 -0
- package/recipes/ecosystem/recipe.toml +1 -0
- package/recipes/issue-loop/recipe.toml +1 -0
- package/recipes/pr-watch/recipe.toml +1 -0
- package/skills/pi-loops/SKILL.md +6 -4
- package/src/activity.ts +199 -0
- package/src/archive.ts +21 -9
- package/src/args.ts +6 -2
- package/src/cli-entry.mjs +12 -3
- package/src/cli.ts +746 -125
- package/src/config.ts +53 -5
- package/src/conversation-rows.ts +469 -0
- package/src/danger.ts +227 -6
- package/src/engine.ts +1240 -0
- package/src/goal.ts +71 -0
- package/src/held-read.ts +27 -0
- package/src/hooks.ts +49 -18
- package/src/host-control-channel.ts +455 -40
- package/src/host-control.ts +602 -111
- package/src/host-entry.mjs +5 -3
- package/src/host-runtime.ts +455 -42
- package/src/host.ts +659 -123
- package/src/inbox-card.ts +278 -0
- package/src/inbox.ts +139 -62
- package/src/job-edit.ts +7 -1
- package/src/job-health.ts +14 -2
- package/src/job-lines.ts +201 -13
- package/src/job-owner.ts +74 -13
- package/src/job-tune.ts +110 -0
- package/src/lease.ts +537 -0
- package/src/lock.ts +7 -2
- package/src/mcp-pool.ts +66 -14
- package/src/mcp.ts +1 -1
- package/src/open-file.ts +54 -0
- package/src/page-children.ts +1507 -0
- package/src/page-devices.ts +192 -0
- package/src/page-server.ts +3150 -0
- package/src/path-precheck.ts +215 -0
- package/src/paths.ts +24 -0
- package/src/pi-floor.ts +10 -0
- package/src/pi-loops.ts +1301 -450
- package/src/pi-resolver.d.mts +1 -0
- package/src/pi-resolver.mjs +110 -10
- package/src/presence.ts +12 -63
- package/src/protocol.ts +10 -1
- package/src/recipe.ts +43 -1
- package/src/requests.ts +354 -0
- package/src/rule-lines.ts +68 -0
- package/src/run-card.ts +105 -0
- package/src/runner.ts +18 -1
- package/src/schedule.ts +1 -2
- package/src/scheduler.ts +595 -245
- package/src/sdk-runner.ts +321 -27
- package/src/seen.ts +278 -0
- package/src/session-head.ts +21 -0
- package/src/share.ts +12 -2
- package/src/snapshot.ts +3 -2
- package/src/store.ts +81 -8
- package/src/tools.ts +196 -65
- package/src/trigger-runtime.ts +164 -127
- package/src/triggers.ts +39 -7
- package/src/trust.ts +46 -2
- package/src/ui-prefs.ts +16 -5
- package/src/watch-plan.ts +174 -0
- package/src/web.d.mts +3 -0
- package/src/web.mjs +2260 -2340
- package/src/window-executor.ts +459 -0
- package/src/window-guard.ts +158 -0
- package/docs/agents/issue-tracker.md +0 -34
package/src/mcp-pool.ts
CHANGED
|
@@ -2,8 +2,8 @@
|
|
|
2
2
|
* Project-level MCP servers for runs outside this process's own project.
|
|
3
3
|
*
|
|
4
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
|
|
6
|
-
* used to get A's servers and never B's, and the
|
|
5
|
+
* a project, not to the process that runs it: 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 host (no project at all) gets none of its own. A
|
|
7
7
|
* single-project process never has this problem — one process, one cwd, one project; this pool is
|
|
8
8
|
* what gives a run its own project's servers when the two differ. It connects a project's servers
|
|
9
9
|
* lazily, keyed by directory, and hands their tools to runs in that directory.
|
|
@@ -11,7 +11,8 @@
|
|
|
11
11
|
* User-level servers are not pooled: those are shared and already connected by the process.
|
|
12
12
|
*/
|
|
13
13
|
import type { ToolDefinition } from "@earendil-works/pi-coding-agent";
|
|
14
|
-
import { McpSource, loadProjectMcpConfig, mcpToolDefinitions } from "./mcp.ts";
|
|
14
|
+
import { McpSource, loadProjectMcpConfig, mcpToolDefinitions, type McpNotification, type McpServerConfig } from "./mcp.ts";
|
|
15
|
+
import { realpathish } from "./paths.ts";
|
|
15
16
|
import { previewRedacted } from "./redact.ts";
|
|
16
17
|
|
|
17
18
|
export interface McpPoolOptions {
|
|
@@ -20,6 +21,12 @@ export interface McpPoolOptions {
|
|
|
20
21
|
/** Where an untrusted project or a failed connection is reported (one line, not one per run). */
|
|
21
22
|
log?: (message: string) => void;
|
|
22
23
|
resolveToken?: (ref: string) => string | undefined;
|
|
24
|
+
/**
|
|
25
|
+
* A push from a project's server. The host passes this: a project's rules are evaluated by the
|
|
26
|
+
* host alone, so a project-level push server has to reach it (a window drops rule-evaluated
|
|
27
|
+
* pushes). Without it the pool only lends tools.
|
|
28
|
+
*/
|
|
29
|
+
onNotification?: (cwd: string, cfg: McpServerConfig, notification: McpNotification, source: McpSource) => void;
|
|
23
30
|
}
|
|
24
31
|
|
|
25
32
|
interface Entry {
|
|
@@ -27,6 +34,10 @@ interface Entry {
|
|
|
27
34
|
defs: Array<{ name: string; def: ToolDefinition<any, any>; server: string }>;
|
|
28
35
|
ready: Promise<void>;
|
|
29
36
|
lastUsed: number;
|
|
37
|
+
/** Kept connected for its pushes by `watch`, not only lent to a run. */
|
|
38
|
+
watched?: boolean;
|
|
39
|
+
/** Connected for a held turn on its owner's trust, not on saved trust for this exact directory. */
|
|
40
|
+
byOwner?: boolean;
|
|
30
41
|
}
|
|
31
42
|
|
|
32
43
|
/** Projects kept connected at once; the least recently used is dropped past this. */
|
|
@@ -36,6 +47,7 @@ export class McpPool {
|
|
|
36
47
|
private readonly entries = new Map<string, Entry>();
|
|
37
48
|
/** Projects we already said were untrusted: say it once, not once per run. */
|
|
38
49
|
private readonly warned = new Set<string>();
|
|
50
|
+
private warnedOverCap = false;
|
|
39
51
|
private readonly opts: McpPoolOptions;
|
|
40
52
|
constructor(opts: McpPoolOptions) {
|
|
41
53
|
this.opts = opts;
|
|
@@ -46,13 +58,17 @@ export class McpPool {
|
|
|
46
58
|
* `taken` is the tool names already in use for that run (built-ins, user-level MCP tools, the
|
|
47
59
|
* automation tools), so a project's server cannot shadow one of them.
|
|
48
60
|
*/
|
|
49
|
-
async toolsFor(cwd: string, taken: Set<string
|
|
61
|
+
async toolsFor(cwd: string, taken: Set<string>, trusted?: boolean): Promise<ToolDefinition<any, any>[]> {
|
|
50
62
|
if (!cwd) return [];
|
|
51
63
|
// 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
|
-
|
|
64
|
+
// not keep the ones an earlier run happened to connect. `trusted` is a held turn's: pi's own
|
|
65
|
+
// saved decision for its conversation's directory or a parent, which its caller read just now.
|
|
66
|
+
const exact = this.opts.isTrusted(cwd);
|
|
67
|
+
if (!trusted && !exact) {
|
|
54
68
|
const stale = this.entries.get(cwd);
|
|
55
|
-
|
|
69
|
+
// A loop's run (no `trusted` at all) does not take away servers a held turn connected on its
|
|
70
|
+
// owner's trust; a held turn whose owner no longer trusts the directory (`false`) does.
|
|
71
|
+
if (stale && (!stale.byOwner || trusted === false)) {
|
|
56
72
|
this.entries.delete(cwd);
|
|
57
73
|
void Promise.all(stale.sources.map((s) => s.stop())).catch(() => undefined);
|
|
58
74
|
}
|
|
@@ -62,7 +78,8 @@ export class McpPool {
|
|
|
62
78
|
}
|
|
63
79
|
return [];
|
|
64
80
|
}
|
|
65
|
-
const entry = this.entries.get(cwd) ?? this.connect(cwd);
|
|
81
|
+
const entry = this.entries.get(cwd) ?? this.connect(cwd, !exact);
|
|
82
|
+
if (exact) entry.byOwner = false;
|
|
66
83
|
entry.lastUsed = Date.now();
|
|
67
84
|
await entry.ready;
|
|
68
85
|
// Names are settled per run: a project tool that collides with a built-in or a user-level
|
|
@@ -81,8 +98,8 @@ export class McpPool {
|
|
|
81
98
|
return out;
|
|
82
99
|
}
|
|
83
100
|
|
|
84
|
-
private connect(cwd: string): Entry {
|
|
85
|
-
const entry: Entry = { sources: [], defs: [], ready: Promise.resolve(), lastUsed: Date.now() };
|
|
101
|
+
private connect(cwd: string, byOwner = false): Entry {
|
|
102
|
+
const entry: Entry = { sources: [], defs: [], ready: Promise.resolve(), lastUsed: Date.now(), byOwner };
|
|
86
103
|
this.warned.delete(cwd);
|
|
87
104
|
this.evictIfFull();
|
|
88
105
|
this.entries.set(cwd, entry);
|
|
@@ -106,9 +123,9 @@ export class McpPool {
|
|
|
106
123
|
settle();
|
|
107
124
|
}
|
|
108
125
|
},
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
126
|
+
onNotification: (n) => {
|
|
127
|
+
if (this.accepts(cwd)) this.opts.onNotification?.(cwd, cfg, n, source);
|
|
128
|
+
},
|
|
112
129
|
resolveToken: this.opts.resolveToken,
|
|
113
130
|
log: (msg) => this.opts.log?.(`mcp ${cfg.name} (${cwd}): ${previewRedacted(msg, 200)}`),
|
|
114
131
|
});
|
|
@@ -122,10 +139,45 @@ export class McpPool {
|
|
|
122
139
|
return entry;
|
|
123
140
|
}
|
|
124
141
|
|
|
142
|
+
/**
|
|
143
|
+
* Keep these projects' servers connected for their pushes: the projects with enabled rules. Each
|
|
144
|
+
* one is re-checked against trust here, the way a run's lookup is; one already connected is only
|
|
145
|
+
* marked as recently used, and the pool's bound still applies.
|
|
146
|
+
*/
|
|
147
|
+
watch(cwds: readonly string[]): void {
|
|
148
|
+
if (!this.opts.onNotification) return;
|
|
149
|
+
// One spelling per project (a trailing slash, a symlink): otherwise each got its own servers.
|
|
150
|
+
const wanted = [...new Set(cwds.filter(Boolean).map((cwd) => realpathish(cwd)))].filter((cwd) => this.opts.isTrusted(cwd)).sort();
|
|
151
|
+
if (wanted.length > MAX_POOLED_PROJECTS && !this.warnedOverCap) {
|
|
152
|
+
this.warnedOverCap = true;
|
|
153
|
+
this.opts.log?.(`${wanted.length} projects have rules and push servers; only the first ${MAX_POOLED_PROJECTS} are kept connected`);
|
|
154
|
+
}
|
|
155
|
+
const keep = new Set(wanted.slice(0, MAX_POOLED_PROJECTS));
|
|
156
|
+
// What `watch` opened and is no longer wanted (trust revoked, rules removed) is stopped: a
|
|
157
|
+
// project's server must not keep running in the host, pushing into rule checks, once nothing asks for it.
|
|
158
|
+
for (const [cwd, entry] of [...this.entries]) {
|
|
159
|
+
if (!entry.watched || keep.has(cwd)) continue;
|
|
160
|
+
this.entries.delete(cwd);
|
|
161
|
+
void Promise.all(entry.sources.map((s) => s.stop())).catch(() => undefined);
|
|
162
|
+
}
|
|
163
|
+
for (const cwd of keep) {
|
|
164
|
+
const entry = this.entries.get(cwd) ?? this.connect(cwd);
|
|
165
|
+
entry.watched = true;
|
|
166
|
+
entry.lastUsed = Date.now();
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/** Whether a push from `cwd`'s servers should still be acted on: trust is checked again at arrival. */
|
|
171
|
+
accepts(cwd: string): boolean {
|
|
172
|
+
return this.opts.isTrusted(cwd);
|
|
173
|
+
}
|
|
174
|
+
|
|
125
175
|
/** Keep the pool bounded: one loop per project on a busy machine must not pin every project. */
|
|
126
176
|
private evictIfFull(): void {
|
|
127
177
|
while (this.entries.size >= MAX_POOLED_PROJECTS) {
|
|
128
|
-
|
|
178
|
+
// Lent-only connections go first: dropping a watched one only for `watch` to reconnect it a
|
|
179
|
+
// minute later churned every project's servers once the pool was full.
|
|
180
|
+
const oldest = [...this.entries.entries()].sort((a, b) => Number(!!a[1].watched) - Number(!!b[1].watched) || a[1].lastUsed - b[1].lastUsed)[0];
|
|
129
181
|
if (!oldest) return;
|
|
130
182
|
this.entries.delete(oldest[0]);
|
|
131
183
|
this.opts.log?.(`disconnected ${oldest[0]}'s MCP servers (pool limit ${MAX_POOLED_PROJECTS})`);
|
package/src/mcp.ts
CHANGED
|
@@ -828,7 +828,7 @@ export function loadMcpConfigFiles(opts: { dir: string; cwd?: string; projectTru
|
|
|
828
828
|
const projectFile = findProjectMcpConfig(opts.cwd);
|
|
829
829
|
if (projectFile) {
|
|
830
830
|
if (opts.projectTrusted) project = read(projectFile, "project");
|
|
831
|
-
else diagnostics.push(`project MCP config ignored at ${projectFile}: project is not trusted (pi
|
|
831
|
+
else diagnostics.push(`project MCP config ignored at ${projectFile}: project is not trusted (run /trust and restart pi, or start pi with --approve)`);
|
|
832
832
|
}
|
|
833
833
|
}
|
|
834
834
|
return { servers: mergeMcpConfigs(user, project), diagnostics };
|
package/src/open-file.ts
ADDED
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The file `pi-loops` opens the browser on. A one-shot open key in a URL on the opener's command
|
|
3
|
+
* line is readable by every user on the machine (`/proc/<pid>/cmdline`), and whoever loads it first
|
|
4
|
+
* gets the page with the token. So the key goes into a file only this user can read, the browser is
|
|
5
|
+
* opened on that file's path, and the file sends it on to the keyed address. The background process
|
|
6
|
+
* writes the file, because it is the one that sees the key spent: it removes the file then, or after
|
|
7
|
+
* a minute, whichever comes first, and removes any left over when it starts.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { randomBytes } from "node:crypto";
|
|
11
|
+
import * as fs from "node:fs";
|
|
12
|
+
import * as path from "node:path";
|
|
13
|
+
|
|
14
|
+
/** Where the files are: a directory of their own, mode 0700, under `pages/`. */
|
|
15
|
+
export function openFileDir(loopsDir: string): string {
|
|
16
|
+
return path.join(loopsDir, "pages", "open");
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/** The page that sends a browser on to `url`, with nothing else in it. */
|
|
20
|
+
export function openFileHtml(url: string): string {
|
|
21
|
+
// One navigation, never two: the key opens one load, so a refresh racing the script would meet
|
|
22
|
+
// a spent key. The refresh is for a browser that runs no script.
|
|
23
|
+
const attr = url.replace(/&/g, "&").replace(/"/g, """).replace(/</g, "<").replace(/>/g, ">");
|
|
24
|
+
// JSON.stringify leaves "</script>" alone; escaping "<" keeps the address from ending the script.
|
|
25
|
+
const js = JSON.stringify(url).replace(/</g, "\\u003c");
|
|
26
|
+
return `<!doctype html>
|
|
27
|
+
<html><head><meta charset="utf-8"><meta name="referrer" content="no-referrer">
|
|
28
|
+
<noscript><meta http-equiv="refresh" content="0;url=${attr}"></noscript>
|
|
29
|
+
<title>pi-loops</title></head>
|
|
30
|
+
<body><script>location.replace(${js})</script><a href="${attr}">Open pi-loops</a></body></html>
|
|
31
|
+
`;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** Write a file that sends the browser to `url`, mode 0600 in a 0700 directory, and answer its path. */
|
|
35
|
+
export function writeOpenFile(loopsDir: string, url: string): string {
|
|
36
|
+
const dir = openFileDir(loopsDir);
|
|
37
|
+
fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
|
|
38
|
+
// mkdir leaves an existing directory's mode as it was.
|
|
39
|
+
fs.chmodSync(dir, 0o700);
|
|
40
|
+
const file = path.join(dir, `${randomBytes(16).toString("hex")}.html`);
|
|
41
|
+
// `wx`: a name that exists is not ours to write into.
|
|
42
|
+
fs.writeFileSync(file, openFileHtml(url), { mode: 0o600, flag: "wx" });
|
|
43
|
+
return file;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** Remove one file; one already gone is what was wanted. */
|
|
47
|
+
export function removeOpenFile(file: string): void {
|
|
48
|
+
fs.rmSync(file, { force: true });
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** Remove every file a process before this one left: their keys died with it. */
|
|
52
|
+
export function sweepOpenFiles(loopsDir: string): void {
|
|
53
|
+
fs.rmSync(openFileDir(loopsDir), { recursive: true, force: true });
|
|
54
|
+
}
|