groove-dev 0.9.2 → 0.10.0

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.
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@groove-dev/cli",
3
- "version": "0.9.2",
3
+ "version": "0.10.0",
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.9.2",
3
+ "version": "0.10.0",
4
4
  "description": "GROOVE daemon — agent orchestration engine",
5
5
  "license": "FSL-1.1-Apache-2.0",
6
6
  "type": "module",
@@ -254,8 +254,9 @@ export function createApi(app, daemon) {
254
254
  }
255
255
  });
256
256
 
257
- // Instruct an agent (rotation with user message appended)
258
- // Works for both running agents (rotates with handoff) and dead agents (continues conversation)
257
+ // Instruct an agent resumes session if possible, falls back to rotation
258
+ // Resume = zero cold-start (uses --resume SESSION_ID)
259
+ // Rotation = full handoff brief (only for degradation or no session)
259
260
  app.post('/api/agents/:id/instruct', async (req, res) => {
260
261
  try {
261
262
  const { message } = req.body;
@@ -264,9 +265,13 @@ export function createApi(app, daemon) {
264
265
  }
265
266
  const agent = daemon.registry.get(req.params.id);
266
267
  if (!agent) return res.status(404).json({ error: 'Agent not found' });
267
- const newAgent = await daemon.rotator.rotate(req.params.id, {
268
- additionalPrompt: message.trim(),
269
- });
268
+
269
+ // Try session resume first (zero cold-start)
270
+ // Falls back to rotation if no session ID or provider doesn't support resume
271
+ const newAgent = agent.sessionId
272
+ ? await daemon.processes.resume(req.params.id, message.trim())
273
+ : await daemon.rotator.rotate(req.params.id, { additionalPrompt: message.trim() });
274
+
270
275
  res.json(newAgent);
271
276
  } catch (err) {
272
277
  res.status(400).json({ error: err.message });
@@ -194,6 +194,10 @@ For normal file edits within your scope, proceed without review.
194
194
  this.daemon.classifier.addEvent(agent.id, output);
195
195
 
196
196
  const updates = { lastActivity: new Date().toISOString() };
197
+ // Capture session_id for --resume support (zero cold-start continuation)
198
+ if (output.sessionId) {
199
+ updates.sessionId = output.sessionId;
200
+ }
197
201
  if (output.tokensUsed !== undefined && output.tokensUsed > 0) {
198
202
  const current = registry.get(agent.id);
199
203
  if (current) {
@@ -262,6 +266,135 @@ For normal file edits within your scope, proceed without review.
262
266
  return agent;
263
267
  }
264
268
 
269
+ /**
270
+ * Resume a completed agent's session with a new message.
271
+ * Uses --resume SESSION_ID for zero cold-start continuation.
272
+ * Falls back to full spawn if no session ID available.
273
+ */
274
+ async resume(agentId, message) {
275
+ const { registry, locks } = this.daemon;
276
+ const agent = registry.get(agentId);
277
+ if (!agent) throw new Error(`Agent ${agentId} not found`);
278
+
279
+ // If no session ID, fall back to rotation (handoff brief)
280
+ if (!agent.sessionId) {
281
+ return this.daemon.rotator.rotate(agentId, { additionalPrompt: message });
282
+ }
283
+
284
+ const provider = getProvider(agent.provider || 'claude-code');
285
+ if (!provider?.buildResumeCommand) {
286
+ return this.daemon.rotator.rotate(agentId, { additionalPrompt: message });
287
+ }
288
+
289
+ // Clean up old agent entry but keep the data we need
290
+ const config = { ...agent };
291
+ const sessionId = agent.sessionId;
292
+
293
+ // Kill if still running, or remove if dead
294
+ if (this.handles.has(agentId)) {
295
+ await this.kill(agentId);
296
+ } else {
297
+ locks.release(agentId);
298
+ registry.remove(agentId);
299
+ }
300
+
301
+ // Build resume command
302
+ const { command, args, env } = provider.buildResumeCommand(sessionId, message, config.model);
303
+
304
+ // Set up log capture
305
+ const logDir = resolve(this.daemon.grooveDir, 'logs');
306
+ mkdirSync(logDir, { recursive: true });
307
+ const logPath = resolve(logDir, `${sanitizeFilename(config.name)}.log`);
308
+ const logStream = createWriteStream(logPath, { flags: 'a', mode: 0o600 });
309
+
310
+ const resumeLine = `[${new Date().toISOString()}] GROOVE resuming session: ${command} --resume ${sessionId}\n`;
311
+ logStream.write(resumeLine);
312
+
313
+ // Re-register in registry with same name
314
+ const newAgent = registry.add({
315
+ role: config.role,
316
+ scope: config.scope,
317
+ provider: config.provider,
318
+ model: config.model,
319
+ prompt: config.prompt,
320
+ permission: config.permission,
321
+ workingDir: config.workingDir,
322
+ name: config.name,
323
+ });
324
+
325
+ // Carry cumulative tokens
326
+ if (config.tokensUsed > 0) {
327
+ registry.update(newAgent.id, { tokensUsed: config.tokensUsed });
328
+ }
329
+
330
+ // Re-register locks
331
+ if (newAgent.scope && newAgent.scope.length > 0) {
332
+ locks.register(newAgent.id, newAgent.scope);
333
+ }
334
+
335
+ // Spawn the resumed process
336
+ const proc = cpSpawn(command, args, {
337
+ cwd: config.workingDir || this.daemon.projectDir,
338
+ env: { ...process.env, ...env, GROOVE_AGENT_ID: newAgent.id, GROOVE_AGENT_NAME: newAgent.name },
339
+ stdio: ['ignore', 'pipe', 'pipe'],
340
+ detached: false,
341
+ });
342
+
343
+ if (!proc.pid) {
344
+ registry.remove(newAgent.id);
345
+ locks.release(newAgent.id);
346
+ logStream.end();
347
+ throw new Error(`Failed to resume — process has no PID`);
348
+ }
349
+
350
+ this.handles.set(newAgent.id, { proc, logStream });
351
+ registry.update(newAgent.id, { status: 'running', pid: proc.pid });
352
+
353
+ // Same stdout/stderr/exit handling as spawn
354
+ proc.stdout.on('data', (chunk) => {
355
+ logStream.write(chunk);
356
+ const output = provider.parseOutput(chunk.toString());
357
+ if (output) {
358
+ this.daemon.classifier.addEvent(newAgent.id, output);
359
+ const updates = { lastActivity: new Date().toISOString() };
360
+ if (output.sessionId) updates.sessionId = output.sessionId;
361
+ if (output.tokensUsed !== undefined && output.tokensUsed > 0) {
362
+ const current = registry.get(newAgent.id);
363
+ if (current) {
364
+ updates.tokensUsed = current.tokensUsed + output.tokensUsed;
365
+ this.daemon.tokens.record(newAgent.id, output.tokensUsed);
366
+ }
367
+ }
368
+ if (output.contextUsage !== undefined) updates.contextUsage = output.contextUsage;
369
+ registry.update(newAgent.id, updates);
370
+ this.daemon.broadcast({ type: 'agent:output', agentId: newAgent.id, data: output });
371
+ }
372
+ });
373
+
374
+ proc.stderr.on('data', (chunk) => { logStream.write(`[stderr] ${chunk}`); });
375
+
376
+ proc.on('exit', (code, signal) => {
377
+ logStream.write(`[${new Date().toISOString()}] Process exited: code=${code} signal=${signal}\n`);
378
+ logStream.end();
379
+ this.handles.delete(newAgent.id);
380
+ const finalStatus = signal === 'SIGTERM' || signal === 'SIGKILL' ? 'killed' : code === 0 ? 'completed' : 'crashed';
381
+ registry.update(newAgent.id, { status: finalStatus, pid: null });
382
+ this.daemon.broadcast({ type: 'agent:exit', agentId: newAgent.id, code, signal, status: finalStatus });
383
+ if (finalStatus === 'completed' && this.daemon.journalist) {
384
+ this.daemon.journalist.cycle().catch(() => {});
385
+ }
386
+ });
387
+
388
+ proc.on('error', (err) => {
389
+ logStream.write(`[error] ${err.message}\n`);
390
+ logStream.end();
391
+ this.handles.delete(newAgent.id);
392
+ registry.update(newAgent.id, { status: 'crashed', pid: null });
393
+ });
394
+
395
+ return newAgent;
396
+ }
397
+
265
398
  async kill(agentId) {
266
399
  const handle = this.handles.get(agentId);
267
400
 
@@ -61,6 +61,15 @@ export class ClaudeCodeProvider extends Provider {
61
61
  };
62
62
  }
63
63
 
64
+ buildResumeCommand(sessionId, prompt, model) {
65
+ // Resume a previous session — preserves full conversation history
66
+ // No cold start, no handoff brief needed
67
+ const args = ['--resume', sessionId, '--output-format', 'stream-json', '--verbose', '--dangerously-skip-permissions'];
68
+ if (model) args.push('--model', model);
69
+ if (prompt) args.push(prompt);
70
+ return { command: 'claude', args, env: {} };
71
+ }
72
+
64
73
  buildHeadlessCommand(prompt, model) {
65
74
  const args = ['-p', prompt, '--output-format', 'stream-json', '--verbose'];
66
75
  if (model) args.push('--model', model);
@@ -109,6 +118,11 @@ export class ClaudeCodeProvider extends Provider {
109
118
  try {
110
119
  const data = JSON.parse(l);
111
120
 
121
+ // Capture session_id for --resume support
122
+ if (data.session_id) {
123
+ events.push({ type: 'session', sessionId: data.session_id });
124
+ }
125
+
112
126
  if (data.type === 'assistant') {
113
127
  events.push({
114
128
  type: 'activity',
@@ -48,7 +48,7 @@ export class Registry extends EventEmitter {
48
48
  if (!agent) return null;
49
49
 
50
50
  // Only allow known fields to prevent prototype pollution
51
- const SAFE_FIELDS = ['status', 'pid', 'tokensUsed', 'contextUsage', 'lastActivity', 'model', 'name', 'routingMode', 'routingReason'];
51
+ const SAFE_FIELDS = ['status', 'pid', 'tokensUsed', 'contextUsage', 'lastActivity', 'model', 'name', 'routingMode', 'routingReason', 'sessionId'];
52
52
  for (const key of Object.keys(updates)) {
53
53
  if (SAFE_FIELDS.includes(key)) {
54
54
  agent[key] = updates[key];
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@groove-dev/gui",
3
- "version": "0.9.2",
3
+ "version": "0.10.0",
4
4
  "description": "GROOVE GUI — visual agent control plane",
5
5
  "license": "FSL-1.1-Apache-2.0",
6
6
  "type": "module",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "groove-dev",
3
- "version": "0.9.2",
3
+ "version": "0.10.0",
4
4
  "description": "Open-source agent orchestration layer for AI coding tools. GUI dashboard, multi-agent coordination, zero cold-start (Journalist), infinite sessions (adaptive context rotation), AI Project Manager, Quick Launch. Works with Claude Code, Codex, Gemini CLI, Ollama.",
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.9.2",
3
+ "version": "0.10.0",
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.9.2",
3
+ "version": "0.10.0",
4
4
  "description": "GROOVE daemon — agent orchestration engine",
5
5
  "license": "FSL-1.1-Apache-2.0",
6
6
  "type": "module",
@@ -254,8 +254,9 @@ export function createApi(app, daemon) {
254
254
  }
255
255
  });
256
256
 
257
- // Instruct an agent (rotation with user message appended)
258
- // Works for both running agents (rotates with handoff) and dead agents (continues conversation)
257
+ // Instruct an agent resumes session if possible, falls back to rotation
258
+ // Resume = zero cold-start (uses --resume SESSION_ID)
259
+ // Rotation = full handoff brief (only for degradation or no session)
259
260
  app.post('/api/agents/:id/instruct', async (req, res) => {
260
261
  try {
261
262
  const { message } = req.body;
@@ -264,9 +265,13 @@ export function createApi(app, daemon) {
264
265
  }
265
266
  const agent = daemon.registry.get(req.params.id);
266
267
  if (!agent) return res.status(404).json({ error: 'Agent not found' });
267
- const newAgent = await daemon.rotator.rotate(req.params.id, {
268
- additionalPrompt: message.trim(),
269
- });
268
+
269
+ // Try session resume first (zero cold-start)
270
+ // Falls back to rotation if no session ID or provider doesn't support resume
271
+ const newAgent = agent.sessionId
272
+ ? await daemon.processes.resume(req.params.id, message.trim())
273
+ : await daemon.rotator.rotate(req.params.id, { additionalPrompt: message.trim() });
274
+
270
275
  res.json(newAgent);
271
276
  } catch (err) {
272
277
  res.status(400).json({ error: err.message });
@@ -194,6 +194,10 @@ For normal file edits within your scope, proceed without review.
194
194
  this.daemon.classifier.addEvent(agent.id, output);
195
195
 
196
196
  const updates = { lastActivity: new Date().toISOString() };
197
+ // Capture session_id for --resume support (zero cold-start continuation)
198
+ if (output.sessionId) {
199
+ updates.sessionId = output.sessionId;
200
+ }
197
201
  if (output.tokensUsed !== undefined && output.tokensUsed > 0) {
198
202
  const current = registry.get(agent.id);
199
203
  if (current) {
@@ -262,6 +266,135 @@ For normal file edits within your scope, proceed without review.
262
266
  return agent;
263
267
  }
264
268
 
269
+ /**
270
+ * Resume a completed agent's session with a new message.
271
+ * Uses --resume SESSION_ID for zero cold-start continuation.
272
+ * Falls back to full spawn if no session ID available.
273
+ */
274
+ async resume(agentId, message) {
275
+ const { registry, locks } = this.daemon;
276
+ const agent = registry.get(agentId);
277
+ if (!agent) throw new Error(`Agent ${agentId} not found`);
278
+
279
+ // If no session ID, fall back to rotation (handoff brief)
280
+ if (!agent.sessionId) {
281
+ return this.daemon.rotator.rotate(agentId, { additionalPrompt: message });
282
+ }
283
+
284
+ const provider = getProvider(agent.provider || 'claude-code');
285
+ if (!provider?.buildResumeCommand) {
286
+ return this.daemon.rotator.rotate(agentId, { additionalPrompt: message });
287
+ }
288
+
289
+ // Clean up old agent entry but keep the data we need
290
+ const config = { ...agent };
291
+ const sessionId = agent.sessionId;
292
+
293
+ // Kill if still running, or remove if dead
294
+ if (this.handles.has(agentId)) {
295
+ await this.kill(agentId);
296
+ } else {
297
+ locks.release(agentId);
298
+ registry.remove(agentId);
299
+ }
300
+
301
+ // Build resume command
302
+ const { command, args, env } = provider.buildResumeCommand(sessionId, message, config.model);
303
+
304
+ // Set up log capture
305
+ const logDir = resolve(this.daemon.grooveDir, 'logs');
306
+ mkdirSync(logDir, { recursive: true });
307
+ const logPath = resolve(logDir, `${sanitizeFilename(config.name)}.log`);
308
+ const logStream = createWriteStream(logPath, { flags: 'a', mode: 0o600 });
309
+
310
+ const resumeLine = `[${new Date().toISOString()}] GROOVE resuming session: ${command} --resume ${sessionId}\n`;
311
+ logStream.write(resumeLine);
312
+
313
+ // Re-register in registry with same name
314
+ const newAgent = registry.add({
315
+ role: config.role,
316
+ scope: config.scope,
317
+ provider: config.provider,
318
+ model: config.model,
319
+ prompt: config.prompt,
320
+ permission: config.permission,
321
+ workingDir: config.workingDir,
322
+ name: config.name,
323
+ });
324
+
325
+ // Carry cumulative tokens
326
+ if (config.tokensUsed > 0) {
327
+ registry.update(newAgent.id, { tokensUsed: config.tokensUsed });
328
+ }
329
+
330
+ // Re-register locks
331
+ if (newAgent.scope && newAgent.scope.length > 0) {
332
+ locks.register(newAgent.id, newAgent.scope);
333
+ }
334
+
335
+ // Spawn the resumed process
336
+ const proc = cpSpawn(command, args, {
337
+ cwd: config.workingDir || this.daemon.projectDir,
338
+ env: { ...process.env, ...env, GROOVE_AGENT_ID: newAgent.id, GROOVE_AGENT_NAME: newAgent.name },
339
+ stdio: ['ignore', 'pipe', 'pipe'],
340
+ detached: false,
341
+ });
342
+
343
+ if (!proc.pid) {
344
+ registry.remove(newAgent.id);
345
+ locks.release(newAgent.id);
346
+ logStream.end();
347
+ throw new Error(`Failed to resume — process has no PID`);
348
+ }
349
+
350
+ this.handles.set(newAgent.id, { proc, logStream });
351
+ registry.update(newAgent.id, { status: 'running', pid: proc.pid });
352
+
353
+ // Same stdout/stderr/exit handling as spawn
354
+ proc.stdout.on('data', (chunk) => {
355
+ logStream.write(chunk);
356
+ const output = provider.parseOutput(chunk.toString());
357
+ if (output) {
358
+ this.daemon.classifier.addEvent(newAgent.id, output);
359
+ const updates = { lastActivity: new Date().toISOString() };
360
+ if (output.sessionId) updates.sessionId = output.sessionId;
361
+ if (output.tokensUsed !== undefined && output.tokensUsed > 0) {
362
+ const current = registry.get(newAgent.id);
363
+ if (current) {
364
+ updates.tokensUsed = current.tokensUsed + output.tokensUsed;
365
+ this.daemon.tokens.record(newAgent.id, output.tokensUsed);
366
+ }
367
+ }
368
+ if (output.contextUsage !== undefined) updates.contextUsage = output.contextUsage;
369
+ registry.update(newAgent.id, updates);
370
+ this.daemon.broadcast({ type: 'agent:output', agentId: newAgent.id, data: output });
371
+ }
372
+ });
373
+
374
+ proc.stderr.on('data', (chunk) => { logStream.write(`[stderr] ${chunk}`); });
375
+
376
+ proc.on('exit', (code, signal) => {
377
+ logStream.write(`[${new Date().toISOString()}] Process exited: code=${code} signal=${signal}\n`);
378
+ logStream.end();
379
+ this.handles.delete(newAgent.id);
380
+ const finalStatus = signal === 'SIGTERM' || signal === 'SIGKILL' ? 'killed' : code === 0 ? 'completed' : 'crashed';
381
+ registry.update(newAgent.id, { status: finalStatus, pid: null });
382
+ this.daemon.broadcast({ type: 'agent:exit', agentId: newAgent.id, code, signal, status: finalStatus });
383
+ if (finalStatus === 'completed' && this.daemon.journalist) {
384
+ this.daemon.journalist.cycle().catch(() => {});
385
+ }
386
+ });
387
+
388
+ proc.on('error', (err) => {
389
+ logStream.write(`[error] ${err.message}\n`);
390
+ logStream.end();
391
+ this.handles.delete(newAgent.id);
392
+ registry.update(newAgent.id, { status: 'crashed', pid: null });
393
+ });
394
+
395
+ return newAgent;
396
+ }
397
+
265
398
  async kill(agentId) {
266
399
  const handle = this.handles.get(agentId);
267
400
 
@@ -61,6 +61,15 @@ export class ClaudeCodeProvider extends Provider {
61
61
  };
62
62
  }
63
63
 
64
+ buildResumeCommand(sessionId, prompt, model) {
65
+ // Resume a previous session — preserves full conversation history
66
+ // No cold start, no handoff brief needed
67
+ const args = ['--resume', sessionId, '--output-format', 'stream-json', '--verbose', '--dangerously-skip-permissions'];
68
+ if (model) args.push('--model', model);
69
+ if (prompt) args.push(prompt);
70
+ return { command: 'claude', args, env: {} };
71
+ }
72
+
64
73
  buildHeadlessCommand(prompt, model) {
65
74
  const args = ['-p', prompt, '--output-format', 'stream-json', '--verbose'];
66
75
  if (model) args.push('--model', model);
@@ -109,6 +118,11 @@ export class ClaudeCodeProvider extends Provider {
109
118
  try {
110
119
  const data = JSON.parse(l);
111
120
 
121
+ // Capture session_id for --resume support
122
+ if (data.session_id) {
123
+ events.push({ type: 'session', sessionId: data.session_id });
124
+ }
125
+
112
126
  if (data.type === 'assistant') {
113
127
  events.push({
114
128
  type: 'activity',
@@ -48,7 +48,7 @@ export class Registry extends EventEmitter {
48
48
  if (!agent) return null;
49
49
 
50
50
  // Only allow known fields to prevent prototype pollution
51
- const SAFE_FIELDS = ['status', 'pid', 'tokensUsed', 'contextUsage', 'lastActivity', 'model', 'name', 'routingMode', 'routingReason'];
51
+ const SAFE_FIELDS = ['status', 'pid', 'tokensUsed', 'contextUsage', 'lastActivity', 'model', 'name', 'routingMode', 'routingReason', 'sessionId'];
52
52
  for (const key of Object.keys(updates)) {
53
53
  if (SAFE_FIELDS.includes(key)) {
54
54
  agent[key] = updates[key];
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@groove-dev/gui",
3
- "version": "0.9.2",
3
+ "version": "0.10.0",
4
4
  "description": "GROOVE GUI — visual agent control plane",
5
5
  "license": "FSL-1.1-Apache-2.0",
6
6
  "type": "module",