@ferris1225/pi-subagents 4.3.4 → 4.3.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (38) hide show
  1. package/CHANGELOG.md +36 -0
  2. package/README.md +103 -83
  3. package/agents/artisan.md +0 -1
  4. package/agents/steward.md +1 -2
  5. package/{src/index.ts → index.ts} +19 -19
  6. package/package.json +4 -3
  7. package/src/{config.ts → configuration/config.ts} +19 -24
  8. package/src/configuration/setup.ts +375 -0
  9. package/src/configuration/ui.ts +245 -0
  10. package/src/{agents.ts → delegation/agents.ts} +3 -3
  11. package/src/{dispatch.ts → delegation/dispatch.ts} +12 -18
  12. package/src/{prompt.ts → delegation/prompt.ts} +5 -9
  13. package/src/{background.ts → execution/background.ts} +3 -6
  14. package/src/execution/rpc-control.ts +235 -0
  15. package/src/{rpc-run.ts → execution/rpc-run.ts} +35 -225
  16. package/src/{session-fork.ts → execution/session-fork.ts} +1 -1
  17. package/src/{spawn.ts → execution/spawn.ts} +10 -8
  18. package/src/isolation/git-command.ts +147 -0
  19. package/src/isolation/managed-paths.ts +145 -0
  20. package/src/{recovery.ts → isolation/recovery.ts} +42 -13
  21. package/src/{temp-hygiene.ts → isolation/temp-hygiene.ts} +7 -7
  22. package/src/{worktree.ts → isolation/worktree.ts} +11 -158
  23. package/src/{completion.ts → lifecycle/completion.ts} +2 -2
  24. package/src/{durable.ts → lifecycle/durable.ts} +101 -27
  25. package/src/{runtime.ts → lifecycle/runtime.ts} +12 -12
  26. package/src/{thread-lifecycle.ts → lifecycle/thread-lifecycle.ts} +25 -519
  27. package/src/lifecycle/thread-restore.ts +253 -0
  28. package/src/lifecycle/thread-shared.ts +269 -0
  29. package/src/{tools.ts → lifecycle/tools.ts} +51 -13
  30. package/src/{announcements.ts → presentation/announcements.ts} +4 -4
  31. package/src/{format.ts → presentation/format.ts} +3 -3
  32. package/src/{monitor.ts → presentation/monitor.ts} +2 -2
  33. package/src/{widget.ts → presentation/widget.ts} +1 -1
  34. package/agents/sentinel.md +0 -16
  35. package/src/setup.ts +0 -344
  36. package/src/ui.ts +0 -160
  37. /package/src/{models.ts → configuration/models.ts} +0 -0
  38. /package/src/{status.ts → presentation/status.ts} +0 -0
@@ -1,16 +0,0 @@
1
- ---
2
- name: sentinel
3
- description: Adversarial post-cleanup review; returns only evidence-backed defects and test gaps.
4
- tools: read, grep, find, ls, anchor_grep, web_search, fetch_content, resolve-library-id, query-docs, bash
5
- isolation: shared
6
- ---
7
-
8
- You own one adversarial final-review phase after cleanup. The brief is your only conversation context.
9
-
10
- ## Rules
11
-
12
- - Inspect the complete diff, untracked files, affected callers, and claimed checks. Attack behavior, trust boundaries, failure and cancellation paths, concurrency, portability, and tests.
13
- - Use only matching ferris skills when available. Missing skills are not a blocker; this prompt is the fallback contract. Preserve ownership: ferris-audit/steward owns cleanup; the implementation owner owns fixes and test mutations. Do not duplicate either.
14
- - Work read-only. Run only the smallest targeted check needed to prove a suspected defect. Never edit, stage, commit, push, publish, tag, or release.
15
- - Report only actionable findings, highest severity first: `SEVERITY path:line — failure scenario; evidence; smallest fix`.
16
- - No nits, praise, vague risks, or inspection narrative. If none, output `No findings.` Add only concrete missing verification that could hide a regression. Stay under 30 lines.
package/src/setup.ts DELETED
@@ -1,344 +0,0 @@
1
- /** Single-overlay editor for the UI-configurable pi-subagents settings. */
2
-
3
- import type { Api, Model } from "@earendil-works/pi-ai";
4
- import type { ExtensionCommandContext, Theme } from "@earendil-works/pi-coding-agent";
5
- import {
6
- truncateToWidth,
7
- type Component,
8
- type Focusable,
9
- type KeybindingsManager,
10
- type TUI,
11
- } from "@earendil-works/pi-tui";
12
- import {
13
- BUILTIN_AGENT_NAMES,
14
- agentProfile,
15
- errorMessage,
16
- getConfigPath,
17
- loadConfig,
18
- roleThinkingLevel,
19
- saveConfig,
20
- type SubagentsConfig,
21
- type ThinkingLevel,
22
- } from "./config.ts";
23
- import {
24
- CURRENT_MAIN_MODEL,
25
- applyAgentModelChoice,
26
- availableModelsInScope,
27
- buildModelPickerItems,
28
- findModelByRef,
29
- resolveThinkingLevel,
30
- supportedThinkingLevels,
31
- } from "./models.ts";
32
- import { makePickerStyles, Picker } from "./ui.ts";
33
-
34
- const FIELD_COUNT = 3;
35
- const WIDE_LAYOUT_MIN_WIDTH = 112;
36
-
37
- type SetupResult = SubagentsConfig | undefined;
38
-
39
- export interface SetupOverlayOptions {
40
- config: SubagentsConfig;
41
- models: readonly Model<Api>[];
42
- mainModel: Model<Api> | undefined;
43
- theme: Theme;
44
- tui: TUI;
45
- keybindings: KeybindingsManager;
46
- onDone: (result: SetupResult) => void;
47
- }
48
-
49
- function cloneConfig(config: SubagentsConfig): SubagentsConfig {
50
- return {
51
- ...config,
52
- enabledAgents: [...config.enabledAgents],
53
- knownAgents: [...config.knownAgents],
54
- agentModels: { ...config.agentModels },
55
- agentThinkingLevels: { ...config.agentThinkingLevels },
56
- };
57
- }
58
-
59
- function setupAgentNames(config: SubagentsConfig): string[] {
60
- return [
61
- ...new Set([
62
- ...BUILTIN_AGENT_NAMES,
63
- ...config.knownAgents,
64
- ...config.enabledAgents,
65
- ...Object.keys(config.agentModels),
66
- ...Object.keys(config.agentThinkingLevels),
67
- ]),
68
- ];
69
- }
70
-
71
- /**
72
- * Transactional settings component. It mutates only its private draft and
73
- * returns that draft exclusively from the explicit Save & Exit row.
74
- */
75
- export class SetupOverlay implements Component, Focusable {
76
- private _focused = false;
77
- private readonly state: SubagentsConfig;
78
- private readonly agents: string[];
79
- private row = 0;
80
- private field = 0;
81
- private modelPicker: Picker | undefined;
82
-
83
- constructor(private readonly options: SetupOverlayOptions) {
84
- this.state = cloneConfig(options.config);
85
- this.agents = setupAgentNames(this.state);
86
- this.state.knownAgents = [...new Set([...this.state.knownAgents, ...this.agents])];
87
- }
88
-
89
- get focused(): boolean {
90
- return this._focused;
91
- }
92
-
93
- set focused(value: boolean) {
94
- this._focused = value;
95
- if (this.modelPicker) this.modelPicker.focused = value;
96
- }
97
-
98
- render(width: number): string[] {
99
- if (this.modelPicker) return this.modelPicker.render(width);
100
-
101
- const { theme } = this.options;
102
- const safeWidth = Math.max(0, width);
103
- const fit = (line: string): string => truncateToWidth(line, safeWidth, "", true);
104
- const border = theme.fg("border", "─".repeat(Math.max(1, safeWidth)));
105
- const lines = [
106
- border,
107
- theme.fg("accent", theme.bold("pi-subagents setup")),
108
- theme.fg("dim", "↑/↓ agent or action • ←/→ field • Enter/Space edit • Esc cancels without saving"),
109
- border,
110
- ];
111
-
112
- const maxVisibleAgents = safeWidth >= WIDE_LAYOUT_MIN_WIDTH ? 8 : 3;
113
- const activeAgentRow = Math.min(this.row, this.agents.length - 1);
114
- const start = Math.max(
115
- 0,
116
- Math.min(activeAgentRow - Math.floor(maxVisibleAgents / 2), this.agents.length - maxVisibleAgents),
117
- );
118
- const end = Math.min(start + maxVisibleAgents, this.agents.length);
119
- for (let index = start; index < end; index++) {
120
- const name = this.agents[index]!;
121
- const active = index === this.row;
122
- const cursor = active ? theme.fg("accent", "❯ ") : " ";
123
- const enabled = this.state.enabledAgents.includes(name);
124
- const enabledText = this.cell(enabled ? "[x]" : "[ ]", active && this.field === 0);
125
- const profile = agentProfile(name);
126
- const nameText = profile ? `${name} — ${profile.summary}` : `${name} (custom)`;
127
- const modelText = this.cell(`model: ${this.modelDisplay(name)}`, active && this.field === 1);
128
- const thinkingText = this.cell(`thinking: ${this.thinkingDisplay(name)}`, active && this.field === 2);
129
-
130
- if (safeWidth >= WIDE_LAYOUT_MIN_WIDTH) {
131
- lines.push(`${cursor}${enabledText} ${nameText} │ ${modelText} │ ${thinkingText}`);
132
- } else {
133
- lines.push(`${cursor}${enabledText} ${nameText}`);
134
- lines.push(` ${modelText}`);
135
- lines.push(` ${thinkingText}`);
136
- }
137
- }
138
- const range = start > 0 || end < this.agents.length
139
- ? `agents ${start + 1}-${end} of ${this.agents.length}`
140
- : undefined;
141
- const selectedName = this.agents[this.row];
142
- const selectedProfile = selectedName ? agentProfile(selectedName) : undefined;
143
- const context = [range, selectedProfile?.remark].filter(Boolean).join(" · ");
144
- if (context) lines.push(theme.fg("dim", ` ${context}`));
145
- lines.push(border);
146
- lines.push(this.actionLine(this.agents.length, "Save & Exit", "persist all changes"));
147
- lines.push(this.actionLine(this.agents.length + 1, "Cancel", "discard this session"));
148
- lines.push(border);
149
- return lines.map(fit);
150
- }
151
-
152
- handleInput(data: string): void {
153
- if (this.modelPicker) {
154
- this.modelPicker.handleInput(data);
155
- return;
156
- }
157
-
158
- const { keybindings } = this.options;
159
- const rowCount = this.agents.length + 2;
160
- if (keybindings.matches(data, "tui.select.cancel")) {
161
- this.options.onDone(undefined);
162
- return;
163
- }
164
- if (keybindings.matches(data, "tui.select.up")) {
165
- this.row = (this.row - 1 + rowCount) % rowCount;
166
- } else if (keybindings.matches(data, "tui.select.down")) {
167
- this.row = (this.row + 1) % rowCount;
168
- } else if (this.row < this.agents.length && keybindings.matches(data, "tui.editor.cursorLeft")) {
169
- this.field = (this.field - 1 + FIELD_COUNT) % FIELD_COUNT;
170
- } else if (this.row < this.agents.length && keybindings.matches(data, "tui.editor.cursorRight")) {
171
- this.field = (this.field + 1) % FIELD_COUNT;
172
- } else if (keybindings.matches(data, "tui.select.confirm") || data === " ") {
173
- this.activateCurrent();
174
- }
175
- this.options.tui.requestRender();
176
- }
177
-
178
- invalidate(): void {
179
- this.modelPicker?.invalidate();
180
- }
181
-
182
- private cell(text: string, selected: boolean): string {
183
- if (!selected) return text;
184
- return this.options.theme.bg("selectedBg", this.options.theme.fg("accent", text));
185
- }
186
-
187
- private actionLine(row: number, label: string, description: string): string {
188
- const active = this.row === row;
189
- const cursor = active ? this.options.theme.fg("accent", "❯ ") : " ";
190
- const text = active ? this.cell(label, true) : label;
191
- return `${cursor}${text} ${this.options.theme.fg("dim", `— ${description}`)}`;
192
- }
193
-
194
- private activateCurrent(): void {
195
- if (this.row === this.agents.length) {
196
- this.options.onDone(cloneConfig(this.state));
197
- return;
198
- }
199
- if (this.row === this.agents.length + 1) {
200
- this.options.onDone(undefined);
201
- return;
202
- }
203
-
204
- const name = this.agents[this.row];
205
- if (!name) return;
206
- if (this.field === 0) this.toggleEnabled(name);
207
- else if (this.field === 1) this.openModelPicker(name);
208
- else this.cycleThinking(name);
209
- }
210
-
211
- private toggleEnabled(name: string): void {
212
- if (this.state.enabledAgents.includes(name)) {
213
- this.state.enabledAgents = this.state.enabledAgents.filter((candidate) => candidate !== name);
214
- } else {
215
- this.state.enabledAgents = [...this.state.enabledAgents, name];
216
- }
217
- }
218
-
219
- private openModelPicker(name: string): void {
220
- const explicitRef = this.state.agentModels[name];
221
- const items = buildModelPickerItems({
222
- models: this.options.models,
223
- configuredRef: explicitRef,
224
- mainRef: this.options.mainModel ? `${this.options.mainModel.provider}/${this.options.mainModel.id}` : undefined,
225
- });
226
- if (name === "sentinel") {
227
- const inherited = this.state.agentModels.artisan ?? "current main model";
228
- items[0] = {
229
- value: CURRENT_MAIN_MODEL,
230
- label: "Follow artisan (role default)",
231
- description: `Clear the sentinel override; currently follows ${inherited}`,
232
- };
233
- }
234
-
235
- const styles = makePickerStyles(this.options.theme);
236
- this.modelPicker = new Picker(
237
- items,
238
- styles,
239
- [
240
- styles.title(`Model for ${name}`),
241
- styles.hint("Type to filter • ↑/↓ move • Enter select • Esc return to table"),
242
- ],
243
- this.options.tui,
244
- this.options.keybindings,
245
- {
246
- onSelect: (choice) => {
247
- this.state.agentModels = applyAgentModelChoice(this.state.agentModels, name, choice);
248
- this.clampThinkingOverride(name);
249
- if (name === "artisan" && this.state.agentModels.sentinel === undefined) {
250
- this.clampThinkingOverride("sentinel");
251
- }
252
- this.closeModelPicker();
253
- },
254
- onCancel: () => this.closeModelPicker(),
255
- },
256
- explicitRef ?? CURRENT_MAIN_MODEL,
257
- );
258
- this.modelPicker.focused = this.focused;
259
- }
260
-
261
- private closeModelPicker(): void {
262
- this.modelPicker = undefined;
263
- this.options.tui.requestRender();
264
- }
265
-
266
- private effectiveModel(name: string): Model<Api> | undefined {
267
- const explicitRef = this.state.agentModels[name];
268
- const inheritedRef = name === "sentinel" && explicitRef === undefined ? this.state.agentModels.artisan : undefined;
269
- return findModelByRef(this.options.models, explicitRef ?? inheritedRef) ?? this.options.mainModel;
270
- }
271
-
272
- private clampThinkingOverride(name: string): void {
273
- const current = this.state.agentThinkingLevels[name];
274
- if (current === undefined) return;
275
- this.state.agentThinkingLevels[name] = resolveThinkingLevel(this.effectiveModel(name), current);
276
- }
277
-
278
- private cycleThinking(name: string): void {
279
- const model = this.effectiveModel(name);
280
- const roleDefault = resolveThinkingLevel(model, roleThinkingLevel(name));
281
- const overrides = supportedThinkingLevels(model).filter((level) => level !== roleDefault);
282
- const current = this.state.agentThinkingLevels[name];
283
- let next: ThinkingLevel | undefined;
284
- if (current === undefined) {
285
- next = overrides[0];
286
- } else {
287
- const index = overrides.indexOf(current);
288
- next = index < 0 || index === overrides.length - 1 ? undefined : overrides[index + 1];
289
- }
290
- if (next === undefined) delete this.state.agentThinkingLevels[name];
291
- else this.state.agentThinkingLevels[name] = next;
292
- }
293
-
294
- private modelDisplay(name: string): string {
295
- const explicitRef = this.state.agentModels[name];
296
- if (explicitRef) return explicitRef;
297
- if (name === "sentinel") return `follow artisan → ${this.state.agentModels.artisan ?? "current main"}`;
298
- return "current main (dynamic)";
299
- }
300
-
301
- private thinkingDisplay(name: string): string {
302
- const override = this.state.agentThinkingLevels[name];
303
- if (override !== undefined) return `${resolveThinkingLevel(this.effectiveModel(name), override)} (override)`;
304
- const roleDefault = resolveThinkingLevel(this.effectiveModel(name), roleThinkingLevel(name));
305
- return `${roleDefault} (role default)`;
306
- }
307
- }
308
-
309
- /** Entry point for the /subagents-setup command. */
310
- export async function runSetup(ctx: ExtensionCommandContext, configPath: string = getConfigPath()): Promise<void> {
311
- if (ctx.mode !== "tui") {
312
- ctx.ui.notify("/subagents-setup requires Pi's interactive TUI.", "error");
313
- return;
314
- }
315
-
316
- try {
317
- const config = await loadConfig(configPath, { persistNormalization: false });
318
- const models = availableModelsInScope(ctx);
319
- const result = await ctx.ui.custom<SetupResult>(
320
- (tui, theme, keybindings, done) =>
321
- new SetupOverlay({
322
- config,
323
- models,
324
- mainModel: ctx.model,
325
- theme,
326
- tui,
327
- keybindings,
328
- onDone: done,
329
- }),
330
- {
331
- overlay: true,
332
- overlayOptions: { anchor: "center", width: "90%", minWidth: 36, maxHeight: "90%", margin: 1 },
333
- },
334
- );
335
- if (result === undefined) {
336
- ctx.ui.notify("pi-subagents setup cancelled; no changes saved.", "info");
337
- return;
338
- }
339
- await saveConfig(result, configPath);
340
- ctx.ui.notify(`pi-subagents updated. Saved to ${configPath}`, "info");
341
- } catch (error) {
342
- ctx.ui.notify(`pi-subagents setup failed: ${errorMessage(error)}`, "error");
343
- }
344
- }
package/src/ui.ts DELETED
@@ -1,160 +0,0 @@
1
- /** Searchable, width-safe model picker used inside /subagents-setup. */
2
-
3
- import {
4
- fuzzyFilter,
5
- truncateToWidth,
6
- type Component,
7
- type Focusable,
8
- type KeybindingsManager,
9
- type SelectItem,
10
- type TUI,
11
- } from "@earendil-works/pi-tui";
12
-
13
- /** Rows shown at once; longer lists are reached with PageUp/PageDown. */
14
- const PAGE_SIZE = 8;
15
-
16
- interface PickerStyles {
17
- border: (text: string) => string;
18
- title: (text: string) => string;
19
- hint: (text: string) => string;
20
- cursorMark: (text: string) => string;
21
- selectedLabel: (text: string) => string;
22
- label: (text: string) => string;
23
- dim: (text: string) => string;
24
- filterEcho: (text: string) => string;
25
- }
26
-
27
- type PickerItem = SelectItem;
28
-
29
- function pickerItemSearchText(item: PickerItem): string {
30
- return `${item.value} ${item.label} ${item.description ?? ""}`;
31
- }
32
-
33
- interface PickerCallbacks {
34
- onSelect: (value: string) => void;
35
- onCancel: () => void;
36
- }
37
-
38
- export class Picker implements Component, Focusable {
39
- private _focused = false;
40
- private query = "";
41
- private cursor = 0;
42
- private filtered: PickerItem[];
43
-
44
- constructor(
45
- private readonly items: PickerItem[],
46
- private readonly styles: PickerStyles,
47
- private readonly headerLines: string[],
48
- private readonly tui: TUI,
49
- private readonly keybindings: KeybindingsManager,
50
- private readonly callbacks: PickerCallbacks,
51
- initialValue?: string,
52
- ) {
53
- this.filtered = items;
54
- const initialIndex = initialValue === undefined
55
- ? -1
56
- : items.findIndex((item) => item.value === initialValue);
57
- if (initialIndex >= 0) this.cursor = initialIndex;
58
- }
59
-
60
- get focused(): boolean {
61
- return this._focused;
62
- }
63
-
64
- set focused(value: boolean) {
65
- this._focused = value;
66
- }
67
-
68
- private recompute(): void {
69
- const query = this.query.trim();
70
- this.filtered = query ? fuzzyFilter(this.items, query, pickerItemSearchText) : this.items;
71
- this.cursor = Math.max(0, Math.min(this.cursor, this.filtered.length - 1));
72
- }
73
-
74
- render(width: number): string[] {
75
- const fit = (line: string): string => truncateToWidth(line, width, "");
76
- const border = fit(this.styles.border("─".repeat(Math.max(1, width))));
77
- const lines = [border, ...this.headerLines.map(fit)];
78
- lines.push(fit(this.query
79
- ? this.styles.filterEcho(`filter: ${this.query}`)
80
- : this.styles.dim("filter: (type to narrow)")));
81
- lines.push(border);
82
-
83
- if (this.filtered.length === 0) {
84
- lines.push(fit(this.styles.dim(" (no matches)")));
85
- } else {
86
- const start = Math.max(
87
- 0,
88
- Math.min(this.cursor - Math.floor(PAGE_SIZE / 2), this.filtered.length - PAGE_SIZE),
89
- );
90
- for (const [index, item] of this.filtered.slice(start, start + PAGE_SIZE).entries()) {
91
- const isCursor = start + index === this.cursor;
92
- const mark = isCursor ? this.styles.cursorMark("❯ ") : " ";
93
- const label = isCursor ? this.styles.selectedLabel(item.label) : this.styles.label(item.label);
94
- const description = item.description ? this.styles.dim(` — ${item.description}`) : "";
95
- lines.push(fit(mark + label + description));
96
- }
97
- const paging = this.filtered.length > PAGE_SIZE ? " ↑/↓ move • PgUp/PgDn page" : "";
98
- lines.push(fit(this.styles.dim(` (${this.cursor + 1}/${this.filtered.length})${paging}`)));
99
- }
100
-
101
- lines.push(border);
102
- return lines;
103
- }
104
-
105
- handleInput(data: string): void {
106
- const keybindings = this.keybindings;
107
- if (keybindings.matches(data, "tui.select.up")) {
108
- if (this.filtered.length > 0) {
109
- this.cursor = this.cursor === 0 ? this.filtered.length - 1 : this.cursor - 1;
110
- }
111
- } else if (keybindings.matches(data, "tui.select.down")) {
112
- if (this.filtered.length > 0) {
113
- this.cursor = this.cursor === this.filtered.length - 1 ? 0 : this.cursor + 1;
114
- }
115
- } else if (keybindings.matches(data, "tui.select.pageUp")) {
116
- this.cursor = Math.max(0, this.cursor - PAGE_SIZE);
117
- } else if (keybindings.matches(data, "tui.select.pageDown")) {
118
- this.cursor = Math.min(Math.max(0, this.filtered.length - 1), this.cursor + PAGE_SIZE);
119
- } else if (keybindings.matches(data, "tui.select.confirm")) {
120
- const item = this.filtered[this.cursor];
121
- if (item) this.callbacks.onSelect(item.value);
122
- return;
123
- } else if (keybindings.matches(data, "tui.select.cancel")) {
124
- this.callbacks.onCancel();
125
- return;
126
- } else if (data === "\x7f" || data === "\b") {
127
- this.query = this.query.slice(0, -1);
128
- this.cursor = 0;
129
- this.recompute();
130
- } else if (isPrintable(data)) {
131
- this.query += data;
132
- this.cursor = 0;
133
- this.recompute();
134
- }
135
- this.tui.requestRender();
136
- }
137
-
138
- invalidate(): void {}
139
- }
140
-
141
- function isPrintable(data: string): boolean {
142
- return data.length > 0 && data.charCodeAt(0) >= 0x20;
143
- }
144
-
145
- /** Build picker styles from the pi theme. */
146
- export function makePickerStyles(theme: {
147
- fg: (color: any, text: string) => string;
148
- bold: (text: string) => string;
149
- }): PickerStyles {
150
- return {
151
- border: (text) => theme.fg("accent", text),
152
- title: (text) => theme.fg("accent", theme.bold(text)),
153
- hint: (text) => theme.fg("dim", text),
154
- cursorMark: (text) => theme.fg("accent", text),
155
- selectedLabel: (text) => theme.fg("accent", theme.bold(text)),
156
- label: (text) => text,
157
- dim: (text) => theme.fg("dim", text),
158
- filterEcho: (text) => theme.fg("accent", text),
159
- };
160
- }
File without changes
File without changes