@bacnh85/pi-subagent 0.15.0 → 0.15.2

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,20 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.15.2 (2026-08-18)
4
+
5
+ ### Improvements
6
+
7
+ - Colored borders around thread viewer overlay using agent color.
8
+ - Colored scroll-indicator arrows (↑/↓) matching agent color.
9
+ - Scroll offset only resets when switching to a different thread, not on every refresh.
10
+
11
+ ## 0.15.1 (2026-08-17)
12
+
13
+ ### Improvements
14
+
15
+ - Project-local agent approval is now a single select — **Allow once / Trust for this session / Deny** — instead of a yes/no confirm repeated on every delegation. "Trust for this session" remembers the project agents dir for the session (cleared on `session_start`); dismissed dialogs and Deny cancel the delegation. Headless sessions still fail closed.
16
+ - New test coverage for the approval gate (Deny / dismissed / headless / trust-remembering / session_start clearing).
17
+
3
18
  ## 0.15.0 (2026-08-09)
4
19
 
5
20
  ### Packaging
package/README.md CHANGED
@@ -91,7 +91,7 @@ Threads are session-memory only and are cleared when Pi replaces or reloads the
91
91
  Agent files under `.pi/agents/` are controlled by the current repository. A project agent's system prompt may instruct a child to execute shell commands or modify files.
92
92
 
93
93
  - **Project-agent approval cannot be disabled by the model.** The `confirmProjectAgents` parameter is not exposed in the tool schema. Confirmation policy comes from trusted user configuration only.
94
- - **Interactive sessions** prompt the user before executing project agents.
94
+ - **Interactive sessions** prompt the user before executing project agents (**Allow once / Trust for this session / Deny**); "Trust for this session" remembers the project agents dir until the session ends. Dismissed dialogs and Deny cancel the delegation.
95
95
  - **Headless sessions fail closed.** Project agents are not executed without UI confirmation unless the trusted setting `allowUnconfirmedProjectAgents` is enabled (via `PI_SUBAGENT_ALLOW_UNCONFIRMED_PROJECT_AGENTS=true` environment variable or pi settings).
96
96
  - **The extension service path** (`pi-subagent:run` event) follows the same policy.
97
97
 
package/agents/planner.md CHANGED
@@ -3,7 +3,7 @@ name: planner
3
3
  description: Read-only planning and architecture specialist. Use for consequential design, tradeoff analysis, and implementation plans.
4
4
  tools: read, grep, find, ls
5
5
  models:
6
- - zai-coding-cn/glm-5.2
6
+ - zai-coding-cn/glm-5.3
7
7
  - openrouter/nvidia/nemotron-3-ultra-550b-a55b:free
8
8
  - opencode-go/deepseek-v4-pro
9
9
  thinking: high
@@ -3,7 +3,7 @@ name: reviewer
3
3
  description: Code review specialist. Use for correctness, security, regression, and meaningful test-gap review.
4
4
  tools: read, grep, find, ls
5
5
  models:
6
- - zai-coding-cn/glm-5.2
6
+ - zai-coding-cn/glm-5.3
7
7
  - openrouter/nvidia/nemotron-3-ultra-550b-a55b:free
8
8
  - opencode-go/deepseek-v4-pro
9
9
  thinking: high
@@ -95,6 +95,9 @@ function getTrustedConfig(ctx: ExtensionContext): { allowUnconfirmedProjectAgent
95
95
  };
96
96
  }
97
97
 
98
+ /** Session-scoped approvals for project-local agents ("Trust for this session"). */
99
+ const trustedProjectAgentDirs = new Set<string>();
100
+
98
101
 
99
102
  // ---------------------------------------------------------------------------
100
103
  // Tool parameter schema
@@ -186,6 +189,7 @@ export default function (pi: ExtensionAPI) {
186
189
  currentCtx = ctx;
187
190
  if (event.reason === "reload") invalidateAgentCache();
188
191
  threadStore.clear();
192
+ trustedProjectAgentDirs.clear();
189
193
  // Clear any widget from a prior session.
190
194
  widget.clearWidgetIfIdle();
191
195
  // Mark prior-session running tasks as interrupted (we can't resume them).
@@ -591,19 +595,22 @@ export default function (pi: ExtensionAPI) {
591
595
 
592
596
  if (projectAgentsRequested.length > 0) {
593
597
  if (confirmProjectAgents) {
594
- if (ctx.hasUI) {
598
+ const dir = discovery.projectAgentsDir ?? "(unknown)";
599
+ if (trustedProjectAgentDirs.has(dir)) {
600
+ // Previously approved "Trust for this session" for this agents dir.
601
+ } else if (ctx.hasUI) {
595
602
  const names = projectAgentsRequested.map((a) => a.name).join(", ");
596
- const dir = discovery.projectAgentsDir ?? "(unknown)";
597
- const ok = await ctx.ui.confirm(
598
- "Run project-local agents?",
599
- `Agents: ${names}\nSource: ${dir}\n\nProject agents are repo-controlled. Only continue for trusted repositories.`,
603
+ const choice = await ctx.ui.select(
604
+ `Run project-local agents?\n\nAgents: ${names}\nSource: ${dir}\n\nProject agents are repo-controlled. Only continue for trusted repositories.`,
605
+ ["Allow once", "Trust for this session", "Deny"],
600
606
  );
601
- if (!ok) {
607
+ if (choice !== "Allow once" && choice !== "Trust for this session") {
602
608
  return {
603
609
  content: [{ type: "text", text: "Canceled: project-local agents not approved." }],
604
610
  details: makeDetails(hasChain ? "chain" : hasTasks ? "parallel" : "single")([]),
605
611
  };
606
612
  }
613
+ if (choice === "Trust for this session") trustedProjectAgentDirs.add(dir);
607
614
  } else {
608
615
  // Fail closed in headless sessions.
609
616
  return {
@@ -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.0",
3
+ "version": "0.15.2",
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",