@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.
Files changed (69) hide show
  1. package/.prettierrc +7 -0
  2. package/AGENTS.md +1 -0
  3. package/README.md +85 -0
  4. package/README.zh-CN.md +85 -0
  5. package/agents/explorer.md +10 -0
  6. package/agents/planner.md +16 -0
  7. package/agents/researcher.md +22 -0
  8. package/agents/reviewer.md +10 -0
  9. package/eslint.config.mjs +14 -0
  10. package/example-prompts/coordinator.md +21 -0
  11. package/package.json +48 -0
  12. package/pm-subagents-prompts/coordinator.md +16 -0
  13. package/pnpm-workspace.yaml +5 -0
  14. package/src/bash-readonly.test.ts +331 -0
  15. package/src/bash-readonly.ts +205 -0
  16. package/src/coordinator/coordinator.test.ts +28 -0
  17. package/src/coordinator/coordinator.ts +275 -0
  18. package/src/custom-select.test.ts +91 -0
  19. package/src/custom-select.ts +209 -0
  20. package/src/index.ts +87 -0
  21. package/src/models-config/models-config.test.ts +88 -0
  22. package/src/models-config/models-config.ts +205 -0
  23. package/src/models-config/scoped-models-editor.test.ts +189 -0
  24. package/src/models-config/scoped-models-editor.ts +412 -0
  25. package/src/models-config/subagent-model-constants.ts +2 -0
  26. package/src/models-config/subagent-model-cycle.ts +53 -0
  27. package/src/models-config/subagent-model-utils.test.ts +250 -0
  28. package/src/models-config/subagent-model-utils.ts +52 -0
  29. package/src/pm-mode.test.ts +324 -0
  30. package/src/pm-mode.ts +142 -0
  31. package/src/prompts/mode.test.ts +289 -0
  32. package/src/prompts/mode.ts +31 -0
  33. package/src/prompts/roles.test.ts +724 -0
  34. package/src/prompts/roles.ts +119 -0
  35. package/src/subagent/activity.test.ts +230 -0
  36. package/src/subagent/activity.ts +60 -0
  37. package/src/subagent/batcher.test.ts +198 -0
  38. package/src/subagent/batcher.ts +51 -0
  39. package/src/subagent/consts.ts +1 -0
  40. package/src/subagent/demo.ts +773 -0
  41. package/src/subagent/fleet.test.ts +1758 -0
  42. package/src/subagent/fleet.ts +376 -0
  43. package/src/subagent/identity.test.ts +31 -0
  44. package/src/subagent/identity.ts +16 -0
  45. package/src/subagent/manager.test.ts +392 -0
  46. package/src/subagent/manager.ts +277 -0
  47. package/src/subagent/tools.ts +314 -0
  48. package/src/subagent/viewer.ts +305 -0
  49. package/src/types.ts +15 -0
  50. package/src/ui/border-view.ts +50 -0
  51. package/src/ui/review-pager.ts +146 -0
  52. package/src/ui/scroll-view.test.ts +190 -0
  53. package/src/ui/scroll-view.ts +155 -0
  54. package/src/utils/format.test.ts +76 -0
  55. package/src/utils/format.ts +67 -0
  56. package/src/utils/fs.ts +9 -0
  57. package/src/utils/markdown.test.ts +442 -0
  58. package/src/utils/markdown.ts +79 -0
  59. package/src/utils/messages.test.ts +436 -0
  60. package/src/utils/messages.ts +131 -0
  61. package/src/utils/model-ref.test.ts +42 -0
  62. package/src/utils/model-ref.ts +44 -0
  63. package/src/utils/state.test.ts +98 -0
  64. package/src/utils/state.ts +45 -0
  65. package/src/utils/tools.ts +48 -0
  66. package/src/utils/truncate.test.ts +41 -0
  67. package/src/utils/truncate.ts +59 -0
  68. package/tsconfig.json +24 -0
  69. package/vitest.config.ts +8 -0
package/src/pm-mode.ts ADDED
@@ -0,0 +1,142 @@
1
+ import type {
2
+ ExtensionAPI,
3
+ ExtensionContext,
4
+ ThemeColor,
5
+ } from '@earendil-works/pi-coding-agent'
6
+
7
+ import { BASH_READONLY_TOOL_NAME } from './bash-readonly.js'
8
+ import type { PmMode, PmSubagentState } from './types.js'
9
+ import type { PromptDefinition } from './utils/markdown.js'
10
+ import { modelRefOf, resolveModelRef } from './utils/model-ref.js'
11
+ import { persist } from './utils/state.js'
12
+ import { composeTools, type ToolConfig } from './utils/tools.js'
13
+
14
+ export type { ToolConfig } from './utils/tools.js'
15
+
16
+ /** Restore the model captured before entering a read-only mode. */
17
+ export async function restoreModel(
18
+ pi: ExtensionAPI,
19
+ state: PmSubagentState,
20
+ ctx: ExtensionContext,
21
+ ): Promise<void> {
22
+ const previousModel = state.previousModel
23
+ state.previousModel = undefined
24
+ if (!previousModel) return
25
+
26
+ const currentModel = ctx.model
27
+ if (currentModel) {
28
+ const currentRef = `${currentModel.provider}/${currentModel.id}`
29
+ if (currentRef === previousModel) return
30
+ }
31
+
32
+ const model = resolveModelRef(ctx, [previousModel])
33
+ if (model) await pi.setModel(model)
34
+ }
35
+
36
+ export function baseToolsOf(
37
+ pi: ExtensionAPI,
38
+ state: PmSubagentState,
39
+ ): string[] {
40
+ const diff = state.modeDiffTools
41
+ if (!diff) return pi.getActiveTools()
42
+ const current = pi.getActiveTools()
43
+ const added = new Set(diff.added)
44
+ const merged = [...new Set([...current, ...diff.removed])]
45
+ return merged.filter((name) => !added.has(name))
46
+ }
47
+
48
+ export function restoreTools(pi: ExtensionAPI, state: PmSubagentState): void {
49
+ pi.setActiveTools(baseToolsOf(pi, state))
50
+ state.modeDiffTools = undefined
51
+ }
52
+
53
+ export function assertModeIdle(state: PmSubagentState, entering: PmMode): void {
54
+ if (state.mode !== undefined) {
55
+ throw new Error(
56
+ `Cannot enter ${entering} mode while in ${state.mode} mode — exit the current mode first.`,
57
+ )
58
+ }
59
+ }
60
+
61
+ export const WRITE_TOOLS = new Set(['edit', 'write'])
62
+
63
+ const BASH_REPLACEMENT: ReadonlyMap<string, string> = new Map([
64
+ ['bash', BASH_READONLY_TOOL_NAME],
65
+ ])
66
+
67
+ export function calculateModeTools(
68
+ pi: ExtensionAPI,
69
+ state: PmSubagentState,
70
+ config: ToolConfig = {},
71
+ ): string[] {
72
+ const base = pi.getActiveTools()
73
+ const applied = composeTools(base, config)
74
+ .filter((name) => !WRITE_TOOLS.has(name))
75
+ .map((name) => BASH_REPLACEMENT.get(name) ?? name)
76
+
77
+ if (!state.modeDiffTools) {
78
+ state.modeDiffTools = {
79
+ added: applied.filter((name) => !base.includes(name)),
80
+ removed: base.filter((name) => !applied.includes(name)),
81
+ }
82
+ }
83
+ return applied
84
+ }
85
+
86
+ export async function exitModeFor(
87
+ pi: ExtensionAPI,
88
+ state: PmSubagentState,
89
+ ctx: ExtensionContext,
90
+ name: PmMode,
91
+ ): Promise<void> {
92
+ restoreTools(pi, state)
93
+ await restoreModel(pi, state, ctx)
94
+ ctx.ui.setStatus(name, undefined)
95
+ persist(pi, state)
96
+ }
97
+
98
+ export async function applyModeModel(
99
+ pi: ExtensionAPI,
100
+ state: PmSubagentState,
101
+ ctx: ExtensionContext,
102
+ modelRef: string | undefined,
103
+ ): Promise<void> {
104
+ if (!modelRef) return
105
+
106
+ const model = resolveModelRef(ctx, [modelRef])
107
+ if (!model) {
108
+ ctx.ui.notify(`Invalid model ref: ${modelRef}`, 'warning')
109
+ return
110
+ }
111
+
112
+ if (!state.previousModel) {
113
+ const current = ctx.model
114
+ if (current) state.previousModel = modelRefOf(current)
115
+ }
116
+
117
+ const success = await pi.setModel(model)
118
+ if (!success) {
119
+ ctx.ui.notify('No API key for this model', 'error')
120
+ state.previousModel = undefined
121
+ }
122
+ }
123
+
124
+ export interface ModeSetupOptions {
125
+ /** The mode's prompt definition. Used to derive the active tool set. */
126
+ promptDefinition: PromptDefinition
127
+ /** Theme color role used for the mode's status indicator. */
128
+ color: ThemeColor
129
+ }
130
+
131
+ export async function applyModeFor(
132
+ pi: ExtensionAPI,
133
+ state: PmSubagentState,
134
+ pmMode: PmMode,
135
+ ctx: ExtensionContext,
136
+ options: ModeSetupOptions,
137
+ ): Promise<void> {
138
+ pi.setActiveTools(calculateModeTools(pi, state, options.promptDefinition.fm))
139
+ await applyModeModel(pi, state, ctx, options.promptDefinition.fm.model)
140
+ ctx.ui.setStatus(pmMode, ctx.ui.theme.fg(options.color, pmMode))
141
+ persist(pi, state)
142
+ }
@@ -0,0 +1,289 @@
1
+ import { mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'
2
+ import { tmpdir } from 'node:os'
3
+ import { dirname, join } from 'node:path'
4
+ import { fileURLToPath } from 'node:url'
5
+
6
+ import { parseFrontmatter } from '@earendil-works/pi-coding-agent'
7
+ import { afterEach, beforeEach, describe, expect, it } from 'vitest'
8
+
9
+ import { readModePrompt } from './mode.js'
10
+
11
+ const here = dirname(fileURLToPath(import.meta.url))
12
+
13
+ const MOCK_AGENT_DIR_VAR = 'PI_CODING_AGENT_DIR'
14
+
15
+ describe('mode', () => {
16
+ let originalAgentDir: string | undefined
17
+ let tempDir: string
18
+ let modesDir: string
19
+
20
+ beforeEach(async () => {
21
+ originalAgentDir = process.env[MOCK_AGENT_DIR_VAR]
22
+ tempDir = await mkdtemp(join(tmpdir(), 'pi-pm-subagents-test-mode-'))
23
+ modesDir = join(tempDir, 'pm-subagents-prompts')
24
+ await mkdir(modesDir, { recursive: true })
25
+ process.env[MOCK_AGENT_DIR_VAR] = tempDir
26
+ })
27
+
28
+ afterEach(async () => {
29
+ await rm(tempDir, { recursive: true, force: true })
30
+ process.env[MOCK_AGENT_DIR_VAR] = originalAgentDir
31
+ })
32
+
33
+ describe('readModePrompt', () => {
34
+ it('reads mode prompt with override', async () => {
35
+ await writeFile(
36
+ join(modesDir, 'test-mode.md'),
37
+ `---
38
+ description: A test mode.
39
+ tools: [read, write]
40
+ ---
41
+ This is a test mode prompt.`,
42
+ )
43
+
44
+ const result = await readModePrompt('test-mode')
45
+ expect(result.fm.description).toBe('A test mode.')
46
+ expect(result.fm.tools).toEqual(['read', 'write'])
47
+ expect(result.systemPrompt).toBe('This is a test mode prompt.')
48
+ })
49
+
50
+ it('handles mode without frontmatter', async () => {
51
+ await writeFile(
52
+ join(modesDir, 'no-fm.md'),
53
+ 'Plain prompt without frontmatter.',
54
+ )
55
+
56
+ const result = await readModePrompt('no-fm')
57
+ expect(result.fm.description).toBeUndefined()
58
+ expect(result.fm.tools).toBeUndefined()
59
+ expect(result.systemPrompt).toBe('Plain prompt without frontmatter.')
60
+ })
61
+
62
+ it('supports tools as array', async () => {
63
+ await writeFile(
64
+ join(modesDir, 'array-tools.md'),
65
+ `---
66
+ tools: [read, write, edit]
67
+ ---
68
+ Test prompt.`,
69
+ )
70
+
71
+ const result = await readModePrompt('array-tools')
72
+ expect(result.fm.tools).toEqual(['read', 'write', 'edit'])
73
+ })
74
+
75
+ it('supports extraTools as array', async () => {
76
+ await writeFile(
77
+ join(modesDir, 'extra-array.md'),
78
+ `---
79
+ extraTools: [bash, grep]
80
+ ---
81
+ Test prompt.`,
82
+ )
83
+
84
+ const result = await readModePrompt('extra-array')
85
+ expect(result.fm.extraTools).toEqual(['bash', 'grep'])
86
+ })
87
+
88
+ it('supports removeTools as array', async () => {
89
+ await writeFile(
90
+ join(modesDir, 'remove-array.md'),
91
+ `---
92
+ removeTools: [write, bash]
93
+ ---
94
+ Test prompt.`,
95
+ )
96
+
97
+ const result = await readModePrompt('remove-array')
98
+ expect(result.fm.removeTools).toEqual(['write', 'bash'])
99
+ })
100
+
101
+ it('supports model field', async () => {
102
+ await writeFile(
103
+ join(modesDir, 'model-test.md'),
104
+ `---
105
+ model: anthropic/claude-sonnet-4-5
106
+ ---
107
+ Test prompt.`,
108
+ )
109
+
110
+ const result = await readModePrompt('model-test')
111
+ expect(result.fm.model).toBe('anthropic/claude-sonnet-4-5')
112
+ })
113
+
114
+ it('appends extra content from -append.md', async () => {
115
+ await writeFile(
116
+ join(modesDir, 'with-append.md'),
117
+ `---
118
+ description: Base mode.
119
+ ---
120
+ Base prompt content.`,
121
+ )
122
+ await writeFile(
123
+ join(modesDir, 'with-append-append.md'),
124
+ 'Appended extra content.',
125
+ )
126
+
127
+ const result = await readModePrompt('with-append')
128
+ expect(result.fm.description).toBe('Base mode.')
129
+ expect(result.systemPrompt).toBe(
130
+ 'Base prompt content.\n\nAppended extra content.',
131
+ )
132
+ })
133
+
134
+ it('handles append without override', async () => {
135
+ await writeFile(
136
+ join(modesDir, 'coordinator-append.md'),
137
+ 'Appended extra content.',
138
+ )
139
+
140
+ const bundled = await readFile(
141
+ join(here, '../../pm-subagents-prompts/coordinator.md'),
142
+ 'utf8',
143
+ )
144
+ const { body } = parseFrontmatter(bundled)
145
+
146
+ const result = await readModePrompt('coordinator')
147
+ // Intentionally structural assertions instead of hardcoding the full
148
+ // body: tolerate wording edits to the bundled prompt while guarding
149
+ // against sections being accidentally dropped from it.
150
+ expect(result.systemPrompt).toContain('You are a COORDINATOR agent')
151
+ expect(result.systemPrompt).toContain('## Responsibilities')
152
+ expect(result.systemPrompt).toContain(
153
+ '- Delegate tasks and wait for me to tell you subagent',
154
+ )
155
+ expect(result.systemPrompt).toContain(
156
+ `- Do not trust a subagent's self-reported result blindly.`,
157
+ )
158
+ expect(result.systemPrompt).toContain(body.trim())
159
+ expect(result.systemPrompt).toContain(
160
+ `${body.trim()}\n\nAppended extra content.`,
161
+ )
162
+ })
163
+
164
+ it('handles empty tools array', async () => {
165
+ await writeFile(
166
+ join(modesDir, 'empty-tools.md'),
167
+ `---
168
+ tools: []
169
+ ---
170
+ Test prompt.`,
171
+ )
172
+
173
+ const result = await readModePrompt('empty-tools')
174
+ expect(result.fm.tools).toEqual([])
175
+ })
176
+
177
+ it('handles empty extraTools array', async () => {
178
+ await writeFile(
179
+ join(modesDir, 'empty-extra.md'),
180
+ `---
181
+ extraTools: []
182
+ ---
183
+ Test prompt.`,
184
+ )
185
+
186
+ const result = await readModePrompt('empty-extra')
187
+ expect(result.fm.extraTools).toEqual([])
188
+ })
189
+
190
+ it('handles empty removeTools array', async () => {
191
+ await writeFile(
192
+ join(modesDir, 'empty-remove.md'),
193
+ `---
194
+ removeTools: []
195
+ ---
196
+ Test prompt.`,
197
+ )
198
+
199
+ const result = await readModePrompt('empty-remove')
200
+ expect(result.fm.removeTools).toEqual([])
201
+ })
202
+
203
+ it('frontmatter from base is preserved when append exists', async () => {
204
+ await writeFile(
205
+ join(modesDir, 'fm-base.md'),
206
+ `---
207
+ description: Frontmatter from base.
208
+ tools: [read]
209
+ ---
210
+ Base content.`,
211
+ )
212
+ await writeFile(join(modesDir, 'fm-base-append.md'), 'Append content.')
213
+
214
+ const result = await readModePrompt('fm-base')
215
+ expect(result.fm.description).toBe('Frontmatter from base.')
216
+ expect(result.fm.tools).toEqual(['read'])
217
+ expect(result.systemPrompt).toBe('Base content.\n\nAppend content.')
218
+ })
219
+
220
+ it('append tools merge with base tools (union, deduped)', async () => {
221
+ await writeFile(
222
+ join(modesDir, 'tools-merge.md'),
223
+ `---
224
+ tools: [read, write]
225
+ ---
226
+ Base content.`,
227
+ )
228
+ await writeFile(
229
+ join(modesDir, 'tools-merge-append.md'),
230
+ `---
231
+ tools: [write, grep]
232
+ ---
233
+ `,
234
+ )
235
+
236
+ const result = await readModePrompt('tools-merge')
237
+ expect(result.fm.tools).toEqual(['read', 'write', 'grep'])
238
+ })
239
+
240
+ it('merges description, model, tools, and systemPrompt from append', async () => {
241
+ await writeFile(
242
+ join(modesDir, 'all-merge.md'),
243
+ `---
244
+ description: Base description.
245
+ model: base/model
246
+ tools: [read, write]
247
+ ---
248
+ Base content.`,
249
+ )
250
+ await writeFile(
251
+ join(modesDir, 'all-merge-append.md'),
252
+ `---
253
+ description: Append description.
254
+ model: append/model
255
+ tools: [grep]
256
+ ---
257
+ Append body.`,
258
+ )
259
+
260
+ const result = await readModePrompt('all-merge')
261
+ expect(result.fm.description).toBe('Append description.')
262
+ expect(result.fm.model).toBe('append/model')
263
+ expect(result.fm.tools).toEqual(['read', 'write', 'grep'])
264
+ expect(result.systemPrompt).toBe('Base content.\n\nAppend body.')
265
+ })
266
+
267
+ it('append with frontmatter only and empty body merges cleanly', async () => {
268
+ await writeFile(
269
+ join(modesDir, 'empty-body.md'),
270
+ `---
271
+ description: Base description.
272
+ ---
273
+ Base content.`,
274
+ )
275
+ await writeFile(
276
+ join(modesDir, 'empty-body-append.md'),
277
+ `---
278
+ model: append/model
279
+ ---
280
+ `,
281
+ )
282
+
283
+ const result = await readModePrompt('empty-body')
284
+ expect(result.fm.description).toBe('Base description.')
285
+ expect(result.fm.model).toBe('append/model')
286
+ expect(result.systemPrompt).toBe('Base content.')
287
+ })
288
+ })
289
+ })
@@ -0,0 +1,31 @@
1
+ import { dirname, join } from 'node:path'
2
+ import { fileURLToPath } from 'node:url'
3
+
4
+ import { getAgentDir } from '@earendil-works/pi-coding-agent'
5
+
6
+ import {
7
+ loadMarkdown,
8
+ mergePromptDefinitions,
9
+ type PromptDefinition,
10
+ } from '../utils/markdown.js'
11
+
12
+ export async function readModePrompt(name: string): Promise<PromptDefinition> {
13
+ const here = dirname(fileURLToPath(import.meta.url))
14
+ const agentDir = getAgentDir()
15
+ const modesPromptsDir = join(agentDir, 'pm-subagents-prompts')
16
+
17
+ const overridePath = join(modesPromptsDir, `${name}.md`)
18
+ const bundledPath = join(here, '../..', 'pm-subagents-prompts', `${name}.md`)
19
+ const appendPath = join(modesPromptsDir, `${name}-append.md`)
20
+
21
+ const base =
22
+ (await loadMarkdown(overridePath)) ?? (await loadMarkdown(bundledPath))
23
+
24
+ const append = await loadMarkdown(appendPath)
25
+
26
+ if (!base) throw new Error(`Mode prompt "${name}" not found`)
27
+
28
+ if (!append) return base
29
+
30
+ return mergePromptDefinitions(base, append)
31
+ }