groove-dev 0.27.14 → 0.27.17

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 (169) hide show
  1. package/README.md +37 -1
  2. package/developerID_application.cer +0 -0
  3. package/node_modules/@groove-dev/daemon/src/api.js +587 -68
  4. package/node_modules/@groove-dev/daemon/src/classifier.js +24 -0
  5. package/node_modules/@groove-dev/daemon/src/credentials.js +12 -2
  6. package/node_modules/@groove-dev/daemon/src/federation/ambassador.js +204 -0
  7. package/node_modules/@groove-dev/daemon/src/federation/connection.js +359 -0
  8. package/node_modules/@groove-dev/daemon/src/federation/contracts.js +112 -0
  9. package/node_modules/@groove-dev/daemon/src/federation/whitelist.js +190 -0
  10. package/node_modules/@groove-dev/daemon/src/federation.js +166 -7
  11. package/node_modules/@groove-dev/daemon/src/index.js +172 -19
  12. package/node_modules/@groove-dev/daemon/src/introducer.js +52 -7
  13. package/node_modules/@groove-dev/daemon/src/journalist.js +46 -1
  14. package/node_modules/@groove-dev/daemon/src/memory.js +36 -16
  15. package/node_modules/@groove-dev/daemon/src/process.js +140 -23
  16. package/node_modules/@groove-dev/daemon/src/providers/base.js +1 -0
  17. package/node_modules/@groove-dev/daemon/src/providers/claude-code.js +1 -0
  18. package/node_modules/@groove-dev/daemon/src/providers/codex.js +124 -28
  19. package/node_modules/@groove-dev/daemon/src/providers/gemini.js +104 -17
  20. package/node_modules/@groove-dev/daemon/src/providers/index.js +17 -0
  21. package/node_modules/@groove-dev/daemon/src/registry.js +10 -1
  22. package/node_modules/@groove-dev/daemon/src/rotator.js +93 -30
  23. package/node_modules/@groove-dev/daemon/src/skills.js +33 -3
  24. package/node_modules/@groove-dev/daemon/src/terminal-pty.js +9 -1
  25. package/node_modules/@groove-dev/daemon/src/tool-executor.js +11 -5
  26. package/node_modules/@groove-dev/daemon/src/toys.js +69 -0
  27. package/node_modules/@groove-dev/daemon/src/tunnel-manager.js +24 -5
  28. package/node_modules/@groove-dev/daemon/templates/toys-catalog.json +242 -0
  29. package/node_modules/@groove-dev/daemon/test/classifier.test.js +98 -0
  30. package/node_modules/@groove-dev/daemon/test/introducer.test.js +72 -1
  31. package/node_modules/@groove-dev/daemon/test/journalist.test.js +117 -0
  32. package/node_modules/@groove-dev/daemon/test/memory.test.js +37 -1
  33. package/node_modules/@groove-dev/daemon/test/rotator.test.js +183 -4
  34. package/node_modules/@groove-dev/gui/dist/assets/index-BglPgjlu.js +8607 -0
  35. package/node_modules/@groove-dev/gui/dist/assets/index-CGcwmmJv.css +1 -0
  36. package/node_modules/@groove-dev/gui/dist/index.html +3 -2
  37. package/node_modules/@groove-dev/gui/index.html +1 -0
  38. package/node_modules/@groove-dev/gui/src/app.css +7 -0
  39. package/node_modules/@groove-dev/gui/src/app.jsx +37 -10
  40. package/node_modules/@groove-dev/gui/src/components/agents/agent-chat.jsx +21 -31
  41. package/node_modules/@groove-dev/gui/src/components/agents/agent-config.jsx +11 -6
  42. package/node_modules/@groove-dev/gui/src/components/agents/agent-feed.jsx +2 -2
  43. package/node_modules/@groove-dev/gui/src/components/agents/spawn-wizard.jsx +42 -1
  44. package/node_modules/@groove-dev/gui/src/components/editor/breadcrumbs.jsx +30 -0
  45. package/node_modules/@groove-dev/gui/src/components/editor/code-editor.jsx +33 -2
  46. package/node_modules/@groove-dev/gui/src/components/editor/editor-status-bar.jsx +26 -0
  47. package/node_modules/@groove-dev/gui/src/components/editor/editor-tabs.jsx +113 -34
  48. package/node_modules/@groove-dev/gui/src/components/editor/goto-line.jsx +35 -0
  49. package/node_modules/@groove-dev/gui/src/components/editor/terminal.jsx +12 -6
  50. package/node_modules/@groove-dev/gui/src/components/layout/activity-bar.jsx +13 -3
  51. package/node_modules/@groove-dev/gui/src/components/layout/app-shell.jsx +0 -1
  52. package/node_modules/@groove-dev/gui/src/components/layout/breadcrumb-bar.jsx +165 -47
  53. package/node_modules/@groove-dev/gui/src/components/layout/command-palette.jsx +6 -2
  54. package/node_modules/@groove-dev/gui/src/components/layout/terminal-panel.jsx +10 -9
  55. package/node_modules/@groove-dev/gui/src/components/marketplace/repo-import.jsx +9 -1
  56. package/node_modules/@groove-dev/gui/src/components/onboarding/provider-card.jsx +134 -0
  57. package/node_modules/@groove-dev/gui/src/components/onboarding/setup-wizard.jsx +819 -0
  58. package/node_modules/@groove-dev/gui/src/components/pro/pro-gate.jsx +12 -5
  59. package/node_modules/@groove-dev/gui/src/components/pro/upgrade-card.jsx +15 -8
  60. package/node_modules/@groove-dev/gui/src/components/pro/upgrade-modal.jsx +151 -0
  61. package/node_modules/@groove-dev/gui/src/components/settings/federation-activity.jsx +98 -0
  62. package/node_modules/@groove-dev/gui/src/components/settings/federation-panel.jsx +290 -0
  63. package/node_modules/@groove-dev/gui/src/components/settings/federation-peers.jsx +126 -0
  64. package/node_modules/@groove-dev/gui/src/components/settings/federation-wizard.jsx +293 -0
  65. package/node_modules/@groove-dev/gui/src/components/settings/quick-connect.jsx +110 -67
  66. package/node_modules/@groove-dev/gui/src/components/settings/remote-server-card.jsx +3 -3
  67. package/node_modules/@groove-dev/gui/src/components/settings/server-detail.jsx +310 -0
  68. package/node_modules/@groove-dev/gui/src/components/settings/server-dialog.jsx +4 -1
  69. package/node_modules/@groove-dev/gui/src/components/settings/server-list.jsx +59 -0
  70. package/node_modules/@groove-dev/gui/src/components/settings/ssh-wizard.jsx +549 -0
  71. package/node_modules/@groove-dev/gui/src/components/toys/toy-card.jsx +78 -0
  72. package/node_modules/@groove-dev/gui/src/components/toys/toy-creator.jsx +144 -0
  73. package/node_modules/@groove-dev/gui/src/components/toys/toy-launcher.jsx +187 -0
  74. package/node_modules/@groove-dev/gui/src/components/ui/toast.jsx +2 -2
  75. package/node_modules/@groove-dev/gui/src/lib/electron.js +15 -0
  76. package/node_modules/@groove-dev/gui/src/lib/format.js +1 -0
  77. package/node_modules/@groove-dev/gui/src/stores/groove.js +373 -58
  78. package/node_modules/@groove-dev/gui/src/views/agents.jsx +148 -42
  79. package/node_modules/@groove-dev/gui/src/views/editor.jsx +92 -2
  80. package/node_modules/@groove-dev/gui/src/views/federation.jsx +37 -0
  81. package/node_modules/@groove-dev/gui/src/views/marketplace.jsx +2 -42
  82. package/node_modules/@groove-dev/gui/src/views/settings.jsx +32 -132
  83. package/node_modules/@groove-dev/gui/src/views/subscription-panel.jsx +327 -0
  84. package/node_modules/@groove-dev/gui/src/views/teams.jsx +3 -3
  85. package/node_modules/@groove-dev/gui/src/views/toys.jsx +162 -0
  86. package/package.json +1 -1
  87. package/packages/daemon/src/api.js +587 -68
  88. package/packages/daemon/src/classifier.js +24 -0
  89. package/packages/daemon/src/credentials.js +12 -2
  90. package/packages/daemon/src/federation/ambassador.js +204 -0
  91. package/packages/daemon/src/federation/connection.js +359 -0
  92. package/packages/daemon/src/federation/contracts.js +112 -0
  93. package/packages/daemon/src/federation/whitelist.js +190 -0
  94. package/packages/daemon/src/federation.js +166 -7
  95. package/packages/daemon/src/index.js +172 -19
  96. package/packages/daemon/src/introducer.js +52 -7
  97. package/packages/daemon/src/journalist.js +46 -1
  98. package/packages/daemon/src/memory.js +36 -16
  99. package/packages/daemon/src/process.js +140 -23
  100. package/packages/daemon/src/providers/base.js +1 -0
  101. package/packages/daemon/src/providers/claude-code.js +1 -0
  102. package/packages/daemon/src/providers/codex.js +124 -28
  103. package/packages/daemon/src/providers/gemini.js +104 -17
  104. package/packages/daemon/src/providers/index.js +17 -0
  105. package/packages/daemon/src/registry.js +10 -1
  106. package/packages/daemon/src/rotator.js +93 -30
  107. package/packages/daemon/src/skills.js +33 -3
  108. package/packages/daemon/src/terminal-pty.js +9 -1
  109. package/packages/daemon/src/tool-executor.js +11 -5
  110. package/packages/daemon/src/toys.js +69 -0
  111. package/packages/daemon/src/tunnel-manager.js +24 -5
  112. package/packages/daemon/templates/toys-catalog.json +242 -0
  113. package/packages/gui/dist/assets/index-BglPgjlu.js +8607 -0
  114. package/packages/gui/dist/assets/index-CGcwmmJv.css +1 -0
  115. package/packages/gui/dist/index.html +3 -2
  116. package/packages/gui/index.html +1 -0
  117. package/packages/gui/src/app.css +7 -0
  118. package/packages/gui/src/app.jsx +37 -10
  119. package/packages/gui/src/components/agents/agent-chat.jsx +21 -31
  120. package/packages/gui/src/components/agents/agent-config.jsx +11 -6
  121. package/packages/gui/src/components/agents/agent-feed.jsx +2 -2
  122. package/packages/gui/src/components/agents/spawn-wizard.jsx +42 -1
  123. package/packages/gui/src/components/editor/breadcrumbs.jsx +30 -0
  124. package/packages/gui/src/components/editor/code-editor.jsx +33 -2
  125. package/packages/gui/src/components/editor/editor-status-bar.jsx +26 -0
  126. package/packages/gui/src/components/editor/editor-tabs.jsx +113 -34
  127. package/packages/gui/src/components/editor/goto-line.jsx +35 -0
  128. package/packages/gui/src/components/editor/terminal.jsx +12 -6
  129. package/packages/gui/src/components/layout/activity-bar.jsx +13 -3
  130. package/packages/gui/src/components/layout/app-shell.jsx +0 -1
  131. package/packages/gui/src/components/layout/breadcrumb-bar.jsx +165 -47
  132. package/packages/gui/src/components/layout/command-palette.jsx +6 -2
  133. package/packages/gui/src/components/layout/terminal-panel.jsx +10 -9
  134. package/packages/gui/src/components/marketplace/repo-import.jsx +9 -1
  135. package/packages/gui/src/components/onboarding/provider-card.jsx +134 -0
  136. package/packages/gui/src/components/onboarding/setup-wizard.jsx +819 -0
  137. package/packages/gui/src/components/pro/pro-gate.jsx +12 -5
  138. package/packages/gui/src/components/pro/upgrade-card.jsx +15 -8
  139. package/packages/gui/src/components/pro/upgrade-modal.jsx +151 -0
  140. package/packages/gui/src/components/settings/federation-activity.jsx +98 -0
  141. package/packages/gui/src/components/settings/federation-panel.jsx +290 -0
  142. package/packages/gui/src/components/settings/federation-peers.jsx +126 -0
  143. package/packages/gui/src/components/settings/federation-wizard.jsx +293 -0
  144. package/packages/gui/src/components/settings/quick-connect.jsx +110 -67
  145. package/packages/gui/src/components/settings/remote-server-card.jsx +3 -3
  146. package/packages/gui/src/components/settings/server-detail.jsx +310 -0
  147. package/packages/gui/src/components/settings/server-dialog.jsx +4 -1
  148. package/packages/gui/src/components/settings/server-list.jsx +59 -0
  149. package/packages/gui/src/components/settings/ssh-wizard.jsx +549 -0
  150. package/packages/gui/src/components/toys/toy-card.jsx +78 -0
  151. package/packages/gui/src/components/toys/toy-creator.jsx +144 -0
  152. package/packages/gui/src/components/toys/toy-launcher.jsx +187 -0
  153. package/packages/gui/src/components/ui/toast.jsx +2 -2
  154. package/packages/gui/src/lib/electron.js +15 -0
  155. package/packages/gui/src/lib/format.js +1 -0
  156. package/packages/gui/src/stores/groove.js +373 -58
  157. package/packages/gui/src/views/agents.jsx +148 -42
  158. package/packages/gui/src/views/editor.jsx +92 -2
  159. package/packages/gui/src/views/federation.jsx +37 -0
  160. package/packages/gui/src/views/marketplace.jsx +2 -42
  161. package/packages/gui/src/views/settings.jsx +32 -132
  162. package/packages/gui/src/views/subscription-panel.jsx +327 -0
  163. package/packages/gui/src/views/teams.jsx +3 -3
  164. package/packages/gui/src/views/toys.jsx +162 -0
  165. package/plans/chat-persistence-refactor.md +154 -0
  166. package/node_modules/@groove-dev/gui/dist/assets/index-BE6lYcd7.css +0 -1
  167. package/node_modules/@groove-dev/gui/dist/assets/index-zdzOLAZM.js +0 -677
  168. package/packages/gui/dist/assets/index-BE6lYcd7.css +0 -1
  169. package/packages/gui/dist/assets/index-zdzOLAZM.js +0 -677
@@ -258,6 +258,9 @@ MANDATORY RULES:
258
258
 
259
259
  IMPORTANT: Do not use markdown formatting like ** or ### in your output. Write in plain text with clean formatting. Use line breaks, dashes, and indentation for structure.
260
260
 
261
+ `,
262
+ ambassador: `You are an Ambassador agent — the sole bridge between this Groove daemon and a federated peer. You communicate with the remote Ambassador using diplomatic pouch messages. You can read the local codebase for context. Your ONLY outbound channel is the federation pouch system. When you receive work from your local team, package it as a task-request and send it to your peer. When you receive results from your peer, deliver them to your local team. Report results to your local team. Priority order: deliver to the planner agent first. If no planner exists, deliver to the fullstack agent. If neither exists, broadcast to all running agents. Use the GROOVE coordination API at http://localhost:31415 to discover running agents (GET /api/agents) and instruct them (POST /api/agents/:id/instruct). You do NOT write code or modify files. You translate, negotiate, and coordinate.
263
+
261
264
  `,
262
265
  };
263
266
 
@@ -282,6 +285,24 @@ export class ProcessManager {
282
285
  async spawn(config) {
283
286
  const { registry, locks, introducer } = this.daemon;
284
287
 
288
+ // Validate workingDir is within the project directory
289
+ if (config.workingDir) {
290
+ const resolved = resolve(config.workingDir);
291
+ const projResolved = resolve(this.daemon?.projectDir || process.cwd());
292
+ if (!resolved.startsWith(projResolved)) {
293
+ throw new Error('workingDir must be within project directory');
294
+ }
295
+ }
296
+
297
+ // Ambassador spawn guard: one ambassador per federation peer
298
+ if (config.role === 'ambassador') {
299
+ const peerId = config.peerId || config.metadata?.peerId;
300
+ if (!peerId) throw new Error('Ambassador agents require a peerId');
301
+ if (this.daemon.federation?.ambassadors?.hasAmbassadorForPeer(peerId)) {
302
+ throw new Error(`Ambassador already exists for peer ${peerId}`);
303
+ }
304
+ }
305
+
285
306
  // Clean stale recommended-team.json when spawning a new planner
286
307
  if (config.role === 'planner') {
287
308
  const dirs = [this.daemon.grooveDir];
@@ -350,6 +371,15 @@ export class ProcessManager {
350
371
  locks.register(agent.id, agent.scope);
351
372
  }
352
373
 
374
+ // Register ambassador with federation system
375
+ if (config.role === 'ambassador') {
376
+ const peerId = config.peerId || config.metadata?.peerId;
377
+ if (peerId && this.daemon.federation?.ambassadors) {
378
+ this.daemon.federation.ambassadors.registerAmbassador(peerId, agent.id);
379
+ registry.update(agent.id, { metadata: { ...agent.metadata, peerId } });
380
+ }
381
+ }
382
+
353
383
  // For slides-role agents, write the baked-in layout engine into the working
354
384
  // directory. The agent imports it as `./groove-slides.cjs`. Core layout rules
355
385
  // (Y-cursor, fitFontSize, collision/bounds/wrap gates) ship with Groove so
@@ -393,7 +423,7 @@ export class ProcessManager {
393
423
  }
394
424
 
395
425
  // Generate introduction context (team awareness + negotiation)
396
- const introContext = introducer.generateContext(agent, { taskNegotiation });
426
+ const introContext = introducer.generateContext(agent, { taskNegotiation, hasTask: !!config.prompt });
397
427
 
398
428
  // Track cold-start savings — agent gets context from planner/journalist/team
399
429
  // instead of exploring the codebase from scratch
@@ -557,6 +587,7 @@ For normal file edits within your scope, proceed without review.
557
587
  const files = this.daemon.journalist?.getAgentFiles(agent) || [];
558
588
  if (files.length > 0) this._triggerIdleQC(agent);
559
589
  this._processHandoffs(agent);
590
+ this._writeCompletionHandoff(agent);
560
591
  }
561
592
  });
562
593
 
@@ -595,13 +626,14 @@ For normal file edits within your scope, proceed without review.
595
626
  const spawnLine = `[${new Date().toISOString()}] GROOVE spawning: ${command} [${safeArgs.length} args]\n`;
596
627
  logStream.write(spawnLine);
597
628
 
598
- // Inject ALL stored API keys agents may need keys from other providers
599
- // (e.g., an EA agent on claude-code may need OPENAI_API_KEY for a third-party tool)
629
+ // Inject only the API key for the agent's own provider least-privilege principle
630
+ // (avoids leaking unrelated credentials into agent environments)
600
631
  if (this.daemon.credentials) {
601
- for (const cp of this.daemon.credentials.listProviders()) {
602
- const meta = getProvider(cp.provider);
632
+ const agentProvider = agent.provider || config.provider;
633
+ if (agentProvider) {
634
+ const meta = getProvider(agentProvider);
603
635
  if (meta?.constructor?.envKey) {
604
- const storedKey = this.daemon.credentials.getKey(cp.provider);
636
+ const storedKey = this.daemon.credentials.getKey(agentProvider);
605
637
  if (storedKey) {
606
638
  env[meta.constructor.envKey] = storedKey;
607
639
  }
@@ -700,6 +732,12 @@ For normal file edits within your scope, proceed without review.
700
732
  this.daemon.integrations.refreshMcpJson();
701
733
  }
702
734
 
735
+ // Extract recommended-team.json from planner text output if it wasn't written to disk.
736
+ // Non-Claude providers (Codex, Gemini) may embed the JSON in text rather than using Write.
737
+ if (finalStatus === 'completed' && agent.role === 'planner') {
738
+ this._extractRecommendedTeam(agent, logPath);
739
+ }
740
+
703
741
  // Trigger journalist synthesis immediately on completion so the project
704
742
  // map is fresh for the next agent that spawns (don't wait for 120s cycle)
705
743
  if (finalStatus === 'completed' && this.daemon.journalist) {
@@ -714,8 +752,8 @@ For normal file edits within your scope, proceed without review.
714
752
  if (finalStatus === 'completed') {
715
753
  const files = this.daemon.journalist?.getAgentFiles(agent) || [];
716
754
  if (files.length > 0) this._triggerIdleQC(agent);
717
- // Process cross-scope handoff requests from this agent
718
755
  this._processHandoffs(agent);
756
+ this._writeCompletionHandoff(agent);
719
757
  }
720
758
 
721
759
  // Update Layer 7 specialization profile for this agent's session
@@ -783,8 +821,12 @@ For normal file edits within your scope, proceed without review.
783
821
 
784
822
  for (const line of complete.split('\n')) {
785
823
  if (!line) continue;
786
- const output = provider.parseOutput(line);
787
- if (output) this._handleAgentOutput(agentId, output);
824
+ try {
825
+ const output = provider.parseOutput(line);
826
+ if (output) this._handleAgentOutput(agentId, output);
827
+ } catch (err) {
828
+ console.error(`[Groove] parseOutput error for ${agentId}: ${err.message}`);
829
+ }
788
830
  }
789
831
  }
790
832
 
@@ -849,6 +891,27 @@ For normal file edits within your scope, proceed without review.
849
891
  this.daemon.broadcast({ type: 'agent:output', agentId, data: output });
850
892
  }
851
893
 
894
+ _extractRecommendedTeam(agent, logPath) {
895
+ try {
896
+ const workDir = agent.workingDir || this.daemon.projectDir;
897
+ const grooveDir = resolve(workDir, '.groove');
898
+ const targetPath = resolve(grooveDir, 'recommended-team.json');
899
+
900
+ if (existsSync(targetPath)) return;
901
+
902
+ const log = readFileSync(logPath, 'utf8');
903
+ const match = log.match(/\{[\s\S]*?"agents"\s*:\s*\[[\s\S]*?\]\s*\}/);
904
+ if (!match) return;
905
+
906
+ let parsed;
907
+ try { parsed = JSON.parse(match[0]); } catch { return; }
908
+ if (!parsed || !Array.isArray(parsed.agents) || parsed.agents.length === 0) return;
909
+
910
+ if (!existsSync(grooveDir)) mkdirSync(grooveDir, { recursive: true });
911
+ writeFileSync(targetPath, JSON.stringify(parsed, null, 2));
912
+ } catch { /* best effort */ }
913
+ }
914
+
852
915
  /**
853
916
  * Check if a completed/crashed agent was the last phase 1 agent in a team.
854
917
  * If so, auto-spawn the phase 2 (QC/finisher) agents.
@@ -993,6 +1056,46 @@ For normal file edits within your scope, proceed without review.
993
1056
  });
994
1057
  }
995
1058
 
1059
+ _writeCompletionHandoff(agent) {
1060
+ if (!this.daemon.memory || !this.daemon.journalist) return;
1061
+ try {
1062
+ const agentData = this.daemon.registry.get(agent.id);
1063
+ const filteredLogs = this.daemon.journalist.collectFilteredLogs([agent]);
1064
+ const agentLog = filteredLogs[agent.id];
1065
+ const entries = agentLog?.entries || [];
1066
+ const files = this.daemon.journalist.getAgentFiles(agent) || [];
1067
+
1068
+ const toolSummary = entries
1069
+ .filter(e => e.type === 'tool')
1070
+ .map(e => `- ${e.tool}: ${e.input}`)
1071
+ .slice(-15)
1072
+ .join('\n');
1073
+
1074
+ const errorSummary = entries
1075
+ .filter(e => e.type === 'error')
1076
+ .map(e => `- ${e.text}`)
1077
+ .slice(-5)
1078
+ .join('\n');
1079
+
1080
+ const brief = [
1081
+ `Agent ${agent.name} (${agent.role}) completed.`,
1082
+ agent.prompt ? `Task: ${agent.prompt.slice(0, 300)}` : '',
1083
+ files.length > 0 ? `\nFiles modified:\n${files.slice(0, 15).map(f => '- ' + f).join('\n')}` : '',
1084
+ toolSummary ? `\nRecent actions:\n${toolSummary}` : '',
1085
+ errorSummary ? `\nErrors encountered:\n${errorSummary}` : '',
1086
+ ].filter(Boolean).join('\n');
1087
+
1088
+ this.daemon.memory.appendHandoffBrief(agent.role, {
1089
+ timestamp: new Date().toISOString(),
1090
+ agentId: agent.id,
1091
+ reason: 'completed',
1092
+ oldTokens: agentData?.tokensUsed || 0,
1093
+ contextUsage: agentData?.contextUsage || 0,
1094
+ brief: brief.slice(0, 4000),
1095
+ }, agent.workingDir);
1096
+ } catch { /* best-effort */ }
1097
+ }
1098
+
996
1099
  /**
997
1100
  * Process handoff files in .groove/handoffs/.
998
1101
  * Agents write handoff requests when they need cross-scope work from a teammate.
@@ -1002,6 +1105,9 @@ For normal file edits within your scope, proceed without review.
1002
1105
  const handoffsDir = resolve(this.daemon.grooveDir, 'handoffs');
1003
1106
  if (!existsSync(handoffsDir)) return;
1004
1107
 
1108
+ const MAX_HANDOFFS_PER_ROLE = 3;
1109
+ if (!this.daemon._handoffCounts) this.daemon._handoffCounts = new Map();
1110
+
1005
1111
  const registry = this.daemon.registry;
1006
1112
  let files;
1007
1113
  try { files = readdirSync(handoffsDir); } catch { return; }
@@ -1011,6 +1117,14 @@ For normal file edits within your scope, proceed without review.
1011
1117
  const targetRole = file.replace(/\.md$/, '');
1012
1118
  const filePath = resolve(handoffsDir, file);
1013
1119
 
1120
+ const roleKey = `${sourceAgent.teamId}:${targetRole}`;
1121
+ const count = this.daemon._handoffCounts.get(roleKey) || 0;
1122
+ if (count >= MAX_HANDOFFS_PER_ROLE) {
1123
+ console.warn(`[Groove] Handoff to ${targetRole} skipped — cycle cap reached (${count}/${MAX_HANDOFFS_PER_ROLE})`);
1124
+ try { unlinkSync(filePath); } catch {}
1125
+ continue;
1126
+ }
1127
+
1014
1128
  let content;
1015
1129
  try { content = readFileSync(filePath, 'utf8').trim(); } catch { continue; }
1016
1130
  if (!content) { try { unlinkSync(filePath); } catch {} continue; }
@@ -1029,6 +1143,8 @@ For normal file edits within your scope, proceed without review.
1029
1143
  continue;
1030
1144
  }
1031
1145
 
1146
+ this.daemon._handoffCounts.set(roleKey, count + 1);
1147
+
1032
1148
  // Wake the target agent with the handoff request
1033
1149
  const message = `Cross-scope handoff from ${sourceAgent.name} (${sourceAgent.role}):\n\n${content}`;
1034
1150
  this.daemon.processes.resume(target.id, message).then((newAgent) => {
@@ -1072,13 +1188,12 @@ For normal file edits within your scope, proceed without review.
1072
1188
  const config = { ...agent };
1073
1189
  const sessionId = agent.sessionId;
1074
1190
 
1075
- // Kill if still running, or remove if dead
1191
+ // Stop if running, then remove old entry so we can re-register with same name
1076
1192
  if (this.handles.has(agentId)) {
1077
1193
  await this.kill(agentId);
1078
- } else {
1079
- locks.release(agentId);
1080
- registry.remove(agentId);
1081
1194
  }
1195
+ registry.remove(agentId);
1196
+ locks.release(agentId);
1082
1197
 
1083
1198
  // Build resume command
1084
1199
  const { command, args, env } = provider.buildResumeCommand(sessionId, message, config.model);
@@ -1199,11 +1314,19 @@ For normal file edits within your scope, proceed without review.
1199
1314
 
1200
1315
  async kill(agentId) {
1201
1316
  this.peakContextUsage.delete(agentId);
1317
+
1318
+ // Unregister ambassador if this agent was one
1319
+ const agent = this.daemon.registry.get(agentId);
1320
+ if (agent?.role === 'ambassador' && agent?.metadata?.peerId) {
1321
+ this.daemon.federation?.ambassadors?.unregisterAmbassador(agent.metadata.peerId);
1322
+ }
1323
+
1202
1324
  const handle = this.handles.get(agentId);
1203
1325
 
1204
1326
  if (!handle) {
1205
- // Not running — just clean up registry
1206
- this.daemon.registry.remove(agentId);
1327
+ // Not running — release locks but keep agent in registry.
1328
+ // Spawn's exit handler already set the terminal status.
1329
+ // Callers that want removal must call registry.remove() explicitly.
1207
1330
  this.daemon.locks.release(agentId);
1208
1331
  return;
1209
1332
  }
@@ -1213,24 +1336,19 @@ For normal file edits within your scope, proceed without review.
1213
1336
  // Agent loop path — clean async stop
1214
1337
  if (loop) {
1215
1338
  await loop.stop();
1216
- // Exit handler already fired; finish cleanup
1217
- this.handles.delete(agentId);
1218
- this.daemon.registry.remove(agentId);
1339
+ // Loop exit handler already updated status and cleaned handles
1219
1340
  this.daemon.locks.release(agentId);
1220
1341
  return;
1221
1342
  }
1222
1343
 
1223
- // CLI process path
1344
+ // CLI process path — spawn's exit handler sets status='killed' for SIGTERM
1224
1345
  return new Promise((resolveKill) => {
1225
- // Give the process 5s to exit gracefully
1226
1346
  const forceTimer = setTimeout(() => {
1227
1347
  try { proc.kill('SIGKILL'); } catch {}
1228
1348
  }, 5000);
1229
1349
 
1230
1350
  proc.on('exit', () => {
1231
1351
  clearTimeout(forceTimer);
1232
- this.handles.delete(agentId);
1233
- this.daemon.registry.remove(agentId);
1234
1352
  this.daemon.locks.release(agentId);
1235
1353
  resolveKill();
1236
1354
  });
@@ -1241,7 +1359,6 @@ For normal file edits within your scope, proceed without review.
1241
1359
  // Already dead
1242
1360
  clearTimeout(forceTimer);
1243
1361
  this.handles.delete(agentId);
1244
- this.daemon.registry.remove(agentId);
1245
1362
  this.daemon.locks.release(agentId);
1246
1363
  resolveKill();
1247
1364
  }
@@ -6,6 +6,7 @@ export class Provider {
6
6
  static displayName = 'Base Provider';
7
7
  static command = '';
8
8
  static authType = 'none'; // 'subscription' | 'api-key' | 'local' | 'none'
9
+ static managesOwnContext = false; // true if provider compacts context internally (e.g. Claude Code)
9
10
  static models = [];
10
11
 
11
12
  static isInstalled() {
@@ -11,6 +11,7 @@ export class ClaudeCodeProvider extends Provider {
11
11
  static displayName = 'Claude Code';
12
12
  static command = 'claude';
13
13
  static authType = 'subscription';
14
+ static managesOwnContext = true; // Claude Code compacts context internally (~25-37% → 2-8%)
14
15
  static models = [
15
16
  { id: 'claude-opus-4-6', name: 'Claude Opus 4.6', tier: 'heavy', contextWindow: 1_000_000 },
16
17
  { id: 'claude-sonnet-4-6', name: 'Claude Sonnet 4.6', tier: 'medium', contextWindow: 200_000 },
@@ -16,12 +16,12 @@ export class CodexProvider extends Provider {
16
16
  // Auth hint — Codex uses its own auth system, not just env vars
17
17
  static authHint = 'Codex requires `codex login` — run: echo "YOUR_KEY" | codex login --with-api-key';
18
18
  static models = [
19
- { id: 'gpt-5.4-pro', name: 'GPT-5.4 Pro', tier: 'heavy', pricing: { input: 0.015, output: 0.06 } },
20
- { id: 'gpt-5.4', name: 'GPT-5.4', tier: 'heavy', pricing: { input: 0.005, output: 0.02 } },
21
- { id: 'gpt-5.4-mini', name: 'GPT-5.4 Mini', tier: 'medium', pricing: { input: 0.001, output: 0.004 } },
22
- { id: 'gpt-5.4-nano', name: 'GPT-5.4 Nano', tier: 'light', pricing: { input: 0.0004, output: 0.0016 } },
23
- { id: 'gpt-5-mini', name: 'GPT-5 Mini', tier: 'medium', pricing: { input: 0.0005, output: 0.002 } },
24
- { id: 'gpt-5-nano', name: 'GPT-5 Nano', tier: 'light', pricing: { input: 0.0001, output: 0.0004 } },
19
+ { id: 'gpt-5.4-pro', name: 'GPT-5.4 Pro', tier: 'heavy', maxContext: 200000, pricing: { input: 0.015, output: 0.06 } },
20
+ { id: 'gpt-5.4', name: 'GPT-5.4', tier: 'heavy', maxContext: 200000, pricing: { input: 0.005, output: 0.02 } },
21
+ { id: 'gpt-5.4-mini', name: 'GPT-5.4 Mini', tier: 'medium', maxContext: 200000, pricing: { input: 0.001, output: 0.004 } },
22
+ { id: 'gpt-5.4-nano', name: 'GPT-5.4 Nano', tier: 'light', maxContext: 200000, pricing: { input: 0.0004, output: 0.0016 } },
23
+ { id: 'gpt-5-mini', name: 'GPT-5 Mini', tier: 'medium', maxContext: 200000, pricing: { input: 0.0005, output: 0.002 } },
24
+ { id: 'gpt-5-nano', name: 'GPT-5 Nano', tier: 'light', maxContext: 200000, pricing: { input: 0.0001, output: 0.0004 } },
25
25
  ];
26
26
 
27
27
  static isInstalled() {
@@ -83,16 +83,19 @@ export class CodexProvider extends Provider {
83
83
  }
84
84
 
85
85
  buildSpawnCommand(agent) {
86
- // Use 'codex exec' for non-interactive (headless) operation
87
86
  const args = ['exec'];
88
87
 
89
88
  if (agent.model) args.push('--model', agent.model);
90
89
 
91
- // Full autonomous operation — no approval prompts, no sandbox
90
+ args.push('--json');
92
91
  args.push('--dangerously-bypass-approvals-and-sandbox');
93
92
 
93
+ if (agent.workingDir) args.push('-C', agent.workingDir);
94
+
94
95
  if (agent.prompt) args.push(agent.prompt);
95
96
 
97
+ this._currentModel = agent.model;
98
+
96
99
  return {
97
100
  command: 'codex',
98
101
  args,
@@ -101,7 +104,7 @@ export class CodexProvider extends Provider {
101
104
  }
102
105
 
103
106
  buildHeadlessCommand(prompt, model) {
104
- const args = ['exec', prompt];
107
+ const args = ['exec', '--json', prompt];
105
108
  if (model) args.push('--model', model);
106
109
  return { command: 'codex', args, env: {} };
107
110
  }
@@ -120,33 +123,126 @@ export class CodexProvider extends Provider {
120
123
  }
121
124
 
122
125
  parseOutput(line) {
123
- // Codex outputs plain text and stderr logging
124
126
  const trimmed = line.trim();
125
127
  if (!trimmed) return null;
126
128
 
127
- // Try to parse JSON (codex may output structured data in some modes)
129
+ let event;
128
130
  try {
129
- const data = JSON.parse(trimmed);
130
- if (data.usage?.total_tokens) {
131
- const tokens = data.usage.total_tokens;
131
+ event = JSON.parse(trimmed);
132
+ } catch {
133
+ return { type: 'activity', data: trimmed };
134
+ }
135
+
136
+ switch (event.type) {
137
+ case 'thread.started':
138
+ return { type: 'activity', subtype: 'assistant', sessionId: event.thread_id, data: [{ type: 'text', text: '' }] };
139
+
140
+ case 'turn.started':
141
+ return null;
142
+
143
+ case 'item.started': {
144
+ const item = event.item || {};
145
+ if (item.type === 'command_execution') {
146
+ return {
147
+ type: 'activity', subtype: 'assistant',
148
+ data: [{ type: 'tool_use', id: item.id || 'exec', name: 'Bash', input: { command: item.command } }],
149
+ };
150
+ }
151
+ if (item.type === 'todo_list') {
152
+ const steps = (item.items || []).map((s) => s.text).join(', ');
153
+ return {
154
+ type: 'activity', subtype: 'assistant',
155
+ data: [{ type: 'tool_use', id: item.id || 'plan', name: 'Plan', input: { steps } }],
156
+ };
157
+ }
158
+ if (item.type === 'file_edit' || item.type === 'file_write') {
159
+ return {
160
+ type: 'activity', subtype: 'assistant',
161
+ data: [{ type: 'tool_use', id: item.id || 'edit', name: item.type === 'file_write' ? 'Write' : 'Edit', input: { path: item.path || item.file || '' } }],
162
+ };
163
+ }
164
+ if (item.type === 'file_read') {
165
+ return {
166
+ type: 'activity', subtype: 'assistant',
167
+ data: [{ type: 'tool_use', id: item.id || 'read', name: 'Read', input: { path: item.path || item.file || '' } }],
168
+ };
169
+ }
132
170
  return {
133
- type: 'activity', data: trimmed, tokensUsed: tokens,
134
- estimatedCostUsd: CodexProvider.estimateCost(tokens, data.model),
135
- costSource: 'estimated',
171
+ type: 'activity', subtype: 'assistant',
172
+ data: [{ type: 'tool_use', id: item.id || 'tool', name: item.type || 'Tool', input: {} }],
136
173
  };
137
174
  }
138
- } catch { /* plain text */ }
139
175
 
140
- // Estimate tokens from text length (~4 chars per token)
141
- // Not perfect but gives visibility into activity and burn rate
142
- const estimatedTokens = Math.ceil(trimmed.length / 4);
176
+ case 'item.completed': {
177
+ const item = event.item || {};
178
+ if (item.type === 'agent_message') {
179
+ return {
180
+ type: 'activity', subtype: 'assistant',
181
+ data: [{ type: 'text', text: item.text || '' }],
182
+ };
183
+ }
184
+ if (item.type === 'command_execution') {
185
+ const output = (item.aggregated_output || '').slice(0, 2000);
186
+ return {
187
+ type: 'activity', subtype: 'assistant',
188
+ data: [
189
+ { type: 'tool_use', id: item.id || 'exec', name: 'Bash', input: { command: item.command } },
190
+ ...(output ? [{ type: 'text', text: output }] : []),
191
+ ],
192
+ };
193
+ }
194
+ if (item.type === 'todo_list') {
195
+ const steps = (item.items || []).map((s) => `${s.completed ? '✓' : '○'} ${s.text}`).join('\n');
196
+ return {
197
+ type: 'activity', subtype: 'assistant',
198
+ data: [{ type: 'text', text: steps }],
199
+ };
200
+ }
201
+ if (item.type === 'file_edit' || item.type === 'file_write' || item.type === 'file_read') {
202
+ return {
203
+ type: 'activity', subtype: 'assistant',
204
+ data: [{ type: 'tool_use', id: item.id || 'file', name: item.type === 'file_read' ? 'Read' : item.type === 'file_write' ? 'Write' : 'Edit', input: { path: item.path || item.file || '' } }],
205
+ };
206
+ }
207
+ return null;
208
+ }
143
209
 
144
- return {
145
- type: 'activity',
146
- data: trimmed,
147
- tokensUsed: estimatedTokens,
148
- estimatedCostUsd: 0, // Can't estimate without knowing the model here
149
- costSource: 'estimated',
150
- };
210
+ case 'turn.completed': {
211
+ const usage = event.usage;
212
+ if (!usage) return null;
213
+
214
+ const inputTokens = usage.input_tokens || 0;
215
+ const outputTokens = usage.output_tokens || 0;
216
+ const cachedTokens = usage.cached_input_tokens || 0;
217
+ const totalTokens = inputTokens + outputTokens;
218
+
219
+ const model = CodexProvider.models.find((m) => m.id === this._currentModel);
220
+ const pricing = model?.pricing;
221
+ const maxContext = model?.maxContext || 200000;
222
+
223
+ let estimatedCostUsd = 0;
224
+ if (pricing) {
225
+ const newInput = inputTokens - cachedTokens;
226
+ estimatedCostUsd = (newInput / 1000) * pricing.input
227
+ + (cachedTokens / 1000) * pricing.input * 0.5
228
+ + (outputTokens / 1000) * pricing.output;
229
+ }
230
+
231
+ return {
232
+ type: 'activity', subtype: 'assistant',
233
+ data: [{ type: 'text', text: '' }],
234
+ tokensUsed: totalTokens,
235
+ inputTokens,
236
+ outputTokens,
237
+ cacheReadTokens: cachedTokens,
238
+ contextUsage: inputTokens / maxContext,
239
+ estimatedCostUsd,
240
+ costSource: pricing ? 'calculated' : 'estimated',
241
+ };
242
+ }
243
+
244
+ default:
245
+ return null;
246
+ }
151
247
  }
152
248
  }
@@ -11,11 +11,11 @@ export class GeminiProvider extends Provider {
11
11
  static authType = 'api-key';
12
12
  static envKey = 'GEMINI_API_KEY';
13
13
  static models = [
14
- { id: 'gemini-3.1-pro-preview', name: 'Gemini 3.1 Pro', tier: 'heavy', pricing: { input: 0.00125, output: 0.01 } },
15
- { id: 'gemini-3-flash-preview', name: 'Gemini 3 Flash', tier: 'medium', pricing: { input: 0.00015, output: 0.0006 } },
16
- { id: 'gemini-3.1-flash-lite-preview', name: 'Gemini 3.1 Flash Lite', tier: 'light', pricing: { input: 0.000075, output: 0.0003 } },
17
- { id: 'gemini-2.5-pro', name: 'Gemini 2.5 Pro', tier: 'heavy', pricing: { input: 0.00125, output: 0.01 } },
18
- { id: 'gemini-2.5-flash', name: 'Gemini 2.5 Flash', tier: 'medium', pricing: { input: 0.00015, output: 0.0006 } },
14
+ { id: 'gemini-3.1-pro-preview', name: 'Gemini 3.1 Pro', tier: 'heavy', maxContext: 1000000, pricing: { input: 0.00125, output: 0.01 } },
15
+ { id: 'gemini-3-flash-preview', name: 'Gemini 3 Flash', tier: 'medium', maxContext: 1000000, pricing: { input: 0.00015, output: 0.0006 } },
16
+ { id: 'gemini-3.1-flash-lite-preview', name: 'Gemini 3.1 Flash Lite', tier: 'light', maxContext: 1000000, pricing: { input: 0.000075, output: 0.0003 } },
17
+ { id: 'gemini-2.5-pro', name: 'Gemini 2.5 Pro', tier: 'heavy', maxContext: 1000000, pricing: { input: 0.00125, output: 0.01 } },
18
+ { id: 'gemini-2.5-flash', name: 'Gemini 2.5 Flash', tier: 'medium', maxContext: 1000000, pricing: { input: 0.00015, output: 0.0006 } },
19
19
  ];
20
20
 
21
21
  static isInstalled() {
@@ -36,12 +36,12 @@ export class GeminiProvider extends Provider {
36
36
 
37
37
  if (agent.model) args.push('--model', agent.model);
38
38
 
39
- // YOLO mode — auto-approve all tool calls (file writes, shell commands)
40
- // Without this, Gemini in headless mode can only output text
41
39
  args.push('--yolo');
40
+ args.push('--output-format', 'stream-json');
41
+ args.push('-p', '');
42
+
43
+ this._currentModel = agent.model;
42
44
 
43
- // Pass prompt via stdin to avoid OS arg length limits
44
- // (intro context + role prompt + skill content can be very long)
45
45
  return {
46
46
  command: 'gemini',
47
47
  args,
@@ -51,7 +51,7 @@ export class GeminiProvider extends Provider {
51
51
  }
52
52
 
53
53
  buildHeadlessCommand(prompt, model) {
54
- const args = ['-p', prompt];
54
+ const args = ['--output-format', 'stream-json', '-p', prompt];
55
55
  if (model) args.push('--model', model);
56
56
  return { command: 'gemini', args, env: {} };
57
57
  }
@@ -71,12 +71,99 @@ export class GeminiProvider extends Provider {
71
71
  parseOutput(line) {
72
72
  const trimmed = line.trim();
73
73
  if (!trimmed) return null;
74
- // Estimate tokens from output length (~4 chars per token)
75
- const estimatedTokens = Math.ceil(trimmed.length / 4);
76
- return {
77
- type: 'activity', data: trimmed, tokensUsed: estimatedTokens,
78
- estimatedCostUsd: 0,
79
- costSource: 'estimated',
80
- };
74
+
75
+ let event;
76
+ try {
77
+ event = JSON.parse(trimmed);
78
+ } catch {
79
+ return { type: 'activity', data: trimmed };
80
+ }
81
+
82
+ switch (event.type) {
83
+ case 'agent_start':
84
+ return { type: 'activity', subtype: 'assistant', sessionId: event.streamId, data: [{ type: 'text', text: '' }] };
85
+
86
+ case 'session_update':
87
+ return null;
88
+
89
+ case 'message': {
90
+ if (event.role === 'user') return null;
91
+ const raw = event.content;
92
+ const parts = Array.isArray(raw) ? raw : (typeof raw === 'string' ? [{ text: raw }] : raw ? [raw] : []);
93
+ const blocks = parts.map((p) => {
94
+ if (p.type === 'thought') return { type: 'text', text: p.thought || '' };
95
+ return { type: 'text', text: p.text || '' };
96
+ }).filter((b) => b.text);
97
+ if (!blocks.length) return null;
98
+ return { type: 'activity', subtype: 'assistant', data: blocks };
99
+ }
100
+
101
+ case 'tool_request': {
102
+ const toolName = (event.name || '').includes('shell') || (event.name || '').includes('exec')
103
+ ? 'Bash' : event.name || 'Tool';
104
+ const input = event.name === 'Bash' || (event.name || '').includes('shell')
105
+ ? { command: typeof event.args === 'string' ? event.args : JSON.stringify(event.args || {}) }
106
+ : (event.args || {});
107
+ return {
108
+ type: 'activity', subtype: 'assistant',
109
+ data: [{ type: 'tool_use', id: event.requestId || 'tool', name: toolName, input }],
110
+ };
111
+ }
112
+
113
+ case 'tool_response': {
114
+ const rawContent = event.content;
115
+ const contentParts = Array.isArray(rawContent) ? rawContent : (typeof rawContent === 'string' ? [{ text: rawContent }] : rawContent ? [rawContent] : []);
116
+ const content = contentParts.map((p) => p.text || '').join('').slice(0, 2000);
117
+ const toolName = (event.name || '').includes('shell') || (event.name || '').includes('exec')
118
+ ? 'Bash' : event.name || 'Tool';
119
+ return {
120
+ type: 'activity', subtype: 'assistant',
121
+ data: [
122
+ { type: 'tool_use', id: event.requestId || 'tool', name: toolName, input: {} },
123
+ ...(content ? [{ type: 'text', text: content }] : []),
124
+ ],
125
+ };
126
+ }
127
+
128
+ case 'usage': {
129
+ const inputTokens = event.inputTokens || 0;
130
+ const outputTokens = event.outputTokens || 0;
131
+ const cachedTokens = event.cachedTokens || 0;
132
+ const totalTokens = inputTokens + outputTokens;
133
+
134
+ const model = GeminiProvider.models.find((m) => m.id === this._currentModel);
135
+ const pricing = model?.pricing;
136
+ const maxContext = model?.maxContext || 1000000;
137
+
138
+ let estimatedCostUsd = 0;
139
+ if (pricing) {
140
+ const newInput = inputTokens - cachedTokens;
141
+ estimatedCostUsd = (newInput / 1000) * pricing.input
142
+ + (cachedTokens / 1000) * pricing.input * 0.5
143
+ + (outputTokens / 1000) * pricing.output;
144
+ }
145
+
146
+ return {
147
+ type: 'activity', subtype: 'assistant',
148
+ data: [{ type: 'text', text: '' }],
149
+ tokensUsed: totalTokens,
150
+ inputTokens,
151
+ outputTokens,
152
+ cacheReadTokens: cachedTokens,
153
+ contextUsage: inputTokens / maxContext,
154
+ estimatedCostUsd,
155
+ costSource: pricing ? 'calculated' : 'estimated',
156
+ };
157
+ }
158
+
159
+ case 'agent_end':
160
+ return { type: 'activity', subtype: 'assistant', data: [{ type: 'text', text: '' }] };
161
+
162
+ case 'error':
163
+ return { type: 'activity', subtype: 'assistant', data: [{ type: 'text', text: `Error: ${event.message || 'unknown'}` }] };
164
+
165
+ default:
166
+ return null;
167
+ }
81
168
  }
82
169
  }