@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,3150 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The HTTP server in front of the browser page: the token, the Host and cross-site checks, pairing,
|
|
3
|
+
* and every route. What belongs to the conversation itself (pi's process, its events, its catalog,
|
|
4
|
+
* its session file) is the `PageChild` of src/page-children.ts that `child()` returns.
|
|
5
|
+
*
|
|
6
|
+
* The automation panel reads pi-loops' own files (jobs.json, triggers.json, inbox.jsonl) rather
|
|
7
|
+
* than scraping command output: they are on this machine, they are JSON, and text meant for a
|
|
8
|
+
* human is a bad wire format.
|
|
9
|
+
*/
|
|
10
|
+
import { randomBytes, randomInt, randomUUID, timingSafeEqual } from "node:crypto";
|
|
11
|
+
import * as fs from "node:fs";
|
|
12
|
+
import * as http from "node:http";
|
|
13
|
+
import type { AddressInfo } from "node:net";
|
|
14
|
+
import * as os from "node:os";
|
|
15
|
+
import * as path from "node:path";
|
|
16
|
+
import { fileURLToPath } from "node:url";
|
|
17
|
+
import { ConversationIndex, holderOf, NeedsYouCounts, plainText, projectArgs, type RowReaders, titleOf } from "./conversation-rows.ts";
|
|
18
|
+
import { CLAIM_QUEUE_LOCK, cancelRow, isPageClaim, PAGE_INSTANCE, pageClaimRow, personRow, queueOrder, requestFingerprint } from "./engine.ts";
|
|
19
|
+
import { COPY_KEY_MS, type HostSnapshot, type PageOpened, type PageOpenRequest, requestLines, type StopAnswer } from "./host-control-channel.ts";
|
|
20
|
+
import { buildSessionProjection, migrateSessionEntries, parseSessionEntries, type SessionEntry } from "@earendil-works/pi-coding-agent";
|
|
21
|
+
import { DELIVERY_JOB_ID, holderAlive, holderDelivers, type LeaseHolder, LeaseStore, procStart, type SessionLease, sessionFileName, shouldReadAgain } from "./lease.ts";
|
|
22
|
+
import { ACTIVITY_WINDOW_MS, activityFrom, attentionFrom, machineWide } from "./activity.ts";
|
|
23
|
+
import { formatSchedule, stamp } from "./schedule.ts";
|
|
24
|
+
import { type Look, lookLine, watchOf } from "./path-precheck.ts";
|
|
25
|
+
import { loopSignal, type LoopSignal, SIGNAL_WINDOW_MS } from "./job-signal.ts";
|
|
26
|
+
import { applyTune, rowSignal, scheduleAudit, suggestTune } from "./job-tune.ts";
|
|
27
|
+
import { HELD_READ_MAX_BYTES } from "./held-read.ts";
|
|
28
|
+
import { Inbox, type InboxEntry, inProject } from "./inbox.ts";
|
|
29
|
+
import { type ClaimDelivery, type InboxCard, DISMISS_CHIPS, cardsOf, claimLine, claimPrompt, dismissLine, dismissOutcome, screenText } from "./inbox-card.ts";
|
|
30
|
+
import { INBOX_TEXT_MAX_CHARS } from "./protocol.ts";
|
|
31
|
+
import { redact } from "./redact.ts";
|
|
32
|
+
import { readSessionHead } from "./session-head.ts";
|
|
33
|
+
import { JobStore, type LoopJob, MAX_PROMPT_BYTES } from "./store.ts";
|
|
34
|
+
import { summarizeSessionFile } from "./transcript.ts";
|
|
35
|
+
import { conversationLabel } from "./session-head.ts";
|
|
36
|
+
import { pidAlive } from "./lock.ts";
|
|
37
|
+
import { asleepNote, type OwnerView, runsSomewhere, whereRuns } from "./job-owner.ts";
|
|
38
|
+
import { loopLabel, snapshotLoopLabel } from "./job-lines.ts";
|
|
39
|
+
import {
|
|
40
|
+
type ChildPool,
|
|
41
|
+
type ChildView,
|
|
42
|
+
COMMANDS_TTL_MS,
|
|
43
|
+
DELIVERY_LABEL,
|
|
44
|
+
decideOpen,
|
|
45
|
+
guard,
|
|
46
|
+
isDialog,
|
|
47
|
+
maskSecrets,
|
|
48
|
+
NO_PI_LOOPS,
|
|
49
|
+
PAGE_CRASH_BUDGET,
|
|
50
|
+
PAGE_STREAMS_PER_CHILD,
|
|
51
|
+
PAGE_STREAMS_TOTAL,
|
|
52
|
+
type PageChild,
|
|
53
|
+
readHead,
|
|
54
|
+
} from "./page-children.ts";
|
|
55
|
+
import { removeOpenFile, sweepOpenFiles, writeOpenFile } from "./open-file.ts";
|
|
56
|
+
import { DEVICE_COOKIE, DEVICE_STORAGE_KEY, DeviceStore, KEY_KINDS, type KeyKind, UrlKeys } from "./page-devices.ts";
|
|
57
|
+
import { realpathish, sameProject } from "./paths.ts";
|
|
58
|
+
import { RequestStore, type SessionRequest } from "./requests.ts";
|
|
59
|
+
import { awayIn, markProjectSeen, type SinceYouLooked } from "./seen.ts";
|
|
60
|
+
import { listRecipes, type Starter, starters } from "./recipe.ts";
|
|
61
|
+
import { newTraceId, TriggerStore } from "./triggers.ts";
|
|
62
|
+
import { writeUiPref } from "./ui-prefs.ts";
|
|
63
|
+
import { DOOR, ICON, PAGE } from "./web.mjs";
|
|
64
|
+
import { producerAlive } from "./window-executor.ts";
|
|
65
|
+
|
|
66
|
+
export interface PageServerOptions {
|
|
67
|
+
dir: string;
|
|
68
|
+
bind: { host: string; port: number };
|
|
69
|
+
allowHosts: string[];
|
|
70
|
+
token: string;
|
|
71
|
+
version: string;
|
|
72
|
+
pool: ChildPool;
|
|
73
|
+
/** How a session id from HTTP is checked, and how its pi is started: the lease store's `read`. */
|
|
74
|
+
sessions: { known: (sessionId: string) => Partial<Pick<SessionLease, "sessionFile" | "cwd" | "holder">> | undefined };
|
|
75
|
+
/** The turn pi-loops is running in this conversation, if any: opening it waits for that turn. */
|
|
76
|
+
heldTurn?: (sessionId: string) => { runId: string; label: string } | undefined;
|
|
77
|
+
/** Stop it, on the waiting page. */
|
|
78
|
+
abortRun?: (runId: string) => boolean;
|
|
79
|
+
/** Stop this run, on a loop row (src/host-control-channel.ts stopRun): the whole run id, never a prefix. */
|
|
80
|
+
stopRun?: (runId: string) => StopAnswer;
|
|
81
|
+
/** A rule that waits for a path: its last look in the process that checks rules, and when the next one is. */
|
|
82
|
+
ruleLook?: (ruleId: string) => { look: Look; nextAt: number } | undefined;
|
|
83
|
+
/**
|
|
84
|
+
* pi's sessions directory, which the conversation list reads (src/conversation-rows.ts). Without it
|
|
85
|
+
* the list reads only the directories of the session files leases name, so a server built in a
|
|
86
|
+
* test never reads the person's own.
|
|
87
|
+
*/
|
|
88
|
+
sessionsRoot?: string;
|
|
89
|
+
/** The conversation `GET /` opens. */
|
|
90
|
+
lastPage?: () => string | undefined;
|
|
91
|
+
rememberPage?: (sessionId: string) => void;
|
|
92
|
+
/** What pi-loops in the background is doing: the Background view. Without it there is none. */
|
|
93
|
+
background?: () => HostStatus;
|
|
94
|
+
/** Today's automation spend against the daily cap, for `GET /activity`'s attention block. */
|
|
95
|
+
budget?: () => { spent: number; cap: number; over: boolean };
|
|
96
|
+
/** How often queued page claims are looked at again; `PAGE_CLAIM_SWEEP_MS` unless a test says otherwise. */
|
|
97
|
+
claimSweepMs?: number;
|
|
98
|
+
log: (line: string) => void;
|
|
99
|
+
onListenError: (err: NodeJS.ErrnoException) => void;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
export interface PageServer {
|
|
103
|
+
url(): string;
|
|
104
|
+
port(): number;
|
|
105
|
+
newPairCode(): string;
|
|
106
|
+
/** A one-shot key, good for one load within a minute (or `ttlMs`), that opens this conversation's page. */
|
|
107
|
+
openKeyFor(sessionId: string, ttlMs?: number): string;
|
|
108
|
+
/**
|
|
109
|
+
* The same key, kept in a file only this user can read that sends a browser to the keyed page:
|
|
110
|
+
* what `pi-loops` opens, so the key is in no command line. The file goes with the key.
|
|
111
|
+
*/
|
|
112
|
+
openFileFor(sessionId: string): { openKey: string; openFile: string };
|
|
113
|
+
/** Event streams open across every child. */
|
|
114
|
+
clients(): number;
|
|
115
|
+
close(): void;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* The token is kept in a file rather than made fresh each launch, so the address stays the same
|
|
120
|
+
* one every time: bookmark http://127.0.0.1:4173/ and it works tomorrow. It is a boring secret —
|
|
121
|
+
* 0600 in the loops directory, next to the jobs it lets you run — and it exists because anything
|
|
122
|
+
* that reaches /rpc gets the whole session, including any *website* you happen to have open (a
|
|
123
|
+
* page cannot read this server's answers, but without a check it could still tell your agent what
|
|
124
|
+
* to do). You will not have to type it: a browser is remembered after its first visit.
|
|
125
|
+
*/
|
|
126
|
+
export function webToken(dir: string, fail: (message: string) => never): string {
|
|
127
|
+
const file = path.join(dir, "web-token");
|
|
128
|
+
// 0.22.8 kept its token in a cookie that every port on 127.0.0.1 receives for a year, so the
|
|
129
|
+
// first 0.24 start replaces a token it finds without this marker, once. A token written after
|
|
130
|
+
// the marker is the person's own and is kept.
|
|
131
|
+
const marker = path.join(dir, "web-token.v024");
|
|
132
|
+
const rotated = fs.existsSync(marker);
|
|
133
|
+
try {
|
|
134
|
+
// lstat, not stat: a symlink here is not a token file, it is someone else choosing where
|
|
135
|
+
// this process reads and writes. LOOPS_DIR can be pointed anywhere with --loops-dir.
|
|
136
|
+
const st = fs.lstatSync(file);
|
|
137
|
+
if (!st.isFile()) throw new Error(`${file} is not a regular file`);
|
|
138
|
+
if (typeof process.getuid === "function" && st.uid !== process.getuid()) throw new Error(`${file} belongs to another user`);
|
|
139
|
+
// The mode is only applied when the file is created, so a backup restored at 0644 would
|
|
140
|
+
// otherwise stay that way for ever, readable by every account on the machine.
|
|
141
|
+
if (st.mode & 0o077) fs.chmodSync(file, 0o600);
|
|
142
|
+
const found = fs.readFileSync(file, "utf8").trim();
|
|
143
|
+
if (rotated && /^[A-Za-z0-9_-]{8,}$/.test(found)) return found;
|
|
144
|
+
} catch (err: any) {
|
|
145
|
+
if (err?.code !== "ENOENT") {
|
|
146
|
+
// Anything other than "not there yet" is a thing to say out loud rather than paper over
|
|
147
|
+
// by writing a new token: it means the path is not what this expects it to be.
|
|
148
|
+
fail(`pi-loops web: ${err?.message ?? err}`);
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
const made = randomBytes(16).toString("hex");
|
|
152
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
153
|
+
fs.writeFileSync(file, `${made}\n`, { mode: 0o600, flag: "w" });
|
|
154
|
+
fs.chmodSync(file, 0o600);
|
|
155
|
+
if (!rotated) {
|
|
156
|
+
// `wx`: a marker that is not there yet is created, never followed. A dangling symlink in its
|
|
157
|
+
// place is someone else choosing where this writes, as with the token file above.
|
|
158
|
+
try {
|
|
159
|
+
fs.writeFileSync(marker, "", { mode: 0o600, flag: "wx" });
|
|
160
|
+
} catch (err: any) {
|
|
161
|
+
fail(`pi-loops web: could not write ${marker}: ${err?.message ?? err}`);
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
return made;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/* ------------------------------------------------------------------ pi-loops, read from disk */
|
|
168
|
+
|
|
169
|
+
/** How much of what a terminal added the page reads to tell whether it holds a message. */
|
|
170
|
+
const ADDED_READ_MAX_BYTES = 256 * 1024;
|
|
171
|
+
|
|
172
|
+
/** A run card in a `/history` answer (docs/loops.md "Run cards"): a `pi-loops:view` entry, where it stands among the messages. */
|
|
173
|
+
export interface HistoryCard {
|
|
174
|
+
role: "pi-loops:view";
|
|
175
|
+
/** The entry's id: the page draws an entry once, whether it came in the transcript or as it landed. */
|
|
176
|
+
id?: string;
|
|
177
|
+
title: string;
|
|
178
|
+
lines: string[];
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
/** The most text of one view the page is given: a terminal's `/cron trace` can be long. */
|
|
182
|
+
const VIEW_TEXT_MAX_CHARS = 4000;
|
|
183
|
+
|
|
184
|
+
/**
|
|
185
|
+
* A `pi-loops:view` entry as the page is given it: redacted as `/inbox/run` redacts a transcript,
|
|
186
|
+
* and its title and lines together at most `VIEW_TEXT_MAX_CHARS`. A terminal's views print run
|
|
187
|
+
* errors, summaries and loop state as they are.
|
|
188
|
+
*/
|
|
189
|
+
function viewCard(id: unknown, data: { title?: unknown; lines?: unknown } | undefined): HistoryCard {
|
|
190
|
+
const lines = Array.isArray(data?.lines) ? data.lines.filter((l): l is string => typeof l === "string") : [];
|
|
191
|
+
const text = redact([typeof data?.title === "string" ? data.title : "", ...lines].join("\n"));
|
|
192
|
+
const [title = "", ...rest] = (text.length > VIEW_TEXT_MAX_CHARS ? `${text.slice(0, VIEW_TEXT_MAX_CHARS - 1)}…` : text).split("\n");
|
|
193
|
+
return { role: VIEW_ENTRY, ...(typeof id === "string" ? { id } : {}), title, lines: rest };
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
/**
|
|
197
|
+
* A conversation's messages with its `pi-loops:view` entries put back among them: pi's context has
|
|
198
|
+
* no place for a custom entry, and the page draws a run card where it was written. `entries` are the
|
|
199
|
+
* session's, `leafId` its branch; `messages` are what the page's pi answers (they can run ahead of
|
|
200
|
+
* its entries mid-turn), and pi's own projection of the entries when none are given.
|
|
201
|
+
*/
|
|
202
|
+
export function historyWithCards(entries: readonly unknown[], leafId?: string | null, messages?: readonly unknown[]): unknown[] {
|
|
203
|
+
const projected = buildSessionProjection(entries.filter((e): e is SessionEntry => !!e && typeof e === "object" && (e as { type?: unknown }).type !== "session"), leafId ?? undefined).entries;
|
|
204
|
+
const cards: Array<{ at: number; card: HistoryCard }> = [];
|
|
205
|
+
let count = 0;
|
|
206
|
+
for (const { sourceEntry, messages: own } of projected) {
|
|
207
|
+
count += own.length;
|
|
208
|
+
if (sourceEntry.type !== "custom" || sourceEntry.customType !== VIEW_ENTRY) continue;
|
|
209
|
+
cards.push({ at: count, card: viewCard(sourceEntry.id, sourceEntry.data as { title?: unknown; lines?: unknown } | undefined) });
|
|
210
|
+
}
|
|
211
|
+
const base = messages ?? projected.flatMap((p) => p.messages);
|
|
212
|
+
if (!cards.length) return [...base];
|
|
213
|
+
const out: unknown[] = [];
|
|
214
|
+
let next = 0;
|
|
215
|
+
base.forEach((m, i) => {
|
|
216
|
+
for (; next < cards.length && cards[next].at <= i; next++) out.push(cards[next].card);
|
|
217
|
+
out.push(m);
|
|
218
|
+
});
|
|
219
|
+
for (; next < cards.length; next++) out.push(cards[next].card);
|
|
220
|
+
return out;
|
|
221
|
+
}
|
|
222
|
+
/** The custom entry type pi-loops writes its views and run cards as (src/pi-loops.ts). */
|
|
223
|
+
const VIEW_ENTRY = "pi-loops:view";
|
|
224
|
+
|
|
225
|
+
/**
|
|
226
|
+
* A conversation a terminal holds, as the page shows it: read from its file with pi's own parse,
|
|
227
|
+
* migration and projection functions, so the page's pi is never switched to it (a switch while the
|
|
228
|
+
* page asks for messages answered from either session, and changed the epoch under the page).
|
|
229
|
+
* Read only: pi's `SessionManager.open` would rewrite an old file on migration. The mark is the
|
|
230
|
+
* open file's size and mtime, and only that many bytes are read, so a write during the read is read
|
|
231
|
+
* the next time. A file over `HELD_READ_MAX_BYTES` is not read here: the read would hold up every page.
|
|
232
|
+
*/
|
|
233
|
+
export function readHeldConversation(file: string, fstatSync: typeof fs.fstatSync = fs.fstatSync): { messages: unknown[]; mark: { size: number; mtimeMs: number } } | undefined {
|
|
234
|
+
try {
|
|
235
|
+
const fd = fs.openSync(file, "r");
|
|
236
|
+
try {
|
|
237
|
+
const st = fstatSync(fd);
|
|
238
|
+
if (!st || st.size > HELD_READ_MAX_BYTES) return undefined;
|
|
239
|
+
const buf = Buffer.alloc(st.size);
|
|
240
|
+
const got = fs.readSync(fd, buf, 0, st.size, 0);
|
|
241
|
+
const entries = parseSessionEntries(buf.toString("utf8", 0, got));
|
|
242
|
+
migrateSessionEntries(entries);
|
|
243
|
+
return { messages: historyWithCards(entries), mark: { size: st.size, mtimeMs: st.mtimeMs } };
|
|
244
|
+
} finally {
|
|
245
|
+
fs.closeSync(fd);
|
|
246
|
+
}
|
|
247
|
+
} catch {
|
|
248
|
+
return undefined; // gone or unreadable: the page's pi answers as before
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
/** Whether the bytes of `file` from `from` to `to` hold an entry a person reads: a message, a result put in it, a compaction. The first and the last `ADDED_READ_MAX_BYTES` are read; more than both is taken to hold one. */
|
|
253
|
+
export function addedMessage(file: string, from: number, to: number): boolean {
|
|
254
|
+
if (to < from) return true; // the file was rewritten: read it all again
|
|
255
|
+
if (to - from > 2 * ADDED_READ_MAX_BYTES) return true; // a short message between two large entries is in neither window
|
|
256
|
+
try {
|
|
257
|
+
const fd = fs.openSync(file, "r");
|
|
258
|
+
try {
|
|
259
|
+
// The start and the end of what was added: a message's own line can be longer than the window
|
|
260
|
+
// (a large tool result), and its type is at its start.
|
|
261
|
+
const read = (at: number, len: number) => {
|
|
262
|
+
const buf = Buffer.alloc(len);
|
|
263
|
+
return buf.toString("utf8", 0, fs.readSync(fd, buf, 0, len, at));
|
|
264
|
+
};
|
|
265
|
+
const len = Math.min(to - from, ADDED_READ_MAX_BYTES);
|
|
266
|
+
const said = /"type":"(message|custom_message|compaction|branch_summary)"/;
|
|
267
|
+
return said.test(read(from, len)) || (to - from > len && said.test(read(to - len, len)));
|
|
268
|
+
} finally {
|
|
269
|
+
fs.closeSync(fd);
|
|
270
|
+
}
|
|
271
|
+
} catch {
|
|
272
|
+
return false; // gone: nothing to read again
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
export function readJson(file: string, fallback: any) {
|
|
277
|
+
try {
|
|
278
|
+
return JSON.parse(fs.readFileSync(file, "utf8"));
|
|
279
|
+
} catch {
|
|
280
|
+
return fallback;
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
/**
|
|
285
|
+
* Is `child` inside `ancestor`? A `startsWith` on the resolved path is not this test: with ancestor
|
|
286
|
+
* `/home/u/proj`, `/home/u/proj-secrets/.env` passes it. Symlinks are resolved first — `realpathish`
|
|
287
|
+
* resolves as much of each path as exists, so a file that is not there yet still answers — which
|
|
288
|
+
* means a link planted in the project cannot point out of it either.
|
|
289
|
+
*/
|
|
290
|
+
function isUnder(child: string, ancestor: string) {
|
|
291
|
+
const realRoot = realpathish(ancestor);
|
|
292
|
+
const realPath = realpathish(child);
|
|
293
|
+
const rel = path.relative(realRoot, realPath);
|
|
294
|
+
return rel === "" || (!rel.startsWith("..") && !path.isAbsolute(rel));
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
/** An ISO time, but only if it is still ahead of us. */
|
|
298
|
+
function futureOnly(iso: any) {
|
|
299
|
+
const at = Date.parse(iso ?? "");
|
|
300
|
+
return Number.isFinite(at) && at > Date.now() ? iso : undefined;
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
/* ------------------------------------------------------------------ what a prompt needs first */
|
|
304
|
+
|
|
305
|
+
/**
|
|
306
|
+
* The commands the page runs itself, rather than sending to the model as text.
|
|
307
|
+
*
|
|
308
|
+
* These are the ones you reach for between turns rather than during them — the context is full, or
|
|
309
|
+
* the job is finished and the next one should not inherit it. Typing the command is the way that
|
|
310
|
+
* habit is expressed, so they are completed like any other command and answered by the page; the
|
|
311
|
+
* buttons in the header are the same three actions for anyone who did not grow the habit.
|
|
312
|
+
*/
|
|
313
|
+
const PAGE_COMMANDS = [
|
|
314
|
+
{ name: "clear", description: "start a new session — this one stays on disk, /resume brings it back" },
|
|
315
|
+
{ name: "new", description: "start a new session — this one stays on disk, /resume brings it back" },
|
|
316
|
+
{ name: "resume", description: "go back to an earlier session in this project" },
|
|
317
|
+
{ name: "sessions", description: "list this project's sessions: id, when, what was first said" },
|
|
318
|
+
{ name: "session", description: "export [path] | import <path> — the .pisession archive commands" },
|
|
319
|
+
{ name: "compact", description: "compact the context; anything after it steers the summary" },
|
|
320
|
+
];
|
|
321
|
+
|
|
322
|
+
/** Commands this front end answers some other way, so typing one is not sent to the model as text. */
|
|
323
|
+
const UI_COMMANDS: Record<string, string> = {
|
|
324
|
+
model: "use the model picker in the header",
|
|
325
|
+
thinking: "use the thinking picker in the header",
|
|
326
|
+
abort: "use the stop button",
|
|
327
|
+
cost: "shown in the header, next to the buttons",
|
|
328
|
+
find: "use the find button",
|
|
329
|
+
history: "press ↑ in the composer",
|
|
330
|
+
undo: "use the undo button",
|
|
331
|
+
save: "use the save button",
|
|
332
|
+
export: "use the save button",
|
|
333
|
+
"session-share": "use the share button",
|
|
334
|
+
quit: "close the tab; pi keeps running until you stop this process",
|
|
335
|
+
login: "log in once from a terminal (`pi`), then restart this front end — oauth has no rpc command",
|
|
336
|
+
};
|
|
337
|
+
|
|
338
|
+
/**
|
|
339
|
+
* pi's *built-in* slash commands do not exist in rpc mode — `get_commands` returns only extension
|
|
340
|
+
* commands and skills, and anything else is delivered to the model as literal text. Sending
|
|
341
|
+
* `/help` and getting a paragraph of prose back is worse than being told it is not here.
|
|
342
|
+
*/
|
|
343
|
+
function commandRefusal(text: string, child: PageChild) {
|
|
344
|
+
const trimmed = text.trim();
|
|
345
|
+
if (!trimmed.startsWith("/")) return undefined;
|
|
346
|
+
// Lowercased, as the page lowercases it before deciding which commands it runs itself. Without
|
|
347
|
+
// this a stale tab sending `/CLEAR` was told it is not a command at all, rather than told to
|
|
348
|
+
// reload — the one thing that would have fixed it.
|
|
349
|
+
const name = trimmed.slice(1).split(/\s/)[0].toLowerCase();
|
|
350
|
+
if (!name) return undefined;
|
|
351
|
+
if (child.catalog.commandList.some((c) => c.name.toLowerCase() === name)) return undefined;
|
|
352
|
+
// A page old enough not to know these sends them here. Say which half is out of date, rather
|
|
353
|
+
// than "not a command" about something this server does implement.
|
|
354
|
+
if (PAGE_COMMANDS.some((c) => c.name === name)) {
|
|
355
|
+
return { success: false, error: `/${name} is run by the page — reload this tab to get the version that does it` };
|
|
356
|
+
}
|
|
357
|
+
const hint = UI_COMMANDS[name];
|
|
358
|
+
return {
|
|
359
|
+
success: false,
|
|
360
|
+
error: hint ? `/${name} — ${hint}` : `/${name} is not a command in this session (pi's terminal commands are not available over rpc)`,
|
|
361
|
+
};
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
/**
|
|
365
|
+
* Expand `@path` the way the terminal does before submitting. In rpc mode nothing expands it —
|
|
366
|
+
* verified: the mention arrives at the model as the literal five characters — so the front end
|
|
367
|
+
* that offers the completion has to be the one that honours it.
|
|
368
|
+
*/
|
|
369
|
+
function expandMentions(text: string, cwd: string) {
|
|
370
|
+
const seen = new Set();
|
|
371
|
+
const blocks = [];
|
|
372
|
+
for (const m of text.matchAll(/(^|\s)@([^\s]+)/g)) {
|
|
373
|
+
const rel = m[2].replace(/[.,;:)\]]+$/, "");
|
|
374
|
+
if (seen.has(rel)) continue;
|
|
375
|
+
seen.add(rel);
|
|
376
|
+
const file = path.resolve(cwd, rel);
|
|
377
|
+
// A mention must not read outside the project, and a huge file is a mistake, not a mention.
|
|
378
|
+
if (!isUnder(file, cwd)) continue;
|
|
379
|
+
let stat;
|
|
380
|
+
try {
|
|
381
|
+
stat = fs.statSync(file);
|
|
382
|
+
} catch {
|
|
383
|
+
continue;
|
|
384
|
+
}
|
|
385
|
+
if (!stat.isFile() || stat.size > 256 * 1024) continue;
|
|
386
|
+
let content;
|
|
387
|
+
try {
|
|
388
|
+
content = fs.readFileSync(file, "utf8");
|
|
389
|
+
} catch {
|
|
390
|
+
continue;
|
|
391
|
+
}
|
|
392
|
+
if (content.includes("\u0000")) continue; // binary: the path alone is more use than bytes
|
|
393
|
+
blocks.push(`\n\n<file path="${rel}">\n${content}\n</file>`);
|
|
394
|
+
}
|
|
395
|
+
return text + blocks.join("");
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
/* ------------------------------------------------------------------ requests: what is queued for this session */
|
|
399
|
+
|
|
400
|
+
/**
|
|
401
|
+
* A person row is signed by the child it is rung to, not by this process: a row is sent only while
|
|
402
|
+
* its producer lives (docs/design.md decision 23), and this process outlives every child. Signed by
|
|
403
|
+
* the child, a row whose pi died unsent is offered by the next page instead of run by it.
|
|
404
|
+
*/
|
|
405
|
+
const createdBy = (child: PageChild, version: string) => ({ pid: child.pid, instance: child.instance, procStart: child.procStart, version });
|
|
406
|
+
const mine = (row: any, child: PageChild) => row.created_by.pid === child.pid && row.created_by.instance === child.instance;
|
|
407
|
+
|
|
408
|
+
/** The person rows `child` signed that no holder has taken yet: while there are any, it is not idle. */
|
|
409
|
+
export function unsentPersonRows(requests: RequestStore, child: PageChild): number {
|
|
410
|
+
if (!child.sessionId) return 0;
|
|
411
|
+
try {
|
|
412
|
+
return requests.list(child.sessionId).filter((row) => row.kind === "input" && row.source === "person" && row.handed_at === undefined && mine(row, child)).length;
|
|
413
|
+
} catch {
|
|
414
|
+
return 0; // an id the store refuses has no rows
|
|
415
|
+
}
|
|
416
|
+
}
|
|
417
|
+
/** A row id as the page sends one back: the page's UUID, or a pi-loops id. Checked before it reaches a file. */
|
|
418
|
+
const ROW_ID = /^[A-Za-z0-9_-]{1,80}$/;
|
|
419
|
+
const TEXT_PREVIEW = 200;
|
|
420
|
+
|
|
421
|
+
/**
|
|
422
|
+
* The doorbell: `/pi-loops ring` typed into the pi behind this page, which drains the session's
|
|
423
|
+
* requests at once. With a row's id and fingerprint, the extension first records that row as this
|
|
424
|
+
* page's, which is how a chat message runs as the person's own turn and not capped: this stdin is a
|
|
425
|
+
* channel no model tool can write to. pi answers once the command has run.
|
|
426
|
+
*/
|
|
427
|
+
export function ring(child: PageChild, id?: string, fingerprint?: string): Promise<any> {
|
|
428
|
+
// With no extension to answer it, the bell would be a prompt for the model.
|
|
429
|
+
if (child.catalog.piLoopsLoaded === false) return Promise.resolve({ success: false, error: NO_PI_LOOPS });
|
|
430
|
+
const message = id === undefined ? "/pi-loops ring" : `/pi-loops ring ${id} ${fingerprint}`;
|
|
431
|
+
return child.rpc({ type: "prompt", message }, 15_000).then((r) => {
|
|
432
|
+
if (!r?.success) child.log(`pi-loops web: ring: ${r?.error ?? "no answer"}`);
|
|
433
|
+
return r;
|
|
434
|
+
});
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
/** The source of a row as the queue names it: "you", "cron nightly", "rule 3f2a…". */
|
|
438
|
+
function labelOf(row: any, jobNames: Map<any, any>) {
|
|
439
|
+
const [kind, ...rest] = row.source.split(":");
|
|
440
|
+
const arg = rest.join(":");
|
|
441
|
+
if (row.source === "person") return "you";
|
|
442
|
+
if (kind === "cron" || kind === "cron-run") return `cron ${jobNames.get(arg) ?? arg}`;
|
|
443
|
+
if (kind === "rule" || kind === "inbox") return `${kind} ${arg.slice(0, 8)}`;
|
|
444
|
+
if (kind === "push" || kind === "recipe") return `${kind} ${arg}`;
|
|
445
|
+
return row.source;
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
/**
|
|
449
|
+
* How long a message of this page may wait untaken, with pi idle, before the page says so and offers
|
|
450
|
+
* to send it over rpc instead. Past the ten-second grace a refused hand-over is judged by, plus the
|
|
451
|
+
* follow-up ring's half second, so a send pi refused comes back as "not sent" first and never as
|
|
452
|
+
* both; short of the thirty-second tick a viewer retries its lease on, so a dead chat is not looked
|
|
453
|
+
* at for half a minute.
|
|
454
|
+
*/
|
|
455
|
+
const UNTAKEN_MS = 15_000;
|
|
456
|
+
|
|
457
|
+
/** How often a page's pi that stands aside tries to read its conversation again (`catchUp`). */
|
|
458
|
+
const CATCH_UP_EVERY_MS = 30_000;
|
|
459
|
+
|
|
460
|
+
/**
|
|
461
|
+
* What the queue row of a message of this page says once it has waited `UNTAKEN_MS` with this page's
|
|
462
|
+
* pi idle and nobody has handed it over; undefined otherwise. `line` is the whole label, `button`
|
|
463
|
+
* the offer to send it over rpc instead, when there is one: none when this page's own pi stands
|
|
464
|
+
* aside, since anything written then branches the conversation off an old entry, and /resume is the
|
|
465
|
+
* way out. A holder that delivers may be in a turn this page cannot see, so it is not called "not
|
|
466
|
+
* answering"; answering here forks the conversation under it, which the button says, so a terminal
|
|
467
|
+
* suspended with Ctrl-Z still leaves a way out, chosen knowingly.
|
|
468
|
+
*/
|
|
469
|
+
function untaken(row: any, holder: any, now: number, idle: boolean, child: PageChild) {
|
|
470
|
+
if (row.source !== "person" || !mine(row, child) || row.handed_at !== undefined || !idle) return undefined;
|
|
471
|
+
if (now - Date.parse(row.created_at) < UNTAKEN_MS) return undefined;
|
|
472
|
+
const pid = holder?.pid ?? child.pid;
|
|
473
|
+
const notTaken = (why: string) => `not taken (${why})`;
|
|
474
|
+
const sendHere = "send it here instead";
|
|
475
|
+
if (!holder) return { pid, line: notTaken("nobody holds this conversation"), button: sendHere };
|
|
476
|
+
if (holder.mine && holder.aside) return { pid, line: notTaken("this page's pi shows an older copy of this conversation and reads it again once it is idle") };
|
|
477
|
+
if (holder.aside) return { pid, line: notTaken("shows an older copy of this conversation"), button: sendHere };
|
|
478
|
+
if (!holder.delivers) return { pid, line: notTaken("a non-interactive pi"), button: sendHere };
|
|
479
|
+
if (holder.mine) return { pid, line: notTaken("this page's pi did not take it"), button: sendHere };
|
|
480
|
+
return { pid, line: "another pi holds this conversation and has not taken it yet (it may be busy)", button: "answer here instead (a branch the other pi will not see)" };
|
|
481
|
+
}
|
|
482
|
+
|
|
483
|
+
/* ------------------------------------------------------------------ http */
|
|
484
|
+
|
|
485
|
+
/** What `checkRequest` judges, read off one request by `requestFacts`. */
|
|
486
|
+
export interface RequestFacts {
|
|
487
|
+
method: string;
|
|
488
|
+
path: string;
|
|
489
|
+
host?: string;
|
|
490
|
+
origin?: string;
|
|
491
|
+
secFetchSite?: string;
|
|
492
|
+
secFetchMode?: string;
|
|
493
|
+
secFetchDest?: string;
|
|
494
|
+
/** The socket's peer address, for the tailnet rule. */
|
|
495
|
+
remote?: string;
|
|
496
|
+
queryToken?: string;
|
|
497
|
+
headerToken?: string;
|
|
498
|
+
/** The value of the `pi_web_device` cookie, not the whole Cookie header. It is never a credential here. */
|
|
499
|
+
device?: string;
|
|
500
|
+
/** Whether the browser still sends 0.22.8's `pi_web_token` cookie, which holds the live token. */
|
|
501
|
+
legacyCookie?: boolean;
|
|
502
|
+
}
|
|
503
|
+
|
|
504
|
+
export interface CheckOptions {
|
|
505
|
+
token: string;
|
|
506
|
+
/** Names you put in front of this yourself: a reverse proxy, a hostname on your own network. */
|
|
507
|
+
allowHosts: readonly string[];
|
|
508
|
+
/** Bound to loopback, so a bare IP in Host is not a name anyone was meant to use. */
|
|
509
|
+
loopback: boolean;
|
|
510
|
+
}
|
|
511
|
+
|
|
512
|
+
export type Verdict = { ok: true; via: "query" | "header" } | { status: 401 | 403; why: string };
|
|
513
|
+
|
|
514
|
+
/** A Host that is an address rather than a name: nothing to re-resolve, so nothing to rebind. */
|
|
515
|
+
const IP_LITERAL = /^(?:\d{1,3}(?:\.\d{1,3}){3}|\[[0-9A-Fa-f:.]+\])$/;
|
|
516
|
+
/**
|
|
517
|
+
* Where the connection actually came from, for the tailnet case. `tailscale serve` proxies to
|
|
518
|
+
* loopback, so a genuine one arrives on a local socket; a direct connection to the tailnet address
|
|
519
|
+
* arrives from 100.64/10, which is the range Tailscale hands out. A `.ts.net` Host from anywhere
|
|
520
|
+
* else is a name pointed at this machine by someone, which is the thing the check is for.
|
|
521
|
+
*/
|
|
522
|
+
function fromTailnet(remote: string | undefined) {
|
|
523
|
+
const from = String(remote ?? "").replace(/^::ffff:/, "");
|
|
524
|
+
if (from === "127.0.0.1" || from === "::1") return true;
|
|
525
|
+
const octets = from.split(".").map(Number);
|
|
526
|
+
return octets.length === 4 && octets[0] === 100 && octets[1] >= 64 && octets[1] <= 127;
|
|
527
|
+
}
|
|
528
|
+
|
|
529
|
+
/**
|
|
530
|
+
* Whether this request may be answered at all, judged by the name it arrived under.
|
|
531
|
+
*
|
|
532
|
+
* Without a Host check, a page on any site could point a hostname of its own at 127.0.0.1 and talk
|
|
533
|
+
* to this server from the browser; the token is what actually stops that, and this is the second
|
|
534
|
+
* lock. It is *not* "only this machine": loopback always passes, and so do a bare address on the
|
|
535
|
+
* network this was deliberately bound to, a `.ts.net` name arriving from a tailnet, and every name
|
|
536
|
+
* given to `--allow-host` — which is a promise the person who started this made about what sits in
|
|
537
|
+
* front of it, not a resolution anything here could check.
|
|
538
|
+
*/
|
|
539
|
+
function hostAllowed(req: RequestFacts, opts: CheckOptions) {
|
|
540
|
+
const host = String(req.host ?? "").replace(/:\d+$/, "");
|
|
541
|
+
if (host === "127.0.0.1" || host === "localhost" || host === "[::1]") return true;
|
|
542
|
+
// Bound to the network on purpose: the phone reaches this by IP, so a bare IP address has to be
|
|
543
|
+
// allowed. An address cannot be rebound — there is no name to re-resolve — which is the whole
|
|
544
|
+
// reason the rule is shaped this way.
|
|
545
|
+
if (!opts.loopback && IP_LITERAL.test(host)) return true;
|
|
546
|
+
// Tailscale's MagicDNS names. `tailscale serve` is the good way to reach this from a phone: it
|
|
547
|
+
// terminates TLS on the tailnet and proxies to loopback here, so the request arrives looking
|
|
548
|
+
// local but carrying the tailnet name as its Host. That zone belongs to Tailscale and its names
|
|
549
|
+
// resolve to tailnet devices, so it is not a name an attacker can point at this machine.
|
|
550
|
+
if (host.endsWith(".ts.net") && fromTailnet(req.remote)) return true;
|
|
551
|
+
return opts.allowHosts.some((h) => h.trim() !== "" && h.trim().toLowerCase() === host.toLowerCase());
|
|
552
|
+
}
|
|
553
|
+
|
|
554
|
+
/** The host an Origin names, or undefined for one that is not a URL. */
|
|
555
|
+
function originHost(origin: string) {
|
|
556
|
+
try {
|
|
557
|
+
return new URL(origin).host;
|
|
558
|
+
} catch {
|
|
559
|
+
return undefined; // an Origin that is not a URL is not one this server put there
|
|
560
|
+
}
|
|
561
|
+
}
|
|
562
|
+
|
|
563
|
+
/**
|
|
564
|
+
* The browser says who started the request, and it is not something a page can lie about. Absent
|
|
565
|
+
* from curl and from older browsers, so its absence cannot be treated as a failure.
|
|
566
|
+
*/
|
|
567
|
+
function crossSite(req: RequestFacts) {
|
|
568
|
+
// Asked before Origin. The waiting page sends `referrer-policy: no-referrer`, so Chrome sends its
|
|
569
|
+
// Stop it post with Origin "null"; the same request says same-origin here. A sandboxed preview is
|
|
570
|
+
// an opaque origin, which a browser never calls same-origin, so this does not let it back in.
|
|
571
|
+
if (req.secFetchSite === "same-origin") return false;
|
|
572
|
+
// "same-site" is not the same as "same origin": a cookie's SameSite is scoped to the site, and a
|
|
573
|
+
// site ignores the port — http://localhost:5173, which is any Vite dev server on this machine,
|
|
574
|
+
// is the same site as this one and its pages are handed our cookies. Those are the pages most
|
|
575
|
+
// likely to exist and to be attacked, so same-site is a no, not a yes.
|
|
576
|
+
if (req.secFetchSite === "cross-site" || req.secFetchSite === "same-site") return true;
|
|
577
|
+
// A browser that does not send that header still sends Origin on anything that could do harm.
|
|
578
|
+
// "null" is what an opaque origin sends — a sandboxed document, which is exactly what the file
|
|
579
|
+
// previews are served as. Treating it as same-site would let one of them back in on a browser
|
|
580
|
+
// that sends no Sec-Fetch-Site.
|
|
581
|
+
if (req.origin === "null") return true;
|
|
582
|
+
if (req.origin === undefined) return false;
|
|
583
|
+
return originHost(req.origin) !== req.host;
|
|
584
|
+
}
|
|
585
|
+
|
|
586
|
+
/** The Host and cross-site locks alone: what the door, the manifest and the icon answer to. */
|
|
587
|
+
/**
|
|
588
|
+
* Whether a request is the browser opening this address as its page: a link followed or an address
|
|
589
|
+
* typed, not a frame, a fetch or an image. The launch file is a `file:` page, which a browser calls
|
|
590
|
+
* another site, so its hand-off arrives as a cross-site navigation. Only the open key may come in
|
|
591
|
+
* that way: it is 16 random bytes that no other site can know, and a frame on another site is
|
|
592
|
+
* `iframe`, not `document`. The six-digit code is guessable and stays behind the cross-site lock.
|
|
593
|
+
*/
|
|
594
|
+
export function topLevelNavigation(req: RequestFacts): boolean {
|
|
595
|
+
return req.method === "GET" && req.secFetchMode === "navigate" && req.secFetchDest === "document";
|
|
596
|
+
}
|
|
597
|
+
|
|
598
|
+
function placeRefused(req: RequestFacts, opts: CheckOptions): { status: 403; why: string } | undefined {
|
|
599
|
+
if (!hostAllowed(req, opts)) return { status: 403, why: "forbidden host" };
|
|
600
|
+
if (crossSite(req)) return { status: 403, why: "cross-site request refused" };
|
|
601
|
+
return undefined;
|
|
602
|
+
}
|
|
603
|
+
|
|
604
|
+
/**
|
|
605
|
+
* Constant-time compare, so a token cannot be found a byte at a time. Lengths are compared as bytes:
|
|
606
|
+
* `timingSafeEqual` throws on buffers of different byte lengths, and a token of the right number of
|
|
607
|
+
* characters with one non-ASCII character in it reached that throw before any check passed.
|
|
608
|
+
*/
|
|
609
|
+
function sameToken(given: string | undefined, token: string) {
|
|
610
|
+
if (given === undefined) return false;
|
|
611
|
+
const a = Buffer.from(given);
|
|
612
|
+
const b = Buffer.from(token);
|
|
613
|
+
return a.length === b.length && timingSafeEqual(a, b);
|
|
614
|
+
}
|
|
615
|
+
|
|
616
|
+
/**
|
|
617
|
+
* Whether a request may reach a route, and if not, the status and the reason.
|
|
618
|
+
*
|
|
619
|
+
* Host and cross-site first, then the token: none at all is 401, a wrong one 403. It arrives in the
|
|
620
|
+
* `x-pi-web-token` header, which no other site can set without a preflight this server never
|
|
621
|
+
* answers, or on a GET in `?token=`. A POST never takes it from its URL, where a proxy log or a
|
|
622
|
+
* Referer would keep it. No cookie is a credential: a cookie goes to every port on this host.
|
|
623
|
+
*/
|
|
624
|
+
export function checkRequest(req: RequestFacts, opts: CheckOptions): Verdict {
|
|
625
|
+
const refused = placeRefused(req, opts);
|
|
626
|
+
if (refused) return refused;
|
|
627
|
+
if (req.method === "POST" && req.queryToken !== undefined) return { status: 403, why: "token in the query string is refused on POST" };
|
|
628
|
+
if (req.queryToken === undefined && req.headerToken === undefined) return { status: 401, why: "no token" };
|
|
629
|
+
const via = sameToken(req.queryToken, opts.token) ? "query" : sameToken(req.headerToken, opts.token) ? "header" : undefined;
|
|
630
|
+
if (!via) return { status: 403, why: "bad token" };
|
|
631
|
+
return { ok: true, via };
|
|
632
|
+
}
|
|
633
|
+
|
|
634
|
+
/** The cookie 0.22.8 kept the token in, and the header that makes a browser forget it (it was host-only, `Path=/`). */
|
|
635
|
+
const LEGACY_TOKEN_COOKIE = "pi_web_token";
|
|
636
|
+
const LEGACY_TOKEN_CLEAR = `${LEGACY_TOKEN_COOKIE}=; Path=/; Max-Age=0; HttpOnly; SameSite=Strict`;
|
|
637
|
+
|
|
638
|
+
/**
|
|
639
|
+
* The one place a request's headers are read for `checkRequest`. The device cookie is the one a
|
|
640
|
+
* browser was given on its first visit (src/page-devices.ts); `SameSite=Strict` keeps a browser from
|
|
641
|
+
* attaching it to anything another site initiated, and it opens nothing by itself in any case.
|
|
642
|
+
*/
|
|
643
|
+
function requestFacts(req: http.IncomingMessage, url: URL): RequestFacts {
|
|
644
|
+
const one = (v: string | string[] | undefined) => (typeof v === "string" ? v : undefined);
|
|
645
|
+
let device: string | undefined;
|
|
646
|
+
let legacyCookie = false;
|
|
647
|
+
for (const part of String(req.headers.cookie ?? "").split(";")) {
|
|
648
|
+
const cut = part.indexOf("=");
|
|
649
|
+
const name = cut > 0 ? part.slice(0, cut).trim() : "";
|
|
650
|
+
if (name === DEVICE_COOKIE && device === undefined) device = part.slice(cut + 1).trim();
|
|
651
|
+
if (name === LEGACY_TOKEN_COOKIE) legacyCookie = true;
|
|
652
|
+
}
|
|
653
|
+
return {
|
|
654
|
+
method: req.method ?? "GET",
|
|
655
|
+
path: url.pathname,
|
|
656
|
+
host: one(req.headers.host),
|
|
657
|
+
origin: one(req.headers.origin),
|
|
658
|
+
secFetchSite: one(req.headers["sec-fetch-site"]),
|
|
659
|
+
secFetchMode: one(req.headers["sec-fetch-mode"]),
|
|
660
|
+
secFetchDest: one(req.headers["sec-fetch-dest"]),
|
|
661
|
+
remote: req.socket?.remoteAddress,
|
|
662
|
+
queryToken: url.searchParams.get("token") ?? undefined,
|
|
663
|
+
headerToken: one(req.headers["x-pi-web-token"]),
|
|
664
|
+
device,
|
|
665
|
+
legacyCookie,
|
|
666
|
+
};
|
|
667
|
+
}
|
|
668
|
+
|
|
669
|
+
const PAIR_MAX_TRIES = 20;
|
|
670
|
+
/** Long enough to find your phone and point it at the screen; not long enough to forget about. */
|
|
671
|
+
const PAIR_TTL_MS = 10 * 60 * 1000;
|
|
672
|
+
|
|
673
|
+
/** A request's JSON body. Images arrive here as base64, so the default cap is generous — but it is a cap; a route that takes a few fields passes its own. */
|
|
674
|
+
async function body(req: http.IncomingMessage, max = 32 * 1024 * 1024): Promise<any> {
|
|
675
|
+
const chunks = [];
|
|
676
|
+
let size = 0;
|
|
677
|
+
for await (const c of req) {
|
|
678
|
+
size += c.length;
|
|
679
|
+
if (size > max) throw Object.assign(new Error("request too large"), { status: 413 });
|
|
680
|
+
chunks.push(c);
|
|
681
|
+
}
|
|
682
|
+
return JSON.parse(Buffer.concat(chunks).toString("utf8") || "{}");
|
|
683
|
+
}
|
|
684
|
+
|
|
685
|
+
/** The most an inbox action's body may carry: an id and a reason of at most 500 characters fit many times over. */
|
|
686
|
+
const INBOX_BODY_MAX = 4096;
|
|
687
|
+
/** `POST /inbox/seen`: a project and a time, nothing else. */
|
|
688
|
+
const SEEN_BODY_MAX = 512;
|
|
689
|
+
/** How far ahead of this clock a page's `at` may be: its asOf came from this clock. */
|
|
690
|
+
const SEEN_AHEAD_MS = 5000;
|
|
691
|
+
const ISO_TIME = /^\d{4}-\d\d-\d\dT\d\d:\d\d:\d\d(?:\.\d{1,9})?(?:Z|[+-]\d\d:\d\d)$/;
|
|
692
|
+
/** The most a loop row's Stop or suggestion answer may carry: two ids and a word. */
|
|
693
|
+
const LOOPS_BODY_MAX = 1024;
|
|
694
|
+
/** A run's id as both mints write it (`newId("run")`, `plainRunId`). Matched whole. */
|
|
695
|
+
const RUN_ID = /^run-[0-9a-f]{32}$/;
|
|
696
|
+
/** A job's id: compared with the store's ids, never used as a path. */
|
|
697
|
+
const JOB_ID = /^[A-Za-z0-9_-]{1,80}$/;
|
|
698
|
+
/** A suggestion's key (src/job-tune.ts): kind, job id and the schedule's text. */
|
|
699
|
+
const TUNE_KEY_MAX = 300;
|
|
700
|
+
/** A finding's id as `Inbox.append` writes it (`newId("inb")`). Matched whole: never a prefix or a number. */
|
|
701
|
+
const INBOX_ID = /^inb-[0-9a-f]{32}$/;
|
|
702
|
+
/** The cards one `GET /inbox` answers with; past it, `more` counts the rest. */
|
|
703
|
+
const INBOX_CARDS_MAX = 50;
|
|
704
|
+
/** How often queued page claims are looked at again, so one tapped and forgotten still runs. */
|
|
705
|
+
const PAGE_CLAIM_SWEEP_MS = 10_000;
|
|
706
|
+
|
|
707
|
+
const json = (res: http.ServerResponse, data: unknown, code = 200) => {
|
|
708
|
+
// Some of these carry a session, a transcript, or a live pairing code; none are worth caching.
|
|
709
|
+
res.writeHead(code, { "content-type": "application/json; charset=utf-8", "cache-control": "no-store" });
|
|
710
|
+
res.end(JSON.stringify(data));
|
|
711
|
+
};
|
|
712
|
+
|
|
713
|
+
/** Slash commands at the start of the line, and `@`-paths anywhere. */
|
|
714
|
+
function complete(text: any, cwd: string, child: PageChild) {
|
|
715
|
+
const trimmed = text ?? "";
|
|
716
|
+
if (trimmed.startsWith("/") && !trimmed.includes(" ")) {
|
|
717
|
+
const q = trimmed.slice(1).toLowerCase();
|
|
718
|
+
// The page's own first, and only once: an extension is free to register a command of one of
|
|
719
|
+
// these names, but the page intercepts the line before it is sent, so that is the one that
|
|
720
|
+
// would run and offering both would be offering a choice that does not exist.
|
|
721
|
+
return [...PAGE_COMMANDS, ...child.catalog.commandList.filter((c) => !PAGE_COMMANDS.some((p) => p.name === c.name))]
|
|
722
|
+
.filter((c) => c.name.toLowerCase().startsWith(q))
|
|
723
|
+
.slice(0, 20)
|
|
724
|
+
.map((c) => ({ value: `/${c.name}`, hint: c.description ?? "" }));
|
|
725
|
+
}
|
|
726
|
+
const at = trimmed.lastIndexOf("@");
|
|
727
|
+
if (at === -1) return [];
|
|
728
|
+
const partial = trimmed.slice(at + 1);
|
|
729
|
+
if (/\s/.test(partial)) return [];
|
|
730
|
+
const dir = path.resolve(cwd, partial.endsWith("/") ? partial : path.dirname(partial));
|
|
731
|
+
// Completion must not become a file browser for the whole disk.
|
|
732
|
+
if (!isUnder(dir, cwd)) return [];
|
|
733
|
+
const leaf = partial.endsWith("/") ? "" : path.basename(partial);
|
|
734
|
+
let entries;
|
|
735
|
+
try {
|
|
736
|
+
entries = fs.readdirSync(dir, { withFileTypes: true });
|
|
737
|
+
} catch {
|
|
738
|
+
return [];
|
|
739
|
+
}
|
|
740
|
+
return entries
|
|
741
|
+
.filter((e) => !e.name.startsWith(".") && e.name.toLowerCase().startsWith(leaf.toLowerCase()))
|
|
742
|
+
.slice(0, 20)
|
|
743
|
+
.map((e) => {
|
|
744
|
+
const rel = path.relative(cwd, path.join(dir, e.name));
|
|
745
|
+
return { value: `@${rel}${e.isDirectory() ? "/" : ""}`, hint: e.isDirectory() ? "dir" : "file", replaceFrom: at };
|
|
746
|
+
});
|
|
747
|
+
}
|
|
748
|
+
|
|
749
|
+
/**
|
|
750
|
+
* Commands with a route of their own, which `/rpc` therefore refuses.
|
|
751
|
+
*
|
|
752
|
+
* `/rpc` passes a body straight to pi, which is what makes it useful — anything in the protocol this
|
|
753
|
+
* front end has not grown a button for is reachable. It also made every guard on every route
|
|
754
|
+
* optional: `{"type":"switch_session"}` posted here skipped the mid-turn refusal, the same-session
|
|
755
|
+
* check, the "one of this project's sessions" allowlist, and the epoch, backlog and pending-dialog
|
|
756
|
+
* reset a swap owes the attached browsers. A route that exists is the route that runs.
|
|
757
|
+
*/
|
|
758
|
+
const OWN_ROUTES = new Set(["switch_session", "extension_ui_response", "prompt", "steer", "follow_up", "compact", "fork", "abort", "cycle_model", "cycle_thinking_level"]);
|
|
759
|
+
|
|
760
|
+
/**
|
|
761
|
+
* And the two commands no route here should offer at all. Both replace the session the attached
|
|
762
|
+
* browsers are watching: no new `EPOCH` is minted, the backlog and pending dialogs of the old
|
|
763
|
+
* session are never cleared, the model catalog is never refreshed, and every page goes on rendering
|
|
764
|
+
* a session that is no longer the one pi is in — with no way to notice. Opening a fresh session is
|
|
765
|
+
* `/switch_session`-shaped work, and that route already exists.
|
|
766
|
+
*/
|
|
767
|
+
const NO_ROUTE_AT_ALL = new Set(["new_session", "clone"]);
|
|
768
|
+
|
|
769
|
+
/** A session id that is safe to use as a file name: the rule `sessionFileName` applies in src/lease.ts. */
|
|
770
|
+
const SAFE_SESSION_ID = /^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/;
|
|
771
|
+
|
|
772
|
+
/**
|
|
773
|
+
* The always-on host, if one is running: `host-service.json` and a live pid, which is how
|
|
774
|
+
* `pi-loops status` answers the same question (`hostServiceAlive` in `src/host-control.ts`).
|
|
775
|
+
*
|
|
776
|
+
* That one also checks the process's start time and command line, to catch a recycled pid. Not
|
|
777
|
+
* repeated: this is a line of news printed once on the way out.
|
|
778
|
+
*/
|
|
779
|
+
export function liveHostPid(dir: string) {
|
|
780
|
+
const rec = readJson(path.join(dir, "host-service.json"), undefined);
|
|
781
|
+
if (!rec || rec.v !== 1 || !pidAlive(rec.pid)) return undefined;
|
|
782
|
+
return rec.pid;
|
|
783
|
+
}
|
|
784
|
+
|
|
785
|
+
/**
|
|
786
|
+
* The addresses another device on your networks could actually use.
|
|
787
|
+
*
|
|
788
|
+
* `internal` in Node means loopback and nothing else, so the raw list also contains every virtual
|
|
789
|
+
* bridge this machine happens to run — docker0, libvirt, VirtualBox. Those are addresses that
|
|
790
|
+
* belong to something else on the phone's network, and sending a live pairing code to one hands a
|
|
791
|
+
* secret to a stranger. Named ones are dropped; the rest are ordered by how likely they are to be
|
|
792
|
+
* the one that works, tailnet first.
|
|
793
|
+
*
|
|
794
|
+
* Each comes back as the whole address to open and the interface it belongs to — `{ url, via }`,
|
|
795
|
+
* the shape the page and the pairing dialog both already used, under the names they used. Handing
|
|
796
|
+
* back a bare address and an interface name left two call sites assembling the same URL and calling
|
|
797
|
+
* the same field two different things.
|
|
798
|
+
*/
|
|
799
|
+
const VIRTUAL = /^(docker|virbr|br-|veth|vmnet|vboxnet|lxcbr|podman|cni|flannel|kube)/i;
|
|
800
|
+
|
|
801
|
+
export function lanAddresses(port: number) {
|
|
802
|
+
const out = [];
|
|
803
|
+
for (const [name, list] of Object.entries(os.networkInterfaces())) {
|
|
804
|
+
if (VIRTUAL.test(name)) continue;
|
|
805
|
+
for (const ni of list ?? []) {
|
|
806
|
+
if (ni.family !== "IPv4" || ni.internal) continue;
|
|
807
|
+
const [a, b] = ni.address.split(".").map(Number);
|
|
808
|
+
// 100.64/10 is the range Tailscale hands out: if there is one, it is the answer.
|
|
809
|
+
const rank = a === 100 && b >= 64 && b <= 127 ? 0 : a === 192 && b === 168 ? 1 : a === 10 ? 2 : 3;
|
|
810
|
+
out.push({ url: `http://${ni.address}:${port}/`, via: name, rank });
|
|
811
|
+
}
|
|
812
|
+
}
|
|
813
|
+
return out.sort((x, y) => x.rank - y.rank).map(({ url, via }) => ({ url, via }));
|
|
814
|
+
}
|
|
815
|
+
|
|
816
|
+
/**
|
|
817
|
+
* Where a preview may read from.
|
|
818
|
+
*
|
|
819
|
+
* The session's own directory first — that is where the work is. But an agent asked to make
|
|
820
|
+
* something for a person puts it where a person keeps things, which is usually the home directory,
|
|
821
|
+
* and refusing to show you your own `~/Downloads/report.html` because the session started in
|
|
822
|
+
* `~/code` is a rule serving nobody. The home directory is therefore also a root, minus every dot
|
|
823
|
+
* segment and minus the directory holding the token. Nothing here is a capability the session did
|
|
824
|
+
* not already have: it can read those files, and print them.
|
|
825
|
+
*/
|
|
826
|
+
function previewRoot(file: string, child: PageChild) {
|
|
827
|
+
if (isUnder(file, child.sessionCwd)) return child.sessionCwd;
|
|
828
|
+
const home = os.homedir();
|
|
829
|
+
// A container with HOME=/ would make the whole disk a preview root, which is not a home
|
|
830
|
+
// directory in any sense this rule means.
|
|
831
|
+
if (home && home !== "/" && isUnder(file, home)) return home;
|
|
832
|
+
return undefined;
|
|
833
|
+
}
|
|
834
|
+
|
|
835
|
+
/** What is worth *looking* at, as opposed to reading: the list that applies outside the project. */
|
|
836
|
+
const VISUAL_TYPES = new Set([".png", ".jpg", ".jpeg", ".gif", ".webp", ".avif", ".svg", ".pdf", ".html", ".htm"]);
|
|
837
|
+
|
|
838
|
+
/** What a preview will serve, and nothing else: an extension not on this list is not previewed. */
|
|
839
|
+
const PREVIEW_TYPES: Record<string, string> = {
|
|
840
|
+
".png": "image/png",
|
|
841
|
+
".jpg": "image/jpeg",
|
|
842
|
+
".jpeg": "image/jpeg",
|
|
843
|
+
".gif": "image/gif",
|
|
844
|
+
".webp": "image/webp",
|
|
845
|
+
".avif": "image/avif",
|
|
846
|
+
".svg": "image/svg+xml",
|
|
847
|
+
".pdf": "application/pdf",
|
|
848
|
+
".html": "text/html; charset=utf-8",
|
|
849
|
+
".htm": "text/html; charset=utf-8",
|
|
850
|
+
".txt": "text/plain; charset=utf-8",
|
|
851
|
+
".md": "text/plain; charset=utf-8",
|
|
852
|
+
".csv": "text/plain; charset=utf-8",
|
|
853
|
+
".json": "application/json; charset=utf-8",
|
|
854
|
+
};
|
|
855
|
+
const PREVIEW_MAX_BYTES = 25 * 1024 * 1024;
|
|
856
|
+
/** Eight, because a preview is a tab you have open right now, and nobody has more of those than that. */
|
|
857
|
+
const PREVIEW_KEEP = 8;
|
|
858
|
+
/** Half an hour: long enough that a tab left open over lunch still reloads, short enough to forget. */
|
|
859
|
+
const PREVIEW_TTL_MS = 30 * 60_000;
|
|
860
|
+
|
|
861
|
+
/* ------------------------------------------------------------------ the Background view */
|
|
862
|
+
|
|
863
|
+
export type SessionHolderView =
|
|
864
|
+
| { kind: "terminal"; pid: number; mode?: string; since: string; pushServers: string[] }
|
|
865
|
+
| { kind: "page"; pid: number; since: string }
|
|
866
|
+
| { kind: "host"; busy?: string; since: string }
|
|
867
|
+
| { kind: "nobody" }
|
|
868
|
+
| { kind: "legacy-pi"; pid: number };
|
|
869
|
+
|
|
870
|
+
export interface SessionRow {
|
|
871
|
+
sessionId: string;
|
|
872
|
+
/** The conversation's name or first message, when its file says. */
|
|
873
|
+
title?: string;
|
|
874
|
+
cwd?: string;
|
|
875
|
+
file?: string;
|
|
876
|
+
holder: SessionHolderView;
|
|
877
|
+
plainJobs: number;
|
|
878
|
+
rules: number;
|
|
879
|
+
mailbox: number;
|
|
880
|
+
sinceYouLooked: SinceYouLooked;
|
|
881
|
+
}
|
|
882
|
+
|
|
883
|
+
export interface HostStatus {
|
|
884
|
+
snapshot: HostSnapshot;
|
|
885
|
+
managed: "spawned" | "foreground";
|
|
886
|
+
sessions: SessionRow[];
|
|
887
|
+
}
|
|
888
|
+
|
|
889
|
+
export interface SessionRowsInput {
|
|
890
|
+
leases: readonly SessionLease[];
|
|
891
|
+
plainJobSessions: readonly string[];
|
|
892
|
+
ruleSessions: readonly string[];
|
|
893
|
+
mailbox: ReadonlyMap<string, number>;
|
|
894
|
+
legacy: ReadonlyArray<{ sessionId: string; pid: number }>;
|
|
895
|
+
alive: (holder: LeaseHolder) => boolean;
|
|
896
|
+
since: (sessionId: string) => SinceYouLooked;
|
|
897
|
+
/** A session file's title (src/session-head.ts sessionTitle). */
|
|
898
|
+
title: (sessionFile: string) => string | undefined;
|
|
899
|
+
/** A job's name, when it has one. */
|
|
900
|
+
jobName: (jobId: string) => string | undefined;
|
|
901
|
+
}
|
|
902
|
+
|
|
903
|
+
/** Combines ownership and automation counts so an idle conversation remains visible. */
|
|
904
|
+
export function sessionRows(input: SessionRowsInput): SessionRow[] {
|
|
905
|
+
const leases = new Map(input.leases.map((lease) => [lease.sessionId, lease]));
|
|
906
|
+
const legacy = new Map(input.legacy.map((entry) => [entry.sessionId, entry.pid]));
|
|
907
|
+
const ids = new Set([...leases.keys(), ...input.plainJobSessions, ...input.ruleSessions, ...input.mailbox.keys(), ...legacy.keys()]);
|
|
908
|
+
const counts = (sessions: readonly string[]) => {
|
|
909
|
+
const out = new Map<string, number>();
|
|
910
|
+
for (const id of sessions) out.set(id, (out.get(id) ?? 0) + 1);
|
|
911
|
+
return out;
|
|
912
|
+
};
|
|
913
|
+
const jobs = counts(input.plainJobSessions);
|
|
914
|
+
const rules = counts(input.ruleSessions);
|
|
915
|
+
const rank = { terminal: 0, page: 1, host: 2, "legacy-pi": 3, nobody: 4 };
|
|
916
|
+
return [...ids].map((sessionId): SessionRow => {
|
|
917
|
+
const lease = leases.get(sessionId);
|
|
918
|
+
const holder = lease?.holder;
|
|
919
|
+
let view: SessionHolderView;
|
|
920
|
+
if (holder && input.alive(holder)) {
|
|
921
|
+
if (holder.kind === "terminal" && holder.page === true) view = { kind: "page", pid: holder.pid, since: holder.acquiredAt };
|
|
922
|
+
else if (holder.kind === "terminal") view = { kind: "terminal", pid: holder.pid, ...(holder.mode ? { mode: holder.mode } : {}), since: holder.acquiredAt, pushServers: holder.pushServers };
|
|
923
|
+
// Named the way the page's waiting line names it: a job's id is 32 characters no person typed.
|
|
924
|
+
else view = { kind: "host", ...(holder.busy ? { busy: (holder.busy.jobId && input.jobName(holder.busy.jobId)) || holder.busy.runId.slice(0, 12) } : {}), since: holder.acquiredAt };
|
|
925
|
+
} else if (legacy.has(sessionId)) view = { kind: "legacy-pi", pid: legacy.get(sessionId)! };
|
|
926
|
+
else view = { kind: "nobody" };
|
|
927
|
+
const title = lease?.sessionFile !== undefined ? input.title(lease.sessionFile) : undefined;
|
|
928
|
+
return {
|
|
929
|
+
sessionId,
|
|
930
|
+
...(title !== undefined ? { title } : {}),
|
|
931
|
+
...(lease?.cwd !== undefined ? { cwd: lease.cwd } : {}),
|
|
932
|
+
...(lease?.sessionFile !== undefined ? { file: lease.sessionFile } : {}),
|
|
933
|
+
holder: view,
|
|
934
|
+
plainJobs: jobs.get(sessionId) ?? 0,
|
|
935
|
+
rules: rules.get(sessionId) ?? 0,
|
|
936
|
+
mailbox: input.mailbox.get(sessionId) ?? 0,
|
|
937
|
+
sinceYouLooked: input.since(sessionId),
|
|
938
|
+
};
|
|
939
|
+
}).sort((a, b) => rank[a.holder.kind] - rank[b.holder.kind] || a.sessionId.localeCompare(b.sessionId));
|
|
940
|
+
}
|
|
941
|
+
|
|
942
|
+
/**
|
|
943
|
+
* The page's token block (src/web.mjs, between its markers), comments left out: the Background view
|
|
944
|
+
* is drawn in the same palette, both schemes, rather than in a second one of its own.
|
|
945
|
+
*/
|
|
946
|
+
const TOKENS = (() => {
|
|
947
|
+
const from = PAGE.indexOf("/* tokens: */");
|
|
948
|
+
const to = PAGE.indexOf("/* :tokens */");
|
|
949
|
+
return from >= 0 && to > from ? PAGE.slice(from, to).replace(/\/\*[\s\S]*?\*\//g, "") : "";
|
|
950
|
+
})();
|
|
951
|
+
|
|
952
|
+
/** The page's own before-first-paint rule (src/web.mjs): the theme chosen there, `localStorage.theme`. */
|
|
953
|
+
const THEME_SCRIPT = `try { var t = localStorage.getItem("theme"); if (t === "dark" || t === "light") document.documentElement.dataset.theme = t; } catch (e) {}\n`;
|
|
954
|
+
|
|
955
|
+
/** The page's clock (src/web.mjs, between its markers), writing every `<time>` of the Background view in this browser's format. */
|
|
956
|
+
const CLOCK_SCRIPT = (() => {
|
|
957
|
+
const from = PAGE.indexOf("/* clock: */");
|
|
958
|
+
const to = PAGE.indexOf("/* :clock */");
|
|
959
|
+
const clock = from >= 0 && to > from ? PAGE.slice(from + "/* clock: */".length, to).trim() : "const clock = (iso) => iso;";
|
|
960
|
+
return `${clock}\ndocument.addEventListener("DOMContentLoaded", () => { for (const t of document.querySelectorAll("time[datetime]")) t.textContent = clock(t.getAttribute("datetime")); });\n`;
|
|
961
|
+
})();
|
|
962
|
+
|
|
963
|
+
function escapeHtml(value: unknown): string {
|
|
964
|
+
return String(value ?? "").replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """).replace(/'/g, "'");
|
|
965
|
+
}
|
|
966
|
+
|
|
967
|
+
/** Where a conversation is open, in the page's words. */
|
|
968
|
+
function openIn(holder: SessionHolderView): string {
|
|
969
|
+
switch (holder.kind) {
|
|
970
|
+
case "terminal": return "a terminal";
|
|
971
|
+
case "page": return "this page";
|
|
972
|
+
case "host": return `the background${holder.busy ? `, running ${holder.busy}` : ""}`;
|
|
973
|
+
case "legacy-pi": return "a pi-loops 0.22 window";
|
|
974
|
+
case "nobody": return "closed";
|
|
975
|
+
}
|
|
976
|
+
}
|
|
977
|
+
|
|
978
|
+
/** Renders a static page; escaping each displayed status value keeps conversation and model text inert. */
|
|
979
|
+
export function renderHostPage(s: HostStatus): string {
|
|
980
|
+
const e = escapeHtml;
|
|
981
|
+
const snap = s.snapshot;
|
|
982
|
+
/** A stamp as the page's clock writes it (CLOCK_SCRIPT); the stamp itself until then. */
|
|
983
|
+
const time = (iso: unknown) => `<time datetime="${e(iso)}">${e(iso)}</time>`;
|
|
984
|
+
const count = (n: number, one: string) => `${n} ${one}${n === 1 ? "" : "s"}`;
|
|
985
|
+
const since = ({ sinceYouLooked: y }: SessionRow) => (y.runs === 0 && y.findings === 0 ? "—" : `${count(y.runs, "run")}${y.failed ? `, ${y.failed} failed` : ""}, ${count(y.findings, "finding")}${y.mine && y.mine !== y.findings ? `, ${y.mine} from this conversation` : ""}`);
|
|
986
|
+
// A title and a path are what someone typed or named: drawn as text, as the page's list draws them.
|
|
987
|
+
const rows = s.sessions.map((row) => `<tr><td>${e(plainText(conversationLabel(row.sessionId, row.title)))}</td><td>${e(plainText(row.cwd ?? "—"))}</td><td>${e(openIn(row.holder))}</td><td>${e(row.plainJobs)}</td><td>${e(row.rules)}</td><td>${e(row.mailbox)}</td><td>${e(since(row))}</td></tr>`).join("");
|
|
988
|
+
const table = `<div class="scroll"><table><thead><tr><th>conversation</th><th>project</th><th>where</th><th>loops</th><th>rules</th><th>waiting</th><th>since you looked</th></tr></thead><tbody>${rows}</tbody></table></div>`;
|
|
989
|
+
const running = [...snap.runs.map((run) => `${e(run.label)} since ${time(run.startedAt)}: ${e(run.promptPreview)}`), ...snap.checks.map((check) => `check ${e(check.sourceLabel)}/${e(check.eventLabel)} since ${time(check.startedAt)}: ${e(check.cwd)}`)];
|
|
990
|
+
const runningHtml = running.length ? `<ul>${running.map((line) => `<li>${line}</li>`).join("")}</ul>` : `<p>nothing running right now</p>`;
|
|
991
|
+
const due = snap.nextDue ? `<p>next run ${time(snap.nextDue)}</p>` : "";
|
|
992
|
+
const failing = snap.failing?.length ? `<section><h2>Failing</h2><ul>${snap.failing.map((item) => `<li>${e(snapshotLoopLabel(item.job))}: ${e(item.error)}</li>`).join("")}</ul></section>` : "";
|
|
993
|
+
const recent = snap.recent?.length ? `<section><h2>Recent runs</h2><ul>${snap.recent.map((item) => `<li>${e(item.ok ? "ok" : item.stopped ? "stopped" : "FAIL")} ${time(item.at)} ${e(snapshotLoopLabel(item.job))}${item.cost === undefined ? "" : ` · $${e(Number(item.cost).toFixed(3))}`}${item.byPerson ? " — stopped by you" : item.error ? ` — ${e(item.error)}` : ""}</li>`).join("")}</ul></section>` : "";
|
|
994
|
+
const mcp = snap.mcp.length ? `<section><h2>MCP</h2><ul>${snap.mcp.map((item) => `<li>${e(item.name)}: ${e(item.state)}${item.lastError ? ` — ${e(item.lastError)}` : ""}</li>`).join("")}</ul></section>` : "";
|
|
995
|
+
const budget = snap.budget ? `<section><h2>Budget</h2><p>$${e(Number(snap.budget.spent).toFixed(2))} of $${e(Number(snap.budget.cap).toFixed(2))} today</p></section>` : "";
|
|
996
|
+
// The words `pi-loops status` uses; where each is open is the table's column.
|
|
997
|
+
const waiting = snap.requests?.length ? `<section><h2>Waiting</h2><ul>${requestLines(snap.requests, Date.now(), { where: false }).map((item) => `<li>${e(item.trim())}</li>`).join("")}</ul></section>` : "";
|
|
998
|
+
return `<!doctype html>
|
|
999
|
+
<html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1"><meta http-equiv="refresh" content="15"><title>pi-loops in the background</title><style>
|
|
1000
|
+
${TOKENS}
|
|
1001
|
+
body{font:15px/1.5 var(--font-ui);max-width:1100px;margin:0 auto;padding:1rem;background:var(--bg);color:var(--fg)}h1{font-size:1.35rem}h2{font-size:1.1rem}.muted{color:var(--dim)}a{color:var(--brand)}section{margin:1.5rem 0}.scroll{overflow-x:auto;background:var(--panel);border-radius:12px}table{border-collapse:collapse;width:100%}th,td{text-align:left;padding:.55rem;border-bottom:1px solid var(--line);vertical-align:top}th{white-space:nowrap;color:var(--dim);font-weight:500}tr:last-child td{border-bottom:0}ul{padding-left:1.4rem}li{margin:.35rem 0}footer{margin-top:2rem}
|
|
1002
|
+
</style></head><body>
|
|
1003
|
+
<header><p><a href="/">← back to the page</a></p><h1>pi-loops in the background</h1><p>pi-loops ${e(snap.version ?? "—")} · started ${time(snap.startedAt)}${snap.model ? ` · model ${e(snap.model)}` : ""}</p></header>
|
|
1004
|
+
<section><h2>Conversations</h2>${table}</section>
|
|
1005
|
+
<section><h2>Running</h2>${runningHtml}${due}</section>
|
|
1006
|
+
${waiting}${budget}${failing}${recent}${mcp}
|
|
1007
|
+
<footer><a href="/background.json">background.json</a></footer></body></html>`;
|
|
1008
|
+
}
|
|
1009
|
+
|
|
1010
|
+
/**
|
|
1011
|
+
* `page-open` over the socket: the conversation's page, its pi started here when it is not open. A
|
|
1012
|
+
* new conversation is known by its pi's answer to `get_state`; until then it has no id to put in a
|
|
1013
|
+
* URL, so the answer waits for that, at most 15 seconds. `server` is the page once its bind settles,
|
|
1014
|
+
* undefined when it is not served: pi starts at once, and only the answer, which carries the
|
|
1015
|
+
* page's address, waits for the bind.
|
|
1016
|
+
*/
|
|
1017
|
+
export async function openForLaunch(
|
|
1018
|
+
req: Omit<PageOpenRequest, "op">,
|
|
1019
|
+
deps: {
|
|
1020
|
+
pool: ChildPool;
|
|
1021
|
+
server: Promise<PageServer | undefined>;
|
|
1022
|
+
notServed: () => string;
|
|
1023
|
+
heldTurn: (sessionId: string) => { runId: string; label: string } | undefined;
|
|
1024
|
+
lease: (sessionId: string) => Partial<Pick<SessionLease, "sessionFile" | "holder">> | undefined;
|
|
1025
|
+
},
|
|
1026
|
+
): Promise<PageOpened> {
|
|
1027
|
+
const opened = await startForLaunch(req, deps);
|
|
1028
|
+
const server = await deps.server;
|
|
1029
|
+
if (!server) {
|
|
1030
|
+
// Nothing can reach a pi started for a page that is not served.
|
|
1031
|
+
if ("started" in opened) await deps.pool.stop(opened.sessionId, "the page is not served");
|
|
1032
|
+
return { error: deps.notServed() };
|
|
1033
|
+
}
|
|
1034
|
+
if ("error" in opened) return opened;
|
|
1035
|
+
const { sessionId, reused } = opened;
|
|
1036
|
+
return {
|
|
1037
|
+
url: `${server.url()}c/${encodeURIComponent(sessionId)}/`,
|
|
1038
|
+
sessionId,
|
|
1039
|
+
...(req.openFile ? server.openFileFor(sessionId) : { openKey: server.openKeyFor(sessionId) }),
|
|
1040
|
+
...(req.copyKey ? { copyKey: server.openKeyFor(sessionId, COPY_KEY_MS) } : {}),
|
|
1041
|
+
...(reused ? { reused: true as const } : {}),
|
|
1042
|
+
...(reused && req.piArgs.length ? { ignoredArgs: req.piArgs } : {}),
|
|
1043
|
+
};
|
|
1044
|
+
}
|
|
1045
|
+
|
|
1046
|
+
/** This package's root: what an `-e` that loads pi-loops names. */
|
|
1047
|
+
const PACKAGE_ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
|
|
1048
|
+
|
|
1049
|
+
/** Whether `args` carry `-e <path>` or `--extension <path>` naming this package's root, a relative path read from `base`. */
|
|
1050
|
+
function loadsPiLoops(args: readonly string[], base: string): boolean {
|
|
1051
|
+
for (let i = 0; i + 1 < args.length; i++) {
|
|
1052
|
+
if (args[i] !== "-e" && args[i] !== "--extension") continue;
|
|
1053
|
+
if (realpathish(path.resolve(base, args[i + 1])) === realpathish(PACKAGE_ROOT)) return true;
|
|
1054
|
+
i++;
|
|
1055
|
+
}
|
|
1056
|
+
return false;
|
|
1057
|
+
}
|
|
1058
|
+
|
|
1059
|
+
/** The conversation `page-open` asks for, its pi started when it should be: `started` when this call started one. */
|
|
1060
|
+
async function startForLaunch(
|
|
1061
|
+
req: Omit<PageOpenRequest, "op">,
|
|
1062
|
+
deps: Parameters<typeof openForLaunch>[1],
|
|
1063
|
+
): Promise<{ sessionId: string; reused?: true; started?: true } | { error: string }> {
|
|
1064
|
+
const { pool } = deps;
|
|
1065
|
+
if (req.sessionId !== undefined) {
|
|
1066
|
+
const id = req.sessionId;
|
|
1067
|
+
if (pool.has(id)) return { sessionId: id, reused: true };
|
|
1068
|
+
// A turn pi-loops runs there, or a pi that keeps stopping: the conversation's page says which. A
|
|
1069
|
+
// pi started now would be a second writer, or a fourth crash.
|
|
1070
|
+
const lease = deps.lease(id);
|
|
1071
|
+
const file = req.sessionFile ?? lease?.sessionFile;
|
|
1072
|
+
if (decideOpen({ heldTurn: deps.heldTurn(id), lease, fileMissing: !!file && !fs.existsSync(file), crashes: pool.crashes(id), now: Date.now() }).kind !== "start") {
|
|
1073
|
+
// Whatever starts it later, the page after the wait or Start again, starts it as this launch asked.
|
|
1074
|
+
pool.recordArgs(id, req.piArgs);
|
|
1075
|
+
return { sessionId: id };
|
|
1076
|
+
}
|
|
1077
|
+
await pool.open({ sessionId: id, sessionFile: req.sessionFile ?? deps.lease(id)?.sessionFile, cwd: req.cwd, piArgs: req.piArgs });
|
|
1078
|
+
return { sessionId: id, started: true };
|
|
1079
|
+
}
|
|
1080
|
+
const fresh = await startConversation(pool, req.cwd, req.piArgs);
|
|
1081
|
+
return "error" in fresh ? fresh : { ...fresh, started: true };
|
|
1082
|
+
}
|
|
1083
|
+
|
|
1084
|
+
/**
|
|
1085
|
+
* A new conversation in `cwd`, its pi started with `piArgs`: the id pi says it is in, which waits
|
|
1086
|
+
* for its answer to `get_state`, at most 15 seconds. What a launch and the page's `+ New` in another
|
|
1087
|
+
* project both start. Throws what `ChildPool.open` throws (the cap).
|
|
1088
|
+
*/
|
|
1089
|
+
async function startConversation(pool: ChildPool, cwd: string, piArgs: string[]): Promise<{ sessionId: string } | { error: string }> {
|
|
1090
|
+
const child = await pool.open({ cwd, piArgs });
|
|
1091
|
+
const key = await pool.named(child);
|
|
1092
|
+
if (key !== undefined && !key.startsWith("new-")) return { sessionId: key };
|
|
1093
|
+
const words = key === undefined ? pool.lastWords(`new-${child.pid}`) : child.recentStderr();
|
|
1094
|
+
return { error: `pi did not say which conversation it is in ${key === undefined ? "before it exited" : "within 15 s"}${words ? `: ${words}` : ""}` };
|
|
1095
|
+
}
|
|
1096
|
+
|
|
1097
|
+
export function startPageServer(opts: PageServerOptions): Promise<PageServer> {
|
|
1098
|
+
const LOOPS_DIR = opts.dir;
|
|
1099
|
+
const TOKEN = opts.token;
|
|
1100
|
+
const VERSION = opts.version;
|
|
1101
|
+
const HOST_BIND = opts.bind.host;
|
|
1102
|
+
const PORT = opts.bind.port;
|
|
1103
|
+
const LOOPBACK = HOST_BIND === "127.0.0.1" || HOST_BIND === "::1" || HOST_BIND === "localhost";
|
|
1104
|
+
|
|
1105
|
+
/**
|
|
1106
|
+
* When each job runs next, as the scheduler worked it out.
|
|
1107
|
+
*
|
|
1108
|
+
* This used to be computed here, and understood only `every <interval>` — so a job on a cron
|
|
1109
|
+
* expression, which is the first example in the README, showed no next run at all. Writing a second
|
|
1110
|
+
* cron parser into this file was the wrong fix: the answer is a pure function of the job and the
|
|
1111
|
+
* clock, and the scheduler already has the evaluator. It writes the answers to
|
|
1112
|
+
* `next-runs.json` beside the store when one of them changes, and this reads them like every other
|
|
1113
|
+
* file pi-loops keeps.
|
|
1114
|
+
*
|
|
1115
|
+
* Missing file, older pi-loops, no host yet: no next run shown, which is what was shown before.
|
|
1116
|
+
*/
|
|
1117
|
+
function nextRuns() {
|
|
1118
|
+
// The scheduler's one file. If two machines shared a `$HOME` they would both run every job and
|
|
1119
|
+
// overwrite one another's answers here; that is unsupported rather than handled
|
|
1120
|
+
// (docs/design.md §19).
|
|
1121
|
+
const doc = readJson(path.join(LOOPS_DIR, "next-runs.json"), { next: {} });
|
|
1122
|
+
return doc?.next && typeof doc.next === "object" ? doc.next : {};
|
|
1123
|
+
}
|
|
1124
|
+
|
|
1125
|
+
/**
|
|
1126
|
+
* The run log and the inbox, read for the loop rows' signal once per change of either file: the
|
|
1127
|
+
* snapshot is rebuilt every 8 seconds per open page, and both files grow with every run.
|
|
1128
|
+
*/
|
|
1129
|
+
let signalCache: { stamp: string; runs: RunRecordLike[]; inbox: InboxEntry[] } | undefined;
|
|
1130
|
+
type RunRecordLike = Parameters<typeof loopSignal>[1][number];
|
|
1131
|
+
function signalSources(): { runs: RunRecordLike[]; inbox: InboxEntry[] } {
|
|
1132
|
+
const st = (file: string) => {
|
|
1133
|
+
try {
|
|
1134
|
+
const s = fs.statSync(file);
|
|
1135
|
+
return `${s.size}:${s.mtimeMs}`;
|
|
1136
|
+
} catch {
|
|
1137
|
+
return "-"; // not written yet
|
|
1138
|
+
}
|
|
1139
|
+
};
|
|
1140
|
+
const key = `${st(jobStore.runsFile)}|${st(path.join(LOOPS_DIR, "inbox.jsonl"))}`;
|
|
1141
|
+
if (signalCache?.stamp !== key) {
|
|
1142
|
+
let runs: RunRecordLike[] = [];
|
|
1143
|
+
let entries: InboxEntry[] = [];
|
|
1144
|
+
try {
|
|
1145
|
+
runs = jobStore.allRuns();
|
|
1146
|
+
} catch (err: any) {
|
|
1147
|
+
opts.log(`pi-loops web: runs: ${err?.message ?? err}`);
|
|
1148
|
+
}
|
|
1149
|
+
try {
|
|
1150
|
+
entries = inbox.list();
|
|
1151
|
+
} catch (err: any) {
|
|
1152
|
+
opts.log(`pi-loops web: inbox: ${err?.message ?? err}`);
|
|
1153
|
+
}
|
|
1154
|
+
signalCache = { stamp: key, runs, inbox: entries };
|
|
1155
|
+
}
|
|
1156
|
+
return signalCache;
|
|
1157
|
+
}
|
|
1158
|
+
|
|
1159
|
+
function signalOf(jobId: string, now: number): LoopSignal {
|
|
1160
|
+
const src = signalSources();
|
|
1161
|
+
return loopSignal(jobId, src.runs, src.inbox, now - SIGNAL_WINDOW_MS);
|
|
1162
|
+
}
|
|
1163
|
+
|
|
1164
|
+
/** The latest run record of a job: its id and whether a person stopped it. */
|
|
1165
|
+
function lastEndOf(jobId: unknown): { runId: string; stoppedBy?: "person" } | undefined {
|
|
1166
|
+
const runs = signalSources().runs;
|
|
1167
|
+
for (let i = runs.length - 1; i >= 0; i--) {
|
|
1168
|
+
const r = runs[i];
|
|
1169
|
+
if (r.jobId === jobId) return { runId: r.runId, ...(r.stoppedBy === "person" ? { stoppedBy: "person" as const } : {}) };
|
|
1170
|
+
}
|
|
1171
|
+
return undefined;
|
|
1172
|
+
}
|
|
1173
|
+
|
|
1174
|
+
/** A loop row's signal and suggestion; a plain job has neither. */
|
|
1175
|
+
function loopWorth(j: any, now: number): { signal?: ReturnType<typeof rowSignal>; suggestion?: { kind: string; key: string; label: string } } {
|
|
1176
|
+
if (!j.stateful || typeof j.id !== "string") return {};
|
|
1177
|
+
const sig = signalOf(j.id, now);
|
|
1178
|
+
const s = suggestTune(j as LoopJob, sig, now);
|
|
1179
|
+
return { signal: rowSignal(sig), suggestion: s && { kind: s.kind, key: s.key, label: s.label } };
|
|
1180
|
+
}
|
|
1181
|
+
|
|
1182
|
+
/**
|
|
1183
|
+
* What a rule that waits for a path says, and when its next look is: the page writes "next look
|
|
1184
|
+
* 09:22 · " in the browser's local time in front of the line.
|
|
1185
|
+
*/
|
|
1186
|
+
function ruleWait(r: any): { waitsFor?: string; look?: string; nextLookAt?: string } {
|
|
1187
|
+
const w = typeof r?.condition === "string" ? watchOf(r) : undefined;
|
|
1188
|
+
if (!w || !r.enabled) return {};
|
|
1189
|
+
const last = opts.ruleLook?.(r.id);
|
|
1190
|
+
const timed = (last?.look ?? "absent") === "absent" && last ? { nextLookAt: stamp(last.nextAt) } : {};
|
|
1191
|
+
return { waitsFor: maskSecrets(w.raw), look: lookLine(last?.look, undefined, String), ...timed };
|
|
1192
|
+
}
|
|
1193
|
+
|
|
1194
|
+
function automation(cwd: string, sessionId: any, child: PageChild) {
|
|
1195
|
+
// The directory is the evidence, not any one file in it: a project with rules and no cron jobs
|
|
1196
|
+
// has no jobs.json at all, and reporting "pi-loops not found" there would be a lie.
|
|
1197
|
+
if (!fs.existsSync(LOOPS_DIR)) return { installed: false, dir: LOOPS_DIR };
|
|
1198
|
+
// A plain job runs only in the session that created it; the id in this session's header says
|
|
1199
|
+
// whether that is this one, and the panel says "resume it" rather than promising a time.
|
|
1200
|
+
// pi's own word first: before the first message there is no session file to read a header from,
|
|
1201
|
+
// and the window's own plain job was marked as another session's.
|
|
1202
|
+
const currentSession = sessionId || (child.sessionFile ? readHead(child.sessionFile)?.id : undefined);
|
|
1203
|
+
const jobsFile = readJson(path.join(LOOPS_DIR, "jobs.json"), { jobs: [] });
|
|
1204
|
+
/**
|
|
1205
|
+
* This project's jobs, and the count of everything in other projects goes with it, because a
|
|
1206
|
+
* list that silently drops things is worse than a longer list.
|
|
1207
|
+
*/
|
|
1208
|
+
const inThisProject = (j: any) => sameProject(j.cwd, cwd);
|
|
1209
|
+
const allJobs = jobsFile.jobs ?? [];
|
|
1210
|
+
const nexts = nextRuns();
|
|
1211
|
+
const now = Date.now();
|
|
1212
|
+
const hostPid = liveHostPid(LOOPS_DIR);
|
|
1213
|
+
const hostUp = hostPid !== undefined;
|
|
1214
|
+
// Read on every request, like hostPid: nothing runs a loop, or a closed conversation's plain job,
|
|
1215
|
+
// while no background process is up. The reason is the window's (its runtime snapshot); without
|
|
1216
|
+
// one yet, the plain fact.
|
|
1217
|
+
const paused = hostUp ? undefined : child.live.runtime?.paused || "pi-loops is not running";
|
|
1218
|
+
const view = pageOwnerView(currentSession, child, paused);
|
|
1219
|
+
const owned = (j: any) => ({ stateful: !!j.stateful, sessionId: typeof j.sessionId === "string" ? j.sessionId : undefined });
|
|
1220
|
+
const jobs = allJobs.filter(inThisProject).map((j: any) => ({
|
|
1221
|
+
id: j.id,
|
|
1222
|
+
name: j.name,
|
|
1223
|
+
label: loopLabel({ id: String(j.id), name: typeof j.name === "string" ? j.name : undefined }),
|
|
1224
|
+
// The terminal's words for it (src/schedule.ts): `every 1d` on both. A hand-written job may carry no schedule.
|
|
1225
|
+
schedule: j.schedule && typeof j.schedule === "object" ? (formatSchedule(j.schedule) ?? "?") : "?",
|
|
1226
|
+
stateful: !!j.stateful,
|
|
1227
|
+
enabled: !!j.enabled,
|
|
1228
|
+
prompt: maskSecrets(j.prompt),
|
|
1229
|
+
runCount: j.runCount ?? 0,
|
|
1230
|
+
lastError: maskSecrets(j.lastError),
|
|
1231
|
+
running: !!j.running,
|
|
1232
|
+
// The run Stop this run names: the whole id, which the page posts back as it is.
|
|
1233
|
+
// Only a run pi-loops in the background holds (a loop's run, a held turn) can be stopped from
|
|
1234
|
+
// here; a plain job's turn in a window is that window's to stop.
|
|
1235
|
+
run: j.running && typeof j.running.runId === "string" && hostPid !== undefined && j.running.pid === hostPid ? { runId: j.running.runId, since: j.running.startedAt } : undefined,
|
|
1236
|
+
// How the loop's latest run ended, which is what the row says once a stop is through.
|
|
1237
|
+
lastEnd: lastEndOf(j.id),
|
|
1238
|
+
// A loop's worth, and at most one suggestion that never applies itself (src/job-tune.ts).
|
|
1239
|
+
...loopWorth(j, now),
|
|
1240
|
+
// The open body of a card shows these, which the terminal's `/cron` line has and the panel
|
|
1241
|
+
// did not; they leave the store as they were written, no reshaping.
|
|
1242
|
+
cwd: j.cwd,
|
|
1243
|
+
lastFiredAt: j.lastFiredAt,
|
|
1244
|
+
// Due ticks a run was too slow to take. The `lastError` a skip writes is cleared by the next
|
|
1245
|
+
// run; this count is not, so it is the last evidence a loop is losing cadence.
|
|
1246
|
+
skippedOverlap: j.skippedOverlap ?? 0,
|
|
1247
|
+
// Set only when it is not this machine's: the panel says so, and nothing else has to guess.
|
|
1248
|
+
// What to type to fix it. The terminal's version of this line says `/cron set <n>`, where n
|
|
1249
|
+
// is the position in a numbered list — which this panel does not have, so telling someone to
|
|
1250
|
+
// use one was telling them to go and find a terminal. A name or a full id resolves anywhere.
|
|
1251
|
+
ref: j.name || j.id,
|
|
1252
|
+
// Only for a job that is actually going to run: disabled, or belonging to another machine,
|
|
1253
|
+
// and a next run would be a promise nothing intends to keep — which is the rule `/cron`
|
|
1254
|
+
// follows. A time that has already passed is a stale answer rather than a next run (the job
|
|
1255
|
+
// fired and no tick has rewritten the file yet), and saying nothing beats saying something
|
|
1256
|
+
// visibly wrong.
|
|
1257
|
+
next: j.enabled && runsSomewhere(owned(j), view) ? futureOnly(nexts[j.id]) : undefined,
|
|
1258
|
+
// Where it runs when that is not here, in the terminal's words (src/job-owner.ts). A disabled
|
|
1259
|
+
// one runs nowhere, and the card already says it is off.
|
|
1260
|
+
asleep: j.enabled ? asleepNote(owned(j), view) : undefined,
|
|
1261
|
+
}));
|
|
1262
|
+
const allRules = readJson(path.join(LOOPS_DIR, "triggers.json"), { rules: [] }).rules ?? [];
|
|
1263
|
+
const rules = allRules
|
|
1264
|
+
.filter(inThisProject)
|
|
1265
|
+
.map((r: any) => ({ id: r.id, condition: maskSecrets(r.condition), action: maskSecrets(r.action), enabled: !!r.enabled, fireOnce: !!r.fireOnce, firedAt: r.firedAt, ...ruleWait(r) }));
|
|
1266
|
+
// Two counts, not one. They used to be added together, and `/cron` counts jobs only — so the
|
|
1267
|
+
// panel's number disagreed with the command's whenever a rule lived somewhere else, and the line
|
|
1268
|
+
// that exists to say "your job is not gone, it is elsewhere" pointed at `/cron all`, where a
|
|
1269
|
+
// reader would then count one job too few and conclude the panel was wrong about both.
|
|
1270
|
+
const elsewhere = { jobs: allJobs.length - jobs.length, rules: allRules.length - rules.length };
|
|
1271
|
+
|
|
1272
|
+
// What the inbox lists, counted by the same reader: a line it cannot read or a finding with no
|
|
1273
|
+
// text is not a number on the panel either.
|
|
1274
|
+
let inboxNew = 0;
|
|
1275
|
+
let inboxDecisions = 0;
|
|
1276
|
+
try {
|
|
1277
|
+
const fresh = inbox.listNew();
|
|
1278
|
+
inboxNew = fresh.length;
|
|
1279
|
+
inboxDecisions = fresh.filter((e) => e.kind === "checkpoint").length;
|
|
1280
|
+
} catch (err: any) {
|
|
1281
|
+
opts.log(`pi-loops web: inbox: ${err?.message ?? err}`);
|
|
1282
|
+
}
|
|
1283
|
+
|
|
1284
|
+
// MCP servers and hooks are deliberately not counted from the config files here: what matters
|
|
1285
|
+
// is which ones actually connected and what they exposed, and that is in the runtime snapshot.
|
|
1286
|
+
// Read from host-service.json on every request: the runtime snapshot is written only when a change
|
|
1287
|
+
// is worth an entry, and pi-loops coming up may not be written for a minute.
|
|
1288
|
+
return { installed: true, dir: LOOPS_DIR, jobs, rules, inboxNew, inboxDecisions, elsewhere, hostPid, lastCheck: lastCheckIn(cwd), paused: jobs.some((j: any) => j.enabled && (j.stateful || whereRuns(owned(j), view).kind === "paused")) || rules.some((r: any) => r.enabled) ? paused : undefined };
|
|
1289
|
+
}
|
|
1290
|
+
|
|
1291
|
+
const requests = new RequestStore(LOOPS_DIR);
|
|
1292
|
+
const leases = new LeaseStore(LOOPS_DIR);
|
|
1293
|
+
const audit = new TriggerStore(LOOPS_DIR);
|
|
1294
|
+
const jobStore = new JobStore(LOOPS_DIR);
|
|
1295
|
+
const pool = opts.pool;
|
|
1296
|
+
const signed = (child: PageChild) => createdBy(child, VERSION);
|
|
1297
|
+
/** pi's session files, read for the list and for a conversation no lease names. Nothing is read until asked. */
|
|
1298
|
+
const index = new ConversationIndex(opts.sessionsRoot, undefined, () => leases.list().flatMap((l) => (l.sessionFile ? [l.sessionFile] : [])));
|
|
1299
|
+
const needsYou = new NeedsYouCounts(path.join(LOOPS_DIR, "inbox.jsonl"));
|
|
1300
|
+
|
|
1301
|
+
/**
|
|
1302
|
+
* `GET /conversations`: one project's conversations and the projects to switch to. `cwd` is only
|
|
1303
|
+
* looked up among the projects found; without it, the project of `session` (its lease, else its
|
|
1304
|
+
* file), else the newest. A file read: it never asks the pool for a pi.
|
|
1305
|
+
*/
|
|
1306
|
+
/** The cwds a project is known by besides pi's own files: a live lease's, a job's. */
|
|
1307
|
+
function projectCwds(all: readonly SessionLease[], jobs: unknown, alive: (h: LeaseHolder) => boolean): string[] {
|
|
1308
|
+
return [...all.flatMap((l) => (l.cwd && l.holder && alive(l.holder) ? [l.cwd] : [])), ...(Array.isArray(jobs) ? jobs.flatMap((j: any) => (typeof j?.cwd === "string" ? [j.cwd] : [])) : [])];
|
|
1309
|
+
}
|
|
1310
|
+
|
|
1311
|
+
/** Whether `cwd` is one of the projects `GET /conversations` lists. */
|
|
1312
|
+
function knownProject(cwd: string): boolean {
|
|
1313
|
+
const jobs = readJson(path.join(LOOPS_DIR, "jobs.json"), { jobs: [] }).jobs;
|
|
1314
|
+
return index.projects({ projectCwds: () => projectCwds(leases.list(), jobs, (h) => holderAlive(h)) }).some((p) => p.cwd === cwd);
|
|
1315
|
+
}
|
|
1316
|
+
|
|
1317
|
+
function conversationList(cwdAsked: string | null, sessionAsked: string | null): { status: number; body: Record<string, unknown> } {
|
|
1318
|
+
const all = leases.list();
|
|
1319
|
+
const byId = new Map(all.map((l) => [l.sessionId, l]));
|
|
1320
|
+
const alive = new Map<string, boolean>();
|
|
1321
|
+
const aliveOnce = (h: LeaseHolder) => {
|
|
1322
|
+
const key = `${h.pid}:${h.procStart ?? ""}:${h.acquiredAt}`;
|
|
1323
|
+
if (!alive.has(key)) alive.set(key, holderAlive(h));
|
|
1324
|
+
return alive.get(key) as boolean;
|
|
1325
|
+
};
|
|
1326
|
+
const waiting = new Set(requests.sessions());
|
|
1327
|
+
const jobs = readJson(path.join(LOOPS_DIR, "jobs.json"), { jobs: [] }).jobs;
|
|
1328
|
+
const readers: RowReaders = {
|
|
1329
|
+
lease: (id) => byId.get(id),
|
|
1330
|
+
alive: aliveOnce,
|
|
1331
|
+
open: (id) => pool.has(id),
|
|
1332
|
+
needsYou: () => needsYou.get(),
|
|
1333
|
+
queued: (id) => {
|
|
1334
|
+
if (!waiting.has(id)) return 0;
|
|
1335
|
+
try {
|
|
1336
|
+
return requests.list(id).filter((row) => row.kind === "input").length;
|
|
1337
|
+
} catch {
|
|
1338
|
+
return 0; // an id the store refuses has no rows
|
|
1339
|
+
}
|
|
1340
|
+
},
|
|
1341
|
+
projectCwds: () => projectCwds(all, jobs, aliveOnce),
|
|
1342
|
+
};
|
|
1343
|
+
const scan = index.scan();
|
|
1344
|
+
const projects = index.projects(readers, scan);
|
|
1345
|
+
const known = (cwd: string | undefined) => (cwd !== undefined && projects.some((p) => p.cwd === cwd) ? cwd : undefined);
|
|
1346
|
+
let cwd = cwdAsked ?? undefined;
|
|
1347
|
+
if (cwd === undefined) {
|
|
1348
|
+
const session = sessionAsked ? idFrom(sessionAsked) : undefined;
|
|
1349
|
+
cwd = (session !== undefined ? (known(byId.get(session)?.cwd) ?? known(index.byId(session)?.cwd)) : undefined) ?? projects[0]?.cwd;
|
|
1350
|
+
}
|
|
1351
|
+
if (cwd === undefined) return { status: 200, body: { cwd: null, rows: [], more: 0, projects } };
|
|
1352
|
+
const listed = known(cwd) !== undefined ? index.rows(cwd, readers, scan) : undefined;
|
|
1353
|
+
if (!listed) return { status: 404, body: { error: "no such project" } };
|
|
1354
|
+
return { status: 200, body: { cwd, rows: listed.rows, more: listed.more, projects } };
|
|
1355
|
+
}
|
|
1356
|
+
|
|
1357
|
+
/** A person row's image bytes for the composer; a file that is gone or not the row's gives the text back alone, and the reason is logged. */
|
|
1358
|
+
function imagesOf(row: any) {
|
|
1359
|
+
try {
|
|
1360
|
+
return requests.images(row);
|
|
1361
|
+
} catch (err: any) {
|
|
1362
|
+
opts.log(`pi-loops web: ${err?.message ?? err}`);
|
|
1363
|
+
return [];
|
|
1364
|
+
}
|
|
1365
|
+
}
|
|
1366
|
+
|
|
1367
|
+
/**
|
|
1368
|
+
* The input rows queued for the session, in the order the engine offers them, as the page lists
|
|
1369
|
+
* them: the person's and pi-loops' in one queue, each cancellable. A person row from a page that is
|
|
1370
|
+
* gone is `offered`: nothing sends it until this page says so (`/queue/send`) or discards it. One pi
|
|
1371
|
+
* refused (`last_error`, written by the holder) is taken out here, audited, and handed back to the
|
|
1372
|
+
* composer with the error, through the event stream. One of this page's nobody took (`untaken`)
|
|
1373
|
+
* says why, with send-here. With no pi-loops in this pi the file is nobody's to drain, and the
|
|
1374
|
+
* queue the page shows is pi's own.
|
|
1375
|
+
*/
|
|
1376
|
+
function queuedRequests(sessionId: any, idle = false, child: PageChild) {
|
|
1377
|
+
if (typeof sessionId !== "string" || !sessionId || child.catalog.piLoopsLoaded === false) return [];
|
|
1378
|
+
const holder = holderNow(sessionId, child);
|
|
1379
|
+
const now = Date.now();
|
|
1380
|
+
let rows: any[];
|
|
1381
|
+
try {
|
|
1382
|
+
rows = queueOrder(requests.list(sessionId));
|
|
1383
|
+
} catch (err: any) {
|
|
1384
|
+
opts.log(`pi-loops web: requests: ${err?.message ?? err}`);
|
|
1385
|
+
return [];
|
|
1386
|
+
}
|
|
1387
|
+
const failed = rows.filter((row) => row.source === "person" && row.last_error !== undefined);
|
|
1388
|
+
for (const row of failed) {
|
|
1389
|
+
// The bytes first: removing the row removes its image file.
|
|
1390
|
+
const images = imagesOf(row);
|
|
1391
|
+
requests.remove(sessionId, [row.id]);
|
|
1392
|
+
audit.appendAudit({ cwd: row.payload?.cwd ?? "", type: "request", traceId: row.id, state: "failed", sourceLabel: row.source, eventLabel: sessionId, details: { session_id: sessionId, error: row.last_error, returned: "to the composer" } });
|
|
1393
|
+
child.broadcast({ type: "send_failed", id: row.id, text: row.payload?.text ?? "", images, error: row.last_error });
|
|
1394
|
+
}
|
|
1395
|
+
const jobNames = new Map((readJson(path.join(LOOPS_DIR, "jobs.json"), { jobs: [] }).jobs ?? []).map((j: any) => [j?.id, j?.name]).filter(([id, name]: any) => typeof id === "string" && typeof name === "string"));
|
|
1396
|
+
return rows
|
|
1397
|
+
.filter((row) => !failed.includes(row))
|
|
1398
|
+
.map((row) => ({
|
|
1399
|
+
id: row.id,
|
|
1400
|
+
source: row.source,
|
|
1401
|
+
label: labelOf(row, jobNames),
|
|
1402
|
+
// An offered row is shown whole: send vouches for all of it, so all of it is what you read.
|
|
1403
|
+
text: maskSecrets(String(row.payload?.text ?? "").replace(/^\[Trigger [^\]]+\]\s*/, "")).slice(0, row.source === "person" && !producerAlive(row) ? Infinity : TEXT_PREVIEW),
|
|
1404
|
+
images: row.payload?.images?.length ?? 0,
|
|
1405
|
+
created_at: row.created_at,
|
|
1406
|
+
mine: mine(row, child),
|
|
1407
|
+
offered: row.source === "person" && !producerAlive(row),
|
|
1408
|
+
stuck: untaken(row, holder, now, idle, child),
|
|
1409
|
+
}));
|
|
1410
|
+
}
|
|
1411
|
+
|
|
1412
|
+
/**
|
|
1413
|
+
* Who holds the session, as the page's answers need it: the pid, whether it is the pi behind this
|
|
1414
|
+
* page, whether it stands aside, and whether it delivers person rows at all (src/lease.ts
|
|
1415
|
+
* holderDelivers). Undefined when nobody alive holds it.
|
|
1416
|
+
*/
|
|
1417
|
+
function holderNow(sessionId: string, child: PageChild) {
|
|
1418
|
+
let lease;
|
|
1419
|
+
try {
|
|
1420
|
+
lease = leases.read(sessionId);
|
|
1421
|
+
} catch {
|
|
1422
|
+
return undefined; // no lease directory yet: nobody holds it
|
|
1423
|
+
}
|
|
1424
|
+
const holder = lease?.holder;
|
|
1425
|
+
if (!holder || !holderAlive(holder)) return undefined;
|
|
1426
|
+
return { kind: holderOf(lease, () => true, false).kind, pid: holder.pid, mine: holder.pid === child.pid, aside: holder.aside === true, delivers: holderDelivers(holder), turns: holder.turns === true, ...(holder.turnSince ? { turnSince: holder.turnSince } : {}) };
|
|
1427
|
+
}
|
|
1428
|
+
|
|
1429
|
+
/** What each page pi's copy of a terminal-held conversation was read from, and when it was last read again. */
|
|
1430
|
+
const viewerMarks = new WeakMap<PageChild, { size: number; mtimeMs: number }>();
|
|
1431
|
+
const viewerReadAt = new WeakMap<PageChild, number>();
|
|
1432
|
+
/**
|
|
1433
|
+
* The page's copy of a conversation a terminal holds is read again once the terminal's turn is
|
|
1434
|
+
* over (src/lease.ts shouldReadAgain): the pages are told with a numbered `reread`, and each reads
|
|
1435
|
+
* `/history`, which reads the file. Once per change: the mark moves when it is said.
|
|
1436
|
+
*/
|
|
1437
|
+
function viewerCatchUp(child: PageChild, holder: ReturnType<typeof holderNow>, busy: boolean) {
|
|
1438
|
+
const file = child.sessionFile;
|
|
1439
|
+
if (!file || !holder) return;
|
|
1440
|
+
let now;
|
|
1441
|
+
try {
|
|
1442
|
+
const st = fs.statSync(file);
|
|
1443
|
+
now = { size: st.size, mtimeMs: st.mtimeMs };
|
|
1444
|
+
} catch {
|
|
1445
|
+
return; // gone: nothing to read
|
|
1446
|
+
}
|
|
1447
|
+
const mark = viewerMarks.get(child);
|
|
1448
|
+
// A page that read its pi's copy before the pool knew the file starts counting from here.
|
|
1449
|
+
if (!mark) return void viewerMarks.set(child, now);
|
|
1450
|
+
const last = viewerReadAt.get(child);
|
|
1451
|
+
if (!shouldReadAgain({ holder, busyHere: busy, readMark: mark, now, sinceLastMs: last === undefined ? Infinity : Date.now() - last, newMessage: () => addedMessage(file, mark.size, now.size) })) return;
|
|
1452
|
+
viewerReadAt.set(child, Date.now());
|
|
1453
|
+
viewerMarks.set(child, now);
|
|
1454
|
+
child.broadcast({ type: "reread", why: "your terminal answered — the conversation above was read again" });
|
|
1455
|
+
}
|
|
1456
|
+
|
|
1457
|
+
/**
|
|
1458
|
+
* A page's pi that holds its conversation but stands aside reads it again from disk. It stands aside
|
|
1459
|
+
* when it took the conversation back from a terminal that wrote after it read the file, and a
|
|
1460
|
+
* terminal has no way to tell it; in a terminal the person types /resume, and the page's pi has
|
|
1461
|
+
* nobody to type it. pi refuses the swap during a turn, so a busy pi is tried again later. Not
|
|
1462
|
+
* awaited: the page's poll answers at once, and the next one shows the conversation caught up.
|
|
1463
|
+
*/
|
|
1464
|
+
function catchUp(child: PageChild) {
|
|
1465
|
+
const sessionId = child.sessionId;
|
|
1466
|
+
if (!sessionId || child.catalog.piLoopsLoaded === false) return;
|
|
1467
|
+
const holder = holderNow(sessionId, child);
|
|
1468
|
+
if (!holder?.mine || !holder.aside) return;
|
|
1469
|
+
const last = caughtUpAt.get(child);
|
|
1470
|
+
if (last !== undefined && Date.now() - last < CATCH_UP_EVERY_MS) return;
|
|
1471
|
+
caughtUpAt.set(child, Date.now());
|
|
1472
|
+
child
|
|
1473
|
+
.swapSession(undefined, { restoreModel: true, reopen: true })
|
|
1474
|
+
.then((answer: any) => opts.log(answer?.success ? `page: ${sessionId.slice(0, 8)} read again from disk; it is current` : `page: ${sessionId.slice(0, 8)} could not be read again: ${answer?.error ?? "no answer"}`))
|
|
1475
|
+
.catch((err: any) => opts.log(`page: ${sessionId.slice(0, 8)} could not be read again: ${err?.message ?? err}`));
|
|
1476
|
+
}
|
|
1477
|
+
/** When each page pi last tried to catch up, so one that stays aside is not reloaded on every poll. */
|
|
1478
|
+
const caughtUpAt = new WeakMap<PageChild, number>();
|
|
1479
|
+
|
|
1480
|
+
/**
|
|
1481
|
+
* The child's `get_state`, with the request rows and the panel this side reads from disk, and who
|
|
1482
|
+
* holds the conversation by the rule `/prompt` follows (`holderNow`): the page's held line and the
|
|
1483
|
+
* notice after a send cannot disagree. A session id that is not the one the pool has this child
|
|
1484
|
+
* under re-keys it, and its pages are told first.
|
|
1485
|
+
*/
|
|
1486
|
+
async function snapshot(child: PageChild) {
|
|
1487
|
+
catchUp(child);
|
|
1488
|
+
const state = await child.snapshot({ requests: (sessionId, idle) => queuedRequests(sessionId, idle, child), automation: (cwd, sessionId) => automation(cwd, sessionId, child) });
|
|
1489
|
+
pool.follow(child);
|
|
1490
|
+
// Whether a message sent here goes to the holder at all: only a pi with pi-loops in it writes a
|
|
1491
|
+
// request row a terminal can take. Without, it answers the message itself, beside the terminal.
|
|
1492
|
+
if (child.catalog.piLoopsLoaded === undefined) await child.refreshCommands();
|
|
1493
|
+
const piLoops = child.catalog.piLoopsLoaded !== false;
|
|
1494
|
+
const id = typeof state.sessionId === "string" && state.sessionId ? state.sessionId : child.sessionId;
|
|
1495
|
+
const h = id ? holderNow(id, child) : undefined;
|
|
1496
|
+
viewerCatchUp(child, h, !!state.busy || !!state.compacting);
|
|
1497
|
+
return { ...state, holder: h ? { kind: h.kind, pid: h.pid, mine: h.mine, delivers: h.delivers, aside: h.aside, piLoops, ...(h.turnSince ? { turnSince: h.turnSince } : {}) } : { kind: "none", mine: false, delivers: false, aside: false, piLoops } };
|
|
1498
|
+
}
|
|
1499
|
+
|
|
1500
|
+
/** Event streams open across every child. */
|
|
1501
|
+
const clients = () => pool.list().reduce((n, c) => n + c.clients.size, 0);
|
|
1502
|
+
|
|
1503
|
+
const CHECK: CheckOptions = { token: TOKEN, allowHosts: opts.allowHosts, loopback: LOOPBACK };
|
|
1504
|
+
/** The browsers that paired, and the keys minted for the addresses a browser fetches without our header. */
|
|
1505
|
+
const devices = new DeviceStore(LOOPS_DIR, TOKEN, opts.log);
|
|
1506
|
+
const urlKeys = new UrlKeys();
|
|
1507
|
+
const deviceCookie = (id: string) => `${DEVICE_COOKIE}=${id}; Path=/; Max-Age=31536000; HttpOnly; SameSite=Strict`;
|
|
1508
|
+
|
|
1509
|
+
/**
|
|
1510
|
+
* One-shot keys for the browser launcher. The real token must not appear in argv: on Linux
|
|
1511
|
+
* `/proc/<pid>/cmdline` is world-readable, so `xdg-open http://…?token=…` hands the token to every
|
|
1512
|
+
* other account on the machine — and with it `/rpc`, which is the whole session. A key is valid for
|
|
1513
|
+
* one page load, for a minute, opens the conversation it was made for, and the page it serves
|
|
1514
|
+
* carries the real token.
|
|
1515
|
+
*/
|
|
1516
|
+
const OPEN_KEY_MS = 60_000;
|
|
1517
|
+
const openKeys = new Map<string, { sessionId: string; expires: number; file?: string }>();
|
|
1518
|
+
/** Forget a key, and remove the file that held it. */
|
|
1519
|
+
function dropOpenKey(key: string) {
|
|
1520
|
+
const file = openKeys.get(key)?.file;
|
|
1521
|
+
openKeys.delete(key);
|
|
1522
|
+
if (file) guard(opts.log, "remove open file", () => removeOpenFile(file))();
|
|
1523
|
+
}
|
|
1524
|
+
function openKeyFor(sessionId: string, ttlMs = OPEN_KEY_MS) {
|
|
1525
|
+
const now = Date.now();
|
|
1526
|
+
for (const [key, v] of openKeys) if (v.expires <= now) dropOpenKey(key);
|
|
1527
|
+
const key = randomBytes(16).toString("hex");
|
|
1528
|
+
openKeys.set(key, { sessionId, expires: now + ttlMs });
|
|
1529
|
+
return key;
|
|
1530
|
+
}
|
|
1531
|
+
function openFileFor(sessionId: string) {
|
|
1532
|
+
const openKey = openKeyFor(sessionId);
|
|
1533
|
+
const openFile = writeOpenFile(LOOPS_DIR, `${api.url()}c/${encodeURIComponent(sessionId)}/?open=${openKey}`);
|
|
1534
|
+
openKeys.get(openKey)!.file = openFile;
|
|
1535
|
+
// Unspent, the file goes when the key would have: nothing else may come to read it.
|
|
1536
|
+
setTimeout(() => {
|
|
1537
|
+
if (openKeys.get(openKey)?.file === openFile) dropOpenKey(openKey);
|
|
1538
|
+
}, OPEN_KEY_MS).unref();
|
|
1539
|
+
return { openKey, openFile };
|
|
1540
|
+
}
|
|
1541
|
+
/** The conversation a key opens, spending the key; undefined for one that is unknown or late. */
|
|
1542
|
+
function spendOpenKey(key: string | null) {
|
|
1543
|
+
const found = key ? openKeys.get(key) : undefined;
|
|
1544
|
+
if (!key || !found) return undefined;
|
|
1545
|
+
dropOpenKey(key);
|
|
1546
|
+
return found.expires > Date.now() ? found.sessionId : undefined;
|
|
1547
|
+
}
|
|
1548
|
+
// The keys of a process before this one died with it; so do the files that held them.
|
|
1549
|
+
guard(opts.log, "sweep open files", () => sweepOpenFiles(LOOPS_DIR))();
|
|
1550
|
+
|
|
1551
|
+
/**
|
|
1552
|
+
* A six-digit code for a phone. The token is 32 hex characters, which is fine to click and
|
|
1553
|
+
* miserable to type on a screen keyboard, so the terminal prints a number instead: enter it once
|
|
1554
|
+
* and that device has the cookie for good. Single use, and the scheme gives up after twenty wrong
|
|
1555
|
+
* guesses — six digits is a million, so twenty tries is not a search, but the counter is what
|
|
1556
|
+
* makes that a fact rather than an assumption.
|
|
1557
|
+
*/
|
|
1558
|
+
let pairCode = "";
|
|
1559
|
+
let pairTries = 0;
|
|
1560
|
+
let pairExpires = 0;
|
|
1561
|
+
|
|
1562
|
+
function newPairCode() {
|
|
1563
|
+
pairCode = String(randomInt(0, 1_000_000)).padStart(6, "0");
|
|
1564
|
+
pairExpires = Date.now() + PAIR_TTL_MS;
|
|
1565
|
+
pairTries = 0;
|
|
1566
|
+
return pairCode;
|
|
1567
|
+
}
|
|
1568
|
+
|
|
1569
|
+
/** Used, cancelled, or expired: all three mean the same thing to everything downstream. */
|
|
1570
|
+
function clearPairCode() {
|
|
1571
|
+
pairCode = "";
|
|
1572
|
+
pairExpires = 0;
|
|
1573
|
+
}
|
|
1574
|
+
|
|
1575
|
+
/**
|
|
1576
|
+
* Check a pairing guess, and spend one of the twenty tries if it is wrong.
|
|
1577
|
+
*
|
|
1578
|
+
* Named for the side effect, because the side effect is the whole reason the order of the checks in
|
|
1579
|
+
* `GET /` matters: a browser that is already signed in must be recognised as signed in *before*
|
|
1580
|
+
* anything asks this, or reloading a bookmarked address that still carries an old `?pair=` burns a
|
|
1581
|
+
* try the phone waiting on the other side of the room needs.
|
|
1582
|
+
*/
|
|
1583
|
+
function spendPairCode(given: string | null) {
|
|
1584
|
+
if (pairCode && Date.now() > pairExpires) clearPairCode();
|
|
1585
|
+
if (!pairCode || given == null) return false;
|
|
1586
|
+
// Shape first. A six-character guess can still be twelve bytes, and timingSafeEqual throws on a
|
|
1587
|
+
// length mismatch — which used to be a 500 that told a stranger a code was armed, and did it
|
|
1588
|
+
// without spending one of the twenty tries.
|
|
1589
|
+
if (typeof given !== "string" || !/^\d{6}$/.test(given)) {
|
|
1590
|
+
if (given) spendTry();
|
|
1591
|
+
return false;
|
|
1592
|
+
}
|
|
1593
|
+
if (timingSafeEqual(Buffer.from(given), Buffer.from(pairCode))) {
|
|
1594
|
+
clearPairCode();
|
|
1595
|
+
return true;
|
|
1596
|
+
}
|
|
1597
|
+
spendTry();
|
|
1598
|
+
return false;
|
|
1599
|
+
}
|
|
1600
|
+
|
|
1601
|
+
/** A wrong code is either a typo or a search, and there is no way to tell them apart from here. */
|
|
1602
|
+
function spendTry() {
|
|
1603
|
+
if (++pairTries >= PAIR_MAX_TRIES) {
|
|
1604
|
+
clearPairCode();
|
|
1605
|
+
opts.log("pi-loops web: too many wrong pairing codes; ask for another one");
|
|
1606
|
+
}
|
|
1607
|
+
}
|
|
1608
|
+
|
|
1609
|
+
/**
|
|
1610
|
+
* What you chose last time, so the next session starts there.
|
|
1611
|
+
*
|
|
1612
|
+
* A session opens on pi's default model, which is why choosing the same one every morning was the
|
|
1613
|
+
* first thing this front end asked of anybody. It lives next to the loops rather than in the
|
|
1614
|
+
* package, so it survives an upgrade, and the launcher is what applies it — the terminal window
|
|
1615
|
+
* gets the same memory as this one.
|
|
1616
|
+
*/
|
|
1617
|
+
function rememberPref(key: "model" | "thinking", setting: string) {
|
|
1618
|
+
writeUiPref(LOOPS_DIR, key, setting);
|
|
1619
|
+
}
|
|
1620
|
+
|
|
1621
|
+
/** HTML written into the conversation instead of into a file, kept just long enough to look at. */
|
|
1622
|
+
const previews = new Map<string, { html: string; at: number }>();
|
|
1623
|
+
|
|
1624
|
+
/** A session id from a URL, or undefined for one that is not a usable file name. */
|
|
1625
|
+
function idFrom(raw: string) {
|
|
1626
|
+
try {
|
|
1627
|
+
return sessionFileName(decodeURIComponent(raw));
|
|
1628
|
+
} catch {
|
|
1629
|
+
return undefined; // not an id: `..`, a slash, a malformed escape
|
|
1630
|
+
}
|
|
1631
|
+
}
|
|
1632
|
+
|
|
1633
|
+
/**
|
|
1634
|
+
* `POST /conversations/new`: a new conversation in another project, as `+ New` starts one when the
|
|
1635
|
+
* list shows a project other than the open conversation's. `cwd` is a key into the projects the
|
|
1636
|
+
* list offers, never a path to start pi in; pi's arguments are that project's (`argsFor`), never
|
|
1637
|
+
* the request's. Started as a launch starts one (`startConversation`), under the same cap.
|
|
1638
|
+
*/
|
|
1639
|
+
async function newConversation(b: Record<string, unknown>): Promise<{ status: number; body: Record<string, unknown> }> {
|
|
1640
|
+
if (Object.keys(b).some((k) => k !== "cwd") || typeof b.cwd !== "string" || !b.cwd || !knownProject(b.cwd)) return { status: 400, body: { error: "not a project the page lists" } };
|
|
1641
|
+
let fresh: { sessionId: string } | { error: string };
|
|
1642
|
+
try {
|
|
1643
|
+
fresh = await startConversation(pool, b.cwd, argsFor(undefined, b.cwd));
|
|
1644
|
+
} catch (err: any) {
|
|
1645
|
+
return { status: 503, body: { error: String(err?.message ?? err) } };
|
|
1646
|
+
}
|
|
1647
|
+
if ("error" in fresh) return { status: 503, body: { error: fresh.error } };
|
|
1648
|
+
return { status: 200, body: { url: `/c/${encodeURIComponent(fresh.sessionId)}/` } };
|
|
1649
|
+
}
|
|
1650
|
+
|
|
1651
|
+
const viewOf = (sessionId: string): ChildView | undefined => pool.views().find((v) => v.sessionId === sessionId);
|
|
1652
|
+
const NO_SUCH = { error: "no such conversation" };
|
|
1653
|
+
const waitingLine = (label: string) => `pi-loops is running ${label} in this conversation; it opens when the turn ends`;
|
|
1654
|
+
const unsavedLine = "This conversation is open in a terminal and has nothing saved yet; it opens here after its first answer";
|
|
1655
|
+
const crashLine = `pi stopped ${PAGE_CRASH_BUDGET.count} times within ${PAGE_CRASH_BUDGET.windowMs / 60_000} minutes in this conversation, so it is not started again on its own`;
|
|
1656
|
+
|
|
1657
|
+
type Opened = { child: PageChild } | { status: number; body: Record<string, unknown>; decision?: ReturnType<typeof decideOpen> };
|
|
1658
|
+
|
|
1659
|
+
/**
|
|
1660
|
+
* The child of a conversation named in a URL, started when the conversation is one pi-loops knows
|
|
1661
|
+
* and `decideOpen` says start. A session id is accepted only when it is a usable file name and the
|
|
1662
|
+
* pool has it or a lease names it; `cwd` and pi's arguments never come from HTTP.
|
|
1663
|
+
*/
|
|
1664
|
+
async function conversation(sessionId: string | undefined): Promise<Opened> {
|
|
1665
|
+
if (sessionId === undefined) return { status: 404, body: NO_SUCH };
|
|
1666
|
+
// A conversation no lease names (from before 0.23, a `pi -p` run, a pi without pi-loops) opens
|
|
1667
|
+
// from pi's own file, found by its header id under pi's sessions root: what `pi --session` takes.
|
|
1668
|
+
const lease: Partial<Pick<SessionLease, "sessionFile" | "cwd" | "holder">> | undefined = opts.sessions.known(sessionId) ?? (pool.get(sessionId) ? undefined : index.byId(sessionId));
|
|
1669
|
+
const fileMissing = !!lease?.sessionFile && !fs.existsSync(lease.sessionFile);
|
|
1670
|
+
// Its own file now: a conversation pi started another one for opens as itself again.
|
|
1671
|
+
if (lease?.sessionFile && !fileMissing) pool.forgetMove(sessionId);
|
|
1672
|
+
const have = pool.get(sessionId);
|
|
1673
|
+
const known = have ? undefined : lease;
|
|
1674
|
+
if (!have && !(known?.cwd && known.sessionFile)) return { status: 404, body: NO_SUCH };
|
|
1675
|
+
const decision = decideOpen({ child: have ? viewOf(sessionId) : undefined, heldTurn: opts.heldTurn?.(sessionId), lease: known, fileMissing, crashes: pool.crashes(sessionId), now: Date.now() });
|
|
1676
|
+
if (decision.kind === "wait-background") return { status: 409, body: { error: waitingLine(decision.label), waiting: { runId: decision.runId, label: decision.label } }, decision };
|
|
1677
|
+
if (decision.kind === "wait-terminal") return { status: 409, body: { error: unsavedLine }, decision };
|
|
1678
|
+
if (decision.kind === "crash-looping") return { status: 503, body: { error: crashLine }, decision };
|
|
1679
|
+
if (have) return { child: have };
|
|
1680
|
+
try {
|
|
1681
|
+
return { child: await pool.open({ sessionId, sessionFile: known?.sessionFile, cwd: known?.cwd as string, piArgs: argsFor(sessionId, known?.cwd as string) }) };
|
|
1682
|
+
} catch (err: any) {
|
|
1683
|
+
return { status: 503, body: { error: String(err?.message ?? err) } };
|
|
1684
|
+
}
|
|
1685
|
+
}
|
|
1686
|
+
|
|
1687
|
+
/**
|
|
1688
|
+
* The pi arguments a conversation's pi starts with: those recorded for it, else those its project's
|
|
1689
|
+
* most recent conversation was opened with (a page open now, else a lease's), so a conversation
|
|
1690
|
+
* opened from the list loads pi-loops with `-e` when the rest of that project's pages do. Never
|
|
1691
|
+
* from HTTP.
|
|
1692
|
+
*/
|
|
1693
|
+
function argsFor(sessionId: string | undefined, cwd: string): string[] {
|
|
1694
|
+
const own = sessionId === undefined ? [] : pool.argsOf(sessionId);
|
|
1695
|
+
if (own.length) return own;
|
|
1696
|
+
const live = pool.list().map((c) => ({ cwd: c.cwd, at: c.startedAt, args: c.piArgs }));
|
|
1697
|
+
let recorded: Array<{ cwd?: string; at: number; args: string[] }> = [];
|
|
1698
|
+
try {
|
|
1699
|
+
recorded = leases
|
|
1700
|
+
.list()
|
|
1701
|
+
.filter((l) => l.cwd === cwd && l.sessionId !== sessionId)
|
|
1702
|
+
.map((l) => ({ cwd: l.cwd, at: Date.parse(l.updatedAt) || 0, args: pool.argsOf(l.sessionId) }));
|
|
1703
|
+
} catch (err: any) {
|
|
1704
|
+
opts.log(`pi-loops page: leases: ${err?.message ?? err}`);
|
|
1705
|
+
}
|
|
1706
|
+
const project = projectArgs(cwd, [...live, ...recorded]);
|
|
1707
|
+
if (project.length) return project;
|
|
1708
|
+
// A project with no arguments of its own: only the flag that loads pi-loops itself, from a page pi
|
|
1709
|
+
// started with it or this process's own launch, so its conversations have pi-loops too. No model,
|
|
1710
|
+
// no other extension: nothing else of another project's crosses over.
|
|
1711
|
+
const loadsUs = [...pool.list().map((c) => ({ args: c.piArgs, base: c.cwd })), { args: process.argv.slice(2), base: process.cwd() }].some(({ args, base }) => loadsPiLoops(args, base));
|
|
1712
|
+
return loadsUs ? ["-e", PACKAGE_ROOT] : [];
|
|
1713
|
+
}
|
|
1714
|
+
|
|
1715
|
+
/* -------------------------------------------------------- the inbox as cards (docs/loops.md) */
|
|
1716
|
+
|
|
1717
|
+
const inbox = new Inbox(LOOPS_DIR);
|
|
1718
|
+
/** `project`: the loop belongs to its project (no creating conversation, recipe.ts installScope); a claim runs where it is made. */
|
|
1719
|
+
type Owner = { holder: "page" | "terminal" | "terminal-quiet" | "background" | "none" | "gone" | "project"; title?: string };
|
|
1720
|
+
type PageCard = InboxCard & { owner: Owner; line?: string };
|
|
1721
|
+
|
|
1722
|
+
/** A lease, or undefined for an id the store refuses or no lease directory yet. */
|
|
1723
|
+
function leaseOf(sessionId: string): SessionLease | undefined {
|
|
1724
|
+
try {
|
|
1725
|
+
return leases.read(sessionId);
|
|
1726
|
+
} catch {
|
|
1727
|
+
return undefined; // not an id the store takes, or no lease directory yet: nobody holds it
|
|
1728
|
+
}
|
|
1729
|
+
}
|
|
1730
|
+
|
|
1731
|
+
/**
|
|
1732
|
+
* The conversation `sessionId` names, when it is still there: open in the page, or a session file
|
|
1733
|
+
* a lease or pi's own sessions directory names. A finding's `session_id` is data from a file, so it
|
|
1734
|
+
* is checked as a file name before it reaches any path.
|
|
1735
|
+
*/
|
|
1736
|
+
function conversationThere(sessionId: string | undefined): { sessionId: string; file?: string; cwd?: string } | undefined {
|
|
1737
|
+
if (!sessionId) return undefined;
|
|
1738
|
+
let id: string;
|
|
1739
|
+
try {
|
|
1740
|
+
id = sessionFileName(sessionId);
|
|
1741
|
+
} catch {
|
|
1742
|
+
return undefined; // not a usable id: `..`, a slash
|
|
1743
|
+
}
|
|
1744
|
+
const child = pool.get(id);
|
|
1745
|
+
const lease = opts.sessions.known(id);
|
|
1746
|
+
if (child) {
|
|
1747
|
+
// Open in the page: there even before its first answer, with its file when it has one, for its title.
|
|
1748
|
+
const file = [lease?.sessionFile, index.byId(id)?.sessionFile].find((f) => f !== undefined && fs.existsSync(f));
|
|
1749
|
+
return file ? { sessionId: id, file, cwd: child.cwd } : { sessionId: id, cwd: child.cwd };
|
|
1750
|
+
}
|
|
1751
|
+
if (lease?.sessionFile && fs.existsSync(lease.sessionFile)) return { sessionId: id, file: lease.sessionFile, cwd: lease.cwd };
|
|
1752
|
+
const found = index.byId(id);
|
|
1753
|
+
if (found && fs.existsSync(found.sessionFile)) return { sessionId: id, file: found.sessionFile, cwd: found.cwd };
|
|
1754
|
+
return undefined;
|
|
1755
|
+
}
|
|
1756
|
+
|
|
1757
|
+
/** Who would run a claim queued for `sessionId` now, and what the conversation is called. */
|
|
1758
|
+
function ownerOf(sessionId: string | undefined, titles: Map<string, string | undefined>): Owner {
|
|
1759
|
+
if (sessionId === undefined) return { holder: "project" };
|
|
1760
|
+
const there = conversationThere(sessionId);
|
|
1761
|
+
if (!there) return { holder: "gone" };
|
|
1762
|
+
if (!titles.has(there.sessionId)) titles.set(there.sessionId, there.file ? titleOf(readSessionHead(there.file)) : undefined);
|
|
1763
|
+
const title = titles.get(there.sessionId);
|
|
1764
|
+
const lease = leaseOf(there.sessionId);
|
|
1765
|
+
const h = holderOf(lease, (x) => holderAlive(x), pool.has(there.sessionId));
|
|
1766
|
+
const busy = lease?.holder?.kind === "host" && !!lease.holder.busy;
|
|
1767
|
+
const holder: Owner["holder"] = h.kind === "terminal" ? (h.delivers ? "terminal" : "terminal-quiet") : h.kind === "background" ? (busy ? "background" : "none") : h.kind === "page" ? "page" : "none";
|
|
1768
|
+
return title === undefined ? { holder } : { holder, title };
|
|
1769
|
+
}
|
|
1770
|
+
|
|
1771
|
+
/** Every queued `inbox:` row on this machine, by the finding it claims. */
|
|
1772
|
+
function queuedClaims(): Map<string, { sessionId: string; row: SessionRequest }> {
|
|
1773
|
+
const out = new Map<string, { sessionId: string; row: SessionRequest }>();
|
|
1774
|
+
for (const sessionId of requests.sessions()) {
|
|
1775
|
+
let rows: SessionRequest[];
|
|
1776
|
+
try {
|
|
1777
|
+
rows = requests.list(sessionId);
|
|
1778
|
+
} catch {
|
|
1779
|
+
continue; // an id the store refuses has no rows
|
|
1780
|
+
}
|
|
1781
|
+
for (const row of rows) if (row.kind === "input" && row.source.startsWith("inbox:")) out.set(row.source.slice("inbox:".length), { sessionId, row });
|
|
1782
|
+
}
|
|
1783
|
+
return out;
|
|
1784
|
+
}
|
|
1785
|
+
|
|
1786
|
+
const jobNames = () => {
|
|
1787
|
+
const jobs = readJson(path.join(LOOPS_DIR, "jobs.json"), { jobs: [] }).jobs;
|
|
1788
|
+
return new Map<string, { id: string; name?: string }>((Array.isArray(jobs) ? jobs : []).flatMap((j: any) => (typeof j?.id === "string" ? [[j.id, typeof j.name === "string" ? { id: j.id, name: j.name } : { id: j.id }]] : [])));
|
|
1789
|
+
};
|
|
1790
|
+
|
|
1791
|
+
/**
|
|
1792
|
+
* `GET /activity`: the strip's items for one project over the last day, newest first (src/activity.ts),
|
|
1793
|
+
* and the attention block the title and the hidden tab's notifications read. Scoped as `GET /inbox`
|
|
1794
|
+
* is. A file read of the tails of the run log and the audit: it starts no pi.
|
|
1795
|
+
*/
|
|
1796
|
+
function activityListing(cwdAsked: string | null, sessionAsked: string | null): { status: number; body: Record<string, unknown> } {
|
|
1797
|
+
if (cwdAsked && !knownProject(cwdAsked)) return { status: 404, body: { error: "no such project" } };
|
|
1798
|
+
const here = sessionAsked ? idFrom(sessionAsked) : undefined;
|
|
1799
|
+
const cwd = cwdAsked || (here ? (leaseOf(here)?.cwd ?? conversationThere(here)?.cwd) : undefined) || undefined;
|
|
1800
|
+
const jobList = objectsIn(readJson(path.join(LOOPS_DIR, "jobs.json"), { jobs: [] }).jobs);
|
|
1801
|
+
const findings = inbox.list();
|
|
1802
|
+
const attentionNow = (inCwd: string | undefined) => attentionFrom({ findings, jobs: jobList, cwd: inCwd, inProject: sameProject, budget: opts.budget?.(), day: stamp().slice(0, 10) });
|
|
1803
|
+
// A conversation asked about that is in no project known: no project's items or counts, not the whole machine's.
|
|
1804
|
+
if (sessionAsked && !cwd) return { status: 200, body: { cwd: null, items: [], more: 0, failed: 0, attention: machineWide(attentionNow(undefined)) } };
|
|
1805
|
+
const ruleList = objectsIn(readJson(path.join(LOOPS_DIR, "triggers.json"), { rules: [] }).rules);
|
|
1806
|
+
const activity = activityFrom({
|
|
1807
|
+
runs: tailRecords(path.join(LOOPS_DIR, "runs.jsonl")),
|
|
1808
|
+
audit: tailRecords(path.join(LOOPS_DIR, "triggers-audit.jsonl")),
|
|
1809
|
+
findings,
|
|
1810
|
+
jobs: new Map(jobList.map((j) => [String(j.id), j])),
|
|
1811
|
+
rules: new Map(ruleList.map((r) => [String(r.id), { condition: String(r.condition ?? ""), createdBy: r.createdBy }])),
|
|
1812
|
+
cwd,
|
|
1813
|
+
inProject: sameProject,
|
|
1814
|
+
sessionId: here,
|
|
1815
|
+
sinceMs: Date.now() - ACTIVITY_WINDOW_MS,
|
|
1816
|
+
});
|
|
1817
|
+
// A loop's row names it as every other surface does (src/job-lines.ts loopLabel); a rule's names its condition.
|
|
1818
|
+
const items = activity.items.map((i) => (i.kind === "run" ? { ...i, source: { ...i.source, label: loopLabel(i.source) } } : i));
|
|
1819
|
+
return { status: 200, body: { cwd: cwd ?? null, ...activity, items, attention: attentionNow(cwd) } };
|
|
1820
|
+
}
|
|
1821
|
+
|
|
1822
|
+
/**
|
|
1823
|
+
* `GET /inbox`: one project's new findings as cards, decisions first, at most fifty (`more` counts
|
|
1824
|
+
* the rest); the claimed ones whose hand-off is still queued (`handled`); and how many are new in
|
|
1825
|
+
* other projects. `cwd` is the project asked for; without it, the project of `session`, else the
|
|
1826
|
+
* whole machine. A file read: it starts no pi.
|
|
1827
|
+
*/
|
|
1828
|
+
function inboxListing(cwdAsked: string | null, sessionAsked: string | null): { status: number; body: Record<string, unknown> } {
|
|
1829
|
+
// As `GET /conversations` takes it: a cwd is a key into the projects found, not a path to look up.
|
|
1830
|
+
if (cwdAsked && !knownProject(cwdAsked)) return { status: 404, body: { error: "no such project" } };
|
|
1831
|
+
const here = sessionAsked ? idFrom(sessionAsked) : undefined;
|
|
1832
|
+
const all = inbox.list();
|
|
1833
|
+
const fresh = all.filter((e) => e.status === "new");
|
|
1834
|
+
const cwd = cwdAsked || (here ? (leaseOf(here)?.cwd ?? conversationThere(here)?.cwd) : undefined) || undefined;
|
|
1835
|
+
const scoped = (entries: InboxEntry[]) => (cwd ? inProject(entries, cwd, sameProject) : entries);
|
|
1836
|
+
const queued = queuedClaims();
|
|
1837
|
+
const jobs = jobNames();
|
|
1838
|
+
const rules = new Map(objectsIn(readJson(path.join(LOOPS_DIR, "triggers.json"), { rules: [] }).rules).map((r) => [String(r.id), r.condition]));
|
|
1839
|
+
const ctx = { here, loopName: (id: string) => jobs.get(id)?.name, ruleCondition: (id: string) => (typeof rules.get(id) === "string" ? (rules.get(id) as string) : undefined), claiming: (id: string) => { const q = queued.get(id); return !!q && isPageClaim(q.row); } };
|
|
1840
|
+
const titles = new Map<string, string | undefined>();
|
|
1841
|
+
const cards = cardsOf(scoped(fresh), ctx);
|
|
1842
|
+
const transcripts = new Map(fresh.map((e) => [e.id, e.transcript]));
|
|
1843
|
+
const shown: PageCard[] = cards.slice(0, INBOX_CARDS_MAX).map((card) => {
|
|
1844
|
+
// See the run is offered only for a transcript `GET /inbox/run` would read.
|
|
1845
|
+
const named = transcripts.get(card.id);
|
|
1846
|
+
const c = card.hasTranscript && !(named && readableRun(named)) ? { ...card, hasTranscript: false } : card;
|
|
1847
|
+
const owner = ownerOf(c.sessionId, titles);
|
|
1848
|
+
if (!c.claiming) return { ...c, owner };
|
|
1849
|
+
// Claimed and still queued: where it waits is the conversation it was claimed into.
|
|
1850
|
+
const waits = c.sessionId === undefined ? ownerOf(queued.get(c.id)?.sessionId ?? "", titles) : owner;
|
|
1851
|
+
return { ...c, owner, line: claimLine(deliveryOf(waits), waits.title) };
|
|
1852
|
+
});
|
|
1853
|
+
const handled: PageCard[] = cardsOf(scoped(all.filter((e) => e.status === "claimed" && queued.has(e.id))), ctx)
|
|
1854
|
+
.slice(0, INBOX_CARDS_MAX)
|
|
1855
|
+
.map((c) => {
|
|
1856
|
+
const owner = ownerOf(queued.get(c.id)?.sessionId, titles);
|
|
1857
|
+
return { ...c, owner, line: claimLine(deliveryOf(owner), owner.title) };
|
|
1858
|
+
});
|
|
1859
|
+
// What happened in the project while nobody looked: the terminal's session-start line counts the same (src/seen.ts).
|
|
1860
|
+
// A run log that cannot be read costs the card, not the cards.
|
|
1861
|
+
let away: ReturnType<typeof awayIn> | null = null;
|
|
1862
|
+
try {
|
|
1863
|
+
away = cwd ? awayIn(LOOPS_DIR, cwd, here) : null;
|
|
1864
|
+
} catch (err: any) {
|
|
1865
|
+
opts.log(`pi-loops web: away summary: ${err?.message ?? err}`);
|
|
1866
|
+
}
|
|
1867
|
+
const offer = cwd && here ? startersFor(cwd, leaseOf(here)?.cwd ?? conversationThere(here)?.cwd) : undefined;
|
|
1868
|
+
return { status: 200, body: { cwd: cwd ?? null, cards: shown, more: cards.length - shown.length, handled, elsewhere: fresh.length - scoped(fresh).length, chips: DISMISS_CHIPS, away, ...(offer ? { starters: offer } : {}) } };
|
|
1869
|
+
}
|
|
1870
|
+
|
|
1871
|
+
/** The packaged starters, read once: they are files this package ships. */
|
|
1872
|
+
let starterList: Starter[] | undefined;
|
|
1873
|
+
/**
|
|
1874
|
+
* The starters `GET /inbox` offers: only for the open conversation's project (Add runs
|
|
1875
|
+
* `/recipe add` there, which installs into that conversation's cwd), and only while the project
|
|
1876
|
+
* has no loop and no rule, enabled or not. Conversations do not count.
|
|
1877
|
+
*/
|
|
1878
|
+
function startersFor(cwd: string, conversationCwd: string | undefined): Starter[] | undefined {
|
|
1879
|
+
if (!conversationCwd || !sameProject(cwd, conversationCwd)) return undefined;
|
|
1880
|
+
const inHere = (list: unknown) => objectsIn(list).some((x) => typeof x.cwd === "string" && sameProject(x.cwd, cwd));
|
|
1881
|
+
if (inHere(readJson(path.join(LOOPS_DIR, "jobs.json"), { jobs: [] }).jobs) || inHere(readJson(path.join(LOOPS_DIR, "triggers.json"), { rules: [] }).rules)) return undefined;
|
|
1882
|
+
if (!starterList) {
|
|
1883
|
+
try {
|
|
1884
|
+
starterList = starters(listRecipes());
|
|
1885
|
+
} catch (err: any) {
|
|
1886
|
+
opts.log(`pi-loops web: starters: ${err?.message ?? err}`);
|
|
1887
|
+
starterList = [];
|
|
1888
|
+
}
|
|
1889
|
+
}
|
|
1890
|
+
return starterList.length ? starterList : undefined;
|
|
1891
|
+
}
|
|
1892
|
+
|
|
1893
|
+
/**
|
|
1894
|
+
* `POST /inbox/seen`: Mark all seen. Moves the project's cursor in `seen.json` to `at` (the away
|
|
1895
|
+
* summary's asOf), forward only, and writes nothing else: no finding changes, no turn, no row.
|
|
1896
|
+
* The terminal's next session-start line reads the same cursor.
|
|
1897
|
+
*/
|
|
1898
|
+
function markSeenFromPage(b: Record<string, unknown>): { status: number; body: Record<string, unknown> } {
|
|
1899
|
+
const keys = Object.keys(b).sort();
|
|
1900
|
+
if (keys.length !== 2 || keys[0] !== "at" || keys[1] !== "cwd") return { status: 400, body: { error: "a mark is a cwd and an at, nothing else" } };
|
|
1901
|
+
if (typeof b.cwd !== "string" || !b.cwd || typeof b.at !== "string") return { status: 400, body: { error: "cwd and at are text" } };
|
|
1902
|
+
const at = Date.parse(b.at);
|
|
1903
|
+
if (!ISO_TIME.test(b.at) || !Number.isFinite(at)) return { status: 400, body: { error: "at is not an ISO time" } };
|
|
1904
|
+
if (at > Date.now() + SEEN_AHEAD_MS) return { status: 400, body: { error: "at is in the future" } };
|
|
1905
|
+
// As GET /inbox takes it: a cwd is a key into the projects found, not a path to look up.
|
|
1906
|
+
if (!knownProject(b.cwd)) return { status: 404, body: { error: "no such project" } };
|
|
1907
|
+
const since = markProjectSeen(LOOPS_DIR, b.cwd, b.at);
|
|
1908
|
+
opts.log(`pi-loops web: marked ${plainText(b.cwd)} seen at ${since}`);
|
|
1909
|
+
return { status: 200, body: { since } };
|
|
1910
|
+
}
|
|
1911
|
+
|
|
1912
|
+
/** What a claim queued for a conversation with `owner` is waiting on, before anything is rung. */
|
|
1913
|
+
// A project-owned finding's claim waits in the conversation it was claimed into, which the caller asks about instead.
|
|
1914
|
+
const deliveryOf = (owner: Owner): ClaimDelivery => (owner.holder === "none" ? "started" : owner.holder === "project" ? "gone" : owner.holder);
|
|
1915
|
+
|
|
1916
|
+
/** Conversations whose pi had no pi-loops when a claim was to be rung there (`deliverTo`). */
|
|
1917
|
+
const withoutPiLoops = new Set<string>();
|
|
1918
|
+
/** The rows each page pi was rung for, so the sweep does not ring one twice. A restarted pi is a new child and is rung again. */
|
|
1919
|
+
const rungFor = new WeakMap<PageChild, Set<string>>();
|
|
1920
|
+
function ringOnce(child: PageChild, row: SessionRequest) {
|
|
1921
|
+
const rung = rungFor.get(child) ?? new Set<string>();
|
|
1922
|
+
rungFor.set(child, rung);
|
|
1923
|
+
if (rung.has(row.id)) return;
|
|
1924
|
+
rung.add(row.id);
|
|
1925
|
+
ring(child, row.id, requestFingerprint(row)).catch((err) => opts.log(`pi-loops web: ring: ${err?.message ?? err}`));
|
|
1926
|
+
}
|
|
1927
|
+
|
|
1928
|
+
/**
|
|
1929
|
+
* Take the page's claims queued for `sessionId` one step on: ring the page pi that holds the
|
|
1930
|
+
* conversation, leave them to a terminal that holds it (its watcher drains them), wait for
|
|
1931
|
+
* pi-loops' scheduled turn there, or start a page pi when nobody holds it. Says which.
|
|
1932
|
+
*/
|
|
1933
|
+
async function deliverTo(sessionId: string, rows: SessionRequest[]): Promise<ClaimDelivery> {
|
|
1934
|
+
const lease = leaseOf(sessionId);
|
|
1935
|
+
const h = lease?.holder && holderAlive(lease.holder) ? lease.holder : undefined;
|
|
1936
|
+
const child = pool.get(sessionId);
|
|
1937
|
+
if (h?.kind === "terminal" && h.page !== true && child?.pid !== h.pid) return holderDelivers(h) ? "terminal" : "terminal-quiet";
|
|
1938
|
+
if (h?.kind === "host" && h.busy) return "background";
|
|
1939
|
+
// A conversation whose pi was found without pi-loops is not started again for its claims: nothing
|
|
1940
|
+
// there would drain the row, and each start would cost a pi for nothing. One open with pi-loops now
|
|
1941
|
+
// (opened from the list after a fix) is tried again.
|
|
1942
|
+
if (withoutPiLoops.has(sessionId) && child?.catalog.piLoopsLoaded !== true) return "no-pi-loops";
|
|
1943
|
+
const opened = child && h?.pid === child.pid ? { child } : await conversation(sessionId);
|
|
1944
|
+
if ("status" in opened) {
|
|
1945
|
+
if (opened.decision?.kind === "wait-background") return "background";
|
|
1946
|
+
if (opened.decision?.kind === "wait-terminal") return "terminal";
|
|
1947
|
+
if (opened.decision?.kind === "crash-looping") return "stopped";
|
|
1948
|
+
if (opened.status === 404) return "gone";
|
|
1949
|
+
return "full";
|
|
1950
|
+
}
|
|
1951
|
+
// As `/prompt` does: the command list first, since without pi-loops the ring would reach the model
|
|
1952
|
+
// as the person's message. A list that did not come is asked for again at the next sweep.
|
|
1953
|
+
if (opened.child.catalog.piLoopsLoaded === undefined) await opened.child.refreshCommands();
|
|
1954
|
+
if (opened.child.catalog.piLoopsLoaded === false) {
|
|
1955
|
+
withoutPiLoops.add(sessionId);
|
|
1956
|
+
return "no-pi-loops";
|
|
1957
|
+
}
|
|
1958
|
+
if (opened.child.catalog.piLoopsLoaded !== true) return child === opened.child ? "page" : "started";
|
|
1959
|
+
withoutPiLoops.delete(sessionId);
|
|
1960
|
+
for (const row of rows) ringOnce(opened.child, row);
|
|
1961
|
+
return child === opened.child ? "page" : "started";
|
|
1962
|
+
}
|
|
1963
|
+
|
|
1964
|
+
/**
|
|
1965
|
+
* The page's queued claims, finished from the files alone: a finding still new (pi-loops stopped
|
|
1966
|
+
* between the row and the status) is marked claimed; one dismissed meanwhile, or claimed into
|
|
1967
|
+
* another conversation or by a shell, takes its row back; the rest are delivered (`deliverTo`).
|
|
1968
|
+
* Run after each claim, at start and every ten seconds, one sweep at a time: asked for while one
|
|
1969
|
+
* runs, it answers undefined and one more full sweep follows; a tick of the timer then is skipped.
|
|
1970
|
+
* `only` limits it to one conversation.
|
|
1971
|
+
*/
|
|
1972
|
+
let sweepRunning: Promise<Map<string, ClaimDelivery>> | undefined;
|
|
1973
|
+
let sweepAgain = false;
|
|
1974
|
+
function deliverPageClaims(only?: string): Promise<Map<string, ClaimDelivery>> | undefined {
|
|
1975
|
+
if (sweepRunning) {
|
|
1976
|
+
sweepAgain = true;
|
|
1977
|
+
return undefined;
|
|
1978
|
+
}
|
|
1979
|
+
const run = sweepPageClaims(only).finally(() => {
|
|
1980
|
+
sweepRunning = undefined;
|
|
1981
|
+
if (!sweepAgain) return;
|
|
1982
|
+
sweepAgain = false;
|
|
1983
|
+
deliverPageClaims()?.catch((err) => opts.log(`pi-loops web: inbox: ${err?.message ?? err}`));
|
|
1984
|
+
});
|
|
1985
|
+
sweepRunning = run;
|
|
1986
|
+
return run;
|
|
1987
|
+
}
|
|
1988
|
+
async function sweepPageClaims(only?: string): Promise<Map<string, ClaimDelivery>> {
|
|
1989
|
+
const out = new Map<string, ClaimDelivery>();
|
|
1990
|
+
const sessions = only === undefined ? requests.sessions() : [only];
|
|
1991
|
+
let findings: Map<string, InboxEntry> | undefined;
|
|
1992
|
+
for (const sessionId of sessions) {
|
|
1993
|
+
let rows: SessionRequest[];
|
|
1994
|
+
try {
|
|
1995
|
+
rows = requests.list(sessionId).filter(isPageClaim);
|
|
1996
|
+
} catch {
|
|
1997
|
+
continue; // an id the store refuses has no rows
|
|
1998
|
+
}
|
|
1999
|
+
if (!rows.length) continue;
|
|
2000
|
+
findings ??= new Map(inbox.list().map((e) => [e.id, e]));
|
|
2001
|
+
const live: SessionRequest[] = [];
|
|
2002
|
+
for (const row of rows) {
|
|
2003
|
+
const id = row.source.slice("inbox:".length);
|
|
2004
|
+
const found = findings.get(id);
|
|
2005
|
+
const decided = found?.status === "new" ? await inbox.decide(id, "claimed", { by: sessionId }) : undefined;
|
|
2006
|
+
const now = decided === undefined ? found : "ok" in decided ? decided.ok : "already" in decided ? { status: decided.already, claimed_by: decided.by } : undefined;
|
|
2007
|
+
// Delivered only for a claim into this conversation: one dismissed meanwhile, or claimed by a
|
|
2008
|
+
// shell or into another conversation, would run its turn a second time. A finding no longer
|
|
2009
|
+
// in the file (rotated out once handled) keeps its row.
|
|
2010
|
+
if (now && !(now.status === "claimed" && now.claimed_by === sessionId)) {
|
|
2011
|
+
requests.removeIf(sessionId, row.id, (r) => r.handed_at === undefined);
|
|
2012
|
+
continue;
|
|
2013
|
+
}
|
|
2014
|
+
live.push(row);
|
|
2015
|
+
}
|
|
2016
|
+
if (live.length) out.set(sessionId, await deliverTo(sessionId, live));
|
|
2017
|
+
}
|
|
2018
|
+
return out;
|
|
2019
|
+
}
|
|
2020
|
+
|
|
2021
|
+
/** Who signs the page's claims: this process, and the row is born left (`pageClaimRow`). */
|
|
2022
|
+
const pageSigner = () => ({ pid: process.pid, instance: PAGE_INSTANCE, procStart: procStart(process.pid), version: VERSION });
|
|
2023
|
+
|
|
2024
|
+
/** An inbox action's body: a JSON object of at most `max` bytes, or the answer that refuses it. */
|
|
2025
|
+
async function inboxBody(req: http.IncomingMessage, max = INBOX_BODY_MAX): Promise<{ ok: Record<string, unknown> } | { status: number; error: string }> {
|
|
2026
|
+
let b: unknown;
|
|
2027
|
+
try {
|
|
2028
|
+
b = await body(req, max);
|
|
2029
|
+
} catch (err: any) {
|
|
2030
|
+
return err?.status === 413 ? { status: 413, error: "request too large" } : { status: 400, error: "the body is not JSON" };
|
|
2031
|
+
}
|
|
2032
|
+
if (!b || typeof b !== "object" || Array.isArray(b)) return { status: 400, error: "the body is not a JSON object" };
|
|
2033
|
+
return { ok: b as Record<string, unknown> };
|
|
2034
|
+
}
|
|
2035
|
+
|
|
2036
|
+
/** The finding a page action names by its whole id, or the answer that refuses it. */
|
|
2037
|
+
function findingNamed(id: unknown): { entry: InboxEntry } | { status: number; body: Record<string, unknown> } {
|
|
2038
|
+
if (typeof id !== "string" || !INBOX_ID.test(id)) return { status: 400, body: { error: "bad id" } };
|
|
2039
|
+
const entry = inbox.list().find((e) => e.id === id);
|
|
2040
|
+
if (!entry) return { status: 404, body: { error: "no such finding" } };
|
|
2041
|
+
if (entry.status !== "new") return { status: 409, body: { error: `already ${entry.status}`, status: entry.status } };
|
|
2042
|
+
return { entry };
|
|
2043
|
+
}
|
|
2044
|
+
|
|
2045
|
+
/** A loop route's body: exactly `keys`, each a string, or the answer that refuses it. Nothing is read from a refused body. */
|
|
2046
|
+
function exactly(b: Record<string, unknown>, keys: string[]): { status: number; body: Record<string, unknown> } | undefined {
|
|
2047
|
+
const extra = Object.keys(b).filter((k) => !keys.includes(k));
|
|
2048
|
+
if (extra.length) return { status: 400, body: { error: `unknown field ${JSON.stringify(extra[0].slice(0, 40))}` } };
|
|
2049
|
+
const missing = keys.find((k) => typeof b[k] !== "string");
|
|
2050
|
+
if (missing) return { status: 400, body: { error: `${missing} must be a string` } };
|
|
2051
|
+
return undefined;
|
|
2052
|
+
}
|
|
2053
|
+
|
|
2054
|
+
/**
|
|
2055
|
+
* `POST /loops/stop {runId}`: Stop this run on a loop row. The whole run id, matched; pi-loops
|
|
2056
|
+
* stops it (a held turn through its conversation's queue) and answers at once. A file read and a
|
|
2057
|
+
* call into this process: it never starts a conversation's pi.
|
|
2058
|
+
*/
|
|
2059
|
+
function stopLoopRun(b: Record<string, unknown>): { status: number; body: Record<string, unknown> } {
|
|
2060
|
+
const refused = exactly(b, ["runId"]);
|
|
2061
|
+
if (refused) return refused;
|
|
2062
|
+
const runId = b.runId as string;
|
|
2063
|
+
if (!RUN_ID.test(runId)) return { status: 400, body: { error: "bad run id" } };
|
|
2064
|
+
if (!opts.stopRun) return { status: 404, body: { error: "not found" } };
|
|
2065
|
+
const answer = opts.stopRun(runId);
|
|
2066
|
+
return { status: 200, body: "stopping" in answer ? { ok: true, stopping: answer.stopping } : { ok: false, finished: true } };
|
|
2067
|
+
}
|
|
2068
|
+
|
|
2069
|
+
/**
|
|
2070
|
+
* `POST /loops/tune {jobId, key, choice}`: a loop row's suggestion, applied or kept. The body names
|
|
2071
|
+
* the suggestion only by its key; the server computes it again from the store and refuses a key
|
|
2072
|
+
* it would not suggest now (409), so a page cannot ask for any other change. Both writes are one
|
|
2073
|
+
* patch under the store's lock, which re-checks the schedule the key was computed under.
|
|
2074
|
+
*/
|
|
2075
|
+
async function tuneLoop(b: Record<string, unknown>): Promise<{ status: number; body: Record<string, unknown> }> {
|
|
2076
|
+
const refused = exactly(b, ["jobId", "key", "choice"]);
|
|
2077
|
+
if (refused) return refused;
|
|
2078
|
+
const { jobId, key, choice } = b as { jobId: string; key: string; choice: string };
|
|
2079
|
+
if (!JOB_ID.test(jobId)) return { status: 400, body: { error: "bad job id" } };
|
|
2080
|
+
if (choice !== "apply" && choice !== "keep") return { status: 400, body: { error: "choice is apply or keep" } };
|
|
2081
|
+
if (key.length > TUNE_KEY_MAX) return { status: 400, body: { error: "bad key" } };
|
|
2082
|
+
try {
|
|
2083
|
+
return await tuneJob(jobId, key, choice);
|
|
2084
|
+
} catch (err: any) {
|
|
2085
|
+
// The store's own words stay in the log: they name paths on this machine.
|
|
2086
|
+
opts.log(`pi-loops web: tune ${jobId}: ${err?.stack ?? err?.message ?? err}`);
|
|
2087
|
+
return { status: 500, body: { error: "could not change the loop" } };
|
|
2088
|
+
}
|
|
2089
|
+
}
|
|
2090
|
+
|
|
2091
|
+
async function tuneJob(jobId: string, key: string, choice: "apply" | "keep"): Promise<{ status: number; body: Record<string, unknown> }> {
|
|
2092
|
+
const job = jobStore.load().find((j) => j.id === jobId);
|
|
2093
|
+
// Only a loop of a project the page lists, as the conversation list would show it.
|
|
2094
|
+
if (!job || typeof job.cwd !== "string" || !job.cwd || !knownProject(job.cwd)) return { status: 404, body: { error: "no such loop" } };
|
|
2095
|
+
const now = Date.now();
|
|
2096
|
+
const suggestion = suggestTune(job, signalOf(job.id, now), now);
|
|
2097
|
+
const changed = { status: 409, body: { error: "the suggestion changed" } };
|
|
2098
|
+
if (!suggestion || suggestion.key !== key) return changed;
|
|
2099
|
+
// Re-checked under the lock: the loop as the key was computed for it, and not kept meanwhile
|
|
2100
|
+
// (Keep in one tab and Apply in another: only the first lands).
|
|
2101
|
+
const sameSchedule = (j: LoopJob) => j.enabled && !j.running && j.tuneKept !== key && formatSchedule(j.schedule) === formatSchedule(job.schedule);
|
|
2102
|
+
if (choice === "keep") {
|
|
2103
|
+
let kept = false;
|
|
2104
|
+
await jobStore.update(job.id, (j) => {
|
|
2105
|
+
if (!sameSchedule(j)) return;
|
|
2106
|
+
j.tuneKept = key;
|
|
2107
|
+
kept = true;
|
|
2108
|
+
});
|
|
2109
|
+
return kept ? { status: 200, body: { ok: true, kept: true } } : changed;
|
|
2110
|
+
}
|
|
2111
|
+
const applied = applyTune(job, suggestion, { now, maxPromptBytes: MAX_PROMPT_BYTES, checkName: () => undefined, others: [] });
|
|
2112
|
+
let after: LoopJob | undefined;
|
|
2113
|
+
await jobStore.update(job.id, (j) => {
|
|
2114
|
+
if (!sameSchedule(j)) return;
|
|
2115
|
+
Object.assign(j, applied.patch);
|
|
2116
|
+
after = { ...j };
|
|
2117
|
+
});
|
|
2118
|
+
if (!after) return changed;
|
|
2119
|
+
const row = scheduleAudit(job, after, { sourceLabel: "page", suggestion: key });
|
|
2120
|
+
if (row) audit.appendAudit({ ...row, traceId: newTraceId() });
|
|
2121
|
+
// The time goes as a stamp: the page writes it in the browser's local time, as it does the rest of the row.
|
|
2122
|
+
if (applied.op === "disable") return { status: 200, body: { ok: true, op: applied.op, line: "paused" } };
|
|
2123
|
+
const next = applied.nextRun.kind === "at" ? { nextAt: stamp(applied.nextRun.at) } : applied.nextRun.kind === "due" ? { due: true } : {};
|
|
2124
|
+
return { status: 200, body: { ok: true, op: applied.op, line: `now ${formatSchedule(after.schedule)}`, schedule: formatSchedule(after.schedule), ...next } };
|
|
2125
|
+
}
|
|
2126
|
+
|
|
2127
|
+
/**
|
|
2128
|
+
* `POST /inbox/claim`: the row `/inbox claim` writes, for the conversation that created the loop
|
|
2129
|
+
* (else `from`, the page's own, when it is in the finding's project), born left so its holder runs
|
|
2130
|
+
* it. Written under the inbox lock once the finding is found new, then the finding marked claimed
|
|
2131
|
+
* (`decide`), as `handOver` orders them; a claim that loses writes nothing.
|
|
2132
|
+
*/
|
|
2133
|
+
async function claimFinding(b: Record<string, unknown>): Promise<{ status: number; body: Record<string, unknown> }> {
|
|
2134
|
+
const named = findingNamed(b.id);
|
|
2135
|
+
if (!("entry" in named)) return named;
|
|
2136
|
+
const entry = named.entry;
|
|
2137
|
+
const from = typeof b.from === "string" ? conversationThere(idFrom(b.from)) : undefined;
|
|
2138
|
+
const owner = conversationThere(entry.session_id);
|
|
2139
|
+
const target = owner ?? (from && from.cwd && (!entry.cwd || sameProject(entry.cwd, from.cwd)) ? from : undefined);
|
|
2140
|
+
if (!target) return { status: 409, body: { error: `${entry.session_id === undefined ? "this loop belongs to its project" : "the conversation that made this loop is gone"}; open a conversation in ${plainText(entry.cwd || "its project")} to claim it there` } };
|
|
2141
|
+
const row = pageClaimRow(claimPrompt(entry), entry.id, { sessionId: target.sessionId, cwd: entry.cwd || target.cwd || "", createdBy: pageSigner() });
|
|
2142
|
+
// Queued under the inbox lock once the finding is found still new: of claims racing one finding
|
|
2143
|
+
// (another page, a terminal, a shell, a held lock), only the winner queues a row.
|
|
2144
|
+
let written = false;
|
|
2145
|
+
const decided = await inbox.decide(entry.id, "claimed", { by: target.sessionId, before: () => void (written = requests.appendIfAbsent(row, { collapse: true }, CLAIM_QUEUE_LOCK)) });
|
|
2146
|
+
if (!("ok" in decided)) {
|
|
2147
|
+
return "missing" in decided ? { status: 404, body: { error: "no such finding" } } : { status: 409, body: { error: `already ${decided.already}`, status: decided.already } };
|
|
2148
|
+
}
|
|
2149
|
+
const titles = new Map<string, string | undefined>();
|
|
2150
|
+
const ownerNow = ownerOf(target.sessionId, titles);
|
|
2151
|
+
// Delivered now, unless a sweep is already running (it may be waiting on another conversation's
|
|
2152
|
+
// pi): then the next sweep delivers it, and the answer says where it will run.
|
|
2153
|
+
const swept = deliverPageClaims(target.sessionId);
|
|
2154
|
+
const delivery = (swept ? (await swept).get(target.sessionId) : undefined) ?? deliveryOf(ownerNow);
|
|
2155
|
+
audit.appendAudit({ cwd: entry.cwd, type: "request", traceId: row.id, state: "claimed", sourceLabel: row.source, eventLabel: target.sessionId, details: { session_id: target.sessionId, finding: entry.id, from: "page", delivery, ...(written ? {} : { collapsed: true }) } });
|
|
2156
|
+
return { status: 200, body: { ok: true, id: entry.id, delivery, conversation: target.sessionId, line: claimLine(delivery, ownerNow.title) } };
|
|
2157
|
+
}
|
|
2158
|
+
|
|
2159
|
+
/** `POST /inbox/dismiss`: a reason (a chip's words, or typed, at most 500 characters) goes to the loop's next run; none is a silent dismiss. */
|
|
2160
|
+
async function dismissFinding(b: Record<string, unknown>): Promise<{ status: number; body: Record<string, unknown> }> {
|
|
2161
|
+
if (b.reason !== undefined && b.reason !== null && typeof b.reason !== "string") return { status: 400, body: { error: "a reason is text" } };
|
|
2162
|
+
// Stored as it will be shown: no escape sequence or control character reaches a terminal from it.
|
|
2163
|
+
const reason = typeof b.reason === "string" ? screenText(b.reason).replace(/\s+/g, " ").trim() : "";
|
|
2164
|
+
if (Array.from(reason).length > INBOX_TEXT_MAX_CHARS) return { status: 400, body: { error: `reason is longer than ${INBOX_TEXT_MAX_CHARS} characters` } };
|
|
2165
|
+
const named = findingNamed(b.id);
|
|
2166
|
+
if (!("entry" in named)) return named;
|
|
2167
|
+
const decided = await inbox.decide(named.entry.id, "dismissed", { reason: reason || undefined });
|
|
2168
|
+
if ("missing" in decided) return { status: 404, body: { error: "no such finding" } };
|
|
2169
|
+
if ("already" in decided) return { status: 409, body: { error: `already ${decided.already}`, status: decided.already } };
|
|
2170
|
+
const entry = decided.ok;
|
|
2171
|
+
const told = dismissOutcome(entry, entry.job_id ? jobNames().get(entry.job_id) : undefined, reason);
|
|
2172
|
+
audit.appendAudit({ cwd: entry.cwd, type: "request", traceId: entry.id, state: "dismissed", sourceLabel: entry.source, eventLabel: entry.session_id, details: { finding: entry.id, from: "page", reason: reason ? redact(reason) : null, chip: (DISMISS_CHIPS as readonly string[]).includes(reason) } });
|
|
2173
|
+
// A claim the page queued for it before pi-loops stopped takes its row back now, not at the next sweep.
|
|
2174
|
+
deliverPageClaims()?.catch((err) => opts.log(`pi-loops web: inbox: ${err?.message ?? err}`));
|
|
2175
|
+
return { status: 200, body: { ok: true, id: entry.id, told, line: dismissLine(told) } };
|
|
2176
|
+
}
|
|
2177
|
+
|
|
2178
|
+
/**
|
|
2179
|
+
* `GET /inbox/run`: the transcript of the run that filed a finding, folded into lines and redacted
|
|
2180
|
+
* (src/transcript.ts), read-only. Only a file under the loops directory's `sessions/`, whatever
|
|
2181
|
+
* the line names: a hand-edited inbox must not read anything else.
|
|
2182
|
+
*/
|
|
2183
|
+
function runTranscript(id: unknown): { status: number; body: Record<string, unknown> } {
|
|
2184
|
+
if (typeof id !== "string" || !INBOX_ID.test(id)) return { status: 400, body: { error: "bad id" } };
|
|
2185
|
+
const entry = inbox.list().find((e) => e.id === id);
|
|
2186
|
+
if (!entry) return { status: 404, body: { error: "no such finding" } };
|
|
2187
|
+
if (entry.source.startsWith("trigger:")) return { status: 404, body: { error: "a rule check keeps no run transcript" } };
|
|
2188
|
+
const gone = { status: 404, body: { error: "the run's transcript is gone (pruned, or its loop was removed)" } };
|
|
2189
|
+
const named = entry.transcript ?? (entry.run_id ? new JobStore(LOOPS_DIR).allRuns().find((r) => r.runId === entry.run_id)?.sessionFile : undefined);
|
|
2190
|
+
if (!named) return gone;
|
|
2191
|
+
const file = readableRun(named);
|
|
2192
|
+
if (!file) return gone;
|
|
2193
|
+
return { status: 200, body: { id: entry.id, runId: entry.run_id, lines: summarizeSessionFile(file, { maxLines: 200 }) } };
|
|
2194
|
+
}
|
|
2195
|
+
|
|
2196
|
+
/** A run transcript's real path when it is a file under the loops directory's `sessions/`, else undefined. */
|
|
2197
|
+
function readableRun(named: string): string | undefined {
|
|
2198
|
+
try {
|
|
2199
|
+
const file = fs.realpathSync(named);
|
|
2200
|
+
return isUnder(file, path.join(LOOPS_DIR, "sessions")) && fs.statSync(file).isFile() ? file : undefined;
|
|
2201
|
+
} catch {
|
|
2202
|
+
return undefined; // not there any more, or gone between resolving it and looking at it
|
|
2203
|
+
}
|
|
2204
|
+
}
|
|
2205
|
+
|
|
2206
|
+
deliverPageClaims()?.catch((err) => opts.log(`pi-loops web: inbox: ${err?.message ?? err}`));
|
|
2207
|
+
// A tick while a sweep runs is skipped, not queued behind it.
|
|
2208
|
+
const claimSweep = setInterval(() => {
|
|
2209
|
+
if (!sweepRunning) deliverPageClaims()?.catch((err) => opts.log(`pi-loops web: inbox: ${err?.message ?? err}`));
|
|
2210
|
+
}, opts.claimSweepMs ?? PAGE_CLAIM_SWEEP_MS);
|
|
2211
|
+
claimSweep.unref();
|
|
2212
|
+
|
|
2213
|
+
/**
|
|
2214
|
+
* The script a plain page carries, under a nonce of its own: the device secret, when this answer is
|
|
2215
|
+
* the first visit that hands it over, and whatever else the page needs (`more`).
|
|
2216
|
+
*/
|
|
2217
|
+
function plainScript(secret: string | undefined, more = "") {
|
|
2218
|
+
if (!secret && !more) return undefined;
|
|
2219
|
+
const nonce = randomBytes(16).toString("hex");
|
|
2220
|
+
const keep = secret ? `const DEVICE_SECRET = "${secret}";\ntry { localStorage.setItem(${JSON.stringify(DEVICE_STORAGE_KEY)}, DEVICE_SECRET); } catch (e) {}\n` : "";
|
|
2221
|
+
return { csp: `script-src 'nonce-${nonce}'; connect-src 'self'`, html: `<script nonce="${nonce}">\n${keep}${more}</script>` };
|
|
2222
|
+
}
|
|
2223
|
+
|
|
2224
|
+
/** The plain pages `GET /c/<id>/` answers when there is no page to give yet. */
|
|
2225
|
+
function plainPage(res: http.ServerResponse, status: number, body: string, opts: { refresh?: boolean; secret?: string; script?: string } = {}) {
|
|
2226
|
+
const script = plainScript(opts.secret, opts.script);
|
|
2227
|
+
res.writeHead(status, {
|
|
2228
|
+
"content-type": "text/html; charset=utf-8",
|
|
2229
|
+
"cache-control": "no-store",
|
|
2230
|
+
"x-content-type-options": "nosniff",
|
|
2231
|
+
"referrer-policy": "no-referrer",
|
|
2232
|
+
"content-security-policy": `default-src 'none'; style-src 'unsafe-inline'; ${script ? `${script.csp}; ` : ""}form-action 'none'; base-uri 'none'; frame-ancestors 'none'`,
|
|
2233
|
+
"x-pi-loops-web": "1",
|
|
2234
|
+
});
|
|
2235
|
+
res.end(
|
|
2236
|
+
`<!doctype html><html><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1">${opts.refresh ? '<meta http-equiv="refresh" content="5">' : ""}<title>pi-loops</title><style>body{font:15px/1.5 system-ui,sans-serif;max-width:40rem;margin:2rem auto;padding:0 1rem}pre{white-space:pre-wrap;background:#8881;padding:.5rem}</style></head><body>${body}${script?.html ?? ""}</body></html>`,
|
|
2237
|
+
);
|
|
2238
|
+
}
|
|
2239
|
+
|
|
2240
|
+
/**
|
|
2241
|
+
* Stop it, on the waiting page. No cookie authenticates a post, so the button trades the device
|
|
2242
|
+
* cookie and the secret this origin keeps for the token, and posts with it.
|
|
2243
|
+
*/
|
|
2244
|
+
const stopScript = (sessionId: string) => {
|
|
2245
|
+
const base = `/c/${encodeURIComponent(sessionId)}/`;
|
|
2246
|
+
return `document.getElementById("stop").onclick = async () => {
|
|
2247
|
+
let secret = "";
|
|
2248
|
+
try { secret = localStorage.getItem(${JSON.stringify(DEVICE_STORAGE_KEY)}) || ""; } catch (e) {}
|
|
2249
|
+
const t = await fetch(${JSON.stringify(`${base}token`)}, { method: "POST", headers: { "x-pi-web-device-secret": secret } }).then((r) => r.json()).catch(() => ({}));
|
|
2250
|
+
if (t.token) await fetch(${JSON.stringify(`${base}stop-turn`)}, { method: "POST", headers: { "x-pi-web-token": t.token } }).catch(() => {});
|
|
2251
|
+
location.reload();
|
|
2252
|
+
};
|
|
2253
|
+
`;
|
|
2254
|
+
};
|
|
2255
|
+
|
|
2256
|
+
/**
|
|
2257
|
+
* The conversation `GET /` opens: the one the page opened last, while pi-loops can still open it,
|
|
2258
|
+
* else the open one somebody looked at most recently, else a new one in the last one's project when
|
|
2259
|
+
* that one was never saved (an empty conversation has no file to open). Undefined: the Background
|
|
2260
|
+
* view instead.
|
|
2261
|
+
*/
|
|
2262
|
+
function rootPage(): { sessionId: string } | { newIn: string } | undefined {
|
|
2263
|
+
const last = opts.lastPage?.();
|
|
2264
|
+
const lease = last === undefined ? undefined : opts.sessions.known(last);
|
|
2265
|
+
// Held by a live window, it is that window's to save: the open path says so (decideOpen).
|
|
2266
|
+
if (last !== undefined && (pool.get(last) || (lease?.sessionFile && (fs.existsSync(lease.sessionFile) || (lease.holder && holderAlive(lease.holder)))))) return { sessionId: last };
|
|
2267
|
+
const open = pool.views().sort((a, b) => b.lastClientAt - a.lastClientAt)[0]?.sessionId;
|
|
2268
|
+
if (open !== undefined) return { sessionId: open };
|
|
2269
|
+
// The cwd is the one pi-loops recorded in its own lease, never one from HTTP; a project with
|
|
2270
|
+
// nothing saved yet is in no list, so being a directory still is the check.
|
|
2271
|
+
const cwd = lease?.cwd;
|
|
2272
|
+
let isDirectory = false;
|
|
2273
|
+
try {
|
|
2274
|
+
isDirectory = !!cwd && fs.statSync(cwd).isDirectory();
|
|
2275
|
+
} catch {
|
|
2276
|
+
// the project directory is gone: nothing to start a conversation in
|
|
2277
|
+
}
|
|
2278
|
+
return cwd && isDirectory ? { newIn: cwd } : undefined;
|
|
2279
|
+
}
|
|
2280
|
+
|
|
2281
|
+
/**
|
|
2282
|
+
* The Background view: what pi-loops in the background is doing, as HTML or JSON. Read-only: every
|
|
2283
|
+
* action on it stays on the 0600 socket. Its error text can carry a path or a provider's message,
|
|
2284
|
+
* so that goes to the log and the page says only that it is unavailable.
|
|
2285
|
+
*/
|
|
2286
|
+
function background(res: http.ServerResponse, read: () => HostStatus, as: "html" | "json", secret?: string) {
|
|
2287
|
+
let status: HostStatus;
|
|
2288
|
+
try {
|
|
2289
|
+
status = read();
|
|
2290
|
+
} catch (err: any) {
|
|
2291
|
+
opts.log(`pi-loops page: background view: ${err?.message ?? err}`);
|
|
2292
|
+
return void res.writeHead(500, { "content-type": "text/plain; charset=utf-8", "cache-control": "no-store" }).end("status unavailable\n");
|
|
2293
|
+
}
|
|
2294
|
+
// The theme this browser chose on the page, before the first paint, as the page applies it.
|
|
2295
|
+
const script = as === "html" ? plainScript(secret, THEME_SCRIPT + CLOCK_SCRIPT) : undefined;
|
|
2296
|
+
const headers: Record<string, string> = {
|
|
2297
|
+
"cache-control": "no-store",
|
|
2298
|
+
"x-content-type-options": "nosniff",
|
|
2299
|
+
"referrer-policy": "no-referrer",
|
|
2300
|
+
"content-security-policy": `default-src 'none'; style-src 'unsafe-inline'; ${script ? `${script.csp}; ` : ""}base-uri 'none'; form-action 'none'; frame-ancestors 'none'`,
|
|
2301
|
+
"x-pi-loops-web": "1",
|
|
2302
|
+
};
|
|
2303
|
+
if (as === "json") return void res.writeHead(200, { ...headers, "content-type": "application/json; charset=utf-8" }).end(`${JSON.stringify(status, null, 2)}\n`);
|
|
2304
|
+
res.writeHead(200, { ...headers, "content-type": "text/html; charset=utf-8" }).end(script ? renderHostPage(status).replace("<style>", () => `${script.html}<style>`) : renderHostPage(status));
|
|
2305
|
+
}
|
|
2306
|
+
|
|
2307
|
+
/**
|
|
2308
|
+
* `GET /` and `GET /c/<id>/`: the page of one conversation. The door, the open key and the pairing
|
|
2309
|
+
* code come first, as they always have; then which conversation, and whether its pi can be had.
|
|
2310
|
+
*
|
|
2311
|
+
* Two kinds of load get a page. A first visit (the open key, the pairing code, or the token
|
|
2312
|
+
* itself) gets the token and a device secret in its HTML, and the device cookie. A browser with a
|
|
2313
|
+
* known device cookie gets the page with neither in it: the page trades the cookie and the secret
|
|
2314
|
+
* it kept for the token at `/token`, so whoever else receives the cookie fetches HTML and no more.
|
|
2315
|
+
*/
|
|
2316
|
+
async function servePage(res: http.ServerResponse, url: URL, facts: RequestFacts, pathId: string | null | undefined) {
|
|
2317
|
+
// The second lock still applies: a page on another site cannot be allowed to fetch this
|
|
2318
|
+
// one just because it guessed the key, and only a browser on this machine ever has it.
|
|
2319
|
+
// Both of these hand out a device without a token, so both owe the same locks the token
|
|
2320
|
+
// routes have. Without the cross-site one, a page on any site could point an iframe at
|
|
2321
|
+
// this URL twenty times and burn the pairing code the phone was waiting for.
|
|
2322
|
+
const trusted = !placeRefused(facts, CHECK);
|
|
2323
|
+
// Asked first, because a browser that is already signed in must not pay for a pairing guess:
|
|
2324
|
+
// `spendPairCode` spends one of twenty tries, so a signed-in tab reloading a bookmark that
|
|
2325
|
+
// still carries an old `?pair=` used to burn a try. The open key is the other way round:
|
|
2326
|
+
// whichever load presents it first spends it, signed in or not, so nobody else has a minute to
|
|
2327
|
+
// trade it in. It may also arrive as a cross-site navigation, from the launch file (topLevelNavigation).
|
|
2328
|
+
const verdict = checkRequest(facts, CHECK);
|
|
2329
|
+
const tokened = "ok" in verdict;
|
|
2330
|
+
const known = trusted && devices.known(facts.device);
|
|
2331
|
+
let keyed: string | undefined;
|
|
2332
|
+
let paired = false;
|
|
2333
|
+
if (trusted || (hostAllowed(facts, CHECK) && topLevelNavigation(facts))) {
|
|
2334
|
+
keyed = spendOpenKey(url.searchParams.get("open"));
|
|
2335
|
+
// A key opens its own conversation and no other.
|
|
2336
|
+
if (keyed !== undefined && pathId !== undefined && pathId !== keyed) keyed = undefined;
|
|
2337
|
+
}
|
|
2338
|
+
if (trusted && keyed === undefined && !tokened && !known) paired = spendPairCode(url.searchParams.get("pair"));
|
|
2339
|
+
// The token in the header, on a device this server knows, is a page asking with what it already
|
|
2340
|
+
// holds (a fetch that followed a redirect, a tab with the token in memory): a new secret here is
|
|
2341
|
+
// one that page never learns, and its next reload meets the door. `?token=` is a person opening
|
|
2342
|
+
// the address pi-loops printed, on a browser that may have lost this origin's secret: that is
|
|
2343
|
+
// a first visit, and it gets a secret.
|
|
2344
|
+
const first = keyed !== undefined || paired || (tokened && !(known && verdict.via === "header"));
|
|
2345
|
+
if (!first && !known) return void res.writeHead(403, { "content-type": "text/html; charset=utf-8", "cache-control": "no-store", "x-pi-loops-web": "1" }).end(DOOR);
|
|
2346
|
+
// The key and the code are spent now, whatever answer follows: the waiting page reloads itself
|
|
2347
|
+
// and the crash page has a link, and either arrives next with nothing but the device cookie.
|
|
2348
|
+
let secret: string | undefined;
|
|
2349
|
+
if (first) {
|
|
2350
|
+
const device = devices.enroll(facts.device);
|
|
2351
|
+
secret = device.secret;
|
|
2352
|
+
res.appendHeader("set-cookie", deviceCookie(device.id));
|
|
2353
|
+
}
|
|
2354
|
+
if (pathId === null) return void json(res, NO_SUCH, 404);
|
|
2355
|
+
|
|
2356
|
+
let sessionId = pathId ?? keyed;
|
|
2357
|
+
if (sessionId === undefined) {
|
|
2358
|
+
const root = rootPage();
|
|
2359
|
+
if (root && "newIn" in root) {
|
|
2360
|
+
const fresh = await startConversation(pool, root.newIn, argsFor(undefined, root.newIn)).catch((err: any) => ({ error: String(err?.message ?? err) }));
|
|
2361
|
+
if ("error" in fresh) return void plainPage(res, 503, `<p>${escapeHtml(fresh.error)}.</p><p><a href="/">Back</a></p>`, { secret });
|
|
2362
|
+
sessionId = fresh.sessionId;
|
|
2363
|
+
} else sessionId = root?.sessionId;
|
|
2364
|
+
}
|
|
2365
|
+
if (sessionId === undefined) {
|
|
2366
|
+
if (opts.background) return void background(res, opts.background, "html", secret);
|
|
2367
|
+
return void plainPage(res, 404, "<p>No conversation is open in the page yet.</p>", { secret });
|
|
2368
|
+
}
|
|
2369
|
+
if (url.searchParams.get("again") === "1") pool.clearCrashes(sessionId);
|
|
2370
|
+
const opened = await conversation(sessionId);
|
|
2371
|
+
if ("status" in opened) {
|
|
2372
|
+
const d = opened.decision;
|
|
2373
|
+
if (d?.kind === "wait-background") {
|
|
2374
|
+
// A delivery is a write, not a run: it has nothing to stop and ends on its own.
|
|
2375
|
+
if (d.label === DELIVERY_LABEL) return void plainPage(res, 200, `<p>${escapeHtml(waitingLine(d.label))}</p>`, { refresh: true, secret });
|
|
2376
|
+
return void plainPage(res, 200, `<p>${escapeHtml(waitingLine(d.label))}</p><p><button id="stop">Stop it</button></p>`, { refresh: true, secret, script: stopScript(sessionId) });
|
|
2377
|
+
}
|
|
2378
|
+
if (d?.kind === "wait-terminal") return void plainPage(res, 200, `<p>${escapeHtml(unsavedLine)}.</p>`, { refresh: true, secret });
|
|
2379
|
+
if (d?.kind === "crash-looping") {
|
|
2380
|
+
const words = pool.lastWords(sessionId);
|
|
2381
|
+
return void plainPage(res, 503, `<p>${escapeHtml(crashLine)}.</p>${words ? `<pre>${escapeHtml(words)}</pre>` : ""}<p><a href="/c/${encodeURIComponent(sessionId)}/?again=1">Start again</a></p>`, { secret });
|
|
2382
|
+
}
|
|
2383
|
+
// pi could not be started: every one of the page's six is busy, say. A person reads this.
|
|
2384
|
+
if (opened.status === 503) return void plainPage(res, 503, `<p>${escapeHtml(opened.body.error)}.</p><p><a href="/">Back</a></p>`, { secret });
|
|
2385
|
+
return void json(res, opened.body, opened.status);
|
|
2386
|
+
}
|
|
2387
|
+
pool.touch(sessionId);
|
|
2388
|
+
opts.rememberPage?.(sessionId);
|
|
2389
|
+
res.writeHead(200, {
|
|
2390
|
+
"content-type": "text/html; charset=utf-8",
|
|
2391
|
+
// The URL can carry the token, so no other site should ever be told it — and a first visit's
|
|
2392
|
+
// page carries it, so it must not sit in the browser's disk cache either.
|
|
2393
|
+
"referrer-policy": "no-referrer",
|
|
2394
|
+
"cache-control": "no-store",
|
|
2395
|
+
"x-content-type-options": "nosniff",
|
|
2396
|
+
/*
|
|
2397
|
+
* The page carries the token in a variable, and the Markdown renderer turns model
|
|
2398
|
+
* output into DOM. Those two facts are fine today and the review found nothing to get
|
|
2399
|
+
* through the renderer — but a mistake there tomorrow would be a mistake that can
|
|
2400
|
+
* exfiltrate a credential which never expires. This says: no origin but this one, for
|
|
2401
|
+
* anything. Inline script and style are what this page is made of; the value is in
|
|
2402
|
+
* connect-src and frame-ancestors, which is where a stolen token would have to go and
|
|
2403
|
+
* how a hostile page would have to reach in.
|
|
2404
|
+
*/
|
|
2405
|
+
"content-security-policy":
|
|
2406
|
+
"default-src 'none'; connect-src 'self'; img-src 'self' data: blob:; script-src 'unsafe-inline'; style-src 'unsafe-inline'; form-action 'self'; base-uri 'none'; frame-ancestors 'none'",
|
|
2407
|
+
// How a second launch on the same port knows the thing already there is one of these.
|
|
2408
|
+
"x-pi-loops-web": "1",
|
|
2409
|
+
});
|
|
2410
|
+
const id = sessionId;
|
|
2411
|
+
res.end(
|
|
2412
|
+
PAGE.replace("__TOKEN__", () => (first ? TOKEN : ""))
|
|
2413
|
+
.replace("__DEVICE_SECRET__", () => secret ?? "")
|
|
2414
|
+
.replace("__VERSION__", () => VERSION)
|
|
2415
|
+
.replace("__SESSION__", () => id),
|
|
2416
|
+
);
|
|
2417
|
+
}
|
|
2418
|
+
|
|
2419
|
+
const server = http.createServer(
|
|
2420
|
+
guard(opts.log, "http", async (req: http.IncomingMessage, res: http.ServerResponse) => {
|
|
2421
|
+
// Inside the try: a request line that is not a URL (`GET //[`) threw here, outside it, and the
|
|
2422
|
+
// rejection nobody handled ended the front end.
|
|
2423
|
+
let url;
|
|
2424
|
+
try {
|
|
2425
|
+
url = new URL(req.url as string, "http://127.0.0.1");
|
|
2426
|
+
} catch {
|
|
2427
|
+
res.writeHead(400).end();
|
|
2428
|
+
return;
|
|
2429
|
+
}
|
|
2430
|
+
try {
|
|
2431
|
+
const facts = requestFacts(req, url);
|
|
2432
|
+
// 0.22.8 kept its token in a cookie that goes to every port on this host. The first 0.24
|
|
2433
|
+
// start replaced that token, so the cookie opens nothing; whatever this answers, the browser
|
|
2434
|
+
// forgets it.
|
|
2435
|
+
if (facts.legacyCookie) res.appendHeader("set-cookie", LEGACY_TOKEN_CLEAR);
|
|
2436
|
+
// Every conversation's routes are under `/c/<sessionId>/`; null for a prefix that names no
|
|
2437
|
+
// usable id, which nothing below answers but 404.
|
|
2438
|
+
const prefixed = /^\/c\/([^/]*)(\/.*)?$/.exec(url.pathname);
|
|
2439
|
+
const pathId = prefixed ? (idFrom(prefixed[1]) ?? null) : undefined;
|
|
2440
|
+
const route = prefixed ? (prefixed[2] ?? "/") : url.pathname;
|
|
2441
|
+
if (req.method === "GET" && route === "/") return void (await servePage(res, url, facts, pathId));
|
|
2442
|
+
/**
|
|
2443
|
+
* Installability, before the token check on purpose. A browser fetches the manifest and the
|
|
2444
|
+
* icon without credentials — they would 403 behind it, and the page would simply not be
|
|
2445
|
+
* installable — and neither one says anything a stranger does not already know from the
|
|
2446
|
+
* door page. There is no service worker: nothing here is worth caching, and a stale copy of
|
|
2447
|
+
* a front end whose only job is to be live is worse than no copy at all. What this buys is
|
|
2448
|
+
* the part that matters on a phone: an icon on the home screen and a window with no browser
|
|
2449
|
+
* chrome eating a fifth of the screen.
|
|
2450
|
+
*/
|
|
2451
|
+
// Behind the same "did another site start this" lock as everything else. A browser fetching a
|
|
2452
|
+
// manifest or an icon for this page says same-origin; an <img> on someone else's page probing
|
|
2453
|
+
// a port range says cross-site, and would otherwise learn from the load that this is here.
|
|
2454
|
+
if ((url.pathname === "/manifest.webmanifest" || url.pathname === "/icon.svg") && placeRefused(facts, CHECK)) {
|
|
2455
|
+
return void res.writeHead(403).end();
|
|
2456
|
+
}
|
|
2457
|
+
if (url.pathname === "/manifest.webmanifest") {
|
|
2458
|
+
res.writeHead(200, { "content-type": "application/manifest+json; charset=utf-8" });
|
|
2459
|
+
return void res.end(
|
|
2460
|
+
JSON.stringify({
|
|
2461
|
+
name: "pi-loops",
|
|
2462
|
+
short_name: "pi-loops",
|
|
2463
|
+
start_url: "/",
|
|
2464
|
+
scope: "/",
|
|
2465
|
+
display: "standalone",
|
|
2466
|
+
background_color: "#000000",
|
|
2467
|
+
theme_color: "#000000",
|
|
2468
|
+
icons: [{ src: "/icon.svg", sizes: "any", type: "image/svg+xml", purpose: "any maskable" }],
|
|
2469
|
+
}),
|
|
2470
|
+
);
|
|
2471
|
+
}
|
|
2472
|
+
if (url.pathname === "/icon.svg") {
|
|
2473
|
+
res.writeHead(200, { "content-type": "image/svg+xml; charset=utf-8", "cache-control": "max-age=86400" });
|
|
2474
|
+
return void res.end(ICON);
|
|
2475
|
+
}
|
|
2476
|
+
/*
|
|
2477
|
+
* The device cookie and the secret this origin keeps, for the token: how a page served
|
|
2478
|
+
* without the token gets it. The secret rides in a header no other site can set without a
|
|
2479
|
+
* preflight this server never answers, and only the page's own origin can read it.
|
|
2480
|
+
*/
|
|
2481
|
+
if (prefixed && route === "/token" && req.method === "POST") {
|
|
2482
|
+
const refused = placeRefused(facts, CHECK);
|
|
2483
|
+
if (refused) return void json(res, { error: refused.why }, refused.status);
|
|
2484
|
+
if (pathId === null) return void json(res, NO_SUCH, 404);
|
|
2485
|
+
const secret = req.headers["x-pi-web-device-secret"];
|
|
2486
|
+
const traded = devices.trade(facts.device, typeof secret === "string" ? secret : undefined);
|
|
2487
|
+
if ("ok" in traded) return void json(res, { token: TOKEN });
|
|
2488
|
+
// This browser's cookie is no good any more: clear it, so its next load is the door.
|
|
2489
|
+
res.appendHeader("set-cookie", `${DEVICE_COOKIE}=; Path=/; Max-Age=0; HttpOnly; SameSite=Strict`);
|
|
2490
|
+
return void json(res, { error: traded.why }, traded.status);
|
|
2491
|
+
}
|
|
2492
|
+
/*
|
|
2493
|
+
* What a browser fetches by address and cannot add a header to: the event stream, a file,
|
|
2494
|
+
* a preview. Each takes a key minted for it at /key, and nothing else: no cookie, no
|
|
2495
|
+
* ?token=, which would sit in a URL that a preview can read.
|
|
2496
|
+
*/
|
|
2497
|
+
const keyed: KeyKind | undefined =
|
|
2498
|
+
req.method !== "GET" ? undefined : prefixed && route === "/events" ? "events" : prefixed && route === "/file" ? "file" : !prefixed && url.pathname.startsWith("/preview/") ? "preview" : undefined;
|
|
2499
|
+
if (keyed) {
|
|
2500
|
+
const refused = placeRefused(facts, CHECK);
|
|
2501
|
+
if (refused) return void json(res, { error: refused.why }, refused.status);
|
|
2502
|
+
const key = url.searchParams.get("key");
|
|
2503
|
+
if (!key) return void json(res, { error: "no key" }, 401);
|
|
2504
|
+
const ask = keyed === "preview" ? { kind: keyed, path: url.pathname.slice("/preview/".length) } : { kind: keyed, sessionId: pathId ?? undefined, path: keyed === "file" ? (url.searchParams.get("path") ?? "") : undefined };
|
|
2505
|
+
if (!urlKeys.spend(key, ask)) return void json(res, { error: "bad key" }, 403);
|
|
2506
|
+
}
|
|
2507
|
+
// The Background view is read-only and has no token in it: a browser that paired may look.
|
|
2508
|
+
const view = !prefixed && req.method === "GET" && (url.pathname === "/background" || url.pathname === "/background.json");
|
|
2509
|
+
const verdict = keyed || (view && !placeRefused(facts, CHECK) && devices.known(facts.device)) ? undefined : checkRequest(facts, CHECK);
|
|
2510
|
+
if (verdict && !("ok" in verdict)) return void json(res, { error: verdict.why }, verdict.status);
|
|
2511
|
+
// Before any body is read: a form post from another site can only send a few content types,
|
|
2512
|
+
// and this one is not among them.
|
|
2513
|
+
// Stop it, on the waiting page, posts nothing: it has no body and so no content type.
|
|
2514
|
+
if (req.method === "POST" && route !== "/stop-turn" && !/^application\/json\s*(;|$)/i.test(String(req.headers["content-type"] ?? "")))
|
|
2515
|
+
return void json(res, { error: "a post must be application/json" }, 415);
|
|
2516
|
+
|
|
2517
|
+
if (route === "/preview" && req.method === "POST") {
|
|
2518
|
+
// HTML the model wrote into the conversation rather than into a file. Held in memory,
|
|
2519
|
+
// briefly, and served under the same sandbox as anything else here.
|
|
2520
|
+
const { html } = await body(req);
|
|
2521
|
+
const text = String(html ?? "");
|
|
2522
|
+
if (text.length > PREVIEW_MAX_BYTES) return void json(res, { success: false, error: "too big to preview" }, 413);
|
|
2523
|
+
const id = randomBytes(9).toString("hex");
|
|
2524
|
+
previews.set(id, { html: text, at: Date.now() });
|
|
2525
|
+
// Two rules, said separately, because one of them used to be written as a condition inside a
|
|
2526
|
+
// loop over the Map and depended on it shrinking as it was walked.
|
|
2527
|
+
for (const [key, v] of previews) if (Date.now() - v.at > PREVIEW_TTL_MS) previews.delete(key);
|
|
2528
|
+
while (previews.size > PREVIEW_KEEP) previews.delete(previews.keys().next().value as string);
|
|
2529
|
+
return void json(res, { success: true, id });
|
|
2530
|
+
}
|
|
2531
|
+
if (url.pathname.startsWith("/preview/") && req.method === "GET") {
|
|
2532
|
+
const found = previews.get(url.pathname.slice("/preview/".length));
|
|
2533
|
+
if (!found) return void res.writeHead(404).end("that preview has expired");
|
|
2534
|
+
res.writeHead(200, {
|
|
2535
|
+
"content-type": "text/html; charset=utf-8",
|
|
2536
|
+
"x-content-type-options": "nosniff",
|
|
2537
|
+
"cache-control": "no-store",
|
|
2538
|
+
"content-security-policy": "sandbox allow-scripts",
|
|
2539
|
+
"referrer-policy": "no-referrer",
|
|
2540
|
+
});
|
|
2541
|
+
return void res.end(found.html);
|
|
2542
|
+
}
|
|
2543
|
+
if (route === "/pair" && req.method === "POST") {
|
|
2544
|
+
// Behind the token, so this is a browser that is already in asking for a code for the next
|
|
2545
|
+
// device. It replaces any outstanding one and resets the guess budget with it.
|
|
2546
|
+
if ((await body(req)).cancel) {
|
|
2547
|
+
// The dialog was closed. A code left armed and forgotten is the thing to avoid.
|
|
2548
|
+
clearPairCode();
|
|
2549
|
+
return void json(res, { success: true });
|
|
2550
|
+
}
|
|
2551
|
+
// Which addresses that device could actually use. The page knows the one it reached this
|
|
2552
|
+
// server on — which is the right answer under `tailscale serve` — and this adds the ones
|
|
2553
|
+
// only the machine can see. A loopback-only server has none, and the page says so.
|
|
2554
|
+
const port = (server.address() as AddressInfo | null)?.port ?? PORT;
|
|
2555
|
+
const addresses = LOOPBACK ? [] : lanAddresses(port);
|
|
2556
|
+
return void json(res, { success: true, code: newPairCode(), expiresIn: Math.round(PAIR_TTL_MS / 1000), addresses });
|
|
2557
|
+
}
|
|
2558
|
+
if (route === "/stop-turn" && req.method === "POST") {
|
|
2559
|
+
if (!pathId) return void json(res, NO_SUCH, 404);
|
|
2560
|
+
// The turn pi-loops runs here, or, before it says which, the run its lease names: it takes
|
|
2561
|
+
// the lease, then claims the job. A delivery has no run to stop and ends on its own.
|
|
2562
|
+
const held = opts.heldTurn?.(pathId);
|
|
2563
|
+
const holder = held ? undefined : opts.sessions.known(pathId)?.holder;
|
|
2564
|
+
const runId = held?.runId ?? (holder?.kind === "host" && holder.busy && holder.busy.jobId !== DELIVERY_JOB_ID ? holder.busy.runId : undefined);
|
|
2565
|
+
// The same stop as a loop row's: a held turn through its queue, as the page's, with a row
|
|
2566
|
+
// and an audit line; a run the lease names before pi-loops says which turn it is, aborted.
|
|
2567
|
+
if (runId) {
|
|
2568
|
+
if (opts.stopRun) opts.stopRun(runId);
|
|
2569
|
+
else opts.abortRun?.(runId);
|
|
2570
|
+
}
|
|
2571
|
+
// No redirect: the button's fetch would follow it with the token, and the page reloads itself.
|
|
2572
|
+
return void res.writeHead(204, { "cache-control": "no-store" }).end();
|
|
2573
|
+
}
|
|
2574
|
+
if (view) {
|
|
2575
|
+
if (!opts.background) return void json(res, { error: "not found" }, 404);
|
|
2576
|
+
return void background(res, opts.background, url.pathname === "/background" ? "html" : "json");
|
|
2577
|
+
}
|
|
2578
|
+
// The conversation list: a file read behind the token. No device-cookie exemption, as the
|
|
2579
|
+
// Background view has: its titles are what people typed.
|
|
2580
|
+
if (!prefixed && url.pathname === "/conversations" && req.method === "GET") {
|
|
2581
|
+
const listed = conversationList(url.searchParams.get("cwd"), url.searchParams.get("session"));
|
|
2582
|
+
return void json(res, listed.body, listed.status);
|
|
2583
|
+
}
|
|
2584
|
+
if (!prefixed && url.pathname === "/conversations/new" && req.method === "POST") {
|
|
2585
|
+
const b = await inboxBody(req);
|
|
2586
|
+
if (!("ok" in b)) return void json(res, { error: b.error }, b.status);
|
|
2587
|
+
const done = await newConversation(b.ok);
|
|
2588
|
+
return void json(res, done.body, done.status);
|
|
2589
|
+
}
|
|
2590
|
+
// The inbox as cards: global, like the list, since a card's conversation is seldom this page's,
|
|
2591
|
+
// and reading or acting on one must not start this page's pi.
|
|
2592
|
+
if (!prefixed && url.pathname === "/inbox" && req.method === "GET") {
|
|
2593
|
+
const listed = inboxListing(url.searchParams.get("cwd"), url.searchParams.get("session"));
|
|
2594
|
+
return void json(res, listed.body, listed.status);
|
|
2595
|
+
}
|
|
2596
|
+
// The activity strip and the attention counts: a file read behind the token, like the inbox.
|
|
2597
|
+
if (!prefixed && url.pathname === "/activity" && req.method === "GET") {
|
|
2598
|
+
const listed = activityListing(url.searchParams.get("cwd"), url.searchParams.get("session"));
|
|
2599
|
+
return void json(res, listed.body, listed.status);
|
|
2600
|
+
}
|
|
2601
|
+
if (!prefixed && url.pathname === "/inbox/run" && req.method === "GET") {
|
|
2602
|
+
const read = runTranscript(url.searchParams.get("id"));
|
|
2603
|
+
return void json(res, read.body, read.status);
|
|
2604
|
+
}
|
|
2605
|
+
// A loop row's Stop this run and its suggestion: behind the token like the inbox's actions,
|
|
2606
|
+
// and like them they never start this page's pi.
|
|
2607
|
+
if (!prefixed && (url.pathname === "/loops/stop" || url.pathname === "/loops/tune") && req.method === "POST") {
|
|
2608
|
+
const b = await inboxBody(req, LOOPS_BODY_MAX);
|
|
2609
|
+
if (!("ok" in b)) return void json(res, { error: b.error }, b.status);
|
|
2610
|
+
const done = url.pathname === "/loops/stop" ? stopLoopRun(b.ok) : await tuneLoop(b.ok);
|
|
2611
|
+
return void json(res, done.body, done.status);
|
|
2612
|
+
}
|
|
2613
|
+
if (!prefixed && url.pathname === "/inbox/seen" && req.method === "POST") {
|
|
2614
|
+
const b = await inboxBody(req, SEEN_BODY_MAX);
|
|
2615
|
+
if (!("ok" in b)) return void json(res, { error: b.error }, b.status);
|
|
2616
|
+
const done = markSeenFromPage(b.ok);
|
|
2617
|
+
return void json(res, done.body, done.status);
|
|
2618
|
+
}
|
|
2619
|
+
if (!prefixed && (url.pathname === "/inbox/claim" || url.pathname === "/inbox/dismiss") && req.method === "POST") {
|
|
2620
|
+
const b = await inboxBody(req);
|
|
2621
|
+
if (!("ok" in b)) return void json(res, { error: b.error }, b.status);
|
|
2622
|
+
const done = url.pathname === "/inbox/claim" ? await claimFinding(b.ok) : await dismissFinding(b.ok);
|
|
2623
|
+
return void json(res, done.body, done.status);
|
|
2624
|
+
}
|
|
2625
|
+
if (pathId == null) return void json(res, prefixed ? NO_SUCH : { error: "not found" }, 404);
|
|
2626
|
+
if (route === "/key" && req.method === "POST") {
|
|
2627
|
+
// Behind the token, like every post: a key is a way to reach one address without it.
|
|
2628
|
+
const b = await body(req);
|
|
2629
|
+
const kind = b?.for;
|
|
2630
|
+
if (!KEY_KINDS.includes(kind)) return void json(res, { error: "a key is for events, a file or a preview" }, 400);
|
|
2631
|
+
if (kind !== "events" && (typeof b.path !== "string" || !b.path)) return void json(res, { error: `a ${kind} key needs its path` }, 400);
|
|
2632
|
+
return void json(res, { key: urlKeys.mint(pathId, kind, kind === "events" ? undefined : b.path) });
|
|
2633
|
+
}
|
|
2634
|
+
const opened = await conversation(pathId);
|
|
2635
|
+
if ("status" in opened) return void json(res, opened.body, opened.status);
|
|
2636
|
+
const child = opened.child;
|
|
2637
|
+
const touch = () => {
|
|
2638
|
+
const key = pool.keyOf(child);
|
|
2639
|
+
if (key !== undefined) pool.touch(key);
|
|
2640
|
+
};
|
|
2641
|
+
touch();
|
|
2642
|
+
if (route === "/events" && req.method === "GET") {
|
|
2643
|
+
// One person, a few tabs; a few conversations. An unbounded set is a way to run this
|
|
2644
|
+
// process out of memory.
|
|
2645
|
+
if (child.clients.size >= PAGE_STREAMS_PER_CHILD || clients() >= PAGE_STREAMS_TOTAL) return void json(res, { error: "too many event streams open" }, 429);
|
|
2646
|
+
res.on("error", guard(opts.log, "stream error", () => child.clients.delete(res)));
|
|
2647
|
+
res.writeHead(200, { "content-type": "text/event-stream", "cache-control": "no-cache", connection: "keep-alive" });
|
|
2648
|
+
res.write(": connected\n\n");
|
|
2649
|
+
// A browser opened mid-session still needs what already happened. A dialog is replayed only
|
|
2650
|
+
// while pi still waits for its answer, with the time it has left: one pi already answered by its
|
|
2651
|
+
// timeout would come back with a full countdown, and an approve would go nowhere.
|
|
2652
|
+
for (const e of child.backlog) {
|
|
2653
|
+
const ask = isDialog(e) ? child.live.pendingAsks.get(e.id) : undefined;
|
|
2654
|
+
if (isDialog(e) && !ask) continue;
|
|
2655
|
+
const out = ask?.expiresAt === undefined ? e : { ...e, timeout: Math.max(1, ask.expiresAt - Date.now()) };
|
|
2656
|
+
res.write(`data: ${JSON.stringify(out)}\n\n`);
|
|
2657
|
+
}
|
|
2658
|
+
child.clients.add(res);
|
|
2659
|
+
const keepAlive = setInterval(
|
|
2660
|
+
guard(opts.log, "keep-alive", () => res.write(": ping\n\n")),
|
|
2661
|
+
25_000,
|
|
2662
|
+
);
|
|
2663
|
+
req.on(
|
|
2664
|
+
"close",
|
|
2665
|
+
guard(opts.log, "stream closed", () => {
|
|
2666
|
+
clearInterval(keepAlive);
|
|
2667
|
+
child.clients.delete(res);
|
|
2668
|
+
touch();
|
|
2669
|
+
}),
|
|
2670
|
+
);
|
|
2671
|
+
return;
|
|
2672
|
+
}
|
|
2673
|
+
if (route === "/state" && req.method === "GET") return void json(res, await snapshot(child));
|
|
2674
|
+
if (route === "/history" && req.method === "GET") {
|
|
2675
|
+
// A terminal holds it: the file, not the page's pi, which keeps the copy it read at its start.
|
|
2676
|
+
const holder = child.sessionId ? holderNow(child.sessionId, child) : undefined;
|
|
2677
|
+
if (holder?.kind === "terminal" && !holder.mine && child.sessionFile) {
|
|
2678
|
+
// Numbered before the read: an event after this number is newer than the transcript.
|
|
2679
|
+
const seq = child.eventSeq;
|
|
2680
|
+
const read = readHeldConversation(child.sessionFile);
|
|
2681
|
+
if (read) {
|
|
2682
|
+
viewerMarks.set(child, read.mark);
|
|
2683
|
+
return void json(res, { messages: read.messages, seq, epoch: child.epoch });
|
|
2684
|
+
}
|
|
2685
|
+
}
|
|
2686
|
+
const r = await child.rpc({ type: "get_messages" }, 20_000);
|
|
2687
|
+
// The number of the last event that happened before this transcript was taken.
|
|
2688
|
+
const seq = child.eventSeq;
|
|
2689
|
+
const messages = r?.success ? (r.data?.messages ?? []) : [];
|
|
2690
|
+
// The run cards this pi saved are entries, not messages: put back where they were written.
|
|
2691
|
+
// Asked fresh, not from the search cache, which can be from before the last card; and not
|
|
2692
|
+
// at all for a session too big to parse here (its cards are then not drawn).
|
|
2693
|
+
const e = child.entriesReadable() ? await child.rpc({ type: "get_entries" }, 20_000) : undefined;
|
|
2694
|
+
const entries = e?.success ? e.data?.entries : undefined;
|
|
2695
|
+
return void json(res, { messages: Array.isArray(entries) && Array.isArray(messages) ? historyWithCards(entries, e.data?.leafId, messages) : messages, seq, epoch: child.epoch });
|
|
2696
|
+
}
|
|
2697
|
+
if (route === "/prompt" && req.method === "POST") {
|
|
2698
|
+
const { text, images, mode } = await body(req);
|
|
2699
|
+
if (!text && !(images ?? []).length) return void json(res, { success: false, error: "empty prompt" }, 400);
|
|
2700
|
+
const guard = commandRefusal(text ?? "", child);
|
|
2701
|
+
if (guard) return void json(res, guard, 400);
|
|
2702
|
+
const message = expandMentions(text ?? "", child.sessionCwd);
|
|
2703
|
+
if (message.trimStart().startsWith("/") || mode === "steer") {
|
|
2704
|
+
// A command acts on the process and may sit in a dialog for minutes; a steer belongs to
|
|
2705
|
+
// the turn that is running. Neither is queued content, so both stay rpc `prompt`: pi
|
|
2706
|
+
// runs an extension command at once even while streaming, and queues a steer behind
|
|
2707
|
+
// the turn (docs/web-ui-parity.md). pi answers a prompt command when the command has
|
|
2708
|
+
// *finished*, and a slash command may wait on a person for as long as a setup script
|
|
2709
|
+
// takes to read. Sixty seconds reported that as "timed out" while the command went on
|
|
2710
|
+
// to succeed.
|
|
2711
|
+
const patience = mode === "steer" ? undefined : 15 * 60_000;
|
|
2712
|
+
const answer = await child.rpc({ type: "prompt", message, streamingBehavior: mode === "steer" ? "steer" : "followUp", ...(images?.length ? { images } : {}) }, patience);
|
|
2713
|
+
// A command can change what is loaded (`/reload`): the next chat message is routed on a
|
|
2714
|
+
// list asked for after it, not before.
|
|
2715
|
+
if (mode !== "steer") child.refreshCommands().catch((err) => opts.log(`pi-loops web: commands: ${err?.message ?? err}`));
|
|
2716
|
+
return void json(res, answer);
|
|
2717
|
+
}
|
|
2718
|
+
// No pi-loops in this pi (older than the floor, a load error, extensions off): nobody would
|
|
2719
|
+
// drain a row, and the ring would reach the model as text. The text goes to pi, as before 0.24.
|
|
2720
|
+
if (child.catalog.piLoopsLoaded === undefined || Date.now() - child.catalog.commandsAt > COMMANDS_TTL_MS) await child.refreshCommands();
|
|
2721
|
+
if (!child.catalog.piLoopsLoaded) return void json(res, await child.rpc({ type: "prompt", message, streamingBehavior: "followUp", ...(images?.length ? { images } : {}) }));
|
|
2722
|
+
// Chat text is a request row (docs/design.md decision 23): it waits in the file, visible and
|
|
2723
|
+
// cancellable, until the pi holding the session is idle, survives Esc and pi exiting, and
|
|
2724
|
+
// runs in one order with what pi-loops addressed to the conversation. The ring is the bell
|
|
2725
|
+
// and the proof of who wrote it; the drain that answers it hands the row over if pi is idle.
|
|
2726
|
+
if (!child.sessionId) await snapshot(child);
|
|
2727
|
+
if (!child.sessionId) return void json(res, { success: false, error: "pi is not running" }, 503);
|
|
2728
|
+
// The bytes go to a file of their own; what reaches the file is checked here, at the edge.
|
|
2729
|
+
const attached = Array.isArray(images) && images.length ? images : undefined;
|
|
2730
|
+
if (attached && !attached.every((i) => i && typeof i === "object" && i.type === "image" && typeof i.data === "string" && typeof i.mimeType === "string")) return void json(res, { success: false, error: "bad image" }, 400);
|
|
2731
|
+
const row = personRow(randomUUID(), message, attached, { sessionId: child.sessionId, cwd: child.sessionCwd, createdBy: signed(child) });
|
|
2732
|
+
requests.append(row, attached);
|
|
2733
|
+
const holder = holderNow(child.sessionId, child);
|
|
2734
|
+
ring(child, row.id, requestFingerprint(row)).catch((err) => opts.log(`pi-loops web: ring: ${err?.message ?? err}`));
|
|
2735
|
+
// Owner decision O2: a terminal that holds the session and delivers answers the row. One
|
|
2736
|
+
// that holds it and cannot (standing aside, a one-shot run) is passed over: the page's own
|
|
2737
|
+
// pi runs the row (src/engine.ts), unless it is the one standing aside, in which case nothing
|
|
2738
|
+
// may write into the conversation until it is resumed, and the page says so.
|
|
2739
|
+
const where = holder && !holder.mine && holder.delivers ? { elsewhere: { pid: holder.pid } } : holder?.mine && holder.aside ? { aside: { pid: holder.pid } } : {};
|
|
2740
|
+
return void json(res, { success: true, queued: row.id, ...where });
|
|
2741
|
+
}
|
|
2742
|
+
if (route === "/model" && req.method === "POST") {
|
|
2743
|
+
// Recorded below, once pi has accepted it.
|
|
2744
|
+
// The catalog is rendered as `provider/id`; the command takes the two halves.
|
|
2745
|
+
const { model } = await body(req);
|
|
2746
|
+
const cut = String(model ?? "").indexOf("/");
|
|
2747
|
+
if (cut < 1) return void json(res, { success: false, error: "model must be provider/id" }, 400);
|
|
2748
|
+
const answer = await child.rpc({ type: "set_model", provider: model.slice(0, cut), modelId: model.slice(cut + 1) }, 20_000);
|
|
2749
|
+
// A different model has a different set of thinking levels; the picker must follow it.
|
|
2750
|
+
if (answer?.success) {
|
|
2751
|
+
await child.refreshCatalog();
|
|
2752
|
+
rememberPref("model", String(model));
|
|
2753
|
+
}
|
|
2754
|
+
return void json(res, answer);
|
|
2755
|
+
}
|
|
2756
|
+
if (route === "/thinking" && req.method === "POST") {
|
|
2757
|
+
const { level } = await body(req);
|
|
2758
|
+
const answer = await child.rpc({ type: "set_thinking_level", level });
|
|
2759
|
+
if (answer?.success) rememberPref("thinking", String(level));
|
|
2760
|
+
return void json(res, answer);
|
|
2761
|
+
}
|
|
2762
|
+
if (route === "/complete" && req.method === "POST") {
|
|
2763
|
+
const { text } = await body(req);
|
|
2764
|
+
return void json(res, { items: complete(text, child.sessionCwd, child) });
|
|
2765
|
+
}
|
|
2766
|
+
/**
|
|
2767
|
+
* A file the session made, so you can look at it instead of at its source.
|
|
2768
|
+
*
|
|
2769
|
+
* Everything about this route is about not turning "show me that chart" into "run whatever
|
|
2770
|
+
* the model wrote". It is anchored inside the session's own directory, the extensions it
|
|
2771
|
+
* serves are a list rather than anything on disk, and every response is sandboxed — an
|
|
2772
|
+
* opaque origin, which is what stops an HTML file the model wrote from turning round and
|
|
2773
|
+
* driving the session with the cookie that fetched it.
|
|
2774
|
+
*/
|
|
2775
|
+
if (route === "/file" && req.method === "GET") {
|
|
2776
|
+
const wanted = url.searchParams.get("path") ?? "";
|
|
2777
|
+
const file = path.resolve(child.sessionCwd, wanted.startsWith("~/") ? path.join(os.homedir(), wanted.slice(2)) : wanted);
|
|
2778
|
+
const root = previewRoot(file, child);
|
|
2779
|
+
if (!root) return void res.writeHead(403).end("outside the session directory and your home directory");
|
|
2780
|
+
// A dot segment is where secrets live — .ssh, .env, .claude/.credentials.json — and
|
|
2781
|
+
// nothing anybody wants to *look at* begins with one. Checked on the path the filesystem
|
|
2782
|
+
// resolves to, not the one that was asked for: membership is decided after symlinks are
|
|
2783
|
+
// followed, and a rule applied before them is a rule with a door next to it. A link at
|
|
2784
|
+
// ~/Documents/cfg pointing into ~/.config is how that door gets used.
|
|
2785
|
+
const real = realpathish(file);
|
|
2786
|
+
const rel = path.relative(realpathish(root), real);
|
|
2787
|
+
if (rel.split(path.sep).some((seg) => seg.startsWith(".")) || isUnder(file, LOOPS_DIR)) {
|
|
2788
|
+
return void res.writeHead(403).end("not a file this previews");
|
|
2789
|
+
}
|
|
2790
|
+
const type = PREVIEW_TYPES[path.extname(real).toLowerCase()];
|
|
2791
|
+
if (!type) return void res.writeHead(415).end("not a kind of file this previews");
|
|
2792
|
+
// Outside the session's own directory, only things one *looks* at. A home directory holds
|
|
2793
|
+
// service-account keys named like ordinary JSON and password exports named like ordinary
|
|
2794
|
+
// CSV, and neither is a thing anybody previews — while a picture or a page is exactly
|
|
2795
|
+
// what an agent leaves in Downloads for a person to open.
|
|
2796
|
+
const inSessionDir = isUnder(file, child.sessionCwd);
|
|
2797
|
+
if (!inSessionDir && !VISUAL_TYPES.has(path.extname(real).toLowerCase())) {
|
|
2798
|
+
return void res.writeHead(415).end("outside the session directory, only pictures, PDFs and pages are shown");
|
|
2799
|
+
}
|
|
2800
|
+
// Opened once, and everything after this is about that one open file — not about the
|
|
2801
|
+
// name, which the session is free to point somewhere else in between.
|
|
2802
|
+
// Non-blocking: open(2) on a FIFO with no writer waits for one, and this is the event loop of
|
|
2803
|
+
// the process that runs every loop, rule and conversation. A FIFO then opens at once and is
|
|
2804
|
+
// refused below as not a file; a regular file reads as it always does.
|
|
2805
|
+
let fd;
|
|
2806
|
+
try {
|
|
2807
|
+
fd = fs.openSync(file, fs.constants.O_RDONLY | (fs.constants.O_NONBLOCK ?? 0));
|
|
2808
|
+
} catch {
|
|
2809
|
+
return void res.writeHead(404).end("no such file");
|
|
2810
|
+
}
|
|
2811
|
+
const stat = fs.fstatSync(fd);
|
|
2812
|
+
if (!stat.isFile()) {
|
|
2813
|
+
fs.closeSync(fd);
|
|
2814
|
+
return void res.writeHead(404).end("not a file");
|
|
2815
|
+
}
|
|
2816
|
+
if (stat.size > PREVIEW_MAX_BYTES) {
|
|
2817
|
+
fs.closeSync(fd);
|
|
2818
|
+
return void res.writeHead(413).end("too big to preview");
|
|
2819
|
+
}
|
|
2820
|
+
res.writeHead(200, {
|
|
2821
|
+
"content-type": type,
|
|
2822
|
+
"content-length": stat.size,
|
|
2823
|
+
"x-content-type-options": "nosniff",
|
|
2824
|
+
"cache-control": "no-store",
|
|
2825
|
+
/*
|
|
2826
|
+
* An opaque origin: no cookie of ours is attached to anything it asks for, and it has
|
|
2827
|
+
* no same-origin access to this server. On top of that it may not fetch anything,
|
|
2828
|
+
* which is what stops a page from carrying its contents somewhere else — and scripts
|
|
2829
|
+
* run only for the session's own files. `~/Downloads` is where a browser puts what
|
|
2830
|
+
* the web gave you, and running that under an address you trust is not previewing.
|
|
2831
|
+
*/
|
|
2832
|
+
"content-security-policy": [
|
|
2833
|
+
inSessionDir ? "sandbox allow-scripts" : "sandbox",
|
|
2834
|
+
"default-src 'none'",
|
|
2835
|
+
"img-src data: blob:",
|
|
2836
|
+
"style-src 'unsafe-inline'",
|
|
2837
|
+
inSessionDir ? "script-src 'unsafe-inline'" : "script-src 'none'",
|
|
2838
|
+
"font-src data:",
|
|
2839
|
+
].join("; "),
|
|
2840
|
+
// It chooses its own referrer policy otherwise, and this address is our address.
|
|
2841
|
+
"referrer-policy": "no-referrer",
|
|
2842
|
+
"content-disposition": "inline",
|
|
2843
|
+
});
|
|
2844
|
+
// A read that fails (a vanished mount, EIO) ends this answer, not the process.
|
|
2845
|
+
return void fs
|
|
2846
|
+
.createReadStream(file, { fd })
|
|
2847
|
+
.on("error", (err) => {
|
|
2848
|
+
opts.log(`pi-loops web: reading ${file}: ${err.message}`);
|
|
2849
|
+
res.destroy();
|
|
2850
|
+
})
|
|
2851
|
+
.pipe(res);
|
|
2852
|
+
}
|
|
2853
|
+
if (route === "/abort" && req.method === "POST") {
|
|
2854
|
+
return void json(res, await child.rpc({ type: "abort" }));
|
|
2855
|
+
}
|
|
2856
|
+
if (route === "/compact" && req.method === "POST") {
|
|
2857
|
+
// What survives compaction is worth steering — "keep the API shapes, drop the search" is
|
|
2858
|
+
// the difference between a summary you can work from and one you have to undo. pi takes
|
|
2859
|
+
// the instructions the way `/compact <text>` does in the terminal.
|
|
2860
|
+
const { instructions } = await body(req);
|
|
2861
|
+
const custom = String(instructions ?? "").trim();
|
|
2862
|
+
return void json(res, await child.rpc({ type: "compact", ...(custom ? { customInstructions: custom.slice(0, 2000) } : {}) }, 300_000));
|
|
2863
|
+
}
|
|
2864
|
+
if (route === "/sessions" && req.method === "GET") {
|
|
2865
|
+
await snapshot(child); // whichever file pi is writing now is what the list is anchored to
|
|
2866
|
+
return void json(res, { sessions: child.listSessionsHere(), dir: child.sessionsDir() });
|
|
2867
|
+
}
|
|
2868
|
+
if (route === "/session/new" && req.method === "POST") {
|
|
2869
|
+
await snapshot(child);
|
|
2870
|
+
const answer = await child.swapSession(child.newSessionPath());
|
|
2871
|
+
// The pool re-keys the child to the new id and its pages are told, before this answers.
|
|
2872
|
+
await snapshot(child);
|
|
2873
|
+
return void json(res, answer);
|
|
2874
|
+
}
|
|
2875
|
+
if (route === "/session/switch" && req.method === "POST") {
|
|
2876
|
+
const { file } = await body(req);
|
|
2877
|
+
await snapshot(child);
|
|
2878
|
+
// The list this came from is the only place it may point: a path from the browser is not
|
|
2879
|
+
// a reason to open a file anywhere on the disk, and `switch_session` on something that is
|
|
2880
|
+
// not a session is how you get an empty session written over it.
|
|
2881
|
+
const wanted = typeof file === "string" ? path.resolve(file) : "";
|
|
2882
|
+
const match = child.listSessionsHere().find((s) => path.resolve(s.file) === wanted);
|
|
2883
|
+
if (!match) return void json(res, { success: false, error: "no session of this project has that path" }, 400);
|
|
2884
|
+
// One child per conversation: a conversation open in another child is opened there.
|
|
2885
|
+
const other = typeof match.id === "string" ? pool.get(match.id) : undefined;
|
|
2886
|
+
if (other && other !== child) return void json(res, { success: false, error: "that conversation is already open in the page", url: `/c/${encodeURIComponent(match.id)}/` }, 409);
|
|
2887
|
+
// The resolved path, which is the one that was checked: pi's cwd is not this process's,
|
|
2888
|
+
// so a relative path would be checked here and opened somewhere else.
|
|
2889
|
+
const answer = await child.swapSession(path.resolve(match.file), { restoreModel: true });
|
|
2890
|
+
await snapshot(child);
|
|
2891
|
+
return void json(res, answer);
|
|
2892
|
+
}
|
|
2893
|
+
if (route === "/queue/clear" && req.method === "POST") {
|
|
2894
|
+
// Everything "queued N ✕" counts: every input row, the person's and pi-loops', as each one's
|
|
2895
|
+
// own ✕ would cancel it, and what pi itself still holds (a steer, a command).
|
|
2896
|
+
const sid = child.sessionId;
|
|
2897
|
+
if (sid && child.catalog.piLoopsLoaded !== false) {
|
|
2898
|
+
for (const row of requests.list(sid)) if (row.kind === "input") requests.append(cancelRow(sid, { id: row.id }, "person", signed(child)));
|
|
2899
|
+
ring(child).catch((err) => opts.log(`pi-loops web: ring: ${err?.message ?? err}`));
|
|
2900
|
+
}
|
|
2901
|
+
return void json(res, await child.rpc({ type: "clear_queue" }));
|
|
2902
|
+
}
|
|
2903
|
+
if (route === "/queue/send-here" && req.method === "POST") {
|
|
2904
|
+
// A message of this page nobody took (`untaken`): out of the queue, audited, and sent to the
|
|
2905
|
+
// pi behind this page over rpc `prompt`, as every message went before 0.24. Removed here
|
|
2906
|
+
// rather than by a cancel row, since the point is that nobody is draining the rows.
|
|
2907
|
+
const { id } = await body(req);
|
|
2908
|
+
if (child.catalog.piLoopsLoaded === false) return void json(res, { success: false, error: NO_PI_LOOPS }, 400);
|
|
2909
|
+
if (!ROW_ID.test(id ?? "") || !child.sessionId) return void json(res, { success: false, error: "bad id" }, 400);
|
|
2910
|
+
const row = requests.list(child.sessionId).find((r) => r.id === id && r.kind === "input" && r.source === "person" && mine(r, child));
|
|
2911
|
+
if (!row) return void json(res, { success: false, error: "not queued" }, 404);
|
|
2912
|
+
const holder = holderNow(child.sessionId, child);
|
|
2913
|
+
if (holder?.mine && holder.aside) return void json(res, { success: false, error: "this page's pi shows an older copy of this conversation and reads it again once it is idle; send it then" }, 409);
|
|
2914
|
+
const images = imagesOf(row);
|
|
2915
|
+
// The check and the removal are one critical section: a holder that stamps the row handed
|
|
2916
|
+
// over in between keeps it, and this answers 409 instead of answering the message twice.
|
|
2917
|
+
const taken = requests.removeIf(child.sessionId, row.id, (r) => r.handed_at === undefined && mine(r, child));
|
|
2918
|
+
if (taken.outcome !== "removed") return void json(res, { success: false, error: taken.outcome === "kept" ? "already handed to pi" : "not queued" }, taken.outcome === "kept" ? 409 : 404);
|
|
2919
|
+
audit.appendAudit({ cwd: row.payload?.cwd ?? "", type: "request", traceId: row.id, state: "cancelled", sourceLabel: row.source, eventLabel: child.sessionId, details: { session_id: child.sessionId, kind: row.kind, sent: "over rpc, from the page" } });
|
|
2920
|
+
return void json(res, await child.rpc({ type: "prompt", message: row.payload?.text ?? "", streamingBehavior: "followUp", ...(images.length ? { images } : {}) }));
|
|
2921
|
+
}
|
|
2922
|
+
if (route === "/queue/cancel" && req.method === "POST") {
|
|
2923
|
+
// A cancel is a row too: the holder removes the queued row and audits it, whoever holds the
|
|
2924
|
+
// session. Discarding a message a previous page left behind is the same request.
|
|
2925
|
+
const { id } = await body(req);
|
|
2926
|
+
if (child.catalog.piLoopsLoaded === false) return void json(res, { success: false, error: NO_PI_LOOPS }, 400);
|
|
2927
|
+
if (!ROW_ID.test(id ?? "") || !child.sessionId) return void json(res, { success: false, error: "bad id" }, 400);
|
|
2928
|
+
if (!requests.list(child.sessionId).some((row) => row.id === id && row.kind === "input")) return void json(res, { success: false, error: "not queued" }, 404);
|
|
2929
|
+
requests.append(cancelRow(child.sessionId, { id }, "person", signed(child)));
|
|
2930
|
+
ring(child).catch((err) => opts.log(`pi-loops web: ring: ${err?.message ?? err}`));
|
|
2931
|
+
return void json(res, { success: true });
|
|
2932
|
+
}
|
|
2933
|
+
if (route === "/queue/send" && req.method === "POST") {
|
|
2934
|
+
// A message a previous page left queued ("not sent before pi stopped") becomes this page's,
|
|
2935
|
+
// under the lock, and is rung for. Only a person row: the ring is what makes a row believed as
|
|
2936
|
+
// typed here, and the page owns no other kind.
|
|
2937
|
+
const { id } = await body(req);
|
|
2938
|
+
if (child.catalog.piLoopsLoaded === false) return void json(res, { success: false, error: NO_PI_LOOPS }, 400);
|
|
2939
|
+
if (!ROW_ID.test(id ?? "") || !child.sessionId) return void json(res, { success: false, error: "bad id" }, 400);
|
|
2940
|
+
const sid = child.sessionId;
|
|
2941
|
+
const person = (row: any) => row.kind === "input" && row.source === "person" && row.id === id;
|
|
2942
|
+
if (requests.list(sid).some(person)) requests.update(sid, id, { created_by: signed(child) });
|
|
2943
|
+
const row = requests.list(sid).find(person);
|
|
2944
|
+
if (!row) return void json(res, { success: false, error: "not queued" }, 404);
|
|
2945
|
+
ring(child, row.id, requestFingerprint(row)).catch((err) => opts.log(`pi-loops web: ring: ${err?.message ?? err}`));
|
|
2946
|
+
return void json(res, { success: true, sent: row.id });
|
|
2947
|
+
}
|
|
2948
|
+
if (route === "/trigger/immediate" && req.method === "POST") {
|
|
2949
|
+
// "Run now" on a job. pi-loops exposes it as a command, and a command is a prompt here.
|
|
2950
|
+
const { id } = await body(req);
|
|
2951
|
+
if (!/^[A-Za-z0-9_-]{1,80}$/.test(id ?? "")) return void json(res, { success: false, error: "bad id" }, 400);
|
|
2952
|
+
const command = id.startsWith("dyn-") ? `/triggers run ${id}` : `/cron run ${id}`;
|
|
2953
|
+
return void json(res, await child.rpc({ type: "prompt", message: command }));
|
|
2954
|
+
}
|
|
2955
|
+
if (route === "/stats" && req.method === "GET") {
|
|
2956
|
+
// The cost, and the context gauge the terminal footer shows.
|
|
2957
|
+
const r = await child.rpc({ type: "get_session_stats" }, 20_000);
|
|
2958
|
+
return void json(res, r?.success ? r.data : { error: r?.error });
|
|
2959
|
+
}
|
|
2960
|
+
if (route === "/find" && req.method === "POST") {
|
|
2961
|
+
// Find. `get_entries` is the whole tree, so this searches abandoned branches and
|
|
2962
|
+
// pre-compaction history too, which is exactly what you want when looking for something
|
|
2963
|
+
// you remember saying.
|
|
2964
|
+
const { q } = await body(req);
|
|
2965
|
+
const needle = String(q ?? "").toLowerCase();
|
|
2966
|
+
if (needle.length < 2) return void json(res, { hits: [] });
|
|
2967
|
+
const r = await child.cachedEntries();
|
|
2968
|
+
const hits = [];
|
|
2969
|
+
for (const e of r?.success ? (r.data?.entries ?? []) : []) {
|
|
2970
|
+
const m = e?.message;
|
|
2971
|
+
if (!m) continue;
|
|
2972
|
+
const text = typeof m.content === "string" ? m.content : (m.content ?? []).map((c: any) => c.text ?? c.thinking ?? "").join(" ");
|
|
2973
|
+
const at = text.toLowerCase().indexOf(needle);
|
|
2974
|
+
if (at === -1) continue;
|
|
2975
|
+
hits.push({ role: m.role, when: e.timestamp, excerpt: text.slice(Math.max(0, at - 60), at + 140) });
|
|
2976
|
+
if (hits.length >= 50) break;
|
|
2977
|
+
}
|
|
2978
|
+
return void json(res, { hits });
|
|
2979
|
+
}
|
|
2980
|
+
if (route === "/undo" && req.method === "POST") {
|
|
2981
|
+
// Undo: fork from the last user message on this branch. pi hands back its text, so
|
|
2982
|
+
// the prompt returns to the composer rather than being lost.
|
|
2983
|
+
const r = await child.cachedEntries();
|
|
2984
|
+
if (!r?.success) return void json(res, { success: false, error: r?.error ?? "cannot read the session" }, 500);
|
|
2985
|
+
const entries = r.data?.entries ?? [];
|
|
2986
|
+
const last = [...entries].reverse().find((e) => e?.type === "message" && e.message?.role === "user");
|
|
2987
|
+
if (!last) return void json(res, { success: false, error: "nothing to undo" }, 400);
|
|
2988
|
+
const forked = await child.rpc({ type: "fork", entryId: last.id }, 30_000);
|
|
2989
|
+
child.entriesCache = { at: 0, value: undefined }; // the branch moved under us
|
|
2990
|
+
return void json(res, forked);
|
|
2991
|
+
}
|
|
2992
|
+
if (route === "/share" && req.method === "POST") {
|
|
2993
|
+
// pi-loops' command does the rendering, the redaction and the confirmation; its dialog
|
|
2994
|
+
// reaches the browser as an extension_ui_request like every other approval. It blocks on
|
|
2995
|
+
// that dialog, so this must not sit on a short timeout waiting for an answer.
|
|
2996
|
+
const { public: isPublic } = await body(req);
|
|
2997
|
+
if (!child.catalog.commandList.some((c) => c.name === "session-share")) return void json(res, { success: false, error: "pi-loops is not loaded in this session" }, 400);
|
|
2998
|
+
return void json(res, await child.rpc({ type: "prompt", message: isPublic ? "/session-share --public" : "/session-share" }, 600_000));
|
|
2999
|
+
}
|
|
3000
|
+
if (route === "/export" && req.method === "POST") {
|
|
3001
|
+
// Save. pi writes the HTML itself; this only reports where it landed.
|
|
3002
|
+
return void json(res, await child.rpc({ type: "export_html" }, 60_000));
|
|
3003
|
+
}
|
|
3004
|
+
if (route === "/ui-response" && req.method === "POST") {
|
|
3005
|
+
// Named fields only: spreading the body would let the caller set `type` and write any
|
|
3006
|
+
// command it liked to pi's stdin through a route that is meant to answer a dialog.
|
|
3007
|
+
const { id, value, confirmed, cancelled } = await body(req);
|
|
3008
|
+
if (typeof id !== "string" || !id) return void json(res, { error: "id required" }, 400);
|
|
3009
|
+
child.live.pendingAsks.delete(id);
|
|
3010
|
+
const answer: Record<string, unknown> = { type: "extension_ui_response", id };
|
|
3011
|
+
if (cancelled === true) answer.cancelled = true;
|
|
3012
|
+
else if (typeof confirmed === "boolean") answer.confirmed = confirmed;
|
|
3013
|
+
else if (value !== undefined) answer.value = String(value);
|
|
3014
|
+
child.writeToPi(`${JSON.stringify(answer)}\n`);
|
|
3015
|
+
return void json(res, { ok: true });
|
|
3016
|
+
}
|
|
3017
|
+
if (route === "/rpc" && req.method === "POST") {
|
|
3018
|
+
// Escape hatch: anything in pi's protocol this UI has not grown a button for yet.
|
|
3019
|
+
const command = await body(req);
|
|
3020
|
+
const type = String(command?.type ?? "");
|
|
3021
|
+
if (OWN_ROUTES.has(type)) return void json(res, { success: false, error: `${type} has a route of its own` }, 400);
|
|
3022
|
+
if (NO_ROUTE_AT_ALL.has(type)) return void json(res, { success: false, error: `${type} would replace the session the attached browsers are watching; switch sessions with /switch_session` }, 400);
|
|
3023
|
+
return void json(res, await child.rpc(command));
|
|
3024
|
+
}
|
|
3025
|
+
json(res, { error: "not found" }, 404);
|
|
3026
|
+
} catch (err: any) {
|
|
3027
|
+
json(res, { error: String(err?.message ?? err) }, 500);
|
|
3028
|
+
}
|
|
3029
|
+
}),
|
|
3030
|
+
);
|
|
3031
|
+
|
|
3032
|
+
/** The last 64 KB of a file in the loops directory, newest line first; [] when there is none. */
|
|
3033
|
+
function tailLines(name: string) {
|
|
3034
|
+
try {
|
|
3035
|
+
const fd = fs.openSync(path.join(LOOPS_DIR, name), "r");
|
|
3036
|
+
try {
|
|
3037
|
+
const size = fs.fstatSync(fd).size;
|
|
3038
|
+
const len = Math.min(size, 64 * 1024);
|
|
3039
|
+
const buf = Buffer.alloc(len);
|
|
3040
|
+
fs.readSync(fd, buf, 0, len, size - len);
|
|
3041
|
+
return buf.toString("utf8").split("\n").reverse();
|
|
3042
|
+
} finally {
|
|
3043
|
+
fs.closeSync(fd);
|
|
3044
|
+
}
|
|
3045
|
+
} catch {
|
|
3046
|
+
return []; // no such file yet
|
|
3047
|
+
}
|
|
3048
|
+
}
|
|
3049
|
+
|
|
3050
|
+
/**
|
|
3051
|
+
* This project's latest rule check, from the tail of `triggers-audit.jsonl`. Checks run in the
|
|
3052
|
+
* background, so their results no longer reach this session, where the "last check" line came from.
|
|
3053
|
+
*/
|
|
3054
|
+
function lastCheckIn(cwd: string) {
|
|
3055
|
+
for (const line of tailLines("triggers-audit.jsonl")) {
|
|
3056
|
+
let r;
|
|
3057
|
+
try {
|
|
3058
|
+
r = JSON.parse(line);
|
|
3059
|
+
} catch {
|
|
3060
|
+
continue; // the first line of the tail is usually cut
|
|
3061
|
+
}
|
|
3062
|
+
if (r && r.type === "trigger_result" && r.sourceLabel === "local:dynamic" && r.state !== "running" && typeof r.cwd === "string" && sameProject(r.cwd, cwd)) return { state: String(r.state), at: r.ts ?? r.at };
|
|
3063
|
+
}
|
|
3064
|
+
return undefined;
|
|
3065
|
+
}
|
|
3066
|
+
|
|
3067
|
+
/** The objects of a JSON array read from a file another process writes; anything else is no entry. */
|
|
3068
|
+
function objectsIn(list: unknown): any[] {
|
|
3069
|
+
return Array.isArray(list) ? list.filter((x) => x && typeof x === "object" && typeof x.id === "string") : [];
|
|
3070
|
+
}
|
|
3071
|
+
|
|
3072
|
+
/**
|
|
3073
|
+
* The records in the last `bytes` of a JSONL file, oldest first: the first line of the tail is
|
|
3074
|
+
* usually cut and the last may be torn, and neither is a record. 256 KB of the run log is more than
|
|
3075
|
+
* a day of a 15-minute loop.
|
|
3076
|
+
*/
|
|
3077
|
+
function tailRecords(file: string, bytes = 256 * 1024): any[] {
|
|
3078
|
+
let text = "";
|
|
3079
|
+
try {
|
|
3080
|
+
const fd = fs.openSync(file, "r");
|
|
3081
|
+
try {
|
|
3082
|
+
const size = fs.fstatSync(fd).size;
|
|
3083
|
+
const len = Math.min(size, bytes);
|
|
3084
|
+
const buf = Buffer.alloc(len);
|
|
3085
|
+
fs.readSync(fd, buf, 0, len, size - len);
|
|
3086
|
+
text = buf.toString("utf8");
|
|
3087
|
+
} finally {
|
|
3088
|
+
fs.closeSync(fd);
|
|
3089
|
+
}
|
|
3090
|
+
} catch {
|
|
3091
|
+
return []; // not written yet
|
|
3092
|
+
}
|
|
3093
|
+
return text.split("\n").flatMap((line) => {
|
|
3094
|
+
try {
|
|
3095
|
+
const r = JSON.parse(line);
|
|
3096
|
+
return r && typeof r === "object" && !Array.isArray(r) ? [r] : [];
|
|
3097
|
+
} catch {
|
|
3098
|
+
return []; // a cut or torn line
|
|
3099
|
+
}
|
|
3100
|
+
});
|
|
3101
|
+
}
|
|
3102
|
+
|
|
3103
|
+
/**
|
|
3104
|
+
* What this page's pi knows about who runs which plain job, as `/cron` asks it (src/job-owner.ts):
|
|
3105
|
+
* a live lease names the process holding a conversation (a terminal, the page's pi, pi-loops
|
|
3106
|
+
* answering in it), and a closed conversation whose file pi-loops knows runs in the background.
|
|
3107
|
+
*/
|
|
3108
|
+
function pageOwnerView(currentSession: string | undefined, child: PageChild, paused: string | undefined): OwnerView {
|
|
3109
|
+
const holder = (id: string) => {
|
|
3110
|
+
if (!SAFE_SESSION_ID.test(id) || id.includes("..")) return undefined;
|
|
3111
|
+
const lease = leaseOf(id);
|
|
3112
|
+
return lease?.holder && holderAlive(lease.holder) ? lease.holder : undefined;
|
|
3113
|
+
};
|
|
3114
|
+
const own = currentSession ? holder(currentSession) : undefined;
|
|
3115
|
+
return {
|
|
3116
|
+
sessionId: currentSession,
|
|
3117
|
+
holdsOwn: !own || own.pid === child.pid,
|
|
3118
|
+
standsAside: own?.pid === child.pid && own.aside === true,
|
|
3119
|
+
holderOf: (id) => holder(id)?.pid,
|
|
3120
|
+
holderKind: (id) => {
|
|
3121
|
+
const h = holder(id);
|
|
3122
|
+
return !h ? undefined : h.kind === "host" ? "host" : h.page === true ? "page" : "terminal";
|
|
3123
|
+
},
|
|
3124
|
+
hostRuns: (id) => {
|
|
3125
|
+
const file = SAFE_SESSION_ID.test(id) && !id.includes("..") ? leaseOf(id)?.sessionFile : undefined;
|
|
3126
|
+
return !!file && fs.existsSync(file);
|
|
3127
|
+
},
|
|
3128
|
+
...(paused ? { hostPaused: paused } : {}),
|
|
3129
|
+
};
|
|
3130
|
+
}
|
|
3131
|
+
|
|
3132
|
+
const port = () => (server.address() as AddressInfo | null)?.port ?? PORT;
|
|
3133
|
+
const api: PageServer = {
|
|
3134
|
+
url: () => `http://${LOOPBACK ? "127.0.0.1" : HOST_BIND === "0.0.0.0" ? "127.0.0.1" : HOST_BIND}:${port()}/`,
|
|
3135
|
+
port,
|
|
3136
|
+
newPairCode,
|
|
3137
|
+
openKeyFor,
|
|
3138
|
+
openFileFor,
|
|
3139
|
+
clients,
|
|
3140
|
+
close: () => {
|
|
3141
|
+
server.close();
|
|
3142
|
+
clearInterval(claimSweep);
|
|
3143
|
+
for (const key of [...openKeys.keys()]) dropOpenKey(key);
|
|
3144
|
+
},
|
|
3145
|
+
};
|
|
3146
|
+
return new Promise((resolve) => {
|
|
3147
|
+
server.on("error", guard(opts.log, "listen", opts.onListenError));
|
|
3148
|
+
server.listen(PORT, HOST_BIND, () => resolve(api));
|
|
3149
|
+
});
|
|
3150
|
+
}
|