@alphacoder-v0/pi-loops 0.22.8 → 0.24.1

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