@simonyea/holysheep-cli 1.7.135 → 2.0.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.
@@ -15,9 +15,14 @@ const path = require('path')
15
15
  const { execSync, spawn } = require('child_process')
16
16
  const { loadConfig, saveConfig, getApiKey, BASE_URL_ANTHROPIC, BASE_URL_OPENAI, BASE_URL_CLAUDE_RELAY, SHOP_URL, CONFIG_FILE } = require('../utils/config')
17
17
  const { removeEnvFromShell, writeEnvToShell, getShellRcFiles } = require('../utils/shell')
18
- const { commandExists } = require('../utils/which')
18
+ const { commandExistsAsync } = require('../utils/which')
19
19
  const TOOLS = require('../tools')
20
20
  const pkg = require('../../package.json')
21
+ const workspaceStore = require('./workspace-store')
22
+ const workspaceRuntime = require('./workspace-runtime')
23
+
24
+ const TOOL_CHECK_CACHE_TTL_MS = 10_000
25
+ const toolStateCache = new Map()
21
26
 
22
27
  // ── Helpers ──────────────────────────────────────────────────────────────────
23
28
 
@@ -92,6 +97,52 @@ function parseBody(req) {
92
97
  })
93
98
  }
94
99
 
100
+ function getToolCommand(toolId) {
101
+ const cmds = {
102
+ 'claude-code': 'claude',
103
+ 'codex': 'codex',
104
+ 'droid': 'droid',
105
+ 'gemini-cli': 'gemini',
106
+ 'opencode': 'opencode',
107
+ 'openclaw': 'openclaw',
108
+ 'aider': 'aider',
109
+ 'env-config': null,
110
+ }
111
+ return cmds[toolId] ?? null
112
+ }
113
+
114
+ async function detectToolInstalled(tool) {
115
+ if (tool.id === 'env-config') return true
116
+
117
+ const now = Date.now()
118
+ const cached = toolStateCache.get(tool.id)
119
+ if (cached && now - cached.checkedAt < TOOL_CHECK_CACHE_TTL_MS) {
120
+ return cached.installed
121
+ }
122
+
123
+ const command = getToolCommand(tool.id)
124
+ const installed = command ? await commandExistsAsync(command) : false
125
+ toolStateCache.set(tool.id, { installed, checkedAt: now })
126
+ return installed
127
+ }
128
+
129
+ async function buildToolSummary(tool) {
130
+ const installed = await detectToolInstalled(tool)
131
+ return {
132
+ id: tool.id,
133
+ name: tool.name,
134
+ installed,
135
+ configured: installed ? (tool.isConfigured?.() || false) : false,
136
+ version: installed ? await getVersionAsync(tool) : null,
137
+ installCmd: tool.installCmd,
138
+ hint: tool.hint || null,
139
+ launchCmd: tool.launchCmd || null,
140
+ canAutoInstall: !!AUTO_INSTALL[tool.id],
141
+ canUpgrade: !!UPGRADABLE_TOOLS.find((item) => item.id === tool.id),
142
+ npmPkg: UPGRADABLE_TOOLS.find((item) => item.id === tool.id)?.npmPkg || null,
143
+ }
144
+ }
145
+
95
146
  function json(res, data, status = 200) {
96
147
  res.writeHead(status, { 'Content-Type': 'application/json' })
97
148
  res.end(JSON.stringify(data))
@@ -190,6 +241,11 @@ async function handleLogin(req, res) {
190
241
  const valid = await validateApiKey(apiKey)
191
242
  if (!valid) return json(res, { success: false, message: 'API Key 无效' }, 401)
192
243
  saveConfig({ apiKey, savedAt: new Date().toISOString() })
244
+ workspaceStore.saveHolySheepApiConfig({
245
+ apiKey,
246
+ baseUrl: workspaceStore.getHolySheepApiConfig().baseUrl || BASE_URL_OPENAI,
247
+ model: workspaceStore.getHolySheepApiConfig().model || 'gpt-5.4',
248
+ })
193
249
  json(res, { success: true, apiKey: maskKey(apiKey) })
194
250
  } catch (e) {
195
251
  json(res, { success: false, message: `验证失败: ${e.message}` }, 500)
@@ -201,6 +257,11 @@ async function handleLogout(_req, res) {
201
257
  delete config.apiKey
202
258
  delete config.savedAt
203
259
  fs.writeFileSync(CONFIG_FILE, JSON.stringify(config, null, 2))
260
+ workspaceStore.saveHolySheepApiConfig({
261
+ apiKey: '',
262
+ baseUrl: workspaceStore.getHolySheepApiConfig().baseUrl || BASE_URL_OPENAI,
263
+ model: workspaceStore.getHolySheepApiConfig().model || 'gpt-5.4',
264
+ })
204
265
  json(res, { success: true })
205
266
  }
206
267
 
@@ -240,19 +301,7 @@ async function handleDoctor(_req, res) {
240
301
  const nodeMajor = parseInt(process.version.slice(1), 10)
241
302
 
242
303
  // Tools
243
- const tools = TOOLS.map(t => {
244
- const installed = t.checkInstalled()
245
- return {
246
- id: t.id,
247
- name: t.name,
248
- installed,
249
- configured: installed ? (t.isConfigured?.() || false) : false,
250
- version: installed ? getVersion(t) : null,
251
- installCmd: t.installCmd,
252
- hint: t.hint || null,
253
- launchCmd: t.launchCmd || null,
254
- }
255
- })
304
+ const tools = await Promise.all(TOOLS.map((tool) => buildToolSummary(tool)))
256
305
 
257
306
  // Connectivity
258
307
  let connectivity = { ok: false, modelCount: 0 }
@@ -297,22 +346,7 @@ function isClaudeProxyRunning() {
297
346
  }
298
347
 
299
348
  async function handleTools(_req, res) {
300
- const tools = await Promise.all(TOOLS.map(async t => {
301
- const installed = t.checkInstalled()
302
- return {
303
- id: t.id,
304
- name: t.name,
305
- installed,
306
- configured: installed ? (t.isConfigured?.() || false) : false,
307
- version: installed ? await getVersionAsync(t) : null,
308
- installCmd: t.installCmd,
309
- hint: t.hint || null,
310
- launchCmd: t.launchCmd || null,
311
- canAutoInstall: !!AUTO_INSTALL[t.id],
312
- canUpgrade: !!UPGRADABLE_TOOLS.find(u => u.id === t.id),
313
- npmPkg: UPGRADABLE_TOOLS.find(u => u.id === t.id)?.npmPkg || null,
314
- }
315
- }))
349
+ const tools = await Promise.all(TOOLS.map((tool) => buildToolSummary(tool)))
316
350
 
317
351
  // 追加 claude-proxy 虚拟工具
318
352
  const proxyState = isClaudeProxyRunning()
@@ -1034,6 +1068,192 @@ function handleModels(_req, res) {
1034
1068
  ])
1035
1069
  }
1036
1070
 
1071
+ function getWorkspacePayload() {
1072
+ const config = workspaceRuntime.normalizeRuntimeConfig({})
1073
+ const hasRuntimeConfig = Boolean(config.apiKey && config.baseUrl && config.model)
1074
+ return {
1075
+ conversations: workspaceStore.listConversations(),
1076
+ scheduledTasks: workspaceStore.listTasks(),
1077
+ holySheepApi: {
1078
+ ...config,
1079
+ apiKeyMasked: config.apiKey ? maskKey(config.apiKey) : null,
1080
+ ready: hasRuntimeConfig,
1081
+ },
1082
+ tools: TOOLS.map((tool) => ({
1083
+ id: tool.id,
1084
+ name: tool.name,
1085
+ launchCmd: tool.launchCmd || null,
1086
+ hint: tool.hint || null,
1087
+ })),
1088
+ }
1089
+ }
1090
+
1091
+ async function handleWorkspaceState(_req, res) {
1092
+ json(res, getWorkspacePayload())
1093
+ }
1094
+
1095
+ async function handleWorkspaceSearch(req, res, url) {
1096
+ const query = url.searchParams.get('q') || ''
1097
+ json(res, workspaceStore.searchWorkspace(query))
1098
+ }
1099
+
1100
+ async function handleWorkspaceApiConfig(req, res) {
1101
+ if (req.method === 'GET') {
1102
+ const config = workspaceRuntime.normalizeRuntimeConfig({})
1103
+ return json(res, {
1104
+ apiKey: config.apiKey,
1105
+ apiKeyMasked: config.apiKey ? maskKey(config.apiKey) : null,
1106
+ baseUrl: config.baseUrl,
1107
+ model: config.model,
1108
+ ready: Boolean(config.apiKey && config.baseUrl && config.model),
1109
+ })
1110
+ }
1111
+
1112
+ const body = await parseBody(req)
1113
+ const apiKey = String(body.apiKey || '').trim()
1114
+ const baseUrl = String(body.baseUrl || '').trim() || BASE_URL_OPENAI
1115
+ const model = String(body.model || '').trim()
1116
+ if (!apiKey || !apiKey.startsWith('cr_')) {
1117
+ return json(res, { success: false, error: 'HolySheep API Key 必须以 cr_ 开头' }, 400)
1118
+ }
1119
+ if (!model) {
1120
+ return json(res, { success: false, error: '模型不能为空' }, 400)
1121
+ }
1122
+
1123
+ workspaceStore.saveHolySheepApiConfig({ apiKey, baseUrl, model })
1124
+ saveConfig({ apiKey, savedAt: new Date().toISOString() })
1125
+ json(res, { success: true, config: getWorkspacePayload().holySheepApi })
1126
+ }
1127
+
1128
+ async function handleWorkspaceConversations(req, res) {
1129
+ if (req.method === 'GET') {
1130
+ return json(res, workspaceStore.listConversations())
1131
+ }
1132
+ const body = await parseBody(req)
1133
+ const conversation = workspaceStore.createConversation({
1134
+ title: body.title,
1135
+ toolId: body.toolId,
1136
+ pinned: body.pinned,
1137
+ })
1138
+ json(res, { success: true, conversation })
1139
+ }
1140
+
1141
+ async function handleWorkspaceConversationById(req, res, conversationId) {
1142
+ const conversation = workspaceStore.getConversation(conversationId)
1143
+ if (!conversation) return json(res, { success: false, error: '会话不存在' }, 404)
1144
+
1145
+ if (req.method === 'GET') {
1146
+ return json(res, conversation)
1147
+ }
1148
+
1149
+ if (req.method === 'PATCH') {
1150
+ const body = await parseBody(req)
1151
+ const updated = workspaceStore.updateConversation(conversationId, {
1152
+ title: body.title,
1153
+ toolId: body.toolId,
1154
+ pinned: body.pinned,
1155
+ })
1156
+ return json(res, { success: true, conversation: updated })
1157
+ }
1158
+
1159
+ return json(res, { success: false, error: 'Method not allowed' }, 405)
1160
+ }
1161
+
1162
+ async function handleWorkspaceConversationMessages(req, res, conversationId) {
1163
+ if (req.method === 'GET') {
1164
+ const conversation = workspaceStore.getConversation(conversationId)
1165
+ if (!conversation) return json(res, { success: false, error: '会话不存在' }, 404)
1166
+ return json(res, conversation.messages)
1167
+ }
1168
+
1169
+ const body = await parseBody(req)
1170
+ const content = String(body.content || '').trim()
1171
+ if (!content) return json(res, { success: false, error: '消息不能为空' }, 400)
1172
+
1173
+ try {
1174
+ const result = await workspaceRuntime.sendConversationMessage(conversationId, content, body.runtimeConfig || {})
1175
+ return json(res, {
1176
+ success: true,
1177
+ messages: [result.userMessage, result.assistantMessage],
1178
+ conversation: workspaceStore.getConversation(conversationId),
1179
+ })
1180
+ } catch (error) {
1181
+ return json(res, {
1182
+ success: false,
1183
+ error: error.message,
1184
+ conversation: workspaceStore.getConversation(conversationId),
1185
+ }, 500)
1186
+ }
1187
+ }
1188
+
1189
+ async function handleWorkspaceTasks(req, res) {
1190
+ if (req.method === 'GET') {
1191
+ return json(res, workspaceStore.listTasks())
1192
+ }
1193
+ const body = await parseBody(req)
1194
+ const title = String(body.title || '').trim()
1195
+ const prompt = String(body.prompt || '').trim()
1196
+ if (!title || !prompt) return json(res, { success: false, error: '任务标题和提示词不能为空' }, 400)
1197
+
1198
+ try {
1199
+ workspaceRuntime.parseScheduleToMs(body.schedule || '1h')
1200
+ } catch (error) {
1201
+ return json(res, { success: false, error: error.message }, 400)
1202
+ }
1203
+
1204
+ const task = workspaceStore.saveTask({
1205
+ title,
1206
+ prompt,
1207
+ schedule: body.schedule || '1h',
1208
+ active: body.active !== false,
1209
+ conversationId: body.conversationId || null,
1210
+ modelOverride: body.modelOverride || '',
1211
+ })
1212
+ workspaceRuntime.rescheduleAllTasks()
1213
+ json(res, { success: true, task })
1214
+ }
1215
+
1216
+ async function handleWorkspaceTaskById(req, res, taskId) {
1217
+ if (req.method === 'DELETE') {
1218
+ workspaceStore.deleteTask(taskId)
1219
+ workspaceRuntime.rescheduleAllTasks()
1220
+ return json(res, { success: true })
1221
+ }
1222
+
1223
+ if (req.method === 'PATCH') {
1224
+ const body = await parseBody(req)
1225
+ if (body.schedule) {
1226
+ try {
1227
+ workspaceRuntime.parseScheduleToMs(body.schedule)
1228
+ } catch (error) {
1229
+ return json(res, { success: false, error: error.message }, 400)
1230
+ }
1231
+ }
1232
+ const task = workspaceStore.saveTask({
1233
+ id: taskId,
1234
+ title: body.title,
1235
+ prompt: body.prompt,
1236
+ schedule: body.schedule,
1237
+ active: body.active,
1238
+ conversationId: body.conversationId,
1239
+ modelOverride: body.modelOverride,
1240
+ })
1241
+ workspaceRuntime.rescheduleAllTasks()
1242
+ return json(res, { success: true, task })
1243
+ }
1244
+
1245
+ if (req.method === 'POST') {
1246
+ try {
1247
+ const result = await workspaceRuntime.runTask(taskId)
1248
+ return json(res, { success: true, result })
1249
+ } catch (error) {
1250
+ return json(res, { success: false, error: error.message }, 500)
1251
+ }
1252
+ }
1253
+
1254
+ return json(res, { success: false, error: 'Method not allowed' }, 405)
1255
+ }
1256
+
1037
1257
  // ── Router ───────────────────────────────────────────────────────────────────
1038
1258
 
1039
1259
  async function handleRequest(req, res) {
@@ -1062,6 +1282,17 @@ async function handleRequest(req, res) {
1062
1282
  if (route === '/api/doctor' && req.method === 'GET') return await handleDoctor(req, res)
1063
1283
  if (route === '/api/tools' && req.method === 'GET') return await handleTools(req, res)
1064
1284
  if (route === '/api/models' && req.method === 'GET') return await handleModels(req, res)
1285
+ if (route === '/api/workspace/state' && req.method === 'GET') return await handleWorkspaceState(req, res)
1286
+ if (route === '/api/workspace/search' && req.method === 'GET') return await handleWorkspaceSearch(req, res, url)
1287
+ if (route === '/api/workspace/api-config' && (req.method === 'GET' || req.method === 'POST')) return await handleWorkspaceApiConfig(req, res)
1288
+ if (route === '/api/workspace/conversations' && (req.method === 'GET' || req.method === 'POST')) return await handleWorkspaceConversations(req, res)
1289
+ const conversationMatch = route.match(/^\/api\/workspace\/conversations\/([^/]+)$/)
1290
+ if (conversationMatch) return await handleWorkspaceConversationById(req, res, conversationMatch[1])
1291
+ const messagesMatch = route.match(/^\/api\/workspace\/conversations\/([^/]+)\/messages$/)
1292
+ if (messagesMatch) return await handleWorkspaceConversationMessages(req, res, messagesMatch[1])
1293
+ if (route === '/api/workspace/tasks' && (req.method === 'GET' || req.method === 'POST')) return await handleWorkspaceTasks(req, res)
1294
+ const taskMatch = route.match(/^\/api\/workspace\/tasks\/([^/]+)$/)
1295
+ if (taskMatch) return await handleWorkspaceTaskById(req, res, taskMatch[1])
1065
1296
  if (route === '/api/upgrade' && req.method === 'POST') return await handleUpgrade(req, res)
1066
1297
  if (route === '/api/tool/install' && req.method === 'POST') return await handleToolInstall(req, res)
1067
1298
  if (route === '/api/tool/configure' && req.method === 'POST') return await handleToolConfigure(req, res)
@@ -1113,13 +1344,19 @@ function startServer(port) {
1113
1344
  setTimeout(() => {
1114
1345
  const retry = http.createServer(handleRequest)
1115
1346
  retry.on('error', (err2) => reject(err2))
1116
- retry.listen(port, '127.0.0.1', () => resolve(retry))
1347
+ retry.listen(port, '127.0.0.1', () => {
1348
+ workspaceRuntime.startScheduler()
1349
+ resolve(retry)
1350
+ })
1117
1351
  }, 500)
1118
1352
  } else {
1119
1353
  reject(err)
1120
1354
  }
1121
1355
  })
1122
- server.listen(port, '127.0.0.1', () => resolve(server))
1356
+ server.listen(port, '127.0.0.1', () => {
1357
+ workspaceRuntime.startScheduler()
1358
+ resolve(server)
1359
+ })
1123
1360
  })
1124
1361
  }
1125
1362
 
@@ -0,0 +1,288 @@
1
+ 'use strict'
2
+
3
+ const fetch = require('node-fetch')
4
+ const {
5
+ addMessage,
6
+ createConversation,
7
+ ensureConversation,
8
+ getConversation,
9
+ getHolySheepApiConfig,
10
+ listTasks,
11
+ updateConversation,
12
+ updateTaskRun,
13
+ } = require('./workspace-store')
14
+ const { getApiKey, BASE_URL_OPENAI } = require('../utils/config')
15
+
16
+ const schedulerHandles = new Map()
17
+
18
+ function normalizeRuntimeConfig(config = {}) {
19
+ const saved = getHolySheepApiConfig()
20
+ const apiKey = String(config.apiKey || saved.apiKey || getApiKey() || '').trim()
21
+ const baseUrl = String(config.baseUrl || saved.baseUrl || BASE_URL_OPENAI).replace(/\/+$/, '')
22
+ const model = String(config.model || saved.model || '').trim()
23
+ return { apiKey, baseUrl, model }
24
+ }
25
+
26
+ function assertRuntimeConfig(config) {
27
+ if (!config.apiKey || !config.apiKey.startsWith('cr_')) {
28
+ throw new Error('HolySheep API Key is required')
29
+ }
30
+ if (!config.baseUrl) {
31
+ throw new Error('HolySheep API Base URL is required')
32
+ }
33
+ if (!config.model) {
34
+ throw new Error('HolySheep API model is required')
35
+ }
36
+ }
37
+
38
+ function buildSystemPrompt(toolId) {
39
+ const toolName = String(toolId || 'codex')
40
+ return [
41
+ `You are operating inside HolySheep Workspace.`,
42
+ `The selected coding tool is "${toolName}".`,
43
+ `Respond with concise, implementation-focused guidance suitable for a coding assistant workspace.`,
44
+ `When you mention commands, prefer copy-paste-ready shell commands.`,
45
+ ].join(' ')
46
+ }
47
+
48
+ async function requestCompletion(messages, config) {
49
+ const controller = new AbortController()
50
+ const timeout = setTimeout(() => controller.abort(), 20_000)
51
+ let response
52
+ try {
53
+ response = await fetch(`${config.baseUrl}/chat/completions`, {
54
+ method: 'POST',
55
+ headers: {
56
+ Authorization: `Bearer ${config.apiKey}`,
57
+ 'Content-Type': 'application/json',
58
+ },
59
+ body: JSON.stringify({
60
+ model: config.model,
61
+ temperature: 0.2,
62
+ messages,
63
+ }),
64
+ signal: controller.signal,
65
+ })
66
+ } catch (error) {
67
+ if (error.name === 'AbortError') {
68
+ throw new Error('HolySheep API request timed out')
69
+ }
70
+ throw error
71
+ } finally {
72
+ clearTimeout(timeout)
73
+ }
74
+
75
+ const payload = await response.json().catch(() => null)
76
+ if (!response.ok) {
77
+ const reason = payload?.error?.message || payload?.message || `HTTP ${response.status}`
78
+ throw new Error(reason)
79
+ }
80
+
81
+ const content = payload?.choices?.[0]?.message?.content
82
+ if (typeof content === 'string' && content.trim()) return content.trim()
83
+ if (Array.isArray(content)) {
84
+ const text = content
85
+ .map((part) => {
86
+ if (typeof part === 'string') return part
87
+ if (typeof part?.text === 'string') return part.text
88
+ if (typeof part?.content === 'string') return part.content
89
+ return ''
90
+ })
91
+ .filter(Boolean)
92
+ .join('\n')
93
+ .trim()
94
+ if (text) return text
95
+ }
96
+ const fallbacks = [
97
+ payload?.choices?.[0]?.text,
98
+ payload?.output_text,
99
+ payload?.response,
100
+ payload?.message,
101
+ ]
102
+ for (const item of fallbacks) {
103
+ if (typeof item === 'string' && item.trim()) return item.trim()
104
+ }
105
+ if (Array.isArray(payload?.output)) {
106
+ const text = payload.output
107
+ .map((part) => {
108
+ if (typeof part?.content === 'string') return part.content
109
+ if (typeof part?.text === 'string') return part.text
110
+ if (Array.isArray(part?.content)) {
111
+ return part.content.map((child) => child?.text || child?.content || '').join('\n')
112
+ }
113
+ return ''
114
+ })
115
+ .filter(Boolean)
116
+ .join('\n')
117
+ .trim()
118
+ if (text) return text
119
+ }
120
+ return 'No response content returned.'
121
+ }
122
+
123
+ async function sendConversationMessage(conversationId, input, options = {}) {
124
+ const conversation = ensureConversation(conversationId)
125
+ const config = normalizeRuntimeConfig(options)
126
+ assertRuntimeConfig(config)
127
+
128
+ const userMessage = addMessage(conversationId, {
129
+ role: 'user',
130
+ content: String(input || ''),
131
+ meta: {
132
+ source: 'workspace',
133
+ },
134
+ })
135
+
136
+ try {
137
+ const messages = [
138
+ { role: 'system', content: buildSystemPrompt(conversation.toolId) },
139
+ ...conversation.messages
140
+ .concat(userMessage)
141
+ .map((message) => ({
142
+ role: message.role,
143
+ content: message.content,
144
+ })),
145
+ ]
146
+ const reply = await requestCompletion(messages, config)
147
+ const assistantMessage = addMessage(conversationId, {
148
+ role: 'assistant',
149
+ content: reply,
150
+ meta: {
151
+ model: config.model,
152
+ source: 'holysheep-api',
153
+ },
154
+ })
155
+ updateConversation(conversationId, {
156
+ summary: reply.slice(0, 240),
157
+ })
158
+ return { userMessage, assistantMessage }
159
+ } catch (error) {
160
+ const assistantMessage = addMessage(conversationId, {
161
+ role: 'assistant',
162
+ content: `Error: ${error.message}`,
163
+ status: 'error',
164
+ meta: {
165
+ model: config.model,
166
+ source: 'holysheep-api',
167
+ },
168
+ })
169
+ updateConversation(conversationId, {
170
+ summary: assistantMessage.content.slice(0, 240),
171
+ })
172
+ throw error
173
+ }
174
+ }
175
+
176
+ function parseScheduleToMs(schedule) {
177
+ const value = String(schedule || '').trim().toLowerCase()
178
+ const match = value.match(/^(\d+)\s*([smhd])$/)
179
+ if (!match) throw new Error('Schedule must use format like 30s, 5m, 1h, 1d')
180
+ const amount = Number(match[1])
181
+ const unit = match[2]
182
+ if (!Number.isFinite(amount) || amount <= 0) throw new Error('Invalid schedule amount')
183
+ const factors = { s: 1000, m: 60_000, h: 3_600_000, d: 86_400_000 }
184
+ return amount * factors[unit]
185
+ }
186
+
187
+ async function runTask(taskId, options = {}) {
188
+ const task = listTasks().find((item) => item.id === taskId)
189
+ if (!task) throw new Error('Task not found')
190
+
191
+ const config = normalizeRuntimeConfig({
192
+ ...options,
193
+ model: task.modelOverride || options.model,
194
+ })
195
+ assertRuntimeConfig(config)
196
+ updateTaskRun(taskId, {
197
+ lastStatus: 'running',
198
+ })
199
+
200
+ let conversationId = task.conversationId
201
+ if (!conversationId) {
202
+ const conversation = createConversation({
203
+ title: task.title,
204
+ toolId: 'codex',
205
+ })
206
+ conversationId = conversation.id
207
+ updateTaskRun(taskId, { conversationId })
208
+ }
209
+
210
+ try {
211
+ const { assistantMessage } = await sendConversationMessage(conversationId, task.prompt, config)
212
+ updateTaskRun(taskId, {
213
+ lastRunAt: new Date().toISOString(),
214
+ lastStatus: 'ok',
215
+ lastResult: assistantMessage.content,
216
+ conversationId,
217
+ })
218
+ return assistantMessage
219
+ } catch (error) {
220
+ updateTaskRun(taskId, {
221
+ lastRunAt: new Date().toISOString(),
222
+ lastStatus: 'error',
223
+ lastResult: error.message,
224
+ conversationId,
225
+ })
226
+ throw error
227
+ }
228
+ }
229
+
230
+ function clearTaskHandle(taskId) {
231
+ const existing = schedulerHandles.get(taskId)
232
+ if (!existing) return
233
+ clearInterval(existing)
234
+ schedulerHandles.delete(taskId)
235
+ }
236
+
237
+ function scheduleTask(task) {
238
+ clearTaskHandle(task.id)
239
+ if (!task.active) return
240
+
241
+ let intervalMs
242
+ try {
243
+ intervalMs = parseScheduleToMs(task.schedule)
244
+ } catch {
245
+ return
246
+ }
247
+
248
+ const handle = setInterval(() => {
249
+ void runTask(task.id).catch(() => {})
250
+ }, intervalMs)
251
+ schedulerHandles.set(task.id, handle)
252
+ }
253
+
254
+ function rescheduleAllTasks() {
255
+ const tasks = listTasks()
256
+ const seen = new Set(tasks.map((task) => task.id))
257
+ for (const taskId of schedulerHandles.keys()) {
258
+ if (!seen.has(taskId)) {
259
+ clearTaskHandle(taskId)
260
+ }
261
+ }
262
+ for (const task of tasks) {
263
+ scheduleTask(task)
264
+ }
265
+ }
266
+
267
+ function stopScheduler() {
268
+ for (const taskId of schedulerHandles.keys()) {
269
+ clearTaskHandle(taskId)
270
+ }
271
+ }
272
+
273
+ function startScheduler() {
274
+ rescheduleAllTasks()
275
+ }
276
+
277
+ module.exports = {
278
+ normalizeRuntimeConfig,
279
+ assertRuntimeConfig,
280
+ sendConversationMessage,
281
+ parseScheduleToMs,
282
+ runTask,
283
+ startScheduler,
284
+ stopScheduler,
285
+ rescheduleAllTasks,
286
+ createConversation,
287
+ getConversation,
288
+ }