@yandy0725/pi-subagents 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +155 -0
- package/README.zh.md +155 -0
- package/index.ts +1 -0
- package/package.json +49 -0
- package/src/config/agent-types.ts +127 -0
- package/src/config/custom-agents.ts +109 -0
- package/src/config/default-agents.ts +117 -0
- package/src/config/invocation-config.ts +30 -0
- package/src/debug.ts +14 -0
- package/src/handlers/index.ts +3 -0
- package/src/handlers/interrupt.ts +49 -0
- package/src/handlers/lifecycle.ts +63 -0
- package/src/handlers/tool-start.ts +32 -0
- package/src/index.ts +186 -0
- package/src/layered-settings.ts +105 -0
- package/src/lifecycle/child-lifecycle.ts +88 -0
- package/src/lifecycle/concurrency-limiter.ts +55 -0
- package/src/lifecycle/create-subagent-session.ts +240 -0
- package/src/lifecycle/parent-snapshot.ts +45 -0
- package/src/lifecycle/run-listeners.ts +37 -0
- package/src/lifecycle/subagent-manager.ts +353 -0
- package/src/lifecycle/subagent-session.ts +232 -0
- package/src/lifecycle/subagent-state.ts +216 -0
- package/src/lifecycle/subagent.ts +498 -0
- package/src/lifecycle/turn-limits.ts +13 -0
- package/src/lifecycle/usage.ts +65 -0
- package/src/lifecycle/workspace-bracket.ts +59 -0
- package/src/lifecycle/workspace.ts +47 -0
- package/src/observation/composite-subagent-observer.ts +49 -0
- package/src/observation/notification-state.ts +27 -0
- package/src/observation/notification.ts +186 -0
- package/src/observation/record-observer.ts +75 -0
- package/src/observation/renderer.ts +63 -0
- package/src/observation/subagent-events-observer.ts +94 -0
- package/src/runtime.ts +77 -0
- package/src/service/service-adapter.ts +131 -0
- package/src/service/service.ts +123 -0
- package/src/session/content-items.ts +51 -0
- package/src/session/context.ts +78 -0
- package/src/session/conversation.ts +44 -0
- package/src/session/env.ts +40 -0
- package/src/session/model-resolver.ts +121 -0
- package/src/session/prompts.ts +83 -0
- package/src/session/session-config.ts +172 -0
- package/src/session/session-dir.ts +38 -0
- package/src/settings.ts +227 -0
- package/src/tools/agent-tool.ts +220 -0
- package/src/tools/background-spawner.ts +66 -0
- package/src/tools/foreground-runner.ts +114 -0
- package/src/tools/get-result-tool.ts +120 -0
- package/src/tools/helpers.ts +105 -0
- package/src/tools/result-renderer.ts +109 -0
- package/src/tools/spawn-config.ts +150 -0
- package/src/tools/steer-tool.ts +90 -0
- package/src/types.ts +115 -0
- package/src/ui/agent-widget.ts +311 -0
- package/src/ui/display.ts +174 -0
- package/src/ui/session-navigation.ts +147 -0
- package/src/ui/session-navigator.ts +406 -0
- package/src/ui/subagents-settings.ts +77 -0
- package/src/ui/widget-renderer.ts +296 -0
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* prompts.ts — System prompt builder for agents.
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import type { EnvInfo } from "../session/env";
|
|
6
|
+
import type { AgentPromptConfig } from "../types";
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Build the system prompt for an agent from its config.
|
|
10
|
+
*
|
|
11
|
+
* Both modes place the shared/stable parent prompt (or `genericBase` when no
|
|
12
|
+
* parent is available) first so the LLM's KV cache can reuse the inherited
|
|
13
|
+
* prefix across all subagent invocations.
|
|
14
|
+
*
|
|
15
|
+
* - "replace" mode: parent/genericBase + active_agent tag + env header +
|
|
16
|
+
* config.systemPrompt. No `<sub_agent_context>` bridge and no
|
|
17
|
+
* `<agent_instructions>` wrapper — the custom prompt has full control and
|
|
18
|
+
* the final say.
|
|
19
|
+
* - "append" mode: parent/genericBase + sub-agent context bridge +
|
|
20
|
+
* active_agent tag + env header + config.systemPrompt (wrapped in
|
|
21
|
+
* `<agent_instructions>` when non-empty).
|
|
22
|
+
* - "append" with empty systemPrompt: pure parent clone.
|
|
23
|
+
*
|
|
24
|
+
* Both modes include an `<active_agent name="${config.name}"/>` tag so
|
|
25
|
+
* downstream extensions (e.g. `@yandy0725/pi-permission-system`) can resolve
|
|
26
|
+
* per-agent policy inside the child session by parsing the system prompt.
|
|
27
|
+
* The tag follows the cacheable parent prefix in both modes.
|
|
28
|
+
*
|
|
29
|
+
* @param parentSystemPrompt The parent agent's effective system prompt.
|
|
30
|
+
*/
|
|
31
|
+
export function buildAgentPrompt(
|
|
32
|
+
config: AgentPromptConfig,
|
|
33
|
+
cwd: string,
|
|
34
|
+
env: EnvInfo,
|
|
35
|
+
parentSystemPrompt?: string,
|
|
36
|
+
): string {
|
|
37
|
+
const activeAgentTag = `<active_agent name="${config.name}"/>\n\n`;
|
|
38
|
+
|
|
39
|
+
const envBlock = `# Environment
|
|
40
|
+
Working directory: ${cwd}
|
|
41
|
+
${env.isGitRepo ? `Git repository: yes\nBranch: ${env.branch}` : "Not a git repository"}
|
|
42
|
+
Platform: ${env.platform}`;
|
|
43
|
+
|
|
44
|
+
const identity = parentSystemPrompt ?? genericBase;
|
|
45
|
+
|
|
46
|
+
if (config.promptMode === "append") {
|
|
47
|
+
const bridge = `<sub_agent_context>
|
|
48
|
+
You are operating as a sub-agent invoked to handle a specific task.
|
|
49
|
+
- Use the read tool instead of cat/head/tail
|
|
50
|
+
- Use the edit tool instead of sed/awk
|
|
51
|
+
- Use the write tool instead of echo/heredoc
|
|
52
|
+
- Use the find tool instead of bash find/ls for file search
|
|
53
|
+
- Use the grep tool instead of bash grep/rg for content search
|
|
54
|
+
- Make independent tool calls in parallel
|
|
55
|
+
- Use absolute file paths
|
|
56
|
+
- Do not use emojis
|
|
57
|
+
- Be concise but complete
|
|
58
|
+
</sub_agent_context>`;
|
|
59
|
+
|
|
60
|
+
const customSection = config.systemPrompt.trim()
|
|
61
|
+
? `\n\n<agent_instructions>\n${config.systemPrompt}\n</agent_instructions>`
|
|
62
|
+
: "";
|
|
63
|
+
|
|
64
|
+
// Place shared/stable content first so the LLM's KV cache can reuse the
|
|
65
|
+
// inherited prefix across all subagent invocations. The parent prompt is
|
|
66
|
+
// placed verbatim (no wrapper tag) so it forms an identical byte prefix
|
|
67
|
+
// with the parent session, maximising KV cache hits. The <active_agent>
|
|
68
|
+
// tag and env block vary per call and are placed after the cached prefix.
|
|
69
|
+
return identity + "\n\n" + bridge + "\n\n" + activeAgentTag + envBlock + customSection;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// "replace" mode — parent/genericBase prefix first for KV cache reuse, then
|
|
73
|
+
// the active_agent tag, env block, and the config's full system prompt.
|
|
74
|
+
// Unlike append mode, no <sub_agent_context> bridge or <agent_instructions>
|
|
75
|
+
// wrapper is injected — the custom prompt retains full control.
|
|
76
|
+
return identity + "\n\n" + activeAgentTag + envBlock + "\n\n" + config.systemPrompt;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/** Fallback base prompt when parent system prompt is unavailable (both modes). */
|
|
80
|
+
const genericBase = `# Role
|
|
81
|
+
You are a general-purpose coding agent for complex, multi-step tasks.
|
|
82
|
+
You have full access to read, write, edit files, and execute commands.
|
|
83
|
+
Do what has been asked; nothing more, nothing less.`;
|
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* session-config.ts — Pure configuration assembler for agent sessions.
|
|
3
|
+
*
|
|
4
|
+
* `assembleSessionConfig()` is the pure assembly core called by
|
|
5
|
+
* `createSubagentSession()`. It accepts resolved inputs (agent type, narrow
|
|
6
|
+
* context, run options, env info) and returns everything the factory needs to
|
|
7
|
+
* create the SDK session — without importing or constructing any Pi SDK types.
|
|
8
|
+
*
|
|
9
|
+
* The only async IO in the assembly phase (`detectEnv`) is handled by the caller
|
|
10
|
+
* before invoking this function, keeping the assembler synchronous.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import type { AgentConfigLookup } from "../config/agent-types";
|
|
14
|
+
import type { EnvInfo } from "../session/env";
|
|
15
|
+
import type { AgentPromptConfig, SubagentType, ThinkingLevel } from "../types";
|
|
16
|
+
|
|
17
|
+
// ── Public interfaces ────────────────────────────────────────────────────────
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* IO collaborators injected into `assembleSessionConfig`.
|
|
21
|
+
*
|
|
22
|
+
* Bundling the IO-touching (or promptly testable) function into a single
|
|
23
|
+
* interface keeps the assembler free of direct module imports and makes it
|
|
24
|
+
* trivially testable without `vi.mock()` — callers inject real implementations
|
|
25
|
+
* at the edge (`create-subagent-session.ts`) or stubs in tests.
|
|
26
|
+
*/
|
|
27
|
+
export interface AssemblerIO {
|
|
28
|
+
buildAgentPrompt: (config: AgentPromptConfig, cwd: string, env: EnvInfo, parentPrompt?: string) => string;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Narrow context the assembler reads from the parent session.
|
|
33
|
+
* Tests construct plain objects satisfying this interface — no SDK mocking needed.
|
|
34
|
+
*
|
|
35
|
+
* Models are treated as opaque handles: the assembler never inspects their
|
|
36
|
+
* internals, only passes them through. `getAvailable` returns just enough
|
|
37
|
+
* structural information ({ provider, id }) for the availability check in
|
|
38
|
+
* `resolveDefaultModel`.
|
|
39
|
+
*/
|
|
40
|
+
export interface AssemblerContext {
|
|
41
|
+
/** Parent working directory (overridable via options.cwd). */
|
|
42
|
+
cwd: string;
|
|
43
|
+
/** Parent's effective system prompt (for append-mode agents). */
|
|
44
|
+
parentSystemPrompt: string;
|
|
45
|
+
/** Parent's current model instance (fallback when agent config has no model). */
|
|
46
|
+
parentModel?: unknown;
|
|
47
|
+
/** Model registry for resolving config.model strings. */
|
|
48
|
+
modelRegistry: {
|
|
49
|
+
find(provider: string, modelId: string): unknown;
|
|
50
|
+
getAvailable?(): Array<{ provider: string; id: string }>;
|
|
51
|
+
};
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Narrow slice of per-spawn execution fields consumed by the assembler.
|
|
56
|
+
* All fields are optional — callers pass only what they have.
|
|
57
|
+
*/
|
|
58
|
+
export interface AssemblerOptions {
|
|
59
|
+
/** Override working directory (e.g. for worktree isolation). */
|
|
60
|
+
cwd?: string;
|
|
61
|
+
/** Explicit model override — wins over agentConfig.model and parent model. */
|
|
62
|
+
model?: unknown;
|
|
63
|
+
/** Explicit thinking level — wins over agentConfig.thinking. */
|
|
64
|
+
thinkingLevel?: ThinkingLevel;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Assembled configuration returned to `createSubagentSession()`.
|
|
69
|
+
* Contains everything needed to create the SDK session and filter tools —
|
|
70
|
+
* with no SDK object references.
|
|
71
|
+
*/
|
|
72
|
+
export interface SessionConfig {
|
|
73
|
+
/** Resolved working directory (`options.cwd ?? ctx.cwd`). */
|
|
74
|
+
effectiveCwd: string;
|
|
75
|
+
/** Fully-assembled system prompt string (ready for `systemPromptOverride`). */
|
|
76
|
+
systemPrompt: string;
|
|
77
|
+
/** Built-in tool name allowlist for this agent type. */
|
|
78
|
+
toolNames: string[];
|
|
79
|
+
/**
|
|
80
|
+
* Resolved model instance (undefined → use parent model as passed to SDK).
|
|
81
|
+
* Opaque handle — the assembler passes it through without inspection.
|
|
82
|
+
* Caller casts to the SDK’s Model<any> at the session-creation boundary.
|
|
83
|
+
*/
|
|
84
|
+
model: unknown;
|
|
85
|
+
/** Resolved thinking level (undefined → inherit from session). */
|
|
86
|
+
thinkingLevel: ThinkingLevel | undefined;
|
|
87
|
+
/** Per-agent configured max turns (from agentConfig.maxTurns). */
|
|
88
|
+
agentMaxTurns: number | undefined;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
// ── Internal helpers ─────────────────────────────────────────────────────────
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* Resolve the default model from the agent config's model string.
|
|
95
|
+
*
|
|
96
|
+
* Priority: parentModel is the fallback; if `configModel` is a "provider/modelId"
|
|
97
|
+
* string that resolves against the registry AND is in the available set, return
|
|
98
|
+
* that model instead.
|
|
99
|
+
*/
|
|
100
|
+
function resolveDefaultModel(
|
|
101
|
+
parentModel: unknown,
|
|
102
|
+
registry: AssemblerContext["modelRegistry"],
|
|
103
|
+
configModel?: string,
|
|
104
|
+
): unknown {
|
|
105
|
+
if (configModel) {
|
|
106
|
+
const slashIdx = configModel.indexOf("/");
|
|
107
|
+
if (slashIdx !== -1) {
|
|
108
|
+
const provider = configModel.slice(0, slashIdx);
|
|
109
|
+
const modelId = configModel.slice(slashIdx + 1);
|
|
110
|
+
|
|
111
|
+
const available = registry.getAvailable?.();
|
|
112
|
+
const availableKeys = available ? new Set(available.map((m) => `${m.provider}/${m.id}`)) : undefined;
|
|
113
|
+
const isAvailable = (p: string, id: string) => !availableKeys || availableKeys.has(`${p}/${id}`);
|
|
114
|
+
|
|
115
|
+
const found = registry.find(provider, modelId);
|
|
116
|
+
if (found && isAvailable(provider, modelId)) return found;
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
return parentModel;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
// ── Public function ──────────────────────────────────────────────────────────
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* Assemble all configuration needed to create an agent session.
|
|
126
|
+
*
|
|
127
|
+
* Synchronous and side-effect-free — all IO is delegated through the `io`
|
|
128
|
+
* parameter. The caller is responsible for resolving `EnvInfo` beforehand
|
|
129
|
+
* via `detectEnv()`.
|
|
130
|
+
*
|
|
131
|
+
* @param type The subagent type name (case-insensitive registry lookup).
|
|
132
|
+
* @param ctx Narrow context from the parent session.
|
|
133
|
+
* @param options Per-call overrides (cwd, model, thinkingLevel).
|
|
134
|
+
* @param env Pre-resolved environment info from `detectEnv()`.
|
|
135
|
+
* @param registry Agent config lookup — provides resolveAgentConfig and getToolNamesForType.
|
|
136
|
+
* @param io IO collaborators (skill loader, memory builder, prompt builder).
|
|
137
|
+
*/
|
|
138
|
+
export function assembleSessionConfig(
|
|
139
|
+
type: SubagentType,
|
|
140
|
+
ctx: AssemblerContext,
|
|
141
|
+
options: AssemblerOptions,
|
|
142
|
+
env: EnvInfo,
|
|
143
|
+
registry: AgentConfigLookup,
|
|
144
|
+
io: AssemblerIO,
|
|
145
|
+
): SessionConfig {
|
|
146
|
+
const agentConfig = registry.resolveAgentConfig(type);
|
|
147
|
+
|
|
148
|
+
const effectiveCwd = options.cwd ?? ctx.cwd;
|
|
149
|
+
|
|
150
|
+
const toolNames = registry.getToolNamesForType(type);
|
|
151
|
+
|
|
152
|
+
// Build system prompt from the resolved agent config
|
|
153
|
+
const systemPrompt = io.buildAgentPrompt(agentConfig, effectiveCwd, env, ctx.parentSystemPrompt);
|
|
154
|
+
|
|
155
|
+
// Model resolution: explicit option > config model string > parent model
|
|
156
|
+
const model = options.model ?? resolveDefaultModel(ctx.parentModel, ctx.modelRegistry, agentConfig.model);
|
|
157
|
+
|
|
158
|
+
// Thinking level: explicit option > agent config > undefined (inherit)
|
|
159
|
+
const thinkingLevel = options.thinkingLevel ?? agentConfig.thinking;
|
|
160
|
+
|
|
161
|
+
// Per-agent max turns (combined with per-call maxTurns and defaultMaxTurns by SubagentSession.runTurnLoop)
|
|
162
|
+
const agentMaxTurns = agentConfig.maxTurns;
|
|
163
|
+
|
|
164
|
+
return {
|
|
165
|
+
effectiveCwd,
|
|
166
|
+
systemPrompt,
|
|
167
|
+
toolNames,
|
|
168
|
+
model,
|
|
169
|
+
thinkingLevel,
|
|
170
|
+
agentMaxTurns,
|
|
171
|
+
};
|
|
172
|
+
}
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* session-dir.ts — Pure function for deriving subagent session directories.
|
|
3
|
+
*
|
|
4
|
+
* Subagent sessions are nested under the parent session's basename so they are
|
|
5
|
+
* discoverable via the parent session path without cluttering the main session list.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { tmpdir } from "node:os";
|
|
9
|
+
import { basename, dirname, join } from "node:path";
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Derive the session directory for a subagent from the parent session file.
|
|
13
|
+
*
|
|
14
|
+
* Layout: `<parent-dir>/<parent-basename>/tasks/`
|
|
15
|
+
*
|
|
16
|
+
* Example:
|
|
17
|
+
* parent: `~/.pi/agent/sessions/--project--/2026-05-20T12-00-00Z_.jsonl`
|
|
18
|
+
* result: `~/.pi/agent/sessions/--project--/2026-05-20T12-00-00Z_/tasks`
|
|
19
|
+
*
|
|
20
|
+
* Falls back to a temp directory when the parent session is not persisted
|
|
21
|
+
* (e.g. API/headless mode where the parent uses `SessionManager.inMemory()`).
|
|
22
|
+
*/
|
|
23
|
+
export function deriveSubagentSessionDir(parentSessionFile: string | undefined, cwd: string): string {
|
|
24
|
+
if (parentSessionFile) {
|
|
25
|
+
const dir = dirname(parentSessionFile);
|
|
26
|
+
const base = basename(parentSessionFile, ".jsonl");
|
|
27
|
+
return join(dir, base, "tasks");
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
// Fallback: use a temp directory keyed by uid and cwd so different
|
|
31
|
+
// projects don't collide when the parent session is not persisted.
|
|
32
|
+
const encoded = cwd
|
|
33
|
+
.replace(/[/\\]/g, "-")
|
|
34
|
+
.replace(/^[A-Za-z]:-/, "")
|
|
35
|
+
.replace(/^-+/, "");
|
|
36
|
+
const root = join(tmpdir(), `pi-subagents-${process.getuid?.() ?? 0}`);
|
|
37
|
+
return join(root, encoded, "tasks");
|
|
38
|
+
}
|
package/src/settings.ts
ADDED
|
@@ -0,0 +1,227 @@
|
|
|
1
|
+
// Persistence for pi-subagents operational settings.
|
|
2
|
+
// - Global: ~/.pi/agent/subagents.json (agentDir injected at construction) — manual defaults, never written here
|
|
3
|
+
// - Project: <cwd>/.pi/subagents.json — written by /agents → Settings; overrides global on load
|
|
4
|
+
|
|
5
|
+
import { mkdirSync, writeFileSync } from "node:fs";
|
|
6
|
+
import { dirname, join } from "node:path";
|
|
7
|
+
import { type LayeredSettingsSource, loadLayeredSettings } from "./layered-settings";
|
|
8
|
+
export interface SubagentsSettings {
|
|
9
|
+
maxConcurrent?: number;
|
|
10
|
+
/**
|
|
11
|
+
* 0 = unlimited — the extension's single source of truth for that convention:
|
|
12
|
+
* `normalizeMaxTurns()` in turn-limits.ts treats 0 → `undefined`, and the
|
|
13
|
+
* `/agents` → Settings input prompt explicitly says "0 = unlimited".
|
|
14
|
+
*/
|
|
15
|
+
defaultMaxTurns?: number;
|
|
16
|
+
graceTurns?: number;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/** Emit callback — a subset of `pi.events.emit` to keep helpers testable. */
|
|
20
|
+
export type SettingsEmit = (event: string, payload: unknown) => void;
|
|
21
|
+
|
|
22
|
+
const DEFAULT_MAX_CONCURRENT = 4;
|
|
23
|
+
const DEFAULT_GRACE_TURNS = 5;
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Owns all three in-memory settings values and their load/save/persist cycle.
|
|
27
|
+
* Replaces the scattered free-function + SettingsAppliers callback pattern.
|
|
28
|
+
*/
|
|
29
|
+
export class SettingsManager {
|
|
30
|
+
private _defaultMaxTurns: number | undefined = undefined;
|
|
31
|
+
private _graceTurns: number = DEFAULT_GRACE_TURNS;
|
|
32
|
+
private _maxConcurrent: number = DEFAULT_MAX_CONCURRENT;
|
|
33
|
+
|
|
34
|
+
private readonly emit: SettingsEmit;
|
|
35
|
+
private readonly cwd: string;
|
|
36
|
+
private readonly agentDir: string;
|
|
37
|
+
private readonly onMaxConcurrentChanged: (() => void) | undefined;
|
|
38
|
+
|
|
39
|
+
constructor(deps: { emit: SettingsEmit; cwd: string; agentDir: string; onMaxConcurrentChanged?: () => void }) {
|
|
40
|
+
this.emit = deps.emit;
|
|
41
|
+
this.cwd = deps.cwd;
|
|
42
|
+
this.agentDir = deps.agentDir;
|
|
43
|
+
this.onMaxConcurrentChanged = deps.onMaxConcurrentChanged;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
// ── defaultMaxTurns: 0 or undefined → unlimited (undefined); else max(1, n) ──
|
|
47
|
+
|
|
48
|
+
get defaultMaxTurns(): number | undefined {
|
|
49
|
+
return this._defaultMaxTurns;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
set defaultMaxTurns(n: number | undefined) {
|
|
53
|
+
if (n == null || n === 0) {
|
|
54
|
+
this._defaultMaxTurns = undefined;
|
|
55
|
+
} else {
|
|
56
|
+
this._defaultMaxTurns = Math.max(1, n);
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// ── graceTurns: minimum 1 ──
|
|
61
|
+
|
|
62
|
+
get graceTurns(): number {
|
|
63
|
+
return this._graceTurns;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
set graceTurns(n: number) {
|
|
67
|
+
this._graceTurns = Math.max(1, n);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
// ── maxConcurrent: minimum 1 ──
|
|
71
|
+
|
|
72
|
+
get maxConcurrent(): number {
|
|
73
|
+
return this._maxConcurrent;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
set maxConcurrent(n: number) {
|
|
77
|
+
this._maxConcurrent = Math.max(1, n);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// ── Lifecycle methods ──
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Load merged settings (global + project), apply to in-memory values,
|
|
84
|
+
* and emit the `subagents:settings_loaded` lifecycle event.
|
|
85
|
+
* Returns the raw loaded settings object.
|
|
86
|
+
*/
|
|
87
|
+
load(): SubagentsSettings {
|
|
88
|
+
const settings = loadSettings(this.agentDir, this.cwd);
|
|
89
|
+
if (typeof settings.maxConcurrent === "number") this.maxConcurrent = settings.maxConcurrent;
|
|
90
|
+
if (typeof settings.defaultMaxTurns === "number") this.defaultMaxTurns = settings.defaultMaxTurns;
|
|
91
|
+
if (typeof settings.graceTurns === "number") this.graceTurns = settings.graceTurns;
|
|
92
|
+
this.emit("subagents:settings_loaded", { settings });
|
|
93
|
+
return settings;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* Snapshot current in-memory values for persistence.
|
|
98
|
+
* `defaultMaxTurns` uses 0 as the on-disk marker for unlimited (undefined).
|
|
99
|
+
*/
|
|
100
|
+
snapshot(): { maxConcurrent: number; defaultMaxTurns: number; graceTurns: number } {
|
|
101
|
+
return {
|
|
102
|
+
maxConcurrent: this._maxConcurrent,
|
|
103
|
+
defaultMaxTurns: this._defaultMaxTurns ?? 0,
|
|
104
|
+
graceTurns: this._graceTurns,
|
|
105
|
+
};
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* Set maxConcurrent, notify interested parties, persist, and return the toast.
|
|
110
|
+
* Owns the full consequence chain so callers just say what they want.
|
|
111
|
+
*/
|
|
112
|
+
applyMaxConcurrent(n: number): { message: string; level: "info" | "warning" } {
|
|
113
|
+
this.maxConcurrent = n; // setter normalizes: max(1, n)
|
|
114
|
+
this.onMaxConcurrentChanged?.();
|
|
115
|
+
return this.saveAndNotify(`Max concurrency set to ${this.maxConcurrent}`);
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* Set defaultMaxTurns, persist, and return the toast.
|
|
120
|
+
* Pass 0 for unlimited (maps to undefined internally).
|
|
121
|
+
*/
|
|
122
|
+
applyDefaultMaxTurns(n: number): { message: string; level: "info" | "warning" } {
|
|
123
|
+
this.defaultMaxTurns = n === 0 ? undefined : n; // setter normalizes further
|
|
124
|
+
const label = this.defaultMaxTurns == null ? "unlimited" : String(this.defaultMaxTurns);
|
|
125
|
+
return this.saveAndNotify(`Default max turns set to ${label}`);
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* Set graceTurns, persist, and return the toast.
|
|
130
|
+
*/
|
|
131
|
+
applyGraceTurns(n: number): { message: string; level: "info" | "warning" } {
|
|
132
|
+
this.graceTurns = n; // setter normalizes: max(1, n)
|
|
133
|
+
return this.saveAndNotify(`Grace turns set to ${this.graceTurns}`);
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* Persist the current snapshot, emit `subagents:settings_changed`,
|
|
138
|
+
* and return the toast the UI should display.
|
|
139
|
+
*/
|
|
140
|
+
saveAndNotify(successMsg: string): { message: string; level: "info" | "warning" } {
|
|
141
|
+
const snap = this.snapshot();
|
|
142
|
+
const persisted = saveSettings(snap, this.cwd);
|
|
143
|
+
this.emit("subagents:settings_changed", { settings: snap, persisted });
|
|
144
|
+
return persistToastFor(successMsg, persisted);
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
// Sanity ceilings — prevent hand-edited configs from asking for values that
|
|
149
|
+
// make no operational sense (e.g. 1e6 concurrent subagents). Permissive enough
|
|
150
|
+
// that any realistic power-user setting passes through.
|
|
151
|
+
const MAX_CONCURRENT_CEILING = 1024;
|
|
152
|
+
const MAX_TURNS_CEILING = 10_000;
|
|
153
|
+
const GRACE_TURNS_CEILING = 1_000;
|
|
154
|
+
|
|
155
|
+
/** Drop fields that don't match the expected shape. Silent — garbage becomes absent. */
|
|
156
|
+
function sanitize(raw: unknown): SubagentsSettings {
|
|
157
|
+
if (!raw || typeof raw !== "object") return {};
|
|
158
|
+
const r = raw as Record<string, unknown>;
|
|
159
|
+
const out: SubagentsSettings = {};
|
|
160
|
+
if (
|
|
161
|
+
Number.isInteger(r.maxConcurrent) &&
|
|
162
|
+
(r.maxConcurrent as number) >= 1 &&
|
|
163
|
+
(r.maxConcurrent as number) <= MAX_CONCURRENT_CEILING
|
|
164
|
+
) {
|
|
165
|
+
out.maxConcurrent = r.maxConcurrent as number;
|
|
166
|
+
}
|
|
167
|
+
if (
|
|
168
|
+
Number.isInteger(r.defaultMaxTurns) &&
|
|
169
|
+
(r.defaultMaxTurns as number) >= 0 &&
|
|
170
|
+
(r.defaultMaxTurns as number) <= MAX_TURNS_CEILING
|
|
171
|
+
) {
|
|
172
|
+
out.defaultMaxTurns = r.defaultMaxTurns as number;
|
|
173
|
+
}
|
|
174
|
+
if (
|
|
175
|
+
Number.isInteger(r.graceTurns) &&
|
|
176
|
+
(r.graceTurns as number) >= 1 &&
|
|
177
|
+
(r.graceTurns as number) <= GRACE_TURNS_CEILING
|
|
178
|
+
) {
|
|
179
|
+
out.graceTurns = r.graceTurns as number;
|
|
180
|
+
}
|
|
181
|
+
return out;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
function projectPath(cwd: string): string {
|
|
185
|
+
return join(cwd, ".pi", "subagents.json");
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
/** Load merged settings: global provides defaults, project overrides. */
|
|
189
|
+
export function loadSettings(agentDir: string, cwd: string): SubagentsSettings {
|
|
190
|
+
return loadLayeredSettings({
|
|
191
|
+
agentDir,
|
|
192
|
+
cwd,
|
|
193
|
+
filename: "subagents.json",
|
|
194
|
+
sanitize,
|
|
195
|
+
warnLabel: "pi-subagents",
|
|
196
|
+
} satisfies LayeredSettingsSource<SubagentsSettings>);
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
/**
|
|
200
|
+
* Write project-local settings. Global is never touched from code.
|
|
201
|
+
* Returns `true` on success, `false` if the write (or mkdir) failed so the
|
|
202
|
+
* caller can surface a warning — persistence isn't fatal but isn't silent.
|
|
203
|
+
*/
|
|
204
|
+
export function saveSettings(s: SubagentsSettings, cwd: string = process.cwd()): boolean {
|
|
205
|
+
const path = projectPath(cwd);
|
|
206
|
+
try {
|
|
207
|
+
mkdirSync(dirname(path), { recursive: true });
|
|
208
|
+
writeFileSync(path, JSON.stringify(s, null, 2), "utf-8");
|
|
209
|
+
return true;
|
|
210
|
+
} catch {
|
|
211
|
+
return false;
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
/**
|
|
216
|
+
* Format the user-facing toast for a settings mutation. Pure function —
|
|
217
|
+
* routes the success/failure of `saveSettings` into the right message + level
|
|
218
|
+
* so the UI layer (index.ts) stays a thin wire between input and notification.
|
|
219
|
+
*/
|
|
220
|
+
export function persistToastFor(
|
|
221
|
+
successMsg: string,
|
|
222
|
+
persisted: boolean,
|
|
223
|
+
): { message: string; level: "info" | "warning" } {
|
|
224
|
+
return persisted
|
|
225
|
+
? { message: successMsg, level: "info" }
|
|
226
|
+
: { message: `${successMsg} (session only; failed to persist)`, level: "warning" };
|
|
227
|
+
}
|