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.
@@ -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
- // Check for .openclaw workspace
72
+ // Auto-create .openclaw workspace if missing
73
73
  if (!existsSync(soulPath)) {
74
- error('No .openclaw/ workspace found.');
74
+ info('No .openclaw/ workspace found — setting up now...');
75
75
  console.log();
76
- console.log(` ${dim('Run')} ${bold('serpentstack skills')} ${dim('first to download skills and agent configs.')}`);
77
- console.log();
78
- process.exit(1);
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
- if (issues.length > 0) {
180
- for (const issue of issues) {
181
- console.log(` ${dim(issue.label + ':')}`);
182
- console.log(` ${dim('$')} ${bold(issue.cmd)}`);
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'); // clear the progress line
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 if we have recommendations
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
- name: r.name,
274
- params: r.params,
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 it at the end (never unshift — it breaks numbering)
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
- // If they selected a downloadable model, handle Ollama install + pull
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 model costs
332
+ // Warn about cloud costs
372
333
  if (selected.tier === 'cloud' && (available.local.length > 0 || available.recommended.length > 0)) {
373
- warn(`Cloud models cost tokens per heartbeat. Consider a local model for persistent agents.`);
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
- cleanStalePids(projectDir);
445
- const running = listPids(projectDir);
404
+ async function stopAllAgents(projectDir, config, parsed) {
405
+ const hasOpenClaw = await which('openclaw');
406
+ let stopped = 0;
446
407
 
447
- if (running.length === 0) {
448
- info('No agents are currently running.');
449
- console.log();
450
- return 0;
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
- let stopped = 0;
454
- for (const { name, pid } of running) {
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
- // Remove agent from OpenClaw
468
- try {
469
- await execPromise('openclaw', ['agents', 'delete', name, '--force']);
470
- } catch { /* best-effort */ }
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
- // Clean up PID and workspace
473
- if (pid > 0) {
474
- try { process.kill(pid, 'SIGTERM'); } catch { /* already dead */ }
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 > 0) {
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 getAgentStatus(projectDir, name, config) {
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 (statusInfo.status === 'running') {
505
- console.log(` ${green('●')} ${bold(name)} ${dim(modelStr)} ${dim(schedule)} ${green(`PID ${statusInfo.pid}`)}`);
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(` ${yellow('○')} ${bold(name)} ${dim(modelStr)} ${dim(schedule)} ${dim('ready')}`);
467
+ console.log(` ${dim('○')} ${dim(name)} ${dim(modelStr)} ${dim(schedule)} ${dim('disabled')}`);
510
468
  }
511
469
  }
512
470
 
513
- function printStatusDashboard(config, parsed, projectDir) {
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
- const statusInfo = getAgentStatus(projectDir, name, config);
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(`Could not reach ollama.com — showing cached recommendations`);
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
- async function runStart(projectDir, parsed, config, soulPath, hasOpenClaw) {
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 (startable.length === 0 && runningNames.size > 0) {
744
- info('All enabled agents are already running.');
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
- if (startable.length === 0) {
750
- info('No agents are enabled.');
751
- console.log(` ${dim('Run')} ${bold('serpentstack persistent --agents')} ${dim('to enable agents.')}`);
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 startable) {
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 (idempotent — will update if exists)
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
- // Agent may already exist — that's fine
807
- if (err.message && err.message.includes('already exists')) {
808
- info(`${bold(name)} already registered with OpenClaw`);
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)}: ${err.message || 'unknown error'}`);
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 — non-fatal
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
- // ── Stop (doesn't need full preflight) ──
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
- // Show dashboard
952
- printStatusDashboard(config, parsed, projectDir);
948
+ printStatusDashboard(config, parsed);
953
949
 
954
950
  const enabledAgents = parsed.filter(a => isAgentEnabled(a.name, config));
955
- const runningNames = new Set(listPids(projectDir).map(p => p.name));
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(`${startable.length} agent(s) ready to start.`);
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, `Continue with project configuration anyway?`, true);
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:');
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "serpentstack",
3
- "version": "0.2.18",
3
+ "version": "0.2.19",
4
4
  "description": "CLI for SerpentStack — AI-driven development standards with project-specific skills and persistent agents",
5
5
  "type": "module",
6
6
  "bin": {