groove-dev 0.27.106 → 0.27.108

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 (55) hide show
  1. package/TRAINING_DATA.md +9 -0
  2. package/moe-training/client/envelope-builder.js +5 -0
  3. package/moe-training/client/scrubber.js +2 -2
  4. package/moe-training/client/step-classifier.js +22 -6
  5. package/moe-training/client/trajectory-capture.js +15 -4
  6. package/moe-training/shared/constants.js +2 -0
  7. package/moe-training/shared/envelope-schema.js +1 -1
  8. package/moe-training/test/client/envelope-builder.test.js +32 -0
  9. package/moe-training/test/client/scrubber.test.js +37 -0
  10. package/moe-training/test/client/step-classifier.test.js +96 -7
  11. package/moe-training/test/client/trajectory-capture.test.js +53 -6
  12. package/node_modules/@groove-dev/cli/package.json +1 -1
  13. package/node_modules/@groove-dev/daemon/package.json +1 -1
  14. package/node_modules/@groove-dev/daemon/src/api.js +40 -6
  15. package/node_modules/@groove-dev/daemon/src/process.js +5 -5
  16. package/node_modules/@groove-dev/gui/dist/assets/{index-BN7fQKaF.js → index-CEgtSfbG.js} +1749 -1746
  17. package/node_modules/@groove-dev/gui/dist/assets/{index-QwgLRN8B.css → index-_3cJS_UG.css} +1 -1
  18. package/node_modules/@groove-dev/gui/dist/index.html +2 -2
  19. package/node_modules/@groove-dev/gui/package.json +1 -1
  20. package/node_modules/@groove-dev/gui/src/components/layout/command-palette.jsx +2 -1
  21. package/node_modules/@groove-dev/gui/src/components/layout/status-bar.jsx +9 -3
  22. package/node_modules/@groove-dev/gui/src/components/settings/federation-panel.jsx +2 -2
  23. package/node_modules/@groove-dev/gui/src/components/settings/federation-peers.jsx +14 -2
  24. package/node_modules/@groove-dev/gui/src/components/settings/quick-connect.jsx +9 -0
  25. package/node_modules/@groove-dev/gui/src/stores/groove.js +12 -1
  26. package/node_modules/@groove-dev/gui/src/views/federation.jsx +56 -15
  27. package/node_modules/@groove-dev/gui/src/views/settings.jsx +8 -7
  28. package/node_modules/moe-training/client/envelope-builder.js +5 -0
  29. package/node_modules/moe-training/client/scrubber.js +2 -2
  30. package/node_modules/moe-training/client/step-classifier.js +22 -6
  31. package/node_modules/moe-training/client/trajectory-capture.js +15 -4
  32. package/node_modules/moe-training/shared/constants.js +2 -0
  33. package/node_modules/moe-training/shared/envelope-schema.js +1 -1
  34. package/node_modules/moe-training/test/client/envelope-builder.test.js +32 -0
  35. package/node_modules/moe-training/test/client/scrubber.test.js +37 -0
  36. package/node_modules/moe-training/test/client/step-classifier.test.js +96 -7
  37. package/node_modules/moe-training/test/client/trajectory-capture.test.js +53 -6
  38. package/package.json +1 -1
  39. package/packages/cli/package.json +1 -1
  40. package/packages/daemon/package.json +1 -1
  41. package/packages/daemon/src/api.js +40 -6
  42. package/packages/daemon/src/process.js +5 -5
  43. package/packages/gui/dist/assets/{index-BN7fQKaF.js → index-CEgtSfbG.js} +1749 -1746
  44. package/packages/gui/dist/assets/{index-QwgLRN8B.css → index-_3cJS_UG.css} +1 -1
  45. package/packages/gui/dist/index.html +2 -2
  46. package/packages/gui/package.json +1 -1
  47. package/packages/gui/src/components/layout/command-palette.jsx +2 -1
  48. package/packages/gui/src/components/layout/status-bar.jsx +9 -3
  49. package/packages/gui/src/components/settings/federation-panel.jsx +2 -2
  50. package/packages/gui/src/components/settings/federation-peers.jsx +14 -2
  51. package/packages/gui/src/components/settings/quick-connect.jsx +9 -0
  52. package/packages/gui/src/stores/groove.js +12 -1
  53. package/packages/gui/src/views/federation.jsx +56 -15
  54. package/packages/gui/src/views/settings.jsx +8 -7
  55. package/ssh/main.js +2253 -0
@@ -31,12 +31,17 @@ export class EnvelopeBuilder {
31
31
  return this._buildEnvelope();
32
32
  }
33
33
 
34
+ updateMetadata(updates) {
35
+ Object.assign(this._metadata, updates);
36
+ }
37
+
34
38
  buildSessionClose(outcome) {
35
39
  return {
36
40
  envelope_id: `env_${randomUUID()}`,
37
41
  session_id: this._sessionId,
38
42
  type: 'SESSION_CLOSE',
39
43
  attestation: { session_hmac: '', sequence: 0, app_version_hash: '' },
44
+ metadata: { ...this._metadata },
40
45
  outcome,
41
46
  };
42
47
  }
@@ -65,7 +65,7 @@ export class PIIScrubber {
65
65
  },
66
66
  {
67
67
  name: 'ipv6',
68
- regex: /(?:[0-9a-fA-F]{1,4}:){7}[0-9a-fA-F]{1,4}|(?:[0-9a-fA-F]{1,4}:){1,7}:|(?:[0-9a-fA-F]{1,4}:){1,6}:[0-9a-fA-F]{1,4}|::(?:[fF]{4}:)?(?:\d{1,3}\.){3}\d{1,3}|::1?\b/g,
68
+ regex: /(?<![\w.#-])(?:(?:[0-9a-fA-F]{1,4}:){7}[0-9a-fA-F]{1,4}|(?:[0-9a-fA-F]{1,4}:){1,6}:[0-9a-fA-F]{1,4}|(?:[0-9a-fA-F]{1,4}:){1,7}:|::(?:[fF]{4}:)?(?:\d{1,3}\.){3}\d{1,3}|::1\b)/g,
69
69
  replacement: '[IP]',
70
70
  },
71
71
  {
@@ -100,7 +100,7 @@ export class PIIScrubber {
100
100
  },
101
101
  {
102
102
  name: 'base64_secret',
103
- regex: /(?<![A-Za-z0-9+/])[A-Za-z0-9+/]{40,}={0,2}(?![A-Za-z0-9+/])/g,
103
+ regex: /(?<![A-Za-z0-9+])[A-Za-z0-9+]{40,}={0,2}(?![A-Za-z0-9+])/g,
104
104
  replacement: '[API_KEY]',
105
105
  },
106
106
  ];
@@ -3,23 +3,39 @@
3
3
  const ERROR_SIGNAL_RE = /\b(?:error|Error|ERROR|exception|Exception|EXCEPTION|failed|FAILED|exit code [1-9]|ENOENT|EACCES|EPERM|TypeError|ReferenceError|SyntaxError|Cannot find|Module not found|Command failed|non-zero exit)\b/;
4
4
  const FIX_SIGNAL_RE = /\b(?:fix|correcting|I see the issue|let me fix|the (?:issue|problem|bug) (?:is|was)|instead I should|my mistake)\b/i;
5
5
 
6
+ const CORRECTION_RE = /\b(?:no[,. ](?:that|not|don't|wrong)|that'?s (?:not|wrong|incorrect)|don'?t do that|stop (?:doing|that)|instead (?:of|do)|undo|revert|go back|try (?:again|differently)|you (?:broke|missed|forgot))\b/i;
7
+ const APPROVAL_RE = /\b(?:looks? good|lgtm|approved?|go ahead|ship it|that'?s (?:right|correct|perfect)|perfect|exactly right|nice work|well done|great job)\b/i;
8
+ const CLARIFICATION_RE = /\b(?:what (?:about|I (?:mean|want))|I meant|to (?:be clear|clarify)|let me (?:rephrase|explain)|clarif(?:y|ication)|more specifically)\b/i;
9
+
6
10
  export class StepClassifier {
7
11
  constructor() {
8
12
  this.hasAgentActed = false;
9
13
  this._lastStepType = null;
10
14
  }
11
15
 
12
- classifyUserMessage(text) {
16
+ classifyUserMessage(text, source = 'user') {
13
17
  if (!this.hasAgentActed) {
14
- return null;
18
+ return {
19
+ type: 'instruction',
20
+ content: text,
21
+ source,
22
+ };
15
23
  }
16
24
  return {
17
- type: 'correction',
25
+ type: StepClassifier.classifyIntent(text),
18
26
  content: text,
19
- source: 'user',
27
+ source,
20
28
  };
21
29
  }
22
30
 
31
+ static classifyIntent(text) {
32
+ if (!text || typeof text !== 'string') return 'instruction';
33
+ if (CORRECTION_RE.test(text)) return 'correction';
34
+ if (APPROVAL_RE.test(text)) return 'approval';
35
+ if (CLARIFICATION_RE.test(text)) return 'clarification';
36
+ return 'instruction';
37
+ }
38
+
23
39
  classifyCoordinationEvent(event) {
24
40
  return {
25
41
  type: 'coordination',
@@ -42,7 +58,7 @@ export class StepClassifier {
42
58
  step.type = 'error';
43
59
  }
44
60
 
45
- if (step.type === 'thought' && this._lastStepType === 'correction' && FIX_SIGNAL_RE.test(content)) {
61
+ if (step.type === 'thought' && (this._lastStepType === 'correction' || this._lastStepType === 'instruction') && FIX_SIGNAL_RE.test(content)) {
46
62
  step.correction_context = true;
47
63
  }
48
64
 
@@ -63,6 +79,6 @@ export class StepClassifier {
63
79
  }
64
80
 
65
81
  static countUserInterventions(steps) {
66
- return steps.filter((s) => s.type === 'correction').length;
82
+ return steps.filter((s) => s.type === 'correction' || s.type === 'clarification').length;
67
83
  }
68
84
  }
@@ -18,6 +18,7 @@ import {
18
18
  TRAINING_MIN_TOKENS,
19
19
  TRAINING_MIN_DURATION,
20
20
  TRAINING_EXCLUSION_REASONS,
21
+ USER_MESSAGE_MAX_CHARS,
21
22
  } from '../shared/constants.js';
22
23
 
23
24
  const OFFLINE_RETRY_INTERVAL_MS = 60_000;
@@ -131,15 +132,20 @@ export class TrajectoryCapture {
131
132
 
132
133
  }
133
134
 
134
- onUserMessage(agentId, text) {
135
+ onUserMessage(agentId, text, source = 'user') {
135
136
  if (!this._enabled) return;
136
137
  const ctx = this._contexts.get(agentId);
137
138
  if (!ctx) return;
138
139
 
139
- ctx.revisionRounds++;
140
+ const capped = (typeof text === 'string' && text.length > USER_MESSAGE_MAX_CHARS)
141
+ ? text.slice(0, USER_MESSAGE_MAX_CHARS)
142
+ : text;
140
143
 
141
- const classified = ctx.classifier.classifyUserMessage(text);
142
- if (!classified) return;
144
+ const classified = ctx.classifier.classifyUserMessage(capped, source);
145
+
146
+ if (classified.type === 'correction' || classified.type === 'clarification') {
147
+ ctx.revisionRounds++;
148
+ }
143
149
 
144
150
  this._processStep(agentId, ctx, classified);
145
151
  }
@@ -301,6 +307,11 @@ export class TrajectoryCapture {
301
307
  const { tier, reason: tierReason } = this._computeQualityTier(ctx, status, userInterventions);
302
308
  const { eligible, exclusionReason } = this._computeTrainingEligibility(ctx, durationSeconds);
303
309
 
310
+ ctx.builder.updateMetadata({
311
+ domain_tags: ctx.metadata.domain_tags,
312
+ session_quality: ctx.metadata.session_quality,
313
+ });
314
+
304
315
  const closeEnvelope = ctx.builder.buildSessionClose({
305
316
  status,
306
317
  session_quality: ctx.metadata.session_quality,
@@ -36,4 +36,6 @@ export const TRAINING_MIN_TOKENS = 500;
36
36
  export const TRAINING_MIN_DURATION = 10;
37
37
  export const TRAINING_EXCLUSION_REASONS = ['too_few_steps', 'no_actions', 'no_observations', 'insufficient_tokens', 'too_short'];
38
38
 
39
+ export const USER_MESSAGE_MAX_CHARS = 2000;
40
+
39
41
  export const CENTRAL_COMMAND_URL = process.env.GROOVE_CENTRAL_URL || 'https://api.groovedev.ai';
@@ -2,7 +2,7 @@
2
2
 
3
3
  import { SUPPORTED_PROVIDERS, MODEL_TIERS, TRAINING_EXCLUSION_REASONS } from './constants.js';
4
4
 
5
- export const STEP_TYPES = ['thought', 'action', 'observation', 'correction', 'resolution', 'error', 'coordination', 'edit'];
5
+ export const STEP_TYPES = ['thought', 'action', 'observation', 'correction', 'resolution', 'error', 'coordination', 'edit', 'instruction', 'clarification', 'approval'];
6
6
  const VALID_QUALITY_TIERS = ['TIER_A', 'TIER_B', 'TIER_C'];
7
7
  const VALID_FEEDBACK_SIGNALS = ['accepted', 'modified', 'rejected', 'iterated'];
8
8
 
@@ -117,6 +117,38 @@ describe('EnvelopeBuilder', () => {
117
117
  assert.equal(close.outcome.training_exclusion_reason, null);
118
118
  });
119
119
 
120
+ it('updateMetadata syncs late-computed fields to builder', () => {
121
+ const builder = new EnvelopeBuilder('sess_1', 'user_1', metadata);
122
+ const domainTags = {
123
+ primary: { domain: 'react_frontend', confidence: 0.3 },
124
+ secondary: { domain: 'typescript_node', confidence: 0.25 },
125
+ tertiary: { domain: 'python', confidence: 0 },
126
+ };
127
+ builder.updateMetadata({ domain_tags: domainTags, session_quality: 85 });
128
+
129
+ builder.addStep({ step: 1, type: 'thought', timestamp: 123 });
130
+ const envelope = builder.flush();
131
+ assert.deepEqual(envelope.metadata.domain_tags, domainTags);
132
+ assert.equal(envelope.metadata.session_quality, 85);
133
+ });
134
+
135
+ it('SESSION_CLOSE includes metadata with domain_tags', () => {
136
+ const builder = new EnvelopeBuilder('sess_1', 'user_1', metadata);
137
+ const domainTags = {
138
+ primary: { domain: 'react_frontend', confidence: 0.3 },
139
+ secondary: { domain: 'typescript_node', confidence: 0.25 },
140
+ tertiary: { domain: 'python', confidence: 0 },
141
+ };
142
+ builder.updateMetadata({ domain_tags: domainTags });
143
+
144
+ const close = builder.buildSessionClose({
145
+ status: 'SUCCESS', total_steps: 10, total_chunks: 1,
146
+ });
147
+ assert.ok(close.metadata, 'SESSION_CLOSE must include metadata');
148
+ assert.deepEqual(close.metadata.domain_tags, domainTags);
149
+ assert.equal(close.metadata.agent_role, 'backend');
150
+ });
151
+
120
152
  it('chunk sequence increments correctly', () => {
121
153
  const builder = new EnvelopeBuilder('sess_1', 'user_1', metadata);
122
154
  let first = null;
@@ -131,6 +131,43 @@ describe('PIIScrubber', () => {
131
131
  assert.equal(scrubber.scrub(input), 'cd ~');
132
132
  });
133
133
 
134
+ it('does not scrub CSS pseudo-elements as IPv6', () => {
135
+ const input = '.hero-icon::before { content: ""; }';
136
+ assert.equal(scrubber.scrub(input), input);
137
+ });
138
+
139
+ it('does not scrub CSS pseudo-elements with hex-like class names', () => {
140
+ assert.equal(scrubber.scrub('.page::before { content: ""; }'), '.page::before { content: ""; }');
141
+ assert.equal(scrubber.scrub('.page::-webkit-scrollbar { width: 8px; }'), '.page::-webkit-scrollbar { width: 8px; }');
142
+ assert.equal(scrubber.scrub('.cafe::after { display: block; }'), '.cafe::after { display: block; }');
143
+ assert.equal(scrubber.scrub('::placeholder { color: gray; }'), '::placeholder { color: gray; }');
144
+ });
145
+
146
+ it('still scrubs IPv6 loopback ::1', () => {
147
+ const input = 'listening on ::1 port 3000';
148
+ assert.equal(scrubber.scrub(input), 'listening on [IP] port 3000');
149
+ });
150
+
151
+ it('scrubs compressed IPv6 addresses completely', () => {
152
+ assert.equal(scrubber.scrub('addr fe80::1 here'), 'addr [IP] here');
153
+ assert.equal(scrubber.scrub('addr fe80:: here'), 'addr [IP] here');
154
+ assert.equal(scrubber.scrub('addr 2001:db8::1 here'), 'addr [IP] here');
155
+ });
156
+
157
+ it('does not scrub file paths as base64 secrets', () => {
158
+ const input = '/home/user/project/groove/packages/gui/src/views/settings.jsx';
159
+ const result = scrubber.scrub(input);
160
+ assert.ok(!result.includes('[API_KEY]'), `expected no [API_KEY] in: ${result}`);
161
+ });
162
+
163
+ it('still scrubs real base64 secrets without slashes', () => {
164
+ const b64 = 'YWJjZGVmZ2hpamtsbW5vcHFyc3R1dnd4eXoxMjM0NTY3ODk';
165
+ const input = `key: ${b64} end`;
166
+ const result = scrubber.scrub(input);
167
+ assert.ok(result.includes('[API_KEY]'), `expected [API_KEY] in: ${result}`);
168
+ assert.ok(!result.includes(b64));
169
+ });
170
+
134
171
  it('patterns do not interfere with each other', () => {
135
172
  const input = 'user@example.com called 555-123-4567 from 192.168.1.1';
136
173
  const result = scrubber.scrub(input);
@@ -5,18 +5,54 @@ import assert from 'node:assert/strict';
5
5
  import { StepClassifier } from '../../client/step-classifier.js';
6
6
 
7
7
  describe('StepClassifier', () => {
8
- it('user message before any action is not a correction', () => {
8
+ it('user message before any action is classified as instruction', () => {
9
9
  const classifier = new StepClassifier();
10
10
  const result = classifier.classifyUserMessage('fix the bug');
11
- assert.equal(result, null);
11
+ assert.equal(result.type, 'instruction');
12
+ assert.equal(result.content, 'fix the bug');
13
+ assert.equal(result.source, 'user');
12
14
  });
13
15
 
14
- it('user message after action is a correction', () => {
16
+ it('user correction after action is classified as correction', () => {
15
17
  const classifier = new StepClassifier();
16
18
  classifier.onStep({ type: 'action' });
17
- const result = classifier.classifyUserMessage('no, use exponential backoff');
19
+ const result = classifier.classifyUserMessage('no, that\'s wrong, use exponential backoff');
18
20
  assert.equal(result.type, 'correction');
19
- assert.equal(result.content, 'no, use exponential backoff');
21
+ assert.equal(result.content, 'no, that\'s wrong, use exponential backoff');
22
+ assert.equal(result.source, 'user');
23
+ });
24
+
25
+ it('user approval after action is classified as approval', () => {
26
+ const classifier = new StepClassifier();
27
+ classifier.onStep({ type: 'action' });
28
+ const result = classifier.classifyUserMessage('looks good, ship it');
29
+ assert.equal(result.type, 'approval');
30
+ });
31
+
32
+ it('user clarification after action is classified as clarification', () => {
33
+ const classifier = new StepClassifier();
34
+ classifier.onStep({ type: 'action' });
35
+ const result = classifier.classifyUserMessage('to clarify, I meant the sidebar component');
36
+ assert.equal(result.type, 'clarification');
37
+ });
38
+
39
+ it('new instruction after action defaults to instruction', () => {
40
+ const classifier = new StepClassifier();
41
+ classifier.onStep({ type: 'action' });
42
+ const result = classifier.classifyUserMessage('now add pagination to the list view');
43
+ assert.equal(result.type, 'instruction');
44
+ });
45
+
46
+ it('passes source through from caller', () => {
47
+ const classifier = new StepClassifier();
48
+ const result = classifier.classifyUserMessage('deploy the backend', 'planner');
49
+ assert.equal(result.source, 'planner');
50
+ assert.equal(result.type, 'instruction');
51
+ });
52
+
53
+ it('defaults source to user', () => {
54
+ const classifier = new StepClassifier();
55
+ const result = classifier.classifyUserMessage('do the thing');
20
56
  assert.equal(result.source, 'user');
21
57
  });
22
58
 
@@ -66,17 +102,27 @@ describe('StepClassifier', () => {
66
102
  assert.equal(StepClassifier.detectErrorRecovery(steps), false);
67
103
  });
68
104
 
69
- it('counts user interventions', () => {
105
+ it('counts corrections and clarifications as interventions', () => {
70
106
  const steps = [
71
107
  { type: 'thought' },
72
108
  { type: 'correction' },
73
109
  { type: 'action' },
74
- { type: 'correction' },
110
+ { type: 'clarification' },
75
111
  { type: 'resolution' },
76
112
  ];
77
113
  assert.equal(StepClassifier.countUserInterventions(steps), 2);
78
114
  });
79
115
 
116
+ it('does not count instruction or approval as interventions', () => {
117
+ const steps = [
118
+ { type: 'instruction' },
119
+ { type: 'action' },
120
+ { type: 'approval' },
121
+ { type: 'resolution' },
122
+ ];
123
+ assert.equal(StepClassifier.countUserInterventions(steps), 0);
124
+ });
125
+
80
126
  it('counts zero interventions when none present', () => {
81
127
  const steps = [
82
128
  { type: 'thought' },
@@ -117,6 +163,14 @@ describe('StepClassifier', () => {
117
163
  assert.equal(result.correction_context, true);
118
164
  });
119
165
 
166
+ it('marks thought after instruction as correction_context when fix signal present', () => {
167
+ const classifier = new StepClassifier();
168
+ classifier.onStep({ type: 'instruction', content: 'fix the login page' });
169
+ const step = { type: 'thought', content: 'I see the issue, let me fix the validation' };
170
+ const result = classifier.onStep(step);
171
+ assert.equal(result.correction_context, true);
172
+ });
173
+
120
174
  it('does not mark thought as correction_context without prior correction', () => {
121
175
  const classifier = new StepClassifier();
122
176
  classifier.onStep({ type: 'action', content: 'running test' });
@@ -175,3 +229,38 @@ describe('StepClassifier', () => {
175
229
  assert.equal(result.type, 'error');
176
230
  });
177
231
  });
232
+
233
+ describe('StepClassifier.classifyIntent', () => {
234
+ it('classifies corrections', () => {
235
+ assert.equal(StepClassifier.classifyIntent("no, that's wrong"), 'correction');
236
+ assert.equal(StepClassifier.classifyIntent("that's not what I wanted"), 'correction');
237
+ assert.equal(StepClassifier.classifyIntent('undo that change'), 'correction');
238
+ assert.equal(StepClassifier.classifyIntent('revert the last edit'), 'correction');
239
+ assert.equal(StepClassifier.classifyIntent('you missed the edge case'), 'correction');
240
+ });
241
+
242
+ it('classifies approvals', () => {
243
+ assert.equal(StepClassifier.classifyIntent('looks good'), 'approval');
244
+ assert.equal(StepClassifier.classifyIntent('lgtm, ship it'), 'approval');
245
+ assert.equal(StepClassifier.classifyIntent("that's correct"), 'approval');
246
+ assert.equal(StepClassifier.classifyIntent('go ahead with that approach'), 'approval');
247
+ });
248
+
249
+ it('classifies clarifications', () => {
250
+ assert.equal(StepClassifier.classifyIntent('to clarify, I meant the sidebar'), 'clarification');
251
+ assert.equal(StepClassifier.classifyIntent('what I want is the mobile layout'), 'clarification');
252
+ assert.equal(StepClassifier.classifyIntent('let me rephrase — update the header'), 'clarification');
253
+ });
254
+
255
+ it('defaults to instruction for new directions', () => {
256
+ assert.equal(StepClassifier.classifyIntent('now add pagination to the list'), 'instruction');
257
+ assert.equal(StepClassifier.classifyIntent('also update the README'), 'instruction');
258
+ assert.equal(StepClassifier.classifyIntent('can you refactor the auth module'), 'instruction');
259
+ });
260
+
261
+ it('returns instruction for null/empty input', () => {
262
+ assert.equal(StepClassifier.classifyIntent(null), 'instruction');
263
+ assert.equal(StepClassifier.classifyIntent(''), 'instruction');
264
+ assert.equal(StepClassifier.classifyIntent(undefined), 'instruction');
265
+ });
266
+ });
@@ -29,12 +29,17 @@ function makeCtx(overrides = {}) {
29
29
  session_quality: overrides.quality ?? 80,
30
30
  },
31
31
  builder: {
32
- buildSessionClose: (outcome) => ({
33
- envelope_id: 'env_test_close',
34
- session_id: 'sess_test_1',
35
- type: 'SESSION_CLOSE',
36
- outcome,
37
- }),
32
+ _metadata: {},
33
+ updateMetadata(updates) { Object.assign(this._metadata, updates); },
34
+ buildSessionClose: function (outcome) {
35
+ return {
36
+ envelope_id: 'env_test_close',
37
+ session_id: 'sess_test_1',
38
+ type: 'SESSION_CLOSE',
39
+ metadata: { ...this._metadata },
40
+ outcome,
41
+ };
42
+ },
38
43
  },
39
44
  };
40
45
  }
@@ -439,6 +444,48 @@ describe('TrajectoryCapture — _computeQuality', () => {
439
444
  });
440
445
  });
441
446
 
447
+ describe('TrajectoryCapture — domain_tags in SESSION_CLOSE', () => {
448
+ it('domain_tags set on ctx.metadata flow into SESSION_CLOSE via updateMetadata', () => {
449
+ const tc = makeTc();
450
+ const captured = [];
451
+ tc._signAndTransmit = (_sid, envelope) => { captured.push(envelope); };
452
+
453
+ const ctx = makeCtx();
454
+ ctx.metadata.domain_tags = {
455
+ primary: { domain: 'react_frontend', confidence: 0.3 },
456
+ secondary: { domain: 'typescript_node', confidence: 0.25 },
457
+ tertiary: { domain: 'python', confidence: 0 },
458
+ };
459
+
460
+ ctx.builder.updateMetadata({
461
+ domain_tags: ctx.metadata.domain_tags,
462
+ session_quality: ctx.metadata.session_quality,
463
+ });
464
+ const close = ctx.builder.buildSessionClose({
465
+ status: 'SUCCESS', total_steps: 10, total_chunks: 1,
466
+ });
467
+
468
+ assert.ok(close.metadata, 'SESSION_CLOSE must have metadata');
469
+ assert.deepEqual(close.metadata.domain_tags, ctx.metadata.domain_tags);
470
+ });
471
+
472
+ it('SESSION_CLOSE metadata is absent domain_tags when tagger returns null', () => {
473
+ const ctx = makeCtx();
474
+ ctx.metadata.domain_tags = null;
475
+
476
+ ctx.builder.updateMetadata({
477
+ domain_tags: null,
478
+ session_quality: ctx.metadata.session_quality,
479
+ });
480
+ const close = ctx.builder.buildSessionClose({
481
+ status: 'SUCCESS', total_steps: 5, total_chunks: 1,
482
+ });
483
+
484
+ assert.ok(close.metadata, 'SESSION_CLOSE must have metadata');
485
+ assert.equal(close.metadata.domain_tags, null);
486
+ });
487
+ });
488
+
442
489
  describe('TrajectoryCapture — onParsedOutput', () => {
443
490
  function makeEnabledTc() {
444
491
  const tc = makeTc();
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@groove-dev/cli",
3
- "version": "0.27.106",
3
+ "version": "0.27.108",
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",
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@groove-dev/daemon",
3
- "version": "0.27.106",
3
+ "version": "0.27.108",
4
4
  "description": "GROOVE daemon — agent orchestration engine",
5
5
  "license": "FSL-1.1-Apache-2.0",
6
6
  "type": "module",
@@ -62,12 +62,12 @@ async function _executeApprovalRetry(daemon, approval) {
62
62
  return;
63
63
  }
64
64
  if (rp.agentId) {
65
- await daemon.processes.sendMessage(rp.agentId, `Your ${rp.type === 'integration_exec' ? 'integration action' : 'upload'} was approved and executed successfully. Result: ${resultText}`);
65
+ await daemon.processes.sendMessage(rp.agentId, `Your ${rp.type === 'integration_exec' ? 'integration action' : 'upload'} was approved and executed successfully. Result: ${resultText}`, 'system');
66
66
  }
67
67
  } catch (err) {
68
68
  console.log(`[Groove] Auto-retry for approval ${approval.id} failed: ${err.message}`);
69
69
  if (rp.agentId) {
70
- daemon.processes.sendMessage(rp.agentId, `Your ${rp.type === 'integration_exec' ? 'integration action' : 'upload'} was approved but execution failed: ${err.message}`).catch(() => {});
70
+ daemon.processes.sendMessage(rp.agentId, `Your ${rp.type === 'integration_exec' ? 'integration action' : 'upload'} was approved but execution failed: ${err.message}`, 'system').catch(() => {});
71
71
  }
72
72
  }
73
73
  }
@@ -740,9 +740,10 @@ export function createApi(app, daemon) {
740
740
  };
741
741
 
742
742
  const proc = spawn('codex', ['login'], {
743
- stdio: ['ignore', 'pipe', 'pipe'],
743
+ stdio: ['pipe', 'pipe', 'pipe'],
744
744
  shell: true,
745
745
  });
746
+ proc.stdin.on('error', () => {});
746
747
  let stdout = '';
747
748
  let stderr = '';
748
749
  proc.stdout.on('data', (d) => { stdout += d.toString(); });
@@ -751,8 +752,8 @@ export function createApi(app, daemon) {
751
752
  const timeout = setTimeout(() => {
752
753
  const urlMatch = (stdout + stderr).match(/https:\/\/\S+/);
753
754
  respond(urlMatch
754
- ? { status: 'pending', url: urlMatch[0] }
755
- : { status: 'pending', message: 'Login started — check your browser' });
755
+ ? { status: 'pending', url: urlMatch[0], browserOpened: true }
756
+ : { status: 'pending', message: 'Login started — check your browser', browserOpened: true });
756
757
  }, 5000);
757
758
 
758
759
  proc.on('close', (code) => {
@@ -4210,6 +4211,33 @@ Keep responses concise. Help them think, don't lecture them about the system the
4210
4211
  res.json(daemon.federation.getStatus());
4211
4212
  });
4212
4213
 
4214
+ app.get('/api/federation/test', async (req, res) => {
4215
+ const target = req.query.target;
4216
+ if (!target) return res.status(400).json({ error: 'target required' });
4217
+ const host = target.split(':')[0];
4218
+ const privatePatterns = [
4219
+ /^127\./, /^10\./, /^192\.168\./, /^172\.(1[6-9]|2\d|3[01])\./,
4220
+ /^0\./, /^169\.254\./, /^localhost$/i, /^::1$/, /^\[::1\]$/,
4221
+ /^0\.0\.0\.0$/, /^fc/i, /^fd/i, /^fe80/i,
4222
+ ];
4223
+ if (privatePatterns.some(p => p.test(host))) {
4224
+ return res.status(400).json({ error: 'Private/local addresses are not allowed' });
4225
+ }
4226
+ try {
4227
+ const controller = new AbortController();
4228
+ const timeout = setTimeout(() => controller.abort(), 5000);
4229
+ const resp = await fetch(`http://${target}/api/health`, { signal: controller.signal });
4230
+ clearTimeout(timeout);
4231
+ if (resp.ok) {
4232
+ const data = await resp.json();
4233
+ return res.json({ reachable: true, version: data.version, peerId: data.daemonId, agents: data.agents });
4234
+ }
4235
+ res.json({ reachable: false });
4236
+ } catch {
4237
+ res.json({ reachable: false });
4238
+ }
4239
+ });
4240
+
4213
4241
  // List peers
4214
4242
  app.get('/api/federation/peers', (req, res) => {
4215
4243
  res.json(daemon.federation.getPeers());
@@ -4665,7 +4693,13 @@ Keep responses concise. Help them think, don't lecture them about the system the
4665
4693
  const hasKey = daemon.credentials.hasKey(p.id);
4666
4694
  let authStatus = 'not-configured';
4667
4695
  if (p.authType === 'subscription') {
4668
- authStatus = p.installed ? 'authenticated' : 'not-configured';
4696
+ if (!p.installed) {
4697
+ authStatus = 'not-configured';
4698
+ } else {
4699
+ const provObj = getProvider(p.id);
4700
+ const authResult = provObj?.constructor?.isAuthenticated?.();
4701
+ authStatus = authResult?.authenticated ? 'authenticated' : 'not-configured';
4702
+ }
4669
4703
  } else if (p.authType === 'api-key') {
4670
4704
  authStatus = hasKey ? 'key-set' : 'not-configured';
4671
4705
  if (p.authStatus?.authenticated) authStatus = 'authenticated';
@@ -1434,7 +1434,7 @@ For normal file edits within your scope, proceed without review.
1434
1434
  if (existing && (existing.status === 'running' || existing.status === 'starting')) {
1435
1435
  // Agent already active — reuse it instead of spawning a duplicate
1436
1436
  if (config.prompt) {
1437
- this.sendMessage(existing.id, config.prompt).catch((err) => {
1437
+ this.sendMessage(existing.id, config.prompt, 'planner').catch((err) => {
1438
1438
  console.error(`[Groove] Phase 2 reuse message failed for ${existing.name}: ${err.message}`);
1439
1439
  });
1440
1440
  }
@@ -1527,7 +1527,7 @@ For normal file edits within your scope, proceed without review.
1527
1527
  const message = `Your teammate ${completedAgent.name} (${completedAgent.role}) just finished their work.${fileList}${result ? `\n\nTheir summary:\n${result.slice(0, 2000)}` : ''}\n\nPlease audit their changes: verify correctness, check for bugs, run tests if available, and report any issues.`;
1528
1528
 
1529
1529
  // Send message to the QC agent via the instruct flow
1530
- this.sendMessage(qc.id, message).catch((err) => {
1530
+ this.sendMessage(qc.id, message, 'system').catch((err) => {
1531
1531
  console.error(`[Groove] QC auto-trigger failed: ${err.message}`);
1532
1532
  });
1533
1533
 
@@ -1651,7 +1651,7 @@ For normal file edits within your scope, proceed without review.
1651
1651
  if (target.status === 'running') {
1652
1652
  let sent = false;
1653
1653
  if (this.hasAgentLoop(target.id)) {
1654
- this.sendMessage(target.id, message).catch(() => {});
1654
+ this.sendMessage(target.id, message, 'agent').catch(() => {});
1655
1655
  sent = true;
1656
1656
  }
1657
1657
  if (!sent && this.daemon.journalist) {
@@ -2189,7 +2189,7 @@ For normal file edits within your scope, proceed without review.
2189
2189
  * Send a message to a running agent loop.
2190
2190
  * Returns true if the message was sent, false if the agent doesn't have an active loop.
2191
2191
  */
2192
- async sendMessage(agentId, message) {
2192
+ async sendMessage(agentId, message, source = 'user') {
2193
2193
  const handle = this.handles.get(agentId);
2194
2194
  if (!handle?.loop) return false;
2195
2195
 
@@ -2200,7 +2200,7 @@ For normal file edits within your scope, proceed without review.
2200
2200
  const wrapped = agent ? wrapWithRoleReminder(agent.role, message) : message;
2201
2201
 
2202
2202
  if (this.daemon.trajectoryCapture) {
2203
- try { this.daemon.trajectoryCapture.onUserMessage(agentId, message); } catch (e) { /* fail silent */ }
2203
+ try { this.daemon.trajectoryCapture.onUserMessage(agentId, message, source); } catch (e) { /* fail silent */ }
2204
2204
  }
2205
2205
 
2206
2206
  loop.sendMessage(wrapped).catch(() => {});