tuna-agent 0.1.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.
- package/README.md +93 -0
- package/dist/__tests__/need-input-flow.test.d.ts +11 -0
- package/dist/__tests__/need-input-flow.test.js +646 -0
- package/dist/agents/claude-code-adapter.d.ts +13 -0
- package/dist/agents/claude-code-adapter.js +613 -0
- package/dist/agents/factory.d.ts +2 -0
- package/dist/agents/factory.js +12 -0
- package/dist/agents/openclaw-adapter.d.ts +18 -0
- package/dist/agents/openclaw-adapter.js +217 -0
- package/dist/agents/types.d.ts +31 -0
- package/dist/agents/types.js +1 -0
- package/dist/cli/commands/connect.d.ts +8 -0
- package/dist/cli/commands/connect.js +163 -0
- package/dist/cli/commands/start.d.ts +5 -0
- package/dist/cli/commands/start.js +54 -0
- package/dist/cli/commands/status.d.ts +1 -0
- package/dist/cli/commands/status.js +21 -0
- package/dist/cli/commands/stop.d.ts +1 -0
- package/dist/cli/commands/stop.js +23 -0
- package/dist/cli/index.d.ts +2 -0
- package/dist/cli/index.js +32 -0
- package/dist/config/store.d.ts +29 -0
- package/dist/config/store.js +94 -0
- package/dist/daemon/index.d.ts +6 -0
- package/dist/daemon/index.js +576 -0
- package/dist/daemon/pm-state.d.ts +16 -0
- package/dist/daemon/pm-state.js +37 -0
- package/dist/daemon/ws-client.d.ts +107 -0
- package/dist/daemon/ws-client.js +293 -0
- package/dist/executor/task-runner.d.ts +30 -0
- package/dist/executor/task-runner.js +638 -0
- package/dist/pm/planner.d.ts +20 -0
- package/dist/pm/planner.js +375 -0
- package/dist/system/info.d.ts +18 -0
- package/dist/system/info.js +169 -0
- package/dist/types/index.d.ts +123 -0
- package/dist/types/index.js +1 -0
- package/dist/utils/claude-cli.d.ts +35 -0
- package/dist/utils/claude-cli.js +271 -0
- package/dist/utils/execution-helpers.d.ts +32 -0
- package/dist/utils/execution-helpers.js +177 -0
- package/dist/utils/image-download.d.ts +9 -0
- package/dist/utils/image-download.js +60 -0
- package/dist/utils/message-schemas.d.ts +69 -0
- package/dist/utils/message-schemas.js +80 -0
- package/dist/utils/pm-helpers.d.ts +5 -0
- package/dist/utils/pm-helpers.js +31 -0
- package/dist/utils/skill-scanner.d.ts +13 -0
- package/dist/utils/skill-scanner.js +91 -0
- package/dist/utils/validate-path.d.ts +10 -0
- package/dist/utils/validate-path.js +18 -0
- package/package.json +43 -0
|
@@ -0,0 +1,638 @@
|
|
|
1
|
+
import path from 'path';
|
|
2
|
+
import os from 'os';
|
|
3
|
+
import { spawn } from 'child_process';
|
|
4
|
+
import { runClaude } from '../utils/claude-cli.js';
|
|
5
|
+
import { validatePath } from '../utils/validate-path.js';
|
|
6
|
+
const NEEDS_INPUT_MARKER = '"status":"NEEDS_INPUT"';
|
|
7
|
+
// ===== Single-session execution (original runTask for agent_team / direct mode) =====
|
|
8
|
+
/**
|
|
9
|
+
* Execute a task by spawning a single Claude CLI session.
|
|
10
|
+
* Used for agent_team mode or tasks that don't need planning.
|
|
11
|
+
*/
|
|
12
|
+
export async function runTask(task, onProgress, signal, confirmBeforeEdit) {
|
|
13
|
+
const isAgentTeam = task.mode === 'agent_team';
|
|
14
|
+
const allowedTools = ['Read', 'Edit', 'Write', 'Bash', 'Glob', 'Grep'];
|
|
15
|
+
const args = [
|
|
16
|
+
'-p', task.description,
|
|
17
|
+
'--output-format', 'stream-json',
|
|
18
|
+
'--verbose',
|
|
19
|
+
'--allowedTools', allowedTools.join(','),
|
|
20
|
+
'--max-turns', isAgentTeam ? '50' : '30',
|
|
21
|
+
];
|
|
22
|
+
if (confirmBeforeEdit) {
|
|
23
|
+
args.push('--permission-mode', 'default');
|
|
24
|
+
}
|
|
25
|
+
else {
|
|
26
|
+
args.push('--permission-mode', 'bypassPermissions');
|
|
27
|
+
}
|
|
28
|
+
const env = {
|
|
29
|
+
...process.env,
|
|
30
|
+
HOME: process.env.HOME || '',
|
|
31
|
+
PATH: process.env.PATH || '/usr/local/bin:/usr/bin:/bin',
|
|
32
|
+
};
|
|
33
|
+
if (isAgentTeam) {
|
|
34
|
+
env.CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS = '1';
|
|
35
|
+
env.CLAUDE_CODE_TEAMMATE_MODE = 'in-process';
|
|
36
|
+
}
|
|
37
|
+
onProgress('status_change', { status: 'executing' });
|
|
38
|
+
if (isAgentTeam) {
|
|
39
|
+
onProgress('plan_ready', {
|
|
40
|
+
summary: 'Agent Team mode — Claude will create and coordinate the team',
|
|
41
|
+
subtaskCount: 1,
|
|
42
|
+
});
|
|
43
|
+
}
|
|
44
|
+
const subtaskId = isAgentTeam ? 'agent-team-main' : 'main';
|
|
45
|
+
onProgress('subtask_start', { subtaskId });
|
|
46
|
+
const defaultWorkspace = path.join(os.homedir(), 'tuna-workspace');
|
|
47
|
+
const repoPath = task.repoPath || defaultWorkspace;
|
|
48
|
+
// Validate repoPath stays within home directory
|
|
49
|
+
validatePath(repoPath, os.homedir());
|
|
50
|
+
const TASK_TIMEOUT_MS = 30 * 60 * 1000; // 30 minutes
|
|
51
|
+
return new Promise((resolve, reject) => {
|
|
52
|
+
const proc = spawn('claude', args, {
|
|
53
|
+
cwd: repoPath,
|
|
54
|
+
stdio: [confirmBeforeEdit ? 'pipe' : 'ignore', 'pipe', 'pipe'],
|
|
55
|
+
env,
|
|
56
|
+
});
|
|
57
|
+
// Kill process if it exceeds timeout
|
|
58
|
+
const timeoutTimer = setTimeout(() => {
|
|
59
|
+
console.error(`[Executor] Task timed out after 30 minutes, killing process`);
|
|
60
|
+
proc.kill('SIGTERM');
|
|
61
|
+
setTimeout(() => {
|
|
62
|
+
if (!proc.killed)
|
|
63
|
+
proc.kill('SIGKILL');
|
|
64
|
+
}, 5000);
|
|
65
|
+
}, TASK_TIMEOUT_MS);
|
|
66
|
+
// Abort signal — kill process when task is cancelled
|
|
67
|
+
if (signal) {
|
|
68
|
+
const onAbort = () => {
|
|
69
|
+
console.log('[Executor] Task cancelled — killing process');
|
|
70
|
+
proc.kill('SIGTERM');
|
|
71
|
+
setTimeout(() => { if (!proc.killed)
|
|
72
|
+
proc.kill('SIGKILL'); }, 5000);
|
|
73
|
+
};
|
|
74
|
+
if (signal.aborted) {
|
|
75
|
+
onAbort();
|
|
76
|
+
}
|
|
77
|
+
else {
|
|
78
|
+
signal.addEventListener('abort', onAbort, { once: true });
|
|
79
|
+
proc.on('close', () => signal.removeEventListener('abort', onAbort));
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
let stdout = '';
|
|
83
|
+
let stderr = '';
|
|
84
|
+
let buffer = '';
|
|
85
|
+
proc.stdout.on('data', (chunk) => {
|
|
86
|
+
const text = chunk.toString();
|
|
87
|
+
stdout += text;
|
|
88
|
+
buffer += text;
|
|
89
|
+
const lines = buffer.split('\n');
|
|
90
|
+
buffer = lines.pop() ?? '';
|
|
91
|
+
for (const line of lines) {
|
|
92
|
+
if (!line.trim())
|
|
93
|
+
continue;
|
|
94
|
+
try {
|
|
95
|
+
const data = JSON.parse(line);
|
|
96
|
+
_handleStreamEvent(data, subtaskId, onProgress);
|
|
97
|
+
}
|
|
98
|
+
catch {
|
|
99
|
+
// skip non-JSON lines
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
});
|
|
103
|
+
proc.stderr.on('data', (chunk) => {
|
|
104
|
+
stderr += chunk.toString();
|
|
105
|
+
});
|
|
106
|
+
proc.on('close', (code) => {
|
|
107
|
+
clearTimeout(timeoutTimer);
|
|
108
|
+
if (buffer.trim()) {
|
|
109
|
+
try {
|
|
110
|
+
const data = JSON.parse(buffer);
|
|
111
|
+
_handleStreamEvent(data, subtaskId, onProgress);
|
|
112
|
+
}
|
|
113
|
+
catch { /* skip */ }
|
|
114
|
+
}
|
|
115
|
+
if (code !== 0 && !stdout.trim()) {
|
|
116
|
+
const isTimeout = code === null || code === 143; // SIGTERM
|
|
117
|
+
const errMsg = isTimeout
|
|
118
|
+
? 'Task timed out after 30 minutes'
|
|
119
|
+
: `claude exited with code ${code}: ${stderr}`;
|
|
120
|
+
reject(new Error(errMsg));
|
|
121
|
+
return;
|
|
122
|
+
}
|
|
123
|
+
const lines = stdout.trim().split('\n');
|
|
124
|
+
for (let i = lines.length - 1; i >= 0; i--) {
|
|
125
|
+
try {
|
|
126
|
+
const data = JSON.parse(lines[i]);
|
|
127
|
+
if (data.type === 'result') {
|
|
128
|
+
resolve({
|
|
129
|
+
result: data.result ?? '',
|
|
130
|
+
sessionId: data.session_id,
|
|
131
|
+
costUsd: data.cost_usd,
|
|
132
|
+
durationMs: data.duration_ms,
|
|
133
|
+
isError: data.is_error ?? data.subtype !== 'success',
|
|
134
|
+
});
|
|
135
|
+
return;
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
catch { /* skip */ }
|
|
139
|
+
}
|
|
140
|
+
resolve({
|
|
141
|
+
result: stdout.trim().slice(-500),
|
|
142
|
+
isError: code !== 0,
|
|
143
|
+
});
|
|
144
|
+
});
|
|
145
|
+
proc.on('error', (err) => {
|
|
146
|
+
clearTimeout(timeoutTimer);
|
|
147
|
+
reject(new Error(`Failed to spawn claude: ${err.message}`));
|
|
148
|
+
});
|
|
149
|
+
});
|
|
150
|
+
}
|
|
151
|
+
function _handleStreamEvent(data, subtaskId, onProgress) {
|
|
152
|
+
const type = data.type;
|
|
153
|
+
switch (type) {
|
|
154
|
+
case 'assistant':
|
|
155
|
+
if (data.message) {
|
|
156
|
+
const message = data.message;
|
|
157
|
+
const content = message.content;
|
|
158
|
+
if (Array.isArray(content)) {
|
|
159
|
+
for (const block of content) {
|
|
160
|
+
if (block.type === 'text' && block.text) {
|
|
161
|
+
onProgress('subtask_log', {
|
|
162
|
+
subtaskId,
|
|
163
|
+
log: { type: 'thinking', message: block.text.slice(0, 200) },
|
|
164
|
+
});
|
|
165
|
+
}
|
|
166
|
+
else if (block.type === 'tool_use') {
|
|
167
|
+
onProgress('subtask_log', {
|
|
168
|
+
subtaskId,
|
|
169
|
+
log: { type: 'tool_start', message: `Using tool: ${block.name}` },
|
|
170
|
+
});
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
break;
|
|
176
|
+
case 'tool': {
|
|
177
|
+
const content = data.content;
|
|
178
|
+
if (content?.[0]?.text) {
|
|
179
|
+
const text = content[0].text.slice(0, 200);
|
|
180
|
+
onProgress('subtask_log', {
|
|
181
|
+
subtaskId,
|
|
182
|
+
log: { type: 'tool_end', message: text },
|
|
183
|
+
});
|
|
184
|
+
}
|
|
185
|
+
break;
|
|
186
|
+
}
|
|
187
|
+
case 'result':
|
|
188
|
+
break;
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
// ===== Planner-based execution (subtasks, layers, parallel) =====
|
|
192
|
+
function log(info, type, message) {
|
|
193
|
+
const entry = { timestamp: new Date(), type, message };
|
|
194
|
+
info.logs.push(entry);
|
|
195
|
+
console.log(`[Executor:${info.subtaskId}] ${message}`);
|
|
196
|
+
}
|
|
197
|
+
function buildSessionPrompt(subtask, contracts) {
|
|
198
|
+
let prompt = `You are a ${subtask.role} developer working on this specific task. Focus only on your assigned work. Be concise and efficient.`;
|
|
199
|
+
if (contracts?.length) {
|
|
200
|
+
prompt += `\n\nAPI CONTRACTS (defined by PM — follow these exactly):\n`;
|
|
201
|
+
for (const c of contracts) {
|
|
202
|
+
prompt += `\n${c.method} ${c.path}`;
|
|
203
|
+
if (c.description)
|
|
204
|
+
prompt += ` — ${c.description}`;
|
|
205
|
+
if (c.headers)
|
|
206
|
+
prompt += `\n Headers: ${JSON.stringify(c.headers)}`;
|
|
207
|
+
if (c.body)
|
|
208
|
+
prompt += `\n Request body: ${JSON.stringify(c.body)}`;
|
|
209
|
+
if (c.response)
|
|
210
|
+
prompt += `\n Response: ${JSON.stringify(c.response)}`;
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
prompt += `\n\nCRITICAL: You are running in HEADLESS mode. There is NO interactive terminal. The AskUserQuestion tool does NOT exist here. NEVER use AskUserQuestion.
|
|
214
|
+
|
|
215
|
+
CRITICAL — If you need information from the user/PM to proceed:
|
|
216
|
+
You MUST follow this EXACT procedure — no exceptions:
|
|
217
|
+
Step 1: Use the Write tool to create file ".tuna/question.json" with content:
|
|
218
|
+
{"question": "your question here", "options": ["Option A", "Option B"], "context": "why you need this"}
|
|
219
|
+
Step 2: Your FINAL output must be EXACTLY this JSON and nothing else:
|
|
220
|
+
{"status":"NEEDS_INPUT"}
|
|
221
|
+
|
|
222
|
+
WARNING: If you just write questions as text output without following the above procedure, your questions will be IGNORED and nobody will ever see them. The ONLY way to ask the user is through the .tuna/question.json + NEEDS_INPUT procedure above.
|
|
223
|
+
|
|
224
|
+
When to use NEEDS_INPUT:
|
|
225
|
+
- Missing credentials, tokens, URLs that you cannot find in the codebase
|
|
226
|
+
- Business logic decisions the user must decide
|
|
227
|
+
- Ambiguous requirements where guessing could waste effort
|
|
228
|
+
|
|
229
|
+
When NOT to use NEEDS_INPUT (decide yourself):
|
|
230
|
+
- Technical decisions (library choice, patterns, file structure)
|
|
231
|
+
- Anything with a clear best-practice answer
|
|
232
|
+
- Status updates or acknowledgements`;
|
|
233
|
+
return prompt;
|
|
234
|
+
}
|
|
235
|
+
/**
|
|
236
|
+
* Format tool usage into user-friendly curated message.
|
|
237
|
+
*/
|
|
238
|
+
function formatCuratedMessage(toolName, toolInput) {
|
|
239
|
+
const filePath = toolInput?.file_path;
|
|
240
|
+
const fileName = filePath ? path.basename(filePath) : '';
|
|
241
|
+
switch (toolName) {
|
|
242
|
+
case 'Read':
|
|
243
|
+
return fileName ? `📖 Reading ${fileName}` : '📖 Reading file';
|
|
244
|
+
case 'Write':
|
|
245
|
+
return fileName ? `✍️ Created ${fileName}` : '✍️ Created new file';
|
|
246
|
+
case 'Edit':
|
|
247
|
+
return fileName ? `✏️ Modified ${fileName}` : '✏️ Modified file';
|
|
248
|
+
case 'Bash': {
|
|
249
|
+
const cmd = toolInput?.command;
|
|
250
|
+
if (!cmd)
|
|
251
|
+
return '⚙️ Running command';
|
|
252
|
+
// Extract meaningful command info
|
|
253
|
+
if (cmd.includes('npm test') || cmd.includes('jest') || cmd.includes('pytest')) {
|
|
254
|
+
return '🧪 Running tests';
|
|
255
|
+
}
|
|
256
|
+
if (cmd.includes('npm install') || cmd.includes('yarn add') || cmd.includes('pip install')) {
|
|
257
|
+
return '📦 Installing dependencies';
|
|
258
|
+
}
|
|
259
|
+
if (cmd.includes('npm run build') || cmd.includes('yarn build')) {
|
|
260
|
+
return '🔨 Building project';
|
|
261
|
+
}
|
|
262
|
+
if (cmd.includes('git commit')) {
|
|
263
|
+
return '💾 Committing changes';
|
|
264
|
+
}
|
|
265
|
+
if (cmd.includes('git push')) {
|
|
266
|
+
return '🚀 Pushing to remote';
|
|
267
|
+
}
|
|
268
|
+
// Generic command
|
|
269
|
+
return `⚙️ Running: ${cmd.split(' ')[0]}`;
|
|
270
|
+
}
|
|
271
|
+
case 'Glob':
|
|
272
|
+
return '🔍 Searching files';
|
|
273
|
+
case 'Grep':
|
|
274
|
+
return '🔍 Searching code';
|
|
275
|
+
default:
|
|
276
|
+
return null;
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
function createStreamHandler(info, subtask, callbacks) {
|
|
280
|
+
return (data) => {
|
|
281
|
+
if (data.type === 'assistant' && data.message) {
|
|
282
|
+
const msg = data.message;
|
|
283
|
+
const content = msg.content;
|
|
284
|
+
if (content) {
|
|
285
|
+
for (const block of content) {
|
|
286
|
+
if (block.type === 'tool_use') {
|
|
287
|
+
const toolName = block.name;
|
|
288
|
+
const toolInput = block.input;
|
|
289
|
+
// Format curated message for user
|
|
290
|
+
const curatedMsg = formatCuratedMessage(toolName, toolInput || {});
|
|
291
|
+
if (curatedMsg) {
|
|
292
|
+
callbacks?.onSubtaskLog?.(subtask.id, { type: 'action', message: curatedMsg });
|
|
293
|
+
}
|
|
294
|
+
// Still log verbose version to console for debugging
|
|
295
|
+
const detail = toolInput?.file_path || toolInput?.command || toolInput?.pattern || '';
|
|
296
|
+
console.log(` [tool] ${toolName}: ${String(detail).substring(0, 80)}`);
|
|
297
|
+
}
|
|
298
|
+
// Skip verbose thinking blocks - not useful for end users
|
|
299
|
+
}
|
|
300
|
+
}
|
|
301
|
+
}
|
|
302
|
+
if (data.type === 'system' && data.subtype === 'init') {
|
|
303
|
+
info.sessionId = data.session_id;
|
|
304
|
+
log(info, 'thinking', `Session ID: ${info.sessionId}`);
|
|
305
|
+
}
|
|
306
|
+
};
|
|
307
|
+
}
|
|
308
|
+
/**
|
|
309
|
+
* Execute a single subtask by spawning a Claude CLI session.
|
|
310
|
+
*/
|
|
311
|
+
export async function executeSubtask(subtask, repoPath, contracts, callbacks, signal, confirmBeforeEdit) {
|
|
312
|
+
const info = {
|
|
313
|
+
subtaskId: subtask.id,
|
|
314
|
+
status: 'running',
|
|
315
|
+
logs: [],
|
|
316
|
+
};
|
|
317
|
+
// Validate subtask cwd stays within the repo path
|
|
318
|
+
const cwd = validatePath(subtask.cwd, repoPath);
|
|
319
|
+
log(info, 'thinking', `Starting ${subtask.role} session (cwd: ${cwd})`);
|
|
320
|
+
await callbacks?.onSubtaskStart?.(subtask.id);
|
|
321
|
+
// Send curated status message
|
|
322
|
+
callbacks?.onSubtaskLog?.(subtask.id, {
|
|
323
|
+
type: 'info',
|
|
324
|
+
message: `🚀 Started: ${subtask.description.substring(0, 80)}${subtask.description.length > 80 ? '...' : ''}`,
|
|
325
|
+
});
|
|
326
|
+
try {
|
|
327
|
+
const result = await runClaude({
|
|
328
|
+
prompt: subtask.description,
|
|
329
|
+
cwd,
|
|
330
|
+
allowedTools: ['Read', 'Edit', 'Write', 'Bash', 'Glob', 'Grep'],
|
|
331
|
+
disallowedTools: ['AskUserQuestion'],
|
|
332
|
+
systemPrompt: buildSessionPrompt(subtask, contracts),
|
|
333
|
+
outputFormat: 'stream-json',
|
|
334
|
+
onStreamLine: createStreamHandler(info, subtask, callbacks),
|
|
335
|
+
signal,
|
|
336
|
+
...(confirmBeforeEdit ? {
|
|
337
|
+
permissionMode: 'default',
|
|
338
|
+
onPermissionRequest: callbacks?.onPermissionRequest
|
|
339
|
+
? (tool, detail) => callbacks.onPermissionRequest(subtask.id, tool, detail)
|
|
340
|
+
: undefined,
|
|
341
|
+
} : {}),
|
|
342
|
+
});
|
|
343
|
+
info.costUsd = result.costUsd;
|
|
344
|
+
info.durationMs = result.durationMs;
|
|
345
|
+
info.sessionId = result.sessionId ?? info.sessionId;
|
|
346
|
+
if (result.result.includes(NEEDS_INPUT_MARKER)) {
|
|
347
|
+
info.status = 'waiting_input';
|
|
348
|
+
let question = await readQuestionFile(cwd, subtask.id);
|
|
349
|
+
// Fallback: If no question.json, try to parse question from output
|
|
350
|
+
if (!question) {
|
|
351
|
+
question = parseQuestionFromOutput(result.result, subtask.id);
|
|
352
|
+
}
|
|
353
|
+
if (question) {
|
|
354
|
+
info.pendingQuestion = question;
|
|
355
|
+
log(info, 'thinking', `Needs input: ${question.question}`);
|
|
356
|
+
if (callbacks?.onSubtaskNeedsInput) {
|
|
357
|
+
const answer = await callbacks.onSubtaskNeedsInput(subtask.id, question);
|
|
358
|
+
if (answer) {
|
|
359
|
+
log(info, 'thinking', `Resuming with answer: ${answer.substring(0, 80)}`);
|
|
360
|
+
callbacks?.onSubtaskLog?.(subtask.id, {
|
|
361
|
+
type: 'info',
|
|
362
|
+
message: `💬 Received answer, resuming work...`,
|
|
363
|
+
});
|
|
364
|
+
const resumeResult = await runClaude({
|
|
365
|
+
prompt: `The answer to your question "${question.question}" is: ${answer}\n\nContinue your work with this information.`,
|
|
366
|
+
cwd,
|
|
367
|
+
allowedTools: ['Read', 'Edit', 'Write', 'Bash', 'Glob', 'Grep'],
|
|
368
|
+
disallowedTools: ['AskUserQuestion'],
|
|
369
|
+
resumeSessionId: info.sessionId,
|
|
370
|
+
outputFormat: 'stream-json',
|
|
371
|
+
onStreamLine: createStreamHandler(info, subtask, callbacks),
|
|
372
|
+
signal,
|
|
373
|
+
...(confirmBeforeEdit ? {
|
|
374
|
+
permissionMode: 'default',
|
|
375
|
+
onPermissionRequest: callbacks?.onPermissionRequest
|
|
376
|
+
? (tool, detail) => callbacks.onPermissionRequest(subtask.id, tool, detail)
|
|
377
|
+
: undefined,
|
|
378
|
+
} : {}),
|
|
379
|
+
});
|
|
380
|
+
info.costUsd = (info.costUsd ?? 0) + (resumeResult.costUsd ?? 0);
|
|
381
|
+
info.durationMs = (info.durationMs ?? 0) + (resumeResult.durationMs ?? 0);
|
|
382
|
+
info.pendingQuestion = undefined;
|
|
383
|
+
if (resumeResult.isError) {
|
|
384
|
+
info.status = 'failed';
|
|
385
|
+
info.result = resumeResult.result;
|
|
386
|
+
log(info, 'error', `Failed after resume: ${resumeResult.result.substring(0, 200)}`);
|
|
387
|
+
callbacks?.onSubtaskLog?.(subtask.id, {
|
|
388
|
+
type: 'error',
|
|
389
|
+
message: `❌ Failed after resume: ${resumeResult.result.substring(0, 100)}`,
|
|
390
|
+
});
|
|
391
|
+
}
|
|
392
|
+
else {
|
|
393
|
+
info.status = 'done';
|
|
394
|
+
info.result = resumeResult.result;
|
|
395
|
+
const costStr = info.costUsd != null ? `$${info.costUsd.toFixed(4)}` : 'N/A';
|
|
396
|
+
const durationStr = info.durationMs != null ? `${(info.durationMs / 1000).toFixed(1)}s` : 'N/A';
|
|
397
|
+
log(info, 'result', `Done in ${durationStr}, cost: ${costStr} (resumed)`);
|
|
398
|
+
callbacks?.onSubtaskLog?.(subtask.id, {
|
|
399
|
+
type: 'success',
|
|
400
|
+
message: `✅ Completed successfully in ${durationStr}`,
|
|
401
|
+
});
|
|
402
|
+
}
|
|
403
|
+
}
|
|
404
|
+
else {
|
|
405
|
+
log(info, 'thinking', 'No answer received — task paused');
|
|
406
|
+
}
|
|
407
|
+
}
|
|
408
|
+
}
|
|
409
|
+
else {
|
|
410
|
+
info.status = 'failed';
|
|
411
|
+
info.result = 'Session requested input but no question file found';
|
|
412
|
+
log(info, 'error', 'NEEDS_INPUT but no .tuna/question.json found');
|
|
413
|
+
callbacks?.onSubtaskLog?.(subtask.id, {
|
|
414
|
+
type: 'error',
|
|
415
|
+
message: '❌ Failed: AI requested input but question format was invalid',
|
|
416
|
+
});
|
|
417
|
+
}
|
|
418
|
+
}
|
|
419
|
+
else if (result.isError) {
|
|
420
|
+
info.status = 'failed';
|
|
421
|
+
info.result = result.result;
|
|
422
|
+
log(info, 'error', `Failed: ${result.result.substring(0, 200)}`);
|
|
423
|
+
// Send curated error message
|
|
424
|
+
callbacks?.onSubtaskLog?.(subtask.id, {
|
|
425
|
+
type: 'error',
|
|
426
|
+
message: `❌ Failed: ${result.result.substring(0, 100)}${result.result.length > 100 ? '...' : ''}`,
|
|
427
|
+
});
|
|
428
|
+
}
|
|
429
|
+
else {
|
|
430
|
+
// Fallback: detect if AI just wrote questions as text without using NEEDS_INPUT
|
|
431
|
+
// Only trigger for very short outputs (< 500 chars) that completed quickly (< 15s)
|
|
432
|
+
// This avoids false positives when a completed task output contains sentences with "?"
|
|
433
|
+
const isShortOutput = result.result.length < 500;
|
|
434
|
+
const isQuickRun = result.durationMs != null && result.durationMs < 15000;
|
|
435
|
+
const fallbackQuestion = (isShortOutput && isQuickRun)
|
|
436
|
+
? parseQuestionFromOutput(result.result, subtask.id)
|
|
437
|
+
: null;
|
|
438
|
+
const seemsLikeQuestion = !!fallbackQuestion;
|
|
439
|
+
if (seemsLikeQuestion && fallbackQuestion && callbacks?.onSubtaskNeedsInput) {
|
|
440
|
+
info.status = 'waiting_input';
|
|
441
|
+
info.pendingQuestion = fallbackQuestion;
|
|
442
|
+
log(info, 'thinking', `Fallback: detected question in output (no NEEDS_INPUT marker): ${fallbackQuestion.question}`);
|
|
443
|
+
const answer = await callbacks.onSubtaskNeedsInput(subtask.id, fallbackQuestion);
|
|
444
|
+
if (answer) {
|
|
445
|
+
log(info, 'thinking', `Resuming with answer: ${answer.substring(0, 80)}`);
|
|
446
|
+
callbacks?.onSubtaskLog?.(subtask.id, {
|
|
447
|
+
type: 'info',
|
|
448
|
+
message: `💬 Received answer, resuming work...`,
|
|
449
|
+
});
|
|
450
|
+
const resumeResult = await runClaude({
|
|
451
|
+
prompt: `The answer to your question "${fallbackQuestion.question}" is: ${answer}\n\nContinue your work with this information.`,
|
|
452
|
+
cwd,
|
|
453
|
+
allowedTools: ['Read', 'Edit', 'Write', 'Bash', 'Glob', 'Grep'],
|
|
454
|
+
disallowedTools: ['AskUserQuestion'],
|
|
455
|
+
resumeSessionId: info.sessionId,
|
|
456
|
+
outputFormat: 'stream-json',
|
|
457
|
+
onStreamLine: createStreamHandler(info, subtask, callbacks),
|
|
458
|
+
signal,
|
|
459
|
+
...(confirmBeforeEdit ? {
|
|
460
|
+
permissionMode: 'default',
|
|
461
|
+
onPermissionRequest: callbacks?.onPermissionRequest
|
|
462
|
+
? (tool, detail) => callbacks.onPermissionRequest(subtask.id, tool, detail)
|
|
463
|
+
: undefined,
|
|
464
|
+
} : {}),
|
|
465
|
+
});
|
|
466
|
+
info.costUsd = (info.costUsd ?? 0) + (resumeResult.costUsd ?? 0);
|
|
467
|
+
info.durationMs = (info.durationMs ?? 0) + (resumeResult.durationMs ?? 0);
|
|
468
|
+
info.pendingQuestion = undefined;
|
|
469
|
+
if (resumeResult.isError) {
|
|
470
|
+
info.status = 'failed';
|
|
471
|
+
info.result = resumeResult.result;
|
|
472
|
+
log(info, 'error', `Failed after resume: ${resumeResult.result.substring(0, 200)}`);
|
|
473
|
+
}
|
|
474
|
+
else {
|
|
475
|
+
info.status = 'done';
|
|
476
|
+
info.result = resumeResult.result;
|
|
477
|
+
const costStr = info.costUsd != null ? `$${info.costUsd.toFixed(4)}` : 'N/A';
|
|
478
|
+
const durationStr = info.durationMs != null ? `${(info.durationMs / 1000).toFixed(1)}s` : 'N/A';
|
|
479
|
+
log(info, 'result', `Done in ${durationStr}, cost: ${costStr} (resumed from fallback)`);
|
|
480
|
+
callbacks?.onSubtaskLog?.(subtask.id, {
|
|
481
|
+
type: 'success',
|
|
482
|
+
message: `✅ Completed successfully in ${durationStr}`,
|
|
483
|
+
});
|
|
484
|
+
}
|
|
485
|
+
}
|
|
486
|
+
else {
|
|
487
|
+
log(info, 'thinking', 'No answer received — task paused');
|
|
488
|
+
}
|
|
489
|
+
}
|
|
490
|
+
else {
|
|
491
|
+
info.status = 'done';
|
|
492
|
+
info.result = result.result;
|
|
493
|
+
const costStr = result.costUsd != null ? `$${result.costUsd.toFixed(4)}` : 'N/A';
|
|
494
|
+
const durationStr = result.durationMs != null ? `${(result.durationMs / 1000).toFixed(1)}s` : 'N/A';
|
|
495
|
+
log(info, 'result', `Done in ${durationStr}, cost: ${costStr}`);
|
|
496
|
+
// Send curated success message
|
|
497
|
+
callbacks?.onSubtaskLog?.(subtask.id, {
|
|
498
|
+
type: 'success',
|
|
499
|
+
message: `✅ Completed successfully in ${durationStr}`,
|
|
500
|
+
});
|
|
501
|
+
}
|
|
502
|
+
}
|
|
503
|
+
}
|
|
504
|
+
catch (err) {
|
|
505
|
+
info.status = 'failed';
|
|
506
|
+
const errMsg = err instanceof Error ? err.message : String(err);
|
|
507
|
+
log(info, 'error', `Exception: ${errMsg}`);
|
|
508
|
+
// Send curated error message
|
|
509
|
+
callbacks?.onSubtaskLog?.(subtask.id, {
|
|
510
|
+
type: 'error',
|
|
511
|
+
message: `❌ Error: ${errMsg}`,
|
|
512
|
+
});
|
|
513
|
+
}
|
|
514
|
+
await callbacks?.onSubtaskDone?.(subtask.id, {
|
|
515
|
+
status: info.status,
|
|
516
|
+
result: info.result,
|
|
517
|
+
costUsd: info.costUsd,
|
|
518
|
+
durationMs: info.durationMs,
|
|
519
|
+
sessionId: info.sessionId,
|
|
520
|
+
});
|
|
521
|
+
return info;
|
|
522
|
+
}
|
|
523
|
+
/**
|
|
524
|
+
* Build execution layers from subtask dependencies.
|
|
525
|
+
* Subtasks with all dependencies met run in the same layer (parallel).
|
|
526
|
+
*/
|
|
527
|
+
export function buildExecutionLayers(subtasks) {
|
|
528
|
+
const layers = [];
|
|
529
|
+
const completed = new Set();
|
|
530
|
+
let remaining = [...subtasks];
|
|
531
|
+
while (remaining.length > 0) {
|
|
532
|
+
const layer = remaining.filter((s) => s.dependencies.every((dep) => completed.has(dep)));
|
|
533
|
+
if (layer.length === 0) {
|
|
534
|
+
console.warn('[Executor] Warning: unresolvable dependencies, forcing execution');
|
|
535
|
+
layers.push(remaining);
|
|
536
|
+
break;
|
|
537
|
+
}
|
|
538
|
+
layers.push(layer);
|
|
539
|
+
for (const s of layer)
|
|
540
|
+
completed.add(s.id);
|
|
541
|
+
remaining = remaining.filter((s) => !completed.has(s.id));
|
|
542
|
+
}
|
|
543
|
+
return layers;
|
|
544
|
+
}
|
|
545
|
+
/**
|
|
546
|
+
* Execute a full task plan: run subtasks layer by layer, parallel within each layer.
|
|
547
|
+
* Returns all session results.
|
|
548
|
+
*/
|
|
549
|
+
export async function executeTaskWithPlan(task, plan, onProgress, callbacks, signal, confirmBeforeEdit) {
|
|
550
|
+
const allSessions = [];
|
|
551
|
+
console.log(`\n[Executor] Starting task: ${task.description}`);
|
|
552
|
+
console.log(`[Executor] Subtasks: ${plan.subtasks.length}`);
|
|
553
|
+
if (plan.contracts?.length) {
|
|
554
|
+
console.log(`[Executor] Contracts: ${plan.contracts.length} endpoint(s)`);
|
|
555
|
+
}
|
|
556
|
+
const layers = buildExecutionLayers(plan.subtasks);
|
|
557
|
+
for (let i = 0; i < layers.length; i++) {
|
|
558
|
+
const layer = layers[i];
|
|
559
|
+
console.log(`\n[Executor] === Layer ${i + 1}/${layers.length}: ${layer.length} subtask(s) in parallel ===`);
|
|
560
|
+
await callbacks?.onLayerStart?.(i, layers.length, layer.map((s) => s.id));
|
|
561
|
+
const results = await Promise.all(layer.map((subtask) => executeSubtask(subtask, task.repoPath, plan.contracts, callbacks, signal, confirmBeforeEdit)));
|
|
562
|
+
allSessions.push(...results);
|
|
563
|
+
const failed = results.filter((r) => r.status === 'failed');
|
|
564
|
+
if (failed.length > 0) {
|
|
565
|
+
console.log(`\n[Executor] ${failed.length} subtask(s) failed in layer ${i + 1}`);
|
|
566
|
+
return { sessions: allSessions, status: 'failed' };
|
|
567
|
+
}
|
|
568
|
+
const waiting = results.filter((r) => r.status === 'waiting_input');
|
|
569
|
+
if (waiting.length > 0) {
|
|
570
|
+
console.log(`\n[Executor] ${waiting.length} subtask(s) waiting for input in layer ${i + 1}`);
|
|
571
|
+
return { sessions: allSessions, status: 'waiting_input' };
|
|
572
|
+
}
|
|
573
|
+
for (const result of results) {
|
|
574
|
+
const subtask = plan.subtasks.find((s) => s.id === result.subtaskId);
|
|
575
|
+
if (subtask)
|
|
576
|
+
subtask.status = 'done';
|
|
577
|
+
}
|
|
578
|
+
}
|
|
579
|
+
const totalCost = allSessions.reduce((sum, s) => sum + (s.costUsd ?? 0), 0);
|
|
580
|
+
const totalTime = allSessions.reduce((sum, s) => sum + (s.durationMs ?? 0), 0);
|
|
581
|
+
console.log(`\n[Executor] Task complete!`);
|
|
582
|
+
console.log(`[Executor] Total cost: $${totalCost.toFixed(4)}`);
|
|
583
|
+
console.log(`[Executor] Total time: ${(totalTime / 1000).toFixed(1)}s`);
|
|
584
|
+
return { sessions: allSessions, status: 'done' };
|
|
585
|
+
}
|
|
586
|
+
/**
|
|
587
|
+
* Parse question from Claude output when question.json is missing.
|
|
588
|
+
* Fallback for when Claude outputs NEEDS_INPUT but didn't create the file.
|
|
589
|
+
*/
|
|
590
|
+
function parseQuestionFromOutput(output, subtaskId) {
|
|
591
|
+
try {
|
|
592
|
+
// Strip code blocks to avoid matching `?` inside code/comments
|
|
593
|
+
const withoutCode = output
|
|
594
|
+
.replace(/```[\s\S]*?```/g, '')
|
|
595
|
+
.replace(/`[^`]+`/g, '');
|
|
596
|
+
const cleanLines = withoutCode.split('\n');
|
|
597
|
+
// Look for standalone sentences ending with `?` (not inside code)
|
|
598
|
+
for (let i = cleanLines.length - 1; i >= Math.max(0, cleanLines.length - 20); i--) {
|
|
599
|
+
const line = cleanLines[i].trim();
|
|
600
|
+
// Must be a real sentence ending with `?`, at least 10 chars to avoid false positives
|
|
601
|
+
if (line.endsWith('?') && line.length >= 10) {
|
|
602
|
+
return {
|
|
603
|
+
subtaskId,
|
|
604
|
+
question: line,
|
|
605
|
+
options: undefined,
|
|
606
|
+
context: 'AI requested input during execution',
|
|
607
|
+
};
|
|
608
|
+
}
|
|
609
|
+
}
|
|
610
|
+
// No question found — return null instead of a generic fallback
|
|
611
|
+
// This will cause the caller to treat it as an error (NEEDS_INPUT without valid question)
|
|
612
|
+
return null;
|
|
613
|
+
}
|
|
614
|
+
catch {
|
|
615
|
+
return null;
|
|
616
|
+
}
|
|
617
|
+
}
|
|
618
|
+
/**
|
|
619
|
+
* Read .tuna/question.json from workspace directory.
|
|
620
|
+
*/
|
|
621
|
+
async function readQuestionFile(cwd, subtaskId) {
|
|
622
|
+
try {
|
|
623
|
+
const fs = await import('fs/promises');
|
|
624
|
+
const filePath = path.join(cwd, '.tuna', 'question.json');
|
|
625
|
+
const raw = await fs.readFile(filePath, 'utf-8');
|
|
626
|
+
const data = JSON.parse(raw);
|
|
627
|
+
await fs.unlink(filePath).catch(() => { });
|
|
628
|
+
return {
|
|
629
|
+
subtaskId,
|
|
630
|
+
question: data.question,
|
|
631
|
+
options: data.options,
|
|
632
|
+
context: data.context,
|
|
633
|
+
};
|
|
634
|
+
}
|
|
635
|
+
catch {
|
|
636
|
+
return null;
|
|
637
|
+
}
|
|
638
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import type { TaskAssignment, TaskPlan, PlanResult, ProgressCallback } from '../types/index.js';
|
|
2
|
+
/**
|
|
3
|
+
* Plan a task by spawning a PM Claude session.
|
|
4
|
+
* PM reads the project, analyzes the task, and creates a structured plan.
|
|
5
|
+
*/
|
|
6
|
+
export declare function planTask(task: TaskAssignment, onProgress: ProgressCallback, signal?: AbortSignal, onTextChunk?: (text: string) => void, inputFiles?: string[]): Promise<PlanResult>;
|
|
7
|
+
/**
|
|
8
|
+
* Chat with PM directly (resume session). PM answers conversationally
|
|
9
|
+
* OR switches to planning mode if user gives a code task.
|
|
10
|
+
* Returns { response, plan? } — plan is set only if PM outputs JSON plan.
|
|
11
|
+
*/
|
|
12
|
+
export declare function chatWithPM(pmSessionId: string, repoPath: string, userMessage: string, signal?: AbortSignal, onTextChunk?: (text: string) => void, inputFiles?: string[]): Promise<{
|
|
13
|
+
response: string;
|
|
14
|
+
plan?: TaskPlan;
|
|
15
|
+
sessionId?: string;
|
|
16
|
+
}>;
|
|
17
|
+
/**
|
|
18
|
+
* Ask PM to answer a developer question by resuming PM session.
|
|
19
|
+
*/
|
|
20
|
+
export declare function askPM(pmSessionId: string, repoPath: string, question: string, context: string): Promise<string | null>;
|