@yefengr/pi-subagents 0.0.0-stage → 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 (103) hide show
  1. package/CHANGELOG.md +2411 -0
  2. package/LICENSE +21 -0
  3. package/README.md +374 -2
  4. package/UPSTREAM.md +48 -0
  5. package/dist/public.d.ts +201 -0
  6. package/dist/settings.d.ts +82 -0
  7. package/docs/architecture/architecture.md +833 -0
  8. package/docs/architecture/client-server-opportunities.md +127 -0
  9. package/docs/architecture/history/phase-1-api-boundary.md +8 -0
  10. package/docs/architecture/history/phase-10-structural-decomposition.md +141 -0
  11. package/docs/architecture/history/phase-11-closure-to-class.md +100 -0
  12. package/docs/architecture/history/phase-12-complexity-test-fixtures.md +55 -0
  13. package/docs/architecture/history/phase-13-remaining-smells.md +88 -0
  14. package/docs/architecture/history/phase-14-strip-policy.md +49 -0
  15. package/docs/architecture/history/phase-15-domain-model-evolution.md +73 -0
  16. package/docs/architecture/history/phase-16-invert-dependencies.md +144 -0
  17. package/docs/architecture/history/phase-17-core-consolidation.md +214 -0
  18. package/docs/architecture/history/phase-18-reconsider-ui.md +166 -0
  19. package/docs/architecture/history/phase-19-implement-ui-decisions.md +282 -0
  20. package/docs/architecture/history/phase-2-remove-scheduling.md +9 -0
  21. package/docs/architecture/history/phase-20-result-delivery.md +245 -0
  22. package/docs/architecture/history/phase-21-classification-model-boundary.md +107 -0
  23. package/docs/architecture/history/phase-3-remove-rpc-groupjoin.md +11 -0
  24. package/docs/architecture/history/phase-4-implement-service.md +8 -0
  25. package/docs/architecture/history/phase-5-decompose-index.md +42 -0
  26. package/docs/architecture/history/phase-7-encapsulation.md +173 -0
  27. package/docs/architecture/history/phase-8-testability.md +103 -0
  28. package/docs/architecture/history/phase-9-observation-ctx.md +122 -0
  29. package/docs/comparison-with-upstream.md +79 -0
  30. package/docs/configuration.md +215 -0
  31. package/docs/decisions/0001-deferred-patches.md +80 -0
  32. package/docs/decisions/0002-extensions-on-a-minimal-core.md +114 -0
  33. package/docs/decisions/0003-publish-bundled-type-declarations.md +71 -0
  34. package/docs/decisions/0004-reconsider-ui-direction.md +279 -0
  35. package/index.ts +1 -0
  36. package/package.json +91 -4
  37. package/src/config/agent-enablement.ts +132 -0
  38. package/src/config/agent-types.ts +144 -0
  39. package/src/config/custom-agents.ts +114 -0
  40. package/src/config/default-agents.ts +120 -0
  41. package/src/config/invocation-config.ts +39 -0
  42. package/src/config/resource-limits.ts +26 -0
  43. package/src/config/settings-file.ts +174 -0
  44. package/src/debug.ts +14 -0
  45. package/src/handlers/index.ts +3 -0
  46. package/src/handlers/interrupt.ts +58 -0
  47. package/src/handlers/lifecycle.ts +65 -0
  48. package/src/handlers/tool-start.ts +32 -0
  49. package/src/index.ts +257 -0
  50. package/src/layered-settings.ts +105 -0
  51. package/src/lifecycle/child-lifecycle.ts +89 -0
  52. package/src/lifecycle/child-shutdown.ts +105 -0
  53. package/src/lifecycle/concurrency-limiter.ts +123 -0
  54. package/src/lifecycle/create-subagent-session.ts +265 -0
  55. package/src/lifecycle/invocation-control.ts +109 -0
  56. package/src/lifecycle/lifecycle-record.ts +159 -0
  57. package/src/lifecycle/parent-snapshot.ts +46 -0
  58. package/src/lifecycle/run-listeners.ts +41 -0
  59. package/src/lifecycle/subagent-manager.ts +392 -0
  60. package/src/lifecycle/subagent-session.ts +287 -0
  61. package/src/lifecycle/subagent-state.ts +307 -0
  62. package/src/lifecycle/subagent.ts +854 -0
  63. package/src/lifecycle/usage.ts +60 -0
  64. package/src/lifecycle/workspace-bracket.ts +59 -0
  65. package/src/lifecycle/workspace.ts +47 -0
  66. package/src/observation/composite-subagent-observer.ts +53 -0
  67. package/src/observation/notification.ts +233 -0
  68. package/src/observation/record-observer.ts +78 -0
  69. package/src/observation/renderer.ts +106 -0
  70. package/src/observation/subagent-events-observer.ts +75 -0
  71. package/src/runtime.ts +78 -0
  72. package/src/service/service-adapter.ts +159 -0
  73. package/src/service/service.ts +147 -0
  74. package/src/session/content-items.ts +53 -0
  75. package/src/session/context.ts +80 -0
  76. package/src/session/conversation.ts +49 -0
  77. package/src/session/env.ts +40 -0
  78. package/src/session/model-resolver.ts +135 -0
  79. package/src/session/package-exclusions.ts +75 -0
  80. package/src/session/prompts.ts +145 -0
  81. package/src/session/provider-inheritance.ts +38 -0
  82. package/src/session/session-config.ts +177 -0
  83. package/src/session/session-dir.ts +38 -0
  84. package/src/settings.ts +436 -0
  85. package/src/tools/agent-tool.ts +289 -0
  86. package/src/tools/background-spawner.ts +73 -0
  87. package/src/tools/foreground-runner.ts +135 -0
  88. package/src/tools/get-result-report.ts +73 -0
  89. package/src/tools/get-result-tool.ts +109 -0
  90. package/src/tools/helpers.ts +134 -0
  91. package/src/tools/result-renderer.ts +121 -0
  92. package/src/tools/spawn-config.ts +182 -0
  93. package/src/tools/steer-tool.ts +120 -0
  94. package/src/types.ts +145 -0
  95. package/src/ui/agent-widget.ts +313 -0
  96. package/src/ui/display.ts +214 -0
  97. package/src/ui/glyphs.ts +62 -0
  98. package/src/ui/session-navigation.ts +152 -0
  99. package/src/ui/session-navigator.ts +257 -0
  100. package/src/ui/subagents-agents.ts +116 -0
  101. package/src/ui/subagents-settings.ts +207 -0
  102. package/src/ui/transcript-content.ts +374 -0
  103. package/src/ui/widget-renderer.ts +289 -0
@@ -0,0 +1,114 @@
1
+ /**
2
+ * custom-agents.ts — Load user-defined agents from project (.pi/agents/) and global ($PI_CODING_AGENT_DIR/agents/, default ~/.pi/agent/agents/) locations.
3
+ */
4
+
5
+ import { existsSync, readdirSync, readFileSync } from "node:fs";
6
+ import { basename, join } from "node:path";
7
+ import { getAgentDir, parseFrontmatter } from "@earendil-works/pi-coding-agent";
8
+ import { BUILTIN_TOOL_NAMES } from "#src/config/agent-types";
9
+ import { debugLog } from "#src/debug";
10
+ import type { AgentConfig, ThinkingLevel } from "#src/types";
11
+
12
+ /**
13
+ * Scan for custom agent .md files from multiple locations.
14
+ * Discovery hierarchy (higher priority wins):
15
+ * 1. Project: <cwd>/.pi/agents/*.md
16
+ * 2. Global: $PI_CODING_AGENT_DIR/agents/*.md (default: ~/.pi/agent/agents/*.md)
17
+ *
18
+ * Project-level agents override global ones with the same name.
19
+ * Any name is allowed — names matching defaults (e.g. "Explore") override them.
20
+ */
21
+ export function loadCustomAgents(cwd: string): Map<string, AgentConfig> {
22
+ const globalDir = join(getAgentDir(), "agents");
23
+ const projectDir = join(cwd, ".pi", "agents");
24
+
25
+ const agents = new Map<string, AgentConfig>();
26
+ loadFromDir(globalDir, agents, "global"); // lower priority
27
+ loadFromDir(projectDir, agents, "project"); // higher priority (overwrites)
28
+ return agents;
29
+ }
30
+
31
+ /** Load agent configs from a directory into the map. */
32
+ function loadFromDir(dir: string, agents: Map<string, AgentConfig>, source: "project" | "global"): void {
33
+ if (!existsSync(dir)) return;
34
+
35
+ let files: string[];
36
+ try {
37
+ files = readdirSync(dir).filter(f => f.endsWith(".md"));
38
+ } catch (err) {
39
+ debugLog("readdirSync agents dir", err);
40
+ return;
41
+ }
42
+
43
+ for (const file of files) {
44
+ const name = basename(file, ".md");
45
+
46
+ let content: string;
47
+ try {
48
+ content = readFileSync(join(dir, file), "utf-8");
49
+ } catch (err) {
50
+ debugLog("readFileSync agent file", err);
51
+ continue;
52
+ }
53
+
54
+ const { frontmatter: fm, body } = parseFrontmatter(content);
55
+
56
+ agents.set(name, {
57
+ name,
58
+ displayName: str(fm.display_name),
59
+ description: str(fm.description) ?? name,
60
+ toolNames: listField(fm.tools, BUILTIN_TOOL_NAMES),
61
+ model: str(fm.model),
62
+ thinking: str(fm.thinking) as ThinkingLevel | undefined,
63
+ maxTurns: intAtLeast(fm.max_turns, 1),
64
+ wrapUpTurns: intAtLeast(fm.wrap_up_turns, 0),
65
+ maxDurationSeconds: intAtLeast(fm.max_duration_seconds, 1),
66
+ systemPrompt: body.trim(),
67
+ promptMode: fm.prompt_mode === "replace" ? "replace" : "append",
68
+ inheritContext: fm.inherit_context != null ? fm.inherit_context === true : undefined,
69
+ runInBackground: fm.run_in_background != null ? fm.run_in_background === true : undefined,
70
+ source,
71
+ filePath: join(dir, file),
72
+ });
73
+ }
74
+ }
75
+
76
+ // ---- Field parsers ----
77
+ // All follow the same convention: omitted → default, "none"/empty → nothing, value → exact.
78
+
79
+ /** Extract a string or undefined. */
80
+ function str(val: unknown): string | undefined {
81
+ return typeof val === "string" ? val : undefined;
82
+ }
83
+
84
+ /** Extract an integer at or above `min`, or undefined. 0 is not a special value. */
85
+ function intAtLeast(val: unknown, min: number): number | undefined {
86
+ return typeof val === "number" && Number.isInteger(val) && val >= min ? val : undefined;
87
+ }
88
+
89
+ /**
90
+ * Parse a raw list field into items, or undefined if absent/empty/"none".
91
+ *
92
+ * Frontmatter is YAML, so a list field is written either as a comma-separated
93
+ * scalar (`tools: read, grep`) or as a sequence (`tools: [read, grep]`). Both
94
+ * are supported: a sequence keeps its entries intact, while a scalar is split
95
+ * on commas.
96
+ */
97
+ function parseListField(val: unknown): string[] | undefined {
98
+ if (val === undefined || val === null) return undefined;
99
+ const items = Array.isArray(val)
100
+ ? val.map(entry => String(entry).trim()).filter(Boolean)
101
+ // eslint-disable-next-line @typescript-eslint/no-base-to-string -- val is already narrowed past null/undefined; String() is the intended coercion here
102
+ : String(val).trim().split(",").map(entry => entry.trim()).filter(Boolean);
103
+ if (items.length === 0) return undefined;
104
+ return items.length === 1 && items[0] === "none" ? undefined : items;
105
+ }
106
+
107
+ /**
108
+ * Parse a list field with defaults.
109
+ * omitted → defaults; "none"/empty → []; otherwise → listed items.
110
+ */
111
+ function listField(val: unknown, defaults: string[]): string[] {
112
+ if (val === undefined || val === null) return defaults;
113
+ return parseListField(val) ?? [];
114
+ }
@@ -0,0 +1,120 @@
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 "#src/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
+ toolGuideline: "- Use general-purpose for complex tasks that need file editing.",
19
+ // toolNames omitted — means "all available tools" (resolved at lookup time)
20
+ // inheritContext / runInBackground omitted — strategy fields, callers decide per-call.
21
+ // Setting them to false would lock callsite intent (see resolveAgentInvocationConfig in invocation-config.ts).
22
+ systemPrompt: "",
23
+ promptMode: "append",
24
+ isDefault: true,
25
+ },
26
+ ],
27
+ [
28
+ "Explore",
29
+ {
30
+ name: "Explore",
31
+ displayName: "Explore",
32
+ description: "Fast codebase exploration agent (read-only)",
33
+ toolGuideline: "- Use Explore for codebase searches and code understanding.",
34
+ toolNames: READ_ONLY_TOOLS,
35
+ model: "anthropic/claude-haiku-4-5-20251001",
36
+ systemPrompt: `# CRITICAL: READ-ONLY MODE - NO FILE MODIFICATIONS
37
+ You are a file search specialist. You excel at thoroughly navigating and exploring codebases.
38
+ Your role is EXCLUSIVELY to search and analyze existing code. You do NOT have access to file editing tools.
39
+
40
+ You are STRICTLY PROHIBITED from:
41
+ - Creating new files
42
+ - Modifying existing files
43
+ - Deleting files
44
+ - Moving or copying files
45
+ - Creating temporary files anywhere, including /tmp
46
+ - Using redirect operators (>, >>, |) or heredocs to write to files
47
+ - Running ANY commands that change system state
48
+
49
+ Use Bash ONLY for read-only operations: ls, git status, git log, git diff, find, cat, head, tail.
50
+
51
+ # Tool Usage
52
+ - Use the find tool for file pattern matching (NOT the bash find command)
53
+ - Use the grep tool for content search (NOT bash grep/rg command)
54
+ - Use the read tool for reading files (NOT bash cat/head/tail)
55
+ - Use Bash ONLY for read-only operations
56
+ - Make independent tool calls in parallel for efficiency
57
+ - Adapt search approach based on thoroughness level specified
58
+
59
+ # Output
60
+ - Use absolute file paths in all references
61
+ - Report findings as regular messages
62
+ - Do not use emojis
63
+ - Be thorough and precise`,
64
+ promptMode: "replace",
65
+ isDefault: true,
66
+ },
67
+ ],
68
+ [
69
+ "Plan",
70
+ {
71
+ name: "Plan",
72
+ displayName: "Plan",
73
+ description: "Software architect for implementation planning (read-only)",
74
+ toolGuideline: "- Use Plan for architecture and implementation planning.",
75
+ toolNames: READ_ONLY_TOOLS,
76
+ systemPrompt: `# CRITICAL: READ-ONLY MODE - NO FILE MODIFICATIONS
77
+ You are a software architect and planning specialist.
78
+ Your role is EXCLUSIVELY to explore the codebase and design implementation plans.
79
+ You do NOT have access to file editing tools — attempting to edit files will fail.
80
+
81
+ You are STRICTLY PROHIBITED from:
82
+ - Creating new files
83
+ - Modifying existing files
84
+ - Deleting files
85
+ - Moving or copying files
86
+ - Creating temporary files anywhere, including /tmp
87
+ - Using redirect operators (>, >>, |) or heredocs to write to files
88
+ - Running ANY commands that change system state
89
+
90
+ # Planning Process
91
+ 1. Understand requirements
92
+ 2. Explore thoroughly (read files, find patterns, understand architecture)
93
+ 3. Design solution based on your assigned perspective
94
+ 4. Detail the plan with step-by-step implementation strategy
95
+
96
+ # Requirements
97
+ - Consider trade-offs and architectural decisions
98
+ - Identify dependencies and sequencing
99
+ - Anticipate potential challenges
100
+ - Follow existing patterns where appropriate
101
+
102
+ # Tool Usage
103
+ - Use the find tool for file pattern matching (NOT the bash find command)
104
+ - Use the grep tool for content search (NOT bash grep/rg command)
105
+ - Use the read tool for reading files (NOT bash cat/head/tail)
106
+ - Use Bash ONLY for read-only operations
107
+
108
+ # Output Format
109
+ - Use absolute file paths
110
+ - Do not use emojis
111
+ - End your response with:
112
+
113
+ ### Critical Files for Implementation
114
+ List 3-5 files most critical for implementing this plan:
115
+ - /absolute/path/to/file.ts - [Brief reason]`,
116
+ promptMode: "replace",
117
+ isDefault: true,
118
+ },
119
+ ],
120
+ ]);
@@ -0,0 +1,39 @@
1
+ import { minPositive } from "#src/config/resource-limits";
2
+ import type { AgentConfig, ThinkingLevel } from "#src/types";
3
+
4
+ interface AgentInvocationParams {
5
+ model?: string;
6
+ thinking?: string;
7
+ max_turns?: number;
8
+ wrap_up_turns?: number;
9
+ max_duration_seconds?: number;
10
+ run_in_background?: boolean;
11
+ inherit_context?: boolean;
12
+ }
13
+
14
+ export function resolveAgentInvocationConfig(
15
+ agentConfig: AgentConfig | undefined,
16
+ params: AgentInvocationParams,
17
+ ): {
18
+ modelInput?: string;
19
+ modelFromParams: boolean;
20
+ thinking?: ThinkingLevel;
21
+ maxTurns?: number;
22
+ wrapUpTurns?: number;
23
+ maxDurationSeconds?: number;
24
+ inheritContext: boolean;
25
+ runInBackground: boolean;
26
+ } {
27
+ return {
28
+ modelInput: agentConfig?.model ?? params.model,
29
+ modelFromParams: agentConfig?.model == null && params.model != null,
30
+ thinking: (agentConfig?.thinking ?? params.thinking) as ThinkingLevel | undefined,
31
+ // Turn and duration budgets may only tighten: when both the profile and the
32
+ // tool call define one, the smaller value wins rather than the profile value.
33
+ maxTurns: minPositive(agentConfig?.maxTurns, params.max_turns),
34
+ wrapUpTurns: params.wrap_up_turns ?? agentConfig?.wrapUpTurns,
35
+ maxDurationSeconds: minPositive(agentConfig?.maxDurationSeconds, params.max_duration_seconds),
36
+ inheritContext: agentConfig?.inheritContext ?? params.inherit_context ?? false,
37
+ runInBackground: agentConfig?.runInBackground ?? params.run_in_background ?? false,
38
+ };
39
+ }
@@ -0,0 +1,26 @@
1
+ /**
2
+ * resource-limits.ts — Pure merge helpers for layered runtime budgets.
3
+ *
4
+ * maxTurns and maxDurationSeconds may only tighten as they flow from global
5
+ * settings through the agent profile to the tool call, so the effective value
6
+ * is the minimum of every defined layer. wrapUpTurns instead follows fixed
7
+ * precedence (invocation > profile > global) and is then clamped below the
8
+ * effective turn budget.
9
+ */
10
+
11
+ /** Smallest positive candidate; undefined when no candidate is a positive number. */
12
+ export function minPositive(...values: Array<number | undefined>): number | undefined {
13
+ let min: number | undefined;
14
+ for (const value of values) {
15
+ if (value == null || !Number.isFinite(value) || value < 1) continue;
16
+ if (min === undefined || value < min) min = value;
17
+ }
18
+ return min;
19
+ }
20
+
21
+ /** Clamp a wrap-up turn budget to the [0, maxTurns - 1] window. */
22
+ export function clampWrapUpTurns(wrapUpTurns: number, maxTurns: number): number {
23
+ const ceiling = Math.max(0, Math.floor(maxTurns) - 1);
24
+ const floor = Math.max(0, Math.floor(wrapUpTurns));
25
+ return Math.min(floor, ceiling);
26
+ }
@@ -0,0 +1,174 @@
1
+ import { randomUUID } from "node:crypto";
2
+ import {
3
+ chmodSync,
4
+ closeSync,
5
+ lstatSync,
6
+ mkdirSync,
7
+ openSync,
8
+ readFileSync,
9
+ realpathSync,
10
+ renameSync,
11
+ rmSync,
12
+ statSync,
13
+ writeFileSync,
14
+ } from "node:fs";
15
+ import { basename, dirname, join } from "node:path";
16
+
17
+ const LOCK_STALE_MS = 5 * 60 * 1000;
18
+
19
+ export type JsonObject = Record<string, unknown>;
20
+
21
+ /** Read a JSON object from disk. A missing file is reported as undefined. */
22
+ export function readSettingsFile(path: string): JsonObject | undefined {
23
+ try {
24
+ return parseJsonObject(readFileSync(path, "utf-8"), path);
25
+ } catch (error) {
26
+ if (isMissingFileError(error)) return undefined;
27
+ throw error;
28
+ }
29
+ }
30
+
31
+ /**
32
+ * Atomically update one JSON settings file while preserving unrelated keys.
33
+ * The updater runs after acquiring the lock and re-reading the current file.
34
+ */
35
+ export function updateSettingsFile(path: string, update: (current: JsonObject) => JsonObject): void {
36
+ mkdirSync(dirname(path), { recursive: true });
37
+
38
+ const targetPath = resolveWriteTarget(path);
39
+ const lockPath = `${targetPath}.lock`;
40
+ let lockFd: number | undefined;
41
+ let tempPath: string | undefined;
42
+
43
+ try {
44
+ lockFd = acquireLock(lockPath);
45
+ const current = readSettingsFile(targetPath) ?? {};
46
+ const next = update(current);
47
+ if (!isJsonObject(next)) {
48
+ throw new TypeError("Settings file updates must produce a JSON object");
49
+ }
50
+
51
+ const mode = existingMode(targetPath);
52
+ tempPath = join(dirname(targetPath), `.${basename(targetPath)}.tmp-${process.pid}-${randomUUID()}`);
53
+ writeTempFile(tempPath, `${JSON.stringify(next, null, 2)}\n`, mode);
54
+ renameSync(tempPath, targetPath);
55
+ tempPath = undefined;
56
+ } finally {
57
+ if (tempPath) rmSync(tempPath, { force: true });
58
+ if (lockFd !== undefined) {
59
+ closeSync(lockFd);
60
+ rmSync(lockPath, { force: true });
61
+ }
62
+ }
63
+ }
64
+
65
+ function acquireLock(lockPath: string): number {
66
+ try {
67
+ return createLock(lockPath);
68
+ } catch (error) {
69
+ if (!isExistingFileError(error) || !recoverStaleLock(lockPath)) throw error;
70
+ return createLock(lockPath);
71
+ }
72
+ }
73
+
74
+ function createLock(lockPath: string): number {
75
+ const fd = openSync(lockPath, "wx", 0o600);
76
+ try {
77
+ writeFileSync(fd, `${process.pid}\\n`, "utf-8");
78
+ return fd;
79
+ } catch (error) {
80
+ closeSync(fd);
81
+ rmSync(lockPath, { force: true });
82
+ throw error;
83
+ }
84
+ }
85
+
86
+ function recoverStaleLock(lockPath: string): boolean {
87
+ let before: ReturnType<typeof statSync>;
88
+ try {
89
+ before = statSync(lockPath);
90
+ } catch {
91
+ return false;
92
+ }
93
+ if (Date.now() - before.mtimeMs < LOCK_STALE_MS) return false;
94
+
95
+ const content = readFileSync(lockPath, "utf-8").trim();
96
+ const pid = Number.parseInt(content, 10);
97
+ if (Number.isInteger(pid) && pid > 0) {
98
+ try {
99
+ process.kill(pid, 0);
100
+ return false;
101
+ } catch (error) {
102
+ if (!isMissingProcessError(error)) return false;
103
+ }
104
+ }
105
+
106
+ let after: ReturnType<typeof statSync>;
107
+ try {
108
+ after = statSync(lockPath);
109
+ } catch {
110
+ return false;
111
+ }
112
+ if (after.mtimeMs !== before.mtimeMs || after.size !== before.size) return false;
113
+ rmSync(lockPath);
114
+ return true;
115
+ }
116
+
117
+ function isExistingFileError(error: unknown): boolean {
118
+ return typeof error === "object" && error !== null && "code" in error && error.code === "EEXIST";
119
+ }
120
+
121
+ function isMissingProcessError(error: unknown): boolean {
122
+ return typeof error === "object" && error !== null && "code" in error && error.code === "ESRCH";
123
+ }
124
+
125
+ function parseJsonObject(content: string, path: string): JsonObject {
126
+ const parsed: unknown = JSON.parse(content);
127
+ if (!isJsonObject(parsed)) {
128
+ throw new TypeError(`Settings file at ${path} must contain a JSON object`);
129
+ }
130
+ return parsed;
131
+ }
132
+
133
+ function isJsonObject(value: unknown): value is JsonObject {
134
+ return typeof value === "object" && value !== null && !Array.isArray(value);
135
+ }
136
+
137
+ function isMissingFileError(error: unknown): boolean {
138
+ return typeof error === "object" && error !== null && "code" in error && error.code === "ENOENT";
139
+ }
140
+
141
+ function resolveWriteTarget(path: string): string {
142
+ let entry: ReturnType<typeof lstatSync> | undefined;
143
+ try {
144
+ entry = lstatSync(path);
145
+ } catch (error) {
146
+ if (!isMissingFileError(error)) throw error;
147
+ }
148
+
149
+ // A dangling symlink is an existing link and must never be replaced.
150
+ if (entry?.isSymbolicLink()) return realpathSync(path);
151
+
152
+ // Canonicalizing the parent also makes aliases through ancestor symlinks
153
+ // share one lock and ensures a new file is created beside the real parent.
154
+ return join(realpathSync(dirname(path)), basename(path));
155
+ }
156
+
157
+ function existingMode(path: string): number {
158
+ try {
159
+ return statSync(path).mode & 0o777;
160
+ } catch (error) {
161
+ if (isMissingFileError(error)) return 0o600;
162
+ throw error;
163
+ }
164
+ }
165
+
166
+ function writeTempFile(path: string, content: string, mode: number): void {
167
+ const fd = openSync(path, "wx", mode);
168
+ try {
169
+ writeFileSync(fd, content, "utf-8");
170
+ chmodSync(path, mode);
171
+ } finally {
172
+ closeSync(fd);
173
+ }
174
+ }
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 "#src/handlers/interrupt";
2
+ export { SessionLifecycleHandler } from "#src/handlers/lifecycle";
3
+ export { ToolStartHandler } from "#src/handlers/tool-start";
@@ -0,0 +1,58 @@
1
+ /**
2
+ * turn_start event handler that aborts subagents on a parent interrupt (ESC),
3
+ * subject to the abort-all policy.
4
+ *
5
+ * The parent agent loop creates a fresh AbortController per run and only aborts
6
+ * it on an explicit interrupt — never on normal completion. So latching to the
7
+ * current run's signal and aborting on its `abort` event fires exactly on ESC.
8
+ *
9
+ * `turn_start` carries the live per-run `ctx.signal`, so re-latching each turn
10
+ * keeps the handler tracking the current signal across runs and tool-less turns.
11
+ */
12
+
13
+ /** Narrow manager interface — only the method the interrupt handler calls. */
14
+ export interface InterruptManager {
15
+ abortAll(): number;
16
+ }
17
+
18
+ /** Minimal context shape — only the field the handler reads. */
19
+ interface InterruptCtx {
20
+ signal: AbortSignal | undefined;
21
+ }
22
+
23
+ /**
24
+ * Latches the current parent abort signal and aborts all subagents when it fires,
25
+ * unless `shouldAbortAll` declines.
26
+ *
27
+ * The latch dedups by reference: most turns reuse the same signal (no-op); a new
28
+ * run's signal triggers a detach-and-rewire. The `abort` listener is one-shot.
29
+ *
30
+ * `shouldAbortAll` is consulted inside the listener, so a mid-session settings
31
+ * change applies to the very next interrupt without re-wiring.
32
+ */
33
+ export class InterruptHandler {
34
+ private latched?: AbortSignal;
35
+ private detach?: () => void;
36
+
37
+ constructor(
38
+ private readonly manager: InterruptManager,
39
+ private readonly shouldAbortAll: () => boolean,
40
+ ) {}
41
+
42
+ handleTurnStart(ctx: InterruptCtx): void {
43
+ const signal = ctx.signal;
44
+ if (signal === this.latched) return;
45
+
46
+ this.detach?.();
47
+ this.detach = undefined;
48
+ this.latched = signal;
49
+ if (!signal) return;
50
+
51
+ const onAbort = (): void => {
52
+ if (!this.shouldAbortAll()) return;
53
+ this.manager.abortAll();
54
+ };
55
+ signal.addEventListener("abort", onAbort, { once: true });
56
+ this.detach = () => signal.removeEventListener("abort", onAbort);
57
+ }
58
+ }
@@ -0,0 +1,65 @@
1
+ import type { SessionContext } from "#src/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(): Promise<void>;
13
+ abortAll(): void;
14
+ dispose(): Promise<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): Promise<void> {
41
+ this.runtime.setSessionContext(ctx as SessionContext);
42
+ return this.manager.clearCompleted();
43
+ }
44
+
45
+ handleSessionBeforeSwitch(): Promise<void> {
46
+ return 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. Dispose notifications — silence nudges *before* the aborts that would
53
+ // raise them: no parent run is active at shutdown, so a terminal
54
+ // transition delivers its nudge synchronously and Pi cannot recall it
55
+ // 4. Abort all agents — stop running and queued work
56
+ // 5. Dispose manager — final cleanup, awaited so each child's extensions get
57
+ // their `session_shutdown` before Pi tears the parent down (#709)
58
+ handleSessionShutdown(): Promise<void> {
59
+ this.unpublishService();
60
+ this.runtime.clearSessionContext();
61
+ this.disposeNotifications();
62
+ this.manager.abortAll();
63
+ return this.manager.dispose();
64
+ }
65
+ }
@@ -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
+ }