groove-dev 0.27.119 → 0.27.120

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 (47) hide show
  1. package/moe-training/client/trajectory-capture.js +55 -0
  2. package/moe-training/test/client/trajectory-capture.test.js +63 -0
  3. package/node_modules/@groove-dev/cli/package.json +1 -1
  4. package/node_modules/@groove-dev/cli/src/commands/start.js +2 -1
  5. package/node_modules/@groove-dev/daemon/package.json +1 -1
  6. package/node_modules/@groove-dev/daemon/src/api.js +30 -10
  7. package/node_modules/@groove-dev/daemon/src/conversations.js +54 -32
  8. package/node_modules/@groove-dev/daemon/src/index.js +2 -1
  9. package/node_modules/@groove-dev/daemon/src/introducer.js +45 -20
  10. package/node_modules/@groove-dev/daemon/src/process.js +47 -1
  11. package/node_modules/@groove-dev/daemon/src/teams.js +33 -0
  12. package/node_modules/@groove-dev/gui/dist/assets/{index-DT6Jbf_q.css → index-BLd3MON8.css} +1 -1
  13. package/node_modules/@groove-dev/gui/dist/assets/{index-BxPCaxlC.js → index-oKbzuMnX.js} +1721 -1721
  14. package/node_modules/@groove-dev/gui/dist/index.html +2 -2
  15. package/node_modules/@groove-dev/gui/package.json +1 -1
  16. package/node_modules/@groove-dev/gui/src/components/agents/agent-config.jsx +3 -41
  17. package/node_modules/@groove-dev/gui/src/components/agents/spawn-wizard.jsx +4 -43
  18. package/node_modules/@groove-dev/gui/src/components/onboarding/setup-wizard.jsx +8 -23
  19. package/node_modules/@groove-dev/gui/src/components/settings/ProviderSetupWizard.jsx +54 -143
  20. package/node_modules/@groove-dev/gui/src/components/ui/data-sharing-modal.jsx +7 -57
  21. package/node_modules/@groove-dev/gui/src/stores/groove.js +13 -0
  22. package/node_modules/@groove-dev/gui/src/views/settings.jsx +50 -84
  23. package/node_modules/@groove-dev/gui/src/views/teams.jsx +61 -1
  24. package/node_modules/moe-training/client/trajectory-capture.js +55 -0
  25. package/node_modules/moe-training/test/client/trajectory-capture.test.js +63 -0
  26. package/package.json +1 -1
  27. package/packages/cli/package.json +1 -1
  28. package/packages/cli/src/commands/start.js +2 -1
  29. package/packages/daemon/package.json +1 -1
  30. package/packages/daemon/src/api.js +30 -10
  31. package/packages/daemon/src/conversations.js +54 -32
  32. package/packages/daemon/src/index.js +2 -1
  33. package/packages/daemon/src/introducer.js +45 -20
  34. package/packages/daemon/src/process.js +47 -1
  35. package/packages/daemon/src/teams.js +33 -0
  36. package/packages/gui/dist/assets/{index-DT6Jbf_q.css → index-BLd3MON8.css} +1 -1
  37. package/packages/gui/dist/assets/{index-BxPCaxlC.js → index-oKbzuMnX.js} +1721 -1721
  38. package/packages/gui/dist/index.html +2 -2
  39. package/packages/gui/package.json +1 -1
  40. package/packages/gui/src/components/agents/agent-config.jsx +3 -41
  41. package/packages/gui/src/components/agents/spawn-wizard.jsx +4 -43
  42. package/packages/gui/src/components/onboarding/setup-wizard.jsx +8 -23
  43. package/packages/gui/src/components/settings/ProviderSetupWizard.jsx +54 -143
  44. package/packages/gui/src/components/ui/data-sharing-modal.jsx +7 -57
  45. package/packages/gui/src/stores/groove.js +13 -0
  46. package/packages/gui/src/views/settings.jsx +50 -84
  47. package/packages/gui/src/views/teams.jsx +61 -1
@@ -128,6 +128,61 @@ export class TrajectoryCapture {
128
128
  await this._attestation.openSession(sessionId, metadata);
129
129
  }
130
130
 
131
+ onChatTurnStart(conversationId, provider, model, message) {
132
+ if (!this._enabled) return null;
133
+
134
+ const agentId = `chat-api-${conversationId}-${Date.now()}`;
135
+ const sessionId = `sess_${randomUUID()}`;
136
+ const contributorId = ConsentManager.getOrCreateUserId();
137
+ const metadata = {
138
+ model_engine: model,
139
+ provider,
140
+ agent_role: 'chat',
141
+ agent_id: agentId,
142
+ task_complexity: 'medium',
143
+ team_size: 1,
144
+ session_quality: 0,
145
+ groove_version: this._grooveVersion,
146
+ leaf_context: null,
147
+ };
148
+
149
+ const builder = new EnvelopeBuilder(sessionId, contributorId, metadata);
150
+ const classifier = new StepClassifier();
151
+
152
+ const ctx = {
153
+ sessionId,
154
+ parser: null,
155
+ builder,
156
+ classifier,
157
+ metadata,
158
+ stepCount: 0,
159
+ chunkCount: 0,
160
+ totalTokens: 0,
161
+ errorsEncountered: 0,
162
+ errorsRecovered: 0,
163
+ filesModified: 0,
164
+ coordinationEvents: 0,
165
+ startTime: Date.now(),
166
+ chunkTimer: null,
167
+ allSteps: [],
168
+ revisionRounds: 0,
169
+ };
170
+
171
+ this._contexts.set(agentId, ctx);
172
+
173
+ if (message && typeof message === 'string' && message.trim()) {
174
+ this._processStep(agentId, ctx, {
175
+ type: 'instruction',
176
+ content: message.slice(0, USER_MESSAGE_MAX_CHARS),
177
+ source: 'user',
178
+ });
179
+ }
180
+
181
+ this._attestation.openSession(sessionId, metadata).catch(() => {});
182
+
183
+ return agentId;
184
+ }
185
+
131
186
  onStdoutLine(agentId, jsonLine) {
132
187
  if (!this._enabled) return;
133
188
  const ctx = this._contexts.get(agentId);
@@ -354,6 +354,69 @@ describe('TrajectoryCapture — planner/conversational eligibility', () => {
354
354
  });
355
355
  });
356
356
 
357
+ describe('TrajectoryCapture — API chat capture via onChatTurnStart', () => {
358
+ function makeChatTc() {
359
+ const tc = makeTc();
360
+ tc._enabled = true;
361
+ tc._scrubber = { scrub: (s) => s };
362
+ tc._attestation = { openSession: async () => {}, closeSession: async () => {}, signEnvelope: (sid, e) => e };
363
+ tc._transmissionQueue = { enqueue: () => {}, waitForDrain: async () => {} };
364
+ tc._domainTagger = null;
365
+ return tc;
366
+ }
367
+
368
+ it('returns a synthetic agent ID and creates context', () => {
369
+ const tc = makeChatTc();
370
+ const agentId = tc.onChatTurnStart('conv-123', 'claude-code', 'opus', 'What is React?');
371
+ assert.ok(agentId);
372
+ assert.ok(agentId.startsWith('chat-api-conv-123-'));
373
+ const ctx = tc._contexts.get(agentId);
374
+ assert.ok(ctx);
375
+ assert.equal(ctx.metadata.agent_role, 'chat');
376
+ assert.equal(ctx.metadata.provider, 'claude-code');
377
+ assert.equal(ctx.metadata.model_engine, 'opus');
378
+ });
379
+
380
+ it('records the user message as an instruction step', () => {
381
+ const tc = makeChatTc();
382
+ const agentId = tc.onChatTurnStart('conv-456', 'claude-code', 'opus', 'Explain hooks');
383
+ const ctx = tc._contexts.get(agentId);
384
+ assert.equal(ctx.stepCount, 1);
385
+ assert.equal(ctx.allSteps[0].type, 'instruction');
386
+ assert.ok(ctx.allSteps[0].content.includes('Explain hooks'));
387
+ });
388
+
389
+ it('works with onParsedOutput and onAgentComplete', async () => {
390
+ const tc = makeChatTc();
391
+ const agentId = tc.onChatTurnStart('conv-789', 'claude-code', 'opus', 'Tell me about React');
392
+
393
+ tc.onParsedOutput(agentId, { type: 'activity', subtype: 'assistant', data: 'React is a UI library' });
394
+ tc.onParsedOutput(agentId, { type: 'result', data: 'React is a UI library' });
395
+
396
+ const ctx = tc._contexts.get(agentId);
397
+ assert.equal(ctx.stepCount, 3);
398
+ assert.equal(ctx.allSteps[1].type, 'thought');
399
+ assert.equal(ctx.allSteps[2].type, 'resolution');
400
+
401
+ await tc.onAgentComplete(agentId, { status: 'SUCCESS' });
402
+ assert.equal(tc._contexts.has(agentId), false);
403
+ });
404
+
405
+ it('returns null when disabled', () => {
406
+ const tc = makeChatTc();
407
+ tc._enabled = false;
408
+ const agentId = tc.onChatTurnStart('conv-000', 'claude-code', 'opus', 'Hello');
409
+ assert.equal(agentId, null);
410
+ });
411
+
412
+ it('context has no parser (not needed for API chat)', () => {
413
+ const tc = makeChatTc();
414
+ const agentId = tc.onChatTurnStart('conv-nop', 'claude-code', 'opus', 'Hello');
415
+ const ctx = tc._contexts.get(agentId);
416
+ assert.equal(ctx.parser, null);
417
+ });
418
+ });
419
+
357
420
  describe('TrajectoryCapture — initial prompt capture', () => {
358
421
  function makeSpawnTc() {
359
422
  const tc = makeTc();
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@groove-dev/cli",
3
- "version": "0.27.119",
3
+ "version": "0.27.120",
4
4
  "description": "GROOVE CLI — manage AI coding agents from your terminal",
5
5
  "license": "FSL-1.1-Apache-2.0",
6
6
  "type": "module",
@@ -3,12 +3,13 @@
3
3
 
4
4
  import { existsSync } from 'fs';
5
5
  import { resolve } from 'path';
6
+ import { homedir } from 'os';
6
7
  import { Daemon } from '@groove-dev/daemon';
7
8
  import chalk from 'chalk';
8
9
  import { runSetupWizard, saveKeysViaDaemon } from '../setup.js';
9
10
 
10
11
  export async function start(options) {
11
- const grooveDir = process.env.GROOVE_DIR || resolve(process.cwd(), '.groove');
12
+ const grooveDir = process.env.GROOVE_DIR || resolve(homedir(), '.groove');
12
13
  const isFirstRun = !existsSync(resolve(grooveDir, 'config.json'));
13
14
 
14
15
  // ── First-run interactive wizard ────────────────────────────
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@groove-dev/daemon",
3
- "version": "0.27.119",
3
+ "version": "0.27.120",
4
4
  "description": "GROOVE daemon — agent orchestration engine",
5
5
  "license": "FSL-1.1-Apache-2.0",
6
6
  "type": "module",
@@ -1147,6 +1147,16 @@ export function createApi(app, daemon) {
1147
1147
  }
1148
1148
  });
1149
1149
 
1150
+ app.post('/api/teams/:id/promote', (req, res) => {
1151
+ try {
1152
+ const team = daemon.teams.promote(req.params.id);
1153
+ daemon.audit.log('team.promote', { id: team.id, name: team.name });
1154
+ res.json(team);
1155
+ } catch (err) {
1156
+ res.status(400).json({ error: err.message });
1157
+ }
1158
+ });
1159
+
1150
1160
  // --- Conversations ---
1151
1161
 
1152
1162
  app.get('/api/conversations', (req, res) => {
@@ -2957,8 +2967,8 @@ Keep responses concise. Help them think, don't lecture them about the system the
2957
2967
 
2958
2968
  try {
2959
2969
  const stat = statSync(result.fullPath);
2960
- if (stat.size > 5 * 1024 * 1024) {
2961
- return res.status(400).json({ error: 'File too large (>5MB)' });
2970
+ if (stat.size > 50 * 1024 * 1024) {
2971
+ return res.status(400).json({ error: 'File too large (>50MB)' });
2962
2972
  }
2963
2973
 
2964
2974
  // Binary detection: check first 8KB for null bytes
@@ -2990,8 +3000,8 @@ Keep responses concise. Help them think, don't lecture them about the system the
2990
3000
  if (typeof content !== 'string') {
2991
3001
  return res.status(400).json({ error: 'content must be a string' });
2992
3002
  }
2993
- if (content.length > 5 * 1024 * 1024) {
2994
- return res.status(400).json({ error: 'Content too large (>5MB)' });
3003
+ if (content.length > 50 * 1024 * 1024) {
3004
+ return res.status(400).json({ error: 'Content too large (>50MB)' });
2995
3005
  }
2996
3006
 
2997
3007
  try {
@@ -3435,6 +3445,7 @@ Keep responses concise. Help them think, don't lecture them about the system the
3435
3445
  const plannerAgent = found.agentId ? daemon.registry.get(found.agentId) : null;
3436
3446
  const baseDir = plannerAgent?.workingDir || daemon.config?.defaultWorkingDir || daemon.projectDir;
3437
3447
  const plannerProvider = plannerAgent?.provider || undefined;
3448
+ const plannerModel = plannerAgent?.model || undefined;
3438
3449
 
3439
3450
  // Use the planner's teamId so launched agents join the correct team.
3440
3451
  // Priority: explicit from frontend > agent that wrote the file > most recent planner > default
@@ -3477,6 +3488,15 @@ Keep responses concise. Help them think, don't lecture them about the system the
3477
3488
  });
3478
3489
  }
3479
3490
 
3491
+ function resolveProviderAndModel(cfgProvider, cfgModel, fallbackProvider, fallbackModel) {
3492
+ const provider = cfgProvider || plannerProvider || daemon.config?.defaultProvider || fallbackProvider || undefined;
3493
+ if (cfgModel) return { provider, model: cfgModel };
3494
+ if (!cfgProvider && plannerProvider && plannerProvider !== daemon.config?.defaultProvider) {
3495
+ return { provider, model: plannerModel || 'auto' };
3496
+ }
3497
+ return { provider, model: daemon.config?.defaultModel || fallbackModel || 'auto' };
3498
+ }
3499
+
3480
3500
  // Team-level overrides from the pre-planner config panel
3481
3501
  const teamProvider = req.body?.teamProvider || undefined;
3482
3502
  const teamModel = req.body?.teamModel || undefined;
@@ -3500,10 +3520,12 @@ Keep responses concise. Help them think, don't lecture them about the system the
3500
3520
 
3501
3521
  // Safety net: if planner forgot the QC agent, auto-add one
3502
3522
  if (phase2.length === 0 && phase1.length >= 2) {
3523
+ const { provider: qcProvider, model: qcModel } = resolveProviderAndModel(teamProvider, teamModel);
3503
3524
  phase2 = [{
3504
3525
  name: 'qc-agent',
3505
3526
  role: 'fullstack', phase: 2, scope: [],
3506
- provider: teamProvider || plannerProvider || daemon.config?.defaultProvider || undefined,
3527
+ provider: qcProvider,
3528
+ model: qcModel,
3507
3529
  prompt: 'QC Senior Dev: All builder agents have completed. Audit their changes for correctness, fix any issues, run tests, and verify the project builds cleanly (npm run build). Do NOT start long-running dev servers — just verify the build succeeds. Commit all changes. IMPORTANT: Do NOT delete files from other projects or directories outside this project.',
3508
3530
  }];
3509
3531
  }
@@ -3556,8 +3578,7 @@ Keep responses concise. Help them think, don't lecture them about the system the
3556
3578
  role: existing.role,
3557
3579
  scope: normalizeScope(config.scope || existing.scope || [], existing.workingDir || projectWorkingDir),
3558
3580
  prompt,
3559
- provider: config.provider || plannerProvider || daemon.config?.defaultProvider || existing.provider || undefined,
3560
- model: config.model || existing.model || daemon.config?.defaultModel || 'auto',
3581
+ ...resolveProviderAndModel(config.provider, config.model, existing.provider, existing.model),
3561
3582
  permission: config.permission || existing.permission || 'auto',
3562
3583
  workingDir: existing.workingDir || projectWorkingDir,
3563
3584
  name: existing.name,
@@ -3581,8 +3602,7 @@ Keep responses concise. Help them think, don't lecture them about the system the
3581
3602
  role: config.role,
3582
3603
  scope: normalizeScope(config.scope || [], config.workingDir || projectWorkingDir),
3583
3604
  prompt,
3584
- provider: config.provider || plannerProvider || daemon.config?.defaultProvider || undefined,
3585
- model: config.model || daemon.config?.defaultModel || 'auto',
3605
+ ...resolveProviderAndModel(config.provider, config.model),
3586
3606
  permission: config.permission || 'auto',
3587
3607
  workingDir: config.workingDir || projectWorkingDir,
3588
3608
  name: config.name || undefined,
@@ -3624,7 +3644,7 @@ Keep responses concise. Help them think, don't lecture them about the system the
3624
3644
  waitFor: phase1Ids,
3625
3645
  agents: phase2.map((c) => ({
3626
3646
  role: c.role, scope: c.scope || [], prompt: c.prompt || '',
3627
- provider: c.provider || plannerProvider || daemon.config?.defaultProvider || undefined, model: c.model || daemon.config?.defaultModel || 'auto',
3647
+ ...resolveProviderAndModel(c.provider, c.model),
3628
3648
  permission: c.permission || 'auto',
3629
3649
  reasoningEffort: c.reasoningEffort, temperature: c.temperature, verbosity: c.verbosity,
3630
3650
  workingDir: c.workingDir || projectWorkingDir,
@@ -387,10 +387,19 @@ export class ConversationManager {
387
387
  const effectiveReasoningEffort = reasoningEffort || conv.reasoningEffort || null;
388
388
  const effectiveVerbosity = verbosity || conv.verbosity || null;
389
389
 
390
+ // Trajectory capture for training data
391
+ const tc = this.daemon.trajectoryCapture;
392
+ let tcAgentId = null;
393
+ let tcResponseText = '';
394
+ if (tc) {
395
+ try { tcAgentId = tc.onChatTurnStart(id, providerName, modelId, message); } catch { /* never block chat */ }
396
+ }
397
+
390
398
  // Try direct API streaming first (sub-second latency)
391
399
  const controller = provider.streamChat(
392
400
  messages, modelId, apiKey,
393
401
  (text) => {
402
+ if (tcAgentId) tcResponseText += text;
394
403
  this.daemon.broadcast({
395
404
  type: 'conversation:chunk',
396
405
  data: { conversationId: id, text },
@@ -402,6 +411,15 @@ export class ConversationManager {
402
411
  this._save();
403
412
  }
404
413
  this._getStreamingProcesses().delete(id);
414
+ if (tcAgentId && tc) {
415
+ try {
416
+ tc.onParsedOutput(tcAgentId, { type: 'activity', subtype: 'assistant', data: tcResponseText });
417
+ tc.onParsedOutput(tcAgentId, { type: 'result', data: tcResponseText });
418
+ tc.onAgentComplete(tcAgentId, { status: 'SUCCESS' });
419
+ const count = (this.daemon.state.get('training_sessions_captured') || 0) + 1;
420
+ this.daemon.state.set('training_sessions_captured', count);
421
+ } catch { /* never block chat */ }
422
+ }
405
423
  this.daemon.broadcast({
406
424
  type: 'conversation:complete',
407
425
  data: { conversationId: id },
@@ -409,6 +427,9 @@ export class ConversationManager {
409
427
  },
410
428
  (err) => {
411
429
  this._getStreamingProcesses().delete(id);
430
+ if (tcAgentId && tc) {
431
+ try { tc.onAgentCrash(tcAgentId, err); } catch { /* never block chat */ }
432
+ }
412
433
  this.daemon.broadcast({
413
434
  type: 'conversation:error',
414
435
  data: { conversationId: id, error: err.message },
@@ -430,6 +451,9 @@ export class ConversationManager {
430
451
  const prompt = this._buildHistoryPrompt(history, message);
431
452
  const headlessCmd = provider.buildHeadlessCommand(prompt, modelId);
432
453
  if (!headlessCmd) {
454
+ if (tcAgentId && tc) {
455
+ try { tc.onAgentCrash(tcAgentId, new Error('No API key for chat')); } catch { /* never block chat */ }
456
+ }
433
457
  this.daemon.broadcast({
434
458
  type: 'conversation:error',
435
459
  data: { conversationId: id, error: `${providerName} requires an API key for chat` },
@@ -450,6 +474,9 @@ export class ConversationManager {
450
474
 
451
475
  proc.on('error', (err) => {
452
476
  this._getStreamingProcesses().delete(id);
477
+ if (tcAgentId && tc) {
478
+ try { tc.onAgentCrash(tcAgentId, err); } catch { /* never block chat */ }
479
+ }
453
480
  this.daemon.broadcast({
454
481
  type: 'conversation:error',
455
482
  data: { conversationId: id, error: err.message },
@@ -461,6 +488,14 @@ export class ConversationManager {
461
488
  proc.stdin.end();
462
489
  }
463
490
 
491
+ const emitChunk = (text) => {
492
+ if (tcAgentId) tcResponseText += text;
493
+ this.daemon.broadcast({
494
+ type: 'conversation:chunk',
495
+ data: { conversationId: id, text },
496
+ });
497
+ };
498
+
464
499
  proc.stdout.on('data', (data) => {
465
500
  const text = data.toString();
466
501
  const lines = text.split('\n');
@@ -473,64 +508,51 @@ export class ConversationManager {
473
508
  if (json.type === 'assistant' && json.message?.content) {
474
509
  for (const block of json.message.content) {
475
510
  if (block.type === 'text' && block.text) {
476
- this.daemon.broadcast({
477
- type: 'conversation:chunk',
478
- data: { conversationId: id, text: block.text },
479
- });
511
+ emitChunk(block.text);
480
512
  }
481
513
  }
482
514
  continue;
483
515
  }
484
516
  if (json.type === 'content_block_delta' && json.delta?.text) {
485
- this.daemon.broadcast({
486
- type: 'conversation:chunk',
487
- data: { conversationId: id, text: json.delta.text },
488
- });
517
+ emitChunk(json.delta.text);
489
518
  continue;
490
519
  }
491
520
  if (json.type === 'result' && json.result) continue;
492
521
  if (json.type === 'token' && json.text != null) {
493
- this.daemon.broadcast({
494
- type: 'conversation:chunk',
495
- data: { conversationId: id, text: json.text },
496
- });
522
+ emitChunk(json.text);
497
523
  continue;
498
524
  }
499
525
  if ((json.type === 'done' || json.type === 'complete' || json.type === 'result') && json.text) {
500
- this.daemon.broadcast({
501
- type: 'conversation:chunk',
502
- data: { conversationId: id, text: json.text },
503
- });
526
+ emitChunk(json.text);
504
527
  continue;
505
528
  }
506
529
  if (json.content?.[0]?.text) {
507
- this.daemon.broadcast({
508
- type: 'conversation:chunk',
509
- data: { conversationId: id, text: json.content[0].text },
510
- });
530
+ emitChunk(json.content[0].text);
511
531
  continue;
512
532
  }
513
533
  } catch { /* not JSON */ }
514
534
 
515
535
  if (!trimmed.startsWith('{')) {
516
- this.daemon.broadcast({
517
- type: 'conversation:chunk',
518
- data: { conversationId: id, text: trimmed },
519
- });
536
+ emitChunk(trimmed);
520
537
  }
521
538
  }
522
539
  });
523
540
 
524
- proc.on('error', (err) => {
525
- this._getStreamingProcesses().delete(id);
526
- this.daemon.broadcast({
527
- type: 'conversation:error',
528
- data: { conversationId: id, error: err.message },
529
- });
530
- });
531
-
532
541
  proc.on('exit', (code) => {
533
542
  this._getStreamingProcesses().delete(id);
543
+ if (tcAgentId && tc) {
544
+ try {
545
+ tc.onParsedOutput(tcAgentId, { type: 'activity', subtype: 'assistant', data: tcResponseText });
546
+ tc.onParsedOutput(tcAgentId, { type: 'result', data: tcResponseText });
547
+ if (code === 0 || code === null) {
548
+ tc.onAgentComplete(tcAgentId, { status: 'SUCCESS' });
549
+ } else {
550
+ tc.onAgentCrash(tcAgentId, new Error(`Exit code ${code}`));
551
+ }
552
+ const count = (this.daemon.state.get('training_sessions_captured') || 0) + 1;
553
+ this.daemon.state.set('training_sessions_captured', count);
554
+ } catch { /* never block chat */ }
555
+ }
534
556
  this.daemon.broadcast({
535
557
  type: 'conversation:complete',
536
558
  data: { conversationId: id, exitCode: code },
@@ -5,6 +5,7 @@ import { createServer as createHttpServer, request as httpProxyRequest } from 'h
5
5
  import { createServer as createNetServer } from 'net';
6
6
  import { execFileSync } from 'child_process';
7
7
  import { resolve } from 'path';
8
+ import { homedir } from 'os';
8
9
  import { readFileSync, writeFileSync, unlinkSync, existsSync, mkdirSync, readdirSync, rmdirSync, rmSync, statSync } from 'fs';
9
10
  import express from 'express';
10
11
  import { WebSocketServer } from 'ws';
@@ -94,7 +95,7 @@ export class Daemon {
94
95
  this.port = options.port !== undefined ? options.port : (parseInt(process.env.GROOVE_PORT, 10) || DEFAULT_PORT);
95
96
  this.host = resolveHost(options.host);
96
97
  this.projectDir = options.projectDir || process.cwd();
97
- this.grooveDir = options.grooveDir || resolve(this.projectDir, '.groove');
98
+ this.grooveDir = options.grooveDir || resolve(homedir(), '.groove');
98
99
  this.pidFile = resolve(this.grooveDir, 'daemon.pid');
99
100
 
100
101
  // Ensure .groove directories exist
@@ -16,10 +16,10 @@ export class Introducer {
16
16
  generateContext(newAgent, options = {}) {
17
17
  const { taskNegotiation, hasTask, isRotation } = options;
18
18
  const agents = this.daemon.registry.getAll();
19
- // Only include ACTIVE agents not completed/killed ones from previous sessions
20
- // Completed agents' work is captured in the journalist's project map, not here
19
+ // Only include ACTIVE agents from the SAME TEAM never leak cross-team state
21
20
  const others = agents.filter((a) => a.id !== newAgent.id &&
22
- (a.status === 'running' || a.status === 'starting'));
21
+ (a.status === 'running' || a.status === 'starting') &&
22
+ a.teamId === newAgent.teamId);
23
23
 
24
24
  const lines = [
25
25
  `# GROOVE Agent Context`,
@@ -475,7 +475,6 @@ export class Introducer {
475
475
  const agents = this.daemon.registry.getAll();
476
476
 
477
477
  if (agents.length === 0) {
478
- // Clean up if no agents
479
478
  const regPath = resolve(projectDir, 'AGENTS_REGISTRY.md');
480
479
  if (existsSync(regPath)) {
481
480
  writeFileSync(regPath, '');
@@ -483,25 +482,39 @@ export class Introducer {
483
482
  return;
484
483
  }
485
484
 
486
- const lines = [
487
- `# AGENTS REGISTRY`,
488
- ``,
489
- `*Auto-generated by GROOVE. Do not edit manually.*`,
490
- ``,
491
- `| ID | Name | Role | Provider | Directory | Scope | Status |`,
492
- `|----|------|------|----------|-----------|-------|--------|`,
493
- ];
494
-
485
+ // Group agents by team so each team's registry only shows its own agents
486
+ const teamGroups = new Map();
495
487
  for (const a of agents) {
496
- const scope = a.scope?.length > 0 ? `\`${a.scope.join('`, `')}\`` : '-';
497
- const dir = a.workingDir ? escapeMd(a.workingDir) : '-';
498
- lines.push(`| ${escapeMd(a.id)} | ${escapeMd(a.name)} | ${escapeMd(a.role)} | ${escapeMd(a.provider)} | ${dir} | ${scope} | ${escapeMd(a.status)} |`);
488
+ const tid = a.teamId || '_default';
489
+ if (!teamGroups.has(tid)) teamGroups.set(tid, []);
490
+ teamGroups.get(tid).push(a);
499
491
  }
500
492
 
501
- lines.push('');
502
- lines.push(`*Updated: ${new Date().toISOString()}*`);
493
+ // Write a scoped registry into each team's workingDir
494
+ for (const [teamId, teamAgents] of teamGroups) {
495
+ const team = teamId !== '_default' ? this.daemon.teams?.get(teamId) : null;
496
+ const dir = team?.workingDir || projectDir;
497
+
498
+ const lines = [
499
+ `# AGENTS REGISTRY`,
500
+ ``,
501
+ `*Auto-generated by GROOVE. Do not edit manually.*`,
502
+ ``,
503
+ `| ID | Name | Role | Provider | Directory | Scope | Status |`,
504
+ `|----|------|------|----------|-----------|-------|--------|`,
505
+ ];
506
+
507
+ for (const a of teamAgents) {
508
+ const scope = a.scope?.length > 0 ? `\`${a.scope.join('`, `')}\`` : '-';
509
+ const agentDir = a.workingDir ? escapeMd(a.workingDir) : '-';
510
+ lines.push(`| ${escapeMd(a.id)} | ${escapeMd(a.name)} | ${escapeMd(a.role)} | ${escapeMd(a.provider)} | ${agentDir} | ${scope} | ${escapeMd(a.status)} |`);
511
+ }
503
512
 
504
- writeFileSync(resolve(projectDir, 'AGENTS_REGISTRY.md'), lines.join('\n'));
513
+ lines.push('');
514
+ lines.push(`*Updated: ${new Date().toISOString()}*`);
515
+
516
+ writeFileSync(resolve(dir, 'AGENTS_REGISTRY.md'), lines.join('\n'));
517
+ }
505
518
  }
506
519
 
507
520
  injectGrooveSection(projectDir) {
@@ -510,7 +523,19 @@ export class Introducer {
510
523
  // clobbering the user's content.
511
524
  const claudeMdPath = resolve(projectDir, 'CLAUDE.md');
512
525
  const agents = this.daemon.registry.getAll();
513
- const running = agents.filter((a) => a.status === 'running');
526
+
527
+ // Only show agents that belong to this project directory — exclude agents
528
+ // from teams with their own isolated workingDir (sandbox teams)
529
+ const isolatedDirs = new Set();
530
+ if (this.daemon.teams) {
531
+ for (const team of this.daemon.teams.list()) {
532
+ if (team.workingDir && team.workingDir !== projectDir) {
533
+ isolatedDirs.add(team.workingDir);
534
+ }
535
+ }
536
+ }
537
+ const running = agents.filter((a) => a.status === 'running' &&
538
+ !isolatedDirs.has(a.workingDir));
514
539
 
515
540
  const grooveContent = [
516
541
  GROOVE_SECTION_START,
@@ -697,6 +697,28 @@ export class ProcessManager {
697
697
  }
698
698
  }
699
699
 
700
+ // Validate provider is both installed and authenticated — fall back if not
701
+ const isProviderAuthed = (name) => {
702
+ const p = getProvider(name);
703
+ if (!p || !p.constructor.isInstalled()) return false;
704
+ const authType = p.constructor.authType;
705
+ if (authType === 'local' || authType === 'none') return true;
706
+ if (authType === 'api-key') return !!this.daemon.credentials?.hasKey(name);
707
+ if (authType === 'subscription') {
708
+ const status = p.constructor.isAuthenticated?.();
709
+ return status?.authenticated === true;
710
+ }
711
+ return true;
712
+ };
713
+
714
+ if (!isProviderAuthed(providerName)) {
715
+ const priority = ['claude-code', 'gemini', 'codex', 'local', 'ollama'];
716
+ const fallback = priority.find(p => p !== providerName && isProviderAuthed(p));
717
+ if (fallback) {
718
+ providerName = fallback;
719
+ }
720
+ }
721
+
700
722
  const provider = getProvider(providerName);
701
723
  if (!provider) {
702
724
  throw new Error(`Unknown provider: ${providerName}`);
@@ -717,7 +739,11 @@ export class ProcessManager {
717
739
  if (config.model && config.model !== 'auto' && provider.constructor.models) {
718
740
  const valid = provider.constructor.models.some(m => m.id === config.model);
719
741
  if (!valid) {
720
- throw new Error(`Model '${config.model}' is not available for ${provider.constructor.displayName}. Available: ${provider.constructor.models.map(m => m.id).join(', ')}`);
742
+ const fallback = provider.constructor.models[0];
743
+ if (fallback) {
744
+ console.log(`[Groove] Model '${config.model}' not available for ${provider.constructor.displayName}, falling back to '${fallback.id}'`);
745
+ config.model = fallback.id;
746
+ }
721
747
  }
722
748
  }
723
749
 
@@ -1099,6 +1125,26 @@ For normal file edits within your scope, proceed without review.
1099
1125
  }
1100
1126
  }
1101
1127
 
1128
+ // Best-effort Codex auth recovery: refresh ~/.codex/auth.json if stale
1129
+ const agentProviderName = agent.provider || config.provider;
1130
+ if (agentProviderName === 'codex') {
1131
+ const codexMeta = getProvider('codex');
1132
+ if (codexMeta?.constructor?.isAuthenticated) {
1133
+ const authStatus = codexMeta.constructor.isAuthenticated();
1134
+ if (!authStatus?.authenticated && this.daemon.credentials?.hasKey('codex')) {
1135
+ const storedKey = this.daemon.credentials.getKey('codex');
1136
+ if (storedKey && codexMeta.constructor.onKeySet) {
1137
+ try {
1138
+ const result = await codexMeta.constructor.onKeySet(storedKey);
1139
+ logStream.write(`[${new Date().toISOString()}] Codex auth recovery: ${result?.ok ? 'success' : result?.error || 'failed'}\n`);
1140
+ } catch (e) {
1141
+ logStream.write(`[${new Date().toISOString()}] Codex auth recovery error: ${e.message}\n`);
1142
+ }
1143
+ }
1144
+ }
1145
+ }
1146
+ }
1147
+
1102
1148
  // Spawn the process (use pipe for stdin if provider needs to send prompt via stdin)
1103
1149
  const spawnCwd = [providerCwd, agent.workingDir, this.daemon.projectDir].find(d => d && existsSync(d)) || this.daemon.projectDir;
1104
1150
  const proc = cpSpawn(command, args, {
@@ -358,6 +358,39 @@ export class Teams {
358
358
  }
359
359
  }
360
360
 
361
+ promote(id) {
362
+ const team = this.teams.get(id);
363
+ if (!team) throw new Error('Team not found');
364
+ if (team.mode === 'production') throw new Error('Team is already in production mode');
365
+
366
+ const oldDir = team.workingDir;
367
+ const targetDir = this.daemon.projectDir;
368
+
369
+ const entries = readdirSync(oldDir, { withFileTypes: true });
370
+ for (const entry of entries) {
371
+ if (entry.name === '.groove') continue;
372
+ const src = resolve(oldDir, entry.name);
373
+ const dest = resolve(targetDir, entry.name);
374
+ cpSync(src, dest, { recursive: true, force: true });
375
+ }
376
+
377
+ rmSync(oldDir, { recursive: true, force: true });
378
+
379
+ team.workingDir = targetDir;
380
+ team.mode = 'production';
381
+
382
+ const agents = this.daemon.registry.getAll().filter((a) => a.teamId === id);
383
+ for (const agent of agents) {
384
+ if (agent.workingDir === oldDir) {
385
+ this.daemon.registry.update(agent.id, { workingDir: targetDir });
386
+ }
387
+ }
388
+
389
+ this._save();
390
+ this.daemon.broadcast({ type: 'team:updated', team });
391
+ return team;
392
+ }
393
+
361
394
  // Backward compat stubs
362
395
  onAgentChange() {}
363
396
  getActiveTeam() { return this.getDefault()?.name || null; }