@cr1ms0n/pi-subagent 0.8.9 → 0.9.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/config.ts CHANGED
@@ -1,252 +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
- import { isThinkingLevel, type ThinkingLevel } from "./thinking.js";
7
-
8
- export type { ThinkingLevel } from "./thinking.js";
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 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 = isThinkingLevel(value.thinking) ? value.thinking : undefined;
143
- const fallbackModels = Array.isArray(value.fallbackModels)
144
- ? value.fallbackModels.map(nonEmptyString).filter((model): model is string => !!model)
145
- : undefined;
146
- const defaults = prune<TaskDefaults>({
147
- model: nonEmptyString(value.model),
148
- thinking,
149
- maxTurns: positiveNumber(value.maxTurns),
150
- maxCost: positiveNumber(value.maxCost, 0),
151
- timeoutMs: positiveNumber(value.timeoutMs),
152
- fallbackModels: fallbackModels?.length ? fallbackModels : undefined,
153
- maxRetries: positiveNumber(value.maxRetries, 0),
154
- });
155
- return Object.keys(defaults).length ? defaults : undefined;
156
- }
157
-
158
- function sanitizeTaskDefaultsByProfile(raw: unknown): TaskDefaultsByProfile | undefined {
159
- if (!raw || typeof raw !== "object") return undefined;
160
- const value = raw as Record<string, unknown>;
161
- const result: TaskDefaultsByProfile = {};
162
- for (const profile of ["explore", "review", "general"] as const) {
163
- const defaults = sanitizeTaskDefaults(value[profile]);
164
- if (defaults) result[profile] = defaults;
165
- }
166
- return Object.keys(result).length ? result : undefined;
167
- }
168
-
169
- /** Validate untrusted JSON overrides field-by-field; unknown keys are dropped. */
170
- export function sanitizeConfigOverrides(raw: unknown, source = MODEL_POLICY_CONFIG_FILE): Partial<SubagentConfig> {
171
- if (!raw || typeof raw !== "object") return {};
172
- const value = raw as Record<string, unknown>;
173
- let modelPolicy: ModelPolicySnapshot | undefined;
174
- let modelPolicyError: string | undefined;
175
- if (Object.prototype.hasOwnProperty.call(value, "modelPolicy")) {
176
- try {
177
- modelPolicy = parseModelPolicy(value.modelPolicy, source);
178
- } catch (error: any) {
179
- modelPolicyError = error instanceof Error ? error.message : String(error);
180
- }
181
- }
182
- return prune<SubagentConfig>({
183
- modelPolicy,
184
- modelPolicyError,
185
- taskDefaults: sanitizeTaskDefaultsByProfile(value.taskDefaults),
186
- maxTasksPerRun: positiveNumber(value.maxTasksPerRun),
187
- maxActiveProcesses: positiveNumber(value.maxActiveProcesses),
188
- maxQueuedTasks: positiveNumber(value.maxQueuedTasks, 0),
189
- defaultTimeoutMs: positiveNumber(value.defaultTimeoutMs),
190
- maxResultBytes: positiveNumber(value.maxResultBytes, 1024),
191
- maxResultLines: positiveNumber(value.maxResultLines, 10),
192
- maxDetailsTextBytes: positiveNumber(value.maxDetailsTextBytes, 256),
193
- maxCompletedInMemory: positiveNumber(value.maxCompletedInMemory),
194
- maxDepth: positiveNumber(value.maxDepth, 0),
195
- killGraceMs: positiveNumber(value.killGraceMs, 100),
196
- sessionDir: nonEmptyString(value.sessionDir),
197
- worktreeDir: nonEmptyString(value.worktreeDir),
198
- lockDir: nonEmptyString(value.lockDir),
199
- maxGlobalActive: positiveNumber(value.maxGlobalActive, 0),
200
- worktreeRetentionDays: positiveNumber(value.worktreeRetentionDays, 0),
201
- sessionRetentionDays: positiveNumber(value.sessionRetentionDays, 0),
202
- lockRetentionDays: positiveNumber(value.lockRetentionDays, 0),
203
- graceTurns: positiveNumber(value.graceTurns, 0),
204
- stallAfterMs: positiveNumber(value.stallAfterMs, 0),
205
- stallKillAfterMs: positiveNumber(value.stallKillAfterMs, 0),
206
- maxRetries: positiveNumber(value.maxRetries, 0),
207
- widget: oneOf(WIDGET_MODES, value.widget),
208
- notifications: oneOf(NOTIFICATION_MODES, value.notifications),
209
- });
210
- }
211
-
212
- export function configFromEnv(env: NodeJS.ProcessEnv = process.env): Partial<SubagentConfig> {
213
- return prune<SubagentConfig>({
214
- maxTasksPerRun: positiveNumber(env.PI_SUBAGENT_MAX_TASKS),
215
- maxActiveProcesses: positiveNumber(env.PI_SUBAGENT_MAX_ACTIVE),
216
- maxQueuedTasks: positiveNumber(env.PI_SUBAGENT_MAX_QUEUED, 0),
217
- defaultTimeoutMs: positiveNumber(env.PI_SUBAGENT_TIMEOUT_MS),
218
- maxDepth: positiveNumber(env.PI_SUBAGENT_MAX_DEPTH, 0),
219
- killGraceMs: positiveNumber(env.PI_SUBAGENT_KILL_GRACE_MS, 100),
220
- sessionDir: nonEmptyString(env.PI_SUBAGENT_SESSION_DIR),
221
- worktreeDir: nonEmptyString(env.PI_SUBAGENT_WORKTREE_DIR),
222
- lockDir: nonEmptyString(env.PI_SUBAGENT_LOCK_DIR),
223
- maxGlobalActive: positiveNumber(env.PI_SUBAGENT_MAX_GLOBAL_ACTIVE, 0),
224
- worktreeRetentionDays: positiveNumber(env.PI_SUBAGENT_WORKTREE_RETENTION_DAYS, 0),
225
- sessionRetentionDays: positiveNumber(env.PI_SUBAGENT_SESSION_RETENTION_DAYS, 0),
226
- lockRetentionDays: positiveNumber(env.PI_SUBAGENT_LOCK_RETENTION_DAYS, 0),
227
- graceTurns: positiveNumber(env.PI_SUBAGENT_GRACE_TURNS, 0),
228
- stallAfterMs: positiveNumber(env.PI_SUBAGENT_STALL_AFTER_MS, 0),
229
- stallKillAfterMs: positiveNumber(env.PI_SUBAGENT_STALL_KILL_AFTER_MS, 0),
230
- maxRetries: positiveNumber(env.PI_SUBAGENT_MAX_RETRIES, 0),
231
- widget: oneOf(WIDGET_MODES, env.PI_SUBAGENT_WIDGET),
232
- notifications: oneOf(NOTIFICATION_MODES, env.PI_SUBAGENT_NOTIFICATIONS),
233
- });
234
- }
235
-
236
- /** defaults ← file overrides ← env overrides. Pure; suitable for tests. */
237
- export function loadConfig(
238
- fileOverrides: Partial<SubagentConfig> = {},
239
- env: NodeJS.ProcessEnv = process.env,
240
- ): SubagentConfig {
241
- return { ...defaultConfig, ...prune(fileOverrides), ...configFromEnv(env) };
242
- }
243
-
244
- /** Read + sanitize the optional user config file. Missing or invalid files yield {}. */
245
- export async function readConfigFile(file = CONFIG_FILE): Promise<Partial<SubagentConfig>> {
246
- try {
247
- return sanitizeConfigOverrides(JSON.parse(await fs.readFile(file, "utf8")), file);
248
- } catch (error: any) {
249
- if (error?.code === "ENOENT") return {};
250
- return { modelPolicyError: `Could not read ${file} as JSON; model policy was not loaded.` };
251
- }
252
- }
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 { JEV_ROUTING_CONFIG_FILE, parseJevRouting, type JevRoutingConfig } from "./routing-policy.js";
6
+ import { isThinkingLevel, type ThinkingLevel } from "./thinking.js";
7
+
8
+ export type { ThinkingLevel } from "./thinking.js";
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
+ * thinking and budgets. Legacy model/fallback defaults do not select routes.
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 Jev routing. */
63
+ taskDefaults?: TaskDefaultsByProfile;
64
+ /** Mandatory, user-owned Jev candidate configuration. Never contains a credential. */
65
+ jevRouting?: JevRoutingConfig;
66
+ /** Safe migration or parse failure; existing-run management remains available. */
67
+ jevRoutingError?: 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 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 = isThinkingLevel(value.thinking) ? value.thinking : undefined;
143
+ const fallbackModels = Array.isArray(value.fallbackModels)
144
+ ? value.fallbackModels.map(nonEmptyString).filter((model): model is string => !!model)
145
+ : undefined;
146
+ const defaults = prune<TaskDefaults>({
147
+ model: nonEmptyString(value.model),
148
+ thinking,
149
+ maxTurns: positiveNumber(value.maxTurns),
150
+ maxCost: positiveNumber(value.maxCost, 0),
151
+ timeoutMs: positiveNumber(value.timeoutMs),
152
+ fallbackModels: fallbackModels?.length ? fallbackModels : undefined,
153
+ maxRetries: positiveNumber(value.maxRetries, 0),
154
+ });
155
+ return Object.keys(defaults).length ? defaults : undefined;
156
+ }
157
+
158
+ function sanitizeTaskDefaultsByProfile(raw: unknown): TaskDefaultsByProfile | undefined {
159
+ if (!raw || typeof raw !== "object") return undefined;
160
+ const value = raw as Record<string, unknown>;
161
+ const result: TaskDefaultsByProfile = {};
162
+ for (const profile of ["explore", "review", "general"] as const) {
163
+ const defaults = sanitizeTaskDefaults(value[profile]);
164
+ if (defaults) result[profile] = defaults;
165
+ }
166
+ return Object.keys(result).length ? result : undefined;
167
+ }
168
+
169
+ /** Validate untrusted JSON overrides field-by-field; unknown keys are dropped. */
170
+ export function sanitizeConfigOverrides(raw: unknown, source = JEV_ROUTING_CONFIG_FILE): Partial<SubagentConfig> {
171
+ if (!raw || typeof raw !== "object") return {};
172
+ const value = raw as Record<string, unknown>;
173
+ let jevRouting: JevRoutingConfig | undefined;
174
+ let jevRoutingError: string | undefined;
175
+ if (Object.prototype.hasOwnProperty.call(value, "modelPolicy")) {
176
+ jevRoutingError = `Remove legacy modelPolicy from ${source} and configure jevRouting.models with exact IDs and user-written descriptions. Fixed routing is no longer supported; management remains available.`;
177
+ } else if (Object.prototype.hasOwnProperty.call(value, "jevRouting")) {
178
+ try {
179
+ jevRouting = parseJevRouting(value.jevRouting, source);
180
+ } catch (error) {
181
+ jevRoutingError = error instanceof Error ? error.message : "Invalid jevRouting configuration.";
182
+ }
183
+ }
184
+ return prune<SubagentConfig>({
185
+ jevRouting,
186
+ jevRoutingError,
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 { jevRoutingError: `Could not read ${file} as JSON; Jev routing was not loaded. Existing-run management remains available.` };
253
+ }
254
+ }
@@ -0,0 +1,87 @@
1
+ import * as fs from "node:fs/promises";
2
+ import { constants } from "node:fs";
3
+ import * as path from "node:path";
4
+ import type { PreparedTask } from "./policy.js";
5
+ import type { ResumeAvailabilityResult } from "./registry.js";
6
+
7
+ /** Read-only checks shared by plan and execution, before any paid selector request. */
8
+ export interface LocalPreflightDeps {
9
+ signal: AbortSignal;
10
+ assertOwner(): void;
11
+ checkResumeAvailability(tasks: readonly PreparedTask[]): ResumeAvailabilityResult;
12
+ isGitRepo(cwd: string, signal: AbortSignal): Promise<boolean>;
13
+ stat?(path: string): Promise<{ isDirectory(): boolean }>;
14
+ access?(path: string, mode?: number): Promise<void>;
15
+ }
16
+
17
+ export async function runLocalPreflights(
18
+ tasks: readonly PreparedTask[],
19
+ parentCwd: string,
20
+ deps: LocalPreflightDeps,
21
+ ): Promise<void> {
22
+ deps.assertOwner();
23
+ if (deps.signal.aborted) throw new Error("Local preflight cancelled; no selector request was sent.");
24
+ const resume = deps.checkResumeAvailability(tasks);
25
+ if (!resume.ok) {
26
+ const conflict = resume.conflict!;
27
+ throw new Error(`Child session ${conflict.sessionId} is unavailable (${conflict.reason}, run ${conflict.runId}). Use fork_resume:true for an independent continuation.`);
28
+ }
29
+ const direct = tasks.filter((task) => task.resume && !task.forkResume).map((task) => task.resume);
30
+ if (new Set(direct).size !== direct.length) throw new Error("The same child session cannot be directly resumed by two tasks in one run. Use fork_resume:true.");
31
+ const stat = deps.stat ?? fs.stat;
32
+ const access = deps.access ?? fs.access;
33
+
34
+ for (let index = 0; index < tasks.length; index++) {
35
+ const task = tasks[index]!;
36
+ const controller = new AbortController();
37
+ let timedOut = false;
38
+ const remaining = task.deadline === undefined ? task.timeoutMs : task.deadline - Date.now();
39
+ const interrupted = () => new Error(timedOut
40
+ ? `Task ${index + 1}: timeout during local preflight; no selector request was sent.`
41
+ : "Local preflight cancelled; no selector request was sent.");
42
+ const check = () => {
43
+ deps.assertOwner();
44
+ if (task.deadline !== undefined && Date.now() >= task.deadline) timedOut = true;
45
+ if (timedOut || deps.signal.aborted || controller.signal.aborted) throw interrupted();
46
+ };
47
+ if (remaining <= 0) { timedOut = true; throw interrupted(); }
48
+ check();
49
+ let rejectInterrupted!: (reason: Error) => void;
50
+ const interruption = new Promise<never>((_resolve, reject) => { rejectInterrupted = reject; });
51
+ const onAbort = () => { controller.abort(); rejectInterrupted(interrupted()); };
52
+ deps.signal.addEventListener("abort", onAbort, { once: true });
53
+ const timer = setTimeout(() => { timedOut = true; onAbort(); }, remaining);
54
+ timer.unref?.();
55
+ // fs.stat/access do not accept AbortSignal. Racing them bounds the caller; check()
56
+ // after every await also prevents a late filesystem response from starting more work.
57
+ const wait = async <T>(operation: () => Promise<T>): Promise<T> => {
58
+ check();
59
+ const value = await Promise.race([operation(), interruption]);
60
+ check();
61
+ return value;
62
+ };
63
+ try {
64
+ const cwd = task.cwd ?? parentCwd;
65
+ const cwdStat = await wait(() => stat(cwd).catch(() => undefined));
66
+ if (!cwdStat?.isDirectory()) throw new Error(`Task ${index + 1}: working directory does not exist: ${cwd}`);
67
+ if (task.isolation === "worktree" && !(await wait(() => deps.isGitRepo(cwd, controller.signal)))) {
68
+ throw new Error(`Task ${index + 1}: ${cwd} is not a git repository`);
69
+ }
70
+ if (task.contextFork) {
71
+ if (!task.parentSessionFile) throw new Error(`Task ${index + 1}: context:'fork' requires a persisted parent session file`);
72
+ const readable = await wait(() => access(task.parentSessionFile!).then(() => true, () => false));
73
+ if (!readable) throw new Error(`Task ${index + 1}: context:'fork' parent session file is not readable.`);
74
+ }
75
+ if (task.output) {
76
+ const parentDir = path.dirname(task.output);
77
+ const parentStat = await wait(() => stat(parentDir).catch(() => undefined));
78
+ if (!parentStat?.isDirectory()) throw new Error(`Task ${index + 1}: output parent directory does not exist: ${parentDir}`);
79
+ const writable = await wait(() => access(parentDir, constants.W_OK).then(() => true, () => false));
80
+ if (!writable) throw new Error(`Task ${index + 1}: output parent directory is not writable: ${parentDir}`);
81
+ }
82
+ } finally {
83
+ clearTimeout(timer);
84
+ deps.signal.removeEventListener("abort", onAbort);
85
+ }
86
+ }
87
+ }