@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
@@ -0,0 +1,442 @@
1
+ import { mkdtemp, rm, writeFile } from 'node:fs/promises'
2
+ import { tmpdir } from 'node:os'
3
+ import { join } from 'node:path'
4
+
5
+ import { afterEach, beforeEach, describe, expect, it } from 'vitest'
6
+
7
+ import { loadMarkdown, mergePromptDefinitions } from './markdown.js'
8
+ import { composeTools } from './tools.js'
9
+
10
+ describe('composeTools', () => {
11
+ it('uses base tools when config has no tools', () => {
12
+ expect(composeTools(['read', 'write'], {})).toEqual(['read', 'write'])
13
+ })
14
+
15
+ it('replaces base with config.tools', () => {
16
+ expect(composeTools(['read', 'write'], { tools: ['grep'] })).toEqual([
17
+ 'grep',
18
+ ])
19
+ })
20
+
21
+ it('adds extraTools to candidates', () => {
22
+ expect(composeTools(['read', 'write'], { extraTools: ['bash'] })).toEqual([
23
+ 'read',
24
+ 'write',
25
+ 'bash',
26
+ ])
27
+ })
28
+
29
+ it('removes tools from base and candidates', () => {
30
+ expect(
31
+ composeTools(['read', 'write', 'edit'], { removeTools: ['write'] }),
32
+ ).toEqual(['read', 'edit'])
33
+ })
34
+
35
+ it('combines tools, extraTools, and removeTools', () => {
36
+ const result = composeTools(['read', 'write', 'edit'], {
37
+ tools: ['read', 'bash'],
38
+ extraTools: ['grep'],
39
+ removeTools: ['write'],
40
+ })
41
+ expect(result).toEqual(['read', 'bash', 'grep'])
42
+ })
43
+
44
+ it('deduplicates tools', () => {
45
+ const result = composeTools(['read', 'write'], {
46
+ tools: ['read'],
47
+ extraTools: ['read', 'bash'],
48
+ })
49
+ expect(result).toEqual(['read', 'bash'])
50
+ })
51
+ })
52
+
53
+ describe('loadMarkdown', () => {
54
+ let tempDir: string
55
+
56
+ beforeEach(async () => {
57
+ tempDir = await mkdtemp(join(tmpdir(), 'pi-pm-subagents-test-markdown-'))
58
+ })
59
+
60
+ afterEach(async () => {
61
+ await rm(tempDir, { recursive: true, force: true })
62
+ })
63
+
64
+ it('returns undefined for non-existent file', async () => {
65
+ const result = await loadMarkdown(join(tempDir, 'nonexistent.md'))
66
+ expect(result).toBeUndefined()
67
+ })
68
+
69
+ it('loads markdown with frontmatter', async () => {
70
+ const filePath = join(tempDir, 'test.md')
71
+ await writeFile(
72
+ filePath,
73
+ `---
74
+ description: A test mode.
75
+ tools: [read, write]
76
+ ---
77
+ This is a test mode prompt.`,
78
+ )
79
+
80
+ const result = await loadMarkdown(filePath)
81
+ expect(result).toEqual({
82
+ fm: {
83
+ description: 'A test mode.',
84
+ model: undefined,
85
+ thinkingLevel: undefined,
86
+ tools: ['read', 'write'],
87
+ extraTools: undefined,
88
+ removeTools: undefined,
89
+ reviewOnEnd: undefined,
90
+ },
91
+ systemPrompt: 'This is a test mode prompt.',
92
+ })
93
+ })
94
+
95
+ it('handles markdown without frontmatter', async () => {
96
+ const filePath = join(tempDir, 'no-fm.md')
97
+ await writeFile(filePath, 'Plain prompt without frontmatter.')
98
+
99
+ const result = await loadMarkdown(filePath)
100
+ expect(result).toEqual({
101
+ fm: {
102
+ description: undefined,
103
+ model: undefined,
104
+ thinkingLevel: undefined,
105
+ tools: undefined,
106
+ extraTools: undefined,
107
+ removeTools: undefined,
108
+ reviewOnEnd: undefined,
109
+ },
110
+ systemPrompt: 'Plain prompt without frontmatter.',
111
+ })
112
+ })
113
+
114
+ it('supports tools as array', async () => {
115
+ const filePath = join(tempDir, 'array-tools.md')
116
+ await writeFile(
117
+ filePath,
118
+ `---
119
+ tools: [read, write, edit, grep]
120
+ ---
121
+ Test prompt.`,
122
+ )
123
+
124
+ const result = await loadMarkdown(filePath)
125
+ expect(result?.fm.tools).toEqual(['read', 'write', 'edit', 'grep'])
126
+ })
127
+
128
+ it('supports extraTools as array', async () => {
129
+ const filePath = join(tempDir, 'extra-array.md')
130
+ await writeFile(
131
+ filePath,
132
+ `---
133
+ extraTools: [bash, grep]
134
+ ---
135
+ Test prompt.`,
136
+ )
137
+
138
+ const result = await loadMarkdown(filePath)
139
+ expect(result?.fm.extraTools).toEqual(['bash', 'grep'])
140
+ })
141
+
142
+ it('supports removeTools as array', async () => {
143
+ const filePath = join(tempDir, 'remove-array.md')
144
+ await writeFile(
145
+ filePath,
146
+ `---
147
+ removeTools: [write, bash, edit]
148
+ ---
149
+ Test prompt.`,
150
+ )
151
+
152
+ const result = await loadMarkdown(filePath)
153
+ expect(result?.fm.removeTools).toEqual(['write', 'bash', 'edit'])
154
+ })
155
+
156
+ it('supports model field', async () => {
157
+ const filePath = join(tempDir, 'model-test.md')
158
+ await writeFile(
159
+ filePath,
160
+ `---
161
+ model: anthropic/claude-sonnet-4-5
162
+ ---
163
+ Test prompt.`,
164
+ )
165
+
166
+ const result = await loadMarkdown(filePath)
167
+ expect(result?.fm.model).toBe('anthropic/claude-sonnet-4-5')
168
+ })
169
+
170
+ it('supports reviewOnEnd field as true', async () => {
171
+ const filePath = join(tempDir, 'review-on-end.md')
172
+ await writeFile(
173
+ filePath,
174
+ `---
175
+ reviewOnEnd: true
176
+ ---
177
+ Test prompt.`,
178
+ )
179
+
180
+ const result = await loadMarkdown(filePath)
181
+ expect(result?.fm.reviewOnEnd).toBe(true)
182
+ })
183
+
184
+ it('supports reviewOnEnd field as false', async () => {
185
+ const filePath = join(tempDir, 'review-on-end-false.md')
186
+ await writeFile(
187
+ filePath,
188
+ `---
189
+ reviewOnEnd: false
190
+ ---
191
+ Test prompt.`,
192
+ )
193
+
194
+ const result = await loadMarkdown(filePath)
195
+ expect(result?.fm.reviewOnEnd).toBe(false)
196
+ })
197
+
198
+ it('returns undefined reviewOnEnd when not specified', async () => {
199
+ const filePath = join(tempDir, 'no-review.md')
200
+ await writeFile(filePath, 'Test prompt.')
201
+
202
+ const result = await loadMarkdown(filePath)
203
+ expect(result?.fm.reviewOnEnd).toBeUndefined()
204
+ })
205
+
206
+ it('supports thinkingLevel field', async () => {
207
+ const filePath = join(tempDir, 'thinking-level.md')
208
+ await writeFile(
209
+ filePath,
210
+ `---
211
+ thinkingLevel: high
212
+ ---
213
+ Test prompt.`,
214
+ )
215
+
216
+ const result = await loadMarkdown(filePath)
217
+ expect(result?.fm.thinkingLevel).toBe('high')
218
+ })
219
+
220
+ it('supports thinkingLevel off', async () => {
221
+ const filePath = join(tempDir, 'thinking-level-off.md')
222
+ await writeFile(
223
+ filePath,
224
+ `---
225
+ thinkingLevel: off
226
+ ---
227
+ Test prompt.`,
228
+ )
229
+
230
+ const result = await loadMarkdown(filePath)
231
+ expect(result?.fm.thinkingLevel).toBe('off')
232
+ })
233
+
234
+ it('returns undefined thinkingLevel when not specified', async () => {
235
+ const filePath = join(tempDir, 'no-thinking-level.md')
236
+ await writeFile(filePath, 'Test prompt.')
237
+
238
+ const result = await loadMarkdown(filePath)
239
+ expect(result?.fm.thinkingLevel).toBeUndefined()
240
+ })
241
+
242
+ it('throws when thinkingLevel is invalid', async () => {
243
+ const filePath = join(tempDir, 'invalid-thinking-level.md')
244
+ await writeFile(
245
+ filePath,
246
+ `---
247
+ thinkingLevel: ultra
248
+ ---
249
+ Test prompt.`,
250
+ )
251
+
252
+ await expect(loadMarkdown(filePath)).rejects.toThrow()
253
+ })
254
+
255
+ it('handles empty tools array', async () => {
256
+ const filePath = join(tempDir, 'empty-tools.md')
257
+ await writeFile(
258
+ filePath,
259
+ `---
260
+ tools: []
261
+ ---
262
+ Test prompt.`,
263
+ )
264
+
265
+ const result = await loadMarkdown(filePath)
266
+ expect(result?.fm.tools).toEqual([])
267
+ })
268
+
269
+ it('throws when tools is a number', async () => {
270
+ const filePath = join(tempDir, 'invalid-tools-number.md')
271
+ await writeFile(
272
+ filePath,
273
+ `---
274
+ tools: 123
275
+ ---
276
+ Test prompt.`,
277
+ )
278
+
279
+ await expect(loadMarkdown(filePath)).rejects.toThrow()
280
+ })
281
+
282
+ it('throws when tools array contains non-string', async () => {
283
+ const filePath = join(tempDir, 'invalid-tools-array.md')
284
+ await writeFile(
285
+ filePath,
286
+ `---
287
+ tools: [123, "read"]
288
+ ---
289
+ Test prompt.`,
290
+ )
291
+
292
+ await expect(loadMarkdown(filePath)).rejects.toThrow()
293
+ })
294
+
295
+ it('throws when reviewOnEnd is a string', async () => {
296
+ const filePath = join(tempDir, 'invalid-review-string.md')
297
+ await writeFile(
298
+ filePath,
299
+ `---
300
+ reviewOnEnd: "true"
301
+ ---
302
+ Test prompt.`,
303
+ )
304
+
305
+ await expect(loadMarkdown(filePath)).rejects.toThrow()
306
+ })
307
+ })
308
+
309
+ describe('mergePromptDefinitions', () => {
310
+ const base = {
311
+ fm: {
312
+ description: 'Base description.',
313
+ model: 'base/model',
314
+ tools: ['read', 'write'] as string[],
315
+ extraTools: ['bash'] as string[],
316
+ removeTools: ['write'] as string[],
317
+ thinkingLevel: undefined,
318
+ reviewOnEnd: undefined,
319
+ },
320
+ systemPrompt: 'Base prompt.',
321
+ }
322
+
323
+ it('append description overrides base description', () => {
324
+ const result = mergePromptDefinitions(base, {
325
+ ...base,
326
+ fm: { ...base.fm, description: 'Append description.' },
327
+ })
328
+ expect(result.fm.description).toBe('Append description.')
329
+ })
330
+
331
+ it('keeps base description when append has none', () => {
332
+ const result = mergePromptDefinitions(base, {
333
+ ...base,
334
+ fm: { ...base.fm, description: undefined },
335
+ })
336
+ expect(result.fm.description).toBe('Base description.')
337
+ })
338
+
339
+ it('append model overrides base model', () => {
340
+ const result = mergePromptDefinitions(base, {
341
+ ...base,
342
+ fm: { ...base.fm, model: 'append/model' },
343
+ })
344
+ expect(result.fm.model).toBe('append/model')
345
+ })
346
+
347
+ it('merges tools arrays as union, preserving order with dedup', () => {
348
+ const result = mergePromptDefinitions(base, {
349
+ ...base,
350
+ fm: { ...base.fm, tools: ['write', 'grep', 'read'] },
351
+ })
352
+ expect(result.fm.tools).toEqual(['read', 'write', 'grep'])
353
+ })
354
+
355
+ it('merges extraTools and removeTools as union, preserving order with dedup', () => {
356
+ const result = mergePromptDefinitions(base, {
357
+ ...base,
358
+ fm: {
359
+ ...base.fm,
360
+ extraTools: ['bash', 'glob'],
361
+ removeTools: ['write', 'edit'],
362
+ },
363
+ })
364
+ expect(result.fm.extraTools).toEqual(['bash', 'glob'])
365
+ expect(result.fm.removeTools).toEqual(['write', 'edit'])
366
+ })
367
+
368
+ it('does not throw with empty / partial fields', () => {
369
+ const result = mergePromptDefinitions(
370
+ { fm: {}, systemPrompt: 'Base.' },
371
+ { fm: {}, systemPrompt: '' },
372
+ )
373
+ expect(result).toEqual({
374
+ fm: {
375
+ description: undefined,
376
+ model: undefined,
377
+ thinkingLevel: undefined,
378
+ tools: undefined,
379
+ extraTools: undefined,
380
+ removeTools: undefined,
381
+ reviewOnEnd: undefined,
382
+ },
383
+ systemPrompt: 'Base.',
384
+ })
385
+ })
386
+
387
+ it('append thinkingLevel overrides base thinkingLevel', () => {
388
+ const result = mergePromptDefinitions(base, {
389
+ ...base,
390
+ fm: { ...base.fm, thinkingLevel: 'high' },
391
+ })
392
+ expect(result.fm.thinkingLevel).toBe('high')
393
+ })
394
+
395
+ it('keeps base thinkingLevel when append has none', () => {
396
+ const result = mergePromptDefinitions(
397
+ { ...base, fm: { ...base.fm, thinkingLevel: 'low' } },
398
+ { ...base, fm: { ...base.fm, thinkingLevel: undefined } },
399
+ )
400
+ expect(result.fm.thinkingLevel).toBe('low')
401
+ })
402
+
403
+ it('returns undefined thinkingLevel when neither has it', () => {
404
+ const result = mergePromptDefinitions(
405
+ { ...base, fm: { ...base.fm, thinkingLevel: undefined } },
406
+ { ...base, fm: { ...base.fm, thinkingLevel: undefined } },
407
+ )
408
+ expect(result.fm.thinkingLevel).toBeUndefined()
409
+ })
410
+
411
+ it('uses append reviewOnEnd when defined', () => {
412
+ const result = mergePromptDefinitions(base, {
413
+ ...base,
414
+ fm: { ...base.fm, reviewOnEnd: true },
415
+ })
416
+ expect(result.fm.reviewOnEnd).toBe(true)
417
+ })
418
+
419
+ it('uses base reviewOnEnd when append has none', () => {
420
+ const result = mergePromptDefinitions(
421
+ { ...base, fm: { ...base.fm, reviewOnEnd: true } },
422
+ { ...base, fm: { ...base.fm, reviewOnEnd: undefined } },
423
+ )
424
+ expect(result.fm.reviewOnEnd).toBe(true)
425
+ })
426
+
427
+ it('returns undefined reviewOnEnd when neither has it', () => {
428
+ const result = mergePromptDefinitions(
429
+ { ...base, fm: { ...base.fm, reviewOnEnd: undefined } },
430
+ { ...base, fm: { ...base.fm, reviewOnEnd: undefined } },
431
+ )
432
+ expect(result.fm.reviewOnEnd).toBeUndefined()
433
+ })
434
+
435
+ it('joins systemPrompt with a blank line', () => {
436
+ const result = mergePromptDefinitions(
437
+ { ...base, systemPrompt: 'First.' },
438
+ { ...base, systemPrompt: 'Second.' },
439
+ )
440
+ expect(result.systemPrompt).toBe('First.\n\nSecond.')
441
+ })
442
+ })
@@ -0,0 +1,79 @@
1
+ import { parseFrontmatter } from '@earendil-works/pi-coding-agent'
2
+ import type { Static } from 'typebox'
3
+ import { Type } from 'typebox'
4
+ import { Parse } from 'typebox/value'
5
+
6
+ import { readOptional } from './fs.js'
7
+
8
+ const StringList = Type.Array(Type.String())
9
+
10
+ const ThinkingLevelSchema = Type.Union([
11
+ Type.Literal('off'),
12
+ Type.Literal('minimal'),
13
+ Type.Literal('low'),
14
+ Type.Literal('medium'),
15
+ Type.Literal('high'),
16
+ Type.Literal('xhigh'),
17
+ Type.Literal('max'),
18
+ ])
19
+
20
+ const PromptFrontmatterSchema = Type.Object({
21
+ description: Type.Optional(Type.String()),
22
+ model: Type.Optional(Type.String()),
23
+ thinkingLevel: Type.Optional(ThinkingLevelSchema),
24
+ tools: Type.Optional(StringList),
25
+ extraTools: Type.Optional(StringList),
26
+ removeTools: Type.Optional(StringList),
27
+ reviewOnEnd: Type.Optional(Type.Boolean()),
28
+ })
29
+
30
+ export type PromptFrontmatter = Static<typeof PromptFrontmatterSchema>
31
+
32
+ export interface PromptDefinition {
33
+ fm: PromptFrontmatter
34
+ systemPrompt?: string
35
+ }
36
+
37
+ export async function loadMarkdown(
38
+ path: string,
39
+ ): Promise<PromptDefinition | undefined> {
40
+ const content = await readOptional(path)
41
+ if (content === undefined) return undefined
42
+ const { frontmatter, body } = parseFrontmatter(content)
43
+ const fm = Parse(PromptFrontmatterSchema, frontmatter)
44
+ return { fm, systemPrompt: body.trim() }
45
+ }
46
+
47
+ function mergeUniqueTools(
48
+ baseTools: readonly string[] | undefined,
49
+ appendTools: readonly string[] | undefined,
50
+ ): string[] | undefined {
51
+ const merged = new Set([...(baseTools ?? []), ...(appendTools ?? [])])
52
+ return merged.size > 0 ? [...merged] : undefined
53
+ }
54
+
55
+ export function mergePromptDefinitions(
56
+ baseTools: PromptDefinition,
57
+ append: PromptDefinition,
58
+ ): PromptDefinition {
59
+ return {
60
+ fm: {
61
+ description: append.fm.description ?? baseTools.fm.description,
62
+ model: append.fm.model ?? baseTools.fm.model,
63
+ thinkingLevel: append.fm.thinkingLevel ?? baseTools.fm.thinkingLevel,
64
+ tools: mergeUniqueTools(baseTools.fm.tools, append.fm.tools),
65
+ extraTools: mergeUniqueTools(
66
+ baseTools.fm.extraTools,
67
+ append.fm.extraTools,
68
+ ),
69
+ removeTools: mergeUniqueTools(
70
+ baseTools.fm.removeTools,
71
+ append.fm.removeTools,
72
+ ),
73
+ reviewOnEnd: append.fm.reviewOnEnd ?? baseTools.fm.reviewOnEnd,
74
+ },
75
+ systemPrompt: append.systemPrompt
76
+ ? `${baseTools.systemPrompt}\n\n${append.systemPrompt}`
77
+ : baseTools.systemPrompt,
78
+ }
79
+ }