@alphacoder-v0/pi-loops 0.17.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +1989 -0
- package/LICENSE +21 -0
- package/README.md +370 -0
- package/README.zh-CN.md +606 -0
- package/docs/cli.md +252 -0
- package/docs/configuration.md +110 -0
- package/docs/design.md +155 -0
- package/docs/goal.md +59 -0
- package/docs/hooks.md +110 -0
- package/docs/loops.md +315 -0
- package/docs/mcp.md +90 -0
- package/docs/screenshot.png +0 -0
- package/docs/session-archive.md +64 -0
- package/docs/triggers.md +140 -0
- package/docs/troubleshooting.md +111 -0
- package/docs/web-ui-parity.md +305 -0
- package/examples/README.md +18 -0
- package/examples/mcp-notify-server.mjs +35 -0
- package/examples/mcp.toml +8 -0
- package/package.json +71 -0
- package/skills/pi-loops/SKILL.md +50 -0
- package/src/archive.ts +476 -0
- package/src/args.ts +217 -0
- package/src/cli-entry.mjs +23 -0
- package/src/cli.ts +610 -0
- package/src/config.ts +103 -0
- package/src/danger.ts +128 -0
- package/src/goal.ts +178 -0
- package/src/hooks.ts +436 -0
- package/src/host-control-channel.ts +243 -0
- package/src/host-control.ts +203 -0
- package/src/host-entry.mjs +14 -0
- package/src/host-runtime.ts +222 -0
- package/src/host.ts +329 -0
- package/src/inbox.ts +232 -0
- package/src/job-edit.ts +113 -0
- package/src/job-health.ts +29 -0
- package/src/lock.ts +193 -0
- package/src/log.ts +108 -0
- package/src/mcp-pool.ts +141 -0
- package/src/mcp.ts +885 -0
- package/src/paths.ts +33 -0
- package/src/pi-loops.ts +2292 -0
- package/src/pi-resolver.mjs +49 -0
- package/src/presence.ts +143 -0
- package/src/protocol.ts +198 -0
- package/src/redact.ts +61 -0
- package/src/register-pi.mjs +3 -0
- package/src/runner.ts +149 -0
- package/src/schedule.ts +431 -0
- package/src/scheduler.ts +981 -0
- package/src/sdk-runner.ts +536 -0
- package/src/share.ts +121 -0
- package/src/slots.ts +76 -0
- package/src/snapshot.ts +40 -0
- package/src/store.ts +599 -0
- package/src/subagent-guard.ts +47 -0
- package/src/thinking.ts +30 -0
- package/src/toml.ts +244 -0
- package/src/tools.ts +455 -0
- package/src/transcript.ts +84 -0
- package/src/trigger-runtime.ts +711 -0
- package/src/triggers.ts +558 -0
- package/src/trust.ts +47 -0
- package/src/ts-entry.mjs +67 -0
- package/src/ui-prefs.ts +84 -0
- package/src/version.ts +14 -0
- package/src/web.mjs +4484 -0
|
@@ -0,0 +1,203 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The headless host: a `node` process running `src/host.ts` that keeps the clock (loops,
|
|
3
|
+
* trigger checks, MCP pushes) while no interactive pi is open on this machine. The last
|
|
4
|
+
* interactive pi to quit starts it; the first to open takes the clock back and the host exits.
|
|
5
|
+
* `host.json` under the loops dir names the running host; `host.log` is its output.
|
|
6
|
+
*/
|
|
7
|
+
import { execFileSync, spawn } from "node:child_process";
|
|
8
|
+
import * as fs from "node:fs";
|
|
9
|
+
import * as os from "node:os";
|
|
10
|
+
import * as path from "node:path";
|
|
11
|
+
import { pidAlive, writeFileAtomic } from "./lock.ts";
|
|
12
|
+
import type { PresenceEntry } from "./presence.ts";
|
|
13
|
+
import { stamp } from "./schedule.ts";
|
|
14
|
+
|
|
15
|
+
export interface HostRecord {
|
|
16
|
+
pid: number;
|
|
17
|
+
host: string;
|
|
18
|
+
startedAt: string;
|
|
19
|
+
node: string;
|
|
20
|
+
/** The host entry as it appears in the process's argv, for an exact command-line match. */
|
|
21
|
+
entry?: string;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export const HOST_FILE = "host.json";
|
|
25
|
+
export const HOST_LOG = "host.log";
|
|
26
|
+
|
|
27
|
+
export function readHost(dir: string): HostRecord | undefined {
|
|
28
|
+
try {
|
|
29
|
+
const rec = JSON.parse(fs.readFileSync(path.join(dir, HOST_FILE), "utf8")) as HostRecord;
|
|
30
|
+
return rec && typeof rec.pid === "number" ? rec : undefined;
|
|
31
|
+
} catch {
|
|
32
|
+
return undefined;
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Is `pid` really our host? A record can outlive its process (SIGKILL, power loss) and the pid
|
|
38
|
+
* be recycled, so besides liveness we require the record to postdate the last boot and, where
|
|
39
|
+
* /proc exists, the command line to be the host entry.
|
|
40
|
+
*/
|
|
41
|
+
export function hostProcessMatches(rec: HostRecord): boolean {
|
|
42
|
+
if (!pidAlive(rec.pid)) return false;
|
|
43
|
+
const booted = Date.now() - os.uptime() * 1000;
|
|
44
|
+
if (Number.isFinite(booted) && Date.parse(rec.startedAt) < booted - 60_000) return false;
|
|
45
|
+
const isOurs = (argv: string[]) => (rec.entry ? argv.includes(rec.entry) : argv.some((a) => a.endsWith("host.ts")));
|
|
46
|
+
try {
|
|
47
|
+
return isOurs(fs.readFileSync(`/proc/${rec.pid}/cmdline`, "utf8").split("\0"));
|
|
48
|
+
} catch {
|
|
49
|
+
/* no /proc (macOS): ask ps */
|
|
50
|
+
}
|
|
51
|
+
try {
|
|
52
|
+
const command = execFileSync("ps", ["-o", "command=", "-p", String(rec.pid)], { encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] });
|
|
53
|
+
return isOurs(command.trim().split(/\s+/));
|
|
54
|
+
} catch {
|
|
55
|
+
return true; // liveness + boot time is all we can check
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** The host process on this machine, if it is alive. A stale record is removed. */
|
|
60
|
+
export function liveHost(dir: string): HostRecord | undefined {
|
|
61
|
+
const rec = readHost(dir);
|
|
62
|
+
if (!rec) return undefined;
|
|
63
|
+
if (rec.host !== os.hostname()) return undefined;
|
|
64
|
+
if (hostProcessMatches(rec)) return rec;
|
|
65
|
+
fs.rmSync(path.join(dir, HOST_FILE), { force: true });
|
|
66
|
+
return undefined;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** A record whose process is gone means the host crashed (it removes the record on a clean exit). */
|
|
70
|
+
export function crashedHost(dir: string): HostRecord | undefined {
|
|
71
|
+
const rec = readHost(dir);
|
|
72
|
+
if (!rec || rec.host !== os.hostname()) return undefined;
|
|
73
|
+
if (hostProcessMatches(rec)) return undefined;
|
|
74
|
+
fs.rmSync(path.join(dir, HOST_FILE), { force: true });
|
|
75
|
+
return rec;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
export function writeHostRecord(dir: string, rec: HostRecord): void {
|
|
79
|
+
writeFileAtomic(path.join(dir, HOST_FILE), `${JSON.stringify(rec, null, 2)}\n`);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
export function clearHostRecord(dir: string, pid?: number): void {
|
|
83
|
+
const rec = readHost(dir);
|
|
84
|
+
if (pid !== undefined && rec && rec.pid !== pid) return;
|
|
85
|
+
fs.rmSync(path.join(dir, HOST_FILE), { force: true });
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/** SIGTERM the host (it stops its scheduler and exits); returns the pid or undefined when none ran. */
|
|
89
|
+
export function stopHost(dir: string): number | undefined {
|
|
90
|
+
const rec = liveHost(dir);
|
|
91
|
+
if (!rec) return undefined;
|
|
92
|
+
try {
|
|
93
|
+
process.kill(rec.pid, "SIGTERM");
|
|
94
|
+
} catch {
|
|
95
|
+
/* already gone */
|
|
96
|
+
}
|
|
97
|
+
fs.rmSync(path.join(dir, HOST_FILE), { force: true });
|
|
98
|
+
return rec.pid;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* Hand the clock to a headless host when the last interactive pi on this machine quits and
|
|
103
|
+
* there is something for it to keep running. Pure, so the decision is testable.
|
|
104
|
+
*/
|
|
105
|
+
export function shouldHandOff(input: { auto: boolean; presence: PresenceEntry[]; selfPid: number; selfInstance?: string; hostName: string; enabledLoops: number; enabledRules: number; pushServers: number; hostAlive: boolean }): { handOff: boolean; reason: string } {
|
|
106
|
+
if (!input.auto) return { handOff: false, reason: "[host] auto = false" };
|
|
107
|
+
if (input.hostAlive) return { handOff: false, reason: "a host is already running" };
|
|
108
|
+
const others = input.presence.filter((e) => e.host === input.hostName && e.kind !== "host" && !(e.pid === input.selfPid && (!input.selfInstance || e.instance === input.selfInstance)));
|
|
109
|
+
if (others.length) return { handOff: false, reason: `${others.length} other interactive pi still open` };
|
|
110
|
+
if (!input.enabledLoops && !input.enabledRules && !input.pushServers) return { handOff: false, reason: "nothing to keep running" };
|
|
111
|
+
return { handOff: true, reason: `${input.enabledLoops} loop(s), ${input.enabledRules} rule(s), ${input.pushServers} push source(s)` };
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* MCP servers the host can do something with: a push either injects (inbox) or is evaluated
|
|
116
|
+
* against rules — a tool-only server with no rules is nothing to stay up for.
|
|
117
|
+
*/
|
|
118
|
+
export function hostPushWork(servers: Array<{ injectSummary?: boolean; injectAndRun?: boolean }>, enabledRules: number): number {
|
|
119
|
+
return servers.filter((s) => s.injectSummary || s.injectAndRun || enabledRules > 0).length;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* `node [--experimental-strip-types] --import <register> <host-entry.mjs>`: TypeScript needs the flag
|
|
124
|
+
* before Node 23.6. The entry is the `.mjs` and not `host.ts` on purpose — under an npm install this
|
|
125
|
+
* package sits below node_modules, where Node refuses to strip types at all and only the jiti route
|
|
126
|
+
* inside src/ts-entry.mjs can open the file.
|
|
127
|
+
*/
|
|
128
|
+
export function hostSpawnArgs(nodeVersion: string, registerPath: string, entryPath: string): string[] {
|
|
129
|
+
const [major, minor] = nodeVersion.replace(/^v/, "").split(".").map(Number);
|
|
130
|
+
const strip = major < 23 || (major === 23 && minor < 6) ? ["--experimental-strip-types"] : [];
|
|
131
|
+
return [...strip, "--import", registerPath, entryPath];
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/** Where pi itself lives, for the host's module resolver: `<pi package>/dist/bundle/cli.js` is what runs us. */
|
|
135
|
+
export function piPackageDir(argv1 = process.argv[1]): string | undefined {
|
|
136
|
+
if (!argv1) return undefined;
|
|
137
|
+
try {
|
|
138
|
+
argv1 = fs.realpathSync(argv1); // `pi` is usually a bin symlink into the package
|
|
139
|
+
} catch {
|
|
140
|
+
/* keep as is */
|
|
141
|
+
}
|
|
142
|
+
let dir = path.dirname(argv1);
|
|
143
|
+
for (let i = 0; i < 4; i++) {
|
|
144
|
+
if (fs.existsSync(path.join(dir, "package.json")) && /pi-coding-agent/.test(dir)) return dir;
|
|
145
|
+
dir = path.dirname(dir);
|
|
146
|
+
}
|
|
147
|
+
return undefined;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
export interface SpawnHostOptions {
|
|
151
|
+
dir: string;
|
|
152
|
+
packageDir: string;
|
|
153
|
+
piPackage?: string;
|
|
154
|
+
/** The handing-off pi's model and thinking level: the host's defaults for unpinned work. */
|
|
155
|
+
model?: string;
|
|
156
|
+
thinking?: string;
|
|
157
|
+
node?: string;
|
|
158
|
+
nodeVersion?: string;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/** Start the host detached, logging to `host.log`; returns its pid. */
|
|
162
|
+
export function spawnHost(opts: SpawnHostOptions): number {
|
|
163
|
+
if ((process.versions as any).bun) throw new Error("the headless host needs node (pi is running under bun)");
|
|
164
|
+
const node = opts.node ?? process.execPath;
|
|
165
|
+
const args = hostSpawnArgs(opts.nodeVersion ?? process.version, path.join(opts.packageDir, "src", "register-pi.mjs"), path.join(opts.packageDir, "src", "host-entry.mjs"));
|
|
166
|
+
fs.mkdirSync(opts.dir, { recursive: true });
|
|
167
|
+
const log = fs.openSync(path.join(opts.dir, HOST_LOG), "a");
|
|
168
|
+
const child = spawn(node, args, {
|
|
169
|
+
detached: true,
|
|
170
|
+
stdio: ["ignore", log, log],
|
|
171
|
+
cwd: os.homedir(),
|
|
172
|
+
env: { ...process.env, PI_LOOPS_DIR: opts.dir, ...(opts.piPackage ? { PI_LOOPS_PI_PACKAGE: opts.piPackage } : {}), ...(opts.model ? { PI_LOOPS_HOST_MODEL: opts.model } : {}), ...(opts.thinking ? { PI_LOOPS_HOST_THINKING: opts.thinking } : {}) },
|
|
173
|
+
});
|
|
174
|
+
fs.closeSync(log);
|
|
175
|
+
// A spawn failure surfaces on the next tick; without a listener it would crash the quitting pi.
|
|
176
|
+
child.once("error", (err) => {
|
|
177
|
+
try {
|
|
178
|
+
fs.appendFileSync(path.join(opts.dir, HOST_LOG), `${stamp()} could not start the host: ${err?.message ?? err}\n`);
|
|
179
|
+
} catch {
|
|
180
|
+
/* nothing left to tell */
|
|
181
|
+
}
|
|
182
|
+
});
|
|
183
|
+
child.unref();
|
|
184
|
+
if (!child.pid) throw new Error("could not start the headless host");
|
|
185
|
+
// The host writes host.json itself, under a lock, once it knows no other host is running.
|
|
186
|
+
return child.pid;
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
/**
|
|
190
|
+
* Wait briefly for a spawned host to record itself. `spawn` returns a pid the instant it is called,
|
|
191
|
+
* so a host that dies during module resolution (a `PI_LOOPS_PI_PACKAGE` pointing at the wrong pi,
|
|
192
|
+
* a missing dependency) used to be announced as a successful hand-off, leaving all automation off
|
|
193
|
+
* with nothing but a line in `host.log`.
|
|
194
|
+
*/
|
|
195
|
+
export async function waitForHost(dir: string, pid: number, timeoutMs = 3000): Promise<boolean> {
|
|
196
|
+
const deadline = Date.now() + timeoutMs;
|
|
197
|
+
while (Date.now() < deadline) {
|
|
198
|
+
if (readHost(dir)?.pid === pid) return true;
|
|
199
|
+
if (!pidAlive(pid)) return false;
|
|
200
|
+
await new Promise((r) => setTimeout(r, 100));
|
|
201
|
+
}
|
|
202
|
+
return readHost(dir)?.pid === pid;
|
|
203
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// The headless host's entry point (see src/host.ts), spawned by src/host-control.ts as
|
|
3
|
+
// `node --import <pkg>/src/register-pi.mjs <pkg>/src/host-entry.mjs`. host.ts cannot be the entry
|
|
4
|
+
// itself: under an npm install it sits below node_modules, where Node refuses to strip types, so the
|
|
5
|
+
// same dual load the CLI uses has to happen here too (src/ts-entry.mjs).
|
|
6
|
+
import { importTs } from "./ts-entry.mjs";
|
|
7
|
+
|
|
8
|
+
try {
|
|
9
|
+
await importTs(import.meta.url, "./host.ts");
|
|
10
|
+
} catch (err) {
|
|
11
|
+
// stdout and stderr are the host log; a line there is what `pi-loops host start` points a person at.
|
|
12
|
+
process.stderr.write(`pi-loops host: ${err?.message ?? err}\n`);
|
|
13
|
+
process.exit(1);
|
|
14
|
+
}
|
|
@@ -0,0 +1,222 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* What the headless host runs (see host.ts for the process shell): the same LoopScheduler and
|
|
3
|
+
* TriggerRuntime as the extension, wired for a process that has no chat — promotions go to the
|
|
4
|
+
* inbox, control-plane operations by sub-agents are audited into `triggers-audit.jsonl`, and the
|
|
5
|
+
* process leaves as soon as an interactive pi owns the clock. Injected so it can be unit-tested.
|
|
6
|
+
*/
|
|
7
|
+
import type { ToolDefinition } from "@earendil-works/pi-coding-agent";
|
|
8
|
+
import * as path from "node:path";
|
|
9
|
+
import type { LoopsConfig } from "./config.ts";
|
|
10
|
+
import { HookRunner, truncateSummary } from "./hooks.ts";
|
|
11
|
+
import { PI_BUILTIN_TOOL_NAMES } from "./mcp.ts";
|
|
12
|
+
import { redact } from "./redact.ts";
|
|
13
|
+
import type { SubagentRequest, SubagentRunner } from "./runner.ts";
|
|
14
|
+
import { LoopScheduler, type SessionSnapshot } from "./scheduler.ts";
|
|
15
|
+
import { SubagentSlots } from "./slots.ts";
|
|
16
|
+
import { type LoopJob, newId } from "./store.ts";
|
|
17
|
+
import { type ToolHost, automationTools, createLoopJob } from "./tools.ts";
|
|
18
|
+
import { TriggerRuntime } from "./trigger-runtime.ts";
|
|
19
|
+
import { auditCronFinish, auditCronStart, TriggerStore, controlPlanePreflight } from "./triggers.ts";
|
|
20
|
+
|
|
21
|
+
export interface HostRuntimeDeps {
|
|
22
|
+
dir: string;
|
|
23
|
+
config: () => LoopsConfig;
|
|
24
|
+
/** The host's own defaults: no cwd, the settings' model and thinking level. */
|
|
25
|
+
session: () => SessionSnapshot;
|
|
26
|
+
runner: SubagentRunner;
|
|
27
|
+
/** MCP tool definitions of the host's own (user-level) servers, for every sub-session. */
|
|
28
|
+
mcpTools: () => ToolDefinition<any, any>[];
|
|
29
|
+
/** That project's own MCP servers, connected on demand: the host has no project of its own. */
|
|
30
|
+
projectMcpTools?: (cwd: string, taken: Set<string>) => Promise<ToolDefinition<any, any>[]>;
|
|
31
|
+
/** Whether pi has a saved trust decision for exactly this directory (src/trust.ts). Default: no. */
|
|
32
|
+
isProjectTrusted?: (cwd: string) => boolean;
|
|
33
|
+
log: (message: string) => void;
|
|
34
|
+
/** Called when an interactive pi owns the clock: the host has nothing left to do. */
|
|
35
|
+
exit: (code: number) => void | Promise<void>;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export interface HostRuntime {
|
|
39
|
+
scheduler: LoopScheduler;
|
|
40
|
+
triggers: TriggerRuntime;
|
|
41
|
+
/** The tools a sub-session started by this host gets (its cwd and model, not the host's). */
|
|
42
|
+
customTools: (req: SubagentRequest) => Promise<ToolDefinition<any, any>[]>;
|
|
43
|
+
start(): void;
|
|
44
|
+
stop(): Promise<void>;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export function createHostRuntime(deps: HostRuntimeDeps): HostRuntime {
|
|
48
|
+
const { dir, log } = deps;
|
|
49
|
+
let triggers: TriggerRuntime;
|
|
50
|
+
let exiting = false;
|
|
51
|
+
|
|
52
|
+
/* ------------------------------------------------------ lifecycle hooks */
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* hooks.toml, fired around the runs the host starts: `run_start` and `run_end` (docs/hooks.md).
|
|
56
|
+
* One runner per run — the host has no project of its own, so each run brings the project the
|
|
57
|
+
* rules and the payload are about.
|
|
58
|
+
*/
|
|
59
|
+
const runHooks = new Map<string, HookRunner>();
|
|
60
|
+
|
|
61
|
+
function hookRunnerFor(job: LoopJob, runId: string): HookRunner | undefined {
|
|
62
|
+
// A model can pick a job's cwd (`cron_create` takes one) and nobody is here to answer a trust
|
|
63
|
+
// prompt, so a project's own hooks.toml needs pi's saved trust for that exact directory —
|
|
64
|
+
// the same bar the host puts in front of a project's MCP servers.
|
|
65
|
+
const trusted = !!deps.isProjectTrusted?.(job.cwd);
|
|
66
|
+
const runner = new HookRunner({
|
|
67
|
+
loopsDir: dir,
|
|
68
|
+
projectCwd: job.cwd,
|
|
69
|
+
allowProjectHooks: trusted && deps.config().allowProjectHooks,
|
|
70
|
+
// The run is this "session": its id is what pairs an `agent_start` with its `agent_end`.
|
|
71
|
+
getSession: () => ({ sessionId: runId, cwd: job.cwd, model: job.model ?? deps.session().model, thinking: job.thinking ?? deps.session().thinking }),
|
|
72
|
+
warn: (msg) => log(`hooks: ${msg}`),
|
|
73
|
+
// The host log is where "what did my automation do last night" is answered, so a hook that
|
|
74
|
+
// prints something has somewhere to print it here too.
|
|
75
|
+
log: (msg) => log(msg),
|
|
76
|
+
});
|
|
77
|
+
runner.load();
|
|
78
|
+
// `allow_project_hooks` in the user's own hooks.toml (or PI_ALLOW_PROJECT_HOOKS) opts every
|
|
79
|
+
// project in at once. That is a decision about projects the user opens, not about a directory
|
|
80
|
+
// this process was merely pointed at, so an untrusted one loses its rules again here.
|
|
81
|
+
if (!trusted) {
|
|
82
|
+
const kept = runner.hooks.filter((h) => h.source === "user");
|
|
83
|
+
if (kept.length !== runner.hooks.length) {
|
|
84
|
+
log(`hooks: ignoring ${runner.hooks.length - kept.length} project hook rule(s) in ${job.cwd}: pi has not trusted that directory`);
|
|
85
|
+
runner.hooks.length = 0;
|
|
86
|
+
runner.hooks.push(...kept);
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
for (const d of runner.diagnostics) log(`hooks: ${d}`);
|
|
90
|
+
if (!runner.hooks.length) return undefined;
|
|
91
|
+
runHooks.set(runId, runner);
|
|
92
|
+
return runner;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* One counter for every sub-agent this process starts — loop runs and trigger checks alike — so
|
|
97
|
+
* `[cron] max_concurrent_runs` bounds what the host actually runs (src/slots.ts). No /goal here:
|
|
98
|
+
* the host has no conversation to hold to a condition.
|
|
99
|
+
*/
|
|
100
|
+
const slots = new SubagentSlots(() => deps.config().maxConcurrentRuns);
|
|
101
|
+
|
|
102
|
+
const scheduler = new LoopScheduler({
|
|
103
|
+
dir,
|
|
104
|
+
kind: "host",
|
|
105
|
+
slots,
|
|
106
|
+
getSession: deps.session,
|
|
107
|
+
getSettings: () => ({ maxConcurrentRuns: deps.config().maxConcurrentRuns, catchUp: deps.config().cronCatchUp, dailyBudgetUsd: deps.config().dailyBudgetUsd }),
|
|
108
|
+
runner: deps.runner,
|
|
109
|
+
// No dead-session parking here: that is a decision the interactive leader can show the user.
|
|
110
|
+
hooks: {
|
|
111
|
+
// Plain (inject) jobs belong to a chat; the host has none, so they stay dormant here.
|
|
112
|
+
onInject: () => undefined,
|
|
113
|
+
onRunStart: (job, runId) => {
|
|
114
|
+
// The same rows the interactive extension writes: without them `/triggers audit` is
|
|
115
|
+
// blank for every hour the host had the clock.
|
|
116
|
+
auditCronStart(triggers.store, job, runId);
|
|
117
|
+
log(`loop ${job.name ?? job.id}: run ${runId.slice(0, 12)} started`);
|
|
118
|
+
// Never awaited: a webhook that hangs must not hold up the run it is announcing (the
|
|
119
|
+
// scheduler calls this synchronously). `.catch` because a rejection nobody owns takes
|
|
120
|
+
// the host down — hooks warn per rule, so there is nothing else to do with one.
|
|
121
|
+
const hooks = hookRunnerFor(job, runId);
|
|
122
|
+
if (hooks) void hooks.fire({ event: "run_start", run_job: job.name ?? job.id, run_id: runId, message_summary: truncateSummary(`${job.name ?? job.id}: ${job.prompt}`) }).catch((err: any) => log(`hooks: ${redact(err?.message ?? String(err))}`));
|
|
123
|
+
},
|
|
124
|
+
onCatchUp: (job) => log(`loop ${job.name ?? job.id}: catching up a missed tick`),
|
|
125
|
+
onRunFinished: ({ job, record, result }) => {
|
|
126
|
+
auditCronFinish(triggers.store, job, record, result.stopReason === "aborted");
|
|
127
|
+
log(`loop ${job.name ?? job.id}: ${record.ok ? "ok" : `FAILED (${redact(record.error ?? "")})`} · ${record.findings} finding(s)${record.usage?.cost ? ` · $${record.usage.cost.toFixed(3)}` : ""}`);
|
|
128
|
+
const hooks = runHooks.get(record.runId);
|
|
129
|
+
if (!hooks) return;
|
|
130
|
+
// `$PI_RUN_OK` alone answers "did last night's loop fail" — the thing the host exists to
|
|
131
|
+
// be able to tell someone.
|
|
132
|
+
const label = job.name ?? job.id;
|
|
133
|
+
const summary = record.ok ? `${label}: ok · ${record.findings} finding(s)` : `${label}: failed: ${record.error ?? "unknown error"}`;
|
|
134
|
+
// Its own hooks are the last thing a run does; the runner is dropped once they have run
|
|
135
|
+
// (until then `stop()` still has to drain it).
|
|
136
|
+
const forget = () => void runHooks.delete(record.runId);
|
|
137
|
+
void hooks
|
|
138
|
+
.fire({ event: "run_end", run_job: label, run_id: record.runId, run_ok: record.ok, run_findings: record.findings, run_error: record.error ? redact(record.error) : null, run_cost_usd: record.usage?.cost ?? null, message_summary: truncateSummary(summary) })
|
|
139
|
+
.then(forget, forget);
|
|
140
|
+
},
|
|
141
|
+
onTick: async (now, leader) => {
|
|
142
|
+
if (!leader) {
|
|
143
|
+
if (exiting) return;
|
|
144
|
+
exiting = true;
|
|
145
|
+
log("an interactive pi owns the clock; exiting");
|
|
146
|
+
await deps.exit(0);
|
|
147
|
+
return;
|
|
148
|
+
}
|
|
149
|
+
await triggers.tick(now, leader);
|
|
150
|
+
},
|
|
151
|
+
log: (msg) => log(redact(msg)),
|
|
152
|
+
},
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
const toInbox = async (content: string, trigger: { sourceLabel: string; traceId: string; cwd?: string }) => {
|
|
156
|
+
await scheduler.inbox.append({ source: `trigger:${trigger.sourceLabel}`, text: content.replace(/^\[Trigger [^\]]+\]\s*/, ""), runId: trigger.traceId, jobId: trigger.sourceLabel, cwd: trigger.cwd ?? "" });
|
|
157
|
+
return "inbox" as const;
|
|
158
|
+
};
|
|
159
|
+
triggers = new TriggerRuntime({
|
|
160
|
+
store: new TriggerStore(dir),
|
|
161
|
+
jobStore: scheduler.store,
|
|
162
|
+
getSession: deps.session,
|
|
163
|
+
pollIntervalSecs: deps.config().triggerPollIntervalSecs,
|
|
164
|
+
runTimeoutMs: deps.config().triggerRunTimeoutMs,
|
|
165
|
+
runner: deps.runner,
|
|
166
|
+
dedupFile: path.join(dir, "dedup.json"),
|
|
167
|
+
slots,
|
|
168
|
+
budget: () => scheduler.budgetState(),
|
|
169
|
+
self: scheduler.self,
|
|
170
|
+
presence: () => scheduler.presenceList(),
|
|
171
|
+
isLeader: () => scheduler.isLeader,
|
|
172
|
+
hooks: {
|
|
173
|
+
// No chat here: everything a rule would have promoted lands in the inbox.
|
|
174
|
+
onPromote: toInbox,
|
|
175
|
+
onInjectAndRun: toInbox,
|
|
176
|
+
onFinished: (o) => log(`trigger ${o.trigger.traceId.slice(0, 8)} ${o.delivery} ${o.ok ? "ok" : `FAILED (${redact(o.error ?? "")})`}${o.matchedRules.length ? ` matched ${o.matchedRules.length}` : ""}`),
|
|
177
|
+
log: (msg) => log(redact(msg)),
|
|
178
|
+
},
|
|
179
|
+
});
|
|
180
|
+
|
|
181
|
+
/** The tools' host for one sub-session: that run's cwd and model, not the process defaults. */
|
|
182
|
+
const toolHostFor = (req: Pick<SubagentRequest, "cwd" | "model" | "thinking">): ToolHost => {
|
|
183
|
+
const host: ToolHost = {
|
|
184
|
+
scheduler,
|
|
185
|
+
triggers,
|
|
186
|
+
session: () => ({ ...deps.session(), cwd: req.cwd, model: req.model ?? deps.session().model, thinking: req.thinking ?? deps.session().thinking }),
|
|
187
|
+
createJob: (input, scope) => createLoopJob(host, input, scope),
|
|
188
|
+
// The cron_control_plane audit has no session to live in here; /triggers audit shows it instead.
|
|
189
|
+
cronControlAudit: (op, actor, before, after) => {
|
|
190
|
+
const job = after ?? before;
|
|
191
|
+
const id = newId("audit");
|
|
192
|
+
triggers.store.appendAudit({ cwd: req.cwd, type: "cron_control_plane", traceId: id, state: op, sourceLabel: actor, eventLabel: job?.id, summary: job ? `${job.name ?? job.id}: ${job.prompt}` : undefined, details: { before_enabled: before?.enabled, after_enabled: after?.enabled } });
|
|
193
|
+
log(`cron control plane: ${op} by ${actor} ${job?.id ?? ""}`);
|
|
194
|
+
return id;
|
|
195
|
+
},
|
|
196
|
+
// Nobody can approve here: Prompt-class operations are denied fail-closed.
|
|
197
|
+
confirmTool: async (_ctx, request, atHop) => controlPlanePreflight({ hop: Math.max(1, atHop), hasUI: false }, request.label),
|
|
198
|
+
refreshBadge: () => undefined,
|
|
199
|
+
};
|
|
200
|
+
return host;
|
|
201
|
+
};
|
|
202
|
+
|
|
203
|
+
return {
|
|
204
|
+
scheduler,
|
|
205
|
+
triggers,
|
|
206
|
+
customTools: async (req) => {
|
|
207
|
+
const shared = deps.mcpTools();
|
|
208
|
+
const automation = automationTools({ hop: req.hop, actor: "sub-agent", parentSessionId: req.parentSessionId, parentCwd: req.parentCwd }, toolHostFor(req));
|
|
209
|
+
const taken = new Set([...PI_BUILTIN_TOOL_NAMES, ...shared.map((t) => t.name), ...automation.map((t) => t.name)]);
|
|
210
|
+
const project = req.cwd ? ((await deps.projectMcpTools?.(req.cwd, taken)) ?? []) : [];
|
|
211
|
+
return [...shared, ...project, ...automation];
|
|
212
|
+
},
|
|
213
|
+
start: () => scheduler.start(),
|
|
214
|
+
stop: async () => {
|
|
215
|
+
await triggers.stop();
|
|
216
|
+
await scheduler.stop();
|
|
217
|
+
// The runs are over; their hooks may still be in flight. Same bounded wait as the
|
|
218
|
+
// interactive path, so a "the run failed" webhook survives the host being told to quit.
|
|
219
|
+
await Promise.all([...runHooks.values()].map((h) => h.drain(3000)));
|
|
220
|
+
},
|
|
221
|
+
};
|
|
222
|
+
}
|