@alphacoder-v0/pi-loops 0.22.7 → 0.24.0

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