serpentstack 0.2.18 → 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 +216 -228
- 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,12 +277,10 @@ 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, append
|
|
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
285
|
const idx = choices.length;
|
|
321
286
|
choices.push({ id: currentModel, name: modelShortName(currentModel), tier: 'custom', action: 'use' });
|
|
@@ -328,23 +293,20 @@ async function pickModel(rl, agentName, currentModel, available) {
|
|
|
328
293
|
|
|
329
294
|
const answer = await rl.question(` ${dim(`Enter 1-${choices.length}`)} ${dim(`[${defaultNum}]`)}: `);
|
|
330
295
|
const idx = parseInt(answer.trim(), 10) - 1;
|
|
331
|
-
|
|
332
296
|
const selected = (idx >= 0 && idx < choices.length) ? choices[idx] : choices[Math.max(0, currentIdx)];
|
|
333
297
|
|
|
334
|
-
//
|
|
298
|
+
// Handle downloadable model selection
|
|
335
299
|
if (selected.action === 'download') {
|
|
336
300
|
if (!available.ollamaInstalled) {
|
|
337
301
|
console.log();
|
|
338
302
|
warn('Ollama is required to run local models.');
|
|
339
|
-
console.log();
|
|
340
303
|
console.log(` ${dim('Install Ollama (free, open-source):')}`);
|
|
341
304
|
console.log(` ${dim('$')} ${bold('curl -fsSL https://ollama.com/install.sh | sh')}`);
|
|
342
305
|
console.log(` ${dim('$')} ${bold('ollama serve')}`);
|
|
343
306
|
console.log();
|
|
344
307
|
info(`After installing, re-run ${bold('serpentstack persistent --agents')} to download and select ${bold(selected.name)}.`);
|
|
345
308
|
console.log();
|
|
346
|
-
|
|
347
|
-
// Save the selection anyway so it's remembered
|
|
309
|
+
// Save selection so it's remembered, but mark it can't launch yet
|
|
348
310
|
return selected.id;
|
|
349
311
|
}
|
|
350
312
|
|
|
@@ -353,11 +315,10 @@ async function pickModel(rl, agentName, currentModel, available) {
|
|
|
353
315
|
warn('Ollama is installed but not running.');
|
|
354
316
|
console.log(` ${dim('$')} ${bold('ollama serve')}`);
|
|
355
317
|
console.log();
|
|
356
|
-
info(`Start Ollama, then re-run ${bold('serpentstack persistent --agents')} to download ${bold(selected.name)}.`);
|
|
357
|
-
console.log();
|
|
358
318
|
return selected.id;
|
|
359
319
|
}
|
|
360
320
|
|
|
321
|
+
// Ollama is running — download the model now
|
|
361
322
|
rl.pause();
|
|
362
323
|
const pulled = await ollamaPull(selected.name);
|
|
363
324
|
rl.resume();
|
|
@@ -368,9 +329,9 @@ async function pickModel(rl, agentName, currentModel, available) {
|
|
|
368
329
|
}
|
|
369
330
|
}
|
|
370
331
|
|
|
371
|
-
// Warn about cloud
|
|
332
|
+
// Warn about cloud costs
|
|
372
333
|
if (selected.tier === 'cloud' && (available.local.length > 0 || available.recommended.length > 0)) {
|
|
373
|
-
warn(
|
|
334
|
+
warn('Cloud models cost tokens per heartbeat. Consider a local model for persistent agents.');
|
|
374
335
|
}
|
|
375
336
|
if (selected.tier === 'cloud' && !available.hasApiKey) {
|
|
376
337
|
warn(`No API key detected. Run ${bold('openclaw configure')} to set up authentication.`);
|
|
@@ -440,46 +401,51 @@ end tell`;
|
|
|
440
401
|
|
|
441
402
|
// ─── Stop Flow ──────────────────────────────────────────────
|
|
442
403
|
|
|
443
|
-
async function stopAllAgents(projectDir) {
|
|
444
|
-
|
|
445
|
-
|
|
404
|
+
async function stopAllAgents(projectDir, config, parsed) {
|
|
405
|
+
const hasOpenClaw = await which('openclaw');
|
|
406
|
+
let stopped = 0;
|
|
446
407
|
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
}
|
|
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 || {});
|
|
452
412
|
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
// Remove cron jobs for this agent
|
|
456
|
-
try {
|
|
457
|
-
await execPromise('openclaw', ['cron', 'list', '--json']).then(out => {
|
|
458
|
-
const jobs = JSON.parse(out);
|
|
459
|
-
const agentJobs = (Array.isArray(jobs) ? jobs : jobs.jobs || [])
|
|
460
|
-
.filter(j => j.agent === name || (j.name && j.name.startsWith(`${name}-`)));
|
|
461
|
-
return Promise.all(agentJobs.map(j =>
|
|
462
|
-
execPromise('openclaw', ['cron', 'rm', j.id || j.name]).catch(() => {})
|
|
463
|
-
));
|
|
464
|
-
});
|
|
465
|
-
} catch { /* cron cleanup is best-effort */ }
|
|
413
|
+
for (const name of agentNames) {
|
|
414
|
+
let didSomething = false;
|
|
466
415
|
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
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 */ }
|
|
471
432
|
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
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++;
|
|
475
443
|
}
|
|
476
|
-
removePid(projectDir, name);
|
|
477
|
-
cleanWorkspace(projectDir, name);
|
|
478
|
-
success(`Stopped ${bold(name)}`);
|
|
479
|
-
stopped++;
|
|
480
444
|
}
|
|
481
445
|
|
|
482
|
-
if (stopped
|
|
446
|
+
if (stopped === 0) {
|
|
447
|
+
info('No agents are currently registered with OpenClaw.');
|
|
448
|
+
} else {
|
|
483
449
|
console.log();
|
|
484
450
|
success(`${stopped} agent(s) stopped`);
|
|
485
451
|
}
|
|
@@ -489,28 +455,20 @@ async function stopAllAgents(projectDir) {
|
|
|
489
455
|
|
|
490
456
|
// ─── Agent Status ───────────────────────────────────────────
|
|
491
457
|
|
|
492
|
-
function
|
|
493
|
-
const pid = listPids(projectDir).find(p => p.name === name);
|
|
494
|
-
if (pid && isProcessAlive(pid.pid)) return { status: 'running', pid: pid.pid };
|
|
495
|
-
if (!isAgentEnabled(name, config)) return { status: 'disabled', pid: null };
|
|
496
|
-
return { status: 'stopped', pid: null };
|
|
497
|
-
}
|
|
498
|
-
|
|
499
|
-
function printAgentLine(name, agentMd, config, statusInfo) {
|
|
458
|
+
function printAgentLine(name, agentMd, config) {
|
|
500
459
|
const model = getEffectiveModel(name, agentMd.meta, config);
|
|
501
460
|
const schedule = (agentMd.meta.schedule || []).map(s => s.every).join(', ');
|
|
502
461
|
const modelStr = modelShortName(model);
|
|
462
|
+
const enabled = isAgentEnabled(name, config);
|
|
503
463
|
|
|
504
|
-
if (
|
|
505
|
-
console.log(` ${green('●')} ${bold(name)} ${dim(modelStr)} ${dim(schedule)} ${green(
|
|
506
|
-
} else if (statusInfo.status === 'disabled') {
|
|
507
|
-
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')}`);
|
|
508
466
|
} else {
|
|
509
|
-
console.log(` ${
|
|
467
|
+
console.log(` ${dim('○')} ${dim(name)} ${dim(modelStr)} ${dim(schedule)} ${dim('disabled')}`);
|
|
510
468
|
}
|
|
511
469
|
}
|
|
512
470
|
|
|
513
|
-
function printStatusDashboard(config, parsed
|
|
471
|
+
function printStatusDashboard(config, parsed) {
|
|
514
472
|
console.log(` ${bold(config.project.name)} ${dim(`— ${config.project.framework}`)}`);
|
|
515
473
|
console.log(` ${dim(`Dev: ${config.project.devCmd} · Test: ${config.project.testCmd}`)}`);
|
|
516
474
|
console.log();
|
|
@@ -518,8 +476,7 @@ function printStatusDashboard(config, parsed, projectDir) {
|
|
|
518
476
|
divider('Agents');
|
|
519
477
|
console.log();
|
|
520
478
|
for (const { name, agentMd } of parsed) {
|
|
521
|
-
|
|
522
|
-
printAgentLine(name, agentMd, config, statusInfo);
|
|
479
|
+
printAgentLine(name, agentMd, config);
|
|
523
480
|
}
|
|
524
481
|
console.log();
|
|
525
482
|
}
|
|
@@ -552,14 +509,13 @@ async function runModels(available) {
|
|
|
552
509
|
|
|
553
510
|
console.log();
|
|
554
511
|
|
|
555
|
-
// Show recommended models to install
|
|
556
512
|
if (available.recommended.length > 0) {
|
|
557
513
|
divider('Recommended Models');
|
|
558
514
|
console.log();
|
|
559
515
|
if (available.recommendedLive) {
|
|
560
516
|
success(`Fetched latest models from ${cyan('ollama.com/library')}`);
|
|
561
517
|
} else {
|
|
562
|
-
warn(
|
|
518
|
+
warn('Could not reach ollama.com — showing cached recommendations');
|
|
563
519
|
}
|
|
564
520
|
console.log();
|
|
565
521
|
for (const r of available.recommended) {
|
|
@@ -568,7 +524,6 @@ async function runModels(available) {
|
|
|
568
524
|
console.log();
|
|
569
525
|
}
|
|
570
526
|
|
|
571
|
-
// Status summary
|
|
572
527
|
if (!available.ollamaInstalled) {
|
|
573
528
|
console.log(` ${dim('Install Ollama for free local models:')}`);
|
|
574
529
|
console.log(` ${dim('$')} ${bold('curl -fsSL https://ollama.com/install.sh | sh')}`);
|
|
@@ -624,8 +579,7 @@ async function runConfigure(projectDir, config, soulPath) {
|
|
|
624
579
|
let soul = readFileSync(soulPath, 'utf8');
|
|
625
580
|
const ctx = [
|
|
626
581
|
`# ${config.project.name} — Persistent Development Agents`,
|
|
627
|
-
'',
|
|
628
|
-
`**Project:** ${config.project.name}`,
|
|
582
|
+
'', `**Project:** ${config.project.name}`,
|
|
629
583
|
`**Language:** ${config.project.language}`,
|
|
630
584
|
`**Framework:** ${config.project.framework}`,
|
|
631
585
|
`**Dev server:** \`${config.project.devCmd}\``,
|
|
@@ -652,7 +606,6 @@ async function runConfigure(projectDir, config, soulPath) {
|
|
|
652
606
|
|
|
653
607
|
// ─── Agents Flow ────────────────────────────────────────────
|
|
654
608
|
|
|
655
|
-
// Agent description summaries for the enable/disable flow
|
|
656
609
|
const AGENT_SUMMARIES = {
|
|
657
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.',
|
|
658
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.',
|
|
@@ -660,7 +613,6 @@ const AGENT_SUMMARIES = {
|
|
|
660
613
|
};
|
|
661
614
|
|
|
662
615
|
async function runAgents(projectDir, config, parsed, available) {
|
|
663
|
-
// Show system capabilities so users know what models they can run
|
|
664
616
|
const sys = detectSystemCapabilities();
|
|
665
617
|
|
|
666
618
|
divider('Your System');
|
|
@@ -682,11 +634,9 @@ async function runAgents(projectDir, config, parsed, available) {
|
|
|
682
634
|
const currentModel = existingAgent?.model || 'ollama/llama3.2';
|
|
683
635
|
const schedule = (agentMd.meta.schedule || []).map(s => s.every).join(', ');
|
|
684
636
|
|
|
685
|
-
// Show rich description
|
|
686
637
|
console.log(` ${bold(name)} ${dim(`(${schedule || 'manual'})`)}`);
|
|
687
638
|
const summary = AGENT_SUMMARIES[name] || agentMd.meta.description || '';
|
|
688
639
|
if (summary) {
|
|
689
|
-
// Word-wrap summary to ~70 chars, indented
|
|
690
640
|
const words = summary.split(' ');
|
|
691
641
|
let line = '';
|
|
692
642
|
for (const word of words) {
|
|
@@ -727,32 +677,80 @@ async function runAgents(projectDir, config, parsed, available) {
|
|
|
727
677
|
|
|
728
678
|
// ─── Start Flow ─────────────────────────────────────────────
|
|
729
679
|
|
|
730
|
-
|
|
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) {
|
|
731
689
|
if (!hasOpenClaw) {
|
|
732
690
|
error('Cannot launch agents — OpenClaw is not installed.');
|
|
733
|
-
console.log();
|
|
734
|
-
console.log(` ${dim('$')} ${bold('npm install -g openclaw@latest')}`);
|
|
691
|
+
console.log(` ${dim('$')} ${bold('npm install -g openclaw@latest')}`);
|
|
735
692
|
console.log();
|
|
736
693
|
return;
|
|
737
694
|
}
|
|
738
695
|
|
|
739
696
|
const enabledAgents = parsed.filter(a => isAgentEnabled(a.name, config));
|
|
740
|
-
const runningNames = new Set(listPids(projectDir).map(p => p.name));
|
|
741
|
-
const startable = enabledAgents.filter(a => !runningNames.has(a.name));
|
|
742
697
|
|
|
743
|
-
if (
|
|
744
|
-
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.')}`);
|
|
745
701
|
console.log();
|
|
746
702
|
return;
|
|
747
703
|
}
|
|
748
704
|
|
|
749
|
-
|
|
750
|
-
|
|
751
|
-
|
|
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.`);
|
|
752
750
|
console.log();
|
|
753
|
-
return;
|
|
754
751
|
}
|
|
755
752
|
|
|
753
|
+
// Confirm which agents to start
|
|
756
754
|
const rl = createInterface({ input: stdin, output: stdout });
|
|
757
755
|
const toStart = [];
|
|
758
756
|
|
|
@@ -760,7 +758,7 @@ async function runStart(projectDir, parsed, config, soulPath, hasOpenClaw) {
|
|
|
760
758
|
divider('Launch');
|
|
761
759
|
console.log();
|
|
762
760
|
|
|
763
|
-
for (const agent of
|
|
761
|
+
for (const agent of launchable) {
|
|
764
762
|
const model = getEffectiveModel(agent.name, agent.agentMd.meta, config);
|
|
765
763
|
const yes = await askYesNo(rl, `Start ${bold(agent.name)} ${dim(`(${modelShortName(model)})`)}?`, true);
|
|
766
764
|
if (yes) toStart.push(agent);
|
|
@@ -778,10 +776,50 @@ async function runStart(projectDir, parsed, config, soulPath, hasOpenClaw) {
|
|
|
778
776
|
|
|
779
777
|
console.log();
|
|
780
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
|
|
781
820
|
const sharedSoul = readFileSync(soulPath, 'utf8');
|
|
782
821
|
let registered = 0;
|
|
783
822
|
|
|
784
|
-
// Step 1: Generate workspaces and register agents with OpenClaw
|
|
785
823
|
for (const { name, agentMd } of toStart) {
|
|
786
824
|
try {
|
|
787
825
|
const effectiveModel = getEffectiveModel(name, agentMd.meta, config);
|
|
@@ -793,7 +831,7 @@ async function runStart(projectDir, parsed, config, soulPath, hasOpenClaw) {
|
|
|
793
831
|
const workspacePath = generateWorkspace(projectDir, name, overriddenMd, sharedSoul);
|
|
794
832
|
const absWorkspace = resolve(workspacePath);
|
|
795
833
|
|
|
796
|
-
// Register agent with OpenClaw
|
|
834
|
+
// Register agent with OpenClaw
|
|
797
835
|
try {
|
|
798
836
|
await execPromise('openclaw', [
|
|
799
837
|
'agents', 'add', name,
|
|
@@ -803,11 +841,13 @@ async function runStart(projectDir, parsed, config, soulPath, hasOpenClaw) {
|
|
|
803
841
|
]);
|
|
804
842
|
success(`Registered ${bold(name)} ${dim(`(${modelShortName(effectiveModel)})`)}`);
|
|
805
843
|
} catch (err) {
|
|
806
|
-
|
|
807
|
-
if (
|
|
808
|
-
|
|
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`);
|
|
809
848
|
} else {
|
|
810
|
-
warn(`Could not register ${bold(name)}: ${
|
|
849
|
+
warn(`Could not register ${bold(name)}: ${msg}`);
|
|
850
|
+
continue;
|
|
811
851
|
}
|
|
812
852
|
}
|
|
813
853
|
|
|
@@ -825,11 +865,10 @@ async function runStart(projectDir, parsed, config, soulPath, hasOpenClaw) {
|
|
|
825
865
|
'--light-context',
|
|
826
866
|
]);
|
|
827
867
|
} catch {
|
|
828
|
-
// Cron job may already exist
|
|
868
|
+
// Cron job may already exist
|
|
829
869
|
}
|
|
830
870
|
}
|
|
831
871
|
|
|
832
|
-
writePid(projectDir, name, -1); // marker
|
|
833
872
|
registered++;
|
|
834
873
|
} catch (err) {
|
|
835
874
|
error(`${bold(name)}: ${err.message}`);
|
|
@@ -843,46 +882,6 @@ async function runStart(projectDir, parsed, config, soulPath, hasOpenClaw) {
|
|
|
843
882
|
return;
|
|
844
883
|
}
|
|
845
884
|
|
|
846
|
-
console.log();
|
|
847
|
-
|
|
848
|
-
// Step 2: Check if gateway is running, start it if not
|
|
849
|
-
let gatewayRunning = false;
|
|
850
|
-
try {
|
|
851
|
-
const healthResp = await fetch('http://127.0.0.1:18789/health');
|
|
852
|
-
gatewayRunning = healthResp.ok;
|
|
853
|
-
} catch {
|
|
854
|
-
// not running
|
|
855
|
-
}
|
|
856
|
-
|
|
857
|
-
if (!gatewayRunning) {
|
|
858
|
-
info('Starting OpenClaw gateway...');
|
|
859
|
-
|
|
860
|
-
const method = openInTerminal(
|
|
861
|
-
'OpenClaw Gateway',
|
|
862
|
-
'openclaw gateway',
|
|
863
|
-
resolve(projectDir),
|
|
864
|
-
);
|
|
865
|
-
|
|
866
|
-
if (method) {
|
|
867
|
-
success(`Gateway opened in ${method}`);
|
|
868
|
-
} else {
|
|
869
|
-
// Fallback: start in background
|
|
870
|
-
const child = spawn('openclaw', ['gateway'], {
|
|
871
|
-
stdio: 'ignore',
|
|
872
|
-
detached: true,
|
|
873
|
-
cwd: resolve(projectDir),
|
|
874
|
-
});
|
|
875
|
-
child.unref();
|
|
876
|
-
success(`Gateway started in background ${dim(`(PID ${child.pid})`)}`);
|
|
877
|
-
}
|
|
878
|
-
|
|
879
|
-
// Give gateway a moment to start
|
|
880
|
-
console.log(` ${dim('Waiting for gateway...')}`);
|
|
881
|
-
await new Promise(r => setTimeout(r, 3000));
|
|
882
|
-
} else {
|
|
883
|
-
success('Gateway is already running');
|
|
884
|
-
}
|
|
885
|
-
|
|
886
885
|
console.log();
|
|
887
886
|
success(`${registered} agent(s) registered — fangs out 🐍`);
|
|
888
887
|
console.log();
|
|
@@ -906,21 +905,19 @@ export async function persistent({ stop = false, configure = false, agents = fal
|
|
|
906
905
|
|
|
907
906
|
printHeader();
|
|
908
907
|
|
|
909
|
-
// ──
|
|
910
|
-
if (stop) {
|
|
911
|
-
cleanStalePids(projectDir);
|
|
912
|
-
await stopAllAgents(projectDir);
|
|
913
|
-
return;
|
|
914
|
-
}
|
|
915
|
-
|
|
916
|
-
// ── Full preflight (checks workspace, agents, runtime) ──
|
|
908
|
+
// ── Full preflight (auto-creates .openclaw if missing) ──
|
|
917
909
|
const { soulPath, parsed, hasOpenClaw, available } = await preflight(projectDir);
|
|
918
|
-
cleanStalePids(projectDir);
|
|
919
910
|
|
|
920
911
|
// Load config
|
|
921
912
|
let config = readConfig(projectDir) || { project: {}, agents: {} };
|
|
922
913
|
const isConfigured = !!config._configured;
|
|
923
914
|
|
|
915
|
+
// ── --stop: stop all agents ──
|
|
916
|
+
if (stop) {
|
|
917
|
+
await stopAllAgents(projectDir, config, parsed);
|
|
918
|
+
return;
|
|
919
|
+
}
|
|
920
|
+
|
|
924
921
|
// ── --models: list installed and recommended models ──
|
|
925
922
|
if (models) {
|
|
926
923
|
await runModels(available);
|
|
@@ -942,25 +939,19 @@ export async function persistent({ stop = false, configure = false, agents = fal
|
|
|
942
939
|
|
|
943
940
|
// ── --start: launch agents ──
|
|
944
941
|
if (start) {
|
|
945
|
-
await runStart(projectDir, parsed, config, soulPath, hasOpenClaw);
|
|
942
|
+
await runStart(projectDir, parsed, config, soulPath, hasOpenClaw, available);
|
|
946
943
|
return;
|
|
947
944
|
}
|
|
948
945
|
|
|
949
946
|
// ── Bare `serpentstack persistent` ──
|
|
950
947
|
if (isConfigured) {
|
|
951
|
-
|
|
952
|
-
printStatusDashboard(config, parsed, projectDir);
|
|
948
|
+
printStatusDashboard(config, parsed);
|
|
953
949
|
|
|
954
950
|
const enabledAgents = parsed.filter(a => isAgentEnabled(a.name, config));
|
|
955
|
-
|
|
956
|
-
const startable = enabledAgents.filter(a => !runningNames.has(a.name));
|
|
957
|
-
|
|
958
|
-
if (startable.length === 0 && runningNames.size > 0) {
|
|
959
|
-
info('All enabled agents are running.');
|
|
960
|
-
} else if (startable.length === 0) {
|
|
951
|
+
if (enabledAgents.length === 0) {
|
|
961
952
|
info('No agents are enabled.');
|
|
962
953
|
} else {
|
|
963
|
-
info(`${
|
|
954
|
+
info(`${enabledAgents.length} agent(s) enabled.`);
|
|
964
955
|
}
|
|
965
956
|
|
|
966
957
|
console.log();
|
|
@@ -976,8 +967,6 @@ export async function persistent({ stop = false, configure = false, agents = fal
|
|
|
976
967
|
}
|
|
977
968
|
|
|
978
969
|
// ── First-time setup: guided walkthrough ──
|
|
979
|
-
|
|
980
|
-
// Step 0: Show runtime status
|
|
981
970
|
const canLaunch = printPreflightStatus(hasOpenClaw, available);
|
|
982
971
|
|
|
983
972
|
if (!canLaunch) {
|
|
@@ -985,11 +974,10 @@ export async function persistent({ stop = false, configure = false, agents = fal
|
|
|
985
974
|
console.log(` ${dim('$')} ${bold('serpentstack persistent')}`);
|
|
986
975
|
console.log();
|
|
987
976
|
|
|
988
|
-
// Still let them configure even without OpenClaw
|
|
989
977
|
const rl = createInterface({ input: stdin, output: stdout });
|
|
990
978
|
let proceed;
|
|
991
979
|
try {
|
|
992
|
-
proceed = await askYesNo(rl,
|
|
980
|
+
proceed = await askYesNo(rl, 'Continue with project configuration anyway?', true);
|
|
993
981
|
} finally {
|
|
994
982
|
rl.close();
|
|
995
983
|
}
|
|
@@ -1011,7 +999,7 @@ export async function persistent({ stop = false, configure = false, agents = fal
|
|
|
1011
999
|
|
|
1012
1000
|
// Step 3: Launch (only if OpenClaw is installed)
|
|
1013
1001
|
if (canLaunch) {
|
|
1014
|
-
await runStart(projectDir, parsed, config, soulPath, hasOpenClaw);
|
|
1002
|
+
await runStart(projectDir, parsed, config, soulPath, hasOpenClaw, available);
|
|
1015
1003
|
} else {
|
|
1016
1004
|
console.log();
|
|
1017
1005
|
info('Skipping launch — install OpenClaw first, then run:');
|