@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.
Files changed (91) hide show
  1. package/CHANGELOG.md +681 -0
  2. package/CONTRACT.md +7 -7
  3. package/README.md +91 -63
  4. package/README.zh-CN.md +59 -39
  5. package/docs/cli.md +272 -83
  6. package/docs/configuration.md +86 -24
  7. package/docs/design.md +395 -56
  8. package/docs/downstream.md +2 -2
  9. package/docs/goal.md +12 -3
  10. package/docs/hooks.md +6 -7
  11. package/docs/loops.md +288 -59
  12. package/docs/mcp.md +22 -14
  13. package/docs/recipes.md +6 -0
  14. package/docs/screenshot.png +0 -0
  15. package/docs/triggers.md +120 -26
  16. package/docs/troubleshooting.md +37 -26
  17. package/docs/web-ui-parity.md +691 -65
  18. package/package.json +4 -3
  19. package/recipes/autoresearch/recipe.toml +1 -0
  20. package/recipes/changelog-draft/recipe.toml +1 -0
  21. package/recipes/ci-sweeper/recipe.toml +1 -0
  22. package/recipes/daily-digest/recipe.toml +1 -0
  23. package/recipes/deps-sweeper/recipe.toml +1 -0
  24. package/recipes/ecosystem/recipe.toml +1 -0
  25. package/recipes/issue-loop/recipe.toml +1 -0
  26. package/recipes/pr-watch/recipe.toml +1 -0
  27. package/skills/pi-loops/SKILL.md +6 -4
  28. package/src/activity.ts +199 -0
  29. package/src/archive.ts +21 -9
  30. package/src/args.ts +6 -2
  31. package/src/cli-entry.mjs +12 -3
  32. package/src/cli.ts +746 -125
  33. package/src/config.ts +53 -5
  34. package/src/conversation-rows.ts +469 -0
  35. package/src/danger.ts +227 -6
  36. package/src/engine.ts +1240 -0
  37. package/src/goal.ts +71 -0
  38. package/src/held-read.ts +27 -0
  39. package/src/hooks.ts +49 -18
  40. package/src/host-control-channel.ts +455 -40
  41. package/src/host-control.ts +602 -111
  42. package/src/host-entry.mjs +5 -3
  43. package/src/host-runtime.ts +455 -41
  44. package/src/host.ts +659 -123
  45. package/src/inbox-card.ts +278 -0
  46. package/src/inbox.ts +139 -62
  47. package/src/job-edit.ts +7 -1
  48. package/src/job-health.ts +14 -2
  49. package/src/job-lines.ts +201 -13
  50. package/src/job-owner.ts +74 -13
  51. package/src/job-tune.ts +110 -0
  52. package/src/lease.ts +537 -0
  53. package/src/lock.ts +7 -2
  54. package/src/mcp-pool.ts +66 -14
  55. package/src/mcp.ts +1 -1
  56. package/src/open-file.ts +54 -0
  57. package/src/page-children.ts +1507 -0
  58. package/src/page-devices.ts +192 -0
  59. package/src/page-server.ts +3150 -0
  60. package/src/path-precheck.ts +215 -0
  61. package/src/paths.ts +24 -0
  62. package/src/pi-floor.ts +10 -0
  63. package/src/pi-loops.ts +1301 -450
  64. package/src/pi-resolver.d.mts +1 -0
  65. package/src/pi-resolver.mjs +110 -10
  66. package/src/presence.ts +12 -63
  67. package/src/protocol.ts +10 -1
  68. package/src/recipe.ts +43 -1
  69. package/src/requests.ts +354 -0
  70. package/src/rule-lines.ts +68 -0
  71. package/src/run-card.ts +105 -0
  72. package/src/runner.ts +18 -1
  73. package/src/schedule.ts +1 -2
  74. package/src/scheduler.ts +598 -245
  75. package/src/sdk-runner.ts +321 -27
  76. package/src/seen.ts +278 -0
  77. package/src/session-head.ts +21 -0
  78. package/src/share.ts +12 -2
  79. package/src/snapshot.ts +3 -2
  80. package/src/store.ts +81 -8
  81. package/src/tools.ts +196 -65
  82. package/src/trigger-runtime.ts +164 -127
  83. package/src/triggers.ts +39 -7
  84. package/src/trust.ts +46 -2
  85. package/src/ui-prefs.ts +16 -5
  86. package/src/watch-plan.ts +174 -0
  87. package/src/web.d.mts +3 -0
  88. package/src/web.mjs +2260 -2340
  89. package/src/window-executor.ts +459 -0
  90. package/src/window-guard.ts +158 -0
  91. package/docs/agents/issue-tracker.md +0 -34
package/src/host.ts CHANGED
@@ -1,10 +1,12 @@
1
1
  /**
2
- * The headless host — pi-loops with no pi window. Started by the last interactive pi to quit
3
- * (`host-control.ts`), it keeps the clock on this machine: stateful loops, dynamic trigger checks
4
- * for every project, MCP pushes, catch-up — with the same stores, the same in-process sub-agent
5
- * runner (pi's SDK) and its own MCP client instances. Chat-bound results go to the inbox. The
6
- * first interactive pi to open takes the clock back (its scheduler preempts a "host" leader) and
7
- * this process exits. Nothing restarts it after a reboot until a pi opens.
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, clearHostRecord, hostProcessMatches, readHost, writeHostRecord } from "./host-control.ts";
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 { createInProcessRunner } from "./sdk-runner.ts";
31
- import { defaultLoopsDir } from "./store.ts";
32
- import { isExactlyTrusted } from "./trust.ts";
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 host's stdout and stderr are this file too (host-control.ts), so a chatty MCP server
45
- // writes here as well. It is the one file a user is told to read; keep it readable — by the
46
- // same rule as every other log in this project, which lives in src/log.ts.
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, the model and thinking level
71
- // of the pi that handed off (env), else the settings' defaults. Never "some available model".
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
- const mcpLoaded = loadMcpConfigFiles({ dir, projectTrusted: false });
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 runner = createInProcessRunner({
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
- exit: (code) => shutdown(code),
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
- const source = new McpSource(cfg, {
145
- onConnected: async (src) => {
146
- let tools;
147
- try {
148
- tools = await src.listTools();
149
- } catch (err: any) {
150
- src.status.lastError = `tools/list failed: ${err?.message ?? err}`;
151
- return;
152
- }
153
- const already = mcpToolNames.get(cfg.name) ?? [];
154
- const defs = mcpToolDefs.get(cfg.name) ?? [];
155
- const taken = new Set([...mcpToolDefs.values()].flat().map((d) => d.name));
156
- for (const { def } of mcpToolDefinitions(src, tools, taken, already)) defs.push(def);
157
- mcpToolNames.set(cfg.name, already);
158
- mcpToolDefs.set(cfg.name, defs);
159
- log(`mcp ${cfg.name}: connected, ${already.length} tool(s)`);
160
- },
161
- onNotification: (n) => {
162
- const trigger = mapNotification(cfg.name, n);
163
- if (!trigger) {
164
- source.status.droppedCount++;
165
- source.status.lastError = droppedNotificationMessage(n.method);
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(() => process.exit(code), 10_000).unref();
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
- if (code === 0) clearHostRecord(dir, process.pid);
202
- log(`host stopped (exit ${code})`);
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
- log(`uncaught: ${redact(String(err?.stack ?? err))}`);
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
- // One host per machine: two pis quitting together may both spawn one; the second to get here
220
- // leaves without touching the first's record (host.json names the survivor, so /cron host and
221
- // crash detection keep working).
222
- const claimed = await withFileLock(path.join(dir, "host.lock"), () => {
223
- const other = readHost(dir);
224
- if (other && other.pid !== process.pid && other.host === os.hostname() && hostProcessMatches(other)) return false;
225
- writeHostRecord(dir, { pid: process.pid, host: os.hostname(), startedAt: stamp(), node: process.execPath, entry: process.argv[1] });
226
- return true;
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
- if (!claimed) {
229
- log(`another host is already running (pid ${readHost(dir)?.pid}); exiting`);
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
- log(`pi-loops ${PI_LOOPS_VERSION} headless host started (pid ${process.pid}, dir ${dir})`);
233
- // A window into a process with no chat: `/cron host` and `pi-loops host status` read this.
234
- const startedAt = stamp();
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
- // One read of each store for the whole snapshot. jobs.json was read four times and
254
- // triggers.json twice, so a tick landing in between could have `enabled` counted against
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: () => leave(0),
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 the hand-off, or a provider that started resolving, would
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()