tuna-agent 0.1.70 → 0.1.72

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.
@@ -47,6 +47,8 @@ export declare class ClaudeCodeAdapter implements AgentAdapter {
47
47
  getMetrics(): Record<string, unknown>;
48
48
  /** Register an agent folder path for rules parsing (called from daemon). */
49
49
  registerAgentFolder(agentId: string, folderPath: string): void;
50
+ /** Get folder path for an agent by ID (returns undefined if not registered). */
51
+ getAgentFolder(agentId: string): string | undefined;
50
52
  /** Seed memoryCount from Mem0 for all registered agents (called once on daemon startup). */
51
53
  seedMemoryCounts(): Promise<void>;
52
54
  checkHealth(): Promise<{
@@ -77,14 +79,14 @@ export declare class ClaudeCodeAdapter implements AgentAdapter {
77
79
  private static extractKeyPhrases;
78
80
  /** Check if two rules are semantically similar (>40% key phrase overlap). */
79
81
  private static isSimilarRule;
80
- runSelfImprovement(cwd: string): Promise<void>;
82
+ runSelfImprovement(cwd: string, agentId?: string): Promise<void>;
81
83
  /**
82
84
  * Parse "## Learned Rules" section from CLAUDE.md and store in learnedRulesMap.
83
85
  * Rule format: `- Rule text (confidence: 0.7)`
84
86
  */
85
87
  private parseLearnedRules;
86
88
  /** Track task completion metrics (public for daemon resume path). */
87
- trackMetricsPublic(status: 'done' | 'failed', durationMs: number): void;
89
+ trackMetricsPublic(status: 'done' | 'failed', durationMs: number, agentId?: string): void;
88
90
  /** Track task completion metrics. */
89
91
  private trackMetrics;
90
92
  dispose(): Promise<void>;
@@ -92,6 +92,10 @@ export class ClaudeCodeAdapter {
92
92
  registerAgentFolder(agentId, folderPath) {
93
93
  this.agentFolderMap.set(agentId, folderPath);
94
94
  }
95
+ /** Get folder path for an agent by ID (returns undefined if not registered). */
96
+ getAgentFolder(agentId) {
97
+ return this.agentFolderMap.get(agentId);
98
+ }
95
99
  /** Seed memoryCount from Mem0 for all registered agents (called once on daemon startup). */
96
100
  async seedMemoryCounts() {
97
101
  for (const [agentId, folder] of this.agentFolderMap) {
@@ -145,13 +149,16 @@ export class ClaudeCodeAdapter {
145
149
  : undefined;
146
150
  // Default mode: direct chat with Claude CLI (no PM layer)
147
151
  // Only use PM planning when mode is explicitly 'tuna'
148
- // Set current agent context (metrics + Mem0 identity)
149
- this.currentAgentId = task.agentId || '';
152
+ // Capture agent context as LOCAL variables for parallel-safety
153
+ const localAgentId = task.agentId || '';
150
154
  const defaultWorkspaceEarly = path.join(os.homedir(), 'tuna-workspace');
151
- this.currentAgentName = path.basename(task.repoPath || defaultWorkspaceEarly);
155
+ const localAgentName = path.basename(task.repoPath || defaultWorkspaceEarly);
156
+ // Also set instance vars for backward compat (metrics getter, heartbeat, etc.)
157
+ this.currentAgentId = localAgentId;
158
+ this.currentAgentName = localAgentName;
152
159
  // Track agent folder for rules parsing in heartbeat
153
160
  const cwd = task.repoPath || defaultWorkspaceEarly;
154
- this.agentFolderMap.set(this.currentAgentId, cwd);
161
+ this.agentFolderMap.set(localAgentId, cwd);
155
162
  if (task.mode !== 'tuna') {
156
163
  console.log(`[ClaudeCode] Agent Team mode — direct chat with Claude CLI`);
157
164
  ws.sendProgress(task.id, 'executing', { startedAt: new Date().toISOString() });
@@ -175,7 +182,7 @@ export class ClaudeCodeAdapter {
175
182
  if (process.env.MEM0_SSH_HOST && task.description.length >= 20) {
176
183
  try {
177
184
  const { callMem0SearchMemory } = await import('../mcp/setup.js');
178
- const memories = await callMem0SearchMemory(task.description, this.currentAgentName, 5);
185
+ const memories = await callMem0SearchMemory(task.description, localAgentName, 5);
179
186
  if (memories.length > 0) {
180
187
  const memoryContext = memories.map(m => `- ${m}`).join('\n');
181
188
  userMessage = `${task.description}\n\n<past_learnings>\nRelevant lessons from previous tasks:\n${memoryContext}\n</past_learnings>`;
@@ -203,10 +210,11 @@ export class ClaudeCodeAdapter {
203
210
  if (round === 0) {
204
211
  writeAgentFolderMcpConfig(cwd, this.agentConfig);
205
212
  // Seed memoryCount from Mem0 so it survives daemon restarts (non-blocking)
206
- fetchMem0Count(this.currentAgentName).then(count => {
207
- if (count > this.metrics.memoryCount) {
208
- this.metrics.memoryCount = count;
209
- console.log(`[Metrics] Seeded memoryCount=${count} from Mem0 for "${this.currentAgentName}"`);
213
+ fetchMem0Count(localAgentName).then(count => {
214
+ const m = this.getMetricsForAgent(localAgentId);
215
+ if (count > m.memoryCount) {
216
+ m.memoryCount = count;
217
+ console.log(`[Metrics] Seeded memoryCount=${count} from Mem0 for "${localAgentName}"`);
210
218
  }
211
219
  }).catch(() => { });
212
220
  }
@@ -324,7 +332,7 @@ export class ClaudeCodeAdapter {
324
332
  startedAt: firstChunkIso || undefined,
325
333
  });
326
334
  ws.sendTaskFailed(task.id, result.result);
327
- this.trackMetrics('failed', totalDurationMs);
335
+ this.trackMetrics('failed', totalDurationMs, localAgentId);
328
336
  console.log(`[ClaudeCode] Agent Team task ${task.id} failed in round ${round + 1}`);
329
337
  this.runReflection(task, result.result, 'failed', cwd).catch(() => { });
330
338
  return;
@@ -364,6 +372,7 @@ export class ClaudeCodeAdapter {
364
372
  savedAt: new Date().toISOString(),
365
373
  mode: 'agent_team',
366
374
  agentTeamSessionId: sessionId,
375
+ agentId: task.agentId,
367
376
  });
368
377
  console.log(`[ClaudeCode] Agent Team round ${round + 1} done — waiting for follow-up (60s timeout)`);
369
378
  // Wait for follow-up message with timeout — free agent if no reply
@@ -384,10 +393,10 @@ export class ClaudeCodeAdapter {
384
393
  durationMs: totalDurationMs,
385
394
  sessionId,
386
395
  });
387
- this.trackMetrics('done', totalDurationMs);
396
+ this.trackMetrics('done', totalDurationMs, localAgentId);
388
397
  const timeoutOutput = lastTaskOutput || 'Task completed (no follow-up)';
389
398
  this.runReflection(task, timeoutOutput, 'done', task.repoPath)
390
- .then(() => this.runSelfImprovement(task.repoPath))
399
+ .then(() => this.runSelfImprovement(task.repoPath, task.agentId))
391
400
  .catch(() => { });
392
401
  return;
393
402
  }
@@ -418,11 +427,11 @@ export class ClaudeCodeAdapter {
418
427
  });
419
428
  await new Promise(resolve => setTimeout(resolve, 150));
420
429
  ws.sendPMMessage(task.id, { sender: 'pm', content: 'Task completed.' });
421
- this.trackMetrics('done', totalDurationMs);
430
+ this.trackMetrics('done', totalDurationMs, localAgentId);
422
431
  console.log(`[ClaudeCode] Agent Team task ${task.id} completed (${(totalDurationMs / 1000).toFixed(1)}s)`);
423
432
  // Post-task reflection with actual output (non-blocking)
424
433
  this.runReflection(task, lastTaskOutput || 'Task completed without text output', 'done', task.repoPath)
425
- .then(() => this.runSelfImprovement(task.repoPath))
434
+ .then(() => this.runSelfImprovement(task.repoPath, task.agentId))
426
435
  .catch(() => { });
427
436
  }
428
437
  finally {
@@ -486,7 +495,7 @@ export class ClaudeCodeAdapter {
486
495
  const MAX_CHAT_ROUNDS = 50;
487
496
  let pmSessionId = planResult.pmSessionId || '';
488
497
  // Persist PM state for recovery after agent restart
489
- savePMState({ taskId: task.id, pmSessionId, repoPath: task.repoPath, savedAt: new Date().toISOString() });
498
+ savePMState({ taskId: task.id, pmSessionId, repoPath: task.repoPath, savedAt: new Date().toISOString(), agentId: task.agentId });
490
499
  // Track the latest PM message to use as context for needs_input
491
500
  let latestPMMessage = firstMsg;
492
501
  for (let round = 0; round < MAX_CHAT_ROUNDS; round++) {
@@ -529,7 +538,7 @@ export class ClaudeCodeAdapter {
529
538
  console.log(`[ClaudeCode] ⏱ chat total: ${chatDoneMs - inputReceivedMs}ms | first chunk: ${firstChunkSentMs ? firstChunkSentMs - inputReceivedMs : 'none'}ms | chunks: ${chunkCount}`);
530
539
  pmSessionId = chatResult.sessionId || pmSessionId;
531
540
  // Update persisted PM session ID
532
- savePMState({ taskId: task.id, pmSessionId, repoPath: task.repoPath, savedAt: new Date().toISOString() });
541
+ savePMState({ taskId: task.id, pmSessionId, repoPath: task.repoPath, savedAt: new Date().toISOString(), agentId: task.agentId });
533
542
  // PM produced a plan with subtasks → break out to execution
534
543
  if (chatResult.plan && chatResult.plan.subtasks.length > 0) {
535
544
  planResult = { plan: chatResult.plan, pmSessionId };
@@ -779,6 +788,7 @@ export class ClaudeCodeAdapter {
779
788
  pmSessionId: planResult.pmSessionId,
780
789
  repoPath: task.repoPath,
781
790
  savedAt: new Date().toISOString(),
791
+ agentId: task.agentId,
782
792
  });
783
793
  }
784
794
  ws.sendTaskDone(task.id, {
@@ -897,7 +907,7 @@ export class ClaudeCodeAdapter {
897
907
  const overlap = phrasesA.filter(p => phrasesB.has(p)).length;
898
908
  return overlap / phrasesA.length > 0.4;
899
909
  }
900
- async runSelfImprovement(cwd) {
910
+ async runSelfImprovement(cwd, agentId) {
901
911
  if (!process.env.MEM0_SSH_HOST)
902
912
  return;
903
913
  this.taskCount++;
@@ -983,7 +993,7 @@ export class ClaudeCodeAdapter {
983
993
  console.log(`[Self-Improve] Added ${toAdd.length} new rules to CLAUDE.md:`);
984
994
  toAdd.forEach(p => console.log(`[Self-Improve] - ${p.rule}`));
985
995
  // Sync learned rules to heartbeat metrics
986
- this.parseLearnedRules(claudeMdPath);
996
+ this.parseLearnedRules(claudeMdPath, agentId);
987
997
  }
988
998
  catch (err) {
989
999
  console.warn(`[Self-Improve] Failed:`, err instanceof Error ? err.message : err);
@@ -993,7 +1003,7 @@ export class ClaudeCodeAdapter {
993
1003
  * Parse "## Learned Rules" section from CLAUDE.md and store in learnedRulesMap.
994
1004
  * Rule format: `- Rule text (confidence: 0.7)`
995
1005
  */
996
- parseLearnedRules(claudeMdPath) {
1006
+ parseLearnedRules(claudeMdPath, agentId) {
997
1007
  try {
998
1008
  if (!fs.existsSync(claudeMdPath))
999
1009
  return;
@@ -1038,8 +1048,9 @@ export class ClaudeCodeAdapter {
1038
1048
  });
1039
1049
  }
1040
1050
  if (rules.length > 0) {
1041
- this.learnedRulesMap.set(this.currentAgentId, rules);
1042
- this.metrics.rulesCount = rules.length;
1051
+ const resolvedAgentId = agentId || this.currentAgentId;
1052
+ this.learnedRulesMap.set(resolvedAgentId, rules);
1053
+ this.getMetricsForAgent(resolvedAgentId).rulesCount = rules.length;
1043
1054
  console.log(`[Self-Improve] Parsed ${rules.length} rules from CLAUDE.md for heartbeat sync`);
1044
1055
  }
1045
1056
  }
@@ -1048,20 +1059,21 @@ export class ClaudeCodeAdapter {
1048
1059
  }
1049
1060
  }
1050
1061
  /** Track task completion metrics (public for daemon resume path). */
1051
- trackMetricsPublic(status, durationMs) {
1052
- this.trackMetrics(status, durationMs);
1062
+ trackMetricsPublic(status, durationMs, agentId) {
1063
+ this.trackMetrics(status, durationMs, agentId);
1053
1064
  }
1054
1065
  /** Track task completion metrics. */
1055
- trackMetrics(status, durationMs) {
1056
- this.metrics.taskCount++;
1066
+ trackMetrics(status, durationMs, agentId) {
1067
+ const m = agentId ? this.getMetricsForAgent(agentId) : this.metrics;
1068
+ m.taskCount++;
1057
1069
  if (status === 'done')
1058
- this.metrics.successCount++;
1070
+ m.successCount++;
1059
1071
  else
1060
- this.metrics.failCount++;
1061
- this.metrics.totalDurationMs += durationMs;
1062
- this.metrics.avgDurationMs = Math.round(this.metrics.totalDurationMs / this.metrics.taskCount);
1063
- this.metrics.lastTaskAt = new Date().toISOString();
1064
- console.log(`[Metrics] Tasks: ${this.metrics.successCount}✓ ${this.metrics.failCount}✗ | Avg: ${(this.metrics.avgDurationMs / 1000).toFixed(0)}s | Reflections: ${this.metrics.reflectionCount} | Patterns: ${this.metrics.patternsLearnedCount}`);
1072
+ m.failCount++;
1073
+ m.totalDurationMs += durationMs;
1074
+ m.avgDurationMs = Math.round(m.totalDurationMs / m.taskCount);
1075
+ m.lastTaskAt = new Date().toISOString();
1076
+ console.log(`[Metrics] Tasks: ${m.successCount}✓ ${m.failCount}✗ | Avg: ${(m.avgDurationMs / 1000).toFixed(0)}s | Reflections: ${m.reflectionCount} | Patterns: ${m.patternsLearnedCount}`);
1065
1077
  }
1066
1078
  async dispose() {
1067
1079
  // No persistent resources to clean up
@@ -134,9 +134,7 @@ export async function startDaemon(config) {
134
134
  const activeAgentTasks = new Map();
135
135
  // Track abort controllers per task
136
136
  const taskAbortControllers = new Map();
137
- // Legacy single-task tracking (for functions that still use it)
138
- let currentTaskId = null;
139
- let currentTaskAbort = null;
137
+ // Note: currentTaskId/currentTaskAbort removed use taskAbortControllers + activeAgentTasks instead
140
138
  const onAuthFailed = (code, reason) => {
141
139
  console.error(`\n[Daemon] Authentication failed (code: ${code}, reason: ${reason}).`);
142
140
  console.error('[Daemon] Your machine token is invalid or expired.');
@@ -203,7 +201,7 @@ export async function startDaemon(config) {
203
201
  }
204
202
  // Recover orphaned tasks — pass active task IDs so API won't fail tasks we're still running
205
203
  const activeTaskIds = Array.from(taskAbortControllers.keys());
206
- ws.send({ action: 'recover_orphaned_tasks', activeTaskId: currentTaskId ?? undefined, activeTaskIds });
204
+ ws.send({ action: 'recover_orphaned_tasks', activeTaskIds });
207
205
  break;
208
206
  case 'task_assigned': {
209
207
  const task = msg.task;
@@ -226,11 +224,7 @@ export async function startDaemon(config) {
226
224
  activeAgentTasks.set(agentId, task.id);
227
225
  const abort = new AbortController();
228
226
  taskAbortControllers.set(task.id, abort);
229
- currentTaskId = task.id;
230
- currentTaskAbort = abort;
231
227
  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
228
  // Run task in background (non-blocking) to allow parallel agent execution
235
229
  (async () => {
236
230
  try {
@@ -250,10 +244,6 @@ export async function startDaemon(config) {
250
244
  activeTasks--;
251
245
  activeAgentTasks.delete(agentId);
252
246
  taskAbortControllers.delete(task.id);
253
- if (currentTaskId === task.id) {
254
- currentTaskId = null;
255
- currentTaskAbort = null;
256
- }
257
247
  pendingInputResolvers.delete(task.id);
258
248
  ws.send({ action: 'agent_ready', agentId });
259
249
  }
@@ -435,13 +425,14 @@ ${skillContent.slice(0, 15000)}`;
435
425
  // Store rating in Mem0 via adapter (fire-and-forget)
436
426
  if (adapter.type === 'claude-code') {
437
427
  const ccAdapter = adapter;
438
- const defaultCwd = path.join(os.homedir(), 'tuna-workspace');
428
+ const agentFolder = agentId ? ccAdapter.getAgentFolder(agentId) : undefined;
429
+ const cwd = agentFolder || path.join(os.homedir(), 'tuna-workspace');
439
430
  ccAdapter.storeRatingMemory({
440
431
  taskTitle,
441
432
  taskDescription,
442
433
  score,
443
434
  comment: comment || undefined,
444
- cwd: defaultCwd,
435
+ cwd,
445
436
  }).catch((err) => {
446
437
  console.warn(`[Daemon] Rating→Mem0 failed:`, err instanceof Error ? err.message : err);
447
438
  });
@@ -467,6 +458,13 @@ ${skillContent.slice(0, 15000)}`;
467
458
  // No resolver — check if we have a persisted session to resume
468
459
  const savedState = loadPMState(taskId);
469
460
  if (savedState) {
461
+ // Check per-agent concurrency before resuming
462
+ const resumeAgentId = savedState.agentId || '__default__';
463
+ if (activeAgentTasks.has(resumeAgentId)) {
464
+ console.warn(`[Daemon] Cannot resume task ${taskId} — agent ${resumeAgentId} is busy with task ${activeAgentTasks.get(resumeAgentId)}`);
465
+ ws.send({ action: 'task_rejected', taskId, reason: 'agent_busy' });
466
+ break;
467
+ }
470
468
  if (savedState.mode === 'agent_team') {
471
469
  console.log(`[Daemon] Resuming agent_team for task ${taskId} (session: ${savedState.agentTeamSessionId?.substring(0, 12) ?? 'none'})`);
472
470
  resumeAgentTeamChat(taskId, answer, attachments, savedState, ws, pendingInputResolvers);
@@ -635,11 +633,11 @@ ${skillContent.slice(0, 15000)}`;
635
633
  * Loads persisted pmSessionId and runs a chat loop.
636
634
  */
637
635
  async function resumePMChat(taskId, firstMessage, firstAttachments, pmState, wsClient, resolvers) {
636
+ const agentId = pmState.agentId || '__default__';
638
637
  activeTasks++;
638
+ activeAgentTasks.set(agentId, taskId);
639
639
  const abort = new AbortController();
640
640
  taskAbortControllers.set(taskId, abort);
641
- currentTaskId = taskId;
642
- currentTaskAbort = abort;
643
641
  let pmSessionId = pmState.pmSessionId;
644
642
  const MAX_RESUMED_ROUNDS = 50;
645
643
  try {
@@ -667,7 +665,7 @@ ${skillContent.slice(0, 15000)}`;
667
665
  }, inputFiles);
668
666
  wsClient.sendPMStreamEnd(taskId, streamMsgId);
669
667
  pmSessionId = chatResult.sessionId || pmSessionId;
670
- savePMState({ taskId, pmSessionId, repoPath: pmState.repoPath, savedAt: new Date().toISOString() });
668
+ savePMState({ taskId, pmSessionId, repoPath: pmState.repoPath, savedAt: new Date().toISOString(), agentId: pmState.agentId });
671
669
  // PM produced a plan → execute it using shared helper
672
670
  if (chatResult.plan && chatResult.plan.subtasks.length > 0) {
673
671
  const plan = chatResult.plan;
@@ -731,14 +729,11 @@ ${skillContent.slice(0, 15000)}`;
731
729
  }
732
730
  finally {
733
731
  activeTasks--;
732
+ activeAgentTasks.delete(agentId);
734
733
  taskAbortControllers.delete(taskId);
735
- if (currentTaskId === taskId) {
736
- currentTaskId = null;
737
- currentTaskAbort = null;
738
- }
739
734
  resolvers.delete(taskId);
740
735
  cleanupAttachments(taskId);
741
- wsClient.send({ action: 'agent_ready' });
736
+ wsClient.send({ action: 'agent_ready', agentId });
742
737
  }
743
738
  }
744
739
  /**
@@ -746,11 +741,11 @@ ${skillContent.slice(0, 15000)}`;
746
741
  * Uses saved Claude CLI session ID to resume with --resume flag.
747
742
  */
748
743
  async function resumeAgentTeamChat(taskId, firstMessage, firstAttachments, savedState, wsClient, resolvers) {
744
+ const agentId = savedState.agentId || '__default__';
749
745
  activeTasks++;
746
+ activeAgentTasks.set(agentId, taskId);
750
747
  const abort = new AbortController();
751
748
  taskAbortControllers.set(taskId, abort);
752
- currentTaskId = taskId;
753
- currentTaskAbort = abort;
754
749
  let sessionId = savedState.agentTeamSessionId;
755
750
  let totalDurationMs = 0;
756
751
  try {
@@ -849,6 +844,7 @@ ${skillContent.slice(0, 15000)}`;
849
844
  savedAt: new Date().toISOString(),
850
845
  mode: 'agent_team',
851
846
  agentTeamSessionId: sessionId,
847
+ agentId: savedState.agentId,
852
848
  });
853
849
  // Wait for follow-up with timeout
854
850
  const FOLLOW_UP_TIMEOUT_MS = 60_000;
@@ -871,7 +867,7 @@ ${skillContent.slice(0, 15000)}`;
871
867
  // Track metrics + reflection on the adapter
872
868
  if (adapter.type === 'claude-code') {
873
869
  const ccAdapter = adapter;
874
- ccAdapter.trackMetricsPublic('done', totalDurationMs);
870
+ ccAdapter.trackMetricsPublic('done', totalDurationMs, savedState.agentId);
875
871
  ccAdapter.runReflection({ id: taskId, description: firstMessage, repoPath: cwd, enableReflection: true }, lastResumeOutput || 'Task completed (no follow-up)', 'done', cwd).then(() => ccAdapter.runSelfImprovement(cwd)).catch(() => { });
876
872
  }
877
873
  return;
@@ -909,14 +905,11 @@ ${skillContent.slice(0, 15000)}`;
909
905
  }
910
906
  finally {
911
907
  activeTasks--;
908
+ activeAgentTasks.delete(agentId);
912
909
  taskAbortControllers.delete(taskId);
913
- if (currentTaskId === taskId) {
914
- currentTaskId = null;
915
- currentTaskAbort = null;
916
- }
917
910
  resolvers.delete(taskId);
918
911
  cleanupAttachments(taskId);
919
- wsClient.send({ action: 'agent_ready' });
912
+ wsClient.send({ action: 'agent_ready', agentId });
920
913
  }
921
914
  }
922
915
  // Wire up agent metrics to heartbeat
@@ -7,6 +7,8 @@ export interface PMSessionState {
7
7
  mode?: 'tuna' | 'agent_team';
8
8
  /** Claude CLI session ID for agent_team resume */
9
9
  agentTeamSessionId?: string;
10
+ /** Agent ID for per-agent concurrency tracking during resume */
11
+ agentId?: string;
10
12
  }
11
13
  /** Save PM session state for a task (survives agent restart). */
12
14
  export declare function savePMState(state: PMSessionState): void;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "tuna-agent",
3
- "version": "0.1.70",
3
+ "version": "0.1.72",
4
4
  "description": "Tuna Agent - Run AI coding tasks on your machine",
5
5
  "bin": {
6
6
  "tuna-agent": "dist/cli/index.js"