@alphacoder-v0/pi-loops 0.22.7 → 0.24.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +681 -0
- package/CONTRACT.md +7 -7
- package/README.md +91 -63
- package/README.zh-CN.md +59 -39
- package/docs/cli.md +272 -83
- package/docs/configuration.md +86 -24
- package/docs/design.md +395 -56
- package/docs/downstream.md +2 -2
- package/docs/goal.md +12 -3
- package/docs/hooks.md +6 -7
- package/docs/loops.md +288 -59
- package/docs/mcp.md +22 -14
- package/docs/recipes.md +6 -0
- package/docs/screenshot.png +0 -0
- package/docs/triggers.md +120 -26
- package/docs/troubleshooting.md +37 -26
- package/docs/web-ui-parity.md +691 -65
- package/package.json +4 -3
- package/recipes/autoresearch/recipe.toml +1 -0
- package/recipes/changelog-draft/recipe.toml +1 -0
- package/recipes/ci-sweeper/recipe.toml +1 -0
- package/recipes/daily-digest/recipe.toml +1 -0
- package/recipes/deps-sweeper/recipe.toml +1 -0
- package/recipes/ecosystem/recipe.toml +1 -0
- package/recipes/issue-loop/recipe.toml +1 -0
- package/recipes/pr-watch/recipe.toml +1 -0
- package/skills/pi-loops/SKILL.md +6 -4
- package/src/activity.ts +199 -0
- package/src/archive.ts +21 -9
- package/src/args.ts +6 -2
- package/src/cli-entry.mjs +12 -3
- package/src/cli.ts +746 -125
- package/src/config.ts +53 -5
- package/src/conversation-rows.ts +469 -0
- package/src/danger.ts +227 -6
- package/src/engine.ts +1240 -0
- package/src/goal.ts +71 -0
- package/src/held-read.ts +27 -0
- package/src/hooks.ts +49 -18
- package/src/host-control-channel.ts +455 -40
- package/src/host-control.ts +602 -111
- package/src/host-entry.mjs +5 -3
- package/src/host-runtime.ts +455 -41
- package/src/host.ts +659 -123
- package/src/inbox-card.ts +278 -0
- package/src/inbox.ts +139 -62
- package/src/job-edit.ts +7 -1
- package/src/job-health.ts +14 -2
- package/src/job-lines.ts +201 -13
- package/src/job-owner.ts +74 -13
- package/src/job-tune.ts +110 -0
- package/src/lease.ts +537 -0
- package/src/lock.ts +7 -2
- package/src/mcp-pool.ts +66 -14
- package/src/mcp.ts +1 -1
- package/src/open-file.ts +54 -0
- package/src/page-children.ts +1507 -0
- package/src/page-devices.ts +192 -0
- package/src/page-server.ts +3150 -0
- package/src/path-precheck.ts +215 -0
- package/src/paths.ts +24 -0
- package/src/pi-floor.ts +10 -0
- package/src/pi-loops.ts +1301 -450
- package/src/pi-resolver.d.mts +1 -0
- package/src/pi-resolver.mjs +110 -10
- package/src/presence.ts +12 -63
- package/src/protocol.ts +10 -1
- package/src/recipe.ts +43 -1
- package/src/requests.ts +354 -0
- package/src/rule-lines.ts +68 -0
- package/src/run-card.ts +105 -0
- package/src/runner.ts +18 -1
- package/src/schedule.ts +1 -2
- package/src/scheduler.ts +598 -245
- package/src/sdk-runner.ts +321 -27
- package/src/seen.ts +278 -0
- package/src/session-head.ts +21 -0
- package/src/share.ts +12 -2
- package/src/snapshot.ts +3 -2
- package/src/store.ts +81 -8
- package/src/tools.ts +196 -65
- package/src/trigger-runtime.ts +164 -127
- package/src/triggers.ts +39 -7
- package/src/trust.ts +46 -2
- package/src/ui-prefs.ts +16 -5
- package/src/watch-plan.ts +174 -0
- package/src/web.d.mts +3 -0
- package/src/web.mjs +2260 -2340
- package/src/window-executor.ts +459 -0
- package/src/window-guard.ts +158 -0
- package/docs/agents/issue-tracker.md +0 -34
|
@@ -0,0 +1,192 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* What lets a browser back in without the token ever riding in a cookie.
|
|
3
|
+
*
|
|
4
|
+
* A cookie ignores ports, so `pi_web_token=<token>` went to every server on 127.0.0.1: anything that
|
|
5
|
+
* bound a loopback port and got one request from the browser held the token for good. Two things
|
|
6
|
+
* replace it. The device cookie names a browser and opens nothing alone: the page trades it for
|
|
7
|
+
* the token only together with a secret it keeps in `localStorage`, which belongs to this origin
|
|
8
|
+
* (port included) and which no server on another port can read. And the three addresses a browser
|
|
9
|
+
* fetches without our header (the event stream, a file, a preview) take a key minted for that one
|
|
10
|
+
* address over the header-authenticated API.
|
|
11
|
+
*/
|
|
12
|
+
import { createHash, randomBytes, timingSafeEqual } from "node:crypto";
|
|
13
|
+
import * as fs from "node:fs";
|
|
14
|
+
import * as path from "node:path";
|
|
15
|
+
import { writeFileAtomic } from "./lock.ts";
|
|
16
|
+
import { stamp } from "./schedule.ts";
|
|
17
|
+
|
|
18
|
+
export const DEVICE_COOKIE = "pi_web_device";
|
|
19
|
+
/** Where the page keeps the device secret. Shared by the page and the plain pages that hand it over. */
|
|
20
|
+
export const DEVICE_STORAGE_KEY = "pi-loops-device";
|
|
21
|
+
/** Wrong secrets one device takes before it is forgotten: a typo is not a thing a script makes twenty times. */
|
|
22
|
+
export const DEVICE_MAX_TRIES = 20;
|
|
23
|
+
/** Devices kept; the one seen least recently goes first. A browser that comes back keeps its id. */
|
|
24
|
+
export const DEVICE_CAP = 64;
|
|
25
|
+
|
|
26
|
+
export interface DeviceRecord {
|
|
27
|
+
since: string;
|
|
28
|
+
lastSeen: string;
|
|
29
|
+
secretHash: string;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** `<loops>/web-devices.json`. `tokenHash` ties it to one token: a new token starts with no devices. */
|
|
33
|
+
export interface DeviceFile {
|
|
34
|
+
v: 1;
|
|
35
|
+
tokenHash: string;
|
|
36
|
+
devices: Record<string, DeviceRecord>;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
const sha256 = (s: string) => createHash("sha256").update(s).digest("hex");
|
|
40
|
+
const DEVICE_ID = /^[0-9a-f]{32}$/;
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Whether `secret` is the one this device was given. Undefined `record`: no such device, which is
|
|
44
|
+
* 401 rather than 403 because there is nothing to count a try against.
|
|
45
|
+
*/
|
|
46
|
+
export function deviceVerdict(record: DeviceRecord | undefined, secret: string | undefined): "ok" | "unknown" | "bad" {
|
|
47
|
+
if (!record) return "unknown";
|
|
48
|
+
if (!secret) return "bad";
|
|
49
|
+
const given = Buffer.from(sha256(secret));
|
|
50
|
+
const kept = Buffer.from(record.secretHash);
|
|
51
|
+
return given.length === kept.length && timingSafeEqual(given, kept) ? "ok" : "bad";
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** The file as read, or an empty one when it is missing, malformed, or was made for another token. */
|
|
55
|
+
export function parseDeviceFile(text: string | undefined, tokenHash: string): DeviceFile {
|
|
56
|
+
const empty: DeviceFile = { v: 1, tokenHash, devices: {} };
|
|
57
|
+
if (text === undefined) return empty;
|
|
58
|
+
let raw: any;
|
|
59
|
+
try {
|
|
60
|
+
raw = JSON.parse(text);
|
|
61
|
+
} catch {
|
|
62
|
+
return empty; // a torn or hand-edited file: every browser pairs again, which is the safe side
|
|
63
|
+
}
|
|
64
|
+
if (raw?.v !== 1 || raw.tokenHash !== tokenHash || typeof raw.devices !== "object" || raw.devices === null) return empty;
|
|
65
|
+
const devices: Record<string, DeviceRecord> = {};
|
|
66
|
+
for (const [id, r] of Object.entries<any>(raw.devices)) {
|
|
67
|
+
if (DEVICE_ID.test(id) && typeof r?.secretHash === "string" && typeof r.since === "string" && typeof r.lastSeen === "string") devices[id] = { since: r.since, lastSeen: r.lastSeen, secretHash: r.secretHash };
|
|
68
|
+
}
|
|
69
|
+
return { v: 1, tokenHash, devices };
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** The browsers that paired with this token. Read once; every change is written through, 0600. */
|
|
73
|
+
export class DeviceStore {
|
|
74
|
+
private readonly file: string;
|
|
75
|
+
private data: DeviceFile;
|
|
76
|
+
private readonly tries = new Map<string, number>();
|
|
77
|
+
private readonly log: (line: string) => void;
|
|
78
|
+
|
|
79
|
+
constructor(dir: string, token: string, log: (line: string) => void) {
|
|
80
|
+
this.log = log;
|
|
81
|
+
this.file = path.join(dir, "web-devices.json");
|
|
82
|
+
let text: string | undefined;
|
|
83
|
+
try {
|
|
84
|
+
text = fs.readFileSync(this.file, "utf8");
|
|
85
|
+
} catch {
|
|
86
|
+
text = undefined; // no file yet: no devices
|
|
87
|
+
}
|
|
88
|
+
this.data = parseDeviceFile(text, sha256(token));
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
known(id: string | undefined): boolean {
|
|
92
|
+
return id !== undefined && Object.hasOwn(this.data.devices, id);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* A device for a browser that just proved itself with the open key, the pairing code or the token.
|
|
97
|
+
* A browser that already has a device keeps its id and gets a new secret; any other gets a new id.
|
|
98
|
+
*/
|
|
99
|
+
enroll(id: string | undefined): { id: string; secret: string } {
|
|
100
|
+
const now = stamp();
|
|
101
|
+
const keep = this.known(id) ? (id as string) : randomBytes(16).toString("hex");
|
|
102
|
+
const secret = randomBytes(32).toString("hex");
|
|
103
|
+
this.data.devices[keep] = { since: this.data.devices[keep]?.since ?? now, lastSeen: now, secretHash: sha256(secret) };
|
|
104
|
+
this.tries.delete(keep);
|
|
105
|
+
const others = Object.keys(this.data.devices)
|
|
106
|
+
.filter((other) => other !== keep)
|
|
107
|
+
.sort((a, b) => Date.parse(this.data.devices[b].lastSeen) - Date.parse(this.data.devices[a].lastSeen));
|
|
108
|
+
for (const old of others.slice(DEVICE_CAP - 1)) delete this.data.devices[old];
|
|
109
|
+
this.save();
|
|
110
|
+
return { id: keep, secret };
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/** The cookie and the secret together, for the token. A wrong secret spends one of twenty tries. */
|
|
114
|
+
trade(id: string | undefined, secret: string | undefined): { ok: true } | { status: 401 | 403; why: string } {
|
|
115
|
+
const record = this.known(id) ? this.data.devices[id as string] : undefined;
|
|
116
|
+
const verdict = deviceVerdict(record, secret);
|
|
117
|
+
if (verdict === "unknown") return { status: 401, why: "no device" };
|
|
118
|
+
const device = id as string;
|
|
119
|
+
if (verdict === "bad") {
|
|
120
|
+
const n = (this.tries.get(device) ?? 0) + 1;
|
|
121
|
+
this.tries.set(device, n);
|
|
122
|
+
if (n >= DEVICE_MAX_TRIES) {
|
|
123
|
+
delete this.data.devices[device];
|
|
124
|
+
this.tries.delete(device);
|
|
125
|
+
this.save();
|
|
126
|
+
this.log("pi-loops page: a browser gave the wrong device secret twenty times; it pairs again from now on");
|
|
127
|
+
}
|
|
128
|
+
return { status: 403, why: "bad device secret" };
|
|
129
|
+
}
|
|
130
|
+
this.tries.delete(device);
|
|
131
|
+
this.data.devices[device].lastSeen = stamp();
|
|
132
|
+
this.save();
|
|
133
|
+
return { ok: true };
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
private save() {
|
|
137
|
+
try {
|
|
138
|
+
writeFileAtomic(this.file, `${JSON.stringify(this.data, null, 2)}\n`, 0o600);
|
|
139
|
+
} catch (err: any) {
|
|
140
|
+
// The device still works until pi-loops restarts; after that the browser pairs again.
|
|
141
|
+
this.log(`pi-loops page: could not write ${this.file}: ${err?.message ?? err}`);
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/* ------------------------------------------------------------------ keys for one address */
|
|
147
|
+
|
|
148
|
+
export type KeyKind = "events" | "file" | "preview";
|
|
149
|
+
export const KEY_KINDS: readonly KeyKind[] = ["events", "file", "preview"];
|
|
150
|
+
|
|
151
|
+
export interface UrlKey {
|
|
152
|
+
sessionId: string;
|
|
153
|
+
kind: KeyKind;
|
|
154
|
+
/** The exact `path` a `file` key opens, or the id a `preview` key opens. */
|
|
155
|
+
path?: string;
|
|
156
|
+
expires: number;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/**
|
|
160
|
+
* An EventSource reconnects on its own with the address it was given, so an `events` key is good
|
|
161
|
+
* for every reconnect within ten minutes. A file and a preview are one load each.
|
|
162
|
+
*/
|
|
163
|
+
export const KEY_TTL_MS: Readonly<Record<KeyKind, number>> = { events: 10 * 60_000, file: 60_000, preview: 60_000 };
|
|
164
|
+
export const KEYS_CAP = 256;
|
|
165
|
+
|
|
166
|
+
/** Whether `key` opens what is asked. A preview is not under a conversation, so only its id binds it. */
|
|
167
|
+
export function keyOpens(key: UrlKey | undefined, ask: { kind: KeyKind; sessionId?: string; path?: string }, now: number): boolean {
|
|
168
|
+
if (!key || key.kind !== ask.kind || key.expires <= now) return false;
|
|
169
|
+
if (ask.kind !== "preview" && key.sessionId !== ask.sessionId) return false;
|
|
170
|
+
return ask.kind === "events" || key.path === ask.path;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/** Keys in memory, capped: past the cap the oldest one goes. */
|
|
174
|
+
export class UrlKeys {
|
|
175
|
+
private readonly keys = new Map<string, UrlKey>();
|
|
176
|
+
|
|
177
|
+
mint(sessionId: string, kind: KeyKind, keyPath?: string, now = Date.now()): string {
|
|
178
|
+
for (const [k, v] of this.keys) if (v.expires <= now) this.keys.delete(k);
|
|
179
|
+
const key = randomBytes(16).toString("hex");
|
|
180
|
+
this.keys.set(key, { sessionId, kind, path: keyPath, expires: now + KEY_TTL_MS[kind] });
|
|
181
|
+
while (this.keys.size > KEYS_CAP) this.keys.delete(this.keys.keys().next().value as string);
|
|
182
|
+
return key;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
/** Whether `key` opens what is asked. A file or preview key is spent by being shown, right or wrong. */
|
|
186
|
+
spend(key: string | null, ask: { kind: KeyKind; sessionId?: string; path?: string }, now = Date.now()): boolean {
|
|
187
|
+
if (!key) return false;
|
|
188
|
+
const found = this.keys.get(key);
|
|
189
|
+
if (found && found.kind !== "events") this.keys.delete(key);
|
|
190
|
+
return keyOpens(found, ask, now);
|
|
191
|
+
}
|
|
192
|
+
}
|