@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.
- package/CHANGELOG.md +2411 -0
- package/LICENSE +21 -0
- package/README.md +374 -2
- package/UPSTREAM.md +48 -0
- package/dist/public.d.ts +201 -0
- package/dist/settings.d.ts +82 -0
- package/docs/architecture/architecture.md +833 -0
- package/docs/architecture/client-server-opportunities.md +127 -0
- package/docs/architecture/history/phase-1-api-boundary.md +8 -0
- package/docs/architecture/history/phase-10-structural-decomposition.md +141 -0
- package/docs/architecture/history/phase-11-closure-to-class.md +100 -0
- package/docs/architecture/history/phase-12-complexity-test-fixtures.md +55 -0
- package/docs/architecture/history/phase-13-remaining-smells.md +88 -0
- package/docs/architecture/history/phase-14-strip-policy.md +49 -0
- package/docs/architecture/history/phase-15-domain-model-evolution.md +73 -0
- package/docs/architecture/history/phase-16-invert-dependencies.md +144 -0
- package/docs/architecture/history/phase-17-core-consolidation.md +214 -0
- package/docs/architecture/history/phase-18-reconsider-ui.md +166 -0
- package/docs/architecture/history/phase-19-implement-ui-decisions.md +282 -0
- package/docs/architecture/history/phase-2-remove-scheduling.md +9 -0
- package/docs/architecture/history/phase-20-result-delivery.md +245 -0
- package/docs/architecture/history/phase-21-classification-model-boundary.md +107 -0
- package/docs/architecture/history/phase-3-remove-rpc-groupjoin.md +11 -0
- package/docs/architecture/history/phase-4-implement-service.md +8 -0
- package/docs/architecture/history/phase-5-decompose-index.md +42 -0
- package/docs/architecture/history/phase-7-encapsulation.md +173 -0
- package/docs/architecture/history/phase-8-testability.md +103 -0
- package/docs/architecture/history/phase-9-observation-ctx.md +122 -0
- package/docs/comparison-with-upstream.md +79 -0
- package/docs/configuration.md +215 -0
- package/docs/decisions/0001-deferred-patches.md +80 -0
- package/docs/decisions/0002-extensions-on-a-minimal-core.md +114 -0
- package/docs/decisions/0003-publish-bundled-type-declarations.md +71 -0
- package/docs/decisions/0004-reconsider-ui-direction.md +279 -0
- package/index.ts +1 -0
- package/package.json +91 -4
- package/src/config/agent-enablement.ts +132 -0
- package/src/config/agent-types.ts +144 -0
- package/src/config/custom-agents.ts +114 -0
- package/src/config/default-agents.ts +120 -0
- package/src/config/invocation-config.ts +39 -0
- package/src/config/resource-limits.ts +26 -0
- package/src/config/settings-file.ts +174 -0
- package/src/debug.ts +14 -0
- package/src/handlers/index.ts +3 -0
- package/src/handlers/interrupt.ts +58 -0
- package/src/handlers/lifecycle.ts +65 -0
- package/src/handlers/tool-start.ts +32 -0
- package/src/index.ts +257 -0
- package/src/layered-settings.ts +105 -0
- package/src/lifecycle/child-lifecycle.ts +89 -0
- package/src/lifecycle/child-shutdown.ts +105 -0
- package/src/lifecycle/concurrency-limiter.ts +123 -0
- package/src/lifecycle/create-subagent-session.ts +265 -0
- package/src/lifecycle/invocation-control.ts +109 -0
- package/src/lifecycle/lifecycle-record.ts +159 -0
- package/src/lifecycle/parent-snapshot.ts +46 -0
- package/src/lifecycle/run-listeners.ts +41 -0
- package/src/lifecycle/subagent-manager.ts +392 -0
- package/src/lifecycle/subagent-session.ts +287 -0
- package/src/lifecycle/subagent-state.ts +307 -0
- package/src/lifecycle/subagent.ts +854 -0
- package/src/lifecycle/usage.ts +60 -0
- package/src/lifecycle/workspace-bracket.ts +59 -0
- package/src/lifecycle/workspace.ts +47 -0
- package/src/observation/composite-subagent-observer.ts +53 -0
- package/src/observation/notification.ts +233 -0
- package/src/observation/record-observer.ts +78 -0
- package/src/observation/renderer.ts +106 -0
- package/src/observation/subagent-events-observer.ts +75 -0
- package/src/runtime.ts +78 -0
- package/src/service/service-adapter.ts +159 -0
- package/src/service/service.ts +147 -0
- package/src/session/content-items.ts +53 -0
- package/src/session/context.ts +80 -0
- package/src/session/conversation.ts +49 -0
- package/src/session/env.ts +40 -0
- package/src/session/model-resolver.ts +135 -0
- package/src/session/package-exclusions.ts +75 -0
- package/src/session/prompts.ts +145 -0
- package/src/session/provider-inheritance.ts +38 -0
- package/src/session/session-config.ts +177 -0
- package/src/session/session-dir.ts +38 -0
- package/src/settings.ts +436 -0
- package/src/tools/agent-tool.ts +289 -0
- package/src/tools/background-spawner.ts +73 -0
- package/src/tools/foreground-runner.ts +135 -0
- package/src/tools/get-result-report.ts +73 -0
- package/src/tools/get-result-tool.ts +109 -0
- package/src/tools/helpers.ts +134 -0
- package/src/tools/result-renderer.ts +121 -0
- package/src/tools/spawn-config.ts +182 -0
- package/src/tools/steer-tool.ts +120 -0
- package/src/types.ts +145 -0
- package/src/ui/agent-widget.ts +313 -0
- package/src/ui/display.ts +214 -0
- package/src/ui/glyphs.ts +62 -0
- package/src/ui/session-navigation.ts +152 -0
- package/src/ui/session-navigator.ts +257 -0
- package/src/ui/subagents-agents.ts +116 -0
- package/src/ui/subagents-settings.ts +207 -0
- package/src/ui/transcript-content.ts +374 -0
- 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,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
|
+
}
|