@yandy0725/pi-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.
Files changed (61) hide show
  1. package/README.md +155 -0
  2. package/README.zh.md +155 -0
  3. package/index.ts +1 -0
  4. package/package.json +49 -0
  5. package/src/config/agent-types.ts +127 -0
  6. package/src/config/custom-agents.ts +109 -0
  7. package/src/config/default-agents.ts +117 -0
  8. package/src/config/invocation-config.ts +30 -0
  9. package/src/debug.ts +14 -0
  10. package/src/handlers/index.ts +3 -0
  11. package/src/handlers/interrupt.ts +49 -0
  12. package/src/handlers/lifecycle.ts +63 -0
  13. package/src/handlers/tool-start.ts +32 -0
  14. package/src/index.ts +186 -0
  15. package/src/layered-settings.ts +105 -0
  16. package/src/lifecycle/child-lifecycle.ts +88 -0
  17. package/src/lifecycle/concurrency-limiter.ts +55 -0
  18. package/src/lifecycle/create-subagent-session.ts +240 -0
  19. package/src/lifecycle/parent-snapshot.ts +45 -0
  20. package/src/lifecycle/run-listeners.ts +37 -0
  21. package/src/lifecycle/subagent-manager.ts +353 -0
  22. package/src/lifecycle/subagent-session.ts +232 -0
  23. package/src/lifecycle/subagent-state.ts +216 -0
  24. package/src/lifecycle/subagent.ts +498 -0
  25. package/src/lifecycle/turn-limits.ts +13 -0
  26. package/src/lifecycle/usage.ts +65 -0
  27. package/src/lifecycle/workspace-bracket.ts +59 -0
  28. package/src/lifecycle/workspace.ts +47 -0
  29. package/src/observation/composite-subagent-observer.ts +49 -0
  30. package/src/observation/notification-state.ts +27 -0
  31. package/src/observation/notification.ts +186 -0
  32. package/src/observation/record-observer.ts +75 -0
  33. package/src/observation/renderer.ts +63 -0
  34. package/src/observation/subagent-events-observer.ts +94 -0
  35. package/src/runtime.ts +77 -0
  36. package/src/service/service-adapter.ts +131 -0
  37. package/src/service/service.ts +123 -0
  38. package/src/session/content-items.ts +51 -0
  39. package/src/session/context.ts +78 -0
  40. package/src/session/conversation.ts +44 -0
  41. package/src/session/env.ts +40 -0
  42. package/src/session/model-resolver.ts +121 -0
  43. package/src/session/prompts.ts +83 -0
  44. package/src/session/session-config.ts +172 -0
  45. package/src/session/session-dir.ts +38 -0
  46. package/src/settings.ts +227 -0
  47. package/src/tools/agent-tool.ts +220 -0
  48. package/src/tools/background-spawner.ts +66 -0
  49. package/src/tools/foreground-runner.ts +114 -0
  50. package/src/tools/get-result-tool.ts +120 -0
  51. package/src/tools/helpers.ts +105 -0
  52. package/src/tools/result-renderer.ts +109 -0
  53. package/src/tools/spawn-config.ts +150 -0
  54. package/src/tools/steer-tool.ts +90 -0
  55. package/src/types.ts +115 -0
  56. package/src/ui/agent-widget.ts +311 -0
  57. package/src/ui/display.ts +174 -0
  58. package/src/ui/session-navigation.ts +147 -0
  59. package/src/ui/session-navigator.ts +406 -0
  60. package/src/ui/subagents-settings.ts +77 -0
  61. package/src/ui/widget-renderer.ts +296 -0
@@ -0,0 +1,117 @@
1
+ /**
2
+ * default-agents.ts — Embedded default agent configurations.
3
+ *
4
+ * These are always available but can be overridden by user .md files with the same name.
5
+ */
6
+
7
+ import type { AgentConfig } from "../types";
8
+
9
+ const READ_ONLY_TOOLS = ["read", "bash", "grep", "find", "ls"];
10
+
11
+ export const DEFAULT_AGENTS: Map<string, AgentConfig> = new Map([
12
+ [
13
+ "general-purpose",
14
+ {
15
+ name: "general-purpose",
16
+ displayName: "Agent",
17
+ description: "General-purpose agent for complex, multi-step tasks",
18
+ // builtinToolNames omitted — means "all available tools" (resolved at lookup time)
19
+ // inheritContext / runInBackground omitted — strategy fields, callers decide per-call.
20
+ // Setting them to false would lock callsite intent (see resolveAgentInvocationConfig in invocation-config.ts).
21
+ systemPrompt: "",
22
+ promptMode: "append",
23
+ isDefault: true,
24
+ },
25
+ ],
26
+ [
27
+ "Explore",
28
+ {
29
+ name: "Explore",
30
+ displayName: "Explore",
31
+ description: "Fast codebase exploration agent (read-only)",
32
+ builtinToolNames: READ_ONLY_TOOLS,
33
+ model: "anthropic/claude-haiku-4-5-20251001",
34
+ systemPrompt: `# CRITICAL: READ-ONLY MODE - NO FILE MODIFICATIONS
35
+ You are a file search specialist. You excel at thoroughly navigating and exploring codebases.
36
+ Your role is EXCLUSIVELY to search and analyze existing code. You do NOT have access to file editing tools.
37
+
38
+ You are STRICTLY PROHIBITED from:
39
+ - Creating new files
40
+ - Modifying existing files
41
+ - Deleting files
42
+ - Moving or copying files
43
+ - Creating temporary files anywhere, including /tmp
44
+ - Using redirect operators (>, >>, |) or heredocs to write to files
45
+ - Running ANY commands that change system state
46
+
47
+ Use Bash ONLY for read-only operations: ls, git status, git log, git diff, find, cat, head, tail.
48
+
49
+ # Tool Usage
50
+ - Use the find tool for file pattern matching (NOT the bash find command)
51
+ - Use the grep tool for content search (NOT bash grep/rg command)
52
+ - Use the read tool for reading files (NOT bash cat/head/tail)
53
+ - Use Bash ONLY for read-only operations
54
+ - Make independent tool calls in parallel for efficiency
55
+ - Adapt search approach based on thoroughness level specified
56
+
57
+ # Output
58
+ - Use absolute file paths in all references
59
+ - Report findings as regular messages
60
+ - Do not use emojis
61
+ - Be thorough and precise`,
62
+ promptMode: "replace",
63
+ isDefault: true,
64
+ },
65
+ ],
66
+ [
67
+ "Plan",
68
+ {
69
+ name: "Plan",
70
+ displayName: "Plan",
71
+ description: "Software architect for implementation planning (read-only)",
72
+ builtinToolNames: READ_ONLY_TOOLS,
73
+ systemPrompt: `# CRITICAL: READ-ONLY MODE - NO FILE MODIFICATIONS
74
+ You are a software architect and planning specialist.
75
+ Your role is EXCLUSIVELY to explore the codebase and design implementation plans.
76
+ You do NOT have access to file editing tools — attempting to edit files will fail.
77
+
78
+ You are STRICTLY PROHIBITED from:
79
+ - Creating new files
80
+ - Modifying existing files
81
+ - Deleting files
82
+ - Moving or copying files
83
+ - Creating temporary files anywhere, including /tmp
84
+ - Using redirect operators (>, >>, |) or heredocs to write to files
85
+ - Running ANY commands that change system state
86
+
87
+ # Planning Process
88
+ 1. Understand requirements
89
+ 2. Explore thoroughly (read files, find patterns, understand architecture)
90
+ 3. Design solution based on your assigned perspective
91
+ 4. Detail the plan with step-by-step implementation strategy
92
+
93
+ # Requirements
94
+ - Consider trade-offs and architectural decisions
95
+ - Identify dependencies and sequencing
96
+ - Anticipate potential challenges
97
+ - Follow existing patterns where appropriate
98
+
99
+ # Tool Usage
100
+ - Use the find tool for file pattern matching (NOT the bash find command)
101
+ - Use the grep tool for content search (NOT bash grep/rg command)
102
+ - Use the read tool for reading files (NOT bash cat/head/tail)
103
+ - Use Bash ONLY for read-only operations
104
+
105
+ # Output Format
106
+ - Use absolute file paths
107
+ - Do not use emojis
108
+ - End your response with:
109
+
110
+ ### Critical Files for Implementation
111
+ List 3-5 files most critical for implementing this plan:
112
+ - /absolute/path/to/file.ts - [Brief reason]`,
113
+ promptMode: "replace",
114
+ isDefault: true,
115
+ },
116
+ ],
117
+ ]);
@@ -0,0 +1,30 @@
1
+ import type { AgentConfig, ThinkingLevel } from "../types";
2
+
3
+ interface AgentInvocationParams {
4
+ model?: string;
5
+ thinking?: string;
6
+ max_turns?: number;
7
+ run_in_background?: boolean;
8
+ inherit_context?: boolean;
9
+ }
10
+
11
+ export function resolveAgentInvocationConfig(
12
+ agentConfig: AgentConfig | undefined,
13
+ params: AgentInvocationParams,
14
+ ): {
15
+ modelInput?: string;
16
+ modelFromParams: boolean;
17
+ thinking?: ThinkingLevel;
18
+ maxTurns?: number;
19
+ inheritContext: boolean;
20
+ runInBackground: boolean;
21
+ } {
22
+ return {
23
+ modelInput: agentConfig?.model ?? params.model,
24
+ modelFromParams: agentConfig?.model == null && params.model != null,
25
+ thinking: (agentConfig?.thinking ?? params.thinking) as ThinkingLevel | undefined,
26
+ maxTurns: agentConfig?.maxTurns ?? params.max_turns,
27
+ inheritContext: agentConfig?.inheritContext ?? params.inherit_context ?? false,
28
+ runInBackground: agentConfig?.runInBackground ?? params.run_in_background ?? false,
29
+ };
30
+ }
package/src/debug.ts ADDED
@@ -0,0 +1,14 @@
1
+ /**
2
+ * debug.ts — Debug logging utility for silenced catch blocks.
3
+ *
4
+ * Set PI_SUBAGENTS_DEBUG=1 to reveal silent failures in catch blocks
5
+ * throughout the package. Production behavior is unchanged when unset.
6
+ */
7
+
8
+ export function isDebug(): boolean {
9
+ return process.env.PI_SUBAGENTS_DEBUG === "1";
10
+ }
11
+
12
+ export function debugLog(context: string, err: unknown): void {
13
+ if (isDebug()) console.warn(`[pi-subagents:debug] ${context}:`, err);
14
+ }
@@ -0,0 +1,3 @@
1
+ export { InterruptHandler } from "../handlers/interrupt";
2
+ export { SessionLifecycleHandler } from "../handlers/lifecycle";
3
+ export { ToolStartHandler } from "../handlers/tool-start";
@@ -0,0 +1,49 @@
1
+ /**
2
+ * turn_start event handler that aborts subagents on a parent interrupt (ESC).
3
+ *
4
+ * The parent agent loop creates a fresh AbortController per run and only aborts
5
+ * it on an explicit interrupt — never on normal completion. So latching to the
6
+ * current run's signal and aborting on its `abort` event fires exactly on ESC.
7
+ *
8
+ * `turn_start` carries the live per-run `ctx.signal`, so re-latching each turn
9
+ * keeps the handler tracking the current signal across runs and tool-less turns.
10
+ */
11
+
12
+ /** Narrow manager interface — only the method the interrupt handler calls. */
13
+ export interface InterruptManager {
14
+ abortAll(): number;
15
+ }
16
+
17
+ /** Minimal context shape — only the field the handler reads. */
18
+ interface InterruptCtx {
19
+ signal: AbortSignal | undefined;
20
+ }
21
+
22
+ /**
23
+ * Latches the current parent abort signal and aborts all subagents when it fires.
24
+ *
25
+ * The latch dedups by reference: most turns reuse the same signal (no-op); a new
26
+ * run's signal triggers a detach-and-rewire. The `abort` listener is one-shot.
27
+ */
28
+ export class InterruptHandler {
29
+ private latched?: AbortSignal;
30
+ private detach?: () => void;
31
+
32
+ constructor(private readonly manager: InterruptManager) {}
33
+
34
+ handleTurnStart(ctx: InterruptCtx): void {
35
+ const signal = ctx.signal;
36
+ if (signal === this.latched) return;
37
+
38
+ this.detach?.();
39
+ this.detach = undefined;
40
+ this.latched = signal;
41
+ if (!signal) return;
42
+
43
+ const onAbort = (): void => {
44
+ this.manager.abortAll();
45
+ };
46
+ signal.addEventListener("abort", onAbort, { once: true });
47
+ this.detach = () => signal.removeEventListener("abort", onAbort);
48
+ }
49
+ }
@@ -0,0 +1,63 @@
1
+ import type { SessionContext } from "../types";
2
+
3
+ /**
4
+ * Session lifecycle event handlers: session_start, session_before_switch, session_shutdown.
5
+ *
6
+ * Extracted from index.ts so each handler can be tested in isolation
7
+ * with mocked narrow interfaces.
8
+ */
9
+
10
+ /** Narrow manager interface — only the methods lifecycle handlers call. */
11
+ export interface LifecycleManager {
12
+ clearCompleted(): void;
13
+ abortAll(): void;
14
+ dispose(): void;
15
+ }
16
+
17
+ /** Narrow runtime interface — only the methods lifecycle handlers call. */
18
+ export interface LifecycleRuntime {
19
+ setSessionContext(ctx: SessionContext): void;
20
+ clearSessionContext(): void;
21
+ }
22
+
23
+ /**
24
+ * Handles session lifecycle events.
25
+ *
26
+ * Constructor deps:
27
+ * - `runtime` — owns session context state
28
+ * - `manager` — manages agent lifecycle (clear, abort, dispose)
29
+ * - `disposeNotifications` — tears down the notification system on shutdown
30
+ * - `unpublishService` — unpublishes the SubagentsService symbol on shutdown
31
+ */
32
+ export class SessionLifecycleHandler {
33
+ constructor(
34
+ private readonly runtime: LifecycleRuntime,
35
+ private readonly manager: LifecycleManager,
36
+ private readonly disposeNotifications: () => void,
37
+ private readonly unpublishService: () => void,
38
+ ) {}
39
+
40
+ handleSessionStart(_event: unknown, ctx: unknown): void {
41
+ this.runtime.setSessionContext(ctx as SessionContext);
42
+ this.manager.clearCompleted();
43
+ }
44
+
45
+ handleSessionBeforeSwitch(): void {
46
+ this.manager.clearCompleted();
47
+ }
48
+
49
+ // Cleanup order matters:
50
+ // 1. Unpublish service — prevent new cross-extension calls
51
+ // 2. Clear session context — no more session state
52
+ // 3. Abort all agents — stop running work
53
+ // 4. Dispose notifications — cancel pending nudges/timers
54
+ // 5. Dispose manager — final cleanup
55
+ handleSessionShutdown(): Promise<void> {
56
+ this.unpublishService();
57
+ this.runtime.clearSessionContext();
58
+ this.manager.abortAll();
59
+ this.disposeNotifications();
60
+ this.manager.dispose();
61
+ return Promise.resolve();
62
+ }
63
+ }
@@ -0,0 +1,32 @@
1
+ /**
2
+ * tool_execution_start event handler.
3
+ *
4
+ * Extracted from index.ts so the handler can be tested in isolation
5
+ * with a mocked narrow runtime interface.
6
+ */
7
+
8
+ /** Narrow widget interface — only the methods the handler calls. */
9
+ export interface ToolStartWidget {
10
+ setUICtx(ctx: unknown): void;
11
+ onTurnStart(): void;
12
+ }
13
+
14
+ /** Minimal context shape for tool_execution_start — only the field the handler reads. */
15
+ interface ToolStartCtx {
16
+ ui: unknown;
17
+ }
18
+
19
+ /**
20
+ * Handles tool_execution_start events.
21
+ *
22
+ * Grabs UI context from the first tool execution of each turn
23
+ * and signals the widget to clear lingering state.
24
+ */
25
+ export class ToolStartHandler {
26
+ constructor(private readonly widget: ToolStartWidget) {}
27
+
28
+ handleToolExecutionStart(_event: unknown, ctx: ToolStartCtx): void {
29
+ this.widget.setUICtx(ctx.ui);
30
+ this.widget.onTurnStart();
31
+ }
32
+ }
package/src/index.ts ADDED
@@ -0,0 +1,186 @@
1
+ /* eslint-disable @typescript-eslint/no-unsafe-argument -- Pi SDK types are not fully exported; see upstream Pi SDK for type improvements */
2
+ /**
3
+ * pi-agents — A pi extension providing focused, in-process autonomous sub-agents.
4
+ *
5
+ * Tools:
6
+ * Agent — LLM-callable: spawn a sub-agent
7
+ * get_subagent_result — LLM-callable: check background agent status/result
8
+ * steer_subagent — LLM-callable: send a steering message to a running agent
9
+ *
10
+ * Commands:
11
+ */
12
+
13
+ import { readFileSync } from "node:fs";
14
+ import {
15
+ createAgentSession,
16
+ DefaultResourceLoader,
17
+ type ExtensionAPI,
18
+ getAgentDir,
19
+ SettingsManager as SdkSettingsManager,
20
+ SessionManager,
21
+ } from "@earendil-works/pi-coding-agent";
22
+ import { AgentTypeRegistry } from "./config/agent-types";
23
+ import { loadCustomAgents } from "./config/custom-agents";
24
+ import { InterruptHandler, SessionLifecycleHandler, ToolStartHandler } from "./handlers/index";
25
+ import { createChildLifecyclePublisher } from "./lifecycle/child-lifecycle";
26
+ import { ConcurrencyLimiter } from "./lifecycle/concurrency-limiter";
27
+ import { createSubagentSession, type SubagentSessionDeps } from "./lifecycle/create-subagent-session";
28
+ import { SubagentManager } from "./lifecycle/subagent-manager";
29
+ import { CompositeSubagentObserver } from "./observation/composite-subagent-observer";
30
+ import { type NotificationDetails, NotificationManager } from "./observation/notification";
31
+ import { createNotificationRenderer } from "./observation/renderer";
32
+ import { SubagentEventsObserver } from "./observation/subagent-events-observer";
33
+ import { createSubagentRuntime } from "./runtime";
34
+ import { publishSubagentsService, unpublishSubagentsService } from "./service/service";
35
+ import { SubagentsServiceAdapter } from "./service/service-adapter";
36
+ import { detectEnv } from "./session/env";
37
+
38
+ import { resolveModel } from "./session/model-resolver";
39
+ import { buildAgentPrompt } from "./session/prompts";
40
+ import { deriveSubagentSessionDir } from "./session/session-dir";
41
+ import { SettingsManager } from "./settings";
42
+ import { AgentTool } from "./tools/agent-tool";
43
+ import { GetResultTool } from "./tools/get-result-tool";
44
+ import { SteerTool } from "./tools/steer-tool";
45
+ import { AgentWidget } from "./ui/agent-widget";
46
+ import { SessionNavigatorHandler } from "./ui/session-navigator";
47
+ import { SubagentsSettingsHandler } from "./ui/subagents-settings";
48
+
49
+ export default function (pi: ExtensionAPI) {
50
+ // ---- Register custom notification renderer ----
51
+ pi.registerMessageRenderer<NotificationDetails>("subagent-notification", createNotificationRenderer());
52
+
53
+ const registry = new AgentTypeRegistry(() => loadCustomAgents(process.cwd()));
54
+
55
+ // ---- Runtime: all mutable extension state in one place ----
56
+ const runtime = createSubagentRuntime();
57
+
58
+ // ---- Notification system ----
59
+ // Owns completion nudges and live-activity cleanup. The widget detects finished
60
+ // agents itself (AgentWidget.update self-seeds), so NotificationManager has no
61
+ // widget dependency — keeping the construction graph a cycle-free DAG.
62
+ const notifications = new NotificationManager((msg, opts) => pi.sendMessage(msg, opts));
63
+
64
+ // Settings: owns all three in-memory values and handles load/save/emit.
65
+ // onMaxConcurrentChanged is wired to the limiter directly (closure captures by reference).
66
+ const settings = new SettingsManager({
67
+ emit: (event, payload) => pi.events.emit(event, payload),
68
+ cwd: process.cwd(),
69
+ agentDir: getAgentDir(),
70
+ onMaxConcurrentChanged: () => limiter.recheck(),
71
+ });
72
+ settings.load();
73
+
74
+ // Observer: receives agent lifecycle notifications and dispatches events/notifications.
75
+ const eventsObserver = new SubagentEventsObserver({
76
+ emit: (channel, data) => pi.events.emit(channel, data),
77
+ appendEntry: (customType, data) => pi.appendEntry(customType, data),
78
+ notifications,
79
+ });
80
+
81
+ // Fan-out observer: lets the widget subscribe as a second lifecycle consumer
82
+ // while the manager keeps its single-observer contract. The widget is added
83
+ // after construction (it needs the manager); the manager consults the observer
84
+ // only at spawn time, so registering late is safe.
85
+ const observer = new CompositeSubagentObserver([eventsObserver]);
86
+
87
+ const subagentSessionDeps: SubagentSessionDeps = {
88
+ io: {
89
+ detectEnv,
90
+ getAgentDir,
91
+ createResourceLoader: (opts) => new DefaultResourceLoader(opts),
92
+ deriveSessionDir: deriveSubagentSessionDir,
93
+ createSessionManager: (cwd, dir) => SessionManager.create(cwd, dir),
94
+ createSettingsManager: (cwd, dir) => SdkSettingsManager.create(cwd, dir),
95
+ createSession: (opts) => createAgentSession(opts as any),
96
+ assemblerIO: {
97
+ buildAgentPrompt,
98
+ },
99
+ },
100
+ exec: (cmd, args, opts) => pi.exec(cmd, args, opts),
101
+ registry,
102
+ lifecycle: createChildLifecyclePublisher((channel, data) => pi.events.emit(channel, data)),
103
+ };
104
+
105
+ // ConcurrencyLimiter: schedules background run thunks FIFO against the limit.
106
+ // It knows nothing about agents or the manager — dependency direction is strictly manager → limiter.
107
+ const limiter = new ConcurrencyLimiter(() => settings.maxConcurrent);
108
+
109
+ const manager = new SubagentManager({
110
+ createSubagentSession: (params) => createSubagentSession(params, subagentSessionDeps),
111
+ baseCwd: process.cwd(),
112
+ observer,
113
+ limiter,
114
+ getRunConfig: () => settings,
115
+ });
116
+
117
+ // Typed service published via Symbol.for() for cross-extension access.
118
+ // Consumers: const { getSubagentsService } = await import("@yandy0725/pi-subagents");
119
+ const service = new SubagentsServiceAdapter(manager, resolveModel, runtime);
120
+ publishSubagentsService(service);
121
+
122
+ const lifecycle = new SessionLifecycleHandler(
123
+ runtime,
124
+ manager,
125
+ () => notifications.dispose(),
126
+ unpublishSubagentsService,
127
+ );
128
+
129
+ pi.on("session_start", (event, ctx) => lifecycle.handleSessionStart(event, ctx));
130
+ pi.on("session_before_switch", () => lifecycle.handleSessionBeforeSwitch());
131
+ pi.on("session_shutdown", () => lifecycle.handleSessionShutdown());
132
+
133
+ // Live widget: constructed after the manager (it polls listAgents()) and
134
+ // registered as a lifecycle observer so it self-drives its update timer.
135
+ const widget = new AgentWidget(manager, registry);
136
+ observer.add(widget);
137
+
138
+ // Grab UI context from first tool execution + clear lingering widget on new turn
139
+ const toolStart = new ToolStartHandler(widget);
140
+ pi.on("tool_execution_start", (event, ctx) => toolStart.handleToolExecutionStart(event, ctx));
141
+
142
+ // Abort all subagents when the parent agent loop is interrupted (ESC).
143
+ const interrupt = new InterruptHandler(manager);
144
+ pi.on("turn_start", (_event, ctx) => interrupt.handleTurnStart(ctx));
145
+
146
+ // ---- Agent tool ----
147
+
148
+ pi.registerTool(new AgentTool(manager, runtime, settings, registry, getAgentDir()).toToolDefinition());
149
+
150
+ // ---- get_subagent_result tool ----
151
+
152
+ pi.registerTool(new GetResultTool(manager, notifications, registry).toToolDefinition());
153
+
154
+ // ---- steer_subagent tool ----
155
+
156
+ pi.registerTool(new SteerTool(manager, pi.events).toToolDefinition());
157
+
158
+ // ---- /subagents:settings command ----
159
+
160
+ const subagentsSettings = new SubagentsSettingsHandler(settings);
161
+
162
+ pi.registerCommand("subagents:settings", {
163
+ description: "Configure subagent settings (concurrency, turn limits)",
164
+ handler: async (_args, ctx) => {
165
+ await subagentsSettings.handle({ ui: ctx.ui });
166
+ },
167
+ });
168
+
169
+ // ---- /subagents:sessions command ----
170
+
171
+ const sessionNavigator = new SessionNavigatorHandler();
172
+
173
+ pi.registerCommand("subagents:sessions", {
174
+ description: "View a subagent's session transcript (read-only)",
175
+ handler: async (_args, ctx) => {
176
+ await sessionNavigator.handle({
177
+ ui: ctx.ui,
178
+ agents: manager.listAgents(),
179
+ evicted: manager.listEvicted(),
180
+ registry,
181
+ cwd: ctx.cwd,
182
+ readFile: (path) => readFileSync(path, "utf8"),
183
+ });
184
+ },
185
+ });
186
+ }
@@ -0,0 +1,105 @@
1
+ /**
2
+ * Generic layered settings loader for `@yandy0725/pi-*` extensions.
3
+ *
4
+ * Extensions that store configuration in JSON files under a global agent
5
+ * directory and a per-project `.pi/` folder share the same three-step idiom:
6
+ *
7
+ * 1. Read the global file (`<agentDir>/<filename>`).
8
+ * 2. Read the project file (`<cwd>/.pi/<filename>`).
9
+ * 3. Merge them — project wins on conflicts — and return the result.
10
+ *
11
+ * Both layers are optional: a missing file is silent (`{}`), and a file that
12
+ * cannot be parsed warns to stderr and is treated as absent so startup
13
+ * proceeds normally.
14
+ *
15
+ * ## Usage
16
+ *
17
+ * ```typescript
18
+ * import { loadLayeredSettings, type LayeredSettingsSource } from "@yandy0725/pi-subagents/settings";
19
+ *
20
+ * interface MyConfig { enabled?: boolean; limit?: number }
21
+ *
22
+ * function sanitize(raw: unknown): Partial<MyConfig> {
23
+ * if (!raw || typeof raw !== "object") return {};
24
+ * const r = raw as Record<string, unknown>;
25
+ * const out: Partial<MyConfig> = {};
26
+ * if (typeof r.enabled === "boolean") out.enabled = r.enabled;
27
+ * if (typeof r.limit === "number") out.limit = r.limit;
28
+ * return out;
29
+ * }
30
+ *
31
+ * const config = loadLayeredSettings<MyConfig>({
32
+ * agentDir, // e.g. from the Pi runtime env — the agent home directory
33
+ * cwd, // project root — project file is at <cwd>/.pi/<filename>
34
+ * filename: "my-extension.json",
35
+ * sanitize,
36
+ * warnLabel: "my-extension",
37
+ * });
38
+ * ```
39
+ *
40
+ * @public
41
+ */
42
+
43
+ import { existsSync, readFileSync } from "node:fs";
44
+ import { join } from "node:path";
45
+
46
+ /**
47
+ * Parameters for one layered settings load: describes where the files live,
48
+ * how to validate their contents, and what label to use in warnings.
49
+ *
50
+ * @public
51
+ */
52
+ export interface LayeredSettingsSource<T> {
53
+ /** Directory holding the global settings file (typically the Pi agent dir). */
54
+ agentDir: string;
55
+ /** Project root; the project file lives at `<cwd>/.pi/<filename>`. */
56
+ cwd: string;
57
+ /** Base filename for both layers, e.g. `"subagents.json"`. */
58
+ filename: string;
59
+ /**
60
+ * Validate and coerce parsed JSON into a partial settings object.
61
+ * Unknown or invalid fields should be silently dropped — return `{}` for
62
+ * unrecognised shapes. Never throw.
63
+ */
64
+ sanitize: (raw: unknown) => Partial<T>;
65
+ /**
66
+ * Short label used in the malformed-file warning prefix,
67
+ * e.g. `"pi-subagents"` → `"[pi-subagents] Ignoring malformed settings at …"`.
68
+ */
69
+ warnLabel: string;
70
+ }
71
+
72
+ /**
73
+ * Load merged layered settings: global provides defaults, project overrides.
74
+ *
75
+ * - A missing file is silent — returns `{}` for that layer.
76
+ * - A file that exists but cannot be parsed warns to stderr and returns `{}` for
77
+ * that layer, so startup proceeds normally.
78
+ * - The two layers are merged with a shallow spread; project keys win.
79
+ *
80
+ * Throws nothing. All error conditions produce a warning and fall back to `{}`.
81
+ *
82
+ * @public
83
+ */
84
+ export function loadLayeredSettings<T>(source: LayeredSettingsSource<T>): Partial<T> {
85
+ const { agentDir, cwd, filename, sanitize, warnLabel } = source;
86
+ const global = readLayer(join(agentDir, filename), sanitize, warnLabel);
87
+ const project = readLayer(join(cwd, ".pi", filename), sanitize, warnLabel);
88
+ return { ...global, ...project };
89
+ }
90
+
91
+ // ── Private helpers ──────────────────────────────────────────────────────────
92
+
93
+ /**
94
+ * Read one settings file. Missing → `{}` (silent). Malformed → `{}` + warn.
95
+ */
96
+ function readLayer<T>(path: string, sanitize: (raw: unknown) => Partial<T>, warnLabel: string): Partial<T> {
97
+ if (!existsSync(path)) return {};
98
+ try {
99
+ return sanitize(JSON.parse(readFileSync(path, "utf-8")));
100
+ } catch (err) {
101
+ const reason = err instanceof Error ? err.message : String(err);
102
+ console.warn(`[${warnLabel}] Ignoring malformed settings at ${path}: ${reason}`);
103
+ return {};
104
+ }
105
+ }