@jopqior/pi-subagents 1.0.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 +2705 -0
- package/LICENSE +21 -0
- package/README.md +503 -0
- package/dist/public.d.ts +331 -0
- package/dist/settings.d.ts +82 -0
- package/docs/architecture/architecture.md +1566 -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 +77 -0
- package/docs/configuration.md +364 -0
- package/docs/decisions/0001-deferred-patches.md +80 -0
- package/docs/decisions/0002-extensions-on-a-minimal-core.md +125 -0
- package/docs/decisions/0003-publish-bundled-type-declarations.md +71 -0
- package/docs/decisions/0004-reconsider-ui-direction.md +279 -0
- package/docs/decisions/0005-subagent-record-admission-policy.md +106 -0
- package/docs/decisions/0006-inherited-prompt-is-identity-only.md +104 -0
- package/docs/decisions/0007-transcript-viewer-is-not-an-overlay.md +228 -0
- package/docs/decisions/0008-inherited-region-is-shared-parts.md +81 -0
- package/docs/decisions/0009-portable-inheritance-is-provider-scoped.md +116 -0
- package/package.json +91 -0
- package/src/config/agent-types.ts +135 -0
- package/src/config/custom-agents.ts +151 -0
- package/src/config/default-agents.ts +121 -0
- package/src/config/invocation-config.ts +167 -0
- package/src/config/thinking-level.ts +58 -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 +71 -0
- package/src/handlers/widget-events.ts +49 -0
- package/src/index.ts +292 -0
- package/src/layered-settings.ts +105 -0
- package/src/lifecycle/child-lifecycle.ts +115 -0
- package/src/lifecycle/child-shutdown.ts +105 -0
- package/src/lifecycle/concurrency-limiter.ts +55 -0
- package/src/lifecycle/create-subagent-session.ts +335 -0
- package/src/lifecycle/parent-snapshot.ts +119 -0
- package/src/lifecycle/run-listeners.ts +37 -0
- package/src/lifecycle/selection-scope.ts +116 -0
- package/src/lifecycle/spawn-selection.ts +259 -0
- package/src/lifecycle/subagent-manager.ts +546 -0
- package/src/lifecycle/subagent-session.ts +347 -0
- package/src/lifecycle/subagent-state.ts +404 -0
- package/src/lifecycle/subagent.ts +885 -0
- package/src/lifecycle/turn-limits.ts +13 -0
- package/src/lifecycle/usage.ts +60 -0
- package/src/lifecycle/workspace-bracket.ts +76 -0
- package/src/lifecycle/workspace.ts +46 -0
- package/src/observation/composite-subagent-observer.ts +74 -0
- package/src/observation/notification.ts +430 -0
- package/src/observation/outcome-delivery.ts +239 -0
- package/src/observation/record-observer.ts +78 -0
- package/src/observation/renderer.ts +161 -0
- package/src/observation/subagent-events-observer.ts +148 -0
- package/src/runtime.ts +137 -0
- package/src/service/service-adapter.ts +201 -0
- package/src/service/service.ts +246 -0
- package/src/session/ask-parent-tool.ts +69 -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 +126 -0
- package/src/session/notify-parent-tool.ts +83 -0
- package/src/session/package-exclusions.ts +75 -0
- package/src/session/prompts.ts +231 -0
- package/src/session/provider-inheritance.ts +56 -0
- package/src/session/selection-catalogue.ts +143 -0
- package/src/session/session-config.ts +202 -0
- package/src/session/session-dir.ts +38 -0
- package/src/settings.ts +447 -0
- package/src/tools/agent-tool.ts +305 -0
- package/src/tools/background-spawner.ts +83 -0
- package/src/tools/foreground-runner.ts +159 -0
- package/src/tools/get-result-renderer.ts +119 -0
- package/src/tools/get-result-report.ts +84 -0
- package/src/tools/get-result-tool.ts +192 -0
- package/src/tools/helpers.ts +118 -0
- package/src/tools/result-renderer.ts +153 -0
- package/src/tools/spawn-config.ts +192 -0
- package/src/tools/steer-tool.ts +109 -0
- package/src/types.ts +143 -0
- package/src/ui/agent-widget.ts +333 -0
- package/src/ui/bounded-lines.ts +45 -0
- package/src/ui/display.ts +180 -0
- package/src/ui/glyphs.ts +62 -0
- package/src/ui/session-navigation.ts +150 -0
- package/src/ui/session-navigator.ts +255 -0
- package/src/ui/subagents-settings.ts +179 -0
- package/src/ui/transcript-content.ts +374 -0
- package/src/ui/widget-renderer.ts +301 -0
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* agent-types.ts — Unified agent type registry.
|
|
3
|
+
*
|
|
4
|
+
* Merges embedded default agents with user-defined agents from .pi/agents/*.md.
|
|
5
|
+
* User agents override defaults with the same name. Disabled agents are kept but excluded from spawning.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { DEFAULT_AGENTS } from "#src/config/default-agents";
|
|
9
|
+
import type { AgentConfig } from "#src/types";
|
|
10
|
+
|
|
11
|
+
// ── AgentConfigLookup interface ──────────────────────────────────────────────
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Narrow registry interface for consumers that only need config resolution.
|
|
15
|
+
* Prefer this over the full `AgentTypeRegistry` in function signatures (ISP).
|
|
16
|
+
*/
|
|
17
|
+
export interface AgentConfigLookup {
|
|
18
|
+
resolveAgentConfig(type: string): AgentConfig;
|
|
19
|
+
getToolNamesForType(type: string): string[];
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
// ── AgentTypeRegistry class ──────────────────────────────────────────────────
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Injectable registry of all agent configurations (defaults + user-defined).
|
|
26
|
+
*
|
|
27
|
+
* Replaces the module-scoped `agents` Map and its companion free functions.
|
|
28
|
+
* The constructor accepts a `loadUserAgents` callback to defer disk I/O to the
|
|
29
|
+
* call site, keeping this class side-effect-free and easy to test.
|
|
30
|
+
*/
|
|
31
|
+
export class AgentTypeRegistry implements AgentConfigLookup {
|
|
32
|
+
private agents = new Map<string, AgentConfig>();
|
|
33
|
+
|
|
34
|
+
/** The three embedded default agent names. */
|
|
35
|
+
static readonly DEFAULT_AGENT_NAMES = ["general-purpose", "Explore", "Plan"] as const;
|
|
36
|
+
|
|
37
|
+
constructor(private loadUserAgents: () => Map<string, AgentConfig>) {
|
|
38
|
+
this.reload();
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Re-scan user agents and rebuild the registry.
|
|
43
|
+
* Starts with DEFAULT_AGENTS, then overlays whatever `loadUserAgents()` returns.
|
|
44
|
+
*/
|
|
45
|
+
reload(): void {
|
|
46
|
+
this.agents.clear();
|
|
47
|
+
for (const [name, config] of DEFAULT_AGENTS) {
|
|
48
|
+
this.agents.set(name, config);
|
|
49
|
+
}
|
|
50
|
+
for (const [name, config] of this.loadUserAgents()) {
|
|
51
|
+
this.agents.set(name, config);
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** Resolve a type name case-insensitively. Returns the canonical key or undefined. */
|
|
56
|
+
resolveType(name: string): string | undefined {
|
|
57
|
+
return this.resolveKey(name);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** Get all enabled type names (for spawning and tool descriptions). */
|
|
61
|
+
getAvailableTypes(): string[] {
|
|
62
|
+
return [...this.agents.entries()]
|
|
63
|
+
.filter(([_, config]) => config.enabled !== false)
|
|
64
|
+
.map(([name]) => name);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** Get all type names including disabled (for UI listing). */
|
|
68
|
+
getAllTypes(): string[] {
|
|
69
|
+
return [...this.agents.keys()];
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** Get names of default agents currently in the registry. */
|
|
73
|
+
getDefaultAgentNames(): string[] {
|
|
74
|
+
return [...this.agents.entries()]
|
|
75
|
+
.filter(([_, config]) => config.isDefault === true)
|
|
76
|
+
.map(([name]) => name);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/** Get names of user-defined agents (non-defaults) currently in the registry. */
|
|
80
|
+
getUserAgentNames(): string[] {
|
|
81
|
+
return [...this.agents.entries()]
|
|
82
|
+
.filter(([_, config]) => config.isDefault !== true)
|
|
83
|
+
.map(([name]) => name);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/** Check if a type is valid and enabled (case-insensitive). */
|
|
87
|
+
isValidType(type: string): boolean {
|
|
88
|
+
const key = this.resolveKey(type);
|
|
89
|
+
if (!key) return false;
|
|
90
|
+
return this.agents.get(key)?.enabled !== false;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* Get the capability tool names for a type (case-insensitive).
|
|
95
|
+
*
|
|
96
|
+
* An agent that declares no `tools:` key gets the built-ins; one that declares
|
|
97
|
+
* `tools: none` gets nothing. Resolution goes through `resolveAgentConfig` so
|
|
98
|
+
* the two cannot disagree about which config a type names.
|
|
99
|
+
*/
|
|
100
|
+
getToolNamesForType(type: string): string[] {
|
|
101
|
+
return this.resolveAgentConfig(type).toolNames ?? [...BUILTIN_TOOL_NAMES];
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/** Resolve agent config with guaranteed non-null return. Falls back: unknown → general-purpose → absolute fallback. */
|
|
105
|
+
resolveAgentConfig(type: string): AgentConfig {
|
|
106
|
+
const key = this.resolveKey(type);
|
|
107
|
+
const config = key ? this.agents.get(key) : undefined;
|
|
108
|
+
if (config) return config;
|
|
109
|
+
|
|
110
|
+
const gp = this.agents.get("general-purpose");
|
|
111
|
+
if (gp) return gp;
|
|
112
|
+
|
|
113
|
+
// Absolute fallback (should never happen in practice)
|
|
114
|
+
return {
|
|
115
|
+
name: type,
|
|
116
|
+
displayName: "Agent",
|
|
117
|
+
description: "General-purpose agent for complex, multi-step tasks",
|
|
118
|
+
toolNames: BUILTIN_TOOL_NAMES,
|
|
119
|
+
systemPrompt: "",
|
|
120
|
+
promptMode: "append",
|
|
121
|
+
};
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
private resolveKey(name: string): string | undefined {
|
|
125
|
+
if (this.agents.has(name)) return name;
|
|
126
|
+
const lower = name.toLowerCase();
|
|
127
|
+
for (const key of this.agents.keys()) {
|
|
128
|
+
if (key.toLowerCase() === lower) return key;
|
|
129
|
+
}
|
|
130
|
+
return undefined;
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/** All known built-in tool names. */
|
|
135
|
+
export const BUILTIN_TOOL_NAMES: string[] = ["read", "bash", "edit", "write", "grep", "find", "ls"];
|
|
@@ -0,0 +1,151 @@
|
|
|
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 { isLockableField, type LockDeclaration } from "#src/config/invocation-config";
|
|
10
|
+
import { parseThinkingLevel, thinkingLevelError } from "#src/config/thinking-level";
|
|
11
|
+
import { debugLog } from "#src/debug";
|
|
12
|
+
import type { AgentConfig } from "#src/types";
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Scan for custom agent .md files from multiple locations.
|
|
16
|
+
* Discovery hierarchy (higher priority wins):
|
|
17
|
+
* 1. Project: <cwd>/.pi/agents/*.md
|
|
18
|
+
* 2. Global: $PI_CODING_AGENT_DIR/agents/*.md (default: ~/.pi/agent/agents/*.md)
|
|
19
|
+
*
|
|
20
|
+
* Project-level agents override global ones with the same name.
|
|
21
|
+
* Any name is allowed — names matching defaults (e.g. "Explore") override them.
|
|
22
|
+
*/
|
|
23
|
+
export function loadCustomAgents(cwd: string): Map<string, AgentConfig> {
|
|
24
|
+
const globalDir = join(getAgentDir(), "agents");
|
|
25
|
+
const projectDir = join(cwd, ".pi", "agents");
|
|
26
|
+
|
|
27
|
+
const agents = new Map<string, AgentConfig>();
|
|
28
|
+
loadFromDir(globalDir, agents, "global"); // lower priority
|
|
29
|
+
loadFromDir(projectDir, agents, "project"); // higher priority (overwrites)
|
|
30
|
+
return agents;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** Load agent configs from a directory into the map. */
|
|
34
|
+
function loadFromDir(dir: string, agents: Map<string, AgentConfig>, source: "project" | "global"): void {
|
|
35
|
+
if (!existsSync(dir)) return;
|
|
36
|
+
|
|
37
|
+
let files: string[];
|
|
38
|
+
try {
|
|
39
|
+
files = readdirSync(dir).filter(f => f.endsWith(".md"));
|
|
40
|
+
} catch (err) {
|
|
41
|
+
debugLog("readdirSync agents dir", err);
|
|
42
|
+
return;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
for (const file of files) {
|
|
46
|
+
const name = basename(file, ".md");
|
|
47
|
+
|
|
48
|
+
let content: string;
|
|
49
|
+
try {
|
|
50
|
+
content = readFileSync(join(dir, file), "utf-8");
|
|
51
|
+
} catch (err) {
|
|
52
|
+
debugLog("readFileSync agent file", err);
|
|
53
|
+
continue;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
const { frontmatter: fm, body } = parseFrontmatter(content);
|
|
57
|
+
|
|
58
|
+
agents.set(name, {
|
|
59
|
+
name,
|
|
60
|
+
displayName: str(fm.display_name),
|
|
61
|
+
description: str(fm.description) ?? name,
|
|
62
|
+
toolNames: listField(fm.tools, BUILTIN_TOOL_NAMES),
|
|
63
|
+
model: str(fm.model),
|
|
64
|
+
thinking: thinkingLevel(fm.thinking, name),
|
|
65
|
+
maxTurns: nonNegativeInt(fm.max_turns),
|
|
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
|
+
locked: lockDeclaration(fm.locked, name),
|
|
71
|
+
enabled: fm.enabled !== false, // default true; explicitly false disables
|
|
72
|
+
source,
|
|
73
|
+
});
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// ---- Field parsers ----
|
|
78
|
+
// All follow the same convention: omitted → default, "none"/empty → nothing, value → exact.
|
|
79
|
+
|
|
80
|
+
/** Extract a string or undefined. */
|
|
81
|
+
function str(val: unknown): string | undefined {
|
|
82
|
+
return typeof val === "string" ? val : undefined;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* Parse the `locked:` key into a lock declaration, or undefined when it claims nothing.
|
|
87
|
+
*
|
|
88
|
+
* `true` is the whole-file form; anything else parses as a field list, so both YAML
|
|
89
|
+
* spellings `tools:` accepts work here too. An entry naming no lockable field is
|
|
90
|
+
* dropped rather than failing the agent's load.
|
|
91
|
+
*/
|
|
92
|
+
function lockDeclaration(val: unknown, agentName: string): LockDeclaration | undefined {
|
|
93
|
+
if (typeof val === "boolean") return val ? true : undefined;
|
|
94
|
+
|
|
95
|
+
const entries = parseListField(val);
|
|
96
|
+
if (entries === undefined) return undefined;
|
|
97
|
+
|
|
98
|
+
const fields = entries.filter(isLockableField);
|
|
99
|
+
const unknownEntries = entries.filter((entry) => !isLockableField(entry));
|
|
100
|
+
if (unknownEntries.length > 0) {
|
|
101
|
+
debugLog(`agent ${agentName} frontmatter locked`, `unknown fields: ${unknownEntries.join(", ")}`);
|
|
102
|
+
}
|
|
103
|
+
return fields.length > 0 ? fields : undefined;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* Extract a thinking level, dropping an unrecognized one.
|
|
108
|
+
*
|
|
109
|
+
* Passing it through would not surface as an error: Pi clamps a level missing from
|
|
110
|
+
* its own table down to `off`, silently disabling thinking for an agent whose author
|
|
111
|
+
* asked for more of it. Inheriting the parent's level is the safer miss (Refs #834).
|
|
112
|
+
*/
|
|
113
|
+
function thinkingLevel(val: unknown, agentName: string): AgentConfig["thinking"] {
|
|
114
|
+
const level = parseThinkingLevel(val);
|
|
115
|
+
if (val != null && level === undefined) {
|
|
116
|
+
debugLog(`agent ${agentName} frontmatter thinking`, thinkingLevelError(val));
|
|
117
|
+
}
|
|
118
|
+
return level;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/** Extract a non-negative integer or undefined. 0 means unlimited for max_turns. */
|
|
122
|
+
function nonNegativeInt(val: unknown): number | undefined {
|
|
123
|
+
return typeof val === "number" && val >= 0 ? val : undefined;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* Parse a raw list field into items, or undefined if absent/empty/"none".
|
|
128
|
+
*
|
|
129
|
+
* Frontmatter is YAML, so a list field is written either as a comma-separated
|
|
130
|
+
* scalar (`tools: read, grep`) or as a sequence (`tools: [read, grep]`). Both
|
|
131
|
+
* are supported: a sequence keeps its entries intact, while a scalar is split
|
|
132
|
+
* on commas.
|
|
133
|
+
*/
|
|
134
|
+
function parseListField(val: unknown): string[] | undefined {
|
|
135
|
+
if (val === undefined || val === null) return undefined;
|
|
136
|
+
const items = Array.isArray(val)
|
|
137
|
+
? val.map(entry => String(entry).trim()).filter(Boolean)
|
|
138
|
+
// eslint-disable-next-line @typescript-eslint/no-base-to-string -- val is already narrowed past null/undefined; String() is the intended coercion here
|
|
139
|
+
: String(val).trim().split(",").map(entry => entry.trim()).filter(Boolean);
|
|
140
|
+
if (items.length === 0) return undefined;
|
|
141
|
+
return items.length === 1 && items[0] === "none" ? undefined : items;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/**
|
|
145
|
+
* Parse a list field with defaults.
|
|
146
|
+
* omitted → defaults; "none"/empty → []; otherwise → listed items.
|
|
147
|
+
*/
|
|
148
|
+
function listField(val: unknown, defaults: string[]): string[] {
|
|
149
|
+
if (val === undefined || val === null) return defaults;
|
|
150
|
+
return parseListField(val) ?? [];
|
|
151
|
+
}
|
|
@@ -0,0 +1,121 @@
|
|
|
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
|
+
// No built-in declares `locked`: Explore's haiku is a cost default, not a
|
|
22
|
+
// correctness one, so a caller that knows better may override it (#829).
|
|
23
|
+
systemPrompt: "",
|
|
24
|
+
promptMode: "append",
|
|
25
|
+
isDefault: true,
|
|
26
|
+
},
|
|
27
|
+
],
|
|
28
|
+
[
|
|
29
|
+
"Explore",
|
|
30
|
+
{
|
|
31
|
+
name: "Explore",
|
|
32
|
+
displayName: "Explore",
|
|
33
|
+
description: "Fast codebase exploration agent (read-only)",
|
|
34
|
+
toolGuideline: "- Use Explore for codebase searches and code understanding.",
|
|
35
|
+
toolNames: READ_ONLY_TOOLS,
|
|
36
|
+
model: "anthropic/claude-haiku-4-5-20251001",
|
|
37
|
+
systemPrompt: `# CRITICAL: READ-ONLY MODE - NO FILE MODIFICATIONS
|
|
38
|
+
You are a file search specialist. You excel at thoroughly navigating and exploring codebases.
|
|
39
|
+
Your role is EXCLUSIVELY to search and analyze existing code. You do NOT have access to file editing tools.
|
|
40
|
+
|
|
41
|
+
You are STRICTLY PROHIBITED from:
|
|
42
|
+
- Creating new files
|
|
43
|
+
- Modifying existing files
|
|
44
|
+
- Deleting files
|
|
45
|
+
- Moving or copying files
|
|
46
|
+
- Creating temporary files anywhere, including /tmp
|
|
47
|
+
- Using redirect operators (>, >>, |) or heredocs to write to files
|
|
48
|
+
- Running ANY commands that change system state
|
|
49
|
+
|
|
50
|
+
Use Bash ONLY for read-only operations: ls, git status, git log, git diff, find, cat, head, tail.
|
|
51
|
+
|
|
52
|
+
# Tool Usage
|
|
53
|
+
- Use the find tool for file pattern matching (NOT the bash find command)
|
|
54
|
+
- Use the grep tool for content search (NOT bash grep/rg command)
|
|
55
|
+
- Use the read tool for reading files (NOT bash cat/head/tail)
|
|
56
|
+
- Use Bash ONLY for read-only operations
|
|
57
|
+
- Make independent tool calls in parallel for efficiency
|
|
58
|
+
- Adapt search approach based on thoroughness level specified
|
|
59
|
+
|
|
60
|
+
# Output
|
|
61
|
+
- Use absolute file paths in all references
|
|
62
|
+
- Report findings as regular messages
|
|
63
|
+
- Do not use emojis
|
|
64
|
+
- Be thorough and precise`,
|
|
65
|
+
promptMode: "replace",
|
|
66
|
+
isDefault: true,
|
|
67
|
+
},
|
|
68
|
+
],
|
|
69
|
+
[
|
|
70
|
+
"Plan",
|
|
71
|
+
{
|
|
72
|
+
name: "Plan",
|
|
73
|
+
displayName: "Plan",
|
|
74
|
+
description: "Software architect for implementation planning (read-only)",
|
|
75
|
+
toolGuideline: "- Use Plan for architecture and implementation planning.",
|
|
76
|
+
toolNames: READ_ONLY_TOOLS,
|
|
77
|
+
systemPrompt: `# CRITICAL: READ-ONLY MODE - NO FILE MODIFICATIONS
|
|
78
|
+
You are a software architect and planning specialist.
|
|
79
|
+
Your role is EXCLUSIVELY to explore the codebase and design implementation plans.
|
|
80
|
+
You do NOT have access to file editing tools — attempting to edit files will fail.
|
|
81
|
+
|
|
82
|
+
You are STRICTLY PROHIBITED from:
|
|
83
|
+
- Creating new files
|
|
84
|
+
- Modifying existing files
|
|
85
|
+
- Deleting files
|
|
86
|
+
- Moving or copying files
|
|
87
|
+
- Creating temporary files anywhere, including /tmp
|
|
88
|
+
- Using redirect operators (>, >>, |) or heredocs to write to files
|
|
89
|
+
- Running ANY commands that change system state
|
|
90
|
+
|
|
91
|
+
# Planning Process
|
|
92
|
+
1. Understand requirements
|
|
93
|
+
2. Explore thoroughly (read files, find patterns, understand architecture)
|
|
94
|
+
3. Design solution based on your assigned perspective
|
|
95
|
+
4. Detail the plan with step-by-step implementation strategy
|
|
96
|
+
|
|
97
|
+
# Requirements
|
|
98
|
+
- Consider trade-offs and architectural decisions
|
|
99
|
+
- Identify dependencies and sequencing
|
|
100
|
+
- Anticipate potential challenges
|
|
101
|
+
- Follow existing patterns where appropriate
|
|
102
|
+
|
|
103
|
+
# Tool Usage
|
|
104
|
+
- Use the find tool for file pattern matching (NOT the bash find command)
|
|
105
|
+
- Use the grep tool for content search (NOT bash grep/rg command)
|
|
106
|
+
- Use the read tool for reading files (NOT bash cat/head/tail)
|
|
107
|
+
- Use Bash ONLY for read-only operations
|
|
108
|
+
|
|
109
|
+
# Output Format
|
|
110
|
+
- Use absolute file paths
|
|
111
|
+
- Do not use emojis
|
|
112
|
+
- End your response with:
|
|
113
|
+
|
|
114
|
+
### Critical Files for Implementation
|
|
115
|
+
List 3-5 files most critical for implementing this plan:
|
|
116
|
+
- /absolute/path/to/file.ts - [Brief reason]`,
|
|
117
|
+
promptMode: "replace",
|
|
118
|
+
isDefault: true,
|
|
119
|
+
},
|
|
120
|
+
],
|
|
121
|
+
]);
|
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
import type { AgentConfig, ThinkingLevel } from "#src/types";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* The fields a `subagent` tool caller may pass, spelled as the frontmatter key an
|
|
5
|
+
* agent author writes rather than the camelCase `AgentConfig` property.
|
|
6
|
+
*/
|
|
7
|
+
export const LOCKABLE_FIELDS = [
|
|
8
|
+
"model",
|
|
9
|
+
"thinking",
|
|
10
|
+
"max_turns",
|
|
11
|
+
"inherit_context",
|
|
12
|
+
"run_in_background",
|
|
13
|
+
] as const;
|
|
14
|
+
|
|
15
|
+
/** One field an agent file may withhold from its callers. */
|
|
16
|
+
export type LockableField = (typeof LOCKABLE_FIELDS)[number];
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* An agent file's claim over the fields a caller may not override.
|
|
20
|
+
*
|
|
21
|
+
* `true` locks every field the file sets, which is the pre-#829 blanket behavior. A
|
|
22
|
+
* list locks exactly the fields it names, whether or not the file supplies a value —
|
|
23
|
+
* so an author can deny an override without pinning one.
|
|
24
|
+
*/
|
|
25
|
+
export type LockDeclaration = true | readonly LockableField[];
|
|
26
|
+
|
|
27
|
+
/** True when `name` is a field an agent file may lock. */
|
|
28
|
+
export function isLockableField(name: string): name is LockableField {
|
|
29
|
+
return (LOCKABLE_FIELDS as readonly string[]).includes(name);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* A front door's answer to "should this agent run in the background?".
|
|
34
|
+
*
|
|
35
|
+
* `explicit` is a commitment the door has already acted on — the foreground
|
|
36
|
+
* runner holds the result promise, or the tool door merged frontmatter itself
|
|
37
|
+
* and routed on the answer. `default` is a door with no commitment, so the
|
|
38
|
+
* agent's own `runInBackground` frontmatter fills it.
|
|
39
|
+
*/
|
|
40
|
+
export type BackgroundRequest =
|
|
41
|
+
| { kind: "explicit"; isBackground: boolean }
|
|
42
|
+
| { kind: "default"; isBackground: boolean };
|
|
43
|
+
|
|
44
|
+
/** Resolve the effective background mode. Explicit answers are honored verbatim. */
|
|
45
|
+
export function resolveBackgroundMode(
|
|
46
|
+
agentConfig: Pick<AgentConfig, "runInBackground">,
|
|
47
|
+
request: BackgroundRequest,
|
|
48
|
+
): boolean {
|
|
49
|
+
return request.kind === "explicit"
|
|
50
|
+
? request.isBackground
|
|
51
|
+
: (agentConfig.runInBackground ?? request.isBackground);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
interface AgentInvocationParams {
|
|
55
|
+
model?: string;
|
|
56
|
+
/** Already validated by the door — see `parseThinkingLevel`. */
|
|
57
|
+
thinking?: ThinkingLevel;
|
|
58
|
+
max_turns?: number;
|
|
59
|
+
run_in_background?: boolean;
|
|
60
|
+
inherit_context?: boolean;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** The per-call values the Agent tool door resolved from its caller and the agent file. */
|
|
64
|
+
export interface AgentInvocationConfig {
|
|
65
|
+
modelInput?: string;
|
|
66
|
+
/**
|
|
67
|
+
* True when the winning model string came from the caller.
|
|
68
|
+
*
|
|
69
|
+
* Decides whether an unresolvable string surfaces as an error or falls back to the
|
|
70
|
+
* parent model silently: the caller is present to read an error, an agent file's
|
|
71
|
+
* author is not.
|
|
72
|
+
*/
|
|
73
|
+
modelFromParams: boolean;
|
|
74
|
+
thinking?: ThinkingLevel;
|
|
75
|
+
maxTurns?: number;
|
|
76
|
+
inheritContext: boolean;
|
|
77
|
+
runInBackground: boolean;
|
|
78
|
+
/**
|
|
79
|
+
* Locked fields whose caller value was thrown away, in `LOCKABLE_FIELDS` order.
|
|
80
|
+
*
|
|
81
|
+
* A caller that passed the agent's own value discarded nothing and is absent here.
|
|
82
|
+
*/
|
|
83
|
+
discarded: LockableField[];
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* Merge an agent file's frontmatter with the `subagent` tool call's parameters.
|
|
88
|
+
*
|
|
89
|
+
* The caller wins by default and the agent file fills what the caller left unset. The
|
|
90
|
+
* upstream guard this inverts existed because the *model* guesses harness knobs it does
|
|
91
|
+
* not understand — an agent author who wants that guard back declares `locked:`, which
|
|
92
|
+
* is the only case where a caller's value is discarded (Refs #829).
|
|
93
|
+
*/
|
|
94
|
+
export function resolveAgentInvocationConfig(
|
|
95
|
+
agentConfig: AgentConfig,
|
|
96
|
+
params: AgentInvocationParams,
|
|
97
|
+
): AgentInvocationConfig {
|
|
98
|
+
const locked = agentConfig.locked;
|
|
99
|
+
const model = resolveField("model", agentConfig.model, params.model, locked);
|
|
100
|
+
const thinking = resolveField("thinking", agentConfig.thinking, params.thinking, locked);
|
|
101
|
+
const maxTurns = resolveField("max_turns", agentConfig.maxTurns, params.max_turns, locked);
|
|
102
|
+
const inheritContext = resolveField(
|
|
103
|
+
"inherit_context",
|
|
104
|
+
agentConfig.inheritContext,
|
|
105
|
+
params.inherit_context,
|
|
106
|
+
locked,
|
|
107
|
+
);
|
|
108
|
+
const runInBackground = resolveField(
|
|
109
|
+
"run_in_background",
|
|
110
|
+
agentConfig.runInBackground,
|
|
111
|
+
params.run_in_background,
|
|
112
|
+
locked,
|
|
113
|
+
);
|
|
114
|
+
|
|
115
|
+
return {
|
|
116
|
+
modelInput: model.value,
|
|
117
|
+
modelFromParams: model.source === "caller",
|
|
118
|
+
thinking: thinking.value,
|
|
119
|
+
maxTurns: maxTurns.value,
|
|
120
|
+
inheritContext: inheritContext.value ?? false,
|
|
121
|
+
runInBackground: runInBackground.value ?? false,
|
|
122
|
+
discarded: [model, thinking, maxTurns, inheritContext, runInBackground]
|
|
123
|
+
.filter((resolution) => resolution.discarded)
|
|
124
|
+
.map((resolution) => resolution.field),
|
|
125
|
+
};
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/** One field's precedence outcome; `source` names the side that supplied the value. */
|
|
129
|
+
interface FieldResolution<T> {
|
|
130
|
+
field: LockableField;
|
|
131
|
+
value: T | undefined;
|
|
132
|
+
source: "caller" | "agent" | "none";
|
|
133
|
+
/** True when a lock threw away a caller value that differed from the agent's. */
|
|
134
|
+
discarded: boolean;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/** Apply the precedence rule to a single field. */
|
|
138
|
+
function resolveField<T>(
|
|
139
|
+
field: LockableField,
|
|
140
|
+
agentValue: T | undefined,
|
|
141
|
+
callerValue: T | undefined,
|
|
142
|
+
locked: LockDeclaration | undefined,
|
|
143
|
+
): FieldResolution<T> {
|
|
144
|
+
if (!isLocked(field, agentValue, locked) && callerValue !== undefined) {
|
|
145
|
+
return { field, value: callerValue, source: "caller", discarded: false };
|
|
146
|
+
}
|
|
147
|
+
const discarded = callerValue !== undefined && callerValue !== agentValue;
|
|
148
|
+
return agentValue !== undefined
|
|
149
|
+
? { field, value: agentValue, source: "agent", discarded }
|
|
150
|
+
: { field, value: undefined, source: "none", discarded };
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/**
|
|
154
|
+
* Whether an agent file withholds `field` from its callers.
|
|
155
|
+
*
|
|
156
|
+
* The two declaration forms differ deliberately on a field the file leaves unset:
|
|
157
|
+
* `true` means "what I set is mine", so it locks nothing it did not supply, while a
|
|
158
|
+
* list names fields outright and denies an override with no value of its own.
|
|
159
|
+
*/
|
|
160
|
+
function isLocked(
|
|
161
|
+
field: LockableField,
|
|
162
|
+
agentValue: unknown,
|
|
163
|
+
locked: LockDeclaration | undefined,
|
|
164
|
+
): boolean {
|
|
165
|
+
if (locked === undefined) return false;
|
|
166
|
+
return locked === true ? agentValue !== undefined : locked.includes(field);
|
|
167
|
+
}
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* thinking-level.ts — The thinking-level vocabulary both spawn doors validate against.
|
|
3
|
+
*
|
|
4
|
+
* Pi accepts `off` alongside pi-ai's `ThinkingLevel`, but does not export the
|
|
5
|
+
* combined list: `THINKING_LEVEL_OPTIONS` lives in `@earendil-works/pi-coding-agent`'s
|
|
6
|
+
* internals and is absent from its public entry, so this package carries its own.
|
|
7
|
+
*
|
|
8
|
+
* An unrecognized level is not ignored by the SDK — `clampThinkingLevel` misses it in
|
|
9
|
+
* the ordered table and falls to the first supported level, which is always `off`. A
|
|
10
|
+
* typo therefore disables thinking entirely, which is why both doors reject rather
|
|
11
|
+
* than pass the value through (Refs #834).
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import type { ThinkingLevel as SdkThinkingLevel } from "@earendil-works/pi-ai";
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Every level Pi accepts, in ascending order of effort.
|
|
18
|
+
*
|
|
19
|
+
* The `satisfies` clause rejects an entry the installed SDK does not declare, and the
|
|
20
|
+
* parity tests in `test/config/thinking-level.test.ts` check both directions at runtime.
|
|
21
|
+
* One drift escapes them: a level the SDK adds *and* gates behind a `thinkingLevelMap`
|
|
22
|
+
* key, the way `xhigh` and `max` are gated, is invisible until a caller names it.
|
|
23
|
+
*/
|
|
24
|
+
export const THINKING_LEVELS = [
|
|
25
|
+
"off",
|
|
26
|
+
"minimal",
|
|
27
|
+
"low",
|
|
28
|
+
"medium",
|
|
29
|
+
"high",
|
|
30
|
+
"xhigh",
|
|
31
|
+
"max",
|
|
32
|
+
] as const satisfies readonly (SdkThinkingLevel | "off")[];
|
|
33
|
+
|
|
34
|
+
/** A thinking level as an agent file or a spawn caller may spell it. */
|
|
35
|
+
export type SubagentThinkingLevel = (typeof THINKING_LEVELS)[number];
|
|
36
|
+
|
|
37
|
+
/** Narrow an unvalidated value to a thinking level, or undefined when it is not one. */
|
|
38
|
+
export function parseThinkingLevel(value: unknown): SubagentThinkingLevel | undefined {
|
|
39
|
+
return THINKING_LEVELS.find((level): boolean => level === value);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** The message a door reports for a value {@link parseThinkingLevel} rejected. */
|
|
43
|
+
export function thinkingLevelError(value: unknown): string {
|
|
44
|
+
return `Invalid thinking level ${describeRejected(value)}. Valid levels: ${THINKING_LEVELS.join(", ")}.`;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Render a rejected value for an error message.
|
|
49
|
+
*
|
|
50
|
+
* The value arrives unvalidated from YAML frontmatter or a tool call, so it may be
|
|
51
|
+
* any shape at all — anything past a primitive is named by type rather than serialized.
|
|
52
|
+
*/
|
|
53
|
+
function describeRejected(value: unknown): string {
|
|
54
|
+
if (typeof value === "string") return `"${value}"`;
|
|
55
|
+
if (value === null) return "null";
|
|
56
|
+
if (typeof value === "number" || typeof value === "boolean") return String(value);
|
|
57
|
+
return `of type ${typeof value}`;
|
|
58
|
+
}
|
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
|
+
}
|