serpentstack 0.2.15 → 0.2.18

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.
@@ -33,6 +33,19 @@ function which(cmd) {
33
33
  });
34
34
  }
35
35
 
36
+ function execPromise(cmd, args) {
37
+ return new Promise((resolve, reject) => {
38
+ execFile(cmd, args, { timeout: 15000 }, (err, stdout, stderr) => {
39
+ if (err) {
40
+ const msg = stderr?.trim() || stdout?.trim() || err.message;
41
+ reject(new Error(msg));
42
+ } else {
43
+ resolve(stdout);
44
+ }
45
+ });
46
+ });
47
+ }
48
+
36
49
  async function ask(rl, label, defaultValue) {
37
50
  const hint = defaultValue ? ` ${dim(`[${defaultValue}]`)}` : '';
38
51
  const answer = await rl.question(` ${green('?')} ${bold(label)}${hint}: `);
@@ -302,10 +315,12 @@ async function pickModel(rl, agentName, currentModel, available) {
302
315
  return currentModel;
303
316
  }
304
317
 
305
- // If current model isn't in any list, add it at the top
318
+ // If current model isn't in any list, append it at the end (never unshift — it breaks numbering)
306
319
  if (!choices.some(c => c.id === currentModel)) {
307
- choices.unshift({ id: currentModel, name: modelShortName(currentModel), tier: 'custom', action: 'use' });
308
- console.log(` ${dim(`Current: ${modelShortName(currentModel)} (not in detected models)`)}`);
320
+ const idx = choices.length;
321
+ choices.push({ id: currentModel, name: modelShortName(currentModel), tier: 'custom', action: 'use' });
322
+ console.log(` ${dim('── Current ─────────────────────────')}`);
323
+ console.log(` ${green('>')} ${dim(`${idx + 1}.`)} ${bold(modelShortName(currentModel))} ${dim('(not installed)')} ${green('← current')}`);
309
324
  }
310
325
 
311
326
  const currentIdx = choices.findIndex(c => c.id === currentModel);
@@ -425,7 +440,7 @@ end tell`;
425
440
 
426
441
  // ─── Stop Flow ──────────────────────────────────────────────
427
442
 
428
- function stopAllAgents(projectDir) {
443
+ async function stopAllAgents(projectDir) {
429
444
  cleanStalePids(projectDir);
430
445
  const running = listPids(projectDir);
431
446
 
@@ -437,19 +452,31 @@ function stopAllAgents(projectDir) {
437
452
 
438
453
  let stopped = 0;
439
454
  for (const { name, pid } of running) {
455
+ // Remove cron jobs for this agent
440
456
  try {
441
- process.kill(pid, 'SIGTERM');
442
- removePid(projectDir, name);
443
- cleanWorkspace(projectDir, name);
444
- success(`Stopped ${bold(name)} ${dim(`(PID ${pid})`)}`);
445
- stopped++;
446
- } catch (err) {
447
- if (err.code === 'ESRCH') {
448
- removePid(projectDir, name);
449
- } else {
450
- error(`Failed to stop ${bold(name)}: ${err.message}`);
451
- }
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 */ }
466
+
467
+ // Remove agent from OpenClaw
468
+ try {
469
+ await execPromise('openclaw', ['agents', 'delete', name, '--force']);
470
+ } catch { /* best-effort */ }
471
+
472
+ // Clean up PID and workspace
473
+ if (pid > 0) {
474
+ try { process.kill(pid, 'SIGTERM'); } catch { /* already dead */ }
452
475
  }
476
+ removePid(projectDir, name);
477
+ cleanWorkspace(projectDir, name);
478
+ success(`Stopped ${bold(name)}`);
479
+ stopped++;
453
480
  }
454
481
 
455
482
  if (stopped > 0) {
@@ -752,8 +779,9 @@ async function runStart(projectDir, parsed, config, soulPath, hasOpenClaw) {
752
779
  console.log();
753
780
 
754
781
  const sharedSoul = readFileSync(soulPath, 'utf8');
755
- let started = 0;
782
+ let registered = 0;
756
783
 
784
+ // Step 1: Generate workspaces and register agents with OpenClaw
757
785
  for (const { name, agentMd } of toStart) {
758
786
  try {
759
787
  const effectiveModel = getEffectiveModel(name, agentMd.meta, config);
@@ -764,38 +792,111 @@ async function runStart(projectDir, parsed, config, soulPath, hasOpenClaw) {
764
792
 
765
793
  const workspacePath = generateWorkspace(projectDir, name, overriddenMd, sharedSoul);
766
794
  const absWorkspace = resolve(workspacePath);
767
- const absProject = resolve(projectDir);
768
795
 
769
- const openclawCmd = `OPENCLAW_STATE_DIR='${join(absWorkspace, '.state')}' openclaw start --workspace '${absWorkspace}'`;
770
- const method = openInTerminal(`SerpentStack: ${name}`, openclawCmd, absProject);
796
+ // Register agent with OpenClaw (idempotent will update if exists)
797
+ try {
798
+ await execPromise('openclaw', [
799
+ 'agents', 'add', name,
800
+ '--workspace', absWorkspace,
801
+ '--model', effectiveModel,
802
+ '--non-interactive',
803
+ ]);
804
+ success(`Registered ${bold(name)} ${dim(`(${modelShortName(effectiveModel)})`)}`);
805
+ } 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`);
809
+ } else {
810
+ warn(`Could not register ${bold(name)}: ${err.message || 'unknown error'}`);
811
+ }
812
+ }
771
813
 
772
- if (method) {
773
- writePid(projectDir, name, -1);
774
- success(`${bold(name)} opened in ${method} ${dim(`(${modelShortName(effectiveModel)})`)}`);
775
- started++;
776
- } else {
777
- warn(`No terminal detected — starting ${bold(name)} in background`);
778
- const child = spawn('openclaw', ['start', '--workspace', absWorkspace], {
779
- stdio: 'ignore',
780
- detached: true,
781
- cwd: absProject,
782
- env: { ...process.env, OPENCLAW_STATE_DIR: join(absWorkspace, '.state') },
783
- });
784
- child.unref();
785
- writePid(projectDir, name, child.pid);
786
- success(`${bold(name)} started ${dim(`PID ${child.pid}`)}`);
787
- started++;
814
+ // Add cron jobs for the agent's schedule
815
+ const schedules = agentMd.meta.schedule || [];
816
+ for (const sched of schedules) {
817
+ try {
818
+ await execPromise('openclaw', [
819
+ 'cron', 'add',
820
+ '--agent', name,
821
+ '--model', effectiveModel,
822
+ '--every', sched.every,
823
+ '--message', `Run task: ${sched.task}`,
824
+ '--name', `${name}-${sched.task}`,
825
+ '--light-context',
826
+ ]);
827
+ } catch {
828
+ // Cron job may already exist — non-fatal
829
+ }
788
830
  }
831
+
832
+ writePid(projectDir, name, -1); // marker
833
+ registered++;
789
834
  } catch (err) {
790
835
  error(`${bold(name)}: ${err.message}`);
791
836
  }
792
837
  }
793
838
 
794
- console.log();
795
- if (started > 0) {
796
- success(`${started} agent(s) launched — fangs out 🐍`);
839
+ if (registered === 0) {
840
+ console.log();
841
+ error('No agents were registered.');
797
842
  console.log();
843
+ return;
844
+ }
845
+
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
798
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
+ console.log();
887
+ success(`${registered} agent(s) registered — fangs out 🐍`);
888
+ console.log();
889
+
890
+ printBox('Your agents are running', [
891
+ `${dim('The OpenClaw gateway manages your agents on their schedules.')}`,
892
+ `${dim('View agent activity with:')}`,
893
+ '',
894
+ `${dim('$')} ${bold('openclaw tui')} ${dim('# interactive terminal UI')}`,
895
+ `${dim('$')} ${bold('openclaw cron list')} ${dim('# see scheduled tasks')}`,
896
+ `${dim('$')} ${bold('openclaw agents list')} ${dim('# see registered agents')}`,
897
+ `${dim('$')} ${bold('serpentstack persistent --stop')} ${dim('# stop all agents')}`,
898
+ ]);
899
+ console.log();
799
900
  }
800
901
 
801
902
  // ─── Main Entry Point ───────────────────────────────────────
@@ -808,7 +909,7 @@ export async function persistent({ stop = false, configure = false, agents = fal
808
909
  // ── Stop (doesn't need full preflight) ──
809
910
  if (stop) {
810
911
  cleanStalePids(projectDir);
811
- stopAllAgents(projectDir);
912
+ await stopAllAgents(projectDir);
812
913
  return;
813
914
  }
814
915
 
@@ -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/lib/utils/ui.js CHANGED
@@ -94,7 +94,7 @@ export function printHeader() {
94
94
 
95
95
  export function divider(label) {
96
96
  if (label) {
97
- console.log(` ${DIM}── ${RESET}${BOLD}${label}${RESET} ${DIM}${'─'.repeat(Math.max(0, 50 - stripAnsi(label).length))}${RESET}`);
97
+ console.log(` ${DIM}──${RESET} ${GREEN}${BOLD}${label}${RESET} ${DIM}${'─'.repeat(Math.max(0, 50 - stripAnsi(label).length))}${RESET}`);
98
98
  } else {
99
99
  console.log(` ${DIM}${'─'.repeat(54)}${RESET}`);
100
100
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "serpentstack",
3
- "version": "0.2.15",
3
+ "version": "0.2.18",
4
4
  "description": "CLI for SerpentStack — AI-driven development standards with project-specific skills and persistent agents",
5
5
  "type": "module",
6
6
  "bin": {