@miphamai/cli 0.9.0 → 0.11.0
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/dist/mipham +0 -0
- package/package.json +1 -1
- package/src/agent/agent-context.ts +60 -1
- package/src/agent/agent-registry.ts +1 -0
- package/src/agent/background-registry.ts +192 -0
- package/src/agent/message-bus.ts +113 -0
- package/src/agent/sub-agent.ts +101 -3
- package/src/agent/types.ts +6 -0
- package/src/agent-view/agent-view-manager.ts +10 -0
- package/src/agent-view/dashboard.tsx +20 -1
- package/src/core/engine.ts +90 -0
- package/src/core/hooks.ts +48 -0
- package/src/core/output-styles.ts +95 -0
- package/src/core/rules-loader.ts +175 -0
- package/src/mcp/client.ts +9 -0
- package/src/shared/types.ts +16 -1
- package/src/tools/agent/agent.ts +41 -2
- package/src/tools/agent/enter-plan.ts +92 -0
- package/src/tools/agent/exit-plan.ts +54 -0
- package/src/tools/agent/report-findings.ts +186 -0
- package/src/tools/agent/send-message.ts +67 -0
- package/src/tools/agent/workflow.ts +25 -5
- package/src/tools/exec/enter-worktree.ts +142 -0
- package/src/tools/exec/exit-worktree.ts +162 -0
- package/src/tools/exec/task.ts +215 -22
- package/src/tools/index.ts +14 -0
- package/src/tools/system/tool-search.ts +143 -0
- package/src/ui/app.tsx +49 -19
- package/src/ui/commands.ts +172 -55
- package/src/workflow/primitives/agent.ts +77 -15
- package/src/workflow/runtime.ts +84 -7
- package/src/workflow/schema-validator.ts +162 -0
package/src/core/engine.ts
CHANGED
|
@@ -9,6 +9,8 @@ import { analyzeForMemory } from './memory/memory-writer'
|
|
|
9
9
|
import { getMemoryManager } from './memory/memory-loader'
|
|
10
10
|
import type { AgentViewManager } from '../agent-view/agent-view-manager'
|
|
11
11
|
import type { SkillsLoader } from '../skills/loader'
|
|
12
|
+
import { getBackgroundAgentRegistry } from '../agent/background-registry'
|
|
13
|
+
import { RulesLoader } from './rules-loader'
|
|
12
14
|
|
|
13
15
|
export class QueryEngine {
|
|
14
16
|
private hookEngine?: HookEngine
|
|
@@ -70,6 +72,77 @@ export class QueryEngine {
|
|
|
70
72
|
this.skillsLoader = loader
|
|
71
73
|
}
|
|
72
74
|
|
|
75
|
+
/** Rules loader for path-scoped rules injection. */
|
|
76
|
+
private rulesLoader?: RulesLoader
|
|
77
|
+
/** Files touched in the current turn (for rules matching). */
|
|
78
|
+
private touchedFiles: Set<string> = new Set()
|
|
79
|
+
|
|
80
|
+
/** Register the rules loader. */
|
|
81
|
+
setRulesLoader(loader: RulesLoader): void {
|
|
82
|
+
this.rulesLoader = loader
|
|
83
|
+
this.rulesLoader.load()
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/** Pending task notifications from background agents (cleared after draining). */
|
|
87
|
+
private pendingTaskNotifications: Array<StreamChunk> = []
|
|
88
|
+
|
|
89
|
+
/** Track files touched by tools for rules matching. */
|
|
90
|
+
private trackTouchedFile(toolName: string, params: Record<string, unknown>): void {
|
|
91
|
+
const fileTools = ['Read', 'Write', 'Edit', 'Glob', 'Grep']
|
|
92
|
+
if (!fileTools.includes(toolName)) return
|
|
93
|
+
const filePath = (params.file_path || params.path || params.file) as string | undefined
|
|
94
|
+
if (filePath && typeof filePath === 'string') {
|
|
95
|
+
this.touchedFiles.add(filePath)
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/** Inject matching rules as context after tool execution. */
|
|
100
|
+
private injectRules(): void {
|
|
101
|
+
if (!this.rulesLoader || this.touchedFiles.size === 0) return
|
|
102
|
+
const files = Array.from(this.touchedFiles)
|
|
103
|
+
const block = this.rulesLoader.buildContextBlock(files)
|
|
104
|
+
if (!block) return
|
|
105
|
+
this.context.addMessage({ role: 'user', content: block })
|
|
106
|
+
this.touchedFiles.clear()
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* Drain pending background task notifications.
|
|
111
|
+
* Call this after tool execution to surface completed/failed background agent results.
|
|
112
|
+
* Returns an array of StreamChunks that can be yielded in a generator.
|
|
113
|
+
*/
|
|
114
|
+
drainTaskNotifications(): StreamChunk[] {
|
|
115
|
+
const bgRegistry = getBackgroundAgentRegistry()
|
|
116
|
+
const tasks = bgRegistry.list()
|
|
117
|
+
const chunks: StreamChunk[] = []
|
|
118
|
+
|
|
119
|
+
for (const task of tasks) {
|
|
120
|
+
if (task.status === 'running') continue
|
|
121
|
+
|
|
122
|
+
// Check if we've already notified for this task
|
|
123
|
+
const alreadyNotified = this.pendingTaskNotifications.some(
|
|
124
|
+
(n) => n.taskNotification?.taskId === task.id,
|
|
125
|
+
)
|
|
126
|
+
if (alreadyNotified) continue
|
|
127
|
+
|
|
128
|
+
const chunk: StreamChunk = {
|
|
129
|
+
type: 'task_notification',
|
|
130
|
+
taskNotification: {
|
|
131
|
+
taskId: task.id,
|
|
132
|
+
status: task.status as 'completed' | 'failed',
|
|
133
|
+
description: task.description,
|
|
134
|
+
content: task.result,
|
|
135
|
+
error: task.error,
|
|
136
|
+
},
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
this.pendingTaskNotifications.push(chunk)
|
|
140
|
+
chunks.push(chunk)
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
return chunks
|
|
144
|
+
}
|
|
145
|
+
|
|
73
146
|
/** Set the session goal for goal-driven execution. */
|
|
74
147
|
setGoal(
|
|
75
148
|
goal: string,
|
|
@@ -310,6 +383,14 @@ export class QueryEngine {
|
|
|
310
383
|
})
|
|
311
384
|
}
|
|
312
385
|
|
|
386
|
+
// Inject path-scoped rules for touched files
|
|
387
|
+
this.injectRules()
|
|
388
|
+
|
|
389
|
+
// Drain task notifications after tool execution
|
|
390
|
+
for (const chunk of this.drainTaskNotifications()) {
|
|
391
|
+
yield chunk
|
|
392
|
+
}
|
|
393
|
+
|
|
313
394
|
// If tools were executed, recursively continue the conversation
|
|
314
395
|
if (toolUses.length > 0) {
|
|
315
396
|
yield* this.continueWithTools(signal)
|
|
@@ -318,6 +399,11 @@ export class QueryEngine {
|
|
|
318
399
|
|
|
319
400
|
// Fire Stop hooks when AI finishes with no tool calls
|
|
320
401
|
yield* this.checkStopHook(signal)
|
|
402
|
+
|
|
403
|
+
// Final drain of task notifications
|
|
404
|
+
for (const chunk of this.drainTaskNotifications()) {
|
|
405
|
+
yield chunk
|
|
406
|
+
}
|
|
321
407
|
}
|
|
322
408
|
|
|
323
409
|
async *processWithGoal(input: string, signal?: AbortSignal): AsyncGenerator<StreamChunk> {
|
|
@@ -579,8 +665,12 @@ export class QueryEngine {
|
|
|
579
665
|
toolRegistry: this.tools,
|
|
580
666
|
artifactServer: this.artifactServer,
|
|
581
667
|
agentRegistry: this.agentRegistry,
|
|
668
|
+
backgroundAgentRegistry: getBackgroundAgentRegistry(),
|
|
582
669
|
})
|
|
583
670
|
|
|
671
|
+
// Track touched files for rules matching
|
|
672
|
+
this.trackTouchedFile(name, effectiveParams)
|
|
673
|
+
|
|
584
674
|
// Run PostToolUse hooks
|
|
585
675
|
if (this.hookEngine) {
|
|
586
676
|
await this.hookEngine.executePostToolUse(name, effectiveParams, result, 'session-1')
|
package/src/core/hooks.ts
CHANGED
|
@@ -107,6 +107,54 @@ export class HookEngine {
|
|
|
107
107
|
return this.runHooks('ConfigChange', undefined, ctx)
|
|
108
108
|
}
|
|
109
109
|
|
|
110
|
+
/** SubagentStart: fires when a sub-agent begins execution. */
|
|
111
|
+
async executeSubagentStart(
|
|
112
|
+
agentType: string,
|
|
113
|
+
description: string,
|
|
114
|
+
sessionId: string,
|
|
115
|
+
): Promise<HookResult> {
|
|
116
|
+
const ctx: HookContext = {
|
|
117
|
+
event: 'SubagentStart',
|
|
118
|
+
sessionId,
|
|
119
|
+
toolInput: { agentType, description },
|
|
120
|
+
}
|
|
121
|
+
return this.runHooks('SubagentStart', undefined, ctx)
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/** SubagentStop: fires when a sub-agent completes (success or failure). */
|
|
125
|
+
async executeSubagentStop(
|
|
126
|
+
agentType: string,
|
|
127
|
+
description: string,
|
|
128
|
+
sessionId: string,
|
|
129
|
+
success: boolean,
|
|
130
|
+
result?: string,
|
|
131
|
+
): Promise<HookResult> {
|
|
132
|
+
const ctx: HookContext = {
|
|
133
|
+
event: 'SubagentStop',
|
|
134
|
+
sessionId,
|
|
135
|
+
toolInput: { agentType, description, success },
|
|
136
|
+
toolResult: result ? { success, content: result.slice(0, 2000) } : undefined,
|
|
137
|
+
}
|
|
138
|
+
return this.runHooks('SubagentStop', undefined, ctx)
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/** PostToolUseFailure: fires when a tool call fails. */
|
|
142
|
+
async executePostToolUseFailure(
|
|
143
|
+
toolName: string,
|
|
144
|
+
toolInput: Record<string, unknown>,
|
|
145
|
+
error: string,
|
|
146
|
+
sessionId: string,
|
|
147
|
+
): Promise<HookResult> {
|
|
148
|
+
const ctx: HookContext = {
|
|
149
|
+
event: 'PostToolUseFailure',
|
|
150
|
+
toolName,
|
|
151
|
+
toolInput,
|
|
152
|
+
toolResult: { success: false, content: '', error },
|
|
153
|
+
sessionId,
|
|
154
|
+
}
|
|
155
|
+
return this.runHooks('PostToolUseFailure', toolName, ctx)
|
|
156
|
+
}
|
|
157
|
+
|
|
110
158
|
// ── Core execution ──
|
|
111
159
|
|
|
112
160
|
private async runHooks(
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* OutputStylesLoader — loads custom response persona styles from .mipham/output-styles/.
|
|
3
|
+
*
|
|
4
|
+
* Each .md file in the directory is a named style. The active style's content
|
|
5
|
+
* is injected into the system prompt to shape the AI's tone and communication style.
|
|
6
|
+
*
|
|
7
|
+
* Usage:
|
|
8
|
+
* const loader = new OutputStylesLoader(cwd)
|
|
9
|
+
* loader.list() // ['concise', 'academic', ...]
|
|
10
|
+
* loader.get('concise') // style content or undefined
|
|
11
|
+
* loader.setActive('concise') // switch active style
|
|
12
|
+
* loader.getActiveContent() // active style content (for system prompt)
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import { readdirSync, readFileSync, existsSync } from 'node:fs'
|
|
16
|
+
import { join, basename, extname } from 'node:path'
|
|
17
|
+
|
|
18
|
+
export class OutputStylesLoader {
|
|
19
|
+
private stylesDir: string
|
|
20
|
+
private activeStyle: string | null = null
|
|
21
|
+
|
|
22
|
+
constructor(cwd: string) {
|
|
23
|
+
this.stylesDir = join(cwd, '.mipham', 'output-styles')
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* List all available style names (filename without .md extension).
|
|
28
|
+
*/
|
|
29
|
+
list(): string[] {
|
|
30
|
+
if (!existsSync(this.stylesDir)) return []
|
|
31
|
+
try {
|
|
32
|
+
return readdirSync(this.stylesDir)
|
|
33
|
+
.filter((f) => f.endsWith('.md'))
|
|
34
|
+
.map((f) => basename(f, extname(f)))
|
|
35
|
+
} catch {
|
|
36
|
+
return []
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Get the content of a named style. Returns undefined if not found.
|
|
42
|
+
*/
|
|
43
|
+
get(name: string): string | undefined {
|
|
44
|
+
if (!existsSync(this.stylesDir)) return undefined
|
|
45
|
+
const filepath = join(this.stylesDir, `${name}.md`)
|
|
46
|
+
if (!existsSync(filepath)) return undefined
|
|
47
|
+
try {
|
|
48
|
+
return readFileSync(filepath, 'utf-8').trim()
|
|
49
|
+
} catch {
|
|
50
|
+
return undefined
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Set the active output style.
|
|
56
|
+
*/
|
|
57
|
+
setActive(name: string): boolean {
|
|
58
|
+
const content = this.get(name)
|
|
59
|
+
if (!content) return false
|
|
60
|
+
this.activeStyle = name
|
|
61
|
+
return true
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Clear the active output style (revert to default personality).
|
|
66
|
+
*/
|
|
67
|
+
clearActive(): void {
|
|
68
|
+
this.activeStyle = null
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Get the active style name (null if none selected).
|
|
73
|
+
*/
|
|
74
|
+
getActiveName(): string | null {
|
|
75
|
+
return this.activeStyle
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Get the active style content for system prompt injection.
|
|
80
|
+
* Returns empty string if no style is active.
|
|
81
|
+
*/
|
|
82
|
+
getActiveContent(): string {
|
|
83
|
+
if (!this.activeStyle) return ''
|
|
84
|
+
const content = this.get(this.activeStyle)
|
|
85
|
+
if (!content) return ''
|
|
86
|
+
return [
|
|
87
|
+
`[Output Style: ${this.activeStyle}]`,
|
|
88
|
+
'Adopt the following communication style in all your responses:',
|
|
89
|
+
'',
|
|
90
|
+
content,
|
|
91
|
+
'',
|
|
92
|
+
'---',
|
|
93
|
+
].join('\n')
|
|
94
|
+
}
|
|
95
|
+
}
|
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* RulesLoader — path-scoped rules from .mipham/rules/.
|
|
3
|
+
*
|
|
4
|
+
* Rules are markdown files with YAML frontmatter. They are injected into
|
|
5
|
+
* the conversation when the AI touches matching files.
|
|
6
|
+
*
|
|
7
|
+
* Directory structure:
|
|
8
|
+
* .mipham/rules/
|
|
9
|
+
* always.md — always loaded (no paths filter)
|
|
10
|
+
* typescript.md — loaded when touching *.ts files
|
|
11
|
+
* security.md — loaded when touching auth/ or crypto/ paths
|
|
12
|
+
*
|
|
13
|
+
* Frontmatter:
|
|
14
|
+
* ---
|
|
15
|
+
* paths: "apps/cli/src/**\/*.ts"
|
|
16
|
+
* description: TypeScript coding standards
|
|
17
|
+
* ---
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
import { readdirSync, readFileSync, existsSync } from 'node:fs'
|
|
21
|
+
import { join, relative } from 'node:path'
|
|
22
|
+
|
|
23
|
+
interface RuleFile {
|
|
24
|
+
name: string
|
|
25
|
+
paths: string[] // glob patterns, empty = always loaded
|
|
26
|
+
description: string
|
|
27
|
+
content: string
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export class RulesLoader {
|
|
31
|
+
private rules: RuleFile[] = []
|
|
32
|
+
private rulesDir: string
|
|
33
|
+
|
|
34
|
+
constructor(cwd: string) {
|
|
35
|
+
this.rulesDir = join(cwd, '.mipham', 'rules')
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Load all rules from .mipham/rules/. Call once at startup.
|
|
40
|
+
*/
|
|
41
|
+
load(): void {
|
|
42
|
+
this.rules = []
|
|
43
|
+
if (!existsSync(this.rulesDir)) return
|
|
44
|
+
|
|
45
|
+
try {
|
|
46
|
+
const files = readdirSync(this.rulesDir).filter((f) => f.endsWith('.md'))
|
|
47
|
+
for (const file of files) {
|
|
48
|
+
const filepath = join(this.rulesDir, file)
|
|
49
|
+
try {
|
|
50
|
+
const raw = readFileSync(filepath, 'utf-8')
|
|
51
|
+
const { paths, description, content } = this.parseRule(raw, file)
|
|
52
|
+
this.rules.push({ name: file.replace(/\.md$/, ''), paths, description, content })
|
|
53
|
+
} catch {
|
|
54
|
+
// Skip unparseable files
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
} catch {
|
|
58
|
+
// Directory read error — rules unavailable
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Get rules that match the given file paths.
|
|
64
|
+
* Rules with no paths filter ("always") are always included.
|
|
65
|
+
*/
|
|
66
|
+
getMatchingRules(touchedFiles: string[]): RuleFile[] {
|
|
67
|
+
const matched: RuleFile[] = []
|
|
68
|
+
|
|
69
|
+
for (const rule of this.rules) {
|
|
70
|
+
// Always rules — no paths filter
|
|
71
|
+
if (rule.paths.length === 0) {
|
|
72
|
+
matched.push(rule)
|
|
73
|
+
continue
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// Check if any touched file matches any rule path pattern
|
|
77
|
+
for (const file of touchedFiles) {
|
|
78
|
+
for (const pattern of rule.paths) {
|
|
79
|
+
if (this.matchPath(file, pattern)) {
|
|
80
|
+
matched.push(rule)
|
|
81
|
+
// Break inner loops — rule already matched
|
|
82
|
+
break
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
if (matched.includes(rule)) break
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
return matched
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* Build a context block to inject into the conversation.
|
|
94
|
+
*/
|
|
95
|
+
buildContextBlock(touchedFiles: string[]): string {
|
|
96
|
+
const matched = this.getMatchingRules(touchedFiles)
|
|
97
|
+
if (matched.length === 0) return ''
|
|
98
|
+
|
|
99
|
+
const blocks = matched.map(
|
|
100
|
+
(r) => `[Rule: ${r.name}]${r.description ? ` — ${r.description}` : ''}\n${r.content}`,
|
|
101
|
+
)
|
|
102
|
+
return `\n<!-- Path-scoped rules matching: ${touchedFiles.join(', ')} -->\n${blocks.join('\n\n')}\n`
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* Count loaded rules.
|
|
107
|
+
*/
|
|
108
|
+
count(): number {
|
|
109
|
+
return this.rules.length
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* List loaded rule names.
|
|
114
|
+
*/
|
|
115
|
+
list(): string[] {
|
|
116
|
+
return this.rules.map((r) => r.name)
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* Simple glob matching. Supports **, *, and exact file matching.
|
|
121
|
+
* Returns true if file matches pattern.
|
|
122
|
+
*/
|
|
123
|
+
private matchPath(file: string, pattern: string): boolean {
|
|
124
|
+
// Convert glob pattern to regex
|
|
125
|
+
let regexStr = pattern
|
|
126
|
+
.replace(/\./g, '\\.')
|
|
127
|
+
.replace(/\*\*/g, '<<GLOBSTAR>>')
|
|
128
|
+
.replace(/\*/g, '[^/]*')
|
|
129
|
+
.replace(/<<GLOBSTAR>>/g, '.*')
|
|
130
|
+
|
|
131
|
+
// If pattern doesn't start with ** or *, anchor to be a suffix match
|
|
132
|
+
if (!pattern.startsWith('**') && !pattern.startsWith('*')) {
|
|
133
|
+
regexStr = regexStr + '$'
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
try {
|
|
137
|
+
return new RegExp(regexStr).test(file)
|
|
138
|
+
} catch {
|
|
139
|
+
// Invalid pattern — fallback to simple includes
|
|
140
|
+
return file.includes(pattern.replace(/\*\*/g, '').replace(/\*/g, ''))
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/**
|
|
145
|
+
* Parse a rule file — extract frontmatter and body.
|
|
146
|
+
*/
|
|
147
|
+
private parseRule(
|
|
148
|
+
raw: string,
|
|
149
|
+
filename: string,
|
|
150
|
+
): { paths: string[]; description: string; content: string } {
|
|
151
|
+
const match = raw.match(/^---\r?\n([\s\S]*?)\r?\n---\r?\n?([\s\S]*)$/)
|
|
152
|
+
if (!match) {
|
|
153
|
+
return { paths: [], description: '', content: raw.trim() }
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
const frontmatter = match[1] || ''
|
|
157
|
+
const body = (match[2] || '').trim()
|
|
158
|
+
|
|
159
|
+
const paths: string[] = []
|
|
160
|
+
let description = ''
|
|
161
|
+
|
|
162
|
+
for (const line of frontmatter.split('\n')) {
|
|
163
|
+
const pathMatch = line.match(/^paths:\s*"(.+)"$/)
|
|
164
|
+
if (pathMatch) {
|
|
165
|
+
pathMatch[1]!.split(',').forEach((p) => paths.push(p.trim()))
|
|
166
|
+
}
|
|
167
|
+
const descMatch = line.match(/^description:\s*(.+)$/)
|
|
168
|
+
if (descMatch) {
|
|
169
|
+
description = descMatch[1]!.trim()
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
return { paths, description, content: body }
|
|
174
|
+
}
|
|
175
|
+
}
|
package/src/mcp/client.ts
CHANGED
|
@@ -151,6 +151,15 @@ export class McpClient {
|
|
|
151
151
|
return this.connections.get(name)?.tools || []
|
|
152
152
|
}
|
|
153
153
|
|
|
154
|
+
/** List all currently connected MCP server names. */
|
|
155
|
+
getConnectedServers(): string[] {
|
|
156
|
+
const names: string[] = []
|
|
157
|
+
for (const [name, conn] of this.connections) {
|
|
158
|
+
if (conn.status === 'connected') names.push(name)
|
|
159
|
+
}
|
|
160
|
+
return names
|
|
161
|
+
}
|
|
162
|
+
|
|
154
163
|
async callTool(
|
|
155
164
|
serverName: string,
|
|
156
165
|
toolName: string,
|
package/src/shared/types.ts
CHANGED
|
@@ -80,6 +80,7 @@ export interface ToolContext {
|
|
|
80
80
|
toolRegistry?: Map<string, ToolDefinition>
|
|
81
81
|
artifactServer?: import('../artifacts/server').ArtifactServer
|
|
82
82
|
agentRegistry?: import('../agent/agent-registry').AgentRegistry
|
|
83
|
+
backgroundAgentRegistry?: import('../agent/background-registry').BackgroundAgentRegistry
|
|
83
84
|
}
|
|
84
85
|
|
|
85
86
|
// ── Artifact Types ──
|
|
@@ -116,9 +117,18 @@ export interface ToolDefinition {
|
|
|
116
117
|
execute: (params: Record<string, unknown>, ctx: ToolContext) => Promise<ToolResult>
|
|
117
118
|
}
|
|
118
119
|
|
|
120
|
+
// ── Task Notification Types ──
|
|
121
|
+
export interface TaskNotification {
|
|
122
|
+
taskId: string
|
|
123
|
+
status: 'started' | 'completed' | 'failed'
|
|
124
|
+
description: string
|
|
125
|
+
content?: string
|
|
126
|
+
error?: string
|
|
127
|
+
}
|
|
128
|
+
|
|
119
129
|
// ── Stream Types ──
|
|
120
130
|
export interface StreamChunk {
|
|
121
|
-
type: 'text' | 'tool_use' | 'tool_result' | 'thinking' | 'stop' | 'error'
|
|
131
|
+
type: 'text' | 'tool_use' | 'tool_result' | 'thinking' | 'stop' | 'error' | 'task_notification'
|
|
122
132
|
content?: string
|
|
123
133
|
toolUse?: ToolUseContent
|
|
124
134
|
tool_use_id?: string
|
|
@@ -127,6 +137,8 @@ export interface StreamChunk {
|
|
|
127
137
|
reasoning_content?: string
|
|
128
138
|
/** Anthropic thinking block content (DeepSeek Anthropic endpoint). */
|
|
129
139
|
thinking?: string
|
|
140
|
+
/** Background task notification payload (type: 'task_notification'). */
|
|
141
|
+
taskNotification?: TaskNotification
|
|
130
142
|
}
|
|
131
143
|
|
|
132
144
|
// ── Config Types ──
|
|
@@ -173,6 +185,7 @@ export interface SkillDefinition {
|
|
|
173
185
|
export type HookEvent =
|
|
174
186
|
| 'PreToolUse'
|
|
175
187
|
| 'PostToolUse'
|
|
188
|
+
| 'PostToolUseFailure'
|
|
176
189
|
| 'SessionStart'
|
|
177
190
|
| 'SessionEnd'
|
|
178
191
|
| 'Notification'
|
|
@@ -181,6 +194,8 @@ export type HookEvent =
|
|
|
181
194
|
| 'PreCompact'
|
|
182
195
|
| 'PostCompact'
|
|
183
196
|
| 'ConfigChange'
|
|
197
|
+
| 'SubagentStart'
|
|
198
|
+
| 'SubagentStop'
|
|
184
199
|
|
|
185
200
|
export type HookType = 'command' | 'http' | 'code' | 'mcp_tool'
|
|
186
201
|
|
package/src/tools/agent/agent.ts
CHANGED
|
@@ -1,13 +1,17 @@
|
|
|
1
1
|
import type { ToolDefinition } from '../../shared/index.ts'
|
|
2
2
|
import { SubAgent } from '../../agent/sub-agent'
|
|
3
3
|
import type { SubAgentType } from '../../agent/types'
|
|
4
|
+
import { getBackgroundAgentRegistry } from '../../agent/background-registry'
|
|
4
5
|
|
|
5
6
|
const VALID_TYPES: SubAgentType[] = ['general', 'explore', 'plan', 'code-review']
|
|
6
7
|
|
|
7
8
|
export const agentTool: ToolDefinition = {
|
|
8
9
|
name: 'Agent',
|
|
9
10
|
description:
|
|
10
|
-
'Launch a sub-agent to handle complex, multi-step tasks independently.
|
|
11
|
+
'Launch a sub-agent to handle complex, multi-step tasks independently. ' +
|
|
12
|
+
'Available types: general (default), explore (code search), plan (design), code-review. ' +
|
|
13
|
+
'Set run_in_background: true to execute asynchronously — returns a task ID immediately; ' +
|
|
14
|
+
'results are retrievable via the Task tool (output action) or Agent View.',
|
|
11
15
|
category: 'agent',
|
|
12
16
|
permission: 'ask',
|
|
13
17
|
parameters: {
|
|
@@ -19,6 +23,11 @@ export const agentTool: ToolDefinition = {
|
|
|
19
23
|
type: 'string',
|
|
20
24
|
description: 'Type: general (default), explore (code search), plan (design), code-review',
|
|
21
25
|
},
|
|
26
|
+
run_in_background: {
|
|
27
|
+
type: 'boolean',
|
|
28
|
+
description:
|
|
29
|
+
'When true, execute asynchronously and return a task ID immediately. Use Task output to retrieve results. Default: false.',
|
|
30
|
+
},
|
|
22
31
|
},
|
|
23
32
|
required: ['description', 'prompt'],
|
|
24
33
|
},
|
|
@@ -26,6 +35,7 @@ export const agentTool: ToolDefinition = {
|
|
|
26
35
|
const description = params.description as string
|
|
27
36
|
const prompt = params.prompt as string
|
|
28
37
|
const agentType = (params.subagent_type as SubAgentType) || 'general'
|
|
38
|
+
const runInBackground = params.run_in_background === true
|
|
29
39
|
|
|
30
40
|
if (!VALID_TYPES.includes(agentType)) {
|
|
31
41
|
return {
|
|
@@ -51,7 +61,36 @@ export const agentTool: ToolDefinition = {
|
|
|
51
61
|
|
|
52
62
|
try {
|
|
53
63
|
const sub = new SubAgent(registry, toolRegistry)
|
|
54
|
-
const result = await sub.execute(prompt, description, {
|
|
64
|
+
const result = await sub.execute(prompt, description, {
|
|
65
|
+
type: agentType,
|
|
66
|
+
agentDef,
|
|
67
|
+
runInBackground,
|
|
68
|
+
})
|
|
69
|
+
|
|
70
|
+
// If background execution, also register in the task system for Task tool integration
|
|
71
|
+
if (runInBackground) {
|
|
72
|
+
const bgMatch = result.match(/\[background-task:(.+?)\]/)
|
|
73
|
+
if (bgMatch) {
|
|
74
|
+
const bgTaskId = bgMatch[1]!
|
|
75
|
+
const bgRegistry = getBackgroundAgentRegistry()
|
|
76
|
+
const bgTask = bgRegistry.get(bgTaskId)
|
|
77
|
+
|
|
78
|
+
return {
|
|
79
|
+
success: true,
|
|
80
|
+
content:
|
|
81
|
+
`── Background Agent Started ──\n\n` +
|
|
82
|
+
`Task ID: ${bgTaskId}\n` +
|
|
83
|
+
`Type: ${agentType}\n` +
|
|
84
|
+
`Task: ${description}\n` +
|
|
85
|
+
`Status: ${bgTask?.status || 'running'}\n\n` +
|
|
86
|
+
`The agent is running in the background. You can continue working.\n` +
|
|
87
|
+
`Use Task output taskId="${bgTaskId}" to check results.\n` +
|
|
88
|
+
`Use Task stop taskId="${bgTaskId}" to cancel.\n` +
|
|
89
|
+
`Use /agents to view in Agent View dashboard.`,
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
55
94
|
return { success: true, content: result }
|
|
56
95
|
} catch (err) {
|
|
57
96
|
return { success: false, content: '', error: String(err) }
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
import { mkdirSync, writeFileSync } from 'node:fs'
|
|
2
|
+
import { join } from 'node:path'
|
|
3
|
+
import type { ToolDefinition } from '../../shared/index.ts'
|
|
4
|
+
|
|
5
|
+
export const enterPlanModeTool: ToolDefinition = {
|
|
6
|
+
name: 'EnterPlanMode',
|
|
7
|
+
description:
|
|
8
|
+
'Enter plan mode — a read-only mode for analysis and design. ' +
|
|
9
|
+
'In plan mode, only Read/Grep/Glob tools are auto-approved; all other tools require confirmation. ' +
|
|
10
|
+
'Use this before writing code to design an implementation approach, ' +
|
|
11
|
+
'explore the codebase, and get user approval before executing changes.',
|
|
12
|
+
category: 'agent',
|
|
13
|
+
permission: 'auto',
|
|
14
|
+
parameters: {
|
|
15
|
+
type: 'object',
|
|
16
|
+
properties: {
|
|
17
|
+
description: {
|
|
18
|
+
type: 'string',
|
|
19
|
+
description:
|
|
20
|
+
'Brief description of what you are planning (e.g., "Add user authentication flow")',
|
|
21
|
+
},
|
|
22
|
+
},
|
|
23
|
+
required: [],
|
|
24
|
+
},
|
|
25
|
+
async execute(params, ctx) {
|
|
26
|
+
const description = (params.description as string) || 'Implementation Plan'
|
|
27
|
+
|
|
28
|
+
// Create plan file
|
|
29
|
+
const planDir = join(ctx.cwd, '.mipham', 'plans')
|
|
30
|
+
mkdirSync(planDir, { recursive: true })
|
|
31
|
+
|
|
32
|
+
const timestamp = new Date().toISOString().replace(/[:.]/g, '-').slice(0, 19)
|
|
33
|
+
const filename = `plan-${timestamp}.md`
|
|
34
|
+
const filepath = join(planDir, filename)
|
|
35
|
+
|
|
36
|
+
const content = [
|
|
37
|
+
`# ${description}`,
|
|
38
|
+
'',
|
|
39
|
+
`> Generated: ${new Date().toISOString()}`,
|
|
40
|
+
`> Status: Draft`,
|
|
41
|
+
'',
|
|
42
|
+
'## Overview',
|
|
43
|
+
'',
|
|
44
|
+
'[Describe what this plan aims to achieve]',
|
|
45
|
+
'',
|
|
46
|
+
'## Files to Modify',
|
|
47
|
+
'',
|
|
48
|
+
'| File | Change | Reason |',
|
|
49
|
+
'|------|--------|--------|',
|
|
50
|
+
'| | | |',
|
|
51
|
+
'',
|
|
52
|
+
'## Implementation Steps',
|
|
53
|
+
'',
|
|
54
|
+
'1. ',
|
|
55
|
+
'2. ',
|
|
56
|
+
'3. ',
|
|
57
|
+
'',
|
|
58
|
+
'## Verification',
|
|
59
|
+
'',
|
|
60
|
+
'- [ ] Tests pass',
|
|
61
|
+
'- [ ] Typecheck passes',
|
|
62
|
+
'- [ ] Manual verification',
|
|
63
|
+
'',
|
|
64
|
+
'## Notes',
|
|
65
|
+
'',
|
|
66
|
+
'[Any additional context, risks, or dependencies]',
|
|
67
|
+
].join('\n')
|
|
68
|
+
|
|
69
|
+
writeFileSync(filepath, content, 'utf-8')
|
|
70
|
+
|
|
71
|
+
return {
|
|
72
|
+
success: true,
|
|
73
|
+
content: [
|
|
74
|
+
'── Plan Mode Activated ──',
|
|
75
|
+
'',
|
|
76
|
+
`Plan file: ${filepath}`,
|
|
77
|
+
'',
|
|
78
|
+
'Plan mode is READ-ONLY:',
|
|
79
|
+
' ✅ Read, Grep, Glob — auto-approved',
|
|
80
|
+
' ⚠️ All other tools — require confirmation',
|
|
81
|
+
'',
|
|
82
|
+
'Design your approach, explore the codebase, then:',
|
|
83
|
+
' • Use ExitPlanMode to submit your plan for approval',
|
|
84
|
+
' • The user will review and approve before code changes begin',
|
|
85
|
+
'',
|
|
86
|
+
'ExitPlanMode parameters:',
|
|
87
|
+
' approved: true → switch to acceptEdits mode (code changes allowed)',
|
|
88
|
+
' approved: false → revert to default mode',
|
|
89
|
+
].join('\n'),
|
|
90
|
+
}
|
|
91
|
+
},
|
|
92
|
+
}
|