@alphacoder-v0/pi-loops 0.22.8 → 0.24.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +664 -0
- package/CONTRACT.md +7 -7
- package/README.md +91 -63
- package/README.zh-CN.md +59 -39
- package/docs/cli.md +272 -83
- package/docs/configuration.md +86 -24
- package/docs/design.md +395 -56
- package/docs/downstream.md +2 -2
- package/docs/goal.md +12 -3
- package/docs/hooks.md +6 -7
- package/docs/loops.md +288 -59
- package/docs/mcp.md +22 -14
- package/docs/recipes.md +6 -0
- package/docs/screenshot.png +0 -0
- package/docs/triggers.md +120 -26
- package/docs/troubleshooting.md +37 -26
- package/docs/web-ui-parity.md +691 -65
- package/package.json +4 -3
- package/recipes/autoresearch/recipe.toml +1 -0
- package/recipes/changelog-draft/recipe.toml +1 -0
- package/recipes/ci-sweeper/recipe.toml +1 -0
- package/recipes/daily-digest/recipe.toml +1 -0
- package/recipes/deps-sweeper/recipe.toml +1 -0
- package/recipes/ecosystem/recipe.toml +1 -0
- package/recipes/issue-loop/recipe.toml +1 -0
- package/recipes/pr-watch/recipe.toml +1 -0
- package/skills/pi-loops/SKILL.md +6 -4
- package/src/activity.ts +199 -0
- package/src/archive.ts +21 -9
- package/src/args.ts +6 -2
- package/src/cli-entry.mjs +12 -3
- package/src/cli.ts +746 -125
- package/src/config.ts +53 -5
- package/src/conversation-rows.ts +469 -0
- package/src/danger.ts +227 -6
- package/src/engine.ts +1240 -0
- package/src/goal.ts +71 -0
- package/src/held-read.ts +27 -0
- package/src/hooks.ts +49 -18
- package/src/host-control-channel.ts +455 -40
- package/src/host-control.ts +602 -111
- package/src/host-entry.mjs +5 -3
- package/src/host-runtime.ts +455 -42
- package/src/host.ts +659 -123
- package/src/inbox-card.ts +278 -0
- package/src/inbox.ts +139 -62
- package/src/job-edit.ts +7 -1
- package/src/job-health.ts +14 -2
- package/src/job-lines.ts +201 -13
- package/src/job-owner.ts +74 -13
- package/src/job-tune.ts +110 -0
- package/src/lease.ts +537 -0
- package/src/lock.ts +7 -2
- package/src/mcp-pool.ts +66 -14
- package/src/mcp.ts +1 -1
- package/src/open-file.ts +54 -0
- package/src/page-children.ts +1507 -0
- package/src/page-devices.ts +192 -0
- package/src/page-server.ts +3150 -0
- package/src/path-precheck.ts +215 -0
- package/src/paths.ts +24 -0
- package/src/pi-floor.ts +10 -0
- package/src/pi-loops.ts +1301 -450
- package/src/pi-resolver.d.mts +1 -0
- package/src/pi-resolver.mjs +110 -10
- package/src/presence.ts +12 -63
- package/src/protocol.ts +10 -1
- package/src/recipe.ts +43 -1
- package/src/requests.ts +354 -0
- package/src/rule-lines.ts +68 -0
- package/src/run-card.ts +105 -0
- package/src/runner.ts +18 -1
- package/src/schedule.ts +1 -2
- package/src/scheduler.ts +595 -245
- package/src/sdk-runner.ts +321 -27
- package/src/seen.ts +278 -0
- package/src/session-head.ts +21 -0
- package/src/share.ts +12 -2
- package/src/snapshot.ts +3 -2
- package/src/store.ts +81 -8
- package/src/tools.ts +196 -65
- package/src/trigger-runtime.ts +164 -127
- package/src/triggers.ts +39 -7
- package/src/trust.ts +46 -2
- package/src/ui-prefs.ts +16 -5
- package/src/watch-plan.ts +174 -0
- package/src/web.d.mts +3 -0
- package/src/web.mjs +2260 -2340
- package/src/window-executor.ts +459 -0
- package/src/window-guard.ts +158 -0
- package/docs/agents/issue-tracker.md +0 -34
package/src/pi-loops.ts
CHANGED
|
@@ -5,7 +5,9 @@
|
|
|
5
5
|
* is an in-process `AgentSession` through pi's SDK (`src/sdk-runner.ts`) — never a child process —
|
|
6
6
|
* so it shares this session's MCP clients, extensions and model. A loop's state is Markdown on
|
|
7
7
|
* disk, findings go to a global JSONL inbox, and `/inbox claim` turns a finding into a real user
|
|
8
|
-
* turn
|
|
8
|
+
* turn. Every prompt pi-loops has for the chat (a plain job, a push, a claim, a goal continuation)
|
|
9
|
+
* is a request addressed to the conversation (src/requests.ts), and reaches pi through the window
|
|
10
|
+
* executor only (src/window-executor.ts), once pi is idle.
|
|
9
11
|
*
|
|
10
12
|
* Commands, tools and their arguments are documented in `docs/` (loops.md, triggers.md, goal.md,
|
|
11
13
|
* configuration.md); this file is where they are registered.
|
|
@@ -14,45 +16,57 @@
|
|
|
14
16
|
import * as path from "node:path";
|
|
15
17
|
import { fileURLToPath } from "node:url";
|
|
16
18
|
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";
|
|
19
|
+
import { ProjectTrustStore, SessionManager, VERSION as PI_VERSION, getAgentDir, readStoredCredential } from "@earendil-works/pi-coding-agent";
|
|
18
20
|
import { ARCHIVE_EXT, type ActivateMode, applyImport, automationOf, defaultExportPath, exportSession, importSession, offerToEnable, parseActivateMode } from "./archive.ts";
|
|
19
21
|
import { Box, Text } from "@earendil-works/pi-tui";
|
|
20
22
|
import { Type } from "typebox";
|
|
21
23
|
import { parseAddArgs, parseSetArgs, splitCommand, tokenize } from "./args.ts";
|
|
22
24
|
import { envFlag, loadConfig } from "./config.ts";
|
|
23
|
-
import { GOAL_ENTRY, type GoalAction, type GoalState, MAX_CONTINUATIONS, abortedTurn, applyDecision, branchMovedSince, continuationPrompt, evaluatorPrompt, latestGoal, newGoal, parseDecision, pauseFor, transcriptFromMessages } from "./goal.ts";
|
|
24
|
-
import { sameProject } from "./
|
|
25
|
-
import {
|
|
25
|
+
import { GOAL_ENTRY, type GoalAction, type GoalState, MAX_CONTINUATIONS, abortedTurn, applyDecision, branchMovedSince, continuationPrompt, evaluationApplies, evaluatorPrompt, goalPursued, judgeCaughtUp, latestGoal, newGoal, parseDecision, pauseFor, resumeGoal, settledTurn, transcriptFromMessages } from "./goal.ts";
|
|
26
|
+
import { sameProject } from "./paths.ts";
|
|
27
|
+
import { olderPiOnSession } from "./presence.ts";
|
|
28
|
+
import { isExactlyTrusted, projectNeedsTrust, sessionTrustCovers, windowTrusted } from "./trust.ts";
|
|
26
29
|
import { HookRunner, type HookEventData, messageKind, messageSummary, resultSummary, runEndEvent, runStartEvent, truncateSummary } from "./hooks.ts";
|
|
27
|
-
import { failingSummary } from "./job-health.ts";
|
|
30
|
+
import { failingSummary, ruleFailureMark } from "./job-health.ts";
|
|
28
31
|
import { loopSignal, signalSummary } from "./job-signal.ts";
|
|
29
32
|
import { LoopsLog, pruneLogs } from "./log.ts";
|
|
30
|
-
import { type InboxEntry, belongsToProject, inProject, resolveInboxRef } from "./inbox.ts";
|
|
33
|
+
import { type Inbox, type InboxEntry, belongsToProject, inProject, resolveInboxRef } from "./inbox.ts";
|
|
31
34
|
import { McpPool } from "./mcp-pool.ts";
|
|
32
35
|
import { McpSource, PI_BUILTIN_TOOL_NAMES, type McpServerConfig, type McpToolDef, droppedNotificationMessage, loadMcpConfigFiles, mapNotification, mcpToolDefinitions, mcpTokenFromEnv } from "./mcp.ts";
|
|
33
36
|
import { capRedacted, previewRedacted, redact } from "./redact.ts";
|
|
37
|
+
import { cardLines, cardOrigin, claimPrompt, dismissOutcome, dismissTail, inboxCard, screenText } from "./inbox-card.ts";
|
|
34
38
|
import { shouldEmitSnapshot, snapshotFingerprint } from "./snapshot.ts";
|
|
35
|
-
import {
|
|
36
|
-
import {
|
|
39
|
+
import { FRESH_RUNS_READ, burstLine, freshRuns, panelRuns, placeRunCards, runCardDelivery, runCardLines, runCardsHere, runLine } from "./run-card.ts";
|
|
40
|
+
import { type ShareMessage, renderShare, shareSummary, shareableMessages } from "./share.ts";
|
|
41
|
+
import { installLauncherWithConfirm, installedByPi, launcherSource, launcherState, launcherTarget, refreshLauncher, stopAndWait, stopService } from "./cli.ts";
|
|
37
42
|
import { execFile } from "node:child_process";
|
|
38
43
|
import { createHash } from "node:crypto";
|
|
39
44
|
import { computeDue, computeNext, formatLocal, formatSchedule, localOffset, parseSchedule, stamp } from "./schedule.ts";
|
|
40
45
|
import { applyJobEdit } from "./job-edit.ts";
|
|
41
46
|
import { jobLines as cronJobLines } from "./job-lines.ts";
|
|
42
|
-
import {
|
|
43
|
-
import {
|
|
47
|
+
import { openWhere, runsHere } from "./job-owner.ts";
|
|
48
|
+
import { type AcquireOutcome, LeaseBinding, LeaseStore, TURN_EVENTS, type TurnMarks, closedConversation, markTurnEvent, openWindowPids, procStart, turnSinceOf } from "./lease.ts";
|
|
49
|
+
import { type DrainOptions, type EngineSinks, type HandOverDeps, type HandOverOutcome, type Producer, type RowAddress, armRetry, cancelRow, coalesced, drain, dropContinuations, goalRow, handOver as engineHandOver, handOverTarget, heldTurnFor, inboxClaimRow, newTriggerRow, promotionTarget, promotionView, pushRunRow, recipeRow, releaseLocalRows, ringProduced, ruleResultRow, stopRow, waitingLine } from "./engine.ts";
|
|
50
|
+
import { PANEL_RUN_LIMIT, heldJobsNotice, leftConversationLine, panelLines, pickQueued, plural, queueLines, toolsCapNotice } from "./job-lines.ts";
|
|
51
|
+
import { RequestStore, type SessionRequest } from "./requests.ts";
|
|
52
|
+
import { WindowExecutor, unattendedTurn } from "./window-executor.ts";
|
|
53
|
+
import { type GuardAnswer, WINDOW_GUARD_CONFIRM_MS, askRefusingFirst, windowGuard } from "./window-guard.ts";
|
|
54
|
+
import { AUTONOMY_LEVELS, type AutonomyLevel, INSTALL_ROOT, installScope, RECIPE_TIERS, type Recipe, type UpdateResult, addLineFor, ensureExcluded, installDirFor, installFiles, installedRecipes, isRecipeName, listRecipes, MAX_SETUP_SHOWN, TRACKER_FILE, loadRecipe, neverSection, packagedRecipesDir, parseAddWords, planInstall, playbookFiles, preflightChecks, purgeInstall, readRecord, renderPreflight, requireRecipeName, resolveRecipeRef, runPreflight, updateFiles } from "./recipe.ts";
|
|
44
55
|
import { LoopScheduler, type SessionSnapshot } from "./scheduler.ts";
|
|
45
56
|
import { MAX_PROMPT_BYTES, type LoopJob, type RunRecord, defaultLoopsDir, newId, resolveJobRef, sessionExists } from "./store.ts";
|
|
46
57
|
import { parentRuntimeFlags, type SubagentRequest } from "./runner.ts";
|
|
47
58
|
import { createInProcessRunner } from "./sdk-runner.ts";
|
|
48
59
|
import { SubagentSlots } from "./slots.ts";
|
|
49
|
-
import { askHost, renderHostSnapshot } from "./host-control-channel.ts";
|
|
50
|
-
import {
|
|
60
|
+
import { askHost, noticesToShow, renderHostSnapshot, ringHost, type HostSnapshot } from "./host-control-channel.ts";
|
|
61
|
+
import { HOST_LOG, crashPaused, crashPauseText, decideEviction, ensureHost, evictHost, heartbeatFresh, hostPushWork, hostServiceAlive, LEAVE_WAIT_MS, RESTART_WAIT_LINE, piPackageDir, readHostService, readSpawnLog, type HostServiceRecord } from "./host-control.ts";
|
|
51
62
|
import { summarizeSessionFile } from "./transcript.ts";
|
|
52
|
-
import { TriggerRuntime, type TriggerOutcome } from "./trigger-runtime.ts";
|
|
53
|
-
import {
|
|
54
|
-
import {
|
|
55
|
-
import {
|
|
63
|
+
import { TriggerRuntime, type TriggerOutcome, promotionToInbox } from "./trigger-runtime.ts";
|
|
64
|
+
import { scheduleAudit } from "./job-tune.ts";
|
|
65
|
+
import { auditArgs, auditCronFinish, auditCronStart, newTraceId, TriggerStore, buildPeriodicCheckTrigger, controlPlanePreflight, resolveRuleRef } from "./triggers.ts";
|
|
66
|
+
import { type ControlPlaneRequest, type CreateJobInput, type JobScope, type ToolHost, automationTools, checkJobName, createLoopJob, resolveJobRefScoped, toolsDropped } from "./tools.ts";
|
|
67
|
+
import { claimPageChild } from "./page-children.ts";
|
|
68
|
+
import { clearUiPref, panelEnabled, readUiPrefs, writeUiPref } from "./ui-prefs.ts";
|
|
69
|
+
import { ALL_PROJECTS, awayIn, awayLine, markProjectSeen } from "./seen.ts";
|
|
56
70
|
import * as fs from "node:fs";
|
|
57
71
|
import * as os from "node:os";
|
|
58
72
|
|
|
@@ -62,6 +76,10 @@ const GOAL_STATUS_KEY = "pi-loops-goal";
|
|
|
62
76
|
/** The evaluator is one tool-less read of a capped transcript; it must not hold a turn for 15 minutes. */
|
|
63
77
|
const GOAL_EVALUATOR_TIMEOUT_MS = 120_000;
|
|
64
78
|
import { PI_LOOPS_VERSION } from "./version.ts";
|
|
79
|
+
import { pidAlive } from "./lock.ts";
|
|
80
|
+
import { shortSessionId } from "./session-head.ts";
|
|
81
|
+
import { ruleLines as listRules, triggerStatusLines } from "./rule-lines.ts";
|
|
82
|
+
import { projectLabel } from "./conversation-rows.ts";
|
|
65
83
|
|
|
66
84
|
interface ViewData {
|
|
67
85
|
title: string;
|
|
@@ -71,14 +89,6 @@ interface ViewData {
|
|
|
71
89
|
export default function piLoops(pi: ExtensionAPI) {
|
|
72
90
|
/** Trigger hop, for cycle suppression: the interactive pi is 0; the sub-agents it runs are 1. */
|
|
73
91
|
const hop = 0;
|
|
74
|
-
/** `/cron host start|stop`: this pi's override of `[host] auto` for the hand-off when it quits. */
|
|
75
|
-
let handOffOnQuit: boolean | undefined;
|
|
76
|
-
/**
|
|
77
|
-
* The hand-off rule, in one place: this pi's `/cron host start|stop` if it said anything, else
|
|
78
|
-
* `[host] auto`. Three sites read it — the card, the scheduler line and the shutdown decision —
|
|
79
|
-
* and each used to spell it out again, one of them leaning on `??` binding tighter than `?:`.
|
|
80
|
-
*/
|
|
81
|
-
const handsOffOnQuit = (): boolean => handOffOnQuit ?? config.hostAuto;
|
|
82
92
|
const dir = defaultLoopsDir(getAgentDir());
|
|
83
93
|
/** Everything this process diagnoses, kept after the window is gone. */
|
|
84
94
|
const log = new LoopsLog(dir, `pi-${process.pid}.log`);
|
|
@@ -87,6 +97,52 @@ export default function piLoops(pi: ExtensionAPI) {
|
|
|
87
97
|
|
|
88
98
|
let session: SessionSnapshot = { cwd: process.cwd() };
|
|
89
99
|
let lastCtx: ExtensionContext | undefined;
|
|
100
|
+
/**
|
|
101
|
+
* This instance's claim on its conversation (src/lease.ts). Its plain jobs fire and its requests
|
|
102
|
+
* are delivered only while it holds the lease; a second window on the same conversation is a viewer.
|
|
103
|
+
* The scheduler's presence entry carries the same instance id.
|
|
104
|
+
*/
|
|
105
|
+
const instance = newId("pi");
|
|
106
|
+
const ownStart = procStart(process.pid);
|
|
107
|
+
let leaseMode: "tui" | "rpc" | "print" | "json" | "unbound" = "unbound";
|
|
108
|
+
/** A conversation open in the browser page: the background process starts its pi for it (src/page-children.ts `claimPageChild`: read once, kept across this factory's re-runs, not inherited by tool processes). */
|
|
109
|
+
const pageChild = claimPageChild();
|
|
110
|
+
// A page child and a terminal say on the lease when a turn runs (markTurn): `turns` tells a reader to expect it.
|
|
111
|
+
const binding = new LeaseBinding(new LeaseStore(dir), instance, () => ({ kind: "terminal", pid: process.pid, procStart: ownStart, mode: leaseMode, pushServers: [], ...(pageChild ? { page: true as const } : {}), ...(pageChild || leaseMode === "tui" ? { turns: true as const } : {}) }));
|
|
112
|
+
/** What happened in this project since anyone looked, in a terminal or the page (src/seen.ts): the page's away card says the same. */
|
|
113
|
+
function awayHere() {
|
|
114
|
+
return awayIn(dir, session.cwd, session.sessionId);
|
|
115
|
+
}
|
|
116
|
+
/** Listing `/inbox` is the terminal's Mark all seen: for this project, or every project with `--all`. */
|
|
117
|
+
function markSeenIfShown(everywhere: boolean): void {
|
|
118
|
+
markProjectSeen(dir, everywhere ? ALL_PROJECTS : session.cwd, stamp());
|
|
119
|
+
}
|
|
120
|
+
/** Closed, and the host may take it (src/lease.ts closedConversation); a 0.22 window counts as open. */
|
|
121
|
+
/** A conversation a pi-loops 0.22 window has open: it has no lease, and never reads requests/. */
|
|
122
|
+
const legacyOpen = (id: string): boolean => scheduler.presenceList().some((e) => e.sessionId === id && !e.leases && e.kind !== "host");
|
|
123
|
+
function closedHere(sessionId: string | undefined): boolean {
|
|
124
|
+
return closedConversation(binding.store, sessionId, legacyOpen);
|
|
125
|
+
}
|
|
126
|
+
const requests = new RequestStore(dir);
|
|
127
|
+
/** The engine's executor here: the pi this extension runs in (src/window-executor.ts). */
|
|
128
|
+
/** This process, as the rows it produces name it: the one that runs them while it is on the conversation. */
|
|
129
|
+
const self: Producer = { pid: process.pid, procStart: ownStart };
|
|
130
|
+
const executor = new WindowExecutor(
|
|
131
|
+
pi,
|
|
132
|
+
binding,
|
|
133
|
+
() => lastCtx,
|
|
134
|
+
self,
|
|
135
|
+
undefined,
|
|
136
|
+
() => {
|
|
137
|
+
const sessionId = binding.here().sessionId;
|
|
138
|
+
return sessionId ? requests.list(sessionId) : [];
|
|
139
|
+
},
|
|
140
|
+
(row) => requests.images(row),
|
|
141
|
+
);
|
|
142
|
+
let requestWatch: fs.FSWatcher | undefined;
|
|
143
|
+
let storeWatch: fs.FSWatcher | undefined;
|
|
144
|
+
/** The holder pid this window last told the person about, so the viewer line is said once per change. */
|
|
145
|
+
let viewerNoticeFor: number | undefined;
|
|
90
146
|
let schedulerStarted = false;
|
|
91
147
|
/**
|
|
92
148
|
* Whether this extension instance has already had its `session_start`.
|
|
@@ -102,22 +158,28 @@ export default function piLoops(pi: ExtensionAPI) {
|
|
|
102
158
|
* what this counts.
|
|
103
159
|
*/
|
|
104
160
|
let startHandled = false;
|
|
105
|
-
/**
|
|
161
|
+
/** From session_shutdown on: the lease goes back during it, and another window may write next. */
|
|
162
|
+
let leaving = false;
|
|
163
|
+
/** The two local trigger sources the host runs: the crontab, and the dynamic-rule checker. */
|
|
106
164
|
const LOCAL_SOURCES = 2;
|
|
107
|
-
/**
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
165
|
+
/** Whether those two are connected: they run in the always-on host, so they are live while it is. */
|
|
166
|
+
const localSourcesConnected = (): boolean => !!liveHostRecord();
|
|
167
|
+
/** The host this loops directory has, when its process is really there. */
|
|
168
|
+
const liveHostRecord = (): HostServiceRecord | undefined => {
|
|
169
|
+
const rec = readHostService(dir);
|
|
170
|
+
return rec && hostServiceAlive(rec) ? rec : undefined;
|
|
171
|
+
};
|
|
113
172
|
|
|
173
|
+
/** `pi --approve` (pi's own spelling of it): trust for this process, which pi grants without asking. */
|
|
174
|
+
const APPROVED = process.argv.slice(2).some((arg) => arg === "--approve" || arg === "-a");
|
|
114
175
|
/** This session as the schedulers see it: the project, the model and the thinking level of the chat. */
|
|
115
176
|
const sessionSnapshot = (ctx: ExtensionContext): SessionSnapshot => ({
|
|
116
177
|
sessionId: ctx.sessionManager.getSessionId(),
|
|
117
178
|
cwd: ctx.cwd,
|
|
118
179
|
model: ctx.model ? `${ctx.model.provider}/${ctx.model.id}` : undefined,
|
|
119
180
|
thinking: ctx.thinkingLevel,
|
|
120
|
-
trusted: ctx.isProjectTrusted(),
|
|
181
|
+
trusted: windowTrusted(getAgentDir(), ctx.cwd, ctx.isProjectTrusted(), APPROVED),
|
|
182
|
+
builtinTools: pi.getActiveTools().filter((t) => PI_BUILTIN_TOOL_NAMES.includes(t)),
|
|
121
183
|
});
|
|
122
184
|
/**
|
|
123
185
|
* Sub-agents run inside this process through pi's SDK: the same
|
|
@@ -174,6 +236,7 @@ export default function piLoops(pi: ExtensionAPI) {
|
|
|
174
236
|
const subagentSlots = new SubagentSlots(() => config.maxConcurrentRuns);
|
|
175
237
|
|
|
176
238
|
const scheduler: LoopScheduler = new LoopScheduler({
|
|
239
|
+
writesNextRuns: () => !liveHostRecord(),
|
|
177
240
|
dir,
|
|
178
241
|
hop,
|
|
179
242
|
slots: subagentSlots,
|
|
@@ -181,15 +244,20 @@ export default function piLoops(pi: ExtensionAPI) {
|
|
|
181
244
|
getSettings: () => ({ maxConcurrentRuns: config.maxConcurrentRuns, catchUp: config.cronCatchUp, dailyBudgetUsd: config.dailyBudgetUsd }),
|
|
182
245
|
runner,
|
|
183
246
|
kind: "interactive",
|
|
247
|
+
instance,
|
|
248
|
+
leases: {
|
|
249
|
+
heldEpoch: (id) => binding.heldEpoch(id),
|
|
250
|
+
holderOf: (id) => binding.holderOf(id),
|
|
251
|
+
standsAside: () => binding.standingAside,
|
|
252
|
+
holderKind: (id) => binding.holderKindOf(id),
|
|
253
|
+
hostRuns: (id) => closedHere(id),
|
|
254
|
+
hostPaused: () => hostPauseReason(),
|
|
255
|
+
},
|
|
256
|
+
// A window fires only the plain jobs of the conversation it holds; every loop runs in the host.
|
|
257
|
+
owns: (job, view) => !job.stateful && runsHere(job, view),
|
|
184
258
|
// A plain job whose session is gone is parked, /cron gc removes it.
|
|
185
259
|
sessionExists: (id) => sessionExists(path.join(getAgentDir(), "sessions"), id),
|
|
186
260
|
hooks: {
|
|
187
|
-
onInject: (_job, prompt, runId) => {
|
|
188
|
-
if (!lastCtx) return;
|
|
189
|
-
const idle = lastCtx.isIdle();
|
|
190
|
-
pi.sendUserMessage(prompt, idle ? undefined : { deliverAs: "followUp" });
|
|
191
|
-
triggeredTurnLine(runId, idle);
|
|
192
|
-
},
|
|
193
261
|
onRunStart: (job, runId) => {
|
|
194
262
|
auditCronStart(triggers.store, job, runId);
|
|
195
263
|
refreshBadge();
|
|
@@ -199,20 +267,36 @@ export default function piLoops(pi: ExtensionAPI) {
|
|
|
199
267
|
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");
|
|
200
268
|
},
|
|
201
269
|
onJobRetired: (job) => cronControlAudit("remove", "scheduler", job, undefined),
|
|
202
|
-
|
|
270
|
+
// A row this window wrote is its own (`/cron run`'s is the person's). While pi is busy the row
|
|
271
|
+
// waits in the file, where nothing else shows it: said once, as pi said a queued follow-up.
|
|
272
|
+
onRowWritten: (row, job) => {
|
|
273
|
+
executor.produced(row);
|
|
274
|
+
const line = waitingLine(row.source, row.id, job.name ?? job.id);
|
|
275
|
+
if (line && lastCtx?.hasUI && !lastCtx.isIdle()) lastCtx.ui.notify(line, "info");
|
|
276
|
+
refreshBadge();
|
|
277
|
+
},
|
|
278
|
+
onRunFinished: ({ job, record, result }) => {
|
|
203
279
|
auditCronFinish(triggers.store, job, record, result.stopReason === "aborted");
|
|
204
280
|
refreshBadge();
|
|
205
281
|
fireRunHook(runEndEvent(job, record));
|
|
206
282
|
if (!lastCtx?.hasUI) return;
|
|
207
283
|
// Another project's findings and prompt previews do not belong in this transcript; the
|
|
208
284
|
// audit sink below and the inbox already carry them to where they do.
|
|
209
|
-
if (sameProject(job.cwd, session.cwd)) showRunCard(lastCtx, job, record
|
|
285
|
+
if (sameProject(job.cwd, session.cwd)) showRunCard(lastCtx, job.name, record);
|
|
210
286
|
},
|
|
211
287
|
onInboxChanged: () => refreshBadge(),
|
|
212
|
-
//
|
|
213
|
-
//
|
|
214
|
-
onTick: (
|
|
215
|
-
|
|
288
|
+
// Rule checks run in the host. A window handles only the inject_* pushes for the
|
|
289
|
+
// conversation it holds, deduplicated machine-wide (see startMcpSources).
|
|
290
|
+
onTick: () => {
|
|
291
|
+
leaseTick();
|
|
292
|
+
showFinishedRuns();
|
|
293
|
+
// A change inside the snapshot's minimum gap is held back, not lost: the next tick writes it.
|
|
294
|
+
emitSnapshot();
|
|
295
|
+
// Runs happen in the background now, so nothing else tells the status line and the panel
|
|
296
|
+
// that one started, finished or filed a finding: redraw them after each look.
|
|
297
|
+
if (binding.here().holds) hostTick(true).then(() => refreshBadge()).catch((err: any) => log.info(`host: ${err?.message ?? err}`));
|
|
298
|
+
else refreshBadge();
|
|
299
|
+
},
|
|
216
300
|
// These carry "job disabled", "state write failed", "cannot read jobs"; the scheduler says
|
|
217
301
|
// which of its lines are routine instead of leaving it to be guessed from the wording.
|
|
218
302
|
log: (msg, level) => diagnostic(msg, level),
|
|
@@ -236,26 +320,34 @@ export default function piLoops(pi: ExtensionAPI) {
|
|
|
236
320
|
// same counter rather than on a second one of their own.
|
|
237
321
|
slots: subagentSlots,
|
|
238
322
|
budget: () => scheduler.budgetState(),
|
|
323
|
+
inbox: scheduler.inbox,
|
|
239
324
|
hop,
|
|
240
|
-
// A project's checks run in a pi open in that project, so a result lands in the conversation it belongs to.
|
|
241
|
-
self: scheduler.self,
|
|
242
|
-
presence: () => scheduler.presenceList(),
|
|
243
|
-
isLeader: () => scheduler.isLeader,
|
|
244
325
|
hooks: {
|
|
245
|
-
onPromote: async (content, trigger) => {
|
|
246
|
-
//
|
|
247
|
-
//
|
|
248
|
-
//
|
|
249
|
-
|
|
250
|
-
|
|
326
|
+
onPromote: async (content, trigger, forSession) => {
|
|
327
|
+
// A rule's result is a request addressed to a conversation (src/engine.ts promotionTarget):
|
|
328
|
+
// the creating one while a window holds it, else one a window holds in the rule's project,
|
|
329
|
+
// this one included, else the closed creating one; delivered here when this window holds the
|
|
330
|
+
// target, by the holding window or the background process otherwise. A rule with no cwd of its
|
|
331
|
+
// own is this project's.
|
|
332
|
+
const here = binding.here();
|
|
333
|
+
const route = promotionTarget(forSession, promotionView(binding.store, trigger.cwd ?? session.cwd, legacyOpen));
|
|
334
|
+
if (route.to === "inbox") {
|
|
335
|
+
// The check decided this result's finding already (TriggerRuntime's inbox); a push with no rule did not.
|
|
336
|
+
await promotionToInbox(scheduler.inbox, triggers.store, content, trigger, { cwd: trigger.cwd ?? session.cwd, sessionId: route.sessionId });
|
|
251
337
|
refreshBadge();
|
|
252
338
|
return "inbox";
|
|
253
339
|
}
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
if (
|
|
257
|
-
|
|
258
|
-
|
|
340
|
+
const target = route.sessionId;
|
|
341
|
+
requests.append(ruleResultRow(target, content, { ...trigger, cwd: trigger.cwd ?? session.cwd }, createdBy()));
|
|
342
|
+
if (target !== here.sessionId || !here.holds) {
|
|
343
|
+
// Another window's conversation drains on its own watcher; a closed one is the background
|
|
344
|
+
// process's, which hears this instead of waiting for its tick. Never awaited: the row is
|
|
345
|
+
// on disk, and a lost ring costs one tick.
|
|
346
|
+
void ringHost(dir, `request:${target}`).catch(() => undefined);
|
|
347
|
+
return "queued";
|
|
348
|
+
}
|
|
349
|
+
const result = await drainRequests();
|
|
350
|
+
return result?.removed.includes(trigger.traceId) ? "chat" : "queued";
|
|
259
351
|
},
|
|
260
352
|
onInjectAndRun: async (prompt, trigger) => {
|
|
261
353
|
if (trigger.cwd && !sameProject(trigger.cwd, session.cwd)) {
|
|
@@ -263,10 +355,9 @@ export default function piLoops(pi: ExtensionAPI) {
|
|
|
263
355
|
refreshBadge();
|
|
264
356
|
return "inbox";
|
|
265
357
|
}
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
return "chat";
|
|
358
|
+
// The turn is a row like a plain job's: pi gets it while idle, Esc has nothing of ours to drop.
|
|
359
|
+
const { written, result } = await handOver(pushRunRow(prompt, trigger, rowAddress()));
|
|
360
|
+
return handOverTarget(written, result, trigger.traceId);
|
|
270
361
|
},
|
|
271
362
|
onStarted: () => refreshBadge(),
|
|
272
363
|
onFinished: (outcome) => {
|
|
@@ -283,14 +374,20 @@ export default function piLoops(pi: ExtensionAPI) {
|
|
|
283
374
|
});
|
|
284
375
|
// Trigger audit is kept as session custom entries (trigger / trigger_result / trigger_promotion),
|
|
285
376
|
// so it resumes with the session and travels in archives, on top of the machine-wide JSONL.
|
|
377
|
+
/**
|
|
378
|
+
* Whether this window may write entries into its conversation. A viewer writes nothing into the
|
|
379
|
+
* conversation another window holds: an entry on its own stale branch made the holder's later turns
|
|
380
|
+
* a dead branch once this window took the conversation over.
|
|
381
|
+
*/
|
|
382
|
+
const mayAppend = (): boolean => !!lastCtx && !leaving && binding.viewerOf === undefined && !binding.standingAside;
|
|
286
383
|
triggers.store.onAudit = (record) => {
|
|
287
|
-
if (!
|
|
288
|
-
// Only this project's rows:
|
|
384
|
+
if (!mayAppend()) return;
|
|
385
|
+
// Only this project's rows: a result for another project must not put that project's
|
|
289
386
|
// output into this session (and its archives).
|
|
290
387
|
if (record.cwd && !sameProject(record.cwd, session.cwd)) return;
|
|
291
388
|
pi.appendEntry(record.type, record);
|
|
292
389
|
};
|
|
293
|
-
pi.registerFlag("trigger-poll-secs", { type: "string", description: "
|
|
390
|
+
pi.registerFlag("trigger-poll-secs", { type: "string", description: "Ignored: pi-loops checks rules every [triggers] poll_interval_secs in config.toml (pi-loops)" });
|
|
294
391
|
|
|
295
392
|
const mcpSources: McpSource[] = [];
|
|
296
393
|
/** Project-level MCP servers of *other* projects, connected on demand for their loops. */
|
|
@@ -383,9 +480,9 @@ export default function piLoops(pi: ExtensionAPI) {
|
|
|
383
480
|
const source = new McpSource(cfg, {
|
|
384
481
|
onConnected: (src) => registerMcpTools(src),
|
|
385
482
|
onNotification: (n) => {
|
|
386
|
-
//
|
|
387
|
-
// (dedup.json)
|
|
388
|
-
// once
|
|
483
|
+
// A window handles only the inject_* pushes for the conversation it holds; the
|
|
484
|
+
// machine-wide dedup window (dedup.json) makes a push that several processes see count
|
|
485
|
+
// once. Rule pushes are the host's (below).
|
|
389
486
|
const trigger = mapNotification(cfg.name, n);
|
|
390
487
|
if (!trigger) {
|
|
391
488
|
source.status.droppedCount++;
|
|
@@ -394,6 +491,23 @@ export default function piLoops(pi: ExtensionAPI) {
|
|
|
394
491
|
}
|
|
395
492
|
trigger.cwd = session.cwd;
|
|
396
493
|
const delivery = cfg.injectAndRun ? "inject_and_run" : cfg.injectSummary ? "inject_summary" : "sub_agent";
|
|
494
|
+
// Rules are evaluated by the host alone, on its own connection to the server. It connects a
|
|
495
|
+
// project's own servers only once pi has saved trust for that directory: before that, this
|
|
496
|
+
// push reaches no rule, and the source says so rather than dropping it without a word.
|
|
497
|
+
if (delivery === "sub_agent") {
|
|
498
|
+
if (cfg.source === "project" && !isExactlyTrusted(getAgentDir(), session.cwd)) {
|
|
499
|
+
source.status.droppedCount++;
|
|
500
|
+
source.status.lastError = "rule pushes are checked in the background, which connects this project's servers only after /trust saves trust for it";
|
|
501
|
+
}
|
|
502
|
+
return;
|
|
503
|
+
}
|
|
504
|
+
// The window holding this conversation injects it; a second window on it would put it there twice.
|
|
505
|
+
if (binding.viewerOf !== undefined) return;
|
|
506
|
+
if (binding.standingAside) {
|
|
507
|
+
source.status.droppedCount++;
|
|
508
|
+
source.status.lastError = "this window shows an older copy of its conversation; /resume it to take pushes again";
|
|
509
|
+
return;
|
|
510
|
+
}
|
|
397
511
|
void triggers
|
|
398
512
|
.handle(trigger, delivery)
|
|
399
513
|
.then((out) => {
|
|
@@ -424,10 +538,13 @@ export default function piLoops(pi: ExtensionAPI) {
|
|
|
424
538
|
await Promise.all(sources.map((s) => s.stop()));
|
|
425
539
|
}
|
|
426
540
|
|
|
427
|
-
/**
|
|
541
|
+
/**
|
|
542
|
+
* Trigger output as a bounded card in the transcript, never an LLM message. A rule check cards
|
|
543
|
+
* only when it failed: a match is a finding in the inbox (and in the chat with `promote_to_chat`),
|
|
544
|
+
* and the panel's Polling line says it; a quiet check stays in /triggers status and the audit.
|
|
545
|
+
*/
|
|
428
546
|
function showTriggerCard(ctx: ExtensionContext, outcome: TriggerOutcome): void {
|
|
429
|
-
|
|
430
|
-
if (quiet) return; // quiet checks stay in /triggers status + audit, not in the panel
|
|
547
|
+
if (outcome.ok && outcome.delivery === "sub_agent") return;
|
|
431
548
|
const secs = Math.round(outcome.durationMs / 1000);
|
|
432
549
|
const cost = outcome.cost ? ` · $${outcome.cost.toFixed(3)}` : "";
|
|
433
550
|
const title = outcome.ok
|
|
@@ -469,7 +586,8 @@ export default function piLoops(pi: ExtensionAPI) {
|
|
|
469
586
|
/* ------------------------------------------------------------ panel */
|
|
470
587
|
|
|
471
588
|
const PANEL_KEY = "pi-loops-panel";
|
|
472
|
-
|
|
589
|
+
/** This project's latest finished runs, oldest first, as showFinishedRuns last read them. */
|
|
590
|
+
let recentRuns: RunRecord[] = [];
|
|
473
591
|
let panelOn = panelEnabled(readUiPrefs(dir));
|
|
474
592
|
|
|
475
593
|
function setPanelEnabled(on: boolean): void {
|
|
@@ -482,8 +600,9 @@ export default function piLoops(pi: ExtensionAPI) {
|
|
|
482
600
|
}
|
|
483
601
|
|
|
484
602
|
/**
|
|
485
|
-
* The panel (
|
|
486
|
-
* above the editor. Hidden when there is nothing to show.
|
|
603
|
+
* The panel (Rules / Last check / Loops / Runs / MCP / Hooks; src/job-lines.ts panelLines), rendered as a widget
|
|
604
|
+
* above the editor. Hidden when there is nothing to show. A run that writes no card into the
|
|
605
|
+
* conversation is seen here, in the Runs block.
|
|
487
606
|
*/
|
|
488
607
|
function refreshPanel(): void {
|
|
489
608
|
try {
|
|
@@ -499,71 +618,402 @@ export default function piLoops(pi: ExtensionAPI) {
|
|
|
499
618
|
lastCtx.ui.setWidget(PANEL_KEY, undefined);
|
|
500
619
|
return;
|
|
501
620
|
}
|
|
502
|
-
const
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
621
|
+
const lines = panelLines({
|
|
622
|
+
rules: triggers.store.load().filter((r) => sameProject(r.cwd, session.cwd)).map((r) => ({ ...r, failureMark: ruleFailureMark(r) })),
|
|
623
|
+
poll: lastPoll(),
|
|
624
|
+
jobs: scheduler.store.load().filter((j) => sameProject(j.cwd, session.cwd)),
|
|
625
|
+
runs: recentRuns,
|
|
626
|
+
runText: runLine,
|
|
627
|
+
sources: mcpSources.map((src) => ({ name: src.config.name, state: src.status.state, tools: (mcpToolNames.get(src.config.name) ?? []).length })),
|
|
628
|
+
hookEvents: hookRunner?.hooks.map((h) => h.event) ?? [],
|
|
629
|
+
});
|
|
630
|
+
if (!lines.length) {
|
|
508
631
|
lastCtx.ui.setWidget(PANEL_KEY, undefined);
|
|
509
632
|
return;
|
|
510
633
|
}
|
|
511
|
-
const clip = (x: string, n: number) => previewRedacted(x, n);
|
|
512
634
|
lastCtx.ui.setWidget(PANEL_KEY, (_tui, theme) => {
|
|
513
|
-
const
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
635
|
+
const colour = { head: "accent", ok: "success", dim: "dim", warn: "warning" } as const;
|
|
636
|
+
return new Text(lines.map((l) => theme.fg(colour[l.tone], l.text)).join("\n"), 0, 0);
|
|
637
|
+
});
|
|
638
|
+
}
|
|
639
|
+
|
|
640
|
+
/** The engine's sinks here: the jobs the rows name, the inbox for dead letters, the audit. */
|
|
641
|
+
const engineSinks = (): EngineSinks => ({ jobs: { exists: (id) => scheduler.store.load().some((j) => j.id === id), enabled: (id) => scheduler.store.load().find((j) => j.id === id)?.enabled ?? false, goalActive: () => goalPursued(goal) }, inbox: scheduler.inbox, audit: triggers.store });
|
|
642
|
+
/** One drain per session for when a wait ends on its own (the ring wait on a page's row), replaced, never stacked. */
|
|
643
|
+
const retryTimers = new Map<string, ReturnType<typeof setTimeout>>();
|
|
644
|
+
/**
|
|
645
|
+
* What is addressed to this conversation, delivered through the executor: everything while this
|
|
646
|
+
* window holds it, its own local rows regardless (src/engine.ts). `tick` marks the scheduler's
|
|
647
|
+
* drain, the one that may judge a hand-over pi never answered.
|
|
648
|
+
*/
|
|
649
|
+
async function drainRequests(opts: DrainOptions = {}) {
|
|
650
|
+
const sessionId = binding.here().sessionId;
|
|
651
|
+
if (!sessionId) return undefined;
|
|
652
|
+
const now = Date.now();
|
|
653
|
+
const result = await drain(sessionId, requests, executor, now, engineSinks(), opts);
|
|
654
|
+
if (result.retryAt !== undefined) armRetry(retryTimers, sessionId, result.retryAt - now + 50, () => void drainRequests().catch((err: any) => log.info(`requests: ${err?.message ?? err}`)));
|
|
655
|
+
// The badge counts what waits in the file: a drain is when that changes.
|
|
656
|
+
if (result.delivered.length || result.removed.length || result.dead.length) refreshBadge();
|
|
657
|
+
return result;
|
|
658
|
+
}
|
|
659
|
+
const createdBy = (): SessionRequest["created_by"] => ({ pid: process.pid, instance, procStart: ownStart, version: PI_LOOPS_VERSION });
|
|
660
|
+
/** Where a row this window produces is addressed: this conversation, in this project. */
|
|
661
|
+
const rowAddress = (): RowAddress => ({ sessionId: binding.here().sessionId ?? session.sessionId ?? "", cwd: session.cwd, createdBy: createdBy() });
|
|
662
|
+
/** Whether a row's proof is in this conversation already (src/engine.ts landed): such a row collapses nothing, and `agent_settled` no longer waits for it. */
|
|
663
|
+
const delivered = (id: string) => executor.delivered(id);
|
|
664
|
+
const handOverDeps = (): HandOverDeps => ({ store: requests, delivered, produced: (row) => executor.produced(row), drain: drainRequests });
|
|
665
|
+
/** A prompt one of this window's sources has for the chat (src/engine.ts handOver), and the line that says it waits. */
|
|
666
|
+
async function handOver(row: SessionRequest, claim?: { inbox: Inbox; entryId: string }): Promise<HandOverOutcome> {
|
|
667
|
+
const out = await engineHandOver(handOverDeps(), row, claim);
|
|
668
|
+
if (out.line && lastCtx?.hasUI) lastCtx.ui.notify(out.line, "info");
|
|
669
|
+
return out;
|
|
670
|
+
}
|
|
671
|
+
|
|
672
|
+
function noteBinding(outcome: AcquireOutcome): void {
|
|
673
|
+
if (outcome.outcome === "acquired" && outcome.tookFromPid) log.info(`took session ${shortSessionId(outcome.lease.sessionId)} from dead pid ${outcome.tookFromPid}`);
|
|
674
|
+
if (outcome.outcome === "acquired" && outcome.tookFromPage) sayInfo("[cron] this conversation was open in the page; it is yours now, and the page shows it is answered here");
|
|
675
|
+
if (outcome.outcome === "acquired") noteUnseenEntries(outcome.lease.sessionFile, !!outcome.tookFromHost?.runId);
|
|
676
|
+
const holder = outcome.outcome === "viewer" ? outcome.holderPid : undefined;
|
|
677
|
+
if (holder === viewerNoticeFor) return;
|
|
678
|
+
viewerNoticeFor = holder;
|
|
679
|
+
if (holder !== undefined) sayInfo(`[cron] this conversation is also ${openWhere(binding.holderKindOf(outcome.lease.sessionId))}; its scheduled messages and rule results arrive there, not in this window`);
|
|
680
|
+
}
|
|
681
|
+
|
|
682
|
+
/**
|
|
683
|
+
* pi read the conversation before this window held it, so anything written in between — a rule
|
|
684
|
+
* result pi-loops appended, or the turns of the window that held it before this one — is on disk
|
|
685
|
+
* and not in this window. A turn fired from here would branch off the
|
|
686
|
+
* old last entry and leave those on a dead branch, so the window stands aside until the person
|
|
687
|
+
* resumes the conversation: it keeps the lease, and its plain jobs and rule results wait.
|
|
688
|
+
*/
|
|
689
|
+
function noteUnseenEntries(file: string | undefined, interruptedHost: boolean): void {
|
|
690
|
+
const ctx = lastCtx;
|
|
691
|
+
if (!ctx || !file || !fs.existsSync(file)) return;
|
|
692
|
+
// pi read the turn's entries as it opened the conversation, so the counts below agree: say it
|
|
693
|
+
// anyway. What the stopped turn writes after this is its tail, and this window's next entry
|
|
694
|
+
// takes the conversation's last place back, so there is no reason to stand aside for it.
|
|
695
|
+
if (interruptedHost) return sayInfo("[cron] pi-loops was answering a scheduled job in this conversation when you opened it, and stopped; /tree shows what it wrote");
|
|
696
|
+
try {
|
|
697
|
+
// `getEntries().length`, not `getEntryCount()`: the latter is newer than the pi floor.
|
|
698
|
+
if (SessionManager.open(file).getEntries().length <= ctx.sessionManager.getEntries().length) return;
|
|
699
|
+
} catch (err: any) {
|
|
700
|
+
log.info(`could not compare the session with what is on disk: ${err?.message ?? err}`);
|
|
701
|
+
return;
|
|
702
|
+
}
|
|
703
|
+
binding.standAside();
|
|
704
|
+
log.info(`session ${shortSessionId(binding.here().sessionId ?? "?")} is ahead on disk; standing aside until it is resumed`);
|
|
705
|
+
if (ctx.hasUI) afterHistory(ctx, () => ctx.ui.notify("[cron] this window shows an older copy of this conversation: it changed after this window read it. /resume this session to see it; until then its scheduled messages and rule results wait", "warning"));
|
|
706
|
+
}
|
|
707
|
+
|
|
708
|
+
/**
|
|
709
|
+
* Take this conversation's lease, or become its viewer. Called from session_start and from the
|
|
710
|
+
* first callbacks that carry a context, for a runtime that never sends session_start.
|
|
711
|
+
*/
|
|
712
|
+
function bindSession(ctx: ExtensionContext): void {
|
|
713
|
+
const sessionId = ctx.sessionManager.getSessionId();
|
|
714
|
+
if (!sessionId || binding.here().sessionId === sessionId) return;
|
|
715
|
+
leaseMode = ctx.mode === "tui" || ctx.mode === "rpc" || ctx.mode === "print" || ctx.mode === "json" ? ctx.mode : "unbound";
|
|
716
|
+
try {
|
|
717
|
+
leaveSession();
|
|
718
|
+
} catch (err: any) {
|
|
719
|
+
log.info(`requests: ${err?.message ?? err}`);
|
|
720
|
+
}
|
|
721
|
+
try {
|
|
722
|
+
noteBinding(binding.bind(sessionId, { sessionFile: ctx.sessionManager.getSessionFile(), cwd: ctx.cwd }));
|
|
723
|
+
watchRequests();
|
|
724
|
+
} catch (err: any) {
|
|
725
|
+
log.info(`lease: ${err?.message ?? err}`);
|
|
726
|
+
}
|
|
727
|
+
}
|
|
728
|
+
|
|
729
|
+
/** Leaving the conversation this window is on (`/new`, `/resume`, quitting): the local rows it produced there are the holder's to run now. */
|
|
730
|
+
function leaveSession(): void {
|
|
731
|
+
const sessionId = binding.here().sessionId;
|
|
732
|
+
if (!sessionId) return;
|
|
733
|
+
const left = releaseLocalRows(requests, sessionId, self, (id) => executor.delivered(id));
|
|
734
|
+
if (left.length) log.info(`left ${left.length} queued request(s) of session ${shortSessionId(sessionId)} to its holder`);
|
|
735
|
+
}
|
|
736
|
+
|
|
737
|
+
/** A request written for this conversation lands without waiting for the tick; the tick drains as well, because fs.watch can miss events. */
|
|
738
|
+
function watchRequests(): void {
|
|
739
|
+
if (requestWatch) return;
|
|
740
|
+
const sessionId = binding.here().sessionId;
|
|
741
|
+
if (!sessionId) return;
|
|
742
|
+
const requestsDir = path.join(dir, "requests");
|
|
743
|
+
fs.mkdirSync(requestsDir, { recursive: true });
|
|
744
|
+
// One drain at a time and one more after it (src/engine.ts coalesced): every removal is a
|
|
745
|
+
// rewrite, and a rewrite is another event, so a burst of rows used to be three drains each.
|
|
746
|
+
const drainOnChange = coalesced(() => drainRequests().catch((err: any) => log.info(`requests: ${err?.message ?? err}`)));
|
|
747
|
+
requestWatch = fs.watch(requestsDir, (_event, name) => {
|
|
748
|
+
if (name !== `${sessionId}.jsonl`) return;
|
|
749
|
+
drainOnChange();
|
|
750
|
+
});
|
|
751
|
+
requestWatch.on("error", (err) => log.info(`requests watch: ${err?.message ?? err}`));
|
|
752
|
+
}
|
|
753
|
+
|
|
754
|
+
/**
|
|
755
|
+
* A run in the background starting or finishing, a finding filed: shown when the file changes, not
|
|
756
|
+
* up to 30 s later on the next tick. In 0.22 the run happened in this window and redrew it at once.
|
|
757
|
+
* The tick still redraws as well, because fs.watch can miss events.
|
|
758
|
+
*/
|
|
759
|
+
const STORE_FILES = new Set(["runs.jsonl", "inbox.jsonl", "jobs.json"]);
|
|
760
|
+
let storeWatchTimer: ReturnType<typeof setTimeout> | undefined;
|
|
761
|
+
function watchStore(): void {
|
|
762
|
+
if (storeWatch || !lastCtx?.hasUI) return;
|
|
763
|
+
try {
|
|
764
|
+
storeWatch = fs.watch(dir, (_event, name) => {
|
|
765
|
+
if (!name || !STORE_FILES.has(String(name)) || storeWatchTimer) return;
|
|
766
|
+
storeWatchTimer = setTimeout(() => {
|
|
767
|
+
storeWatchTimer = undefined;
|
|
768
|
+
// A timer has nobody to throw to: an unreadable store would take pi down from here.
|
|
769
|
+
try {
|
|
770
|
+
showFinishedRuns();
|
|
771
|
+
} catch (err: any) {
|
|
772
|
+
storeReadFailed(err);
|
|
773
|
+
}
|
|
774
|
+
refreshBadge();
|
|
775
|
+
}, 250);
|
|
776
|
+
storeWatchTimer.unref();
|
|
777
|
+
});
|
|
778
|
+
storeWatch.on("error", (err) => log.info(`store watch: ${err?.message ?? err}`));
|
|
779
|
+
} catch (err: any) {
|
|
780
|
+
log.info(`store watch: ${err?.message ?? err}`); // the tick still redraws
|
|
781
|
+
}
|
|
782
|
+
}
|
|
783
|
+
|
|
784
|
+
/** Every scheduler tick: keep the lease (a viewer retries), list the injecting servers on it, and deliver this conversation's requests. */
|
|
785
|
+
function leaseTick(): void {
|
|
786
|
+
try {
|
|
787
|
+
const outcome = binding.refresh();
|
|
788
|
+
if (outcome) noteBinding(outcome);
|
|
789
|
+
binding.setPushServers(mcpSources.filter((src) => (src.config.injectSummary || src.config.injectAndRun) && src.status.state === "connected").map((src) => src.config.name));
|
|
790
|
+
drainRequests({ tick: true }).catch((err: any) => log.info(`requests: ${err?.message ?? err}`));
|
|
791
|
+
} catch (err: any) {
|
|
792
|
+
log.info(`lease: ${err?.message ?? err}`);
|
|
793
|
+
}
|
|
794
|
+
}
|
|
795
|
+
|
|
796
|
+
/** Why the host is not running when it should be, as the badge and `/cron host` say it; undefined while it runs or has nothing to run. */
|
|
797
|
+
/**
|
|
798
|
+
* Why the loops are not running in a host right now, when they are not: it could not start, it kept
|
|
799
|
+
* crashing and is paused, or it runs but its control socket does not answer. One value per state, so
|
|
800
|
+
* a warning is said once per state change and not once per wording of the same failure.
|
|
801
|
+
*/
|
|
802
|
+
let hostState: { kind: "start-failed" | "crash-paused" | "channel-broken"; detail: string } | undefined;
|
|
803
|
+
const HOST_STATE_BADGE = { "start-failed": "loops paused: pi-loops could not start", "crash-paused": "loops paused: pi-loops keeps crashing", "channel-broken": "pi-loops is not answering" } as const;
|
|
804
|
+
/** The one-line reason, where a line has room for it. */
|
|
805
|
+
const hostProblemText = (): string | undefined => (hostState ? `${HOST_STATE_BADGE[hostState.kind]} — ${hostState.detail}` : undefined);
|
|
806
|
+
/**
|
|
807
|
+
* Why loops, checks and the plain jobs of closed conversations will not run right now, or undefined
|
|
808
|
+
* while a host runs or is about to. One answer for the badge, the `/cron` listing, the session-start
|
|
809
|
+
* line and `/cron host`: a stop by hand, `[host] auto = false` and `PI_LOOPS_HOST=off` each pause
|
|
810
|
+
* everything, and each used to leave every surface saying it would run.
|
|
811
|
+
*/
|
|
812
|
+
function hostPauseReason(): string | undefined {
|
|
813
|
+
if (liveHostRecord()) return undefined;
|
|
814
|
+
if (readUiPrefs(dir).host === "stopped") return "pi-loops is stopped — /pi-loops start";
|
|
815
|
+
if (process.env.PI_LOOPS_HOST?.toLowerCase() === "off") return "PI_LOOPS_HOST=off in this pi's environment";
|
|
816
|
+
// `[host] auto = false` pauses nothing while a window is open: this window starts the process, and
|
|
817
|
+
// it leaves with the last window (src/host-control.ts leavesWithWindows).
|
|
818
|
+
const spawns = readSpawnLog(dir);
|
|
819
|
+
if (crashPaused(spawns, Date.now())) return crashPauseText(spawns);
|
|
820
|
+
if (hostState?.kind === "start-failed") return `pi-loops could not start — ${hostState.detail}`;
|
|
821
|
+
return undefined;
|
|
822
|
+
}
|
|
823
|
+
/** Whether anything waits on the host: a pause with nothing to run is not news. */
|
|
824
|
+
const hostHasWork = (): boolean => {
|
|
825
|
+
const work = automationCounts();
|
|
826
|
+
return !!(work.loops || work.rules || work.pushServers || work.closedSessionJobs || work.closedMailboxes);
|
|
827
|
+
};
|
|
828
|
+
let hostChannelBroken: string | undefined;
|
|
829
|
+
/** The pause reason last said to the person, so a pause is announced once. */
|
|
830
|
+
let shownPause: string | undefined;
|
|
831
|
+
/** The host's last status answer (every tick's probe): loops and checks run there, so what is running is there. */
|
|
832
|
+
let hostSnapshot: HostSnapshot | undefined;
|
|
833
|
+
/** A fresh answer for a command that is about to show or act on what runs: the tick's may be 30 s old. */
|
|
834
|
+
async function refreshHostSnapshot(): Promise<HostSnapshot | undefined> {
|
|
835
|
+
const answer = liveHostRecord() ? await askHost(dir, { op: "status" }) : undefined;
|
|
836
|
+
hostSnapshot = answer?.ok ? answer.snapshot : undefined;
|
|
837
|
+
return hostSnapshot;
|
|
838
|
+
}
|
|
839
|
+
/**
|
|
840
|
+
* Runs and checks in flight for this machine: this window's own (`/goal`, `/triggers run`) and the
|
|
841
|
+
* host's. Every view of "what is running" reads this, or it showed only the window and missed every loop.
|
|
842
|
+
*/
|
|
843
|
+
function machineRuns(): Array<{ id: string; source: string; event: string; label: string; startedAt: string; promptPreview: string; sessionFile?: string; check: boolean; inHost: boolean }> {
|
|
844
|
+
return [
|
|
845
|
+
...triggers.runningList().map((r) => ({ id: r.traceId, source: r.sourceLabel, event: r.eventLabel, label: r.sourceLabel === "local:dynamic" ? "trigger-check" : r.sourceLabel, startedAt: r.startedAt, promptPreview: r.promptPreview, check: r.sourceLabel === "local:dynamic", inHost: false })),
|
|
846
|
+
...scheduler.runningRuns().map((r) => ({ id: r.runId, source: "Cron", event: r.jobId, label: r.label, startedAt: r.startedAt, promptPreview: r.promptPreview, sessionFile: r.sessionFile, check: false, inHost: false })),
|
|
847
|
+
...(hostSnapshot?.runs ?? []).map((r) => ({ id: r.runId, source: "Cron", event: r.jobId, label: r.label, startedAt: r.startedAt, promptPreview: r.promptPreview, sessionFile: r.sessionFile, check: false, inHost: true })),
|
|
848
|
+
// A run the background process started since this window last asked it: its marker is in
|
|
849
|
+
// jobs.json at once (see watchStore), the snapshot only on the next tick.
|
|
850
|
+
...storeRunning().filter((r) => !scheduler.runningRuns().some((x) => x.runId === r.id) && !(hostSnapshot?.runs ?? []).some((x) => x.runId === r.id)),
|
|
851
|
+
...(hostSnapshot?.checks ?? []).map((c) => ({ id: c.traceId, source: c.sourceLabel, event: c.eventLabel, label: c.sourceLabel === "local:dynamic" ? "trigger-check" : c.sourceLabel, startedAt: c.startedAt, promptPreview: `${c.sourceLabel}/${c.eventLabel} in ${c.cwd}`, check: c.sourceLabel === "local:dynamic", inHost: true })),
|
|
852
|
+
];
|
|
853
|
+
}
|
|
854
|
+
/** Runs the store has a `running` marker for (src/store.ts RunningMarker), in machineRuns' shape. */
|
|
855
|
+
function storeRunning(): Array<{ id: string; source: string; event: string; label: string; startedAt: string; promptPreview: string; check: boolean; inHost: boolean }> {
|
|
856
|
+
try {
|
|
857
|
+
// A marker whose process is gone is not a run (the background process clears it on its tick).
|
|
858
|
+
return scheduler.store.load().filter((j) => j.running && (!j.running.host || j.running.host === os.hostname()) && pidAlive(j.running.pid)).map((j) => ({ id: j.running!.runId, source: "Cron", event: j.id, label: j.name ?? j.id, startedAt: j.running!.startedAt, promptPreview: previewRedacted(j.prompt, 80), check: false, inHost: true }));
|
|
859
|
+
} catch {
|
|
860
|
+
return []; // an unreadable store is said elsewhere (storeReadFailed)
|
|
861
|
+
}
|
|
862
|
+
}
|
|
863
|
+
/** The latest rule check, this window's or the host's. */
|
|
864
|
+
const lastPoll = () => {
|
|
865
|
+
const mine = triggers.lastPoll;
|
|
866
|
+
// The host checks every project's rules; another project's result is not this one's to show.
|
|
867
|
+
const theirs = hostSnapshot?.lastPolls?.find((p) => sameProject(p.cwd, session.cwd));
|
|
868
|
+
if (!mine) return theirs;
|
|
869
|
+
if (!theirs) return mine;
|
|
870
|
+
return Date.parse(theirs.at) > Date.parse(mine.at) ? theirs : mine;
|
|
871
|
+
};
|
|
872
|
+
/** How far this window has read the background process's warnings (src/host-control-channel.ts noticesToShow). */
|
|
873
|
+
let noticesSeen: { pid: number; seq: number } | undefined;
|
|
874
|
+
function showNotices(snapshot: HostSnapshot, pid: number): void {
|
|
875
|
+
const out = noticesToShow(snapshot.notices ?? [], noticesSeen, pid, session.cwd, sameProject);
|
|
876
|
+
noticesSeen = out.seen;
|
|
877
|
+
for (const n of out.show) diagnostic(n.text);
|
|
878
|
+
}
|
|
879
|
+
/**
|
|
880
|
+
* This project's latest rule check, as a card, when it failed: checks run in the background now.
|
|
881
|
+
* A match is a finding in the inbox and the panel's Polling line, as in showTriggerCard.
|
|
882
|
+
*/
|
|
883
|
+
let checkShown: string | undefined;
|
|
884
|
+
let checkLooked = false;
|
|
885
|
+
function showCheckCard(snapshot: HostSnapshot): void {
|
|
886
|
+
const poll = snapshot.lastPolls?.find((p) => sameProject(p.cwd, session.cwd));
|
|
887
|
+
// The first look records what is there, even nothing: the first check after it is news.
|
|
888
|
+
const first = !checkLooked;
|
|
889
|
+
checkLooked = true;
|
|
890
|
+
if (!poll || poll.traceId === checkShown) return;
|
|
891
|
+
checkShown = poll.traceId;
|
|
892
|
+
if (first || poll.outcome === "no match" || /^matched/.test(poll.outcome) || !lastCtx?.hasUI) return; // the first look only records where things are
|
|
893
|
+
const title = `trigger ${poll.sourceLabel} FAILED (${poll.outcome})`;
|
|
894
|
+
const lines = [...(poll.summary ? previewRedacted(poll.summary, 600).split("\n").slice(0, 8) : []), `trace ${poll.traceId.slice(0, 8)} · /triggers audit`];
|
|
895
|
+
if (lastCtx.mode === "tui") show(lastCtx, title, lines);
|
|
896
|
+
else lastCtx.ui.notify([title, ...lines].join("\n"), "warning");
|
|
897
|
+
}
|
|
898
|
+
/** This window's count of failed probes of the host's socket (src/host-control.ts decideEviction). */
|
|
899
|
+
let hostProbeFailures = 0;
|
|
900
|
+
let hostTickRunning = false;
|
|
901
|
+
/** The host pid the badge, panel and snapshot last showed: one that came, went or changed is redrawn. */
|
|
902
|
+
let shownHostPid: number | undefined;
|
|
903
|
+
|
|
904
|
+
/** What there is for the host to run, as `ensureHost` counts it. */
|
|
905
|
+
function automationCounts(): { loops: number; rules: number; pushServers: number; closedSessionJobs: number; closedMailboxes: number } {
|
|
906
|
+
// Each store on its own: a damaged rules file stopped the loops too, because nothing then started
|
|
907
|
+
// the background process.
|
|
908
|
+
const rules = loadOrNone(() => triggers.store.load()).filter((r) => r.enabled).length;
|
|
909
|
+
const jobs = loadOrNone(() => scheduler.store.load()).filter((j) => j.enabled);
|
|
910
|
+
return {
|
|
911
|
+
loops: jobs.filter((j) => j.stateful).length,
|
|
912
|
+
rules,
|
|
913
|
+
pushServers: hostPushWork(loadMcpConfigFiles({ dir, projectTrusted: false }).servers, rules),
|
|
914
|
+
closedSessionJobs: jobs.filter((j) => !j.stateful && closedHere(j.sessionId)).length,
|
|
915
|
+
closedMailboxes: requests.sessions().filter((id) => closedHere(id)).length,
|
|
916
|
+
};
|
|
917
|
+
}
|
|
918
|
+
|
|
919
|
+
/**
|
|
920
|
+
* Keep the host there: probe it (only a tick's probe counts toward evicting a wedged one, so a
|
|
921
|
+
* host that is still starting when a window opens is never killed for it), then start it, or ask
|
|
922
|
+
* an older one to make way, when there is automation to run. Returns what ensure decided.
|
|
923
|
+
*/
|
|
924
|
+
async function hostTick(fromTick: boolean, asked = false, wait = true): Promise<Awaited<ReturnType<typeof ensureHost>> | undefined> {
|
|
925
|
+
if (hostTickRunning) return undefined;
|
|
926
|
+
hostTickRunning = true;
|
|
927
|
+
try {
|
|
928
|
+
const rec = liveHostRecord();
|
|
929
|
+
if (rec) {
|
|
930
|
+
const answer = await askHost(dir, { op: "status" });
|
|
931
|
+
hostSnapshot = answer?.ok ? answer.snapshot : undefined;
|
|
932
|
+
if (hostSnapshot) {
|
|
933
|
+
showNotices(hostSnapshot, rec.pid);
|
|
934
|
+
showCheckCard(hostSnapshot);
|
|
522
935
|
}
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
const label = j.name ?? j.id.slice(0, 12);
|
|
537
|
-
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}`) : ""));
|
|
936
|
+
const verdict = decideEviction({ failures: hostProbeFailures, probeOk: !!answer?.ok, fromTick, pidMatches: hostServiceAlive(rec), heartbeatFresh: heartbeatFresh(rec, Date.now()) });
|
|
937
|
+
hostProbeFailures = verdict.failures;
|
|
938
|
+
if (answer?.ok) hostChannelBroken = undefined;
|
|
939
|
+
if (verdict.channelBroken && !hostChannelBroken) {
|
|
940
|
+
// An answer that is an error is the process saying what is wrong (EMFILE, say): show that.
|
|
941
|
+
const said = answer && !answer.ok && answer.error ? `answers only with an error (${answer.error})` : "does not answer";
|
|
942
|
+
hostChannelBroken = `pid ${rec.pid} is running (its heartbeat is fresh) but its control socket ${said}; see ${homeRel(path.join(dir, HOST_LOG))}`;
|
|
943
|
+
log.write("warn", `pi-loops (pid ${rec.pid}) does not answer on its socket but is still ticking; leaving it running`);
|
|
944
|
+
}
|
|
945
|
+
if (verdict.evict) {
|
|
946
|
+
log.write("warn", `pi-loops (pid ${rec.pid}) stopped answering for three ticks; replacing it`);
|
|
947
|
+
// Waited on, and killed, only while that pid is still this host (its start time and entry).
|
|
948
|
+
await evictHost(dir, rec.pid, { alive: () => hostServiceAlive(rec) });
|
|
538
949
|
}
|
|
539
|
-
if (jobs.length > PANEL_RULE_LIMIT) lines.push(dim(`… ${jobs.length - PANEL_RULE_LIMIT} more`));
|
|
540
950
|
}
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
951
|
+
const out = await ensureHost({ dir, packageDir: PACKAGE_DIR, piPackage: piPackageDir(), startedBy: `the pi window pid ${process.pid}`, automation: automationCounts(), auto: config.hostAuto, stopped: readUiPrefs(dir).host === "stopped", asked, myVersion: PI_LOOPS_VERSION, myPiVersion: PI_VERSION, wait });
|
|
952
|
+
const before = hostProblemText();
|
|
953
|
+
// The spawn log's reason in both branches: the attempt's own error and the backoff's are the
|
|
954
|
+
// same failure, and wording it twice warned twice.
|
|
955
|
+
const startError = () => readSpawnLog(dir).lastError ?? out.error ?? `pi-loops did not start; see ${homeRel(path.join(dir, HOST_LOG))}`;
|
|
956
|
+
// Started without waiting (a quitting window): whether it came up is the next window's to see.
|
|
957
|
+
if (out.action.kind === "spawn" && out.pid && out.up === undefined && !out.error) log.info(`starting pi-loops (pid ${out.pid}); not waiting for it`);
|
|
958
|
+
else if (out.action.kind === "spawn") {
|
|
959
|
+
hostState = out.up ? undefined : { kind: "start-failed", detail: startError() };
|
|
960
|
+
if (out.up) log.info(`started pi-loops (pid ${out.pid})`);
|
|
961
|
+
} else if (out.action.kind === "upgrade") log.info("asked pi-loops to make way for this version");
|
|
962
|
+
else if (out.action.reason === "running" || out.action.reason === "nothing to run" || out.action.reason.startsWith("a newer pi-loops")) hostState = undefined;
|
|
963
|
+
else if (out.action.reason.startsWith("backing off")) hostState = { kind: "start-failed", detail: startError() };
|
|
964
|
+
else if (out.action.reason.startsWith("paused:")) hostState = { kind: "crash-paused", detail: out.action.reason.slice("paused: ".length) };
|
|
965
|
+
if (hostChannelBroken && liveHostRecord()) hostState = { kind: "channel-broken", detail: hostChannelBroken };
|
|
966
|
+
const now = hostProblemText();
|
|
967
|
+
// A crash-pause is said by the pause line below, in the same words: once, not twice.
|
|
968
|
+
if (now && now !== before && hostState?.kind !== "start-failed" && hostState?.kind !== "crash-paused" && lastCtx?.hasUI) lastCtx.ui.notify(`[cron] ${now}`, "warning");
|
|
969
|
+
// A pause is said once when it begins (another window stopped the host, a config edit), not per tick.
|
|
970
|
+
const pause = hostHasWork() ? hostPauseReason() : undefined;
|
|
971
|
+
if (pause && pause !== shownPause && lastCtx?.hasUI) lastCtx.ui.notify(`[cron] loops paused: ${pause}`, "warning");
|
|
972
|
+
if (pause !== shownPause) {
|
|
973
|
+
shownPause = pause;
|
|
974
|
+
refreshBadge();
|
|
545
975
|
}
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
976
|
+
// Whoever started or stopped it: another window, `pi-loops host stop`, a crash.
|
|
977
|
+
const livePid = liveHostRecord()?.pid;
|
|
978
|
+
if (now !== before || livePid !== shownHostPid) {
|
|
979
|
+
shownHostPid = livePid;
|
|
980
|
+
refreshBadge();
|
|
981
|
+
}
|
|
982
|
+
return out;
|
|
983
|
+
} finally {
|
|
984
|
+
hostTickRunning = false;
|
|
985
|
+
}
|
|
552
986
|
}
|
|
553
987
|
|
|
554
988
|
/**
|
|
555
|
-
*
|
|
556
|
-
*
|
|
557
|
-
*
|
|
989
|
+
* Loops run in the host, so their cards come from the run log: on each tick, every run of this
|
|
990
|
+
* project that finished since the last look. The carded ones (src/run-card.ts) are shown, at most
|
|
991
|
+
* five, and the rest of a burst is one line; the panel lists the latest runs whatever they carded.
|
|
558
992
|
*/
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
993
|
+
let runsShownAfter = stamp();
|
|
994
|
+
function showFinishedRuns(): void {
|
|
995
|
+
const ctx = lastCtx;
|
|
996
|
+
if (!ctx?.hasUI) return;
|
|
997
|
+
const all = scheduler.store.listRuns(undefined, FRESH_RUNS_READ);
|
|
998
|
+
recentRuns = panelRuns(all, (cwd) => sameProject(cwd, session.cwd), PANEL_RUN_LIMIT);
|
|
999
|
+
const { fresh, cursor } = freshRuns(all, runsShownAfter);
|
|
1000
|
+
if (!fresh.length) return;
|
|
1001
|
+
runsShownAfter = cursor;
|
|
1002
|
+
// The record's own cwd and name: a one-shot loop is removed from jobs.json by the time its
|
|
1003
|
+
// record is read here, and its card is still this conversation's.
|
|
1004
|
+
const jobs = new Map(scheduler.store.load().map((j) => [j.id, j]));
|
|
1005
|
+
const label = (r: RunRecord) => jobs.get(r.jobId)?.name ?? r.jobName;
|
|
1006
|
+
// Which conversation each card belongs to is src/run-card.ts: where a person asked, else where
|
|
1007
|
+
// the loop was made.
|
|
1008
|
+
const here = binding.here().sessionId ?? session.sessionId;
|
|
1009
|
+
const placed = placeRunCards(runCardsHere(fresh, { sessionId: here, inProject: (cwd) => sameProject(cwd, session.cwd) }));
|
|
1010
|
+
for (const record of placed.cards) showRunCard(ctx, label(record), record);
|
|
1011
|
+
if (placed.more) ctx.ui.notify(burstLine(placed), placed.failed ? "warning" : "info");
|
|
562
1012
|
}
|
|
563
1013
|
|
|
564
1014
|
/**
|
|
565
1015
|
* A corrupt or truncated store must not take the session down with it. Both of these read the
|
|
566
|
-
* stores, and both are reached from the tick (`
|
|
1016
|
+
* stores, and both are reached from the tick (`onTick` → `refreshBadge`) which runs as
|
|
567
1017
|
* `void this.tick()` — pi installs no `unhandledRejection` handler, so a throw here kills pi.
|
|
568
1018
|
*/
|
|
569
1019
|
function refreshBadge(): void {
|
|
@@ -578,23 +1028,28 @@ export default function piLoops(pi: ExtensionAPI) {
|
|
|
578
1028
|
* The same state the TUI panel draws, written into the session as a `pi_loops_snapshot` entry so
|
|
579
1029
|
* a front end that is not a terminal can read it. Everything here lives in this process — which
|
|
580
1030
|
* MCP servers actually connected, what they exposed, which tools are active, whether this pi
|
|
581
|
-
*
|
|
1031
|
+
* the host runs — and none of it is in the store files a reader could open instead.
|
|
582
1032
|
*/
|
|
583
1033
|
function snapshotData(): Record<string, unknown> {
|
|
584
1034
|
const rules = triggers.store.load().filter((r) => sameProject(r.cwd, session.cwd));
|
|
585
1035
|
const jobs = scheduler.store.load().filter((j) => sameProject(j.cwd, session.cwd));
|
|
586
|
-
const poll =
|
|
1036
|
+
const poll = lastPoll();
|
|
587
1037
|
return {
|
|
588
1038
|
version: PI_LOOPS_VERSION,
|
|
589
1039
|
cwd: session.cwd,
|
|
590
1040
|
host: os.hostname(),
|
|
591
1041
|
scheduler: {
|
|
592
1042
|
running: schedulerStarted,
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
1043
|
+
host: (() => {
|
|
1044
|
+
const rec = liveHostRecord();
|
|
1045
|
+
return rec ? { running: true, pid: rec.pid } : { running: false };
|
|
1046
|
+
})(),
|
|
1047
|
+
runs: machineRuns().filter((r) => !r.check).length,
|
|
1048
|
+
checks: machineRuns().filter((r) => r.check).length,
|
|
596
1049
|
deduped: triggers.dedupedCount,
|
|
597
1050
|
},
|
|
1051
|
+
// Why nothing runs, when that is so: the browser panel shows it in place of next runs.
|
|
1052
|
+
paused: hostHasWork() ? hostPauseReason() : undefined,
|
|
598
1053
|
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() },
|
|
599
1054
|
mcp: mcpSources.map((src) => ({
|
|
600
1055
|
name: src.config.name,
|
|
@@ -622,7 +1077,7 @@ export default function piLoops(pi: ExtensionAPI) {
|
|
|
622
1077
|
|
|
623
1078
|
/** Write the snapshot when `src/snapshot.ts` says this change deserves a permanent entry. */
|
|
624
1079
|
function emitSnapshot(force = false): void {
|
|
625
|
-
if (!
|
|
1080
|
+
if (!mayAppend()) return;
|
|
626
1081
|
try {
|
|
627
1082
|
const data = snapshotData();
|
|
628
1083
|
const fingerprint = snapshotFingerprint(data);
|
|
@@ -638,6 +1093,16 @@ export default function piLoops(pi: ExtensionAPI) {
|
|
|
638
1093
|
}
|
|
639
1094
|
}
|
|
640
1095
|
|
|
1096
|
+
/** A store that cannot be read counts as empty where a count only decides what to say or start, and says so once. */
|
|
1097
|
+
function loadOrNone<T>(read: () => T[]): T[] {
|
|
1098
|
+
try {
|
|
1099
|
+
return read();
|
|
1100
|
+
} catch (err: any) {
|
|
1101
|
+
storeReadFailed(err);
|
|
1102
|
+
return [];
|
|
1103
|
+
}
|
|
1104
|
+
}
|
|
1105
|
+
|
|
641
1106
|
let storeErrorShown: string | undefined;
|
|
642
1107
|
function storeReadFailed(err: any): void {
|
|
643
1108
|
const message = err?.message ?? String(err);
|
|
@@ -660,45 +1125,33 @@ export default function piLoops(pi: ExtensionAPI) {
|
|
|
660
1125
|
// going quiet.
|
|
661
1126
|
const failing = failingSummary(scheduler.store.load());
|
|
662
1127
|
if (failing) parts.push(failing);
|
|
663
|
-
const running =
|
|
1128
|
+
const running = machineRuns().map((r) => r.label);
|
|
664
1129
|
if (running.length) parts.push(`running: ${running.join(", ")}`);
|
|
1130
|
+
// What waits for this conversation and pi does not have yet (a prompt behind a busy turn, after
|
|
1131
|
+
// Esc): the one place it shows without a command. `/cron queue` lists and cancels it.
|
|
1132
|
+
const here = binding.here().sessionId;
|
|
1133
|
+
const queued = here ? requests.list(here).filter((r) => r.kind === "input" && !executor.handedOver.has(r.id)).length : 0;
|
|
1134
|
+
if (queued) parts.push(`queued ${queued}`);
|
|
665
1135
|
const attention = mcpSources.filter((s) => s.status.state === "disconnected" || s.status.state === "auth_failed").length;
|
|
666
1136
|
if (attention) parts.push(`mcp: ${attention} source${attention === 1 ? "" : "s"} down`);
|
|
667
|
-
|
|
1137
|
+
const paused = hostPauseReason();
|
|
1138
|
+
if (paused && hostHasWork()) parts.push(`loops paused: ${paused}`);
|
|
1139
|
+
else if (hostState?.kind === "channel-broken") parts.push(HOST_STATE_BADGE["channel-broken"]);
|
|
668
1140
|
lastCtx.ui.setStatus(STATUS_KEY, parts.length ? parts.join(" · ") : undefined);
|
|
669
1141
|
}
|
|
670
1142
|
|
|
671
|
-
/**
|
|
672
|
-
|
|
673
|
-
|
|
674
|
-
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
|
|
678
|
-
|
|
679
|
-
|
|
680
|
-
|
|
681
|
-
|
|
682
|
-
|
|
683
|
-
const cost = record.usage?.cost ? ` · $${record.usage.cost.toFixed(3)}` : "";
|
|
684
|
-
const title = record.ok
|
|
685
|
-
? `cron ${label} · ${secs}s${cost} · ${findings.length} finding${findings.length === 1 ? "" : "s"}${record.checker?.ok ? " (verified)" : ""}${record.stateUpdated ? " · state updated" : ""}`
|
|
686
|
-
: `cron ${label} FAILED · ${secs}s`;
|
|
687
|
-
const lines: string[] = [];
|
|
688
|
-
if (!record.ok) lines.push(`! ${record.error ?? "unknown error"}`);
|
|
689
|
-
lines.push(...runNotes(record as any));
|
|
690
|
-
if (record.summary) lines.push(...record.summary.split("\n").slice(0, 6));
|
|
691
|
-
for (const f of findings) lines.push(`• ${previewRedacted(f, 160)}`);
|
|
692
|
-
if (record.droppedFindings) lines.push(`(${record.droppedFindings} more findings dropped: per-run cap)`);
|
|
693
|
-
if (record.checker) {
|
|
694
|
-
const checker = record.checker;
|
|
695
|
-
if (!checker.ok) lines.push(`checker FAILED (${checker.error}); findings entered unverified`);
|
|
696
|
-
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`);
|
|
697
|
-
for (const drop of checker.dropped) lines.push(` ✗ ${previewRedacted(drop.text, 100)} — ${previewRedacted(drop.reason, 100)}`);
|
|
698
|
-
}
|
|
699
|
-
lines.push(`/cron trace ${label} · /inbox`);
|
|
700
|
-
if (ctx.mode === "tui") show(ctx, title, lines);
|
|
701
|
-
else ctx.ui.notify([title, ...lines].join("\n"), record.ok ? "info" : "warning");
|
|
1143
|
+
/**
|
|
1144
|
+
* A finished run in this conversation (src/run-card.ts): a loop's first three runs and every failed
|
|
1145
|
+
* run are one compact card; every other run is a line in the panel only. A failure is also said as
|
|
1146
|
+
* a warning, so it is seen when the card has scrolled by.
|
|
1147
|
+
*/
|
|
1148
|
+
function showRunCard(ctx: ExtensionContext, name: string | undefined, record: RunRecord): void {
|
|
1149
|
+
// A window that does not hold the conversation writes nothing into it (see mayAppend).
|
|
1150
|
+
const how = runCardDelivery(record, { mode: ctx.mode, mayAppend: mayAppend() });
|
|
1151
|
+
const line = runLine(record, name);
|
|
1152
|
+
const lines = runCardLines(record);
|
|
1153
|
+
if (how.save) pi.appendEntry<ViewData>(VIEW_ENTRY, { title: line, lines });
|
|
1154
|
+
if (how.notify) ctx.ui.notify([line, ...lines].join("\n"), how.notify);
|
|
702
1155
|
}
|
|
703
1156
|
|
|
704
1157
|
/* ------------------------------------------------------------ views */
|
|
@@ -712,29 +1165,31 @@ export default function piLoops(pi: ExtensionAPI) {
|
|
|
712
1165
|
});
|
|
713
1166
|
|
|
714
1167
|
function show(ctx: ExtensionContext, title: string, lines: string[]): void {
|
|
715
|
-
|
|
1168
|
+
// A window that does not hold the conversation writes nothing into it (see mayAppend).
|
|
1169
|
+
if (ctx.mode === "tui" && mayAppend()) pi.appendEntry<ViewData>(VIEW_ENTRY, { title, lines });
|
|
716
1170
|
else if (ctx.hasUI) ctx.ui.notify([title, ...lines].join("\n"), "info");
|
|
717
1171
|
else console.log([title, ...lines].join("\n"));
|
|
718
1172
|
}
|
|
719
1173
|
|
|
720
1174
|
const short = (s: string, n: number) => (s.length > n ? `${s.slice(0, n - 1)}…` : s);
|
|
721
|
-
const homeRel = (p: string) =>
|
|
722
|
-
const home = process.env.HOME ?? "";
|
|
723
|
-
return home && p.startsWith(home) ? `~${p.slice(home.length)}` : p;
|
|
724
|
-
};
|
|
1175
|
+
const homeRel = (p: string) => projectLabel(p, process.env.HOME ?? "");
|
|
725
1176
|
|
|
726
|
-
function jobLines(jobs: LoopJob[]): string[] {
|
|
1177
|
+
function jobLines(jobs: LoopJob[], showCwd = false): string[] {
|
|
727
1178
|
// Read once for the whole listing: the run log and the inbox are each one file, and a
|
|
728
1179
|
// project with a dozen loops would otherwise parse them a dozen times per `/cron`. The shape
|
|
729
1180
|
// of the lines is src/job-lines.ts, where a test can see a marker go.
|
|
730
1181
|
const runs = jobs.some((j) => j.stateful) ? scheduler.store.allRuns() : [];
|
|
731
1182
|
const inbox = jobs.some((j) => j.stateful) ? scheduler.inbox.list() : [];
|
|
732
|
-
return cronJobLines(jobs, { runs, inbox,
|
|
1183
|
+
return cronJobLines(jobs, { runs, inbox, requests: (sessionId) => requests.list(sessionId), view: scheduler.ownerView(), now: Date.now(), home: process.env.HOME ?? "", cwdExists: (cwd) => fs.existsSync(cwd), showCwd });
|
|
733
1184
|
}
|
|
734
1185
|
|
|
735
1186
|
/* --------------------------------------------------------- creation */
|
|
736
1187
|
|
|
737
|
-
|
|
1188
|
+
/**
|
|
1189
|
+
* A slash command's job: the person typed it, so it gets this chat's built-ins even when a scheduled
|
|
1190
|
+
* turn happens to be running (pi runs extension commands while it streams). The tools' host caps.
|
|
1191
|
+
*/
|
|
1192
|
+
const createJob = (input: CreateJobInput, scope?: JobScope) => createLoopJob({ ...toolHost, session: () => session }, input, scope);
|
|
738
1193
|
|
|
739
1194
|
/**
|
|
740
1195
|
* The `cron_control_plane` audit: every add / enable / disable / remove, from a slash
|
|
@@ -745,6 +1200,8 @@ export default function piLoops(pi: ExtensionAPI) {
|
|
|
745
1200
|
const next = after?.enabled ? computeNext({ schedule: after.schedule, createdAt: Date.parse(after.createdAt), lastFiredAt: after.lastFiredAt ? Date.parse(after.lastFiredAt) : undefined }, Date.now()) : undefined;
|
|
746
1201
|
// pi.appendEntry returns no id; mint one so tool results can point at the entry.
|
|
747
1202
|
const auditEntryId = newId("audit");
|
|
1203
|
+
// The scheduler's own retirements happen on every window's tick: a viewer leaves them to the holder.
|
|
1204
|
+
if (actor === "scheduler" && !mayAppend()) return auditEntryId;
|
|
748
1205
|
pi.appendEntry("cron_control_plane", {
|
|
749
1206
|
audit_entry_id: auditEntryId,
|
|
750
1207
|
op,
|
|
@@ -771,25 +1228,25 @@ export default function piLoops(pi: ExtensionAPI) {
|
|
|
771
1228
|
" --verify: maker/checker — a second adversarial sub-agent reviews findings before they enter /inbox (--checker-model <provider/id> to use another model)",
|
|
772
1229
|
" 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)",
|
|
773
1230
|
"/cron enable|disable|remove <n|id|name> /cron run <n|id|name> fire now",
|
|
774
|
-
"/cron set <n|id|name> [--model <p/id>|-] [--thinking <lvl>|-] [--timeout <dur>|-] [--name <n>|-] change what a job runs with (- =
|
|
1231
|
+
"/cron set <n|id|name> [--model <p/id>|-] [--thinking <lvl>|-] [--timeout <dur>|-] [--name <n>|-] [--tools a,b|-] change what a job runs with (- = clear it: a loop then uses pi's default, a plain job its conversation's; --tools - uncaps a plain job's turns)",
|
|
775
1232
|
' 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',
|
|
1233
|
+
"/cron queue [cancel <n|id>|clear] what waits to run in this conversation while the agent is busy (a plain job's prompt, a claim, a rule's result), numbered; cancel one by its number or id, or clear them all",
|
|
776
1234
|
"/cron state <n|id|name> the loop's notes (state spine)",
|
|
777
1235
|
"/cron runs [n|id|name] recent runs /cron trace [n|id|name] [k] [checker] k-th latest run's transcript (maker, or its checker)",
|
|
778
|
-
"/cron scheduler
|
|
779
|
-
"/cron snapshot what only this process knows — connected MCP servers and their tools,
|
|
1236
|
+
"/cron scheduler this window, its conversation, pi-loops /cron panel on|off side panel above the editor",
|
|
1237
|
+
"/cron snapshot what only this process knows — connected MCP servers and their tools, whether pi-loops runs — written into the session, for a front end that is not a terminal",
|
|
780
1238
|
"/cron gc remove plain jobs whose session was deleted (they are parked as disabled first)",
|
|
781
1239
|
"/cron cost [today|7d|all] what automation has cost, by job, and today's budget if one is set",
|
|
782
1240
|
"/cron clear <n|id|name> clear a stuck `running` marker left by a process that is gone",
|
|
783
1241
|
"/cron remove <ref> [--purge] remove a job; its loop state is kept unless --purge",
|
|
784
1242
|
"/cron disable --all pause every job in this project (--all-projects for the machine); /cron enable --all resumes",
|
|
785
|
-
"/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",
|
|
786
1243
|
"/inbox triage findings from stateful jobs (/inbox help)",
|
|
787
1244
|
`/session-export [path] [--exclude-triggers] transcript + this project's cron jobs, trigger rules and loop state as one ${ARCHIVE_EXT} archive`,
|
|
788
1245
|
"/session-import <path> [--activate-triggers=off|ask|on] [--cwd <dir>] [--resume] restore it here (automation stays off unless activated)",
|
|
789
1246
|
];
|
|
790
1247
|
|
|
791
1248
|
const cronCompletions = (prefix: string) => {
|
|
792
|
-
const subs = ["add", "list", "all", "enable", "disable", "remove", "set", "
|
|
1249
|
+
const subs = ["add", "list", "all", "enable", "disable", "remove", "set", "queue", "clear", "cost", "gc", "run", "state", "runs", "trace", "scheduler", "panel", "snapshot", "help"];
|
|
793
1250
|
const items = subs.filter((s) => s.startsWith(prefix)).map((s) => ({ value: s, label: s }));
|
|
794
1251
|
return items.length ? items : null;
|
|
795
1252
|
};
|
|
@@ -807,7 +1264,7 @@ export default function piLoops(pi: ExtensionAPI) {
|
|
|
807
1264
|
// Every subcommand, by name only: with five more of them the argument spellings no longer fit
|
|
808
1265
|
// on a line a notification can show, and `/cron help` has them all. A name left out of here
|
|
809
1266
|
// is a subcommand nobody finds, which is the worse of the two.
|
|
810
|
-
const CRON_USAGE = "[list|all|add|set|run|enable|disable|remove|state|runs|trace|cost|clear|gc|
|
|
1267
|
+
const CRON_USAGE = "[list|all|add|set|run|queue|enable|disable|remove|state|runs|trace|cost|clear|gc|scheduler|panel|snapshot|help] — /cron help for the arguments";
|
|
811
1268
|
try {
|
|
812
1269
|
switch (sub) {
|
|
813
1270
|
case "":
|
|
@@ -817,33 +1274,71 @@ export default function piLoops(pi: ExtensionAPI) {
|
|
|
817
1274
|
case "all": {
|
|
818
1275
|
const all = jobs();
|
|
819
1276
|
if (!all.length) {
|
|
820
|
-
show(ctx, "
|
|
1277
|
+
show(ctx, "Loops: none", ['/cron add [--stateful] "0 9 * * *" <prompt> (/cron help for more)']);
|
|
821
1278
|
return;
|
|
822
1279
|
}
|
|
823
1280
|
if (sub === "all") {
|
|
824
|
-
show(ctx, `
|
|
1281
|
+
show(ctx, `Loops (this machine, ${all.length}, times ${localOffset(Date.now())}):`, jobLines(all, true));
|
|
825
1282
|
return;
|
|
826
1283
|
}
|
|
827
1284
|
const here = all.filter((j) => sameProject(j.cwd, session.cwd));
|
|
828
1285
|
const elsewhere = all.length - here.length;
|
|
829
1286
|
const lines = here.length ? jobLines(here) : ["(none in this project)"];
|
|
830
|
-
if (elsewhere) lines.push(`+ ${elsewhere
|
|
831
|
-
show(ctx, `
|
|
1287
|
+
if (elsewhere) lines.push(`+ ${plural(elsewhere, "loop")} in other projects — /cron all`);
|
|
1288
|
+
show(ctx, `Loops (${homeRel(session.cwd)}, ${here.length}, times ${localOffset(Date.now())}):`, lines);
|
|
832
1289
|
return;
|
|
833
1290
|
}
|
|
834
1291
|
case "help":
|
|
835
1292
|
show(ctx, "/cron", CRON_HELP);
|
|
836
1293
|
return;
|
|
1294
|
+
case "queue": {
|
|
1295
|
+
// What waits for this conversation, and the way to take it out: the file a plain job's
|
|
1296
|
+
// prompt, a claim or a rule's result sits in while pi is busy, and runs from once pi is
|
|
1297
|
+
// idle (Esc stops the turn, not the queue). The listing and the pick are src/job-lines.ts.
|
|
1298
|
+
const sessionId = binding.here().sessionId;
|
|
1299
|
+
if (!sessionId) {
|
|
1300
|
+
ctx.ui.notify("no conversation here (--no-session): nothing can be queued for it", "warning");
|
|
1301
|
+
return;
|
|
1302
|
+
}
|
|
1303
|
+
const { sub: action, rest: ref } = splitCommand(rest);
|
|
1304
|
+
const rows = requests.list(sessionId);
|
|
1305
|
+
const names = new Map(jobs().map((j) => [j.id, j.name ?? j.id] as const));
|
|
1306
|
+
if (action === "" || action === "list") {
|
|
1307
|
+
show(ctx, `Queued for this conversation (${rows.filter((r) => r.kind === "input").length}):`, [...queueLines(rows, { now: Date.now(), jobName: (id) => names.get(id), inFlight: executor.handedOver }), "/cron queue cancel <n|id> /cron queue clear"]);
|
|
1308
|
+
return;
|
|
1309
|
+
}
|
|
1310
|
+
if (action !== "cancel" && action !== "clear") {
|
|
1311
|
+
ctx.ui.notify("usage: /cron queue [cancel <n|id>|clear]", "warning");
|
|
1312
|
+
return;
|
|
1313
|
+
}
|
|
1314
|
+
const targets = action === "clear" ? rows.filter((r) => r.kind === "input") : [pickQueued(rows, ref)].filter((r): r is SessionRequest => r !== undefined);
|
|
1315
|
+
if (!targets.length) {
|
|
1316
|
+
ctx.ui.notify(action === "clear" ? "nothing is queued for this conversation" : `nothing queued matches '${ref}'; /cron queue lists what is`, "warning");
|
|
1317
|
+
return;
|
|
1318
|
+
}
|
|
1319
|
+
// A cancel is a row too (src/engine.ts cancelRow): the holder removes the queued row and
|
|
1320
|
+
// audits it, whoever holds the conversation; one pi already has is left to pi, where Esc stops it.
|
|
1321
|
+
for (const row of targets) requests.append(cancelRow(sessionId, { id: row.id }, "window", createdBy()));
|
|
1322
|
+
const result = await drainRequests();
|
|
1323
|
+
const gone = targets.filter((row) => result?.removed.includes(row.id)).length;
|
|
1324
|
+
ctx.ui.notify(gone === targets.length ? `cancelled ${gone} queued item${gone === 1 ? "" : "s"}` : `cancelled ${gone} of ${targets.length}; the rest are handed to pi already (Esc stops the turn) or are for the window that has this conversation to cancel`, "info");
|
|
1325
|
+
refreshBadge();
|
|
1326
|
+
return;
|
|
1327
|
+
}
|
|
837
1328
|
case "panel": {
|
|
838
1329
|
const on = rest.trim() === "on" ? true : rest.trim() === "off" ? false : !panelOn;
|
|
839
1330
|
setPanelEnabled(on);
|
|
840
|
-
ctx.ui.notify(`panel ${on ? "on" : "off"} (
|
|
1331
|
+
ctx.ui.notify(`panel ${on ? "on" : "off"} (Rules / Loops / Runs / MCP above the editor)`, "info");
|
|
841
1332
|
return;
|
|
842
1333
|
}
|
|
843
1334
|
case "snapshot": {
|
|
844
1335
|
// The panel's contents as a `pi_loops_snapshot` session entry: which MCP servers
|
|
845
|
-
// connected, what they exposed, the active tools,
|
|
1336
|
+
// connected, what they exposed, the active tools, whether the host runs. A front end
|
|
846
1337
|
// that is not a terminal (src/web.mjs) reads that instead of the widget.
|
|
1338
|
+
if (!mayAppend()) {
|
|
1339
|
+
ctx.ui.notify(binding.standingAside ? "this window shows an older copy of this conversation and writes nothing into it; /resume this session first" : `this conversation is also ${openWhere(binding.holderKindOf(binding.here().sessionId ?? ""))}, which writes its snapshot; this window writes nothing into it`, "info");
|
|
1340
|
+
return;
|
|
1341
|
+
}
|
|
847
1342
|
emitSnapshot(true);
|
|
848
1343
|
ctx.ui.notify("wrote a pi_loops_snapshot entry to the session", "info");
|
|
849
1344
|
return;
|
|
@@ -866,9 +1361,19 @@ export default function piLoops(pi: ExtensionAPI) {
|
|
|
866
1361
|
});
|
|
867
1362
|
const next = computeNext({ schedule: job.schedule, createdAt: Date.parse(job.createdAt) }, Date.now());
|
|
868
1363
|
cronControlAudit("add", "slash", undefined, job);
|
|
1364
|
+
const dropped = toolsDropped(parsed.tools, job.tools);
|
|
869
1365
|
show(ctx, `added cron job ${job.id}${job.name ? ` "${job.name}"` : ""}`, [
|
|
870
|
-
`
|
|
871
|
-
|
|
1366
|
+
...(dropped.length ? [` dropped from --tools: ${dropped.join(", ")} (this chat does not have it)`] : []),
|
|
1367
|
+
// A paused background process runs no loop: no next run is promised for a run that will not
|
|
1368
|
+
// happen. This chat's own plain job runs here, paused or not.
|
|
1369
|
+
` schedule: ${formatSchedule(job.schedule)}${job.stateful && hostPauseReason() ? ` (paused: ${hostPauseReason()})` : next ? ` (next run ${formatLocal(next)})` : ""}`,
|
|
1370
|
+
...(job.stateful
|
|
1371
|
+
? [
|
|
1372
|
+
` mode: stateful loop (findings go to /inbox${job.verify ? " after the checker" : ""})`,
|
|
1373
|
+
// It runs elsewhere: say what goes with it, and what stays with this window.
|
|
1374
|
+
` runs in the background with ${job.tools ? `the tools ${job.tools.join(", ")}` : job.builtinTools ? `this chat's built-in tools (${job.builtinTools.join(", ") || "none"})` : "pi's default tools"}${job.model ? ` on ${job.model}` : ""}${!projectNeedsTrust(job.cwd) ? "" : isExactlyTrusted(getAgentDir(), job.cwd) ? ", and this project's own settings and servers" : "; this project's own settings and servers only once /trust saves trust for it"}; not this window's -e extensions or flags`,
|
|
1375
|
+
]
|
|
1376
|
+
: [" mode: inject-and-run (the result appears in this chat; while this chat is closed it still runs, in the background, and each run is a model turn)"]),
|
|
872
1377
|
` action: ${previewRedacted(job.prompt, 120)}`,
|
|
873
1378
|
]);
|
|
874
1379
|
refreshBadge();
|
|
@@ -877,11 +1382,15 @@ export default function piLoops(pi: ExtensionAPI) {
|
|
|
877
1382
|
case "run": {
|
|
878
1383
|
const job = pick(rest);
|
|
879
1384
|
if (!job) return;
|
|
880
|
-
|
|
881
|
-
|
|
882
|
-
|
|
883
|
-
|
|
884
|
-
|
|
1385
|
+
// A loop runs in the host; a plain job only in the window holding its conversation.
|
|
1386
|
+
let ran: true | string;
|
|
1387
|
+
if (job.stateful) {
|
|
1388
|
+
if (!liveHostRecord()) await hostTick(false);
|
|
1389
|
+
// Who asked, so the run's card comes back here whatever its run number (src/run-card.ts).
|
|
1390
|
+
const asker = binding.here().sessionId ?? session.sessionId;
|
|
1391
|
+
const answer = await askHost(dir, { op: "run", jobId: job.id, ...(asker ? { requestedBy: { sessionId: asker } } : {}) });
|
|
1392
|
+
ran = answer?.ok ? (answer.ran ?? true) : `did not start: pi-loops is not running${hostPauseReason() ? ` (${hostPauseReason()})` : hostState ? ` (${hostProblemText()})` : ""}; loops do not run in this window`;
|
|
1393
|
+
} else ran = await scheduler.runNow(job.id);
|
|
885
1394
|
// A refusal comes back as the reason, phrased to follow the job's name — the shape
|
|
886
1395
|
// this line has always had ("nightly is already running").
|
|
887
1396
|
ctx.ui.notify(ran === true ? `running ${job.name ?? job.id} in the background…` : `${job.name ?? job.id} ${ran}`, ran === true ? "info" : "warning");
|
|
@@ -901,19 +1410,25 @@ export default function piLoops(pi: ExtensionAPI) {
|
|
|
901
1410
|
for (const j of targets) {
|
|
902
1411
|
const after = await scheduler.store.update(j.id, (x) => {
|
|
903
1412
|
x.enabled = enable;
|
|
904
|
-
if (enable)
|
|
1413
|
+
if (enable) {
|
|
1414
|
+
x.lastError = undefined;
|
|
1415
|
+
x.interruptions = undefined;
|
|
1416
|
+
}
|
|
905
1417
|
});
|
|
906
1418
|
cronControlAudit(enable ? "enable" : "disable", "slash", j, after ?? j);
|
|
907
1419
|
}
|
|
908
1420
|
refreshBadge();
|
|
909
|
-
ctx.ui.notify(`${enable ? "enabled" : "disabled"} ${targets.length
|
|
1421
|
+
ctx.ui.notify(`${enable ? "enabled" : "disabled"} ${plural(targets.length, "loop")}${everywhere ? " on this machine" : " in this project"}`, "info");
|
|
910
1422
|
return;
|
|
911
1423
|
}
|
|
912
1424
|
const job = pick(rest);
|
|
913
1425
|
if (!job) return;
|
|
914
1426
|
const after = await scheduler.store.update(job.id, (j) => {
|
|
915
1427
|
j.enabled = enable;
|
|
916
|
-
if (enable)
|
|
1428
|
+
if (enable) {
|
|
1429
|
+
j.lastError = undefined;
|
|
1430
|
+
j.interruptions = undefined;
|
|
1431
|
+
}
|
|
917
1432
|
});
|
|
918
1433
|
cronControlAudit(enable ? "enable" : "disable", "slash", job, after ?? job);
|
|
919
1434
|
ctx.ui.notify(`${enable ? "enabled" : "disabled"} cron job ${job.id}${job.name ? ` "${job.name}"` : ""}`, "info");
|
|
@@ -949,29 +1464,8 @@ export default function piLoops(pi: ExtensionAPI) {
|
|
|
949
1464
|
return;
|
|
950
1465
|
}
|
|
951
1466
|
case "host": {
|
|
952
|
-
//
|
|
953
|
-
|
|
954
|
-
if (action === "stop") {
|
|
955
|
-
handOffOnQuit = false;
|
|
956
|
-
const pid = stopHost(dir);
|
|
957
|
-
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");
|
|
958
|
-
return;
|
|
959
|
-
}
|
|
960
|
-
if (action === "start") {
|
|
961
|
-
handOffOnQuit = true;
|
|
962
|
-
ctx.ui.notify("a background host will keep the clock when this pi quits (this pi owns it while open)", "info");
|
|
963
|
-
return;
|
|
964
|
-
}
|
|
965
|
-
const live = liveHost(dir);
|
|
966
|
-
const answer = live ? await askHost(dir, { op: "status" }) : undefined;
|
|
967
|
-
const hostSnapshot = answer?.ok ? answer.snapshot : undefined;
|
|
968
|
-
show(ctx, "Background host", [
|
|
969
|
-
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)",
|
|
970
|
-
...(hostSnapshot ? renderHostSnapshot(hostSnapshot) : []),
|
|
971
|
-
` 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)"}`,
|
|
972
|
-
` log: ${homeRel(path.join(dir, HOST_LOG))}`,
|
|
973
|
-
" /cron host start | stop",
|
|
974
|
-
]);
|
|
1467
|
+
// 0.22.8's spelling of /pi-loops start | stop | status.
|
|
1468
|
+
await backgroundCommand(rest.trim() || "status", ctx);
|
|
975
1469
|
return;
|
|
976
1470
|
}
|
|
977
1471
|
case "clear": {
|
|
@@ -984,11 +1478,11 @@ export default function piLoops(pi: ExtensionAPI) {
|
|
|
984
1478
|
return;
|
|
985
1479
|
}
|
|
986
1480
|
const marker = job.running;
|
|
987
|
-
const ok = await ctx.ui.confirm("Clear the running marker?", `${job.name ?? job.id} is marked as running since ${formatLocal(Date.parse(marker.startedAt))}
|
|
1481
|
+
const ok = await ctx.ui.confirm("Clear the running marker?", `${job.name ?? job.id} is marked as running since ${formatLocal(Date.parse(marker.startedAt))}${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.`);
|
|
988
1482
|
if (!ok) return;
|
|
989
1483
|
const updated = await scheduler.store.update(job.id, (j) => {
|
|
990
1484
|
j.running = undefined;
|
|
991
|
-
j.lastError = `running marker cleared by hand (was ${marker.runId.slice(0, 12)}
|
|
1485
|
+
j.lastError = `running marker cleared by hand (was ${marker.runId.slice(0, 12)})`;
|
|
992
1486
|
});
|
|
993
1487
|
refreshBadge();
|
|
994
1488
|
ctx.ui.notify(updated ? `cleared the running marker on ${updated.name ?? updated.id}; it can fire again` : "job vanished", "info");
|
|
@@ -1020,12 +1514,12 @@ export default function piLoops(pi: ExtensionAPI) {
|
|
|
1020
1514
|
const runsInLog = scheduler.store.allRuns();
|
|
1021
1515
|
const inboxAll = scheduler.inbox.list();
|
|
1022
1516
|
const worthOf = (id: string) => signalSummary(loopSignal(id, runsInLog, inboxAll, since));
|
|
1023
|
-
show(ctx, `Automation cost ${label}: $${spend.total.toFixed(3)} over ${spend.runs
|
|
1517
|
+
show(ctx, `Automation cost ${label}: $${spend.total.toFixed(3)} over ${plural(spend.runs, "run")}`, [
|
|
1024
1518
|
...(spend.rotated > 0 ? [` including $${spend.rotated.toFixed(3)} from runs the log has already rotated away`] : []),
|
|
1025
1519
|
...(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)"]),
|
|
1026
1520
|
...byJob.slice(0, 12).map(([id, e]) => {
|
|
1027
1521
|
const worth = worthOf(id);
|
|
1028
|
-
return ` $${e.cost.toFixed(3)} ${e.runs
|
|
1522
|
+
return ` $${e.cost.toFixed(3)} ${plural(e.runs, "run")} ${e.name ?? id}${worth ? ` — ${worth}` : ""}`;
|
|
1029
1523
|
}),
|
|
1030
1524
|
...(byJob.length > 12 ? [` (+${byJob.length - 12} more)`] : []),
|
|
1031
1525
|
...(spend.runs ? [] : [" nothing has run in this window"]),
|
|
@@ -1042,11 +1536,11 @@ export default function piLoops(pi: ExtensionAPI) {
|
|
|
1042
1536
|
const orphans = scheduler.store.orphanStates();
|
|
1043
1537
|
const purge = /(^|\s)--purge(\s|$)/.test(rest);
|
|
1044
1538
|
if (purge && orphans.length) for (const o of orphans) scheduler.store.purgeState(o.id);
|
|
1045
|
-
show(ctx, `removed ${removed.length
|
|
1046
|
-
...removed.map((j) => ` - ${j.id}${j.name ? ` "${j.name}"` : ""} (session ${(j.sessionId ?? "?")
|
|
1047
|
-
...(orphans.length && !purge ? [` ${orphans.length
|
|
1048
|
-
...(all ? [] : [" --all also collects other projects' dead jobs"]),
|
|
1049
|
-
...(purge && orphans.length ? [` deleted the loop state and transcripts of ${orphans.length
|
|
1539
|
+
show(ctx, `removed ${plural(removed.length, "plain job")} whose session no longer exists${all ? " (every project)" : " in this project"}`, [
|
|
1540
|
+
...removed.map((j) => ` - ${j.id}${j.name ? ` "${j.name}"` : ""} (session ${shortSessionId(j.sessionId ?? "?")})`),
|
|
1541
|
+
...(orphans.length && !purge ? [` ${plural(orphans.length, "loop")} that ${orphans.length === 1 ? "is" : "are"} gone left loop state or transcripts behind (${Math.round(orphans.reduce((n, o) => n + o.bytes, 0) / 1024)} KB) — /cron gc --purge deletes them`] : []),
|
|
1542
|
+
...(all ? [] : [" --all also collects other projects' dead plain jobs"]),
|
|
1543
|
+
...(purge && orphans.length ? [` deleted the loop state and transcripts of ${plural(orphans.length, "loop")} that ${orphans.length === 1 ? "is" : "are"} gone`] : []),
|
|
1050
1544
|
]);
|
|
1051
1545
|
refreshBadge();
|
|
1052
1546
|
return;
|
|
@@ -1071,6 +1565,10 @@ export default function piLoops(pi: ExtensionAPI) {
|
|
|
1071
1565
|
// that started a run in between has already set `running` on the copy it hands back.
|
|
1072
1566
|
const updated = await scheduler.store.update(job.id, (j) => Object.assign(j, applied.patch));
|
|
1073
1567
|
if (!updated) return;
|
|
1568
|
+
// The row the page writes for a schedule change, in the shared audit; nothing for an edit
|
|
1569
|
+
// that left the schedule alone (src/job-tune.ts scheduleAudit decides).
|
|
1570
|
+
const setRow = scheduleAudit(job, updated, { sourceLabel: "slash" });
|
|
1571
|
+
if (setRow) triggers.store.appendAudit({ ...setRow, traceId: newTraceId() });
|
|
1074
1572
|
for (const line of applied.changed) log.info(`cron ${updated.id}: ${line}`);
|
|
1075
1573
|
// A typo in a cron expression is invisible until it fails to fire, so the next run is
|
|
1076
1574
|
// part of the confirmation.
|
|
@@ -1089,6 +1587,7 @@ export default function piLoops(pi: ExtensionAPI) {
|
|
|
1089
1587
|
` model: ${updated.model ?? "(the running session's current model)"}`,
|
|
1090
1588
|
` thinking: ${updated.thinking ?? "(the running session's current level)"}`,
|
|
1091
1589
|
` timeout: ${updated.timeoutMs ? `${Math.round(updated.timeoutMs / 1000)}s` : "default"}`,
|
|
1590
|
+
` tools: ${updated.tools?.length ? updated.tools.join(", ") : updated.stateful ? "(this chat's built-in tools)" : "(none: its turns run uncapped, as your own do)"}`,
|
|
1092
1591
|
]);
|
|
1093
1592
|
return;
|
|
1094
1593
|
}
|
|
@@ -1118,7 +1617,7 @@ export default function piLoops(pi: ExtensionAPI) {
|
|
|
1118
1617
|
const who = job ? "" : `${r.jobName ?? r.jobId} `;
|
|
1119
1618
|
const cost = r.usage?.cost ? ` $${r.usage.cost.toFixed(3)}` : "";
|
|
1120
1619
|
const chk = r.checker ? (r.checker.ok ? `, checker kept ${r.checker.kept}/${r.checker.kept + r.checker.dropped.length + r.checker.unreviewed}` : ", checker FAILED") : "";
|
|
1121
|
-
const tail = r.ok ? `${r.findings} finding${r.findings === 1 ? "" : "s"}${chk}${r.stateUpdated ? ", state updated" : ""}` : `FAILED: ${previewRedacted(r.error ?? "?", 80)}`;
|
|
1620
|
+
const tail = r.ok ? `${r.findings} finding${r.findings === 1 ? "" : "s"}${chk}${r.stateUpdated ? ", state updated" : ""}` : r.stoppedBy === "person" ? `stopped by you ${r.stoppedFrom === "page" ? "from the page" : "from a terminal"}` : r.aborted ? "stopped" : `FAILED: ${previewRedacted(r.error ?? "?", 80)}`;
|
|
1122
1621
|
return `${String(i + 1).padStart(2)}. ${formatLocal(Date.parse(r.startedAt))} ${who}${secs}s${cost} · ${tail}${r.sessionFile ? "" : " (no transcript)"}`;
|
|
1123
1622
|
}),
|
|
1124
1623
|
job ? `transcript: /cron trace ${job.name ?? job.id} <k>` : "transcript: /cron trace <job> <k>",
|
|
@@ -1166,14 +1665,15 @@ export default function piLoops(pi: ExtensionAPI) {
|
|
|
1166
1665
|
return;
|
|
1167
1666
|
}
|
|
1168
1667
|
case "scheduler": {
|
|
1169
|
-
const
|
|
1170
|
-
const
|
|
1668
|
+
const live = liveHostRecord();
|
|
1669
|
+
const here = binding.here();
|
|
1171
1670
|
show(ctx, `Cron scheduler (pi-loops ${PI_LOOPS_VERSION} on pi ${PI_VERSION})`, [
|
|
1172
|
-
`this
|
|
1173
|
-
`
|
|
1174
|
-
|
|
1671
|
+
`this window: pid ${process.pid}, ${schedulerStarted ? "firing the plain jobs of its conversation" : "not scheduling (non-interactive)"}${here.sessionId ? ` · conversation ${shortSessionId(here.sessionId)} ${here.holds ? "runs here" : binding.viewerOf !== undefined ? "open in another window" : binding.standingAside ? "does not run here: this window's copy is older than the file, /resume it" : "does not run here"}` : ""}`,
|
|
1672
|
+
`pi-loops: ${live ? `pid ${live.pid}, version ${live.version}, since ${formatLocal(Date.parse(live.startedAt))} (runs every loop and check in the background)` : `not running${hostPauseReason() ? ` — ${hostPauseReason()}` : hostState ? ` — ${hostProblemText()}` : ""}`}`,
|
|
1673
|
+
...machineRuns().filter((r) => r.inHost).map((r) => ` running: ${r.label} since ${formatLocal(Date.parse(r.startedAt))}`),
|
|
1175
1674
|
`store: ${homeRel(dir)}`,
|
|
1176
|
-
|
|
1675
|
+
// Loops run in the background, and its log is where their trouble is written.
|
|
1676
|
+
`log: ${homeRel(path.join(dir, HOST_LOG))} (loops and checks) · ${homeRel(log.file)} (this window)`,
|
|
1177
1677
|
`inbox: ${scheduler.inbox.newCount()} new`,
|
|
1178
1678
|
]);
|
|
1179
1679
|
return;
|
|
@@ -1186,6 +1686,57 @@ export default function piLoops(pi: ExtensionAPI) {
|
|
|
1186
1686
|
}
|
|
1187
1687
|
};
|
|
1188
1688
|
|
|
1689
|
+
/**
|
|
1690
|
+
* `/pi-loops start | stop | restart | status`: the process that runs loops and rule checks while no
|
|
1691
|
+
* pi is open, by the name a person knows it by. "host" is the code's word for it, not theirs.
|
|
1692
|
+
*/
|
|
1693
|
+
async function backgroundCommand(action: string, ctx: ExtensionCommandContext): Promise<void> {
|
|
1694
|
+
if (action === "stop" || action === "restart") {
|
|
1695
|
+
const live = liveHostRecord();
|
|
1696
|
+
const said: string[] = [];
|
|
1697
|
+
if (!live) writeUiPref(dir, "host", "stopped");
|
|
1698
|
+
else if (action === "stop") await stopService(dir, live, (l) => said.push(l));
|
|
1699
|
+
// Started only once the old process has gone: until then a start finds it and does nothing.
|
|
1700
|
+
else {
|
|
1701
|
+
ctx.ui.notify(RESTART_WAIT_LINE, "info");
|
|
1702
|
+
if (!(await stopAndWait(dir, live, (l) => said.push(l), { reason: "restart" }))) {
|
|
1703
|
+
ctx.ui.notify(`pi-loops has not exited after ${LEAVE_WAIT_MS / 1000} s, and it is marked stopped: /pi-loops start once it has gone (see ${homeRel(path.join(dir, HOST_LOG))})`, "warning");
|
|
1704
|
+
refreshBadge();
|
|
1705
|
+
return;
|
|
1706
|
+
}
|
|
1707
|
+
}
|
|
1708
|
+
if (action === "stop") {
|
|
1709
|
+
ctx.ui.notify(live ? `${said.join("; ")}; loops and checks are paused` : "pi-loops is not running; nothing starts it until /pi-loops start, pi-loops start or pi-loops opening the page", "info");
|
|
1710
|
+
refreshBadge();
|
|
1711
|
+
return;
|
|
1712
|
+
}
|
|
1713
|
+
}
|
|
1714
|
+
if (action === "start" || action === "restart") {
|
|
1715
|
+
// Asked by name: kept after the last window under `[host] auto = false`, until a stop.
|
|
1716
|
+
writeUiPref(dir, "host", "started");
|
|
1717
|
+
const out = await hostTick(false, true);
|
|
1718
|
+
const live = liveHostRecord();
|
|
1719
|
+
ctx.ui.notify(live ? "pi-loops is running in the background" : `pi-loops is not running: ${out?.error ?? (out?.action.kind === "none" ? out.action.reason : "it did not start")}`, live ? "info" : "warning");
|
|
1720
|
+
refreshBadge();
|
|
1721
|
+
return;
|
|
1722
|
+
}
|
|
1723
|
+
if (action !== "status") {
|
|
1724
|
+
ctx.ui.notify("usage: /pi-loops start | stop | restart | status", "warning");
|
|
1725
|
+
return;
|
|
1726
|
+
}
|
|
1727
|
+
const live = liveHostRecord();
|
|
1728
|
+
const answer = live ? await askHost(dir, { op: "status" }) : undefined;
|
|
1729
|
+
const snapshot = answer?.ok ? answer.snapshot : undefined;
|
|
1730
|
+
// A snapshot that answered says the pid, the version and the start itself.
|
|
1731
|
+
show(ctx, "pi-loops in the background", [
|
|
1732
|
+
...(snapshot
|
|
1733
|
+
? renderHostSnapshot(snapshot)
|
|
1734
|
+
: [live ? ` running: pid ${live.pid}, pi-loops ${live.version}, since ${formatLocal(Date.parse(live.startedAt))} — not answering on its socket` : ` not running${hostPauseReason() ? ` — ${hostPauseReason()}` : hostState ? ` — ${hostProblemText()}` : " (it starts when there is work to run)"}`]),
|
|
1735
|
+
` log: ${homeRel(path.join(dir, HOST_LOG))}`,
|
|
1736
|
+
" /pi-loops start | stop | restart",
|
|
1737
|
+
]);
|
|
1738
|
+
}
|
|
1739
|
+
|
|
1189
1740
|
pi.registerCommand("cron", {
|
|
1190
1741
|
description: 'Scheduled jobs: /cron add [--stateful] "<schedule>" <prompt> — /cron help',
|
|
1191
1742
|
getArgumentCompletions: cronCompletions,
|
|
@@ -1194,7 +1745,7 @@ export default function piLoops(pi: ExtensionAPI) {
|
|
|
1194
1745
|
pi.registerCommand("crontab", { description: "Alias of /cron", getArgumentCompletions: cronCompletions, handler: cronHandler });
|
|
1195
1746
|
pi.registerCommand("loop", { description: "Alias of /cron", getArgumentCompletions: cronCompletions, handler: cronHandler });
|
|
1196
1747
|
|
|
1197
|
-
const INBOX_USAGE = "[list|all|claim <n>|dismiss <n> [reason]|clear|help] [--all]";
|
|
1748
|
+
const INBOX_USAGE = "[list|all|claim <n>|dismiss <n> [reason]|open <n>|clear|help] [--all]";
|
|
1198
1749
|
|
|
1199
1750
|
const INBOX_HELP = [
|
|
1200
1751
|
"/inbox list new findings of this project /inbox --all every project on this machine",
|
|
@@ -1202,37 +1753,39 @@ export default function piLoops(pi: ExtensionAPI) {
|
|
|
1202
1753
|
"/inbox claim <n|id> mark claimed and hand it to the agent as a real turn",
|
|
1203
1754
|
"/inbox dismiss <n|id> [reason] mark dismissed; a reason is shown to the loop's next run",
|
|
1204
1755
|
"/inbox clear [--all] dismiss the ones listed",
|
|
1756
|
+
"/inbox open <n|id> start a new conversation from the run that filed it",
|
|
1205
1757
|
];
|
|
1206
1758
|
|
|
1207
1759
|
/** The project a finding came from, as `/cron` shows a job's: the directory name is enough to tell them apart. */
|
|
1208
1760
|
const projectOf = (cwd: string) => (cwd ? path.basename(cwd) || homeRel(cwd) : "—");
|
|
1209
1761
|
|
|
1210
|
-
/**
|
|
1762
|
+
/**
|
|
1763
|
+
* List lines: the finding as src/inbox-card.ts splits it (a decision's clauses under it), id
|
|
1764
|
+
* prefix, project, source, when it arrived. The numbers are positions in `entries`, which is what
|
|
1765
|
+
* `claim` and `dismiss` resolve a number against.
|
|
1766
|
+
*/
|
|
1211
1767
|
function inboxLines(entries: InboxEntry[], numbered: boolean): string[] {
|
|
1212
|
-
|
|
1768
|
+
const names = new Map(scheduler.store.load().map((j) => [j.id, j.name]));
|
|
1769
|
+
const ctx = { here: session.sessionId, loopName: (id: string) => names.get(id) };
|
|
1770
|
+
return entries.flatMap((e, i) => {
|
|
1771
|
+
const card = inboxCard(e, ctx);
|
|
1772
|
+
if (!card) return [];
|
|
1213
1773
|
// Stored as an instant (ISO, UTC) and shown in this machine's timezone, like every other
|
|
1214
1774
|
// time pi-loops puts on a screen. It used to be the stored string with the `Z` sliced
|
|
1215
1775
|
// off — a UTC time wearing the shape of a local one, eight hours from the `next` on the
|
|
1216
1776
|
// `/cron` line above it, with nothing on either to say which was which.
|
|
1217
1777
|
const when = formatLocal(Date.parse(e.created_at));
|
|
1218
|
-
// ⚑ is a checkpoint — a decision the finding asks of a person; ✓ is the checker's mark.
|
|
1219
|
-
const mark = `${e.kind === "checkpoint" ? "⚑ " : ""}${e.verified ? "✓ " : ""}`;
|
|
1220
1778
|
// The project comes first of the three: with loops running in several checkouts it is what
|
|
1221
1779
|
// decides whether a finding is this morning's problem, and claiming runs it in that cwd.
|
|
1222
|
-
|
|
1223
|
-
|
|
1224
|
-
|
|
1780
|
+
// Every field from the file goes through screenText, as the card's text does: none of them
|
|
1781
|
+
// may repaint the terminal this prints in.
|
|
1782
|
+
const why = e.dismiss_reason ? ` — dismissed: ${redact(screenText(e.dismiss_reason))}` : "";
|
|
1783
|
+
const where = `${screenText(projectOf(e.cwd))}, ${screenText(cardOrigin(card))}`;
|
|
1784
|
+
if (!numbered) return cardLines(card, ` [${e.status}] `, `${why} (${where})`);
|
|
1785
|
+
return cardLines(card, ` ${i + 1}. [${e.id.slice(0, 12)}] `, ` (${where}, ${when})`);
|
|
1225
1786
|
});
|
|
1226
1787
|
}
|
|
1227
1788
|
|
|
1228
|
-
function claimPrompt(e: InboxEntry): string {
|
|
1229
|
-
const verified = e.verified ? `\n(An independent checker reviewed and kept this finding${e.verified_reason ? `: ${e.verified_reason}` : ""}.)` : "";
|
|
1230
|
-
// A checkpoint is a decision the loop prepared and left to a person; the claim is that
|
|
1231
|
-
// person's yes, so the turn carries the decision out rather than re-investigating it.
|
|
1232
|
-
const checkpoint = e.kind === "checkpoint" ? "\nThis finding is a checkpoint: claiming it is the person's approval of the decision it recommends — carry that decision out, and stop where the finding says a person acts." : "";
|
|
1233
|
-
return `A recurring loop (${e.source}, running in ${e.cwd}) reported this finding — investigate and address it:\n${e.text}${verified}${checkpoint}`;
|
|
1234
|
-
}
|
|
1235
|
-
|
|
1236
1789
|
/* ------------------------------------------------------------------ /goal */
|
|
1237
1790
|
|
|
1238
1791
|
/**
|
|
@@ -1244,6 +1797,8 @@ export default function piLoops(pi: ExtensionAPI) {
|
|
|
1244
1797
|
let goal: GoalState | undefined;
|
|
1245
1798
|
let goalMessages: unknown[] = [];
|
|
1246
1799
|
let goalEvaluating = false;
|
|
1800
|
+
/** A turn settled while the evaluator ran and was not judged; the running evaluation's settle judges it next (judgeCaughtUp). */
|
|
1801
|
+
let goalSkippedTurn = false;
|
|
1247
1802
|
/** Set while the evaluator runs, so an abort or `/goal pause` can cut it short. */
|
|
1248
1803
|
let goalAbort: AbortController | undefined;
|
|
1249
1804
|
/** How the last turn ended: an aborted or failed turn says nothing about the goal. */
|
|
@@ -1256,13 +1811,29 @@ export default function piLoops(pi: ExtensionAPI) {
|
|
|
1256
1811
|
if (ctx?.hasUI) ctx.ui.setStatus(GOAL_STATUS_KEY, next.status === "cleared" ? undefined : `goal: ${next.status}`);
|
|
1257
1812
|
}
|
|
1258
1813
|
|
|
1259
|
-
|
|
1814
|
+
/**
|
|
1815
|
+
* A continuation still queued belongs to the goal being replaced or cleared: a new goal would
|
|
1816
|
+
* otherwise be handed the old condition's prompt as its first turn.
|
|
1817
|
+
*/
|
|
1818
|
+
function dropQueuedContinuations(): void {
|
|
1819
|
+
const sessionId = binding.here().sessionId;
|
|
1820
|
+
if (!sessionId) return;
|
|
1821
|
+
dropContinuations(requests, sessionId, self, executor.view(sessionId).producerAlive);
|
|
1822
|
+
}
|
|
1823
|
+
|
|
1824
|
+
/** Evaluates the goal at the end of a turn. True when it sent a continuation, which is the next turn. */
|
|
1825
|
+
async function evaluateGoal(ctx: ExtensionContext): Promise<boolean> {
|
|
1260
1826
|
const pursued = goal;
|
|
1261
|
-
if (!pursued || pursued.status !== "pursuing"
|
|
1827
|
+
if (!pursued || pursued.status !== "pursuing") return false;
|
|
1828
|
+
if (goalEvaluating) {
|
|
1829
|
+
goalSkippedTurn = true;
|
|
1830
|
+
return false;
|
|
1831
|
+
}
|
|
1262
1832
|
// A turn that the user interrupted, or that the provider failed, is not evidence about the
|
|
1263
1833
|
// goal — and re-prompting after an abort would leave Esc unable to stop a goal at all.
|
|
1264
|
-
if (lastTurnStopReason === "aborted" || lastTurnStopReason === "error") return;
|
|
1834
|
+
if (lastTurnStopReason === "aborted" || lastTurnStopReason === "error") return false;
|
|
1265
1835
|
goalEvaluating = true;
|
|
1836
|
+
goalSkippedTurn = false; // only a turn that settles during this evaluation counts
|
|
1266
1837
|
const evaluatorStartedAt = Date.now();
|
|
1267
1838
|
// Where the session is now. The continuation the evaluator may ask for is a prompt about
|
|
1268
1839
|
// *this* point; if the user has taken the turn by the time it comes back, it must not be
|
|
@@ -1300,10 +1871,10 @@ export default function piLoops(pi: ExtensionAPI) {
|
|
|
1300
1871
|
});
|
|
1301
1872
|
// The user can pause, clear or replace the goal while the evaluator runs; a decision about
|
|
1302
1873
|
// the goal they had must not be written over the one they have now.
|
|
1303
|
-
|
|
1874
|
+
// An aborted evaluation is discarded: whoever aborted it has already written the goal.
|
|
1875
|
+
if (!evaluationApplies(goal, pursued, ctrl.signal.aborted)) return false;
|
|
1304
1876
|
let outcome: { state: GoalState; action: GoalAction };
|
|
1305
|
-
if (
|
|
1306
|
-
else if (!result.ok) outcome = pauseFor(pursued, `goal evaluator failed: ${result.errorMessage ?? "unknown error"}`);
|
|
1877
|
+
if (!result.ok) outcome = pauseFor(pursued, `goal evaluator failed: ${result.errorMessage ?? "unknown error"}`);
|
|
1307
1878
|
else {
|
|
1308
1879
|
try {
|
|
1309
1880
|
outcome = applyDecision(pursued, parseDecision(result.text));
|
|
@@ -1340,7 +1911,7 @@ export default function piLoops(pi: ExtensionAPI) {
|
|
|
1340
1911
|
});
|
|
1341
1912
|
scheduler.store.pruneSessions("goal", 20);
|
|
1342
1913
|
if (outcome.action.kind === "stop") {
|
|
1343
|
-
show(ctx, "Goal achieved", [` condition: ${previewRedacted(outcome.state.condition, 200)}`, ` evidence: ${previewRedacted(outcome.state.lastReason ?? "", 300)}`, ` ${outcome.state.iterations
|
|
1914
|
+
show(ctx, "Goal achieved", [` condition: ${previewRedacted(outcome.state.condition, 200)}`, ` evidence: ${previewRedacted(outcome.state.lastReason ?? "", 300)}`, ` ${plural(outcome.state.iterations, "continuation")}`]);
|
|
1344
1915
|
} else if (outcome.action.kind === "pause") {
|
|
1345
1916
|
notifyOrLog(ctx, `[goal] paused: ${previewRedacted(outcome.action.reason, 200)} — /goal resume to continue`, "warning");
|
|
1346
1917
|
} else if (held) {
|
|
@@ -1350,12 +1921,14 @@ export default function piLoops(pi: ExtensionAPI) {
|
|
|
1350
1921
|
notifyOrLog(ctx, `[goal] not satisfied (${outcome.state.iterations}/${MAX_CONTINUATIONS}): ${previewRedacted(outcome.state.lastReason ?? "", 160)}`, "info");
|
|
1351
1922
|
// The reason is model output derived from a transcript that can contain hostile file,
|
|
1352
1923
|
// web or MCP content, and this is the highest-trust channel in the session — so it is
|
|
1353
|
-
// redacted and capped before it is handed back to the agent. `
|
|
1354
|
-
//
|
|
1355
|
-
//
|
|
1924
|
+
// redacted and capped before it is handed back to the agent. `iterations` is already
|
|
1925
|
+
// persisted above; the continuation is a row the window hands to pi while idle, and
|
|
1926
|
+
// `settledTurn` keeps the next settle from charging it again while it is on its way.
|
|
1356
1927
|
const prompt = continuationPrompt(outcome.state.condition, capRedacted(outcome.state.lastReason ?? "", 2000));
|
|
1357
|
-
|
|
1928
|
+
await handOver(goalRow(prompt, outcome.state, rowAddress()));
|
|
1929
|
+
return true;
|
|
1358
1930
|
}
|
|
1931
|
+
return false;
|
|
1359
1932
|
} finally {
|
|
1360
1933
|
slot.release();
|
|
1361
1934
|
goalEvaluating = false;
|
|
@@ -1405,25 +1978,30 @@ export default function piLoops(pi: ExtensionAPI) {
|
|
|
1405
1978
|
const sub = /^(pause|resume|clear|help|start)$/.test(text) ? text : "";
|
|
1406
1979
|
if (sub === "help") return show(ctx, "Goal", GOAL_HELP);
|
|
1407
1980
|
if (!text) {
|
|
1408
|
-
if (!goal) return show(ctx, "Goal: none", [" /goal <condition>
|
|
1981
|
+
if (!goal) return show(ctx, "Goal: none", [" /goal <condition>"]);
|
|
1409
1982
|
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))}`]);
|
|
1410
1983
|
}
|
|
1411
1984
|
if (sub === "start") return ctx.ui.notify("usage: /goal start <prompt> — set a goal with /goal <condition>, then send the prompt to begin", "warning");
|
|
1412
1985
|
if (sub === "pause" || sub === "resume" || sub === "clear") {
|
|
1413
1986
|
if (!goal) return ctx.ui.notify("no active goal; set one with /goal <condition>", "warning");
|
|
1414
|
-
// An evaluation in flight was decided about the goal as it was; stop it here
|
|
1987
|
+
// An evaluation in flight was decided about the goal as it was; stop it here, and do not
|
|
1988
|
+
// judge a turn it skipped against the goal as it is now.
|
|
1415
1989
|
goalAbort?.abort();
|
|
1990
|
+
goalSkippedTurn = false;
|
|
1416
1991
|
if (sub === "clear") {
|
|
1992
|
+
dropQueuedContinuations();
|
|
1417
1993
|
persistGoal(ctx, { ...goal, status: "cleared", updatedAt: stamp() });
|
|
1418
1994
|
return ctx.ui.notify("goal cleared", "info");
|
|
1419
1995
|
}
|
|
1420
1996
|
if (sub === "resume" && goal.status === "achieved") return ctx.ui.notify("this goal was achieved; set a new one with /goal <condition>", "warning");
|
|
1421
|
-
// Resuming
|
|
1422
|
-
const next: GoalState = sub === "pause" ? { ...goal, status: "paused", updatedAt: stamp() } :
|
|
1997
|
+
// Resuming with the allowance spent starts it again (resumeGoal).
|
|
1998
|
+
const next: GoalState = sub === "pause" ? { ...goal, status: "paused", updatedAt: stamp() } : resumeGoal(goal);
|
|
1423
1999
|
persistGoal(ctx, next);
|
|
1424
2000
|
return ctx.ui.notify(`goal ${sub === "pause" ? "paused" : "resumed"}`, "info");
|
|
1425
2001
|
}
|
|
1426
2002
|
goalAbort?.abort(); // the old goal's evaluation is about a condition the user just replaced
|
|
2003
|
+
goalSkippedTurn = false;
|
|
2004
|
+
dropQueuedContinuations();
|
|
1427
2005
|
persistGoal(ctx, newGoal(text));
|
|
1428
2006
|
ctx.ui.notify(`goal set: ${previewRedacted(text, 160)} — send a prompt to begin; evaluated after every turn, up to ${MAX_CONTINUATIONS} continuations`, "info");
|
|
1429
2007
|
},
|
|
@@ -1432,7 +2010,7 @@ export default function piLoops(pi: ExtensionAPI) {
|
|
|
1432
2010
|
pi.registerCommand("inbox", {
|
|
1433
2011
|
description: "Triage findings from stateful cron jobs — /inbox help",
|
|
1434
2012
|
getArgumentCompletions: (prefix) => {
|
|
1435
|
-
const subs = ["list", "all", "claim", "dismiss", "clear", "help", "--all"];
|
|
2013
|
+
const subs = ["list", "all", "claim", "dismiss", "open", "clear", "help", "--all"];
|
|
1436
2014
|
const items = subs.filter((s) => s.startsWith(prefix)).map((s) => ({ value: s, label: s }));
|
|
1437
2015
|
return items.length ? items : null;
|
|
1438
2016
|
},
|
|
@@ -1454,15 +2032,16 @@ export default function piLoops(pi: ExtensionAPI) {
|
|
|
1454
2032
|
const all = scheduler.inbox.listNew();
|
|
1455
2033
|
const entries = scoped(all);
|
|
1456
2034
|
if (!entries.length) {
|
|
1457
|
-
show(ctx, all.length ? `inbox: nothing new in ${homeRel(session.cwd)} — ${all.length
|
|
2035
|
+
show(ctx, all.length ? `inbox: nothing new in ${homeRel(session.cwd)} — ${plural(all.length, "finding")} elsewhere (/inbox --all)` : "inbox: empty", []);
|
|
2036
|
+
markSeenIfShown(everywhere);
|
|
1458
2037
|
return;
|
|
1459
2038
|
}
|
|
1460
2039
|
const lines = inboxLines(entries, true);
|
|
1461
2040
|
const elsewhere = all.length - entries.length;
|
|
1462
2041
|
if (elsewhere) lines.push(`+ ${elsewhere} finding${elsewhere === 1 ? "" : "s"} in other projects — /inbox --all`);
|
|
1463
|
-
lines.push("claim with /inbox claim <n>, dismiss with /inbox dismiss <n>");
|
|
1464
2042
|
const decisions = entries.filter((e) => e.kind === "checkpoint").length;
|
|
1465
2043
|
show(ctx, `Inbox (${where}, ${entries.length} new${decisions ? `, ${decisions} need${decisions === 1 ? "s" : ""} a decision` : ""}, times ${localOffset(Date.now())}):`, lines);
|
|
2044
|
+
markSeenIfShown(everywhere);
|
|
1466
2045
|
return;
|
|
1467
2046
|
}
|
|
1468
2047
|
case "all": {
|
|
@@ -1474,7 +2053,8 @@ export default function piLoops(pi: ExtensionAPI) {
|
|
|
1474
2053
|
show(ctx, "inbox", INBOX_HELP);
|
|
1475
2054
|
return;
|
|
1476
2055
|
case "claim":
|
|
1477
|
-
case "dismiss":
|
|
2056
|
+
case "dismiss":
|
|
2057
|
+
case "open": {
|
|
1478
2058
|
// `/inbox dismiss 3 that file is generated`: the first word names the finding, the rest is
|
|
1479
2059
|
// why, and the reason goes to the loop that reported it, in its next run's prompt. A
|
|
1480
2060
|
// claim takes the whole argument as the ref, as it always has. For a dismiss, `--all`
|
|
@@ -1502,21 +2082,44 @@ export default function piLoops(pi: ExtensionAPI) {
|
|
|
1502
2082
|
const entry = resolveInboxRef(entries, target, { ordinals: true });
|
|
1503
2083
|
if (!entry) {
|
|
1504
2084
|
const n = Number(target);
|
|
1505
|
-
ctx.ui.notify(!target ? "usage: /inbox claim <n or inb-id> | /inbox dismiss <n or inb-id> [reason]" : Number.isInteger(n) ? `no inbox entry #${n} in ${where} (have ${entries.length}; /inbox --all lists every project)` : `no new inbox entry matching '${target}'`, "warning");
|
|
2085
|
+
ctx.ui.notify(!target ? "usage: /inbox claim <n or inb-id> | /inbox dismiss <n or inb-id> [reason] | /inbox open <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 '${target}'`, "warning");
|
|
2086
|
+
return;
|
|
2087
|
+
}
|
|
2088
|
+
if (sub === "open") {
|
|
2089
|
+
if (entry.source.startsWith("trigger:")) {
|
|
2090
|
+
ctx.ui.notify(`inbox entry ${target} is a rule's result: a rule check keeps no run transcript to open`, "warning");
|
|
2091
|
+
return;
|
|
2092
|
+
}
|
|
2093
|
+
// The run worked in its own project; a conversation forked from it here would be a
|
|
2094
|
+
// conversation about that project with this one's directory and tools.
|
|
2095
|
+
if (entry.cwd && !sameProject(entry.cwd, session.cwd)) {
|
|
2096
|
+
ctx.ui.notify(`inbox entry ${target} is from ${homeRel(entry.cwd)}: open it from a pi in that project`, "warning");
|
|
2097
|
+
return;
|
|
2098
|
+
}
|
|
2099
|
+
const transcript = entry.transcript ?? scheduler.store.allRuns().find((record) => record.runId === entry.run_id)?.sessionFile;
|
|
2100
|
+
if (!transcript || !fs.existsSync(transcript)) {
|
|
2101
|
+
ctx.ui.notify(`transcript for inbox entry ${target} is gone (pruned, or the run kept none)`, "warning");
|
|
2102
|
+
return;
|
|
2103
|
+
}
|
|
2104
|
+
// Into this window's session directory: with `--session-dir`, pi's default one is not where `/resume` looks.
|
|
2105
|
+
const forkedFile = SessionManager.forkFrom(transcript, ctx.cwd, ctx.sessionManager.getSessionDir()).getSessionFile();
|
|
2106
|
+
if (forkedFile) await ctx.switchSession(forkedFile);
|
|
1506
2107
|
return;
|
|
1507
2108
|
}
|
|
1508
|
-
await scheduler.inbox.setStatus(entry.id, sub === "claim" ? "claimed" : "dismissed", sub === "claim" ? session.sessionId : undefined, reason || undefined);
|
|
1509
|
-
refreshBadge();
|
|
1510
2109
|
if (sub === "dismiss") {
|
|
2110
|
+
const decided = await scheduler.inbox.decide(entry.id, "dismissed", { reason: reason || undefined });
|
|
2111
|
+
refreshBadge();
|
|
2112
|
+
if (!("ok" in decided)) {
|
|
2113
|
+
ctx.ui.notify("missing" in decided ? `inbox entry ${target} is gone` : `inbox entry ${target} was already ${decided.already}`, "warning");
|
|
2114
|
+
return;
|
|
2115
|
+
}
|
|
1511
2116
|
const loop = scheduler.store.load().find((j) => j.id === entry.job_id);
|
|
1512
|
-
|
|
1513
|
-
// trigger has no notes and no next run to tell.
|
|
1514
|
-
const gone = entry.job_id?.startsWith("cron-") ? "the loop that reported it is gone, so nobody is told" : "a trigger reported it, and a trigger keeps no notes, so nobody is told";
|
|
1515
|
-
const told = reason ? ` — ${loop ? `${loop.name ?? loop.id} is told why on its next run` : gone}` : "";
|
|
1516
|
-
ctx.ui.notify(`dismissed: ${previewRedacted(entry.text, 80)}${told}`, "info");
|
|
2117
|
+
ctx.ui.notify(`dismissed: ${previewRedacted(screenText(entry.text), 80)}${dismissTail(dismissOutcome(entry, loop, reason))}`, "info");
|
|
1517
2118
|
return;
|
|
1518
2119
|
}
|
|
1519
|
-
|
|
2120
|
+
// The hand-off is queued before the finding is marked claimed (src/engine.ts handOver).
|
|
2121
|
+
await handOver(inboxClaimRow(claimPrompt(entry), entry.id, rowAddress()), { inbox: scheduler.inbox, entryId: entry.id });
|
|
2122
|
+
refreshBadge();
|
|
1520
2123
|
return;
|
|
1521
2124
|
}
|
|
1522
2125
|
case "clear": {
|
|
@@ -1544,8 +2147,8 @@ export default function piLoops(pi: ExtensionAPI) {
|
|
|
1544
2147
|
const TRIGGERS_USAGE = "[status|rules|sources|hooks|enable|disable|remove|set|run|running|audit|abort|panel|help] — /triggers help for the arguments";
|
|
1545
2148
|
|
|
1546
2149
|
const TRIGGERS_HELP = [
|
|
1547
|
-
"/triggers
|
|
1548
|
-
"/triggers
|
|
2150
|
+
"/triggers rules [--all] this project's rules, as bare /triggers lists them (--all: every project on this machine)",
|
|
2151
|
+
"/triggers status rule counts, what checks them, the last check, push sources",
|
|
1549
2152
|
"/triggers sources MCP push sources, the local crontab, the dynamic checker — and what each has seen (/triggers hooks is the same view)",
|
|
1550
2153
|
"/triggers enable <n|id> /triggers disable <n|id> also --all (this project) | --all-projects (the machine)",
|
|
1551
2154
|
"/triggers remove <n|id> also remove --all | remove --all-projects; /new-trigger creates one",
|
|
@@ -1554,22 +2157,12 @@ export default function piLoops(pi: ExtensionAPI) {
|
|
|
1554
2157
|
"/triggers running actions in flight (dynamic checks and cron runs), and the sub-agent slots in use",
|
|
1555
2158
|
"/triggers audit [N] [--all] recent decisions, with each run's transcript path (default 10)",
|
|
1556
2159
|
"/triggers abort <trace_id> stop one running action /triggers abort --all stop every one",
|
|
1557
|
-
"/triggers panel on|off side panel above the editor (
|
|
2160
|
+
"/triggers panel on|off side panel above the editor (Rules / Loops / Runs / MCP)",
|
|
1558
2161
|
"create one: /new-trigger when ~/build.done exists, run cargo test and show me the result",
|
|
1559
2162
|
"config: ~/.pi/agent/loops/config.toml [triggers] poll_interval_secs, mcp.toml for push sources, hooks.toml for lifecycle hooks",
|
|
1560
2163
|
];
|
|
1561
2164
|
|
|
1562
|
-
|
|
1563
|
-
return rules.map((r, i) => {
|
|
1564
|
-
const state = r.enabled ? "enabled" : "disabled";
|
|
1565
|
-
const fire = r.fireOnce ? "fire_once" : "repeat";
|
|
1566
|
-
const out = r.promoteToChat ? "promote_to_chat" : "audit_only";
|
|
1567
|
-
const fired = r.firedAt ? `, fired_at=${r.firedAt}` : "";
|
|
1568
|
-
const head = numbered ? `${String(i + 1).padStart(2)}. ` : " - ";
|
|
1569
|
-
const other = r.createdBy?.sessionId && r.createdBy.sessionId !== session.sessionId ? ` (session ${r.createdBy.sessionId.slice(0, 8)})` : "";
|
|
1570
|
-
return `${head}${r.id} [${state}, ${fire}, ${out}${fired}] when ${previewRedacted(r.condition, 80)} -> ${previewRedacted(r.action, 80)}${!sameProject(r.cwd, session.cwd) ? ` (${homeRel(r.cwd)})` : ""}${other}`;
|
|
1571
|
-
});
|
|
1572
|
-
}
|
|
2165
|
+
const ruleLines = (rules: ReturnType<TriggerStore["load"]>, numbered: boolean) => listRules(rules, { numbered, sessionId: session.sessionId, cwd: session.cwd, home: process.env.HOME ?? "", sameProject });
|
|
1573
2166
|
|
|
1574
2167
|
pi.registerCommand("triggers", {
|
|
1575
2168
|
description: "Show trigger sources, rules, running actions, and recent audit — /triggers help",
|
|
@@ -1590,25 +2183,27 @@ export default function piLoops(pi: ExtensionAPI) {
|
|
|
1590
2183
|
};
|
|
1591
2184
|
try {
|
|
1592
2185
|
switch (sub) {
|
|
1593
|
-
case "":
|
|
1594
2186
|
case "status": {
|
|
1595
|
-
const
|
|
1596
|
-
|
|
1597
|
-
|
|
1598
|
-
|
|
1599
|
-
|
|
1600
|
-
|
|
1601
|
-
|
|
1602
|
-
|
|
1603
|
-
|
|
1604
|
-
|
|
1605
|
-
|
|
1606
|
-
|
|
1607
|
-
|
|
1608
|
-
|
|
1609
|
-
|
|
2187
|
+
const [first, ...lines] = triggerStatusLines({
|
|
2188
|
+
rules: store.load(),
|
|
2189
|
+
pollIntervalSecs: triggers.pollIntervalSecs,
|
|
2190
|
+
running: !!liveHostRecord(),
|
|
2191
|
+
lastCheck: lastPoll(),
|
|
2192
|
+
home: process.env.HOME ?? "",
|
|
2193
|
+
pushSources: mcpConfigs.length,
|
|
2194
|
+
configError: mcpConfigError,
|
|
2195
|
+
sources: { total: mcpSources.length + LOCAL_SOURCES, connected: mcpSources.filter((s) => s.status.state === "connected").length + (localSourcesConnected() ? LOCAL_SOURCES : 0), attention: mcpSources.filter((s) => s.status.requiresAttention).length },
|
|
2196
|
+
runningCount: machineRuns().length,
|
|
2197
|
+
deduplicated: triggers.dedupedCount,
|
|
2198
|
+
cyclesStopped: triggers.cycleSuppressedCount,
|
|
2199
|
+
rulesFile: store.rulesFile,
|
|
2200
|
+
auditFile: store.auditFile,
|
|
2201
|
+
persistenceError: store.lastPersistenceError,
|
|
2202
|
+
});
|
|
2203
|
+
show(ctx, first, lines);
|
|
1610
2204
|
return;
|
|
1611
2205
|
}
|
|
2206
|
+
case "":
|
|
1612
2207
|
case "rules": {
|
|
1613
2208
|
const all = store.load();
|
|
1614
2209
|
const here = all.filter((r) => sameProject(r.cwd, session.cwd));
|
|
@@ -1626,7 +2221,7 @@ export default function piLoops(pi: ExtensionAPI) {
|
|
|
1626
2221
|
case "hooks": {
|
|
1627
2222
|
// MCP hooks are registered first, then the cron hook, then the dynamic checker.
|
|
1628
2223
|
const lines: string[] = [];
|
|
1629
|
-
const localState = localSourcesConnected() ? "connected" :
|
|
2224
|
+
const localState = localSourcesConnected() ? "connected" : "pi-loops is not running";
|
|
1630
2225
|
mcpSources.forEach((source, index) => {
|
|
1631
2226
|
const status = source.status;
|
|
1632
2227
|
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}` : ""}`);
|
|
@@ -1644,10 +2239,10 @@ export default function piLoops(pi: ExtensionAPI) {
|
|
|
1644
2239
|
.sort((a, b) => Date.parse(a) - Date.parse(b))
|
|
1645
2240
|
.at(-1);
|
|
1646
2241
|
lines.push(` - source #${mcpSources.length + 1}: ${localState} queued=${scheduler.runningCount} dropped=0 deduped=0 last_event=${lastFired ?? "never"}`);
|
|
1647
|
-
lines.push(` subscriptions: ${jobs.length ? `local crontab: ${jobs.length
|
|
2242
|
+
lines.push(` subscriptions: ${jobs.length ? `local crontab: ${plural(jobs.length, "loop")}, ${jobs.filter((j) => j.enabled).length} enabled` : "local crontab: 0 loops"}`);
|
|
1648
2243
|
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"}`);
|
|
1649
2244
|
lines.push(" subscriptions: dynamic trigger periodic check");
|
|
1650
|
-
if (mcpSources.length) lines.push(" (pushes are deduplicated machine-wide;
|
|
2245
|
+
if (mcpSources.length) lines.push(" (pushes are deduplicated machine-wide; a rule's result goes to the conversation that created it)");
|
|
1651
2246
|
if (mcpConfigError) lines.push(` ! ${mcpConfigError}`);
|
|
1652
2247
|
show(ctx, `Trigger sources (${LOCAL_SOURCES + mcpSources.length}):`, lines);
|
|
1653
2248
|
return;
|
|
@@ -1663,7 +2258,7 @@ export default function piLoops(pi: ExtensionAPI) {
|
|
|
1663
2258
|
const targets = store.load().filter((r) => (everywhere || sameProject(r.cwd, session.cwd)) && r.enabled !== enable);
|
|
1664
2259
|
for (const r of targets) await store.setEnabled(r.id, enable);
|
|
1665
2260
|
refreshBadge();
|
|
1666
|
-
ctx.ui.notify(`${enable ? "enabled" : "disabled"} ${targets.length
|
|
2261
|
+
ctx.ui.notify(`${enable ? "enabled" : "disabled"} ${plural(targets.length, "rule")}${everywhere ? " on this machine" : " in this project"}`, "info");
|
|
1667
2262
|
return;
|
|
1668
2263
|
}
|
|
1669
2264
|
const rule = pickRule(rest);
|
|
@@ -1684,7 +2279,7 @@ export default function piLoops(pi: ExtensionAPI) {
|
|
|
1684
2279
|
if (rest.trim() === "--all" || rest.trim() === "--all-projects") {
|
|
1685
2280
|
const everywhere = rest.trim() === "--all-projects";
|
|
1686
2281
|
const n = await store.clear(everywhere ? undefined : session.cwd, sameProject);
|
|
1687
|
-
ctx.ui.notify(`removed ${n
|
|
2282
|
+
ctx.ui.notify(`removed ${plural(n, "dynamic trigger rule")}${everywhere ? " (every project on this machine)" : ` in ${homeRel(session.cwd)}`}`, "info");
|
|
1688
2283
|
return;
|
|
1689
2284
|
}
|
|
1690
2285
|
const rule = pickRule(rest);
|
|
@@ -1748,17 +2343,14 @@ export default function piLoops(pi: ExtensionAPI) {
|
|
|
1748
2343
|
return;
|
|
1749
2344
|
}
|
|
1750
2345
|
case "running": {
|
|
2346
|
+
await refreshHostSnapshot();
|
|
1751
2347
|
const now = Date.now();
|
|
1752
|
-
const running =
|
|
1753
|
-
...triggers.runningList().map((r) => ({ traceId: r.traceId, sourceLabel: r.sourceLabel, eventLabel: r.eventLabel, startedAt: r.startedAt, promptPreview: r.promptPreview, sessionFile: undefined as string | undefined })),
|
|
1754
|
-
...scheduler.runningRuns().map((r) => ({ traceId: r.runId, sourceLabel: "Cron", eventLabel: r.jobId, startedAt: r.startedAt, promptPreview: r.promptPreview, sessionFile: r.sessionFile })),
|
|
1755
|
-
];
|
|
2348
|
+
const running = machineRuns().map((r) => ({ traceId: r.id, sourceLabel: r.inHost ? `${r.source} (in the background)` : r.source, eventLabel: r.event, startedAt: r.startedAt, promptPreview: r.promptPreview, sessionFile: r.sessionFile }));
|
|
1756
2349
|
show(
|
|
1757
2350
|
ctx,
|
|
1758
|
-
// The
|
|
1759
|
-
// project's job holds
|
|
1760
|
-
|
|
1761
|
-
`${running.length ? `Running triggers (${running.length}):` : "(no running triggers)"} ${triggers.slots.inUseCount} of ${triggers.slots.limit} sub-agent slot(s) in use`,
|
|
2351
|
+
// The machine's runs, here and in the background, against the limit both use: a run
|
|
2352
|
+
// started by another project's job holds a slot too, and is in this list.
|
|
2353
|
+
`${running.length ? `Running triggers (${running.length}):` : "(no running triggers)"} ${running.length} of ${plural(triggers.slots.limit, "sub-agent slot")} in use`,
|
|
1762
2354
|
running.flatMap((r) => {
|
|
1763
2355
|
const secs = Math.max(0, Math.round((now - Date.parse(r.startedAt)) / 1000));
|
|
1764
2356
|
const elapsed = secs < 90 ? `${secs}s` : `${Math.round(secs / 60)}m`;
|
|
@@ -1798,15 +2390,47 @@ export default function piLoops(pi: ExtensionAPI) {
|
|
|
1798
2390
|
return;
|
|
1799
2391
|
}
|
|
1800
2392
|
case "abort": {
|
|
2393
|
+
// Loops and checks run in the host: what is not running here is asked of the host.
|
|
2394
|
+
const askHostAbort = async (id: string, isRun: boolean): Promise<boolean> => {
|
|
2395
|
+
const answer = await askHost(dir, isRun ? { op: "abort", runId: id } : { op: "abort", traceId: id });
|
|
2396
|
+
return !!answer?.ok && !!answer.aborted;
|
|
2397
|
+
};
|
|
1801
2398
|
if (rest.trim() === "--all") {
|
|
1802
2399
|
let n = triggers.abortAll();
|
|
1803
2400
|
for (const r of scheduler.runningRuns()) if (scheduler.abortRun(r.runId)) n++;
|
|
1804
|
-
|
|
2401
|
+
const host = await refreshHostSnapshot();
|
|
2402
|
+
for (const r of host?.runs ?? []) if (await askHostAbort(r.runId, true)) n++;
|
|
2403
|
+
for (const c of host?.checks ?? []) if (await askHostAbort(c.traceId, false)) n++;
|
|
2404
|
+
ctx.ui.notify(`requested abort for ${plural(n, "running trigger")}`, "info");
|
|
1805
2405
|
return;
|
|
1806
2406
|
}
|
|
1807
2407
|
const target = rest.trim();
|
|
1808
2408
|
const hit = triggers.runningList().find((r) => r.traceId === target || r.traceId.startsWith(target));
|
|
1809
2409
|
const cronHit = scheduler.runningRuns().find((r) => r.runId === target || r.runId.startsWith(target));
|
|
2410
|
+
if (target && !hit && !cronHit) {
|
|
2411
|
+
// A turn in a closed conversation, run by pi-loops in the background: stopped through a
|
|
2412
|
+
// request (src/engine.ts stopRow), which outlives a socket that does not answer. A loop
|
|
2413
|
+
// run or a check belongs to no conversation and keeps the socket.
|
|
2414
|
+
const held = heldTurnFor(binding.store.list(), target);
|
|
2415
|
+
if (held?.kind === "ambiguous") {
|
|
2416
|
+
ctx.ui.notify(`${target} is a prefix of ${held.matches.length} running turns; give more of the id: ${held.matches.join(", ")}`, "warning");
|
|
2417
|
+
return;
|
|
2418
|
+
}
|
|
2419
|
+
if (held) {
|
|
2420
|
+
requests.append(stopRow(held.sessionId, { id: held.runId }, "window", createdBy()));
|
|
2421
|
+
void ringHost(dir, `request:${held.sessionId}`).catch(() => undefined);
|
|
2422
|
+
ctx.ui.notify(`requested stop for ${held.runId} (session ${shortSessionId(held.sessionId)})`, "info");
|
|
2423
|
+
return;
|
|
2424
|
+
}
|
|
2425
|
+
const host = await refreshHostSnapshot();
|
|
2426
|
+
const hostRun = host?.runs.find((r) => r.runId === target || r.runId.startsWith(target));
|
|
2427
|
+
const hostCheck = host?.checks.find((c) => c.traceId === target || c.traceId.startsWith(target));
|
|
2428
|
+
if (hostRun || hostCheck) {
|
|
2429
|
+
const ok = await askHostAbort(hostRun?.runId ?? hostCheck!.traceId, !!hostRun);
|
|
2430
|
+
ctx.ui.notify(ok ? `requested abort for ${hostRun?.runId ?? hostCheck!.traceId} in the background` : `pi-loops did not abort ${target}: it may have just finished`, ok ? "info" : "warning");
|
|
2431
|
+
return;
|
|
2432
|
+
}
|
|
2433
|
+
}
|
|
1810
2434
|
if (!target || (!hit && !cronHit)) {
|
|
1811
2435
|
ctx.ui.notify(target ? `no running trigger with trace_id '${target}'` : "usage: /triggers abort <trace_id>|--all", "warning");
|
|
1812
2436
|
return;
|
|
@@ -1840,10 +2464,33 @@ export default function piLoops(pi: ExtensionAPI) {
|
|
|
1840
2464
|
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.`;
|
|
1841
2465
|
|
|
1842
2466
|
pi.registerCommand("pi-loops", {
|
|
1843
|
-
description: "About this extension
|
|
2467
|
+
description: "About this extension; start | stop | restart | status the part that runs loops while no pi is open; install-launcher puts the `pi-loops` command on your PATH",
|
|
1844
2468
|
handler: async (args, ctx) => {
|
|
1845
2469
|
lastCtx = ctx;
|
|
1846
2470
|
const sub = args.trim().split(/\s+/)[0] ?? "";
|
|
2471
|
+
if (sub === "start" || sub === "stop" || sub === "restart" || sub === "status") {
|
|
2472
|
+
await backgroundCommand(sub, ctx);
|
|
2473
|
+
return;
|
|
2474
|
+
}
|
|
2475
|
+
if (sub === "ring") {
|
|
2476
|
+
// The browser page's doorbell (docs/design.md decision 23): drain now; with an id and a
|
|
2477
|
+
// fingerprint, first believe that row's source, since this stdin is the page's own channel.
|
|
2478
|
+
const [, id, fingerprint] = args.trim().split(/\s+/);
|
|
2479
|
+
// A viewer retries its lease at every tick; the bell is a better moment: a holder that
|
|
2480
|
+
// died since the last tick would otherwise keep the page's message waiting up to 30 s.
|
|
2481
|
+
try {
|
|
2482
|
+
const outcome = binding.refresh();
|
|
2483
|
+
if (outcome) noteBinding(outcome);
|
|
2484
|
+
} catch (err: any) {
|
|
2485
|
+
log.info(`lease: ${err?.message ?? err}`);
|
|
2486
|
+
}
|
|
2487
|
+
const sessionId = binding.here().sessionId;
|
|
2488
|
+
if (id && fingerprint && sessionId && !ringProduced(requests.list(sessionId), id, fingerprint, (row) => executor.produced(row))) log.info(`ring: no queued request ${id.slice(0, 8)} with that fingerprint; it runs capped`);
|
|
2489
|
+
// A command handler, outside pi's own event cycle: this drain may judge a send pi refused
|
|
2490
|
+
// (the page rings again once the grace has passed), as the tick may.
|
|
2491
|
+
await drainRequests({ tick: true });
|
|
2492
|
+
return;
|
|
2493
|
+
}
|
|
1847
2494
|
if (sub === "install-launcher") {
|
|
1848
2495
|
// The command line's own `install-launcher` cannot be reached until it has run once —
|
|
1849
2496
|
// `pi install` does not put the package on your PATH. You are already inside pi with
|
|
@@ -1862,8 +2509,9 @@ export default function piLoops(pi: ExtensionAPI) {
|
|
|
1862
2509
|
` loops directory: ${homeRel(dir)}`,
|
|
1863
2510
|
` package: ${homeRel(PACKAGE_DIR)}`,
|
|
1864
2511
|
"",
|
|
2512
|
+
" /pi-loops start | stop | restart | status",
|
|
1865
2513
|
" /pi-loops install-launcher [--dir <dir>] put the `pi-loops` command on your PATH",
|
|
1866
|
-
" /cron · /triggers · /inbox · /goal
|
|
2514
|
+
" /cron · /triggers · /inbox · /goal",
|
|
1867
2515
|
]);
|
|
1868
2516
|
},
|
|
1869
2517
|
});
|
|
@@ -1983,8 +2631,8 @@ export default function piLoops(pi: ExtensionAPI) {
|
|
|
1983
2631
|
return;
|
|
1984
2632
|
}
|
|
1985
2633
|
pendingRecipeAdd = { words, project };
|
|
1986
|
-
|
|
1987
|
-
ctx.ui.notify(`${m.name} reads ${TRACKER_FILE}, and this project has none yet — the agent will write it with you now, and the install continues here as soon as it exists`, "info");
|
|
2634
|
+
const { written } = await handOver(recipeRow(trackerSetupPrompt(recipe, project), m.name, "tracker", rowAddress()));
|
|
2635
|
+
ctx.ui.notify(written ? `${m.name} reads ${TRACKER_FILE}, and this project has none yet — the agent will write it with you now, and the install continues here as soon as it exists` : `${m.name} reads ${TRACKER_FILE}, and this project has none yet — the agent is already queued to write it with you; the install continues here as soon as it exists`, "info");
|
|
1988
2636
|
return;
|
|
1989
2637
|
}
|
|
1990
2638
|
let level: AutonomyLevel | undefined;
|
|
@@ -2003,7 +2651,7 @@ export default function piLoops(pi: ExtensionAPI) {
|
|
|
2003
2651
|
const existing = scheduler.store.load();
|
|
2004
2652
|
const clash = m.jobs.map((j) => existing.find((e) => e.name === j.name)).filter((e): e is LoopJob => !!e);
|
|
2005
2653
|
if (clash.length) {
|
|
2006
|
-
show(ctx, `not installed: ${clash.length
|
|
2654
|
+
show(ctx, `not installed: ${plural(clash.length, "job name")} already in use`, [
|
|
2007
2655
|
...clash.map((e) => ` "${e.name}" — ${e.recipe ? `from recipe ${e.recipe}` : "created with /cron add"}, in ${homeRel(e.cwd)}`),
|
|
2008
2656
|
"",
|
|
2009
2657
|
` ${clash.some((e) => e.recipe === m.name) ? `/recipe remove ${m.name}` : `/cron remove <name>`} first, then /recipe add ${ref} again`,
|
|
@@ -2014,7 +2662,7 @@ export default function piLoops(pi: ExtensionAPI) {
|
|
|
2014
2662
|
const preflight = await preflightLines(recipe, project, level);
|
|
2015
2663
|
let overwrite = false;
|
|
2016
2664
|
if (plan.changed.length) {
|
|
2017
|
-
overwrite = await ctx.ui.confirm(`Overwrite ${plan.changed.length
|
|
2665
|
+
overwrite = await ctx.ui.confirm(`Overwrite ${plural(plan.changed.length, "edited file")}?`, [...plan.changed.map((f) => ` ${f}`), "", "These differ from what would be written. Yes replaces them with the packaged version; No keeps your copies (the record is still written, and /recipe update merges later)."].join("\n"));
|
|
2018
2666
|
}
|
|
2019
2667
|
const setupText = m.setup ? fs.readFileSync(path.join(recipe.dir, m.setup), "utf8") : undefined;
|
|
2020
2668
|
// The confirmation is the only gate before `sh <script>` runs with this session's
|
|
@@ -2047,7 +2695,7 @@ export default function piLoops(pi: ExtensionAPI) {
|
|
|
2047
2695
|
}
|
|
2048
2696
|
const lines: string[] = [];
|
|
2049
2697
|
const record = installFiles(recipe, project, level, { overwrite, source: resolved.source });
|
|
2050
|
-
lines.push(` ${record.files.length
|
|
2698
|
+
lines.push(` ${plural(record.files.length, "file")} → ${homeRel(plan.targetDir)}/`);
|
|
2051
2699
|
const excluded = ensureExcluded(project, path.join(INSTALL_ROOT, m.name));
|
|
2052
2700
|
// The tracker description names an account and a workflow; it is the project's to keep, not
|
|
2053
2701
|
// the repository's to publish — so it stays out of history the same way the playbooks do.
|
|
@@ -2062,13 +2710,20 @@ export default function piLoops(pi: ExtensionAPI) {
|
|
|
2062
2710
|
}
|
|
2063
2711
|
lines.push(` ${m.setup}: ok`, ...out);
|
|
2064
2712
|
}
|
|
2713
|
+
// A conversation pi has not saved yet (a starter's Add in a new page conversation) cannot own a loop.
|
|
2714
|
+
const owner = installScope(ctx.sessionManager.getSessionFile());
|
|
2715
|
+
if (owner.projectOwned) lines.push(" the loops belong to this project: this conversation is not saved yet, so a finding runs in the conversation you claim it from");
|
|
2065
2716
|
for (const line of plan.addLines) {
|
|
2066
2717
|
const parsed = parseAddArgs(line);
|
|
2067
|
-
const job = await createJob({ schedule: parsed.schedule, prompt: parsed.prompt, stateful: parsed.stateful, name: parsed.name, model: parsed.model, thinking: parsed.thinking, tools: parsed.tools, timeoutMs: parsed.timeoutMs, catchUp: parsed.catchUp, verify: parsed.verify, checkerModel: parsed.checkerModel, recipe: m.name });
|
|
2718
|
+
const job = await createJob({ schedule: parsed.schedule, prompt: parsed.prompt, stateful: parsed.stateful, name: parsed.name, model: parsed.model, thinking: parsed.thinking, tools: parsed.tools, timeoutMs: parsed.timeoutMs, catchUp: parsed.catchUp, verify: parsed.verify, checkerModel: parsed.checkerModel, recipe: m.name }, owner);
|
|
2068
2719
|
cronControlAudit("add", "slash", undefined, job);
|
|
2069
2720
|
const next = computeNext({ schedule: job.schedule, createdAt: Date.parse(job.createdAt) }, Date.now());
|
|
2070
|
-
lines.push(` job ${job.name}: ${formatSchedule(job.schedule)}${next ? ` (next run ${formatLocal(next)})` : ""}`);
|
|
2721
|
+
lines.push(` job ${job.name}: ${formatSchedule(job.schedule)}${next ? ` (next run ${formatLocal(next)})` : ""}${job.stateful ? "" : " · runs in this chat, and in the background while it is closed (each run a model turn)"}`);
|
|
2722
|
+
const dropped = toolsDropped(parsed.tools, job.tools);
|
|
2723
|
+
if (dropped.length) lines.push(` dropped from its tools: ${dropped.join(", ")} (this chat does not have it)`);
|
|
2071
2724
|
}
|
|
2725
|
+
// What every loop here takes to the background, said once for the recipe rather than per job.
|
|
2726
|
+
lines.push("", ` loops run in the background with this chat's built-in tools (${(session.builtinTools ?? []).join(", ") || "none"}), not this window's -e extensions or flags${projectNeedsTrust(session.cwd) && !isExactlyTrusted(getAgentDir(), session.cwd) ? "; this project's own settings and servers only once /trust saves trust for it" : ""}`);
|
|
2072
2727
|
lines.push("", ` edit the playbooks in ${path.join(INSTALL_ROOT, m.name)}/ — a run reads them fresh every time; /cron run <job> tries one now`);
|
|
2073
2728
|
show(ctx, `installed recipe ${m.name} (${level})`, lines);
|
|
2074
2729
|
refreshBadge();
|
|
@@ -2099,7 +2754,7 @@ export default function piLoops(pi: ExtensionAPI) {
|
|
|
2099
2754
|
const line = (r: Recipe) => {
|
|
2100
2755
|
const rec = installed.get(r.manifest.name);
|
|
2101
2756
|
const n = jobs.filter((j) => j.recipe === r.manifest.name).length;
|
|
2102
|
-
return ` ${r.manifest.name.padEnd(17)} ${
|
|
2757
|
+
return ` ${r.manifest.name.padEnd(17)} ${rec ? `[installed: ${rec.level}, ${plural(n, "loop")}] ` : ""}${r.manifest.line ?? r.manifest.summary}`;
|
|
2103
2758
|
};
|
|
2104
2759
|
// Starters first: they read and file findings, and are the ones to install before
|
|
2105
2760
|
// anything that writes to a tracker or opens a pull request.
|
|
@@ -2107,7 +2762,7 @@ export default function piLoops(pi: ExtensionAPI) {
|
|
|
2107
2762
|
for (const tier of RECIPE_TIERS) {
|
|
2108
2763
|
const group = packaged.filter((r) => r.manifest.tier === tier);
|
|
2109
2764
|
if (!group.length) continue;
|
|
2110
|
-
lines.push(tier === "starter" ? " starter
|
|
2765
|
+
lines.push(tier === "starter" ? " starter" : " advanced — writes to a worktree, a tracker or a pull request");
|
|
2111
2766
|
lines.push(...group.map(line));
|
|
2112
2767
|
}
|
|
2113
2768
|
for (const rec of installed.values()) {
|
|
@@ -2167,14 +2822,16 @@ export default function piLoops(pi: ExtensionAPI) {
|
|
|
2167
2822
|
}
|
|
2168
2823
|
const recipe = loadRecipe(path.isAbsolute(record.source) ? record.source : resolveRecipeRef(record.source, { cwd: project }).dir);
|
|
2169
2824
|
const result = updateFiles(recipe, project, record.level);
|
|
2825
|
+
// Queued first, so the line says what became of it: an earlier conflict hand-off of this
|
|
2826
|
+
// recipe still waiting collapses this one, and "handed to the agent" would then be false.
|
|
2827
|
+
const conflicts = result.conflicts.length ? await handOver(recipeRow(mergeConflictPrompt(name, dir, result.conflicts), name, "merge", rowAddress())) : undefined;
|
|
2170
2828
|
const lines = [
|
|
2171
2829
|
...(result.updated.length ? [` updated: ${result.updated.join(", ")}`] : []),
|
|
2172
2830
|
...(result.unchanged.length ? [` unchanged: ${result.unchanged.join(", ")}`] : []),
|
|
2173
2831
|
...(result.noBase.length ? [` left alone (no .orig to merge against): ${result.noBase.join(", ")}`] : []),
|
|
2174
|
-
...(
|
|
2832
|
+
...(conflicts ? [` conflicts, not written: ${result.conflicts.map((c) => c.file).join(", ")} — ${conflicts.written ? "handed to the agent to resolve with you" : "the agent is already queued to resolve this recipe's conflicts with you"}`] : []),
|
|
2175
2833
|
];
|
|
2176
2834
|
show(ctx, `recipe ${name}: merged pi-loops ${PI_LOOPS_VERSION} playbooks into ${homeRel(dir)}/`, lines);
|
|
2177
|
-
if (result.conflicts.length) pi.sendUserMessage(mergeConflictPrompt(name, dir, result.conflicts), ctx.isIdle() ? undefined : { deliverAs: "followUp" });
|
|
2178
2835
|
return;
|
|
2179
2836
|
}
|
|
2180
2837
|
case "remove":
|
|
@@ -2195,8 +2852,8 @@ export default function piLoops(pi: ExtensionAPI) {
|
|
|
2195
2852
|
return;
|
|
2196
2853
|
}
|
|
2197
2854
|
const ok = await ctx.ui.confirm(`Remove recipe ${name}?`, [
|
|
2198
|
-
jobs.length ? `${jobs.length
|
|
2199
|
-
purge ? `and delete what the install wrote in ${homeRel(dir)}/ (${record ? `${record.files.length
|
|
2855
|
+
jobs.length ? `${plural(jobs.length, "loop")}: ${jobs.map((j) => j.name ?? j.id).join(", ")}` : "no loops of its own here",
|
|
2856
|
+
purge ? `and delete what the install wrote in ${homeRel(dir)}/ (${record ? `${plural(record.files.length, "file")} as installed — an edited one is kept — their untouched copies, the record` : "no record: only the directory if it is empty"}) and the loops' notes` : `the files in ${homeRel(dir)}/ and the loops' notes are kept (--purge deletes both)`,
|
|
2200
2857
|
].join("\n"));
|
|
2201
2858
|
if (!ok) {
|
|
2202
2859
|
ctx.ui.notify("not removed", "info");
|
|
@@ -2212,7 +2869,7 @@ export default function piLoops(pi: ExtensionAPI) {
|
|
|
2212
2869
|
if (purge) {
|
|
2213
2870
|
if (record) {
|
|
2214
2871
|
const { kept } = purgeInstall(dir, record, listRecipes().find((r) => r.manifest.name === name)?.manifest.setup);
|
|
2215
|
-
if (kept.length > 0) keptNote = `; kept ${kept.length
|
|
2872
|
+
if (kept.length > 0) keptNote = `; kept ${plural(kept.length, "edited file")}: ${kept.join(", ")}`;
|
|
2216
2873
|
} else {
|
|
2217
2874
|
try {
|
|
2218
2875
|
fs.rmdirSync(dir);
|
|
@@ -2223,7 +2880,7 @@ export default function piLoops(pi: ExtensionAPI) {
|
|
|
2223
2880
|
}
|
|
2224
2881
|
refreshBadge();
|
|
2225
2882
|
ctx.ui.notify(
|
|
2226
|
-
`removed recipe ${name}: ${jobs.length
|
|
2883
|
+
`removed recipe ${name}: ${plural(jobs.length, "loop")}${purge ? ", its files and notes" : `; files kept in ${homeRel(dir)}/`}${aborted > 0 ? `; aborted ${plural(aborted, "running run")}` : ""}${keptNote}`,
|
|
2227
2884
|
"info",
|
|
2228
2885
|
);
|
|
2229
2886
|
return;
|
|
@@ -2256,7 +2913,7 @@ export default function piLoops(pi: ExtensionAPI) {
|
|
|
2256
2913
|
}
|
|
2257
2914
|
let messages: ShareMessage[];
|
|
2258
2915
|
try {
|
|
2259
|
-
messages = (ctx.sessionManager.buildContextEntries() as Array<{ message?: ShareMessage }>)
|
|
2916
|
+
messages = shareableMessages(ctx.sessionManager.buildContextEntries() as Array<{ type?: string; message?: ShareMessage }>);
|
|
2260
2917
|
} catch (err: any) {
|
|
2261
2918
|
ctx.ui.notify(`share: cannot read this session: ${err?.message ?? err}`, "error");
|
|
2262
2919
|
return;
|
|
@@ -2403,7 +3060,7 @@ export default function piLoops(pi: ExtensionAPI) {
|
|
|
2403
3060
|
const enabled = await offerToEnable(imp, store, triggers.store, mode === "ask" && ctx.hasUI ? (question, detail) => ctx.ui.confirm(question, detail) : undefined, (before, after) => cronControlAudit("enable", "slash", before, after));
|
|
2404
3061
|
if (enabled.jobs || enabled.rules) {
|
|
2405
3062
|
refreshBadge();
|
|
2406
|
-
ctx.ui.notify(`enabled ${enabled.jobs
|
|
3063
|
+
ctx.ui.notify(`enabled ${plural(enabled.jobs, "loop")} and ${plural(enabled.rules, "rule")}`, "info");
|
|
2407
3064
|
}
|
|
2408
3065
|
if (resume) await ctx.switchSession(imp.sessionPath);
|
|
2409
3066
|
} catch (err: any) {
|
|
@@ -2421,9 +3078,9 @@ export default function piLoops(pi: ExtensionAPI) {
|
|
|
2421
3078
|
return;
|
|
2422
3079
|
}
|
|
2423
3080
|
const prompt =
|
|
2424
|
-
"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.
|
|
3081
|
+
"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. 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" +
|
|
2425
3082
|
args.trim();
|
|
2426
|
-
|
|
3083
|
+
await handOver(newTriggerRow(prompt, rowAddress()));
|
|
2427
3084
|
},
|
|
2428
3085
|
});
|
|
2429
3086
|
|
|
@@ -2449,11 +3106,22 @@ export default function piLoops(pi: ExtensionAPI) {
|
|
|
2449
3106
|
const toolHost: ToolHost = {
|
|
2450
3107
|
scheduler,
|
|
2451
3108
|
triggers,
|
|
2452
|
-
|
|
3109
|
+
// In a scheduled turn the chat is the row's declared tools, not this window's: a job made
|
|
3110
|
+
// there gets no more built-ins than the turn, as a job a background run makes gets that run's.
|
|
3111
|
+
session: () => {
|
|
3112
|
+
const current = executor.currentTurn();
|
|
3113
|
+
if (!unattendedTurn(current)) return session;
|
|
3114
|
+
const capped = current.builtinTools ?? current.tools?.filter((t) => PI_BUILTIN_TOOL_NAMES.includes(t));
|
|
3115
|
+
return capped ? { ...session, builtinTools: [...capped] } : session;
|
|
3116
|
+
},
|
|
2453
3117
|
createJob: (input, scope) => createLoopJob(toolHost, input, scope),
|
|
2454
3118
|
cronControlAudit,
|
|
2455
3119
|
confirmTool,
|
|
2456
3120
|
refreshBadge,
|
|
3121
|
+
// A runtime that never sends session_start still gets its host this way (cron_create, new_trigger).
|
|
3122
|
+
ensureHost: () => void hostTick(false).catch((err: any) => log.info(`host: ${err?.message ?? err}`)),
|
|
3123
|
+
// The create tools ask only in the person's own turn: a scheduled turn keeps the window guard's gate.
|
|
3124
|
+
scheduledTurn: () => unattendedTurn(executor.currentTurn()),
|
|
2457
3125
|
};
|
|
2458
3126
|
/** The same host, bound to one sub-agent run: its project and its model, not this chat's. */
|
|
2459
3127
|
function toolHostFor(req: Pick<SubagentRequest, "cwd" | "model" | "thinking">): ToolHost {
|
|
@@ -2466,7 +3134,8 @@ export default function piLoops(pi: ExtensionAPI) {
|
|
|
2466
3134
|
}
|
|
2467
3135
|
/**
|
|
2468
3136
|
* Trigger creation/removal and trigger/cron enable need a person's yes: pi has no permission
|
|
2469
|
-
* popups, so the tool asks through `ctx.ui.confirm`
|
|
3137
|
+
* popups, so the tool asks itself, through `ctx.ui.confirm` in the person's own turn and through
|
|
3138
|
+
* the window guard's refuse-first `ctx.ui.select` in a scheduled one. A sub-agent has no UI, so
|
|
2470
3139
|
* `controlPlanePreflight` denies fail-closed (`ToolCallEventResult`).
|
|
2471
3140
|
*/
|
|
2472
3141
|
async function confirmTool(ctx: ExtensionContext, req: ControlPlaneRequest, atHop: number): Promise<string | undefined> {
|
|
@@ -2475,11 +3144,18 @@ export default function piLoops(pi: ExtensionAPI) {
|
|
|
2475
3144
|
// The approval card: Action / Tool / Reason / Args hash / Preview, then a feed line per decision.
|
|
2476
3145
|
const argsHash = createHash("sha256").update(JSON.stringify(req.args ?? null)).digest("hex").slice(0, 12);
|
|
2477
3146
|
ctx.ui.notify(`approval required: ${req.label}`, "info");
|
|
2478
|
-
const
|
|
2479
|
-
|
|
2480
|
-
|
|
3147
|
+
const card = [`Action: ${req.label}`, `Tool: ${req.tool}`, `Reason: ${req.reason}`, `Args hash: ${argsHash}`, `Preview: ${previewRedacted(req.preview, 200)}`].join("\n");
|
|
3148
|
+
// In a scheduled turn the question is the window guard's (design §6.3): its minute and no more,
|
|
3149
|
+
// since nobody may be there, and the refusing answer focused, so a reflexive Enter refuses.
|
|
3150
|
+
const scheduled = unattendedTurn(executor.currentTurn());
|
|
3151
|
+
const answer: GuardAnswer = scheduled ? await askRefusingFirst(ctx, "Control-plane approval required", card, WINDOW_GUARD_CONFIRM_MS) : (await ctx.ui.confirm("Control-plane approval required", card)) ? "yes" : "no";
|
|
3152
|
+
ctx.ui.notify(answer === "yes" ? `approved control-plane action: ${req.label}` : `denied control-plane action: ${req.label} (${answer === "no" ? "denied by user" : "not answered in time"})`, "info");
|
|
3153
|
+
return answer === "yes" ? undefined : answer === "no" ? `user declined: ${req.label}` : `nobody answered in time: ${req.label}`;
|
|
2481
3154
|
}
|
|
2482
3155
|
for (const def of automationTools({ hop, actor: "tool" }, toolHost)) pi.registerTool(def);
|
|
3156
|
+
// The cap on a scheduled turn's calls while this window runs it (src/window-guard.ts): the
|
|
3157
|
+
// decision is there, this only hands it what pi is in and what config.toml says.
|
|
3158
|
+
pi.on("tool_call", windowGuard({ current: () => executor.currentTurn(), config: () => ({ windowGuard: config.windowGuard, allowCommands: config.allowCommands }), log: (line) => log.info(line) }));
|
|
2483
3159
|
|
|
2484
3160
|
/* -------------------------------------------------------- lifecycle */
|
|
2485
3161
|
|
|
@@ -2529,78 +3205,217 @@ export default function piLoops(pi: ExtensionAPI) {
|
|
|
2529
3205
|
ctx.ui.notify(`[pi-loops] ${lines.map((l) => l.trim()).join(" · ")}`, code === 0 ? "info" : "warning");
|
|
2530
3206
|
}
|
|
2531
3207
|
|
|
3208
|
+
/**
|
|
3209
|
+
* Run `say` once pi has drawn the session's history. pi draws a resumed conversation after
|
|
3210
|
+
* `session_start` returns, and keeps only what fits the screen: a line said before that was cut
|
|
3211
|
+
* from any conversation longer than a screen.
|
|
3212
|
+
*/
|
|
3213
|
+
function afterHistory(ctx: ExtensionContext, say: () => void): void {
|
|
3214
|
+
if (ctx.mode !== "tui") return say();
|
|
3215
|
+
// pi hands each event a new context object, so the session is told apart by a count instead.
|
|
3216
|
+
const generation = sessionGeneration;
|
|
3217
|
+
setTimeout(() => {
|
|
3218
|
+
if (generation === sessionGeneration) say();
|
|
3219
|
+
}, 1000).unref();
|
|
3220
|
+
}
|
|
3221
|
+
/** Bumped at every session_shutdown: a line meant for the session that left is not said in the next. */
|
|
3222
|
+
let sessionGeneration = 0;
|
|
3223
|
+
|
|
3224
|
+
/**
|
|
3225
|
+
* The info lines a session start says, gathered into one notice. pi shows back-to-back info
|
|
3226
|
+
* notices on one line, each replacing the one before, so said one by one only the last was seen.
|
|
3227
|
+
* `startShown` runs once that notice is on the screen.
|
|
3228
|
+
*/
|
|
3229
|
+
let startLines: string[] | undefined;
|
|
3230
|
+
let startShown: (() => void)[] = [];
|
|
3231
|
+
/** What the window left behind on `/new` or `/resume` (src/job-lines.ts leftConversationLine), said at the next session's start, where pi draws again. */
|
|
3232
|
+
let leftLine: string | undefined;
|
|
3233
|
+
function sayInfo(line: string): void {
|
|
3234
|
+
if (startLines) startLines.push(line);
|
|
3235
|
+
else if (lastCtx?.hasUI) lastCtx.ui.notify(line, "info");
|
|
3236
|
+
}
|
|
3237
|
+
function flushStartLines(ctx: ExtensionContext): void {
|
|
3238
|
+
const lines = startLines ?? [];
|
|
3239
|
+
const shown = startShown;
|
|
3240
|
+
startLines = undefined;
|
|
3241
|
+
startShown = [];
|
|
3242
|
+
if (!lines.length || !ctx.hasUI) return;
|
|
3243
|
+
afterHistory(ctx, () => {
|
|
3244
|
+
ctx.ui.notify(lines.join("\n"), "info");
|
|
3245
|
+
for (const done of shown) done();
|
|
3246
|
+
});
|
|
3247
|
+
}
|
|
3248
|
+
|
|
2532
3249
|
pi.on("session_start", async (_event, ctx) => {
|
|
2533
3250
|
lastCtx = ctx;
|
|
2534
3251
|
if (startHandled) return; // the same start, delivered a second time by a second rebind
|
|
2535
3252
|
startHandled = true;
|
|
3253
|
+
leaving = false;
|
|
2536
3254
|
session = sessionSnapshot(ctx);
|
|
2537
|
-
|
|
2538
|
-
|
|
2539
|
-
|
|
2540
|
-
|
|
2541
|
-
|
|
2542
|
-
|
|
2543
|
-
|
|
2544
|
-
|
|
2545
|
-
|
|
2546
|
-
|
|
2547
|
-
|
|
2548
|
-
|
|
2549
|
-
|
|
2550
|
-
|
|
2551
|
-
|
|
2552
|
-
|
|
2553
|
-
|
|
2554
|
-
|
|
2555
|
-
|
|
2556
|
-
|
|
2557
|
-
|
|
2558
|
-
|
|
2559
|
-
|
|
2560
|
-
|
|
2561
|
-
|
|
2562
|
-
|
|
2563
|
-
|
|
2564
|
-
|
|
2565
|
-
|
|
2566
|
-
|
|
2567
|
-
|
|
2568
|
-
|
|
2569
|
-
|
|
2570
|
-
|
|
2571
|
-
|
|
2572
|
-
|
|
2573
|
-
|
|
2574
|
-
|
|
2575
|
-
|
|
2576
|
-
|
|
2577
|
-
|
|
2578
|
-
|
|
2579
|
-
|
|
2580
|
-
|
|
2581
|
-
|
|
2582
|
-
|
|
2583
|
-
|
|
2584
|
-
|
|
2585
|
-
|
|
3255
|
+
startLines = [];
|
|
3256
|
+
startShown = [];
|
|
3257
|
+
try {
|
|
3258
|
+
bindSession(ctx);
|
|
3259
|
+
// A 0.22.x pi on the same conversation has no lease and fires its plain jobs regardless.
|
|
3260
|
+
const older = olderPiOnSession(scheduler.presenceList(), session.sessionId, scheduler.self);
|
|
3261
|
+
if (older && ctx.hasUI) ctx.ui.notify("[cron] this conversation is also open in a window with an older pi-loops; both windows may run its plain jobs — close or restart that window", "warning");
|
|
3262
|
+
config = loadConfig(dir);
|
|
3263
|
+
// The goal lives in the session, so `--resume` picks up where it left off.
|
|
3264
|
+
goal = latestGoal(ctx.sessionManager.getEntries() as any);
|
|
3265
|
+
if (goal && ctx.hasUI) ctx.ui.setStatus(GOAL_STATUS_KEY, `goal: ${goal.status}`);
|
|
3266
|
+
// Rules are checked by the host, which reads config.toml and no window's flags: the flag is kept
|
|
3267
|
+
// so a command line that passes it still starts, and says where the interval is set now.
|
|
3268
|
+
const flagRaw = pi.getFlag("trigger-poll-secs");
|
|
3269
|
+
if (flagRaw !== undefined && flagRaw !== "") config.errors.push(`triggers: --trigger-poll-secs is ignored: pi-loops checks rules every [triggers] poll_interval_secs in config.toml (now ${config.triggerPollIntervalSecs}s)`);
|
|
3270
|
+
triggers.pollIntervalSecs = config.triggerPollIntervalSecs;
|
|
3271
|
+
triggers.runTimeoutMs = config.triggerRunTimeoutMs;
|
|
3272
|
+
// Without a UI, hook failures go to stderr instead of vanishing.
|
|
3273
|
+
const warnHook = (message: string) => (ctx.hasUI ? ctx.ui.notify(`[hooks] ${message}`, "warning") : process.stderr.write(`[pi-loops hooks] ${message}\n`));
|
|
3274
|
+
hookRunner = new HookRunner({ loopsDir: dir, projectCwd: ctx.cwd, allowProjectHooks: config.allowProjectHooks, getSession: () => session, warn: warnHook, log: (msg) => log.info(msg) });
|
|
3275
|
+
hookRunner.load();
|
|
3276
|
+
for (const e of [...config.errors, ...hookRunner.diagnostics]) if (ctx.hasUI) ctx.ui.notify(`[pi-loops] ${e}`, "warning");
|
|
3277
|
+
loadMcpConfig(windowTrusted(getAgentDir(), ctx.cwd, ctx.isProjectTrusted(), APPROVED));
|
|
3278
|
+
for (const d of mcpDiagnostics) if (ctx.hasUI) ctx.ui.notify(`[pi-loops] ${d}`, "warning");
|
|
3279
|
+
await startMcpSources(); // tools for every process; pushes are consumed by interactive processes only
|
|
3280
|
+
// Not awaited: the question below is a dialog, and the clock must not wait behind someone who
|
|
3281
|
+
// has walked away from it.
|
|
3282
|
+
void keepLauncher(ctx).catch((err: any) => log.info(`launcher: ${err?.message ?? err}`));
|
|
3283
|
+
// tui and rpc processes stay alive and run the clock; `PI_LOOPS_HOST=1` starts it in a `pi -p`
|
|
3284
|
+
// run too, which queues its conversation's due prompts but hands none to pi (the window
|
|
3285
|
+
// executor delivers only in tui and rpc mode): a window or pi-loops in the background does.
|
|
3286
|
+
const hostMode = ctx.mode === "tui" || ctx.mode === "rpc" || envFlag("LOOPS_HOST");
|
|
3287
|
+
if (!hostMode) return;
|
|
3288
|
+
// The host runs the loops; this window only makes sure there is one. Not awaited: a start can
|
|
3289
|
+
// take seconds, and the session must not wait behind it. A probe here never counts toward
|
|
3290
|
+
// evicting a host that is still starting.
|
|
3291
|
+
watchStore();
|
|
3292
|
+
// The session-start line below says a pause already in place when this project has loops or rules;
|
|
3293
|
+
// the tick does not say it a second time. Elsewhere the tick is what says it.
|
|
3294
|
+
const saysPause = loadOrNone(() => scheduler.store.load()).some((j) => sameProject(j.cwd, session.cwd) && j.enabled && j.stateful) || loadOrNone(() => triggers.store.load()).some((r) => sameProject(r.cwd, session.cwd) && r.enabled);
|
|
3295
|
+
shownPause = saysPause && hostHasWork() ? hostPauseReason() : undefined;
|
|
3296
|
+
hostTick(false).catch((err: any) => log.info(`host: ${err?.message ?? err}`));
|
|
3297
|
+
runsShownAfter = stamp();
|
|
3298
|
+
scheduler.start();
|
|
3299
|
+
schedulerStarted = true;
|
|
3300
|
+
refreshBadge();
|
|
3301
|
+
pruneLogs(dir);
|
|
3302
|
+
// What was loaded is printed on every start; without a line here a session
|
|
3303
|
+
// can begin with loops and rules the user has entirely forgotten about.
|
|
3304
|
+
// "Active here" means it: a plain job of another session is asleep, not active.
|
|
3305
|
+
const myJobs = scheduler.store.load().filter((j) => sameProject(j.cwd, session.cwd) && j.enabled && runsHere(j, scheduler.ownerView()));
|
|
3306
|
+
const myRules = loadOrNone(() => triggers.store.load()).filter((r) => sameProject(r.cwd, session.cwd) && r.enabled);
|
|
3307
|
+
// What is scheduled says nothing about what is working: a loop that has failed every night
|
|
3308
|
+
// since Tuesday is still counted as active, and this line is the only one a user reads.
|
|
3309
|
+
const failing = failingSummary(myJobs);
|
|
3310
|
+
log.info(`session start: ${myJobs.length} enabled loop(s), ${myRules.length} enabled rule(s)${failing ? `, ${failing}` : ""} in ${session.cwd}`);
|
|
3311
|
+
{
|
|
3312
|
+
const line = awayLine(awayHere());
|
|
3313
|
+
// After pi has drawn the resumed history: said now, a long conversation pushed it off the screen.
|
|
3314
|
+
if (line) sayInfo(line);
|
|
3315
|
+
}
|
|
3316
|
+
// Said once, the first time this version starts: plain jobs used to sleep while their conversation
|
|
3317
|
+
// was closed, and closing it was how people stopped one. Now the host runs them, and they cost turns.
|
|
3318
|
+
if (ctx.hasUI && readUiPrefs(dir).heldJobsNotice !== "shown") {
|
|
3319
|
+
// Every conversation's plain jobs (src/job-lines.ts heldJobsNotice): one an older pi-loops last
|
|
3320
|
+
// had open runs in the background too, once it has been opened here; a count of what runs
|
|
3321
|
+
// right now was spent on the first start and said nothing about the rest.
|
|
3322
|
+
const line = heldJobsNotice(scheduler.store.load());
|
|
3323
|
+
if (line) {
|
|
3324
|
+
sayInfo(line);
|
|
3325
|
+
// Marked only once it is on the screen: a notice used up unseen is never said again.
|
|
3326
|
+
startShown.push(() => writeUiPref(dir, "heldJobsNotice", "shown"));
|
|
3327
|
+
}
|
|
3328
|
+
}
|
|
3329
|
+
if (ctx.hasUI && readUiPrefs(dir).toolsCapNotice !== "shown") {
|
|
3330
|
+
// A 0.22 plain job's `--tools` was never used; now it caps the job's turns (docs/loops.md).
|
|
3331
|
+
const line = toolsCapNotice(scheduler.store.load());
|
|
3332
|
+
if (line) {
|
|
3333
|
+
sayInfo(line);
|
|
3334
|
+
startShown.push(() => writeUiPref(dir, "toolsCapNotice", "shown"));
|
|
3335
|
+
}
|
|
3336
|
+
}
|
|
3337
|
+
if (leftLine) {
|
|
3338
|
+
sayInfo(leftLine);
|
|
3339
|
+
leftLine = undefined;
|
|
3340
|
+
}
|
|
3341
|
+
const pausedLoops = scheduler.store.load().filter((j) => sameProject(j.cwd, session.cwd) && j.enabled && j.stateful).length;
|
|
3342
|
+
const pauseReason = hostPauseReason();
|
|
3343
|
+
if (ctx.hasUI && pauseReason && (pausedLoops || myRules.length)) {
|
|
3344
|
+
afterHistory(ctx, () => ctx.ui.notify(`[cron] ${plural(pausedLoops, "loop")} and ${plural(myRules.length, "rule")} here are paused: ${pauseReason}`, "warning"));
|
|
3345
|
+
} else if (ctx.hasUI && (myJobs.length || myRules.length)) {
|
|
3346
|
+
const next = myJobs
|
|
3347
|
+
.map((j) => computeNext({ schedule: j.schedule, createdAt: Date.parse(j.createdAt), lastFiredAt: j.lastFiredAt ? Date.parse(j.lastFiredAt) : undefined }, Date.now()))
|
|
3348
|
+
.filter((n): n is number => n !== undefined)
|
|
3349
|
+
.sort((a, b) => a - b)[0];
|
|
3350
|
+
const active = `[cron] ${plural(myJobs.length, "loop")} and ${plural(myRules.length, "rule")} active here${next ? ` · next ${formatLocal(next)}` : ""}${failing ? ` · ${failing} — /cron runs` : ""}`;
|
|
3351
|
+
sayInfo(active);
|
|
3352
|
+
}
|
|
3353
|
+
} finally {
|
|
3354
|
+
flushStartLines(ctx);
|
|
2586
3355
|
}
|
|
2587
3356
|
});
|
|
2588
3357
|
|
|
3358
|
+
/** Only the page's pi says on its lease that a turn runs: a terminal takes a conversation from it between turns, never in one. */
|
|
3359
|
+
let turnMarks: TurnMarks = {};
|
|
3360
|
+
function markTurn(event: keyof typeof TURN_EVENTS): void {
|
|
3361
|
+
// The page child, for a terminal's takeover (src/lease.ts decideAcquire); a terminal, for the
|
|
3362
|
+
// page that views its conversation and reads it again when the turn ends (shouldReadAgain).
|
|
3363
|
+
if (!pageChild && leaseMode !== "tui") return;
|
|
3364
|
+
const was = turnSinceOf(turnMarks);
|
|
3365
|
+
turnMarks = markTurnEvent(turnMarks, event, stamp());
|
|
3366
|
+
const since = turnSinceOf(turnMarks);
|
|
3367
|
+
if (since === was) return;
|
|
3368
|
+
try {
|
|
3369
|
+
binding.setTurn(since);
|
|
3370
|
+
} catch (err: any) {
|
|
3371
|
+
log.info(`lease: ${err?.message ?? err}`);
|
|
3372
|
+
}
|
|
3373
|
+
}
|
|
3374
|
+
|
|
2589
3375
|
// hooks.toml events, mapped from pi's lifecycle events.
|
|
2590
|
-
pi.on("agent_start", async (_e, ctx) =>
|
|
3376
|
+
pi.on("agent_start", async (_e, ctx) => {
|
|
3377
|
+
bindSession(ctx);
|
|
3378
|
+
markTurn("agent_start");
|
|
3379
|
+
executor.agentStarted(Date.now());
|
|
3380
|
+
await fireHook({ event: "agent_start" }, ctx);
|
|
3381
|
+
});
|
|
3382
|
+
// Observed only: the person typing is what makes the next user message theirs (the cap). Our own
|
|
3383
|
+
// hand-overs arrive here too, as `source: "extension"`; either says pi is about to run something,
|
|
3384
|
+
// so nothing handed over is judged refused for a while yet.
|
|
3385
|
+
pi.on("input", async (e) => {
|
|
3386
|
+
executor.inputSeen(Date.now());
|
|
3387
|
+
if (e.source !== "extension") executor.personInput();
|
|
3388
|
+
});
|
|
2591
3389
|
pi.on("turn_start", async (_e, ctx) => fireHook({ event: "turn_start" }, ctx));
|
|
2592
3390
|
pi.on("turn_end", async (e, ctx) => fireHook({ event: "turn_end", message_kind: messageKind(e.message), message_summary: messageSummary(e.message) }, ctx));
|
|
2593
|
-
pi.on("message_start", async (e, ctx) =>
|
|
3391
|
+
pi.on("message_start", async (e, ctx) => {
|
|
3392
|
+
executor.messageStarted(e.message);
|
|
3393
|
+
await fireHook({ event: "message_start", message_kind: messageKind(e.message), message_summary: messageSummary(e.message) }, ctx);
|
|
3394
|
+
});
|
|
2594
3395
|
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));
|
|
2595
|
-
pi.on("message_end", async (e, ctx) =>
|
|
3396
|
+
pi.on("message_end", async (e, ctx) => {
|
|
3397
|
+
// pi appends the user message once this handler returns: a drain right after finds a person
|
|
3398
|
+
// row's proof in the conversation, and the page's queue stops listing a message that is running.
|
|
3399
|
+
if ((e.message as { role?: unknown } | undefined)?.role === "user" && executor.handedOver.size) setImmediate(() => void drainRequests().catch((err: any) => log.info(`requests: ${err?.message ?? err}`)));
|
|
3400
|
+
await fireHook({ event: "message_end", message_kind: messageKind(e.message), message_summary: messageSummary(e.message) }, ctx);
|
|
3401
|
+
});
|
|
2596
3402
|
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));
|
|
2597
3403
|
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));
|
|
2598
3404
|
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));
|
|
2599
|
-
pi.on("
|
|
3405
|
+
pi.on("session_before_compact", async () => {
|
|
3406
|
+
markTurn("session_before_compact");
|
|
3407
|
+
});
|
|
3408
|
+
pi.on("session_compact", async (e, ctx) => {
|
|
3409
|
+
markTurn("session_compact");
|
|
3410
|
+
await 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);
|
|
3411
|
+
});
|
|
2600
3412
|
// A compaction that failed or was cancelled is the same news to a watcher, and the more urgent
|
|
2601
3413
|
// half of it: a session that cannot compact is a session about to fail on context length. It
|
|
2602
3414
|
// carries no summary and no token count — nothing was written — only the flag saying so.
|
|
2603
|
-
pi.on("session_compact_failed", async (e, ctx) =>
|
|
3415
|
+
pi.on("session_compact_failed", async (e, ctx) => {
|
|
3416
|
+
markTurn("session_compact_failed");
|
|
3417
|
+
await fireHook({ event: "compaction", compaction_trigger: e.reason === "manual" ? "manual" : "auto", compaction_failed: true }, ctx);
|
|
3418
|
+
});
|
|
2604
3419
|
|
|
2605
3420
|
pi.on("model_select", async (_event, ctx) => {
|
|
2606
3421
|
session = sessionSnapshot(ctx);
|
|
@@ -2613,8 +3428,10 @@ export default function piLoops(pi: ExtensionAPI) {
|
|
|
2613
3428
|
goalMessages = event.messages ?? [];
|
|
2614
3429
|
const last: any = (event.messages ?? []).at(-1);
|
|
2615
3430
|
lastTurnStopReason = typeof last?.stopReason === "string" ? last.stopReason : undefined;
|
|
3431
|
+
executor.agentEnded();
|
|
2616
3432
|
if (lastTurnStopReason === "aborted") {
|
|
2617
3433
|
goalAbort?.abort();
|
|
3434
|
+
goalSkippedTurn = false;
|
|
2618
3435
|
// Esc pauses the goal, not only this turn's judgement: the person said stop, and a goal
|
|
2619
3436
|
// that went on pursuing would send them back to work on their next message.
|
|
2620
3437
|
const stopped = goal && abortedTurn(goal);
|
|
@@ -2629,6 +3446,9 @@ export default function piLoops(pi: ExtensionAPI) {
|
|
|
2629
3446
|
// The goal is evaluated at the end of a turn; pi's hook for that is `agent_settled` — the point where
|
|
2630
3447
|
// no automatic retry, compaction or queued continuation will run, so a decision here is final.
|
|
2631
3448
|
pi.on("agent_settled", async (_event, ctx) => {
|
|
3449
|
+
markTurn("agent_settled");
|
|
3450
|
+
// pi is idle now: what it dropped is offered again by the drain below.
|
|
3451
|
+
executor.settled();
|
|
2632
3452
|
// The tracker description `/recipe add` was waiting for: written during this turn, so the
|
|
2633
3453
|
// wizard picks up where it stopped — in the same project, with the same words.
|
|
2634
3454
|
if (pendingRecipeAdd && sameProject(pendingRecipeAdd.project, session.cwd) && fs.existsSync(path.join(pendingRecipeAdd.project, TRACKER_FILE))) {
|
|
@@ -2641,76 +3461,107 @@ export default function piLoops(pi: ExtensionAPI) {
|
|
|
2641
3461
|
}
|
|
2642
3462
|
}
|
|
2643
3463
|
lastCtx = ctx;
|
|
2644
|
-
|
|
2645
|
-
|
|
2646
|
-
|
|
2647
|
-
|
|
2648
|
-
|
|
3464
|
+
// Judge the turn unless a continuation is already on its way, then drain (src/goal.ts settledTurn).
|
|
3465
|
+
const sessionId = binding.here().sessionId;
|
|
3466
|
+
const evaluate = async () => {
|
|
3467
|
+
try {
|
|
3468
|
+
const skipped = () => {
|
|
3469
|
+
const was = goalSkippedTurn;
|
|
3470
|
+
goalSkippedTurn = false;
|
|
3471
|
+
return was;
|
|
3472
|
+
};
|
|
3473
|
+
return await judgeCaughtUp(() => evaluateGoal(ctx), skipped, () => ctx.isIdle());
|
|
3474
|
+
} catch (err: any) {
|
|
3475
|
+
if (ctx.hasUI) ctx.ui.notify(`[goal] ${err?.message ?? err}`, "warning");
|
|
3476
|
+
return false;
|
|
3477
|
+
}
|
|
3478
|
+
};
|
|
3479
|
+
await settledTurn(sessionId ? requests.list(sessionId) : [], executor.handedOver, delivered, evaluate, () => drainRequests().catch((err: any) => log.info(`requests: ${err?.message ?? err}`)));
|
|
2649
3480
|
});
|
|
2650
3481
|
|
|
2651
|
-
/** Whether this quitting pi should hand the clock to a background host. May throw on a damaged store. */
|
|
2652
|
-
function handOffDecision(here: string, wasStarted: boolean): ReturnType<typeof shouldHandOff> | undefined {
|
|
2653
|
-
if (!wasStarted) return undefined;
|
|
2654
|
-
const enabledRules = triggers.store.load().filter((r) => r.enabled).length;
|
|
2655
|
-
return shouldHandOff({
|
|
2656
|
-
auto: handsOffOnQuit(),
|
|
2657
|
-
presence: scheduler.presenceList(),
|
|
2658
|
-
selfPid: process.pid,
|
|
2659
|
-
selfInstance: scheduler.self.instance,
|
|
2660
|
-
hostName: here,
|
|
2661
|
-
enabledLoops: scheduler.store.load().filter((j) => j.enabled && j.stateful).length,
|
|
2662
|
-
enabledRules,
|
|
2663
|
-
pushServers: hostPushWork(loadMcpConfigFiles({ dir, projectTrusted: false }).servers, enabledRules),
|
|
2664
|
-
hostAlive: !!liveHost(dir),
|
|
2665
|
-
});
|
|
2666
|
-
}
|
|
2667
|
-
|
|
2668
3482
|
pi.on("session_shutdown", async (event, ctx) => {
|
|
2669
|
-
|
|
2670
|
-
|
|
3483
|
+
sessionGeneration++;
|
|
3484
|
+
leaving = true;
|
|
3485
|
+
// Said on the way out, as it was when the last window handed loops on: closing the window does
|
|
3486
|
+
// not stop them, and a person who just closed it is deciding whether they keep running. pi
|
|
3487
|
+
// draws no notification while it quits, so the line goes to the terminal once pi has left it.
|
|
3488
|
+
// A store that cannot be read must not cost the lease release and the stops below.
|
|
3489
|
+
// This conversation's plain jobs too: closing it used to stop them, and now it does not. The
|
|
3490
|
+
// line is worded at exit, once the background process has been started for them (below).
|
|
3491
|
+
let mine = 0;
|
|
3492
|
+
/** Set when this quit started pi-loops in the background and did not wait for it to register. */
|
|
3493
|
+
let startedOnQuit = false;
|
|
3494
|
+
if (event.reason === "quit") {
|
|
3495
|
+
try {
|
|
3496
|
+
// Only once pi has written the conversation's file: until then there is nothing to run them in.
|
|
3497
|
+
const file = ctx.sessionManager.getSessionFile();
|
|
3498
|
+
mine = file && fs.existsSync(file) ? scheduler.store.load().filter((j) => j.enabled && !j.stateful && j.sessionId === session.sessionId).length : 0;
|
|
3499
|
+
const work = automationCounts();
|
|
3500
|
+
const what = [work.loops || work.rules ? `${plural(work.loops, "loop")} and ${plural(work.rules, "rule")}` : "", mine ? `${plural(mine, "plain job")} of this conversation (each run a model turn)` : ""].filter(Boolean).join(", and ");
|
|
3501
|
+
if (what && ctx.hasUI && ctx.mode === "tui") {
|
|
3502
|
+
process.once("exit", () => {
|
|
3503
|
+
const live = liveHostRecord();
|
|
3504
|
+
const pause = hostPauseReason();
|
|
3505
|
+
// `[host] auto = false`: with no other window open, pi-loops leaves with this one (0.22.8).
|
|
3506
|
+
const lastWindow = !config.hostAuto && readUiPrefs(dir).host !== "started" && ![...openWindowPids(binding.store)].some((pid) => pid !== process.pid);
|
|
3507
|
+
const line = lastWindow ? `${what} stop with this window ([host] auto = false); they run again once a pi window is open` : live ? `${what} keep running in the background; pi-loops stop ends them` : startedOnQuit ? `${what} keep running once pi-loops has started in the background; pi-loops stop ends them` : pause ? `${what} are paused: ${pause}` : `${what} run in the background once pi-loops is running: pi-loops start`;
|
|
3508
|
+
process.stderr.write(`[pi-loops] ${line}\n`);
|
|
3509
|
+
});
|
|
3510
|
+
}
|
|
3511
|
+
} catch (err: any) {
|
|
3512
|
+
log.info(`quit line: ${err?.message ?? err}`);
|
|
3513
|
+
}
|
|
3514
|
+
}
|
|
3515
|
+
// The host keeps running whatever this window does: nothing is handed off.
|
|
2671
3516
|
//
|
|
2672
3517
|
// `/new`, `/resume`, `/reload` and `/fork` replace the session inside the same process, and pi
|
|
2673
3518
|
// rebuilds the extension with it — so this scheduler must stop, or two would run at once.
|
|
2674
3519
|
// A run aborted by the swap gives its slot back (see `launch`), and the replacement session
|
|
2675
3520
|
// re-fires it on its first tick instead of losing it until the next due time.
|
|
2676
3521
|
startHandled = false;
|
|
2677
|
-
const wasStarted = schedulerStarted;
|
|
2678
3522
|
if (schedulerStarted) {
|
|
2679
3523
|
schedulerStarted = false;
|
|
2680
3524
|
await triggers.stop();
|
|
2681
3525
|
await scheduler.stop();
|
|
2682
3526
|
}
|
|
2683
|
-
//
|
|
2684
|
-
|
|
2685
|
-
|
|
2686
|
-
|
|
2687
|
-
|
|
3527
|
+
// The conversation is free for the next window (or, after /new, the next session here) at once.
|
|
3528
|
+
requestWatch?.close();
|
|
3529
|
+
requestWatch = undefined;
|
|
3530
|
+
storeWatch?.close();
|
|
3531
|
+
storeWatch = undefined;
|
|
3532
|
+
clearTimeout(storeWatchTimer);
|
|
3533
|
+
storeWatchTimer = undefined;
|
|
3534
|
+
try {
|
|
3535
|
+
leaveSession();
|
|
3536
|
+
} catch (err: any) {
|
|
3537
|
+
log.info(`requests: ${err?.message ?? err}`);
|
|
2688
3538
|
}
|
|
2689
|
-
config = loadConfig(dir); // `[host] auto` may have been edited while this pi was open
|
|
2690
|
-
const here = os.hostname();
|
|
2691
|
-
// A damaged store must not abort the rest of shutdown: the hooks, the MCP pool and the MCP
|
|
2692
|
-
// servers below all still have to be torn down, or their child processes outlive pi.
|
|
2693
|
-
let handOff: ReturnType<typeof shouldHandOff> | undefined;
|
|
2694
3539
|
try {
|
|
2695
|
-
|
|
3540
|
+
binding.release();
|
|
2696
3541
|
} catch (err: any) {
|
|
2697
|
-
|
|
3542
|
+
log.info(`lease release: ${err?.message ?? err}`);
|
|
2698
3543
|
}
|
|
2699
|
-
if (
|
|
3544
|
+
if (event.reason !== "quit") {
|
|
3545
|
+
// Leaving by /new or /resume: in 0.22.8 that stopped the conversation's plain jobs, and now it
|
|
3546
|
+
// does not. Said once the next session has drawn, since pi draws nothing during the swap.
|
|
2700
3547
|
try {
|
|
2701
|
-
|
|
2702
|
-
|
|
2703
|
-
|
|
2704
|
-
|
|
2705
|
-
const note = up
|
|
2706
|
-
? `[cron] handed the clock to a background host (pid ${pid}; ${handOff.reason}); /cron host stop ends it`
|
|
2707
|
-
: `[cron] the background host (pid ${pid}) did not start; automation is not running — see ${homeRel(path.join(dir, HOST_LOG))}`;
|
|
2708
|
-
log.write(up ? "info" : "error", note.replace("[cron] ", ""));
|
|
2709
|
-
if (ctx.hasUI) ctx.ui.notify(note, up ? "info" : "warning");
|
|
2710
|
-
else process.stderr.write(`${note}\n`);
|
|
3548
|
+
// A reload keeps the conversation; new, resume and fork leave it.
|
|
3549
|
+
const left = event.reason !== "reload" ? session.sessionId : undefined;
|
|
3550
|
+
const plain = left ? scheduler.store.load().filter((j) => j.enabled && !j.stateful && j.sessionId === left).length : 0;
|
|
3551
|
+
leftLine = left ? leftConversationLine(left, plain, config.hostAuto) : undefined;
|
|
2711
3552
|
} catch (err: any) {
|
|
2712
|
-
|
|
3553
|
+
log.info(`left line: ${err?.message ?? err}`);
|
|
2713
3554
|
}
|
|
3555
|
+
await hookRunner?.drain(3000);
|
|
3556
|
+
return; // the replacement session_start starts everything again
|
|
3557
|
+
}
|
|
3558
|
+
// Closed now: its plain jobs are the background process's to run, and with no loop or rule on
|
|
3559
|
+
// this machine nothing has started one yet. With `[host] auto = false` and this the last window,
|
|
3560
|
+
// a process started now would only leave again.
|
|
3561
|
+
// Started, not waited for: quitting has nothing to do with the answer, and the wait cost seconds.
|
|
3562
|
+
if (mine && config.hostAuto) {
|
|
3563
|
+
const out = await hostTick(false, false, false).catch((err: any) => void log.info(`host: ${err?.message ?? err}`));
|
|
3564
|
+
startedOnQuit = out?.action.kind === "spawn" && !!out.pid && !out.error;
|
|
2714
3565
|
}
|
|
2715
3566
|
await hookRunner?.drain(3000);
|
|
2716
3567
|
await mcpPool.stopAll();
|