groove-dev 0.27.213 → 0.27.215
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/CLAUDE.md +2 -0
- package/node_modules/@groove-dev/cli/bin/groove.js +8 -0
- package/node_modules/@groove-dev/cli/package.json +1 -1
- package/node_modules/@groove-dev/cli/src/commands/handoff.js +22 -0
- package/node_modules/@groove-dev/daemon/package.json +1 -1
- package/node_modules/@groove-dev/daemon/src/index.js +18 -1
- package/node_modules/@groove-dev/daemon/src/journalist.js +28 -0
- package/node_modules/@groove-dev/daemon/src/rotator.js +179 -0
- package/node_modules/@groove-dev/daemon/src/routes/agents.js +25 -0
- package/node_modules/@groove-dev/daemon/src/tunnel-manager.js +217 -30
- package/node_modules/@groove-dev/daemon/test/rotator.test.js +86 -0
- package/node_modules/@groove-dev/daemon/test/tunnel-manager.test.js +143 -2
- package/node_modules/@groove-dev/gui/dist/assets/{index-DPsim83z.css → index-B82kMi13.css} +1 -1
- package/node_modules/@groove-dev/gui/dist/assets/{index-BP4oE2UL.js → index-CLfmxBx1.js} +1 -1
- package/node_modules/@groove-dev/gui/dist/index.html +2 -2
- package/node_modules/@groove-dev/gui/package.json +1 -1
- package/package.json +1 -1
- package/packages/cli/bin/groove.js +8 -0
- package/packages/cli/package.json +1 -1
- package/packages/cli/src/commands/handoff.js +22 -0
- package/packages/daemon/package.json +1 -1
- package/packages/daemon/src/index.js +18 -1
- package/packages/daemon/src/journalist.js +28 -0
- package/packages/daemon/src/rotator.js +179 -0
- package/packages/daemon/src/routes/agents.js +25 -0
- package/packages/daemon/src/tunnel-manager.js +217 -30
- package/packages/gui/dist/assets/{index-DPsim83z.css → index-B82kMi13.css} +1 -1
- package/packages/gui/dist/assets/{index-BP4oE2UL.js → index-CLfmxBx1.js} +1 -1
- package/packages/gui/dist/index.html +2 -2
- package/packages/gui/package.json +1 -1
package/CLAUDE.md
CHANGED
|
@@ -134,6 +134,7 @@ groove agents — list agents
|
|
|
134
134
|
groove status — daemon status
|
|
135
135
|
groove nuke — kill all + stop
|
|
136
136
|
groove rotate <id> — context rotation (kill + respawn with handoff brief)
|
|
137
|
+
groove handoff <id> — succession: fresh agent interviews this one via InnerChat, then takes over
|
|
137
138
|
groove team create <name> — create a new team
|
|
138
139
|
groove team rename <id> — rename a team
|
|
139
140
|
groove team list — list teams
|
|
@@ -167,6 +168,7 @@ All endpoints on `http://localhost:31415/api/`. CORS restricted to localhost. 50
|
|
|
167
168
|
| Health | GET | /api/health, /api/status | Health check, daemon status |
|
|
168
169
|
| Agents | GET/POST/DELETE | /api/agents, /api/agents/:id | Agent CRUD, detail, update |
|
|
169
170
|
| Agent Actions | POST | /api/agents/:id/rotate, instruct, query | Rotation, instructions, queries |
|
|
171
|
+
| Succession | POST | /api/agents/:id/handoff, /api/handoff/:id/complete | Spawn successor + interview, retire predecessor |
|
|
170
172
|
| Agent Routing | GET | /api/agents/:id/routing/recommend | Model routing recommendations |
|
|
171
173
|
| Teams | GET/POST | /api/teams | List, create teams |
|
|
172
174
|
| Teams | PATCH/DELETE | /api/teams/:id | Rename, delete teams |
|
|
@@ -12,6 +12,7 @@ import { agents } from '../src/commands/agents.js';
|
|
|
12
12
|
import { status } from '../src/commands/status.js';
|
|
13
13
|
import { nuke } from '../src/commands/nuke.js';
|
|
14
14
|
import { rotate } from '../src/commands/rotate.js';
|
|
15
|
+
import { handoff } from '../src/commands/handoff.js';
|
|
15
16
|
import { teamCreate, teamSave, teamLoad, teamList, teamDelete, teamRename, teamExport, teamImport } from '../src/commands/team.js';
|
|
16
17
|
import { approvals, approve, reject } from '../src/commands/approve.js';
|
|
17
18
|
import { providers, setKey } from '../src/commands/providers.js';
|
|
@@ -99,6 +100,13 @@ program
|
|
|
99
100
|
.description('Rotate an agent (kill + respawn with fresh context)')
|
|
100
101
|
.action(rotate);
|
|
101
102
|
|
|
103
|
+
program
|
|
104
|
+
.command('handoff <id>')
|
|
105
|
+
.description('Succession: spawn a fresh agent that interviews this one, then takes over')
|
|
106
|
+
.option('--name <name>', 'successor name (default: <agent>-successor)')
|
|
107
|
+
.option('--no-keep-name', 'successor keeps its own name instead of inheriting')
|
|
108
|
+
.action(handoff);
|
|
109
|
+
|
|
102
110
|
// Teams
|
|
103
111
|
const team = program.command('team').description('Manage agent teams');
|
|
104
112
|
team.command('create <name>').description('Create a new team').action(teamCreate);
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
// GROOVE CLI — handoff command (succession to a fresh agent)
|
|
2
|
+
// FSL-1.1-Apache-2.0 — see LICENSE
|
|
3
|
+
|
|
4
|
+
import chalk from 'chalk';
|
|
5
|
+
import { apiCall } from '../client.js';
|
|
6
|
+
|
|
7
|
+
export async function handoff(id, options = {}) {
|
|
8
|
+
try {
|
|
9
|
+
console.log(chalk.yellow(` Preparing succession dossier for ${id}...`));
|
|
10
|
+
const record = await apiCall('POST', `/api/agents/${id}/handoff`, {
|
|
11
|
+
name: options.name,
|
|
12
|
+
inheritName: options.keepName !== false,
|
|
13
|
+
});
|
|
14
|
+
console.log(chalk.green(` Succession started.`));
|
|
15
|
+
console.log(` ${chalk.bold(record.successorName)} is interviewing ${chalk.bold(record.predecessorName)} over InnerChat.`);
|
|
16
|
+
console.log(` When done it will retire the predecessor${record.inheritName ? ` and take over the name "${record.predecessorName}"` : ''}.`);
|
|
17
|
+
console.log(chalk.dim(` Handoff id: ${record.id}`));
|
|
18
|
+
} catch (err) {
|
|
19
|
+
console.error(chalk.red(' Handoff failed:'), err.message);
|
|
20
|
+
process.exit(1);
|
|
21
|
+
}
|
|
22
|
+
}
|
|
@@ -4,7 +4,8 @@
|
|
|
4
4
|
import { createServer as createHttpServer, request as httpProxyRequest } from 'http';
|
|
5
5
|
import { createServer as createNetServer } from 'net';
|
|
6
6
|
import { execFileSync } from 'child_process';
|
|
7
|
-
import { resolve } from 'path';
|
|
7
|
+
import { resolve, dirname, basename } from 'path';
|
|
8
|
+
import { homedir } from 'os';
|
|
8
9
|
import { readFileSync, writeFileSync, unlinkSync, existsSync, mkdirSync, readdirSync, rmdirSync, rmSync, statSync } from 'fs';
|
|
9
10
|
import express from 'express';
|
|
10
11
|
import { WebSocketServer } from 'ws';
|
|
@@ -630,6 +631,22 @@ export class Daemon {
|
|
|
630
631
|
// Read back actual port (critical for port 0 / dynamic allocation)
|
|
631
632
|
this.port = this.server.address().port;
|
|
632
633
|
writeFileSync(this.pidFile, String(process.pid));
|
|
634
|
+
|
|
635
|
+
// Anchor for restart tooling. grooveDir — the daemon's entire world of
|
|
636
|
+
// teams/agents/state — is derived from the cwd `groove start` ran in.
|
|
637
|
+
// Remote restart paths (tunnel autoStart, upgrades, the desktop shell)
|
|
638
|
+
// each guess a cwd; a wrong guess boots a fresh empty .groove that
|
|
639
|
+
// looks exactly like total data loss. Record where this world lives at
|
|
640
|
+
// a FIXED path so every restarter can re-enter it instead of guessing.
|
|
641
|
+
// Staging/override daemons (custom GROOVE_DIR) skip this — they must
|
|
642
|
+
// not hijack the anchor of the real daemon.
|
|
643
|
+
try {
|
|
644
|
+
if (basename(this.grooveDir) === '.groove') {
|
|
645
|
+
const anchorHome = resolve(homedir(), '.groove');
|
|
646
|
+
mkdirSync(anchorHome, { recursive: true });
|
|
647
|
+
writeFileSync(resolve(anchorHome, 'last-run-dir'), dirname(this.grooveDir));
|
|
648
|
+
}
|
|
649
|
+
} catch { /* non-fatal */ }
|
|
633
650
|
// Write actual port and host so CLI can find us
|
|
634
651
|
writeFileSync(resolve(this.grooveDir, 'daemon.port'), String(this.port));
|
|
635
652
|
writeFileSync(resolve(this.grooveDir, 'daemon.host'), this.host);
|
|
@@ -1057,6 +1057,34 @@ export class Journalist {
|
|
|
1057
1057
|
return brief;
|
|
1058
1058
|
}
|
|
1059
1059
|
|
|
1060
|
+
/**
|
|
1061
|
+
* Succession dossier — the deep version of the handoff brief, for handing a
|
|
1062
|
+
* long-lived agent's role to a NEW agent. The regular brief is recency-biased
|
|
1063
|
+
* by design (last 3 chain entries, current session); after weeks of work the
|
|
1064
|
+
* older accumulated knowledge matters just as much. The dossier adds the full
|
|
1065
|
+
* handoff chain and the project decision log, and its reader is expected to
|
|
1066
|
+
* fill remaining gaps by interviewing the predecessor directly.
|
|
1067
|
+
*/
|
|
1068
|
+
async generateSuccessionDossier(agent) {
|
|
1069
|
+
const brief = await this.generateHandoffBrief(agent, { reason: 'succession' });
|
|
1070
|
+
|
|
1071
|
+
// The whole chain (up to the retained 10 generations), not the last 3.
|
|
1072
|
+
const fullChain = this.daemon.memory?.getRecentHandoffMarkdown(
|
|
1073
|
+
agent.role, 10, 12000, agent.workingDir, agent.teamId,
|
|
1074
|
+
) || '';
|
|
1075
|
+
|
|
1076
|
+
let decisions = '';
|
|
1077
|
+
try {
|
|
1078
|
+
const p = resolve(this.daemon.projectDir, 'GROOVE_DECISIONS.md');
|
|
1079
|
+
if (existsSync(p)) decisions = readFileSync(p, 'utf8').slice(0, 8000);
|
|
1080
|
+
} catch { /* optional */ }
|
|
1081
|
+
|
|
1082
|
+
const parts = [brief];
|
|
1083
|
+
if (fullChain) parts.push(`## Full Rotation History (oldest knowledge — read it, it is why things are the way they are)\n\n${fullChain}`);
|
|
1084
|
+
if (decisions) parts.push(`## Project Decision Log\n\n${decisions}`);
|
|
1085
|
+
return parts.join('\n\n');
|
|
1086
|
+
}
|
|
1087
|
+
|
|
1060
1088
|
// --- Conversation Thread Extraction (for idle resume) ---
|
|
1061
1089
|
|
|
1062
1090
|
/**
|
|
@@ -4,7 +4,9 @@
|
|
|
4
4
|
import { EventEmitter } from 'events';
|
|
5
5
|
import { readFileSync, writeFileSync, existsSync } from 'fs';
|
|
6
6
|
import { resolve } from 'path';
|
|
7
|
+
import { randomUUID } from 'crypto';
|
|
7
8
|
import { getProvider } from './providers/index.js';
|
|
9
|
+
import { deliverInstruction } from './deliver.js';
|
|
8
10
|
|
|
9
11
|
const DEFAULT_THRESHOLD = 0.65; // For non-self-managing providers (was 0.75)
|
|
10
12
|
const HARD_CEILING = 0.80; // Force rotate (was 0.85) — only for non-self-managing
|
|
@@ -40,6 +42,7 @@ export class Rotator extends EventEmitter {
|
|
|
40
42
|
this.interval = null;
|
|
41
43
|
this.rotationHistory = [];
|
|
42
44
|
this.rotating = new Set();
|
|
45
|
+
this.handoffs = new Map(); // handoffId -> { predecessorId, successorId, status, ... }
|
|
43
46
|
this.lastRotationTime = new Map(); // agentId -> timestamp of last rotation
|
|
44
47
|
this._lastContextState = new Map(); // agentId -> { contextUsage, timestamp }
|
|
45
48
|
this.compactionCounts = new Map(); // agentId -> number of natural compactions
|
|
@@ -638,6 +641,182 @@ export class Rotator extends EventEmitter {
|
|
|
638
641
|
}
|
|
639
642
|
}
|
|
640
643
|
|
|
644
|
+
// ── Succession handoff ─────────────────────────────────────────
|
|
645
|
+
//
|
|
646
|
+
// Rotation replaces an agent with a same-name clone and is recency-biased —
|
|
647
|
+
// right for context pressure, wrong for retiring a long-lived agent. A
|
|
648
|
+
// succession spawns the successor ALONGSIDE the still-running predecessor,
|
|
649
|
+
// seeded with a deep dossier, and mandates an InnerChat interview before the
|
|
650
|
+
// predecessor is retired. The interview is the point: after weeks of work
|
|
651
|
+
// the highest-bandwidth transfer is questions answered by the agent that
|
|
652
|
+
// still remembers, not any summary.
|
|
653
|
+
|
|
654
|
+
async successionHandoff(agentId, options = {}) {
|
|
655
|
+
const registry = this.daemon.registry;
|
|
656
|
+
const agent = registry.get(agentId);
|
|
657
|
+
if (!agent) throw new Error('Agent not found');
|
|
658
|
+
if (this.rotating.has(agentId)) throw new Error('Agent is mid-rotation — try again shortly');
|
|
659
|
+
for (const h of this.handoffs.values()) {
|
|
660
|
+
if (h.status === 'interviewing' && (h.predecessorId === agentId || h.successorId === agentId)) {
|
|
661
|
+
throw new Error('A handoff is already in progress for this agent');
|
|
662
|
+
}
|
|
663
|
+
}
|
|
664
|
+
|
|
665
|
+
const handoffId = randomUUID().slice(0, 12);
|
|
666
|
+
const dossier = await this.daemon.journalist.generateSuccessionDossier(agent);
|
|
667
|
+
|
|
668
|
+
// Persist to the chain up front — even a failed handoff leaves the record.
|
|
669
|
+
if (this.daemon.memory) {
|
|
670
|
+
this.daemon.memory.appendHandoffBrief(agent.role, {
|
|
671
|
+
timestamp: new Date().toISOString(),
|
|
672
|
+
agentId: agent.id,
|
|
673
|
+
newAgentId: null,
|
|
674
|
+
reason: 'succession',
|
|
675
|
+
oldTokens: agent.tokensUsed,
|
|
676
|
+
contextUsage: agent.contextUsage,
|
|
677
|
+
brief: dossier.slice(0, 6000),
|
|
678
|
+
}, agent.workingDir, agent.teamId);
|
|
679
|
+
}
|
|
680
|
+
|
|
681
|
+
const inheritName = options.inheritName !== false;
|
|
682
|
+
const successorName = (options.name && String(options.name).trim())
|
|
683
|
+
|| `${agent.name}-successor`;
|
|
684
|
+
const port = this.daemon.port || 31415;
|
|
685
|
+
|
|
686
|
+
const prompt = [
|
|
687
|
+
`# SUCCESSION — you are taking over from ${agent.name}`,
|
|
688
|
+
``,
|
|
689
|
+
`${agent.name} has been working this role for a long time and is being retired`,
|
|
690
|
+
`for context degradation. You are its successor. Its accumulated knowledge is`,
|
|
691
|
+
`below; its live memory is still available for a short window — use it.`,
|
|
692
|
+
``,
|
|
693
|
+
dossier,
|
|
694
|
+
``,
|
|
695
|
+
`## Step 1 — Interview your predecessor (do this FIRST)`,
|
|
696
|
+
``,
|
|
697
|
+
`${agent.name} is still running and has been told to expect your questions.`,
|
|
698
|
+
`Ask via InnerChat (blocking; run in the foreground):`,
|
|
699
|
+
``,
|
|
700
|
+
'```bash',
|
|
701
|
+
`curl -s http://localhost:${port}/api/innerchat/ask -X POST -H 'Content-Type: application/json' \\`,
|
|
702
|
+
` -d '{"from":"${successorName}","to":"${agent.name}","message":"YOUR_QUESTION"}'`,
|
|
703
|
+
'```',
|
|
704
|
+
``,
|
|
705
|
+
`Ask about: work currently in flight and exactly where it stands; fragile or`,
|
|
706
|
+
`dangerous areas; unwritten conventions the dossier missed; what it planned to`,
|
|
707
|
+
`do next and why. Several focused rounds beat one broad one. The exchange`,
|
|
708
|
+
`budget is shared — stop when answers stop teaching you.`,
|
|
709
|
+
``,
|
|
710
|
+
`## Step 2 — Declare takeover`,
|
|
711
|
+
``,
|
|
712
|
+
`When you have what you need (interview done or predecessor unresponsive):`,
|
|
713
|
+
``,
|
|
714
|
+
'```bash',
|
|
715
|
+
`curl -s -X POST http://localhost:${port}/api/handoff/${handoffId}/complete`,
|
|
716
|
+
'```',
|
|
717
|
+
``,
|
|
718
|
+
`This retires ${agent.name}${inheritName ? ` and renames you to "${agent.name}"` : ''}. Then continue the work — you own it now.`,
|
|
719
|
+
`Do NOT start new work before completing both steps.`,
|
|
720
|
+
].join('\n');
|
|
721
|
+
|
|
722
|
+
const successor = await this.daemon.processes.spawn({
|
|
723
|
+
role: agent.role,
|
|
724
|
+
scope: agent.scope,
|
|
725
|
+
provider: options.provider || agent.provider,
|
|
726
|
+
model: options.model || agent.model,
|
|
727
|
+
prompt,
|
|
728
|
+
permission: agent.permission || 'full',
|
|
729
|
+
workingDir: agent.workingDir,
|
|
730
|
+
name: successorName,
|
|
731
|
+
teamId: agent.teamId,
|
|
732
|
+
});
|
|
733
|
+
|
|
734
|
+
// Survives a daemon restart: /complete can reconstruct from this metadata.
|
|
735
|
+
registry.update(successor.id, {
|
|
736
|
+
metadata: {
|
|
737
|
+
...(successor.metadata || {}),
|
|
738
|
+
handoff: { handoffId, predecessorId: agent.id, predecessorName: agent.name, inheritName },
|
|
739
|
+
},
|
|
740
|
+
});
|
|
741
|
+
|
|
742
|
+
const record = {
|
|
743
|
+
id: handoffId,
|
|
744
|
+
predecessorId: agent.id,
|
|
745
|
+
predecessorName: agent.name,
|
|
746
|
+
successorId: successor.id,
|
|
747
|
+
successorName: successor.name || successorName,
|
|
748
|
+
inheritName,
|
|
749
|
+
status: 'interviewing',
|
|
750
|
+
startedAt: new Date().toISOString(),
|
|
751
|
+
};
|
|
752
|
+
this.handoffs.set(handoffId, record);
|
|
753
|
+
|
|
754
|
+
// Heads-up to the predecessor — best effort; InnerChat will wake it anyway.
|
|
755
|
+
deliverInstruction(this.daemon, agent.id,
|
|
756
|
+
`[Succession] ${record.successorName} is taking over your role. It will interview you `
|
|
757
|
+
+ `over InnerChat shortly. Answer its questions completely and concretely — in-flight work, `
|
|
758
|
+
+ `fragile areas, unwritten conventions, planned next steps. Do NOT start new work. `
|
|
759
|
+
+ `Anything only you know must get said now; after the interview you will be retired.`,
|
|
760
|
+
{ recordFeedback: false },
|
|
761
|
+
).catch((err) => console.warn(` Rotator: could not brief predecessor ${agent.name}: ${err.message}`));
|
|
762
|
+
|
|
763
|
+
this.daemon.audit?.log('agent.handoff.start', { handoffId, predecessor: agent.id, successor: successor.id });
|
|
764
|
+
this.daemon.broadcast({ type: 'handoff:started', data: record });
|
|
765
|
+
if (this.daemon.timeline) {
|
|
766
|
+
this.daemon.timeline.recordEvent('handoff', {
|
|
767
|
+
agentId: successor.id, oldAgentId: agent.id,
|
|
768
|
+
agentName: record.successorName, role: agent.role, reason: 'succession',
|
|
769
|
+
});
|
|
770
|
+
}
|
|
771
|
+
return record;
|
|
772
|
+
}
|
|
773
|
+
|
|
774
|
+
async completeHandoff(handoffId) {
|
|
775
|
+
const registry = this.daemon.registry;
|
|
776
|
+
let record = this.handoffs.get(handoffId);
|
|
777
|
+
if (!record) {
|
|
778
|
+
// Daemon restarted mid-handoff — reconstruct from successor metadata.
|
|
779
|
+
const successor = registry.getAll().find((a) => a.metadata?.handoff?.handoffId === handoffId);
|
|
780
|
+
if (!successor) throw new Error('Handoff not found');
|
|
781
|
+
const h = successor.metadata.handoff;
|
|
782
|
+
record = {
|
|
783
|
+
id: handoffId, predecessorId: h.predecessorId, predecessorName: h.predecessorName,
|
|
784
|
+
successorId: successor.id, successorName: successor.name,
|
|
785
|
+
inheritName: h.inheritName, status: 'interviewing', startedAt: null,
|
|
786
|
+
};
|
|
787
|
+
}
|
|
788
|
+
if (record.status === 'complete') return record;
|
|
789
|
+
|
|
790
|
+
const successor = registry.get(record.successorId);
|
|
791
|
+
if (!successor) throw new Error('Successor no longer exists — cannot complete handoff');
|
|
792
|
+
|
|
793
|
+
const predecessor = registry.get(record.predecessorId);
|
|
794
|
+
if (predecessor) {
|
|
795
|
+
try { await this.daemon.processes.kill(predecessor.id); } catch { /* already dead */ }
|
|
796
|
+
registry.remove(predecessor.id);
|
|
797
|
+
this.daemon.locks?.release(predecessor.id);
|
|
798
|
+
}
|
|
799
|
+
|
|
800
|
+
if (record.inheritName && predecessor) {
|
|
801
|
+
registry.update(successor.id, { name: record.predecessorName });
|
|
802
|
+
record.successorName = record.predecessorName;
|
|
803
|
+
}
|
|
804
|
+
// Clear the marker — the successor is now just a normal agent.
|
|
805
|
+
const meta = { ...(successor.metadata || {}) };
|
|
806
|
+
delete meta.handoff;
|
|
807
|
+
registry.update(successor.id, { metadata: meta });
|
|
808
|
+
|
|
809
|
+
record.status = 'complete';
|
|
810
|
+
record.completedAt = new Date().toISOString();
|
|
811
|
+
this.handoffs.set(handoffId, record);
|
|
812
|
+
|
|
813
|
+
this.daemon.audit?.log('agent.handoff.complete', {
|
|
814
|
+
handoffId, predecessor: record.predecessorId, successor: record.successorId,
|
|
815
|
+
});
|
|
816
|
+
this.daemon.broadcast({ type: 'handoff:completed', data: record });
|
|
817
|
+
return record;
|
|
818
|
+
}
|
|
819
|
+
|
|
641
820
|
_schedulePostRotationCheck(newAgentId, oldQualityScore, record) {
|
|
642
821
|
// Wait for the new agent to accumulate MIN_EVENTS classifier events,
|
|
643
822
|
// checking every 15s for up to 5 minutes (20 checks)
|
|
@@ -246,6 +246,31 @@ export function registerAgentRoutes(app, daemon) {
|
|
|
246
246
|
}
|
|
247
247
|
});
|
|
248
248
|
|
|
249
|
+
// Succession handoff — spawn a fresh successor alongside a degrading agent,
|
|
250
|
+
// seeded with a deep dossier; it interviews the predecessor over InnerChat,
|
|
251
|
+
// then calls /api/handoff/:id/complete to retire it and take over.
|
|
252
|
+
app.post('/api/agents/:id/handoff', async (req, res) => {
|
|
253
|
+
try {
|
|
254
|
+
const { name, inheritName, model, provider } = req.body || {};
|
|
255
|
+
const record = await daemon.rotator.successionHandoff(req.params.id, {
|
|
256
|
+
name, inheritName, model, provider,
|
|
257
|
+
});
|
|
258
|
+
res.json(record);
|
|
259
|
+
} catch (err) {
|
|
260
|
+
res.status(400).json({ error: err.message });
|
|
261
|
+
}
|
|
262
|
+
});
|
|
263
|
+
|
|
264
|
+
// Called by the successor itself when it has finished interviewing.
|
|
265
|
+
app.post('/api/handoff/:handoffId/complete', async (req, res) => {
|
|
266
|
+
try {
|
|
267
|
+
const record = await daemon.rotator.completeHandoff(req.params.handoffId);
|
|
268
|
+
res.json(record);
|
|
269
|
+
} catch (err) {
|
|
270
|
+
res.status(400).json({ error: err.message });
|
|
271
|
+
}
|
|
272
|
+
});
|
|
273
|
+
|
|
249
274
|
// Instruct an agent — send message to agent loop, resume session, or rotate
|
|
250
275
|
// Agent loop = direct message to running loop (local models)
|
|
251
276
|
// Resume = zero cold-start (uses --resume SESSION_ID)
|