groove-dev 0.27.116 → 0.27.117

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.
@@ -169,6 +169,26 @@ const DOMAIN_TAXONOMY = {
169
169
  keywords: ['scientific', 'simulation', 'matlab', 'scipy', 'julia', 'fortran', 'numerical', 'differential equation', 'finite element', 'linear algebra'],
170
170
  description: 'Scientific computing, numerical methods, MATLAB/SciPy/Julia, simulations, optimization, statistics',
171
171
  },
172
+ planning_strategy: {
173
+ keywords: ['plan', 'strategy', 'architect', 'design doc', 'breakdown', 'scope', 'roadmap', 'milestone', 'prioritize', 'tradeoff', 'approach', 'recommend', 'team', 'coordinate', 'delegate', 'assign'],
174
+ description: 'Project planning, task breakdown, architecture decisions, team coordination, strategy, roadmaps, scoping, prioritization',
175
+ },
176
+ conversational_reasoning: {
177
+ keywords: ['explain', 'why', 'how does', 'what is', 'clarify', 'understand', 'reason', 'think through', 'analyze', 'compare', 'evaluate', 'brainstorm', 'discuss', 'opinion', 'advice'],
178
+ description: 'Conversational reasoning, explanation, analysis, brainstorming, Q&A, decision-making, advice, evaluation',
179
+ },
180
+ documentation_writing: {
181
+ keywords: ['readme', 'documentation', 'docs', 'markdown', 'api docs', 'changelog', 'tutorial', 'guide', 'specification', 'wiki', 'jsdoc', 'docstring', 'technical writing'],
182
+ description: 'Documentation writing, READMEs, API docs, changelogs, tutorials, guides, technical writing, specifications',
183
+ },
184
+ product_design: {
185
+ keywords: ['product', 'feature', 'user story', 'requirements', 'ux', 'wireframe', 'prototype', 'feedback', 'iteration', 'mvp', 'spec', 'acceptance criteria', 'stakeholder'],
186
+ description: 'Product design, feature planning, user stories, requirements gathering, UX, prototyping, MVPs, stakeholder communication',
187
+ },
188
+ devops_general: {
189
+ keywords: ['deploy', 'deployment', 'release', 'rollback', 'staging', 'production', 'environment', 'migration', 'upgrade', 'maintenance', 'incident', 'postmortem', 'runbook'],
190
+ description: 'DevOps operations, deployments, releases, rollbacks, environment management, incident response, runbooks',
191
+ },
172
192
  };
173
193
 
174
194
  export class DomainTagger {
@@ -33,9 +33,11 @@ export class TrajectoryCapture {
33
33
  this._transmissionQueue = null;
34
34
  this._offlineRetryTimer = null;
35
35
  this._contexts = new Map();
36
+ this._shutdown = false;
36
37
  }
37
38
 
38
39
  async init() {
40
+ if (this._shutdown) return;
39
41
  if (!ConsentManager.isCaptureEnabled()) {
40
42
  this._enabled = false;
41
43
  return;
@@ -47,12 +49,16 @@ export class TrajectoryCapture {
47
49
  this._transmissionQueue.start();
48
50
  this._domainTagger = new DomainTagger();
49
51
  await this._domainTagger.init();
52
+ if (this._shutdown) return;
50
53
  this._offlineRetryTimer = setInterval(() => {
51
54
  this._retryOfflineQueue();
52
55
  }, OFFLINE_RETRY_INTERVAL_MS);
56
+ if (typeof this._offlineRetryTimer.unref === 'function') {
57
+ this._offlineRetryTimer.unref();
58
+ }
53
59
  }
54
60
 
55
- async onAgentSpawn(agentId, provider, model, role, teamSize) {
61
+ async onAgentSpawn(agentId, provider, model, role, teamSize, prompt) {
56
62
  if (!this._enabled) return;
57
63
 
58
64
  const parser = getParser(provider);
@@ -98,9 +104,20 @@ export class TrajectoryCapture {
98
104
  ctx.chunkTimer = setInterval(() => {
99
105
  this._flushContext(agentId);
100
106
  }, CHUNK_TIMEOUT_MS);
107
+ if (typeof ctx.chunkTimer.unref === 'function') {
108
+ ctx.chunkTimer.unref();
109
+ }
101
110
 
102
111
  this._contexts.set(agentId, ctx);
103
112
 
113
+ if (prompt && typeof prompt === 'string' && prompt.trim()) {
114
+ this._processStep(agentId, ctx, {
115
+ type: 'instruction',
116
+ content: prompt.slice(0, USER_MESSAGE_MAX_CHARS),
117
+ source: 'user',
118
+ });
119
+ }
120
+
104
121
  await this._attestation.openSession(sessionId, metadata);
105
122
  }
106
123
 
@@ -205,6 +222,7 @@ export class TrajectoryCapture {
205
222
  }
206
223
 
207
224
  async shutdown() {
225
+ this._shutdown = true;
208
226
  if (this._offlineRetryTimer) clearInterval(this._offlineRetryTimer);
209
227
  for (const agentId of this._contexts.keys()) {
210
228
  await this._closeAgent(agentId, 'SHUTDOWN');
@@ -368,6 +386,23 @@ export class TrajectoryCapture {
368
386
  }
369
387
 
370
388
  _computeTrainingEligibility(ctx, durationSeconds) {
389
+ const role = ctx.metadata.agent_role || '';
390
+ const isConversational = role === 'planner' || role === 'chat' || role === 'advisor';
391
+
392
+ if (ctx.totalTokens < TRAINING_MIN_TOKENS) {
393
+ return { eligible: false, exclusionReason: 'insufficient_tokens' };
394
+ }
395
+ if (durationSeconds < TRAINING_MIN_DURATION) {
396
+ return { eligible: false, exclusionReason: 'too_short' };
397
+ }
398
+
399
+ if (isConversational) {
400
+ if (ctx.stepCount < 2) {
401
+ return { eligible: false, exclusionReason: 'too_few_steps' };
402
+ }
403
+ return { eligible: true, exclusionReason: null };
404
+ }
405
+
371
406
  if (ctx.stepCount < TRAINING_MIN_STEPS) {
372
407
  return { eligible: false, exclusionReason: 'too_few_steps' };
373
408
  }
@@ -379,12 +414,6 @@ export class TrajectoryCapture {
379
414
  if (!hasObservation) {
380
415
  return { eligible: false, exclusionReason: 'no_observations' };
381
416
  }
382
- if (ctx.totalTokens < TRAINING_MIN_TOKENS) {
383
- return { eligible: false, exclusionReason: 'insufficient_tokens' };
384
- }
385
- if (durationSeconds < TRAINING_MIN_DURATION) {
386
- return { eligible: false, exclusionReason: 'too_short' };
387
- }
388
417
  return { eligible: true, exclusionReason: null };
389
418
  }
390
419
 
@@ -208,7 +208,7 @@ describe('TrajectoryCapture — training eligibility', () => {
208
208
  assert.equal(result.exclusionReason, null);
209
209
  });
210
210
 
211
- it('exclusion reasons follow priority order', () => {
211
+ it('exclusion reasons follow priority order: tokens before duration before steps', () => {
212
212
  const tc = makeTc();
213
213
  const ctx = makeCtx({
214
214
  stepCount: 3,
@@ -220,8 +220,189 @@ describe('TrajectoryCapture — training eligibility', () => {
220
220
  ],
221
221
  });
222
222
  const result = tc._computeTrainingEligibility(ctx, 5);
223
+ assert.equal(result.exclusionReason, 'insufficient_tokens');
224
+ });
225
+
226
+ it('duration checked before steps when tokens pass', () => {
227
+ const tc = makeTc();
228
+ const ctx = makeCtx({
229
+ stepCount: 3,
230
+ totalTokens: 5000,
231
+ allSteps: [
232
+ { step: 1, type: 'thought', content: 'thinking' },
233
+ { step: 2, type: 'thought', content: 'more' },
234
+ { step: 3, type: 'thought', content: 'done' },
235
+ ],
236
+ });
237
+ const result = tc._computeTrainingEligibility(ctx, 5);
238
+ assert.equal(result.exclusionReason, 'too_short');
239
+ });
240
+
241
+ it('steps checked after tokens and duration pass', () => {
242
+ const tc = makeTc();
243
+ const ctx = makeCtx({
244
+ stepCount: 3,
245
+ totalTokens: 5000,
246
+ allSteps: [
247
+ { step: 1, type: 'thought', content: 'thinking' },
248
+ { step: 2, type: 'thought', content: 'more' },
249
+ { step: 3, type: 'thought', content: 'done' },
250
+ ],
251
+ });
252
+ const result = tc._computeTrainingEligibility(ctx, 60);
253
+ assert.equal(result.exclusionReason, 'too_few_steps');
254
+ });
255
+ });
256
+
257
+ describe('TrajectoryCapture — planner/conversational eligibility', () => {
258
+ function makeConversationalCtx(role, overrides = {}) {
259
+ const ctx = makeCtx(overrides);
260
+ ctx.metadata.agent_role = role;
261
+ return ctx;
262
+ }
263
+
264
+ it('planner eligible with only thoughts (no actions/observations)', () => {
265
+ const tc = makeTc();
266
+ const ctx = makeConversationalCtx('planner', {
267
+ stepCount: 10,
268
+ totalTokens: 2000,
269
+ allSteps: Array.from({ length: 10 }, (_, i) => ({ step: i + 1, type: 'thought', content: 'planning' })),
270
+ });
271
+ const result = tc._computeTrainingEligibility(ctx, 60);
272
+ assert.equal(result.eligible, true);
273
+ assert.equal(result.exclusionReason, null);
274
+ });
275
+
276
+ it('chat role eligible with only thoughts', () => {
277
+ const tc = makeTc();
278
+ const ctx = makeConversationalCtx('chat', {
279
+ stepCount: 5,
280
+ totalTokens: 1000,
281
+ allSteps: [
282
+ { step: 1, type: 'instruction', content: 'explain React hooks' },
283
+ { step: 2, type: 'thought', content: 'explaining' },
284
+ { step: 3, type: 'thought', content: 'more detail' },
285
+ { step: 4, type: 'thought', content: 'examples' },
286
+ { step: 5, type: 'resolution', content: 'done' },
287
+ ],
288
+ });
289
+ const result = tc._computeTrainingEligibility(ctx, 30);
290
+ assert.equal(result.eligible, true);
291
+ });
292
+
293
+ it('advisor role eligible with only thoughts', () => {
294
+ const tc = makeTc();
295
+ const ctx = makeConversationalCtx('advisor', {
296
+ stepCount: 3,
297
+ totalTokens: 800,
298
+ allSteps: [
299
+ { step: 1, type: 'instruction', content: 'review approach' },
300
+ { step: 2, type: 'thought', content: 'analysis' },
301
+ { step: 3, type: 'resolution', content: 'recommendation' },
302
+ ],
303
+ });
304
+ const result = tc._computeTrainingEligibility(ctx, 20);
305
+ assert.equal(result.eligible, true);
306
+ });
307
+
308
+ it('planner still requires minimum tokens', () => {
309
+ const tc = makeTc();
310
+ const ctx = makeConversationalCtx('planner', {
311
+ stepCount: 10,
312
+ totalTokens: 100,
313
+ allSteps: Array.from({ length: 10 }, (_, i) => ({ step: i + 1, type: 'thought', content: 'plan' })),
314
+ });
315
+ const result = tc._computeTrainingEligibility(ctx, 60);
316
+ assert.equal(result.eligible, false);
317
+ assert.equal(result.exclusionReason, 'insufficient_tokens');
318
+ });
319
+
320
+ it('planner still requires minimum duration', () => {
321
+ const tc = makeTc();
322
+ const ctx = makeConversationalCtx('planner', {
323
+ stepCount: 10,
324
+ totalTokens: 2000,
325
+ allSteps: Array.from({ length: 10 }, (_, i) => ({ step: i + 1, type: 'thought', content: 'plan' })),
326
+ });
327
+ const result = tc._computeTrainingEligibility(ctx, 5);
328
+ assert.equal(result.eligible, false);
329
+ assert.equal(result.exclusionReason, 'too_short');
330
+ });
331
+
332
+ it('planner requires at least 2 steps', () => {
333
+ const tc = makeTc();
334
+ const ctx = makeConversationalCtx('planner', {
335
+ stepCount: 1,
336
+ totalTokens: 2000,
337
+ allSteps: [{ step: 1, type: 'thought', content: 'plan' }],
338
+ });
339
+ const result = tc._computeTrainingEligibility(ctx, 60);
340
+ assert.equal(result.eligible, false);
223
341
  assert.equal(result.exclusionReason, 'too_few_steps');
224
342
  });
343
+
344
+ it('coding role (fullstack) still requires actions and observations', () => {
345
+ const tc = makeTc();
346
+ const ctx = makeConversationalCtx('fullstack', {
347
+ stepCount: 10,
348
+ totalTokens: 2000,
349
+ allSteps: Array.from({ length: 10 }, (_, i) => ({ step: i + 1, type: 'thought', content: 'thinking' })),
350
+ });
351
+ const result = tc._computeTrainingEligibility(ctx, 60);
352
+ assert.equal(result.eligible, false);
353
+ assert.equal(result.exclusionReason, 'no_actions');
354
+ });
355
+ });
356
+
357
+ describe('TrajectoryCapture — initial prompt capture', () => {
358
+ function makeSpawnTc() {
359
+ const tc = makeTc();
360
+ tc._enabled = true;
361
+ tc._scrubber = { scrub: (s) => s };
362
+ tc._attestation = { openSession: async () => {}, signEnvelope: (sid, e) => e };
363
+ tc._transmissionQueue = { enqueue: () => {} };
364
+ tc._domainTagger = null;
365
+ return tc;
366
+ }
367
+
368
+ it('onAgentSpawn records prompt as instruction step', async () => {
369
+ const tc = makeSpawnTc();
370
+ await tc.onAgentSpawn('agent-p1', 'claude-code', 'opus', 'planner', 1, 'Build a React app');
371
+
372
+ const ctx = tc._contexts.get('agent-p1');
373
+ assert.ok(ctx);
374
+ assert.equal(ctx.stepCount, 1);
375
+ assert.equal(ctx.allSteps[0].type, 'instruction');
376
+ assert.ok(ctx.allSteps[0].content.includes('Build a React app'));
377
+ assert.equal(ctx.allSteps[0].source, 'user');
378
+ });
379
+
380
+ it('onAgentSpawn with no prompt creates no instruction step', async () => {
381
+ const tc = makeSpawnTc();
382
+ await tc.onAgentSpawn('agent-p2', 'claude-code', 'opus', 'fullstack', 1);
383
+
384
+ const ctx = tc._contexts.get('agent-p2');
385
+ assert.ok(ctx);
386
+ assert.equal(ctx.stepCount, 0);
387
+ assert.equal(ctx.allSteps.length, 0);
388
+ });
389
+
390
+ it('onAgentSpawn truncates long prompts', async () => {
391
+ const tc = makeSpawnTc();
392
+ const longPrompt = 'x'.repeat(50000);
393
+ await tc.onAgentSpawn('agent-p3', 'claude-code', 'opus', 'planner', 1, longPrompt);
394
+
395
+ const ctx = tc._contexts.get('agent-p3');
396
+ assert.ok(ctx.allSteps[0].content.length <= 10001);
397
+ });
398
+
399
+ it('onAgentSpawn ignores empty/whitespace prompts', async () => {
400
+ const tc = makeSpawnTc();
401
+ await tc.onAgentSpawn('agent-p4', 'claude-code', 'opus', 'planner', 1, ' ');
402
+
403
+ const ctx = tc._contexts.get('agent-p4');
404
+ assert.equal(ctx.stepCount, 0);
405
+ });
225
406
  });
226
407
 
227
408
  describe('TrajectoryCapture — user feedback emission', () => {
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@groove-dev/cli",
3
- "version": "0.27.116",
3
+ "version": "0.27.117",
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.116",
3
+ "version": "0.27.117",
4
4
  "description": "GROOVE daemon — agent orchestration engine",
5
5
  "license": "FSL-1.1-Apache-2.0",
6
6
  "type": "module",
@@ -330,6 +330,7 @@ export class ProcessManager {
330
330
  this._rotatingAgents = new Set(); // agentIds currently being rotated (rotator wrote handoff)
331
331
  this._stalledAgents = new Set(); // agentIds already flagged as stalled (avoids duplicate broadcasts)
332
332
  this._exitHandled = new Set();
333
+ this._resultReceived = new Set();
333
334
 
334
335
  this._stallWatchdog = setInterval(() => this._checkStalls(), STALL_CHECK_INTERVAL_MS);
335
336
  if (this._stallWatchdog.unref) this._stallWatchdog.unref();
@@ -389,6 +390,7 @@ export class ProcessManager {
389
390
  this._exitHandled.add(agentId);
390
391
  setTimeout(() => this._exitHandled.delete(agentId), 30_000);
391
392
  this._stalledAgents.delete(agentId);
393
+ this._resultReceived.delete(agentId);
392
394
  const throttle = this._streamThrottle.get(agentId);
393
395
  if (throttle?.timer) clearTimeout(throttle.timer);
394
396
  this._streamThrottle.delete(agentId);
@@ -425,11 +427,16 @@ export class ProcessManager {
425
427
 
426
428
  if (this.daemon.locks) this.daemon.locks.release(agent.id);
427
429
 
428
- const finalStatus = signal === 'SIGTERM' || signal === 'SIGKILL'
429
- ? 'killed'
430
- : code === 0
431
- ? 'completed'
432
- : 'crashed';
430
+ const hadResult = this._resultReceived.has(agent.id);
431
+ this._resultReceived.delete(agent.id);
432
+
433
+ const finalStatus = hadResult
434
+ ? 'completed'
435
+ : signal === 'SIGTERM' || signal === 'SIGKILL'
436
+ ? 'killed'
437
+ : code === 0
438
+ ? 'completed'
439
+ : 'crashed';
433
440
 
434
441
  const crashError = finalStatus === 'crashed' ? stderrBuf.join('').trim().slice(-500) : null;
435
442
 
@@ -552,7 +559,10 @@ export class ProcessManager {
552
559
 
553
560
  if (this.daemon.locks) this.daemon.locks.release(agent.id);
554
561
 
555
- const finalStatus = signal === 'SIGTERM' || signal === 'SIGKILL' ? 'killed' : code === 0 ? 'completed' : 'crashed';
562
+ const hadResult = this._resultReceived.has(agent.id);
563
+ this._resultReceived.delete(agent.id);
564
+
565
+ const finalStatus = hadResult ? 'completed' : signal === 'SIGTERM' || signal === 'SIGKILL' ? 'killed' : code === 0 ? 'completed' : 'crashed';
556
566
  registry.update(agent.id, { status: finalStatus, pid: null });
557
567
 
558
568
  if (this.daemon.trajectoryCapture) {
@@ -727,7 +737,7 @@ export class ProcessManager {
727
737
  try {
728
738
  const teamSize = registry.getAll().filter(a => a.status === 'active' || a.status === 'running' || a.status === 'starting').length;
729
739
  this.daemon.trajectoryCapture.onAgentSpawn(
730
- agent.id, providerName, config.model || null, config.role, teamSize
740
+ agent.id, providerName, config.model || null, config.role, teamSize, config.prompt
731
741
  ).catch(() => {});
732
742
  } catch (e) { /* fail silent */ }
733
743
  }
@@ -970,6 +980,7 @@ For normal file edits within your scope, proceed without review.
970
980
  logStream.end();
971
981
  this.handles.delete(agent.id);
972
982
  this._stalledAgents.delete(agent.id);
983
+ this._resultReceived.delete(agent.id);
973
984
 
974
985
  // Clean up stream throttle so pending timers don't fire for dead agents
975
986
  const throttle = this._streamThrottle.get(agent.id);
@@ -1262,6 +1273,21 @@ For normal file edits within your scope, proceed without review.
1262
1273
  if (output.cost) updates.costUsd = (agent.costUsd || 0) + output.cost;
1263
1274
  if (output.duration) updates.durationMs = output.duration;
1264
1275
  if (output.turns) updates.turns = output.turns;
1276
+
1277
+ // Claude Code sometimes hangs after emitting the result event — the
1278
+ // process stays alive instead of exiting. Record that the result
1279
+ // arrived so exit handlers know this was a successful completion even
1280
+ // if we have to SIGTERM the process. After a 5s grace period, force-
1281
+ // kill any process that hasn't exited on its own.
1282
+ this._resultReceived.add(agentId);
1283
+ const handle = this.handles.get(agentId);
1284
+ if (handle?.proc && typeof handle.proc.kill === 'function') {
1285
+ setTimeout(() => {
1286
+ if (this.handles.has(agentId) && this._resultReceived.has(agentId)) {
1287
+ try { handle.proc.kill('SIGTERM'); } catch {}
1288
+ }
1289
+ }, 5_000);
1290
+ }
1265
1291
  }
1266
1292
 
1267
1293
  // Context window usage (0-1 scale) — drives rotation threshold
@@ -1845,7 +1871,7 @@ For normal file edits within your scope, proceed without review.
1845
1871
  try {
1846
1872
  const teamSize = registry.getAll().filter(a => a.status === 'active' || a.status === 'running' || a.status === 'starting').length;
1847
1873
  this.daemon.trajectoryCapture.onAgentSpawn(
1848
- newAgent.id, config.provider, config.model || null, config.role, teamSize
1874
+ newAgent.id, config.provider, config.model || null, config.role, teamSize, config.prompt
1849
1875
  ).catch(() => {});
1850
1876
  } catch (e) { /* fail silent */ }
1851
1877
  }
@@ -1987,7 +2013,7 @@ For normal file edits within your scope, proceed without review.
1987
2013
  try {
1988
2014
  const teamSize = registry.getAll().filter(a => a.status === 'active' || a.status === 'running' || a.status === 'starting').length;
1989
2015
  this.daemon.trajectoryCapture.onAgentSpawn(
1990
- newAgent.id, config.provider, loopConfig.model || config.model || null, config.role, teamSize
2016
+ newAgent.id, config.provider, loopConfig.model || config.model || null, config.role, teamSize, config.prompt
1991
2017
  ).catch(() => {});
1992
2018
  } catch (e) { /* fail silent */ }
1993
2019
  }
@@ -274,9 +274,11 @@ export class TunnelManager {
274
274
  throw new Error(testResult.error || 'Host unreachable');
275
275
  }
276
276
 
277
+ let preConnectHandled = false;
277
278
  if (!testResult.daemonRunning && !testResult.grooveInstalled) {
278
279
  this.daemon.broadcast({ type: 'tunnel.status', data: { id, step: 'installing' } });
279
280
  await this.remoteInstall(id);
281
+ preConnectHandled = true;
280
282
  } else if (!testResult.daemonRunning && testResult.grooveInstalled) {
281
283
  const localVer = getLocalVersion();
282
284
  if (testResult.remoteVersion && testResult.remoteVersion !== localVer) {
@@ -285,6 +287,7 @@ export class TunnelManager {
285
287
  }
286
288
  this.daemon.broadcast({ type: 'tunnel.status', data: { id, step: 'starting' } });
287
289
  await this.autoStart(id);
290
+ preConnectHandled = true;
288
291
  }
289
292
 
290
293
  this.daemon.broadcast({ type: 'tunnel.status', data: { id, step: 'connecting' } });
@@ -342,7 +345,9 @@ export class TunnelManager {
342
345
  failCount: 0,
343
346
  });
344
347
 
345
- await this._checkAndUpgradeRunning(id, config, localPort);
348
+ if (!preConnectHandled) {
349
+ await this._checkAndUpgradeRunning(id, config, localPort);
350
+ }
346
351
 
347
352
  try {
348
353
  const statusResp = await fetch(`http://localhost:${localPort}/api/status`, { signal: AbortSignal.timeout(5000) });
@@ -473,6 +478,18 @@ export class TunnelManager {
473
478
 
474
479
  this.daemon.audit.log('tunnel.upgrade', { id, from: oldVersion, to: daemonVer || installedVer });
475
480
  } catch (err) {
481
+ try {
482
+ const verify = await fetch(`http://localhost:${localPort}/api/status`, { signal: AbortSignal.timeout(5000) });
483
+ if (verify.ok) {
484
+ const verifyData = await verify.json();
485
+ if (verifyData.version === localVer) {
486
+ this.daemon.broadcast({ type: 'tunnel.version-info', data: { id, localVersion: localVer, remoteVersion: verifyData.version, match: true } });
487
+ return;
488
+ }
489
+ this.daemon.broadcast({ type: 'tunnel.version-mismatch', data: { id, localVersion: localVer, remoteVersion: verifyData.version, message: 'Upgrade timed out but remote is reachable' } });
490
+ return;
491
+ }
492
+ } catch { /* tunnel verification failed */ }
476
493
  this.daemon.broadcast({ type: 'tunnel.upgrade-failed', data: { id, error: err.message } });
477
494
  }
478
495
  }
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@groove-dev/gui",
3
- "version": "0.27.116",
3
+ "version": "0.27.117",
4
4
  "description": "GROOVE GUI — visual agent control plane",
5
5
  "license": "FSL-1.1-Apache-2.0",
6
6
  "type": "module",
@@ -169,6 +169,26 @@ const DOMAIN_TAXONOMY = {
169
169
  keywords: ['scientific', 'simulation', 'matlab', 'scipy', 'julia', 'fortran', 'numerical', 'differential equation', 'finite element', 'linear algebra'],
170
170
  description: 'Scientific computing, numerical methods, MATLAB/SciPy/Julia, simulations, optimization, statistics',
171
171
  },
172
+ planning_strategy: {
173
+ keywords: ['plan', 'strategy', 'architect', 'design doc', 'breakdown', 'scope', 'roadmap', 'milestone', 'prioritize', 'tradeoff', 'approach', 'recommend', 'team', 'coordinate', 'delegate', 'assign'],
174
+ description: 'Project planning, task breakdown, architecture decisions, team coordination, strategy, roadmaps, scoping, prioritization',
175
+ },
176
+ conversational_reasoning: {
177
+ keywords: ['explain', 'why', 'how does', 'what is', 'clarify', 'understand', 'reason', 'think through', 'analyze', 'compare', 'evaluate', 'brainstorm', 'discuss', 'opinion', 'advice'],
178
+ description: 'Conversational reasoning, explanation, analysis, brainstorming, Q&A, decision-making, advice, evaluation',
179
+ },
180
+ documentation_writing: {
181
+ keywords: ['readme', 'documentation', 'docs', 'markdown', 'api docs', 'changelog', 'tutorial', 'guide', 'specification', 'wiki', 'jsdoc', 'docstring', 'technical writing'],
182
+ description: 'Documentation writing, READMEs, API docs, changelogs, tutorials, guides, technical writing, specifications',
183
+ },
184
+ product_design: {
185
+ keywords: ['product', 'feature', 'user story', 'requirements', 'ux', 'wireframe', 'prototype', 'feedback', 'iteration', 'mvp', 'spec', 'acceptance criteria', 'stakeholder'],
186
+ description: 'Product design, feature planning, user stories, requirements gathering, UX, prototyping, MVPs, stakeholder communication',
187
+ },
188
+ devops_general: {
189
+ keywords: ['deploy', 'deployment', 'release', 'rollback', 'staging', 'production', 'environment', 'migration', 'upgrade', 'maintenance', 'incident', 'postmortem', 'runbook'],
190
+ description: 'DevOps operations, deployments, releases, rollbacks, environment management, incident response, runbooks',
191
+ },
172
192
  };
173
193
 
174
194
  export class DomainTagger {
@@ -33,9 +33,11 @@ export class TrajectoryCapture {
33
33
  this._transmissionQueue = null;
34
34
  this._offlineRetryTimer = null;
35
35
  this._contexts = new Map();
36
+ this._shutdown = false;
36
37
  }
37
38
 
38
39
  async init() {
40
+ if (this._shutdown) return;
39
41
  if (!ConsentManager.isCaptureEnabled()) {
40
42
  this._enabled = false;
41
43
  return;
@@ -47,12 +49,16 @@ export class TrajectoryCapture {
47
49
  this._transmissionQueue.start();
48
50
  this._domainTagger = new DomainTagger();
49
51
  await this._domainTagger.init();
52
+ if (this._shutdown) return;
50
53
  this._offlineRetryTimer = setInterval(() => {
51
54
  this._retryOfflineQueue();
52
55
  }, OFFLINE_RETRY_INTERVAL_MS);
56
+ if (typeof this._offlineRetryTimer.unref === 'function') {
57
+ this._offlineRetryTimer.unref();
58
+ }
53
59
  }
54
60
 
55
- async onAgentSpawn(agentId, provider, model, role, teamSize) {
61
+ async onAgentSpawn(agentId, provider, model, role, teamSize, prompt) {
56
62
  if (!this._enabled) return;
57
63
 
58
64
  const parser = getParser(provider);
@@ -98,9 +104,20 @@ export class TrajectoryCapture {
98
104
  ctx.chunkTimer = setInterval(() => {
99
105
  this._flushContext(agentId);
100
106
  }, CHUNK_TIMEOUT_MS);
107
+ if (typeof ctx.chunkTimer.unref === 'function') {
108
+ ctx.chunkTimer.unref();
109
+ }
101
110
 
102
111
  this._contexts.set(agentId, ctx);
103
112
 
113
+ if (prompt && typeof prompt === 'string' && prompt.trim()) {
114
+ this._processStep(agentId, ctx, {
115
+ type: 'instruction',
116
+ content: prompt.slice(0, USER_MESSAGE_MAX_CHARS),
117
+ source: 'user',
118
+ });
119
+ }
120
+
104
121
  await this._attestation.openSession(sessionId, metadata);
105
122
  }
106
123
 
@@ -205,6 +222,7 @@ export class TrajectoryCapture {
205
222
  }
206
223
 
207
224
  async shutdown() {
225
+ this._shutdown = true;
208
226
  if (this._offlineRetryTimer) clearInterval(this._offlineRetryTimer);
209
227
  for (const agentId of this._contexts.keys()) {
210
228
  await this._closeAgent(agentId, 'SHUTDOWN');
@@ -368,6 +386,23 @@ export class TrajectoryCapture {
368
386
  }
369
387
 
370
388
  _computeTrainingEligibility(ctx, durationSeconds) {
389
+ const role = ctx.metadata.agent_role || '';
390
+ const isConversational = role === 'planner' || role === 'chat' || role === 'advisor';
391
+
392
+ if (ctx.totalTokens < TRAINING_MIN_TOKENS) {
393
+ return { eligible: false, exclusionReason: 'insufficient_tokens' };
394
+ }
395
+ if (durationSeconds < TRAINING_MIN_DURATION) {
396
+ return { eligible: false, exclusionReason: 'too_short' };
397
+ }
398
+
399
+ if (isConversational) {
400
+ if (ctx.stepCount < 2) {
401
+ return { eligible: false, exclusionReason: 'too_few_steps' };
402
+ }
403
+ return { eligible: true, exclusionReason: null };
404
+ }
405
+
371
406
  if (ctx.stepCount < TRAINING_MIN_STEPS) {
372
407
  return { eligible: false, exclusionReason: 'too_few_steps' };
373
408
  }
@@ -379,12 +414,6 @@ export class TrajectoryCapture {
379
414
  if (!hasObservation) {
380
415
  return { eligible: false, exclusionReason: 'no_observations' };
381
416
  }
382
- if (ctx.totalTokens < TRAINING_MIN_TOKENS) {
383
- return { eligible: false, exclusionReason: 'insufficient_tokens' };
384
- }
385
- if (durationSeconds < TRAINING_MIN_DURATION) {
386
- return { eligible: false, exclusionReason: 'too_short' };
387
- }
388
417
  return { eligible: true, exclusionReason: null };
389
418
  }
390
419
 
@@ -208,7 +208,7 @@ describe('TrajectoryCapture — training eligibility', () => {
208
208
  assert.equal(result.exclusionReason, null);
209
209
  });
210
210
 
211
- it('exclusion reasons follow priority order', () => {
211
+ it('exclusion reasons follow priority order: tokens before duration before steps', () => {
212
212
  const tc = makeTc();
213
213
  const ctx = makeCtx({
214
214
  stepCount: 3,
@@ -220,8 +220,189 @@ describe('TrajectoryCapture — training eligibility', () => {
220
220
  ],
221
221
  });
222
222
  const result = tc._computeTrainingEligibility(ctx, 5);
223
+ assert.equal(result.exclusionReason, 'insufficient_tokens');
224
+ });
225
+
226
+ it('duration checked before steps when tokens pass', () => {
227
+ const tc = makeTc();
228
+ const ctx = makeCtx({
229
+ stepCount: 3,
230
+ totalTokens: 5000,
231
+ allSteps: [
232
+ { step: 1, type: 'thought', content: 'thinking' },
233
+ { step: 2, type: 'thought', content: 'more' },
234
+ { step: 3, type: 'thought', content: 'done' },
235
+ ],
236
+ });
237
+ const result = tc._computeTrainingEligibility(ctx, 5);
238
+ assert.equal(result.exclusionReason, 'too_short');
239
+ });
240
+
241
+ it('steps checked after tokens and duration pass', () => {
242
+ const tc = makeTc();
243
+ const ctx = makeCtx({
244
+ stepCount: 3,
245
+ totalTokens: 5000,
246
+ allSteps: [
247
+ { step: 1, type: 'thought', content: 'thinking' },
248
+ { step: 2, type: 'thought', content: 'more' },
249
+ { step: 3, type: 'thought', content: 'done' },
250
+ ],
251
+ });
252
+ const result = tc._computeTrainingEligibility(ctx, 60);
253
+ assert.equal(result.exclusionReason, 'too_few_steps');
254
+ });
255
+ });
256
+
257
+ describe('TrajectoryCapture — planner/conversational eligibility', () => {
258
+ function makeConversationalCtx(role, overrides = {}) {
259
+ const ctx = makeCtx(overrides);
260
+ ctx.metadata.agent_role = role;
261
+ return ctx;
262
+ }
263
+
264
+ it('planner eligible with only thoughts (no actions/observations)', () => {
265
+ const tc = makeTc();
266
+ const ctx = makeConversationalCtx('planner', {
267
+ stepCount: 10,
268
+ totalTokens: 2000,
269
+ allSteps: Array.from({ length: 10 }, (_, i) => ({ step: i + 1, type: 'thought', content: 'planning' })),
270
+ });
271
+ const result = tc._computeTrainingEligibility(ctx, 60);
272
+ assert.equal(result.eligible, true);
273
+ assert.equal(result.exclusionReason, null);
274
+ });
275
+
276
+ it('chat role eligible with only thoughts', () => {
277
+ const tc = makeTc();
278
+ const ctx = makeConversationalCtx('chat', {
279
+ stepCount: 5,
280
+ totalTokens: 1000,
281
+ allSteps: [
282
+ { step: 1, type: 'instruction', content: 'explain React hooks' },
283
+ { step: 2, type: 'thought', content: 'explaining' },
284
+ { step: 3, type: 'thought', content: 'more detail' },
285
+ { step: 4, type: 'thought', content: 'examples' },
286
+ { step: 5, type: 'resolution', content: 'done' },
287
+ ],
288
+ });
289
+ const result = tc._computeTrainingEligibility(ctx, 30);
290
+ assert.equal(result.eligible, true);
291
+ });
292
+
293
+ it('advisor role eligible with only thoughts', () => {
294
+ const tc = makeTc();
295
+ const ctx = makeConversationalCtx('advisor', {
296
+ stepCount: 3,
297
+ totalTokens: 800,
298
+ allSteps: [
299
+ { step: 1, type: 'instruction', content: 'review approach' },
300
+ { step: 2, type: 'thought', content: 'analysis' },
301
+ { step: 3, type: 'resolution', content: 'recommendation' },
302
+ ],
303
+ });
304
+ const result = tc._computeTrainingEligibility(ctx, 20);
305
+ assert.equal(result.eligible, true);
306
+ });
307
+
308
+ it('planner still requires minimum tokens', () => {
309
+ const tc = makeTc();
310
+ const ctx = makeConversationalCtx('planner', {
311
+ stepCount: 10,
312
+ totalTokens: 100,
313
+ allSteps: Array.from({ length: 10 }, (_, i) => ({ step: i + 1, type: 'thought', content: 'plan' })),
314
+ });
315
+ const result = tc._computeTrainingEligibility(ctx, 60);
316
+ assert.equal(result.eligible, false);
317
+ assert.equal(result.exclusionReason, 'insufficient_tokens');
318
+ });
319
+
320
+ it('planner still requires minimum duration', () => {
321
+ const tc = makeTc();
322
+ const ctx = makeConversationalCtx('planner', {
323
+ stepCount: 10,
324
+ totalTokens: 2000,
325
+ allSteps: Array.from({ length: 10 }, (_, i) => ({ step: i + 1, type: 'thought', content: 'plan' })),
326
+ });
327
+ const result = tc._computeTrainingEligibility(ctx, 5);
328
+ assert.equal(result.eligible, false);
329
+ assert.equal(result.exclusionReason, 'too_short');
330
+ });
331
+
332
+ it('planner requires at least 2 steps', () => {
333
+ const tc = makeTc();
334
+ const ctx = makeConversationalCtx('planner', {
335
+ stepCount: 1,
336
+ totalTokens: 2000,
337
+ allSteps: [{ step: 1, type: 'thought', content: 'plan' }],
338
+ });
339
+ const result = tc._computeTrainingEligibility(ctx, 60);
340
+ assert.equal(result.eligible, false);
223
341
  assert.equal(result.exclusionReason, 'too_few_steps');
224
342
  });
343
+
344
+ it('coding role (fullstack) still requires actions and observations', () => {
345
+ const tc = makeTc();
346
+ const ctx = makeConversationalCtx('fullstack', {
347
+ stepCount: 10,
348
+ totalTokens: 2000,
349
+ allSteps: Array.from({ length: 10 }, (_, i) => ({ step: i + 1, type: 'thought', content: 'thinking' })),
350
+ });
351
+ const result = tc._computeTrainingEligibility(ctx, 60);
352
+ assert.equal(result.eligible, false);
353
+ assert.equal(result.exclusionReason, 'no_actions');
354
+ });
355
+ });
356
+
357
+ describe('TrajectoryCapture — initial prompt capture', () => {
358
+ function makeSpawnTc() {
359
+ const tc = makeTc();
360
+ tc._enabled = true;
361
+ tc._scrubber = { scrub: (s) => s };
362
+ tc._attestation = { openSession: async () => {}, signEnvelope: (sid, e) => e };
363
+ tc._transmissionQueue = { enqueue: () => {} };
364
+ tc._domainTagger = null;
365
+ return tc;
366
+ }
367
+
368
+ it('onAgentSpawn records prompt as instruction step', async () => {
369
+ const tc = makeSpawnTc();
370
+ await tc.onAgentSpawn('agent-p1', 'claude-code', 'opus', 'planner', 1, 'Build a React app');
371
+
372
+ const ctx = tc._contexts.get('agent-p1');
373
+ assert.ok(ctx);
374
+ assert.equal(ctx.stepCount, 1);
375
+ assert.equal(ctx.allSteps[0].type, 'instruction');
376
+ assert.ok(ctx.allSteps[0].content.includes('Build a React app'));
377
+ assert.equal(ctx.allSteps[0].source, 'user');
378
+ });
379
+
380
+ it('onAgentSpawn with no prompt creates no instruction step', async () => {
381
+ const tc = makeSpawnTc();
382
+ await tc.onAgentSpawn('agent-p2', 'claude-code', 'opus', 'fullstack', 1);
383
+
384
+ const ctx = tc._contexts.get('agent-p2');
385
+ assert.ok(ctx);
386
+ assert.equal(ctx.stepCount, 0);
387
+ assert.equal(ctx.allSteps.length, 0);
388
+ });
389
+
390
+ it('onAgentSpawn truncates long prompts', async () => {
391
+ const tc = makeSpawnTc();
392
+ const longPrompt = 'x'.repeat(50000);
393
+ await tc.onAgentSpawn('agent-p3', 'claude-code', 'opus', 'planner', 1, longPrompt);
394
+
395
+ const ctx = tc._contexts.get('agent-p3');
396
+ assert.ok(ctx.allSteps[0].content.length <= 10001);
397
+ });
398
+
399
+ it('onAgentSpawn ignores empty/whitespace prompts', async () => {
400
+ const tc = makeSpawnTc();
401
+ await tc.onAgentSpawn('agent-p4', 'claude-code', 'opus', 'planner', 1, ' ');
402
+
403
+ const ctx = tc._contexts.get('agent-p4');
404
+ assert.equal(ctx.stepCount, 0);
405
+ });
225
406
  });
226
407
 
227
408
  describe('TrajectoryCapture — user feedback emission', () => {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "groove-dev",
3
- "version": "0.27.116",
3
+ "version": "0.27.117",
4
4
  "description": "Open-source agent orchestration layer — the AI company OS. Local model agent engine (GGUF/Ollama/llama-server), HuggingFace model browser, MCP integrations (Slack, Gmail, Stripe, 15+), agent scheduling (cron), business roles (CMO, CFO, EA). GUI dashboard, multi-agent coordination, zero cold-start, infinite sessions. Works with Claude Code, Codex, Gemini CLI, Ollama, any local model.",
5
5
  "license": "FSL-1.1-Apache-2.0",
6
6
  "author": "Groove Dev <hello@groovedev.ai> (https://groovedev.ai)",
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@groove-dev/cli",
3
- "version": "0.27.116",
3
+ "version": "0.27.117",
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.116",
3
+ "version": "0.27.117",
4
4
  "description": "GROOVE daemon — agent orchestration engine",
5
5
  "license": "FSL-1.1-Apache-2.0",
6
6
  "type": "module",
@@ -330,6 +330,7 @@ export class ProcessManager {
330
330
  this._rotatingAgents = new Set(); // agentIds currently being rotated (rotator wrote handoff)
331
331
  this._stalledAgents = new Set(); // agentIds already flagged as stalled (avoids duplicate broadcasts)
332
332
  this._exitHandled = new Set();
333
+ this._resultReceived = new Set();
333
334
 
334
335
  this._stallWatchdog = setInterval(() => this._checkStalls(), STALL_CHECK_INTERVAL_MS);
335
336
  if (this._stallWatchdog.unref) this._stallWatchdog.unref();
@@ -389,6 +390,7 @@ export class ProcessManager {
389
390
  this._exitHandled.add(agentId);
390
391
  setTimeout(() => this._exitHandled.delete(agentId), 30_000);
391
392
  this._stalledAgents.delete(agentId);
393
+ this._resultReceived.delete(agentId);
392
394
  const throttle = this._streamThrottle.get(agentId);
393
395
  if (throttle?.timer) clearTimeout(throttle.timer);
394
396
  this._streamThrottle.delete(agentId);
@@ -425,11 +427,16 @@ export class ProcessManager {
425
427
 
426
428
  if (this.daemon.locks) this.daemon.locks.release(agent.id);
427
429
 
428
- const finalStatus = signal === 'SIGTERM' || signal === 'SIGKILL'
429
- ? 'killed'
430
- : code === 0
431
- ? 'completed'
432
- : 'crashed';
430
+ const hadResult = this._resultReceived.has(agent.id);
431
+ this._resultReceived.delete(agent.id);
432
+
433
+ const finalStatus = hadResult
434
+ ? 'completed'
435
+ : signal === 'SIGTERM' || signal === 'SIGKILL'
436
+ ? 'killed'
437
+ : code === 0
438
+ ? 'completed'
439
+ : 'crashed';
433
440
 
434
441
  const crashError = finalStatus === 'crashed' ? stderrBuf.join('').trim().slice(-500) : null;
435
442
 
@@ -552,7 +559,10 @@ export class ProcessManager {
552
559
 
553
560
  if (this.daemon.locks) this.daemon.locks.release(agent.id);
554
561
 
555
- const finalStatus = signal === 'SIGTERM' || signal === 'SIGKILL' ? 'killed' : code === 0 ? 'completed' : 'crashed';
562
+ const hadResult = this._resultReceived.has(agent.id);
563
+ this._resultReceived.delete(agent.id);
564
+
565
+ const finalStatus = hadResult ? 'completed' : signal === 'SIGTERM' || signal === 'SIGKILL' ? 'killed' : code === 0 ? 'completed' : 'crashed';
556
566
  registry.update(agent.id, { status: finalStatus, pid: null });
557
567
 
558
568
  if (this.daemon.trajectoryCapture) {
@@ -727,7 +737,7 @@ export class ProcessManager {
727
737
  try {
728
738
  const teamSize = registry.getAll().filter(a => a.status === 'active' || a.status === 'running' || a.status === 'starting').length;
729
739
  this.daemon.trajectoryCapture.onAgentSpawn(
730
- agent.id, providerName, config.model || null, config.role, teamSize
740
+ agent.id, providerName, config.model || null, config.role, teamSize, config.prompt
731
741
  ).catch(() => {});
732
742
  } catch (e) { /* fail silent */ }
733
743
  }
@@ -970,6 +980,7 @@ For normal file edits within your scope, proceed without review.
970
980
  logStream.end();
971
981
  this.handles.delete(agent.id);
972
982
  this._stalledAgents.delete(agent.id);
983
+ this._resultReceived.delete(agent.id);
973
984
 
974
985
  // Clean up stream throttle so pending timers don't fire for dead agents
975
986
  const throttle = this._streamThrottle.get(agent.id);
@@ -1262,6 +1273,21 @@ For normal file edits within your scope, proceed without review.
1262
1273
  if (output.cost) updates.costUsd = (agent.costUsd || 0) + output.cost;
1263
1274
  if (output.duration) updates.durationMs = output.duration;
1264
1275
  if (output.turns) updates.turns = output.turns;
1276
+
1277
+ // Claude Code sometimes hangs after emitting the result event — the
1278
+ // process stays alive instead of exiting. Record that the result
1279
+ // arrived so exit handlers know this was a successful completion even
1280
+ // if we have to SIGTERM the process. After a 5s grace period, force-
1281
+ // kill any process that hasn't exited on its own.
1282
+ this._resultReceived.add(agentId);
1283
+ const handle = this.handles.get(agentId);
1284
+ if (handle?.proc && typeof handle.proc.kill === 'function') {
1285
+ setTimeout(() => {
1286
+ if (this.handles.has(agentId) && this._resultReceived.has(agentId)) {
1287
+ try { handle.proc.kill('SIGTERM'); } catch {}
1288
+ }
1289
+ }, 5_000);
1290
+ }
1265
1291
  }
1266
1292
 
1267
1293
  // Context window usage (0-1 scale) — drives rotation threshold
@@ -1845,7 +1871,7 @@ For normal file edits within your scope, proceed without review.
1845
1871
  try {
1846
1872
  const teamSize = registry.getAll().filter(a => a.status === 'active' || a.status === 'running' || a.status === 'starting').length;
1847
1873
  this.daemon.trajectoryCapture.onAgentSpawn(
1848
- newAgent.id, config.provider, config.model || null, config.role, teamSize
1874
+ newAgent.id, config.provider, config.model || null, config.role, teamSize, config.prompt
1849
1875
  ).catch(() => {});
1850
1876
  } catch (e) { /* fail silent */ }
1851
1877
  }
@@ -1987,7 +2013,7 @@ For normal file edits within your scope, proceed without review.
1987
2013
  try {
1988
2014
  const teamSize = registry.getAll().filter(a => a.status === 'active' || a.status === 'running' || a.status === 'starting').length;
1989
2015
  this.daemon.trajectoryCapture.onAgentSpawn(
1990
- newAgent.id, config.provider, loopConfig.model || config.model || null, config.role, teamSize
2016
+ newAgent.id, config.provider, loopConfig.model || config.model || null, config.role, teamSize, config.prompt
1991
2017
  ).catch(() => {});
1992
2018
  } catch (e) { /* fail silent */ }
1993
2019
  }
@@ -274,9 +274,11 @@ export class TunnelManager {
274
274
  throw new Error(testResult.error || 'Host unreachable');
275
275
  }
276
276
 
277
+ let preConnectHandled = false;
277
278
  if (!testResult.daemonRunning && !testResult.grooveInstalled) {
278
279
  this.daemon.broadcast({ type: 'tunnel.status', data: { id, step: 'installing' } });
279
280
  await this.remoteInstall(id);
281
+ preConnectHandled = true;
280
282
  } else if (!testResult.daemonRunning && testResult.grooveInstalled) {
281
283
  const localVer = getLocalVersion();
282
284
  if (testResult.remoteVersion && testResult.remoteVersion !== localVer) {
@@ -285,6 +287,7 @@ export class TunnelManager {
285
287
  }
286
288
  this.daemon.broadcast({ type: 'tunnel.status', data: { id, step: 'starting' } });
287
289
  await this.autoStart(id);
290
+ preConnectHandled = true;
288
291
  }
289
292
 
290
293
  this.daemon.broadcast({ type: 'tunnel.status', data: { id, step: 'connecting' } });
@@ -342,7 +345,9 @@ export class TunnelManager {
342
345
  failCount: 0,
343
346
  });
344
347
 
345
- await this._checkAndUpgradeRunning(id, config, localPort);
348
+ if (!preConnectHandled) {
349
+ await this._checkAndUpgradeRunning(id, config, localPort);
350
+ }
346
351
 
347
352
  try {
348
353
  const statusResp = await fetch(`http://localhost:${localPort}/api/status`, { signal: AbortSignal.timeout(5000) });
@@ -473,6 +478,18 @@ export class TunnelManager {
473
478
 
474
479
  this.daemon.audit.log('tunnel.upgrade', { id, from: oldVersion, to: daemonVer || installedVer });
475
480
  } catch (err) {
481
+ try {
482
+ const verify = await fetch(`http://localhost:${localPort}/api/status`, { signal: AbortSignal.timeout(5000) });
483
+ if (verify.ok) {
484
+ const verifyData = await verify.json();
485
+ if (verifyData.version === localVer) {
486
+ this.daemon.broadcast({ type: 'tunnel.version-info', data: { id, localVersion: localVer, remoteVersion: verifyData.version, match: true } });
487
+ return;
488
+ }
489
+ this.daemon.broadcast({ type: 'tunnel.version-mismatch', data: { id, localVersion: localVer, remoteVersion: verifyData.version, message: 'Upgrade timed out but remote is reachable' } });
490
+ return;
491
+ }
492
+ } catch { /* tunnel verification failed */ }
476
493
  this.daemon.broadcast({ type: 'tunnel.upgrade-failed', data: { id, error: err.message } });
477
494
  }
478
495
  }
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@groove-dev/gui",
3
- "version": "0.27.116",
3
+ "version": "0.27.117",
4
4
  "description": "GROOVE GUI — visual agent control plane",
5
5
  "license": "FSL-1.1-Apache-2.0",
6
6
  "type": "module",