@raidou/pi-pm-subagents 0.1.1
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/.prettierrc +7 -0
- package/AGENTS.md +1 -0
- package/README.md +85 -0
- package/README.zh-CN.md +85 -0
- package/agents/explorer.md +10 -0
- package/agents/planner.md +16 -0
- package/agents/researcher.md +22 -0
- package/agents/reviewer.md +10 -0
- package/eslint.config.mjs +14 -0
- package/example-prompts/coordinator.md +21 -0
- package/package.json +48 -0
- package/pm-subagents-prompts/coordinator.md +16 -0
- package/pnpm-workspace.yaml +5 -0
- package/src/bash-readonly.test.ts +331 -0
- package/src/bash-readonly.ts +205 -0
- package/src/coordinator/coordinator.test.ts +28 -0
- package/src/coordinator/coordinator.ts +275 -0
- package/src/custom-select.test.ts +91 -0
- package/src/custom-select.ts +209 -0
- package/src/index.ts +87 -0
- package/src/models-config/models-config.test.ts +88 -0
- package/src/models-config/models-config.ts +205 -0
- package/src/models-config/scoped-models-editor.test.ts +189 -0
- package/src/models-config/scoped-models-editor.ts +412 -0
- package/src/models-config/subagent-model-constants.ts +2 -0
- package/src/models-config/subagent-model-cycle.ts +53 -0
- package/src/models-config/subagent-model-utils.test.ts +250 -0
- package/src/models-config/subagent-model-utils.ts +52 -0
- package/src/pm-mode.test.ts +324 -0
- package/src/pm-mode.ts +142 -0
- package/src/prompts/mode.test.ts +289 -0
- package/src/prompts/mode.ts +31 -0
- package/src/prompts/roles.test.ts +724 -0
- package/src/prompts/roles.ts +119 -0
- package/src/subagent/activity.test.ts +230 -0
- package/src/subagent/activity.ts +60 -0
- package/src/subagent/batcher.test.ts +198 -0
- package/src/subagent/batcher.ts +51 -0
- package/src/subagent/consts.ts +1 -0
- package/src/subagent/demo.ts +773 -0
- package/src/subagent/fleet.test.ts +1758 -0
- package/src/subagent/fleet.ts +376 -0
- package/src/subagent/identity.test.ts +31 -0
- package/src/subagent/identity.ts +16 -0
- package/src/subagent/manager.test.ts +392 -0
- package/src/subagent/manager.ts +277 -0
- package/src/subagent/tools.ts +314 -0
- package/src/subagent/viewer.ts +305 -0
- package/src/types.ts +15 -0
- package/src/ui/border-view.ts +50 -0
- package/src/ui/review-pager.ts +146 -0
- package/src/ui/scroll-view.test.ts +190 -0
- package/src/ui/scroll-view.ts +155 -0
- package/src/utils/format.test.ts +76 -0
- package/src/utils/format.ts +67 -0
- package/src/utils/fs.ts +9 -0
- package/src/utils/markdown.test.ts +442 -0
- package/src/utils/markdown.ts +79 -0
- package/src/utils/messages.test.ts +436 -0
- package/src/utils/messages.ts +131 -0
- package/src/utils/model-ref.test.ts +42 -0
- package/src/utils/model-ref.ts +44 -0
- package/src/utils/state.test.ts +98 -0
- package/src/utils/state.ts +45 -0
- package/src/utils/tools.ts +48 -0
- package/src/utils/truncate.test.ts +41 -0
- package/src/utils/truncate.ts +59 -0
- package/tsconfig.json +24 -0
- package/vitest.config.ts +8 -0
|
@@ -0,0 +1,209 @@
|
|
|
1
|
+
import type { ExtensionContext, Theme } from '@earendil-works/pi-coding-agent'
|
|
2
|
+
import { DynamicBorder } from '@earendil-works/pi-coding-agent'
|
|
3
|
+
import type { Focusable, TUI } from '@earendil-works/pi-tui'
|
|
4
|
+
import {
|
|
5
|
+
Container,
|
|
6
|
+
fuzzyFilter,
|
|
7
|
+
Input,
|
|
8
|
+
Key,
|
|
9
|
+
matchesKey,
|
|
10
|
+
Spacer,
|
|
11
|
+
Text,
|
|
12
|
+
} from '@earendil-works/pi-tui'
|
|
13
|
+
|
|
14
|
+
export interface CustomSelectItem {
|
|
15
|
+
readonly key: string
|
|
16
|
+
readonly text: string
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export interface CustomSelectOptions {
|
|
20
|
+
readonly items: readonly CustomSelectItem[]
|
|
21
|
+
readonly title?: string
|
|
22
|
+
readonly placeholder?: string
|
|
23
|
+
readonly maxVisible?: number
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** Open a searchable, height-limited selector over `{ key, text }` items. */
|
|
27
|
+
export async function customSelect(
|
|
28
|
+
ctx: ExtensionContext,
|
|
29
|
+
options: CustomSelectOptions,
|
|
30
|
+
): Promise<string | undefined> {
|
|
31
|
+
return ctx.ui.custom<string | undefined>((tui, theme, _keybindings, done) => {
|
|
32
|
+
return new CustomSelectComponent(
|
|
33
|
+
tui,
|
|
34
|
+
theme,
|
|
35
|
+
options,
|
|
36
|
+
(key) => {
|
|
37
|
+
done(key)
|
|
38
|
+
},
|
|
39
|
+
() => {
|
|
40
|
+
done(undefined)
|
|
41
|
+
},
|
|
42
|
+
)
|
|
43
|
+
})
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Searchable, height-limited selector over a list of `{ key, text }` items.
|
|
48
|
+
* Returns the picked item's `key` (or undefined on cancel).
|
|
49
|
+
*/
|
|
50
|
+
export class CustomSelectComponent extends Container implements Focusable {
|
|
51
|
+
readonly #tui: TUI
|
|
52
|
+
readonly #theme: Theme
|
|
53
|
+
readonly #items: readonly CustomSelectItem[]
|
|
54
|
+
readonly #placeholder: string
|
|
55
|
+
readonly #maxVisible: number
|
|
56
|
+
readonly #searchInput: Input
|
|
57
|
+
readonly #listContainer: Container
|
|
58
|
+
readonly #onSelect: (key: string) => void
|
|
59
|
+
readonly #onCancel: () => void
|
|
60
|
+
#focused = false
|
|
61
|
+
#filtered: CustomSelectItem[]
|
|
62
|
+
#selectedIndex = 0
|
|
63
|
+
|
|
64
|
+
constructor(
|
|
65
|
+
tui: TUI,
|
|
66
|
+
theme: Theme,
|
|
67
|
+
options: CustomSelectOptions,
|
|
68
|
+
onSelect: (key: string) => void,
|
|
69
|
+
onCancel: () => void,
|
|
70
|
+
) {
|
|
71
|
+
super()
|
|
72
|
+
this.#tui = tui
|
|
73
|
+
this.#theme = theme
|
|
74
|
+
this.#items = options.items
|
|
75
|
+
this.#placeholder = options.placeholder ?? 'filter'
|
|
76
|
+
this.#maxVisible = options.maxVisible ?? 10
|
|
77
|
+
this.#onSelect = onSelect
|
|
78
|
+
this.#onCancel = onCancel
|
|
79
|
+
this.#filtered = [...this.#items]
|
|
80
|
+
|
|
81
|
+
const borderColor = (s: string) => theme.fg('borderMuted', s)
|
|
82
|
+
|
|
83
|
+
this.addChild(new DynamicBorder(borderColor))
|
|
84
|
+
this.addChild(new Spacer(1))
|
|
85
|
+
this.addChild(
|
|
86
|
+
new Text(theme.fg('accent', theme.bold(options.title ?? 'Select')), 1, 0),
|
|
87
|
+
)
|
|
88
|
+
this.addChild(new Spacer(1))
|
|
89
|
+
this.addChild(new Text(theme.fg('muted', this.#placeholder), 1, 0))
|
|
90
|
+
|
|
91
|
+
this.#searchInput = new Input()
|
|
92
|
+
this.#searchInput.onSubmit = () => {
|
|
93
|
+
this.#confirm()
|
|
94
|
+
}
|
|
95
|
+
this.addChild(this.#searchInput)
|
|
96
|
+
this.addChild(new Spacer(1))
|
|
97
|
+
|
|
98
|
+
this.#listContainer = new Container()
|
|
99
|
+
this.addChild(this.#listContainer)
|
|
100
|
+
this.addChild(new Spacer(1))
|
|
101
|
+
this.addChild(new DynamicBorder(borderColor))
|
|
102
|
+
|
|
103
|
+
this.#renderList()
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
get focused(): boolean {
|
|
107
|
+
return this.#focused
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
set focused(value: boolean) {
|
|
111
|
+
this.#focused = value
|
|
112
|
+
this.#searchInput.focused = value
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
handleInput(keyData: string): void {
|
|
116
|
+
if (matchesKey(keyData, Key.up) || matchesKey(keyData, Key.ctrl('p'))) {
|
|
117
|
+
this.#move(-1)
|
|
118
|
+
} else if (
|
|
119
|
+
matchesKey(keyData, Key.down) ||
|
|
120
|
+
matchesKey(keyData, Key.ctrl('n'))
|
|
121
|
+
) {
|
|
122
|
+
this.#move(1)
|
|
123
|
+
} else if (
|
|
124
|
+
matchesKey(keyData, Key.pageUp) ||
|
|
125
|
+
matchesKey(keyData, Key.ctrl('u'))
|
|
126
|
+
) {
|
|
127
|
+
this.#move(-this.#maxVisible)
|
|
128
|
+
} else if (
|
|
129
|
+
matchesKey(keyData, Key.pageDown) ||
|
|
130
|
+
matchesKey(keyData, Key.ctrl('d'))
|
|
131
|
+
) {
|
|
132
|
+
this.#move(this.#maxVisible)
|
|
133
|
+
} else if (matchesKey(keyData, Key.enter)) {
|
|
134
|
+
this.#confirm()
|
|
135
|
+
} else if (
|
|
136
|
+
matchesKey(keyData, Key.escape) ||
|
|
137
|
+
matchesKey(keyData, Key.ctrl('c'))
|
|
138
|
+
) {
|
|
139
|
+
this.#onCancel()
|
|
140
|
+
} else {
|
|
141
|
+
this.#searchInput.handleInput(keyData)
|
|
142
|
+
this.#refilter(this.#searchInput.getValue())
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
#move(delta: number): void {
|
|
147
|
+
if (this.#filtered.length === 0) return
|
|
148
|
+
const last = this.#filtered.length - 1
|
|
149
|
+
this.#selectedIndex =
|
|
150
|
+
delta > 0
|
|
151
|
+
? Math.min(last, this.#selectedIndex + delta)
|
|
152
|
+
: Math.max(0, this.#selectedIndex + delta)
|
|
153
|
+
this.#renderList()
|
|
154
|
+
this.#tui.requestRender()
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
#confirm(): void {
|
|
158
|
+
const picked = this.#filtered[this.#selectedIndex]
|
|
159
|
+
if (picked) this.#onSelect(picked.key)
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
#refilter(query: string): void {
|
|
163
|
+
const needle = query.trim().toLowerCase()
|
|
164
|
+
const source: CustomSelectItem[] = [...this.#items]
|
|
165
|
+
this.#filtered = needle
|
|
166
|
+
? fuzzyFilter(source, needle, (item) => item.text)
|
|
167
|
+
: source
|
|
168
|
+
this.#selectedIndex = 0
|
|
169
|
+
this.#renderList()
|
|
170
|
+
this.#tui.requestRender()
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
#renderList(): void {
|
|
174
|
+
this.#listContainer.clear()
|
|
175
|
+
const total = this.#filtered.length
|
|
176
|
+
const visible = Math.min(this.#maxVisible, total)
|
|
177
|
+
const start = Math.max(
|
|
178
|
+
0,
|
|
179
|
+
Math.min(
|
|
180
|
+
this.#selectedIndex - Math.floor(this.#maxVisible / 2),
|
|
181
|
+
total - visible,
|
|
182
|
+
),
|
|
183
|
+
)
|
|
184
|
+
const end = start + visible
|
|
185
|
+
for (let i = start; i < end; i++) {
|
|
186
|
+
const item = this.#filtered[i]
|
|
187
|
+
if (!item) continue
|
|
188
|
+
const prefix = i === this.#selectedIndex ? '→ ' : ' '
|
|
189
|
+
const text =
|
|
190
|
+
i === this.#selectedIndex
|
|
191
|
+
? this.#theme.fg('accent', item.text)
|
|
192
|
+
: item.text
|
|
193
|
+
this.#listContainer.addChild(new Text(`${prefix}${text}`, 1, 0))
|
|
194
|
+
}
|
|
195
|
+
if (total > visible) {
|
|
196
|
+
this.#listContainer.addChild(
|
|
197
|
+
new Text(
|
|
198
|
+
this.#theme.fg('muted', ` (${this.#selectedIndex + 1}/${total})`),
|
|
199
|
+
1,
|
|
200
|
+
0,
|
|
201
|
+
),
|
|
202
|
+
)
|
|
203
|
+
} else if (total === 0) {
|
|
204
|
+
this.#listContainer.addChild(
|
|
205
|
+
new Text(this.#theme.fg('muted', ' No matches'), 1, 0),
|
|
206
|
+
)
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
import type { ExtensionAPI } from '@earendil-works/pi-coding-agent'
|
|
2
|
+
|
|
3
|
+
import { setupBashReadonlyTool } from './bash-readonly.js'
|
|
4
|
+
import {
|
|
5
|
+
applyCoordinatorMode,
|
|
6
|
+
enterCoordinatorMode,
|
|
7
|
+
setupCoordinator,
|
|
8
|
+
} from './coordinator/coordinator.js'
|
|
9
|
+
import {
|
|
10
|
+
getPmSubagentsConfig,
|
|
11
|
+
loadPmSubagentsConfig,
|
|
12
|
+
setupPmSubagentsConfig,
|
|
13
|
+
} from './models-config/models-config.js'
|
|
14
|
+
import { setupSubagentModelCycle } from './models-config/subagent-model-cycle.js'
|
|
15
|
+
import { readModePrompt } from './prompts/mode.js'
|
|
16
|
+
import { isSubagentSpawnContext } from './subagent/identity.js'
|
|
17
|
+
import type { PmSubagentState } from './types.js'
|
|
18
|
+
import { createState, getLastPmSubagentState } from './utils/state.js'
|
|
19
|
+
|
|
20
|
+
let pendingPmSubagentState: Partial<PmSubagentState> | undefined
|
|
21
|
+
|
|
22
|
+
export default async function pmSubagentsExtension(
|
|
23
|
+
pi: ExtensionAPI,
|
|
24
|
+
): Promise<void> {
|
|
25
|
+
setupBashReadonlyTool(pi)
|
|
26
|
+
if (isSubagentSpawnContext()) {
|
|
27
|
+
return
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
const state = createState()
|
|
31
|
+
await loadPmSubagentsConfig()
|
|
32
|
+
|
|
33
|
+
const coordinatorDefinition = await readModePrompt('coordinator')
|
|
34
|
+
|
|
35
|
+
const demoEnabled = process.env.PI_DEMO === '1'
|
|
36
|
+
|
|
37
|
+
await setupCoordinator(pi, state, {
|
|
38
|
+
demoEnabled,
|
|
39
|
+
coordinatorDefinition,
|
|
40
|
+
})
|
|
41
|
+
setupPmSubagentsConfig(pi, state, coordinatorDefinition)
|
|
42
|
+
setupSubagentModelCycle(pi, state)
|
|
43
|
+
|
|
44
|
+
pi.on('session_before_switch', (_event, ctx) => {
|
|
45
|
+
pendingPmSubagentState = getLastPmSubagentState(
|
|
46
|
+
ctx.sessionManager.getEntries(),
|
|
47
|
+
)
|
|
48
|
+
})
|
|
49
|
+
|
|
50
|
+
pi.on('session_before_fork', (_event, ctx) => {
|
|
51
|
+
pendingPmSubagentState = getLastPmSubagentState(
|
|
52
|
+
ctx.sessionManager.getEntries(),
|
|
53
|
+
)
|
|
54
|
+
})
|
|
55
|
+
|
|
56
|
+
pi.on('session_start', async (_event, ctx) => {
|
|
57
|
+
let data = getLastPmSubagentState(ctx.sessionManager.getEntries())
|
|
58
|
+
|
|
59
|
+
if (!data && pendingPmSubagentState) {
|
|
60
|
+
data = pendingPmSubagentState
|
|
61
|
+
pendingPmSubagentState = undefined
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
if (data) {
|
|
65
|
+
state.mode = data.mode
|
|
66
|
+
state.modeDiffTools = data.modeDiffTools
|
|
67
|
+
state.previousModel = data.previousModel
|
|
68
|
+
state.sessionSubagentModel = data.sessionSubagentModel
|
|
69
|
+
|
|
70
|
+
if (state.mode === 'coordinator') {
|
|
71
|
+
await applyCoordinatorMode(pi, state, ctx, coordinatorDefinition)
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
return
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
if (getPmSubagentsConfig().defaultMode === 'coordinator') {
|
|
78
|
+
await enterCoordinatorMode(
|
|
79
|
+
pi,
|
|
80
|
+
state,
|
|
81
|
+
undefined,
|
|
82
|
+
ctx,
|
|
83
|
+
coordinatorDefinition,
|
|
84
|
+
)
|
|
85
|
+
}
|
|
86
|
+
})
|
|
87
|
+
}
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest'
|
|
2
|
+
|
|
3
|
+
import type { PmSubagentsConfig } from './models-config.js'
|
|
4
|
+
import { sanitizeConfig } from './models-config.js'
|
|
5
|
+
import { MODEL_DEFAULT } from './subagent-model-constants.js'
|
|
6
|
+
|
|
7
|
+
describe('PmSubagentsConfig schema', () => {
|
|
8
|
+
it('has subagentModelScope field', () => {
|
|
9
|
+
const config: PmSubagentsConfig = {
|
|
10
|
+
subagentModelScoped: [MODEL_DEFAULT, 'provider/model'],
|
|
11
|
+
}
|
|
12
|
+
expect(config.subagentModelScoped).toBeDefined()
|
|
13
|
+
expect(config.subagentModelScoped?.length).toBe(2)
|
|
14
|
+
})
|
|
15
|
+
|
|
16
|
+
it('has subagentModel field', () => {
|
|
17
|
+
const config: PmSubagentsConfig = {
|
|
18
|
+
subagentModel: 'anthropic/claude-sonnet-4-5',
|
|
19
|
+
}
|
|
20
|
+
expect(config.subagentModel).toBe('anthropic/claude-sonnet-4-5')
|
|
21
|
+
})
|
|
22
|
+
})
|
|
23
|
+
|
|
24
|
+
describe('sanitizeConfig', () => {
|
|
25
|
+
it('keeps defaultMode when it is coordinator', () => {
|
|
26
|
+
expect(sanitizeConfig({ defaultMode: 'coordinator' })).toEqual({
|
|
27
|
+
defaultMode: 'coordinator',
|
|
28
|
+
})
|
|
29
|
+
})
|
|
30
|
+
|
|
31
|
+
it('clears invalid defaultMode', () => {
|
|
32
|
+
expect(sanitizeConfig({ defaultMode: 'bogus' })).toEqual({})
|
|
33
|
+
})
|
|
34
|
+
|
|
35
|
+
it('keeps valid subagentModel and scope', () => {
|
|
36
|
+
const config = sanitizeConfig({
|
|
37
|
+
subagentModel: 'provider/model',
|
|
38
|
+
subagentModelScoped: ['provider/model', MODEL_DEFAULT],
|
|
39
|
+
})
|
|
40
|
+
expect(config.subagentModel).toBe('provider/model')
|
|
41
|
+
expect(config.subagentModelScoped).toEqual([
|
|
42
|
+
'provider/model',
|
|
43
|
+
MODEL_DEFAULT,
|
|
44
|
+
])
|
|
45
|
+
})
|
|
46
|
+
|
|
47
|
+
it('clears invalid subagentModel and empty scope', () => {
|
|
48
|
+
const config = sanitizeConfig({
|
|
49
|
+
subagentModel: 'no-slash',
|
|
50
|
+
subagentModelScoped: ['also-bad'],
|
|
51
|
+
})
|
|
52
|
+
expect(config.subagentModel).toBeUndefined()
|
|
53
|
+
expect(config.subagentModelScoped).toBeUndefined()
|
|
54
|
+
})
|
|
55
|
+
|
|
56
|
+
it('keeps valid skipPluginAgents', () => {
|
|
57
|
+
expect(sanitizeConfig({ skipPluginAgents: true })).toEqual({
|
|
58
|
+
skipPluginAgents: true,
|
|
59
|
+
})
|
|
60
|
+
})
|
|
61
|
+
|
|
62
|
+
it('clears invalid skipPluginAgents', () => {
|
|
63
|
+
expect(
|
|
64
|
+
sanitizeConfig({ skipPluginAgents: 'yes' as unknown as boolean }),
|
|
65
|
+
).toEqual({})
|
|
66
|
+
})
|
|
67
|
+
|
|
68
|
+
it('clears invalid defaultMode without affecting a valid subagentModel', () => {
|
|
69
|
+
expect(
|
|
70
|
+
sanitizeConfig({
|
|
71
|
+
subagentModel: 'provider/model',
|
|
72
|
+
defaultMode: 'bogus',
|
|
73
|
+
}),
|
|
74
|
+
).toEqual({ subagentModel: 'provider/model' })
|
|
75
|
+
})
|
|
76
|
+
|
|
77
|
+
it('does not mutate the input record', () => {
|
|
78
|
+
const record: PmSubagentsConfig = {
|
|
79
|
+
subagentModel: 'no-slash',
|
|
80
|
+
defaultMode: 'bogus',
|
|
81
|
+
}
|
|
82
|
+
sanitizeConfig(record)
|
|
83
|
+
expect(record).toEqual({
|
|
84
|
+
subagentModel: 'no-slash',
|
|
85
|
+
defaultMode: 'bogus',
|
|
86
|
+
})
|
|
87
|
+
})
|
|
88
|
+
})
|
|
@@ -0,0 +1,205 @@
|
|
|
1
|
+
import { mkdir, readFile, writeFile } from 'node:fs/promises'
|
|
2
|
+
import { dirname, join } from 'node:path'
|
|
3
|
+
|
|
4
|
+
import type {
|
|
5
|
+
ExtensionAPI,
|
|
6
|
+
ExtensionContext,
|
|
7
|
+
} from '@earendil-works/pi-coding-agent'
|
|
8
|
+
import { getAgentDir } from '@earendil-works/pi-coding-agent'
|
|
9
|
+
import type { Static } from 'typebox'
|
|
10
|
+
import { Type } from 'typebox'
|
|
11
|
+
import { Parse } from 'typebox/value'
|
|
12
|
+
|
|
13
|
+
import {
|
|
14
|
+
enterCoordinatorMode,
|
|
15
|
+
renderCoordinatorModeWidget,
|
|
16
|
+
} from '../coordinator/coordinator.js'
|
|
17
|
+
import { customSelect } from '../custom-select.js'
|
|
18
|
+
import type { PmSubagentState } from '../types.js'
|
|
19
|
+
import type { PromptDefinition } from '../utils/markdown.js'
|
|
20
|
+
import {
|
|
21
|
+
modelOptionOf,
|
|
22
|
+
parseModelRef,
|
|
23
|
+
resolveModelRef,
|
|
24
|
+
} from '../utils/model-ref.js'
|
|
25
|
+
import { scopedModelsEditor } from './scoped-models-editor.js'
|
|
26
|
+
import {
|
|
27
|
+
MODEL_DEFAULT,
|
|
28
|
+
MODEL_DEFAULT_LABEL,
|
|
29
|
+
} from './subagent-model-constants.js'
|
|
30
|
+
|
|
31
|
+
const PmSubagentsConfigSchema = Type.Object({
|
|
32
|
+
subagentModel: Type.Optional(Type.String()),
|
|
33
|
+
subagentModelScoped: Type.Optional(Type.Array(Type.String())),
|
|
34
|
+
defaultMode: Type.Optional(Type.String()),
|
|
35
|
+
skipPluginAgents: Type.Optional(Type.Boolean()),
|
|
36
|
+
})
|
|
37
|
+
|
|
38
|
+
export function sanitizeConfig(record: PmSubagentsConfig): PmSubagentsConfig {
|
|
39
|
+
const model = record.subagentModel
|
|
40
|
+
const subagentModel = model && !parseModelRef(model) ? undefined : model
|
|
41
|
+
|
|
42
|
+
let subagentModelScoped = record.subagentModelScoped
|
|
43
|
+
if (subagentModelScoped) {
|
|
44
|
+
const filtered = subagentModelScoped.filter(
|
|
45
|
+
(ref) => ref === MODEL_DEFAULT || parseModelRef(ref) !== undefined,
|
|
46
|
+
)
|
|
47
|
+
subagentModelScoped = filtered.length > 0 ? filtered : undefined
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
const defaultMode =
|
|
51
|
+
record.defaultMode === 'coordinator' ? 'coordinator' : undefined
|
|
52
|
+
|
|
53
|
+
const skipPluginAgents =
|
|
54
|
+
typeof record.skipPluginAgents === 'boolean'
|
|
55
|
+
? record.skipPluginAgents
|
|
56
|
+
: undefined
|
|
57
|
+
|
|
58
|
+
return {
|
|
59
|
+
subagentModel,
|
|
60
|
+
subagentModelScoped,
|
|
61
|
+
defaultMode,
|
|
62
|
+
skipPluginAgents,
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export type PmSubagentsConfig = Static<typeof PmSubagentsConfigSchema>
|
|
67
|
+
|
|
68
|
+
let pmSubagentsConfig: PmSubagentsConfig = {}
|
|
69
|
+
|
|
70
|
+
function configPath(): string {
|
|
71
|
+
return join(getAgentDir(), 'pi-pm-subagents.json')
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export async function loadPmSubagentsConfig(): Promise<void> {
|
|
75
|
+
try {
|
|
76
|
+
const raw: unknown = JSON.parse(await readFile(configPath(), 'utf8'))
|
|
77
|
+
pmSubagentsConfig = sanitizeConfig(Parse(PmSubagentsConfigSchema, raw))
|
|
78
|
+
} catch {
|
|
79
|
+
pmSubagentsConfig = {}
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
export function getPmSubagentsConfig(): PmSubagentsConfig {
|
|
84
|
+
return pmSubagentsConfig
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
export async function setSubagentModelScoped(scope: string[]): Promise<void> {
|
|
88
|
+
pmSubagentsConfig.subagentModelScoped = scope
|
|
89
|
+
await savePmSubagentsConfig()
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
export async function setSubagentModel(
|
|
93
|
+
model: string | undefined,
|
|
94
|
+
): Promise<void> {
|
|
95
|
+
pmSubagentsConfig.subagentModel = model
|
|
96
|
+
await savePmSubagentsConfig()
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
export async function setDefaultMode(
|
|
100
|
+
mode: 'coordinator' | undefined,
|
|
101
|
+
): Promise<void> {
|
|
102
|
+
pmSubagentsConfig.defaultMode = mode
|
|
103
|
+
await savePmSubagentsConfig()
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
async function savePmSubagentsConfig(): Promise<void> {
|
|
107
|
+
await mkdir(dirname(configPath()), { recursive: true })
|
|
108
|
+
await writeFile(
|
|
109
|
+
configPath(),
|
|
110
|
+
`${JSON.stringify(pmSubagentsConfig, null, 2)}\n`,
|
|
111
|
+
'utf8',
|
|
112
|
+
)
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
async function pickModel(ctx: ExtensionContext): Promise<string | undefined> {
|
|
116
|
+
const items: Array<{ key: string; text: string }> = [
|
|
117
|
+
{ key: MODEL_DEFAULT, text: MODEL_DEFAULT_LABEL },
|
|
118
|
+
...ctx.modelRegistry.getAvailable().map(modelOptionOf),
|
|
119
|
+
]
|
|
120
|
+
return customSelect(ctx, {
|
|
121
|
+
items,
|
|
122
|
+
title: 'Choose model',
|
|
123
|
+
placeholder: 'filter (provider/id substring)',
|
|
124
|
+
})
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
export function setupPmSubagentsConfig(
|
|
128
|
+
pi: ExtensionAPI,
|
|
129
|
+
state: PmSubagentState,
|
|
130
|
+
coordinatorDefinition: PromptDefinition,
|
|
131
|
+
): void {
|
|
132
|
+
pi.registerCommand('pm-subagent-model', {
|
|
133
|
+
description: 'Configure the subagent model',
|
|
134
|
+
handler: async (_args, ctx) => {
|
|
135
|
+
const model = await pickModel(ctx)
|
|
136
|
+
if (!model) {
|
|
137
|
+
ctx.ui.notify('No model selected', 'warning')
|
|
138
|
+
return
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
const isDefaultModel = model === MODEL_DEFAULT
|
|
142
|
+
if (!isDefaultModel && !resolveModelRef(ctx, [model])) {
|
|
143
|
+
ctx.ui.notify(`Unknown model "${model}"`, 'warning')
|
|
144
|
+
return
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
await setSubagentModel(isDefaultModel ? undefined : model)
|
|
148
|
+
state.sessionSubagentModel = isDefaultModel ? undefined : model
|
|
149
|
+
|
|
150
|
+
const current = pmSubagentsConfig.subagentModel ?? MODEL_DEFAULT
|
|
151
|
+
ctx.ui.notify(`Subagent model: ${current}`, 'info')
|
|
152
|
+
if (state.mode === 'coordinator') renderCoordinatorModeWidget(ctx, state)
|
|
153
|
+
},
|
|
154
|
+
})
|
|
155
|
+
|
|
156
|
+
pi.registerCommand('pm-subagent-scoped', {
|
|
157
|
+
description: 'Manage subagent model scope (cycle pool)',
|
|
158
|
+
handler: async (_args, ctx) => {
|
|
159
|
+
const currentDefault = pmSubagentsConfig.subagentModel
|
|
160
|
+
const currentScope =
|
|
161
|
+
pmSubagentsConfig.subagentModelScoped ??
|
|
162
|
+
(currentDefault ? [currentDefault] : [])
|
|
163
|
+
const result = await scopedModelsEditor(ctx, {
|
|
164
|
+
items: [
|
|
165
|
+
{ key: MODEL_DEFAULT, text: MODEL_DEFAULT_LABEL, provider: '' },
|
|
166
|
+
...ctx.modelRegistry.getAvailable().map(modelOptionOf),
|
|
167
|
+
],
|
|
168
|
+
initialChecked: currentScope,
|
|
169
|
+
title: 'Subagent Model Scope',
|
|
170
|
+
})
|
|
171
|
+
if (result === undefined) {
|
|
172
|
+
ctx.ui.notify('Scope unchanged.', 'info')
|
|
173
|
+
return
|
|
174
|
+
}
|
|
175
|
+
await setSubagentModelScoped(result)
|
|
176
|
+
ctx.ui.notify(`Subagent scope saved (${result.length} items).`, 'info')
|
|
177
|
+
if (state.mode === 'coordinator') renderCoordinatorModeWidget(ctx, state)
|
|
178
|
+
},
|
|
179
|
+
})
|
|
180
|
+
|
|
181
|
+
const runPmDefaultCommand = async (_args: string, ctx: ExtensionContext) => {
|
|
182
|
+
const enabled = pmSubagentsConfig.defaultMode !== 'coordinator'
|
|
183
|
+
await setDefaultMode(enabled ? 'coordinator' : undefined)
|
|
184
|
+
ctx.ui.notify(`pm mode on startup: ${enabled ? 'on' : 'off'}`, 'info')
|
|
185
|
+
if (enabled && state.mode !== 'coordinator') {
|
|
186
|
+
await enterCoordinatorMode(
|
|
187
|
+
pi,
|
|
188
|
+
state,
|
|
189
|
+
undefined,
|
|
190
|
+
ctx,
|
|
191
|
+
coordinatorDefinition,
|
|
192
|
+
)
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
pi.registerCommand('coordinator-default', {
|
|
197
|
+
description: 'Toggle pm (coordinator) mode enabled by default on startup',
|
|
198
|
+
handler: runPmDefaultCommand,
|
|
199
|
+
})
|
|
200
|
+
|
|
201
|
+
pi.registerCommand('pm-default', {
|
|
202
|
+
description: 'Toggle pm (coordinator) mode enabled by default on startup',
|
|
203
|
+
handler: runPmDefaultCommand,
|
|
204
|
+
})
|
|
205
|
+
}
|