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.
- package/bin/serpentstack.js +76 -29
- package/lib/commands/persistent.js +318 -286
- package/lib/commands/skills-init.js +61 -23
- package/lib/commands/skills-update.js +10 -8
- package/lib/commands/stack-new.js +56 -21
- package/lib/utils/agent-utils.js +1 -1
- package/lib/utils/config.js +14 -7
- package/lib/utils/fs-helpers.js +1 -1
- package/lib/utils/models.js +181 -0
- package/lib/utils/ui.js +70 -49
- package/package.json +3 -3
|
@@ -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,
|
|
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
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
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
|
|
41
|
-
const
|
|
42
|
-
const
|
|
43
|
-
const
|
|
44
|
-
|
|
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
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
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 '
|
|
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
|
-
|
|
85
|
-
|
|
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',
|
|
88
|
-
['
|
|
89
|
-
['
|
|
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 })
|
|
95
|
-
|
|
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
|
-
}
|
|
99
|
-
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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(`
|
|
229
|
+
console.log(` ${green('●')} ${bold(name)} ${dim(modelStr)} ${dim(schedule)} ${green(`PID ${statusInfo.pid}`)}`);
|
|
289
230
|
} else if (statusInfo.status === 'disabled') {
|
|
290
|
-
console.log(`
|
|
231
|
+
console.log(` ${dim('○')} ${dim(name)} ${dim(modelStr)} ${dim(schedule)} ${dim('disabled')}`);
|
|
291
232
|
} else {
|
|
292
|
-
console.log(`
|
|
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
|
-
//
|
|
244
|
+
// ── Stop ──
|
|
304
245
|
if (stop) {
|
|
305
246
|
stopAllAgents(projectDir);
|
|
306
247
|
return;
|
|
307
248
|
}
|
|
308
249
|
|
|
309
|
-
//
|
|
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
|
|
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
|
-
|
|
291
|
+
warn(`Skipping ${bold(agent.name)}: ${err.message}`);
|
|
340
292
|
}
|
|
341
293
|
}
|
|
342
|
-
|
|
343
294
|
if (parsed.length === 0) {
|
|
344
|
-
error('No valid
|
|
295
|
+
error('No valid AGENT.md files found.');
|
|
345
296
|
console.log();
|
|
346
297
|
process.exit(1);
|
|
347
298
|
}
|
|
348
299
|
|
|
349
|
-
// Load
|
|
300
|
+
// Load config
|
|
350
301
|
let config = readConfig(projectDir) || { project: {}, agents: {} };
|
|
351
302
|
const needsSetup = !config.project?.name || reconfigure;
|
|
352
303
|
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
console.log(` ${bold('Project Setup')}`);
|
|
304
|
+
// Detect models in background while we show status
|
|
305
|
+
const modelsPromise = detectModels();
|
|
356
306
|
|
|
357
|
-
|
|
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
|
-
|
|
361
|
-
|
|
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
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
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
|
-
|
|
372
|
-
|
|
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
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
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
|
-
|
|
383
|
-
|
|
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
|
-
|
|
388
|
-
|
|
389
|
-
console.log();
|
|
350
|
+
const rl = createInterface({ input: stdin, output: stdout });
|
|
351
|
+
let configDirty = false;
|
|
390
352
|
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
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
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
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
|
-
|
|
404
|
-
|
|
371
|
+
divider('Project');
|
|
372
|
+
console.log(` ${dim('Press Enter to keep defaults.')}`);
|
|
405
373
|
console.log();
|
|
406
374
|
|
|
407
|
-
|
|
408
|
-
|
|
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
|
-
|
|
416
|
-
|
|
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
|
-
|
|
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
|
-
|
|
427
|
-
|
|
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
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
490
|
-
//
|
|
491
|
-
|
|
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:
|
|
495
|
-
warn(`
|
|
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
|
|
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(`${
|
|
552
|
+
success(`${started} agent(s) launched — fangs out 🐍`);
|
|
515
553
|
console.log();
|
|
516
554
|
printBox('Manage agents', [
|
|
517
|
-
`${dim('$')} ${bold('serpentstack persistent')} ${dim('#
|
|
518
|
-
`${dim('$')} ${bold('serpentstack persistent --stop')} ${dim('# stop all
|
|
519
|
-
`${dim('$')} ${bold('serpentstack persistent --reconfigure')} ${dim('# change models
|
|
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
|
}
|