@miphamai/cli 0.9.0 → 0.10.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/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 +4 -0
- package/src/agent-view/agent-view-manager.ts +10 -0
- package/src/agent-view/dashboard.tsx +20 -1
- package/src/core/engine.ts +52 -0
- package/src/core/hooks.ts +48 -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 +12 -0
- package/src/ui/app.tsx +49 -19
- package/src/ui/commands.ts +10 -12
- package/src/workflow/primitives/agent.ts +77 -15
- package/src/workflow/runtime.ts +84 -7
- package/src/workflow/schema-validator.ts +162 -0
package/dist/mipham
ADDED
|
Binary file
|
package/package.json
CHANGED
|
@@ -0,0 +1,192 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* BackgroundAgentRegistry — lifecycle manager for background agent tasks.
|
|
3
|
+
*
|
|
4
|
+
* Each background task runs as a detached Promise with an AbortController
|
|
5
|
+
* for cancellation. The registry tracks status and stores results, which
|
|
6
|
+
* can be queried via the Task tool (output/stop actions) or the Agent View.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
export type BackgroundTaskStatus = 'running' | 'completed' | 'failed'
|
|
10
|
+
|
|
11
|
+
export interface BackgroundTask {
|
|
12
|
+
id: string
|
|
13
|
+
description: string
|
|
14
|
+
agentType: string
|
|
15
|
+
status: BackgroundTaskStatus
|
|
16
|
+
startedAt: Date
|
|
17
|
+
completedAt?: Date
|
|
18
|
+
result?: string
|
|
19
|
+
error?: string
|
|
20
|
+
abortController: AbortController
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
type CompleteCallback = (task: BackgroundTask) => void
|
|
24
|
+
|
|
25
|
+
export class BackgroundAgentRegistry {
|
|
26
|
+
private tasks: Map<string, BackgroundTask> = new Map()
|
|
27
|
+
private completeCallbacks: Map<string, CompleteCallback[]> = new Map()
|
|
28
|
+
private idCounter = 0
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Spawn a background task. Returns the task ID immediately.
|
|
32
|
+
* The executor function runs asynchronously; its result is stored in the task.
|
|
33
|
+
*
|
|
34
|
+
* @param description - Human-readable description
|
|
35
|
+
* @param agentType - Sub-agent type (general, explore, plan, code-review)
|
|
36
|
+
* @param executor - Async function that performs the work
|
|
37
|
+
*/
|
|
38
|
+
spawn(
|
|
39
|
+
description: string,
|
|
40
|
+
agentType: string,
|
|
41
|
+
executor: (signal: AbortSignal) => Promise<string>,
|
|
42
|
+
): string {
|
|
43
|
+
const id = `bg-${++this.idCounter}-${Date.now().toString(36)}`
|
|
44
|
+
|
|
45
|
+
const task: BackgroundTask = {
|
|
46
|
+
id,
|
|
47
|
+
description,
|
|
48
|
+
agentType,
|
|
49
|
+
status: 'running',
|
|
50
|
+
startedAt: new Date(),
|
|
51
|
+
abortController: new AbortController(),
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
this.tasks.set(id, task)
|
|
55
|
+
|
|
56
|
+
// Execute in background — do NOT await
|
|
57
|
+
executor(task.abortController.signal)
|
|
58
|
+
.then((result) => {
|
|
59
|
+
task.status = 'completed'
|
|
60
|
+
task.completedAt = new Date()
|
|
61
|
+
task.result = result
|
|
62
|
+
this.fireComplete(id)
|
|
63
|
+
})
|
|
64
|
+
.catch((err) => {
|
|
65
|
+
// Don't mark as failed if it was intentionally aborted
|
|
66
|
+
if (err instanceof Error && err.name === 'AbortError') {
|
|
67
|
+
task.status = 'failed'
|
|
68
|
+
task.error = 'Task was cancelled.'
|
|
69
|
+
} else {
|
|
70
|
+
task.status = 'failed'
|
|
71
|
+
task.error = String(err)
|
|
72
|
+
}
|
|
73
|
+
task.completedAt = new Date()
|
|
74
|
+
this.fireComplete(id)
|
|
75
|
+
})
|
|
76
|
+
|
|
77
|
+
return id
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* Get a task by ID. Returns undefined if not found.
|
|
82
|
+
*/
|
|
83
|
+
get(id: string): BackgroundTask | undefined {
|
|
84
|
+
return this.tasks.get(id)
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* List all background tasks, newest first.
|
|
89
|
+
*/
|
|
90
|
+
list(): BackgroundTask[] {
|
|
91
|
+
return Array.from(this.tasks.values()).sort(
|
|
92
|
+
(a, b) => b.startedAt.getTime() - a.startedAt.getTime(),
|
|
93
|
+
)
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* List only running tasks.
|
|
98
|
+
*/
|
|
99
|
+
listRunning(): BackgroundTask[] {
|
|
100
|
+
return this.list().filter((t) => t.status === 'running')
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* Stop a running task by aborting its controller.
|
|
105
|
+
* Returns true if the task was running and was stopped.
|
|
106
|
+
*/
|
|
107
|
+
stop(id: string): boolean {
|
|
108
|
+
const task = this.tasks.get(id)
|
|
109
|
+
if (!task) return false
|
|
110
|
+
if (task.status !== 'running') return false
|
|
111
|
+
|
|
112
|
+
task.abortController.abort()
|
|
113
|
+
return true
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* Register a callback that fires when a task completes (success or failure).
|
|
118
|
+
* The callback is called once and then automatically removed.
|
|
119
|
+
*/
|
|
120
|
+
onComplete(id: string, callback: CompleteCallback): void {
|
|
121
|
+
// If already complete, call immediately
|
|
122
|
+
const task = this.tasks.get(id)
|
|
123
|
+
if (task && task.status !== 'running') {
|
|
124
|
+
callback(task)
|
|
125
|
+
return
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
const cbs = this.completeCallbacks.get(id) || []
|
|
129
|
+
cbs.push(callback)
|
|
130
|
+
this.completeCallbacks.set(id, cbs)
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/**
|
|
134
|
+
* Remove completed/failed tasks older than `maxAgeMs`.
|
|
135
|
+
* Returns the number of tasks pruned.
|
|
136
|
+
*/
|
|
137
|
+
prune(maxAgeMs: number = 30 * 60 * 1000): number {
|
|
138
|
+
const now = Date.now()
|
|
139
|
+
let removed = 0
|
|
140
|
+
for (const [id, task] of this.tasks) {
|
|
141
|
+
if (task.status === 'running') continue
|
|
142
|
+
if (task.completedAt && now - task.completedAt.getTime() > maxAgeMs) {
|
|
143
|
+
this.tasks.delete(id)
|
|
144
|
+
this.completeCallbacks.delete(id)
|
|
145
|
+
removed++
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
return removed
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/**
|
|
152
|
+
* Count tasks by status.
|
|
153
|
+
*/
|
|
154
|
+
countByStatus(): Record<BackgroundTaskStatus, number> {
|
|
155
|
+
const counts: Record<BackgroundTaskStatus, number> = {
|
|
156
|
+
running: 0,
|
|
157
|
+
completed: 0,
|
|
158
|
+
failed: 0,
|
|
159
|
+
}
|
|
160
|
+
for (const [, task] of this.tasks) {
|
|
161
|
+
counts[task.status]++
|
|
162
|
+
}
|
|
163
|
+
return counts
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
private fireComplete(id: string): void {
|
|
167
|
+
const task = this.tasks.get(id)
|
|
168
|
+
if (!task) return
|
|
169
|
+
|
|
170
|
+
const cbs = this.completeCallbacks.get(id)
|
|
171
|
+
if (cbs) {
|
|
172
|
+
for (const cb of cbs) {
|
|
173
|
+
try {
|
|
174
|
+
cb(task)
|
|
175
|
+
} catch {
|
|
176
|
+
// Callback errors should not propagate
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
this.completeCallbacks.delete(id)
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
/** Singleton instance for the CLI process. */
|
|
185
|
+
let _instance: BackgroundAgentRegistry | null = null
|
|
186
|
+
|
|
187
|
+
export function getBackgroundAgentRegistry(): BackgroundAgentRegistry {
|
|
188
|
+
if (!_instance) {
|
|
189
|
+
_instance = new BackgroundAgentRegistry()
|
|
190
|
+
}
|
|
191
|
+
return _instance
|
|
192
|
+
}
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* AgentMessageBus — inter-agent communication channel.
|
|
3
|
+
*
|
|
4
|
+
* Agents (main session + background sub-agents) can post and poll messages.
|
|
5
|
+
* Messages are stored in-memory and keyed by recipient. Each message has a
|
|
6
|
+
* read flag so recipients can distinguish new from seen messages.
|
|
7
|
+
*
|
|
8
|
+
* Usage:
|
|
9
|
+
* const bus = getMessageBus()
|
|
10
|
+
* const msgId = bus.post('main', 'bg-1', 'hello', 'How is it going?')
|
|
11
|
+
* const unread = bus.poll('bg-1') // messages addressed TO bg-1
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
export interface AgentMessage {
|
|
15
|
+
id: string
|
|
16
|
+
from: string
|
|
17
|
+
to: string
|
|
18
|
+
summary: string
|
|
19
|
+
message: string
|
|
20
|
+
timestamp: Date
|
|
21
|
+
read: boolean
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export class AgentMessageBus {
|
|
25
|
+
private messages: AgentMessage[] = []
|
|
26
|
+
private idCounter = 0
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Post a message from one agent to another.
|
|
30
|
+
* Returns the message ID.
|
|
31
|
+
*/
|
|
32
|
+
post(from: string, to: string, summary: string, message: string): string {
|
|
33
|
+
const id = `msg-${++this.idCounter}`
|
|
34
|
+
this.messages.push({
|
|
35
|
+
id,
|
|
36
|
+
from,
|
|
37
|
+
to,
|
|
38
|
+
summary: summary.slice(0, 200),
|
|
39
|
+
message,
|
|
40
|
+
timestamp: new Date(),
|
|
41
|
+
read: false,
|
|
42
|
+
})
|
|
43
|
+
return id
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Get all unread messages addressed to the given agent.
|
|
48
|
+
* Does NOT mark them as read — use markRead() for that.
|
|
49
|
+
*/
|
|
50
|
+
poll(agentId: string): AgentMessage[] {
|
|
51
|
+
return this.messages.filter((m) => m.to === agentId && !m.read)
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Get all messages addressed to the given agent (read + unread).
|
|
56
|
+
*/
|
|
57
|
+
list(agentId: string): AgentMessage[] {
|
|
58
|
+
return this.messages.filter((m) => m.to === agentId)
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Mark a specific message as read by ID.
|
|
63
|
+
* Returns true if the message was found and marked.
|
|
64
|
+
*/
|
|
65
|
+
markRead(messageId: string): boolean {
|
|
66
|
+
const msg = this.messages.find((m) => m.id === messageId)
|
|
67
|
+
if (!msg) return false
|
|
68
|
+
msg.read = true
|
|
69
|
+
return true
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Mark all messages for a recipient as read.
|
|
74
|
+
* Returns the count of messages marked.
|
|
75
|
+
*/
|
|
76
|
+
markAllRead(agentId: string): number {
|
|
77
|
+
let count = 0
|
|
78
|
+
for (const msg of this.messages) {
|
|
79
|
+
if (msg.to === agentId && !msg.read) {
|
|
80
|
+
msg.read = true
|
|
81
|
+
count++
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
return count
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* Get unread message count for an agent.
|
|
89
|
+
*/
|
|
90
|
+
unreadCount(agentId: string): number {
|
|
91
|
+
return this.messages.filter((m) => m.to === agentId && !m.read).length
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* Prune messages older than maxAgeMs. Returns the number pruned.
|
|
96
|
+
*/
|
|
97
|
+
prune(maxAgeMs: number = 60 * 60 * 1000): number {
|
|
98
|
+
const now = Date.now()
|
|
99
|
+
const before = this.messages.length
|
|
100
|
+
this.messages = this.messages.filter((m) => now - m.timestamp.getTime() < maxAgeMs)
|
|
101
|
+
return before - this.messages.length
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/** Singleton instance. */
|
|
106
|
+
let _instance: AgentMessageBus | null = null
|
|
107
|
+
|
|
108
|
+
export function getMessageBus(): AgentMessageBus {
|
|
109
|
+
if (!_instance) {
|
|
110
|
+
_instance = new AgentMessageBus()
|
|
111
|
+
}
|
|
112
|
+
return _instance
|
|
113
|
+
}
|
package/src/agent/sub-agent.ts
CHANGED
|
@@ -2,6 +2,8 @@ import type { ProviderRegistry, ProviderInstance } from '../providers/registry'
|
|
|
2
2
|
import type { ToolDefinition } from '../shared/index.ts'
|
|
3
3
|
import type { SubAgentType, SubAgentOptions, AgentDefinition } from './types'
|
|
4
4
|
import { createAgentContext } from './agent-context'
|
|
5
|
+
import { getBackgroundAgentRegistry } from './background-registry'
|
|
6
|
+
import type { HookEngine } from '../core/hooks'
|
|
5
7
|
|
|
6
8
|
const TYPE_SYSTEM_PROMPTS: Record<SubAgentType, string> = {
|
|
7
9
|
general: 'You are a focused sub-agent. Complete the assigned task thoroughly and return results.',
|
|
@@ -16,17 +18,91 @@ const TYPE_SYSTEM_PROMPTS: Record<SubAgentType, string> = {
|
|
|
16
18
|
* Sub-agent engine — creates an isolated conversation context and processes
|
|
17
19
|
* a single prompt independently via the active AI provider. Returns the
|
|
18
20
|
* consolidated result text.
|
|
21
|
+
*
|
|
22
|
+
* Supports background execution via `runInBackground` option — when true,
|
|
23
|
+
* execution is spawned as a detached promise and the method returns immediately
|
|
24
|
+
* with a `[background-task:<id>]` marker.
|
|
19
25
|
*/
|
|
20
26
|
export class SubAgent {
|
|
21
27
|
constructor(
|
|
22
28
|
private registry: ProviderRegistry,
|
|
23
29
|
private toolRegistry: Map<string, ToolDefinition>,
|
|
30
|
+
private hookEngine?: HookEngine,
|
|
24
31
|
) {}
|
|
25
32
|
|
|
33
|
+
/**
|
|
34
|
+
* Execute a sub-agent task.
|
|
35
|
+
*
|
|
36
|
+
* When `options.runInBackground` is true:
|
|
37
|
+
* - The task runs asynchronously in BackgroundAgentRegistry
|
|
38
|
+
* - Returns immediately with `[background-task:<id>]`
|
|
39
|
+
* - Results are retrievable via Task output or Agent View
|
|
40
|
+
*/
|
|
26
41
|
async execute(
|
|
27
42
|
prompt: string,
|
|
28
43
|
description: string,
|
|
29
44
|
options: SubAgentOptions = {},
|
|
45
|
+
): Promise<string> {
|
|
46
|
+
const agentType = options.type || 'general'
|
|
47
|
+
|
|
48
|
+
// ── Fire SubagentStart hook ──
|
|
49
|
+
if (this.hookEngine) {
|
|
50
|
+
await this.hookEngine.executeSubagentStart(agentType, description, 'sub-agent')
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
// ── Background execution path ──
|
|
54
|
+
if (options.runInBackground) {
|
|
55
|
+
const bgRegistry = getBackgroundAgentRegistry()
|
|
56
|
+
|
|
57
|
+
const taskId = bgRegistry.spawn(description, agentType, async (signal) => {
|
|
58
|
+
// Run the synchronous execution inside the background executor
|
|
59
|
+
return this.runExecution(prompt, options, signal)
|
|
60
|
+
})
|
|
61
|
+
|
|
62
|
+
// Register completion callback for hook firing
|
|
63
|
+
bgRegistry.onComplete(taskId, (task) => {
|
|
64
|
+
if (this.hookEngine) {
|
|
65
|
+
this.hookEngine.executeSubagentStop(
|
|
66
|
+
agentType,
|
|
67
|
+
description,
|
|
68
|
+
taskId,
|
|
69
|
+
task.status === 'completed',
|
|
70
|
+
task.result || task.error,
|
|
71
|
+
)
|
|
72
|
+
}
|
|
73
|
+
})
|
|
74
|
+
|
|
75
|
+
return `[background-task:${taskId}]`
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
// ── Synchronous execution path ──
|
|
79
|
+
try {
|
|
80
|
+
const result = await this.runExecution(prompt, options)
|
|
81
|
+
if (this.hookEngine) {
|
|
82
|
+
await this.hookEngine.executeSubagentStop(agentType, description, 'sub-agent', true, result)
|
|
83
|
+
}
|
|
84
|
+
return result
|
|
85
|
+
} catch (err) {
|
|
86
|
+
if (this.hookEngine) {
|
|
87
|
+
await this.hookEngine.executeSubagentStop(
|
|
88
|
+
agentType,
|
|
89
|
+
description,
|
|
90
|
+
'sub-agent',
|
|
91
|
+
false,
|
|
92
|
+
String(err),
|
|
93
|
+
)
|
|
94
|
+
}
|
|
95
|
+
throw err
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* Internal execution method — shared by sync and background paths.
|
|
101
|
+
*/
|
|
102
|
+
private async runExecution(
|
|
103
|
+
prompt: string,
|
|
104
|
+
options: SubAgentOptions,
|
|
105
|
+
signal?: AbortSignal,
|
|
30
106
|
): Promise<string> {
|
|
31
107
|
const provider = this.registry.getActive()
|
|
32
108
|
if (!provider) {
|
|
@@ -34,15 +110,16 @@ export class SubAgent {
|
|
|
34
110
|
}
|
|
35
111
|
|
|
36
112
|
const model = this.registry.getActiveModel()
|
|
37
|
-
const
|
|
113
|
+
const agentType = options.type || 'general'
|
|
38
114
|
const agentDef = options.agentDef
|
|
39
115
|
|
|
40
116
|
// Resolve system prompt: agentDef > options.systemPrompt > builtin type
|
|
41
|
-
const systemPrompt =
|
|
117
|
+
const systemPrompt =
|
|
118
|
+
agentDef?.systemPrompt || options.systemPrompt || TYPE_SYSTEM_PROMPTS[agentType]
|
|
42
119
|
|
|
43
120
|
// Create isolated context with tool scoping
|
|
44
121
|
const resolvedDef: AgentDefinition = agentDef || {
|
|
45
|
-
name:
|
|
122
|
+
name: agentType,
|
|
46
123
|
description: '',
|
|
47
124
|
systemPrompt,
|
|
48
125
|
model: options.modelOverride || 'inherit',
|
|
@@ -82,6 +159,11 @@ export class SubAgent {
|
|
|
82
159
|
|
|
83
160
|
try {
|
|
84
161
|
for (let turn = 0; turn < MAX_TOOL_TURNS; turn++) {
|
|
162
|
+
// Check abort signal
|
|
163
|
+
if (signal?.aborted) {
|
|
164
|
+
throw new DOMException('Aborted', 'AbortError')
|
|
165
|
+
}
|
|
166
|
+
|
|
85
167
|
const toolUses: Array<{ id: string; name: string; input: Record<string, unknown> }> = []
|
|
86
168
|
let turnText = ''
|
|
87
169
|
|
|
@@ -92,8 +174,17 @@ export class SubAgent {
|
|
|
92
174
|
tools: toolDefs,
|
|
93
175
|
maxTokens: 4096,
|
|
94
176
|
})) {
|
|
177
|
+
// Check abort signal mid-stream
|
|
178
|
+
if (signal?.aborted) {
|
|
179
|
+
throw new DOMException('Aborted', 'AbortError')
|
|
180
|
+
}
|
|
181
|
+
|
|
95
182
|
if (chunk.type === 'text' && chunk.content) {
|
|
96
183
|
turnText += chunk.content
|
|
184
|
+
// Stream progress to callback if provided
|
|
185
|
+
if (options.onProgress) {
|
|
186
|
+
options.onProgress(chunk.content)
|
|
187
|
+
}
|
|
97
188
|
}
|
|
98
189
|
if (chunk.type === 'tool_use' && chunk.toolUse) {
|
|
99
190
|
toolUses.push({
|
|
@@ -125,6 +216,10 @@ export class SubAgent {
|
|
|
125
216
|
]
|
|
126
217
|
|
|
127
218
|
for (const tu of toolUses) {
|
|
219
|
+
if (signal?.aborted) {
|
|
220
|
+
throw new DOMException('Aborted', 'AbortError')
|
|
221
|
+
}
|
|
222
|
+
|
|
128
223
|
const tool = this.toolRegistry.get(tu.name)
|
|
129
224
|
if (!tool) {
|
|
130
225
|
currentMessages.push({
|
|
@@ -168,6 +263,9 @@ export class SubAgent {
|
|
|
168
263
|
currentSystemPrompt = ''
|
|
169
264
|
}
|
|
170
265
|
} catch (err) {
|
|
266
|
+
if (err instanceof DOMException && err.name === 'AbortError') {
|
|
267
|
+
throw err
|
|
268
|
+
}
|
|
171
269
|
if (err instanceof Error && err.message.startsWith('Sub-agent')) {
|
|
172
270
|
throw err
|
|
173
271
|
}
|
package/src/agent/types.ts
CHANGED
|
@@ -38,4 +38,8 @@ export interface SubAgentOptions {
|
|
|
38
38
|
modelOverride?: string
|
|
39
39
|
/** Maximum tool-calling turns (default: 5) to prevent infinite loops. */
|
|
40
40
|
maxTurns?: number
|
|
41
|
+
/** When true, execute in background and return immediately with a task ID. */
|
|
42
|
+
runInBackground?: boolean
|
|
43
|
+
/** Optional callback for streaming progress chunks during background execution. */
|
|
44
|
+
onProgress?: (chunk: string) => void
|
|
41
45
|
}
|
|
@@ -200,6 +200,16 @@ export class AgentViewManager {
|
|
|
200
200
|
return counts
|
|
201
201
|
}
|
|
202
202
|
|
|
203
|
+
/**
|
|
204
|
+
* Rename a session's title. Returns true if found and renamed.
|
|
205
|
+
*/
|
|
206
|
+
rename(id: string, newTitle: string): boolean {
|
|
207
|
+
const session = this.sessions.get(id)
|
|
208
|
+
if (!session) return false
|
|
209
|
+
session.title = newTitle
|
|
210
|
+
return true
|
|
211
|
+
}
|
|
212
|
+
|
|
203
213
|
/**
|
|
204
214
|
* Remove all completed and failed sessions (cleanup).
|
|
205
215
|
*/
|
|
@@ -30,6 +30,7 @@ const STATUS_HEADERS: Record<string, { label: string; color: string }> = {
|
|
|
30
30
|
export function AgentViewDashboard({ manager, onAttach, onExit }: DashboardProps) {
|
|
31
31
|
const [selectedIndex, setSelectedIndex] = useState(0)
|
|
32
32
|
const [peekingSessionId, setPeekingSessionId] = useState<string | null>(null)
|
|
33
|
+
const [groupBy, setGroupBy] = useState<'status' | 'directory'>('status')
|
|
33
34
|
|
|
34
35
|
// Build a flat list of sessions in group order, with group headers
|
|
35
36
|
const flatList = useMemo(() => {
|
|
@@ -95,6 +96,22 @@ export function AgentViewDashboard({ manager, onAttach, onExit }: DashboardProps
|
|
|
95
96
|
return
|
|
96
97
|
}
|
|
97
98
|
|
|
99
|
+
// Ctrl+S — toggle group by (status ↔ directory)
|
|
100
|
+
if (input === '\x13') {
|
|
101
|
+
setGroupBy((prev) => (prev === 'status' ? 'directory' : 'status'))
|
|
102
|
+
return
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
// Ctrl+R — rename selected session
|
|
106
|
+
if (input === '\x12') {
|
|
107
|
+
if (sessionsOnly.length === 0) return
|
|
108
|
+
const current = sessionsOnly[selectedIndex]
|
|
109
|
+
if (!current) return
|
|
110
|
+
const newTitle = `session-${Date.now().toString(36)}`
|
|
111
|
+
manager.rename(current.session.id, newTitle)
|
|
112
|
+
return
|
|
113
|
+
}
|
|
114
|
+
|
|
98
115
|
if (input === 'j') {
|
|
99
116
|
setSelectedIndex((prev) => Math.min(prev + 1, sessionsOnly.length - 1))
|
|
100
117
|
setPeekingSessionId(null)
|
|
@@ -163,7 +180,9 @@ export function AgentViewDashboard({ manager, onAttach, onExit }: DashboardProps
|
|
|
163
180
|
</Text>
|
|
164
181
|
</Box>
|
|
165
182
|
<Box>
|
|
166
|
-
<Text dimColor>
|
|
183
|
+
<Text dimColor>
|
|
184
|
+
j/k navigate · Space peek · Enter attach · Ctrl+S group · Ctrl+R rename · Esc back
|
|
185
|
+
</Text>
|
|
167
186
|
</Box>
|
|
168
187
|
</Box>
|
|
169
188
|
|
package/src/core/engine.ts
CHANGED
|
@@ -9,6 +9,7 @@ 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'
|
|
12
13
|
|
|
13
14
|
export class QueryEngine {
|
|
14
15
|
private hookEngine?: HookEngine
|
|
@@ -70,6 +71,46 @@ export class QueryEngine {
|
|
|
70
71
|
this.skillsLoader = loader
|
|
71
72
|
}
|
|
72
73
|
|
|
74
|
+
/** Pending task notifications from background agents (cleared after draining). */
|
|
75
|
+
private pendingTaskNotifications: Array<StreamChunk> = []
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Drain pending background task notifications.
|
|
79
|
+
* Call this after tool execution to surface completed/failed background agent results.
|
|
80
|
+
* Returns an array of StreamChunks that can be yielded in a generator.
|
|
81
|
+
*/
|
|
82
|
+
drainTaskNotifications(): StreamChunk[] {
|
|
83
|
+
const bgRegistry = getBackgroundAgentRegistry()
|
|
84
|
+
const tasks = bgRegistry.list()
|
|
85
|
+
const chunks: StreamChunk[] = []
|
|
86
|
+
|
|
87
|
+
for (const task of tasks) {
|
|
88
|
+
if (task.status === 'running') continue
|
|
89
|
+
|
|
90
|
+
// Check if we've already notified for this task
|
|
91
|
+
const alreadyNotified = this.pendingTaskNotifications.some(
|
|
92
|
+
(n) => n.taskNotification?.taskId === task.id,
|
|
93
|
+
)
|
|
94
|
+
if (alreadyNotified) continue
|
|
95
|
+
|
|
96
|
+
const chunk: StreamChunk = {
|
|
97
|
+
type: 'task_notification',
|
|
98
|
+
taskNotification: {
|
|
99
|
+
taskId: task.id,
|
|
100
|
+
status: task.status as 'completed' | 'failed',
|
|
101
|
+
description: task.description,
|
|
102
|
+
content: task.result,
|
|
103
|
+
error: task.error,
|
|
104
|
+
},
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
this.pendingTaskNotifications.push(chunk)
|
|
108
|
+
chunks.push(chunk)
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
return chunks
|
|
112
|
+
}
|
|
113
|
+
|
|
73
114
|
/** Set the session goal for goal-driven execution. */
|
|
74
115
|
setGoal(
|
|
75
116
|
goal: string,
|
|
@@ -310,6 +351,11 @@ export class QueryEngine {
|
|
|
310
351
|
})
|
|
311
352
|
}
|
|
312
353
|
|
|
354
|
+
// Drain task notifications after tool execution
|
|
355
|
+
for (const chunk of this.drainTaskNotifications()) {
|
|
356
|
+
yield chunk
|
|
357
|
+
}
|
|
358
|
+
|
|
313
359
|
// If tools were executed, recursively continue the conversation
|
|
314
360
|
if (toolUses.length > 0) {
|
|
315
361
|
yield* this.continueWithTools(signal)
|
|
@@ -318,6 +364,11 @@ export class QueryEngine {
|
|
|
318
364
|
|
|
319
365
|
// Fire Stop hooks when AI finishes with no tool calls
|
|
320
366
|
yield* this.checkStopHook(signal)
|
|
367
|
+
|
|
368
|
+
// Final drain of task notifications
|
|
369
|
+
for (const chunk of this.drainTaskNotifications()) {
|
|
370
|
+
yield chunk
|
|
371
|
+
}
|
|
321
372
|
}
|
|
322
373
|
|
|
323
374
|
async *processWithGoal(input: string, signal?: AbortSignal): AsyncGenerator<StreamChunk> {
|
|
@@ -579,6 +630,7 @@ export class QueryEngine {
|
|
|
579
630
|
toolRegistry: this.tools,
|
|
580
631
|
artifactServer: this.artifactServer,
|
|
581
632
|
agentRegistry: this.agentRegistry,
|
|
633
|
+
backgroundAgentRegistry: getBackgroundAgentRegistry(),
|
|
582
634
|
})
|
|
583
635
|
|
|
584
636
|
// Run PostToolUse hooks
|
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(
|