@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,205 @@
|
|
|
1
|
+
import {
|
|
2
|
+
type BashOperations,
|
|
3
|
+
createBashToolDefinition,
|
|
4
|
+
createLocalBashOperations,
|
|
5
|
+
defineTool,
|
|
6
|
+
type ExtensionAPI,
|
|
7
|
+
} from '@earendil-works/pi-coding-agent'
|
|
8
|
+
import { parse as parseShell } from 'shell-quote'
|
|
9
|
+
|
|
10
|
+
import { registerOptionalTools } from './utils/tools.js'
|
|
11
|
+
|
|
12
|
+
export type BashSafetyIssue =
|
|
13
|
+
{ allowed: true } | { allowed: false; subCommand: string }
|
|
14
|
+
|
|
15
|
+
const FORWARD_PREFIX = ['rtk'] as const
|
|
16
|
+
|
|
17
|
+
const DESTRUCTIVE_BASH_PATTERNS = [
|
|
18
|
+
/^\s*(rm|rmdir|mv|cp|mkdir|touch|chmod|chown|tee|truncate|dd|shred)\b/i,
|
|
19
|
+
/(^|[^<])>(?!>)/,
|
|
20
|
+
/>>/,
|
|
21
|
+
/^\s*(npm|yarn|pnpm|bun)\s+(install|uninstall|add|remove|update|ci|link|publish)\b/i,
|
|
22
|
+
/^\s*pip\s+(install|uninstall)\b/i,
|
|
23
|
+
/^\s*apt(-get)?\s+(install|remove|purge|update|upgrade)\b/i,
|
|
24
|
+
/^\s*brew\s+(install|uninstall|upgrade)\b/i,
|
|
25
|
+
/^\s*git\s+(add|commit|push|pull|merge|rebase|reset|checkout|cherry-pick|revert|tag|init|clone)\b/i,
|
|
26
|
+
/^\s*git\s+stash\s+(push|pop|apply|drop|clear|save|create|branch|store|import|export)\b/i,
|
|
27
|
+
/^\s*sudo\b/i,
|
|
28
|
+
/^\s*(kill|pkill|killall|reboot|shutdown)\b/i,
|
|
29
|
+
/^\s*(vim?|nano|emacs|code|subl)\b/i,
|
|
30
|
+
] as const
|
|
31
|
+
|
|
32
|
+
const BASH_READONLY_PATTERNS = [
|
|
33
|
+
/^\s*(cat|head|tail|less|more|grep|find|ls|cd|pwd|echo|printf|wc|sort|uniq|diff|file|stat|du|df|tree|which|whereis|type|env|printenv|uname|whoami|id|date|uptime|ps|free)\b/,
|
|
34
|
+
/^\s*git\s+(status|log|diff|show|branch|remote|ls-)/i,
|
|
35
|
+
/^\s*git\s+stash\s+(list|show)\b/i,
|
|
36
|
+
/^\s*(npm|yarn|pnpm)\s+(list|ls|view|info|outdated|audit)\b/i,
|
|
37
|
+
/^\s*(npm|yarn|pnpm)\s+(run\s+)?(test|test:.*)\b/i,
|
|
38
|
+
/^\s*(node|python|python3)\s+--version/i,
|
|
39
|
+
/^\s*curl\s/,
|
|
40
|
+
/^\s*jq\b/,
|
|
41
|
+
/^\s*sed\s+-n/i,
|
|
42
|
+
/^\s*awk\b/,
|
|
43
|
+
/^\s*rg\b/,
|
|
44
|
+
/^\s*fd\b/,
|
|
45
|
+
/^\s*bat\b/,
|
|
46
|
+
/^\s*eza\b/,
|
|
47
|
+
] as const
|
|
48
|
+
|
|
49
|
+
const HARMLESS_SUB_COMMAND = /^\s*export\s+[\w]+\s*=\s*\S+\s*$/
|
|
50
|
+
|
|
51
|
+
const SUB_COMMAND_OPS = new Set(['|', '||', '|&', '&&', ';'])
|
|
52
|
+
|
|
53
|
+
const isFd = (token: unknown, n: number): token is string => token === `${n}`
|
|
54
|
+
|
|
55
|
+
const REDIRECT_OPS = new Set(['>', '>>', '<', '<>', '>&', '<&', '>|', '&>>'])
|
|
56
|
+
|
|
57
|
+
type SubCommand = { tokens: string[]; hasStdoutRedirect: boolean }
|
|
58
|
+
|
|
59
|
+
function splitShellSubCommands(command: string): SubCommand[] {
|
|
60
|
+
let parsed: ReturnType<typeof parseShell>
|
|
61
|
+
try {
|
|
62
|
+
parsed = parseShell(command)
|
|
63
|
+
} catch {
|
|
64
|
+
return []
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
const subCommands: SubCommand[] = []
|
|
68
|
+
let current: string[] = []
|
|
69
|
+
let hasStdoutRedirect = false
|
|
70
|
+
|
|
71
|
+
for (let i = 0; i < parsed.length; i++) {
|
|
72
|
+
const token = parsed[i]
|
|
73
|
+
const nextToken = parsed[i + 1]
|
|
74
|
+
const prevToken = i > 0 ? parsed[i - 1] : null
|
|
75
|
+
|
|
76
|
+
if (typeof token === 'object' && 'op' in token) {
|
|
77
|
+
const op = token.op as string
|
|
78
|
+
if (SUB_COMMAND_OPS.has(op)) {
|
|
79
|
+
subCommands.push({ tokens: current, hasStdoutRedirect })
|
|
80
|
+
current = []
|
|
81
|
+
hasStdoutRedirect = false
|
|
82
|
+
} else if (op === '>&' && isFd(prevToken, 2) && isFd(nextToken, 1)) {
|
|
83
|
+
const tokenAfterNext = parsed[i + 2]
|
|
84
|
+
const hasPipeAfter =
|
|
85
|
+
typeof tokenAfterNext === 'object' &&
|
|
86
|
+
'op' in tokenAfterNext &&
|
|
87
|
+
tokenAfterNext.op === '|'
|
|
88
|
+
if (!hasPipeAfter) hasStdoutRedirect = true
|
|
89
|
+
} else if (
|
|
90
|
+
(op === '>' || op === '>>' || op === '>|') &&
|
|
91
|
+
!isFd(prevToken, 1) &&
|
|
92
|
+
!isFd(prevToken, 2)
|
|
93
|
+
) {
|
|
94
|
+
hasStdoutRedirect = true
|
|
95
|
+
}
|
|
96
|
+
} else if (typeof token === 'string') {
|
|
97
|
+
const isRedirectTarget =
|
|
98
|
+
prevToken &&
|
|
99
|
+
typeof prevToken === 'object' &&
|
|
100
|
+
'op' in prevToken &&
|
|
101
|
+
REDIRECT_OPS.has(prevToken.op as string)
|
|
102
|
+
|
|
103
|
+
const isRedirectFd =
|
|
104
|
+
/^\d+$/.test(token) &&
|
|
105
|
+
nextToken &&
|
|
106
|
+
typeof nextToken === 'object' &&
|
|
107
|
+
'op' in nextToken &&
|
|
108
|
+
REDIRECT_OPS.has(nextToken.op)
|
|
109
|
+
|
|
110
|
+
if (!isRedirectTarget && !isRedirectFd) {
|
|
111
|
+
current.push(token)
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
if (current.length > 0 || subCommands.length > 0)
|
|
116
|
+
subCommands.push({ tokens: current, hasStdoutRedirect })
|
|
117
|
+
|
|
118
|
+
return subCommands
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
function checkSubCommandSafety(
|
|
122
|
+
subCommand: SubCommand,
|
|
123
|
+
): { ok: true } | { ok: false; subCommand: string } {
|
|
124
|
+
const text = subCommand.tokens.join(' ').trim()
|
|
125
|
+
|
|
126
|
+
if (HARMLESS_SUB_COMMAND.test(text)) return { ok: true }
|
|
127
|
+
|
|
128
|
+
let normalizedCommand = text
|
|
129
|
+
for (const prefix of FORWARD_PREFIX) {
|
|
130
|
+
const pattern = new RegExp(`^\\s*${prefix}\\s+`, 'i')
|
|
131
|
+
const match = normalizedCommand.match(pattern)
|
|
132
|
+
if (match) {
|
|
133
|
+
normalizedCommand = normalizedCommand.slice(match[0].length).trim()
|
|
134
|
+
break
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
if (subCommand.hasStdoutRedirect)
|
|
139
|
+
return { ok: false, subCommand: normalizedCommand }
|
|
140
|
+
|
|
141
|
+
const destructive = DESTRUCTIVE_BASH_PATTERNS.some((p) =>
|
|
142
|
+
p.test(normalizedCommand),
|
|
143
|
+
)
|
|
144
|
+
const readonly = BASH_READONLY_PATTERNS.some((p) => p.test(normalizedCommand))
|
|
145
|
+
|
|
146
|
+
if (destructive) return { ok: false, subCommand: normalizedCommand }
|
|
147
|
+
if (!readonly) return { ok: false, subCommand: normalizedCommand }
|
|
148
|
+
return { ok: true }
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
export function checkBashSafety(command: string): BashSafetyIssue {
|
|
152
|
+
const subCommands = splitShellSubCommands(command)
|
|
153
|
+
if (subCommands.length === 0) {
|
|
154
|
+
return { allowed: false, subCommand: command }
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
for (const subCommand of subCommands) {
|
|
158
|
+
if (subCommand.tokens.length === 0) {
|
|
159
|
+
return { allowed: false, subCommand: '(empty)' }
|
|
160
|
+
}
|
|
161
|
+
const result = checkSubCommandSafety(subCommand)
|
|
162
|
+
if (!result.ok) {
|
|
163
|
+
return { allowed: false, subCommand: result.subCommand }
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
return { allowed: true }
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
export function isBashReadonlyCommand(command: string): boolean {
|
|
171
|
+
return checkBashSafety(command).allowed
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
export const BASH_READONLY_TOOL_NAME = 'bash_readonly'
|
|
175
|
+
|
|
176
|
+
export function setupBashReadonlyTool(pi: ExtensionAPI): void {
|
|
177
|
+
const localOps = createLocalBashOperations()
|
|
178
|
+
const safeOps: BashOperations = {
|
|
179
|
+
exec: async (command, cwd, options) => {
|
|
180
|
+
const safety = checkBashSafety(command)
|
|
181
|
+
if (!safety.allowed) {
|
|
182
|
+
throw new Error(
|
|
183
|
+
`${BASH_READONLY_TOOL_NAME}: not allowed: "${safety.subCommand}"`,
|
|
184
|
+
)
|
|
185
|
+
}
|
|
186
|
+
return localOps.exec(command, cwd, options)
|
|
187
|
+
},
|
|
188
|
+
}
|
|
189
|
+
const def = createBashToolDefinition(process.cwd(), { operations: safeOps })
|
|
190
|
+
registerOptionalTools(
|
|
191
|
+
pi,
|
|
192
|
+
[
|
|
193
|
+
defineTool({
|
|
194
|
+
...def,
|
|
195
|
+
name: BASH_READONLY_TOOL_NAME,
|
|
196
|
+
label: 'Read-Only Bash',
|
|
197
|
+
description:
|
|
198
|
+
'Execute a bash command. Only commands classified as read-only are allowed; write operations are rejected. Use the regular bash tool outside of read-only modes.',
|
|
199
|
+
promptSnippet: undefined,
|
|
200
|
+
promptGuidelines: undefined,
|
|
201
|
+
}),
|
|
202
|
+
],
|
|
203
|
+
false,
|
|
204
|
+
)
|
|
205
|
+
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import { dirname, join } from 'node:path'
|
|
2
|
+
import { fileURLToPath } from 'node:url'
|
|
3
|
+
|
|
4
|
+
import { describe, expect, it } from 'vitest'
|
|
5
|
+
|
|
6
|
+
import { loadMarkdown } from '../utils/markdown.js'
|
|
7
|
+
|
|
8
|
+
const __dirname = dirname(fileURLToPath(import.meta.url))
|
|
9
|
+
const repoRoot = join(__dirname, '../..')
|
|
10
|
+
|
|
11
|
+
describe('coordinator mode prompt', () => {
|
|
12
|
+
it('has correct removeTools in coordinator.md', async () => {
|
|
13
|
+
const coordinatorPath = join(
|
|
14
|
+
repoRoot,
|
|
15
|
+
'pm-subagents-prompts',
|
|
16
|
+
'coordinator.md',
|
|
17
|
+
)
|
|
18
|
+
const result = await loadMarkdown(coordinatorPath)
|
|
19
|
+
expect(result).toBeDefined()
|
|
20
|
+
expect(result?.fm.removeTools).toEqual([
|
|
21
|
+
'find',
|
|
22
|
+
'grep',
|
|
23
|
+
'bash',
|
|
24
|
+
'web_search',
|
|
25
|
+
'web_fetch',
|
|
26
|
+
])
|
|
27
|
+
})
|
|
28
|
+
})
|
|
@@ -0,0 +1,275 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
ExtensionAPI,
|
|
3
|
+
ExtensionContext,
|
|
4
|
+
} from '@earendil-works/pi-coding-agent'
|
|
5
|
+
|
|
6
|
+
import { getPmSubagentsConfig } from '../models-config/models-config.js'
|
|
7
|
+
import { formatSubagentModelLabel } from '../models-config/subagent-model-utils.js'
|
|
8
|
+
import { applyModeFor, assertModeIdle, exitModeFor } from '../pm-mode.js'
|
|
9
|
+
import { loadRoles } from '../prompts/roles.js'
|
|
10
|
+
import { ActivityReporter } from '../subagent/activity.js'
|
|
11
|
+
import { MessageBatcher } from '../subagent/batcher.js'
|
|
12
|
+
import { SubagentManagerDemo } from '../subagent/demo.js'
|
|
13
|
+
import { FleetList } from '../subagent/fleet.js'
|
|
14
|
+
import {
|
|
15
|
+
type LiveSubagent,
|
|
16
|
+
SubagentManager,
|
|
17
|
+
type SubagentStatus,
|
|
18
|
+
} from '../subagent/manager.js'
|
|
19
|
+
import { registerSubagentTools, SUBAGENT_TOOLS } from '../subagent/tools.js'
|
|
20
|
+
import { openSubagentViewer } from '../subagent/viewer.js'
|
|
21
|
+
import type { PmSubagentState } from '../types.js'
|
|
22
|
+
import {
|
|
23
|
+
mergePromptDefinitions,
|
|
24
|
+
type PromptDefinition,
|
|
25
|
+
} from '../utils/markdown.js'
|
|
26
|
+
import { notifyAgentMessage } from '../utils/messages.ts'
|
|
27
|
+
|
|
28
|
+
const COORDINATOR_MODE_WIDGET_KEY = 'pi-pm-subagents:coordinator-mode'
|
|
29
|
+
|
|
30
|
+
/** job event for pi-notify */
|
|
31
|
+
const JOB_START_EVENT = 'pi-notify:job:start'
|
|
32
|
+
const JOB_END_EVENT = 'pi-notify:job:end'
|
|
33
|
+
|
|
34
|
+
export type { LiveSubagent, SubagentStatus }
|
|
35
|
+
|
|
36
|
+
interface CoordinatorRuntime {
|
|
37
|
+
manager: SubagentManager
|
|
38
|
+
activityReporter: ActivityReporter
|
|
39
|
+
demoSubagentManager: SubagentManagerDemo | undefined
|
|
40
|
+
fleet: FleetList
|
|
41
|
+
batcher: MessageBatcher
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
let runtime: CoordinatorRuntime | undefined
|
|
45
|
+
|
|
46
|
+
function requiredRuntime(): CoordinatorRuntime {
|
|
47
|
+
if (!runtime) {
|
|
48
|
+
throw new Error('Coordinator mode is not initialized.')
|
|
49
|
+
}
|
|
50
|
+
return runtime
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export async function enterCoordinatorMode(
|
|
54
|
+
pi: ExtensionAPI,
|
|
55
|
+
state: PmSubagentState,
|
|
56
|
+
prompt: string | undefined,
|
|
57
|
+
ctx: ExtensionContext,
|
|
58
|
+
def: PromptDefinition,
|
|
59
|
+
): Promise<void> {
|
|
60
|
+
assertModeIdle(state, 'coordinator')
|
|
61
|
+
state.mode = 'coordinator'
|
|
62
|
+
await applyCoordinatorMode(pi, state, ctx, def)
|
|
63
|
+
if (prompt) notifyAgentMessage(pi, prompt)
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export function renderCoordinatorModeWidget(
|
|
67
|
+
ctx: ExtensionContext,
|
|
68
|
+
state?: PmSubagentState,
|
|
69
|
+
): void {
|
|
70
|
+
const config = getPmSubagentsConfig()
|
|
71
|
+
const scope = config.subagentModelScoped ?? []
|
|
72
|
+
const currentRef = state?.sessionSubagentModel ?? config.subagentModel
|
|
73
|
+
const label = formatSubagentModelLabel(scope, currentRef)
|
|
74
|
+
ctx.ui.setWidget(COORDINATOR_MODE_WIDGET_KEY, [
|
|
75
|
+
ctx.ui.theme.fg(
|
|
76
|
+
'accent',
|
|
77
|
+
`${ctx.ui.theme.bold('👥 COORDINATOR MODE')} - subagent: ${label}`,
|
|
78
|
+
),
|
|
79
|
+
])
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
export async function applyCoordinatorMode(
|
|
83
|
+
pi: ExtensionAPI,
|
|
84
|
+
state: PmSubagentState,
|
|
85
|
+
ctx: ExtensionContext,
|
|
86
|
+
def: PromptDefinition,
|
|
87
|
+
): Promise<void> {
|
|
88
|
+
const { fleet, activityReporter } = requiredRuntime()
|
|
89
|
+
fleet.setContext(ctx)
|
|
90
|
+
|
|
91
|
+
await applyModeFor(pi, state, 'coordinator', ctx, {
|
|
92
|
+
promptDefinition: mergePromptDefinitions(def, {
|
|
93
|
+
fm: {
|
|
94
|
+
extraTools: Object.values(SUBAGENT_TOOLS),
|
|
95
|
+
},
|
|
96
|
+
}),
|
|
97
|
+
color: 'accent',
|
|
98
|
+
})
|
|
99
|
+
|
|
100
|
+
renderCoordinatorModeWidget(ctx, state)
|
|
101
|
+
|
|
102
|
+
fleet.update()
|
|
103
|
+
activityReporter.start()
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
export async function exitCoordinatorMode(
|
|
107
|
+
pi: ExtensionAPI,
|
|
108
|
+
state: PmSubagentState,
|
|
109
|
+
ctx: ExtensionContext,
|
|
110
|
+
): Promise<void> {
|
|
111
|
+
const { manager, fleet, batcher, activityReporter } = requiredRuntime()
|
|
112
|
+
batcher.clear()
|
|
113
|
+
activityReporter.stop()
|
|
114
|
+
state.mode = undefined
|
|
115
|
+
fleet.dispose()
|
|
116
|
+
manager.disposeAll()
|
|
117
|
+
ctx.ui.setWidget(COORDINATOR_MODE_WIDGET_KEY, undefined)
|
|
118
|
+
await exitModeFor(pi, state, ctx, 'coordinator')
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
export async function setupCoordinator(
|
|
122
|
+
pi: ExtensionAPI,
|
|
123
|
+
state: PmSubagentState,
|
|
124
|
+
{
|
|
125
|
+
demoEnabled,
|
|
126
|
+
coordinatorDefinition,
|
|
127
|
+
}: {
|
|
128
|
+
demoEnabled: boolean
|
|
129
|
+
coordinatorDefinition: PromptDefinition
|
|
130
|
+
},
|
|
131
|
+
): Promise<void> {
|
|
132
|
+
const batcher = new MessageBatcher((messages: readonly string[]) => {
|
|
133
|
+
if (state.mode !== 'coordinator') return
|
|
134
|
+
notifyAgentMessage(pi, messages.join('\n\n'))
|
|
135
|
+
})
|
|
136
|
+
const manager = new SubagentManager({
|
|
137
|
+
onStatusChange: () => runtime?.fleet.update(),
|
|
138
|
+
onEachStart: (subagent) => {
|
|
139
|
+
pi.events.emit(JOB_START_EVENT, {
|
|
140
|
+
id: `pi-pm-subagents:session:${subagent.id}`,
|
|
141
|
+
})
|
|
142
|
+
},
|
|
143
|
+
onEachEnd: (subagent) => {
|
|
144
|
+
pi.events.emit(JOB_END_EVENT, {
|
|
145
|
+
id: `pi-pm-subagents:session:${subagent.id}`,
|
|
146
|
+
})
|
|
147
|
+
},
|
|
148
|
+
})
|
|
149
|
+
const activityReporter = new ActivityReporter({
|
|
150
|
+
list: () => manager.list(),
|
|
151
|
+
onActivity: (subagent, report) => {
|
|
152
|
+
batcher.add(subagent, 'activity', report)
|
|
153
|
+
},
|
|
154
|
+
})
|
|
155
|
+
|
|
156
|
+
const fleet = new FleetList({
|
|
157
|
+
list: () => runtime?.demoSubagentManager?.list() ?? manager.list(),
|
|
158
|
+
onOpen: async (ctx, id) => {
|
|
159
|
+
const activeManager = runtime?.demoSubagentManager ?? manager
|
|
160
|
+
return openSubagentViewer(ctx, activeManager, id)
|
|
161
|
+
},
|
|
162
|
+
})
|
|
163
|
+
|
|
164
|
+
await loadRoles(process.cwd(), {
|
|
165
|
+
skipPluginAgents: getPmSubagentsConfig().skipPluginAgents,
|
|
166
|
+
})
|
|
167
|
+
|
|
168
|
+
runtime = {
|
|
169
|
+
manager,
|
|
170
|
+
activityReporter,
|
|
171
|
+
demoSubagentManager: undefined,
|
|
172
|
+
fleet,
|
|
173
|
+
batcher,
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
registerSubagentTools(pi, state, manager, fleet, batcher)
|
|
177
|
+
|
|
178
|
+
const coordinatorPrompt = coordinatorDefinition.systemPrompt
|
|
179
|
+
|
|
180
|
+
pi.on('before_agent_start', async (event) => {
|
|
181
|
+
if (state.mode !== 'coordinator') return
|
|
182
|
+
return { systemPrompt: `${event.systemPrompt}\n\n${coordinatorPrompt}` }
|
|
183
|
+
})
|
|
184
|
+
|
|
185
|
+
const runCoordinatorCommand = async (args: string, ctx: ExtensionContext) => {
|
|
186
|
+
const request = args.trim()
|
|
187
|
+
if (state.mode === 'coordinator') {
|
|
188
|
+
await exitCoordinatorMode(pi, state, ctx)
|
|
189
|
+
ctx.ui.notify('Coordinator mode off.', 'info')
|
|
190
|
+
return
|
|
191
|
+
}
|
|
192
|
+
await enterCoordinatorMode(pi, state, undefined, ctx, coordinatorDefinition)
|
|
193
|
+
if (request) notifyAgentMessage(pi, request)
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
pi.registerCommand('coordinator', {
|
|
197
|
+
description:
|
|
198
|
+
'Coordinator mode. Usage: /coordinator [request] — toggles, or enters with a task',
|
|
199
|
+
handler: runCoordinatorCommand,
|
|
200
|
+
})
|
|
201
|
+
|
|
202
|
+
pi.registerCommand('pm', {
|
|
203
|
+
description:
|
|
204
|
+
'Coordinator mode. Usage: /pm [request] — toggles, or enters with a task',
|
|
205
|
+
handler: runCoordinatorCommand,
|
|
206
|
+
})
|
|
207
|
+
|
|
208
|
+
pi.registerCommand('plan', {
|
|
209
|
+
description: 'Delegate a planner subagent. Usage: /plan <request>',
|
|
210
|
+
handler: async (args, ctx) => {
|
|
211
|
+
if (state.mode !== 'coordinator') {
|
|
212
|
+
await enterCoordinatorMode(
|
|
213
|
+
pi,
|
|
214
|
+
state,
|
|
215
|
+
undefined,
|
|
216
|
+
ctx,
|
|
217
|
+
coordinatorDefinition,
|
|
218
|
+
)
|
|
219
|
+
}
|
|
220
|
+
let request = args.trim()
|
|
221
|
+
if (!request && ctx.hasUI) {
|
|
222
|
+
request = (await ctx.ui.editor('Enter the request to plan:', '')) ?? ''
|
|
223
|
+
}
|
|
224
|
+
if (!request.trim()) return
|
|
225
|
+
notifyAgentMessage(
|
|
226
|
+
pi,
|
|
227
|
+
[
|
|
228
|
+
`<planRequest>${request}</planRequest>`,
|
|
229
|
+
"Use role 'planner' to create a plan. then other subagents to implements",
|
|
230
|
+
].join('\n'),
|
|
231
|
+
)
|
|
232
|
+
},
|
|
233
|
+
})
|
|
234
|
+
|
|
235
|
+
if (demoEnabled) {
|
|
236
|
+
pi.registerCommand('subagent-demo', {
|
|
237
|
+
description: 'Browse a subagent live demo (coordinator mode)',
|
|
238
|
+
handler: async (args, ctx) => {
|
|
239
|
+
const argList = args.split(/\s+/)
|
|
240
|
+
if (!ctx.hasUI) return
|
|
241
|
+
if (state.mode !== 'coordinator') {
|
|
242
|
+
ctx.ui.notify(
|
|
243
|
+
'Subagents browser is only available in coordinator mode.',
|
|
244
|
+
'info',
|
|
245
|
+
)
|
|
246
|
+
return
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
const runtime = requiredRuntime()
|
|
250
|
+
const { fleet } = runtime
|
|
251
|
+
|
|
252
|
+
if (argList[0] === 'add') {
|
|
253
|
+
runtime.demoSubagentManager ??= new SubagentManagerDemo()
|
|
254
|
+
runtime.demoSubagentManager.add(argList.slice(1).join(' '))
|
|
255
|
+
fleet.update()
|
|
256
|
+
ctx.ui.notify(
|
|
257
|
+
'Added a demo subagent. Use /subagent demo to exit demo mode.',
|
|
258
|
+
'info',
|
|
259
|
+
)
|
|
260
|
+
} else if (runtime.demoSubagentManager) {
|
|
261
|
+
runtime.demoSubagentManager = undefined
|
|
262
|
+
fleet.update()
|
|
263
|
+
ctx.ui.notify('Demo mode exited.', 'info')
|
|
264
|
+
} else {
|
|
265
|
+
runtime.demoSubagentManager = new SubagentManagerDemo()
|
|
266
|
+
fleet.update()
|
|
267
|
+
ctx.ui.notify(
|
|
268
|
+
'Demo mode active: fake subagents loaded. Use /subagent demo to exit.',
|
|
269
|
+
'info',
|
|
270
|
+
)
|
|
271
|
+
}
|
|
272
|
+
},
|
|
273
|
+
})
|
|
274
|
+
}
|
|
275
|
+
}
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
import type { Theme } from '@earendil-works/pi-coding-agent'
|
|
2
|
+
import type { TUI } from '@earendil-works/pi-tui'
|
|
3
|
+
import { describe, expect, it } from 'vitest'
|
|
4
|
+
|
|
5
|
+
import { CustomSelectComponent } from './custom-select.js'
|
|
6
|
+
|
|
7
|
+
const DOWN = '\x1b[B'
|
|
8
|
+
|
|
9
|
+
const makeTui = (): TUI =>
|
|
10
|
+
({
|
|
11
|
+
terminal: { rows: 30, columns: 80 },
|
|
12
|
+
requestRender: () => {},
|
|
13
|
+
}) as unknown as TUI
|
|
14
|
+
|
|
15
|
+
const makeTheme = (): Theme =>
|
|
16
|
+
({
|
|
17
|
+
fg: (_color: string, text: string) => text,
|
|
18
|
+
bold: (text: string) => text,
|
|
19
|
+
}) as unknown as Theme
|
|
20
|
+
|
|
21
|
+
describe('CustomSelectComponent', () => {
|
|
22
|
+
it('renders without throwing on small lists', () => {
|
|
23
|
+
const picker = new CustomSelectComponent(
|
|
24
|
+
makeTui(),
|
|
25
|
+
makeTheme(),
|
|
26
|
+
{
|
|
27
|
+
items: [
|
|
28
|
+
{ key: 'a', text: 'apple' },
|
|
29
|
+
{ key: 'b', text: 'banana' },
|
|
30
|
+
],
|
|
31
|
+
title: 'Fruit',
|
|
32
|
+
},
|
|
33
|
+
() => {},
|
|
34
|
+
() => {},
|
|
35
|
+
)
|
|
36
|
+
expect(picker.render(40)).toBeDefined()
|
|
37
|
+
})
|
|
38
|
+
|
|
39
|
+
it('renders without throwing on large lists and caps the viewport', () => {
|
|
40
|
+
const items = Array.from({ length: 100 }, (_, i) => ({
|
|
41
|
+
key: `k${i}`,
|
|
42
|
+
text: `option-${i}`,
|
|
43
|
+
}))
|
|
44
|
+
const picker = new CustomSelectComponent(
|
|
45
|
+
makeTui(),
|
|
46
|
+
makeTheme(),
|
|
47
|
+
{ items, title: 'Pick', maxVisible: 10 },
|
|
48
|
+
() => {},
|
|
49
|
+
() => {},
|
|
50
|
+
)
|
|
51
|
+
const lines = picker.render(40)
|
|
52
|
+
expect(lines.length).toBeGreaterThan(0)
|
|
53
|
+
})
|
|
54
|
+
|
|
55
|
+
it('reports the selected key via onSelect', () => {
|
|
56
|
+
let picked: string | undefined
|
|
57
|
+
const picker = new CustomSelectComponent(
|
|
58
|
+
makeTui(),
|
|
59
|
+
makeTheme(),
|
|
60
|
+
{
|
|
61
|
+
items: [
|
|
62
|
+
{ key: 'a', text: 'apple' },
|
|
63
|
+
{ key: 'b', text: 'banana' },
|
|
64
|
+
],
|
|
65
|
+
title: 'Fruit',
|
|
66
|
+
},
|
|
67
|
+
(key) => {
|
|
68
|
+
picked = key
|
|
69
|
+
},
|
|
70
|
+
() => {},
|
|
71
|
+
)
|
|
72
|
+
picker.handleInput(DOWN) // move down
|
|
73
|
+
picker.handleInput('\n') // confirm
|
|
74
|
+
expect(picked).toBe('b')
|
|
75
|
+
})
|
|
76
|
+
|
|
77
|
+
it('invokes onCancel on escape', () => {
|
|
78
|
+
let cancelled = false
|
|
79
|
+
const picker = new CustomSelectComponent(
|
|
80
|
+
makeTui(),
|
|
81
|
+
makeTheme(),
|
|
82
|
+
{ items: [{ key: 'a', text: 'apple' }], title: 'Fruit' },
|
|
83
|
+
() => {},
|
|
84
|
+
() => {
|
|
85
|
+
cancelled = true
|
|
86
|
+
},
|
|
87
|
+
)
|
|
88
|
+
picker.handleInput('\x1b')
|
|
89
|
+
expect(cancelled).toBe(true)
|
|
90
|
+
})
|
|
91
|
+
})
|