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