@alphacoder-v0/pi-loops 0.22.7 → 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 +681 -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 -41
- 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 +598 -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/host.ts
CHANGED
|
@@ -1,10 +1,12 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* The
|
|
3
|
-
* (`
|
|
4
|
-
*
|
|
5
|
-
* runner (pi's SDK) and its own MCP client instances.
|
|
6
|
-
*
|
|
7
|
-
*
|
|
2
|
+
* The always-on host — pi-loops with no pi window, one per loops directory. A pi window starts it
|
|
3
|
+
* when there is automation to run (`ensure` in src/pi-loops.ts) and it never yields to one: it runs
|
|
4
|
+
* every loop, checks every rule and evaluates MCP pushes, with the same stores, the same in-process
|
|
5
|
+
* sub-agent runner (pi's SDK) and its own MCP client instances. A rule's result goes to the window
|
|
6
|
+
* holding the conversation that created the rule, or to the inbox marked for it. After a reboot it
|
|
7
|
+
* starts again when a pi opens, or when a service manager the person set up runs
|
|
8
|
+
* `pi-loops start --foreground`. It serves the browser page (src/page-server.ts): one pi per open
|
|
9
|
+
* conversation (src/page-children.ts), and the Background view of what it is doing.
|
|
8
10
|
*
|
|
9
11
|
* node --import <pkg>/src/register-pi.mjs <pkg>/src/host-entry.mjs (env: PI_LOOPS_DIR, PI_LOOPS_PI_PACKAGE)
|
|
10
12
|
*/
|
|
@@ -12,38 +14,54 @@ import * as fs from "node:fs";
|
|
|
12
14
|
import * as os from "node:os";
|
|
13
15
|
import * as path from "node:path";
|
|
14
16
|
import { fileURLToPath } from "node:url";
|
|
15
|
-
import { ModelRuntime, ProjectTrustStore, SettingsManager, getAgentDir, readStoredCredential, type ToolDefinition } from "@earendil-works/pi-coding-agent";
|
|
17
|
+
import { ModelRuntime, ProjectTrustStore, SettingsManager, VERSION as PI_VERSION, getAgentDir, readStoredCredential, type ToolDefinition } from "@earendil-works/pi-coding-agent";
|
|
16
18
|
import type { Model } from "@earendil-works/pi-ai";
|
|
17
|
-
import { loadConfig } from "./config.ts";
|
|
19
|
+
import { loadConfig, pageBindPort } from "./config.ts";
|
|
18
20
|
import { computeNext, stamp } from "./schedule.ts";
|
|
19
|
-
import { hostSocketPath, serveHostChannel } from "./host-control-channel.ts";
|
|
20
|
-
import { HOST_LOG,
|
|
21
|
+
import { hostSocketPath, recentRun, requestQueues, serveHostChannel, stopRunFrom } from "./host-control-channel.ts";
|
|
22
|
+
import { HOST_LOG, claimHostService, crashPaused, crashPauseText, hostServiceNames, leavesWithWindows, openWindows, stopWaitsForPage, waitForPageTurns, readSpawnLog, recordCrash, removeHostService, updateHostService, type HostServiceRecord } from "./host-control.ts";
|
|
23
|
+
import { ChildPool, piCommand } from "./page-children.ts";
|
|
24
|
+
import { type HostStatus, lanAddresses, openForLaunch, type PageServer, ring as ringChild, sessionRows, startPageServer, unsentPersonRows, webToken } from "./page-server.ts";
|
|
25
|
+
import { piPackage } from "./pi-resolver.mjs";
|
|
26
|
+
import { RequestStore } from "./requests.ts";
|
|
27
|
+
import { awayFor, pruneSeen, readSeen, sinceCounts } from "./seen.ts";
|
|
28
|
+
import { LeaseStore, holderAlive, openWindowPids, procStart } from "./lease.ts";
|
|
29
|
+
import { sessionTitle, shortSessionId } from "./session-head.ts";
|
|
30
|
+
import { writeFileAtomic } from "./lock.ts";
|
|
21
31
|
import { THINKING_LEVELS, thinkingLevelOrUndefined } from "./thinking.ts";
|
|
22
|
-
import { withFileLock } from "./lock.ts";
|
|
23
32
|
import { rotateInPlace } from "./log.ts";
|
|
24
33
|
import { createHostRuntime } from "./host-runtime.ts";
|
|
25
34
|
import { McpPool } from "./mcp-pool.ts";
|
|
26
|
-
import { McpSource, droppedNotificationMessage, loadMcpConfigFiles, mapNotification, mcpToolDefinitions, mcpTokenFromEnv } from "./mcp.ts";
|
|
35
|
+
import { McpSource, droppedNotificationMessage, loadMcpConfigFiles, mapNotification, mcpToolDefinitions, mcpTokenFromEnv, type McpNotification, type McpServerConfig } from "./mcp.ts";
|
|
36
|
+
import { failingJobs } from "./job-health.ts";
|
|
37
|
+
import { loopLabel } from "./job-lines.ts";
|
|
27
38
|
import { previewRedacted, redact } from "./redact.ts";
|
|
28
|
-
import { parentRuntimeFlags } from "./runner.ts";
|
|
39
|
+
import { parentRuntimeFlags, type RunnerResult, type SubagentRunner } from "./runner.ts";
|
|
29
40
|
import type { SessionSnapshot } from "./scheduler.ts";
|
|
30
|
-
import {
|
|
31
|
-
import {
|
|
32
|
-
import {
|
|
41
|
+
import type { HostSnapshot, PageOpened, PageOpenRequest } from "./host-control-channel.ts";
|
|
42
|
+
import { createInProcessRunner, releaseHeldResources } from "./sdk-runner.ts";
|
|
43
|
+
import { defaultLoopsDir, homeLoopsDir, sessionExists } from "./store.ts";
|
|
44
|
+
import { sameProject } from "./paths.ts";
|
|
45
|
+
import { isExactlyTrusted, isTrustedByPi } from "./trust.ts";
|
|
33
46
|
import { PI_LOOPS_VERSION } from "./version.ts";
|
|
47
|
+
import { readUiPrefs, writeUiPref } from "./ui-prefs.ts";
|
|
34
48
|
|
|
35
49
|
const agentDir = getAgentDir();
|
|
36
50
|
const dir = process.env.PI_LOOPS_DIR || defaultLoopsDir(agentDir);
|
|
37
51
|
const PACKAGE_DIR = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
|
|
38
52
|
fs.mkdirSync(dir, { recursive: true });
|
|
39
53
|
const logFile = path.join(dir, HOST_LOG);
|
|
54
|
+
// Run with `pi-loops start --foreground`, a line also goes to the terminal, or the service manager's journal.
|
|
55
|
+
const logToStdout = process.env.PI_LOOPS_HOST_MANAGED === "foreground";
|
|
40
56
|
const log = (msg: string) => {
|
|
41
57
|
const line = `${stamp()} ${redact(msg)}\n`;
|
|
58
|
+
if (logToStdout) process.stdout.write(line);
|
|
42
59
|
try {
|
|
43
60
|
fs.appendFileSync(logFile, line);
|
|
44
|
-
// The
|
|
45
|
-
//
|
|
46
|
-
//
|
|
61
|
+
// The process's own stdout and stderr are this file too (host-control.ts): what an extension
|
|
62
|
+
// prints there arrives as written, not through this function's redaction. MCP servers' stderr
|
|
63
|
+
// does not (src/mcp.ts pipes it). It is the one file a user is told to read; keep it readable,
|
|
64
|
+
// by the same rule as every other log in this project, which lives in src/log.ts.
|
|
47
65
|
rotateInPlace(logFile);
|
|
48
66
|
} catch {
|
|
49
67
|
process.stderr.write(line);
|
|
@@ -67,8 +85,8 @@ function logConfigErrors(): void {
|
|
|
67
85
|
}
|
|
68
86
|
logConfigErrors();
|
|
69
87
|
|
|
70
|
-
// The session the host "is": no chat, no project; for unpinned work,
|
|
71
|
-
//
|
|
88
|
+
// The session the host "is": no chat, no project; for unpinned work, `PI_LOOPS_HOST_MODEL` and
|
|
89
|
+
// `PI_LOOPS_HOST_THINKING` when set, else the settings' defaults. Never "some available model".
|
|
72
90
|
const settings = SettingsManager.create(os.homedir(), agentDir, { projectTrusted: false });
|
|
73
91
|
const settingsModel = settings.getDefaultProvider() && settings.getDefaultModel() ? `${settings.getDefaultProvider()}/${settings.getDefaultModel()}` : undefined;
|
|
74
92
|
// The environment is a boundary: `PI_LOOPS_HOST_THINKING` is whatever the process that spawned this
|
|
@@ -88,8 +106,17 @@ let parentModel: Model<any> | undefined;
|
|
|
88
106
|
|
|
89
107
|
/* ---------------------------------------------------------------- MCP */
|
|
90
108
|
|
|
91
|
-
|
|
109
|
+
let mcpLoaded = loadMcpConfigFiles({ dir, projectTrusted: false });
|
|
92
110
|
for (const d of mcpLoaded.diagnostics) log(`mcp: ${d}`);
|
|
111
|
+
/** mcp.toml's modification time when it was last read; "" when there is none. */
|
|
112
|
+
const mcpConfigStamp = (): string => {
|
|
113
|
+
try {
|
|
114
|
+
return String(fs.statSync(path.join(dir, "mcp.toml")).mtimeMs);
|
|
115
|
+
} catch {
|
|
116
|
+
return ""; // no mcp.toml: no servers of the user's own
|
|
117
|
+
}
|
|
118
|
+
};
|
|
119
|
+
let mcpReadAt = mcpConfigStamp();
|
|
93
120
|
const mcpSources: McpSource[] = [];
|
|
94
121
|
const mcpToolDefs = new Map<string, ToolDefinition<any, any>[]>();
|
|
95
122
|
const mcpToolNames = new Map<string, string[]>();
|
|
@@ -108,7 +135,30 @@ const resolveMcpToken = (ref: string): string | undefined => {
|
|
|
108
135
|
|
|
109
136
|
/* ------------------------------------------------------------ runtime */
|
|
110
137
|
|
|
111
|
-
const
|
|
138
|
+
const leases = new LeaseStore(dir);
|
|
139
|
+
const requests = new RequestStore(dir);
|
|
140
|
+
/**
|
|
141
|
+
* An injecting push is the holder's to put in its chat; the host files it only when no window has
|
|
142
|
+
* that server. A project's server is that project's: a window elsewhere with a server of the same name
|
|
143
|
+
* does not count.
|
|
144
|
+
*/
|
|
145
|
+
const someWindowInjects = (server: string, cwd?: string): boolean => leases.list().some((l) => !!l.holder && l.holder.pushServers.includes(server) && holderAlive(l.holder) && (!cwd || (!!l.cwd && sameProject(l.cwd, cwd))));
|
|
146
|
+
|
|
147
|
+
/**
|
|
148
|
+
* A host started by a service manager has its environment, not a shell's: a provider key that is
|
|
149
|
+
* only exported in `.bashrc` is not there. When a run fails on authentication, the error says so, so
|
|
150
|
+
* the failure in `/cron` points at its cause instead of at the job.
|
|
151
|
+
*/
|
|
152
|
+
const AUTH_FAILURE = /\b(401|403|unauthori[sz]ed|authentication|api[ _-]?key|invalid[ _-]?key|no credentials|not logged in)\b/i;
|
|
153
|
+
function explainAuthFailure(result: RunnerResult): RunnerResult {
|
|
154
|
+
if (result.ok || !AUTH_FAILURE.test(result.errorMessage ?? "")) return result;
|
|
155
|
+
// Started by a window, it has that window's environment, for days: a key exported in another shell
|
|
156
|
+
// since, or in the window's shell after it started, never reached it.
|
|
157
|
+
const whose = managed === "foreground" ? "pi-loops was started with --foreground by a service manager, so keys exported only in your shell do not reach it" : `pi-loops in the background has the environment of ${process.env.PI_LOOPS_STARTED_BY || "the window that started it"}; pi-loops restart from a shell gives it that shell's`;
|
|
158
|
+
return { ...result, errorMessage: `${result.errorMessage} (${whose}; keys saved with pi's /login always reach it)` };
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
const inProcessRunner = createInProcessRunner({
|
|
112
162
|
agentDir,
|
|
113
163
|
parentFlags: parentRuntimeFlags([]),
|
|
114
164
|
getParentModel: () => parentModel,
|
|
@@ -123,57 +173,128 @@ const runner = createInProcessRunner({
|
|
|
123
173
|
// host owns the scheduler and is built with this runner, so it can only be asked at run time.
|
|
124
174
|
budget: () => host.scheduler.budgetState(),
|
|
125
175
|
log: (msg) => log(`sub-agent: ${msg}`),
|
|
176
|
+
notice: (text, cwd) => host.notice(text, cwd),
|
|
177
|
+
});
|
|
178
|
+
const runner: SubagentRunner = async (req) => explainAuthFailure(await inProcessRunner(req));
|
|
179
|
+
/**
|
|
180
|
+
* The browser page's pis, one per open conversation. Built before the claim: what it does at once is
|
|
181
|
+
* stop the children of a pi-loops that is gone, which a second process losing the claim may do too.
|
|
182
|
+
*/
|
|
183
|
+
const pool = new ChildPool({
|
|
184
|
+
dir,
|
|
185
|
+
command: () => piCommand(process.env, piPackage),
|
|
186
|
+
env: process.env,
|
|
187
|
+
version: PI_LOOPS_VERSION,
|
|
188
|
+
log,
|
|
189
|
+
ring: (child) => ringChild(child),
|
|
190
|
+
unsent: (child) => unsentPersonRows(requests, child),
|
|
191
|
+
onExit: (child, code, signal) => log(`page: conversation ${(child.sessionId ? shortSessionId(child.sessionId) : undefined) ?? `not yet named (pid ${child.pid})`} pi exited (${signal ?? code})`),
|
|
126
192
|
});
|
|
127
193
|
/** Project-level MCP servers, connected on demand: the host itself has no project. */
|
|
128
|
-
const mcpPool = new McpPool({ isTrusted: (cwd) => isExactlyTrusted(agentDir, cwd), resolveToken: resolveMcpToken, log: (msg) => log(`mcp pool: ${msg}`) });
|
|
194
|
+
const mcpPool = new McpPool({ isTrusted: (cwd) => isExactlyTrusted(agentDir, cwd), resolveToken: resolveMcpToken, log: (msg) => log(`mcp pool: ${msg}`), onNotification: (cwd, cfg, n, source) => onPush(cfg, n, source, cwd) });
|
|
129
195
|
const host = createHostRuntime({
|
|
130
196
|
dir,
|
|
131
197
|
config: () => config,
|
|
132
198
|
session: () => session,
|
|
133
199
|
runner,
|
|
134
200
|
mcpTools: () => [...mcpToolDefs.values()].flat(),
|
|
135
|
-
projectMcpTools: (cwd, taken) => mcpPool.toolsFor(cwd, taken),
|
|
201
|
+
projectMcpTools: (cwd, taken, trusted) => mcpPool.toolsFor(cwd, taken, trusted),
|
|
136
202
|
// Project-local hooks.toml: the same exact-trust rule as a project's MCP servers and tools.
|
|
137
203
|
isProjectTrusted: (cwd) => isExactlyTrusted(agentDir, cwd),
|
|
204
|
+
ownerTrusts: (cwd) => isTrustedByPi(agentDir, cwd),
|
|
138
205
|
log,
|
|
139
|
-
|
|
206
|
+
// A conversation a window holds is alive whatever the disk says (--session-dir, another root).
|
|
207
|
+
// Its file where a window last saw it counts too: `--session-dir` keeps conversations elsewhere.
|
|
208
|
+
sessionExists: (id) => {
|
|
209
|
+
const lease = leases.read(id);
|
|
210
|
+
if (lease?.holder && holderAlive(lease.holder)) return true;
|
|
211
|
+
if (lease?.sessionFile && fs.existsSync(lease.sessionFile)) return true;
|
|
212
|
+
return sessionExists(path.join(agentDir, "sessions"), id);
|
|
213
|
+
},
|
|
214
|
+
onTick: (now) => everyTick(now),
|
|
215
|
+
// Open in the page, its pi writes the file: a plain job's turn there would be a second writer.
|
|
216
|
+
pageHolds: (id) => pool.has(id),
|
|
140
217
|
});
|
|
141
218
|
|
|
219
|
+
/**
|
|
220
|
+
* One push, from a user-level server or a project's (then `cwd` is that project, and only its rules
|
|
221
|
+
* are evaluated). An injecting push is the holder's to put in its chat; the host files it only when no
|
|
222
|
+
* window has that server.
|
|
223
|
+
*/
|
|
224
|
+
function onPush(cfg: McpServerConfig, n: McpNotification, source: McpSource, cwd?: string): void {
|
|
225
|
+
const trigger = mapNotification(cfg.name, n);
|
|
226
|
+
if (!trigger) {
|
|
227
|
+
source.status.droppedCount++;
|
|
228
|
+
source.status.lastError = droppedNotificationMessage(n.method);
|
|
229
|
+
return;
|
|
230
|
+
}
|
|
231
|
+
if (cwd) trigger.cwd = cwd;
|
|
232
|
+
const delivery = cfg.injectAndRun ? "inject_and_run" : cfg.injectSummary ? "inject_summary" : "sub_agent";
|
|
233
|
+
if (delivery !== "sub_agent" && someWindowInjects(cfg.name, cwd)) return;
|
|
234
|
+
void host.triggers.handle(trigger, delivery).catch((err) => log(`trigger: ${err?.message ?? err}`));
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
/** Every minute: the trusted projects with enabled rules keep their push servers connected. */
|
|
238
|
+
function watchProjectPushServers(): void {
|
|
239
|
+
const cwds = [...new Set(host.triggers.store.load().filter((r) => r.enabled).map((r) => r.cwd))];
|
|
240
|
+
mcpPool.watch(cwds);
|
|
241
|
+
}
|
|
242
|
+
|
|
142
243
|
function startMcpSources(): void {
|
|
143
|
-
for (const cfg of mcpLoaded.servers)
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
return;
|
|
167
|
-
}
|
|
168
|
-
const delivery = cfg.injectAndRun ? "inject_and_run" : cfg.injectSummary ? "inject_summary" : "sub_agent";
|
|
169
|
-
void host.triggers.handle(trigger, delivery).catch((err) => log(`trigger: ${err?.message ?? err}`));
|
|
170
|
-
},
|
|
171
|
-
resolveToken: resolveMcpToken,
|
|
172
|
-
log: (msg) => log(`mcp ${cfg.name}: ${previewRedacted(msg, 200)}`),
|
|
173
|
-
});
|
|
174
|
-
mcpSources.push(source);
|
|
175
|
-
source.start();
|
|
244
|
+
for (const cfg of mcpLoaded.servers) startMcpSource(cfg);
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
/**
|
|
248
|
+
* This process lives for days, and mcp.toml is edited meanwhile: a window reads it at every start,
|
|
249
|
+
* and so must this, or a server added there never reaches a loop until a restart. Only the servers
|
|
250
|
+
* that changed are stopped or started.
|
|
251
|
+
*/
|
|
252
|
+
function reloadMcpIfChanged(): void {
|
|
253
|
+
const at = mcpConfigStamp();
|
|
254
|
+
if (at === mcpReadAt) return;
|
|
255
|
+
mcpReadAt = at;
|
|
256
|
+
const next = loadMcpConfigFiles({ dir, projectTrusted: false });
|
|
257
|
+
for (const d of next.diagnostics) log(`mcp: ${d}`);
|
|
258
|
+
const same = (a: McpServerConfig, b: McpServerConfig) => JSON.stringify(a) === JSON.stringify(b);
|
|
259
|
+
let stopped = 0;
|
|
260
|
+
for (const source of [...mcpSources]) {
|
|
261
|
+
if (next.servers.some((cfg) => same(cfg, source.config))) continue;
|
|
262
|
+
mcpSources.splice(mcpSources.indexOf(source), 1);
|
|
263
|
+
mcpToolDefs.delete(source.config.name);
|
|
264
|
+
mcpToolNames.delete(source.config.name);
|
|
265
|
+
source.stop().catch((err: any) => log(`mcp ${source.config.name}: stop failed: ${err?.message ?? err}`));
|
|
266
|
+
stopped++;
|
|
176
267
|
}
|
|
268
|
+
const fresh = next.servers.filter((cfg) => !mcpSources.some((source) => same(cfg, source.config)));
|
|
269
|
+
mcpLoaded = next;
|
|
270
|
+
for (const cfg of fresh) startMcpSource(cfg);
|
|
271
|
+
log(`mcp.toml changed: ${fresh.length} server(s) started, ${stopped} stopped`);
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
function startMcpSource(cfg: McpServerConfig): void {
|
|
275
|
+
const source = new McpSource(cfg, {
|
|
276
|
+
onConnected: async (src) => {
|
|
277
|
+
let tools;
|
|
278
|
+
try {
|
|
279
|
+
tools = await src.listTools();
|
|
280
|
+
} catch (err: any) {
|
|
281
|
+
src.status.lastError = `tools/list failed: ${err?.message ?? err}`;
|
|
282
|
+
return;
|
|
283
|
+
}
|
|
284
|
+
const already = mcpToolNames.get(cfg.name) ?? [];
|
|
285
|
+
const defs = mcpToolDefs.get(cfg.name) ?? [];
|
|
286
|
+
const taken = new Set([...mcpToolDefs.values()].flat().map((d) => d.name));
|
|
287
|
+
for (const { def } of mcpToolDefinitions(src, tools, taken, already)) defs.push(def);
|
|
288
|
+
mcpToolNames.set(cfg.name, already);
|
|
289
|
+
mcpToolDefs.set(cfg.name, defs);
|
|
290
|
+
log(`mcp ${cfg.name}: connected, ${already.length} tool(s)`);
|
|
291
|
+
},
|
|
292
|
+
onNotification: (n) => onPush(cfg, n, source),
|
|
293
|
+
resolveToken: resolveMcpToken,
|
|
294
|
+
log: (msg) => log(`mcp ${cfg.name}: ${previewRedacted(msg, 200)}`),
|
|
295
|
+
});
|
|
296
|
+
mcpSources.push(source);
|
|
297
|
+
source.start();
|
|
177
298
|
}
|
|
178
299
|
|
|
179
300
|
/* ------------------------------------------------------------ lifecycle */
|
|
@@ -183,23 +304,47 @@ async function shutdown(code: number): Promise<void> {
|
|
|
183
304
|
if (stopping) return;
|
|
184
305
|
stopping = true;
|
|
185
306
|
// Whatever hangs (a lock, an MCP server that will not die), the process leaves.
|
|
186
|
-
setTimeout(() =>
|
|
307
|
+
setTimeout(() => {
|
|
308
|
+
// A clean stop that ran out of time is still a clean stop: its record goes, so the next window
|
|
309
|
+
// does not count it as a crash.
|
|
310
|
+
if (code === 0) {
|
|
311
|
+
try {
|
|
312
|
+
removeHostService(dir, process.pid);
|
|
313
|
+
} catch (err: any) {
|
|
314
|
+
log(`could not remove the pi-loops record: ${err?.message ?? err}`);
|
|
315
|
+
}
|
|
316
|
+
}
|
|
317
|
+
process.exit(code);
|
|
318
|
+
}, 10_000).unref();
|
|
187
319
|
try {
|
|
320
|
+
// The page's pis first: each is a conversation someone has open, and it quits as /quit does.
|
|
321
|
+
await pool.stopAll();
|
|
188
322
|
await host.stop();
|
|
323
|
+
// Held turns' extensions get their session_shutdown before the process goes.
|
|
324
|
+
await Promise.race([releaseHeldResources({ idleMs: 0, keep: 0 }), new Promise((r) => setTimeout(r, 3000).unref())]);
|
|
189
325
|
await mcpPool.stopAll();
|
|
190
326
|
await Promise.all(mcpSources.map((s) => s.stop()));
|
|
191
327
|
} catch (err: any) {
|
|
192
328
|
log(`shutdown: ${err?.message ?? err}`);
|
|
193
329
|
}
|
|
194
330
|
try {
|
|
331
|
+
page?.close();
|
|
195
332
|
channel?.close();
|
|
196
333
|
fs.rmSync(hostSocketPath(dir), { force: true });
|
|
197
334
|
} catch {
|
|
198
335
|
/* going away anyway */
|
|
199
336
|
}
|
|
200
|
-
// A clean exit removes the record; a crash leaves it so the next pi can say the host died.
|
|
201
|
-
|
|
202
|
-
|
|
337
|
+
// A clean exit removes the record; a crash leaves it so the next pi can say the host died. Only a
|
|
338
|
+
// record that still names this process: a host that lost its claim must not remove the winner's.
|
|
339
|
+
if (code === 0) {
|
|
340
|
+
try {
|
|
341
|
+
removeHostService(dir, process.pid);
|
|
342
|
+
removeLegacyFence();
|
|
343
|
+
} catch (err: any) {
|
|
344
|
+
log(`could not remove the pi-loops record: ${err?.message ?? err}`);
|
|
345
|
+
}
|
|
346
|
+
}
|
|
347
|
+
log(`pi-loops stopped (exit ${code})`);
|
|
203
348
|
process.exit(code);
|
|
204
349
|
}
|
|
205
350
|
// `shutdown` stops stores, MCP servers and the scheduler; any of them can reject, and the process
|
|
@@ -210,28 +355,165 @@ const leave = (code: number) => void shutdown(code).catch((err: any) => {
|
|
|
210
355
|
});
|
|
211
356
|
process.on("SIGTERM", () => leave(0));
|
|
212
357
|
process.on("SIGINT", () => leave(0));
|
|
358
|
+
// The terminal `pi-loops start --foreground` runs in was closed: a stop like any other, not a crash.
|
|
359
|
+
process.on("SIGHUP", () => leave(0));
|
|
360
|
+
// No catch-and-continue inside the process: its state after an uncaught error cannot be trusted.
|
|
361
|
+
// The log lands first (the error, its stack and what was in flight), then the crash is counted
|
|
362
|
+
// (three within five minutes pause the host instead of a restart loop) and the record cleared, and
|
|
363
|
+
// the process exits for whatever started it to judge.
|
|
213
364
|
process.on("uncaughtException", (err) => {
|
|
214
|
-
|
|
365
|
+
// Already leaving: an error thrown on the way out is logged, not counted as another crash.
|
|
366
|
+
if (stopping) {
|
|
367
|
+
log(`uncaught while stopping: ${redact(String(err?.stack ?? err))}`);
|
|
368
|
+
return;
|
|
369
|
+
}
|
|
370
|
+
const inFlight = [...host.scheduler.runningRuns().map((r) => `${r.label} (${r.runId.slice(0, 12)})`), ...host.triggers.runningList().map((c) => `check ${c.traceId.slice(0, 8)}`)];
|
|
371
|
+
log(`uncaught: ${redact(String(err?.stack ?? err))}${inFlight.length ? `\n in flight: ${inFlight.join(", ")}` : ""}`);
|
|
372
|
+
try {
|
|
373
|
+
recordCrash(dir, `pi-loops crashed: ${previewRedacted(String(err?.message ?? err), 160)}; see ${HOST_LOG}`);
|
|
374
|
+
removeHostService(dir, process.pid);
|
|
375
|
+
} catch (e: any) {
|
|
376
|
+
log(`could not record the crash: ${e?.message ?? e}`);
|
|
377
|
+
}
|
|
215
378
|
leave(1);
|
|
216
379
|
});
|
|
217
380
|
process.on("unhandledRejection", (err: any) => log(`unhandled rejection: ${redact(String(err?.stack ?? err))}`));
|
|
218
381
|
|
|
219
|
-
//
|
|
220
|
-
|
|
221
|
-
// crash
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
382
|
+
// Run by `pi-loops start --foreground` (a service manager the person set up), or spawned by a pi window.
|
|
383
|
+
const managed: HostServiceRecord["managed"] = process.env.PI_LOOPS_HOST_MANAGED === "foreground" ? "foreground" : "spawned";
|
|
384
|
+
// After the crash budget paused it, a service manager would restart it at once after each failure:
|
|
385
|
+
// it waits the pause out instead, as a window does, and goes on early when /pi-loops start ends it.
|
|
386
|
+
// A stop while it waits is a clean stop (the signal handlers above).
|
|
387
|
+
if (managed === "foreground" && crashPaused(readSpawnLog(dir), Date.now())) {
|
|
388
|
+
log(`paused: ${crashPauseText(readSpawnLog(dir))}`);
|
|
389
|
+
await new Promise<void>((resolve) => {
|
|
390
|
+
const wait = setInterval(() => {
|
|
391
|
+
if (crashPaused(readSpawnLog(dir), Date.now())) return;
|
|
392
|
+
clearInterval(wait);
|
|
393
|
+
resolve();
|
|
394
|
+
}, 15_000);
|
|
395
|
+
});
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
// One host per loops directory: two windows opening together may both spawn one, and the second
|
|
399
|
+
// to get here leaves before it opens the socket or any MCP connection. The claim, not the socket,
|
|
400
|
+
// is the single-instance argument (src/host-control.ts).
|
|
401
|
+
const startedAt = stamp();
|
|
402
|
+
const claimToken = webToken(dir, (message) => {
|
|
403
|
+
log(message);
|
|
404
|
+
process.exit(1);
|
|
227
405
|
});
|
|
228
|
-
|
|
229
|
-
|
|
406
|
+
const claim = claimHostService(dir, {
|
|
407
|
+
v: 1,
|
|
408
|
+
pid: process.pid,
|
|
409
|
+
procStart: procStart(process.pid),
|
|
410
|
+
startedAt,
|
|
411
|
+
version: PI_LOOPS_VERSION,
|
|
412
|
+
piVersion: PI_VERSION,
|
|
413
|
+
entry: process.argv[1] ?? "",
|
|
414
|
+
node: process.execPath,
|
|
415
|
+
dir,
|
|
416
|
+
socket: hostSocketPath(dir),
|
|
417
|
+
token: claimToken,
|
|
418
|
+
managed,
|
|
419
|
+
});
|
|
420
|
+
if (!claim.claimed) {
|
|
421
|
+
log(`pi-loops is already running (pid ${claim.other.pid}); exiting`);
|
|
230
422
|
process.exit(0);
|
|
231
423
|
}
|
|
232
|
-
|
|
233
|
-
//
|
|
234
|
-
|
|
424
|
+
// A `pi-loops stop` that landed while a window was starting this process: every start by a person
|
|
425
|
+
// clears the mark first, so a mark here is the stop, said after the window looked.
|
|
426
|
+
if (readUiPrefs(dir).host === "stopped") {
|
|
427
|
+
log("pi-loops was stopped by hand while it started; leaving");
|
|
428
|
+
removeHostService(dir, process.pid);
|
|
429
|
+
process.exit(0);
|
|
430
|
+
}
|
|
431
|
+
log(`pi-loops ${PI_LOOPS_VERSION} started (pid ${process.pid}, dir ${dir})`);
|
|
432
|
+
// The status page's token, from before the page moved here: `web-token` is the one token now.
|
|
433
|
+
try {
|
|
434
|
+
fs.unlinkSync(path.join(dir, "host-page-token"));
|
|
435
|
+
log("removed host-page-token: the page uses web-token now");
|
|
436
|
+
} catch (err: any) {
|
|
437
|
+
if (err?.code !== "ENOENT") log(`host-page-token: ${err?.message ?? err}`);
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
/**
|
|
441
|
+
* The fence that keeps a pi-loops 0.22 window from running loops beside this host during an
|
|
442
|
+
* upgrade. Such a window elects a leader through `scheduler.json`, preempts only a record of kind
|
|
443
|
+
* "host", and stands by while a fresh record with a live pid names someone else; so this writes one
|
|
444
|
+
* every tick. Removed once 0.22 windows are gone (release 6 of the design).
|
|
445
|
+
*/
|
|
446
|
+
const LEGACY_FENCE = path.join(dir, "scheduler.json");
|
|
447
|
+
function writeLegacyFence(now: number): void {
|
|
448
|
+
writeFileAtomic(LEGACY_FENCE, `${JSON.stringify({ pid: process.pid, host: os.hostname(), instance: "service", kind: "service", startedAt, heartbeatAt: stamp(now) }, null, 2)}\n`);
|
|
449
|
+
}
|
|
450
|
+
function removeLegacyFence(): void {
|
|
451
|
+
try {
|
|
452
|
+
const rec = JSON.parse(fs.readFileSync(LEGACY_FENCE, "utf8"));
|
|
453
|
+
if (rec?.pid === process.pid) fs.rmSync(LEGACY_FENCE, { force: true });
|
|
454
|
+
} catch {
|
|
455
|
+
/* no fence, or not ours to remove */
|
|
456
|
+
}
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
/** Every tick: am I still the host the record names (a stale lock break can let two claim), and the fence. */
|
|
460
|
+
/**
|
|
461
|
+
* Every ten minutes: forget conversations nothing refers to any more. A lease is written for every
|
|
462
|
+
* conversation a window opens; one that nobody holds and that has no plain job, rule or queued request
|
|
463
|
+
* is deleted (a window opening it again writes it again), and so is its since-you-looked cursor.
|
|
464
|
+
*/
|
|
465
|
+
let sweptAt = 0;
|
|
466
|
+
function sweepConversations(now: number): void {
|
|
467
|
+
if (now - sweptAt < 10 * 60_000) return;
|
|
468
|
+
sweptAt = now;
|
|
469
|
+
// A loop's runs count for the conversation that created it (since-you-looked), so that one stays too.
|
|
470
|
+
const working = new Set<string>([...host.scheduler.store.load().flatMap((j) => [j.sessionId, j.createdBy?.sessionId].filter((id): id is string => !!id)), ...host.triggers.store.load().flatMap((r) => (r.createdBy?.sessionId ? [r.createdBy.sessionId] : [])), ...requests.sessions()]);
|
|
471
|
+
// The page's last conversation keeps its lease, which names its project: `GET /` opens a new
|
|
472
|
+
// conversation there when that one was never saved (src/page-server.ts rootPage).
|
|
473
|
+
const lastPage = readUiPrefs(dir).lastPage;
|
|
474
|
+
if (lastPage) working.add(lastPage);
|
|
475
|
+
let removed = 0;
|
|
476
|
+
for (const lease of leases.list()) if (!working.has(lease.sessionId) && leases.removeIfFree(lease.sessionId)) removed++;
|
|
477
|
+
const pruned = pruneSeen(dir, (id) => working.has(id) || !!leases.read(id));
|
|
478
|
+
if (removed || pruned) log(`swept ${removed} idle conversation lease(s) and ${pruned} since-you-looked cursor(s)`);
|
|
479
|
+
}
|
|
480
|
+
|
|
481
|
+
function everyTick(now: number): void {
|
|
482
|
+
if (!hostServiceNames(dir, process.pid, startedAt)) {
|
|
483
|
+
log("host-service.json no longer names this process; leaving");
|
|
484
|
+
leave(0);
|
|
485
|
+
return;
|
|
486
|
+
}
|
|
487
|
+
// `[host] auto = false`: this process lives while a pi window does, as 0.22.8 ran loops only in
|
|
488
|
+
// an open window (src/host-control.ts leavesWithWindows). The next window starts it again. The
|
|
489
|
+
// page's pis count with their tabs: one still starting, or one a terminal took the conversation
|
|
490
|
+
// from, holds no lease.
|
|
491
|
+
if (leavesWithWindows({ auto: config.hostAuto, managed, windows: openWindows({ terminalPids: openWindowPids(leases), pageChildPids: pool.list().map((c) => c.pid), tabs: page?.clients() ?? 0 }), upForMs: now - Date.parse(startedAt), asked: process.env.PI_LOOPS_HOST_ASKED === "1" || readUiPrefs(dir).host === "started" })) {
|
|
492
|
+
log("[host] auto = false and no pi window is open; leaving (the next window starts pi-loops again)");
|
|
493
|
+
leave(0);
|
|
494
|
+
return;
|
|
495
|
+
}
|
|
496
|
+
try {
|
|
497
|
+
writeLegacyFence(now);
|
|
498
|
+
} catch (err: any) {
|
|
499
|
+
log(`legacy fence: ${err?.message ?? err}`);
|
|
500
|
+
}
|
|
501
|
+
try {
|
|
502
|
+
sweepConversations(now);
|
|
503
|
+
} catch (err: any) {
|
|
504
|
+
log(`conversation sweep: ${err?.message ?? err}`);
|
|
505
|
+
}
|
|
506
|
+
pool.sweep(now).catch((err: any) => log(`page: idle sweep: ${err?.message ?? err}`));
|
|
507
|
+
if (!page && pageWaiting?.reason === "older-page" && !pageBinding) bindPageAndSay().catch((err: any) => log(`page: ${err?.message ?? err}`));
|
|
508
|
+
// The heartbeat windows read before they evict: a socket that does not answer while this is fresh
|
|
509
|
+
// is a broken control channel, not a wedged host (src/host-control.ts decideEviction).
|
|
510
|
+
try {
|
|
511
|
+
updateHostService(dir, process.pid, { tickAt: stamp(now) });
|
|
512
|
+
} catch (err: any) {
|
|
513
|
+
log(`heartbeat: ${err?.message ?? err}`);
|
|
514
|
+
}
|
|
515
|
+
}
|
|
516
|
+
|
|
235
517
|
/**
|
|
236
518
|
* A control channel that cannot be opened is a degraded host, not a dead one: `pi-loops host
|
|
237
519
|
* status` falls back to the recorded pid, and the loops keep running. This is the top level of the
|
|
@@ -246,61 +528,299 @@ function serveHostChannelSafely(...args: Parameters<typeof serveHostChannel>): R
|
|
|
246
528
|
}
|
|
247
529
|
}
|
|
248
530
|
|
|
531
|
+
/** The rules, or none when triggers.json cannot be read: a status must not fail with the file it reports on. */
|
|
532
|
+
function rulesOrNone(): ReturnType<typeof host.triggers.store.load> {
|
|
533
|
+
try {
|
|
534
|
+
return host.triggers.store.load();
|
|
535
|
+
} catch (err: any) {
|
|
536
|
+
log(`rules: ${err?.message ?? err}`);
|
|
537
|
+
return [];
|
|
538
|
+
}
|
|
539
|
+
}
|
|
540
|
+
|
|
541
|
+
function snapshot(): HostSnapshot {
|
|
542
|
+
// One read of each store for the whole snapshot. jobs.json was read four times and
|
|
543
|
+
// triggers.json twice, so a tick landing in between could have `enabled` counted against
|
|
544
|
+
// one version of the file and `total` against another — a status line contradicting itself.
|
|
545
|
+
const jobs = host.scheduler.store.load();
|
|
546
|
+
const rules = rulesOrNone();
|
|
547
|
+
return {
|
|
548
|
+
pid: process.pid,
|
|
549
|
+
host: os.hostname(),
|
|
550
|
+
startedAt,
|
|
551
|
+
startedBy: process.env.PI_LOOPS_STARTED_BY || undefined,
|
|
552
|
+
model: session.model,
|
|
553
|
+
version: PI_LOOPS_VERSION,
|
|
554
|
+
sessions: leases.list().flatMap((l) => {
|
|
555
|
+
if (!l.holder || !holderAlive(l.holder)) return [];
|
|
556
|
+
const pid = l.holder.pid;
|
|
557
|
+
const child = pool.list().find((c) => c.pid === pid);
|
|
558
|
+
const title = l.sessionFile ? sessionTitle(l.sessionFile) : undefined;
|
|
559
|
+
return [{ sessionId: l.sessionId, pid, mode: l.holder.mode, ...(child ? { page: { tabs: child.clients.size, idleMs: Date.now() - child.lastClientAt } } : {}), ...(title ? { title } : {}) }];
|
|
560
|
+
}),
|
|
561
|
+
runs: host.scheduler.runningRuns(),
|
|
562
|
+
checks: host.triggers.runningList().map((r) => ({ traceId: r.traceId, sourceLabel: r.sourceLabel, eventLabel: r.eventLabel, startedAt: r.startedAt, cwd: r.cwd })),
|
|
563
|
+
jobs: { enabled: jobs.filter((j) => j.enabled).length, total: jobs.length },
|
|
564
|
+
rules: { enabled: rules.filter((r) => r.enabled).length, total: rules.length },
|
|
565
|
+
inboxNew: host.scheduler.inbox.newCount(),
|
|
566
|
+
// Without these a host that has failed every run for six hours reads exactly like one
|
|
567
|
+
// that succeeded an hour ago: "nothing running right now".
|
|
568
|
+
recent: host.scheduler.store
|
|
569
|
+
.listRuns(undefined, 5)
|
|
570
|
+
.reverse()
|
|
571
|
+
.map(recentRun),
|
|
572
|
+
// The three-in-a-row rule `/cron` and the badge use: one missed run is not a failing loop.
|
|
573
|
+
failing: failingJobs(jobs).map((j) => ({ job: loopLabel(j), error: previewRedacted(j.lastError ?? "", 120) })),
|
|
574
|
+
nextDue: (() => {
|
|
575
|
+
const next = jobs
|
|
576
|
+
.filter((j) => j.enabled && j.stateful)
|
|
577
|
+
.map((j) => computeNext({ schedule: j.schedule, createdAt: Date.parse(j.createdAt), lastFiredAt: j.lastFiredAt ? Date.parse(j.lastFiredAt) : undefined }, Date.now()))
|
|
578
|
+
.filter((n): n is number => n !== undefined)
|
|
579
|
+
.sort((a, b) => a - b)[0];
|
|
580
|
+
return next ? stamp(next) : undefined;
|
|
581
|
+
})(),
|
|
582
|
+
budget: (() => {
|
|
583
|
+
const b = host.scheduler.budgetState();
|
|
584
|
+
return b.cap > 0 ? { spent: b.spent, cap: b.cap } : undefined;
|
|
585
|
+
})(),
|
|
586
|
+
mcp: mcpSources.map((s) => ({ name: s.config.name, state: s.status.state, lastError: s.status.lastError ? previewRedacted(s.status.lastError, 120) : undefined })),
|
|
587
|
+
page: pageUrl,
|
|
588
|
+
pageOpen: pool.list().length,
|
|
589
|
+
pageWaiting: pageWaiting && pageWaitingLine(pageWaiting.reason),
|
|
590
|
+
lastPolls: [...host.triggers.lastPolls.values()],
|
|
591
|
+
notices: host.notices(),
|
|
592
|
+
requests: requestQueues(requests, leases),
|
|
593
|
+
};
|
|
594
|
+
}
|
|
595
|
+
|
|
596
|
+
|
|
597
|
+
/**
|
|
598
|
+
* What the Background view shows: the snapshot, and every conversation that has a lease, a plain job, a
|
|
599
|
+
* rule or a queued request, with who holds it. Built per request; the page refreshes every 15 s.
|
|
600
|
+
*/
|
|
601
|
+
function hostStatus(): HostStatus {
|
|
602
|
+
const allJobs = host.scheduler.store.load();
|
|
603
|
+
const jobs = allJobs.filter((j) => j.enabled && !j.stateful && j.sessionId);
|
|
604
|
+
const rules = rulesOrNone().filter((r) => r.enabled && r.createdBy?.sessionId);
|
|
605
|
+
// Only conversations someone holds or that have work: one lease per conversation ever opened
|
|
606
|
+
// would otherwise make the page a list of every closed chat, each reading "nobody".
|
|
607
|
+
const working = new Set([...jobs.map((j) => j.sessionId!), ...rules.map((r) => r.createdBy!.sessionId!), ...requests.sessions()]);
|
|
608
|
+
const leaseList = leases.list().filter((l) => working.has(l.sessionId) || (l.holder && holderAlive(l.holder)));
|
|
609
|
+
const legacy = host.scheduler.presenceList().flatMap((e) => (e.sessionId && !e.leases && e.kind !== "host" ? [{ sessionId: e.sessionId, pid: e.pid }] : []));
|
|
610
|
+
const ids = new Set([...leaseList.map((l) => l.sessionId), ...jobs.map((j) => j.sessionId!), ...rules.map((r) => r.createdBy!.sessionId!), ...legacy.map((l) => l.sessionId)]);
|
|
611
|
+
const queued = new Map<string, number>();
|
|
612
|
+
for (const id of ids) {
|
|
613
|
+
const n = requests.list(id).length;
|
|
614
|
+
if (n) queued.set(id, n);
|
|
615
|
+
}
|
|
616
|
+
const seen = readSeen(dir);
|
|
617
|
+
const runs = host.scheduler.store.allRuns();
|
|
618
|
+
const findings = host.scheduler.inbox.list();
|
|
619
|
+
const cwdOf = new Map(leaseList.flatMap((l) => (l.cwd ? [[l.sessionId, l.cwd] as const] : [])));
|
|
620
|
+
const now = Date.now();
|
|
621
|
+
// Each conversation's project, counted as its away card and session-start line count it.
|
|
622
|
+
const zero = { runs: 0, failed: 0, findings: 0, mine: 0 };
|
|
623
|
+
return {
|
|
624
|
+
snapshot: snapshot(),
|
|
625
|
+
managed,
|
|
626
|
+
sessions: sessionRows({
|
|
627
|
+
leases: leaseList,
|
|
628
|
+
plainJobSessions: jobs.map((j) => j.sessionId!),
|
|
629
|
+
ruleSessions: rules.map((r) => r.createdBy!.sessionId!),
|
|
630
|
+
mailbox: queued,
|
|
631
|
+
legacy,
|
|
632
|
+
alive: (h) => holderAlive(h),
|
|
633
|
+
since: (id) => {
|
|
634
|
+
const cwd = cwdOf.get(id);
|
|
635
|
+
return cwd ? sinceCounts(awayFor({ seen, runs, findings, cwd, sessionId: id, now, inProject: sameProject })) : zero;
|
|
636
|
+
},
|
|
637
|
+
title: sessionTitle,
|
|
638
|
+
jobName: (id) => allJobs.find((j) => j.id === id)?.name,
|
|
639
|
+
}),
|
|
640
|
+
};
|
|
641
|
+
}
|
|
642
|
+
|
|
643
|
+
/** The control channel's `abort`, and Stop it on the page's waiting page. The status lines show shortened ids, so that is what a watcher types back. */
|
|
644
|
+
function abortRun(runId: string): boolean {
|
|
645
|
+
const match = host.scheduler.runningRuns().find((r) => r.runId === runId || r.runId.startsWith(runId));
|
|
646
|
+
return match ? host.scheduler.abortRun(match.runId) : false;
|
|
647
|
+
}
|
|
648
|
+
|
|
649
|
+
/** What src/host-control-channel.ts stopRunFrom needs of this process. */
|
|
650
|
+
const stopHost = {
|
|
651
|
+
scheduler: { runningRuns: () => host.scheduler.runningRuns(), abortRun: (id: string, from: "page" | "cli") => host.scheduler.abortRun(id, from), noteStopFrom: (id: string, from: "page" | "cli") => host.scheduler.noteStopFrom(id, from) },
|
|
652
|
+
heldTurn: (sessionId: string) => host.heldTurn(sessionId),
|
|
653
|
+
ring: (sessionId: string) => host.ring(sessionId),
|
|
654
|
+
leases: () => leases.list(),
|
|
655
|
+
append: (row: Parameters<typeof requests.append>[0]) => void requests.append(row),
|
|
656
|
+
createdBy: () => ({ pid: process.pid, instance: "host", procStart: procStart(process.pid), version: PI_LOOPS_VERSION }),
|
|
657
|
+
};
|
|
658
|
+
|
|
659
|
+
/* ------------------------------------------------------------ the page */
|
|
660
|
+
|
|
661
|
+
/** The browser page, on the port `pageBindPort` picks. A taken port costs the page, never the loops. */
|
|
662
|
+
const pageBind = {
|
|
663
|
+
host: config.pageHost,
|
|
664
|
+
port: pageBindPort(config, { defaultDir: path.resolve(dir) === path.resolve(homeLoopsDir(agentDir)), env: process.env.PI_LOOPS_PAGE_PORT }),
|
|
665
|
+
};
|
|
666
|
+
const pageWhere = `${pageBind.host}:${pageBind.port}`;
|
|
667
|
+
const pageOnLoopback = ["127.0.0.1", "::1", "localhost"].includes(pageBind.host);
|
|
668
|
+
let page: PageServer | undefined;
|
|
669
|
+
let pageUrl: string | undefined;
|
|
670
|
+
/** Why the page is not served yet. An older page's port is tried again every tick; another program's only after a restart. */
|
|
671
|
+
let pageWaiting: { reason: "older-page" | "other-program"; since: string } | undefined;
|
|
672
|
+
/** The bind under way, if one is: the first at start, then one per tick while an older page holds the port. */
|
|
673
|
+
let pageBinding: Promise<void> | undefined;
|
|
674
|
+
|
|
675
|
+
function pageWaitingLine(reason: "older-page" | "other-program"): string {
|
|
676
|
+
return reason === "older-page" ? `waiting for ${pageWhere} (an older pi-loops page holds it)` : `not served: ${pageWhere} is used by another program (set [page] port in config.toml, then pi-loops restart)`;
|
|
677
|
+
}
|
|
678
|
+
|
|
679
|
+
/** Bind the page once: the server, or why not. `startPageServer` settles only on a bind, so a failed one is answered here. */
|
|
680
|
+
function bindPage(): Promise<PageServer | NodeJS.ErrnoException> {
|
|
681
|
+
return new Promise((resolve) => {
|
|
682
|
+
let bound = false;
|
|
683
|
+
startPageServer({
|
|
684
|
+
dir,
|
|
685
|
+
bind: pageBind,
|
|
686
|
+
allowHosts: config.pageAllowHosts,
|
|
687
|
+
token: claimToken,
|
|
688
|
+
version: PI_LOOPS_VERSION,
|
|
689
|
+
pool,
|
|
690
|
+
sessions: { known: (id) => leases.read(id) ?? undefined },
|
|
691
|
+
sessionsRoot: path.join(agentDir, "sessions"),
|
|
692
|
+
heldTurn: (id) => host.heldTurn(id),
|
|
693
|
+
abortRun,
|
|
694
|
+
stopRun: stopRunFrom("person", false, stopHost),
|
|
695
|
+
ruleLook: (id) => host.triggers.ruleLook(id),
|
|
696
|
+
lastPage: () => readUiPrefs(dir).lastPage,
|
|
697
|
+
rememberPage: (id) => writeUiPref(dir, "lastPage", id),
|
|
698
|
+
background: hostStatus,
|
|
699
|
+
budget: () => host.scheduler.budgetState(),
|
|
700
|
+
log,
|
|
701
|
+
onListenError: (err) => {
|
|
702
|
+
if (bound) log(`page: ${err?.message ?? err}`);
|
|
703
|
+
else resolve(err);
|
|
704
|
+
},
|
|
705
|
+
}).then(
|
|
706
|
+
(server) => {
|
|
707
|
+
bound = true;
|
|
708
|
+
resolve(server);
|
|
709
|
+
},
|
|
710
|
+
(err: any) => resolve(err instanceof Error ? err : new Error(String(err))),
|
|
711
|
+
);
|
|
712
|
+
});
|
|
713
|
+
}
|
|
714
|
+
|
|
715
|
+
/**
|
|
716
|
+
* Whatever holds the port answers a plain GET. An older pi-loops page says `x-pi-loops-web: 1` and
|
|
717
|
+
* leaves when its terminal closes, so this waits for it; anything else is another program's port.
|
|
718
|
+
* No credential is sent to find out.
|
|
719
|
+
*/
|
|
720
|
+
async function whoHoldsThePort(): Promise<"older-page" | "other-program"> {
|
|
721
|
+
const probe = pageBind.host === "0.0.0.0" || pageBind.host === "::" ? "127.0.0.1" : pageBind.host;
|
|
722
|
+
try {
|
|
723
|
+
const answer = await fetch(`http://${probe.includes(":") ? `[${probe}]` : probe}:${pageBind.port}/`, { signal: AbortSignal.timeout(2000), redirect: "manual" });
|
|
724
|
+
return answer.headers.get("x-pi-loops-web") === "1" ? "older-page" : "other-program";
|
|
725
|
+
} catch {
|
|
726
|
+
return "other-program"; // it took the port and answers no HTTP: not a page of ours
|
|
727
|
+
}
|
|
728
|
+
}
|
|
729
|
+
|
|
730
|
+
async function bindPageOnce(): Promise<void> {
|
|
731
|
+
const got = await bindPage();
|
|
732
|
+
if (!(got instanceof Error)) {
|
|
733
|
+
page = got;
|
|
734
|
+
pageUrl = got.url();
|
|
735
|
+
pageWaiting = undefined;
|
|
736
|
+
try {
|
|
737
|
+
updateHostService(dir, process.pid, { url: pageUrl, pageWaiting: undefined });
|
|
738
|
+
} catch (err: any) {
|
|
739
|
+
log(`could not record the page: ${err?.message ?? err}`);
|
|
740
|
+
}
|
|
741
|
+
log(`page: ${pageUrl}`);
|
|
742
|
+
if (!pageOnLoopback) log(`page on the local network, unencrypted: ${lanAddresses(got.port())[0]?.url ?? `http://${pageBind.host}:${got.port()}/`}`);
|
|
743
|
+
return;
|
|
744
|
+
}
|
|
745
|
+
if (got.code !== "EADDRINUSE") {
|
|
746
|
+
log(`page unavailable: ${got.message}`);
|
|
747
|
+
return;
|
|
748
|
+
}
|
|
749
|
+
const reason = await whoHoldsThePort();
|
|
750
|
+
if (pageWaiting?.reason === reason) return;
|
|
751
|
+
pageWaiting = { reason, since: stamp() };
|
|
752
|
+
log(reason === "older-page" ? `page: ${pageWhere} is held by a pi-loops page from before 0.24; it is served here once that page exits` : `page unavailable: ${pageWhere} is used by another program; set [page] port in config.toml, then pi-loops restart`);
|
|
753
|
+
try {
|
|
754
|
+
updateHostService(dir, process.pid, { pageWaiting });
|
|
755
|
+
} catch (err: any) {
|
|
756
|
+
log(`could not record the page: ${err?.message ?? err}`);
|
|
757
|
+
}
|
|
758
|
+
}
|
|
759
|
+
|
|
760
|
+
/** One bind at a time: the first at start, then one per tick while an older page holds the port. */
|
|
761
|
+
function bindPageAndSay(): Promise<void> {
|
|
762
|
+
pageBinding = bindPageOnce()
|
|
763
|
+
.catch((err: any) => log(`page: ${err?.message ?? err}`))
|
|
764
|
+
.finally(() => {
|
|
765
|
+
pageBinding = undefined;
|
|
766
|
+
});
|
|
767
|
+
return pageBinding;
|
|
768
|
+
}
|
|
769
|
+
|
|
770
|
+
/**
|
|
771
|
+
* `page-open` over the socket (`openForLaunch`). One that arrives during the first bind starts its
|
|
772
|
+
* pi at once and answers when the bind settles; one that arrives while the port is held is refused.
|
|
773
|
+
*/
|
|
774
|
+
async function pageOpen(req: Omit<PageOpenRequest, "op">): Promise<PageOpened> {
|
|
775
|
+
const notServed = () => (pageWaiting ? `the page is ${pageWaitingLine(pageWaiting.reason)}` : "the page is not served");
|
|
776
|
+
const server = page ? Promise.resolve(page) : !pageWaiting && pageBinding ? pageBinding.then(() => page) : undefined;
|
|
777
|
+
if (!server) return { error: notServed() };
|
|
778
|
+
return openForLaunch(req, {
|
|
779
|
+
pool,
|
|
780
|
+
server,
|
|
781
|
+
notServed,
|
|
782
|
+
heldTurn: (id) => host.heldTurn(id),
|
|
783
|
+
lease: (id) => leases.read(id) ?? undefined,
|
|
784
|
+
});
|
|
785
|
+
}
|
|
786
|
+
|
|
787
|
+
// The page binds while the socket already takes `page-open`: a launch's pi starts beside the bind.
|
|
788
|
+
const firstBind = bindPageAndSay();
|
|
249
789
|
const channel = serveHostChannelSafely(
|
|
250
790
|
dir,
|
|
251
791
|
{
|
|
252
|
-
status: () =>
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
// one version of the file and `total` against another — a status line contradicting itself.
|
|
256
|
-
const jobs = host.scheduler.store.load();
|
|
257
|
-
const rules = host.triggers.store.load();
|
|
258
|
-
return {
|
|
259
|
-
pid: process.pid,
|
|
260
|
-
host: os.hostname(),
|
|
261
|
-
startedAt,
|
|
262
|
-
model: session.model,
|
|
263
|
-
leader: host.scheduler.isLeader,
|
|
264
|
-
runs: host.scheduler.runningRuns(),
|
|
265
|
-
checks: host.triggers.runningList().map((r) => ({ traceId: r.traceId, sourceLabel: r.sourceLabel, eventLabel: r.eventLabel, startedAt: r.startedAt, cwd: r.cwd })),
|
|
266
|
-
jobs: { enabled: jobs.filter((j) => j.enabled).length, total: jobs.length },
|
|
267
|
-
rules: { enabled: rules.filter((r) => r.enabled).length, total: rules.length },
|
|
268
|
-
inboxNew: host.scheduler.inbox.newCount(),
|
|
269
|
-
// Without these a host that has failed every run for six hours reads exactly like one
|
|
270
|
-
// that succeeded an hour ago: "nothing running right now".
|
|
271
|
-
recent: host.scheduler.store
|
|
272
|
-
.listRuns(undefined, 5)
|
|
273
|
-
.reverse()
|
|
274
|
-
.map((r) => ({ job: r.jobName ?? r.jobId, at: r.finishedAt, ok: r.ok, error: r.error ? previewRedacted(r.error, 100) : undefined, cost: r.usage?.cost })),
|
|
275
|
-
failing: jobs.filter((j) => j.enabled && j.lastError).map((j) => ({ job: j.name ?? j.id, error: previewRedacted(j.lastError ?? "", 120) })),
|
|
276
|
-
nextDue: (() => {
|
|
277
|
-
const next = jobs
|
|
278
|
-
.filter((j) => j.enabled && j.stateful)
|
|
279
|
-
.map((j) => computeNext({ schedule: j.schedule, createdAt: Date.parse(j.createdAt), lastFiredAt: j.lastFiredAt ? Date.parse(j.lastFiredAt) : undefined }, Date.now()))
|
|
280
|
-
.filter((n): n is number => n !== undefined)
|
|
281
|
-
.sort((a, b) => a - b)[0];
|
|
282
|
-
return next ? stamp(next) : undefined;
|
|
283
|
-
})(),
|
|
284
|
-
budget: (() => {
|
|
285
|
-
const b = host.scheduler.budgetState();
|
|
286
|
-
return b.cap > 0 ? { spent: b.spent, cap: b.cap } : undefined;
|
|
287
|
-
})(),
|
|
288
|
-
mcp: mcpSources.map((s) => ({ name: s.config.name, state: s.status.state, lastError: s.status.lastError ? previewRedacted(s.status.lastError, 120) : undefined })),
|
|
289
|
-
};
|
|
290
|
-
},
|
|
291
|
-
// The status lines show shortened ids, so that is what a watcher types back.
|
|
292
|
-
abortRun: (runId) => {
|
|
293
|
-
const match = host.scheduler.runningRuns().find((r) => r.runId === runId || r.runId.startsWith(runId));
|
|
294
|
-
return match ? host.scheduler.abortRun(match.runId) : false;
|
|
295
|
-
},
|
|
792
|
+
status: () => snapshot(),
|
|
793
|
+
abortRun,
|
|
794
|
+
stopRun: stopRunFrom("cli", true, stopHost),
|
|
296
795
|
abortCheck: (traceId) => {
|
|
297
796
|
const match = host.triggers.runningList().find((r) => r.traceId === traceId || r.traceId.startsWith(traceId));
|
|
298
797
|
return match ? host.triggers.abort(match.traceId) : false;
|
|
299
798
|
},
|
|
300
|
-
stop
|
|
799
|
+
// Called once, or twice when a plain stop cuts a restart's or an upgrade's wait short: that
|
|
800
|
+
// `stop` leaves at once, and the wait's own leave later finds the shutdown under way. A restart
|
|
801
|
+
// or an upgrade first lets a turn in the page finish, up to PAGE_TURN_WAIT_MS; a signal still
|
|
802
|
+
// leaves at once.
|
|
803
|
+
stop: (reason) => {
|
|
804
|
+
log(reason === "upgrade" ? "a newer pi-loops asked this process to make way; leaving" : reason === "restart" ? "asked to restart; leaving" : "asked to stop; leaving");
|
|
805
|
+
if (!stopWaitsForPage(reason)) return leave(0);
|
|
806
|
+
waitForPageTurns({ busy: () => pool.views().filter((c) => c.busy).length, log })
|
|
807
|
+
.catch((err: any) => log(`waiting for the page: ${err?.message ?? err}`))
|
|
808
|
+
.finally(() => leave(0))
|
|
809
|
+
.catch((err: any) => log(`leaving: ${err?.message ?? err}`));
|
|
810
|
+
},
|
|
811
|
+
run: (jobId, requestedBy) => host.scheduler.runNow(jobId, requestedBy),
|
|
812
|
+
ring: (sessionId) => host.ring(sessionId),
|
|
813
|
+
pageOpen,
|
|
814
|
+
pageKey: (sessionId) => {
|
|
815
|
+
if (!page) return { error: "the page is not served" };
|
|
816
|
+
if (!pool.has(sessionId) && !leases.read(sessionId)) return { error: `pi-loops knows no conversation ${shortSessionId(sessionId)}` };
|
|
817
|
+
return { openKey: page.openKeyFor(sessionId) };
|
|
818
|
+
},
|
|
819
|
+
pair: () => (page ? { code: page.newPairCode(), addresses: pageOnLoopback ? [] : lanAddresses(page.port()).map((a) => a.url) } : { error: "the page is not served" }),
|
|
301
820
|
},
|
|
302
821
|
log,
|
|
303
822
|
);
|
|
823
|
+
await firstBind;
|
|
304
824
|
parentModel = await defaultModel().catch((err) => {
|
|
305
825
|
log(`default model: ${err?.message ?? err}`);
|
|
306
826
|
return undefined;
|
|
@@ -308,15 +828,31 @@ parentModel = await defaultModel().catch((err) => {
|
|
|
308
828
|
log(`default model: ${parentModel ? `${parentModel.provider}/${parentModel.id}` : "none (jobs must pin one)"}`);
|
|
309
829
|
startMcpSources();
|
|
310
830
|
host.start();
|
|
831
|
+
// As in the minute timer below: a rules file that cannot be read must cost its rules, not every loop.
|
|
832
|
+
try {
|
|
833
|
+
watchProjectPushServers();
|
|
834
|
+
} catch (err: any) {
|
|
835
|
+
log(`project push servers: ${err?.message ?? err}`);
|
|
836
|
+
}
|
|
311
837
|
// The scheduler's timer is unref'd (an extension must never keep pi alive); the host wants to live,
|
|
312
838
|
// and re-reads config.toml so edits take effect without a restart.
|
|
313
839
|
setInterval(() => {
|
|
840
|
+
try {
|
|
841
|
+
watchProjectPushServers();
|
|
842
|
+
} catch (err: any) {
|
|
843
|
+
log(`project push servers: ${err?.message ?? err}`);
|
|
844
|
+
}
|
|
314
845
|
config = loadConfig(dir);
|
|
315
846
|
logConfigErrors();
|
|
847
|
+
try {
|
|
848
|
+
reloadMcpIfChanged();
|
|
849
|
+
} catch (err: any) {
|
|
850
|
+
log(`mcp.toml: ${err?.message ?? err}`);
|
|
851
|
+
}
|
|
316
852
|
host.triggers.pollIntervalSecs = config.triggerPollIntervalSecs;
|
|
317
853
|
host.triggers.runTimeoutMs = config.triggerRunTimeoutMs;
|
|
318
854
|
// And the model, on the same minute. This process can live for days: a credential added with
|
|
319
|
-
// `/login` in a pi that opened after
|
|
855
|
+
// `/login` in a pi that opened after this host started, or a provider that started resolving, would
|
|
320
856
|
// otherwise never reach it — `parentModel` was resolved once, at startup. Only an improvement is
|
|
321
857
|
// taken: a model that stops resolving leaves the one the host already has in place.
|
|
322
858
|
void defaultModel()
|