serpentstack 0.2.14 → 0.2.16
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 +200 -47
- package/lib/utils/models.js +33 -0
- package/lib/utils/ui.js +1 -1
- package/package.json +1 -1
|
@@ -23,7 +23,7 @@ import {
|
|
|
23
23
|
getEffectiveModel,
|
|
24
24
|
isAgentEnabled,
|
|
25
25
|
} from '../utils/config.js';
|
|
26
|
-
import { detectModels, modelShortName } from '../utils/models.js';
|
|
26
|
+
import { detectModels, modelShortName, detectSystemCapabilities } from '../utils/models.js';
|
|
27
27
|
|
|
28
28
|
// ─── Helpers ────────────────────────────────────────────────
|
|
29
29
|
|
|
@@ -33,6 +33,19 @@ function which(cmd) {
|
|
|
33
33
|
});
|
|
34
34
|
}
|
|
35
35
|
|
|
36
|
+
function execPromise(cmd, args) {
|
|
37
|
+
return new Promise((resolve, reject) => {
|
|
38
|
+
execFile(cmd, args, { timeout: 15000 }, (err, stdout, stderr) => {
|
|
39
|
+
if (err) {
|
|
40
|
+
const msg = stderr?.trim() || stdout?.trim() || err.message;
|
|
41
|
+
reject(new Error(msg));
|
|
42
|
+
} else {
|
|
43
|
+
resolve(stdout);
|
|
44
|
+
}
|
|
45
|
+
});
|
|
46
|
+
});
|
|
47
|
+
}
|
|
48
|
+
|
|
36
49
|
async function ask(rl, label, defaultValue) {
|
|
37
50
|
const hint = defaultValue ? ` ${dim(`[${defaultValue}]`)}` : '';
|
|
38
51
|
const answer = await rl.question(` ${green('?')} ${bold(label)}${hint}: `);
|
|
@@ -244,13 +257,13 @@ async function pickModel(rl, agentName, currentModel, available) {
|
|
|
244
257
|
}
|
|
245
258
|
}
|
|
246
259
|
|
|
247
|
-
// Section 2:
|
|
248
|
-
if (available.
|
|
260
|
+
// Section 2: Downloadable models — always shown if we have recommendations
|
|
261
|
+
if (available.recommended.length > 0) {
|
|
249
262
|
const liveTag = available.recommendedLive
|
|
250
|
-
? dim(`
|
|
263
|
+
? dim(`live from ollama.com`)
|
|
251
264
|
: dim(`cached list`);
|
|
252
|
-
|
|
253
|
-
|
|
265
|
+
const needsOllama = !available.ollamaInstalled ? dim(' · requires Ollama') : '';
|
|
266
|
+
console.log(` ${dim('── Download')} ${cyan('free')} ${dim('(')}${liveTag}${needsOllama}${dim(') ──')}`);
|
|
254
267
|
const toShow = available.recommended.slice(0, 8);
|
|
255
268
|
for (const r of toShow) {
|
|
256
269
|
const idx = choices.length;
|
|
@@ -316,9 +329,33 @@ async function pickModel(rl, agentName, currentModel, available) {
|
|
|
316
329
|
|
|
317
330
|
const selected = (idx >= 0 && idx < choices.length) ? choices[idx] : choices[Math.max(0, currentIdx)];
|
|
318
331
|
|
|
319
|
-
// If they selected a downloadable model,
|
|
332
|
+
// If they selected a downloadable model, handle Ollama install + pull
|
|
320
333
|
if (selected.action === 'download') {
|
|
321
|
-
|
|
334
|
+
if (!available.ollamaInstalled) {
|
|
335
|
+
console.log();
|
|
336
|
+
warn('Ollama is required to run local models.');
|
|
337
|
+
console.log();
|
|
338
|
+
console.log(` ${dim('Install Ollama (free, open-source):')}`);
|
|
339
|
+
console.log(` ${dim('$')} ${bold('curl -fsSL https://ollama.com/install.sh | sh')}`);
|
|
340
|
+
console.log(` ${dim('$')} ${bold('ollama serve')}`);
|
|
341
|
+
console.log();
|
|
342
|
+
info(`After installing, re-run ${bold('serpentstack persistent --agents')} to download and select ${bold(selected.name)}.`);
|
|
343
|
+
console.log();
|
|
344
|
+
|
|
345
|
+
// Save the selection anyway so it's remembered
|
|
346
|
+
return selected.id;
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
if (!available.ollamaRunning) {
|
|
350
|
+
console.log();
|
|
351
|
+
warn('Ollama is installed but not running.');
|
|
352
|
+
console.log(` ${dim('$')} ${bold('ollama serve')}`);
|
|
353
|
+
console.log();
|
|
354
|
+
info(`Start Ollama, then re-run ${bold('serpentstack persistent --agents')} to download ${bold(selected.name)}.`);
|
|
355
|
+
console.log();
|
|
356
|
+
return selected.id;
|
|
357
|
+
}
|
|
358
|
+
|
|
322
359
|
rl.pause();
|
|
323
360
|
const pulled = await ollamaPull(selected.name);
|
|
324
361
|
rl.resume();
|
|
@@ -401,7 +438,7 @@ end tell`;
|
|
|
401
438
|
|
|
402
439
|
// ─── Stop Flow ──────────────────────────────────────────────
|
|
403
440
|
|
|
404
|
-
function stopAllAgents(projectDir) {
|
|
441
|
+
async function stopAllAgents(projectDir) {
|
|
405
442
|
cleanStalePids(projectDir);
|
|
406
443
|
const running = listPids(projectDir);
|
|
407
444
|
|
|
@@ -413,19 +450,31 @@ function stopAllAgents(projectDir) {
|
|
|
413
450
|
|
|
414
451
|
let stopped = 0;
|
|
415
452
|
for (const { name, pid } of running) {
|
|
453
|
+
// Remove cron jobs for this agent
|
|
416
454
|
try {
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
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 */ }
|
|
464
|
+
|
|
465
|
+
// Remove agent from OpenClaw
|
|
466
|
+
try {
|
|
467
|
+
await execPromise('openclaw', ['agents', 'delete', name, '--force']);
|
|
468
|
+
} catch { /* best-effort */ }
|
|
469
|
+
|
|
470
|
+
// Clean up PID and workspace
|
|
471
|
+
if (pid > 0) {
|
|
472
|
+
try { process.kill(pid, 'SIGTERM'); } catch { /* already dead */ }
|
|
428
473
|
}
|
|
474
|
+
removePid(projectDir, name);
|
|
475
|
+
cleanWorkspace(projectDir, name);
|
|
476
|
+
success(`Stopped ${bold(name)}`);
|
|
477
|
+
stopped++;
|
|
429
478
|
}
|
|
430
479
|
|
|
431
480
|
if (stopped > 0) {
|
|
@@ -601,9 +650,25 @@ async function runConfigure(projectDir, config, soulPath) {
|
|
|
601
650
|
|
|
602
651
|
// ─── Agents Flow ────────────────────────────────────────────
|
|
603
652
|
|
|
653
|
+
// Agent description summaries for the enable/disable flow
|
|
654
|
+
const AGENT_SUMMARIES = {
|
|
655
|
+
'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
|
+
'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.',
|
|
657
|
+
'skill-maintainer': 'Checks every hour whether your .skills/ files still match the actual codebase. When code patterns drift from what skills describe, it proposes exact updates so IDE agents stay accurate.',
|
|
658
|
+
};
|
|
659
|
+
|
|
604
660
|
async function runAgents(projectDir, config, parsed, available) {
|
|
661
|
+
// Show system capabilities so users know what models they can run
|
|
662
|
+
const sys = detectSystemCapabilities();
|
|
663
|
+
|
|
664
|
+
divider('Your System');
|
|
665
|
+
console.log(` ${dim('RAM:')} ${bold(sys.totalGB + ' GB')} total, ${sys.freeGB} GB free`);
|
|
666
|
+
console.log(` ${dim(sys.recommendation)}`);
|
|
667
|
+
console.log();
|
|
668
|
+
|
|
605
669
|
divider('Agents');
|
|
606
|
-
console.log(` ${dim('
|
|
670
|
+
console.log(` ${dim('Each agent runs in its own terminal on a schedule.')}`);
|
|
671
|
+
console.log(` ${dim('Enable the ones you want, then pick a model for each.')}`);
|
|
607
672
|
console.log();
|
|
608
673
|
|
|
609
674
|
const rl = createInterface({ input: stdin, output: stdout });
|
|
@@ -615,8 +680,23 @@ async function runAgents(projectDir, config, parsed, available) {
|
|
|
615
680
|
const currentModel = existingAgent?.model || 'ollama/llama3.2';
|
|
616
681
|
const schedule = (agentMd.meta.schedule || []).map(s => s.every).join(', ');
|
|
617
682
|
|
|
618
|
-
|
|
619
|
-
console.log(`
|
|
683
|
+
// Show rich description
|
|
684
|
+
console.log(` ${bold(name)} ${dim(`(${schedule || 'manual'})`)}`);
|
|
685
|
+
const summary = AGENT_SUMMARIES[name] || agentMd.meta.description || '';
|
|
686
|
+
if (summary) {
|
|
687
|
+
// Word-wrap summary to ~70 chars, indented
|
|
688
|
+
const words = summary.split(' ');
|
|
689
|
+
let line = '';
|
|
690
|
+
for (const word of words) {
|
|
691
|
+
if (line.length + word.length + 1 > 68) {
|
|
692
|
+
console.log(` ${dim(line)}`);
|
|
693
|
+
line = word;
|
|
694
|
+
} else {
|
|
695
|
+
line = line ? `${line} ${word}` : word;
|
|
696
|
+
}
|
|
697
|
+
}
|
|
698
|
+
if (line) console.log(` ${dim(line)}`);
|
|
699
|
+
}
|
|
620
700
|
|
|
621
701
|
const enabled = await askYesNo(rl, `Enable ${bold(name)}?`, currentEnabled);
|
|
622
702
|
|
|
@@ -697,8 +777,9 @@ async function runStart(projectDir, parsed, config, soulPath, hasOpenClaw) {
|
|
|
697
777
|
console.log();
|
|
698
778
|
|
|
699
779
|
const sharedSoul = readFileSync(soulPath, 'utf8');
|
|
700
|
-
let
|
|
780
|
+
let registered = 0;
|
|
701
781
|
|
|
782
|
+
// Step 1: Generate workspaces and register agents with OpenClaw
|
|
702
783
|
for (const { name, agentMd } of toStart) {
|
|
703
784
|
try {
|
|
704
785
|
const effectiveModel = getEffectiveModel(name, agentMd.meta, config);
|
|
@@ -709,38 +790,110 @@ async function runStart(projectDir, parsed, config, soulPath, hasOpenClaw) {
|
|
|
709
790
|
|
|
710
791
|
const workspacePath = generateWorkspace(projectDir, name, overriddenMd, sharedSoul);
|
|
711
792
|
const absWorkspace = resolve(workspacePath);
|
|
712
|
-
const absProject = resolve(projectDir);
|
|
713
793
|
|
|
714
|
-
|
|
715
|
-
|
|
794
|
+
// Register agent with OpenClaw (idempotent — will update if exists)
|
|
795
|
+
try {
|
|
796
|
+
await execPromise('openclaw', [
|
|
797
|
+
'agents', 'add', name,
|
|
798
|
+
'--workspace', absWorkspace,
|
|
799
|
+
'--model', effectiveModel,
|
|
800
|
+
'--non-interactive',
|
|
801
|
+
]);
|
|
802
|
+
success(`${green('✓')} Registered ${bold(name)} ${dim(`(${modelShortName(effectiveModel)})`)}`);
|
|
803
|
+
} catch (err) {
|
|
804
|
+
// Agent may already exist — that's fine
|
|
805
|
+
if (err.message && err.message.includes('already exists')) {
|
|
806
|
+
info(`${bold(name)} already registered with OpenClaw`);
|
|
807
|
+
} else {
|
|
808
|
+
warn(`Could not register ${bold(name)}: ${err.message || 'unknown error'}`);
|
|
809
|
+
}
|
|
810
|
+
}
|
|
716
811
|
|
|
717
|
-
|
|
718
|
-
|
|
719
|
-
|
|
720
|
-
|
|
721
|
-
|
|
722
|
-
|
|
723
|
-
|
|
724
|
-
|
|
725
|
-
|
|
726
|
-
|
|
727
|
-
|
|
728
|
-
|
|
729
|
-
|
|
730
|
-
|
|
731
|
-
|
|
732
|
-
started++;
|
|
812
|
+
// Add cron jobs for the agent's schedule
|
|
813
|
+
const schedules = agentMd.meta.schedule || [];
|
|
814
|
+
for (const sched of schedules) {
|
|
815
|
+
try {
|
|
816
|
+
await execPromise('openclaw', [
|
|
817
|
+
'cron', 'add',
|
|
818
|
+
'--agent', name,
|
|
819
|
+
'--every', sched.every,
|
|
820
|
+
'--message', `Run task: ${sched.task}`,
|
|
821
|
+
'--name', `${name}-${sched.task}`,
|
|
822
|
+
'--light-context',
|
|
823
|
+
]);
|
|
824
|
+
} catch {
|
|
825
|
+
// Cron job may already exist — non-fatal
|
|
826
|
+
}
|
|
733
827
|
}
|
|
828
|
+
|
|
829
|
+
writePid(projectDir, name, -1); // marker
|
|
830
|
+
registered++;
|
|
734
831
|
} catch (err) {
|
|
735
832
|
error(`${bold(name)}: ${err.message}`);
|
|
736
833
|
}
|
|
737
834
|
}
|
|
738
835
|
|
|
739
|
-
|
|
740
|
-
|
|
741
|
-
|
|
836
|
+
if (registered === 0) {
|
|
837
|
+
console.log();
|
|
838
|
+
error('No agents were registered.');
|
|
742
839
|
console.log();
|
|
840
|
+
return;
|
|
743
841
|
}
|
|
842
|
+
|
|
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
|
+
console.log();
|
|
884
|
+
success(`${registered} agent(s) registered — fangs out 🐍`);
|
|
885
|
+
console.log();
|
|
886
|
+
|
|
887
|
+
printBox('Your agents are running', [
|
|
888
|
+
`${dim('The OpenClaw gateway manages your agents on their schedules.')}`,
|
|
889
|
+
`${dim('View agent activity with:')}`,
|
|
890
|
+
'',
|
|
891
|
+
`${dim('$')} ${bold('openclaw tui')} ${dim('# interactive terminal UI')}`,
|
|
892
|
+
`${dim('$')} ${bold('openclaw cron list')} ${dim('# see scheduled tasks')}`,
|
|
893
|
+
`${dim('$')} ${bold('openclaw agents list')} ${dim('# see registered agents')}`,
|
|
894
|
+
`${dim('$')} ${bold('serpentstack persistent --stop')} ${dim('# stop all agents')}`,
|
|
895
|
+
]);
|
|
896
|
+
console.log();
|
|
744
897
|
}
|
|
745
898
|
|
|
746
899
|
// ─── Main Entry Point ───────────────────────────────────────
|
|
@@ -753,7 +906,7 @@ export async function persistent({ stop = false, configure = false, agents = fal
|
|
|
753
906
|
// ── Stop (doesn't need full preflight) ──
|
|
754
907
|
if (stop) {
|
|
755
908
|
cleanStalePids(projectDir);
|
|
756
|
-
stopAllAgents(projectDir);
|
|
909
|
+
await stopAllAgents(projectDir);
|
|
757
910
|
return;
|
|
758
911
|
}
|
|
759
912
|
|
package/lib/utils/models.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { execFile } from 'node:child_process';
|
|
2
|
+
import { freemem, totalmem } from 'node:os';
|
|
2
3
|
|
|
3
4
|
// ─── Fallback Recommendations ───────────────────────────────
|
|
4
5
|
// Used only when the Ollama library API is unreachable.
|
|
@@ -273,6 +274,38 @@ export function modelShortName(model) {
|
|
|
273
274
|
return model;
|
|
274
275
|
}
|
|
275
276
|
|
|
277
|
+
/**
|
|
278
|
+
* Detect system capabilities for model recommendations.
|
|
279
|
+
* Returns { totalRAM, freeRAM, maxModelSize, recommendation }.
|
|
280
|
+
*/
|
|
281
|
+
export function detectSystemCapabilities() {
|
|
282
|
+
const total = totalmem();
|
|
283
|
+
const free = freemem();
|
|
284
|
+
const totalGB = total / (1024 ** 3);
|
|
285
|
+
const freeGB = free / (1024 ** 3);
|
|
286
|
+
|
|
287
|
+
// Ollama needs ~2GB overhead; model needs to fit in remaining RAM
|
|
288
|
+
const availableForModel = Math.max(0, freeGB - 2);
|
|
289
|
+
|
|
290
|
+
let recommendation;
|
|
291
|
+
if (totalGB >= 32) {
|
|
292
|
+
recommendation = 'Your system can handle large models (up to 24B parameters)';
|
|
293
|
+
} else if (totalGB >= 16) {
|
|
294
|
+
recommendation = 'Good for medium models (up to 8B parameters)';
|
|
295
|
+
} else if (totalGB >= 8) {
|
|
296
|
+
recommendation = 'Best with small models (3B–4B parameters)';
|
|
297
|
+
} else {
|
|
298
|
+
recommendation = 'Limited RAM — use cloud models or very small local models';
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
return {
|
|
302
|
+
totalGB: totalGB.toFixed(0),
|
|
303
|
+
freeGB: freeGB.toFixed(1),
|
|
304
|
+
availableGB: availableForModel.toFixed(1),
|
|
305
|
+
recommendation,
|
|
306
|
+
};
|
|
307
|
+
}
|
|
308
|
+
|
|
276
309
|
function execAsync(cmd, args) {
|
|
277
310
|
return new Promise((resolve, reject) => {
|
|
278
311
|
execFile(cmd, args, { timeout: 5000 }, (err, stdout) => {
|
package/lib/utils/ui.js
CHANGED
|
@@ -94,7 +94,7 @@ export function printHeader() {
|
|
|
94
94
|
|
|
95
95
|
export function divider(label) {
|
|
96
96
|
if (label) {
|
|
97
|
-
console.log(` ${DIM}
|
|
97
|
+
console.log(` ${DIM}──${RESET} ${GREEN}${BOLD}${label}${RESET} ${DIM}${'─'.repeat(Math.max(0, 50 - stripAnsi(label).length))}${RESET}`);
|
|
98
98
|
} else {
|
|
99
99
|
console.log(` ${DIM}${'─'.repeat(54)}${RESET}`);
|
|
100
100
|
}
|