@vibe-forge/mcp 2.0.0 → 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) => ({
@@ -94,6 +97,28 @@ describe('task tool integration', () => {
94
97
  }))
95
98
  })
96
99
 
100
+ it('accepts workspace tasks without a separate workspace tool', async () => {
101
+ const { createTaskRegister } = await import('#~/tools/task/index.js')
102
+
103
+ const tester = createToolTester()
104
+ createTaskRegister()(tester.mockRegister)
105
+
106
+ await tester.callTool('StartTasks', {
107
+ tasks: [{
108
+ description: 'fix billing',
109
+ type: 'workspace',
110
+ name: 'billing'
111
+ }]
112
+ })
113
+
114
+ expect(mocks.startTask).toHaveBeenCalledWith(expect.objectContaining({
115
+ taskId: 'task-1',
116
+ description: 'fix billing',
117
+ type: 'workspace',
118
+ name: 'billing'
119
+ }))
120
+ })
121
+
97
122
  it('inherits the parent permission mode when the task does not specify one', async () => {
98
123
  process.env.__VF_PROJECT_AI_PERMISSION_MODE__ = 'dontAsk'
99
124
  mocks.getParentSessionId.mockReturnValue('parent-session')
@@ -159,15 +184,96 @@ describe('task tool integration', () => {
159
184
  const tester = createToolTester()
160
185
  createTaskRegister()(tester.mockRegister)
161
186
 
187
+ expect(tester.getRegisteredTools()).toContain('SendTaskMessage')
162
188
  expect(tester.getRegisteredTools()).toContain('SubmitTaskInput')
163
189
  expect(tester.getRegisteredTools()).toContain('RespondTaskInteraction')
164
190
  expect(tester.getToolInfo('StartTasks')?.description).toContain('GetTaskInfo')
165
- 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')
166
199
  expect(tester.getToolInfo('ListTasks')?.description).toContain('pendingInput')
200
+ expect(tester.getToolInfo('SubmitTaskInput')?.description).toContain('SendTaskMessage')
167
201
  expect(tester.getToolInfo('SubmitTaskInput')?.description).toContain('allow_once')
168
202
  expect(tester.getToolInfo('RespondTaskInteraction')?.description).toContain('Deprecated alias')
169
203
  })
170
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
+
171
277
  it('forwards SubmitTaskInput to the task manager', async () => {
172
278
  mocks.getTask.mockReturnValue({
173
279
  taskId: 'task-1',
@@ -192,6 +298,31 @@ describe('task tool integration', () => {
192
298
  })
193
299
  })
194
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
+
195
326
  it('keeps RespondTaskInteraction as a deprecated alias', async () => {
196
327
  mocks.getTask.mockReturnValue({
197
328
  taskId: 'task-1',
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@vibe-forge/mcp",
3
- "version": "2.0.0",
3
+ "version": "2.0.2-alpha.0",
4
4
  "description": "Vibe Forge MCP server",
5
5
  "imports": {
6
6
  "#~/*.js": {
@@ -31,15 +31,15 @@
31
31
  },
32
32
  "dependencies": {
33
33
  "@modelcontextprotocol/sdk": "^1.25.3",
34
+ "@vibe-forge/config": "^2.0.2",
34
35
  "commander": "^12.1.0",
35
36
  "zod": "^3.24.1",
37
+ "@vibe-forge/cli-helper": "^2.0.0",
36
38
  "@vibe-forge/hooks": "^2.0.0",
37
- "@vibe-forge/types": "^2.0.0",
38
- "@vibe-forge/config": "^2.0.0",
39
- "@vibe-forge/task": "^2.0.0",
40
- "@vibe-forge/utils": "^2.0.0",
39
+ "@vibe-forge/task": "2.0.1-alpha.3",
40
+ "@vibe-forge/types": "^2.0.2",
41
41
  "@vibe-forge/register": "^2.0.0",
42
- "@vibe-forge/cli-helper": "^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__"
@@ -38,9 +38,10 @@ export const createTaskRegister = () => {
38
38
  .enum([
39
39
  'default',
40
40
  'spec',
41
- 'entity'
41
+ 'entity',
42
+ 'workspace'
42
43
  ])
43
- .describe('The type of definition to load (default, spec or entity)'),
44
+ .describe('The type of definition to load (default, spec, entity or workspace)'),
44
45
  name: z
45
46
  .string()
46
47
  .describe('The name of the spec or entity to load, if type is spec or entity. Otherwise, ignored.')
@@ -52,14 +52,16 @@ export interface TaskInfo {
52
52
  taskId: string
53
53
  adapter?: string
54
54
  description: string
55
- type?: 'default' | 'spec' | 'entity'
55
+ type?: 'default' | 'spec' | 'entity' | 'workspace'
56
56
  name?: string
57
+ workspaceCwd?: string
57
58
  permissionMode?: SessionPermissionMode
58
59
  background?: boolean
59
60
  status: 'running' | 'waiting_input' | 'completed' | 'failed'
60
61
  exitCode?: number
61
62
  logs: string[]
62
63
  permissionState: SessionPermissionState
64
+ queuedSteerMessages: string[]
63
65
  pendingInteraction?: PendingTaskInteraction
64
66
  lastError?: AdapterErrorData
65
67
  session?: ManagedTaskSession
@@ -230,7 +232,7 @@ export class TaskManager {
230
232
  public async startTask(options: {
231
233
  taskId: string
232
234
  description: string
233
- type?: 'default' | 'spec' | 'entity'
235
+ type?: 'default' | 'spec' | 'entity' | 'workspace'
234
236
  name?: string
235
237
  permissionMode?: 'default' | 'acceptEdits' | 'plan' | 'dontAsk' | 'bypassPermissions'
236
238
  adapter?: string
@@ -251,6 +253,7 @@ export class TaskManager {
251
253
  status: 'running',
252
254
  logs: [],
253
255
  permissionState: createEmptySessionPermissionState(),
256
+ queuedSteerMessages: [],
254
257
  createdAt: Date.now()
255
258
  }
256
259
  if (enableServerSync) {
@@ -284,8 +287,9 @@ export class TaskManager {
284
287
  return { taskId }
285
288
  }
286
289
 
287
- private async launchTask(task: TaskInfo, runType: 'create' | 'resume') {
290
+ private async launchTask(task: TaskInfo, runType: 'create' | 'resume', resumeMessage?: string) {
288
291
  try {
292
+ const rootCwd = process.cwd()
289
293
  const promptType = task.type !== 'default' ? task.type : undefined
290
294
  const promptName = task.name
291
295
  const promptCWD = process.cwd()
@@ -297,25 +301,29 @@ export class TaskManager {
297
301
  adapter: task.adapter
298
302
  }
299
303
  )
304
+ const taskCwd = resolvedConfig.workspace?.cwd ?? promptCWD
305
+ task.workspaceCwd = resolvedConfig.workspace?.cwd
300
306
  const env = {
301
307
  ...process.env,
302
- __VF_PROJECT_AI_CTX_ID__: process.env.__VF_PROJECT_AI_CTX_ID__ ?? task.taskId
308
+ __VF_PROJECT_AI_CTX_ID__: process.env.__VF_PROJECT_AI_CTX_ID__ ?? task.taskId,
309
+ __VF_PROJECT_WORKSPACE_FOLDER__: taskCwd,
310
+ __VF_PROJECT_PRIMARY_WORKSPACE_FOLDER__: rootCwd
303
311
  }
304
312
  await callHook('GenerateSystemPrompt', {
305
- cwd: promptCWD,
313
+ cwd: taskCwd,
306
314
  sessionId: task.taskId,
307
315
  type: promptType,
308
316
  name: promptName,
309
317
  data
310
318
  }, env)
311
319
 
312
- const injectDefaultSystemPrompt = await loadInjectDefaultSystemPromptValue(promptCWD)
320
+ const injectDefaultSystemPrompt = await loadInjectDefaultSystemPromptValue(taskCwd)
313
321
  const ctxId = process.env.__VF_PROJECT_AI_CTX_ID__ ?? task.taskId
314
322
  const { session, resolvedAdapter } = await run({
315
323
  adapter: task.adapter,
316
- cwd: process.cwd(),
324
+ cwd: taskCwd,
317
325
  env: {
318
- ...process.env,
326
+ ...env,
319
327
  __VF_PROJECT_AI_CTX_ID__: ctxId
320
328
  }
321
329
  }, {
@@ -352,7 +360,7 @@ export class TaskManager {
352
360
  current.lastError = undefined
353
361
  try {
354
362
  await syncTaskPermissionStateMirror({
355
- cwd: process.cwd(),
363
+ cwd: taskCwd,
356
364
  adapter: current.adapter,
357
365
  sessionId: current.taskId,
358
366
  permissionState: current.permissionState
@@ -368,7 +376,7 @@ export class TaskManager {
368
376
  type: 'message',
369
377
  content: [{
370
378
  type: 'text',
371
- text: runType === 'resume' ? TASK_PERMISSION_CONTINUE_PROMPT : current.description
379
+ text: resumeMessage ?? (runType === 'resume' ? TASK_PERMISSION_CONTINUE_PROMPT : current.description)
372
380
  }]
373
381
  })
374
382
  } catch (err) {
@@ -461,6 +469,17 @@ export class TaskManager {
461
469
  task.status = 'completed'
462
470
  task.pendingInteraction = undefined
463
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
+ }
464
483
  task.onStop?.()
465
484
  break
466
485
  }
@@ -483,6 +502,17 @@ export class TaskManager {
483
502
  task.pendingInteraction = undefined
484
503
  appendTaskLog(task, `Process exited with code ${event.data.exitCode}`)
485
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
+ }
486
516
  task.onStop?.()
487
517
  break
488
518
  default:
@@ -575,6 +605,74 @@ export class TaskManager {
575
605
  return Array.from(this.tasks.values())
576
606
  }
577
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
+
578
676
  public async submitTaskInput(params: {
579
677
  taskId: string
580
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. 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
  }