@alphacoder-v0/pi-loops 0.24.1 → 0.24.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 +83 -0
- package/README.md +2 -3
- package/docs/cli.md +9 -5
- package/docs/configuration.md +1 -1
- package/docs/design.md +3 -2
- package/docs/triggers.md +11 -7
- package/docs/web-ui-parity.md +7 -6
- package/package.json +1 -1
- package/skills/pi-loops/SKILL.md +2 -0
- package/src/activity.ts +4 -3
- package/src/cli.ts +28 -19
- package/src/engine.ts +4 -3
- package/src/host-control-channel.ts +2 -1
- package/src/host-control.ts +16 -1
- package/src/host-runtime.ts +11 -11
- package/src/inbox-card.ts +2 -2
- package/src/inbox.ts +7 -0
- package/src/job-lines.ts +1 -5
- package/src/job-signal.ts +2 -1
- package/src/mcp.ts +2 -0
- package/src/page-children.ts +3 -58
- package/src/page-server.ts +71 -24
- package/src/path-precheck.ts +2 -0
- package/src/paths.ts +15 -2
- package/src/pi-loops.ts +8 -7
- package/src/plural.ts +4 -0
- package/src/run-card.ts +3 -2
- package/src/schedule.ts +20 -0
- package/src/seen.ts +1 -1
- package/src/share.ts +1 -1
- package/src/store.ts +11 -2
- package/src/tools.ts +18 -19
- package/src/triggers.ts +2 -3
- package/src/trust.ts +1 -11
- package/src/web.mjs +68 -37
package/src/schedule.ts
CHANGED
|
@@ -127,6 +127,26 @@ export function formatSchedule(schedule: Schedule): string {
|
|
|
127
127
|
}
|
|
128
128
|
}
|
|
129
129
|
|
|
130
|
+
const DAY_NAMES = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"];
|
|
131
|
+
|
|
132
|
+
/**
|
|
133
|
+
* A schedule as the page's Loops pane says it: the common cron shapes in words (`every hour`,
|
|
134
|
+
* `daily 09:00`, `weekdays 09:00`, `weekly Mon 09:00`), anything else as `formatSchedule` says it.
|
|
135
|
+
* A person who typed "daily" reads that back, not `0 9 * * *`.
|
|
136
|
+
*/
|
|
137
|
+
export function scheduleWords(schedule: Schedule): string {
|
|
138
|
+
if (schedule.kind !== "cron") return formatSchedule(schedule);
|
|
139
|
+
const [m, h, dom, mon, dow, ...rest] = schedule.expr.trim().split(/\s+/);
|
|
140
|
+
if (rest.length || dom !== "*" || mon !== "*") return schedule.expr;
|
|
141
|
+
if (m === "0" && h === "*" && dow === "*") return "every hour";
|
|
142
|
+
if (!/^\d{1,2}$/.test(m) || !/^\d{1,2}$/.test(h)) return schedule.expr;
|
|
143
|
+
const at = `${h.padStart(2, "0")}:${m.padStart(2, "0")}`;
|
|
144
|
+
if (dow === "*") return `daily ${at}`;
|
|
145
|
+
if (dow === "1-5") return `weekdays ${at}`;
|
|
146
|
+
if (/^[0-7]$/.test(dow)) return `weekly ${DAY_NAMES[Number(dow)]} ${at}`;
|
|
147
|
+
return schedule.expr;
|
|
148
|
+
}
|
|
149
|
+
|
|
130
150
|
/**
|
|
131
151
|
* The machine's offset from UTC at that instant, as `+08:00`.
|
|
132
152
|
*
|
package/src/seen.ts
CHANGED
|
@@ -5,6 +5,7 @@ import { Inbox, type InboxEntry } from "./inbox.ts";
|
|
|
5
5
|
import { stamp } from "./schedule.ts";
|
|
6
6
|
import { JobStore, type RunRecord } from "./store.ts";
|
|
7
7
|
import { realpathish, sameProject } from "./paths.ts";
|
|
8
|
+
import { plural } from "./plural.ts";
|
|
8
9
|
|
|
9
10
|
/**
|
|
10
11
|
* When `/inbox` was last looked at, per project: the terminal's `/inbox`, and the page's Mark all
|
|
@@ -232,7 +233,6 @@ export function sinceWords(sinceMs: number, nowMs: number): string {
|
|
|
232
233
|
return a.getFullYear() === b.getFullYear() ? `${date} ${time}` : `${date} ${a.getFullYear()} ${time}`;
|
|
233
234
|
}
|
|
234
235
|
|
|
235
|
-
const plural = (n: number, one: string, many = `${one}s`) => `${n} ${n === 1 ? one : many}`;
|
|
236
236
|
|
|
237
237
|
/** How many findings came from this conversation, worth saying only when that is not all of them. */
|
|
238
238
|
const fromHere = (s: AwaySummary): number => (s.fromThisConversation !== s.findings ? s.fromThisConversation : 0);
|
package/src/share.ts
CHANGED
|
@@ -9,7 +9,7 @@
|
|
|
9
9
|
* taken back, so the summary comes first and the confirmation after it.
|
|
10
10
|
*/
|
|
11
11
|
import { redact } from "./redact.ts";
|
|
12
|
-
import { plural } from "./
|
|
12
|
+
import { plural } from "./plural.ts";
|
|
13
13
|
import { stamp } from "./schedule.ts";
|
|
14
14
|
|
|
15
15
|
export interface ShareMessage {
|
package/src/store.ts
CHANGED
|
@@ -196,8 +196,17 @@ export function homeLoopsDir(agentDir: string): string {
|
|
|
196
196
|
|
|
197
197
|
export function defaultLoopsDir(agentDir?: string): string {
|
|
198
198
|
if (process.env.PI_LOOPS_DIR) return process.env.PI_LOOPS_DIR;
|
|
199
|
-
|
|
200
|
-
|
|
199
|
+
return path.join(agentDir ?? piAgentDir(), "loops");
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
/**
|
|
203
|
+
* pi's agent directory, by pi's rule (its `getAgentDir`): `PI_CODING_AGENT_DIR` with a leading `~`
|
|
204
|
+
* expanded, else `~/.pi/agent`. Here so that `pi-loops` does not load pi's SDK, about a second, for
|
|
205
|
+
* a path; test/store.test.ts holds it to the SDK's answer.
|
|
206
|
+
*/
|
|
207
|
+
export function piAgentDir(): string {
|
|
208
|
+
const env = process.env.PI_CODING_AGENT_DIR;
|
|
209
|
+
return env ? env.replace(/^~(?=$|\/)/, os.homedir()) : path.join(os.homedir(), ".pi", "agent");
|
|
201
210
|
}
|
|
202
211
|
|
|
203
212
|
/**
|
package/src/tools.ts
CHANGED
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
* SetCronJobState and NewTrigger / ListTriggers / RemoveTrigger / SetTriggerState — as pi tool
|
|
4
4
|
* definitions. One factory serves the interactive session (hop 0, registered with pi), every
|
|
5
5
|
* in-process sub-session (hop 1, passed as customTools) and the headless host (hop 1, no UI).
|
|
6
|
+
* Hop 1 does not get `new_trigger`, `remove_trigger` or `cron_remove`: nobody can approve any of them there.
|
|
6
7
|
*/
|
|
7
8
|
import { QUIET_MARK_AFTER, SIGNAL_WINDOW_MS, loopSignal } from "./job-signal.ts";
|
|
8
9
|
import { formatWait, queuedPrompt, type QueuedPrompt } from "./engine.ts";
|
|
@@ -16,7 +17,7 @@ import type { LoopScheduler, SessionSnapshot } from "./scheduler.ts";
|
|
|
16
17
|
import { MAX_PROMPT_BYTES, type LoopJob, newId, owningSessionId, resolveJobRef } from "./store.ts";
|
|
17
18
|
import { asleepNote, ownerSession, runsSomewhere, whereRuns, type OwnerView } from "./job-owner.ts";
|
|
18
19
|
import { inProject } from "./inbox.ts";
|
|
19
|
-
import { plural } from "./
|
|
20
|
+
import { plural } from "./plural.ts";
|
|
20
21
|
import type { TriggerRuntime } from "./trigger-runtime.ts";
|
|
21
22
|
import { sameProject, withinProject } from "./paths.ts";
|
|
22
23
|
import { type TriggerStore, cleanCondition, looksLikeFixedScheduleRequest, parseTriggerRule, resolveRuleRef, type DynamicTriggerRule } from "./triggers.ts";
|
|
@@ -323,11 +324,16 @@ export interface ToolScope {
|
|
|
323
324
|
parentSessionId?: string;
|
|
324
325
|
parentCwd?: string;
|
|
325
326
|
}
|
|
327
|
+
// Every call of these is a prompt-class operation, denied at hop > 0 (controlPlanePreflight): a
|
|
328
|
+
// tool that can only fail is one an agent tries first.
|
|
329
|
+
const HOP0_ONLY = new Set(["new_trigger", "remove_trigger", "cron_remove"]);
|
|
330
|
+
|
|
326
331
|
/**
|
|
327
332
|
* The cron/trigger tools are registered in sub-agents too (Prompt-class ones are denied there)
|
|
328
333
|
* and bounds cycles with a hop count. The same definitions serve the interactive session
|
|
329
334
|
* (hop 0, registered with pi) and every in-process sub-session (hop 1, passed as customTools).
|
|
330
335
|
*/
|
|
336
|
+
|
|
331
337
|
export function automationTools(scope: ToolScope, host: ToolHost): ToolDefinition<any, any>[] {
|
|
332
338
|
const defs: ToolDefinition<any, any>[] = [];
|
|
333
339
|
// Only where a person's message can arrive: a sub-agent is told nothing about it.
|
|
@@ -341,9 +347,9 @@ export function automationTools(scope: ToolScope, host: ToolHost): ToolDefinitio
|
|
|
341
347
|
"Create an event/condition-based dynamic trigger rule. Use this for future events such as a browser tab, file, MCP notification, webhook, or other condition becoming true. Do not use this for fixed time, recurring, scheduled, hourly, daily, weekly, cron, crontab, 定时任务, 每小时, or similar time-based jobs; use cron_create instead.",
|
|
342
348
|
parameters: Type.Object(
|
|
343
349
|
{
|
|
344
|
-
condition: Type.String({ description: "The natural-language condition that should be evaluated against future trigger events." }),
|
|
350
|
+
condition: Type.String({ description: "The natural-language condition that should be evaluated against future trigger events. For 'when <path> exists', keep the person's words: '<path> exists', with nothing added (no aside in parentheses) — a condition like that is checked with a local stat, no model call, while the path is absent." }),
|
|
345
351
|
action: Type.String({ description: "The action to perform when the condition matches. This may be a shell command or a natural-language instruction." }),
|
|
346
|
-
spec: Type.Optional(Type.String({ description: "
|
|
352
|
+
spec: Type.Optional(Type.String({ description: "The whole rule as one sentence, only when condition and action cannot be given apart." })),
|
|
347
353
|
fire_once: Type.Optional(Type.Boolean({ description: "Whether to disable the rule after the first successful match. Defaults to true unless the user explicitly asks for a repeating trigger." })),
|
|
348
354
|
promote_to_chat: Type.Optional(Type.Boolean({ description: "Whether a match's result goes into this conversation (true) or the inbox (false). With a person present the question starts at this conversation whatever this says; with nobody to ask, the default is false." })),
|
|
349
355
|
},
|
|
@@ -456,14 +462,14 @@ export function automationTools(scope: ToolScope, host: ToolHost): ToolDefinitio
|
|
|
456
462
|
"Create a scheduled job. Use when the user asks for a fixed time, recurring, scheduled, hourly, daily, weekly, crontab, 定时任务, 每小时, 每天, or similar time-based job. Jobs persist across pi restarts. A plain job runs its prompt in this chat when due. Set stateful=true for loop mode: a fresh sub-agent runs it, keeps persistent notes across runs (injected each time), and routes findings to the user's /inbox instead of the chat — use that for recurring watch/triage jobs like \"check for new issues and report only what changed\"." + watchSentence,
|
|
457
463
|
parameters: Type.Object({
|
|
458
464
|
schedule: Type.String({
|
|
459
|
-
description: 'Local-time schedule: 5-field cron ("0 9 * * *", "*/30 * * * 1-5"), "@daily", "every 30m", "in 10m", or "at 2026-09-08T18:00".',
|
|
465
|
+
description: 'Local-time schedule: 5-field cron ("0 9 * * *", "*/30 * * * 1-5"), "hourly", "daily" (09:00), "weekly" (Monday 09:00), "@daily" (midnight), "every 30m", "in 10m", or "at 2026-09-08T18:00".',
|
|
460
466
|
}),
|
|
461
467
|
action: Type.String({ description: "Natural-language instruction to run when the schedule is due. For stateful jobs, write it for a fresh agent whose only memory is its own notes." }),
|
|
462
468
|
stateful: Type.Optional(Type.Boolean({ description: "Loop mode: sub-agent + notes between runs + findings to the inbox. With a person present the question starts at the inbox unless this is false; with nobody to ask, the default is false." })),
|
|
463
469
|
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." })),
|
|
464
470
|
name: Type.Optional(Type.String({ description: "Short unique label (letters, digits, . _ -)." })),
|
|
465
471
|
cwd: Type.Optional(Type.String({ description: "Directory a stateful job's sub-agent runs in. Default: current project." })),
|
|
466
|
-
catch_up: Type.Optional(Type.Boolean({ description: "Run once for a
|
|
472
|
+
catch_up: Type.Optional(Type.Boolean({ description: "Run once for a missed tick (default: true for stateful jobs, false for plain jobs)." })),
|
|
467
473
|
}),
|
|
468
474
|
async execute(_id, params, signal, _onUpdate, ctx) {
|
|
469
475
|
let schedule: LoopJob["schedule"];
|
|
@@ -521,29 +527,22 @@ export function automationTools(scope: ToolScope, host: ToolHost): ToolDefinitio
|
|
|
521
527
|
name: "cron_remove",
|
|
522
528
|
label: "Remove cron job",
|
|
523
529
|
description:
|
|
524
|
-
"
|
|
530
|
+
"Remove a scheduled job by id or name. Use when the user asks to delete, remove, or clear a scheduled job, cron job, crontab entry, or 定时任务; the user is asked to confirm in the same call. Removal deletes the job itself; a loop's saved notes and run transcripts are kept on disk, and `/cron gc --purge` is what clears them.",
|
|
525
531
|
parameters: Type.Object({
|
|
526
532
|
ref: Type.String({ description: "Job id (for example cron-abc123), unique id prefix, or name." }),
|
|
527
|
-
confirm: Type.Optional(Type.Boolean({ description: "false to preview the removal; true only after explicit user confirmation." })),
|
|
528
533
|
}),
|
|
529
534
|
async execute(_id, params, _signal, _onUpdate, ctx) {
|
|
530
535
|
const job = resolveJobRefScoped(host.scheduler.store.load(), params.ref, host.session().cwd);
|
|
531
|
-
if (!job) return { content: [{ type: "text", text: `no cron job matching '${params.ref}' in this project; cron_list shows the ids and names` }], isError: true, details: { id: undefined as string | undefined, removed_count: 0,
|
|
536
|
+
if (!job) return { content: [{ type: "text", text: `no cron job matching '${params.ref}' in this project; cron_list shows the ids and names` }], isError: true, details: { id: undefined as string | undefined, removed_count: 0, audit_entry_id: undefined as string | undefined } };
|
|
532
537
|
const label = `${job.id}${job.name ? ` "${job.name}"` : ""}`;
|
|
533
|
-
if (!params.confirm) {
|
|
534
|
-
return {
|
|
535
|
-
content: [{ type: "text", text: `remove cron job ${label} requires confirmation\nschedule: ${formatSchedule(job.schedule)}\naction: ${previewRedacted(job.prompt, 120)}\ncall cron_remove again with confirm=true only after the user confirms` }],
|
|
536
|
-
details: { id: job.id as string | undefined, removed_count: 0, confirmation_required: true, audit_entry_id: undefined as string | undefined },
|
|
537
|
-
};
|
|
538
|
-
}
|
|
539
538
|
// The only control-plane tool that used to skip this gate, so a sub-agent (hop > 0) could
|
|
540
539
|
// delete any project's job unapproved — and what that costs is the automation itself: the
|
|
541
540
|
// loop stops running and its id is gone from jobs.json. Its notes and transcripts stay
|
|
542
541
|
// behind (under an id nothing schedules any more; `/cron gc --purge` clears those), which
|
|
543
|
-
// is why the gate is about what stops, not about data destroyed. The
|
|
544
|
-
//
|
|
542
|
+
// is why the gate is about what stops, not about data destroyed. The dialog shows the
|
|
543
|
+
// schedule and the action: one question, where a preview call used to make it two.
|
|
545
544
|
const denied = await host.confirmTool(ctx, { label: `remove cron job ${label}`, tool: "cron_remove", reason: "remove a scheduled job", preview: `${formatSchedule(job.schedule)} · ${previewRedacted(job.prompt, 120)}`, args: params }, scope.hop);
|
|
546
|
-
if (denied) return { content: [{ type: "text", text: denied }], isError: true, details: { id: undefined as string | undefined, removed_count: 0,
|
|
545
|
+
if (denied) return { content: [{ type: "text", text: denied }], isError: true, details: { id: undefined as string | undefined, removed_count: 0, audit_entry_id: undefined as string | undefined } };
|
|
547
546
|
const { aborted } = await host.scheduler.removeJob(job.id);
|
|
548
547
|
const auditEntryId = host.cronControlAudit("remove", scope.actor, job, undefined);
|
|
549
548
|
// The slash command says where a loop's notes went; a tool-driven removal has to say it too,
|
|
@@ -553,7 +552,7 @@ export function automationTools(scope: ToolScope, host: ToolHost): ToolDefinitio
|
|
|
553
552
|
const kept = job.stateful ? "\nits loop state and run transcripts are kept; /cron gc --purge clears them" : "";
|
|
554
553
|
const abortedNote = aborted > 0 ? "\naborted its running run" : "";
|
|
555
554
|
const statePath = job.stateful ? host.scheduler.store.statePath(job.id) : undefined;
|
|
556
|
-
return { content: [{ type: "text", text: `removed cron job ${label}\nschedule: ${formatSchedule(job.schedule)}\naction: ${previewRedacted(job.prompt, 120)}${kept}${abortedNote}` }], details: { id: job.id as string | undefined, removed_count: 1,
|
|
555
|
+
return { content: [{ type: "text", text: `removed cron job ${label}\nschedule: ${formatSchedule(job.schedule)}\naction: ${previewRedacted(job.prompt, 120)}${kept}${abortedNote}` }], details: { id: job.id as string | undefined, removed_count: 1, audit_entry_id: auditEntryId, state_path: statePath } };
|
|
557
556
|
},
|
|
558
557
|
});
|
|
559
558
|
|
|
@@ -587,5 +586,5 @@ export function automationTools(scope: ToolScope, host: ToolHost): ToolDefinitio
|
|
|
587
586
|
};
|
|
588
587
|
},
|
|
589
588
|
});
|
|
590
|
-
return defs;
|
|
589
|
+
return scope.hop === 0 ? defs : defs.filter((d) => !HOP0_ONLY.has(d.name));
|
|
591
590
|
}
|
package/src/triggers.ts
CHANGED
|
@@ -463,9 +463,8 @@ export function auditArgs(rest: string): { limit: number; all: boolean } | { usa
|
|
|
463
463
|
/**
|
|
464
464
|
* The control-plane prompt gate, pre-flight part. Prompt-class tool calls (create or remove
|
|
465
465
|
* a trigger, re-enable a trigger or a cron job) need a human. Sub-agents have no prompt channel
|
|
466
|
-
* and are denied fail-closed
|
|
467
|
-
*
|
|
468
|
-
* interactive UI gets to ask. Returns the denial text, or undefined when the caller should ask.
|
|
466
|
+
* and are denied fail-closed; a UI-less interactive process is refused; an interactive UI gets
|
|
467
|
+
* to ask. Returns the denial text, or undefined when the caller should ask.
|
|
469
468
|
*/
|
|
470
469
|
export function controlPlanePreflight(proc: { hop: number; hasUI: boolean; held?: boolean }, reason: string): string | undefined {
|
|
471
470
|
// A plain job's turn in a closed conversation: the person's own turn, run where they were not.
|
package/src/trust.ts
CHANGED
|
@@ -10,17 +10,7 @@
|
|
|
10
10
|
import * as fs from "node:fs";
|
|
11
11
|
import * as path from "node:path";
|
|
12
12
|
import { ProjectTrustStore, hasTrustRequiringProjectResources } from "@earendil-works/pi-coding-agent";
|
|
13
|
-
import { withinProject } from "./paths.ts";
|
|
14
|
-
|
|
15
|
-
/** pi's own normalisation: resolved, then through symlinks where the path exists. */
|
|
16
|
-
export function canonicalDir(dir: string): string {
|
|
17
|
-
const resolved = path.resolve(dir);
|
|
18
|
-
try {
|
|
19
|
-
return fs.realpathSync(resolved);
|
|
20
|
-
} catch {
|
|
21
|
-
return resolved;
|
|
22
|
-
}
|
|
23
|
-
}
|
|
13
|
+
import { canonicalDir, withinProject } from "./paths.ts";
|
|
24
14
|
|
|
25
15
|
/**
|
|
26
16
|
* pi's own saved decision for `cwd`: the nearest entry, this directory's or a parent's, so a
|
package/src/web.mjs
CHANGED
|
@@ -508,6 +508,7 @@ dialog menu{display:flex;gap:8px;justify-content:flex-end;padding:0;margin:14px
|
|
|
508
508
|
header .grow *{flex-shrink:0}
|
|
509
509
|
header .grow button{padding:5px 7px}
|
|
510
510
|
#cost{flex-shrink:1;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
|
|
511
|
+
#cost:empty{display:none}
|
|
511
512
|
}}
|
|
512
513
|
/* The loops rail is not dropped when the window narrows, it is put behind the rail's Loops button: it
|
|
513
514
|
slides over the conversation rather than taking a third of it, the way a drawer does elsewhere. */
|
|
@@ -597,22 +598,21 @@ dialog menu{display:flex;gap:8px;justify-content:flex-end;padding:0;margin:14px
|
|
|
597
598
|
<button id="expand">▸ steps</button>
|
|
598
599
|
<span id="actions">
|
|
599
600
|
<button id="reload">reload</button>
|
|
600
|
-
<button id="find" aria-label="Search this
|
|
601
|
+
<button id="find" aria-label="Search this conversation">find</button>
|
|
601
602
|
<button id="undo">undo</button>
|
|
602
|
-
<button id="save" title="Export this
|
|
603
|
+
<button id="save" title="Export this conversation as HTML">save</button>
|
|
603
604
|
<button id="share">share</button>
|
|
604
605
|
<button id="compact">compact</button>
|
|
605
|
-
<button id="clear">clear</button>
|
|
606
606
|
<button id="resume">resume</button>
|
|
607
607
|
<button id="adddev" aria-label="Add a device">add device</button>
|
|
608
|
-
<button id="theme"
|
|
608
|
+
<button id="theme">theme: system</button>
|
|
609
609
|
<button id="notifyMe">notifications</button>
|
|
610
610
|
</span>
|
|
611
611
|
<span class="badge" id="status">connecting</span>
|
|
612
|
-
<button id="more" title="
|
|
612
|
+
<button id="more" title="Conversation actions" aria-label="Conversation actions">⋯</button>
|
|
613
613
|
</span>
|
|
614
614
|
</header>
|
|
615
|
-
<div id="findbar" hidden><input id="findq" placeholder="search this
|
|
615
|
+
<div id="findbar" hidden><input id="findq" placeholder="search this conversation…" ><span id="findn" class="notice"></span></div>
|
|
616
616
|
<button id="stale" hidden></button>
|
|
617
617
|
<div id="feed" role="log" aria-live="polite" aria-label="Conversation"></div>
|
|
618
618
|
<div id="activitySlot"></div>
|
|
@@ -623,7 +623,7 @@ dialog menu{display:flex;gap:8px;justify-content:flex-end;padding:0;margin:14px
|
|
|
623
623
|
<div id="pop"></div>
|
|
624
624
|
<div id="thumbs"></div>
|
|
625
625
|
<div class="composer-row">
|
|
626
|
-
<textarea id="input" placeholder="Message pi
|
|
626
|
+
<textarea id="input" placeholder="Message pi"></textarea>
|
|
627
627
|
<button type="button" id="attach">attach</button>
|
|
628
628
|
<button type="submit" class="primary">send</button>
|
|
629
629
|
<button type="button" id="stop">stop</button>
|
|
@@ -643,8 +643,8 @@ dialog menu{display:flex;gap:8px;justify-content:flex-end;padding:0;margin:14px
|
|
|
643
643
|
<a href="#loops" id="tabLoops"><svg class="i" viewBox="0 0 24 24" aria-hidden="true"><path d="M17 2l3 3-3 3"/><path d="M4 11V9a4 4 0 0 1 4-4h12"/><path d="M7 22l-3-3 3-3"/><path d="M20 13v2a4 4 0 0 1-4 4H4"/></svg><span>Loops</span></a>
|
|
644
644
|
</nav>
|
|
645
645
|
<div id="scrim" hidden></div>
|
|
646
|
-
<dialog id="menu" aria-label="
|
|
647
|
-
<h3>
|
|
646
|
+
<dialog id="menu" aria-label="Conversation actions" role="dialog">
|
|
647
|
+
<h3>Conversation</h3><div id="menuBody"></div><div id="sid" class="sid"></div>
|
|
648
648
|
<menu><button id="menuClose" value="close">close</button></menu>
|
|
649
649
|
</dialog>
|
|
650
650
|
<dialog id="pairdlg" aria-labelledby="pairTitle" role="dialog">
|
|
@@ -2003,7 +2003,8 @@ function drawLoopRow(slot, j) {
|
|
|
2003
2003
|
num(g.findings30d) ? num(g.findings30d) + " finding" + (num(g.findings30d) === 1 ? "" : "s") + " in 30 days" : "",
|
|
2004
2004
|
num(g.claimed30d) ? num(g.claimed30d) + " claimed" : "",
|
|
2005
2005
|
num(g.dismissed30d) ? num(g.dismissed30d) + " dismissed" : "",
|
|
2006
|
-
|
|
2006
|
+
// A failing loop's empty runs are its errors, which the red line already says.
|
|
2007
|
+
num(g.quietStreak) >= 3 && !j.lastError ? "quiet ×" + num(g.quietStreak) : "",
|
|
2007
2008
|
].filter(Boolean).join(" · ") : "";
|
|
2008
2009
|
if (said) slot.append(textEl("span", "signal", said));
|
|
2009
2010
|
const told = tuneNote.get(j.id);
|
|
@@ -2029,15 +2030,16 @@ function renderSidebar(s) {
|
|
|
2029
2030
|
else {
|
|
2030
2031
|
let html = "";
|
|
2031
2032
|
for (const j of a.jobs) {
|
|
2032
|
-
//
|
|
2033
|
-
// rather than once and cut
|
|
2034
|
-
// rest with no way to see it. Both copies go through safeText; neither is sliced here.
|
|
2033
|
+
// An unnamed loop is told apart by its prompt: a two-line peek in the summary, the whole of it
|
|
2034
|
+
// in the body, rather than once and cut. A named loop's name says what it is, so no peek.
|
|
2035
2035
|
const prompt = safeText(j.prompt);
|
|
2036
|
+
const named = typeof j.name === "string" && j.name !== "";
|
|
2037
|
+
const fired = [j.lastFiredAt ? "last fired " + safeText(clock(j.lastFiredAt)) : "", j.skippedOverlap ? "skipped overlaps " + num(j.skippedOverlap) : ""].filter(Boolean).join(" · ");
|
|
2036
2038
|
html += '<details class="card job' + (j.enabled ? "" : " off") + '" data-key="job:' + safeText(j.id) + '">' +
|
|
2037
2039
|
'<summary><span class="t"><span class="nm"><b>' + safeText(j.label || j.name || j.id) + "</b>" +
|
|
2038
2040
|
'<span class="m">' + safeText(j.schedule) + (j.stateful ? "" : " · in its conversation") + (j.enabled ? "" : " · disabled") + "</span></span>" +
|
|
2039
2041
|
'<button data-run="' + safeText(j.id) + '">run</button></span>' +
|
|
2040
|
-
'<span class="m peek">' + prompt + "</span>" +
|
|
2042
|
+
(named ? "" : '<span class="m peek">' + prompt + "</span>") +
|
|
2041
2043
|
// A run pi-loops holds has its own line, with Stop this run, under the card's name.
|
|
2042
2044
|
runsLine(j) +
|
|
2043
2045
|
// A one-line hint so a job that is failing says so before it is opened; the whole error is in
|
|
@@ -2051,20 +2053,22 @@ function renderSidebar(s) {
|
|
|
2051
2053
|
'<div class="body">' +
|
|
2052
2054
|
'<pre class="full">' + prompt + "</pre>" +
|
|
2053
2055
|
'<button class="copy" data-copy="' + safeText(j.id) + '">copy</button>' +
|
|
2054
|
-
'<div class="m">
|
|
2056
|
+
(fired ? '<div class="m">' + fired + "</div>" : "") +
|
|
2055
2057
|
// A job belonging to a hostname this machine no longer has: it is listed, because it exists,
|
|
2056
2058
|
// and it says why nothing is happening rather than leaving you to find out from the silence.
|
|
2057
2059
|
(j.lastError ? '<div class="m bad">' + safeText(j.lastError) + "</div>" : "") +
|
|
2058
2060
|
"</div></details>";
|
|
2059
2061
|
}
|
|
2060
2062
|
for (const r of a.rules) {
|
|
2063
|
+
// The condition is the rule's name: what it waits for. Cut in the title, whole in the body.
|
|
2064
|
+
const when = str(r.condition);
|
|
2065
|
+
const cut = clipChars(when, 80);
|
|
2061
2066
|
html += '<details class="card rule' + (r.enabled ? "" : " off") + '" data-key="rule:' + safeText(r.id) + '">' +
|
|
2062
|
-
'<summary><span class="t"><b>
|
|
2063
|
-
'<span class="m peek">when ' + safeText(r.condition) + "</span>" +
|
|
2067
|
+
'<summary><span class="t"><b>' + safeText(cut) + '</b><span class="m">' + (r.enabled ? "" : "disabled · ") + (r.fireOnce ? "once" : "repeat") + "</span></span>" +
|
|
2064
2068
|
(r.look ? '<span class="m looks" data-rulelook="' + safeText(r.id) + '"></span>' : "") +
|
|
2065
2069
|
"</summary>" +
|
|
2066
2070
|
'<div class="body">' +
|
|
2067
|
-
'<pre class="full">when ' + safeText(
|
|
2071
|
+
(cut !== when ? '<pre class="full">when ' + safeText(when) + "</pre>" : "") +
|
|
2068
2072
|
'<pre class="full">→ ' + safeText(r.action) + "</pre>" +
|
|
2069
2073
|
"</div></details>";
|
|
2070
2074
|
}
|
|
@@ -2334,7 +2338,8 @@ async function refresh() {
|
|
|
2334
2338
|
api("/stats").then((st) => {
|
|
2335
2339
|
if (!st || st.error) return;
|
|
2336
2340
|
const pct = st.contextUsage?.percent;
|
|
2337
|
-
|
|
2341
|
+
// Nothing spent yet: no badge, rather than "$0.000 · 0k · ctx 0%".
|
|
2342
|
+
$("cost").textContent = !st.tokens?.total ? "" : "$" + (st.cost ?? 0).toFixed(3) + " · " + Math.round(st.tokens.total / 1000) + "k" + (pct == null ? "" : " · ctx " + Math.round(pct) + "%");
|
|
2338
2343
|
});
|
|
2339
2344
|
for (const ask of state.pendingAsks || []) onAsk(ask);
|
|
2340
2345
|
}
|
|
@@ -2592,6 +2597,13 @@ async function compactNow(instructions) {
|
|
|
2592
2597
|
|
|
2593
2598
|
/** Why the next reload is happening, when it is happening because somebody asked for it. */
|
|
2594
2599
|
let swapWhy = "";
|
|
2600
|
+
/**
|
|
2601
|
+
* A swap is waiting for its answer. The new session's events can arrive before the event that moves
|
|
2602
|
+
* this page to its id: reloaded on the first of them, the page read the old id's history, which the
|
|
2603
|
+
* server opens as that conversation in a pi of its own, and reloaded again on every event after,
|
|
2604
|
+
* each time without the reason. Events wait for the answer, which carries the new id.
|
|
2605
|
+
*/
|
|
2606
|
+
let swapPending = false;
|
|
2595
2607
|
|
|
2596
2608
|
/**
|
|
2597
2609
|
* Start a new session, or go back to one.
|
|
@@ -2607,7 +2619,18 @@ async function swap(call, body, why) {
|
|
|
2607
2619
|
// for a session you had just asked to start.
|
|
2608
2620
|
swapWhy = why;
|
|
2609
2621
|
const note = row("notice", "", "switching…");
|
|
2622
|
+
swapPending = true;
|
|
2610
2623
|
const r = await api(call, body);
|
|
2624
|
+
swapPending = false;
|
|
2625
|
+
const held = waiting.splice(0, waiting.length);
|
|
2626
|
+
if (r.success && !r.data?.cancelled && typeof r.sessionId === "string" && r.sessionId && r.sessionId !== SESSION) {
|
|
2627
|
+
// Moved first, then one reload, for the reason given; what waited is applied after it.
|
|
2628
|
+
moveTo(r.sessionId);
|
|
2629
|
+
waiting.push(...held);
|
|
2630
|
+
await resync(reloadReason());
|
|
2631
|
+
return;
|
|
2632
|
+
}
|
|
2633
|
+
for (const ev of held) applyEvent(ev);
|
|
2611
2634
|
// Mid-turn the server refuses rather than asking, and its refusal says what to do about it. A
|
|
2612
2635
|
// dialog here would be a second way to answer the same question, on the one screen where the
|
|
2613
2636
|
// turn it is about is already visible.
|
|
@@ -2639,7 +2662,7 @@ function pageCommand(text) {
|
|
|
2639
2662
|
else if (name === "resume") openSessions();
|
|
2640
2663
|
else if (name === "sessions") listSessionsHere();
|
|
2641
2664
|
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.");
|
|
2642
|
-
else
|
|
2665
|
+
else newHere();
|
|
2643
2666
|
return true;
|
|
2644
2667
|
}
|
|
2645
2668
|
|
|
@@ -2658,7 +2681,8 @@ async function listSessionsHere() {
|
|
|
2658
2681
|
}
|
|
2659
2682
|
|
|
2660
2683
|
$("compact").onclick = () => compactNow("");
|
|
2661
|
-
|
|
2684
|
+
/** A new conversation in this page's pi: + New in this project, and /clear or /new typed. */
|
|
2685
|
+
function newHere() { swap("/session/new", {}, "new conversation — the one you left is under resume"); }
|
|
2662
2686
|
|
|
2663
2687
|
/** The sessions of this project, newest first — the conversation, not the filename. */
|
|
2664
2688
|
async function openSessions() {
|
|
@@ -2738,7 +2762,6 @@ function qrSvg(q) {
|
|
|
2738
2762
|
$("adddev").onclick = async () => {
|
|
2739
2763
|
const r = await api("/pair", {});
|
|
2740
2764
|
if (!r.success) { row("err", "error", r.error || "could not make a pairing code"); return; }
|
|
2741
|
-
$("pairCode").textContent = r.code;
|
|
2742
2765
|
|
|
2743
2766
|
// The address this browser reached, first: under tailscale serve that is the tailnet name, and
|
|
2744
2767
|
// it is the only one anybody had to configure. Then whatever else the machine can see — which is
|
|
@@ -2746,6 +2769,10 @@ $("adddev").onclick = async () => {
|
|
|
2746
2769
|
const thisWindow = location.origin + "/";
|
|
2747
2770
|
const loopback = /^https?:\/\/(127\.0\.0\.1|localhost|\[::1\])(:|\/)/.test(thisWindow);
|
|
2748
2771
|
const candidates = (loopback ? [] : [{ url: thisWindow, via: "this window" }]).concat(r.addresses || []);
|
|
2772
|
+
// No device but this one can reach the page: a code would be a grant nobody can use, so it is
|
|
2773
|
+
// retired at once and the dialog says only how to make the page reachable.
|
|
2774
|
+
if (candidates.length) $("pairCode").textContent = r.code;
|
|
2775
|
+
else { $("pairCode").textContent = ""; api("/pair", { cancel: true }); }
|
|
2749
2776
|
|
|
2750
2777
|
const pick = $("pairPick");
|
|
2751
2778
|
pick.innerHTML = "";
|
|
@@ -3163,10 +3190,19 @@ async function resync(why) {
|
|
|
3163
3190
|
// Whatever happened while we were reading: anything the transcript already covers is dropped by
|
|
3164
3191
|
// the same rule as always.
|
|
3165
3192
|
const held = waiting.splice(0, waiting.length);
|
|
3166
|
-
|
|
3193
|
+
// An event of an earlier epoch numbered at or before the transcript is the session that was left
|
|
3194
|
+
// (the server's numbering runs on across a swap): drawn, it would reload the page again.
|
|
3195
|
+
for (const ev of held) if (!(ev.seq && ev.epoch && ev.epoch !== epoch && ev.seq <= seen)) applyEvent(ev);
|
|
3167
3196
|
refresh();
|
|
3168
3197
|
}
|
|
3169
3198
|
|
|
3199
|
+
/** Point the page's routes and address at another conversation; the list marks it at the next poll. */
|
|
3200
|
+
function moveTo(id) {
|
|
3201
|
+
SESSION = id;
|
|
3202
|
+
history.replaceState(null, "", "/c/" + SESSION + "/");
|
|
3203
|
+
listAt = 0;
|
|
3204
|
+
}
|
|
3205
|
+
|
|
3170
3206
|
/**
|
|
3171
3207
|
* One event, against the numbering this page is counting on: already covered by the transcript, the
|
|
3172
3208
|
* next one in sequence, or evidence that something in between never arrived.
|
|
@@ -3175,14 +3211,9 @@ function applyEvent(ev) {
|
|
|
3175
3211
|
// While a reload runs, every event waits for it, a replayed one too: a reload started by the replay
|
|
3176
3212
|
// clears the feed, and the replay must not go on drawing into what it cleared (the history it reads
|
|
3177
3213
|
// has those events already). Applied once the transcript is back.
|
|
3178
|
-
if (resyncing) return void waiting.push(ev);
|
|
3179
|
-
|
|
3180
|
-
|
|
3181
|
-
SESSION = ev.sessionId;
|
|
3182
|
-
history.replaceState(null, "", "/c/" + SESSION + "/");
|
|
3183
|
-
// And the list marks it at the next poll, not fifteen seconds later.
|
|
3184
|
-
listAt = 0;
|
|
3185
|
-
}
|
|
3214
|
+
if (resyncing || swapPending) return void waiting.push(ev);
|
|
3215
|
+
// pi is in another conversation now (a new session, a resume).
|
|
3216
|
+
if (ev.type === "page_moved" && ev.sessionId) moveTo(ev.sessionId);
|
|
3186
3217
|
if (!ev.seq) return handle(ev); // not numbered: nothing to reason about
|
|
3187
3218
|
if (epoch && ev.epoch && ev.epoch !== epoch) {
|
|
3188
3219
|
// A different run of the server: its numbers mean nothing next to the ones we were counting.
|
|
@@ -3220,7 +3251,7 @@ const convRows = [];
|
|
|
3220
3251
|
let focusAt = 0;
|
|
3221
3252
|
const HELD_LINE = "Your terminal has this conversation. What you send here is answered there.";
|
|
3222
3253
|
const BESIDE_LINE = "A terminal has this conversation open, and this page's pi has no pi-loops: what you send here is answered here, beside the terminal.";
|
|
3223
|
-
const PLACEHOLDER = $("input").placeholder || "Message pi
|
|
3254
|
+
const PLACEHOLDER = $("input").placeholder || "Message pi";
|
|
3224
3255
|
const phone = () => !!(window.matchMedia && window.matchMedia("(max-width:760px)").matches);
|
|
3225
3256
|
const spanOf = (cls, text) => {
|
|
3226
3257
|
const el = document.createElement("span");
|
|
@@ -3981,7 +4012,7 @@ async function startConversationIn(project) {
|
|
|
3981
4012
|
/** + New: in the project the list shows. The open conversation's starts a new session in this page's pi, as it always did. */
|
|
3982
4013
|
$("newConv").onclick = () => {
|
|
3983
4014
|
const project = listedProject();
|
|
3984
|
-
if (!otherProject(project)) return
|
|
4015
|
+
if (!otherProject(project)) return newHere();
|
|
3985
4016
|
const note = row("notice", "", "starting a new conversation in " + projectName(project) + "…");
|
|
3986
4017
|
startConversationIn(project)
|
|
3987
4018
|
.then((r) => (r.url ? location.assign(r.url) : void (note.textContent = "No new conversation in " + projectName(project) + ": " + r.error)))
|
|
@@ -4089,8 +4120,8 @@ function activityLine(i) {
|
|
|
4089
4120
|
else bits.push(num(i.findings) + " finding" + (num(i.findings) === 1 ? "" : "s"));
|
|
4090
4121
|
if (num(i.seconds)) bits.push(num(i.seconds) + "s");
|
|
4091
4122
|
if (num(i.costUsd)) bits.push("$" + num(i.costUsd).toFixed(3));
|
|
4092
|
-
// Only a card this conversation got is in the chat on the screen.
|
|
4093
|
-
if (i.
|
|
4123
|
+
// Only a card this conversation got is in the chat on the screen (src/run-card.ts runCardsHere).
|
|
4124
|
+
if (i.inChat === true) bits.push("in chat");
|
|
4094
4125
|
return { mark: i.stopped ? "■" : i.ok === false ? "✗" : "✓", text: name + " · " + bits.join(" · ") };
|
|
4095
4126
|
}
|
|
4096
4127
|
|
|
@@ -4279,7 +4310,7 @@ const WATCH_EXAMPLES = [
|
|
|
4279
4310
|
];
|
|
4280
4311
|
/** How long the modal waits for a plan or the end of the turn before it says pi did not answer. */
|
|
4281
4312
|
const WATCH_ANSWER_MS = 90000;
|
|
4282
|
-
const WATCH_TIME = /\d\s*(?:am|pm|m|min|minutes?|h|hours?|s|secs?|seconds?|d|days?|分钟|小时|秒|天|点)\b|\b(?:every|each|daily|nightly|hourly|weekly|monthly|morning|evening|night
|
|
4313
|
+
const WATCH_TIME = /\d\s*(?:am|pm|m|min|minutes?|h|hours?|s|secs?|seconds?|d|days?|分钟|小时|秒|天|点)\b|\b(?:every|each|daily|nightly|hourly|weekly|biweekly|fortnightly|monthly|quarterly|yearly|annually|(?:morning|afternoon|evening|night)s?|noon|midnight|tomorrow|tonight|today|at\s+(?:\d|one|two|three|four|five|six|seven|eight|nine|ten|eleven|twelve|noon|midnight)|@(?:hourly|daily|weekly|monthly)|in\s+(?:\d|an?\s+(?:hour|minute|day|week)|a\s+(?:few|couple\s+of)\s+(?:minutes|hours|days|weeks))|(?:next|this)\s+(?:week|month|quarter|year|weekend|morning|afternoon|evening|monday|tuesday|wednesday|thursday|friday|saturday|sunday)|(?:once|twice)\s+a\s+(?:day|week|month)|(?:mon|tue|wed|thu|fri|sat|sun)s?|(?:monday|tuesday|wednesday|thursday|friday|saturday|sunday)s?|weekdays?|weeknights?|weekends?)\b|每|天|点|小时|分钟|周|明天|今晚|晚上|早上|下午|中午|凌晨|季度|(?:^|\s)(?:[\d*\/,-]+\s+){4}[\d*\/,-]+(?:\s|$)/i;
|
|
4283
4314
|
const WATCH_WAIT = /\b(?:when|whenever|if|once|until|as soon as|after|appears?|exists?|changes?|fails?|gets?|arrives?|merge[sd]?|finish(?:es|ed)?|lands?)\b|当|如果|一旦|等|出现|存在|变|红|失败|完成|合并/i;
|
|
4284
4315
|
/** No time word and no wait word: a thing to do now, not a watch. Generous on purpose: a doubt goes to the model. */
|
|
4285
4316
|
function readsLikeNow(sentence) {
|
|
@@ -4644,7 +4675,7 @@ $("watchText").addEventListener("keydown", (e) => { if (e.key === "Enter" && (e.
|
|
|
4644
4675
|
*/
|
|
4645
4676
|
checkStream = (s) => {
|
|
4646
4677
|
// A reload ends by refreshing, and a refresh polls: without this the two call each other.
|
|
4647
|
-
if (resyncing || !s || !s.epoch) return;
|
|
4678
|
+
if (resyncing || swapPending || !s || !s.epoch) return;
|
|
4648
4679
|
if (s.epoch !== epoch) {
|
|
4649
4680
|
epoch = s.epoch;
|
|
4650
4681
|
seen = s.seq ?? seen;
|