@alphacoder-v0/pi-loops 0.17.6 → 0.19.2
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 +91 -0
- package/README.md +13 -6
- package/README.zh-CN.md +19 -6
- package/docs/agents/issue-tracker.md +34 -0
- package/docs/cli.md +22 -2
- package/docs/configuration.md +1 -1
- package/docs/design.md +63 -6
- package/docs/loops.md +25 -17
- package/docs/recipes.md +239 -0
- package/docs/triggers.md +1 -1
- package/docs/troubleshooting.md +3 -2
- package/docs/web-ui-parity.md +9 -3
- package/package.json +2 -1
- package/recipes/_tracker/issue-tracker-github.md +31 -0
- package/recipes/_tracker/issue-tracker-local.md +38 -0
- package/recipes/_tracker-setup.md +12 -0
- package/recipes/autoresearch/README.md +27 -0
- package/recipes/autoresearch/RESEARCH.template.md +37 -0
- package/recipes/autoresearch/example/RESEARCH.md +31 -0
- package/recipes/autoresearch/example/eval.py +60 -0
- package/recipes/autoresearch/example/eval.sh +3 -0
- package/recipes/autoresearch/example/solution.py +20 -0
- package/recipes/autoresearch/experiment.md +82 -0
- package/recipes/autoresearch/recipe.toml +15 -0
- package/recipes/changelog-draft/recipe.toml +9 -0
- package/recipes/changelog-draft/release.md +35 -0
- package/recipes/issue-loop/README.md +16 -0
- package/recipes/issue-loop/implement.md +135 -0
- package/recipes/issue-loop/labels.sh +21 -0
- package/recipes/issue-loop/recipe.toml +18 -0
- package/recipes/issue-loop/triage.md +147 -0
- package/skills/pi-loops/SKILL.md +3 -1
- package/src/archive.ts +5 -5
- package/src/args.ts +1 -8
- package/src/cli.ts +83 -6
- package/src/job-edit.ts +0 -3
- package/src/job-health.ts +6 -7
- package/src/job-owner.ts +29 -0
- package/src/pi-loops.ts +339 -25
- package/src/protocol.ts +2 -3
- package/src/recipe.ts +651 -0
- package/src/scheduler.ts +13 -13
- package/src/session-head.ts +98 -0
- package/src/store.ts +9 -17
- package/src/tools.ts +17 -19
- package/src/trigger-runtime.ts +3 -5
- package/src/triggers.ts +3 -4
- package/src/web.mjs +40 -17
package/src/scheduler.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* The heartbeat. One pi process on the machine owns the timer at a time
|
|
3
|
-
* (leadership lives in scheduler
|
|
3
|
+
* (leadership lives in scheduler.json with a heartbeat); every other process
|
|
4
4
|
* with this extension stands by and takes over when the owner exits or dies.
|
|
5
5
|
* Loop jobs are therefore host-agnostic: close the pi that created a loop and
|
|
6
6
|
* any other open pi keeps it ticking; restart pi and missed ticks are caught up
|
|
@@ -20,7 +20,8 @@ import { type RunnerResult, type SubagentRunner, failedRun } from "./runner.ts";
|
|
|
20
20
|
import { previewRedacted, redact } from "./redact.ts";
|
|
21
21
|
import { type SubagentSlot, SubagentSlots } from "./slots.ts";
|
|
22
22
|
import { backoffWaitMs, computeDue, computeNext, formatLocal, formatLocalZoned, formatSchedule, isValidSchedule, stamp } from "./schedule.ts";
|
|
23
|
-
import { type CheckerRecord, JobStore, type LoopJob, type RunRecord,
|
|
23
|
+
import { type CheckerRecord, JobStore, type LoopJob, type RunRecord, newId } from "./store.ts";
|
|
24
|
+
import { ownerSession, runsIn } from "./job-owner.ts";
|
|
24
25
|
|
|
25
26
|
export const DEFAULT_TICK_MS = 30_000;
|
|
26
27
|
export const LEADER_STALE_MS = 90_000;
|
|
@@ -172,12 +173,12 @@ export class LoopScheduler {
|
|
|
172
173
|
this.store = new JobStore(opts.dir);
|
|
173
174
|
this.inbox = new Inbox(opts.dir);
|
|
174
175
|
// One leader per host: machines sharing a $HOME must not elect each other.
|
|
175
|
-
this.leaderFile = path.join(opts.dir,
|
|
176
|
+
this.leaderFile = path.join(opts.dir, "scheduler.json");
|
|
176
177
|
// Per host, like the leader record beside it, and for the same reason. Leadership is per
|
|
177
178
|
// host; two machines sharing a `$HOME` are both leaders, and a cron expression is matched
|
|
178
179
|
// against local time — so one file would be two machines writing different answers over each
|
|
179
180
|
// other, and a panel showing whichever wrote last.
|
|
180
|
-
this.nextRunsFile = path.join(opts.dir,
|
|
181
|
+
this.nextRunsFile = path.join(opts.dir, "next-runs.json");
|
|
181
182
|
this.leaderLock = path.join(opts.dir, "scheduler.lock");
|
|
182
183
|
this.getSession = opts.getSession;
|
|
183
184
|
this.hooks = opts.hooks ?? {};
|
|
@@ -276,7 +277,9 @@ export class LoopScheduler {
|
|
|
276
277
|
if (this.kind === "interactive" && rec.kind === "host") return true;
|
|
277
278
|
const age = now - Date.parse(rec.heartbeatAt);
|
|
278
279
|
if (Number.isNaN(age) || age > LEADER_STALE_MS) return true;
|
|
279
|
-
|
|
280
|
+
// One machine: a pid that is not alive here is not alive. (A record from a renamed or rebuilt
|
|
281
|
+
// box used to be untouchable until its heartbeat aged out; now it is taken over like any other.)
|
|
282
|
+
if (!pidAlive(rec.pid)) return true;
|
|
280
283
|
return false;
|
|
281
284
|
}
|
|
282
285
|
|
|
@@ -415,11 +418,9 @@ export class LoopScheduler {
|
|
|
415
418
|
this.log(`dead-session check failed: ${err?.message ?? err}`);
|
|
416
419
|
}
|
|
417
420
|
}
|
|
418
|
-
const host = os.hostname();
|
|
419
421
|
for (const job of jobs) {
|
|
420
422
|
if (!job.enabled) continue;
|
|
421
|
-
|
|
422
|
-
const owned = job.stateful ? leader : !!session.sessionId && job.sessionId === session.sessionId;
|
|
423
|
+
const owned = job.stateful ? leader : runsIn(job, session.sessionId);
|
|
423
424
|
if (!owned) continue;
|
|
424
425
|
// One unusable job must never stop the clock for the others: a schedule that cannot be
|
|
425
426
|
// evaluated disables that job and says why, instead of throwing out of the tick (pi
|
|
@@ -461,7 +462,7 @@ export class LoopScheduler {
|
|
|
461
462
|
if (!exists) return;
|
|
462
463
|
// Open somewhere (--no-session, --session-dir, another sessions root)? Then it is alive whatever the disk says.
|
|
463
464
|
const live = new Set(this.presence.list(this.now()).map((e) => e.sessionId).filter(Boolean));
|
|
464
|
-
const dead = jobs.filter((j) => j.enabled && !j.stateful && j.sessionId &&
|
|
465
|
+
const dead = jobs.filter((j) => j.enabled && !j.stateful && j.sessionId && !live.has(j.sessionId) && !exists(j.sessionId));
|
|
465
466
|
for (const job of dead) {
|
|
466
467
|
await this.store.update(job.id, (j) => {
|
|
467
468
|
j.enabled = false;
|
|
@@ -481,12 +482,8 @@ export class LoopScheduler {
|
|
|
481
482
|
*/
|
|
482
483
|
private writeNextRuns(jobs: LoopJob[], now: number): void {
|
|
483
484
|
const next: Record<string, string> = {};
|
|
484
|
-
const host = os.hostname();
|
|
485
485
|
for (const job of jobs) {
|
|
486
486
|
if (!job.enabled || !isValidSchedule(job.schedule)) continue;
|
|
487
|
-
// The same filter `dispatch` applies: this file is per host, and a next run for a job this
|
|
488
|
-
// machine never dispatches (shared $HOME) is a promise nothing here keeps.
|
|
489
|
-
if (job.host && job.host !== host) continue;
|
|
490
487
|
const at = computeNext({ schedule: job.schedule, createdAt: Date.parse(job.createdAt), lastFiredAt: job.lastFiredAt ? Date.parse(job.lastFiredAt) : undefined }, now);
|
|
491
488
|
if (at !== undefined) next[job.id] = stamp(at);
|
|
492
489
|
}
|
|
@@ -717,6 +714,9 @@ export class LoopScheduler {
|
|
|
717
714
|
// job the dead-session sweep parked is the marker `gc()` matches on, so running it once left
|
|
718
715
|
// a job nothing could fire and nothing could collect.
|
|
719
716
|
if (!job.enabled) return "is disabled (/cron enable it first)";
|
|
717
|
+
// The same reasoning for a job that is enabled but belongs to another session: its prompt is
|
|
718
|
+
// written for that conversation, and running it here would land it in this one.
|
|
719
|
+
if (!runsIn(job, this.getSession().sessionId)) return `belongs to session ${ownerSession(job)}, which is not open here — resume it, or /cron add the job again in this chat`;
|
|
720
720
|
// No `dueIso`: this run was asked for, so whatever slot the schedule owes is still owed.
|
|
721
721
|
if (!(await this.injectPlainJob(job, undefined, now))) return "has no chat to run in (the background host has no session)";
|
|
722
722
|
return true;
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* What a session listing needs from a session file without reading the whole file: the header,
|
|
3
|
+
* the name someone gave it, the first thing the person said, and how many messages there are.
|
|
4
|
+
*
|
|
5
|
+
* `src/web.mjs` carries its own copy of this (it is one file with no imports, on purpose); a
|
|
6
|
+
* change here is a change there.
|
|
7
|
+
*/
|
|
8
|
+
import * as fs from "node:fs";
|
|
9
|
+
import type { DynamicTriggerRule } from "./triggers.ts";
|
|
10
|
+
import type { LoopJob } from "./store.ts";
|
|
11
|
+
|
|
12
|
+
export interface SessionHead {
|
|
13
|
+
id: string;
|
|
14
|
+
cwd: string;
|
|
15
|
+
/** The header's timestamp, as written. */
|
|
16
|
+
startedAt?: string;
|
|
17
|
+
/** `/name`, when someone set one. */
|
|
18
|
+
name?: string;
|
|
19
|
+
/** The first user message, as typed, bounded. */
|
|
20
|
+
first?: string;
|
|
21
|
+
messages: number;
|
|
22
|
+
/** The file was longer than the window read; `messages` is a floor. */
|
|
23
|
+
truncated: boolean;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
const HEAD_BYTES = 64 * 1024;
|
|
27
|
+
|
|
28
|
+
export function readSessionHead(file: string, bytes: number = HEAD_BYTES): SessionHead | undefined {
|
|
29
|
+
let text: string;
|
|
30
|
+
try {
|
|
31
|
+
const fd = fs.openSync(file, "r");
|
|
32
|
+
try {
|
|
33
|
+
const buf = Buffer.alloc(bytes);
|
|
34
|
+
text = buf.toString("utf8", 0, fs.readSync(fd, buf, 0, bytes, 0));
|
|
35
|
+
} finally {
|
|
36
|
+
fs.closeSync(fd);
|
|
37
|
+
}
|
|
38
|
+
} catch {
|
|
39
|
+
return undefined;
|
|
40
|
+
}
|
|
41
|
+
const lines = text.split("\n");
|
|
42
|
+
let header: { type?: unknown; id?: unknown; cwd?: unknown; timestamp?: unknown };
|
|
43
|
+
try {
|
|
44
|
+
header = JSON.parse(lines[0] ?? "");
|
|
45
|
+
} catch {
|
|
46
|
+
return undefined;
|
|
47
|
+
}
|
|
48
|
+
if (header?.type !== "session" || typeof header.id !== "string") return undefined;
|
|
49
|
+
const truncated = text.length >= bytes;
|
|
50
|
+
let name: string | undefined;
|
|
51
|
+
let first: string | undefined;
|
|
52
|
+
let messages = 0;
|
|
53
|
+
// The last line of a partial read is half a line; a file that fitted has no such line.
|
|
54
|
+
for (const line of lines.slice(1, truncated ? -1 : undefined)) {
|
|
55
|
+
if (!line.trim()) continue;
|
|
56
|
+
let entry: any;
|
|
57
|
+
try {
|
|
58
|
+
entry = JSON.parse(line);
|
|
59
|
+
} catch {
|
|
60
|
+
continue;
|
|
61
|
+
}
|
|
62
|
+
if (entry?.type === "session_info" && typeof entry.name === "string") name = entry.name.slice(0, 200);
|
|
63
|
+
if (entry?.type !== "message") continue;
|
|
64
|
+
messages++;
|
|
65
|
+
if (first || entry.message?.role !== "user") continue;
|
|
66
|
+
const content = entry.message.content;
|
|
67
|
+
const said = typeof content === "string" ? content : (content ?? []).map((c: any) => (typeof c?.text === "string" ? c.text : "")).join(" ");
|
|
68
|
+
if (said.trim()) first = said.trim().slice(0, 200);
|
|
69
|
+
}
|
|
70
|
+
return { id: header.id, cwd: typeof header.cwd === "string" ? header.cwd : "", startedAt: typeof header.timestamp === "string" ? header.timestamp : undefined, name, first, messages, truncated };
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* One line of preview: newlines become spaces, and it is cut at `max` *characters* — not bytes,
|
|
75
|
+
* so a CJK character or an emoji at the edge is kept whole — with an ellipsis when something was cut.
|
|
76
|
+
*/
|
|
77
|
+
export function previewText(text: string, max = 80): string {
|
|
78
|
+
const flat = text.replace(/\s*\n\s*/g, " ").trim();
|
|
79
|
+
const chars = [...flat];
|
|
80
|
+
return chars.length > max ? `${chars.slice(0, max).join("")}…` : flat;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* The automation badge of a session listing: the plain jobs bound to this session (`sessionId`)
|
|
85
|
+
* and the jobs and rules it created. `2 cron, 1 trigger`; `automation off` when everything
|
|
86
|
+
* it has is disabled; nothing when it has nothing.
|
|
87
|
+
*/
|
|
88
|
+
export function automationBadge(sessionId: string, jobs: LoopJob[], rules: DynamicTriggerRule[]): string | undefined {
|
|
89
|
+
const mine = jobs.filter((j) => j.sessionId === sessionId || j.createdBy?.sessionId === sessionId);
|
|
90
|
+
const rulesMine = rules.filter((r) => r.createdBy?.sessionId === sessionId);
|
|
91
|
+
if (!mine.length && !rulesMine.length) return undefined;
|
|
92
|
+
const parts: string[] = [];
|
|
93
|
+
const cron = mine.filter((j) => j.enabled).length;
|
|
94
|
+
const trig = rulesMine.filter((r) => r.enabled).length;
|
|
95
|
+
if (cron) parts.push(`${cron} cron`);
|
|
96
|
+
if (trig) parts.push(`${trig} trigger`);
|
|
97
|
+
return parts.length ? parts.join(", ") : "automation off";
|
|
98
|
+
}
|
package/src/store.ts
CHANGED
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
* state/<job-id>.md loop state — the "state spine", human-readable
|
|
8
8
|
* inbox.jsonl the triage inbox (see inbox.ts)
|
|
9
9
|
* runs.jsonl run log, bounded
|
|
10
|
-
* scheduler
|
|
10
|
+
* scheduler.json which process on this machine currently owns the timer
|
|
11
11
|
* *.lock mkdir locks
|
|
12
12
|
*/
|
|
13
13
|
import * as fs from "node:fs";
|
|
@@ -49,10 +49,10 @@ export interface LoopJob {
|
|
|
49
49
|
/** Fire once on startup if a tick was missed while no pi was running (default true). */
|
|
50
50
|
catchUp: boolean;
|
|
51
51
|
timeoutMs?: number;
|
|
52
|
+
/** The recipe that created this job (`/recipe add`), so `/recipe list|remove` can find it. */
|
|
53
|
+
recipe?: string;
|
|
52
54
|
createdAt: string;
|
|
53
55
|
createdBy?: { sessionId?: string; cwd: string };
|
|
54
|
-
/** Host the job belongs to (shared $HOME across machines): other hosts ignore it. Missing = any host (pre-0.1.3). */
|
|
55
|
-
host?: string;
|
|
56
56
|
/** non-stateful jobs only: the session that receives the message. */
|
|
57
57
|
sessionId?: string;
|
|
58
58
|
lastDueAt?: string;
|
|
@@ -217,22 +217,11 @@ export function newId(prefix: string): string {
|
|
|
217
217
|
return `${prefix}-${randomBytes(16).toString("hex")}`;
|
|
218
218
|
}
|
|
219
219
|
|
|
220
|
-
/**
|
|
221
|
-
* A hostname as one filename component: `scheduler.<tag>.json`, `next-runs.<tag>.json`, one
|
|
222
|
-
* presence file per pi. Two machines sharing a `$HOME` must not write over each other, and a
|
|
223
|
-
* hostname may contain characters a path segment may not.
|
|
224
|
-
*
|
|
225
|
-
* `src/web.mjs` spells the same expression out again because it has no imports, and `src/presence.ts`
|
|
226
|
-
* has its own copy; `test/store.test.ts` pins those spellings to this one.
|
|
227
|
-
*/
|
|
228
|
-
export function hostFileTag(host: string = os.hostname()): string {
|
|
229
|
-
return host.replace(/[^A-Za-z0-9._-]/g, "_");
|
|
230
|
-
}
|
|
231
|
-
|
|
232
220
|
/**
|
|
233
221
|
* On-disk shape of `jobs.json`, bumped whenever a field becomes load-bearing, so an older pi-loops sharing this directory refuses
|
|
234
|
-
* the file instead of silently rewriting it without that field. Version 2
|
|
235
|
-
*
|
|
222
|
+
* the file instead of silently rewriting it without that field. Version 2 added `verify`/`checkerModel`,
|
|
223
|
+
* `timeoutMs` and `consecutiveFailures` (and a `host` that gated dispatch until 0.19.0: pi-loops runs
|
|
224
|
+
* on one machine, so the field is ignored when read and dropped when written).
|
|
236
225
|
*/
|
|
237
226
|
export const JOBS_FILE_VERSION = 2;
|
|
238
227
|
|
|
@@ -315,6 +304,9 @@ export class JobStore {
|
|
|
315
304
|
throw new Error(`${this.jobsFile}: version ${parsed.version} was written by a newer pi-loops (this build understands ${JOBS_FILE_VERSION}); upgrade pi-loops`);
|
|
316
305
|
}
|
|
317
306
|
if (!Array.isArray(parsed?.jobs)) throw new Error(`${this.jobsFile}: missing "jobs" array`);
|
|
307
|
+
// Until 0.19.0 a job carried the hostname that created it and other machines ignored it.
|
|
308
|
+
// pi-loops runs on one machine: the stamp is dropped here and gone on the next write.
|
|
309
|
+
for (const j of parsed.jobs) delete (j as { host?: unknown }).host;
|
|
318
310
|
return parsed.jobs;
|
|
319
311
|
}
|
|
320
312
|
|
package/src/tools.ts
CHANGED
|
@@ -12,6 +12,7 @@ import { previewRedacted } from "./redact.ts";
|
|
|
12
12
|
import { computeNext, formatLocal, formatSchedule, parseSchedule, stamp } from "./schedule.ts";
|
|
13
13
|
import type { LoopScheduler, SessionSnapshot } from "./scheduler.ts";
|
|
14
14
|
import { MAX_PROMPT_BYTES, type LoopJob, newId, owningSessionId, resolveJobRef } from "./store.ts";
|
|
15
|
+
import { asleepNote, ownerSession, runsIn } from "./job-owner.ts";
|
|
15
16
|
import type { TriggerRuntime } from "./trigger-runtime.ts";
|
|
16
17
|
import { withinProject } from "./presence.ts";
|
|
17
18
|
import { type TriggerStore, looksLikeFixedScheduleRequest, parseTriggerRule, resolveRuleRef, type DynamicTriggerRule } from "./triggers.ts";
|
|
@@ -54,6 +55,8 @@ export interface CreateJobInput {
|
|
|
54
55
|
catchUp?: boolean;
|
|
55
56
|
verify?: boolean;
|
|
56
57
|
checkerModel?: string;
|
|
58
|
+
/** Set by `/recipe add`: which recipe this job belongs to. */
|
|
59
|
+
recipe?: string;
|
|
57
60
|
}
|
|
58
61
|
|
|
59
62
|
export interface JobScope {
|
|
@@ -147,10 +150,10 @@ export async function createLoopJob(host: Pick<ToolHost, "scheduler" | "session"
|
|
|
147
150
|
checkerModel: input.stateful && input.verify ? input.checkerModel : undefined,
|
|
148
151
|
catchUp: input.catchUp ?? input.stateful,
|
|
149
152
|
timeoutMs: input.timeoutMs,
|
|
153
|
+
recipe: input.recipe,
|
|
150
154
|
createdAt: stamp(),
|
|
151
155
|
// A sub-agent schedules on behalf of the session that runs it.
|
|
152
156
|
createdBy: { sessionId: scope?.parentSessionId ?? host.session().sessionId, cwd: scope?.parentCwd ?? host.session().cwd },
|
|
153
|
-
host: os.hostname(),
|
|
154
157
|
sessionId: owningSessionId(input.stateful, host.session().sessionId, scope?.parentSessionId),
|
|
155
158
|
runCount: 0,
|
|
156
159
|
skippedOverlap: 0,
|
|
@@ -169,18 +172,12 @@ function renderTriggerRulesForTool(rules: ReturnType<TriggerStore["load"]>, host
|
|
|
169
172
|
}
|
|
170
173
|
|
|
171
174
|
/**
|
|
172
|
-
*
|
|
173
|
-
*
|
|
174
|
-
*
|
|
175
|
-
* docs/loops.md says as much; `/cron` obeyed it and the model-facing list did not.
|
|
175
|
+
* When the job next runs *here*, or undefined: disabled, or a plain job of a session this process
|
|
176
|
+
* does not hold. The list is filtered the way dispatch filters — a next run for a job nothing here
|
|
177
|
+
* will dispatch is a time nothing intends to keep, and a model reads it as a promise.
|
|
176
178
|
*/
|
|
177
|
-
function
|
|
178
|
-
|
|
179
|
-
}
|
|
180
|
-
|
|
181
|
-
/** When the job next runs, or undefined when nothing here will run it. */
|
|
182
|
-
function nextRunForTool(job: LoopJob, now: number): number | undefined {
|
|
183
|
-
if (!job.enabled || !runsHere(job)) return undefined;
|
|
179
|
+
function nextRunForTool(job: LoopJob, now: number, sessionId: string | undefined): number | undefined {
|
|
180
|
+
if (!job.enabled || !runsIn(job, sessionId)) return undefined;
|
|
184
181
|
return computeNext({ schedule: job.schedule, createdAt: Date.parse(job.createdAt), lastFiredAt: job.lastFiredAt ? Date.parse(job.lastFiredAt) : undefined }, now);
|
|
185
182
|
}
|
|
186
183
|
|
|
@@ -191,8 +188,9 @@ function renderCronJobsForTool(jobs: LoopJob[], host: Pick<ToolHost, "session">)
|
|
|
191
188
|
const lines = [`cron jobs: ${jobs.length}`];
|
|
192
189
|
for (const job of jobs) {
|
|
193
190
|
lines.push(`- ${job.id}${job.name ? ` "${job.name}"` : ""} [${job.enabled ? "enabled" : "disabled"}${job.stateful ? ", stateful" : ""}${job.verify ? ", verify" : ""}] schedule: ${formatSchedule(job.schedule)} action: ${previewRedacted(job.prompt, 120)}${job.cwd !== host.session().cwd ? ` cwd: ${job.cwd}` : ""}`);
|
|
194
|
-
|
|
195
|
-
|
|
191
|
+
const asleep = asleepNote(job, host.session().sessionId);
|
|
192
|
+
if (asleep) lines.push(` ${asleep} (no next run here)`);
|
|
193
|
+
const next = nextRunForTool(job, now, host.session().sessionId);
|
|
196
194
|
if (next) lines.push(` next_run: ${stamp(next)}`);
|
|
197
195
|
if (job.running) lines.push(` running_run_id: ${job.running.runId}`);
|
|
198
196
|
if (job.lastError) lines.push(` last_error: ${previewRedacted(job.lastError, 120)}`);
|
|
@@ -247,7 +245,7 @@ export function automationTools(scope: ToolScope, host: ToolHost): ToolDefinitio
|
|
|
247
245
|
const reason = fromSpec ? "create dynamic trigger from `spec` field" : "create dynamic trigger from `condition` + `action` fields";
|
|
248
246
|
const denied = await host.confirmTool(ctx, { label: "create dynamic trigger", tool: "new_trigger", reason, preview: `when ${previewRedacted(condition, 80)} -> ${previewRedacted(action, 80)}`, args: params }, scope.hop);
|
|
249
247
|
if (denied) return deny(denied);
|
|
250
|
-
const rule = await host.triggers.store.add({ condition, action, fireOnce: params.fire_once ?? true, promoteToChat: params.promote_to_chat ?? false, cwd: host.session().cwd, sessionId: host.session().sessionId, model: host.session().model, thinking: host.session().thinking
|
|
248
|
+
const rule = await host.triggers.store.add({ condition, action, fireOnce: params.fire_once ?? true, promoteToChat: params.promote_to_chat ?? false, cwd: host.session().cwd, sessionId: host.session().sessionId, model: host.session().model, thinking: host.session().thinking });
|
|
251
249
|
host.refreshBadge();
|
|
252
250
|
return {
|
|
253
251
|
content: [{ type: "text", text: `created dynamic trigger ${rule.id}\ncondition: ${rule.condition}\naction: ${rule.action}\nfire_once: ${rule.fireOnce}\npromote_to_chat: ${rule.promoteToChat}\n(checked every ${host.triggers.pollIntervalSecs}s by a background sub-agent)` }],
|
|
@@ -339,7 +337,7 @@ export function automationTools(scope: ToolScope, host: ToolHost): ToolDefinitio
|
|
|
339
337
|
verify: Type.Optional(Type.Boolean({ description: "Maker/checker: a second adversarial sub-agent verifies each finding before it enters the inbox (stateful jobs only, default false). Use when the user asks for verified, double-checked, or high-precision findings." })),
|
|
340
338
|
name: Type.Optional(Type.String({ description: "Short unique label (letters, digits, . _ -)." })),
|
|
341
339
|
cwd: Type.Optional(Type.String({ description: "Directory a stateful job's sub-agent runs in. Default: current project." })),
|
|
342
|
-
catch_up: Type.Optional(Type.Boolean({ description: "Run once
|
|
340
|
+
catch_up: Type.Optional(Type.Boolean({ description: "Run once for a tick that was missed: a loop when a pi next takes the clock, a plain job when the session that created it is next opened (default: true for stateful jobs, false for plain jobs)." })),
|
|
343
341
|
}),
|
|
344
342
|
async execute(_id, params) {
|
|
345
343
|
const schedule = parseSchedule(params.schedule);
|
|
@@ -362,7 +360,7 @@ export function automationTools(scope: ToolScope, host: ToolHost): ToolDefinitio
|
|
|
362
360
|
// Three lines — what was created, when it runs, what it will do — then where its output goes.
|
|
363
361
|
return {
|
|
364
362
|
content: [{ type: "text", text: `created cron job ${job.id}${job.name ? ` "${job.name}"` : ""}\nschedule: ${formatSchedule(job.schedule)}\naction: ${previewRedacted(job.prompt, 120)}\n${job.stateful ? "[stateful] " : ""}next run ${next ? stamp(next) : "—"}. ${where}` }],
|
|
365
|
-
details: { id: job.id, name: job.name, schedule: formatSchedule(job.schedule), action: job.prompt, enabled: job.enabled, stateful: job.stateful, verify: job.verify ?? false, scope: "machine", next_run: next ? stamp(next) : undefined, audit_entry_id: auditEntryId },
|
|
363
|
+
details: { id: job.id, name: job.name, schedule: formatSchedule(job.schedule), action: job.prompt, enabled: job.enabled, stateful: job.stateful, verify: job.verify ?? false, scope: job.stateful ? "machine" : "session", owner_session: ownerSession(job), next_run: next ? stamp(next) : undefined, audit_entry_id: auditEntryId },
|
|
366
364
|
};
|
|
367
365
|
},
|
|
368
366
|
});
|
|
@@ -380,8 +378,8 @@ export function automationTools(scope: ToolScope, host: ToolHost): ToolDefinitio
|
|
|
380
378
|
const jobs = everywhere ? host.scheduler.store.load() : host.scheduler.store.load().filter((j) => sameProject(j.cwd, listCwd));
|
|
381
379
|
const text = `${renderCronJobsForTool(jobs, host)}\ninbox: ${host.scheduler.inbox.newCount()} new finding(s)`;
|
|
382
380
|
const nowMs = Date.now();
|
|
383
|
-
const nextRun = (j: LoopJob) => nextRunForTool(j, nowMs);
|
|
384
|
-
return { content: [{ type: "text", text }], details: { count: jobs.length, scope: everywhere ? "machine" : listCwd, storage_path: host.scheduler.store.jobsFile, jobs: jobs.map((j) => ({ id: j.id, name: j.name, schedule: formatSchedule(j.schedule), action_preview: previewRedacted(j.prompt, 120), enabled: j.enabled, stateful: j.stateful, verify: j.verify ?? false, cwd: j.cwd, running_run_id: j.running?.runId, last_due_at: j.lastDueAt, last_fired_at: j.lastFiredAt, last_completed_at: j.lastCompletedAt, last_error: j.lastError ? previewRedacted(j.lastError, 120) : undefined, skipped_overlap_count: j.skippedOverlap, next_run: (() => { const n = nextRun(j); return n ? stamp(n) : undefined; })(), created_at: j.createdAt })) } };
|
|
381
|
+
const nextRun = (j: LoopJob) => nextRunForTool(j, nowMs, host.session().sessionId);
|
|
382
|
+
return { content: [{ type: "text", text }], details: { count: jobs.length, scope: everywhere ? "machine" : listCwd, storage_path: host.scheduler.store.jobsFile, jobs: jobs.map((j) => ({ id: j.id, name: j.name, schedule: formatSchedule(j.schedule), action_preview: previewRedacted(j.prompt, 120), enabled: j.enabled, stateful: j.stateful, verify: j.verify ?? false, cwd: j.cwd, running_run_id: j.running?.runId, last_due_at: j.lastDueAt, last_fired_at: j.lastFiredAt, last_completed_at: j.lastCompletedAt, last_error: j.lastError ? previewRedacted(j.lastError, 120) : undefined, skipped_overlap_count: j.skippedOverlap, next_run: (() => { const n = nextRun(j); return n ? stamp(n) : undefined; })(), owner_session: ownerSession(j), asleep: !runsIn(j, host.session().sessionId) || undefined, created_at: j.createdAt })) } };
|
|
385
383
|
},
|
|
386
384
|
});
|
|
387
385
|
|
package/src/trigger-runtime.ts
CHANGED
|
@@ -316,8 +316,7 @@ export class TriggerRuntime {
|
|
|
316
316
|
* `ownIfUnowned` is passed straight to `ownsRule`: it only adds the rules nobody owns.
|
|
317
317
|
*/
|
|
318
318
|
private rulesFor(cwd: string | undefined, ownIfUnowned: boolean): { applicable: DynamicTriggerRule[]; owned: DynamicTriggerRule[] } {
|
|
319
|
-
const
|
|
320
|
-
const all = this.store.load().filter((r) => r.enabled && (!r.host || r.host === host));
|
|
319
|
+
const all = this.store.load().filter((r) => r.enabled);
|
|
321
320
|
const applicable = cwd ? all.filter((r) => withinProject(r.cwd, cwd)) : all;
|
|
322
321
|
return { applicable, owned: applicable.filter((r) => this.ownsRule(r, ownIfUnowned)) };
|
|
323
322
|
}
|
|
@@ -333,7 +332,7 @@ export class TriggerRuntime {
|
|
|
333
332
|
// backlog, not the leader's, so leadership does not gate them.
|
|
334
333
|
this.retryPending();
|
|
335
334
|
const host = this.self?.host ?? os.hostname();
|
|
336
|
-
const rules = this.store.load().filter((r) => r.enabled &&
|
|
335
|
+
const rules = this.store.load().filter((r) => r.enabled && this.ownsRule(r, leader) && !this.inBackoff(r, now));
|
|
337
336
|
if (!rules.length) return;
|
|
338
337
|
const byCwd = new Map<string, DynamicTriggerRule[]>();
|
|
339
338
|
for (const r of rules) byCwd.set(r.cwd, [...(byCwd.get(r.cwd) ?? []), r]);
|
|
@@ -386,8 +385,7 @@ export class TriggerRuntime {
|
|
|
386
385
|
if (trigger.source.kind === "mcp" && delivery === "sub_agent" && !trigger.cwd && !this.getSession().cwd) {
|
|
387
386
|
// No project of our own (the headless host): evaluate the push once per project that has
|
|
388
387
|
// rules, each in that project, as tick() does — never all rules at once in $HOME.
|
|
389
|
-
const
|
|
390
|
-
const cwds = [...new Set(this.store.load().filter((r) => r.enabled && (!r.host || r.host === host)).map((r) => r.cwd))];
|
|
388
|
+
const cwds = [...new Set(this.store.load().filter((r) => r.enabled).map((r) => r.cwd))];
|
|
391
389
|
if (!cwds.length) return await this.admit(trigger, delivery); // audited as no_rules
|
|
392
390
|
const outcomes = await Promise.all(cwds.map((cwd, i) => this.admit({ ...trigger, cwd, traceId: i ? newTraceId() : trigger.traceId }, delivery)));
|
|
393
391
|
return outcomes.find(Boolean);
|
package/src/triggers.ts
CHANGED
|
@@ -35,8 +35,6 @@ export interface DynamicTriggerRule {
|
|
|
35
35
|
thinking?: string;
|
|
36
36
|
/** Per-rule cap on the check/action sub-agent; default `[triggers] run_timeout_secs`. */
|
|
37
37
|
timeoutMs?: number;
|
|
38
|
-
/** Host the rule belongs to (shared $HOME): other hosts ignore it. Missing = any host (pre-0.1.3). */
|
|
39
|
-
host?: string;
|
|
40
38
|
createdBy?: { sessionId?: string };
|
|
41
39
|
/** Checks that failed in a row (a check covers every rule it evaluated). Cleared by one that completes; drives the poll backoff in the trigger runtime, as `JobStore`'s field of the same name drives the scheduler's. */
|
|
42
40
|
consecutiveFailures?: number;
|
|
@@ -262,6 +260,8 @@ export class TriggerStore {
|
|
|
262
260
|
if (!text.trim()) return [];
|
|
263
261
|
const parsed = JSON.parse(text) as RulesFile;
|
|
264
262
|
if (!Array.isArray(parsed?.rules)) throw new Error(`${this.rulesFile}: missing "rules" array`);
|
|
263
|
+
// The pre-0.19.0 host stamp, dropped the way jobs.json drops it.
|
|
264
|
+
for (const r of parsed.rules) delete (r as { host?: unknown }).host;
|
|
265
265
|
return parsed.rules;
|
|
266
266
|
}
|
|
267
267
|
|
|
@@ -274,7 +274,7 @@ export class TriggerStore {
|
|
|
274
274
|
});
|
|
275
275
|
}
|
|
276
276
|
|
|
277
|
-
async add(input: { condition: string; action: string; fireOnce?: boolean; promoteToChat?: boolean; cwd: string; sessionId?: string; model?: string; thinking?: string
|
|
277
|
+
async add(input: { condition: string; action: string; fireOnce?: boolean; promoteToChat?: boolean; cwd: string; sessionId?: string; model?: string; thinking?: string }): Promise<DynamicTriggerRule> {
|
|
278
278
|
const condition = input.condition.trim();
|
|
279
279
|
const action = input.action.trim();
|
|
280
280
|
if (!condition || !action) throw new Error("trigger rule needs both a condition and an action");
|
|
@@ -289,7 +289,6 @@ export class TriggerStore {
|
|
|
289
289
|
cwd: input.cwd,
|
|
290
290
|
model: input.model,
|
|
291
291
|
thinking: input.thinking,
|
|
292
|
-
host: input.host,
|
|
293
292
|
createdBy: { sessionId: input.sessionId },
|
|
294
293
|
};
|
|
295
294
|
await this.mutate((rules) => rules.push(rule));
|
package/src/web.mjs
CHANGED
|
@@ -452,8 +452,7 @@ function nextRuns() {
|
|
|
452
452
|
// This machine's file. Leadership is per host, and so is the clock a cron expression is matched
|
|
453
453
|
// against, so on a shared `$HOME` each machine keeps its own answers rather than overwriting the
|
|
454
454
|
// other's with times computed in a different timezone.
|
|
455
|
-
const
|
|
456
|
-
const doc = readJson(path.join(LOOPS_DIR, `next-runs.${safe}.json`), { next: {} });
|
|
455
|
+
const doc = readJson(path.join(LOOPS_DIR, "next-runs.json"), { next: {} });
|
|
457
456
|
return doc?.next && typeof doc.next === "object" ? doc.next : {};
|
|
458
457
|
}
|
|
459
458
|
|
|
@@ -467,16 +466,13 @@ function automation(cwd) {
|
|
|
467
466
|
// The directory is the evidence, not any one file in it: a project with rules and no cron jobs
|
|
468
467
|
// has no jobs.json at all, and reporting "pi-loops not found" there would be a lie.
|
|
469
468
|
if (!fs.existsSync(LOOPS_DIR)) return { installed: false, dir: LOOPS_DIR };
|
|
469
|
+
// A plain job runs only in the session that created it; the id in this session's header says
|
|
470
|
+
// whether that is this one, and the panel says "resume it" rather than promising a time.
|
|
471
|
+
const currentSession = sessionFile ? readHead(sessionFile)?.id : undefined;
|
|
470
472
|
const jobsFile = readJson(path.join(LOOPS_DIR, "jobs.json"), { jobs: [] });
|
|
471
473
|
/**
|
|
472
|
-
* This project's,
|
|
473
|
-
*
|
|
474
|
-
* The host used to be part of this test, so a job stamped with another hostname vanished from
|
|
475
|
-
* the panel — and a hostname changes on its own: a rebuilt container, a machine renamed by
|
|
476
|
-
* DHCP, a restored backup. What you got was a job sitting enabled in `jobs.json`, never running,
|
|
477
|
-
* invisible in the one place you would look. The terminal always listed it and said whose it
|
|
478
|
-
* was; so does this, and the count of everything in other projects goes with it, because a list
|
|
479
|
-
* that silently drops things is worse than a longer list.
|
|
474
|
+
* This project's jobs, and the count of everything in other projects goes with it, because a
|
|
475
|
+
* list that silently drops things is worse than a longer list.
|
|
480
476
|
*/
|
|
481
477
|
const inThisProject = (j) => sameProject(j.cwd, cwd);
|
|
482
478
|
const allJobs = jobsFile.jobs ?? [];
|
|
@@ -492,7 +488,6 @@ function automation(cwd) {
|
|
|
492
488
|
lastError: j.lastError,
|
|
493
489
|
running: !!j.running,
|
|
494
490
|
// Set only when it is not this machine's: the panel says so, and nothing else has to guess.
|
|
495
|
-
otherHost: j.host && j.host !== HOST ? j.host : undefined,
|
|
496
491
|
// What to type to fix it. The terminal's version of this line says `/cron set <n>`, where n
|
|
497
492
|
// is the position in a numbered list — which this panel does not have, so telling someone to
|
|
498
493
|
// use one was telling them to go and find a terminal. A name or a full id resolves anywhere.
|
|
@@ -502,7 +497,9 @@ function automation(cwd) {
|
|
|
502
497
|
// follows. A time that has already passed is a stale answer rather than a next run (the job
|
|
503
498
|
// fired and no tick has rewritten the file yet), and saying nothing beats saying something
|
|
504
499
|
// visibly wrong.
|
|
505
|
-
next: j.enabled &&
|
|
500
|
+
next: j.enabled && (j.stateful || (currentSession && j.sessionId === currentSession)) ? futureOnly(nexts[j.id]) : undefined,
|
|
501
|
+
// Set for a plain job of a session that is not this one: it is asleep, and this is what wakes it.
|
|
502
|
+
asleep: !j.stateful && !(currentSession && j.sessionId === currentSession) ? "session " + String(j.sessionId || "?").slice(0, 8) + " — resume it to run" : undefined,
|
|
506
503
|
}));
|
|
507
504
|
const allRules = readJson(path.join(LOOPS_DIR, "triggers.json"), { rules: [] }).rules ?? [];
|
|
508
505
|
const rules = allRules
|
|
@@ -547,6 +544,8 @@ const PAGE_COMMANDS = [
|
|
|
547
544
|
{ name: "clear", description: "start a new session — this one stays on disk, /resume brings it back" },
|
|
548
545
|
{ name: "new", description: "start a new session — this one stays on disk, /resume brings it back" },
|
|
549
546
|
{ name: "resume", description: "go back to an earlier session in this project" },
|
|
547
|
+
{ name: "sessions", description: "list this project's sessions: id, when, what was first said" },
|
|
548
|
+
{ name: "session", description: "export [path] | import <path> — the .pisession archive commands" },
|
|
550
549
|
{ name: "compact", description: "compact the context; anything after it steers the summary" },
|
|
551
550
|
];
|
|
552
551
|
|
|
@@ -1363,7 +1362,11 @@ const server = http.createServer(async (req, res) => {
|
|
|
1363
1362
|
// Submitting while a turn runs queues instead of racing it, as the TUI does.
|
|
1364
1363
|
const type = mode === "steer" ? "steer" : mode === "follow_up" ? "follow_up" : "prompt";
|
|
1365
1364
|
const message = expandMentions(text ?? "", sessionCwd);
|
|
1366
|
-
|
|
1365
|
+
// pi answers a prompt command when the command has *finished*, and a slash command may
|
|
1366
|
+
// sit in a dialog for as long as a person takes to read what it shows (a setup script,
|
|
1367
|
+
// say). Sixty seconds reported that as "timed out" while the command went on to succeed.
|
|
1368
|
+
const patience = message.trimStart().startsWith("/") ? 15 * 60_000 : undefined;
|
|
1369
|
+
return void json(res, await rpc({ type, message, ...(images?.length ? { images } : {}) }, patience));
|
|
1367
1370
|
}
|
|
1368
1371
|
if (url.pathname === "/model" && req.method === "POST") {
|
|
1369
1372
|
// Recorded below, once pi has accepted it.
|
|
@@ -1727,7 +1730,7 @@ function liveHostPid() {
|
|
|
1727
1730
|
* their automation is dead when it is not.
|
|
1728
1731
|
*/
|
|
1729
1732
|
function automationToKeepRunning() {
|
|
1730
|
-
const ours = (x) => x.enabled
|
|
1733
|
+
const ours = (x) => x.enabled;
|
|
1731
1734
|
const jobs = (readJson(path.join(LOOPS_DIR, "jobs.json"), { jobs: [] }).jobs ?? []).filter((j) => ours(j) && j.stateful);
|
|
1732
1735
|
const rules = (readJson(path.join(LOOPS_DIR, "triggers.json"), { rules: [] }).rules ?? []).filter(ours);
|
|
1733
1736
|
return jobs.length + rules.length;
|
|
@@ -3425,9 +3428,9 @@ function renderSidebar(s) {
|
|
|
3425
3428
|
'<button data-run="' + safeText(j.id) + '">run</button></div>' +
|
|
3426
3429
|
'<div class="m">' + safeText(str(j.prompt).slice(0, 90)) + "</div>" +
|
|
3427
3430
|
'<div class="m">' + (j.running ? "running · " : "") + "runs " + num(j.runCount) + (j.next ? " · next " + safeText(whenNext(j.next)) : "") + "</div>" +
|
|
3431
|
+
(j.asleep ? '<div class="m" style="color:#c93">' + safeText(j.asleep) + "</div>" : "") +
|
|
3428
3432
|
// A job belonging to a hostname this machine no longer has: it is listed, because it exists,
|
|
3429
3433
|
// and it says why nothing is happening rather than leaving you to find out from the silence.
|
|
3430
|
-
(j.otherHost ? '<div class="m" style="color:#c93">other host: ' + safeText(j.otherHost) + " — run <b>/cron set " + safeText(j.ref) + " --host here</b></div>" : "") +
|
|
3431
3434
|
(j.lastError ? '<div class="m" style="color:#c66">' + safeText(str(j.lastError).slice(0, 120)) + "</div>" : "") + "</div>";
|
|
3432
3435
|
}
|
|
3433
3436
|
for (const r of a.rules) {
|
|
@@ -3784,7 +3787,7 @@ $("input").addEventListener("paste", (e) => {
|
|
|
3784
3787
|
$("composer").onsubmit = async (e) => {
|
|
3785
3788
|
e.preventDefault();
|
|
3786
3789
|
const input = $("input");
|
|
3787
|
-
|
|
3790
|
+
let text = input.value.trim();
|
|
3788
3791
|
if (!text && !images.length) return;
|
|
3789
3792
|
input.value = ""; hidePop();
|
|
3790
3793
|
if (text) { sentPrompts.push(text); if (sentPrompts.length > 200) sentPrompts.shift(); }
|
|
@@ -3793,6 +3796,9 @@ $("composer").onsubmit = async (e) => {
|
|
|
3793
3796
|
// instead of being sent: anything attached stays in the tray, and a session about to be replaced
|
|
3794
3797
|
// is not given a message it would carry nowhere.
|
|
3795
3798
|
if (pageCommand(text)) return;
|
|
3799
|
+
// '/session export|import' are the archive commands here. pi's own '/session' is a terminal
|
|
3800
|
+
// command that does not exist over rpc, so the two-word spelling is free to mean the extension's.
|
|
3801
|
+
text = text.replace(/^\/session\s+(export|import)\b/i, (_m, sub) => "/session-" + sub.toLowerCase());
|
|
3796
3802
|
const payload = { text, images, mode: busy ? "follow_up" : undefined };
|
|
3797
3803
|
const shown = text + (images.length ? "\n[" + images.length + " image(s)]" : "");
|
|
3798
3804
|
if (!busy) {
|
|
@@ -3914,14 +3920,31 @@ function pageCommand(text) {
|
|
|
3914
3920
|
if (!m) return false;
|
|
3915
3921
|
const name = m[1].toLowerCase();
|
|
3916
3922
|
const rest = m[2].trim();
|
|
3917
|
-
if (name
|
|
3923
|
+
if (name === "session" && /^(export|import)\b/i.test(rest)) return false; // an extension command, sent as one
|
|
3924
|
+
if (name !== "clear" && name !== "new" && name !== "resume" && name !== "compact" && name !== "sessions" && name !== "session") return false;
|
|
3918
3925
|
row("user", "you", text.trim());
|
|
3919
3926
|
if (name === "compact") compactNow(rest);
|
|
3920
3927
|
else if (name === "resume") openSessions();
|
|
3928
|
+
else if (name === "sessions") listSessionsHere();
|
|
3929
|
+
else if (name === "session") row("system", "session", "usage: /session export [path] [--exclude-triggers] | /session import <path>\n/sessions lists this project's sessions; the header shows this one's model, cost and context.");
|
|
3921
3930
|
else swap("/session/new", {}, "new session — the one you left is under resume");
|
|
3922
3931
|
return true;
|
|
3923
3932
|
}
|
|
3924
3933
|
|
|
3934
|
+
/* /sessions: one line per session, newest first — short id, when it started, what was first said. No backticks in here: this is inside the page template. */
|
|
3935
|
+
async function listSessionsHere() {
|
|
3936
|
+
const r = await api("/sessions");
|
|
3937
|
+
const list = r.sessions || [];
|
|
3938
|
+
if (!list.length) return row("system", "sessions", "(no sessions for this project yet)");
|
|
3939
|
+
const lines = list.map((s) => {
|
|
3940
|
+
const when = String(s.startedAt || "").slice(0, 16);
|
|
3941
|
+
const said = plain(s.name || s.first || "").replace(/\s+/g, " ");
|
|
3942
|
+
const chars = [...said];
|
|
3943
|
+
return String(s.id).slice(0, 16) + " " + when + (s.current ? " [this one]" : "") + " " + (chars.length > 80 ? chars.slice(0, 80).join("") + "…" : said);
|
|
3944
|
+
});
|
|
3945
|
+
row("system", "sessions", lines.join("\n"));
|
|
3946
|
+
}
|
|
3947
|
+
|
|
3925
3948
|
$("compact").onclick = () => compactNow("");
|
|
3926
3949
|
$("clear").onclick = () => swap("/session/new", {}, "new session — the one you left is under resume");
|
|
3927
3950
|
|