@ilya-lesikov/pi-pi 0.8.0 → 0.10.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/3p/pi-ask-user/index.ts +71 -124
- package/3p/pi-ask-user/single-select-layout.ts +3 -14
- package/3p/pi-subagents/package.json +13 -7
- package/3p/pi-subagents/src/agent-manager.ts +243 -79
- package/3p/pi-subagents/src/agent-runner.ts +501 -365
- package/3p/pi-subagents/src/agent-types.ts +46 -57
- package/3p/pi-subagents/src/cross-extension-rpc.ts +52 -46
- package/3p/pi-subagents/src/custom-agents.ts +30 -6
- package/3p/pi-subagents/src/default-agents.ts +25 -43
- package/3p/pi-subagents/src/enabled-models.ts +180 -0
- package/3p/pi-subagents/src/index.ts +599 -839
- package/3p/pi-subagents/src/model-resolver.ts +26 -7
- package/3p/pi-subagents/src/output-file.ts +21 -2
- package/3p/pi-subagents/src/prompts.ts +17 -3
- package/3p/pi-subagents/src/schedule-store.ts +153 -0
- package/3p/pi-subagents/src/schedule.ts +365 -0
- package/3p/pi-subagents/src/settings.ts +261 -0
- package/3p/pi-subagents/src/skill-loader.ts +77 -54
- package/3p/pi-subagents/src/status-note.ts +25 -0
- package/3p/pi-subagents/src/types.ts +97 -6
- package/3p/pi-subagents/src/ui/agent-widget.ts +94 -21
- package/3p/pi-subagents/src/ui/conversation-viewer.ts +147 -35
- package/3p/pi-subagents/src/ui/schedule-menu.ts +104 -0
- package/3p/pi-subagents/src/ui/viewer-keys.ts +39 -0
- package/3p/pi-subagents/src/usage.ts +60 -0
- package/3p/pi-subagents/src/worktree.ts +49 -20
- package/extensions/orchestrator/agents/advisor.ts +13 -8
- package/extensions/orchestrator/agents/brainstorm-reviewer.ts +8 -3
- package/extensions/orchestrator/agents/code-reviewer.ts +29 -8
- package/extensions/orchestrator/agents/constraints.test.ts +65 -1
- package/extensions/orchestrator/agents/constraints.ts +73 -2
- package/extensions/orchestrator/agents/deep-debugger.ts +13 -8
- package/extensions/orchestrator/agents/explore.ts +12 -6
- package/extensions/orchestrator/agents/librarian.ts +13 -5
- package/extensions/orchestrator/agents/plan-reviewer.ts +8 -3
- package/extensions/orchestrator/agents/planner.ts +8 -3
- package/extensions/orchestrator/agents/pools.test.ts +56 -0
- package/extensions/orchestrator/agents/prompts.test.ts +52 -10
- package/extensions/orchestrator/agents/registry.test.ts +245 -0
- package/extensions/orchestrator/agents/registry.ts +145 -10
- package/extensions/orchestrator/agents/reviewer.ts +14 -8
- package/extensions/orchestrator/agents/task.ts +12 -6
- package/extensions/orchestrator/agents/tool-routing.ts +213 -69
- package/extensions/orchestrator/agents/wait-for-completion.test.ts +171 -0
- package/extensions/orchestrator/ai-comment-cleanup.test.ts +89 -0
- package/extensions/orchestrator/ai-comment-cleanup.ts +73 -0
- package/extensions/orchestrator/ast-search.test.ts +124 -0
- package/extensions/orchestrator/billing-spoof.test.ts +67 -0
- package/extensions/orchestrator/billing-spoof.ts +95 -0
- package/extensions/orchestrator/cbm.more.test.ts +358 -0
- package/extensions/orchestrator/command-handlers.test.ts +47 -0
- package/extensions/orchestrator/command-handlers.ts +49 -27
- package/extensions/orchestrator/commands.test.ts +15 -2
- package/extensions/orchestrator/commands.ts +1 -1
- package/extensions/orchestrator/config.test.ts +129 -2
- package/extensions/orchestrator/config.ts +115 -19
- package/extensions/orchestrator/context.test.ts +46 -0
- package/extensions/orchestrator/context.ts +34 -5
- package/extensions/orchestrator/custom-footer.test.ts +105 -0
- package/extensions/orchestrator/custom-footer.ts +22 -20
- package/extensions/orchestrator/doctor.more.test.ts +315 -0
- package/extensions/orchestrator/doctor.ts +6 -2
- package/extensions/orchestrator/event-handlers.more.test.ts +561 -0
- package/extensions/orchestrator/event-handlers.test.ts +411 -10
- package/extensions/orchestrator/event-handlers.ts +738 -349
- package/extensions/orchestrator/exa.more.test.ts +118 -0
- package/extensions/orchestrator/flant-infra.more.test.ts +326 -0
- package/extensions/orchestrator/flant-infra.test.ts +127 -0
- package/extensions/orchestrator/flant-infra.ts +113 -39
- package/extensions/orchestrator/index.test.ts +76 -0
- package/extensions/orchestrator/index.ts +2 -0
- package/extensions/orchestrator/integration.test.ts +488 -74
- package/extensions/orchestrator/model-registry.test.ts +2 -1
- package/extensions/orchestrator/model-registry.ts +12 -2
- package/extensions/orchestrator/node_modules/.vite/vitest/da39a3ee5e6b4b0d3255bfef95601890afd80709/results.json +1 -0
- package/extensions/orchestrator/orchestrator.test.ts +67 -0
- package/extensions/orchestrator/orchestrator.ts +165 -85
- package/extensions/orchestrator/phases/brainstorm.test.ts +30 -1
- package/extensions/orchestrator/phases/brainstorm.ts +53 -12
- package/extensions/orchestrator/phases/implementation.ts +2 -0
- package/extensions/orchestrator/phases/machine.test.ts +53 -1
- package/extensions/orchestrator/phases/machine.ts +15 -2
- package/extensions/orchestrator/phases/planning.test.ts +47 -1
- package/extensions/orchestrator/phases/planning.ts +18 -3
- package/extensions/orchestrator/phases/review-task.test.ts +20 -0
- package/extensions/orchestrator/phases/review-task.ts +47 -14
- package/extensions/orchestrator/phases/review.test.ts +36 -0
- package/extensions/orchestrator/phases/review.ts +62 -10
- package/extensions/orchestrator/phases/spawn-blocking.test.ts +1 -0
- package/extensions/orchestrator/phases/verdict.ts +6 -5
- package/extensions/orchestrator/plannotator-open-failure.test.ts +67 -0
- package/extensions/orchestrator/plannotator.test.ts +38 -1
- package/extensions/orchestrator/plannotator.ts +50 -3
- package/extensions/orchestrator/pp-menu.leaves.test.ts +590 -0
- package/extensions/orchestrator/pp-menu.more.test.ts +524 -0
- package/extensions/orchestrator/pp-menu.test.ts +314 -1
- package/extensions/orchestrator/pp-menu.ts +937 -450
- package/extensions/orchestrator/pp-state-tools.test.ts +80 -0
- package/extensions/orchestrator/pp-state-tools.ts +65 -0
- package/extensions/orchestrator/rate-limit-fallback.more.test.ts +241 -0
- package/extensions/orchestrator/rate-limit-fallback.test.ts +20 -1
- package/extensions/orchestrator/rate-limit-fallback.ts +12 -0
- package/extensions/orchestrator/review-files.test.ts +26 -0
- package/extensions/orchestrator/review-files.ts +3 -0
- package/extensions/orchestrator/state.test.ts +82 -1
- package/extensions/orchestrator/state.ts +110 -23
- package/extensions/orchestrator/suppress-pierre-theme-spam.test.ts +56 -0
- package/extensions/orchestrator/suppress-pierre-theme-spam.ts +45 -0
- package/extensions/orchestrator/transition-controller.test.ts +100 -0
- package/extensions/orchestrator/transition-controller.ts +61 -3
- package/extensions/orchestrator/validate-artifacts.ts +1 -1
- package/package.json +6 -2
- package/scripts/test-3p.sh +52 -0
- package/AGENTS.md +0 -28
|
@@ -0,0 +1,261 @@
|
|
|
1
|
+
// Persistence for pi-subagents operational settings.
|
|
2
|
+
// - Global: ~/.pi/agent/subagents.json (via getAgentDir()) — manual defaults, never written here
|
|
3
|
+
// - Project: <cwd>/.pi/subagents.json — written by /agents → Settings; overrides global on load
|
|
4
|
+
|
|
5
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
6
|
+
import { dirname, join } from "node:path";
|
|
7
|
+
import { getAgentDir } from "@earendil-works/pi-coding-agent";
|
|
8
|
+
import type { JoinMode, WidgetMode } from "./types.js";
|
|
9
|
+
|
|
10
|
+
export interface SubagentsSettings {
|
|
11
|
+
maxConcurrent?: number;
|
|
12
|
+
/**
|
|
13
|
+
* 0 = unlimited — the extension's single source of truth for that convention:
|
|
14
|
+
* `normalizeMaxTurns()` in agent-runner.ts treats 0 → `undefined`, and the
|
|
15
|
+
* `/agents` → Settings input prompt explicitly says "0 = unlimited".
|
|
16
|
+
*/
|
|
17
|
+
defaultMaxTurns?: number;
|
|
18
|
+
graceTurns?: number;
|
|
19
|
+
defaultJoinMode?: JoinMode;
|
|
20
|
+
/**
|
|
21
|
+
* Master switch for the schedule subagent feature. Defaults to `true`.
|
|
22
|
+
* When `false`: the `Agent` tool's `schedule` param + its guideline are
|
|
23
|
+
* stripped from the tool spec at registration (zero LLM-context cost), the
|
|
24
|
+
* scheduler doesn't bind to the session, and the `/agents → Scheduled jobs`
|
|
25
|
+
* menu entry is hidden. Schema-level removal applies at extension load
|
|
26
|
+
* (next pi session); runtime menu/runtime-fire short-circuit is immediate.
|
|
27
|
+
*/
|
|
28
|
+
schedulingEnabled?: boolean;
|
|
29
|
+
/**
|
|
30
|
+
* When true, the effective model of each subagent spawn is validated
|
|
31
|
+
* against `enabledModels` from pi's settings — both global
|
|
32
|
+
* (`<agentDir>/settings.json`) and project-local (`<cwd>/.pi/settings.json`),
|
|
33
|
+
* with project overriding global (mirrors pi's SettingsManager deep-merge).
|
|
34
|
+
*
|
|
35
|
+
* scopeModels guards against runtime LLM choices, not user-level config.
|
|
36
|
+
* Out-of-scope handling reflects this:
|
|
37
|
+
* - Caller-supplied via `Agent({ model: "..." })` (only when frontmatter
|
|
38
|
+
* has no `model:`, since frontmatter is authoritative): hard error
|
|
39
|
+
* returned to the orchestrator, listing the allowed models. The LLM
|
|
40
|
+
* made an explicit out-of-scope choice and gets explicit feedback.
|
|
41
|
+
* - Frontmatter-pinned: warning toast + the pinned model runs. The
|
|
42
|
+
* agent's author/installer chose this; trust it.
|
|
43
|
+
* - Parent-inherited (neither caller nor frontmatter sets a model):
|
|
44
|
+
* warning toast + parent's model runs. The user chose the parent's
|
|
45
|
+
* model when starting the session; trust it.
|
|
46
|
+
*
|
|
47
|
+
* No-op when pi's `enabledModels` is empty or absent — nothing to validate
|
|
48
|
+
* against. Defaults to false: subagents may use any model.
|
|
49
|
+
*/
|
|
50
|
+
scopeModels?: boolean;
|
|
51
|
+
/**
|
|
52
|
+
* When true, the three built-in default agents (general-purpose, Explore, Plan)
|
|
53
|
+
* are not registered at startup. User-defined agents from .pi/agents/*.md are
|
|
54
|
+
* completely unaffected — only the hardcoded DEFAULT_AGENTS are suppressed.
|
|
55
|
+
* Defaults to false.
|
|
56
|
+
*/
|
|
57
|
+
disableDefaultAgents?: boolean;
|
|
58
|
+
/**
|
|
59
|
+
* Which Agent tool description the LLM sees. "full" (default) is the rich
|
|
60
|
+
* Claude Code-style prompt; "compact" is a ~75% smaller version (one-line
|
|
61
|
+
* agent type list, terse usage notes) for small/local models where tool-spec
|
|
62
|
+
* tokens are expensive; "custom" reads `.pi/agent-tool-description.md`
|
|
63
|
+
* (project, falling back to `<agentDir>/agent-tool-description.md`) with
|
|
64
|
+
* `{{placeholder}}` substitution — a missing/empty file falls back to "full".
|
|
65
|
+
* The mode is read once at tool registration — changing it applies on the
|
|
66
|
+
* next pi session.
|
|
67
|
+
*/
|
|
68
|
+
toolDescriptionMode?: ToolDescriptionMode;
|
|
69
|
+
/**
|
|
70
|
+
* Display mode for the persistent above-editor agent widget:
|
|
71
|
+
* - `all`: show every agent (foreground + background).
|
|
72
|
+
* - `background`: hide foreground agents — they already render inline as the
|
|
73
|
+
* Agent tool result, so the widget would otherwise double-render them
|
|
74
|
+
* (#118); everything else (background, queued, scheduled, RPC) stays.
|
|
75
|
+
* - `off`: hide the widget entirely.
|
|
76
|
+
* Defaults to `background`. Pure-UI and applied live (toggling refreshes the
|
|
77
|
+
* widget).
|
|
78
|
+
*/
|
|
79
|
+
widgetMode?: WidgetMode;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
export type ToolDescriptionMode = "full" | "compact" | "custom";
|
|
83
|
+
|
|
84
|
+
/** Setter hooks used by applySettings to wire persisted values into in-memory state. */
|
|
85
|
+
export interface SettingsAppliers {
|
|
86
|
+
setMaxConcurrent: (n: number) => void;
|
|
87
|
+
setDefaultMaxTurns: (n: number) => void;
|
|
88
|
+
setGraceTurns: (n: number) => void;
|
|
89
|
+
setDefaultJoinMode: (mode: JoinMode) => void;
|
|
90
|
+
setSchedulingEnabled: (b: boolean) => void;
|
|
91
|
+
setScopeModels: (enabled: boolean) => void;
|
|
92
|
+
setDisableDefaultAgents: (b: boolean) => void;
|
|
93
|
+
setToolDescriptionMode: (mode: ToolDescriptionMode) => void;
|
|
94
|
+
setWidgetMode: (mode: WidgetMode) => void;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/** Emit callback — a subset of `pi.events.emit` to keep helpers testable. */
|
|
98
|
+
export type SettingsEmit = (event: string, payload: unknown) => void;
|
|
99
|
+
|
|
100
|
+
const VALID_JOIN_MODES: ReadonlySet<string> = new Set<JoinMode>(["async", "group", "smart"]);
|
|
101
|
+
const VALID_TOOL_DESCRIPTION_MODES: ReadonlySet<string> = new Set<ToolDescriptionMode>(["full", "compact", "custom"]);
|
|
102
|
+
const VALID_WIDGET_MODES: ReadonlySet<string> = new Set<WidgetMode>(["all", "background", "off"]);
|
|
103
|
+
|
|
104
|
+
// Sanity ceilings — prevent hand-edited configs from asking for values that
|
|
105
|
+
// make no operational sense (e.g. 1e6 concurrent subagents). Permissive enough
|
|
106
|
+
// that any realistic power-user setting passes through.
|
|
107
|
+
const MAX_CONCURRENT_CEILING = 1024;
|
|
108
|
+
const MAX_TURNS_CEILING = 10_000;
|
|
109
|
+
const GRACE_TURNS_CEILING = 1_000;
|
|
110
|
+
|
|
111
|
+
/** Drop fields that don't match the expected shape. Silent — garbage becomes absent. */
|
|
112
|
+
function sanitize(raw: unknown): SubagentsSettings {
|
|
113
|
+
if (!raw || typeof raw !== "object") return {};
|
|
114
|
+
const r = raw as Record<string, unknown>;
|
|
115
|
+
const out: SubagentsSettings = {};
|
|
116
|
+
if (
|
|
117
|
+
Number.isInteger(r.maxConcurrent) &&
|
|
118
|
+
(r.maxConcurrent as number) >= 1 &&
|
|
119
|
+
(r.maxConcurrent as number) <= MAX_CONCURRENT_CEILING
|
|
120
|
+
) {
|
|
121
|
+
out.maxConcurrent = r.maxConcurrent as number;
|
|
122
|
+
}
|
|
123
|
+
if (
|
|
124
|
+
Number.isInteger(r.defaultMaxTurns) &&
|
|
125
|
+
(r.defaultMaxTurns as number) >= 0 &&
|
|
126
|
+
(r.defaultMaxTurns as number) <= MAX_TURNS_CEILING
|
|
127
|
+
) {
|
|
128
|
+
out.defaultMaxTurns = r.defaultMaxTurns as number;
|
|
129
|
+
}
|
|
130
|
+
if (
|
|
131
|
+
Number.isInteger(r.graceTurns) &&
|
|
132
|
+
(r.graceTurns as number) >= 1 &&
|
|
133
|
+
(r.graceTurns as number) <= GRACE_TURNS_CEILING
|
|
134
|
+
) {
|
|
135
|
+
out.graceTurns = r.graceTurns as number;
|
|
136
|
+
}
|
|
137
|
+
if (typeof r.defaultJoinMode === "string" && VALID_JOIN_MODES.has(r.defaultJoinMode)) {
|
|
138
|
+
out.defaultJoinMode = r.defaultJoinMode as JoinMode;
|
|
139
|
+
}
|
|
140
|
+
if (typeof r.schedulingEnabled === "boolean") {
|
|
141
|
+
out.schedulingEnabled = r.schedulingEnabled;
|
|
142
|
+
}
|
|
143
|
+
if (typeof r.scopeModels === "boolean") {
|
|
144
|
+
out.scopeModels = r.scopeModels;
|
|
145
|
+
}
|
|
146
|
+
if (typeof r.disableDefaultAgents === "boolean") {
|
|
147
|
+
out.disableDefaultAgents = r.disableDefaultAgents;
|
|
148
|
+
}
|
|
149
|
+
if (typeof r.toolDescriptionMode === "string" && VALID_TOOL_DESCRIPTION_MODES.has(r.toolDescriptionMode)) {
|
|
150
|
+
out.toolDescriptionMode = r.toolDescriptionMode as ToolDescriptionMode;
|
|
151
|
+
}
|
|
152
|
+
if (typeof r.widgetMode === "string" && VALID_WIDGET_MODES.has(r.widgetMode)) {
|
|
153
|
+
out.widgetMode = r.widgetMode as WidgetMode;
|
|
154
|
+
}
|
|
155
|
+
return out;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
function globalPath(): string {
|
|
159
|
+
return join(getAgentDir(), "subagents.json");
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
function projectPath(cwd: string): string {
|
|
163
|
+
return join(cwd, ".pi", "subagents.json");
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/**
|
|
167
|
+
* Read a settings file. Missing file is silent (returns `{}`). A file that
|
|
168
|
+
* exists but can't be parsed emits a warning to stderr so users aren't
|
|
169
|
+
* silently reverted to defaults — and still returns `{}` so startup proceeds.
|
|
170
|
+
*/
|
|
171
|
+
function readSettingsFile(path: string): SubagentsSettings {
|
|
172
|
+
if (!existsSync(path)) return {};
|
|
173
|
+
try {
|
|
174
|
+
return sanitize(JSON.parse(readFileSync(path, "utf-8")));
|
|
175
|
+
} catch (err) {
|
|
176
|
+
const reason = err instanceof Error ? err.message : String(err);
|
|
177
|
+
console.warn(`[pi-subagents] Ignoring malformed settings at ${path}: ${reason}`);
|
|
178
|
+
return {};
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
/** Load merged settings: global provides defaults, project overrides. */
|
|
183
|
+
export function loadSettings(cwd: string = process.cwd()): SubagentsSettings {
|
|
184
|
+
return { ...readSettingsFile(globalPath()), ...readSettingsFile(projectPath(cwd)) };
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
/**
|
|
188
|
+
* Write project-local settings. Global is never touched from code.
|
|
189
|
+
* Returns `true` on success, `false` if the write (or mkdir) failed so the
|
|
190
|
+
* caller can surface a warning — persistence isn't fatal but isn't silent.
|
|
191
|
+
*/
|
|
192
|
+
export function saveSettings(s: SubagentsSettings, cwd: string = process.cwd()): boolean {
|
|
193
|
+
const path = projectPath(cwd);
|
|
194
|
+
try {
|
|
195
|
+
mkdirSync(dirname(path), { recursive: true });
|
|
196
|
+
writeFileSync(path, JSON.stringify(s, null, 2), "utf-8");
|
|
197
|
+
return true;
|
|
198
|
+
} catch {
|
|
199
|
+
return false;
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
/** Apply persisted settings to the in-memory state via caller-supplied setters. */
|
|
204
|
+
export function applySettings(s: SubagentsSettings, appliers: SettingsAppliers): void {
|
|
205
|
+
if (typeof s.maxConcurrent === "number") appliers.setMaxConcurrent(s.maxConcurrent);
|
|
206
|
+
if (typeof s.defaultMaxTurns === "number") appliers.setDefaultMaxTurns(s.defaultMaxTurns);
|
|
207
|
+
if (typeof s.graceTurns === "number") appliers.setGraceTurns(s.graceTurns);
|
|
208
|
+
if (s.defaultJoinMode) appliers.setDefaultJoinMode(s.defaultJoinMode);
|
|
209
|
+
if (typeof s.schedulingEnabled === "boolean") appliers.setSchedulingEnabled(s.schedulingEnabled);
|
|
210
|
+
if (typeof s.scopeModels === "boolean") appliers.setScopeModels(s.scopeModels);
|
|
211
|
+
if (typeof s.disableDefaultAgents === "boolean") appliers.setDisableDefaultAgents(s.disableDefaultAgents);
|
|
212
|
+
if (s.toolDescriptionMode) appliers.setToolDescriptionMode(s.toolDescriptionMode);
|
|
213
|
+
if (s.widgetMode) appliers.setWidgetMode(s.widgetMode);
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
/**
|
|
217
|
+
* Format the user-facing toast for a settings mutation. Pure function —
|
|
218
|
+
* routes the success/failure of `saveSettings` into the right message + level
|
|
219
|
+
* so the UI layer (index.ts) stays a thin wire between input and notification.
|
|
220
|
+
*/
|
|
221
|
+
export function persistToastFor(
|
|
222
|
+
successMsg: string,
|
|
223
|
+
persisted: boolean,
|
|
224
|
+
): { message: string; level: "info" | "warning" } {
|
|
225
|
+
return persisted
|
|
226
|
+
? { message: successMsg, level: "info" }
|
|
227
|
+
: { message: `${successMsg} (session only; failed to persist)`, level: "warning" };
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
/**
|
|
231
|
+
* Load merged settings, apply them to in-memory state, and emit the
|
|
232
|
+
* `subagents:settings_loaded` lifecycle event. Returns the loaded settings so
|
|
233
|
+
* callers can log/inspect. Extension init wires this once.
|
|
234
|
+
*/
|
|
235
|
+
export function applyAndEmitLoaded(
|
|
236
|
+
appliers: SettingsAppliers,
|
|
237
|
+
emit: SettingsEmit,
|
|
238
|
+
cwd: string = process.cwd(),
|
|
239
|
+
): SubagentsSettings {
|
|
240
|
+
const settings = loadSettings(cwd);
|
|
241
|
+
applySettings(settings, appliers);
|
|
242
|
+
emit("subagents:settings_loaded", { settings });
|
|
243
|
+
return settings;
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
/**
|
|
247
|
+
* Persist a settings snapshot, emit the `subagents:settings_changed` event
|
|
248
|
+
* (regardless of persist outcome so listeners see the in-memory change), and
|
|
249
|
+
* return the toast the UI should display. Event payload carries the `persisted`
|
|
250
|
+
* flag so listeners can react to write failures.
|
|
251
|
+
*/
|
|
252
|
+
export function saveAndEmitChanged(
|
|
253
|
+
snapshot: SubagentsSettings,
|
|
254
|
+
successMsg: string,
|
|
255
|
+
emit: SettingsEmit,
|
|
256
|
+
cwd: string = process.cwd(),
|
|
257
|
+
): { message: string; level: "info" | "warning" } {
|
|
258
|
+
const persisted = saveSettings(snapshot, cwd);
|
|
259
|
+
emit("subagents:settings_changed", { settings: snapshot, persisted });
|
|
260
|
+
return persistToastFor(successMsg, persisted);
|
|
261
|
+
}
|
|
@@ -1,79 +1,102 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* skill-loader.ts — Preload
|
|
2
|
+
* skill-loader.ts — Preload named skills.
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
4
|
+
* Roots, in precedence order:
|
|
5
|
+
* - <cwd>/.pi/skills (project, Pi's standard)
|
|
6
|
+
* - <cwd>/.agents/skills (project, cross-tool Agent Skills spec — https://agentskills.io)
|
|
7
|
+
* - getAgentDir()/skills (user, default ~/.pi/agent/skills — Pi's standard)
|
|
8
|
+
* - ~/.agents/skills (user, cross-tool Agent Skills spec)
|
|
9
|
+
* - ~/.pi/skills (legacy global, pre-Pi)
|
|
10
|
+
*
|
|
11
|
+
* Layout per root:
|
|
12
|
+
* - <root>/<name>.md (flat file at the top level)
|
|
13
|
+
* - <root>/.../<name>/SKILL.md (directory skill, may be nested — Pi's standard)
|
|
14
|
+
*
|
|
15
|
+
* Recursion skips dotfile entries and node_modules. A directory that itself contains
|
|
16
|
+
* SKILL.md is a skill — we don't descend into it (Pi: skills don't nest).
|
|
17
|
+
*
|
|
18
|
+
* Symlinks are rejected for security (deviation from Pi, which follows them).
|
|
6
19
|
*/
|
|
7
20
|
|
|
21
|
+
import type { Dirent } from "node:fs";
|
|
22
|
+
import { existsSync, readdirSync } from "node:fs";
|
|
8
23
|
import { homedir } from "node:os";
|
|
9
24
|
import { join } from "node:path";
|
|
10
|
-
import {
|
|
25
|
+
import { getAgentDir } from "@earendil-works/pi-coding-agent";
|
|
26
|
+
import { isSymlink, isUnsafeName, safeReadFile } from "./memory.js";
|
|
11
27
|
|
|
12
28
|
export interface PreloadedSkill {
|
|
13
29
|
name: string;
|
|
14
30
|
content: string;
|
|
15
31
|
}
|
|
16
32
|
|
|
17
|
-
/**
|
|
18
|
-
* Attempt to load named skills from project and global skill directories.
|
|
19
|
-
* Looks for: <dir>/<name>.md, <dir>/<name>.txt, <dir>/<name>
|
|
20
|
-
*
|
|
21
|
-
* @param skillNames List of skill names to preload.
|
|
22
|
-
* @param cwd Working directory for project-level skills.
|
|
23
|
-
* @returns Array of loaded skills (missing skills are skipped with a warning comment).
|
|
24
|
-
*/
|
|
25
33
|
export function preloadSkills(skillNames: string[], cwd: string): PreloadedSkill[] {
|
|
26
|
-
|
|
34
|
+
return skillNames.map((name) => ({ name, content: loadSkillContent(name, cwd) }));
|
|
35
|
+
}
|
|
27
36
|
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
if (isUnsafeName(name)) {
|
|
32
|
-
results.push({ name, content: `(Skill "${name}" skipped: name contains path traversal characters)` });
|
|
33
|
-
continue;
|
|
34
|
-
}
|
|
35
|
-
const content = findAndReadSkill(name, cwd);
|
|
36
|
-
if (content !== undefined) {
|
|
37
|
-
results.push({ name, content });
|
|
38
|
-
} else {
|
|
39
|
-
// Include a note about missing skills so the agent knows it was requested but not found
|
|
40
|
-
results.push({ name, content: `(Skill "${name}" not found in .pi/skills/ or ~/.pi/skills/)` });
|
|
41
|
-
}
|
|
37
|
+
function loadSkillContent(name: string, cwd: string): string {
|
|
38
|
+
if (isUnsafeName(name)) {
|
|
39
|
+
return `(Skill "${name}" skipped: name contains path traversal characters)`;
|
|
42
40
|
}
|
|
41
|
+
const roots = [
|
|
42
|
+
join(cwd, ".pi", "skills"), // project — Pi standard
|
|
43
|
+
join(cwd, ".agents", "skills"), // project — Agent Skills spec
|
|
44
|
+
join(getAgentDir(), "skills"), // user — Pi standard
|
|
45
|
+
join(homedir(), ".agents", "skills"), // user — Agent Skills spec
|
|
46
|
+
join(homedir(), ".pi", "skills"), // legacy global, pre-Pi
|
|
47
|
+
];
|
|
48
|
+
for (const root of roots) {
|
|
49
|
+
const content = findInRoot(root, name);
|
|
50
|
+
if (content !== undefined) return content;
|
|
51
|
+
}
|
|
52
|
+
return `(Skill "${name}" not found in .pi/skills/, .agents/skills/, or global skill locations)`;
|
|
53
|
+
}
|
|
43
54
|
|
|
44
|
-
|
|
55
|
+
function findInRoot(root: string, name: string): string | undefined {
|
|
56
|
+
if (isSymlink(root)) return undefined; // reject symlinked roots entirely
|
|
57
|
+
const flat = safeReadFile(join(root, `${name}.md`))?.trim();
|
|
58
|
+
if (flat !== undefined) return flat;
|
|
59
|
+
return findSkillDirectory(root, name);
|
|
45
60
|
}
|
|
46
61
|
|
|
47
|
-
/**
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
function findAndReadSkill(name: string, cwd: string): string | undefined {
|
|
52
|
-
const projectDir = join(cwd, ".pi", "skills");
|
|
53
|
-
const globalDir = join(homedir(), ".pi", "skills");
|
|
62
|
+
/** BFS under `root` for a directory named `name` containing `SKILL.md`. Pi-conforming filters. */
|
|
63
|
+
function findSkillDirectory(root: string, name: string): string | undefined {
|
|
64
|
+
if (!existsSync(root)) return undefined;
|
|
65
|
+
const queue: string[] = [root];
|
|
54
66
|
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
if (content !== undefined) return content;
|
|
59
|
-
}
|
|
67
|
+
while (queue.length > 0) {
|
|
68
|
+
const current = queue.shift();
|
|
69
|
+
if (current === undefined) continue;
|
|
60
70
|
|
|
61
|
-
|
|
62
|
-
|
|
71
|
+
let entries: Dirent<string>[];
|
|
72
|
+
try {
|
|
73
|
+
entries = readdirSync(current, { withFileTypes: true });
|
|
74
|
+
} catch {
|
|
75
|
+
continue;
|
|
76
|
+
}
|
|
63
77
|
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
* Tries extensions in order: .md, .txt, (no extension)
|
|
67
|
-
*/
|
|
68
|
-
function tryReadSkillFile(dir: string, name: string): string | undefined {
|
|
69
|
-
const extensions = [".md", ".txt", ""];
|
|
78
|
+
// Deterministic byte-order traversal — locale-independent.
|
|
79
|
+
entries.sort((a, b) => (a.name < b.name ? -1 : a.name > b.name ? 1 : 0));
|
|
70
80
|
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
81
|
+
for (const entry of entries) {
|
|
82
|
+
if (!entry.isDirectory()) continue;
|
|
83
|
+
if (entry.name.startsWith(".") || entry.name === "node_modules") continue;
|
|
84
|
+
|
|
85
|
+
// Symlinked dirs already filtered by entry.isDirectory() — Dirent uses lstat semantics.
|
|
86
|
+
const path = join(current, entry.name);
|
|
87
|
+
const skillMd = join(path, "SKILL.md");
|
|
88
|
+
const isSkillDir = existsSync(skillMd);
|
|
77
89
|
|
|
90
|
+
if (isSkillDir) {
|
|
91
|
+
if (entry.name === name) {
|
|
92
|
+
const content = safeReadFile(skillMd)?.trim();
|
|
93
|
+
if (content !== undefined) return content;
|
|
94
|
+
}
|
|
95
|
+
continue; // Pi rule: skills don't nest — don't descend into a skill dir
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
queue.push(path);
|
|
99
|
+
}
|
|
100
|
+
}
|
|
78
101
|
return undefined;
|
|
79
102
|
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* status-note.ts — Parenthetical status note appended to agent result text.
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Explicit parenthetical note for a non-normal terminal outcome, so the parent
|
|
7
|
+
* agent can't mistake partial output for a completed result. Empty string for a
|
|
8
|
+
* clean completion (and any unknown/non-terminal status).
|
|
9
|
+
*
|
|
10
|
+
* `stopped` (a human aborted it) is deliberately distinct from `aborted` (the
|
|
11
|
+
* turn limit was hit) — the parent should treat human intervention differently
|
|
12
|
+
* from a budget cutoff.
|
|
13
|
+
*/
|
|
14
|
+
export function getStatusNote(status: string): string {
|
|
15
|
+
switch (status) {
|
|
16
|
+
case "stopped":
|
|
17
|
+
return " (STOPPED BY THE USER before completion — output is partial; the task was NOT finished)";
|
|
18
|
+
case "aborted":
|
|
19
|
+
return " (aborted — hit the turn limit before completion; output may be incomplete)";
|
|
20
|
+
case "steered":
|
|
21
|
+
return " (wrapped up at the turn limit — output may be partial)";
|
|
22
|
+
default:
|
|
23
|
+
return "";
|
|
24
|
+
}
|
|
25
|
+
}
|
|
@@ -2,8 +2,9 @@
|
|
|
2
2
|
* types.ts — Type definitions for the subagent system.
|
|
3
3
|
*/
|
|
4
4
|
|
|
5
|
-
import type { ThinkingLevel } from "@earendil-works/pi-
|
|
5
|
+
import type { ThinkingLevel } from "@earendil-works/pi-ai";
|
|
6
6
|
import type { AgentSession } from "@earendil-works/pi-coding-agent";
|
|
7
|
+
import type { LifetimeUsage } from "./usage.js";
|
|
7
8
|
|
|
8
9
|
export type { ThinkingLevel };
|
|
9
10
|
|
|
@@ -25,15 +26,25 @@ export interface AgentConfig {
|
|
|
25
26
|
displayName?: string;
|
|
26
27
|
description: string;
|
|
27
28
|
builtinToolNames?: string[];
|
|
29
|
+
/** Raw `ext:` selector entries from the `tools:` CSV, e.g. ["ext:foo", "ext:bar/x"].
|
|
30
|
+
* Presence of any entry flips extension tools to an explicit allowlist. */
|
|
31
|
+
extSelectors?: string[];
|
|
28
32
|
/** Tool denylist — these tools are removed even if `builtinToolNames` or extensions include them. */
|
|
29
33
|
disallowedTools?: string[];
|
|
30
34
|
/** true = inherit all, string[] = only listed, false = none */
|
|
31
35
|
extensions: true | string[] | false;
|
|
36
|
+
/** Extension-name denylist applied after the `extensions:` include set. Exclude wins.
|
|
37
|
+
* Plain canonical names only (case-insensitive); no paths, no wildcard. */
|
|
38
|
+
excludeExtensions?: string[];
|
|
32
39
|
/** true = inherit all, string[] = only listed, false = none */
|
|
33
40
|
skills: true | string[] | false;
|
|
34
41
|
model?: string;
|
|
35
42
|
thinking?: ThinkingLevel;
|
|
36
43
|
maxTurns?: number;
|
|
44
|
+
/** Persist this subagent as a normal pi session instead of keeping it in memory only. */
|
|
45
|
+
persistSession?: boolean;
|
|
46
|
+
/** Optional session directory used when persistSession is true. Omitted = pi's normal session location. */
|
|
47
|
+
sessionDir?: string;
|
|
37
48
|
systemPrompt: string;
|
|
38
49
|
promptMode: "replace" | "append";
|
|
39
50
|
/** Default for spawn: fork parent conversation. undefined = caller decides. */
|
|
@@ -56,6 +67,15 @@ export interface AgentConfig {
|
|
|
56
67
|
|
|
57
68
|
export type JoinMode = 'async' | 'group' | 'smart';
|
|
58
69
|
|
|
70
|
+
/**
|
|
71
|
+
* Display mode for the persistent above-editor agent widget.
|
|
72
|
+
* - `all`: show every agent (foreground + background).
|
|
73
|
+
* - `background`: hide foreground agents (they already render inline as the
|
|
74
|
+
* Agent tool result, #118); show background/queued/scheduled/RPC.
|
|
75
|
+
* - `off`: hide the widget entirely.
|
|
76
|
+
*/
|
|
77
|
+
export type WidgetMode = 'all' | 'background' | 'off';
|
|
78
|
+
|
|
59
79
|
export interface AgentRecord {
|
|
60
80
|
id: string;
|
|
61
81
|
type: SubagentType;
|
|
@@ -76,19 +96,47 @@ export interface AgentRecord {
|
|
|
76
96
|
/** Steering messages queued before the session was ready. */
|
|
77
97
|
pendingSteers?: string[];
|
|
78
98
|
/** Worktree info if the agent is running in an isolated worktree. */
|
|
79
|
-
worktree?: { path: string; branch: string };
|
|
99
|
+
worktree?: { path: string; branch: string; baseSha: string; workPath: string };
|
|
80
100
|
/** Worktree cleanup result after agent completion. */
|
|
81
101
|
worktreeResult?: { hasChanges: boolean; branch?: string };
|
|
82
102
|
/** The tool_use_id from the original Agent tool call. */
|
|
83
103
|
toolCallId?: string;
|
|
84
|
-
/** Whether the subagents:first_tool event has been emitted for this run. */
|
|
85
|
-
firstToolEmitted?: boolean;
|
|
86
|
-
/** Whether the subagents:first_turn event has been emitted for this run. */
|
|
87
|
-
firstTurnEmitted?: boolean;
|
|
88
104
|
/** Path to the streaming output transcript file. */
|
|
89
105
|
outputFile?: string;
|
|
90
106
|
/** Cleanup function for the output file stream subscription. */
|
|
91
107
|
outputCleanup?: () => void;
|
|
108
|
+
/**
|
|
109
|
+
* Lifetime usage breakdown, accumulated via `message_end` events. Survives
|
|
110
|
+
* compaction. Total = input + output + cacheWrite (cacheRead deliberately
|
|
111
|
+
* excluded — see issue #38). Initialized to zeros at spawn.
|
|
112
|
+
*/
|
|
113
|
+
lifetimeUsage: LifetimeUsage;
|
|
114
|
+
/** Number of times this agent's session has compacted. Initialized to 0 at spawn. */
|
|
115
|
+
compactionCount: number;
|
|
116
|
+
/**
|
|
117
|
+
* Whether this agent was spawned to run in the background. Tri-state, set at
|
|
118
|
+
* spawn from `SpawnOptions.isBackground`: `true` = background, `false` =
|
|
119
|
+
* foreground (has an inline Agent tool-result surface), `undefined` = the
|
|
120
|
+
* caller never declared it (e.g. a cross-extension RPC spawn, which is detached
|
|
121
|
+
* and has no inline surface). The widget's background-only filter keys off this
|
|
122
|
+
* — and excludes only explicit `false`, so `undefined` agents stay visible.
|
|
123
|
+
* Reliable across ALL spawn paths, unlike the UI-only `invocation` snapshot,
|
|
124
|
+
* which only the Agent-tool path populates.
|
|
125
|
+
*/
|
|
126
|
+
isBackground?: boolean;
|
|
127
|
+
/** Resolved spawn params, captured for UI display. Fixed at spawn time. */
|
|
128
|
+
invocation?: AgentInvocation;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
export interface AgentInvocation {
|
|
132
|
+
/** Short display name, e.g. "haiku" — only set when different from parent. */
|
|
133
|
+
modelName?: string;
|
|
134
|
+
thinking?: ThinkingLevel;
|
|
135
|
+
maxTurns?: number;
|
|
136
|
+
isolated?: boolean;
|
|
137
|
+
inheritContext?: boolean;
|
|
138
|
+
runInBackground?: boolean;
|
|
139
|
+
isolation?: IsolationMode;
|
|
92
140
|
}
|
|
93
141
|
|
|
94
142
|
/** Details attached to custom notification messages for visual rendering. */
|
|
@@ -113,3 +161,46 @@ export interface EnvInfo {
|
|
|
113
161
|
branch: string;
|
|
114
162
|
platform: string;
|
|
115
163
|
}
|
|
164
|
+
|
|
165
|
+
/**
|
|
166
|
+
* A subagent spawn registered to fire on a schedule.
|
|
167
|
+
*
|
|
168
|
+
* Stored at `<cwd>/.pi/subagent-schedules/<sessionId>.json`. Session-scoped:
|
|
169
|
+
* survives `/resume` but resets on `/new`, mirroring pi-chonky-tasks.
|
|
170
|
+
*/
|
|
171
|
+
export interface ScheduledSubagent {
|
|
172
|
+
id: string;
|
|
173
|
+
/** Unique within store. Defaults to `description`. */
|
|
174
|
+
name: string;
|
|
175
|
+
description: string;
|
|
176
|
+
/** Raw user input — cron expr | "+10m" | ISO | "5m". */
|
|
177
|
+
schedule: string;
|
|
178
|
+
scheduleType: "cron" | "once" | "interval";
|
|
179
|
+
/** Computed at create time for interval/once. */
|
|
180
|
+
intervalMs?: number;
|
|
181
|
+
|
|
182
|
+
// spawn params (subset of Agent tool params; no inherit_context, no resume)
|
|
183
|
+
subagent_type: SubagentType;
|
|
184
|
+
prompt: string;
|
|
185
|
+
model?: string;
|
|
186
|
+
thinking?: ThinkingLevel;
|
|
187
|
+
max_turns?: number;
|
|
188
|
+
isolated?: boolean;
|
|
189
|
+
isolation?: IsolationMode;
|
|
190
|
+
|
|
191
|
+
// state
|
|
192
|
+
enabled: boolean;
|
|
193
|
+
/** ISO timestamp. */
|
|
194
|
+
createdAt: string;
|
|
195
|
+
lastRun?: string;
|
|
196
|
+
lastStatus?: "success" | "error" | "running";
|
|
197
|
+
/** Refreshed on every fire and on store load. */
|
|
198
|
+
nextRun?: string;
|
|
199
|
+
runCount: number;
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
export interface ScheduleStoreData {
|
|
203
|
+
/** For future migrations. */
|
|
204
|
+
version: 1;
|
|
205
|
+
jobs: ScheduledSubagent[];
|
|
206
|
+
}
|