@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,119 @@
1
+ import { readdir } from 'node:fs/promises'
2
+ import { dirname, join } from 'node:path'
3
+ import { fileURLToPath } from 'node:url'
4
+
5
+ import { CONFIG_DIR_NAME, getAgentDir } from '@earendil-works/pi-coding-agent'
6
+
7
+ import { loadMarkdown, type PromptDefinition } from '../utils/markdown.js'
8
+ import { composeTools } from '../utils/tools.js'
9
+
10
+ const DEFAULT_ROLE = 'worker'
11
+
12
+ const roles: Map<string, PromptDefinition> = new Map()
13
+
14
+ export async function loadMarkdownRolesFromDir(
15
+ dir: string,
16
+ ): Promise<Map<string, PromptDefinition>> {
17
+ const loadedRoles = new Map<string, PromptDefinition>()
18
+
19
+ try {
20
+ const entries = await readdir(dir, { withFileTypes: true })
21
+
22
+ await Promise.all(
23
+ entries
24
+ .filter((entry) => entry.isFile() && entry.name.endsWith('.md'))
25
+ .map(async (entry) => {
26
+ const roleName = entry.name.slice(0, -'.md'.length)
27
+ const definition = await loadMarkdown(join(dir, entry.name))
28
+ if (definition) {
29
+ loadedRoles.set(roleName, definition)
30
+ }
31
+ }),
32
+ )
33
+ } catch {
34
+ // Ignore unreadable directories silently
35
+ }
36
+
37
+ return loadedRoles
38
+ }
39
+
40
+ async function loadBuiltins(): Promise<Map<string, PromptDefinition>> {
41
+ return new Map<string, PromptDefinition>([
42
+ [DEFAULT_ROLE, { fm: {}, systemPrompt: '' }],
43
+ ])
44
+ }
45
+
46
+ export interface LoadRolesOptions {
47
+ /** Skip roles bundled with the plugin (agents/ directory in the package root). */
48
+ skipPluginAgents?: boolean
49
+ }
50
+
51
+ export async function loadRoles(
52
+ cwd: string,
53
+ options: LoadRolesOptions = {},
54
+ ): Promise<void> {
55
+ roles.clear()
56
+ const addRoles = (newRoles: Map<string, PromptDefinition>) => {
57
+ for (const [name, role] of newRoles) {
58
+ roles.set(name, role)
59
+ }
60
+ }
61
+
62
+ addRoles(await loadBuiltins())
63
+ if (!options.skipPluginAgents) {
64
+ // Bundled agents live in the package root, next to src/.
65
+ const here = dirname(fileURLToPath(import.meta.url))
66
+ addRoles(await loadMarkdownRolesFromDir(join(here, '../..', 'agents')))
67
+ }
68
+ addRoles(await loadMarkdownRolesFromDir(join(getAgentDir(), 'agents')))
69
+ addRoles(await loadMarkdownRolesFromDir(join(cwd, CONFIG_DIR_NAME, 'agents')))
70
+ }
71
+
72
+ export function resolveRole(name: string = DEFAULT_ROLE): PromptDefinition {
73
+ if (roles.size === 0) {
74
+ throw new Error('Roles not loaded. Call loadRoles() first.')
75
+ }
76
+ const role = roles.get(name)
77
+ if (!role) {
78
+ throw new Error(
79
+ `Role "${name}" not found. Available roles: ${[...roles.keys()].join(', ')}`,
80
+ )
81
+ }
82
+ return role
83
+ }
84
+
85
+ export function listRoles(): string[] {
86
+ return [...roles.keys()]
87
+ }
88
+
89
+ function formatRoleEntry(
90
+ [name, role]: [string, PromptDefinition],
91
+ baseTools: readonly string[],
92
+ ): string {
93
+ const parts: string[] = []
94
+ if (role.fm.description) {
95
+ parts.push(role.fm.description)
96
+ }
97
+ const effective = composeTools(baseTools, {
98
+ tools: role.fm.tools,
99
+ extraTools: role.fm.extraTools,
100
+ removeTools: role.fm.removeTools,
101
+ })
102
+ if (effective.length > 0) parts.push(`tools: ${effective.join(', ')}`)
103
+ if (parts.length > 0) return ` - ${name}: ${parts.join('; ')}`
104
+ return ` - ${name}`
105
+ }
106
+
107
+ export function rolesDescription(baseTools: readonly string[] = []): string {
108
+ if (roles.size === 0) {
109
+ return ''
110
+ }
111
+
112
+ return [...roles.entries()]
113
+ .map((e) => formatRoleEntry(e, baseTools))
114
+ .join('\n')
115
+ }
116
+
117
+ export function clearRoles(): void {
118
+ roles.clear()
119
+ }
@@ -0,0 +1,230 @@
1
+ import { describe, expect, it, vi } from 'vitest'
2
+
3
+ import { ActivityReporter, MAX_ACTIVITY_BYTES } from './activity.js'
4
+ import type { LiveSubagent } from './manager.js'
5
+
6
+ const makeSubagent = (id: number, messages: unknown[]): LiveSubagent =>
7
+ ({
8
+ id,
9
+ title: `task-${id}`,
10
+ text: `task-${id}`,
11
+ status: 'running',
12
+ startedAt: Date.now() - 1000,
13
+ session: { messages: messages as never },
14
+ followUpCount: 0,
15
+ activeTools: [],
16
+ }) as unknown as LiveSubagent
17
+
18
+ const assistant = (text: string) =>
19
+ ({
20
+ role: 'assistant',
21
+ content: [{ type: 'text', text }],
22
+ timestamp: Date.now(),
23
+ }) as unknown as never
24
+
25
+ describe('ActivityReporter.formatActivityReport', () => {
26
+ it('returns last assistant message text', () => {
27
+ const messages = [assistant('Working on it')]
28
+ const subagent = makeSubagent(1, messages)
29
+ const report = ActivityReporter.formatActivityReport(subagent)
30
+ expect(report).toBe('Working on it')
31
+ })
32
+
33
+ it('returns fallback for subagent with no valid last message', () => {
34
+ const subagent = makeSubagent(1, [])
35
+ const report = ActivityReporter.formatActivityReport(subagent)
36
+ expect(report).toBe('(Just started, waiting for first message)')
37
+ })
38
+
39
+ it('truncates long message text to MAX_ACTIVITY_BYTES', () => {
40
+ const longText = 'a'.repeat(MAX_ACTIVITY_BYTES * 2)
41
+ const messages = [assistant(longText)]
42
+ const subagent = makeSubagent(1, messages)
43
+ const report = ActivityReporter.formatActivityReport(subagent)
44
+ expect(report).toContain('[Output truncated')
45
+ })
46
+ })
47
+
48
+ describe('ActivityReporter integration', () => {
49
+ it('starts and stops without errors', () => {
50
+ vi.useFakeTimers()
51
+ let reportCount = 0
52
+
53
+ const activityReporter = new ActivityReporter({
54
+ list: () => [],
55
+ onActivity: () => {
56
+ reportCount += 1
57
+ },
58
+ })
59
+
60
+ activityReporter.start()
61
+ activityReporter.stop()
62
+ vi.advanceTimersByTime(5 * 60 * 1000)
63
+
64
+ expect(reportCount).toBe(0)
65
+ })
66
+
67
+ it('does not call onActivity when no running subagents', () => {
68
+ vi.useFakeTimers()
69
+ const reports: { subagent: LiveSubagent; report: string }[] = []
70
+
71
+ const activityReporter = new ActivityReporter({
72
+ list: () =>
73
+ [
74
+ { id: 1, status: 'done', title: 'Task 1' },
75
+ { id: 2, status: 'failed', title: 'Task 2' },
76
+ ] as LiveSubagent[],
77
+ onActivity: (subagent, report) => reports.push({ subagent, report }),
78
+ checkIntervalMs: 100,
79
+ })
80
+
81
+ activityReporter.start()
82
+ vi.advanceTimersByTime(100)
83
+
84
+ expect(reports).toHaveLength(0)
85
+ })
86
+
87
+ it('calls onActivity with subagent and report when running subagents exist', () => {
88
+ vi.useFakeTimers()
89
+ const reports: { subagent: LiveSubagent; report: string }[] = []
90
+ const subagent = makeSubagent(1, [assistant('Doing task')])
91
+ subagent.startedAt = Date.now() - 60000
92
+
93
+ const activityReporter = new ActivityReporter({
94
+ list: () => [subagent],
95
+ onActivity: (subagent, report) => reports.push({ subagent, report }),
96
+ checkIntervalMs: 100,
97
+ notificationIntervalMs: 1000,
98
+ })
99
+
100
+ activityReporter.start()
101
+ vi.advanceTimersByTime(100)
102
+
103
+ expect(reports).toHaveLength(1)
104
+ expect(reports[0]?.subagent.id).toBe(1)
105
+ expect(reports[0]?.report).toBe('Doing task')
106
+ })
107
+
108
+ it('respects custom checkIntervalMs', () => {
109
+ vi.useFakeTimers()
110
+ const reports: { subagent: LiveSubagent; report: string }[] = []
111
+ const subagent = makeSubagent(1, [])
112
+ subagent.startedAt = Date.now() - 60000
113
+
114
+ const activityReporter = new ActivityReporter({
115
+ list: () => [subagent],
116
+ onActivity: (subagent, report) => reports.push({ subagent, report }),
117
+ checkIntervalMs: 200,
118
+ notificationIntervalMs: 1000,
119
+ })
120
+
121
+ activityReporter.start()
122
+ vi.advanceTimersByTime(199)
123
+ expect(reports).toHaveLength(0)
124
+
125
+ vi.advanceTimersByTime(1)
126
+ expect(reports).toHaveLength(1)
127
+ })
128
+
129
+ it('does not leak timers on multiple starts', () => {
130
+ vi.useFakeTimers()
131
+ let reportCount = 0
132
+ const subagent = makeSubagent(1, [])
133
+ subagent.startedAt = Date.now() - 60000
134
+
135
+ const activityReporter = new ActivityReporter({
136
+ list: () => [subagent],
137
+ onActivity: () => {
138
+ reportCount += 1
139
+ },
140
+ checkIntervalMs: 50,
141
+ notificationIntervalMs: 100,
142
+ })
143
+
144
+ activityReporter.start()
145
+ vi.advanceTimersByTime(50)
146
+ expect(reportCount).toBe(1)
147
+
148
+ vi.advanceTimersByTime(50)
149
+ expect(reportCount).toBe(1)
150
+
151
+ activityReporter.start()
152
+ vi.advanceTimersByTime(50)
153
+ expect(reportCount).toBe(2)
154
+ })
155
+ })
156
+
157
+ describe('ActivityReporter per-subagent throttling', () => {
158
+ it('throttles notifications per subagent based on notificationIntervalMs', () => {
159
+ vi.useFakeTimers()
160
+ const reports: { subagent: LiveSubagent; report: string }[] = []
161
+ const subagent = makeSubagent(1, [])
162
+ subagent.startedAt = Date.now() - 60000
163
+
164
+ const activityReporter = new ActivityReporter({
165
+ list: () => [subagent],
166
+ onActivity: (subagent, report) => reports.push({ subagent, report }),
167
+ checkIntervalMs: 100,
168
+ notificationIntervalMs: 200,
169
+ })
170
+
171
+ activityReporter.start()
172
+ vi.advanceTimersByTime(100)
173
+ expect(reports).toHaveLength(1)
174
+
175
+ vi.advanceTimersByTime(100)
176
+ expect(reports).toHaveLength(1)
177
+
178
+ vi.advanceTimersByTime(100)
179
+ expect(reports).toHaveLength(2)
180
+ })
181
+
182
+ it('tracks per-subagent throttling independently', () => {
183
+ vi.useFakeTimers()
184
+ const reports: { subagent: LiveSubagent; report: string }[] = []
185
+ const s1 = makeSubagent(1, [])
186
+ s1.startedAt = Date.now() - 60000
187
+ const s2 = makeSubagent(2, [])
188
+ s2.startedAt = Date.now() - 60000
189
+
190
+ const activityReporter = new ActivityReporter({
191
+ list: () => [s1, s2],
192
+ onActivity: (subagent, report) => reports.push({ subagent, report }),
193
+ checkIntervalMs: 100,
194
+ notificationIntervalMs: 200,
195
+ })
196
+
197
+ activityReporter.start()
198
+ vi.advanceTimersByTime(100)
199
+ expect(reports).toHaveLength(2)
200
+
201
+ s1.status = 'done'
202
+ vi.advanceTimersByTime(100)
203
+ expect(reports).toHaveLength(2)
204
+
205
+ vi.advanceTimersByTime(100)
206
+ expect(reports).toHaveLength(3)
207
+ expect(reports[2]?.subagent.id).toBe(2)
208
+ })
209
+
210
+ it('does not send report when subagent is not due yet', () => {
211
+ vi.useFakeTimers()
212
+ const reports: { subagent: LiveSubagent; report: string }[] = []
213
+ const subagent = makeSubagent(1, [])
214
+ subagent.startedAt = Date.now() - 1000
215
+
216
+ const activityReporter = new ActivityReporter({
217
+ list: () => [subagent],
218
+ onActivity: (subagent, report) => reports.push({ subagent, report }),
219
+ checkIntervalMs: 100,
220
+ notificationIntervalMs: 1500,
221
+ })
222
+
223
+ activityReporter.start()
224
+ vi.advanceTimersByTime(100)
225
+ expect(reports).toHaveLength(0)
226
+
227
+ vi.advanceTimersByTime(400)
228
+ expect(reports).toHaveLength(1)
229
+ })
230
+ })
@@ -0,0 +1,60 @@
1
+ import { lastMessageText } from '../utils/messages.js'
2
+ import type { LiveSubagent } from './manager.js'
3
+
4
+ const CHECK_INTERVAL_MS = 60 * 1000
5
+ const NOTIFICATION_INTERVAL_MS = 5 * 60 * 1000
6
+ export const MAX_ACTIVITY_BYTES = 1000
7
+
8
+ export class ActivityReporter {
9
+ private timer: ReturnType<typeof setInterval> | undefined
10
+ private lastSentAt = new Map<number, number>()
11
+
12
+ constructor(
13
+ private readonly options: {
14
+ list: () => readonly LiveSubagent[]
15
+ onActivity: (subagent: LiveSubagent, report: string) => void
16
+ checkIntervalMs?: number
17
+ notificationIntervalMs?: number
18
+ },
19
+ ) {}
20
+
21
+ start(): void {
22
+ this.stop()
23
+ const intervalMs = this.options.checkIntervalMs ?? CHECK_INTERVAL_MS
24
+ this.timer = setInterval(() => {
25
+ this.tick()
26
+ }, intervalMs)
27
+ }
28
+
29
+ stop(): void {
30
+ if (this.timer !== undefined) {
31
+ clearInterval(this.timer)
32
+ this.timer = undefined
33
+ }
34
+ }
35
+
36
+ private tick(): void {
37
+ const now = Date.now()
38
+ const notificationMs =
39
+ this.options.notificationIntervalMs ?? NOTIFICATION_INTERVAL_MS
40
+ const runningList = this.options
41
+ .list()
42
+ .filter((w) => w.status === 'running')
43
+ for (const subagent of runningList) {
44
+ const last = this.lastSentAt.get(subagent.id) ?? subagent.startedAt
45
+ if (now - last < notificationMs) continue
46
+ this.options.onActivity(
47
+ subagent,
48
+ ActivityReporter.formatActivityReport(subagent),
49
+ )
50
+ this.lastSentAt.set(subagent.id, now)
51
+ }
52
+ }
53
+
54
+ static formatActivityReport(subagent: LiveSubagent): string {
55
+ return (
56
+ lastMessageText(subagent.session.messages, MAX_ACTIVITY_BYTES) ??
57
+ '(Just started, waiting for first message)'
58
+ )
59
+ }
60
+ }
@@ -0,0 +1,198 @@
1
+ import { afterEach, describe, expect, it, vi } from 'vitest'
2
+
3
+ import { MessageBatcher } from './batcher.js'
4
+ import type { LiveSubagent } from './manager.js'
5
+
6
+ const makeSubagent = (
7
+ id: number,
8
+ title: string,
9
+ status: string,
10
+ ): LiveSubagent =>
11
+ ({
12
+ id,
13
+ title,
14
+ text: title,
15
+ status: status as never,
16
+ startedAt: Date.now() - 5000,
17
+ session: { messages: [] as never },
18
+ followUpCount: 0,
19
+ activeTools: [],
20
+ }) as unknown as LiveSubagent
21
+
22
+ describe('MessageBatcher', () => {
23
+ afterEach(() => {
24
+ vi.useRealTimers()
25
+ })
26
+
27
+ it('flushes after batching window', () => {
28
+ vi.useFakeTimers()
29
+ const flushed: string[][] = []
30
+ const batcher = new MessageBatcher((items) => flushed.push(items), 100)
31
+ const subagent = makeSubagent(1, 'task-1', 'done')
32
+ subagent.contextUsage = {
33
+ tokens: 60000,
34
+ contextWindow: 200000,
35
+ percent: 30,
36
+ }
37
+
38
+ batcher.add(subagent, 'done', 'Task completed')
39
+ expect(batcher.pending.length).toBe(1)
40
+
41
+ vi.advanceTimersByTime(99)
42
+ expect(flushed).toHaveLength(0)
43
+
44
+ vi.advanceTimersByTime(1)
45
+ expect(flushed).toHaveLength(1)
46
+ const firstFlush = flushed[0]
47
+ expect(firstFlush?.[0]).toContain('done #1')
48
+ expect(firstFlush?.[0]).toContain('60k/200k')
49
+ expect(firstFlush?.[0]).toContain('<type>done</type>')
50
+ expect(firstFlush?.[0]).toContain('<message>Task completed</message>')
51
+ expect(batcher.pending).toEqual([])
52
+ })
53
+
54
+ it('combines multiple items in the same window', () => {
55
+ vi.useFakeTimers()
56
+ const flushed: string[][] = []
57
+ const batcher = new MessageBatcher((items) => flushed.push(items), 100)
58
+
59
+ const subagent1 = makeSubagent(1, 'task-1', 'done')
60
+ const subagent2 = makeSubagent(2, 'task-2', 'failed')
61
+
62
+ batcher.add(subagent1, 'done', 'Task 1 completed')
63
+ vi.advanceTimersByTime(50)
64
+ batcher.add(subagent2, 'failed', 'Task 2 failed')
65
+ vi.advanceTimersByTime(100)
66
+
67
+ expect(flushed).toHaveLength(1)
68
+ const firstFlush = flushed[0]
69
+ expect(firstFlush?.length).toBe(2)
70
+ expect(firstFlush?.[0]).toContain('done #1')
71
+ expect(firstFlush?.[0]).toContain('Task 1 completed')
72
+ expect(firstFlush?.[1]).toContain('failed #2')
73
+ expect(firstFlush?.[1]).toContain('Task 2 failed')
74
+ expect(batcher.pending).toEqual([])
75
+ })
76
+
77
+ it('clears pending items after flushNow', () => {
78
+ vi.useFakeTimers()
79
+ const flushed: string[][] = []
80
+ const batcher = new MessageBatcher((items) => flushed.push(items), 100)
81
+
82
+ const subagent1 = makeSubagent(1, 'task-1', 'done')
83
+ batcher.add(subagent1, 'done', 'Task 1 completed')
84
+ batcher.flushNow()
85
+ expect(batcher.pending).toEqual([])
86
+
87
+ const subagent2 = makeSubagent(2, 'task-2', 'done')
88
+ batcher.add(subagent2, 'done', 'Task 2 completed')
89
+ batcher.flushNow()
90
+
91
+ expect(flushed).toHaveLength(2)
92
+ expect(flushed[0]?.[0]).toContain('done #1')
93
+ expect(flushed[1]?.[0]).toContain('done #2')
94
+ expect(batcher.pending).toEqual([])
95
+ })
96
+
97
+ it('flushes immediately when requested via flushNow', () => {
98
+ vi.useFakeTimers()
99
+ const flushed: string[][] = []
100
+ const batcher = new MessageBatcher((items) => flushed.push(items), 100)
101
+
102
+ const subagent = makeSubagent(1, 'task-1', 'done')
103
+ batcher.add(subagent, 'done', 'Task completed')
104
+ batcher.flushNow()
105
+
106
+ expect(flushed).toHaveLength(1)
107
+ const firstFlush = flushed[0]
108
+ expect(firstFlush?.[0]).toContain('done #1')
109
+ vi.advanceTimersByTime(100)
110
+ expect(flushed).toHaveLength(1)
111
+ })
112
+
113
+ it('clears pending items without flushing via clear', () => {
114
+ vi.useFakeTimers()
115
+ const flushed: string[][] = []
116
+ const batcher = new MessageBatcher((items) => flushed.push(items), 100)
117
+
118
+ const subagent = makeSubagent(1, 'task-1', 'done')
119
+ batcher.add(subagent, 'done', 'Task completed')
120
+ batcher.clear()
121
+ vi.advanceTimersByTime(100)
122
+
123
+ expect(flushed).toHaveLength(0)
124
+ expect(batcher.pending).toEqual([])
125
+ })
126
+
127
+ it('adds items in order and flushes them together', () => {
128
+ vi.useFakeTimers()
129
+ const flushed: string[][] = []
130
+ const batcher = new MessageBatcher((items) => flushed.push(items), 100)
131
+
132
+ const subagent1 = makeSubagent(1, 'task-1', 'done')
133
+ const subagent2 = makeSubagent(2, 'task-2', 'running')
134
+
135
+ batcher.add(subagent1, 'done', 'Subagent #1 work done.')
136
+ batcher.add(subagent2, 'activity', 'Subagent activity update')
137
+ vi.advanceTimersByTime(100)
138
+
139
+ expect(flushed).toHaveLength(1)
140
+ const firstFlush = flushed[0]
141
+ expect(firstFlush?.length).toBe(2)
142
+ expect(firstFlush?.[0]).toContain('done #1')
143
+ expect(firstFlush?.[0]).toContain('Subagent #1 work done.')
144
+ expect(firstFlush?.[1]).toContain('running #2')
145
+ expect(firstFlush?.[1]).toContain('Subagent activity update')
146
+ })
147
+
148
+ it('resets timer on each add', () => {
149
+ vi.useFakeTimers()
150
+ const flushed: string[][] = []
151
+ const batcher = new MessageBatcher((items) => flushed.push(items), 100)
152
+
153
+ const subagent1 = makeSubagent(1, 'task-1', 'done')
154
+ const subagent2 = makeSubagent(2, 'task-2', 'running')
155
+
156
+ batcher.add(subagent1, 'done', 'First message')
157
+ vi.advanceTimersByTime(50)
158
+ expect(flushed).toHaveLength(0)
159
+
160
+ batcher.add(subagent2, 'activity', 'Second message')
161
+ vi.advanceTimersByTime(50)
162
+ expect(flushed).toHaveLength(0)
163
+
164
+ vi.advanceTimersByTime(50)
165
+ expect(flushed).toHaveLength(1)
166
+
167
+ const firstFlush = flushed[0]
168
+ expect(firstFlush?.length).toBe(2)
169
+ expect(firstFlush?.[0]).toContain('done #1')
170
+ expect(firstFlush?.[0]).toContain('First message')
171
+ expect(firstFlush?.[1]).toContain('running #2')
172
+ expect(firstFlush?.[1]).toContain('Second message')
173
+ })
174
+
175
+ it('clears old timer and starts new one when adding item', () => {
176
+ vi.useFakeTimers()
177
+ const flushed: string[][] = []
178
+ const batcher = new MessageBatcher((items) => flushed.push(items), 100)
179
+
180
+ const subagent = makeSubagent(1, 'task-1', 'done')
181
+
182
+ batcher.add(subagent, 'done', 'Message 1')
183
+ vi.advanceTimersByTime(80)
184
+ expect(flushed).toHaveLength(0)
185
+
186
+ batcher.add(subagent, 'activity', 'Message 2')
187
+ vi.advanceTimersByTime(80)
188
+ expect(flushed).toHaveLength(0)
189
+
190
+ vi.advanceTimersByTime(20)
191
+ expect(flushed).toHaveLength(1)
192
+
193
+ const firstFlush = flushed[0]
194
+ expect(firstFlush?.length).toBe(2)
195
+ expect(firstFlush?.[0]).toContain('Message 1')
196
+ expect(firstFlush?.[1]).toContain('Message 2')
197
+ })
198
+ })
@@ -0,0 +1,51 @@
1
+ import { formatSubagentSummary, type LiveSubagent } from './manager.js'
2
+
3
+ export class MessageBatcher {
4
+ private items: string[] = []
5
+ private timer: ReturnType<typeof setTimeout> | undefined
6
+
7
+ constructor(
8
+ private readonly flush: (items: string[]) => void,
9
+ private readonly windowMs = 3000,
10
+ ) {}
11
+
12
+ get pending(): readonly string[] {
13
+ return [...this.items]
14
+ }
15
+
16
+ add(subagent: LiveSubagent, type: string, message: string): void {
17
+ const tagName = type === 'done' ? 'subagent-done' : 'subagent-notify'
18
+ const item = [
19
+ `<${tagName}>`,
20
+ `<type>${type}</type>`,
21
+ `<job>${formatSubagentSummary(subagent)}</job>`,
22
+ `<message>${message}</message>`,
23
+ `</${tagName}>`,
24
+ ].join('\n')
25
+ this.items.push(item)
26
+ if (this.timer !== undefined) clearTimeout(this.timer)
27
+ this.timer = setTimeout(() => {
28
+ this.flushNow()
29
+ }, this.windowMs)
30
+ }
31
+
32
+ flushNow(): void {
33
+ if (this.timer !== undefined) {
34
+ clearTimeout(this.timer)
35
+ this.timer = undefined
36
+ }
37
+ if (this.items.length === 0) return
38
+
39
+ const items = this.items
40
+ this.items = []
41
+ this.flush(items)
42
+ }
43
+
44
+ clear(): void {
45
+ if (this.timer !== undefined) {
46
+ clearTimeout(this.timer)
47
+ this.timer = undefined
48
+ }
49
+ this.items = []
50
+ }
51
+ }
@@ -0,0 +1 @@
1
+ export const FOLLOW_SYMBOL = '⟳'