groove-dev 0.27.180 → 0.27.181
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.
- package/node_modules/@groove-dev/cli/package.json +1 -1
- package/node_modules/@groove-dev/daemon/package.json +1 -1
- package/node_modules/@groove-dev/daemon/src/process.js +88 -0
- package/node_modules/@groove-dev/daemon/src/providers/claude-code.js +7 -0
- package/node_modules/@groove-dev/daemon/test/claude-code-provider.test.js +76 -0
- package/node_modules/@groove-dev/gui/package.json +1 -1
- package/package.json +1 -1
- package/packages/cli/package.json +1 -1
- package/packages/daemon/package.json +1 -1
- package/packages/daemon/src/process.js +88 -0
- package/packages/daemon/src/providers/claude-code.js +7 -0
- package/packages/gui/package.json +1 -1
|
@@ -363,6 +363,14 @@ export class ProcessManager {
|
|
|
363
363
|
this._stalledAgents = new Set(); // agentIds already flagged as stalled (avoids duplicate broadcasts)
|
|
364
364
|
this._exitHandled = new Set();
|
|
365
365
|
this._resultReceived = new Set();
|
|
366
|
+
// In-flight user messages, kept until a turn actually completes so a
|
|
367
|
+
// dropped turn can be re-issued instead of losing the user's message.
|
|
368
|
+
// agentId -> { message, attempts }
|
|
369
|
+
this._pendingUserMessage = new Map();
|
|
370
|
+
// Retry count carried across the agent-id change that resume() performs.
|
|
371
|
+
// Kept separate so teardown of the old agent can't reset it to 0 and
|
|
372
|
+
// turn a bounded retry into an infinite respawn loop. oldAgentId -> attempts
|
|
373
|
+
this._retryAttemptCarry = new Map();
|
|
366
374
|
this._truncationFlagged = new Set(); // agentIds that have had any truncation in their session
|
|
367
375
|
this._lastAssistantBlocks = new Map(); // agentId -> last assistant content blocks (for abandoned tool_use detection)
|
|
368
376
|
this._previousCacheReadTokens = new Map(); // agentId -> previous turn's cacheReadTokens
|
|
@@ -1559,6 +1567,18 @@ For normal file edits within your scope, proceed without review.
|
|
|
1559
1567
|
|
|
1560
1568
|
// Session result data (cost, duration, turns)
|
|
1561
1569
|
if (output.type === 'result') {
|
|
1570
|
+
// Phantom-interrupt abort: on --resume with an orphaned background shell
|
|
1571
|
+
// task, the CLI injects a synthetic "[Request interrupted by user]" that
|
|
1572
|
+
// aborts the turn before the model is ever called. Fingerprint is an
|
|
1573
|
+
// error result that never reached the API (apiDurationMs === 0, so zero
|
|
1574
|
+
// cost). Retrying is free and succeeds ~94% of the time — treating it as
|
|
1575
|
+
// a normal result is what made user messages vanish silently.
|
|
1576
|
+
if (output.isError && output.apiDurationMs === 0) {
|
|
1577
|
+
if (this._retryDroppedTurn(agentId, agent, output)) return;
|
|
1578
|
+
}
|
|
1579
|
+
// A turn completed for real — the user's message is safely delivered.
|
|
1580
|
+
this._pendingUserMessage.delete(agentId);
|
|
1581
|
+
|
|
1562
1582
|
tokens.recordResult(agentId, {
|
|
1563
1583
|
costUsd: output.cost, durationMs: output.duration, turns: output.turns,
|
|
1564
1584
|
});
|
|
@@ -2510,6 +2530,59 @@ After fixing all issues, run tests (npm test) and build (npm run build) to verif
|
|
|
2510
2530
|
* spawns fresh with the full conversation thread instead of --resume.
|
|
2511
2531
|
* This avoids degraded context from internal compaction.
|
|
2512
2532
|
*/
|
|
2533
|
+
/**
|
|
2534
|
+
* Re-issue a user message whose turn was aborted before reaching the model.
|
|
2535
|
+
*
|
|
2536
|
+
* Returns true if a retry was scheduled (caller should skip normal result
|
|
2537
|
+
* handling), false if the turn should be treated as a real result.
|
|
2538
|
+
*/
|
|
2539
|
+
_retryDroppedTurn(agentId, agent, output) {
|
|
2540
|
+
const pending = this._pendingUserMessage.get(agentId);
|
|
2541
|
+
if (!pending) return false; // nothing to re-issue — let it fall through
|
|
2542
|
+
|
|
2543
|
+
const MAX_RETRIES = 2;
|
|
2544
|
+
if (pending.attempts >= MAX_RETRIES) {
|
|
2545
|
+
this._pendingUserMessage.delete(agentId);
|
|
2546
|
+
this.daemon.broadcast({
|
|
2547
|
+
type: 'agent:output',
|
|
2548
|
+
agentId,
|
|
2549
|
+
data: {
|
|
2550
|
+
type: 'activity',
|
|
2551
|
+
subtype: 'error',
|
|
2552
|
+
data: `Message could not be delivered after ${MAX_RETRIES + 1} attempts `
|
|
2553
|
+
+ `(${output.terminalReason || output.subtype || 'turn aborted'}). Please re-send.`,
|
|
2554
|
+
},
|
|
2555
|
+
});
|
|
2556
|
+
return false;
|
|
2557
|
+
}
|
|
2558
|
+
|
|
2559
|
+
pending.attempts += 1;
|
|
2560
|
+
this._retryAttemptCarry.set(agentId, pending.attempts);
|
|
2561
|
+
this.daemon.broadcast({
|
|
2562
|
+
type: 'agent:output',
|
|
2563
|
+
agentId,
|
|
2564
|
+
data: {
|
|
2565
|
+
type: 'activity',
|
|
2566
|
+
subtype: 'info',
|
|
2567
|
+
data: `Turn aborted before reaching the model — retrying (${pending.attempts}/${MAX_RETRIES})…`,
|
|
2568
|
+
},
|
|
2569
|
+
});
|
|
2570
|
+
|
|
2571
|
+
// Let the aborted process finish tearing down before re-spawning; resume()
|
|
2572
|
+
// kills the current handle and re-registers under a new agent id.
|
|
2573
|
+
setTimeout(() => {
|
|
2574
|
+
this.resume(agentId, pending.message).catch((err) => {
|
|
2575
|
+
this.daemon.broadcast({
|
|
2576
|
+
type: 'agent:output',
|
|
2577
|
+
agentId,
|
|
2578
|
+
data: { type: 'activity', subtype: 'error', data: `Retry failed: ${err.message}` },
|
|
2579
|
+
});
|
|
2580
|
+
});
|
|
2581
|
+
}, 500);
|
|
2582
|
+
|
|
2583
|
+
return true;
|
|
2584
|
+
}
|
|
2585
|
+
|
|
2513
2586
|
async resume(agentId, message) {
|
|
2514
2587
|
const { registry, locks } = this.daemon;
|
|
2515
2588
|
const agent = registry.get(agentId);
|
|
@@ -2577,8 +2650,19 @@ After fixing all issues, run tests (npm test) and build (npm run build) to verif
|
|
|
2577
2650
|
workingDir: config.workingDir || this.daemon.config?.defaultWorkingDir || undefined,
|
|
2578
2651
|
name: config.name,
|
|
2579
2652
|
teamId: config.teamId,
|
|
2653
|
+
authMode: config.authMode,
|
|
2580
2654
|
});
|
|
2581
2655
|
|
|
2656
|
+
// Hold the message until a turn actually completes, so a turn aborted
|
|
2657
|
+
// before reaching the model can be re-issued rather than lost. Carries the
|
|
2658
|
+
// attempt count forward from the agent id we're replacing.
|
|
2659
|
+
if (message) {
|
|
2660
|
+
const carried = this._retryAttemptCarry.get(agentId);
|
|
2661
|
+
this._retryAttemptCarry.delete(agentId);
|
|
2662
|
+
this._pendingUserMessage.delete(agentId);
|
|
2663
|
+
this._pendingUserMessage.set(newAgent.id, { message, attempts: carried || 0 });
|
|
2664
|
+
}
|
|
2665
|
+
|
|
2582
2666
|
// Carry cumulative tokens
|
|
2583
2667
|
if (config.tokensUsed > 0) {
|
|
2584
2668
|
registry.update(newAgent.id, { tokensUsed: config.tokensUsed });
|
|
@@ -2959,6 +3043,10 @@ After fixing all issues, run tests (npm test) and build (npm run build) to verif
|
|
|
2959
3043
|
if (throttle?.timer) clearTimeout(throttle.timer);
|
|
2960
3044
|
this._streamThrottle.delete(agentId);
|
|
2961
3045
|
this.pendingMessages.delete(agentId);
|
|
3046
|
+
// Safe during a retry: resume() re-registers the message under the new
|
|
3047
|
+
// agent id from its own `message` argument, and the attempt count lives in
|
|
3048
|
+
// _retryAttemptCarry (which kill() must not touch).
|
|
3049
|
+
this._pendingUserMessage.delete(agentId);
|
|
2962
3050
|
|
|
2963
3051
|
// Unregister ambassador if this agent was one
|
|
2964
3052
|
const agent = this.daemon.registry.get(agentId);
|
|
@@ -257,6 +257,13 @@ export class ClaudeCodeProvider extends Provider {
|
|
|
257
257
|
cost: data.total_cost_usd,
|
|
258
258
|
duration: data.duration_ms,
|
|
259
259
|
turns: data.num_turns,
|
|
260
|
+
// Error/abort signals. Without these an `error_during_execution`
|
|
261
|
+
// result looks identical to a successful one and the user's
|
|
262
|
+
// message is silently dropped.
|
|
263
|
+
subtype: data.subtype,
|
|
264
|
+
isError: data.is_error === true || data.subtype === 'error_during_execution',
|
|
265
|
+
apiDurationMs: data.duration_api_ms,
|
|
266
|
+
terminalReason: data.terminal_reason,
|
|
260
267
|
});
|
|
261
268
|
}
|
|
262
269
|
} catch {
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
// GROOVE — Claude Code Provider Tests
|
|
2
|
+
// FSL-1.1-Apache-2.0 — see LICENSE
|
|
3
|
+
|
|
4
|
+
import { describe, it } from 'node:test';
|
|
5
|
+
import assert from 'node:assert/strict';
|
|
6
|
+
import { ClaudeCodeProvider } from '../src/providers/claude-code.js';
|
|
7
|
+
|
|
8
|
+
const provider = new ClaudeCodeProvider();
|
|
9
|
+
|
|
10
|
+
describe('ClaudeCodeProvider result parsing', () => {
|
|
11
|
+
// Regression: the "phantom interrupt" bug. On --resume with an orphaned
|
|
12
|
+
// background shell task, the CLI aborts the turn before calling the model.
|
|
13
|
+
// These fields were previously dropped, so the abort was indistinguishable
|
|
14
|
+
// from a successful turn and the user's message vanished with no UI signal.
|
|
15
|
+
it('surfaces error signals from an aborted turn', () => {
|
|
16
|
+
const out = provider.parseOutput(JSON.stringify({
|
|
17
|
+
type: 'result',
|
|
18
|
+
subtype: 'error_during_execution',
|
|
19
|
+
is_error: true,
|
|
20
|
+
duration_ms: 5126,
|
|
21
|
+
duration_api_ms: 0,
|
|
22
|
+
num_turns: 2,
|
|
23
|
+
total_cost_usd: 0,
|
|
24
|
+
terminal_reason: 'aborted_streaming',
|
|
25
|
+
}));
|
|
26
|
+
|
|
27
|
+
assert.equal(out.type, 'result');
|
|
28
|
+
assert.equal(out.isError, true);
|
|
29
|
+
assert.equal(out.apiDurationMs, 0, 'api duration 0 proves the model was never reached');
|
|
30
|
+
assert.equal(out.terminalReason, 'aborted_streaming');
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
it('treats an error_during_execution subtype as an error even without is_error', () => {
|
|
34
|
+
const out = provider.parseOutput(JSON.stringify({
|
|
35
|
+
type: 'result', subtype: 'error_during_execution', duration_api_ms: 0,
|
|
36
|
+
}));
|
|
37
|
+
assert.equal(out.isError, true);
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
it('does not flag a successful turn as an error', () => {
|
|
41
|
+
const out = provider.parseOutput(JSON.stringify({
|
|
42
|
+
type: 'result',
|
|
43
|
+
subtype: 'success',
|
|
44
|
+
is_error: false,
|
|
45
|
+
duration_ms: 17850,
|
|
46
|
+
duration_api_ms: 17672,
|
|
47
|
+
num_turns: 3,
|
|
48
|
+
total_cost_usd: 0.42,
|
|
49
|
+
terminal_reason: 'completed',
|
|
50
|
+
}));
|
|
51
|
+
|
|
52
|
+
assert.equal(out.isError, false);
|
|
53
|
+
assert.equal(out.apiDurationMs, 17672);
|
|
54
|
+
assert.equal(out.cost, 0.42);
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
// The retry gate is (isError && apiDurationMs === 0). A turn that failed
|
|
58
|
+
// *after* reaching the model has real cost and must not be auto-retried.
|
|
59
|
+
it('distinguishes a pre-model abort from a post-model failure', () => {
|
|
60
|
+
const preModel = provider.parseOutput(JSON.stringify({
|
|
61
|
+
type: 'result', subtype: 'error_during_execution', is_error: true, duration_api_ms: 0,
|
|
62
|
+
}));
|
|
63
|
+
const postModel = provider.parseOutput(JSON.stringify({
|
|
64
|
+
type: 'result', subtype: 'error_during_execution', is_error: true, duration_api_ms: 8400,
|
|
65
|
+
}));
|
|
66
|
+
|
|
67
|
+
assert.equal(preModel.isError && preModel.apiDurationMs === 0, true, 'free to retry');
|
|
68
|
+
assert.equal(postModel.isError && postModel.apiDurationMs === 0, false, 'already billed — do not retry');
|
|
69
|
+
});
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
describe('ClaudeCodeProvider models', () => {
|
|
73
|
+
it('offers Fable 5', () => {
|
|
74
|
+
assert.ok(ClaudeCodeProvider.models.some((m) => m.id === 'claude-fable-5'));
|
|
75
|
+
});
|
|
76
|
+
});
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "groove-dev",
|
|
3
|
-
"version": "0.27.
|
|
3
|
+
"version": "0.27.181",
|
|
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)",
|
|
@@ -363,6 +363,14 @@ export class ProcessManager {
|
|
|
363
363
|
this._stalledAgents = new Set(); // agentIds already flagged as stalled (avoids duplicate broadcasts)
|
|
364
364
|
this._exitHandled = new Set();
|
|
365
365
|
this._resultReceived = new Set();
|
|
366
|
+
// In-flight user messages, kept until a turn actually completes so a
|
|
367
|
+
// dropped turn can be re-issued instead of losing the user's message.
|
|
368
|
+
// agentId -> { message, attempts }
|
|
369
|
+
this._pendingUserMessage = new Map();
|
|
370
|
+
// Retry count carried across the agent-id change that resume() performs.
|
|
371
|
+
// Kept separate so teardown of the old agent can't reset it to 0 and
|
|
372
|
+
// turn a bounded retry into an infinite respawn loop. oldAgentId -> attempts
|
|
373
|
+
this._retryAttemptCarry = new Map();
|
|
366
374
|
this._truncationFlagged = new Set(); // agentIds that have had any truncation in their session
|
|
367
375
|
this._lastAssistantBlocks = new Map(); // agentId -> last assistant content blocks (for abandoned tool_use detection)
|
|
368
376
|
this._previousCacheReadTokens = new Map(); // agentId -> previous turn's cacheReadTokens
|
|
@@ -1559,6 +1567,18 @@ For normal file edits within your scope, proceed without review.
|
|
|
1559
1567
|
|
|
1560
1568
|
// Session result data (cost, duration, turns)
|
|
1561
1569
|
if (output.type === 'result') {
|
|
1570
|
+
// Phantom-interrupt abort: on --resume with an orphaned background shell
|
|
1571
|
+
// task, the CLI injects a synthetic "[Request interrupted by user]" that
|
|
1572
|
+
// aborts the turn before the model is ever called. Fingerprint is an
|
|
1573
|
+
// error result that never reached the API (apiDurationMs === 0, so zero
|
|
1574
|
+
// cost). Retrying is free and succeeds ~94% of the time — treating it as
|
|
1575
|
+
// a normal result is what made user messages vanish silently.
|
|
1576
|
+
if (output.isError && output.apiDurationMs === 0) {
|
|
1577
|
+
if (this._retryDroppedTurn(agentId, agent, output)) return;
|
|
1578
|
+
}
|
|
1579
|
+
// A turn completed for real — the user's message is safely delivered.
|
|
1580
|
+
this._pendingUserMessage.delete(agentId);
|
|
1581
|
+
|
|
1562
1582
|
tokens.recordResult(agentId, {
|
|
1563
1583
|
costUsd: output.cost, durationMs: output.duration, turns: output.turns,
|
|
1564
1584
|
});
|
|
@@ -2510,6 +2530,59 @@ After fixing all issues, run tests (npm test) and build (npm run build) to verif
|
|
|
2510
2530
|
* spawns fresh with the full conversation thread instead of --resume.
|
|
2511
2531
|
* This avoids degraded context from internal compaction.
|
|
2512
2532
|
*/
|
|
2533
|
+
/**
|
|
2534
|
+
* Re-issue a user message whose turn was aborted before reaching the model.
|
|
2535
|
+
*
|
|
2536
|
+
* Returns true if a retry was scheduled (caller should skip normal result
|
|
2537
|
+
* handling), false if the turn should be treated as a real result.
|
|
2538
|
+
*/
|
|
2539
|
+
_retryDroppedTurn(agentId, agent, output) {
|
|
2540
|
+
const pending = this._pendingUserMessage.get(agentId);
|
|
2541
|
+
if (!pending) return false; // nothing to re-issue — let it fall through
|
|
2542
|
+
|
|
2543
|
+
const MAX_RETRIES = 2;
|
|
2544
|
+
if (pending.attempts >= MAX_RETRIES) {
|
|
2545
|
+
this._pendingUserMessage.delete(agentId);
|
|
2546
|
+
this.daemon.broadcast({
|
|
2547
|
+
type: 'agent:output',
|
|
2548
|
+
agentId,
|
|
2549
|
+
data: {
|
|
2550
|
+
type: 'activity',
|
|
2551
|
+
subtype: 'error',
|
|
2552
|
+
data: `Message could not be delivered after ${MAX_RETRIES + 1} attempts `
|
|
2553
|
+
+ `(${output.terminalReason || output.subtype || 'turn aborted'}). Please re-send.`,
|
|
2554
|
+
},
|
|
2555
|
+
});
|
|
2556
|
+
return false;
|
|
2557
|
+
}
|
|
2558
|
+
|
|
2559
|
+
pending.attempts += 1;
|
|
2560
|
+
this._retryAttemptCarry.set(agentId, pending.attempts);
|
|
2561
|
+
this.daemon.broadcast({
|
|
2562
|
+
type: 'agent:output',
|
|
2563
|
+
agentId,
|
|
2564
|
+
data: {
|
|
2565
|
+
type: 'activity',
|
|
2566
|
+
subtype: 'info',
|
|
2567
|
+
data: `Turn aborted before reaching the model — retrying (${pending.attempts}/${MAX_RETRIES})…`,
|
|
2568
|
+
},
|
|
2569
|
+
});
|
|
2570
|
+
|
|
2571
|
+
// Let the aborted process finish tearing down before re-spawning; resume()
|
|
2572
|
+
// kills the current handle and re-registers under a new agent id.
|
|
2573
|
+
setTimeout(() => {
|
|
2574
|
+
this.resume(agentId, pending.message).catch((err) => {
|
|
2575
|
+
this.daemon.broadcast({
|
|
2576
|
+
type: 'agent:output',
|
|
2577
|
+
agentId,
|
|
2578
|
+
data: { type: 'activity', subtype: 'error', data: `Retry failed: ${err.message}` },
|
|
2579
|
+
});
|
|
2580
|
+
});
|
|
2581
|
+
}, 500);
|
|
2582
|
+
|
|
2583
|
+
return true;
|
|
2584
|
+
}
|
|
2585
|
+
|
|
2513
2586
|
async resume(agentId, message) {
|
|
2514
2587
|
const { registry, locks } = this.daemon;
|
|
2515
2588
|
const agent = registry.get(agentId);
|
|
@@ -2577,8 +2650,19 @@ After fixing all issues, run tests (npm test) and build (npm run build) to verif
|
|
|
2577
2650
|
workingDir: config.workingDir || this.daemon.config?.defaultWorkingDir || undefined,
|
|
2578
2651
|
name: config.name,
|
|
2579
2652
|
teamId: config.teamId,
|
|
2653
|
+
authMode: config.authMode,
|
|
2580
2654
|
});
|
|
2581
2655
|
|
|
2656
|
+
// Hold the message until a turn actually completes, so a turn aborted
|
|
2657
|
+
// before reaching the model can be re-issued rather than lost. Carries the
|
|
2658
|
+
// attempt count forward from the agent id we're replacing.
|
|
2659
|
+
if (message) {
|
|
2660
|
+
const carried = this._retryAttemptCarry.get(agentId);
|
|
2661
|
+
this._retryAttemptCarry.delete(agentId);
|
|
2662
|
+
this._pendingUserMessage.delete(agentId);
|
|
2663
|
+
this._pendingUserMessage.set(newAgent.id, { message, attempts: carried || 0 });
|
|
2664
|
+
}
|
|
2665
|
+
|
|
2582
2666
|
// Carry cumulative tokens
|
|
2583
2667
|
if (config.tokensUsed > 0) {
|
|
2584
2668
|
registry.update(newAgent.id, { tokensUsed: config.tokensUsed });
|
|
@@ -2959,6 +3043,10 @@ After fixing all issues, run tests (npm test) and build (npm run build) to verif
|
|
|
2959
3043
|
if (throttle?.timer) clearTimeout(throttle.timer);
|
|
2960
3044
|
this._streamThrottle.delete(agentId);
|
|
2961
3045
|
this.pendingMessages.delete(agentId);
|
|
3046
|
+
// Safe during a retry: resume() re-registers the message under the new
|
|
3047
|
+
// agent id from its own `message` argument, and the attempt count lives in
|
|
3048
|
+
// _retryAttemptCarry (which kill() must not touch).
|
|
3049
|
+
this._pendingUserMessage.delete(agentId);
|
|
2962
3050
|
|
|
2963
3051
|
// Unregister ambassador if this agent was one
|
|
2964
3052
|
const agent = this.daemon.registry.get(agentId);
|
|
@@ -257,6 +257,13 @@ export class ClaudeCodeProvider extends Provider {
|
|
|
257
257
|
cost: data.total_cost_usd,
|
|
258
258
|
duration: data.duration_ms,
|
|
259
259
|
turns: data.num_turns,
|
|
260
|
+
// Error/abort signals. Without these an `error_during_execution`
|
|
261
|
+
// result looks identical to a successful one and the user's
|
|
262
|
+
// message is silently dropped.
|
|
263
|
+
subtype: data.subtype,
|
|
264
|
+
isError: data.is_error === true || data.subtype === 'error_during_execution',
|
|
265
|
+
apiDurationMs: data.duration_api_ms,
|
|
266
|
+
terminalReason: data.terminal_reason,
|
|
260
267
|
});
|
|
261
268
|
}
|
|
262
269
|
} catch {
|