@swarmclawai/swarmclaw 0.7.6 → 0.7.8

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.
Files changed (86) hide show
  1. package/README.md +19 -10
  2. package/package.json +1 -1
  3. package/src/app/api/agents/[id]/route.ts +16 -0
  4. package/src/app/api/agents/route.ts +2 -0
  5. package/src/app/api/chats/[id]/route.ts +21 -1
  6. package/src/app/api/chats/route.ts +13 -1
  7. package/src/app/api/connectors/[id]/route.ts +20 -2
  8. package/src/app/api/connectors/route.ts +12 -8
  9. package/src/app/api/external-agents/[id]/heartbeat/route.ts +3 -0
  10. package/src/app/api/external-agents/[id]/route.ts +38 -6
  11. package/src/app/api/external-agents/route.ts +17 -1
  12. package/src/app/api/gateways/[id]/health/route.ts +8 -0
  13. package/src/app/api/gateways/[id]/route.ts +53 -1
  14. package/src/app/api/gateways/route.ts +53 -0
  15. package/src/app/api/openclaw/deploy/route.ts +139 -0
  16. package/src/app/api/projects/[id]/route.ts +6 -2
  17. package/src/app/api/projects/route.ts +4 -3
  18. package/src/app/api/secrets/[id]/route.ts +1 -0
  19. package/src/app/api/secrets/route.ts +2 -1
  20. package/src/app/api/settings/route.ts +2 -0
  21. package/src/cli/index.js +40 -0
  22. package/src/cli/index.test.js +68 -0
  23. package/src/cli/spec.js +60 -0
  24. package/src/components/agents/agent-sheet.tsx +281 -33
  25. package/src/components/auth/setup-wizard.tsx +75 -2
  26. package/src/components/chat/chat-area.tsx +36 -19
  27. package/src/components/chat/chat-header.tsx +4 -0
  28. package/src/components/chat/delegation-banner.test.ts +14 -1
  29. package/src/components/chat/delegation-banner.tsx +1 -1
  30. package/src/components/gateways/gateway-sheet.tsx +140 -8
  31. package/src/components/layout/app-layout.tsx +40 -23
  32. package/src/components/openclaw/openclaw-deploy-panel.tsx +591 -9
  33. package/src/components/projects/project-detail.tsx +217 -0
  34. package/src/components/projects/project-sheet.tsx +176 -4
  35. package/src/components/providers/provider-list.tsx +221 -17
  36. package/src/components/shared/settings/section-capability-policy.tsx +38 -0
  37. package/src/components/shared/settings/section-voice.tsx +11 -3
  38. package/src/components/tasks/approvals-panel.tsx +177 -18
  39. package/src/components/tasks/task-board.tsx +137 -23
  40. package/src/components/tasks/task-card.tsx +29 -0
  41. package/src/components/tasks/task-sheet.tsx +16 -4
  42. package/src/lib/server/agent-runtime-config.ts +142 -7
  43. package/src/lib/server/agent-thread-session.ts +9 -1
  44. package/src/lib/server/capability-router.test.ts +22 -0
  45. package/src/lib/server/capability-router.ts +54 -18
  46. package/src/lib/server/chat-execution.ts +33 -3
  47. package/src/lib/server/connectors/manager-reconnect.test.ts +47 -0
  48. package/src/lib/server/connectors/manager.ts +99 -74
  49. package/src/lib/server/daemon-state.ts +83 -46
  50. package/src/lib/server/elevenlabs.test.ts +59 -1
  51. package/src/lib/server/heartbeat-service.ts +5 -1
  52. package/src/lib/server/main-agent-loop.test.ts +260 -0
  53. package/src/lib/server/main-agent-loop.ts +559 -14
  54. package/src/lib/server/openclaw-deploy.test.ts +8 -0
  55. package/src/lib/server/openclaw-deploy.ts +679 -19
  56. package/src/lib/server/orchestrator-lg.ts +1 -0
  57. package/src/lib/server/orchestrator.ts +11 -0
  58. package/src/lib/server/plugins.ts +6 -1
  59. package/src/lib/server/project-context.ts +162 -0
  60. package/src/lib/server/project-utils.ts +150 -0
  61. package/src/lib/server/queue-followups.test.ts +147 -2
  62. package/src/lib/server/queue.ts +278 -8
  63. package/src/lib/server/session-run-manager.ts +31 -0
  64. package/src/lib/server/session-tools/connector-inputs.test.ts +37 -0
  65. package/src/lib/server/session-tools/connector.ts +26 -1
  66. package/src/lib/server/session-tools/context.ts +5 -0
  67. package/src/lib/server/session-tools/crud.ts +265 -76
  68. package/src/lib/server/session-tools/delegate-resume.test.ts +50 -0
  69. package/src/lib/server/session-tools/delegate.ts +38 -2
  70. package/src/lib/server/session-tools/manage-tasks.test.ts +114 -0
  71. package/src/lib/server/session-tools/memory.ts +14 -2
  72. package/src/lib/server/session-tools/platform-access.test.ts +58 -0
  73. package/src/lib/server/session-tools/platform.ts +60 -19
  74. package/src/lib/server/session-tools/web-inputs.test.ts +17 -0
  75. package/src/lib/server/session-tools/web.ts +153 -6
  76. package/src/lib/server/stream-agent-chat.test.ts +27 -2
  77. package/src/lib/server/stream-agent-chat.ts +104 -30
  78. package/src/lib/server/tool-aliases.ts +2 -0
  79. package/src/lib/server/tool-capability-policy.test.ts +24 -0
  80. package/src/lib/server/tool-capability-policy.ts +29 -1
  81. package/src/lib/server/tool-planning.test.ts +44 -0
  82. package/src/lib/server/tool-planning.ts +269 -0
  83. package/src/lib/setup-defaults.ts +2 -2
  84. package/src/lib/tool-definitions.ts +2 -1
  85. package/src/lib/validation/schemas.ts +9 -0
  86. package/src/types/index.ts +104 -0
@@ -7,6 +7,7 @@ import { spawnSync } from 'child_process'
7
7
  import * as cheerio from 'cheerio'
8
8
  import {
9
9
  loadAgents, saveAgents,
10
+ loadProjects, saveProjects,
10
11
  loadTasks, saveTasks,
11
12
  loadSchedules, saveSchedules,
12
13
  loadSkills, saveSkills,
@@ -31,9 +32,11 @@ import {
31
32
  } from '@/lib/server/agent-assignment'
32
33
  import { normalizeTaskQualityGate } from '@/lib/server/task-quality-gate'
33
34
  import { normalizeSchedulePayload } from '@/lib/server/schedule-normalization'
35
+ import { buildProjectSnapshot, ensureProjectWorkspace, normalizeProjectCreateInput, normalizeProjectPatchInput } from '@/lib/server/project-utils'
34
36
  import type { ToolBuildContext } from './context'
35
37
  import { safePath, findBinaryOnPath } from './context'
36
38
  import { normalizeToolInputArgs } from './normalize-tool-args'
39
+ import type { BoardTask } from '@/types'
37
40
 
38
41
  // ---------------------------------------------------------------------------
39
42
  // Document helpers
@@ -132,6 +135,98 @@ function normalizeTaskStatusInput(status: unknown, prevStatus?: string): string
132
135
  return normalized
133
136
  }
134
137
 
138
+ function normalizeTaskIdList(value: unknown): string[] {
139
+ const rawValues = Array.isArray(value)
140
+ ? value
141
+ : typeof value === 'string'
142
+ ? value.split(',')
143
+ : []
144
+ const seen = new Set<string>()
145
+ const out: string[] = []
146
+ for (const entry of rawValues) {
147
+ const normalized = typeof entry === 'string' ? entry.trim() : ''
148
+ if (!normalized || seen.has(normalized)) continue
149
+ seen.add(normalized)
150
+ out.push(normalized)
151
+ }
152
+ return out
153
+ }
154
+
155
+ function pickFirstTaskId(value: unknown): string | null {
156
+ const ids = normalizeTaskIdList(value)
157
+ return ids[0] || null
158
+ }
159
+
160
+ function applyTaskContinuationDefaults(
161
+ parsed: Record<string, unknown>,
162
+ tasks: Record<string, BoardTask>,
163
+ explicitInput?: Record<string, unknown>,
164
+ ): string | null {
165
+ const explicit = explicitInput || parsed
166
+ const continuationTaskId = pickFirstTaskId(parsed.continueFromTaskId)
167
+ || pickFirstTaskId(parsed.followUpToTaskId)
168
+ || pickFirstTaskId(parsed.resumeFromTaskId)
169
+ const blockedBy = [
170
+ ...normalizeTaskIdList(parsed.blockedBy),
171
+ ...normalizeTaskIdList(parsed.dependsOn),
172
+ ...normalizeTaskIdList(parsed.dependsOnTaskIds),
173
+ ...normalizeTaskIdList(parsed.prerequisiteTaskIds),
174
+ ]
175
+ if (continuationTaskId && !blockedBy.includes(continuationTaskId)) {
176
+ blockedBy.unshift(continuationTaskId)
177
+ }
178
+ if (blockedBy.length > 0) parsed.blockedBy = blockedBy
179
+
180
+ if (continuationTaskId) {
181
+ const sourceTask = tasks[continuationTaskId]
182
+ if (!sourceTask) return `Error: source task "${continuationTaskId}" not found.`
183
+
184
+ if (!Object.prototype.hasOwnProperty.call(explicit, 'projectId') && typeof sourceTask.projectId === 'string' && sourceTask.projectId.trim()) {
185
+ parsed.projectId = sourceTask.projectId.trim()
186
+ }
187
+ if (
188
+ !Object.prototype.hasOwnProperty.call(explicit, 'agentId')
189
+ && !hasManagedAgentAssignmentInput(explicit)
190
+ && typeof sourceTask.agentId === 'string'
191
+ && sourceTask.agentId.trim()
192
+ ) {
193
+ parsed.agentId = sourceTask.agentId.trim()
194
+ }
195
+ if (!Object.prototype.hasOwnProperty.call(explicit, 'cwd') && typeof sourceTask.cwd === 'string' && sourceTask.cwd.trim()) {
196
+ parsed.cwd = sourceTask.cwd.trim()
197
+ }
198
+ const sourceSessionId = typeof sourceTask.checkpoint?.lastSessionId === 'string' && sourceTask.checkpoint.lastSessionId.trim()
199
+ ? sourceTask.checkpoint.lastSessionId.trim()
200
+ : typeof sourceTask.sessionId === 'string' && sourceTask.sessionId.trim()
201
+ ? sourceTask.sessionId.trim()
202
+ : ''
203
+ if (!Object.prototype.hasOwnProperty.call(explicit, 'sessionId') && sourceSessionId) {
204
+ parsed.sessionId = sourceSessionId
205
+ }
206
+
207
+ const resumeFieldMap: Array<[keyof BoardTask, string]> = [
208
+ ['cliResumeId', 'cliResumeId'],
209
+ ['cliProvider', 'cliProvider'],
210
+ ['claudeResumeId', 'claudeResumeId'],
211
+ ['codexResumeId', 'codexResumeId'],
212
+ ['opencodeResumeId', 'opencodeResumeId'],
213
+ ['geminiResumeId', 'geminiResumeId'],
214
+ ]
215
+ for (const [sourceKey, targetKey] of resumeFieldMap) {
216
+ const value = sourceTask[sourceKey]
217
+ if (Object.prototype.hasOwnProperty.call(explicit, targetKey)) continue
218
+ if (typeof value === 'string' && value.trim()) {
219
+ parsed[targetKey] = value.trim()
220
+ }
221
+ }
222
+ }
223
+
224
+ for (const aliasKey of ['continueFromTaskId', 'followUpToTaskId', 'resumeFromTaskId', 'dependsOn', 'dependsOnTaskIds', 'prerequisiteTaskIds']) {
225
+ delete parsed[aliasKey]
226
+ }
227
+ return null
228
+ }
229
+
135
230
  // ---------------------------------------------------------------------------
136
231
  // RESOURCE_DEFAULTS
137
232
  // ---------------------------------------------------------------------------
@@ -212,6 +307,7 @@ const RESOURCE_DEFAULTS: Record<string, (parsed: any) => any> = {
212
307
  agentIds: Array.isArray(p.agentIds) ? p.agentIds : [],
213
308
  ...p,
214
309
  }),
310
+ manage_projects: (p) => normalizeProjectCreateInput(p),
215
311
  }
216
312
 
217
313
  // ---------------------------------------------------------------------------
@@ -226,6 +322,7 @@ const PLATFORM_RESOURCES: Record<string, {
226
322
  readOnly?: boolean
227
323
  }> = {
228
324
  manage_agents: { toolId: 'manage_agents', label: 'agents', load: loadAgents, save: saveAgents },
325
+ manage_projects: { toolId: 'manage_projects', label: 'projects', load: loadProjects, save: saveProjects },
229
326
  manage_tasks: { toolId: 'manage_tasks', label: 'tasks', load: loadTasks, save: saveTasks },
230
327
  manage_schedules: { toolId: 'manage_schedules', label: 'schedules', load: loadSchedules, save: saveSchedules },
231
328
  manage_skills: { toolId: 'manage_skills', label: 'skills', load: loadSkills, save: saveSkills },
@@ -242,6 +339,14 @@ const PLATFORM_RESOURCES: Record<string, {
242
339
  export function buildCrudTools(bctx: ToolBuildContext): StructuredToolInterface[] {
243
340
  const tools: StructuredToolInterface[] = []
244
341
  const { cwd, ctx, hasPlugin } = bctx
342
+ const buildCrudPayload = (normalized: Record<string, unknown>, action: string | undefined, data: string | undefined): Record<string, unknown> => {
343
+ if (data) return JSON.parse(data)
344
+ if (action !== 'create' && action !== 'update') return {}
345
+ const entries = Object.entries(normalized).filter(([key]) =>
346
+ !['action', 'id', 'data', 'resource', 'input', 'args', 'arguments', 'payload', 'parameters'].includes(key),
347
+ )
348
+ return entries.length > 0 ? Object.fromEntries(entries) : {}
349
+ }
245
350
 
246
351
  // Build dynamic agent summary for tools that need agent awareness
247
352
  const assignScope = ctx?.platformAssignScope || 'self'
@@ -271,6 +376,17 @@ export function buildCrudTools(bctx: ToolBuildContext): StructuredToolInterface[
271
376
  } else {
272
377
  description += `\n\nYou may create tasks for yourself, leave them unassigned, or delegate them to other agents. Your agent ID is "${ctx?.agentId || 'unknown'}". When delegating, set a target agent using "agentId", "assignee", "agent", "assignedAgentId", or "assigned_agent_id". Use the target agent's exact ID when possible. Valid manual statuses: backlog, queued, completed, failed, archived. "running" is runtime-only and set automatically when execution starts.` + agentSummary
273
378
  }
379
+ description += '\n\nCreate/update calls accept either `data` as a JSON string or direct top-level fields like `title`, `description`, `status`, `agentId`, and `projectId`.'
380
+ description += '\n\nFor follow-up work, set `continueFromTaskId` (or `followUpToTaskId`) to a prior task ID. The new task will inherit the predecessor\'s project/agent/session context, block on that task by default, and reuse its execution session when possible.'
381
+ if (ctx?.projectId) {
382
+ description += `\n\nCurrent project context: "${ctx.projectName || ctx.projectId}" (projectId "${ctx.projectId}"). Omit "projectId" to use this active project by default.`
383
+ }
384
+ } else if (toolKey === 'manage_projects') {
385
+ description += '\n\nProjects hold durable execution context for longer-lived work: objective, audience, pilot priorities, open objectives, credential requirements, and preferred heartbeat cadence.'
386
+ description += '\n\nCreate/update calls accept either `data` as a JSON string or direct top-level fields like `name`, `description`, `objective`, `audience`, `priorities`, `openObjectives`, `capabilityHints`, `credentialRequirements`, `heartbeatPrompt`, and `heartbeatIntervalSec`.'
387
+ if (ctx?.projectId) {
388
+ description += `\n\nCurrent project context: "${ctx.projectName || ctx.projectId}" (projectId "${ctx.projectId}"). For get/update/delete, you may omit "id" to target this active project.`
389
+ }
274
390
  } else if (toolKey === 'manage_agents') {
275
391
  description += `\n\nAgents may self-edit their own soul. To update your soul, use action="update", id="${ctx?.agentId || 'your-agent-id'}", and include data with the "soul" field. Set "platformAssignScope":"all" to let an agent delegate work across the fleet; use "self" for solo execution.`
276
392
  } else if (toolKey === 'manage_schedules') {
@@ -279,8 +395,16 @@ export function buildCrudTools(bctx: ToolBuildContext): StructuredToolInterface[
279
395
  } else {
280
396
  description += `\n\nOmit "agentId" to assign a schedule to yourself ("${ctx?.agentId || 'unknown'}"), or set "agentId" to another agent when needed. Schedule types: interval (set intervalMs), cron (set cron), once (set runAt). Provide either taskPrompt, command, or action+path. Before create, call list/get to avoid duplicate schedules. If an equivalent active/paused schedule already exists, create returns that existing schedule (deduplicated=true).` + agentSummary
281
397
  }
398
+ if (ctx?.projectId) {
399
+ description += `\n\nCurrent project context: "${ctx.projectName || ctx.projectId}" (projectId "${ctx.projectId}"). Omit "projectId" to use this active project by default.`
400
+ }
282
401
  } else if (toolKey === 'manage_webhooks') {
283
402
  description += '\n\nUse `source`, `events`, `agentId`, and `secret` when creating webhooks. Inbound calls should POST to `/api/webhooks/{id}` with header `x-webhook-secret` when a secret is configured.'
403
+ } else if (toolKey === 'manage_secrets') {
404
+ description += '\n\nUse this for credential bootstrapping and durable secret storage. Create/update calls accept either `data` as JSON or direct top-level fields like `name`, `service`, `value`, `scope`, `agentIds`, and `projectId`.'
405
+ if (ctx?.projectId) {
406
+ description += `\n\nCurrent project context: "${ctx.projectName || ctx.projectId}" (projectId "${ctx.projectId}"). Omit "projectId" to link the secret to this active project.`
407
+ }
284
408
  }
285
409
 
286
410
  tools.push(
@@ -298,6 +422,11 @@ export function buildCrudTools(bctx: ToolBuildContext): StructuredToolInterface[
298
422
  }
299
423
  try {
300
424
  if (action === 'list') {
425
+ if (toolKey === 'manage_projects') {
426
+ const values = Object.values(res.load())
427
+ .map((project: any) => buildProjectSnapshot(project))
428
+ return JSON.stringify(values)
429
+ }
301
430
  if (toolKey === 'manage_secrets') {
302
431
  const values = Object.values(res.load())
303
432
  .filter((s: any) => canAccessSecret(s))
@@ -307,6 +436,7 @@ export function buildCrudTools(bctx: ToolBuildContext): StructuredToolInterface[
307
436
  service: s.service,
308
437
  scope: s.scope || 'global',
309
438
  agentIds: s.agentIds || [],
439
+ projectId: s.projectId || null,
310
440
  createdAt: s.createdAt,
311
441
  updatedAt: s.updatedAt,
312
442
  }))
@@ -315,40 +445,56 @@ export function buildCrudTools(bctx: ToolBuildContext): StructuredToolInterface[
315
445
  return JSON.stringify(Object.values(res.load()))
316
446
  }
317
447
  if (action === 'get') {
318
- if (!id) return 'Error: "id" is required for get action.'
448
+ const effectiveId = id || (toolKey === 'manage_projects' ? ctx?.projectId || undefined : undefined)
449
+ if (!effectiveId) return 'Error: "id" is required for get action.'
319
450
  const all = res.load()
320
- if (!all[id]) return `Not found: ${res.label} "${id}"`
451
+ if (!all[effectiveId]) return `Not found: ${res.label} "${effectiveId}"`
452
+ if (toolKey === 'manage_projects') {
453
+ return JSON.stringify(buildProjectSnapshot(all[effectiveId]))
454
+ }
321
455
  if (toolKey === 'manage_secrets') {
322
- if (!canAccessSecret(all[id])) return 'Error: you do not have access to this secret.'
456
+ if (!canAccessSecret(all[effectiveId])) return 'Error: you do not have access to this secret.'
323
457
  let value = ''
324
458
  try {
325
- value = all[id].encryptedValue ? decryptKey(all[id].encryptedValue) : ''
459
+ value = all[effectiveId].encryptedValue ? decryptKey(all[effectiveId].encryptedValue) : ''
326
460
  } catch {
327
461
  value = ''
328
462
  }
329
463
  return JSON.stringify({
330
- id: all[id].id,
331
- name: all[id].name,
332
- service: all[id].service,
333
- scope: all[id].scope || 'global',
334
- agentIds: all[id].agentIds || [],
464
+ id: all[effectiveId].id,
465
+ name: all[effectiveId].name,
466
+ service: all[effectiveId].service,
467
+ scope: all[effectiveId].scope || 'global',
468
+ agentIds: all[effectiveId].agentIds || [],
469
+ projectId: all[effectiveId].projectId || null,
335
470
  value,
336
- createdAt: all[id].createdAt,
337
- updatedAt: all[id].updatedAt,
471
+ createdAt: all[effectiveId].createdAt,
472
+ updatedAt: all[effectiveId].updatedAt,
338
473
  })
339
474
  }
340
- return JSON.stringify(all[id])
475
+ return JSON.stringify(all[effectiveId])
341
476
  }
342
477
  if (res.readOnly) return `Cannot ${action} ${res.label} via this tool (read-only).`
343
478
  if (action === 'create') {
344
479
  const all = res.load()
345
- const raw = data ? JSON.parse(data) : {}
480
+ const raw = buildCrudPayload(normalized, action, data)
346
481
  const defaults = RESOURCE_DEFAULTS[toolKey]
347
482
  const parsed = defaults ? defaults(raw) : raw
348
483
  if (parsed && typeof parsed === 'object' && 'id' in parsed) {
349
484
  delete (parsed as Record<string, unknown>).id
350
485
  }
351
486
  const now = Date.now()
487
+ if (toolKey === 'manage_tasks') {
488
+ const continuationError = applyTaskContinuationDefaults(
489
+ parsed as Record<string, unknown>,
490
+ all as Record<string, BoardTask>,
491
+ raw as Record<string, unknown>,
492
+ )
493
+ if (continuationError) return continuationError
494
+ }
495
+ if ((toolKey === 'manage_tasks' || toolKey === 'manage_schedules' || toolKey === 'manage_secrets') && !Object.prototype.hasOwnProperty.call(parsed, 'projectId') && ctx?.projectId) {
496
+ parsed.projectId = ctx.projectId
497
+ }
352
498
  if (toolKey === 'manage_tasks' || toolKey === 'manage_schedules') {
353
499
  const agents = loadAgents()
354
500
  const resolution = resolveManagedAgentAssignment(
@@ -455,7 +601,7 @@ export function buildCrudTools(bctx: ToolBuildContext): StructuredToolInterface[
455
601
  createdAt: now,
456
602
  updatedAt: now,
457
603
  }
458
- let responseEntry: any = entry
604
+ let responseEntry: unknown = entry
459
605
  if (toolKey === 'manage_secrets') {
460
606
  const secretValue = typeof parsed.value === 'string' ? parsed.value : null
461
607
  if (!secretValue) return 'Error: data.value is required to create a secret.'
@@ -470,12 +616,17 @@ export function buildCrudTools(bctx: ToolBuildContext): StructuredToolInterface[
470
616
  ...entry,
471
617
  scope: normalizedScope,
472
618
  agentIds: normalizedAgentIds,
619
+ projectId: typeof parsed.projectId === 'string' && parsed.projectId.trim() ? parsed.projectId.trim() : undefined,
473
620
  encryptedValue: encryptKey(secretValue),
474
621
  }
475
622
  delete (stored as any).value
476
623
  all[newId] = stored
477
624
  const { encryptedValue, ...safe } = stored
478
625
  responseEntry = safe
626
+ } else if (toolKey === 'manage_projects') {
627
+ all[newId] = entry
628
+ ensureProjectWorkspace(newId, entry.name)
629
+ responseEntry = buildProjectSnapshot(entry)
479
630
  } else {
480
631
  all[newId] = entry
481
632
  }
@@ -510,30 +661,38 @@ export function buildCrudTools(bctx: ToolBuildContext): StructuredToolInterface[
510
661
  return JSON.stringify(responseEntry)
511
662
  }
512
663
  if (action === 'update') {
513
- if (!id) return 'Error: "id" is required for update action.'
664
+ const effectiveId = id || (toolKey === 'manage_projects' ? ctx?.projectId || undefined : undefined)
665
+ if (!effectiveId) return 'Error: "id" is required for update action.'
514
666
  const all = res.load()
515
- if (!all[id]) return `Not found: ${res.label} "${id}"`
516
- const parsed = data ? JSON.parse(data) : {}
517
- const prevStatus = all[id]?.status
518
- if (toolKey === 'manage_tasks' && Object.prototype.hasOwnProperty.call(parsed, 'status')) {
519
- const normalized = normalizeTaskStatusInput(parsed.status, prevStatus)
520
- if (normalized) parsed.status = normalized
521
- else delete parsed.status
667
+ if (!all[effectiveId]) return `Not found: ${res.label} "${effectiveId}"`
668
+ const parsed = toolKey === 'manage_projects'
669
+ ? normalizeProjectPatchInput(buildCrudPayload(normalized, action, data))
670
+ : buildCrudPayload(normalized, action, data)
671
+ const parsedRecord = parsed as Record<string, unknown>
672
+ if (toolKey === 'manage_tasks') {
673
+ const continuationError = applyTaskContinuationDefaults(parsedRecord, all as Record<string, BoardTask>, parsedRecord)
674
+ if (continuationError) return continuationError
675
+ }
676
+ const prevStatus = all[effectiveId]?.status
677
+ if (toolKey === 'manage_tasks' && Object.prototype.hasOwnProperty.call(parsedRecord, 'status')) {
678
+ const normalized = normalizeTaskStatusInput(parsedRecord.status, prevStatus)
679
+ if (normalized) parsedRecord.status = normalized
680
+ else delete parsedRecord.status
522
681
  }
523
- if (toolKey === 'manage_tasks' && Object.prototype.hasOwnProperty.call(parsed, 'qualityGate')) {
682
+ if (toolKey === 'manage_tasks' && Object.prototype.hasOwnProperty.call(parsedRecord, 'qualityGate')) {
524
683
  const settings = loadSettings()
525
- parsed.qualityGate = parsed.qualityGate
526
- ? normalizeTaskQualityGate(parsed.qualityGate, settings)
684
+ parsedRecord.qualityGate = parsedRecord.qualityGate
685
+ ? normalizeTaskQualityGate(parsedRecord.qualityGate, settings)
527
686
  : null
528
687
  }
529
688
  if (toolKey === 'manage_tasks' || toolKey === 'manage_schedules') {
530
689
  const agents = loadAgents()
531
- const requestedClear = Object.prototype.hasOwnProperty.call(parsed, 'agentId') && parsed.agentId == null
690
+ const requestedClear = Object.prototype.hasOwnProperty.call(parsedRecord, 'agentId') && parsedRecord.agentId == null
532
691
  const shouldResolveAssignment = requestedClear
533
- || hasManagedAgentAssignmentInput(parsed as Record<string, unknown>)
692
+ || hasManagedAgentAssignmentInput(parsedRecord)
534
693
  if (shouldResolveAssignment) {
535
694
  const resolution = resolveManagedAgentAssignment(
536
- parsed as Record<string, unknown>,
695
+ parsedRecord,
537
696
  agents,
538
697
  null,
539
698
  { allowDescription: false },
@@ -547,108 +706,138 @@ export function buildCrudTools(bctx: ToolBuildContext): StructuredToolInterface[
547
706
  unresolvedReference: requestedClear ? null : resolution.unresolvedReference,
548
707
  isDelegation: toolKey === 'manage_tasks'
549
708
  ? isDelegationTaskPayload({
550
- ...all[id],
551
- ...parsed,
709
+ ...all[effectiveId],
710
+ ...parsedRecord,
552
711
  agentId: requestedClear ? null : resolution.agentId,
553
712
  } as Record<string, unknown>)
554
713
  : false,
555
714
  delegatorAgentId: toolKey === 'manage_tasks'
556
715
  ? resolveDelegatorAgentId({
557
- ...all[id],
558
- ...parsed,
559
- } as Record<string, unknown>, agents, ctx?.agentId || null)
716
+ ...all[effectiveId],
717
+ ...parsedRecord,
718
+ }, agents, ctx?.agentId || null)
560
719
  : null,
561
720
  })
562
721
  if (assignmentError) return assignmentError
563
- if (!requestedClear) parsed.agentId = resolution.agentId
722
+ if (!requestedClear) parsedRecord.agentId = resolution.agentId
564
723
  }
565
724
  }
566
- all[id] = { ...all[id], ...parsed, updatedAt: Date.now() }
725
+ all[effectiveId] = { ...all[effectiveId], ...parsed, updatedAt: Date.now() }
567
726
  if (toolKey === 'manage_schedules') {
568
- const normalizedSchedule = normalizeSchedulePayload(all[id] as Record<string, unknown>, {
727
+ const normalizedSchedule = normalizeSchedulePayload(all[effectiveId] as Record<string, unknown>, {
569
728
  cwd,
570
729
  now: Date.now(),
571
730
  })
572
731
  if (!normalizedSchedule.ok) return normalizedSchedule.error
573
- all[id] = {
574
- ...all[id],
732
+ all[effectiveId] = {
733
+ ...all[effectiveId],
575
734
  ...normalizedSchedule.value,
576
735
  updatedAt: Date.now(),
577
736
  }
578
737
  }
579
738
  if (toolKey === 'manage_secrets') {
580
- if (!canAccessSecret(all[id])) return 'Error: you do not have access to this secret.'
581
- const nextScope = parsed.scope === 'agent'
739
+ if (!canAccessSecret(all[effectiveId])) return 'Error: you do not have access to this secret.'
740
+ const nextScope = parsedRecord.scope === 'agent'
582
741
  ? 'agent'
583
- : parsed.scope === 'global'
742
+ : parsedRecord.scope === 'global'
584
743
  ? 'global'
585
- : (all[id].scope === 'agent' ? 'agent' : 'global')
744
+ : (all[effectiveId].scope === 'agent' ? 'agent' : 'global')
586
745
  if (nextScope === 'agent') {
587
- const incomingIds = Array.isArray(parsed.agentIds)
588
- ? parsed.agentIds.filter((x: any) => typeof x === 'string')
589
- : Array.isArray(all[id].agentIds)
590
- ? all[id].agentIds
746
+ const incomingIds = Array.isArray(parsedRecord.agentIds)
747
+ ? parsedRecord.agentIds.filter((x: any) => typeof x === 'string')
748
+ : Array.isArray(all[effectiveId].agentIds)
749
+ ? all[effectiveId].agentIds
591
750
  : []
592
- all[id].agentIds = Array.from(new Set([
751
+ all[effectiveId].agentIds = Array.from(new Set([
593
752
  ...incomingIds,
594
753
  ...(ctx?.agentId ? [ctx.agentId] : []),
595
754
  ]))
596
755
  } else {
597
- all[id].agentIds = []
756
+ all[effectiveId].agentIds = []
757
+ }
758
+ all[effectiveId].scope = nextScope
759
+ if (Object.prototype.hasOwnProperty.call(parsedRecord, 'projectId')) {
760
+ all[effectiveId].projectId = typeof parsedRecord.projectId === 'string' && parsedRecord.projectId.trim()
761
+ ? parsedRecord.projectId.trim()
762
+ : undefined
598
763
  }
599
- all[id].scope = nextScope
600
- if (typeof parsed.value === 'string' && parsed.value.trim()) {
601
- all[id].encryptedValue = encryptKey(parsed.value)
764
+ if (typeof parsedRecord.value === 'string' && parsedRecord.value.trim()) {
765
+ all[effectiveId].encryptedValue = encryptKey(parsedRecord.value)
602
766
  }
603
- delete all[id].value
767
+ delete all[effectiveId].value
604
768
  }
605
769
 
606
- if (toolKey === 'manage_tasks' && all[id].status === 'completed') {
770
+ if (toolKey === 'manage_tasks' && all[effectiveId].status === 'completed') {
607
771
  const { formatValidationFailure, validateTaskCompletion } = await import('../task-validation')
608
772
  const { ensureTaskCompletionReport } = await import('../task-reports')
609
773
  const settings = loadSettings()
610
- const report = ensureTaskCompletionReport(all[id] as any)
611
- if (report?.relativePath) (all[id] as any).completionReportPath = report.relativePath
612
- const validation = validateTaskCompletion(all[id] as any, { report, settings })
613
- ;(all[id] as any).validation = validation
774
+ const report = ensureTaskCompletionReport(all[effectiveId] as any)
775
+ if (report?.relativePath) (all[effectiveId] as any).completionReportPath = report.relativePath
776
+ const validation = validateTaskCompletion(all[effectiveId] as any, { report, settings })
777
+ ;(all[effectiveId] as any).validation = validation
614
778
  if (!validation.ok) {
615
- all[id].status = 'failed'
616
- ;(all[id] as any).completedAt = null
617
- ;(all[id] as any).error = formatValidationFailure(validation.reasons).slice(0, 500)
618
- } else if ((all[id] as any).completedAt == null) {
619
- ;(all[id] as any).completedAt = Date.now()
779
+ all[effectiveId].status = 'failed'
780
+ ;(all[effectiveId] as any).completedAt = null
781
+ ;(all[effectiveId] as any).error = formatValidationFailure(validation.reasons).slice(0, 500)
782
+ } else if ((all[effectiveId] as any).completedAt == null) {
783
+ ;(all[effectiveId] as any).completedAt = Date.now()
620
784
  }
621
785
  }
622
786
 
623
787
  res.save(all)
624
- if (toolKey === 'manage_tasks' && prevStatus !== 'queued' && all[id].status === 'queued') {
788
+ if (toolKey === 'manage_projects') {
789
+ ensureProjectWorkspace(effectiveId, all[effectiveId].name)
790
+ }
791
+ if (toolKey === 'manage_tasks' && prevStatus !== 'queued' && all[effectiveId].status === 'queued') {
625
792
  const { enqueueTask } = await import('../queue')
626
- enqueueTask(id)
793
+ enqueueTask(effectiveId)
627
794
  } else if (
628
795
  toolKey === 'manage_tasks'
629
- && prevStatus !== all[id].status
630
- && (all[id].status === 'completed' || all[id].status === 'failed')
631
- && all[id].sessionId
796
+ && prevStatus !== all[effectiveId].status
797
+ && (all[effectiveId].status === 'completed' || all[effectiveId].status === 'failed')
798
+ && all[effectiveId].sessionId
632
799
  ) {
633
800
  const { disableSessionHeartbeat } = await import('../queue')
634
- disableSessionHeartbeat(all[id].sessionId)
801
+ disableSessionHeartbeat(all[effectiveId].sessionId)
635
802
  }
636
803
  if (toolKey === 'manage_secrets') {
637
- const { encryptedValue, ...safe } = all[id]
804
+ const { encryptedValue, ...safe } = all[effectiveId]
638
805
  return JSON.stringify(safe)
639
806
  }
640
- return JSON.stringify(all[id])
807
+ if (toolKey === 'manage_projects') {
808
+ return JSON.stringify(buildProjectSnapshot(all[effectiveId]))
809
+ }
810
+ return JSON.stringify(all[effectiveId])
641
811
  }
642
812
  if (action === 'delete') {
643
- if (!id) return 'Error: "id" is required for delete action.'
813
+ const effectiveId = id || (toolKey === 'manage_projects' ? ctx?.projectId || undefined : undefined)
814
+ if (!effectiveId) return 'Error: "id" is required for delete action.'
644
815
  const all = res.load()
645
- if (!all[id]) return `Not found: ${res.label} "${id}"`
646
- if (toolKey === 'manage_secrets' && !canAccessSecret(all[id])) {
816
+ if (!all[effectiveId]) return `Not found: ${res.label} "${effectiveId}"`
817
+ if (toolKey === 'manage_secrets' && !canAccessSecret(all[effectiveId])) {
647
818
  return 'Error: you do not have access to this secret.'
648
819
  }
649
- delete all[id]
820
+ delete all[effectiveId]
650
821
  res.save(all)
651
- return JSON.stringify({ deleted: id })
822
+ if (toolKey === 'manage_projects') {
823
+ const clearProjectId = (load: () => Record<string, Record<string, unknown>>, save: (d: Record<string, Record<string, unknown>>) => void) => {
824
+ const items = load()
825
+ let changed = false
826
+ for (const item of Object.values(items)) {
827
+ if (item.projectId === effectiveId) {
828
+ item.projectId = undefined
829
+ changed = true
830
+ }
831
+ }
832
+ if (changed) save(items)
833
+ }
834
+ clearProjectId(loadAgents, saveAgents)
835
+ clearProjectId(loadTasks, saveTasks)
836
+ clearProjectId(loadSchedules, saveSchedules)
837
+ clearProjectId(loadSkills, saveSkills)
838
+ clearProjectId(loadSecrets, saveSecrets)
839
+ }
840
+ return JSON.stringify({ deleted: effectiveId })
652
841
  }
653
842
  return `Unknown action "${action}". Valid: list, get, create, update, delete`
654
843
  } catch (err: any) {
@@ -662,7 +851,7 @@ export function buildCrudTools(bctx: ToolBuildContext): StructuredToolInterface[
662
851
  action: z.enum(['list', 'get', 'create', 'update', 'delete']).describe('The CRUD action to perform'),
663
852
  id: z.string().optional().describe('Resource ID (required for get, update, delete)'),
664
853
  data: z.string().optional().describe('JSON string of fields for create/update'),
665
- }),
854
+ }).passthrough(),
666
855
  },
667
856
  ),
668
857
  )
@@ -0,0 +1,50 @@
1
+ import assert from 'node:assert/strict'
2
+ import { describe, it } from 'node:test'
3
+ import { resolveDelegateResumeConfig } from './delegate'
4
+
5
+ describe('resolveDelegateResumeConfig', () => {
6
+ it('auto-resumes when a stored backend resume ID exists', () => {
7
+ const config = resolveDelegateResumeConfig(
8
+ { task: 'continue the implementation' },
9
+ 'codex',
10
+ {
11
+ readStoredDelegateResumeId: (key) => key === 'codex' ? 'codex-thread-42' : null,
12
+ },
13
+ )
14
+
15
+ assert.deepEqual(config, {
16
+ resume: true,
17
+ resumeId: '',
18
+ })
19
+ })
20
+
21
+ it('respects explicit resume=false even when a stored ID exists', () => {
22
+ const config = resolveDelegateResumeConfig(
23
+ { task: 'start fresh', resume: false },
24
+ 'claude',
25
+ {
26
+ readStoredDelegateResumeId: () => 'claude-session-99',
27
+ },
28
+ )
29
+
30
+ assert.deepEqual(config, {
31
+ resume: false,
32
+ resumeId: '',
33
+ })
34
+ })
35
+
36
+ it('treats an explicit resumeId as an instruction to resume immediately', () => {
37
+ const config = resolveDelegateResumeConfig(
38
+ { task: 'continue', resumeId: 'gemini-session-5' },
39
+ 'gemini',
40
+ {
41
+ readStoredDelegateResumeId: () => null,
42
+ },
43
+ )
44
+
45
+ assert.deepEqual(config, {
46
+ resume: true,
47
+ resumeId: 'gemini-session-5',
48
+ })
49
+ })
50
+ })
@@ -247,12 +247,48 @@ function bindDelegateRuntime(runtime: DelegateRuntimeState | undefined, child: C
247
247
  child.once('error', clear)
248
248
  }
249
249
 
250
+ function coerceOptionalBool(value: unknown): boolean | null {
251
+ if (typeof value === 'boolean') return value
252
+ if (typeof value === 'string') {
253
+ const normalized = value.trim().toLowerCase()
254
+ if (['true', '1', 'yes', 'on'].includes(normalized)) return true
255
+ if (['false', '0', 'no', 'off'].includes(normalized)) return false
256
+ }
257
+ return null
258
+ }
259
+
260
+ function resumeStorageKeyForBackend(
261
+ backend: 'claude' | 'codex' | 'opencode' | 'gemini',
262
+ ): 'claudeCode' | 'codex' | 'opencode' | 'gemini' {
263
+ if (backend === 'claude') return 'claudeCode'
264
+ if (backend === 'codex') return 'codex'
265
+ if (backend === 'opencode') return 'opencode'
266
+ return 'gemini'
267
+ }
268
+
269
+ export function resolveDelegateResumeConfig(
270
+ normalized: Record<string, unknown>,
271
+ backend: 'claude' | 'codex' | 'opencode' | 'gemini',
272
+ bctx: { readStoredDelegateResumeId?: (key: 'claudeCode' | 'codex' | 'opencode' | 'gemini') => string | null },
273
+ ): { resume: boolean; resumeId: string } {
274
+ const explicitResumeId = typeof normalized.resumeId === 'string' ? normalized.resumeId.trim() : ''
275
+ if (explicitResumeId) return { resume: true, resumeId: explicitResumeId }
276
+
277
+ const explicitResume = coerceOptionalBool(normalized.resume)
278
+ if (explicitResume !== null) return { resume: explicitResume, resumeId: '' }
279
+
280
+ const storedResumeId = bctx.readStoredDelegateResumeId?.(resumeStorageKeyForBackend(backend))
281
+ return {
282
+ resume: Boolean(storedResumeId),
283
+ resumeId: '',
284
+ }
285
+ }
286
+
250
287
  async function runDelegateBackend(args: Record<string, unknown>, bctx: DelegateContext, runtime?: DelegateRuntimeState): Promise<string> {
251
288
  const normalized = normalizeDelegateArgs(args)
252
289
  const task = normalized.task as string
253
290
  const backend = ((normalized.backend as string) || 'claude') as DelegateBackend
254
- const resume = normalized.resume as boolean
255
- const resumeId = normalized.resumeId as string
291
+ const { resume, resumeId } = resolveDelegateResumeConfig(normalized, backend, bctx)
256
292
  const backends = {
257
293
  claude: findBinaryOnPath('claude'),
258
294
  codex: findBinaryOnPath('codex'),