@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/dist/mipham
ADDED
|
Binary file
|
package/package.json
CHANGED
|
@@ -1,4 +1,7 @@
|
|
|
1
1
|
// apps/cli/src/agent/agent-context.ts
|
|
2
|
+
import { readdirSync, readFileSync, existsSync } from 'node:fs'
|
|
3
|
+
import { join } from 'node:path'
|
|
4
|
+
import { homedir } from 'node:os'
|
|
2
5
|
import { ContextManager } from '../core/context'
|
|
3
6
|
import type { ToolDefinition } from '../shared/index.ts'
|
|
4
7
|
import type { AgentDefinition } from './types'
|
|
@@ -8,6 +11,50 @@ export interface AgentContextResult {
|
|
|
8
11
|
allowedTools: ToolDefinition[]
|
|
9
12
|
}
|
|
10
13
|
|
|
14
|
+
/**
|
|
15
|
+
* Load agent memory files from the appropriate scope directory.
|
|
16
|
+
* Returns combined content for injection into the system prompt.
|
|
17
|
+
*/
|
|
18
|
+
function loadAgentMemory(agentName: string, scope: 'user' | 'project' | 'local'): string {
|
|
19
|
+
let memoryDir: string
|
|
20
|
+
const home = homedir()
|
|
21
|
+
|
|
22
|
+
switch (scope) {
|
|
23
|
+
case 'user':
|
|
24
|
+
memoryDir = join(home, '.mipham', 'agent-memory', agentName)
|
|
25
|
+
break
|
|
26
|
+
case 'project':
|
|
27
|
+
memoryDir = join(process.cwd(), '.mipham', 'agent-memory', agentName)
|
|
28
|
+
break
|
|
29
|
+
case 'local':
|
|
30
|
+
memoryDir = join(process.cwd(), '.mipham', 'agent-memory-local', agentName)
|
|
31
|
+
break
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
if (!existsSync(memoryDir)) return ''
|
|
35
|
+
|
|
36
|
+
try {
|
|
37
|
+
const files = readdirSync(memoryDir).filter((f) => f.endsWith('.md'))
|
|
38
|
+
if (files.length === 0) return ''
|
|
39
|
+
|
|
40
|
+
const contents: string[] = []
|
|
41
|
+
for (const file of files.slice(0, 10)) {
|
|
42
|
+
// max 10 files
|
|
43
|
+
try {
|
|
44
|
+
const content = readFileSync(join(memoryDir, file), 'utf-8').trim()
|
|
45
|
+
if (content) contents.push(content)
|
|
46
|
+
} catch {
|
|
47
|
+
// skip unreadable
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
if (contents.length === 0) return ''
|
|
52
|
+
return [`[Agent Memory — ${scope} scope]`, ...contents].join('\n\n')
|
|
53
|
+
} catch {
|
|
54
|
+
return ''
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
11
58
|
/**
|
|
12
59
|
* Create an isolated context and tool set for a sub-agent.
|
|
13
60
|
*
|
|
@@ -15,6 +62,9 @@ export interface AgentContextResult {
|
|
|
15
62
|
* 1. If `tools` is set, only those tools are allowed.
|
|
16
63
|
* 2. If `disallowedTools` is set, those are removed from the full set.
|
|
17
64
|
* 3. If neither is set, all tools are available.
|
|
65
|
+
*
|
|
66
|
+
* Agent memory: if agentDef.memory is set, loads memory files from the
|
|
67
|
+
* appropriate scope and injects them into the system prompt.
|
|
18
68
|
*/
|
|
19
69
|
export function createAgentContext(
|
|
20
70
|
agentDef: AgentDefinition,
|
|
@@ -27,7 +77,16 @@ export function createAgentContext(
|
|
|
27
77
|
compactionThreshold: 0.85,
|
|
28
78
|
})
|
|
29
79
|
|
|
30
|
-
|
|
80
|
+
// Build system prompt with optional agent memory
|
|
81
|
+
let systemPrompt = agentDef.systemPrompt
|
|
82
|
+
if (agentDef.memory) {
|
|
83
|
+
const memory = loadAgentMemory(agentDef.name, agentDef.memory)
|
|
84
|
+
if (memory) {
|
|
85
|
+
systemPrompt = `${systemPrompt}\n\n---\n\n${memory}`
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
context.setSystemPrompt(systemPrompt)
|
|
31
90
|
|
|
32
91
|
// Scope tools
|
|
33
92
|
let allowedTools = Array.from(toolRegistry.values())
|
|
@@ -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
|
@@ -12,6 +12,7 @@ export interface AgentFrontmatter {
|
|
|
12
12
|
maxTurns?: number
|
|
13
13
|
skills?: string
|
|
14
14
|
background?: boolean
|
|
15
|
+
memory?: 'user' | 'project' | 'local' // agent memory scope
|
|
15
16
|
}
|
|
16
17
|
|
|
17
18
|
export interface AgentDefinition {
|
|
@@ -27,6 +28,7 @@ export interface AgentDefinition {
|
|
|
27
28
|
background: boolean
|
|
28
29
|
source: 'builtin' | 'project' | 'user'
|
|
29
30
|
filePath?: string
|
|
31
|
+
memory?: 'user' | 'project' | 'local' // agent memory scope
|
|
30
32
|
}
|
|
31
33
|
|
|
32
34
|
export interface SubAgentOptions {
|
|
@@ -38,4 +40,8 @@ export interface SubAgentOptions {
|
|
|
38
40
|
modelOverride?: string
|
|
39
41
|
/** Maximum tool-calling turns (default: 5) to prevent infinite loops. */
|
|
40
42
|
maxTurns?: number
|
|
43
|
+
/** When true, execute in background and return immediately with a task ID. */
|
|
44
|
+
runInBackground?: boolean
|
|
45
|
+
/** Optional callback for streaming progress chunks during background execution. */
|
|
46
|
+
onProgress?: (chunk: string) => void
|
|
41
47
|
}
|
|
@@ -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
|
|