@cr1ms0n/pi-subagent 0.8.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (46) hide show
  1. package/CHANGELOG.md +352 -0
  2. package/LICENSE +21 -0
  3. package/README.md +543 -0
  4. package/docs/ARCHITECTURE.md +125 -0
  5. package/docs/COST-ACCOUNTING.md +66 -0
  6. package/docs/PLAN.md +325 -0
  7. package/docs/RELEASING.md +32 -0
  8. package/docs/ROADMAP.md +252 -0
  9. package/docs/SECURITY.md +85 -0
  10. package/docs/UI-OVERHAUL.md +186 -0
  11. package/docs/UX.md +141 -0
  12. package/extensions/subagent.ts +1 -0
  13. package/package.json +58 -0
  14. package/skills/subagent/SKILL.md +103 -0
  15. package/src/agents.ts +285 -0
  16. package/src/backend.ts +146 -0
  17. package/src/backends/claude.ts +384 -0
  18. package/src/backends/codex.ts +330 -0
  19. package/src/backends/index.ts +26 -0
  20. package/src/backends/pi.ts +94 -0
  21. package/src/btw.ts +34 -0
  22. package/src/config.ts +254 -0
  23. package/src/distill.ts +222 -0
  24. package/src/extension.ts +1527 -0
  25. package/src/format.ts +365 -0
  26. package/src/index.ts +60 -0
  27. package/src/launch.ts +120 -0
  28. package/src/maintenance.ts +6 -0
  29. package/src/model-policy.ts +157 -0
  30. package/src/notifications.ts +106 -0
  31. package/src/orchestrator.ts +247 -0
  32. package/src/output.ts +124 -0
  33. package/src/persistence.ts +334 -0
  34. package/src/policy.ts +500 -0
  35. package/src/process-lock.ts +687 -0
  36. package/src/protocol.ts +290 -0
  37. package/src/registry.ts +632 -0
  38. package/src/runner.ts +850 -0
  39. package/src/schema.ts +166 -0
  40. package/src/semaphore.ts +123 -0
  41. package/src/structured.ts +169 -0
  42. package/src/transcript.ts +360 -0
  43. package/src/types.ts +197 -0
  44. package/src/ui.ts +545 -0
  45. package/src/usage.ts +274 -0
  46. package/src/worktree.ts +753 -0
package/src/config.ts ADDED
@@ -0,0 +1,254 @@
1
+ import * as fs from "node:fs/promises";
2
+ import * as os from "node:os";
3
+ import * as path from "node:path";
4
+ import type { TaskProfile } from "./types.js";
5
+ import { MODEL_POLICY_CONFIG_FILE, parseModelPolicy, type ModelPolicySnapshot } from "./model-policy.js";
6
+
7
+ const THINKING_LEVELS = ["off", "minimal", "low", "medium", "high", "xhigh"] as const;
8
+ export type ThinkingLevel = (typeof THINKING_LEVELS)[number];
9
+
10
+ const WIDGET_MODES = ["background", "off"] as const;
11
+ export type WidgetMode = (typeof WIDGET_MODES)[number];
12
+ const NOTIFICATION_MODES = ["batched", "off"] as const;
13
+ export type NotificationMode = (typeof NOTIFICATION_MODES)[number];
14
+
15
+ /**
16
+ * Per-profile defaults applied when a task omits the field. Explicit request
17
+ * values always win; profile defaults beat parent-session inheritance for
18
+ * model/thinking so users can e.g. route all explore tasks to a cheap model.
19
+ */
20
+ export interface TaskDefaults {
21
+ model?: string;
22
+ thinking?: ThinkingLevel;
23
+ maxTurns?: number;
24
+ maxCost?: number;
25
+ timeoutMs?: number;
26
+ /** Ordered backup models tried on transient provider failures. */
27
+ fallbackModels?: string[];
28
+ /** Extra attempts on transient failures. */
29
+ maxRetries?: number;
30
+ }
31
+
32
+ export type TaskDefaultsByProfile = Partial<Record<TaskProfile, TaskDefaults>>;
33
+
34
+ export interface SubagentConfig {
35
+ maxTasksPerRun: number;
36
+ maxActiveProcesses: number;
37
+ maxQueuedTasks: number;
38
+ defaultTimeoutMs: number;
39
+ maxResultBytes: number;
40
+ maxResultLines: number;
41
+ maxDetailsTextBytes: number;
42
+ maxCompletedInMemory: number;
43
+ maxDepth: number;
44
+ /** Grace period between SIGTERM and SIGKILL for child process trees. */
45
+ killGraceMs: number;
46
+ sessionDir: string;
47
+ /** Durable root for subagent git worktrees (never a purgeable tmpdir). */
48
+ worktreeDir: string;
49
+ /** Durable root for machine-wide locks, slots, and run process records. */
50
+ lockDir: string;
51
+ /**
52
+ * Machine-wide concurrent child process cap across every Pi parent process.
53
+ * 0 disables the global limiter (per-session maxActiveProcesses still applies).
54
+ */
55
+ maxGlobalActive: number;
56
+ /** Days after which unchanged, orphaned worktrees are swept. 0 disables. */
57
+ worktreeRetentionDays: number;
58
+ /** Days after which unreferenced child session files are swept. Unset/0 disables. */
59
+ sessionRetentionDays?: number;
60
+ /** Days after which terminal run process records / dead locks are swept. */
61
+ lockRetentionDays: number;
62
+ /** Legacy per-profile defaults. Model/fallback fields are retained for config compatibility but ignored by model policy. */
63
+ taskDefaults?: TaskDefaultsByProfile;
64
+ /** Strict, user-owned model routing policy. Missing means new spawns are rejected. */
65
+ modelPolicy?: ModelPolicySnapshot;
66
+ /** Human-readable parse/read error for the model policy. */
67
+ modelPolicyError?: string;
68
+ /**
69
+ * Wrap-up grace turns after a max_turns/max_cost breach: the child is steered
70
+ * to produce a final answer and given this many extra turns before SIGTERM.
71
+ * 0 restores the old immediate-stop behavior.
72
+ */
73
+ graceTurns: number;
74
+ /** Milliseconds of protocol silence before a running child is flagged as stalled. 0 disables. */
75
+ stallAfterMs: number;
76
+ /** Additional silence after the stall flag before the child is killed (feeds retry). 0 disables kill. */
77
+ stallKillAfterMs: number;
78
+ /** Default extra attempts on transient failures (queued timeout, stall, provider error). */
79
+ maxRetries: number;
80
+ /**
81
+ * Ambient background-run widget above the editor. `"off"` clears any existing
82
+ * widget and skips refreshes.
83
+ */
84
+ widget: WidgetMode;
85
+ /**
86
+ * Batched completion followUp messages for async runs. `"off"` disables the
87
+ * CompletionBatcher so the parent is not notified on finish.
88
+ */
89
+ notifications: NotificationMode;
90
+ }
91
+
92
+ export const defaultConfig: SubagentConfig = {
93
+ maxTasksPerRun: 8,
94
+ maxActiveProcesses: 4,
95
+ maxQueuedTasks: 32,
96
+ defaultTimeoutMs: 15 * 60_000,
97
+ maxResultBytes: 50 * 1024,
98
+ maxResultLines: 2_000,
99
+ maxDetailsTextBytes: 10 * 1024,
100
+ maxCompletedInMemory: 20,
101
+ maxDepth: 2,
102
+ killGraceMs: 3_000,
103
+ sessionDir: path.join(os.homedir(), ".pi", "subagent-sessions"),
104
+ worktreeDir: path.join(os.homedir(), ".pi", "subagent-worktrees"),
105
+ lockDir: path.join(os.homedir(), ".pi", "subagent-locks"),
106
+ maxGlobalActive: 16,
107
+ worktreeRetentionDays: 7,
108
+ lockRetentionDays: 7,
109
+ graceTurns: 2,
110
+ stallAfterMs: 90_000,
111
+ stallKillAfterMs: 90_000,
112
+ maxRetries: 1,
113
+ widget: "background",
114
+ notifications: "batched",
115
+ };
116
+
117
+ /** User-facing config file. Env vars override file values; both override defaults. */
118
+ export const CONFIG_FILE = path.join(os.homedir(), ".pi", "subagent.json");
119
+
120
+ function positiveNumber(value: unknown, min = 1): number | undefined {
121
+ const parsed = typeof value === "string" ? Number(value) : value;
122
+ return typeof parsed === "number" && Number.isFinite(parsed) && parsed >= min ? parsed : undefined;
123
+ }
124
+
125
+ function nonEmptyString(value: unknown): string | undefined {
126
+ return typeof value === "string" && value.trim() ? value.trim() : undefined;
127
+ }
128
+
129
+ function oneOf<T extends readonly string[]>(allowed: T, value: unknown): T[number] | undefined {
130
+ return typeof value === "string" && (allowed as readonly string[]).includes(value)
131
+ ? (value as T[number])
132
+ : undefined;
133
+ }
134
+
135
+ function prune<T extends object>(value: Partial<T>): Partial<T> {
136
+ return Object.fromEntries(Object.entries(value).filter(([, v]) => v !== undefined)) as Partial<T>;
137
+ }
138
+
139
+ function sanitizeTaskDefaults(raw: unknown): TaskDefaults | undefined {
140
+ if (!raw || typeof raw !== "object") return undefined;
141
+ const value = raw as Record<string, unknown>;
142
+ const thinking = typeof value.thinking === "string" && (THINKING_LEVELS as readonly string[]).includes(value.thinking)
143
+ ? (value.thinking as ThinkingLevel)
144
+ : undefined;
145
+ const fallbackModels = Array.isArray(value.fallbackModels)
146
+ ? value.fallbackModels.map(nonEmptyString).filter((model): model is string => !!model)
147
+ : undefined;
148
+ const defaults = prune<TaskDefaults>({
149
+ model: nonEmptyString(value.model),
150
+ thinking,
151
+ maxTurns: positiveNumber(value.maxTurns),
152
+ maxCost: positiveNumber(value.maxCost, 0),
153
+ timeoutMs: positiveNumber(value.timeoutMs),
154
+ fallbackModels: fallbackModels?.length ? fallbackModels : undefined,
155
+ maxRetries: positiveNumber(value.maxRetries, 0),
156
+ });
157
+ return Object.keys(defaults).length ? defaults : undefined;
158
+ }
159
+
160
+ function sanitizeTaskDefaultsByProfile(raw: unknown): TaskDefaultsByProfile | undefined {
161
+ if (!raw || typeof raw !== "object") return undefined;
162
+ const value = raw as Record<string, unknown>;
163
+ const result: TaskDefaultsByProfile = {};
164
+ for (const profile of ["explore", "review", "general"] as const) {
165
+ const defaults = sanitizeTaskDefaults(value[profile]);
166
+ if (defaults) result[profile] = defaults;
167
+ }
168
+ return Object.keys(result).length ? result : undefined;
169
+ }
170
+
171
+ /** Validate untrusted JSON overrides field-by-field; unknown keys are dropped. */
172
+ export function sanitizeConfigOverrides(raw: unknown, source = MODEL_POLICY_CONFIG_FILE): Partial<SubagentConfig> {
173
+ if (!raw || typeof raw !== "object") return {};
174
+ const value = raw as Record<string, unknown>;
175
+ let modelPolicy: ModelPolicySnapshot | undefined;
176
+ let modelPolicyError: string | undefined;
177
+ if (Object.prototype.hasOwnProperty.call(value, "modelPolicy")) {
178
+ try {
179
+ modelPolicy = parseModelPolicy(value.modelPolicy, source);
180
+ } catch (error: any) {
181
+ modelPolicyError = error instanceof Error ? error.message : String(error);
182
+ }
183
+ }
184
+ return prune<SubagentConfig>({
185
+ modelPolicy,
186
+ modelPolicyError,
187
+ taskDefaults: sanitizeTaskDefaultsByProfile(value.taskDefaults),
188
+ maxTasksPerRun: positiveNumber(value.maxTasksPerRun),
189
+ maxActiveProcesses: positiveNumber(value.maxActiveProcesses),
190
+ maxQueuedTasks: positiveNumber(value.maxQueuedTasks, 0),
191
+ defaultTimeoutMs: positiveNumber(value.defaultTimeoutMs),
192
+ maxResultBytes: positiveNumber(value.maxResultBytes, 1024),
193
+ maxResultLines: positiveNumber(value.maxResultLines, 10),
194
+ maxDetailsTextBytes: positiveNumber(value.maxDetailsTextBytes, 256),
195
+ maxCompletedInMemory: positiveNumber(value.maxCompletedInMemory),
196
+ maxDepth: positiveNumber(value.maxDepth, 0),
197
+ killGraceMs: positiveNumber(value.killGraceMs, 100),
198
+ sessionDir: nonEmptyString(value.sessionDir),
199
+ worktreeDir: nonEmptyString(value.worktreeDir),
200
+ lockDir: nonEmptyString(value.lockDir),
201
+ maxGlobalActive: positiveNumber(value.maxGlobalActive, 0),
202
+ worktreeRetentionDays: positiveNumber(value.worktreeRetentionDays, 0),
203
+ sessionRetentionDays: positiveNumber(value.sessionRetentionDays, 0),
204
+ lockRetentionDays: positiveNumber(value.lockRetentionDays, 0),
205
+ graceTurns: positiveNumber(value.graceTurns, 0),
206
+ stallAfterMs: positiveNumber(value.stallAfterMs, 0),
207
+ stallKillAfterMs: positiveNumber(value.stallKillAfterMs, 0),
208
+ maxRetries: positiveNumber(value.maxRetries, 0),
209
+ widget: oneOf(WIDGET_MODES, value.widget),
210
+ notifications: oneOf(NOTIFICATION_MODES, value.notifications),
211
+ });
212
+ }
213
+
214
+ export function configFromEnv(env: NodeJS.ProcessEnv = process.env): Partial<SubagentConfig> {
215
+ return prune<SubagentConfig>({
216
+ maxTasksPerRun: positiveNumber(env.PI_SUBAGENT_MAX_TASKS),
217
+ maxActiveProcesses: positiveNumber(env.PI_SUBAGENT_MAX_ACTIVE),
218
+ maxQueuedTasks: positiveNumber(env.PI_SUBAGENT_MAX_QUEUED, 0),
219
+ defaultTimeoutMs: positiveNumber(env.PI_SUBAGENT_TIMEOUT_MS),
220
+ maxDepth: positiveNumber(env.PI_SUBAGENT_MAX_DEPTH, 0),
221
+ killGraceMs: positiveNumber(env.PI_SUBAGENT_KILL_GRACE_MS, 100),
222
+ sessionDir: nonEmptyString(env.PI_SUBAGENT_SESSION_DIR),
223
+ worktreeDir: nonEmptyString(env.PI_SUBAGENT_WORKTREE_DIR),
224
+ lockDir: nonEmptyString(env.PI_SUBAGENT_LOCK_DIR),
225
+ maxGlobalActive: positiveNumber(env.PI_SUBAGENT_MAX_GLOBAL_ACTIVE, 0),
226
+ worktreeRetentionDays: positiveNumber(env.PI_SUBAGENT_WORKTREE_RETENTION_DAYS, 0),
227
+ sessionRetentionDays: positiveNumber(env.PI_SUBAGENT_SESSION_RETENTION_DAYS, 0),
228
+ lockRetentionDays: positiveNumber(env.PI_SUBAGENT_LOCK_RETENTION_DAYS, 0),
229
+ graceTurns: positiveNumber(env.PI_SUBAGENT_GRACE_TURNS, 0),
230
+ stallAfterMs: positiveNumber(env.PI_SUBAGENT_STALL_AFTER_MS, 0),
231
+ stallKillAfterMs: positiveNumber(env.PI_SUBAGENT_STALL_KILL_AFTER_MS, 0),
232
+ maxRetries: positiveNumber(env.PI_SUBAGENT_MAX_RETRIES, 0),
233
+ widget: oneOf(WIDGET_MODES, env.PI_SUBAGENT_WIDGET),
234
+ notifications: oneOf(NOTIFICATION_MODES, env.PI_SUBAGENT_NOTIFICATIONS),
235
+ });
236
+ }
237
+
238
+ /** defaults ← file overrides ← env overrides. Pure; suitable for tests. */
239
+ export function loadConfig(
240
+ fileOverrides: Partial<SubagentConfig> = {},
241
+ env: NodeJS.ProcessEnv = process.env,
242
+ ): SubagentConfig {
243
+ return { ...defaultConfig, ...prune(fileOverrides), ...configFromEnv(env) };
244
+ }
245
+
246
+ /** Read + sanitize the optional user config file. Missing or invalid files yield {}. */
247
+ export async function readConfigFile(file = CONFIG_FILE): Promise<Partial<SubagentConfig>> {
248
+ try {
249
+ return sanitizeConfigOverrides(JSON.parse(await fs.readFile(file, "utf8")), file);
250
+ } catch (error: any) {
251
+ if (error?.code === "ENOENT") return {};
252
+ return { modelPolicyError: `Could not read ${file} as JSON; model policy was not loaded.` };
253
+ }
254
+ }
package/src/distill.ts ADDED
@@ -0,0 +1,222 @@
1
+ import * as fs from "node:fs/promises";
2
+ import * as path from "node:path";
3
+
4
+ /**
5
+ * Lifecycle-driven child-session distillation.
6
+ *
7
+ * A child session is "over" when its run reached a terminal state and nothing
8
+ * on the current parent branch references it for resume. At that point the
9
+ * meaningful impact of the session — what it was asked, what it answered,
10
+ * what it cost — is preserved in a small `.digest.json`, and the full
11
+ * transcript file is deleted. No wall-clock retention windows: the trigger is
12
+ * run lifecycle, with a short min-age guard purely against races with
13
+ * concurrent parents.
14
+ */
15
+
16
+ export interface SessionDigest {
17
+ schemaVersion: 1;
18
+ sessionId: string;
19
+ file: string;
20
+ distilledAt: number;
21
+ startedAt?: number;
22
+ endedAt?: number;
23
+ durationMs?: number;
24
+ model?: string;
25
+ thinking?: string;
26
+ /** First user message (the task), capped. */
27
+ task?: string;
28
+ /** Last non-empty assistant text (the outcome), capped. */
29
+ finalOutput?: string;
30
+ assistantTurns: number;
31
+ toolCalls: number;
32
+ errors: number;
33
+ usage?: { input: number; output: number; cost: number };
34
+ /** Original transcript size in bytes. */
35
+ originalBytes: number;
36
+ /** Set when the transcript could not be parsed; digest is metadata-only. */
37
+ parseFailed?: boolean;
38
+ }
39
+
40
+ export interface LifecycleSweepReport {
41
+ distilled: string[];
42
+ kept: number;
43
+ failed: string[];
44
+ }
45
+
46
+ const TASK_CAP = 600;
47
+ const OUTPUT_CAP = 8_192;
48
+ /** Race guard for concurrent parents whose runs we cannot see. Not retention. */
49
+ export const SESSION_MIN_AGE_MS = 60 * 60_000;
50
+
51
+ function parseTimestamp(value: unknown): number | undefined {
52
+ if (typeof value === "number" && Number.isFinite(value)) return value > 1e12 ? value : value * 1000;
53
+ if (typeof value === "string") {
54
+ const ms = Date.parse(value);
55
+ if (Number.isFinite(ms)) return ms;
56
+ }
57
+ return undefined;
58
+ }
59
+
60
+ function textOf(content: unknown): string {
61
+ if (typeof content === "string") return content;
62
+ if (!Array.isArray(content)) return "";
63
+ return content
64
+ .filter((block: any) => block && block.type === "text" && typeof block.text === "string")
65
+ .map((block: any) => block.text)
66
+ .join("");
67
+ }
68
+
69
+ function countToolCalls(content: unknown): number {
70
+ if (!Array.isArray(content)) return 0;
71
+ return content.filter((block: any) => block && (block.type === "toolCall" || block.type === "toolUse" || block.type === "tool_use")).length;
72
+ }
73
+
74
+ function sessionIdFromFile(file: string): string {
75
+ const base = path.basename(file, ".jsonl");
76
+ const underscore = base.lastIndexOf("_");
77
+ return underscore >= 0 ? base.slice(underscore + 1) : base;
78
+ }
79
+
80
+ export function digestPathFor(file: string): string {
81
+ return file.replace(/\.jsonl$/, ".digest.json");
82
+ }
83
+
84
+ /** Extract the meaningful impact of one child session transcript. */
85
+ export async function digestSessionFile(file: string): Promise<SessionDigest> {
86
+ const stat = await fs.stat(file);
87
+ const digest: SessionDigest = {
88
+ schemaVersion: 1,
89
+ sessionId: sessionIdFromFile(file),
90
+ file: path.basename(file),
91
+ distilledAt: Date.now(),
92
+ assistantTurns: 0,
93
+ toolCalls: 0,
94
+ errors: 0,
95
+ originalBytes: stat.size,
96
+ };
97
+ let raw: string;
98
+ try {
99
+ raw = await fs.readFile(file, "utf8");
100
+ } catch {
101
+ digest.parseFailed = true;
102
+ return digest;
103
+ }
104
+
105
+ let usageInput = 0;
106
+ let usageOutput = 0;
107
+ let usageCost = 0;
108
+ let sawUsage = false;
109
+ let parsedAny = false;
110
+
111
+ for (const line of raw.split("\n")) {
112
+ if (!line.trim()) continue;
113
+ let entry: any;
114
+ try {
115
+ entry = JSON.parse(line);
116
+ } catch {
117
+ continue;
118
+ }
119
+ parsedAny = true;
120
+ const ts = parseTimestamp(entry.timestamp ?? entry.ts);
121
+ if (ts !== undefined) {
122
+ if (digest.startedAt === undefined) digest.startedAt = ts;
123
+ digest.endedAt = ts;
124
+ }
125
+ if (entry.type === "model_change") {
126
+ const model = entry.modelId ?? entry.model;
127
+ if (typeof model === "string" && model) digest.model = model;
128
+ }
129
+ if (entry.type === "thinking_level_change" && typeof entry.thinkingLevel === "string") {
130
+ digest.thinking = entry.thinkingLevel;
131
+ }
132
+ const message = entry.type === "message" ? entry.message : undefined;
133
+ if (!message || typeof message !== "object") continue;
134
+ if (message.role === "user" && digest.task === undefined) {
135
+ const text = textOf(message.content).trim();
136
+ if (text) digest.task = text.slice(0, TASK_CAP);
137
+ }
138
+ if (message.role === "assistant") {
139
+ digest.assistantTurns++;
140
+ digest.toolCalls += countToolCalls(message.content);
141
+ if (message.stopReason === "error") digest.errors++;
142
+ const text = textOf(message.content).trim();
143
+ if (text) digest.finalOutput = text.slice(0, OUTPUT_CAP);
144
+ const usage = message.usage;
145
+ if (usage && typeof usage === "object") {
146
+ const input = Number(usage.input);
147
+ const output = Number(usage.output);
148
+ const cost = typeof usage.cost === "number" ? usage.cost : Number(usage.cost?.total);
149
+ if (Number.isFinite(input)) { usageInput += input; sawUsage = true; }
150
+ if (Number.isFinite(output)) { usageOutput += output; sawUsage = true; }
151
+ if (Number.isFinite(cost)) { usageCost += cost; sawUsage = true; }
152
+ }
153
+ }
154
+ }
155
+
156
+ if (!parsedAny) digest.parseFailed = true;
157
+ if (sawUsage) digest.usage = { input: usageInput, output: usageOutput, cost: usageCost };
158
+ if (digest.startedAt !== undefined && digest.endedAt !== undefined) {
159
+ digest.durationMs = Math.max(0, digest.endedAt - digest.startedAt);
160
+ }
161
+ return digest;
162
+ }
163
+
164
+ /**
165
+ * Distill one session file to a digest and remove the transcript.
166
+ * The transcript is only removed after the digest is durably written.
167
+ */
168
+ export async function distillSessionFile(file: string): Promise<SessionDigest> {
169
+ const digest = await digestSessionFile(file);
170
+ const target = digestPathFor(file);
171
+ const tmp = `${target}.${process.pid}.tmp`;
172
+ await fs.writeFile(tmp, JSON.stringify(digest, null, 2), "utf8");
173
+ await fs.rename(tmp, target);
174
+ await fs.rm(file, { force: true });
175
+ return digest;
176
+ }
177
+
178
+ export interface LifecycleSweepOptions {
179
+ /** Session ids that must be kept (referenced by the live parent branch). */
180
+ keep: ReadonlySet<string>;
181
+ /** Session ids that are busy machine-wide (running run records, live locks). */
182
+ busy: ReadonlySet<string>;
183
+ minAgeMs?: number;
184
+ now?: number;
185
+ }
186
+
187
+ /**
188
+ * Distill every child session whose run is over. "Over" is a lifecycle fact:
189
+ * not referenced by the current branch, not owned by any running run record,
190
+ * not locked, and past a short race guard. Age plays no retention role.
191
+ */
192
+ export async function sweepSessionsLifecycle(
193
+ sessionDir: string,
194
+ options: LifecycleSweepOptions,
195
+ ): Promise<LifecycleSweepReport> {
196
+ const report: LifecycleSweepReport = { distilled: [], kept: 0, failed: [] };
197
+ const minAge = options.minAgeMs ?? SESSION_MIN_AGE_MS;
198
+ const now = options.now ?? Date.now();
199
+ const entries = await fs.readdir(sessionDir, { withFileTypes: true }).catch(() => []);
200
+ for (const entry of entries) {
201
+ if (!entry.isFile() || !entry.name.endsWith(".jsonl")) continue;
202
+ const base = entry.name.slice(0, -".jsonl".length);
203
+ const isMatch = (id: string) => !!id && (base === id || base.includes(id));
204
+ if ([...options.keep].some(isMatch) || [...options.busy].some(isMatch)) {
205
+ report.kept++;
206
+ continue;
207
+ }
208
+ const file = path.join(sessionDir, entry.name);
209
+ const stat = await fs.stat(file).catch(() => undefined);
210
+ if (!stat || now - stat.mtimeMs < minAge) {
211
+ report.kept++;
212
+ continue;
213
+ }
214
+ try {
215
+ await distillSessionFile(file);
216
+ report.distilled.push(file);
217
+ } catch {
218
+ report.failed.push(file);
219
+ }
220
+ }
221
+ return report;
222
+ }