serpentstack 0.2.16 → 0.2.19
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/lib/commands/persistent.js +222 -231
- package/lib/utils/agent-utils.js +3 -0
- package/package.json +1 -1
|
@@ -8,12 +8,6 @@ import {
|
|
|
8
8
|
parseAgentMd,
|
|
9
9
|
discoverAgents,
|
|
10
10
|
generateWorkspace,
|
|
11
|
-
writePid,
|
|
12
|
-
removePid,
|
|
13
|
-
listPids,
|
|
14
|
-
cleanStalePids,
|
|
15
|
-
cleanWorkspace,
|
|
16
|
-
isProcessAlive,
|
|
17
11
|
} from '../utils/agent-utils.js';
|
|
18
12
|
import {
|
|
19
13
|
readConfig,
|
|
@@ -33,9 +27,9 @@ function which(cmd) {
|
|
|
33
27
|
});
|
|
34
28
|
}
|
|
35
29
|
|
|
36
|
-
function execPromise(cmd, args) {
|
|
30
|
+
function execPromise(cmd, args, opts = {}) {
|
|
37
31
|
return new Promise((resolve, reject) => {
|
|
38
|
-
execFile(cmd, args, { timeout: 15000 }, (err, stdout, stderr) => {
|
|
32
|
+
execFile(cmd, args, { timeout: 15000, ...opts }, (err, stdout, stderr) => {
|
|
39
33
|
if (err) {
|
|
40
34
|
const msg = stderr?.trim() || stdout?.trim() || err.message;
|
|
41
35
|
reject(new Error(msg));
|
|
@@ -60,31 +54,42 @@ async function askYesNo(rl, label, defaultYes = true) {
|
|
|
60
54
|
return val === 'y' || val === 'yes';
|
|
61
55
|
}
|
|
62
56
|
|
|
57
|
+
async function isGatewayRunning() {
|
|
58
|
+
try {
|
|
59
|
+
// Try the WebSocket upgrade endpoint with a plain HTTP request
|
|
60
|
+
const resp = await fetch('http://127.0.0.1:18789/', { signal: AbortSignal.timeout(2000) });
|
|
61
|
+
return true; // Any response means gateway is up
|
|
62
|
+
} catch {
|
|
63
|
+
return false;
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
63
67
|
// ─── Preflight ──────────────────────────────────────────────
|
|
64
68
|
|
|
65
|
-
/**
|
|
66
|
-
* Check all prerequisites and return a status object.
|
|
67
|
-
* Exits the process with helpful guidance if anything critical is missing.
|
|
68
|
-
*/
|
|
69
69
|
async function preflight(projectDir) {
|
|
70
70
|
const soulPath = join(projectDir, '.openclaw/SOUL.md');
|
|
71
71
|
|
|
72
|
-
//
|
|
72
|
+
// Auto-create .openclaw workspace if missing
|
|
73
73
|
if (!existsSync(soulPath)) {
|
|
74
|
-
|
|
74
|
+
info('No .openclaw/ workspace found — setting up now...');
|
|
75
75
|
console.log();
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
76
|
+
try {
|
|
77
|
+
const { skillsInit } = await import('./skills-init.js');
|
|
78
|
+
await skillsInit({ force: false });
|
|
79
|
+
console.log();
|
|
80
|
+
} catch (err) {
|
|
81
|
+
error(`Failed to set up workspace: ${err.message}`);
|
|
82
|
+
console.log(` ${dim('Run')} ${bold('serpentstack skills')} ${dim('manually to download skills and agent configs.')}`);
|
|
83
|
+
console.log();
|
|
84
|
+
process.exit(1);
|
|
85
|
+
}
|
|
79
86
|
}
|
|
80
87
|
|
|
81
88
|
// Check for agent definitions
|
|
82
89
|
const agentDirs = discoverAgents(projectDir);
|
|
83
90
|
if (agentDirs.length === 0) {
|
|
84
91
|
error('No agents found in .openclaw/agents/');
|
|
85
|
-
console.log();
|
|
86
|
-
console.log(` ${dim('Run')} ${bold('serpentstack skills')} ${dim('to download the default agents,')}`);
|
|
87
|
-
console.log(` ${dim('or create your own at')} ${bold('.openclaw/agents/<name>/AGENT.md')}`);
|
|
92
|
+
console.log(` ${dim('Run')} ${bold('serpentstack skills')} ${dim('to download the default agents.')}`);
|
|
88
93
|
console.log();
|
|
89
94
|
process.exit(1);
|
|
90
95
|
}
|
|
@@ -107,31 +112,26 @@ async function preflight(projectDir) {
|
|
|
107
112
|
|
|
108
113
|
// Detect runtime dependencies in parallel
|
|
109
114
|
const spin = spinner('Checking runtime...');
|
|
110
|
-
const [hasOpenClaw, available] = await Promise.all([
|
|
115
|
+
const [hasOpenClaw, hasOllama, available] = await Promise.all([
|
|
111
116
|
which('openclaw'),
|
|
117
|
+
which('ollama'),
|
|
112
118
|
detectModels(),
|
|
113
119
|
]);
|
|
114
120
|
spin.stop();
|
|
115
121
|
|
|
116
|
-
return { soulPath, parsed, hasOpenClaw, available };
|
|
122
|
+
return { soulPath, parsed, hasOpenClaw, hasOllama, available };
|
|
117
123
|
}
|
|
118
124
|
|
|
119
|
-
/**
|
|
120
|
-
* Print a summary of what's installed and what's missing.
|
|
121
|
-
* Returns true if everything needed to launch is present.
|
|
122
|
-
*/
|
|
123
125
|
function printPreflightStatus(hasOpenClaw, available) {
|
|
124
126
|
divider('Runtime');
|
|
125
127
|
console.log();
|
|
126
128
|
|
|
127
|
-
// OpenClaw
|
|
128
129
|
if (hasOpenClaw) {
|
|
129
130
|
console.log(` ${green('✓')} OpenClaw ${dim('— persistent agent runtime')}`);
|
|
130
131
|
} else {
|
|
131
132
|
console.log(` ${red('✗')} OpenClaw ${dim('— not installed')}`);
|
|
132
133
|
}
|
|
133
134
|
|
|
134
|
-
// Ollama
|
|
135
135
|
if (available.ollamaRunning) {
|
|
136
136
|
console.log(` ${green('✓')} Ollama ${dim(`— running, ${available.local.length} model(s) installed`)}`);
|
|
137
137
|
} else if (available.ollamaInstalled) {
|
|
@@ -140,48 +140,26 @@ function printPreflightStatus(hasOpenClaw, available) {
|
|
|
140
140
|
console.log(` ${yellow('○')} Ollama ${dim('— not installed (optional, for free local models)')}`);
|
|
141
141
|
}
|
|
142
142
|
|
|
143
|
-
// API key
|
|
144
143
|
if (available.hasApiKey) {
|
|
145
144
|
console.log(` ${green('✓')} API key ${dim('— configured for cloud models')}`);
|
|
146
145
|
}
|
|
147
146
|
|
|
148
147
|
console.log();
|
|
149
148
|
|
|
150
|
-
// Actionable guidance for missing pieces
|
|
151
149
|
const issues = [];
|
|
152
|
-
|
|
153
150
|
if (!hasOpenClaw) {
|
|
154
|
-
issues.push({
|
|
155
|
-
label: 'Install OpenClaw (required to run agents)',
|
|
156
|
-
cmd: 'npm install -g openclaw@latest',
|
|
157
|
-
});
|
|
151
|
+
issues.push({ label: 'Install OpenClaw (required to run agents)', cmd: 'npm install -g openclaw@latest' });
|
|
158
152
|
}
|
|
159
|
-
|
|
160
153
|
if (!available.ollamaInstalled) {
|
|
161
|
-
issues.push({
|
|
162
|
-
label: 'Install Ollama for free local models (recommended)',
|
|
163
|
-
cmd: 'curl -fsSL https://ollama.com/install.sh | sh',
|
|
164
|
-
});
|
|
154
|
+
issues.push({ label: 'Install Ollama for free local models (recommended)', cmd: 'curl -fsSL https://ollama.com/install.sh | sh' });
|
|
165
155
|
} else if (!available.ollamaRunning) {
|
|
166
|
-
issues.push({
|
|
167
|
-
label: 'Start Ollama',
|
|
168
|
-
cmd: 'ollama serve',
|
|
169
|
-
});
|
|
170
|
-
}
|
|
171
|
-
|
|
172
|
-
if (available.ollamaRunning && available.local.length === 0) {
|
|
173
|
-
issues.push({
|
|
174
|
-
label: 'Pull a model (Ollama is running but has no models)',
|
|
175
|
-
cmd: 'ollama pull llama3.2',
|
|
176
|
-
});
|
|
156
|
+
issues.push({ label: 'Start Ollama', cmd: 'ollama serve' });
|
|
177
157
|
}
|
|
178
158
|
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
console.log();
|
|
184
|
-
}
|
|
159
|
+
for (const issue of issues) {
|
|
160
|
+
console.log(` ${dim(issue.label + ':')}`);
|
|
161
|
+
console.log(` ${dim('$')} ${bold(issue.cmd)}`);
|
|
162
|
+
console.log();
|
|
185
163
|
}
|
|
186
164
|
|
|
187
165
|
return hasOpenClaw;
|
|
@@ -189,10 +167,6 @@ function printPreflightStatus(hasOpenClaw, available) {
|
|
|
189
167
|
|
|
190
168
|
// ─── Model Install ──────────────────────────────────────────
|
|
191
169
|
|
|
192
|
-
/**
|
|
193
|
-
* Run `ollama pull <model>` with live progress output.
|
|
194
|
-
* Returns true if the pull succeeded.
|
|
195
|
-
*/
|
|
196
170
|
function ollamaPull(modelName) {
|
|
197
171
|
return new Promise((resolve) => {
|
|
198
172
|
console.log();
|
|
@@ -203,7 +177,6 @@ function ollamaPull(modelName) {
|
|
|
203
177
|
stdio: ['ignore', 'pipe', 'pipe'],
|
|
204
178
|
});
|
|
205
179
|
|
|
206
|
-
// Stream progress to the terminal
|
|
207
180
|
child.stdout.on('data', (data) => {
|
|
208
181
|
const line = data.toString().trim();
|
|
209
182
|
if (line) process.stderr.write(` ${line}\r`);
|
|
@@ -214,7 +187,7 @@ function ollamaPull(modelName) {
|
|
|
214
187
|
});
|
|
215
188
|
|
|
216
189
|
child.on('close', (code) => {
|
|
217
|
-
process.stderr.write('\x1b[K');
|
|
190
|
+
process.stderr.write('\x1b[K');
|
|
218
191
|
if (code === 0) {
|
|
219
192
|
success(`${bold(modelName)} installed`);
|
|
220
193
|
console.log();
|
|
@@ -257,11 +230,9 @@ async function pickModel(rl, agentName, currentModel, available) {
|
|
|
257
230
|
}
|
|
258
231
|
}
|
|
259
232
|
|
|
260
|
-
// Section 2: Downloadable models — always shown
|
|
233
|
+
// Section 2: Downloadable models — always shown
|
|
261
234
|
if (available.recommended.length > 0) {
|
|
262
|
-
const liveTag = available.recommendedLive
|
|
263
|
-
? dim(`live from ollama.com`)
|
|
264
|
-
: dim(`cached list`);
|
|
235
|
+
const liveTag = available.recommendedLive ? dim('live from ollama.com') : dim('cached list');
|
|
265
236
|
const needsOllama = !available.ollamaInstalled ? dim(' · requires Ollama') : '';
|
|
266
237
|
console.log(` ${dim('── Download')} ${cyan('free')} ${dim('(')}${liveTag}${needsOllama}${dim(') ──')}`);
|
|
267
238
|
const toShow = available.recommended.slice(0, 8);
|
|
@@ -269,13 +240,9 @@ async function pickModel(rl, agentName, currentModel, available) {
|
|
|
269
240
|
const idx = choices.length;
|
|
270
241
|
const isCurrent = `ollama/${r.name}` === currentModel;
|
|
271
242
|
choices.push({
|
|
272
|
-
id: `ollama/${r.name}`,
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
size: r.size,
|
|
276
|
-
description: r.description,
|
|
277
|
-
tier: 'downloadable',
|
|
278
|
-
action: 'download',
|
|
243
|
+
id: `ollama/${r.name}`, name: r.name, params: r.params,
|
|
244
|
+
size: r.size, description: r.description,
|
|
245
|
+
tier: 'downloadable', action: 'download',
|
|
279
246
|
});
|
|
280
247
|
const marker = isCurrent ? green('>') : ' ';
|
|
281
248
|
const num = dim(`${idx + 1}.`);
|
|
@@ -310,15 +277,15 @@ async function pickModel(rl, agentName, currentModel, available) {
|
|
|
310
277
|
|
|
311
278
|
if (choices.length === 0) {
|
|
312
279
|
warn('No models available. Install Ollama and pull a model first.');
|
|
313
|
-
console.log(` ${dim('$')} ${bold('curl -fsSL https://ollama.com/install.sh | sh')}`);
|
|
314
|
-
console.log(` ${dim('$')} ${bold('ollama pull llama3.2')}`);
|
|
315
280
|
return currentModel;
|
|
316
281
|
}
|
|
317
282
|
|
|
318
|
-
// If current model isn't in any list,
|
|
283
|
+
// If current model isn't in any list, append at end (never unshift — breaks numbering)
|
|
319
284
|
if (!choices.some(c => c.id === currentModel)) {
|
|
320
|
-
|
|
321
|
-
|
|
285
|
+
const idx = choices.length;
|
|
286
|
+
choices.push({ id: currentModel, name: modelShortName(currentModel), tier: 'custom', action: 'use' });
|
|
287
|
+
console.log(` ${dim('── Current ─────────────────────────')}`);
|
|
288
|
+
console.log(` ${green('>')} ${dim(`${idx + 1}.`)} ${bold(modelShortName(currentModel))} ${dim('(not installed)')} ${green('← current')}`);
|
|
322
289
|
}
|
|
323
290
|
|
|
324
291
|
const currentIdx = choices.findIndex(c => c.id === currentModel);
|
|
@@ -326,23 +293,20 @@ async function pickModel(rl, agentName, currentModel, available) {
|
|
|
326
293
|
|
|
327
294
|
const answer = await rl.question(` ${dim(`Enter 1-${choices.length}`)} ${dim(`[${defaultNum}]`)}: `);
|
|
328
295
|
const idx = parseInt(answer.trim(), 10) - 1;
|
|
329
|
-
|
|
330
296
|
const selected = (idx >= 0 && idx < choices.length) ? choices[idx] : choices[Math.max(0, currentIdx)];
|
|
331
297
|
|
|
332
|
-
//
|
|
298
|
+
// Handle downloadable model selection
|
|
333
299
|
if (selected.action === 'download') {
|
|
334
300
|
if (!available.ollamaInstalled) {
|
|
335
301
|
console.log();
|
|
336
302
|
warn('Ollama is required to run local models.');
|
|
337
|
-
console.log();
|
|
338
303
|
console.log(` ${dim('Install Ollama (free, open-source):')}`);
|
|
339
304
|
console.log(` ${dim('$')} ${bold('curl -fsSL https://ollama.com/install.sh | sh')}`);
|
|
340
305
|
console.log(` ${dim('$')} ${bold('ollama serve')}`);
|
|
341
306
|
console.log();
|
|
342
307
|
info(`After installing, re-run ${bold('serpentstack persistent --agents')} to download and select ${bold(selected.name)}.`);
|
|
343
308
|
console.log();
|
|
344
|
-
|
|
345
|
-
// Save the selection anyway so it's remembered
|
|
309
|
+
// Save selection so it's remembered, but mark it can't launch yet
|
|
346
310
|
return selected.id;
|
|
347
311
|
}
|
|
348
312
|
|
|
@@ -351,11 +315,10 @@ async function pickModel(rl, agentName, currentModel, available) {
|
|
|
351
315
|
warn('Ollama is installed but not running.');
|
|
352
316
|
console.log(` ${dim('$')} ${bold('ollama serve')}`);
|
|
353
317
|
console.log();
|
|
354
|
-
info(`Start Ollama, then re-run ${bold('serpentstack persistent --agents')} to download ${bold(selected.name)}.`);
|
|
355
|
-
console.log();
|
|
356
318
|
return selected.id;
|
|
357
319
|
}
|
|
358
320
|
|
|
321
|
+
// Ollama is running — download the model now
|
|
359
322
|
rl.pause();
|
|
360
323
|
const pulled = await ollamaPull(selected.name);
|
|
361
324
|
rl.resume();
|
|
@@ -366,9 +329,9 @@ async function pickModel(rl, agentName, currentModel, available) {
|
|
|
366
329
|
}
|
|
367
330
|
}
|
|
368
331
|
|
|
369
|
-
// Warn about cloud
|
|
332
|
+
// Warn about cloud costs
|
|
370
333
|
if (selected.tier === 'cloud' && (available.local.length > 0 || available.recommended.length > 0)) {
|
|
371
|
-
warn(
|
|
334
|
+
warn('Cloud models cost tokens per heartbeat. Consider a local model for persistent agents.');
|
|
372
335
|
}
|
|
373
336
|
if (selected.tier === 'cloud' && !available.hasApiKey) {
|
|
374
337
|
warn(`No API key detected. Run ${bold('openclaw configure')} to set up authentication.`);
|
|
@@ -438,46 +401,51 @@ end tell`;
|
|
|
438
401
|
|
|
439
402
|
// ─── Stop Flow ──────────────────────────────────────────────
|
|
440
403
|
|
|
441
|
-
async function stopAllAgents(projectDir) {
|
|
442
|
-
|
|
443
|
-
|
|
404
|
+
async function stopAllAgents(projectDir, config, parsed) {
|
|
405
|
+
const hasOpenClaw = await which('openclaw');
|
|
406
|
+
let stopped = 0;
|
|
444
407
|
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
}
|
|
408
|
+
// Get list of enabled agents from config (don't rely on PIDs)
|
|
409
|
+
const agentNames = parsed
|
|
410
|
+
? parsed.map(a => a.name)
|
|
411
|
+
: Object.keys(config?.agents || {});
|
|
450
412
|
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
// Remove cron jobs for this agent
|
|
454
|
-
try {
|
|
455
|
-
await execPromise('openclaw', ['cron', 'list', '--json']).then(out => {
|
|
456
|
-
const jobs = JSON.parse(out);
|
|
457
|
-
const agentJobs = (Array.isArray(jobs) ? jobs : jobs.jobs || [])
|
|
458
|
-
.filter(j => j.agent === name || (j.name && j.name.startsWith(`${name}-`)));
|
|
459
|
-
return Promise.all(agentJobs.map(j =>
|
|
460
|
-
execPromise('openclaw', ['cron', 'rm', j.id || j.name]).catch(() => {})
|
|
461
|
-
));
|
|
462
|
-
});
|
|
463
|
-
} catch { /* cron cleanup is best-effort */ }
|
|
413
|
+
for (const name of agentNames) {
|
|
414
|
+
let didSomething = false;
|
|
464
415
|
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
416
|
+
if (hasOpenClaw) {
|
|
417
|
+
// Remove cron jobs for this agent
|
|
418
|
+
try {
|
|
419
|
+
const out = await execPromise('openclaw', ['cron', 'list', '--json']);
|
|
420
|
+
const data = JSON.parse(out);
|
|
421
|
+
const jobs = Array.isArray(data) ? data : (data.jobs || []);
|
|
422
|
+
const agentJobs = jobs.filter(j =>
|
|
423
|
+
j.agent === name || (j.name && j.name.startsWith(`${name}-`))
|
|
424
|
+
);
|
|
425
|
+
for (const j of agentJobs) {
|
|
426
|
+
try {
|
|
427
|
+
await execPromise('openclaw', ['cron', 'rm', j.id || j.name]);
|
|
428
|
+
didSomething = true;
|
|
429
|
+
} catch { /* best-effort */ }
|
|
430
|
+
}
|
|
431
|
+
} catch { /* gateway might not be running */ }
|
|
469
432
|
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
433
|
+
// Remove agent registration from OpenClaw
|
|
434
|
+
try {
|
|
435
|
+
await execPromise('openclaw', ['agents', 'delete', name]);
|
|
436
|
+
didSomething = true;
|
|
437
|
+
} catch { /* agent may not exist */ }
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
if (didSomething) {
|
|
441
|
+
success(`Stopped ${bold(name)}`);
|
|
442
|
+
stopped++;
|
|
473
443
|
}
|
|
474
|
-
removePid(projectDir, name);
|
|
475
|
-
cleanWorkspace(projectDir, name);
|
|
476
|
-
success(`Stopped ${bold(name)}`);
|
|
477
|
-
stopped++;
|
|
478
444
|
}
|
|
479
445
|
|
|
480
|
-
if (stopped
|
|
446
|
+
if (stopped === 0) {
|
|
447
|
+
info('No agents are currently registered with OpenClaw.');
|
|
448
|
+
} else {
|
|
481
449
|
console.log();
|
|
482
450
|
success(`${stopped} agent(s) stopped`);
|
|
483
451
|
}
|
|
@@ -487,28 +455,20 @@ async function stopAllAgents(projectDir) {
|
|
|
487
455
|
|
|
488
456
|
// ─── Agent Status ───────────────────────────────────────────
|
|
489
457
|
|
|
490
|
-
function
|
|
491
|
-
const pid = listPids(projectDir).find(p => p.name === name);
|
|
492
|
-
if (pid && isProcessAlive(pid.pid)) return { status: 'running', pid: pid.pid };
|
|
493
|
-
if (!isAgentEnabled(name, config)) return { status: 'disabled', pid: null };
|
|
494
|
-
return { status: 'stopped', pid: null };
|
|
495
|
-
}
|
|
496
|
-
|
|
497
|
-
function printAgentLine(name, agentMd, config, statusInfo) {
|
|
458
|
+
function printAgentLine(name, agentMd, config) {
|
|
498
459
|
const model = getEffectiveModel(name, agentMd.meta, config);
|
|
499
460
|
const schedule = (agentMd.meta.schedule || []).map(s => s.every).join(', ');
|
|
500
461
|
const modelStr = modelShortName(model);
|
|
462
|
+
const enabled = isAgentEnabled(name, config);
|
|
501
463
|
|
|
502
|
-
if (
|
|
503
|
-
console.log(` ${green('●')} ${bold(name)} ${dim(modelStr)} ${dim(schedule)} ${green(
|
|
504
|
-
} else if (statusInfo.status === 'disabled') {
|
|
505
|
-
console.log(` ${dim('○')} ${dim(name)} ${dim(modelStr)} ${dim(schedule)} ${dim('disabled')}`);
|
|
464
|
+
if (enabled) {
|
|
465
|
+
console.log(` ${green('●')} ${bold(name)} ${dim(modelStr)} ${dim(schedule)} ${green('enabled')}`);
|
|
506
466
|
} else {
|
|
507
|
-
console.log(` ${
|
|
467
|
+
console.log(` ${dim('○')} ${dim(name)} ${dim(modelStr)} ${dim(schedule)} ${dim('disabled')}`);
|
|
508
468
|
}
|
|
509
469
|
}
|
|
510
470
|
|
|
511
|
-
function printStatusDashboard(config, parsed
|
|
471
|
+
function printStatusDashboard(config, parsed) {
|
|
512
472
|
console.log(` ${bold(config.project.name)} ${dim(`— ${config.project.framework}`)}`);
|
|
513
473
|
console.log(` ${dim(`Dev: ${config.project.devCmd} · Test: ${config.project.testCmd}`)}`);
|
|
514
474
|
console.log();
|
|
@@ -516,8 +476,7 @@ function printStatusDashboard(config, parsed, projectDir) {
|
|
|
516
476
|
divider('Agents');
|
|
517
477
|
console.log();
|
|
518
478
|
for (const { name, agentMd } of parsed) {
|
|
519
|
-
|
|
520
|
-
printAgentLine(name, agentMd, config, statusInfo);
|
|
479
|
+
printAgentLine(name, agentMd, config);
|
|
521
480
|
}
|
|
522
481
|
console.log();
|
|
523
482
|
}
|
|
@@ -550,14 +509,13 @@ async function runModels(available) {
|
|
|
550
509
|
|
|
551
510
|
console.log();
|
|
552
511
|
|
|
553
|
-
// Show recommended models to install
|
|
554
512
|
if (available.recommended.length > 0) {
|
|
555
513
|
divider('Recommended Models');
|
|
556
514
|
console.log();
|
|
557
515
|
if (available.recommendedLive) {
|
|
558
516
|
success(`Fetched latest models from ${cyan('ollama.com/library')}`);
|
|
559
517
|
} else {
|
|
560
|
-
warn(
|
|
518
|
+
warn('Could not reach ollama.com — showing cached recommendations');
|
|
561
519
|
}
|
|
562
520
|
console.log();
|
|
563
521
|
for (const r of available.recommended) {
|
|
@@ -566,7 +524,6 @@ async function runModels(available) {
|
|
|
566
524
|
console.log();
|
|
567
525
|
}
|
|
568
526
|
|
|
569
|
-
// Status summary
|
|
570
527
|
if (!available.ollamaInstalled) {
|
|
571
528
|
console.log(` ${dim('Install Ollama for free local models:')}`);
|
|
572
529
|
console.log(` ${dim('$')} ${bold('curl -fsSL https://ollama.com/install.sh | sh')}`);
|
|
@@ -622,8 +579,7 @@ async function runConfigure(projectDir, config, soulPath) {
|
|
|
622
579
|
let soul = readFileSync(soulPath, 'utf8');
|
|
623
580
|
const ctx = [
|
|
624
581
|
`# ${config.project.name} — Persistent Development Agents`,
|
|
625
|
-
'',
|
|
626
|
-
`**Project:** ${config.project.name}`,
|
|
582
|
+
'', `**Project:** ${config.project.name}`,
|
|
627
583
|
`**Language:** ${config.project.language}`,
|
|
628
584
|
`**Framework:** ${config.project.framework}`,
|
|
629
585
|
`**Dev server:** \`${config.project.devCmd}\``,
|
|
@@ -650,7 +606,6 @@ async function runConfigure(projectDir, config, soulPath) {
|
|
|
650
606
|
|
|
651
607
|
// ─── Agents Flow ────────────────────────────────────────────
|
|
652
608
|
|
|
653
|
-
// Agent description summaries for the enable/disable flow
|
|
654
609
|
const AGENT_SUMMARIES = {
|
|
655
610
|
'log-watcher': 'Monitors your dev server health and log output every 30–60s. Catches backend crashes, frontend build errors, and import failures — reports them with file paths and suggested fixes.',
|
|
656
611
|
'test-runner': 'Runs your test suite every 5 min and lint/typecheck every 15 min. Catches regressions before you commit — shows which test failed, what changed, and whether the test or source needs fixing.',
|
|
@@ -658,7 +613,6 @@ const AGENT_SUMMARIES = {
|
|
|
658
613
|
};
|
|
659
614
|
|
|
660
615
|
async function runAgents(projectDir, config, parsed, available) {
|
|
661
|
-
// Show system capabilities so users know what models they can run
|
|
662
616
|
const sys = detectSystemCapabilities();
|
|
663
617
|
|
|
664
618
|
divider('Your System');
|
|
@@ -680,11 +634,9 @@ async function runAgents(projectDir, config, parsed, available) {
|
|
|
680
634
|
const currentModel = existingAgent?.model || 'ollama/llama3.2';
|
|
681
635
|
const schedule = (agentMd.meta.schedule || []).map(s => s.every).join(', ');
|
|
682
636
|
|
|
683
|
-
// Show rich description
|
|
684
637
|
console.log(` ${bold(name)} ${dim(`(${schedule || 'manual'})`)}`);
|
|
685
638
|
const summary = AGENT_SUMMARIES[name] || agentMd.meta.description || '';
|
|
686
639
|
if (summary) {
|
|
687
|
-
// Word-wrap summary to ~70 chars, indented
|
|
688
640
|
const words = summary.split(' ');
|
|
689
641
|
let line = '';
|
|
690
642
|
for (const word of words) {
|
|
@@ -725,32 +677,80 @@ async function runAgents(projectDir, config, parsed, available) {
|
|
|
725
677
|
|
|
726
678
|
// ─── Start Flow ─────────────────────────────────────────────
|
|
727
679
|
|
|
728
|
-
|
|
680
|
+
function isModelAvailable(modelId, available) {
|
|
681
|
+
// Check if it's an installed local model
|
|
682
|
+
if (available.local.some(m => m.id === modelId)) return true;
|
|
683
|
+
// Check if it's a cloud model with API key
|
|
684
|
+
if (available.cloud.some(m => m.id === modelId) && available.hasApiKey) return true;
|
|
685
|
+
return false;
|
|
686
|
+
}
|
|
687
|
+
|
|
688
|
+
async function runStart(projectDir, parsed, config, soulPath, hasOpenClaw, available) {
|
|
729
689
|
if (!hasOpenClaw) {
|
|
730
690
|
error('Cannot launch agents — OpenClaw is not installed.');
|
|
731
|
-
console.log();
|
|
732
|
-
console.log(` ${dim('$')} ${bold('npm install -g openclaw@latest')}`);
|
|
691
|
+
console.log(` ${dim('$')} ${bold('npm install -g openclaw@latest')}`);
|
|
733
692
|
console.log();
|
|
734
693
|
return;
|
|
735
694
|
}
|
|
736
695
|
|
|
737
696
|
const enabledAgents = parsed.filter(a => isAgentEnabled(a.name, config));
|
|
738
|
-
const runningNames = new Set(listPids(projectDir).map(p => p.name));
|
|
739
|
-
const startable = enabledAgents.filter(a => !runningNames.has(a.name));
|
|
740
697
|
|
|
741
|
-
if (
|
|
742
|
-
info('
|
|
698
|
+
if (enabledAgents.length === 0) {
|
|
699
|
+
info('No agents are enabled.');
|
|
700
|
+
console.log(` ${dim('Run')} ${bold('serpentstack persistent --agents')} ${dim('to enable agents.')}`);
|
|
743
701
|
console.log();
|
|
744
702
|
return;
|
|
745
703
|
}
|
|
746
704
|
|
|
747
|
-
|
|
748
|
-
|
|
749
|
-
|
|
705
|
+
// Check model availability for each agent BEFORE launching
|
|
706
|
+
const launchable = [];
|
|
707
|
+
const blocked = [];
|
|
708
|
+
|
|
709
|
+
for (const agent of enabledAgents) {
|
|
710
|
+
const model = getEffectiveModel(agent.name, agent.agentMd.meta, config);
|
|
711
|
+
if (isModelAvailable(model, available)) {
|
|
712
|
+
launchable.push(agent);
|
|
713
|
+
} else {
|
|
714
|
+
blocked.push({ agent, model });
|
|
715
|
+
}
|
|
716
|
+
}
|
|
717
|
+
|
|
718
|
+
if (blocked.length > 0) {
|
|
719
|
+
divider('Model Issues');
|
|
720
|
+
console.log();
|
|
721
|
+
for (const { agent, model } of blocked) {
|
|
722
|
+
const shortName = modelShortName(model);
|
|
723
|
+
if (model.startsWith('ollama/')) {
|
|
724
|
+
if (!available.ollamaInstalled) {
|
|
725
|
+
warn(`${bold(agent.name)} needs ${bold(shortName)} but Ollama is not installed.`);
|
|
726
|
+
console.log(` ${dim('$')} ${bold('curl -fsSL https://ollama.com/install.sh | sh')}`);
|
|
727
|
+
console.log(` ${dim('$')} ${bold(`ollama pull ${shortName}`)}`);
|
|
728
|
+
} else if (!available.ollamaRunning) {
|
|
729
|
+
warn(`${bold(agent.name)} needs ${bold(shortName)} but Ollama is not running.`);
|
|
730
|
+
console.log(` ${dim('$')} ${bold('ollama serve')}`);
|
|
731
|
+
} else {
|
|
732
|
+
warn(`${bold(agent.name)} needs ${bold(shortName)} which is not installed.`);
|
|
733
|
+
console.log(` ${dim('$')} ${bold(`ollama pull ${shortName}`)}`);
|
|
734
|
+
}
|
|
735
|
+
} else {
|
|
736
|
+
warn(`${bold(agent.name)} needs ${bold(shortName)} but no API key is configured.`);
|
|
737
|
+
console.log(` ${dim('$')} ${bold('openclaw configure')}`);
|
|
738
|
+
}
|
|
739
|
+
console.log();
|
|
740
|
+
}
|
|
741
|
+
|
|
742
|
+
if (launchable.length === 0) {
|
|
743
|
+
error('No agents can launch — fix the model issues above first.');
|
|
744
|
+
console.log(` ${dim('Or run')} ${bold('serpentstack persistent --agents')} ${dim('to pick different models.')}`);
|
|
745
|
+
console.log();
|
|
746
|
+
return;
|
|
747
|
+
}
|
|
748
|
+
|
|
749
|
+
info(`${launchable.length} of ${enabledAgents.length} agent(s) can launch. Continuing with available agents.`);
|
|
750
750
|
console.log();
|
|
751
|
-
return;
|
|
752
751
|
}
|
|
753
752
|
|
|
753
|
+
// Confirm which agents to start
|
|
754
754
|
const rl = createInterface({ input: stdin, output: stdout });
|
|
755
755
|
const toStart = [];
|
|
756
756
|
|
|
@@ -758,7 +758,7 @@ async function runStart(projectDir, parsed, config, soulPath, hasOpenClaw) {
|
|
|
758
758
|
divider('Launch');
|
|
759
759
|
console.log();
|
|
760
760
|
|
|
761
|
-
for (const agent of
|
|
761
|
+
for (const agent of launchable) {
|
|
762
762
|
const model = getEffectiveModel(agent.name, agent.agentMd.meta, config);
|
|
763
763
|
const yes = await askYesNo(rl, `Start ${bold(agent.name)} ${dim(`(${modelShortName(model)})`)}?`, true);
|
|
764
764
|
if (yes) toStart.push(agent);
|
|
@@ -776,10 +776,50 @@ async function runStart(projectDir, parsed, config, soulPath, hasOpenClaw) {
|
|
|
776
776
|
|
|
777
777
|
console.log();
|
|
778
778
|
|
|
779
|
+
// Ensure gateway is running first
|
|
780
|
+
let gatewayRunning = await isGatewayRunning();
|
|
781
|
+
|
|
782
|
+
if (!gatewayRunning) {
|
|
783
|
+
info('Starting OpenClaw gateway...');
|
|
784
|
+
|
|
785
|
+
const method = openInTerminal('OpenClaw Gateway', 'openclaw gateway', resolve(projectDir));
|
|
786
|
+
|
|
787
|
+
if (method) {
|
|
788
|
+
success(`Gateway opened in ${method}`);
|
|
789
|
+
} else {
|
|
790
|
+
const child = spawn('openclaw', ['gateway'], {
|
|
791
|
+
stdio: 'ignore', detached: true, cwd: resolve(projectDir),
|
|
792
|
+
});
|
|
793
|
+
child.unref();
|
|
794
|
+
success(`Gateway started in background ${dim(`(PID ${child.pid})`)}`);
|
|
795
|
+
}
|
|
796
|
+
|
|
797
|
+
// Wait for gateway to be ready
|
|
798
|
+
console.log(` ${dim('Waiting for gateway...')}`);
|
|
799
|
+
for (let i = 0; i < 10; i++) {
|
|
800
|
+
await new Promise(r => setTimeout(r, 1000));
|
|
801
|
+
if (await isGatewayRunning()) {
|
|
802
|
+
gatewayRunning = true;
|
|
803
|
+
break;
|
|
804
|
+
}
|
|
805
|
+
}
|
|
806
|
+
|
|
807
|
+
if (!gatewayRunning) {
|
|
808
|
+
warn('Gateway did not start in time. Agents may not run immediately.');
|
|
809
|
+
console.log(` ${dim('Check the gateway terminal for errors, then retry with:')}`)
|
|
810
|
+
console.log(` ${dim('$')} ${bold('serpentstack persistent --start')}`);
|
|
811
|
+
console.log();
|
|
812
|
+
}
|
|
813
|
+
} else {
|
|
814
|
+
success('Gateway is already running');
|
|
815
|
+
}
|
|
816
|
+
|
|
817
|
+
console.log();
|
|
818
|
+
|
|
819
|
+
// Register agents and create cron jobs
|
|
779
820
|
const sharedSoul = readFileSync(soulPath, 'utf8');
|
|
780
821
|
let registered = 0;
|
|
781
822
|
|
|
782
|
-
// Step 1: Generate workspaces and register agents with OpenClaw
|
|
783
823
|
for (const { name, agentMd } of toStart) {
|
|
784
824
|
try {
|
|
785
825
|
const effectiveModel = getEffectiveModel(name, agentMd.meta, config);
|
|
@@ -791,7 +831,7 @@ async function runStart(projectDir, parsed, config, soulPath, hasOpenClaw) {
|
|
|
791
831
|
const workspacePath = generateWorkspace(projectDir, name, overriddenMd, sharedSoul);
|
|
792
832
|
const absWorkspace = resolve(workspacePath);
|
|
793
833
|
|
|
794
|
-
// Register agent with OpenClaw
|
|
834
|
+
// Register agent with OpenClaw
|
|
795
835
|
try {
|
|
796
836
|
await execPromise('openclaw', [
|
|
797
837
|
'agents', 'add', name,
|
|
@@ -799,13 +839,15 @@ async function runStart(projectDir, parsed, config, soulPath, hasOpenClaw) {
|
|
|
799
839
|
'--model', effectiveModel,
|
|
800
840
|
'--non-interactive',
|
|
801
841
|
]);
|
|
802
|
-
success(
|
|
842
|
+
success(`Registered ${bold(name)} ${dim(`(${modelShortName(effectiveModel)})`)}`);
|
|
803
843
|
} catch (err) {
|
|
804
|
-
|
|
805
|
-
if (
|
|
806
|
-
|
|
844
|
+
const msg = err.message || '';
|
|
845
|
+
if (msg.includes('already exists') || msg.includes('already')) {
|
|
846
|
+
// Try to update the model on an existing agent
|
|
847
|
+
info(`${bold(name)} already registered — updating model`);
|
|
807
848
|
} else {
|
|
808
|
-
warn(`Could not register ${bold(name)}: ${
|
|
849
|
+
warn(`Could not register ${bold(name)}: ${msg}`);
|
|
850
|
+
continue;
|
|
809
851
|
}
|
|
810
852
|
}
|
|
811
853
|
|
|
@@ -816,17 +858,17 @@ async function runStart(projectDir, parsed, config, soulPath, hasOpenClaw) {
|
|
|
816
858
|
await execPromise('openclaw', [
|
|
817
859
|
'cron', 'add',
|
|
818
860
|
'--agent', name,
|
|
861
|
+
'--model', effectiveModel,
|
|
819
862
|
'--every', sched.every,
|
|
820
863
|
'--message', `Run task: ${sched.task}`,
|
|
821
864
|
'--name', `${name}-${sched.task}`,
|
|
822
865
|
'--light-context',
|
|
823
866
|
]);
|
|
824
867
|
} catch {
|
|
825
|
-
// Cron job may already exist
|
|
868
|
+
// Cron job may already exist
|
|
826
869
|
}
|
|
827
870
|
}
|
|
828
871
|
|
|
829
|
-
writePid(projectDir, name, -1); // marker
|
|
830
872
|
registered++;
|
|
831
873
|
} catch (err) {
|
|
832
874
|
error(`${bold(name)}: ${err.message}`);
|
|
@@ -840,46 +882,6 @@ async function runStart(projectDir, parsed, config, soulPath, hasOpenClaw) {
|
|
|
840
882
|
return;
|
|
841
883
|
}
|
|
842
884
|
|
|
843
|
-
console.log();
|
|
844
|
-
|
|
845
|
-
// Step 2: Check if gateway is running, start it if not
|
|
846
|
-
let gatewayRunning = false;
|
|
847
|
-
try {
|
|
848
|
-
const healthResp = await fetch('http://127.0.0.1:18789/health');
|
|
849
|
-
gatewayRunning = healthResp.ok;
|
|
850
|
-
} catch {
|
|
851
|
-
// not running
|
|
852
|
-
}
|
|
853
|
-
|
|
854
|
-
if (!gatewayRunning) {
|
|
855
|
-
info('Starting OpenClaw gateway...');
|
|
856
|
-
|
|
857
|
-
const method = openInTerminal(
|
|
858
|
-
'OpenClaw Gateway',
|
|
859
|
-
'openclaw gateway',
|
|
860
|
-
resolve(projectDir),
|
|
861
|
-
);
|
|
862
|
-
|
|
863
|
-
if (method) {
|
|
864
|
-
success(`Gateway opened in ${method}`);
|
|
865
|
-
} else {
|
|
866
|
-
// Fallback: start in background
|
|
867
|
-
const child = spawn('openclaw', ['gateway'], {
|
|
868
|
-
stdio: 'ignore',
|
|
869
|
-
detached: true,
|
|
870
|
-
cwd: resolve(projectDir),
|
|
871
|
-
});
|
|
872
|
-
child.unref();
|
|
873
|
-
success(`Gateway started in background ${dim(`(PID ${child.pid})`)}`);
|
|
874
|
-
}
|
|
875
|
-
|
|
876
|
-
// Give gateway a moment to start
|
|
877
|
-
console.log(` ${dim('Waiting for gateway...')}`);
|
|
878
|
-
await new Promise(r => setTimeout(r, 3000));
|
|
879
|
-
} else {
|
|
880
|
-
success('Gateway is already running');
|
|
881
|
-
}
|
|
882
|
-
|
|
883
885
|
console.log();
|
|
884
886
|
success(`${registered} agent(s) registered — fangs out 🐍`);
|
|
885
887
|
console.log();
|
|
@@ -903,21 +905,19 @@ export async function persistent({ stop = false, configure = false, agents = fal
|
|
|
903
905
|
|
|
904
906
|
printHeader();
|
|
905
907
|
|
|
906
|
-
// ──
|
|
907
|
-
if (stop) {
|
|
908
|
-
cleanStalePids(projectDir);
|
|
909
|
-
await stopAllAgents(projectDir);
|
|
910
|
-
return;
|
|
911
|
-
}
|
|
912
|
-
|
|
913
|
-
// ── Full preflight (checks workspace, agents, runtime) ──
|
|
908
|
+
// ── Full preflight (auto-creates .openclaw if missing) ──
|
|
914
909
|
const { soulPath, parsed, hasOpenClaw, available } = await preflight(projectDir);
|
|
915
|
-
cleanStalePids(projectDir);
|
|
916
910
|
|
|
917
911
|
// Load config
|
|
918
912
|
let config = readConfig(projectDir) || { project: {}, agents: {} };
|
|
919
913
|
const isConfigured = !!config._configured;
|
|
920
914
|
|
|
915
|
+
// ── --stop: stop all agents ──
|
|
916
|
+
if (stop) {
|
|
917
|
+
await stopAllAgents(projectDir, config, parsed);
|
|
918
|
+
return;
|
|
919
|
+
}
|
|
920
|
+
|
|
921
921
|
// ── --models: list installed and recommended models ──
|
|
922
922
|
if (models) {
|
|
923
923
|
await runModels(available);
|
|
@@ -939,25 +939,19 @@ export async function persistent({ stop = false, configure = false, agents = fal
|
|
|
939
939
|
|
|
940
940
|
// ── --start: launch agents ──
|
|
941
941
|
if (start) {
|
|
942
|
-
await runStart(projectDir, parsed, config, soulPath, hasOpenClaw);
|
|
942
|
+
await runStart(projectDir, parsed, config, soulPath, hasOpenClaw, available);
|
|
943
943
|
return;
|
|
944
944
|
}
|
|
945
945
|
|
|
946
946
|
// ── Bare `serpentstack persistent` ──
|
|
947
947
|
if (isConfigured) {
|
|
948
|
-
|
|
949
|
-
printStatusDashboard(config, parsed, projectDir);
|
|
948
|
+
printStatusDashboard(config, parsed);
|
|
950
949
|
|
|
951
950
|
const enabledAgents = parsed.filter(a => isAgentEnabled(a.name, config));
|
|
952
|
-
|
|
953
|
-
const startable = enabledAgents.filter(a => !runningNames.has(a.name));
|
|
954
|
-
|
|
955
|
-
if (startable.length === 0 && runningNames.size > 0) {
|
|
956
|
-
info('All enabled agents are running.');
|
|
957
|
-
} else if (startable.length === 0) {
|
|
951
|
+
if (enabledAgents.length === 0) {
|
|
958
952
|
info('No agents are enabled.');
|
|
959
953
|
} else {
|
|
960
|
-
info(`${
|
|
954
|
+
info(`${enabledAgents.length} agent(s) enabled.`);
|
|
961
955
|
}
|
|
962
956
|
|
|
963
957
|
console.log();
|
|
@@ -973,8 +967,6 @@ export async function persistent({ stop = false, configure = false, agents = fal
|
|
|
973
967
|
}
|
|
974
968
|
|
|
975
969
|
// ── First-time setup: guided walkthrough ──
|
|
976
|
-
|
|
977
|
-
// Step 0: Show runtime status
|
|
978
970
|
const canLaunch = printPreflightStatus(hasOpenClaw, available);
|
|
979
971
|
|
|
980
972
|
if (!canLaunch) {
|
|
@@ -982,11 +974,10 @@ export async function persistent({ stop = false, configure = false, agents = fal
|
|
|
982
974
|
console.log(` ${dim('$')} ${bold('serpentstack persistent')}`);
|
|
983
975
|
console.log();
|
|
984
976
|
|
|
985
|
-
// Still let them configure even without OpenClaw
|
|
986
977
|
const rl = createInterface({ input: stdin, output: stdout });
|
|
987
978
|
let proceed;
|
|
988
979
|
try {
|
|
989
|
-
proceed = await askYesNo(rl,
|
|
980
|
+
proceed = await askYesNo(rl, 'Continue with project configuration anyway?', true);
|
|
990
981
|
} finally {
|
|
991
982
|
rl.close();
|
|
992
983
|
}
|
|
@@ -1008,7 +999,7 @@ export async function persistent({ stop = false, configure = false, agents = fal
|
|
|
1008
999
|
|
|
1009
1000
|
// Step 3: Launch (only if OpenClaw is installed)
|
|
1010
1001
|
if (canLaunch) {
|
|
1011
|
-
await runStart(projectDir, parsed, config, soulPath, hasOpenClaw);
|
|
1002
|
+
await runStart(projectDir, parsed, config, soulPath, hasOpenClaw, available);
|
|
1012
1003
|
} else {
|
|
1013
1004
|
console.log();
|
|
1014
1005
|
info('Skipping launch — install OpenClaw first, then run:');
|
package/lib/utils/agent-utils.js
CHANGED
|
@@ -250,6 +250,9 @@ export function listPids(projectDir) {
|
|
|
250
250
|
* Check if a process is alive.
|
|
251
251
|
*/
|
|
252
252
|
export function isProcessAlive(pid) {
|
|
253
|
+
// PID -1 is a marker for "terminal-managed" — we can't check those.
|
|
254
|
+
// process.kill(-1, 0) sends to ALL processes, always succeeds — never use it.
|
|
255
|
+
if (!pid || pid <= 0) return false;
|
|
253
256
|
try {
|
|
254
257
|
process.kill(pid, 0);
|
|
255
258
|
return true;
|