@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/hooks.ts
ADDED
|
@@ -0,0 +1,436 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Lifecycle hooks: user-configured shell commands and JSON
|
|
3
|
+
* webhooks that fire on agent events. Best-effort side effects: they never mutate
|
|
4
|
+
* agent state and failures never fail a turn.
|
|
5
|
+
*
|
|
6
|
+
* ~/.pi/agent/loops/hooks.toml user hooks (top-level `allow_project_hooks = true` opts project hooks in)
|
|
7
|
+
* <project>/.pi/hooks.toml project hooks (ignored unless allowed)
|
|
8
|
+
*/
|
|
9
|
+
import { spawn } from "node:child_process";
|
|
10
|
+
import { randomUUID } from "node:crypto";
|
|
11
|
+
import * as fs from "node:fs";
|
|
12
|
+
import * as os from "node:os";
|
|
13
|
+
import * as path from "node:path";
|
|
14
|
+
import { envFlag } from "./config.ts";
|
|
15
|
+
import { previewRedacted } from "./redact.ts";
|
|
16
|
+
import { parseToml } from "./toml.ts";
|
|
17
|
+
import { PI_LOOPS_VERSION } from "./version.ts";
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* `run_start` / `run_end` exist because a scheduled run is not a turn. A run can happen with no
|
|
21
|
+
* conversation at all (the headless host) or beside one (a loop firing while you are typing), so
|
|
22
|
+
* overloading `agent_*` would mean a rule written about your own turns quietly started firing for
|
|
23
|
+
* automation too. A run gets its own pair, and `agent_*` keeps meaning what its author thought it
|
|
24
|
+
* meant.
|
|
25
|
+
*/
|
|
26
|
+
export const HOOK_EVENTS = ["agent_start", "agent_end", "run_start", "run_end", "turn_start", "turn_end", "message_start", "message_update", "message_end", "tool_start", "tool_update", "tool_end", "compaction"] as const;
|
|
27
|
+
export type HookEvent = (typeof HOOK_EVENTS)[number];
|
|
28
|
+
|
|
29
|
+
const DEFAULT_TIMEOUT_MS = 5000;
|
|
30
|
+
const MAX_SUMMARY_CHARS = 2000;
|
|
31
|
+
/** What a hook may put in the log per run. The same bound stderr has used for the failure message. */
|
|
32
|
+
const MAX_OUTPUT_CHARS = 4000;
|
|
33
|
+
|
|
34
|
+
export interface HookConfig {
|
|
35
|
+
event: HookEvent;
|
|
36
|
+
command?: string;
|
|
37
|
+
webhook?: string;
|
|
38
|
+
timeoutMs: number;
|
|
39
|
+
/** Where the command runs: `project` is the project directory, `loops` the pi-loops data directory, `home` the user's home. */
|
|
40
|
+
cwd: "project" | "loops" | "home";
|
|
41
|
+
onFailure: "warn" | "ignore";
|
|
42
|
+
tool?: string;
|
|
43
|
+
headers?: Record<string, string>;
|
|
44
|
+
source: "user" | "project";
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** The payload (webhook body and `$PI_HOOK_PAYLOAD` file). */
|
|
48
|
+
export interface HookPayload {
|
|
49
|
+
event: HookEvent;
|
|
50
|
+
session_id: string;
|
|
51
|
+
cwd: string;
|
|
52
|
+
model_provider: string;
|
|
53
|
+
model_id: string;
|
|
54
|
+
thinking_level: string;
|
|
55
|
+
source?: "user" | "project";
|
|
56
|
+
message_kind?: string | null;
|
|
57
|
+
message_summary?: string | null;
|
|
58
|
+
assistant_event?: string | null;
|
|
59
|
+
tool_call_id?: string | null;
|
|
60
|
+
tool_name?: string | null;
|
|
61
|
+
tool_is_error?: boolean | null;
|
|
62
|
+
tool_args?: unknown | null;
|
|
63
|
+
tool_result_summary?: string | null;
|
|
64
|
+
compaction_trigger?: "auto" | "manual" | null;
|
|
65
|
+
compaction_tokens_before?: number | null;
|
|
66
|
+
compaction_summary?: string | null;
|
|
67
|
+
/** `run_start` / `run_end`: which job, and how it went. Null for every other event. */
|
|
68
|
+
run_job?: string | null;
|
|
69
|
+
run_id?: string | null;
|
|
70
|
+
run_ok?: boolean | null;
|
|
71
|
+
run_findings?: number | null;
|
|
72
|
+
run_error?: string | null;
|
|
73
|
+
run_cost_usd?: number | null;
|
|
74
|
+
/**
|
|
75
|
+
* pi also reports a compaction that failed or was cancelled, and that
|
|
76
|
+
* is the case a watcher most wants — a session that cannot compact is a session about to fail
|
|
77
|
+
* on context length. It reaches the same `compaction` hook with this set, and no summary.
|
|
78
|
+
*/
|
|
79
|
+
compaction_failed?: boolean | null;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/** Event-specific fields; the runner fills in the session-level ones. */
|
|
83
|
+
export type HookEventData = Pick<HookPayload, "event" | "message_kind" | "message_summary" | "assistant_event" | "tool_call_id" | "tool_name" | "tool_is_error" | "tool_args" | "tool_result_summary" | "compaction_trigger" | "compaction_tokens_before" | "compaction_summary" | "compaction_failed" | "run_job" | "run_id" | "run_ok" | "run_findings" | "run_error" | "run_cost_usd">;
|
|
84
|
+
|
|
85
|
+
export interface ParsedHooksFile {
|
|
86
|
+
allowProjectHooks: boolean;
|
|
87
|
+
hooks: HookConfig[];
|
|
88
|
+
diagnostics: string[];
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/** Bad rules are skipped with a diagnostic, the rest still load. */
|
|
92
|
+
export function parseHooksToml(text: string, source: "user" | "project"): ParsedHooksFile {
|
|
93
|
+
const doc = parseToml(text);
|
|
94
|
+
const out: ParsedHooksFile = { allowProjectHooks: doc.allow_project_hooks === true, hooks: [], diagnostics: [] };
|
|
95
|
+
const hooks = Array.isArray(doc.hook) ? (doc.hook as any[]) : [];
|
|
96
|
+
hooks.forEach((h, i) => {
|
|
97
|
+
if (h?.enabled === false) return;
|
|
98
|
+
if (!h || typeof h.event !== "string" || !(HOOK_EVENTS as readonly string[]).includes(h.event)) {
|
|
99
|
+
out.diagnostics.push(`hooks ${source}: hook #${i + 1} has unknown event ${JSON.stringify(h?.event)}`);
|
|
100
|
+
return;
|
|
101
|
+
}
|
|
102
|
+
const command = typeof h.command === "string" && h.command.trim() ? h.command : undefined;
|
|
103
|
+
const webhook = typeof h.webhook === "string" ? h.webhook : undefined;
|
|
104
|
+
if (!command && !webhook) {
|
|
105
|
+
out.diagnostics.push(`hooks ${source}: hook #${i + 1} has neither command nor webhook`);
|
|
106
|
+
return;
|
|
107
|
+
}
|
|
108
|
+
const cwd = h.cwd ?? "project";
|
|
109
|
+
if (!["project", "loops", "home"].includes(cwd)) {
|
|
110
|
+
out.diagnostics.push(`hooks ${source}: hook #${i + 1} has invalid cwd ${JSON.stringify(h.cwd)} (project | loops | home)`);
|
|
111
|
+
return;
|
|
112
|
+
}
|
|
113
|
+
out.hooks.push({
|
|
114
|
+
event: h.event,
|
|
115
|
+
command,
|
|
116
|
+
webhook,
|
|
117
|
+
timeoutMs: typeof h.timeout_ms === "number" ? h.timeout_ms : DEFAULT_TIMEOUT_MS,
|
|
118
|
+
cwd,
|
|
119
|
+
onFailure: h.on_failure === "ignore" ? "ignore" : "warn",
|
|
120
|
+
tool: typeof h.tool === "string" ? h.tool : undefined,
|
|
121
|
+
headers: h.headers && typeof h.headers === "object" ? Object.fromEntries(Object.entries(h.headers).map(([k, v]) => [k, String(v)])) : undefined,
|
|
122
|
+
source,
|
|
123
|
+
});
|
|
124
|
+
});
|
|
125
|
+
return out;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
export interface HookRunnerOptions {
|
|
129
|
+
loopsDir: string;
|
|
130
|
+
projectCwd: string;
|
|
131
|
+
/** From config.toml; the user hooks.toml's own `allow_project_hooks` and PI_ALLOW_PROJECT_HOOKS also count. */
|
|
132
|
+
allowProjectHooks?: boolean;
|
|
133
|
+
warn: (message: string) => void;
|
|
134
|
+
/**
|
|
135
|
+
* Where a hook's own output goes (the per-process log, `logs/pi-<pid>.log`). Without it the
|
|
136
|
+
* usual debugging move — echo something and look at it — has nowhere to land, and that file is
|
|
137
|
+
* already the answer to "what did my automation do last night".
|
|
138
|
+
*/
|
|
139
|
+
log?: (message: string) => void;
|
|
140
|
+
getSession: () => { sessionId?: string; cwd: string; model?: string; thinking?: string };
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
export class HookRunner {
|
|
144
|
+
readonly hooks: HookConfig[] = [];
|
|
145
|
+
readonly diagnostics: string[] = [];
|
|
146
|
+
private readonly opts: HookRunnerOptions;
|
|
147
|
+
/** Hooks run in event order, one rule at a time — but never block the agent. */
|
|
148
|
+
private queue: Promise<void> = Promise.resolve();
|
|
149
|
+
|
|
150
|
+
constructor(opts: HookRunnerOptions) {
|
|
151
|
+
this.opts = opts;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
load(): void {
|
|
155
|
+
this.hooks.length = 0;
|
|
156
|
+
this.diagnostics.length = 0;
|
|
157
|
+
const userFile = path.join(this.opts.loopsDir, "hooks.toml");
|
|
158
|
+
const projectFile = path.join(this.opts.projectCwd, ".pi", "hooks.toml");
|
|
159
|
+
const user = this.readFile(userFile, "user");
|
|
160
|
+
const allowProject = envFlag("ALLOW_PROJECT_HOOKS") || !!this.opts.allowProjectHooks || !!user?.allowProjectHooks;
|
|
161
|
+
if (user) this.hooks.push(...user.hooks);
|
|
162
|
+
if (fs.existsSync(projectFile)) {
|
|
163
|
+
if (allowProject) {
|
|
164
|
+
const project = this.readFile(projectFile, "project");
|
|
165
|
+
if (project) this.hooks.push(...project.hooks);
|
|
166
|
+
} else {
|
|
167
|
+
this.diagnostics.push(`project hooks ignored at ${projectFile}; set allow_project_hooks = true in ${userFile} or PI_ALLOW_PROJECT_HOOKS=1`);
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
private readFile(file: string, source: "user" | "project"): ParsedHooksFile | undefined {
|
|
173
|
+
let text: string;
|
|
174
|
+
try {
|
|
175
|
+
text = fs.readFileSync(file, "utf8");
|
|
176
|
+
} catch (err: any) {
|
|
177
|
+
if (err?.code !== "ENOENT") this.diagnostics.push(`hooks ${source}: read ${file} failed: ${err?.message ?? err}`);
|
|
178
|
+
return undefined;
|
|
179
|
+
}
|
|
180
|
+
try {
|
|
181
|
+
const parsed = parseHooksToml(text, source);
|
|
182
|
+
this.diagnostics.push(...parsed.diagnostics);
|
|
183
|
+
return parsed;
|
|
184
|
+
} catch (err: any) {
|
|
185
|
+
this.diagnostics.push(`hooks ${source}: parse ${file} failed: ${err?.message ?? err}`);
|
|
186
|
+
return undefined;
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
hasHooksFor(event: HookEvent): boolean {
|
|
191
|
+
return this.hooks.some((h) => h.event === event);
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
/** Wait for queued hooks to finish; bounded so quitting never hangs. */
|
|
195
|
+
async drain(timeoutMs = 3000): Promise<boolean> {
|
|
196
|
+
let timer: NodeJS.Timeout | undefined;
|
|
197
|
+
const timeout = new Promise<false>((r) => {
|
|
198
|
+
timer = setTimeout(() => r(false), timeoutMs);
|
|
199
|
+
});
|
|
200
|
+
const done = this.queue.then(() => true as const, () => true as const);
|
|
201
|
+
const result = await Promise.race([done, timeout]);
|
|
202
|
+
if (timer) clearTimeout(timer);
|
|
203
|
+
return result;
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
/** Queue every matching rule for this event. Returns when they have all run (tests await it; callers may not). */
|
|
207
|
+
fire(data: HookEventData, signal?: AbortSignal): Promise<void> {
|
|
208
|
+
const matching = this.hooks.filter((h) => h.event === data.event && (!h.tool || h.tool === data.tool_name));
|
|
209
|
+
if (!matching.length) return this.queue;
|
|
210
|
+
const run = async () => {
|
|
211
|
+
for (const h of matching) {
|
|
212
|
+
if (signal?.aborted) return;
|
|
213
|
+
try {
|
|
214
|
+
// Inside the try: building the payload reads the session and the event, and a throw
|
|
215
|
+
// here would reject the shared queue promise — which callers deliberately do not
|
|
216
|
+
// await, so it would surface as an unhandled rejection and take pi down.
|
|
217
|
+
const payload = this.payloadFor(h, data);
|
|
218
|
+
await this.runRule(h, payload, signal);
|
|
219
|
+
} catch (err: any) {
|
|
220
|
+
if (h.onFailure === "warn") this.opts.warn(`hook ${h.source} ${h.event}${h.tool ? ` (tool=${h.tool})` : ""} failed: ${previewRedacted(err?.message ?? String(err), 300)}`);
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
};
|
|
224
|
+
this.queue = this.queue.then(run, run);
|
|
225
|
+
return this.queue;
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
/** Every payload field is serialized; absent optionals are `null`, never omitted. */
|
|
229
|
+
private payloadFor(h: HookConfig, data: HookEventData): HookPayload {
|
|
230
|
+
const s = this.opts.getSession();
|
|
231
|
+
const [provider, ...rest] = (s.model ?? "").split("/");
|
|
232
|
+
return {
|
|
233
|
+
event: data.event,
|
|
234
|
+
session_id: s.sessionId ?? "",
|
|
235
|
+
cwd: s.cwd,
|
|
236
|
+
model_provider: s.model ? provider : "",
|
|
237
|
+
model_id: s.model ? rest.join("/") : "",
|
|
238
|
+
thinking_level: s.thinking ?? "off",
|
|
239
|
+
source: h.source,
|
|
240
|
+
message_kind: data.message_kind ?? null,
|
|
241
|
+
message_summary: data.message_summary ?? null,
|
|
242
|
+
assistant_event: data.assistant_event ?? null,
|
|
243
|
+
tool_call_id: data.tool_call_id ?? null,
|
|
244
|
+
tool_name: data.tool_name ?? null,
|
|
245
|
+
tool_is_error: data.tool_is_error ?? null,
|
|
246
|
+
tool_args: data.tool_args ?? null,
|
|
247
|
+
tool_result_summary: data.tool_result_summary ?? null,
|
|
248
|
+
compaction_trigger: data.compaction_trigger ?? null,
|
|
249
|
+
compaction_tokens_before: data.compaction_tokens_before ?? null,
|
|
250
|
+
compaction_summary: data.compaction_summary ?? null,
|
|
251
|
+
compaction_failed: data.compaction_failed ?? null,
|
|
252
|
+
run_job: data.run_job ?? null,
|
|
253
|
+
run_id: data.run_id ?? null,
|
|
254
|
+
run_ok: data.run_ok ?? null,
|
|
255
|
+
run_findings: data.run_findings ?? null,
|
|
256
|
+
run_error: data.run_error ?? null,
|
|
257
|
+
run_cost_usd: data.run_cost_usd ?? null,
|
|
258
|
+
};
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
private resolveCwd(h: HookConfig): string {
|
|
262
|
+
if (h.cwd === "home") return os.homedir();
|
|
263
|
+
if (h.cwd === "loops") return this.opts.loopsDir;
|
|
264
|
+
return this.opts.projectCwd;
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
private async runRule(h: HookConfig, payload: HookPayload, signal?: AbortSignal): Promise<void> {
|
|
268
|
+
const json = JSON.stringify(payload);
|
|
269
|
+
const dir = path.join(os.tmpdir(), "pi-hooks");
|
|
270
|
+
fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
|
|
271
|
+
const payloadFile = path.join(dir, `${randomUUID()}.json`);
|
|
272
|
+
fs.writeFileSync(payloadFile, json, { mode: 0o600 });
|
|
273
|
+
try {
|
|
274
|
+
if (h.command) await this.runCommand(h, payload, payloadFile, signal);
|
|
275
|
+
if (h.webhook) await this.postWebhook(h, json, signal);
|
|
276
|
+
} finally {
|
|
277
|
+
fs.rmSync(payloadFile, { force: true });
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
private runCommand(h: HookConfig, payload: HookPayload, payloadFile: string, signal?: AbortSignal): Promise<void> {
|
|
282
|
+
const vars: Record<string, string | null | undefined> = {
|
|
283
|
+
HOOK_EVENT: payload.event,
|
|
284
|
+
HOOK_PAYLOAD: payloadFile,
|
|
285
|
+
SESSION_ID: payload.session_id,
|
|
286
|
+
CWD: payload.cwd,
|
|
287
|
+
MODEL_PROVIDER: payload.model_provider,
|
|
288
|
+
MODEL_ID: payload.model_id,
|
|
289
|
+
THINKING_LEVEL: payload.thinking_level,
|
|
290
|
+
MESSAGE_KIND: payload.message_kind,
|
|
291
|
+
ASSISTANT_EVENT: payload.assistant_event,
|
|
292
|
+
TOOL_CALL_ID: payload.tool_call_id,
|
|
293
|
+
TOOL_NAME: payload.tool_name,
|
|
294
|
+
TOOL_IS_ERROR: payload.tool_is_error == null ? undefined : String(payload.tool_is_error),
|
|
295
|
+
COMPACTION_TRIGGER: payload.compaction_trigger,
|
|
296
|
+
COMPACTION_TOKENS_BEFORE: payload.compaction_tokens_before == null ? undefined : String(payload.compaction_tokens_before),
|
|
297
|
+
COMPACTION_FAILED: payload.compaction_failed == null ? undefined : String(payload.compaction_failed),
|
|
298
|
+
RUN_JOB: payload.run_job,
|
|
299
|
+
RUN_ID: payload.run_id,
|
|
300
|
+
// A string, because `[ "$PI_RUN_OK" = false ]` is the shape a shell hook will be written in.
|
|
301
|
+
RUN_OK: payload.run_ok == null ? undefined : String(payload.run_ok),
|
|
302
|
+
RUN_FINDINGS: payload.run_findings == null ? undefined : String(payload.run_findings),
|
|
303
|
+
RUN_ERROR: payload.run_error,
|
|
304
|
+
RUN_COST_USD: payload.run_cost_usd == null ? undefined : String(payload.run_cost_usd),
|
|
305
|
+
};
|
|
306
|
+
// Environment variables exist only when they have a value; the JSON payload carries nulls.
|
|
307
|
+
const env: Record<string, string> = { ...(process.env as Record<string, string>) };
|
|
308
|
+
for (const [key, value] of Object.entries(vars)) {
|
|
309
|
+
if (value == null) continue;
|
|
310
|
+
env[`PI_${key}`] = value;
|
|
311
|
+
}
|
|
312
|
+
const isWin = process.platform === "win32";
|
|
313
|
+
return new Promise<void>((resolve, reject) => {
|
|
314
|
+
// Own process group (setsid equivalent) so a timeout kills the whole tree, not just `sh`.
|
|
315
|
+
// stdout is piped only when there is somewhere to put it; otherwise it goes to /dev/null as before.
|
|
316
|
+
const proc = spawn(isWin ? "cmd" : "sh", [isWin ? "/C" : "-c", h.command!], { cwd: this.resolveCwd(h), env, stdio: ["ignore", this.opts.log ? "pipe" : "ignore", "pipe"], detached: !isWin });
|
|
317
|
+
let stderr = "";
|
|
318
|
+
proc.stderr?.on("data", (d) => {
|
|
319
|
+
if (stderr.length < MAX_OUTPUT_CHARS) stderr += d.toString();
|
|
320
|
+
});
|
|
321
|
+
// What the hook itself printed. Bounded: a hook that prints a megabyte would otherwise put
|
|
322
|
+
// a megabyte in the log and rotate away everything the automation had recorded before it.
|
|
323
|
+
let stdout = "";
|
|
324
|
+
let overflowed = false;
|
|
325
|
+
proc.stdout?.on("data", (d) => {
|
|
326
|
+
if (stdout.length >= MAX_OUTPUT_CHARS) overflowed = true;
|
|
327
|
+
else stdout += d.toString();
|
|
328
|
+
});
|
|
329
|
+
const logOutput = () => {
|
|
330
|
+
const text = stdout.trim();
|
|
331
|
+
if (!text) return; // a silent hook stays silent
|
|
332
|
+
const capped = overflowed || text.length > MAX_OUTPUT_CHARS;
|
|
333
|
+
this.opts.log?.(`hook ${h.source} ${h.event}${h.tool ? ` (tool=${h.tool})` : ""}: ${capped ? `${text.slice(0, MAX_OUTPUT_CHARS)}… (output truncated)` : text}`);
|
|
334
|
+
};
|
|
335
|
+
const killTree = () => {
|
|
336
|
+
try {
|
|
337
|
+
if (!isWin && proc.pid) process.kill(-proc.pid, "SIGKILL");
|
|
338
|
+
else proc.kill("SIGKILL");
|
|
339
|
+
} catch {
|
|
340
|
+
/* already gone */
|
|
341
|
+
}
|
|
342
|
+
};
|
|
343
|
+
let done = false;
|
|
344
|
+
const finish = (err?: Error) => {
|
|
345
|
+
if (done) return;
|
|
346
|
+
done = true;
|
|
347
|
+
clearTimeout(timer);
|
|
348
|
+
signal?.removeEventListener("abort", onAbort);
|
|
349
|
+
err ? reject(err) : resolve();
|
|
350
|
+
};
|
|
351
|
+
const timer = setTimeout(() => {
|
|
352
|
+
killTree();
|
|
353
|
+
finish(new Error(`timed out after ${h.timeoutMs}ms`));
|
|
354
|
+
}, h.timeoutMs);
|
|
355
|
+
const onAbort = () => {
|
|
356
|
+
killTree();
|
|
357
|
+
finish(new Error("cancelled"));
|
|
358
|
+
};
|
|
359
|
+
if (signal?.aborted) onAbort();
|
|
360
|
+
else signal?.addEventListener("abort", onAbort, { once: true });
|
|
361
|
+
proc.on("error", (err) => finish(new Error(`spawn: ${err.message}`)));
|
|
362
|
+
proc.on("close", (code) => {
|
|
363
|
+
// Also on a timeout or an abort, where `finish` has already rejected: what the hook
|
|
364
|
+
// managed to say before it was killed is exactly what the reader needs.
|
|
365
|
+
logOutput();
|
|
366
|
+
if (code === 0) finish();
|
|
367
|
+
else finish(new Error(`command exited ${code ?? -1}: ${stderr.trim()}`));
|
|
368
|
+
});
|
|
369
|
+
});
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
private async postWebhook(h: HookConfig, json: string, signal?: AbortSignal): Promise<void> {
|
|
373
|
+
const signals = [AbortSignal.timeout(h.timeoutMs), ...(signal ? [signal] : [])];
|
|
374
|
+
const res = await fetch(h.webhook!, {
|
|
375
|
+
method: "POST",
|
|
376
|
+
headers: { "Content-Type": "application/json", "User-Agent": `pi-loops/${PI_LOOPS_VERSION}`, ...(h.headers ?? {}) },
|
|
377
|
+
body: json,
|
|
378
|
+
signal: AbortSignal.any(signals),
|
|
379
|
+
});
|
|
380
|
+
if (!res.ok) {
|
|
381
|
+
const text = await res.text().catch(() => "");
|
|
382
|
+
throw new Error(`webhook status ${res.status}: ${Array.from(text).slice(0, 500).join("")}`);
|
|
383
|
+
}
|
|
384
|
+
}
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
/* ------------------------------------------------ pi event → payload data */
|
|
388
|
+
|
|
389
|
+
export function truncateSummary(text: string): string {
|
|
390
|
+
const chars = Array.from(text);
|
|
391
|
+
return chars.length <= MAX_SUMMARY_CHARS ? text : `${chars.slice(0, MAX_SUMMARY_CHARS).join("")}…`;
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
/** `message_kind`: user | assistant | tool_result | <custom role> (pi: the message's customType). */
|
|
395
|
+
export function messageKind(msg: any): string | undefined {
|
|
396
|
+
const role = msg?.role;
|
|
397
|
+
if (typeof role !== "string") return undefined;
|
|
398
|
+
if (role === "toolResult") return "tool_result";
|
|
399
|
+
if (role === "custom" && typeof msg.customType === "string" && msg.customType) return msg.customType;
|
|
400
|
+
return role;
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
/** `message_summary`: text joined with placeholders for thinking / tool calls / images, truncated. */
|
|
404
|
+
export function messageSummary(msg: any): string | undefined {
|
|
405
|
+
if (!msg) return undefined;
|
|
406
|
+
const content = msg.content;
|
|
407
|
+
if (typeof content === "string") return truncateSummary(content);
|
|
408
|
+
if (!Array.isArray(content)) return truncateSummary(typeof msg === "object" ? JSON.stringify(msg.payload ?? msg.details ?? "") : "");
|
|
409
|
+
return truncateSummary(
|
|
410
|
+
content
|
|
411
|
+
.map((b: any) => {
|
|
412
|
+
switch (b?.type) {
|
|
413
|
+
case "text":
|
|
414
|
+
return b.text ?? "";
|
|
415
|
+
case "thinking":
|
|
416
|
+
return "<thinking>";
|
|
417
|
+
case "toolCall":
|
|
418
|
+
return `<tool_call ${b.name ?? ""}>`;
|
|
419
|
+
case "image":
|
|
420
|
+
return `<image ${b.mimeType ?? b.mime_type ?? ""}>`;
|
|
421
|
+
default:
|
|
422
|
+
return "";
|
|
423
|
+
}
|
|
424
|
+
})
|
|
425
|
+
.join("\n"),
|
|
426
|
+
);
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
/** `result_summary` for tool results / partial results. */
|
|
430
|
+
export function resultSummary(result: any): string | undefined {
|
|
431
|
+
if (result === undefined || result === null) return undefined;
|
|
432
|
+
if (typeof result === "string") return truncateSummary(result);
|
|
433
|
+
const content = Array.isArray(result?.content) ? result.content : Array.isArray(result) ? result : undefined;
|
|
434
|
+
if (!content) return truncateSummary(typeof result === "object" ? JSON.stringify(result) : String(result));
|
|
435
|
+
return truncateSummary(content.map((b: any) => (b?.type === "text" ? (b.text ?? "") : b?.type === "image" ? `<image ${b.mimeType ?? ""}>` : "")).join("\n"));
|
|
436
|
+
}
|
|
@@ -0,0 +1,243 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A window into the headless host, and a way to interrupt it.
|
|
3
|
+
*
|
|
4
|
+
* The answer to "I am away and want to look in" would normally be that its process is still open: `--web` serves
|
|
5
|
+
* a loopback UI and `/web-connect` relays it. pi-loops' host has no chat at all, so it publishes
|
|
6
|
+
* the same snapshot the extension builds for `/cron scheduler` and `/triggers` over a unix socket
|
|
7
|
+
* in the loops directory, plus the one control a watcher actually needs: abort a run that has gone
|
|
8
|
+
* wrong. Read-mostly by design — a host that could be prompted would be a second chat.
|
|
9
|
+
*
|
|
10
|
+
* The socket is created 0600 under `PI_LOOPS_DIR`, so only the user who owns the host can read it.
|
|
11
|
+
*/
|
|
12
|
+
import { createHash } from "node:crypto";
|
|
13
|
+
import * as fs from "node:fs";
|
|
14
|
+
import * as net from "node:net";
|
|
15
|
+
import * as os from "node:os";
|
|
16
|
+
import * as path from "node:path";
|
|
17
|
+
|
|
18
|
+
export const HOST_SOCKET = "host.sock";
|
|
19
|
+
const MAX_UNIX_SOCKET_PATH = 100; // 104 on macOS, minus headroom so a rename or a longer socket name cannot cross it
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Where the host listens. Normally `<dir>/host.sock`, but a unix socket path is capped at 108
|
|
23
|
+
* bytes on Linux (104 on macOS) — the whole `sockaddr_un.sun_path` — and a loops directory nested
|
|
24
|
+
* a few levels deep goes past that. `listen()` then fails with EINVAL, the host runs on with no
|
|
25
|
+
* control channel, and `pi-loops host status|abort|stop` report a host that "is not answering"
|
|
26
|
+
* while it is perfectly healthy. So a long path falls back to a short one, named by a hash of the
|
|
27
|
+
* loops directory so two of them never collide.
|
|
28
|
+
*
|
|
29
|
+
* The fallback lives one directory down, in a per-user directory this process owns, never directly
|
|
30
|
+
* in the shared temp directory: this socket accepts `abort` and `stop`, so a predictable path any
|
|
31
|
+
* local account could bind first would let someone else answer for the host — `host stop` would
|
|
32
|
+
* report success against a forged reply while the real host kept running unattended.
|
|
33
|
+
*/
|
|
34
|
+
export function hostSocketPath(dir: string): string {
|
|
35
|
+
const preferred = path.join(dir, HOST_SOCKET);
|
|
36
|
+
// A byte count, not a character count: the kernel measures bytes.
|
|
37
|
+
if (Buffer.byteLength(preferred) <= MAX_UNIX_SOCKET_PATH) return preferred;
|
|
38
|
+
const digest = createHash("sha256").update(path.resolve(dir)).digest("hex").slice(0, 16);
|
|
39
|
+
return path.join(os.tmpdir(), `pi-loops-${process.getuid?.() ?? "u"}`, `host-${digest}.sock`);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* The socket's directory, restricted to this user and verified rather than assumed: in a shared
|
|
44
|
+
* temp directory the path can already exist as someone else's directory, or as a symlink into one.
|
|
45
|
+
* Returns false when it cannot be made safe, and the caller then runs without a control channel
|
|
46
|
+
* rather than listening somewhere anyone can reach.
|
|
47
|
+
*/
|
|
48
|
+
function prepareSocketDir(socketPath: string, log?: (message: string) => void): boolean {
|
|
49
|
+
const parent = path.dirname(socketPath);
|
|
50
|
+
try {
|
|
51
|
+
fs.mkdirSync(parent, { recursive: true, mode: 0o700 });
|
|
52
|
+
const st = fs.lstatSync(parent);
|
|
53
|
+
if (!st.isDirectory()) return fail(`${parent} is not a directory`);
|
|
54
|
+
if (st.uid !== (process.getuid?.() ?? st.uid)) return fail(`${parent} belongs to another user`);
|
|
55
|
+
if (st.mode & 0o077) {
|
|
56
|
+
fs.chmodSync(parent, 0o700);
|
|
57
|
+
if (fs.lstatSync(parent).mode & 0o077) return fail(`${parent} is readable by other users`);
|
|
58
|
+
}
|
|
59
|
+
return true;
|
|
60
|
+
} catch (err: any) {
|
|
61
|
+
return fail(err?.message ?? String(err));
|
|
62
|
+
}
|
|
63
|
+
function fail(reason: string): boolean {
|
|
64
|
+
log?.(`control channel: refusing to listen — ${reason}`);
|
|
65
|
+
return false;
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Is this the socket we created, and not someone else's? The mode is deliberately not part of the
|
|
71
|
+
* test: `listen()` creates the socket under the process umask and the chmod lands a tick later, so
|
|
72
|
+
* a mode check here would reject a healthy host that had only just started. What actually gates
|
|
73
|
+
* access is the directory — 0700 and ownership-checked — plus this uid comparison, which is what
|
|
74
|
+
* stops another account's socket from answering for the host.
|
|
75
|
+
*/
|
|
76
|
+
function ownedSocket(socketPath: string): boolean {
|
|
77
|
+
try {
|
|
78
|
+
// lstat, so a symlink pointing at someone else's socket is rejected rather than followed.
|
|
79
|
+
const st = fs.lstatSync(socketPath);
|
|
80
|
+
return st.isSocket() && st.uid === (process.getuid?.() ?? st.uid);
|
|
81
|
+
} catch {
|
|
82
|
+
return false;
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
export interface HostSnapshot {
|
|
87
|
+
pid: number;
|
|
88
|
+
host: string;
|
|
89
|
+
startedAt: string;
|
|
90
|
+
model?: string;
|
|
91
|
+
leader: boolean;
|
|
92
|
+
runs: Array<{ runId: string; label: string; jobId: string; startedAt: string; promptPreview: string }>;
|
|
93
|
+
checks: Array<{ traceId: string; sourceLabel: string; eventLabel: string; startedAt: string; cwd: string }>;
|
|
94
|
+
jobs: { enabled: number; total: number };
|
|
95
|
+
rules: { enabled: number; total: number };
|
|
96
|
+
inboxNew: number;
|
|
97
|
+
mcp: Array<{ name: string; state: string; lastError?: string }>;
|
|
98
|
+
/** The last few runs, newest first: a host that has been failing for hours must not look idle. */
|
|
99
|
+
recent?: Array<{ job: string; at: string; ok: boolean; error?: string; cost?: number }>;
|
|
100
|
+
/** Jobs currently carrying an error, and when anything is next due. */
|
|
101
|
+
failing?: Array<{ job: string; error: string }>;
|
|
102
|
+
nextDue?: string;
|
|
103
|
+
/** Today's spend against the cap, when one is configured. */
|
|
104
|
+
budget?: { spent: number; cap: number };
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
export type HostRequest = { op: "status" } | { op: "abort"; runId?: string; traceId?: string } | { op: "stop" };
|
|
108
|
+
export type HostResponse = { ok: true; snapshot?: HostSnapshot; aborted?: boolean } | { ok: false; error: string };
|
|
109
|
+
|
|
110
|
+
export interface HostChannelHandlers {
|
|
111
|
+
status: () => HostSnapshot;
|
|
112
|
+
abortRun: (runId: string) => boolean;
|
|
113
|
+
abortCheck: (traceId: string) => boolean;
|
|
114
|
+
stop: () => void;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/** One newline-delimited JSON request per connection, answered and closed. */
|
|
118
|
+
export function serveHostChannel(dir: string, handlers: HostChannelHandlers, log?: (message: string) => void): net.Server {
|
|
119
|
+
const socketPath = hostSocketPath(dir);
|
|
120
|
+
if (!prepareSocketDir(socketPath, log)) return net.createServer();
|
|
121
|
+
// The directory holds prompts, findings, transcripts and now a control socket: it is the
|
|
122
|
+
// owner's alone. `listen()` creates the socket with the process umask (0775 under umask 002)
|
|
123
|
+
// and only chmods afterwards, so the directory mode is what closes that window.
|
|
124
|
+
fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
|
|
125
|
+
try {
|
|
126
|
+
fs.chmodSync(dir, 0o700);
|
|
127
|
+
} catch (err: any) {
|
|
128
|
+
log?.(`could not restrict ${dir} to this user: ${err?.message ?? err}`);
|
|
129
|
+
}
|
|
130
|
+
try {
|
|
131
|
+
// `force` only swallows ENOENT: in a sticky temp directory this can be EPERM, and an
|
|
132
|
+
// unhandled throw here would take the whole host down at startup.
|
|
133
|
+
if (ownedSocket(socketPath)) fs.rmSync(socketPath, { force: true }); // left by a crashed host
|
|
134
|
+
else if (fs.existsSync(socketPath)) throw new Error(`${socketPath} exists and is not ours`);
|
|
135
|
+
} catch (err: any) {
|
|
136
|
+
log?.(`control channel: ${err?.message ?? err}`);
|
|
137
|
+
return net.createServer(); // an unbound server: the host runs, `host status` falls back to the pid
|
|
138
|
+
}
|
|
139
|
+
const server = net.createServer((socket) => {
|
|
140
|
+
socket.setEncoding("utf8");
|
|
141
|
+
let buf = "";
|
|
142
|
+
socket.on("data", (chunk) => {
|
|
143
|
+
buf += chunk;
|
|
144
|
+
const nl = buf.indexOf("\n");
|
|
145
|
+
if (nl < 0) {
|
|
146
|
+
if (buf.length > 4096) socket.destroy();
|
|
147
|
+
return;
|
|
148
|
+
}
|
|
149
|
+
let response: HostResponse;
|
|
150
|
+
try {
|
|
151
|
+
const req = JSON.parse(buf.slice(0, nl)) as HostRequest;
|
|
152
|
+
if (req.op === "status") response = { ok: true, snapshot: handlers.status() };
|
|
153
|
+
else if (req.op === "abort") response = { ok: true, aborted: req.runId ? handlers.abortRun(req.runId) : req.traceId ? handlers.abortCheck(req.traceId) : false };
|
|
154
|
+
else if (req.op === "stop") {
|
|
155
|
+
response = { ok: true };
|
|
156
|
+
setTimeout(() => handlers.stop(), 50).unref?.();
|
|
157
|
+
} else response = { ok: false, error: `unknown op ${JSON.stringify((req as any).op)}` };
|
|
158
|
+
} catch (err: any) {
|
|
159
|
+
response = { ok: false, error: err?.message ?? String(err) };
|
|
160
|
+
}
|
|
161
|
+
socket.end(`${JSON.stringify(response)}\n`, () => socket.destroy());
|
|
162
|
+
buf = "";
|
|
163
|
+
socket.removeAllListeners("data"); // one request per connection, never replayed
|
|
164
|
+
});
|
|
165
|
+
socket.on("error", () => socket.destroy());
|
|
166
|
+
socket.setTimeout(10_000, () => socket.destroy());
|
|
167
|
+
});
|
|
168
|
+
server.on("error", (err: any) => log?.(`control channel: ${err?.message ?? err}`));
|
|
169
|
+
server.listen(socketPath, () => {
|
|
170
|
+
try {
|
|
171
|
+
fs.chmodSync(socketPath, 0o600);
|
|
172
|
+
} catch (err: any) {
|
|
173
|
+
// Never leave a reachable control socket behind because a chmod failed.
|
|
174
|
+
log?.(`could not restrict the control socket; closing it: ${err?.message ?? err}`);
|
|
175
|
+
server.close();
|
|
176
|
+
}
|
|
177
|
+
});
|
|
178
|
+
server.unref();
|
|
179
|
+
return server;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
/** Ask a running host something. Returns undefined when no host is listening. */
|
|
183
|
+
export function askHost(dir: string, request: HostRequest, timeoutMs = 2000): Promise<HostResponse | undefined> {
|
|
184
|
+
const socketPath = hostSocketPath(dir);
|
|
185
|
+
return new Promise((resolve) => {
|
|
186
|
+
// Not just "does it exist": whoever is listening there gets to answer for the host, and this
|
|
187
|
+
// answer decides whether `host stop` sends a signal. Someone else's socket is no host at all.
|
|
188
|
+
if (!ownedSocket(socketPath)) return resolve(undefined);
|
|
189
|
+
const socket = net.createConnection(socketPath);
|
|
190
|
+
let buf = "";
|
|
191
|
+
const done = (value: HostResponse | undefined) => {
|
|
192
|
+
socket.destroy();
|
|
193
|
+
resolve(value);
|
|
194
|
+
};
|
|
195
|
+
const timer = setTimeout(() => done(undefined), timeoutMs);
|
|
196
|
+
timer.unref?.();
|
|
197
|
+
socket.setEncoding("utf8");
|
|
198
|
+
socket.on("connect", () => socket.write(`${JSON.stringify(request)}\n`));
|
|
199
|
+
socket.on("data", (chunk) => {
|
|
200
|
+
buf += chunk;
|
|
201
|
+
const nl = buf.indexOf("\n");
|
|
202
|
+
if (nl < 0) return;
|
|
203
|
+
clearTimeout(timer);
|
|
204
|
+
try {
|
|
205
|
+
done(JSON.parse(buf.slice(0, nl)) as HostResponse);
|
|
206
|
+
} catch {
|
|
207
|
+
done(undefined);
|
|
208
|
+
}
|
|
209
|
+
});
|
|
210
|
+
socket.on("error", () => {
|
|
211
|
+
clearTimeout(timer);
|
|
212
|
+
done(undefined);
|
|
213
|
+
});
|
|
214
|
+
});
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
/**
|
|
218
|
+
* Everything here arrived over a socket and is printed to a terminal. Even with the socket
|
|
219
|
+
* ownership-checked, a snapshot is data from another process: escape sequences would repaint the
|
|
220
|
+
* lines around it, and a number that is not a number would throw out of `toFixed`.
|
|
221
|
+
*/
|
|
222
|
+
function line(text: unknown): string {
|
|
223
|
+
return String(text ?? "").replace(/[\u0000-\u001f\u007f-\u009f]/g, " ");
|
|
224
|
+
}
|
|
225
|
+
function money(n: unknown, digits: number): string {
|
|
226
|
+
return Number.isFinite(Number(n)) ? Number(n).toFixed(digits) : "?";
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
/** The lines `/cron host` shows for a live host. */
|
|
230
|
+
export function renderHostSnapshot(s: HostSnapshot): string[] {
|
|
231
|
+
const lines = [
|
|
232
|
+
` pid ${line(s.pid)} on ${line(s.host)}, started ${line(s.startedAt)}${s.model ? `, model ${line(s.model)}` : ""}`,
|
|
233
|
+
` ${s.leader ? "owns the clock" : "standby"} · ${line(s.jobs?.enabled)}/${line(s.jobs?.total)} loop(s), ${line(s.rules?.enabled)}/${line(s.rules?.total)} rule(s) enabled · inbox: ${line(s.inboxNew)} new`,
|
|
234
|
+
];
|
|
235
|
+
for (const r of s.runs ?? []) lines.push(` running ${line(r.label)} (${line(r.runId).slice(0, 12)}) since ${line(r.startedAt)}: ${line(r.promptPreview)}`);
|
|
236
|
+
for (const c of s.checks ?? []) lines.push(` checking ${line(c.sourceLabel)}/${line(c.eventLabel)} (${line(c.traceId).slice(0, 8)}) in ${line(c.cwd)}`);
|
|
237
|
+
for (const m of s.mcp ?? []) lines.push(` mcp ${line(m.name)}: ${line(m.state)}${m.lastError ? ` — ${line(m.lastError)}` : ""}`);
|
|
238
|
+
if (!s.runs?.length && !s.checks?.length) lines.push(` nothing running right now${s.nextDue ? ` · next due ${line(s.nextDue)}` : ""}`);
|
|
239
|
+
if (s.budget && Number(s.budget.cap) > 0) lines.push(` spent today: $${money(s.budget.spent, 2)} of $${money(s.budget.cap, 2)}${Number(s.budget.spent) >= Number(s.budget.cap) ? " — dispatching is paused" : ""}`);
|
|
240
|
+
for (const f of s.failing ?? []) lines.push(` ! ${line(f.job)}: ${line(f.error)}`);
|
|
241
|
+
for (const r of s.recent ?? []) lines.push(` ${r.ok ? "ok " : "FAIL"} ${line(r.at)} ${line(r.job)}${r.cost ? ` · $${money(r.cost, 3)}` : ""}${r.error ? ` — ${line(r.error)}` : ""}`);
|
|
242
|
+
return lines;
|
|
243
|
+
}
|