pi-better-harness 0.42.1 → 0.43.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/CHANGELOG.md CHANGED
@@ -1,5 +1,11 @@
1
1
  # Changelog
2
2
 
3
+ ## [0.43.0] - 2026-11-11
4
+
5
+ ### Changed
6
+
7
+ - Bundle Subagents 0.30.0 for the 60-minute default hard timeout, its session setting, and preserved timing across resumes.
8
+
3
9
  ## [0.42.1] - 2026-11-10
4
10
 
5
11
  ### Changed
@@ -18,7 +18,7 @@ Use `pi-better-subagents` when you want Pi to launch independent agent work with
18
18
  - Explicit tool allowlists for child sessions, and `skills` for prompt exposure: omit it to keep user-catalog discovery, pass `[]` for no skills, or pass absolute `SKILL.md` paths for exactly those. See [skill exposure](docs/usage.md#skill-exposure).
19
19
  - Durable logs, result retrieval, and [failure observations](docs/failure-observations.md) independent of lifecycle status. A live child handles its own tool errors; the parent is woken only for actionable incidents, and children can classify handled failures with `failure_disposition`.
20
20
  - Live background-work navigator for active runs.
21
- - Harness-owned run timing: every run gets independent factual alerts for a tool call left open (5 min) and for no progress (10 min), while wall-clock soft deadlines and hard ceilings are opt-in so productive agents are not stopped solely for taking a long time. Configure per spawn with `deadline_minutes`, `grace_minutes`, `max_minutes`, `tool_wait_minutes`, `stuck_minutes`, globally in `config.json` / `PI_SUBAGENT_*_MINUTES`, or in `/subagents settings`. See [usage notes](docs/usage.md#run-timing-deadline-ceiling-tool-wait-stuck).
21
+ - Harness-owned run timing: every run gets independent factual alerts for a tool call left open (5 min) and for no progress (10 min), plus a 60-minute hard timeout. Change the timeout in `/subagents settings`; `0` disables it. Soft deadlines remain opt-in. Configure per spawn with `deadline_minutes`, `grace_minutes`, `max_minutes`, `tool_wait_minutes`, `stuck_minutes`, globally in `config.json` / `PI_SUBAGENT_*_MINUTES`, or in `/subagents settings`. See [usage notes](docs/usage.md#run-timing-deadline-ceiling-tool-wait-stuck).
22
22
 
23
23
  Every single or batch launch automatically joins one shared supervision watcher in the
24
24
  foreground Pi process. The last active run's exit or stop tears it down; a later
@@ -195,6 +195,7 @@ export function writeSubagentSettings(
195
195
  settings: {
196
196
  delegationMode: DelegationMode;
197
197
  maxConcurrent: number;
198
+ maxMinutes?: number;
198
199
  /** Omit to preserve the saved value; `null` explicitly records discovery. */
199
200
  defaultSkills?: string[] | null;
200
201
  /** Omit to preserve the saved value. `0` disables that alert. */
@@ -207,12 +208,15 @@ export function writeSubagentSettings(
207
208
  if (!isDelegationMode(settings.delegationMode)) throw new Error("Invalid delegation mode.");
208
209
  if (!isConcurrencyCap(settings.maxConcurrent)) throw new Error("Concurrent subagents must be a positive whole number.");
209
210
  const next: {
210
- delegationMode: DelegationMode; maxConcurrent: number; defaultSkills?: string[] | null;
211
+ delegationMode: DelegationMode; maxConcurrent: number; maxMinutes?: number; defaultSkills?: string[] | null;
211
212
  toolWaitMinutes?: number; stuckMinutes?: number;
212
213
  } = {
213
214
  delegationMode: settings.delegationMode,
214
215
  maxConcurrent: settings.maxConcurrent,
215
216
  };
217
+ if (settings.maxMinutes !== undefined) {
218
+ next.maxMinutes = validatedTimingMinutes(settings.maxMinutes, "maxMinutes");
219
+ }
216
220
  if (Object.hasOwn(settings, "defaultSkills")) {
217
221
  if (settings.defaultSkills === null) {
218
222
  next.defaultSkills = null;
@@ -283,15 +283,18 @@ by the existing harness runtime.
283
283
  Timeout control belongs to the harness, not to the prompt or the skill that
284
284
  launched the run. A time limit written into a prompt is enforced by nothing, and
285
285
  a parent that only notices late tends to kill a child that was still making
286
- progress. Every run gets a tool-wait alert and stall detection by default.
287
- Elapsed-time limits are opt-in because total runtime does not distinguish a slow
288
- agent from a stalled one.
286
+ progress. Every run gets a tool-wait alert, stall detection, and a 60-minute hard
287
+ timeout by default. Soft deadlines remain opt-in because total runtime does not
288
+ distinguish a slow agent from a stalled one.
289
+ Use Timeout (minutes) in `/subagents settings` to change the session default;
290
+ Ctrl+S saves it for future sessions. `0` disables the timeout. Changes apply to
291
+ new attempts, not runs already in progress.
289
292
 
290
293
  | Control | Default | What happens |
291
294
  |---------|---------|--------------|
292
295
  | Soft deadline (`deadline_minutes`) | Off | When configured, the child gets one steering message (delivered through Pi's steer queue after its current tool call finishes): stop starting new work, commit or save what is done, and report what is complete, what is not, and where the work is. The parent gets one wake saying so. |
293
296
  | Grace (`grace_minutes`) | 5 | Grace starts when the steering message actually reaches the child, which is after its current tool call. While the message is still waiting behind a running tool call, the deadline stop is held, so a test run that began just before the deadline is not killed mid-run. If the child has not finished by the end of grace, the harness stops it; the ordinary completion callback reports `killed; stopped: deadline`. A child that completes inside grace is not stopped; its surfaces say `deadline: finished in grace`. |
294
- | Hard ceiling (`max_minutes`) | Off | When configured, the run is stopped at once, without grace or a steer, and reported as `stopped: ceiling`. The ceiling bounds the run, including a tool call that never ends and an orphaned run (child gone, its process group still alive). |
297
+ | Hard ceiling (`max_minutes`) | 60 | When configured, the run is stopped at once, without grace or a steer, and reported as `stopped: ceiling`. The ceiling bounds the run, including a tool call that never ends and an orphaned run (child gone, its process group still alive). |
295
298
  | Tool-wait window (`tool_wait_minutes`) | 5 | One tool call that stays open this long sends one factual parent wake for that call, including when it is a normal long test. Parallel calls alert independently. The alert never stops, cancels, or restarts the run, and a later call can alert on its own. |
296
299
  | Stuck window (`stuck_minutes`) | 10 | No progress (see below) for this long wakes the parent once (`stuck`). It never stops the run. After new progress, a later stuck spell wakes again. Time inside a running tool call is excluded, so tool waiting and no-progress are reported separately. |
297
300
 
@@ -351,8 +354,9 @@ three times wakes the parent once as an `Action required` incident
351
354
  deadline), `deadline: passed` (ended some other way after the deadline, for
352
355
  example it crashed or a user stopped it), `stopped: ceiling`, or `stuck`. The spawn response lists the limits in force.
353
356
 
354
- **Global defaults.** Precedence is spawn parameter, then environment, then
355
- `config.json`, then the built-in default:
357
+ **Global defaults.** Precedence is spawn parameter, then the session timeout
358
+ choice (for `max_minutes`), then environment, then `config.json`, then the
359
+ built-in default:
356
360
 
357
361
  | Setting | Environment | `config.json` |
358
362
  |---------|-------------|---------------|
@@ -41,6 +41,7 @@ import { createSubagentSettingsPage } from "./settings-page.ts";
41
41
  import { deliverSubagentPrompt, effectiveSubagentSettings, formatSubagentSettings, subagentSettingsTool, SUBAGENT_SETTINGS_CONTEXT } from "./effective-settings.ts";
42
42
  import { STEER_FILE_ENV, readSteerReceipt } from "./child-steer.ts";
43
43
  import {
44
+ continuationTimingParams,
44
45
  decideTiming,
45
46
  describeTiming,
46
47
  DEFAULT_TIMING_MINUTES,
@@ -2082,7 +2083,7 @@ export default function (pi: ExtensionAPI) {
2082
2083
  let catalogModelRegistry: ExtensionContext["modelRegistry"] | undefined;
2083
2084
  let delegationOverride: DelegationMode | undefined;
2084
2085
  let capOverride: number | undefined;
2085
- let timingOverride: { toolWaitMinutes?: number; stuckMinutes?: number } = {};
2086
+ let timingOverride: { maxMinutes?: number; toolWaitMinutes?: number; stuckMinutes?: number } = {};
2086
2087
  interface SkillSelection { mode: SkillExposureMode; paths: string[] }
2087
2088
  let skillsOverride: SkillSelection | undefined;
2088
2089
  const savedSkills = (): SkillSelection & { source: "config" | "default"; error?: string } => {
@@ -2128,11 +2129,19 @@ export default function (pi: ExtensionAPI) {
2128
2129
  };
2129
2130
  const activeConcurrencyCap = (): number => capOverride ?? normalizeConcurrencyCap(loadConfig().maxConcurrent);
2130
2131
  const timingSettingsFor = (cfg = loadConfig()): TimingSettings => ({ ...cfg, ...timingOverride });
2132
+ /** A per-run max_minutes wins; otherwise a session timeout choice beats the environment. */
2133
+ const launchTimingParams = (params: TimingParams = {}): TimingParams => ({
2134
+ ...params,
2135
+ max_minutes: params.max_minutes ?? timingOverride.maxMinutes,
2136
+ });
2131
2137
  interface TimingMinutesState {
2138
+ maxMinutes: number;
2132
2139
  toolWaitMinutes: number;
2133
2140
  stuckMinutes: number;
2141
+ timeoutSource: "environment" | "session" | "config" | "default";
2134
2142
  toolWaitSource: "environment" | "session" | "config" | "default";
2135
2143
  stuckSource: "environment" | "session" | "config" | "default";
2144
+ defaultMaxMinutes: number;
2136
2145
  defaultToolWaitMinutes: number;
2137
2146
  defaultStuckMinutes: number;
2138
2147
  timingError?: string;
@@ -2147,24 +2156,36 @@ export default function (pi: ExtensionAPI) {
2147
2156
  envFor(key) !== undefined ? "environment"
2148
2157
  : timingOverride[key] !== undefined ? "session"
2149
2158
  : cfg[key] !== undefined && cfg[key] !== null ? "config" : "default";
2159
+ const timeoutEnv = process.env[TIMING_ENV.max];
2160
+ const timeoutSource = (): "environment" | "session" | "config" | "default" =>
2161
+ timingOverride.maxMinutes !== undefined ? "session"
2162
+ : timeoutEnv !== undefined && timeoutEnv.trim() !== "" ? "environment"
2163
+ : cfg.maxMinutes !== undefined && cfg.maxMinutes !== null ? "config" : "default";
2150
2164
  try {
2151
- const effective = resolveRunTiming({ settings: timingSettingsFor(cfg), env: process.env, startedAt: 0 });
2165
+ const sessionTimeout = timingOverride.maxMinutes !== undefined ? { max_minutes: timingOverride.maxMinutes } : {};
2166
+ const effective = resolveRunTiming({ params: sessionTimeout, settings: timingSettingsFor(cfg), env: process.env, startedAt: 0 });
2152
2167
  const defaults = resolveRunTiming({ settings: cfg, env: process.env, startedAt: 0 });
2153
2168
  const minutes = (ms: number | undefined): number => ms === undefined ? 0 : ms / 60_000;
2154
2169
  return {
2170
+ maxMinutes: minutes(effective.ceilingAt),
2155
2171
  toolWaitMinutes: minutes(effective.toolWaitMs),
2156
2172
  stuckMinutes: minutes(effective.stuckMs),
2173
+ timeoutSource: timeoutSource(),
2157
2174
  toolWaitSource: sourceFor("toolWaitMinutes"),
2158
2175
  stuckSource: sourceFor("stuckMinutes"),
2176
+ defaultMaxMinutes: minutes(defaults.ceilingAt),
2159
2177
  defaultToolWaitMinutes: minutes(defaults.toolWaitMs),
2160
2178
  defaultStuckMinutes: minutes(defaults.stuckMs),
2161
2179
  };
2162
2180
  } catch (error) {
2163
2181
  return {
2182
+ maxMinutes: DEFAULT_TIMING_MINUTES.max,
2164
2183
  toolWaitMinutes: DEFAULT_TIMING_MINUTES.toolWait,
2165
2184
  stuckMinutes: DEFAULT_TIMING_MINUTES.stuck,
2185
+ timeoutSource: timeoutSource(),
2166
2186
  toolWaitSource: sourceFor("toolWaitMinutes"),
2167
2187
  stuckSource: sourceFor("stuckMinutes"),
2188
+ defaultMaxMinutes: DEFAULT_TIMING_MINUTES.max,
2168
2189
  defaultToolWaitMinutes: DEFAULT_TIMING_MINUTES.toolWait,
2169
2190
  defaultStuckMinutes: DEFAULT_TIMING_MINUTES.stuck,
2170
2191
  timingError: error instanceof Error ? error.message : String(error),
@@ -2176,7 +2197,7 @@ export default function (pi: ExtensionAPI) {
2176
2197
  capOverride = cap;
2177
2198
  pi.appendEntry("pi-better-subagents-cap", { version: 1, maxConcurrent: cap });
2178
2199
  };
2179
- const changeTiming = (key: "toolWaitMinutes" | "stuckMinutes", minutes: number): void => {
2200
+ const changeTiming = (key: "maxMinutes" | "toolWaitMinutes" | "stuckMinutes", minutes: number): void => {
2180
2201
  const value = validatedTimingMinutes(minutes, key);
2181
2202
  timingOverride = { ...timingOverride, [key]: value };
2182
2203
  pi.appendEntry("pi-better-subagents-timing", { version: 1, ...timingOverride });
@@ -2192,7 +2213,7 @@ export default function (pi: ExtensionAPI) {
2192
2213
  skillsOverride = undefined;
2193
2214
  pi.appendEntry("pi-better-subagents-cap", { version: 1, maxConcurrent: null });
2194
2215
  pi.appendEntry("pi-better-subagents-delegation", { version: 1, mode: null });
2195
- pi.appendEntry("pi-better-subagents-timing", { version: 1, toolWaitMinutes: null, stuckMinutes: null });
2216
+ pi.appendEntry("pi-better-subagents-timing", { version: 1, maxMinutes: null, toolWaitMinutes: null, stuckMinutes: null });
2196
2217
  pi.appendEntry("pi-better-subagents-skills", { version: 1, mode: null });
2197
2218
  };
2198
2219
  const activeDelegationMode = (): DelegationMode => delegationOverride ?? normalizeDelegationMode(loadConfig().delegationMode);
@@ -2254,7 +2275,12 @@ export default function (pi: ExtensionAPI) {
2254
2275
  const hasSavedSkillChoice = savedSkillValue !== undefined && savedSkillValue !== null;
2255
2276
  // Like skills, only save a timing key this session overrides or the
2256
2277
  // config already carries; built-in defaults need no stored copy.
2257
- const savedTiming: { toolWaitMinutes?: number; stuckMinutes?: number } = {};
2278
+ const savedTiming: { maxMinutes?: number; toolWaitMinutes?: number; stuckMinutes?: number } = {};
2279
+ if (timingOverride.maxMinutes !== undefined) {
2280
+ savedTiming.maxMinutes = timingOverride.maxMinutes;
2281
+ } else if (isTimingMinutes(cfg.maxMinutes)) {
2282
+ savedTiming.maxMinutes = cfg.maxMinutes;
2283
+ }
2258
2284
  if (timingOverride.toolWaitMinutes !== undefined) {
2259
2285
  savedTiming.toolWaitMinutes = timingOverride.toolWaitMinutes;
2260
2286
  } else if (isTimingMinutes(cfg.toolWaitMinutes)) {
@@ -2307,6 +2333,16 @@ export default function (pi: ExtensionAPI) {
2307
2333
  const branch = ctx.sessionManager?.getBranch?.();
2308
2334
  if (!Array.isArray(branch)) return;
2309
2335
  for (const entry of branch) {
2336
+ if (entry.type === "custom" && entry.customType === "pi-better-subagents-timeout") {
2337
+ const data = entry.data as { version?: unknown; maxMinutes?: unknown } | null;
2338
+ if (data?.version === 1) {
2339
+ const next = { ...timingOverride };
2340
+ if (data.maxMinutes === null) delete next.maxMinutes;
2341
+ else if (isTimingMinutes(data.maxMinutes)) next.maxMinutes = data.maxMinutes;
2342
+ timingOverride = next;
2343
+ }
2344
+ continue;
2345
+ }
2310
2346
  if (entry.type === "custom" && entry.customType === "pi-better-subagents-cap") {
2311
2347
  const data = entry.data as { version?: unknown; maxConcurrent?: unknown } | null;
2312
2348
  if (data?.version === 1) {
@@ -2329,10 +2365,11 @@ export default function (pi: ExtensionAPI) {
2329
2365
  }
2330
2366
  if (entry.type === "custom" && entry.customType === "pi-better-subagents-timing") {
2331
2367
  const data = entry.data as {
2332
- version?: unknown; toolWaitMinutes?: unknown; stuckMinutes?: unknown;
2368
+ version?: unknown; maxMinutes?: unknown; toolWaitMinutes?: unknown; stuckMinutes?: unknown;
2333
2369
  } | null;
2334
2370
  if (data?.version !== 1) continue;
2335
- const next: { toolWaitMinutes?: number; stuckMinutes?: number } = {};
2371
+ const next: { maxMinutes?: number; toolWaitMinutes?: number; stuckMinutes?: number } = {};
2372
+ if (isTimingMinutes(data.maxMinutes)) next.maxMinutes = data.maxMinutes;
2336
2373
  if (isTimingMinutes(data.toolWaitMinutes)) next.toolWaitMinutes = data.toolWaitMinutes;
2337
2374
  if (isTimingMinutes(data.stuckMinutes)) next.stuckMinutes = data.stuckMinutes;
2338
2375
  timingOverride = next;
@@ -2538,7 +2575,7 @@ export default function (pi: ExtensionAPI) {
2538
2575
  }
2539
2576
  const cfg = loadConfig();
2540
2577
  // Harness-owned timing (soft deadline, ceiling, tool-wait and stuck windows); validated before any side effect.
2541
- resolveRunTiming({ params: p, settings: timingSettingsFor(cfg), env: process.env, startedAt: 0 });
2578
+ resolveRunTiming({ params: launchTimingParams(p), settings: timingSettingsFor(cfg), env: process.env, startedAt: 0 });
2542
2579
  let model: string | undefined;
2543
2580
  let thinking: ThinkingLevel | undefined;
2544
2581
  if (resume) {
@@ -2935,7 +2972,7 @@ export default function (pi: ExtensionAPI) {
2935
2972
  authorityReference: "",
2936
2973
  // The assignment deadline the attempt was admitted with; a
2937
2974
  // continuation must not silently extend it.
2938
- deadlineAt: resolveRunTiming({ params: p, settings: timingSettingsFor(cfg), env: process.env, startedAt: Date.now() }).deadlineAt ?? null,
2975
+ deadlineAt: resolveRunTiming({ params: launchTimingParams(p), settings: timingSettingsFor(cfg), env: process.env, startedAt: Date.now() }).deadlineAt ?? null,
2939
2976
  taskPolicy: taskRuntime?.policy ?? null,
2940
2977
  });
2941
2978
  const authorityReference = `worker-policy:${createHash("sha256")
@@ -3074,7 +3111,7 @@ export default function (pi: ExtensionAPI) {
3074
3111
  tools: bridgePlan.tools,
3075
3112
  },
3076
3113
  } : {}),
3077
- timing: resolveRunTiming({ params: p, settings: timingSettingsFor(cfg), env: process.env, startedAt }),
3114
+ timing: resolveRunTiming({ params: launchTimingParams(p), settings: timingSettingsFor(cfg), env: process.env, startedAt }),
3078
3115
  };
3079
3116
  // This instance holds the exit observer for a run it actually
3080
3117
  // started; only it can finalize on the exit event (#625).
@@ -3316,17 +3353,7 @@ export default function (pi: ExtensionAPI) {
3316
3353
  function continuationTiming(prior: RunMeta): TimingParams {
3317
3354
  const timing = prior.timing;
3318
3355
  if (!timing) return {};
3319
- const minutes = (at: number | undefined): number | undefined => {
3320
- if (at === undefined) return undefined;
3321
- return Math.max(1, at - Date.now()) / 60_000;
3322
- };
3323
- return {
3324
- ...(timing.deadlineAt !== undefined ? { deadline_minutes: minutes(timing.deadlineAt) } : {}),
3325
- ...(timing.ceilingAt !== undefined ? { max_minutes: minutes(timing.ceilingAt) } : {}),
3326
- ...(timing.graceMs !== undefined ? { grace_minutes: timing.graceMs / 60_000 } : {}),
3327
- ...(timing.toolWaitMs !== undefined ? { tool_wait_minutes: timing.toolWaitMs / 60_000 } : {}),
3328
- ...(timing.stuckMs !== undefined ? { stuck_minutes: timing.stuckMs / 60_000 } : {}),
3329
- };
3356
+ return continuationTimingParams(timing, Date.now());
3330
3357
  }
3331
3358
 
3332
3359
  /**
@@ -3415,7 +3442,7 @@ export default function (pi: ExtensionAPI) {
3415
3442
  "The tools param is both the tool allowlist AND what determines which extensions load in the child (e.g. tools='read,bash,web_fetch' loads only the web-tools package). Ask for the tools the task needs and nothing more; clean:true gives a built-ins-only child. When the task already names its SKILL.md by path, pass skills with those absolute paths (or [] for none) so the child does not advertise the whole skills catalog. Without an agent or role, pick a model with the model param (e.g. 'xai/grok-4.5@high'); providerless model patterns are resolved by Pi, while provider/model is deterministic and loads mapped provider extensions.",
3416
3443
  ...CATALOG_GUIDELINES,
3417
3444
  "Subagent sandboxing defaults to off. Human settings in /sandbox control file, credential-file, command, and network permissions; sandbox:false cannot override an enabled human profile. Set sandbox:true to request kernel confinement, including without published settings. Set callback:false to finish quietly — then read the result on demand via subagent_result.",
3418
- "Every launch automatically joins one shared harness-owned watcher, including batch launches; it stops when the last active run ends. Do not start a separate heartbeat or background watcher for subagents. A tool call still open for tool_wait_minutes (default 5) or no progress for stuck_minutes (default 10) sends one factual alert; these alerts never stop, cancel, or restart the run. Elapsed-time limits are opt-in for work with a real wall-clock bound: deadline_minutes steers the child to wrap up and later stops it after grace_minutes, while max_minutes stops it without grace.",
3445
+ "Every launch automatically joins one shared harness-owned watcher, including batch launches; it stops when the last active run ends. Do not start a separate heartbeat or background watcher for subagents. A tool call still open for tool_wait_minutes (default 5) or no progress for stuck_minutes (default 10) sends one factual alert; these alerts never stop, cancel, or restart the run. Hard timeouts default to 60 minutes; max_minutes overrides that default (0 disables). Soft deadlines remain opt-in for work with a real wall-clock bound: deadline_minutes steers the child to wrap up and later stops it after grace_minutes, while max_minutes stops it without grace. Do not set elapsed-time limits merely because a task may take a while; use them only when the work needs a real wall-clock bound.",
3419
3446
  "Use git_clone_workspace:true when the subagent will mutate Git in a sandbox. The parent prepares a disposable, self-contained clone with a real .git/ directory inside the sandbox root, so linked-worktree metadata outside the sandbox cannot stall the child.",
3420
3447
  "Saved conversations persist automatically. To continue a completed or stopped worker in its own history, call subagent_spawn with session_id (the native session id reported for the saved conversation) and the new feedback as prompt. Omit session_id for a brand-new conversation.",
3421
3448
  "On a continuation, pass prompt, session_id, and only optional model, thinking, callback, or timing overrides. Launch-only options (name, agent, role, alias, tools, exclude_tools, skills, clean, sandbox, sandbox_dir, cwd, git_clone_workspace, approve, allow_nested) keep the saved recipe; repeat them only with the same value, otherwise the launch is refused.",
@@ -3914,6 +3941,7 @@ export default function (pi: ExtensionAPI) {
3914
3941
  const timing = timingState();
3915
3942
  return {
3916
3943
  mode: settings.delegationMode, maxConcurrent: settings.maxConcurrent,
3944
+ maxMinutes: timing.maxMinutes,
3917
3945
  modeSource: settings.sources.delegationMode,
3918
3946
  capSource: settings.sources.maxConcurrent,
3919
3947
  defaultMode: normalizeDelegationMode(loadConfig().delegationMode),
@@ -3940,7 +3968,8 @@ export default function (pi: ExtensionAPI) {
3940
3968
  : skills.mode === "none" ? "no skills" : `${skills.paths.length} selected skills`;
3941
3969
  const timing = timingState();
3942
3970
  ctx.ui.notify(`Delegation mode: ${activeDelegationMode()}. Concurrent subagents: ${activeConcurrencyCap()}. ` +
3943
- `Tool-wait alert: ${timing.toolWaitMinutes} min. No-progress alert: ${timing.stuckMinutes} min. Skills: ${skillChoice}. ` +
3971
+ `Timeout: ${timing.maxMinutes} minutes (0 disables). Tool-wait alert: ${timing.toolWaitMinutes} min. ` +
3972
+ `No-progress alert: ${timing.stuckMinutes} min. Skills: ${skillChoice}. ` +
3944
3973
  `Use /subagents mode <mode>, cap <number>, tool-wait <minutes>, stuck <minutes>, or settings; /subagents save persists them.`, "info");
3945
3974
  }
3946
3975
  };
@@ -3976,7 +4005,8 @@ export default function (pi: ExtensionAPI) {
3976
4005
  const timing = timingState();
3977
4006
  if (ctx.hasUI && !(await ctx.ui.confirm("Save subagent defaults?",
3978
4007
  `Future sessions will start in ${activeDelegationMode()} with a cap of ${activeConcurrencyCap()}, ` +
3979
- `tool-wait alert ${timing.toolWaitMinutes} min, no-progress alert ${timing.stuckMinutes} min, and ${skillChoice}.`))) {
4008
+ `timeout ${timing.maxMinutes} minutes (0 disables), tool-wait alert ${timing.toolWaitMinutes} min, ` +
4009
+ `no-progress alert ${timing.stuckMinutes} min, and ${skillChoice}.`))) {
3980
4010
  ctx.ui.notify("Subagent defaults unchanged.", "info");
3981
4011
  return;
3982
4012
  }
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-better-subagents",
3
- "version": "0.29.1",
3
+ "version": "0.30.0",
4
4
  "description": "Pi extension for detached, sandboxed subagent runs that keep the foreground session free.",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -6,11 +6,12 @@ import type { SkillExposureMode } from "./skills.ts";
6
6
  import { createSkillPicker, type DiscoveredSkill } from "./skill-picker.ts";
7
7
  import { validatedTimingMinutes } from "./timing.ts";
8
8
 
9
- export type TimingSettingKey = "toolWaitMinutes" | "stuckMinutes";
9
+ export type TimingSettingKey = "maxMinutes" | "toolWaitMinutes" | "stuckMinutes";
10
10
 
11
11
  export interface SubagentSettingsSnapshot {
12
12
  mode: DelegationMode;
13
13
  maxConcurrent: number;
14
+ maxMinutes: number;
14
15
  modeSource: "session" | "config";
15
16
  capSource: "session" | "config";
16
17
  defaultMode: DelegationMode;
@@ -45,13 +46,14 @@ export interface SubagentSettingsHandlers {
45
46
  openAgents?(): void;
46
47
  }
47
48
 
48
- type EditTarget = { kind: "cap" } | { kind: "toolWait" } | { kind: "stuck" };
49
+ type EditTarget = { kind: "cap" } | { kind: "toolWait" } | { kind: "stuck" } | { kind: "timeout" };
49
50
 
50
51
  type Row =
51
52
  | { kind: "mode" }
52
53
  | { kind: "cap" }
53
54
  | { kind: "toolWait" }
54
55
  | { kind: "stuck" }
56
+ | { kind: "timeout" }
55
57
  | { kind: "skills" }
56
58
  | { kind: "choose" }
57
59
  | { kind: "agents" }
@@ -110,7 +112,7 @@ export function createSubagentSettingsPage(
110
112
 
111
113
  function rowsOf(settings: SubagentSettingsSnapshot): Row[] {
112
114
  const rows: Row[] = [
113
- { kind: "mode" }, { kind: "cap" }, { kind: "toolWait" }, { kind: "stuck" },
115
+ { kind: "mode" }, { kind: "cap" }, { kind: "toolWait" }, { kind: "stuck" }, { kind: "timeout" },
114
116
  { kind: "skills" }, { kind: "choose" },
115
117
  ];
116
118
  rows.push({ kind: "agents" }, { kind: "reset" });
@@ -178,6 +180,8 @@ export function createSubagentSettingsPage(
178
180
  startEditing({ kind: "toolWait" }, String(settings.toolWaitMinutes));
179
181
  } else if (selected.kind === "stuck") {
180
182
  startEditing({ kind: "stuck" }, String(settings.stuckMinutes));
183
+ } else if (selected.kind === "timeout") {
184
+ startEditing({ kind: "timeout" }, String(settings.maxMinutes));
181
185
  } else if (selected.kind === "skills") {
182
186
  if (settings.skillsMode === "explicit") rememberedSkillPaths = [...settings.skillsPaths];
183
187
  const next = cycleSkillMode(settings.skillsMode);
@@ -219,11 +223,13 @@ export function createSubagentSettingsPage(
219
223
  }
220
224
  return;
221
225
  }
222
- const key = target.kind === "toolWait" ? "toolWaitMinutes" : "stuckMinutes";
226
+ const key = target.kind === "toolWait" ? "toolWaitMinutes"
227
+ : target.kind === "stuck" ? "stuckMinutes" : "maxMinutes";
223
228
  try {
224
229
  const minutes = validatedTimingMinutes(value.trim(), key);
225
230
  handlers.changeTiming(key, minutes);
226
- finishEditing(`${target.kind === "toolWait" ? "Tool-wait" : "No-progress"} alert updated.`);
231
+ finishEditing(target.kind === "toolWait" ? "Tool-wait alert updated."
232
+ : target.kind === "stuck" ? "No-progress alert updated." : "Timeout updated.");
227
233
  } catch (cause) {
228
234
  report(cause instanceof Error ? cause.message : String(cause), true);
229
235
  }
@@ -302,12 +308,15 @@ export function createSubagentSettingsPage(
302
308
  rows.forEach((entry, index) => {
303
309
  const selected = index === row;
304
310
  if (selected) selectedStart = lines.length;
305
- if (entry.kind === "mode" || entry.kind === "cap" || entry.kind === "toolWait" || entry.kind === "stuck") {
311
+ if (entry.kind === "mode" || entry.kind === "cap" || entry.kind === "toolWait"
312
+ || entry.kind === "stuck" || entry.kind === "timeout") {
306
313
  const label = entry.kind === "mode" ? "Delegation mode"
307
314
  : entry.kind === "cap" ? "Concurrent subagents"
308
- : entry.kind === "toolWait" ? "Tool-wait alert" : "No-progress alert";
315
+ : entry.kind === "toolWait" ? "Tool-wait alert"
316
+ : entry.kind === "stuck" ? "No-progress alert" : "Timeout (minutes)";
309
317
  const value = entry.kind === "mode" ? settings.mode
310
318
  : entry.kind === "cap" ? String(settings.maxConcurrent)
319
+ : entry.kind === "timeout" ? String(settings.maxMinutes)
311
320
  : `${entry.kind === "toolWait" ? settings.toolWaitMinutes : settings.stuckMinutes} min`;
312
321
  const active = editing?.kind === entry.kind
313
322
  ? input.render(compact ? Math.max(1, w - 2) : valueWidth)[0]!
@@ -58,7 +58,7 @@ export interface OpenCallRecord {
58
58
  export const DEFAULT_TIMING_MINUTES = Object.freeze({
59
59
  deadline: 0,
60
60
  grace: 5,
61
- max: 0,
61
+ max: 60,
62
62
  toolWait: 5,
63
63
  stuck: 10,
64
64
  });
@@ -111,6 +111,18 @@ export interface TimingParams {
111
111
  stuck_minutes?: number | null;
112
112
  }
113
113
 
114
+ /** Carry a parked run's remaining limits, including an explicitly disabled ceiling. */
115
+ export function continuationTimingParams(timing: RunTiming, now: number): TimingParams {
116
+ const remaining = (at: number): number => Math.max(1, at - now) / 60_000;
117
+ return {
118
+ ...(timing.deadlineAt !== undefined ? { deadline_minutes: remaining(timing.deadlineAt) } : {}),
119
+ max_minutes: timing.ceilingAt === undefined ? 0 : remaining(timing.ceilingAt),
120
+ grace_minutes: timing.graceMs / 60_000,
121
+ ...(timing.toolWaitMs !== undefined ? { tool_wait_minutes: timing.toolWaitMs / 60_000 } : {}),
122
+ ...(timing.stuckMs !== undefined ? { stuck_minutes: timing.stuckMs / 60_000 } : {}),
123
+ };
124
+ }
125
+
114
126
  export interface TimingSettings {
115
127
  deadlineMinutes?: number | null;
116
128
  graceMinutes?: number | null;
@@ -148,11 +160,6 @@ export function validatedTimingMinutes(value: unknown, label = "minutes"): numbe
148
160
  return parsed;
149
161
  }
150
162
 
151
- /**
152
- * One optional minutes value. `undefined`, `null`, and (for env) an empty
153
- * string mean "not set". Anything else must be a finite number from 0 to a
154
- * week; `0` disables.
155
- */
156
163
  function minutes(value: unknown, label: string): number | undefined {
157
164
  if (value === undefined || value === null) return undefined;
158
165
  if (typeof value === "string") {
@@ -212,7 +219,7 @@ export function resolveRunTiming(input: {
212
219
  const TIMING_DESCRIPTIONS = {
213
220
  deadline_minutes: "Optional soft deadline in minutes (default: none; 0 = none; null or omitted = inherit: in a batch job the shared value, otherwise the default). At the deadline the child is told to stop starting new work, commit what is done, and report, and you get one wake. Grace starts when the message reaches the child (after its current tool call); if it has not finished by then, the harness stops it with reason deadline.",
214
221
  grace_minutes: "Minutes after the wrap-up message reaches the child before the run is stopped (default 5; null or omitted = inherit).",
215
- max_minutes: "Optional hard ceiling in minutes (default: none; 0 = none; null or omitted = inherit). The run is stopped at once, without grace, with reason ceiling.",
222
+ max_minutes: "Optional hard ceiling in minutes (default: 60; 0 = none; null or omitted = inherit). The run is stopped at once, without grace, with reason ceiling.",
216
223
  tool_wait_minutes: "Tool-call wait alert in minutes (default 5; 0 = off; null or omitted = inherit). While a tool call stays open this long you get one factual wake for that call, including a normal long test. Parallel calls alert independently. The alert does not stop or cancel the run.",
217
224
  stuck_minutes: "No-progress window in minutes (default 10; 0 = off; null or omitted = inherit). Progress is any successful tool call that is not an exact repeat of an earlier one (same tool, same arguments); edits, writes, commits, and a success after a failure always count. Time inside a running tool call does not count. Wakes you once per stuck spell; never stops the run.",
218
225
  } as const;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-better-harness",
3
- "version": "0.42.1",
3
+ "version": "0.43.0",
4
4
  "description": "Pi extension bundle for a write sandbox, subagents, background tasks, SSH, goals, model routing, and Outcome & Todo.",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -64,7 +64,7 @@
64
64
  "pi-ollama-cloud": "0.13.1",
65
65
  "pi-better-sandbox": "0.14.1",
66
66
  "pi-better-ssh": "0.1.5",
67
- "pi-better-subagents": "0.29.1",
67
+ "pi-better-subagents": "0.30.0",
68
68
  "proper-lockfile": "^4.1.2",
69
69
  "shell-quote": "1.12.0",
70
70
  "smol-toml": "1.9.0",