@alphacoder-v0/pi-loops 0.22.8 → 0.24.1
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 +717 -0
- package/CONTRACT.md +7 -7
- package/README.md +114 -78
- package/README.zh-CN.md +62 -41
- package/docs/cli.md +352 -91
- package/docs/configuration.md +86 -24
- package/docs/design.md +404 -61
- 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 +41 -28
- package/docs/web-ui-parity.md +702 -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/browser-open.ts +169 -0
- package/src/cli-entry.mjs +12 -3
- package/src/cli.ts +960 -143
- 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 -42
- 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 +1370 -473
- 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 +595 -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 +22 -7
- package/src/watch-plan.ts +174 -0
- package/src/web.d.mts +3 -0
- package/src/web.mjs +2261 -2340
- package/src/window-executor.ts +459 -0
- package/src/window-guard.ts +158 -0
- package/docs/agents/issue-tracker.md +0 -34
package/src/tools.ts
CHANGED
|
@@ -5,6 +5,7 @@
|
|
|
5
5
|
* in-process sub-session (hop 1, passed as customTools) and the headless host (hop 1, no UI).
|
|
6
6
|
*/
|
|
7
7
|
import { QUIET_MARK_AFTER, SIGNAL_WINDOW_MS, loopSignal } from "./job-signal.ts";
|
|
8
|
+
import { formatWait, queuedPrompt, type QueuedPrompt } from "./engine.ts";
|
|
8
9
|
import type { ExtensionContext, ToolDefinition } from "@earendil-works/pi-coding-agent";
|
|
9
10
|
import * as os from "node:os";
|
|
10
11
|
import * as path from "node:path";
|
|
@@ -13,10 +14,14 @@ import { previewRedacted } from "./redact.ts";
|
|
|
13
14
|
import { computeNext, formatLocal, formatSchedule, parseSchedule, stamp } from "./schedule.ts";
|
|
14
15
|
import type { LoopScheduler, SessionSnapshot } from "./scheduler.ts";
|
|
15
16
|
import { MAX_PROMPT_BYTES, type LoopJob, newId, owningSessionId, resolveJobRef } from "./store.ts";
|
|
16
|
-
import { asleepNote, ownerSession,
|
|
17
|
+
import { asleepNote, ownerSession, runsSomewhere, whereRuns, type OwnerView } from "./job-owner.ts";
|
|
18
|
+
import { inProject } from "./inbox.ts";
|
|
19
|
+
import { plural } from "./job-lines.ts";
|
|
17
20
|
import type { TriggerRuntime } from "./trigger-runtime.ts";
|
|
18
|
-
import { sameProject, withinProject } from "./
|
|
19
|
-
import { type TriggerStore, looksLikeFixedScheduleRequest, parseTriggerRule, resolveRuleRef, type DynamicTriggerRule } from "./triggers.ts";
|
|
21
|
+
import { sameProject, withinProject } from "./paths.ts";
|
|
22
|
+
import { type TriggerStore, cleanCondition, looksLikeFixedScheduleRequest, parseTriggerRule, resolveRuleRef, type DynamicTriggerRule } from "./triggers.ts";
|
|
23
|
+
import { PI_BUILTIN_TOOL_NAMES } from "./mcp.ts";
|
|
24
|
+
import { WATCH_ASK_MS, type WatchChoice, type WatchPlan, askTitle, planCard, watchChoice, watchOptions } from "./watch-plan.ts";
|
|
20
25
|
|
|
21
26
|
export interface ControlPlaneRequest {
|
|
22
27
|
/** What is being approved, value-free. */
|
|
@@ -40,6 +45,14 @@ export interface ToolHost {
|
|
|
40
45
|
/** The confirmation gate; resolves to a denial text or undefined (allowed). */
|
|
41
46
|
confirmTool(ctx: ExtensionContext, req: ControlPlaneRequest, atHop: number): Promise<string | undefined>;
|
|
42
47
|
refreshBadge(): void;
|
|
48
|
+
/** Make sure the host is up now that there is a loop or a rule for it. Absent in the host itself. */
|
|
49
|
+
ensureHost?(): void;
|
|
50
|
+
/**
|
|
51
|
+
* A turn pi-loops started in an open window for a scheduled job or a delivery: nobody may be
|
|
52
|
+
* there, so `cron_create` and `new_trigger` do not wait on a person and keep the window guard's
|
|
53
|
+
* gate (docs/design.md decision 24). Absent where no such turn runs.
|
|
54
|
+
*/
|
|
55
|
+
scheduledTurn?(): boolean;
|
|
43
56
|
}
|
|
44
57
|
|
|
45
58
|
export interface CreateJobInput {
|
|
@@ -63,6 +76,8 @@ export interface CreateJobInput {
|
|
|
63
76
|
export interface JobScope {
|
|
64
77
|
parentSessionId?: string;
|
|
65
78
|
parentCwd?: string;
|
|
79
|
+
/** The loop belongs to its project, not a conversation: one made where no saved conversation is (recipe.ts installScope). */
|
|
80
|
+
projectOwned?: true;
|
|
66
81
|
}
|
|
67
82
|
|
|
68
83
|
/**
|
|
@@ -116,7 +131,17 @@ export function checkJobName(name: string | undefined, existing: LoopJob[]): voi
|
|
|
116
131
|
}
|
|
117
132
|
|
|
118
133
|
/** `/cron add` and `cron_create`: validate, fill the defaults, record who created it and where. */
|
|
119
|
-
|
|
134
|
+
/** The tools a job was asked for and does not have: built-ins the chat making it lacks. */
|
|
135
|
+
export function toolsDropped(asked: readonly string[] | undefined, kept: readonly string[] | undefined): string[] {
|
|
136
|
+
return (asked ?? []).filter((t) => !(kept ?? []).includes(t));
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/**
|
|
140
|
+
* Everything a job is refused for, with nothing written: the checks `createLoopJob` runs before it
|
|
141
|
+
* builds the job, in one place so a person is never asked about a job their yes cannot make.
|
|
142
|
+
* Returns the directory the job would run in.
|
|
143
|
+
*/
|
|
144
|
+
export function checkJobInput(input: Pick<CreateJobInput, "schedule" | "prompt" | "stateful" | "name" | "cwd">, existing: LoopJob[], session: Pick<SessionSnapshot, "sessionId" | "cwd">): string {
|
|
120
145
|
if (!input.prompt.trim()) throw new Error("cron action cannot be empty");
|
|
121
146
|
if (Buffer.byteLength(input.prompt, "utf8") > MAX_PROMPT_BYTES) throw new Error(`cron action exceeds ${MAX_PROMPT_BYTES} bytes`);
|
|
122
147
|
// An expression can parse and still never match ("0 0 30 2 *"): the job would go quiet with
|
|
@@ -126,20 +151,29 @@ export async function createLoopJob(host: Pick<ToolHost, "scheduler" | "session"
|
|
|
126
151
|
if (input.schedule.kind === "cron" && computeNext({ schedule: input.schedule, createdAt: Date.now() }, Date.now()) === undefined) {
|
|
127
152
|
throw new Error(`${formatSchedule(input.schedule)} has no next run`);
|
|
128
153
|
}
|
|
129
|
-
const existing = host.scheduler.store.load();
|
|
130
154
|
checkJobName(input.name, existing);
|
|
131
|
-
if (!input.stateful && !
|
|
155
|
+
if (!input.stateful && !session.sessionId) throw new Error("a non-stateful cron job needs a persistent chat session to inject into (not --no-session, not a background process); use stateful=true");
|
|
156
|
+
return resolveJobCwd(session.cwd, input.cwd);
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
export async function createLoopJob(host: Pick<ToolHost, "scheduler" | "session" | "ensureHost">, input: CreateJobInput, scope?: JobScope): Promise<LoopJob> {
|
|
160
|
+
const cwd = checkJobInput(input, host.scheduler.store.load(), host.session());
|
|
132
161
|
const job: LoopJob = {
|
|
133
162
|
id: newId("cron"),
|
|
134
163
|
name: input.name,
|
|
135
164
|
schedule: input.schedule,
|
|
136
165
|
stateful: input.stateful,
|
|
137
166
|
prompt: input.prompt,
|
|
138
|
-
cwd
|
|
139
|
-
// Captured now
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
167
|
+
cwd,
|
|
168
|
+
// Captured now for a loop: it runs in the background, which may be on another model. A plain
|
|
169
|
+
// job runs in its conversation, on whatever model that conversation is on when it fires.
|
|
170
|
+
model: input.model ?? (input.stateful ? host.session().model : undefined),
|
|
171
|
+
thinking: input.thinking ?? (input.stateful ? host.session().thinking : undefined),
|
|
172
|
+
// Named tools may not add a built-in tool the chat (or the run) making it does not have. Trimmed,
|
|
173
|
+
// as 0.22 did, and said by the caller (`toolsDropped`): refusing would fail a recipe's install.
|
|
174
|
+
tools: input.tools && host.session().builtinTools ? input.tools.filter((t) => !PI_BUILTIN_TOOL_NAMES.includes(t) || host.session().builtinTools!.includes(t)) : input.tools,
|
|
175
|
+
// The loop's declared tools: those of the chat it is made in, unless it names its own.
|
|
176
|
+
builtinTools: input.tools ? undefined : host.session().builtinTools,
|
|
143
177
|
enabled: true,
|
|
144
178
|
verify: input.stateful && input.verify ? true : undefined,
|
|
145
179
|
checkerModel: input.stateful && input.verify ? input.checkerModel : undefined,
|
|
@@ -148,18 +182,57 @@ export async function createLoopJob(host: Pick<ToolHost, "scheduler" | "session"
|
|
|
148
182
|
recipe: input.recipe,
|
|
149
183
|
createdAt: stamp(),
|
|
150
184
|
// A sub-agent schedules on behalf of the session that runs it.
|
|
151
|
-
createdBy: { sessionId: scope?.parentSessionId ?? host.session().sessionId, cwd: scope?.parentCwd ?? host.session().cwd },
|
|
185
|
+
...(scope?.projectOwned ? { owner: "project" as const } : { createdBy: { sessionId: scope?.parentSessionId ?? host.session().sessionId, cwd: scope?.parentCwd ?? host.session().cwd } }),
|
|
152
186
|
sessionId: owningSessionId(input.stateful, host.session().sessionId, scope?.parentSessionId),
|
|
153
187
|
runCount: 0,
|
|
154
188
|
skippedOverlap: 0,
|
|
155
189
|
};
|
|
156
190
|
await host.scheduler.store.add(job);
|
|
191
|
+
if (job.stateful) host.ensureHost?.();
|
|
157
192
|
return job;
|
|
158
193
|
}
|
|
159
194
|
|
|
160
195
|
|
|
161
196
|
const deny = (text: string) => ({ content: [{ type: "text" as const, text }], isError: true, details: { id: undefined as string | undefined } });
|
|
162
197
|
|
|
198
|
+
/** `cron_create` says it at hop 0, where a person's message can arrive: the page's Watch box sends `Watch for: <sentence>` as a chat message, and only the question the create tools ask may answer it. Said once: both tools are in the same list. */
|
|
199
|
+
const WATCH_SENTENCE = " A message that starts with `Watch for:` comes from the page's Watch box: answer it with one of these two tools (new_trigger or cron_create); if it names no time and nothing to wait for, call neither, do nothing, and say it reads like something to do now. Never do the action of a `Watch for:` message yourself.";
|
|
200
|
+
|
|
201
|
+
/** The create tools ask first: hop 0, a person present, and the person's own turn, on every surface pi has a dialog. */
|
|
202
|
+
function asking(scope: ToolScope, host: ToolHost, ctx: ExtensionContext): boolean {
|
|
203
|
+
return scope.hop === 0 && ctx.hasUI && !host.scheduledTurn?.();
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
/** What the tool says when the person did not set the watch up: nothing exists, and the model is told not to do it some other way. */
|
|
207
|
+
function notSetUp(reason: "Cancel" | "aborted" | "timed out") {
|
|
208
|
+
return {
|
|
209
|
+
content: [{ type: "text" as const, text: `cancelled: the person did not set up this watch (${reason}). Nothing was created. Do not do the action now and do not call this tool again for it.` }],
|
|
210
|
+
details: { id: undefined as string | undefined },
|
|
211
|
+
};
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
/**
|
|
215
|
+
* The question, through pi's own select: the card as its title, the fixed options, the tool's own
|
|
216
|
+
* abort signal and the thirty-minute timeout. pi answers `undefined` for Esc, an abort and the
|
|
217
|
+
* timeout alike; Esc is the person's Cancel. An answer that was not offered is a cancel too.
|
|
218
|
+
*/
|
|
219
|
+
async function askWatch(ctx: ExtensionContext, signal: AbortSignal | undefined, host: ToolHost, plan: WatchPlan): Promise<Extract<WatchChoice, { verdict: "set" }> | ReturnType<typeof notSetUp>> {
|
|
220
|
+
const options = watchOptions(plan);
|
|
221
|
+
const asked = Date.now();
|
|
222
|
+
const answer = await ctx.ui.select(askTitle(planCard(plan, { now: asked, pollSecs: host.triggers.pollIntervalSecs })), options, { signal, timeout: WATCH_ASK_MS });
|
|
223
|
+
const choice = watchChoice(answer !== undefined && options.includes(answer) ? answer : undefined);
|
|
224
|
+
if (choice.verdict === "set") return choice;
|
|
225
|
+
return notSetUp(signal?.aborted ? "aborted" : answer === undefined && Date.now() - asked >= WATCH_ASK_MS ? "timed out" : "Cancel");
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
/**
|
|
229
|
+
* What a create result adds while nothing runs in the background, so the model does not tell the person
|
|
230
|
+
* that a paused job will run. The same reason `cron_list` shows, and both ways to end it.
|
|
231
|
+
*/
|
|
232
|
+
function pausedLine(reason: string | undefined): string {
|
|
233
|
+
return reason ? `\nPAUSED: ${reason}. Nothing runs in the background until pi-loops starts again: /pi-loops start in pi, or pi-loops start in a shell.` : "";
|
|
234
|
+
}
|
|
235
|
+
|
|
163
236
|
/** Trigger rules, rendered for a tool result. */
|
|
164
237
|
function renderTriggerRulesForTool(rules: ReturnType<TriggerStore["load"]>, host: Pick<ToolHost, "session">): string {
|
|
165
238
|
if (!rules.length) return "dynamic trigger rules: none";
|
|
@@ -167,15 +240,20 @@ function renderTriggerRulesForTool(rules: ReturnType<TriggerStore["load"]>, host
|
|
|
167
240
|
}
|
|
168
241
|
|
|
169
242
|
/**
|
|
170
|
-
* When the job next runs
|
|
171
|
-
*
|
|
172
|
-
*
|
|
243
|
+
* When the job next runs, or undefined: disabled, or a plain job that is asleep or paused. A next
|
|
244
|
+
* run for a job nothing will dispatch is a time nothing intends to keep, and a model reads it as a
|
|
245
|
+
* promise; one held by another window is shown, with the pid it runs in.
|
|
173
246
|
*/
|
|
174
|
-
function nextRunForTool(job: LoopJob, now: number,
|
|
175
|
-
if (!job.enabled || !
|
|
247
|
+
function nextRunForTool(job: LoopJob, now: number, view: OwnerView): number | undefined {
|
|
248
|
+
if (!job.enabled || !runsSomewhere(job, view)) return undefined;
|
|
176
249
|
return computeNext({ schedule: job.schedule, createdAt: Date.parse(job.createdAt), lastFiredAt: job.lastFiredAt ? Date.parse(job.lastFiredAt) : undefined }, now);
|
|
177
250
|
}
|
|
178
251
|
|
|
252
|
+
/** A plain job's prompt waiting in its conversation's queue (src/engine.ts `queuedPrompt`); a loop has no queue. */
|
|
253
|
+
function queuedForTool(job: LoopJob, host: Pick<ToolHost, "scheduler">, now: number): QueuedPrompt | undefined {
|
|
254
|
+
return !job.stateful && job.sessionId ? queuedPrompt(job, host.scheduler.requests.list(job.sessionId), now) : undefined;
|
|
255
|
+
}
|
|
256
|
+
|
|
179
257
|
/** Cron jobs, rendered for a tool result. */
|
|
180
258
|
function renderCronJobsForTool(jobs: LoopJob[], host: Pick<ToolHost, "session" | "scheduler">): string {
|
|
181
259
|
if (!jobs.length) return "cron jobs: none";
|
|
@@ -186,11 +264,15 @@ function renderCronJobsForTool(jobs: LoopJob[], host: Pick<ToolHost, "session" |
|
|
|
186
264
|
const inbox = jobs.some((j) => j.stateful) ? host.scheduler.inbox.list() : [];
|
|
187
265
|
for (const job of jobs) {
|
|
188
266
|
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}` : ""}`);
|
|
189
|
-
const
|
|
190
|
-
|
|
191
|
-
const
|
|
267
|
+
const view = host.scheduler.ownerView();
|
|
268
|
+
const asleep = asleepNote(job, view);
|
|
269
|
+
const kind = whereRuns(job, view).kind;
|
|
270
|
+
if (asleep) lines.push(` ${asleep}${kind === "asleep" ? " (no next run here)" : kind === "paused" ? " (no next run until pi-loops starts again)" : kind === "aside" ? "" : " (runs there, not here)"}`);
|
|
271
|
+
const next = nextRunForTool(job, now, view);
|
|
192
272
|
if (next) lines.push(` next_run: ${stamp(next)}`);
|
|
193
273
|
if (job.running) lines.push(` running_run_id: ${job.running.runId}`);
|
|
274
|
+
const queued = queuedForTool(job, host, now);
|
|
275
|
+
if (queued) lines.push(` queued: ${formatWait(now - Date.parse(queued.since))} (since ${queued.since})${queued.reason ? ` — ${queued.reason}` : ""}`);
|
|
194
276
|
if (job.lastError) lines.push(` last_error: ${previewRedacted(job.lastError, 120)}`);
|
|
195
277
|
if (job.consecutiveFailures) lines.push(` consecutive_failures: ${job.consecutiveFailures}`);
|
|
196
278
|
if (job.skippedOverlap) lines.push(` skipped_overlap_count: ${job.skippedOverlap}`);
|
|
@@ -205,6 +287,36 @@ function renderCronJobsForTool(jobs: LoopJob[], host: Pick<ToolHost, "session" |
|
|
|
205
287
|
return lines.join("\n");
|
|
206
288
|
}
|
|
207
289
|
|
|
290
|
+
/** The rule, made: the one write both ways to `new_trigger` end in, and the text the model reads on every surface. */
|
|
291
|
+
async function addRule(host: ToolHost, input: Parameters<TriggerStore["add"]>[0]) {
|
|
292
|
+
const rule = await host.triggers.store.add(input);
|
|
293
|
+
host.refreshBadge();
|
|
294
|
+
host.ensureHost?.();
|
|
295
|
+
return {
|
|
296
|
+
content: [{ type: "text" as const, 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 sub-agent in the background)${pausedLine(host.scheduler.ownerView().hostPaused)}` }],
|
|
297
|
+
details: { id: rule.id as string | undefined, condition: rule.condition, action: rule.action, enabled: rule.enabled, fire_once: rule.fireOnce, fired_at: rule.firedAt, promote_to_chat: rule.promoteToChat },
|
|
298
|
+
};
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
/** The job, made: the one write both ways to `cron_create` end in, and the text the model reads on every surface. */
|
|
302
|
+
async function createCron(host: ToolHost, scope: ToolScope, input: CreateJobInput) {
|
|
303
|
+
const job = await host.createJob(input, scope);
|
|
304
|
+
const auditEntryId = host.cronControlAudit("add", scope.actor, undefined, job);
|
|
305
|
+
host.refreshBadge();
|
|
306
|
+
const next = computeNext({ schedule: job.schedule, createdAt: Date.parse(job.createdAt) }, Date.now());
|
|
307
|
+
// The model tells the person, so it has to know the costs a person would otherwise hear in /cron add.
|
|
308
|
+
const tools = job.tools ? `the tools ${job.tools.join(", ")}` : job.builtinTools ? `this chat's built-in tools (${job.builtinTools.join(", ") || "none"})` : "pi's default tools";
|
|
309
|
+
const where = job.stateful
|
|
310
|
+
? `Findings will appear in /inbox${job.verify ? " after an independent checker reviews them" : ""}. It runs in the background with ${tools}, not this window's -e extensions or flags.`
|
|
311
|
+
: `Its result will appear in this chat. While this chat is closed it still runs, in the background, and each run is a model turn. Its turns run with ${tools} under the dangerous-command guard: in an open window a dangerous command or a tool outside them asks the user first, and in the background it is refused. In the background they also run without this window's -e extensions or flags.`;
|
|
312
|
+
const runsAt = whereRuns(job, host.scheduler.ownerView());
|
|
313
|
+
// Three lines — what was created, when it runs, what it will do — then where its output goes.
|
|
314
|
+
return {
|
|
315
|
+
content: [{ type: "text" as const, 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}${pausedLine(runsAt.kind === "paused" ? runsAt.reason : undefined)}` }],
|
|
316
|
+
details: { id: job.id as string | undefined, 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 },
|
|
317
|
+
};
|
|
318
|
+
}
|
|
319
|
+
|
|
208
320
|
export interface ToolScope {
|
|
209
321
|
hop: number;
|
|
210
322
|
actor: "tool" | "sub-agent";
|
|
@@ -218,6 +330,8 @@ export interface ToolScope {
|
|
|
218
330
|
*/
|
|
219
331
|
export function automationTools(scope: ToolScope, host: ToolHost): ToolDefinition<any, any>[] {
|
|
220
332
|
const defs: ToolDefinition<any, any>[] = [];
|
|
333
|
+
// Only where a person's message can arrive: a sub-agent is told nothing about it.
|
|
334
|
+
const watchSentence = scope.hop === 0 ? WATCH_SENTENCE : "";
|
|
221
335
|
// Generic so each definition keeps its inferred parameter type, exactly as pi.registerTool does.
|
|
222
336
|
const register = <T extends TSchema, D>(d: ToolDefinition<T, D>): void => void defs.push(d as ToolDefinition<any, any>);
|
|
223
337
|
register({
|
|
@@ -231,13 +345,13 @@ export function automationTools(scope: ToolScope, host: ToolHost): ToolDefinitio
|
|
|
231
345
|
action: Type.String({ description: "The action to perform when the condition matches. This may be a shell command or a natural-language instruction." }),
|
|
232
346
|
spec: Type.Optional(Type.String({ description: "Fallback complete trigger rule text when condition and action cannot be supplied separately." })),
|
|
233
347
|
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." })),
|
|
234
|
-
promote_to_chat: Type.Optional(Type.Boolean({ description: "Whether
|
|
348
|
+
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." })),
|
|
235
349
|
},
|
|
236
350
|
{ additionalProperties: false },
|
|
237
351
|
),
|
|
238
|
-
async execute(_id, params,
|
|
352
|
+
async execute(_id, params, signal, _onUpdate, ctx) {
|
|
239
353
|
if ([params.condition, params.action, params.spec].some((t) => t && looksLikeFixedScheduleRequest(t))) return deny("fixed scheduled jobs must use cron_create, not new_trigger");
|
|
240
|
-
let condition = params.condition
|
|
354
|
+
let condition = cleanCondition(params.condition ?? "");
|
|
241
355
|
let action = params.action?.trim() ?? "";
|
|
242
356
|
const fromSpec = !condition || !action;
|
|
243
357
|
if (fromSpec) {
|
|
@@ -248,15 +362,21 @@ export function automationTools(scope: ToolScope, host: ToolHost): ToolDefinitio
|
|
|
248
362
|
return deny(err?.message ?? String(err));
|
|
249
363
|
}
|
|
250
364
|
}
|
|
365
|
+
const s = host.session();
|
|
366
|
+
const draft = { condition, action, cwd: s.cwd, sessionId: s.sessionId, model: s.model, thinking: s.thinking };
|
|
367
|
+
if (asking(scope, host, ctx)) {
|
|
368
|
+
// The question replaces the confirm, never adds to it: one question. Its defaults are the
|
|
369
|
+
// person's to change, not the model's: Result starts at this conversation whatever
|
|
370
|
+
// `promote_to_chat` said (its documented default is not a choice anyone made); Repeats
|
|
371
|
+
// follows `fire_once`, whose default is the rule's own.
|
|
372
|
+
const asked = await askWatch(ctx, signal, host, { kind: "rule", rule: draft, where: "chat", once: params.fire_once ?? true });
|
|
373
|
+
if (!("verdict" in asked)) return asked;
|
|
374
|
+
return addRule(host, { ...draft, fireOnce: asked.once, promoteToChat: asked.where === "chat" });
|
|
375
|
+
}
|
|
251
376
|
const reason = fromSpec ? "create dynamic trigger from `spec` field" : "create dynamic trigger from `condition` + `action` fields";
|
|
252
377
|
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);
|
|
253
378
|
if (denied) return deny(denied);
|
|
254
|
-
|
|
255
|
-
host.refreshBadge();
|
|
256
|
-
return {
|
|
257
|
-
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)` }],
|
|
258
|
-
details: { id: rule.id as string | undefined, condition: rule.condition, action: rule.action, enabled: rule.enabled, fire_once: rule.fireOnce, fired_at: rule.firedAt, promote_to_chat: rule.promoteToChat },
|
|
259
|
-
};
|
|
379
|
+
return addRule(host, { ...draft, fireOnce: params.fire_once ?? true, promoteToChat: params.promote_to_chat ?? false });
|
|
260
380
|
},
|
|
261
381
|
});
|
|
262
382
|
register({
|
|
@@ -296,7 +416,7 @@ export function automationTools(scope: ToolScope, host: ToolHost): ToolDefinitio
|
|
|
296
416
|
}
|
|
297
417
|
if (!params.id) return err("missing required arg: id");
|
|
298
418
|
const rule = resolveRuleRefScoped(host.triggers.store.load(), params.id, host.session().cwd);
|
|
299
|
-
if (!rule) return err(`no dynamic trigger rule with id '${params.id}'`);
|
|
419
|
+
if (!rule) return err(`no dynamic trigger rule with id '${params.id}'; list_triggers shows the ids`);
|
|
300
420
|
const denied = await host.confirmTool(ctx, { label: `remove dynamic trigger ${rule.id}`, tool: "remove_trigger", reason: "remove a dynamic trigger rule by `id`", preview: `when ${previewRedacted(rule.condition, 120)}`, args: params }, scope.hop);
|
|
301
421
|
if (denied) return err(denied);
|
|
302
422
|
await host.triggers.store.remove(rule.id);
|
|
@@ -314,7 +434,7 @@ export function automationTools(scope: ToolScope, host: ToolHost): ToolDefinitio
|
|
|
314
434
|
async execute(_id, params, _signal, _onUpdate, ctx) {
|
|
315
435
|
const ruleCwd = host.session().cwd;
|
|
316
436
|
const rule = resolveRuleRefScoped(host.triggers.store.load(), params.id, ruleCwd);
|
|
317
|
-
if (!rule) return deny(`no dynamic trigger rule with id '${params.id}'`);
|
|
437
|
+
if (!rule) return deny(`no dynamic trigger rule with id '${params.id}'; list_triggers shows the ids`);
|
|
318
438
|
// Re-enabling is gated; pausing another project's rule is just as much a surprise, so it
|
|
319
439
|
// is gated too (a sub-agent is refused outright, having nobody to ask).
|
|
320
440
|
if (params.enabled || rule.cwd !== ruleCwd) {
|
|
@@ -333,48 +453,53 @@ export function automationTools(scope: ToolScope, host: ToolHost): ToolDefinitio
|
|
|
333
453
|
name: "cron_create",
|
|
334
454
|
label: "Create cron job",
|
|
335
455
|
description:
|
|
336
|
-
"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\".",
|
|
456
|
+
"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,
|
|
337
457
|
parameters: Type.Object({
|
|
338
458
|
schedule: Type.String({
|
|
339
459
|
description: 'Local-time schedule: 5-field cron ("0 9 * * *", "*/30 * * * 1-5"), "@daily", "every 30m", "in 10m", or "at 2026-09-08T18:00".',
|
|
340
460
|
}),
|
|
341
461
|
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." }),
|
|
342
|
-
stateful: Type.Optional(Type.Boolean({ description: "Loop mode: sub-agent + notes between runs + findings to
|
|
462
|
+
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." })),
|
|
343
463
|
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." })),
|
|
344
464
|
name: Type.Optional(Type.String({ description: "Short unique label (letters, digits, . _ -)." })),
|
|
345
465
|
cwd: Type.Optional(Type.String({ description: "Directory a stateful job's sub-agent runs in. Default: current project." })),
|
|
346
|
-
catch_up: Type.Optional(Type.Boolean({ description: "Run once for a tick that was missed: a loop when
|
|
466
|
+
catch_up: Type.Optional(Type.Boolean({ description: "Run once for a tick that was missed while nothing could run the job: a loop when pi-loops next starts, a plain job when its conversation is next opened or pi-loops next starts (default: true for stateful jobs, false for plain jobs)." })),
|
|
347
467
|
}),
|
|
348
|
-
async execute(_id, params) {
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
468
|
+
async execute(_id, params, signal, _onUpdate, ctx) {
|
|
469
|
+
let schedule: LoopJob["schedule"];
|
|
470
|
+
try {
|
|
471
|
+
schedule = parseSchedule(params.schedule);
|
|
472
|
+
} catch (err: any) {
|
|
473
|
+
return deny(err?.message ?? String(err));
|
|
474
|
+
}
|
|
475
|
+
if (asking(scope, host, ctx)) {
|
|
476
|
+
// A schedule's Result starts at the inbox (a loop) unless the model said plainly that it
|
|
477
|
+
// belongs in this chat. Checked now as a create would be, so the person is never asked
|
|
478
|
+
// about a job their yes cannot make.
|
|
479
|
+
const where = params.stateful === false && !params.verify ? "chat" : "inbox";
|
|
480
|
+
const draft = { schedule, prompt: params.action, verify: params.verify ?? false, name: params.name, catchUp: params.catch_up };
|
|
481
|
+
let cwd: string;
|
|
482
|
+
try {
|
|
483
|
+
cwd = checkJobInput({ ...draft, stateful: where === "inbox", cwd: params.cwd }, host.scheduler.store.load(), host.session());
|
|
484
|
+
} catch (err: any) {
|
|
485
|
+
return deny(err?.message ?? String(err));
|
|
486
|
+
}
|
|
487
|
+
// The question is the consent, and it does not show a directory: a job it sets up runs in
|
|
488
|
+
// this project, so a `cwd` elsewhere (a prompt-injected one, say) is refused here.
|
|
489
|
+
if (!withinProject(host.session().cwd, cwd)) return deny(`a watch you are asked about runs in this project (${host.session().cwd}): cwd ${cwd} is outside it; /cron add --cwd sets one up elsewhere`);
|
|
490
|
+
const asked = await askWatch(ctx, signal, host, { kind: "schedule", job: { ...draft, cwd }, where });
|
|
491
|
+
if (!("verdict" in asked)) return asked;
|
|
492
|
+
return createCron(host, scope, { ...draft, cwd, stateful: asked.where === "inbox", verify: asked.where === "inbox" && asked.checks });
|
|
493
|
+
}
|
|
494
|
+
// verify implies a loop: a checker only reviews loop findings (`args.ts` does the same for `--verify`)
|
|
495
|
+
return createCron(host, scope, { schedule, prompt: params.action, stateful: params.stateful ?? params.verify ?? false, verify: params.verify ?? false, name: params.name, cwd: params.cwd, catchUp: params.catch_up });
|
|
371
496
|
},
|
|
372
497
|
});
|
|
373
498
|
|
|
374
499
|
register({
|
|
375
500
|
name: "cron_list",
|
|
376
501
|
label: "List cron jobs",
|
|
377
|
-
description: "List the current project's scheduled jobs with schedule, next run, [stateful] marker and last error. Also reports how many unread inbox findings exist. Set all_projects only when the user asks about other projects.",
|
|
502
|
+
description: "List the current project's scheduled jobs with schedule, next run, [stateful] marker and last error. A plain job whose prompt is waiting in its conversation's queue reports `queued` (since when, and the wait reason when the queue says one). Also reports how many unread inbox findings exist. Set all_projects only when the user asks about other projects.",
|
|
378
503
|
parameters: Type.Object({
|
|
379
504
|
all_projects: Type.Optional(Type.Boolean({ description: "Include jobs of every project on this machine (default false)." })),
|
|
380
505
|
}),
|
|
@@ -382,10 +507,13 @@ export function automationTools(scope: ToolScope, host: ToolHost): ToolDefinitio
|
|
|
382
507
|
const listCwd = host.session().cwd;
|
|
383
508
|
const everywhere = params.all_projects === true && scope.hop === 0;
|
|
384
509
|
const jobs = everywhere ? host.scheduler.store.load() : host.scheduler.store.load().filter((j) => sameProject(j.cwd, listCwd));
|
|
385
|
-
const
|
|
510
|
+
const newFindings = everywhere ? host.scheduler.inbox.newCount() : inProject(host.scheduler.inbox.listNew(), listCwd, sameProject).length;
|
|
511
|
+
const text = `${renderCronJobsForTool(jobs, host)}\ninbox: ${plural(newFindings, "new finding")}`;
|
|
386
512
|
const nowMs = Date.now();
|
|
387
|
-
const
|
|
388
|
-
|
|
513
|
+
const view = host.scheduler.ownerView();
|
|
514
|
+
const nextRun = (j: LoopJob) => nextRunForTool(j, nowMs, view);
|
|
515
|
+
const heldBy = (j: LoopJob) => { const where = whereRuns(j, view); return where.kind === "terminal" ? where.pid : undefined; };
|
|
516
|
+
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, consecutive_failures: j.consecutiveFailures, skipped_overlap_count: j.skippedOverlap, next_run: (() => { const n = nextRun(j); return n ? stamp(n) : undefined; })(), owner_session: ownerSession(j), asleep: whereRuns(j, view).kind === "asleep" || undefined, held_by_pid: heldBy(j), queued: queuedForTool(j, host, nowMs), created_at: j.createdAt })) } };
|
|
389
517
|
},
|
|
390
518
|
});
|
|
391
519
|
|
|
@@ -400,7 +528,7 @@ export function automationTools(scope: ToolScope, host: ToolHost): ToolDefinitio
|
|
|
400
528
|
}),
|
|
401
529
|
async execute(_id, params, _signal, _onUpdate, ctx) {
|
|
402
530
|
const job = resolveJobRefScoped(host.scheduler.store.load(), params.ref, host.session().cwd);
|
|
403
|
-
if (!job) return { content: [{ type: "text", text: `no cron job
|
|
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, confirmation_required: false, audit_entry_id: undefined as string | undefined } };
|
|
404
532
|
const label = `${job.id}${job.name ? ` "${job.name}"` : ""}`;
|
|
405
533
|
if (!params.confirm) {
|
|
406
534
|
return {
|
|
@@ -440,14 +568,17 @@ export function automationTools(scope: ToolScope, host: ToolHost): ToolDefinitio
|
|
|
440
568
|
async execute(_id, params, _signal, _onUpdate, ctx) {
|
|
441
569
|
const jobCwd = host.session().cwd;
|
|
442
570
|
const job = resolveJobRefScoped(host.scheduler.store.load(), params.ref, jobCwd);
|
|
443
|
-
if (!job) return deny(`no cron job
|
|
571
|
+
if (!job) return deny(`no cron job matching '${params.ref}' in this project; cron_list shows the ids and names`);
|
|
444
572
|
if (params.enabled || job.cwd !== jobCwd) {
|
|
445
573
|
const denied = await host.confirmTool(ctx, { label: `${params.enabled ? "enable" : "disable"} cron job ${job.name ?? job.id}`, tool: "set_cron_job_state", reason: params.enabled ? "enable a cron job from a model-facing tool (`enabled` = true)" : "disable a cron job of another project", preview: `${formatSchedule(job.schedule)}: ${previewRedacted(job.prompt, 120)}`, args: params }, scope.hop);
|
|
446
|
-
if (denied) return deny(
|
|
574
|
+
if (denied) return deny(denied);
|
|
447
575
|
}
|
|
448
576
|
const updated = (await host.scheduler.store.update(job.id, (j) => {
|
|
449
577
|
j.enabled = params.enabled;
|
|
450
|
-
if (params.enabled)
|
|
578
|
+
if (params.enabled) {
|
|
579
|
+
j.lastError = undefined;
|
|
580
|
+
j.interruptions = undefined;
|
|
581
|
+
}
|
|
451
582
|
})) ?? job;
|
|
452
583
|
const auditEntryId = host.cronControlAudit(params.enabled ? "enable" : "disable", scope.actor, job, updated);
|
|
453
584
|
return {
|