@geekbeer/minion 2.23.0 → 2.32.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 (47) hide show
  1. package/core/lib/platform.js +117 -0
  2. package/{routes → core/routes}/health.js +1 -1
  3. package/{routes → core/routes}/routines.js +44 -4
  4. package/{routes → core/routes}/skills.js +3 -3
  5. package/{routes → core/routes}/workflows.js +4 -4
  6. package/{chat-store.js → core/stores/chat-store.js} +1 -1
  7. package/{execution-store.js → core/stores/execution-store.js} +1 -1
  8. package/{routine-store.js → core/stores/routine-store.js} +1 -1
  9. package/{workflow-store.js → core/stores/workflow-store.js} +1 -1
  10. package/{minion-cli.sh → linux/minion-cli.sh} +245 -4
  11. package/{routes → linux/routes}/chat.js +3 -3
  12. package/{routes → linux/routes}/commands.js +1 -1
  13. package/{routes → linux/routes}/config.js +3 -3
  14. package/{routes → linux/routes}/directives.js +5 -5
  15. package/{routes → linux/routes}/files.js +2 -2
  16. package/{routes → linux/routes}/terminal.js +2 -2
  17. package/{routine-runner.js → linux/routine-runner.js} +4 -4
  18. package/{server.js → linux/server.js} +71 -36
  19. package/{workflow-runner.js → linux/workflow-runner.js} +4 -4
  20. package/package.json +16 -20
  21. package/win/bin/hq-win.js +18 -0
  22. package/win/bin/hq.ps1 +108 -0
  23. package/win/bin/minion-cli-win.js +20 -0
  24. package/win/lib/llm-checker.js +115 -0
  25. package/win/lib/log-manager.js +119 -0
  26. package/win/lib/process-manager.js +112 -0
  27. package/win/minion-cli.ps1 +869 -0
  28. package/win/routes/chat.js +280 -0
  29. package/win/routes/commands.js +101 -0
  30. package/win/routes/config.js +227 -0
  31. package/win/routes/directives.js +136 -0
  32. package/win/routes/files.js +283 -0
  33. package/win/routes/terminal.js +316 -0
  34. package/win/routine-runner.js +324 -0
  35. package/win/server.js +230 -0
  36. package/win/terminal-server.js +234 -0
  37. package/win/workflow-runner.js +380 -0
  38. package/routes/index.js +0 -106
  39. /package/{api.js → core/api.js} +0 -0
  40. /package/{config.js → core/config.js} +0 -0
  41. /package/{lib → core/lib}/auth.js +0 -0
  42. /package/{lib → core/lib}/llm-checker.js +0 -0
  43. /package/{lib → core/lib}/log-manager.js +0 -0
  44. /package/{routes → core/routes}/auth.js +0 -0
  45. /package/{bin → linux/bin}/hq +0 -0
  46. /package/{lib → linux/lib}/process-manager.js +0 -0
  47. /package/{terminal-proxy.js → linux/terminal-proxy.js} +0 -0
@@ -0,0 +1,316 @@
1
+ /**
2
+ * Windows Terminal management endpoints
3
+ *
4
+ * Uses node-pty sessions instead of tmux.
5
+ * Shares activeSessions Map with workflow-runner.js and routine-runner.js.
6
+ *
7
+ * Same API surface as routes/terminal.js for HQ compatibility.
8
+ */
9
+
10
+ const path = require('path')
11
+ const { verifyToken } = require('../../core/lib/auth')
12
+ const { config } = require('../../core/config')
13
+ const { activeSessions } = require('../workflow-runner')
14
+
15
+ const homeDir = config.HOME_DIR
16
+
17
+ /**
18
+ * Load node-pty dynamically.
19
+ */
20
+ function loadNodePty() {
21
+ // Prefer prebuilt binaries (no Build Tools required)
22
+ try { return require('node-pty-prebuilt-multiarch') } catch {}
23
+ // Fallback: source-compiled version
24
+ try { return require('node-pty') } catch {}
25
+ return null
26
+ }
27
+
28
+ /**
29
+ * Create a new pty session.
30
+ * @param {string} sessionName - Session name
31
+ * @param {string} [command] - Optional command to run
32
+ * @returns {object} Session entry
33
+ */
34
+ function createPtySession(sessionName, command) {
35
+ const pty = loadNodePty()
36
+ if (!pty) throw new Error('node-pty is not installed')
37
+
38
+ const shell = process.env.COMSPEC || 'cmd.exe'
39
+ let shellArgs
40
+ if (command) {
41
+ shellArgs = shell.toLowerCase().includes('cmd') ? ['/c', command] : ['-Command', command]
42
+ } else {
43
+ shellArgs = []
44
+ }
45
+
46
+ const ptyProcess = pty.spawn(shell, shellArgs, {
47
+ name: 'xterm-256color',
48
+ cols: 120,
49
+ rows: 30,
50
+ cwd: homeDir,
51
+ env: { ...process.env, HOME: homeDir, USERPROFILE: homeDir },
52
+ })
53
+
54
+ const session = {
55
+ pty: ptyProcess,
56
+ buffer: '',
57
+ logStream: null,
58
+ completed: false,
59
+ exitCode: null,
60
+ startedAt: new Date().toISOString(),
61
+ }
62
+
63
+ ptyProcess.onData((data) => {
64
+ session.buffer += data
65
+ if (session.buffer.length > 1024 * 1024) {
66
+ session.buffer = session.buffer.slice(-512 * 1024)
67
+ }
68
+ })
69
+
70
+ ptyProcess.onExit(({ exitCode }) => {
71
+ session.completed = true
72
+ session.exitCode = exitCode
73
+ })
74
+
75
+ activeSessions.set(sessionName, session)
76
+ return session
77
+ }
78
+
79
+ /**
80
+ * Register terminal routes as Fastify plugin
81
+ * @param {import('fastify').FastifyInstance} fastify
82
+ */
83
+ async function terminalRoutes(fastify) {
84
+ // List sessions
85
+ fastify.get('/api/terminal/sessions', async (request, reply) => {
86
+ if (!verifyToken(request)) {
87
+ reply.code(401)
88
+ return { success: false, error: 'Unauthorized' }
89
+ }
90
+
91
+ const sessions = []
92
+ for (const [name, session] of activeSessions) {
93
+ sessions.push({
94
+ name,
95
+ attached: false,
96
+ completed: session.completed,
97
+ exit_code: session.exitCode,
98
+ started_at: session.startedAt,
99
+ })
100
+ }
101
+
102
+ console.log(`[Terminal] Found ${sessions.length} session(s): ${sessions.map(s => s.name).join(', ') || '(none)'}`)
103
+ return { success: true, sessions }
104
+ })
105
+
106
+ // Capture pane content
107
+ fastify.get('/api/terminal/capture', async (request, reply) => {
108
+ if (!verifyToken(request)) {
109
+ reply.code(401)
110
+ return { success: false, error: 'Unauthorized' }
111
+ }
112
+
113
+ const { session: sessionName, lines = '100' } = request.query || {}
114
+ if (!sessionName) {
115
+ reply.code(400)
116
+ return { success: false, error: 'session parameter is required' }
117
+ }
118
+ if (!/^[\w-]+$/.test(sessionName)) {
119
+ reply.code(400)
120
+ return { success: false, error: 'Invalid session name' }
121
+ }
122
+
123
+ const session = activeSessions.get(sessionName)
124
+ if (!session) {
125
+ reply.code(404)
126
+ return { success: false, error: `Session '${sessionName}' not found` }
127
+ }
128
+
129
+ const lineCount = Math.min(Math.max(parseInt(lines) || 100, 1), 1000)
130
+ const allLines = session.buffer.split('\n')
131
+ const content = allLines.slice(-lineCount).join('\n')
132
+
133
+ return {
134
+ success: true,
135
+ session: sessionName,
136
+ content,
137
+ lines: lineCount,
138
+ timestamp: Date.now(),
139
+ }
140
+ })
141
+
142
+ // Send keys to session
143
+ fastify.post('/api/terminal/send', async (request, reply) => {
144
+ if (!verifyToken(request)) {
145
+ reply.code(401)
146
+ return { success: false, error: 'Unauthorized' }
147
+ }
148
+
149
+ const { session: sessionName, input, enter = false, special } = request.body || {}
150
+ if (!sessionName) {
151
+ reply.code(400)
152
+ return { success: false, error: 'session is required' }
153
+ }
154
+ if (!/^[\w-]+$/.test(sessionName)) {
155
+ reply.code(400)
156
+ return { success: false, error: 'Invalid session name' }
157
+ }
158
+ if (!input && !special) {
159
+ reply.code(400)
160
+ return { success: false, error: 'input or special key is required' }
161
+ }
162
+
163
+ const session = activeSessions.get(sessionName)
164
+ if (!session || !session.pty) {
165
+ reply.code(404)
166
+ return { success: false, error: `Session '${sessionName}' not found` }
167
+ }
168
+ if (session.completed) {
169
+ reply.code(400)
170
+ return { success: false, error: `Session '${sessionName}' has already exited` }
171
+ }
172
+
173
+ const specialKeyMap = {
174
+ 'Enter': '\r',
175
+ 'Escape': '\x1b',
176
+ 'Tab': '\t',
177
+ 'C-c': '\x03',
178
+ 'C-d': '\x04',
179
+ 'C-z': '\x1a',
180
+ 'Up': '\x1b[A',
181
+ 'Down': '\x1b[B',
182
+ 'Left': '\x1b[D',
183
+ 'Right': '\x1b[C',
184
+ }
185
+
186
+ try {
187
+ if (special) {
188
+ const key = specialKeyMap[special]
189
+ if (!key) {
190
+ reply.code(400)
191
+ return { success: false, error: `Invalid special key. Allowed: ${Object.keys(specialKeyMap).join(', ')}` }
192
+ }
193
+ session.pty.write(key)
194
+ } else {
195
+ session.pty.write(input)
196
+ if (enter) session.pty.write('\r')
197
+ }
198
+
199
+ console.log(`[Terminal] Sent keys to session '${sessionName}'`)
200
+ return { success: true, session: sessionName }
201
+ } catch (error) {
202
+ console.error(`[Terminal] Failed to send keys: ${error.message}`)
203
+ reply.code(500)
204
+ return { success: false, error: error.message }
205
+ }
206
+ })
207
+
208
+ // Create a new session
209
+ fastify.post('/api/terminal/create', async (request, reply) => {
210
+ if (!verifyToken(request)) {
211
+ reply.code(401)
212
+ return { success: false, error: 'Unauthorized' }
213
+ }
214
+
215
+ const { name, command } = request.body || {}
216
+ const sessionName = name || `session-${Date.now()}`
217
+
218
+ if (!/^[\w-]+$/.test(sessionName)) {
219
+ reply.code(400)
220
+ return { success: false, error: 'Invalid session name' }
221
+ }
222
+
223
+ if (activeSessions.has(sessionName)) {
224
+ reply.code(409)
225
+ return { success: false, error: `Session '${sessionName}' already exists` }
226
+ }
227
+
228
+ try {
229
+ createPtySession(sessionName, command)
230
+ console.log(`[Terminal] Created session '${sessionName}'`)
231
+ return { success: true, session: sessionName, message: `Session '${sessionName}' created` }
232
+ } catch (error) {
233
+ console.error(`[Terminal] Failed to create session: ${error.message}`)
234
+ reply.code(500)
235
+ return { success: false, error: error.message }
236
+ }
237
+ })
238
+
239
+ // Kill a session
240
+ fastify.post('/api/terminal/kill', async (request, reply) => {
241
+ if (!verifyToken(request)) {
242
+ reply.code(401)
243
+ return { success: false, error: 'Unauthorized' }
244
+ }
245
+
246
+ const { session: sessionName } = request.body || {}
247
+ if (!sessionName) {
248
+ reply.code(400)
249
+ return { success: false, error: 'session is required' }
250
+ }
251
+ if (!/^[\w-]+$/.test(sessionName)) {
252
+ reply.code(400)
253
+ return { success: false, error: 'Invalid session name' }
254
+ }
255
+
256
+ const session = activeSessions.get(sessionName)
257
+ if (!session) {
258
+ reply.code(404)
259
+ return { success: false, error: `Session '${sessionName}' not found` }
260
+ }
261
+
262
+ try {
263
+ if (session.pty && !session.completed) {
264
+ session.pty.kill()
265
+ }
266
+ activeSessions.delete(sessionName)
267
+ console.log(`[Terminal] Killed session '${sessionName}'`)
268
+ return { success: true, session: sessionName, message: `Session '${sessionName}' terminated` }
269
+ } catch (error) {
270
+ console.error(`[Terminal] Failed to kill session: ${error.message}`)
271
+ reply.code(500)
272
+ return { success: false, error: error.message }
273
+ }
274
+ })
275
+
276
+ // ttyd status endpoint (compatibility — reports node-pty sessions instead)
277
+ fastify.get('/api/terminal/ttyd/status', async (request, reply) => {
278
+ if (!verifyToken(request)) {
279
+ reply.code(401)
280
+ return { success: false, error: 'Unauthorized' }
281
+ }
282
+
283
+ const sessions = []
284
+ for (const [name, session] of activeSessions) {
285
+ sessions.push({
286
+ session: name,
287
+ port: null, // No per-session port; WebSocket server handles all
288
+ startedAt: session.startedAt,
289
+ running: !session.completed,
290
+ })
291
+ }
292
+
293
+ return {
294
+ success: true,
295
+ ttyd_installed: !!loadNodePty(),
296
+ active_sessions: sessions,
297
+ }
298
+ })
299
+ }
300
+
301
+ function cleanupSessions() {
302
+ for (const [name, session] of activeSessions) {
303
+ try {
304
+ if (session.pty && !session.completed) session.pty.kill()
305
+ console.log(`[Terminal] Cleanup: killed session '${name}'`)
306
+ } catch { /* ignore */ }
307
+ }
308
+ activeSessions.clear()
309
+ }
310
+
311
+ module.exports = {
312
+ terminalRoutes,
313
+ cleanupSessions,
314
+ activeSessions,
315
+ createPtySession,
316
+ }
@@ -0,0 +1,324 @@
1
+ /**
2
+ * Windows Routine Runner
3
+ *
4
+ * Manages cron-based routine execution using node-pty instead of tmux.
5
+ * Same architecture as win/workflow-runner.js with routine-specific naming.
6
+ */
7
+
8
+ const { Cron } = require('croner')
9
+ const crypto = require('crypto')
10
+ const path = require('path')
11
+ const fs = require('fs').promises
12
+ const fsSync = require('fs')
13
+
14
+ const { config } = require('../core/config')
15
+ const executionStore = require('../core/stores/execution-store')
16
+ const routineStore = require('../core/stores/routine-store')
17
+ const logManager = require('./lib/log-manager')
18
+ const { MARKER_DIR, buildExtendedPath } = require('../core/lib/platform')
19
+ const { activeSessions } = require('./workflow-runner')
20
+
21
+ const activeJobs = new Map()
22
+ const runningExecutions = new Map()
23
+
24
+ function sleep(ms) {
25
+ return new Promise((resolve) => setTimeout(resolve, ms))
26
+ }
27
+
28
+ function generateSessionName(routineId, executionId) {
29
+ const routineShort = routineId ? routineId.substring(0, 8) : 'manual'
30
+ const execShort = executionId ? executionId.substring(0, 4) : ''
31
+ return execShort ? `rt-${routineShort}-${execShort}` : `rt-${routineShort}`
32
+ }
33
+
34
+ async function writeMarkerFile(sessionName, data) {
35
+ try {
36
+ await fs.mkdir(MARKER_DIR, { recursive: true })
37
+ const filePath = path.join(MARKER_DIR, `${sessionName}.json`)
38
+ await fs.writeFile(filePath, JSON.stringify(data, null, 2), 'utf-8')
39
+ console.log(`[RoutineRunner] Wrote marker file: ${filePath}`)
40
+ } catch (err) {
41
+ console.error(`[RoutineRunner] Failed to write marker file: ${err.message}`)
42
+ }
43
+ }
44
+
45
+ async function cleanupMarkerFile(sessionName) {
46
+ try {
47
+ const filePath = path.join(MARKER_DIR, `${sessionName}.json`)
48
+ await fs.unlink(filePath)
49
+ console.log(`[RoutineRunner] Cleaned up marker file: ${filePath}`)
50
+ } catch { /* ignore */ }
51
+ }
52
+
53
+ function loadNodePty() {
54
+ // Prefer prebuilt binaries (no Build Tools required)
55
+ try { return require('node-pty-prebuilt-multiarch') } catch {}
56
+ // Fallback: source-compiled version
57
+ try { return require('node-pty') } catch {}
58
+ throw new Error(
59
+ 'node-pty is required for Windows routine execution. ' +
60
+ 'Install with: npm install node-pty-prebuilt-multiarch'
61
+ )
62
+ }
63
+
64
+ async function executeRoutineSession(routine, executionId, skillNames) {
65
+ const pty = loadNodePty()
66
+ const homeDir = config.HOME_DIR
67
+ const sessionName = generateSessionName(routine.id, executionId)
68
+
69
+ const skillCommands = skillNames.map(name => `/${name}`).join(', then ')
70
+ const contextPrefix = routine.context
71
+ ? `## Context\n\n${routine.context}\n\n---\n\n`
72
+ : ''
73
+ const prompt = `${contextPrefix}Run the following skills in order: ${skillCommands}. After completing all skills, run /execution-report to report the results.`
74
+
75
+ const extendedPath = buildExtendedPath(homeDir)
76
+ const logFile = logManager.getLogPath(executionId)
77
+
78
+ console.log(`[RoutineRunner] Executing routine: ${routine.name}`)
79
+ console.log(`[RoutineRunner] Skills: ${skillNames.join(' -> ')} -> execution-report`)
80
+ console.log(`[RoutineRunner] Session: ${sessionName}`)
81
+ console.log(`[RoutineRunner] Log file: ${logFile}`)
82
+
83
+ try {
84
+ await logManager.ensureLogDir()
85
+ await logManager.pruneOldLogs()
86
+
87
+ const existing = activeSessions.get(sessionName)
88
+ if (existing && existing.pty) {
89
+ try { existing.pty.kill() } catch { /* ignore */ }
90
+ activeSessions.delete(sessionName)
91
+ }
92
+
93
+ await writeMarkerFile(sessionName, {
94
+ execution_id: executionId,
95
+ routine_id: routine.id,
96
+ routine_name: routine.name,
97
+ skill_names: skillNames,
98
+ started_at: new Date().toISOString(),
99
+ })
100
+
101
+ if (!config.LLM_COMMAND) {
102
+ throw new Error('LLM_COMMAND is not configured. Set LLM_COMMAND in minion.env')
103
+ }
104
+ const escapedPrompt = prompt.replace(/'/g, "''")
105
+ const llmCommand = config.LLM_COMMAND.replace(/\{prompt\}/g, escapedPrompt)
106
+
107
+ const env = {
108
+ ...process.env,
109
+ HOME: homeDir,
110
+ USERPROFILE: homeDir,
111
+ PATH: extendedPath,
112
+ MINION_EXECUTION_ID: executionId,
113
+ MINION_ROUTINE_ID: routine.id,
114
+ MINION_ROUTINE_NAME: routine.name,
115
+ }
116
+
117
+ const logDir = path.dirname(logFile)
118
+ await fs.mkdir(logDir, { recursive: true })
119
+ const logStream = fsSync.createWriteStream(logFile, { flags: 'a' })
120
+
121
+ const shell = process.env.COMSPEC || 'cmd.exe'
122
+ const shellArgs = shell.toLowerCase().includes('cmd') ? ['/c', llmCommand] : ['-Command', llmCommand]
123
+
124
+ const ptyProcess = pty.spawn(shell, shellArgs, {
125
+ name: 'xterm-256color',
126
+ cols: 200,
127
+ rows: 50,
128
+ cwd: homeDir,
129
+ env,
130
+ })
131
+
132
+ const session = {
133
+ pty: ptyProcess,
134
+ buffer: '',
135
+ logStream,
136
+ completed: false,
137
+ exitCode: null,
138
+ startedAt: new Date().toISOString(),
139
+ }
140
+ activeSessions.set(sessionName, session)
141
+
142
+ console.log(`[RoutineRunner] Started pty session: ${sessionName} (PID: ${ptyProcess.pid})`)
143
+
144
+ return await new Promise((resolve) => {
145
+ const timeout = 60 * 60 * 1000
146
+ const timer = setTimeout(() => {
147
+ console.error(`[RoutineRunner] Routine ${routine.name} timed out after 60 minutes`)
148
+ try { ptyProcess.kill() } catch { /* ignore */ }
149
+ resolve({ success: false, error: 'Execution timeout (60 minutes)', sessionName })
150
+ }, timeout)
151
+
152
+ ptyProcess.onData((data) => {
153
+ session.buffer += data
154
+ if (session.buffer.length > 1024 * 1024) {
155
+ session.buffer = session.buffer.slice(-512 * 1024)
156
+ }
157
+ try { logStream.write(data) } catch { /* ignore */ }
158
+ })
159
+
160
+ ptyProcess.onExit(({ exitCode }) => {
161
+ clearTimeout(timer)
162
+ session.completed = true
163
+ session.exitCode = exitCode
164
+ try { logStream.end() } catch { /* ignore */ }
165
+
166
+ if (exitCode === 0) {
167
+ console.log(`[RoutineRunner] Routine ${routine.name} completed successfully`)
168
+ resolve({ success: true, sessionName })
169
+ } else {
170
+ console.error(`[RoutineRunner] Routine ${routine.name} failed with exit code: ${exitCode}`)
171
+ resolve({ success: false, error: `Exit code: ${exitCode}`, sessionName })
172
+ }
173
+ })
174
+ })
175
+ } catch (error) {
176
+ console.error(`[RoutineRunner] Routine ${routine.name} failed: ${error.message}`)
177
+ return { success: false, error: error.message, sessionName }
178
+ } finally {
179
+ await cleanupMarkerFile(sessionName)
180
+ }
181
+ }
182
+
183
+ async function runRoutine(routine) {
184
+ const pipelineSkillNames = routine.pipeline_skill_names || []
185
+ if (pipelineSkillNames.length === 0) {
186
+ console.log(`[RoutineRunner] Routine "${routine.name}" has empty pipeline, skipping`)
187
+ return { execution_id: null, session_name: null }
188
+ }
189
+
190
+ const executionId = crypto.randomUUID()
191
+ const startedAt = new Date().toISOString()
192
+ const sessionName = generateSessionName(routine.id, executionId)
193
+
194
+ console.log(`[RoutineRunner] Starting routine: ${routine.name} (execution: ${executionId})`)
195
+
196
+ runningExecutions.set(executionId, {
197
+ routine_name: routine.name,
198
+ skill_names: pipelineSkillNames,
199
+ started_at: startedAt,
200
+ session_name: sessionName,
201
+ })
202
+
203
+ const logFile = logManager.getLogPath(executionId)
204
+
205
+ await executionStore.save({
206
+ id: executionId,
207
+ skill_name: pipelineSkillNames.join(' -> '),
208
+ routine_id: routine.id,
209
+ routine_name: routine.name,
210
+ status: 'running',
211
+ outcome: null,
212
+ started_at: startedAt,
213
+ completed_at: null,
214
+ parent_execution_id: null,
215
+ error_message: null,
216
+ log_file: logFile,
217
+ })
218
+
219
+ const result = await executeRoutineSession(routine, executionId, pipelineSkillNames)
220
+ const completedAt = new Date().toISOString()
221
+
222
+ await executionStore.save({
223
+ id: executionId,
224
+ skill_name: pipelineSkillNames.join(' -> '),
225
+ routine_id: routine.id,
226
+ routine_name: routine.name,
227
+ status: result.success ? 'completed' : 'failed',
228
+ outcome: result.success ? null : 'failure',
229
+ started_at: startedAt,
230
+ completed_at: completedAt,
231
+ parent_execution_id: null,
232
+ error_message: result.error || null,
233
+ log_file: logFile,
234
+ })
235
+
236
+ await routineStore.updateLastRun(routine.id)
237
+
238
+ runningExecutions.delete(executionId)
239
+ console.log(`[RoutineRunner] Completed routine: ${routine.name}`)
240
+
241
+ return { execution_id: executionId, session_name: sessionName }
242
+ }
243
+
244
+ function loadRoutines(routines) {
245
+ stopAll()
246
+ let activeCount = 0
247
+
248
+ for (const routine of routines) {
249
+ if (!routine.is_active) {
250
+ console.log(`[RoutineRunner] Skipping inactive routine: ${routine.name}`)
251
+ continue
252
+ }
253
+ if (!routine.cron_expression) {
254
+ console.log(`[RoutineRunner] Routine "${routine.name}" has no schedule (manual trigger only)`)
255
+ activeJobs.set(routine.id, { job: null, routine })
256
+ continue
257
+ }
258
+ try {
259
+ const job = new Cron(routine.cron_expression, () => {
260
+ runRoutine(routine).catch(err => {
261
+ console.error(`[RoutineRunner] Unhandled error in ${routine.name}: ${err.message}`)
262
+ })
263
+ })
264
+ activeJobs.set(routine.id, { job, routine })
265
+ activeCount++
266
+ console.log(`[RoutineRunner] Registered: ${routine.name} (${routine.cron_expression})`)
267
+ } catch (err) {
268
+ console.error(`[RoutineRunner] Failed to register ${routine.name}: ${err.message}`)
269
+ }
270
+ }
271
+
272
+ console.log(`[RoutineRunner] Loaded ${routines.length} routines, ${activeCount} with cron schedule`)
273
+ }
274
+
275
+ function stopAll() {
276
+ for (const [, { job, routine }] of activeJobs) {
277
+ if (job) job.stop()
278
+ console.log(`[RoutineRunner] Stopped: ${routine.name}`)
279
+ }
280
+ activeJobs.clear()
281
+ }
282
+
283
+ function getStatus() {
284
+ const routines = []
285
+ for (const [id, { job, routine }] of activeJobs) {
286
+ routines.push({
287
+ id,
288
+ name: routine.name,
289
+ cron_expression: routine.cron_expression,
290
+ next_run: job?.nextRun()?.toISOString() || null,
291
+ })
292
+ }
293
+ const running = []
294
+ for (const [id, info] of runningExecutions) {
295
+ running.push({
296
+ execution_id: id,
297
+ routine_name: info.routine_name,
298
+ skill_names: info.skill_names,
299
+ session_name: info.session_name,
300
+ started_at: info.started_at,
301
+ })
302
+ }
303
+ return {
304
+ active_routines: activeJobs.size,
305
+ running_executions: runningExecutions.size,
306
+ routines,
307
+ running,
308
+ }
309
+ }
310
+
311
+ function getRoutineById(routineId) {
312
+ const entry = activeJobs.get(routineId)
313
+ return entry?.routine || null
314
+ }
315
+
316
+ module.exports = {
317
+ loadRoutines,
318
+ stopAll,
319
+ getStatus,
320
+ runRoutine,
321
+ getRoutineById,
322
+ generateSessionName,
323
+ MARKER_DIR,
324
+ }