serpentstack 0.2.5 → 0.2.9

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.
@@ -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, confirm, bold, dim, green, cyan, yellow, red, printBox, printHeader } from '../utils/ui.js';
6
+ import { info, success, warn, error, bold, dim, green, cyan, yellow, red, divider, printBox, printHeader } from '../utils/ui.js';
7
7
  import {
8
8
  parseAgentMd,
9
9
  discoverAgents,
@@ -22,6 +22,9 @@ import {
22
22
  getEffectiveModel,
23
23
  isAgentEnabled,
24
24
  } from '../utils/config.js';
25
+ import { detectModels, modelShortName } from '../utils/models.js';
26
+
27
+ // ─── Helpers ────────────────────────────────────────────────
25
28
 
26
29
  function which(cmd) {
27
30
  return new Promise((resolve) => {
@@ -29,74 +32,139 @@ function which(cmd) {
29
32
  });
30
33
  }
31
34
 
32
- function modelShortName(model) {
33
- if (model.includes('haiku')) return 'Haiku';
34
- if (model.includes('sonnet')) return 'Sonnet';
35
- if (model.includes('opus')) return 'Opus';
36
- if (model.includes('ollama') || model.includes(':')) return model.split('/').pop();
37
- return model;
35
+ async function ask(rl, label, defaultValue) {
36
+ const hint = defaultValue ? ` ${dim(`[${defaultValue}]`)}` : '';
37
+ const answer = await rl.question(` ${green('?')} ${bold(label)}${hint}: `);
38
+ return answer.trim() || defaultValue || '';
38
39
  }
39
40
 
40
- async function askQuestion(rl, label, hint, defaultValue) {
41
- const defaultHint = defaultValue ? ` ${dim(`[${defaultValue}]`)}` : '';
42
- const hintStr = hint ? ` ${dim(hint)}` : '';
43
- const answer = await rl.question(` ${green('?')} ${bold(label)}${hintStr}${defaultHint}: `);
44
- return answer.trim() || defaultValue || '';
41
+ async function askYesNo(rl, label, defaultYes = true) {
42
+ const hint = defaultYes ? dim('[Y/n]') : dim('[y/N]');
43
+ const answer = await rl.question(` ${green('?')} ${label} ${hint} `);
44
+ const val = answer.trim().toLowerCase();
45
+ if (defaultYes) return val !== 'n' && val !== 'no';
46
+ return val === 'y' || val === 'yes';
47
+ }
48
+
49
+ // ─── Model Picker ───────────────────────────────────────────
50
+
51
+ async function pickModel(rl, agentName, currentModel, available) {
52
+ const choices = [];
53
+
54
+ // Local models first (free, fast, recommended)
55
+ if (available.local.length > 0) {
56
+ console.log(` ${dim('── Local')} ${green('free')} ${dim('──────────────────────')}`);
57
+ for (const m of available.local) {
58
+ const isCurrent = m.id === currentModel;
59
+ const idx = choices.length;
60
+ choices.push(m);
61
+ const marker = isCurrent ? green('>') : ' ';
62
+ const num = dim(`${idx + 1}.`);
63
+ const label = isCurrent ? bold(m.name) : m.name;
64
+ const params = m.params ? dim(` ${m.params}`) : '';
65
+ const quant = m.quant ? dim(` ${m.quant}`) : '';
66
+ const size = m.size ? dim(` (${m.size})`) : '';
67
+ const tag = isCurrent ? green(' \u2190 current') : '';
68
+ console.log(` ${marker} ${num} ${label}${params}${quant}${size}${tag}`);
69
+ }
70
+ }
71
+
72
+ // Cloud models (require API key, cost money)
73
+ if (available.cloud.length > 0) {
74
+ const apiNote = available.hasApiKey ? green('key ✓') : yellow('needs API key');
75
+ console.log(` ${dim('── Cloud')} ${apiNote} ${dim('─────────────────────')}`);
76
+ for (const m of available.cloud) {
77
+ const isCurrent = m.id === currentModel;
78
+ const idx = choices.length;
79
+ choices.push(m);
80
+ const marker = isCurrent ? green('>') : ' ';
81
+ const num = dim(`${idx + 1}.`);
82
+ const label = isCurrent ? bold(m.name) : m.name;
83
+ const provider = m.provider ? dim(` (${m.provider})`) : '';
84
+ const tag = isCurrent ? green(' \u2190 current') : '';
85
+ console.log(` ${marker} ${num} ${label}${provider}${tag}`);
86
+ }
87
+ }
88
+
89
+ // If current model isn't in either list, add it
90
+ if (!choices.some(c => c.id === currentModel)) {
91
+ choices.unshift({ id: currentModel, name: modelShortName(currentModel), tier: 'custom' });
92
+ // Re-render isn't needed since we'll just note it
93
+ console.log(` ${dim(`Current: ${modelShortName(currentModel)} (not in detected models)`)}`);
94
+ }
95
+
96
+ const currentIdx = choices.findIndex(c => c.id === currentModel);
97
+ const defaultNum = currentIdx >= 0 ? currentIdx + 1 : 1;
98
+
99
+ const answer = await rl.question(` ${dim(`Enter 1-${choices.length}`)} ${dim(`[${defaultNum}]`)}: `);
100
+ const idx = parseInt(answer.trim(), 10) - 1;
101
+
102
+ const selected = (idx >= 0 && idx < choices.length) ? choices[idx] : choices[Math.max(0, currentIdx)];
103
+
104
+ // Warn about cloud model costs
105
+ if (selected.tier === 'cloud' && available.local.length > 0) {
106
+ warn(`Cloud models cost tokens per heartbeat cycle. Consider a local model for persistent agents.`);
107
+ }
108
+ if (selected.tier === 'cloud' && !available.hasApiKey) {
109
+ warn(`No API key detected. Run ${bold('openclaw configure')} to set up authentication.`);
110
+ }
111
+
112
+ return selected.id;
45
113
  }
46
114
 
47
115
  // ─── Terminal Spawning ──────────────────────────────────────
48
116
 
49
- /**
50
- * Open a new terminal window/tab running the given command.
51
- * Returns the method used ('terminal', 'iterm', 'fallback').
52
- */
53
117
  function openInTerminal(title, command, cwd) {
54
118
  const platform = process.platform;
55
119
  const termProgram = process.env.TERM_PROGRAM || '';
120
+ const safeCwd = cwd.replace(/'/g, "'\\''").replace(/"/g, '\\"');
121
+ const safeCmd = command.replace(/"/g, '\\"');
56
122
 
57
123
  if (platform === 'darwin') {
58
124
  if (termProgram === 'iTerm.app') {
59
- // iTerm2: open a new tab
60
- const script = `
61
- tell application "iTerm"
62
- tell current window
63
- create tab with default profile
64
- tell current session
65
- set name to "${title}"
66
- write text "cd ${escapeShell(cwd)} && ${command}"
67
- end tell
68
- end tell
69
- end tell
70
- `;
125
+ const script = `tell application "iTerm"
126
+ tell current window
127
+ create tab with default profile
128
+ tell current session
129
+ set name to "${title}"
130
+ write text "cd '${safeCwd}' && ${safeCmd}"
131
+ end tell
132
+ end tell
133
+ end tell`;
71
134
  spawn('osascript', ['-e', script], { stdio: 'ignore', detached: true }).unref();
72
- return 'iterm';
73
- } else {
74
- // Terminal.app: open a new tab
75
- const script = `
76
- tell application "Terminal"
77
- activate
78
- do script "cd ${escapeShell(cwd)} && ${command}"
79
- end tell
80
- `;
81
- spawn('osascript', ['-e', script], { stdio: 'ignore', detached: true }).unref();
82
- return 'terminal';
135
+ return 'iTerm';
83
136
  }
84
- } else if (platform === 'linux') {
85
- // Try common Linux terminal emulators
137
+ const script = `tell application "Terminal"
138
+ activate
139
+ do script "cd '${safeCwd}' && ${safeCmd}"
140
+ end tell`;
141
+ spawn('osascript', ['-e', script], { stdio: 'ignore', detached: true }).unref();
142
+ return 'Terminal';
143
+ }
144
+
145
+ if (platform === 'linux') {
146
+ const shellCmd = `cd '${safeCwd}' && ${command}; exec bash`;
86
147
  const terminals = [
87
- ['gnome-terminal', ['--title', title, '--', 'bash', '-c', `cd ${escapeShell(cwd)} && ${command}; exec bash`]],
88
- ['xterm', ['-title', title, '-e', `cd ${escapeShell(cwd)} && ${command}; exec bash`]],
89
- ['konsole', ['--new-tab', '-e', 'bash', '-c', `cd ${escapeShell(cwd)} && ${command}; exec bash`]],
148
+ ['gnome-terminal', ['--title', title, '--', 'bash', '-c', shellCmd]],
149
+ ['kitty', ['--title', title, 'bash', '-c', shellCmd]],
150
+ ['alacritty', ['--title', title, '-e', 'bash', '-c', shellCmd]],
151
+ ['wezterm', ['start', '--', 'bash', '-c', shellCmd]],
152
+ ['konsole', ['--new-tab', '-e', 'bash', '-c', shellCmd]],
153
+ ['xterm', ['-title', title, '-e', 'bash', '-c', shellCmd]],
90
154
  ];
91
-
92
155
  for (const [bin, args] of terminals) {
93
156
  try {
94
- spawn(bin, args, { stdio: 'ignore', detached: true }).unref();
95
- return bin;
157
+ const child = spawn(bin, args, { stdio: 'ignore', detached: true });
158
+ child.unref();
159
+ // Verify it didn't immediately fail
160
+ const alive = child.pid && !child.killed;
161
+ if (alive) return bin;
96
162
  } catch { continue; }
97
163
  }
98
- } else if (platform === 'win32') {
99
- spawn('cmd.exe', ['/c', 'start', 'cmd', '/k', `cd /d ${cwd} && ${command}`], {
164
+ }
165
+
166
+ if (platform === 'win32') {
167
+ spawn('cmd.exe', ['/c', 'start', `"${title}"`, 'cmd', '/k', `cd /d "${cwd}" && ${command}`], {
100
168
  stdio: 'ignore', detached: true,
101
169
  }).unref();
102
170
  return 'cmd';
@@ -105,10 +173,6 @@ function openInTerminal(title, command, cwd) {
105
173
  return null;
106
174
  }
107
175
 
108
- function escapeShell(str) {
109
- return str.replace(/'/g, "'\\''");
110
- }
111
-
112
176
  // ─── Stop Flow ──────────────────────────────────────────────
113
177
 
114
178
  function stopAllAgents(projectDir) {
@@ -118,156 +182,33 @@ function stopAllAgents(projectDir) {
118
182
  if (running.length === 0) {
119
183
  info('No agents are currently running.');
120
184
  console.log();
121
- return;
185
+ return 0;
122
186
  }
123
187
 
124
- console.log(` ${dim('Stopping')} ${bold(String(running.length))} ${dim('agent(s)...')}`);
125
- console.log();
126
-
127
188
  let stopped = 0;
128
189
  for (const { name, pid } of running) {
129
190
  try {
130
191
  process.kill(pid, 'SIGTERM');
131
192
  removePid(projectDir, name);
132
193
  cleanWorkspace(projectDir, name);
133
- success(`${bold(name)} stopped ${dim(`(PID ${pid})`)}`);
194
+ success(`Stopped ${bold(name)} ${dim(`(PID ${pid})`)}`);
134
195
  stopped++;
135
196
  } catch (err) {
136
197
  if (err.code === 'ESRCH') {
137
198
  removePid(projectDir, name);
138
- success(`${bold(name)} already stopped`);
139
- stopped++;
199
+ // Don't count already-dead processes as "stopped"
140
200
  } else {
141
201
  error(`Failed to stop ${bold(name)}: ${err.message}`);
142
202
  }
143
203
  }
144
204
  }
145
205
 
146
- console.log();
147
- success(`${green(String(stopped))} agent(s) stopped`);
148
- console.log();
149
- }
150
-
151
- // ─── Configure Project ──────────────────────────────────────
152
-
153
- async function configureProject(projectDir, existingConfig) {
154
- const rl = createInterface({ input: stdin, output: stdout });
155
- const templateDefaults = detectTemplateDefaults(projectDir);
156
- const existing = existingConfig?.project || {};
157
-
158
- const defaults = {
159
- name: existing.name || templateDefaults?.name || '',
160
- language: existing.language || templateDefaults?.language || '',
161
- framework: existing.framework || templateDefaults?.framework || '',
162
- devCmd: existing.devCmd || templateDefaults?.devCmd || '',
163
- testCmd: existing.testCmd || templateDefaults?.testCmd || '',
164
- conventions: existing.conventions || templateDefaults?.conventions || '',
165
- };
166
-
167
- if (templateDefaults && !existing.name) {
168
- console.log();
169
- info('Detected SerpentStack template — defaults pre-filled');
170
- }
171
-
172
- console.log();
173
- console.log(` ${bold('Configure your project')}`);
174
- console.log(` ${dim('Press Enter to accept defaults shown in [brackets].')}`);
175
- console.log();
176
-
177
- try {
178
- const project = {
179
- name: await askQuestion(rl, 'Project name', '(e.g., Acme API)', defaults.name),
180
- language: await askQuestion(rl, 'Primary language', '(e.g., Python, TypeScript)', defaults.language),
181
- framework: await askQuestion(rl, 'Framework', '(e.g., FastAPI, Next.js)', defaults.framework),
182
- devCmd: await askQuestion(rl, 'Dev server command', '(e.g., make dev)', defaults.devCmd),
183
- testCmd: await askQuestion(rl, 'Test command', '(e.g., make test)', defaults.testCmd),
184
- conventions: await askQuestion(rl, 'Key conventions', '(brief)', defaults.conventions),
185
- };
186
-
187
- console.log();
188
-
189
- // Update SOUL.md with project context
190
- const soulPath = join(projectDir, '.openclaw/SOUL.md');
191
- if (existsSync(soulPath)) {
192
- let soul = readFileSync(soulPath, 'utf8');
193
-
194
- const projectContext = [
195
- `# ${project.name} — Persistent Development Agents`,
196
- '',
197
- `**Project:** ${project.name}`,
198
- `**Language:** ${project.language}`,
199
- `**Framework:** ${project.framework}`,
200
- `**Dev server:** \`${project.devCmd}\``,
201
- `**Tests:** \`${project.testCmd}\``,
202
- `**Conventions:** ${project.conventions}`,
203
- '',
204
- '---',
205
- '',
206
- ].join('\n');
207
-
208
- const dashIndex = soul.indexOf('---');
209
- if (dashIndex !== -1) {
210
- soul = projectContext + soul.slice(dashIndex + 3).trimStart();
211
- } else {
212
- soul = projectContext + soul;
213
- }
214
-
215
- writeFileSync(soulPath, soul, 'utf8');
216
- success(`Updated ${bold('.openclaw/SOUL.md')} with ${green(project.name)} project context`);
217
- }
218
-
219
- return project;
220
- } finally {
221
- rl.close();
222
- }
223
- }
224
-
225
- // ─── Configure Single Agent ─────────────────────────────────
226
-
227
- async function configureAgent(rl, name, agentMd, existingAgent) {
228
- const currentEnabled = existingAgent?.enabled !== false;
229
- const currentModel = existingAgent?.model || agentMd.meta.model || 'anthropic/claude-haiku-4-20250414';
230
-
231
- const enableStr = await askQuestion(rl, 'Enabled?', '(y/n)', currentEnabled ? 'y' : 'n');
232
- const enabled = enableStr.toLowerCase() !== 'n';
233
-
234
- let model = currentModel;
235
- if (enabled) {
236
- model = await askQuestion(rl, 'Model', '(e.g., anthropic/claude-haiku-4-20250414, ollama/llama3)', currentModel);
237
- }
238
-
239
- return { enabled, model };
240
- }
241
-
242
- // ─── Install OpenClaw ───────────────────────────────────────
243
-
244
- async function installOpenClaw() {
245
- console.log();
246
- warn('OpenClaw is not installed.');
247
- console.log();
248
- console.log(` ${dim('OpenClaw is the persistent agent runtime. Each agent')}`);
249
- console.log(` ${dim('opens in its own terminal window.')}`);
250
- console.log();
251
-
252
- const install = await confirm('Install OpenClaw now? (npm install -g openclaw@latest)');
253
- if (!install) {
254
- console.log();
255
- info('Install manually when ready:');
256
- console.log(` ${dim('$')} ${bold('npm install -g openclaw@latest')}`);
257
- console.log(` ${dim('$')} ${bold('serpentstack persistent')}`);
206
+ if (stopped > 0) {
258
207
  console.log();
259
- return false;
208
+ success(`${stopped} agent(s) stopped`);
260
209
  }
261
-
262
- console.log();
263
- info('Installing OpenClaw...');
264
- await new Promise((resolve, reject) => {
265
- const child = spawn('npm', ['install', '-g', 'openclaw@latest'], { stdio: 'inherit' });
266
- child.on('close', (code) => code === 0 ? resolve() : reject(new Error(`npm install exited with code ${code}`)));
267
- });
268
210
  console.log();
269
- success('OpenClaw installed');
270
- return true;
211
+ return stopped;
271
212
  }
272
213
 
273
214
  // ─── Agent Status ───────────────────────────────────────────
@@ -279,17 +220,17 @@ function getAgentStatus(projectDir, name, config) {
279
220
  return { status: 'stopped', pid: null };
280
221
  }
281
222
 
282
- function printAgentStatus(name, agentMd, config, statusInfo) {
223
+ function printAgentLine(name, agentMd, config, statusInfo) {
283
224
  const model = getEffectiveModel(name, agentMd.meta, config);
284
225
  const schedule = (agentMd.meta.schedule || []).map(s => s.every).join(', ');
285
226
  const modelStr = modelShortName(model);
286
227
 
287
228
  if (statusInfo.status === 'running') {
288
- console.log(` ${green('\u25CF')} ${bold(name)} ${dim(modelStr)} ${dim(schedule)} ${green(`PID ${statusInfo.pid}`)}`);
229
+ console.log(` ${green('')} ${bold(name)} ${dim(modelStr)} ${dim(schedule)} ${green(`PID ${statusInfo.pid}`)}`);
289
230
  } else if (statusInfo.status === 'disabled') {
290
- console.log(` ${dim('\u25CB')} ${dim(name)} ${dim(modelStr)} ${dim(schedule)} ${dim('disabled')}`);
231
+ console.log(` ${dim('')} ${dim(name)} ${dim(modelStr)} ${dim(schedule)} ${dim('disabled')}`);
291
232
  } else {
292
- console.log(` ${yellow('\u25CB')} ${bold(name)} ${dim(modelStr)} ${dim(schedule)} ${dim('stopped')}`);
233
+ console.log(` ${yellow('')} ${bold(name)} ${dim(modelStr)} ${dim(schedule)} ${dim('ready')}`);
293
234
  }
294
235
  }
295
236
 
@@ -300,144 +241,254 @@ export async function persistent({ stop = false, reconfigure = false } = {}) {
300
241
 
301
242
  printHeader();
302
243
 
303
- // --stop flag
244
+ // ── Stop ──
304
245
  if (stop) {
305
246
  stopAllAgents(projectDir);
306
247
  return;
307
248
  }
308
249
 
309
- // Check workspace exists
250
+ // ── Preflight checks ──
310
251
  const soulPath = join(projectDir, '.openclaw/SOUL.md');
311
252
  if (!existsSync(soulPath)) {
312
253
  error('No .openclaw/ workspace found.');
313
- console.log();
314
254
  console.log(` Run ${bold('serpentstack skills')} first to download the workspace files.`);
315
255
  console.log();
316
256
  process.exit(1);
317
257
  }
318
258
 
319
- // Discover agents
320
259
  const agents = discoverAgents(projectDir);
321
260
  if (agents.length === 0) {
322
261
  error('No agents found in .openclaw/agents/');
323
- console.log();
324
262
  console.log(` Run ${bold('serpentstack skills')} to download the default agents,`);
325
263
  console.log(` or create your own at ${bold('.openclaw/agents/<name>/AGENT.md')}`);
326
264
  console.log();
327
265
  process.exit(1);
328
266
  }
329
267
 
268
+ // Check OpenClaw early — don't waste time configuring if it's missing
269
+ const hasOpenClaw = await which('openclaw');
270
+ if (!hasOpenClaw) {
271
+ warn('OpenClaw is not installed.');
272
+ console.log();
273
+ console.log(` ${dim('OpenClaw is the persistent agent runtime.')}`);
274
+ console.log(` ${dim('Install it first, then re-run this command:')}`);
275
+ console.log();
276
+ console.log(` ${dim('$')} ${bold('npm install -g openclaw@latest')}`);
277
+ console.log(` ${dim('$')} ${bold('serpentstack persistent')}`);
278
+ console.log();
279
+ process.exit(1);
280
+ }
281
+
330
282
  cleanStalePids(projectDir);
331
283
 
332
- // Parse all AGENT.md files
284
+ // Parse agent definitions
333
285
  const parsed = [];
334
286
  for (const agent of agents) {
335
287
  try {
336
288
  const agentMd = parseAgentMd(agent.agentMdPath);
337
289
  parsed.push({ ...agent, agentMd });
338
290
  } catch (err) {
339
- error(`${bold(agent.name)}: ${err.message}`);
291
+ warn(`Skipping ${bold(agent.name)}: ${err.message}`);
340
292
  }
341
293
  }
342
-
343
294
  if (parsed.length === 0) {
344
- error('No valid agents found. Check your AGENT.md files.');
295
+ error('No valid AGENT.md files found.');
345
296
  console.log();
346
297
  process.exit(1);
347
298
  }
348
299
 
349
- // Load or create config
300
+ // Load config
350
301
  let config = readConfig(projectDir) || { project: {}, agents: {} };
351
302
  const needsSetup = !config.project?.name || reconfigure;
352
303
 
353
- if (needsSetup) {
354
- // First time or --reconfigure: full setup
355
- console.log(` ${bold('Project Setup')}`);
304
+ // Detect models in background while we show status
305
+ const modelsPromise = detectModels();
356
306
 
357
- config.project = await configureProject(projectDir, config);
307
+ // ── If configured, show status dashboard ──
308
+ if (!needsSetup) {
309
+ console.log(` ${bold(config.project.name)} ${dim(`— ${config.project.framework}`)}`);
310
+ console.log(` ${dim(`Dev: ${config.project.devCmd} · Test: ${config.project.testCmd}`)}`);
358
311
  console.log();
359
312
 
360
- // Configure agents
361
- console.log(` ${bold('Agent Configuration')}`);
313
+ for (const { name, agentMd } of parsed) {
314
+ const statusInfo = getAgentStatus(projectDir, name, config);
315
+ printAgentLine(name, agentMd, config, statusInfo);
316
+ }
362
317
  console.log();
363
318
 
364
- const rl = createInterface({ input: stdin, output: stdout });
365
- try {
366
- for (const { name, agentMd } of parsed) {
367
- const schedule = (agentMd.meta.schedule || []).map(s => s.every).join(', ');
368
- console.log(` ${bold(name)} ${dim(agentMd.meta.description || '')}`);
369
- console.log(` ${dim(`Schedule: ${schedule || 'none'}`)}`);
319
+ // Determine what to do
320
+ const enabledAgents = parsed.filter(a => isAgentEnabled(a.name, config));
321
+ const runningNames = new Set(listPids(projectDir).map(p => p.name));
322
+ const startable = enabledAgents.filter(a => !runningNames.has(a.name));
370
323
 
371
- const agentConfig = await configureAgent(rl, name, agentMd, config.agents?.[name]);
372
- config.agents[name] = agentConfig;
324
+ if (startable.length === 0 && runningNames.size > 0) {
325
+ info('All enabled agents are running.');
326
+ console.log(` ${dim('Run')} ${bold('serpentstack persistent --stop')} ${dim('to stop them.')}`);
327
+ console.log(` ${dim('Run')} ${bold('serpentstack persistent --reconfigure')} ${dim('to change settings.')}`);
328
+ console.log();
329
+ return;
330
+ }
373
331
 
374
- const status = agentConfig.enabled ? green('enabled') : dim('disabled');
375
- console.log(` ${dim('\u2192')} ${status}${agentConfig.enabled ? `, ${dim(modelShortName(agentConfig.model))}` : ''}`);
376
- console.log();
377
- }
378
- } finally {
379
- rl.close();
332
+ if (startable.length === 0) {
333
+ info('No agents are enabled.');
334
+ console.log(` ${dim('Run')} ${bold('serpentstack persistent --reconfigure')} ${dim('to enable agents.')}`);
335
+ console.log();
336
+ return;
380
337
  }
381
338
 
382
- writeConfig(projectDir, config);
383
- success(`Saved ${bold('.openclaw/config.json')}`);
339
+ // Start startable agents
340
+ await launchAgents(projectDir, startable, config, soulPath);
341
+ return;
342
+ }
343
+
344
+ // ── First-time setup / reconfigure ──
345
+ if (reconfigure) {
346
+ info('Reconfiguring...');
384
347
  console.log();
385
348
  }
386
349
 
387
- // Show current status
388
- console.log(` ${bold('Agents')}`);
389
- console.log();
350
+ const rl = createInterface({ input: stdin, output: stdout });
351
+ let configDirty = false;
390
352
 
391
- for (const { name, agentMd } of parsed) {
392
- const statusInfo = getAgentStatus(projectDir, name, config);
393
- printAgentStatus(name, agentMd, config, statusInfo);
394
- }
395
- console.log();
353
+ try {
354
+ // ── Project configuration ──
355
+ const templateDefaults = detectTemplateDefaults(projectDir);
356
+ const existing = config.project || {};
357
+ const defaults = {
358
+ name: existing.name || templateDefaults?.name || '',
359
+ language: existing.language || templateDefaults?.language || '',
360
+ framework: existing.framework || templateDefaults?.framework || '',
361
+ devCmd: existing.devCmd || templateDefaults?.devCmd || '',
362
+ testCmd: existing.testCmd || templateDefaults?.testCmd || '',
363
+ conventions: existing.conventions || templateDefaults?.conventions || '',
364
+ };
396
365
 
397
- // Determine which agents can be started
398
- const enabledAgents = parsed.filter(a => isAgentEnabled(a.name, config));
399
- const runningNames = new Set(listPids(projectDir).map(p => p.name));
400
- const startableAgents = enabledAgents.filter(a => !runningNames.has(a.name));
401
- const runningAgents = enabledAgents.filter(a => runningNames.has(a.name));
366
+ if (templateDefaults && !existing.name) {
367
+ info('Detected SerpentStack template defaults pre-filled');
368
+ console.log();
369
+ }
402
370
 
403
- if (startableAgents.length === 0 && runningAgents.length > 0) {
404
- info('All enabled agents are already running.');
371
+ divider('Project');
372
+ console.log(` ${dim('Press Enter to keep defaults.')}`);
405
373
  console.log();
406
374
 
407
- const doStop = await confirm('Stop all agents?');
408
- if (doStop) {
375
+ config.project = {
376
+ name: await ask(rl, 'Project name', defaults.name),
377
+ language: await ask(rl, 'Primary language', defaults.language),
378
+ framework: await ask(rl, 'Framework', defaults.framework),
379
+ devCmd: await ask(rl, 'Dev server command', defaults.devCmd),
380
+ testCmd: await ask(rl, 'Test command', defaults.testCmd),
381
+ conventions: await ask(rl, 'Key conventions', defaults.conventions),
382
+ };
383
+ configDirty = true;
384
+
385
+ // Update SOUL.md
386
+ if (existsSync(soulPath)) {
387
+ let soul = readFileSync(soulPath, 'utf8');
388
+ const ctx = [
389
+ `# ${config.project.name} \u2014 Persistent Development Agents`,
390
+ '',
391
+ `**Project:** ${config.project.name}`,
392
+ `**Language:** ${config.project.language}`,
393
+ `**Framework:** ${config.project.framework}`,
394
+ `**Dev server:** \`${config.project.devCmd}\``,
395
+ `**Tests:** \`${config.project.testCmd}\``,
396
+ `**Conventions:** ${config.project.conventions}`,
397
+ '', '---', '',
398
+ ].join('\n');
399
+ const dashIdx = soul.indexOf('---');
400
+ soul = dashIdx !== -1 ? ctx + soul.slice(dashIdx + 3).trimStart() : ctx + soul;
401
+ writeFileSync(soulPath, soul, 'utf8');
402
+ }
403
+ console.log();
404
+ success(`Updated ${bold('.openclaw/SOUL.md')}`);
405
+ console.log();
406
+
407
+ // ── Agent configuration ──
408
+ const available = await modelsPromise;
409
+
410
+ if (available.local.length > 0) {
411
+ info(`${available.local.length} local model(s) detected via Ollama`);
412
+ } else {
413
+ warn('No local models found. Install Ollama and pull a model for free persistent agents:');
414
+ console.log(` ${dim('$')} ${bold('ollama pull llama3.2')}`);
415
+ }
416
+ if (available.hasApiKey) {
417
+ info('API key configured for cloud models');
418
+ }
419
+ console.log();
420
+
421
+ divider('Agents');
422
+ console.log(` ${dim('Enable/disable each agent and pick a model.')}`);
423
+ console.log();
424
+
425
+ for (const { name, agentMd } of parsed) {
426
+ const existingAgent = config.agents?.[name];
427
+ const currentEnabled = existingAgent?.enabled !== false;
428
+ const currentModel = existingAgent?.model || 'ollama/llama3.2';
429
+ const schedule = (agentMd.meta.schedule || []).map(s => s.every).join(', ');
430
+
431
+ console.log(` ${bold(name)} ${dim(agentMd.meta.description || '')}`);
432
+ console.log(` ${dim(`Schedule: ${schedule || 'none'}`)}`);
433
+
434
+ const enabled = await askYesNo(rl, `Enable ${bold(name)}?`, currentEnabled);
435
+
436
+ let model = currentModel;
437
+ if (enabled) {
438
+ console.log();
439
+ model = await pickModel(rl, name, currentModel, available);
440
+ }
441
+
442
+ config.agents[name] = { enabled, model };
443
+
444
+ const status = enabled ? green('\u2713 enabled') : dim('\u2717 disabled');
445
+ const modelLabel = enabled ? `, ${modelShortName(model)}` : '';
446
+ console.log(` ${status}${modelLabel}`);
447
+ console.log();
448
+ }
449
+
450
+ configDirty = true;
451
+ } finally {
452
+ rl.close();
453
+ // Only save if we completed configuration
454
+ if (configDirty) {
455
+ writeConfig(projectDir, config);
456
+ success(`Saved ${bold('.openclaw/config.json')}`);
409
457
  console.log();
410
- stopAllAgents(projectDir);
411
458
  }
412
- return;
413
459
  }
414
460
 
415
- if (startableAgents.length === 0) {
416
- info('No agents are enabled. Run with --reconfigure to enable agents.');
461
+ // Show status and launch
462
+ for (const { name, agentMd } of parsed) {
463
+ const statusInfo = getAgentStatus(projectDir, name, config);
464
+ printAgentLine(name, agentMd, config, statusInfo);
465
+ }
466
+ console.log();
467
+
468
+ const enabledAgents = parsed.filter(a => isAgentEnabled(a.name, config));
469
+ if (enabledAgents.length === 0) {
470
+ info('No agents enabled. Run with --reconfigure to enable agents.');
417
471
  console.log();
418
472
  return;
419
473
  }
420
474
 
421
- // Let user pick which agents to start
475
+ await launchAgents(projectDir, enabledAgents, config, soulPath);
476
+ }
477
+
478
+ // ─── Launch Flow ────────────────────────────────────────────
479
+
480
+ async function launchAgents(projectDir, agentsToStart, config, soulPath) {
422
481
  const rl = createInterface({ input: stdin, output: stdout });
423
482
  const toStart = [];
424
483
 
425
484
  try {
426
- if (startableAgents.length === 1) {
427
- const a = startableAgents[0];
428
- const start = await confirm(`Start ${bold(a.name)}?`);
429
- if (start) toStart.push(a);
430
- } else {
431
- console.log(` ${dim('Which agents would you like to start?')}`);
432
- console.log();
485
+ divider('Launch');
486
+ console.log();
433
487
 
434
- for (const agent of startableAgents) {
435
- const model = getEffectiveModel(agent.name, agent.agentMd.meta, config);
436
- const answer = await rl.question(` ${green('?')} Start ${bold(agent.name)} ${dim(`(${modelShortName(model)})`)}? ${dim('[Y/n]')} `);
437
- if (answer.trim().toLowerCase() !== 'n') {
438
- toStart.push(agent);
439
- }
440
- }
488
+ for (const agent of agentsToStart) {
489
+ const model = getEffectiveModel(agent.name, agent.agentMd.meta, config);
490
+ const yes = await askYesNo(rl, `Start ${bold(agent.name)} ${dim(`(${modelShortName(model)})`)}?`, true);
491
+ if (yes) toStart.push(agent);
441
492
  }
442
493
  } finally {
443
494
  rl.close();
@@ -450,16 +501,8 @@ export async function persistent({ stop = false, reconfigure = false } = {}) {
450
501
  return;
451
502
  }
452
503
 
453
- // Check OpenClaw
454
- const hasOpenClaw = await which('openclaw');
455
- if (!hasOpenClaw) {
456
- const installed = await installOpenClaw();
457
- if (!installed) return;
458
- }
459
-
460
504
  console.log();
461
505
 
462
- // Start each agent in its own terminal window
463
506
  const sharedSoul = readFileSync(soulPath, 'utf8');
464
507
  let started = 0;
465
508
 
@@ -475,24 +518,19 @@ export async function persistent({ stop = false, reconfigure = false } = {}) {
475
518
  const absWorkspace = resolve(workspacePath);
476
519
  const absProject = resolve(projectDir);
477
520
 
478
- // Build the openclaw command
479
- const openclawCmd = `OPENCLAW_STATE_DIR="${join(absWorkspace, '.state')}" openclaw start --workspace "${absWorkspace}"`;
521
+ const openclawCmd = `OPENCLAW_STATE_DIR='${join(absWorkspace, '.state')}' openclaw start --workspace '${absWorkspace}'`;
480
522
 
481
- // Try to open in a new terminal window
482
- const method = openInTerminal(
483
- `SerpentStack: ${name}`,
484
- openclawCmd,
485
- absProject,
486
- );
523
+ const method = openInTerminal(`SerpentStack: ${name}`, openclawCmd, absProject);
487
524
 
488
525
  if (method) {
489
- // Record PID tracking (we won't have the exact PID from the terminal,
490
- // but we record a placeholder so --stop can find/kill it)
491
- success(`${bold(name)} opened in new ${method} window ${dim(`(${modelShortName(effectiveModel)})`)}`);
526
+ // For terminal-spawned agents, record workspace path so we can track it
527
+ // The terminal process will create its own PID we record ours as a marker
528
+ writePid(projectDir, name, -1); // -1 = terminal-managed
529
+ success(`${bold(name)} opened in ${method} ${dim(`(${modelShortName(effectiveModel)})`)}`);
492
530
  started++;
493
531
  } else {
494
- // Fallback: start in background if no terminal method works
495
- warn(`Could not open terminal for ${bold(name)} — starting in background`);
532
+ // Fallback: background process
533
+ warn(`No terminal detected \u2014 starting ${bold(name)} in background`);
496
534
  const child = spawn('openclaw', ['start', '--workspace', absWorkspace], {
497
535
  stdio: 'ignore',
498
536
  detached: true,
@@ -501,7 +539,7 @@ export async function persistent({ stop = false, reconfigure = false } = {}) {
501
539
  });
502
540
  child.unref();
503
541
  writePid(projectDir, name, child.pid);
504
- success(`${bold(name)} started in background ${dim(`PID ${child.pid}`)}`);
542
+ success(`${bold(name)} started ${dim(`PID ${child.pid}`)}`);
505
543
  started++;
506
544
  }
507
545
  } catch (err) {
@@ -511,18 +549,12 @@ export async function persistent({ stop = false, reconfigure = false } = {}) {
511
549
 
512
550
  console.log();
513
551
  if (started > 0) {
514
- success(`${green(String(started))} agent(s) launched`);
552
+ success(`${started} agent(s) launched — fangs out 🐍`);
515
553
  console.log();
516
554
  printBox('Manage agents', [
517
- `${dim('$')} ${bold('serpentstack persistent')} ${dim('# view status + start agents')}`,
518
- `${dim('$')} ${bold('serpentstack persistent --stop')} ${dim('# stop all agents')}`,
519
- `${dim('$')} ${bold('serpentstack persistent --reconfigure')} ${dim('# change models, enable/disable')}`,
520
- '',
521
- `${dim('Each agent runs in its own terminal window.')}`,
522
- `${dim('Close a window to stop that agent.')}`,
555
+ `${dim('$')} ${bold('serpentstack persistent')} ${dim('# status + start')}`,
556
+ `${dim('$')} ${bold('serpentstack persistent --stop')} ${dim('# stop all')}`,
557
+ `${dim('$')} ${bold('serpentstack persistent --reconfigure')} ${dim('# change models')}`,
523
558
  ]);
524
- } else {
525
- error('No agents were started. Check the errors above.');
526
- console.log();
527
559
  }
528
560
  }