@bacnh85/pi-subagent 0.15.1 → 0.15.3

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,19 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.15.3 (2026-08-20)
4
+ ### Improvements
5
+ - Timeouts have been extracted as Environment Variables enabling overriding.
6
+ - `PI_SUBAGENT_INACTIVITY_TIMEOUT_MINS` default : 3 Mins
7
+ - `PI_SUBAGENT_HARD_TIMEOUT_MINS` default: 20 Mins
8
+
9
+ ## 0.15.2 (2026-08-18)
10
+
11
+ ### Improvements
12
+
13
+ - Colored borders around thread viewer overlay using agent color.
14
+ - Colored scroll-indicator arrows (↑/↓) matching agent color.
15
+ - Scroll offset only resets when switching to a different thread, not on every refresh.
16
+
3
17
  ## 0.15.1 (2026-08-17)
4
18
 
5
19
  ### Improvements
package/README.md CHANGED
@@ -140,8 +140,8 @@ each writes into its own checkout.
140
140
 
141
141
  Every child execution receives a timeout:
142
142
 
143
- - **Default inactivity window:** 3 minutes (`DEFAULT_TIMEOUT_MS`); real SDK lifecycle activity resets it.
144
- - **Absolute cap:** 20 minutes for every child, even when active.
143
+ - **Default inactivity window:** 3 minutes (`DEFAULT_TIMEOUT_MS`); real SDK lifecycle activity resets it. Overridable with Environment Variable `PI_SUBAGENT_INACTIVITY_TIMEOUT_MINS`
144
+ - **Absolute cap:** Default 20 minutes for every child, even when active. Overridable with Environment variable `PI_SUBAGENT_HARD_TIMEOUT_MINS`
145
145
  - **Maximum requested inactivity window:** 60 minutes (`MAX_TIMEOUT_MS`); values must be positive integers.
146
146
  - Timeout diagnostics distinguish `Idle timeout` from `Hard timeout` and parent cancellation.
147
147
  - 30-second progress heartbeats only keep the parent transport alive; they never reset inactivity.
@@ -39,6 +39,7 @@ import {
39
39
  startHeartbeat,
40
40
  } from "./runner.ts";
41
41
  import {
42
+ flushWarnings,
42
43
  isRateLimitError,
43
44
  normalizeTimeout,
44
45
  resolveSafeCwd,
@@ -107,14 +108,14 @@ const TaskItem = Type.Object({
107
108
  agent: Type.String({ description: "Name of the agent to invoke" }),
108
109
  task: Type.String({ description: "Task to delegate to the agent" }),
109
110
  cwd: Type.Optional(Type.String({ description: "Working directory for the agent" })),
110
- timeout: Type.Optional(Type.Number({ description: "Inactivity timeout in milliseconds for this task; real child activity resets it (default 3 minutes, absolute cap 20 minutes)" })),
111
+ timeout: Type.Optional(Type.Number({ description: "Inactivity timeout in ms; aborts on no activity within timeout. Default: 3 min (PI_SUBAGENT_INACTIVITY_TIMEOUT_MINS). The agent always has a lifetime cap: default 20 min or (PI_SUBAGENT_HARD_TIMEOUT_MINS)." })),
111
112
  });
112
113
 
113
114
  const ChainItem = Type.Object({
114
115
  agent: Type.String({ description: "Name of the agent to invoke" }),
115
116
  task: Type.String({ description: "Task with optional {previous} placeholder for prior output" }),
116
117
  cwd: Type.Optional(Type.String({ description: "Working directory for the agent" })),
117
- timeout: Type.Optional(Type.Number({ description: "Inactivity timeout in milliseconds for this step; real child activity resets it (default 3 minutes, absolute cap 20 minutes)" })),
118
+ timeout: Type.Optional(Type.Number({ description: "Inactivity timeout in ms; aborts on no activity within timeout. Default: 3 min (PI_SUBAGENT_INACTIVITY_TIMEOUT_MINS). The agent always has a lifetime cap: default 20 min or (PI_SUBAGENT_HARD_TIMEOUT_MINS)." })),
118
119
  });
119
120
 
120
121
  const AgentScopeSchema = StringEnum(["user", "project", "both"] as const, {
@@ -153,7 +154,7 @@ const SubagentParams = Type.Object({
153
154
  // Project-agent confirmation is enforced via trusted configuration.
154
155
  // See Security model section in README.
155
156
  cwd: Type.Optional(Type.String({ description: "Working directory (single mode, must be inside workspace)" })),
156
- timeout: Type.Optional(Type.Number({ description: "Global inactivity timeout in milliseconds (default 3 minutes; real activity resets it; fixed 20-minute absolute cap)" })),
157
+ timeout: Type.Optional(Type.Number({ description: "Inactivity timeout for the whole run, in ms; resets on activity, aborts on silence. Default 3 min (PI_SUBAGENT_INACTIVITY_TIMEOUT_MINS). Lifetime cap: 20 min or (PI_SUBAGENT_HARD_TIMEOUT_MINS)." })),
157
158
  instructions: Type.Optional(Type.String({ description: "Bounded repository/task instructions passed to each child (max 16 KB)" })),
158
159
  abortOnFailure: Type.Optional(Type.Boolean({ description: "In parallel mode, cancel remaining tasks when one fails. Default: false.", default: false })),
159
160
  });
@@ -477,6 +478,11 @@ export default function (pi: ExtensionAPI) {
477
478
  "Use /subagent to list all available agents or /subagent <name> for agent details.",
478
479
  ],
479
480
  async execute(_toolCallId, params, signal, onUpdate, ctx) {
481
+ // Surface env-var timeout warnings collected at module load. The
482
+ // interactive TUI swallows module-load stderr, so notify on launch.
483
+ for (const msg of flushWarnings()) {
484
+ ctx.ui?.notify?.(msg, "warning");
485
+ }
480
486
  const agentScope: AgentScope = params.agentScope ?? "user";
481
487
  const discovery = discoverAgents(ctx.cwd, agentScope, bundledAgentsDir);
482
488
  const agents = discovery.agents;
@@ -1711,4 +1717,4 @@ export default function (pi: ExtensionAPI) {
1711
1717
  }
1712
1718
  }, { overlay: true, overlayOptions: { maxHeight: "70%" } }); // Overlay: editor stays visible below
1713
1719
  }
1714
- }
1720
+ }
@@ -30,6 +30,8 @@ import {
30
30
  classifyStopReason,
31
31
  createCombinedAbortSignal,
32
32
  type SubagentStatus,
33
+ DEFAULT_TIMEOUT_MS,
34
+ HARD_TIMEOUT_MS,
33
35
  } from "./security.ts";
34
36
 
35
37
  // ---------------------------------------------------------------------------
@@ -46,8 +48,10 @@ export interface UsageStats {
46
48
  turns: number;
47
49
  }
48
50
 
49
- export const DEFAULT_INACTIVITY_TIMEOUT_MS = 3 * 60 * 1000;
50
- export const HARD_TIMEOUT_MS = 20 * 60 * 1000;
51
+ /** Re-export from security.ts single source of truth for both timeouts. */
52
+ export const DEFAULT_INACTIVITY_TIMEOUT_MS = DEFAULT_TIMEOUT_MS;
53
+ export { HARD_TIMEOUT_MS };
54
+
51
55
 
52
56
  // ---------------------------------------------------------------------------
53
57
  // Extension resource loader
@@ -65,10 +65,57 @@ export const MUTATION_TOOLS: readonly string[] = ["edit", "write"];
65
65
  export const EXECUTION_TOOLS: readonly string[] = ["bash"];
66
66
 
67
67
  /**
68
- * Default inactivity timeout. Real SDK lifecycle activity resets this window;
69
- * the runner separately enforces a fixed 20-minute absolute cap.
68
+ * Generic warning sink for configuration problems. Stderr so it is visible
69
+ * with or without a TUI attached. Warnings are also buffered so the extension
70
+ * host can surface them as TUI notifications — the interactive TUI swallows
71
+ * module-load stderr, so module-load warnings alone are invisible in-TUI.
72
+ */
73
+ const warnings: string[] = [];
74
+ function warn(message: string): void {
75
+ process.stderr.write(`[pi-subagent] ${message}\n`);
76
+ warnings.push(message);
77
+ }
78
+
79
+ /**
80
+ * Flush env-var config warnings collected at module load. Called once from
81
+ * the /subagent tool handler so the warnings surface as TUI notifications
82
+ * (module-load stderr is not visible inside the interactive TUI).
70
83
  */
71
- export const DEFAULT_TIMEOUT_MS = 3 * 60 * 1_000; // 3 minutes
84
+ export function flushWarnings(): string[] {
85
+ return warnings.splice(0, warnings.length);
86
+ }
87
+
88
+ /**
89
+ * Read a numeric value from an env var, with validation:
90
+ * - undefined/empty -> defaultValue (no warning)
91
+ * - not a finite number (NaN) -> defaultValue + warning
92
+ * - below `min` -> `min` + warning
93
+ * - above `max` -> `max` + warning
94
+ * - otherwise -> the parsed value (no warning)
95
+ */
96
+ export function getNumericEnvVar(
97
+ envVar: string,
98
+ defaultValue: number,
99
+ min: number,
100
+ max: number,
101
+ ): number {
102
+ const raw = process.env[envVar];
103
+ if (raw === undefined || raw === "") return defaultValue;
104
+ const value = Number(raw);
105
+ if (!Number.isFinite(value)) {
106
+ warn(`ENV VAR: ${envVar}="${raw}" is not a number; using default ${defaultValue}.`);
107
+ return defaultValue;
108
+ }
109
+ if (value < min) {
110
+ warn(`ENV VAR: ${envVar}=${value} is below the minimum of ${min}; using ${min}.`);
111
+ return min;
112
+ }
113
+ if (value > max) {
114
+ warn(`ENV VAR: ${envVar}=${value} is above the maximum of ${max}; using ${max}.`);
115
+ return max;
116
+ }
117
+ return value;
118
+ }
72
119
 
73
120
  /**
74
121
  * Absolute maximum timeout. Any requested value above this cap is rejected
@@ -76,6 +123,35 @@ export const DEFAULT_TIMEOUT_MS = 3 * 60 * 1_000; // 3 minutes
76
123
  */
77
124
  export const MAX_TIMEOUT_MS = 60 * 60 * 1_000; // 60 minutes
78
125
 
126
+ /**
127
+ * Timeout caps, in minutes, shared by getNumericEnvVar for both env vars.
128
+ * No timeout may be shorter than 1 minute; the inactivity window may not
129
+ * exceed the 60-minute absolute maximum.
130
+ */
131
+ const MIN_TIMEOUT_MINS = 1;
132
+ const MAX_TIMEOUT_MINS = MAX_TIMEOUT_MS / 60_000;
133
+
134
+ /**
135
+ * Default inactivity timeout. Real SDK lifecycle activity resets this window;
136
+ * the runner enforces a fixed absolute cap (HARD_TIMEOUT_MS).
137
+ *
138
+ * Reads PI_SUBAGENT_INACTIVITY_TIMEOUT_MINS (default 3 min, range 1–60) and
139
+ * PI_SUBAGENT_HARD_TIMEOUT_MINS (default 20 min, range 1–60) from env via
140
+ * getNumericEnvVar, which warns on invalid/out-of-range values. The hard cap
141
+ * is additionally clamped up to the inactivity window: a lifetime cap smaller
142
+ * than the idle window is nonsensical.
143
+ */
144
+ const INACTIVITY_TIMEOUT_MINS = getNumericEnvVar("PI_SUBAGENT_INACTIVITY_TIMEOUT_MINS", 3, MIN_TIMEOUT_MINS, MAX_TIMEOUT_MINS);
145
+ let hardTimeoutMins = getNumericEnvVar("PI_SUBAGENT_HARD_TIMEOUT_MINS", 20, MIN_TIMEOUT_MINS, MAX_TIMEOUT_MINS);
146
+ if (hardTimeoutMins < INACTIVITY_TIMEOUT_MINS) {
147
+ warn(
148
+ `PI_SUBAGENT_HARD_TIMEOUT_MINS (${hardTimeoutMins}) is below the inactivity window (${INACTIVITY_TIMEOUT_MINS}); raising hard cap to ${INACTIVITY_TIMEOUT_MINS}.`,
149
+ );
150
+ hardTimeoutMins = INACTIVITY_TIMEOUT_MINS;
151
+ }
152
+ export const DEFAULT_TIMEOUT_MS = INACTIVITY_TIMEOUT_MINS * 60 * 1_000;
153
+ export const HARD_TIMEOUT_MS = hardTimeoutMins * 60 * 1_000;
154
+
79
155
  // ---------------------------------------------------------------------------
80
156
  // Canonical result status
81
157
  // ---------------------------------------------------------------------------
@@ -35,6 +35,12 @@ export interface ThreadViewerCallbacks {
35
35
 
36
36
  /** Viewport height for the overlay (estimated lines). Must be > 3. */
37
37
  const OVERLAY_HEIGHT = 24;
38
+ const borderSize = 1;
39
+ const truncateFit = function (text: string, width: number): string {
40
+ text = " " + text;
41
+ return truncateToWidth(text, width - ((borderSize*2) +1), "...", true) + " ";
42
+
43
+ }
38
44
 
39
45
  export class ThreadViewer {
40
46
  private thread: SubagentThread;
@@ -44,13 +50,17 @@ export class ThreadViewer {
44
50
  private cachedWidth?: number;
45
51
  private cachedUpdatedAt?: number;
46
52
  private cachedLines?: string[];
53
+ private lastThreadId: string | null = null;
47
54
 
48
55
  constructor(thread: SubagentThread, callbacks: ThreadViewerCallbacks, theme: ViewerTheme) {
49
56
  this.thread = thread;
50
57
  this.callbacks = callbacks;
51
58
  this.theme = theme;
59
+ this.lastThreadId = thread.id;
60
+
52
61
  }
53
62
 
63
+
54
64
  handleInput(data: string): void {
55
65
  if (matchesKey(data, Key.escape)) {
56
66
  this.callbacks.onClose();
@@ -58,14 +68,12 @@ export class ThreadViewer {
58
68
  }
59
69
  if (matchesKey(data, Key.alt("left"))) {
60
70
  if (this.callbacks.hasPrev) {
61
- this.scrollOffset = 0;
62
71
  this.callbacks.onPrev();
63
72
  }
64
73
  return;
65
74
  }
66
75
  if (matchesKey(data, Key.alt("right"))) {
67
76
  if (this.callbacks.hasNext) {
68
- this.scrollOffset = 0;
69
77
  this.callbacks.onNext();
70
78
  }
71
79
  return;
@@ -131,19 +139,19 @@ export class ThreadViewer {
131
139
  const reasonColor = result.stopReason === "timeout" ? "warning" : "error";
132
140
  header += ` ${t.fg(reasonColor, `[${result.stopReason}]`)}`;
133
141
  }
134
- lines.push(truncateToWidth(header, width));
142
+ lines.push(truncateFit(header, width));
135
143
 
136
144
  // Error message
137
145
  if (result && isErr && result.errorMessage) {
138
146
  const msgColor = result.stopReason === "timeout" ? "warning" : "error";
139
- lines.push(truncateToWidth(t.fg(msgColor, `Error: ${result.errorMessage}`), width));
147
+ lines.push(truncateFit(t.fg(msgColor, `Error: ${result.errorMessage}`), width));
140
148
  }
141
149
 
142
150
  lines.push("");
143
151
 
144
152
  // Task
145
- lines.push(truncateToWidth(t.fg("muted", "─── Task ───"), width));
146
- lines.push(truncateToWidth(t.fg("dim", this.thread.task), width));
153
+ lines.push(truncateFit(t.fg("muted", "─── Task ───"), width));
154
+ lines.push(truncateFit(t.fg("dim", this.thread.task), width));
147
155
  lines.push("");
148
156
 
149
157
  if (status === "running") {
@@ -152,18 +160,18 @@ export class ThreadViewer {
152
160
  const activity = this.thread.lastActivityAt ? `${Math.floor((now - this.thread.lastActivityAt) / 1000)}s ago (${this.thread.lastActivityLabel})` : "none yet";
153
161
  const idleMs = this.thread.inactivityDeadline ? this.thread.inactivityDeadline - now : 0;
154
162
  const idle = this.thread.inactivityDeadline ? `${Math.max(0, Math.ceil(idleMs / 1000))}s remaining` : "pending";
155
- lines.push(truncateToWidth(t.fg(idleMs < 30_000 ? "warning" : "muted", `Elapsed ${elapsed}s · last activity ${activity} · idle ${idle}`), width));
163
+ lines.push(truncateFit(t.fg(idleMs < 30_000 ? "warning" : "muted", `Elapsed ${elapsed}s · last activity ${activity} · idle ${idle}`), width));
156
164
  }
157
165
  if (status === "running" && (!result || result.messages.length === 0)) {
158
- lines.push(truncateToWidth(t.fg("muted", "(waiting for first message...)"), width));
166
+ lines.push(truncateFit(t.fg("muted", "(waiting for first message...)"), width));
159
167
  } else if (result) {
160
168
  const displayItems = getDisplayItems(result.messages);
161
169
  const finalOutput = getFinalOutput(result.messages);
162
170
 
163
- lines.push(truncateToWidth(t.fg("muted", "─── Output ───"), width));
171
+ lines.push(truncateFit(t.fg("muted", "─── Output ───"), width));
164
172
 
165
173
  if (displayItems.length === 0 && !finalOutput) {
166
- lines.push(truncateToWidth(t.fg("muted", "(no output)"), width));
174
+ lines.push(truncateFit(t.fg("muted", "(no output)"), width));
167
175
  } else {
168
176
  const mdTheme = getMarkdownTheme();
169
177
 
@@ -171,7 +179,7 @@ export class ThreadViewer {
171
179
  for (const item of displayItems) {
172
180
  if (item.type === "toolCall") {
173
181
  lines.push(
174
- truncateToWidth(
182
+ truncateFit(
175
183
  t.fg("muted", "→ ") + formatToolCall(item.name, item.args, t.fg.bind(t)),
176
184
  width,
177
185
  ),
@@ -182,7 +190,7 @@ export class ThreadViewer {
182
190
  const md = new Markdown(item.text.trim(), 0, 0, mdTheme);
183
191
  const mdLines = md.render(contentWidth);
184
192
  for (const mdLine of mdLines) {
185
- lines.push(` ${truncateToWidth(mdLine, contentWidth)}`);
193
+ lines.push(` ${truncateFit(mdLine, contentWidth)}`);
186
194
  }
187
195
  }
188
196
  }
@@ -194,7 +202,7 @@ export class ThreadViewer {
194
202
  const contentWidth = Math.max(1, width - 2);
195
203
  const md = new Markdown(finalOutput.trim(), 0, 0, mdTheme);
196
204
  for (const mdLine of md.render(contentWidth)) {
197
- lines.push(` ${truncateToWidth(mdLine, contentWidth)}`);
205
+ lines.push(` ${truncateFit(mdLine, contentWidth)}`);
198
206
  }
199
207
  }
200
208
  }
@@ -203,7 +211,7 @@ export class ThreadViewer {
203
211
  const usageStr = formatUsageStats(result.usage, result.model);
204
212
  if (usageStr) {
205
213
  lines.push("");
206
- lines.push(truncateToWidth(t.fg("dim", usageStr), width));
214
+ lines.push(truncateFit(t.fg("dim", usageStr), width));
207
215
  }
208
216
  }
209
217
 
@@ -215,7 +223,7 @@ export class ThreadViewer {
215
223
  if (this.callbacks.hasPrev) navParts.push("alt+← prev");
216
224
  if (this.callbacks.hasNext) navParts.push("alt+→ next");
217
225
  navParts.push("↑↓ scroll");
218
- lines.push(truncateToWidth(t.fg("dim", navParts.join(" · ")), width));
226
+ lines.push(truncateFit(t.fg("dim", navParts.join(" · ")), width));
219
227
 
220
228
  this.cachedLines = lines;
221
229
  this.cachedWidth = width;
@@ -227,6 +235,7 @@ export class ThreadViewer {
227
235
  private renderVisible(allLines: string[], width: number): string[] {
228
236
  const total = allLines.length;
229
237
  const maxVisible = Math.max(3, OVERLAY_HEIGHT);
238
+ const color = this.thread.color ?? "accent";
230
239
 
231
240
  // Clamp scrollOffset so the last page shows a full viewport minus one indicator line
232
241
  const maxOffset =
@@ -246,21 +255,47 @@ export class ThreadViewer {
246
255
 
247
256
  // Scroll indicator at top
248
257
  if (aboveShown) {
249
- visible.unshift(truncateToWidth(
250
- this.theme.fg("muted", `↑ ${offset} more lines above`),
258
+ const abmsg = this.theme.fg(color, `↑ ${offset}`) + this.theme.fg("muted", ` more lines above`);
259
+ visible.unshift(truncateFit(
260
+ abmsg,
251
261
  width,
252
262
  ));
253
263
  }
254
264
  // Scroll indicator at bottom
255
265
  if (belowShown) {
256
266
  const remaining = total - offset - bodyHeight;
257
- visible.push(truncateToWidth(
258
- this.theme.fg("muted", `↓ ${remaining} more lines below`),
267
+ const remmsg = this.theme.fg(color, `↓ ${remaining}`) + this.theme.fg("muted", ` more lines below`);
268
+ visible.push(truncateFit(
269
+ remmsg,
259
270
  width,
260
271
  ));
261
272
  }
262
273
 
263
- return visible;
274
+ // Add border around the visible content
275
+ const borderWidth = width - 2;
276
+ const borderedLines: string[] = [];
277
+
278
+
279
+ // Top border with colored characters
280
+ const borderColor = this.theme.fg(color, "─");
281
+ const cornerColor = this.theme.fg(color, "┌");
282
+ const bottomCornerColor = this.theme.fg(color, "└");
283
+ const sideBorder = this.theme.fg(color, "│");
284
+ borderedLines.push(cornerColor + borderColor.repeat(borderWidth) + this.theme.fg(color, "┐"));
285
+
286
+ // Content with side borders
287
+ for (const line of visible) {
288
+ let borderedLine = sideBorder + line + sideBorder;
289
+ if (line.trim() === "") {
290
+ borderedLine = sideBorder + " ".repeat(Math.max(0, width - ( borderSize * 2))) + sideBorder;
291
+ }
292
+ borderedLines.push(borderedLine);
293
+ }
294
+
295
+ // Bottom border with colored characters
296
+ borderedLines.push(bottomCornerColor + borderColor.repeat(borderWidth) + this.theme.fg(color, "┘"));
297
+
298
+ return borderedLines;
264
299
  }
265
300
 
266
301
  invalidate(): void {
@@ -273,7 +308,11 @@ export class ThreadViewer {
273
308
  setThread(thread: SubagentThread, callbacks: ThreadViewerCallbacks): void {
274
309
  this.thread = thread;
275
310
  this.callbacks = callbacks;
276
- this.scrollOffset = 0;
311
+ // Only reset scrollOffset when switching to a different thread
312
+ if (this.lastThreadId !== thread.id) {
313
+ this.scrollOffset = 0;
314
+ this.lastThreadId = thread.id;
315
+ }
277
316
  this.invalidate();
278
317
  }
279
318
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bacnh85/pi-subagent",
3
- "version": "0.15.1",
3
+ "version": "0.15.3",
4
4
  "description": "In-process subagents for Pi with isolated SDK sessions, parallel and chained delegation, and inspectable threads.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -73,9 +73,15 @@
73
73
  "@earendil-works/pi-tui": "^0.84.0",
74
74
  "@types/mocha": "^10.0.10",
75
75
  "@types/node": "^20.19.43",
76
- "mocha": "^10.8.2",
76
+ "mocha": "^11.8.0",
77
77
  "tsx": "^4.22.4",
78
78
  "typescript": "^5.9.3",
79
79
  "typebox": "^1.3.1"
80
+ },
81
+ "overrides": {
82
+ "serialize-javascript@>=5.0.0 <7.0.5": "^7.0.5",
83
+ "js-yaml@>=4.0.0 <4.3.1": "^4.3.1",
84
+ "brace-expansion@>=2.0.0 <2.1.4": "^2.1.4",
85
+ "diff": "^8.0.3"
80
86
  }
81
87
  }