@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 ADDED
Binary file
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@miphamai/cli",
3
- "version": "0.9.0",
3
+ "version": "0.10.0",
4
4
  "description": "Mipham Code — Multi-model open-core intelligent coding terminal by MiphamAI",
5
5
  "keywords": [
6
6
  "ai",
@@ -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
+ }
@@ -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 type = options.type || 'general'
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 = agentDef?.systemPrompt || options.systemPrompt || TYPE_SYSTEM_PROMPTS[type]
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: type,
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
  }
@@ -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>j/k navigate · Space peek · Enter attach · Esc back</Text>
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
 
@@ -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(