serpentstack 0.2.3 → 0.2.4
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 +2 -2
- package/lib/commands/skills-init.js +7 -6
- package/lib/commands/skills-persistent.js +212 -103
- package/lib/commands/skills-update.js +10 -4
- package/lib/utils/agent-utils.js +288 -0
- package/package.json +1 -1
package/bin/serpentstack.js
CHANGED
|
@@ -27,8 +27,8 @@ function showHelp() {
|
|
|
27
27
|
${bold(green('Skills commands'))} ${dim('(any project)')}
|
|
28
28
|
${cyan('skills init')} Download base skills + persistent agent configs
|
|
29
29
|
${cyan('skills update')} Update base skills to latest versions
|
|
30
|
-
${cyan('skills persistent')} Guided setup: configure + install + start
|
|
31
|
-
${cyan('skills persistent')} --stop Stop
|
|
30
|
+
${cyan('skills persistent')} Guided setup: configure + install + start all agents
|
|
31
|
+
${cyan('skills persistent')} --stop Stop all running agents
|
|
32
32
|
|
|
33
33
|
${bold('Options:')}
|
|
34
34
|
--force Overwrite existing files
|
|
@@ -12,8 +12,9 @@ const SKILLS_FILES = [
|
|
|
12
12
|
|
|
13
13
|
const OPENCLAW_FILES = [
|
|
14
14
|
'.openclaw/SOUL.md',
|
|
15
|
-
'.openclaw/
|
|
16
|
-
'.openclaw/
|
|
15
|
+
'.openclaw/agents/log-watcher/AGENT.md',
|
|
16
|
+
'.openclaw/agents/test-runner/AGENT.md',
|
|
17
|
+
'.openclaw/agents/skill-maintainer/AGENT.md',
|
|
17
18
|
];
|
|
18
19
|
|
|
19
20
|
const DOCS_FILES = [
|
|
@@ -95,11 +96,11 @@ export async function skillsInit({ force = false } = {}) {
|
|
|
95
96
|
`descriptions. Reference SKILL-AUTHORING.md for the format.`,
|
|
96
97
|
]);
|
|
97
98
|
|
|
98
|
-
printBox('After generating skills, try setting up
|
|
99
|
+
printBox('After generating skills, try setting up persistent agents too', [
|
|
99
100
|
`${dim('$')} ${bold('serpentstack skills persistent')} ${dim('# guided setup + start')}`,
|
|
100
101
|
'',
|
|
101
|
-
`${dim('
|
|
102
|
-
`${dim('
|
|
103
|
-
`${dim('
|
|
102
|
+
`${dim('Background agents that watch your dev server, run tests,')}`,
|
|
103
|
+
`${dim('and keep your skills up to date. Each agent in .openclaw/agents/')}`,
|
|
104
|
+
`${dim('runs independently — add, remove, or customize them freely.')}`,
|
|
104
105
|
]);
|
|
105
106
|
}
|
|
@@ -1,9 +1,21 @@
|
|
|
1
|
-
import { existsSync } from 'node:fs';
|
|
1
|
+
import { existsSync, readFileSync, writeFileSync } from 'node:fs';
|
|
2
2
|
import { join } 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, printBox, printHeader } from '../utils/ui.js';
|
|
6
|
+
import { info, success, warn, error, confirm, bold, dim, green, cyan, yellow, printBox, printHeader } from '../utils/ui.js';
|
|
7
|
+
import {
|
|
8
|
+
parseAgentMd,
|
|
9
|
+
discoverAgents,
|
|
10
|
+
generateWorkspace,
|
|
11
|
+
writePid,
|
|
12
|
+
readPid,
|
|
13
|
+
removePid,
|
|
14
|
+
listPids,
|
|
15
|
+
isProcessAlive,
|
|
16
|
+
cleanStalePids,
|
|
17
|
+
cleanWorkspace,
|
|
18
|
+
} from '../utils/agent-utils.js';
|
|
7
19
|
|
|
8
20
|
function which(cmd) {
|
|
9
21
|
return new Promise((resolve) => {
|
|
@@ -11,38 +23,59 @@ function which(cmd) {
|
|
|
11
23
|
});
|
|
12
24
|
}
|
|
13
25
|
|
|
14
|
-
function
|
|
15
|
-
const
|
|
16
|
-
|
|
17
|
-
if (!existsSync(dir)) return false;
|
|
18
|
-
return required.every((f) => existsSync(join(dir, f)));
|
|
26
|
+
async function askQuestion(rl, label, hint) {
|
|
27
|
+
const answer = await rl.question(` ${green('?')} ${bold(label)}${hint ? ` ${dim(hint)}` : ''}: `);
|
|
28
|
+
return answer.trim();
|
|
19
29
|
}
|
|
20
30
|
|
|
21
|
-
|
|
22
|
-
const soulPath = join(process.cwd(), '.openclaw/SOUL.md');
|
|
23
|
-
if (!existsSync(soulPath)) return false;
|
|
24
|
-
const { readFileSync } = require_fs();
|
|
25
|
-
const content = readFileSync(soulPath, 'utf8');
|
|
26
|
-
// If it still has the template placeholder, it's not customized
|
|
27
|
-
return !content.includes('{{PROJECT_NAME}}') && !content.startsWith('# SerpentStack');
|
|
28
|
-
}
|
|
31
|
+
// ─── Stop Flow ──────────────────────────────────────────────
|
|
29
32
|
|
|
30
|
-
function
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
}
|
|
33
|
+
async function stopAllAgents(projectDir) {
|
|
34
|
+
cleanStalePids(projectDir);
|
|
35
|
+
const running = listPids(projectDir);
|
|
34
36
|
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
37
|
+
if (running.length === 0) {
|
|
38
|
+
info('No agents are currently running.');
|
|
39
|
+
console.log();
|
|
40
|
+
return;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
console.log(` ${dim('Stopping')} ${bold(String(running.length))} ${dim('agent(s)...')}`);
|
|
44
|
+
console.log();
|
|
45
|
+
|
|
46
|
+
let stopped = 0;
|
|
47
|
+
for (const { name, pid } of running) {
|
|
48
|
+
try {
|
|
49
|
+
process.kill(pid, 'SIGTERM');
|
|
50
|
+
removePid(projectDir, name);
|
|
51
|
+
cleanWorkspace(projectDir, name);
|
|
52
|
+
success(`${bold(name)} stopped ${dim(`(PID ${pid})`)}`);
|
|
53
|
+
stopped++;
|
|
54
|
+
} catch (err) {
|
|
55
|
+
if (err.code === 'ESRCH') {
|
|
56
|
+
// Process already dead
|
|
57
|
+
removePid(projectDir, name);
|
|
58
|
+
success(`${bold(name)} already stopped`);
|
|
59
|
+
stopped++;
|
|
60
|
+
} else {
|
|
61
|
+
error(`Failed to stop ${bold(name)}: ${err.message}`);
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
console.log();
|
|
67
|
+
success(`${green(String(stopped))} agent(s) stopped`);
|
|
68
|
+
console.log();
|
|
38
69
|
}
|
|
39
70
|
|
|
40
|
-
|
|
71
|
+
// ─── Customize Workspace ────────────────────────────────────
|
|
72
|
+
|
|
73
|
+
async function customizeWorkspace(projectDir) {
|
|
41
74
|
const rl = createInterface({ input: stdin, output: stdout });
|
|
42
75
|
|
|
43
76
|
console.log();
|
|
44
|
-
console.log(` ${bold('Configure your
|
|
45
|
-
console.log(` ${dim(
|
|
77
|
+
console.log(` ${bold('Configure your project identity')}`);
|
|
78
|
+
console.log(` ${dim('Answer a few questions so all agents understand your project.')}`);
|
|
46
79
|
console.log();
|
|
47
80
|
|
|
48
81
|
try {
|
|
@@ -55,13 +88,11 @@ async function customizeWorkspace() {
|
|
|
55
88
|
|
|
56
89
|
console.log();
|
|
57
90
|
|
|
58
|
-
|
|
59
|
-
const { readFileSync, writeFileSync } = await import('node:fs');
|
|
60
|
-
const soulPath = join(process.cwd(), '.openclaw/SOUL.md');
|
|
91
|
+
const soulPath = join(projectDir, '.openclaw/SOUL.md');
|
|
61
92
|
let soul = readFileSync(soulPath, 'utf8');
|
|
62
93
|
|
|
63
94
|
const projectContext = [
|
|
64
|
-
`# ${name} — Persistent Development
|
|
95
|
+
`# ${name} — Persistent Development Agents`,
|
|
65
96
|
'',
|
|
66
97
|
`**Project:** ${name}`,
|
|
67
98
|
`**Language:** ${lang}`,
|
|
@@ -84,35 +115,26 @@ async function customizeWorkspace() {
|
|
|
84
115
|
writeFileSync(soulPath, soul, 'utf8');
|
|
85
116
|
success(`Updated ${bold('.openclaw/SOUL.md')} with ${green(name)} project context`);
|
|
86
117
|
|
|
87
|
-
// Update HEARTBEAT.md with the actual dev/test commands
|
|
88
|
-
const heartbeatPath = join(process.cwd(), '.openclaw/HEARTBEAT.md');
|
|
89
|
-
if (existsSync(heartbeatPath)) {
|
|
90
|
-
let heartbeat = readFileSync(heartbeatPath, 'utf8');
|
|
91
|
-
// Replace placeholder commands if they exist
|
|
92
|
-
heartbeat = heartbeat.replace(/make dev/g, devCmd);
|
|
93
|
-
heartbeat = heartbeat.replace(/make test/g, testCmd);
|
|
94
|
-
writeFileSync(heartbeatPath, heartbeat, 'utf8');
|
|
95
|
-
success(`Updated ${bold('.openclaw/HEARTBEAT.md')} with your dev/test commands`);
|
|
96
|
-
}
|
|
97
|
-
|
|
98
118
|
return true;
|
|
99
119
|
} finally {
|
|
100
120
|
rl.close();
|
|
101
121
|
}
|
|
102
122
|
}
|
|
103
123
|
|
|
124
|
+
// ─── Install OpenClaw ───────────────────────────────────────
|
|
125
|
+
|
|
104
126
|
async function installOpenClaw() {
|
|
105
127
|
console.log();
|
|
106
128
|
warn('OpenClaw is not installed.');
|
|
107
129
|
console.log();
|
|
108
|
-
console.log(` ${dim('OpenClaw is the persistent agent runtime.
|
|
109
|
-
console.log(` ${dim('
|
|
130
|
+
console.log(` ${dim('OpenClaw is the persistent agent runtime. Each agent in')}`);
|
|
131
|
+
console.log(` ${dim('.openclaw/agents/ runs as a separate OpenClaw process.')}`);
|
|
110
132
|
console.log();
|
|
111
133
|
|
|
112
134
|
const install = await confirm('Install OpenClaw now? (npm install -g openclaw@latest)');
|
|
113
135
|
if (!install) {
|
|
114
136
|
console.log();
|
|
115
|
-
info(
|
|
137
|
+
info('Install manually when ready:');
|
|
116
138
|
console.log(` ${dim('$')} ${bold('npm install -g openclaw@latest')}`);
|
|
117
139
|
console.log(` ${dim('$')} ${bold('serpentstack skills persistent')}`);
|
|
118
140
|
console.log();
|
|
@@ -130,99 +152,152 @@ async function installOpenClaw() {
|
|
|
130
152
|
return true;
|
|
131
153
|
}
|
|
132
154
|
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
155
|
+
// ─── Start Agents ───────────────────────────────────────────
|
|
156
|
+
|
|
157
|
+
function startAgent(projectDir, agentName, workspacePath) {
|
|
158
|
+
return new Promise((resolve, reject) => {
|
|
159
|
+
const child = spawn('openclaw', ['start', '--workspace', workspacePath], {
|
|
160
|
+
stdio: 'ignore',
|
|
161
|
+
detached: true,
|
|
162
|
+
cwd: projectDir,
|
|
163
|
+
env: {
|
|
164
|
+
...process.env,
|
|
165
|
+
OPENCLAW_STATE_DIR: join(workspacePath, '.state'),
|
|
166
|
+
},
|
|
167
|
+
});
|
|
143
168
|
|
|
144
|
-
|
|
145
|
-
stdio: 'inherit',
|
|
146
|
-
cwd: process.cwd(),
|
|
147
|
-
});
|
|
169
|
+
child.unref();
|
|
148
170
|
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
});
|
|
171
|
+
child.on('error', (err) => {
|
|
172
|
+
reject(new Error(`Failed to start ${agentName}: ${err.message}`));
|
|
173
|
+
});
|
|
153
174
|
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
}
|
|
175
|
+
// Give it a moment to start, then record PID
|
|
176
|
+
setTimeout(() => {
|
|
177
|
+
writePid(projectDir, agentName, child.pid);
|
|
178
|
+
resolve(child.pid);
|
|
179
|
+
}, 500);
|
|
159
180
|
});
|
|
160
181
|
}
|
|
161
182
|
|
|
183
|
+
// ─── Main Flow ──────────────────────────────────────────────
|
|
184
|
+
|
|
162
185
|
export async function skillsPersistent({ stop = false } = {}) {
|
|
163
|
-
|
|
186
|
+
const projectDir = process.cwd();
|
|
187
|
+
|
|
188
|
+
// --stop flag
|
|
164
189
|
if (stop) {
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
error('OpenClaw is not installed. Nothing to stop.');
|
|
168
|
-
return;
|
|
169
|
-
}
|
|
170
|
-
info('Stopping persistent agent...');
|
|
171
|
-
await new Promise((resolve, reject) => {
|
|
172
|
-
execFile('openclaw', ['stop'], (err, _stdout, stderr) => {
|
|
173
|
-
if (err) reject(new Error(stderr || err.message));
|
|
174
|
-
else resolve();
|
|
175
|
-
});
|
|
176
|
-
});
|
|
177
|
-
success('Persistent agent stopped');
|
|
178
|
-
console.log();
|
|
190
|
+
printHeader();
|
|
191
|
+
await stopAllAgents(projectDir);
|
|
179
192
|
return;
|
|
180
193
|
}
|
|
181
194
|
|
|
182
195
|
// === Guided setup flow ===
|
|
183
196
|
printHeader();
|
|
184
197
|
|
|
185
|
-
// Step 1: Check
|
|
186
|
-
console.log(` ${bold('Step 1/
|
|
198
|
+
// Step 1: Check workspace
|
|
199
|
+
console.log(` ${bold('Step 1/4')} ${dim('\u2014 Check workspace')}`);
|
|
187
200
|
console.log();
|
|
188
201
|
|
|
189
|
-
|
|
202
|
+
const soulPath = join(projectDir, '.openclaw/SOUL.md');
|
|
203
|
+
if (!existsSync(soulPath)) {
|
|
190
204
|
error('No .openclaw/ workspace found.');
|
|
191
205
|
console.log();
|
|
192
206
|
console.log(` Run ${bold('serpentstack skills init')} first to download the workspace files.`);
|
|
193
207
|
console.log();
|
|
194
208
|
process.exit(1);
|
|
195
209
|
}
|
|
210
|
+
success('.openclaw/SOUL.md found');
|
|
196
211
|
|
|
197
|
-
|
|
212
|
+
const agents = discoverAgents(projectDir);
|
|
213
|
+
if (agents.length === 0) {
|
|
214
|
+
error('No agents found in .openclaw/agents/');
|
|
215
|
+
console.log();
|
|
216
|
+
console.log(` Run ${bold('serpentstack skills init')} to download the default agents,`);
|
|
217
|
+
console.log(` or create your own at ${bold('.openclaw/agents/<name>/AGENT.md')}`);
|
|
218
|
+
console.log();
|
|
219
|
+
process.exit(1);
|
|
220
|
+
}
|
|
221
|
+
success(`${green(String(agents.length))} agent(s) found in .openclaw/agents/`);
|
|
198
222
|
console.log();
|
|
199
223
|
|
|
200
|
-
//
|
|
201
|
-
|
|
224
|
+
// Clean up any stale PIDs from previous runs
|
|
225
|
+
cleanStalePids(projectDir);
|
|
226
|
+
|
|
227
|
+
// Check if any agents are already running
|
|
228
|
+
const alreadyRunning = listPids(projectDir);
|
|
229
|
+
if (alreadyRunning.length > 0) {
|
|
230
|
+
warn(`${bold(String(alreadyRunning.length))} agent(s) already running`);
|
|
231
|
+
for (const { name, pid } of alreadyRunning) {
|
|
232
|
+
console.log(` ${dim('\u2022')} ${bold(name)} ${dim(`(PID ${pid})`)}`);
|
|
233
|
+
}
|
|
234
|
+
console.log();
|
|
235
|
+
const restart = await confirm('Stop running agents and restart?');
|
|
236
|
+
if (restart) {
|
|
237
|
+
await stopAllAgents(projectDir);
|
|
238
|
+
} else {
|
|
239
|
+
console.log();
|
|
240
|
+
info('Keeping existing agents running.');
|
|
241
|
+
console.log();
|
|
242
|
+
return;
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
// Step 2: Configure project identity
|
|
247
|
+
console.log(` ${bold('Step 2/4')} ${dim('\u2014 Configure project identity')}`);
|
|
202
248
|
|
|
203
|
-
// Check if SOUL.md looks like it has been customized
|
|
204
|
-
const { readFileSync } = await import('node:fs');
|
|
205
|
-
const soulPath = join(process.cwd(), '.openclaw/SOUL.md');
|
|
206
249
|
const soulContent = readFileSync(soulPath, 'utf8');
|
|
207
250
|
const isCustomized = !soulContent.startsWith('# SerpentStack') && !soulContent.includes('{{PROJECT_NAME}}');
|
|
208
251
|
|
|
209
252
|
if (isCustomized) {
|
|
210
253
|
console.log();
|
|
211
|
-
success('
|
|
254
|
+
success('SOUL.md already customized');
|
|
212
255
|
console.log();
|
|
213
|
-
|
|
214
256
|
const reconfigure = await confirm('Reconfigure? (will overwrite current settings)');
|
|
215
257
|
if (reconfigure) {
|
|
216
|
-
await customizeWorkspace();
|
|
258
|
+
await customizeWorkspace(projectDir);
|
|
217
259
|
}
|
|
218
260
|
console.log();
|
|
219
261
|
} else {
|
|
220
|
-
await customizeWorkspace();
|
|
262
|
+
await customizeWorkspace(projectDir);
|
|
263
|
+
console.log();
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
// Step 3: Select agents
|
|
267
|
+
console.log(` ${bold('Step 3/4')} ${dim('\u2014 Review agents')}`);
|
|
268
|
+
console.log();
|
|
269
|
+
|
|
270
|
+
// Parse all agents and display them
|
|
271
|
+
const parsed = [];
|
|
272
|
+
for (const agent of agents) {
|
|
273
|
+
try {
|
|
274
|
+
const agentMd = parseAgentMd(agent.agentMdPath);
|
|
275
|
+
parsed.push({ ...agent, agentMd });
|
|
276
|
+
|
|
277
|
+
const model = agentMd.meta.model || 'default';
|
|
278
|
+
const modelShort = model.includes('haiku') ? 'Haiku' : model.includes('sonnet') ? 'Sonnet' : model.includes('opus') ? 'Opus' : model;
|
|
279
|
+
const schedule = (agentMd.meta.schedule || []).map(s => s.every).join(', ');
|
|
280
|
+
|
|
281
|
+
success(`${bold(agent.name)} ${dim(agentMd.meta.description || '')}`);
|
|
282
|
+
console.log(` ${dim(`Model: ${modelShort}`)}${schedule ? dim(` \u2022 Schedule: ${schedule}`) : ''}`);
|
|
283
|
+
} catch (err) {
|
|
284
|
+
error(`${bold(agent.name)}: ${err.message}`);
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
if (parsed.length === 0) {
|
|
289
|
+
console.log();
|
|
290
|
+
error('No valid agents found. Check your AGENT.md files.');
|
|
221
291
|
console.log();
|
|
292
|
+
process.exit(1);
|
|
222
293
|
}
|
|
223
294
|
|
|
224
|
-
|
|
225
|
-
console.log(` ${
|
|
295
|
+
console.log();
|
|
296
|
+
console.log(` ${dim(`${parsed.length} agent(s) will be started. Delete an agent's folder to disable it.`)}`);
|
|
297
|
+
console.log();
|
|
298
|
+
|
|
299
|
+
// Step 4: Install + start
|
|
300
|
+
console.log(` ${bold('Step 4/4')} ${dim('\u2014 Start agents')}`);
|
|
226
301
|
|
|
227
302
|
const hasOpenClaw = await which('openclaw');
|
|
228
303
|
if (!hasOpenClaw) {
|
|
@@ -232,18 +307,52 @@ export async function skillsPersistent({ stop = false } = {}) {
|
|
|
232
307
|
success('OpenClaw is installed');
|
|
233
308
|
}
|
|
234
309
|
|
|
235
|
-
const shouldStart = await confirm(
|
|
310
|
+
const shouldStart = await confirm(`Start ${parsed.length} agent(s) now?`);
|
|
236
311
|
if (!shouldStart) {
|
|
237
312
|
console.log();
|
|
238
313
|
printBox('Start later', [
|
|
239
|
-
`${dim('$')} ${bold('serpentstack skills persistent')}
|
|
240
|
-
`${dim('$')} ${bold('
|
|
241
|
-
'',
|
|
242
|
-
`${dim('To stop:')}`,
|
|
243
|
-
`${dim('$')} ${bold('serpentstack skills persistent --stop')}`,
|
|
314
|
+
`${dim('$')} ${bold('serpentstack skills persistent')} ${dim('# run setup again')}`,
|
|
315
|
+
`${dim('$')} ${bold('serpentstack skills persistent --stop')} ${dim('# stop all agents')}`,
|
|
244
316
|
]);
|
|
245
317
|
return;
|
|
246
318
|
}
|
|
247
319
|
|
|
248
|
-
|
|
320
|
+
console.log();
|
|
321
|
+
|
|
322
|
+
// Read shared SOUL.md
|
|
323
|
+
const sharedSoul = readFileSync(soulPath, 'utf8');
|
|
324
|
+
let started = 0;
|
|
325
|
+
|
|
326
|
+
for (const { name, agentMd } of parsed) {
|
|
327
|
+
try {
|
|
328
|
+
const workspacePath = generateWorkspace(projectDir, name, agentMd, sharedSoul);
|
|
329
|
+
const pid = await startAgent(projectDir, name, workspacePath);
|
|
330
|
+
|
|
331
|
+
const model = agentMd.meta.model || 'default';
|
|
332
|
+
const modelShort = model.includes('haiku') ? 'Haiku' : model.includes('sonnet') ? 'Sonnet' : model.includes('opus') ? 'Opus' : model;
|
|
333
|
+
const schedule = (agentMd.meta.schedule || []).map(s => s.every).join(', ');
|
|
334
|
+
|
|
335
|
+
success(`${bold(name)} started ${dim(`(${modelShort}, ${schedule || 'no schedule'}) PID ${pid}`)}`);
|
|
336
|
+
started++;
|
|
337
|
+
} catch (err) {
|
|
338
|
+
error(`${bold(name)}: ${err.message}`);
|
|
339
|
+
}
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
console.log();
|
|
343
|
+
if (started > 0) {
|
|
344
|
+
success(`${green(String(started))} agent(s) running`);
|
|
345
|
+
console.log();
|
|
346
|
+
printBox('Manage your agents', [
|
|
347
|
+
`${dim('$')} ${bold('serpentstack skills persistent --stop')} ${dim('# stop all agents')}`,
|
|
348
|
+
`${dim('$')} ${bold('serpentstack skills persistent')} ${dim('# restart / reconfigure')}`,
|
|
349
|
+
'',
|
|
350
|
+
`${dim('Add agents:')} ${dim('Create .openclaw/agents/<name>/AGENT.md')}`,
|
|
351
|
+
`${dim('Remove agents:')} ${dim('Delete the agent folder from .openclaw/agents/')}`,
|
|
352
|
+
`${dim('Customize:')} ${dim('Edit AGENT.md frontmatter (model, schedule, tools)')}`,
|
|
353
|
+
]);
|
|
354
|
+
} else {
|
|
355
|
+
error('No agents were started. Check the errors above.');
|
|
356
|
+
console.log();
|
|
357
|
+
}
|
|
249
358
|
}
|
|
@@ -10,13 +10,19 @@ const MANIFEST = [
|
|
|
10
10
|
'.skills/git-workflow/SKILL.md',
|
|
11
11
|
'.skills/model-routing/SKILL.md',
|
|
12
12
|
'.openclaw/SOUL.md',
|
|
13
|
-
'.openclaw/
|
|
14
|
-
'.openclaw/
|
|
13
|
+
'.openclaw/agents/log-watcher/AGENT.md',
|
|
14
|
+
'.openclaw/agents/test-runner/AGENT.md',
|
|
15
|
+
'.openclaw/agents/skill-maintainer/AGENT.md',
|
|
15
16
|
'SKILL-AUTHORING.md',
|
|
16
17
|
];
|
|
17
18
|
|
|
18
|
-
// OpenClaw files are meant to be customized — warn before overwriting
|
|
19
|
-
const CUSTOMIZABLE = new Set([
|
|
19
|
+
// OpenClaw files that are meant to be customized — warn before overwriting
|
|
20
|
+
const CUSTOMIZABLE = new Set([
|
|
21
|
+
'.openclaw/SOUL.md',
|
|
22
|
+
'.openclaw/agents/log-watcher/AGENT.md',
|
|
23
|
+
'.openclaw/agents/test-runner/AGENT.md',
|
|
24
|
+
'.openclaw/agents/skill-maintainer/AGENT.md',
|
|
25
|
+
]);
|
|
20
26
|
|
|
21
27
|
export async function skillsUpdate({ force = false, all = false } = {}) {
|
|
22
28
|
printHeader();
|
|
@@ -0,0 +1,288 @@
|
|
|
1
|
+
import { existsSync, readFileSync, writeFileSync, mkdirSync, readdirSync, unlinkSync, rmSync } from 'node:fs';
|
|
2
|
+
import { join, resolve } from 'node:path';
|
|
3
|
+
import { createHash } from 'node:crypto';
|
|
4
|
+
import { homedir } from 'node:os';
|
|
5
|
+
|
|
6
|
+
const STATE_ROOT = join(homedir(), '.serpentstack');
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Parse an AGENT.md file — YAML frontmatter between --- delimiters + markdown body.
|
|
10
|
+
* Returns { meta: { name, description, model, schedule, tools }, body: string }
|
|
11
|
+
*/
|
|
12
|
+
export function parseAgentMd(filePath) {
|
|
13
|
+
const raw = readFileSync(filePath, 'utf8');
|
|
14
|
+
const match = raw.match(/^---\n([\s\S]*?)\n---\n([\s\S]*)$/);
|
|
15
|
+
if (!match) {
|
|
16
|
+
throw new Error(`Invalid AGENT.md format — missing frontmatter: ${filePath}`);
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
const meta = parseYamlFrontmatter(match[1]);
|
|
20
|
+
const body = match[2].trim();
|
|
21
|
+
return { meta, body };
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Minimal YAML parser for AGENT.md frontmatter.
|
|
26
|
+
* Handles: scalars, simple lists, and lists of objects (schedule).
|
|
27
|
+
* No external dependencies.
|
|
28
|
+
*/
|
|
29
|
+
function parseYamlFrontmatter(yaml) {
|
|
30
|
+
const result = {};
|
|
31
|
+
const lines = yaml.split('\n');
|
|
32
|
+
let i = 0;
|
|
33
|
+
|
|
34
|
+
while (i < lines.length) {
|
|
35
|
+
const line = lines[i];
|
|
36
|
+
|
|
37
|
+
// Skip blank lines
|
|
38
|
+
if (!line.trim()) { i++; continue; }
|
|
39
|
+
|
|
40
|
+
const keyMatch = line.match(/^(\w[\w-]*):\s*(.*)/);
|
|
41
|
+
if (!keyMatch) { i++; continue; }
|
|
42
|
+
|
|
43
|
+
const key = keyMatch[1];
|
|
44
|
+
const inlineValue = keyMatch[2].trim();
|
|
45
|
+
|
|
46
|
+
// Check if next lines are list items
|
|
47
|
+
if (!inlineValue && i + 1 < lines.length && lines[i + 1].match(/^\s+-/)) {
|
|
48
|
+
// It's a list
|
|
49
|
+
const items = [];
|
|
50
|
+
i++;
|
|
51
|
+
while (i < lines.length && lines[i].match(/^\s+-/)) {
|
|
52
|
+
const itemLine = lines[i].replace(/^\s+-\s*/, '');
|
|
53
|
+
|
|
54
|
+
// Check if this is a key: value item (for schedule objects)
|
|
55
|
+
if (itemLine.includes(':')) {
|
|
56
|
+
const obj = {};
|
|
57
|
+
// Parse inline key: value
|
|
58
|
+
const parts = itemLine.match(/(\w[\w-]*):\s*(.*)/);
|
|
59
|
+
if (parts) obj[parts[1]] = parts[2].trim();
|
|
60
|
+
|
|
61
|
+
// Check for continuation lines of this object
|
|
62
|
+
i++;
|
|
63
|
+
while (i < lines.length && lines[i].match(/^\s{4,}\w/) && !lines[i].match(/^\s+-/)) {
|
|
64
|
+
const subMatch = lines[i].trim().match(/^(\w[\w-]*):\s*(.*)/);
|
|
65
|
+
if (subMatch) obj[subMatch[1]] = subMatch[2].trim();
|
|
66
|
+
i++;
|
|
67
|
+
}
|
|
68
|
+
items.push(obj);
|
|
69
|
+
} else {
|
|
70
|
+
items.push(itemLine);
|
|
71
|
+
i++;
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
result[key] = items;
|
|
75
|
+
} else {
|
|
76
|
+
result[key] = inlineValue;
|
|
77
|
+
i++;
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
return result;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* Discover all agents in .openclaw/agents/ directory.
|
|
86
|
+
* Returns array of { name, dir, agentMdPath }
|
|
87
|
+
*/
|
|
88
|
+
export function discoverAgents(projectDir) {
|
|
89
|
+
const agentsDir = join(projectDir, '.openclaw', 'agents');
|
|
90
|
+
if (!existsSync(agentsDir)) return [];
|
|
91
|
+
|
|
92
|
+
const agents = [];
|
|
93
|
+
for (const entry of readdirSync(agentsDir, { withFileTypes: true })) {
|
|
94
|
+
if (!entry.isDirectory()) continue;
|
|
95
|
+
const agentMd = join(agentsDir, entry.name, 'AGENT.md');
|
|
96
|
+
if (existsSync(agentMd)) {
|
|
97
|
+
agents.push({
|
|
98
|
+
name: entry.name,
|
|
99
|
+
dir: join(agentsDir, entry.name),
|
|
100
|
+
agentMdPath: agentMd,
|
|
101
|
+
});
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
return agents.sort((a, b) => a.name.localeCompare(b.name));
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* Generate an OpenClaw runtime workspace for a single agent.
|
|
110
|
+
* Combines the shared SOUL.md with agent-specific config.
|
|
111
|
+
*
|
|
112
|
+
* Creates files in ~/.serpentstack/agents/<project-hash>/<agent-name>/
|
|
113
|
+
* Returns the workspace path.
|
|
114
|
+
*/
|
|
115
|
+
export function generateWorkspace(projectDir, agentName, agentMd, sharedSoul) {
|
|
116
|
+
const hash = projectHash(projectDir);
|
|
117
|
+
const workspaceDir = join(STATE_ROOT, 'agents', hash, agentName);
|
|
118
|
+
mkdirSync(workspaceDir, { recursive: true });
|
|
119
|
+
|
|
120
|
+
// SOUL.md = shared project soul + agent-specific instructions
|
|
121
|
+
const soul = [
|
|
122
|
+
sharedSoul,
|
|
123
|
+
'',
|
|
124
|
+
'---',
|
|
125
|
+
'',
|
|
126
|
+
`# Agent: ${agentMd.meta.name || agentName}`,
|
|
127
|
+
'',
|
|
128
|
+
agentMd.meta.description ? `> ${agentMd.meta.description}` : '',
|
|
129
|
+
'',
|
|
130
|
+
agentMd.body,
|
|
131
|
+
].filter(Boolean).join('\n');
|
|
132
|
+
|
|
133
|
+
writeFileSync(join(workspaceDir, 'SOUL.md'), soul, 'utf8');
|
|
134
|
+
|
|
135
|
+
// HEARTBEAT.md = generated from schedule frontmatter
|
|
136
|
+
const heartbeat = generateHeartbeat(agentMd.meta.schedule || []);
|
|
137
|
+
writeFileSync(join(workspaceDir, 'HEARTBEAT.md'), heartbeat, 'utf8');
|
|
138
|
+
|
|
139
|
+
// AGENTS.md = generated from model and tools frontmatter
|
|
140
|
+
const agentsConfig = generateAgentsConfig(agentMd.meta);
|
|
141
|
+
writeFileSync(join(workspaceDir, 'AGENTS.md'), agentsConfig, 'utf8');
|
|
142
|
+
|
|
143
|
+
return workspaceDir;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
function generateHeartbeat(schedule) {
|
|
147
|
+
const lines = ['# Heartbeat Schedule', ''];
|
|
148
|
+
|
|
149
|
+
if (schedule.length === 0) {
|
|
150
|
+
lines.push('No scheduled checks configured.');
|
|
151
|
+
return lines.join('\n');
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
for (const entry of schedule) {
|
|
155
|
+
const interval = entry.every || 'unknown';
|
|
156
|
+
const task = entry.task || entry.check || 'unnamed-task';
|
|
157
|
+
lines.push(`## Every ${interval}: ${task}`);
|
|
158
|
+
lines.push('');
|
|
159
|
+
lines.push(`Run the \`${task}\` check as defined in the agent instructions.`);
|
|
160
|
+
lines.push('');
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
return lines.join('\n');
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
function generateAgentsConfig(meta) {
|
|
167
|
+
const model = meta.model || 'anthropic/claude-haiku-4-20250414';
|
|
168
|
+
const tools = meta.tools || ['file-system', 'shell', 'git'];
|
|
169
|
+
|
|
170
|
+
return [
|
|
171
|
+
'# Agent Configuration',
|
|
172
|
+
'',
|
|
173
|
+
'## Workspace',
|
|
174
|
+
'',
|
|
175
|
+
'```yaml',
|
|
176
|
+
`name: ${meta.name || 'unnamed-agent'}`,
|
|
177
|
+
`description: ${meta.description || 'Persistent agent'}`,
|
|
178
|
+
'workspace: .',
|
|
179
|
+
'```',
|
|
180
|
+
'',
|
|
181
|
+
'## Model',
|
|
182
|
+
'',
|
|
183
|
+
'```yaml',
|
|
184
|
+
`primary_model: ${model}`,
|
|
185
|
+
`heartbeat_model: ${model}`,
|
|
186
|
+
'```',
|
|
187
|
+
'',
|
|
188
|
+
'## Tool Access',
|
|
189
|
+
'',
|
|
190
|
+
...tools.map(t => `- **${t}**`),
|
|
191
|
+
'',
|
|
192
|
+
'## Operating Rules',
|
|
193
|
+
'',
|
|
194
|
+
'1. Read `.skills/` on startup — follow project conventions.',
|
|
195
|
+
'2. Notify before fixing — report issues with context.',
|
|
196
|
+
'3. Run verification after changes.',
|
|
197
|
+
'4. Keep memory lean.',
|
|
198
|
+
'',
|
|
199
|
+
].join('\n');
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
// ─── PID Management ──────────────────────────────────────────
|
|
203
|
+
|
|
204
|
+
/**
|
|
205
|
+
* Write a PID file for a running agent.
|
|
206
|
+
*/
|
|
207
|
+
export function writePid(projectDir, agentName, pid) {
|
|
208
|
+
const pidDir = join(STATE_ROOT, 'pids', projectHash(projectDir));
|
|
209
|
+
mkdirSync(pidDir, { recursive: true });
|
|
210
|
+
writeFileSync(join(pidDir, `${agentName}.pid`), String(pid), 'utf8');
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
/**
|
|
214
|
+
* Read the PID for a running agent. Returns null if not found.
|
|
215
|
+
*/
|
|
216
|
+
export function readPid(projectDir, agentName) {
|
|
217
|
+
const pidFile = join(STATE_ROOT, 'pids', projectHash(projectDir), `${agentName}.pid`);
|
|
218
|
+
if (!existsSync(pidFile)) return null;
|
|
219
|
+
const pid = parseInt(readFileSync(pidFile, 'utf8').trim(), 10);
|
|
220
|
+
return isNaN(pid) ? null : pid;
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
/**
|
|
224
|
+
* Remove the PID file for an agent.
|
|
225
|
+
*/
|
|
226
|
+
export function removePid(projectDir, agentName) {
|
|
227
|
+
const pidFile = join(STATE_ROOT, 'pids', projectHash(projectDir), `${agentName}.pid`);
|
|
228
|
+
if (existsSync(pidFile)) unlinkSync(pidFile);
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
/**
|
|
232
|
+
* List all agents with PID files for this project.
|
|
233
|
+
* Returns array of { name, pid }
|
|
234
|
+
*/
|
|
235
|
+
export function listPids(projectDir) {
|
|
236
|
+
const pidDir = join(STATE_ROOT, 'pids', projectHash(projectDir));
|
|
237
|
+
if (!existsSync(pidDir)) return [];
|
|
238
|
+
|
|
239
|
+
return readdirSync(pidDir)
|
|
240
|
+
.filter(f => f.endsWith('.pid'))
|
|
241
|
+
.map(f => {
|
|
242
|
+
const name = f.replace('.pid', '');
|
|
243
|
+
const pid = readPid(projectDir, name);
|
|
244
|
+
return pid ? { name, pid } : null;
|
|
245
|
+
})
|
|
246
|
+
.filter(Boolean);
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
/**
|
|
250
|
+
* Check if a process is alive.
|
|
251
|
+
*/
|
|
252
|
+
export function isProcessAlive(pid) {
|
|
253
|
+
try {
|
|
254
|
+
process.kill(pid, 0);
|
|
255
|
+
return true;
|
|
256
|
+
} catch {
|
|
257
|
+
return false;
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
/**
|
|
262
|
+
* Clean up PID files for dead processes.
|
|
263
|
+
*/
|
|
264
|
+
export function cleanStalePids(projectDir) {
|
|
265
|
+
for (const { name, pid } of listPids(projectDir)) {
|
|
266
|
+
if (!isProcessAlive(pid)) {
|
|
267
|
+
removePid(projectDir, name);
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
/**
|
|
273
|
+
* Clean up generated workspace files for an agent.
|
|
274
|
+
*/
|
|
275
|
+
export function cleanWorkspace(projectDir, agentName) {
|
|
276
|
+
const hash = projectHash(projectDir);
|
|
277
|
+
const workspaceDir = join(STATE_ROOT, 'agents', hash, agentName);
|
|
278
|
+
if (existsSync(workspaceDir)) {
|
|
279
|
+
rmSync(workspaceDir, { recursive: true, force: true });
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
// ─── Helpers ─────────────────────────────────────────────────
|
|
284
|
+
|
|
285
|
+
function projectHash(projectDir) {
|
|
286
|
+
const absolute = resolve(projectDir);
|
|
287
|
+
return createHash('sha256').update(absolute).digest('hex').slice(0, 12);
|
|
288
|
+
}
|