@alphacoder-v0/pi-loops 0.17.3
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 +1989 -0
- package/LICENSE +21 -0
- package/README.md +370 -0
- package/README.zh-CN.md +606 -0
- package/docs/cli.md +252 -0
- package/docs/configuration.md +110 -0
- package/docs/design.md +155 -0
- package/docs/goal.md +59 -0
- package/docs/hooks.md +110 -0
- package/docs/loops.md +315 -0
- package/docs/mcp.md +90 -0
- package/docs/screenshot.png +0 -0
- package/docs/session-archive.md +64 -0
- package/docs/triggers.md +140 -0
- package/docs/troubleshooting.md +111 -0
- package/docs/web-ui-parity.md +305 -0
- package/examples/README.md +18 -0
- package/examples/mcp-notify-server.mjs +35 -0
- package/examples/mcp.toml +8 -0
- package/package.json +71 -0
- package/skills/pi-loops/SKILL.md +50 -0
- package/src/archive.ts +476 -0
- package/src/args.ts +217 -0
- package/src/cli-entry.mjs +23 -0
- package/src/cli.ts +610 -0
- package/src/config.ts +103 -0
- package/src/danger.ts +128 -0
- package/src/goal.ts +178 -0
- package/src/hooks.ts +436 -0
- package/src/host-control-channel.ts +243 -0
- package/src/host-control.ts +203 -0
- package/src/host-entry.mjs +14 -0
- package/src/host-runtime.ts +222 -0
- package/src/host.ts +329 -0
- package/src/inbox.ts +232 -0
- package/src/job-edit.ts +113 -0
- package/src/job-health.ts +29 -0
- package/src/lock.ts +193 -0
- package/src/log.ts +108 -0
- package/src/mcp-pool.ts +141 -0
- package/src/mcp.ts +885 -0
- package/src/paths.ts +33 -0
- package/src/pi-loops.ts +2292 -0
- package/src/pi-resolver.mjs +49 -0
- package/src/presence.ts +143 -0
- package/src/protocol.ts +198 -0
- package/src/redact.ts +61 -0
- package/src/register-pi.mjs +3 -0
- package/src/runner.ts +149 -0
- package/src/schedule.ts +431 -0
- package/src/scheduler.ts +981 -0
- package/src/sdk-runner.ts +536 -0
- package/src/share.ts +121 -0
- package/src/slots.ts +76 -0
- package/src/snapshot.ts +40 -0
- package/src/store.ts +599 -0
- package/src/subagent-guard.ts +47 -0
- package/src/thinking.ts +30 -0
- package/src/toml.ts +244 -0
- package/src/tools.ts +455 -0
- package/src/transcript.ts +84 -0
- package/src/trigger-runtime.ts +711 -0
- package/src/triggers.ts +558 -0
- package/src/trust.ts +47 -0
- package/src/ts-entry.mjs +67 -0
- package/src/ui-prefs.ts +84 -0
- package/src/version.ts +14 -0
- package/src/web.mjs +4484 -0
package/src/tools.ts
ADDED
|
@@ -0,0 +1,455 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The model-facing automation tools:
|
|
3
|
+
* SetCronJobState and NewTrigger / ListTriggers / RemoveTrigger / SetTriggerState — as pi tool
|
|
4
|
+
* definitions. One factory serves the interactive session (hop 0, registered with pi), every
|
|
5
|
+
* in-process sub-session (hop 1, passed as customTools) and the headless host (hop 1, no UI).
|
|
6
|
+
*/
|
|
7
|
+
import type { ExtensionContext, ToolDefinition } from "@earendil-works/pi-coding-agent";
|
|
8
|
+
import * as os from "node:os";
|
|
9
|
+
import * as path from "node:path";
|
|
10
|
+
import { Type, type TSchema } from "typebox";
|
|
11
|
+
import { previewRedacted } from "./redact.ts";
|
|
12
|
+
import { computeNext, formatLocal, formatSchedule, parseSchedule, stamp } from "./schedule.ts";
|
|
13
|
+
import type { LoopScheduler, SessionSnapshot } from "./scheduler.ts";
|
|
14
|
+
import { MAX_PROMPT_BYTES, type LoopJob, newId, owningSessionId, resolveJobRef } from "./store.ts";
|
|
15
|
+
import type { TriggerRuntime } from "./trigger-runtime.ts";
|
|
16
|
+
import { withinProject } from "./presence.ts";
|
|
17
|
+
import { type TriggerStore, looksLikeFixedScheduleRequest, parseTriggerRule, resolveRuleRef, type DynamicTriggerRule } from "./triggers.ts";
|
|
18
|
+
|
|
19
|
+
export interface ControlPlaneRequest {
|
|
20
|
+
/** What is being approved, value-free. */
|
|
21
|
+
label: string;
|
|
22
|
+
tool: string;
|
|
23
|
+
/** Names the fields involved, never their values: a reason that quotes an argument leaks it into the audit. */
|
|
24
|
+
reason: string;
|
|
25
|
+
/** Redacted, bounded preview of the payload — the one place values are shown. */
|
|
26
|
+
preview: string;
|
|
27
|
+
args?: unknown;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** What the tools need from whoever hosts them (the extension, a sub-session, the headless host). */
|
|
31
|
+
export interface ToolHost {
|
|
32
|
+
scheduler: LoopScheduler;
|
|
33
|
+
triggers: TriggerRuntime;
|
|
34
|
+
session(): SessionSnapshot;
|
|
35
|
+
createJob(input: CreateJobInput, scope?: JobScope): Promise<LoopJob>;
|
|
36
|
+
/** The cron_control_plane audit; returns the entry id. */
|
|
37
|
+
cronControlAudit(op: "add" | "enable" | "disable" | "remove", actor: "slash" | "tool" | "sub-agent", before?: LoopJob, after?: LoopJob): string;
|
|
38
|
+
/** The confirmation gate; resolves to a denial text or undefined (allowed). */
|
|
39
|
+
confirmTool(ctx: ExtensionContext, req: ControlPlaneRequest, atHop: number): Promise<string | undefined>;
|
|
40
|
+
refreshBadge(): void;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export interface CreateJobInput {
|
|
44
|
+
schedule: LoopJob["schedule"];
|
|
45
|
+
prompt: string;
|
|
46
|
+
stateful: boolean;
|
|
47
|
+
name?: string;
|
|
48
|
+
cwd?: string;
|
|
49
|
+
model?: string;
|
|
50
|
+
thinking?: string;
|
|
51
|
+
tools?: string[];
|
|
52
|
+
timeoutMs?: number;
|
|
53
|
+
/** Default: stateful jobs catch up a missed tick, inject jobs do not. */
|
|
54
|
+
catchUp?: boolean;
|
|
55
|
+
verify?: boolean;
|
|
56
|
+
checkerModel?: string;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export interface JobScope {
|
|
60
|
+
parentSessionId?: string;
|
|
61
|
+
parentCwd?: string;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* A job's directory is always explicit: with no project of our own, `path.resolve` would silently
|
|
66
|
+
* mean $HOME. A relative `cwd` is resolved against the session's project when there is one; with no
|
|
67
|
+
* session project there is nothing to resolve against, so it has to be absolute — the headless
|
|
68
|
+
* host's process cwd *is* $HOME, and resolving a sub-agent's `cwd: "code/piz"` against it pinned
|
|
69
|
+
* the job to a real, unrelated project without saying so.
|
|
70
|
+
*/
|
|
71
|
+
function resolveJobCwd(sessionCwd: string, cwd: string | undefined): string {
|
|
72
|
+
if (!sessionCwd && !cwd) throw new Error("no project directory for this job: pass cwd");
|
|
73
|
+
if (!sessionCwd && !path.isAbsolute(cwd!)) throw new Error(`this job needs an absolute directory: there is no project to resolve "${cwd}" against`);
|
|
74
|
+
return cwd ? path.resolve(sessionCwd || process.cwd(), cwd) : path.resolve(sessionCwd);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** A worktree, a symlinked path or a subdirectory is the same project, as the runtime treats it. */
|
|
78
|
+
function sameProject(a: string, b: string): boolean {
|
|
79
|
+
return withinProject(b, a) || withinProject(a, b);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/** A rule by ref, preferring this project's; another project's needs its exact id. */
|
|
83
|
+
export function resolveRuleRefScoped(rules: DynamicTriggerRule[], ref: string, cwd: string): DynamicTriggerRule | undefined {
|
|
84
|
+
// Ordinals are what the user sees in `/triggers rules`; a model's list may be a different one,
|
|
85
|
+
// so the tools take an id, a unique prefix or a name — never a position.
|
|
86
|
+
if (/^\d+$/.test(ref.trim())) return undefined;
|
|
87
|
+
const mine = resolveRuleRef(
|
|
88
|
+
rules.filter((r) => withinProject(cwd, r.cwd) || withinProject(r.cwd, cwd)),
|
|
89
|
+
ref,
|
|
90
|
+
);
|
|
91
|
+
if (mine) return mine;
|
|
92
|
+
const trimmed = ref.trim();
|
|
93
|
+
return rules.find((r) => r.id === trimmed);
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/** A job by ref, preferring this project's; another project's needs its exact id. */
|
|
97
|
+
export function resolveJobRefScoped(jobs: LoopJob[], ref: string, cwd: string): LoopJob | undefined {
|
|
98
|
+
if (/^\d+$/.test(ref.trim())) return undefined;
|
|
99
|
+
const mine = resolveJobRef(
|
|
100
|
+
jobs.filter((j) => sameProject(j.cwd, cwd)),
|
|
101
|
+
ref,
|
|
102
|
+
);
|
|
103
|
+
if (mine) return mine;
|
|
104
|
+
const trimmed = ref.trim();
|
|
105
|
+
return jobs.find((j) => j.id === trimmed);
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* The rule for a job's name, in one place because both `/cron add` and `/cron set --name` have to
|
|
110
|
+
* apply it. A name is how a job is referred to (`/cron run ci`), so two jobs sharing one make every
|
|
111
|
+
* reference ambiguous, and the resolution silently picks whichever the lookup reaches first.
|
|
112
|
+
* `existing` excludes the job being renamed, so keeping a job's own name is not a collision.
|
|
113
|
+
*/
|
|
114
|
+
export function checkJobName(name: string | undefined, existing: LoopJob[]): void {
|
|
115
|
+
if (!name) return;
|
|
116
|
+
if (!/^[\w.-]{1,40}$/.test(name)) throw new Error("name must be 1-40 chars of letters, digits, . _ -");
|
|
117
|
+
if (existing.some((j) => j.name === name)) throw new Error(`a cron job named "${name}" already exists`);
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/** `/cron add` and `cron_create`: validate, fill the defaults, record who created it and where. */
|
|
121
|
+
export async function createLoopJob(host: Pick<ToolHost, "scheduler" | "session">, input: CreateJobInput, scope?: JobScope): Promise<LoopJob> {
|
|
122
|
+
if (!input.prompt.trim()) throw new Error("cron action cannot be empty");
|
|
123
|
+
if (Buffer.byteLength(input.prompt, "utf8") > MAX_PROMPT_BYTES) throw new Error(`cron action exceeds ${MAX_PROMPT_BYTES} bytes`);
|
|
124
|
+
// An expression can parse and still never match ("0 0 30 2 *"): the job would go quiet with
|
|
125
|
+
// nothing to see, and every scan for its next run walks the whole lookahead window. `/cron set`
|
|
126
|
+
// has refused this since it existed (job-edit.ts); creation did not. Only cron expressions are
|
|
127
|
+
// asked: a `once` schedule whose time has passed has no next run because it is due now.
|
|
128
|
+
if (input.schedule.kind === "cron" && computeNext({ schedule: input.schedule, createdAt: Date.now() }, Date.now()) === undefined) {
|
|
129
|
+
throw new Error(`${formatSchedule(input.schedule)} has no next run`);
|
|
130
|
+
}
|
|
131
|
+
const existing = host.scheduler.store.load();
|
|
132
|
+
checkJobName(input.name, existing);
|
|
133
|
+
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");
|
|
134
|
+
const job: LoopJob = {
|
|
135
|
+
id: newId("cron"),
|
|
136
|
+
name: input.name,
|
|
137
|
+
schedule: input.schedule,
|
|
138
|
+
stateful: input.stateful,
|
|
139
|
+
prompt: input.prompt,
|
|
140
|
+
cwd: resolveJobCwd(host.session().cwd, input.cwd),
|
|
141
|
+
// Captured now: the run happens in whichever pi owns the timer, and that one may be on another model.
|
|
142
|
+
model: input.model ?? host.session().model,
|
|
143
|
+
thinking: input.thinking ?? host.session().thinking,
|
|
144
|
+
tools: input.tools,
|
|
145
|
+
enabled: true,
|
|
146
|
+
verify: input.stateful && input.verify ? true : undefined,
|
|
147
|
+
checkerModel: input.stateful && input.verify ? input.checkerModel : undefined,
|
|
148
|
+
catchUp: input.catchUp ?? input.stateful,
|
|
149
|
+
timeoutMs: input.timeoutMs,
|
|
150
|
+
createdAt: stamp(),
|
|
151
|
+
// A sub-agent schedules on behalf of the session that runs it.
|
|
152
|
+
createdBy: { sessionId: scope?.parentSessionId ?? host.session().sessionId, cwd: scope?.parentCwd ?? host.session().cwd },
|
|
153
|
+
host: os.hostname(),
|
|
154
|
+
sessionId: owningSessionId(input.stateful, host.session().sessionId, scope?.parentSessionId),
|
|
155
|
+
runCount: 0,
|
|
156
|
+
skippedOverlap: 0,
|
|
157
|
+
};
|
|
158
|
+
await host.scheduler.store.add(job);
|
|
159
|
+
return job;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
const deny = (text: string) => ({ content: [{ type: "text" as const, text }], isError: true, details: { id: undefined as string | undefined } });
|
|
164
|
+
|
|
165
|
+
/** Trigger rules, rendered for a tool result. */
|
|
166
|
+
function renderTriggerRulesForTool(rules: ReturnType<TriggerStore["load"]>, host: Pick<ToolHost, "session">): string {
|
|
167
|
+
if (!rules.length) return "dynamic trigger rules: none";
|
|
168
|
+
return [`dynamic trigger rules: ${rules.length}`, ...rules.map((r) => `- ${r.id} [${r.enabled ? "enabled" : "disabled"}, ${r.fireOnce ? "fire_once" : "repeat"}, ${r.promoteToChat ? "promote_to_chat" : "audit_only"}] created_at=${r.createdAt} condition: ${previewRedacted(r.condition, 200)} action: ${previewRedacted(r.action, 200)}${r.cwd !== host.session().cwd ? ` cwd: ${r.cwd}` : ""}`)].join("\n");
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/**
|
|
172
|
+
* Whether this machine will ever dispatch the job. The scheduler filters on `job.host`, so a job
|
|
173
|
+
* stamped with another machine's hostname (a shared $HOME, a renamed box, a rebuilt container) is
|
|
174
|
+
* that host's to run — and a next run promised for it is a time nothing here will honour.
|
|
175
|
+
* docs/loops.md says as much; `/cron` obeyed it and the model-facing list did not.
|
|
176
|
+
*/
|
|
177
|
+
function runsHere(job: LoopJob): boolean {
|
|
178
|
+
return !job.host || job.host === os.hostname();
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
/** When the job next runs, or undefined when nothing here will run it. */
|
|
182
|
+
function nextRunForTool(job: LoopJob, now: number): number | undefined {
|
|
183
|
+
if (!job.enabled || !runsHere(job)) return undefined;
|
|
184
|
+
return computeNext({ schedule: job.schedule, createdAt: Date.parse(job.createdAt), lastFiredAt: job.lastFiredAt ? Date.parse(job.lastFiredAt) : undefined }, now);
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
/** Cron jobs, rendered for a tool result. */
|
|
188
|
+
function renderCronJobsForTool(jobs: LoopJob[], host: Pick<ToolHost, "session">): string {
|
|
189
|
+
if (!jobs.length) return "cron jobs: none";
|
|
190
|
+
const now = Date.now();
|
|
191
|
+
const lines = [`cron jobs: ${jobs.length}`];
|
|
192
|
+
for (const job of jobs) {
|
|
193
|
+
lines.push(`- ${job.id}${job.name ? ` "${job.name}"` : ""} [${job.enabled ? "enabled" : "disabled"}${job.stateful ? ", stateful" : ""}${job.verify ? ", verify" : ""}] schedule: ${formatSchedule(job.schedule)} action: ${previewRedacted(job.prompt, 120)}${job.cwd !== host.session().cwd ? ` cwd: ${job.cwd}` : ""}`);
|
|
194
|
+
if (!runsHere(job)) lines.push(` other_host: ${job.host} (this machine does not run it)`);
|
|
195
|
+
const next = nextRunForTool(job, now);
|
|
196
|
+
if (next) lines.push(` next_run: ${stamp(next)}`);
|
|
197
|
+
if (job.running) lines.push(` running_run_id: ${job.running.runId}`);
|
|
198
|
+
if (job.lastError) lines.push(` last_error: ${previewRedacted(job.lastError, 120)}`);
|
|
199
|
+
if (job.skippedOverlap) lines.push(` skipped_overlap_count: ${job.skippedOverlap}`);
|
|
200
|
+
}
|
|
201
|
+
return lines.join("\n");
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
export interface ToolScope {
|
|
205
|
+
hop: number;
|
|
206
|
+
actor: "tool" | "sub-agent";
|
|
207
|
+
parentSessionId?: string;
|
|
208
|
+
parentCwd?: string;
|
|
209
|
+
}
|
|
210
|
+
/**
|
|
211
|
+
* The cron/trigger tools are registered in sub-agents too (Prompt-class ones are denied there)
|
|
212
|
+
* and bounds cycles with a hop count. The same definitions serve the interactive session
|
|
213
|
+
* (hop 0, registered with pi) and every in-process sub-session (hop 1, passed as customTools).
|
|
214
|
+
*/
|
|
215
|
+
export function automationTools(scope: ToolScope, host: ToolHost): ToolDefinition<any, any>[] {
|
|
216
|
+
const defs: ToolDefinition<any, any>[] = [];
|
|
217
|
+
// Generic so each definition keeps its inferred parameter type, exactly as pi.registerTool does.
|
|
218
|
+
const register = <T extends TSchema, D>(d: ToolDefinition<T, D>): void => void defs.push(d as ToolDefinition<any, any>);
|
|
219
|
+
register({
|
|
220
|
+
name: "new_trigger",
|
|
221
|
+
label: "Create trigger",
|
|
222
|
+
description:
|
|
223
|
+
"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.",
|
|
224
|
+
parameters: Type.Object(
|
|
225
|
+
{
|
|
226
|
+
condition: Type.String({ description: "The natural-language condition that should be evaluated against future trigger events." }),
|
|
227
|
+
action: Type.String({ description: "The action to perform when the condition matches. This may be a shell command or a natural-language instruction." }),
|
|
228
|
+
spec: Type.Optional(Type.String({ description: "Fallback complete trigger rule text when condition and action cannot be supplied separately." })),
|
|
229
|
+
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." })),
|
|
230
|
+
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." })),
|
|
231
|
+
},
|
|
232
|
+
{ additionalProperties: false },
|
|
233
|
+
),
|
|
234
|
+
async execute(_id, params, _signal, _onUpdate, ctx) {
|
|
235
|
+
if ([params.condition, params.action, params.spec].some((t) => t && looksLikeFixedScheduleRequest(t))) return deny("fixed scheduled jobs must use cron_create, not new_trigger");
|
|
236
|
+
let condition = params.condition?.trim() ?? "";
|
|
237
|
+
let action = params.action?.trim() ?? "";
|
|
238
|
+
const fromSpec = !condition || !action;
|
|
239
|
+
if (fromSpec) {
|
|
240
|
+
if (!params.spec) return deny("missing required args: provide condition and action");
|
|
241
|
+
try {
|
|
242
|
+
({ condition, action } = parseTriggerRule(params.spec));
|
|
243
|
+
} catch (err: any) {
|
|
244
|
+
return deny(err?.message ?? String(err));
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
const reason = fromSpec ? "create dynamic trigger from `spec` field" : "create dynamic trigger from `condition` + `action` fields";
|
|
248
|
+
const denied = await host.confirmTool(ctx, { label: "create dynamic trigger", tool: "new_trigger", reason, preview: `when ${previewRedacted(condition, 80)} -> ${previewRedacted(action, 80)}`, args: params }, scope.hop);
|
|
249
|
+
if (denied) return deny(denied);
|
|
250
|
+
const rule = await host.triggers.store.add({ condition, action, fireOnce: params.fire_once ?? true, promoteToChat: params.promote_to_chat ?? false, cwd: host.session().cwd, sessionId: host.session().sessionId, model: host.session().model, thinking: host.session().thinking, host: os.hostname() });
|
|
251
|
+
host.refreshBadge();
|
|
252
|
+
return {
|
|
253
|
+
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)` }],
|
|
254
|
+
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 },
|
|
255
|
+
};
|
|
256
|
+
},
|
|
257
|
+
});
|
|
258
|
+
register({
|
|
259
|
+
name: "list_triggers",
|
|
260
|
+
label: "List triggers",
|
|
261
|
+
description: "List dynamic trigger rules of the current project. Use this when the user asks to view, list, show, inspect, or find trigger ids. Set all_projects only when the user asks about other projects.",
|
|
262
|
+
parameters: Type.Object({
|
|
263
|
+
all_projects: Type.Optional(Type.Boolean({ description: "Include rules of every project on this machine (default false)." })),
|
|
264
|
+
}),
|
|
265
|
+
async execute(_id, params) {
|
|
266
|
+
// The store is machine-wide, so containment is this filter's job: a model sees its own
|
|
267
|
+
// project's rules unless a human asked for all of them.
|
|
268
|
+
const cwd = host.session().cwd;
|
|
269
|
+
const all = host.triggers.store.load();
|
|
270
|
+
// A sub-agent never gets the machine-wide view: nobody is there to have asked for it.
|
|
271
|
+
const rules = params.all_projects && scope.hop === 0 ? all : all.filter((r) => sameProject(r.cwd, cwd));
|
|
272
|
+
return { content: [{ type: "text", text: renderTriggerRulesForTool(rules, host) }], details: { count: rules.length, scope: params.all_projects && scope.hop === 0 ? "machine" : cwd, rules, storage_path: host.triggers.store.rulesFile } };
|
|
273
|
+
},
|
|
274
|
+
});
|
|
275
|
+
register({
|
|
276
|
+
name: "remove_trigger",
|
|
277
|
+
label: "Remove trigger",
|
|
278
|
+
description: "Delete dynamic trigger rules. Use this when the user asks to delete, remove, or clear an existing dynamic trigger.",
|
|
279
|
+
parameters: Type.Object({
|
|
280
|
+
id: Type.Optional(Type.String({ description: "The exact dynamic trigger rule id to remove." })),
|
|
281
|
+
all: Type.Optional(Type.Boolean({ description: "Set true only when the user explicitly asks to remove all dynamic trigger rules." })),
|
|
282
|
+
}),
|
|
283
|
+
async execute(_id, params, _signal, _onUpdate, ctx) {
|
|
284
|
+
const err = (text: string) => ({ content: [{ type: "text" as const, text }], isError: true, details: { removed_count: 0 } });
|
|
285
|
+
if (params.all) {
|
|
286
|
+
// Scoped to this run's project: a machine-wide wipe is not something a model can ask for.
|
|
287
|
+
const cwd = host.session().cwd;
|
|
288
|
+
const denied = await host.confirmTool(ctx, { label: "remove ALL dynamic triggers", tool: "remove_trigger", reason: "remove every dynamic trigger rule of this project (`all` flag)", preview: `${host.triggers.store.load().filter((r) => sameProject(r.cwd, cwd)).length} rule(s)`, args: params }, scope.hop);
|
|
289
|
+
if (denied) return err(denied);
|
|
290
|
+
const n = await host.triggers.store.clear(cwd, sameProject);
|
|
291
|
+
return { content: [{ type: "text", text: `removed ${n} dynamic trigger rule(s)` }], details: { removed_count: n } };
|
|
292
|
+
}
|
|
293
|
+
if (!params.id) return err("missing required arg: id");
|
|
294
|
+
const rule = resolveRuleRefScoped(host.triggers.store.load(), params.id, host.session().cwd);
|
|
295
|
+
if (!rule) return err(`no dynamic trigger rule with id '${params.id}'`);
|
|
296
|
+
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);
|
|
297
|
+
if (denied) return err(denied);
|
|
298
|
+
await host.triggers.store.remove(rule.id);
|
|
299
|
+
return { content: [{ type: "text", text: `removed dynamic trigger ${rule.id}\ncondition: ${rule.condition}\naction: ${rule.action}` }], details: { removed_count: 1 } };
|
|
300
|
+
},
|
|
301
|
+
});
|
|
302
|
+
register({
|
|
303
|
+
name: "set_trigger_state",
|
|
304
|
+
label: "Enable/disable trigger",
|
|
305
|
+
description: "Enable or disable an existing dynamic trigger rule without deleting it. Use this when the user asks to pause, disable, enable, or resume a trigger.",
|
|
306
|
+
parameters: Type.Object({
|
|
307
|
+
id: Type.String({ description: "The exact dynamic trigger rule id to update." }),
|
|
308
|
+
enabled: Type.Boolean({ description: "Set false to pause or disable the trigger; set true to enable or resume it." }),
|
|
309
|
+
}),
|
|
310
|
+
async execute(_id, params, _signal, _onUpdate, ctx) {
|
|
311
|
+
const ruleCwd = host.session().cwd;
|
|
312
|
+
const rule = resolveRuleRefScoped(host.triggers.store.load(), params.id, ruleCwd);
|
|
313
|
+
if (!rule) return deny(`no dynamic trigger rule with id '${params.id}'`);
|
|
314
|
+
// Re-enabling is gated; pausing another project's rule is just as much a surprise, so it
|
|
315
|
+
// is gated too (a sub-agent is refused outright, having nobody to ask).
|
|
316
|
+
if (params.enabled || rule.cwd !== ruleCwd) {
|
|
317
|
+
const denied = await host.confirmTool(ctx, { label: `${params.enabled ? "re-enable" : "disable"} dynamic trigger ${rule.id}`, tool: "set_trigger_state", reason: params.enabled ? "re-enable a dynamic trigger rule (`enabled` = true); it will fire again" : "disable a dynamic trigger rule of another project", preview: `when ${previewRedacted(rule.condition, 120)}`, args: params }, scope.hop);
|
|
318
|
+
if (denied) return deny(denied);
|
|
319
|
+
}
|
|
320
|
+
const updated = (await host.triggers.store.setEnabled(rule.id, params.enabled)) ?? rule;
|
|
321
|
+
return {
|
|
322
|
+
content: [{ type: "text", text: `updated dynamic trigger ${updated.id}\nstate: ${updated.enabled ? "enabled" : "disabled"}\ncondition: ${updated.condition}\naction: ${updated.action}` }],
|
|
323
|
+
details: { id: updated.id as string | undefined, condition: updated.condition, action: updated.action, enabled: updated.enabled, fire_once: updated.fireOnce, fired_at: updated.firedAt, promote_to_chat: updated.promoteToChat },
|
|
324
|
+
};
|
|
325
|
+
},
|
|
326
|
+
});
|
|
327
|
+
|
|
328
|
+
register({
|
|
329
|
+
name: "cron_create",
|
|
330
|
+
label: "Create cron job",
|
|
331
|
+
description:
|
|
332
|
+
"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\".",
|
|
333
|
+
parameters: Type.Object({
|
|
334
|
+
schedule: Type.String({
|
|
335
|
+
description: 'Local-time schedule: 5-field cron ("0 9 * * *", "*/30 * * * 1-5"), "@daily", "every 30m", "in 10m", or "at 2026-09-08T18:00".',
|
|
336
|
+
}),
|
|
337
|
+
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." }),
|
|
338
|
+
stateful: Type.Optional(Type.Boolean({ description: "Loop mode: sub-agent + notes between runs + findings to /inbox (default false)." })),
|
|
339
|
+
verify: Type.Optional(Type.Boolean({ description: "Maker/checker: a second adversarial sub-agent verifies each finding before it enters the inbox (stateful jobs only, default false). Use when the user asks for verified, double-checked, or high-precision findings." })),
|
|
340
|
+
name: Type.Optional(Type.String({ description: "Short unique label (letters, digits, . _ -)." })),
|
|
341
|
+
cwd: Type.Optional(Type.String({ description: "Directory a stateful job's sub-agent runs in. Default: current project." })),
|
|
342
|
+
catch_up: Type.Optional(Type.Boolean({ description: "Run once at startup if a tick was missed while no pi was open (default: true for stateful jobs, false for plain jobs)." })),
|
|
343
|
+
}),
|
|
344
|
+
async execute(_id, params) {
|
|
345
|
+
const schedule = parseSchedule(params.schedule);
|
|
346
|
+
const job = await host.createJob(
|
|
347
|
+
{
|
|
348
|
+
schedule,
|
|
349
|
+
prompt: params.action,
|
|
350
|
+
stateful: params.stateful ?? params.verify ?? false, // verify implies a loop: a checker only reviews loop findings (`args.ts` does the same for `--verify`)
|
|
351
|
+
verify: params.verify ?? false,
|
|
352
|
+
name: params.name,
|
|
353
|
+
cwd: params.cwd,
|
|
354
|
+
catchUp: params.catch_up,
|
|
355
|
+
},
|
|
356
|
+
scope,
|
|
357
|
+
);
|
|
358
|
+
const auditEntryId = host.cronControlAudit("add", scope.actor, undefined, job);
|
|
359
|
+
host.refreshBadge();
|
|
360
|
+
const next = computeNext({ schedule: job.schedule, createdAt: Date.parse(job.createdAt) }, Date.now());
|
|
361
|
+
const where = job.stateful ? `Findings will appear in /inbox${job.verify ? " after an independent checker reviews them" : ""}.` : "Its result will appear in this chat.";
|
|
362
|
+
// Three lines — what was created, when it runs, what it will do — then where its output goes.
|
|
363
|
+
return {
|
|
364
|
+
content: [{ type: "text", text: `created cron job ${job.id}${job.name ? ` "${job.name}"` : ""}\nschedule: ${formatSchedule(job.schedule)}\naction: ${previewRedacted(job.prompt, 120)}\n${job.stateful ? "[stateful] " : ""}next run ${next ? stamp(next) : "—"}. ${where}` }],
|
|
365
|
+
details: { id: job.id, name: job.name, schedule: formatSchedule(job.schedule), action: job.prompt, enabled: job.enabled, stateful: job.stateful, verify: job.verify ?? false, scope: "machine", next_run: next ? stamp(next) : undefined, audit_entry_id: auditEntryId },
|
|
366
|
+
};
|
|
367
|
+
},
|
|
368
|
+
});
|
|
369
|
+
|
|
370
|
+
register({
|
|
371
|
+
name: "cron_list",
|
|
372
|
+
label: "List cron jobs",
|
|
373
|
+
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.",
|
|
374
|
+
parameters: Type.Object({
|
|
375
|
+
all_projects: Type.Optional(Type.Boolean({ description: "Include jobs of every project on this machine (default false)." })),
|
|
376
|
+
}),
|
|
377
|
+
async execute(_id, params) {
|
|
378
|
+
const listCwd = host.session().cwd;
|
|
379
|
+
const everywhere = params.all_projects === true && scope.hop === 0;
|
|
380
|
+
const jobs = everywhere ? host.scheduler.store.load() : host.scheduler.store.load().filter((j) => sameProject(j.cwd, listCwd));
|
|
381
|
+
const text = `${renderCronJobsForTool(jobs, host)}\ninbox: ${host.scheduler.inbox.newCount()} new finding(s)`;
|
|
382
|
+
const nowMs = Date.now();
|
|
383
|
+
const nextRun = (j: LoopJob) => nextRunForTool(j, nowMs);
|
|
384
|
+
return { content: [{ type: "text", text }], details: { count: jobs.length, scope: everywhere ? "machine" : listCwd, storage_path: host.scheduler.store.jobsFile, jobs: jobs.map((j) => ({ id: j.id, name: j.name, schedule: formatSchedule(j.schedule), action_preview: previewRedacted(j.prompt, 120), enabled: j.enabled, stateful: j.stateful, verify: j.verify ?? false, cwd: j.cwd, running_run_id: j.running?.runId, last_due_at: j.lastDueAt, last_fired_at: j.lastFiredAt, last_completed_at: j.lastCompletedAt, last_error: j.lastError ? previewRedacted(j.lastError, 120) : undefined, skipped_overlap_count: j.skippedOverlap, next_run: (() => { const n = nextRun(j); return n ? stamp(n) : undefined; })(), created_at: j.createdAt })) } };
|
|
385
|
+
},
|
|
386
|
+
});
|
|
387
|
+
|
|
388
|
+
register({
|
|
389
|
+
name: "cron_remove",
|
|
390
|
+
label: "Remove cron job",
|
|
391
|
+
description:
|
|
392
|
+
"Preview or confirm removal of a scheduled job by id or name. Use confirm=false first when the user asks to delete, remove, or clear a scheduled job, cron job, crontab entry, or 定时任务. Call confirm=true only after the user explicitly confirms removal. 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.",
|
|
393
|
+
parameters: Type.Object({
|
|
394
|
+
ref: Type.String({ description: "Job id (for example cron-abc123), unique id prefix, or name." }),
|
|
395
|
+
confirm: Type.Optional(Type.Boolean({ description: "false to preview the removal; true only after explicit user confirmation." })),
|
|
396
|
+
}),
|
|
397
|
+
async execute(_id, params, _signal, _onUpdate, ctx) {
|
|
398
|
+
const job = resolveJobRefScoped(host.scheduler.store.load(), params.ref, host.session().cwd);
|
|
399
|
+
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 } };
|
|
400
|
+
const label = `${job.id}${job.name ? ` "${job.name}"` : ""}`;
|
|
401
|
+
if (!params.confirm) {
|
|
402
|
+
return {
|
|
403
|
+
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` }],
|
|
404
|
+
details: { id: job.id as string | undefined, removed_count: 0, confirmation_required: true, audit_entry_id: undefined as string | undefined },
|
|
405
|
+
};
|
|
406
|
+
}
|
|
407
|
+
// The only control-plane tool that used to skip this gate, so a sub-agent (hop > 0) could
|
|
408
|
+
// delete any project's job unapproved — and what that costs is the automation itself: the
|
|
409
|
+
// loop stops running and its id is gone from jobs.json. Its notes and transcripts stay
|
|
410
|
+
// behind (under an id nothing schedules any more; `/cron gc --purge` clears those), which
|
|
411
|
+
// is why the gate is about what stops, not about data destroyed. The preview above stays
|
|
412
|
+
// free: it is what the user is shown before deciding.
|
|
413
|
+
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);
|
|
414
|
+
if (denied) return { content: [{ type: "text", text: denied }], isError: true, details: { id: undefined as string | undefined, removed_count: 0, confirmation_required: false, audit_entry_id: undefined as string | undefined } };
|
|
415
|
+
await host.scheduler.store.remove(job.id);
|
|
416
|
+
const auditEntryId = host.cronControlAudit("remove", scope.actor, job, undefined);
|
|
417
|
+
// The slash command says where a loop's notes went; a tool-driven removal has to say it too,
|
|
418
|
+
// or the person who asked the agent to delete a job is the only one left guessing. The prose
|
|
419
|
+
// carries the fact and `details` carries the path, which is the division this tool already
|
|
420
|
+
// uses elsewhere (`cron_list` reports `storage_path` the same way).
|
|
421
|
+
const kept = job.stateful ? "\nits loop state and run transcripts are kept; /cron gc --purge clears them" : "";
|
|
422
|
+
const statePath = job.stateful ? host.scheduler.store.statePath(job.id) : undefined;
|
|
423
|
+
return { content: [{ type: "text", text: `removed cron job ${label}\nschedule: ${formatSchedule(job.schedule)}\naction: ${previewRedacted(job.prompt, 120)}${kept}` }], details: { id: job.id as string | undefined, removed_count: 1, confirmation_required: false, audit_entry_id: auditEntryId, state_path: statePath } };
|
|
424
|
+
},
|
|
425
|
+
});
|
|
426
|
+
|
|
427
|
+
register({
|
|
428
|
+
name: "set_cron_job_state",
|
|
429
|
+
label: "Enable/disable cron job",
|
|
430
|
+
description: "Disable (pause) or enable (resume) a scheduled job by id or name. Enabling asks the user to confirm first.",
|
|
431
|
+
parameters: Type.Object({
|
|
432
|
+
ref: Type.String({ description: "Job id (for example cron-abc123), unique id prefix, or name." }),
|
|
433
|
+
enabled: Type.Boolean({ description: "true to enable/resume the cron job; false to disable/pause it." }),
|
|
434
|
+
}),
|
|
435
|
+
async execute(_id, params, _signal, _onUpdate, ctx) {
|
|
436
|
+
const jobCwd = host.session().cwd;
|
|
437
|
+
const job = resolveJobRefScoped(host.scheduler.store.load(), params.ref, jobCwd);
|
|
438
|
+
if (!job) return deny(`no cron job with id '${params.ref}'`);
|
|
439
|
+
if (params.enabled || job.cwd !== jobCwd) {
|
|
440
|
+
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);
|
|
441
|
+
if (denied) return deny(`${denied}; use /cron enable <id>`);
|
|
442
|
+
}
|
|
443
|
+
const updated = (await host.scheduler.store.update(job.id, (j) => {
|
|
444
|
+
j.enabled = params.enabled;
|
|
445
|
+
if (params.enabled) j.lastError = undefined;
|
|
446
|
+
})) ?? job;
|
|
447
|
+
const auditEntryId = host.cronControlAudit(params.enabled ? "enable" : "disable", scope.actor, job, updated);
|
|
448
|
+
return {
|
|
449
|
+
content: [{ type: "text", text: `updated cron job ${updated.id}\nstate: ${updated.enabled ? "enabled" : "disabled"}\nschedule: ${formatSchedule(updated.schedule)}\naction: ${previewRedacted(updated.prompt, 120)}` }],
|
|
450
|
+
details: { id: updated.id as string | undefined, schedule: formatSchedule(updated.schedule), enabled: updated.enabled, stateful: updated.stateful, audit_entry_id: auditEntryId },
|
|
451
|
+
};
|
|
452
|
+
},
|
|
453
|
+
});
|
|
454
|
+
return defs;
|
|
455
|
+
}
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Compact, redacted view of a sub-agent session file (pi's JSONL session format),
|
|
3
|
+
* so a loop run can be inspected from the parent session without opening the file:
|
|
4
|
+
* what it was asked, which tools it called, what came back, what it said.
|
|
5
|
+
*/
|
|
6
|
+
import * as fs from "node:fs";
|
|
7
|
+
import { jobTextOf, stripProtocolTags } from "./protocol.ts";
|
|
8
|
+
import { previewRedacted } from "./redact.ts";
|
|
9
|
+
|
|
10
|
+
export interface TranscriptLine {
|
|
11
|
+
kind: "user" | "assistant" | "tool" | "result" | "meta";
|
|
12
|
+
text: string;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
function describeToolCall(name: string, args: Record<string, unknown>): string {
|
|
16
|
+
const str = (v: unknown) => (typeof v === "string" ? v : v === undefined ? "" : JSON.stringify(v));
|
|
17
|
+
switch (name) {
|
|
18
|
+
case "bash":
|
|
19
|
+
return `$ ${str(args.command)}`;
|
|
20
|
+
case "read":
|
|
21
|
+
return `read ${str(args.path ?? args.file_path)}`;
|
|
22
|
+
case "write":
|
|
23
|
+
return `write ${str(args.path ?? args.file_path)}`;
|
|
24
|
+
case "edit":
|
|
25
|
+
return `edit ${str(args.path ?? args.file_path)}`;
|
|
26
|
+
case "ls":
|
|
27
|
+
return `ls ${str(args.path ?? ".")}`;
|
|
28
|
+
case "find":
|
|
29
|
+
return `find ${str(args.pattern)} in ${str(args.path ?? ".")}`;
|
|
30
|
+
case "grep":
|
|
31
|
+
return `grep /${str(args.pattern)}/ in ${str(args.path ?? ".")}`;
|
|
32
|
+
default:
|
|
33
|
+
return `${name} ${JSON.stringify(args)}`;
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** Parse a session JSONL into display lines. Unparseable lines are skipped. Never throws on content. */
|
|
38
|
+
export function summarizeSessionFile(file: string, opts: { maxLines?: number; width?: number } = {}): TranscriptLine[] {
|
|
39
|
+
const width = opts.width ?? 160;
|
|
40
|
+
const maxLines = opts.maxLines ?? 80;
|
|
41
|
+
let text: string;
|
|
42
|
+
try {
|
|
43
|
+
text = fs.readFileSync(file, "utf8");
|
|
44
|
+
} catch (err: any) {
|
|
45
|
+
return [{ kind: "meta", text: `(transcript unavailable: ${err?.message ?? err})` }];
|
|
46
|
+
}
|
|
47
|
+
const lines: TranscriptLine[] = [];
|
|
48
|
+
for (const raw of text.split("\n")) {
|
|
49
|
+
if (!raw.trim()) continue;
|
|
50
|
+
let entry: any;
|
|
51
|
+
try {
|
|
52
|
+
entry = JSON.parse(raw);
|
|
53
|
+
} catch {
|
|
54
|
+
continue;
|
|
55
|
+
}
|
|
56
|
+
if (entry?.type !== "message" || !entry.message) continue;
|
|
57
|
+
const msg = entry.message;
|
|
58
|
+
if (msg.role === "user") {
|
|
59
|
+
const content = typeof msg.content === "string" ? msg.content : Array.isArray(msg.content) ? msg.content.filter((p: any) => p?.type === "text").map((p: any) => p.text).join(" ") : "";
|
|
60
|
+
// The loop prompt is long and known; show only the job text, which protocol.ts knows how to find.
|
|
61
|
+
const body = jobTextOf(content);
|
|
62
|
+
lines.push({ kind: "user", text: `› ${previewRedacted(body, width)}` });
|
|
63
|
+
} else if (msg.role === "assistant") {
|
|
64
|
+
for (const part of Array.isArray(msg.content) ? msg.content : []) {
|
|
65
|
+
if (part?.type === "text" && part.text?.trim()) {
|
|
66
|
+
const shown = stripProtocolTags(part.text) || part.text;
|
|
67
|
+
lines.push({ kind: "assistant", text: previewRedacted(shown, width * 2) });
|
|
68
|
+
} else if (part?.type === "toolCall") {
|
|
69
|
+
lines.push({ kind: "tool", text: previewRedacted(describeToolCall(part.name, part.arguments ?? {}), width) });
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
} else if (msg.role === "toolResult") {
|
|
73
|
+
const out = Array.isArray(msg.content) ? msg.content.filter((p: any) => p?.type === "text").map((p: any) => p.text).join("\n") : "";
|
|
74
|
+
const first = out.split("\n").find((l: string) => l.trim()) ?? "";
|
|
75
|
+
const total = out.split("\n").filter((l: string) => l.trim()).length;
|
|
76
|
+
lines.push({ kind: "result", text: ` ${msg.isError ? "✗" : "→"} ${previewRedacted(first, width)}${total > 1 ? ` (+${total - 1} lines)` : ""}` });
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
if (lines.length > maxLines) {
|
|
80
|
+
const dropped = lines.length - maxLines;
|
|
81
|
+
return [{ kind: "meta", text: `(… ${dropped} earlier lines omitted)` }, ...lines.slice(-maxLines)];
|
|
82
|
+
}
|
|
83
|
+
return lines;
|
|
84
|
+
}
|