@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,49 @@
|
|
|
1
|
+
// Where pi's packages are, for everything that runs outside pi's own loader: the `node --import` hook
|
|
2
|
+
// for the headless host (src/host-entry.mjs) and the tests, and the jiti aliases in src/ts-entry.mjs.
|
|
3
|
+
// PI_LOOPS_PI_PACKAGE names pi's package dir; else the one beside this Node, else the global install.
|
|
4
|
+
import { execSync } from "node:child_process";
|
|
5
|
+
import * as fs from "node:fs";
|
|
6
|
+
import * as path from "node:path";
|
|
7
|
+
import { pathToFileURL } from "node:url";
|
|
8
|
+
|
|
9
|
+
let pkg;
|
|
10
|
+
/** pi's package directory: what was told, else the one beside this Node, else the global install. */
|
|
11
|
+
export function piPackage() {
|
|
12
|
+
if (pkg) return pkg;
|
|
13
|
+
const told = process.env.PI_LOOPS_PI_PACKAGE;
|
|
14
|
+
if (told && fs.existsSync(path.join(told, "package.json"))) return (pkg = told);
|
|
15
|
+
const guess = path.join(path.dirname(path.dirname(process.execPath)), "lib", "node_modules", "@earendil-works", "pi-coding-agent");
|
|
16
|
+
if (fs.existsSync(path.join(guess, "package.json"))) return (pkg = guess);
|
|
17
|
+
return (pkg = path.join(execSync("npm root -g", { encoding: "utf8" }).trim(), "@earendil-works", "pi-coding-agent"));
|
|
18
|
+
}
|
|
19
|
+
// Relative to pi's package directory.
|
|
20
|
+
const MAP = {
|
|
21
|
+
"@earendil-works/pi-coding-agent": "dist/index.js",
|
|
22
|
+
"@earendil-works/pi-ai": "node_modules/@earendil-works/pi-ai/dist/index.js",
|
|
23
|
+
"@earendil-works/pi-agent-core": "node_modules/@earendil-works/pi-agent-core/dist/index.js",
|
|
24
|
+
"@earendil-works/pi-tui": "node_modules/@earendil-works/pi-tui/dist/index.js",
|
|
25
|
+
typebox: "node_modules/typebox/build/index.mjs",
|
|
26
|
+
};
|
|
27
|
+
/**
|
|
28
|
+
* The same map as an absolute-path table, which is the shape jiti's `alias` option wants — for when
|
|
29
|
+
* jiti loads this package's TypeScript instead of Node (src/ts-entry.mjs). It is derived from `MAP`
|
|
30
|
+
* rather than written twice so the two routes cannot resolve pi differently. Entries whose file is
|
|
31
|
+
* missing are dropped, exactly as the hook below declines to short-circuit for them.
|
|
32
|
+
*/
|
|
33
|
+
export function piAliases() {
|
|
34
|
+
const dir = piPackage();
|
|
35
|
+
const out = {};
|
|
36
|
+
for (const [specifier, rel] of Object.entries(MAP)) {
|
|
37
|
+
const file = path.join(dir, rel);
|
|
38
|
+
if (fs.existsSync(file)) out[specifier] = file;
|
|
39
|
+
}
|
|
40
|
+
return out;
|
|
41
|
+
}
|
|
42
|
+
export async function resolve(specifier, context, next) {
|
|
43
|
+
const rel = MAP[specifier];
|
|
44
|
+
if (rel) {
|
|
45
|
+
const file = path.join(piPackage(), rel);
|
|
46
|
+
if (fs.existsSync(file)) return { url: pathToFileURL(file).href, shortCircuit: true };
|
|
47
|
+
}
|
|
48
|
+
return next(specifier, context);
|
|
49
|
+
}
|
package/src/presence.ts
ADDED
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Presence registry: which pi processes are alive on this machine, in which project and
|
|
3
|
+
* session. One small file per process under `<dir>/presence/`, refreshed on every scheduler
|
|
4
|
+
* tick and removed on shutdown; stale entries (no heartbeat for 90 s, or a dead pid on this
|
|
5
|
+
* host) are ignored and pruned.
|
|
6
|
+
*
|
|
7
|
+
* It restores what session scoping would give for free: a dynamic rule is checked by the pi
|
|
8
|
+
* session that created it, and failing that by a pi that is *in that project*, so promotions land
|
|
9
|
+
* in the right chat. Only when no pi is open there does the machine leader step in (and the result
|
|
10
|
+
* goes to the inbox).
|
|
11
|
+
*/
|
|
12
|
+
import * as fs from "node:fs";
|
|
13
|
+
import * as os from "node:os";
|
|
14
|
+
import * as path from "node:path";
|
|
15
|
+
import { pidAlive, writeFileAtomic } from "./lock.ts";
|
|
16
|
+
import { realpathish } from "./paths.ts";
|
|
17
|
+
import { stamp } from "./schedule.ts";
|
|
18
|
+
|
|
19
|
+
export const PRESENCE_STALE_MS = 90_000;
|
|
20
|
+
|
|
21
|
+
export type PresenceKind = "interactive" | "host";
|
|
22
|
+
|
|
23
|
+
export interface PresenceEntry {
|
|
24
|
+
pid: number;
|
|
25
|
+
host: string;
|
|
26
|
+
/** Distinguishes scheduler instances inside one process (reload, tests). */
|
|
27
|
+
instance: string;
|
|
28
|
+
sessionId?: string;
|
|
29
|
+
cwd: string;
|
|
30
|
+
/** "host" = the headless process that keeps the clock while no interactive pi is open. */
|
|
31
|
+
kind?: PresenceKind;
|
|
32
|
+
heartbeatAt: string;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export type PresenceSelf = Omit<PresenceEntry, "heartbeatAt">;
|
|
36
|
+
|
|
37
|
+
export class PresenceRegistry {
|
|
38
|
+
readonly dir: string;
|
|
39
|
+
readonly file: string;
|
|
40
|
+
private readonly self: PresenceSelf;
|
|
41
|
+
|
|
42
|
+
constructor(dir: string, self: PresenceSelf) {
|
|
43
|
+
this.dir = path.join(dir, "presence");
|
|
44
|
+
this.self = self;
|
|
45
|
+
this.file = path.join(this.dir, `${self.host.replace(/[^A-Za-z0-9._-]/g, "_")}-${self.pid}-${self.instance}.json`);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** Refresh this process's entry (session and cwd may change between ticks). */
|
|
49
|
+
heartbeat(now: number, current?: Partial<Pick<PresenceEntry, "sessionId" | "cwd">>): void {
|
|
50
|
+
const entry: PresenceEntry = { ...this.self, ...current, heartbeatAt: stamp(now) };
|
|
51
|
+
writeFileAtomic(this.file, `${JSON.stringify(entry)}\n`);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
remove(): void {
|
|
55
|
+
fs.rmSync(this.file, { force: true });
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** Live entries on every host; stale ones are pruned as a side effect (best effort). */
|
|
59
|
+
list(now: number): PresenceEntry[] {
|
|
60
|
+
let names: string[];
|
|
61
|
+
try {
|
|
62
|
+
names = fs.readdirSync(this.dir);
|
|
63
|
+
} catch {
|
|
64
|
+
return [];
|
|
65
|
+
}
|
|
66
|
+
const out: PresenceEntry[] = [];
|
|
67
|
+
for (const name of names) {
|
|
68
|
+
if (!name.endsWith(".json")) continue;
|
|
69
|
+
const file = path.join(this.dir, name);
|
|
70
|
+
let e: PresenceEntry | undefined;
|
|
71
|
+
try {
|
|
72
|
+
e = JSON.parse(fs.readFileSync(file, "utf8")) as PresenceEntry;
|
|
73
|
+
} catch {
|
|
74
|
+
continue;
|
|
75
|
+
}
|
|
76
|
+
if (!e || typeof e.pid !== "number" || typeof e.cwd !== "string") continue;
|
|
77
|
+
const age = now - Date.parse(e.heartbeatAt);
|
|
78
|
+
const mine = e.pid === this.self.pid && e.host === this.self.host && e.instance === this.self.instance;
|
|
79
|
+
// A negative age means that machine's clock is ahead of ours; such an entry would never
|
|
80
|
+
// age out and would keep a dead session's plain jobs from ever being parked.
|
|
81
|
+
const stale = !mine && (Number.isNaN(age) || age > PRESENCE_STALE_MS || age < -PRESENCE_STALE_MS || (e.host === this.self.host && !pidAlive(e.pid)));
|
|
82
|
+
if (stale) {
|
|
83
|
+
fs.rmSync(file, { force: true });
|
|
84
|
+
continue;
|
|
85
|
+
}
|
|
86
|
+
out.push(e);
|
|
87
|
+
}
|
|
88
|
+
return out;
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/** A root so broad that containment would mean "everything": never treated as one project. */
|
|
93
|
+
function tooBroad(dir: string): boolean {
|
|
94
|
+
return dir === "/" || dir === os.homedir() || path.dirname(dir) === dir;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/** True when `p` is the project rooted at `root` or a directory inside it, symlinks resolved. */
|
|
98
|
+
export function withinProject(root: string, p: string): boolean {
|
|
99
|
+
const a = realpathish(root);
|
|
100
|
+
const b = realpathish(p);
|
|
101
|
+
if (!a || !b) return false;
|
|
102
|
+
if (a === b) return true;
|
|
103
|
+
// Containment is what makes a worktree or a subdirectory the same project. `/` and `$HOME`
|
|
104
|
+
// contain everything, so a rule created with one of those as its cwd governs only itself.
|
|
105
|
+
if (tooBroad(a)) return false;
|
|
106
|
+
const rel = path.relative(a, b);
|
|
107
|
+
return !!rel && !rel.startsWith("..") && !path.isAbsolute(rel);
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* The process that should act for the project rooted at `cwd` on `host`: a live pi opened in it —
|
|
112
|
+
* or in a subdirectory of it, or through a symlink to it — lowest pid as the tie-break so every
|
|
113
|
+
* process computes the same answer. `undefined` when no pi is open there: the caller falls back
|
|
114
|
+
* to the machine leader.
|
|
115
|
+
*/
|
|
116
|
+
export function chooseCwdOwner(entries: PresenceEntry[], cwd: string, host: string): PresenceEntry | undefined {
|
|
117
|
+
const here = entries.filter((e) => e.host === host && e.kind !== "host" && withinProject(cwd, e.cwd));
|
|
118
|
+
if (!here.length) return undefined;
|
|
119
|
+
return [...here].sort((a, b) => a.pid - b.pid || a.instance.localeCompare(b.instance))[0];
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* The process that evaluates one rule of the project rooted at `cwd`.
|
|
124
|
+
*
|
|
125
|
+
* Rules are machine-global, which is what lets them outlive the session that created them — and it
|
|
126
|
+
* costs the one thing a per-session file would have given away: knowing whose conversation a
|
|
127
|
+
* promotion belongs in. This is how that is won back. While the creating session is open on this
|
|
128
|
+
* host it owns its rule; once that session is gone the project's cwd owner takes it, and only with
|
|
129
|
+
* no pi open in the project at all does the machine leader (whose results go to the inbox, having
|
|
130
|
+
* no conversation to claim).
|
|
131
|
+
*/
|
|
132
|
+
export function chooseRuleOwner(entries: PresenceEntry[], cwd: string, host: string, sessionId?: string): PresenceEntry | undefined {
|
|
133
|
+
const session = sessionId ? entries.find((e) => e.sessionId === sessionId && e.host === host && e.kind !== "host") : undefined;
|
|
134
|
+
return session ?? chooseCwdOwner(entries, cwd, host);
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
export function isSelf(entry: PresenceEntry | undefined, self: PresenceSelf): boolean {
|
|
138
|
+
return !!entry && entry.pid === self.pid && entry.host === self.host && entry.instance === self.instance;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
export function localHost(): string {
|
|
142
|
+
return os.hostname();
|
|
143
|
+
}
|
package/src/protocol.ts
ADDED
|
@@ -0,0 +1,198 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The loop output protocol — plain text, provider-agnostic, matching
|
|
3
|
+
* docs/loops.md: the sub-agent ends its reply with <loop-state>…</loop-state>
|
|
4
|
+
* and zero or more <inbox>…</inbox> tags. Extraction never fails a run.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
export const LOOP_STATE_MAX_CHARS = 2000;
|
|
8
|
+
export const INBOX_TEXT_MAX_CHARS = 500;
|
|
9
|
+
export const INBOX_TAGS_PER_RUN = 16;
|
|
10
|
+
/**
|
|
11
|
+
* How many `<inbox>` tags are read out of one reply at all. Past this the dropped-finding count
|
|
12
|
+
* stops being exact, which is the right trade: a reply carrying a thousand findings is a runaway,
|
|
13
|
+
* not a report, and scanning it to the end only puts a number on how far it ran away.
|
|
14
|
+
*/
|
|
15
|
+
const MAX_INBOX_TAGS_SCANNED = 1000;
|
|
16
|
+
export const FIRST_RUN_MARKER = "(first run)";
|
|
17
|
+
/**
|
|
18
|
+
* The two lines that bracket the job's own text in a composed prompt. They are part of the protocol,
|
|
19
|
+
* so they are named here and read here (`jobTextOf`): the transcript view used to carry its own
|
|
20
|
+
* copies of both literals, and a prompt reworded on one side silently stopped being parsed on the other.
|
|
21
|
+
*/
|
|
22
|
+
export const LOOP_STATE_CLOSE = "[/loop-state]";
|
|
23
|
+
export const OUTPUT_PROTOCOL_HEADING = "Output protocol (mandatory):";
|
|
24
|
+
|
|
25
|
+
export function capChars(text: string, max: number): string {
|
|
26
|
+
const chars = Array.from(text.trim());
|
|
27
|
+
if (chars.length <= max) return chars.join("");
|
|
28
|
+
return `${chars.slice(0, max).join("")}…`;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export function composeLoopPrompt(action: string, previousState: string | undefined, meta?: { name?: string; runAt?: string }): string {
|
|
32
|
+
const state = previousState && previousState.trim() ? capChars(previousState, LOOP_STATE_MAX_CHARS) : FIRST_RUN_MARKER;
|
|
33
|
+
const header = meta?.name ? `You are running the recurring loop "${meta.name}"` : "You are running a recurring loop";
|
|
34
|
+
/**
|
|
35
|
+
* The run time, and how to write one.
|
|
36
|
+
*
|
|
37
|
+
* The notes this prompt asks for hold watermarks — "everything up to here has been seen" — and
|
|
38
|
+
* a watermark is a time the model writes in whatever shape it likes. That was survivable while
|
|
39
|
+
* a loop belonged to one machine. It does not: a job with no `host` runs on any machine sharing
|
|
40
|
+
* the `$HOME` (scheduler.ts, and `/cron set <ref> --host -` asks for exactly that), so run N can
|
|
41
|
+
* write "checked up to 20:00" in Shanghai and run N+1 read it in New York.
|
|
42
|
+
*
|
|
43
|
+
* The stamp carries its offset; this asks for the same of anything the model writes back. It
|
|
44
|
+
* goes in this line rather than in the protocol block below, which is quoted verbatim.
|
|
45
|
+
*/
|
|
46
|
+
const when = meta?.runAt ? ` (current run started ${meta.runAt}; write any time in your notes with its offset, as that one has)` : "";
|
|
47
|
+
return [
|
|
48
|
+
`${header}${when}. This is a background run: nobody is watching, and your final reply is parsed by a program.`,
|
|
49
|
+
"",
|
|
50
|
+
"[loop-state] (your notes from the previous run of this recurring job)",
|
|
51
|
+
state,
|
|
52
|
+
LOOP_STATE_CLOSE,
|
|
53
|
+
"",
|
|
54
|
+
action.trim(),
|
|
55
|
+
"",
|
|
56
|
+
OUTPUT_PROTOCOL_HEADING,
|
|
57
|
+
`- End your reply with <loop-state>notes for the next run</loop-state> — it REPLACES the saved state; keep it under ${LOOP_STATE_MAX_CHARS} characters and make it the information your next run needs (baselines, ids already seen, watermarks).`,
|
|
58
|
+
"- For each finding a human should act on, emit <inbox>one concise line</inbox>. No findings → no inbox tags; do not invent work.",
|
|
59
|
+
"- Keep everything after the last tool call short so the tags are not truncated.",
|
|
60
|
+
].join("\n");
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** Last `<tag>…</tag>` block, trimmed. Unclosed tags are ignored. */
|
|
64
|
+
export function extractTagBlock(text: string, tag: string): string | undefined {
|
|
65
|
+
const open = `<${tag}>`;
|
|
66
|
+
const close = `</${tag}>`;
|
|
67
|
+
const start = text.lastIndexOf(open);
|
|
68
|
+
if (start < 0) return undefined;
|
|
69
|
+
const rest = text.slice(start + open.length);
|
|
70
|
+
const end = rest.indexOf(close);
|
|
71
|
+
if (end < 0) return undefined;
|
|
72
|
+
return rest.slice(0, end).trim();
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** Every `<tag>…</tag>` block in order, capped at `max`. Empty bodies are skipped. */
|
|
76
|
+
export function extractTagAll(text: string, tag: string, max: number): string[] {
|
|
77
|
+
const open = `<${tag}>`;
|
|
78
|
+
const close = `</${tag}>`;
|
|
79
|
+
const out: string[] = [];
|
|
80
|
+
let rest = text;
|
|
81
|
+
while (out.length < max) {
|
|
82
|
+
const start = rest.indexOf(open);
|
|
83
|
+
if (start < 0) break;
|
|
84
|
+
const after = rest.slice(start + open.length);
|
|
85
|
+
const end = after.indexOf(close);
|
|
86
|
+
if (end < 0) break;
|
|
87
|
+
const body = after.slice(0, end).trim();
|
|
88
|
+
if (body) out.push(body);
|
|
89
|
+
rest = after.slice(end + close.length);
|
|
90
|
+
}
|
|
91
|
+
return out;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/** Remove protocol blocks for display. */
|
|
95
|
+
export function stripProtocolTags(text: string): string {
|
|
96
|
+
let out = text;
|
|
97
|
+
for (const tag of ["loop-state", "inbox"]) {
|
|
98
|
+
const re = new RegExp(`<${tag}>[\\s\\S]*?</${tag}>`, "g");
|
|
99
|
+
out = out.replace(re, "");
|
|
100
|
+
}
|
|
101
|
+
return out
|
|
102
|
+
.split("\n")
|
|
103
|
+
.map((l) => l.trimEnd())
|
|
104
|
+
.filter((l, i, arr) => !(l.trim() === "" && (i === 0 || arr[i - 1].trim() === "")))
|
|
105
|
+
.join("\n")
|
|
106
|
+
.trim();
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* The job's own text out of a composed loop prompt: what sits between the state block and the
|
|
111
|
+
* protocol block (`composeLoopPrompt`). A prompt this does not recognise is returned whole, which is
|
|
112
|
+
* what a display wants — a prompt from somewhere else is still the best thing to show.
|
|
113
|
+
*/
|
|
114
|
+
export function jobTextOf(prompt: string): string {
|
|
115
|
+
return prompt.split(LOOP_STATE_CLOSE).pop()?.split(OUTPUT_PROTOCOL_HEADING)[0] ?? prompt;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/* ---------------------------------------------------------- maker/checker */
|
|
119
|
+
|
|
120
|
+
export const CHECKER_MARKER = "You are the checker for a recurring loop";
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* A second sub-agent adversarially
|
|
124
|
+
* reviews the maker's findings before they reach the inbox. Plain-text protocol again:
|
|
125
|
+
* one <verdict n="i">keep|drop</verdict> per finding, reason inside the tag body after
|
|
126
|
+
* the verdict word; a <rewrite n="i">…</rewrite> may replace the finding's text.
|
|
127
|
+
*/
|
|
128
|
+
export function composeCheckerPrompt(action: string, makerState: string | undefined, findings: string[], meta?: { name?: string }): string {
|
|
129
|
+
const list = findings.map((f, i) => `${i + 1}. ${f}`).join("\n");
|
|
130
|
+
return [
|
|
131
|
+
`${CHECKER_MARKER}${meta?.name ? ` "${meta.name}"` : ""}. A separate agent (the maker) just ran this loop and reported the findings below. Your job is adversarial: assume each finding may be wrong, stale, duplicated, trivial, or unsupported, and verify it independently with the tools available (read files, run commands, query APIs) before you decide. Do not trust the maker's notes as evidence.`,
|
|
132
|
+
"",
|
|
133
|
+
"[loop-goal]",
|
|
134
|
+
action.trim(),
|
|
135
|
+
"[/loop-goal]",
|
|
136
|
+
"",
|
|
137
|
+
"[maker-notes]",
|
|
138
|
+
makerState?.trim() || FIRST_RUN_MARKER,
|
|
139
|
+
"[/maker-notes]",
|
|
140
|
+
"",
|
|
141
|
+
"[findings]",
|
|
142
|
+
list,
|
|
143
|
+
"[/findings]",
|
|
144
|
+
"",
|
|
145
|
+
OUTPUT_PROTOCOL_HEADING,
|
|
146
|
+
'- For EVERY numbered finding emit exactly one <verdict n="i">keep — reason</verdict> or <verdict n="i">drop — reason</verdict>. keep = a human should still act on it; drop = false, stale, duplicate, not actionable, or not worth attention.',
|
|
147
|
+
'- If a kept finding is imprecise, add <rewrite n="i">one corrected line</rewrite> to replace its text.',
|
|
148
|
+
"- Do not invent new findings. Do not emit <inbox> or <loop-state> tags.",
|
|
149
|
+
"- Keep everything after the last tool call short so the tags are not truncated.",
|
|
150
|
+
].join("\n");
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
export interface CheckerVerdict {
|
|
154
|
+
n: number;
|
|
155
|
+
verdict: "keep" | "drop";
|
|
156
|
+
reason: string;
|
|
157
|
+
rewrite?: string;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/** Verdicts by finding number (1-based). Findings without a verdict are absent from the map. */
|
|
161
|
+
export function parseCheckerOutput(text: string): Map<number, CheckerVerdict> {
|
|
162
|
+
const out = new Map<number, CheckerVerdict>();
|
|
163
|
+
for (const m of text.matchAll(/<verdict\s+n="?(\d+)"?\s*>([\s\S]*?)<\/verdict>/g)) {
|
|
164
|
+
const n = Number(m[1]);
|
|
165
|
+
const body = m[2].trim();
|
|
166
|
+
const word = /^(keep|drop)\b/i.exec(body);
|
|
167
|
+
if (!n || !word) continue;
|
|
168
|
+
const reason = body
|
|
169
|
+
.slice(word[0].length)
|
|
170
|
+
.replace(/^[\s—:\-–]+/, "")
|
|
171
|
+
.replace(/\s+/g, " ")
|
|
172
|
+
.trim();
|
|
173
|
+
out.set(n, { n, verdict: word[1].toLowerCase() as "keep" | "drop", reason });
|
|
174
|
+
}
|
|
175
|
+
for (const m of text.matchAll(/<rewrite\s+n="?(\d+)"?\s*>([\s\S]*?)<\/rewrite>/g)) {
|
|
176
|
+
const v = out.get(Number(m[1]));
|
|
177
|
+
const body = m[2].replace(/\s+/g, " ").trim();
|
|
178
|
+
if (v && v.verdict === "keep" && body) v.rewrite = capChars(body, INBOX_TEXT_MAX_CHARS);
|
|
179
|
+
}
|
|
180
|
+
return out;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
export interface ParsedRunOutput {
|
|
184
|
+
state?: string;
|
|
185
|
+
findings: string[];
|
|
186
|
+
droppedFindings: number;
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
export function parseRunOutput(text: string): ParsedRunOutput {
|
|
190
|
+
const stateRaw = extractTagBlock(text, "loop-state");
|
|
191
|
+
const all = extractTagAll(text, "inbox", MAX_INBOX_TAGS_SCANNED);
|
|
192
|
+
const findings = all.slice(0, INBOX_TAGS_PER_RUN).map((f) => capChars(f.replace(/\s+/g, " "), INBOX_TEXT_MAX_CHARS));
|
|
193
|
+
return {
|
|
194
|
+
state: stateRaw === undefined ? undefined : capChars(stateRaw, LOOP_STATE_MAX_CHARS),
|
|
195
|
+
findings,
|
|
196
|
+
droppedFindings: Math.max(0, all.length - INBOX_TAGS_PER_RUN),
|
|
197
|
+
};
|
|
198
|
+
}
|
package/src/redact.ts
ADDED
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Secret redaction for anything that reaches a screen, a log or a gist.
|
|
3
|
+
*
|
|
4
|
+
* Loop prompts and sub-agent output routinely carry tokens ("check the API with key sk-…"), and
|
|
5
|
+
* what this project prints is read hours later by whoever opens the inbox or the audit. Every
|
|
6
|
+
* preview goes through here, so a token cannot be echoed back out of a place that outlives the run.
|
|
7
|
+
*/
|
|
8
|
+
const REDACTORS: Array<[string, RegExp]> = [
|
|
9
|
+
["openai_anthropic_key", /sk-[A-Za-z0-9_-]{20,}/g],
|
|
10
|
+
["aws_access_key", /\b(?:AKIA|ASIA)[0-9A-Z]{16}\b/g],
|
|
11
|
+
["github_token", /\bgh[ousp]_[A-Za-z0-9]{30,}\b/g],
|
|
12
|
+
["github_fine_grained", /\bgithub_pat_[A-Za-z0-9_]{20,}\b/g],
|
|
13
|
+
["slack_token", /\bxox[abprs]-[A-Za-z0-9-]{10,}\b/g],
|
|
14
|
+
["google_api_key", /\bAIza[0-9A-Za-z_-]{35}\b/g],
|
|
15
|
+
["bearer_token", /Bearer\s+[A-Za-z0-9._\-]{16,}/g],
|
|
16
|
+
// Browser login and loopback OAuth callback URLs carry auth state or one-time codes.
|
|
17
|
+
["login_url", /https?:\/\/[^\s]+\/login\?[^\s]+/g],
|
|
18
|
+
["callback_url", /http:\/\/127\.0\.0\.1:[0-9]+\/callback(?:\?[^\s]+)?/g],
|
|
19
|
+
["jwt", /\beyJ[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}\b/g],
|
|
20
|
+
["url_credentials", /(https?:\/\/)[^\s/:@]+:[^\s/@]+@/g],
|
|
21
|
+
["env_assignment", /\b([A-Z0-9_]*(?:TOKEN|SECRET|PASSWORD|API_KEY|APIKEY)[A-Z0-9_]*)=(['"]?)[^\s'"]{8,}\2/g],
|
|
22
|
+
// A whole transcript can be uploaded now (`/session-share`), so the net covers the shapes a secret takes
|
|
23
|
+
// in a file rather than only in a prompt: config and JSON pairs, PEM blocks, and the vendors
|
|
24
|
+
// whose keys use `_` where the older patterns expected `-`.
|
|
25
|
+
["stripe_key", /\b[sprk]k_(?:live|test)_[A-Za-z0-9]{16,}\b/g],
|
|
26
|
+
["private_key_block", /-----BEGIN [A-Z ]*PRIVATE KEY-----[\s\S]*?-----END [A-Z ]*PRIVATE KEY-----/g],
|
|
27
|
+
["keyed_value", /\b([A-Za-z0-9_.-]*(?:token|secret|password|passwd|api[_-]?key|apikey|credential)[A-Za-z0-9_.-]*)("|')?(\s*[:=]\s*)("|')?(?!\[REDACTED:)([^\s"',]{8,})\4?/gi],
|
|
28
|
+
];
|
|
29
|
+
|
|
30
|
+
export function redact(input: string): string {
|
|
31
|
+
let out = input;
|
|
32
|
+
for (const [label, re] of REDACTORS) {
|
|
33
|
+
re.lastIndex = 0;
|
|
34
|
+
out = out.replace(re, (match, ...groups) => {
|
|
35
|
+
if (label === "url_credentials") return `${groups[0]}[REDACTED:${label}]@`;
|
|
36
|
+
if (label === "env_assignment") return `${groups[0]}=[REDACTED:${label}]`;
|
|
37
|
+
// Keep the name, mask the value: "which key was it" is the useful half of the line.
|
|
38
|
+
if (label === "keyed_value") {
|
|
39
|
+
// The name, its closing quote and the separator are the reader's half of the line; only
|
|
40
|
+
// the value is replaced, so `"api_key": "…"` stays valid JSON and `X=…` stays a shell line.
|
|
41
|
+
const [name, nameQuote, separator, valueQuote] = groups as [string, string | undefined, string, string | undefined];
|
|
42
|
+
const q = valueQuote ?? "";
|
|
43
|
+
return `${name}${nameQuote ?? ""}${separator}${q}[REDACTED:${label}]${q}`;
|
|
44
|
+
}
|
|
45
|
+
return `[REDACTED:${label}]`;
|
|
46
|
+
});
|
|
47
|
+
}
|
|
48
|
+
return out;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** Redacted and capped, but with the text's own line structure intact: capped, never reflowed. */
|
|
52
|
+
export function capRedacted(input: string, maxChars: number): string {
|
|
53
|
+
const chars = Array.from(redact(input));
|
|
54
|
+
return chars.length > maxChars ? `${chars.slice(0, maxChars).join("")}…` : chars.join("");
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** Redacted, collapsed to one line and capped: for the one-line previews a TUI row shows. */
|
|
58
|
+
export function previewRedacted(input: string, maxChars: number): string {
|
|
59
|
+
const chars = Array.from(redact(input).replace(/\s+/g, " ").trim());
|
|
60
|
+
return chars.length > maxChars ? `${chars.slice(0, maxChars).join("")}…` : chars.join("");
|
|
61
|
+
}
|
package/src/runner.ts
ADDED
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Sub-agent runs. A run is a fresh conversation (no parent history)
|
|
3
|
+
* with the parent's tools, model and skills, executed by a `SubagentRunner`. The production
|
|
4
|
+
* runner (`sdk-runner.ts`) opens the conversation *inside the interactive pi process* through
|
|
5
|
+
* pi's SDK, in-process, so the parent's live MCP servers, `-e`
|
|
6
|
+
* extensions and system prompt are shared. Tests inject a fake runner.
|
|
7
|
+
*/
|
|
8
|
+
import * as fs from "node:fs";
|
|
9
|
+
import * as path from "node:path";
|
|
10
|
+
|
|
11
|
+
export interface RunnerUsage {
|
|
12
|
+
input: number;
|
|
13
|
+
output: number;
|
|
14
|
+
/** Cached prompt tokens (pi bills them differently from `input`); optional — a fake runner may omit them. */
|
|
15
|
+
cacheRead?: number;
|
|
16
|
+
cacheWrite?: number;
|
|
17
|
+
cost: number;
|
|
18
|
+
turns: number;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export interface RunnerResult {
|
|
22
|
+
ok: boolean;
|
|
23
|
+
timedOut: boolean;
|
|
24
|
+
/** Final assistant text (last assistant message with text content). */
|
|
25
|
+
text: string;
|
|
26
|
+
errorMessage?: string;
|
|
27
|
+
stopReason?: string;
|
|
28
|
+
/**
|
|
29
|
+
* The run finished, but not as configured — a pinned model that no longer resolves, say. Kept
|
|
30
|
+
* apart from `errorMessage` so a self-healed run is not reported as a failure.
|
|
31
|
+
*/
|
|
32
|
+
warning?: string;
|
|
33
|
+
model?: string;
|
|
34
|
+
/** What the run had to do to get through: silent provider retries and context compactions. */
|
|
35
|
+
retries?: number;
|
|
36
|
+
compactions?: number;
|
|
37
|
+
usage: RunnerUsage;
|
|
38
|
+
sessionId?: string;
|
|
39
|
+
/** Transcript file when `sessionDir` was given. */
|
|
40
|
+
sessionFile?: string;
|
|
41
|
+
/** Kept for run records: 0 on success, 1 on failure (there is no child process any more). */
|
|
42
|
+
exitCode?: number;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export interface SubagentRequest {
|
|
46
|
+
cwd: string;
|
|
47
|
+
prompt: string;
|
|
48
|
+
/** "provider/id"; undefined → the interactive session's current model. */
|
|
49
|
+
model?: string;
|
|
50
|
+
thinking?: string;
|
|
51
|
+
/** Allowlist of tool names (built-in, MCP and automation tools alike). */
|
|
52
|
+
tools?: string[];
|
|
53
|
+
/**
|
|
54
|
+
* Called once the sub-session's transcript file exists, so a run in flight can be inspected
|
|
55
|
+
* while it is still going (`/cron trace <job> live`) rather than only after it finishes.
|
|
56
|
+
*/
|
|
57
|
+
onSessionFile?: (file: string) => void;
|
|
58
|
+
timeoutMs: number;
|
|
59
|
+
signal?: AbortSignal;
|
|
60
|
+
/** Keep the transcript here (a pi session file); omit for an in-memory run. */
|
|
61
|
+
sessionDir?: string;
|
|
62
|
+
/** Trigger hop: 1 for a sub-agent of the interactive pi, for cycle suppression. */
|
|
63
|
+
hop: number;
|
|
64
|
+
/** The interactive session this run acts for; plain cron jobs it schedules bind to it. */
|
|
65
|
+
parentSessionId?: string;
|
|
66
|
+
parentCwd?: string;
|
|
67
|
+
kind: "loop" | "checker" | "trigger";
|
|
68
|
+
jobId?: string;
|
|
69
|
+
runId?: string;
|
|
70
|
+
traceId?: string;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export type SubagentRunner = (req: SubagentRequest) => Promise<RunnerResult>;
|
|
74
|
+
|
|
75
|
+
export function failedRun(message: string, extra: Partial<RunnerResult> = {}): RunnerResult {
|
|
76
|
+
return { ok: false, exitCode: 1, timedOut: false, text: "", errorMessage: message, usage: { input: 0, output: 0, cost: 0, turns: 0 }, ...extra };
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/* ------------------------------------------- the parent's runtime shape */
|
|
80
|
+
|
|
81
|
+
/** What the interactive pi was started with that a sub-agent should share. */
|
|
82
|
+
export interface ParentRuntimeFlags {
|
|
83
|
+
extensionPaths: string[];
|
|
84
|
+
skillPaths: string[];
|
|
85
|
+
promptTemplatePaths: string[];
|
|
86
|
+
appendSystemPrompt: string[];
|
|
87
|
+
systemPrompt?: string;
|
|
88
|
+
noSkills: boolean;
|
|
89
|
+
noExtensions: boolean;
|
|
90
|
+
noContextFiles: boolean;
|
|
91
|
+
noPromptTemplates: boolean;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* A sub-agent may run in another project's cwd, and pi resolves `-e ./ext.ts` against its cwd
|
|
96
|
+
* and loads explicit `-e` paths regardless of trust — so a relative path is pinned to the
|
|
97
|
+
* parent's cwd here, or a same-named file in an untrusted project would be executed. Package
|
|
98
|
+
* specs (`npm:…`, `git:…`, bare names) are left alone.
|
|
99
|
+
*/
|
|
100
|
+
export function absolutizeFlagValue(value: string, parentCwd: string): string {
|
|
101
|
+
if (/^(npm|git|https?|file):/.test(value)) return value;
|
|
102
|
+
if (value.startsWith("~/")) return path.join(process.env.HOME ?? "", value.slice(2));
|
|
103
|
+
if (value.startsWith("./") || value.startsWith("../") || value.includes("/") || value === "." || value === "..") return path.resolve(parentCwd, value);
|
|
104
|
+
if (fs.existsSync(path.resolve(parentCwd, value))) return path.resolve(parentCwd, value);
|
|
105
|
+
return value;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/** Parse the parent pi's argv for the flags a sub-agent inherits. Secrets, identity, mode and tool selection stay with the job. */
|
|
109
|
+
export function parentRuntimeFlags(argv: string[], parentCwd = process.cwd()): ParentRuntimeFlags {
|
|
110
|
+
const out: ParentRuntimeFlags = { extensionPaths: [], skillPaths: [], promptTemplatePaths: [], appendSystemPrompt: [], noSkills: false, noExtensions: false, noContextFiles: false, noPromptTemplates: false };
|
|
111
|
+
const valued = new Set(["-e", "--extension", "--skill", "--prompt-template", "--append-system-prompt", "--system-prompt"]);
|
|
112
|
+
const skipValued = new Set(["--api-key", "--provider", "--model", "--thinking", "--session", "--session-id", "--session-dir", "--fork", "--name", "-n", "--models", "--tools", "-t", "--exclude-tools", "-xt", "--mode", "--export", "--list-models", "--tui-mode", "--use-theme", "--theme", "--trigger-poll-secs"]);
|
|
113
|
+
const take = (flag: string, value: string) => {
|
|
114
|
+
switch (flag) {
|
|
115
|
+
case "-e":
|
|
116
|
+
case "--extension":
|
|
117
|
+
out.extensionPaths.push(absolutizeFlagValue(value, parentCwd));
|
|
118
|
+
break;
|
|
119
|
+
case "--skill":
|
|
120
|
+
out.skillPaths.push(absolutizeFlagValue(value, parentCwd));
|
|
121
|
+
break;
|
|
122
|
+
case "--prompt-template":
|
|
123
|
+
out.promptTemplatePaths.push(absolutizeFlagValue(value, parentCwd));
|
|
124
|
+
break;
|
|
125
|
+
case "--append-system-prompt":
|
|
126
|
+
out.appendSystemPrompt.push(value);
|
|
127
|
+
break;
|
|
128
|
+
case "--system-prompt":
|
|
129
|
+
out.systemPrompt = value;
|
|
130
|
+
break;
|
|
131
|
+
}
|
|
132
|
+
};
|
|
133
|
+
for (let i = 2; i < argv.length; i++) {
|
|
134
|
+
const a = argv[i];
|
|
135
|
+
if (a === "--") break;
|
|
136
|
+
if (valued.has(a)) {
|
|
137
|
+
if (i + 1 < argv.length) take(a, argv[++i]);
|
|
138
|
+
} else if (a === "--no-skills" || a === "-ns") out.noSkills = true;
|
|
139
|
+
else if (a === "--no-extensions" || a === "-ne") out.noExtensions = true;
|
|
140
|
+
else if (a === "--no-context-files" || a === "-nc") out.noContextFiles = true;
|
|
141
|
+
else if (a === "--no-prompt-templates" || a === "-np") out.noPromptTemplates = true;
|
|
142
|
+
else if (skipValued.has(a)) i++;
|
|
143
|
+
else if (a.startsWith("--") && a.includes("=")) {
|
|
144
|
+
const [k, v] = a.split(/=(.*)/s);
|
|
145
|
+
if (valued.has(k)) take(k, v);
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
return out;
|
|
149
|
+
}
|