serpentstack 0.2.16 → 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,15 +277,15 @@ 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, add it at the top
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
- choices.unshift({ id: currentModel, name: modelShortName(currentModel), tier: 'custom', action: 'use' });
321
- console.log(` ${dim(`Current: ${modelShortName(currentModel)} (not in detected models)`)}`);
285
+ const idx = choices.length;
286
+ choices.push({ id: currentModel, name: modelShortName(currentModel), tier: 'custom', action: 'use' });
287
+ console.log(` ${dim('── Current ─────────────────────────')}`);
288
+ console.log(` ${green('>')} ${dim(`${idx + 1}.`)} ${bold(modelShortName(currentModel))} ${dim('(not installed)')} ${green('← current')}`);
322
289
  }
323
290
 
324
291
  const currentIdx = choices.findIndex(c => c.id === currentModel);
@@ -326,23 +293,20 @@ async function pickModel(rl, agentName, currentModel, available) {
326
293
 
327
294
  const answer = await rl.question(` ${dim(`Enter 1-${choices.length}`)} ${dim(`[${defaultNum}]`)}: `);
328
295
  const idx = parseInt(answer.trim(), 10) - 1;
329
-
330
296
  const selected = (idx >= 0 && idx < choices.length) ? choices[idx] : choices[Math.max(0, currentIdx)];
331
297
 
332
- // If they selected a downloadable model, handle Ollama install + pull
298
+ // Handle downloadable model selection
333
299
  if (selected.action === 'download') {
334
300
  if (!available.ollamaInstalled) {
335
301
  console.log();
336
302
  warn('Ollama is required to run local models.');
337
- console.log();
338
303
  console.log(` ${dim('Install Ollama (free, open-source):')}`);
339
304
  console.log(` ${dim('$')} ${bold('curl -fsSL https://ollama.com/install.sh | sh')}`);
340
305
  console.log(` ${dim('$')} ${bold('ollama serve')}`);
341
306
  console.log();
342
307
  info(`After installing, re-run ${bold('serpentstack persistent --agents')} to download and select ${bold(selected.name)}.`);
343
308
  console.log();
344
-
345
- // Save the selection anyway so it's remembered
309
+ // Save selection so it's remembered, but mark it can't launch yet
346
310
  return selected.id;
347
311
  }
348
312
 
@@ -351,11 +315,10 @@ async function pickModel(rl, agentName, currentModel, available) {
351
315
  warn('Ollama is installed but not running.');
352
316
  console.log(` ${dim('$')} ${bold('ollama serve')}`);
353
317
  console.log();
354
- info(`Start Ollama, then re-run ${bold('serpentstack persistent --agents')} to download ${bold(selected.name)}.`);
355
- console.log();
356
318
  return selected.id;
357
319
  }
358
320
 
321
+ // Ollama is running — download the model now
359
322
  rl.pause();
360
323
  const pulled = await ollamaPull(selected.name);
361
324
  rl.resume();
@@ -366,9 +329,9 @@ async function pickModel(rl, agentName, currentModel, available) {
366
329
  }
367
330
  }
368
331
 
369
- // Warn about cloud model costs
332
+ // Warn about cloud costs
370
333
  if (selected.tier === 'cloud' && (available.local.length > 0 || available.recommended.length > 0)) {
371
- 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.');
372
335
  }
373
336
  if (selected.tier === 'cloud' && !available.hasApiKey) {
374
337
  warn(`No API key detected. Run ${bold('openclaw configure')} to set up authentication.`);
@@ -438,46 +401,51 @@ end tell`;
438
401
 
439
402
  // ─── Stop Flow ──────────────────────────────────────────────
440
403
 
441
- async function stopAllAgents(projectDir) {
442
- cleanStalePids(projectDir);
443
- const running = listPids(projectDir);
404
+ async function stopAllAgents(projectDir, config, parsed) {
405
+ const hasOpenClaw = await which('openclaw');
406
+ let stopped = 0;
444
407
 
445
- if (running.length === 0) {
446
- info('No agents are currently running.');
447
- console.log();
448
- return 0;
449
- }
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 || {});
450
412
 
451
- let stopped = 0;
452
- for (const { name, pid } of running) {
453
- // Remove cron jobs for this agent
454
- try {
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 */ }
413
+ for (const name of agentNames) {
414
+ let didSomething = false;
464
415
 
465
- // Remove agent from OpenClaw
466
- try {
467
- await execPromise('openclaw', ['agents', 'delete', name, '--force']);
468
- } 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 */ }
469
432
 
470
- // Clean up PID and workspace
471
- if (pid > 0) {
472
- 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++;
473
443
  }
474
- removePid(projectDir, name);
475
- cleanWorkspace(projectDir, name);
476
- success(`Stopped ${bold(name)}`);
477
- stopped++;
478
444
  }
479
445
 
480
- if (stopped > 0) {
446
+ if (stopped === 0) {
447
+ info('No agents are currently registered with OpenClaw.');
448
+ } else {
481
449
  console.log();
482
450
  success(`${stopped} agent(s) stopped`);
483
451
  }
@@ -487,28 +455,20 @@ async function stopAllAgents(projectDir) {
487
455
 
488
456
  // ─── Agent Status ───────────────────────────────────────────
489
457
 
490
- function getAgentStatus(projectDir, name, config) {
491
- const pid = listPids(projectDir).find(p => p.name === name);
492
- if (pid && isProcessAlive(pid.pid)) return { status: 'running', pid: pid.pid };
493
- if (!isAgentEnabled(name, config)) return { status: 'disabled', pid: null };
494
- return { status: 'stopped', pid: null };
495
- }
496
-
497
- function printAgentLine(name, agentMd, config, statusInfo) {
458
+ function printAgentLine(name, agentMd, config) {
498
459
  const model = getEffectiveModel(name, agentMd.meta, config);
499
460
  const schedule = (agentMd.meta.schedule || []).map(s => s.every).join(', ');
500
461
  const modelStr = modelShortName(model);
462
+ const enabled = isAgentEnabled(name, config);
501
463
 
502
- if (statusInfo.status === 'running') {
503
- console.log(` ${green('●')} ${bold(name)} ${dim(modelStr)} ${dim(schedule)} ${green(`PID ${statusInfo.pid}`)}`);
504
- } else if (statusInfo.status === 'disabled') {
505
- 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')}`);
506
466
  } else {
507
- console.log(` ${yellow('○')} ${bold(name)} ${dim(modelStr)} ${dim(schedule)} ${dim('ready')}`);
467
+ console.log(` ${dim('○')} ${dim(name)} ${dim(modelStr)} ${dim(schedule)} ${dim('disabled')}`);
508
468
  }
509
469
  }
510
470
 
511
- function printStatusDashboard(config, parsed, projectDir) {
471
+ function printStatusDashboard(config, parsed) {
512
472
  console.log(` ${bold(config.project.name)} ${dim(`— ${config.project.framework}`)}`);
513
473
  console.log(` ${dim(`Dev: ${config.project.devCmd} · Test: ${config.project.testCmd}`)}`);
514
474
  console.log();
@@ -516,8 +476,7 @@ function printStatusDashboard(config, parsed, projectDir) {
516
476
  divider('Agents');
517
477
  console.log();
518
478
  for (const { name, agentMd } of parsed) {
519
- const statusInfo = getAgentStatus(projectDir, name, config);
520
- printAgentLine(name, agentMd, config, statusInfo);
479
+ printAgentLine(name, agentMd, config);
521
480
  }
522
481
  console.log();
523
482
  }
@@ -550,14 +509,13 @@ async function runModels(available) {
550
509
 
551
510
  console.log();
552
511
 
553
- // Show recommended models to install
554
512
  if (available.recommended.length > 0) {
555
513
  divider('Recommended Models');
556
514
  console.log();
557
515
  if (available.recommendedLive) {
558
516
  success(`Fetched latest models from ${cyan('ollama.com/library')}`);
559
517
  } else {
560
- warn(`Could not reach ollama.com — showing cached recommendations`);
518
+ warn('Could not reach ollama.com — showing cached recommendations');
561
519
  }
562
520
  console.log();
563
521
  for (const r of available.recommended) {
@@ -566,7 +524,6 @@ async function runModels(available) {
566
524
  console.log();
567
525
  }
568
526
 
569
- // Status summary
570
527
  if (!available.ollamaInstalled) {
571
528
  console.log(` ${dim('Install Ollama for free local models:')}`);
572
529
  console.log(` ${dim('$')} ${bold('curl -fsSL https://ollama.com/install.sh | sh')}`);
@@ -622,8 +579,7 @@ async function runConfigure(projectDir, config, soulPath) {
622
579
  let soul = readFileSync(soulPath, 'utf8');
623
580
  const ctx = [
624
581
  `# ${config.project.name} — Persistent Development Agents`,
625
- '',
626
- `**Project:** ${config.project.name}`,
582
+ '', `**Project:** ${config.project.name}`,
627
583
  `**Language:** ${config.project.language}`,
628
584
  `**Framework:** ${config.project.framework}`,
629
585
  `**Dev server:** \`${config.project.devCmd}\``,
@@ -650,7 +606,6 @@ async function runConfigure(projectDir, config, soulPath) {
650
606
 
651
607
  // ─── Agents Flow ────────────────────────────────────────────
652
608
 
653
- // Agent description summaries for the enable/disable flow
654
609
  const AGENT_SUMMARIES = {
655
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.',
656
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.',
@@ -658,7 +613,6 @@ const AGENT_SUMMARIES = {
658
613
  };
659
614
 
660
615
  async function runAgents(projectDir, config, parsed, available) {
661
- // Show system capabilities so users know what models they can run
662
616
  const sys = detectSystemCapabilities();
663
617
 
664
618
  divider('Your System');
@@ -680,11 +634,9 @@ async function runAgents(projectDir, config, parsed, available) {
680
634
  const currentModel = existingAgent?.model || 'ollama/llama3.2';
681
635
  const schedule = (agentMd.meta.schedule || []).map(s => s.every).join(', ');
682
636
 
683
- // Show rich description
684
637
  console.log(` ${bold(name)} ${dim(`(${schedule || 'manual'})`)}`);
685
638
  const summary = AGENT_SUMMARIES[name] || agentMd.meta.description || '';
686
639
  if (summary) {
687
- // Word-wrap summary to ~70 chars, indented
688
640
  const words = summary.split(' ');
689
641
  let line = '';
690
642
  for (const word of words) {
@@ -725,32 +677,80 @@ async function runAgents(projectDir, config, parsed, available) {
725
677
 
726
678
  // ─── Start Flow ─────────────────────────────────────────────
727
679
 
728
- 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) {
729
689
  if (!hasOpenClaw) {
730
690
  error('Cannot launch agents — OpenClaw is not installed.');
731
- console.log();
732
- console.log(` ${dim('$')} ${bold('npm install -g openclaw@latest')}`);
691
+ console.log(` ${dim('$')} ${bold('npm install -g openclaw@latest')}`);
733
692
  console.log();
734
693
  return;
735
694
  }
736
695
 
737
696
  const enabledAgents = parsed.filter(a => isAgentEnabled(a.name, config));
738
- const runningNames = new Set(listPids(projectDir).map(p => p.name));
739
- const startable = enabledAgents.filter(a => !runningNames.has(a.name));
740
697
 
741
- if (startable.length === 0 && runningNames.size > 0) {
742
- 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.')}`);
743
701
  console.log();
744
702
  return;
745
703
  }
746
704
 
747
- if (startable.length === 0) {
748
- info('No agents are enabled.');
749
- 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.`);
750
750
  console.log();
751
- return;
752
751
  }
753
752
 
753
+ // Confirm which agents to start
754
754
  const rl = createInterface({ input: stdin, output: stdout });
755
755
  const toStart = [];
756
756
 
@@ -758,7 +758,7 @@ async function runStart(projectDir, parsed, config, soulPath, hasOpenClaw) {
758
758
  divider('Launch');
759
759
  console.log();
760
760
 
761
- for (const agent of startable) {
761
+ for (const agent of launchable) {
762
762
  const model = getEffectiveModel(agent.name, agent.agentMd.meta, config);
763
763
  const yes = await askYesNo(rl, `Start ${bold(agent.name)} ${dim(`(${modelShortName(model)})`)}?`, true);
764
764
  if (yes) toStart.push(agent);
@@ -776,10 +776,50 @@ async function runStart(projectDir, parsed, config, soulPath, hasOpenClaw) {
776
776
 
777
777
  console.log();
778
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
779
820
  const sharedSoul = readFileSync(soulPath, 'utf8');
780
821
  let registered = 0;
781
822
 
782
- // Step 1: Generate workspaces and register agents with OpenClaw
783
823
  for (const { name, agentMd } of toStart) {
784
824
  try {
785
825
  const effectiveModel = getEffectiveModel(name, agentMd.meta, config);
@@ -791,7 +831,7 @@ async function runStart(projectDir, parsed, config, soulPath, hasOpenClaw) {
791
831
  const workspacePath = generateWorkspace(projectDir, name, overriddenMd, sharedSoul);
792
832
  const absWorkspace = resolve(workspacePath);
793
833
 
794
- // Register agent with OpenClaw (idempotent — will update if exists)
834
+ // Register agent with OpenClaw
795
835
  try {
796
836
  await execPromise('openclaw', [
797
837
  'agents', 'add', name,
@@ -799,13 +839,15 @@ async function runStart(projectDir, parsed, config, soulPath, hasOpenClaw) {
799
839
  '--model', effectiveModel,
800
840
  '--non-interactive',
801
841
  ]);
802
- success(`${green('✓')} Registered ${bold(name)} ${dim(`(${modelShortName(effectiveModel)})`)}`);
842
+ success(`Registered ${bold(name)} ${dim(`(${modelShortName(effectiveModel)})`)}`);
803
843
  } 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`);
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`);
807
848
  } else {
808
- warn(`Could not register ${bold(name)}: ${err.message || 'unknown error'}`);
849
+ warn(`Could not register ${bold(name)}: ${msg}`);
850
+ continue;
809
851
  }
810
852
  }
811
853
 
@@ -816,17 +858,17 @@ async function runStart(projectDir, parsed, config, soulPath, hasOpenClaw) {
816
858
  await execPromise('openclaw', [
817
859
  'cron', 'add',
818
860
  '--agent', name,
861
+ '--model', effectiveModel,
819
862
  '--every', sched.every,
820
863
  '--message', `Run task: ${sched.task}`,
821
864
  '--name', `${name}-${sched.task}`,
822
865
  '--light-context',
823
866
  ]);
824
867
  } catch {
825
- // Cron job may already exist — non-fatal
868
+ // Cron job may already exist
826
869
  }
827
870
  }
828
871
 
829
- writePid(projectDir, name, -1); // marker
830
872
  registered++;
831
873
  } catch (err) {
832
874
  error(`${bold(name)}: ${err.message}`);
@@ -840,46 +882,6 @@ async function runStart(projectDir, parsed, config, soulPath, hasOpenClaw) {
840
882
  return;
841
883
  }
842
884
 
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
885
  console.log();
884
886
  success(`${registered} agent(s) registered — fangs out 🐍`);
885
887
  console.log();
@@ -903,21 +905,19 @@ export async function persistent({ stop = false, configure = false, agents = fal
903
905
 
904
906
  printHeader();
905
907
 
906
- // ── Stop (doesn't need full preflight) ──
907
- if (stop) {
908
- cleanStalePids(projectDir);
909
- await stopAllAgents(projectDir);
910
- return;
911
- }
912
-
913
- // ── Full preflight (checks workspace, agents, runtime) ──
908
+ // ── Full preflight (auto-creates .openclaw if missing) ──
914
909
  const { soulPath, parsed, hasOpenClaw, available } = await preflight(projectDir);
915
- cleanStalePids(projectDir);
916
910
 
917
911
  // Load config
918
912
  let config = readConfig(projectDir) || { project: {}, agents: {} };
919
913
  const isConfigured = !!config._configured;
920
914
 
915
+ // ── --stop: stop all agents ──
916
+ if (stop) {
917
+ await stopAllAgents(projectDir, config, parsed);
918
+ return;
919
+ }
920
+
921
921
  // ── --models: list installed and recommended models ──
922
922
  if (models) {
923
923
  await runModels(available);
@@ -939,25 +939,19 @@ export async function persistent({ stop = false, configure = false, agents = fal
939
939
 
940
940
  // ── --start: launch agents ──
941
941
  if (start) {
942
- await runStart(projectDir, parsed, config, soulPath, hasOpenClaw);
942
+ await runStart(projectDir, parsed, config, soulPath, hasOpenClaw, available);
943
943
  return;
944
944
  }
945
945
 
946
946
  // ── Bare `serpentstack persistent` ──
947
947
  if (isConfigured) {
948
- // Show dashboard
949
- printStatusDashboard(config, parsed, projectDir);
948
+ printStatusDashboard(config, parsed);
950
949
 
951
950
  const enabledAgents = parsed.filter(a => isAgentEnabled(a.name, config));
952
- const runningNames = new Set(listPids(projectDir).map(p => p.name));
953
- const startable = enabledAgents.filter(a => !runningNames.has(a.name));
954
-
955
- if (startable.length === 0 && runningNames.size > 0) {
956
- info('All enabled agents are running.');
957
- } else if (startable.length === 0) {
951
+ if (enabledAgents.length === 0) {
958
952
  info('No agents are enabled.');
959
953
  } else {
960
- info(`${startable.length} agent(s) ready to start.`);
954
+ info(`${enabledAgents.length} agent(s) enabled.`);
961
955
  }
962
956
 
963
957
  console.log();
@@ -973,8 +967,6 @@ export async function persistent({ stop = false, configure = false, agents = fal
973
967
  }
974
968
 
975
969
  // ── First-time setup: guided walkthrough ──
976
-
977
- // Step 0: Show runtime status
978
970
  const canLaunch = printPreflightStatus(hasOpenClaw, available);
979
971
 
980
972
  if (!canLaunch) {
@@ -982,11 +974,10 @@ export async function persistent({ stop = false, configure = false, agents = fal
982
974
  console.log(` ${dim('$')} ${bold('serpentstack persistent')}`);
983
975
  console.log();
984
976
 
985
- // Still let them configure even without OpenClaw
986
977
  const rl = createInterface({ input: stdin, output: stdout });
987
978
  let proceed;
988
979
  try {
989
- proceed = await askYesNo(rl, `Continue with project configuration anyway?`, true);
980
+ proceed = await askYesNo(rl, 'Continue with project configuration anyway?', true);
990
981
  } finally {
991
982
  rl.close();
992
983
  }
@@ -1008,7 +999,7 @@ export async function persistent({ stop = false, configure = false, agents = fal
1008
999
 
1009
1000
  // Step 3: Launch (only if OpenClaw is installed)
1010
1001
  if (canLaunch) {
1011
- await runStart(projectDir, parsed, config, soulPath, hasOpenClaw);
1002
+ await runStart(projectDir, parsed, config, soulPath, hasOpenClaw, available);
1012
1003
  } else {
1013
1004
  console.log();
1014
1005
  info('Skipping launch — install OpenClaw first, then run:');
@@ -250,6 +250,9 @@ export function listPids(projectDir) {
250
250
  * Check if a process is alive.
251
251
  */
252
252
  export function isProcessAlive(pid) {
253
+ // PID -1 is a marker for "terminal-managed" — we can't check those.
254
+ // process.kill(-1, 0) sends to ALL processes, always succeeds — never use it.
255
+ if (!pid || pid <= 0) return false;
253
256
  try {
254
257
  process.kill(pid, 0);
255
258
  return true;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "serpentstack",
3
- "version": "0.2.16",
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": {