@gotgenes/pi-subagents 5.0.0 → 5.2.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.
@@ -68,36 +68,12 @@ function filterActiveTools(
68
68
  });
69
69
  }
70
70
 
71
- /** Default max turns. undefined = unlimited (no turn limit). */
72
- let defaultMaxTurns: number | undefined;
73
-
74
71
  /** Normalize max turns. undefined or 0 = unlimited, otherwise minimum 1. */
75
72
  export function normalizeMaxTurns(n: number | undefined): number | undefined {
76
73
  if (n == null || n === 0) return undefined;
77
74
  return Math.max(1, n);
78
75
  }
79
76
 
80
- /** Get the default max turns value. undefined = unlimited. */
81
- export function getDefaultMaxTurns(): number | undefined {
82
- return defaultMaxTurns;
83
- }
84
- /** Set the default max turns value. undefined or 0 = unlimited, otherwise minimum 1. */
85
- export function setDefaultMaxTurns(n: number | undefined): void {
86
- defaultMaxTurns = normalizeMaxTurns(n);
87
- }
88
-
89
- /** Additional turns allowed after the soft limit steer message. */
90
- let graceTurns = 5;
91
-
92
- /** Get the grace turns value. */
93
- export function getGraceTurns(): number {
94
- return graceTurns;
95
- }
96
- /** Set the grace turns value (minimum 1). */
97
- export function setGraceTurns(n: number): void {
98
- graceTurns = Math.max(1, n);
99
- }
100
-
101
77
  /**
102
78
  * Try to find the right model for an agent type.
103
79
  * Priority: explicit option > config.model > parent model.
@@ -174,6 +150,17 @@ export interface RunOptions {
174
150
  reason: "manual" | "threshold" | "overflow";
175
151
  tokensBefore: number;
176
152
  }) => void;
153
+ /**
154
+ * Default max turns from runtime config. Falls back to the module-scope
155
+ * `defaultMaxTurns` during the lift-and-shift migration; superseded by
156
+ * per-call `maxTurns` and per-agent `agentConfig.maxTurns`.
157
+ */
158
+ defaultMaxTurns?: number;
159
+ /**
160
+ * Grace turns after the soft-limit steer message. Falls back to the
161
+ * module-scope `graceTurns` during migration.
162
+ */
163
+ graceTurns?: number;
177
164
  }
178
165
 
179
166
  export interface RunResult {
@@ -424,7 +411,7 @@ export async function runAgent(
424
411
  // Track turns for graceful max_turns enforcement
425
412
  let turnCount = 0;
426
413
  const maxTurns = normalizeMaxTurns(
427
- options.maxTurns ?? agentConfig?.maxTurns ?? defaultMaxTurns,
414
+ options.maxTurns ?? agentConfig?.maxTurns ?? options.defaultMaxTurns,
428
415
  );
429
416
  let softLimitReached = false;
430
417
  let aborted = false;
@@ -440,7 +427,7 @@ export async function runAgent(
440
427
  session.steer(
441
428
  "You have reached your turn limit. Wrap up immediately — provide your final answer now.",
442
429
  );
443
- } else if (softLimitReached && turnCount >= maxTurns + graceTurns) {
430
+ } else if (softLimitReached && turnCount >= maxTurns + (options.graceTurns ?? 5)) {
444
431
  aborted = true;
445
432
  session.abort();
446
433
  }
@@ -6,6 +6,7 @@ import { existsSync, readdirSync, readFileSync } from "node:fs";
6
6
  import { basename, join } from "node:path";
7
7
  import { getAgentDir, parseFrontmatter } from "@earendil-works/pi-coding-agent";
8
8
  import { BUILTIN_TOOL_NAMES } from "./agent-types.js";
9
+ import { debugLog } from "./debug.js";
9
10
  import type { AgentConfig, MemoryScope, ThinkingLevel } from "./types.js";
10
11
 
11
12
  /**
@@ -34,7 +35,8 @@ function loadFromDir(dir: string, agents: Map<string, AgentConfig>, source: "pro
34
35
  let files: string[];
35
36
  try {
36
37
  files = readdirSync(dir).filter(f => f.endsWith(".md"));
37
- } catch {
38
+ } catch (err) {
39
+ debugLog("readdirSync agents dir", err);
38
40
  return;
39
41
  }
40
42
 
@@ -44,7 +46,8 @@ function loadFromDir(dir: string, agents: Map<string, AgentConfig>, source: "pro
44
46
  let content: string;
45
47
  try {
46
48
  content = readFileSync(join(dir, file), "utf-8");
47
- } catch {
49
+ } catch (err) {
50
+ debugLog("readFileSync agent file", err);
48
51
  continue;
49
52
  }
50
53
 
package/src/debug.ts ADDED
@@ -0,0 +1,14 @@
1
+ /**
2
+ * debug.ts — Debug logging utility for silenced catch blocks.
3
+ *
4
+ * Set PI_SUBAGENTS_DEBUG=1 to reveal silent failures in catch blocks
5
+ * throughout the package. Production behavior is unchanged when unset.
6
+ */
7
+
8
+ export function isDebug(): boolean {
9
+ return process.env.PI_SUBAGENTS_DEBUG === "1";
10
+ }
11
+
12
+ export function debugLog(context: string, err: unknown): void {
13
+ if (isDebug()) console.warn(`[pi-subagents:debug] ${context}:`, err);
14
+ }
package/src/env.ts CHANGED
@@ -3,6 +3,7 @@
3
3
  */
4
4
 
5
5
  import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
6
+ import { debugLog } from "./debug.js";
6
7
  import type { EnvInfo } from "./types.js";
7
8
 
8
9
  export async function detectEnv(pi: ExtensionAPI, cwd: string): Promise<EnvInfo> {
@@ -12,15 +13,16 @@ export async function detectEnv(pi: ExtensionAPI, cwd: string): Promise<EnvInfo>
12
13
  try {
13
14
  const result = await pi.exec("git", ["rev-parse", "--is-inside-work-tree"], { cwd, timeout: 5000 });
14
15
  isGitRepo = result.code === 0 && result.stdout.trim() === "true";
15
- } catch {
16
- // Not a git repo or git not installed
16
+ } catch (err) {
17
+ debugLog("git rev-parse", err);
17
18
  }
18
19
 
19
20
  if (isGitRepo) {
20
21
  try {
21
22
  const result = await pi.exec("git", ["branch", "--show-current"], { cwd, timeout: 5000 });
22
23
  branch = result.code === 0 ? result.stdout.trim() : "unknown";
23
- } catch {
24
+ } catch (err) {
25
+ debugLog("git branch", err);
24
26
  branch = "unknown";
25
27
  }
26
28
  }
package/src/index.ts CHANGED
@@ -13,12 +13,13 @@
13
13
  import { join } from "node:path";
14
14
  import { defineTool, type ExtensionAPI, getAgentDir } from "@earendil-works/pi-coding-agent";
15
15
  import { AgentManager } from "./agent-manager.js";
16
- import { getAgentConversation, getDefaultMaxTurns, getGraceTurns, setDefaultMaxTurns, setGraceTurns, steerAgent } from "./agent-runner.js";
16
+ import { getAgentConversation, normalizeMaxTurns, steerAgent } from "./agent-runner.js";
17
17
  import { getAgentConfig, getAvailableTypes, getDefaultAgentNames, getUserAgentNames, registerAgents, } from "./agent-types.js";
18
18
  import { loadCustomAgents } from "./custom-agents.js";
19
19
  import { type ModelRegistry, resolveModel } from "./model-resolver.js";
20
20
  import { buildEventData, createNotificationSystem } from "./notification.js";
21
21
  import { createNotificationRenderer } from "./renderer.js";
22
+ import { createSubagentRuntime } from "./runtime.js";
22
23
  import { publishSubagentsService, unpublishSubagentsService } from "./service.js";
23
24
  import { createSubagentsService } from "./service-adapter.js";
24
25
  import { applyAndEmitLoaded, saveAndEmitChanged } from "./settings.js";
@@ -29,7 +30,6 @@ import { createSteerTool } from "./tools/steer-tool.js";
29
30
  import { type NotificationDetails } from "./types.js";
30
31
  import { createAgentsMenuHandler } from "./ui/agent-menu.js";
31
32
  import {
32
- type AgentActivity,
33
33
  AgentWidget,
34
34
  type UICtx,
35
35
  } from "./ui/agent-widget.js";
@@ -47,17 +47,17 @@ export default function (pi: ExtensionAPI) {
47
47
  // Initial load
48
48
  reloadCustomAgents();
49
49
 
50
- // ---- Agent activity tracking ----
51
- const agentActivity = new Map<string, AgentActivity>();
50
+ // ---- Runtime: all mutable extension state in one place ----
51
+ const runtime = createSubagentRuntime();
52
52
 
53
53
  // ---- Notification system ----
54
- // Widget assigned after AgentManager construction; arrow closures capture by reference.
55
- let widget: AgentWidget;
54
+ // runtime.widget is assigned after AgentManager construction; arrow closures
55
+ // capture `runtime` by reference so they always read the current value.
56
56
  const notifications = createNotificationSystem({
57
57
  sendMessage: (msg, opts) => pi.sendMessage(msg as any, opts as any),
58
- agentActivity,
59
- markFinished: (id) => widget.markFinished(id),
60
- updateWidget: () => widget.update(),
58
+ agentActivity: runtime.agentActivity,
59
+ markFinished: (id) => runtime.widget!.markFinished(id),
60
+ updateWidget: () => runtime.widget!.update(),
61
61
  });
62
62
 
63
63
  // Background completion: emit lifecycle event and delegate to notification system
@@ -102,21 +102,21 @@ export default function (pi: ExtensionAPI) {
102
102
  tokensBefore: info.tokensBefore,
103
103
  compactionCount: record.compactionCount,
104
104
  });
105
- });
105
+ },
106
+ () => ({ defaultMaxTurns: runtime.defaultMaxTurns, graceTurns: runtime.graceTurns }));
106
107
 
107
108
  // Typed service published via Symbol.for() for cross-extension access.
108
109
  // Consumers: const { getSubagentsService } = await import("@gotgenes/pi-subagents");
109
- let currentCtx: { pi: unknown; ctx: unknown } | undefined;
110
110
  const service = createSubagentsService({
111
111
  manager,
112
112
  resolveModel,
113
- getCtx: () => currentCtx,
114
- getModelRegistry: () => (currentCtx?.ctx as { modelRegistry?: ModelRegistry } | undefined)?.modelRegistry,
113
+ getCtx: () => runtime.currentCtx,
114
+ getModelRegistry: () => (runtime.currentCtx?.ctx as { modelRegistry?: ModelRegistry } | undefined)?.modelRegistry,
115
115
  });
116
116
  publishSubagentsService(service);
117
117
 
118
118
  pi.on("session_start", async (_event, ctx) => {
119
- currentCtx = { pi, ctx };
119
+ runtime.currentCtx = { pi, ctx };
120
120
  manager.clearCompleted();
121
121
  });
122
122
 
@@ -128,19 +128,19 @@ export default function (pi: ExtensionAPI) {
128
128
  // If the session is going down, there's nothing left to consume agent results.
129
129
  pi.on("session_shutdown", async () => {
130
130
  unpublishSubagentsService();
131
- currentCtx = undefined;
131
+ runtime.currentCtx = undefined;
132
132
  manager.abortAll();
133
133
  notifications.dispose();
134
134
  manager.dispose();
135
135
  });
136
136
 
137
137
  // Live widget: show running agents above editor
138
- widget = new AgentWidget(manager, agentActivity);
138
+ runtime.widget = new AgentWidget(manager, runtime.agentActivity);
139
139
 
140
140
  // Grab UI context from first tool execution + clear lingering widget on new turn
141
141
  pi.on("tool_execution_start", async (_event, ctx) => {
142
- widget.setUICtx(ctx.ui as UICtx);
143
- widget.onTurnStart();
142
+ runtime.widget!.setUICtx(ctx.ui as UICtx);
143
+ runtime.widget!.onTurnStart();
144
144
  });
145
145
 
146
146
  /** Build the full type list text dynamically from the unified registry. */
@@ -176,8 +176,8 @@ export default function (pi: ExtensionAPI) {
176
176
  applyAndEmitLoaded(
177
177
  {
178
178
  setMaxConcurrent: (n) => manager.setMaxConcurrent(n),
179
- setDefaultMaxTurns,
180
- setGraceTurns,
179
+ setDefaultMaxTurns: (n) => { runtime.defaultMaxTurns = normalizeMaxTurns(n); },
180
+ setGraceTurns: (n) => { runtime.graceTurns = Math.max(1, n); },
181
181
  },
182
182
  (event, payload) => pi.events.emit(event, payload),
183
183
  );
@@ -194,17 +194,18 @@ export default function (pi: ExtensionAPI) {
194
194
  listAgents: () => manager.listAgents(),
195
195
  },
196
196
  widget: {
197
- setUICtx: (ctx) => widget.setUICtx(ctx as UICtx),
198
- ensureTimer: () => widget.ensureTimer(),
199
- update: () => widget.update(),
200
- markFinished: (id) => widget.markFinished(id),
197
+ setUICtx: (ctx) => runtime.widget!.setUICtx(ctx as UICtx),
198
+ ensureTimer: () => runtime.widget!.ensureTimer(),
199
+ update: () => runtime.widget!.update(),
200
+ markFinished: (id) => runtime.widget!.markFinished(id),
201
201
  },
202
- agentActivity,
202
+ agentActivity: runtime.agentActivity,
203
203
  emitEvent: (name, data) => pi.events.emit(name, data),
204
204
  reloadCustomAgents,
205
205
  typeListText,
206
206
  availableTypesText: getAvailableTypes().join(", "),
207
207
  agentDir: getAgentDir(),
208
+ getDefaultMaxTurns: () => runtime.defaultMaxTurns,
208
209
  }) as any));
209
210
 
210
211
  // ---- get_subagent_result tool ----
@@ -234,7 +235,7 @@ export default function (pi: ExtensionAPI) {
234
235
  setMaxConcurrent: (n) => manager.setMaxConcurrent(n),
235
236
  },
236
237
  reloadCustomAgents,
237
- agentActivity,
238
+ agentActivity: runtime.agentActivity,
238
239
  getModelLabel: (type, registry) => {
239
240
  const cfg = getAgentConfig(type);
240
241
  if (!cfg?.model) return 'inherit';
@@ -246,9 +247,17 @@ export default function (pi: ExtensionAPI) {
246
247
  },
247
248
  snapshotSettings: () => ({
248
249
  maxConcurrent: manager.getMaxConcurrent(),
249
- defaultMaxTurns: getDefaultMaxTurns() ?? 0,
250
- graceTurns: getGraceTurns(),
250
+ defaultMaxTurns: runtime.defaultMaxTurns ?? 0,
251
+ graceTurns: runtime.graceTurns,
251
252
  }),
253
+ getDefaultMaxTurns: () => runtime.defaultMaxTurns,
254
+ getGraceTurns: () => runtime.graceTurns,
255
+ setDefaultMaxTurns: (n) => {
256
+ runtime.defaultMaxTurns = normalizeMaxTurns(n);
257
+ },
258
+ setGraceTurns: (n) => {
259
+ runtime.graceTurns = Math.max(1, n);
260
+ },
252
261
  saveSettings: (settings, successMsg) => saveAndEmitChanged(
253
262
  settings,
254
263
  successMsg,
package/src/memory.ts CHANGED
@@ -10,6 +10,7 @@
10
10
  import { existsSync, lstatSync, mkdirSync, readFileSync } from "node:fs";
11
11
  import { homedir } from "node:os";
12
12
  import { join, } from "node:path";
13
+ import { debugLog } from "./debug.js";
13
14
  import type { MemoryScope } from "./types.js";
14
15
 
15
16
  /** Maximum lines to read from MEMORY.md */
@@ -30,7 +31,8 @@ export function isUnsafeName(name: string): boolean {
30
31
  export function isSymlink(filePath: string): boolean {
31
32
  try {
32
33
  return lstatSync(filePath).isSymbolicLink();
33
- } catch {
34
+ } catch (err) {
35
+ debugLog("lstatSync", err);
34
36
  return false;
35
37
  }
36
38
  }
@@ -44,7 +46,8 @@ export function safeReadFile(filePath: string): string | undefined {
44
46
  if (isSymlink(filePath)) return undefined;
45
47
  try {
46
48
  return readFileSync(filePath, "utf-8");
47
- } catch {
49
+ } catch (err) {
50
+ debugLog("readFileSync", err);
48
51
  return undefined;
49
52
  }
50
53
  }
@@ -1,3 +1,4 @@
1
+ import { debugLog } from "./debug.js";
1
2
  import type { AgentRecord, NotificationDetails } from "./types.js";
2
3
  import type { AgentActivity } from "./ui/agent-widget.js";
3
4
  import { getLifetimeTotal, getSessionContextPercent } from "./usage.js";
@@ -142,8 +143,8 @@ export function createNotificationSystem(deps: NotificationDeps): NotificationSy
142
143
  pendingNudges.delete(key);
143
144
  try {
144
145
  send();
145
- } catch {
146
- /* ignore stale completion side-effect errors */
146
+ } catch (err) {
147
+ debugLog("notification render", err);
147
148
  }
148
149
  }, delay),
149
150
  );
@@ -9,6 +9,7 @@ import { appendFileSync, chmodSync, mkdirSync, writeFileSync } from "node:fs";
9
9
  import { tmpdir } from "node:os";
10
10
  import { join } from "node:path";
11
11
  import type { AgentSession, AgentSessionEvent } from "@earendil-works/pi-coding-agent";
12
+ import { debugLog } from "./debug.js";
12
13
 
13
14
  /**
14
15
  * Encode a cwd path as a filesystem-safe directory name. Handles:
@@ -80,7 +81,9 @@ export function streamToOutputFile(
80
81
  };
81
82
  try {
82
83
  appendFileSync(path, JSON.stringify(entry) + "\n", "utf-8");
83
- } catch { /* ignore write errors */ }
84
+ } catch (err) {
85
+ debugLog("write JSONL chunk", err);
86
+ }
84
87
  writtenCount++;
85
88
  }
86
89
  };
package/src/runtime.ts ADDED
@@ -0,0 +1,62 @@
1
+ /**
2
+ * runtime.ts — SubagentRuntime: composition root for all mutable extension state.
3
+ *
4
+ * Eliminates module-scope state in agent-runner.ts and closure-scoped state
5
+ * in index.ts by consolidating them into a single, testable object.
6
+ * Follows the same pattern as pi-permission-system's ExtensionRuntime.
7
+ */
8
+
9
+ import type { AgentActivity, AgentWidget } from "./ui/agent-widget.js";
10
+
11
+ /**
12
+ * Narrow config subset read by AgentManager when constructing RunOptions.
13
+ * Kept separate so callers can satisfy it without depending on the full runtime.
14
+ */
15
+ export interface RunConfig {
16
+ readonly defaultMaxTurns: number | undefined;
17
+ readonly graceTurns: number;
18
+ }
19
+
20
+ /**
21
+ * All mutable state owned by the pi-subagents extension.
22
+ *
23
+ * Created once inside `piSubagentsExtension()` via `createSubagentRuntime()`.
24
+ * Tests construct a fresh runtime per test for full isolation.
25
+ */
26
+ export interface SubagentRuntime {
27
+ // ── Execution config (was module-scope in agent-runner.ts) ──────────────
28
+ /** Default max turns for all agents. undefined = unlimited. */
29
+ defaultMaxTurns: number | undefined;
30
+ /** Additional turns allowed after the soft-limit steer message. */
31
+ graceTurns: number;
32
+
33
+ // ── Session state (was closure-scoped in index.ts) ───────────────────────
34
+ /** Active Pi session context — set on session_start, cleared on session_shutdown. */
35
+ currentCtx: { pi: unknown; ctx: unknown } | undefined;
36
+ /**
37
+ * Per-agent live activity state shared across the notification system,
38
+ * widget, and tool handlers. The Map itself is never replaced.
39
+ */
40
+ readonly agentActivity: Map<string, AgentActivity>;
41
+ /**
42
+ * Persistent widget reference. Null until constructed after AgentManager.
43
+ * Notification closures use `runtime.widget!` — safe because agents always
44
+ * complete after widget construction.
45
+ */
46
+ widget: AgentWidget | null;
47
+ }
48
+
49
+ /**
50
+ * Create a fully-initialized SubagentRuntime with default values.
51
+ *
52
+ * Call once at extension startup; pass the result to factories and handlers.
53
+ */
54
+ export function createSubagentRuntime(): SubagentRuntime {
55
+ return {
56
+ defaultMaxTurns: undefined,
57
+ graceTurns: 5,
58
+ currentCtx: undefined,
59
+ agentActivity: new Map(),
60
+ widget: null,
61
+ };
62
+ }
@@ -23,6 +23,7 @@ import { existsSync, readdirSync } from "node:fs";
23
23
  import { homedir } from "node:os";
24
24
  import { join } from "node:path";
25
25
  import { getAgentDir } from "@earendil-works/pi-coding-agent";
26
+ import { debugLog } from "./debug.js";
26
27
  import { isSymlink, isUnsafeName, safeReadFile } from "./memory.js";
27
28
 
28
29
  export interface PreloadedSkill {
@@ -71,7 +72,8 @@ function findSkillDirectory(root: string, name: string): string | undefined {
71
72
  let entries: Dirent<string>[];
72
73
  try {
73
74
  entries = readdirSync(current, { withFileTypes: true });
74
- } catch {
75
+ } catch (err) {
76
+ debugLog("readdirSync skill root", err);
75
77
  continue;
76
78
  }
77
79
 
@@ -1,6 +1,6 @@
1
1
  import { Text } from "@earendil-works/pi-tui";
2
2
  import { Type } from "@sinclair/typebox";
3
- import { getDefaultMaxTurns, normalizeMaxTurns } from "../agent-runner.js";
3
+ import { normalizeMaxTurns } from "../agent-runner.js";
4
4
  import { getAgentConfig, resolveType } from "../agent-types.js";
5
5
  import { resolveAgentInvocationConfig } from "../invocation-config.js";
6
6
  import { resolveInvocationModel } from "../model-resolver.js";
@@ -146,6 +146,8 @@ export interface AgentToolDeps {
146
146
  typeListText: string;
147
147
  availableTypesText: string;
148
148
  agentDir: string;
149
+ /** Returns the runtime default max turns (undefined = unlimited). */
150
+ getDefaultMaxTurns: () => number | undefined;
149
151
  }
150
152
 
151
153
  // ---- Factory ----
@@ -396,7 +398,7 @@ Guidelines:
396
398
  ? (model?.name ?? effectiveModelId).replace(/^Claude\s+/i, "").toLowerCase()
397
399
  : undefined;
398
400
  const effectiveMaxTurns = normalizeMaxTurns(
399
- resolvedConfig.maxTurns ?? getDefaultMaxTurns(),
401
+ resolvedConfig.maxTurns ?? deps.getDefaultMaxTurns(),
400
402
  );
401
403
  const agentInvocation: AgentInvocation = {
402
404
  modelName,
@@ -1,11 +1,6 @@
1
1
  import { existsSync, mkdirSync, readFileSync, unlinkSync } from "node:fs";
2
2
  import { join } from "node:path";
3
- import {
4
- getDefaultMaxTurns,
5
- getGraceTurns,
6
- setDefaultMaxTurns,
7
- setGraceTurns,
8
- } from "../agent-runner.js";
3
+
9
4
  import {
10
5
  BUILTIN_TOOL_NAMES,
11
6
  getAgentConfig,
@@ -42,6 +37,14 @@ export interface AgentMenuDeps {
42
37
  ) => { message: string; level: string };
43
38
  emitEvent: (name: string, data: unknown) => void;
44
39
  personalAgentsDir: string;
40
+ /** Returns the runtime default max turns (undefined = unlimited). */
41
+ getDefaultMaxTurns: () => number | undefined;
42
+ /** Returns the runtime grace turns value. */
43
+ getGraceTurns: () => number;
44
+ /** Updates the runtime default max turns (undefined = unlimited). */
45
+ setDefaultMaxTurns: (n: number | undefined) => void;
46
+ /** Updates the runtime grace turns value (minimum 1). */
47
+ setGraceTurns: (n: number) => void;
45
48
  }
46
49
 
47
50
  // ---- Narrow UI context types ----
@@ -620,8 +623,8 @@ ${systemPrompt}
620
623
  async function showSettings(ctx: MenuContext) {
621
624
  const choice = await ctx.ui.select("Settings", [
622
625
  `Max concurrency (current: ${deps.manager.getMaxConcurrent()})`,
623
- `Default max turns (current: ${getDefaultMaxTurns() ?? "unlimited"})`,
624
- `Grace turns (current: ${getGraceTurns()})`,
626
+ `Default max turns (current: ${deps.getDefaultMaxTurns() ?? "unlimited"})`,
627
+ `Grace turns (current: ${deps.getGraceTurns()})`,
625
628
  ]);
626
629
  if (!choice) return;
627
630
 
@@ -642,15 +645,15 @@ ${systemPrompt}
642
645
  } else if (choice.startsWith("Default max turns")) {
643
646
  const val = await ctx.ui.input(
644
647
  "Default max turns before wrap-up (0 = unlimited)",
645
- String(getDefaultMaxTurns() ?? 0),
648
+ String(deps.getDefaultMaxTurns() ?? 0),
646
649
  );
647
650
  if (val) {
648
651
  const n = parseInt(val, 10);
649
652
  if (n === 0) {
650
- setDefaultMaxTurns(undefined);
653
+ deps.setDefaultMaxTurns(undefined);
651
654
  notifyApplied(ctx, "Default max turns set to unlimited");
652
655
  } else if (n >= 1) {
653
- setDefaultMaxTurns(n);
656
+ deps.setDefaultMaxTurns(n);
654
657
  notifyApplied(ctx, `Default max turns set to ${n}`);
655
658
  } else {
656
659
  ctx.ui.notify("Must be 0 (unlimited) or a positive integer.", "warning");
@@ -659,12 +662,12 @@ ${systemPrompt}
659
662
  } else if (choice.startsWith("Grace turns")) {
660
663
  const val = await ctx.ui.input(
661
664
  "Grace turns after wrap-up steer",
662
- String(getGraceTurns()),
665
+ String(deps.getGraceTurns()),
663
666
  );
664
667
  if (val) {
665
668
  const n = parseInt(val, 10);
666
669
  if (n >= 1) {
667
- setGraceTurns(n);
670
+ deps.setGraceTurns(n);
668
671
  notifyApplied(ctx, `Grace turns set to ${n}`);
669
672
  } else {
670
673
  ctx.ui.notify("Must be a positive integer.", "warning");
package/src/worktree.ts CHANGED
@@ -11,6 +11,7 @@ import { randomUUID } from "node:crypto";
11
11
  import { existsSync } from "node:fs";
12
12
  import { tmpdir } from "node:os";
13
13
  import { join } from "node:path";
14
+ import { debugLog } from "./debug.js";
14
15
 
15
16
  export interface WorktreeInfo {
16
17
  /** Absolute path to the worktree directory. */
@@ -37,7 +38,8 @@ export function createWorktree(cwd: string, agentId: string): WorktreeInfo | und
37
38
  try {
38
39
  execFileSync("git", ["rev-parse", "--is-inside-work-tree"], { cwd, stdio: "pipe", timeout: 5000 });
39
40
  execFileSync("git", ["rev-parse", "HEAD"], { cwd, stdio: "pipe", timeout: 5000 });
40
- } catch {
41
+ } catch (err) {
42
+ debugLog("createWorktree git rev-parse", err);
41
43
  return undefined;
42
44
  }
43
45
 
@@ -53,8 +55,8 @@ export function createWorktree(cwd: string, agentId: string): WorktreeInfo | und
53
55
  timeout: 30000,
54
56
  });
55
57
  return { path: worktreePath, branch };
56
- } catch {
57
- // If worktree creation fails, return undefined (agent runs in normal cwd)
58
+ } catch (err) {
59
+ debugLog("git worktree add", err);
58
60
  return undefined;
59
61
  }
60
62
  }
@@ -107,8 +109,8 @@ export function cleanupWorktree(
107
109
  stdio: "pipe",
108
110
  timeout: 5000,
109
111
  });
110
- } catch {
111
- // Branch already exists — use a unique suffix
112
+ } catch (err) {
113
+ debugLog("git branch", err);
112
114
  branchName = `${worktree.branch}-${Date.now()}`;
113
115
  execFileSync("git", ["branch", branchName], {
114
116
  cwd: worktree.path,
@@ -127,9 +129,9 @@ export function cleanupWorktree(
127
129
  branch: worktree.branch,
128
130
  path: worktree.path,
129
131
  };
130
- } catch {
131
- // Best effort cleanup on error
132
- try { removeWorktree(cwd, worktree.path); } catch { /* ignore */ }
132
+ } catch (err) {
133
+ debugLog("cleanupWorktree", err);
134
+ try { removeWorktree(cwd, worktree.path); } catch (removeErr) { debugLog("removeWorktree on cleanup error", removeErr); }
133
135
  return { hasChanges: false };
134
136
  }
135
137
  }
@@ -144,11 +146,11 @@ function removeWorktree(cwd: string, worktreePath: string): void {
144
146
  stdio: "pipe",
145
147
  timeout: 10000,
146
148
  });
147
- } catch {
148
- // If git worktree remove fails, try pruning
149
+ } catch (err) {
150
+ debugLog("git worktree remove", err);
149
151
  try {
150
152
  execFileSync("git", ["worktree", "prune"], { cwd, stdio: "pipe", timeout: 5000 });
151
- } catch { /* ignore */ }
153
+ } catch (pruneErr) { debugLog("git worktree prune", pruneErr); }
152
154
  }
153
155
  }
154
156
 
@@ -158,5 +160,5 @@ function removeWorktree(cwd: string, worktreePath: string): void {
158
160
  export function pruneWorktrees(cwd: string): void {
159
161
  try {
160
162
  execFileSync("git", ["worktree", "prune"], { cwd, stdio: "pipe", timeout: 5000 });
161
- } catch { /* ignore */ }
163
+ } catch (err) { debugLog("pruneWorktrees", err); }
162
164
  }