@tintinweb/pi-subagents 0.7.1 → 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.
- package/CHANGELOG.md +22 -0
- package/README.md +23 -5
- package/dist/agent-manager.d.ts +3 -1
- package/dist/agent-manager.js +1 -0
- package/dist/index.js +22 -17
- package/dist/skill-loader.d.ts +16 -11
- package/dist/skill-loader.js +79 -53
- package/dist/types.d.ts +12 -0
- package/dist/ui/agent-widget.d.ts +6 -1
- package/dist/ui/agent-widget.js +19 -0
- package/dist/ui/conversation-viewer.d.ts +4 -0
- package/dist/ui/conversation-viewer.js +25 -7
- package/package.json +1 -1
- package/src/agent-manager.ts +4 -1
- package/src/index.ts +23 -13
- package/src/skill-loader.ts +77 -54
- package/src/types.ts +13 -0
- package/src/ui/agent-widget.ts +16 -1
- package/src/ui/conversation-viewer.ts +25 -7
package/CHANGELOG.md
CHANGED
|
@@ -7,6 +7,28 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
7
7
|
|
|
8
8
|
## [Unreleased]
|
|
9
9
|
|
|
10
|
+
## [0.7.2] - 2026-05-12
|
|
11
|
+
|
|
12
|
+
> **Heads-up — behavior changes in skill preloading:**
|
|
13
|
+
> - **`.txt` and extensionless flat skill files are no longer loaded.** Only `<name>.md` flat files and `<name>/SKILL.md` directory skills resolve now. Rename any `<name>.txt` or extensionless skill files to `<name>.md`.
|
|
14
|
+
|
|
15
|
+
### Added
|
|
16
|
+
- **Pi-standard `<name>/SKILL.md` directory layout** is now discovered alongside flat `<name>.md` files. Top-level and nested matches both resolve via BFS — for skill `foo`, the loader checks `<root>/foo/SKILL.md`, then recursively descends looking for `*/.../foo/SKILL.md`. Recursion skips dotfile directories and `node_modules`; a directory that itself contains `SKILL.md` is treated as a single skill (Pi's "skills don't nest" rule).
|
|
17
|
+
- **Five discovery roots**, checked in precedence order:
|
|
18
|
+
- `<cwd>/.pi/skills/` (project, Pi)
|
|
19
|
+
- `<cwd>/.agents/skills/` (project, [Agent Skills spec](https://agentskills.io/integrate-skills))
|
|
20
|
+
- `$PI_CODING_AGENT_DIR/skills/` — default `~/.pi/agent/skills/` (user, Pi)
|
|
21
|
+
- `~/.agents/skills/` (user, Agent Skills spec)
|
|
22
|
+
- `~/.pi/skills/` (legacy global, kept for backward compatibility)
|
|
23
|
+
- **Symlink rejection broadened** to the new layouts: symlinked skill roots, nested skill directories, and `SKILL.md` files inside otherwise-real directories are all rejected (intentional deviation from Pi, which follows symlinks).
|
|
24
|
+
- **Deterministic traversal order** — entries are sorted byte-order so collisions resolve identically across filesystems. Pi's iteration order is `readdirSync`-dependent.
|
|
25
|
+
- **Resolved spawn args are now shown in the dedicated conversation viewer** ([#62](https://github.com/tintinweb/pi-subagents/issues/62)). Open `/subagent` → Running Agents → select an agent: a second header row displays the effective invocation — model override (when different from parent), `thinking: <level>`, `isolated`, `worktree`, `inherit context`, `background`, and `max turns: N`. Tags appear when the resolved value is notable (e.g. `isolated: true`), not just when the caller explicitly set it; `max turns` is the one exception and shows only when explicitly configured. Lets you verify the parent agent honored your spawn instructions without scrolling back through the chat. Snapshot stored on the new `AgentRecord.invocation` field. The same tag set is also surfaced on the `Agent` tool-call result render (which previously showed a narrower subset).
|
|
26
|
+
- **`Shift+↑` / `Shift+↓` scroll a full page in the conversation viewer** — same behavior as `PgUp` / `PgDn`. Note: some terminal emulators intercept Shift+arrows for text selection or tab switching, in which case `PgUp`/`PgDn` remain available.
|
|
27
|
+
|
|
28
|
+
### Changed
|
|
29
|
+
- **`.txt` and extensionless flat skill files are no longer loaded.** Pi only supports `.md`; we now match. **Migration:** rename any `<name>.txt` / `<name>` skill files to `<name>.md`.
|
|
30
|
+
- **Conversation viewer no longer fills the full screen.** The overlay is now capped at 70% of terminal height (90% width unchanged), and the viewer's internal viewport mirrors that cap so the footer/scroll indicator can't be clipped.
|
|
31
|
+
|
|
10
32
|
## [0.7.1] - 2026-05-07
|
|
11
33
|
|
|
12
34
|
> **Heads-up — behavior change:**
|
package/README.md
CHANGED
|
@@ -25,7 +25,7 @@ https://github.com/user-attachments/assets/8685261b-9338-4fea-8dfe-1c590d5df543
|
|
|
25
25
|
- **Context inheritance** — optionally fork the parent conversation into a sub-agent so it knows what's been discussed
|
|
26
26
|
- **Persistent agent memory** — three scopes (project, local, user) with automatic read-only fallback for agents without write tools
|
|
27
27
|
- **Git worktree isolation** — run agents in isolated repo copies; changes auto-committed to branches on completion
|
|
28
|
-
- **Skill preloading** — inject named
|
|
28
|
+
- **Skill preloading** — inject named skills into agent system prompts, discovered from `.pi/skills/`, `.agents/skills/`, and global locations (Pi-standard `<name>/SKILL.md` directory layout supported)
|
|
29
29
|
- **Tool denylist** — block specific tools via `disallowed_tools` frontmatter
|
|
30
30
|
- **Styled completion notifications** — background agent results render as themed, compact notification boxes (icon, stats, result preview) instead of raw XML. Expandable to show full output. Group completions render each agent individually
|
|
31
31
|
- **Event bus** — lifecycle events (`subagents:created`, `started`, `completed`, `failed`, `steered`, `compacted`) emitted via `pi.events`, enabling other extensions to react to sub-agent activity
|
|
@@ -195,7 +195,7 @@ All fields are optional — sensible defaults for everything.
|
|
|
195
195
|
| `display_name` | — | Display name for UI (e.g. widget, agent list) |
|
|
196
196
|
| `tools` | all 7 | Comma-separated built-in tools: read, bash, edit, write, grep, find, ls. `none` for no tools |
|
|
197
197
|
| `extensions` | `true` | Inherit MCP/extension tools. `false` to disable |
|
|
198
|
-
| `skills` | `true` | Inherit skills from parent. Can be a comma-separated list of skill names to preload
|
|
198
|
+
| `skills` | `true` | Inherit skills from parent. Can be a comma-separated list of skill names to preload (see [Skill Preloading](#skill-preloading) for discovery locations) |
|
|
199
199
|
| `memory` | — | Persistent agent memory scope: `project`, `local`, or `user`. Auto-detects read-only agents |
|
|
200
200
|
| `disallowed_tools` | — | Comma-separated tools to deny even if extensions provide them |
|
|
201
201
|
| `isolation` | — | Set to `worktree` to run in an isolated git worktree |
|
|
@@ -457,7 +457,7 @@ If the worktree cannot be created (not a git repo, no commits, or `git worktree
|
|
|
457
457
|
|
|
458
458
|
## Skill Preloading
|
|
459
459
|
|
|
460
|
-
Skills can be preloaded
|
|
460
|
+
Skills can be preloaded by name and injected into the agent's system prompt:
|
|
461
461
|
|
|
462
462
|
```yaml
|
|
463
463
|
---
|
|
@@ -465,7 +465,25 @@ skills: api-conventions, error-handling
|
|
|
465
465
|
---
|
|
466
466
|
```
|
|
467
467
|
|
|
468
|
-
|
|
468
|
+
**Discovery roots** (checked in this order, first match wins):
|
|
469
|
+
|
|
470
|
+
| Scope | Path | Source |
|
|
471
|
+
|---|---|---|
|
|
472
|
+
| Project | `<cwd>/.pi/skills/` | Pi-standard |
|
|
473
|
+
| Project | `<cwd>/.agents/skills/` | [Agent Skills spec](https://agentskills.io/integrate-skills) |
|
|
474
|
+
| User | `$PI_CODING_AGENT_DIR/skills/` (default `~/.pi/agent/skills/`) | Pi-standard |
|
|
475
|
+
| User | `~/.agents/skills/` | [Agent Skills spec](https://agentskills.io/integrate-skills) |
|
|
476
|
+
| User | `~/.pi/skills/` | Legacy (pre-Pi) |
|
|
477
|
+
|
|
478
|
+
**Per root, a skill named `foo` resolves to the first of:**
|
|
479
|
+
|
|
480
|
+
- `<root>/foo.md` — flat file at the top level
|
|
481
|
+
- `<root>/foo/SKILL.md` — directory skill (top-level)
|
|
482
|
+
- `<root>/*/.../foo/SKILL.md` — directory skill, found by recursive descent
|
|
483
|
+
|
|
484
|
+
Recursion skips dotfile directories and `node_modules`. A directory that itself contains a `SKILL.md` is treated as a single skill — we don't descend into it. Traversal is byte-order sorted for deterministic resolution across filesystems.
|
|
485
|
+
|
|
486
|
+
**Security:** symlinks are rejected at every layer (root, flat file, skill directory, `SKILL.md` inside a skill directory) — intentional deviation from Pi, which follows symlinks. Skill names with path-traversal characters (`..`, `/`, `\`, spaces, leading dot, >128 chars) are rejected.
|
|
469
487
|
|
|
470
488
|
## Tool Denylist
|
|
471
489
|
|
|
@@ -494,7 +512,7 @@ src/
|
|
|
494
512
|
group-join.ts # Group join manager: batched completion notifications with timeout
|
|
495
513
|
custom-agents.ts # Load user-defined agents from .pi/agents/*.md
|
|
496
514
|
memory.ts # Persistent agent memory (resolve, read, build prompt blocks)
|
|
497
|
-
skill-loader.ts # Preload
|
|
515
|
+
skill-loader.ts # Preload skills (Pi-standard + Agent Skills spec layouts)
|
|
498
516
|
output-file.ts # Streaming output file transcripts for agent sessions
|
|
499
517
|
worktree.ts # Git worktree isolation (create, cleanup, prune)
|
|
500
518
|
prompts.ts # Config-driven system prompt builder
|
package/dist/agent-manager.d.ts
CHANGED
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
import type { Model } from "@mariozechner/pi-ai";
|
|
9
9
|
import type { AgentSession, ExtensionAPI, ExtensionContext } from "@mariozechner/pi-coding-agent";
|
|
10
10
|
import { type ToolActivity } from "./agent-runner.js";
|
|
11
|
-
import type { AgentRecord, IsolationMode, SubagentType, ThinkingLevel } from "./types.js";
|
|
11
|
+
import type { AgentInvocation, AgentRecord, IsolationMode, SubagentType, ThinkingLevel } from "./types.js";
|
|
12
12
|
export type OnAgentComplete = (record: AgentRecord) => void;
|
|
13
13
|
export type OnAgentStart = (record: AgentRecord) => void;
|
|
14
14
|
export type OnAgentCompact = (record: AgentRecord, info: CompactionInfo) => void;
|
|
@@ -32,6 +32,8 @@ interface SpawnOptions {
|
|
|
32
32
|
bypassQueue?: boolean;
|
|
33
33
|
/** Isolation mode — "worktree" creates a temp git worktree for the agent. */
|
|
34
34
|
isolation?: IsolationMode;
|
|
35
|
+
/** Resolved invocation snapshot captured for UI display. */
|
|
36
|
+
invocation?: AgentInvocation;
|
|
35
37
|
/** Parent abort signal — when aborted, the subagent is also stopped. */
|
|
36
38
|
signal?: AbortSignal;
|
|
37
39
|
/** Called on tool start/end with activity info (for streaming progress to UI). */
|
package/dist/agent-manager.js
CHANGED
package/dist/index.js
CHANGED
|
@@ -26,7 +26,7 @@ import { createOutputFilePath, streamToOutputFile, writeInitialEntry } from "./o
|
|
|
26
26
|
import { SubagentScheduler } from "./schedule.js";
|
|
27
27
|
import { resolveStorePath, ScheduleStore } from "./schedule-store.js";
|
|
28
28
|
import { applyAndEmitLoaded, saveAndEmitChanged } from "./settings.js";
|
|
29
|
-
import { AgentWidget, describeActivity, formatDuration, formatMs, formatTokens, formatTurns, getDisplayName, getPromptModeLabel, SPINNER, } from "./ui/agent-widget.js";
|
|
29
|
+
import { AgentWidget, buildInvocationTags, describeActivity, formatDuration, formatMs, formatTokens, formatTurns, getDisplayName, getPromptModeLabel, SPINNER, } from "./ui/agent-widget.js";
|
|
30
30
|
import { showSchedulesMenu } from "./ui/schedule-menu.js";
|
|
31
31
|
import { addUsage, getLifetimeTotal, getSessionContextPercent } from "./usage.js";
|
|
32
32
|
// ---- Shared helpers ----
|
|
@@ -739,29 +739,32 @@ Guidelines:
|
|
|
739
739
|
const runInBackground = resolvedConfig.runInBackground;
|
|
740
740
|
const isolated = resolvedConfig.isolated;
|
|
741
741
|
const isolation = resolvedConfig.isolation;
|
|
742
|
-
// Build display tags for non-default config
|
|
743
742
|
const parentModelId = ctx.model?.id;
|
|
744
743
|
const effectiveModelId = model?.id;
|
|
745
|
-
const
|
|
744
|
+
const modelName = effectiveModelId && effectiveModelId !== parentModelId
|
|
746
745
|
? (model?.name ?? effectiveModelId).replace(/^Claude\s+/i, "").toLowerCase()
|
|
747
746
|
: undefined;
|
|
748
|
-
const agentTags = [];
|
|
749
|
-
const modeLabel = getPromptModeLabel(subagentType);
|
|
750
|
-
if (modeLabel)
|
|
751
|
-
agentTags.push(modeLabel);
|
|
752
|
-
if (thinking)
|
|
753
|
-
agentTags.push(`thinking: ${thinking}`);
|
|
754
|
-
if (isolated)
|
|
755
|
-
agentTags.push("isolated");
|
|
756
|
-
if (isolation === "worktree")
|
|
757
|
-
agentTags.push("worktree");
|
|
758
747
|
const effectiveMaxTurns = normalizeMaxTurns(resolvedConfig.maxTurns ?? getDefaultMaxTurns());
|
|
759
|
-
|
|
748
|
+
const agentInvocation = {
|
|
749
|
+
modelName,
|
|
750
|
+
thinking,
|
|
751
|
+
// Explicit value only — the default fallback would just add noise.
|
|
752
|
+
// Normalize so `0` (unlimited) doesn't surface as a misleading "max turns: 0".
|
|
753
|
+
maxTurns: normalizeMaxTurns(resolvedConfig.maxTurns),
|
|
754
|
+
isolated,
|
|
755
|
+
inheritContext,
|
|
756
|
+
runInBackground,
|
|
757
|
+
isolation,
|
|
758
|
+
};
|
|
759
|
+
// Tool-result render shows the mode label too; viewer's header already does.
|
|
760
|
+
const modeLabel = getPromptModeLabel(subagentType);
|
|
761
|
+
const { tags: invocationTags } = buildInvocationTags(agentInvocation);
|
|
762
|
+
const agentTags = modeLabel ? [modeLabel, ...invocationTags] : invocationTags;
|
|
760
763
|
const detailBase = {
|
|
761
764
|
displayName,
|
|
762
765
|
description: params.description,
|
|
763
766
|
subagentType,
|
|
764
|
-
modelName
|
|
767
|
+
modelName,
|
|
765
768
|
tags: agentTags.length > 0 ? agentTags : undefined,
|
|
766
769
|
};
|
|
767
770
|
// ---- Schedule: register a job, don't spawn now ----
|
|
@@ -843,6 +846,7 @@ Guidelines:
|
|
|
843
846
|
thinkingLevel: thinking,
|
|
844
847
|
isBackground: true,
|
|
845
848
|
isolation,
|
|
849
|
+
invocation: agentInvocation,
|
|
846
850
|
...bgCallbacks,
|
|
847
851
|
});
|
|
848
852
|
}
|
|
@@ -943,6 +947,7 @@ Guidelines:
|
|
|
943
947
|
inheritContext,
|
|
944
948
|
thinkingLevel: thinking,
|
|
945
949
|
isolation,
|
|
950
|
+
invocation: agentInvocation,
|
|
946
951
|
signal,
|
|
947
952
|
...fgCallbacks,
|
|
948
953
|
});
|
|
@@ -1250,14 +1255,14 @@ Guidelines:
|
|
|
1250
1255
|
ctx.ui.notify(`Agent is ${record.status === "queued" ? "queued" : "expired"} — no session available.`, "info");
|
|
1251
1256
|
return;
|
|
1252
1257
|
}
|
|
1253
|
-
const { ConversationViewer } = await import("./ui/conversation-viewer.js");
|
|
1258
|
+
const { ConversationViewer, VIEWPORT_HEIGHT_PCT } = await import("./ui/conversation-viewer.js");
|
|
1254
1259
|
const session = record.session;
|
|
1255
1260
|
const activity = agentActivity.get(record.id);
|
|
1256
1261
|
await ctx.ui.custom((tui, theme, _keybindings, done) => {
|
|
1257
1262
|
return new ConversationViewer(tui, session, record, activity, theme, done);
|
|
1258
1263
|
}, {
|
|
1259
1264
|
overlay: true,
|
|
1260
|
-
overlayOptions: { anchor: "center", width: "90%" },
|
|
1265
|
+
overlayOptions: { anchor: "center", width: "90%", maxHeight: `${VIEWPORT_HEIGHT_PCT}%` },
|
|
1261
1266
|
});
|
|
1262
1267
|
}
|
|
1263
1268
|
async function showAgentDetail(ctx, name) {
|
package/dist/skill-loader.d.ts
CHANGED
|
@@ -1,19 +1,24 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* skill-loader.ts — Preload
|
|
2
|
+
* skill-loader.ts — Preload named skills.
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
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
|
export interface PreloadedSkill {
|
|
8
21
|
name: string;
|
|
9
22
|
content: string;
|
|
10
23
|
}
|
|
11
|
-
/**
|
|
12
|
-
* Attempt to load named skills from project and global skill directories.
|
|
13
|
-
* Looks for: <dir>/<name>.md, <dir>/<name>.txt, <dir>/<name>
|
|
14
|
-
*
|
|
15
|
-
* @param skillNames List of skill names to preload.
|
|
16
|
-
* @param cwd Working directory for project-level skills.
|
|
17
|
-
* @returns Array of loaded skills (missing skills are skipped with a warning comment).
|
|
18
|
-
*/
|
|
19
24
|
export declare function preloadSkills(skillNames: string[], cwd: string): PreloadedSkill[];
|
package/dist/skill-loader.js
CHANGED
|
@@ -1,67 +1,93 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* skill-loader.ts — Preload
|
|
2
|
+
* skill-loader.ts — Preload named skills.
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
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
|
*/
|
|
20
|
+
import { existsSync, readdirSync } from "node:fs";
|
|
7
21
|
import { homedir } from "node:os";
|
|
8
22
|
import { join } from "node:path";
|
|
9
|
-
import {
|
|
10
|
-
|
|
11
|
-
* Attempt to load named skills from project and global skill directories.
|
|
12
|
-
* Looks for: <dir>/<name>.md, <dir>/<name>.txt, <dir>/<name>
|
|
13
|
-
*
|
|
14
|
-
* @param skillNames List of skill names to preload.
|
|
15
|
-
* @param cwd Working directory for project-level skills.
|
|
16
|
-
* @returns Array of loaded skills (missing skills are skipped with a warning comment).
|
|
17
|
-
*/
|
|
23
|
+
import { getAgentDir } from "@mariozechner/pi-coding-agent";
|
|
24
|
+
import { isSymlink, isUnsafeName, safeReadFile } from "./memory.js";
|
|
18
25
|
export function preloadSkills(skillNames, cwd) {
|
|
19
|
-
|
|
20
|
-
for (const name of skillNames) {
|
|
21
|
-
// Unlike memory (which throws on unsafe names because it's part of agent setup),
|
|
22
|
-
// skills are optional — skip gracefully to avoid blocking agent startup.
|
|
23
|
-
if (isUnsafeName(name)) {
|
|
24
|
-
results.push({ name, content: `(Skill "${name}" skipped: name contains path traversal characters)` });
|
|
25
|
-
continue;
|
|
26
|
-
}
|
|
27
|
-
const content = findAndReadSkill(name, cwd);
|
|
28
|
-
if (content !== undefined) {
|
|
29
|
-
results.push({ name, content });
|
|
30
|
-
}
|
|
31
|
-
else {
|
|
32
|
-
// Include a note about missing skills so the agent knows it was requested but not found
|
|
33
|
-
results.push({ name, content: `(Skill "${name}" not found in .pi/skills/ or ~/.pi/skills/)` });
|
|
34
|
-
}
|
|
35
|
-
}
|
|
36
|
-
return results;
|
|
26
|
+
return skillNames.map((name) => ({ name, content: loadSkillContent(name, cwd) }));
|
|
37
27
|
}
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
28
|
+
function loadSkillContent(name, cwd) {
|
|
29
|
+
if (isUnsafeName(name)) {
|
|
30
|
+
return `(Skill "${name}" skipped: name contains path traversal characters)`;
|
|
31
|
+
}
|
|
32
|
+
const roots = [
|
|
33
|
+
join(cwd, ".pi", "skills"), // project — Pi standard
|
|
34
|
+
join(cwd, ".agents", "skills"), // project — Agent Skills spec
|
|
35
|
+
join(getAgentDir(), "skills"), // user — Pi standard
|
|
36
|
+
join(homedir(), ".agents", "skills"), // user — Agent Skills spec
|
|
37
|
+
join(homedir(), ".pi", "skills"), // legacy global, pre-Pi
|
|
38
|
+
];
|
|
39
|
+
for (const root of roots) {
|
|
40
|
+
const content = findInRoot(root, name);
|
|
48
41
|
if (content !== undefined)
|
|
49
42
|
return content;
|
|
50
43
|
}
|
|
51
|
-
return
|
|
44
|
+
return `(Skill "${name}" not found in .pi/skills/, .agents/skills/, or global skill locations)`;
|
|
52
45
|
}
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
46
|
+
function findInRoot(root, name) {
|
|
47
|
+
if (isSymlink(root))
|
|
48
|
+
return undefined; // reject symlinked roots entirely
|
|
49
|
+
const flat = safeReadFile(join(root, `${name}.md`))?.trim();
|
|
50
|
+
if (flat !== undefined)
|
|
51
|
+
return flat;
|
|
52
|
+
return findSkillDirectory(root, name);
|
|
53
|
+
}
|
|
54
|
+
/** BFS under `root` for a directory named `name` containing `SKILL.md`. Pi-conforming filters. */
|
|
55
|
+
function findSkillDirectory(root, name) {
|
|
56
|
+
if (!existsSync(root))
|
|
57
|
+
return undefined;
|
|
58
|
+
const queue = [root];
|
|
59
|
+
while (queue.length > 0) {
|
|
60
|
+
const current = queue.shift();
|
|
61
|
+
if (current === undefined)
|
|
62
|
+
continue;
|
|
63
|
+
let entries;
|
|
64
|
+
try {
|
|
65
|
+
entries = readdirSync(current, { withFileTypes: true });
|
|
66
|
+
}
|
|
67
|
+
catch {
|
|
68
|
+
continue;
|
|
69
|
+
}
|
|
70
|
+
// Deterministic byte-order traversal — locale-independent.
|
|
71
|
+
entries.sort((a, b) => (a.name < b.name ? -1 : a.name > b.name ? 1 : 0));
|
|
72
|
+
for (const entry of entries) {
|
|
73
|
+
if (!entry.isDirectory())
|
|
74
|
+
continue;
|
|
75
|
+
if (entry.name.startsWith(".") || entry.name === "node_modules")
|
|
76
|
+
continue;
|
|
77
|
+
// Symlinked dirs already filtered by entry.isDirectory() — Dirent uses lstat semantics.
|
|
78
|
+
const path = join(current, entry.name);
|
|
79
|
+
const skillMd = join(path, "SKILL.md");
|
|
80
|
+
const isSkillDir = existsSync(skillMd);
|
|
81
|
+
if (isSkillDir) {
|
|
82
|
+
if (entry.name === name) {
|
|
83
|
+
const content = safeReadFile(skillMd)?.trim();
|
|
84
|
+
if (content !== undefined)
|
|
85
|
+
return content;
|
|
86
|
+
}
|
|
87
|
+
continue; // Pi rule: skills don't nest — don't descend into a skill dir
|
|
88
|
+
}
|
|
89
|
+
queue.push(path);
|
|
90
|
+
}
|
|
65
91
|
}
|
|
66
92
|
return undefined;
|
|
67
93
|
}
|
package/dist/types.d.ts
CHANGED
|
@@ -91,6 +91,18 @@ export interface AgentRecord {
|
|
|
91
91
|
lifetimeUsage: LifetimeUsage;
|
|
92
92
|
/** Number of times this agent's session has compacted. Initialized to 0 at spawn. */
|
|
93
93
|
compactionCount: number;
|
|
94
|
+
/** Resolved spawn params, captured for UI display. Fixed at spawn time. */
|
|
95
|
+
invocation?: AgentInvocation;
|
|
96
|
+
}
|
|
97
|
+
export interface AgentInvocation {
|
|
98
|
+
/** Short display name, e.g. "haiku" — only set when different from parent. */
|
|
99
|
+
modelName?: string;
|
|
100
|
+
thinking?: ThinkingLevel;
|
|
101
|
+
maxTurns?: number;
|
|
102
|
+
isolated?: boolean;
|
|
103
|
+
inheritContext?: boolean;
|
|
104
|
+
runInBackground?: boolean;
|
|
105
|
+
isolation?: IsolationMode;
|
|
94
106
|
}
|
|
95
107
|
/** Details attached to custom notification messages for visual rendering. */
|
|
96
108
|
export interface NotificationDetails {
|
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
* Uses the callback form of setWidget for themed rendering.
|
|
6
6
|
*/
|
|
7
7
|
import type { AgentManager } from "../agent-manager.js";
|
|
8
|
-
import type { SubagentType } from "../types.js";
|
|
8
|
+
import type { AgentInvocation, SubagentType } from "../types.js";
|
|
9
9
|
import { type LifetimeUsage, type SessionLike } from "../usage.js";
|
|
10
10
|
/** Braille spinner frames for animated running indicator. */
|
|
11
11
|
export declare const SPINNER: string[];
|
|
@@ -84,6 +84,11 @@ export declare function formatDuration(startedAt: number, completedAt?: number):
|
|
|
84
84
|
export declare function getDisplayName(type: SubagentType): string;
|
|
85
85
|
/** Short label for prompt mode: "twin" for append, nothing for replace (the default). */
|
|
86
86
|
export declare function getPromptModeLabel(type: SubagentType): string | undefined;
|
|
87
|
+
/** Mode label is not included — callers add it where they want it. */
|
|
88
|
+
export declare function buildInvocationTags(invocation: AgentInvocation | undefined): {
|
|
89
|
+
modelName?: string;
|
|
90
|
+
tags: string[];
|
|
91
|
+
};
|
|
87
92
|
/** Build a human-readable activity string from currently-running tools or response text. */
|
|
88
93
|
export declare function describeActivity(activeTools: Map<string, string>, responseText?: string): string;
|
|
89
94
|
export declare class AgentWidget {
|
package/dist/ui/agent-widget.js
CHANGED
|
@@ -80,6 +80,25 @@ export function getPromptModeLabel(type) {
|
|
|
80
80
|
const config = getConfig(type);
|
|
81
81
|
return config.promptMode === "append" ? "twin" : undefined;
|
|
82
82
|
}
|
|
83
|
+
/** Mode label is not included — callers add it where they want it. */
|
|
84
|
+
export function buildInvocationTags(invocation) {
|
|
85
|
+
const tags = [];
|
|
86
|
+
if (!invocation)
|
|
87
|
+
return { tags };
|
|
88
|
+
if (invocation.thinking)
|
|
89
|
+
tags.push(`thinking: ${invocation.thinking}`);
|
|
90
|
+
if (invocation.isolated)
|
|
91
|
+
tags.push("isolated");
|
|
92
|
+
if (invocation.isolation === "worktree")
|
|
93
|
+
tags.push("worktree");
|
|
94
|
+
if (invocation.inheritContext)
|
|
95
|
+
tags.push("inherit context");
|
|
96
|
+
if (invocation.runInBackground)
|
|
97
|
+
tags.push("background");
|
|
98
|
+
if (invocation.maxTurns != null)
|
|
99
|
+
tags.push(`max turns: ${invocation.maxTurns}`);
|
|
100
|
+
return { modelName: invocation.modelName, tags };
|
|
101
|
+
}
|
|
83
102
|
/** Truncate text to a single line, max `len` chars. */
|
|
84
103
|
function truncateLine(text, len = 60) {
|
|
85
104
|
const line = text.split("\n").find(l => l.trim())?.trim() ?? "";
|
|
@@ -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
|
-
/**
|
|
12
|
-
const
|
|
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
|
-
|
|
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
package/src/agent-manager.ts
CHANGED
|
@@ -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). */
|
|
@@ -124,6 +126,7 @@ export class AgentManager {
|
|
|
124
126
|
abortController,
|
|
125
127
|
lifetimeUsage: { input: 0, output: 0, cacheWrite: 0 },
|
|
126
128
|
compactionCount: 0,
|
|
129
|
+
invocation: options.invocation,
|
|
127
130
|
};
|
|
128
131
|
this.agents.set(id, record);
|
|
129
132
|
|
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,
|
|
@@ -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
|
|
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
|
-
|
|
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
|
|
876
|
+
modelName,
|
|
869
877
|
tags: agentTags.length > 0 ? agentTags : undefined,
|
|
870
878
|
};
|
|
871
879
|
|
|
@@ -956,6 +964,7 @@ Guidelines:
|
|
|
956
964
|
thinkingLevel: thinking,
|
|
957
965
|
isBackground: true,
|
|
958
966
|
isolation,
|
|
967
|
+
invocation: agentInvocation,
|
|
959
968
|
...bgCallbacks,
|
|
960
969
|
});
|
|
961
970
|
} catch (err) {
|
|
@@ -1068,6 +1077,7 @@ Guidelines:
|
|
|
1068
1077
|
inheritContext,
|
|
1069
1078
|
thinkingLevel: thinking,
|
|
1070
1079
|
isolation,
|
|
1080
|
+
invocation: agentInvocation,
|
|
1071
1081
|
signal,
|
|
1072
1082
|
...fgCallbacks,
|
|
1073
1083
|
});
|
|
@@ -1406,7 +1416,7 @@ Guidelines:
|
|
|
1406
1416
|
return;
|
|
1407
1417
|
}
|
|
1408
1418
|
|
|
1409
|
-
const { ConversationViewer } = await import("./ui/conversation-viewer.js");
|
|
1419
|
+
const { ConversationViewer, VIEWPORT_HEIGHT_PCT } = await import("./ui/conversation-viewer.js");
|
|
1410
1420
|
const session = record.session;
|
|
1411
1421
|
const activity = agentActivity.get(record.id);
|
|
1412
1422
|
|
|
@@ -1416,7 +1426,7 @@ Guidelines:
|
|
|
1416
1426
|
},
|
|
1417
1427
|
{
|
|
1418
1428
|
overlay: true,
|
|
1419
|
-
overlayOptions: { anchor: "center", width: "90%" },
|
|
1429
|
+
overlayOptions: { anchor: "center", width: "90%", maxHeight: `${VIEWPORT_HEIGHT_PCT}%` },
|
|
1420
1430
|
},
|
|
1421
1431
|
);
|
|
1422
1432
|
}
|
package/src/skill-loader.ts
CHANGED
|
@@ -1,79 +1,102 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* skill-loader.ts — Preload
|
|
2
|
+
* skill-loader.ts — Preload named skills.
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
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 {
|
|
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
|
-
|
|
34
|
+
return skillNames.map((name) => ({ name, content: loadSkillContent(name, cwd) }));
|
|
35
|
+
}
|
|
27
36
|
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
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
|
-
|
|
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
|
-
|
|
49
|
-
|
|
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
|
-
|
|
56
|
-
|
|
57
|
-
|
|
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
|
-
|
|
62
|
-
|
|
71
|
+
let entries: Dirent<string>[];
|
|
72
|
+
try {
|
|
73
|
+
entries = readdirSync(current, { withFileTypes: true });
|
|
74
|
+
} catch {
|
|
75
|
+
continue;
|
|
76
|
+
}
|
|
63
77
|
|
|
64
|
-
|
|
65
|
-
|
|
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
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
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. */
|
package/src/ui/agent-widget.ts
CHANGED
|
@@ -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
|
-
/**
|
|
17
|
-
const
|
|
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
|
-
|
|
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[] {
|