openclaw-sc 5.38.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 (48) hide show
  1. package/.env.example +13 -0
  2. package/INSTALL.md +13 -0
  3. package/LICENSE +233 -0
  4. package/README.md +123 -0
  5. package/SECURITY.md +27 -0
  6. package/index.js +3479 -0
  7. package/lib/code-review-shared.js +164 -0
  8. package/lib/config.js +164 -0
  9. package/lib/constants.js +438 -0
  10. package/lib/decomposer.js +896 -0
  11. package/lib/dialog-recall.js +389 -0
  12. package/lib/env.js +59 -0
  13. package/lib/level-rules.js +72 -0
  14. package/lib/log-manager.js +258 -0
  15. package/lib/preemption.js +278 -0
  16. package/lib/priority-calc.js +134 -0
  17. package/lib/prompt-injection.js +748 -0
  18. package/lib/route-evidence.js +701 -0
  19. package/lib/shared-fs.js +244 -0
  20. package/lib/steward-rules.js +648 -0
  21. package/lib/task-center.js +602 -0
  22. package/lib/task-chain.js +713 -0
  23. package/lib/task-checker.js +317 -0
  24. package/lib/task-profiles.js +255 -0
  25. package/lib/tcell.js +411 -0
  26. package/lib/tool-auto-discover.js +522 -0
  27. package/lib/tool-enrich-subagent.js +375 -0
  28. package/lib/tool-handlers.js +178 -0
  29. package/lib/tool-selector.js +459 -0
  30. package/openclaw.plugin.json +55 -0
  31. package/package.json +63 -0
  32. package/security.js +141 -0
  33. package/tools/bridge.js +3288 -0
  34. package/tools/dashboard/tk-dashboard.py +262 -0
  35. package/tools/hippocampus-multi-search.js +1038 -0
  36. package/tools/hippocampus-sqlite.js +738 -0
  37. package/tools/hippocampus-store.js +262 -0
  38. package/tools/mcp-tools.config.json +653 -0
  39. package/tools/pipeline-engine.js +667 -0
  40. package/tools/sidecar/_check_tools.py +34 -0
  41. package/tools/sidecar/sidecar-server.cjs +1360 -0
  42. package/tools/sidecar/subagent-runner.cjs +1471 -0
  43. package/tools/system-tools.js +619 -0
  44. package/vector/README.md +100 -0
  45. package/vector/usearch-bridge.js +639 -0
  46. package/vector/usearch-http.js +74 -0
  47. package/vector/usearch-serve.js +156 -0
  48. package/workers/worker.js +1419 -0
@@ -0,0 +1,1360 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * 🦞 sc MCP Sidecar
4
+ *
5
+ * 独立Node.js HTTP服务,作为Windows服务运行(通过NSSM注册)。
6
+ * 不依赖Gateway,Gateway重启后任务继续执行。
7
+ *
8
+ * 端口:18792(已确认可用,非18791/Gateway MCP端口)
9
+ *
10
+ * API端点:
11
+ * POST /execute_task — 提交长任务
12
+ * POST /spawn_subagent — 提交子Agent任务
13
+ * GET /task/:id — 查任务状态
14
+ * GET /tasks — 列出所有任务
15
+ * POST /task/:id/cancel — 取消任务
16
+ * POST /task/:id/retry — retry任务
17
+ * GET /health — 健康检查
18
+ * POST /shutdown — 优雅关闭(需要X-Shutdown-Token头)
19
+ */
20
+
21
+ const http = require('http');
22
+ const { homedir } = require('os');
23
+ const fs = require('fs');
24
+ const path = require('path');
25
+ const { spawn } = require('child_process');
26
+ const { worker } = require('cluster');
27
+
28
+ // 强制UTF-8输出编码,防止Windows/NSSM GBK乱码
29
+ if (process.stdout) process.stdout.setDefaultEncoding('utf8');
30
+
31
+ // ── 配置 ───────────────────────────────────────────────────────────────
32
+ const PORT = 18792;
33
+ const SCRIPT_DIR = path.dirname(process.argv[1]);
34
+ const TASKS_DIR = path.join(SCRIPT_DIR, 'tasks');
35
+ const INBOX_DIR = path.join(SCRIPT_DIR, 'inbox');
36
+ const LOG_FILE = path.join(SCRIPT_DIR, '..', '..', 'logs', 'sidecar.log');
37
+ const SHUTDOWN_TOKEN = process.env.SIDECAR_SHUTDOWN_TOKEN || 'sansan-sidecar-shutdown-2026';
38
+ const ACS_LLM_BASE_URL = process.env.LLM_BASE_URL || 'http://127.0.0.1:18801/v1/chat/completions';
39
+ const COMPLETION_EVENT_TYPE = 'sc.completion';
40
+ const LOW_INFO_COMPLETION_SOURCES = new Set([
41
+ 'sidecar-child-close',
42
+ 'sidecar-done-poll',
43
+ 'sidecar-orphan-scan',
44
+ ]);
45
+ const BOUNDED_COMPLETION_FIELDS = [
46
+ 'artifactPath',
47
+ 'outputPath',
48
+ 'diaryPath',
49
+ 'taskPath',
50
+ 'budgetUsed',
51
+ 'budgetExceeded',
52
+ 'rawOutputPolicy',
53
+ 'rawReportPath',
54
+ 'evidence_paths_read',
55
+ 'evidence_paths_not_read',
56
+ 'not_inspected',
57
+ 'tool_usage_summary',
58
+ 'sensitive_scan_result',
59
+ 'exitCode',
60
+ 'error',
61
+ ];
62
+ const INBOX_RETENTION_MS = 14 * 24 * 60 * 60 * 1000;
63
+ const SIDECAR_STARTED_AT_MS = Date.now();
64
+
65
+ // 子Agent启动队列 + 硬并发闸门。
66
+ // 目标是允许 100 路总量进入队列,但不允许 100 个 runner 同时压垮 ACS/LLM 入口。
67
+ const SPAWN_INTERVAL_MS = positiveInt(process.env.SC_SUBAGENT_SPAWN_INTERVAL_MS, 50);
68
+ const SUBAGENT_MAX_ACTIVE = positiveInt(process.env.SC_SUBAGENT_MAX_ACTIVE, 24);
69
+ const ACS_GATE_TIMEOUT_MS = positiveInt(process.env.SC_ACS_GATE_TIMEOUT_MS, 1500);
70
+ const ACS_GATE_RETRY_MS = positiveInt(process.env.SC_ACS_GATE_RETRY_MS, 1000);
71
+ const ACS_HEALTH_URL = acsHealthUrl();
72
+ const spawnQueue = [];
73
+ let spawnQueueTimer = null;
74
+ let lastSpawnGateLogAt = 0;
75
+
76
+ function positiveInt(value, fallback) {
77
+ const n = Number(value);
78
+ return Number.isFinite(n) && n > 0 ? Math.floor(n) : fallback;
79
+ }
80
+
81
+ function acsHealthUrl() {
82
+ try {
83
+ const url = new URL(ACS_LLM_BASE_URL);
84
+ url.pathname = '/health';
85
+ url.search = '';
86
+ url.hash = '';
87
+ return url.toString();
88
+ } catch (_) {
89
+ return 'http://127.0.0.1:18801/health';
90
+ }
91
+ }
92
+
93
+ function activeSubagentCount() {
94
+ let count = 0;
95
+ for (const entry of runningTasks.values()) {
96
+ if (entry && entry.kind === 'subagent') count++;
97
+ }
98
+ return count;
99
+ }
100
+
101
+ function logSpawnGate(level, msg, data) {
102
+ const now = Date.now();
103
+ if (now - lastSpawnGateLogAt < 10_000) return;
104
+ lastSpawnGateLogAt = now;
105
+ log(level, msg, data);
106
+ }
107
+
108
+ async function isAcsHealthy(timeoutMs = ACS_GATE_TIMEOUT_MS) {
109
+ try {
110
+ const res = await fetch(ACS_HEALTH_URL, { signal: AbortSignal.timeout(timeoutMs) });
111
+ return res.ok;
112
+ } catch (_) {
113
+ return false;
114
+ }
115
+ }
116
+
117
+ function scheduleSpawnQueue(delayMs) {
118
+ if (spawnQueueTimer !== null || spawnQueue.length === 0) return;
119
+ spawnQueueTimer = setTimeout(() => {
120
+ spawnQueueTimer = null;
121
+ void processSpawnQueue();
122
+ }, Math.max(0, delayMs));
123
+ }
124
+
125
+ async function processSpawnQueue() {
126
+ if (spawnQueue.length === 0) return;
127
+
128
+ const active = activeSubagentCount();
129
+ if (active >= SUBAGENT_MAX_ACTIVE) {
130
+ logSpawnGate('INFO', 'Subagent spawn queue waiting for active slot', {
131
+ queued: spawnQueue.length,
132
+ active,
133
+ maxActive: SUBAGENT_MAX_ACTIVE,
134
+ });
135
+ scheduleSpawnQueue(ACS_GATE_RETRY_MS);
136
+ return;
137
+ }
138
+
139
+ const acsOk = await isAcsHealthy();
140
+ if (!acsOk) {
141
+ logSpawnGate('WARN', 'Subagent spawn queue waiting for ACS health', {
142
+ queued: spawnQueue.length,
143
+ active,
144
+ maxActive: SUBAGENT_MAX_ACTIVE,
145
+ acsHealthUrl: ACS_HEALTH_URL,
146
+ });
147
+ scheduleSpawnQueue(ACS_GATE_RETRY_MS);
148
+ return;
149
+ }
150
+
151
+ const entry = spawnQueue.shift();
152
+ if (entry) entry();
153
+ if (spawnQueue.length > 0) {
154
+ scheduleSpawnQueue(SPAWN_INTERVAL_MS);
155
+ }
156
+ }
157
+
158
+ function enqueueSpawn(fn) {
159
+ spawnQueue.push(fn);
160
+ scheduleSpawnQueue(0);
161
+ }
162
+
163
+ // 任务默认超时:24小时
164
+ const DEFAULT_TIMEOUT_MS = 24 * 60 * 60 * 1000;
165
+
166
+ // ── 日志 ───────────────────────────────────────────────────────────────
167
+ function log(level, msg, data) {
168
+ const ts = new Date().toISOString();
169
+ const line = `[${ts}] [${level}] ${msg}${data ? ' ' + JSON.stringify(data) : ''}`;
170
+ console.log(line);
171
+ try {
172
+ fs.appendFileSync(LOG_FILE, line + '\n');
173
+ } catch (_) { /* 日志文件写失败不阻塞 */ }
174
+ }
175
+
176
+ function cleanLabel(value, max = 80) {
177
+ if (typeof value !== 'string') return '';
178
+ return value.replace(/[\r\n\t]+/g, ' ').trim().slice(0, max);
179
+ }
180
+
181
+ // ── 任务状态机 ────────────────────────────────────────────────────────
182
+ // 状态: pending → running → success|failed|cancelled|timeout
183
+
184
+ function taskFilePath(id) {
185
+ return path.join(TASKS_DIR, `${id}.json`);
186
+ }
187
+
188
+ function generateId() {
189
+ return Date.now().toString(36) + '-' + Math.random().toString(36).slice(2, 8);
190
+ }
191
+
192
+ function ensureDir(dirPath) {
193
+ if (!fs.existsSync(dirPath)) fs.mkdirSync(dirPath, { recursive: true });
194
+ }
195
+
196
+ function safeId(value, fallback = 'event') {
197
+ const text = String(value || '').replace(/[^a-zA-Z0-9_.-]+/g, '-').replace(/^-+|-+$/g, '');
198
+ return (text || fallback).slice(0, 160);
199
+ }
200
+
201
+ function clipText(value, max = 1200) {
202
+ if (value === undefined || value === null) return '';
203
+ const text = typeof value === 'string' ? value : JSON.stringify(value);
204
+ return text.replace(/[\r\n\t]+/g, ' ').trim().slice(0, max);
205
+ }
206
+
207
+ function summarizeOutputFile(outputPath) {
208
+ if (!outputPath) return '';
209
+ try {
210
+ const resolved = path.resolve(outputPath);
211
+ if (!fs.existsSync(resolved)) return '';
212
+ const st = fs.statSync(resolved);
213
+ if (st.size > 512000) return `result file: ${resolved} (${st.size} bytes)`;
214
+ const raw = fs.readFileSync(resolved, 'utf8');
215
+ const parsed = JSON.parse(raw);
216
+ if (parsed.error) return clipText(parsed.error, 1200);
217
+ if (parsed.summary) return clipText(parsed.summary, 1200);
218
+ if (parsed.data) return clipText(parsed.data, 1200);
219
+ return clipText(parsed, 1200);
220
+ } catch {
221
+ return '';
222
+ }
223
+ }
224
+
225
+ function completionEventId(taskId, status) {
226
+ return `sce-${safeId(taskId, 'task')}-${safeId(status || 'done')}`;
227
+ }
228
+
229
+ function inboxEventPath(eventId) {
230
+ return path.join(INBOX_DIR, `${safeId(eventId)}.json`);
231
+ }
232
+
233
+ function readInboxEvent(eventIdOrPath) {
234
+ try {
235
+ const fp = eventIdOrPath.endsWith && eventIdOrPath.endsWith('.json')
236
+ ? eventIdOrPath
237
+ : inboxEventPath(eventIdOrPath);
238
+ return JSON.parse(fs.readFileSync(fp, 'utf8'));
239
+ } catch {
240
+ return null;
241
+ }
242
+ }
243
+
244
+ function readJsonFile(filePath) {
245
+ try {
246
+ if (!filePath) return null;
247
+ const resolved = path.resolve(filePath);
248
+ if (!fs.existsSync(resolved)) return null;
249
+ return JSON.parse(fs.readFileSync(resolved, 'utf8'));
250
+ } catch {
251
+ return null;
252
+ }
253
+ }
254
+
255
+ function hasSuccessCompletionEvent(taskId) {
256
+ const existing = readInboxEvent(completionEventId(taskId, 'success'));
257
+ return existing?.status === 'success';
258
+ }
259
+
260
+ function hasSuccessOutputFile(outputPath) {
261
+ const parsed = readJsonFile(outputPath);
262
+ return parsed?.status === 'success';
263
+ }
264
+
265
+ function hasRecordedSuccessForTask(taskId, outputPath) {
266
+ return hasSuccessCompletionEvent(taskId) || hasSuccessOutputFile(outputPath);
267
+ }
268
+
269
+ function hasMeaningfulInboxValue(value) {
270
+ if (value === undefined || value === null || value === '') return false;
271
+ if (Array.isArray(value)) return value.length > 0;
272
+ if (typeof value === 'object') return Object.keys(value).length > 0;
273
+ return true;
274
+ }
275
+
276
+ function shouldPreserveInboxField(existingValue, incomingValue, source) {
277
+ if (!hasMeaningfulInboxValue(existingValue)) return false;
278
+ if (LOW_INFO_COMPLETION_SOURCES.has(source)) return true;
279
+ return !hasMeaningfulInboxValue(incomingValue);
280
+ }
281
+
282
+ function saveInboxEvent(event) {
283
+ ensureDir(INBOX_DIR);
284
+ const existing = readInboxEvent(event.id);
285
+ const now = new Date().toISOString();
286
+ const source = event.source || 'sidecar';
287
+ const sources = new Set([...(existing?.sources || []), source]);
288
+ const existingSummary = existing?.summary || '';
289
+ const incomingSummary = event.summary || '';
290
+ const shouldPreserveSummary = existingSummary && (
291
+ !incomingSummary ||
292
+ LOW_INFO_COMPLETION_SOURCES.has(source)
293
+ );
294
+ const next = {
295
+ ...(existing || {}),
296
+ ...event,
297
+ type: COMPLETION_EVENT_TYPE,
298
+ summary: shouldPreserveSummary ? existingSummary : (incomingSummary || existingSummary),
299
+ lifecycle: event.lifecycle || existing?.lifecycle || 'pending',
300
+ receivedAt: existing?.receivedAt || event.receivedAt || now,
301
+ updatedAt: now,
302
+ deliveredAt: event.deliveredAt || existing?.deliveredAt || null,
303
+ ackedAt: event.ackedAt || existing?.ackedAt || null,
304
+ sources: [...sources],
305
+ };
306
+ for (const field of BOUNDED_COMPLETION_FIELDS) {
307
+ const incoming = field === 'rawOutputPolicy'
308
+ ? (event.rawOutputPolicy || event.raw_output_policy)
309
+ : event[field];
310
+ if (shouldPreserveInboxField(existing?.[field], incoming, source)) {
311
+ next[field] = existing[field];
312
+ }
313
+ }
314
+ if (
315
+ existing?.sensitive_scan_result &&
316
+ existing.sensitive_scan_result !== 'not_run' &&
317
+ next.sensitive_scan_result === 'not_run'
318
+ ) {
319
+ next.sensitive_scan_result = existing.sensitive_scan_result;
320
+ }
321
+ const fp = inboxEventPath(next.id);
322
+ const tmp = `${fp}.${process.pid}.${Date.now()}.tmp`;
323
+ fs.writeFileSync(tmp, JSON.stringify(next, null, 2));
324
+ fs.renameSync(tmp, fp);
325
+ return next;
326
+ }
327
+
328
+ function compactInboxEvent(event) {
329
+ return {
330
+ id: event.id,
331
+ type: event.type,
332
+ taskId: event.taskId,
333
+ runId: event.runId || '',
334
+ batchName: event.batchName || '',
335
+ groupName: event.groupName || '',
336
+ taskName: event.taskName || '',
337
+ status: event.status,
338
+ summary: event.summary || '',
339
+ artifactPath: event.artifactPath || event.outputPath || event.diaryPath || '',
340
+ outputPath: event.outputPath || '',
341
+ diaryPath: event.diaryPath || '',
342
+ taskPath: event.taskPath || '',
343
+ budgetUsed: event.budgetUsed || null,
344
+ budgetExceeded: event.budgetExceeded === true,
345
+ rawOutputPolicy: event.rawOutputPolicy || event.raw_output_policy || 'no_full_dump',
346
+ rawReportPath: event.rawReportPath || '',
347
+ evidence_paths_read: event.evidence_paths_read || [],
348
+ evidence_paths_not_read: event.evidence_paths_not_read || [],
349
+ not_inspected: event.not_inspected || [],
350
+ tool_usage_summary: event.tool_usage_summary || null,
351
+ sensitive_scan_result: event.sensitive_scan_result || 'not_run',
352
+ createdAt: event.createdAt || '',
353
+ completedAt: event.completedAt || '',
354
+ receivedAt: event.receivedAt || '',
355
+ deliveredAt: event.deliveredAt || null,
356
+ ackedAt: event.ackedAt || null,
357
+ lifecycle: event.lifecycle || 'pending',
358
+ sources: event.sources || [],
359
+ };
360
+ }
361
+
362
+ function buildCompletionEvent(input) {
363
+ const taskId = safeId(input.taskId || input.id || generateId(), 'task');
364
+ const status = input.status || 'completed';
365
+ const task = input.task || loadTask(taskId) || {};
366
+ const now = new Date().toISOString();
367
+ const source = input.source || 'sidecar';
368
+ const outputPath = input.outputPath || task.outputPath || '';
369
+ const diaryPath = input.diaryPath || task.diaryPath || '';
370
+ const budgetUsed = input.budgetUsed || task.budgetUsed || null;
371
+ const rawOutputPolicy = input.rawOutputPolicy || input.raw_output_policy || task.rawOutputPolicy || task.raw_output_policy || 'no_full_dump';
372
+ const artifactSummary = summarizeOutputFile(outputPath);
373
+ const lowInfoSource = LOW_INFO_COMPLETION_SOURCES.has(source);
374
+ const visibleSummary = lowInfoSource
375
+ ? (artifactSummary || input.summary || input.error || task.error)
376
+ : (input.summary || artifactSummary || input.error || task.error);
377
+ const visibleError = lowInfoSource && artifactSummary
378
+ ? artifactSummary
379
+ : (input.error || task.error || artifactSummary);
380
+ const summary = clipText(
381
+ visibleSummary ||
382
+ `Subagent ${taskId} completed with status=${status}`,
383
+ 1600
384
+ );
385
+
386
+ return {
387
+ id: input.eventId || completionEventId(taskId, status),
388
+ type: COMPLETION_EVENT_TYPE,
389
+ taskId,
390
+ runId: input.runId || task.runId || '',
391
+ batchName: cleanLabel(input.batchName || task.batchName || ''),
392
+ groupName: cleanLabel(input.groupName || task.groupName || ''),
393
+ taskName: cleanLabel(input.taskName || task.taskName || taskId),
394
+ status,
395
+ summary,
396
+ artifactPath: input.artifactPath || outputPath || diaryPath || '',
397
+ outputPath,
398
+ diaryPath,
399
+ taskPath: taskFilePath(taskId),
400
+ budgetUsed,
401
+ budgetExceeded: input.budgetExceeded === true || task.budgetExceeded === true,
402
+ rawOutputPolicy,
403
+ rawReportPath: input.rawReportPath || task.rawReportPath || '',
404
+ evidence_paths_read: Array.isArray(input.evidence_paths_read) ? input.evidence_paths_read : (Array.isArray(task.evidence_paths_read) ? task.evidence_paths_read : []),
405
+ evidence_paths_not_read: Array.isArray(input.evidence_paths_not_read) ? input.evidence_paths_not_read : (Array.isArray(task.evidence_paths_not_read) ? task.evidence_paths_not_read : []),
406
+ not_inspected: Array.isArray(input.not_inspected) ? input.not_inspected : (Array.isArray(task.not_inspected) ? task.not_inspected : []),
407
+ tool_usage_summary: input.tool_usage_summary || task.tool_usage_summary || null,
408
+ sensitive_scan_result: input.sensitive_scan_result || task.sensitive_scan_result || 'not_run',
409
+ createdAt: input.createdAt || task.createdAt || now,
410
+ completedAt: input.completedAt || task.completedAt || now,
411
+ receivedAt: now,
412
+ lifecycle: 'pending',
413
+ source,
414
+ exitCode: input.exitCode ?? task.exitCode,
415
+ error: clipText(visibleError || '', 800),
416
+ };
417
+ }
418
+
419
+ function recordCompletionEvent(input) {
420
+ try {
421
+ const event = saveInboxEvent(buildCompletionEvent(input));
422
+ log('INFO', `SC inbox completion recorded: task=${event.taskId} status=${event.status} event=${event.id}`);
423
+ return event;
424
+ } catch (e) {
425
+ log('WARN', `SC inbox completion record failed: ${e.message}`);
426
+ return null;
427
+ }
428
+ }
429
+
430
+ function listInboxEvents(options = {}) {
431
+ const limit = Math.min(Math.max(Number(options.limit) || 20, 1), 200);
432
+ const includeAcked = options.includeAcked === true;
433
+ const undeliveredOnly = options.undeliveredOnly === true;
434
+ ensureDir(INBOX_DIR);
435
+ return fs.readdirSync(INBOX_DIR)
436
+ .filter(f => f.endsWith('.json') && !f.endsWith('.tmp.json'))
437
+ .map(f => readInboxEvent(path.join(INBOX_DIR, f)))
438
+ .filter(Boolean)
439
+ .filter(e => e.type === COMPLETION_EVENT_TYPE)
440
+ .filter(e => includeAcked || !e.ackedAt)
441
+ .filter(e => !undeliveredOnly || !e.deliveredAt)
442
+ .sort((a, b) => (b.receivedAt || '').localeCompare(a.receivedAt || ''))
443
+ .slice(0, limit)
444
+ .map(compactInboxEvent);
445
+ }
446
+
447
+ function markInboxDelivered(eventIds, deliveredBy = 'sc-bridge') {
448
+ const ids = Array.isArray(eventIds) ? eventIds : [];
449
+ const delivered = [];
450
+ for (const id of ids) {
451
+ const existing = readInboxEvent(id);
452
+ if (!existing || existing.ackedAt) continue;
453
+ const next = saveInboxEvent({
454
+ ...existing,
455
+ deliveredAt: existing.deliveredAt || new Date().toISOString(),
456
+ deliveredBy,
457
+ deliveryAttempts: Number(existing.deliveryAttempts || 0) + 1,
458
+ source: deliveredBy,
459
+ });
460
+ delivered.push(compactInboxEvent(next));
461
+ }
462
+ return delivered;
463
+ }
464
+
465
+ function ackInboxEvents(options = {}) {
466
+ const now = new Date().toISOString();
467
+ const eventIds = new Set((options.eventIds || []).map(String));
468
+ const taskIds = new Set((options.taskIds || []).map(v => safeId(v, 'task')));
469
+ const ackAll = options.all === true;
470
+ const acked = [];
471
+ ensureDir(INBOX_DIR);
472
+ for (const f of fs.readdirSync(INBOX_DIR)) {
473
+ if (!f.endsWith('.json') || f.endsWith('.tmp.json')) continue;
474
+ const event = readInboxEvent(path.join(INBOX_DIR, f));
475
+ if (!event || event.type !== COMPLETION_EVENT_TYPE || event.ackedAt) continue;
476
+ if (!ackAll && !eventIds.has(event.id) && !taskIds.has(event.taskId)) continue;
477
+ const next = saveInboxEvent({
478
+ ...event,
479
+ lifecycle: 'acked',
480
+ ackedAt: now,
481
+ ackedBy: options.ackedBy || 'sc-inbox-consumer',
482
+ source: options.ackedBy || 'sc-inbox-consumer',
483
+ });
484
+ acked.push(compactInboxEvent(next));
485
+ }
486
+ return acked;
487
+ }
488
+
489
+ function inboxStats() {
490
+ ensureDir(INBOX_DIR);
491
+ const events = fs.readdirSync(INBOX_DIR)
492
+ .filter(f => f.endsWith('.json') && !f.endsWith('.tmp.json'))
493
+ .map(f => readInboxEvent(path.join(INBOX_DIR, f)))
494
+ .filter(Boolean)
495
+ .filter(e => e.type === COMPLETION_EVENT_TYPE);
496
+ return {
497
+ total: events.length,
498
+ pending: events.filter(e => !e.ackedAt).length,
499
+ undelivered: events.filter(e => !e.ackedAt && !e.deliveredAt).length,
500
+ acked: events.filter(e => e.ackedAt).length,
501
+ dir: INBOX_DIR,
502
+ };
503
+ }
504
+
505
+ function loadTask(id) {
506
+ try {
507
+ const raw = fs.readFileSync(taskFilePath(id), 'utf8');
508
+ return JSON.parse(raw);
509
+ } catch { return null; }
510
+ }
511
+
512
+ function saveTask(task) {
513
+ const tmp = taskFilePath(task.id) + '.tmp';
514
+ fs.writeFileSync(tmp, JSON.stringify(task, null, 2));
515
+ fs.renameSync(tmp, taskFilePath(task.id));
516
+ }
517
+
518
+ function listTasks() {
519
+ try {
520
+ return fs.readdirSync(TASKS_DIR)
521
+ .filter(f => f.endsWith('.json') && !f.endsWith('.tmp.json'))
522
+ .map(f => {
523
+ try {
524
+ const t = JSON.parse(fs.readFileSync(path.join(TASKS_DIR, f), 'utf8'));
525
+ return { id: t.id, command: t.command, status: t.status, createdAt: t.createdAt, completedAt: t.completedAt };
526
+ } catch { return null; }
527
+ })
528
+ .filter(Boolean)
529
+ .sort((a, b) => (b.createdAt || '').localeCompare(a.createdAt || ''));
530
+ } catch { return []; }
531
+ }
532
+
533
+ // ── 运行中的子进程表 ──────────────────────────────────────────────────
534
+ const runningTasks = new Map(); // id → { process, timeout }
535
+ const SUBAGENT_RUNNER = path.join(SCRIPT_DIR, 'subagent-runner.cjs');
536
+ const DEEPSEEK_API_KEY = getDeepSeekKey();
537
+ const TAVILY_API_KEY = getTavilyKey();
538
+ console.log('[Sidecar] TAVILY key loaded:', !!TAVILY_API_KEY, 'len:', TAVILY_API_KEY.length);
539
+ console.log('[Sidecar] DEEPSEEK key loaded:', !!DEEPSEEK_API_KEY, 'len:', DEEPSEEK_API_KEY.length);
540
+
541
+ // 从环境变量/mcp-tools.config.json/openclaw.json读取DeepSeek API Key
542
+ // 优先级:环境变量 > mcp-tools.config.json > openclaw.json > 空
543
+ function getDeepSeekKey() {
544
+ // 优先环境变量(Gateway注入的活跃key)
545
+ if (process.env.DEEPSEEK_API_KEY && process.env.DEEPSEEK_API_KEY.length > 10) {
546
+ return process.env.DEEPSEEK_API_KEY;
547
+ }
548
+ // 回退1: mcp-tools.config.json → apiKeys.deepseek
549
+ try {
550
+ const mcpConfigPath = path.join(SCRIPT_DIR, '..', 'mcp-tools.config.json');
551
+ if (fs.existsSync(mcpConfigPath)) {
552
+ let raw = fs.readFileSync(mcpConfigPath, 'utf8');
553
+ if (raw.charCodeAt(0) === 0xFEFF) raw = raw.slice(1);
554
+ const cfg = JSON.parse(raw);
555
+ const key = cfg?.apiKeys?.deepseek || '';
556
+ if (key && key.length > 10) return key;
557
+ }
558
+ } catch {}
559
+ // 回退2: openclaw.json → models.providers.deepseek.apiKey(兼容旧路径)
560
+ try {
561
+ const configPath = path.join(SCRIPT_DIR, '..', '..', '..', '..', '..', 'openclaw.json');
562
+ if (fs.existsSync(configPath)) {
563
+ let raw = fs.readFileSync(configPath, 'utf8');
564
+ if (raw.charCodeAt(0) === 0xFEFF) raw = raw.slice(1);
565
+ const cfg = JSON.parse(raw);
566
+ return cfg?.models?.providers?.deepseek?.apiKey || '';
567
+ }
568
+ } catch {}
569
+ return '';
570
+ }
571
+
572
+ // 从openclaw.json读取Tavily API Key
573
+ // Tavily key 存在 plugins.entries.tavily.config.webSearch.apiKey
574
+ function getTavilyKey() {
575
+ // 优先环境变量
576
+ if (process.env.TAVILY_API_KEY && process.env.TAVILY_API_KEY.length > 10) {
577
+ return process.env.TAVILY_API_KEY;
578
+ }
579
+ // 回退到文件读取(带BOM兼容)
580
+ try {
581
+ const configPath = path.join(SCRIPT_DIR, '..', '..', '..', '..', '..', 'openclaw.json');
582
+ if (fs.existsSync(configPath)) {
583
+ let raw = fs.readFileSync(configPath, 'utf8');
584
+ if (raw.charCodeAt(0) === 0xFEFF) raw = raw.slice(1); // 清BOM
585
+ const cfg = JSON.parse(raw);
586
+ return cfg?.plugins?.entries?.tavily?.config?.webSearch?.apiKey || '';
587
+ }
588
+ } catch {}
589
+ return '';
590
+ }
591
+
592
+ // ── 执行任务 ──────────────────────────────────────────────────────────
593
+ function executeTask(task) {
594
+ task.status = 'running';
595
+ task.startedAt = new Date().toISOString();
596
+ task.output = [];
597
+ saveTask(task);
598
+
599
+ log('INFO', `Starting task ${task.id}: ${task.command}`);
600
+
601
+ // 使用 spawn 而非 exec 以支持长时间运行和实时输出
602
+ const child = spawn('cmd.exe', ['/c', task.command], {
603
+ windowsHide: true,
604
+ stdio: ['ignore', 'pipe', 'pipe'],
605
+ cwd: task.cwd || undefined,
606
+ env: { ...process.env, ...(task.env || {}) }
607
+ });
608
+
609
+ const entry = { process: child };
610
+ runningTasks.set(task.id, entry);
611
+
612
+ // 输出缓冲
613
+ let stdoutBuf = '';
614
+ let stderrBuf = '';
615
+
616
+ child.stdout.on('data', (chunk) => {
617
+ const text = chunk.toString('utf8');
618
+ stdoutBuf += text;
619
+ // 限制输出大小(每最多500KB)
620
+ if (stdoutBuf.length > 512000) stdoutBuf = stdoutBuf.slice(-512000);
621
+ });
622
+
623
+ child.stderr.on('data', (chunk) => {
624
+ const text = chunk.toString('utf8');
625
+ stderrBuf += text;
626
+ if (stderrBuf.length > 512000) stderrBuf = stderrBuf.slice(-512000);
627
+ });
628
+
629
+ // 超时定时器
630
+ const timeoutMs = task.timeout || DEFAULT_TIMEOUT_MS;
631
+ const timeoutTimer = setTimeout(() => {
632
+ if (runningTasks.has(task.id)) {
633
+ log('WARN', `Task ${task.id} timed out after ${timeoutMs}ms`);
634
+ child.kill('SIGTERM');
635
+ // 2s后强杀
636
+ setTimeout(() => {
637
+ try { child.kill('SIGKILL'); } catch (_) { /* cleanup, ignore */ }
638
+ }, 2000);
639
+ }
640
+ }, timeoutMs);
641
+
642
+ entry.timeout = timeoutTimer;
643
+
644
+ child.on('close', (exitCode, signal) => {
645
+ clearTimeout(timeoutTimer);
646
+ runningTasks.delete(task.id);
647
+
648
+ const now = new Date().toISOString();
649
+
650
+ // 判断最终状态
651
+ if (task.status === 'cancelling') {
652
+ task.status = 'cancelled';
653
+ task.cancelledAt = now;
654
+ } else if (signal === 'SIGTERM') {
655
+ task.status = 'timeout';
656
+ } else if (exitCode === 0) {
657
+ task.status = 'success';
658
+ } else {
659
+ task.status = 'failed';
660
+ }
661
+
662
+ task.completedAt = now;
663
+ task.exitCode = exitCode;
664
+ task.signal = signal || null;
665
+ task.stdout = stdoutBuf.slice(-500000);
666
+ task.stderr = stderrBuf.slice(-500000);
667
+ saveTask(task);
668
+
669
+ log('INFO', `Task ${task.id} completed: status=${task.status} exitCode=${exitCode}`);
670
+ });
671
+ }
672
+
673
+ // ── 取消任务 ──────────────────────────────────────────────────────────
674
+ function cancelTask(task) {
675
+ const entry = runningTasks.get(task.id);
676
+ if (!entry) {
677
+ // 任务未在运行中,直接标记已取消
678
+ task.status = 'cancelled';
679
+ task.cancelledAt = new Date().toISOString();
680
+ saveTask(task);
681
+ return true;
682
+ }
683
+
684
+ task.status = 'cancelling';
685
+ saveTask(task);
686
+
687
+ // 发送Ctrl+C信号(SIGINT),然后SIGTERM,最后强杀
688
+ entry.process.kill('SIGINT');
689
+ setTimeout(() => {
690
+ try {
691
+ entry.process.kill('SIGTERM');
692
+ } catch (_) { /* cleanup, ignore */ }
693
+ }, 3000);
694
+ setTimeout(() => {
695
+ try {
696
+ entry.process.kill('SIGKILL');
697
+ } catch (_) { /* cleanup, ignore */ }
698
+ }, 5000);
699
+
700
+ // 🔧 BUGFIX: 从 runningTasks 中移除条目, 防止 Map 膨胀
701
+ runningTasks.delete(task.id);
702
+
703
+ return true;
704
+ }
705
+
706
+ // ── retry任务 ──────────────────────────────────────────────────────────
707
+ function retryTask(oldTask) {
708
+ const newTask = {
709
+ id: generateId(),
710
+ command: oldTask.command,
711
+ cwd: oldTask.cwd,
712
+ env: oldTask.env,
713
+ timeout: oldTask.timeout,
714
+ status: 'pending',
715
+ retryOf: oldTask.id,
716
+ createdAt: new Date().toISOString()
717
+ };
718
+ saveTask(newTask);
719
+ // 延迟执行,让响应先返回
720
+ setImmediate(() => executeTask(newTask));
721
+ return newTask;
722
+ }
723
+
724
+ // ── 清理旧状态文件 ───────────────────────────────────────────────────
725
+ function cleanOldFiles() {
726
+ const cutoff = Date.now() - 7 * 24 * 60 * 60 * 1000; // 7天前的任务
727
+ const inboxCutoff = Date.now() - INBOX_RETENTION_MS;
728
+ let cleaned = 0;
729
+ try {
730
+ for (const f of fs.readdirSync(TASKS_DIR)) {
731
+ if (!f.endsWith('.json') || f.endsWith('.tmp.json')) continue;
732
+ const fp = path.join(TASKS_DIR, f);
733
+ try {
734
+ if (fs.statSync(fp).mtimeMs < cutoff) {
735
+ fs.unlinkSync(fp);
736
+ cleaned++;
737
+ }
738
+ } catch (_) { /* cleanup, ignore */ }
739
+ }
740
+ } catch (_) { /* cleanup, ignore */ }
741
+ try {
742
+ ensureDir(INBOX_DIR);
743
+ for (const f of fs.readdirSync(INBOX_DIR)) {
744
+ if (!f.endsWith('.json') || f.endsWith('.tmp.json')) continue;
745
+ const fp = path.join(INBOX_DIR, f);
746
+ try {
747
+ const event = readInboxEvent(fp);
748
+ if (event?.ackedAt && fs.statSync(fp).mtimeMs < inboxCutoff) {
749
+ fs.unlinkSync(fp);
750
+ cleaned++;
751
+ }
752
+ } catch (_) { /* cleanup, ignore */ }
753
+ }
754
+ } catch (_) { /* cleanup, ignore */ }
755
+ if (cleaned > 0) log('INFO', `Cleaned ${cleaned} old task files`);
756
+ }
757
+
758
+ // ── HTTP 请求体解析 ──────────────────────────────────────────────────
759
+ function parseBody(req) {
760
+ return new Promise((resolve, reject) => {
761
+ let body = '';
762
+ req.on('data', chunk => body += chunk.toString('utf8'));
763
+ req.on('end', () => {
764
+ try { resolve(body ? JSON.parse(body) : {}); }
765
+ catch { reject(new Error('Invalid JSON')); }
766
+ });
767
+ req.on('error', reject);
768
+ });
769
+ }
770
+
771
+ // ── JSON 响应辅助 ────────────────────────────────────────────────────
772
+ function json(res, status, data) {
773
+ res.writeHead(status, {
774
+ 'Content-Type': 'application/json',
775
+ 'Access-Control-Allow-Origin': '*',
776
+ 'Access-Control-Allow-Methods': 'GET, POST, OPTIONS',
777
+ 'Access-Control-Allow-Headers': 'Content-Type, X-Shutdown-Token'
778
+ });
779
+ res.end(JSON.stringify(data));
780
+ }
781
+
782
+ // ── 路由 ──────────────────────────────────────────────────────────────
783
+ async function handleRequest(req, res) {
784
+ const url = new URL(req.url, `http://${req.headers.host || 'localhost'}`);
785
+ const method = req.method;
786
+ const pathname = url.pathname;
787
+
788
+ // CORS preflight
789
+ if (method === 'OPTIONS') {
790
+ json(res, 204, {});
791
+ return;
792
+ }
793
+
794
+ try {
795
+ // POST /execute_task
796
+ if (method === 'POST' && pathname === '/execute_task') {
797
+ const body = await parseBody(req);
798
+ if (!body.command || typeof body.command !== 'string') {
799
+ json(res, 400, { error: 'Missing or invalid "command" field' });
800
+ return;
801
+ }
802
+ if (body.command.length > 32000) {
803
+ json(res, 400, { error: 'Command too long (max 32000 chars)' });
804
+ return;
805
+ }
806
+
807
+ const task = {
808
+ id: generateId(),
809
+ command: body.command,
810
+ cwd: body.cwd || null,
811
+ env: body.env || null,
812
+ timeout: Number(body.timeout) || DEFAULT_TIMEOUT_MS,
813
+ status: 'pending',
814
+ createdAt: new Date().toISOString()
815
+ };
816
+ saveTask(task);
817
+
818
+ // 异步执行
819
+ setImmediate(() => executeTask(task));
820
+
821
+ log('INFO', `Task created: ${task.id}`);
822
+ json(res, 201, { id: task.id, command: task.command, status: task.status });
823
+ return;
824
+ }
825
+
826
+ // POST /spawn_subagent — 提交子Agent任务
827
+ if (method === 'POST' && pathname === '/spawn_subagent') {
828
+ const body = await parseBody(req);
829
+ // 🔒 安全校验:禁止外部覆盖系统API Key
830
+ if (body._apiKey) {
831
+ json(res, 403, { error: 'Parameter _apiKey is reserved and cannot be set externally' });
832
+ return;
833
+ }
834
+ if (!body.prompt || typeof body.prompt !== 'string') {
835
+ json(res, 400, { error: 'Missing or invalid "prompt" field' });
836
+ return;
837
+ }
838
+
839
+ // 🔒 安全校验:taskId 可选,支持复用(传已有 taskId 则继承上下文)
840
+ const taskId = body.taskId || ('sa-' + Date.now().toString(36) + '-' + Math.random().toString(36).slice(2, 6));
841
+ const depth = Number(body.depth) || 0;
842
+ const maxDepth = Number(body.maxDepth) || 0;
843
+ const maxChildren = Number(body.maxChildren) || 2;
844
+ const model = body.model || 'deepseek/deepseek-v4-flash';
845
+ const timeout = Number(body.timeout) || 300;
846
+ const maxRounds = Number(body.maxRounds) || 20;
847
+ const batchName = cleanLabel(body.batchName || '');
848
+ const groupName = cleanLabel(body.groupName || body.name || '');
849
+ const taskName = cleanLabel(body.taskName || groupName || batchName || taskId);
850
+ const runId = cleanLabel(body.runId || batchName || '');
851
+ const runDir = typeof body.runDir === 'string' ? body.runDir : '';
852
+ const budgets = body.budgets && typeof body.budgets === 'object' ? body.budgets : {};
853
+ const taskCard = body.taskCard && typeof body.taskCard === 'object' ? body.taskCard : null;
854
+ const toolPolicy = body.toolPolicy || body.tool_policy || taskCard?.toolPolicy || taskCard?.tool_policy || null;
855
+ const rawOutputPolicy = body.raw_output_policy || body.rawOutputPolicy || budgets.raw_output_policy || 'no_full_dump';
856
+ const codeMode = body.codeMode === true || /修|改代码|edit_code|修复|fix|改bug/.test(body.prompt || '');
857
+ // 杉哥2026-06-09: 自动识别修代码任务→timeout=600/manualOverride通过body.timeout传入
858
+ // 🔧 BUGFIX: codeMode检测到时显式timeout不应覆盖翻倍保护,取max保证至少600s
859
+ const effectiveTimeout = codeMode ? Math.max(timeout, 600) : timeout;
860
+ const tools = body.tools || [];
861
+ const outputPath = body.outputPath || path.join(SCRIPT_DIR, '..', '..', '..', '..', 'memory', 'task-states', `${taskId}.json`);
862
+ const diaryPath = body.diaryPath || path.join(SCRIPT_DIR, '..', '..', '..', '..', 'memory', 'dialog', 'subagent', `${taskId}.md`);
863
+
864
+ // 创建任务记录
865
+ const task = {
866
+ id: taskId,
867
+ type: 'subagent',
868
+ prompt: body.prompt.substring(0, 500),
869
+ taskName, batchName, groupName,
870
+ runId, runDir,
871
+ rawOutputPolicy,
872
+ taskCard,
873
+ toolPolicy,
874
+ budgets,
875
+ collector: body.collector || null,
876
+ acceptance: body.acceptance || null,
877
+ evidence: body.evidence || null,
878
+ notifyPolicy: body.notifyPolicy || 'notify-only',
879
+ guardMode: body.guardMode || '',
880
+ guardWarnings: body.guardWarnings || [],
881
+ depth, maxDepth, maxChildren,
882
+ model, timeout,
883
+ status: 'pending',
884
+ outputPath,
885
+ createdAt: new Date().toISOString()
886
+ };
887
+ saveTask(task);
888
+
889
+ // 提前准备key(队列执行时再用)
890
+ const liveDeepSeekKey = getDeepSeekKey();
891
+ const liveTavilyKey = getTavilyKey();
892
+ // 🔒 安全修复:只从环境变量读取API Key,禁止调用者通过body._apiKey注入
893
+ const useApiKey = liveDeepSeekKey || '';
894
+
895
+
896
+ // 读取x0x配置(子Agent实时通信用,daemon不在就静默跳过)
897
+ let x0xConfig = {};
898
+ try {
899
+ // 从USERPROFILE动态获取用户目录,兼容SYSTEM/NSSM账号
900
+ const x0xName = process.env.X0X_NAME || 'sssan';
901
+ const userDataRoot = process.env.USERPROFILE || homedir();
902
+ const x0xDir = process.env.X0X_DATA_DIR || path.join(userDataRoot, 'AppData', 'Roaming', 'x0x-' + x0xName);
903
+ if (fs.existsSync(path.join(x0xDir, 'api.port'))) {
904
+ const apiAddr = fs.readFileSync(path.join(x0xDir, 'api.port'), 'utf8').trim();
905
+ const x0xToken = fs.readFileSync(path.join(x0xDir, 'api-token'), 'utf8').trim();
906
+ x0xConfig = { x0xApiUrl: `http://${apiAddr}`, x0xToken, x0xTopic: taskId };
907
+ }
908
+ if (x0xConfig.x0xApiUrl) log('INFO', `x0x桥接ready: ${x0xConfig.x0xApiUrl} topic=${x0xConfig.x0xTopic}`);
909
+ } catch (e) { log('WARN', `x0x桥接不可用: ${e.message}`); }
910
+ const params = {
911
+ taskId, prompt: body.prompt,
912
+ tools, outputPath, diaryPath, flagDir: path.resolve(__dirname, '..', '..', '..', '..', 'memory', 'dialog', 'subagent'),
913
+ taskName, batchName, groupName,
914
+ runId, runDir,
915
+ rawOutputPolicy,
916
+ taskCard,
917
+ toolPolicy,
918
+ budgets,
919
+ collector: body.collector || null,
920
+ acceptance: body.acceptance || null,
921
+ evidence: body.evidence || null,
922
+ notifyPolicy: body.notifyPolicy || 'notify-only',
923
+ guardMode: body.guardMode || '',
924
+ guardWarnings: body.guardWarnings || [],
925
+ depth, maxDepth, maxChildren,
926
+ apiKey: useApiKey,
927
+ model, timeout: effectiveTimeout, maxRounds: codeMode ? Math.max(maxRounds, 100) : maxRounds, codeMode,
928
+ ...x0xConfig
929
+ };
930
+
931
+ // 加入启动队列,每隔50ms启动一个,防SSE连接风暴
932
+ enqueueSpawn(() => {
933
+ const child = spawn(process.execPath, [SUBAGENT_RUNNER], {
934
+ windowsHide: true,
935
+ stdio: ['pipe', 'pipe', 'pipe'],
936
+ env: { ...process.env, LLM_BASE_URL: ACS_LLM_BASE_URL, DEEPSEEK_API_KEY: useApiKey, TAVILY_API_KEY: liveTavilyKey, X0X_API_URL: x0xConfig.x0xApiUrl || '', X0X_TOKEN: x0xConfig.x0xToken || '', X0X_TOPIC: x0xConfig.x0xTopic || '' }
937
+ });
938
+ // 通过stdin传参(绕过Windows命令行8191字符限制)
939
+ child.stdin.write(JSON.stringify(params));
940
+ child.stdin.end();
941
+
942
+ let stdout = '';
943
+ let stderr = '';
944
+ child.stdout.on('data', d => stdout += d.toString());
945
+ child.stderr.on('data', d => stderr += d.toString());
946
+
947
+ const timer = setTimeout(() => {
948
+ try { child.kill('SIGTERM'); } catch {}
949
+ task.status = 'timeout';
950
+ task.completedAt = new Date().toISOString();
951
+ task.stderr = stderr.slice(-10000);
952
+ saveTask(task);
953
+ }, (timeout + 30) * 1000);
954
+
955
+ // 🔧 BUGFIX: 子Agent进程加入 runningTasks 追踪,防止孤儿进程泄漏
956
+ runningTasks.set(taskId, { process: child, timeout: timer, kind: 'subagent' });
957
+ task.pid = child.pid;
958
+ task.status = 'running';
959
+ saveTask(task);
960
+
961
+ child.on('close', (code) => {
962
+ clearTimeout(timer);
963
+ runningTasks.delete(taskId);
964
+ scheduleSpawnQueue(0);
965
+ const childExitCode = code === null || code === undefined ? -1 : code;
966
+ const successAlreadyRecorded = childExitCode !== 0 && hasRecordedSuccessForTask(taskId, outputPath);
967
+ task.status = successAlreadyRecorded ? 'success' : childExitCode === 0 ? 'success' : childExitCode === 42 ? 'stalled' : 'failed';
968
+ task.exitCode = successAlreadyRecorded ? 0 : childExitCode;
969
+ if (successAlreadyRecorded) {
970
+ task.childExitCode = childExitCode;
971
+ task.childCloseWarning = `child closed nonzero after success artifact was recorded: ${childExitCode}`;
972
+ }
973
+ task.completedAt = new Date().toISOString();
974
+ task.stdout = stdout.slice(-10000);
975
+ task.stderr = stderr.slice(-10000);
976
+ saveTask(task);
977
+ log('INFO', `Subagent ${taskId} completed: status=${task.status} exitCode=${code}`);
978
+
979
+ if (successAlreadyRecorded) {
980
+ log('WARN', `Subagent ${taskId} child close exitCode=${childExitCode} ignored because success artifact/event already exists`);
981
+ if (!hasSuccessCompletionEvent(taskId)) {
982
+ recordCompletionEvent({
983
+ source: 'sidecar-child-close',
984
+ taskId,
985
+ status: 'success',
986
+ taskName, batchName, groupName,
987
+ outputPath, diaryPath,
988
+ runId,
989
+ rawOutputPolicy,
990
+ completedAt: task.completedAt,
991
+ exitCode: 0,
992
+ });
993
+ }
994
+ return;
995
+ }
996
+
997
+ recordCompletionEvent({
998
+ source: 'sidecar-child-close',
999
+ taskId,
1000
+ status: task.status,
1001
+ taskName, batchName, groupName,
1002
+ outputPath, diaryPath,
1003
+ runId,
1004
+ rawOutputPolicy,
1005
+ completedAt: task.completedAt,
1006
+ exitCode: code,
1007
+ error: task.stderr,
1008
+ });
1009
+ });
1010
+
1011
+ child.on('error', (err) => {
1012
+ clearTimeout(timer);
1013
+ runningTasks.delete(taskId);
1014
+ scheduleSpawnQueue(0);
1015
+ task.status = 'failed';
1016
+ task.error = err.message;
1017
+ task.completedAt = new Date().toISOString();
1018
+ saveTask(task);
1019
+ log('ERROR', `Subagent ${taskId} spawn error: ${err.message}`);
1020
+ recordCompletionEvent({
1021
+ source: 'sidecar-spawn-error',
1022
+ taskId,
1023
+ status: 'failed',
1024
+ taskName, batchName, groupName,
1025
+ outputPath, diaryPath,
1026
+ runId,
1027
+ rawOutputPolicy,
1028
+ completedAt: task.completedAt,
1029
+ error: err.message,
1030
+ });
1031
+ });
1032
+
1033
+ log('INFO', `Subagent spawned: ${taskId} depth=${depth}/${maxDepth} model=${model}`);
1034
+ });
1035
+
1036
+ // 立即返回201,不等待队列执行
1037
+ json(res, 201, {
1038
+ id: taskId,
1039
+ status: 'pending',
1040
+ depth, maxDepth, maxChildren,
1041
+ model, outputPath, diaryPath,
1042
+ checkStatus: `/task/${taskId}`
1043
+ });
1044
+ return;
1045
+ }
1046
+
1047
+ // SC inbox: completion event ingress. This is SC-owned, not user/assistant spoofing.
1048
+ if (method === 'POST' && (pathname === '/inbox/completion' || pathname === '/sc/inbox/completion')) {
1049
+ const body = await parseBody(req);
1050
+ if (!body.taskId || typeof body.taskId !== 'string') {
1051
+ json(res, 400, { error: 'Missing or invalid "taskId" field' });
1052
+ return;
1053
+ }
1054
+ const event = recordCompletionEvent({ ...body, source: body.source || 'http-completion' });
1055
+ if (!event) {
1056
+ json(res, 500, { error: 'Failed to record completion event' });
1057
+ return;
1058
+ }
1059
+ json(res, 201, { ok: true, event: compactInboxEvent(event) });
1060
+ return;
1061
+ }
1062
+
1063
+ // SC inbox: list unacked completion events. undeliveredOnly=true is for bridge polling.
1064
+ if (method === 'GET' && (pathname === '/inbox/pending' || pathname === '/sc/inbox/pending')) {
1065
+ const events = listInboxEvents({
1066
+ limit: url.searchParams.get('limit') || 20,
1067
+ includeAcked: url.searchParams.get('includeAcked') === 'true',
1068
+ undeliveredOnly: url.searchParams.get('undeliveredOnly') === 'true',
1069
+ });
1070
+ json(res, 200, { count: events.length, events, stats: inboxStats() });
1071
+ return;
1072
+ }
1073
+
1074
+ // SC inbox: mark events as delivered to a consumer, but keep them unacked.
1075
+ if (method === 'POST' && (pathname === '/inbox/delivered' || pathname === '/sc/inbox/delivered')) {
1076
+ const body = await parseBody(req);
1077
+ const delivered = markInboxDelivered(body.eventIds || [], body.deliveredBy || 'sc-bridge');
1078
+ json(res, 200, { ok: true, count: delivered.length, events: delivered, stats: inboxStats() });
1079
+ return;
1080
+ }
1081
+
1082
+ // SC inbox: ack only after a user-visible report or an explicit consumer decision.
1083
+ if (method === 'POST' && (pathname === '/inbox/ack' || pathname === '/sc/inbox/ack')) {
1084
+ const body = await parseBody(req);
1085
+ const acked = ackInboxEvents(body);
1086
+ json(res, 200, { ok: true, count: acked.length, events: acked, stats: inboxStats() });
1087
+ return;
1088
+ }
1089
+
1090
+ if (method === 'GET' && (pathname === '/inbox/stats' || pathname === '/sc/inbox/stats')) {
1091
+ json(res, 200, inboxStats());
1092
+ return;
1093
+ }
1094
+
1095
+ // GET /task/:id 或 POST /task/:id/:action
1096
+ const taskMatch = pathname.match(/^\/task\/([^/]+)$/);
1097
+ const taskActionMatch = pathname.match(/^\/task\/([^/]+)\/(cancel|retry)$/);
1098
+
1099
+ if (taskMatch && method === 'GET') {
1100
+ const task = loadTask(taskMatch[1]);
1101
+ if (!task) { json(res, 404, { error: 'Task not found' }); return; }
1102
+ json(res, 200, task);
1103
+ return;
1104
+ }
1105
+
1106
+ if (taskActionMatch) {
1107
+ const taskId = taskActionMatch[1];
1108
+ const action = taskActionMatch[2];
1109
+ const task = loadTask(taskId);
1110
+ if (!task) { json(res, 404, { error: 'Task not found' }); return; }
1111
+
1112
+ if (action === 'cancel') {
1113
+ // 已经在终态则不允许取消
1114
+ if (['success', 'failed', 'cancelled', 'timeout'].includes(task.status)) {
1115
+ json(res, 400, { error: `Task already in terminal state: ${task.status}` });
1116
+ return;
1117
+ }
1118
+ cancelTask(task);
1119
+ log('INFO', `Task cancelled: ${taskId}`);
1120
+ json(res, 200, { id: taskId, status: 'cancelling' });
1121
+ return;
1122
+ }
1123
+
1124
+ if (action === 'retry') {
1125
+ if (task.status !== 'failed' && task.status !== 'timeout' && task.status !== 'cancelled') {
1126
+ json(res, 400, { error: `Can only retry completed tasks, current status: ${task.status}` });
1127
+ return;
1128
+ }
1129
+ const newTask = retryTask(task);
1130
+ log('INFO', `Task retry: ${taskId} → ${newTask.id}`);
1131
+ json(res, 201, { id: newTask.id, retryOf: taskId, command: newTask.command, status: newTask.status });
1132
+ return;
1133
+ }
1134
+ }
1135
+
1136
+ // GET /tasks
1137
+ if (method === 'GET' && pathname === '/tasks') {
1138
+ const tasks = listTasks();
1139
+ json(res, 200, { count: tasks.length, tasks });
1140
+ return;
1141
+ }
1142
+
1143
+ // GET /health
1144
+ if (pathname === '/health') {
1145
+ json(res, 200, {
1146
+ status: 'ok',
1147
+ uptime: process.uptime(),
1148
+ runningTasks: runningTasks.size,
1149
+ subagentQueue: {
1150
+ queued: spawnQueue.length,
1151
+ active: activeSubagentCount(),
1152
+ maxActive: SUBAGENT_MAX_ACTIVE,
1153
+ spawnIntervalMs: SPAWN_INTERVAL_MS,
1154
+ acsGateRetryMs: ACS_GATE_RETRY_MS,
1155
+ acsGateTimeoutMs: ACS_GATE_TIMEOUT_MS,
1156
+ acsHealthUrl: ACS_HEALTH_URL,
1157
+ },
1158
+ pid: process.pid,
1159
+ timestamp: new Date().toISOString()
1160
+ });
1161
+ return;
1162
+ }
1163
+
1164
+ // POST /shutdown
1165
+ if (method === 'POST' && pathname === '/shutdown') {
1166
+ const token = req.headers['x-shutdown-token'];
1167
+ if (token !== SHUTDOWN_TOKEN) {
1168
+ json(res, 403, { error: 'Invalid shutdown token' });
1169
+ return;
1170
+ }
1171
+ json(res, 200, { status: 'shutting_down' });
1172
+ // 优雅关闭
1173
+ log('INFO', 'Shutdown requested, killing all running tasks...');
1174
+ for (const [id, entry] of runningTasks) {
1175
+ try {
1176
+ entry.process.kill('SIGTERM');
1177
+ clearTimeout(entry.timeout);
1178
+ } catch (_) { /* cleanup, ignore */ }
1179
+ }
1180
+ setTimeout(() => process.exit(0), 1000);
1181
+ return;
1182
+ }
1183
+
1184
+ // 404
1185
+ json(res, 404, { error: `Not found: ${method} ${pathname}` });
1186
+
1187
+ } catch (err) {
1188
+ log('ERROR', `Request error: ${err.message}`, { url: req.url });
1189
+ json(res, 500, { error: 'Internal server error', detail: err.message });
1190
+ }
1191
+ }
1192
+
1193
+ // ── 启动 ──────────────────────────────────────────────────────────────
1194
+ function start() {
1195
+ // 确保状态目录存在
1196
+ ensureDir(TASKS_DIR);
1197
+ ensureDir(INBOX_DIR);
1198
+
1199
+ // 启动时清理旧文件(7天前的)
1200
+ cleanOldFiles();
1201
+
1202
+ // 启动HTTP服务器
1203
+ const server = http.createServer(handleRequest);
1204
+
1205
+ server.on('error', (err) => {
1206
+ log('FATAL', `Server error: ${err.message}`);
1207
+ if (err.code === 'EADDRINUSE') {
1208
+ log('FATAL', `Port ${PORT} is already in use!`);
1209
+ }
1210
+ process.exit(1);
1211
+ });
1212
+
1213
+ server.listen(PORT, '127.0.0.1', () => {
1214
+ log('INFO', `sc Sidecar running on http://127.0.0.1:${PORT}`);
1215
+ log('INFO', `Tasks directory: ${TASKS_DIR}`);
1216
+ log('INFO', `SC inbox directory: ${INBOX_DIR}`);
1217
+ log('INFO', `PID: ${process.pid}`);
1218
+
1219
+ // 如果通过NSSM运行,通知NSSM服务已启动
1220
+ if (process.env.NSSM_CONFIGURATION) {
1221
+ log('INFO', 'Running under NSSM service manager');
1222
+ }
1223
+
1224
+ // 启动时扫描 tasks 目录,将 pending 状态的任务重新加入队列
1225
+ // 这是关键设计:重启后任务仍在,需要恢复执行
1226
+ try {
1227
+ const files = fs.readdirSync(TASKS_DIR).filter(f => f.endsWith('.json') && !f.endsWith('.tmp.json'));
1228
+ let recoveredCount = 0;
1229
+ for (const f of files) {
1230
+ try {
1231
+ const fp = path.join(TASKS_DIR, f);
1232
+ const task = JSON.parse(fs.readFileSync(fp, 'utf8'));
1233
+ if (task.status === 'pending') {
1234
+ // 重新加入执行队列
1235
+ enqueueSpawn(() => executeTask(task));
1236
+ recoveredCount++;
1237
+ }
1238
+ } catch (readErr) {
1239
+ log('WARN', `无法读取任务文件 ${f}: ${readErr.message}`);
1240
+ }
1241
+ }
1242
+ if (recoveredCount > 0) {
1243
+ log('INFO', `从重启中恢复了 ${recoveredCount} 个 pending 任务`);
1244
+ }
1245
+
1246
+ // 🔥 孤儿检测: 扫描running状态任务,PID不存在→标orphaned
1247
+ let orphanedCount = 0;
1248
+ for (const f of files) {
1249
+ try {
1250
+ const fp = path.join(TASKS_DIR, f);
1251
+ const task = JSON.parse(fs.readFileSync(fp, 'utf8'));
1252
+ if (task.status === 'running' && task.pid) {
1253
+ try {
1254
+ process.kill(task.pid, 0); // 只检查信号,不杀进程
1255
+ } catch {
1256
+ // PID不存在,孤儿
1257
+ task.status = 'orphaned';
1258
+ task.completedAt = new Date().toISOString();
1259
+ saveTask(task);
1260
+ // 创建DONE标记让主agent知道
1261
+ const subagentDoneDir = path.join(homedir(), '.openclaw', 'workspace', 'memory', 'dialog', 'subagent');
1262
+ try {
1263
+ fs.mkdirSync(subagentDoneDir, { recursive: true });
1264
+ fs.writeFileSync(path.join(subagentDoneDir, 'DONE_' + task.id + '_orphaned'), JSON.stringify({
1265
+ taskId: task.id, status: 'orphaned', reason: 'sidecar重启后子Agent进程已消失',
1266
+ completedAt: new Date().toISOString()
1267
+ }));
1268
+ } catch(e) { log('WARN', `孤儿DONE标记创建失败: ${e.message}`); }
1269
+ recordCompletionEvent({
1270
+ source: 'sidecar-orphan-scan',
1271
+ taskId: task.id,
1272
+ status: 'orphaned',
1273
+ task,
1274
+ completedAt: task.completedAt,
1275
+ error: 'sidecar重启后子Agent进程已消失',
1276
+ });
1277
+ orphanedCount++;
1278
+ }
1279
+ }
1280
+ } catch (readErr) { /* skip */ }
1281
+ }
1282
+ if (orphanedCount > 0) {
1283
+ log('INFO', `检测到 ${orphanedCount} 个孤儿任务,已标记`);
1284
+ }
1285
+ } catch (scanErr) {
1286
+ log('WARN', `启动时扫描 tasks 目录失败: ${scanErr.message}`);
1287
+ }
1288
+ });
1289
+
1290
+ // 优雅退出
1291
+ const shutdown = () => {
1292
+ log('INFO', 'Received shutdown signal, stopping...');
1293
+ // 不杀运行中的任务——这是关键设计:重启后任务仍在
1294
+ server.close(() => process.exit(0));
1295
+ };
1296
+
1297
+ process.on('SIGINT', shutdown);
1298
+ process.on('SIGTERM', shutdown);
1299
+
1300
+ // 每6小时清理旧任务文件
1301
+ setInterval(cleanOldFiles, 6 * 60 * 60 * 1000);
1302
+
1303
+ // ====== DONE标记轮询: 每3秒检查子Agent完成状态 ======
1304
+ const subagentDir = path.join(homedir(), '.openclaw', 'workspace', 'memory', 'dialog', 'subagent');
1305
+ let processedDones = new Set();
1306
+ setInterval(() => {
1307
+ try {
1308
+ const files = fs.readdirSync(subagentDir).filter(f => f.startsWith('DONE_'));
1309
+ for (const file of files) {
1310
+ if (processedDones.has(file)) continue;
1311
+ const donePath = path.join(subagentDir, file);
1312
+ try {
1313
+ if (fs.statSync(donePath).mtimeMs < SIDECAR_STARTED_AT_MS - 5000) {
1314
+ processedDones.add(file);
1315
+ continue;
1316
+ }
1317
+ } catch {}
1318
+ processedDones.add(file);
1319
+ // 解析taskId和状态
1320
+ const match = file.match(/^DONE_(.+)_(success|failed|stalled|orphaned)$/);
1321
+ if (!match) continue;
1322
+ const taskId = match[1];
1323
+ const status = match[2];
1324
+ // 读task文件获取完整结果
1325
+ const taskPath = path.join(__dirname, 'tasks', taskId + '.json');
1326
+ let detail = '';
1327
+ if (fs.existsSync(taskPath)) {
1328
+ try {
1329
+ const task = JSON.parse(fs.readFileSync(taskPath, 'utf8'));
1330
+ detail = task.stderr || '';
1331
+ } catch {}
1332
+ }
1333
+ if (!fs.existsSync(inboxEventPath(completionEventId(taskId, status)))) {
1334
+ recordCompletionEvent({
1335
+ source: 'sidecar-done-poll',
1336
+ taskId,
1337
+ status,
1338
+ completedAt: new Date().toISOString(),
1339
+ error: detail,
1340
+ });
1341
+ }
1342
+ process.stderr.write(`[轮询] 子Agent完成: ${taskId} status=${status} detail=${detail.substring(0,200)}\n`);
1343
+ }
1344
+ // 定期清理已处理的记录(保留最近1000条)
1345
+ if (processedDones.size > 1000) {
1346
+ const arr = [...processedDones];
1347
+ processedDones = new Set(arr.slice(-500));
1348
+ }
1349
+ } catch (e) {
1350
+ process.stderr.write(`[轮询] DONE检查错误: ${e.message}\n`);
1351
+ }
1352
+ }, 3000);
1353
+ }
1354
+
1355
+ // ── 启动(如果直接运行) ─────────────────────────────────────────────
1356
+ if (require.main === module) {
1357
+ start();
1358
+ }
1359
+
1360
+ module.exports = { start, executeTask, cancelTask, retryTask, loadTask, listTasks };