@tintinweb/pi-subagents 0.7.0 → 0.7.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.
@@ -9,6 +9,8 @@ import { type Component, type TUI } from "@mariozechner/pi-tui";
9
9
  import type { AgentRecord } from "../types.js";
10
10
  import type { Theme } from "./agent-widget.js";
11
11
  import { type AgentActivity } from "./agent-widget.js";
12
+ /** Height ceiling shared by the overlay's `maxHeight` and the viewer's internal viewport cap. */
13
+ export declare const VIEWPORT_HEIGHT_PCT = 70;
12
14
  export declare class ConversationViewer implements Component {
13
15
  private tui;
14
16
  private session;
@@ -27,5 +29,7 @@ export declare class ConversationViewer implements Component {
27
29
  invalidate(): void;
28
30
  dispose(): void;
29
31
  private viewportHeight;
32
+ private chromeLines;
33
+ private invocationLine;
30
34
  private buildContentLines;
31
35
  }
@@ -7,10 +7,12 @@
7
7
  import { matchesKey, truncateToWidth, visibleWidth, wrapTextWithAnsi } from "@mariozechner/pi-tui";
8
8
  import { extractText } from "../context.js";
9
9
  import { getLifetimeTotal, getSessionContextPercent } from "../usage.js";
10
- import { describeActivity, formatDuration, formatSessionTokens, getDisplayName, getPromptModeLabel } from "./agent-widget.js";
11
- /** Lines consumed by chrome: top border + header + header sep + footer sep + footer + bottom border. */
12
- const CHROME_LINES = 6;
10
+ import { buildInvocationTags, describeActivity, formatDuration, formatSessionTokens, getDisplayName, getPromptModeLabel } from "./agent-widget.js";
11
+ /** Base lines consumed by chrome: top border + header + header sep + footer sep + footer + bottom border. */
12
+ const CHROME_LINES_BASE = 6;
13
13
  const MIN_VIEWPORT = 3;
14
+ /** Height ceiling shared by the overlay's `maxHeight` and the viewer's internal viewport cap. */
15
+ export const VIEWPORT_HEIGHT_PCT = 70;
14
16
  export class ConversationViewer {
15
17
  tui;
16
18
  session;
@@ -53,11 +55,11 @@ export class ConversationViewer {
53
55
  this.scrollOffset = Math.min(maxScroll, this.scrollOffset + 1);
54
56
  this.autoScroll = this.scrollOffset >= maxScroll;
55
57
  }
56
- else if (matchesKey(data, "pageUp")) {
58
+ else if (matchesKey(data, "pageUp") || matchesKey(data, "shift+up")) {
57
59
  this.scrollOffset = Math.max(0, this.scrollOffset - viewportHeight);
58
60
  this.autoScroll = false;
59
61
  }
60
- else if (matchesKey(data, "pageDown")) {
62
+ else if (matchesKey(data, "pageDown") || matchesKey(data, "shift+down")) {
61
63
  this.scrollOffset = Math.min(maxScroll, this.scrollOffset + viewportHeight);
62
64
  this.autoScroll = this.scrollOffset >= maxScroll;
63
65
  }
@@ -108,6 +110,9 @@ export class ConversationViewer {
108
110
  headerParts.push(formatSessionTokens(tokens, percent, th, this.record.compactionCount));
109
111
  }
110
112
  lines.push(row(`${statusIcon} ${th.bold(name)}${modeTag} ${th.fg("muted", this.record.description)} ${th.fg("dim", "·")} ${th.fg("dim", headerParts.join(" · "))}`));
113
+ const invocationLine = this.invocationLine();
114
+ if (invocationLine)
115
+ lines.push(row(invocationLine));
111
116
  lines.push(hrMid);
112
117
  // Content area — rebuild every render (live data, no cache needed)
113
118
  const contentLines = this.buildContentLines(innerW);
@@ -127,7 +132,7 @@ export class ConversationViewer {
127
132
  ? "100%"
128
133
  : `${Math.round(((visibleStart + viewportHeight) / contentLines.length) * 100)}%`;
129
134
  const footerLeft = th.fg("dim", `${contentLines.length} lines · ${scrollPct}`);
130
- const footerRight = th.fg("dim", "↑↓ scroll · PgUp/PgDn · Esc close");
135
+ const footerRight = th.fg("dim", "↑↓ scroll · PgUp/PgDn or Shift+↑↓ · Esc close");
131
136
  const footerGap = Math.max(1, innerW - visibleWidth(footerLeft) - visibleWidth(footerRight));
132
137
  lines.push(row(footerLeft + " ".repeat(footerGap) + footerRight));
133
138
  lines.push(hrBot);
@@ -143,7 +148,20 @@ export class ConversationViewer {
143
148
  }
144
149
  // ---- Private ----
145
150
  viewportHeight() {
146
- return Math.max(MIN_VIEWPORT, this.tui.terminal.rows - CHROME_LINES);
151
+ // Cap mirrors the overlay's maxHeight — otherwise the viewer would render
152
+ // more lines than the overlay shows and clip the footer.
153
+ const maxRows = Math.floor((this.tui.terminal.rows * VIEWPORT_HEIGHT_PCT) / 100);
154
+ return Math.max(MIN_VIEWPORT, maxRows - this.chromeLines());
155
+ }
156
+ chromeLines() {
157
+ return CHROME_LINES_BASE + (this.invocationLine() ? 1 : 0);
158
+ }
159
+ invocationLine() {
160
+ const { modelName, tags } = buildInvocationTags(this.record.invocation);
161
+ const parts = modelName ? [modelName, ...tags] : tags;
162
+ if (parts.length === 0)
163
+ return undefined;
164
+ return this.theme.fg("dim", ` ↳ ${parts.join(" · ")}`);
147
165
  }
148
166
  buildContentLines(width) {
149
167
  if (width <= 0)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tintinweb/pi-subagents",
3
- "version": "0.7.0",
3
+ "version": "0.7.2",
4
4
  "description": "A pi extension extension that brings smart Claude Code-style autonomous sub-agents to pi.",
5
5
  "author": "tintinweb",
6
6
  "license": "MIT",
@@ -10,7 +10,7 @@ import { randomUUID } from "node:crypto";
10
10
  import type { Model } from "@mariozechner/pi-ai";
11
11
  import type { AgentSession, ExtensionAPI, ExtensionContext } from "@mariozechner/pi-coding-agent";
12
12
  import { resumeAgent, runAgent, type ToolActivity } from "./agent-runner.js";
13
- import type { AgentRecord, IsolationMode, SubagentType, ThinkingLevel } from "./types.js";
13
+ import type { AgentInvocation, AgentRecord, IsolationMode, SubagentType, ThinkingLevel } from "./types.js";
14
14
  import { addUsage } from "./usage.js";
15
15
  import { cleanupWorktree, createWorktree, pruneWorktrees, } from "./worktree.js";
16
16
 
@@ -46,6 +46,8 @@ interface SpawnOptions {
46
46
  bypassQueue?: boolean;
47
47
  /** Isolation mode — "worktree" creates a temp git worktree for the agent. */
48
48
  isolation?: IsolationMode;
49
+ /** Resolved invocation snapshot captured for UI display. */
50
+ invocation?: AgentInvocation;
49
51
  /** Parent abort signal — when aborted, the subagent is also stopped. */
50
52
  signal?: AbortSignal;
51
53
  /** Called on tool start/end with activity info (for streaming progress to UI). */
@@ -87,6 +89,7 @@ export class AgentManager {
87
89
  this.maxConcurrent = maxConcurrent;
88
90
  // Cleanup completed agents after 10 minutes (but keep sessions for resume)
89
91
  this.cleanupInterval = setInterval(() => this.cleanup(), 60_000);
92
+ this.cleanupInterval.unref();
90
93
  }
91
94
 
92
95
  /** Update the max concurrent background agents limit. */
@@ -123,6 +126,7 @@ export class AgentManager {
123
126
  abortController,
124
127
  lifetimeUsage: { input: 0, output: 0, cacheWrite: 0 },
125
128
  compactionCount: 0,
129
+ invocation: options.invocation,
126
130
  };
127
131
  this.agents.set(id, record);
128
132
 
@@ -134,12 +138,35 @@ export class AgentManager {
134
138
  return id;
135
139
  }
136
140
 
137
- this.startAgent(id, record, args);
141
+ // startAgent can throw (e.g. strict worktree-isolation failure) — clean
142
+ // up the record so callers don't see an orphan in `listAgents()`.
143
+ try {
144
+ this.startAgent(id, record, args);
145
+ } catch (err) {
146
+ this.agents.delete(id);
147
+ throw err;
148
+ }
138
149
  return id;
139
150
  }
140
151
 
141
152
  /** Actually start an agent (called immediately or from queue drain). */
142
153
  private startAgent(id: string, record: AgentRecord, { pi, ctx, type, prompt, options }: SpawnArgs) {
154
+ // Worktree isolation: try to create a temporary git worktree. Strict —
155
+ // fail loud if not possible (no silent fallback to main tree). Done
156
+ // BEFORE state mutation so a throw doesn't leave the record half-running.
157
+ let worktreeCwd: string | undefined;
158
+ if (options.isolation === "worktree") {
159
+ const wt = createWorktree(ctx.cwd, id);
160
+ if (!wt) {
161
+ throw new Error(
162
+ 'Cannot run with isolation: "worktree" — not a git repo, no commits yet, or `git worktree add` failed. ' +
163
+ 'Initialize git and commit at least once, or omit `isolation`.',
164
+ );
165
+ }
166
+ record.worktree = wt;
167
+ worktreeCwd = wt.path;
168
+ }
169
+
143
170
  record.status = "running";
144
171
  record.startedAt = Date.now();
145
172
  if (options.isBackground) this.runningBackground++;
@@ -154,23 +181,7 @@ export class AgentManager {
154
181
  }
155
182
  const detach = () => { detachParentSignal?.(); detachParentSignal = undefined; };
156
183
 
157
- // Worktree isolation: create a temporary git worktree if requested
158
- let worktreeCwd: string | undefined;
159
- let worktreeWarning = "";
160
- if (options.isolation === "worktree") {
161
- const wt = createWorktree(ctx.cwd, id);
162
- if (wt) {
163
- record.worktree = wt;
164
- worktreeCwd = wt.path;
165
- } else {
166
- worktreeWarning = "\n\n[WARNING: Worktree isolation was requested but failed (not a git repo, or no commits yet). Running in the main working directory instead.]";
167
- }
168
- }
169
-
170
- // Prepend worktree warning to prompt if isolation failed
171
- const effectivePrompt = worktreeWarning ? worktreeWarning + "\n\n" + prompt : prompt;
172
-
173
- const promise = runAgent(ctx, type, effectivePrompt, {
184
+ const promise = runAgent(ctx, type, prompt, {
174
185
  pi,
175
186
  model: options.model,
176
187
  maxTurns: options.maxTurns,
@@ -235,7 +246,7 @@ export class AgentManager {
235
246
 
236
247
  if (options.isBackground) {
237
248
  this.runningBackground--;
238
- this.onComplete?.(record);
249
+ try { this.onComplete?.(record); } catch { /* ignore completion side-effect errors */ }
239
250
  this.drainQueue();
240
251
  }
241
252
  return responseText;
@@ -281,7 +292,16 @@ export class AgentManager {
281
292
  const next = this.queue.shift()!;
282
293
  const record = this.agents.get(next.id);
283
294
  if (!record || record.status !== "queued") continue;
284
- this.startAgent(next.id, record, next.args);
295
+ try {
296
+ this.startAgent(next.id, record, next.args);
297
+ } catch (err) {
298
+ // Late failure (e.g. strict worktree-isolation) — surface on the record
299
+ // so the user/agent can see it via /agents, then keep draining.
300
+ record.status = "error";
301
+ record.error = err instanceof Error ? err.message : String(err);
302
+ record.completedAt = Date.now();
303
+ this.onComplete?.(record);
304
+ }
285
305
  }
286
306
  }
287
307
 
package/src/index.ts CHANGED
@@ -27,11 +27,12 @@ import { createOutputFilePath, streamToOutputFile, writeInitialEntry } from "./o
27
27
  import { SubagentScheduler } from "./schedule.js";
28
28
  import { resolveStorePath, ScheduleStore } from "./schedule-store.js";
29
29
  import { applyAndEmitLoaded, type SubagentsSettings, saveAndEmitChanged } from "./settings.js";
30
- import { type AgentConfig, type AgentRecord, type JoinMode, type NotificationDetails, type SubagentType } from "./types.js";
30
+ import { type AgentConfig, type AgentInvocation, type AgentRecord, type JoinMode, type NotificationDetails, type SubagentType } from "./types.js";
31
31
  import {
32
32
  type AgentActivity,
33
33
  type AgentDetails,
34
34
  AgentWidget,
35
+ buildInvocationTags,
35
36
  describeActivity,
36
37
  formatDuration,
37
38
  formatMs,
@@ -275,7 +276,7 @@ export default function (pi: ExtensionAPI) {
275
276
  cancelNudge(key);
276
277
  pendingNudges.set(key, setTimeout(() => {
277
278
  pendingNudges.delete(key);
278
- send();
279
+ try { send(); } catch { /* ignore stale completion side-effect errors */ }
279
280
  }, delay));
280
281
  }
281
282
 
@@ -847,25 +848,32 @@ Guidelines:
847
848
  const isolated = resolvedConfig.isolated;
848
849
  const isolation = resolvedConfig.isolation;
849
850
 
850
- // Build display tags for non-default config
851
851
  const parentModelId = ctx.model?.id;
852
852
  const effectiveModelId = model?.id;
853
- const agentModelName = effectiveModelId && effectiveModelId !== parentModelId
853
+ const modelName = effectiveModelId && effectiveModelId !== parentModelId
854
854
  ? (model?.name ?? effectiveModelId).replace(/^Claude\s+/i, "").toLowerCase()
855
855
  : undefined;
856
- const agentTags: string[] = [];
857
- const modeLabel = getPromptModeLabel(subagentType);
858
- if (modeLabel) agentTags.push(modeLabel);
859
- if (thinking) agentTags.push(`thinking: ${thinking}`);
860
- if (isolated) agentTags.push("isolated");
861
- if (isolation === "worktree") agentTags.push("worktree");
862
856
  const effectiveMaxTurns = normalizeMaxTurns(resolvedConfig.maxTurns ?? getDefaultMaxTurns());
863
- // Shared base fields for all AgentDetails in this call
857
+ const agentInvocation: AgentInvocation = {
858
+ modelName,
859
+ thinking,
860
+ // Explicit value only — the default fallback would just add noise.
861
+ // Normalize so `0` (unlimited) doesn't surface as a misleading "max turns: 0".
862
+ maxTurns: normalizeMaxTurns(resolvedConfig.maxTurns),
863
+ isolated,
864
+ inheritContext,
865
+ runInBackground,
866
+ isolation,
867
+ };
868
+ // Tool-result render shows the mode label too; viewer's header already does.
869
+ const modeLabel = getPromptModeLabel(subagentType);
870
+ const { tags: invocationTags } = buildInvocationTags(agentInvocation);
871
+ const agentTags = modeLabel ? [modeLabel, ...invocationTags] : invocationTags;
864
872
  const detailBase = {
865
873
  displayName,
866
874
  description: params.description,
867
875
  subagentType,
868
- modelName: agentModelName,
876
+ modelName,
869
877
  tags: agentTags.length > 0 ? agentTags : undefined,
870
878
  };
871
879
 
@@ -946,17 +954,22 @@ Guidelines:
946
954
  }
947
955
  };
948
956
 
949
- id = manager.spawn(pi, ctx, subagentType, params.prompt, {
950
- description: params.description,
951
- model,
952
- maxTurns: effectiveMaxTurns,
953
- isolated,
954
- inheritContext,
955
- thinkingLevel: thinking,
956
- isBackground: true,
957
- isolation,
958
- ...bgCallbacks,
959
- });
957
+ try {
958
+ id = manager.spawn(pi, ctx, subagentType, params.prompt, {
959
+ description: params.description,
960
+ model,
961
+ maxTurns: effectiveMaxTurns,
962
+ isolated,
963
+ inheritContext,
964
+ thinkingLevel: thinking,
965
+ isBackground: true,
966
+ isolation,
967
+ invocation: agentInvocation,
968
+ ...bgCallbacks,
969
+ });
970
+ } catch (err) {
971
+ return textResult(err instanceof Error ? err.message : String(err));
972
+ }
960
973
 
961
974
  // Set output file + join mode synchronously after spawn, before the
962
975
  // event loop yields — onSessionCreated is async so this is safe.
@@ -1054,17 +1067,24 @@ Guidelines:
1054
1067
 
1055
1068
  streamUpdate();
1056
1069
 
1057
- const record = await manager.spawnAndWait(pi, ctx, subagentType, params.prompt, {
1058
- description: params.description,
1059
- model,
1060
- maxTurns: effectiveMaxTurns,
1061
- isolated,
1062
- inheritContext,
1063
- thinkingLevel: thinking,
1064
- isolation,
1065
- signal,
1066
- ...fgCallbacks,
1067
- });
1070
+ let record: AgentRecord;
1071
+ try {
1072
+ record = await manager.spawnAndWait(pi, ctx, subagentType, params.prompt, {
1073
+ description: params.description,
1074
+ model,
1075
+ maxTurns: effectiveMaxTurns,
1076
+ isolated,
1077
+ inheritContext,
1078
+ thinkingLevel: thinking,
1079
+ isolation,
1080
+ invocation: agentInvocation,
1081
+ signal,
1082
+ ...fgCallbacks,
1083
+ });
1084
+ } catch (err) {
1085
+ clearInterval(spinnerInterval);
1086
+ return textResult(err instanceof Error ? err.message : String(err));
1087
+ }
1068
1088
 
1069
1089
  clearInterval(spinnerInterval);
1070
1090
 
@@ -1396,7 +1416,7 @@ Guidelines:
1396
1416
  return;
1397
1417
  }
1398
1418
 
1399
- const { ConversationViewer } = await import("./ui/conversation-viewer.js");
1419
+ const { ConversationViewer, VIEWPORT_HEIGHT_PCT } = await import("./ui/conversation-viewer.js");
1400
1420
  const session = record.session;
1401
1421
  const activity = agentActivity.get(record.id);
1402
1422
 
@@ -1406,7 +1426,7 @@ Guidelines:
1406
1426
  },
1407
1427
  {
1408
1428
  overlay: true,
1409
- overlayOptions: { anchor: "center", width: "90%" },
1429
+ overlayOptions: { anchor: "center", width: "90%", maxHeight: `${VIEWPORT_HEIGHT_PCT}%` },
1410
1430
  },
1411
1431
  );
1412
1432
  }
@@ -1,79 +1,102 @@
1
1
  /**
2
- * skill-loader.ts — Preload specific skill files and inject their content into the system prompt.
2
+ * skill-loader.ts — Preload named skills.
3
3
  *
4
- * When skills is a string[], reads each named skill from .pi/skills/ or ~/.pi/skills/
5
- * and returns their content for injection into the agent's system prompt.
4
+ * Roots, in precedence order:
5
+ * - <cwd>/.pi/skills (project, Pi's standard)
6
+ * - <cwd>/.agents/skills (project, cross-tool Agent Skills spec — https://agentskills.io)
7
+ * - getAgentDir()/skills (user, default ~/.pi/agent/skills — Pi's standard)
8
+ * - ~/.agents/skills (user, cross-tool Agent Skills spec)
9
+ * - ~/.pi/skills (legacy global, pre-Pi)
10
+ *
11
+ * Layout per root:
12
+ * - <root>/<name>.md (flat file at the top level)
13
+ * - <root>/.../<name>/SKILL.md (directory skill, may be nested — Pi's standard)
14
+ *
15
+ * Recursion skips dotfile entries and node_modules. A directory that itself contains
16
+ * SKILL.md is a skill — we don't descend into it (Pi: skills don't nest).
17
+ *
18
+ * Symlinks are rejected for security (deviation from Pi, which follows them).
6
19
  */
7
20
 
21
+ import type { Dirent } from "node:fs";
22
+ import { existsSync, readdirSync } from "node:fs";
8
23
  import { homedir } from "node:os";
9
24
  import { join } from "node:path";
10
- import { isUnsafeName, safeReadFile } from "./memory.js";
25
+ import { getAgentDir } from "@mariozechner/pi-coding-agent";
26
+ import { isSymlink, isUnsafeName, safeReadFile } from "./memory.js";
11
27
 
12
28
  export interface PreloadedSkill {
13
29
  name: string;
14
30
  content: string;
15
31
  }
16
32
 
17
- /**
18
- * Attempt to load named skills from project and global skill directories.
19
- * Looks for: <dir>/<name>.md, <dir>/<name>.txt, <dir>/<name>
20
- *
21
- * @param skillNames List of skill names to preload.
22
- * @param cwd Working directory for project-level skills.
23
- * @returns Array of loaded skills (missing skills are skipped with a warning comment).
24
- */
25
33
  export function preloadSkills(skillNames: string[], cwd: string): PreloadedSkill[] {
26
- const results: PreloadedSkill[] = [];
34
+ return skillNames.map((name) => ({ name, content: loadSkillContent(name, cwd) }));
35
+ }
27
36
 
28
- for (const name of skillNames) {
29
- // Unlike memory (which throws on unsafe names because it's part of agent setup),
30
- // skills are optional skip gracefully to avoid blocking agent startup.
31
- if (isUnsafeName(name)) {
32
- results.push({ name, content: `(Skill "${name}" skipped: name contains path traversal characters)` });
33
- continue;
34
- }
35
- const content = findAndReadSkill(name, cwd);
36
- if (content !== undefined) {
37
- results.push({ name, content });
38
- } else {
39
- // Include a note about missing skills so the agent knows it was requested but not found
40
- results.push({ name, content: `(Skill "${name}" not found in .pi/skills/ or ~/.pi/skills/)` });
41
- }
37
+ function loadSkillContent(name: string, cwd: string): string {
38
+ if (isUnsafeName(name)) {
39
+ return `(Skill "${name}" skipped: name contains path traversal characters)`;
42
40
  }
41
+ const roots = [
42
+ join(cwd, ".pi", "skills"), // project — Pi standard
43
+ join(cwd, ".agents", "skills"), // project — Agent Skills spec
44
+ join(getAgentDir(), "skills"), // user — Pi standard
45
+ join(homedir(), ".agents", "skills"), // user — Agent Skills spec
46
+ join(homedir(), ".pi", "skills"), // legacy global, pre-Pi
47
+ ];
48
+ for (const root of roots) {
49
+ const content = findInRoot(root, name);
50
+ if (content !== undefined) return content;
51
+ }
52
+ return `(Skill "${name}" not found in .pi/skills/, .agents/skills/, or global skill locations)`;
53
+ }
43
54
 
44
- return results;
55
+ function findInRoot(root: string, name: string): string | undefined {
56
+ if (isSymlink(root)) return undefined; // reject symlinked roots entirely
57
+ const flat = safeReadFile(join(root, `${name}.md`))?.trim();
58
+ if (flat !== undefined) return flat;
59
+ return findSkillDirectory(root, name);
45
60
  }
46
61
 
47
- /**
48
- * Search for a skill file in project and global directories.
49
- * Project-level takes priority over global.
50
- */
51
- function findAndReadSkill(name: string, cwd: string): string | undefined {
52
- const projectDir = join(cwd, ".pi", "skills");
53
- const globalDir = join(homedir(), ".pi", "skills");
62
+ /** BFS under `root` for a directory named `name` containing `SKILL.md`. Pi-conforming filters. */
63
+ function findSkillDirectory(root: string, name: string): string | undefined {
64
+ if (!existsSync(root)) return undefined;
65
+ const queue: string[] = [root];
54
66
 
55
- // Try project first, then global
56
- for (const dir of [projectDir, globalDir]) {
57
- const content = tryReadSkillFile(dir, name);
58
- if (content !== undefined) return content;
59
- }
67
+ while (queue.length > 0) {
68
+ const current = queue.shift();
69
+ if (current === undefined) continue;
60
70
 
61
- return undefined;
62
- }
71
+ let entries: Dirent<string>[];
72
+ try {
73
+ entries = readdirSync(current, { withFileTypes: true });
74
+ } catch {
75
+ continue;
76
+ }
63
77
 
64
- /**
65
- * Try to read a skill file from a directory.
66
- * Tries extensions in order: .md, .txt, (no extension)
67
- */
68
- function tryReadSkillFile(dir: string, name: string): string | undefined {
69
- const extensions = [".md", ".txt", ""];
78
+ // Deterministic byte-order traversal — locale-independent.
79
+ entries.sort((a, b) => (a.name < b.name ? -1 : a.name > b.name ? 1 : 0));
70
80
 
71
- for (const ext of extensions) {
72
- const path = join(dir, name + ext);
73
- // safeReadFile rejects symlinks to prevent reading arbitrary files
74
- const content = safeReadFile(path);
75
- if (content !== undefined) return content.trim();
76
- }
81
+ for (const entry of entries) {
82
+ if (!entry.isDirectory()) continue;
83
+ if (entry.name.startsWith(".") || entry.name === "node_modules") continue;
84
+
85
+ // Symlinked dirs already filtered by entry.isDirectory() — Dirent uses lstat semantics.
86
+ const path = join(current, entry.name);
87
+ const skillMd = join(path, "SKILL.md");
88
+ const isSkillDir = existsSync(skillMd);
77
89
 
90
+ if (isSkillDir) {
91
+ if (entry.name === name) {
92
+ const content = safeReadFile(skillMd)?.trim();
93
+ if (content !== undefined) return content;
94
+ }
95
+ continue; // Pi rule: skills don't nest — don't descend into a skill dir
96
+ }
97
+
98
+ queue.push(path);
99
+ }
100
+ }
78
101
  return undefined;
79
102
  }
package/src/types.ts CHANGED
@@ -94,6 +94,19 @@ export interface AgentRecord {
94
94
  lifetimeUsage: LifetimeUsage;
95
95
  /** Number of times this agent's session has compacted. Initialized to 0 at spawn. */
96
96
  compactionCount: number;
97
+ /** Resolved spawn params, captured for UI display. Fixed at spawn time. */
98
+ invocation?: AgentInvocation;
99
+ }
100
+
101
+ export interface AgentInvocation {
102
+ /** Short display name, e.g. "haiku" — only set when different from parent. */
103
+ modelName?: string;
104
+ thinking?: ThinkingLevel;
105
+ maxTurns?: number;
106
+ isolated?: boolean;
107
+ inheritContext?: boolean;
108
+ runInBackground?: boolean;
109
+ isolation?: IsolationMode;
97
110
  }
98
111
 
99
112
  /** Details attached to custom notification messages for visual rendering. */
@@ -8,7 +8,7 @@
8
8
  import { truncateToWidth } from "@mariozechner/pi-tui";
9
9
  import type { AgentManager } from "../agent-manager.js";
10
10
  import { getConfig } from "../agent-types.js";
11
- import type { SubagentType } from "../types.js";
11
+ import type { AgentInvocation, SubagentType } from "../types.js";
12
12
  import { getLifetimeTotal, getSessionContextPercent, type LifetimeUsage, type SessionLike } from "../usage.js";
13
13
 
14
14
  // ---- Constants ----
@@ -153,6 +153,21 @@ export function getPromptModeLabel(type: SubagentType): string | undefined {
153
153
  return config.promptMode === "append" ? "twin" : undefined;
154
154
  }
155
155
 
156
+ /** Mode label is not included — callers add it where they want it. */
157
+ export function buildInvocationTags(
158
+ invocation: AgentInvocation | undefined,
159
+ ): { modelName?: string; tags: string[] } {
160
+ const tags: string[] = [];
161
+ if (!invocation) return { tags };
162
+ if (invocation.thinking) tags.push(`thinking: ${invocation.thinking}`);
163
+ if (invocation.isolated) tags.push("isolated");
164
+ if (invocation.isolation === "worktree") tags.push("worktree");
165
+ if (invocation.inheritContext) tags.push("inherit context");
166
+ if (invocation.runInBackground) tags.push("background");
167
+ if (invocation.maxTurns != null) tags.push(`max turns: ${invocation.maxTurns}`);
168
+ return { modelName: invocation.modelName, tags };
169
+ }
170
+
156
171
  /** Truncate text to a single line, max `len` chars. */
157
172
  function truncateLine(text: string, len = 60): string {
158
173
  const line = text.split("\n").find(l => l.trim())?.trim() ?? "";
@@ -11,11 +11,13 @@ import { extractText } from "../context.js";
11
11
  import type { AgentRecord } from "../types.js";
12
12
  import { getLifetimeTotal, getSessionContextPercent } from "../usage.js";
13
13
  import type { Theme } from "./agent-widget.js";
14
- import { type AgentActivity, describeActivity, formatDuration, formatSessionTokens, getDisplayName, getPromptModeLabel } from "./agent-widget.js";
14
+ import { type AgentActivity, buildInvocationTags, describeActivity, formatDuration, formatSessionTokens, getDisplayName, getPromptModeLabel } from "./agent-widget.js";
15
15
 
16
- /** Lines consumed by chrome: top border + header + header sep + footer sep + footer + bottom border. */
17
- const CHROME_LINES = 6;
16
+ /** Base lines consumed by chrome: top border + header + header sep + footer sep + footer + bottom border. */
17
+ const CHROME_LINES_BASE = 6;
18
18
  const MIN_VIEWPORT = 3;
19
+ /** Height ceiling shared by the overlay's `maxHeight` and the viewer's internal viewport cap. */
20
+ export const VIEWPORT_HEIGHT_PCT = 70;
19
21
 
20
22
  export class ConversationViewer implements Component {
21
23
  private scrollOffset = 0;
@@ -55,10 +57,10 @@ export class ConversationViewer implements Component {
55
57
  } else if (matchesKey(data, "down") || matchesKey(data, "j")) {
56
58
  this.scrollOffset = Math.min(maxScroll, this.scrollOffset + 1);
57
59
  this.autoScroll = this.scrollOffset >= maxScroll;
58
- } else if (matchesKey(data, "pageUp")) {
60
+ } else if (matchesKey(data, "pageUp") || matchesKey(data, "shift+up")) {
59
61
  this.scrollOffset = Math.max(0, this.scrollOffset - viewportHeight);
60
62
  this.autoScroll = false;
61
- } else if (matchesKey(data, "pageDown")) {
63
+ } else if (matchesKey(data, "pageDown") || matchesKey(data, "shift+down")) {
62
64
  this.scrollOffset = Math.min(maxScroll, this.scrollOffset + viewportHeight);
63
65
  this.autoScroll = this.scrollOffset >= maxScroll;
64
66
  } else if (matchesKey(data, "home")) {
@@ -113,6 +115,8 @@ export class ConversationViewer implements Component {
113
115
  lines.push(row(
114
116
  `${statusIcon} ${th.bold(name)}${modeTag} ${th.fg("muted", this.record.description)} ${th.fg("dim", "·")} ${th.fg("dim", headerParts.join(" · "))}`,
115
117
  ));
118
+ const invocationLine = this.invocationLine();
119
+ if (invocationLine) lines.push(row(invocationLine));
116
120
  lines.push(hrMid);
117
121
 
118
122
  // Content area — rebuild every render (live data, no cache needed)
@@ -137,7 +141,7 @@ export class ConversationViewer implements Component {
137
141
  ? "100%"
138
142
  : `${Math.round(((visibleStart + viewportHeight) / contentLines.length) * 100)}%`;
139
143
  const footerLeft = th.fg("dim", `${contentLines.length} lines · ${scrollPct}`);
140
- const footerRight = th.fg("dim", "↑↓ scroll · PgUp/PgDn · Esc close");
144
+ const footerRight = th.fg("dim", "↑↓ scroll · PgUp/PgDn or Shift+↑↓ · Esc close");
141
145
  const footerGap = Math.max(1, innerW - visibleWidth(footerLeft) - visibleWidth(footerRight));
142
146
  lines.push(row(footerLeft + " ".repeat(footerGap) + footerRight));
143
147
  lines.push(hrBot);
@@ -158,7 +162,21 @@ export class ConversationViewer implements Component {
158
162
  // ---- Private ----
159
163
 
160
164
  private viewportHeight(): number {
161
- return Math.max(MIN_VIEWPORT, this.tui.terminal.rows - CHROME_LINES);
165
+ // Cap mirrors the overlay's maxHeight — otherwise the viewer would render
166
+ // more lines than the overlay shows and clip the footer.
167
+ const maxRows = Math.floor((this.tui.terminal.rows * VIEWPORT_HEIGHT_PCT) / 100);
168
+ return Math.max(MIN_VIEWPORT, maxRows - this.chromeLines());
169
+ }
170
+
171
+ private chromeLines(): number {
172
+ return CHROME_LINES_BASE + (this.invocationLine() ? 1 : 0);
173
+ }
174
+
175
+ private invocationLine(): string | undefined {
176
+ const { modelName, tags } = buildInvocationTags(this.record.invocation);
177
+ const parts = modelName ? [modelName, ...tags] : tags;
178
+ if (parts.length === 0) return undefined;
179
+ return this.theme.fg("dim", ` ↳ ${parts.join(" · ")}`);
162
180
  }
163
181
 
164
182
  private buildContentLines(width: number): string[] {