@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
package/src/host.ts
ADDED
|
@@ -0,0 +1,329 @@
|
|
|
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.
|
|
8
|
+
*
|
|
9
|
+
* node --import <pkg>/src/register-pi.mjs <pkg>/src/host-entry.mjs (env: PI_LOOPS_DIR, PI_LOOPS_PI_PACKAGE)
|
|
10
|
+
*/
|
|
11
|
+
import * as fs from "node:fs";
|
|
12
|
+
import * as os from "node:os";
|
|
13
|
+
import * as path from "node:path";
|
|
14
|
+
import { fileURLToPath } from "node:url";
|
|
15
|
+
import { ModelRuntime, ProjectTrustStore, SettingsManager, getAgentDir, readStoredCredential, type ToolDefinition } from "@earendil-works/pi-coding-agent";
|
|
16
|
+
import type { Model } from "@earendil-works/pi-ai";
|
|
17
|
+
import { loadConfig } from "./config.ts";
|
|
18
|
+
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 { THINKING_LEVELS, thinkingLevelOrUndefined } from "./thinking.ts";
|
|
22
|
+
import { withFileLock } from "./lock.ts";
|
|
23
|
+
import { rotateInPlace } from "./log.ts";
|
|
24
|
+
import { createHostRuntime } from "./host-runtime.ts";
|
|
25
|
+
import { McpPool } from "./mcp-pool.ts";
|
|
26
|
+
import { McpSource, droppedNotificationMessage, loadMcpConfigFiles, mapNotification, mcpToolDefinitions, mcpTokenFromEnv } from "./mcp.ts";
|
|
27
|
+
import { previewRedacted, redact } from "./redact.ts";
|
|
28
|
+
import { parentRuntimeFlags } from "./runner.ts";
|
|
29
|
+
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";
|
|
33
|
+
import { PI_LOOPS_VERSION } from "./version.ts";
|
|
34
|
+
|
|
35
|
+
const agentDir = getAgentDir();
|
|
36
|
+
const dir = process.env.PI_LOOPS_DIR || defaultLoopsDir(agentDir);
|
|
37
|
+
const PACKAGE_DIR = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
|
|
38
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
39
|
+
const logFile = path.join(dir, HOST_LOG);
|
|
40
|
+
const log = (msg: string) => {
|
|
41
|
+
const line = `${stamp()} ${redact(msg)}\n`;
|
|
42
|
+
try {
|
|
43
|
+
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.
|
|
47
|
+
rotateInPlace(logFile);
|
|
48
|
+
} catch {
|
|
49
|
+
process.stderr.write(line);
|
|
50
|
+
}
|
|
51
|
+
};
|
|
52
|
+
|
|
53
|
+
let config = loadConfig(dir);
|
|
54
|
+
/**
|
|
55
|
+
* Every config error this host has already said, so the 60-second re-read below can say a *new* one
|
|
56
|
+
* without repeating the old ones every minute. An error introduced by editing `config.toml` while
|
|
57
|
+
* the host is up was silently ignored for the rest of its life: the setting fell back to its
|
|
58
|
+
* default, and the only record of why was in a file nobody re-read.
|
|
59
|
+
*/
|
|
60
|
+
const saidConfigErrors = new Set<string>();
|
|
61
|
+
function logConfigErrors(): void {
|
|
62
|
+
for (const e of config.errors) {
|
|
63
|
+
if (saidConfigErrors.has(e)) continue;
|
|
64
|
+
saidConfigErrors.add(e);
|
|
65
|
+
log(`config: ${e}`);
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
logConfigErrors();
|
|
69
|
+
|
|
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".
|
|
72
|
+
const settings = SettingsManager.create(os.homedir(), agentDir, { projectTrusted: false });
|
|
73
|
+
const settingsModel = settings.getDefaultProvider() && settings.getDefaultModel() ? `${settings.getDefaultProvider()}/${settings.getDefaultModel()}` : undefined;
|
|
74
|
+
// The environment is a boundary: `PI_LOOPS_HOST_THINKING` is whatever the process that spawned this
|
|
75
|
+
// one had, and it goes straight into every model call. An unknown level is dropped out loud rather
|
|
76
|
+
// than cast to one, so a provider rejecting "hgih" is not the first anyone hears of it.
|
|
77
|
+
const envThinking = process.env.PI_LOOPS_HOST_THINKING;
|
|
78
|
+
const handedThinking = thinkingLevelOrUndefined(envThinking);
|
|
79
|
+
if (envThinking && !handedThinking) log(`ignoring PI_LOOPS_HOST_THINKING=${envThinking}: not a thinking level (${THINKING_LEVELS.join(", ")})`);
|
|
80
|
+
const session: SessionSnapshot = { cwd: "", model: process.env.PI_LOOPS_HOST_MODEL || settingsModel, thinking: handedThinking || settings.getDefaultThinkingLevel(), trusted: false };
|
|
81
|
+
async function defaultModel(): Promise<Model<any> | undefined> {
|
|
82
|
+
if (!session.model) return undefined;
|
|
83
|
+
const rt = await ModelRuntime.create();
|
|
84
|
+
const [provider, ...rest] = session.model.split("/");
|
|
85
|
+
return rt.getModel(provider, rest.join("/")) ?? undefined;
|
|
86
|
+
}
|
|
87
|
+
let parentModel: Model<any> | undefined;
|
|
88
|
+
|
|
89
|
+
/* ---------------------------------------------------------------- MCP */
|
|
90
|
+
|
|
91
|
+
const mcpLoaded = loadMcpConfigFiles({ dir, projectTrusted: false });
|
|
92
|
+
for (const d of mcpLoaded.diagnostics) log(`mcp: ${d}`);
|
|
93
|
+
const mcpSources: McpSource[] = [];
|
|
94
|
+
const mcpToolDefs = new Map<string, ToolDefinition<any, any>[]>();
|
|
95
|
+
const mcpToolNames = new Map<string, string[]>();
|
|
96
|
+
const resolveMcpToken = (ref: string): string | undefined => {
|
|
97
|
+
const fromEnv = mcpTokenFromEnv(ref);
|
|
98
|
+
if (fromEnv) return fromEnv;
|
|
99
|
+
try {
|
|
100
|
+
const cred: any = readStoredCredential(ref);
|
|
101
|
+
if (cred && typeof cred === "object") return cred.key ?? cred.access ?? cred.token;
|
|
102
|
+
if (typeof cred === "string") return cred;
|
|
103
|
+
} catch {
|
|
104
|
+
/* no store */
|
|
105
|
+
}
|
|
106
|
+
return undefined;
|
|
107
|
+
};
|
|
108
|
+
|
|
109
|
+
/* ------------------------------------------------------------ runtime */
|
|
110
|
+
|
|
111
|
+
const runner = createInProcessRunner({
|
|
112
|
+
agentDir,
|
|
113
|
+
parentFlags: parentRuntimeFlags([]),
|
|
114
|
+
getParentModel: () => parentModel,
|
|
115
|
+
getParentThinking: () => session.thinking,
|
|
116
|
+
customTools: (req) => host.customTools(req),
|
|
117
|
+
// Only projects the user trusted before (pi's saved decisions); the host never trusts on its own.
|
|
118
|
+
// Exact trust only (src/trust.ts): nobody is watching what a job's cwd points at.
|
|
119
|
+
isTrusted: (cwd) => isExactlyTrusted(agentDir, cwd),
|
|
120
|
+
ownDir: PACKAGE_DIR,
|
|
121
|
+
allowCommands: () => config.allowCommands,
|
|
122
|
+
// The daily cap, read while a run is in flight and not only before it is dispatched. Lazy: the
|
|
123
|
+
// host owns the scheduler and is built with this runner, so it can only be asked at run time.
|
|
124
|
+
budget: () => host.scheduler.budgetState(),
|
|
125
|
+
log: (msg) => log(`sub-agent: ${msg}`),
|
|
126
|
+
});
|
|
127
|
+
/** 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}`) });
|
|
129
|
+
const host = createHostRuntime({
|
|
130
|
+
dir,
|
|
131
|
+
config: () => config,
|
|
132
|
+
session: () => session,
|
|
133
|
+
runner,
|
|
134
|
+
mcpTools: () => [...mcpToolDefs.values()].flat(),
|
|
135
|
+
projectMcpTools: (cwd, taken) => mcpPool.toolsFor(cwd, taken),
|
|
136
|
+
// Project-local hooks.toml: the same exact-trust rule as a project's MCP servers and tools.
|
|
137
|
+
isProjectTrusted: (cwd) => isExactlyTrusted(agentDir, cwd),
|
|
138
|
+
log,
|
|
139
|
+
exit: (code) => shutdown(code),
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
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();
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/* ------------------------------------------------------------ lifecycle */
|
|
180
|
+
|
|
181
|
+
let stopping = false;
|
|
182
|
+
async function shutdown(code: number): Promise<void> {
|
|
183
|
+
if (stopping) return;
|
|
184
|
+
stopping = true;
|
|
185
|
+
// Whatever hangs (a lock, an MCP server that will not die), the process leaves.
|
|
186
|
+
setTimeout(() => process.exit(code), 10_000).unref();
|
|
187
|
+
try {
|
|
188
|
+
await host.stop();
|
|
189
|
+
await mcpPool.stopAll();
|
|
190
|
+
await Promise.all(mcpSources.map((s) => s.stop()));
|
|
191
|
+
} catch (err: any) {
|
|
192
|
+
log(`shutdown: ${err?.message ?? err}`);
|
|
193
|
+
}
|
|
194
|
+
try {
|
|
195
|
+
channel?.close();
|
|
196
|
+
fs.rmSync(hostSocketPath(dir), { force: true });
|
|
197
|
+
} catch {
|
|
198
|
+
/* going away anyway */
|
|
199
|
+
}
|
|
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})`);
|
|
203
|
+
process.exit(code);
|
|
204
|
+
}
|
|
205
|
+
// `shutdown` stops stores, MCP servers and the scheduler; any of them can reject, and the process
|
|
206
|
+
// is on its way out — so the exit does not depend on the shutdown succeeding.
|
|
207
|
+
const leave = (code: number) => void shutdown(code).catch((err: any) => {
|
|
208
|
+
log(`shutdown failed: ${err?.message ?? err}`);
|
|
209
|
+
process.exit(code);
|
|
210
|
+
});
|
|
211
|
+
process.on("SIGTERM", () => leave(0));
|
|
212
|
+
process.on("SIGINT", () => leave(0));
|
|
213
|
+
process.on("uncaughtException", (err) => {
|
|
214
|
+
log(`uncaught: ${redact(String(err?.stack ?? err))}`);
|
|
215
|
+
leave(1);
|
|
216
|
+
});
|
|
217
|
+
process.on("unhandledRejection", (err: any) => log(`unhandled rejection: ${redact(String(err?.stack ?? err))}`));
|
|
218
|
+
|
|
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;
|
|
227
|
+
});
|
|
228
|
+
if (!claimed) {
|
|
229
|
+
log(`another host is already running (pid ${readHost(dir)?.pid}); exiting`);
|
|
230
|
+
process.exit(0);
|
|
231
|
+
}
|
|
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();
|
|
235
|
+
/**
|
|
236
|
+
* A control channel that cannot be opened is a degraded host, not a dead one: `pi-loops host
|
|
237
|
+
* status` falls back to the recorded pid, and the loops keep running. This is the top level of the
|
|
238
|
+
* process, so a throw here would take the host down at startup instead.
|
|
239
|
+
*/
|
|
240
|
+
function serveHostChannelSafely(...args: Parameters<typeof serveHostChannel>): ReturnType<typeof serveHostChannel> | undefined {
|
|
241
|
+
try {
|
|
242
|
+
return serveHostChannel(...args);
|
|
243
|
+
} catch (err: any) {
|
|
244
|
+
log(`control channel unavailable: ${err?.message ?? err}`);
|
|
245
|
+
return undefined;
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
const channel = serveHostChannelSafely(
|
|
250
|
+
dir,
|
|
251
|
+
{
|
|
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
|
+
},
|
|
296
|
+
abortCheck: (traceId) => {
|
|
297
|
+
const match = host.triggers.runningList().find((r) => r.traceId === traceId || r.traceId.startsWith(traceId));
|
|
298
|
+
return match ? host.triggers.abort(match.traceId) : false;
|
|
299
|
+
},
|
|
300
|
+
stop: () => leave(0),
|
|
301
|
+
},
|
|
302
|
+
log,
|
|
303
|
+
);
|
|
304
|
+
parentModel = await defaultModel().catch((err) => {
|
|
305
|
+
log(`default model: ${err?.message ?? err}`);
|
|
306
|
+
return undefined;
|
|
307
|
+
});
|
|
308
|
+
log(`default model: ${parentModel ? `${parentModel.provider}/${parentModel.id}` : "none (jobs must pin one)"}`);
|
|
309
|
+
startMcpSources();
|
|
310
|
+
host.start();
|
|
311
|
+
// The scheduler's timer is unref'd (an extension must never keep pi alive); the host wants to live,
|
|
312
|
+
// and re-reads config.toml so edits take effect without a restart.
|
|
313
|
+
setInterval(() => {
|
|
314
|
+
config = loadConfig(dir);
|
|
315
|
+
logConfigErrors();
|
|
316
|
+
host.triggers.pollIntervalSecs = config.triggerPollIntervalSecs;
|
|
317
|
+
host.triggers.runTimeoutMs = config.triggerRunTimeoutMs;
|
|
318
|
+
// 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
|
|
320
|
+
// otherwise never reach it — `parentModel` was resolved once, at startup. Only an improvement is
|
|
321
|
+
// taken: a model that stops resolving leaves the one the host already has in place.
|
|
322
|
+
void defaultModel()
|
|
323
|
+
.then((model) => {
|
|
324
|
+
if (!model || (parentModel && parentModel.provider === model.provider && parentModel.id === model.id)) return;
|
|
325
|
+
parentModel = model;
|
|
326
|
+
log(`default model: ${model.provider}/${model.id}`);
|
|
327
|
+
})
|
|
328
|
+
.catch((err: any) => log(`default model: ${err?.message ?? err}`));
|
|
329
|
+
}, 60_000);
|
package/src/inbox.ts
ADDED
|
@@ -0,0 +1,232 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The triage inbox — the routing layer. Global JSONL shared by every session and
|
|
3
|
+
* project: loops run wherever they were created; the inbox is what you open in
|
|
4
|
+
* the morning. Findings are one-liners with a new → claimed/dismissed lifecycle.
|
|
5
|
+
*
|
|
6
|
+
* Appends are line-atomic; status rewrites are serialized through a file lock.
|
|
7
|
+
* Corrupt lines are skipped on read, never deleted.
|
|
8
|
+
*/
|
|
9
|
+
import * as fs from "node:fs";
|
|
10
|
+
import * as path from "node:path";
|
|
11
|
+
import { withFileLock, writeFileAtomic } from "./lock.ts";
|
|
12
|
+
import { INBOX_TEXT_MAX_CHARS, capChars } from "./protocol.ts";
|
|
13
|
+
import { newId } from "./store.ts";
|
|
14
|
+
import { stamp } from "./schedule.ts";
|
|
15
|
+
|
|
16
|
+
export type InboxStatus = "new" | "claimed" | "dismissed";
|
|
17
|
+
|
|
18
|
+
/** Past this the inbox drops its oldest triaged entries (docs/configuration.md). */
|
|
19
|
+
export const INBOX_ROTATE_BYTES = 1_000_000;
|
|
20
|
+
|
|
21
|
+
export interface InboxEntry {
|
|
22
|
+
id: string;
|
|
23
|
+
createdAt: string;
|
|
24
|
+
/** Bounded origin label, e.g. "cron:check-issues" or "cron:cron-1a2b3c4d". */
|
|
25
|
+
source: string;
|
|
26
|
+
text: string;
|
|
27
|
+
runId: string;
|
|
28
|
+
jobId: string;
|
|
29
|
+
cwd: string;
|
|
30
|
+
/** Session that owned the loop when it reported; loops are machine-global here. */
|
|
31
|
+
sessionId?: string;
|
|
32
|
+
status: InboxStatus;
|
|
33
|
+
claimedBy?: string;
|
|
34
|
+
/** true = passed the checker; false = checker dropped it (never appended); undefined = no checker / unreviewed. */
|
|
35
|
+
verified?: boolean;
|
|
36
|
+
/** Checker's reason when it kept a finding, if it gave one. */
|
|
37
|
+
verifiedReason?: string;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export class Inbox {
|
|
41
|
+
readonly file: string;
|
|
42
|
+
private readonly lockPath: string;
|
|
43
|
+
|
|
44
|
+
constructor(dir: string) {
|
|
45
|
+
this.file = path.join(dir, "inbox.jsonl");
|
|
46
|
+
this.lockPath = path.join(dir, "inbox.lock");
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
async append(entry: Omit<InboxEntry, "id" | "createdAt" | "status">): Promise<InboxEntry> {
|
|
50
|
+
const full: InboxEntry = {
|
|
51
|
+
id: newId("inb"), // inb-<32 hex>, the shape every other id in pi-loops has
|
|
52
|
+
createdAt: stamp(),
|
|
53
|
+
...entry,
|
|
54
|
+
source: capChars(entry.source, 80),
|
|
55
|
+
text: capChars(entry.text.replace(/\s+/g, " "), INBOX_TEXT_MAX_CHARS),
|
|
56
|
+
status: "new",
|
|
57
|
+
};
|
|
58
|
+
fs.mkdirSync(path.dirname(this.file), { recursive: true });
|
|
59
|
+
// Under the same lock as the triage rewrites: findings are appended by every pi window, the
|
|
60
|
+
// headless host and up to `max_concurrent_runs` loop runs, while `/inbox dismiss|clear`
|
|
61
|
+
// rewrites the whole file — an append landing mid-rewrite is a finding nobody ever sees.
|
|
62
|
+
// The lock is awaited, never spun on: a leftover lock directory from a killed process would
|
|
63
|
+
// otherwise block this process's event loop for the whole stale window.
|
|
64
|
+
await withFileLock(this.lockPath, () => {
|
|
65
|
+
fs.appendFileSync(this.file, `${JSON.stringify(toDisk(full))}\n`, "utf8");
|
|
66
|
+
this.rotate();
|
|
67
|
+
});
|
|
68
|
+
return full;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Past `INBOX_ROTATE_BYTES`, drop the oldest already-triaged entries (`claimed`/`dismissed`) —
|
|
73
|
+
* never anything still `new`, which is the whole point of the inbox, and the reason this is not the
|
|
74
|
+
* blind halving the run log and the audit use. This file was the one that grew forever, and
|
|
75
|
+
* `newCount()` re-parses it on every badge refresh. Caller holds the lock.
|
|
76
|
+
*/
|
|
77
|
+
private rotate(): void {
|
|
78
|
+
try {
|
|
79
|
+
if (fs.statSync(this.file).size < INBOX_ROTATE_BYTES) return;
|
|
80
|
+
const entries = this.list();
|
|
81
|
+
const triaged = entries.filter((e) => e.status !== "new");
|
|
82
|
+
const drop = Math.floor(triaged.length / 2);
|
|
83
|
+
// Nothing triaged to drop means an inbox full of unread findings: rewriting it would
|
|
84
|
+
// change nothing and would cost a full read+write on every append from here on.
|
|
85
|
+
if (drop === 0) return;
|
|
86
|
+
const dropped = new Set(triaged.slice(0, drop).map((e) => e.id));
|
|
87
|
+
this.rewrite(entries.filter((e) => !dropped.has(e.id)));
|
|
88
|
+
} catch {
|
|
89
|
+
/* best effort: a rotation that fails must never lose the append that just succeeded */
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/** All entries, oldest first. Unparseable lines are skipped. */
|
|
94
|
+
list(): InboxEntry[] {
|
|
95
|
+
let text: string;
|
|
96
|
+
try {
|
|
97
|
+
text = fs.readFileSync(this.file, "utf8");
|
|
98
|
+
} catch (err: any) {
|
|
99
|
+
if (err?.code === "ENOENT") return [];
|
|
100
|
+
throw err;
|
|
101
|
+
}
|
|
102
|
+
const out: InboxEntry[] = [];
|
|
103
|
+
for (const line of text.split("\n")) {
|
|
104
|
+
if (!line.trim()) continue;
|
|
105
|
+
try {
|
|
106
|
+
const e = fromDisk(JSON.parse(line));
|
|
107
|
+
if (e && typeof e.id === "string" && typeof e.text === "string") out.push(e);
|
|
108
|
+
} catch {
|
|
109
|
+
/* skip corrupt line */
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
return out;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
listNew(): InboxEntry[] {
|
|
116
|
+
return this.list().filter((e) => e.status === "new");
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/** Count of new entries; 0 on any error (badge path must never throw). */
|
|
120
|
+
newCount(): number {
|
|
121
|
+
try {
|
|
122
|
+
return this.listNew().length;
|
|
123
|
+
} catch {
|
|
124
|
+
return 0;
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
async setStatus(id: string, status: InboxStatus, claimedBy?: string): Promise<InboxEntry | undefined> {
|
|
129
|
+
return withFileLock(this.lockPath, () => {
|
|
130
|
+
const entries = this.list();
|
|
131
|
+
const entry = entries.find((e) => e.id === id);
|
|
132
|
+
if (!entry) return undefined;
|
|
133
|
+
entry.status = status;
|
|
134
|
+
if (claimedBy) entry.claimedBy = claimedBy;
|
|
135
|
+
this.rewrite(entries);
|
|
136
|
+
return entry;
|
|
137
|
+
});
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/** `match` limits it to what the caller listed — `/inbox clear` must not dismiss findings it did not show. */
|
|
141
|
+
async dismissAllNew(match?: (entry: InboxEntry) => boolean): Promise<number> {
|
|
142
|
+
return withFileLock(this.lockPath, () => {
|
|
143
|
+
const entries = this.list();
|
|
144
|
+
let changed = 0;
|
|
145
|
+
for (const e of entries) {
|
|
146
|
+
if (e.status === "new" && (!match || match(e))) {
|
|
147
|
+
e.status = "dismissed";
|
|
148
|
+
changed++;
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
if (changed) this.rewrite(entries);
|
|
152
|
+
return changed;
|
|
153
|
+
});
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
private rewrite(entries: InboxEntry[]): void {
|
|
157
|
+
writeFileAtomic(this.file, entries.map((e) => JSON.stringify(toDisk(e))).join("\n") + (entries.length ? "\n" : ""));
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/**
|
|
162
|
+
* On disk the record shape is `{id, created_at, source, text, trace_id, session_id,
|
|
163
|
+
* status}` — plus pi-loops' extras (`job_id`, `cwd`, `claimed_by`, `verified`, `verified_reason`),
|
|
164
|
+
* so a reader of that shape can consume it. Lines written by pi-loops ≤ 0.1.2 (camelCase)
|
|
165
|
+
* are still understood.
|
|
166
|
+
*/
|
|
167
|
+
function toDisk(e: InboxEntry): Record<string, unknown> {
|
|
168
|
+
return {
|
|
169
|
+
id: e.id,
|
|
170
|
+
created_at: e.createdAt,
|
|
171
|
+
source: e.source,
|
|
172
|
+
text: e.text,
|
|
173
|
+
trace_id: e.runId,
|
|
174
|
+
session_id: e.sessionId ?? null,
|
|
175
|
+
status: e.status,
|
|
176
|
+
job_id: e.jobId,
|
|
177
|
+
cwd: e.cwd,
|
|
178
|
+
...(e.claimedBy !== undefined ? { claimed_by: e.claimedBy } : {}),
|
|
179
|
+
...(e.verified !== undefined ? { verified: e.verified } : {}),
|
|
180
|
+
...(e.verifiedReason !== undefined ? { verified_reason: e.verifiedReason } : {}),
|
|
181
|
+
};
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
function fromDisk(raw: any): InboxEntry | undefined {
|
|
185
|
+
if (!raw || typeof raw !== "object") return undefined;
|
|
186
|
+
const pick = <T>(...keys: string[]): T | undefined => {
|
|
187
|
+
for (const k of keys) if (raw[k] !== undefined && raw[k] !== null) return raw[k] as T;
|
|
188
|
+
return undefined;
|
|
189
|
+
};
|
|
190
|
+
const status = pick<string>("status");
|
|
191
|
+
return {
|
|
192
|
+
id: String(raw.id ?? ""),
|
|
193
|
+
createdAt: pick<string>("created_at", "createdAt") ?? "",
|
|
194
|
+
source: pick<string>("source") ?? "",
|
|
195
|
+
text: String(raw.text ?? ""),
|
|
196
|
+
runId: pick<string>("trace_id", "runId") ?? "",
|
|
197
|
+
jobId: pick<string>("job_id", "jobId") ?? "",
|
|
198
|
+
cwd: pick<string>("cwd") ?? "",
|
|
199
|
+
sessionId: pick<string>("session_id", "sessionId"),
|
|
200
|
+
status: status === "claimed" || status === "dismissed" ? status : "new",
|
|
201
|
+
claimedBy: pick<string>("claimed_by", "claimedBy"),
|
|
202
|
+
verified: pick<boolean>("verified"),
|
|
203
|
+
verifiedReason: pick<string>("verified_reason", "verifiedReason"),
|
|
204
|
+
};
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
/**
|
|
208
|
+
* The findings that belong to one project, in list order. The inbox is machine-wide because loops
|
|
209
|
+
* are; triage is not — a finding about another repository read in this one is guesswork, and
|
|
210
|
+
* claiming it runs it in the wrong directory. `/inbox` lists these the way `/cron` lists this
|
|
211
|
+
* project's jobs, with `--all` for the machine.
|
|
212
|
+
*
|
|
213
|
+
* `sameProject` is the caller's test (a worktree, a symlink or a subdirectory is the same project).
|
|
214
|
+
* A finding written without a cwd belongs to no project and is never hidden by the filter.
|
|
215
|
+
*/
|
|
216
|
+
export function inProject(entries: InboxEntry[], cwd: string, sameProject: (a: string, b: string) => boolean): InboxEntry[] {
|
|
217
|
+
return entries.filter((e) => belongsToProject(e, cwd, sameProject));
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
/** The same test for one finding — `/inbox clear` dismisses exactly what `/inbox` listed. */
|
|
221
|
+
export function belongsToProject(entry: InboxEntry, cwd: string, sameProject: (a: string, b: string) => boolean): boolean {
|
|
222
|
+
return !entry.cwd || sameProject(entry.cwd, cwd);
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
/** Resolve "<n>" (1-based in the new list) or an id / unique id prefix. */
|
|
226
|
+
export function resolveInboxRef(entries: InboxEntry[], ref: string): InboxEntry | undefined {
|
|
227
|
+
const trimmed = ref.trim();
|
|
228
|
+
if (!trimmed) return undefined;
|
|
229
|
+
if (/^\d+$/.test(trimmed)) return entries[Number(trimmed) - 1];
|
|
230
|
+
const matches = entries.filter((e) => e.id === trimmed || e.id.startsWith(trimmed));
|
|
231
|
+
return matches.length === 1 ? matches[0] : undefined;
|
|
232
|
+
}
|
package/src/job-edit.ts
ADDED
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* What `/cron set` decides, as a function instead of a closure.
|
|
3
|
+
*
|
|
4
|
+
* The command handler lives inside the extension's default export, where nothing can import it, so
|
|
5
|
+
* everything it decided was covered by reading rather than by tests — and what it decides is not
|
|
6
|
+
* obvious: which stamp to anchor when a schedule changes, whether the job is now due at once,
|
|
7
|
+
* whether an expression that parses will ever match. This module holds those decisions; the
|
|
8
|
+
* handler is left with reading arguments, writing the store and printing.
|
|
9
|
+
*
|
|
10
|
+
* It returns a *patch*, not a whole job, on purpose. `JobStore.update` re-reads the job under a
|
|
11
|
+
* lock, so a tick that started a run between the read and the write has already set `running` —
|
|
12
|
+
* assigning a whole job built from a stale copy would erase it.
|
|
13
|
+
*/
|
|
14
|
+
import { computeDue, computeNext, formatSchedule, stamp, type Schedule } from "./schedule.ts";
|
|
15
|
+
import type { LoopJob } from "./store.ts";
|
|
16
|
+
|
|
17
|
+
export interface JobEdit {
|
|
18
|
+
model?: string | null;
|
|
19
|
+
thinking?: string | null;
|
|
20
|
+
timeoutMs?: number | null;
|
|
21
|
+
name?: string | null;
|
|
22
|
+
/** "here" pins the job to this machine; null lets any machine run it. */
|
|
23
|
+
host?: string | null;
|
|
24
|
+
prompt?: string;
|
|
25
|
+
schedule?: Schedule;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** When the job runs next, for the confirmation. `due` means the next tick will fire it. */
|
|
29
|
+
export type NextRun = { kind: "disabled" } | { kind: "due" } | { kind: "at"; at: number } | { kind: "none" };
|
|
30
|
+
|
|
31
|
+
export interface AppliedJobEdit {
|
|
32
|
+
/** Exactly the fields to write. Anything absent must keep whatever the store has. */
|
|
33
|
+
patch: Partial<LoopJob>;
|
|
34
|
+
/** One line per field a person would not otherwise be able to recover, for the log. */
|
|
35
|
+
changed: string[];
|
|
36
|
+
nextRun: NextRun;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export interface JobEditContext {
|
|
40
|
+
now: number;
|
|
41
|
+
hostName: string;
|
|
42
|
+
maxPromptBytes: number;
|
|
43
|
+
/** Called with the requested name and the other jobs; throws if it is not usable as a reference. */
|
|
44
|
+
checkName: (name: string, others: LoopJob[]) => void;
|
|
45
|
+
/** Every other job, for the name check. The job being edited must not be in here. */
|
|
46
|
+
others: LoopJob[];
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Apply `edit` to `job`. Throws — leaving the caller nothing to write — when the result would be a
|
|
51
|
+
* job that cannot run or cannot be referred to.
|
|
52
|
+
*/
|
|
53
|
+
export function applyJobEdit(job: LoopJob, edit: JobEdit, ctx: JobEditContext): AppliedJobEdit {
|
|
54
|
+
if (edit.prompt !== undefined) {
|
|
55
|
+
if (!edit.prompt.trim()) throw new Error("cron action cannot be empty");
|
|
56
|
+
if (Buffer.byteLength(edit.prompt, "utf8") > ctx.maxPromptBytes) throw new Error(`cron action exceeds ${ctx.maxPromptBytes} bytes`);
|
|
57
|
+
}
|
|
58
|
+
if (edit.name) ctx.checkName(edit.name, ctx.others);
|
|
59
|
+
// A one-shot is spent by running, and the scheduler deletes it afterwards — which would take the
|
|
60
|
+
// loop's notes with it. That is the opposite of why editing in place exists.
|
|
61
|
+
if (edit.schedule?.kind === "once") throw new Error("a job cannot be changed to a one-shot schedule; /cron run <id> runs it now instead");
|
|
62
|
+
|
|
63
|
+
const patch: Partial<LoopJob> = {};
|
|
64
|
+
const changed: string[] = [];
|
|
65
|
+
if (edit.model !== undefined) patch.model = edit.model ?? undefined;
|
|
66
|
+
if (edit.thinking !== undefined) patch.thinking = edit.thinking ?? undefined;
|
|
67
|
+
if (edit.timeoutMs !== undefined) patch.timeoutMs = edit.timeoutMs ?? undefined;
|
|
68
|
+
if (edit.name !== undefined) patch.name = edit.name ?? undefined;
|
|
69
|
+
if (edit.host !== undefined) patch.host = edit.host === "here" ? ctx.hostName : undefined;
|
|
70
|
+
if (edit.prompt !== undefined) {
|
|
71
|
+
patch.prompt = edit.prompt;
|
|
72
|
+
// The old wording is otherwise unrecoverable, and it is what ties "this loop started
|
|
73
|
+
// behaving differently" to the edit that caused it. Only when it actually changed.
|
|
74
|
+
if (edit.prompt !== job.prompt) changed.push(`prompt changed from ${JSON.stringify(job.prompt)} to ${JSON.stringify(edit.prompt)}`);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
const schedule = edit.schedule ?? job.schedule;
|
|
78
|
+
const createdAt = Date.parse(job.createdAt);
|
|
79
|
+
const lastFiredAt = job.lastFiredAt ? Date.parse(job.lastFiredAt) : undefined;
|
|
80
|
+
if (edit.schedule !== undefined) {
|
|
81
|
+
// An expression can parse and still never match ("0 0 30 2 *"): the job would go quiet with
|
|
82
|
+
// nothing to see. Refuse it, so the caller writes nothing.
|
|
83
|
+
if (computeNext({ schedule, createdAt, lastFiredAt }, ctx.now) === undefined) {
|
|
84
|
+
throw new Error(`${formatSchedule(edit.schedule)} has no next run; the job is unchanged`);
|
|
85
|
+
}
|
|
86
|
+
patch.schedule = edit.schedule;
|
|
87
|
+
// A cron job owes every slot its expression matched since `lastDueAt`, so moving a daily job
|
|
88
|
+
// to "*/5 * * * *" at noon would owe a run at once — for a slot that only existed
|
|
89
|
+
// retroactively. Restart the clock at the edit; the first run under the new expression is its
|
|
90
|
+
// next one. An `every <dur>` job is measured from `lastFiredAt`, which is real bookkeeping and
|
|
91
|
+
// is left alone: "every 30m" means at most 30 minutes apart, so one that last ran an hour ago
|
|
92
|
+
// is genuinely overdue and should fire on the next tick.
|
|
93
|
+
patch.lastDueAt = stamp(ctx.now);
|
|
94
|
+
changed.push(`schedule changed from ${formatSchedule(job.schedule)} to ${formatSchedule(edit.schedule)}`);
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
const merged = { ...job, ...patch };
|
|
98
|
+
return { patch, changed, nextRun: nextRunOf(merged, ctx.now) };
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/** Read the job back exactly as the scheduler will, so the confirmation cannot promise a fiction. */
|
|
102
|
+
export function nextRunOf(job: LoopJob, now: number): NextRun {
|
|
103
|
+
if (!job.enabled) return { kind: "disabled" };
|
|
104
|
+
const input = {
|
|
105
|
+
schedule: job.schedule,
|
|
106
|
+
createdAt: Date.parse(job.createdAt),
|
|
107
|
+
lastDueAt: job.lastDueAt ? Date.parse(job.lastDueAt) : undefined,
|
|
108
|
+
lastFiredAt: job.lastFiredAt ? Date.parse(job.lastFiredAt) : undefined,
|
|
109
|
+
};
|
|
110
|
+
if (computeDue(input, now) !== undefined) return { kind: "due" };
|
|
111
|
+
const at = computeNext(input, now);
|
|
112
|
+
return at === undefined ? { kind: "none" } : { kind: "at", at };
|
|
113
|
+
}
|