@alphacoder-v0/pi-loops 0.22.7 → 0.24.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (91) hide show
  1. package/CHANGELOG.md +681 -0
  2. package/CONTRACT.md +7 -7
  3. package/README.md +91 -63
  4. package/README.zh-CN.md +59 -39
  5. package/docs/cli.md +272 -83
  6. package/docs/configuration.md +86 -24
  7. package/docs/design.md +395 -56
  8. package/docs/downstream.md +2 -2
  9. package/docs/goal.md +12 -3
  10. package/docs/hooks.md +6 -7
  11. package/docs/loops.md +288 -59
  12. package/docs/mcp.md +22 -14
  13. package/docs/recipes.md +6 -0
  14. package/docs/screenshot.png +0 -0
  15. package/docs/triggers.md +120 -26
  16. package/docs/troubleshooting.md +37 -26
  17. package/docs/web-ui-parity.md +691 -65
  18. package/package.json +4 -3
  19. package/recipes/autoresearch/recipe.toml +1 -0
  20. package/recipes/changelog-draft/recipe.toml +1 -0
  21. package/recipes/ci-sweeper/recipe.toml +1 -0
  22. package/recipes/daily-digest/recipe.toml +1 -0
  23. package/recipes/deps-sweeper/recipe.toml +1 -0
  24. package/recipes/ecosystem/recipe.toml +1 -0
  25. package/recipes/issue-loop/recipe.toml +1 -0
  26. package/recipes/pr-watch/recipe.toml +1 -0
  27. package/skills/pi-loops/SKILL.md +6 -4
  28. package/src/activity.ts +199 -0
  29. package/src/archive.ts +21 -9
  30. package/src/args.ts +6 -2
  31. package/src/cli-entry.mjs +12 -3
  32. package/src/cli.ts +746 -125
  33. package/src/config.ts +53 -5
  34. package/src/conversation-rows.ts +469 -0
  35. package/src/danger.ts +227 -6
  36. package/src/engine.ts +1240 -0
  37. package/src/goal.ts +71 -0
  38. package/src/held-read.ts +27 -0
  39. package/src/hooks.ts +49 -18
  40. package/src/host-control-channel.ts +455 -40
  41. package/src/host-control.ts +602 -111
  42. package/src/host-entry.mjs +5 -3
  43. package/src/host-runtime.ts +455 -41
  44. package/src/host.ts +659 -123
  45. package/src/inbox-card.ts +278 -0
  46. package/src/inbox.ts +139 -62
  47. package/src/job-edit.ts +7 -1
  48. package/src/job-health.ts +14 -2
  49. package/src/job-lines.ts +201 -13
  50. package/src/job-owner.ts +74 -13
  51. package/src/job-tune.ts +110 -0
  52. package/src/lease.ts +537 -0
  53. package/src/lock.ts +7 -2
  54. package/src/mcp-pool.ts +66 -14
  55. package/src/mcp.ts +1 -1
  56. package/src/open-file.ts +54 -0
  57. package/src/page-children.ts +1507 -0
  58. package/src/page-devices.ts +192 -0
  59. package/src/page-server.ts +3150 -0
  60. package/src/path-precheck.ts +215 -0
  61. package/src/paths.ts +24 -0
  62. package/src/pi-floor.ts +10 -0
  63. package/src/pi-loops.ts +1301 -450
  64. package/src/pi-resolver.d.mts +1 -0
  65. package/src/pi-resolver.mjs +110 -10
  66. package/src/presence.ts +12 -63
  67. package/src/protocol.ts +10 -1
  68. package/src/recipe.ts +43 -1
  69. package/src/requests.ts +354 -0
  70. package/src/rule-lines.ts +68 -0
  71. package/src/run-card.ts +105 -0
  72. package/src/runner.ts +18 -1
  73. package/src/schedule.ts +1 -2
  74. package/src/scheduler.ts +598 -245
  75. package/src/sdk-runner.ts +321 -27
  76. package/src/seen.ts +278 -0
  77. package/src/session-head.ts +21 -0
  78. package/src/share.ts +12 -2
  79. package/src/snapshot.ts +3 -2
  80. package/src/store.ts +81 -8
  81. package/src/tools.ts +196 -65
  82. package/src/trigger-runtime.ts +164 -127
  83. package/src/triggers.ts +39 -7
  84. package/src/trust.ts +46 -2
  85. package/src/ui-prefs.ts +16 -5
  86. package/src/watch-plan.ts +174 -0
  87. package/src/web.d.mts +3 -0
  88. package/src/web.mjs +2260 -2340
  89. package/src/window-executor.ts +459 -0
  90. package/src/window-guard.ts +158 -0
  91. package/docs/agents/issue-tracker.md +0 -34
package/src/sdk-runner.ts CHANGED
@@ -16,6 +16,8 @@ import { realpathish } from "./paths.ts";
16
16
  import { previewRedacted } from "./redact.ts";
17
17
  import { type ParentRuntimeFlags, type RunnerResult, type SubagentRequest, type SubagentRunner, failedRun } from "./runner.ts";
18
18
  import { subagentGuardExtension } from "./subagent-guard.ts";
19
+ import { PI_BUILTIN_TOOL_NAMES } from "./mcp.ts";
20
+ import { projectNeedsTrust } from "./trust.ts";
19
21
 
20
22
  export interface InProcessRunnerDeps {
21
23
  agentDir: string;
@@ -35,6 +37,8 @@ export interface InProcessRunnerDeps {
35
37
  * pi falls back to its four-tool default, which both drops what the session has (grep, find,
36
38
  * web_fetch…) and keeps what the session took away (`-xt`).
37
39
  */
40
+ /** A warning about a project's runs that a window of that project should show (the host's notices). */
41
+ notice?: (text: string, cwd: string) => void;
38
42
  getParentTools?: () => string[] | undefined;
39
43
  getParentThinking: () => string | undefined;
40
44
  /** The parent's live tools for this run: every MCP server's tools plus the automation tools at hop 1. */
@@ -129,8 +133,19 @@ export interface SubSessionResources {
129
133
  trusted: boolean;
130
134
  }
131
135
 
132
- /** One loaded extension set per (cwd, trust, agent dir, parent flags). */
133
- const loadedResources = new Map<string, Promise<SubSessionResources>>();
136
+ /**
137
+ * One loaded extension set per (cwd, trust, agent dir, parent flags), with the runs using it. A set
138
+ * replaced while a run still uses it shuts down when that run ends: shutting it at once closed the
139
+ * running loop's MCP connections under it, whenever a `/model` anywhere rewrote settings.json.
140
+ */
141
+ interface SharedEntry {
142
+ loading: Promise<SubSessionResources>;
143
+ inUse: number;
144
+ lastUsed: number;
145
+ outdated: boolean;
146
+ cwd: string;
147
+ }
148
+ const loadedResources = new Map<string, SharedEntry>();
134
149
  /** Projects we have already said are untrusted: say it once, not once per run. */
135
150
  const untrustedWarned = new Set<string>();
136
151
 
@@ -147,31 +162,229 @@ const untrustedWarned = new Set<string>();
147
162
  * The trust decision is part of the key, so a loader built for a trusted project can never be
148
163
  * handed to an untrusted cwd.
149
164
  */
150
- export async function sharedSubSessionResources(req: Pick<SubagentRequest, "cwd">, deps: Pick<InProcessRunnerDeps, "agentDir" | "parentFlags" | "isTrusted" | "ownDir" | "log" | "allowCommands">): Promise<SubSessionResources> {
165
+ /** The key each cwd's set was last loaded under, so an outdated set is dropped rather than kept. */
166
+ const loadedKeyFor = new Map<string, string>();
167
+
168
+ /**
169
+ * What a sub-session's resources are read from, as modification times: the project's and pi's
170
+ * AGENTS.md and settings, and their skills, extensions and prompts (each skill's SKILL.md too, whose
171
+ * edit does not touch its directory). A missing file counts as 0.
172
+ */
173
+ export function resourceStamp(cwd: string, agentDir: string): string {
174
+ const at = (file: string): number => {
175
+ try {
176
+ return fs.statSync(file).mtimeMs;
177
+ } catch {
178
+ return 0; // not there: as good an answer as a time
179
+ }
180
+ };
181
+ const files: string[] = [];
182
+ for (const root of [cwd, path.join(cwd, ".pi"), agentDir]) files.push(path.join(root, "AGENTS.md"), path.join(root, "CLAUDE.md"), path.join(root, "settings.json"));
183
+ for (const dirName of [path.join(cwd, ".pi", "skills"), path.join(cwd, ".agents", "skills"), path.join(agentDir, "skills"), path.join(cwd, ".pi", "extensions"), path.join(agentDir, "extensions"), path.join(cwd, ".pi", "prompts"), path.join(agentDir, "prompts")]) {
184
+ files.push(dirName);
185
+ let entries: string[] = [];
186
+ try {
187
+ entries = fs.readdirSync(dirName);
188
+ } catch {
189
+ continue; // no such directory
190
+ }
191
+ for (const e of entries) files.push(path.join(dirName, e), path.join(dirName, e, "SKILL.md"));
192
+ }
193
+ return files.map(at).join(",");
194
+ }
195
+
196
+ export async function sharedSubSessionResources(req: Pick<SubagentRequest, "cwd">, deps: Pick<InProcessRunnerDeps, "agentDir" | "parentFlags" | "isTrusted" | "ownDir" | "log" | "notice" | "allowCommands">): Promise<{ resources: SubSessionResources; done: () => void }> {
197
+ // Loops and checks: saved trust for this exact directory only. A held turn's owner trust goes to its own loader.
151
198
  const trusted = deps.isTrusted(req.cwd);
152
199
  // Worth saying out loud once per project: pi's trust prompt offers "trust the parent folder",
153
200
  // which records the parent and deletes the child entry, and "this session only", which records
154
201
  // nothing — so a run can silently lose the project's AGENTS.md, skills, extensions and settings.
155
- if (!trusted && !untrustedWarned.has(req.cwd)) {
202
+ if (!trusted && !untrustedWarned.has(req.cwd) && projectNeedsTrust(req.cwd)) {
156
203
  untrustedWarned.add(req.cwd);
157
- deps.log?.(`${req.cwd} is not trusted, so runs there get no project settings, skills or extensions (open pi in that directory and run /trust to change it)`);
204
+ const message = `${req.cwd} is not trusted, so runs there get no project settings, skills or extensions (open pi in that directory and run /trust to change it)`;
205
+ deps.log?.(message);
206
+ deps.notice?.(message, req.cwd);
207
+ }
208
+ // The files the set was loaded from, by modification time: a background process lives for days,
209
+ // and an AGENTS.md or a skill edited meanwhile must reach the next run, as a new window's would.
210
+ const key = JSON.stringify([req.cwd, trusted, deps.agentDir, deps.ownDir, deps.parentFlags, resourceStamp(req.cwd, deps.agentDir)]);
211
+ // Per directory and trust: a trusted and an untrusted run in one directory do not evict each other.
212
+ const slot = JSON.stringify([req.cwd, trusted]);
213
+ const before = loadedKeyFor.get(slot);
214
+ if (before !== undefined && before !== key) {
215
+ // Its extensions get their session_shutdown, or each edit would leave a live copy behind; once
216
+ // the runs using it have ended.
217
+ const outdated = loadedResources.get(before);
218
+ loadedResources.delete(before);
219
+ if (outdated) {
220
+ outdated.outdated = true;
221
+ if (outdated.inUse === 0) shutdownShared(outdated, deps);
222
+ }
158
223
  }
159
- const key = JSON.stringify([req.cwd, trusted, deps.agentDir, deps.ownDir, deps.parentFlags]);
160
- let loading = loadedResources.get(key);
161
- if (!loading) {
162
- loading = (async () => {
224
+ loadedKeyFor.set(slot, key);
225
+ let entry = loadedResources.get(key);
226
+ if (!entry) {
227
+ const loading = (async () => {
163
228
  const resources = subSessionResources(req, { ...deps, isTrusted: () => trusted });
164
229
  await resources.loader.reload();
165
230
  // Once per load, not once per run: the errors are a property of the extension set.
166
- for (const e of resources.loader.getExtensions().errors) deps.log?.(`sub-agent extension ${e.path}: ${e.error}`);
231
+ for (const e of resources.loader.getExtensions().errors) {
232
+ deps.log?.(`sub-agent extension ${e.path}: ${e.error}`);
233
+ deps.notice?.(`a loop's extension ${e.path} did not load: ${e.error}`, req.cwd);
234
+ }
235
+ return resources;
236
+ })();
237
+ const created: SharedEntry = { loading, inUse: 0, lastUsed: Date.now(), outdated: false, cwd: req.cwd };
238
+ loading.catch(() => {
239
+ if (loadedResources.get(key) === created) loadedResources.delete(key); // a failed load (a package install, say) must not be cached
240
+ });
241
+ loadedResources.set(key, created);
242
+ entry = created;
243
+ }
244
+ const shared = entry;
245
+ shared.inUse++;
246
+ let returned = false;
247
+ const done = () => {
248
+ if (returned) return;
249
+ returned = true;
250
+ shared.inUse = Math.max(0, shared.inUse - 1);
251
+ shared.lastUsed = Date.now();
252
+ if (shared.outdated && shared.inUse === 0) shutdownShared(shared, deps);
253
+ };
254
+ try {
255
+ return { resources: await shared.loading, done };
256
+ } catch (err) {
257
+ done();
258
+ throw err;
259
+ }
260
+ }
261
+
262
+ function shutdownShared(entry: SharedEntry, deps: Pick<InProcessRunnerDeps, "agentDir" | "log">): void {
263
+ entry.loading.then((r) => shutdownResources(r, entry.cwd, deps)).catch((err: any) => deps.log?.(`outdated resources in ${entry.cwd}: ${err?.message ?? err}`));
264
+ }
265
+
266
+ /**
267
+ * Release the loops' shared sets that no run has used for `idleMs`. The background process lives for
268
+ * weeks, and a project whose loops were removed kept its extensions' timers and sockets until then.
269
+ * Returns how many were released.
270
+ */
271
+ export function releaseSharedResources(deps: Pick<InProcessRunnerDeps, "agentDir" | "log">, opts: { now?: number; idleMs?: number } = {}): number {
272
+ const now = opts.now ?? Date.now();
273
+ const idleMs = opts.idleMs ?? HELD_IDLE_MS;
274
+ let released = 0;
275
+ for (const [key, entry] of [...loadedResources]) {
276
+ if (entry.inUse > 0 || now - entry.lastUsed < idleMs) continue;
277
+ loadedResources.delete(key);
278
+ for (const [slot, k] of loadedKeyFor) if (k === key) loadedKeyFor.delete(slot);
279
+ shutdownShared(entry, deps);
280
+ released++;
281
+ }
282
+ return released;
283
+ }
284
+
285
+ /** A conversation's held-turn resources are released after this long without a turn. */
286
+ export const HELD_IDLE_MS = 60 * 60 * 1000;
287
+ /** At most this many conversations keep their held-turn resources at once. */
288
+ export const HELD_RESOURCES_MAX = 8;
289
+ /** How long a release may take to send `session_shutdown` before it gives up waiting. */
290
+ const HELD_SHUTDOWN_MS = 10_000;
291
+
292
+ interface HeldEntry {
293
+ loading: Promise<SubSessionResources>;
294
+ lastUsed: number;
295
+ inUse: number;
296
+ release: () => Promise<void>;
297
+ }
298
+ const heldResources = new Map<string, HeldEntry>();
299
+
300
+ /**
301
+ * A held turn's resources, loaded once per conversation and kept for that conversation's later
302
+ * turns, rather than once per turn.
303
+ * Loading per turn re-ran every extension factory each time and never shut the instances down, so
304
+ * the long-lived host gained a set of timers, sockets and listeners per turn. One set per
305
+ * conversation, not per cwd: a `pi.*` action from an extension between turns can then only reach
306
+ * another turn of the same conversation, never a loop's throwaway session or someone else's chat.
307
+ * Released after an hour idle, past `HELD_RESOURCES_MAX`, and when the host stops.
308
+ */
309
+ export async function heldSubSessionResources(req: Pick<SubagentRequest, "cwd" | "sessionFile" | "trusted">, deps: Pick<InProcessRunnerDeps, "agentDir" | "parentFlags" | "isTrusted" | "ownDir" | "log" | "allowCommands">): Promise<{ resources: SubSessionResources; done: () => void }> {
310
+ const trusted = !!req.trusted || deps.isTrusted(req.cwd);
311
+ const key = JSON.stringify([req.sessionFile, req.cwd, trusted, deps.agentDir, deps.ownDir, deps.parentFlags]);
312
+ let entry = heldResources.get(key);
313
+ if (!entry) {
314
+ await releaseHeldResources({ keep: HELD_RESOURCES_MAX - 1 });
315
+ const loading = (async () => {
316
+ const resources = subSessionResources(req, { ...deps, isTrusted: () => trusted });
317
+ await resources.loader.reload();
318
+ for (const e of resources.loader.getExtensions().errors) deps.log?.(`held-turn extension ${e.path}: ${e.error}`);
167
319
  return resources;
168
- })().catch((err) => {
169
- loadedResources.delete(key); // a failed load (a package install, say) must not be cached
170
- throw err;
320
+ })();
321
+ const created: HeldEntry = { loading, lastUsed: Date.now(), inUse: 0, release: async () => shutdownResources(await loading.catch(() => undefined), req.cwd, deps) };
322
+ heldResources.set(key, created);
323
+ loading.catch(() => {
324
+ if (heldResources.get(key) === created) heldResources.delete(key); // a failed load is not cached
171
325
  });
172
- loadedResources.set(key, loading);
326
+ entry = created;
327
+ }
328
+ const held = entry;
329
+ held.inUse++;
330
+ const done = () => {
331
+ held.inUse = Math.max(0, held.inUse - 1);
332
+ held.lastUsed = Date.now();
333
+ };
334
+ try {
335
+ return { resources: await held.loading, done };
336
+ } catch (err) {
337
+ done();
338
+ throw err;
339
+ }
340
+ }
341
+
342
+ /**
343
+ * Release cached held-turn resources that no turn is using: those idle for `idleMs`, then the least
344
+ * recently used until at most `keep` remain. `{ idleMs: 0, keep: 0 }` releases everything idle (the
345
+ * host stopping). Returns how many were released.
346
+ */
347
+ export async function releaseHeldResources(opts: { now?: number; idleMs?: number; keep?: number } = {}): Promise<number> {
348
+ const now = opts.now ?? Date.now();
349
+ const idleMs = opts.idleMs ?? HELD_IDLE_MS;
350
+ const keep = opts.keep ?? HELD_RESOURCES_MAX;
351
+ const idle = [...heldResources.entries()].filter(([, e]) => e.inUse === 0).sort((a, b) => a[1].lastUsed - b[1].lastUsed);
352
+ const victims = idle.filter(([, e]) => now - e.lastUsed >= idleMs);
353
+ for (const candidate of idle) {
354
+ if (heldResources.size - victims.length <= keep) break;
355
+ if (!victims.includes(candidate)) victims.push(candidate);
356
+ }
357
+ for (const [key] of victims) heldResources.delete(key);
358
+ // In parallel: each release is bounded on its own, and the host stopping waits on all of them.
359
+ await Promise.all(victims.map(([, e]) => e.release()));
360
+ return victims.length;
361
+ }
362
+
363
+ /**
364
+ * Tell a released set's extensions to shut down, then drop it: a throwaway in-memory session built on
365
+ * that set sends `session_shutdown`, and is disposed whatever happens. Bounded, because an extension
366
+ * that never answers must not hold up the host.
367
+ */
368
+ async function shutdownResources(resources: SubSessionResources | undefined, cwd: string, deps: Pick<InProcessRunnerDeps, "agentDir" | "log">): Promise<void> {
369
+ if (!resources) return;
370
+ let session: { extensionRunner: { emit(event: { type: "session_shutdown"; reason: "quit" }): Promise<unknown> }; dispose(): void } | undefined;
371
+ let timer: NodeJS.Timeout | undefined;
372
+ const deadline = new Promise<"timeout">((resolve) => {
373
+ timer = setTimeout(() => resolve("timeout"), HELD_SHUTDOWN_MS);
374
+ timer.unref();
375
+ });
376
+ try {
377
+ const created = await Promise.race([createAgentSession({ cwd, agentDir: deps.agentDir, resourceLoader: resources.loader, settingsManager: resources.settingsManager, sessionManager: SessionManager.inMemory(cwd) }), deadline]);
378
+ if (created === "timeout") return;
379
+ session = created.session as unknown as typeof session;
380
+ const sent = await Promise.race([session!.extensionRunner.emit({ type: "session_shutdown", reason: "quit" }), deadline]);
381
+ if (sent === "timeout") deps.log?.(`held-turn extensions in ${cwd} did not finish session_shutdown within ${HELD_SHUTDOWN_MS / 1000}s`);
382
+ } catch (err: any) {
383
+ deps.log?.(`held-turn extensions in ${cwd}: shutdown failed: ${err?.message ?? err}`);
384
+ } finally {
385
+ if (timer) clearTimeout(timer);
386
+ if (session) disposeSubSession(session, deps.log);
173
387
  }
174
- return loading;
175
388
  }
176
389
 
177
390
  /**
@@ -214,6 +427,11 @@ export function resolveRunModel(
214
427
  * parent's active set. Custom tools (this run's MCP servers and the automation tools) are always
215
428
  * added, because pi's allowlist filters those too and they are the sub-agent's whole point.
216
429
  */
430
+ /** `active` with its built-in tools replaced by `builtins`; every other tool is left as it is. */
431
+ export function keepBuiltins(active: string[], builtins: string[]): string[] {
432
+ return [...new Set([...active.filter((t) => !PI_BUILTIN_TOOL_NAMES.includes(t)), ...builtins])];
433
+ }
434
+
217
435
  export function subSessionTools(req: Pick<SubagentRequest, "tools">, parentTools: string[] | undefined, customTools: Array<{ name: string }>): string[] | undefined {
218
436
  const custom = customTools.map((t) => t.name);
219
437
  if (req.tools) return [...new Set(req.tools.filter((t) => !parentTools || parentTools.includes(t) || custom.includes(t)))];
@@ -283,17 +501,44 @@ export interface RunTelemetry {
283
501
  compactions: number;
284
502
  }
285
503
 
504
+ type SessionStats = { tokens: { input: number; output: number; cacheRead: number; cacheWrite: number }; cost: number; assistantMessages: number };
505
+
506
+ /**
507
+ * Where a held turn started: the conversation it opened already had messages and a cost, and pi's
508
+ * stats add up the whole file. Everything this run reports is measured from here.
509
+ */
510
+ export interface RunBaseline {
511
+ messages: number;
512
+ stats: SessionStats;
513
+ }
514
+
515
+ /** This run's share of a session's stats: everything, or what was added since `base`. */
516
+ export function usageSince(stats: SessionStats, base?: SessionStats): RunnerResult["usage"] {
517
+ const less = (a: number, b = 0) => Math.max(0, a - b);
518
+ return {
519
+ input: less(stats.tokens.input, base?.tokens.input),
520
+ output: less(stats.tokens.output, base?.tokens.output),
521
+ cacheRead: less(stats.tokens.cacheRead, base?.tokens.cacheRead),
522
+ cacheWrite: less(stats.tokens.cacheWrite, base?.tokens.cacheWrite),
523
+ cost: less(stats.cost, base?.cost),
524
+ turns: less(stats.assistantMessages, base?.assistantMessages),
525
+ };
526
+ }
527
+
286
528
  /** A finished (or failed) sub-session, reduced to what the schedulers record. */
287
529
  export function collect(
288
- session: { messages: unknown[]; sessionId: string; sessionFile: string | undefined; getSessionStats(): { tokens: { input: number; output: number; cacheRead: number; cacheWrite: number }; cost: number; assistantMessages: number }; agent: { state: { errorMessage?: string } } },
530
+ session: { messages: unknown[]; sessionId: string; sessionFile: string | undefined; getSessionStats(): SessionStats; agent: { state: { errorMessage?: string } } },
289
531
  req: Pick<SubagentRequest, "signal" | "timeoutMs">,
290
532
  timedOut: boolean,
291
533
  thrown?: string,
292
534
  telemetry?: RunTelemetry,
293
535
  /** Set when the daily budget stopped this run: it outranks the abort it had to use to do it. */
294
536
  budgetStopMessage?: string,
537
+ baseline?: RunBaseline,
295
538
  ): RunnerResult {
296
- const messages = session.messages as any[];
539
+ // Only this run's messages: in a conversation that already had answers, the last one before this
540
+ // turn is not this turn's reply, and a turn that produced none must not borrow it.
541
+ const messages = (session.messages as any[]).slice(baseline?.messages ?? 0);
297
542
  const assistant = [...messages].reverse().find((message) => message?.role === "assistant");
298
543
  const text = assistant ? (assistant.content as any[]).filter((b) => b?.type === "text").map((b) => b.text).join("\n") : "";
299
544
  const stopReason: string | undefined = assistant?.stopReason;
@@ -317,7 +562,7 @@ export function collect(
317
562
  // A run that silently retried five times or compacted twice must not look like a clean one.
318
563
  retries: telemetry?.retries,
319
564
  compactions: telemetry?.compactions,
320
- usage: { input: stats.tokens.input, output: stats.tokens.output, cacheRead: stats.tokens.cacheRead, cacheWrite: stats.tokens.cacheWrite, cost: stats.cost, turns: stats.assistantMessages },
565
+ usage: usageSince(stats, baseline?.stats),
321
566
  sessionId: session.sessionId,
322
567
  sessionFile: session.sessionFile,
323
568
  };
@@ -325,6 +570,8 @@ export function collect(
325
570
 
326
571
  /** Resolved when the run's deadline or its abort signal fires; raced against every setup step. */
327
572
  const STOPPED = Symbol("stopped");
573
+ /** How often a held turn looks whether a window took its conversation back, between events. */
574
+ const HELD_CHECK_MS = 2_000;
328
575
 
329
576
  /**
330
577
  * End of one run. No `session_shutdown`: the extension instances are shared with the runs that
@@ -342,6 +589,13 @@ export function disposeSubSession(session: { dispose(): void }, log?: (message:
342
589
  }
343
590
 
344
591
  export function createInProcessRunner(deps: InProcessRunnerDeps): SubagentRunner {
592
+ // Conversations whose held turns stopped coming give their extensions back within the idle window.
593
+ // So do the loops' shared sets that no run has used for as long.
594
+ const sweep = setInterval(() => {
595
+ releaseSharedResources(deps);
596
+ void releaseHeldResources().catch((err: any) => deps.log?.(`held-turn resources: ${err?.message ?? err}`));
597
+ }, 10 * 60 * 1000);
598
+ sweep.unref();
345
599
  // Only a fallback for the headless host, which has no interactive session to borrow from. It
346
600
  // reads the credentials of *this* agent dir, and is rebuilt when they change on disk, so a
347
601
  // rotated key or a fresh `pi login` is picked up without restarting the host.
@@ -378,7 +632,9 @@ export function createInProcessRunner(deps: InProcessRunnerDeps): SubagentRunner
378
632
  return async (req: SubagentRequest): Promise<RunnerResult> => {
379
633
  let session: Awaited<ReturnType<typeof createAgentSession>>["session"] | undefined;
380
634
  let unsubscribe: (() => void) | undefined;
635
+ let releaseHeld: (() => void) | undefined;
381
636
  let timedOut = false;
637
+ let takenBack = false;
382
638
  let budgetStopMessage: string | undefined;
383
639
  let stop = () => {};
384
640
  const stopped = new Promise<typeof STOPPED>((resolve) => {
@@ -396,12 +652,28 @@ export function createInProcessRunner(deps: InProcessRunnerDeps): SubagentRunner
396
652
  onAbort();
397
653
  }, req.timeoutMs);
398
654
  timer.unref();
655
+ // A quiet tool (a long `sleep`, a build) sends no events, so the per-event check below would
656
+ // see a window take the conversation back only when it ends, and the turn would go on writing
657
+ // into a conversation a person is now using. A held turn also looks on a clock.
658
+ const holdCheck = req.stillHolds
659
+ ? setInterval(() => {
660
+ try {
661
+ if (takenBack || req.stillHolds!()) return;
662
+ } catch {
663
+ return; // a lease that cannot be read now is not one taken away: the next look decides
664
+ }
665
+ takenBack = true;
666
+ onAbort();
667
+ }, HELD_CHECK_MS)
668
+ : undefined;
669
+ holdCheck?.unref();
399
670
  req.signal?.addEventListener("abort", onAbort, { once: true });
400
671
  // This run's own live cost, and its place in what the process has in flight. Retired in the
401
672
  // `finally` below, before the session is disposed, so its final cost is still readable there.
673
+ let baseline: RunBaseline | undefined;
402
674
  const runCost = () => {
403
675
  try {
404
- return session?.getSessionStats().cost ?? 0;
676
+ return Math.max(0, (session?.getSessionStats().cost ?? 0) - (baseline?.stats.cost ?? 0));
405
677
  } catch {
406
678
  return 0; // a half-built or already disposed session has nothing to report
407
679
  }
@@ -429,7 +701,7 @@ export function createInProcessRunner(deps: InProcessRunnerDeps): SubagentRunner
429
701
  onAbort();
430
702
  return true;
431
703
  };
432
- const stoppedEarly = () => (budgetStopMessage ? failedRun(budgetStopMessage, { stopReason: "aborted" }) : timedOut ? failedRun(`timed out after ${Math.round(req.timeoutMs / 1000)}s`, { timedOut: true }) : req.signal?.aborted ? failedRun("aborted", { stopReason: "aborted" }) : undefined);
704
+ const stoppedEarly = () => (takenBack ? failedRun("taken back: a window opened this conversation", { stopReason: "aborted" }) : budgetStopMessage ? failedRun(budgetStopMessage, { stopReason: "aborted" }) : timedOut ? failedRun(`timed out after ${Math.round(req.timeoutMs / 1000)}s`, { timedOut: true }) : req.signal?.aborted ? failedRun("aborted", { stopReason: "aborted" }) : undefined);
433
705
  const stoppedResult = () => stoppedEarly() ?? failedRun("aborted", { stopReason: "aborted" });
434
706
  // Until the session exists there is nothing for `abort()` to reach, and setup is not quick:
435
707
  // `loader.reload()` shells out to npm/git for a project's `settings.json` packages, so a
@@ -450,31 +722,38 @@ export function createInProcessRunner(deps: InProcessRunnerDeps): SubagentRunner
450
722
  // Only a pinned model needs the catalogue; the parent's model is already resolved.
451
723
  let resolved: ReturnType<typeof resolveRunModel> = { model: parent };
452
724
  if (req.model) resolved = resolveRunModel(req.model, parent, await modelRuntime());
725
+ else if (req.sessionFile) resolved = { model: undefined }; // a conversation keeps the model it was using
453
726
  if (resolved.error) return failedRun(resolved.error);
454
727
  model = resolved.model;
455
728
  if (resolved.warning) {
456
729
  telemetry.warning = resolved.warning;
457
730
  deps.log?.(resolved.warning);
458
731
  }
459
- if (!model) return failedRun("no model is available for the sub-agent (the session has none and the job pins none)");
732
+ if (!model && !req.sessionFile) return failedRun("no model is available for the sub-agent (the session has none and the job pins none)");
460
733
  } catch (err: any) {
461
734
  return failedRun(`model runtime: ${err?.message ?? err}`);
462
735
  }
463
736
 
464
737
  const customTools = await untilStopped(deps.customTools(req));
465
738
  if (customTools === STOPPED) return stoppedResult();
466
- const resources = await untilStopped(sharedSubSessionResources(req, deps));
739
+ // A held turn runs in someone's conversation, so it gets a loader of its own: the shared one
740
+ // stubs `invalidate`, which is fine for a throwaway sub-session and wrong for that.
741
+ // A held turn borrows its conversation's cached set and hands it back however the run ends.
742
+ // A loop's run borrows the shared set the same way, so a newer set does not shut this one under it.
743
+ const load = req.sessionFile ? heldSubSessionResources(req, deps) : sharedSubSessionResources(req, deps);
744
+ releaseHeld = () => void load.then((h) => h.done()).catch(() => undefined);
745
+ const resources = await untilStopped(load.then((h) => h.resources));
467
746
  if (resources === STOPPED) return stoppedResult();
468
747
  const created = await untilStopped(
469
748
  createAgentSession({
470
749
  cwd: req.cwd,
471
750
  agentDir: deps.agentDir,
472
751
  model,
473
- thinkingLevel: (req.thinking ?? deps.getParentThinking()) as Thinking,
752
+ thinkingLevel: (req.thinking ?? (req.sessionFile ? undefined : deps.getParentThinking())) as Thinking,
474
753
  tools: subSessionTools(req, deps.getParentTools?.(), customTools),
475
754
  customTools,
476
755
  resourceLoader: resources.loader,
477
- sessionManager: req.sessionDir ? SessionManager.create(req.cwd, req.sessionDir) : SessionManager.inMemory(req.cwd),
756
+ sessionManager: req.sessionFile ? SessionManager.open(req.sessionFile) : req.sessionDir ? SessionManager.create(req.cwd, req.sessionDir) : SessionManager.inMemory(req.cwd),
478
757
  settingsManager: resources.settingsManager,
479
758
  modelRuntime: await modelRuntime(),
480
759
  sessionStartEvent: { type: "session_start", reason: "new" },
@@ -482,6 +761,11 @@ export function createInProcessRunner(deps: InProcessRunnerDeps): SubagentRunner
482
761
  );
483
762
  if (created === STOPPED) return stoppedResult();
484
763
  session = created.session;
764
+ // The built-in tools of the chat the job was made in, and no more: a run in the background has
765
+ // no `--tools` of its own. Extension, MCP and automation tools stay as they are.
766
+ if (req.builtinTools && !req.tools) session.setActiveToolsByName(keepBuiltins(session.getActiveToolNames(), req.builtinTools));
767
+ // A held turn opens a conversation that already has a history and a cost: measure from here.
768
+ if (req.sessionFile) baseline = { messages: session.messages.length, stats: session.getSessionStats() };
485
769
  if (session.sessionFile) req.onSessionFile?.(session.sessionFile);
486
770
 
487
771
  // Nobody else watches this session, so what the run had to do to get through is only
@@ -492,6 +776,14 @@ export function createInProcessRunner(deps: InProcessRunnerDeps): SubagentRunner
492
776
  const debug = envFlag("LOOPS_DEBUG");
493
777
  const label = `${req.kind}/${(req.jobId ?? req.traceId ?? "?").slice(0, 12)}`;
494
778
  unsubscribe = session.subscribe((e: any) => {
779
+ // The per-event lease check of a held turn. pi persists at message and tool boundaries, so
780
+ // stopping at the first event after a window took the conversation bounds what is written
781
+ // after it to one entry.
782
+ if (req.stillHolds && e.type !== "message_update" && !takenBack && !req.stillHolds()) {
783
+ takenBack = true;
784
+ onAbort();
785
+ return;
786
+ }
495
787
  if (e.type === "auto_retry_start") {
496
788
  telemetry.retries++;
497
789
  if (debug) deps.log?.(`debug ${label}: provider retry`);
@@ -514,20 +806,22 @@ export function createInProcessRunner(deps: InProcessRunnerDeps): SubagentRunner
514
806
  if (bound === STOPPED) return stoppedResult();
515
807
  // Setup can take seconds; the runs beside this one were spending throughout. Check with the
516
808
  // session in hand, before the first token is bought, then once per turn from the subscriber.
517
- if (overBudget()) return collect(session, req, timedOut, undefined, telemetry, budgetStopMessage);
809
+ if (overBudget()) return collect(session, req, timedOut, undefined, telemetry, budgetStopMessage, baseline);
518
810
  await session.prompt(req.prompt);
519
- return collect(session, req, timedOut, undefined, telemetry, budgetStopMessage);
811
+ return collect(session, req, timedOut, undefined, telemetry, budgetStopMessage, baseline);
520
812
  } catch (err: any) {
521
813
  const message = err?.message ?? String(err);
522
- if (session) return collect(session, req, timedOut, message, telemetry, budgetStopMessage);
814
+ if (session) return collect(session, req, timedOut, message, telemetry, budgetStopMessage, baseline);
523
815
  return failedRun(budgetStopMessage ?? message, { warning: telemetry.warning });
524
816
  } finally {
525
817
  clearTimeout(timer);
818
+ clearInterval(holdCheck);
526
819
  stop();
527
820
  retireCost();
528
821
  unsubscribe?.();
529
822
  req.signal?.removeEventListener("abort", onAbort);
530
823
  if (session) disposeSubSession(session, deps.log);
824
+ releaseHeld?.();
531
825
  }
532
826
  };
533
827
  }