@gotgenes/pi-subagents 12.1.0 → 13.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.
Files changed (38) hide show
  1. package/CHANGELOG.md +24 -0
  2. package/dist/public.d.ts +1 -3
  3. package/docs/architecture/architecture.md +86 -57
  4. package/docs/plans/0264-remove-extension-lifecycle-control.md +275 -0
  5. package/docs/plans/0265-born-complete-subagent-session.md +330 -0
  6. package/docs/retro/0264-remove-extension-lifecycle-control.md +89 -0
  7. package/docs/retro/0265-born-complete-subagent-session.md +58 -0
  8. package/package.json +1 -1
  9. package/src/config/agent-types.ts +0 -2
  10. package/src/config/custom-agents.ts +0 -30
  11. package/src/config/default-agents.ts +1 -7
  12. package/src/config/invocation-config.ts +0 -3
  13. package/src/index.ts +3 -5
  14. package/src/lifecycle/agent-manager.ts +9 -10
  15. package/src/lifecycle/agent.ts +56 -55
  16. package/src/lifecycle/create-subagent-session.ts +242 -0
  17. package/src/lifecycle/subagent-session.ts +204 -0
  18. package/src/lifecycle/turn-limits.ts +13 -0
  19. package/src/runtime.ts +1 -1
  20. package/src/service/service-adapter.ts +0 -1
  21. package/src/service/service.ts +0 -1
  22. package/src/session/conversation.ts +49 -0
  23. package/src/session/prompts.ts +2 -23
  24. package/src/session/session-config.ts +10 -45
  25. package/src/settings.ts +1 -1
  26. package/src/tools/agent-tool.ts +0 -5
  27. package/src/tools/background-spawner.ts +0 -1
  28. package/src/tools/foreground-runner.ts +0 -1
  29. package/src/tools/get-result-tool.ts +1 -1
  30. package/src/tools/spawn-config.ts +1 -5
  31. package/src/types.ts +0 -7
  32. package/src/ui/agent-config-editor.ts +0 -5
  33. package/src/ui/agent-creation-wizard.ts +0 -4
  34. package/src/ui/display.ts +1 -2
  35. package/src/lifecycle/agent-runner.ts +0 -472
  36. package/src/lifecycle/execution-state.ts +0 -17
  37. package/src/session/safe-fs.ts +0 -45
  38. package/src/session/skill-loader.ts +0 -104
@@ -1,472 +0,0 @@
1
- /**
2
- * agent-runner.ts - Core execution engine: creates sessions, runs agents, collects results.
3
- */
4
-
5
- import type { Model } from "@earendil-works/pi-ai";
6
- import {
7
- type AgentSession,
8
- type AgentSessionEvent,
9
- type SettingsManager,
10
- } from "@earendil-works/pi-coding-agent";
11
- import type { AgentConfigLookup } from "#src/config/agent-types";
12
- import type { ChildLifecyclePublisher } from "#src/lifecycle/child-lifecycle";
13
- import type { ParentSnapshot } from "#src/lifecycle/parent-snapshot";
14
- import { extractAssistantContent } from "#src/session/content-items";
15
- import { extractText } from "#src/session/context";
16
- import type { EnvInfo } from "#src/session/env";
17
- import { type AssemblerIO, assembleSessionConfig } from "#src/session/session-config";
18
- import type { ParentSessionInfo, ShellExec, SubagentType, ThinkingLevel } from "#src/types";
19
-
20
- /** Names of tools registered by this extension that subagents must NOT inherit. */
21
- const EXCLUDED_TOOL_NAMES = ["subagent", "get_subagent_result", "steer_subagent"];
22
-
23
- /**
24
- * Filter the session's active tool names: remove recursion-guard tools.
25
- *
26
- * Run once after `bindExtensions` so extension-registered tools (added during
27
- * `bindExtensions`) are also covered by the guard.
28
- *
29
- * @param activeTools Names currently active on the session.
30
- */
31
- function filterActiveTools(activeTools: string[]): string[] {
32
- return activeTools.filter((t) => !EXCLUDED_TOOL_NAMES.includes(t));
33
- }
34
-
35
- /** Normalize max turns. undefined or 0 = unlimited, otherwise minimum 1. */
36
- export function normalizeMaxTurns(n: number | undefined): number | undefined {
37
- if (n == null || n === 0) return undefined;
38
- return Math.max(1, n);
39
- }
40
-
41
- // ── IO boundary ───────────────────────────────────────────────────────────────
42
-
43
- /** Minimal resource-loader contract used by the runner. */
44
- export interface ResourceLoaderLike {
45
- reload(): Promise<void>;
46
- }
47
-
48
- /** Minimal session-manager contract used by the runner. */
49
- export interface SessionManagerLike {
50
- newSession(opts: { parentSession?: string }): void;
51
- getSessionFile(): string | undefined;
52
- }
53
-
54
- /** Options passed to EnvironmentIO/SessionFactoryIO methods. */
55
- export interface ResourceLoaderOptions {
56
- cwd: string;
57
- agentDir: string;
58
- noExtensions?: boolean;
59
- noSkills?: boolean;
60
- noPromptTemplates?: boolean;
61
- noThemes?: boolean;
62
- noContextFiles?: boolean;
63
- systemPromptOverride?: () => string;
64
- /** Override the append system prompt. Receives the current base value; return the replacement. */
65
- appendSystemPromptOverride?: (base: string[]) => string[];
66
- }
67
-
68
- /** Options passed to SessionFactoryIO.createSession. */
69
- export interface CreateSessionOptions {
70
- cwd: string;
71
- agentDir: string;
72
- sessionManager: SessionManagerLike;
73
- settingsManager: SettingsManager;
74
- modelRegistry: unknown;
75
- model?: unknown;
76
- tools: string[];
77
- resourceLoader: ResourceLoaderLike;
78
- thinkingLevel?: ThinkingLevel;
79
- }
80
-
81
- /**
82
- * Environment discovery - detect runtime context and resolve directories.
83
- *
84
- * Decouples the runner from direct process/SDK reads so each can be stubbed
85
- * independently in tests.
86
- */
87
- export interface EnvironmentIO {
88
- detectEnv: (exec: ShellExec, cwd: string) => Promise<EnvInfo>;
89
- getAgentDir: () => string;
90
- deriveSessionDir: (parentSessionFile: string | undefined, effectiveCwd: string) => string;
91
- }
92
-
93
- /**
94
- * Session factory - create SDK objects for a child agent session.
95
- *
96
- * Decouples the runner from direct Pi SDK imports and sibling-module IO,
97
- * making it testable via plain stub objects without vi.mock().
98
- */
99
- export interface SessionFactoryIO {
100
- createResourceLoader: (opts: ResourceLoaderOptions) => ResourceLoaderLike;
101
- createSessionManager: (cwd: string, sessionDir: string) => SessionManagerLike;
102
- createSettingsManager: (cwd: string, agentDir: string) => SettingsManager;
103
- createSession: (opts: CreateSessionOptions) => Promise<{ session: AgentSession }>;
104
- assemblerIO: AssemblerIO;
105
- }
106
-
107
- /**
108
- * IO boundary injected into runAgent().
109
- *
110
- * Backward-compatible intersection of EnvironmentIO and SessionFactoryIO.
111
- * Callers that previously constructed a RunnerIO object continue to satisfy
112
- * both sub-interfaces via TypeScript's structural typing.
113
- */
114
- export type RunnerIO = EnvironmentIO & SessionFactoryIO;
115
-
116
- /**
117
- * Dependencies owned by the runner — injected at construction time.
118
- *
119
- * Groups the IO boundary with the two static domain deps (exec, registry)
120
- * that every run() call needs but that do not vary per call.
121
- */
122
- export interface RunnerDeps {
123
- io: RunnerIO;
124
- exec: ShellExec;
125
- registry: AgentConfigLookup;
126
- /** Publishes the child-execution lifecycle so consumers can observe it. */
127
- lifecycle: ChildLifecyclePublisher;
128
- }
129
-
130
- // ── Public interfaces ─────────────────────────────────────────────────────────
131
-
132
- /**
133
- * Per-call execution context — fields that vary per spawn.
134
- *
135
- * Static dependencies (exec, registry) live on RunnerDeps; this interface
136
- * carries only the two per-call fields that AgentManager supplies at spawn time.
137
- */
138
- export interface RunContext {
139
- /** Override working directory (e.g. for worktree isolation). */
140
- cwd?: string;
141
- /** Parent session identity (file path + session ID). */
142
- parentSession?: ParentSessionInfo;
143
- }
144
-
145
- export interface RunOptions {
146
- /** Parent execution context - where/who is running. */
147
- context: RunContext;
148
- model?: Model<any>;
149
- maxTurns?: number;
150
- signal?: AbortSignal;
151
- isolated?: boolean;
152
- thinkingLevel?: ThinkingLevel;
153
- /** Called once after session creation - session delivery mechanism. */
154
- onSessionCreated?: (session: AgentSession) => void;
155
- /**
156
- * Default max turns from runtime config. Falls back to the module-scope
157
- * `defaultMaxTurns` during the lift-and-shift migration; superseded by
158
- * per-call `maxTurns` and per-agent `agentConfig.maxTurns`.
159
- */
160
- defaultMaxTurns?: number;
161
- /**
162
- * Grace turns after the soft-limit steer message. Falls back to the
163
- * module-scope `graceTurns` during migration.
164
- */
165
- graceTurns?: number;
166
- }
167
-
168
- export interface RunResult {
169
- responseText: string;
170
- session: AgentSession;
171
- /** True if the agent was hard-aborted (max_turns + grace exceeded). */
172
- aborted: boolean;
173
- /** True if the agent was steered to wrap up (hit soft turn limit) but finished in time. */
174
- steered: boolean;
175
- /** Path to the persisted session JSONL file, if the session was persisted. */
176
- sessionFile?: string;
177
- }
178
-
179
- /** Options for resuming an existing agent session. */
180
- export interface ResumeOptions {
181
- signal?: AbortSignal;
182
- }
183
-
184
- /**
185
- * Execution boundary: decouples AgentManager (lifecycle management) from the
186
- * SDK session orchestration in runAgent/resumeAgent.
187
- */
188
- export interface AgentRunner {
189
- run(snapshot: ParentSnapshot, type: SubagentType, prompt: string, options: RunOptions): Promise<RunResult>;
190
- resume(session: AgentSession, prompt: string, options?: ResumeOptions): Promise<string>;
191
- }
192
-
193
- /**
194
- * Concrete AgentRunner backed by RunnerDeps.
195
- *
196
- * Captures IO, exec, and registry at construction time so AgentManager
197
- * remains unaware of runner-internal dependencies.
198
- */
199
- export class ConcreteAgentRunner implements AgentRunner {
200
- constructor(private readonly deps: RunnerDeps) {}
201
-
202
- run(snapshot: ParentSnapshot, type: SubagentType, prompt: string, options: RunOptions): Promise<RunResult> {
203
- return runAgent(snapshot, type, prompt, options, this.deps);
204
- }
205
-
206
- resume(session: AgentSession, prompt: string, options?: ResumeOptions): Promise<string> {
207
- return resumeAgent(session, prompt, options);
208
- }
209
- }
210
-
211
-
212
- // ── Private helpers ───────────────────────────────────────────────────────────
213
-
214
- /**
215
- * Subscribe to a session and collect the last assistant message text.
216
- * Returns an object with a `getText()` getter and an `unsubscribe` function.
217
- */
218
- function collectResponseText(session: AgentSession) {
219
- let text = "";
220
- const unsubscribe = session.subscribe((event: AgentSessionEvent) => {
221
- if (event.type === "message_start") {
222
- text = "";
223
- }
224
- if (
225
- event.type === "message_update" &&
226
- event.assistantMessageEvent.type === "text_delta"
227
- ) {
228
- text += event.assistantMessageEvent.delta;
229
- }
230
- });
231
- return { getText: () => text, unsubscribe };
232
- }
233
-
234
- /** Get the last assistant text from the completed session history. */
235
- function getLastAssistantText(session: AgentSession): string {
236
- for (let i = session.messages.length - 1; i >= 0; i--) {
237
- const msg = session.messages[i];
238
- if (msg.role !== "assistant") continue;
239
- const text = extractText(msg.content).trim();
240
- if (text) return text;
241
- }
242
- return "";
243
- }
244
-
245
- /**
246
- * Wire an AbortSignal to abort a session.
247
- * Returns a cleanup function to remove the listener.
248
- */
249
- function forwardAbortSignal(
250
- session: AgentSession,
251
- signal?: AbortSignal,
252
- ): () => void {
253
- if (!signal) return () => {};
254
- const onAbort = (): void => { void session.abort(); };
255
- signal.addEventListener("abort", onAbort, { once: true });
256
- return () => signal.removeEventListener("abort", onAbort);
257
- }
258
-
259
- // ── Public functions ──────────────────────────────────────────────────────────
260
-
261
- export async function runAgent(
262
- snapshot: ParentSnapshot,
263
- type: SubagentType,
264
- prompt: string,
265
- options: RunOptions,
266
- deps: RunnerDeps,
267
- ): Promise<RunResult> {
268
- const parentSessionId = options.context.parentSession?.parentSessionId;
269
- deps.lifecycle.spawning({ agentName: type, parentSessionId });
270
-
271
- // Resolve working directory upfront - needed for detectEnv before assembly.
272
- const effectiveCwd = options.context.cwd ?? snapshot.cwd;
273
- const env = await deps.io.detectEnv(deps.exec, effectiveCwd);
274
-
275
- // Assemble session configuration (synchronous, no SDK objects).
276
- const cfg = assembleSessionConfig(
277
- type,
278
- {
279
- cwd: snapshot.cwd,
280
- parentSystemPrompt: snapshot.systemPrompt,
281
- parentModel: snapshot.model,
282
- modelRegistry: snapshot.modelRegistry,
283
- },
284
- {
285
- cwd: options.context.cwd,
286
- isolated: options.isolated,
287
- model: options.model,
288
- thinkingLevel: options.thinkingLevel,
289
- },
290
- env,
291
- deps.registry,
292
- deps.io.assemblerIO,
293
- );
294
-
295
- const agentDir = deps.io.getAgentDir();
296
-
297
- // Load extensions/skills: true → load; false → don't.
298
- // Suppress AGENTS.md/CLAUDE.md and APPEND_SYSTEM.md - upstream's
299
- // buildSystemPrompt() re-appends both AFTER systemPromptOverride, which
300
- // would defeat prompt_mode: replace and isolated: true. Parent context, if
301
- // wanted, reaches the subagent via prompt_mode: append (parentSystemPrompt
302
- // is embedded in systemPromptOverride) or inherit_context (conversation).
303
- const loader = deps.io.createResourceLoader({
304
- cwd: cfg.effectiveCwd,
305
- agentDir,
306
- noExtensions: !cfg.extensions,
307
- noSkills: cfg.noSkills,
308
- noPromptTemplates: true,
309
- noThemes: true,
310
- noContextFiles: true,
311
- systemPromptOverride: () => cfg.systemPrompt,
312
- appendSystemPromptOverride: () => [],
313
- });
314
- await loader.reload();
315
-
316
- // Create a persisted SessionManager so transcripts are written in Pi's
317
- // official JSONL format. Falls back to a temp directory when the parent
318
- // session is not persisted (e.g. headless/API mode).
319
- const sessionDir = deps.io.deriveSessionDir(options.context.parentSession?.parentSessionFile, cfg.effectiveCwd);
320
- const sessionManager = deps.io.createSessionManager(cfg.effectiveCwd, sessionDir);
321
- sessionManager.newSession({ parentSession: options.context.parentSession?.parentSessionId });
322
-
323
- const { session } = await deps.io.createSession({
324
- cwd: cfg.effectiveCwd,
325
- agentDir,
326
- sessionManager,
327
- settingsManager: deps.io.createSettingsManager(cfg.effectiveCwd, agentDir),
328
- modelRegistry: snapshot.modelRegistry,
329
- model: cfg.model,
330
- tools: cfg.toolNames,
331
- resourceLoader: loader,
332
- thinkingLevel: cfg.thinkingLevel,
333
- });
334
-
335
- // Publish session-created before bindExtensions() so observers (e.g. the
336
- // permission system) can register the child synchronously and have their
337
- // entry in place for the first permission check during child extension
338
- // initialization. The event bus dispatches synchronously, so a synchronous
339
- // subscriber completes before this returns. Paired with disposed() in the
340
- // finally block below to guarantee cleanup on both success and error paths.
341
- deps.lifecycle.sessionCreated({ sessionDir, agentName: type, parentSessionId });
342
-
343
- // Bind extensions so that session_start fires and extensions can initialize
344
- // (e.g. loading credentials, setting up state). Placed after tool filtering
345
- // so extension-provided skills/prompts from extendResourcesFromExtensions()
346
- // respect the active tool set. All ExtensionBindings fields are optional.
347
- await session.bindExtensions({});
348
-
349
- // Apply recursion guard: remove our own tools from the child's active set.
350
- // Runs after bindExtensions so extension-registered tools are included in the
351
- // post-bind active set. Only needed when extensions are loaded (extensions: false
352
- // means no extension tools were registered, so the guard is a no-op).
353
- if (cfg.extensions) {
354
- const filtered = filterActiveTools(session.getActiveToolNames());
355
- session.setActiveToolsByName(filtered);
356
- }
357
-
358
- options.onSessionCreated?.(session);
359
-
360
- // Track turns for graceful max_turns enforcement
361
- let turnCount = 0;
362
- const maxTurns = normalizeMaxTurns(
363
- options.maxTurns ?? cfg.agentMaxTurns ?? options.defaultMaxTurns,
364
- );
365
- let softLimitReached = false;
366
- let aborted = false;
367
-
368
- const unsubTurns = session.subscribe((event: AgentSessionEvent) => {
369
- if (event.type === "turn_end") {
370
- turnCount++;
371
- if (maxTurns != null) {
372
- if (!softLimitReached && turnCount >= maxTurns) {
373
- softLimitReached = true;
374
- void session.steer(
375
- "You have reached your turn limit. Wrap up immediately - provide your final answer now.",
376
- );
377
- } else if (softLimitReached && turnCount >= maxTurns + (options.graceTurns ?? 5)) {
378
- aborted = true;
379
- void session.abort();
380
- }
381
- }
382
- }
383
- });
384
-
385
- const collector = collectResponseText(session);
386
- const cleanupAbort = forwardAbortSignal(session, options.signal);
387
-
388
- // Prepend parent context if it was captured at spawn time
389
- let effectivePrompt = prompt;
390
- if (snapshot.parentContext) {
391
- effectivePrompt = snapshot.parentContext + prompt;
392
- }
393
-
394
- try {
395
- await session.prompt(effectivePrompt);
396
- deps.lifecycle.completed({ sessionDir, agentName: type, aborted, steered: softLimitReached });
397
- } finally {
398
- unsubTurns();
399
- collector.unsubscribe();
400
- cleanupAbort();
401
- deps.lifecycle.disposed({ sessionDir });
402
- }
403
-
404
- const responseText =
405
- collector.getText().trim() || getLastAssistantText(session);
406
- return {
407
- responseText,
408
- session,
409
- aborted,
410
- steered: softLimitReached,
411
- sessionFile: sessionManager.getSessionFile(),
412
- };
413
- }
414
-
415
- /**
416
- * Send a new prompt to an existing session (resume).
417
- */
418
- export async function resumeAgent(
419
- session: AgentSession,
420
- prompt: string,
421
- options: ResumeOptions = {},
422
- ): Promise<string> {
423
- const collector = collectResponseText(session);
424
- const cleanupAbort = forwardAbortSignal(session, options.signal);
425
-
426
- try {
427
- await session.prompt(prompt);
428
- } finally {
429
- collector.unsubscribe();
430
- cleanupAbort();
431
- }
432
-
433
- return collector.getText().trim() || getLastAssistantText(session);
434
- }
435
-
436
- /**
437
- * Get the subagent's conversation messages as formatted text.
438
- */
439
- export function getAgentConversation(session: AgentSession): string {
440
- const parts: string[] = [];
441
-
442
- for (const msg of session.messages) {
443
- if (msg.role === "user") {
444
- const text =
445
- typeof msg.content === "string"
446
- ? msg.content
447
- : extractText(msg.content);
448
- if (text.trim()) parts.push(`[User]: ${text.trim()}`);
449
- } else if (msg.role === "assistant") {
450
- const { textParts, toolNames } = extractAssistantContent(msg.content);
451
- const attribution = formatAttribution(msg);
452
- if (textParts.length > 0)
453
- parts.push(`[Assistant${attribution}]: ${textParts.join("\n")}`);
454
- if (toolNames.length > 0)
455
- parts.push(`[Tool Calls]:\n${toolNames.map((n) => ` Tool: ${n}`).join("\n")}`);
456
- } else if (msg.role === "toolResult") {
457
- const text = extractText(msg.content);
458
- const truncated = text.length > 200 ? text.slice(0, 200) + "..." : text;
459
- parts.push(`[Tool Result (${msg.toolName})]: ${truncated}`);
460
- }
461
- }
462
-
463
- return parts.join("\n\n");
464
- }
465
-
466
- /** Build a `(provider/model)` attribution suffix for assistant messages. */
467
- function formatAttribution(msg: { provider?: string; model?: string }): string {
468
- const { provider, model } = msg;
469
- if (!provider && !model) return "";
470
- if (provider && model) return ` (${provider}/${model})`;
471
- return ` (${provider ?? model})`;
472
- }
@@ -1,17 +0,0 @@
1
- /**
2
- * execution-state.ts — ExecutionState: execution-phase state for a running agent.
3
- *
4
- * Constructed and attached to Agent when onSessionCreated fires inside startAgent().
5
- * Contains the session and output file — the two fields that become known once the
6
- * runner creates the session. promise stays as a separate Agent field because
7
- * it is set at a different moment (after runner.run() returns).
8
- */
9
-
10
- import type { AgentSession } from "@earendil-works/pi-coding-agent";
11
-
12
- export interface ExecutionState {
13
- /** The active agent session — available from the moment the session is created. */
14
- readonly session: AgentSession;
15
- /** Path to the agent's session JSONL file, or undefined if not yet available. */
16
- readonly outputFile: string | undefined;
17
- }
@@ -1,45 +0,0 @@
1
- /**
2
- * safe-fs.ts — Filesystem safety utilities for reading untrusted paths.
3
- *
4
- * Used by skill-loader.ts to reject symlinks and path-traversal names
5
- * before reading skill files from disk.
6
- */
7
-
8
- import { existsSync, lstatSync, readFileSync } from "node:fs";
9
- import { debugLog } from "#src/debug";
10
-
11
- /**
12
- * Returns true if a name contains characters not allowed in agent/skill names.
13
- * Uses a whitelist: only alphanumeric, hyphens, underscores, and dots (no leading dot).
14
- */
15
- export function isUnsafeName(name: string): boolean {
16
- if (!name || name.length > 128) return true;
17
- return !/^[a-zA-Z0-9][a-zA-Z0-9._-]*$/.test(name);
18
- }
19
-
20
- /**
21
- * Returns true if the given path is a symlink (defense against symlink attacks).
22
- */
23
- export function isSymlink(filePath: string): boolean {
24
- try {
25
- return lstatSync(filePath).isSymbolicLink();
26
- } catch (err) {
27
- debugLog("lstatSync", err);
28
- return false;
29
- }
30
- }
31
-
32
- /**
33
- * Safely read a file, rejecting symlinks.
34
- * Returns undefined if the file doesn't exist, is a symlink, or can't be read.
35
- */
36
- export function safeReadFile(filePath: string): string | undefined {
37
- if (!existsSync(filePath)) return undefined;
38
- if (isSymlink(filePath)) return undefined;
39
- try {
40
- return readFileSync(filePath, "utf-8");
41
- } catch (err) {
42
- debugLog("readFileSync", err);
43
- return undefined;
44
- }
45
- }
@@ -1,104 +0,0 @@
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 { debugLog } from "#src/debug";
27
- import { isSymlink, isUnsafeName, safeReadFile } from "#src/session/safe-fs";
28
-
29
- export interface PreloadedSkill {
30
- name: string;
31
- content: string;
32
- }
33
-
34
- export function preloadSkills(skillNames: string[], cwd: string): PreloadedSkill[] {
35
- return skillNames.map((name) => ({ name, content: loadSkillContent(name, cwd) }));
36
- }
37
-
38
- function loadSkillContent(name: string, cwd: string): string {
39
- if (isUnsafeName(name)) {
40
- return `(Skill "${name}" skipped: name contains path traversal characters)`;
41
- }
42
- const roots = [
43
- join(cwd, ".pi", "skills"), // project — Pi standard
44
- join(cwd, ".agents", "skills"), // project — Agent Skills spec
45
- join(getAgentDir(), "skills"), // user — Pi standard
46
- join(homedir(), ".agents", "skills"), // user — Agent Skills spec
47
- join(homedir(), ".pi", "skills"), // legacy global, pre-Pi
48
- ];
49
- for (const root of roots) {
50
- const content = findInRoot(root, name);
51
- if (content !== undefined) return content;
52
- }
53
- return `(Skill "${name}" not found in .pi/skills/, .agents/skills/, or global skill locations)`;
54
- }
55
-
56
- function findInRoot(root: string, name: string): string | undefined {
57
- if (isSymlink(root)) return undefined; // reject symlinked roots entirely
58
- const flat = safeReadFile(join(root, `${name}.md`))?.trim();
59
- if (flat !== undefined) return flat;
60
- return findSkillDirectory(root, name);
61
- }
62
-
63
- /** BFS under `root` for a directory named `name` containing `SKILL.md`. Pi-conforming filters. */
64
- function findSkillDirectory(root: string, name: string): string | undefined {
65
- if (!existsSync(root)) return undefined;
66
- const queue: string[] = [root];
67
-
68
- while (queue.length > 0) {
69
- const current = queue.shift();
70
- if (current === undefined) continue;
71
-
72
- let entries: Dirent[];
73
- try {
74
- entries = readdirSync(current, { withFileTypes: true });
75
- } catch (err) {
76
- debugLog("readdirSync skill root", err);
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
- }