pi-subagents-lite 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +82 -0
- package/package.json +52 -0
- package/src/agent-discovery.ts +412 -0
- package/src/agent-manager.ts +545 -0
- package/src/agent-runner.ts +435 -0
- package/src/agent-types.ts +140 -0
- package/src/context.ts +13 -0
- package/src/default-agents.ts +67 -0
- package/src/index.ts +1356 -0
- package/src/model-precedence.ts +71 -0
- package/src/model-selector.ts +271 -0
- package/src/output-file.ts +176 -0
- package/src/prompts.ts +61 -0
- package/src/result-viewer.ts +218 -0
- package/src/skill-loader.ts +104 -0
- package/src/types.ts +96 -0
- package/src/ui/agent-widget.ts +666 -0
- package/src/usage.ts +39 -0
- package/src/utils.ts +40 -0
|
@@ -0,0 +1,218 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* result-viewer.ts — TUI scrollable markdown viewer for agent results.
|
|
3
|
+
*
|
|
4
|
+
* Used by the /agents > running agents menu to display agent results
|
|
5
|
+
* in a bordered, scrollable panel with keyboard navigation.
|
|
6
|
+
* Renders markdown so headings, code blocks, lists, etc. are styled.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import {
|
|
10
|
+
Container,
|
|
11
|
+
type Component,
|
|
12
|
+
getKeybindings,
|
|
13
|
+
Markdown,
|
|
14
|
+
Spacer,
|
|
15
|
+
Text,
|
|
16
|
+
type MarkdownTheme,
|
|
17
|
+
} from "@earendil-works/pi-tui";
|
|
18
|
+
import { DynamicBorder } from "@earendil-works/pi-coding-agent";
|
|
19
|
+
|
|
20
|
+
// Theme type from ctx.ui.custom() callback
|
|
21
|
+
type Theme = any;
|
|
22
|
+
|
|
23
|
+
/* ------------------------------------------------------------------ */
|
|
24
|
+
/* Types */
|
|
25
|
+
/* ------------------------------------------------------------------ */
|
|
26
|
+
|
|
27
|
+
export interface ResultViewerCallbacks {
|
|
28
|
+
onClose: () => void;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/* ------------------------------------------------------------------ */
|
|
32
|
+
/* ResultViewer */
|
|
33
|
+
/* ------------------------------------------------------------------ */
|
|
34
|
+
|
|
35
|
+
const PAGE_SIZE = 14;
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Build a MarkdownTheme from the TUI theme instance.
|
|
39
|
+
*/
|
|
40
|
+
function buildMarkdownTheme(theme: Theme): MarkdownTheme {
|
|
41
|
+
return {
|
|
42
|
+
heading: (text: string) => theme.fg("accent", theme.bold(text)),
|
|
43
|
+
link: (text: string) => theme.fg("accent", text),
|
|
44
|
+
linkUrl: (text: string) => theme.fg("muted", text),
|
|
45
|
+
code: (text: string) => theme.fg("accent", text),
|
|
46
|
+
codeBlock: (text: string) => text,
|
|
47
|
+
codeBlockBorder: (text: string) => theme.fg("muted", text),
|
|
48
|
+
quote: (text: string) => theme.fg("muted", text),
|
|
49
|
+
quoteBorder: (text: string) => theme.fg("muted", text),
|
|
50
|
+
hr: (text: string) => theme.fg("muted", text),
|
|
51
|
+
listBullet: (text: string) => theme.fg("accent", text),
|
|
52
|
+
bold: (text: string) => theme.bold(text),
|
|
53
|
+
italic: (text: string) => (theme.italic ? theme.italic(text) : text),
|
|
54
|
+
strikethrough: (text: string) => text,
|
|
55
|
+
underline: (text: string) => text,
|
|
56
|
+
};
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* A scrollable markdown viewer with bordered frame.
|
|
61
|
+
*
|
|
62
|
+
* Rendering:
|
|
63
|
+
* - Top border
|
|
64
|
+
* - Title bar with agent info
|
|
65
|
+
* - Separator
|
|
66
|
+
* - Paginated markdown content
|
|
67
|
+
* - Scroll position indicator (when scrollable)
|
|
68
|
+
* - Key hints footer
|
|
69
|
+
* - Bottom border
|
|
70
|
+
*
|
|
71
|
+
* Key bindings: up/down/pageup/pagedown/g/G/escape
|
|
72
|
+
*/
|
|
73
|
+
export class ResultViewer extends Container implements Component {
|
|
74
|
+
private markdown: Markdown;
|
|
75
|
+
private renderedLines: string[];
|
|
76
|
+
private viewport: Container;
|
|
77
|
+
private scrollOffset: number;
|
|
78
|
+
private theme: Theme;
|
|
79
|
+
private callbacks: ResultViewerCallbacks;
|
|
80
|
+
|
|
81
|
+
constructor(
|
|
82
|
+
title: string,
|
|
83
|
+
text: string,
|
|
84
|
+
callbacks: ResultViewerCallbacks,
|
|
85
|
+
theme: Theme,
|
|
86
|
+
) {
|
|
87
|
+
super();
|
|
88
|
+
|
|
89
|
+
this.callbacks = callbacks;
|
|
90
|
+
this.theme = theme;
|
|
91
|
+
this.scrollOffset = 0;
|
|
92
|
+
|
|
93
|
+
// Build markdown renderer (pre-render to get total lines)
|
|
94
|
+
const mdTheme = buildMarkdownTheme(theme);
|
|
95
|
+
this.markdown = new Markdown(text, 0, 0, mdTheme);
|
|
96
|
+
// Pre-render at a reasonable width to get line count
|
|
97
|
+
this.renderedLines = this.markdown.render(78);
|
|
98
|
+
|
|
99
|
+
// Build UI
|
|
100
|
+
this.addChild(new DynamicBorder());
|
|
101
|
+
this.addChild(new Spacer(1));
|
|
102
|
+
|
|
103
|
+
// Title bar
|
|
104
|
+
this.addChild(
|
|
105
|
+
new Text(this.theme.fg("accent", theme.bold(` ${title}`)), 0, 0),
|
|
106
|
+
);
|
|
107
|
+
this.addChild(new Spacer(1));
|
|
108
|
+
|
|
109
|
+
// Separator
|
|
110
|
+
this.addChild(
|
|
111
|
+
new Text(this.theme.fg("muted", "─".repeat(78)), 0, 0),
|
|
112
|
+
);
|
|
113
|
+
this.addChild(new Spacer(1));
|
|
114
|
+
|
|
115
|
+
// Scrollable viewport
|
|
116
|
+
this.viewport = new Container();
|
|
117
|
+
this.addChild(this.viewport);
|
|
118
|
+
|
|
119
|
+
// Bottom spacer + key hints + border
|
|
120
|
+
this.addChild(new Spacer(1));
|
|
121
|
+
const hints = this.theme.fg(
|
|
122
|
+
"muted",
|
|
123
|
+
" ↑↓ navigate · PgUp/PgDn · g/G top/bottom · Esc close",
|
|
124
|
+
);
|
|
125
|
+
this.addChild(new Text(hints, 0, 0));
|
|
126
|
+
this.addChild(new Spacer(1));
|
|
127
|
+
this.addChild(new DynamicBorder());
|
|
128
|
+
|
|
129
|
+
this.updateViewport();
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
handleInput(keyData: string): void {
|
|
133
|
+
const kb = getKeybindings();
|
|
134
|
+
|
|
135
|
+
// Up
|
|
136
|
+
if (kb.matches(keyData, "tui.select.up")) {
|
|
137
|
+
if (this.scrollOffset > 0) {
|
|
138
|
+
this.scrollOffset--;
|
|
139
|
+
this.updateViewport();
|
|
140
|
+
}
|
|
141
|
+
return;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
// Down
|
|
145
|
+
if (kb.matches(keyData, "tui.select.down")) {
|
|
146
|
+
if (this.scrollOffset < this.renderedLines.length - 1) {
|
|
147
|
+
this.scrollOffset++;
|
|
148
|
+
this.updateViewport();
|
|
149
|
+
}
|
|
150
|
+
return;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
// PageUp
|
|
154
|
+
if (kb.matches(keyData, "tui.select.pageUp")) {
|
|
155
|
+
this.scrollOffset = Math.max(0, this.scrollOffset - PAGE_SIZE);
|
|
156
|
+
this.updateViewport();
|
|
157
|
+
return;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
// PageDown
|
|
161
|
+
if (kb.matches(keyData, "tui.select.pageDown")) {
|
|
162
|
+
this.scrollOffset = Math.min(
|
|
163
|
+
this.renderedLines.length - 1,
|
|
164
|
+
this.scrollOffset + PAGE_SIZE,
|
|
165
|
+
);
|
|
166
|
+
this.updateViewport();
|
|
167
|
+
return;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
// 'g' — jump to top
|
|
171
|
+
if (keyData === "g") {
|
|
172
|
+
this.scrollOffset = 0;
|
|
173
|
+
this.updateViewport();
|
|
174
|
+
return;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
// 'G' — jump to bottom
|
|
178
|
+
if (keyData === "G") {
|
|
179
|
+
this.scrollOffset = this.renderedLines.length - 1;
|
|
180
|
+
this.updateViewport();
|
|
181
|
+
return;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
// Escape / Ctrl+C — close
|
|
185
|
+
if (kb.matches(keyData, "tui.select.cancel")) {
|
|
186
|
+
this.callbacks.onClose();
|
|
187
|
+
return;
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
invalidate(): void {}
|
|
192
|
+
|
|
193
|
+
private updateViewport(): void {
|
|
194
|
+
this.viewport.clear();
|
|
195
|
+
|
|
196
|
+
const visibleLines = Math.min(
|
|
197
|
+
PAGE_SIZE,
|
|
198
|
+
this.renderedLines.length - this.scrollOffset,
|
|
199
|
+
);
|
|
200
|
+
for (let i = 0; i < visibleLines; i++) {
|
|
201
|
+
const lineIdx = this.scrollOffset + i;
|
|
202
|
+
const line = this.renderedLines[lineIdx] ?? "";
|
|
203
|
+
this.viewport.addChild(new Text(line, 0, 0));
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
// Scroll position indicator
|
|
207
|
+
if (this.renderedLines.length > PAGE_SIZE) {
|
|
208
|
+
const pct = Math.round(
|
|
209
|
+
(this.scrollOffset / this.renderedLines.length) * 100,
|
|
210
|
+
);
|
|
211
|
+
const indicator = this.theme.fg(
|
|
212
|
+
"muted",
|
|
213
|
+
` (${this.scrollOffset + 1}/${this.renderedLines.length} · ${pct}%)`,
|
|
214
|
+
);
|
|
215
|
+
this.viewport.addChild(new Text(indicator, 0, 0));
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
}
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* skill-loader.ts — Preload named skills.
|
|
3
|
+
*
|
|
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).
|
|
19
|
+
*
|
|
20
|
+
* Changed from upstream: imports from ./utils.js instead of ./memory.js.
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
import type { Dirent } from "node:fs";
|
|
24
|
+
import { existsSync, readdirSync } from "node:fs";
|
|
25
|
+
import { homedir } from "node:os";
|
|
26
|
+
import { join } from "node:path";
|
|
27
|
+
import { getAgentDir } from "@earendil-works/pi-coding-agent";
|
|
28
|
+
import { isSymlink, isUnsafeName, safeReadFile } from "./utils.js";
|
|
29
|
+
|
|
30
|
+
export interface PreloadedSkill {
|
|
31
|
+
name: string;
|
|
32
|
+
content: string;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export function preloadSkills(skillNames: string[], cwd: string): PreloadedSkill[] {
|
|
36
|
+
return skillNames.map((name) => ({ name, content: loadSkillContent(name, cwd) }));
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function loadSkillContent(name: string, cwd: string): string {
|
|
40
|
+
if (isUnsafeName(name)) {
|
|
41
|
+
return `(Skill "${name}" skipped: name contains path traversal characters)`;
|
|
42
|
+
}
|
|
43
|
+
const roots = [
|
|
44
|
+
join(cwd, ".pi", "skills"), // project — Pi standard
|
|
45
|
+
join(cwd, ".agents", "skills"), // project — Agent Skills spec
|
|
46
|
+
join(getAgentDir(), "skills"), // user — Pi standard
|
|
47
|
+
join(homedir(), ".agents", "skills"), // user — Agent Skills spec
|
|
48
|
+
join(homedir(), ".pi", "skills"), // legacy global, pre-Pi
|
|
49
|
+
];
|
|
50
|
+
for (const root of roots) {
|
|
51
|
+
const content = findInRoot(root, name);
|
|
52
|
+
if (content !== undefined) return content;
|
|
53
|
+
}
|
|
54
|
+
return `(Skill "${name}" not found in .pi/skills/, .agents/skills/, or global skill locations)`;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function findInRoot(root: string, name: string): string | undefined {
|
|
58
|
+
if (isSymlink(root)) return undefined; // reject symlinked roots entirely
|
|
59
|
+
const flat = safeReadFile(join(root, `${name}.md`))?.trim();
|
|
60
|
+
if (flat !== undefined) return flat;
|
|
61
|
+
return findSkillDirectory(root, name);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** BFS under `root` for a directory named `name` containing `SKILL.md`. Pi-conforming filters. */
|
|
65
|
+
function findSkillDirectory(root: string, name: string): string | undefined {
|
|
66
|
+
if (!existsSync(root)) return undefined;
|
|
67
|
+
const queue: string[] = [root];
|
|
68
|
+
|
|
69
|
+
while (queue.length > 0) {
|
|
70
|
+
const current = queue.shift();
|
|
71
|
+
if (current === undefined) continue;
|
|
72
|
+
|
|
73
|
+
let entries: Dirent[];
|
|
74
|
+
try {
|
|
75
|
+
entries = readdirSync(current, { withFileTypes: true });
|
|
76
|
+
} catch {
|
|
77
|
+
continue;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// Deterministic byte-order traversal — locale-independent.
|
|
81
|
+
entries.sort((a, b) => (a.name < b.name ? -1 : a.name > b.name ? 1 : 0));
|
|
82
|
+
|
|
83
|
+
for (const entry of entries) {
|
|
84
|
+
if (!entry.isDirectory()) continue;
|
|
85
|
+
if (entry.name.startsWith(".") || entry.name === "node_modules") continue;
|
|
86
|
+
|
|
87
|
+
// Symlinked dirs already filtered by entry.isDirectory() — Dirent uses lstat semantics.
|
|
88
|
+
const path = join(current, entry.name);
|
|
89
|
+
const skillMd = join(path, "SKILL.md");
|
|
90
|
+
const isSkillDir = existsSync(skillMd);
|
|
91
|
+
|
|
92
|
+
if (isSkillDir) {
|
|
93
|
+
if (entry.name === name) {
|
|
94
|
+
const content = safeReadFile(skillMd)?.trim();
|
|
95
|
+
if (content !== undefined) return content;
|
|
96
|
+
}
|
|
97
|
+
continue; // Pi rule: skills don't nest — don't descend into a skill dir
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
queue.push(path);
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
return undefined;
|
|
104
|
+
}
|
package/src/types.ts
ADDED
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* types.ts — Type definitions for the subagent system.
|
|
3
|
+
*
|
|
4
|
+
* Trimmed from upstream: removed ScheduledSubagent, ScheduleStoreData,
|
|
5
|
+
* IsolationMode, MemoryScope, JoinMode.
|
|
6
|
+
* From AgentConfig: removed memory, isolation, inheritContext, runInBackground.
|
|
7
|
+
* From AgentRecord: removed groupId, joinMode, worktree, worktreeResult.
|
|
8
|
+
* From AgentInvocation: removed inheritContext, isolation.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import type { AgentSession } from "@earendil-works/pi-coding-agent";
|
|
12
|
+
import type { LifetimeUsage } from "./usage.js";
|
|
13
|
+
|
|
14
|
+
/** Thinking level for agent models. */
|
|
15
|
+
export type ThinkingLevel = "off" | "minimal" | "low" | "medium" | "high" | "xhigh";
|
|
16
|
+
|
|
17
|
+
/** Agent type: any string name (built-in defaults or user-defined). */
|
|
18
|
+
export type SubagentType = string;
|
|
19
|
+
|
|
20
|
+
/** Unified agent configuration — used for both default and user-defined agents. */
|
|
21
|
+
export interface AgentConfig {
|
|
22
|
+
name: string;
|
|
23
|
+
displayName?: string;
|
|
24
|
+
description: string;
|
|
25
|
+
builtinToolNames?: string[];
|
|
26
|
+
/** Tool denylist — these tools are removed even if `builtinToolNames` or extensions include them. */
|
|
27
|
+
disallowedTools?: string[];
|
|
28
|
+
/** true = inherit all, string[] = only listed, false = none */
|
|
29
|
+
extensions: true | string[] | false;
|
|
30
|
+
/** true = inherit all, string[] = only listed, false = none */
|
|
31
|
+
skills: true | string[] | false;
|
|
32
|
+
model?: string;
|
|
33
|
+
thinking?: ThinkingLevel;
|
|
34
|
+
maxTurns?: number;
|
|
35
|
+
systemPrompt: string;
|
|
36
|
+
/** Default for spawn: no extension tools. undefined = caller decides. */
|
|
37
|
+
isolated?: boolean;
|
|
38
|
+
/** true = this is an embedded default agent (informational) */
|
|
39
|
+
isDefault?: boolean;
|
|
40
|
+
/** false = agent is hidden from the registry */
|
|
41
|
+
enabled?: boolean;
|
|
42
|
+
/** Where this agent was loaded from */
|
|
43
|
+
source?: "default" | "project" | "global";
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export interface AgentRecord {
|
|
47
|
+
id: string;
|
|
48
|
+
type: SubagentType;
|
|
49
|
+
description: string;
|
|
50
|
+
status: "queued" | "running" | "completed" | "steered" | "aborted" | "stopped" | "error";
|
|
51
|
+
result?: string;
|
|
52
|
+
error?: string;
|
|
53
|
+
toolUses: number;
|
|
54
|
+
startedAt: number;
|
|
55
|
+
completedAt?: number;
|
|
56
|
+
session?: AgentSession;
|
|
57
|
+
abortController?: AbortController;
|
|
58
|
+
promise?: Promise<string>;
|
|
59
|
+
/** Steering messages queued before the session was ready. */
|
|
60
|
+
pendingSteers?: string[];
|
|
61
|
+
/** The tool_use_id from the original Agent tool call. */
|
|
62
|
+
toolCallId?: string;
|
|
63
|
+
/** Path to the streaming output transcript file. */
|
|
64
|
+
outputFile?: string;
|
|
65
|
+
/** Cleanup function for the output file stream subscription. */
|
|
66
|
+
outputCleanup?: () => void;
|
|
67
|
+
/**
|
|
68
|
+
* Lifetime usage breakdown, accumulated via `message_end` events. Survives
|
|
69
|
+
* compaction. Total = input + output + cacheWrite (cacheRead deliberately
|
|
70
|
+
* excluded — see issue #38). Initialized to zeros at spawn.
|
|
71
|
+
*/
|
|
72
|
+
lifetimeUsage: LifetimeUsage;
|
|
73
|
+
/** Final turn count (set on completion). Used by widget after activity cleanup. */
|
|
74
|
+
turnCount?: number;
|
|
75
|
+
/** Max turns limit (from invocation or default). */
|
|
76
|
+
maxTurns?: number;
|
|
77
|
+
/** Number of times this agent's session has compacted. Initialized to 0 at spawn. */
|
|
78
|
+
compactionCount: number;
|
|
79
|
+
/** Resolved spawn params, captured for UI display. Fixed at spawn time. */
|
|
80
|
+
invocation?: AgentInvocation;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
export interface AgentInvocation {
|
|
84
|
+
/** Short display name, e.g. "haiku" — only set when different from parent. */
|
|
85
|
+
modelName?: string;
|
|
86
|
+
thinking?: ThinkingLevel;
|
|
87
|
+
maxTurns?: number;
|
|
88
|
+
isolated?: boolean;
|
|
89
|
+
runInBackground?: boolean;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
export interface EnvInfo {
|
|
93
|
+
isGitRepo: boolean;
|
|
94
|
+
branch: string | null;
|
|
95
|
+
platform: string;
|
|
96
|
+
}
|