@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,376 @@
1
+ import type {
2
+ ContextUsage,
3
+ ExtensionContext,
4
+ Theme,
5
+ } from '@earendil-works/pi-coding-agent'
6
+ import type { TUI } from '@earendil-works/pi-tui'
7
+ import { Editor } from '@earendil-works/pi-tui'
8
+ import {
9
+ isKeyRelease,
10
+ Key,
11
+ matchesKey,
12
+ visibleWidth,
13
+ } from '@earendil-works/pi-tui'
14
+ import { orderBy } from 'lodash-es'
15
+
16
+ import {
17
+ formatContextUsage,
18
+ formatElapsed,
19
+ rightAlign,
20
+ strInline,
21
+ } from '../utils/format.js'
22
+ import { truncateText } from '../utils/truncate.js'
23
+ import { FOLLOW_SYMBOL } from './consts.ts'
24
+
25
+ const FLEET_KEY = 'pi-pm-subagents:fleet'
26
+ const TICK_MS = 200
27
+ const MAX_ROWS = 8
28
+
29
+ function visibleWindow(
30
+ rowCount: number,
31
+ selectedRowIndex: number,
32
+ ): { start: number; end: number; hiddenAbove: number; hiddenBelow: number } {
33
+ const visible = Math.min(MAX_ROWS, rowCount)
34
+ const start = Math.max(0, selectedRowIndex - visible + 1)
35
+ const end = Math.min(start + visible, rowCount)
36
+ return { start, end, hiddenAbove: start, hiddenBelow: rowCount - end }
37
+ }
38
+
39
+ export type FleetEntryStatus = 'running' | 'done' | 'failed' | 'killed'
40
+
41
+ export interface FleetEntryBase {
42
+ title: string
43
+ status: FleetEntryStatus
44
+ followUpCount: number
45
+ startedAt: number
46
+ completedAt?: number
47
+ contextUsage?: ContextUsage
48
+ }
49
+
50
+ export interface FleetEntry extends FleetEntryBase {
51
+ id: number
52
+ previousEntries: FleetEntryBase[]
53
+ role: string
54
+ }
55
+
56
+ export interface FleetListOptions {
57
+ list: () => FleetEntry[]
58
+ onOpen: (ctx: ExtensionContext, id: number) => void | Promise<void>
59
+ }
60
+
61
+ type FleetRow = {
62
+ kind: 'item' | 'previous'
63
+ entry: FleetEntryBase
64
+ item: FleetEntry
65
+ }
66
+
67
+ export class FleetList {
68
+ private ctx: ExtensionContext | undefined
69
+ private tui: TUI | undefined
70
+ private inputUnsub: (() => void) | undefined
71
+ private timer: ReturnType<typeof setInterval> | undefined
72
+ private registered = false
73
+ private activeSelect = false
74
+ private selectedIndex = 0
75
+
76
+ constructor(private options: FleetListOptions) {}
77
+
78
+ setContext(ctx: ExtensionContext): void {
79
+ if (ctx === this.ctx) return
80
+ this.inputUnsub?.()
81
+ this.ctx = ctx
82
+ this.registered = false
83
+ this.tui = undefined
84
+ this.inputUnsub = ctx.ui.onTerminalInput((data) => this.handleKey(data))
85
+ this.update()
86
+ }
87
+
88
+ update(): void {
89
+ const ctx = this.ctx
90
+ if (!ctx) return
91
+
92
+ const items = this.options.list()
93
+ if (items.length === 0) {
94
+ this.hide()
95
+ return
96
+ }
97
+
98
+ this.clampSelection()
99
+ this.ensureTimer()
100
+
101
+ if (!this.registered) {
102
+ ctx.ui.setWidget(
103
+ FLEET_KEY,
104
+ (tui) => {
105
+ this.tui = tui
106
+ return {
107
+ render: (width: number) => this.renderBar(width),
108
+ invalidate: () => {
109
+ this.registered = false
110
+ this.tui = undefined
111
+ },
112
+ }
113
+ },
114
+ { placement: 'belowEditor' },
115
+ )
116
+ this.registered = true
117
+ } else {
118
+ this.tui?.requestRender()
119
+ }
120
+ }
121
+
122
+ hide() {
123
+ const ctx = this.ctx
124
+ if (!ctx) return
125
+ if (this.registered) {
126
+ ctx.ui.setWidget(FLEET_KEY, undefined)
127
+ this.registered = false
128
+ this.tui = undefined
129
+ }
130
+ this.stopTimer()
131
+ this.activeSelect = false
132
+ this.selectedIndex = 0
133
+ }
134
+
135
+ private editorHasFocus(): boolean {
136
+ if (!this.tui) return true
137
+ const focused = (this.tui as unknown as { focusedComponent?: unknown })
138
+ .focusedComponent
139
+ if (focused == null) return true
140
+ return focused instanceof Editor
141
+ }
142
+
143
+ dispose(): void {
144
+ this.stopTimer()
145
+ this.inputUnsub?.()
146
+ this.inputUnsub = undefined
147
+ this.hide()
148
+ this.ctx = undefined
149
+ this.activeSelect = false
150
+ this.selectedIndex = 0
151
+ }
152
+
153
+ private roster(): FleetRow[] {
154
+ const items = orderBy(
155
+ this.options.list(),
156
+ [(it) => (it.status === 'running' ? 0 : 1), (it) => it.id],
157
+ ['asc', 'desc'],
158
+ )
159
+ const rows: FleetRow[] = []
160
+ for (const item of items) {
161
+ rows.push({ kind: 'item', entry: item, item })
162
+ for (const previous of item.previousEntries) {
163
+ rows.push({ kind: 'previous', entry: previous, item })
164
+ }
165
+ }
166
+ return rows
167
+ }
168
+
169
+ private clampSelection(): void {
170
+ const max = this.roster().length
171
+ const min = this.activeSelect ? 1 : 0
172
+ this.selectedIndex = Math.max(min, Math.min(this.selectedIndex, max))
173
+ }
174
+
175
+ private handleKey(data: string): { consume?: boolean } | undefined {
176
+ const ctx = this.ctx
177
+ if (!ctx) return undefined
178
+ if (isKeyRelease(data)) return undefined
179
+ if (this.tui?.hasOverlay()) return
180
+ if (!this.editorHasFocus()) {
181
+ if (this.activeSelect) {
182
+ this.deactivate()
183
+ }
184
+ return undefined
185
+ }
186
+
187
+ if (!this.activeSelect) {
188
+ const activator = matchesKey(data, 'down') || matchesKey(data, 'left')
189
+ if (
190
+ activator &&
191
+ this.options.list().length > 0 &&
192
+ ctx.ui.getEditorText() === ''
193
+ ) {
194
+ this.activeSelect = true
195
+ this.selectedIndex = 1
196
+ this.update()
197
+ return { consume: true }
198
+ }
199
+ return undefined
200
+ }
201
+
202
+ if (matchesKey(data, 'down')) {
203
+ const max = this.roster().length
204
+ this.selectedIndex = Math.min(max, this.selectedIndex + 1)
205
+ this.update()
206
+ return { consume: true }
207
+ }
208
+ if (matchesKey(data, 'up')) {
209
+ if (this.selectedIndex === 1) {
210
+ this.deactivate()
211
+ return { consume: true }
212
+ }
213
+ this.selectedIndex -= 1
214
+ this.update()
215
+ return { consume: true }
216
+ }
217
+ if (matchesKey(data, Key.escape)) {
218
+ this.deactivate()
219
+ return { consume: true }
220
+ }
221
+ if (matchesKey(data, Key.enter)) {
222
+ void this.openSelected()
223
+ return { consume: true }
224
+ }
225
+
226
+ this.deactivate()
227
+ return undefined
228
+ }
229
+
230
+ private deactivate(): void {
231
+ this.activeSelect = false
232
+ this.selectedIndex = 0
233
+ this.update()
234
+ }
235
+
236
+ private async openSelected(): Promise<void> {
237
+ const ctx = this.ctx
238
+ if (!ctx) return
239
+ const entry = this.roster()[this.selectedIndex - 1]
240
+ if (!entry) {
241
+ this.deactivate()
242
+ return
243
+ }
244
+ const id = entry.item.id
245
+ await this.options.onOpen(ctx, id)
246
+ this.update()
247
+ }
248
+
249
+ private renderBar(width: number): string[] {
250
+ const ctx = this.ctx
251
+ if (!ctx) return []
252
+ const theme = ctx.ui.theme
253
+ const rows = this.roster()
254
+ if (rows.length === 0) return []
255
+
256
+ const sel = this.activeSelect ? this.selectedIndex : -1
257
+ const hint = this.activeSelect
258
+ ? '↑↓ select · enter view · esc back'
259
+ : 'esc to interrupt · ←/↓ for items'
260
+ const mainLine = ` ${theme.fg('dim', 'subagents')}`
261
+ const lines: string[] = [
262
+ truncateText(` ${theme.fg('dim', hint)}`, width),
263
+ '',
264
+ truncateText(mainLine, width),
265
+ ]
266
+
267
+ const selRow = sel >= 1 && sel <= rows.length ? rows[sel - 1] : undefined
268
+ const selectedParentId =
269
+ selRow?.kind === 'previous' ? selRow.item.id : undefined
270
+ const selectedRow = sel - 1
271
+ const { start, end, hiddenAbove, hiddenBelow } = visibleWindow(
272
+ rows.length,
273
+ selectedRow,
274
+ )
275
+
276
+ if (hiddenAbove > 0) {
277
+ lines.push(
278
+ rightAlign('', theme.fg('dim', `↑ ${hiddenAbove} more`), width),
279
+ )
280
+ }
281
+
282
+ for (const [offset, row] of rows.slice(start, end).entries()) {
283
+ const rowNumber = start + offset + 1
284
+ const hlBullet = rowNumber === sel || row.item.id === selectedParentId
285
+ const prefix =
286
+ row.kind === 'previous'
287
+ ? ` ${theme.fg('muted', '↳')} `
288
+ : ` ${this.bullet(hlBullet, theme)} ${theme.fg('muted', `#${row.item.id} [${row.item.role}]`)} `
289
+ const line = this.renderItemRow(
290
+ row.entry,
291
+ prefix,
292
+ width,
293
+ rowNumber === sel,
294
+ theme,
295
+ )
296
+ lines.push(truncateText(line, width))
297
+ }
298
+
299
+ if (hiddenBelow > 0) {
300
+ lines.push(
301
+ rightAlign('', theme.fg('dim', `↓ ${hiddenBelow} more`), width),
302
+ )
303
+ }
304
+ return lines
305
+ }
306
+
307
+ private renderTitle(
308
+ status: FleetEntryStatus,
309
+ title: string,
310
+ theme: Theme,
311
+ ): string {
312
+ switch (status) {
313
+ case 'running':
314
+ return theme.fg('syntaxVariable', theme.bold(title))
315
+ case 'done':
316
+ return title
317
+ case 'failed':
318
+ case 'killed':
319
+ return theme.fg('error', title)
320
+ }
321
+ }
322
+
323
+ private bullet(highlight: boolean, theme: Theme): string {
324
+ return highlight ? theme.fg('accent', '●') : theme.fg('dim', '◯')
325
+ }
326
+
327
+ private renderItemRow(
328
+ entry: FleetEntryBase,
329
+ prefix: string,
330
+ width: number,
331
+ isSelected: boolean,
332
+ theme: Theme,
333
+ ): string {
334
+ const inlineTitle = strInline(entry.title)
335
+ const processedTitle = this.renderTitle(entry.status, inlineTitle, theme)
336
+ const left = prefix + processedTitle
337
+ const statusCol = theme.fg('accent', entry.status.padStart(7, ' '))
338
+ const followCol = theme.fg(
339
+ 'border',
340
+ ` ${FOLLOW_SYMBOL} ${entry.followUpCount}`.padStart(3, ' '),
341
+ )
342
+ const elapsedCol = theme.fg('muted', formatElapsed(entry).padStart(8, ' '))
343
+ const contextCol = this.renderContextCol(entry, theme)
344
+ const right = `${statusCol}${contextCol}${followCol}${elapsedCol}`
345
+ const leftMaxWidth = Math.max(0, width - visibleWidth(right) - 1)
346
+ const line = rightAlign(truncateText(left, leftMaxWidth), right, width)
347
+ return isSelected ? theme.bg('selectedBg', line) : line
348
+ }
349
+
350
+ private renderContextCol(entry: FleetEntryBase, theme: Theme): string {
351
+ const cu = entry.contextUsage
352
+ if (!cu || cu.contextWindow === 0) return theme.fg('muted', ' '.repeat(12))
353
+ const padded = formatContextUsage(cu).padStart(12, ' ')
354
+ const percent = cu.percent
355
+ const color =
356
+ percent !== null && percent > 90
357
+ ? 'error'
358
+ : percent !== null && percent > 70
359
+ ? 'warning'
360
+ : 'muted'
361
+ return theme.fg(color, padded)
362
+ }
363
+
364
+ private ensureTimer(): void {
365
+ if (!this.timer) {
366
+ this.timer = setInterval(() => this.tui?.requestRender(), TICK_MS)
367
+ }
368
+ }
369
+
370
+ private stopTimer(): void {
371
+ if (this.timer) {
372
+ clearInterval(this.timer)
373
+ this.timer = undefined
374
+ }
375
+ }
376
+ }
@@ -0,0 +1,31 @@
1
+ import { describe, expect, it } from 'vitest'
2
+
3
+ import {
4
+ isSubagentSpawnContext,
5
+ runInSubagentSpawnContext,
6
+ SUBAGENT_SESSION_ID_PREFIX,
7
+ } from './identity.js'
8
+
9
+ describe('subagent spawn context', () => {
10
+ it('is false outside of spawn windows', () => {
11
+ expect(isSubagentSpawnContext()).toBe(false)
12
+ })
13
+
14
+ it('keeps the stable session id prefix', () => {
15
+ expect(SUBAGENT_SESSION_ID_PREFIX).toBe('pi-pm-subagents-subagent-')
16
+ })
17
+
18
+ it('is true inside a spawn window', async () => {
19
+ await runInSubagentSpawnContext(7, async () => {
20
+ expect(isSubagentSpawnContext()).toBe(true)
21
+ })
22
+ expect(isSubagentSpawnContext()).toBe(false)
23
+ })
24
+
25
+ it('propagates across await boundaries', async () => {
26
+ await runInSubagentSpawnContext(3, async () => {
27
+ await new Promise((resolve) => setTimeout(resolve, 0))
28
+ expect(isSubagentSpawnContext()).toBe(true)
29
+ })
30
+ })
31
+ })
@@ -0,0 +1,16 @@
1
+ import { AsyncLocalStorage } from 'node:async_hooks'
2
+
3
+ export const SUBAGENT_SESSION_ID_PREFIX = 'pi-pm-subagents-subagent-'
4
+
5
+ const spawnContext = new AsyncLocalStorage<{ id: number }>()
6
+
7
+ export function isSubagentSpawnContext(): boolean {
8
+ return spawnContext.getStore() !== undefined
9
+ }
10
+
11
+ export function runInSubagentSpawnContext<T>(
12
+ id: number,
13
+ fn: () => Promise<T>,
14
+ ): Promise<T> {
15
+ return spawnContext.run({ id }, fn)
16
+ }