@alphacoder-v0/pi-loops 0.17.3

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 (68) hide show
  1. package/CHANGELOG.md +1989 -0
  2. package/LICENSE +21 -0
  3. package/README.md +370 -0
  4. package/README.zh-CN.md +606 -0
  5. package/docs/cli.md +252 -0
  6. package/docs/configuration.md +110 -0
  7. package/docs/design.md +155 -0
  8. package/docs/goal.md +59 -0
  9. package/docs/hooks.md +110 -0
  10. package/docs/loops.md +315 -0
  11. package/docs/mcp.md +90 -0
  12. package/docs/screenshot.png +0 -0
  13. package/docs/session-archive.md +64 -0
  14. package/docs/triggers.md +140 -0
  15. package/docs/troubleshooting.md +111 -0
  16. package/docs/web-ui-parity.md +305 -0
  17. package/examples/README.md +18 -0
  18. package/examples/mcp-notify-server.mjs +35 -0
  19. package/examples/mcp.toml +8 -0
  20. package/package.json +71 -0
  21. package/skills/pi-loops/SKILL.md +50 -0
  22. package/src/archive.ts +476 -0
  23. package/src/args.ts +217 -0
  24. package/src/cli-entry.mjs +23 -0
  25. package/src/cli.ts +610 -0
  26. package/src/config.ts +103 -0
  27. package/src/danger.ts +128 -0
  28. package/src/goal.ts +178 -0
  29. package/src/hooks.ts +436 -0
  30. package/src/host-control-channel.ts +243 -0
  31. package/src/host-control.ts +203 -0
  32. package/src/host-entry.mjs +14 -0
  33. package/src/host-runtime.ts +222 -0
  34. package/src/host.ts +329 -0
  35. package/src/inbox.ts +232 -0
  36. package/src/job-edit.ts +113 -0
  37. package/src/job-health.ts +29 -0
  38. package/src/lock.ts +193 -0
  39. package/src/log.ts +108 -0
  40. package/src/mcp-pool.ts +141 -0
  41. package/src/mcp.ts +885 -0
  42. package/src/paths.ts +33 -0
  43. package/src/pi-loops.ts +2292 -0
  44. package/src/pi-resolver.mjs +49 -0
  45. package/src/presence.ts +143 -0
  46. package/src/protocol.ts +198 -0
  47. package/src/redact.ts +61 -0
  48. package/src/register-pi.mjs +3 -0
  49. package/src/runner.ts +149 -0
  50. package/src/schedule.ts +431 -0
  51. package/src/scheduler.ts +981 -0
  52. package/src/sdk-runner.ts +536 -0
  53. package/src/share.ts +121 -0
  54. package/src/slots.ts +76 -0
  55. package/src/snapshot.ts +40 -0
  56. package/src/store.ts +599 -0
  57. package/src/subagent-guard.ts +47 -0
  58. package/src/thinking.ts +30 -0
  59. package/src/toml.ts +244 -0
  60. package/src/tools.ts +455 -0
  61. package/src/transcript.ts +84 -0
  62. package/src/trigger-runtime.ts +711 -0
  63. package/src/triggers.ts +558 -0
  64. package/src/trust.ts +47 -0
  65. package/src/ts-entry.mjs +67 -0
  66. package/src/ui-prefs.ts +84 -0
  67. package/src/version.ts +14 -0
  68. package/src/web.mjs +4484 -0
@@ -0,0 +1,536 @@
1
+ /**
2
+ * The in-process sub-agent runner, on top of pi's SDK. A run is a new
3
+ * `AgentSession` created inside the interactive pi process — fresh context, its own transcript
4
+ * file, the job's cwd — that shares what the parent has live: the same MCP client instances (a
5
+ * browser tab or database session opened here is the one the loop sees), the parent's `-e`
6
+ * extensions, system prompt and skills, the parent's model unless the job pins one, and the
7
+ * project's trust when the run is in the same project (or one the user trusted before). No child
8
+ * process, nothing re-spawned.
9
+ */
10
+ import * as fs from "node:fs";
11
+ import * as path from "node:path";
12
+ import { DefaultResourceLoader, type ModelRegistry, ModelRuntime, SessionManager, SettingsManager, createAgentSession, type ToolDefinition } from "@earendil-works/pi-coding-agent";
13
+ import type { Model } from "@earendil-works/pi-ai";
14
+ import { envFlag } from "./config.ts";
15
+ import { realpathish } from "./paths.ts";
16
+ import { previewRedacted } from "./redact.ts";
17
+ import { type ParentRuntimeFlags, type RunnerResult, type SubagentRequest, type SubagentRunner, failedRun } from "./runner.ts";
18
+ import { subagentGuardExtension } from "./subagent-guard.ts";
19
+
20
+ export interface InProcessRunnerDeps {
21
+ agentDir: string;
22
+ parentFlags: ParentRuntimeFlags;
23
+ /** The interactive session's current model (used when the job pins none, or when a pin stopped resolving). */
24
+ getParentModel: () => Model<any> | undefined;
25
+ /**
26
+ * The parent session's live model/auth runtime — an extension has it as `ctx.modelRegistry`.
27
+ * pi applies `--api-key` to that instance (`dist/main.js:642-651`) and `/login` mutates it, so a
28
+ * run that borrows it authenticates by the exact path the parent authenticated with, and a
29
+ * credential rotated in the chat is visible on the next tick. Without it the runner builds its
30
+ * own from `agentDir`, which is the same credentials by a longer route and one that misses both.
31
+ */
32
+ getParentModelRuntime?: () => ModelRuntime | ModelRegistry | undefined;
33
+ /**
34
+ * The parent session's active tool names, handed to the sub-agent as its tool list. Without them
35
+ * pi falls back to its four-tool default, which both drops what the session has (grep, find,
36
+ * web_fetch…) and keeps what the session took away (`-xt`).
37
+ */
38
+ getParentTools?: () => string[] | undefined;
39
+ getParentThinking: () => string | undefined;
40
+ /** The parent's live tools for this run: every MCP server's tools plus the automation tools at hop 1. */
41
+ customTools: (req: SubagentRequest) => ToolDefinition<any, any>[] | Promise<ToolDefinition<any, any>[]>;
42
+ /** Whether project-local resources in `cwd` may be loaded (the user trusted that project). */
43
+ isTrusted: (cwd: string) => boolean;
44
+ /** pi-loops' own package directory: the sub-session must not load a second copy of this extension. */
45
+ ownDir: string;
46
+ /** Command prefixes the dangerous-command gate lets through (`[danger] allow`). */
47
+ allowCommands?: () => readonly string[];
48
+ /**
49
+ * Today's automation spend and the cap it must stay under — `scheduler.budgetState()`, read
50
+ * *while* a run is in flight rather than only before it is dispatched. A callback, not the
51
+ * scheduler itself: the scheduler owns the runner, and the runner must not own the scheduler
52
+ * back. Omitted (a test, a caller with no scheduler) → runs are not measured against a cap.
53
+ */
54
+ budget?: () => DailyBudget;
55
+ log?: (message: string) => void;
56
+ }
57
+
58
+ /** What `[limits] daily_budget_usd` has been spent against today, and the cap itself (0 = no cap). */
59
+ export interface DailyBudget {
60
+ spent: number;
61
+ cap: number;
62
+ }
63
+
64
+ type Thinking = Parameters<typeof createAgentSession>[0] extends { thinkingLevel?: infer T } | undefined ? T : never;
65
+
66
+ /**
67
+ * True when `p` lives under `dir` (both resolved through symlinks); never matches a sibling like
68
+ * `dir2/`. Both sides go through `realpathish` because a symlink anywhere above either one makes a
69
+ * textual comparison answer the wrong question, and on macOS that is *every* path under a temporary
70
+ * directory, since /var is a link to /private/var. Here it means our own extension is not
71
+ * recognised as ours, and the sub-session loads a second copy of pi-loops — which pi refuses to
72
+ * start with.
73
+ */
74
+ export function isInsideDir(dir: string, p: string): boolean {
75
+ const rel = path.relative(realpathish(dir), realpathish(p));
76
+ return rel === "" || (!rel.startsWith("..") && !path.isAbsolute(rel));
77
+ }
78
+
79
+ /**
80
+ * The settings and resource loader of one sub-session. Trust is decided here and only here:
81
+ * `SettingsManager.create` defaults a project to trusted (pi's CLI passes false explicitly), so
82
+ * an untrusted cwd must be pinned to false or its `.pi/extensions` would run. pi-loops itself is
83
+ * filtered out of the extension set; the sub-session gets the automation tools as customTools.
84
+ */
85
+ export function subSessionResources(req: Pick<SubagentRequest, "cwd">, deps: Pick<InProcessRunnerDeps, "agentDir" | "parentFlags" | "isTrusted" | "ownDir" | "log" | "allowCommands">): SubSessionResources {
86
+ const trusted = deps.isTrusted(req.cwd);
87
+ const settingsManager = SettingsManager.create(req.cwd, deps.agentDir, { projectTrusted: trusted });
88
+ const f = deps.parentFlags;
89
+ const ownDir = deps.ownDir;
90
+ const loader = new DefaultResourceLoader({
91
+ cwd: req.cwd,
92
+ agentDir: deps.agentDir,
93
+ settingsManager,
94
+ additionalExtensionPaths: f.extensionPaths,
95
+ additionalSkillPaths: f.skillPaths,
96
+ additionalPromptTemplatePaths: f.promptTemplatePaths,
97
+ noExtensions: f.noExtensions,
98
+ noSkills: f.noSkills,
99
+ noContextFiles: f.noContextFiles,
100
+ noPromptTemplates: f.noPromptTemplates,
101
+ noThemes: true,
102
+ systemPrompt: f.systemPrompt,
103
+ appendSystemPrompt: f.appendSystemPrompt,
104
+ // pi-loops itself must not load a second time; the dangerous-command gate is added in its
105
+ // place, because a sub-session is the one place where no user is watching the tool calls.
106
+ extensionsOverride: (base) => {
107
+ // One loaded set is shared by every run in this cwd (`sharedSubSessionResources`), so a
108
+ // finished run's `session.dispose()` — which invalidates its ExtensionRunner and, through
109
+ // it, the runtime the extensions captured at load time (`dist/core/extensions/loader.js:166-176`)
110
+ // — must not make the next run's `pi.*` calls throw "stale ctx" for good. Per-runner
111
+ // staleness, which is what that message is really about, is untouched. The cost is that
112
+ // a `pi.*` action between runs reaches the most recently created session instead of
113
+ // throwing; pi rebinds those to the live session on the next run (`runner.js:160-175`).
114
+ // Only the staleness half is neutralised: the same call also runs pi's event-bus
115
+ // unsubscribers, and dropping those would leak every subscription a shared extension made,
116
+ // run after run, in a host that lives for days.
117
+ const invalidate = base.runtime.invalidate.bind(base.runtime);
118
+ base.runtime.invalidate = (message?: string) => {
119
+ const before = (base.runtime as any).staleMessage;
120
+ invalidate(message);
121
+ (base.runtime as any).staleMessage = before;
122
+ };
123
+ return { ...base, extensions: [...base.extensions.filter((e) => !isInsideDir(ownDir, e.resolvedPath) && !isInsideDir(ownDir, e.path)), subagentGuardExtension(deps.log, deps.allowCommands?.() ?? [])] };
124
+ },
125
+ });
126
+ return { settingsManager, loader, trusted };
127
+ }
128
+
129
+ export interface SubSessionResources {
130
+ settingsManager: SettingsManager;
131
+ loader: DefaultResourceLoader;
132
+ trusted: boolean;
133
+ }
134
+
135
+ /** One loaded extension set per (cwd, trust, agent dir, parent flags). */
136
+ const loadedResources = new Map<string, Promise<SubSessionResources>>();
137
+ /** Projects we have already said are untrusted: say it once, not once per run. */
138
+ const untrustedWarned = new Set<string>();
139
+
140
+ /**
141
+ * The loaded resources for one run, loaded once and then shared. pi re-invokes every extension
142
+ * factory on each `reload()` and keys its module cache by a single process-global cwd
143
+ * (`dist/core/extensions/loader.js:115-129`), so loading per run both re-instantiates the parent's
144
+ * extensions — `pi -e ./browser.ts` opens a browser every tick, and that run's `session_shutdown`
145
+ * closes the one the interactive session is still using, since the module (and whatever it holds)
146
+ * is shared — and wipes that cache whenever a run's cwd differs from this process's. Sharing one
147
+ * loaded set per cwd is the trade, and this is its cost: runs that overlap in one cwd share
148
+ * extension instances, and an extension file edited on disk needs a pi restart to take effect.
149
+ *
150
+ * The trust decision is part of the key, so a loader built for a trusted project can never be
151
+ * handed to an untrusted cwd.
152
+ */
153
+ export async function sharedSubSessionResources(req: Pick<SubagentRequest, "cwd">, deps: Pick<InProcessRunnerDeps, "agentDir" | "parentFlags" | "isTrusted" | "ownDir" | "log" | "allowCommands">): Promise<SubSessionResources> {
154
+ const trusted = deps.isTrusted(req.cwd);
155
+ // Worth saying out loud once per project: pi's trust prompt offers "trust the parent folder",
156
+ // which records the parent and deletes the child entry, and "this session only", which records
157
+ // nothing — so a run can silently lose the project's AGENTS.md, skills, extensions and settings.
158
+ if (!trusted && !untrustedWarned.has(req.cwd)) {
159
+ untrustedWarned.add(req.cwd);
160
+ 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)`);
161
+ }
162
+ const key = JSON.stringify([req.cwd, trusted, deps.agentDir, deps.ownDir, deps.parentFlags]);
163
+ let loading = loadedResources.get(key);
164
+ if (!loading) {
165
+ loading = (async () => {
166
+ const resources = subSessionResources(req, { ...deps, isTrusted: () => trusted });
167
+ await resources.loader.reload();
168
+ // Once per load, not once per run: the errors are a property of the extension set.
169
+ for (const e of resources.loader.getExtensions().errors) deps.log?.(`sub-agent extension ${e.path}: ${e.error}`);
170
+ return resources;
171
+ })().catch((err) => {
172
+ loadedResources.delete(key); // a failed load (a package install, say) must not be cached
173
+ throw err;
174
+ });
175
+ loadedResources.set(key, loading);
176
+ }
177
+ return loading;
178
+ }
179
+
180
+ /**
181
+ * The parent's `ctx.modelRegistry` is only a synchronous facade over the session's `ModelRuntime`
182
+ * (`dist/core/model-registry.d.ts`), and `createAgentSession` needs the runtime itself — unwrap it
183
+ * here so the caller can hand over whichever it has.
184
+ */
185
+ export function unwrapModelRuntime(source: ModelRuntime | ModelRegistry | undefined): ModelRuntime | undefined {
186
+ if (!source) return undefined;
187
+ if (typeof (source as ModelRuntime).getModel === "function") return source as ModelRuntime;
188
+ const inner = (source as unknown as { runtime?: ModelRuntime }).runtime;
189
+ return typeof inner?.getModel === "function" ? inner : undefined;
190
+ }
191
+
192
+ /**
193
+ * The model for one run. A job that pins no model reads the parent's live model when it fires, so
194
+ * a loop follows the session it was set up in. A job may pin one, but a pin that stopped resolving
195
+ * — provider removed, credential deleted, model renamed — must not fail this tick and every tick
196
+ * after it forever; it falls back to the session's model and says so. `getModel` alone is not
197
+ * enough: pi's own restore path pairs it with `hasConfiguredAuth` (`dist/core/sdk.js:89`), or a
198
+ * model still in the catalogue with no credential blows up inside `session.prompt()`.
199
+ */
200
+ export function resolveRunModel(
201
+ pinned: string | undefined,
202
+ parentModel: Model<any> | undefined,
203
+ runtime: { getModel(provider: string, modelId: string): Model<any> | undefined; hasConfiguredAuth(provider: string): boolean },
204
+ ): { model?: Model<any>; warning?: string; error?: string } {
205
+ if (!pinned) return { model: parentModel };
206
+ const [provider, ...rest] = pinned.split("/");
207
+ const model = runtime.getModel(provider, rest.join("/"));
208
+ if (model && runtime.hasConfiguredAuth(model.provider)) return { model };
209
+ const reason = model ? "no auth configured" : "model no longer exists"; // pi's own wording (`dist/core/model-resolver.js:547`)
210
+ if (parentModel) return { model: parentModel, warning: `pinned model ${pinned} is unavailable (${reason}); using ${parentModel.provider}/${parentModel.id}` };
211
+ return { error: `model ${pinned} is not available (${reason}) and the session has none (check /model or /login)` };
212
+ }
213
+
214
+ /**
215
+ * The tool allowlist for one run. A job's own `--tools` is an allowlist over what the parent has
216
+ * (never a way to gain a tool the session gave up); with no `--tools` the run inherits the
217
+ * parent's active set. Custom tools (this run's MCP servers and the automation tools) are always
218
+ * added, because pi's allowlist filters those too and they are the sub-agent's whole point.
219
+ */
220
+ export function subSessionTools(req: Pick<SubagentRequest, "tools">, parentTools: string[] | undefined, customTools: Array<{ name: string }>): string[] | undefined {
221
+ const custom = customTools.map((t) => t.name);
222
+ if (req.tools) return [...new Set(req.tools.filter((t) => !parentTools || parentTools.includes(t) || custom.includes(t)))];
223
+ if (!parentTools) return undefined; // no parent session (the headless host): pi's own default
224
+ return [...new Set([...parentTools, ...custom])];
225
+ }
226
+
227
+ /**
228
+ * Why a run in flight has to stop, or undefined while it may go on. `[limits] daily_budget_usd` is
229
+ * a *daily total*, so a run is measured against what today has already cost plus everything this
230
+ * process has in flight, its own cost included: consulted only at the entrance, the cap would bound
231
+ * the rate of dispatch rather than the bill. `cap <= 0` is "no cap", which is the default.
232
+ */
233
+ export function budgetStopReason(budget: DailyBudget | undefined, inFlightCost: number): string | undefined {
234
+ if (!budget || !(budget.cap > 0)) return undefined;
235
+ if (budget.spent + inFlightCost < budget.cap) return undefined;
236
+ const money = (n: number) => `$${n.toFixed(2)}`;
237
+ // The first 80 characters are all `/cron runs` shows, so the cap and the word budget come first:
238
+ // a run stopped on purpose must not be read as a run that broke.
239
+ return `stopped by today's ${money(budget.cap)} daily budget (${money(budget.spent)} already spent, ${money(inFlightCost)} in flight); raise [limits] daily_budget_usd or wait for midnight`;
240
+ }
241
+
242
+ /**
243
+ * What this process has spent that the run log does not know about yet. The cap is measured against
244
+ * the run log, and a record only lands there when the run finishes: without this every run in
245
+ * flight would be measured as if the ones beside it were free, and three runs admitted in the same
246
+ * tick could each spend the whole cap.
247
+ */
248
+ export interface InFlightCosts {
249
+ /** Register a run's live cost. The returned function retires it once the run is over. */
250
+ enter(runId: string | undefined, cost: () => number): () => void;
251
+ /** Uncommitted spend as this run must see it: every live run, plus this run's finished siblings. */
252
+ total(runId: string | undefined): number;
253
+ }
254
+
255
+ export function createInFlightCosts(remember = 64): InFlightCosts {
256
+ const live = new Set<() => number>();
257
+ // A run's own finished sub-agents: `--verify` makes the checker a second sub-agent of the same
258
+ // run (same `runId`), and it must not be measured as though the maker before it had been free.
259
+ // Read only by a request carrying that id, which cannot double-count: the run's record — the
260
+ // thing that would count the maker a second time — is not written until its checker is done.
261
+ // Nothing tells the runner when that happens, so the map is simply bounded instead of cleared.
262
+ const finished = new Map<string, number>();
263
+ return {
264
+ enter(runId, cost) {
265
+ live.add(cost);
266
+ return () => {
267
+ if (!live.delete(cost)) return;
268
+ if (!runId) return;
269
+ finished.set(runId, (finished.get(runId) ?? 0) + cost());
270
+ const oldest = finished.keys().next();
271
+ if (finished.size > remember && !oldest.done) finished.delete(oldest.value);
272
+ };
273
+ },
274
+ total(runId) {
275
+ let sum = runId ? (finished.get(runId) ?? 0) : 0;
276
+ for (const cost of live) sum += cost();
277
+ return sum;
278
+ },
279
+ };
280
+ }
281
+
282
+ /** What a run had to do to get through, counted off the session's events (nobody else sees them). */
283
+ export interface RunTelemetry {
284
+ warning?: string;
285
+ retries: number;
286
+ compactions: number;
287
+ }
288
+
289
+ /** A finished (or failed) sub-session, reduced to what the schedulers record. */
290
+ export function collect(
291
+ 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 } } },
292
+ req: Pick<SubagentRequest, "signal" | "timeoutMs">,
293
+ timedOut: boolean,
294
+ thrown?: string,
295
+ telemetry?: RunTelemetry,
296
+ /** Set when the daily budget stopped this run: it outranks the abort it had to use to do it. */
297
+ budgetStopMessage?: string,
298
+ ): RunnerResult {
299
+ const messages = session.messages as any[];
300
+ const assistant = [...messages].reverse().find((message) => message?.role === "assistant");
301
+ const text = assistant ? (assistant.content as any[]).filter((b) => b?.type === "text").map((b) => b.text).join("\n") : "";
302
+ const stopReason: string | undefined = assistant?.stopReason;
303
+ const stats = session.getSessionStats();
304
+ const aborted = req.signal?.aborted ?? false;
305
+ const errorMessage = budgetStopMessage ?? thrown ?? (timedOut ? `timed out after ${Math.round(req.timeoutMs / 1000)}s` : aborted ? "aborted" : stopReason === "error" ? (assistant?.errorMessage ?? session.agent.state.errorMessage ?? "model error") : undefined);
306
+ const ok = !budgetStopMessage && !timedOut && !aborted && !thrown && stopReason !== "error" && stopReason !== "aborted" && !!assistant;
307
+ return {
308
+ ok,
309
+ exitCode: ok ? 0 : 1,
310
+ timedOut,
311
+ text,
312
+ errorMessage: ok ? undefined : (errorMessage ?? "the sub-agent produced no reply"),
313
+ // A budget stop is deliberate, so it reports the abort it performed rather than whatever the
314
+ // model was in the middle of: the schedulers read `aborted` as "this was not the job failing"
315
+ // — the slot goes back, the failure streak is untouched and a one-shot is not retired — which
316
+ // is exactly right for a run the cap ended, and is what an abort mid-call reports anyway.
317
+ stopReason: budgetStopMessage ? "aborted" : stopReason,
318
+ warning: telemetry?.warning,
319
+ model: assistant ? `${assistant.provider}/${assistant.model}` : undefined,
320
+ // A run that silently retried five times or compacted twice must not look like a clean one.
321
+ retries: telemetry?.retries,
322
+ compactions: telemetry?.compactions,
323
+ usage: { input: stats.tokens.input, output: stats.tokens.output, cacheRead: stats.tokens.cacheRead, cacheWrite: stats.tokens.cacheWrite, cost: stats.cost, turns: stats.assistantMessages },
324
+ sessionId: session.sessionId,
325
+ sessionFile: session.sessionFile,
326
+ };
327
+ }
328
+
329
+ /** Resolved when the run's deadline or its abort signal fires; raced against every setup step. */
330
+ const STOPPED = Symbol("stopped");
331
+
332
+ /**
333
+ * End of one run. No `session_shutdown`: the extension instances are shared with the runs that
334
+ * follow and, through pi's process-global module cache, with the parent's own instances — a
335
+ * sub-run's shutdown would close the browser the interactive session is still using, and a run is
336
+ * a guest in this process rather than its owner. `dispose()` stays: it is per-session (abort,
337
+ * listeners, that session's own resources).
338
+ */
339
+ export function disposeSubSession(session: { dispose(): void }, log?: (message: string) => void): void {
340
+ try {
341
+ session.dispose();
342
+ } catch (err: any) {
343
+ log?.(`sub-agent dispose: ${err?.message ?? err}`);
344
+ }
345
+ }
346
+
347
+ export function createInProcessRunner(deps: InProcessRunnerDeps): SubagentRunner {
348
+ // Only a fallback for the headless host, which has no interactive session to borrow from. It
349
+ // reads the credentials of *this* agent dir, and is rebuilt when they change on disk, so a
350
+ // rotated key or a fresh `pi login` is picked up without restarting the host.
351
+ const authPath = path.join(deps.agentDir, "auth.json");
352
+ const modelsPath = path.join(deps.agentDir, "models.json");
353
+ const authStamp = () => {
354
+ try {
355
+ const s = fs.statSync(authPath);
356
+ return `${s.mtimeMs}:${s.size}`;
357
+ } catch {
358
+ return "none";
359
+ }
360
+ };
361
+ let own: { stamp: string; runtime: Promise<ModelRuntime> } | undefined;
362
+ const ownRuntime = () => {
363
+ const stamp = authStamp();
364
+ if (own?.stamp !== stamp) {
365
+ const entry: { stamp: string; runtime: Promise<ModelRuntime> } = {
366
+ stamp,
367
+ runtime: ModelRuntime.create({ authPath, modelsPath }).catch((err) => {
368
+ if (own === entry) own = undefined; // a failed creation must not be cached forever
369
+ throw err;
370
+ }),
371
+ };
372
+ own = entry;
373
+ }
374
+ return own.runtime;
375
+ };
376
+ const modelRuntime = async (): Promise<ModelRuntime> => unwrapModelRuntime(deps.getParentModelRuntime?.()) ?? (await ownRuntime());
377
+ // Shared by every run this runner starts, so each one is measured against what the others are
378
+ // spending right now and not only against what the run log has already been told.
379
+ const inFlight = createInFlightCosts();
380
+
381
+ return async (req: SubagentRequest): Promise<RunnerResult> => {
382
+ let session: Awaited<ReturnType<typeof createAgentSession>>["session"] | undefined;
383
+ let unsubscribe: (() => void) | undefined;
384
+ let timedOut = false;
385
+ let budgetStopMessage: string | undefined;
386
+ let stop = () => {};
387
+ const stopped = new Promise<typeof STOPPED>((resolve) => {
388
+ stop = () => resolve(STOPPED);
389
+ });
390
+ const onAbort = () => {
391
+ stop();
392
+ // pi's abort can reject (a provider connection already torn down); the run is over either
393
+ // way, and an unhandled rejection here would end the parent session, not just the run.
394
+ void session?.abort().catch(() => undefined);
395
+ };
396
+ // The deadline covers resource loading and session creation too, not just the prompt.
397
+ const timer = setTimeout(() => {
398
+ timedOut = true;
399
+ onAbort();
400
+ }, req.timeoutMs);
401
+ timer.unref();
402
+ req.signal?.addEventListener("abort", onAbort, { once: true });
403
+ // This run's own live cost, and its place in what the process has in flight. Retired in the
404
+ // `finally` below, before the session is disposed, so its final cost is still readable there.
405
+ const runCost = () => {
406
+ try {
407
+ return session?.getSessionStats().cost ?? 0;
408
+ } catch {
409
+ return 0; // a half-built or already disposed session has nothing to report
410
+ }
411
+ };
412
+ const retireCost = inFlight.enter(req.runId, runCost);
413
+ /**
414
+ * Whether the daily budget has to stop this run — and if so, stops it. The abort is the
415
+ * timeout's: pi's `session.abort()`, the same `stopped` race, the same `collect()` at the end,
416
+ * so the run is recorded with its cost, the slot is handed back and the schedule keeps its
417
+ * next appointment. Only the reason differs, and it has to: `/cron runs` would otherwise show
418
+ * a deliberate stop exactly like a job that broke.
419
+ */
420
+ const overBudget = (): boolean => {
421
+ if (budgetStopMessage) return true;
422
+ let budget: DailyBudget | undefined;
423
+ try {
424
+ budget = deps.budget?.();
425
+ } catch {
426
+ return false; // an unreadable run log must not fail the run (`budgetState` reads it too)
427
+ }
428
+ const reason = budgetStopReason(budget, inFlight.total(req.runId));
429
+ if (!reason) return false;
430
+ budgetStopMessage = reason;
431
+ deps.log?.(reason);
432
+ onAbort();
433
+ return true;
434
+ };
435
+ 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);
436
+ const stoppedResult = () => stoppedEarly() ?? failedRun("aborted", { stopReason: "aborted" });
437
+ // Until the session exists there is nothing for `abort()` to reach, and setup is not quick:
438
+ // `loader.reload()` shells out to npm/git for a project's `settings.json` packages, so a
439
+ // stalled clone would hold the job's running claim and a concurrency slot forever. So every
440
+ // setup step is raced against the run's own deadline, not only the model call after them.
441
+ // The step itself keeps running detached — an npm install cannot be cancelled — but the run
442
+ // gives its slot back on time and the finished load still lands in the shared cache.
443
+ const untilStopped = async <T>(work: T | Promise<T>): Promise<T | typeof STOPPED> => Promise.race([work, stopped]);
444
+ const telemetry: RunTelemetry = { retries: 0, compactions: 0 };
445
+ try {
446
+ // Before anything is bought: the day can go over between the dispatcher admitting this run
447
+ // and the run reaching here, because the runs admitted with it are spending in parallel.
448
+ // Setting up is not free either — loading a project's packages shells out to npm/git.
449
+ if (overBudget()) return stoppedResult();
450
+ let model: Model<any> | undefined;
451
+ try {
452
+ const parent = deps.getParentModel();
453
+ // Only a pinned model needs the catalogue; the parent's model is already resolved.
454
+ let resolved: ReturnType<typeof resolveRunModel> = { model: parent };
455
+ if (req.model) resolved = resolveRunModel(req.model, parent, await modelRuntime());
456
+ if (resolved.error) return failedRun(resolved.error);
457
+ model = resolved.model;
458
+ if (resolved.warning) {
459
+ telemetry.warning = resolved.warning;
460
+ deps.log?.(resolved.warning);
461
+ }
462
+ if (!model) return failedRun("no model is available for the sub-agent (the session has none and the job pins none)");
463
+ } catch (err: any) {
464
+ return failedRun(`model runtime: ${err?.message ?? err}`);
465
+ }
466
+
467
+ const customTools = await untilStopped(deps.customTools(req));
468
+ if (customTools === STOPPED) return stoppedResult();
469
+ const resources = await untilStopped(sharedSubSessionResources(req, deps));
470
+ if (resources === STOPPED) return stoppedResult();
471
+ const created = await untilStopped(
472
+ createAgentSession({
473
+ cwd: req.cwd,
474
+ agentDir: deps.agentDir,
475
+ model,
476
+ thinkingLevel: (req.thinking ?? deps.getParentThinking()) as Thinking,
477
+ tools: subSessionTools(req, deps.getParentTools?.(), customTools),
478
+ customTools,
479
+ resourceLoader: resources.loader,
480
+ sessionManager: req.sessionDir ? SessionManager.create(req.cwd, req.sessionDir) : SessionManager.inMemory(req.cwd),
481
+ settingsManager: resources.settingsManager,
482
+ modelRuntime: await modelRuntime(),
483
+ sessionStartEvent: { type: "session_start", reason: "new" },
484
+ }),
485
+ );
486
+ if (created === STOPPED) return stoppedResult();
487
+ session = created.session;
488
+ if (session.sessionFile) req.onSessionFile?.(session.sessionFile);
489
+
490
+ // Nobody else watches this session, so what the run had to do to get through is only
491
+ // visible here: silent provider retries and context compactions.
492
+ // PI_LOOPS_DEBUG=1 traces what the run actually did:
493
+ // enough to recognise a retry storm, a tool loop or a run that produced nothing, without
494
+ // opening the transcript. It goes to the same log the rest of the diagnostics do.
495
+ const debug = envFlag("LOOPS_DEBUG");
496
+ const label = `${req.kind}/${(req.jobId ?? req.traceId ?? "?").slice(0, 12)}`;
497
+ unsubscribe = session.subscribe((e: any) => {
498
+ if (e.type === "auto_retry_start") {
499
+ telemetry.retries++;
500
+ if (debug) deps.log?.(`debug ${label}: provider retry`);
501
+ } else if (e.type === "compaction_end" && !e.aborted) {
502
+ telemetry.compactions++;
503
+ if (debug) deps.log?.(`debug ${label}: context compacted`);
504
+ } else if (e.type === "turn_end") {
505
+ // Once per turn, not per event: a completed turn is the smallest step whose cost has
506
+ // actually landed in the session's stats (`getSessionStats` adds up its entries), and
507
+ // the point where stopping wastes nothing that was already paid for. Checking on every
508
+ // event would re-read the whole session for deltas that cannot have changed.
509
+ overBudget();
510
+ } else if (debug && e.type === "tool_execution_start") deps.log?.(`debug ${label}: ${e.toolName} ${previewRedacted(JSON.stringify(e.args ?? {}), 160)}`);
511
+ else if (debug && e.type === "tool_execution_end") deps.log?.(`debug ${label}: ${e.toolName} ${e.isError ? "FAILED" : "ok"}`);
512
+ });
513
+ if (debug) deps.log?.(`debug ${label}: started in ${req.cwd} on ${model?.provider}/${model?.id}`);
514
+ // The parent's extensions get their session_start (they may register tools or state there),
515
+ // exactly as pi's own headless modes bind them.
516
+ const bound = await untilStopped(session.bindExtensions({ mode: "print", onError: (e) => deps.log?.(`sub-agent extension ${e.extensionPath} ${e.event}: ${e.error}`) }));
517
+ if (bound === STOPPED) return stoppedResult();
518
+ // Setup can take seconds; the runs beside this one were spending throughout. Check with the
519
+ // session in hand, before the first token is bought, then once per turn from the subscriber.
520
+ if (overBudget()) return collect(session, req, timedOut, undefined, telemetry, budgetStopMessage);
521
+ await session.prompt(req.prompt);
522
+ return collect(session, req, timedOut, undefined, telemetry, budgetStopMessage);
523
+ } catch (err: any) {
524
+ const message = err?.message ?? String(err);
525
+ if (session) return collect(session, req, timedOut, message, telemetry, budgetStopMessage);
526
+ return failedRun(budgetStopMessage ?? message, { warning: telemetry.warning });
527
+ } finally {
528
+ clearTimeout(timer);
529
+ stop();
530
+ retireCost();
531
+ unsubscribe?.();
532
+ req.signal?.removeEventListener("abort", onAbort);
533
+ if (session) disposeSubSession(session, deps.log);
534
+ }
535
+ };
536
+ }
package/src/share.ts ADDED
@@ -0,0 +1,121 @@
1
+ /**
2
+ * `/session-share`: this session as a Markdown transcript, uploaded as a GitHub gist through `gh`.
3
+ *
4
+ * The transcript is rendered here and shelled straight out to `gh gist create` — nothing of it
5
+ * reaches a server of ours, because there is no server of ours. Two things happen before it
6
+ * leaves: everything goes through `redact`, and the command shows what it is about to upload — how
7
+ * many messages, how many tool results, how many secrets it masked. A transcript names every file
8
+ * the agent read and every command it ran, and an upload is the one action here that cannot be
9
+ * taken back, so the summary comes first and the confirmation after it.
10
+ */
11
+ import { redact } from "./redact.ts";
12
+ import { stamp } from "./schedule.ts";
13
+
14
+ export interface ShareMessage {
15
+ role?: string;
16
+ content?: unknown;
17
+ toolName?: string;
18
+ isError?: boolean;
19
+ }
20
+
21
+ export interface RenderedShare {
22
+ markdown: string;
23
+ messages: number;
24
+ toolResults: number;
25
+ /** How many secrets `redact` masked, so the confirmation can say so. */
26
+ redactions: number;
27
+ bytes: number;
28
+ }
29
+
30
+ const REDACTED = /\[REDACTED:[a-z_]+\]/g;
31
+
32
+ function countRedactions(text: string): number {
33
+ return (text.match(REDACTED) ?? []).length;
34
+ }
35
+
36
+ /** One content block, whatever shape the provider used. */
37
+ function blockText(block: any): string {
38
+ if (typeof block === "string") return block;
39
+ if (!block || typeof block !== "object") return "";
40
+ if (block.type === "text") return String(block.text ?? "");
41
+ if (block.type === "thinking") return String(block.thinking ?? "");
42
+ if (block.type === "image") return "`[image]`";
43
+ return "";
44
+ }
45
+
46
+ function contentText(content: unknown): string {
47
+ if (typeof content === "string") return content;
48
+ if (Array.isArray(content)) return content.map(blockText).filter(Boolean).join("\n\n");
49
+ return "";
50
+ }
51
+
52
+ /**
53
+ * Render the messages of a session as Markdown. Pure: the caller decides where it goes, which is
54
+ * what makes the redaction counting testable without a session, a model or a network.
55
+ */
56
+ export function renderShare(messages: ShareMessage[], meta: { model?: string; sessionId?: string; cwd?: string; when?: Date } = {}): RenderedShare {
57
+ const when = meta.when ?? new Date();
58
+ // The body first, then the header: a message of a shape nobody would recognise is skipped, so
59
+ // what the header claims and what the confirmation the user approves reports have to be the one
60
+ // count — the rendered one. `messages.length` was the other, and they differ exactly when the
61
+ // transcript holds something unusual.
62
+ const body: string[] = [];
63
+ let toolResults = 0;
64
+ let shown = 0;
65
+ for (const m of messages) {
66
+ const role = m.role ?? "unknown";
67
+ if (role === "user") {
68
+ body.push(`## ${shown}. User`, "", contentText(m.content), "");
69
+ } else if (role === "assistant") {
70
+ body.push(`## ${shown}. Assistant`, "");
71
+ const blocks = Array.isArray(m.content) ? m.content : [m.content];
72
+ for (const b of blocks as any[]) {
73
+ if (b?.type === "thinking" && b.thinking) {
74
+ body.push("<details><summary>thinking</summary>", "", "```", String(b.thinking), "```", "", "</details>", "");
75
+ } else if (b?.type === "toolCall") {
76
+ body.push(`**tool call** \`${b.name}\``, "", "```json", JSON.stringify(b.arguments ?? {}, null, 2), "```", "");
77
+ } else {
78
+ const text = blockText(b);
79
+ if (text) body.push(text, "");
80
+ }
81
+ }
82
+ } else if (role === "toolResult") {
83
+ toolResults++;
84
+ body.push(`## ${shown}. Tool result \`${m.toolName ?? ""}\`${m.isError ? " (error)" : ""}`, "", "```", contentText(m.content), "```", "");
85
+ } else if (role === "custom") {
86
+ body.push(`## ${shown}. ${role}`, "", contentText(m.content) || "`(no displayable content)`", "");
87
+ } else {
88
+ continue; // nothing a reader would recognise; skip rather than print a shape
89
+ }
90
+ shown++;
91
+ }
92
+
93
+ const out: string[] = ["# Session transcript", ""];
94
+ if (meta.model) out.push(`- Model: \`${meta.model}\``);
95
+ if (meta.sessionId) out.push(`- Session: \`${meta.sessionId}\``);
96
+ out.push(`- Messages: ${shown}`, `- Exported: ${stamp(when.getTime())}`, "", "> Redacted by pi-loops before upload. Review it anyway: a transcript carries whatever the agent read.", "");
97
+
98
+ const markdown = redact([...out, ...body].join("\n"));
99
+ return {
100
+ markdown,
101
+ messages: shown,
102
+ toolResults,
103
+ redactions: countRedactions(markdown),
104
+ bytes: Buffer.byteLength(markdown, "utf8"),
105
+ };
106
+ }
107
+
108
+ /**
109
+ * What the confirmation says before anything leaves the machine. The redaction count is reported,
110
+ * but never on its own: a count of zero reads as "nothing sensitive in here", and what it actually
111
+ * means is that no *known shape* matched. The redactor does not know your employer's key format,
112
+ * and a secret wrapped across two lines of tool output is two strings as far as it is concerned.
113
+ */
114
+ export function shareSummary(rendered: RenderedShare, opts: { public: boolean }): string[] {
115
+ return [
116
+ ` ${rendered.messages} message(s), ${rendered.toolResults} tool result(s), ${(rendered.bytes / 1024).toFixed(1)} KB`,
117
+ ` ${rendered.redactions} secret(s) masked — the redactor knows common shapes, not every shape`,
118
+ opts.public ? " visibility: PUBLIC — anyone can find it" : " visibility: secret gist — unlisted, but anyone with the link can read it",
119
+ " it contains every file the agent read and every command it ran in this session",
120
+ ];
121
+ }