sessix-server 0.4.9 → 0.5.3

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 (3) hide show
  1. package/dist/index.js +1498 -1110
  2. package/dist/server.js +1498 -1110
  3. package/package.json +1 -1
package/dist/index.js CHANGED
@@ -302,16 +302,18 @@ function t(key, params) {
302
302
  }
303
303
 
304
304
  // src/server.ts
305
- var import_uuid9 = require("uuid");
305
+ var import_uuid8 = require("uuid");
306
306
  var import_promises7 = require("fs/promises");
307
307
  var import_node_os9 = require("os");
308
308
  var import_node_path9 = require("path");
309
309
  var import_node_child_process12 = require("child_process");
310
310
  var import_node_util3 = require("util");
311
+ var import_node_v8 = require("v8");
312
+ var import_node_vm = require("vm");
311
313
 
312
314
  // src/providers/ProcessProvider.ts
313
315
  var import_child_process = require("child_process");
314
- var import_readline = require("readline");
316
+ var import_readline2 = require("readline");
315
317
  var import_events = require("events");
316
318
  var import_node_os2 = require("os");
317
319
  var import_uuid = require("uuid");
@@ -362,12 +364,60 @@ function isNormalExit(code, signal) {
362
364
  }
363
365
 
364
366
  // src/utils/claudePath.ts
367
+ function resolveStable(candidate) {
368
+ if (!candidate) return null;
369
+ try {
370
+ const real = (0, import_node_fs.realpathSync)(candidate.trim());
371
+ (0, import_node_fs.accessSync)(real, import_node_fs.constants.X_OK);
372
+ return real;
373
+ } catch {
374
+ return null;
375
+ }
376
+ }
377
+ function resolveViaLoginShell() {
378
+ if (isWindows) return null;
379
+ const shell = process.env.SHELL || "/bin/zsh";
380
+ try {
381
+ const out = (0, import_node_child_process2.execSync)(`${shell} -ilc 'command -v claude' 2>/dev/null`, {
382
+ encoding: "utf-8",
383
+ timeout: 8e3
384
+ }).trim().split("\n").filter(Boolean).pop();
385
+ return resolveStable(out);
386
+ } catch {
387
+ return null;
388
+ }
389
+ }
390
+ function resolveViaFnm() {
391
+ if (isWindows) return null;
392
+ const base = (0, import_node_path.join)((0, import_node_os.homedir)(), ".fnm", "node-versions");
393
+ try {
394
+ const versions = (0, import_node_fs.readdirSync)(base).filter((v) => /^v?\d+\./.test(v)).sort(
395
+ (a, b) => b.localeCompare(a, void 0, { numeric: true, sensitivity: "base" })
396
+ );
397
+ for (const v of versions) {
398
+ const p = resolveStable((0, import_node_path.join)(base, v, "installation", "bin", "claude"));
399
+ if (p) return p;
400
+ }
401
+ } catch {
402
+ }
403
+ return null;
404
+ }
405
+ var cached = null;
365
406
  function findClaudePath() {
407
+ if (cached) return cached;
408
+ const override = resolveStable(process.env.SESSIX_CLAUDE_PATH);
409
+ if (override) return cached = log(override, "env:SESSIX_CLAUDE_PATH");
366
410
  try {
367
411
  const cmd = isWindows ? "where claude" : "which claude";
368
- return (0, import_node_child_process2.execSync)(cmd, { encoding: "utf-8" }).trim().split("\n")[0];
412
+ const which = (0, import_node_child_process2.execSync)(cmd, { encoding: "utf-8" }).trim().split("\n")[0];
413
+ const stable = resolveStable(which);
414
+ if (stable) return cached = log(stable, "which");
369
415
  } catch {
370
416
  }
417
+ const fnm = resolveViaFnm();
418
+ if (fnm) return cached = log(fnm, "fnm-scan");
419
+ const viaShell = resolveViaLoginShell();
420
+ if (viaShell) return cached = log(viaShell, "login-shell");
371
421
  const candidates = isWindows ? [
372
422
  (0, import_node_path.join)(process.env.LOCALAPPDATA ?? "", "Programs", "claude", "claude.exe"),
373
423
  (0, import_node_path.join)((0, import_node_os.homedir)(), "AppData", "Local", "Programs", "claude", "claude.exe"),
@@ -378,631 +428,936 @@ function findClaudePath() {
378
428
  "/opt/homebrew/bin/claude"
379
429
  ];
380
430
  for (const candidate of candidates) {
381
- try {
382
- (0, import_node_fs.accessSync)(candidate, import_node_fs.constants.X_OK);
383
- return candidate;
384
- } catch {
385
- }
431
+ const stable = resolveStable(candidate);
432
+ if (stable) return cached = log(stable, "candidate");
386
433
  }
387
- return "claude";
434
+ console.warn(
435
+ "[claudePath] \u672A\u80FD\u5B9A\u4F4D claude\uFF0C\u515C\u5E95\u4F7F\u7528\u88F8 'claude'\uFF08PATH \u4E0D\u542B claude \u65F6\u4F1A\u5931\u8D25\uFF09"
436
+ );
437
+ return cached = "claude";
438
+ }
439
+ function log(path2, via) {
440
+ console.log(`[claudePath] \u89E3\u6790\u5230 claude: ${path2} (via ${via})`);
441
+ return path2;
388
442
  }
389
443
 
390
- // src/providers/ProcessProvider.ts
391
- var CLAUDE_PATH = findClaudePath();
392
- var ProcessProvider = class {
393
- /** 活跃会话映射表:sessionId -> { session, process } */
394
- activeSessions = /* @__PURE__ */ new Map();
395
- /** 事件发射器,用于分发 Claude 事件流 */
396
- emitter = new import_events.EventEmitter();
397
- /** 已发射的 AskUserQuestion toolUseId 集合,按会话隔离(避免 partial message 重复触发) */
398
- emittedQuestionToolUseIds = /* @__PURE__ */ new Map();
399
- /**
400
- * 启动新会话或恢复已有会话
401
- *
402
- * 会 spawn 一个 `claude` CLI 进程,设置工作目录和环境变量,
403
- * 并开始监听 stdout NDJSON 输出。
404
- */
405
- async startSession(opts) {
406
- const { projectPath, message, sessionId: existingSessionId, model, permissionMode, effort, images, fallbackModel, maxBudgetUsd } = opts;
407
- const sessionId = existingSessionId ?? (0, import_uuid.v4)();
408
- if (this.activeSessions.has(sessionId)) {
409
- await this.killSession(sessionId);
410
- }
411
- const projectId = projectPath.split("/").filter(Boolean).pop() ?? "unknown";
412
- const session = {
413
- id: sessionId,
414
- projectId,
415
- projectPath,
416
- status: "running",
417
- createdAt: Date.now(),
418
- lastActiveAt: Date.now(),
419
- summary: message.slice(0, 80)
420
- };
421
- const resume = opts.resume ?? !!existingSessionId;
422
- const proc = this.spawnClaudeProcess(sessionId, projectPath, resume, model, permissionMode, effort, fallbackModel, maxBudgetUsd);
423
- this.writeUserMessage(proc, message, sessionId, images);
424
- session.pid = proc.pid;
425
- this.activeSessions.set(sessionId, { session, process: proc, model, permissionMode, effort, fallbackModel, maxBudgetUsd });
426
- proc.on("error", (err) => {
427
- console.error(`[ProcessProvider] Session ${sessionId} process error:`, err.message);
428
- this.activeSessions.delete(sessionId);
429
- const syntheticResult = {
430
- type: "result",
431
- subtype: "error",
432
- result: `Process spawn failed: ${err.message}`,
433
- session_id: sessionId,
434
- duration_ms: 0,
435
- is_error: true,
436
- num_turns: 0
437
- };
438
- this.emitter.emit(this.getEventName(sessionId), syntheticResult);
444
+ // src/session/ProjectReader.ts
445
+ var import_promises = require("fs/promises");
446
+ var import_readline = require("readline");
447
+ var import_path = require("path");
448
+ var import_os = require("os");
449
+ var CLAUDE_PROJECTS_DIR = (0, import_path.join)((0, import_os.homedir)(), ".claude", "projects");
450
+ function getSessionFilePath(projectPath, sessionId) {
451
+ return (0, import_path.join)(CLAUDE_PROJECTS_DIR, encodeDirName(projectPath), `${sessionId}.jsonl`);
452
+ }
453
+ async function getSessionModel(projectPath, sessionId) {
454
+ const filePath = getSessionFilePath(projectPath, sessionId);
455
+ let fileHandle;
456
+ try {
457
+ fileHandle = await (0, import_promises.open)(filePath, "r");
458
+ const rl = (0, import_readline.createInterface)({
459
+ input: fileHandle.createReadStream({ encoding: "utf-8" }),
460
+ crlfDelay: Infinity
439
461
  });
440
- this.attachStdoutListener(sessionId, proc);
441
- this.attachStderrListener(sessionId, proc);
442
- this.attachExitListener(sessionId, proc);
443
- return session;
444
- }
445
- /**
446
- * 终止指定会话
447
- *
448
- * kill 进程并从活跃映射中移除。
449
- */
450
- async killSession(sessionId) {
451
- const entry = this.activeSessions.get(sessionId);
452
- if (!entry) {
453
- return;
454
- }
455
- if (entry.process.exitCode === null && entry.process.signalCode === null) {
462
+ let lastModel;
463
+ for await (const line of rl) {
464
+ if (!line.trim()) continue;
456
465
  try {
457
- entry.process.stdin?.end();
466
+ const obj = JSON.parse(line);
467
+ if (obj.type !== "assistant" || !obj.message) continue;
468
+ const model = obj.message.model;
469
+ if (typeof model === "string" && model && model !== "unknown") {
470
+ lastModel = model;
471
+ }
458
472
  } catch {
459
473
  }
460
- await killProcessCrossPlatform(entry.process);
461
474
  }
462
- this.emittedQuestionToolUseIds.delete(sessionId);
463
- this.activeSessions.delete(sessionId);
475
+ return lastModel;
476
+ } catch (err) {
477
+ if (err.code === "ENOENT") return void 0;
478
+ throw err;
479
+ } finally {
480
+ await fileHandle?.close();
464
481
  }
465
- /**
466
- * 向已有会话发送新消息
467
- *
468
- * 快速路径:进程存活时直接写 stdin(毫秒级响应)。
469
- * 慢速路径:进程已退出时 respawn 并 --resume。
470
- */
471
- async sendMessage(sessionId, message, permissionMode, images) {
472
- const entry = this.activeSessions.get(sessionId);
473
- if (!entry) {
474
- throw new Error(`Session ${sessionId} not found or already ended`);
482
+ }
483
+ async function getProjects() {
484
+ try {
485
+ const dirExists = await directoryExists(CLAUDE_PROJECTS_DIR);
486
+ if (!dirExists) {
487
+ return { ok: true, value: [] };
475
488
  }
476
- const modeChanged = permissionMode != null && permissionMode !== (entry.permissionMode ?? "default");
477
- if (!modeChanged && entry.process.exitCode === null && entry.process.signalCode === null && !entry.process.stdin?.destroyed) {
478
- entry.session.status = "running";
479
- entry.session.lastActiveAt = Date.now();
480
- this.writeUserMessage(entry.process, message, sessionId, images);
481
- return;
489
+ const entries = await (0, import_promises.readdir)(CLAUDE_PROJECTS_DIR, { withFileTypes: true });
490
+ const projects = [];
491
+ for (const entry of entries) {
492
+ if (!entry.isDirectory() || entry.name.startsWith(".")) {
493
+ continue;
494
+ }
495
+ const encodedPath = entry.name;
496
+ const decodedPath = decodeDirName(encodedPath);
497
+ const name = decodedPath.split("/").filter(Boolean).pop() ?? encodedPath;
498
+ const projectDir = (0, import_path.join)(CLAUDE_PROJECTS_DIR, encodedPath);
499
+ const { count: sessionCount, latestMtime } = await countJsonlFilesWithMtime(projectDir);
500
+ projects.push({
501
+ id: encodedPath,
502
+ path: decodedPath,
503
+ name,
504
+ sessionCount,
505
+ lastActiveAt: latestMtime
506
+ });
482
507
  }
483
- if (modeChanged) {
484
- console.log(`[ProcessProvider] Session ${sessionId}: permission mode change ${entry.permissionMode ?? "default"} \u2192 ${permissionMode}, respawn`);
485
- if (entry.process.exitCode === null && entry.process.signalCode === null) {
508
+ projects.sort((a, b) => a.name.localeCompare(b.name));
509
+ return { ok: true, value: projects };
510
+ } catch (err) {
511
+ return {
512
+ ok: false,
513
+ error: err instanceof Error ? err : new Error(String(err))
514
+ };
515
+ }
516
+ }
517
+ async function getHistoricalSessions(projectPath) {
518
+ try {
519
+ const encodedPath = encodeDirName(projectPath);
520
+ const projectDir = (0, import_path.join)(CLAUDE_PROJECTS_DIR, encodedPath);
521
+ const dirExists = await directoryExists(projectDir);
522
+ if (!dirExists) {
523
+ return { ok: true, value: [] };
524
+ }
525
+ const entries = await (0, import_promises.readdir)(projectDir, { withFileTypes: true });
526
+ const jsonlFiles = entries.filter((e) => e.isFile() && e.name.endsWith(".jsonl"));
527
+ const mtimeMap = /* @__PURE__ */ new Map();
528
+ await Promise.all(
529
+ jsonlFiles.map(async (entry) => {
530
+ const sessionId = entry.name.slice(0, -6);
531
+ const filePath = (0, import_path.join)(projectDir, entry.name);
486
532
  try {
487
- entry.process.stdin?.end();
533
+ const contentTs = await extractLastTimestamp(filePath);
534
+ if (contentTs) {
535
+ mtimeMap.set(sessionId, contentTs);
536
+ } else {
537
+ const fileStat = await (0, import_promises.stat)(filePath);
538
+ mtimeMap.set(sessionId, fileStat.mtimeMs);
539
+ }
488
540
  } catch {
541
+ mtimeMap.set(sessionId, 0);
489
542
  }
490
- killProcessCrossPlatform(entry.process);
543
+ })
544
+ );
545
+ const uuidDirs = entries.filter(
546
+ (e) => e.isDirectory() && UUID_RE.test(e.name) && !mtimeMap.has(e.name)
547
+ );
548
+ for (const entry of uuidDirs) {
549
+ try {
550
+ const fileStat = await (0, import_promises.stat)((0, import_path.join)(projectDir, entry.name));
551
+ mtimeMap.set(entry.name, fileStat.mtimeMs);
552
+ } catch {
553
+ mtimeMap.set(entry.name, 0);
491
554
  }
492
- } else {
493
- console.log(`[ProcessProvider] Session ${sessionId}: process exited, respawning`);
494
555
  }
495
- const savedPendingQuestion = entry.pendingQuestion;
496
- const newMode = permissionMode ?? entry.permissionMode;
497
- const proc = this.spawnClaudeProcess(sessionId, entry.session.projectPath, true, entry.model, newMode, entry.effort, entry.fallbackModel, entry.maxBudgetUsd);
498
- this.writeUserMessage(proc, message, sessionId, images);
499
- entry.session.status = "running";
500
- entry.session.lastActiveAt = Date.now();
501
- entry.session.pid = proc.pid;
502
- entry.process = proc;
503
- entry.permissionMode = newMode;
504
- entry.pendingQuestion = savedPendingQuestion;
505
- proc.on("error", (err) => {
506
- console.error(`[ProcessProvider] Session ${sessionId} sendMessage process error:`, err.message);
507
- this.activeSessions.delete(sessionId);
508
- const syntheticResult = {
509
- type: "result",
510
- subtype: "error",
511
- result: `Failed to send message: ${err.message}`,
512
- session_id: sessionId,
513
- duration_ms: 0,
514
- is_error: true,
515
- num_turns: 0
516
- };
517
- this.emitter.emit(this.getEventName(sessionId), syntheticResult);
556
+ const indexPath = (0, import_path.join)(projectDir, "sessions-index.json");
557
+ const sessionMap = /* @__PURE__ */ new Map();
558
+ try {
559
+ const indexContent = await (0, import_promises.readFile)(indexPath, "utf-8");
560
+ const indexData = JSON.parse(indexContent);
561
+ if (indexData.version === 1 && Array.isArray(indexData.entries)) {
562
+ for (const entry of indexData.entries) {
563
+ const mtime = mtimeMap.get(entry.sessionId) ?? entry.fileMtime ?? (entry.modified ? new Date(entry.modified).getTime() : 0);
564
+ sessionMap.set(entry.sessionId, {
565
+ sessionId: entry.sessionId,
566
+ lastModified: mtime,
567
+ summary: entry.summary,
568
+ firstPrompt: entry.firstPrompt,
569
+ messageCount: entry.messageCount
570
+ });
571
+ }
572
+ await Promise.all(
573
+ Array.from(sessionMap.values()).filter((s) => (s.messageCount ?? 0) > 0 && !s.summary && !s.firstPrompt).map(async (s) => {
574
+ const filePath = (0, import_path.join)(projectDir, `${s.sessionId}.jsonl`);
575
+ const firstPrompt = await extractFirstPrompt(filePath).catch(() => void 0);
576
+ if (firstPrompt) s.firstPrompt = firstPrompt;
577
+ })
578
+ );
579
+ }
580
+ } catch {
581
+ }
582
+ const uuidDirSet = new Set(uuidDirs.map((e) => e.name));
583
+ for (const [sessionId, mtime] of mtimeMap) {
584
+ if (!sessionMap.has(sessionId)) {
585
+ if (uuidDirSet.has(sessionId)) {
586
+ sessionMap.set(sessionId, { sessionId, lastModified: mtime, messageCount: -1 });
587
+ } else {
588
+ const filePath = (0, import_path.join)(projectDir, `${sessionId}.jsonl`);
589
+ const firstPrompt = await extractFirstPrompt(filePath).catch(() => void 0);
590
+ sessionMap.set(sessionId, { sessionId, lastModified: mtime, firstPrompt });
591
+ }
592
+ }
593
+ }
594
+ const sessions = Array.from(sessionMap.values()).filter((s) => {
595
+ if (s.messageCount === 0) return false;
596
+ if (s.messageCount === -1) return true;
597
+ if (s.firstPrompt === void 0 && s.messageCount === void 0) return false;
598
+ return true;
518
599
  });
519
- this.attachStdoutListener(sessionId, proc);
520
- this.attachStderrListener(sessionId, proc);
521
- this.attachExitListener(sessionId, proc);
522
- }
523
- /**
524
- * 订阅指定会话的 Claude 事件流
525
- *
526
- * @returns 取消订阅函数
527
- */
528
- onEvent(sessionId, callback) {
529
- const eventName = this.getEventName(sessionId);
530
- this.emitter.on(eventName, callback);
531
- return () => {
532
- this.emitter.off(eventName, callback);
600
+ sessions.sort((a, b) => b.lastModified - a.lastModified);
601
+ return { ok: true, value: sessions };
602
+ } catch (err) {
603
+ return {
604
+ ok: false,
605
+ error: err instanceof Error ? err : new Error(String(err))
533
606
  };
534
607
  }
535
- /**
536
- * 获取当前所有活跃会话列表
537
- */
538
- getActiveSessions() {
539
- return Array.from(this.activeSessions.values()).map((entry) => entry.session);
540
- }
541
- /**
542
- * 清理空闲进程
543
- *
544
- * 找出所有 status='idle' lastActiveAt 距今超过 maxIdleMs 的活跃进程,
545
- * kill 进程释放内存。entry 保留在 activeSessions 中,用户下次 sendMessage
546
- * 走 slow path 自动 --resume 重启进程。
547
- *
548
- * @returns sweep 的 sessionId 列表
549
- */
550
- async sweepIdleProcesses(maxIdleMs) {
551
- const now = Date.now();
552
- const swept = [];
553
- for (const [sessionId, entry] of this.activeSessions) {
554
- if (entry.process.exitCode !== null || entry.process.signalCode !== null) continue;
555
- if (entry.session.status !== "idle") continue;
556
- if (now - entry.session.lastActiveAt < maxIdleMs) continue;
557
- const idleMin = Math.round((now - entry.session.lastActiveAt) / 6e4);
558
- console.log(`[ProcessProvider] sweeping idle process: ${sessionId} (idle ${idleMin}m)`);
608
+ }
609
+ async function getSessionHistory(projectPath, sessionId) {
610
+ let fileHandle;
611
+ try {
612
+ const encodedPath = encodeDirName(projectPath);
613
+ const filePath = (0, import_path.join)(CLAUDE_PROJECTS_DIR, encodedPath, `${sessionId}.jsonl`);
614
+ try {
615
+ fileHandle = await (0, import_promises.open)(filePath, "r");
616
+ } catch (err) {
617
+ if (err.code === "ENOENT") return { ok: true, value: [] };
618
+ throw err;
619
+ }
620
+ const rl = (0, import_readline.createInterface)({
621
+ input: fileHandle.createReadStream({ encoding: "utf-8" }),
622
+ crlfDelay: Infinity
623
+ });
624
+ const events = [];
625
+ for await (const line of rl) {
626
+ if (!line.trim()) continue;
559
627
  try {
560
- entry.process.stdin?.end();
628
+ const obj = JSON.parse(line);
629
+ const type = obj.type;
630
+ if (type === "user" && obj.message) {
631
+ const msgContent = obj.message.content;
632
+ if (typeof msgContent === "string") {
633
+ if (msgContent.includes("<local-command") || msgContent.includes("<command-name>")) continue;
634
+ } else if (Array.isArray(msgContent)) {
635
+ const hasText = msgContent.some(
636
+ (b) => b.type === "text" && !b.text?.includes("<local-command") && !b.text?.includes("<command-name>")
637
+ );
638
+ if (!hasText) continue;
639
+ }
640
+ const normalizedContent = typeof msgContent === "string" ? [{ type: "text", text: msgContent }] : Array.isArray(msgContent) ? msgContent.filter((b) => b.type === "text" && typeof b.text === "string") : [];
641
+ if (normalizedContent.length === 0) continue;
642
+ events.push({
643
+ type: "user",
644
+ message: {
645
+ ...obj.message,
646
+ content: normalizedContent
647
+ },
648
+ session_id: sessionId
649
+ });
650
+ } else if (type === "assistant" && obj.message) {
651
+ const content = (obj.message.content ?? []).filter(
652
+ (b) => b.type === "text" || b.type === "tool_use" || b.type === "thinking"
653
+ );
654
+ if (content.length === 0) continue;
655
+ events.push({
656
+ type: "assistant",
657
+ message: {
658
+ id: obj.message.id ?? obj.uuid ?? `hist-${events.length}`,
659
+ model: obj.message.model ?? "unknown",
660
+ role: "assistant",
661
+ content,
662
+ stop_reason: obj.message.stop_reason,
663
+ usage: obj.message.usage
664
+ },
665
+ session_id: sessionId
666
+ });
667
+ }
561
668
  } catch {
562
669
  }
563
- try {
564
- await killProcessCrossPlatform(entry.process);
565
- } catch (err) {
566
- console.error(`[ProcessProvider] sweep kill failed for ${sessionId}:`, err);
567
- continue;
670
+ }
671
+ if (events.length > 0) {
672
+ let totalInputTokens = 0;
673
+ let totalOutputTokens = 0;
674
+ for (const ev of events) {
675
+ if (ev.type === "assistant" && ev.message.usage) {
676
+ totalInputTokens += ev.message.usage.input_tokens ?? 0;
677
+ totalOutputTokens += ev.message.usage.output_tokens ?? 0;
678
+ }
679
+ }
680
+ if (totalInputTokens > 0 || totalOutputTokens > 0) {
681
+ events.push({
682
+ type: "result",
683
+ subtype: "success",
684
+ is_error: false,
685
+ duration_ms: 0,
686
+ num_turns: events.filter((e) => e.type === "user").length,
687
+ result: "",
688
+ session_id: sessionId,
689
+ usage: { input_tokens: totalInputTokens, output_tokens: totalOutputTokens }
690
+ });
568
691
  }
569
- swept.push(sessionId);
570
692
  }
571
- return swept;
693
+ return { ok: true, value: events };
694
+ } catch (err) {
695
+ return {
696
+ ok: false,
697
+ error: err instanceof Error ? err : new Error(String(err))
698
+ };
699
+ } finally {
700
+ await fileHandle?.close();
572
701
  }
573
- /**
574
- * LRU 上限清理
575
- *
576
- * 当活跃进程数超过 maxAlive 时,按 lastActiveAt 升序(最久未用优先)kill
577
- * 状态为 idle 的进程,直到活跃数回到上限以内。
578
- * running / waiting_question 状态的进程永远不会被 kill。
579
- *
580
- * @returns sweep 的 sessionId 列表
581
- */
582
- async sweepLruProcesses(maxAlive) {
583
- const swept = [];
584
- if (maxAlive <= 0) return swept;
585
- const aliveEntries = Array.from(this.activeSessions.entries()).filter(
586
- ([, e]) => e.process.exitCode === null && e.process.signalCode === null
587
- );
588
- if (aliveEntries.length <= maxAlive) return swept;
589
- const idleSorted = aliveEntries.filter(([, e]) => e.session.status === "idle").sort((a, b) => a[1].session.lastActiveAt - b[1].session.lastActiveAt);
590
- let aliveCount = aliveEntries.length;
591
- for (const [sessionId, entry] of idleSorted) {
592
- if (aliveCount <= maxAlive) break;
593
- const idleMin = Math.round((Date.now() - entry.session.lastActiveAt) / 6e4);
594
- console.log(`[ProcessProvider] LRU sweep: ${sessionId} (idle ${idleMin}m, alive=${aliveCount}/${maxAlive})`);
702
+ }
703
+ async function extractLastTimestamp(filePath) {
704
+ let fileHandle;
705
+ try {
706
+ fileHandle = await (0, import_promises.open)(filePath, "r");
707
+ const fileStat = await fileHandle.stat();
708
+ const readSize = Math.min(fileStat.size, 8192);
709
+ const buffer = Buffer.alloc(readSize);
710
+ await fileHandle.read(buffer, 0, readSize, fileStat.size - readSize);
711
+ const tail = buffer.toString("utf-8");
712
+ const lines = tail.split("\n").filter((l) => l.trim());
713
+ for (let i = lines.length - 1; i >= 0; i--) {
595
714
  try {
596
- entry.process.stdin?.end();
715
+ const obj = JSON.parse(lines[i]);
716
+ if (obj.timestamp) {
717
+ const ts = new Date(obj.timestamp).getTime();
718
+ if (!isNaN(ts)) return ts;
719
+ }
597
720
  } catch {
598
721
  }
722
+ }
723
+ } catch {
724
+ } finally {
725
+ await fileHandle?.close();
726
+ }
727
+ return void 0;
728
+ }
729
+ async function extractFirstPrompt(filePath) {
730
+ let fileHandle;
731
+ try {
732
+ fileHandle = await (0, import_promises.open)(filePath, "r");
733
+ const rl = (0, import_readline.createInterface)({
734
+ input: fileHandle.createReadStream({ encoding: "utf-8" }),
735
+ crlfDelay: Infinity
736
+ });
737
+ let lineCount = 0;
738
+ for await (const line of rl) {
739
+ if (++lineCount > 20) break;
740
+ if (!line.trim()) continue;
599
741
  try {
600
- await killProcessCrossPlatform(entry.process);
601
- swept.push(sessionId);
602
- aliveCount--;
603
- } catch (err) {
604
- console.error(`[ProcessProvider] LRU kill failed for ${sessionId}:`, err);
742
+ const obj = JSON.parse(line);
743
+ if (obj.type === "user" && obj.message) {
744
+ const msgContent = obj.message.content;
745
+ let text = "";
746
+ if (typeof msgContent === "string") {
747
+ text = msgContent;
748
+ } else if (Array.isArray(msgContent)) {
749
+ const textBlock = msgContent.find((b) => b.type === "text" && typeof b.text === "string");
750
+ text = textBlock?.text ?? "";
751
+ }
752
+ if (text && !text.includes("<local-command") && !text.includes("<command-name>")) {
753
+ text = text.replace(/<ide_opened_file>[\s\S]*?<\/ide_opened_file>/gi, "");
754
+ text = text.replace(/<[^>]+>/g, "").trim();
755
+ rl.close();
756
+ return text.length > 80 ? text.slice(0, 80) + "..." : text;
757
+ }
758
+ }
759
+ } catch {
605
760
  }
606
761
  }
607
- if (aliveCount > maxAlive) {
608
- console.warn(`[ProcessProvider] LRU sweep: ${aliveCount} alive after sweep > limit ${maxAlive}; remaining are running/waiting`);
609
- }
610
- return swept;
762
+ } catch {
763
+ } finally {
764
+ await fileHandle?.close();
611
765
  }
612
- // ============================================
613
- // 私有方法
614
- // ============================================
615
- /**
616
- * 启动 claude CLI 进程(持久模式,stdin 保持开放接收多条消息)
617
- */
618
- spawnClaudeProcess(sessionId, projectPath, resume = false, model, permissionMode, effort, fallbackModel, maxBudgetUsd) {
619
- const args = [
620
- "--input-format",
621
- "stream-json",
622
- "--output-format",
623
- "stream-json",
624
- "--verbose",
625
- "--include-partial-messages",
626
- "--include-hook-events"
627
- ];
628
- if (resume) {
629
- args.push("--resume", sessionId);
630
- } else {
631
- args.push("--session-id", sessionId);
632
- }
633
- if (model) {
634
- args.push("--model", model);
635
- }
636
- if (permissionMode && permissionMode !== "default") {
637
- args.push("--permission-mode", permissionMode);
638
- }
639
- if (effort) {
640
- args.push("--effort", effort);
641
- }
642
- if (fallbackModel) {
643
- args.push("--fallback-model", fallbackModel);
644
- }
645
- if (maxBudgetUsd != null) {
646
- args.push("--max-budget-usd", String(maxBudgetUsd));
647
- }
648
- const env = { ...process.env, SESSIX_SESSION_ID: sessionId };
649
- delete env.CLAUDECODE;
650
- const proc = (0, import_child_process.spawn)(CLAUDE_PATH, args, {
651
- cwd: projectPath,
652
- env,
653
- stdio: ["pipe", "pipe", "pipe"]
654
- });
655
- return proc;
766
+ return void 0;
767
+ }
768
+ function decodeDirName(dirName) {
769
+ const placeholder = "\0";
770
+ const escaped = dirName.replace(/--/g, placeholder);
771
+ const decoded = escaped.replace(/-/g, "/");
772
+ return decoded.replace(new RegExp(placeholder, "g"), "-");
773
+ }
774
+ function encodeDirName(path2) {
775
+ const escaped = path2.replace(/-/g, "--");
776
+ return escaped.replace(/\//g, "-");
777
+ }
778
+ async function directoryExists(dirPath) {
779
+ try {
780
+ const s = await (0, import_promises.stat)(dirPath);
781
+ return s.isDirectory();
782
+ } catch {
783
+ return false;
784
+ }
785
+ }
786
+ var UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
787
+ async function countJsonlFilesWithMtime(dirPath) {
788
+ try {
789
+ const entries = await (0, import_promises.readdir)(dirPath, { withFileTypes: true });
790
+ const jsonlNames = new Set(
791
+ entries.filter((e) => e.isFile() && e.name.endsWith(".jsonl")).map((e) => e.name.slice(0, -6))
792
+ );
793
+ const uuidDirs = entries.filter(
794
+ (e) => e.isDirectory() && UUID_RE.test(e.name) && !jsonlNames.has(e.name)
795
+ );
796
+ let latestMtime = 0;
797
+ const jsonlEntries = entries.filter((e) => e.isFile() && e.name.endsWith(".jsonl"));
798
+ await Promise.all([
799
+ ...jsonlEntries.map(async (entry) => {
800
+ try {
801
+ const contentTs = await extractLastTimestamp((0, import_path.join)(dirPath, entry.name));
802
+ const ts = contentTs ?? (await (0, import_promises.stat)((0, import_path.join)(dirPath, entry.name))).mtimeMs;
803
+ if (ts > latestMtime) latestMtime = ts;
804
+ } catch {
805
+ }
806
+ }),
807
+ ...uuidDirs.map(async (entry) => {
808
+ try {
809
+ const fileStat = await (0, import_promises.stat)((0, import_path.join)(dirPath, entry.name));
810
+ if (fileStat.mtimeMs > latestMtime) latestMtime = fileStat.mtimeMs;
811
+ } catch {
812
+ }
813
+ })
814
+ ]);
815
+ return { count: jsonlNames.size + uuidDirs.length, latestMtime };
816
+ } catch {
817
+ return { count: 0, latestMtime: 0 };
656
818
  }
819
+ }
820
+
821
+ // src/providers/ProcessProvider.ts
822
+ var CLAUDE_PATH = findClaudePath();
823
+ var ProcessProvider = class {
824
+ /** 活跃会话映射表:sessionId -> { session, process } */
825
+ activeSessions = /* @__PURE__ */ new Map();
826
+ /** 事件发射器,用于分发 Claude 事件流 */
827
+ emitter = new import_events.EventEmitter();
657
828
  /**
658
- * 向持久进程的 stdin 写入一条用户消息(NDJSON 格式)
829
+ * 启动新会话或恢复已有会话
659
830
  *
660
- * 写入失败时合成 error result 事件,确保 SessionManager 能感知到失败。
831
+ * spawn 一个 `claude` CLI 进程,设置工作目录和环境变量,
832
+ * 并开始监听 stdout 的 NDJSON 输出。
661
833
  */
662
- writeUserMessage(proc, message, sessionId, images) {
663
- const content = [];
664
- if (images?.length) {
665
- const ALLOWED_TYPES = /* @__PURE__ */ new Set(["image/jpeg", "image/png", "image/webp", "image/gif"]);
666
- const MAX_IMAGE_BYTES = 5 * 1024 * 1024;
667
- for (let i = 0; i < images.length; i++) {
668
- const img = images[i];
669
- if (!ALLOWED_TYPES.has(img.media_type)) {
670
- if (sessionId) {
671
- this.emitWriteError(sessionId, `Image #${i + 1} rejected: unsupported media_type "${img.media_type}". Only JPEG/PNG/WebP/GIF are accepted.`);
672
- }
673
- return;
674
- }
675
- const sizeBytes = Math.floor(img.data.length * 0.75);
676
- if (sizeBytes > MAX_IMAGE_BYTES) {
677
- if (sessionId) {
678
- const sizeMb = (sizeBytes / (1024 * 1024)).toFixed(1);
679
- this.emitWriteError(sessionId, `Image #${i + 1} rejected: ${sizeMb}MB exceeds 5MB per-image limit.`);
680
- }
681
- return;
682
- }
683
- content.push({
684
- type: "image",
685
- source: { type: "base64", media_type: img.media_type, data: img.data }
686
- });
687
- }
834
+ async startSession(opts) {
835
+ const { projectPath, message, sessionId: existingSessionId, model, permissionMode, effort, images, fallbackModel, maxBudgetUsd } = opts;
836
+ const sessionId = existingSessionId ?? (0, import_uuid.v4)();
837
+ if (this.activeSessions.has(sessionId)) {
838
+ await this.killSession(sessionId);
688
839
  }
689
- content.push({ type: "text", text: message });
690
- const payload = JSON.stringify({
691
- type: "user",
692
- session_id: "",
693
- message: { role: "user", content },
694
- parent_tool_use_id: null
695
- });
696
- if (!proc.stdin || proc.stdin.destroyed) {
697
- console.error(`[ProcessProvider] stdin unavailable, message lost`);
698
- if (sessionId) {
699
- this.emitWriteError(sessionId, "Process stdin closed, message not delivered");
840
+ const projectId = projectPath.split("/").filter(Boolean).pop() ?? "unknown";
841
+ const session = {
842
+ id: sessionId,
843
+ projectId,
844
+ projectPath,
845
+ status: "running",
846
+ createdAt: Date.now(),
847
+ lastActiveAt: Date.now(),
848
+ summary: message.slice(0, 80)
849
+ };
850
+ const resume = opts.resume ?? !!existingSessionId;
851
+ let effectiveModel = model;
852
+ if (resume && !effectiveModel) {
853
+ effectiveModel = await getSessionModel(projectPath, sessionId).catch(() => void 0);
854
+ if (effectiveModel) {
855
+ console.log(`[ProcessProvider] Session ${sessionId}: resume restored original model "${effectiveModel}"`);
700
856
  }
701
- return;
702
857
  }
703
- proc.stdin.write(payload + "\n", (err) => {
704
- if (err && sessionId) {
705
- console.error(`[ProcessProvider] Session ${sessionId} stdin write failed:`, err.message);
706
- this.emitWriteError(sessionId, `Failed to send message: ${err.message}`);
707
- }
708
- });
709
- if (sessionId) {
710
- const syntheticUser = {
711
- type: "user",
858
+ const proc = this.spawnClaudeProcess(sessionId, projectPath, resume, effectiveModel, permissionMode, effort, fallbackModel, maxBudgetUsd);
859
+ this.writeUserMessage(proc, message, sessionId, images);
860
+ session.pid = proc.pid;
861
+ this.activeSessions.set(sessionId, { session, process: proc, model: effectiveModel, permissionMode, effort, fallbackModel, maxBudgetUsd });
862
+ proc.on("error", (err) => {
863
+ console.error(`[ProcessProvider] Session ${sessionId} process error:`, err.message);
864
+ this.activeSessions.delete(sessionId);
865
+ const syntheticResult = {
866
+ type: "result",
867
+ subtype: "error",
868
+ result: `Process spawn failed: ${err.message}`,
712
869
  session_id: sessionId,
713
- message: { role: "user", content }
870
+ duration_ms: 0,
871
+ is_error: true,
872
+ num_turns: 0
714
873
  };
715
- this.emitter.emit(this.getEventName(sessionId), syntheticUser);
716
- }
717
- }
718
- /**
719
- * 发出写入失败的合成错误事件
720
- */
721
- emitWriteError(sessionId, message) {
722
- const syntheticResult = {
723
- type: "result",
724
- subtype: "error",
725
- result: message,
726
- session_id: sessionId,
727
- duration_ms: 0,
728
- is_error: true,
729
- num_turns: 0
730
- };
731
- this.emitter.emit(this.getEventName(sessionId), syntheticResult);
874
+ this.emitter.emit(this.getEventName(sessionId), syntheticResult);
875
+ });
876
+ this.attachStdoutListener(sessionId, proc);
877
+ this.attachStderrListener(sessionId, proc);
878
+ this.attachExitListener(sessionId, proc);
879
+ return session;
732
880
  }
733
881
  /**
734
- * 挂载 stdout 监听器,逐行解析 NDJSON
882
+ * 终止指定会话
883
+ *
884
+ * kill 进程并从活跃映射中移除。
735
885
  */
736
- attachStdoutListener(sessionId, proc) {
737
- if (!proc.stdout) {
738
- console.warn(`[ProcessProvider] Session ${sessionId}: stdout unavailable`);
739
- return;
740
- }
741
- const rl = (0, import_readline.createInterface)({
742
- input: proc.stdout,
743
- crlfDelay: Infinity
744
- });
886
+ async killSession(sessionId) {
745
887
  const entry = this.activeSessions.get(sessionId);
746
- if (entry) {
747
- entry.rl = rl;
888
+ if (!entry) {
889
+ return;
748
890
  }
749
- rl.on("line", (line) => {
750
- const trimmed = line.trim();
751
- if (!trimmed) return;
752
- const result = this.parseLine(trimmed);
753
- if (result.ok) {
754
- const event = result.value;
755
- if (event.type === "assistant") {
756
- for (const block of event.message.content) {
757
- if (block.type === "tool_use") {
758
- const isQuestion = block.name === "AskUserQuestion" || block.name === "AskFollowupQuestion";
759
- if (!isQuestion) continue;
760
- const input = block.input;
761
- let question = "";
762
- let options;
763
- let questions;
764
- if (typeof input.question === "string") {
765
- question = input.question;
766
- options = Array.isArray(input.options) ? input.options : void 0;
767
- } else if (Array.isArray(input.questions) && input.questions.length > 0) {
768
- questions = input.questions.map((q) => {
769
- const item = {
770
- question: typeof q.question === "string" ? q.question : "",
771
- header: typeof q.header === "string" ? q.header : void 0,
772
- multiSelect: typeof q.multiSelect === "boolean" ? q.multiSelect : void 0
773
- };
774
- if (Array.isArray(q.options)) {
775
- item.options = q.options.map((o) => ({
776
- label: typeof o.label === "string" ? o.label : String(o),
777
- description: typeof o.description === "string" ? o.description : void 0
778
- }));
779
- }
780
- return item;
781
- });
782
- const first = questions[0];
783
- question = first.question;
784
- options = first.options?.map((o) => o.label);
785
- }
786
- if (!question) continue;
787
- const prevKey = `${block.id}:${question}:${JSON.stringify(options ?? [])}`;
788
- let sessionSet = this.emittedQuestionToolUseIds.get(sessionId);
789
- if (!sessionSet) {
790
- sessionSet = /* @__PURE__ */ new Set();
791
- this.emittedQuestionToolUseIds.set(sessionId, sessionSet);
792
- }
793
- if (sessionSet.has(prevKey)) continue;
794
- sessionSet.add(prevKey);
795
- console.log(`[ProcessProvider] Session ${sessionId}: detected ${block.name} (toolUseId=${block.id})`);
796
- this.emitter.emit(this.getQuestionEventName(sessionId), {
797
- toolUseId: block.id,
798
- question,
799
- options,
800
- questions
801
- });
802
- }
803
- }
804
- }
805
- this.updateSessionStatus(sessionId, event);
806
- this.emitter.emit(this.getEventName(sessionId), event);
807
- } else {
808
- console.warn(
809
- `[ProcessProvider] Session ${sessionId}: failed to parse line: ${trimmed.substring(0, 100)}`
810
- );
811
- }
812
- });
813
- }
814
- /**
815
- * 挂载 stderr 监听器,记录日志
816
- */
817
- attachStderrListener(sessionId, proc) {
818
- if (!proc.stderr) return;
819
- proc.stderr.on("data", (data) => {
820
- const text = data.toString().trim();
821
- if (text) {
822
- console.error(`[ProcessProvider] Session ${sessionId} stderr: ${text}`);
891
+ if (entry.process.exitCode === null && entry.process.signalCode === null) {
892
+ try {
893
+ entry.process.stdin?.end();
894
+ } catch {
823
895
  }
824
- });
896
+ await killProcessCrossPlatform(entry.process);
897
+ }
898
+ this.activeSessions.delete(sessionId);
825
899
  }
826
900
  /**
827
- * 挂载进程退出监听器
901
+ * 向已有会话发送新消息
828
902
  *
829
- * 当进程退出时发出合成的 result 事件,确保 SessionManager 能感知到退出。
830
- * 正常退出时 Claude 会先通过 stdout 发送真实 result 事件,
831
- * updateSessionStatus 会将 session.status 更新为 idle/error。
832
- * 此时合成事件会重复触发,导致手机端出现两张总结卡。
833
- * 修复:已收到真实 result(status 已为 idle/error)时跳过合成事件。
834
- * 异常退出时(crash/OOM/killed)没有真实 result 事件,合成事件确保状态正确广播。
903
+ * 快速路径:进程存活时直接写 stdin(毫秒级响应)。
904
+ * 慢速路径:进程已退出时 respawn --resume。
835
905
  */
836
- attachExitListener(sessionId, proc) {
837
- proc.once("exit", (code, signal) => {
838
- const entry = this.activeSessions.get(sessionId);
839
- if (!entry) return;
840
- if (entry.process !== proc) return;
841
- if (entry.rl) {
842
- entry.rl.close();
843
- entry.rl = void 0;
844
- }
845
- entry.session.pid = void 0;
906
+ async sendMessage(sessionId, message, permissionMode, images) {
907
+ const entry = this.activeSessions.get(sessionId);
908
+ if (!entry) {
909
+ throw new Error(`Session ${sessionId} not found or already ended`);
910
+ }
911
+ const modeChanged = permissionMode != null && permissionMode !== (entry.permissionMode ?? "default");
912
+ if (!modeChanged && entry.process.exitCode === null && entry.process.signalCode === null && !entry.process.stdin?.destroyed) {
913
+ entry.session.status = "running";
846
914
  entry.session.lastActiveAt = Date.now();
847
- const alreadyHasResult = entry.session.status === "idle" || entry.session.status === "error";
848
- if (alreadyHasResult) return;
849
- const isNormal = isNormalExit(code, signal);
850
- entry.session.status = isNormal ? "idle" : "error";
851
- if (!isNormal) {
852
- console.error(
853
- `[ProcessProvider] Session ${sessionId}: process exited abnormally code=${code} signal=${signal}`
854
- );
915
+ this.writeUserMessage(entry.process, message, sessionId, images);
916
+ return;
917
+ }
918
+ if (modeChanged) {
919
+ console.log(`[ProcessProvider] Session ${sessionId}: permission mode change ${entry.permissionMode ?? "default"} \u2192 ${permissionMode}, respawn`);
920
+ if (entry.process.exitCode === null && entry.process.signalCode === null) {
921
+ try {
922
+ entry.process.stdin?.end();
923
+ } catch {
924
+ }
925
+ killProcessCrossPlatform(entry.process);
855
926
  }
927
+ } else {
928
+ console.log(`[ProcessProvider] Session ${sessionId}: process exited, respawning`);
929
+ }
930
+ const newMode = permissionMode ?? entry.permissionMode;
931
+ const proc = this.spawnClaudeProcess(sessionId, entry.session.projectPath, true, entry.model, newMode, entry.effort, entry.fallbackModel, entry.maxBudgetUsd);
932
+ this.writeUserMessage(proc, message, sessionId, images);
933
+ entry.session.status = "running";
934
+ entry.session.lastActiveAt = Date.now();
935
+ entry.session.pid = proc.pid;
936
+ entry.process = proc;
937
+ entry.permissionMode = newMode;
938
+ proc.on("error", (err) => {
939
+ console.error(`[ProcessProvider] Session ${sessionId} sendMessage process error:`, err.message);
940
+ this.activeSessions.delete(sessionId);
856
941
  const syntheticResult = {
857
942
  type: "result",
858
- subtype: isNormal ? "success" : "error",
943
+ subtype: "error",
944
+ result: `Failed to send message: ${err.message}`,
859
945
  session_id: sessionId,
860
- is_error: !isNormal,
861
- result: isNormal ? "" : `Process exited code=${code} signal=${signal}`,
862
946
  duration_ms: 0,
947
+ is_error: true,
863
948
  num_turns: 0
864
949
  };
865
950
  this.emitter.emit(this.getEventName(sessionId), syntheticResult);
866
951
  });
952
+ this.attachStdoutListener(sessionId, proc);
953
+ this.attachStderrListener(sessionId, proc);
954
+ this.attachExitListener(sessionId, proc);
867
955
  }
868
956
  /**
869
- * 解析一行 NDJSON 文本为 ClaudeStreamEvent
957
+ * 订阅指定会话的 Claude 事件流
958
+ *
959
+ * @returns 取消订阅函数
870
960
  */
871
- parseLine(line) {
872
- try {
873
- const parsed = JSON.parse(line);
874
- return { ok: true, value: parsed };
875
- } catch (err) {
876
- return {
877
- ok: false,
878
- error: err instanceof Error ? err : new Error(String(err))
879
- };
880
- }
961
+ onEvent(sessionId, callback) {
962
+ const eventName = this.getEventName(sessionId);
963
+ this.emitter.on(eventName, callback);
964
+ return () => {
965
+ this.emitter.off(eventName, callback);
966
+ };
881
967
  }
882
968
  /**
883
- * 根据 Claude 事件更新会话状态
969
+ * 获取当前所有活跃会话列表
884
970
  */
885
- updateSessionStatus(sessionId, event) {
886
- const entry = this.activeSessions.get(sessionId);
887
- if (!entry) return;
888
- entry.session.lastActiveAt = Date.now();
889
- switch (event.type) {
890
- case "system":
891
- if (event.subtype === "init") {
892
- entry.session.status = "running";
893
- }
894
- break;
895
- case "assistant":
896
- entry.session.status = "running";
897
- break;
898
- case "result":
899
- entry.session.status = event.is_error ? "error" : "idle";
900
- break;
901
- }
971
+ getActiveSessions() {
972
+ return Array.from(this.activeSessions.values()).map((entry) => entry.session);
902
973
  }
903
974
  /**
904
- * 根据对话上下文生成下一步建议指令
975
+ * 清理空闲进程
905
976
  *
906
- * 使用 --output-format text 做一次性调用,返回纯文本结果。
977
+ * 找出所有 status='idle' lastActiveAt 距今超过 maxIdleMs 的活跃进程,
978
+ * kill 进程释放内存。entry 保留在 activeSessions 中,用户下次 sendMessage
979
+ * 走 slow path 自动 --resume 重启进程。
980
+ *
981
+ * @returns 被 sweep 的 sessionId 列表
907
982
  */
908
- async generateSuggestion(context) {
909
- const prompt = `You are an AI coding assistant. Based on the following Claude Code conversation context, suggest the most valuable next instruction for the user (give the instruction directly, no explanation, no quotes):
910
-
911
- ${context}`;
912
- return new Promise((resolve, reject) => {
913
- const env = { ...process.env };
914
- delete env.CLAUDECODE;
915
- const proc = (0, import_child_process.spawn)(CLAUDE_PATH, ["-p", prompt, "--output-format", "text"], {
916
- cwd: (0, import_node_os2.homedir)(),
917
- env,
918
- stdio: ["pipe", "pipe", "pipe"]
919
- });
920
- proc.stdin.end();
921
- let output = "";
922
- proc.stdout?.on("data", (data) => {
923
- output += data.toString();
924
- });
925
- proc.once("exit", (code) => {
926
- if (code === 0) {
927
- resolve(output.trim());
928
- } else {
929
- reject(new Error(`generateSuggestion process exit code: ${code}`));
930
- }
931
- });
932
- proc.once("error", reject);
933
- });
983
+ async sweepIdleProcesses(maxIdleMs) {
984
+ const now = Date.now();
985
+ const swept = [];
986
+ for (const [sessionId, entry] of this.activeSessions) {
987
+ if (entry.process.exitCode !== null || entry.process.signalCode !== null) continue;
988
+ if (entry.session.status !== "idle") continue;
989
+ if (now - entry.session.lastActiveAt < maxIdleMs) continue;
990
+ const idleMin = Math.round((now - entry.session.lastActiveAt) / 6e4);
991
+ console.log(`[ProcessProvider] sweeping idle process: ${sessionId} (idle ${idleMin}m)`);
992
+ try {
993
+ entry.process.stdin?.end();
994
+ } catch {
995
+ }
996
+ try {
997
+ await killProcessCrossPlatform(entry.process);
998
+ } catch (err) {
999
+ console.error(`[ProcessProvider] sweep kill failed for ${sessionId}:`, err);
1000
+ continue;
1001
+ }
1002
+ swept.push(sessionId);
1003
+ }
1004
+ return swept;
934
1005
  }
935
1006
  /**
936
- * 向正在等待中的 AskUserQuestion 提供答案
1007
+ * LRU 上限清理
1008
+ *
1009
+ * 当活跃进程数超过 maxAlive 时,按 lastActiveAt 升序(最久未用优先)kill
1010
+ * 状态为 idle 的进程,直到活跃数回到上限以内。
1011
+ * running / waiting_question 状态的进程永远不会被 kill。
937
1012
  *
938
- * 将答案写入 Claude 进程的 stdin(作为 tool_result),
939
- * Claude 收到后继续执行。
1013
+ * @returns sweep sessionId 列表
940
1014
  */
941
- async answerQuestion(sessionId, toolUseId, answer) {
942
- const entry = this.activeSessions.get(sessionId);
943
- if (!entry) {
944
- throw new Error(`Session ${sessionId} not found`);
1015
+ async sweepLruProcesses(maxAlive) {
1016
+ const swept = [];
1017
+ if (maxAlive <= 0) return swept;
1018
+ const aliveEntries = Array.from(this.activeSessions.entries()).filter(
1019
+ ([, e]) => e.process.exitCode === null && e.process.signalCode === null
1020
+ );
1021
+ if (aliveEntries.length <= maxAlive) return swept;
1022
+ const idleSorted = aliveEntries.filter(([, e]) => e.session.status === "idle").sort((a, b) => a[1].session.lastActiveAt - b[1].session.lastActiveAt);
1023
+ let aliveCount = aliveEntries.length;
1024
+ for (const [sessionId, entry] of idleSorted) {
1025
+ if (aliveCount <= maxAlive) break;
1026
+ const idleMin = Math.round((Date.now() - entry.session.lastActiveAt) / 6e4);
1027
+ console.log(`[ProcessProvider] LRU sweep: ${sessionId} (idle ${idleMin}m, alive=${aliveCount}/${maxAlive})`);
1028
+ try {
1029
+ entry.process.stdin?.end();
1030
+ } catch {
1031
+ }
1032
+ try {
1033
+ await killProcessCrossPlatform(entry.process);
1034
+ swept.push(sessionId);
1035
+ aliveCount--;
1036
+ } catch (err) {
1037
+ console.error(`[ProcessProvider] LRU kill failed for ${sessionId}:`, err);
1038
+ }
945
1039
  }
946
- if (!entry.process.stdin || entry.process.stdin.destroyed) {
947
- throw new Error(`Session ${sessionId} stdin unavailable`);
1040
+ if (aliveCount > maxAlive) {
1041
+ console.warn(`[ProcessProvider] LRU sweep: ${aliveCount} alive after sweep > limit ${maxAlive}; remaining are running/waiting`);
948
1042
  }
949
- const toolResult = JSON.stringify({
950
- type: "user",
951
- session_id: "",
952
- message: {
953
- role: "user",
954
- content: [{ type: "tool_result", tool_use_id: toolUseId, content: answer }]
955
- },
956
- parent_tool_use_id: toolUseId
957
- });
958
- await new Promise((resolve, reject) => {
959
- entry.process.stdin.write(toolResult + "\n", (err) => {
960
- if (err) reject(err);
961
- else resolve();
962
- });
963
- });
964
- console.log(`[ProcessProvider] Session ${sessionId}: AskUserQuestion answered (toolUseId=${toolUseId})`);
1043
+ return swept;
965
1044
  }
966
1045
  /**
967
- * 订阅指定会话的 AskUserQuestion 事件
1046
+ * 枚举可淘汰的老会话
968
1047
  *
969
- * @returns 取消订阅函数
1048
+ * 进程已退出(已被空闲 GC kill)且空闲超过 maxIdleMs 的会话——其 entry 与各 Map
1049
+ * 仍长期占内存。调用方对返回 id 执行 killSession 彻底清除;淘汰后手机端发消息
1050
+ * 会自动走 resume 路径(--resume + JSONL),不影响继续对话。
1051
+ *
1052
+ * @returns 可淘汰的 sessionId 列表(仅枚举,不删除)
970
1053
  */
971
- onQuestion(sessionId, callback) {
972
- const eventName = this.getQuestionEventName(sessionId);
973
- this.emitter.on(eventName, callback);
974
- return () => {
975
- this.emitter.off(eventName, callback);
976
- };
1054
+ listEvictableSessions(maxIdleMs) {
1055
+ if (maxIdleMs <= 0) return [];
1056
+ const now = Date.now();
1057
+ const evictable = [];
1058
+ for (const [sessionId, entry] of this.activeSessions) {
1059
+ if (entry.process.exitCode === null && entry.process.signalCode === null) continue;
1060
+ if (entry.session.status === "running" || entry.session.status === "waiting_question" || entry.session.status === "waiting_approval") continue;
1061
+ if (now - entry.session.lastActiveAt < maxIdleMs) continue;
1062
+ evictable.push(sessionId);
1063
+ }
1064
+ return evictable;
977
1065
  }
1066
+ // ============================================
1067
+ // 私有方法
1068
+ // ============================================
978
1069
  /**
979
- * 生成事件名称
1070
+ * 启动 claude CLI 进程(持久模式,stdin 保持开放接收多条消息)
980
1071
  */
981
- getEventName(sessionId) {
982
- return `claude:${sessionId}`;
1072
+ spawnClaudeProcess(sessionId, projectPath, resume = false, model, permissionMode, effort, fallbackModel, maxBudgetUsd) {
1073
+ const args = [
1074
+ "--input-format",
1075
+ "stream-json",
1076
+ "--output-format",
1077
+ "stream-json",
1078
+ "--verbose",
1079
+ "--include-partial-messages",
1080
+ "--include-hook-events"
1081
+ ];
1082
+ if (resume) {
1083
+ args.push("--resume", sessionId);
1084
+ } else {
1085
+ args.push("--session-id", sessionId);
1086
+ }
1087
+ if (model) {
1088
+ args.push("--model", model);
1089
+ }
1090
+ if (permissionMode && permissionMode !== "default") {
1091
+ args.push("--permission-mode", permissionMode);
1092
+ }
1093
+ if (effort) {
1094
+ args.push("--effort", effort);
1095
+ }
1096
+ if (fallbackModel) {
1097
+ args.push("--fallback-model", fallbackModel);
1098
+ }
1099
+ if (maxBudgetUsd != null) {
1100
+ args.push("--max-budget-usd", String(maxBudgetUsd));
1101
+ }
1102
+ const env = { ...process.env, SESSIX_SESSION_ID: sessionId };
1103
+ delete env.CLAUDECODE;
1104
+ const proc = (0, import_child_process.spawn)(CLAUDE_PATH, args, {
1105
+ cwd: projectPath,
1106
+ env,
1107
+ stdio: ["pipe", "pipe", "pipe"]
1108
+ });
1109
+ return proc;
983
1110
  }
984
1111
  /**
985
- * 生成 AskUserQuestion 内部事件名称
1112
+ * 向持久进程的 stdin 写入一条用户消息(NDJSON 格式)
1113
+ *
1114
+ * 写入失败时合成 error result 事件,确保 SessionManager 能感知到失败。
986
1115
  */
987
- getQuestionEventName(sessionId) {
988
- return `question:${sessionId}`;
989
- }
990
- };
991
-
992
- // src/providers/CodexProvider.ts
993
- var import_child_process2 = require("child_process");
994
- var import_readline2 = require("readline");
995
- var import_events2 = require("events");
996
- var import_fs = require("fs");
997
- var import_path = require("path");
998
- var import_os = require("os");
999
- var import_uuid2 = require("uuid");
1000
-
1001
- // src/utils/codexPath.ts
1002
- var import_node_child_process3 = require("child_process");
1003
- var import_node_fs2 = require("fs");
1004
- var import_node_path2 = require("path");
1005
- var import_node_os3 = require("os");
1116
+ writeUserMessage(proc, message, sessionId, images) {
1117
+ const content = [];
1118
+ if (images?.length) {
1119
+ const ALLOWED_TYPES = /* @__PURE__ */ new Set(["image/jpeg", "image/png", "image/webp", "image/gif"]);
1120
+ const MAX_IMAGE_BYTES = 5 * 1024 * 1024;
1121
+ for (let i = 0; i < images.length; i++) {
1122
+ const img = images[i];
1123
+ if (!ALLOWED_TYPES.has(img.media_type)) {
1124
+ if (sessionId) {
1125
+ this.emitWriteError(sessionId, `Image #${i + 1} rejected: unsupported media_type "${img.media_type}". Only JPEG/PNG/WebP/GIF are accepted.`);
1126
+ }
1127
+ return;
1128
+ }
1129
+ const sizeBytes = Math.floor(img.data.length * 0.75);
1130
+ if (sizeBytes > MAX_IMAGE_BYTES) {
1131
+ if (sessionId) {
1132
+ const sizeMb = (sizeBytes / (1024 * 1024)).toFixed(1);
1133
+ this.emitWriteError(sessionId, `Image #${i + 1} rejected: ${sizeMb}MB exceeds 5MB per-image limit.`);
1134
+ }
1135
+ return;
1136
+ }
1137
+ content.push({
1138
+ type: "image",
1139
+ source: { type: "base64", media_type: img.media_type, data: img.data }
1140
+ });
1141
+ }
1142
+ }
1143
+ content.push({ type: "text", text: message });
1144
+ const payload = JSON.stringify({
1145
+ type: "user",
1146
+ session_id: "",
1147
+ message: { role: "user", content },
1148
+ parent_tool_use_id: null
1149
+ });
1150
+ if (!proc.stdin || proc.stdin.destroyed) {
1151
+ console.error(`[ProcessProvider] stdin unavailable, message lost`);
1152
+ if (sessionId) {
1153
+ this.emitWriteError(sessionId, "Process stdin closed, message not delivered");
1154
+ }
1155
+ return;
1156
+ }
1157
+ proc.stdin.write(payload + "\n", (err) => {
1158
+ if (err && sessionId) {
1159
+ console.error(`[ProcessProvider] Session ${sessionId} stdin write failed:`, err.message);
1160
+ this.emitWriteError(sessionId, `Failed to send message: ${err.message}`);
1161
+ }
1162
+ });
1163
+ if (sessionId) {
1164
+ const syntheticUser = {
1165
+ type: "user",
1166
+ session_id: sessionId,
1167
+ message: { role: "user", content }
1168
+ };
1169
+ this.emitter.emit(this.getEventName(sessionId), syntheticUser);
1170
+ }
1171
+ }
1172
+ /**
1173
+ * 发出写入失败的合成错误事件
1174
+ */
1175
+ emitWriteError(sessionId, message) {
1176
+ const syntheticResult = {
1177
+ type: "result",
1178
+ subtype: "error",
1179
+ result: message,
1180
+ session_id: sessionId,
1181
+ duration_ms: 0,
1182
+ is_error: true,
1183
+ num_turns: 0
1184
+ };
1185
+ this.emitter.emit(this.getEventName(sessionId), syntheticResult);
1186
+ }
1187
+ /**
1188
+ * 挂载 stdout 监听器,逐行解析 NDJSON
1189
+ */
1190
+ attachStdoutListener(sessionId, proc) {
1191
+ if (!proc.stdout) {
1192
+ console.warn(`[ProcessProvider] Session ${sessionId}: stdout unavailable`);
1193
+ return;
1194
+ }
1195
+ const rl = (0, import_readline2.createInterface)({
1196
+ input: proc.stdout,
1197
+ crlfDelay: Infinity
1198
+ });
1199
+ const entry = this.activeSessions.get(sessionId);
1200
+ if (entry) {
1201
+ entry.rl = rl;
1202
+ }
1203
+ rl.on("line", (line) => {
1204
+ const trimmed = line.trim();
1205
+ if (!trimmed) return;
1206
+ const result = this.parseLine(trimmed);
1207
+ if (result.ok) {
1208
+ const event = result.value;
1209
+ this.updateSessionStatus(sessionId, event);
1210
+ this.emitter.emit(this.getEventName(sessionId), event);
1211
+ } else {
1212
+ console.warn(
1213
+ `[ProcessProvider] Session ${sessionId}: failed to parse line: ${trimmed.substring(0, 100)}`
1214
+ );
1215
+ }
1216
+ });
1217
+ }
1218
+ /**
1219
+ * 挂载 stderr 监听器,记录日志
1220
+ */
1221
+ attachStderrListener(sessionId, proc) {
1222
+ if (!proc.stderr) return;
1223
+ proc.stderr.on("data", (data) => {
1224
+ const text = data.toString().trim();
1225
+ if (text) {
1226
+ console.error(`[ProcessProvider] Session ${sessionId} stderr: ${text}`);
1227
+ }
1228
+ });
1229
+ }
1230
+ /**
1231
+ * 挂载进程退出监听器
1232
+ *
1233
+ * 当进程退出时发出合成的 result 事件,确保 SessionManager 能感知到退出。
1234
+ * 正常退出时 Claude 会先通过 stdout 发送真实 result 事件,
1235
+ * updateSessionStatus 会将 session.status 更新为 idle/error。
1236
+ * 此时合成事件会重复触发,导致手机端出现两张总结卡。
1237
+ * 修复:已收到真实 result(status 已为 idle/error)时跳过合成事件。
1238
+ * 异常退出时(crash/OOM/killed)没有真实 result 事件,合成事件确保状态正确广播。
1239
+ */
1240
+ attachExitListener(sessionId, proc) {
1241
+ proc.once("exit", (code, signal) => {
1242
+ const entry = this.activeSessions.get(sessionId);
1243
+ if (!entry) return;
1244
+ if (entry.process !== proc) return;
1245
+ if (entry.rl) {
1246
+ entry.rl.close();
1247
+ entry.rl = void 0;
1248
+ }
1249
+ entry.session.pid = void 0;
1250
+ entry.session.lastActiveAt = Date.now();
1251
+ const alreadyHasResult = entry.session.status === "idle" || entry.session.status === "error";
1252
+ if (alreadyHasResult) return;
1253
+ const isNormal = isNormalExit(code, signal);
1254
+ entry.session.status = isNormal ? "idle" : "error";
1255
+ if (!isNormal) {
1256
+ console.error(
1257
+ `[ProcessProvider] Session ${sessionId}: process exited abnormally code=${code} signal=${signal}`
1258
+ );
1259
+ }
1260
+ const syntheticResult = {
1261
+ type: "result",
1262
+ subtype: isNormal ? "success" : "error",
1263
+ session_id: sessionId,
1264
+ is_error: !isNormal,
1265
+ result: isNormal ? "" : `Process exited code=${code} signal=${signal}`,
1266
+ duration_ms: 0,
1267
+ num_turns: 0
1268
+ };
1269
+ this.emitter.emit(this.getEventName(sessionId), syntheticResult);
1270
+ });
1271
+ }
1272
+ /**
1273
+ * 解析一行 NDJSON 文本为 ClaudeStreamEvent
1274
+ */
1275
+ parseLine(line) {
1276
+ try {
1277
+ const parsed = JSON.parse(line);
1278
+ return { ok: true, value: parsed };
1279
+ } catch (err) {
1280
+ return {
1281
+ ok: false,
1282
+ error: err instanceof Error ? err : new Error(String(err))
1283
+ };
1284
+ }
1285
+ }
1286
+ /**
1287
+ * 根据 Claude 事件更新会话状态
1288
+ */
1289
+ updateSessionStatus(sessionId, event) {
1290
+ const entry = this.activeSessions.get(sessionId);
1291
+ if (!entry) return;
1292
+ entry.session.lastActiveAt = Date.now();
1293
+ switch (event.type) {
1294
+ case "system":
1295
+ if (event.subtype === "init") {
1296
+ entry.session.status = "running";
1297
+ }
1298
+ break;
1299
+ case "assistant":
1300
+ entry.session.status = "running";
1301
+ break;
1302
+ case "result":
1303
+ entry.session.status = event.is_error ? "error" : "idle";
1304
+ break;
1305
+ }
1306
+ }
1307
+ /**
1308
+ * 根据对话上下文生成下一步建议指令
1309
+ *
1310
+ * 使用 --output-format text 做一次性调用,返回纯文本结果。
1311
+ */
1312
+ async generateSuggestion(context) {
1313
+ const prompt = `You are an AI coding assistant. Based on the following Claude Code conversation context, suggest the most valuable next instruction for the user (give the instruction directly, no explanation, no quotes):
1314
+
1315
+ ${context}`;
1316
+ return new Promise((resolve, reject) => {
1317
+ const env = { ...process.env };
1318
+ delete env.CLAUDECODE;
1319
+ const proc = (0, import_child_process.spawn)(CLAUDE_PATH, ["-p", prompt, "--output-format", "text"], {
1320
+ cwd: (0, import_node_os2.homedir)(),
1321
+ env,
1322
+ stdio: ["pipe", "pipe", "pipe"]
1323
+ });
1324
+ proc.stdin.end();
1325
+ let output = "";
1326
+ proc.stdout?.on("data", (data) => {
1327
+ output += data.toString();
1328
+ });
1329
+ proc.once("exit", (code) => {
1330
+ if (code === 0) {
1331
+ resolve(output.trim());
1332
+ } else {
1333
+ reject(new Error(`generateSuggestion process exit code: ${code}`));
1334
+ }
1335
+ });
1336
+ proc.once("error", reject);
1337
+ });
1338
+ }
1339
+ /**
1340
+ * 生成事件名称
1341
+ */
1342
+ getEventName(sessionId) {
1343
+ return `claude:${sessionId}`;
1344
+ }
1345
+ };
1346
+
1347
+ // src/providers/CodexProvider.ts
1348
+ var import_child_process2 = require("child_process");
1349
+ var import_readline3 = require("readline");
1350
+ var import_events2 = require("events");
1351
+ var import_fs = require("fs");
1352
+ var import_path2 = require("path");
1353
+ var import_os2 = require("os");
1354
+ var import_uuid2 = require("uuid");
1355
+
1356
+ // src/utils/codexPath.ts
1357
+ var import_node_child_process3 = require("child_process");
1358
+ var import_node_fs2 = require("fs");
1359
+ var import_node_path2 = require("path");
1360
+ var import_node_os3 = require("os");
1006
1361
  function findCodexPath() {
1007
1362
  try {
1008
1363
  const cmd = isWindows ? "where codex" : "which codex";
@@ -1074,9 +1429,9 @@ function isCodexAvailable() {
1074
1429
  }
1075
1430
 
1076
1431
  // src/providers/CodexProvider.ts
1077
- var SESSIX_DIR = (0, import_path.join)((0, import_os.homedir)(), ".sessix");
1078
- var CODEX_SESSIONS_FILE = (0, import_path.join)(SESSIX_DIR, "codex-sessions.json");
1079
- var CODEX_EVENTS_DIR = (0, import_path.join)(SESSIX_DIR, "codex-events");
1432
+ var SESSIX_DIR = (0, import_path2.join)((0, import_os2.homedir)(), ".sessix");
1433
+ var CODEX_SESSIONS_FILE = (0, import_path2.join)(SESSIX_DIR, "codex-sessions.json");
1434
+ var CODEX_EVENTS_DIR = (0, import_path2.join)(SESSIX_DIR, "codex-events");
1080
1435
  var SESSION_EXPIRY_MS = 30 * 24 * 60 * 60 * 1e3;
1081
1436
  var CodexProvider = class {
1082
1437
  activeSessions = /* @__PURE__ */ new Map();
@@ -1223,12 +1578,6 @@ var CodexProvider = class {
1223
1578
  async generateSuggestion(_context) {
1224
1579
  return "";
1225
1580
  }
1226
- async answerQuestion(_sessionId, _toolUseId, _answer) {
1227
- }
1228
- onQuestion(_sessionId, _callback) {
1229
- return () => {
1230
- };
1231
- }
1232
1581
  // ============================================
1233
1582
  // 私有方法
1234
1583
  // ============================================
@@ -1280,7 +1629,7 @@ var CodexProvider = class {
1280
1629
  */
1281
1630
  attachStdoutListener(sessionId, proc) {
1282
1631
  if (!proc.stdout) return;
1283
- const rl = (0, import_readline2.createInterface)({ input: proc.stdout, crlfDelay: Infinity });
1632
+ const rl = (0, import_readline3.createInterface)({ input: proc.stdout, crlfDelay: Infinity });
1284
1633
  const entry = this.activeSessions.get(sessionId);
1285
1634
  if (entry) entry.rl = rl;
1286
1635
  rl.on("line", (line) => {
@@ -1562,9 +1911,9 @@ var CodexProvider = class {
1562
1911
  * 优先从内存读,miss 时从磁盘加载
1563
1912
  */
1564
1913
  getSessionHistory(sessionId) {
1565
- const cached = this.sessionEvents.get(sessionId);
1566
- if (cached && cached.length > 0) return cached;
1567
- const filePath = (0, import_path.join)(CODEX_EVENTS_DIR, `${sessionId}.json`);
1914
+ const cached2 = this.sessionEvents.get(sessionId);
1915
+ if (cached2 && cached2.length > 0) return cached2;
1916
+ const filePath = (0, import_path2.join)(CODEX_EVENTS_DIR, `${sessionId}.json`);
1568
1917
  try {
1569
1918
  if (!(0, import_fs.existsSync)(filePath)) return [];
1570
1919
  const data = JSON.parse((0, import_fs.readFileSync)(filePath, "utf-8"));
@@ -1585,7 +1934,7 @@ var CodexProvider = class {
1585
1934
  (0, import_fs.mkdirSync)(CODEX_EVENTS_DIR, { recursive: true });
1586
1935
  }
1587
1936
  (0, import_fs.writeFileSync)(
1588
- (0, import_path.join)(CODEX_EVENTS_DIR, `${sessionId}.json`),
1937
+ (0, import_path2.join)(CODEX_EVENTS_DIR, `${sessionId}.json`),
1589
1938
  JSON.stringify(events),
1590
1939
  "utf-8"
1591
1940
  );
@@ -1610,7 +1959,7 @@ var CodexProvider = class {
1610
1959
  if (now - m.lastActiveAt > SESSION_EXPIRY_MS) {
1611
1960
  expiredCount++;
1612
1961
  try {
1613
- const eventsFile = (0, import_path.join)(CODEX_EVENTS_DIR, `${sessionId}.json`);
1962
+ const eventsFile = (0, import_path2.join)(CODEX_EVENTS_DIR, `${sessionId}.json`);
1614
1963
  if ((0, import_fs.existsSync)(eventsFile)) (0, import_fs.unlinkSync)(eventsFile);
1615
1964
  } catch {
1616
1965
  }
@@ -1723,7 +2072,6 @@ var ProviderFactory = class {
1723
2072
  };
1724
2073
 
1725
2074
  // src/session/SessionManager.ts
1726
- var import_uuid3 = require("uuid");
1727
2075
  var BUFFER_MAX = 5e3;
1728
2076
  var SessionManager = class {
1729
2077
  provider;
@@ -1731,10 +2079,18 @@ var SessionManager = class {
1731
2079
  sessionAgentType = /* @__PURE__ */ new Map();
1732
2080
  /** 事件回调列表(事件会被转发到 WsBridge) */
1733
2081
  eventCallbacks = [];
2082
+ /** 会话被移除(kill / 淘汰)时的回调列表(用于释放外部模块的会话级状态,如 NotificationService) */
2083
+ sessionRemovedCallbacks = [];
1734
2084
  /** 每个会话的事件流取消订阅函数 */
1735
2085
  unsubscribeMap = /* @__PURE__ */ new Map();
1736
2086
  /** 每个会话的事件缓冲区(用于新订阅者重放)*/
1737
2087
  sessionEventBuffers = /* @__PURE__ */ new Map();
2088
+ /**
2089
+ * 每个会话最近一次 AskUserQuestion tool_use 的真实 id(从 claude_event 流捕获)。
2090
+ * PreToolUse hook payload 不含 tool_use_id,但内联卡片需要它来匹配状态,
2091
+ * 故在转发流事件时记录,askQuestion 时兜底回填。
2092
+ */
2093
+ lastAskQuestionToolUseId = /* @__PURE__ */ new Map();
1738
2094
  /** AskUserQuestion 问题映射:requestId → resolve 回调 + 原始问题内容 */
1739
2095
  pendingQuestions = /* @__PURE__ */ new Map();
1740
2096
  /**
@@ -1843,6 +2199,7 @@ var SessionManager = class {
1843
2199
  this.bufferTruncated.delete(sessionId);
1844
2200
  this.sessionProjectPaths.delete(sessionId);
1845
2201
  this.sessionStats.delete(sessionId);
2202
+ this.lastAskQuestionToolUseId.delete(sessionId);
1846
2203
  const pending = this.pendingAssistantEvents.get(sessionId);
1847
2204
  if (pending) {
1848
2205
  clearTimeout(pending.timer);
@@ -1851,6 +2208,13 @@ var SessionManager = class {
1851
2208
  const provider = this.getProviderForSession(sessionId);
1852
2209
  await provider.killSession(sessionId);
1853
2210
  this.sessionAgentType.delete(sessionId);
2211
+ for (const cb of this.sessionRemovedCallbacks) {
2212
+ try {
2213
+ cb(sessionId);
2214
+ } catch (err) {
2215
+ console.error("[SessionManager] sessionRemoved callback failed:", err);
2216
+ }
2217
+ }
1854
2218
  console.log(`[SessionManager] Session killed: ${sessionId}`);
1855
2219
  }
1856
2220
  /**
@@ -1903,7 +2267,22 @@ var SessionManager = class {
1903
2267
  }
1904
2268
  }
1905
2269
  /**
1906
- * 获取指定会话的所有待回答问题(用于重连时恢复)
2270
+ * 幂等清理单个待回答问题(由 ApprovalProxy onQuestionResolved 触发:
2271
+ * 答案到达 / 325s 超时 / 会话 kill / 服务关闭)。
2272
+ * 已不存在则静默返回(不打 warn,与 handleQuestionResponse 区分)。
2273
+ */
2274
+ clearPendingQuestion(requestId) {
2275
+ const pending = this.pendingQuestions.get(requestId);
2276
+ if (!pending) return;
2277
+ const { sessionId } = pending;
2278
+ this.pendingQuestions.delete(requestId);
2279
+ pending.resolve("");
2280
+ if (!this.hasPendingQuestionsForSession(sessionId)) {
2281
+ this.updateSessionStatus(sessionId, "running");
2282
+ }
2283
+ }
2284
+ /**
2285
+ * 获取指定会话的所有待回答问题(用于重连时恢复)
1907
2286
  */
1908
2287
  getPendingQuestionsForSession(sessionId) {
1909
2288
  const result = [];
@@ -2026,6 +2405,21 @@ var SessionManager = class {
2026
2405
  }
2027
2406
  };
2028
2407
  }
2408
+ /**
2409
+ * 注册"会话被移除"回调(会话 kill 或淘汰时触发,传入 sessionId)。
2410
+ * 用于让外部模块释放会话级状态,如 NotificationService.releaseSession。
2411
+ *
2412
+ * @returns 取消注册的函数
2413
+ */
2414
+ onSessionRemoved(callback) {
2415
+ this.sessionRemovedCallbacks.push(callback);
2416
+ return () => {
2417
+ const index = this.sessionRemovedCallbacks.indexOf(callback);
2418
+ if (index !== -1) {
2419
+ this.sessionRemovedCallbacks.splice(index, 1);
2420
+ }
2421
+ };
2422
+ }
2029
2423
  /**
2030
2424
  * 清理所有资源
2031
2425
  */
@@ -2043,31 +2437,28 @@ var SessionManager = class {
2043
2437
  clearTimeout(pending.timer);
2044
2438
  }
2045
2439
  this.pendingAssistantEvents.clear();
2440
+ for (const pending of this.pendingQuestions.values()) {
2441
+ pending.resolve("");
2442
+ }
2046
2443
  this.pendingQuestions.clear();
2047
2444
  this.lastBroadcastStatus.clear();
2048
2445
  this.eventCallbacks.length = 0;
2446
+ this.sessionRemovedCallbacks.length = 0;
2049
2447
  console.log("[SessionManager] Destroyed");
2050
2448
  }
2051
2449
  // ============================================
2052
2450
  // 内部方法
2053
2451
  // ============================================
2054
2452
  /**
2055
- * 订阅指定会话的事件流(包括 AskUserQuestion 问题事件)
2453
+ * 订阅指定会话的事件流(AskUserQuestion 已改由 ApprovalProxy hook 驱动)
2056
2454
  */
2057
2455
  subscribeToSession(sessionId) {
2058
2456
  const provider = this.getProviderForSession(sessionId);
2059
2457
  const unsubscribeEvent = provider.onEvent(sessionId, (event) => {
2060
2458
  this.handleClaudeEvent(sessionId, event);
2061
2459
  });
2062
- const unsubscribeQuestion = provider.onQuestion(
2063
- sessionId,
2064
- ({ toolUseId, question, options, questions }) => {
2065
- this.handleAskUserQuestion(sessionId, toolUseId, question, options, questions);
2066
- }
2067
- );
2068
2460
  this.unsubscribeMap.set(sessionId, () => {
2069
2461
  unsubscribeEvent();
2070
- unsubscribeQuestion();
2071
2462
  });
2072
2463
  }
2073
2464
  /**
@@ -2101,6 +2492,13 @@ var SessionManager = class {
2101
2492
  console.log(`[SessionManager] \u{1F9E0} thinking block detected in ${sessionId}: msgId=${event.message.id}, blocks=${thinkingBlocks.length}, len=${thinkingBlocks.map((b) => (b.thinking || "").length).join(",")}`);
2102
2493
  }
2103
2494
  }
2495
+ if (event.type === "assistant" && Array.isArray(event.message?.content)) {
2496
+ for (const block of event.message.content) {
2497
+ if (block.type === "tool_use" && (block.name === "AskUserQuestion" || block.name === "AskFollowupQuestion") && typeof block.id === "string") {
2498
+ this.lastAskQuestionToolUseId.set(sessionId, block.id);
2499
+ }
2500
+ }
2501
+ }
2104
2502
  switch (event.type) {
2105
2503
  case "assistant":
2106
2504
  this.bufferAssistantEvent(sessionId, event);
@@ -2216,55 +2614,35 @@ var SessionManager = class {
2216
2614
  return runningStartedAt ? { ...base, runningStartedAt } : base;
2217
2615
  }
2218
2616
  /**
2219
- * 处理 AskUserQuestion 事件:广播问题请求到手机,等待用户回答
2617
+ * ApprovalProxy 在 PreToolUse hook 拦截到 AskUserQuestion 时调用。
2618
+ * 登记 pendingQuestion、广播 question_request、置 waiting_question,
2619
+ * 返回的 Promise 在 handleQuestionResponse 时 resolve。
2220
2620
  */
2221
- handleAskUserQuestion(sessionId, toolUseId, question, options, questions) {
2222
- const existingEntry = Array.from(this.pendingQuestions.entries()).find(
2223
- ([, v]) => v.toolUseId === toolUseId
2224
- );
2225
- if (existingEntry) {
2226
- const [existingRequestId, existingPending] = existingEntry;
2227
- existingPending.question = question;
2228
- existingPending.options = options;
2229
- existingPending.questions = questions;
2230
- existingPending.createdAt = Date.now();
2231
- const updatedRequest = {
2232
- id: existingRequestId,
2233
- sessionId,
2234
- toolUseId,
2235
- question,
2236
- options,
2237
- questions,
2238
- createdAt: existingPending.createdAt
2239
- };
2240
- this.emit({ type: "question_request", request: updatedRequest });
2241
- console.log(`[SessionManager] Session ${sessionId}: AskUserQuestion updated (requestId=${existingRequestId})`);
2242
- return;
2243
- }
2244
- const requestId = (0, import_uuid3.v4)();
2621
+ askQuestion(sessionId, toolUseId, questions, requestId) {
2622
+ const resolvedToolUseId = toolUseId || this.lastAskQuestionToolUseId.get(sessionId) || "";
2245
2623
  const request = {
2246
2624
  id: requestId,
2247
2625
  sessionId,
2248
- toolUseId,
2249
- question,
2250
- options,
2626
+ toolUseId: resolvedToolUseId,
2627
+ question: questions[0]?.question ?? "",
2628
+ options: questions[0]?.options?.map((o) => o.label),
2251
2629
  questions,
2252
2630
  createdAt: Date.now()
2253
2631
  };
2254
2632
  this.updateSessionStatus(sessionId, "waiting_question");
2255
2633
  this.emit({ type: "question_request", request });
2256
- const answerPromise = new Promise((resolve) => {
2257
- this.pendingQuestions.set(requestId, { sessionId, toolUseId, question, options, questions, createdAt: request.createdAt, resolve });
2258
- });
2259
- answerPromise.then(async (answer) => {
2260
- try {
2261
- const provider = this.getProviderForSession(sessionId);
2262
- await provider.answerQuestion(sessionId, toolUseId, answer);
2263
- } catch (err) {
2264
- console.error(`[SessionManager] answerQuestion failed (${sessionId}):`, err);
2265
- }
2266
- }).catch((err) => console.error("[SessionManager] answerPromise rejected:", err));
2267
2634
  console.log(`[SessionManager] Session ${sessionId}: AskUserQuestion pushed (requestId=${requestId})`);
2635
+ return new Promise((resolve) => {
2636
+ this.pendingQuestions.set(requestId, {
2637
+ sessionId,
2638
+ toolUseId: resolvedToolUseId,
2639
+ question: request.question,
2640
+ options: request.options,
2641
+ questions,
2642
+ createdAt: request.createdAt,
2643
+ resolve
2644
+ });
2645
+ });
2268
2646
  }
2269
2647
  /**
2270
2648
  * 清除指定会话的所有待回答问题
@@ -2277,6 +2655,7 @@ var SessionManager = class {
2277
2655
  }
2278
2656
  }
2279
2657
  for (const requestId of toRemove) {
2658
+ this.pendingQuestions.get(requestId)?.resolve("");
2280
2659
  this.pendingQuestions.delete(requestId);
2281
2660
  }
2282
2661
  }
@@ -2296,7 +2675,7 @@ var SessionManager = class {
2296
2675
 
2297
2676
  // src/session/SessionFileWatcher.ts
2298
2677
  var import_chokidar = __toESM(require("chokidar"));
2299
- var import_promises = require("fs/promises");
2678
+ var import_promises2 = require("fs/promises");
2300
2679
  var import_node_readline = require("readline");
2301
2680
  var SessionFileWatcher = class {
2302
2681
  watchers = /* @__PURE__ */ new Map();
@@ -2375,7 +2754,7 @@ var SessionFileWatcher = class {
2375
2754
  let fileHandle;
2376
2755
  let rl;
2377
2756
  try {
2378
- fileHandle = await (0, import_promises.open)(entry.filePath, "r");
2757
+ fileHandle = await (0, import_promises2.open)(entry.filePath, "r");
2379
2758
  const fileStat = await fileHandle.stat();
2380
2759
  const newSize = fileStat.size;
2381
2760
  if (newSize <= entry.byteOffset) return;
@@ -2685,7 +3064,7 @@ var import_node_http = __toESM(require("http"));
2685
3064
  var import_node_fs3 = __toESM(require("fs"));
2686
3065
  var import_node_path3 = __toESM(require("path"));
2687
3066
  var import_node_os4 = __toESM(require("os"));
2688
- var import_uuid4 = require("uuid");
3067
+ var import_uuid3 = require("uuid");
2689
3068
  var ApprovalProxy = class _ApprovalProxy {
2690
3069
  server;
2691
3070
  token;
@@ -2693,10 +3072,16 @@ var ApprovalProxy = class _ApprovalProxy {
2693
3072
  settingsPath = import_node_path3.default.join(import_node_os4.default.homedir(), ".claude", "settings.json");
2694
3073
  /** 待处理的审批请求:requestId -> { resolve, timer, request } */
2695
3074
  pendingApprovals = /* @__PURE__ */ new Map();
3075
+ /** 待回答的 AskUserQuestion:requestId -> { resolve, timer, request } */
3076
+ pendingQuestions = /* @__PURE__ */ new Map();
3077
+ /** 由外部注入:把问题推给手机并等待答案(返回用户答案文本) */
3078
+ questionHandler = null;
2696
3079
  /** 审批请求回调(通知外部推送到手机) */
2697
3080
  approvalRequestCallbacks = [];
2698
3081
  /** 审批 resolve 回调(任何来源的 resolve 都会触发,用于 WS 广播清理 */
2699
3082
  approvalResolvedCallbacks = [];
3083
+ /** 问题 resolve 回调(任何来源的 resolve 都会触发,用于 SessionManager 清理) */
3084
+ questionResolvedCallbacks = [];
2700
3085
  /** Hook 通知回调(PreCompact / PermissionDenied / 等非阻塞 hook) */
2701
3086
  notifyCallbacks = [];
2702
3087
  /** YOLO 模式状态:sessionId -> enabled */
@@ -2760,6 +3145,30 @@ var ApprovalProxy = class _ApprovalProxy {
2760
3145
  }
2761
3146
  }
2762
3147
  }
3148
+ /**
3149
+ * 注册问题 resolve 回调
3150
+ *
3151
+ * 任何来源的 resolve 都会触发:
3152
+ * - resolveQuestion(手机端答案到达)
3153
+ * - 325s 超时自动空答案
3154
+ * - clearPendingQuestionsForSession(会话被 kill)
3155
+ * - close()(服务关闭)
3156
+ *
3157
+ * 用于通知 SessionManager 清理 pendingQuestions,避免会话卡在 waiting_question。
3158
+ */
3159
+ onQuestionResolved(callback) {
3160
+ this.questionResolvedCallbacks.push(callback);
3161
+ }
3162
+ /** 通知所有问题 resolve 回调(内部调用) */
3163
+ notifyQuestionResolved(requestId) {
3164
+ for (const cb of this.questionResolvedCallbacks) {
3165
+ try {
3166
+ cb(requestId);
3167
+ } catch (err) {
3168
+ console.error("[ApprovalProxy] question resolved callback error:", err);
3169
+ }
3170
+ }
3171
+ }
2763
3172
  /**
2764
3173
  * 注册非阻塞 hook 通知回调(如 PreCompact、PermissionDenied)
2765
3174
  *
@@ -2814,6 +3223,42 @@ var ApprovalProxy = class _ApprovalProxy {
2814
3223
  this.notifyApprovalResolved(requestId, decision);
2815
3224
  return true;
2816
3225
  }
3226
+ /** 注入问题处理器(server.ts 接到 SessionManager.askQuestion) */
3227
+ setQuestionHandler(handler) {
3228
+ this.questionHandler = handler;
3229
+ }
3230
+ /** 解析一个待回答问题(手机端答案到达时由 server.ts 调用) */
3231
+ resolveQuestion(requestId, answer) {
3232
+ const pending = this.pendingQuestions.get(requestId);
3233
+ if (!pending) {
3234
+ console.warn(`[ApprovalProxy] Question request not found: ${requestId}`);
3235
+ return false;
3236
+ }
3237
+ clearTimeout(pending.timer);
3238
+ pending.resolve(answer);
3239
+ this.pendingQuestions.delete(requestId);
3240
+ console.log(`[ApprovalProxy] Question answered: ${requestId}`);
3241
+ this.notifyQuestionResolved(requestId);
3242
+ return true;
3243
+ }
3244
+ /** 清理会话的待回答问题(会话被 kill 时,给空答案让 hook 不再阻塞) */
3245
+ clearPendingQuestionsForSession(sessionId) {
3246
+ const toRemove = [];
3247
+ for (const [requestId, pending] of this.pendingQuestions) {
3248
+ if (pending.request.sessionId === sessionId) {
3249
+ toRemove.push(requestId);
3250
+ }
3251
+ }
3252
+ for (const requestId of toRemove) {
3253
+ const pending = this.pendingQuestions.get(requestId);
3254
+ if (!pending) continue;
3255
+ clearTimeout(pending.timer);
3256
+ pending.resolve("");
3257
+ this.pendingQuestions.delete(requestId);
3258
+ console.log(`[ApprovalProxy] Session ${sessionId} killed, cleared pending question ${requestId}`);
3259
+ this.notifyQuestionResolved(requestId);
3260
+ }
3261
+ }
2817
3262
  /** 获取当前待处理的审批数量 */
2818
3263
  getPendingCount() {
2819
3264
  return this.pendingApprovals.size;
@@ -2935,6 +3380,13 @@ var ApprovalProxy = class _ApprovalProxy {
2935
3380
  pending.resolve({ decision: "deny", reason: t("approval.serverClosed") });
2936
3381
  }
2937
3382
  this.pendingApprovals.clear();
3383
+ const pendingQuestionEntries = Array.from(this.pendingQuestions.entries());
3384
+ for (const [requestId, pending] of pendingQuestionEntries) {
3385
+ clearTimeout(pending.timer);
3386
+ pending.resolve("");
3387
+ this.notifyQuestionResolved(requestId);
3388
+ }
3389
+ this.pendingQuestions.clear();
2938
3390
  this.server.close((err) => {
2939
3391
  if (err) {
2940
3392
  reject(err);
@@ -2990,7 +3442,7 @@ var ApprovalProxy = class _ApprovalProxy {
2990
3442
  try {
2991
3443
  const body = await this.parseJsonBody(req);
2992
3444
  const payload = body.payload ?? body;
2993
- const requestId = (0, import_uuid4.v4)();
3445
+ const requestId = (0, import_uuid3.v4)();
2994
3446
  const projectPath = String(body.projectPath ?? "unknown");
2995
3447
  const toolName = String(payload.tool_name ?? body.tool_name ?? "unknown");
2996
3448
  const toolInput = payload.tool_input ?? body.tool_input ?? {};
@@ -3004,6 +3456,51 @@ var ApprovalProxy = class _ApprovalProxy {
3004
3456
  createdAt: Date.now()
3005
3457
  };
3006
3458
  console.log(`[ApprovalProxy] ${t("approval.received")}: ${requestId} (${approvalRequest.toolName})`);
3459
+ if ((approvalRequest.toolName === "AskUserQuestion" || approvalRequest.toolName === "AskFollowupQuestion") && this.questionHandler) {
3460
+ const questions = parseQuestionsFromInput(toolInput);
3461
+ if (questions.length === 0) {
3462
+ this.sendJson(res, 200, { decision: "allow" });
3463
+ return;
3464
+ }
3465
+ const toolUseId = String(
3466
+ payload.tool_use_id ?? body.tool_use_id ?? ""
3467
+ );
3468
+ const qRequest = {
3469
+ id: requestId,
3470
+ sessionId: approvalRequest.sessionId,
3471
+ toolUseId,
3472
+ question: questions[0].question,
3473
+ options: questions[0].options?.map((o) => o.label),
3474
+ questions,
3475
+ createdAt: Date.now()
3476
+ };
3477
+ const answer = await new Promise((resolve) => {
3478
+ const timer = setTimeout(() => {
3479
+ this.pendingQuestions.delete(requestId);
3480
+ console.log(`[ApprovalProxy] Question timeout: ${requestId}`);
3481
+ resolve("");
3482
+ this.notifyQuestionResolved(requestId);
3483
+ }, 325e3);
3484
+ this.pendingQuestions.set(requestId, { resolve, timer, request: qRequest });
3485
+ this.questionHandler(qRequest.sessionId, toolUseId, questions, requestId).then((ans) => {
3486
+ if (ans && this.pendingQuestions.has(requestId)) this.resolveQuestion(requestId, ans);
3487
+ }).catch((err) => console.error("[ApprovalProxy] questionHandler error:", err));
3488
+ });
3489
+ if (!answer) {
3490
+ this.sendJson(res, 200, {
3491
+ decision: "deny",
3492
+ reason: "\u7528\u6237\u672A\u5728\u9650\u5B9A\u65F6\u95F4\u5185\u56DE\u7B54\u8BE5\u95EE\u9898\u3002\u8BF7\u57FA\u4E8E\u73B0\u6709\u4FE1\u606F\u81EA\u884C\u51B3\u7B56\u540E\u7EE7\u7EED\uFF0C\u4E0D\u8981\u91CD\u590D\u8C03\u7528 AskUserQuestion\u3002",
3493
+ systemMessage: "\u7528\u6237\u672A\u56DE\u7B54 AskUserQuestion\uFF1B\u8BF7\u52FF\u91CD\u8BD5\u8BE5\u5DE5\u5177\uFF0C\u81EA\u884C\u51B3\u7B56\u7EE7\u7EED\u3002"
3494
+ });
3495
+ return;
3496
+ }
3497
+ this.sendJson(res, 200, {
3498
+ decision: "deny",
3499
+ reason: formatQuestionAnswer(questions, answer),
3500
+ systemMessage: "\u7528\u6237\u5DF2\u901A\u8FC7 Sessix \u79FB\u52A8\u7AEF\u56DE\u7B54\u4E86 AskUserQuestion\uFF0C\u56DE\u7B54\u5728\u5DE5\u5177\u53CD\u9988\u4E2D\u7ED9\u51FA\u3002\u8BF7\u5C06\u5176\u89C6\u4E3A\u8BE5\u5DE5\u5177\u7684\u6709\u6548\u8FD4\u56DE\u76F4\u63A5\u7EE7\u7EED\uFF0C\u4E0D\u8981\u518D\u6B21\u8C03\u7528 AskUserQuestion/AskFollowupQuestion\u3002"
3501
+ });
3502
+ return;
3503
+ }
3007
3504
  if (this.isToolAlwaysAllowed(approvalRequest.toolName, projectPath !== "unknown" ? projectPath : void 0)) {
3008
3505
  console.log(`[ApprovalProxy] ${t("approval.alwaysAllowPassThrough", { tool: approvalRequest.toolName })}`);
3009
3506
  this.sendJson(res, 200, { decision: "allow" });
@@ -3193,6 +3690,49 @@ var ApprovalProxy = class _ApprovalProxy {
3193
3690
  res.end(body);
3194
3691
  }
3195
3692
  };
3693
+ function parseQuestionsFromInput(input) {
3694
+ if (Array.isArray(input.questions) && input.questions.length > 0) {
3695
+ return input.questions.map((q) => {
3696
+ const item = {
3697
+ question: typeof q.question === "string" ? q.question : "",
3698
+ header: typeof q.header === "string" ? q.header : void 0,
3699
+ multiSelect: typeof q.multiSelect === "boolean" ? q.multiSelect : void 0
3700
+ };
3701
+ if (Array.isArray(q.options)) {
3702
+ item.options = q.options.map((o) => ({
3703
+ label: typeof o.label === "string" ? o.label : "",
3704
+ description: typeof o.description === "string" ? o.description : void 0
3705
+ }));
3706
+ }
3707
+ return item;
3708
+ });
3709
+ }
3710
+ if (typeof input.question === "string") {
3711
+ const opts = Array.isArray(input.options) ? input.options.map((o) => ({ label: String(o) })) : void 0;
3712
+ return [{ question: input.question, options: opts }];
3713
+ }
3714
+ return [];
3715
+ }
3716
+ function formatQuestionAnswer(questions, raw) {
3717
+ let pairs = [];
3718
+ try {
3719
+ const parsed = JSON.parse(raw);
3720
+ if (parsed && typeof parsed === "object" && parsed.answers && typeof parsed.answers === "object") {
3721
+ const answers = parsed.answers;
3722
+ pairs = questions.length > 0 ? questions.map((item) => ({ q: item.question, a: String(answers[item.question] ?? "") })) : Object.entries(answers).map(([q, a]) => ({ q, a: String(a) }));
3723
+ }
3724
+ } catch {
3725
+ }
3726
+ if (pairs.length === 0) {
3727
+ pairs = [{ q: questions[0]?.question ?? "\uFF08\u672A\u77E5\u95EE\u9898\uFF09", a: raw }];
3728
+ }
3729
+ const body = pairs.map((p, i) => `${i + 1}. \u95EE\u9898\uFF1A${p.q}
3730
+ \u7528\u6237\u56DE\u7B54\uFF1A${p.a}`).join("\n");
3731
+ return `\u3010\u7528\u6237\u5DF2\u901A\u8FC7 Sessix \u56DE\u7B54\u3011
3732
+ ${body}
3733
+
3734
+ \u8BF7\u5C06\u4EE5\u4E0A\u56DE\u7B54\u89C6\u4E3A AskUserQuestion \u7684\u6709\u6548\u8FD4\u56DE\uFF0C\u76F4\u63A5\u636E\u6B64\u7EE7\u7EED\u5B8C\u6210\u4EFB\u52A1\uFF0C\u4E0D\u8981\u518D\u6B21\u8C03\u7528 AskUserQuestion/AskFollowupQuestion\u3002`;
3735
+ }
3196
3736
 
3197
3737
  // src/mdns/MdnsService.ts
3198
3738
  var import_node_child_process5 = require("child_process");
@@ -3358,7 +3898,7 @@ function getLanAddresses(networkInterfacesFn) {
3358
3898
  }
3359
3899
 
3360
3900
  // src/hooks/HookInstaller.ts
3361
- var import_promises2 = require("fs/promises");
3901
+ var import_promises3 = require("fs/promises");
3362
3902
  var import_node_path4 = require("path");
3363
3903
  var import_node_os6 = require("os");
3364
3904
  var SESSIX_HOOKS_DIR = (0, import_node_path4.join)((0, import_node_os6.homedir)(), ".sessix", "hooks");
@@ -3378,7 +3918,7 @@ var LEGACY_HOOK_COMMANDS = [
3378
3918
  "~/.sessix/hooks/permission-accept.sh"
3379
3919
  ];
3380
3920
  var HOOK_SCRIPT_TEMPLATE = `#!/usr/bin/env node
3381
- // Sessix Approval Hook
3921
+ // Sessix Approval Hook v2 (systemMessage passthrough)
3382
3922
  // \u4EC5\u5728 Sessix \u7BA1\u7406\u7684\u4F1A\u8BDD\u4E2D\u6FC0\u6D3B
3383
3923
 
3384
3924
  const sessionId = process.env.SESSIX_SESSION_ID
@@ -3409,6 +3949,9 @@ process.stdin.on('end', async () => {
3409
3949
  if (decision === 'deny' && data.reason) {
3410
3950
  output.hookSpecificOutput.permissionDecisionReason = String(data.reason)
3411
3951
  }
3952
+ if (data.systemMessage) {
3953
+ output.systemMessage = String(data.systemMessage)
3954
+ }
3412
3955
  process.stdout.write(JSON.stringify(output))
3413
3956
  process.exit(0)
3414
3957
  } catch {
@@ -3548,17 +4091,17 @@ var HookInstaller = class {
3548
4091
  * 4. 更新 Claude Code settings.json 添加 hook 配置
3549
4092
  */
3550
4093
  async install() {
3551
- await (0, import_promises2.mkdir)(SESSIX_HOOKS_DIR, { recursive: true });
3552
- await (0, import_promises2.writeFile)(HOOK_SCRIPT_PATH, HOOK_SCRIPT_TEMPLATE, "utf-8");
3553
- await (0, import_promises2.writeFile)(PERMISSION_ACCEPT_PATH, PERMISSION_ACCEPT_TEMPLATE, "utf-8");
3554
- await (0, import_promises2.writeFile)(COMPACT_HOOK_PATH, COMPACT_HOOK_TEMPLATE, "utf-8");
3555
- await (0, import_promises2.writeFile)(POST_COMPACT_HOOK_PATH, POST_COMPACT_HOOK_TEMPLATE, "utf-8");
3556
- await (0, import_promises2.writeFile)(PERMISSION_DENIED_HOOK_PATH, PERMISSION_DENIED_HOOK_TEMPLATE, "utf-8");
3557
- await (0, import_promises2.chmod)(HOOK_SCRIPT_PATH, 493);
3558
- await (0, import_promises2.chmod)(PERMISSION_ACCEPT_PATH, 493);
3559
- await (0, import_promises2.chmod)(COMPACT_HOOK_PATH, 493);
3560
- await (0, import_promises2.chmod)(POST_COMPACT_HOOK_PATH, 493);
3561
- await (0, import_promises2.chmod)(PERMISSION_DENIED_HOOK_PATH, 493);
4094
+ await (0, import_promises3.mkdir)(SESSIX_HOOKS_DIR, { recursive: true });
4095
+ await (0, import_promises3.writeFile)(HOOK_SCRIPT_PATH, HOOK_SCRIPT_TEMPLATE, "utf-8");
4096
+ await (0, import_promises3.writeFile)(PERMISSION_ACCEPT_PATH, PERMISSION_ACCEPT_TEMPLATE, "utf-8");
4097
+ await (0, import_promises3.writeFile)(COMPACT_HOOK_PATH, COMPACT_HOOK_TEMPLATE, "utf-8");
4098
+ await (0, import_promises3.writeFile)(POST_COMPACT_HOOK_PATH, POST_COMPACT_HOOK_TEMPLATE, "utf-8");
4099
+ await (0, import_promises3.writeFile)(PERMISSION_DENIED_HOOK_PATH, PERMISSION_DENIED_HOOK_TEMPLATE, "utf-8");
4100
+ await (0, import_promises3.chmod)(HOOK_SCRIPT_PATH, 493);
4101
+ await (0, import_promises3.chmod)(PERMISSION_ACCEPT_PATH, 493);
4102
+ await (0, import_promises3.chmod)(COMPACT_HOOK_PATH, 493);
4103
+ await (0, import_promises3.chmod)(POST_COMPACT_HOOK_PATH, 493);
4104
+ await (0, import_promises3.chmod)(PERMISSION_DENIED_HOOK_PATH, 493);
3562
4105
  await this.addHookToSettings();
3563
4106
  console.log("[HookInstaller] Hook installation complete");
3564
4107
  }
@@ -3588,33 +4131,34 @@ var HookInstaller = class {
3588
4131
  let postCompactScriptExists = false;
3589
4132
  let permissionDeniedScriptExists = false;
3590
4133
  try {
3591
- approvalScriptContent = await (0, import_promises2.readFile)(HOOK_SCRIPT_PATH, "utf-8");
4134
+ approvalScriptContent = await (0, import_promises3.readFile)(HOOK_SCRIPT_PATH, "utf-8");
3592
4135
  } catch {
3593
4136
  }
3594
4137
  try {
3595
- await (0, import_promises2.access)(PERMISSION_ACCEPT_PATH);
4138
+ await (0, import_promises3.access)(PERMISSION_ACCEPT_PATH);
3596
4139
  permissionScriptExists = true;
3597
4140
  } catch {
3598
4141
  }
3599
4142
  try {
3600
- await (0, import_promises2.access)(COMPACT_HOOK_PATH);
4143
+ await (0, import_promises3.access)(COMPACT_HOOK_PATH);
3601
4144
  compactScriptExists = true;
3602
4145
  } catch {
3603
4146
  }
3604
4147
  try {
3605
- await (0, import_promises2.access)(POST_COMPACT_HOOK_PATH);
4148
+ await (0, import_promises3.access)(POST_COMPACT_HOOK_PATH);
3606
4149
  postCompactScriptExists = true;
3607
4150
  } catch {
3608
4151
  }
3609
4152
  try {
3610
- await (0, import_promises2.access)(PERMISSION_DENIED_HOOK_PATH);
4153
+ await (0, import_promises3.access)(PERMISSION_DENIED_HOOK_PATH);
3611
4154
  permissionDeniedScriptExists = true;
3612
4155
  } catch {
3613
4156
  }
3614
- const isLatestVersion = approvalScriptContent.includes("permissionDecision");
4157
+ const isLatestVersion = approvalScriptContent.includes("permissionDecision") && approvalScriptContent.includes("Sessix Approval Hook v2");
3615
4158
  const settings = await this.readClaudeSettings();
3616
4159
  const configExists = this.hasHookConfig(settings);
3617
- return isLatestVersion && permissionScriptExists && compactScriptExists && postCompactScriptExists && permissionDeniedScriptExists && configExists;
4160
+ const hasLegacyHook = this.hasHookEntry(settings?.hooks?.PreToolUse, LEGACY_HOOK_COMMANDS[0]) || this.hasHookEntry(settings?.hooks?.PermissionRequest, LEGACY_HOOK_COMMANDS[1]);
4161
+ return isLatestVersion && permissionScriptExists && compactScriptExists && postCompactScriptExists && permissionDeniedScriptExists && configExists && !hasLegacyHook;
3618
4162
  }
3619
4163
  // ============================================
3620
4164
  // 内部方法
@@ -3626,8 +4170,14 @@ var HookInstaller = class {
3626
4170
  let settings = await this.readClaudeSettings();
3627
4171
  let changed = false;
3628
4172
  for (const cmd of LEGACY_HOOK_COMMANDS) {
3629
- this.removeHookCommand(settings, "PreToolUse", cmd);
3630
- this.removeHookCommand(settings, "PermissionRequest", cmd);
4173
+ if (this.hasHookEntry(settings?.hooks?.PreToolUse, cmd)) {
4174
+ this.removeHookCommand(settings, "PreToolUse", cmd);
4175
+ changed = true;
4176
+ }
4177
+ if (this.hasHookEntry(settings?.hooks?.PermissionRequest, cmd)) {
4178
+ this.removeHookCommand(settings, "PermissionRequest", cmd);
4179
+ changed = true;
4180
+ }
3631
4181
  }
3632
4182
  if (!settings.hooks) {
3633
4183
  settings.hooks = {};
@@ -3719,7 +4269,7 @@ var HookInstaller = class {
3719
4269
  */
3720
4270
  async readClaudeSettings() {
3721
4271
  try {
3722
- const content = await (0, import_promises2.readFile)(CLAUDE_SETTINGS_PATH, "utf-8");
4272
+ const content = await (0, import_promises3.readFile)(CLAUDE_SETTINGS_PATH, "utf-8");
3723
4273
  return JSON.parse(content);
3724
4274
  } catch {
3725
4275
  return {};
@@ -3729,8 +4279,8 @@ var HookInstaller = class {
3729
4279
  * 写入 Claude Code settings.json
3730
4280
  */
3731
4281
  async writeClaudeSettings(settings) {
3732
- await (0, import_promises2.mkdir)((0, import_node_path4.join)((0, import_node_os6.homedir)(), ".claude"), { recursive: true });
3733
- await (0, import_promises2.writeFile)(CLAUDE_SETTINGS_PATH, JSON.stringify(settings, null, 2) + "\n", "utf-8");
4282
+ await (0, import_promises3.mkdir)((0, import_node_path4.join)((0, import_node_os6.homedir)(), ".claude"), { recursive: true });
4283
+ await (0, import_promises3.writeFile)(CLAUDE_SETTINGS_PATH, JSON.stringify(settings, null, 2) + "\n", "utf-8");
3734
4284
  }
3735
4285
  /**
3736
4286
  * 检查 settings 中是否已包含所有 Sessix hook 配置
@@ -3771,7 +4321,7 @@ var HookInstaller = class {
3771
4321
  var import_node_path5 = require("path");
3772
4322
  var RECENT_ACTIVITY_MAX = 6;
3773
4323
  var ACTIVITY_PUSH_THROTTLE_MS = 4e3;
3774
- var NotificationService = class {
4324
+ var NotificationService = class _NotificationService {
3775
4325
  constructor(sessionManager, expoChannel = null) {
3776
4326
  this.sessionManager = sessionManager;
3777
4327
  this.expoChannel = expoChannel;
@@ -3813,6 +4363,14 @@ var NotificationService = class {
3813
4363
  * token 注册时启动,flushActivityEnd / removeActivityPushToken 时停止。
3814
4364
  */
3815
4365
  laHeartbeatTimers = /* @__PURE__ */ new Map();
4366
+ /**
4367
+ * 上次推送的内容指纹(status + recentActivity + approvalId)。
4368
+ * 只在内容实际变化时发 priority-10 推送;未变化时低频刷新(30s),
4369
+ * 节省 APNs push budget,避免 iOS 节流导致 LA 停滞。
4370
+ */
4371
+ lastPushedFingerprint = /* @__PURE__ */ new Map();
4372
+ /** 内容未变化时低频刷新间隔(仅刷新 stats/timer,不含内容变化) */
4373
+ static STATS_REFRESH_INTERVAL_MS = 3e4;
3816
4374
  /** 添加通知渠道(id 唯一,可用于后续动态开关) */
3817
4375
  addChannel(id, channel, enabled = true) {
3818
4376
  this.channelMap.set(id, { channel, enabled });
@@ -3869,6 +4427,7 @@ var NotificationService = class {
3869
4427
  this.recentActivityState.delete(sessionId);
3870
4428
  this.lastActivityPushAt.delete(sessionId);
3871
4429
  this.activityCounters.delete(sessionId);
4430
+ this.lastPushedFingerprint.delete(sessionId);
3872
4431
  }
3873
4432
  /** 注入"会话 → 待审批列表"提供器(server.ts 启动时调用) */
3874
4433
  setPendingApprovalsProvider(fn) {
@@ -4015,10 +4574,39 @@ var NotificationService = class {
4015
4574
  this.latestAssistantText.clear();
4016
4575
  for (const timer of this.activityPushTimers.values()) clearTimeout(timer);
4017
4576
  this.activityPushTimers.clear();
4577
+ for (const timer of this.idleEndTimers.values()) clearTimeout(timer);
4578
+ this.idleEndTimers.clear();
4579
+ for (const timer of this.laHeartbeatTimers.values()) clearInterval(timer);
4580
+ this.laHeartbeatTimers.clear();
4018
4581
  this.recentActivityState.clear();
4019
4582
  this.lastActivityPushAt.clear();
4020
4583
  this.pendingPriority.clear();
4021
4584
  this.activityCounters.clear();
4585
+ this.lastPushedFingerprint.clear();
4586
+ }
4587
+ /**
4588
+ * 释放单个会话的全部内存状态(会话被 kill 或淘汰时调用)。
4589
+ * 由 SessionManager.onSessionRemoved 钩子触发,覆盖用户主动 kill 和自动淘汰两条路径。
4590
+ * 幂等:重复调用或对未知会话调用都安全。
4591
+ */
4592
+ releaseSession(sessionId) {
4593
+ this.clearActivityPushTimer(sessionId);
4594
+ this.cancelIdleEndTimer(sessionId);
4595
+ this.stopLaHeartbeat(sessionId);
4596
+ this.clearSessionActivityState(sessionId);
4597
+ this.yoloModeState.delete(sessionId);
4598
+ this.lastActivityPushAt.delete(sessionId);
4599
+ this.lastPushedFingerprint.delete(sessionId);
4600
+ this.pendingPriority.delete(sessionId);
4601
+ }
4602
+ /**
4603
+ * 清空单会话可重建的重状态(recentActivity / 计数器 / 最新文本)。
4604
+ * 会话走到 idle 时调用即可释放内存——resume 后这些状态会随新事件自动重建。
4605
+ */
4606
+ clearSessionActivityState(sessionId) {
4607
+ this.recentActivityState.delete(sessionId);
4608
+ this.activityCounters.delete(sessionId);
4609
+ this.latestAssistantText.delete(sessionId);
4022
4610
  }
4023
4611
  // ============================================
4024
4612
  // 内部方法
@@ -4057,6 +4645,7 @@ var NotificationService = class {
4057
4645
  badge: this.getGlobalPendingCount(),
4058
4646
  data: { type: "task_complete", sessionId: event.sessionId }
4059
4647
  });
4648
+ this.clearSessionActivityState(event.sessionId);
4060
4649
  }
4061
4650
  } else if (event.status === "running" || event.status === "waiting_approval" || event.status === "waiting_question") {
4062
4651
  this.cancelIdleEndTimer(event.sessionId);
@@ -4119,6 +4708,8 @@ var NotificationService = class {
4119
4708
  while (state2.history.length > RECENT_ACTIVITY_MAX) state2.history.shift();
4120
4709
  state2.currentEntries = [];
4121
4710
  state2.currentMessageId = null;
4711
+ state2.accumulatedText = "";
4712
+ state2.countedToolIds = /* @__PURE__ */ new Set();
4122
4713
  }
4123
4714
  return;
4124
4715
  }
@@ -4127,7 +4718,7 @@ var NotificationService = class {
4127
4718
  if (!Array.isArray(msg.content)) return;
4128
4719
  let state = this.recentActivityState.get(sessionId);
4129
4720
  if (!state) {
4130
- state = { history: [], currentMessageId: null, currentEntries: [] };
4721
+ state = { history: [], currentMessageId: null, currentEntries: [], accumulatedText: "", countedToolIds: /* @__PURE__ */ new Set() };
4131
4722
  this.recentActivityState.set(sessionId, state);
4132
4723
  }
4133
4724
  if (state.currentMessageId !== msg.id) {
@@ -4137,16 +4728,25 @@ var NotificationService = class {
4137
4728
  }
4138
4729
  state.currentEntries = [];
4139
4730
  state.currentMessageId = msg.id;
4731
+ state.accumulatedText = "";
4732
+ state.countedToolIds = /* @__PURE__ */ new Set();
4733
+ }
4734
+ for (const block of msg.content) {
4735
+ if (block.type === "text" && typeof block.text === "string") {
4736
+ state.accumulatedText += block.text;
4737
+ }
4140
4738
  }
4141
4739
  const next = [];
4740
+ const accText = this.summarizeText(state.accumulatedText);
4741
+ if (accText.length >= 4) next.push(accText);
4142
4742
  for (const block of msg.content) {
4143
- if (block.type === "text") {
4144
- const line = this.summarizeText(block.text);
4145
- if (line.length >= 4) next.push(line);
4146
- } else if (block.type === "tool_use") {
4743
+ if (block.type === "tool_use") {
4147
4744
  const line = this.summarizeToolCall(block.name, block.input ?? {});
4148
4745
  if (line) next.push(line);
4149
- this.incrementCounter(sessionId, block.name);
4746
+ if (!state.countedToolIds.has(block.id)) {
4747
+ state.countedToolIds.add(block.id);
4748
+ this.incrementCounter(sessionId, block.name);
4749
+ }
4150
4750
  }
4151
4751
  }
4152
4752
  state.currentEntries = next;
@@ -4262,7 +4862,7 @@ var NotificationService = class {
4262
4862
  return;
4263
4863
  }
4264
4864
  if (session.status === "running" || session.status === "waiting_approval" || session.status === "waiting_question") {
4265
- this.scheduleActivityPush(sessionId);
4865
+ this.scheduleActivityPush(sessionId, true);
4266
4866
  }
4267
4867
  }, ACTIVITY_PUSH_THROTTLE_MS);
4268
4868
  this.laHeartbeatTimers.set(sessionId, timer);
@@ -4334,17 +4934,38 @@ var NotificationService = class {
4334
4934
  });
4335
4935
  }
4336
4936
  this.stopLaHeartbeat(sessionId);
4337
- this.recentActivityState.delete(sessionId);
4937
+ this.clearSessionActivityState(sessionId);
4338
4938
  this.lastActivityPushAt.delete(sessionId);
4339
- this.activityCounters.delete(sessionId);
4939
+ this.lastPushedFingerprint.delete(sessionId);
4340
4940
  console.log(`[NotificationService] \u{1F3C1} LA end (${reason}) session=${sessionId.slice(0, 8)}\u2026`);
4341
4941
  }
4942
+ /**
4943
+ * 计算内容指纹:status + recentActivity + latestApproval。
4944
+ * 用于判断 LA 内容是否实际变化,避免重复推送消耗 APNs budget。
4945
+ */
4946
+ computeContentFingerprint(sessionId) {
4947
+ const activity = this.getRecentActivity(sessionId);
4948
+ const session = this.sessionManager.getActiveSessions().find((s) => s.id === sessionId);
4949
+ const approvals = this.pendingApprovalsProvider?.(sessionId) ?? [];
4950
+ const latestApproval = approvals[approvals.length - 1];
4951
+ return `${session?.status ?? ""}|${activity.join(" ")}|${latestApproval?.id ?? ""}`;
4952
+ }
4342
4953
  /** 真正发送一次 LA content push(无 alert) */
4343
4954
  flushActivityPush(sessionId) {
4344
4955
  const channel = this.activityPushChannel;
4345
4956
  if (!channel?.hasToken(sessionId)) return;
4346
4957
  const session = this.sessionManager.getActiveSessions().find((s) => s.id === sessionId);
4347
4958
  if (!session) return;
4959
+ const fingerprint = this.computeContentFingerprint(sessionId);
4960
+ const lastFingerprint = this.lastPushedFingerprint.get(sessionId);
4961
+ const contentChanged = fingerprint !== lastFingerprint;
4962
+ if (!contentChanged) {
4963
+ const lastPush = this.lastActivityPushAt.get(sessionId) ?? 0;
4964
+ if (Date.now() - lastPush < _NotificationService.STATS_REFRESH_INTERVAL_MS) {
4965
+ return;
4966
+ }
4967
+ }
4968
+ this.lastPushedFingerprint.set(sessionId, fingerprint);
4348
4969
  const recentActivity = this.getRecentActivity(sessionId);
4349
4970
  const latestMessage = recentActivity[recentActivity.length - 1] ?? this.latestAssistantText.get(sessionId) ?? "";
4350
4971
  const sessionTitle = this.getSessionTitle(sessionId);
@@ -4373,13 +4994,14 @@ var NotificationService = class {
4373
4994
  };
4374
4995
  }
4375
4996
  contentState.stats = this.buildStatsPayload(session);
4376
- const priority = this.pendingPriority.get(sessionId) ?? "5";
4997
+ const explicitPriority = this.pendingPriority.get(sessionId);
4998
+ const priority = explicitPriority ?? (contentChanged ? "10" : "5");
4377
4999
  this.pendingPriority.delete(sessionId);
4378
5000
  this.lastActivityPushAt.set(sessionId, Date.now());
4379
5001
  const lineCount = recentActivity.length;
4380
5002
  channel.updateActivity(sessionId, contentState, { priority }).then((ok) => {
4381
5003
  if (ok) {
4382
- console.log(`[NotificationService] \u{1F4E1} LA push \u2713 session=${sessionId.slice(0, 8)}\u2026 status=${status} p=${priority} lines=${lineCount}`);
5004
+ console.log(`[NotificationService] \u{1F4E1} LA push \u2713 session=${sessionId.slice(0, 8)}\u2026 status=${status} p=${priority} changed=${contentChanged} lines=${lineCount}`);
4383
5005
  }
4384
5006
  }).catch((err) => {
4385
5007
  console.warn(`[NotificationService] \u{1F4E1} LA push \u2717 session=${sessionId.slice(0, 8)}\u2026:`, err instanceof Error ? err.message : err);
@@ -4508,6 +5130,8 @@ var DesktopNotificationChannel = class {
4508
5130
 
4509
5131
  // src/notification/ExpoNotificationChannel.ts
4510
5132
  var EXPO_PUSH_API = "https://exp.host/--/api/v2/push/send";
5133
+ var EXPO_RECEIPT_API = "https://exp.host/--/api/v2/push/getReceipts";
5134
+ var RECEIPT_CHECK_DELAY_MS = 1e4;
4511
5135
  var ExpoNotificationChannel = class {
4512
5136
  tokens = /* @__PURE__ */ new Set();
4513
5137
  /** push token → WebSocket 连接映射,用于前台抑制 */
@@ -4555,6 +5179,7 @@ var ExpoNotificationChannel = class {
4555
5179
  if (prefs) {
4556
5180
  const notifType = payload.data?.type ?? "";
4557
5181
  if (notifType === "approval_request" && prefs.approval) sound = prefs.approval;
5182
+ else if (notifType === "question_request" && prefs.approval) sound = prefs.approval;
4558
5183
  else if (notifType === "task_complete" && prefs.taskComplete) sound = prefs.taskComplete;
4559
5184
  else if (notifType === "task_error" && prefs.taskError) sound = prefs.taskError;
4560
5185
  }
@@ -4585,6 +5210,7 @@ var ExpoNotificationChannel = class {
4585
5210
  console.warn(`[ExpoNotificationChannel] ${t("notification.pushApiFormatError")}`, JSON.stringify(body));
4586
5211
  return;
4587
5212
  }
5213
+ const receiptIdToToken = /* @__PURE__ */ new Map();
4588
5214
  for (let i = 0; i < body.data.length; i++) {
4589
5215
  const ticket = body.data[i];
4590
5216
  if (ticket.status === "error") {
@@ -4597,13 +5223,63 @@ var ExpoNotificationChannel = class {
4597
5223
  this.soundPreferences.delete(staleToken);
4598
5224
  console.warn(`[ExpoNotificationChannel] \u26A0\uFE0F \u5DF2\u79FB\u9664\u5931\u6548 token\uFF08DeviceNotRegistered\uFF09\u3002\u82E5\u901A\u77E5\u672A\u6062\u590D\uFF0C\u8BF7\u91CD\u542F App \u91CD\u65B0\u6CE8\u518C push token\u3002`);
4599
5225
  }
5226
+ } else if (ticket.status === "ok" && typeof ticket.id === "string" && targetTokens[i]) {
5227
+ receiptIdToToken.set(ticket.id, targetTokens[i]);
4600
5228
  }
4601
5229
  }
5230
+ this.scheduleReceiptCheck(receiptIdToToken);
4602
5231
  }
4603
5232
  } catch (err) {
4604
5233
  console.warn(`[ExpoNotificationChannel] ${t("notification.sendFailed")}`, err);
4605
5234
  }
4606
5235
  }
5236
+ /**
5237
+ * Expo push 二阶段:延迟查 receipt,暴露 ticket 阶段看不到的 APNs 投递失败。
5238
+ *
5239
+ * 关键诊断点:InvalidCredentials / MismatchSenderId 表示 Expo 项目的 APNs
5240
+ * 凭证配置问题(不是用户机器问题)——这正是"只有开发者能收到推送"的根因,
5241
+ * 且 ticket 全为 ok、不查 receipt 永远静默。
5242
+ */
5243
+ scheduleReceiptCheck(receiptIdToToken) {
5244
+ if (receiptIdToToken.size === 0) return;
5245
+ const timer = setTimeout(async () => {
5246
+ try {
5247
+ const ids = Array.from(receiptIdToToken.keys());
5248
+ const res = await fetch(EXPO_RECEIPT_API, {
5249
+ method: "POST",
5250
+ headers: { "Content-Type": "application/json", Accept: "application/json" },
5251
+ body: JSON.stringify({ ids })
5252
+ });
5253
+ const body = await res.json();
5254
+ if (!res.ok || !body?.data || typeof body.data !== "object") {
5255
+ console.warn("[ExpoNotificationChannel] \u26A0\uFE0F push receipt \u67E5\u8BE2\u5931\u8D25", res.status, JSON.stringify(body));
5256
+ return;
5257
+ }
5258
+ const receipts = body.data;
5259
+ for (const [receiptId, receipt] of Object.entries(receipts)) {
5260
+ if (receipt?.status !== "error") continue;
5261
+ const errorCode = receipt.details?.error ?? "unknown";
5262
+ const token = receiptIdToToken.get(receiptId);
5263
+ console.error(
5264
+ `[ExpoNotificationChannel] \u274C APNs \u6295\u9012\u5931\u8D25 receipt=${receiptId} error=${errorCode}` + (receipt.message ? ` \u2014 ${receipt.message}` : "")
5265
+ );
5266
+ if (errorCode === "DeviceNotRegistered" && token) {
5267
+ this.tokens.delete(token);
5268
+ this.tokenWsMap.delete(token);
5269
+ this.soundPreferences.delete(token);
5270
+ console.warn("[ExpoNotificationChannel] \u26A0\uFE0F \u5DF2\u79FB\u9664\u5931\u6548 token\uFF08receipt DeviceNotRegistered\uFF09\u3002\u91CD\u542F App \u53EF\u91CD\u65B0\u6CE8\u518C\u3002");
5271
+ } else if (errorCode === "InvalidCredentials" || errorCode === "MismatchSenderId") {
5272
+ console.error(
5273
+ "[ExpoNotificationChannel] \u26A0\uFE0F \u8FD9\u662F Expo \u9879\u76EE\u7684 APNs \u51ED\u8BC1\u914D\u7F6E\u95EE\u9898\uFF08\u4E0D\u662F\u7528\u6237\u673A\u5668\u95EE\u9898\uFF09\uFF1A\n Expo \u63A5\u53D7\u4E86\u63A8\u9001\u4F46 APNs \u62D2\u6295\uFF0C\u901A\u5E38\u56E0\u4E3A\u8BE5 Expo \u9879\u76EE\u672A\u914D\u7F6E\u53EF\u6295\u9012\u5230\u3010\u751F\u4EA7\u73AF\u5883\u3011\u7684 APNs Auth Key\u3002\n \u6392\u67E5\uFF1Acd packages/mobile && npx eas credentials -p ios \u2014\u2014 \u786E\u8BA4\u5B58\u5728\u8986\u76D6\u751F\u4EA7\u7684 APNs Push Key(.p8)\u3002"
5274
+ );
5275
+ }
5276
+ }
5277
+ } catch (err) {
5278
+ console.warn("[ExpoNotificationChannel] \u26A0\uFE0F push receipt \u67E5\u8BE2\u5F02\u5E38:", err);
5279
+ }
5280
+ }, RECEIPT_CHECK_DELAY_MS);
5281
+ timer.unref?.();
5282
+ }
4607
5283
  };
4608
5284
 
4609
5285
  // src/notification/ActivityPushChannel.ts
@@ -4898,470 +5574,134 @@ var ActivityPushChannel = class {
4898
5574
  const reason = err instanceof ApnsError ? JSON.parse(err.responseBody || "{}")?.reason ?? err.statusCode : String(err);
4899
5575
  console.log(`[ActivityPushChannel] \u{1F50D} probe ${env} failed: ${reason}`);
4900
5576
  if (isProviderTokenError(err)) {
4901
- console.error(
4902
- `[ActivityPushChannel] \u274C APNs Auth Key \u5DF2\u5931\u6548\uFF08${reason}\uFF09\u3002\u8BF7\u5230 Apple Developer \u4E0B\u8F7D\u65B0\u7684 .p8 \u6587\u4EF6\uFF0C\u5E76\u66F4\u65B0 ~/.sessix/apns.json \u4E2D\u7684 keyId \u548C authKeyPath\uFF0C\u7136\u540E\u91CD\u542F\u670D\u52A1\u7AEF\u3002`
4903
- );
4904
- throw err;
4905
- }
4906
- if (!isBadDeviceTokenError(err)) {
4907
- throw err;
4908
- }
4909
- }
4910
- }
4911
- this.deadTokens.add(deviceToken);
4912
- for (const [sid, tok] of this.tokens) {
4913
- if (tok === deviceToken) {
4914
- this.tokens.delete(sid);
4915
- console.warn(`[ActivityPushChannel] \u2620\uFE0F dead token, session removed: session=${sid.slice(0, 8)}\u2026 token=${short}\u2026 (Activity ended/iOS reinstalled)`);
4916
- break;
4917
- }
4918
- }
4919
- throw lastErr ?? new Error("APNs send failed: all environments rejected token");
4920
- }
4921
- /** 单次 APNs HTTP/2 请求(内部 helper,不做探测) */
4922
- async sendToAPNsOnce(deviceToken, payload, opts, env) {
4923
- const topic = opts.topic ?? `${this.bundleId}.push-type.liveactivity`;
4924
- const pushType = opts.pushType ?? "liveactivity";
4925
- const jwt = this.getJWT();
4926
- const payloadStr = JSON.stringify(payload);
4927
- const priority = opts.priority ?? "10";
4928
- return new Promise((resolve, reject) => {
4929
- let client;
4930
- try {
4931
- client = this.getHttp2Client(env);
4932
- } catch (err) {
4933
- return reject(err);
4934
- }
4935
- const headers = {
4936
- ":method": "POST",
4937
- ":path": `/3/device/${deviceToken}`,
4938
- "authorization": `bearer ${jwt}`,
4939
- "apns-topic": topic,
4940
- "apns-push-type": pushType,
4941
- "apns-priority": priority,
4942
- "apns-expiration": String(Math.floor(Date.now() / 1e3) + 300),
4943
- "content-type": "application/json",
4944
- "content-length": Buffer.byteLength(payloadStr)
4945
- };
4946
- if (opts.collapseId) {
4947
- headers["apns-collapse-id"] = opts.collapseId;
4948
- }
4949
- const req = client.request(headers);
4950
- let statusCode = 0;
4951
- let responseData = "";
4952
- req.on("response", (headers2) => {
4953
- statusCode = Number(headers2[":status"] ?? 0);
4954
- });
4955
- req.on("data", (chunk) => {
4956
- responseData += chunk;
4957
- });
4958
- req.on("end", () => {
4959
- if (statusCode === 200) {
4960
- resolve();
4961
- } else {
4962
- if (statusCode === 0) {
4963
- const c = this.http2Clients[env];
4964
- c?.destroy();
4965
- delete this.http2Clients[env];
4966
- }
4967
- reject(new ApnsError(statusCode, responseData));
4968
- }
4969
- });
4970
- req.on("error", (err) => {
4971
- reject(err);
4972
- });
4973
- req.write(payloadStr);
4974
- req.end();
4975
- });
4976
- }
4977
- /** 生成或获取缓存的 APNs JWT token */
4978
- getJWT() {
4979
- const now = Math.floor(Date.now() / 1e3);
4980
- if (this.cachedJwt && this.cachedJwt.expiresAt > now) {
4981
- return this.cachedJwt.token;
4982
- }
4983
- const header = Buffer.from(JSON.stringify({
4984
- alg: "ES256",
4985
- kid: this.keyId
4986
- })).toString("base64url");
4987
- const claims = Buffer.from(JSON.stringify({
4988
- iss: this.teamId,
4989
- iat: now
4990
- })).toString("base64url");
4991
- const signingInput = `${header}.${claims}`;
4992
- const sign = crypto.createSign("SHA256");
4993
- sign.update(signingInput);
4994
- const signature = sign.sign({ key: this.authKey, dsaEncoding: "ieee-p1363" }, "base64url");
4995
- const token = `${signingInput}.${signature}`;
4996
- this.cachedJwt = { token, expiresAt: now + 3e3 };
4997
- return token;
4998
- }
4999
- };
5000
- var ApnsError = class extends Error {
5001
- constructor(statusCode, responseBody) {
5002
- super(`APNs returned ${statusCode}: ${responseBody}`);
5003
- this.statusCode = statusCode;
5004
- this.responseBody = responseBody;
5005
- this.name = "ApnsError";
5006
- }
5007
- };
5008
- function isProviderTokenError(err) {
5009
- if (!(err instanceof ApnsError)) return false;
5010
- if (err.statusCode !== 403) return false;
5011
- try {
5012
- const parsed = JSON.parse(err.responseBody);
5013
- return parsed.reason === "InvalidProviderToken" || parsed.reason === "ExpiredProviderToken";
5014
- } catch {
5015
- return false;
5016
- }
5017
- }
5018
- function isBadDeviceTokenError(err) {
5019
- if (!(err instanceof ApnsError)) return false;
5020
- if (err.statusCode !== 400 && err.statusCode !== 403 && err.statusCode !== 410) return false;
5021
- try {
5022
- const parsed = JSON.parse(err.responseBody);
5023
- return parsed.reason === "BadDeviceToken" || parsed.reason === "BadEnvironmentKeyInToken" || parsed.reason === "Unregistered";
5024
- } catch {
5025
- return false;
5026
- }
5027
- }
5028
-
5029
- // src/session/ProjectReader.ts
5030
- var import_promises3 = require("fs/promises");
5031
- var import_readline3 = require("readline");
5032
- var import_path2 = require("path");
5033
- var import_os2 = require("os");
5034
- var CLAUDE_PROJECTS_DIR = (0, import_path2.join)((0, import_os2.homedir)(), ".claude", "projects");
5035
- function getSessionFilePath(projectPath, sessionId) {
5036
- return (0, import_path2.join)(CLAUDE_PROJECTS_DIR, encodeDirName(projectPath), `${sessionId}.jsonl`);
5037
- }
5038
- async function getProjects() {
5039
- try {
5040
- const dirExists = await directoryExists(CLAUDE_PROJECTS_DIR);
5041
- if (!dirExists) {
5042
- return { ok: true, value: [] };
5043
- }
5044
- const entries = await (0, import_promises3.readdir)(CLAUDE_PROJECTS_DIR, { withFileTypes: true });
5045
- const projects = [];
5046
- for (const entry of entries) {
5047
- if (!entry.isDirectory() || entry.name.startsWith(".")) {
5048
- continue;
5049
- }
5050
- const encodedPath = entry.name;
5051
- const decodedPath = decodeDirName(encodedPath);
5052
- const name = decodedPath.split("/").filter(Boolean).pop() ?? encodedPath;
5053
- const projectDir = (0, import_path2.join)(CLAUDE_PROJECTS_DIR, encodedPath);
5054
- const { count: sessionCount, latestMtime } = await countJsonlFilesWithMtime(projectDir);
5055
- projects.push({
5056
- id: encodedPath,
5057
- path: decodedPath,
5058
- name,
5059
- sessionCount,
5060
- lastActiveAt: latestMtime
5061
- });
5062
- }
5063
- projects.sort((a, b) => a.name.localeCompare(b.name));
5064
- return { ok: true, value: projects };
5065
- } catch (err) {
5066
- return {
5067
- ok: false,
5068
- error: err instanceof Error ? err : new Error(String(err))
5069
- };
5070
- }
5071
- }
5072
- async function getHistoricalSessions(projectPath) {
5073
- try {
5074
- const encodedPath = encodeDirName(projectPath);
5075
- const projectDir = (0, import_path2.join)(CLAUDE_PROJECTS_DIR, encodedPath);
5076
- const dirExists = await directoryExists(projectDir);
5077
- if (!dirExists) {
5078
- return { ok: true, value: [] };
5079
- }
5080
- const entries = await (0, import_promises3.readdir)(projectDir, { withFileTypes: true });
5081
- const jsonlFiles = entries.filter((e) => e.isFile() && e.name.endsWith(".jsonl"));
5082
- const mtimeMap = /* @__PURE__ */ new Map();
5083
- await Promise.all(
5084
- jsonlFiles.map(async (entry) => {
5085
- const sessionId = entry.name.slice(0, -6);
5086
- const filePath = (0, import_path2.join)(projectDir, entry.name);
5087
- try {
5088
- const contentTs = await extractLastTimestamp(filePath);
5089
- if (contentTs) {
5090
- mtimeMap.set(sessionId, contentTs);
5091
- } else {
5092
- const fileStat = await (0, import_promises3.stat)(filePath);
5093
- mtimeMap.set(sessionId, fileStat.mtimeMs);
5094
- }
5095
- } catch {
5096
- mtimeMap.set(sessionId, 0);
5097
- }
5098
- })
5099
- );
5100
- const uuidDirs = entries.filter(
5101
- (e) => e.isDirectory() && UUID_RE.test(e.name) && !mtimeMap.has(e.name)
5102
- );
5103
- for (const entry of uuidDirs) {
5104
- try {
5105
- const fileStat = await (0, import_promises3.stat)((0, import_path2.join)(projectDir, entry.name));
5106
- mtimeMap.set(entry.name, fileStat.mtimeMs);
5107
- } catch {
5108
- mtimeMap.set(entry.name, 0);
5109
- }
5110
- }
5111
- const indexPath = (0, import_path2.join)(projectDir, "sessions-index.json");
5112
- const sessionMap = /* @__PURE__ */ new Map();
5113
- try {
5114
- const indexContent = await (0, import_promises3.readFile)(indexPath, "utf-8");
5115
- const indexData = JSON.parse(indexContent);
5116
- if (indexData.version === 1 && Array.isArray(indexData.entries)) {
5117
- for (const entry of indexData.entries) {
5118
- const mtime = mtimeMap.get(entry.sessionId) ?? entry.fileMtime ?? (entry.modified ? new Date(entry.modified).getTime() : 0);
5119
- sessionMap.set(entry.sessionId, {
5120
- sessionId: entry.sessionId,
5121
- lastModified: mtime,
5122
- summary: entry.summary,
5123
- firstPrompt: entry.firstPrompt,
5124
- messageCount: entry.messageCount
5125
- });
5126
- }
5127
- await Promise.all(
5128
- Array.from(sessionMap.values()).filter((s) => (s.messageCount ?? 0) > 0 && !s.summary && !s.firstPrompt).map(async (s) => {
5129
- const filePath = (0, import_path2.join)(projectDir, `${s.sessionId}.jsonl`);
5130
- const firstPrompt = await extractFirstPrompt(filePath).catch(() => void 0);
5131
- if (firstPrompt) s.firstPrompt = firstPrompt;
5132
- })
5133
- );
5134
- }
5135
- } catch {
5136
- }
5137
- const uuidDirSet = new Set(uuidDirs.map((e) => e.name));
5138
- for (const [sessionId, mtime] of mtimeMap) {
5139
- if (!sessionMap.has(sessionId)) {
5140
- if (uuidDirSet.has(sessionId)) {
5141
- sessionMap.set(sessionId, { sessionId, lastModified: mtime, messageCount: -1 });
5142
- } else {
5143
- const filePath = (0, import_path2.join)(projectDir, `${sessionId}.jsonl`);
5144
- const firstPrompt = await extractFirstPrompt(filePath).catch(() => void 0);
5145
- sessionMap.set(sessionId, { sessionId, lastModified: mtime, firstPrompt });
5146
- }
5147
- }
5148
- }
5149
- const sessions = Array.from(sessionMap.values()).filter((s) => {
5150
- if (s.messageCount === 0) return false;
5151
- if (s.messageCount === -1) return true;
5152
- if (s.firstPrompt === void 0 && s.messageCount === void 0) return false;
5153
- return true;
5154
- });
5155
- sessions.sort((a, b) => b.lastModified - a.lastModified);
5156
- return { ok: true, value: sessions };
5157
- } catch (err) {
5158
- return {
5159
- ok: false,
5160
- error: err instanceof Error ? err : new Error(String(err))
5161
- };
5162
- }
5163
- }
5164
- async function getSessionHistory(projectPath, sessionId) {
5165
- try {
5166
- const encodedPath = encodeDirName(projectPath);
5167
- const filePath = (0, import_path2.join)(CLAUDE_PROJECTS_DIR, encodedPath, `${sessionId}.jsonl`);
5168
- const raw = await (0, import_promises3.readFile)(filePath, "utf-8").catch((err) => {
5169
- if (err.code === "ENOENT") return null;
5170
- throw err;
5171
- });
5172
- if (raw === null) return { ok: true, value: [] };
5173
- const lines = raw.split("\n").filter((l) => l.trim());
5174
- const events = [];
5175
- for (const line of lines) {
5176
- try {
5177
- const obj = JSON.parse(line);
5178
- const type = obj.type;
5179
- if (type === "user" && obj.message) {
5180
- const msgContent = obj.message.content;
5181
- if (typeof msgContent === "string") {
5182
- if (msgContent.includes("<local-command") || msgContent.includes("<command-name>")) continue;
5183
- } else if (Array.isArray(msgContent)) {
5184
- const hasText = msgContent.some(
5185
- (b) => b.type === "text" && !b.text?.includes("<local-command") && !b.text?.includes("<command-name>")
5186
- );
5187
- if (!hasText) continue;
5188
- }
5189
- const normalizedContent = typeof msgContent === "string" ? [{ type: "text", text: msgContent }] : Array.isArray(msgContent) ? msgContent.filter((b) => b.type === "text" && typeof b.text === "string") : [];
5190
- if (normalizedContent.length === 0) continue;
5191
- events.push({
5192
- type: "user",
5193
- message: {
5194
- ...obj.message,
5195
- content: normalizedContent
5196
- },
5197
- session_id: sessionId
5198
- });
5199
- } else if (type === "assistant" && obj.message) {
5200
- const content = (obj.message.content ?? []).filter(
5201
- (b) => b.type === "text" || b.type === "tool_use" || b.type === "thinking"
5202
- );
5203
- if (content.length === 0) continue;
5204
- events.push({
5205
- type: "assistant",
5206
- message: {
5207
- id: obj.message.id ?? obj.uuid ?? `hist-${events.length}`,
5208
- model: obj.message.model ?? "unknown",
5209
- role: "assistant",
5210
- content,
5211
- stop_reason: obj.message.stop_reason,
5212
- usage: obj.message.usage
5213
- },
5214
- session_id: sessionId
5215
- });
5577
+ console.error(
5578
+ `[ActivityPushChannel] \u274C APNs Auth Key \u5DF2\u5931\u6548\uFF08${reason}\uFF09\u3002\u8BF7\u5230 Apple Developer \u4E0B\u8F7D\u65B0\u7684 .p8 \u6587\u4EF6\uFF0C\u5E76\u66F4\u65B0 ~/.sessix/apns.json \u4E2D\u7684 keyId \u548C authKeyPath\uFF0C\u7136\u540E\u91CD\u542F\u670D\u52A1\u7AEF\u3002`
5579
+ );
5580
+ throw err;
5216
5581
  }
5217
- } catch {
5218
- }
5219
- }
5220
- if (events.length > 0) {
5221
- let totalInputTokens = 0;
5222
- let totalOutputTokens = 0;
5223
- for (const ev of events) {
5224
- if (ev.type === "assistant" && ev.message.usage) {
5225
- totalInputTokens += ev.message.usage.input_tokens ?? 0;
5226
- totalOutputTokens += ev.message.usage.output_tokens ?? 0;
5582
+ if (!isBadDeviceTokenError(err)) {
5583
+ throw err;
5227
5584
  }
5228
5585
  }
5229
- if (totalInputTokens > 0 || totalOutputTokens > 0) {
5230
- events.push({
5231
- type: "result",
5232
- subtype: "success",
5233
- is_error: false,
5234
- duration_ms: 0,
5235
- num_turns: events.filter((e) => e.type === "user").length,
5236
- result: "",
5237
- session_id: sessionId,
5238
- usage: { input_tokens: totalInputTokens, output_tokens: totalOutputTokens }
5239
- });
5240
- }
5241
5586
  }
5242
- return { ok: true, value: events };
5243
- } catch (err) {
5244
- return {
5245
- ok: false,
5246
- error: err instanceof Error ? err : new Error(String(err))
5247
- };
5248
- }
5249
- }
5250
- async function extractLastTimestamp(filePath) {
5251
- let fileHandle;
5252
- try {
5253
- fileHandle = await (0, import_promises3.open)(filePath, "r");
5254
- const fileStat = await fileHandle.stat();
5255
- const readSize = Math.min(fileStat.size, 8192);
5256
- const buffer = Buffer.alloc(readSize);
5257
- await fileHandle.read(buffer, 0, readSize, fileStat.size - readSize);
5258
- const tail = buffer.toString("utf-8");
5259
- const lines = tail.split("\n").filter((l) => l.trim());
5260
- for (let i = lines.length - 1; i >= 0; i--) {
5261
- try {
5262
- const obj = JSON.parse(lines[i]);
5263
- if (obj.timestamp) {
5264
- const ts = new Date(obj.timestamp).getTime();
5265
- if (!isNaN(ts)) return ts;
5266
- }
5267
- } catch {
5587
+ this.deadTokens.add(deviceToken);
5588
+ for (const [sid, tok] of this.tokens) {
5589
+ if (tok === deviceToken) {
5590
+ this.tokens.delete(sid);
5591
+ console.warn(`[ActivityPushChannel] \u2620\uFE0F dead token, session removed: session=${sid.slice(0, 8)}\u2026 token=${short}\u2026 (Activity ended/iOS reinstalled)`);
5592
+ break;
5268
5593
  }
5269
5594
  }
5270
- } catch {
5271
- } finally {
5272
- await fileHandle?.close();
5595
+ throw lastErr ?? new Error("APNs send failed: all environments rejected token");
5273
5596
  }
5274
- return void 0;
5275
- }
5276
- async function extractFirstPrompt(filePath) {
5277
- let fileHandle;
5278
- try {
5279
- fileHandle = await (0, import_promises3.open)(filePath, "r");
5280
- const rl = (0, import_readline3.createInterface)({
5281
- input: fileHandle.createReadStream({ encoding: "utf-8" }),
5282
- crlfDelay: Infinity
5283
- });
5284
- let lineCount = 0;
5285
- for await (const line of rl) {
5286
- if (++lineCount > 20) break;
5287
- if (!line.trim()) continue;
5597
+ /** 单次 APNs HTTP/2 请求(内部 helper,不做探测) */
5598
+ async sendToAPNsOnce(deviceToken, payload, opts, env) {
5599
+ const topic = opts.topic ?? `${this.bundleId}.push-type.liveactivity`;
5600
+ const pushType = opts.pushType ?? "liveactivity";
5601
+ const jwt = this.getJWT();
5602
+ const payloadStr = JSON.stringify(payload);
5603
+ const priority = opts.priority ?? "10";
5604
+ return new Promise((resolve, reject) => {
5605
+ let client;
5288
5606
  try {
5289
- const obj = JSON.parse(line);
5290
- if (obj.type === "user" && obj.message) {
5291
- const msgContent = obj.message.content;
5292
- let text = "";
5293
- if (typeof msgContent === "string") {
5294
- text = msgContent;
5295
- } else if (Array.isArray(msgContent)) {
5296
- const textBlock = msgContent.find((b) => b.type === "text" && typeof b.text === "string");
5297
- text = textBlock?.text ?? "";
5298
- }
5299
- if (text && !text.includes("<local-command") && !text.includes("<command-name>")) {
5300
- text = text.replace(/<ide_opened_file>[\s\S]*?<\/ide_opened_file>/gi, "");
5301
- text = text.replace(/<[^>]+>/g, "").trim();
5302
- rl.close();
5303
- return text.length > 80 ? text.slice(0, 80) + "..." : text;
5607
+ client = this.getHttp2Client(env);
5608
+ } catch (err) {
5609
+ return reject(err);
5610
+ }
5611
+ const headers = {
5612
+ ":method": "POST",
5613
+ ":path": `/3/device/${deviceToken}`,
5614
+ "authorization": `bearer ${jwt}`,
5615
+ "apns-topic": topic,
5616
+ "apns-push-type": pushType,
5617
+ "apns-priority": priority,
5618
+ "apns-expiration": String(Math.floor(Date.now() / 1e3) + 300),
5619
+ "content-type": "application/json",
5620
+ "content-length": Buffer.byteLength(payloadStr)
5621
+ };
5622
+ if (opts.collapseId) {
5623
+ headers["apns-collapse-id"] = opts.collapseId;
5624
+ }
5625
+ const req = client.request(headers);
5626
+ req.setTimeout(1e4, () => {
5627
+ req.close(http2.constants.NGHTTP2_CANCEL);
5628
+ });
5629
+ let statusCode = 0;
5630
+ let responseData = "";
5631
+ req.on("response", (headers2) => {
5632
+ statusCode = Number(headers2[":status"] ?? 0);
5633
+ });
5634
+ req.on("data", (chunk) => {
5635
+ responseData += chunk;
5636
+ });
5637
+ req.on("end", () => {
5638
+ if (statusCode === 200) {
5639
+ resolve();
5640
+ } else {
5641
+ if (statusCode === 0) {
5642
+ const c = this.http2Clients[env];
5643
+ c?.destroy();
5644
+ delete this.http2Clients[env];
5304
5645
  }
5646
+ reject(new ApnsError(statusCode, responseData));
5305
5647
  }
5306
- } catch {
5307
- }
5648
+ });
5649
+ req.on("error", (err) => {
5650
+ reject(err);
5651
+ });
5652
+ req.write(payloadStr);
5653
+ req.end();
5654
+ });
5655
+ }
5656
+ /** 生成或获取缓存的 APNs JWT token */
5657
+ getJWT() {
5658
+ const now = Math.floor(Date.now() / 1e3);
5659
+ if (this.cachedJwt && this.cachedJwt.expiresAt > now) {
5660
+ return this.cachedJwt.token;
5308
5661
  }
5309
- } catch {
5310
- } finally {
5311
- await fileHandle?.close();
5662
+ const header = Buffer.from(JSON.stringify({
5663
+ alg: "ES256",
5664
+ kid: this.keyId
5665
+ })).toString("base64url");
5666
+ const claims = Buffer.from(JSON.stringify({
5667
+ iss: this.teamId,
5668
+ iat: now
5669
+ })).toString("base64url");
5670
+ const signingInput = `${header}.${claims}`;
5671
+ const sign = crypto.createSign("SHA256");
5672
+ sign.update(signingInput);
5673
+ const signature = sign.sign({ key: this.authKey, dsaEncoding: "ieee-p1363" }, "base64url");
5674
+ const token = `${signingInput}.${signature}`;
5675
+ this.cachedJwt = { token, expiresAt: now + 3e3 };
5676
+ return token;
5312
5677
  }
5313
- return void 0;
5314
- }
5315
- function decodeDirName(dirName) {
5316
- const placeholder = "\0";
5317
- const escaped = dirName.replace(/--/g, placeholder);
5318
- const decoded = escaped.replace(/-/g, "/");
5319
- return decoded.replace(new RegExp(placeholder, "g"), "-");
5320
- }
5321
- function encodeDirName(path2) {
5322
- const escaped = path2.replace(/-/g, "--");
5323
- return escaped.replace(/\//g, "-");
5324
- }
5325
- async function directoryExists(dirPath) {
5678
+ };
5679
+ var ApnsError = class extends Error {
5680
+ constructor(statusCode, responseBody) {
5681
+ super(`APNs returned ${statusCode}: ${responseBody}`);
5682
+ this.statusCode = statusCode;
5683
+ this.responseBody = responseBody;
5684
+ this.name = "ApnsError";
5685
+ }
5686
+ };
5687
+ function isProviderTokenError(err) {
5688
+ if (!(err instanceof ApnsError)) return false;
5689
+ if (err.statusCode !== 403) return false;
5326
5690
  try {
5327
- const s = await (0, import_promises3.stat)(dirPath);
5328
- return s.isDirectory();
5691
+ const parsed = JSON.parse(err.responseBody);
5692
+ return parsed.reason === "InvalidProviderToken" || parsed.reason === "ExpiredProviderToken";
5329
5693
  } catch {
5330
5694
  return false;
5331
5695
  }
5332
5696
  }
5333
- var UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
5334
- async function countJsonlFilesWithMtime(dirPath) {
5697
+ function isBadDeviceTokenError(err) {
5698
+ if (!(err instanceof ApnsError)) return false;
5699
+ if (err.statusCode !== 400 && err.statusCode !== 403 && err.statusCode !== 410) return false;
5335
5700
  try {
5336
- const entries = await (0, import_promises3.readdir)(dirPath, { withFileTypes: true });
5337
- const jsonlNames = new Set(
5338
- entries.filter((e) => e.isFile() && e.name.endsWith(".jsonl")).map((e) => e.name.slice(0, -6))
5339
- );
5340
- const uuidDirs = entries.filter(
5341
- (e) => e.isDirectory() && UUID_RE.test(e.name) && !jsonlNames.has(e.name)
5342
- );
5343
- let latestMtime = 0;
5344
- const jsonlEntries = entries.filter((e) => e.isFile() && e.name.endsWith(".jsonl"));
5345
- await Promise.all([
5346
- ...jsonlEntries.map(async (entry) => {
5347
- try {
5348
- const contentTs = await extractLastTimestamp((0, import_path2.join)(dirPath, entry.name));
5349
- const ts = contentTs ?? (await (0, import_promises3.stat)((0, import_path2.join)(dirPath, entry.name))).mtimeMs;
5350
- if (ts > latestMtime) latestMtime = ts;
5351
- } catch {
5352
- }
5353
- }),
5354
- ...uuidDirs.map(async (entry) => {
5355
- try {
5356
- const fileStat = await (0, import_promises3.stat)((0, import_path2.join)(dirPath, entry.name));
5357
- if (fileStat.mtimeMs > latestMtime) latestMtime = fileStat.mtimeMs;
5358
- } catch {
5359
- }
5360
- })
5361
- ]);
5362
- return { count: jsonlNames.size + uuidDirs.length, latestMtime };
5701
+ const parsed = JSON.parse(err.responseBody);
5702
+ return parsed.reason === "BadDeviceToken" || parsed.reason === "BadEnvironmentKeyInToken" || parsed.reason === "Unregistered";
5363
5703
  } catch {
5364
- return { count: 0, latestMtime: 0 };
5704
+ return false;
5365
5705
  }
5366
5706
  }
5367
5707
 
@@ -5487,7 +5827,10 @@ var AuthManager = class extends import_events3.EventEmitter {
5487
5827
  email: parsed.email,
5488
5828
  authMethod: parsed.authMethod
5489
5829
  };
5490
- } catch {
5830
+ } catch (err) {
5831
+ console.warn(
5832
+ `[AuthManager] checkAuth \u5931\u8D25 (claudePath=${CLAUDE_PATH2}): ${err instanceof Error ? err.message : String(err)}`
5833
+ );
5491
5834
  return { loggedIn: false };
5492
5835
  }
5493
5836
  }
@@ -5583,7 +5926,7 @@ var AuthManager = class extends import_events3.EventEmitter {
5583
5926
 
5584
5927
  // src/terminal/TerminalExecutor.ts
5585
5928
  var import_node_child_process8 = require("child_process");
5586
- var import_uuid5 = require("uuid");
5929
+ var import_uuid4 = require("uuid");
5587
5930
  var EXEC_TIMEOUT_MS = 30 * 60 * 1e3;
5588
5931
  var TerminalExecutor = class {
5589
5932
  processes = /* @__PURE__ */ new Map();
@@ -5605,7 +5948,7 @@ var TerminalExecutor = class {
5605
5948
  }
5606
5949
  }
5607
5950
  exec(sessionId, command, cwd) {
5608
- const execId = (0, import_uuid5.v4)();
5951
+ const execId = (0, import_uuid4.v4)();
5609
5952
  const shell = isWindows ? "powershell" : process.env.SHELL || "/bin/zsh";
5610
5953
  const args = isWindows ? ["-Command", command] : ["-l", "-c", command];
5611
5954
  const proc = (0, import_node_child_process8.spawn)(shell, args, {
@@ -5682,7 +6025,7 @@ var import_node_util = require("util");
5682
6025
  var import_promises4 = require("fs/promises");
5683
6026
  var import_node_path6 = require("path");
5684
6027
  var import_node_os7 = require("os");
5685
- var import_uuid6 = require("uuid");
6028
+ var import_uuid5 = require("uuid");
5686
6029
  var execAsync = (0, import_node_util.promisify)(import_node_child_process9.exec);
5687
6030
  var BUILD_TIMEOUT_MS = 30 * 60 * 1e3;
5688
6031
  var INSTALL_TIMEOUT_MS = 5 * 60 * 1e3;
@@ -5693,7 +6036,6 @@ var SKIP_DIRS = /* @__PURE__ */ new Set([
5693
6036
  "DerivedData",
5694
6037
  "Pods",
5695
6038
  ".build",
5696
- "build",
5697
6039
  "dist",
5698
6040
  "__pycache__",
5699
6041
  ".next",
@@ -5870,7 +6212,7 @@ ${e.stderr ?? ""}`);
5870
6212
  return null;
5871
6213
  }
5872
6214
  if (override) await this.saveConfig(projectPath, override);
5873
- const buildId = (0, import_uuid6.v4)();
6215
+ const buildId = (0, import_uuid5.v4)();
5874
6216
  const args = buildArgs(config);
5875
6217
  const proc = (0, import_node_child_process9.spawn)("xcodebuild", args, {
5876
6218
  cwd: projectPath,
@@ -5934,7 +6276,7 @@ ${e.stderr ?? ""}`);
5934
6276
  this.emitInstallError(sessionId, "", "\u672A\u627E\u5230\u6784\u5EFA\u914D\u7F6E\uFF0C\u8BF7\u5148\u6784\u5EFA\u4E00\u6B21\n");
5935
6277
  return null;
5936
6278
  }
5937
- const installId = (0, import_uuid6.v4)();
6279
+ const installId = (0, import_uuid5.v4)();
5938
6280
  let appPath;
5939
6281
  try {
5940
6282
  appPath = await this.getAppPath(projectPath, config);
@@ -6505,7 +6847,7 @@ function sourceWeight(s) {
6505
6847
  // src/git/GitExecutor.ts
6506
6848
  var import_node_child_process10 = require("child_process");
6507
6849
  var import_node_util2 = require("util");
6508
- var import_uuid7 = require("uuid");
6850
+ var import_uuid6 = require("uuid");
6509
6851
  var execAsync2 = (0, import_node_util2.promisify)(import_node_child_process10.exec);
6510
6852
  var STATUS_TIMEOUT_MS = 15e3;
6511
6853
  var COMMIT_TIMEOUT_MS = 6e4;
@@ -6625,7 +6967,7 @@ var GitExecutor = class {
6625
6967
  * - 若未提供 files:默认 git add -A(提交所有变更)
6626
6968
  */
6627
6969
  async commit(sessionId, projectPath, message, files, alsoPush) {
6628
- const opId = (0, import_uuid7.v4)();
6970
+ const opId = (0, import_uuid6.v4)();
6629
6971
  this.runSequence(sessionId, opId, "commit", projectPath, [
6630
6972
  files && files.length > 0 ? ["git", "add", "--", ...files] : ["git", "add", "-A"],
6631
6973
  ["git", "commit", "-m", message]
@@ -6641,7 +6983,7 @@ var GitExecutor = class {
6641
6983
  return opId;
6642
6984
  }
6643
6985
  async push(sessionId, projectPath) {
6644
- const opId = (0, import_uuid7.v4)();
6986
+ const opId = (0, import_uuid6.v4)();
6645
6987
  this.runSequence(sessionId, opId, "push", projectPath, [
6646
6988
  ["git", "push"]
6647
6989
  ], PUSH_TIMEOUT_MS).catch((err) => {
@@ -6719,7 +7061,7 @@ var GitExecutor = class {
6719
7061
  var import_promises6 = require("fs/promises");
6720
7062
  var import_node_os8 = require("os");
6721
7063
  var import_node_path8 = require("path");
6722
- var import_uuid8 = require("uuid");
7064
+ var import_uuid7 = require("uuid");
6723
7065
  var MAX_TIMEOUT_MS = 2147483647;
6724
7066
  var ScheduledSessionManager = class {
6725
7067
  tasks = /* @__PURE__ */ new Map();
@@ -6757,7 +7099,7 @@ var ScheduledSessionManager = class {
6757
7099
  /** 注册一个定时任务(payload 由调用方校验) */
6758
7100
  schedule(scheduledAt, payload) {
6759
7101
  const task = {
6760
- id: (0, import_uuid8.v4)(),
7102
+ id: (0, import_uuid7.v4)(),
6761
7103
  scheduledAt,
6762
7104
  createdAt: Date.now(),
6763
7105
  payload
@@ -6861,10 +7203,11 @@ function isValidTask(value) {
6861
7203
  // src/utils/cliCapabilities.ts
6862
7204
  var import_node_child_process11 = require("child_process");
6863
7205
  var DEFAULT_MODELS = [
6864
- { value: "opus", label: "Opus 4.7", sublabel: "Most capable for ambitious work" },
6865
- { value: "claude-opus-4-6", label: "Opus 4.6", sublabel: "Previous generation flagship" },
6866
- { value: "sonnet", label: "Sonnet 4.6", sublabel: "Most efficient for everyday tasks" },
6867
- { value: "haiku", label: "Haiku 4.5", sublabel: "Fastest for quick answers" }
7206
+ { value: "opus", label: "Opus 4.8", sublabel: "Most capable for ambitious work", maxEffort: "max", defaultEffort: "xhigh" },
7207
+ { value: "claude-opus-4-7", label: "Opus 4.7", sublabel: "Previous generation flagship", maxEffort: "max", defaultEffort: "xhigh" },
7208
+ { value: "claude-opus-4-6", label: "Opus 4.6", sublabel: "Earlier generation flagship", maxEffort: "max", defaultEffort: "xhigh" },
7209
+ { value: "sonnet", label: "Sonnet 4.6", sublabel: "Most efficient for everyday tasks", maxEffort: "high", defaultEffort: "high" },
7210
+ { value: "haiku", label: "Haiku 4.5", sublabel: "Fastest for quick answers", maxEffort: "medium", defaultEffort: "medium" }
6868
7211
  ];
6869
7212
  var DEFAULT_CAPABILITIES = {
6870
7213
  effortLevels: ["low", "medium", "high", "xhigh", "max"],
@@ -6997,7 +7340,7 @@ async function start(opts = {}) {
6997
7340
  try {
6998
7341
  token = (await (0, import_promises7.readFile)(tokenFile, "utf8")).trim();
6999
7342
  } catch {
7000
- token = (0, import_uuid9.v4)();
7343
+ token = (0, import_uuid8.v4)();
7001
7344
  await (0, import_promises7.mkdir)(configDir, { recursive: true });
7002
7345
  await (0, import_promises7.writeFile)(tokenFile, token, "utf8");
7003
7346
  }
@@ -7032,6 +7375,7 @@ async function start(opts = {}) {
7032
7375
  const notificationService = new NotificationService(sessionManager, expoChannel);
7033
7376
  notificationService.addChannel("expo", expoChannel, opts.enableExpoPush !== false);
7034
7377
  notificationService.addChannel("mac", new DesktopNotificationChannel(), opts.enableMacNotification !== false);
7378
+ sessionManager.onSessionRemoved((sessionId) => notificationService.releaseSession(sessionId));
7035
7379
  const activityPushOpts = opts.activityPush ?? await loadApnsConfigFromFile();
7036
7380
  if (activityPushOpts) {
7037
7381
  try {
@@ -7189,6 +7533,7 @@ async function start(opts = {}) {
7189
7533
  case "kill_session": {
7190
7534
  wsBridge.broadcast({ type: "status_change", sessionId: event.sessionId, status: "idle" });
7191
7535
  approvalProxy.clearPendingForSession(event.sessionId);
7536
+ approvalProxy.clearPendingQuestionsForSession(event.sessionId);
7192
7537
  await sessionManager.killSession(event.sessionId);
7193
7538
  wsBridge.broadcast({
7194
7539
  type: "session_list",
@@ -7207,6 +7552,7 @@ async function start(opts = {}) {
7207
7552
  }
7208
7553
  case "answer_question": {
7209
7554
  sessionManager.handleQuestionResponse(event.requestId, event.answer);
7555
+ approvalProxy.resolveQuestion(event.requestId, event.answer);
7210
7556
  break;
7211
7557
  }
7212
7558
  case "subscribe": {
@@ -7636,6 +7982,9 @@ async function start(opts = {}) {
7636
7982
  decision: decision.decision
7637
7983
  });
7638
7984
  });
7985
+ approvalProxy.onQuestionResolved((requestId) => {
7986
+ sessionManager.clearPendingQuestion(requestId);
7987
+ });
7639
7988
  approvalProxy.onApprovalRequest((request) => {
7640
7989
  wsBridge.broadcast({ type: "approval_request", request });
7641
7990
  setTimeout(() => {
@@ -7652,6 +8001,9 @@ async function start(opts = {}) {
7652
8001
  notificationService.notifyApproval(request, pendingCount);
7653
8002
  }, 6e4);
7654
8003
  });
8004
+ approvalProxy.setQuestionHandler(
8005
+ (sessionId, toolUseId, questions, requestId) => sessionManager.askQuestion(sessionId, toolUseId, questions, requestId)
8006
+ );
7655
8007
  sessionManager.onEvent((event) => {
7656
8008
  if (event.type !== "question_request") return;
7657
8009
  const { request } = event;
@@ -7712,8 +8064,30 @@ async function start(opts = {}) {
7712
8064
  const idleTimeoutMs = Number(process.env.SESSIX_IDLE_TIMEOUT_MS ?? 30 * 60 * 1e3);
7713
8065
  const idleSweepIntervalMs = Number(process.env.SESSIX_IDLE_SWEEP_INTERVAL_MS ?? 5 * 60 * 1e3);
7714
8066
  const maxActiveProcesses = Number(process.env.SESSIX_MAX_ACTIVE_PROCESSES ?? 15);
8067
+ const sessionEvictMs = Number(process.env.SESSIX_SESSION_EVICT_MS ?? 2 * 60 * 60 * 1e3);
8068
+ let gcFn;
8069
+ const maybeGc = () => {
8070
+ if (gcFn === void 0) {
8071
+ gcFn = globalThis.gc ?? null;
8072
+ if (!gcFn) {
8073
+ try {
8074
+ (0, import_node_v8.setFlagsFromString)("--expose-gc");
8075
+ const fn = (0, import_node_vm.runInNewContext)("gc");
8076
+ gcFn = typeof fn === "function" ? fn : null;
8077
+ } catch {
8078
+ gcFn = null;
8079
+ }
8080
+ }
8081
+ }
8082
+ if (gcFn) {
8083
+ try {
8084
+ gcFn();
8085
+ } catch {
8086
+ }
8087
+ }
8088
+ };
7715
8089
  let idleSweepTimer = null;
7716
- if (idleSweepIntervalMs > 0 && (idleTimeoutMs > 0 || maxActiveProcesses > 0)) {
8090
+ if (idleSweepIntervalMs > 0 && (idleTimeoutMs > 0 || maxActiveProcesses > 0 || sessionEvictMs > 0)) {
7717
8091
  idleSweepTimer = setInterval(async () => {
7718
8092
  try {
7719
8093
  let totalSwept = 0;
@@ -7732,7 +8106,18 @@ async function start(opts = {}) {
7732
8106
  swept.forEach(broadcastShrink);
7733
8107
  totalSwept += swept.length;
7734
8108
  }
8109
+ if (sessionEvictMs > 0 && typeof provider.listEvictableSessions === "function") {
8110
+ const evictable = provider.listEvictableSessions(sessionEvictMs);
8111
+ for (const id of evictable) {
8112
+ await sessionManager.killSession(id);
8113
+ }
8114
+ if (evictable.length > 0) {
8115
+ console.log(`[Server] Idle GC: evicted ${evictable.length} stale session(s)`);
8116
+ totalSwept += evictable.length;
8117
+ }
8118
+ }
7735
8119
  }
8120
+ const hasRunning = sessionManager.getActiveSessions().some((s) => s.status === "running");
7736
8121
  if (totalSwept > 0) {
7737
8122
  console.log(`[Server] Idle GC: swept ${totalSwept} idle session(s)`);
7738
8123
  wsBridge.broadcast({
@@ -7740,6 +8125,9 @@ async function start(opts = {}) {
7740
8125
  sessions: sessionManager.getActiveSessions()
7741
8126
  });
7742
8127
  }
8128
+ if (totalSwept > 0 || !hasRunning) {
8129
+ maybeGc();
8130
+ }
7743
8131
  } catch (err) {
7744
8132
  console.error("[Server] Idle GC failed:", err);
7745
8133
  }
@@ -7794,7 +8182,7 @@ async function start(opts = {}) {
7794
8182
  openPairing: (duration) => pairingManager.open(duration),
7795
8183
  closePairing: () => pairingManager.close(),
7796
8184
  regenerateToken: async () => {
7797
- const newToken = (0, import_uuid9.v4)();
8185
+ const newToken = (0, import_uuid8.v4)();
7798
8186
  await (0, import_promises7.mkdir)(configDir, { recursive: true });
7799
8187
  await (0, import_promises7.writeFile)(tokenFile, newToken, "utf8");
7800
8188
  instance.token = newToken;