tuna-agent 0.1.80 → 0.1.82

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.
@@ -185,6 +185,7 @@ export class ClaudeCodeAdapter {
185
185
  let firstChunkIso = '';
186
186
  let turnAccumulatedText = '';
187
187
  let messageCount = 0; // Track message_start events to detect new turns
188
+ let lastSentContent = ''; // Dedup: skip sending same message twice
188
189
  console.log(`[ClaudeCode] Agent Team round ${round + 1}: ${userMessage.substring(0, 80)}${currentInputFiles?.length ? ` (+${currentInputFiles.length} images)` : ''}`);
189
190
  const defaultWorkspace = path.join(os.homedir(), 'tuna-workspace');
190
191
  const cwd = task.repoPath || defaultWorkspace;
@@ -231,11 +232,18 @@ export class ClaudeCodeAdapter {
231
232
  if (messageCount > 1 && turnAccumulatedText.trim()) {
232
233
  console.log(`[ClaudeCode] ✂️ Splitting bubble — finalizing ${turnAccumulatedText.length} chars from previous turn`);
233
234
  ws.sendPMStreamEnd(task.id, streamMsgId);
234
- ws.sendPMMessage(task.id, {
235
- sender: 'pm',
236
- content: simplifyMarkdown(turnAccumulatedText),
237
- startedAt: firstChunkIso || undefined,
238
- });
235
+ const simplified = simplifyMarkdown(turnAccumulatedText);
236
+ if (simplified !== lastSentContent) {
237
+ ws.sendPMMessage(task.id, {
238
+ sender: 'pm',
239
+ content: simplified,
240
+ startedAt: firstChunkIso || undefined,
241
+ });
242
+ lastSentContent = simplified;
243
+ }
244
+ else {
245
+ console.log(`[ClaudeCode] ⏭️ Skipping duplicate message (${simplified.length} chars)`);
246
+ }
239
247
  turnAccumulatedText = '';
240
248
  firstChunkIso = '';
241
249
  streamMsgId = `team-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
@@ -327,13 +335,20 @@ export class ClaudeCodeAdapter {
327
335
  if (lastTaskOutput) {
328
336
  console.log(`[Reflection] Captured ${lastTaskOutput.length} chars of task output for reflection`);
329
337
  }
330
- // Send finalized message for the last turn's remaining text
338
+ // Send finalized message for the last turn's remaining text (skip if duplicate)
331
339
  if (turnAccumulatedText.trim()) {
332
- ws.sendPMMessage(task.id, {
333
- sender: 'pm',
334
- content: simplifyMarkdown(turnAccumulatedText),
335
- startedAt: firstChunkIso || undefined,
336
- });
340
+ const simplified = simplifyMarkdown(turnAccumulatedText);
341
+ if (simplified !== lastSentContent) {
342
+ ws.sendPMMessage(task.id, {
343
+ sender: 'pm',
344
+ content: simplified,
345
+ startedAt: firstChunkIso || undefined,
346
+ });
347
+ lastSentContent = simplified;
348
+ }
349
+ else {
350
+ console.log(`[ClaudeCode] ⏭️ Skipping duplicate final message (${simplified.length} chars)`);
351
+ }
337
352
  }
338
353
  // Last round → close
339
354
  if (round === MAX_ROUNDS - 1)
@@ -1,7 +1,6 @@
1
1
  import path from 'path';
2
2
  import os from 'os';
3
3
  import { spawn } from 'child_process';
4
- import { StringDecoder } from 'string_decoder';
5
4
  import { runClaude } from '../utils/claude-cli.js';
6
5
  import { validatePath } from '../utils/validate-path.js';
7
6
  const NEEDS_INPUT_MARKER = '"status":"NEEDS_INPUT"';
@@ -23,9 +22,6 @@ export async function runTask(task, onProgress, signal, confirmBeforeEdit) {
23
22
  if (confirmBeforeEdit) {
24
23
  args.push('--permission-mode', 'default');
25
24
  }
26
- else {
27
- args.push('--permission-mode', 'bypassPermissions');
28
- }
29
25
  const env = {
30
26
  ...process.env,
31
27
  HOME: process.env.HOME || '',
@@ -83,9 +79,8 @@ export async function runTask(task, onProgress, signal, confirmBeforeEdit) {
83
79
  let stdout = '';
84
80
  let stderr = '';
85
81
  let buffer = '';
86
- const stdoutDecoder = new StringDecoder('utf8');
87
82
  proc.stdout.on('data', (chunk) => {
88
- const text = stdoutDecoder.write(chunk);
83
+ const text = chunk.toString();
89
84
  stdout += text;
90
85
  buffer += text;
91
86
  const lines = buffer.split('\n');
@@ -102,9 +97,8 @@ export async function runTask(task, onProgress, signal, confirmBeforeEdit) {
102
97
  }
103
98
  }
104
99
  });
105
- const stderrDecoder = new StringDecoder('utf8');
106
100
  proc.stderr.on('data', (chunk) => {
107
- stderr += stderrDecoder.write(chunk);
101
+ stderr += chunk.toString();
108
102
  });
109
103
  proc.on('close', (code) => {
110
104
  clearTimeout(timeoutTimer);
@@ -431,15 +425,8 @@ export async function executeSubtask(subtask, repoPath, contracts, callbacks, si
431
425
  }
432
426
  else {
433
427
  // Fallback: detect if AI just wrote questions as text without using NEEDS_INPUT
434
- // Only trigger for very short outputs (< 500 chars) that completed quickly (< 15s)
435
- // This avoids false positives when a completed task output contains sentences with "?"
436
- const isShortOutput = result.result.length < 500;
437
- const isQuickRun = result.durationMs != null && result.durationMs < 15000;
438
- const fallbackQuestion = (isShortOutput && isQuickRun)
439
- ? parseQuestionFromOutput(result.result, subtask.id)
440
- : null;
441
- const seemsLikeQuestion = !!fallbackQuestion;
442
- if (seemsLikeQuestion && fallbackQuestion && callbacks?.onSubtaskNeedsInput) {
428
+ const fallbackQuestion = parseQuestionFromOutput(result.result, subtask.id);
429
+ if (fallbackQuestion && callbacks?.onSubtaskNeedsInput) {
443
430
  info.status = 'waiting_input';
444
431
  info.pendingQuestion = fallbackQuestion;
445
432
  log(info, 'thinking', `Fallback: detected question in output (no NEEDS_INPUT marker): ${fallbackQuestion.question}`);
@@ -490,6 +477,15 @@ export async function executeSubtask(subtask, repoPath, contracts, callbacks, si
490
477
  log(info, 'thinking', 'No answer received — task paused');
491
478
  }
492
479
  }
480
+ else if (looksIncomplete(result.result)) {
481
+ info.status = 'failed';
482
+ info.result = result.result;
483
+ log(info, 'error', `Detected incomplete output — marking as failed`);
484
+ callbacks?.onSubtaskLog?.(subtask.id, {
485
+ type: 'error',
486
+ message: `❌ Task did not complete: AI could not fulfill the request`,
487
+ });
488
+ }
493
489
  else {
494
490
  info.status = 'done';
495
491
  info.result = result.result;
@@ -586,6 +582,26 @@ export async function executeTaskWithPlan(task, plan, onProgress, callbacks, sig
586
582
  console.log(`[Executor] Total time: ${(totalTime / 1000).toFixed(1)}s`);
587
583
  return { sessions: allSessions, status: 'done' };
588
584
  }
585
+ /**
586
+ * Detect if Claude's output indicates it could NOT complete the task.
587
+ * Checks the last ~500 chars for common failure/inability patterns.
588
+ */
589
+ function looksIncomplete(output) {
590
+ // Check only the tail — that's where the conclusion lives
591
+ const tail = output.slice(-500).toLowerCase();
592
+ const patterns = [
593
+ /i (?:was |am )?(?:unable|not able) to/,
594
+ /(?:could|can)(?:n't|not) (?:find|locate|access|complete|proceed|continue)/,
595
+ /(?:doesn't|does not|don't|do not) (?:exist|have access)/,
596
+ /(?:no (?:such|matching) (?:file|directory|path|repo))/,
597
+ /(?:not found|file not found|directory not found)/,
598
+ /(?:i need (?:you to|more information|the .* path|access))/,
599
+ /(?:please (?:provide|specify|confirm|check))/,
600
+ /(?:unfortunately|i apologize).{0,50}(?:cannot|couldn't|unable|can't)/,
601
+ /(?:blocked|stuck).{0,30}(?:because|due to|cannot)/,
602
+ ];
603
+ return patterns.some((p) => p.test(tail));
604
+ }
589
605
  /**
590
606
  * Parse question from Claude output when question.json is missing.
591
607
  * Fallback for when Claude outputs NEEDS_INPUT but didn't create the file.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "tuna-agent",
3
- "version": "0.1.80",
3
+ "version": "0.1.82",
4
4
  "description": "Tuna Agent - Run AI coding tasks on your machine",
5
5
  "bin": {
6
6
  "tuna-agent": "dist/cli/index.js"