skimpyclaw 0.3.6 → 0.3.9

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 (73) hide show
  1. package/README.md +14 -6
  2. package/dist/__tests__/api.test.js +1 -0
  3. package/dist/__tests__/channels.test.js +1 -1
  4. package/dist/__tests__/code-agents-orchestrator.test.js +74 -7
  5. package/dist/__tests__/code-agents-preflight.test.d.ts +1 -0
  6. package/dist/__tests__/code-agents-preflight.test.js +88 -0
  7. package/dist/__tests__/code-agents-sandbox.test.d.ts +1 -0
  8. package/dist/__tests__/code-agents-sandbox.test.js +163 -0
  9. package/dist/__tests__/code-agents-utils.test.js +12 -1
  10. package/dist/__tests__/context-manager.test.d.ts +1 -0
  11. package/dist/__tests__/context-manager.test.js +236 -0
  12. package/dist/__tests__/package-manager-detection.test.js +5 -5
  13. package/dist/__tests__/setup.test.js +7 -5
  14. package/dist/__tests__/skills.test.js +2 -2
  15. package/dist/__tests__/structured-context.test.d.ts +1 -0
  16. package/dist/__tests__/structured-context.test.js +100 -0
  17. package/dist/__tests__/tools.test.js +65 -3
  18. package/dist/agent.js +4 -5
  19. package/dist/api.js +10 -58
  20. package/dist/audit.js +5 -51
  21. package/dist/channels/telegram/handlers.js +2 -60
  22. package/dist/channels/telegram/index.js +0 -7
  23. package/dist/channels.js +1 -1
  24. package/dist/cli.js +151 -16
  25. package/dist/code-agents/executor.d.ts +9 -4
  26. package/dist/code-agents/executor.js +187 -13
  27. package/dist/code-agents/index.d.ts +1 -1
  28. package/dist/code-agents/index.js +30 -22
  29. package/dist/code-agents/orchestrator.d.ts +8 -2
  30. package/dist/code-agents/orchestrator.js +318 -27
  31. package/dist/code-agents/structured-context.d.ts +7 -0
  32. package/dist/code-agents/structured-context.js +54 -0
  33. package/dist/code-agents/types.d.ts +2 -0
  34. package/dist/code-agents/utils.d.ts +4 -0
  35. package/dist/code-agents/utils.js +38 -2
  36. package/dist/code-agents/worktree.d.ts +40 -0
  37. package/dist/code-agents/worktree.js +215 -0
  38. package/dist/config.d.ts +1 -0
  39. package/dist/config.js +5 -3
  40. package/dist/cron.js +18 -4
  41. package/dist/dashboard/assets/{index-CkonC7Cd.js → index-BoTHPby4.js} +20 -20
  42. package/dist/dashboard/assets/{index-EAg6lqF5.css → index-D4mufvBg.css} +1 -1
  43. package/dist/dashboard/index.html +2 -2
  44. package/dist/discord.js +4 -40
  45. package/dist/exec-approval.js +1 -1
  46. package/dist/file-lock.js +1 -1
  47. package/dist/gateway.js +3 -10
  48. package/dist/providers/anthropic.js +9 -5
  49. package/dist/providers/codex.js +10 -6
  50. package/dist/providers/context-manager.d.ts +22 -0
  51. package/dist/providers/context-manager.js +100 -0
  52. package/dist/providers/openai.js +9 -5
  53. package/dist/providers/types.d.ts +1 -0
  54. package/dist/security.js +9 -0
  55. package/dist/setup.js +122 -27
  56. package/dist/skills.js +9 -2
  57. package/dist/subagent.js +33 -2
  58. package/dist/tools/bash-tool.js +8 -0
  59. package/dist/tools/browser-tool.js +2 -1
  60. package/dist/tools/definitions.d.ts +0 -27
  61. package/dist/tools/definitions.js +0 -18
  62. package/dist/tools/execute-context.d.ts +4 -4
  63. package/dist/tools/file-tools.d.ts +1 -1
  64. package/dist/tools/file-tools.js +1 -1
  65. package/dist/tools.d.ts +5 -5
  66. package/dist/tools.js +87 -98
  67. package/dist/types.d.ts +14 -22
  68. package/dist/usage.d.ts +1 -0
  69. package/dist/usage.js +30 -46
  70. package/dist/utils.d.ts +18 -0
  71. package/dist/utils.js +71 -0
  72. package/dist/voice.js +9 -7
  73. package/package.json +26 -21
@@ -1,9 +1,15 @@
1
1
  // Code Agent Orchestrator - Team coordination logic
2
+ import { execSync } from 'child_process';
3
+ import { existsSync, readFileSync } from 'fs';
4
+ import { join } from 'path';
2
5
  import { getNextCodeAgentId, storeCodeAgentTask, writeCodeAgentTask, getCodeAgent } from './registry.js';
3
6
  import { runCodeAgentBackground, runValidation } from './executor.js';
4
7
  import { notifyCodeAgentResult } from './utils.js';
8
+ import { parseAgentOutput, formatStructuredContext } from './structured-context.js';
5
9
  import { runAgentTurn } from '../agent.js';
6
10
  import { startTrace, addEvent, endTrace } from '../audit.js';
11
+ import { toErrorMessage } from '../utils.js';
12
+ import { isGitRepo, commitPendingChanges, createWorktree, mergeWorktree, removeWorktree, cleanupAllWorktrees, } from './worktree.js';
7
13
  /**
8
14
  * Compute execution waves from dependency info.
9
15
  * Returns an array of waves, where each wave is an array of subtask indices that can run in parallel.
@@ -44,14 +50,62 @@ export function computeWaves(subtasks) {
44
50
  }
45
51
  return waves;
46
52
  }
53
+ /**
54
+ * Gather lightweight codebase context to improve task decomposition.
55
+ * Returns a short summary of the project structure (file tree, package.json scripts).
56
+ * Capped at ~2000 chars to keep the decomposition prompt small.
57
+ */
58
+ export function gatherCodebaseContext(workdir) {
59
+ const parts = [];
60
+ // Package.json scripts
61
+ try {
62
+ const pkgPath = join(workdir, 'package.json');
63
+ if (existsSync(pkgPath)) {
64
+ const pkg = JSON.parse(readFileSync(pkgPath, 'utf-8'));
65
+ if (pkg.scripts) {
66
+ const scriptNames = Object.keys(pkg.scripts).slice(0, 15).join(', ');
67
+ parts.push(`Scripts: ${scriptNames}`);
68
+ }
69
+ if (pkg.dependencies || pkg.devDependencies) {
70
+ const deps = Object.keys({ ...pkg.dependencies, ...pkg.devDependencies }).slice(0, 20).join(', ');
71
+ parts.push(`Key deps: ${deps}`);
72
+ }
73
+ }
74
+ }
75
+ catch { /* ignore */ }
76
+ // Source file tree (top-level structure)
77
+ try {
78
+ const tree = execSync('find . -name "*.ts" -o -name "*.tsx" -o -name "*.js" -o -name "*.jsx" | grep -v node_modules | grep -v dist | grep -v .test. | sort | head -60', { cwd: workdir, timeout: 5000, encoding: 'utf-8' }).trim();
79
+ if (tree)
80
+ parts.push(`Source files:\n${tree}`);
81
+ }
82
+ catch { /* ignore */ }
83
+ const context = parts.join('\n\n');
84
+ return context.slice(0, 2000);
85
+ }
47
86
  /**
48
87
  * Use a quick model call to decompose a complex task into N subtasks with optional dependency info.
49
88
  * Falls back to numbered subtask splitting on parse error.
50
89
  * Falls back to all-independent if dependency info is missing or invalid.
51
90
  */
52
- export async function decomposeTask(task, teamSize, config) {
91
+ export async function decomposeTask(task, teamSize, config, workdir) {
53
92
  try {
54
- const prompt = `Split into exactly ${teamSize} subtasks. Return JSON only: {"subtasks":[{"description":"...","dependsOn":[]},...]}. Use 0-based indices for dependsOn.\n\nTask: ${task}`;
93
+ // Gather codebase context for smarter decomposition
94
+ const codebaseContext = workdir ? gatherCodebaseContext(workdir) : '';
95
+ const contextBlock = codebaseContext
96
+ ? `\n\nProject structure:\n${codebaseContext}\n`
97
+ : '';
98
+ const prompt = `You are a task decomposition expert. Split the following coding task into exactly ${teamSize} independent or dependent subtasks that can be assigned to separate coding agents.
99
+
100
+ Rules:
101
+ - Each subtask should be self-contained with clear file scope
102
+ - Each parallel agent gets its own git worktree (branch), so file overlap is OK but be aware changes are merged after
103
+ - Use dependsOn to order subtasks that must run sequentially (e.g. create interface before implementation)
104
+ - Be specific: mention exact files, functions, and expected changes
105
+ - Return JSON only: {"subtasks":[{"description":"...","dependsOn":[]},...]}
106
+ - Use 0-based indices for dependsOn
107
+ ${contextBlock}
108
+ Task: ${task}`;
55
109
  const result = await runAgentTurn('main', prompt, config);
56
110
  const match = result.match(/\{[\s\S]*"subtasks"[\s\S]*\}/);
57
111
  if (match) {
@@ -96,15 +150,36 @@ export async function decomposeTask(task, teamSize, config) {
96
150
  /**
97
151
  * Use a quick model call to synthesize results from multiple subtask completions.
98
152
  */
99
- export async function synthesizeResults(originalTask, results, config) {
153
+ export async function synthesizeResults(originalTask, results, config, workdir) {
100
154
  try {
101
- const resultSummary = results.map((r, i) => `### Subtask ${i + 1}: ${r.subtask}\nStatus: ${r.status}\n${r.output ? `Output: ${r.output.slice(0, 300)}` : ''}${r.error ? `Error: ${r.error}` : ''}`).join('\n\n');
155
+ const resultSummary = results.map((r, i) => {
156
+ const context = r.output
157
+ ? formatStructuredContext(parseAgentOutput(r.output))
158
+ : '';
159
+ return `### Subtask ${i + 1}: ${r.subtask}\nStatus: ${r.status}\n${context}${r.error ? `\nError: ${r.error}` : ''}`;
160
+ }).join('\n\n');
161
+ // Include actual file changes from git for accuracy
162
+ let diffBlock = '';
163
+ if (workdir) {
164
+ try {
165
+ const diffStat = execSync('git diff --stat HEAD 2>/dev/null || git diff --stat 2>/dev/null', {
166
+ cwd: workdir,
167
+ timeout: 5000,
168
+ encoding: 'utf-8',
169
+ }).trim();
170
+ if (diffStat)
171
+ diffBlock = `\n\nActual file changes (git diff --stat):\n${diffStat.slice(0, 2000)}`;
172
+ }
173
+ catch { /* not a git repo or no changes */ }
174
+ }
175
+ const succeeded = results.filter(r => r.status === 'completed').length;
176
+ const failed = results.filter(r => r.status !== 'completed').length;
102
177
  const prompt = `You are a results synthesizer. Summarize the results of a multi-agent coding task.
103
178
 
104
179
  Original task: ${originalTask}
105
180
 
106
- Results from each agent:
107
- ${resultSummary}
181
+ Results from each agent (${succeeded} succeeded, ${failed} failed):
182
+ ${resultSummary}${diffBlock}
108
183
 
109
184
  Provide a concise markdown summary of what was accomplished, what succeeded, and what failed (if anything). Be specific about files changed and outcomes.`;
110
185
  return await runAgentTurn('main', prompt, config);
@@ -131,9 +206,15 @@ export async function runTeamOrchestrator(parentId, task, teamSize, workdir, val
131
206
  durationMs: 0,
132
207
  detail: { teamSize, workdir, agent, model, validate },
133
208
  });
134
- const timeoutMinutes = Math.min(context?.fullConfig?.subagents?.maxConcurrent ? 60 : 20, 60);
135
- const perChildTimeout = Math.max(5, Math.floor(timeoutMinutes / teamSize));
209
+ const configTeamTimeout = context?.fullConfig?.codeAgents?.teamTimeoutMinutes ?? 60;
210
+ const timeoutMinutes = Math.min(configTeamTimeout, 120);
211
+ // Reserve budget for overhead (decompose, synthesize, validation) and distribute rest across waves
212
+ const overheadMinutes = 5;
213
+ const availableForChildren = Math.max(timeoutMinutes - overheadMinutes, timeoutMinutes * 0.7);
136
214
  const CANCELLED_MESSAGE = 'Cancelled by user';
215
+ // Worktree state — declared outside try so catch can clean up
216
+ const _useWorktrees = isGitRepo(workdir);
217
+ const _activeWorktrees = new Map();
137
218
  try {
138
219
  if (getCodeAgent(parentId)?.status === 'cancelled')
139
220
  throw new Error(CANCELLED_MESSAGE);
@@ -143,8 +224,10 @@ export async function runTeamOrchestrator(parentId, task, teamSize, workdir, val
143
224
  const fullConfig = context?.fullConfig;
144
225
  if (!fullConfig)
145
226
  throw new Error('No config available for task decomposition');
146
- const subtasks = await decomposeTask(task, teamSize, fullConfig);
227
+ const subtasks = await decomposeTask(task, teamSize, fullConfig, workdir);
147
228
  const waves = computeWaves(subtasks);
229
+ // Distribute timeout across waves (not team size) for better budgeting
230
+ const perChildTimeout = Math.max(5, Math.floor(availableForChildren / waves.length));
148
231
  addEvent(traceId, {
149
232
  type: 'decompose',
150
233
  summary: `Decomposed into ${subtasks.length} subtasks in ${waves.length} wave(s)`,
@@ -181,6 +264,20 @@ export async function runTeamOrchestrator(parentId, task, teamSize, workdir, val
181
264
  }
182
265
  parentTask.childTaskIds = childIds;
183
266
  writeCodeAgentTask(parentTask);
267
+ // Helper: get git diff summary for context passing between waves
268
+ function getGitDiffSummary() {
269
+ try {
270
+ const diff = execSync('git diff --stat HEAD 2>/dev/null || git diff --stat 2>/dev/null', {
271
+ cwd: workdir,
272
+ timeout: 5000,
273
+ encoding: 'utf-8',
274
+ }).trim();
275
+ return diff ? diff.slice(0, 1500) : '';
276
+ }
277
+ catch {
278
+ return '';
279
+ }
280
+ }
184
281
  // Helper: build task prompt with predecessor context for dependent subtasks
185
282
  function buildChildPrompt(subtaskIdx) {
186
283
  const sub = subtasks[subtaskIdx];
@@ -190,12 +287,34 @@ export async function runTeamOrchestrator(parentId, task, teamSize, workdir, val
190
287
  for (const depIdx of sub.dependsOn) {
191
288
  const depChild = getCodeAgent(childIdByIndex[depIdx]);
192
289
  if (depChild && depChild.outputPreview) {
193
- contextParts.push(`- Task "${subtasks[depIdx].description}": ${depChild.outputPreview.slice(0, 1000)}`);
290
+ // Use the full outputPreview (up to 5000 chars) not just the 500-char summary
291
+ const structured = formatStructuredContext(parseAgentOutput(depChild.outputPreview));
292
+ contextParts.push(`- Task "${subtasks[depIdx].description}" [${depChild.status}]:\n${structured}`);
194
293
  }
195
294
  }
196
- if (contextParts.length === 0)
295
+ // Include git diff to show what predecessor waves actually changed on disk
296
+ const diffSummary = getGitDiffSummary();
297
+ const diffBlock = diffSummary ? `\nFiles changed so far:\n${diffSummary}\n` : '';
298
+ if (contextParts.length === 0 && !diffBlock)
197
299
  return sub.description;
198
- return `Context from completed prerequisite tasks:\n${contextParts.join('\n')}\n\nYour task: ${sub.description}`;
300
+ return `Context from completed prerequisite tasks:\n${contextParts.join('\n')}${diffBlock}\nYour task: ${sub.description}`;
301
+ }
302
+ // Worktree isolation: parallel agents in the same wave get their own worktree
303
+ // so they can't overwrite each other's files. After the wave, branches are
304
+ // merged back sequentially. Falls back to shared workdir if not a git repo.
305
+ const useWorktrees = _useWorktrees;
306
+ const activeWorktrees = _activeWorktrees;
307
+ if (useWorktrees) {
308
+ // Clean up any stale worktrees from previous crashed runs
309
+ cleanupAllWorktrees(workdir);
310
+ addEvent(traceId, {
311
+ type: 'worktree',
312
+ summary: 'Git worktree isolation enabled',
313
+ durationMs: Date.now() - startedAt.getTime(),
314
+ });
315
+ }
316
+ else {
317
+ console.warn('[code-team] Not a git repo — agents will share workdir (risk of file conflicts)');
199
318
  }
200
319
  // Phase 3: Execute waves sequentially, tasks within each wave in parallel
201
320
  const POLL_INTERVAL = 3000;
@@ -210,29 +329,55 @@ export async function runTeamOrchestrator(parentId, task, teamSize, workdir, val
210
329
  summary: `Starting wave ${waveIdx + 1}/${totalWaves} (${waveIndices.length} tasks)`,
211
330
  durationMs: Date.now() - startedAt.getTime(),
212
331
  });
213
- // Spawn all tasks in this wave
214
- for (const subtaskIdx of waveIndices) {
215
- const childId = childIdByIndex[subtaskIdx];
216
- const child = getCodeAgent(childId);
217
- const prompt = buildChildPrompt(subtaskIdx);
218
- child.status = 'running';
219
- child.task = prompt;
220
- child.startedAt = new Date().toISOString();
221
- writeCodeAgentTask(child);
222
- runCodeAgentBackground(childId, agent, prompt, workdir, false, // children don't validate individually
223
- { task: prompt, model, timeout_minutes: perChildTimeout }, new Date(), { skipNotification: true, defaultTimeoutMinutes: perChildTimeout, maxTimeoutMinutes: perChildTimeout }).catch((err) => {
332
+ // Helper: spawn a single child task in the given workdir
333
+ function spawnChild(childId, prompt, childWorkdir) {
334
+ runCodeAgentBackground(childId, agent, prompt, childWorkdir, false, // per-child validation is handled by the orchestrator below
335
+ { task: prompt, model, timeout_minutes: perChildTimeout }, new Date(), { skipNotification: true, defaultTimeoutMinutes: perChildTimeout, maxTimeoutMinutes: perChildTimeout, validationCommands: fullConfig?.codeAgents?.validationCommands }).catch((err) => {
224
336
  const child = getCodeAgent(childId);
225
337
  if (child && child.status === 'running') {
226
338
  Object.assign(child, {
227
339
  status: 'failed',
228
340
  endedAt: new Date().toISOString(),
229
- error: err instanceof Error ? err.message : String(err),
341
+ error: toErrorMessage(err),
230
342
  });
231
343
  writeCodeAgentTask(child);
232
344
  }
233
345
  console.error(`[code-team] Child ${childId} background error:`, err);
234
346
  });
235
347
  }
348
+ // Create worktrees for parallel tasks (waves with >1 child)
349
+ const useWorktreeForWave = useWorktrees && waveIndices.length > 1;
350
+ if (useWorktreeForWave) {
351
+ // Commit any pending changes so worktrees branch from a clean state
352
+ commitPendingChanges(workdir, `[skimpyclaw] pre-wave-${waveIdx + 1}`);
353
+ parentTask.liveOutput = `Phase: Creating worktrees for wave ${waveIdx + 1}...`;
354
+ writeCodeAgentTask(parentTask);
355
+ }
356
+ // Spawn all tasks in this wave
357
+ for (const subtaskIdx of waveIndices) {
358
+ const childId = childIdByIndex[subtaskIdx];
359
+ const child = getCodeAgent(childId);
360
+ const prompt = buildChildPrompt(subtaskIdx);
361
+ // Determine workdir for this child
362
+ let childWorkdir = workdir;
363
+ if (useWorktreeForWave) {
364
+ try {
365
+ const wt = createWorktree(workdir, childId);
366
+ activeWorktrees.set(childId, wt);
367
+ childWorkdir = wt.path;
368
+ console.log(`[code-team] Created worktree for ${childId}: ${wt.path} (branch ${wt.branch})`);
369
+ }
370
+ catch (err) {
371
+ console.error(`[code-team] Failed to create worktree for ${childId}, using shared workdir:`, err);
372
+ }
373
+ }
374
+ child.status = 'running';
375
+ child.task = prompt;
376
+ child.workdir = childWorkdir;
377
+ child.startedAt = new Date().toISOString();
378
+ writeCodeAgentTask(child);
379
+ spawnChild(childId, prompt, childWorkdir);
380
+ }
236
381
  // Poll until all tasks in this wave complete
237
382
  const waveChildIds = waveIndices.map(i => childIdByIndex[i]);
238
383
  // Check cancellation after spawning
@@ -303,6 +448,117 @@ export async function runTeamOrchestrator(parentId, task, teamSize, workdir, val
303
448
  break;
304
449
  }
305
450
  }
451
+ // Per-wave validation: run build after each wave to catch breakage early
452
+ if (validate && Date.now() - startedAt.getTime() < totalTimeoutMs) {
453
+ const waveCompleted = waveChildIds.every(id => getCodeAgent(id)?.status === 'completed');
454
+ if (waveCompleted) {
455
+ parentTask.liveOutput = `Phase: Validating wave ${waveIdx + 1}/${totalWaves}...`;
456
+ writeCodeAgentTask(parentTask);
457
+ const { passed, output: valOutput } = await runValidation(workdir, fullConfig?.codeAgents?.validationCommands);
458
+ if (!passed) {
459
+ addEvent(traceId, {
460
+ type: 'wave_validation',
461
+ summary: `Wave ${waveIdx + 1} validation failed — retrying failed children`,
462
+ durationMs: Date.now() - startedAt.getTime(),
463
+ });
464
+ // Retry each child in this wave once with the validation error context
465
+ const retryChildIds = [];
466
+ for (const subtaskIdx of waveIndices) {
467
+ const childId = childIdByIndex[subtaskIdx];
468
+ const child = getCodeAgent(childId);
469
+ if (child.retryCount)
470
+ continue; // already retried
471
+ child.retryCount = 1;
472
+ child.status = 'running';
473
+ child.validationOutput = valOutput.slice(0, 4000);
474
+ child.startedAt = new Date().toISOString();
475
+ writeCodeAgentTask(child);
476
+ const retryPrompt = `Fix build/test errors. Your original task: ${subtasks[subtaskIdx].description}\n\nValidation errors:\n${valOutput.slice(0, 4000)}`;
477
+ child.task = retryPrompt;
478
+ writeCodeAgentTask(child);
479
+ spawnChild(childId, retryPrompt, workdir);
480
+ retryChildIds.push(childId);
481
+ }
482
+ // Poll until retries complete
483
+ if (retryChildIds.length > 0) {
484
+ while (true) {
485
+ await new Promise(resolve => setTimeout(resolve, POLL_INTERVAL));
486
+ if (getCodeAgent(parentId)?.status === 'cancelled')
487
+ throw new Error(CANCELLED_MESSAGE);
488
+ const retryDone = retryChildIds.every(id => {
489
+ const c = getCodeAgent(id);
490
+ return c.status !== 'running' && c.status !== 'validating';
491
+ });
492
+ if (retryDone)
493
+ break;
494
+ if (Date.now() - startedAt.getTime() > totalTimeoutMs)
495
+ break;
496
+ }
497
+ addEvent(traceId, {
498
+ type: 'wave_retry_complete',
499
+ summary: `Wave ${waveIdx + 1} retry complete`,
500
+ durationMs: Date.now() - startedAt.getTime(),
501
+ });
502
+ }
503
+ }
504
+ else {
505
+ addEvent(traceId, {
506
+ type: 'wave_validation',
507
+ summary: `Wave ${waveIdx + 1} validation passed`,
508
+ durationMs: Date.now() - startedAt.getTime(),
509
+ });
510
+ }
511
+ }
512
+ }
513
+ // Merge worktree branches back into main branch sequentially
514
+ if (useWorktreeForWave && Date.now() - startedAt.getTime() < totalTimeoutMs) {
515
+ parentTask.liveOutput = `Phase: Merging wave ${waveIdx + 1} results...`;
516
+ writeCodeAgentTask(parentTask);
517
+ const mergeErrors = [];
518
+ for (const subtaskIdx of waveIndices) {
519
+ const childId = childIdByIndex[subtaskIdx];
520
+ const wt = activeWorktrees.get(childId);
521
+ if (!wt)
522
+ continue;
523
+ const child = getCodeAgent(childId);
524
+ if (!child || child.status !== 'completed') {
525
+ // Don't merge failed/timed-out children
526
+ console.log(`[code-team] Skipping merge for ${childId} (status: ${child?.status})`);
527
+ removeWorktree(workdir, childId, wt.branch);
528
+ activeWorktrees.delete(childId);
529
+ continue;
530
+ }
531
+ // Commit any uncommitted changes in the worktree before merging
532
+ commitPendingChanges(wt.path, `[skimpyclaw] ${childId}: ${subtasks[subtaskIdx].description.slice(0, 60)}`);
533
+ const { merged, conflict } = mergeWorktree(workdir, wt.branch, childId);
534
+ if (merged) {
535
+ console.log(`[code-team] Merged ${childId} (branch ${wt.branch}) successfully`);
536
+ addEvent(traceId, {
537
+ type: 'merge',
538
+ summary: `Merged ${childId} successfully`,
539
+ durationMs: Date.now() - startedAt.getTime(),
540
+ });
541
+ }
542
+ else {
543
+ const conflictMsg = `Merge conflict for ${childId}: ${conflict}`;
544
+ console.error(`[code-team] ${conflictMsg}`);
545
+ mergeErrors.push(conflictMsg);
546
+ addEvent(traceId, {
547
+ type: 'merge_conflict',
548
+ summary: conflictMsg.slice(0, 200),
549
+ durationMs: Date.now() - startedAt.getTime(),
550
+ });
551
+ }
552
+ // Clean up worktree regardless of merge result
553
+ removeWorktree(workdir, childId, wt.branch);
554
+ activeWorktrees.delete(childId);
555
+ }
556
+ if (mergeErrors.length > 0) {
557
+ const errorMsg = `Merge conflicts in wave ${waveIdx + 1}:\n${mergeErrors.join('\n')}`;
558
+ parentTask.error = (parentTask.error ? parentTask.error + '\n' : '') + errorMsg;
559
+ writeCodeAgentTask(parentTask);
560
+ }
561
+ }
306
562
  // If we timed out, don't start more waves
307
563
  if (Date.now() - startedAt.getTime() > totalTimeoutMs)
308
564
  break;
@@ -311,6 +567,27 @@ export async function runTeamOrchestrator(parentId, task, teamSize, workdir, val
311
567
  if (getCodeAgent(parentId)?.status === 'cancelled')
312
568
  throw new Error(CANCELLED_MESSAGE);
313
569
  parentTask.liveOutput = 'Phase: Synthesizing results...';
570
+ // Aggregate cost/tokens from all children into parent
571
+ let totalCost = 0;
572
+ let totalInput = 0;
573
+ let totalOutput = 0;
574
+ let hasCostData = false;
575
+ for (const cid of childIds) {
576
+ const child = getCodeAgent(cid);
577
+ if (child?.totalCost != null) {
578
+ totalCost += child.totalCost;
579
+ hasCostData = true;
580
+ }
581
+ if (child?.inputTokens != null)
582
+ totalInput += child.inputTokens;
583
+ if (child?.outputTokens != null)
584
+ totalOutput += child.outputTokens;
585
+ }
586
+ if (hasCostData) {
587
+ parentTask.totalCost = totalCost;
588
+ parentTask.inputTokens = totalInput;
589
+ parentTask.outputTokens = totalOutput;
590
+ }
314
591
  writeCodeAgentTask(parentTask);
315
592
  const childResults = childIds.map(id => {
316
593
  const child = getCodeAgent(id);
@@ -326,14 +603,14 @@ export async function runTeamOrchestrator(parentId, task, teamSize, workdir, val
326
603
  summary: `Synthesizing ${childResults.length} results`,
327
604
  durationMs: Date.now() - startedAt.getTime(),
328
605
  });
329
- const synthesis = await synthesizeResults(task, childResults, fullConfig);
606
+ const synthesis = await synthesizeResults(task, childResults, fullConfig, workdir);
330
607
  parentTask.synthesisResult = synthesis;
331
608
  // Phase 5: Validation (once, on the combined result)
332
609
  if (validate) {
333
610
  parentTask.liveOutput = 'Phase: Validating...';
334
611
  parentTask.status = 'validating';
335
612
  writeCodeAgentTask(parentTask);
336
- const { passed, output } = await runValidation(workdir);
613
+ const { passed, output } = await runValidation(workdir, fullConfig?.codeAgents?.validationCommands);
337
614
  const endedAt = new Date();
338
615
  const duration = Math.round((endedAt.getTime() - startedAt.getTime()) / 1000);
339
616
  if (!passed) {
@@ -382,7 +659,21 @@ export async function runTeamOrchestrator(parentId, task, teamSize, workdir, val
382
659
  await notifyCodeAgentResult(parentTask, getCodeAgent);
383
660
  }
384
661
  catch (err) {
385
- const errMsg = err instanceof Error ? err.message : String(err);
662
+ // Clean up any remaining worktrees
663
+ for (const [childId, wt] of _activeWorktrees) {
664
+ try {
665
+ removeWorktree(workdir, childId, wt.branch);
666
+ }
667
+ catch { /* best effort */ }
668
+ }
669
+ _activeWorktrees.clear();
670
+ if (_useWorktrees) {
671
+ try {
672
+ cleanupAllWorktrees(workdir);
673
+ }
674
+ catch { /* best effort */ }
675
+ }
676
+ const errMsg = toErrorMessage(err);
386
677
  addEvent(traceId, { type: 'error', summary: errMsg.slice(0, 200), durationMs: Date.now() - startedAt.getTime() });
387
678
  await endTrace(traceId, 'error');
388
679
  Object.assign(parentTask, {
@@ -0,0 +1,7 @@
1
+ export interface StructuredAgentOutput {
2
+ summary: string;
3
+ files: string[];
4
+ errors: string[];
5
+ }
6
+ export declare function parseAgentOutput(raw: string): StructuredAgentOutput;
7
+ export declare function formatStructuredContext(parsed: StructuredAgentOutput): string;
@@ -0,0 +1,54 @@
1
+ // Structured context extraction for team agent inter-communication.
2
+ // Replaces raw string truncation with a token-efficient structured summary
3
+ // that preserves the most useful signal: what files changed and what went wrong.
4
+ // Matches backtick-quoted paths like `src/foo.ts`
5
+ const BACKTICK_PATH_RE = /`([^`\n]{3,200})`/g;
6
+ // Matches absolute paths like /Users/katre/Sites/skimpyclaw/src/foo.ts
7
+ const ABS_PATH_RE = /(?:^|\s)(\/[^\s,;'"()\n]{3,300})/gm;
8
+ // Matches error-like lines
9
+ const ERROR_LINE_RE = /^.{0,20}(?:error|failed|failure|exception)[^\n]{0,200}$/gim;
10
+ export function parseAgentOutput(raw) {
11
+ const files = new Set();
12
+ // Extract backtick-quoted paths (e.g. `src/agent.ts`, `/abs/path.ts`)
13
+ BACKTICK_PATH_RE.lastIndex = 0;
14
+ let m;
15
+ while ((m = BACKTICK_PATH_RE.exec(raw)) !== null) {
16
+ const p = m[1].trim();
17
+ // Must look like a file path: contains a dot and no spaces
18
+ if (p.includes('.') && !p.includes(' ')) {
19
+ files.add(p.replace(/[.,;]$/, ''));
20
+ }
21
+ }
22
+ // Extract absolute paths
23
+ ABS_PATH_RE.lastIndex = 0;
24
+ while ((m = ABS_PATH_RE.exec(raw)) !== null) {
25
+ const p = m[1].trim().replace(/[.,;]$/, '');
26
+ if (p.includes('.')) {
27
+ files.add(p);
28
+ }
29
+ }
30
+ // Extract error lines
31
+ const errors = [];
32
+ ERROR_LINE_RE.lastIndex = 0;
33
+ while ((m = ERROR_LINE_RE.exec(raw)) !== null) {
34
+ const err = m[0].trim().slice(0, 200);
35
+ if (errors.length < 5 && !errors.includes(err)) {
36
+ errors.push(err);
37
+ }
38
+ }
39
+ return {
40
+ summary: raw.slice(0, 500),
41
+ files: [...files].slice(0, 15),
42
+ errors,
43
+ };
44
+ }
45
+ export function formatStructuredContext(parsed) {
46
+ const parts = [`Summary: ${parsed.summary}`];
47
+ if (parsed.files.length > 0) {
48
+ parts.push(`Files: ${parsed.files.join(', ')}`);
49
+ }
50
+ if (parsed.errors.length > 0) {
51
+ parts.push(`Errors: ${parsed.errors.join(' | ')}`);
52
+ }
53
+ return parts.join('\n');
54
+ }
@@ -45,6 +45,8 @@ export interface CodeAgentBackgroundOptions {
45
45
  maxTimeoutMinutes?: number;
46
46
  /** Skip sending notification on completion (parent handles it) */
47
47
  skipNotification?: boolean;
48
+ /** Per-project validation command overrides from config */
49
+ validationCommands?: Record<string, string>;
48
50
  /** Sandbox configuration — when enabled, run CLI inside container */
49
51
  sandboxConfig?: SandboxConfig;
50
52
  /** Paths to mount into the sandbox container */
@@ -1,5 +1,9 @@
1
1
  import type { BuildCodeAgentArgsInput, CodeAgentTask } from './types.js';
2
2
  import type { Config } from '../types.js';
3
+ /** Return supported coding CLIs currently available on PATH. */
4
+ export declare function getAvailableCodingCliTools(commandChecker?: (name: string) => boolean): Array<'codex' | 'claude' | 'kimi'>;
5
+ /** Return preflight error when no supported coding CLI is installed. */
6
+ export declare function getCodingCliPreflightError(commandChecker?: (name: string) => boolean): string | null;
3
7
  /**
4
8
  * Normalize legacy/default agent values to supported CLI agent IDs.
5
9
  * Accepts strict IDs and older alias-like values (e.g. "claude-think").
@@ -12,9 +12,35 @@ function resolveCliPath(name) {
12
12
  return name;
13
13
  }
14
14
  }
15
+ function isCommandAvailable(name) {
16
+ try {
17
+ execSync(`command -v ${name}`, { stdio: 'ignore' });
18
+ return true;
19
+ }
20
+ catch {
21
+ return false;
22
+ }
23
+ }
15
24
  const CLAUDE_CLI_PATH = resolveCliPath('claude');
16
25
  const CODEX_CLI_PATH = resolveCliPath('codex');
17
26
  const KIMI_CLI_PATH = resolveCliPath('kimi');
27
+ /** Return supported coding CLIs currently available on PATH. */
28
+ export function getAvailableCodingCliTools(commandChecker = isCommandAvailable) {
29
+ const available = [];
30
+ if (commandChecker('codex'))
31
+ available.push('codex');
32
+ if (commandChecker('claude') || commandChecker('claude-code'))
33
+ available.push('claude');
34
+ if (commandChecker('kimi'))
35
+ available.push('kimi');
36
+ return available;
37
+ }
38
+ /** Return preflight error when no supported coding CLI is installed. */
39
+ export function getCodingCliPreflightError(commandChecker = isCommandAvailable) {
40
+ if (getAvailableCodingCliTools(commandChecker).length > 0)
41
+ return null;
42
+ return 'Error: No supported coding CLI found on PATH. Install Codex CLI (`codex`), Claude Code CLI (`claude` or `claude-code`), or Kimi CLI (`kimi`).';
43
+ }
18
44
  /**
19
45
  * Normalize legacy/default agent values to supported CLI agent IDs.
20
46
  * Accepts strict IDs and older alias-like values (e.g. "claude-think").
@@ -201,11 +227,21 @@ export async function notifyCodeAgentResult(task, getChildTask) {
201
227
  // Team coordinator gets a structured notification
202
228
  if (task.agent === 'team-coordinator') {
203
229
  message = buildTeamNotification(task, getChildTask);
204
- await sendActiveChannelProactiveMessage(_codeAgentConfig, message).catch(() => { });
230
+ const sent = await sendActiveChannelProactiveMessage(_codeAgentConfig, message).catch((err) => {
231
+ console.error(`[code-agent] Failed to send team notification for ${task.id}:`, err);
232
+ return false;
233
+ });
234
+ if (!sent)
235
+ console.warn(`[code-agent] Team notification not delivered for ${task.id} (no active channel or target)`);
205
236
  return;
206
237
  }
207
238
  message = buildSoloNotification(task);
208
- await sendActiveChannelProactiveMessage(_codeAgentConfig, message).catch(() => { });
239
+ const sent = await sendActiveChannelProactiveMessage(_codeAgentConfig, message).catch((err) => {
240
+ console.error(`[code-agent] Failed to send notification for ${task.id}:`, err);
241
+ return false;
242
+ });
243
+ if (!sent)
244
+ console.warn(`[code-agent] Notification not delivered for ${task.id} (no active channel or target)`);
209
245
  }
210
246
  /** Check workdir against allowed paths. */
211
247
  export function resolveWorkdir(rawWorkdir, projects, skimpyclawRoot) {
@@ -0,0 +1,40 @@
1
+ export interface WorktreeInfo {
2
+ /** Absolute path to the worktree directory */
3
+ path: string;
4
+ /** Branch name created for this worktree */
5
+ branch: string;
6
+ }
7
+ /**
8
+ * Check if a directory is inside a git repo.
9
+ */
10
+ export declare function isGitRepo(workdir: string): boolean;
11
+ /**
12
+ * Get the git repo root directory.
13
+ */
14
+ export declare function getGitRoot(workdir: string): string;
15
+ /**
16
+ * Stage and commit all current changes so worktrees branch from a clean state.
17
+ * Returns the commit hash, or null if nothing to commit.
18
+ */
19
+ export declare function commitPendingChanges(workdir: string, message: string): string | null;
20
+ /**
21
+ * Create a git worktree for a child agent.
22
+ * Creates a new branch and worktree directory under .skimpyclaw-worktrees/.
23
+ */
24
+ export declare function createWorktree(workdir: string, childId: string): WorktreeInfo;
25
+ /**
26
+ * Merge a child's worktree branch back into the current branch.
27
+ * Returns { merged: true } on success, { merged: false, conflict: string } on conflict.
28
+ */
29
+ export declare function mergeWorktree(workdir: string, branch: string, childId: string): {
30
+ merged: boolean;
31
+ conflict?: string;
32
+ };
33
+ /**
34
+ * Remove a worktree and its branch.
35
+ */
36
+ export declare function removeWorktree(workdir: string, childId: string, branch: string): void;
37
+ /**
38
+ * Clean up all skimpyclaw worktrees in a repo (e.g. on crash recovery).
39
+ */
40
+ export declare function cleanupAllWorktrees(workdir: string): void;