groove-dev 0.27.112 → 0.27.115

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/CENTRAL_COMMAND_REBUILD.md +689 -0
  2. package/EMBEDDING_DIAGNOSTIC.md +197 -0
  3. package/TRAINING_DATA_v4.md +3 -0
  4. package/moe-training/client/parsers/codex.js +3 -3
  5. package/moe-training/client/parsers/gemini.js +2 -2
  6. package/moe-training/client/step-classifier.js +2 -2
  7. package/moe-training/test/client/step-classifier.test.js +63 -7
  8. package/node_modules/@groove-dev/cli/package.json +1 -1
  9. package/node_modules/@groove-dev/cli/src/commands/team.js +43 -1
  10. package/node_modules/@groove-dev/daemon/package.json +1 -1
  11. package/node_modules/@groove-dev/daemon/src/api.js +75 -15
  12. package/node_modules/@groove-dev/daemon/src/filewatcher.js +45 -0
  13. package/node_modules/@groove-dev/daemon/src/index.js +36 -10
  14. package/node_modules/@groove-dev/daemon/src/teams.js +100 -6
  15. package/node_modules/@groove-dev/daemon/src/tunnel-manager.js +75 -43
  16. package/node_modules/@groove-dev/gui/dist/assets/{index-CHu5w3i3.js → index-BKCiOUDb.js} +593 -593
  17. package/node_modules/@groove-dev/gui/dist/assets/index-D4Q72afD.css +1 -0
  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/agents/workspace-mode.jsx +0 -22
  21. package/node_modules/@groove-dev/gui/src/components/layout/status-bar.jsx +43 -45
  22. package/node_modules/@groove-dev/gui/src/components/preview/preview-workspace.jsx +3 -1
  23. package/node_modules/@groove-dev/gui/src/components/settings/quick-connect.jsx +2 -1
  24. package/node_modules/@groove-dev/gui/src/stores/groove.js +57 -8
  25. package/node_modules/@groove-dev/gui/src/views/agents.jsx +31 -3
  26. package/node_modules/@groove-dev/gui/src/views/editor.jsx +1 -20
  27. package/node_modules/@groove-dev/gui/src/views/teams.jsx +106 -3
  28. package/node_modules/moe-training/client/parsers/codex.js +3 -3
  29. package/node_modules/moe-training/client/parsers/gemini.js +2 -2
  30. package/node_modules/moe-training/client/step-classifier.js +2 -2
  31. package/node_modules/moe-training/test/client/step-classifier.test.js +63 -7
  32. package/package.json +1 -1
  33. package/packages/cli/package.json +1 -1
  34. package/packages/cli/src/commands/team.js +43 -1
  35. package/packages/daemon/package.json +1 -1
  36. package/packages/daemon/src/api.js +75 -15
  37. package/packages/daemon/src/filewatcher.js +45 -0
  38. package/packages/daemon/src/index.js +36 -10
  39. package/packages/daemon/src/teams.js +100 -6
  40. package/packages/daemon/src/tunnel-manager.js +75 -43
  41. package/packages/gui/dist/assets/{index-CHu5w3i3.js → index-BKCiOUDb.js} +593 -593
  42. package/packages/gui/dist/assets/index-D4Q72afD.css +1 -0
  43. package/packages/gui/dist/index.html +2 -2
  44. package/packages/gui/package.json +1 -1
  45. package/packages/gui/src/components/agents/workspace-mode.jsx +0 -22
  46. package/packages/gui/src/components/layout/status-bar.jsx +43 -45
  47. package/packages/gui/src/components/preview/preview-workspace.jsx +3 -1
  48. package/packages/gui/src/components/settings/quick-connect.jsx +2 -1
  49. package/packages/gui/src/stores/groove.js +57 -8
  50. package/packages/gui/src/views/agents.jsx +31 -3
  51. package/packages/gui/src/views/editor.jsx +1 -20
  52. package/packages/gui/src/views/teams.jsx +106 -3
  53. package/TRAINING_DATA_v2.md +0 -9
  54. package/node_modules/@groove-dev/gui/dist/assets/index-DAlSbVyK.css +0 -1
  55. package/packages/gui/dist/assets/index-DAlSbVyK.css +0 -1
@@ -0,0 +1,197 @@
1
+ # Embedding Service Diagnostic — Central Command Rebuild
2
+
3
+ ## Problem
4
+
5
+ After the Central Command rebuild, `session_embedding` is `null` on all SESSION_CLOSE envelopes and `domain_tags` show keyword-mode confidence (~0.067) instead of embedding-mode confidence (~0.3-0.5). Embeddings were working correctly before the rebuild (TRAINING_DATA_v3 had full 384-dim vectors on every session).
6
+
7
+ The endpoint responds to direct curl tests from a developer machine, but the Groove client is falling back to keyword mode during live sessions.
8
+
9
+ ## What "working" looked like (v3 data)
10
+
11
+ ```
12
+ domain_tags: primary=react_frontend (0.4932)
13
+ session_embedding: 384d, model=sentence-transformers/all-MiniLM-L6-v2, nonzero=377/384
14
+ ```
15
+
16
+ ## What's happening now (v4 data)
17
+
18
+ ```
19
+ domain_tags: primary=nlp_llm (0.0769) ← keyword mode
20
+ session_embedding: null ← embed() returns null in keyword mode
21
+ ```
22
+
23
+ ## How the client decides between keyword and embedding mode
24
+
25
+ On every agent spawn, the `DomainTagger.init()` runs this sequence:
26
+
27
+ ```
28
+ 1. POST to EMBEDDING_SERVICE_URL with {"input": "health check", "model": "sentence-transformers/all-MiniLM-L6-v2"}
29
+ 2. If response is 200 OK:
30
+ a. Switch to "http" mode
31
+ b. Call _buildCentroids() — embeds all 40 domain descriptions (40 sequential POST calls)
32
+ c. If all succeeds → ready, embedding mode active
33
+ 3. If ANYTHING fails (network error, non-200, timeout, _buildCentroids() error):
34
+ → Fall back to "keyword" mode silently
35
+ ```
36
+
37
+ The client **never logs** why it fell back — it catches all errors silently. So the failure is invisible.
38
+
39
+ ## Likely failure points (in order of probability)
40
+
41
+ ### 1. `_buildCentroids()` is failing
42
+
43
+ Even if the health probe succeeds, `init()` then makes **40 sequential embed calls** to build domain centroids. If any single one fails, the whole init throws and falls back to keyword mode. The entire block is wrapped in a try/catch:
44
+
45
+ ```javascript
46
+ try {
47
+ const res = await fetch(this._serviceUrl, { ... }); // health probe
48
+ if (res.ok) {
49
+ this._mode = this._leafRegistry?.length ? 'registry' : 'http';
50
+ await this._buildCentroids(); // ← 40 more calls, if this throws → keyword mode
51
+ this._ready = true;
52
+ return;
53
+ }
54
+ } catch {
55
+ // silently falls through to keyword mode
56
+ }
57
+ ```
58
+
59
+ **To check:** Look at Central Command logs during a Groove session. You should see ~41 POST requests to `/v1/embed` in quick succession (1 health probe + 40 centroid builds). If you see fewer, one is failing.
60
+
61
+ ### 2. Timeout under load
62
+
63
+ The health probe has a **5-second timeout**. The centroid embed calls have a **10-second timeout** each. If the ONNX model is cold or the server is under load, individual calls might be timing out.
64
+
65
+ **To check:** Add timing logs to the `/v1/embed` endpoint:
66
+ ```javascript
67
+ const start = Date.now();
68
+ const vector = await embed(text);
69
+ console.log(`[embed] ${Date.now() - start}ms "${text.slice(0, 50)}..."`);
70
+ ```
71
+
72
+ ### 3. Response format mismatch
73
+
74
+ The client parses the response as:
75
+ ```javascript
76
+ const data = await res.json();
77
+ const embedding = data?.data?.[0]?.embedding;
78
+ if (!Array.isArray(embedding)) {
79
+ // returns null — "Invalid embedding response format"
80
+ }
81
+ ```
82
+
83
+ The expected response is **exactly**:
84
+ ```json
85
+ {
86
+ "data": [
87
+ {
88
+ "embedding": [0.0123, -0.0456, ...384 floats],
89
+ "index": 0
90
+ }
91
+ ],
92
+ "model": "sentence-transformers/all-MiniLM-L6-v2"
93
+ }
94
+ ```
95
+
96
+ **To check:** Verify the response structure. Common mistakes:
97
+ - Returning `{ "embedding": [...] }` instead of `{ "data": [{ "embedding": [...] }] }`
98
+ - Returning the vector as a nested array `[[0.01, ...]]` instead of flat `[0.01, ...]`
99
+ - Returning Float32Array or typed array instead of plain JSON numbers
100
+
101
+ ### 4. CORS or request rejection
102
+
103
+ The Groove daemon makes requests from `localhost` (or wherever it runs). If the rebuilt server has stricter CORS, different firewall rules, or middleware that rejects requests without a specific User-Agent or Origin header, the fetch would fail.
104
+
105
+ **To check:** The original Central Command had `Access-Control-Allow-Origin: *`. Verify the rebuild has the same.
106
+
107
+ ### 5. URL path mismatch
108
+
109
+ The client hits exactly: `POST https://api.groovedev.ai/v1/embed`
110
+
111
+ **To check:** Verify the route is mounted at `/v1/embed`, not `/embed` or `/v1/embeddings` or `/api/v1/embed`.
112
+
113
+ ## Quick diagnostic commands
114
+
115
+ Run these on the Central Command server:
116
+
117
+ ```bash
118
+ # 1. Verify endpoint exists and responds correctly
119
+ curl -s -X POST http://localhost:8443/v1/embed \
120
+ -H "Content-Type: application/json" \
121
+ -d '{"input": "Python programming debugging testing"}' \
122
+ | python3 -c "
123
+ import sys, json
124
+ d = json.load(sys.stdin)
125
+ v = d.get('data', [{}])[0].get('embedding')
126
+ if v and isinstance(v, list):
127
+ print(f'OK: {len(v)}-dim vector, type={type(v[0]).__name__}')
128
+ else:
129
+ print(f'BROKEN: response structure = {json.dumps(d)[:200]}')"
130
+
131
+ # 2. Simulate the full init sequence (health probe + 40 centroids)
132
+ python3 -c "
133
+ import requests, time
134
+ url = 'http://localhost:8443/v1/embed'
135
+ domains = [
136
+ 'Python programming, debugging, testing with pytest',
137
+ 'TypeScript and Node.js backend, Express frameworks',
138
+ 'React frontend development, JSX components, hooks',
139
+ # ...add all 40 domain descriptions from domain-tagger.js
140
+ ]
141
+
142
+ # Health probe
143
+ start = time.time()
144
+ r = requests.post(url, json={'input': 'health check', 'model': 'sentence-transformers/all-MiniLM-L6-v2'}, timeout=5)
145
+ print(f'Health probe: {r.status_code} in {time.time()-start:.1f}s')
146
+
147
+ # Centroid batch
148
+ fails = 0
149
+ for i, desc in enumerate(domains):
150
+ try:
151
+ r = requests.post(url, json={'input': desc}, timeout=10)
152
+ d = r.json()
153
+ v = d.get('data', [{}])[0].get('embedding')
154
+ if not v or not isinstance(v, list) or len(v) != 384:
155
+ print(f' Domain {i}: BAD RESPONSE')
156
+ fails += 1
157
+ except Exception as e:
158
+ print(f' Domain {i}: FAILED - {e}')
159
+ fails += 1
160
+
161
+ print(f'Centroid build: {len(domains)-fails}/{len(domains)} succeeded, {fails} failed')
162
+ if fails > 0:
163
+ print('THIS IS WHY EMBEDDINGS ARE NULL - any failure causes full fallback to keyword mode')
164
+ "
165
+
166
+ # 3. Check response time under sequential load
167
+ for i in $(seq 1 10); do
168
+ time curl -s -X POST http://localhost:8443/v1/embed \
169
+ -H "Content-Type: application/json" \
170
+ -d "{\"input\": \"test embedding call $i\"}" > /dev/null
171
+ done
172
+
173
+ # 4. Check server logs during a live Groove session
174
+ # Start a Groove agent and watch for /v1/embed requests in the Central Command logs
175
+ ```
176
+
177
+ ## What success looks like
178
+
179
+ After fix, a SESSION_CLOSE envelope should show:
180
+ ```json
181
+ {
182
+ "metadata": {
183
+ "domain_tags": {
184
+ "primary": { "domain": "react_frontend", "confidence": 0.49 },
185
+ "secondary": { "domain": "vue_frontend", "confidence": 0.47 },
186
+ "tertiary": { "domain": "typescript_node", "confidence": 0.40 }
187
+ },
188
+ "session_embedding": {
189
+ "model": "sentence-transformers/all-MiniLM-L6-v2",
190
+ "vector": [0.0123, -0.0456, "...384 floats"],
191
+ "source_text": "frontend\nI'll scaffold the project..."
192
+ }
193
+ }
194
+ }
195
+ ```
196
+
197
+ Confidence scores in the 0.25-0.55 range (cosine similarity), NOT 0.06-0.08 (keyword hit ratio).
@@ -0,0 +1,3 @@
1
+ {"envelope_id":"env_70dfa132-575b-48a3-becc-a2ea8a75aecc","session_id":"sess_54e35697-302f-45bc-bc3e-c4282b5f6034","chunk_sequence":0,"contributor_id":"23e6dd8c4a884836bf78cafda9fbe57d","attestation":{"session_hmac":"9f2d5a6369edcb2545181101574e61e02c65e939e253c068d398d2cbf8f20cd4","sequence":0,"app_version_hash":"4a527565d85cc601d3c47c376e4f361d5af987de58f60cf71dbc263a0de91247"},"metadata":{"model_engine":"claude-opus-4-6","provider":"claude-code","agent_role":"fullstack","agent_id":"e18a8fa4","task_complexity":"medium","team_size":2,"session_quality":0,"groove_version":"0.27.113","leaf_context":null},"trajectory_log":[{"step":1,"type":"thought","timestamp":1777354900.276,"content":"Let me trace the exact URL the client is using.","token_count":12},{"step":2,"type":"action","timestamp":1777354901.244,"tool":"Bash","arguments":{"command":"cd ~/Desktop/groove-project/groove && grep -n 'EMBEDDING_SERVICE_URL\\|CENTRAL_COMMAND_URL' moe-training/shared/constants.js"},"content":"Using Bash","token_count":37},{"step":3,"type":"observation","timestamp":1777354901.808,"content":"41:export const CENTRAL_COMMAND_URL = process.env.GROOVE_CENTRAL_URL || 'https://api.groovedev.ai';\n42:export const EMBEDDING_SERVICE_URL = process.env.EMBEDDING_SERVICE_URL || `${CENTRAL_COMMAND_URL}/v1/embed`;\nShell cwd was reset to ~/Desktop/groove-project/groove/training","truncated":false,"original_token_count":71,"is_error":false,"tool":"Bash","token_count":69},{"step":4,"type":"action","timestamp":1777354905.484,"tool":"Bash","arguments":{"command":"cd ~/Desktop/groove-project/groove && grep -n 'serviceUrl\\|EMBEDDING' moe-training/client/domain-tagger.js | head -10"},"content":"Using Bash","token_count":36},{"step":5,"type":"observation","timestamp":1777354905.528,"content":"3:import { EMBEDDING_SERVICE_URL } from '../shared/constants.js';\n176: this._serviceUrl = options.serviceUrl !== undefined ? options.serviceUrl : EMBEDDING_SERVICE_URL;\n208: if (this._serviceUrl) {\n210: const res = await fetch(this._serviceUrl, {\n361: const res = await fetch(this._serviceUrl, {\nShell cwd was reset to ~/Desktop/groove-project/groove/training","truncated":false,"original_token_count":97,"is_error":false,"tool":"Bash","token_count":95},{"step":6,"type":"thought","timestamp":1777354911.293,"content":"The constant looks correct — `https://api.groovedev.ai/v1/embed`, no trailing slash. But let me check what the DomainTagger actually receives at runtime. The `TrajectoryCapture` creates the DomainTagger — let me see if it passes any options.","token_count":61},{"step":7,"type":"action","timestamp":1777354911.644,"tool":"Bash","arguments":{"command":"cd ~/Desktop/groove-project/groove && grep -n 'DomainTagger\\|domainTagger\\|_domainTagger' moe-training/client/trajectory-capture.js"},"content":"Using Bash","token_count":40},{"step":8,"type":"observation","timestamp":1777354911.695,"content":"11:import { DomainTagger } from './domain-tagger.js';\n48: this._domainTagger = new DomainTagger();\n49: await this._domainTagger.init();\n299: if (this._domainTagger) {\n304: const routingText = DomainTagger.buildRoutingText(role, firstPrompt, remainingThoughts);\n305: ctx.metadata.domain_tags = await this._domainTagger.tag(routingText);\n306: ctx.metadata.session_embedding = await this._domainTagger.embed(routingText);\nShell cwd was reset to ~/Desktop/groove-project/groove/training","truncated":false,"original_token_count":129,"is_error":false,"tool":"Bash","token_count":127},{"step":9,"type":"thought","timestamp":1777354916.313,"content":"`new DomainTagger()` — no options passed, so it uses the default `EMBEDDING_SERVICE_URL`. Now let me check where the daemon actually imports and uses this. The `moe-training` module is consumed by the daemon.","token_count":52},{"step":10,"type":"action","timestamp":1777354916.852,"tool":"Bash","arguments":{"command":"cd ~/Desktop/groove-project/groove && grep -rn 'TrajectoryCapture\\|trajectory-capture\\|moe-training' packages/daemon/src/ | grep -v node_modules"},"content":"Using Bash","token_count":43},{"step":11,"type":"observation","timestamp":1777354916.946,"content":"packages/daemon/src/index.js:46:import { TrajectoryCapture, ConsentManager } from '../../../moe-training/client/index.js';\npackages/daemon/src/index.js:594: this._initTrajectoryCapture().catch(() => {});\npackages/daemon/src/index.js:680: async _initTrajectoryCapture() {\npackages/daemon/src/index.js:685: this.trajectoryCapture = new TrajectoryCapture({\npackages/daemon/src/api.js:18:import { ConsentManager } from '../../../moe-training/client/index.js';\npackages/daemon/src/api.js:4939: await daemon._initTrajectoryCapture();\nShell cwd was reset to ~/Desktop/groove-project/groove/training","truncated":false,"original_token_count":155,"is_error":false,"tool":"Bash","token_count":153},{"step":12,"type":"action","timestamp":1777354920.727,"tool":"Read","arguments":{"file_path":"~/Desktop/groove-project/groove/packages/daemon/src/index.js","offset":678,"limit":25},"content":"Using Read","token_count":28},{"step":13,"type":"observation","timestamp":1777354920.728,"content":"678\t }\n679\t\n680\t async _initTrajectoryCapture() {\n681\t try {\n682\t if (!ConsentManager.isCaptureEnabled()) return;\n683\t const pkgPath = new URL('../package.json', import.meta.url);\n684\t const version = JSON.parse(readFileSync(pkgPath, 'utf8')).version;\n685\t this.trajectoryCapture = new TrajectoryCapture({\n686\t centralCommandUrl: process.env.GROOVE_CENTRAL_URL || 'https://api.groovedev.ai',\n687\t grooveVersion: version,\n688\t });\n689\t this.trajectoryCapture.init();\n690\t } catch (e) {\n691\t // Training capture is never critical\n692\t }\n693\t }\n694\t\n695\t _startGarbageCollector() {\n696\t // Run once on startup, then every 24 hours\n697\t this._gc();\n698\t this._gcInterval = setInterval(() => this._gc(), 24 * 60 * 60 * 1000);\n699\t\n700\t // Periodic state save — crash protection (every 30s)\n701\t this._stateSaveInterval = setInterval(async () => {\n702\t try { this.state.set('agents', this.registry.getAll()); await this.state.save(); } catch {}","truncated":false,"original_token_count":255,"is_error":false,"tool":"Read","token_count":255},{"step":14,"type":"thought","timestamp":1777354925.525,"content":"Line 689 — `this.trajectoryCapture.init()` — no `await`. That's not the bug here though. Let me look at what `TrajectoryCapture.init()` does with the DomainTagger.","token_count":41},{"step":15,"type":"action","timestamp":1777354926.407,"tool":"Read","arguments":{"file_path":"~/Desktop/groove-project/groove/moe-training/client/trajectory-capture.js","offset":38,"limit":16},"content":"Using Read","token_count":31},{"step":16,"type":"observation","timestamp":1777354926.407,"content":"38\t async init() {\n39\t if (!ConsentManager.isCaptureEnabled()) {\n40\t this._enabled = false;\n41\t return;\n42\t }\n43\t this._enabled = true;\n44\t this._scrubber = new PIIScrubber();\n45\t this._attestation = new SessionAttestation(this._centralCommandUrl);\n46\t this._transmissionQueue = new TransmissionQueue(this._centralCommandUrl);\n47\t this._transmissionQueue.start();\n48\t this._domainTagger = new DomainTagger();\n49\t await this._domainTagger.init();\n50\t this._offlineRetryTimer = setInterval(() => {\n51\t this._retryOfflineQueue();\n52\t }, OFFLINE_RETRY_INTERVAL_MS);\n53\t }","truncated":false,"original_token_count":155,"is_error":false,"tool":"Read","token_count":155},{"step":17,"type":"thought","timestamp":1777354935.764,"content":"Wait — line 689 in `index.js`: `this.trajectoryCapture.init()` — **no await**. That means `init()` runs as a fire-and-forget promise. The DomainTagger's `init()` calls `await this._domainTagger.init()` which does 41 HTTP requests. But the daemon doesn't await the TrajectoryCapture's init.\n\nThat's fine in theory — the DomainTagger should still eventually resolve. But let me check: if `init()` isn't awaited and an agent spawns before the DomainTagger finishes its 41 embed calls, the `_domainTagger` might be set but not yet `_ready`. Let me look at the timing.\n\nActually, that's not the issue either — `onAgentSpawn` doesn't check `_domainTagger.ready`. The domain tagging only happens in `_closeAgent`, by which point init would have long completed.\n\nLet me look at this from another angle — is the `EMBEDDING_SERVICE_URL` constant actually making it into the published npm package?","token_count":222},{"step":18,"type":"action","timestamp":1777354936.676,"tool":"Bash","arguments":{"command":"cd ~/Desktop/groove-project/groove && node -e \"\nimport { EMBEDDING_SERVICE_URL, CENTRAL_COMMAND_URL } from './moe-training/shared/constants.js';\nconsole.log('CENTRAL_COMMAND_URL:', CENTRAL_COMMAND_URL);\nconsole.log('EMBEDDING_SERVICE_URL:', EMBEDDING_SERVICE_URL);\n\""},"content":"Using Bash","token_count":74},{"step":19,"type":"observation","timestamp":1777354936.764,"content":"CENTRAL_COMMAND_URL: https://api.groovedev.ai\nEMBEDDING_SERVICE_URL: https://api.groovedev.ai/v1/embed\nShell cwd was reset to ~/Desktop/groove-project/groove/training","truncated":false,"original_token_count":44,"is_error":false,"tool":"Bash","token_count":42},{"step":20,"type":"action","timestamp":1777354948.816,"tool":"Bash","arguments":{"command":"cd ~/Desktop/groove-project/groove && node -e \"\nimport { DomainTagger } from './moe-training/client/domain-tagger.js';\nconst t = new DomainTagger();\nconsole.log('serviceUrl:', t._serviceUrl);\nconsole.log('mode:', t._mode);\nconsole.log('ready:', t._ready);\n\""},"content":"Using Bash","token_count":73},{"step":21,"type":"observation","timestamp":1777354948.897,"content":"serviceUrl: https://api.groovedev.ai/v1/embed\nmode: null\nready: false\nShell cwd was reset to ~/Desktop/groove-project/groove/training","truncated":false,"original_token_count":36,"is_error":false,"tool":"Bash","token_count":34},{"step":22,"type":"thought","timestamp":1777354952.865,"content":"URL is correct. Let me test the full init flow.","token_count":12},{"step":23,"type":"action","timestamp":1777354956.383,"tool":"Bash","arguments":{"command":"cd ~/Desktop/groove-project/groove && node -e \"\nimport { DomainTagger } from './moe-training/client/domain-tagger.js';\nconst t = new DomainTagger();\nconsole.log('serviceUrl:', t._serviceUrl);\nconsole.time('init');\ntry {\n await t.init();\n console.timeEnd('init');\n console.log('mode:', t.mode);\n console.log('ready:', t.ready);\n console.log('lastError:', t.lastError);\n if (t._centroids) {\n console.log('centroids:', Object.keys(t._centroids).length);\n }\n const result = await t.tag('Build a React frontend with TypeScript and Tailwind CSS');\n console.log('tag result:', JSON.stringify(result));\n const emb = await t.embed('Build a React frontend with TypeScript');\n console.log('embed:', emb ? emb.vector.length + 'd' : 'null');\n} catch (e) {\n console.error('ERROR:', e.message);\n}\n\" 2>&1","timeout":60000},"content":"Using Bash","token_count":217},{"step":24,"type":"observation","timestamp":1777354961.386,"content":"serviceUrl: https://api.groovedev.ai/v1/embed\ninit: 4.706s\nmode: http\nready: true\nlastError: null\ncentroids: 40\ntag result: {\"primary\":{\"domain\":\"react_frontend\",\"confidence\":0.685},\"secondary\":{\"domain\":\"typescript_node\",\"confidence\":0.4633},\"tertiary\":{\"domain\":\"vue_frontend\",\"confidence\":0.3264}}\nembed: 384d\nShell cwd was reset to ~/Desktop/groove-project/groove/training","truncated":false,"original_token_count":97,"is_error":false,"tool":"Bash","token_count":94},{"step":25,"type":"thought","timestamp":1777354969.631,"content":"It works perfectly from this machine — `http` mode, 40 centroids, 0.685 confidence on React, 384d embedding. The code is correct.\n\nThe issue is that the **running daemon** was started before these changes were committed. The daemon process has the old code in memory where `DomainTagger` didn't import `EMBEDDING_SERVICE_URL` and defaulted `serviceUrl` to `null`.\n\nYou need to restart the Groove daemon to pick up the new code. The changes are in the files but the running Node process is still using the old version.","token_count":130},{"step":26,"type":"action","timestamp":1777354971.472,"tool":"Bash","arguments":{"command":"cd ~/Desktop/groove-project/groove && node -e \"\nimport { DomainTagger } from './moe-training/client/domain-tagger.js';\nconst t = new DomainTagger();\nconsole.log('Confirmed: serviceUrl =', t._serviceUrl);\nconsole.log('This proves the code is correct.');\nconsole.log('If live sessions show keyword mode, the running daemon has stale code in memory.');\nconsole.log('Fix: restart the daemon (groove stop && groove start)');\n\""},"content":"Using Bash","token_count":114},{"step":27,"type":"observation","timestamp":1777354971.549,"content":"Confirmed: serviceUrl = https://api.groovedev.ai/v1/embed\nThis proves the code is correct.\nIf live sessions show keyword mode, the running daemon has stale code in memory.\nFix: restart the daemon (groove stop && groove start)\nShell cwd was reset to ~/Desktop/groove-project/groove/training","truncated":false,"original_token_count":75,"is_error":false,"tool":"Bash","token_count":73},{"step":28,"type":"thought","timestamp":1777354976.411,"content":"Mystery solved. The code works — `http` mode, 40 centroids built in 4.7s, 0.685 confidence, 384d embeddings. The running daemon just needs a restart to pick up the new `EMBEDDING_SERVICE_URL` default. Run `groove stop && groove start` and the next session will have full embeddings.","token_count":71},{"step":29,"type":"resolution","timestamp":1777354976.457,"content":"Mystery solved. The code works — `http` mode, 40 centroids built in 4.7s, 0.685 confidence, 384d embeddings. The running daemon just needs a restart to pick up the new `EMBEDDING_SERVICE_URL` default. Run `groove stop && groove start` and the next session will have full embeddings.","token_count":71}]}
2
+ {"envelope_id":"env_8c59a800-92c2-486e-aab2-d251958883da","session_id":"sess_54e35697-302f-45bc-bc3e-c4282b5f6034","type":"SESSION_CLOSE","attestation":{"session_hmac":"3fd6f3891108e43eb7482a0361fc5789ecfb9fdf40f637a63edb01e154c6f74f","sequence":1,"app_version_hash":"4a527565d85cc601d3c47c376e4f361d5af987de58f60cf71dbc263a0de91247"},"metadata":{"model_engine":"claude-opus-4-6","provider":"claude-code","agent_role":"fullstack","agent_id":"e18a8fa4","task_complexity":"medium","team_size":2,"session_quality":70,"groove_version":"0.27.113","leaf_context":null,"domain_tags":{"primary":{"domain":"linux_admin","confidence":0.0769},"secondary":{"domain":"nlp_llm","confidence":0.0769},"tertiary":{"domain":"react_frontend","confidence":0.0667}},"session_embedding":null},"outcome":{"status":"SUCCESS","session_quality":70,"quality_tier":"TIER_A","quality_tier_reason":"high_quality_no_errors","user_interventions":0,"total_steps":29,"total_chunks":1,"total_tokens":2462,"duration_seconds":86,"files_modified":0,"errors_encountered":0,"errors_recovered":0,"coordination_events":0,"training_eligible":true,"training_exclusion_reason":null}}
3
+ {"envelope_id":"env_eafaf868-6b85-46c8-b687-43a576aedc50","session_id":"sess_54e35697-302f-45bc-bc3e-c4282b5f6034","type":"USER_FEEDBACK","attestation":{"session_hmac":"a60cc84ce26d8714749bbaedd3d51a8fb153d008e3cb396d3413740d6970454c","sequence":2,"app_version_hash":"4a527565d85cc601d3c47c376e4f361d5af987de58f60cf71dbc263a0de91247"},"feedback":{"signal":"accepted","timestamp":1777354977.009,"context":"session completed successfully with no user interventions","target_step":29,"revision_rounds":0,"delta_summary":null}}
@@ -57,15 +57,15 @@ export class CodexParser {
57
57
  if (item.type === 'command_execution') {
58
58
  const rawOutput = item.aggregated_output || '';
59
59
  if (item.exit_code !== 0) {
60
- return { type: 'error', content: rawOutput.slice(0, 2000) || `Exit code: ${item.exit_code}` };
60
+ return { type: 'error', is_error: true, content: rawOutput.slice(0, 2000) || `Exit code: ${item.exit_code}` };
61
61
  }
62
62
  const obs = truncateObservation(rawOutput);
63
- return { type: 'observation', content: obs.content, truncated: obs.truncated, original_token_count: obs.original_token_count };
63
+ return { type: 'observation', is_error: false, content: obs.content, truncated: obs.truncated, original_token_count: obs.original_token_count };
64
64
  }
65
65
  if (item.type === 'file_edit' || item.type === 'file_write' || item.type === 'file_read') {
66
66
  const rawOutput = item.output || item.content || '';
67
67
  const obs = truncateObservation(rawOutput);
68
- return { type: 'observation', content: obs.content, truncated: obs.truncated, original_token_count: obs.original_token_count };
68
+ return { type: 'observation', is_error: false, content: obs.content, truncated: obs.truncated, original_token_count: obs.original_token_count };
69
69
  }
70
70
  return null;
71
71
  }
@@ -64,11 +64,11 @@ export class GeminiParser {
64
64
  const contentParts = Array.isArray(rawContent) ? rawContent : (typeof rawContent === 'string' ? [{ text: rawContent }] : rawContent ? [rawContent] : []);
65
65
  const rawText = contentParts.map((p) => p.text || '').join('');
66
66
  const obs = truncateObservation(rawText);
67
- return { type: 'observation', content: obs.content, truncated: obs.truncated, original_token_count: obs.original_token_count };
67
+ return { type: 'observation', is_error: false, content: obs.content, truncated: obs.truncated, original_token_count: obs.original_token_count };
68
68
  }
69
69
 
70
70
  case 'error': {
71
- return { type: 'error', content: jsonEvent.message || 'Unknown error' };
71
+ return { type: 'error', is_error: true, content: jsonEvent.message || 'Unknown error' };
72
72
  }
73
73
 
74
74
  case 'agent_end': {
@@ -1,6 +1,6 @@
1
1
  // FSL-1.1-Apache-2.0 — see LICENSE
2
2
 
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/;
3
+ const ERROR_SIGNAL_RE = /(?:^|\n)\s*(?:error[ :\[]\S|Error[ :\[]\S|ERROR[ :\[]\S)|(?:exit code [1-9]|non-zero exit|ENOENT|EACCES|EPERM|Command failed)|(?:(?:TypeError|ReferenceError|SyntaxError|RangeError|URIError):\s)|(?:Cannot find module|Module not found|ModuleNotFoundError|ImportError:)|(?:FATAL|PANIC|Traceback \(most recent)|(?:error TS\d{4}:)/;
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
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;
@@ -54,7 +54,7 @@ export class StepClassifier {
54
54
 
55
55
  const content = step.content || '';
56
56
 
57
- if ((step.type === 'action' || step.type === 'observation') && step.is_error !== false && ERROR_SIGNAL_RE.test(content)) {
57
+ if (step.type === 'observation' && step.is_error !== false && ERROR_SIGNAL_RE.test(content)) {
58
58
  step.type = 'error';
59
59
  }
60
60
 
@@ -132,11 +132,11 @@ describe('StepClassifier', () => {
132
132
  assert.equal(StepClassifier.countUserInterventions(steps), 0);
133
133
  });
134
134
 
135
- it('reclassifies action with error content to error', () => {
135
+ it('never reclassifies action to error', () => {
136
136
  const classifier = new StepClassifier();
137
137
  const step = { type: 'action', content: 'Command failed with exit code 1' };
138
138
  const result = classifier.onStep(step);
139
- assert.equal(result.type, 'error');
139
+ assert.equal(result.type, 'action');
140
140
  });
141
141
 
142
142
  it('reclassifies observation with error content to error', () => {
@@ -215,19 +215,75 @@ describe('StepClassifier', () => {
215
215
  assert.equal(result.type, 'error');
216
216
  });
217
217
 
218
- it('preserves action type when is_error is false', () => {
218
+ it('does not reclassify observation containing bare word "error" in source code', () => {
219
219
  const classifier = new StepClassifier();
220
- const step = { type: 'action', content: 'Command failed with exit code 1', is_error: false };
220
+ const step = { type: 'observation', content: 'function handleError(err) { console.error(err); }' };
221
221
  const result = classifier.onStep(step);
222
- assert.equal(result.type, 'action');
222
+ assert.equal(result.type, 'observation');
223
223
  });
224
224
 
225
- it('still reclassifies action to error when is_error is not set', () => {
225
+ it('does not reclassify observation with "0 errors" or "found 0 vulnerabilities"', () => {
226
226
  const classifier = new StepClassifier();
227
- const step = { type: 'action', content: 'Command failed with exit code 1' };
227
+ const step = { type: 'observation', content: 'Build succeeded\n0 errors, 0 warnings\nfound 0 vulnerabilities' };
228
+ const result = classifier.onStep(step);
229
+ assert.equal(result.type, 'observation');
230
+ });
231
+
232
+ it('does not reclassify observation reading a file that mentions exceptions', () => {
233
+ const classifier = new StepClassifier();
234
+ const step = { type: 'observation', content: '{"scripts": {"build": "tsc && vite build"}, "name": "my-app"}' };
235
+ const result = classifier.onStep(step);
236
+ assert.equal(result.type, 'observation');
237
+ });
238
+
239
+ it('reclassifies observation with real TypeScript build error', () => {
240
+ const classifier = new StepClassifier();
241
+ const step = { type: 'observation', content: 'src/main.ts(1,8): error TS2882: Cannot find module' };
242
+ const result = classifier.onStep(step);
243
+ assert.equal(result.type, 'error');
244
+ });
245
+
246
+ it('reclassifies observation with Python traceback', () => {
247
+ const classifier = new StepClassifier();
248
+ const step = { type: 'observation', content: 'Traceback (most recent call last):\n File "main.py", line 5' };
249
+ const result = classifier.onStep(step);
250
+ assert.equal(result.type, 'error');
251
+ });
252
+
253
+ it('reclassifies observation with actual TypeError message', () => {
254
+ const classifier = new StepClassifier();
255
+ const step = { type: 'observation', content: 'TypeError: Cannot read properties of undefined (reading "map")' };
256
+ const result = classifier.onStep(step);
257
+ assert.equal(result.type, 'error');
258
+ });
259
+
260
+ it('reclassifies observation with exit code failure', () => {
261
+ const classifier = new StepClassifier();
262
+ const step = { type: 'observation', content: 'Process exited with exit code 1' };
263
+ const result = classifier.onStep(step);
264
+ assert.equal(result.type, 'error');
265
+ });
266
+
267
+ it('reclassifies observation with ModuleNotFoundError', () => {
268
+ const classifier = new StepClassifier();
269
+ const step = { type: 'observation', content: 'ModuleNotFoundError: No module named requests' };
228
270
  const result = classifier.onStep(step);
229
271
  assert.equal(result.type, 'error');
230
272
  });
273
+
274
+ it('preserves action type even with error keywords', () => {
275
+ const classifier = new StepClassifier();
276
+ const step = { type: 'action', content: 'Command failed with exit code 1' };
277
+ const result = classifier.onStep(step);
278
+ assert.equal(result.type, 'action');
279
+ });
280
+
281
+ it('preserves action type regardless of is_error flag', () => {
282
+ const classifier = new StepClassifier();
283
+ const step = { type: 'action', content: 'ENOENT: no such file', is_error: true };
284
+ const result = classifier.onStep(step);
285
+ assert.equal(result.type, 'action');
286
+ });
231
287
  });
232
288
 
233
289
  describe('StepClassifier.classifyIntent', () => {
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@groove-dev/cli",
3
- "version": "0.27.112",
3
+ "version": "0.27.115",
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,9 +1,17 @@
1
1
  // GROOVE CLI — team commands
2
2
  // FSL-1.1-Apache-2.0 — see LICENSE
3
3
 
4
+ import { createInterface } from 'readline';
4
5
  import chalk from 'chalk';
5
6
  import { apiCall } from '../client.js';
6
7
 
8
+ function confirm(prompt) {
9
+ return new Promise((resolve) => {
10
+ const rl = createInterface({ input: process.stdin, output: process.stdout });
11
+ rl.question(prompt, (answer) => { rl.close(); resolve(answer.trim().toLowerCase() === 'y'); });
12
+ });
13
+ }
14
+
7
15
  export async function teamCreate(name) {
8
16
  try {
9
17
  const team = await apiCall('POST', '/api/teams', { name });
@@ -37,9 +45,43 @@ export async function teamList() {
37
45
  }
38
46
 
39
47
  export async function teamDelete(id) {
48
+ const ok = await confirm(` This will archive the team directory. Continue? [y/N] `);
49
+ if (!ok) {
50
+ console.log(chalk.dim(' Cancelled.'));
51
+ return;
52
+ }
40
53
  try {
41
54
  await apiCall('DELETE', `/api/teams/${encodeURIComponent(id)}`);
42
- console.log(chalk.green(` Deleted team "${id}"`));
55
+ console.log(chalk.green(` Archived team "${id}"`) + chalk.dim(' — restore with `groove team restore <id>`'));
56
+ } catch (err) {
57
+ console.error(chalk.red(' Failed:'), err.message);
58
+ process.exit(1);
59
+ }
60
+ }
61
+
62
+ export async function teamArchived() {
63
+ try {
64
+ const { archived } = await apiCall('GET', '/api/teams/archived');
65
+ if (archived.length === 0) {
66
+ console.log(chalk.dim(' No archived teams.'));
67
+ return;
68
+ }
69
+ console.log(chalk.bold(`\n Archived Teams (${archived.length})\n`));
70
+ for (const t of archived) {
71
+ const date = t.deletedAt ? new Date(t.deletedAt).toLocaleDateString() : 'unknown';
72
+ console.log(` ${chalk.bold(t.originalName || t.id)} — archive-id: ${t.id} — deleted ${date} — ${t.agentCount} agent(s)`);
73
+ }
74
+ console.log('');
75
+ } catch {
76
+ console.error(chalk.red(' Cannot connect to daemon.'));
77
+ process.exit(1);
78
+ }
79
+ }
80
+
81
+ export async function teamRestore(id) {
82
+ try {
83
+ const team = await apiCall('POST', `/api/teams/archived/${encodeURIComponent(id)}/restore`);
84
+ console.log(chalk.green(` Restored team "${team.name}"`) + ` (new id: ${team.id})`);
43
85
  } catch (err) {
44
86
  console.error(chalk.red(' Failed:'), err.message);
45
87
  process.exit(1);
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@groove-dev/daemon",
3
- "version": "0.27.112",
3
+ "version": "0.27.115",
4
4
  "description": "GROOVE daemon — agent orchestration engine",
5
5
  "license": "FSL-1.1-Apache-2.0",
6
6
  "type": "module",
@@ -1100,6 +1100,30 @@ export function createApi(app, daemon) {
1100
1100
  }
1101
1101
  });
1102
1102
 
1103
+ app.get('/api/teams/archived', (req, res) => {
1104
+ res.json({ archived: daemon.teams.listArchived() });
1105
+ });
1106
+
1107
+ app.post('/api/teams/archived/:id/restore', (req, res) => {
1108
+ try {
1109
+ const team = daemon.teams.restore(req.params.id);
1110
+ daemon.audit.log('team.restore', { archivedId: req.params.id, newId: team.id, name: team.name });
1111
+ res.json(team);
1112
+ } catch (err) {
1113
+ res.status(400).json({ error: err.message });
1114
+ }
1115
+ });
1116
+
1117
+ app.delete('/api/teams/archived/:id', (req, res) => {
1118
+ try {
1119
+ daemon.teams.purge(req.params.id);
1120
+ daemon.audit.log('team.purge', { archivedId: req.params.id });
1121
+ res.json({ ok: true });
1122
+ } catch (err) {
1123
+ res.status(400).json({ error: err.message });
1124
+ }
1125
+ });
1126
+
1103
1127
  app.patch('/api/teams/:id', (req, res) => {
1104
1128
  try {
1105
1129
  if (req.body.name) daemon.teams.rename(req.params.id, req.body.name);
@@ -3855,6 +3879,48 @@ Keep responses concise. Help them think, don't lecture them about the system the
3855
3879
  // --- Preview Proxy (same-origin iframe support) ---
3856
3880
  // Forwards HTTP requests to the dev server so the GUI can iframe the preview
3857
3881
  // without cross-origin issues. WebSocket upgrade for HMR is handled in index.js.
3882
+
3883
+ function rewriteAbsoluteUrls(body, proxyBase) {
3884
+ let out = body;
3885
+ // HTML attributes: src, href, action, poster
3886
+ out = out.replace(/((?:src|href|action|poster)\s*=\s*(["']))\/(?!\/|api\/preview\/)/g, `$1${proxyBase}/`);
3887
+ // JS imports: from '/' and import('/')
3888
+ out = out.replace(/(from\s+(["']))\/(?!\/|api\/preview\/)/g, `$1${proxyBase}/`);
3889
+ out = out.replace(/(import\s*\(\s*(["']))\/(?!\/|api\/preview\/)/g, `$1${proxyBase}/`);
3890
+ // CSS url()
3891
+ out = out.replace(/(url\s*\(\s*(["']?))\/(?!\/|api\/preview\/)/g, `$1${proxyBase}/`);
3892
+ return out;
3893
+ }
3894
+
3895
+ const REWRITABLE_TYPES = ['text/html', 'application/javascript', 'text/javascript', 'text/css'];
3896
+
3897
+ function handleProxyResponse(proxyRes, res, proxyBase) {
3898
+ const fwdHeaders = { ...proxyRes.headers };
3899
+ delete fwdHeaders['content-security-policy'];
3900
+ delete fwdHeaders['x-frame-options'];
3901
+
3902
+ const ct = (fwdHeaders['content-type'] || '').toLowerCase();
3903
+ const shouldRewrite = REWRITABLE_TYPES.some((t) => ct.includes(t));
3904
+
3905
+ if (!shouldRewrite) {
3906
+ res.writeHead(proxyRes.statusCode, fwdHeaders);
3907
+ proxyRes.pipe(res);
3908
+ return;
3909
+ }
3910
+
3911
+ const chunks = [];
3912
+ proxyRes.on('data', (c) => chunks.push(c));
3913
+ proxyRes.on('end', () => {
3914
+ let body = Buffer.concat(chunks).toString('utf8');
3915
+ body = rewriteAbsoluteUrls(body, proxyBase);
3916
+ const buf = Buffer.from(body, 'utf8');
3917
+ fwdHeaders['content-length'] = buf.length;
3918
+ delete fwdHeaders['transfer-encoding'];
3919
+ res.writeHead(proxyRes.statusCode, fwdHeaders);
3920
+ res.end(buf);
3921
+ });
3922
+ }
3923
+
3858
3924
  app.all('/api/preview/:teamId/proxy/*', (req, res) => {
3859
3925
  const entry = daemon.preview?.get(req.params.teamId);
3860
3926
  if (!entry || !entry.url) return res.status(404).json({ error: 'No active preview for this team' });
@@ -3865,9 +3931,11 @@ Keep responses concise. Help them think, don't lecture them about the system the
3865
3931
  const proxyPath = req.params[0] || '';
3866
3932
  const search = req.url.includes('?') ? '?' + req.url.split('?').slice(1).join('?') : '';
3867
3933
  const fullPath = '/' + proxyPath + search;
3934
+ const proxyBase = `/api/preview/${req.params.teamId}/proxy`;
3868
3935
 
3869
3936
  const headers = { ...req.headers };
3870
3937
  delete headers.host;
3938
+ delete headers['accept-encoding'];
3871
3939
  headers.host = targetUrl.host;
3872
3940
 
3873
3941
  const proxyReq = httpRequest({
@@ -3876,13 +3944,7 @@ Keep responses concise. Help them think, don't lecture them about the system the
3876
3944
  path: fullPath,
3877
3945
  method: req.method,
3878
3946
  headers,
3879
- }, (proxyRes) => {
3880
- const fwdHeaders = { ...proxyRes.headers };
3881
- delete fwdHeaders['content-security-policy'];
3882
- delete fwdHeaders['x-frame-options'];
3883
- res.writeHead(proxyRes.statusCode, fwdHeaders);
3884
- proxyRes.pipe(res);
3885
- });
3947
+ }, (proxyRes) => handleProxyResponse(proxyRes, res, proxyBase));
3886
3948
 
3887
3949
  proxyReq.on('error', (err) => {
3888
3950
  if (!res.headersSent) res.status(502).json({ error: `Proxy error: ${err.message}` });
@@ -3899,9 +3961,11 @@ Keep responses concise. Help them think, don't lecture them about the system the
3899
3961
  try { targetUrl = new URL(entry.devUrl || entry.url); } catch { return res.status(500).json({ error: 'Invalid preview URL' }); }
3900
3962
 
3901
3963
  const search = req.url.includes('?') ? '?' + req.url.split('?').slice(1).join('?') : '';
3964
+ const proxyBase = `/api/preview/${req.params.teamId}/proxy`;
3902
3965
 
3903
3966
  const headers = { ...req.headers };
3904
3967
  delete headers.host;
3968
+ delete headers['accept-encoding'];
3905
3969
  headers.host = targetUrl.host;
3906
3970
 
3907
3971
  const proxyReq = httpRequest({
@@ -3910,13 +3974,7 @@ Keep responses concise. Help them think, don't lecture them about the system the
3910
3974
  path: '/' + search,
3911
3975
  method: req.method,
3912
3976
  headers,
3913
- }, (proxyRes) => {
3914
- const fwdHeaders = { ...proxyRes.headers };
3915
- delete fwdHeaders['content-security-policy'];
3916
- delete fwdHeaders['x-frame-options'];
3917
- res.writeHead(proxyRes.statusCode, fwdHeaders);
3918
- proxyRes.pipe(res);
3919
- });
3977
+ }, (proxyRes) => handleProxyResponse(proxyRes, res, proxyBase));
3920
3978
 
3921
3979
  proxyReq.on('error', (err) => {
3922
3980
  if (!res.headersSent) res.status(502).json({ error: `Proxy error: ${err.message}` });
@@ -6183,13 +6241,15 @@ Keep responses concise. Help them think, don't lecture them about the system the
6183
6241
  stdio: ['ignore', 'pipe', 'pipe'],
6184
6242
  env: tagEnv,
6185
6243
  });
6244
+ daemon._networkCheckProc = proc;
6186
6245
  let stdout = '';
6187
6246
  let stderr = '';
6188
6247
  proc.stdout.on('data', (c) => { stdout += c.toString(); });
6189
6248
  proc.stderr.on('data', (c) => { stderr += c.toString(); });
6190
6249
  const timeout = setTimeout(() => { safeKill(proc, 'SIGTERM'); }, 10_000);
6191
- proc.on('error', () => { clearTimeout(timeout); resolvePromise(null); });
6250
+ proc.on('error', () => { clearTimeout(timeout); daemon._networkCheckProc = null; resolvePromise(null); });
6192
6251
  proc.on('close', (code) => {
6252
+ daemon._networkCheckProc = null;
6193
6253
  clearTimeout(timeout);
6194
6254
  if (code !== 0) return resolvePromise(null);
6195
6255
  const tags = [];