@swarmclawai/swarmclaw 0.6.8 → 0.7.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.
Files changed (166) hide show
  1. package/README.md +70 -45
  2. package/next.config.ts +31 -6
  3. package/package.json +3 -2
  4. package/src/app/api/agents/[id]/thread/route.ts +1 -0
  5. package/src/app/api/agents/route.ts +18 -5
  6. package/src/app/api/approvals/route.ts +22 -0
  7. package/src/app/api/clawhub/install/route.ts +2 -2
  8. package/src/app/api/mcp-servers/[id]/conformance/route.ts +26 -0
  9. package/src/app/api/mcp-servers/[id]/invoke/route.ts +81 -0
  10. package/src/app/api/memory/route.ts +36 -5
  11. package/src/app/api/notifications/route.ts +3 -0
  12. package/src/app/api/plugins/install/route.ts +57 -5
  13. package/src/app/api/plugins/marketplace/route.ts +73 -22
  14. package/src/app/api/plugins/route.ts +61 -1
  15. package/src/app/api/plugins/ui/route.ts +34 -0
  16. package/src/app/api/settings/route.ts +62 -0
  17. package/src/app/api/setup/doctor/route.ts +22 -5
  18. package/src/app/api/tasks/[id]/approve/route.ts +4 -3
  19. package/src/app/api/tasks/[id]/route.ts +11 -3
  20. package/src/app/api/tasks/route.ts +8 -2
  21. package/src/app/globals.css +27 -0
  22. package/src/app/page.tsx +10 -5
  23. package/src/cli/index.js +13 -0
  24. package/src/components/activity/activity-feed.tsx +9 -2
  25. package/src/components/agents/agent-avatar.tsx +5 -1
  26. package/src/components/agents/agent-card.tsx +55 -9
  27. package/src/components/agents/agent-sheet.tsx +86 -29
  28. package/src/components/agents/inspector-panel.tsx +1 -1
  29. package/src/components/auth/access-key-gate.tsx +63 -54
  30. package/src/components/auth/user-picker.tsx +37 -32
  31. package/src/components/chat/chat-area.tsx +11 -0
  32. package/src/components/chat/chat-header.tsx +69 -25
  33. package/src/components/chat/chat-tool-toggles.tsx +2 -2
  34. package/src/components/chat/code-block.tsx +3 -1
  35. package/src/components/chat/exec-approval-card.tsx +8 -1
  36. package/src/components/chat/message-bubble.tsx +164 -4
  37. package/src/components/chat/message-list.tsx +30 -4
  38. package/src/components/chat/session-approval-card.tsx +80 -0
  39. package/src/components/chat/streaming-bubble.tsx +6 -5
  40. package/src/components/chat/thinking-indicator.tsx +48 -12
  41. package/src/components/chat/tool-request-banner.tsx +39 -20
  42. package/src/components/chatrooms/chatroom-list.tsx +11 -4
  43. package/src/components/chatrooms/chatroom-sheet.tsx +7 -2
  44. package/src/components/connectors/connector-list.tsx +33 -11
  45. package/src/components/connectors/connector-sheet.tsx +29 -6
  46. package/src/components/home/home-view.tsx +20 -14
  47. package/src/components/input/chat-input.tsx +22 -1
  48. package/src/components/knowledge/knowledge-list.tsx +17 -18
  49. package/src/components/knowledge/knowledge-sheet.tsx +9 -5
  50. package/src/components/layout/app-layout.tsx +73 -21
  51. package/src/components/mcp-servers/mcp-server-list.tsx +352 -50
  52. package/src/components/mcp-servers/mcp-server-sheet.tsx +25 -9
  53. package/src/components/memory/memory-list.tsx +20 -13
  54. package/src/components/plugins/plugin-list.tsx +213 -59
  55. package/src/components/plugins/plugin-sheet.tsx +119 -24
  56. package/src/components/projects/project-list.tsx +17 -9
  57. package/src/components/providers/provider-list.tsx +21 -6
  58. package/src/components/providers/provider-sheet.tsx +42 -25
  59. package/src/components/runs/run-list.tsx +17 -13
  60. package/src/components/schedules/schedule-card.tsx +10 -3
  61. package/src/components/schedules/schedule-list.tsx +2 -2
  62. package/src/components/schedules/schedule-sheet.tsx +19 -7
  63. package/src/components/secrets/secret-sheet.tsx +7 -2
  64. package/src/components/secrets/secrets-list.tsx +18 -5
  65. package/src/components/sessions/new-session-sheet.tsx +183 -376
  66. package/src/components/sessions/session-card.tsx +10 -2
  67. package/src/components/settings/gateway-connection-panel.tsx +9 -8
  68. package/src/components/shared/command-palette.tsx +13 -5
  69. package/src/components/shared/empty-state.tsx +20 -8
  70. package/src/components/shared/notification-center.tsx +134 -86
  71. package/src/components/shared/profile-sheet.tsx +4 -0
  72. package/src/components/shared/settings/plugin-manager.tsx +360 -135
  73. package/src/components/shared/settings/section-capability-policy.tsx +3 -3
  74. package/src/components/shared/settings/section-runtime-loop.tsx +144 -0
  75. package/src/components/skills/clawhub-browser.tsx +1 -0
  76. package/src/components/skills/skill-list.tsx +31 -12
  77. package/src/components/skills/skill-sheet.tsx +20 -7
  78. package/src/components/tasks/approvals-panel.tsx +170 -66
  79. package/src/components/tasks/task-board.tsx +20 -12
  80. package/src/components/tasks/task-card.tsx +21 -7
  81. package/src/components/tasks/task-column.tsx +4 -3
  82. package/src/components/tasks/task-list.tsx +1 -1
  83. package/src/components/tasks/task-sheet.tsx +130 -1
  84. package/src/components/ui/dialog.tsx +1 -0
  85. package/src/components/ui/sheet.tsx +1 -0
  86. package/src/components/usage/metrics-dashboard.tsx +66 -64
  87. package/src/components/wallets/wallet-panel.tsx +65 -41
  88. package/src/components/wallets/wallet-section.tsx +9 -3
  89. package/src/components/webhooks/webhook-list.tsx +21 -12
  90. package/src/components/webhooks/webhook-sheet.tsx +13 -3
  91. package/src/lib/approval-display.test.ts +45 -0
  92. package/src/lib/approval-display.ts +62 -0
  93. package/src/lib/clipboard.ts +38 -0
  94. package/src/lib/memory.ts +8 -0
  95. package/src/lib/providers/claude-cli.ts +5 -3
  96. package/src/lib/providers/index.ts +67 -21
  97. package/src/lib/runtime-loop.ts +3 -2
  98. package/src/lib/server/approvals.ts +150 -0
  99. package/src/lib/server/chat-execution.ts +223 -62
  100. package/src/lib/server/clawhub-client.ts +82 -6
  101. package/src/lib/server/connectors/manager.ts +27 -1
  102. package/src/lib/server/cost.test.ts +73 -0
  103. package/src/lib/server/cost.ts +165 -34
  104. package/src/lib/server/daemon-state.ts +42 -0
  105. package/src/lib/server/data-dir.ts +18 -1
  106. package/src/lib/server/integrity-monitor.ts +208 -0
  107. package/src/lib/server/llm-response-cache.test.ts +102 -0
  108. package/src/lib/server/llm-response-cache.ts +227 -0
  109. package/src/lib/server/main-agent-loop.ts +1 -1
  110. package/src/lib/server/main-session.ts +6 -3
  111. package/src/lib/server/mcp-conformance.test.ts +18 -0
  112. package/src/lib/server/mcp-conformance.ts +233 -0
  113. package/src/lib/server/memory-db.ts +180 -17
  114. package/src/lib/server/memory-retrieval.test.ts +56 -0
  115. package/src/lib/server/orchestrator-lg.ts +4 -1
  116. package/src/lib/server/orchestrator.ts +4 -3
  117. package/src/lib/server/plugins.ts +650 -142
  118. package/src/lib/server/process-manager.ts +18 -0
  119. package/src/lib/server/queue.ts +253 -11
  120. package/src/lib/server/runtime-settings.ts +9 -0
  121. package/src/lib/server/session-run-manager.test.ts +23 -0
  122. package/src/lib/server/session-run-manager.ts +11 -1
  123. package/src/lib/server/session-tools/canvas.ts +85 -50
  124. package/src/lib/server/session-tools/chatroom.ts +130 -127
  125. package/src/lib/server/session-tools/connector.ts +233 -454
  126. package/src/lib/server/session-tools/context-mgmt.ts +87 -105
  127. package/src/lib/server/session-tools/crud.ts +84 -7
  128. package/src/lib/server/session-tools/delegate.ts +351 -752
  129. package/src/lib/server/session-tools/discovery.ts +198 -0
  130. package/src/lib/server/session-tools/edit_file.ts +82 -0
  131. package/src/lib/server/session-tools/file-send.test.ts +39 -0
  132. package/src/lib/server/session-tools/file.ts +257 -425
  133. package/src/lib/server/session-tools/git.ts +87 -47
  134. package/src/lib/server/session-tools/http.ts +85 -33
  135. package/src/lib/server/session-tools/index.ts +205 -160
  136. package/src/lib/server/session-tools/memory.ts +152 -265
  137. package/src/lib/server/session-tools/monitor.ts +126 -0
  138. package/src/lib/server/session-tools/normalize-tool-args.test.ts +61 -0
  139. package/src/lib/server/session-tools/normalize-tool-args.ts +48 -0
  140. package/src/lib/server/session-tools/openclaw-nodes.ts +82 -99
  141. package/src/lib/server/session-tools/openclaw-workspace.ts +103 -93
  142. package/src/lib/server/session-tools/platform.ts +86 -0
  143. package/src/lib/server/session-tools/plugin-creator.ts +239 -0
  144. package/src/lib/server/session-tools/sample-ui.ts +97 -0
  145. package/src/lib/server/session-tools/sandbox.ts +175 -148
  146. package/src/lib/server/session-tools/schedule.ts +66 -31
  147. package/src/lib/server/session-tools/session-info.ts +104 -410
  148. package/src/lib/server/session-tools/shell-normalize.test.ts +43 -0
  149. package/src/lib/server/session-tools/shell.ts +171 -143
  150. package/src/lib/server/session-tools/subagent.ts +77 -77
  151. package/src/lib/server/session-tools/wallet.ts +182 -106
  152. package/src/lib/server/session-tools/web.ts +179 -349
  153. package/src/lib/server/storage.ts +24 -0
  154. package/src/lib/server/stream-agent-chat.ts +301 -244
  155. package/src/lib/server/task-quality-gate.test.ts +44 -0
  156. package/src/lib/server/task-quality-gate.ts +67 -0
  157. package/src/lib/server/task-validation.test.ts +78 -0
  158. package/src/lib/server/task-validation.ts +67 -2
  159. package/src/lib/server/tool-aliases.ts +68 -0
  160. package/src/lib/server/tool-capability-policy.ts +23 -5
  161. package/src/lib/tasks.ts +7 -1
  162. package/src/lib/tool-definitions.ts +23 -23
  163. package/src/lib/validation/schemas.ts +12 -0
  164. package/src/lib/view-routes.ts +2 -24
  165. package/src/stores/use-app-store.ts +23 -1
  166. package/src/types/index.ts +121 -7
@@ -5,6 +5,7 @@ const MAX_LOG_CHARS = 200_000
5
5
  const DEFAULT_BACKGROUND_YIELD_MS = 10_000
6
6
  const DEFAULT_TIMEOUT_MS = 30 * 60_000
7
7
  const DEFAULT_TTL_MS = 30 * 60_000
8
+ const BACKGROUND_STARTUP_GRACE_MS = 500
8
9
 
9
10
  export type ProcessStatus = 'running' | 'exited' | 'killed' | 'failed' | 'timeout'
10
11
 
@@ -170,6 +171,23 @@ export async function startManagedProcess(opts: StartProcessOptions): Promise<St
170
171
  state.exitWaiters.set(id, exitPromise)
171
172
 
172
173
  if (opts.background) {
174
+ // Give background processes a brief grace window so immediate crashes
175
+ // (e.g., bind/permission errors) are surfaced instead of misreported as running.
176
+ const startupWaitMs = Math.min(
177
+ Math.max(100, BACKGROUND_STARTUP_GRACE_MS),
178
+ Math.max(200, timeoutMs),
179
+ )
180
+ await wait(startupWaitMs)
181
+ const rec = state.records.get(id)
182
+ if (rec && rec.status !== 'running') {
183
+ return {
184
+ status: 'completed',
185
+ processId: id,
186
+ output: rec.log,
187
+ exitCode: rec.exitCode,
188
+ signal: rec.signal,
189
+ }
190
+ }
173
191
  return {
174
192
  status: 'running',
175
193
  processId: id,
@@ -23,12 +23,14 @@ interface SessionMessageLike {
23
23
  role?: string
24
24
  text?: string
25
25
  time?: number
26
- kind?: 'chat' | 'heartbeat' | 'system' | 'context-clear'
26
+ kind?: string
27
27
  source?: {
28
28
  connectorId?: string
29
29
  channelId?: string
30
30
  }
31
31
  toolEvents?: Array<{ name?: string; output?: string }>
32
+ streaming?: boolean
33
+ imageUrl?: string
32
34
  }
33
35
 
34
36
  interface SessionLike {
@@ -97,6 +99,181 @@ function applyTaskPolicyDefaults(task: BoardTask): void {
97
99
  if (task.deadLetteredAt === undefined) task.deadLetteredAt = null
98
100
  }
99
101
 
102
+ const DEV_TASK_HINT = /\b(dev(?:\s+server)?|start(?:ing)?\s+(?:the\s+)?server|run(?:ning)?\s+(?:the\s+)?(?:app|project|site)|serve|localhost|http\s+server|web\s+server|npm\b|pnpm\b|yarn\b|bun\b|vite|next(?:\.js)?|react|build|compile)\b/i
103
+ const TASK_CWD_NOISE_DIRS = new Set([
104
+ 'uploads',
105
+ 'data',
106
+ 'projects',
107
+ 'tasks',
108
+ '.swarm-data-test',
109
+ '.git',
110
+ '.next',
111
+ 'node_modules',
112
+ ])
113
+ const PROJECT_MARKER_FILES = ['package.json', 'pyproject.toml', 'Cargo.toml', 'go.mod', '.git']
114
+ const SOURCE_MARKER_DIRS = ['src', 'app', 'public', 'pages']
115
+ const WORKSPACE_PROJECTS_DIR = path.join(WORKSPACE_DIR, 'projects')
116
+
117
+ interface WorkspaceDirCandidate {
118
+ dir: string
119
+ name: string
120
+ hasProjectMarker: boolean
121
+ hasSourceMarker: boolean
122
+ }
123
+
124
+ let workspaceDirCache: { expiresAt: number; candidates: WorkspaceDirCandidate[] } | null = null
125
+
126
+ function isExistingDirectory(dirPath: string): boolean {
127
+ try {
128
+ return fs.statSync(dirPath).isDirectory()
129
+ } catch {
130
+ return false
131
+ }
132
+ }
133
+
134
+ function isWithinDirectory(parent: string, child: string): boolean {
135
+ const parentResolved = path.resolve(parent)
136
+ const childResolved = path.resolve(child)
137
+ const rel = path.relative(parentResolved, childResolved)
138
+ return rel === '' || (!rel.startsWith('..') && !path.isAbsolute(rel))
139
+ }
140
+
141
+ function normalizeForMatch(value: string): string {
142
+ return value.toLowerCase().replace(/[^a-z0-9]+/g, ' ').trim()
143
+ }
144
+
145
+ function hasAnyMarker(dirPath: string, markers: string[]): boolean {
146
+ return markers.some((marker) => fs.existsSync(path.join(dirPath, marker)))
147
+ }
148
+
149
+ function normalizeDirCandidate(raw: unknown, baseDir: string): string | null {
150
+ if (typeof raw !== 'string') return null
151
+ const trimmed = raw.trim()
152
+ if (!trimmed) return null
153
+ const homeDir = process.env.HOME || ''
154
+ const expanded = trimmed === '~'
155
+ ? homeDir
156
+ : trimmed.startsWith('~/')
157
+ ? path.join(homeDir, trimmed.slice(2))
158
+ : trimmed
159
+ const resolved = path.isAbsolute(expanded) ? path.resolve(expanded) : path.resolve(baseDir, expanded)
160
+ return isExistingDirectory(resolved) ? resolved : null
161
+ }
162
+
163
+ function looksLikeDevTask(task: Pick<BoardTask, 'title' | 'description'>): boolean {
164
+ const text = `${task.title || ''} ${task.description || ''}`.trim()
165
+ return DEV_TASK_HINT.test(text)
166
+ }
167
+
168
+ function listWorkspaceDirCandidates(): WorkspaceDirCandidate[] {
169
+ const now = Date.now()
170
+ if (workspaceDirCache && workspaceDirCache.expiresAt > now) return workspaceDirCache.candidates
171
+
172
+ const candidates: WorkspaceDirCandidate[] = []
173
+ const seen = new Set<string>()
174
+ const roots = [WORKSPACE_DIR, WORKSPACE_PROJECTS_DIR]
175
+
176
+ for (const root of roots) {
177
+ if (!isExistingDirectory(root)) continue
178
+ let entries: fs.Dirent[] = []
179
+ try {
180
+ entries = fs.readdirSync(root, { withFileTypes: true })
181
+ } catch {
182
+ continue
183
+ }
184
+ for (const entry of entries) {
185
+ if (!entry.isDirectory()) continue
186
+ const name = entry.name
187
+ if (!name || name.startsWith('.')) continue
188
+ if (TASK_CWD_NOISE_DIRS.has(name)) continue
189
+ const dir = path.join(root, name)
190
+ const key = path.resolve(dir)
191
+ if (seen.has(key)) continue
192
+ seen.add(key)
193
+ candidates.push({
194
+ dir: key,
195
+ name,
196
+ hasProjectMarker: hasAnyMarker(key, PROJECT_MARKER_FILES),
197
+ hasSourceMarker: hasAnyMarker(key, SOURCE_MARKER_DIRS),
198
+ })
199
+ }
200
+ }
201
+
202
+ candidates.sort((a, b) => a.name.localeCompare(b.name))
203
+ workspaceDirCache = {
204
+ expiresAt: now + 15_000,
205
+ candidates,
206
+ }
207
+ return candidates
208
+ }
209
+
210
+ function inferWorkspaceProjectCwd(task: Pick<BoardTask, 'title' | 'description' | 'file'>): string | null {
211
+ const candidates = listWorkspaceDirCandidates()
212
+ if (!candidates.length) return null
213
+
214
+ const taskText = normalizeForMatch(`${task.title || ''} ${task.description || ''} ${task.file || ''}`)
215
+ const devTask = looksLikeDevTask(task)
216
+ const markerCandidates = candidates.filter((candidate) => candidate.hasProjectMarker)
217
+
218
+ let best: { dir: string; score: number } | null = null
219
+ for (const candidate of candidates) {
220
+ const nameNorm = normalizeForMatch(candidate.name)
221
+ if (!nameNorm) continue
222
+ let score = 0
223
+ if (taskText.includes(nameNorm)) score += 8
224
+ for (const token of nameNorm.split(' ')) {
225
+ if (token.length < 3) continue
226
+ if (taskText.includes(token)) score += 1
227
+ }
228
+ if (candidate.hasProjectMarker) score += devTask ? 3 : 1
229
+ if (candidate.hasSourceMarker) score += 1
230
+ if (!best || score > best.score) best = { dir: candidate.dir, score }
231
+ }
232
+
233
+ if (best && best.score >= 4) return best.dir
234
+ if (devTask && markerCandidates.length === 1) return markerCandidates[0].dir
235
+ return null
236
+ }
237
+
238
+ function resolveTaskExecutionCwd(task: ScheduleTaskMeta, sessions: Record<string, SessionLike>): string {
239
+ const workspaceRoot = path.resolve(WORKSPACE_DIR)
240
+
241
+ const explicitCwd = normalizeDirCandidate(task.cwd, workspaceRoot)
242
+ if (explicitCwd) return explicitCwd
243
+
244
+ const projectId = typeof task.projectId === 'string' ? task.projectId.trim() : ''
245
+ if (projectId) {
246
+ const projectDir = path.join(WORKSPACE_PROJECTS_DIR, projectId)
247
+ if (isExistingDirectory(projectDir)) return projectDir
248
+ }
249
+
250
+ const fileRef = typeof task.file === 'string' ? task.file.trim() : ''
251
+ if (fileRef) {
252
+ const filePath = path.isAbsolute(fileRef) ? fileRef : path.resolve(workspaceRoot, fileRef)
253
+ const fileDir = isExistingDirectory(filePath) ? filePath : path.dirname(filePath)
254
+ if (isExistingDirectory(fileDir) && isWithinDirectory(workspaceRoot, fileDir)) return fileDir
255
+ }
256
+
257
+ const inferredCwd = inferWorkspaceProjectCwd(task)
258
+ if (inferredCwd) return inferredCwd
259
+
260
+ const sourceSessionId = typeof task.createdInSessionId === 'string' ? task.createdInSessionId.trim() : ''
261
+ const sourceSessionCwd = sourceSessionId
262
+ ? normalizeDirCandidate(sessions[sourceSessionId]?.cwd, workspaceRoot)
263
+ : null
264
+ if (sourceSessionCwd && path.resolve(sourceSessionCwd) !== workspaceRoot) return sourceSessionCwd
265
+
266
+ const runSessionId = typeof task.sessionId === 'string' ? task.sessionId.trim() : ''
267
+ const runSessionCwd = runSessionId
268
+ ? normalizeDirCandidate(sessions[runSessionId]?.cwd, workspaceRoot)
269
+ : null
270
+ if (runSessionCwd && path.resolve(runSessionCwd) !== workspaceRoot) return runSessionCwd
271
+
272
+ const sandboxDir = path.join(workspaceRoot, 'tasks', task.id)
273
+ fs.mkdirSync(sandboxDir, { recursive: true })
274
+ return sandboxDir
275
+ }
276
+
100
277
  function queueContains(queue: string[], id: string): boolean {
101
278
  return queue.includes(id)
102
279
  }
@@ -316,12 +493,34 @@ export function resolveTaskOriginConnectorFollowupTarget(params: {
316
493
  // Task result extraction now uses Zod-validated structured data
317
494
  // from ./task-result.ts (extractTaskResult, formatResultBody)
318
495
 
496
+ /** Check if a task result looks incomplete (agent stopped mid-objective). */
497
+ function looksIncomplete(text: string): boolean {
498
+ if (!text) return false
499
+ const trimmed = text.trim()
500
+ // Ends with ellipsis or continuation signal
501
+ if (trimmed.endsWith('...') || trimmed.endsWith('…')) return true
502
+ // Ends with a step/phase header (agent was listing next steps)
503
+ if (/(?:^|\n)#{1,3}\s+(?:Step|Phase|Next)\s+\d/i.test(trimmed.slice(-200))) return true
504
+ // Contains forward-looking language at the end
505
+ const lastChunk = trimmed.slice(-300).toLowerCase()
506
+ if (/\b(?:next i(?:'ll| will)|now i(?:'ll| will)|let me (?:now|next)|moving on to|proceeding to)\b/.test(lastChunk)) return true
507
+ return false
508
+ }
509
+
319
510
  async function executeTaskRun(
320
511
  task: BoardTask,
321
512
  agent: Agent,
322
513
  sessionId: string,
323
514
  ): Promise<string> {
324
- const prompt = task.description || task.title
515
+ const basePrompt = task.description || task.title
516
+ const prompt = [
517
+ basePrompt,
518
+ '',
519
+ 'Completion requirements:',
520
+ '- Execute the task before replying; do not reply with only a plan.',
521
+ '- Include concrete evidence in your final summary: changed file paths, commands run, and verification results.',
522
+ '- If blocked, state the blocker explicitly and what input or permission is missing.',
523
+ ].join('\n')
325
524
  if (agent?.isOrchestrator) {
326
525
  return executeOrchestrator(agent, prompt, sessionId, task.id)
327
526
  }
@@ -331,11 +530,24 @@ async function executeTaskRun(
331
530
  message: prompt,
332
531
  internal: false,
333
532
  source: 'task',
533
+ runId: task.id,
334
534
  })
335
- const text = typeof run.text === 'string' ? run.text.trim() : ''
336
- if (text) return text
337
- if (run.error) return `Error: ${run.error}`
338
- return ''
535
+ let text = typeof run.text === 'string' ? run.text.trim() : ''
536
+ if (run.error) return text ? text : `Error: ${run.error}`
537
+
538
+ // Auto-continue if the result looks incomplete
539
+ if (text && looksIncomplete(text)) {
540
+ const followUp = await executeSessionChatTurn({
541
+ sessionId,
542
+ message: 'Continue and complete the remaining steps. Provide a final summary when done.',
543
+ internal: false,
544
+ source: 'task',
545
+ })
546
+ const contText = typeof followUp.text === 'string' ? followUp.text.trim() : ''
547
+ if (contText) text = contText
548
+ }
549
+
550
+ return text
339
551
  }
340
552
 
341
553
  function notifyMainChatScheduleResult(task: BoardTask): void {
@@ -378,8 +590,8 @@ function notifyMainChatScheduleResult(task: BoardTask): void {
378
590
  const now = Date.now()
379
591
  let changed = false
380
592
 
381
- const buildMsg = (): Message => {
382
- const msg: Message = { role: 'assistant', text: body, time: now, kind: 'system' }
593
+ const buildMsg = (): SessionMessageLike => {
594
+ const msg: SessionMessageLike = { role: 'assistant', text: body, time: now, kind: 'system' }
383
595
  if (firstImage) msg.imageUrl = firstImage.url
384
596
  return msg
385
597
  }
@@ -702,6 +914,7 @@ export function enqueueTask(taskId: string) {
702
914
  export function validateCompletedTasksQueue() {
703
915
  const tasks = loadTasks()
704
916
  const sessions = loadSessions()
917
+ const settings = loadSettings()
705
918
  const now = Date.now()
706
919
  let checked = 0
707
920
  let demoted = 0
@@ -718,7 +931,7 @@ export function validateCompletedTasksQueue() {
718
931
  tasksDirty = true
719
932
  }
720
933
 
721
- const validation = validateTaskCompletion(task, { report })
934
+ const validation = validateTaskCompletion(task, { report, settings })
722
935
  const prevValidation = task.validation || null
723
936
  const validationChanged = !prevValidation
724
937
  || prevValidation.ok !== validation.ok
@@ -930,7 +1143,9 @@ export async function processNext() {
930
1143
  task.validation = null
931
1144
  task.updatedAt = Date.now()
932
1145
 
933
- const taskCwd = task.cwd || WORKSPACE_DIR
1146
+ const sessionsForCwd = loadSessions() as Record<string, SessionLike>
1147
+ const taskCwd = resolveTaskExecutionCwd(task as ScheduleTaskMeta, sessionsForCwd)
1148
+ task.cwd = taskCwd
934
1149
  let sessionId = ''
935
1150
  const scheduleTask = task as ScheduleTaskMeta
936
1151
  const isScheduleTask = scheduleTask.sourceType === 'schedule'
@@ -1029,6 +1244,7 @@ export async function processNext() {
1029
1244
  try {
1030
1245
  const result = await executeTaskRun(task, agent, sessionId)
1031
1246
  const t2 = loadTasks()
1247
+ const settings = loadSettings()
1032
1248
  if (t2[taskId]) {
1033
1249
  applyTaskPolicyDefaults(t2[taskId])
1034
1250
  // Structured extraction: Zod-validated result with typed artifacts
@@ -1045,7 +1261,7 @@ export async function processNext() {
1045
1261
  t2[taskId].updatedAt = Date.now()
1046
1262
  const report = ensureTaskCompletionReport(t2[taskId])
1047
1263
  if (report?.relativePath) t2[taskId].completionReportPath = report.relativePath
1048
- const validation = validateTaskCompletion(t2[taskId], { report })
1264
+ const validation = validateTaskCompletion(t2[taskId], { report, settings })
1049
1265
  t2[taskId].validation = validation
1050
1266
 
1051
1267
  const now = Date.now()
@@ -1260,6 +1476,29 @@ export function recoverStalledRunningTasks(): { recovered: number; deadLettered:
1260
1476
 
1261
1477
  for (const task of Object.values(tasks) as BoardTask[]) {
1262
1478
  if (task.status !== 'running') continue
1479
+ if (!task.startedAt) {
1480
+ const recoveredAt = Date.now()
1481
+ task.status = 'queued'
1482
+ task.queuedAt = task.queuedAt || recoveredAt
1483
+ task.retryScheduledAt = null
1484
+ task.updatedAt = recoveredAt
1485
+ task.error = 'Recovered inconsistent running state (missing startedAt); requeued.'
1486
+ if (!task.comments) task.comments = []
1487
+ task.comments.push({
1488
+ id: genId(),
1489
+ author: 'System',
1490
+ text: 'Recovered inconsistent running state (missing startedAt). Task requeued.',
1491
+ createdAt: recoveredAt,
1492
+ })
1493
+ pushQueueUnique(queue, task.id)
1494
+ recovered++
1495
+ changed = true
1496
+ pushMainLoopEventToMainSessions({
1497
+ type: 'task_stall_recovered',
1498
+ text: `Recovered inconsistent running task "${task.title}" (${task.id}) and requeued it.`,
1499
+ })
1500
+ continue
1501
+ }
1263
1502
  const since = Math.max(task.updatedAt || 0, task.startedAt || 0)
1264
1503
  if (!since || (now - since) < staleMs) continue
1265
1504
 
@@ -1286,6 +1525,9 @@ export function recoverStalledRunningTasks(): { recovered: number; deadLettered:
1286
1525
  if (changed) {
1287
1526
  saveTasks(tasks)
1288
1527
  saveQueue(queue)
1528
+ if (recovered > 0) {
1529
+ setTimeout(() => processNext(), 250)
1530
+ }
1289
1531
  }
1290
1532
 
1291
1533
  return { recovered, deadLettered }
@@ -3,6 +3,7 @@ import {
3
3
  DEFAULT_AGENT_LOOP_RECURSION_LIMIT,
4
4
  DEFAULT_CLAUDE_CODE_TIMEOUT_SEC,
5
5
  DEFAULT_CLI_PROCESS_TIMEOUT_SEC,
6
+ DEFAULT_DELEGATION_MAX_DEPTH,
6
7
  DEFAULT_LEGACY_ORCHESTRATOR_MAX_TURNS,
7
8
  DEFAULT_LOOP_MODE,
8
9
  DEFAULT_ONGOING_LOOP_MAX_ITERATIONS,
@@ -17,6 +18,7 @@ export interface RuntimeSettings {
17
18
  agentLoopRecursionLimit: number
18
19
  orchestratorLoopRecursionLimit: number
19
20
  legacyOrchestratorMaxTurns: number
21
+ delegationMaxDepth: number
20
22
  ongoingLoopMaxIterations: number
21
23
  ongoingLoopMaxRuntimeMs: number | null
22
24
  shellCommandTimeoutMs: number
@@ -61,6 +63,12 @@ export function loadRuntimeSettings(): RuntimeSettings {
61
63
  1,
62
64
  300,
63
65
  )
66
+ const delegationMaxDepth = parseIntSetting(
67
+ settings.delegationMaxDepth,
68
+ DEFAULT_DELEGATION_MAX_DEPTH,
69
+ 1,
70
+ 12,
71
+ )
64
72
  const ongoingLoopMaxIterations = parseIntSetting(
65
73
  settings.ongoingLoopMaxIterations,
66
74
  DEFAULT_ONGOING_LOOP_MAX_ITERATIONS,
@@ -98,6 +106,7 @@ export function loadRuntimeSettings(): RuntimeSettings {
98
106
  agentLoopRecursionLimit,
99
107
  orchestratorLoopRecursionLimit,
100
108
  legacyOrchestratorMaxTurns,
109
+ delegationMaxDepth,
101
110
  ongoingLoopMaxIterations,
102
111
  ongoingLoopMaxRuntimeMs: ongoingLoopMaxRuntimeMinutes > 0 ? ongoingLoopMaxRuntimeMinutes * 60_000 : null,
103
112
  shellCommandTimeoutMs: shellCommandTimeoutSec * 1000,
@@ -0,0 +1,23 @@
1
+ import { describe, it } from 'node:test'
2
+ import assert from 'node:assert/strict'
3
+ import { isMainMissionSession } from './session-run-manager'
4
+
5
+ describe('isMainMissionSession', () => {
6
+ it('accepts explicit main sessions', () => {
7
+ assert.equal(isMainMissionSession({ id: 'main-user', name: '__main__' }), true)
8
+ })
9
+
10
+ it('rejects human agent-thread sessions', () => {
11
+ assert.equal(
12
+ isMainMissionSession({ id: 'agent-thread-agent_coder-123', name: 'agent-thread:agent_coder', sessionType: 'human' }),
13
+ false,
14
+ )
15
+ })
16
+
17
+ it('accepts orchestrated sessions', () => {
18
+ assert.equal(
19
+ isMainMissionSession({ id: 'agent-thread-worker-1', name: 'agent-thread:worker', sessionType: 'orchestrated' }),
20
+ true,
21
+ )
22
+ })
23
+ })
@@ -203,7 +203,7 @@ function scheduleMainLoopFollowup(sessionId: string, followup: MainLoopFollowupR
203
203
  try {
204
204
  const sessions = loadSessions()
205
205
  const session = sessions[sessionId]
206
- if (!session || session.name !== '__main__') return
206
+ if (!session || !isMainMissionSession(session)) return
207
207
  enqueueSessionRun({
208
208
  sessionId,
209
209
  message: followup.message,
@@ -218,6 +218,16 @@ function scheduleMainLoopFollowup(sessionId: string, followup: MainLoopFollowupR
218
218
  }, delayMs)
219
219
  }
220
220
 
221
+ export function isMainMissionSession(session: Record<string, unknown>): boolean {
222
+ const id = typeof session.id === 'string' ? session.id.trim() : ''
223
+ const name = typeof session.name === 'string' ? session.name.trim() : ''
224
+ const sessionType = typeof session.sessionType === 'string' ? session.sessionType : ''
225
+ if (id.startsWith('main-') || name === '__main__') return true
226
+ // Only orchestrated thread sessions should receive autonomous main-loop followups.
227
+ if (sessionType === 'orchestrated') return true
228
+ return false
229
+ }
230
+
221
231
  async function drainExecution(executionKey: string): Promise<void> {
222
232
  if (state.runningByExecution.has(executionKey)) return
223
233
  const q = queueForExecution(executionKey)
@@ -3,65 +3,100 @@ import { tool, type StructuredToolInterface } from '@langchain/core/tools'
3
3
  import { loadSessions, saveSessions } from '../storage'
4
4
  import { notify } from '../ws-hub'
5
5
  import type { ToolBuildContext } from './context'
6
+ import type { Plugin, PluginHooks } from '@/types'
7
+ import { getPluginManager } from '../plugins'
8
+ import { normalizeToolInputArgs } from './normalize-tool-args'
6
9
 
7
- export function buildCanvasTools(bctx: ToolBuildContext): StructuredToolInterface[] {
8
- const { ctx, hasTool } = bctx
9
- if (!hasTool('canvas')) return []
10
+ /**
11
+ * Core Canvas Execution Logic
12
+ */
13
+ async function executeCanvasAction(args: Record<string, unknown>, context: { sessionId?: string }) {
14
+ const normalized = normalizeToolInputArgs(args)
15
+ const action = normalized.action as string
16
+ const content = normalized.content as string | undefined
17
+ try {
18
+ const sessionId = context.sessionId
19
+ if (!sessionId) return 'Error: no active session for canvas.'
10
20
 
11
- return [
12
- tool(
13
- async ({ action, content }) => {
14
- try {
15
- const sessionId = ctx?.sessionId
16
- if (!sessionId) return 'Error: no active session for canvas.'
21
+ const sessions = loadSessions()
22
+ const session = sessions[sessionId]
23
+ if (!session) return 'Error: session not found.'
17
24
 
18
- const sessions = loadSessions()
19
- const session = sessions[sessionId]
20
- if (!session) return 'Error: session not found.'
25
+ if (action === 'present') {
26
+ if (!content) return 'Error: content is required for present action.'
27
+ ;(session as Record<string, unknown>).canvasContent = content
28
+ session.lastActiveAt = Date.now()
29
+ sessions[sessionId] = session
30
+ saveSessions(sessions)
31
+ notify(`canvas:${sessionId}`)
32
+ return JSON.stringify({ ok: true, action: 'present', contentLength: content.length })
33
+ }
21
34
 
22
- if (action === 'present') {
23
- if (!content) return 'Error: content is required for present action.'
24
- ;(session as Record<string, unknown>).canvasContent = content
25
- session.lastActiveAt = Date.now()
26
- sessions[sessionId] = session
27
- saveSessions(sessions)
28
- notify(`canvas:${sessionId}`)
29
- return JSON.stringify({ ok: true, action: 'present', contentLength: content.length })
30
- }
35
+ if (action === 'hide') {
36
+ ;(session as Record<string, unknown>).canvasContent = null
37
+ session.lastActiveAt = Date.now()
38
+ sessions[sessionId] = session
39
+ saveSessions(sessions)
40
+ notify(`canvas:${sessionId}`)
41
+ return JSON.stringify({ ok: true, action: 'hide' })
42
+ }
31
43
 
32
- if (action === 'hide') {
33
- ;(session as Record<string, unknown>).canvasContent = null
34
- session.lastActiveAt = Date.now()
35
- sessions[sessionId] = session
36
- saveSessions(sessions)
37
- notify(`canvas:${sessionId}`)
38
- return JSON.stringify({ ok: true, action: 'hide' })
39
- }
44
+ if (action === 'snapshot') {
45
+ const current = (session as Record<string, unknown>).canvasContent
46
+ return JSON.stringify({
47
+ ok: true,
48
+ action: 'snapshot',
49
+ hasContent: !!current,
50
+ contentLength: typeof current === 'string' ? current.length : 0,
51
+ preview: typeof current === 'string' ? current.slice(0, 500) : null,
52
+ })
53
+ }
40
54
 
41
- if (action === 'snapshot') {
42
- const current = (session as Record<string, unknown>).canvasContent
43
- return JSON.stringify({
44
- ok: true,
45
- action: 'snapshot',
46
- hasContent: !!current,
47
- contentLength: typeof current === 'string' ? current.length : 0,
48
- preview: typeof current === 'string' ? current.slice(0, 500) : null,
49
- })
50
- }
55
+ return `Unknown canvas action "${action}".`
56
+ } catch (err: unknown) {
57
+ return `Error: ${err instanceof Error ? err.message : String(err)}`
58
+ }
59
+ }
51
60
 
52
- return `Unknown canvas action "${action}". Valid: present, hide, snapshot`
53
- } catch (err: unknown) {
54
- return `Error: ${err instanceof Error ? err.message : String(err)}`
55
- }
61
+ /**
62
+ * Register as a Built-in Plugin
63
+ */
64
+ const CanvasPlugin: Plugin = {
65
+ name: 'Core Canvas',
66
+ description: 'Present live HTML/CSS/JS content to the user in an interactive canvas panel.',
67
+ hooks: {} as PluginHooks,
68
+ tools: [
69
+ {
70
+ name: 'canvas',
71
+ description: 'Interact with the live canvas panel.',
72
+ parameters: {
73
+ type: 'object',
74
+ properties: {
75
+ action: { type: 'string', enum: ['present', 'hide', 'snapshot'] },
76
+ content: { type: 'string' }
77
+ },
78
+ required: ['action']
56
79
  },
80
+ execute: async (args, context) => executeCanvasAction(args, { sessionId: context.session.id })
81
+ }
82
+ ]
83
+ }
84
+
85
+ getPluginManager().registerBuiltin('canvas', CanvasPlugin)
86
+
87
+ /**
88
+ * Legacy Bridge
89
+ */
90
+ export function buildCanvasTools(bctx: ToolBuildContext): StructuredToolInterface[] {
91
+ if (!bctx.hasTool('canvas')) return []
92
+ return [
93
+ tool(
94
+ async (args) => executeCanvasAction(args, { sessionId: bctx.ctx?.sessionId || undefined }),
57
95
  {
58
96
  name: 'canvas',
59
- description: 'Present live HTML/CSS/JS content to the user in an interactive canvas panel. Use "present" to show content, "hide" to dismiss, "snapshot" to check current state. The canvas renders in a sandboxed iframe alongside the chat.',
60
- schema: z.object({
61
- action: z.enum(['present', 'hide', 'snapshot']).describe('Canvas action to perform'),
62
- content: z.string().optional().describe('HTML content to render (required for "present"). Can include inline CSS and JS.'),
63
- }),
64
- },
65
- ),
97
+ description: CanvasPlugin.tools![0].description,
98
+ schema: z.object({}).passthrough()
99
+ }
100
+ )
66
101
  ]
67
102
  }