@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,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
+ }