@alphacoder-v0/pi-loops 0.22.7 → 0.24.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (91) hide show
  1. package/CHANGELOG.md +681 -0
  2. package/CONTRACT.md +7 -7
  3. package/README.md +91 -63
  4. package/README.zh-CN.md +59 -39
  5. package/docs/cli.md +272 -83
  6. package/docs/configuration.md +86 -24
  7. package/docs/design.md +395 -56
  8. package/docs/downstream.md +2 -2
  9. package/docs/goal.md +12 -3
  10. package/docs/hooks.md +6 -7
  11. package/docs/loops.md +288 -59
  12. package/docs/mcp.md +22 -14
  13. package/docs/recipes.md +6 -0
  14. package/docs/screenshot.png +0 -0
  15. package/docs/triggers.md +120 -26
  16. package/docs/troubleshooting.md +37 -26
  17. package/docs/web-ui-parity.md +691 -65
  18. package/package.json +4 -3
  19. package/recipes/autoresearch/recipe.toml +1 -0
  20. package/recipes/changelog-draft/recipe.toml +1 -0
  21. package/recipes/ci-sweeper/recipe.toml +1 -0
  22. package/recipes/daily-digest/recipe.toml +1 -0
  23. package/recipes/deps-sweeper/recipe.toml +1 -0
  24. package/recipes/ecosystem/recipe.toml +1 -0
  25. package/recipes/issue-loop/recipe.toml +1 -0
  26. package/recipes/pr-watch/recipe.toml +1 -0
  27. package/skills/pi-loops/SKILL.md +6 -4
  28. package/src/activity.ts +199 -0
  29. package/src/archive.ts +21 -9
  30. package/src/args.ts +6 -2
  31. package/src/cli-entry.mjs +12 -3
  32. package/src/cli.ts +746 -125
  33. package/src/config.ts +53 -5
  34. package/src/conversation-rows.ts +469 -0
  35. package/src/danger.ts +227 -6
  36. package/src/engine.ts +1240 -0
  37. package/src/goal.ts +71 -0
  38. package/src/held-read.ts +27 -0
  39. package/src/hooks.ts +49 -18
  40. package/src/host-control-channel.ts +455 -40
  41. package/src/host-control.ts +602 -111
  42. package/src/host-entry.mjs +5 -3
  43. package/src/host-runtime.ts +455 -41
  44. package/src/host.ts +659 -123
  45. package/src/inbox-card.ts +278 -0
  46. package/src/inbox.ts +139 -62
  47. package/src/job-edit.ts +7 -1
  48. package/src/job-health.ts +14 -2
  49. package/src/job-lines.ts +201 -13
  50. package/src/job-owner.ts +74 -13
  51. package/src/job-tune.ts +110 -0
  52. package/src/lease.ts +537 -0
  53. package/src/lock.ts +7 -2
  54. package/src/mcp-pool.ts +66 -14
  55. package/src/mcp.ts +1 -1
  56. package/src/open-file.ts +54 -0
  57. package/src/page-children.ts +1507 -0
  58. package/src/page-devices.ts +192 -0
  59. package/src/page-server.ts +3150 -0
  60. package/src/path-precheck.ts +215 -0
  61. package/src/paths.ts +24 -0
  62. package/src/pi-floor.ts +10 -0
  63. package/src/pi-loops.ts +1301 -450
  64. package/src/pi-resolver.d.mts +1 -0
  65. package/src/pi-resolver.mjs +110 -10
  66. package/src/presence.ts +12 -63
  67. package/src/protocol.ts +10 -1
  68. package/src/recipe.ts +43 -1
  69. package/src/requests.ts +354 -0
  70. package/src/rule-lines.ts +68 -0
  71. package/src/run-card.ts +105 -0
  72. package/src/runner.ts +18 -1
  73. package/src/schedule.ts +1 -2
  74. package/src/scheduler.ts +598 -245
  75. package/src/sdk-runner.ts +321 -27
  76. package/src/seen.ts +278 -0
  77. package/src/session-head.ts +21 -0
  78. package/src/share.ts +12 -2
  79. package/src/snapshot.ts +3 -2
  80. package/src/store.ts +81 -8
  81. package/src/tools.ts +196 -65
  82. package/src/trigger-runtime.ts +164 -127
  83. package/src/triggers.ts +39 -7
  84. package/src/trust.ts +46 -2
  85. package/src/ui-prefs.ts +16 -5
  86. package/src/watch-plan.ts +174 -0
  87. package/src/web.d.mts +3 -0
  88. package/src/web.mjs +2260 -2340
  89. package/src/window-executor.ts +459 -0
  90. package/src/window-guard.ts +158 -0
  91. 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, runsIn } from "./job-owner.ts";
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 "./presence.ts";
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
- export async function createLoopJob(host: Pick<ToolHost, "scheduler" | "session">, input: CreateJobInput, scope?: JobScope): Promise<LoopJob> {
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 && !host.session().sessionId) throw new Error("a non-stateful cron job needs a persistent chat session to inject into (not --no-session, not the background host); use stateful=true");
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: resolveJobCwd(host.session().cwd, input.cwd),
139
- // Captured now: the run happens in whichever pi owns the timer, and that one may be on another model.
140
- model: input.model ?? host.session().model,
141
- thinking: input.thinking ?? host.session().thinking,
142
- tools: input.tools,
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 *here*, or undefined: disabled, or a plain job of a session this process
171
- * does not hold. The list is filtered the way dispatch filters — a next run for a job nothing here
172
- * will dispatch is a time nothing intends to keep, and a model reads it as a promise.
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, sessionId: string | undefined): number | undefined {
175
- if (!job.enabled || !runsIn(job, sessionId)) return undefined;
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 asleep = asleepNote(job, host.session().sessionId);
190
- if (asleep) lines.push(` ${asleep} (no next run here)`);
191
- const next = nextRunForTool(job, now, host.session().sessionId);
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 successful trigger output should be inserted into the parent chat context so future turns can see it. Defaults to false unless the user explicitly asks for that behavior." })),
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, _signal, _onUpdate, ctx) {
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?.trim() ?? "";
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
- 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 });
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 /inbox (default false)." })),
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 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)." })),
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
- const schedule = parseSchedule(params.schedule);
350
- const job = await host.createJob(
351
- {
352
- schedule,
353
- prompt: params.action,
354
- stateful: params.stateful ?? params.verify ?? false, // verify implies a loop: a checker only reviews loop findings (`args.ts` does the same for `--verify`)
355
- verify: params.verify ?? false,
356
- name: params.name,
357
- cwd: params.cwd,
358
- catchUp: params.catch_up,
359
- },
360
- scope,
361
- );
362
- const auditEntryId = host.cronControlAudit("add", scope.actor, undefined, job);
363
- host.refreshBadge();
364
- const next = computeNext({ schedule: job.schedule, createdAt: Date.parse(job.createdAt) }, Date.now());
365
- const where = job.stateful ? `Findings will appear in /inbox${job.verify ? " after an independent checker reviews them" : ""}.` : "Its result will appear in this chat.";
366
- // Three lines — what was created, when it runs, what it will do — then where its output goes.
367
- return {
368
- 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}` }],
369
- 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 },
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 text = `${renderCronJobsForTool(jobs, host)}\ninbox: ${host.scheduler.inbox.newCount()} new finding(s)`;
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 nextRun = (j: LoopJob) => nextRunForTool(j, nowMs, host.session().sessionId);
388
- 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: !runsIn(j, host.session().sessionId) || undefined, created_at: j.createdAt })) } };
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 with id '${params.ref}'` }], isError: true, details: { id: undefined as string | undefined, removed_count: 0, confirmation_required: false, audit_entry_id: undefined as string | undefined } };
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 with id '${params.ref}'`);
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(`${denied}; use /cron enable <id>`);
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) j.lastError = undefined;
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 {