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.
@@ -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 agent
31
- ${cyan('skills persistent')} --stop Stop the background agent
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/HEARTBEAT.md',
16
- '.openclaw/AGENTS.md',
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 a persistent agent too', [
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('A background agent that watches your dev server, catches')}`,
102
- `${dim('errors, runs tests, and keeps your skills up to date.')}`,
103
- `${dim('Customize its behavior by editing the files in .openclaw/.')}`,
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 checkOpenClawWorkspace() {
15
- const dir = join(process.cwd(), '.openclaw');
16
- const required = ['SOUL.md', 'HEARTBEAT.md', 'AGENTS.md'];
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
- function checkSoulCustomized() {
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 require_fs() {
31
- // Lazy import to avoid top-level dynamic import
32
- return { readFileSync: existsSync ? (await_import()).readFileSync : null };
33
- }
33
+ async function stopAllAgents(projectDir) {
34
+ cleanStalePids(projectDir);
35
+ const running = listPids(projectDir);
34
36
 
35
- async function askQuestion(rl, label, hint) {
36
- const answer = await rl.question(` ${green('?')} ${bold(label)}${hint ? ` ${dim(hint)}` : ''}: `);
37
- return answer.trim();
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
- async function customizeWorkspace() {
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 persistent agent')}`);
45
- console.log(` ${dim("Answer a few questions so the agent understands your project.")}`);
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
- // Read and update SOUL.md with project-specific info
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 Agent`,
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. It runs in the background,')}`);
109
- console.log(` ${dim('watching your dev server and running health checks on a schedule.')}`);
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(`Install manually when ready:`);
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
- async function startAgent() {
134
- console.log();
135
- info('Starting persistent agent...');
136
- console.log();
137
- console.log(` ${dim('The agent will:')}`);
138
- console.log(` ${dim('\u2022 Watch your dev server for errors')}`);
139
- console.log(` ${dim('\u2022 Run tests on a schedule')}`);
140
- console.log(` ${dim('\u2022 Flag when skills go stale')}`);
141
- console.log(` ${dim('\u2022 Propose fixes with full context')}`);
142
- console.log();
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
- const child = spawn('openclaw', ['start', '--workspace', '.openclaw/'], {
145
- stdio: 'inherit',
146
- cwd: process.cwd(),
147
- });
169
+ child.unref();
148
170
 
149
- child.on('error', (err) => {
150
- error(`Failed to start OpenClaw: ${err.message}`);
151
- process.exit(1);
152
- });
171
+ child.on('error', (err) => {
172
+ reject(new Error(`Failed to start ${agentName}: ${err.message}`));
173
+ });
153
174
 
154
- child.on('close', (code) => {
155
- if (code !== 0) {
156
- error(`OpenClaw exited with code ${code}`);
157
- process.exit(code);
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
- // --stop is the only flag — everything else is a guided flow
186
+ const projectDir = process.cwd();
187
+
188
+ // --stop flag
164
189
  if (stop) {
165
- const hasOpenClaw = await which('openclaw');
166
- if (!hasOpenClaw) {
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 for .openclaw/ workspace
186
- console.log(` ${bold('Step 1/3')} ${dim('\u2014 Check workspace')}`);
198
+ // Step 1: Check workspace
199
+ console.log(` ${bold('Step 1/4')} ${dim('\u2014 Check workspace')}`);
187
200
  console.log();
188
201
 
189
- if (!checkOpenClawWorkspace()) {
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
- success('.openclaw/ workspace found');
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
- // Step 2: Customize if needed
201
- console.log(` ${bold('Step 2/3')} ${dim('\u2014 Configure for your project')}`);
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('Workspace already customized');
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
- // Step 3: Install OpenClaw + start
225
- console.log(` ${bold('Step 3/3')} ${dim('\u2014 Start the agent')}`);
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('Start the persistent agent now?');
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')} ${dim('# run setup again')}`,
240
- `${dim('$')} ${bold('openclaw start --workspace .openclaw/')} ${dim('# start directly')}`,
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
- await startAgent();
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/HEARTBEAT.md',
14
- '.openclaw/AGENTS.md',
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(['.openclaw/SOUL.md', '.openclaw/HEARTBEAT.md', '.openclaw/AGENTS.md']);
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
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "serpentstack",
3
- "version": "0.2.3",
3
+ "version": "0.2.4",
4
4
  "description": "CLI for SerpentStack — AI-driven development standards with project-specific skills and persistent agents",
5
5
  "type": "module",
6
6
  "bin": {