groove-dev 0.25.21 → 0.26.2

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 (45) hide show
  1. package/node_modules/@groove-dev/daemon/src/agent-loop.js +444 -0
  2. package/node_modules/@groove-dev/daemon/src/api.js +104 -5
  3. package/node_modules/@groove-dev/daemon/src/index.js +6 -1
  4. package/node_modules/@groove-dev/daemon/src/llama-server.js +268 -0
  5. package/node_modules/@groove-dev/daemon/src/model-manager.js +411 -0
  6. package/node_modules/@groove-dev/daemon/src/process.js +160 -9
  7. package/node_modules/@groove-dev/daemon/src/providers/codex.js +51 -1
  8. package/node_modules/@groove-dev/daemon/src/providers/gemini.js +3 -2
  9. package/node_modules/@groove-dev/daemon/src/providers/index.js +4 -0
  10. package/node_modules/@groove-dev/daemon/src/providers/local.js +183 -0
  11. package/node_modules/@groove-dev/daemon/src/registry.js +1 -1
  12. package/node_modules/@groove-dev/daemon/src/tool-executor.js +367 -0
  13. package/node_modules/@groove-dev/gui/dist/assets/index-BC2Bhfv0.js +633 -0
  14. package/node_modules/@groove-dev/gui/dist/assets/index-BQnZrh4f.css +1 -0
  15. package/node_modules/@groove-dev/gui/dist/index.html +2 -2
  16. package/node_modules/@groove-dev/gui/src/app.jsx +2 -0
  17. package/node_modules/@groove-dev/gui/src/components/agents/agent-config.jsx +7 -2
  18. package/node_modules/@groove-dev/gui/src/components/layout/activity-bar.jsx +2 -1
  19. package/node_modules/@groove-dev/gui/src/stores/groove.js +7 -1
  20. package/node_modules/@groove-dev/gui/src/views/models.jsx +380 -0
  21. package/package.json +2 -2
  22. package/packages/daemon/src/agent-loop.js +444 -0
  23. package/packages/daemon/src/api.js +104 -5
  24. package/packages/daemon/src/index.js +6 -1
  25. package/packages/daemon/src/llama-server.js +268 -0
  26. package/packages/daemon/src/model-manager.js +411 -0
  27. package/packages/daemon/src/process.js +160 -9
  28. package/packages/daemon/src/providers/codex.js +51 -1
  29. package/packages/daemon/src/providers/gemini.js +3 -2
  30. package/packages/daemon/src/providers/index.js +4 -0
  31. package/packages/daemon/src/providers/local.js +183 -0
  32. package/packages/daemon/src/registry.js +1 -1
  33. package/packages/daemon/src/tool-executor.js +367 -0
  34. package/packages/gui/dist/assets/index-BC2Bhfv0.js +633 -0
  35. package/packages/gui/dist/assets/index-BQnZrh4f.css +1 -0
  36. package/packages/gui/dist/index.html +2 -2
  37. package/packages/gui/src/app.jsx +2 -0
  38. package/packages/gui/src/components/agents/agent-config.jsx +7 -2
  39. package/packages/gui/src/components/layout/activity-bar.jsx +2 -1
  40. package/packages/gui/src/stores/groove.js +7 -1
  41. package/packages/gui/src/views/models.jsx +380 -0
  42. package/node_modules/@groove-dev/gui/dist/assets/index-B1FkEzF0.js +0 -623
  43. package/node_modules/@groove-dev/gui/dist/assets/index-GYcMwmjs.css +0 -1
  44. package/packages/gui/dist/assets/index-B1FkEzF0.js +0 -623
  45. package/packages/gui/dist/assets/index-GYcMwmjs.css +0 -1
@@ -0,0 +1,444 @@
1
+ // GROOVE — Agent Loop Engine (Local Model Runtime)
2
+ // FSL-1.1-Apache-2.0 — see LICENSE
3
+ //
4
+ // Core agentic runtime for local models. Manages a multi-turn conversation
5
+ // with tool calling against any OpenAI-compatible API. Plugs into all
6
+ // existing GROOVE orchestration (rotation, journalist, token tracking, routing).
7
+
8
+ import { EventEmitter } from 'events';
9
+ import { TOOL_DEFINITIONS, ToolExecutor } from './tool-executor.js';
10
+
11
+ export class AgentLoop extends EventEmitter {
12
+ constructor({ daemon, agent, loopConfig, logStream }) {
13
+ super();
14
+ this.daemon = daemon;
15
+ this.agent = agent;
16
+ this.config = loopConfig;
17
+ this.logStream = logStream;
18
+
19
+ // Conversation state
20
+ this.messages = [];
21
+ this.running = false;
22
+ this.idle = true;
23
+ this.abortController = null;
24
+
25
+ // Metrics
26
+ this.totalTokensIn = 0;
27
+ this.totalTokensOut = 0;
28
+ this.turns = 0;
29
+ this.toolCallCount = 0;
30
+ this.startedAt = Date.now();
31
+
32
+ // Tool executor — sandboxed to agent's working directory
33
+ this.executor = new ToolExecutor(
34
+ agent.workingDir || daemon.projectDir,
35
+ daemon,
36
+ agent.id,
37
+ );
38
+
39
+ // Initialize system prompt
40
+ this.messages.push({
41
+ role: 'system',
42
+ content: this._buildSystemPrompt(),
43
+ });
44
+ }
45
+
46
+ // --- Lifecycle ---
47
+
48
+ async start(initialPrompt) {
49
+ this.running = true;
50
+ this._writeLog({ type: 'system', event: 'start', model: this.config.model });
51
+
52
+ if (initialPrompt) {
53
+ await this.sendMessage(initialPrompt);
54
+ }
55
+ }
56
+
57
+ async sendMessage(content) {
58
+ if (!this.running) return;
59
+
60
+ this.idle = false;
61
+ this.messages.push({ role: 'user', content });
62
+ this._writeLog({ type: 'user', content: content.slice(0, 1000) });
63
+
64
+ try {
65
+ await this._runLoop();
66
+ } catch (err) {
67
+ this._writeLog({ type: 'error', text: err.message });
68
+ this.emit('error', { message: err.message });
69
+ }
70
+
71
+ this.idle = true;
72
+ }
73
+
74
+ async stop() {
75
+ this.running = false;
76
+ if (this.abortController) {
77
+ this.abortController.abort();
78
+ }
79
+
80
+ const duration = Date.now() - this.startedAt;
81
+ this._writeLog({
82
+ type: 'result',
83
+ result: 'Agent stopped',
84
+ tokensUsed: this.totalTokensIn + this.totalTokensOut,
85
+ duration,
86
+ turns: this.turns,
87
+ });
88
+
89
+ // Record final session metrics
90
+ this.daemon.tokens.recordResult(this.agent.id, {
91
+ durationMs: duration,
92
+ turns: this.turns,
93
+ });
94
+
95
+ this.emit('exit', { code: 0, signal: 'SIGTERM', status: 'killed' });
96
+ }
97
+
98
+ // --- Core Loop ---
99
+
100
+ async _runLoop() {
101
+ let consecutiveErrors = 0;
102
+
103
+ while (this.running) {
104
+ this.turns++;
105
+
106
+ const response = await this._callApi();
107
+ if (!response || !this.running) break;
108
+
109
+ const { content, toolCalls, usage, finishReason } = response;
110
+ consecutiveErrors = 0; // Reset on successful call
111
+
112
+ // Update token tracking from API response
113
+ if (usage) {
114
+ this._updateTokens(usage);
115
+ }
116
+
117
+ // Append assistant message to conversation history
118
+ const assistantMsg = { role: 'assistant' };
119
+ if (content) assistantMsg.content = content;
120
+ if (toolCalls?.length > 0) {
121
+ assistantMsg.tool_calls = toolCalls.map((tc) => ({
122
+ id: tc.id,
123
+ type: 'function',
124
+ function: { name: tc.function.name, arguments: tc.function.arguments },
125
+ }));
126
+ }
127
+ this.messages.push(assistantMsg);
128
+
129
+ // Broadcast text output to GUI
130
+ if (content) {
131
+ this._writeLog({ type: 'assistant', content: content.slice(0, 2000) });
132
+ this.emit('output', { type: 'activity', subtype: 'text', data: content });
133
+ }
134
+
135
+ // No tool calls → turn complete, go idle
136
+ if (!toolCalls || toolCalls.length === 0) {
137
+ this.emit('output', { type: 'result', data: content || 'Turn complete', turns: this.turns });
138
+ break;
139
+ }
140
+
141
+ // Execute each tool call
142
+ for (const call of toolCalls) {
143
+ if (!this.running) break;
144
+
145
+ let args;
146
+ try {
147
+ args = JSON.parse(call.function.arguments);
148
+ } catch {
149
+ args = {};
150
+ }
151
+
152
+ const toolName = call.function.name;
153
+ const inputSummary = this._summarizeToolInput(toolName, args);
154
+
155
+ // Log + broadcast tool invocation
156
+ this._writeLog({ type: 'tool_use', tool: toolName, input: inputSummary });
157
+ this.emit('output', { type: 'activity', subtype: 'tool_use', data: `${toolName}: ${inputSummary}` });
158
+
159
+ // Feed classifier for adaptive routing
160
+ this.daemon.classifier.addEvent(this.agent.id, {
161
+ type: 'tool', tool: toolName,
162
+ input: args.path || args.command || args.pattern || '',
163
+ });
164
+
165
+ // Execute
166
+ const result = await this.executor.execute(toolName, args);
167
+ this.toolCallCount++;
168
+
169
+ // Log + broadcast result
170
+ const resultPreview = (result.result || result.error || '').slice(0, 500);
171
+ this._writeLog({
172
+ type: 'tool_result', tool: toolName,
173
+ success: result.success, output: resultPreview,
174
+ });
175
+ this.emit('output', {
176
+ type: 'activity', subtype: 'tool_result',
177
+ data: result.success ? `${toolName}: done` : `${toolName}: error — ${result.error}`,
178
+ });
179
+
180
+ if (!result.success) {
181
+ this.daemon.classifier.addEvent(this.agent.id, { type: 'error', text: result.error });
182
+ }
183
+
184
+ // Append tool result to conversation for the model
185
+ this.messages.push({
186
+ role: 'tool',
187
+ tool_call_id: call.id,
188
+ content: result.success ? (result.result || 'Done.') : `Error: ${result.error}`,
189
+ });
190
+ }
191
+
192
+ // Context rotation is handled by the Rotator's 15s polling loop
193
+ // which checks registry.contextUsage against the adaptive threshold.
194
+ // The journalist has full logs — no need for in-loop compaction.
195
+ }
196
+ }
197
+
198
+ // --- API Communication ---
199
+
200
+ async _callApi() {
201
+ this.abortController = new AbortController();
202
+
203
+ const body = {
204
+ model: this.config.model,
205
+ messages: this.messages,
206
+ tools: TOOL_DEFINITIONS,
207
+ tool_choice: 'auto',
208
+ temperature: this.config.temperature ?? 0.1,
209
+ max_tokens: this.config.maxResponseTokens || 4096,
210
+ };
211
+
212
+ // Streaming for real-time output
213
+ if (this.config.stream !== false) {
214
+ body.stream = true;
215
+ body.stream_options = { include_usage: true };
216
+ }
217
+
218
+ const url = `${this.config.apiBase}/chat/completions`;
219
+
220
+ let response;
221
+ try {
222
+ response = await fetch(url, {
223
+ method: 'POST',
224
+ headers: {
225
+ 'Content-Type': 'application/json',
226
+ ...(this.config.apiKey ? { Authorization: `Bearer ${this.config.apiKey}` } : {}),
227
+ ...this.config.headers,
228
+ },
229
+ body: JSON.stringify(body),
230
+ signal: this.abortController.signal,
231
+ });
232
+ } catch (err) {
233
+ if (err.name === 'AbortError') return null;
234
+ this._writeLog({ type: 'error', text: `API request failed: ${err.message}` });
235
+ this.emit('error', { message: `Inference API unreachable: ${err.message}` });
236
+ return null;
237
+ }
238
+
239
+ if (!response.ok) {
240
+ const text = await response.text().catch(() => '');
241
+ const errMsg = `API error ${response.status}: ${text.slice(0, 500)}`;
242
+ this._writeLog({ type: 'error', text: errMsg });
243
+ this.emit('error', { message: errMsg });
244
+ return null;
245
+ }
246
+
247
+ // Parse streaming or non-streaming response
248
+ if (body.stream) {
249
+ return this._parseSSE(response);
250
+ }
251
+ return this._parseJSON(response);
252
+ }
253
+
254
+ async _parseSSE(response) {
255
+ let content = '';
256
+ const toolCalls = new Map(); // index -> { id, function: { name, arguments } }
257
+ let usage = null;
258
+ let finishReason = null;
259
+ let buffer = '';
260
+
261
+ const reader = response.body.getReader();
262
+ const decoder = new TextDecoder();
263
+
264
+ try {
265
+ while (true) {
266
+ const { done, value } = await reader.read();
267
+ if (done) break;
268
+
269
+ buffer += decoder.decode(value, { stream: true });
270
+ const lines = buffer.split('\n');
271
+ buffer = lines.pop() || '';
272
+
273
+ for (const line of lines) {
274
+ const trimmed = line.trim();
275
+ if (!trimmed.startsWith('data: ')) continue;
276
+ const payload = trimmed.slice(6);
277
+ if (payload === '[DONE]') continue;
278
+
279
+ let data;
280
+ try { data = JSON.parse(payload); } catch { continue; }
281
+
282
+ if (data.usage) usage = data.usage;
283
+
284
+ const choice = data.choices?.[0];
285
+ if (!choice) continue;
286
+
287
+ if (choice.finish_reason) finishReason = choice.finish_reason;
288
+ const delta = choice.delta || {};
289
+
290
+ // Stream text tokens to GUI in real-time
291
+ if (delta.content) {
292
+ content += delta.content;
293
+ this.emit('output', { type: 'activity', subtype: 'stream', data: delta.content });
294
+ }
295
+
296
+ // Accumulate tool call deltas
297
+ if (delta.tool_calls) {
298
+ for (const tc of delta.tool_calls) {
299
+ const idx = tc.index ?? 0;
300
+ if (!toolCalls.has(idx)) {
301
+ toolCalls.set(idx, {
302
+ id: tc.id || `call_${idx}_${Date.now()}`,
303
+ function: { name: '', arguments: '' },
304
+ });
305
+ }
306
+ const existing = toolCalls.get(idx);
307
+ if (tc.id) existing.id = tc.id;
308
+ if (tc.function?.name) existing.function.name = tc.function.name;
309
+ if (tc.function?.arguments) existing.function.arguments += tc.function.arguments;
310
+ }
311
+ }
312
+ }
313
+ }
314
+ } catch (err) {
315
+ if (err.name === 'AbortError') return null;
316
+ this._writeLog({ type: 'error', text: `Stream parse error: ${err.message}` });
317
+ this.emit('error', { message: `Stream error: ${err.message}` });
318
+ return null;
319
+ }
320
+
321
+ return {
322
+ content: content || null,
323
+ toolCalls: toolCalls.size > 0 ? Array.from(toolCalls.values()) : null,
324
+ usage,
325
+ finishReason,
326
+ };
327
+ }
328
+
329
+ async _parseJSON(response) {
330
+ const data = await response.json();
331
+ const choice = data.choices?.[0];
332
+ if (!choice) return null;
333
+
334
+ const msg = choice.message || {};
335
+ return {
336
+ content: msg.content || null,
337
+ toolCalls: msg.tool_calls?.map((tc) => ({
338
+ id: tc.id,
339
+ function: { name: tc.function.name, arguments: tc.function.arguments },
340
+ })) || null,
341
+ usage: data.usage || null,
342
+ finishReason: choice.finish_reason,
343
+ };
344
+ }
345
+
346
+ // --- Token Tracking ---
347
+
348
+ _updateTokens(usage) {
349
+ const inputTokens = usage.prompt_tokens || 0;
350
+ const outputTokens = usage.completion_tokens || 0;
351
+ const totalTokens = usage.total_tokens || (inputTokens + outputTokens);
352
+
353
+ this.totalTokensIn += inputTokens;
354
+ this.totalTokensOut += outputTokens;
355
+
356
+ // Context usage = how full the context window is
357
+ const contextWindow = this.config.contextWindow || 32768;
358
+ const contextUsage = contextWindow > 0 ? Math.min(inputTokens / contextWindow, 1) : 0;
359
+
360
+ // Emit token event — ProcessManager handles registry updates + subsystem feeding
361
+ this.emit('output', {
362
+ type: 'activity',
363
+ tokensUsed: totalTokens,
364
+ inputTokens,
365
+ outputTokens,
366
+ model: this.config.model,
367
+ contextUsage,
368
+ });
369
+ }
370
+
371
+ // --- System Prompt ---
372
+
373
+ _buildSystemPrompt() {
374
+ const parts = [];
375
+ const wd = this.agent.workingDir || this.daemon.projectDir;
376
+
377
+ parts.push(`You are a coding agent. Your working directory is: ${wd}`);
378
+ parts.push('');
379
+ parts.push('You have tools for reading, writing, editing, and searching files, and for running shell commands.');
380
+ parts.push('Work methodically: explore the codebase first, understand what exists, then make changes. Test your work when possible.');
381
+ parts.push('');
382
+ parts.push('Guidelines:');
383
+ parts.push('- Read files before editing them');
384
+ parts.push('- Make targeted edits with edit_file rather than rewriting entire files');
385
+ parts.push('- Run tests and builds after changes to verify correctness');
386
+ parts.push('- If a tool call fails, read the error and adjust your approach');
387
+
388
+ if (this.agent.scope?.length > 0) {
389
+ parts.push('');
390
+ parts.push(`File scope: You may only modify files matching these patterns: ${this.agent.scope.join(', ')}`);
391
+ parts.push('You can read any file, but writes outside your scope will be blocked.');
392
+ }
393
+
394
+ // GROOVE intro context — team awareness, coordination, project map
395
+ if (this.config.introContext) {
396
+ parts.push('');
397
+ parts.push(this.config.introContext);
398
+ }
399
+
400
+ return parts.join('\n');
401
+ }
402
+
403
+ // --- Logging (journalist-compatible) ---
404
+
405
+ _writeLog(entry) {
406
+ if (!this.logStream) return;
407
+ const line = JSON.stringify({ ...entry, ts: Date.now() });
408
+ this.logStream.write(line + '\n');
409
+ }
410
+
411
+ _summarizeToolInput(toolName, args) {
412
+ switch (toolName) {
413
+ case 'read_file': {
414
+ let s = args.path || '';
415
+ if (args.offset) s += ` (from line ${args.offset})`;
416
+ if (args.limit) s += ` (${args.limit} lines)`;
417
+ return s;
418
+ }
419
+ case 'write_file': return `${args.path || ''} (${(args.content || '').split('\n').length} lines)`;
420
+ case 'edit_file': return args.path || '';
421
+ case 'run_command': return (args.command || '').slice(0, 120);
422
+ case 'search_files': return args.pattern || '';
423
+ case 'search_content': return `${args.pattern || ''} in ${args.path || '.'}`;
424
+ case 'list_directory': return args.path || '.';
425
+ default: return JSON.stringify(args).slice(0, 100);
426
+ }
427
+ }
428
+
429
+ // --- Status ---
430
+
431
+ getState() {
432
+ return {
433
+ running: this.running,
434
+ idle: this.idle,
435
+ turns: this.turns,
436
+ toolCallCount: this.toolCallCount,
437
+ totalTokensIn: this.totalTokensIn,
438
+ totalTokensOut: this.totalTokensOut,
439
+ messageCount: this.messages.length,
440
+ model: this.config.model,
441
+ uptime: Date.now() - this.startedAt,
442
+ };
443
+ }
444
+ }
@@ -228,17 +228,99 @@ export function createApi(app, daemon) {
228
228
  }
229
229
  });
230
230
 
231
+ // --- Local Models (GGUF via HuggingFace) ---
232
+
233
+ app.get('/api/models/installed', (req, res) => {
234
+ const installed = daemon.modelManager.getInstalled();
235
+ const llamaStatus = daemon.llamaServer.getStatus();
236
+ res.json({ models: installed, llamaServer: llamaStatus });
237
+ });
238
+
239
+ app.get('/api/models/search', async (req, res) => {
240
+ try {
241
+ const query = req.query.q || req.query.query || '';
242
+ if (!query) return res.status(400).json({ error: 'query parameter (q) is required' });
243
+ const results = await daemon.modelManager.search(query, {
244
+ limit: parseInt(req.query.limit) || 20,
245
+ });
246
+ res.json(results);
247
+ } catch (err) {
248
+ res.status(500).json({ error: err.message });
249
+ }
250
+ });
251
+
252
+ app.get('/api/models/:repoId(*)/files', async (req, res) => {
253
+ try {
254
+ const files = await daemon.modelManager.getModelFiles(req.params.repoId);
255
+ res.json(files);
256
+ } catch (err) {
257
+ res.status(500).json({ error: err.message });
258
+ }
259
+ });
260
+
261
+ app.post('/api/models/download', async (req, res) => {
262
+ try {
263
+ const { repoId, filename } = req.body;
264
+ if (!repoId || !filename) return res.status(400).json({ error: 'repoId and filename are required' });
265
+ // Start download in background — progress via WebSocket
266
+ daemon.modelManager.download(repoId, filename).catch(() => {});
267
+ daemon.audit.log('model.download', { repoId, filename });
268
+ res.json({ started: true, filename, repoId });
269
+ } catch (err) {
270
+ res.status(400).json({ error: err.message });
271
+ }
272
+ });
273
+
274
+ app.post('/api/models/download/cancel', (req, res) => {
275
+ const { filename } = req.body;
276
+ if (!filename) return res.status(400).json({ error: 'filename is required' });
277
+ const cancelled = daemon.modelManager.cancelDownload(filename);
278
+ res.json({ cancelled });
279
+ });
280
+
281
+ app.get('/api/models/downloads', (req, res) => {
282
+ res.json(daemon.modelManager.getActiveDownloads());
283
+ });
284
+
285
+ app.delete('/api/models/:id', (req, res) => {
286
+ const deleted = daemon.modelManager.deleteModel(req.params.id);
287
+ if (deleted) {
288
+ daemon.audit.log('model.delete', { id: req.params.id });
289
+ res.json({ ok: true });
290
+ } else {
291
+ res.status(404).json({ error: 'Model not found' });
292
+ }
293
+ });
294
+
295
+ app.get('/api/models/recommend', (req, res) => {
296
+ const ramGb = parseInt(req.query.ram) || 16;
297
+ const quant = daemon.modelManager.recommendQuantization('7B', ramGb);
298
+ res.json({ recommendedQuantization: quant, ramGb });
299
+ });
300
+
301
+ app.get('/api/llama/status', (req, res) => {
302
+ res.json(daemon.llamaServer.getStatus());
303
+ });
304
+
231
305
  // --- Credentials ---
232
306
 
233
307
  app.get('/api/credentials', (req, res) => {
234
308
  res.json(daemon.credentials.listProviders());
235
309
  });
236
310
 
237
- app.post('/api/credentials/:provider', (req, res) => {
311
+ app.post('/api/credentials/:provider', async (req, res) => {
238
312
  if (!req.body.key) return res.status(400).json({ error: 'key is required' });
239
313
  daemon.credentials.setKey(req.params.provider, req.body.key);
240
314
  daemon.audit.log('credential.set', { provider: req.params.provider });
241
- res.json({ ok: true, masked: daemon.credentials.mask(req.body.key) });
315
+
316
+ // Provider-specific auth setup (e.g., Codex auto-login)
317
+ const provider = getProvider(req.params.provider);
318
+ let authResult = null;
319
+ if (provider?.constructor?.onKeySet) {
320
+ try { authResult = await provider.constructor.onKeySet(req.body.key); } catch { /* best effort */ }
321
+ }
322
+
323
+ res.json({ ok: true, masked: daemon.credentials.mask(req.body.key), auth: authResult });
242
324
  });
243
325
 
244
326
  app.delete('/api/credentials/:provider', (req, res) => {
@@ -363,7 +445,8 @@ export function createApi(app, daemon) {
363
445
  }
364
446
  });
365
447
 
366
- // Instruct an agent — resumes session if possible, falls back to rotation
448
+ // Instruct an agent — send message to agent loop, resume session, or rotate
449
+ // Agent loop = direct message to running loop (local models)
367
450
  // Resume = zero cold-start (uses --resume SESSION_ID)
368
451
  // Rotation = full handoff brief (only for degradation or no session)
369
452
  app.post('/api/agents/:id/instruct', async (req, res) => {
@@ -375,8 +458,17 @@ export function createApi(app, daemon) {
375
458
  const agent = daemon.registry.get(req.params.id);
376
459
  if (!agent) return res.status(404).json({ error: 'Agent not found' });
377
460
 
378
- // Try session resume first (zero cold-start)
379
- // Falls back to rotation if no session ID or provider doesn't support resume
461
+ // Agent loop path send message directly to the running loop
462
+ if (daemon.processes.hasAgentLoop(req.params.id)) {
463
+ const sent = await daemon.processes.sendMessage(req.params.id, message.trim());
464
+ if (sent) {
465
+ daemon.audit.log('agent.chat', { id: req.params.id });
466
+ return res.json({ id: agent.id, status: 'message_sent' });
467
+ }
468
+ // Loop exists but not running — fall through to resume/rotate
469
+ }
470
+
471
+ // CLI agent path — session resume or rotation
380
472
  const resumed = !!agent.sessionId;
381
473
  const newAgent = resumed
382
474
  ? await daemon.processes.resume(req.params.id, message.trim())
@@ -390,6 +482,7 @@ export function createApi(app, daemon) {
390
482
  });
391
483
 
392
484
  // Query an agent (headless one-shot, agent keeps running)
485
+ // For agent loop agents: sends message directly to the loop
393
486
  app.post('/api/agents/:id/query', async (req, res) => {
394
487
  try {
395
488
  const { message } = req.body;
@@ -399,6 +492,12 @@ export function createApi(app, daemon) {
399
492
  const agent = daemon.registry.get(req.params.id);
400
493
  if (!agent) return res.status(404).json({ error: 'Agent not found' });
401
494
 
495
+ // Agent loop agents: send message directly (they're interactive)
496
+ if (daemon.processes.hasAgentLoop(req.params.id)) {
497
+ const sent = await daemon.processes.sendMessage(req.params.id, message.trim());
498
+ return res.json({ response: sent ? 'Message sent to agent' : 'Agent not running', agentId: agent.id, agentName: agent.name });
499
+ }
500
+
402
501
  // Build context about the agent's work
403
502
  const activity = daemon.classifier?.agentWindows?.[agent.id] || [];
404
503
  const recentActivity = activity.slice(-20).map((e) => e.data || e.text || '').join('\n');
@@ -34,6 +34,8 @@ import { FileWatcher } from './filewatcher.js';
34
34
  import { TimelineTracker } from './timeline.js';
35
35
  import { TerminalManager } from './terminal-pty.js';
36
36
  import { GatewayManager } from './gateways/manager.js';
37
+ import { ModelManager } from './model-manager.js';
38
+ import { LlamaServerManager } from './llama-server.js';
37
39
  import { isFirstRun, runFirstTimeSetup, loadConfig, saveConfig, printWelcome } from './firstrun.js';
38
40
 
39
41
  const DEFAULT_PORT = 31415;
@@ -129,6 +131,8 @@ export class Daemon {
129
131
  this.fileWatcher = new FileWatcher(this);
130
132
  this.terminalManager = new TerminalManager(this);
131
133
  this.gateways = new GatewayManager(this);
134
+ this.modelManager = new ModelManager(this);
135
+ this.llamaServer = new LlamaServerManager(this);
132
136
 
133
137
  // HTTP + WebSocket server
134
138
  this.app = express();
@@ -400,8 +404,9 @@ export class Daemon {
400
404
  this.fileWatcher.unwatchAll();
401
405
  this.terminalManager.killAll();
402
406
 
403
- // Kill all agent processes
407
+ // Kill all agent processes and stop inference servers
404
408
  await this.processes.killAll();
409
+ await this.llamaServer.stopAll();
405
410
 
406
411
  // Clean up PID and host files
407
412
  if (existsSync(this.pidFile)) {