tuna-agent 0.1.68 → 0.1.70

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.
@@ -38,6 +38,8 @@ export declare class ClaudeCodeAdapter implements AgentAdapter {
38
38
  private currentAgentName;
39
39
  /** Returns metrics for the currently active agent, initializing if needed. */
40
40
  private get metrics();
41
+ /** Returns metrics for a specific agent by ID (safe for parallel execution). */
42
+ getMetricsForAgent(agentId: string): AgentMetrics;
41
43
  constructor(config: AgentConfig);
42
44
  /** Resolve CLAUDE.md path — check root first, then .claude/ subfolder. */
43
45
  static resolveClaudeMdPath(folder: string): string;
@@ -23,8 +23,12 @@ export class ClaudeCodeAdapter {
23
23
  currentAgentName = '';
24
24
  /** Returns metrics for the currently active agent, initializing if needed. */
25
25
  get metrics() {
26
- if (!this.metricsMap.has(this.currentAgentId)) {
27
- this.metricsMap.set(this.currentAgentId, {
26
+ return this.getMetricsForAgent(this.currentAgentId);
27
+ }
28
+ /** Returns metrics for a specific agent by ID (safe for parallel execution). */
29
+ getMetricsForAgent(agentId) {
30
+ if (!this.metricsMap.has(agentId)) {
31
+ this.metricsMap.set(agentId, {
28
32
  taskCount: 0,
29
33
  successCount: 0,
30
34
  failCount: 0,
@@ -42,7 +46,7 @@ export class ClaudeCodeAdapter {
42
46
  upSince: new Date().toISOString(),
43
47
  });
44
48
  }
45
- return this.metricsMap.get(this.currentAgentId);
49
+ return this.metricsMap.get(agentId);
46
50
  }
47
51
  constructor(config) {
48
52
  this.agentConfig = config;
@@ -810,22 +814,26 @@ export class ClaudeCodeAdapter {
810
814
  const MIN_DESCRIPTION_LENGTH = 20;
811
815
  if (task.description.length < MIN_DESCRIPTION_LENGTH)
812
816
  return;
817
+ // Derive agent name from cwd (safe for parallel execution — no shared state)
818
+ const agentName = path.basename(cwd) || this.currentAgentName;
819
+ const agentId = task.agentId || this.currentAgentId;
813
820
  try {
814
821
  // Step 1: Generate AI-powered reflection via Ollama
815
822
  console.log(`[Reflection] Generating AI reflection for task ${task.id} (${status}), input: ${resultSummary.substring(0, 150)}...`);
816
823
  const { callMem0Reflect, callMem0AddMemory } = await import('../mcp/setup.js');
817
824
  const aiReflection = await callMem0Reflect(task.description, resultSummary, status);
818
825
  if (!aiReflection) {
819
- this.metrics.reflectionSkipCount++;
826
+ this.getMetricsForAgent(agentId).reflectionSkipCount++;
820
827
  console.log(`[Reflection] No meaningful lesson — skipping storage for task ${task.id}`);
821
828
  return;
822
829
  }
823
830
  // Step 2: Store the AI-generated reflection in Mem0
824
831
  console.log(`[Reflection] Storing: "${aiReflection.substring(0, 100)}..."`);
825
- await callMem0AddMemory(aiReflection, this.currentAgentName);
826
- this.metrics.reflectionCount++;
827
- this.metrics.memoryCount++;
828
- this.metrics.lastReflectionAt = new Date().toISOString();
832
+ await callMem0AddMemory(aiReflection, agentName);
833
+ const m = this.getMetricsForAgent(agentId);
834
+ m.reflectionCount++;
835
+ m.memoryCount++;
836
+ m.lastReflectionAt = new Date().toISOString();
829
837
  console.log(`[Reflection] Stored for task ${task.id}`);
830
838
  }
831
839
  catch (err) {
@@ -836,7 +844,7 @@ export class ClaudeCodeAdapter {
836
844
  ? `Task failed: "${task.description.substring(0, 150)}". Error: ${resultSummary.substring(0, 200)}`
837
845
  : `Task completed: "${task.description.substring(0, 150)}". Result: ${resultSummary.substring(0, 200)}`;
838
846
  const { callMem0AddMemory } = await import('../mcp/setup.js');
839
- await callMem0AddMemory(fallback, this.currentAgentName);
847
+ await callMem0AddMemory(fallback, agentName);
840
848
  }
841
849
  catch {
842
850
  // Both AI and fallback failed — give up silently
@@ -859,7 +867,8 @@ export class ClaudeCodeAdapter {
859
867
  try {
860
868
  console.log(`[Rating→Mem0] Storing rating for task "${data.taskTitle}" (${data.score > 0 ? '👍' : '👎'})`);
861
869
  const { callMem0AddMemory } = await import('../mcp/setup.js');
862
- await callMem0AddMemory(memoryText, this.currentAgentName);
870
+ const agentName = path.basename(data.cwd) || this.currentAgentName;
871
+ await callMem0AddMemory(memoryText, agentName);
863
872
  console.log(`[Rating→Mem0] Rating stored successfully`);
864
873
  }
865
874
  catch (err) {
@@ -897,7 +906,8 @@ export class ClaudeCodeAdapter {
897
906
  try {
898
907
  console.log(`[Self-Improve] Running pattern detection (every ${ClaudeCodeAdapter.PATTERN_CHECK_INTERVAL} tasks, count=${this.taskCount})`);
899
908
  const { callMem0Patterns } = await import('../mcp/setup.js');
900
- const patterns = await callMem0Patterns(this.currentAgentName, 2);
909
+ const agentName = path.basename(cwd) || this.currentAgentName;
910
+ const patterns = await callMem0Patterns(agentName, 2);
901
911
  if (patterns.length === 0) {
902
912
  console.log(`[Self-Improve] No patterns detected yet`);
903
913
  return;
@@ -127,9 +127,14 @@ export async function startDaemon(config) {
127
127
  console.log(`[Daemon] Agent ready: ${adapter.displayName} (${health.message})`);
128
128
  }
129
129
  let activeTasks = 0;
130
- const MAX_CONCURRENT = 1;
130
+ const MAX_CONCURRENT_PER_AGENT = 1; // Each agent can run 1 task at a time
131
131
  const pendingInputResolvers = new Map();
132
132
  const pendingPermissionResolvers = new Map();
133
+ // Track active tasks per agent (agentId → taskId)
134
+ const activeAgentTasks = new Map();
135
+ // Track abort controllers per task
136
+ const taskAbortControllers = new Map();
137
+ // Legacy single-task tracking (for functions that still use it)
133
138
  let currentTaskId = null;
134
139
  let currentTaskAbort = null;
135
140
  const onAuthFailed = (code, reason) => {
@@ -196,8 +201,9 @@ export async function startDaemon(config) {
196
201
  // Seed memory counts from Mem0 so they appear on agent detail immediately
197
202
  ccAdapter.seedMemoryCounts().catch(() => { });
198
203
  }
199
- // Recover orphaned tasks — pass activeTaskId so API won't fail a task we're still running
200
- ws.send({ action: 'recover_orphaned_tasks', activeTaskId: currentTaskId ?? undefined });
204
+ // Recover orphaned tasks — pass active task IDs so API won't fail tasks we're still running
205
+ const activeTaskIds = Array.from(taskAbortControllers.keys());
206
+ ws.send({ action: 'recover_orphaned_tasks', activeTaskId: currentTaskId ?? undefined, activeTaskIds });
201
207
  break;
202
208
  case 'task_assigned': {
203
209
  const task = msg.task;
@@ -209,53 +215,58 @@ export async function startDaemon(config) {
209
215
  if (task.repoPath?.startsWith('~/')) {
210
216
  task.repoPath = path.join(os.homedir(), task.repoPath.slice(2));
211
217
  }
212
- if (activeTasks >= MAX_CONCURRENT) {
213
- console.log(`[Daemon] Busy rejecting task ${task.id}`);
218
+ // Check per-agent concurrency (each agent can run 1 task at a time)
219
+ const agentId = task.agentId || '__default__';
220
+ if (activeAgentTasks.has(agentId)) {
221
+ console.log(`[Daemon] Agent ${agentId} busy — rejecting task ${task.id}`);
214
222
  ws.send({ action: 'task_rejected', taskId: task.id, reason: 'agent_busy' });
215
223
  break;
216
224
  }
217
225
  activeTasks++;
226
+ activeAgentTasks.set(agentId, task.id);
227
+ const abort = new AbortController();
228
+ taskAbortControllers.set(task.id, abort);
218
229
  currentTaskId = task.id;
219
- currentTaskAbort = new AbortController();
220
- console.log(`[Daemon] Received task: ${task.id} agentId=${task.agentId || '(none)'} — ${task.description.slice(0, 80)} (attachments: ${task.attachments?.length ?? 0})`);
221
- // Update MCP config with the task's agent ID so knowledge server uses correct attribution
222
- if (task.agentId) {
230
+ currentTaskAbort = abort;
231
+ console.log(`[Daemon] Received task: ${task.id} agent=${agentId} — ${task.description.slice(0, 80)} (attachments: ${task.attachments?.length ?? 0}) [active: ${activeTasks}]`);
232
+ // MCP config per-agent is handled by writeAgentFolderMcpConfig in adapter
233
+ // (writes per-agent .mcp.json in agent folder — safe for parallel execution)
234
+ // Run task in background (non-blocking) to allow parallel agent execution
235
+ (async () => {
223
236
  try {
224
- setupMcpConfig({ ...config, agentId: task.agentId });
237
+ await adapter.handleTask(task, ws, pendingInputResolvers, abort.signal, pendingPermissionResolvers);
225
238
  }
226
239
  catch (err) {
227
- console.warn(`[Daemon] MCP config update for task failed (non-fatal):`, err);
228
- }
229
- }
230
- try {
231
- await adapter.handleTask(task, ws, pendingInputResolvers, currentTaskAbort.signal, pendingPermissionResolvers);
232
- }
233
- catch (err) {
234
- const errMsg = err instanceof Error ? err.message : String(err);
235
- if (currentTaskAbort?.signal.aborted) {
236
- console.log(`[Daemon] Task ${task.id} cancelled`);
240
+ const errMsg = err instanceof Error ? err.message : String(err);
241
+ if (abort.signal.aborted) {
242
+ console.log(`[Daemon] Task ${task.id} cancelled`);
243
+ }
244
+ else {
245
+ ws.sendTaskFailed(task.id, errMsg);
246
+ console.error(`[Daemon] Task ${task.id} error:`, errMsg);
247
+ }
237
248
  }
238
- else {
239
- ws.sendTaskFailed(task.id, errMsg);
240
- console.error(`[Daemon] Task ${task.id} error:`, errMsg);
249
+ finally {
250
+ activeTasks--;
251
+ activeAgentTasks.delete(agentId);
252
+ taskAbortControllers.delete(task.id);
253
+ if (currentTaskId === task.id) {
254
+ currentTaskId = null;
255
+ currentTaskAbort = null;
256
+ }
257
+ pendingInputResolvers.delete(task.id);
258
+ ws.send({ action: 'agent_ready', agentId });
241
259
  }
242
- }
243
- finally {
244
- activeTasks--;
245
- currentTaskId = null;
246
- currentTaskAbort = null;
247
- pendingInputResolvers.delete(task.id);
248
- // Notify API that agent is available to pick up queued tasks
249
- ws.send({ action: 'agent_ready' });
250
- }
260
+ })();
251
261
  break;
252
262
  }
253
263
  case 'task_cancelled': {
254
264
  const taskId = msg.taskId;
255
265
  console.log(`[Daemon] Task cancelled: ${taskId}`);
256
266
  clearPMState(taskId);
257
- if (currentTaskId === taskId && currentTaskAbort) {
258
- currentTaskAbort.abort();
267
+ const taskAbort = taskAbortControllers.get(taskId);
268
+ if (taskAbort) {
269
+ taskAbort.abort();
259
270
  console.log(`[Daemon] Aborted running task ${taskId}`);
260
271
  }
261
272
  // Also resolve any pending input to unblock waitForInput
@@ -455,7 +466,7 @@ ${skillContent.slice(0, 15000)}`;
455
466
  else {
456
467
  // No resolver — check if we have a persisted session to resume
457
468
  const savedState = loadPMState(taskId);
458
- if (savedState && activeTasks < MAX_CONCURRENT) {
469
+ if (savedState) {
459
470
  if (savedState.mode === 'agent_team') {
460
471
  console.log(`[Daemon] Resuming agent_team for task ${taskId} (session: ${savedState.agentTeamSessionId?.substring(0, 12) ?? 'none'})`);
461
472
  resumeAgentTeamChat(taskId, answer, attachments, savedState, ws, pendingInputResolvers);
@@ -465,9 +476,6 @@ ${skillContent.slice(0, 15000)}`;
465
476
  resumePMChat(taskId, answer, attachments, savedState, ws, pendingInputResolvers);
466
477
  }
467
478
  }
468
- else if (savedState) {
469
- console.warn(`[Daemon] Cannot resume — agent busy (active: ${activeTasks})`);
470
- }
471
479
  else {
472
480
  console.warn(`[Daemon] No pending input resolver for task ${taskId}`);
473
481
  }
@@ -628,8 +636,10 @@ ${skillContent.slice(0, 15000)}`;
628
636
  */
629
637
  async function resumePMChat(taskId, firstMessage, firstAttachments, pmState, wsClient, resolvers) {
630
638
  activeTasks++;
639
+ const abort = new AbortController();
640
+ taskAbortControllers.set(taskId, abort);
631
641
  currentTaskId = taskId;
632
- currentTaskAbort = new AbortController();
642
+ currentTaskAbort = abort;
633
643
  let pmSessionId = pmState.pmSessionId;
634
644
  const MAX_RESUMED_ROUNDS = 50;
635
645
  try {
@@ -650,7 +660,7 @@ ${skillContent.slice(0, 15000)}`;
650
660
  console.log(`[Daemon] Resumed PM chat round ${round + 1}: ${userMessage.substring(0, 80)}${inputFiles?.length ? ` (+${inputFiles.length} images)` : ''}`);
651
661
  const streamMsgId = `pm-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
652
662
  let firstChunkIso = '';
653
- const chatResult = await chatWithPM(pmSessionId, pmState.repoPath, userMessage, currentTaskAbort.signal, (chunk) => {
663
+ const chatResult = await chatWithPM(pmSessionId, pmState.repoPath, userMessage, abort.signal, (chunk) => {
654
664
  if (!firstChunkIso)
655
665
  firstChunkIso = new Date().toISOString();
656
666
  wsClient.sendPMStream(taskId, chunk);
@@ -677,7 +687,7 @@ ${skillContent.slice(0, 15000)}`;
677
687
  repoPath: pmState.repoPath,
678
688
  mode: 'tuna',
679
689
  };
680
- const result = await executePlanAndReport(task, plan, wsClient, resolvers, currentTaskAbort.signal);
690
+ const result = await executePlanAndReport(task, plan, wsClient, resolvers, abort.signal);
681
691
  if (result.status === 'done' && !result.followUpMessage) {
682
692
  const totalDuration = result.sessions.reduce((sum, s) => sum + (s.durationMs ?? 0), 0);
683
693
  wsClient.sendTaskDone(taskId, { result: plan.summary, durationMs: totalDuration });
@@ -711,7 +721,7 @@ ${skillContent.slice(0, 15000)}`;
711
721
  }
712
722
  }
713
723
  catch (err) {
714
- if (currentTaskAbort?.signal.aborted) {
724
+ if (abort.signal.aborted) {
715
725
  console.log(`[Daemon] Resumed PM chat cancelled for task ${taskId}`);
716
726
  }
717
727
  else {
@@ -721,8 +731,11 @@ ${skillContent.slice(0, 15000)}`;
721
731
  }
722
732
  finally {
723
733
  activeTasks--;
724
- currentTaskId = null;
725
- currentTaskAbort = null;
734
+ taskAbortControllers.delete(taskId);
735
+ if (currentTaskId === taskId) {
736
+ currentTaskId = null;
737
+ currentTaskAbort = null;
738
+ }
726
739
  resolvers.delete(taskId);
727
740
  cleanupAttachments(taskId);
728
741
  wsClient.send({ action: 'agent_ready' });
@@ -734,8 +747,10 @@ ${skillContent.slice(0, 15000)}`;
734
747
  */
735
748
  async function resumeAgentTeamChat(taskId, firstMessage, firstAttachments, savedState, wsClient, resolvers) {
736
749
  activeTasks++;
750
+ const abort = new AbortController();
751
+ taskAbortControllers.set(taskId, abort);
737
752
  currentTaskId = taskId;
738
- currentTaskAbort = new AbortController();
753
+ currentTaskAbort = abort;
739
754
  let sessionId = savedState.agentTeamSessionId;
740
755
  let totalDurationMs = 0;
741
756
  try {
@@ -770,7 +785,7 @@ ${skillContent.slice(0, 15000)}`;
770
785
  agentTeam: true,
771
786
  maxTurns: 50,
772
787
  resumeSessionId: sessionId,
773
- signal: currentTaskAbort.signal,
788
+ signal: abort.signal,
774
789
  inputFiles: currentInputFiles,
775
790
  onStreamLine: (data) => {
776
791
  if (data.type === 'stream_event') {
@@ -884,7 +899,7 @@ ${skillContent.slice(0, 15000)}`;
884
899
  });
885
900
  }
886
901
  catch (err) {
887
- if (currentTaskAbort?.signal.aborted) {
902
+ if (abort.signal.aborted) {
888
903
  console.log(`[Daemon] Resumed agent_team cancelled for task ${taskId}`);
889
904
  }
890
905
  else {
@@ -894,8 +909,11 @@ ${skillContent.slice(0, 15000)}`;
894
909
  }
895
910
  finally {
896
911
  activeTasks--;
897
- currentTaskId = null;
898
- currentTaskAbort = null;
912
+ taskAbortControllers.delete(taskId);
913
+ if (currentTaskId === taskId) {
914
+ currentTaskId = null;
915
+ currentTaskAbort = null;
916
+ }
899
917
  resolvers.delete(taskId);
900
918
  cleanupAttachments(taskId);
901
919
  wsClient.send({ action: 'agent_ready' });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "tuna-agent",
3
- "version": "0.1.68",
3
+ "version": "0.1.70",
4
4
  "description": "Tuna Agent - Run AI coding tasks on your machine",
5
5
  "bin": {
6
6
  "tuna-agent": "dist/cli/index.js"