@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,314 @@
|
|
|
1
|
+
import {
|
|
2
|
+
type AgentToolResult,
|
|
3
|
+
defineTool,
|
|
4
|
+
type ExtensionAPI,
|
|
5
|
+
} from '@earendil-works/pi-coding-agent'
|
|
6
|
+
import { orderBy } from 'lodash-es'
|
|
7
|
+
import { Type } from 'typebox'
|
|
8
|
+
|
|
9
|
+
import { resolveSubagentModelForSpawn } from '../models-config/subagent-model-utils.js'
|
|
10
|
+
import { baseToolsOf } from '../pm-mode.js'
|
|
11
|
+
import { resolveRole, rolesDescription } from '../prompts/roles.js'
|
|
12
|
+
import type { PmSubagentState } from '../types.js'
|
|
13
|
+
import { askHowToProceed } from '../ui/review-pager.js'
|
|
14
|
+
import { composeTools, registerOptionalTools } from '../utils/tools.js'
|
|
15
|
+
import type { MessageBatcher } from './batcher.js'
|
|
16
|
+
import type { FleetList } from './fleet.js'
|
|
17
|
+
import type { SubagentManager } from './manager.js'
|
|
18
|
+
import type { LiveSubagent } from './manager.js'
|
|
19
|
+
import {
|
|
20
|
+
formatSubagentSummary,
|
|
21
|
+
MAX_CONCURRENCY_SUBAGENT,
|
|
22
|
+
MAX_REUSE_FOLLOWUPS,
|
|
23
|
+
} from './manager.js'
|
|
24
|
+
|
|
25
|
+
function toolResultFromError(error: unknown): AgentToolResult<unknown> {
|
|
26
|
+
const message = error instanceof Error ? error.message : String(error)
|
|
27
|
+
return { content: [{ type: 'text', text: message }], details: {} }
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
const stopSubagentOnAbort = (
|
|
31
|
+
signal: AbortSignal | undefined,
|
|
32
|
+
id: number,
|
|
33
|
+
manager: SubagentManager,
|
|
34
|
+
): void => {
|
|
35
|
+
if (!signal) return
|
|
36
|
+
const stop = (): void => {
|
|
37
|
+
void manager.abort(id)
|
|
38
|
+
}
|
|
39
|
+
if (signal.aborted) stop()
|
|
40
|
+
else signal.addEventListener('abort', stop, { once: true })
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export const SUBAGENT_TOOLS = {
|
|
44
|
+
delegate: 'subagent_delegate',
|
|
45
|
+
followup: 'subagent_followup',
|
|
46
|
+
kill: 'subagent_kill',
|
|
47
|
+
list: 'subagent_list',
|
|
48
|
+
} as const
|
|
49
|
+
|
|
50
|
+
const LIST_COOL_DOWN_MS = 1 * 60 * 1000
|
|
51
|
+
|
|
52
|
+
export function registerSubagentTools(
|
|
53
|
+
pi: ExtensionAPI,
|
|
54
|
+
state: PmSubagentState,
|
|
55
|
+
manager: SubagentManager,
|
|
56
|
+
fleet: FleetList,
|
|
57
|
+
batcher: MessageBatcher,
|
|
58
|
+
): void {
|
|
59
|
+
let lastListAt = Date.now()
|
|
60
|
+
|
|
61
|
+
pi.on('session_start', () => {
|
|
62
|
+
const tools = [
|
|
63
|
+
defineTool({
|
|
64
|
+
name: SUBAGENT_TOOLS.list,
|
|
65
|
+
label: 'List Subagents',
|
|
66
|
+
description: `List all background subagents, don't poll this tool to wait subagents complete, just wait silently`,
|
|
67
|
+
parameters: Type.Object({}),
|
|
68
|
+
async execute() {
|
|
69
|
+
if (Date.now() - lastListAt < LIST_COOL_DOWN_MS)
|
|
70
|
+
return {
|
|
71
|
+
content: [
|
|
72
|
+
{
|
|
73
|
+
type: 'text',
|
|
74
|
+
text: "Please don't poll for this tool, just wait silently",
|
|
75
|
+
},
|
|
76
|
+
],
|
|
77
|
+
details: {},
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
lastListAt = Date.now()
|
|
81
|
+
|
|
82
|
+
const allSubagents = manager.list()
|
|
83
|
+
if (allSubagents.length === 0) {
|
|
84
|
+
return {
|
|
85
|
+
content: [{ type: 'text', text: 'No subagents.' }],
|
|
86
|
+
details: {},
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
const sorted = orderBy(allSubagents, (it) => it.id, 'desc')
|
|
91
|
+
const lines = [`Subagents (${allSubagents.length}):`]
|
|
92
|
+
for (const subagent of sorted) {
|
|
93
|
+
lines.push(formatSubagentSummary(subagent))
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
const hasRunning = sorted.some(
|
|
97
|
+
(subagent) => subagent.status === 'running',
|
|
98
|
+
)
|
|
99
|
+
if (hasRunning)
|
|
100
|
+
lines.push(
|
|
101
|
+
'',
|
|
102
|
+
`DO NOT POLL THIS TOOL TO WAIT FOR SUBAGENTS COMPLETION, JUST WAIT SILENTLY, YOU WILL BE NOTIFIED WHEN SUBAGENTS FINISH.`,
|
|
103
|
+
)
|
|
104
|
+
return {
|
|
105
|
+
content: [
|
|
106
|
+
{
|
|
107
|
+
type: 'text',
|
|
108
|
+
text: lines.join('\n'),
|
|
109
|
+
},
|
|
110
|
+
],
|
|
111
|
+
details: {},
|
|
112
|
+
}
|
|
113
|
+
},
|
|
114
|
+
}),
|
|
115
|
+
|
|
116
|
+
defineTool({
|
|
117
|
+
name: SUBAGENT_TOOLS.delegate,
|
|
118
|
+
label: 'Delegate Subagent',
|
|
119
|
+
description: `Delegate task to background with full tool access. The tool returns immediately with a subagent id; I'll send you last message when subagent finishes. Max concurrency ${MAX_CONCURRENCY_SUBAGENT} running subagents\nAvailable roles:\n${rolesDescription(baseToolsOf(pi, state))}`,
|
|
120
|
+
parameters: Type.Object({
|
|
121
|
+
title: Type.String(),
|
|
122
|
+
prompt: Type.String({
|
|
123
|
+
description: 'Prompt of the subagent should do',
|
|
124
|
+
}),
|
|
125
|
+
role: Type.String({
|
|
126
|
+
description: `Role of subagent`,
|
|
127
|
+
}),
|
|
128
|
+
}),
|
|
129
|
+
async execute(
|
|
130
|
+
_toolCallId,
|
|
131
|
+
params,
|
|
132
|
+
signal,
|
|
133
|
+
_onUpdate,
|
|
134
|
+
ctx,
|
|
135
|
+
): Promise<AgentToolResult<unknown>> {
|
|
136
|
+
const role = resolveRole(params.role)
|
|
137
|
+
const reviewOnEnd = role.fm.reviewOnEnd ?? false
|
|
138
|
+
|
|
139
|
+
const tools = composeTools(baseToolsOf(pi, state), {
|
|
140
|
+
tools: role.fm.tools,
|
|
141
|
+
extraTools: role.fm.extraTools,
|
|
142
|
+
removeTools: role.fm.removeTools,
|
|
143
|
+
})
|
|
144
|
+
|
|
145
|
+
const model = resolveSubagentModelForSpawn(
|
|
146
|
+
ctx,
|
|
147
|
+
role.fm.model,
|
|
148
|
+
state.sessionSubagentModel,
|
|
149
|
+
)
|
|
150
|
+
|
|
151
|
+
let subagent: LiveSubagent
|
|
152
|
+
try {
|
|
153
|
+
subagent = await manager.createNewSubagent(
|
|
154
|
+
params.title,
|
|
155
|
+
params.prompt,
|
|
156
|
+
{
|
|
157
|
+
cwd: ctx.cwd,
|
|
158
|
+
model,
|
|
159
|
+
thinkingLevel: role.fm.thinkingLevel ?? 'low',
|
|
160
|
+
tools,
|
|
161
|
+
systemPrompt: role.systemPrompt,
|
|
162
|
+
role: params.role,
|
|
163
|
+
onComplete: async (subagent, lastMessage) => {
|
|
164
|
+
if (state.mode !== 'coordinator') return
|
|
165
|
+
if (subagent.status === 'killed') return
|
|
166
|
+
if (!lastMessage) return
|
|
167
|
+
if (subagent.status === 'failed') {
|
|
168
|
+
batcher.add(subagent, 'done', lastMessage)
|
|
169
|
+
return
|
|
170
|
+
}
|
|
171
|
+
if (!reviewOnEnd || !ctx.hasUI) {
|
|
172
|
+
batcher.add(subagent, 'done', lastMessage)
|
|
173
|
+
return
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
await askHowToProceed(ctx, {
|
|
177
|
+
title: '📋 Planner Review',
|
|
178
|
+
plan: lastMessage,
|
|
179
|
+
choices: [
|
|
180
|
+
{
|
|
181
|
+
id: 'send-to-coordinator',
|
|
182
|
+
label: 'Send plan to coordinator',
|
|
183
|
+
action: () => {
|
|
184
|
+
batcher.add(subagent, 'done', lastMessage)
|
|
185
|
+
},
|
|
186
|
+
},
|
|
187
|
+
{
|
|
188
|
+
id: 'update-the-plan',
|
|
189
|
+
label: 'Update the plan',
|
|
190
|
+
action: async () => {
|
|
191
|
+
const updatePrompt = await ctx.ui.editor(
|
|
192
|
+
'Update the plan:',
|
|
193
|
+
'',
|
|
194
|
+
)
|
|
195
|
+
if (updatePrompt?.trim()) {
|
|
196
|
+
await manager.followup(
|
|
197
|
+
subagent.id,
|
|
198
|
+
`${subagent.title} (revised)`,
|
|
199
|
+
`Update the plan based on:\n\n${updatePrompt.trim()}`,
|
|
200
|
+
)
|
|
201
|
+
}
|
|
202
|
+
},
|
|
203
|
+
},
|
|
204
|
+
{
|
|
205
|
+
id: 'discard',
|
|
206
|
+
label: 'Discard',
|
|
207
|
+
action: () => {},
|
|
208
|
+
},
|
|
209
|
+
],
|
|
210
|
+
})
|
|
211
|
+
},
|
|
212
|
+
},
|
|
213
|
+
)
|
|
214
|
+
fleet.update()
|
|
215
|
+
} catch (error) {
|
|
216
|
+
return toolResultFromError(error)
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
stopSubagentOnAbort(signal, subagent.id, manager)
|
|
220
|
+
|
|
221
|
+
return {
|
|
222
|
+
content: [
|
|
223
|
+
{
|
|
224
|
+
type: 'text',
|
|
225
|
+
text: `Subagent id #${subagent.id} running at background. I'll send you last message when it finishes.`,
|
|
226
|
+
},
|
|
227
|
+
],
|
|
228
|
+
details: { subagentId: subagent.id, status: subagent.status },
|
|
229
|
+
}
|
|
230
|
+
},
|
|
231
|
+
}),
|
|
232
|
+
|
|
233
|
+
defineTool({
|
|
234
|
+
name: SUBAGENT_TOOLS.followup,
|
|
235
|
+
label: 'Follow Up Subagent',
|
|
236
|
+
description: `Continue working with an existing subagent. Max reuse ${MAX_REUSE_FOLLOWUPS} times.`,
|
|
237
|
+
parameters: Type.Object({
|
|
238
|
+
id: Type.Number(),
|
|
239
|
+
title: Type.String(),
|
|
240
|
+
prompt: Type.String({
|
|
241
|
+
description: 'Prompt of the subagent should do',
|
|
242
|
+
}),
|
|
243
|
+
}),
|
|
244
|
+
async execute(
|
|
245
|
+
_toolCallId,
|
|
246
|
+
params,
|
|
247
|
+
signal,
|
|
248
|
+
): Promise<AgentToolResult<unknown>> {
|
|
249
|
+
let subagent: LiveSubagent
|
|
250
|
+
try {
|
|
251
|
+
subagent = await manager.followup(
|
|
252
|
+
params.id,
|
|
253
|
+
params.title,
|
|
254
|
+
params.prompt,
|
|
255
|
+
)
|
|
256
|
+
} catch (error) {
|
|
257
|
+
return toolResultFromError(error)
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
stopSubagentOnAbort(signal, subagent.id, manager)
|
|
261
|
+
|
|
262
|
+
return {
|
|
263
|
+
content: [
|
|
264
|
+
{
|
|
265
|
+
type: 'text',
|
|
266
|
+
text: `Subagent #${subagent.id} continued. I'll send you last message when it finishes.`,
|
|
267
|
+
},
|
|
268
|
+
],
|
|
269
|
+
details: { subagentId: subagent.id, status: subagent.status },
|
|
270
|
+
}
|
|
271
|
+
},
|
|
272
|
+
}),
|
|
273
|
+
|
|
274
|
+
defineTool({
|
|
275
|
+
name: SUBAGENT_TOOLS.kill,
|
|
276
|
+
label: 'Kill Subagent',
|
|
277
|
+
description: 'Kill a running subagent by id.',
|
|
278
|
+
parameters: Type.Object({
|
|
279
|
+
id: Type.Number({ description: 'Subagent id to stop.' }),
|
|
280
|
+
}),
|
|
281
|
+
async execute(_toolCallId, params): Promise<AgentToolResult<unknown>> {
|
|
282
|
+
const subagent = manager.get(params.id)
|
|
283
|
+
if (!subagent) {
|
|
284
|
+
return {
|
|
285
|
+
content: [
|
|
286
|
+
{ type: 'text', text: `Subagent #${params.id} not found.` },
|
|
287
|
+
],
|
|
288
|
+
details: {},
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
try {
|
|
292
|
+
const stopped = await manager.abort(params.id)
|
|
293
|
+
const message = stopped
|
|
294
|
+
? `Subagent #${params.id} killed.`
|
|
295
|
+
: `Subagent #${params.id} is not running (status: ${subagent.status}).`
|
|
296
|
+
return {
|
|
297
|
+
content: [{ type: 'text', text: message }],
|
|
298
|
+
details: { subagentId: params.id, status: subagent.status },
|
|
299
|
+
}
|
|
300
|
+
} catch (error) {
|
|
301
|
+
const message =
|
|
302
|
+
error instanceof Error ? error.message : String(error)
|
|
303
|
+
return {
|
|
304
|
+
content: [{ type: 'text', text: message }],
|
|
305
|
+
details: { subagentId: params.id, status: subagent.status },
|
|
306
|
+
}
|
|
307
|
+
}
|
|
308
|
+
},
|
|
309
|
+
}),
|
|
310
|
+
]
|
|
311
|
+
|
|
312
|
+
registerOptionalTools(pi, tools, true)
|
|
313
|
+
})
|
|
314
|
+
}
|
|
@@ -0,0 +1,305 @@
|
|
|
1
|
+
import type { UserMessage } from '@earendil-works/pi-ai'
|
|
2
|
+
import type {
|
|
3
|
+
ExtensionContext,
|
|
4
|
+
Theme,
|
|
5
|
+
ThemeColor,
|
|
6
|
+
} from '@earendil-works/pi-coding-agent'
|
|
7
|
+
import type { Component, OverlayHandle, TUI } from '@earendil-works/pi-tui'
|
|
8
|
+
import {
|
|
9
|
+
isKeyRelease,
|
|
10
|
+
Key,
|
|
11
|
+
matchesKey,
|
|
12
|
+
visibleWidth,
|
|
13
|
+
wrapTextWithAnsi,
|
|
14
|
+
} from '@earendil-works/pi-tui'
|
|
15
|
+
|
|
16
|
+
import { BorderView } from '../ui/border-view.js'
|
|
17
|
+
import { ScrollView } from '../ui/scroll-view.js'
|
|
18
|
+
import { rightAlign, strInline } from '../utils/format.js'
|
|
19
|
+
import { truncateText } from '../utils/truncate.js'
|
|
20
|
+
import type {
|
|
21
|
+
LiveSubagent,
|
|
22
|
+
SubagentManager,
|
|
23
|
+
SubagentStatus,
|
|
24
|
+
} from './manager.js'
|
|
25
|
+
|
|
26
|
+
const STATUS_COLOR = {
|
|
27
|
+
running: 'accent',
|
|
28
|
+
done: 'success',
|
|
29
|
+
failed: 'error',
|
|
30
|
+
killed: 'dim',
|
|
31
|
+
} satisfies Record<SubagentStatus, ThemeColor>
|
|
32
|
+
|
|
33
|
+
let openedViewerHandle: OverlayHandle | undefined
|
|
34
|
+
|
|
35
|
+
const TOOL_RESULT_PREVIEW = 500
|
|
36
|
+
const VIEWPORT_HEIGHT_PCT = 80
|
|
37
|
+
const VIEWER_CHROME_LINES = 7
|
|
38
|
+
const OVERLAY_WIDTH_PCT = '90%'
|
|
39
|
+
|
|
40
|
+
export type ViewerResult = undefined | 'steer'
|
|
41
|
+
|
|
42
|
+
export class SubagentViewer implements Component {
|
|
43
|
+
#stopArmed = false
|
|
44
|
+
readonly #scroll: ScrollView
|
|
45
|
+
readonly #unsubscribe: () => void
|
|
46
|
+
|
|
47
|
+
constructor(
|
|
48
|
+
private tui: TUI,
|
|
49
|
+
private theme: Theme,
|
|
50
|
+
private subagent: LiveSubagent,
|
|
51
|
+
private manager: SubagentManager,
|
|
52
|
+
private done: (result: ViewerResult) => void,
|
|
53
|
+
) {
|
|
54
|
+
this.#scroll = new ScrollView(tui, theme, {
|
|
55
|
+
child: {
|
|
56
|
+
render: (width) => this.renderContent(width),
|
|
57
|
+
invalidate: () => {},
|
|
58
|
+
},
|
|
59
|
+
viewportHeight: () =>
|
|
60
|
+
Math.max(
|
|
61
|
+
3,
|
|
62
|
+
Math.floor((tui.terminal.rows * VIEWPORT_HEIGHT_PCT) / 100) -
|
|
63
|
+
VIEWER_CHROME_LINES,
|
|
64
|
+
),
|
|
65
|
+
autoFollow: true,
|
|
66
|
+
})
|
|
67
|
+
this.#unsubscribe = subagent.session.subscribe(() => {
|
|
68
|
+
tui.requestRender()
|
|
69
|
+
})
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
handleInput(data: string): void {
|
|
73
|
+
if (isKeyRelease(data)) return
|
|
74
|
+
|
|
75
|
+
if (matchesKey(data, Key.escape) || data === 'q') {
|
|
76
|
+
this.done(undefined)
|
|
77
|
+
return
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
if (data === 'x' && this.subagent.status === 'running') {
|
|
81
|
+
if (this.#stopArmed) {
|
|
82
|
+
this.#stopArmed = false
|
|
83
|
+
void this.manager.abort(this.subagent.id).then(() => {
|
|
84
|
+
this.tui.requestRender()
|
|
85
|
+
})
|
|
86
|
+
} else {
|
|
87
|
+
this.#stopArmed = true
|
|
88
|
+
}
|
|
89
|
+
this.tui.requestRender()
|
|
90
|
+
return
|
|
91
|
+
}
|
|
92
|
+
if (this.#stopArmed) this.#stopArmed = false
|
|
93
|
+
|
|
94
|
+
if (matchesKey(data, Key.enter) && this.subagent.status === 'running') {
|
|
95
|
+
this.done('steer')
|
|
96
|
+
return
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
this.#scroll.handleInput(data)
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
render(width: number): string[] {
|
|
103
|
+
if (width < 4) return []
|
|
104
|
+
const separator = this.theme.fg('dim', '─'.repeat(width))
|
|
105
|
+
const lines = [this.headerLine(width)]
|
|
106
|
+
const tools = this.toolsLine(width)
|
|
107
|
+
if (tools) lines.push(tools)
|
|
108
|
+
lines.push(
|
|
109
|
+
separator,
|
|
110
|
+
...this.#scroll.render(width),
|
|
111
|
+
separator,
|
|
112
|
+
this.footerLine(width),
|
|
113
|
+
)
|
|
114
|
+
return lines
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
invalidate(): void {
|
|
118
|
+
this.#scroll.invalidate()
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
dispose(): void {
|
|
122
|
+
this.#unsubscribe()
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
private headerLine(width: number): string {
|
|
126
|
+
const th = this.theme
|
|
127
|
+
const status = this.subagent.status
|
|
128
|
+
const color = STATUS_COLOR[status]
|
|
129
|
+
const id = `#${this.subagent.id}`
|
|
130
|
+
const role = `[${this.subagent.role}]`
|
|
131
|
+
const titleMaxWidth = width - visibleWidth(`${status + id} ${role}`) - 1
|
|
132
|
+
return rightAlign(
|
|
133
|
+
`${th.fg('muted', `#${this.subagent.id}`)} ${th.fg('muted', role)} ${truncateText(strInline(this.subagent.title), titleMaxWidth)}`,
|
|
134
|
+
th.fg(color, status),
|
|
135
|
+
width,
|
|
136
|
+
)
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
private toolsLine(width: number): string {
|
|
140
|
+
const th = this.theme
|
|
141
|
+
const tools = this.subagent.activeTools
|
|
142
|
+
if (tools.length === 0) return ''
|
|
143
|
+
const sep = th.fg('dim', ' · ')
|
|
144
|
+
return rightAlign(
|
|
145
|
+
'',
|
|
146
|
+
`Tools: ${tools.map((name) => th.fg('muted', name)).join(sep)}`,
|
|
147
|
+
width,
|
|
148
|
+
)
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
private footerLine(width: number): string {
|
|
152
|
+
const th = this.theme
|
|
153
|
+
const running = this.subagent.status === 'running'
|
|
154
|
+
const sep = th.fg('dim', ' · ')
|
|
155
|
+
const keys: [string, string][] = []
|
|
156
|
+
if (running) {
|
|
157
|
+
keys.push(this.#stopArmed ? ['x', 'again to STOP'] : ['x', 'stop'], [
|
|
158
|
+
'enter',
|
|
159
|
+
'steer',
|
|
160
|
+
])
|
|
161
|
+
}
|
|
162
|
+
keys.push(
|
|
163
|
+
['j/k ↑/↓', 'line'],
|
|
164
|
+
['u/d ␣', 'PageUp/Dn page'],
|
|
165
|
+
['g/G', 'Home/End jump'],
|
|
166
|
+
['q/esc', 'close'],
|
|
167
|
+
)
|
|
168
|
+
return truncateText(
|
|
169
|
+
keys
|
|
170
|
+
.map(
|
|
171
|
+
([key, desc]) =>
|
|
172
|
+
`${th.fg('syntaxKeyword', key)} ${th.fg('success', desc)}`,
|
|
173
|
+
)
|
|
174
|
+
.join(sep),
|
|
175
|
+
width,
|
|
176
|
+
)
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
private renderContent(width: number): string[] {
|
|
180
|
+
if (width <= 0) return []
|
|
181
|
+
const th = this.theme
|
|
182
|
+
const messages = this.subagent.session.messages
|
|
183
|
+
if (messages.length === 0)
|
|
184
|
+
return [th.fg('dim', '(waiting for first message…)')]
|
|
185
|
+
|
|
186
|
+
const separatorLine = th.fg('dim', '─'.repeat(width))
|
|
187
|
+
const lines: string[] = []
|
|
188
|
+
let separator = false
|
|
189
|
+
for (const message of messages) {
|
|
190
|
+
if (message.role === 'user') {
|
|
191
|
+
const text = userText(message)
|
|
192
|
+
if (!text.trim()) continue
|
|
193
|
+
if (separator) lines.push(separatorLine)
|
|
194
|
+
lines.push(th.fg('accent', th.bold('[user]')))
|
|
195
|
+
lines.push(...wrapTextWithAnsi(text.trim(), width))
|
|
196
|
+
} else if (message.role === 'assistant') {
|
|
197
|
+
const text: string[] = []
|
|
198
|
+
const tools: { name: string; params: string }[] = []
|
|
199
|
+
for (const block of message.content) {
|
|
200
|
+
if (block.type === 'text' && block.text) text.push(block.text)
|
|
201
|
+
else if (block.type === 'toolCall')
|
|
202
|
+
tools.push({
|
|
203
|
+
name: block.name,
|
|
204
|
+
params: JSON.stringify(block.arguments),
|
|
205
|
+
})
|
|
206
|
+
}
|
|
207
|
+
if (text.length === 0 && tools.length === 0) continue
|
|
208
|
+
if (separator) lines.push(separatorLine)
|
|
209
|
+
lines.push(th.bold('[assistant]'))
|
|
210
|
+
if (text.length > 0)
|
|
211
|
+
lines.push(...wrapTextWithAnsi(text.join('\n').trim(), width))
|
|
212
|
+
for (const { name, params } of tools) {
|
|
213
|
+
lines.push(
|
|
214
|
+
truncateText(
|
|
215
|
+
`${th.fg('muted', `🔧 ${name}`)} ${th.fg('dim', params)}`,
|
|
216
|
+
width,
|
|
217
|
+
),
|
|
218
|
+
)
|
|
219
|
+
}
|
|
220
|
+
} else if (message.role === 'toolResult') {
|
|
221
|
+
const raw = message.content
|
|
222
|
+
.filter(
|
|
223
|
+
(block): block is { type: 'text'; text: string } =>
|
|
224
|
+
block.type === 'text',
|
|
225
|
+
)
|
|
226
|
+
.map((block) => block.text)
|
|
227
|
+
.join('\n')
|
|
228
|
+
.trim()
|
|
229
|
+
if (!raw) continue
|
|
230
|
+
const preview =
|
|
231
|
+
raw.length > TOOL_RESULT_PREVIEW
|
|
232
|
+
? `${raw.slice(0, TOOL_RESULT_PREVIEW)}…`
|
|
233
|
+
: raw
|
|
234
|
+
if (separator) lines.push(separatorLine)
|
|
235
|
+
lines.push(th.fg('dim', '[result]'))
|
|
236
|
+
lines.push(
|
|
237
|
+
...wrapTextWithAnsi(preview, width).map((line) => th.fg('dim', line)),
|
|
238
|
+
)
|
|
239
|
+
}
|
|
240
|
+
separator = true
|
|
241
|
+
}
|
|
242
|
+
return lines.map((line) => truncateText(line, width))
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
function userText(message: UserMessage): string {
|
|
247
|
+
return typeof message.content === 'string'
|
|
248
|
+
? message.content
|
|
249
|
+
: message.content
|
|
250
|
+
.filter(
|
|
251
|
+
(block): block is { type: 'text'; text: string } =>
|
|
252
|
+
block.type === 'text',
|
|
253
|
+
)
|
|
254
|
+
.map((block) => block.text)
|
|
255
|
+
.join('\n')
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
export async function openSubagentViewer(
|
|
259
|
+
ctx: ExtensionContext,
|
|
260
|
+
manager: SubagentManager,
|
|
261
|
+
id: number,
|
|
262
|
+
): Promise<void> {
|
|
263
|
+
const subagent = manager.get(id)
|
|
264
|
+
if (!subagent) {
|
|
265
|
+
ctx.ui.notify(`Subagent #${id} not found.`, 'warning')
|
|
266
|
+
return
|
|
267
|
+
}
|
|
268
|
+
openedViewerHandle?.hide()
|
|
269
|
+
ctx.ui.setWorkingVisible(false)
|
|
270
|
+
let result: ViewerResult
|
|
271
|
+
try {
|
|
272
|
+
result = await ctx.ui.custom<ViewerResult>(
|
|
273
|
+
(tui, theme, _keybindings, done) =>
|
|
274
|
+
new BorderView(theme, {
|
|
275
|
+
child: new SubagentViewer(tui, theme, subagent, manager, done),
|
|
276
|
+
}),
|
|
277
|
+
{
|
|
278
|
+
overlay: true,
|
|
279
|
+
overlayOptions: {
|
|
280
|
+
anchor: 'center',
|
|
281
|
+
width: OVERLAY_WIDTH_PCT,
|
|
282
|
+
maxHeight: `${VIEWPORT_HEIGHT_PCT}%`,
|
|
283
|
+
},
|
|
284
|
+
onHandle: (handle) => {
|
|
285
|
+
openedViewerHandle = handle
|
|
286
|
+
},
|
|
287
|
+
},
|
|
288
|
+
)
|
|
289
|
+
} finally {
|
|
290
|
+
openedViewerHandle = undefined
|
|
291
|
+
ctx.ui.setWorkingVisible(true)
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
if (result !== 'steer') return
|
|
295
|
+
const message = await ctx.ui.editor(`Steer subagent #${id}:`, '')
|
|
296
|
+
const trimmed = message?.trim()
|
|
297
|
+
if (trimmed) {
|
|
298
|
+
const ok = await manager.steer(id, trimmed)
|
|
299
|
+
ctx.ui.notify(
|
|
300
|
+
ok ? `Steered subagent #${id}.` : `Subagent #${id} is no longer running.`,
|
|
301
|
+
ok ? 'info' : 'warning',
|
|
302
|
+
)
|
|
303
|
+
}
|
|
304
|
+
return openSubagentViewer(ctx, manager, id)
|
|
305
|
+
}
|
package/src/types.ts
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
export type PmMode = 'coordinator'
|
|
2
|
+
|
|
3
|
+
export interface ModeToolsDiff {
|
|
4
|
+
added: string[]
|
|
5
|
+
removed: string[]
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
export interface PmSubagentState {
|
|
9
|
+
mode: PmMode | undefined
|
|
10
|
+
modeDiffTools?: ModeToolsDiff
|
|
11
|
+
/** Current model captured before entering a read-only mode, restored on exit. */
|
|
12
|
+
previousModel?: string
|
|
13
|
+
/** Session-scoped subagent model. Source of truth for spawn. */
|
|
14
|
+
sessionSubagentModel?: string
|
|
15
|
+
}
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import type { Theme } from '@earendil-works/pi-coding-agent'
|
|
2
|
+
import type { Component } from '@earendil-works/pi-tui'
|
|
3
|
+
|
|
4
|
+
import { truncateText } from '../utils/truncate.js'
|
|
5
|
+
|
|
6
|
+
export interface BorderViewOptions {
|
|
7
|
+
readonly child: Component
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* A border wrapper for pi-tui components. Adds a border around the child
|
|
12
|
+
* component while preserving all its functionality (render, handleInput, invalidate).
|
|
13
|
+
*/
|
|
14
|
+
export class BorderView implements Component {
|
|
15
|
+
readonly #child: Component
|
|
16
|
+
readonly #theme: Theme
|
|
17
|
+
|
|
18
|
+
constructor(theme: Theme, options: BorderViewOptions) {
|
|
19
|
+
this.#child = options.child
|
|
20
|
+
this.#theme = theme
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
render(width: number): string[] {
|
|
24
|
+
if (width < 4) return this.#child.render(width)
|
|
25
|
+
|
|
26
|
+
const contentWidth = Math.max(1, width - 2)
|
|
27
|
+
const content = this.#child.render(contentWidth)
|
|
28
|
+
const th = this.#theme
|
|
29
|
+
|
|
30
|
+
const topBorder = th.fg('dim', `┌${'─'.repeat(contentWidth)}┐`)
|
|
31
|
+
const bottomBorder = th.fg('dim', `└${'─'.repeat(contentWidth)}┘`)
|
|
32
|
+
const leftBorder = th.fg('dim', '│')
|
|
33
|
+
const rightBorder = th.fg('dim', '│')
|
|
34
|
+
|
|
35
|
+
const borderedContent = content.map(
|
|
36
|
+
(line) =>
|
|
37
|
+
`${leftBorder}${truncateText(line, contentWidth, undefined, true)}${rightBorder}`,
|
|
38
|
+
)
|
|
39
|
+
|
|
40
|
+
return [topBorder, ...borderedContent, bottomBorder]
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
handleInput(data: string): void {
|
|
44
|
+
this.#child.handleInput?.(data)
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
invalidate(): void {
|
|
48
|
+
this.#child.invalidate()
|
|
49
|
+
}
|
|
50
|
+
}
|