@alphacoder-v0/pi-loops 0.22.8 → 0.24.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +717 -0
- package/CONTRACT.md +7 -7
- package/README.md +114 -78
- package/README.zh-CN.md +62 -41
- package/docs/cli.md +352 -91
- package/docs/configuration.md +86 -24
- package/docs/design.md +404 -61
- package/docs/downstream.md +2 -2
- package/docs/goal.md +12 -3
- package/docs/hooks.md +6 -7
- package/docs/loops.md +288 -59
- package/docs/mcp.md +22 -14
- package/docs/recipes.md +6 -0
- package/docs/screenshot.png +0 -0
- package/docs/triggers.md +120 -26
- package/docs/troubleshooting.md +41 -28
- package/docs/web-ui-parity.md +702 -65
- package/package.json +4 -3
- package/recipes/autoresearch/recipe.toml +1 -0
- package/recipes/changelog-draft/recipe.toml +1 -0
- package/recipes/ci-sweeper/recipe.toml +1 -0
- package/recipes/daily-digest/recipe.toml +1 -0
- package/recipes/deps-sweeper/recipe.toml +1 -0
- package/recipes/ecosystem/recipe.toml +1 -0
- package/recipes/issue-loop/recipe.toml +1 -0
- package/recipes/pr-watch/recipe.toml +1 -0
- package/skills/pi-loops/SKILL.md +6 -4
- package/src/activity.ts +199 -0
- package/src/archive.ts +21 -9
- package/src/args.ts +6 -2
- package/src/browser-open.ts +169 -0
- package/src/cli-entry.mjs +12 -3
- package/src/cli.ts +960 -143
- package/src/config.ts +53 -5
- package/src/conversation-rows.ts +469 -0
- package/src/danger.ts +227 -6
- package/src/engine.ts +1240 -0
- package/src/goal.ts +71 -0
- package/src/held-read.ts +27 -0
- package/src/hooks.ts +49 -18
- package/src/host-control-channel.ts +455 -40
- package/src/host-control.ts +602 -111
- package/src/host-entry.mjs +5 -3
- package/src/host-runtime.ts +455 -42
- package/src/host.ts +659 -123
- package/src/inbox-card.ts +278 -0
- package/src/inbox.ts +139 -62
- package/src/job-edit.ts +7 -1
- package/src/job-health.ts +14 -2
- package/src/job-lines.ts +201 -13
- package/src/job-owner.ts +74 -13
- package/src/job-tune.ts +110 -0
- package/src/lease.ts +537 -0
- package/src/lock.ts +7 -2
- package/src/mcp-pool.ts +66 -14
- package/src/mcp.ts +1 -1
- package/src/open-file.ts +54 -0
- package/src/page-children.ts +1507 -0
- package/src/page-devices.ts +192 -0
- package/src/page-server.ts +3150 -0
- package/src/path-precheck.ts +215 -0
- package/src/paths.ts +24 -0
- package/src/pi-floor.ts +10 -0
- package/src/pi-loops.ts +1370 -473
- package/src/pi-resolver.d.mts +1 -0
- package/src/pi-resolver.mjs +110 -10
- package/src/presence.ts +12 -63
- package/src/protocol.ts +10 -1
- package/src/recipe.ts +43 -1
- package/src/requests.ts +354 -0
- package/src/rule-lines.ts +68 -0
- package/src/run-card.ts +105 -0
- package/src/runner.ts +18 -1
- package/src/schedule.ts +1 -2
- package/src/scheduler.ts +595 -245
- package/src/sdk-runner.ts +321 -27
- package/src/seen.ts +278 -0
- package/src/session-head.ts +21 -0
- package/src/share.ts +12 -2
- package/src/snapshot.ts +3 -2
- package/src/store.ts +81 -8
- package/src/tools.ts +196 -65
- package/src/trigger-runtime.ts +164 -127
- package/src/triggers.ts +39 -7
- package/src/trust.ts +46 -2
- package/src/ui-prefs.ts +22 -7
- package/src/watch-plan.ts +174 -0
- package/src/web.d.mts +3 -0
- package/src/web.mjs +2261 -2340
- package/src/window-executor.ts +459 -0
- package/src/window-guard.ts +158 -0
- package/docs/agents/issue-tracker.md +0 -34
package/src/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
|
-
/**
|
|
133
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
160
|
-
let
|
|
161
|
-
if (!
|
|
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)
|
|
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
|
-
})()
|
|
169
|
-
|
|
170
|
-
|
|
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
|
-
|
|
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():
|
|
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
|
-
|
|
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:
|
|
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
|
-
|
|
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
|
}
|