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.
@@ -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: Recommended models (not installed, auto-download on select)
248
- if (available.ollamaInstalled && available.recommended.length > 0) {
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(`fetched from ollama.com`)
263
+ ? dim(`live from ollama.com`)
251
264
  : dim(`cached list`);
252
- console.log(` ${dim('── Download')} ${cyan('free')} ${dim('(')}${liveTag}${dim(') ──')}`);
253
- // Show a reasonable subset (not 50 models)
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, pull it now
332
+ // If they selected a downloadable model, handle Ollama install + pull
320
333
  if (selected.action === 'download') {
321
- // Close rl temporarily so ollama pull can use the terminal
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
- process.kill(pid, 'SIGTERM');
418
- removePid(projectDir, name);
419
- cleanWorkspace(projectDir, name);
420
- success(`Stopped ${bold(name)} ${dim(`(PID ${pid})`)}`);
421
- stopped++;
422
- } catch (err) {
423
- if (err.code === 'ESRCH') {
424
- removePid(projectDir, name);
425
- } else {
426
- error(`Failed to stop ${bold(name)}: ${err.message}`);
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('Enable/disable each agent and pick a model.')}`);
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
- console.log(` ${bold(name)} ${dim(agentMd.meta.description || '')}`);
619
- console.log(` ${dim(`Schedule: ${schedule || 'none'}`)}`);
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 started = 0;
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
- const openclawCmd = `OPENCLAW_STATE_DIR='${join(absWorkspace, '.state')}' openclaw start --workspace '${absWorkspace}'`;
715
- const method = openInTerminal(`SerpentStack: ${name}`, openclawCmd, absProject);
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
- if (method) {
718
- writePid(projectDir, name, -1);
719
- success(`${bold(name)} opened in ${method} ${dim(`(${modelShortName(effectiveModel)})`)}`);
720
- started++;
721
- } else {
722
- warn(`No terminal detected — starting ${bold(name)} in background`);
723
- const child = spawn('openclaw', ['start', '--workspace', absWorkspace], {
724
- stdio: 'ignore',
725
- detached: true,
726
- cwd: absProject,
727
- env: { ...process.env, OPENCLAW_STATE_DIR: join(absWorkspace, '.state') },
728
- });
729
- child.unref();
730
- writePid(projectDir, name, child.pid);
731
- success(`${bold(name)} started ${dim(`PID ${child.pid}`)}`);
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
- console.log();
740
- if (started > 0) {
741
- success(`${started} agent(s) launched — fangs out 🐍`);
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
 
@@ -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}── ${RESET}${BOLD}${label}${RESET} ${DIM}${'─'.repeat(Math.max(0, 50 - stripAnsi(label).length))}${RESET}`);
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
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "serpentstack",
3
- "version": "0.2.14",
3
+ "version": "0.2.16",
4
4
  "description": "CLI for SerpentStack — AI-driven development standards with project-specific skills and persistent agents",
5
5
  "type": "module",
6
6
  "bin": {