@winmatrix/supervisor 1.0.5

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.
@@ -0,0 +1,1580 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Agent Engine Supervisor CLI(task 5.1)
4
+ *
5
+ * 工作站容器内常驻 Agent Engine Supervisor,通过 Unix socket 提供统一接口。
6
+ * 实现幂等启动、子进程监管、状态/结果文件、callback、cancel marker。
7
+ *
8
+ * 用法:
9
+ * node run-agent-engine.mjs launch <json-payload> # 启动 Agent Engine
10
+ * node run-agent-engine.mjs status <runKey> # 查询运行状态
11
+ * node run-agent-engine.mjs cancel <runKey> # 取消运行
12
+ * node run-agent-engine.mjs list-active # 列出活跃运行
13
+ * node run-agent-engine.mjs health # 健康检查
14
+ *
15
+ * 设计约束(design D14):
16
+ * - 幂等启动:相同 runKey+fingerprint 返回 existing,不同 fingerprint 拒绝
17
+ * - 状态文件:<runDir>/<runKey>/status.json(running/completed/failed)
18
+ * - 结果文件:<runDir>/<runKey>/result.json
19
+ * - callback:完成后通过 HTTP POST 通知 Server
20
+ * - cancel marker:<runDir>/<runKey>/cancel.marker
21
+ */
22
+
23
+ import { createRequire } from 'module';
24
+ import { createHash } from 'crypto';
25
+ import { fileURLToPath } from 'url';
26
+ // Claude Session 查询公共纯函数(task 7.15)
27
+ import {
28
+ loadSdk,
29
+ getOptValue,
30
+ hasFlag,
31
+ clampInt,
32
+ parseLimit,
33
+ encodeOffsetCursor,
34
+ decodeOffsetCursor,
35
+ toIsoString,
36
+ lastActivityMs,
37
+ buildSessionMeta,
38
+ extractText,
39
+ cloneMessagesForJson,
40
+ buildCliReplayGuide,
41
+ buildDiagnostics,
42
+ maybeTruncate,
43
+ resolveDirViaGlobalListSessions,
44
+ } from './claude-session-lib.mjs';
45
+
46
+ // D11.2-D11.4: Engine Driver 增强模块
47
+ import { getEngineDriverConfig } from './engine-driver-config.mjs';
48
+ import { sanitizeEnv, getFilteredEnvKeys } from './env-sanitizer.mjs';
49
+ import { filterBlockedArgs, formatBlockedArgsAudit } from './blocked-args-filter.mjs';
50
+ import { createDeliverableState, updateDeliverable, extractDeliverable, computeDeliverableFromEvents } from './deliverable-tracker.mjs';
51
+ import { extractTokenUsage } from './token-usage.mjs';
52
+ import { isResumeRejected, buildContinuityNotice, buildResumeDegradedEvent, isResumeFailureOnExit } from './resume-degradation.mjs';
53
+
54
+ const require = createRequire(import.meta.url);
55
+ const fs = require('node:fs');
56
+ const path = require('node:path');
57
+ const { spawn, spawnSync } = require('node:child_process');
58
+ const http = require('node:http');
59
+ const readline = require('node:readline');
60
+ const SCRIPT_PATH = fileURLToPath(import.meta.url);
61
+
62
+ // Supervisor 根目录(每个 runKey 一个子目录)
63
+ const SUPERVISOR_ROOT = process.env.WINMATRIX_SUPERVISOR_ROOT || '/tmp/agent-engine-supervisor';
64
+ const CALLBACK_TIMEOUT_MS = Number(process.env.WINMATRIX_CALLBACK_TIMEOUT_MS) || 5000;
65
+ const MAX_ACTIVE_RUNS = Number(process.env.WINMATRIX_MAX_ACTIVE_RUNS) || 100;
66
+
67
+ // 确保根目录存在
68
+ if (!fs.existsSync(SUPERVISOR_ROOT)) {
69
+ fs.mkdirSync(SUPERVISOR_ROOT, { recursive: true });
70
+ }
71
+
72
+ // 运行状态注册表(内存中,重启后从文件系统恢复)
73
+ const activeRuns = new Map();
74
+
75
+ /**
76
+ * 持久化活跃运行列表到 active-runs.json
77
+ */
78
+ function saveActiveRuns() {
79
+ const activeRunsFile = path.join(SUPERVISOR_ROOT, 'active-runs.json');
80
+ const data = Array.from(activeRuns.entries()).map(([runKey, { metadata }]) => ({
81
+ runKey,
82
+ metadata,
83
+ }));
84
+ fs.writeFileSync(activeRunsFile, JSON.stringify(data, null, 2), 'utf8');
85
+ }
86
+
87
+ /**
88
+ * 从 active-runs.json 恢复活跃运行列表
89
+ */
90
+ function loadActiveRuns() {
91
+ const activeRunsFile = path.join(SUPERVISOR_ROOT, 'active-runs.json');
92
+ if (!fs.existsSync(activeRunsFile)) {
93
+ return;
94
+ }
95
+ try {
96
+ const data = JSON.parse(fs.readFileSync(activeRunsFile, 'utf8'));
97
+ for (const { runKey, metadata } of data) {
98
+ // 只恢复 running 状态的 run
99
+ if (metadata.status === 'running') {
100
+ activeRuns.set(runKey, { child: null, metadata });
101
+ }
102
+ }
103
+ } catch (error) {
104
+ console.error('[run-agent-engine] Failed to load active-runs.json:', error.message);
105
+ }
106
+ }
107
+
108
+ // 启动时恢复活跃运行列表
109
+ loadActiveRuns();
110
+
111
+ /**
112
+ * Run 元数据(持久化到 status.json)
113
+ * @typedef {Object} RunMetadata
114
+ * @property {string} runKey - 运行唯一标识(instanceId:attemptNo)
115
+ * @property {string} invocationFingerprint - 调用指纹(SHA-256)
116
+ * @property {string} engineId - Engine ID(claude-code/codex/hermes)
117
+ * @property {string} status - 运行状态(running/completed/failed/cancelled)
118
+ * @property {number} pid - 子进程 PID
119
+ * @property {string} startTime - 启动时间(ISO 8601)
120
+ * @property {string} [endTime] - 结束时间(ISO 8601)
121
+ * @property {number} [exitCode] - 退出码
122
+ * @property {string} [error] - 错误信息
123
+ */
124
+
125
+ /**
126
+ * 递归排序对象的所有 key(与 Server 端 canonicalJson 一致)
127
+ * @param {any} obj - 要排序的对象
128
+ * @returns {any} 排序后的对象
129
+ */
130
+ function canonicalize(obj) {
131
+ if (obj === null || typeof obj !== 'object') {
132
+ return obj;
133
+ }
134
+ if (Array.isArray(obj)) {
135
+ return obj.map(canonicalize);
136
+ }
137
+ const out = {};
138
+ for (const key of Object.keys(obj).sort()) {
139
+ out[key] = canonicalize(obj[key]);
140
+ }
141
+ return out;
142
+ }
143
+
144
+ /**
145
+ * 计算 invocation fingerprint(SHA-256)
146
+ * 使用递归 key 排序,与 Server 端 canonicalJsonStringify 一致
147
+ * @param {object} launchSpec - Launch spec
148
+ * @returns {string} SHA-256 hash
149
+ */
150
+ function computeFingerprint(launchSpec) {
151
+ const canonical = canonicalize(launchSpec);
152
+ const json = JSON.stringify(canonical);
153
+ return createHash('sha256').update(json).digest('hex');
154
+ }
155
+
156
+ /**
157
+ * 获取 run 目录路径
158
+ * @param {string} runKey - Run key
159
+ * @returns {string} Run directory path
160
+ */
161
+ function getRunDir(runKey) {
162
+ assertSafeRunKey(runKey);
163
+ // 保留现有目录命名兼容,同时拒绝任何路径分隔符和遍历片段。
164
+ const safeKey = runKey.replace(/:/g, '_');
165
+ return path.join(SUPERVISOR_ROOT, safeKey);
166
+ }
167
+
168
+ function assertSafeRunKey(runKey) {
169
+ if (typeof runKey !== 'string' || !/^[A-Za-z0-9._-]+:[1-9][0-9]*$/.test(runKey)) {
170
+ throw new Error('Invalid runKey: expected safe <recordId>:<positive-attemptNo>');
171
+ }
172
+ }
173
+
174
+ function assertSafeSessionId(sessionId) {
175
+ if (typeof sessionId !== 'string' || !/^[A-Za-z0-9._-]{1,256}$/.test(sessionId)) {
176
+ throw new Error('Invalid sessionId');
177
+ }
178
+ }
179
+
180
+ /**
181
+ * 保存 run 元数据到 status.json
182
+ * @param {string} runKey - Run key
183
+ * @param {RunMetadata} metadata - Run metadata
184
+ */
185
+ function saveRunMetadata(runKey, metadata) {
186
+ const runDir = getRunDir(runKey);
187
+ if (!fs.existsSync(runDir)) {
188
+ fs.mkdirSync(runDir, { recursive: true });
189
+ }
190
+ const statusFile = path.join(runDir, 'status.json');
191
+ fs.writeFileSync(statusFile, JSON.stringify(metadata, null, 2), 'utf8');
192
+ }
193
+
194
+ /**
195
+ * 加载 run 元数据
196
+ * @param {string} runKey - Run key
197
+ * @returns {RunMetadata|null} Run metadata or null
198
+ */
199
+ function loadRunMetadata(runKey) {
200
+ const runDir = getRunDir(runKey);
201
+ const statusFile = path.join(runDir, 'status.json');
202
+ if (!fs.existsSync(statusFile)) {
203
+ return null;
204
+ }
205
+ try {
206
+ return JSON.parse(fs.readFileSync(statusFile, 'utf8'));
207
+ } catch (error) {
208
+ console.error(`[run-agent-engine] Failed to load status.json for ${runKey}:`, error.message);
209
+ return null;
210
+ }
211
+ }
212
+
213
+ /**
214
+ * 保存结果到 result.json
215
+ * @param {string} runKey - Run key
216
+ * @param {object} result - Result data
217
+ */
218
+ function saveResult(runKey, result) {
219
+ const runDir = getRunDir(runKey);
220
+ if (!fs.existsSync(runDir)) {
221
+ fs.mkdirSync(runDir, { recursive: true });
222
+ }
223
+ const resultFile = path.join(runDir, 'result.json');
224
+ fs.writeFileSync(resultFile, JSON.stringify(result, null, 2), 'utf8');
225
+ }
226
+
227
+ /**
228
+ * 加载结果
229
+ * @param {string} runKey - Run key
230
+ * @returns {object|null} Result data or null
231
+ */
232
+ function loadResult(runKey) {
233
+ const runDir = getRunDir(runKey);
234
+ const resultFile = path.join(runDir, 'result.json');
235
+ if (!fs.existsSync(resultFile)) {
236
+ return null;
237
+ }
238
+ try {
239
+ return JSON.parse(fs.readFileSync(resultFile, 'utf8'));
240
+ } catch (error) {
241
+ console.error(`[run-agent-engine] Failed to load result.json for ${runKey}:`, error.message);
242
+ return null;
243
+ }
244
+ }
245
+
246
+ /**
247
+ * 创建 cancel marker
248
+ * @param {string} runKey - Run key
249
+ */
250
+ function createCancelMarker(runKey) {
251
+ const runDir = getRunDir(runKey);
252
+ if (!fs.existsSync(runDir)) {
253
+ fs.mkdirSync(runDir, { recursive: true });
254
+ }
255
+ const cancelFile = path.join(runDir, 'cancel.marker');
256
+ fs.writeFileSync(cancelFile, new Date().toISOString(), 'utf8');
257
+ }
258
+
259
+ /**
260
+ * 检查 cancel marker 是否存在
261
+ * @param {string} runKey - Run key
262
+ * @returns {boolean} True if cancel marker exists
263
+ */
264
+ function hasCancelMarker(runKey) {
265
+ const runDir = getRunDir(runKey);
266
+ const cancelFile = path.join(runDir, 'cancel.marker');
267
+ return fs.existsSync(cancelFile);
268
+ }
269
+
270
+ /**
271
+ * 构建 workstation 任务终态 callback payload(merge-workstation-tools R11 / 9b.5)。
272
+ *
273
+ * 对齐 V1 run-claude-agent.mjs 的字段语义,满足 Server 端
274
+ * /api/v1/workstation-task-callbacks 的 zod schema(recordId 必填、attemptNo 正整数)。
275
+ * runKey 形如 <recordId>:<attemptNo>,据此回解 record 身份。
276
+ *
277
+ * @param {string} runKey - Run key(<recordId>:<attemptNo>)
278
+ * @param {RunMetadata} endMetadata - 终态元数据(status/endTime/exitCode/...)
279
+ * @param {object} result - 终态结果(success/text/sessionId/error/usage)
280
+ * @param {RunMetadata} startMetadata - 启动元数据(startTime)
281
+ * @returns {object} Callback payload
282
+ */
283
+ function buildTerminalCallbackPayload(runKey, endMetadata, result, startMetadata) {
284
+ const sep = runKey.lastIndexOf(':');
285
+ const recordId = sep > 0 ? runKey.slice(0, sep) : runKey;
286
+ const parsedAttempt = sep > 0 ? Number(runKey.slice(sep + 1)) : NaN;
287
+ const attemptNo = Number.isInteger(parsedAttempt) && parsedAttempt > 0 ? parsedAttempt : 1;
288
+ const runDir = getRunDir(runKey);
289
+
290
+ const startMs = Date.parse(startMetadata?.startTime ?? '');
291
+ const endMs = Date.parse(endMetadata?.endTime ?? '');
292
+ const durationSeconds = Number.isFinite(startMs) && Number.isFinite(endMs) && endMs >= startMs
293
+ ? Math.round((endMs - startMs) / 1000)
294
+ : undefined;
295
+
296
+ const text = typeof result?.text === 'string' ? result.text : undefined;
297
+ return {
298
+ recordId,
299
+ attemptNo,
300
+ runKey,
301
+ status: endMetadata.status,
302
+ summary: text ? text.slice(0, 800) : undefined,
303
+ result: text,
304
+ error: typeof result?.error === 'string' ? result.error : undefined,
305
+ durationSeconds,
306
+ claudeSessionId: typeof result?.sessionId === 'string' ? result.sessionId : undefined,
307
+ statusFilePath: path.join(runDir, 'status.json'),
308
+ resultFilePath: path.join(runDir, 'result.json'),
309
+ completedAt: endMetadata.endTime,
310
+ metadata: {
311
+ exitCode: endMetadata.exitCode,
312
+ ...(endMetadata.signal ? { signal: endMetadata.signal } : {}),
313
+ ...(endMetadata.resumeDegraded ? { resumeDegraded: true } : {}),
314
+ ...(result?.usage ? { usage: result.usage } : {}),
315
+ },
316
+ };
317
+ }
318
+
319
+ /**
320
+ * 发送 callback 到 Server
321
+ * @param {string} callbackUrl - Callback URL
322
+ * @param {string} token - Callback token
323
+ * @param {object} payload - Callback payload
324
+ * @returns {Promise<void>}
325
+ */
326
+ async function sendCallback(callbackUrl, token, payload) {
327
+ return new Promise((resolve, reject) => {
328
+ const url = new URL(callbackUrl);
329
+ const data = JSON.stringify(payload);
330
+ const options = {
331
+ hostname: url.hostname,
332
+ port: url.port || 80,
333
+ path: url.pathname + url.search,
334
+ method: 'POST',
335
+ headers: {
336
+ 'Content-Type': 'application/json',
337
+ 'Content-Length': Buffer.byteLength(data),
338
+ 'Authorization': `Bearer ${token}`,
339
+ },
340
+ timeout: CALLBACK_TIMEOUT_MS,
341
+ };
342
+
343
+ const req = http.request(options, (res) => {
344
+ if (res.statusCode >= 200 && res.statusCode < 300) {
345
+ console.log(`[run-agent-engine] Callback sent successfully: ${callbackUrl}`);
346
+ resolve();
347
+ } else {
348
+ reject(new Error(`Callback failed with status ${res.statusCode}`));
349
+ }
350
+ });
351
+
352
+ req.on('error', (error) => {
353
+ console.error(`[run-agent-engine] Callback error:`, error.message);
354
+ reject(error);
355
+ });
356
+
357
+ req.on('timeout', () => {
358
+ req.destroy();
359
+ reject(new Error('Callback timeout'));
360
+ });
361
+
362
+ req.write(data);
363
+ req.end();
364
+ });
365
+ }
366
+
367
+ /**
368
+ * 启动 Agent Engine
369
+ * @param {object} payload - Launch payload
370
+ * @returns {Promise<object>} Launch result
371
+ */
372
+ async function runOwned(payload) {
373
+ const { runKey, invocationFingerprint, launchSpec, privateBindings } = payload;
374
+
375
+ if (!runKey || !invocationFingerprint || !launchSpec) {
376
+ throw new Error('Missing required fields: runKey, invocationFingerprint, launchSpec');
377
+ }
378
+
379
+ // 幂等性检查:相同 runKey 是否已存在
380
+ const existingMetadata = loadRunMetadata(runKey);
381
+ if (existingMetadata) {
382
+ // 验证 fingerprint 是否匹配
383
+ if (existingMetadata.invocationFingerprint === invocationFingerprint) {
384
+ console.log(`[run-agent-engine] Run already exists: ${runKey}`);
385
+ return {
386
+ disposition: 'existing',
387
+ ref: {
388
+ runKey,
389
+ invocationFingerprint,
390
+ engineId: launchSpec.engineId,
391
+ instance: { instanceId: payload.instanceId, hostKind: 'workstation' },
392
+ executionSnapshot: {
393
+ hostProfile: 'coding',
394
+ digitalEmployeeId: payload.instanceId,
395
+ },
396
+ },
397
+ executionDurability: 'host_persistent',
398
+ eventStreamMode: 'replay',
399
+ };
400
+ } else {
401
+ throw new Error(`Fingerprint mismatch for runKey=${runKey}`);
402
+ }
403
+ }
404
+
405
+ // 验证 fingerprint
406
+ const computedFingerprint = computeFingerprint(launchSpec);
407
+ if (computedFingerprint !== invocationFingerprint) {
408
+ throw new Error(`Invalid invocation fingerprint: expected=${computedFingerprint}, got=${invocationFingerprint}`);
409
+ }
410
+
411
+ // 检查并发限制
412
+ if (activeRuns.size >= MAX_ACTIVE_RUNS) {
413
+ throw new Error(`Max active runs limit reached: ${MAX_ACTIVE_RUNS}`);
414
+ }
415
+
416
+ // 构建 argv(D11.3: 自动过滤 blocked args)
417
+ const argv = buildEngineArgv(launchSpec);
418
+
419
+ // D11.3: 使用 sanitized 环境变量,过滤 Supervisor 内部标记
420
+ const filteredEnvKeys = getFilteredEnvKeys();
421
+ if (filteredEnvKeys.length > 0) {
422
+ console.log(`[run-agent-engine] Filtering ${filteredEnvKeys.length} env vars from child process: ${filteredEnvKeys.join(', ')}`);
423
+ }
424
+ const env = sanitizeEnv({
425
+ // Gateway/Supervisor owns durable completion and callback delivery. The
426
+ // legacy Claude runner must not mutate project-local hook settings here.
427
+ WIN_AGENT_ENGINE_GATEWAY_RUN: 'true',
428
+ ...launchSpec.nonSensitiveEnv,
429
+ });
430
+
431
+ // 准备 stdin(包含 sensitive bindings)
432
+ const stdinData = privateBindings ? JSON.stringify(privateBindings) : '';
433
+
434
+ // 启动子进程
435
+ const runDir = getRunDir(runKey);
436
+ if (!fs.existsSync(runDir)) {
437
+ fs.mkdirSync(runDir, { recursive: true });
438
+ }
439
+
440
+ const logFile = path.join(runDir, 'stdout.log');
441
+ const errFile = path.join(runDir, 'stderr.log');
442
+ // 先清空旧 stdout 日志
443
+ try { fs.writeFileSync(logFile, '', 'utf8'); } catch { /* ignore */ }
444
+
445
+ const child = spawn(argv[0], argv.slice(1), {
446
+ env,
447
+ cwd: launchSpec.workDir || process.cwd(),
448
+ stdio: ['pipe', 'pipe', fs.openSync(errFile, 'a')],
449
+ // D11.2: detached=true 用于创建独立进程组,两阶段关闭时可 SIGTERM 整组
450
+ detached: process.platform !== 'win32',
451
+ });
452
+
453
+ // 写入 stdin
454
+ if (stdinData) {
455
+ child.stdin.write(stdinData);
456
+ child.stdin.end();
457
+ }
458
+
459
+ // 读取 stdout JSONL 事件,同时写入 stdout.log
460
+ const stdoutWriteStream = fs.createWriteStream(logFile, { flags: 'a' });
461
+ const rl = readline.createInterface({ input: child.stdout });
462
+ rl.on('line', (line) => {
463
+ stdoutWriteStream.write(`${line}\n`);
464
+ if (!line.trim()) return;
465
+ let event;
466
+ try {
467
+ event = JSON.parse(line);
468
+ } catch {
469
+ // 非 JSON 行视为 stderr 风格诊断输出,不进入事件流
470
+ return;
471
+ }
472
+ appendEvent(runKey, metadata, event);
473
+ deliverableState = updateDeliverable(deliverableState, event);
474
+ });
475
+ child.stdout.on('close', () => {
476
+ stdoutWriteStream.end();
477
+ });
478
+
479
+ // 保存元数据
480
+ const metadata = {
481
+ runKey,
482
+ invocationFingerprint,
483
+ engineId: launchSpec.engineId,
484
+ status: 'running',
485
+ pid: child.pid,
486
+ startTime: new Date().toISOString(),
487
+ instanceId: payload.instanceId,
488
+ lastSequence: 0,
489
+ };
490
+ saveRunMetadata(runKey, metadata);
491
+ activeRuns.set(runKey, { child, metadata });
492
+ saveActiveRuns();
493
+
494
+ console.log(`[run-agent-engine] Launched run: ${runKey} (pid=${child.pid})`);
495
+
496
+ appendEvent(runKey, metadata, { type: 'status', text: 'running' });
497
+
498
+ // D11.2: 获取 Engine driver 配置(gracefulShutdownTimeoutMs 等)
499
+ const driverConfig = getEngineDriverConfig(launchSpec.engineId);
500
+
501
+ // D11.4: 初始化 deliverable tracker
502
+ let deliverableState = createDeliverableState();
503
+
504
+ // D11.5: 跟踪 stderr 用于 resume 降级检测
505
+ let stderrAccumulator = '';
506
+
507
+ // D11.2: 两阶段优雅关闭函数
508
+ function gracefulShutdown(killSignal) {
509
+ if (child.killed || child.exitCode !== null) return;
510
+
511
+ console.log(`[run-agent-engine] D11.2: Phase 1 graceful shutdown for ${runKey} (stdin.close)`);
512
+
513
+ // Phase 1: stdin.Close() → 让 Engine 自行写状态文件并退出
514
+ try {
515
+ child.stdin.end();
516
+ } catch {
517
+ // stdin 可能已经关闭
518
+ }
519
+
520
+ // Phase 1 超时后进入 Phase 2
521
+ const phase1Timer = setTimeout(() => {
522
+ if (child.exitCode !== null) return; // Engine 已退出
523
+
524
+ console.log(`[run-agent-engine] D11.2: Phase 1 timeout (${driverConfig.gracefulShutdownTimeoutMs}ms), entering Phase 2 (SIGTERM)`);
525
+
526
+ // Phase 2: SIGTERM 进程组
527
+ try {
528
+ if (process.platform !== 'win32' && child.pid) {
529
+ // Unix: 发送到进程组
530
+ try { process.kill(-child.pid, 'SIGTERM'); } catch { /* 进程组可能不存在 */ }
531
+ } else {
532
+ child.kill('SIGTERM');
533
+ }
534
+ } catch {
535
+ // 进程可能已退出
536
+ }
537
+
538
+ // Phase 2 超时后进入 Phase 3
539
+ const phase2Timer = setTimeout(() => {
540
+ if (child.exitCode !== null) return;
541
+
542
+ console.log(`[run-agent-engine] D11.2: Phase 2 timeout (${driverConfig.phase2TimeoutMs}ms), entering Phase 3 (SIGKILL)`);
543
+
544
+ // Phase 3: SIGKILL 进程组
545
+ try {
546
+ if (process.platform !== 'win32' && child.pid) {
547
+ try { process.kill(-child.pid, 'SIGKILL'); } catch { /* 进程组可能不存在 */ }
548
+ } else {
549
+ child.kill('SIGKILL');
550
+ }
551
+ } catch {
552
+ // 进程可能已退出
553
+ }
554
+ }, driverConfig.phase2TimeoutMs);
555
+
556
+ // 确保 phase2Timer 不阻止进程退出
557
+ if (phase2Timer.unref) phase2Timer.unref();
558
+ }, driverConfig.gracefulShutdownTimeoutMs);
559
+
560
+ // 确保 phase1Timer 不阻止进程退出
561
+ if (phase1Timer.unref) phase1Timer.unref();
562
+ }
563
+
564
+ // D11.2: cancel marker 轮询使用两阶段关闭
565
+ const cancelPoll = setInterval(() => {
566
+ if (hasCancelMarker(runKey) && !child.killed && child.exitCode === null) {
567
+ gracefulShutdown('SIGTERM');
568
+ }
569
+ }, 500);
570
+
571
+ const completion = new Promise((resolve) => child.on('exit', async (code, signal) => {
572
+ clearInterval(cancelPoll);
573
+ console.log(`[run-agent-engine] Run exited: ${runKey} (code=${code}, signal=${signal})`);
574
+
575
+ const cancelled = hasCancelMarker(runKey);
576
+
577
+ // D11.2: Resume 降级检测
578
+ // 检查是否是 resume session 且 Engine 输出包含拒绝模式
579
+ const isResume = launchSpec.session?.mode === 'resume';
580
+ const resumeFailed = isResume && (
581
+ isResumeFailureOnExit(launchSpec.engineId, code, stderrAccumulator)
582
+ );
583
+
584
+ // D11.4: 从事件文件计算 deliverable 和 token usage
585
+ let resultText = '';
586
+ let usage = null;
587
+ let resultSessionId = null;
588
+ try {
589
+ const eventsFile = path.join(getRunDir(runKey), 'events.jsonl');
590
+ if (fs.existsSync(eventsFile)) {
591
+ const eventLines = fs.readFileSync(eventsFile, 'utf8').split('\n').filter(Boolean);
592
+ const events = eventLines.map(line => {
593
+ try { return JSON.parse(line); } catch { return null; }
594
+ }).filter(Boolean);
595
+
596
+ // D11.4: Deliverable 提取
597
+ resultText = computeDeliverableFromEvents(events);
598
+
599
+ // D11.4: Token Usage 多路获取
600
+ usage = extractTokenUsage(events, {
601
+ engineId: launchSpec.engineId,
602
+ sessionId: launchSpec.session?.session?.sessionId,
603
+ });
604
+
605
+ // 提取 fresh run 产生的新 sessionId(最后一个 result 事件携带;
606
+ // 行可能是 envelope({event})或裸事件,与 token-usage 解析保持一致)
607
+ for (let i = events.length - 1; i >= 0; i -= 1) {
608
+ const evt = events[i]?.event ?? events[i];
609
+ if (evt?.type === 'result' && typeof evt?.result?.sessionId === 'string') {
610
+ resultSessionId = evt.result.sessionId;
611
+ break;
612
+ }
613
+ }
614
+ }
615
+ } catch (err) {
616
+ console.error(`[run-agent-engine] Failed to compute deliverable/usage for ${runKey}:`, err.message);
617
+ }
618
+
619
+ const endMetadata = {
620
+ ...metadata,
621
+ status: cancelled ? 'cancelled' : code === 0 ? 'completed' : 'failed',
622
+ endTime: new Date().toISOString(),
623
+ exitCode: code,
624
+ ...(signal ? { signal } : {}),
625
+ ...(resumeFailed ? { resumeDegraded: true } : {}),
626
+ };
627
+ saveRunMetadata(runKey, endMetadata);
628
+ activeRuns.delete(runKey);
629
+ saveActiveRuns();
630
+
631
+ // D11.4: 构建包含 deliverable 和 usage 的结果
632
+ const result = {
633
+ success: !cancelled && code === 0,
634
+ text: resultText || undefined,
635
+ ...(resultSessionId ? { sessionId: resultSessionId } : {}),
636
+ ...(usage ? { usage } : {}),
637
+ ...(cancelled ? { error: 'cancelled' } : code === 0 ? {} : { error: `Engine exited with code ${code}` }),
638
+ metadata: {
639
+ exitCode: code,
640
+ ...(signal ? { signal } : {}),
641
+ ...(resumeFailed ? { resumeDegraded: true } : {}),
642
+ ...(usage?.source ? { usageSource: usage.source } : {}),
643
+ },
644
+ };
645
+ saveResult(runKey, result);
646
+ appendEvent(runKey, endMetadata, { type: 'result', result });
647
+
648
+ // 发送 callback(如果有)
649
+ // merge-workstation-tools R11 / 9b.5:payload 对齐 V1 run-claude-agent.mjs
650
+ // 字段语义,满足 /api/v1/workstation-task-callbacks 的 zod schema(recordId 必填),
651
+ // 使 HTTP callback 快路径对 V2 run 也能闭合(此前仅 observer 投影 + reconcile 兜底)。
652
+ if (privateBindings?.callback) {
653
+ sendCallback(
654
+ privateBindings.callback.url,
655
+ privateBindings.callback.token,
656
+ buildTerminalCallbackPayload(runKey, endMetadata, result, metadata),
657
+ ).catch((error) => {
658
+ console.error(`[run-agent-engine] Callback failed for ${runKey}:`, error.message);
659
+ });
660
+ }
661
+ resolve();
662
+ }));
663
+
664
+ return { ack: {
665
+ disposition: 'accepted',
666
+ ref: {
667
+ runKey,
668
+ invocationFingerprint,
669
+ engineId: launchSpec.engineId,
670
+ instance: { instanceId: payload.instanceId, hostKind: 'workstation' },
671
+ executionSnapshot: {
672
+ hostProfile: 'coding',
673
+ digitalEmployeeId: payload.instanceId,
674
+ },
675
+ },
676
+ executionDurability: 'host_persistent',
677
+ eventStreamMode: 'replay',
678
+ eventRetention: { maxEvents: 10000 },
679
+ runtimeVersion: 'run-agent-engine/1',
680
+ protocolVersion: '1',
681
+ }, completion };
682
+ }
683
+
684
+ function appendEvent(runKey, metadata, event) {
685
+ const sequence = Number(metadata.lastSequence ?? 0) + 1;
686
+ metadata.lastSequence = sequence;
687
+ saveRunMetadata(runKey, metadata);
688
+ const envelope = {
689
+ runKey,
690
+ engineId: metadata.engineId,
691
+ invocationFingerprint: metadata.invocationFingerprint,
692
+ sequence,
693
+ timestamp: new Date().toISOString(),
694
+ event,
695
+ };
696
+ fs.appendFileSync(path.join(getRunDir(runKey), 'events.jsonl'), `${JSON.stringify(envelope)}\n`, 'utf8');
697
+ }
698
+
699
+ function buildRunAck(payload, disposition) {
700
+ return {
701
+ disposition,
702
+ ref: {
703
+ runKey: payload.runKey,
704
+ invocationFingerprint: payload.invocationFingerprint,
705
+ engineId: payload.launchSpec.engineId,
706
+ instance: { instanceId: payload.instanceId, hostKind: 'workstation' },
707
+ executionSnapshot: {},
708
+ },
709
+ executionDurability: 'host_persistent',
710
+ eventStreamMode: 'replay',
711
+ eventRetention: { maxEvents: 10000 },
712
+ runtimeVersion: 'run-agent-engine/1',
713
+ protocolVersion: '1',
714
+ };
715
+ }
716
+
717
+ async function launch(payload) {
718
+ const { runKey, invocationFingerprint, launchSpec, instanceId } = payload;
719
+ if (!runKey || !invocationFingerprint || !launchSpec || !instanceId) {
720
+ throw new Error('Missing required fields: runKey, invocationFingerprint, launchSpec, instanceId');
721
+ }
722
+ assertSafeRunKey(runKey);
723
+ if (computeFingerprint(launchSpec) !== invocationFingerprint) {
724
+ throw new Error('Invalid invocation fingerprint');
725
+ }
726
+
727
+ const existing = loadRunMetadata(runKey);
728
+ if (existing) {
729
+ if (existing.invocationFingerprint !== invocationFingerprint) {
730
+ throw new Error(`Fingerprint mismatch for runKey=${runKey}`);
731
+ }
732
+ return buildRunAck(payload, 'existing');
733
+ }
734
+
735
+ const claimFile = path.join(SUPERVISOR_ROOT, `${runKey.replace(/:/g, '_')}.launch`);
736
+ const waitForClaimedRun = async () => {
737
+ const deadline = Date.now() + 2_000;
738
+ while (Date.now() < deadline) {
739
+ const metadata = loadRunMetadata(runKey);
740
+ if (metadata) {
741
+ if (metadata.invocationFingerprint !== invocationFingerprint) {
742
+ throw new Error(`Fingerprint mismatch for runKey=${runKey}`);
743
+ }
744
+ return metadata;
745
+ }
746
+ if (!fs.existsSync(claimFile)) break;
747
+ await new Promise((resolve) => setTimeout(resolve, 20));
748
+ }
749
+ throw new Error(`Claimed run did not start: ${runKey}`);
750
+ };
751
+ let claim;
752
+ try {
753
+ claim = fs.openSync(claimFile, 'wx', 0o600);
754
+ fs.writeFileSync(claim, invocationFingerprint, 'utf8');
755
+ } catch (error) {
756
+ if (error?.code !== 'EEXIST') throw error;
757
+ const claimedFingerprint = fs.readFileSync(claimFile, 'utf8');
758
+ if (claimedFingerprint !== invocationFingerprint) {
759
+ throw new Error(`Fingerprint mismatch for runKey=${runKey}`);
760
+ }
761
+ await waitForClaimedRun();
762
+ return buildRunAck(payload, 'existing');
763
+ } finally {
764
+ if (claim !== undefined) fs.closeSync(claim);
765
+ }
766
+
767
+ if (process.env.WINMATRIX_AGENT_ENGINE_TEST_INLINE === 'true') {
768
+ try {
769
+ const owned = await runOwned(payload);
770
+ return owned.ack;
771
+ } catch (error) {
772
+ fs.rmSync(claimFile, { force: true });
773
+ throw error;
774
+ }
775
+ }
776
+
777
+ const worker = spawn(process.execPath, [SCRIPT_PATH, 'supervise-run'], {
778
+ detached: true,
779
+ stdio: ['pipe', 'ignore', 'ignore'],
780
+ env: process.env,
781
+ });
782
+ worker.stdin.end(JSON.stringify(payload));
783
+ worker.unref();
784
+ const deadline = Date.now() + 2_000;
785
+ while (!loadRunMetadata(runKey) && Date.now() < deadline) {
786
+ await new Promise((resolve) => setTimeout(resolve, 20));
787
+ }
788
+ if (!loadRunMetadata(runKey)) {
789
+ fs.rmSync(claimFile, { force: true });
790
+ throw new Error(`Run worker failed to start: ${runKey}`);
791
+ }
792
+ return buildRunAck(payload, 'accepted');
793
+ }
794
+
795
+ function listEvents(runKey, afterSequence = 0) {
796
+ const file = path.join(getRunDir(runKey), 'events.jsonl');
797
+ if (!fs.existsSync(file)) return [];
798
+ return fs.readFileSync(file, 'utf8').split('\n').filter(Boolean)
799
+ .map((line) => JSON.parse(line))
800
+ .filter((event) => event.sequence > afterSequence);
801
+ }
802
+
803
+ /**
804
+ * 构建 Engine argv(不包含 sensitive bindings)
805
+ * @param {object} launchSpec - Launch spec
806
+ * @returns {string[]} argv
807
+ */
808
+ function buildEngineArgv(launchSpec) {
809
+ const supportedEngines = ['claude-code', 'codex', 'hermes', 'openclaw'];
810
+ if (!supportedEngines.includes(launchSpec.engineId)) {
811
+ throw new Error(`Unsupported engineId: ${launchSpec.engineId}`);
812
+ }
813
+ if (process.env.WINMATRIX_AGENT_ENGINE_TEST_HOLD === 'true') {
814
+ const holdMs = Number(process.env.WINMATRIX_AGENT_ENGINE_TEST_HOLD_MS) || 30000;
815
+ return [process.execPath, '-e', `setTimeout(() => process.exit(0), ${holdMs})`];
816
+ }
817
+ const { engineId, model, permissionMode, allowedTools, session, input, timeoutMs, workDir } = launchSpec;
818
+
819
+ const argv = ['node', '/opt/winmatrix/run-engine.mjs', '--engine', engineId];
820
+
821
+ // 添加 model
822
+ if (model) {
823
+ argv.push('--model', model);
824
+ }
825
+
826
+ // 添加 permission-mode
827
+ if (permissionMode) {
828
+ argv.push('--permission-mode', permissionMode);
829
+ }
830
+
831
+ // 添加 allowed-tools
832
+ if (allowedTools && allowedTools.length > 0) {
833
+ argv.push('--allowed-tools', allowedTools.join(','));
834
+ }
835
+
836
+ // 添加 work-dir
837
+ if (workDir) {
838
+ argv.push('--work-dir', workDir);
839
+ }
840
+
841
+ // 添加 session
842
+ if (session) {
843
+ if (session.mode === 'resume' && session.session?.sessionId) {
844
+ argv.push('--resume', session.session.sessionId);
845
+ } else if (session.mode === 'fork' && session.session?.sessionId) {
846
+ argv.push('--fork', session.session.sessionId);
847
+ }
848
+ }
849
+
850
+ // 添加 input(第一个 text block)
851
+ if (input?.blocks) {
852
+ for (const block of input.blocks) {
853
+ if (block.kind === 'text' && block.text) {
854
+ argv.push(block.text);
855
+ break;
856
+ }
857
+ }
858
+ }
859
+
860
+ // 添加 timeout
861
+ if (timeoutMs) {
862
+ const timeoutSec = Math.floor(timeoutMs / 1000);
863
+ argv.push('--timeout', String(timeoutSec));
864
+ }
865
+
866
+ // D11.3: 添加用户 customArgs(过滤 blocked args 后)
867
+ if (launchSpec.engineOptions?.customArgs && Array.isArray(launchSpec.engineOptions.customArgs)) {
868
+ const { filtered, removed } = filterBlockedArgs(engineId, launchSpec.engineOptions.customArgs);
869
+ if (removed.length > 0) {
870
+ const audit = formatBlockedArgsAudit(removed, engineId);
871
+ console.log(`[run-agent-engine] ${audit}`);
872
+ // 记录到审计日志(通过 appendEvent,需要 runKey)
873
+ // 这里只做 console.log,实际审计在 runOwned 中通过 metadata 记录
874
+ }
875
+ argv.push(...filtered);
876
+ }
877
+
878
+ return argv;
879
+ }
880
+
881
+ /**
882
+ * 查询运行状态
883
+ * @param {string} runKey - Run key
884
+ * @returns {object} Run status
885
+ */
886
+ function status(runKey) {
887
+ const metadata = loadRunMetadata(runKey);
888
+ if (!metadata) {
889
+ throw new Error(`Run not found: ${runKey}`);
890
+ }
891
+
892
+ const result = loadResult(runKey);
893
+ const cancelled = hasCancelMarker(runKey);
894
+
895
+ return {
896
+ ref: {
897
+ instance: { instanceId: metadata.instanceId, hostKind: 'workstation' },
898
+ executionSnapshot: {},
899
+ runKey: metadata.runKey,
900
+ engineId: metadata.engineId,
901
+ invocationFingerprint: metadata.invocationFingerprint,
902
+ },
903
+ status: cancelled ? 'cancelled' : metadata.status,
904
+ executionDurability: 'host_persistent',
905
+ eventStreamMode: 'replay',
906
+ eventRetention: { maxEvents: 10000 },
907
+ lastSequence: Number(metadata.lastSequence ?? 0),
908
+ pendingInteractions: [],
909
+ ...(result ? { result } : {}),
910
+ recoverable: true,
911
+ runtimeVersion: 'run-agent-engine/1',
912
+ protocolVersion: '1',
913
+ };
914
+ }
915
+
916
+ /**
917
+ * 取消运行
918
+ * @param {string} runKey - Run key
919
+ * @returns {object} Cancel result
920
+ */
921
+ function cancel(runKey) {
922
+ const metadata = loadRunMetadata(runKey);
923
+ if (!metadata) {
924
+ throw new Error(`Run not found: ${runKey}`);
925
+ }
926
+
927
+ if (metadata.status !== 'running') {
928
+ return { success: false, message: 'Run is not running' };
929
+ }
930
+
931
+ // 创建 cancel marker
932
+ createCancelMarker(runKey);
933
+
934
+ // D11.2: 两阶段优雅关闭(与 cancel poll 使用相同逻辑)
935
+ const activeRun = activeRuns.get(runKey);
936
+ if (activeRun && activeRun.child && activeRun.child.exitCode === null) {
937
+ const driverConfig = getEngineDriverConfig(activeRun.metadata.engineId);
938
+
939
+ // Phase 1: stdin.Close() → 让 Engine 自行写状态文件并退出
940
+ console.log(`[run-agent-engine] D11.2: Cancel Phase 1 for ${runKey} (stdin.close)`);
941
+ try { activeRun.child.stdin.end(); } catch { /* 可能已关闭 */ }
942
+
943
+ // Phase 1 超时后 Phase 2
944
+ setTimeout(() => {
945
+ if (!activeRuns.has(runKey)) return;
946
+ const child = activeRun.child;
947
+ if (!child || child.exitCode !== null) return;
948
+
949
+ console.log(`[run-agent-engine] D11.2: Cancel Phase 2 for ${runKey} (SIGTERM)`);
950
+ try {
951
+ if (process.platform !== 'win32' && child.pid) {
952
+ try { process.kill(-child.pid, 'SIGTERM'); } catch { /* 进程组可能不存在 */ }
953
+ } else {
954
+ child.kill('SIGTERM');
955
+ }
956
+ } catch { /* 进程可能已退出 */ }
957
+
958
+ // Phase 2 超时后 Phase 3
959
+ setTimeout(() => {
960
+ if (!activeRuns.has(runKey)) return;
961
+ if (!child || child.exitCode !== null) return;
962
+
963
+ console.log(`[run-agent-engine] D11.2: Cancel Phase 3 for ${runKey} (SIGKILL)`);
964
+ try {
965
+ if (process.platform !== 'win32' && child.pid) {
966
+ try { process.kill(-child.pid, 'SIGKILL'); } catch { /* 进程组可能不存在 */ }
967
+ } else {
968
+ child.kill('SIGKILL');
969
+ }
970
+ } catch { /* 进程可能已退出 */ }
971
+ }, driverConfig.phase2TimeoutMs);
972
+ }, driverConfig.gracefulShutdownTimeoutMs);
973
+ }
974
+
975
+ return { success: true, message: 'Cancel signal sent' };
976
+ }
977
+
978
+ /**
979
+ * 列出活跃运行
980
+ * @returns {object[]} Active runs
981
+ */
982
+ function listActive() {
983
+ const runs = [];
984
+ for (const [runKey, { metadata }] of activeRuns.entries()) {
985
+ const current = loadRunMetadata(runKey) ?? metadata;
986
+ if (!['running', 'starting', 'accepted', 'waiting_interaction', 'cancel_requested'].includes(current.status)) continue;
987
+ runs.push({
988
+ ref: {
989
+ instance: { instanceId: current.instanceId, hostKind: 'workstation' },
990
+ executionSnapshot: {},
991
+ runKey: current.runKey,
992
+ engineId: current.engineId,
993
+ invocationFingerprint: current.invocationFingerprint,
994
+ },
995
+ status: current.status,
996
+ startedAt: current.startTime,
997
+ lastSequence: Number(current.lastSequence ?? 0),
998
+ pendingInteractionCount: 0,
999
+ });
1000
+ }
1001
+ return { runs };
1002
+ }
1003
+
1004
+ /**
1005
+ * 健康检查
1006
+ * @returns {object} Health status
1007
+ */
1008
+ function health() {
1009
+ return {
1010
+ status: 'ok',
1011
+ activeRuns: activeRuns.size,
1012
+ supervisorRoot: SUPERVISOR_ROOT,
1013
+ };
1014
+ }
1015
+
1016
+ /* ── Engine 原生 Session 查询子命令(task 7.15) ──
1017
+ *
1018
+ * 这 4 个子命令是无状态、只读(delete 幂等)的 argv-native 入口,
1019
+ * 与 launch/status/cancel/list-active 的耐久性监管正交。
1020
+ * 输出 = protocol DTO JSON(EngineSessionPage / EngineSessionDetail +
1021
+ * metadata / EngineSessionSearchPage / EngineSessionDeleteReceipt)。
1022
+ * Host 透传 instanceId/engineId/sessionId,CLI 仅补充 source/summary 等元数据。
1023
+ *
1024
+ * argv 契约(design §1734-1737):
1025
+ * session-list --engine <id> --limit <n> [--cursor <c>] [--project-id <pid>] [--normalized-work-dir <dir>]
1026
+ * session-get --engine <id> --session-id <sid> --limit <n> [--cursor <c>]
1027
+ * [--include-raw-messages] [--include-system] [--no-truncate] [--normalized-work-dir <dir>]
1028
+ * session-search --engine <id> --query <text> --limit <n> [--cursor <c>] [--project-id <pid>] [--normalized-work-dir <dir>]
1029
+ * session-delete --engine <id> --session-id <sid> --request-id <rid>
1030
+ */
1031
+
1032
+ /** instanceId / hostKind 由调用方(Sandbox API)通过环境变量注入(同一 Pod 内)。 */
1033
+ const SESSION_INSTANCE_ID = process.env.WINMATRIX_AGENT_ENGINE_INSTANCE_ID || '';
1034
+ const SESSION_HOST_KIND = 'workstation';
1035
+
1036
+ function redactSessionText(value) {
1037
+ let redacted = false;
1038
+ let text = String(value ?? '');
1039
+ const patterns = [
1040
+ /\b(Bearer\s+)[A-Za-z0-9._~+\/-]+=*/gi,
1041
+ /\b(sk-[A-Za-z0-9_-]{8,})\b/g,
1042
+ /\b(password|passwd|token|secret|api[_-]?key)\s*[:=]\s*([^\s,;]+)/gi,
1043
+ ];
1044
+ text = text.replace(patterns[0], (_match, prefix) => { redacted = true; return `${prefix}[REDACTED]`; });
1045
+ text = text.replace(patterns[1], () => { redacted = true; return '[REDACTED]'; });
1046
+ text = text.replace(patterns[2], (_match, key) => { redacted = true; return `${key}=[REDACTED]`; });
1047
+ return { text, redacted };
1048
+ }
1049
+
1050
+ function deepRedactSessionValue(value, key = '') {
1051
+ if (/password|passwd|token|secret|api[_-]?key/i.test(key)) return '[REDACTED]';
1052
+ if (typeof value === 'string') return redactSessionText(value).text;
1053
+ if (Array.isArray(value)) return value.map((item) => deepRedactSessionValue(item));
1054
+ if (value && typeof value === 'object') {
1055
+ return Object.fromEntries(Object.entries(value).map(([childKey, child]) => [
1056
+ childKey, deepRedactSessionValue(child, childKey),
1057
+ ]));
1058
+ }
1059
+ return value;
1060
+ }
1061
+
1062
+ /**
1063
+ * 把 SDK 宽松 session 记录适配为结构化 EngineSessionSummary(含 ref)。
1064
+ * instanceId/engineId/sessionId 由 Host 决定,CLI 不覆盖。
1065
+ * @param {object} meta buildSessionMeta 产出的元数据
1066
+ * @param {string} engineId
1067
+ */
1068
+ function sessionSummary(meta, engineId) {
1069
+ const sessionId = meta.id != null ? String(meta.id) : '';
1070
+ const title =
1071
+ meta.customTitle != null ? meta.customTitle
1072
+ : meta.summary != null ? meta.summary
1073
+ : meta.firstPrompt != null ? meta.firstPrompt
1074
+ : undefined;
1075
+ const workDir = meta.cwd ?? meta.projectPath;
1076
+
1077
+ /** @type {Record<string, unknown>} */
1078
+ const summary = {
1079
+ ref: {
1080
+ instance: { instanceId: SESSION_INSTANCE_ID, hostKind: SESSION_HOST_KIND },
1081
+ engineId,
1082
+ sessionId,
1083
+ },
1084
+ source: 'engine_native',
1085
+ };
1086
+ if (title !== undefined) summary.title = title;
1087
+ if (meta.projectPath !== undefined) summary.projectId = undefined; // protocol projectId 为产品项目 id;此处不暴露
1088
+ if (workDir !== undefined) summary.workDir = workDir;
1089
+ if (meta.createdAt !== undefined) summary.createdAt = meta.createdAt;
1090
+ if (meta.lastActivityAt !== undefined) summary.lastActivityAt = meta.lastActivityAt;
1091
+ if (meta.messageCount !== undefined) summary.messageCount = meta.messageCount;
1092
+ return summary;
1093
+ }
1094
+
1095
+ /**
1096
+ * session-list:SDK listSessions({dir?}) → EngineSessionPage。
1097
+ * dir = --normalized-work-dir(语义=SDK 的 dir=projectPath,非 HOME)。
1098
+ */
1099
+ async function sessionList() {
1100
+ const argv = process.argv;
1101
+ const engineId = getOptValue(argv, '--engine') || 'claude-code';
1102
+ const limit = parseLimit(argv, 80, 200);
1103
+ const cursor = getOptValue(argv, '--cursor');
1104
+ const projectId = getOptValue(argv, '--project-id');
1105
+ const normalizedWorkDir = getOptValue(argv, '--normalized-work-dir');
1106
+ const offset = decodeOffsetCursor(cursor);
1107
+
1108
+ const { sdk, error } = loadSdk();
1109
+ if (!sdk || typeof sdk.listSessions !== 'function') {
1110
+ return { sessions: [], error: error ?? 'SDK 不支持 listSessions' };
1111
+ }
1112
+
1113
+ /** @type {Record<string, unknown>} */
1114
+ const opts = { limit: Math.min(400, limit * 2 + 20) };
1115
+ if (normalizedWorkDir) opts.dir = normalizedWorkDir;
1116
+
1117
+ const list = await sdk.listSessions(opts);
1118
+ const arr = Array.isArray(list) ? list : [];
1119
+ const sorted = [...arr].sort(
1120
+ (a, b) =>
1121
+ lastActivityMs(/** @type {Record<string, unknown>} */ (b)) -
1122
+ lastActivityMs(/** @type {Record<string, unknown>} */ (a)),
1123
+ );
1124
+ const sessions = sorted
1125
+ .slice(offset, offset + limit)
1126
+ .map((s) => sessionSummary(buildSessionMeta(/** @type {Record<string, unknown>} */ (s)), engineId));
1127
+ const result = { sessions };
1128
+ if (offset + limit < sorted.length) result.nextCursor = encodeOffsetCursor(offset + limit);
1129
+ return result;
1130
+ }
1131
+
1132
+ function probeEngine(engineId, instanceId = 'unknown') {
1133
+ const command = engineId === 'claude-code' ? 'claude' : engineId;
1134
+ if (!['claude-code', 'codex', 'hermes'].includes(engineId)) {
1135
+ return {
1136
+ engineId, instanceId, hostKind: 'workstation', checkedAt: new Date().toISOString(),
1137
+ status: 'unavailable', binaryStatus: 'missing', credentialStatus: 'unknown',
1138
+ issues: [{ code: 'unsupported_engine', message: `Unsupported engine: ${engineId}` }],
1139
+ };
1140
+ }
1141
+ const result = spawnSync(command, ['--version'], { encoding: 'utf8', timeout: 5000 });
1142
+ const found = !result.error || result.error.code !== 'ENOENT';
1143
+ return {
1144
+ engineId, instanceId, hostKind: 'workstation', checkedAt: new Date().toISOString(),
1145
+ status: found ? 'ready' : 'unavailable',
1146
+ binaryStatus: found ? 'found' : 'missing',
1147
+ credentialStatus: 'unknown',
1148
+ ...(found ? { version: String(result.stdout || result.stderr || '').trim() || undefined } : {}),
1149
+ issues: found ? [] : [{ code: 'binary_missing', message: `${command} binary not found` }],
1150
+ };
1151
+ }
1152
+
1153
+ function listEngines() {
1154
+ const engines = ['claude-code', 'codex', 'hermes'].map((engineId) => {
1155
+ const readiness = probeEngine(engineId);
1156
+ return {
1157
+ engineId,
1158
+ availability: readiness.status === 'ready' ? 'ready' : 'unavailable',
1159
+ ...(readiness.version ? { version: readiness.version } : {}),
1160
+ };
1161
+ });
1162
+ return { engines };
1163
+ }
1164
+
1165
+ function listModels() {
1166
+ // CLI 没有稳定的离线模型发现命令时返回空目录,不用静态列表伪造实例能力。
1167
+ return { models: [] };
1168
+ }
1169
+
1170
+ function engineOptions(engineId) {
1171
+ return {
1172
+ engineId,
1173
+ modes: [],
1174
+ configOptions: [],
1175
+ commands: [],
1176
+ checkedAt: new Date().toISOString(),
1177
+ };
1178
+ }
1179
+
1180
+ /**
1181
+ * session-get:SDK getSessionInfo + getSessionMessages → EngineSessionDetail + metadata。
1182
+ * metadata 透传富诊断(diagnostics/cliReplayGuide/userPrompts/assistantText/...)。
1183
+ */
1184
+ async function sessionGet() {
1185
+ const argv = process.argv;
1186
+ const engineId = getOptValue(argv, '--engine') || 'claude-code';
1187
+ const sessionId = getOptValue(argv, '--session-id');
1188
+ if (!sessionId) throw new Error('session-get: --session-id 必填');
1189
+ assertSafeSessionId(sessionId);
1190
+
1191
+ const limit = clampInt(getOptValue(argv, '--limit') ?? '', 100, 1, 1000);
1192
+ const cursor = getOptValue(argv, '--cursor');
1193
+ const offset = decodeOffsetCursor(cursor);
1194
+ const includeRawMessages = hasFlag(argv, '--include-raw-messages');
1195
+ const includeSystem = hasFlag(argv, '--include-system');
1196
+ const noTruncate = hasFlag(argv, '--no-truncate');
1197
+ const normalizedWorkDir = getOptValue(argv, '--normalized-work-dir');
1198
+
1199
+ const { sdk, error } = loadSdk();
1200
+ if (!sdk || typeof sdk.getSessionInfo !== 'function' || typeof sdk.getSessionMessages !== 'function') {
1201
+ return {
1202
+ session: null,
1203
+ messages: [],
1204
+ rawMessagesIncluded: false,
1205
+ metadata: { error: error ?? 'SDK 不支持 getSessionInfo/getSessionMessages' },
1206
+ };
1207
+ }
1208
+
1209
+ /** @type {Record<string, unknown>} */
1210
+ const infoOpts = {};
1211
+ if (normalizedWorkDir) infoOpts.dir = normalizedWorkDir;
1212
+
1213
+ /** @type {Record<string, unknown>} */
1214
+ const msgOpts = { limit, offset, includeSystemMessages: includeSystem };
1215
+ if (normalizedWorkDir) msgOpts.dir = normalizedWorkDir;
1216
+
1217
+ const info = await sdk.getSessionInfo(sessionId, infoOpts);
1218
+ if (!info) {
1219
+ return {
1220
+ session: null,
1221
+ messages: [],
1222
+ rawMessagesIncluded: false,
1223
+ metadata: { error: `未找到 sessionId=${sessionId}` },
1224
+ };
1225
+ }
1226
+
1227
+ const sessionMeta = buildSessionMeta(/** @type {Record<string, unknown>} */ (info));
1228
+
1229
+ // 未显式传 dir 时用 session 元数据路径补齐(与旧 detail mjs 行为一致)
1230
+ /** @type {'cli' | 'sessionMeta' | 'listSessionsGlobal' | undefined} */
1231
+ let dirSource;
1232
+ if (normalizedWorkDir) {
1233
+ dirSource = 'cli';
1234
+ } else {
1235
+ const autoDir = sessionMeta.projectPath || sessionMeta.cwd;
1236
+ if (autoDir) {
1237
+ msgOpts.dir = autoDir;
1238
+ dirSource = 'sessionMeta';
1239
+ }
1240
+ }
1241
+
1242
+ /** @type {unknown[]} */
1243
+ let messages = [];
1244
+ try {
1245
+ const raw = await sdk.getSessionMessages(sessionId, msgOpts);
1246
+ messages = Array.isArray(raw) ? raw : [];
1247
+ // 三级 dir 回退:sessionMeta 路径取不到时,用 listSessions 全局列表里的 cwd 再试
1248
+ if (messages.length === 0 && typeof sdk.listSessions === 'function') {
1249
+ const fromList = await resolveDirViaGlobalListSessions(sdk, sessionId);
1250
+ const tried = msgOpts.dir ? String(msgOpts.dir).replace(/\/+$/, '') : '';
1251
+ if (fromList && fromList !== tried) {
1252
+ msgOpts.dir = fromList;
1253
+ const raw2 = await sdk.getSessionMessages(sessionId, msgOpts);
1254
+ messages = Array.isArray(raw2) ? raw2 : [];
1255
+ if (messages.length > 0) dirSource = 'listSessionsGlobal';
1256
+ } else if (fromList && !tried) {
1257
+ msgOpts.dir = fromList;
1258
+ const raw3 = await sdk.getSessionMessages(sessionId, msgOpts);
1259
+ messages = Array.isArray(raw3) ? raw3 : [];
1260
+ if (messages.length > 0) dirSource = 'listSessionsGlobal';
1261
+ }
1262
+ }
1263
+ } catch (e) {
1264
+ return {
1265
+ session: sessionSummary(sessionMeta, engineId),
1266
+ messages: [],
1267
+ rawMessagesIncluded: false,
1268
+ metadata: {
1269
+ sessionRaw: sessionMeta,
1270
+ error: `getSessionMessages 失败: ${e.message}`,
1271
+ sessionMessagesDirSource: dirSource,
1272
+ },
1273
+ };
1274
+ }
1275
+
1276
+ // 结构化 messages(user/assistant/system 文本块)
1277
+ /** @type {Array<{role: string; content: Array<{kind:'text'; text:string}>; redacted: boolean}>} */
1278
+ const structuredMessages = [];
1279
+ const userPrompts = [];
1280
+ const assistantTexts = [];
1281
+ let lastAssistantText = '';
1282
+ let anyTruncated = false;
1283
+
1284
+ for (const m of messages) {
1285
+ if (m == null || typeof m !== 'object') continue;
1286
+ const rec = /** @type {Record<string, unknown>} */ (m);
1287
+ const type = String(rec.type ?? '');
1288
+ const text = extractText(rec.message);
1289
+ const { text: shownBeforeRedaction, truncated } = maybeTruncate(text, noTruncate);
1290
+ const { text: shown, redacted } = redactSessionText(shownBeforeRedaction);
1291
+ if (truncated) anyTruncated = true;
1292
+ const role = type === 'user' ? 'user' : type === 'assistant' ? 'assistant' : type === 'system' ? 'system' : 'unknown';
1293
+ if (text) {
1294
+ structuredMessages.push({
1295
+ role,
1296
+ content: [{ kind: 'text', text: shown }],
1297
+ redacted,
1298
+ });
1299
+ }
1300
+ if (type === 'user') userPrompts.push(shown);
1301
+ else if (type === 'assistant') {
1302
+ assistantTexts.push(shown);
1303
+ lastAssistantText = shown;
1304
+ }
1305
+ }
1306
+
1307
+ const cliReplayGuide = buildCliReplayGuide(sessionMeta, sessionId);
1308
+ const diagnostics = deepRedactSessionValue(buildDiagnostics(messages, noTruncate));
1309
+
1310
+ /** @type {Record<string, unknown>} */
1311
+ const detail = {
1312
+ session: sessionSummary(sessionMeta, engineId),
1313
+ messages: structuredMessages,
1314
+ rawMessagesIncluded: includeRawMessages,
1315
+ };
1316
+
1317
+ /** @type {Record<string, unknown>} */
1318
+ const metadata = {
1319
+ messageCount: messages.length,
1320
+ truncated: anyTruncated,
1321
+ userPrompts,
1322
+ assistantText: assistantTexts.join('\n\n'),
1323
+ lastAssistantText,
1324
+ diagnostics,
1325
+ cliReplayGuide,
1326
+ sessionMessagesProjectDir: msgOpts.dir != null ? String(msgOpts.dir) : undefined,
1327
+ sessionMessagesDirSource: dirSource,
1328
+ };
1329
+
1330
+ if (includeRawMessages) {
1331
+ const { list, ok } = cloneMessagesForJson(messages);
1332
+ metadata.rawMessages = deepRedactSessionValue(list);
1333
+ if (!ok && messages.length > 0) metadata.rawMessagesSerializationFailed = true;
1334
+ }
1335
+
1336
+ return { ...detail, metadata };
1337
+ }
1338
+
1339
+ /**
1340
+ * session-search:SDK 无原生搜索,用 listSessions 内存过滤。
1341
+ * 命中 summary/customTitle/firstPrompt/cwd/projectName/projectPath/gitBranch/tag(大小写不敏感)。
1342
+ */
1343
+ async function sessionSearch() {
1344
+ const argv = process.argv;
1345
+ const engineId = getOptValue(argv, '--engine') || 'claude-code';
1346
+ const text = getOptValue(argv, '--query');
1347
+ if (!text) throw new Error('session-search: --query 必填');
1348
+ const limit = parseLimit(argv, 50, 200);
1349
+ const cursor = getOptValue(argv, '--cursor');
1350
+ const offset = decodeOffsetCursor(cursor);
1351
+ const normalizedWorkDir = getOptValue(argv, '--normalized-work-dir');
1352
+
1353
+ const { sdk, error } = loadSdk();
1354
+ if (!sdk || typeof sdk.listSessions !== 'function') {
1355
+ return { hits: [], error: error ?? 'SDK 不支持 listSessions' };
1356
+ }
1357
+
1358
+ /** @type {Record<string, unknown>} */
1359
+ const opts = { limit: 800 };
1360
+ if (normalizedWorkDir) opts.dir = normalizedWorkDir;
1361
+
1362
+ const list = await sdk.listSessions(opts);
1363
+ const arr = Array.isArray(list) ? list : [];
1364
+ const needle = text.toLowerCase();
1365
+ const matched = [];
1366
+ for (const raw of arr) {
1367
+ const meta = buildSessionMeta(/** @type {Record<string, unknown>} */ (raw));
1368
+ const fields = [
1369
+ meta.summary, meta.customTitle, meta.firstPrompt, meta.cwd,
1370
+ meta.projectName, meta.projectPath, meta.gitBranch, meta.tag,
1371
+ ];
1372
+ const hitField = fields.find((f) => typeof f === 'string' && f.toLowerCase().includes(needle));
1373
+ if (hitField !== undefined) {
1374
+ const summary = sessionSummary(meta, engineId);
1375
+ const snippet = String(hitField);
1376
+ matched.push({
1377
+ session: summary,
1378
+ matches: [{ snippet: snippet.length > 200 ? snippet.slice(0, 200) + '…' : snippet }],
1379
+ });
1380
+ }
1381
+ }
1382
+ const start = Math.min(offset, matched.length);
1383
+ const hits = matched.slice(start, start + limit);
1384
+ const nextOffset = start + hits.length;
1385
+ const result = { hits };
1386
+ if (nextOffset < matched.length) {
1387
+ result.nextCursor = encodeOffsetCursor(nextOffset);
1388
+ }
1389
+ return result;
1390
+ }
1391
+
1392
+ /**
1393
+ * session-delete:优先 SDK deleteSession,兜底删 jsonl 文件(幂等 already_deleted)。
1394
+ */
1395
+ async function sessionDelete() {
1396
+ const argv = process.argv;
1397
+ const sessionId = getOptValue(argv, '--session-id');
1398
+ if (!sessionId) throw new Error('session-delete: --session-id 必填');
1399
+ assertSafeSessionId(sessionId);
1400
+ const requestId = getOptValue(argv, '--request-id');
1401
+ if (!requestId) throw new Error('session-delete: --request-id 必填');
1402
+
1403
+ const { sdk } = loadSdk();
1404
+
1405
+ // 优先 SDK 原生删除(若可用)
1406
+ if (sdk && typeof sdk.deleteSession === 'function') {
1407
+ try {
1408
+ const res = await sdk.deleteSession(sessionId, { requestId });
1409
+ if (res && typeof res === 'object' && 'status' in res) {
1410
+ const status = /** @type {{ status?: string }} */ (res).status;
1411
+ if (status === 'deleted' || status === 'already_deleted' || status === 'rejected') {
1412
+ return { status };
1413
+ }
1414
+ }
1415
+ } catch (e) {
1416
+ // SDK 删除失败,回退到文件删除
1417
+ void e;
1418
+ }
1419
+ }
1420
+
1421
+ // 兜底:删除 $HOME/.claude/projects/<encoded-cwd>/<sessionId>.jsonl
1422
+ const home = process.env.HOME?.trim();
1423
+ if (!home) {
1424
+ return {
1425
+ status: 'rejected',
1426
+ error: { code: 'no_home', message: 'session-delete: HOME 未设置,无法定位会话文件', retryable: false },
1427
+ };
1428
+ }
1429
+
1430
+ const projectsDir = path.join(home, '.claude', 'projects');
1431
+ let deleted = false;
1432
+ try {
1433
+ if (fs.existsSync(projectsDir)) {
1434
+ for (const entry of fs.readdirSync(projectsDir)) {
1435
+ const file = path.join(projectsDir, entry, `${sessionId}.jsonl`);
1436
+ if (fs.existsSync(file)) {
1437
+ fs.unlinkSync(file);
1438
+ deleted = true;
1439
+ }
1440
+ }
1441
+ }
1442
+ // best-effort 清理 ~/.claude/sessions/*.json 索引
1443
+ const sessionsDir = path.join(home, '.claude', 'sessions');
1444
+ if (fs.existsSync(sessionsDir)) {
1445
+ for (const entry of fs.readdirSync(sessionsDir)) {
1446
+ if (!entry.endsWith('.json')) continue;
1447
+ const idxFile = path.join(sessionsDir, entry);
1448
+ try {
1449
+ const idx = JSON.parse(fs.readFileSync(idxFile, 'utf8'));
1450
+ if (idx && String(idx.session_id ?? idx.sessionId ?? '') === sessionId) {
1451
+ fs.unlinkSync(idxFile);
1452
+ }
1453
+ } catch {
1454
+ // 索引文件损坏,跳过
1455
+ }
1456
+ }
1457
+ }
1458
+ } catch (e) {
1459
+ return {
1460
+ status: 'rejected',
1461
+ error: { code: 'delete_failed', message: `session-delete: ${e.message}`, retryable: true },
1462
+ };
1463
+ }
1464
+
1465
+ return { status: deleted ? 'deleted' : 'already_deleted' };
1466
+ }
1467
+
1468
+ /**
1469
+ * CLI 入口
1470
+ */
1471
+ async function main() {
1472
+ const command = process.argv[2];
1473
+ const arg = process.argv[3];
1474
+ const fileFlag = process.argv[4];
1475
+
1476
+ if (!command) {
1477
+ console.error('[run-agent-engine] Usage: node run-agent-engine.mjs <command> [args]');
1478
+ console.error('Commands: launch <json> | launch --file <path> | status <runKey> | cancel <runKey> | list-active | health');
1479
+ console.error(' session-list | session-get | session-search | session-delete (task 7.15)');
1480
+ process.exit(1);
1481
+ }
1482
+
1483
+ try {
1484
+ let result;
1485
+ switch (command) {
1486
+ case 'launch': {
1487
+ let payload;
1488
+ if (arg === '--file' && fileFlag) {
1489
+ // 从文件读取 JSON payload
1490
+ const fs = await import('node:fs');
1491
+ const content = fs.readFileSync(fileFlag, 'utf8');
1492
+ payload = JSON.parse(content);
1493
+ } else if (arg === '--stdin') {
1494
+ payload = JSON.parse(await readStdin());
1495
+ } else if (arg) {
1496
+ // 直接解析 JSON 参数
1497
+ payload = JSON.parse(arg);
1498
+ } else {
1499
+ throw new Error('Missing JSON payload for launch');
1500
+ }
1501
+ result = await launch(payload);
1502
+ break;
1503
+ }
1504
+ case 'supervise-run': {
1505
+ const payload = JSON.parse(await readStdin());
1506
+ const owned = await runOwned(payload);
1507
+ await owned.completion;
1508
+ return;
1509
+ }
1510
+ case 'status':
1511
+ if (!arg) {
1512
+ throw new Error('Missing runKey for status');
1513
+ }
1514
+ result = status(arg);
1515
+ break;
1516
+ case 'cancel':
1517
+ if (!arg) {
1518
+ throw new Error('Missing runKey for cancel');
1519
+ }
1520
+ result = cancel(arg);
1521
+ break;
1522
+ case 'list-active':
1523
+ result = listActive();
1524
+ break;
1525
+ case 'health':
1526
+ result = health();
1527
+ break;
1528
+ case 'events':
1529
+ if (!arg) throw new Error('Missing runKey for events');
1530
+ result = listEvents(arg, Number(process.argv[4] ?? 0));
1531
+ break;
1532
+ case 'engine-list':
1533
+ result = listEngines();
1534
+ break;
1535
+ case 'model-list':
1536
+ if (!arg) throw new Error('Missing engineId for model-list');
1537
+ result = listModels(arg);
1538
+ break;
1539
+ case 'engine-options':
1540
+ if (!arg) throw new Error('Missing engineId for engine-options');
1541
+ result = engineOptions(arg);
1542
+ break;
1543
+ case 'probe-engine':
1544
+ if (!arg) throw new Error('Missing engineId for probe-engine');
1545
+ result = probeEngine(arg, process.argv[4]);
1546
+ break;
1547
+ case 'session-list':
1548
+ result = await sessionList();
1549
+ break;
1550
+ case 'session-get':
1551
+ result = await sessionGet();
1552
+ break;
1553
+ case 'session-search':
1554
+ result = await sessionSearch();
1555
+ break;
1556
+ case 'session-delete':
1557
+ result = await sessionDelete();
1558
+ break;
1559
+ default:
1560
+ throw new Error(`Unknown command: ${command}`);
1561
+ }
1562
+
1563
+ console.log(JSON.stringify(result, null, 2));
1564
+ process.exit(0);
1565
+ } catch (error) {
1566
+ console.error('[run-agent-engine] Error:', error.message);
1567
+ process.exit(1);
1568
+ }
1569
+ }
1570
+
1571
+ async function readStdin() {
1572
+ const chunks = [];
1573
+ for await (const chunk of process.stdin) chunks.push(chunk);
1574
+ return Buffer.concat(chunks).toString('utf8');
1575
+ }
1576
+
1577
+ main().catch((error) => {
1578
+ console.error('[run-agent-engine] Fatal error:', error);
1579
+ process.exit(1);
1580
+ });