@ladbabynpm/picc-subagents 0.1.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.
@@ -0,0 +1,102 @@
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
+
21
+ import type { Dirent } from "node:fs";
22
+ import { existsSync, readdirSync } from "node:fs";
23
+ import { homedir } from "node:os";
24
+ import { join } from "node:path";
25
+ import { getAgentDir } from "@earendil-works/pi-coding-agent";
26
+ import { isSymlink, isUnsafeName, safeReadFile } from "./memory.js";
27
+
28
+ export interface PreloadedSkill {
29
+ name: string;
30
+ content: string;
31
+ }
32
+
33
+ export function preloadSkills(skillNames: string[], cwd: string): PreloadedSkill[] {
34
+ return skillNames.map((name) => ({ name, content: loadSkillContent(name, cwd) }));
35
+ }
36
+
37
+ function loadSkillContent(name: string, cwd: string): string {
38
+ if (isUnsafeName(name)) {
39
+ return `(Skill "${name}" skipped: name contains path traversal characters)`;
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
+ }
54
+
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);
60
+ }
61
+
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];
66
+
67
+ while (queue.length > 0) {
68
+ const current = queue.shift();
69
+ if (current === undefined) continue;
70
+
71
+ let entries: Dirent<string>[];
72
+ try {
73
+ entries = readdirSync(current, { withFileTypes: true });
74
+ } catch {
75
+ continue;
76
+ }
77
+
78
+ // Deterministic byte-order traversal — locale-independent.
79
+ entries.sort((a, b) => (a.name < b.name ? -1 : a.name > b.name ? 1 : 0));
80
+
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);
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
+ }
101
+ return undefined;
102
+ }
@@ -0,0 +1,25 @@
1
+ /**
2
+ * status-note.ts — Parenthetical status note appended to agent result text.
3
+ */
4
+
5
+ /**
6
+ * Explicit parenthetical note for a non-normal terminal outcome, so the parent
7
+ * agent can't mistake partial output for a completed result. Empty string for a
8
+ * clean completion (and any unknown/non-terminal status).
9
+ *
10
+ * `stopped` (a human aborted it) is deliberately distinct from `aborted` (the
11
+ * turn limit was hit) — the parent should treat human intervention differently
12
+ * from a budget cutoff.
13
+ */
14
+ export function getStatusNote(status: string): string {
15
+ switch (status) {
16
+ case "stopped":
17
+ return " (STOPPED BY THE USER before completion — output is partial; the task was NOT finished)";
18
+ case "aborted":
19
+ return " (aborted — hit the turn limit before completion; output may be incomplete)";
20
+ case "steered":
21
+ return " (wrapped up at the turn limit — output may be partial)";
22
+ default:
23
+ return "";
24
+ }
25
+ }
@@ -0,0 +1,52 @@
1
+ /**
2
+ * subagent-env.ts — Environment-driven subagent defaults.
3
+ *
4
+ * Mirrors claude-code's `CLAUDE_CODE_SUBAGENT_MODEL` hook for picking the
5
+ * default subagent model. When set, the env var overrides:
6
+ * - frontmatter `model:` pins on the agent config
7
+ * - `model:` parameters supplied to the `Agent` tool by the orchestrator
8
+ * - the parent thread's model (i.e. "inherit")
9
+ *
10
+ * Precedence: PI_SUBAGENT_MODEL (fork-specific) > CLAUDE_CODE_SUBAGENT_MODEL
11
+ * (claude-code-compatible) > undefined (caller falls through to existing
12
+ * resolution in `agent-runner.ts` / `index.ts`).
13
+ */
14
+
15
+ export const PI_SUBAGENT_MODEL_ENV = "PI_SUBAGENT_MODEL";
16
+ export const CLAUDE_CODE_SUBAGENT_MODEL_ENV = "CLAUDE_CODE_SUBAGENT_MODEL";
17
+
18
+ /**
19
+ * Pick the env-driven default subagent model.
20
+ *
21
+ * Returns the raw env-var string — the caller is responsible for resolving it
22
+ * against the model registry. Resolution errors are surfaced by the caller;
23
+ * we intentionally do NOT silently fall back to the parent model, because
24
+ * setting this env var is treated as an explicit user choice (matching
25
+ * claude-code's behavior).
26
+ *
27
+ * @returns The env-var value, or `undefined` when neither is set.
28
+ */
29
+ export function pickSubagentEnvModel(): string | undefined {
30
+ const pi = process.env[PI_SUBAGENT_MODEL_ENV];
31
+ if (pi && pi.trim().length > 0) return pi.trim();
32
+
33
+ const claude = process.env[CLAUDE_CODE_SUBAGENT_MODEL_ENV];
34
+ if (claude && claude.trim().length > 0) return claude.trim();
35
+
36
+ return undefined;
37
+ }
38
+
39
+ /**
40
+ * Whether the resolved model came from an env var (PI_SUBAGENT_MODEL or
41
+ * CLAUDE_CODE_SUBAGENT_MODEL). Useful for display-labeling and for skipping
42
+ * scope-validation against the user's `enabledModels` allowlist, since the
43
+ * user explicitly asked for this model via environment.
44
+ *
45
+ * The check is by value-equality with what `pickSubagentEnvModel()` returned,
46
+ * so callers can compare strings without leaking env names into log output.
47
+ */
48
+ export function isEnvSourcedModel(rawInput: string | undefined): boolean {
49
+ if (!rawInput) return false;
50
+ return rawInput === process.env[PI_SUBAGENT_MODEL_ENV]?.trim()
51
+ || rawInput === process.env[CLAUDE_CODE_SUBAGENT_MODEL_ENV]?.trim();
52
+ }
package/src/types.ts ADDED
@@ -0,0 +1,206 @@
1
+ /**
2
+ * types.ts — Type definitions for the subagent system.
3
+ */
4
+
5
+ import type { ThinkingLevel } from "@earendil-works/pi-ai";
6
+ import type { AgentSession } from "@earendil-works/pi-coding-agent";
7
+ import type { LifetimeUsage } from "./usage.js";
8
+
9
+ export type { ThinkingLevel };
10
+
11
+ /** Agent type: any string name (built-in defaults or user-defined). */
12
+ export type SubagentType = string;
13
+
14
+ /** Names of the three embedded default agents. */
15
+ export const DEFAULT_AGENT_NAMES = ["general-purpose", "Explore", "Plan"] as const;
16
+
17
+ /** Memory scope for persistent agent memory. */
18
+ export type MemoryScope = "user" | "project" | "local";
19
+
20
+ /** Isolation mode for agent execution. */
21
+ export type IsolationMode = "worktree";
22
+
23
+ /** Unified agent configuration — used for both default and user-defined agents. */
24
+ export interface AgentConfig {
25
+ name: string;
26
+ displayName?: string;
27
+ description: string;
28
+ builtinToolNames?: string[];
29
+ /** Raw `ext:` selector entries from the `tools:` CSV, e.g. ["ext:foo", "ext:bar/x"].
30
+ * Presence of any entry flips extension tools to an explicit allowlist. */
31
+ extSelectors?: string[];
32
+ /** Tool denylist — these tools are removed even if `builtinToolNames` or extensions include them. */
33
+ disallowedTools?: string[];
34
+ /** true = inherit all, string[] = only listed, false = none */
35
+ extensions: true | string[] | false;
36
+ /** Extension-name denylist applied after the `extensions:` include set. Exclude wins.
37
+ * Plain canonical names only (case-insensitive); no paths, no wildcard. */
38
+ excludeExtensions?: string[];
39
+ /** true = inherit all, string[] = only listed, false = none */
40
+ skills: true | string[] | false;
41
+ model?: string;
42
+ thinking?: ThinkingLevel;
43
+ maxTurns?: number;
44
+ /** Persist this subagent as a normal pi session instead of keeping it in memory only. */
45
+ persistSession?: boolean;
46
+ /** Optional session directory used when persistSession is true. Omitted = pi's normal session location. */
47
+ sessionDir?: string;
48
+ systemPrompt: string;
49
+ promptMode: "replace" | "append";
50
+ /** Default for spawn: fork parent conversation. undefined = caller decides. */
51
+ inheritContext?: boolean;
52
+ /** Default for spawn: run in background. undefined = caller decides. */
53
+ runInBackground?: boolean;
54
+ /** Default for spawn: no extension tools. undefined = caller decides. */
55
+ isolated?: boolean;
56
+ /** Persistent memory scope — agents with memory get a persistent directory and MEMORY.md */
57
+ memory?: MemoryScope;
58
+ /** Isolation mode — "worktree" runs the agent in a temporary git worktree */
59
+ isolation?: IsolationMode;
60
+ /** true = this is an embedded default agent (informational) */
61
+ isDefault?: boolean;
62
+ /** false = agent is hidden from the registry */
63
+ enabled?: boolean;
64
+ /** Where this agent was loaded from */
65
+ source?: "default" | "project" | "global";
66
+ }
67
+
68
+ export type JoinMode = 'async' | 'group' | 'smart';
69
+
70
+ /**
71
+ * Display mode for the persistent above-editor agent widget.
72
+ * - `all`: show every agent (foreground + background).
73
+ * - `background`: hide foreground agents (they already render inline as the
74
+ * Agent tool result, #118); show background/queued/scheduled/RPC.
75
+ * - `off`: hide the widget entirely.
76
+ */
77
+ export type WidgetMode = 'all' | 'background' | 'off';
78
+
79
+ export interface AgentRecord {
80
+ id: string;
81
+ type: SubagentType;
82
+ description: string;
83
+ status: "queued" | "running" | "completed" | "steered" | "aborted" | "stopped" | "error";
84
+ result?: string;
85
+ error?: string;
86
+ toolUses: number;
87
+ startedAt: number;
88
+ completedAt?: number;
89
+ session?: AgentSession;
90
+ abortController?: AbortController;
91
+ promise?: Promise<string>;
92
+ groupId?: string;
93
+ joinMode?: JoinMode;
94
+ /** Set when result was already returned inline to the caller — suppresses completion notification. */
95
+ resultConsumed?: boolean;
96
+ /** Steering messages queued before the session was ready. */
97
+ pendingSteers?: string[];
98
+ /** Worktree info if the agent is running in an isolated worktree. */
99
+ worktree?: { path: string; branch: string; baseSha: string; workPath: string };
100
+ /** Worktree cleanup result after agent completion. */
101
+ worktreeResult?: { hasChanges: boolean; branch?: string };
102
+ /** The tool_use_id from the original Agent tool call. */
103
+ toolCallId?: string;
104
+ /** Path to the streaming output transcript file. */
105
+ outputFile?: string;
106
+ /** Cleanup function for the output file stream subscription. */
107
+ outputCleanup?: () => void;
108
+ /**
109
+ * Lifetime usage breakdown, accumulated via `message_end` events. Survives
110
+ * compaction. Total = input + output + cacheWrite (cacheRead deliberately
111
+ * excluded — see issue #38). Initialized to zeros at spawn.
112
+ */
113
+ lifetimeUsage: LifetimeUsage;
114
+ /** Number of times this agent's session has compacted. Initialized to 0 at spawn. */
115
+ compactionCount: number;
116
+ /**
117
+ * Whether this agent was spawned to run in the background. Tri-state, set at
118
+ * spawn from `SpawnOptions.isBackground`: `true` = background, `false` =
119
+ * foreground (has an inline Agent tool-result surface), `undefined` = the
120
+ * caller never declared it (e.g. a cross-extension RPC spawn, which is detached
121
+ * and has no inline surface). The widget's background-only filter keys off this
122
+ * — and excludes only explicit `false`, so `undefined` agents stay visible.
123
+ * Reliable across ALL spawn paths, unlike the UI-only `invocation` snapshot,
124
+ * which only the Agent-tool path populates.
125
+ */
126
+ isBackground?: boolean;
127
+ /** Resolved spawn params, captured for UI display. Fixed at spawn time. */
128
+ invocation?: AgentInvocation;
129
+ }
130
+
131
+ export interface AgentInvocation {
132
+ /** Short display name, e.g. "haiku" — only set when different from parent. */
133
+ modelName?: string;
134
+ thinking?: ThinkingLevel;
135
+ maxTurns?: number;
136
+ isolated?: boolean;
137
+ inheritContext?: boolean;
138
+ runInBackground?: boolean;
139
+ isolation?: IsolationMode;
140
+ }
141
+
142
+ /** Details attached to custom notification messages for visual rendering. */
143
+ export interface NotificationDetails {
144
+ id: string;
145
+ description: string;
146
+ status: string;
147
+ toolUses: number;
148
+ turnCount: number;
149
+ maxTurns?: number;
150
+ totalTokens: number;
151
+ durationMs: number;
152
+ outputFile?: string;
153
+ error?: string;
154
+ resultPreview: string;
155
+ /** Additional agents in a group notification. */
156
+ others?: NotificationDetails[];
157
+ }
158
+
159
+ export interface EnvInfo {
160
+ isGitRepo: boolean;
161
+ branch: string;
162
+ platform: string;
163
+ }
164
+
165
+ /**
166
+ * A subagent spawn registered to fire on a schedule.
167
+ *
168
+ * Stored at `<cwd>/.pi/subagent-schedules/<sessionId>.json`. Session-scoped:
169
+ * survives `/resume` but resets on `/new`, mirroring pi-chonky-tasks.
170
+ */
171
+ export interface ScheduledSubagent {
172
+ id: string;
173
+ /** Unique within store. Defaults to `description`. */
174
+ name: string;
175
+ description: string;
176
+ /** Raw user input — cron expr | "+10m" | ISO | "5m". */
177
+ schedule: string;
178
+ scheduleType: "cron" | "once" | "interval";
179
+ /** Computed at create time for interval/once. */
180
+ intervalMs?: number;
181
+
182
+ // spawn params (subset of Agent tool params; no inherit_context, no resume)
183
+ subagent_type: SubagentType;
184
+ prompt: string;
185
+ model?: string;
186
+ thinking?: ThinkingLevel;
187
+ max_turns?: number;
188
+ isolated?: boolean;
189
+ isolation?: IsolationMode;
190
+
191
+ // state
192
+ enabled: boolean;
193
+ /** ISO timestamp. */
194
+ createdAt: string;
195
+ lastRun?: string;
196
+ lastStatus?: "success" | "error" | "running";
197
+ /** Refreshed on every fire and on store load. */
198
+ nextRun?: string;
199
+ runCount: number;
200
+ }
201
+
202
+ export interface ScheduleStoreData {
203
+ /** For future migrations. */
204
+ version: 1;
205
+ jobs: ScheduledSubagent[];
206
+ }