@vibe-forge/mcp 2.0.1 → 2.0.2-alpha.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.
@@ -208,6 +208,240 @@ describe('taskManager fatal error scenarios', () => {
208
208
  })
209
209
  })
210
210
 
211
+ it('sends a follow-up message directly to a running task without server sync', async () => {
212
+ const { TaskManager } = await import('#~/tools/task/manager.js')
213
+ const emit = vi.fn()
214
+
215
+ mocks.run.mockResolvedValueOnce({
216
+ session: {
217
+ emit,
218
+ kill: vi.fn()
219
+ }
220
+ })
221
+
222
+ const managedTaskManager = new TaskManager()
223
+ await managedTaskManager.startTask({
224
+ taskId: 'task-send-local',
225
+ description: 'trigger'
226
+ })
227
+
228
+ await managedTaskManager.sendTaskMessage({
229
+ taskId: 'task-send-local',
230
+ message: 'keep going'
231
+ })
232
+
233
+ const task = managedTaskManager.getTask('task-send-local')
234
+ expect(emit).toHaveBeenNthCalledWith(2, {
235
+ type: 'message',
236
+ content: [{
237
+ type: 'text',
238
+ text: 'keep going'
239
+ }]
240
+ })
241
+ expect(task?.logs).toContain('User message submitted (direct): keep going')
242
+ })
243
+
244
+ it('syncs follow-up messages through the child session when server sync is enabled', async () => {
245
+ const { TaskManager } = await import('#~/tools/task/manager.js')
246
+ const emit = vi.fn()
247
+
248
+ mocks.run.mockResolvedValueOnce({
249
+ session: {
250
+ emit,
251
+ kill: vi.fn()
252
+ }
253
+ })
254
+
255
+ const managedTaskManager = new TaskManager()
256
+ await managedTaskManager.startTask({
257
+ taskId: 'task-send-synced',
258
+ description: 'trigger',
259
+ enableServerSync: true
260
+ })
261
+
262
+ await managedTaskManager.sendTaskMessage({
263
+ taskId: 'task-send-synced',
264
+ message: 'keep going'
265
+ })
266
+
267
+ const task = managedTaskManager.getTask('task-send-synced')
268
+ expect(emit).toHaveBeenCalledTimes(1)
269
+ expect(mocks.postSessionEvent).toHaveBeenCalledWith('task-send-synced', {
270
+ type: 'message',
271
+ data: expect.objectContaining({
272
+ role: 'user',
273
+ content: 'keep going'
274
+ })
275
+ })
276
+ expect(task?.logs).toContain('User message submitted (direct): keep going')
277
+ })
278
+
279
+ it('queues steer follow-up messages and resumes the same task after natural completion', async () => {
280
+ const { TaskManager } = await import('#~/tools/task/manager.js')
281
+ let onEvent: ((event: any) => void) | undefined
282
+ const resumedEmit = vi.fn()
283
+
284
+ mocks.run
285
+ .mockImplementationOnce(async (_options: unknown, adapterOptions: any) => {
286
+ onEvent = adapterOptions.onEvent
287
+ return {
288
+ session: {
289
+ emit: vi.fn(),
290
+ kill: vi.fn()
291
+ }
292
+ }
293
+ })
294
+ .mockResolvedValueOnce({
295
+ session: {
296
+ emit: resumedEmit,
297
+ kill: vi.fn()
298
+ }
299
+ })
300
+
301
+ const managedTaskManager = new TaskManager()
302
+ await managedTaskManager.startTask({
303
+ taskId: 'task-send-steer',
304
+ description: 'trigger'
305
+ })
306
+
307
+ await managedTaskManager.sendTaskMessage({
308
+ taskId: 'task-send-steer',
309
+ message: 'after you finish, summarize blockers',
310
+ mode: 'steer'
311
+ })
312
+
313
+ onEvent?.({
314
+ type: 'stop',
315
+ data: undefined
316
+ })
317
+ await new Promise(resolve => setTimeout(resolve, 0))
318
+
319
+ const task = managedTaskManager.getTask('task-send-steer')
320
+ expect(mocks.run).toHaveBeenCalledTimes(2)
321
+ expect(resumedEmit).toHaveBeenCalledWith({
322
+ type: 'message',
323
+ content: [{
324
+ type: 'text',
325
+ text: 'after you finish, summarize blockers'
326
+ }]
327
+ })
328
+ expect(task?.logs).toContain('Queued task message (steer): after you finish, summarize blockers')
329
+ expect(task?.logs).toContain('Resuming task from steer queue: after you finish, summarize blockers')
330
+ })
331
+
332
+ it('rejects follow-up messages when a task is waiting for input', async () => {
333
+ const { TaskManager } = await import('#~/tools/task/manager.js')
334
+
335
+ mocks.run.mockImplementationOnce(async (_options: unknown, adapterOptions: any) => {
336
+ const session = {
337
+ emit: vi.fn(() => {
338
+ adapterOptions.onEvent({
339
+ type: 'interaction_request',
340
+ data: {
341
+ id: 'interaction-send-blocked',
342
+ payload: {
343
+ sessionId: 'task-send-blocked',
344
+ kind: 'permission',
345
+ question: 'Allow editing files?',
346
+ options: [
347
+ { label: 'Allow once', value: 'allow_once' }
348
+ ]
349
+ }
350
+ }
351
+ })
352
+ }),
353
+ kill: vi.fn(),
354
+ respondInteraction: vi.fn()
355
+ }
356
+ return { session }
357
+ })
358
+
359
+ const managedTaskManager = new TaskManager()
360
+ await managedTaskManager.startTask({
361
+ taskId: 'task-send-blocked',
362
+ description: 'trigger',
363
+ background: false
364
+ })
365
+
366
+ await expect(managedTaskManager.sendTaskMessage({
367
+ taskId: 'task-send-blocked',
368
+ message: 'continue'
369
+ })).rejects.toThrow('Task task-send-blocked is waiting for input. Use SubmitTaskInput instead.')
370
+ })
371
+
372
+ it('rejects steer follow-up messages when a task is waiting for input', async () => {
373
+ const { TaskManager } = await import('#~/tools/task/manager.js')
374
+
375
+ mocks.run.mockImplementationOnce(async (_options: unknown, adapterOptions: any) => {
376
+ const session = {
377
+ emit: vi.fn(() => {
378
+ adapterOptions.onEvent({
379
+ type: 'interaction_request',
380
+ data: {
381
+ id: 'interaction-send-steer-blocked',
382
+ payload: {
383
+ sessionId: 'task-send-steer-blocked',
384
+ kind: 'permission',
385
+ question: 'Allow editing files?',
386
+ options: [
387
+ { label: 'Allow once', value: 'allow_once' }
388
+ ]
389
+ }
390
+ }
391
+ })
392
+ }),
393
+ kill: vi.fn(),
394
+ respondInteraction: vi.fn()
395
+ }
396
+ return { session }
397
+ })
398
+
399
+ const managedTaskManager = new TaskManager()
400
+ await managedTaskManager.startTask({
401
+ taskId: 'task-send-steer-blocked',
402
+ description: 'trigger',
403
+ background: false
404
+ })
405
+
406
+ await expect(managedTaskManager.sendTaskMessage({
407
+ taskId: 'task-send-steer-blocked',
408
+ message: 'summarize blockers after this',
409
+ mode: 'steer'
410
+ })).rejects.toThrow('Task task-send-steer-blocked is waiting for input. Use SubmitTaskInput instead.')
411
+ })
412
+
413
+ it('rejects steer follow-up messages after a task has already completed', async () => {
414
+ const { TaskManager } = await import('#~/tools/task/manager.js')
415
+ let onEvent: ((event: any) => void) | undefined
416
+
417
+ mocks.run.mockImplementationOnce(async (_options: unknown, adapterOptions: any) => {
418
+ onEvent = adapterOptions.onEvent
419
+ return {
420
+ session: {
421
+ emit: vi.fn(),
422
+ kill: vi.fn()
423
+ }
424
+ }
425
+ })
426
+
427
+ const managedTaskManager = new TaskManager()
428
+ await managedTaskManager.startTask({
429
+ taskId: 'task-send-completed',
430
+ description: 'trigger'
431
+ })
432
+
433
+ onEvent?.({
434
+ type: 'stop',
435
+ data: undefined
436
+ })
437
+
438
+ await expect(managedTaskManager.sendTaskMessage({
439
+ taskId: 'task-send-completed',
440
+ message: 'queue this for later',
441
+ mode: 'steer'
442
+ })).rejects.toThrow('Task task-send-completed is not active. Start a new task instead.')
443
+ })
444
+
211
445
  it('builds synthetic permission recovery for claude-code and resumes after SubmitTaskInput', async () => {
212
446
  const { TaskManager } = await import('#~/tools/task/manager.js')
213
447
  const resumedEmit = vi.fn()
@@ -8,6 +8,7 @@ const mocks = vi.hoisted(() => {
8
8
  createChildSession: vi.fn(),
9
9
  getParentSessionId: vi.fn(),
10
10
  startTask: vi.fn(),
11
+ sendTaskMessage: vi.fn(),
11
12
  submitTaskInput: vi.fn(),
12
13
  respondToTaskInteraction: vi.fn(),
13
14
  getTask: vi.fn(),
@@ -33,6 +34,7 @@ vi.mock('#~/sync.js', () => ({
33
34
  vi.mock('#~/tools/task/manager.js', () => ({
34
35
  TaskManager: class {
35
36
  startTask = mocks.startTask
37
+ sendTaskMessage = mocks.sendTaskMessage
36
38
  submitTaskInput = mocks.submitTaskInput
37
39
  respondToTaskInteraction = mocks.respondToTaskInteraction
38
40
  getTask = mocks.getTask
@@ -52,6 +54,7 @@ describe('task tool integration', () => {
52
54
  mocks.getParentSessionId.mockReturnValue(undefined)
53
55
  mocks.createChildSession.mockResolvedValue({})
54
56
  mocks.startTask.mockResolvedValue(undefined)
57
+ mocks.sendTaskMessage.mockResolvedValue(undefined)
55
58
  mocks.submitTaskInput.mockResolvedValue(undefined)
56
59
  mocks.respondToTaskInteraction.mockResolvedValue(undefined)
57
60
  mocks.getTask.mockImplementation((taskId: string) => ({
@@ -181,15 +184,96 @@ describe('task tool integration', () => {
181
184
  const tester = createToolTester()
182
185
  createTaskRegister()(tester.mockRegister)
183
186
 
187
+ expect(tester.getRegisteredTools()).toContain('SendTaskMessage')
184
188
  expect(tester.getRegisteredTools()).toContain('SubmitTaskInput')
185
189
  expect(tester.getRegisteredTools()).toContain('RespondTaskInteraction')
186
190
  expect(tester.getToolInfo('StartTasks')?.description).toContain('GetTaskInfo')
187
- expect(tester.getToolInfo('GetTaskInfo')?.description).toContain('SubmitTaskInput')
191
+ expect(tester.getToolInfo('StartTasks')?.description).toContain('SendTaskMessage')
192
+ expect(tester.getToolInfo('GetTaskInfo')?.description).toContain('10 most recent logs')
193
+ expect(tester.getToolInfo('GetTaskInfo')?.description).toContain('logOrder')
194
+ expect(tester.getToolInfo('GetTaskInfo')?.description).toContain('SendTaskMessage')
195
+ expect(tester.getToolInfo('SendTaskMessage')?.description).toContain('mode "direct"')
196
+ expect(tester.getToolInfo('SendTaskMessage')?.description).toContain('mode "steer"')
197
+ expect(tester.getToolInfo('ListTasks')?.description).toContain('10 most recent logs')
198
+ expect(tester.getToolInfo('ListTasks')?.description).toContain('SendTaskMessage')
188
199
  expect(tester.getToolInfo('ListTasks')?.description).toContain('pendingInput')
200
+ expect(tester.getToolInfo('SubmitTaskInput')?.description).toContain('SendTaskMessage')
189
201
  expect(tester.getToolInfo('SubmitTaskInput')?.description).toContain('allow_once')
190
202
  expect(tester.getToolInfo('RespondTaskInteraction')?.description).toContain('Deprecated alias')
191
203
  })
192
204
 
205
+ it('returns the 10 most recent logs in descending order by default', async () => {
206
+ mocks.getTask.mockReturnValue({
207
+ taskId: 'task-1',
208
+ status: 'running',
209
+ logs: Array.from({ length: 12 }, (_, index) => `log-${index + 1}`)
210
+ })
211
+
212
+ const { createTaskRegister } = await import('#~/tools/task/index.js')
213
+
214
+ const tester = createToolTester()
215
+ createTaskRegister()(tester.mockRegister)
216
+
217
+ const result = await tester.callTool('GetTaskInfo', {
218
+ taskId: 'task-1'
219
+ }) as { content: Array<{ text: string }> }
220
+ const [task] = JSON.parse(result.content[0].text) as Array<{ logs: string[] }>
221
+
222
+ expect(task.logs).toEqual([
223
+ 'log-12',
224
+ 'log-11',
225
+ 'log-10',
226
+ 'log-9',
227
+ 'log-8',
228
+ 'log-7',
229
+ 'log-6',
230
+ 'log-5',
231
+ 'log-4',
232
+ 'log-3'
233
+ ])
234
+ })
235
+
236
+ it('supports custom log windows and ascending order in ListTasks', async () => {
237
+ mocks.getAllTasks.mockReturnValue([
238
+ {
239
+ taskId: 'task-1',
240
+ status: 'running',
241
+ logs: ['a', 'b', 'c', 'd']
242
+ },
243
+ {
244
+ taskId: 'task-2',
245
+ status: 'completed',
246
+ logs: ['1', '2', '3']
247
+ }
248
+ ])
249
+
250
+ const { createTaskRegister } = await import('#~/tools/task/index.js')
251
+
252
+ const tester = createToolTester()
253
+ createTaskRegister()(tester.mockRegister)
254
+
255
+ const result = await tester.callTool('ListTasks', {
256
+ logLimit: 2,
257
+ logOrder: 'asc'
258
+ }) as { content: Array<{ text: string }> }
259
+ const tasks = JSON.parse(result.content[0].text) as Array<{ taskId: string; logs: string[] }>
260
+
261
+ expect(tasks).toEqual([
262
+ {
263
+ taskId: 'task-1',
264
+ status: 'running',
265
+ logs: ['c', 'd'],
266
+ guidance: []
267
+ },
268
+ {
269
+ taskId: 'task-2',
270
+ status: 'completed',
271
+ logs: ['2', '3'],
272
+ guidance: []
273
+ }
274
+ ])
275
+ })
276
+
193
277
  it('forwards SubmitTaskInput to the task manager', async () => {
194
278
  mocks.getTask.mockReturnValue({
195
279
  taskId: 'task-1',
@@ -214,6 +298,31 @@ describe('task tool integration', () => {
214
298
  })
215
299
  })
216
300
 
301
+ it('forwards SendTaskMessage to the task manager', async () => {
302
+ mocks.getTask.mockReturnValue({
303
+ taskId: 'task-1',
304
+ status: 'running',
305
+ logs: ['Queued task message (steer): keep checking logs']
306
+ })
307
+
308
+ const { createTaskRegister } = await import('#~/tools/task/index.js')
309
+
310
+ const tester = createToolTester()
311
+ createTaskRegister()(tester.mockRegister)
312
+
313
+ await tester.callTool('SendTaskMessage', {
314
+ taskId: 'task-1',
315
+ message: 'keep checking logs',
316
+ mode: 'steer'
317
+ })
318
+
319
+ expect(mocks.sendTaskMessage).toHaveBeenCalledWith({
320
+ taskId: 'task-1',
321
+ message: 'keep checking logs',
322
+ mode: 'steer'
323
+ })
324
+ })
325
+
217
326
  it('keeps RespondTaskInteraction as a deprecated alias', async () => {
218
327
  mocks.getTask.mockReturnValue({
219
328
  taskId: 'task-1',
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@vibe-forge/mcp",
3
- "version": "2.0.1",
3
+ "version": "2.0.2-alpha.0",
4
4
  "description": "Vibe Forge MCP server",
5
5
  "imports": {
6
6
  "#~/*.js": {
@@ -34,12 +34,12 @@
34
34
  "@vibe-forge/config": "^2.0.2",
35
35
  "commander": "^12.1.0",
36
36
  "zod": "^3.24.1",
37
- "@vibe-forge/hooks": "^2.0.1",
38
- "@vibe-forge/register": "^2.0.1",
39
- "@vibe-forge/cli-helper": "^2.0.1",
40
- "@vibe-forge/utils": "^2.0.1",
41
- "@vibe-forge/types": "^2.0.1",
42
- "@vibe-forge/task": "^2.0.1"
37
+ "@vibe-forge/cli-helper": "^2.0.0",
38
+ "@vibe-forge/hooks": "^2.0.0",
39
+ "@vibe-forge/task": "2.0.1-alpha.3",
40
+ "@vibe-forge/types": "^2.0.2",
41
+ "@vibe-forge/register": "^2.0.0",
42
+ "@vibe-forge/utils": "2.0.4-alpha.1"
43
43
  },
44
44
  "scripts": {
45
45
  "test": "pnpm -C ../.. exec vitest run --workspace vitest.workspace.ts --project bundler packages/mcp/__tests__"
@@ -61,6 +61,7 @@ export interface TaskInfo {
61
61
  exitCode?: number
62
62
  logs: string[]
63
63
  permissionState: SessionPermissionState
64
+ queuedSteerMessages: string[]
64
65
  pendingInteraction?: PendingTaskInteraction
65
66
  lastError?: AdapterErrorData
66
67
  session?: ManagedTaskSession
@@ -252,6 +253,7 @@ export class TaskManager {
252
253
  status: 'running',
253
254
  logs: [],
254
255
  permissionState: createEmptySessionPermissionState(),
256
+ queuedSteerMessages: [],
255
257
  createdAt: Date.now()
256
258
  }
257
259
  if (enableServerSync) {
@@ -285,7 +287,7 @@ export class TaskManager {
285
287
  return { taskId }
286
288
  }
287
289
 
288
- private async launchTask(task: TaskInfo, runType: 'create' | 'resume') {
290
+ private async launchTask(task: TaskInfo, runType: 'create' | 'resume', resumeMessage?: string) {
289
291
  try {
290
292
  const rootCwd = process.cwd()
291
293
  const promptType = task.type !== 'default' ? task.type : undefined
@@ -374,7 +376,7 @@ export class TaskManager {
374
376
  type: 'message',
375
377
  content: [{
376
378
  type: 'text',
377
- text: runType === 'resume' ? TASK_PERMISSION_CONTINUE_PROMPT : current.description
379
+ text: resumeMessage ?? (runType === 'resume' ? TASK_PERMISSION_CONTINUE_PROMPT : current.description)
378
380
  }]
379
381
  })
380
382
  } catch (err) {
@@ -467,6 +469,17 @@ export class TaskManager {
467
469
  task.status = 'completed'
468
470
  task.pendingInteraction = undefined
469
471
  this.stopServerPolling(taskId)
472
+ if (task.queuedSteerMessages.length > 0) {
473
+ void this.dispatchQueuedSteerMessage(task).catch((error) => {
474
+ task.status = 'failed'
475
+ appendTaskLog(
476
+ task,
477
+ `Failed to resume steer-queued task: ${error instanceof Error ? error.message : String(error)}`
478
+ )
479
+ task.onStop?.()
480
+ })
481
+ break
482
+ }
470
483
  task.onStop?.()
471
484
  break
472
485
  }
@@ -489,6 +502,17 @@ export class TaskManager {
489
502
  task.pendingInteraction = undefined
490
503
  appendTaskLog(task, `Process exited with code ${event.data.exitCode}`)
491
504
  this.stopServerPolling(taskId)
505
+ if (task.status === 'completed' && task.queuedSteerMessages.length > 0) {
506
+ void this.dispatchQueuedSteerMessage(task).catch((error) => {
507
+ task.status = 'failed'
508
+ appendTaskLog(
509
+ task,
510
+ `Failed to resume steer-queued task: ${error instanceof Error ? error.message : String(error)}`
511
+ )
512
+ task.onStop?.()
513
+ })
514
+ break
515
+ }
492
516
  task.onStop?.()
493
517
  break
494
518
  default:
@@ -581,6 +605,74 @@ export class TaskManager {
581
605
  return Array.from(this.tasks.values())
582
606
  }
583
607
 
608
+ public async sendTaskMessage(params: {
609
+ taskId: string
610
+ message: string
611
+ mode?: 'direct' | 'steer'
612
+ }): Promise<void> {
613
+ const task = this.tasks.get(params.taskId)
614
+ if (task == null) {
615
+ throw new Error(`Task ${params.taskId} not found.`)
616
+ }
617
+
618
+ const message = params.message.trim()
619
+ const mode = params.mode ?? 'direct'
620
+ if (message === '') {
621
+ throw new Error('Task message cannot be empty.')
622
+ }
623
+
624
+ if (task.pendingInteraction != null || task.status === 'waiting_input') {
625
+ throw new Error(`Task ${params.taskId} is waiting for input. Use SubmitTaskInput instead.`)
626
+ }
627
+
628
+ if (mode === 'steer') {
629
+ if (task.status === 'completed' || task.status === 'failed') {
630
+ throw new Error(`Task ${params.taskId} is not active. Start a new task instead.`)
631
+ }
632
+ task.queuedSteerMessages.push(message)
633
+ appendTaskLog(task, `Queued task message (${mode}): ${message}`)
634
+ return
635
+ }
636
+
637
+ if (task.status !== 'running' || task.session == null) {
638
+ throw new Error(`Task ${params.taskId} is not running. Start a new task instead.`)
639
+ }
640
+
641
+ if (task.serverSync != null) {
642
+ await postSessionEvent(task.serverSync.sessionId, {
643
+ type: 'message',
644
+ data: {
645
+ id: `task-user:${task.taskId}:${Date.now()}:${Math.random().toString(36).slice(2, 8)}`,
646
+ role: 'user',
647
+ content: message,
648
+ createdAt: Date.now()
649
+ }
650
+ })
651
+ } else {
652
+ task.session.emit({
653
+ type: 'message',
654
+ content: [{
655
+ type: 'text',
656
+ text: message
657
+ }]
658
+ })
659
+ }
660
+
661
+ appendTaskLog(task, `User message submitted (${mode}): ${message}`)
662
+ }
663
+
664
+ private async dispatchQueuedSteerMessage(task: TaskInfo) {
665
+ const nextMessage = task.queuedSteerMessages.shift()
666
+ if (nextMessage == null || nextMessage.trim() === '') {
667
+ return false
668
+ }
669
+
670
+ task.status = 'running'
671
+ appendTaskLog(task, `Resuming task from steer queue: ${nextMessage}`)
672
+ await this.launchTask(task, 'resume', nextMessage)
673
+ return true
674
+ }
675
+
584
676
  public async submitTaskInput(params: {
585
677
  taskId: string
586
678
  interactionId?: string
@@ -5,15 +5,21 @@ import type { SessionPermissionMode } from '@vibe-forge/types'
5
5
  import type { TaskInfo } from './manager'
6
6
 
7
7
  export const SESSION_PERMISSION_MODES = ['default', 'acceptEdits', 'plan', 'dontAsk', 'bypassPermissions'] as const
8
+ export const TASK_LOG_ORDERS = ['asc', 'desc'] as const
9
+ export const DEFAULT_TASK_LOG_LIMIT = 10
10
+ export type TaskLogsOrder = typeof TASK_LOG_ORDERS[number]
8
11
 
9
12
  export const START_TASKS_DESCRIPTION =
10
- 'Start multiple tasks in background or foreground. Use type "workspace" plus name to run in a configured workspace. If a task stalls, fails, or asks for permission/input, call GetTaskInfo. If GetTaskInfo returns pendingInput or pendingInteraction, resolve it with SubmitTaskInput. If logs show permission_required, you can answer the recovery prompt with SubmitTaskInput instead of restarting manually.'
13
+ 'Start multiple tasks in background or foreground. Use type "workspace" plus name to run in a configured workspace. If a task stalls, fails, or asks for permission/input, call GetTaskInfo. GetTaskInfo returns the 10 most recent logs by default in descending order, so newer log lines appear earlier in the logs array. If you need to add another instruction to a task that is still running, use SendTaskMessage instead of starting a replacement task. If GetTaskInfo returns pendingInput or pendingInteraction, resolve it with SubmitTaskInput. If logs show permission_required, you can answer the recovery prompt with SubmitTaskInput instead of restarting manually.'
11
14
 
12
15
  export const GET_TASK_INFO_DESCRIPTION =
13
- 'Get the detailed status, logs, pendingInput, pendingInteraction, lastError, and guidance for a task. Use this when a task seems stuck, is waiting for permission/input, or has failed. If pendingInput is present, answer it with SubmitTaskInput.'
16
+ 'Get the detailed status, logs, pendingInput, pendingInteraction, lastError, and guidance for a task. By default this returns the 10 most recent logs in descending order, so newer log lines appear earlier in the logs array. Use logLimit to inspect a different number of recent logs, and set logOrder to "asc" when you want the selected log window in oldest-to-newest order. If the task is still active and you need to continue it, call SendTaskMessage with mode "direct" or "steer" depending on timing. If pendingInput is present, answer it with SubmitTaskInput.'
17
+
18
+ export const SEND_TASK_MESSAGE_DESCRIPTION =
19
+ 'Send a follow-up user message to a managed task. Use mode "direct" (default) to continue the current running task immediately. Use mode "steer" to queue a follow-up that should run after the current task finishes naturally. Do not use this to answer pendingInput or pendingInteraction; use SubmitTaskInput for that. If the task already completed or failed, start a new task instead.'
14
20
 
15
21
  export const SUBMIT_TASK_INPUT_DESCRIPTION =
16
- 'Submit input for a task that is blocked waiting for permission or user input. First call GetTaskInfo or ListTasks, then use taskId plus one of pendingInput.payload.options[].value when available. Common permission answers are allow_once, allow_session, allow_project, deny_once, deny_session, or deny_project.'
22
+ 'Submit input for a task that is blocked waiting for permission or user input. First call GetTaskInfo or ListTasks, then use taskId plus one of pendingInput.payload.options[].value when available. Do not use this for ordinary follow-up instructions to a running task or queued steer messages; use SendTaskMessage instead. Common permission answers are allow_once, allow_session, allow_project, deny_once, deny_session, or deny_project.'
17
23
 
18
24
  export const RESPOND_TASK_INTERACTION_DESCRIPTION =
19
25
  'Deprecated alias of SubmitTaskInput. Use SubmitTaskInput for both permission prompts and generic task input.'
@@ -22,7 +28,7 @@ export const STOP_TASK_DESCRIPTION =
22
28
  'Stop a running or blocked task. Use this when the task is no longer needed or cannot recover cleanly.'
23
29
 
24
30
  export const LIST_TASKS_DESCRIPTION =
25
- 'List all managed tasks with status, pendingInput, pendingInteraction, lastError, and guidance. Use this first to find which tasks are blocked; then call GetTaskInfo for one task or SubmitTaskInput if it is waiting for input.'
31
+ 'List all managed tasks with status, logs, pendingInput, pendingInteraction, lastError, and guidance. Each task returns the 10 most recent logs by default in descending order, so newer log lines appear earlier in the logs array. Use logLimit and logOrder to adjust the recent log window for every listed task. If a listed task needs another instruction, call SendTaskMessage with mode "direct" or "steer". If it is waiting for input, call GetTaskInfo for details or SubmitTaskInput to answer it.'
26
32
 
27
33
  export const TASK_PERMISSION_MODE_DESCRIPTION =
28
34
  'Permission mode for the task. If omitted, inherits the parent session. Raise it only when the task is blocked by permission errors.'
@@ -30,6 +36,12 @@ export const TASK_PERMISSION_MODE_DESCRIPTION =
30
36
  export const TASK_BACKGROUND_DESCRIPTION =
31
37
  'Whether to run in background (default: true). If false, waits until the task completes, fails, or becomes blocked waiting for input, then returns the current logs.'
32
38
 
39
+ export const TASK_LOG_LIMIT_DESCRIPTION =
40
+ `How many recent log entries to include. Defaults to ${DEFAULT_TASK_LOG_LIMIT}.`
41
+
42
+ export const TASK_LOG_ORDER_DESCRIPTION =
43
+ 'Order of the selected log window. Defaults to "desc", which returns newer log lines first. Use "asc" for oldest-to-newest order.'
44
+
33
45
  export const resolveInheritedPermissionMode = (): SessionPermissionMode | undefined => {
34
46
  const value = process.env.__VF_PROJECT_AI_PERMISSION_MODE__?.trim()
35
47
  if (value == null || value === '') return undefined
@@ -74,20 +86,33 @@ export const serializeTaskInfo = (params: {
74
86
  description?: string
75
87
  status?: TaskInfo['status']
76
88
  info?: TaskInfo
89
+ logLimit?: number
90
+ logOrder?: TaskLogsOrder
77
91
  }) => {
78
92
  const info = params.info
79
93
  const safeInfo = (() => {
80
94
  if (info == null) {
81
95
  return undefined
82
96
  }
83
- const { session, onStop, serverSync, createdAt, ...rest } = info
97
+ const { session, onStop, serverSync, createdAt, logs, ...rest } = info
84
98
  return rest
85
99
  })()
100
+ const selectedLogs = (() => {
101
+ const logs = info?.logs ?? []
102
+ const limit = params.logLimit
103
+ const windowedLogs = limit == null
104
+ ? logs
105
+ : logs.slice(Math.max(0, logs.length - limit))
106
+ return params.logOrder === 'desc'
107
+ ? [...windowedLogs].reverse()
108
+ : windowedLogs
109
+ })()
110
+
86
111
  return {
87
112
  taskId: params.taskId,
88
113
  description: params.description ?? info?.description,
89
114
  status: info?.status ?? params.status,
90
- logs: info?.logs ?? [],
115
+ logs: selectedLogs,
91
116
  pendingInput: safeInfo?.pendingInteraction,
92
117
  ...safeInfo,
93
118
  guidance: buildTaskGuidance(safeInfo ?? {})
@@ -3,11 +3,16 @@ import { z } from 'zod'
3
3
  import type { Register } from '../types'
4
4
  import type { TaskManager } from './manager'
5
5
  import {
6
+ DEFAULT_TASK_LOG_LIMIT,
6
7
  GET_TASK_INFO_DESCRIPTION,
7
8
  LIST_TASKS_DESCRIPTION,
8
9
  RESPOND_TASK_INTERACTION_DESCRIPTION,
10
+ SEND_TASK_MESSAGE_DESCRIPTION,
9
11
  STOP_TASK_DESCRIPTION,
10
12
  SUBMIT_TASK_INPUT_DESCRIPTION,
13
+ TASK_LOG_LIMIT_DESCRIPTION,
14
+ TASK_LOG_ORDER_DESCRIPTION,
15
+ TASK_LOG_ORDERS,
11
16
  serializeTaskInfo
12
17
  } from './presentation'
13
18
 
@@ -21,10 +26,20 @@ export const registerTaskRuntimeTools = (
21
26
  title: 'Get Task Info',
22
27
  description: GET_TASK_INFO_DESCRIPTION,
23
28
  inputSchema: z.object({
24
- taskId: z.string().describe('The ID of the task to check')
29
+ taskId: z.string().describe('The ID of the task to check'),
30
+ logLimit: z
31
+ .number()
32
+ .int()
33
+ .min(1)
34
+ .describe(TASK_LOG_LIMIT_DESCRIPTION)
35
+ .default(DEFAULT_TASK_LOG_LIMIT),
36
+ logOrder: z
37
+ .enum(TASK_LOG_ORDERS)
38
+ .describe(TASK_LOG_ORDER_DESCRIPTION)
39
+ .default('desc')
25
40
  })
26
41
  },
27
- async ({ taskId }) => {
42
+ async ({ taskId, logLimit, logOrder }) => {
28
43
  const task = taskManager.getTask(taskId)
29
44
  if (!task) {
30
45
  return {
@@ -35,7 +50,46 @@ export const registerTaskRuntimeTools = (
35
50
  return {
36
51
  content: [{
37
52
  type: 'text',
38
- text: JSON.stringify([serializeTaskInfo({ taskId, info: task })])
53
+ text: JSON.stringify([serializeTaskInfo({ taskId, info: task, logLimit, logOrder })])
54
+ }]
55
+ }
56
+ }
57
+ )
58
+
59
+ server.registerTool(
60
+ 'SendTaskMessage',
61
+ {
62
+ title: 'Send Task Message',
63
+ description: SEND_TASK_MESSAGE_DESCRIPTION,
64
+ inputSchema: z.object({
65
+ taskId: z.string().describe('The ID of the running task to continue'),
66
+ message: z
67
+ .string()
68
+ .trim()
69
+ .min(1)
70
+ .describe('The follow-up instruction to send to the task'),
71
+ mode: z
72
+ .enum(['direct', 'steer'])
73
+ .describe('How to deliver the message: direct (default) or steer')
74
+ .default('direct')
75
+ })
76
+ },
77
+ async ({ taskId, message, mode }) => {
78
+ await taskManager.sendTaskMessage({
79
+ taskId,
80
+ message,
81
+ mode
82
+ })
83
+ const task = taskManager.getTask(taskId)
84
+ return {
85
+ content: [{
86
+ type: 'text',
87
+ text: JSON.stringify([serializeTaskInfo({
88
+ taskId,
89
+ info: task,
90
+ logLimit: DEFAULT_TASK_LOG_LIMIT,
91
+ logOrder: 'desc'
92
+ })])
39
93
  }]
40
94
  }
41
95
  }
@@ -67,7 +121,12 @@ export const registerTaskRuntimeTools = (
67
121
  return {
68
122
  content: [{
69
123
  type: 'text',
70
- text: JSON.stringify([serializeTaskInfo({ taskId, info: task })])
124
+ text: JSON.stringify([serializeTaskInfo({
125
+ taskId,
126
+ info: task,
127
+ logLimit: DEFAULT_TASK_LOG_LIMIT,
128
+ logOrder: 'desc'
129
+ })])
71
130
  }]
72
131
  }
73
132
  }
@@ -99,7 +158,12 @@ export const registerTaskRuntimeTools = (
99
158
  return {
100
159
  content: [{
101
160
  type: 'text',
102
- text: JSON.stringify([serializeTaskInfo({ taskId, info: task })])
161
+ text: JSON.stringify([serializeTaskInfo({
162
+ taskId,
163
+ info: task,
164
+ logLimit: DEFAULT_TASK_LOG_LIMIT,
165
+ logOrder: 'desc'
166
+ })])
103
167
  }]
104
168
  }
105
169
  }
@@ -130,14 +194,30 @@ export const registerTaskRuntimeTools = (
130
194
  {
131
195
  title: 'List Tasks',
132
196
  description: LIST_TASKS_DESCRIPTION,
133
- inputSchema: z.object({})
197
+ inputSchema: z.object({
198
+ logLimit: z
199
+ .number()
200
+ .int()
201
+ .min(1)
202
+ .describe(TASK_LOG_LIMIT_DESCRIPTION)
203
+ .default(DEFAULT_TASK_LOG_LIMIT),
204
+ logOrder: z
205
+ .enum(TASK_LOG_ORDERS)
206
+ .describe(TASK_LOG_ORDER_DESCRIPTION)
207
+ .default('desc')
208
+ })
134
209
  },
135
- async () => {
210
+ async ({ logLimit, logOrder }) => {
136
211
  const tasks = taskManager.getAllTasks()
137
212
  return {
138
213
  content: [{
139
214
  type: 'text',
140
- text: JSON.stringify(tasks.map(task => serializeTaskInfo({ taskId: task.taskId, info: task })))
215
+ text: JSON.stringify(tasks.map(task => serializeTaskInfo({
216
+ taskId: task.taskId,
217
+ info: task,
218
+ logLimit,
219
+ logOrder
220
+ })))
141
221
  }]
142
222
  }
143
223
  }