groove-dev 0.16.3 → 0.17.0

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.
Files changed (38) hide show
  1. package/README.md +18 -16
  2. package/node_modules/@groove-dev/daemon/integrations-registry.json +321 -0
  3. package/node_modules/@groove-dev/daemon/src/api.js +152 -0
  4. package/node_modules/@groove-dev/daemon/src/index.js +13 -1
  5. package/node_modules/@groove-dev/daemon/src/integrations.js +389 -0
  6. package/node_modules/@groove-dev/daemon/src/introducer.js +23 -0
  7. package/node_modules/@groove-dev/daemon/src/process.js +59 -0
  8. package/node_modules/@groove-dev/daemon/src/registry.js +2 -1
  9. package/node_modules/@groove-dev/daemon/src/scheduler.js +336 -0
  10. package/node_modules/@groove-dev/daemon/src/terminal-pty.js +119 -54
  11. package/node_modules/@groove-dev/daemon/src/validate.js +10 -0
  12. package/node_modules/@groove-dev/gui/dist/assets/index-C5k-qSwi.js +153 -0
  13. package/node_modules/@groove-dev/gui/dist/index.html +1 -1
  14. package/node_modules/@groove-dev/gui/src/App.jsx +6 -0
  15. package/node_modules/@groove-dev/gui/src/components/SpawnPanel.jsx +98 -7
  16. package/node_modules/@groove-dev/gui/src/components/Terminal.jsx +29 -12
  17. package/node_modules/@groove-dev/gui/src/views/IntegrationsStore.jsx +954 -0
  18. package/node_modules/@groove-dev/gui/src/views/ScheduleManager.jsx +614 -0
  19. package/package.json +2 -2
  20. package/packages/daemon/integrations-registry.json +321 -0
  21. package/packages/daemon/src/api.js +152 -0
  22. package/packages/daemon/src/index.js +13 -1
  23. package/packages/daemon/src/integrations.js +389 -0
  24. package/packages/daemon/src/introducer.js +23 -0
  25. package/packages/daemon/src/process.js +59 -0
  26. package/packages/daemon/src/registry.js +2 -1
  27. package/packages/daemon/src/scheduler.js +336 -0
  28. package/packages/daemon/src/terminal-pty.js +119 -54
  29. package/packages/daemon/src/validate.js +10 -0
  30. package/packages/gui/dist/assets/index-C5k-qSwi.js +153 -0
  31. package/packages/gui/dist/index.html +1 -1
  32. package/packages/gui/src/App.jsx +6 -0
  33. package/packages/gui/src/components/SpawnPanel.jsx +98 -7
  34. package/packages/gui/src/components/Terminal.jsx +29 -12
  35. package/packages/gui/src/views/IntegrationsStore.jsx +954 -0
  36. package/packages/gui/src/views/ScheduleManager.jsx +614 -0
  37. package/node_modules/@groove-dev/gui/dist/assets/index-CFeltwTB.js +0 -153
  38. package/packages/gui/dist/assets/index-CFeltwTB.js +0 -153
@@ -0,0 +1,336 @@
1
+ // GROOVE — Agent Scheduler (Cron-based agent spawning)
2
+ // FSL-1.1-Apache-2.0 — see LICENSE
3
+
4
+ import { existsSync, mkdirSync, writeFileSync, readFileSync, readdirSync, unlinkSync } from 'fs';
5
+ import { resolve } from 'path';
6
+ import { randomUUID } from 'crypto';
7
+
8
+ // Simple cron field parser — supports: *, N, */N
9
+ // Fields: minute(0-59) hour(0-23) dayOfMonth(1-31) month(1-12) dayOfWeek(0-6)
10
+ function parseCronField(field, min, max) {
11
+ if (field === '*') return null; // any
12
+ if (field.startsWith('*/')) {
13
+ const step = parseInt(field.slice(2), 10);
14
+ if (isNaN(step) || step <= 0) return null;
15
+ return { type: 'step', step };
16
+ }
17
+ const val = parseInt(field, 10);
18
+ if (!isNaN(val) && val >= min && val <= max) {
19
+ return { type: 'exact', value: val };
20
+ }
21
+ return null;
22
+ }
23
+
24
+ function fieldMatches(parsed, value) {
25
+ if (parsed === null) return true; // wildcard
26
+ if (parsed.type === 'exact') return value === parsed.value;
27
+ if (parsed.type === 'step') return value % parsed.step === 0;
28
+ return true;
29
+ }
30
+
31
+ function cronMatches(cronExpr, date) {
32
+ const parts = cronExpr.trim().split(/\s+/);
33
+ if (parts.length !== 5) return false;
34
+
35
+ const fields = [
36
+ parseCronField(parts[0], 0, 59), // minute
37
+ parseCronField(parts[1], 0, 23), // hour
38
+ parseCronField(parts[2], 1, 31), // day of month
39
+ parseCronField(parts[3], 1, 12), // month
40
+ parseCronField(parts[4], 0, 6), // day of week
41
+ ];
42
+
43
+ return (
44
+ fieldMatches(fields[0], date.getMinutes()) &&
45
+ fieldMatches(fields[1], date.getHours()) &&
46
+ fieldMatches(fields[2], date.getDate()) &&
47
+ fieldMatches(fields[3], date.getMonth() + 1) &&
48
+ fieldMatches(fields[4], date.getDay())
49
+ );
50
+ }
51
+
52
+ // Human-readable cron description
53
+ function describeCron(cron) {
54
+ const presets = {
55
+ '* * * * *': 'Every minute',
56
+ '*/5 * * * *': 'Every 5 minutes',
57
+ '*/15 * * * *': 'Every 15 minutes',
58
+ '*/30 * * * *': 'Every 30 minutes',
59
+ '0 * * * *': 'Every hour',
60
+ '0 */2 * * *': 'Every 2 hours',
61
+ '0 */6 * * *': 'Every 6 hours',
62
+ '0 0 * * *': 'Daily at midnight',
63
+ '0 9 * * *': 'Daily at 9:00 AM',
64
+ '0 9 * * 1-5': 'Weekdays at 9:00 AM',
65
+ '0 0 * * 0': 'Weekly (Sunday midnight)',
66
+ '0 0 * * 1': 'Weekly (Monday midnight)',
67
+ '0 0 1 * *': 'Monthly (1st at midnight)',
68
+ };
69
+ return presets[cron] || cron;
70
+ }
71
+
72
+ const CHECK_INTERVAL = 60_000; // 1 minute
73
+ const MAX_HISTORY = 50;
74
+
75
+ export class Scheduler {
76
+ constructor(daemon) {
77
+ this.daemon = daemon;
78
+ this.schedulesDir = resolve(daemon.grooveDir, 'schedules');
79
+ mkdirSync(this.schedulesDir, { recursive: true });
80
+ this.schedules = new Map();
81
+ this.runningAgents = new Map(); // scheduleId -> agentId
82
+ this.history = new Map(); // scheduleId -> [{ timestamp, agentId, status }]
83
+ this.interval = null;
84
+ this._load();
85
+ }
86
+
87
+ /**
88
+ * Create a new schedule.
89
+ */
90
+ create(config) {
91
+ if (!config.name) throw new Error('Schedule name is required');
92
+ if (!config.cron) throw new Error('Cron expression is required');
93
+ if (!config.agentConfig) throw new Error('Agent config is required');
94
+ if (!config.agentConfig.role) throw new Error('Agent role is required');
95
+
96
+ // Validate cron (basic check)
97
+ const parts = config.cron.trim().split(/\s+/);
98
+ if (parts.length !== 5) throw new Error('Cron must have 5 fields: minute hour day month weekday');
99
+
100
+ const schedule = {
101
+ id: randomUUID().slice(0, 8),
102
+ name: config.name,
103
+ cron: config.cron.trim(),
104
+ cronDescription: describeCron(config.cron.trim()),
105
+ agentConfig: config.agentConfig,
106
+ enabled: config.enabled !== false,
107
+ maxConcurrent: config.maxConcurrent || 1,
108
+ createdAt: new Date().toISOString(),
109
+ updatedAt: new Date().toISOString(),
110
+ };
111
+
112
+ this.schedules.set(schedule.id, schedule);
113
+ this.history.set(schedule.id, []);
114
+ this._save(schedule.id);
115
+
116
+ this.daemon.audit.log('schedule.create', { id: schedule.id, name: schedule.name, cron: schedule.cron });
117
+
118
+ return schedule;
119
+ }
120
+
121
+ /**
122
+ * Update an existing schedule.
123
+ */
124
+ update(id, updates) {
125
+ const schedule = this.schedules.get(id);
126
+ if (!schedule) throw new Error(`Schedule not found: ${id}`);
127
+
128
+ const SAFE = ['name', 'cron', 'agentConfig', 'enabled', 'maxConcurrent'];
129
+ for (const key of Object.keys(updates)) {
130
+ if (SAFE.includes(key)) {
131
+ schedule[key] = updates[key];
132
+ }
133
+ }
134
+ if (updates.cron) {
135
+ schedule.cronDescription = describeCron(updates.cron.trim());
136
+ }
137
+ schedule.updatedAt = new Date().toISOString();
138
+ this._save(id);
139
+
140
+ return schedule;
141
+ }
142
+
143
+ /**
144
+ * Delete a schedule.
145
+ */
146
+ delete(id) {
147
+ if (!this.schedules.has(id)) throw new Error(`Schedule not found: ${id}`);
148
+ this.schedules.delete(id);
149
+ this.history.delete(id);
150
+ this.runningAgents.delete(id);
151
+
152
+ const filePath = resolve(this.schedulesDir, `${id}.json`);
153
+ if (existsSync(filePath)) unlinkSync(filePath);
154
+
155
+ this.daemon.audit.log('schedule.delete', { id });
156
+ }
157
+
158
+ /**
159
+ * Enable a schedule.
160
+ */
161
+ enable(id) {
162
+ const schedule = this.schedules.get(id);
163
+ if (!schedule) throw new Error(`Schedule not found: ${id}`);
164
+ schedule.enabled = true;
165
+ schedule.updatedAt = new Date().toISOString();
166
+ this._save(id);
167
+ return schedule;
168
+ }
169
+
170
+ /**
171
+ * Disable a schedule.
172
+ */
173
+ disable(id) {
174
+ const schedule = this.schedules.get(id);
175
+ if (!schedule) throw new Error(`Schedule not found: ${id}`);
176
+ schedule.enabled = false;
177
+ schedule.updatedAt = new Date().toISOString();
178
+ this._save(id);
179
+ return schedule;
180
+ }
181
+
182
+ /**
183
+ * List all schedules with their current state.
184
+ */
185
+ list() {
186
+ return Array.from(this.schedules.values()).map((s) => ({
187
+ ...s,
188
+ lastRun: this._lastRun(s.id),
189
+ isRunning: this.runningAgents.has(s.id),
190
+ }));
191
+ }
192
+
193
+ /**
194
+ * Get a specific schedule with history.
195
+ */
196
+ get(id) {
197
+ const schedule = this.schedules.get(id);
198
+ if (!schedule) return null;
199
+ return {
200
+ ...schedule,
201
+ history: this.history.get(id) || [],
202
+ lastRun: this._lastRun(id),
203
+ isRunning: this.runningAgents.has(id),
204
+ };
205
+ }
206
+
207
+ /**
208
+ * Manually trigger a schedule (run now).
209
+ */
210
+ async run(id) {
211
+ const schedule = this.schedules.get(id);
212
+ if (!schedule) throw new Error(`Schedule not found: ${id}`);
213
+ return this._execute(schedule);
214
+ }
215
+
216
+ /**
217
+ * Start the scheduler (check every minute).
218
+ */
219
+ start() {
220
+ if (this.interval) return;
221
+ this.interval = setInterval(() => this._check(), CHECK_INTERVAL);
222
+ }
223
+
224
+ /**
225
+ * Stop the scheduler.
226
+ */
227
+ stop() {
228
+ if (this.interval) {
229
+ clearInterval(this.interval);
230
+ this.interval = null;
231
+ }
232
+ }
233
+
234
+ // --- Internal ---
235
+
236
+ _check() {
237
+ const now = new Date();
238
+ for (const schedule of this.schedules.values()) {
239
+ if (!schedule.enabled) continue;
240
+ if (cronMatches(schedule.cron, now)) {
241
+ // Check concurrency
242
+ if (this.runningAgents.has(schedule.id)) {
243
+ const agentId = this.runningAgents.get(schedule.id);
244
+ const agent = this.daemon.registry.get(agentId);
245
+ if (agent && (agent.status === 'running' || agent.status === 'starting')) {
246
+ // Still running — skip
247
+ this._recordHistory(schedule.id, null, 'skipped');
248
+ continue;
249
+ }
250
+ // Agent finished — clear
251
+ this.runningAgents.delete(schedule.id);
252
+ }
253
+ this._execute(schedule).catch(() => {});
254
+ }
255
+ }
256
+ }
257
+
258
+ async _execute(schedule) {
259
+ try {
260
+ const agent = await this.daemon.processes.spawn({
261
+ ...schedule.agentConfig,
262
+ name: `sched-${schedule.name.replace(/[^a-zA-Z0-9_-]/g, '_').slice(0, 20)}`,
263
+ });
264
+ this.runningAgents.set(schedule.id, agent.id);
265
+ this._recordHistory(schedule.id, agent.id, 'spawned');
266
+
267
+ this.daemon.broadcast({
268
+ type: 'schedule:execute',
269
+ scheduleId: schedule.id,
270
+ agentId: agent.id,
271
+ });
272
+
273
+ this.daemon.audit.log('schedule.execute', {
274
+ id: schedule.id,
275
+ name: schedule.name,
276
+ agentId: agent.id,
277
+ });
278
+
279
+ return agent;
280
+ } catch (err) {
281
+ this._recordHistory(schedule.id, null, 'error', err.message);
282
+ throw err;
283
+ }
284
+ }
285
+
286
+ _recordHistory(scheduleId, agentId, status, error) {
287
+ const history = this.history.get(scheduleId) || [];
288
+ history.unshift({
289
+ timestamp: new Date().toISOString(),
290
+ agentId,
291
+ status,
292
+ error,
293
+ });
294
+ // Keep only last N entries
295
+ if (history.length > MAX_HISTORY) history.length = MAX_HISTORY;
296
+ this.history.set(scheduleId, history);
297
+ }
298
+
299
+ _lastRun(scheduleId) {
300
+ const history = this.history.get(scheduleId) || [];
301
+ return history[0] || null;
302
+ }
303
+
304
+ _save(id) {
305
+ const schedule = this.schedules.get(id);
306
+ if (!schedule) return;
307
+ const filePath = resolve(this.schedulesDir, `${id}.json`);
308
+ writeFileSync(filePath, JSON.stringify({
309
+ ...schedule,
310
+ history: this.history.get(id) || [],
311
+ }, null, 2));
312
+ }
313
+
314
+ _load() {
315
+ if (!existsSync(this.schedulesDir)) return;
316
+ for (const file of readdirSync(this.schedulesDir)) {
317
+ if (!file.endsWith('.json')) continue;
318
+ try {
319
+ const data = JSON.parse(readFileSync(resolve(this.schedulesDir, file), 'utf8'));
320
+ const id = data.id || file.replace('.json', '');
321
+ this.schedules.set(id, {
322
+ id,
323
+ name: data.name,
324
+ cron: data.cron,
325
+ cronDescription: describeCron(data.cron),
326
+ agentConfig: data.agentConfig,
327
+ enabled: data.enabled !== false,
328
+ maxConcurrent: data.maxConcurrent || 1,
329
+ createdAt: data.createdAt,
330
+ updatedAt: data.updatedAt,
331
+ });
332
+ this.history.set(id, data.history || []);
333
+ } catch { /* skip corrupt files */ }
334
+ }
335
+ }
336
+ }
@@ -1,66 +1,129 @@
1
1
  // GROOVE — Terminal PTY Manager (shell sessions over WebSocket)
2
2
  // FSL-1.1-Apache-2.0 — see LICENSE
3
3
 
4
- import { spawn } from 'child_process';
4
+ import { spawn, execFileSync } from 'child_process';
5
5
  import { existsSync } from 'fs';
6
6
 
7
- /**
8
- * Manages interactive shell sessions for the GUI terminal.
9
- * Uses the `script` command to allocate a real PTY (prompts, colors, line editing).
10
- * No native modules required works on macOS and Linux out of the box.
11
- */
7
+ // Python helper that creates a real PTY and relays I/O through stdin/stdout pipes.
8
+ // The shell sees a genuine TTY prompts, colors, line editing, tab completion all work.
9
+ const PTY_HELPER = `
10
+ import pty, os, sys, select, signal, struct, fcntl, termios, errno
11
+
12
+ master, slave = pty.openpty()
13
+
14
+ cols = int(os.environ.get('COLS', '120'))
15
+ rows = int(os.environ.get('ROWS', '30'))
16
+ fcntl.ioctl(slave, termios.TIOCSWINSZ, struct.pack('HHHH', rows, cols, 0, 0))
17
+
18
+ pid = os.fork()
19
+ if pid == 0:
20
+ os.setsid()
21
+ fcntl.ioctl(slave, termios.TIOCSCTTY, 0)
22
+ os.dup2(slave, 0)
23
+ os.dup2(slave, 1)
24
+ os.dup2(slave, 2)
25
+ os.close(master)
26
+ os.close(slave)
27
+ shell = os.environ.get('GROOVE_SHELL', os.environ.get('SHELL', '/bin/bash'))
28
+ os.execvp(shell, [shell, '-l'])
29
+
30
+ os.close(slave)
31
+
32
+ def resize(sig, frame):
33
+ pass
34
+ signal.signal(signal.SIGWINCH, resize)
35
+
36
+ flags = fcntl.fcntl(0, fcntl.F_GETFL)
37
+ fcntl.fcntl(0, fcntl.F_SETFL, flags | os.O_NONBLOCK)
38
+
39
+ try:
40
+ while True:
41
+ rlist = select.select([0, master], [], [], 0.05)[0]
42
+ if 0 in rlist:
43
+ try:
44
+ data = os.read(0, 4096)
45
+ if not data: break
46
+ # Resize command: ESC ] 7 ; <rows> ; <cols> BEL
47
+ if b'\\x1b]7;' in data:
48
+ idx = data.index(b'\\x1b]7;')
49
+ end = data.index(b'\\x07', idx)
50
+ params = data[idx+4:end].decode().split(';')
51
+ if len(params) == 2:
52
+ r, c = int(params[0]), int(params[1])
53
+ fcntl.ioctl(master, termios.TIOCSWINSZ, struct.pack('HHHH', r, c, 0, 0))
54
+ os.kill(pid, signal.SIGWINCH)
55
+ rest = data[:idx] + data[end+1:]
56
+ if rest:
57
+ os.write(master, rest)
58
+ else:
59
+ os.write(master, data)
60
+ except OSError as e:
61
+ if e.errno != errno.EAGAIN: break
62
+ if master in rlist:
63
+ try:
64
+ data = os.read(master, 4096)
65
+ if not data: break
66
+ sys.stdout.buffer.write(data)
67
+ sys.stdout.buffer.flush()
68
+ except OSError: break
69
+ # Check child
70
+ try:
71
+ p, status = os.waitpid(pid, os.WNOHANG)
72
+ if p != 0: break
73
+ except ChildProcessError: break
74
+ except: pass
75
+ finally:
76
+ try: os.kill(pid, signal.SIGTERM)
77
+ except: pass
78
+ `.trim();
79
+
12
80
  export class TerminalManager {
13
81
  constructor(daemon) {
14
82
  this.daemon = daemon;
15
- this.sessions = new Map(); // sessionId → { proc, ws }
83
+ this.sessions = new Map();
16
84
  this.counter = 0;
85
+ this._python = this._findPython();
17
86
  }
18
87
 
19
- /**
20
- * Spawn a new shell session connected to a specific WebSocket client.
21
- */
22
88
  spawn(ws, options = {}) {
23
89
  const id = `term-${++this.counter}`;
24
90
  const shell = this._detectShell();
25
91
  const cwd = options.cwd || this.daemon.projectDir;
26
-
27
- // Use `script` to allocate a real PTY — gives us prompts, colors, and line editing.
28
- // macOS: script -q /dev/null <shell>
29
- // Linux: script -qfc "<shell>" /dev/null
30
- let proc;
31
- const env = {
32
- ...process.env,
33
- TERM: 'xterm-256color',
34
- COLORTERM: 'truecolor',
35
- LANG: process.env.LANG || 'en_US.UTF-8',
36
- };
37
-
38
- if (process.platform === 'darwin') {
39
- proc = spawn('script', ['-q', '/dev/null', shell, '-l'], {
40
- cwd,
41
- env,
42
- stdio: ['pipe', 'pipe', 'pipe'],
43
- });
44
- } else {
45
- // Linux (util-linux script)
46
- proc = spawn('script', ['-qfc', `${shell} -l`, '/dev/null'], {
47
- cwd,
48
- env,
49
- stdio: ['pipe', 'pipe', 'pipe'],
50
- });
92
+ const cols = options.cols || 120;
93
+ const rows = options.rows || 30;
94
+
95
+ if (!this._python) {
96
+ ws.send(JSON.stringify({
97
+ type: 'terminal:output', id,
98
+ data: '\r\n\x1b[31mTerminal requires Python 3 (python3 not found in PATH)\x1b[0m\r\n',
99
+ }));
100
+ ws.send(JSON.stringify({ type: 'terminal:exit', id, code: 1 }));
101
+ return id;
51
102
  }
52
103
 
104
+ const proc = spawn(this._python, ['-u', '-c', PTY_HELPER], {
105
+ cwd,
106
+ env: {
107
+ ...process.env,
108
+ TERM: 'xterm-256color',
109
+ COLORTERM: 'truecolor',
110
+ LANG: process.env.LANG || 'en_US.UTF-8',
111
+ GROOVE_SHELL: shell,
112
+ COLS: String(cols),
113
+ ROWS: String(rows),
114
+ },
115
+ stdio: ['pipe', 'pipe', 'pipe'],
116
+ });
117
+
53
118
  const session = { proc, ws, id };
54
119
  this.sessions.set(id, session);
55
120
 
56
- // Relay stdout → WS
57
121
  proc.stdout.on('data', (data) => {
58
122
  if (ws.readyState === 1) {
59
123
  ws.send(JSON.stringify({ type: 'terminal:output', id, data: data.toString('utf8') }));
60
124
  }
61
125
  });
62
126
 
63
- // Relay stderr → WS (merge with stdout)
64
127
  proc.stderr.on('data', (data) => {
65
128
  if (ws.readyState === 1) {
66
129
  ws.send(JSON.stringify({ type: 'terminal:output', id, data: data.toString('utf8') }));
@@ -84,18 +147,19 @@ export class TerminalManager {
84
147
  return id;
85
148
  }
86
149
 
87
- /**
88
- * Write input to a terminal session.
89
- */
90
150
  write(id, data) {
91
151
  const session = this.sessions.get(id);
92
152
  if (!session || !session.proc.stdin.writable) return;
93
153
  session.proc.stdin.write(data);
94
154
  }
95
155
 
96
- /**
97
- * Kill a terminal session.
98
- */
156
+ resize(id, rows, cols) {
157
+ const session = this.sessions.get(id);
158
+ if (!session || !session.proc.stdin.writable) return;
159
+ // Send resize command via the custom escape sequence
160
+ session.proc.stdin.write(`\x1b]7;${rows};${cols}\x07`);
161
+ }
162
+
99
163
  kill(id) {
100
164
  const session = this.sessions.get(id);
101
165
  if (!session) return;
@@ -108,18 +172,12 @@ export class TerminalManager {
108
172
  this.sessions.delete(id);
109
173
  }
110
174
 
111
- /**
112
- * Kill all sessions (daemon shutdown).
113
- */
114
175
  killAll() {
115
176
  for (const [id] of this.sessions) {
116
177
  this.kill(id);
117
178
  }
118
179
  }
119
180
 
120
- /**
121
- * Clean up sessions for a disconnected WS client.
122
- */
123
181
  cleanupClient(ws) {
124
182
  for (const [id, session] of this.sessions) {
125
183
  if (session.ws === ws) {
@@ -129,13 +187,20 @@ export class TerminalManager {
129
187
  }
130
188
 
131
189
  _detectShell() {
132
- if (process.env.SHELL && existsSync(process.env.SHELL)) {
133
- return process.env.SHELL;
134
- }
135
- const candidates = ['/bin/zsh', '/bin/bash', '/bin/sh'];
136
- for (const sh of candidates) {
190
+ if (process.env.SHELL && existsSync(process.env.SHELL)) return process.env.SHELL;
191
+ for (const sh of ['/bin/zsh', '/bin/bash', '/bin/sh']) {
137
192
  if (existsSync(sh)) return sh;
138
193
  }
139
194
  return 'sh';
140
195
  }
196
+
197
+ _findPython() {
198
+ for (const cmd of ['python3', 'python']) {
199
+ try {
200
+ const v = execFileSync(cmd, ['--version'], { encoding: 'utf8', timeout: 3000 }).trim();
201
+ if (v.startsWith('Python 3')) return cmd;
202
+ } catch { /* not found */ }
203
+ }
204
+ return null;
205
+ }
141
206
  }
@@ -66,6 +66,15 @@ export function validateAgentConfig(config) {
66
66
  skills = config.skills.filter((s) => typeof s === 'string' && s.length > 0 && s.length <= 100);
67
67
  }
68
68
 
69
+ // Validate integrations (array of integration IDs)
70
+ let integrations = [];
71
+ if (config.integrations !== undefined && config.integrations !== null) {
72
+ if (!Array.isArray(config.integrations)) {
73
+ throw new Error('Integrations must be an array');
74
+ }
75
+ integrations = config.integrations.filter((s) => typeof s === 'string' && s.length > 0 && s.length <= 100);
76
+ }
77
+
69
78
  // Return sanitized config (only known fields)
70
79
  return {
71
80
  role: config.role,
@@ -77,6 +86,7 @@ export function validateAgentConfig(config) {
77
86
  workingDir: typeof config.workingDir === 'string' ? config.workingDir : undefined,
78
87
  permission,
79
88
  skills,
89
+ integrations,
80
90
  };
81
91
  }
82
92