@vibe-forge/mcp 0.11.0 → 0.11.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -3,19 +3,51 @@ import process from 'node:process'
3
3
  import { loadInjectDefaultSystemPromptValue, mergeSystemPrompts } from '@vibe-forge/config'
4
4
  import { callHook } from '@vibe-forge/hooks'
5
5
  import { generateAdapterQueryOptions, run } from '@vibe-forge/task'
6
- import type { AdapterOutputEvent, ChatMessage, McpTaskSession, SessionPermissionMode } from '@vibe-forge/types'
6
+ import type {
7
+ AdapterErrorData,
8
+ AdapterOutputEvent,
9
+ AskUserQuestionParams,
10
+ ChatMessage,
11
+ McpTaskSession,
12
+ PermissionInteractionDecision,
13
+ SessionPermissionMode
14
+ } from '@vibe-forge/types'
15
+ import { createEmptySessionPermissionState, normalizePermissionToolName } from '@vibe-forge/utils'
16
+ import type { SessionPermissionState } from '@vibe-forge/utils'
7
17
  import { extractTextFromMessage } from '@vibe-forge/utils/chat-message'
8
18
 
9
19
  import { fetchSessionMessages, postSessionEvent } from '#~/sync.js'
20
+ import {
21
+ PERMISSION_DECISION_CANCEL,
22
+ applyTaskPermissionDecision,
23
+ buildPermissionRecoveryPayload,
24
+ extractPermissionErrorContext,
25
+ resolvePermissionInteractionDecision,
26
+ syncTaskPermissionStateMirror
27
+ } from './permission-recovery'
28
+
29
+ const TASK_PERMISSION_CONTINUE_PROMPT = '权限规则已更新。请继续刚才被权限拦截的工作,并重试被阻止的操作。'
10
30
 
11
31
  interface ServerSyncState {
12
32
  sessionId: string
13
33
  lastEventIndex: number
14
34
  lastAssistantMessageId?: string
15
35
  seenMessageIds: Set<string>
36
+ lastPollError?: string
16
37
  poller?: NodeJS.Timeout
17
38
  }
18
39
 
40
+ interface PendingTaskInteraction {
41
+ id: string
42
+ payload: AskUserQuestionParams
43
+ source: 'adapter' | 'permission_recovery'
44
+ subjectKeys?: string[]
45
+ }
46
+
47
+ type ManagedTaskSession = McpTaskSession & {
48
+ respondInteraction?: (interactionId: string, data: string | string[]) => void | Promise<void>
49
+ }
50
+
19
51
  export interface TaskInfo {
20
52
  taskId: string
21
53
  adapter?: string
@@ -24,17 +56,176 @@ export interface TaskInfo {
24
56
  name?: string
25
57
  permissionMode?: SessionPermissionMode
26
58
  background?: boolean
27
- status: 'running' | 'completed' | 'failed'
59
+ status: 'running' | 'waiting_input' | 'completed' | 'failed'
28
60
  exitCode?: number
29
61
  logs: string[]
30
- session?: McpTaskSession
62
+ permissionState: SessionPermissionState
63
+ pendingInteraction?: PendingTaskInteraction
64
+ lastError?: AdapterErrorData
65
+ session?: ManagedTaskSession
31
66
  createdAt: number
32
67
  onStop?: () => void
33
68
  serverSync?: ServerSyncState
34
69
  }
35
70
 
71
+ const appendTaskLog = (task: TaskInfo, message: string | undefined) => {
72
+ const normalized = message?.trim()
73
+ if (normalized == null || normalized === '') return
74
+ if (task.logs.at(-1) === normalized) return
75
+ task.logs.push(normalized)
76
+ }
77
+
78
+ const extractMessageLogText = (message: ChatMessage) => {
79
+ const extracted = extractTextFromMessage(message)?.trim()
80
+ if (extracted != null && extracted !== '') {
81
+ return extracted
82
+ }
83
+
84
+ if (!Array.isArray(message.content)) {
85
+ return undefined
86
+ }
87
+
88
+ for (const item of message.content) {
89
+ if (item.type === 'tool_result') {
90
+ if (typeof item.content === 'string' && item.content.trim() !== '') {
91
+ return item.content.trim()
92
+ }
93
+ try {
94
+ const serialized = JSON.stringify(item.content)
95
+ return serialized === '""' ? undefined : serialized
96
+ } catch {
97
+ return undefined
98
+ }
99
+ }
100
+ }
101
+
102
+ return undefined
103
+ }
104
+
105
+ const formatInteractionLog = (payload: AskUserQuestionParams) => {
106
+ const options = payload.options
107
+ ?.map(option => option.value ?? option.label)
108
+ .filter((value): value is string => value.trim() !== '')
109
+ const optionsSuffix = options != null && options.length > 0
110
+ ? ` Available responses: ${options.join(', ')}.`
111
+ : ''
112
+ return `Waiting for ${
113
+ payload.kind === 'permission' ? 'permission' : 'user'
114
+ } input: ${payload.question}${optionsSuffix}`
115
+ }
116
+
117
+ const formatPermissionErrorLog = (error: AdapterErrorData) => {
118
+ if (error.code !== 'permission_required' || error.details == null || typeof error.details !== 'object') {
119
+ return undefined
120
+ }
121
+
122
+ const details = error.details as {
123
+ permissionDenials?: Array<{ message?: string; deniedTools?: string[] }>
124
+ }
125
+ const permissionDenials = Array.isArray(details.permissionDenials) ? details.permissionDenials : []
126
+ const deniedTools = [
127
+ ...new Set(permissionDenials.flatMap(item => Array.isArray(item.deniedTools) ? item.deniedTools : []))
128
+ ]
129
+ const reasons = permissionDenials
130
+ .map(item => item.message?.trim())
131
+ .filter((value): value is string => value != null && value !== '')
132
+
133
+ const parts = []
134
+ if (deniedTools.length > 0) {
135
+ parts.push(`Denied tools: ${deniedTools.join(', ')}`)
136
+ }
137
+ if (reasons.length > 0) {
138
+ parts.push(`Reasons: ${reasons.join(' | ')}`)
139
+ }
140
+
141
+ return parts.length > 0 ? parts.join(' | ') : undefined
142
+ }
143
+
36
144
  export class TaskManager {
37
145
  private tasks: Map<string, TaskInfo> = new Map()
146
+ private permissionToolUseCache = new Map<string, Map<string, string>>()
147
+
148
+ private getPermissionToolUseCache(taskId: string) {
149
+ let cache = this.permissionToolUseCache.get(taskId)
150
+ if (cache == null) {
151
+ cache = new Map<string, string>()
152
+ this.permissionToolUseCache.set(taskId, cache)
153
+ }
154
+ return cache
155
+ }
156
+
157
+ private trimPermissionToolUseCache(taskId: string, maxSize = 128) {
158
+ const cache = this.permissionToolUseCache.get(taskId)
159
+ if (cache == null) return
160
+
161
+ while (cache.size > maxSize) {
162
+ const firstKey = cache.keys().next().value as string | undefined
163
+ if (firstKey == null) break
164
+ cache.delete(firstKey)
165
+ }
166
+ }
167
+
168
+ private rememberPermissionToolUses(taskId: string, message: ChatMessage) {
169
+ if (!Array.isArray(message.content)) {
170
+ return
171
+ }
172
+
173
+ const cache = this.getPermissionToolUseCache(taskId)
174
+ for (const item of message.content) {
175
+ if (
176
+ item == null ||
177
+ typeof item !== 'object' ||
178
+ item.type !== 'tool_use' ||
179
+ typeof item.id !== 'string' ||
180
+ item.id.trim() === ''
181
+ ) {
182
+ continue
183
+ }
184
+
185
+ const rawName = typeof item.name === 'string' && item.name.trim() !== ''
186
+ ? item.name.trim()
187
+ : undefined
188
+ const normalizedToolName = rawName?.startsWith('adapter:')
189
+ ? rawName.split(':').at(-1)?.trim() ?? rawName
190
+ : rawName
191
+ const subject = normalizePermissionToolName(normalizedToolName ?? rawName)
192
+ if (subject == null) {
193
+ continue
194
+ }
195
+
196
+ cache.set(item.id.trim(), subject.key)
197
+ }
198
+
199
+ this.trimPermissionToolUseCache(taskId)
200
+ }
201
+
202
+ private resolvePermissionErrorContext(taskId: string, error: AdapterErrorData) {
203
+ const context = extractPermissionErrorContext(error)
204
+ if (context.subjectKeys.length > 0) {
205
+ return context
206
+ }
207
+
208
+ const details = error.details != null && typeof error.details === 'object'
209
+ ? error.details as Record<string, unknown>
210
+ : {}
211
+ const toolUseId = typeof details.toolUseId === 'string' && details.toolUseId.trim() !== ''
212
+ ? details.toolUseId.trim()
213
+ : undefined
214
+ if (toolUseId == null) {
215
+ return context
216
+ }
217
+
218
+ const cachedSubjectKey = this.permissionToolUseCache.get(taskId)?.get(toolUseId)
219
+ if (cachedSubjectKey == null || cachedSubjectKey.trim() === '') {
220
+ return context
221
+ }
222
+
223
+ return {
224
+ subjectKeys: [...new Set([...context.subjectKeys, cachedSubjectKey])],
225
+ deniedTools: [...new Set([...context.deniedTools, cachedSubjectKey])],
226
+ reasons: context.reasons
227
+ }
228
+ }
38
229
 
39
230
  public async startTask(options: {
40
231
  taskId: string
@@ -59,6 +250,7 @@ export class TaskManager {
59
250
  background,
60
251
  status: 'running',
61
252
  logs: [],
253
+ permissionState: createEmptySessionPermissionState(),
62
254
  createdAt: Date.now()
63
255
  }
64
256
  if (enableServerSync) {
@@ -69,114 +261,140 @@ export class TaskManager {
69
261
  }
70
262
  }
71
263
  this.tasks.set(taskId, taskInfo)
264
+ this.permissionToolUseCache.set(taskId, new Map())
72
265
 
266
+ await this.launchTask(taskInfo, 'create')
267
+
268
+ if (!background) {
269
+ await new Promise<void>((resolve) => {
270
+ const task = this.tasks.get(taskId)
271
+ if (!task) {
272
+ resolve()
273
+ return
274
+ }
275
+ if (task.status !== 'running') {
276
+ resolve()
277
+ return
278
+ }
279
+ task.onStop = resolve
280
+ })
281
+ return { taskId, logs: taskInfo.logs }
282
+ }
283
+
284
+ return { taskId }
285
+ }
286
+
287
+ private async launchTask(task: TaskInfo, runType: 'create' | 'resume') {
73
288
  try {
74
- // Resolve Config
75
- const promptType = type !== 'default' ? type : undefined
76
- const promptName = name
289
+ const promptType = task.type !== 'default' ? task.type : undefined
290
+ const promptName = task.name
77
291
  const promptCWD = process.cwd()
78
292
  const [data, resolvedConfig] = await generateAdapterQueryOptions(
79
293
  promptType,
80
294
  promptName,
81
295
  promptCWD,
82
296
  {
83
- adapter
297
+ adapter: task.adapter
84
298
  }
85
299
  )
86
300
  const env = {
87
301
  ...process.env,
88
- __VF_PROJECT_AI_CTX_ID__: process.env.__VF_PROJECT_AI_CTX_ID__ ?? taskId
302
+ __VF_PROJECT_AI_CTX_ID__: process.env.__VF_PROJECT_AI_CTX_ID__ ?? task.taskId
89
303
  }
90
304
  await callHook('GenerateSystemPrompt', {
91
305
  cwd: promptCWD,
92
- sessionId: taskId,
306
+ sessionId: task.taskId,
93
307
  type: promptType,
94
308
  name: promptName,
95
309
  data
96
310
  }, env)
97
311
 
98
312
  const injectDefaultSystemPrompt = await loadInjectDefaultSystemPromptValue(promptCWD)
99
-
100
- // Start Task
101
- const ctxId = process.env.__VF_PROJECT_AI_CTX_ID__ ?? taskId
102
- const { session } = await run({
103
- adapter,
313
+ const ctxId = process.env.__VF_PROJECT_AI_CTX_ID__ ?? task.taskId
314
+ const { session, resolvedAdapter } = await run({
315
+ adapter: task.adapter,
104
316
  cwd: process.cwd(),
105
317
  env: {
106
318
  ...process.env,
107
319
  __VF_PROJECT_AI_CTX_ID__: ctxId
108
320
  }
109
321
  }, {
110
- type: 'create',
322
+ type: runType,
111
323
  runtime: 'mcp',
112
324
  mode: 'stream',
113
- sessionId: taskId,
325
+ sessionId: task.taskId,
114
326
  systemPrompt: mergeSystemPrompts({
115
327
  generatedSystemPrompt: resolvedConfig.systemPrompt,
116
328
  injectDefaultSystemPrompt
117
329
  }),
118
- permissionMode,
330
+ permissionMode: task.permissionMode,
119
331
  tools: resolvedConfig.tools,
120
332
  skills: resolvedConfig.skills,
121
333
  mcpServers: resolvedConfig.mcpServers,
122
334
  promptAssetIds: resolvedConfig.promptAssetIds,
123
335
  assetBundle: resolvedConfig.assetBundle,
124
336
  onEvent: (event: AdapterOutputEvent) => {
125
- this.handleEvent(taskId, event)
337
+ this.handleEvent(task.taskId, event)
126
338
  }
127
339
  })
128
- // Store session for control
129
- const task = this.tasks.get(taskId)
130
- if (task) {
131
- task.session = session
132
- // Send initial prompt (description)
133
- session.emit({
134
- type: 'message',
135
- content: [{ type: 'text', text: description }]
136
- })
137
- this.startServerPolling(taskId)
340
+
341
+ const current = this.tasks.get(task.taskId)
342
+ if (current == null) {
343
+ session.kill()
344
+ return
138
345
  }
139
346
 
140
- if (!background) {
141
- // Wait for completion
142
- await new Promise<void>((resolve) => {
143
- const task = this.tasks.get(taskId)
144
- if (!task) {
145
- resolve()
146
- return
147
- }
148
- // Check if already finished
149
- if (task.status !== 'running') {
150
- resolve()
151
- return
152
- }
153
- // Register callback
154
- task.onStop = resolve
347
+ current.adapter = resolvedAdapter ?? current.adapter
348
+ current.session = session as ManagedTaskSession
349
+ current.status = 'running'
350
+ current.exitCode = undefined
351
+ current.pendingInteraction = undefined
352
+ current.lastError = undefined
353
+ try {
354
+ await syncTaskPermissionStateMirror({
355
+ cwd: process.cwd(),
356
+ adapter: current.adapter,
357
+ sessionId: current.taskId,
358
+ permissionState: current.permissionState
155
359
  })
156
- return { taskId, logs: taskInfo.logs }
360
+ } catch (error) {
361
+ appendTaskLog(
362
+ current,
363
+ `Permission mirror sync failed: ${error instanceof Error ? error.message : String(error)}`
364
+ )
157
365
  }
366
+ this.startServerPolling(task.taskId)
367
+ session.emit({
368
+ type: 'message',
369
+ content: [{
370
+ type: 'text',
371
+ text: runType === 'resume' ? TASK_PERMISSION_CONTINUE_PROMPT : current.description
372
+ }]
373
+ })
158
374
  } catch (err) {
159
- const task = this.tasks.get(taskId)
160
- if (task) {
161
- task.status = 'failed'
162
- task.logs.push(`Failed to start task: ${err instanceof Error ? err.message : String(err)}`)
163
- task.onStop?.()
375
+ const current = this.tasks.get(task.taskId)
376
+ if (current) {
377
+ current.status = 'failed'
378
+ appendTaskLog(current, `Failed to start task: ${err instanceof Error ? err.message : String(err)}`)
379
+ current.onStop?.()
164
380
  }
165
381
  throw err
166
382
  }
167
-
168
- return { taskId }
169
383
  }
170
384
 
171
385
  private handleEvent(taskId: string, event: AdapterOutputEvent) {
172
386
  const task = this.tasks.get(taskId)
173
387
  if (!task) return
174
388
 
175
- void this.syncEvent(task, event)
389
+ const shouldSyncEvent = !(task.status === 'failed' && (event.type === 'exit' || event.type === 'stop'))
390
+ if (shouldSyncEvent) {
391
+ void this.syncEvent(task, event)
392
+ }
176
393
 
177
394
  switch (event.type) {
178
395
  case 'message': {
179
396
  const message = event.data as ChatMessage
397
+ this.rememberPermissionToolUses(taskId, message)
180
398
  if (message?.id) {
181
399
  task.serverSync?.seenMessageIds.add(message.id)
182
400
  }
@@ -185,42 +403,85 @@ export class TaskManager {
185
403
  task.serverSync.lastAssistantMessageId = message.id
186
404
  }
187
405
  }
188
- const content = event.data.content
189
- let text = ''
190
- if (typeof content === 'string') {
191
- text = content
192
- } else if (Array.isArray(content)) {
193
- text = content.map(c => c.type === 'text' ? c.text : '').join('')
194
- }
195
- if (text) {
196
- task.logs.push(text)
197
- }
406
+ appendTaskLog(task, extractMessageLogText(message))
198
407
  break
199
408
  }
409
+ case 'interaction_request':
410
+ task.status = 'waiting_input'
411
+ task.pendingInteraction = {
412
+ id: event.data.id,
413
+ payload: event.data.payload,
414
+ source: 'adapter'
415
+ }
416
+ appendTaskLog(task, formatInteractionLog(event.data.payload))
417
+ task.onStop?.()
418
+ break
200
419
  case 'error': {
201
- task.logs.push(event.data.message)
420
+ task.lastError = event.data
421
+ appendTaskLog(task, event.data.message)
422
+ appendTaskLog(task, formatPermissionErrorLog(event.data))
423
+ if (event.data.code === 'permission_required') {
424
+ const permissionContext = this.resolvePermissionErrorContext(taskId, event.data)
425
+ const payload = buildPermissionRecoveryPayload({
426
+ sessionId: task.taskId,
427
+ adapter: task.adapter,
428
+ currentMode: task.permissionMode,
429
+ context: permissionContext
430
+ })
431
+ if (payload != null) {
432
+ task.status = 'waiting_input'
433
+ task.pendingInteraction = {
434
+ id: `task-recovery:${task.taskId}:${Date.now()}`,
435
+ payload,
436
+ source: 'permission_recovery',
437
+ subjectKeys: permissionContext.subjectKeys
438
+ }
439
+ appendTaskLog(task, formatInteractionLog(payload))
440
+ this.stopServerPolling(taskId)
441
+ void this.syncSyntheticInteraction(task)
442
+ task.onStop?.()
443
+ break
444
+ }
445
+ }
202
446
  if (event.data.fatal !== false) {
203
447
  task.status = 'failed'
448
+ task.pendingInteraction = undefined
204
449
  this.stopServerPolling(taskId)
205
450
  task.onStop?.()
206
451
  }
207
452
  break
208
453
  }
209
454
  case 'stop': {
455
+ task.session = undefined
210
456
  if (task.status === 'failed') {
211
457
  this.stopServerPolling(taskId)
212
458
  task.onStop?.()
213
459
  break
214
460
  }
215
461
  task.status = 'completed'
462
+ task.pendingInteraction = undefined
216
463
  this.stopServerPolling(taskId)
217
464
  task.onStop?.()
218
465
  break
219
466
  }
220
467
  case 'exit':
468
+ task.session = undefined
469
+ if (task.status === 'failed') {
470
+ task.exitCode = event.data.exitCode ?? undefined
471
+ this.stopServerPolling(taskId)
472
+ task.onStop?.()
473
+ break
474
+ }
475
+ if (task.status === 'waiting_input') {
476
+ task.exitCode = event.data.exitCode ?? undefined
477
+ this.stopServerPolling(taskId)
478
+ task.onStop?.()
479
+ break
480
+ }
221
481
  task.status = event.data.exitCode === 0 ? 'completed' : 'failed'
222
482
  task.exitCode = event.data.exitCode ?? undefined
223
- task.logs.push(`Process exited with code ${event.data.exitCode}`)
483
+ task.pendingInteraction = undefined
484
+ appendTaskLog(task, `Process exited with code ${event.data.exitCode}`)
224
485
  this.stopServerPolling(taskId)
225
486
  task.onStop?.()
226
487
  break
@@ -239,6 +500,7 @@ export class TaskManager {
239
500
  if (!current?.serverSync || !current.session) return
240
501
  try {
241
502
  const events = await fetchSessionMessages(current.serverSync.sessionId)
503
+ current.serverSync.lastPollError = undefined
242
504
  const startIndex = current.serverSync.lastEventIndex
243
505
  const newEvents = events.slice(startIndex)
244
506
  current.serverSync.lastEventIndex = events.length
@@ -260,7 +522,13 @@ export class TaskManager {
260
522
  parentUuid: current.serverSync.lastAssistantMessageId
261
523
  })
262
524
  }
263
- } catch {}
525
+ } catch (error) {
526
+ const message = `Sync poll failed: ${error instanceof Error ? error.message : String(error)}`
527
+ if (current.serverSync.lastPollError !== message) {
528
+ current.serverSync.lastPollError = message
529
+ appendTaskLog(current, message)
530
+ }
531
+ }
264
532
  }
265
533
 
266
534
  task.serverSync.poller = setInterval(() => {
@@ -282,7 +550,20 @@ export class TaskManager {
282
550
  try {
283
551
  await postSessionEvent(task.serverSync.sessionId, event as unknown as Record<string, unknown>)
284
552
  } catch (err) {
285
- task.logs.push(`Sync event failed: ${err instanceof Error ? err.message : String(err)}`)
553
+ appendTaskLog(task, `Sync event failed: ${err instanceof Error ? err.message : String(err)}`)
554
+ }
555
+ }
556
+
557
+ private async syncSyntheticInteraction(task: TaskInfo) {
558
+ if (task.serverSync == null || task.pendingInteraction == null) return
559
+ try {
560
+ await postSessionEvent(task.serverSync.sessionId, {
561
+ type: 'interaction_request',
562
+ id: task.pendingInteraction.id,
563
+ payload: task.pendingInteraction.payload
564
+ })
565
+ } catch (error) {
566
+ appendTaskLog(task, `Sync interaction request failed: ${error instanceof Error ? error.message : String(error)}`)
286
567
  }
287
568
  }
288
569
 
@@ -294,13 +575,156 @@ export class TaskManager {
294
575
  return Array.from(this.tasks.values())
295
576
  }
296
577
 
578
+ public async submitTaskInput(params: {
579
+ taskId: string
580
+ interactionId?: string
581
+ data: string | string[]
582
+ }): Promise<void> {
583
+ const task = this.tasks.get(params.taskId)
584
+ if (task == null) {
585
+ throw new Error(`Task ${params.taskId} not found.`)
586
+ }
587
+
588
+ const pendingInteraction = task.pendingInteraction
589
+ if (pendingInteraction == null) {
590
+ throw new Error(`Task ${params.taskId} does not have a pending interaction.`)
591
+ }
592
+
593
+ const interactionId = params.interactionId ?? pendingInteraction.id
594
+ if (interactionId !== pendingInteraction.id) {
595
+ throw new Error(`Interaction ${interactionId} is not pending for task ${params.taskId}.`)
596
+ }
597
+
598
+ if (pendingInteraction.source === 'adapter') {
599
+ if (task.session?.respondInteraction == null) {
600
+ throw new Error(`Task ${params.taskId} does not support interaction responses.`)
601
+ }
602
+ await task.session.respondInteraction(interactionId, params.data)
603
+ await this.syncTaskInputResponse(task, interactionId, params.data)
604
+ task.pendingInteraction = undefined
605
+ task.status = 'running'
606
+ const responseText = Array.isArray(params.data) ? params.data.join(', ') : params.data
607
+ appendTaskLog(task, `Interaction response submitted: ${responseText}`)
608
+ return
609
+ }
610
+
611
+ const decision = resolvePermissionInteractionDecision(params.data)
612
+ if (decision == null) {
613
+ throw new Error(`Task ${params.taskId} requires a permission decision response.`)
614
+ }
615
+
616
+ await this.syncTaskInputResponse(task, interactionId, params.data)
617
+ if (decision === PERMISSION_DECISION_CANCEL) {
618
+ task.pendingInteraction = undefined
619
+ task.status = 'failed'
620
+ appendTaskLog(task, 'Permission recovery cancelled. Task will not continue.')
621
+ task.onStop?.()
622
+ return
623
+ }
624
+
625
+ task.permissionState = await applyTaskPermissionDecision({
626
+ cwd: process.cwd(),
627
+ sessionId: task.taskId,
628
+ adapter: task.adapter,
629
+ permissionState: task.permissionState,
630
+ subjectKeys: pendingInteraction.subjectKeys ?? [],
631
+ action: decision as PermissionInteractionDecision
632
+ })
633
+
634
+ if (
635
+ decision === 'deny_once' ||
636
+ decision === 'deny_session' ||
637
+ decision === 'deny_project'
638
+ ) {
639
+ task.pendingInteraction = undefined
640
+ task.status = 'failed'
641
+ appendTaskLog(task, `Permission decision applied: ${decision}. Task will not continue.`)
642
+ task.onStop?.()
643
+ return
644
+ }
645
+
646
+ task.pendingInteraction = undefined
647
+ task.status = 'running'
648
+ appendTaskLog(task, `Permission decision applied: ${decision}. Restarting task.`)
649
+ await this.launchTask(task, 'resume')
650
+ }
651
+
652
+ public async respondToTaskInteraction(params: {
653
+ taskId: string
654
+ interactionId?: string
655
+ data: string | string[]
656
+ }): Promise<void> {
657
+ await this.submitTaskInput(params)
658
+ }
659
+
660
+ private async syncTaskInputResponse(task: TaskInfo, interactionId: string, data: string | string[]) {
661
+ if (!task.serverSync) return
662
+ try {
663
+ await postSessionEvent(task.serverSync.sessionId, {
664
+ type: 'interaction_response',
665
+ id: interactionId,
666
+ data
667
+ })
668
+ } catch (error) {
669
+ appendTaskLog(
670
+ task,
671
+ `Sync interaction response failed: ${error instanceof Error ? error.message : String(error)}`
672
+ )
673
+ }
674
+ }
675
+
676
+ private async syncStoppedTask(params: {
677
+ task: TaskInfo
678
+ pendingInteraction?: PendingTaskInteraction
679
+ }) {
680
+ const { task, pendingInteraction } = params
681
+ if (!task.serverSync) return
682
+
683
+ if (pendingInteraction != null) {
684
+ try {
685
+ await postSessionEvent(task.serverSync.sessionId, {
686
+ type: 'interaction_response',
687
+ id: pendingInteraction.id,
688
+ data: PERMISSION_DECISION_CANCEL
689
+ })
690
+ } catch (error) {
691
+ appendTaskLog(
692
+ task,
693
+ `Sync interaction cancellation failed: ${error instanceof Error ? error.message : String(error)}`
694
+ )
695
+ }
696
+ }
697
+
698
+ try {
699
+ await postSessionEvent(task.serverSync.sessionId, {
700
+ type: 'error',
701
+ data: {
702
+ message: 'Task stopped by user',
703
+ fatal: true
704
+ }
705
+ })
706
+ } catch (error) {
707
+ appendTaskLog(
708
+ task,
709
+ `Sync stop event failed: ${error instanceof Error ? error.message : String(error)}`
710
+ )
711
+ }
712
+ }
713
+
297
714
  public stopTask(taskId: string): boolean {
298
715
  const task = this.tasks.get(taskId)
299
- if (task && task.session) {
300
- task.session.kill()
301
- task.logs.push('Task stopped by user')
716
+ if (task && (task.session != null || task.pendingInteraction != null || task.status === 'waiting_input')) {
717
+ const pendingInteraction = task.pendingInteraction
718
+ task.session?.kill()
719
+ task.session = undefined
720
+ task.pendingInteraction = undefined
721
+ appendTaskLog(task, 'Task stopped by user')
302
722
  task.status = 'failed' // or 'stopped' if we had that status
303
723
  this.stopServerPolling(taskId)
724
+ void this.syncStoppedTask({
725
+ task,
726
+ pendingInteraction
727
+ })
304
728
  if (task.onStop) task.onStop()
305
729
  return true
306
730
  }