@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/pi-loops.ts
ADDED
|
@@ -0,0 +1,2292 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* pi-loops — stateful cron jobs, dynamic triggers and a triage inbox, as a plain pi extension.
|
|
3
|
+
*
|
|
4
|
+
* Nothing in pi is patched: timers start in session_start and stop in session_shutdown. A loop run
|
|
5
|
+
* is an in-process `AgentSession` through pi's SDK (`src/sdk-runner.ts`) — never a child process —
|
|
6
|
+
* so it shares this session's MCP clients, extensions and model. A loop's state is Markdown on
|
|
7
|
+
* disk, findings go to a global JSONL inbox, and `/inbox claim` turns a finding into a real user
|
|
8
|
+
* turn via `pi.sendUserMessage()`.
|
|
9
|
+
*
|
|
10
|
+
* Commands, tools and their arguments are documented in `docs/` (loops.md, triggers.md, goal.md,
|
|
11
|
+
* configuration.md); this file is where they are registered.
|
|
12
|
+
* Storage: ~/.pi/agent/loops/{jobs.json,state/<id>.md,inbox.jsonl,runs.jsonl,triggers.json}
|
|
13
|
+
*/
|
|
14
|
+
import * as path from "node:path";
|
|
15
|
+
import { fileURLToPath } from "node:url";
|
|
16
|
+
import type { ExtensionAPI, ExtensionCommandContext, ExtensionContext, ToolDefinition } from "@earendil-works/pi-coding-agent";
|
|
17
|
+
import { ProjectTrustStore, VERSION as PI_VERSION, getAgentDir, readStoredCredential } from "@earendil-works/pi-coding-agent";
|
|
18
|
+
import { ARCHIVE_EXT, defaultExportPath, exportSession, importSession } from "./archive.ts";
|
|
19
|
+
import { Box, Text } from "@earendil-works/pi-tui";
|
|
20
|
+
import { Type } from "typebox";
|
|
21
|
+
import { parseAddArgs, parseSetArgs, splitCommand } from "./args.ts";
|
|
22
|
+
import { envFlag, loadConfig } from "./config.ts";
|
|
23
|
+
import { GOAL_ENTRY, type GoalAction, type GoalState, MAX_CONTINUATIONS, applyDecision, branchMovedSince, continuationPrompt, evaluatorPrompt, latestGoal, newGoal, parseDecision, pauseFor, transcriptFromMessages } from "./goal.ts";
|
|
24
|
+
import { withinProject } from "./presence.ts";
|
|
25
|
+
import { isExactlyTrusted, sessionTrustCovers } from "./trust.ts";
|
|
26
|
+
import { HookRunner, type HookEventData, messageKind, messageSummary, resultSummary, truncateSummary } from "./hooks.ts";
|
|
27
|
+
import { failingSummary } from "./job-health.ts";
|
|
28
|
+
import { LoopsLog, pruneLogs } from "./log.ts";
|
|
29
|
+
import { type InboxEntry, belongsToProject, inProject, resolveInboxRef } from "./inbox.ts";
|
|
30
|
+
import { McpPool } from "./mcp-pool.ts";
|
|
31
|
+
import { McpSource, PI_BUILTIN_TOOL_NAMES, type McpServerConfig, type McpToolDef, droppedNotificationMessage, loadMcpConfigFiles, mapNotification, mcpToolDefinitions, mcpTokenFromEnv } from "./mcp.ts";
|
|
32
|
+
import { capRedacted, previewRedacted, redact } from "./redact.ts";
|
|
33
|
+
import { shouldEmitSnapshot, snapshotFingerprint } from "./snapshot.ts";
|
|
34
|
+
import { type ShareMessage, renderShare, shareSummary } from "./share.ts";
|
|
35
|
+
import { installLauncher } from "./cli.ts";
|
|
36
|
+
import { createHash } from "node:crypto";
|
|
37
|
+
import { computeDue, computeNext, formatLocal, formatSchedule, localOffset, parseSchedule, stamp } from "./schedule.ts";
|
|
38
|
+
import { applyJobEdit } from "./job-edit.ts";
|
|
39
|
+
import { LoopScheduler, type SessionSnapshot } from "./scheduler.ts";
|
|
40
|
+
import { MAX_PROMPT_BYTES, type LoopJob, type RunRecord, defaultLoopsDir, newId, resolveJobRef, sessionExists } from "./store.ts";
|
|
41
|
+
import { parentRuntimeFlags, type SubagentRequest } from "./runner.ts";
|
|
42
|
+
import { createInProcessRunner } from "./sdk-runner.ts";
|
|
43
|
+
import { SubagentSlots } from "./slots.ts";
|
|
44
|
+
import { askHost, renderHostSnapshot } from "./host-control-channel.ts";
|
|
45
|
+
import { waitForHost, HOST_LOG, crashedHost, hostPushWork, liveHost, piPackageDir, shouldHandOff, spawnHost, stopHost } from "./host-control.ts";
|
|
46
|
+
import { summarizeSessionFile } from "./transcript.ts";
|
|
47
|
+
import { TriggerRuntime, type TriggerOutcome } from "./trigger-runtime.ts";
|
|
48
|
+
import { auditCronFinish, auditCronStart, TriggerStore, buildPeriodicCheckTrigger, controlPlanePreflight, resolveRuleRef } from "./triggers.ts";
|
|
49
|
+
import { type ControlPlaneRequest, type CreateJobInput, type JobScope, type ToolHost, automationTools, checkJobName, createLoopJob } from "./tools.ts";
|
|
50
|
+
import { panelEnabled, readUiPrefs, writeUiPref } from "./ui-prefs.ts";
|
|
51
|
+
import * as fs from "node:fs";
|
|
52
|
+
import * as os from "node:os";
|
|
53
|
+
|
|
54
|
+
const VIEW_ENTRY = "pi-loops:view";
|
|
55
|
+
const STATUS_KEY = "pi-loops";
|
|
56
|
+
const GOAL_STATUS_KEY = "pi-loops-goal";
|
|
57
|
+
/** The evaluator is one tool-less read of a capped transcript; it must not hold a turn for 15 minutes. */
|
|
58
|
+
const GOAL_EVALUATOR_TIMEOUT_MS = 120_000;
|
|
59
|
+
import { PI_LOOPS_VERSION } from "./version.ts";
|
|
60
|
+
|
|
61
|
+
interface ViewData {
|
|
62
|
+
title: string;
|
|
63
|
+
lines: string[];
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export default function piLoops(pi: ExtensionAPI) {
|
|
67
|
+
/** Trigger hop, for cycle suppression: the interactive pi is 0; the sub-agents it runs are 1. */
|
|
68
|
+
const hop = 0;
|
|
69
|
+
/** `/cron host start|stop`: this pi's override of `[host] auto` for the hand-off when it quits. */
|
|
70
|
+
let handOffOnQuit: boolean | undefined;
|
|
71
|
+
/**
|
|
72
|
+
* The hand-off rule, in one place: this pi's `/cron host start|stop` if it said anything, else
|
|
73
|
+
* `[host] auto`. Three sites read it — the card, the scheduler line and the shutdown decision —
|
|
74
|
+
* and each used to spell it out again, one of them leaning on `??` binding tighter than `?:`.
|
|
75
|
+
*/
|
|
76
|
+
const handsOffOnQuit = (): boolean => handOffOnQuit ?? config.hostAuto;
|
|
77
|
+
const dir = defaultLoopsDir(getAgentDir());
|
|
78
|
+
/** Everything this process diagnoses, kept after the window is gone. */
|
|
79
|
+
const log = new LoopsLog(dir, `pi-${process.pid}.log`);
|
|
80
|
+
/** This package's directory: a sub-session must not load a second copy of this extension. */
|
|
81
|
+
const PACKAGE_DIR = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
|
|
82
|
+
|
|
83
|
+
let session: SessionSnapshot = { cwd: process.cwd() };
|
|
84
|
+
let lastCtx: ExtensionContext | undefined;
|
|
85
|
+
let schedulerStarted = false;
|
|
86
|
+
/**
|
|
87
|
+
* Whether this extension instance has already had its `session_start`.
|
|
88
|
+
*
|
|
89
|
+
* pi replaces a session in rpc mode by building a new runtime and rebinding the extensions to it —
|
|
90
|
+
* and it rebinds twice: `AgentSessionRuntimeHost.finishSessionReplacement` calls the rebind
|
|
91
|
+
* callback rpc mode registered, and rpc mode's own `switch_session` / `fork` / `clone` handler
|
|
92
|
+
* calls it again. `AgentSession.bindExtensions` re-emits the session's start event on each bind, so
|
|
93
|
+
* every clear and resume in the browser front end delivered `session_start` twice, three
|
|
94
|
+
* milliseconds apart: two `session start:` lines in the log, and — the part that matters — two MCP
|
|
95
|
+
* source starts, two hook loads, two attempts to take the clock back from the headless host. A
|
|
96
|
+
* start is a start until its shutdown, which pi does emit exactly once per session, so that is
|
|
97
|
+
* what this counts.
|
|
98
|
+
*/
|
|
99
|
+
let startHandled = false;
|
|
100
|
+
/** The two trigger sources this process *is*: the local crontab, and the dynamic-rule checker. */
|
|
101
|
+
const LOCAL_SOURCES = 2;
|
|
102
|
+
/**
|
|
103
|
+
* Whether those two are connected. They are this process's own scheduler, so they are live only
|
|
104
|
+
* while it is scheduling *and* owns the clock; on a non-leader the enumeration below has always
|
|
105
|
+
* called them `standby`, and the count above it used to call them connected in the same breath.
|
|
106
|
+
*/
|
|
107
|
+
const localSourcesConnected = (): boolean => schedulerStarted && scheduler.isLeader;
|
|
108
|
+
|
|
109
|
+
/** This session as the schedulers see it: the project, the model and the thinking level of the chat. */
|
|
110
|
+
const sessionSnapshot = (ctx: ExtensionContext): SessionSnapshot => ({
|
|
111
|
+
sessionId: ctx.sessionManager.getSessionId(),
|
|
112
|
+
cwd: ctx.cwd,
|
|
113
|
+
model: ctx.model ? `${ctx.model.provider}/${ctx.model.id}` : undefined,
|
|
114
|
+
thinking: ctx.thinkingLevel,
|
|
115
|
+
trusted: ctx.isProjectTrusted(),
|
|
116
|
+
});
|
|
117
|
+
/**
|
|
118
|
+
* Sub-agents run inside this process through pi's SDK: the same
|
|
119
|
+
* live MCP clients, the parent's `-e` extensions, system prompt and skills, the parent's model
|
|
120
|
+
* unless the job pins one, its own transcript file. `customTools` hands each run the parent's
|
|
121
|
+
* MCP tools plus the automation tools at hop 1.
|
|
122
|
+
*/
|
|
123
|
+
const runner = createInProcessRunner({
|
|
124
|
+
agentDir: getAgentDir(),
|
|
125
|
+
parentFlags: parentRuntimeFlags(process.argv),
|
|
126
|
+
getParentModel: () => lastCtx?.model,
|
|
127
|
+
getParentThinking: () => session.thinking,
|
|
128
|
+
getParentTools: () => pi.getActiveTools(),
|
|
129
|
+
// The parent's own runtime: `pi --api-key`, `/login` and a rotated credential reach a run
|
|
130
|
+
// through it, so a sub-agent inherits the parent's stream function.
|
|
131
|
+
getParentModelRuntime: () => lastCtx?.modelRegistry as any,
|
|
132
|
+
// The tools act in the run's own project and model — a loop for project B that schedules a
|
|
133
|
+
// follow-up must not pin it to whichever project this pi happens to be open in.
|
|
134
|
+
customTools: async (req: SubagentRequest) => {
|
|
135
|
+
const shared = allMcpToolDefs();
|
|
136
|
+
const automation = automationTools({ hop: req.hop, actor: "sub-agent", parentSessionId: req.parentSessionId, parentCwd: req.parentCwd }, toolHostFor(req));
|
|
137
|
+
// A run in another project gets that project's own MCP servers too — this process only
|
|
138
|
+
// loaded its own project's, and a loop belongs to its project, not to this window.
|
|
139
|
+
const taken = new Set([...PI_BUILTIN_TOOL_NAMES, ...pi.getAllTools().map((t) => t.name), ...shared.map((t) => t.name), ...automation.map((t) => t.name)]);
|
|
140
|
+
// `sameProject`, not a string comparison: `--cwd ./sub`, a worktree or a symlinked path in
|
|
141
|
+
// the project this pi has open is this project, and treating it as foreign warns about an
|
|
142
|
+
// ignored MCP config and connects a second copy of servers this process already runs.
|
|
143
|
+
const project = req.cwd && !sameProject(req.cwd, session.cwd) ? await mcpPool.toolsFor(req.cwd, taken) : [];
|
|
144
|
+
return [...shared, ...project, ...automation];
|
|
145
|
+
},
|
|
146
|
+
// The project this session is open in — itself and what is inside it, never an ancestor
|
|
147
|
+
// (`sessionTrustCovers`) — or one the user trusted before (pi's saved decisions), and there
|
|
148
|
+
// exactly: a job's cwd can be model-chosen, and pi's inherited trust would make every
|
|
149
|
+
// directory under a trusted repo (node_modules, a submodule, an extracted tarball) able to
|
|
150
|
+
// load its own extensions and MCP servers in an unattended run. See src/trust.ts.
|
|
151
|
+
isTrusted: (cwd) => (!!session.trusted && sessionTrustCovers(session.cwd, cwd)) || isExactlyTrusted(getAgentDir(), cwd),
|
|
152
|
+
ownDir: PACKAGE_DIR,
|
|
153
|
+
allowCommands: () => config.allowCommands,
|
|
154
|
+
// The daily cap, read while a run is in flight and not only before it is dispatched. Lazy: the
|
|
155
|
+
// scheduler is built with this runner, and asking it anything before then would be a cycle.
|
|
156
|
+
budget: () => scheduler.budgetState(),
|
|
157
|
+
log: (msg) => {
|
|
158
|
+
log.warn(`sub-agent: ${msg}`);
|
|
159
|
+
if (lastCtx?.hasUI) lastCtx.ui.notify(`[sub-agent] ${msg}`, "warning");
|
|
160
|
+
},
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
/**
|
|
164
|
+
* `[cron] max_concurrent_runs` counts sub-agents, not loop runs: the scheduler, the trigger
|
|
165
|
+
* runtime and the /goal evaluator all start in-process pi sessions in *this* process, on the same
|
|
166
|
+
* model and the same bill, so they share one counter (src/slots.ts). Counting per pipeline meant
|
|
167
|
+
* a setting of 3 permitted three runs plus three checks plus an evaluator.
|
|
168
|
+
*/
|
|
169
|
+
const subagentSlots = new SubagentSlots(() => config.maxConcurrentRuns);
|
|
170
|
+
|
|
171
|
+
const scheduler: LoopScheduler = new LoopScheduler({
|
|
172
|
+
dir,
|
|
173
|
+
hop,
|
|
174
|
+
slots: subagentSlots,
|
|
175
|
+
getSession: () => session,
|
|
176
|
+
getSettings: () => ({ maxConcurrentRuns: config.maxConcurrentRuns, catchUp: config.cronCatchUp, dailyBudgetUsd: config.dailyBudgetUsd }),
|
|
177
|
+
runner,
|
|
178
|
+
kind: "interactive",
|
|
179
|
+
// A plain job whose session is gone is parked, /cron gc removes it.
|
|
180
|
+
sessionExists: (id) => sessionExists(path.join(getAgentDir(), "sessions"), id),
|
|
181
|
+
hooks: {
|
|
182
|
+
onInject: (_job, prompt, runId) => {
|
|
183
|
+
if (!lastCtx) return;
|
|
184
|
+
const idle = lastCtx.isIdle();
|
|
185
|
+
pi.sendUserMessage(prompt, idle ? undefined : { deliverAs: "followUp" });
|
|
186
|
+
triggeredTurnLine(runId, idle);
|
|
187
|
+
},
|
|
188
|
+
onRunStart: (job, runId) => {
|
|
189
|
+
auditCronStart(triggers.store, job, runId);
|
|
190
|
+
refreshBadge();
|
|
191
|
+
// The same two events the headless host fires. Whether a run happens here or there is
|
|
192
|
+
// an accident of who held the clock, and a hook rule should not be able to tell.
|
|
193
|
+
fireRunHook({ event: "run_start", run_job: job.name ?? job.id, run_id: runId, message_summary: truncateSummary(`${job.name ?? job.id}: ${job.prompt}`) });
|
|
194
|
+
},
|
|
195
|
+
onCatchUp: (job, dueAt) => {
|
|
196
|
+
if (lastCtx?.hasUI && sameProject(job.cwd, session.cwd)) lastCtx.ui.notify(`cron ${job.name ?? job.id}: catching up the run missed at ${formatLocal(dueAt)}`, "info");
|
|
197
|
+
},
|
|
198
|
+
onRunFinished: ({ job, record, findings, result }) => {
|
|
199
|
+
auditCronFinish(triggers.store, job, record, result.stopReason === "aborted");
|
|
200
|
+
refreshBadge();
|
|
201
|
+
fireRunHook({
|
|
202
|
+
event: "run_end",
|
|
203
|
+
run_job: job.name ?? job.id,
|
|
204
|
+
run_id: record.runId,
|
|
205
|
+
run_ok: record.ok,
|
|
206
|
+
run_findings: record.findings,
|
|
207
|
+
run_error: record.error ? redact(record.error) : null,
|
|
208
|
+
run_cost_usd: record.usage?.cost ?? null,
|
|
209
|
+
message_summary: truncateSummary(record.ok ? `${job.name ?? job.id}: ok · ${record.findings} finding(s)` : `${job.name ?? job.id}: failed: ${record.error ?? "unknown error"}`),
|
|
210
|
+
});
|
|
211
|
+
if (!lastCtx?.hasUI) return;
|
|
212
|
+
// Another project's findings and prompt previews do not belong in this transcript; the
|
|
213
|
+
// audit sink below and the inbox already carry them to where they do.
|
|
214
|
+
if (sameProject(job.cwd, session.cwd)) showRunCard(lastCtx, job, record, findings);
|
|
215
|
+
},
|
|
216
|
+
onInboxChanged: () => refreshBadge(),
|
|
217
|
+
// Dynamic triggers piggyback on the same 30s tick (leader only). MCP pushes are consumed
|
|
218
|
+
// by every interactive process and deduplicated machine-wide (see startMcpSources).
|
|
219
|
+
onTick: (now, leader): Promise<void> => triggers.tick(now, leader),
|
|
220
|
+
onLeadership: async () => refreshBadge(),
|
|
221
|
+
// These carry "job disabled", "state write failed", "cannot read jobs"; the scheduler says
|
|
222
|
+
// which of its lines are routine instead of leaving it to be guessed from the wording.
|
|
223
|
+
log: (msg, level) => diagnostic(msg, level),
|
|
224
|
+
onSchedulerError: (msg) => diagnostic(msg),
|
|
225
|
+
onBudgetExceeded: (spent, cap) => diagnostic(`today's automation has cost $${spent.toFixed(2)} of the $${cap.toFixed(2)} budget; dispatching is paused until tomorrow or a higher [limits] daily_budget_usd`),
|
|
226
|
+
},
|
|
227
|
+
});
|
|
228
|
+
|
|
229
|
+
/* ---------------------------------------------------------- triggers */
|
|
230
|
+
|
|
231
|
+
let config = loadConfig(dir);
|
|
232
|
+
const triggers: TriggerRuntime = new TriggerRuntime({
|
|
233
|
+
store: new TriggerStore(dir),
|
|
234
|
+
jobStore: scheduler.store,
|
|
235
|
+
getSession: () => session,
|
|
236
|
+
pollIntervalSecs: config.triggerPollIntervalSecs,
|
|
237
|
+
runTimeoutMs: config.triggerRunTimeoutMs,
|
|
238
|
+
runner,
|
|
239
|
+
dedupFile: path.join(dir, "dedup.json"),
|
|
240
|
+
// Checks and actions cost the same money and the same process as loop runs, so they draw on the
|
|
241
|
+
// same counter rather than on a second one of their own.
|
|
242
|
+
slots: subagentSlots,
|
|
243
|
+
budget: () => scheduler.budgetState(),
|
|
244
|
+
hop,
|
|
245
|
+
// A project's checks run in a pi open in that project, so a result lands in the conversation it belongs to.
|
|
246
|
+
self: scheduler.self,
|
|
247
|
+
presence: () => scheduler.presenceList(),
|
|
248
|
+
isLeader: () => scheduler.isLeader,
|
|
249
|
+
hooks: {
|
|
250
|
+
onPromote: async (content, trigger) => {
|
|
251
|
+
// Promotion = the result becomes visible to future turns (inserting a `[Trigger …]` user
|
|
252
|
+
// message into the parent session). Only into a chat that belongs to the rule's project;
|
|
253
|
+
// a different project's chat gets nothing — the finding goes to the inbox instead.
|
|
254
|
+
if (trigger.cwd && !sameProject(trigger.cwd, session.cwd)) {
|
|
255
|
+
await scheduler.inbox.append({ source: `trigger:${trigger.sourceLabel}`, text: content.replace(/^\[Trigger [^\]]+\]\s*/, ""), runId: trigger.traceId, jobId: trigger.sourceLabel, cwd: trigger.cwd });
|
|
256
|
+
refreshBadge();
|
|
257
|
+
return "inbox";
|
|
258
|
+
}
|
|
259
|
+
// Idle → inserted without a model call; streaming → follow-up queue, which runs a turn
|
|
260
|
+
// once the current one ends so the agent sees it without waiting for the next prompt.
|
|
261
|
+
if (lastCtx?.isIdle() ?? true) pi.sendMessage({ customType: "pi-loops:trigger", content, display: true }, { triggerTurn: false });
|
|
262
|
+
else pi.sendMessage({ customType: "pi-loops:trigger", content, display: true }, { triggerTurn: true, deliverAs: "followUp" });
|
|
263
|
+
return "chat";
|
|
264
|
+
},
|
|
265
|
+
onInjectAndRun: async (prompt, trigger) => {
|
|
266
|
+
if (trigger.cwd && !sameProject(trigger.cwd, session.cwd)) {
|
|
267
|
+
await scheduler.inbox.append({ source: `trigger:${trigger.sourceLabel}`, text: prompt.replace(/^\[Trigger [^\]]+\]\s*/, ""), runId: trigger.traceId, jobId: trigger.sourceLabel, cwd: trigger.cwd });
|
|
268
|
+
refreshBadge();
|
|
269
|
+
return "inbox";
|
|
270
|
+
}
|
|
271
|
+
const idle = lastCtx?.isIdle() ?? true;
|
|
272
|
+
pi.sendUserMessage(prompt, idle ? undefined : { deliverAs: "followUp" });
|
|
273
|
+
triggeredTurnLine(trigger.traceId, idle);
|
|
274
|
+
return "chat";
|
|
275
|
+
},
|
|
276
|
+
onStarted: () => refreshBadge(),
|
|
277
|
+
onFinished: (outcome) => {
|
|
278
|
+
refreshBadge();
|
|
279
|
+
if (lastCtx?.hasUI && (!outcome.trigger.cwd || sameProject(outcome.trigger.cwd, session.cwd))) showTriggerCard(lastCtx, outcome);
|
|
280
|
+
},
|
|
281
|
+
// Trigger diagnostics carry failures, dedup and deferral decisions: the log always, and a
|
|
282
|
+
// notification for the ones a user would otherwise never learn about.
|
|
283
|
+
log: (msg) => {
|
|
284
|
+
log.info(`triggers: ${msg}`);
|
|
285
|
+
if (lastCtx?.hasUI) lastCtx.ui.notify(`[triggers] ${msg}`, /failed|disabled|not run/.test(msg) ? "warning" : "info");
|
|
286
|
+
},
|
|
287
|
+
},
|
|
288
|
+
});
|
|
289
|
+
// Trigger audit is kept as session custom entries (trigger / trigger_result / trigger_promotion),
|
|
290
|
+
// so it resumes with the session and travels in archives, on top of the machine-wide JSONL.
|
|
291
|
+
triggers.store.onAudit = (record) => {
|
|
292
|
+
if (!lastCtx) return;
|
|
293
|
+
// Only this project's rows: the leader covering another project must not put that project's
|
|
294
|
+
// output into this session (and its archives).
|
|
295
|
+
if (record.cwd && !sameProject(record.cwd, session.cwd)) return;
|
|
296
|
+
pi.appendEntry(record.type, record);
|
|
297
|
+
};
|
|
298
|
+
pi.registerFlag("trigger-poll-secs", { type: "string", description: "Dynamic trigger poll interval in seconds (pi-loops; default 600 or config.toml [triggers].poll_interval_secs)" });
|
|
299
|
+
|
|
300
|
+
const mcpSources: McpSource[] = [];
|
|
301
|
+
/** Project-level MCP servers of *other* projects, connected on demand for their loops. */
|
|
302
|
+
const mcpPool = new McpPool({
|
|
303
|
+
isTrusted: (cwd) => isExactlyTrusted(getAgentDir(), cwd),
|
|
304
|
+
resolveToken: resolveMcpToken,
|
|
305
|
+
log: (msg) => {
|
|
306
|
+
log.warn(`mcp pool: ${msg}`);
|
|
307
|
+
if (lastCtx?.hasUI) lastCtx.ui.notify(`[mcp] ${msg}`, "warning");
|
|
308
|
+
},
|
|
309
|
+
});
|
|
310
|
+
let mcpConfigs: McpServerConfig[] = [];
|
|
311
|
+
let mcpConfigError: string | undefined;
|
|
312
|
+
const mcpDiagnostics: string[] = [];
|
|
313
|
+
|
|
314
|
+
/** user `mcp.toml` + project `.pi/mcp.toml` (same name → project wins). Project config needs project trust. */
|
|
315
|
+
function loadMcpConfig(projectTrusted: boolean): void {
|
|
316
|
+
mcpDiagnostics.length = 0;
|
|
317
|
+
const loaded = loadMcpConfigFiles({ dir, cwd: session.cwd, projectTrusted });
|
|
318
|
+
mcpConfigs = loaded.servers;
|
|
319
|
+
mcpDiagnostics.push(...loaded.diagnostics);
|
|
320
|
+
mcpConfigError = mcpDiagnostics.length ? mcpDiagnostics.join("; ") : undefined;
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
/** `auth.token_keychain_ref` → environment variable, then pi's credential store (`/login`-style api keys). */
|
|
324
|
+
function resolveMcpToken(ref: string): string | undefined {
|
|
325
|
+
const fromEnv = mcpTokenFromEnv(ref);
|
|
326
|
+
if (fromEnv) return fromEnv;
|
|
327
|
+
try {
|
|
328
|
+
const cred: any = readStoredCredential(ref);
|
|
329
|
+
if (cred && typeof cred === "object") {
|
|
330
|
+
if (typeof cred.key === "string") return cred.key;
|
|
331
|
+
if (typeof cred.access === "string") return cred.access;
|
|
332
|
+
if (typeof cred.token === "string") return cred.token;
|
|
333
|
+
}
|
|
334
|
+
if (typeof cred === "string") return cred;
|
|
335
|
+
} catch {
|
|
336
|
+
/* no store */
|
|
337
|
+
}
|
|
338
|
+
return undefined;
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
/** MCP tool names registered with pi, per server. */
|
|
342
|
+
const mcpToolNames = new Map<string, string[]>();
|
|
343
|
+
/** The registered tool definitions per server — handed to every sub-session as customTools. */
|
|
344
|
+
const mcpToolDefs = new Map<string, ToolDefinition<any, any>[]>();
|
|
345
|
+
function allMcpToolDefs(): ToolDefinition<any, any>[] {
|
|
346
|
+
return [...mcpToolDefs.values()].flat();
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
/**
|
|
350
|
+
* After the handshake, `tools/list` and register every server tool with
|
|
351
|
+
* the agent. Registration is per extension instance; a reconnect refreshes the catalog but
|
|
352
|
+
* cannot re-register names pi already knows, so new tools appear after /reload.
|
|
353
|
+
*/
|
|
354
|
+
async function registerMcpTools(source: McpSource): Promise<void> {
|
|
355
|
+
let tools: McpToolDef[];
|
|
356
|
+
try {
|
|
357
|
+
tools = await source.listTools();
|
|
358
|
+
} catch (err: any) {
|
|
359
|
+
source.status.lastError = `tools/list failed: ${err?.message ?? err}`;
|
|
360
|
+
return;
|
|
361
|
+
}
|
|
362
|
+
const already = mcpToolNames.get(source.config.name) ?? [];
|
|
363
|
+
const defs = mcpToolDefs.get(source.config.name) ?? [];
|
|
364
|
+
for (const { def } of mcpToolDefinitions(source, tools, new Set(pi.getAllTools().map((t) => t.name)), already)) {
|
|
365
|
+
defs.push(def);
|
|
366
|
+
pi.registerTool(def);
|
|
367
|
+
}
|
|
368
|
+
mcpToolNames.set(source.config.name, already);
|
|
369
|
+
mcpToolDefs.set(source.config.name, defs);
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
/** What the running sources were started from, so a session swap can tell whether they still match. */
|
|
373
|
+
let mcpRunningKey = "";
|
|
374
|
+
const mcpConfigKey = (): string => JSON.stringify(mcpConfigs.map((c) => ({ ...c })));
|
|
375
|
+
|
|
376
|
+
async function startMcpSources(): Promise<void> {
|
|
377
|
+
// `/new`, `/resume` and `/reload` reload the config (and may change the project's trust)
|
|
378
|
+
// without quitting. If what we are running no longer matches, restart the sources —
|
|
379
|
+
// otherwise the panel would describe one set of servers while another kept running.
|
|
380
|
+
if (mcpSources.length) {
|
|
381
|
+
if (mcpRunningKey === mcpConfigKey()) return;
|
|
382
|
+
await stopMcpSources();
|
|
383
|
+
mcpToolDefs.clear();
|
|
384
|
+
mcpToolNames.clear();
|
|
385
|
+
}
|
|
386
|
+
mcpRunningKey = mcpConfigKey();
|
|
387
|
+
for (const cfg of mcpConfigs) {
|
|
388
|
+
const source = new McpSource(cfg, {
|
|
389
|
+
onConnected: (src) => registerMcpTools(src),
|
|
390
|
+
onNotification: (n) => {
|
|
391
|
+
// Every interactive process consumes what it receives; the machine-wide dedup window
|
|
392
|
+
// (dedup.json) guarantees a push that several pi processes see is handled exactly
|
|
393
|
+
// once, and the promotion hooks route results to the right project's chat or inbox.
|
|
394
|
+
const trigger = mapNotification(cfg.name, n);
|
|
395
|
+
if (!trigger) {
|
|
396
|
+
source.status.droppedCount++;
|
|
397
|
+
source.status.lastError = droppedNotificationMessage(n.method);
|
|
398
|
+
return;
|
|
399
|
+
}
|
|
400
|
+
trigger.cwd = session.cwd;
|
|
401
|
+
const delivery = cfg.injectAndRun ? "inject_and_run" : cfg.injectSummary ? "inject_summary" : "sub_agent";
|
|
402
|
+
void triggers
|
|
403
|
+
.handle(trigger, delivery)
|
|
404
|
+
.then((out) => {
|
|
405
|
+
if (!out) source.status.dedupedCount++;
|
|
406
|
+
})
|
|
407
|
+
.catch((err) => {
|
|
408
|
+
try {
|
|
409
|
+
if (lastCtx?.hasUI) lastCtx.ui.notify(`[triggers] ${err?.message ?? err}`, "warning");
|
|
410
|
+
} catch {
|
|
411
|
+
/* the notifier itself failed; nothing left to report to */
|
|
412
|
+
}
|
|
413
|
+
});
|
|
414
|
+
},
|
|
415
|
+
onStatus: () => refreshBadge(),
|
|
416
|
+
resolveToken: resolveMcpToken,
|
|
417
|
+
log: (msg) => {
|
|
418
|
+
if (lastCtx?.hasUI) lastCtx.ui.notify(`mcp server '${cfg.name}' failed: ${msg}`, "warning");
|
|
419
|
+
},
|
|
420
|
+
});
|
|
421
|
+
mcpSources.push(source);
|
|
422
|
+
source.start();
|
|
423
|
+
}
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
async function stopMcpSources(): Promise<void> {
|
|
427
|
+
const sources = mcpSources.splice(0);
|
|
428
|
+
mcpRunningKey = "";
|
|
429
|
+
await Promise.all(sources.map((s) => s.stop()));
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
/** Trigger output as a bounded card in the transcript, never an LLM message. */
|
|
433
|
+
function showTriggerCard(ctx: ExtensionContext, outcome: TriggerOutcome): void {
|
|
434
|
+
const quiet = outcome.ok && outcome.delivery === "sub_agent" && outcome.matchedRules.length === 0;
|
|
435
|
+
if (quiet) return; // quiet checks stay in /triggers status + audit, not in the panel
|
|
436
|
+
const secs = Math.round(outcome.durationMs / 1000);
|
|
437
|
+
const cost = outcome.cost ? ` · $${outcome.cost.toFixed(3)}` : "";
|
|
438
|
+
const title = outcome.ok
|
|
439
|
+
? `trigger ${outcome.trigger.sourceLabel} · ${outcome.trigger.eventLabel} · ${secs}s${cost}${outcome.matchedRules.length ? ` · matched ${outcome.matchedRules.length}` : ""}${outcome.promoted ? " · promoted to chat" : ""}`
|
|
440
|
+
: `trigger ${outcome.trigger.sourceLabel} FAILED · ${secs}s`;
|
|
441
|
+
const lines: string[] = [];
|
|
442
|
+
if (!outcome.ok) lines.push(`! ${outcome.error ?? "unknown error"}`);
|
|
443
|
+
for (const r of outcome.matchedRules) lines.push(`• ${r.id.slice(0, 12)} when ${previewRedacted(r.condition, 60)} -> ${previewRedacted(r.action, 60)}${r.fireOnce ? " (fired once, now disabled)" : ""}`);
|
|
444
|
+
if (outcome.summary) lines.push(...previewRedacted(outcome.summary, 600).split("\n").slice(0, 8));
|
|
445
|
+
lines.push(`trace ${outcome.trigger.traceId.slice(0, 8)} · /triggers audit`);
|
|
446
|
+
if (ctx.mode === "tui") show(ctx, title, lines);
|
|
447
|
+
else ctx.ui.notify([title, ...lines].join("\n"), outcome.ok ? "info" : "warning");
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
/* ------------------------------------------------------ lifecycle hooks */
|
|
451
|
+
|
|
452
|
+
let hookRunner: HookRunner | undefined;
|
|
453
|
+
|
|
454
|
+
async function fireHook(data: HookEventData, ctx?: ExtensionContext): Promise<void> {
|
|
455
|
+
// Sub-agents are in-process sessions without this extension; only the interactive session fires hooks.
|
|
456
|
+
if (!hookRunner?.hasHooksFor(data.event)) return;
|
|
457
|
+
// Hooks are awaited inline, so a hook always completes before the agent moves on
|
|
458
|
+
// (and nothing is lost at exit). `[hooks] mode = "async"` restores the queued-off-turn behavior.
|
|
459
|
+
if (config.hooksMode === "async") void hookRunner.fire(data, ctx?.signal).catch((err: any) => log.warn(`hooks: ${err?.message ?? err}`));
|
|
460
|
+
else await hookRunner.fire(data, ctx?.signal);
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
/**
|
|
464
|
+
* A run's hooks, from the scheduler's callbacks. Never awaited even in `sync` mode: those
|
|
465
|
+
* callbacks are on the tick's path, and a webhook that hangs must not hold up the clock — the
|
|
466
|
+
* run it is announcing has already started or already finished. `sync` is about ordering within
|
|
467
|
+
* a conversation turn, and a run is not one.
|
|
468
|
+
*/
|
|
469
|
+
function fireRunHook(data: HookEventData): void {
|
|
470
|
+
if (!hookRunner?.hasHooksFor(data.event)) return;
|
|
471
|
+
void hookRunner.fire(data).catch((err: any) => log.warn(`hooks: ${err?.message ?? err}`));
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
/* ------------------------------------------------------------ panel */
|
|
475
|
+
|
|
476
|
+
const PANEL_KEY = "pi-loops-panel";
|
|
477
|
+
const PANEL_RULE_LIMIT = 5; // more than five rules is a list, not a panel
|
|
478
|
+
let panelOn = panelEnabled(readUiPrefs(dir));
|
|
479
|
+
|
|
480
|
+
function setPanelEnabled(on: boolean): void {
|
|
481
|
+
panelOn = on;
|
|
482
|
+
// `ui.json` also holds what the browser front end remembered, and this used to write the whole
|
|
483
|
+
// file: a panel toggle sent the next session back to pi's default model. src/ui-prefs.ts is
|
|
484
|
+
// where that merge lives now, for every writer this side of the front end.
|
|
485
|
+
writeUiPref(dir, "panel", on);
|
|
486
|
+
refreshPanel();
|
|
487
|
+
}
|
|
488
|
+
|
|
489
|
+
/**
|
|
490
|
+
* The panel (Triggers / Polling / Inbox / Cron / sources), rendered as a widget
|
|
491
|
+
* above the editor. Hidden when there is nothing to show.
|
|
492
|
+
*/
|
|
493
|
+
function refreshPanel(): void {
|
|
494
|
+
try {
|
|
495
|
+
refreshPanelInner();
|
|
496
|
+
} catch (err: any) {
|
|
497
|
+
storeReadFailed(err);
|
|
498
|
+
}
|
|
499
|
+
}
|
|
500
|
+
|
|
501
|
+
function refreshPanelInner(): void {
|
|
502
|
+
if (!lastCtx || lastCtx.mode !== "tui") return;
|
|
503
|
+
if (!panelOn || !schedulerStarted) {
|
|
504
|
+
lastCtx.ui.setWidget(PANEL_KEY, undefined);
|
|
505
|
+
return;
|
|
506
|
+
}
|
|
507
|
+
const rules = triggers.store.load().filter((r) => sameProject(r.cwd, session.cwd));
|
|
508
|
+
const jobs = scheduler.store.load().filter((j) => sameProject(j.cwd, session.cwd));
|
|
509
|
+
const inboxNew = scheduler.inbox.newCount();
|
|
510
|
+
const poll = triggers.lastPoll;
|
|
511
|
+
const sources = mcpSources.map((src) => ({ name: src.config.name, state: src.status.state, tools: (mcpToolNames.get(src.config.name) ?? []).length }));
|
|
512
|
+
if (!rules.length && !jobs.length && !inboxNew && !sources.length) {
|
|
513
|
+
lastCtx.ui.setWidget(PANEL_KEY, undefined);
|
|
514
|
+
return;
|
|
515
|
+
}
|
|
516
|
+
const clip = (x: string, n: number) => previewRedacted(x, n);
|
|
517
|
+
lastCtx.ui.setWidget(PANEL_KEY, (_tui, theme) => {
|
|
518
|
+
const dim = (x: string) => theme.fg("dim", x);
|
|
519
|
+
const head = (x: string) => theme.fg("accent", x);
|
|
520
|
+
const ok = (x: string) => theme.fg("success", x);
|
|
521
|
+
const warn = (x: string) => theme.fg("warning", x);
|
|
522
|
+
const lines: string[] = [];
|
|
523
|
+
if (rules.length) {
|
|
524
|
+
lines.push(head("Triggers"));
|
|
525
|
+
for (const r of rules.slice(0, PANEL_RULE_LIMIT)) {
|
|
526
|
+
lines.push((r.enabled ? ok : dim)(`${r.id.slice(0, 12)} [${r.enabled ? "enabled" : "disabled"}, ${r.fireOnce ? "once" : "repeat"}]`) + dim(` when ${clip(r.condition, 40)} do ${clip(r.action, 40)}`));
|
|
527
|
+
}
|
|
528
|
+
if (rules.length > PANEL_RULE_LIMIT) lines.push(dim(`… ${rules.length - PANEL_RULE_LIMIT} more`));
|
|
529
|
+
}
|
|
530
|
+
if (poll) {
|
|
531
|
+
// Polling: checked_at · outcome, source / event, trace, summary.
|
|
532
|
+
lines.push(head("Polling") + warn(` ${formatLocal(Date.parse(poll.at))} · ${poll.outcome}`));
|
|
533
|
+
lines.push(dim(` ${poll.sourceLabel} / ${poll.eventLabel} trace ${poll.traceId.slice(0, 8)}`));
|
|
534
|
+
lines.push(dim(` ${clip(poll.summary, 80)}`));
|
|
535
|
+
}
|
|
536
|
+
if (inboxNew > 0) lines.push(warn(`Inbox ${inboxNew} new — /inbox`));
|
|
537
|
+
if (jobs.length) {
|
|
538
|
+
const enabled = jobs.filter((j) => j.enabled).length;
|
|
539
|
+
lines.push(head("Cron") + (enabled === jobs.length ? ok : warn)(` enabled ${enabled} · disabled ${jobs.length - enabled}`));
|
|
540
|
+
for (const j of jobs.slice(0, PANEL_RULE_LIMIT)) {
|
|
541
|
+
const label = j.name ?? j.id.slice(0, 12);
|
|
542
|
+
lines.push((j.enabled ? ok : dim)(`${label} [${j.enabled ? "enabled" : "disabled"}${j.stateful ? ", stateful" : ""}] ${formatSchedule(j.schedule)}`) + dim(` do ${clip(j.prompt, 48)}`) + (j.skippedOverlap ? warn(` skipped overlaps ${j.skippedOverlap}`) : ""));
|
|
543
|
+
}
|
|
544
|
+
if (jobs.length > PANEL_RULE_LIMIT) lines.push(dim(`… ${jobs.length - PANEL_RULE_LIMIT} more`));
|
|
545
|
+
}
|
|
546
|
+
if (sources.length) {
|
|
547
|
+
const tools = sources.reduce((n, s) => n + s.tools, 0);
|
|
548
|
+
lines.push(head("MCP") + dim(` servers ${sources.length} · tools ${tools} · notification hooks ${sources.length}`));
|
|
549
|
+
lines.push(sources.map((src) => (src.state === "connected" ? ok : src.state === "disabled" ? dim : warn)(` ${src.name} ${src.state}${src.tools ? ` · ${src.tools} tool${src.tools === 1 ? "" : "s"}` : ""}`)).join(""));
|
|
550
|
+
}
|
|
551
|
+
const hookCount = hookRunner?.hooks.length ?? 0;
|
|
552
|
+
lines.push(head("Hooks") + dim(hookCount ? ` cli_hooks ${hookCount} rule${hookCount === 1 ? "" : "s"} (${[...new Set(hookRunner!.hooks.map((h) => h.event))].join(", ")})` : " none"));
|
|
553
|
+
lines.push(head("Runtime") + dim(" dedup · cycle suppress · fire-once rules · inject-and-run"));
|
|
554
|
+
const text = new Text(lines.join("\n"), 0, 0);
|
|
555
|
+
return text;
|
|
556
|
+
});
|
|
557
|
+
}
|
|
558
|
+
|
|
559
|
+
/**
|
|
560
|
+
* The system line when an inject-and-run turn starts (or is queued behind the current one). The id
|
|
561
|
+
* is a rule's trace for a trigger and a run id for a cron inject, which has no trace; the line
|
|
562
|
+
* calls it a trace either way, because that is the word people have learned to look for.
|
|
563
|
+
*/
|
|
564
|
+
function triggeredTurnLine(runId: string, idle: boolean): void {
|
|
565
|
+
if (!lastCtx?.hasUI) return;
|
|
566
|
+
lastCtx.ui.notify(idle ? `running triggered turn (trace ${runId.slice(0, 8)})` : `queued triggered turn (trace ${runId.slice(0, 8)}) after the current one`, "info");
|
|
567
|
+
}
|
|
568
|
+
|
|
569
|
+
/**
|
|
570
|
+
* A corrupt or truncated store must not take the session down with it. Both of these read the
|
|
571
|
+
* stores, and both are reached from the tick (`onLeadership` → `refreshBadge`) which runs as
|
|
572
|
+
* `void this.tick()` — pi installs no `unhandledRejection` handler, so a throw here kills pi.
|
|
573
|
+
*/
|
|
574
|
+
function refreshBadge(): void {
|
|
575
|
+
try {
|
|
576
|
+
refreshBadgeInner();
|
|
577
|
+
} catch (err: any) {
|
|
578
|
+
storeReadFailed(err);
|
|
579
|
+
}
|
|
580
|
+
}
|
|
581
|
+
|
|
582
|
+
/**
|
|
583
|
+
* The same state the TUI panel draws, written into the session as a `pi_loops_snapshot` entry so
|
|
584
|
+
* a front end that is not a terminal can read it. Everything here lives in this process — which
|
|
585
|
+
* MCP servers actually connected, what they exposed, which tools are active, whether this pi
|
|
586
|
+
* owns the clock — and none of it is in the store files a reader could open instead.
|
|
587
|
+
*/
|
|
588
|
+
function snapshotData(): Record<string, unknown> {
|
|
589
|
+
const rules = triggers.store.load().filter((r) => sameProject(r.cwd, session.cwd));
|
|
590
|
+
const jobs = scheduler.store.load().filter((j) => sameProject(j.cwd, session.cwd));
|
|
591
|
+
const poll = triggers.lastPoll;
|
|
592
|
+
return {
|
|
593
|
+
version: PI_LOOPS_VERSION,
|
|
594
|
+
cwd: session.cwd,
|
|
595
|
+
host: os.hostname(),
|
|
596
|
+
scheduler: {
|
|
597
|
+
running: schedulerStarted,
|
|
598
|
+
leader: schedulerStarted && scheduler.isLeader,
|
|
599
|
+
runs: scheduler.runningCount,
|
|
600
|
+
checks: triggers.runningList().filter((r) => r.sourceLabel === "local:dynamic").length,
|
|
601
|
+
deduped: triggers.dedupedCount,
|
|
602
|
+
},
|
|
603
|
+
counts: { jobs: jobs.length, jobsEnabled: jobs.filter((j) => j.enabled).length, rules: rules.length, rulesEnabled: rules.filter((r) => r.enabled).length, inboxNew: scheduler.inbox.newCount() },
|
|
604
|
+
mcp: mcpSources.map((src) => ({
|
|
605
|
+
name: src.config.name,
|
|
606
|
+
state: src.status.state,
|
|
607
|
+
kind: src.config.kind,
|
|
608
|
+
tools: mcpToolNames.get(src.config.name) ?? [],
|
|
609
|
+
injects: !!(src.config.injectAndRun || src.config.injectSummary),
|
|
610
|
+
queued: src.status.queuedCount,
|
|
611
|
+
dropped: src.status.droppedCount,
|
|
612
|
+
attention: src.status.requiresAttention ? previewRedacted(src.status.requiresAttention, 120) : undefined,
|
|
613
|
+
lastError: src.status.lastError ? previewRedacted(src.status.lastError, 160) : undefined,
|
|
614
|
+
})),
|
|
615
|
+
// A parse error can quote the offending line of mcp.toml, and this entry is written to the
|
|
616
|
+
// session file — so it goes through the same redaction as everything else that is shown.
|
|
617
|
+
mcpConfigError: mcpConfigError ? previewRedacted(mcpConfigError, 240) : undefined,
|
|
618
|
+
hooks: { count: hookRunner?.hooks.length ?? 0, events: [...new Set((hookRunner?.hooks ?? []).map((h) => h.event))] },
|
|
619
|
+
tools: lastCtx ? pi.getActiveTools() : [],
|
|
620
|
+
poll: poll ? { at: poll.at, outcome: poll.outcome, sourceLabel: poll.sourceLabel, eventLabel: poll.eventLabel, traceId: poll.traceId, summary: previewRedacted(poll.summary, 200) } : undefined,
|
|
621
|
+
at: stamp(),
|
|
622
|
+
};
|
|
623
|
+
}
|
|
624
|
+
|
|
625
|
+
let lastSnapshotFingerprint: string | undefined;
|
|
626
|
+
let lastSnapshotAt = 0;
|
|
627
|
+
|
|
628
|
+
/** Write the snapshot when `src/snapshot.ts` says this change deserves a permanent entry. */
|
|
629
|
+
function emitSnapshot(force = false): void {
|
|
630
|
+
if (!lastCtx) return;
|
|
631
|
+
try {
|
|
632
|
+
const data = snapshotData();
|
|
633
|
+
const fingerprint = snapshotFingerprint(data);
|
|
634
|
+
const now = Date.now();
|
|
635
|
+
if (!shouldEmitSnapshot({ fingerprint: lastSnapshotFingerprint, at: lastSnapshotAt }, fingerprint, now, force)) return;
|
|
636
|
+
lastSnapshotFingerprint = fingerprint;
|
|
637
|
+
lastSnapshotAt = now;
|
|
638
|
+
pi.appendEntry("pi_loops_snapshot", data);
|
|
639
|
+
} catch (err: any) {
|
|
640
|
+
// Reached from the tick, which runs as `void this.tick()`; pi installs no
|
|
641
|
+
// unhandledRejection handler, so a throw here would take the session down.
|
|
642
|
+
log.warn(`snapshot failed: ${err?.message ?? err}`);
|
|
643
|
+
}
|
|
644
|
+
}
|
|
645
|
+
|
|
646
|
+
let storeErrorShown: string | undefined;
|
|
647
|
+
function storeReadFailed(err: any): void {
|
|
648
|
+
const message = err?.message ?? String(err);
|
|
649
|
+
if (message === storeErrorShown) return; // say it once per distinct problem, not once per tick
|
|
650
|
+
storeErrorShown = message;
|
|
651
|
+
if (lastCtx?.hasUI) lastCtx.ui.notify(`[cron] cannot read the automation store: ${message}`, "warning");
|
|
652
|
+
else process.stderr.write(`[pi-loops] cannot read the automation store: ${message}\n`);
|
|
653
|
+
}
|
|
654
|
+
|
|
655
|
+
function refreshBadgeInner(): void {
|
|
656
|
+
refreshPanel();
|
|
657
|
+
emitSnapshot();
|
|
658
|
+
if (!lastCtx?.hasUI) return;
|
|
659
|
+
const parts: string[] = [];
|
|
660
|
+
const n = scheduler.inbox.newCount();
|
|
661
|
+
if (n > 0) parts.push(`Inbox: ${n} new`);
|
|
662
|
+
// Machine-wide like the inbox count and the running list above it: the clock is one per host,
|
|
663
|
+
// and a loop failing in another checkout is still this machine's automation going quiet.
|
|
664
|
+
const failing = failingSummary(scheduler.store.load(), os.hostname());
|
|
665
|
+
if (failing) parts.push(failing);
|
|
666
|
+
const running = [...scheduler.runningLabels(), ...triggers.runningList().map((r) => (r.sourceLabel === "local:dynamic" ? "trigger-check" : r.sourceLabel))];
|
|
667
|
+
if (running.length) parts.push(`running: ${running.join(", ")}`);
|
|
668
|
+
const attention = mcpSources.filter((s) => s.status.state === "disconnected" || s.status.state === "auth_failed").length;
|
|
669
|
+
if (attention) parts.push(`mcp: ${attention} source${attention === 1 ? "" : "s"} down`);
|
|
670
|
+
if (schedulerStarted && !scheduler.isLeader) parts.push("loops standby");
|
|
671
|
+
lastCtx.ui.setStatus(STATUS_KEY, parts.length ? parts.join(" · ") : undefined);
|
|
672
|
+
}
|
|
673
|
+
|
|
674
|
+
/** A run that fell back or retried looks identical to a clean one unless it is said out loud. */
|
|
675
|
+
function runNotes(record: { warning?: string; retries?: number; compactions?: number }): string[] {
|
|
676
|
+
const notes: string[] = [];
|
|
677
|
+
if (record.warning) notes.push(`! ${previewRedacted(record.warning, 200)}`);
|
|
678
|
+
if (record.retries) notes.push(` ${record.retries} provider retry/retries`);
|
|
679
|
+
if (record.compactions) notes.push(` ${record.compactions} context compaction(s)`);
|
|
680
|
+
return notes;
|
|
681
|
+
}
|
|
682
|
+
|
|
683
|
+
function showRunCard(ctx: ExtensionContext, job: LoopJob, record: RunRecord, findings: string[]): void {
|
|
684
|
+
const label = job.name ?? job.id;
|
|
685
|
+
const secs = Math.max(0, Math.round((Date.parse(record.finishedAt) - Date.parse(record.startedAt)) / 1000));
|
|
686
|
+
const cost = record.usage?.cost ? ` · $${record.usage.cost.toFixed(3)}` : "";
|
|
687
|
+
const title = record.ok
|
|
688
|
+
? `cron ${label} · ${secs}s${cost} · ${findings.length} finding${findings.length === 1 ? "" : "s"}${record.checker?.ok ? " (verified)" : ""}${record.stateUpdated ? " · state updated" : ""}`
|
|
689
|
+
: `cron ${label} FAILED · ${secs}s`;
|
|
690
|
+
const lines: string[] = [];
|
|
691
|
+
if (!record.ok) lines.push(`! ${record.error ?? "unknown error"}`);
|
|
692
|
+
lines.push(...runNotes(record as any));
|
|
693
|
+
if (record.summary) lines.push(...record.summary.split("\n").slice(0, 6));
|
|
694
|
+
for (const f of findings) lines.push(`• ${previewRedacted(f, 160)}`);
|
|
695
|
+
if (record.droppedFindings) lines.push(`(${record.droppedFindings} more findings dropped: per-run cap)`);
|
|
696
|
+
if (record.checker) {
|
|
697
|
+
const checker = record.checker;
|
|
698
|
+
if (!checker.ok) lines.push(`checker FAILED (${checker.error}); findings entered unverified`);
|
|
699
|
+
else lines.push(`checker kept ${checker.kept}, dropped ${checker.dropped.length}${checker.unreviewed ? `, ${checker.unreviewed} unreviewed` : ""}${checker.cost ? ` · $${checker.cost.toFixed(3)}` : ""} — /cron trace ${label} 1 checker`);
|
|
700
|
+
for (const drop of checker.dropped) lines.push(` ✗ ${previewRedacted(drop.text, 100)} — ${previewRedacted(drop.reason, 100)}`);
|
|
701
|
+
}
|
|
702
|
+
lines.push(`/cron trace ${label} · /inbox`);
|
|
703
|
+
if (ctx.mode === "tui") show(ctx, title, lines);
|
|
704
|
+
else ctx.ui.notify([title, ...lines].join("\n"), record.ok ? "info" : "warning");
|
|
705
|
+
}
|
|
706
|
+
|
|
707
|
+
/* ------------------------------------------------------------ views */
|
|
708
|
+
|
|
709
|
+
pi.registerEntryRenderer<ViewData>(VIEW_ENTRY, (entry, _opts, theme) => {
|
|
710
|
+
const data = entry.data ?? { title: "", lines: [] };
|
|
711
|
+
const box = new Box(1, 0, (text) => theme.bg("customMessageBg", text));
|
|
712
|
+
box.addChild(new Text(theme.fg("accent", data.title), 0, 0));
|
|
713
|
+
for (const line of data.lines) box.addChild(new Text(line, 0, 0));
|
|
714
|
+
return box;
|
|
715
|
+
});
|
|
716
|
+
|
|
717
|
+
function show(ctx: ExtensionContext, title: string, lines: string[]): void {
|
|
718
|
+
if (ctx.mode === "tui") pi.appendEntry<ViewData>(VIEW_ENTRY, { title, lines });
|
|
719
|
+
else if (ctx.hasUI) ctx.ui.notify([title, ...lines].join("\n"), "info");
|
|
720
|
+
else console.log([title, ...lines].join("\n"));
|
|
721
|
+
}
|
|
722
|
+
|
|
723
|
+
const short = (s: string, n: number) => (s.length > n ? `${s.slice(0, n - 1)}…` : s);
|
|
724
|
+
const homeRel = (p: string) => {
|
|
725
|
+
const home = process.env.HOME ?? "";
|
|
726
|
+
return home && p.startsWith(home) ? `~${p.slice(home.length)}` : p;
|
|
727
|
+
};
|
|
728
|
+
|
|
729
|
+
function jobLines(jobs: LoopJob[]): string[] {
|
|
730
|
+
const now = Date.now();
|
|
731
|
+
const here = os.hostname();
|
|
732
|
+
return jobs.map((job, i) => {
|
|
733
|
+
// A job stamped with another machine's hostname is filtered out by the scheduler
|
|
734
|
+
// (`job.host !== os.hostname()`), so promising a next run would be a lie. This also
|
|
735
|
+
// catches a renamed machine or a rebuilt container, which needs no second machine at all.
|
|
736
|
+
const elsewhere = job.host && job.host !== here ? `[other host: ${job.host} — /cron set ${i + 1} --host here]` : undefined;
|
|
737
|
+
const next = job.enabled && !elsewhere
|
|
738
|
+
? computeNext(
|
|
739
|
+
{
|
|
740
|
+
schedule: job.schedule,
|
|
741
|
+
createdAt: Date.parse(job.createdAt),
|
|
742
|
+
lastFiredAt: job.lastFiredAt ? Date.parse(job.lastFiredAt) : undefined,
|
|
743
|
+
},
|
|
744
|
+
now,
|
|
745
|
+
)
|
|
746
|
+
: undefined;
|
|
747
|
+
const dormant = !job.stateful && job.sessionId !== session.sessionId ? `[dormant: session ${(job.sessionId ?? "?").slice(0, 8)} not open here]` : undefined;
|
|
748
|
+
const orphan = job.stateful && !fs.existsSync(job.cwd) ? "[orphan: cwd missing]" : undefined;
|
|
749
|
+
const marks = [job.stateful ? "[stateful]" : undefined, job.verify ? "[verify]" : undefined, elsewhere, dormant, orphan, job.running ? `running ${job.running.runId}` : undefined, job.catchUp ? undefined : "[no-catchup]"]
|
|
750
|
+
.filter(Boolean)
|
|
751
|
+
.join(" ");
|
|
752
|
+
const head = `${String(i + 1).padStart(2)}. ${job.id}${job.name ? ` "${job.name}"` : ""} ${job.enabled ? "enabled" : "disabled"} ${formatSchedule(job.schedule)}${marks ? ` ${marks}` : ""}`;
|
|
753
|
+
const action = ` action: ${previewRedacted(job.prompt, 120)}`;
|
|
754
|
+
const meta = ` next ${next ? formatLocal(next) : "—"} · runs ${job.runCount}${job.skippedOverlap ? ` · overlap skips ${job.skippedOverlap}` : ""} · ${homeRel(job.cwd)}`;
|
|
755
|
+
const err = job.lastError ? ` last error: ${previewRedacted(job.lastError, 100)}` : job.lastFiredAt ? ` last fired: ${job.lastFiredAt}` : undefined;
|
|
756
|
+
return [head, action, meta, err].filter((l): l is string => !!l);
|
|
757
|
+
}).flat();
|
|
758
|
+
}
|
|
759
|
+
|
|
760
|
+
/* --------------------------------------------------------- creation */
|
|
761
|
+
|
|
762
|
+
const createJob = (input: CreateJobInput, scope?: JobScope) => createLoopJob(toolHost, input, scope);
|
|
763
|
+
|
|
764
|
+
/**
|
|
765
|
+
* The `cron_control_plane` audit: every add / enable / disable / remove, from a slash
|
|
766
|
+
* command or a tool, leaves a custom entry in the session (never in LLM context).
|
|
767
|
+
*/
|
|
768
|
+
function cronControlAudit(op: "add" | "enable" | "disable" | "remove", actor: "slash" | "tool" | "sub-agent", before?: LoopJob, after?: LoopJob): string {
|
|
769
|
+
const job = after ?? before;
|
|
770
|
+
const next = after?.enabled ? computeNext({ schedule: after.schedule, createdAt: Date.parse(after.createdAt), lastFiredAt: after.lastFiredAt ? Date.parse(after.lastFiredAt) : undefined }, Date.now()) : undefined;
|
|
771
|
+
// pi.appendEntry returns no id; mint one so tool results can point at the entry.
|
|
772
|
+
const auditEntryId = newId("audit");
|
|
773
|
+
pi.appendEntry("cron_control_plane", {
|
|
774
|
+
audit_entry_id: auditEntryId,
|
|
775
|
+
op,
|
|
776
|
+
actor,
|
|
777
|
+
job_id: job?.id,
|
|
778
|
+
schedule: job ? formatSchedule(job.schedule) : undefined,
|
|
779
|
+
action_preview: job ? previewRedacted(job.prompt, 120) : undefined,
|
|
780
|
+
before_enabled: before?.enabled,
|
|
781
|
+
after_enabled: after?.enabled,
|
|
782
|
+
next_run: next ? stamp(next) : undefined,
|
|
783
|
+
removed: !!before && !after,
|
|
784
|
+
});
|
|
785
|
+
return auditEntryId;
|
|
786
|
+
}
|
|
787
|
+
|
|
788
|
+
/* --------------------------------------------------------- commands */
|
|
789
|
+
|
|
790
|
+
const CRON_HELP = [
|
|
791
|
+
"/cron jobs of this project /cron all every project on this machine",
|
|
792
|
+
'/cron add [--stateful] "<minute hour dom month dow>" <prompt>',
|
|
793
|
+
' plain job: result appears in this chat (inject-and-run). --stateful: loop with memory, findings go to /inbox',
|
|
794
|
+
' e.g. /cron add --stateful "0 9 * * *" check the repo issues and report anything new since the last run',
|
|
795
|
+
" schedule also accepts @daily | every 30m | in 10m | at 2026-09-08T18:00",
|
|
796
|
+
" --verify: maker/checker — a second adversarial sub-agent reviews findings before they enter /inbox (--checker-model <provider/id> to use another model)",
|
|
797
|
+
" more flags: --name <n> --cwd <dir> --model <provider/id> --thinking <lvl> --tools a,b --timeout 20m --catchup|--no-catchup (default: loops catch up a missed tick, plain jobs do not)",
|
|
798
|
+
"/cron enable|disable|remove <n|id|name> /cron run <n|id|name> fire now",
|
|
799
|
+
"/cron set <n|id|name> [--model <p/id>|-] [--thinking <lvl>|-] [--timeout <dur>|-] [--name <n>|-] [--host here|-] change what a job runs with (- = use the session's current)",
|
|
800
|
+
' also [--prompt "<text>"] [--schedule "<expr>"]: reword a loop or move it to another hour in place — the job keeps its id, and a stateful loop keeps its notes',
|
|
801
|
+
"/cron state <n|id|name> the loop's notes (state spine)",
|
|
802
|
+
"/cron runs [n|id|name] recent runs /cron trace [n|id|name] [k] [checker] k-th latest run's transcript (maker, or its checker)",
|
|
803
|
+
"/cron scheduler who owns the timer /cron panel on|off side panel above the editor",
|
|
804
|
+
"/cron snapshot what only this process knows — connected MCP servers and their tools, who owns the clock — written into the session, for a front end that is not a terminal",
|
|
805
|
+
"/cron gc remove plain jobs whose session was deleted (they are parked as disabled first)",
|
|
806
|
+
"/cron cost [today|7d|all] what automation has cost, by job, and today's budget if one is set",
|
|
807
|
+
"/cron clear <n|id|name> clear a stuck `running` marker left by a process that is gone",
|
|
808
|
+
"/cron remove <ref> [--purge] remove a job; its loop state is kept unless --purge",
|
|
809
|
+
"/cron disable --all pause every job in this project (--all-projects for the machine); /cron enable --all resumes",
|
|
810
|
+
"/cron host [start|stop] the headless host that keeps the clock after the last pi quits; start = hand off on quit even with [host] auto = false",
|
|
811
|
+
"/inbox triage findings from stateful jobs (/inbox help)",
|
|
812
|
+
`/session-export [path] [--exclude-triggers] transcript + this project's cron jobs, trigger rules and loop state as one ${ARCHIVE_EXT} archive`,
|
|
813
|
+
"/session-import <path> [--activate-triggers=on|off] [--cwd <dir>] [--resume] restore it here (automation stays off unless activated)",
|
|
814
|
+
];
|
|
815
|
+
|
|
816
|
+
const cronCompletions = (prefix: string) => {
|
|
817
|
+
const subs = ["add", "list", "all", "enable", "disable", "remove", "set", "clear", "cost", "gc", "host", "run", "state", "runs", "trace", "scheduler", "panel", "snapshot", "help"];
|
|
818
|
+
const items = subs.filter((s) => s.startsWith(prefix)).map((s) => ({ value: s, label: s }));
|
|
819
|
+
return items.length ? items : null;
|
|
820
|
+
};
|
|
821
|
+
const cronHandler = async (args: string, ctx: ExtensionCommandContext) => {
|
|
822
|
+
lastCtx = ctx;
|
|
823
|
+
const { sub, rest } = splitCommand(args);
|
|
824
|
+
const jobs = () => scheduler.store.load();
|
|
825
|
+
// Numbers refer to the project-scoped list (/loop); ids and names resolve machine-wide.
|
|
826
|
+
const pick = (ref: string): LoopJob | undefined => {
|
|
827
|
+
const all = jobs();
|
|
828
|
+
const job = /^\d+$/.test(ref.trim()) ? resolveJobRef(all.filter((j) => sameProject(j.cwd, session.cwd)), ref) : resolveJobRef(all, ref);
|
|
829
|
+
if (!job) ctx.ui.notify(ref ? `no cron job with id '${ref}'` : `usage: /cron ${sub} <id>`, "warning");
|
|
830
|
+
return job;
|
|
831
|
+
};
|
|
832
|
+
// Every subcommand, by name only: with five more of them the argument spellings no longer fit
|
|
833
|
+
// on a line a notification can show, and `/cron help` has them all. A name left out of here
|
|
834
|
+
// is a subcommand nobody finds, which is the worse of the two.
|
|
835
|
+
const CRON_USAGE = "[list|all|add|set|run|enable|disable|remove|state|runs|trace|cost|clear|gc|host|scheduler|panel|snapshot|help] — /cron help for the arguments";
|
|
836
|
+
try {
|
|
837
|
+
switch (sub) {
|
|
838
|
+
case "":
|
|
839
|
+
case "list":
|
|
840
|
+
case "ls":
|
|
841
|
+
case "status":
|
|
842
|
+
case "all": {
|
|
843
|
+
const all = jobs();
|
|
844
|
+
if (!all.length) {
|
|
845
|
+
show(ctx, "Cron jobs: none", ['/cron add [--stateful] "0 9 * * *" <prompt> (/cron help for more)']);
|
|
846
|
+
return;
|
|
847
|
+
}
|
|
848
|
+
if (sub === "all") {
|
|
849
|
+
show(ctx, `Cron jobs (this machine, ${all.length}, times ${localOffset(Date.now())}):`, jobLines(all));
|
|
850
|
+
return;
|
|
851
|
+
}
|
|
852
|
+
const here = all.filter((j) => sameProject(j.cwd, session.cwd));
|
|
853
|
+
const elsewhere = all.length - here.length;
|
|
854
|
+
const lines = here.length ? jobLines(here) : ["(none in this project)"];
|
|
855
|
+
if (elsewhere) lines.push(`+ ${elsewhere} job${elsewhere === 1 ? "" : "s"} in other projects — /cron all`);
|
|
856
|
+
show(ctx, `Cron jobs (${homeRel(session.cwd)}, ${here.length}, times ${localOffset(Date.now())}):`, lines);
|
|
857
|
+
return;
|
|
858
|
+
}
|
|
859
|
+
case "help":
|
|
860
|
+
show(ctx, "/cron", CRON_HELP);
|
|
861
|
+
return;
|
|
862
|
+
case "panel": {
|
|
863
|
+
const on = rest.trim() === "on" ? true : rest.trim() === "off" ? false : !panelOn;
|
|
864
|
+
setPanelEnabled(on);
|
|
865
|
+
ctx.ui.notify(`panel ${on ? "on" : "off"} (Triggers / Inbox / Cron / MCP widget above the editor)`, "info");
|
|
866
|
+
return;
|
|
867
|
+
}
|
|
868
|
+
case "snapshot": {
|
|
869
|
+
// The panel's contents as a `pi_loops_snapshot` session entry: which MCP servers
|
|
870
|
+
// connected, what they exposed, the active tools, who owns the clock. A front end
|
|
871
|
+
// that is not a terminal (src/web.mjs) reads that instead of the widget.
|
|
872
|
+
emitSnapshot(true);
|
|
873
|
+
ctx.ui.notify("wrote a pi_loops_snapshot entry to the session", "info");
|
|
874
|
+
return;
|
|
875
|
+
}
|
|
876
|
+
case "add": {
|
|
877
|
+
const parsed = parseAddArgs(rest);
|
|
878
|
+
const job = await createJob({
|
|
879
|
+
schedule: parsed.schedule,
|
|
880
|
+
prompt: parsed.prompt,
|
|
881
|
+
stateful: parsed.stateful,
|
|
882
|
+
name: parsed.name,
|
|
883
|
+
cwd: parsed.cwd,
|
|
884
|
+
model: parsed.model,
|
|
885
|
+
thinking: parsed.thinking,
|
|
886
|
+
tools: parsed.tools,
|
|
887
|
+
timeoutMs: parsed.timeoutMs,
|
|
888
|
+
catchUp: parsed.catchUp,
|
|
889
|
+
verify: parsed.verify,
|
|
890
|
+
checkerModel: parsed.checkerModel,
|
|
891
|
+
});
|
|
892
|
+
const next = computeNext({ schedule: job.schedule, createdAt: Date.parse(job.createdAt) }, Date.now());
|
|
893
|
+
cronControlAudit("add", "slash", undefined, job);
|
|
894
|
+
show(ctx, `added cron job ${job.id}${job.name ? ` "${job.name}"` : ""}`, [
|
|
895
|
+
` schedule: ${formatSchedule(job.schedule)}${next ? ` (next run ${formatLocal(next)})` : ""}`,
|
|
896
|
+
...(job.stateful ? [` mode: stateful loop (findings go to /inbox${job.verify ? " after the checker" : ""})`] : [" mode: inject-and-run (the result appears in this chat)"]),
|
|
897
|
+
` action: ${previewRedacted(job.prompt, 120)}`,
|
|
898
|
+
]);
|
|
899
|
+
refreshBadge();
|
|
900
|
+
return;
|
|
901
|
+
}
|
|
902
|
+
case "run": {
|
|
903
|
+
const job = pick(rest);
|
|
904
|
+
if (!job) return;
|
|
905
|
+
if (job.stateful && !schedulerStarted) {
|
|
906
|
+
ctx.ui.notify("the scheduler is not running in this session (non-interactive mode)", "warning");
|
|
907
|
+
return;
|
|
908
|
+
}
|
|
909
|
+
const ran = await scheduler.runNow(job.id);
|
|
910
|
+
// A refusal comes back as the reason, phrased to follow the job's name — the shape
|
|
911
|
+
// this line has always had ("nightly is already running").
|
|
912
|
+
ctx.ui.notify(ran === true ? `running ${job.name ?? job.id} in the background…` : `${job.name ?? job.id} ${ran}`, ran === true ? "info" : "warning");
|
|
913
|
+
return;
|
|
914
|
+
}
|
|
915
|
+
case "enable":
|
|
916
|
+
case "resume":
|
|
917
|
+
case "disable":
|
|
918
|
+
case "pause": {
|
|
919
|
+
const enable = sub === "enable" || sub === "resume";
|
|
920
|
+
// Quitting pi is the *on* switch here (the host takes over), so "stop everything"
|
|
921
|
+
// has to be one command rather than one per job.
|
|
922
|
+
const scope = rest.trim();
|
|
923
|
+
if (scope === "--all" || scope === "--all-projects") {
|
|
924
|
+
const everywhere = scope === "--all-projects";
|
|
925
|
+
const targets = scheduler.store.load().filter((j) => (everywhere || sameProject(j.cwd, session.cwd)) && j.enabled !== enable);
|
|
926
|
+
for (const j of targets) {
|
|
927
|
+
const after = await scheduler.store.update(j.id, (x) => {
|
|
928
|
+
x.enabled = enable;
|
|
929
|
+
if (enable) x.lastError = undefined;
|
|
930
|
+
});
|
|
931
|
+
cronControlAudit(enable ? "enable" : "disable", "slash", j, after ?? j);
|
|
932
|
+
}
|
|
933
|
+
refreshBadge();
|
|
934
|
+
ctx.ui.notify(`${enable ? "enabled" : "disabled"} ${targets.length} job(s)${everywhere ? " on this machine" : " in this project"}`, "info");
|
|
935
|
+
return;
|
|
936
|
+
}
|
|
937
|
+
const job = pick(rest);
|
|
938
|
+
if (!job) return;
|
|
939
|
+
const after = await scheduler.store.update(job.id, (j) => {
|
|
940
|
+
j.enabled = enable;
|
|
941
|
+
if (enable) j.lastError = undefined;
|
|
942
|
+
});
|
|
943
|
+
cronControlAudit(enable ? "enable" : "disable", "slash", job, after ?? job);
|
|
944
|
+
ctx.ui.notify(`${enable ? "enabled" : "disabled"} cron job ${job.id}${job.name ? ` "${job.name}"` : ""}`, "info");
|
|
945
|
+
return;
|
|
946
|
+
}
|
|
947
|
+
case "remove":
|
|
948
|
+
case "rm":
|
|
949
|
+
case "delete": {
|
|
950
|
+
// `--purge` also deletes the loop's notes and transcripts. Without it they are kept:
|
|
951
|
+
// remove-and-re-add is how a schedule or prompt gets changed, and that must not
|
|
952
|
+
// throw away months of accumulated state.
|
|
953
|
+
const purge = /(^|\s)--purge(\s|$)/.test(rest);
|
|
954
|
+
const job = pick(rest.replace(/(^|\s)--purge(\s|$)/, " ").trim());
|
|
955
|
+
if (!job) return;
|
|
956
|
+
await scheduler.store.remove(job.id, { purge });
|
|
957
|
+
cronControlAudit("remove", "slash", job, undefined);
|
|
958
|
+
const kept = job.stateful && !purge ? `; its loop state is kept (${homeRel(scheduler.store.statePath(job.id))}) — /cron gc --purge clears orphaned state` : purge && job.stateful ? " and its loop state" : "";
|
|
959
|
+
ctx.ui.notify(`removed cron job ${job.id}${job.name ? ` "${job.name}"` : ""}${kept}`, "info");
|
|
960
|
+
return;
|
|
961
|
+
}
|
|
962
|
+
case "host": {
|
|
963
|
+
// While a pi is open it owns the clock; the host only ever runs when nobody is around.
|
|
964
|
+
const action = rest.trim();
|
|
965
|
+
if (action === "stop") {
|
|
966
|
+
handOffOnQuit = false;
|
|
967
|
+
const pid = stopHost(dir);
|
|
968
|
+
ctx.ui.notify(pid ? `stopped the background host (pid ${pid}); none will be started when this pi quits` : "no background host is running; none will be started when this pi quits", "info");
|
|
969
|
+
return;
|
|
970
|
+
}
|
|
971
|
+
if (action === "start") {
|
|
972
|
+
handOffOnQuit = true;
|
|
973
|
+
ctx.ui.notify("a background host will keep the clock when this pi quits (this pi owns it while open)", "info");
|
|
974
|
+
return;
|
|
975
|
+
}
|
|
976
|
+
const live = liveHost(dir);
|
|
977
|
+
const answer = live ? await askHost(dir, { op: "status" }) : undefined;
|
|
978
|
+
const hostSnapshot = answer?.ok ? answer.snapshot : undefined;
|
|
979
|
+
show(ctx, "Background host", [
|
|
980
|
+
live ? ` running: pid ${live.pid} since ${formatLocal(Date.parse(live.startedAt))} — unexpected while a pi is open; it exits on its next tick` : " not running (it runs only while no pi is open)",
|
|
981
|
+
...(hostSnapshot ? renderHostSnapshot(hostSnapshot) : []),
|
|
982
|
+
` hand-off on quit: ${handsOffOnQuit() ? "on" : "off"}${handOffOnQuit === undefined ? ` ([host] auto = ${config.hostAuto}; /cron host start|stop overrides it for this pi)` : handOffOnQuit ? " (/cron host start)" : " (/cron host stop)"}`,
|
|
983
|
+
` log: ${homeRel(path.join(dir, HOST_LOG))}`,
|
|
984
|
+
" /cron host start | stop",
|
|
985
|
+
]);
|
|
986
|
+
return;
|
|
987
|
+
}
|
|
988
|
+
case "clear": {
|
|
989
|
+
// A marker left by a process that was killed, whose pid another process now has,
|
|
990
|
+
// parks a loop forever: `clearStaleRunning` sees a live pid and leaves it alone.
|
|
991
|
+
const job = pick(rest);
|
|
992
|
+
if (!job) return;
|
|
993
|
+
if (!job.running) {
|
|
994
|
+
ctx.ui.notify(`${job.name ?? job.id} is not marked running`, "info");
|
|
995
|
+
return;
|
|
996
|
+
}
|
|
997
|
+
const marker = job.running;
|
|
998
|
+
const ok = await ctx.ui.confirm("Clear the running marker?", `${job.name ?? job.id} is marked as running since ${formatLocal(Date.parse(marker.startedAt))} by pid ${marker.pid}${marker.host && marker.host !== os.hostname() ? ` on ${marker.host}` : ""}.\n\nClear it only if that run is really gone — if it is still going, its result will still be written.`);
|
|
999
|
+
if (!ok) return;
|
|
1000
|
+
const updated = await scheduler.store.update(job.id, (j) => {
|
|
1001
|
+
j.running = undefined;
|
|
1002
|
+
j.lastError = `running marker cleared by hand (was ${marker.runId.slice(0, 12)}, pid ${marker.pid})`;
|
|
1003
|
+
});
|
|
1004
|
+
refreshBadge();
|
|
1005
|
+
ctx.ui.notify(updated ? `cleared the running marker on ${updated.name ?? updated.id}; it can fire again` : "job vanished", "info");
|
|
1006
|
+
return;
|
|
1007
|
+
}
|
|
1008
|
+
case "cost": {
|
|
1009
|
+
// The run log already holds every number; nothing added them up before, so a
|
|
1010
|
+
// headless host could run for a week before anyone saw the bill.
|
|
1011
|
+
const window = rest.trim() || "today";
|
|
1012
|
+
const now = Date.now();
|
|
1013
|
+
const since = (() => {
|
|
1014
|
+
if (window === "all") return 0;
|
|
1015
|
+
if (/^\d+d$/.test(window)) return now - Number(window.slice(0, -1)) * 86_400_000;
|
|
1016
|
+
const midnight = new Date(now);
|
|
1017
|
+
midnight.setHours(0, 0, 0, 0);
|
|
1018
|
+
return midnight.getTime();
|
|
1019
|
+
})();
|
|
1020
|
+
if (window !== "today" && window !== "all" && !/^\d+d$/.test(window)) {
|
|
1021
|
+
ctx.ui.notify("usage: /cron cost [today|7d|all]", "warning");
|
|
1022
|
+
return;
|
|
1023
|
+
}
|
|
1024
|
+
const spend = scheduler.store.spend(since);
|
|
1025
|
+
const byJob = [...spend.byJob.entries()].sort((a, b) => b[1].cost - a[1].cost);
|
|
1026
|
+
const budget = scheduler.budgetState();
|
|
1027
|
+
const label = window === "all" ? "since the run log was last rotated" : window === "today" ? "today" : `the last ${window}`;
|
|
1028
|
+
show(ctx, `Automation cost ${label}: $${spend.total.toFixed(3)} over ${spend.runs} run(s)`, [
|
|
1029
|
+
...(spend.rotated > 0 ? [` including $${spend.rotated.toFixed(3)} from runs the log has already rotated away`] : []),
|
|
1030
|
+
...(budget.cap > 0 ? [` today's budget: $${budget.spent.toFixed(2)} of $${budget.cap.toFixed(2)}${budget.over ? " — dispatching is paused" : ""}`] : [" no budget cap set ([limits] daily_budget_usd)"]),
|
|
1031
|
+
...byJob.slice(0, 12).map(([id, e]) => ` $${e.cost.toFixed(3)} ${e.runs} run(s) ${e.name ?? id}`),
|
|
1032
|
+
...(byJob.length > 12 ? [` (+${byJob.length - 12} more)`] : []),
|
|
1033
|
+
...(spend.runs ? [] : [" nothing has run in this window"]),
|
|
1034
|
+
]);
|
|
1035
|
+
return;
|
|
1036
|
+
}
|
|
1037
|
+
case "gc": {
|
|
1038
|
+
// The store is machine-wide, so an unscoped gc here would delete another
|
|
1039
|
+
// project's jobs — and with --purge their loop state — from a session that
|
|
1040
|
+
// never listed them. Every other listing is project-scoped; so is this.
|
|
1041
|
+
const all = /(^|\s)--all(\s|$)/.test(rest);
|
|
1042
|
+
const removed = await scheduler.gc((j) => all || sameProject(j.cwd, session.cwd));
|
|
1043
|
+
for (const job of removed) cronControlAudit("remove", "slash", job, undefined);
|
|
1044
|
+
const orphans = scheduler.store.orphanStates();
|
|
1045
|
+
const purge = /(^|\s)--purge(\s|$)/.test(rest);
|
|
1046
|
+
if (purge && orphans.length) for (const o of orphans) scheduler.store.purgeState(o.id);
|
|
1047
|
+
show(ctx, `removed ${removed.length} job(s) whose session no longer exists${all ? " (every project)" : " in this project"}`, [
|
|
1048
|
+
...removed.map((j) => ` - ${j.id}${j.name ? ` "${j.name}"` : ""} (session ${(j.sessionId ?? "?").slice(0, 8)})`),
|
|
1049
|
+
...(orphans.length && !purge ? [` ${orphans.length} loop state file(s) belong to jobs that are gone (${Math.round(orphans.reduce((n, o) => n + o.bytes, 0) / 1024)} KB) — /cron gc --purge deletes them`] : []),
|
|
1050
|
+
...(all ? [] : [" --all also collects other projects' dead jobs"]),
|
|
1051
|
+
...(purge && orphans.length ? [` deleted ${orphans.length} orphaned loop state file(s)`] : []),
|
|
1052
|
+
]);
|
|
1053
|
+
refreshBadge();
|
|
1054
|
+
return;
|
|
1055
|
+
}
|
|
1056
|
+
case "set": {
|
|
1057
|
+
// A pin here is explicit and editable.
|
|
1058
|
+
// --prompt and --schedule edit the job in place: its id, and therefore the loop's
|
|
1059
|
+
// notes at state/<id>.md, survive a rewording that used to cost a remove-and-re-add.
|
|
1060
|
+
const change = parseSetArgs(rest, { job: true });
|
|
1061
|
+
const job = pick(change.ref);
|
|
1062
|
+
if (!job) return;
|
|
1063
|
+
// What this edit means — which stamp to anchor, whether the job is now due at once,
|
|
1064
|
+
// whether the new expression will ever match — is decided in src/job-edit.ts, where
|
|
1065
|
+
// it can be tested. Anything it refuses throws before the store is touched.
|
|
1066
|
+
const applied = applyJobEdit(job, change, {
|
|
1067
|
+
now: Date.now(),
|
|
1068
|
+
hostName: os.hostname(),
|
|
1069
|
+
maxPromptBytes: MAX_PROMPT_BYTES,
|
|
1070
|
+
checkName: checkJobName,
|
|
1071
|
+
others: scheduler.store.load().filter((j) => j.id !== job.id),
|
|
1072
|
+
});
|
|
1073
|
+
// The patch carries only the edited fields: `update` re-reads under a lock, and a tick
|
|
1074
|
+
// that started a run in between has already set `running` on the copy it hands back.
|
|
1075
|
+
const updated = await scheduler.store.update(job.id, (j) => Object.assign(j, applied.patch));
|
|
1076
|
+
if (!updated) return;
|
|
1077
|
+
for (const line of applied.changed) log.info(`cron ${updated.id}: ${line}`);
|
|
1078
|
+
// A typo in a cron expression is invisible until it fails to fire, so the next run is
|
|
1079
|
+
// part of the confirmation.
|
|
1080
|
+
const nextRun =
|
|
1081
|
+
applied.nextRun.kind === "disabled"
|
|
1082
|
+
? `— (disabled; /cron enable ${updated.name ?? updated.id})`
|
|
1083
|
+
: applied.nextRun.kind === "due"
|
|
1084
|
+
? "due now (the next tick will fire it)"
|
|
1085
|
+
: applied.nextRun.kind === "at"
|
|
1086
|
+
? formatLocal(applied.nextRun.at)
|
|
1087
|
+
: "—";
|
|
1088
|
+
show(ctx, `updated cron job ${updated.id}${updated.name ? ` "${updated.name}"` : ""}`, [
|
|
1089
|
+
` action: ${previewRedacted(updated.prompt, 120)}`,
|
|
1090
|
+
` schedule: ${formatSchedule(updated.schedule)}`,
|
|
1091
|
+
` next run: ${nextRun}`,
|
|
1092
|
+
` model: ${updated.model ?? "(the running session's current model)"}`,
|
|
1093
|
+
` thinking: ${updated.thinking ?? "(the running session's current level)"}`,
|
|
1094
|
+
` timeout: ${updated.timeoutMs ? `${Math.round(updated.timeoutMs / 1000)}s` : "default"}`,
|
|
1095
|
+
` host: ${updated.host ?? "(any machine)"}`,
|
|
1096
|
+
]);
|
|
1097
|
+
return;
|
|
1098
|
+
}
|
|
1099
|
+
case "state": {
|
|
1100
|
+
const job = pick(rest);
|
|
1101
|
+
if (!job) return;
|
|
1102
|
+
if (!job.stateful) {
|
|
1103
|
+
ctx.ui.notify(`${job.name ?? job.id} is not stateful; only --stateful jobs keep notes`, "warning");
|
|
1104
|
+
return;
|
|
1105
|
+
}
|
|
1106
|
+
const state = scheduler.store.readState(job.id);
|
|
1107
|
+
show(ctx, `loop state of ${job.name ?? job.id} (${homeRel(scheduler.store.statePath(job.id))})`, state ? state.split("\n") : ["(first run — no notes yet)"]);
|
|
1108
|
+
return;
|
|
1109
|
+
}
|
|
1110
|
+
case "runs": {
|
|
1111
|
+
const job = rest ? pick(rest) : undefined;
|
|
1112
|
+
if (rest && !job) return;
|
|
1113
|
+
const runs = scheduler.store.listRuns(job?.id, 15);
|
|
1114
|
+
if (!runs.length) {
|
|
1115
|
+
show(ctx, "Runs: none yet", []);
|
|
1116
|
+
return;
|
|
1117
|
+
}
|
|
1118
|
+
const newestFirst = [...runs].reverse();
|
|
1119
|
+
show(ctx, `Recent runs${job ? ` of ${job.name ?? job.id}` : ""} (${runs.length}, newest first)`, [
|
|
1120
|
+
...newestFirst.map((r, i) => {
|
|
1121
|
+
const secs = Math.max(0, Math.round((Date.parse(r.finishedAt) - Date.parse(r.startedAt)) / 1000));
|
|
1122
|
+
const who = job ? "" : `${r.jobName ?? r.jobId} `;
|
|
1123
|
+
const cost = r.usage?.cost ? ` $${r.usage.cost.toFixed(3)}` : "";
|
|
1124
|
+
const chk = r.checker ? (r.checker.ok ? `, checker kept ${r.checker.kept}/${r.checker.kept + r.checker.dropped.length + r.checker.unreviewed}` : ", checker FAILED") : "";
|
|
1125
|
+
const tail = r.ok ? `${r.findings} finding${r.findings === 1 ? "" : "s"}${chk}${r.stateUpdated ? ", state updated" : ""}` : `FAILED: ${previewRedacted(r.error ?? "?", 80)}`;
|
|
1126
|
+
return `${String(i + 1).padStart(2)}. ${formatLocal(Date.parse(r.startedAt))} ${who}${secs}s${cost} · ${tail}${r.sessionFile ? "" : " (no transcript)"}`;
|
|
1127
|
+
}),
|
|
1128
|
+
job ? `transcript: /cron trace ${job.name ?? job.id} <k>` : "transcript: /cron trace <job> <k>",
|
|
1129
|
+
]);
|
|
1130
|
+
return;
|
|
1131
|
+
}
|
|
1132
|
+
case "trace": {
|
|
1133
|
+
const parts = rest.split(/\s+/).filter(Boolean);
|
|
1134
|
+
const wantChecker = parts[parts.length - 1]?.toLowerCase() === "checker";
|
|
1135
|
+
if (wantChecker) parts.pop();
|
|
1136
|
+
const k = parts.length > 1 ? Number(parts[parts.length - 1]) : 1;
|
|
1137
|
+
const ref = parts.length > 1 ? parts.slice(0, -1).join(" ") : parts[0] ?? "";
|
|
1138
|
+
const job = ref ? pick(ref) : undefined;
|
|
1139
|
+
if (ref && !job) return;
|
|
1140
|
+
if (!Number.isInteger(k) || k < 1) {
|
|
1141
|
+
ctx.ui.notify("usage: /cron trace [job] [k] (k = 1 is the latest run)", "warning");
|
|
1142
|
+
return;
|
|
1143
|
+
}
|
|
1144
|
+
const runs = scheduler.store.listRuns(job?.id, 200).reverse();
|
|
1145
|
+
const run = runs[k - 1];
|
|
1146
|
+
if (!run) {
|
|
1147
|
+
ctx.ui.notify(`no run #${k}${job ? ` for ${job.name ?? job.id}` : ""} (have ${runs.length})`, "warning");
|
|
1148
|
+
return;
|
|
1149
|
+
}
|
|
1150
|
+
const head = `${run.jobName ?? run.jobId} · ${formatLocal(Date.parse(run.startedAt))} · ${run.ok ? "ok" : "FAILED"}`;
|
|
1151
|
+
if (wantChecker) {
|
|
1152
|
+
if (!run.checker) {
|
|
1153
|
+
ctx.ui.notify("that run had no checker (job not created with --verify, or the maker reported nothing)", "warning");
|
|
1154
|
+
return;
|
|
1155
|
+
}
|
|
1156
|
+
const checker = run.checker;
|
|
1157
|
+
const lines = [`checker ${checker.ok ? "ok" : `FAILED: ${checker.error}`} · kept ${checker.kept} · dropped ${checker.dropped.length} · unreviewed ${checker.unreviewed} · ${Math.round(checker.durationMs / 1000)}s${checker.cost ? ` $${checker.cost.toFixed(3)}` : ""}${checker.model ? ` · ${checker.model}` : ""}`];
|
|
1158
|
+
for (const drop of checker.dropped) lines.push(` dropped: ${drop.text} — ${drop.reason}`);
|
|
1159
|
+
if (checker.sessionFile) lines.push("", ...summarizeSessionFile(checker.sessionFile, { maxLines: 60 }).map((line) => line.text), "", `full transcript: pi --session ${checker.sessionFile}`);
|
|
1160
|
+
show(ctx, `checker trace: ${head}`, lines);
|
|
1161
|
+
return;
|
|
1162
|
+
}
|
|
1163
|
+
if (!run.sessionFile) {
|
|
1164
|
+
show(ctx, `trace: ${head}`, [run.error ? `! ${run.error}` : "", run.summary ?? "(no transcript kept for this run)"].filter(Boolean));
|
|
1165
|
+
return;
|
|
1166
|
+
}
|
|
1167
|
+
const lines = summarizeSessionFile(run.sessionFile, { maxLines: 60 }).map((l) => l.text);
|
|
1168
|
+
lines.push("", `full transcript: pi --session ${run.sessionFile}`);
|
|
1169
|
+
show(ctx, `trace: ${head}`, lines);
|
|
1170
|
+
return;
|
|
1171
|
+
}
|
|
1172
|
+
case "scheduler": {
|
|
1173
|
+
const leader = scheduler.readLeader();
|
|
1174
|
+
const me = process.pid;
|
|
1175
|
+
show(ctx, `Cron scheduler (pi-loops ${PI_LOOPS_VERSION} on pi ${PI_VERSION})`, [
|
|
1176
|
+
`this process: pid ${me}, ${schedulerStarted ? (scheduler.isLeader ? "owns the timer" : "standby") : "not scheduling (non-interactive)"}, ${scheduler.runningCount} run(s) in flight${scheduler.runningCount ? ` (${scheduler.runningLabels().join(", ")})` : ""}`,
|
|
1177
|
+
`timer owner: ${leader ? `pid ${leader.pid}@${leader.host}${(leader as any).kind === "host" ? " (background host)" : ""}, heartbeat ${formatLocal(Date.parse(leader.heartbeatAt))}` : "none"}`,
|
|
1178
|
+
`background host: ${liveHost(dir) ? `pid ${liveHost(dir)!.pid} (exits on its next tick: a pi is open)` : "not running (runs only while no pi is open)"} · hand-off on quit: ${handsOffOnQuit() ? "on" : "off"}`,
|
|
1179
|
+
`store: ${homeRel(dir)}`,
|
|
1180
|
+
`log: ${homeRel(log.file)}`,
|
|
1181
|
+
`inbox: ${scheduler.inbox.newCount()} new`,
|
|
1182
|
+
]);
|
|
1183
|
+
return;
|
|
1184
|
+
}
|
|
1185
|
+
default:
|
|
1186
|
+
ctx.ui.notify(`unknown /cron command: ${sub}. usage: /cron ${CRON_USAGE}`, "warning");
|
|
1187
|
+
}
|
|
1188
|
+
} catch (err: any) {
|
|
1189
|
+
ctx.ui.notify(`cron: ${err?.message ?? err}`, "error");
|
|
1190
|
+
}
|
|
1191
|
+
};
|
|
1192
|
+
|
|
1193
|
+
pi.registerCommand("cron", {
|
|
1194
|
+
description: 'Scheduled jobs: /cron add [--stateful] "<schedule>" <prompt> — /cron help',
|
|
1195
|
+
getArgumentCompletions: cronCompletions,
|
|
1196
|
+
handler: cronHandler,
|
|
1197
|
+
});
|
|
1198
|
+
pi.registerCommand("crontab", { description: "Alias of /cron", getArgumentCompletions: cronCompletions, handler: cronHandler });
|
|
1199
|
+
pi.registerCommand("loop", { description: "Alias of /cron", getArgumentCompletions: cronCompletions, handler: cronHandler });
|
|
1200
|
+
|
|
1201
|
+
const INBOX_USAGE = "[list|all|claim <n>|dismiss <n>|clear|help] [--all]";
|
|
1202
|
+
|
|
1203
|
+
const INBOX_HELP = [
|
|
1204
|
+
"/inbox list new findings of this project /inbox --all every project on this machine",
|
|
1205
|
+
"/inbox all [--all] include claimed/dismissed history",
|
|
1206
|
+
"/inbox claim <n|id> mark claimed and hand it to the agent as a real turn",
|
|
1207
|
+
"/inbox dismiss <n|id> mark dismissed /inbox clear [--all] dismiss the ones listed",
|
|
1208
|
+
];
|
|
1209
|
+
|
|
1210
|
+
/** The project a finding came from, as `/cron` shows a job's: the directory name is enough to tell them apart. */
|
|
1211
|
+
const projectOf = (cwd: string) => (cwd ? path.basename(cwd) || homeRel(cwd) : "—");
|
|
1212
|
+
|
|
1213
|
+
/** List lines: the full (≤500-char) finding, id prefix, project, source, when it arrived. */
|
|
1214
|
+
function inboxLines(entries: InboxEntry[], numbered: boolean): string[] {
|
|
1215
|
+
return entries.map((e, i) => {
|
|
1216
|
+
// Stored as an instant (ISO, UTC) and shown in this machine's timezone, like every other
|
|
1217
|
+
// time pi-loops puts on a screen. It used to be the stored string with the `Z` sliced
|
|
1218
|
+
// off — a UTC time wearing the shape of a local one, eight hours from the `next` on the
|
|
1219
|
+
// `/cron` line above it, with nothing on either to say which was which.
|
|
1220
|
+
const when = formatLocal(Date.parse(e.createdAt));
|
|
1221
|
+
const mark = e.verified ? "✓ " : "";
|
|
1222
|
+
// The project comes first of the three: with loops running in several checkouts it is what
|
|
1223
|
+
// decides whether a finding is this morning's problem, and claiming runs it in that cwd.
|
|
1224
|
+
if (!numbered) return ` [${e.status}] ${mark}${redact(e.text)} (${projectOf(e.cwd)}, ${e.source})`;
|
|
1225
|
+
return ` ${i + 1}. [${e.id.slice(0, 12)}] ${mark}${redact(e.text)} (${projectOf(e.cwd)}, ${e.source}, ${when})`;
|
|
1226
|
+
});
|
|
1227
|
+
}
|
|
1228
|
+
|
|
1229
|
+
function claimPrompt(e: InboxEntry): string {
|
|
1230
|
+
const verified = e.verified ? `\n(An independent checker reviewed and kept this finding${e.verifiedReason ? `: ${e.verifiedReason}` : ""}.)` : "";
|
|
1231
|
+
return `A recurring loop (${e.source}, running in ${e.cwd}) reported this finding — investigate and address it:\n${e.text}${verified}`;
|
|
1232
|
+
}
|
|
1233
|
+
|
|
1234
|
+
/* ------------------------------------------------------------------ /goal */
|
|
1235
|
+
|
|
1236
|
+
/**
|
|
1237
|
+
* A stop condition the session is held to. The goal lives in the session (append-only `goal_state` entries,
|
|
1238
|
+
* so `--resume` finds it); after every settled turn an evaluator with no tools judges the
|
|
1239
|
+
* condition against a bounded transcript and either stops, sends the agent back to work, or
|
|
1240
|
+
* pauses. See src/goal.ts for the state machine and the prompts.
|
|
1241
|
+
*/
|
|
1242
|
+
let goal: GoalState | undefined;
|
|
1243
|
+
let goalMessages: unknown[] = [];
|
|
1244
|
+
let goalEvaluating = false;
|
|
1245
|
+
/** Set while the evaluator runs, so an abort or `/goal pause` can cut it short. */
|
|
1246
|
+
let goalAbort: AbortController | undefined;
|
|
1247
|
+
/** How the last turn ended: an aborted or failed turn says nothing about the goal. */
|
|
1248
|
+
let lastTurnStopReason: string | undefined;
|
|
1249
|
+
|
|
1250
|
+
function persistGoal(ctx: ExtensionContext | undefined, next: GoalState): void {
|
|
1251
|
+
goal = next.status === "cleared" ? undefined : next;
|
|
1252
|
+
pi.appendEntry(GOAL_ENTRY, next);
|
|
1253
|
+
refreshBadge();
|
|
1254
|
+
if (ctx?.hasUI) ctx.ui.setStatus(GOAL_STATUS_KEY, next.status === "cleared" ? undefined : `goal: ${next.status}`);
|
|
1255
|
+
}
|
|
1256
|
+
|
|
1257
|
+
async function evaluateGoal(ctx: ExtensionContext): Promise<void> {
|
|
1258
|
+
const pursued = goal;
|
|
1259
|
+
if (!pursued || pursued.status !== "pursuing" || goalEvaluating) return;
|
|
1260
|
+
// A turn that the user interrupted, or that the provider failed, is not evidence about the
|
|
1261
|
+
// goal — and re-prompting after an abort would leave Esc unable to stop a goal at all.
|
|
1262
|
+
if (lastTurnStopReason === "aborted" || lastTurnStopReason === "error") return;
|
|
1263
|
+
goalEvaluating = true;
|
|
1264
|
+
const evaluatorStartedAt = Date.now();
|
|
1265
|
+
// Where the session is now. The continuation the evaluator may ask for is a prompt about
|
|
1266
|
+
// *this* point; if the user has taken the turn by the time it comes back, it must not be
|
|
1267
|
+
// delivered as a follow-up on theirs (see branchMovedSince).
|
|
1268
|
+
const evaluatedAt = ctx.sessionManager.getLeafId();
|
|
1269
|
+
const ctrl = new AbortController();
|
|
1270
|
+
goalAbort = ctrl;
|
|
1271
|
+
// The evaluator is a sub-agent like any other and bills like one, so it counts against
|
|
1272
|
+
// `[cron] max_concurrent_runs` — but it is never refused a slot. It is this session's own turn
|
|
1273
|
+
// loop, one at a time, and a busy machine quietly declining to evaluate would look exactly like
|
|
1274
|
+
// a goal that was never set. It can therefore push the count past the limit; see slots.ts.
|
|
1275
|
+
// Taken here, immediately before the try that releases it, so nothing in between can leak it.
|
|
1276
|
+
const slot = subagentSlots.occupy();
|
|
1277
|
+
try {
|
|
1278
|
+
// The whole conversation on the active branch, compaction-aware — not just the messages of
|
|
1279
|
+
// the run that happened to end. `agent_end.messages` is only that run's, so evidence from
|
|
1280
|
+
// an earlier turn would be invisible and a satisfied goal would never be recognised.
|
|
1281
|
+
// Redacted like every other channel that leaves this process: the transcript is now the
|
|
1282
|
+
// whole branch, and it is both sent to the evaluator and kept as a sub-agent transcript.
|
|
1283
|
+
const transcript = redact(transcriptFromMessages(goalTranscript(ctx)));
|
|
1284
|
+
// No tools, the session's own model: the evaluator only reads what already happened.
|
|
1285
|
+
const result = await runner({
|
|
1286
|
+
cwd: session.cwd,
|
|
1287
|
+
prompt: evaluatorPrompt(pursued.condition, transcript),
|
|
1288
|
+
model: session.model,
|
|
1289
|
+
thinking: "off",
|
|
1290
|
+
tools: [],
|
|
1291
|
+
timeoutMs: GOAL_EVALUATOR_TIMEOUT_MS,
|
|
1292
|
+
signal: ctrl.signal,
|
|
1293
|
+
sessionDir: scheduler.store.sessionDirFor("goal"),
|
|
1294
|
+
hop: hop + 1,
|
|
1295
|
+
parentSessionId: session.sessionId,
|
|
1296
|
+
parentCwd: session.cwd,
|
|
1297
|
+
kind: "checker",
|
|
1298
|
+
});
|
|
1299
|
+
// The user can pause, clear or replace the goal while the evaluator runs; a decision about
|
|
1300
|
+
// the goal they had must not be written over the one they have now.
|
|
1301
|
+
if (!goal || goal.status !== "pursuing" || goal.condition !== pursued.condition) return;
|
|
1302
|
+
let outcome: { state: GoalState; action: GoalAction };
|
|
1303
|
+
if (ctrl.signal.aborted) outcome = pauseFor(pursued, "goal evaluator cancelled");
|
|
1304
|
+
else if (!result.ok) outcome = pauseFor(pursued, `goal evaluator failed: ${result.errorMessage ?? "unknown error"}`);
|
|
1305
|
+
else {
|
|
1306
|
+
try {
|
|
1307
|
+
outcome = applyDecision(pursued, parseDecision(result.text));
|
|
1308
|
+
} catch (err: any) {
|
|
1309
|
+
outcome = pauseFor(pursued, err?.message ?? String(err));
|
|
1310
|
+
}
|
|
1311
|
+
}
|
|
1312
|
+
// A continuation belongs to the point the evaluation started from. If the session has moved
|
|
1313
|
+
// on since — an unrelated prompt, a rewind — it is held rather than delivered on top of
|
|
1314
|
+
// somebody else's turn; the turn now running settles into another evaluation, which judges
|
|
1315
|
+
// the session as it then is. A continuation that was never sent does not cost budget either.
|
|
1316
|
+
const held = outcome.action.kind === "continue" && branchMovedSince(ctx.sessionManager.getBranch(), evaluatedAt);
|
|
1317
|
+
persistGoal(ctx, held ? { ...outcome.state, iterations: pursued.iterations } : outcome.state);
|
|
1318
|
+
// The evaluator is a model call like any other: it belongs in the run log, or `/cron cost`
|
|
1319
|
+
// would under-report every session that has a goal set.
|
|
1320
|
+
scheduler.store.appendRun({
|
|
1321
|
+
runId: newId("run"),
|
|
1322
|
+
jobId: "goal",
|
|
1323
|
+
jobName: "goal evaluator",
|
|
1324
|
+
stateful: false,
|
|
1325
|
+
cwd: session.cwd,
|
|
1326
|
+
pid: process.pid,
|
|
1327
|
+
startedAt: stamp(evaluatorStartedAt),
|
|
1328
|
+
finishedAt: stamp(),
|
|
1329
|
+
ok: result.ok,
|
|
1330
|
+
error: result.ok ? undefined : result.errorMessage,
|
|
1331
|
+
findings: 0,
|
|
1332
|
+
droppedFindings: 0,
|
|
1333
|
+
stateUpdated: false,
|
|
1334
|
+
model: result.model,
|
|
1335
|
+
usage: result.usage,
|
|
1336
|
+
summary: previewRedacted(outcome.state.lastReason ?? "", 200),
|
|
1337
|
+
sessionFile: result.sessionFile,
|
|
1338
|
+
});
|
|
1339
|
+
scheduler.store.pruneSessions("goal", 20);
|
|
1340
|
+
if (outcome.action.kind === "stop") {
|
|
1341
|
+
show(ctx, "Goal achieved", [` condition: ${previewRedacted(outcome.state.condition, 200)}`, ` evidence: ${previewRedacted(outcome.state.lastReason ?? "", 300)}`, ` ${outcome.state.iterations} continuation(s)`]);
|
|
1342
|
+
} else if (outcome.action.kind === "pause") {
|
|
1343
|
+
notifyOrLog(ctx, `[goal] paused: ${previewRedacted(outcome.action.reason, 200)} — /goal resume to continue`, "warning");
|
|
1344
|
+
} else if (held) {
|
|
1345
|
+
log.info(`goal: continuation held, the session moved on while the evaluator ran (${previewRedacted(outcome.state.lastReason ?? "", 160)})`);
|
|
1346
|
+
notifyOrLog(ctx, "[goal] not satisfied, but you sent something meanwhile — the continuation is held and re-evaluated when this turn settles", "info");
|
|
1347
|
+
} else {
|
|
1348
|
+
notifyOrLog(ctx, `[goal] not satisfied (${outcome.state.iterations}/${MAX_CONTINUATIONS}): ${previewRedacted(outcome.state.lastReason ?? "", 160)}`, "info");
|
|
1349
|
+
// The reason is model output derived from a transcript that can contain hostile file,
|
|
1350
|
+
// web or MCP content, and this is the highest-trust channel in the session — so it is
|
|
1351
|
+
// redacted and capped before it is handed back to the agent. `deliverAs` matches every
|
|
1352
|
+
// other injection site: the session accepts input again before this runs, so an
|
|
1353
|
+
// unguarded prompt() would throw and the continuation would be lost.
|
|
1354
|
+
const prompt = continuationPrompt(outcome.state.condition, capRedacted(outcome.state.lastReason ?? "", 2000));
|
|
1355
|
+
pi.sendUserMessage(prompt, ctx.isIdle() ? undefined : { deliverAs: "followUp" });
|
|
1356
|
+
}
|
|
1357
|
+
} finally {
|
|
1358
|
+
slot.release();
|
|
1359
|
+
goalEvaluating = false;
|
|
1360
|
+
goalAbort = undefined;
|
|
1361
|
+
}
|
|
1362
|
+
}
|
|
1363
|
+
|
|
1364
|
+
/** `notify` is a no-op without a UI (`pi -p`, the rpc mode), where these lines still matter. */
|
|
1365
|
+
function notifyOrLog(ctx: ExtensionContext, text: string, level: "info" | "warning"): void {
|
|
1366
|
+
if (ctx.hasUI) ctx.ui.notify(text, level);
|
|
1367
|
+
else process.stderr.write(`${text}\n`);
|
|
1368
|
+
}
|
|
1369
|
+
|
|
1370
|
+
/** The active branch's conversation, which is what the goal is judged against. */
|
|
1371
|
+
function goalTranscript(ctx: ExtensionContext): Array<{ role?: string; content?: unknown }> {
|
|
1372
|
+
try {
|
|
1373
|
+
const entries = ctx.sessionManager.buildContextEntries() as Array<{ message?: { role?: string; content?: unknown } }>;
|
|
1374
|
+
const messages = entries.map((e) => e.message).filter((m): m is { role?: string; content?: unknown } => !!m);
|
|
1375
|
+
if (messages.length) return messages;
|
|
1376
|
+
} catch {
|
|
1377
|
+
/* fall back to the run that just ended */
|
|
1378
|
+
}
|
|
1379
|
+
return goalMessages as Array<{ role?: string; content?: unknown }>;
|
|
1380
|
+
}
|
|
1381
|
+
|
|
1382
|
+
const GOAL_HELP = [
|
|
1383
|
+
"/goal <condition> hold this session to a condition; after every turn an evaluator decides",
|
|
1384
|
+
"/goal show the current goal and what the evaluator last said",
|
|
1385
|
+
"/goal pause | resume stop / restart evaluating without losing the condition",
|
|
1386
|
+
"/goal clear drop the goal",
|
|
1387
|
+
" (these three are exact words; anything longer is read as a condition)",
|
|
1388
|
+
`the agent is sent back to work at most ${MAX_CONTINUATIONS} times; an evaluator that cannot decide pauses instead of looping`,
|
|
1389
|
+
];
|
|
1390
|
+
|
|
1391
|
+
pi.registerCommand("goal", {
|
|
1392
|
+
description: "Hold this session to a stop condition, evaluated after every turn",
|
|
1393
|
+
getArgumentCompletions: (prefix) => {
|
|
1394
|
+
const subs = ["pause", "resume", "clear", "help"];
|
|
1395
|
+
const items = subs.filter((s) => s.startsWith(prefix)).map((s) => ({ value: s, label: s }));
|
|
1396
|
+
return items.length ? items : null;
|
|
1397
|
+
},
|
|
1398
|
+
handler: async (args, ctx: ExtensionCommandContext) => {
|
|
1399
|
+
lastCtx = ctx;
|
|
1400
|
+
const text = args.trim();
|
|
1401
|
+
// The subcommand arms are guarded by arity: anything longer than the bare
|
|
1402
|
+
// word is a condition. Without that, `/goal clear all the type errors` wipes a live goal.
|
|
1403
|
+
const sub = /^(pause|resume|clear|help|start)$/.test(text) ? text : "";
|
|
1404
|
+
if (sub === "help") return show(ctx, "Goal", GOAL_HELP);
|
|
1405
|
+
if (!text) {
|
|
1406
|
+
if (!goal) return show(ctx, "Goal: none", [" /goal <condition> — e.g. /goal the test suite passes and the changes are committed"]);
|
|
1407
|
+
return show(ctx, `Goal: ${goal.status}`, [` condition: ${previewRedacted(goal.condition, 200)}`, ` ${goal.iterations}/${MAX_CONTINUATIONS} continuations`, ...(goal.lastReason ? [` evaluator: ${previewRedacted(goal.lastReason, 300)}`] : []), ` updated ${formatLocal(Date.parse(goal.updatedAt))}`]);
|
|
1408
|
+
}
|
|
1409
|
+
if (sub === "start") return ctx.ui.notify("usage: /goal start <prompt> — set a goal with /goal <condition>, then send the prompt to begin", "warning");
|
|
1410
|
+
if (sub === "pause" || sub === "resume" || sub === "clear") {
|
|
1411
|
+
if (!goal) return ctx.ui.notify("no active goal; set one with /goal <condition>", "warning");
|
|
1412
|
+
// An evaluation in flight was decided about the goal as it was; stop it here.
|
|
1413
|
+
goalAbort?.abort();
|
|
1414
|
+
if (sub === "clear") {
|
|
1415
|
+
persistGoal(ctx, { ...goal, status: "cleared", updatedAt: stamp() });
|
|
1416
|
+
return ctx.ui.notify("goal cleared", "info");
|
|
1417
|
+
}
|
|
1418
|
+
if (sub === "resume" && goal.status === "achieved") return ctx.ui.notify("this goal was achieved; set a new one with /goal <condition>", "warning");
|
|
1419
|
+
// Resuming after the budget ran out starts the allowance again.
|
|
1420
|
+
const next: GoalState = sub === "pause" ? { ...goal, status: "paused", updatedAt: stamp() } : { ...goal, status: "pursuing", iterations: goal.status === "budget_limited" ? 0 : goal.iterations, updatedAt: stamp() };
|
|
1421
|
+
persistGoal(ctx, next);
|
|
1422
|
+
return ctx.ui.notify(`goal ${sub === "pause" ? "paused" : "resumed"}`, "info");
|
|
1423
|
+
}
|
|
1424
|
+
goalAbort?.abort(); // the old goal's evaluation is about a condition the user just replaced
|
|
1425
|
+
persistGoal(ctx, newGoal(text));
|
|
1426
|
+
ctx.ui.notify(`goal set: ${previewRedacted(text, 160)} — send a prompt to begin; evaluated after every turn, up to ${MAX_CONTINUATIONS} continuations`, "info");
|
|
1427
|
+
},
|
|
1428
|
+
});
|
|
1429
|
+
|
|
1430
|
+
pi.registerCommand("inbox", {
|
|
1431
|
+
description: "Triage findings from stateful cron jobs — /inbox help",
|
|
1432
|
+
getArgumentCompletions: (prefix) => {
|
|
1433
|
+
const subs = ["list", "all", "claim", "dismiss", "clear", "help", "--all"];
|
|
1434
|
+
const items = subs.filter((s) => s.startsWith(prefix)).map((s) => ({ value: s, label: s }));
|
|
1435
|
+
return items.length ? items : null;
|
|
1436
|
+
},
|
|
1437
|
+
handler: async (args, ctx: ExtensionCommandContext) => {
|
|
1438
|
+
lastCtx = ctx;
|
|
1439
|
+
const { sub, rest } = splitCommand(args);
|
|
1440
|
+
// The inbox is machine-wide because loops are; triage is per project, like /cron and
|
|
1441
|
+
// /triggers, with the same `--all` escape. It may sit anywhere in the line (`/inbox --all`,
|
|
1442
|
+
// `/inbox all --all`, `/inbox clear --all`), so it is read off the whole argument string.
|
|
1443
|
+
const everywhere = /(^|\s)--all(\s|$)/.test(args);
|
|
1444
|
+
const ref = rest.replace(/(^|\s)--all(\s|$)/, " ").trim();
|
|
1445
|
+
const scoped = (entries: InboxEntry[]) => (everywhere ? entries : inProject(entries, session.cwd, sameProject));
|
|
1446
|
+
const where = everywhere ? "this machine" : homeRel(session.cwd);
|
|
1447
|
+
try {
|
|
1448
|
+
switch (sub) {
|
|
1449
|
+
case "":
|
|
1450
|
+
case "--all":
|
|
1451
|
+
case "list": {
|
|
1452
|
+
const all = scheduler.inbox.listNew();
|
|
1453
|
+
const entries = scoped(all);
|
|
1454
|
+
if (!entries.length) {
|
|
1455
|
+
show(ctx, all.length ? `inbox: nothing new in ${homeRel(session.cwd)} — ${all.length} finding(s) elsewhere (/inbox --all)` : "inbox: empty — stateful loops (/cron add --stateful) report findings here", []);
|
|
1456
|
+
return;
|
|
1457
|
+
}
|
|
1458
|
+
const lines = inboxLines(entries, true);
|
|
1459
|
+
const elsewhere = all.length - entries.length;
|
|
1460
|
+
if (elsewhere) lines.push(`+ ${elsewhere} finding${elsewhere === 1 ? "" : "s"} in other projects — /inbox --all`);
|
|
1461
|
+
lines.push("claim with /inbox claim <n>, dismiss with /inbox dismiss <n>");
|
|
1462
|
+
show(ctx, `Inbox (${where}, ${entries.length} new, times ${localOffset(Date.now())}):`, lines);
|
|
1463
|
+
return;
|
|
1464
|
+
}
|
|
1465
|
+
case "all": {
|
|
1466
|
+
const entries = scoped(scheduler.inbox.list());
|
|
1467
|
+
show(ctx, `Inbox history (${where}, ${entries.length} total):`, entries.length ? inboxLines(entries, false) : ["(empty)"]);
|
|
1468
|
+
return;
|
|
1469
|
+
}
|
|
1470
|
+
case "help":
|
|
1471
|
+
show(ctx, "inbox", INBOX_HELP);
|
|
1472
|
+
return;
|
|
1473
|
+
case "claim":
|
|
1474
|
+
case "dismiss": {
|
|
1475
|
+
// Numbers are the numbers on screen — this project's list, as `/cron` does it; an id
|
|
1476
|
+
// or a prefix still resolves machine-wide, so a finding can be claimed from anywhere.
|
|
1477
|
+
const entries = /^\d+$/.test(ref) ? scoped(scheduler.inbox.listNew()) : scheduler.inbox.listNew();
|
|
1478
|
+
const entry = resolveInboxRef(entries, ref);
|
|
1479
|
+
if (!entry) {
|
|
1480
|
+
const n = Number(ref);
|
|
1481
|
+
ctx.ui.notify(!ref ? "usage: /inbox claim|dismiss <n or inb-id>" : Number.isInteger(n) ? `no inbox entry #${n} in ${where} (have ${entries.length}; /inbox --all lists every project)` : `no new inbox entry matching '${ref}'`, "warning");
|
|
1482
|
+
return;
|
|
1483
|
+
}
|
|
1484
|
+
await scheduler.inbox.setStatus(entry.id, sub === "claim" ? "claimed" : "dismissed", sub === "claim" ? session.sessionId : undefined);
|
|
1485
|
+
refreshBadge();
|
|
1486
|
+
if (sub === "dismiss") {
|
|
1487
|
+
ctx.ui.notify(`dismissed: ${previewRedacted(entry.text, 80)}`, "info");
|
|
1488
|
+
return;
|
|
1489
|
+
}
|
|
1490
|
+
pi.sendUserMessage(claimPrompt(entry), ctx.isIdle() ? undefined : { deliverAs: "followUp" });
|
|
1491
|
+
return;
|
|
1492
|
+
}
|
|
1493
|
+
case "clear": {
|
|
1494
|
+
// What was listed is what is dismissed: with loops in several checkouts, a clear that
|
|
1495
|
+
// also wiped four other projects' unread findings would be unrecoverable.
|
|
1496
|
+
const n = await scheduler.inbox.dismissAllNew(everywhere ? undefined : (e) => belongsToProject(e, session.cwd, sameProject));
|
|
1497
|
+
refreshBadge();
|
|
1498
|
+
ctx.ui.notify(`dismissed ${n} inbox entr${n === 1 ? "y" : "ies"} in ${where}`, "info");
|
|
1499
|
+
return;
|
|
1500
|
+
}
|
|
1501
|
+
default:
|
|
1502
|
+
ctx.ui.notify(`unknown /inbox subcommand: ${sub}; usage: /inbox ${INBOX_USAGE}`, "warning");
|
|
1503
|
+
}
|
|
1504
|
+
} catch (err: any) {
|
|
1505
|
+
ctx.ui.notify(`inbox: ${err?.message ?? err}`, "error");
|
|
1506
|
+
}
|
|
1507
|
+
},
|
|
1508
|
+
});
|
|
1509
|
+
|
|
1510
|
+
/* ---------------------------------------------------------- /triggers */
|
|
1511
|
+
|
|
1512
|
+
// The menu names every subcommand; the arguments live in TRIGGERS_HELP below, which `/triggers
|
|
1513
|
+
// help` prints. This line used to *be* that help, so what it left out (`hooks`, `panel`,
|
|
1514
|
+
// `set --host`) was left out of the product.
|
|
1515
|
+
const TRIGGERS_USAGE = "[status|rules|sources|hooks|enable|disable|remove|set|run|running|audit|abort|panel|help] — /triggers help for the arguments";
|
|
1516
|
+
|
|
1517
|
+
const TRIGGERS_HELP = [
|
|
1518
|
+
"/triggers rule counts, who owns the checker, its last check, push sources",
|
|
1519
|
+
"/triggers rules [--all] this project's dynamic rules (--all: every project on this machine)",
|
|
1520
|
+
"/triggers sources MCP push sources, the local crontab, the dynamic checker — and what each has seen (/triggers hooks is the same view)",
|
|
1521
|
+
"/triggers enable <n|id> /triggers disable <n|id> also --all (this project) | --all-projects (the machine)",
|
|
1522
|
+
"/triggers remove <n|id> also remove --all | remove --all-projects; /new-trigger creates one",
|
|
1523
|
+
"/triggers set <n|id> [--model <p/id>|-] [--thinking <lvl>|-] [--timeout <dur>|-] [--host here|-] what the action runs with (- = the session's current; --host - = any machine)",
|
|
1524
|
+
"/triggers run <n|id> check one rule now — dedup, audit, sub-agent and promotion as on a poll",
|
|
1525
|
+
"/triggers running actions in flight (dynamic checks and cron runs), and the sub-agent slots in use",
|
|
1526
|
+
"/triggers audit [N] [--all] recent decisions, with each run's transcript path (default 10)",
|
|
1527
|
+
"/triggers abort <trace_id> stop one running action /triggers abort --all stop every one",
|
|
1528
|
+
"/triggers panel on|off side panel above the editor (Triggers / Inbox / Cron / MCP)",
|
|
1529
|
+
"create one: /new-trigger when ~/build.done exists, run cargo test and show me the result",
|
|
1530
|
+
"config: ~/.pi/agent/loops/config.toml [triggers] poll_interval_secs, mcp.toml for push sources, hooks.toml for lifecycle hooks",
|
|
1531
|
+
];
|
|
1532
|
+
|
|
1533
|
+
function ruleLines(rules: ReturnType<TriggerStore["load"]>, numbered: boolean): string[] {
|
|
1534
|
+
return rules.map((r, i) => {
|
|
1535
|
+
const state = r.enabled ? "enabled" : "disabled";
|
|
1536
|
+
const fire = r.fireOnce ? "fire_once" : "repeat";
|
|
1537
|
+
const out = r.promoteToChat ? "promote_to_chat" : "audit_only";
|
|
1538
|
+
const fired = r.firedAt ? `, fired_at=${r.firedAt}` : "";
|
|
1539
|
+
const head = numbered ? `${String(i + 1).padStart(2)}. ` : " - ";
|
|
1540
|
+
const other = r.createdBy?.sessionId && r.createdBy.sessionId !== session.sessionId ? ` (session ${r.createdBy.sessionId.slice(0, 8)})` : "";
|
|
1541
|
+
// Same as jobLines: the runtime skips a rule stamped with another machine's hostname.
|
|
1542
|
+
const elsewhere = r.host && r.host !== os.hostname() ? ` [other host: ${r.host}]` : "";
|
|
1543
|
+
return `${head}${r.id} [${state}, ${fire}, ${out}${fired}]${elsewhere} when ${previewRedacted(r.condition, 80)} -> ${previewRedacted(r.action, 80)}${!sameProject(r.cwd, session.cwd) ? ` (${homeRel(r.cwd)})` : ""}${other}`;
|
|
1544
|
+
});
|
|
1545
|
+
}
|
|
1546
|
+
|
|
1547
|
+
pi.registerCommand("triggers", {
|
|
1548
|
+
description: "Show trigger sources, rules, running actions, and recent audit — /triggers help",
|
|
1549
|
+
getArgumentCompletions: (prefix) => {
|
|
1550
|
+
const subs = ["status", "rules", "sources", "hooks", "enable", "disable", "remove", "set", "run", "running", "audit", "abort", "panel", "help"];
|
|
1551
|
+
const items = subs.filter((s) => s.startsWith(prefix)).map((s) => ({ value: s, label: s }));
|
|
1552
|
+
return items.length ? items : null;
|
|
1553
|
+
},
|
|
1554
|
+
handler: async (args, ctx) => {
|
|
1555
|
+
lastCtx = ctx;
|
|
1556
|
+
const { sub, rest } = splitCommand(args);
|
|
1557
|
+
const store = triggers.store;
|
|
1558
|
+
const pickRule = (ref: string) => {
|
|
1559
|
+
const all = store.load();
|
|
1560
|
+
const rule = /^\d+$/.test(ref.trim()) ? resolveRuleRef(all.filter((r) => sameProject(r.cwd, session.cwd)), ref) : resolveRuleRef(all, ref);
|
|
1561
|
+
if (!rule) ctx.ui.notify(ref ? `no dynamic trigger rule with id '${ref}'` : `usage: /triggers ${sub} <id>${sub === "remove" || sub === "rm" || sub === "delete" ? "|--all" : ""}`, "warning");
|
|
1562
|
+
return rule;
|
|
1563
|
+
};
|
|
1564
|
+
try {
|
|
1565
|
+
switch (sub) {
|
|
1566
|
+
case "":
|
|
1567
|
+
case "status": {
|
|
1568
|
+
const rules = store.load();
|
|
1569
|
+
const enabled = rules.filter((r) => r.enabled).length;
|
|
1570
|
+
const fireOnce = rules.filter((r) => r.fireOnce).length;
|
|
1571
|
+
const promote = rules.filter((r) => r.promoteToChat).length;
|
|
1572
|
+
const leader = scheduler.readLeader();
|
|
1573
|
+
show(ctx, "Trigger status:", [
|
|
1574
|
+
` dynamic rules: ${rules.length} total, ${enabled} enabled, ${rules.length - enabled} disabled (${fireOnce} fire_once, ${rules.length - fireOnce} repeat, ${promote} promote_to_chat)`,
|
|
1575
|
+
` local dynamic checker: ${schedulerStarted ? `this process for ${homeRel(session.cwd)}${scheduler.isLeader ? " (and, as timer owner, for projects with no pi open)" : ` (timer owned by pid ${leader?.pid ?? "?"})`}` : "not running here"}, polls every ${triggers.pollIntervalSecs}s while enabled rules exist (checks run in a pi open in the rule's project)`,
|
|
1576
|
+
` last check: ${triggers.lastPoll ? `${formatLocal(Date.parse(triggers.lastPoll.at))} in ${homeRel(triggers.lastPoll.cwd)} — ${triggers.lastPoll.outcome}` : "none yet"}`,
|
|
1577
|
+
` push trigger sources: ${mcpConfigs.length} configured MCP server(s) feed server-pushed events into the same trigger runtime (deduplicated machine-wide, hop ${hop})${mcpConfigError ? ` (config error: ${mcpConfigError})` : ""}`,
|
|
1578
|
+
` sources: ${mcpSources.length + LOCAL_SOURCES} total, ${mcpSources.filter((s) => s.status.state === "connected").length + (localSourcesConnected() ? LOCAL_SOURCES : 0)} connected, ${mcpSources.filter((s) => s.status.requiresAttention).length} require attention`,
|
|
1579
|
+
` running: ${triggers.runningList().length} · deduped: ${triggers.dedupedCount} · cycle_suppressed: ${triggers.cycleSuppressedCount} · storage: ${homeRel(store.rulesFile)}`,
|
|
1580
|
+
...(store.lastPersistenceError ? [` ! ${store.lastPersistenceError}`] : []),
|
|
1581
|
+
` audit: ${homeRel(store.auditFile)} (/triggers audit [N])`,
|
|
1582
|
+
]);
|
|
1583
|
+
return;
|
|
1584
|
+
}
|
|
1585
|
+
case "rules": {
|
|
1586
|
+
const all = store.load();
|
|
1587
|
+
const here = all.filter((r) => sameProject(r.cwd, session.cwd));
|
|
1588
|
+
const elsewhere = all.length - here.length;
|
|
1589
|
+
const lines = here.length ? ruleLines(here, true) : ["none"];
|
|
1590
|
+
if (elsewhere) lines.push(` + ${elsewhere} rule${elsewhere === 1 ? "" : "s"} in other projects — /triggers rules --all`);
|
|
1591
|
+
if (rest.trim() === "--all") {
|
|
1592
|
+
show(ctx, `Dynamic trigger rules (this machine, ${all.length}):`, all.length ? ruleLines(all, false) : ["none"]);
|
|
1593
|
+
return;
|
|
1594
|
+
}
|
|
1595
|
+
show(ctx, `Dynamic trigger rules (${here.length}):`, lines);
|
|
1596
|
+
return;
|
|
1597
|
+
}
|
|
1598
|
+
case "sources":
|
|
1599
|
+
case "hooks": {
|
|
1600
|
+
// MCP hooks are registered first, then the cron hook, then the dynamic checker.
|
|
1601
|
+
const lines: string[] = [];
|
|
1602
|
+
const localState = localSourcesConnected() ? "connected" : schedulerStarted ? "standby" : "disabled";
|
|
1603
|
+
mcpSources.forEach((source, index) => {
|
|
1604
|
+
const status = source.status;
|
|
1605
|
+
lines.push(` - source #${index + 1}: ${status.state}${status.reason ? ` (${previewRedacted(status.reason, 80)})` : ""} queued=${status.queuedCount} dropped=${status.droppedCount} deduped=${status.dedupedCount} last_event=${status.lastEventAt ?? "never"}${status.requiresAttention ? ` ! ${status.requiresAttention}` : ""}`);
|
|
1606
|
+
lines.push(` subscriptions: ${status.subscriptionLabels.join(", ")}${source.config.injectAndRun ? " [inject_and_run]" : source.config.injectSummary ? " [inject_summary]" : ""} (${source.config.kind}, ${source.config.source}) · tools: ${(mcpToolNames.get(source.config.name) ?? []).length ? (mcpToolNames.get(source.config.name) ?? []).join(", ") : "none"}`);
|
|
1607
|
+
if (status.lastError) lines.push(` last error: ${previewRedacted(status.lastError, 160)}`);
|
|
1608
|
+
if (status.lastStderr) lines.push(` stderr: ${previewRedacted(status.lastStderr, 160)}`);
|
|
1609
|
+
});
|
|
1610
|
+
const jobs = scheduler.store.load();
|
|
1611
|
+
// By instant, not by string. Sorting the stamps themselves was right only while every
|
|
1612
|
+
// one of them ended in `Z`; they carry an offset now, and two spellings of the same
|
|
1613
|
+
// moment do not sort the way the moments do.
|
|
1614
|
+
const lastFired = jobs
|
|
1615
|
+
.map((j) => j.lastFiredAt)
|
|
1616
|
+
.filter((t): t is string => !!t)
|
|
1617
|
+
.sort((a, b) => Date.parse(a) - Date.parse(b))
|
|
1618
|
+
.at(-1);
|
|
1619
|
+
lines.push(` - source #${mcpSources.length + 1}: ${localState} queued=${scheduler.runningCount} dropped=0 deduped=0 last_event=${lastFired ?? "never"}`);
|
|
1620
|
+
lines.push(` subscriptions: ${jobs.length ? `local crontab: ${jobs.length} job(s), ${jobs.filter((j) => j.enabled).length} enabled` : "local crontab: 0 jobs"}`);
|
|
1621
|
+
lines.push(` - source #${mcpSources.length + LOCAL_SOURCES}: ${localState} queued=${triggers.runningList().filter((r) => r.sourceLabel === "local:dynamic").length} dropped=0 deduped=${triggers.dedupedCount} last_event=${triggers.lastPoll?.at ?? "never"}`);
|
|
1622
|
+
lines.push(" subscriptions: dynamic trigger periodic check");
|
|
1623
|
+
if (mcpSources.length) lines.push(" (pushes are deduplicated machine-wide; results go to this chat only for this project's rules, otherwise to /inbox)");
|
|
1624
|
+
if (mcpConfigError) lines.push(` ! ${mcpConfigError}`);
|
|
1625
|
+
show(ctx, `Trigger sources (${LOCAL_SOURCES + mcpSources.length}):`, lines);
|
|
1626
|
+
return;
|
|
1627
|
+
}
|
|
1628
|
+
case "enable":
|
|
1629
|
+
case "resume":
|
|
1630
|
+
case "disable":
|
|
1631
|
+
case "pause": {
|
|
1632
|
+
const enable = sub === "enable" || sub === "resume";
|
|
1633
|
+
const scope = rest.trim();
|
|
1634
|
+
if (scope === "--all" || scope === "--all-projects") {
|
|
1635
|
+
const everywhere = scope === "--all-projects";
|
|
1636
|
+
const targets = store.load().filter((r) => (everywhere || sameProject(r.cwd, session.cwd)) && r.enabled !== enable);
|
|
1637
|
+
for (const r of targets) await store.setEnabled(r.id, enable);
|
|
1638
|
+
refreshBadge();
|
|
1639
|
+
ctx.ui.notify(`${enable ? "enabled" : "disabled"} ${targets.length} rule(s)${everywhere ? " on this machine" : " in this project"}`, "info");
|
|
1640
|
+
return;
|
|
1641
|
+
}
|
|
1642
|
+
const rule = pickRule(rest);
|
|
1643
|
+
if (!rule) return;
|
|
1644
|
+
const updated = (await store.setEnabled(rule.id, enable)) ?? rule;
|
|
1645
|
+
show(ctx, `${enable ? "enabled" : "disabled"} trigger ${updated.id}`, [
|
|
1646
|
+
` condition: ${previewRedacted(updated.condition, 120)}`,
|
|
1647
|
+
` action: ${previewRedacted(updated.action, 120)}`,
|
|
1648
|
+
...(updated.enabled && updated.fireOnce ? [" fire_once: true (will disable again after the next successful match)"] : []),
|
|
1649
|
+
]);
|
|
1650
|
+
return;
|
|
1651
|
+
}
|
|
1652
|
+
case "remove":
|
|
1653
|
+
case "rm":
|
|
1654
|
+
case "delete": {
|
|
1655
|
+
// `--all` means "all of this project's", scoped to the project, not the machine; wiping every
|
|
1656
|
+
// project on the machine needs saying so.
|
|
1657
|
+
if (rest.trim() === "--all" || rest.trim() === "--all-projects") {
|
|
1658
|
+
const everywhere = rest.trim() === "--all-projects";
|
|
1659
|
+
const n = await store.clear(everywhere ? undefined : session.cwd, sameProject);
|
|
1660
|
+
ctx.ui.notify(`removed ${n} dynamic trigger rule(s)${everywhere ? " (every project on this machine)" : ` in ${homeRel(session.cwd)}`}`, "info");
|
|
1661
|
+
return;
|
|
1662
|
+
}
|
|
1663
|
+
const rule = pickRule(rest);
|
|
1664
|
+
if (!rule) return;
|
|
1665
|
+
await store.remove(rule.id);
|
|
1666
|
+
show(ctx, `removed trigger ${rule.id}`, [` condition: ${previewRedacted(rule.condition, 120)}`, ` action: ${previewRedacted(rule.action, 120)}`]);
|
|
1667
|
+
return;
|
|
1668
|
+
}
|
|
1669
|
+
case "set": {
|
|
1670
|
+
const change = parseSetArgs(rest);
|
|
1671
|
+
if (change.name !== undefined) {
|
|
1672
|
+
ctx.ui.notify("rules have no name; /triggers set takes --model, --thinking, --timeout and --host", "warning");
|
|
1673
|
+
return;
|
|
1674
|
+
}
|
|
1675
|
+
const rule = pickRule(change.ref);
|
|
1676
|
+
if (!rule) return;
|
|
1677
|
+
const updated = await store.update(rule.id, (r) => {
|
|
1678
|
+
if (change.model !== undefined) r.model = change.model ?? undefined;
|
|
1679
|
+
if (change.thinking !== undefined) r.thinking = change.thinking ?? undefined;
|
|
1680
|
+
if (change.timeoutMs !== undefined) r.timeoutMs = change.timeoutMs ?? undefined;
|
|
1681
|
+
if (change.host !== undefined) r.host = change.host === "here" ? os.hostname() : undefined;
|
|
1682
|
+
});
|
|
1683
|
+
if (!updated) return;
|
|
1684
|
+
show(ctx, `updated trigger ${updated.id}`, [
|
|
1685
|
+
` model: ${updated.model ?? "(the running session's current model)"}`,
|
|
1686
|
+
` thinking: ${updated.thinking ?? "(the running session's current level)"}`,
|
|
1687
|
+
` timeout: ${updated.timeoutMs ? `${Math.round(updated.timeoutMs / 1000)}s` : `default (${Math.round(triggers.runTimeoutMs / 1000)}s)`}`,
|
|
1688
|
+
` host: ${updated.host ?? "(any machine)"}`,
|
|
1689
|
+
]);
|
|
1690
|
+
return;
|
|
1691
|
+
}
|
|
1692
|
+
case "run": {
|
|
1693
|
+
// "Run now" on a rule. `handle` is the same path the periodic check takes,
|
|
1694
|
+
// so dedup, audit, the sub-agent and promotion all behave as they do on a poll —
|
|
1695
|
+
// what is skipped is only the poll ledger, which is the point of running it now.
|
|
1696
|
+
const rule = pickRule(rest);
|
|
1697
|
+
if (!rule) return;
|
|
1698
|
+
if (!schedulerStarted) {
|
|
1699
|
+
ctx.ui.notify("the trigger runtime is not running in this session", "warning");
|
|
1700
|
+
return;
|
|
1701
|
+
}
|
|
1702
|
+
if (!rule.enabled) {
|
|
1703
|
+
ctx.ui.notify(`trigger ${rule.id} is disabled — /triggers enable ${rule.id} first`, "warning");
|
|
1704
|
+
return;
|
|
1705
|
+
}
|
|
1706
|
+
// An id resolves against the machine-wide store, and this is the one rule command
|
|
1707
|
+
// that *runs* something: a sub-agent, in that rule's project, with that project's
|
|
1708
|
+
// tools. Enabling or renaming another project's rule from here is one thing;
|
|
1709
|
+
// starting work there from a session that never listed it is another.
|
|
1710
|
+
if (!sameProject(rule.cwd, session.cwd)) {
|
|
1711
|
+
ctx.ui.notify(`trigger ${rule.id} belongs to ${rule.cwd} — run it from a pi open in that project`, "warning");
|
|
1712
|
+
return;
|
|
1713
|
+
}
|
|
1714
|
+
const trigger = buildPeriodicCheckTrigger(rule.cwd, 1, new Date(), `${process.pid}-run-now`);
|
|
1715
|
+
// Never awaited: a check runs a sub-agent and can take minutes. `handle` is
|
|
1716
|
+
// documented not to reject, and the catch is there because pi installs no
|
|
1717
|
+
// unhandledRejection handler.
|
|
1718
|
+
void triggers.handle(trigger, "sub_agent", [rule]).catch((err: any) => log.warn(`run-now failed: ${err?.message ?? err}`));
|
|
1719
|
+
show(ctx, `checking trigger ${rule.id} now (trace ${trigger.traceId.slice(0, 8)})`, [
|
|
1720
|
+
` condition: ${previewRedacted(rule.condition, 120)}`,
|
|
1721
|
+
" the result appears here when the check finishes — /triggers running shows it meanwhile",
|
|
1722
|
+
]);
|
|
1723
|
+
return;
|
|
1724
|
+
}
|
|
1725
|
+
case "running": {
|
|
1726
|
+
const now = Date.now();
|
|
1727
|
+
const running = [
|
|
1728
|
+
...triggers.runningList().map((r) => ({ traceId: r.traceId, sourceLabel: r.sourceLabel, eventLabel: r.eventLabel, startedAt: r.startedAt, promptPreview: r.promptPreview, sessionFile: undefined as string | undefined })),
|
|
1729
|
+
...scheduler.runningRuns().map((r) => ({ traceId: r.runId, sourceLabel: "Cron", eventLabel: r.jobId, startedAt: r.startedAt, promptPreview: r.promptPreview, sessionFile: r.sessionFile })),
|
|
1730
|
+
];
|
|
1731
|
+
show(
|
|
1732
|
+
ctx,
|
|
1733
|
+
// The slot count is the whole machine's, not this list's: a run started by another
|
|
1734
|
+
// project's job holds one too, and "why is nothing starting" is answered by the
|
|
1735
|
+
// number, not by the rows.
|
|
1736
|
+
`${running.length ? `Running triggers (${running.length}):` : "(no running triggers)"} ${triggers.slots.inUseCount} of ${triggers.slots.limit} sub-agent slot(s) in use`,
|
|
1737
|
+
running.flatMap((r) => {
|
|
1738
|
+
const secs = Math.max(0, Math.round((now - Date.parse(r.startedAt)) / 1000));
|
|
1739
|
+
const elapsed = secs < 90 ? `${secs}s` : `${Math.round(secs / 60)}m`;
|
|
1740
|
+
return [
|
|
1741
|
+
` - ${r.traceId} ${r.sourceLabel} / ${r.eventLabel} running ${elapsed} (since ${formatLocal(Date.parse(r.startedAt))})`,
|
|
1742
|
+
` prompt: ${previewRedacted(r.promptPreview, 120)}`,
|
|
1743
|
+
// The transcript is already on disk: "is it stuck or is it working" is
|
|
1744
|
+
// answerable now rather than only after the run ends.
|
|
1745
|
+
...(r.sessionFile ? [` watch: pi --session ${r.sessionFile}`] : []),
|
|
1746
|
+
];
|
|
1747
|
+
}),
|
|
1748
|
+
);
|
|
1749
|
+
return;
|
|
1750
|
+
}
|
|
1751
|
+
case "audit": {
|
|
1752
|
+
const all = /\s--all\b|^--all\b/.test(rest);
|
|
1753
|
+
const limit = Number.parseInt(rest.replace("--all", ""), 10) || 10;
|
|
1754
|
+
// This project's rows by default (rows without a cwd predate 0.1.3 and are shown too).
|
|
1755
|
+
const rows = store.listAudit(limit, all ? undefined : (r) => !r.cwd || sameProject(r.cwd, session.cwd));
|
|
1756
|
+
show(
|
|
1757
|
+
ctx,
|
|
1758
|
+
rows.length ? `Recent trigger audit (${rows.length}):` : "(no trigger audit entries)",
|
|
1759
|
+
rows.flatMap((r) => {
|
|
1760
|
+
const lines = [` - ${r.ts} ${r.type}/${r.state} trace=${r.traceId.slice(0, 8)} ${r.sourceLabel ?? "-"} / ${r.eventLabel ?? "-"}`];
|
|
1761
|
+
if (r.summary) lines.push(` ${previewRedacted(r.summary, 160)}`);
|
|
1762
|
+
const details = r.details as any;
|
|
1763
|
+
if (details?.evaluator_decision?.outcome) lines.push(` decision: ${details.evaluator_decision.outcome}${details.evaluator_decision.permission ? `, permission: ${details.evaluator_decision.permission}` : ""}`);
|
|
1764
|
+
if (details?.previous_trace_id) lines.push(` previous_trace_id: ${String(details.previous_trace_id).slice(0, 8)}`);
|
|
1765
|
+
if (Array.isArray(details?.matched_rule_ids) && details.matched_rule_ids.length) lines.push(` matched: ${details.matched_rule_ids.join(", ")}`);
|
|
1766
|
+
if (details?.session_file) lines.push(` transcript: pi --session ${details.session_file}`);
|
|
1767
|
+
return lines;
|
|
1768
|
+
}),
|
|
1769
|
+
);
|
|
1770
|
+
return;
|
|
1771
|
+
}
|
|
1772
|
+
case "abort": {
|
|
1773
|
+
if (rest.trim() === "--all") {
|
|
1774
|
+
let n = triggers.abortAll();
|
|
1775
|
+
for (const r of scheduler.runningRuns()) if (scheduler.abortRun(r.runId)) n++;
|
|
1776
|
+
ctx.ui.notify(`requested abort for ${n} running trigger(s)`, "info");
|
|
1777
|
+
return;
|
|
1778
|
+
}
|
|
1779
|
+
const target = rest.trim();
|
|
1780
|
+
const hit = triggers.runningList().find((r) => r.traceId === target || r.traceId.startsWith(target));
|
|
1781
|
+
const cronHit = scheduler.runningRuns().find((r) => r.runId === target || r.runId.startsWith(target));
|
|
1782
|
+
if (!target || (!hit && !cronHit)) {
|
|
1783
|
+
ctx.ui.notify(target ? `no running trigger with trace_id '${target}'` : "usage: /triggers abort <trace_id>|--all", "warning");
|
|
1784
|
+
return;
|
|
1785
|
+
}
|
|
1786
|
+
if (hit) triggers.abort(hit.traceId);
|
|
1787
|
+
if (cronHit) scheduler.abortRun(cronHit.runId);
|
|
1788
|
+
ctx.ui.notify(`requested abort for trigger ${hit?.traceId ?? cronHit?.runId}`, "info");
|
|
1789
|
+
return;
|
|
1790
|
+
}
|
|
1791
|
+
case "panel": {
|
|
1792
|
+
const on = rest.trim() === "on" ? true : rest.trim() === "off" ? false : !panelOn;
|
|
1793
|
+
setPanelEnabled(on);
|
|
1794
|
+
ctx.ui.notify(`panel ${on ? "on" : "off"}`, "info");
|
|
1795
|
+
return;
|
|
1796
|
+
}
|
|
1797
|
+
case "help":
|
|
1798
|
+
show(ctx, "/triggers", TRIGGERS_HELP);
|
|
1799
|
+
return;
|
|
1800
|
+
default:
|
|
1801
|
+
ctx.ui.notify(`unknown /triggers command: ${sub}. usage: /triggers ${TRIGGERS_USAGE}`, "warning");
|
|
1802
|
+
}
|
|
1803
|
+
} catch (err: any) {
|
|
1804
|
+
ctx.ui.notify(`triggers: ${err?.message ?? err}`, "error");
|
|
1805
|
+
}
|
|
1806
|
+
},
|
|
1807
|
+
});
|
|
1808
|
+
|
|
1809
|
+
/* ------------------------------------------------ session archives */
|
|
1810
|
+
|
|
1811
|
+
|
|
1812
|
+
const ARCHIVE_WARNING = `warning: ${ARCHIVE_EXT} archives include transcript and tool history. They do not include separate auth stores, provider credentials, OAuth tokens, MCP config, or the inbox.`;
|
|
1813
|
+
|
|
1814
|
+
pi.registerCommand("pi-loops", {
|
|
1815
|
+
description: "About this extension, and `install-launcher` to put the `pi-loops` command on your PATH",
|
|
1816
|
+
handler: async (args, ctx) => {
|
|
1817
|
+
lastCtx = ctx;
|
|
1818
|
+
const sub = args.trim().split(/\s+/)[0] ?? "";
|
|
1819
|
+
if (sub === "install-launcher") {
|
|
1820
|
+
// The command line's own `install-launcher` cannot be reached until it has run once —
|
|
1821
|
+
// `pi install` does not put the package on your PATH. You are already inside pi with
|
|
1822
|
+
// this extension loaded, so here is where that circle can be broken.
|
|
1823
|
+
const lines: string[] = [];
|
|
1824
|
+
const target = path.join(os.homedir(), ".local", "bin");
|
|
1825
|
+
const ok = await ctx.ui.confirm("Put `pi-loops` on your PATH?", [`This writes a launcher into ${target} (or another directory on your PATH).`, "", "It is a two-line shell script that runs this package with this node.", "Afterwards `pi-loops` starts a session from any directory."].join("\n"));
|
|
1826
|
+
if (!ok) {
|
|
1827
|
+
ctx.ui.notify("not installed", "info");
|
|
1828
|
+
return;
|
|
1829
|
+
}
|
|
1830
|
+
const code = await installLauncher(undefined, (l) => lines.push(l));
|
|
1831
|
+
show(ctx, code === 0 ? "installed the pi-loops launcher" : "could not install the launcher", lines.map((l) => ` ${l.trim()}`));
|
|
1832
|
+
return;
|
|
1833
|
+
}
|
|
1834
|
+
show(ctx, `pi-loops ${PI_LOOPS_VERSION}`, [
|
|
1835
|
+
` loops directory: ${homeRel(dir)}`,
|
|
1836
|
+
` package: ${homeRel(PACKAGE_DIR)}`,
|
|
1837
|
+
"",
|
|
1838
|
+
" /pi-loops install-launcher put the `pi-loops` command on your PATH",
|
|
1839
|
+
" /cron · /triggers · /inbox · /goal the automation itself",
|
|
1840
|
+
]);
|
|
1841
|
+
},
|
|
1842
|
+
});
|
|
1843
|
+
|
|
1844
|
+
// Not `share`: pi has a built-in `/share` of its own, and an extension command with a built-in's
|
|
1845
|
+
// name is dropped from autocomplete and shadowed at the prompt. The name follows the two
|
|
1846
|
+
// commands next to it (`/session-export`, `/session-import`), which are about the same object.
|
|
1847
|
+
pi.registerCommand("session-share", {
|
|
1848
|
+
description: "Upload this session's transcript as a private GitHub gist via `gh`, redacted and shown to you first",
|
|
1849
|
+
handler: async (args, ctx) => {
|
|
1850
|
+
lastCtx = ctx;
|
|
1851
|
+
const parts = args.split(/\s+/).filter(Boolean);
|
|
1852
|
+
const isPublic = parts.includes("--public");
|
|
1853
|
+
const unknown = parts.filter((p) => p !== "--public");
|
|
1854
|
+
if (unknown.length) {
|
|
1855
|
+
ctx.ui.notify("usage: /session-share [--public]", "warning");
|
|
1856
|
+
return;
|
|
1857
|
+
}
|
|
1858
|
+
let messages: ShareMessage[];
|
|
1859
|
+
try {
|
|
1860
|
+
messages = (ctx.sessionManager.buildContextEntries() as Array<{ message?: ShareMessage }>).map((e) => e.message).filter((m): m is ShareMessage => !!m);
|
|
1861
|
+
} catch (err: any) {
|
|
1862
|
+
ctx.ui.notify(`share: cannot read this session: ${err?.message ?? err}`, "error");
|
|
1863
|
+
return;
|
|
1864
|
+
}
|
|
1865
|
+
if (!messages.length) {
|
|
1866
|
+
ctx.ui.notify("nothing to share yet — this session has no messages", "warning");
|
|
1867
|
+
return;
|
|
1868
|
+
}
|
|
1869
|
+
const rendered = renderShare(messages, { model: ctx.model ? `${ctx.model.provider}/${ctx.model.id}` : undefined, sessionId: ctx.sessionManager.getSessionId?.() ?? undefined });
|
|
1870
|
+
|
|
1871
|
+
// The file lands locally first, so "what did I just publish" is answerable afterwards and
|
|
1872
|
+
// answerable *before*: the confirmation names a file that is already there to read.
|
|
1873
|
+
// Resolved, because `PI_LOOPS_DIR` is whatever the environment says and `gh` would read a
|
|
1874
|
+
// path that begins with `-` as a flag rather than as the file to upload.
|
|
1875
|
+
const outDir = path.resolve(dir, "shares");
|
|
1876
|
+
const outFile = path.join(outDir, `session-${new Date().toISOString().replace(/[:.]/g, "-")}.md`);
|
|
1877
|
+
try {
|
|
1878
|
+
fs.mkdirSync(outDir, { recursive: true, mode: 0o700 });
|
|
1879
|
+
// The loops directory is not necessarily 0700 (only the host tightens it), so `shares`
|
|
1880
|
+
// may already exist as someone else's directory or as a symlink into one — and mkdir's
|
|
1881
|
+
// mode does not apply to a directory that is already there.
|
|
1882
|
+
fs.chmodSync(outDir, 0o700);
|
|
1883
|
+
const st = fs.lstatSync(outDir);
|
|
1884
|
+
if (!st.isDirectory() || st.uid !== (process.getuid?.() ?? st.uid)) throw new Error("shares/ is not a directory this user owns");
|
|
1885
|
+
// "wx": create, never follow or truncate something that is already at that path.
|
|
1886
|
+
fs.writeFileSync(outFile, rendered.markdown, { mode: 0o600, flag: "wx" });
|
|
1887
|
+
} catch (err: any) {
|
|
1888
|
+
ctx.ui.notify(`share: could not write ${outFile}: ${err?.message ?? err}`, "error");
|
|
1889
|
+
return;
|
|
1890
|
+
}
|
|
1891
|
+
show(ctx, `Ready to upload ${path.basename(outFile)}`, [...shareSummary(rendered, { public: isPublic }), ` local copy: ${outFile}`]);
|
|
1892
|
+
|
|
1893
|
+
const ok = await ctx.ui.confirm(isPublic ? "Upload this transcript PUBLICLY?" : "Upload this transcript to a gist?", [...shareSummary(rendered, { public: isPublic }), "", `Read it first: ${outFile}`, "", "gh gist create runs as you, with your GitHub account."].join("\n"));
|
|
1894
|
+
if (!ok) {
|
|
1895
|
+
ctx.ui.notify(`not uploaded — the rendered transcript is at ${outFile}`, "info");
|
|
1896
|
+
return;
|
|
1897
|
+
}
|
|
1898
|
+
|
|
1899
|
+
// Shelling out to `gh` is what keeps this honest: the credential is already there and
|
|
1900
|
+
// pi-loops never has to hold one.
|
|
1901
|
+
// `--` so the filename is a filename even if a future path could look like a flag.
|
|
1902
|
+
const gh = await pi.exec("gh", ["gist", "create", ...(isPublic ? ["--public"] : []), "--desc", `pi session ${ctx.sessionManager.getSessionId?.() ?? ""}`.trim(), "--", outFile]).catch((err: any) => ({ code: -1, stdout: "", stderr: err?.message ?? String(err) }) as any);
|
|
1903
|
+
const stdout = String(gh.stdout ?? "").trim();
|
|
1904
|
+
const stderr = previewRedacted(String(gh.stderr ?? "").trim(), 300);
|
|
1905
|
+
if (gh.code !== 0) {
|
|
1906
|
+
const missing = /ENOENT|not found/i.test(stderr);
|
|
1907
|
+
ctx.ui.notify(missing ? "share needs the GitHub CLI: install `gh` and run `gh auth login`" : `gh gist create failed (${gh.code}): ${stderr}`, "error");
|
|
1908
|
+
return;
|
|
1909
|
+
}
|
|
1910
|
+
const url = stdout.split(/\s+/).find((t) => t.startsWith("https://")) ?? stdout;
|
|
1911
|
+
show(ctx, `shared: ${url}`, [isPublic ? " public gist" : " secret gist — unlisted, but anyone with the link can read it", ` local copy: ${outFile}`, " delete it with: gh gist delete <id>"]);
|
|
1912
|
+
},
|
|
1913
|
+
});
|
|
1914
|
+
|
|
1915
|
+
pi.registerCommand("session-export", {
|
|
1916
|
+
description: `Export this session + its cron jobs, trigger rules and loop state to a ${ARCHIVE_EXT} archive`,
|
|
1917
|
+
handler: async (args, ctx) => {
|
|
1918
|
+
lastCtx = ctx;
|
|
1919
|
+
const parts = args.split(/\s+/).filter(Boolean);
|
|
1920
|
+
const excludeTriggers = parts.includes("--exclude-triggers");
|
|
1921
|
+
const pathArgs = parts.filter((p) => !p.startsWith("--"));
|
|
1922
|
+
if (pathArgs.length > 1) {
|
|
1923
|
+
ctx.ui.notify("usage: /session-export [path] [--exclude-triggers]", "warning");
|
|
1924
|
+
return;
|
|
1925
|
+
}
|
|
1926
|
+
const sessionFile = ctx.sessionManager.getSessionFile();
|
|
1927
|
+
if (!sessionFile) {
|
|
1928
|
+
ctx.ui.notify("this session is ephemeral (--no-session); nothing to export", "warning");
|
|
1929
|
+
return;
|
|
1930
|
+
}
|
|
1931
|
+
const sessionId = ctx.sessionManager.getSessionId();
|
|
1932
|
+
const outputPath = path.resolve(session.cwd, pathArgs[0] ?? defaultExportPath(session.cwd, sessionId));
|
|
1933
|
+
ctx.ui.notify(ARCHIVE_WARNING, "warning"); // Printed before the attempt, success or not
|
|
1934
|
+
try {
|
|
1935
|
+
// The store is machine-global, so the session that
|
|
1936
|
+
// created a job or rule is what scopes the archive; jobs from before `createdBy`
|
|
1937
|
+
// existed fall back to the project.
|
|
1938
|
+
const mine = (owner: { sessionId?: string } | undefined, cwd: string) => (owner?.sessionId ? owner.sessionId === sessionId : sameProject(cwd, session.cwd));
|
|
1939
|
+
const jobs = scheduler.store.load().filter((j) => sameProject(j.cwd, session.cwd) && mine(j.createdBy, j.cwd));
|
|
1940
|
+
const rules = triggers.store.load().filter((r) => sameProject(r.cwd, session.cwd) && mine(r.createdBy, r.cwd));
|
|
1941
|
+
const states: Record<string, string> = {};
|
|
1942
|
+
for (const job of jobs) {
|
|
1943
|
+
const loopState = job.stateful ? scheduler.store.readState(job.id) : undefined;
|
|
1944
|
+
if (loopState) states[job.id] = loopState;
|
|
1945
|
+
}
|
|
1946
|
+
const summary = exportSession({ sessionFile, cwd: session.cwd, jobs, rules, states, excludeTriggers, outputPath, piVersion: PI_VERSION, piLoopsVersion: PI_LOOPS_VERSION });
|
|
1947
|
+
show(ctx, `exported session archive: ${homeRel(summary.outputPath)}`, [
|
|
1948
|
+
`session ${summary.sessionId.slice(0, 16)} entries=${summary.entryCount} triggers=${summary.hasTriggers ? "yes" : "no"} cron=${summary.hasCron ? "yes" : "no"} loop_state=${summary.loopStateCount}`,
|
|
1949
|
+
]);
|
|
1950
|
+
} catch (err: any) {
|
|
1951
|
+
ctx.ui.notify(`session export failed: ${err?.code === "EEXIST" ? `${homeRel(outputPath)} already exists (refusing to overwrite)` : (err?.message ?? err)}`, "error");
|
|
1952
|
+
}
|
|
1953
|
+
},
|
|
1954
|
+
});
|
|
1955
|
+
|
|
1956
|
+
pi.registerCommand("session-import", {
|
|
1957
|
+
description: `Import a ${ARCHIVE_EXT} archive: new session file here, cron jobs / trigger rules / loop state restored`,
|
|
1958
|
+
handler: async (args, ctx) => {
|
|
1959
|
+
lastCtx = ctx;
|
|
1960
|
+
const parts = args.split(/\s+/).filter(Boolean);
|
|
1961
|
+
let activate = false;
|
|
1962
|
+
let resume = false;
|
|
1963
|
+
let targetCwd = session.cwd;
|
|
1964
|
+
const positional: string[] = [];
|
|
1965
|
+
for (let i = 0; i < parts.length; i++) {
|
|
1966
|
+
const arg = parts[i];
|
|
1967
|
+
if (arg === "--resume") resume = true;
|
|
1968
|
+
else if (arg.startsWith("--activate-triggers=")) {
|
|
1969
|
+
const value = arg.slice("--activate-triggers=".length);
|
|
1970
|
+
if (value === "on") activate = true;
|
|
1971
|
+
else if (value === "off") activate = false;
|
|
1972
|
+
else {
|
|
1973
|
+
ctx.ui.notify(`--activate-triggers=${value} is not supported (use on|off)`, "warning");
|
|
1974
|
+
return;
|
|
1975
|
+
}
|
|
1976
|
+
} else if (arg === "--cwd") targetCwd = path.resolve(session.cwd, parts[++i] ?? ".");
|
|
1977
|
+
else positional.push(arg);
|
|
1978
|
+
}
|
|
1979
|
+
if (positional.length !== 1) {
|
|
1980
|
+
ctx.ui.notify("usage: /session-import <path> [--activate-triggers=on|off] [--cwd <dir>] [--resume]", "warning");
|
|
1981
|
+
return;
|
|
1982
|
+
}
|
|
1983
|
+
const archivePath = path.resolve(session.cwd, positional[0]);
|
|
1984
|
+
ctx.ui.notify(ARCHIVE_WARNING, "warning");
|
|
1985
|
+
try {
|
|
1986
|
+
const store = scheduler.store;
|
|
1987
|
+
const imp = importSession({
|
|
1988
|
+
archivePath,
|
|
1989
|
+
sessionDir: ctx.sessionManager.getSessionDir(),
|
|
1990
|
+
targetCwd,
|
|
1991
|
+
activate,
|
|
1992
|
+
existingJobs: store.load(),
|
|
1993
|
+
existingRules: triggers.store.load(),
|
|
1994
|
+
existingJobIds: new Set(store.load().map((j) => j.id)),
|
|
1995
|
+
existingRuleIds: new Set(triggers.store.load().map((r) => r.id)),
|
|
1996
|
+
});
|
|
1997
|
+
try {
|
|
1998
|
+
if (imp.jobs.length) await store.mutate((jobs) => ({ jobs: [...jobs, ...imp.jobs], result: undefined }));
|
|
1999
|
+
for (const [id, text] of Object.entries(imp.states)) store.writeState(id, text);
|
|
2000
|
+
if (imp.rules.length) await triggers.store.mutate((rules) => rules.push(...imp.rules));
|
|
2001
|
+
} catch (err) {
|
|
2002
|
+
// Roll back, best effort and in this order: a half-imported archive must leave neither
|
|
2003
|
+
// an orphan session nor a partial store, and the original error is what gets reported.
|
|
2004
|
+
const ids = new Set(imp.jobs.map((j) => j.id));
|
|
2005
|
+
const attempt = (fn: () => void) => {
|
|
2006
|
+
try {
|
|
2007
|
+
fn();
|
|
2008
|
+
} catch {
|
|
2009
|
+
/* best effort */
|
|
2010
|
+
}
|
|
2011
|
+
};
|
|
2012
|
+
attempt(() => fs.rmSync(imp.sessionPath, { force: true }));
|
|
2013
|
+
await store.mutate((jobs) => ({ jobs: jobs.filter((j) => !ids.has(j.id)), result: undefined })).catch(() => {});
|
|
2014
|
+
for (const id of ids) attempt(() => fs.rmSync(store.statePath(id), { force: true }));
|
|
2015
|
+
throw err;
|
|
2016
|
+
}
|
|
2017
|
+
for (const job of imp.jobs) cronControlAudit("add", "slash", undefined, job);
|
|
2018
|
+
refreshBadge();
|
|
2019
|
+
const skipped = (imp.skippedJobs ?? 0) + (imp.skippedRules ?? 0);
|
|
2020
|
+
show(ctx, `imported session: ${imp.sessionId.slice(0, 16)}`, [
|
|
2021
|
+
`path: ${homeRel(imp.sessionPath)}`,
|
|
2022
|
+
`entries=${imp.entryCount} triggers=${imp.rules.length} cron=${imp.jobs.length} loop_state=${Object.keys(imp.states).length} automation=${imp.automationEnabled ? "enabled" : "disabled"}${skipped ? ` skipped=${skipped} (already imported)` : ""}`,
|
|
2023
|
+
...(imp.notes ?? []).map((n: string) => `note: ${n}`),
|
|
2024
|
+
`resume with: pi --session ${imp.sessionPath}`,
|
|
2025
|
+
]);
|
|
2026
|
+
// Offer to switch originally-enabled automation back on.
|
|
2027
|
+
const pending = imp.originallyEnabledJobs.length + imp.originallyEnabledRules.length;
|
|
2028
|
+
if (!activate && pending && ctx.hasUI) {
|
|
2029
|
+
const ok = await ctx.ui.confirm("Enable imported automation?", `${imp.originallyEnabledJobs.length} cron job(s) and ${imp.originallyEnabledRules.length} trigger rule(s) were enabled in the source session. Enable them here now?`);
|
|
2030
|
+
if (ok) {
|
|
2031
|
+
for (const id of imp.originallyEnabledJobs) {
|
|
2032
|
+
const before = store.load().find((j) => j.id === id);
|
|
2033
|
+
const after = await store.update(id, (j) => {
|
|
2034
|
+
j.enabled = true;
|
|
2035
|
+
});
|
|
2036
|
+
if (before && after) cronControlAudit("enable", "slash", before, after);
|
|
2037
|
+
}
|
|
2038
|
+
for (const id of imp.originallyEnabledRules) await triggers.store.setEnabled(id, true);
|
|
2039
|
+
refreshBadge();
|
|
2040
|
+
ctx.ui.notify(`enabled ${imp.originallyEnabledJobs.length} cron job(s) and ${imp.originallyEnabledRules.length} trigger rule(s)`, "info");
|
|
2041
|
+
}
|
|
2042
|
+
}
|
|
2043
|
+
if (resume) await ctx.switchSession(imp.sessionPath);
|
|
2044
|
+
} catch (err: any) {
|
|
2045
|
+
ctx.ui.notify(`session import failed: ${err?.message ?? err}`, "error");
|
|
2046
|
+
}
|
|
2047
|
+
},
|
|
2048
|
+
});
|
|
2049
|
+
|
|
2050
|
+
pi.registerCommand("new-trigger", {
|
|
2051
|
+
description: "Create a dynamic natural-language trigger rule — /new-trigger <request>",
|
|
2052
|
+
handler: async (args, ctx) => {
|
|
2053
|
+
lastCtx = ctx;
|
|
2054
|
+
if (!args.trim()) {
|
|
2055
|
+
ctx.ui.notify("usage: /new-trigger <natural-language trigger request>", "warning");
|
|
2056
|
+
return;
|
|
2057
|
+
}
|
|
2058
|
+
const prompt =
|
|
2059
|
+
"The user asked pi to create a dynamic trigger. Extract the trigger condition and action from the request, then call new_trigger with structured condition and action fields. Dynamic triggers fire once by default; set fire_once=false only when the user explicitly asks for a repeating trigger. Trigger output is shown in the TUI and audit by default; set promote_to_chat=true only when the user explicitly asks for trigger results to enter the main chat context or be visible to future turns. Do not require a fixed syntax. If either the condition or action is missing, ask one concise clarification question instead of calling tools.\n\nUser request:\n" +
|
|
2060
|
+
args.trim();
|
|
2061
|
+
pi.sendUserMessage(prompt, ctx.isIdle() ? undefined : { deliverAs: "followUp" });
|
|
2062
|
+
},
|
|
2063
|
+
});
|
|
2064
|
+
|
|
2065
|
+
/* ------------------------------------------------------------ tools */
|
|
2066
|
+
|
|
2067
|
+
/**
|
|
2068
|
+
* The same project, whichever path this pi was opened through: a worktree, a symlink or a
|
|
2069
|
+
* subdirectory of the project root all belong to the rule or job that names the root.
|
|
2070
|
+
*/
|
|
2071
|
+
const sameProject = (a: string, b: string): boolean => withinProject(b, a) || withinProject(a, b);
|
|
2072
|
+
|
|
2073
|
+
/**
|
|
2074
|
+
* Where a diagnostic goes: the log file always, and the chat as a warning — pi's `showStatus`
|
|
2075
|
+
* replaces the previous status line in place, so several info-level notices in one tick collapse
|
|
2076
|
+
* to the last one. These are the lines that say a job was disabled or a write failed.
|
|
2077
|
+
*/
|
|
2078
|
+
function diagnostic(message: string, level?: "info" | "warning"): void {
|
|
2079
|
+
// A caller that does not say otherwise is reporting something wrong: a disabled job, a failed
|
|
2080
|
+
// write, a paused budget — the lines pi's in-place status replacement used to swallow. The
|
|
2081
|
+
// routine ones (the scheduler taking the timer over) say `"info"` themselves, which they used
|
|
2082
|
+
// to be told by a regex over their own wording here.
|
|
2083
|
+
const at = level ?? "warning";
|
|
2084
|
+
log.write(at === "warning" ? "warn" : "info", message);
|
|
2085
|
+
if (lastCtx?.hasUI) lastCtx.ui.notify(`[cron] ${message}`, at);
|
|
2086
|
+
else if (at === "warning") process.stderr.write(`[pi-loops] ${message}\n`);
|
|
2087
|
+
}
|
|
2088
|
+
|
|
2089
|
+
/** The tools' view of this extension (see tools.ts); sub-sessions and the headless host get their own. */
|
|
2090
|
+
const toolHost: ToolHost = {
|
|
2091
|
+
scheduler,
|
|
2092
|
+
triggers,
|
|
2093
|
+
session: () => session,
|
|
2094
|
+
createJob: (input, scope) => createLoopJob(toolHost, input, scope),
|
|
2095
|
+
cronControlAudit,
|
|
2096
|
+
confirmTool,
|
|
2097
|
+
refreshBadge,
|
|
2098
|
+
};
|
|
2099
|
+
/** The same host, bound to one sub-agent run: its project and its model, not this chat's. */
|
|
2100
|
+
function toolHostFor(req: Pick<SubagentRequest, "cwd" | "model" | "thinking">): ToolHost {
|
|
2101
|
+
const scoped: ToolHost = {
|
|
2102
|
+
...toolHost,
|
|
2103
|
+
session: () => ({ ...session, cwd: req.cwd, model: req.model ?? session.model, thinking: req.thinking ?? session.thinking }),
|
|
2104
|
+
createJob: (input, s) => createLoopJob(scoped, input, s),
|
|
2105
|
+
};
|
|
2106
|
+
return scoped;
|
|
2107
|
+
}
|
|
2108
|
+
/**
|
|
2109
|
+
* Trigger creation/removal and trigger/cron enable need a person's yes: pi has no permission
|
|
2110
|
+
* popups, so the tool asks through `ctx.ui.confirm` itself. A sub-agent has no UI, so
|
|
2111
|
+
* `controlPlanePreflight` denies fail-closed (`ToolCallEventResult`).
|
|
2112
|
+
*/
|
|
2113
|
+
async function confirmTool(ctx: ExtensionContext, req: ControlPlaneRequest, atHop: number): Promise<string | undefined> {
|
|
2114
|
+
const denied = controlPlanePreflight({ hop: atHop, hasUI: ctx.hasUI }, req.label);
|
|
2115
|
+
if (denied) return denied;
|
|
2116
|
+
// The approval card: Action / Tool / Reason / Args hash / Preview, then a feed line per decision.
|
|
2117
|
+
const argsHash = createHash("sha256").update(JSON.stringify(req.args ?? null)).digest("hex").slice(0, 12);
|
|
2118
|
+
ctx.ui.notify(`approval required: ${req.label}`, "info");
|
|
2119
|
+
const ok = await ctx.ui.confirm("Control-plane approval required", [`Action: ${req.label}`, `Tool: ${req.tool}`, `Reason: ${req.reason}`, `Args hash: ${argsHash}`, `Preview: ${previewRedacted(req.preview, 200)}`].join("\n"));
|
|
2120
|
+
ctx.ui.notify(ok ? `approved control-plane action: ${req.label}` : `denied control-plane action: ${req.label} (denied by user)`, "info");
|
|
2121
|
+
return ok ? undefined : `user declined: ${req.label}`;
|
|
2122
|
+
}
|
|
2123
|
+
for (const def of automationTools({ hop, actor: "tool" }, toolHost)) pi.registerTool(def);
|
|
2124
|
+
|
|
2125
|
+
/* -------------------------------------------------------- lifecycle */
|
|
2126
|
+
|
|
2127
|
+
pi.on("session_start", async (_event, ctx) => {
|
|
2128
|
+
lastCtx = ctx;
|
|
2129
|
+
if (startHandled) return; // the same start, delivered a second time by a second rebind
|
|
2130
|
+
startHandled = true;
|
|
2131
|
+
session = sessionSnapshot(ctx);
|
|
2132
|
+
config = loadConfig(dir);
|
|
2133
|
+
// The goal lives in the session, so `--resume` picks up where it left off.
|
|
2134
|
+
goal = latestGoal(ctx.sessionManager.getEntries() as any);
|
|
2135
|
+
if (goal && ctx.hasUI) ctx.ui.setStatus(GOAL_STATUS_KEY, `goal: ${goal.status}`);
|
|
2136
|
+
const flagRaw = pi.getFlag("trigger-poll-secs");
|
|
2137
|
+
const flagSecs = Number(flagRaw);
|
|
2138
|
+
if (flagRaw !== undefined && flagRaw !== "" && !(Number.isFinite(flagSecs) && flagSecs >= 1)) config.errors.push(`triggers: ignoring invalid --trigger-poll-secs ${JSON.stringify(flagRaw)}: must be a whole number of seconds ≥ 1`);
|
|
2139
|
+
triggers.pollIntervalSecs = Number.isFinite(flagSecs) && flagSecs >= 1 ? Math.floor(flagSecs) : config.triggerPollIntervalSecs;
|
|
2140
|
+
triggers.runTimeoutMs = config.triggerRunTimeoutMs;
|
|
2141
|
+
// Without a UI, hook failures go to stderr instead of vanishing.
|
|
2142
|
+
const warnHook = (message: string) => (ctx.hasUI ? ctx.ui.notify(`[hooks] ${message}`, "warning") : process.stderr.write(`[pi-loops hooks] ${message}\n`));
|
|
2143
|
+
hookRunner = new HookRunner({ loopsDir: dir, projectCwd: ctx.cwd, allowProjectHooks: config.allowProjectHooks, getSession: () => session, warn: warnHook, log: (msg) => log.info(msg) });
|
|
2144
|
+
hookRunner.load();
|
|
2145
|
+
for (const e of [...config.errors, ...hookRunner.diagnostics]) if (ctx.hasUI) ctx.ui.notify(`[pi-loops] ${e}`, "warning");
|
|
2146
|
+
loadMcpConfig(ctx.isProjectTrusted());
|
|
2147
|
+
for (const d of mcpDiagnostics) if (ctx.hasUI) ctx.ui.notify(`[pi-loops] ${d}`, "warning");
|
|
2148
|
+
await startMcpSources(); // tools for every process; pushes are consumed by interactive processes only
|
|
2149
|
+
// tui and rpc processes stay alive and host the timer; `PI_LOOPS_HOST=1` lets a `pi -p`
|
|
2150
|
+
// run (a long headless prompt) host it too.
|
|
2151
|
+
const hostMode = ctx.mode === "tui" || ctx.mode === "rpc" || envFlag("LOOPS_HOST");
|
|
2152
|
+
if (!hostMode) return;
|
|
2153
|
+
// Take the clock back from the headless host, if one kept it while nothing was open; a record
|
|
2154
|
+
// with no process behind it means the host died (it removes its record on a clean exit).
|
|
2155
|
+
const dead = crashedHost(dir);
|
|
2156
|
+
if (dead && ctx.hasUI) ctx.ui.notify(`[cron] the background host (pid ${dead.pid}, started ${formatLocal(Date.parse(dead.startedAt))}) died; see ${homeRel(path.join(dir, HOST_LOG))}`, "warning");
|
|
2157
|
+
const hostPid = stopHost(dir);
|
|
2158
|
+
if (hostPid && ctx.hasUI) ctx.ui.notify(`[cron] took the clock back from the background host (pid ${hostPid})`, "info");
|
|
2159
|
+
scheduler.start();
|
|
2160
|
+
schedulerStarted = true;
|
|
2161
|
+
refreshBadge();
|
|
2162
|
+
pruneLogs(dir);
|
|
2163
|
+
// What was loaded is printed on every start; without a line here a session
|
|
2164
|
+
// can begin with loops and rules the user has entirely forgotten about.
|
|
2165
|
+
const myJobs = scheduler.store.load().filter((j) => sameProject(j.cwd, session.cwd) && j.enabled);
|
|
2166
|
+
const myRules = triggers.store.load().filter((r) => sameProject(r.cwd, session.cwd) && r.enabled);
|
|
2167
|
+
// What is scheduled says nothing about what is working: a loop that has failed every night
|
|
2168
|
+
// since Tuesday is still counted as active, and this line is the only one a user reads.
|
|
2169
|
+
const failing = failingSummary(myJobs, os.hostname());
|
|
2170
|
+
log.info(`session start: ${myJobs.length} enabled loop(s), ${myRules.length} enabled rule(s)${failing ? `, ${failing}` : ""} in ${session.cwd}`);
|
|
2171
|
+
if (ctx.hasUI && (myJobs.length || myRules.length)) {
|
|
2172
|
+
const next = myJobs
|
|
2173
|
+
.map((j) => computeNext({ schedule: j.schedule, createdAt: Date.parse(j.createdAt), lastFiredAt: j.lastFiredAt ? Date.parse(j.lastFiredAt) : undefined }, Date.now()))
|
|
2174
|
+
.filter((n): n is number => n !== undefined)
|
|
2175
|
+
.sort((a, b) => a - b)[0];
|
|
2176
|
+
ctx.ui.notify(`[cron] ${myJobs.length} loop(s) and ${myRules.length} rule(s) active here${next ? ` · next ${formatLocal(next)}` : ""}${failing ? ` · ${failing} — /cron runs` : ""}`, "info");
|
|
2177
|
+
}
|
|
2178
|
+
});
|
|
2179
|
+
|
|
2180
|
+
// hooks.toml events, mapped from pi's lifecycle events.
|
|
2181
|
+
pi.on("agent_start", async (_e, ctx) => fireHook({ event: "agent_start" }, ctx));
|
|
2182
|
+
pi.on("turn_start", async (_e, ctx) => fireHook({ event: "turn_start" }, ctx));
|
|
2183
|
+
pi.on("turn_end", async (e, ctx) => fireHook({ event: "turn_end", message_kind: messageKind(e.message), message_summary: messageSummary(e.message) }, ctx));
|
|
2184
|
+
pi.on("message_start", async (e, ctx) => fireHook({ event: "message_start", message_kind: messageKind(e.message), message_summary: messageSummary(e.message) }, ctx));
|
|
2185
|
+
pi.on("message_update", async (e, ctx) => fireHook({ event: "message_update", message_kind: messageKind(e.message), message_summary: messageSummary(e.message), assistant_event: (e.assistantMessageEvent as any)?.type }, ctx));
|
|
2186
|
+
pi.on("message_end", async (e, ctx) => fireHook({ event: "message_end", message_kind: messageKind(e.message), message_summary: messageSummary(e.message) }, ctx));
|
|
2187
|
+
pi.on("tool_execution_start", async (e, ctx) => fireHook({ event: "tool_start", tool_call_id: e.toolCallId, tool_name: e.toolName, tool_args: e.args }, ctx));
|
|
2188
|
+
pi.on("tool_execution_update", async (e, ctx) => fireHook({ event: "tool_update", tool_call_id: e.toolCallId, tool_name: e.toolName, tool_args: e.args, tool_result_summary: resultSummary(e.partialResult) }, ctx));
|
|
2189
|
+
pi.on("tool_execution_end", async (e: any, ctx) => fireHook({ event: "tool_end", tool_call_id: e.toolCallId, tool_name: e.toolName, tool_is_error: !!e.isError, tool_result_summary: resultSummary(e.result) }, ctx));
|
|
2190
|
+
pi.on("session_compact", async (e, ctx) => fireHook({ event: "compaction", compaction_trigger: e.reason === "manual" ? "manual" : "auto", compaction_tokens_before: e.compactionEntry?.tokensBefore, compaction_summary: e.compactionEntry?.summary ? truncateSummary(e.compactionEntry.summary) : undefined }, ctx));
|
|
2191
|
+
// A compaction that failed or was cancelled is the same news to a watcher, and the more urgent
|
|
2192
|
+
// half of it: a session that cannot compact is a session about to fail on context length. It
|
|
2193
|
+
// carries no summary and no token count — nothing was written — only the flag saying so.
|
|
2194
|
+
pi.on("session_compact_failed", async (e, ctx) => fireHook({ event: "compaction", compaction_trigger: e.reason === "manual" ? "manual" : "auto", compaction_failed: true }, ctx));
|
|
2195
|
+
|
|
2196
|
+
pi.on("model_select", async (_event, ctx) => {
|
|
2197
|
+
session = sessionSnapshot(ctx);
|
|
2198
|
+
});
|
|
2199
|
+
pi.on("thinking_level_select", async (_event, ctx) => {
|
|
2200
|
+
session = sessionSnapshot(ctx);
|
|
2201
|
+
});
|
|
2202
|
+
pi.on("agent_end", async (event, ctx) => {
|
|
2203
|
+
lastCtx = ctx;
|
|
2204
|
+
goalMessages = event.messages ?? [];
|
|
2205
|
+
const last: any = (event.messages ?? []).at(-1);
|
|
2206
|
+
lastTurnStopReason = typeof last?.stopReason === "string" ? last.stopReason : undefined;
|
|
2207
|
+
if (lastTurnStopReason === "aborted") goalAbort?.abort();
|
|
2208
|
+
await fireHook({ event: "agent_end" }, ctx);
|
|
2209
|
+
refreshBadge();
|
|
2210
|
+
});
|
|
2211
|
+
// The goal is evaluated at the end of a turn; pi's hook for that is `agent_settled` — the point where
|
|
2212
|
+
// no automatic retry, compaction or queued continuation will run, so a decision here is final.
|
|
2213
|
+
pi.on("agent_settled", async (_event, ctx) => {
|
|
2214
|
+
lastCtx = ctx;
|
|
2215
|
+
try {
|
|
2216
|
+
await evaluateGoal(ctx);
|
|
2217
|
+
} catch (err: any) {
|
|
2218
|
+
if (ctx.hasUI) ctx.ui.notify(`[goal] ${err?.message ?? err}`, "warning");
|
|
2219
|
+
}
|
|
2220
|
+
});
|
|
2221
|
+
|
|
2222
|
+
/** Whether this quitting pi should hand the clock to a background host. May throw on a damaged store. */
|
|
2223
|
+
function handOffDecision(here: string, wasStarted: boolean): ReturnType<typeof shouldHandOff> | undefined {
|
|
2224
|
+
if (!wasStarted) return undefined;
|
|
2225
|
+
const enabledRules = triggers.store.load().filter((r) => r.enabled && (!r.host || r.host === here)).length;
|
|
2226
|
+
return shouldHandOff({
|
|
2227
|
+
auto: handsOffOnQuit(),
|
|
2228
|
+
presence: scheduler.presenceList(),
|
|
2229
|
+
selfPid: process.pid,
|
|
2230
|
+
selfInstance: scheduler.self.instance,
|
|
2231
|
+
hostName: here,
|
|
2232
|
+
enabledLoops: scheduler.store.load().filter((j) => j.enabled && j.stateful && (!j.host || j.host === here)).length,
|
|
2233
|
+
enabledRules,
|
|
2234
|
+
pushServers: hostPushWork(loadMcpConfigFiles({ dir, projectTrusted: false }).servers, enabledRules),
|
|
2235
|
+
hostAlive: !!liveHost(dir),
|
|
2236
|
+
});
|
|
2237
|
+
}
|
|
2238
|
+
|
|
2239
|
+
pi.on("session_shutdown", async (event, ctx) => {
|
|
2240
|
+
// The last interactive pi to quit hands the clock to a headless host (host.ts) so loops,
|
|
2241
|
+
// trigger checks and MCP pushes keep running; the next pi to open takes it back.
|
|
2242
|
+
//
|
|
2243
|
+
// `/new`, `/resume`, `/reload` and `/fork` replace the session inside the same process, and pi
|
|
2244
|
+
// rebuilds the extension with it — so this scheduler must stop, or two would run at once.
|
|
2245
|
+
// A run aborted by the swap gives its slot back (see `launch`), and the replacement session
|
|
2246
|
+
// re-fires it on its first tick instead of losing it until the next due time.
|
|
2247
|
+
startHandled = false;
|
|
2248
|
+
const wasStarted = schedulerStarted;
|
|
2249
|
+
if (schedulerStarted) {
|
|
2250
|
+
schedulerStarted = false;
|
|
2251
|
+
await triggers.stop();
|
|
2252
|
+
await scheduler.stop();
|
|
2253
|
+
}
|
|
2254
|
+
// Decided after stop(): our own presence entry is gone. Two pis quitting together may both
|
|
2255
|
+
// hand off; the extra host exits on its first tick — better than neither handing off.
|
|
2256
|
+
if (event.reason !== "quit") {
|
|
2257
|
+
await hookRunner?.drain(3000);
|
|
2258
|
+
return; // the replacement session_start starts everything again
|
|
2259
|
+
}
|
|
2260
|
+
config = loadConfig(dir); // `[host] auto` may have been edited while this pi was open
|
|
2261
|
+
const here = os.hostname();
|
|
2262
|
+
// A damaged store must not abort the rest of shutdown: the hooks, the MCP pool and the MCP
|
|
2263
|
+
// servers below all still have to be torn down, or their child processes outlive pi.
|
|
2264
|
+
let handOff: ReturnType<typeof shouldHandOff> | undefined;
|
|
2265
|
+
try {
|
|
2266
|
+
handOff = handOffDecision(here, wasStarted);
|
|
2267
|
+
} catch (err: any) {
|
|
2268
|
+
process.stderr.write(`[pi-loops] could not decide on a background host: ${err?.message ?? err}\n`);
|
|
2269
|
+
}
|
|
2270
|
+
if (handOff?.handOff) {
|
|
2271
|
+
try {
|
|
2272
|
+
const pid = spawnHost({ dir, packageDir: PACKAGE_DIR, piPackage: piPackageDir(), model: session.model, thinking: session.thinking });
|
|
2273
|
+
// Only claim the hand-off once the host has recorded itself: one that dies during
|
|
2274
|
+
// module resolution would otherwise be announced as a success, with automation off.
|
|
2275
|
+
const up = await waitForHost(dir, pid);
|
|
2276
|
+
const note = up
|
|
2277
|
+
? `[cron] handed the clock to a background host (pid ${pid}; ${handOff.reason}); /cron host stop ends it`
|
|
2278
|
+
: `[cron] the background host (pid ${pid}) did not start; automation is not running — see ${homeRel(path.join(dir, HOST_LOG))}`;
|
|
2279
|
+
log.write(up ? "info" : "error", note.replace("[cron] ", ""));
|
|
2280
|
+
if (ctx.hasUI) ctx.ui.notify(note, up ? "info" : "warning");
|
|
2281
|
+
else process.stderr.write(`${note}\n`);
|
|
2282
|
+
} catch (err: any) {
|
|
2283
|
+
process.stderr.write(`[pi-loops] could not start the background host: ${err?.message ?? err}\n`);
|
|
2284
|
+
}
|
|
2285
|
+
}
|
|
2286
|
+
await hookRunner?.drain(3000);
|
|
2287
|
+
await mcpPool.stopAll();
|
|
2288
|
+
await stopMcpSources();
|
|
2289
|
+
if (ctx.hasUI) ctx.ui.setStatus(STATUS_KEY, undefined);
|
|
2290
|
+
if (ctx.mode === "tui") ctx.ui.setWidget(PANEL_KEY, undefined);
|
|
2291
|
+
});
|
|
2292
|
+
}
|