serpentstack 0.2.11 → 0.2.13

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.
@@ -68,9 +68,12 @@ function showHelp() {
68
68
  divider('Any project');
69
69
  console.log(` ${cyan('skills')} Download all skills + persistent agent configs`);
70
70
  console.log(` ${cyan('skills update')} Update base skills to latest versions`);
71
- console.log(` ${cyan('persistent')} Manage and launch persistent background agents`);
71
+ console.log(` ${cyan('persistent')} Status dashboard (first run = full setup)`);
72
+ console.log(` ${cyan('persistent')} ${dim('--configure')} Edit project settings`);
73
+ console.log(` ${cyan('persistent')} ${dim('--agents')} Change agent models, enable/disable`);
74
+ console.log(` ${cyan('persistent')} ${dim('--models')} List installed & recommended models`);
75
+ console.log(` ${cyan('persistent')} ${dim('--start')} Launch enabled agents`);
72
76
  console.log(` ${cyan('persistent')} ${dim('--stop')} Stop all running agents`);
73
- console.log(` ${cyan('persistent')} ${dim('--reconfigure')} Change models, enable/disable agents`);
74
77
  console.log();
75
78
 
76
79
  divider('Options');
@@ -134,7 +137,13 @@ async function main() {
134
137
  }
135
138
  } else if (noun === 'persistent') {
136
139
  const { persistent } = await import('../lib/commands/persistent.js');
137
- await persistent({ stop: !!flags.stop, reconfigure: !!flags.reconfigure });
140
+ await persistent({
141
+ stop: !!flags.stop,
142
+ configure: !!flags.configure,
143
+ agents: !!flags.agents,
144
+ start: !!flags.start,
145
+ models: !!flags.models,
146
+ });
138
147
  } else {
139
148
  error(`Unknown command: ${bold(noun)}`);
140
149
  const suggestion = suggestCommand(noun);
@@ -3,7 +3,7 @@ import { join, resolve } from 'node:path';
3
3
  import { execFile, spawn } from 'node:child_process';
4
4
  import { createInterface } from 'node:readline/promises';
5
5
  import { stdin, stdout } from 'node:process';
6
- import { info, success, warn, error, bold, dim, green, cyan, yellow, red, divider, printBox, printHeader } from '../utils/ui.js';
6
+ import { info, success, warn, error, bold, dim, green, cyan, yellow, red, divider, printBox, printHeader, spinner } from '../utils/ui.js';
7
7
  import {
8
8
  parseAgentMd,
9
9
  discoverAgents,
@@ -47,12 +47,139 @@ async function askYesNo(rl, label, defaultYes = true) {
47
47
  return val === 'y' || val === 'yes';
48
48
  }
49
49
 
50
+ // ─── Preflight ──────────────────────────────────────────────
51
+
52
+ /**
53
+ * Check all prerequisites and return a status object.
54
+ * Exits the process with helpful guidance if anything critical is missing.
55
+ */
56
+ async function preflight(projectDir) {
57
+ const soulPath = join(projectDir, '.openclaw/SOUL.md');
58
+
59
+ // Check for .openclaw workspace
60
+ if (!existsSync(soulPath)) {
61
+ error('No .openclaw/ workspace found.');
62
+ console.log();
63
+ console.log(` ${dim('Run')} ${bold('serpentstack skills')} ${dim('first to download skills and agent configs.')}`);
64
+ console.log();
65
+ process.exit(1);
66
+ }
67
+
68
+ // Check for agent definitions
69
+ const agentDirs = discoverAgents(projectDir);
70
+ if (agentDirs.length === 0) {
71
+ error('No agents found in .openclaw/agents/');
72
+ console.log();
73
+ console.log(` ${dim('Run')} ${bold('serpentstack skills')} ${dim('to download the default agents,')}`);
74
+ console.log(` ${dim('or create your own at')} ${bold('.openclaw/agents/<name>/AGENT.md')}`);
75
+ console.log();
76
+ process.exit(1);
77
+ }
78
+
79
+ // Parse agent definitions
80
+ const parsed = [];
81
+ for (const agent of agentDirs) {
82
+ try {
83
+ const agentMd = parseAgentMd(agent.agentMdPath);
84
+ parsed.push({ ...agent, agentMd });
85
+ } catch (err) {
86
+ warn(`Skipping ${bold(agent.name)}: ${err.message}`);
87
+ }
88
+ }
89
+ if (parsed.length === 0) {
90
+ error('No valid AGENT.md files found.');
91
+ console.log();
92
+ process.exit(1);
93
+ }
94
+
95
+ // Detect runtime dependencies in parallel
96
+ const spin = spinner('Checking runtime...');
97
+ const [hasOpenClaw, available] = await Promise.all([
98
+ which('openclaw'),
99
+ detectModels(),
100
+ ]);
101
+ spin.stop();
102
+
103
+ return { soulPath, parsed, hasOpenClaw, available };
104
+ }
105
+
106
+ /**
107
+ * Print a summary of what's installed and what's missing.
108
+ * Returns true if everything needed to launch is present.
109
+ */
110
+ function printPreflightStatus(hasOpenClaw, available) {
111
+ divider('Runtime');
112
+ console.log();
113
+
114
+ // OpenClaw
115
+ if (hasOpenClaw) {
116
+ console.log(` ${green('✓')} OpenClaw ${dim('— persistent agent runtime')}`);
117
+ } else {
118
+ console.log(` ${red('✗')} OpenClaw ${dim('— not installed')}`);
119
+ }
120
+
121
+ // Ollama
122
+ if (available.ollamaRunning) {
123
+ console.log(` ${green('✓')} Ollama ${dim(`— running, ${available.local.length} model(s) installed`)}`);
124
+ } else if (available.ollamaInstalled) {
125
+ console.log(` ${yellow('△')} Ollama ${dim('— installed but not running')}`);
126
+ } else {
127
+ console.log(` ${yellow('○')} Ollama ${dim('— not installed (optional, for free local models)')}`);
128
+ }
129
+
130
+ // API key
131
+ if (available.hasApiKey) {
132
+ console.log(` ${green('✓')} API key ${dim('— configured for cloud models')}`);
133
+ }
134
+
135
+ console.log();
136
+
137
+ // Actionable guidance for missing pieces
138
+ const issues = [];
139
+
140
+ if (!hasOpenClaw) {
141
+ issues.push({
142
+ label: 'Install OpenClaw (required to run agents)',
143
+ cmd: 'npm install -g openclaw@latest',
144
+ });
145
+ }
146
+
147
+ if (!available.ollamaInstalled) {
148
+ issues.push({
149
+ label: 'Install Ollama for free local models (recommended)',
150
+ cmd: 'curl -fsSL https://ollama.com/install.sh | sh',
151
+ });
152
+ } else if (!available.ollamaRunning) {
153
+ issues.push({
154
+ label: 'Start Ollama',
155
+ cmd: 'ollama serve',
156
+ });
157
+ }
158
+
159
+ if (available.ollamaRunning && available.local.length === 0) {
160
+ issues.push({
161
+ label: 'Pull a model (Ollama is running but has no models)',
162
+ cmd: 'ollama pull llama3.2',
163
+ });
164
+ }
165
+
166
+ if (issues.length > 0) {
167
+ for (const issue of issues) {
168
+ console.log(` ${dim(issue.label + ':')}`);
169
+ console.log(` ${dim('$')} ${bold(issue.cmd)}`);
170
+ console.log();
171
+ }
172
+ }
173
+
174
+ return hasOpenClaw;
175
+ }
176
+
50
177
  // ─── Model Picker ───────────────────────────────────────────
51
178
 
52
179
  async function pickModel(rl, agentName, currentModel, available) {
53
180
  const choices = [];
54
181
 
55
- // Local models first (free, fast, recommended)
182
+ // Local models first
56
183
  if (available.local.length > 0) {
57
184
  console.log(` ${dim('── Local')} ${green('free')} ${dim('──────────────────────')}`);
58
185
  for (const m of available.local) {
@@ -65,12 +192,12 @@ async function pickModel(rl, agentName, currentModel, available) {
65
192
  const params = m.params ? dim(` ${m.params}`) : '';
66
193
  const quant = m.quant ? dim(` ${m.quant}`) : '';
67
194
  const size = m.size ? dim(` (${m.size})`) : '';
68
- const tag = isCurrent ? green(' \u2190 current') : '';
195
+ const tag = isCurrent ? green(' current') : '';
69
196
  console.log(` ${marker} ${num} ${label}${params}${quant}${size}${tag}`);
70
197
  }
71
198
  }
72
199
 
73
- // Cloud models (require API key, cost money)
200
+ // Cloud models
74
201
  if (available.cloud.length > 0) {
75
202
  const apiNote = available.hasApiKey ? green('key ✓') : yellow('needs API key');
76
203
  console.log(` ${dim('── Cloud')} ${apiNote} ${dim('─────────────────────')}`);
@@ -82,15 +209,20 @@ async function pickModel(rl, agentName, currentModel, available) {
82
209
  const num = dim(`${idx + 1}.`);
83
210
  const label = isCurrent ? bold(m.name) : m.name;
84
211
  const provider = m.provider ? dim(` (${m.provider})`) : '';
85
- const tag = isCurrent ? green(' \u2190 current') : '';
212
+ const tag = isCurrent ? green(' current') : '';
86
213
  console.log(` ${marker} ${num} ${label}${provider}${tag}`);
87
214
  }
88
215
  }
89
216
 
217
+ if (choices.length === 0) {
218
+ warn('No models available. Install Ollama and pull a model first.');
219
+ console.log(` ${dim('$')} ${bold('ollama pull llama3.2')}`);
220
+ return currentModel;
221
+ }
222
+
90
223
  // If current model isn't in either list, add it
91
224
  if (!choices.some(c => c.id === currentModel)) {
92
225
  choices.unshift({ id: currentModel, name: modelShortName(currentModel), tier: 'custom' });
93
- // Re-render isn't needed since we'll just note it
94
226
  console.log(` ${dim(`Current: ${modelShortName(currentModel)} (not in detected models)`)}`);
95
227
  }
96
228
 
@@ -102,9 +234,8 @@ async function pickModel(rl, agentName, currentModel, available) {
102
234
 
103
235
  const selected = (idx >= 0 && idx < choices.length) ? choices[idx] : choices[Math.max(0, currentIdx)];
104
236
 
105
- // Warn about cloud model costs
106
237
  if (selected.tier === 'cloud' && available.local.length > 0) {
107
- warn(`Cloud models cost tokens per heartbeat cycle. Consider a local model for persistent agents.`);
238
+ warn(`Cloud models cost tokens per heartbeat. Consider a local model for persistent agents.`);
108
239
  }
109
240
  if (selected.tier === 'cloud' && !available.hasApiKey) {
110
241
  warn(`No API key detected. Run ${bold('openclaw configure')} to set up authentication.`);
@@ -157,9 +288,7 @@ end tell`;
157
288
  try {
158
289
  const child = spawn(bin, args, { stdio: 'ignore', detached: true });
159
290
  child.unref();
160
- // Verify it didn't immediately fail
161
- const alive = child.pid && !child.killed;
162
- if (alive) return bin;
291
+ if (child.pid && !child.killed) return bin;
163
292
  } catch { continue; }
164
293
  }
165
294
  }
@@ -197,7 +326,6 @@ function stopAllAgents(projectDir) {
197
326
  } catch (err) {
198
327
  if (err.code === 'ESRCH') {
199
328
  removePid(projectDir, name);
200
- // Don't count already-dead processes as "stopped"
201
329
  } else {
202
330
  error(`Failed to stop ${bold(name)}: ${err.message}`);
203
331
  }
@@ -235,124 +363,85 @@ function printAgentLine(name, agentMd, config, statusInfo) {
235
363
  }
236
364
  }
237
365
 
238
- // ─── Main Flow ──────────────────────────────────────────────
239
-
240
- export async function persistent({ stop = false, reconfigure = false } = {}) {
241
- const projectDir = process.cwd();
242
-
243
- printHeader();
244
-
245
- // ── Stop ──
246
- if (stop) {
247
- stopAllAgents(projectDir);
248
- return;
249
- }
250
-
251
- // ── Preflight checks ──
252
- const soulPath = join(projectDir, '.openclaw/SOUL.md');
253
- if (!existsSync(soulPath)) {
254
- error('No .openclaw/ workspace found.');
255
- console.log(` Run ${bold('serpentstack skills')} first to download the workspace files.`);
256
- console.log();
257
- process.exit(1);
258
- }
366
+ function printStatusDashboard(config, parsed, projectDir) {
367
+ console.log(` ${bold(config.project.name)} ${dim(`— ${config.project.framework}`)}`);
368
+ console.log(` ${dim(`Dev: ${config.project.devCmd} · Test: ${config.project.testCmd}`)}`);
369
+ console.log();
259
370
 
260
- const agents = discoverAgents(projectDir);
261
- if (agents.length === 0) {
262
- error('No agents found in .openclaw/agents/');
263
- console.log(` Run ${bold('serpentstack skills')} to download the default agents,`);
264
- console.log(` or create your own at ${bold('.openclaw/agents/<name>/AGENT.md')}`);
265
- console.log();
266
- process.exit(1);
371
+ divider('Agents');
372
+ console.log();
373
+ for (const { name, agentMd } of parsed) {
374
+ const statusInfo = getAgentStatus(projectDir, name, config);
375
+ printAgentLine(name, agentMd, config, statusInfo);
267
376
  }
377
+ console.log();
378
+ }
268
379
 
269
- // Check OpenClaw early — don't waste time configuring if it's missing
270
- const hasOpenClaw = await which('openclaw');
271
- if (!hasOpenClaw) {
272
- warn('OpenClaw is not installed.');
273
- console.log();
274
- console.log(` ${dim('OpenClaw is the persistent agent runtime.')}`);
275
- console.log(` ${dim('Install it first, then re-run this command:')}`);
276
- console.log();
277
- console.log(` ${dim('$')} ${bold('npm install -g openclaw@latest')}`);
278
- console.log(` ${dim('$')} ${bold('serpentstack persistent')}`);
279
- console.log();
280
- process.exit(1);
281
- }
380
+ // ─── Models Command ─────────────────────────────────────────
282
381
 
283
- cleanStalePids(projectDir);
382
+ async function runModels(available) {
383
+ divider('Installed Models');
384
+ console.log();
284
385
 
285
- // Parse agent definitions
286
- const parsed = [];
287
- for (const agent of agents) {
288
- try {
289
- const agentMd = parseAgentMd(agent.agentMdPath);
290
- parsed.push({ ...agent, agentMd });
291
- } catch (err) {
292
- warn(`Skipping ${bold(agent.name)}: ${err.message}`);
386
+ if (available.local.length > 0) {
387
+ for (const m of available.local) {
388
+ const params = m.params ? dim(` ${m.params}`) : '';
389
+ const quant = m.quant ? dim(` ${m.quant}`) : '';
390
+ const size = m.size ? dim(` (${m.size})`) : '';
391
+ console.log(` ${green('●')} ${bold(m.name)}${params}${quant}${size}`);
293
392
  }
393
+ } else {
394
+ console.log(` ${dim('No local models installed.')}`);
294
395
  }
295
- if (parsed.length === 0) {
296
- error('No valid AGENT.md files found.');
396
+
397
+ if (available.cloud.length > 0) {
297
398
  console.log();
298
- process.exit(1);
399
+ const apiNote = available.hasApiKey ? green('key ✓') : yellow('needs API key');
400
+ console.log(` ${dim('Cloud models')} ${apiNote}`);
401
+ for (const m of available.cloud) {
402
+ console.log(` ${dim('●')} ${m.name} ${dim(`(${m.provider})`)}`);
403
+ }
299
404
  }
300
405
 
301
- // Load config
302
- let config = readConfig(projectDir) || { project: {}, agents: {} };
303
- const needsSetup = !config.project?.name || reconfigure;
304
-
305
- // Detect models in background while we show status
306
- const modelsPromise = detectModels();
406
+ console.log();
307
407
 
308
- // ── If configured, show status dashboard ──
309
- if (!needsSetup) {
310
- console.log(` ${bold(config.project.name)} ${dim(`— ${config.project.framework}`)}`);
311
- console.log(` ${dim(`Dev: ${config.project.devCmd} · Test: ${config.project.testCmd}`)}`);
408
+ // Show recommended models to install
409
+ if (available.recommended.length > 0) {
410
+ divider('Recommended Models');
312
411
  console.log();
313
-
314
- for (const { name, agentMd } of parsed) {
315
- const statusInfo = getAgentStatus(projectDir, name, config);
316
- printAgentLine(name, agentMd, config, statusInfo);
412
+ if (available.recommendedLive) {
413
+ success(`Fetched latest models from ${cyan('ollama.com/library')}`);
414
+ } else {
415
+ warn(`Could not reach ollama.com — showing cached recommendations`);
317
416
  }
318
417
  console.log();
319
-
320
- // Determine what to do
321
- const enabledAgents = parsed.filter(a => isAgentEnabled(a.name, config));
322
- const runningNames = new Set(listPids(projectDir).map(p => p.name));
323
- const startable = enabledAgents.filter(a => !runningNames.has(a.name));
324
-
325
- if (startable.length === 0 && runningNames.size > 0) {
326
- info('All enabled agents are running.');
327
- console.log(` ${dim('Run')} ${bold('serpentstack persistent --stop')} ${dim('to stop them.')}`);
328
- console.log(` ${dim('Run')} ${bold('serpentstack persistent --reconfigure')} ${dim('to change settings.')}`);
329
- console.log();
330
- return;
331
- }
332
-
333
- if (startable.length === 0) {
334
- info('No agents are enabled.');
335
- console.log(` ${dim('Run')} ${bold('serpentstack persistent --reconfigure')} ${dim('to enable agents.')}`);
336
- console.log();
337
- return;
418
+ for (const r of available.recommended) {
419
+ console.log(` ${dim('$')} ${bold(`ollama pull ${r.name}`)} ${dim(`${r.params} — ${r.description}`)}`);
338
420
  }
339
-
340
- // Start startable agents
341
- await launchAgents(projectDir, startable, config, soulPath);
342
- return;
421
+ console.log();
343
422
  }
344
423
 
345
- // ── First-time setup / reconfigure ──
346
- if (reconfigure) {
347
- info('Reconfiguring...');
424
+ // Status summary
425
+ if (!available.ollamaInstalled) {
426
+ console.log(` ${dim('Install Ollama for free local models:')}`);
427
+ console.log(` ${dim('$')} ${bold('curl -fsSL https://ollama.com/install.sh | sh')}`);
428
+ console.log();
429
+ } else if (!available.ollamaRunning) {
430
+ console.log(` ${dim('Start Ollama to use local models:')}`);
431
+ console.log(` ${dim('$')} ${bold('ollama serve')}`);
348
432
  console.log();
349
433
  }
350
434
 
435
+ console.log(` ${dim('Browse all models:')} ${cyan('https://ollama.com/library')}`);
436
+ console.log();
437
+ }
438
+
439
+ // ─── Configure Flow ─────────────────────────────────────────
440
+
441
+ async function runConfigure(projectDir, config, soulPath) {
351
442
  const rl = createInterface({ input: stdin, output: stdout });
352
- let configDirty = false;
353
443
 
354
444
  try {
355
- // ── Project configuration ──
356
445
  const detected = detectProjectDefaults(projectDir);
357
446
  const template = detectTemplateDefaults(projectDir);
358
447
  const existing = config.project || {};
@@ -382,13 +471,12 @@ export async function persistent({ stop = false, reconfigure = false } = {}) {
382
471
  testCmd: await ask(rl, 'Test command', defaults.testCmd),
383
472
  conventions: await ask(rl, 'Key conventions', defaults.conventions),
384
473
  };
385
- configDirty = true;
386
474
 
387
475
  // Update SOUL.md
388
476
  if (existsSync(soulPath)) {
389
477
  let soul = readFileSync(soulPath, 'utf8');
390
478
  const ctx = [
391
- `# ${config.project.name} \u2014 Persistent Development Agents`,
479
+ `# ${config.project.name} Persistent Development Agents`,
392
480
  '',
393
481
  `**Project:** ${config.project.name}`,
394
482
  `**Language:** ${config.project.language}`,
@@ -405,25 +493,26 @@ export async function persistent({ stop = false, reconfigure = false } = {}) {
405
493
  console.log();
406
494
  success(`Updated ${bold('.openclaw/SOUL.md')}`);
407
495
  console.log();
496
+ } finally {
497
+ rl.close();
498
+ }
408
499
 
409
- // ── Agent configuration ──
410
- const available = await modelsPromise;
500
+ config._configured = true;
501
+ writeConfig(projectDir, config);
502
+ success(`Saved ${bold('.openclaw/config.json')}`);
503
+ console.log();
504
+ }
411
505
 
412
- if (available.local.length > 0) {
413
- info(`${available.local.length} local model(s) detected via Ollama`);
414
- } else {
415
- warn('No local models found. Install Ollama and pull a model for free persistent agents:');
416
- console.log(` ${dim('$')} ${bold('ollama pull llama3.2')}`);
417
- }
418
- if (available.hasApiKey) {
419
- info('API key configured for cloud models');
420
- }
421
- console.log();
506
+ // ─── Agents Flow ────────────────────────────────────────────
422
507
 
423
- divider('Agents');
424
- console.log(` ${dim('Enable/disable each agent and pick a model.')}`);
425
- console.log();
508
+ async function runAgents(projectDir, config, parsed, available) {
509
+ divider('Agents');
510
+ console.log(` ${dim('Enable/disable each agent and pick a model.')}`);
511
+ console.log();
512
+
513
+ const rl = createInterface({ input: stdin, output: stdout });
426
514
 
515
+ try {
427
516
  for (const { name, agentMd } of parsed) {
428
517
  const existingAgent = config.agents?.[name];
429
518
  const currentEnabled = existingAgent?.enabled !== false;
@@ -443,43 +532,49 @@ export async function persistent({ stop = false, reconfigure = false } = {}) {
443
532
 
444
533
  config.agents[name] = { enabled, model };
445
534
 
446
- const status = enabled ? green('\u2713 enabled') : dim('\u2717 disabled');
535
+ const status = enabled ? green(' enabled') : dim(' disabled');
447
536
  const modelLabel = enabled ? `, ${modelShortName(model)}` : '';
448
537
  console.log(` ${status}${modelLabel}`);
449
538
  console.log();
450
539
  }
451
-
452
- configDirty = true;
453
540
  } finally {
454
541
  rl.close();
455
- // Only save if we completed configuration
456
- if (configDirty) {
457
- writeConfig(projectDir, config);
458
- success(`Saved ${bold('.openclaw/config.json')}`);
459
- console.log();
460
- }
461
542
  }
462
543
 
463
- // Show status and launch
464
- for (const { name, agentMd } of parsed) {
465
- const statusInfo = getAgentStatus(projectDir, name, config);
466
- printAgentLine(name, agentMd, config, statusInfo);
467
- }
544
+ config._configured = true;
545
+ writeConfig(projectDir, config);
546
+ success(`Saved ${bold('.openclaw/config.json')}`);
468
547
  console.log();
548
+ }
469
549
 
470
- const enabledAgents = parsed.filter(a => isAgentEnabled(a.name, config));
471
- if (enabledAgents.length === 0) {
472
- info('No agents enabled. Run with --reconfigure to enable agents.');
550
+ // ─── Start Flow ─────────────────────────────────────────────
551
+
552
+ async function runStart(projectDir, parsed, config, soulPath, hasOpenClaw) {
553
+ if (!hasOpenClaw) {
554
+ error('Cannot launch agents — OpenClaw is not installed.');
555
+ console.log();
556
+ console.log(` ${dim('$')} ${bold('npm install -g openclaw@latest')}`);
473
557
  console.log();
474
558
  return;
475
559
  }
476
560
 
477
- await launchAgents(projectDir, enabledAgents, config, soulPath);
478
- }
561
+ const enabledAgents = parsed.filter(a => isAgentEnabled(a.name, config));
562
+ const runningNames = new Set(listPids(projectDir).map(p => p.name));
563
+ const startable = enabledAgents.filter(a => !runningNames.has(a.name));
479
564
 
480
- // ─── Launch Flow ────────────────────────────────────────────
565
+ if (startable.length === 0 && runningNames.size > 0) {
566
+ info('All enabled agents are already running.');
567
+ console.log();
568
+ return;
569
+ }
570
+
571
+ if (startable.length === 0) {
572
+ info('No agents are enabled.');
573
+ console.log(` ${dim('Run')} ${bold('serpentstack persistent --agents')} ${dim('to enable agents.')}`);
574
+ console.log();
575
+ return;
576
+ }
481
577
 
482
- async function launchAgents(projectDir, agentsToStart, config, soulPath) {
483
578
  const rl = createInterface({ input: stdin, output: stdout });
484
579
  const toStart = [];
485
580
 
@@ -487,7 +582,7 @@ async function launchAgents(projectDir, agentsToStart, config, soulPath) {
487
582
  divider('Launch');
488
583
  console.log();
489
584
 
490
- for (const agent of agentsToStart) {
585
+ for (const agent of startable) {
491
586
  const model = getEffectiveModel(agent.name, agent.agentMd.meta, config);
492
587
  const yes = await askYesNo(rl, `Start ${bold(agent.name)} ${dim(`(${modelShortName(model)})`)}?`, true);
493
588
  if (yes) toStart.push(agent);
@@ -521,18 +616,14 @@ async function launchAgents(projectDir, agentsToStart, config, soulPath) {
521
616
  const absProject = resolve(projectDir);
522
617
 
523
618
  const openclawCmd = `OPENCLAW_STATE_DIR='${join(absWorkspace, '.state')}' openclaw start --workspace '${absWorkspace}'`;
524
-
525
619
  const method = openInTerminal(`SerpentStack: ${name}`, openclawCmd, absProject);
526
620
 
527
621
  if (method) {
528
- // For terminal-spawned agents, record workspace path so we can track it
529
- // The terminal process will create its own PID — we record ours as a marker
530
- writePid(projectDir, name, -1); // -1 = terminal-managed
622
+ writePid(projectDir, name, -1);
531
623
  success(`${bold(name)} opened in ${method} ${dim(`(${modelShortName(effectiveModel)})`)}`);
532
624
  started++;
533
625
  } else {
534
- // Fallback: background process
535
- warn(`No terminal detected \u2014 starting ${bold(name)} in background`);
626
+ warn(`No terminal detected — starting ${bold(name)} in background`);
536
627
  const child = spawn('openclaw', ['start', '--workspace', absWorkspace], {
537
628
  stdio: 'ignore',
538
629
  detached: true,
@@ -553,10 +644,126 @@ async function launchAgents(projectDir, agentsToStart, config, soulPath) {
553
644
  if (started > 0) {
554
645
  success(`${started} agent(s) launched — fangs out 🐍`);
555
646
  console.log();
556
- printBox('Manage agents', [
557
- `${dim('$')} ${bold('serpentstack persistent')} ${dim('# status + start')}`,
558
- `${dim('$')} ${bold('serpentstack persistent --stop')} ${dim('# stop all')}`,
559
- `${dim('$')} ${bold('serpentstack persistent --reconfigure')} ${dim('# change models')}`,
647
+ }
648
+ }
649
+
650
+ // ─── Main Entry Point ───────────────────────────────────────
651
+
652
+ export async function persistent({ stop = false, configure = false, agents = false, start = false, models = false } = {}) {
653
+ const projectDir = process.cwd();
654
+
655
+ printHeader();
656
+
657
+ // ── Stop (doesn't need full preflight) ──
658
+ if (stop) {
659
+ cleanStalePids(projectDir);
660
+ stopAllAgents(projectDir);
661
+ return;
662
+ }
663
+
664
+ // ── Full preflight (checks workspace, agents, runtime) ──
665
+ const { soulPath, parsed, hasOpenClaw, available } = await preflight(projectDir);
666
+ cleanStalePids(projectDir);
667
+
668
+ // Load config
669
+ let config = readConfig(projectDir) || { project: {}, agents: {} };
670
+ const isConfigured = !!config._configured;
671
+
672
+ // ── --models: list installed and recommended models ──
673
+ if (models) {
674
+ await runModels(available);
675
+ return;
676
+ }
677
+
678
+ // ── --configure: edit project settings ──
679
+ if (configure) {
680
+ await runConfigure(projectDir, config, soulPath);
681
+ return;
682
+ }
683
+
684
+ // ── --agents: edit agent models and enabled state ──
685
+ if (agents) {
686
+ config = readConfig(projectDir) || config;
687
+ await runAgents(projectDir, config, parsed, available);
688
+ return;
689
+ }
690
+
691
+ // ── --start: launch agents ──
692
+ if (start) {
693
+ await runStart(projectDir, parsed, config, soulPath, hasOpenClaw);
694
+ return;
695
+ }
696
+
697
+ // ── Bare `serpentstack persistent` ──
698
+ if (isConfigured) {
699
+ // Show dashboard
700
+ printStatusDashboard(config, parsed, projectDir);
701
+
702
+ const enabledAgents = parsed.filter(a => isAgentEnabled(a.name, config));
703
+ const runningNames = new Set(listPids(projectDir).map(p => p.name));
704
+ const startable = enabledAgents.filter(a => !runningNames.has(a.name));
705
+
706
+ if (startable.length === 0 && runningNames.size > 0) {
707
+ info('All enabled agents are running.');
708
+ } else if (startable.length === 0) {
709
+ info('No agents are enabled.');
710
+ } else {
711
+ info(`${startable.length} agent(s) ready to start.`);
712
+ }
713
+
714
+ console.log();
715
+ printBox('Commands', [
716
+ `${dim('$')} ${bold('serpentstack persistent --start')} ${dim('# launch agents')}`,
717
+ `${dim('$')} ${bold('serpentstack persistent --stop')} ${dim('# stop all')}`,
718
+ `${dim('$')} ${bold('serpentstack persistent --configure')} ${dim('# edit project settings')}`,
719
+ `${dim('$')} ${bold('serpentstack persistent --agents')} ${dim('# change models')}`,
720
+ `${dim('$')} ${bold('serpentstack persistent --models')} ${dim('# list & install models')}`,
560
721
  ]);
722
+ console.log();
723
+ return;
724
+ }
725
+
726
+ // ── First-time setup: guided walkthrough ──
727
+
728
+ // Step 0: Show runtime status
729
+ const canLaunch = printPreflightStatus(hasOpenClaw, available);
730
+
731
+ if (!canLaunch) {
732
+ console.log(` ${dim('Install the missing dependencies above, then run:')}`);
733
+ console.log(` ${dim('$')} ${bold('serpentstack persistent')}`);
734
+ console.log();
735
+
736
+ // Still let them configure even without OpenClaw
737
+ const rl = createInterface({ input: stdin, output: stdout });
738
+ let proceed;
739
+ try {
740
+ proceed = await askYesNo(rl, `Continue with project configuration anyway?`, true);
741
+ } finally {
742
+ rl.close();
743
+ }
744
+
745
+ if (!proceed) {
746
+ console.log();
747
+ return;
748
+ }
749
+ console.log();
750
+ }
751
+
752
+ // Step 1: Project settings
753
+ await runConfigure(projectDir, config, soulPath);
754
+ config = readConfig(projectDir) || config;
755
+
756
+ // Step 2: Agent settings
757
+ await runAgents(projectDir, config, parsed, available);
758
+ config = readConfig(projectDir) || config;
759
+
760
+ // Step 3: Launch (only if OpenClaw is installed)
761
+ if (canLaunch) {
762
+ await runStart(projectDir, parsed, config, soulPath, hasOpenClaw);
763
+ } else {
764
+ console.log();
765
+ info('Skipping launch — install OpenClaw first, then run:');
766
+ console.log(` ${dim('$')} ${bold('serpentstack persistent --start')}`);
767
+ console.log();
561
768
  }
562
769
  }
@@ -139,11 +139,10 @@ export async function skillsInit({ force = false } = {}) {
139
139
  ]);
140
140
 
141
141
  printBox('Want persistent background agents too?', [
142
- `${dim('$')} ${bold('serpentstack persistent')}`,
142
+ `${dim('$')} ${bold('serpentstack persistent')} ${dim('# first-time setup walkthrough')}`,
143
143
  '',
144
- `${dim('Agents that watch your dev server, run tests, and keep')}`,
145
- `${dim('skills fresh — each in its own terminal. Pick which to')}`,
146
- `${dim('run and choose local or cloud models.')}`,
144
+ `${dim('Configures your project, picks models, and launches')}`,
145
+ `${dim('agents — each in its own terminal window.')}`,
147
146
  ]);
148
147
  console.log();
149
148
  }
@@ -1,43 +1,149 @@
1
1
  import { execFile } from 'node:child_process';
2
2
 
3
+ // ─── Fallback Recommendations ───────────────────────────────
4
+ // Used only when the Ollama library API is unreachable.
5
+ // When online, we fetch fresh models from ollama.com/api/tags.
6
+
7
+ const FALLBACK_RECOMMENDATIONS = [
8
+ { name: 'gemma3:4b', params: '4B', size: '8.0 GB', description: 'Small and fast — great for log watching' },
9
+ { name: 'ministral-3:3b', params: '3B', size: '4.3 GB', description: 'Lightweight, good for simple tasks' },
10
+ { name: 'ministral-3:8b', params: '8B', size: '9.7 GB', description: 'Balanced — good default for most agents' },
11
+ { name: 'devstral-small-2:24b', params: '24B', size: '14.6 GB', description: 'Code-focused, strong for test runner agents' },
12
+ ];
13
+
14
+ // Max model size to recommend (16 GB — fits on most dev machines)
15
+ const MAX_RECOMMEND_SIZE = 16 * 1024 ** 3;
16
+
3
17
  /**
4
18
  * Detect all available models: local (Ollama) and cloud (via OpenClaw auth).
5
- * Local models are preferred for persistent agents — they're free and fast.
6
- * Cloud models require API keys and cost money per token.
19
+ * Also fetches recommended models from the Ollama library.
7
20
  *
8
- * Returns { local: [...], cloud: [...], hasApiKey: bool }
21
+ * Returns {
22
+ * local: [...],
23
+ * cloud: [...],
24
+ * hasApiKey: bool,
25
+ * ollamaRunning: bool,
26
+ * ollamaInstalled: bool,
27
+ * openclawInstalled: bool,
28
+ * recommended: [...], // models user doesn't have yet
29
+ * }
9
30
  */
10
31
  export async function detectModels() {
11
- const [ollamaModels, openclawInfo] = await Promise.all([
12
- detectOllamaModels(),
32
+ const [ollamaStatus, openclawInfo, libraryResult] = await Promise.all([
33
+ detectOllamaStatus(),
13
34
  detectOpenClawAuth(),
35
+ fetchOllamaLibrary(),
14
36
  ]);
15
37
 
38
+ // Filter out models the user already has installed
39
+ const installedNames = new Set(ollamaStatus.models.map(m => m.name.split(':')[0]));
40
+ const recommended = libraryResult.models.filter(r => {
41
+ const baseName = r.name.split(':')[0];
42
+ return !installedNames.has(baseName);
43
+ });
44
+
16
45
  return {
17
- local: ollamaModels,
46
+ local: ollamaStatus.models,
18
47
  cloud: openclawInfo.models,
19
48
  hasApiKey: openclawInfo.hasApiKey,
49
+ ollamaRunning: ollamaStatus.running,
50
+ ollamaInstalled: ollamaStatus.installed,
51
+ openclawInstalled: openclawInfo.installed,
52
+ recommended,
53
+ recommendedLive: libraryResult.live,
20
54
  };
21
55
  }
22
56
 
57
+ // ─── Ollama Library (live from ollama.com) ───────────────────
58
+
23
59
  /**
24
- * Detect locally installed Ollama models via the REST API.
25
- * GET http://localhost:11434/api/tags returns structured JSON with real
26
- * parameter counts, sizes, and quantization levels no CLI parsing needed.
60
+ * Fetch available models from the Ollama library API.
61
+ * Filters to models suitable for persistent agents (< MAX_RECOMMEND_SIZE).
62
+ * Falls back to a hardcoded list if the API is unreachable.
27
63
  */
28
- async function detectOllamaModels() {
64
+ async function fetchOllamaLibrary() {
65
+ try {
66
+ const response = await fetchWithTimeout('https://ollama.com/api/tags', 5000);
67
+ if (!response.ok) return { models: FALLBACK_RECOMMENDATIONS, live: false };
68
+
69
+ const data = await response.json();
70
+ if (!data.models || !Array.isArray(data.models)) return { models: FALLBACK_RECOMMENDATIONS, live: false };
71
+
72
+ // Filter to models that fit on a dev machine
73
+ const suitable = data.models
74
+ .filter(m => m.size > 0 && m.size <= MAX_RECOMMEND_SIZE)
75
+ .sort((a, b) => new Date(b.modified_at) - new Date(a.modified_at))
76
+ .map(m => {
77
+ const name = m.name || '';
78
+ const params = extractParams(name, m.size);
79
+ return {
80
+ name,
81
+ params,
82
+ size: formatBytes(m.size),
83
+ description: describeModel(name),
84
+ };
85
+ });
86
+
87
+ return suitable.length > 0
88
+ ? { models: suitable, live: true }
89
+ : { models: FALLBACK_RECOMMENDATIONS, live: false };
90
+ } catch {
91
+ return { models: FALLBACK_RECOMMENDATIONS, live: false };
92
+ }
93
+ }
94
+
95
+ /**
96
+ * Generate a short description for a model based on its name.
97
+ */
98
+ function describeModel(name) {
99
+ const n = name.toLowerCase();
100
+ if (n.includes('devstral') || n.includes('codellama') || n.includes('deepseek-coder') || n.includes('coder'))
101
+ return 'Code-focused';
102
+ if (n.includes('gemma')) return 'Google, general purpose';
103
+ if (n.includes('llama')) return 'Meta, general purpose';
104
+ if (n.includes('mistral') || n.includes('ministral')) return 'Mistral AI';
105
+ if (n.includes('qwen')) return 'Alibaba, multilingual';
106
+ if (n.includes('nemotron')) return 'NVIDIA';
107
+ if (n.includes('gpt-oss')) return 'Open-source GPT variant';
108
+ return 'General purpose';
109
+ }
110
+
111
+ /**
112
+ * Extract parameter count from model name tag or estimate from size.
113
+ */
114
+ function extractParams(name, size) {
115
+ // Check for explicit param count in the name (e.g., "gemma3:4b", "ministral-3:8b")
116
+ const tagMatch = name.match(/:(\d+\.?\d*)([bBmM])/);
117
+ if (tagMatch) return `${tagMatch[1]}${tagMatch[2].toUpperCase()}`;
118
+
119
+ // Estimate from size
120
+ return guessParamsFromSize(size);
121
+ }
122
+
123
+ // ─── Ollama Local Status ────────────────────────────────────
124
+
125
+ async function detectOllamaStatus() {
126
+ const result = { installed: false, running: false, models: [] };
127
+
128
+ try {
129
+ await execAsync('which', ['ollama']);
130
+ result.installed = true;
131
+ } catch {
132
+ return result;
133
+ }
134
+
29
135
  try {
30
136
  const response = await fetchWithTimeout('http://localhost:11434/api/tags', 3000);
31
- if (!response.ok) return [];
137
+ if (!response.ok) return result;
32
138
 
139
+ result.running = true;
33
140
  const data = await response.json();
34
- if (!data.models || !Array.isArray(data.models)) return [];
141
+ if (!data.models || !Array.isArray(data.models)) return result;
35
142
 
36
- return data.models.map(m => {
143
+ result.models = data.models.map(m => {
37
144
  const name = (m.name || '').replace(':latest', '');
38
145
  if (!name) return null;
39
146
 
40
- // Use the real parameter count from the API when available
41
147
  const details = m.details || {};
42
148
  const params = formatParamCount(details.parameter_size) || guessParamsFromSize(m.size);
43
149
  const quant = details.quantization_level || '';
@@ -53,71 +159,34 @@ async function detectOllamaModels() {
53
159
  };
54
160
  }).filter(Boolean);
55
161
  } catch {
56
- // Ollama not running or not installed
57
- return [];
162
+ // Ollama installed but not running
58
163
  }
59
- }
60
-
61
- /**
62
- * Format parameter count from Ollama API (e.g., "7B", "3.2B", "70B").
63
- * The API returns strings like "7B", "3.21B", etc. in details.parameter_size.
64
- */
65
- function formatParamCount(paramSize) {
66
- if (!paramSize) return '';
67
- // Already formatted like "7B" or "3.2B"
68
- const s = String(paramSize).trim();
69
- if (/^\d+\.?\d*[BbMm]$/i.test(s)) return s.toUpperCase();
70
- return s;
71
- }
72
164
 
73
- /**
74
- * Fallback: estimate parameter count from file size.
75
- * Rough heuristic: ~0.5GB per billion parameters for Q4 quantization.
76
- */
77
- function guessParamsFromSize(bytes) {
78
- if (!bytes || bytes <= 0) return '';
79
- const gb = bytes / (1024 ** 3);
80
- const billions = Math.round(gb * 2); // Q4 ≈ 0.5GB/B
81
- if (billions > 0) return `~${billions}B`;
82
- return '';
83
- }
84
-
85
- /**
86
- * Format bytes into human-readable size (e.g., "4.7 GB").
87
- */
88
- function formatBytes(bytes) {
89
- if (!bytes || bytes <= 0) return '';
90
- const gb = bytes / (1024 ** 3);
91
- if (gb >= 1) return `${gb.toFixed(1)} GB`;
92
- const mb = bytes / (1024 ** 2);
93
- return `${Math.round(mb)} MB`;
165
+ return result;
94
166
  }
95
167
 
96
- /**
97
- * Fetch with timeout using AbortController (Node 18+).
98
- */
99
- function fetchWithTimeout(url, timeoutMs) {
100
- const controller = new AbortController();
101
- const timeout = setTimeout(() => controller.abort(), timeoutMs);
102
- return fetch(url, { signal: controller.signal }).finally(() => clearTimeout(timeout));
103
- }
168
+ // ─── OpenClaw Auth & Cloud Models ───────────────────────────
104
169
 
105
- /**
106
- * Check OpenClaw for configured models and API key status.
107
- */
108
170
  async function detectOpenClawAuth() {
109
- const result = { models: [], hasApiKey: false };
171
+ const result = { models: [], hasApiKey: false, installed: false };
110
172
 
111
173
  try {
112
- // Check if any API key is configured via openclaw models status
113
- const status = await execAsync('openclaw', ['models', 'status']);
174
+ await execAsync('which', ['openclaw']);
175
+ result.installed = true;
176
+ } catch {
177
+ result.models = [
178
+ { id: 'anthropic/claude-haiku-4-20250414', name: 'Haiku', provider: 'anthropic', tier: 'cloud' },
179
+ { id: 'anthropic/claude-sonnet-4-20250514', name: 'Sonnet', provider: 'anthropic', tier: 'cloud' },
180
+ ];
181
+ return result;
182
+ }
114
183
 
115
- // Look for "api_key" or "configured" in the output
184
+ try {
185
+ const status = await execAsync('openclaw', ['models', 'status']);
116
186
  if (status.includes('api_key') || status.includes('configured')) {
117
187
  result.hasApiKey = true;
118
188
  }
119
189
 
120
- // Get the model catalog for cloud options
121
190
  const list = await execAsync('openclaw', ['models', 'list', '--json']);
122
191
  try {
123
192
  const models = JSON.parse(list);
@@ -132,7 +201,6 @@ async function detectOpenClawAuth() {
132
201
  }));
133
202
  }
134
203
  } catch {
135
- // Fall back to text parsing
136
204
  const text = await execAsync('openclaw', ['models', 'list']);
137
205
  const lines = text.trim().split('\n').filter(l => l.trim() && !l.startsWith('Model'));
138
206
  result.models = lines.map(l => {
@@ -142,7 +210,6 @@ async function detectOpenClawAuth() {
142
210
  }).filter(Boolean);
143
211
  }
144
212
  } catch {
145
- // OpenClaw not installed or no models configured — use defaults
146
213
  result.models = [
147
214
  { id: 'anthropic/claude-haiku-4-20250414', name: 'Haiku', provider: 'anthropic', tier: 'cloud' },
148
215
  { id: 'anthropic/claude-sonnet-4-20250514', name: 'Sonnet', provider: 'anthropic', tier: 'cloud' },
@@ -152,21 +219,49 @@ async function detectOpenClawAuth() {
152
219
  return result;
153
220
  }
154
221
 
222
+ // ─── Formatting Helpers ─────────────────────────────────────
223
+
224
+ function formatParamCount(paramSize) {
225
+ if (!paramSize) return '';
226
+ const s = String(paramSize).trim();
227
+ if (/^\d+\.?\d*[BbMm]$/i.test(s)) return s.toUpperCase();
228
+ return s;
229
+ }
230
+
231
+ function guessParamsFromSize(bytes) {
232
+ if (!bytes || bytes <= 0) return '';
233
+ const gb = bytes / (1024 ** 3);
234
+ const billions = Math.round(gb * 2);
235
+ if (billions > 0) return `~${billions}B`;
236
+ return '';
237
+ }
238
+
239
+ function formatBytes(bytes) {
240
+ if (!bytes || bytes <= 0) return '';
241
+ const gb = bytes / (1024 ** 3);
242
+ if (gb >= 1) return `${gb.toFixed(1)} GB`;
243
+ const mb = bytes / (1024 ** 2);
244
+ return `${Math.round(mb)} MB`;
245
+ }
246
+
247
+ function fetchWithTimeout(url, timeoutMs) {
248
+ const controller = new AbortController();
249
+ const timeout = setTimeout(() => controller.abort(), timeoutMs);
250
+ return fetch(url, { signal: controller.signal }).finally(() => clearTimeout(timeout));
251
+ }
252
+
155
253
  /**
156
254
  * Short display name for a model ID.
157
255
  */
158
256
  export function modelShortName(model) {
159
257
  if (!model) return 'unknown';
160
- // Anthropic models
161
258
  if (model.startsWith('anthropic/')) {
162
259
  if (model.includes('haiku')) return 'Haiku';
163
260
  if (model.includes('sonnet')) return 'Sonnet';
164
261
  if (model.includes('opus')) return 'Opus';
165
262
  return model.slice('anthropic/'.length);
166
263
  }
167
- // Ollama models
168
264
  if (model.startsWith('ollama/')) return model.slice('ollama/'.length);
169
- // Other: strip provider prefix
170
265
  if (model.includes('/')) return model.split('/').pop();
171
266
  return model;
172
267
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "serpentstack",
3
- "version": "0.2.11",
3
+ "version": "0.2.13",
4
4
  "description": "CLI for SerpentStack — AI-driven development standards with project-specific skills and persistent agents",
5
5
  "type": "module",
6
6
  "bin": {