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/server.js CHANGED
@@ -307,16 +307,18 @@ function t(key, params) {
307
307
  }
308
308
 
309
309
  // src/server.ts
310
- var import_uuid9 = require("uuid");
310
+ var import_uuid8 = require("uuid");
311
311
  var import_promises7 = require("fs/promises");
312
312
  var import_node_os9 = require("os");
313
313
  var import_node_path9 = require("path");
314
314
  var import_node_child_process12 = require("child_process");
315
315
  var import_node_util3 = require("util");
316
+ var import_node_v8 = require("v8");
317
+ var import_node_vm = require("vm");
316
318
 
317
319
  // src/providers/ProcessProvider.ts
318
320
  var import_child_process = require("child_process");
319
- var import_readline = require("readline");
321
+ var import_readline2 = require("readline");
320
322
  var import_events = require("events");
321
323
  var import_node_os2 = require("os");
322
324
  var import_uuid = require("uuid");
@@ -367,12 +369,60 @@ function isNormalExit(code, signal) {
367
369
  }
368
370
 
369
371
  // src/utils/claudePath.ts
372
+ function resolveStable(candidate) {
373
+ if (!candidate) return null;
374
+ try {
375
+ const real = (0, import_node_fs.realpathSync)(candidate.trim());
376
+ (0, import_node_fs.accessSync)(real, import_node_fs.constants.X_OK);
377
+ return real;
378
+ } catch {
379
+ return null;
380
+ }
381
+ }
382
+ function resolveViaLoginShell() {
383
+ if (isWindows) return null;
384
+ const shell = process.env.SHELL || "/bin/zsh";
385
+ try {
386
+ const out = (0, import_node_child_process2.execSync)(`${shell} -ilc 'command -v claude' 2>/dev/null`, {
387
+ encoding: "utf-8",
388
+ timeout: 8e3
389
+ }).trim().split("\n").filter(Boolean).pop();
390
+ return resolveStable(out);
391
+ } catch {
392
+ return null;
393
+ }
394
+ }
395
+ function resolveViaFnm() {
396
+ if (isWindows) return null;
397
+ const base = (0, import_node_path.join)((0, import_node_os.homedir)(), ".fnm", "node-versions");
398
+ try {
399
+ const versions = (0, import_node_fs.readdirSync)(base).filter((v) => /^v?\d+\./.test(v)).sort(
400
+ (a, b) => b.localeCompare(a, void 0, { numeric: true, sensitivity: "base" })
401
+ );
402
+ for (const v of versions) {
403
+ const p = resolveStable((0, import_node_path.join)(base, v, "installation", "bin", "claude"));
404
+ if (p) return p;
405
+ }
406
+ } catch {
407
+ }
408
+ return null;
409
+ }
410
+ var cached = null;
370
411
  function findClaudePath() {
412
+ if (cached) return cached;
413
+ const override = resolveStable(process.env.SESSIX_CLAUDE_PATH);
414
+ if (override) return cached = log(override, "env:SESSIX_CLAUDE_PATH");
371
415
  try {
372
416
  const cmd = isWindows ? "where claude" : "which claude";
373
- return (0, import_node_child_process2.execSync)(cmd, { encoding: "utf-8" }).trim().split("\n")[0];
417
+ const which = (0, import_node_child_process2.execSync)(cmd, { encoding: "utf-8" }).trim().split("\n")[0];
418
+ const stable = resolveStable(which);
419
+ if (stable) return cached = log(stable, "which");
374
420
  } catch {
375
421
  }
422
+ const fnm = resolveViaFnm();
423
+ if (fnm) return cached = log(fnm, "fnm-scan");
424
+ const viaShell = resolveViaLoginShell();
425
+ if (viaShell) return cached = log(viaShell, "login-shell");
376
426
  const candidates = isWindows ? [
377
427
  (0, import_node_path.join)(process.env.LOCALAPPDATA ?? "", "Programs", "claude", "claude.exe"),
378
428
  (0, import_node_path.join)((0, import_node_os.homedir)(), "AppData", "Local", "Programs", "claude", "claude.exe"),
@@ -383,631 +433,936 @@ function findClaudePath() {
383
433
  "/opt/homebrew/bin/claude"
384
434
  ];
385
435
  for (const candidate of candidates) {
386
- try {
387
- (0, import_node_fs.accessSync)(candidate, import_node_fs.constants.X_OK);
388
- return candidate;
389
- } catch {
390
- }
436
+ const stable = resolveStable(candidate);
437
+ if (stable) return cached = log(stable, "candidate");
391
438
  }
392
- return "claude";
439
+ console.warn(
440
+ "[claudePath] \u672A\u80FD\u5B9A\u4F4D claude\uFF0C\u515C\u5E95\u4F7F\u7528\u88F8 'claude'\uFF08PATH \u4E0D\u542B claude \u65F6\u4F1A\u5931\u8D25\uFF09"
441
+ );
442
+ return cached = "claude";
443
+ }
444
+ function log(path2, via) {
445
+ console.log(`[claudePath] \u89E3\u6790\u5230 claude: ${path2} (via ${via})`);
446
+ return path2;
393
447
  }
394
448
 
395
- // src/providers/ProcessProvider.ts
396
- var CLAUDE_PATH = findClaudePath();
397
- var ProcessProvider = class {
398
- /** 活跃会话映射表:sessionId -> { session, process } */
399
- activeSessions = /* @__PURE__ */ new Map();
400
- /** 事件发射器,用于分发 Claude 事件流 */
401
- emitter = new import_events.EventEmitter();
402
- /** 已发射的 AskUserQuestion toolUseId 集合,按会话隔离(避免 partial message 重复触发) */
403
- emittedQuestionToolUseIds = /* @__PURE__ */ new Map();
404
- /**
405
- * 启动新会话或恢复已有会话
406
- *
407
- * 会 spawn 一个 `claude` CLI 进程,设置工作目录和环境变量,
408
- * 并开始监听 stdout NDJSON 输出。
409
- */
410
- async startSession(opts) {
411
- const { projectPath, message, sessionId: existingSessionId, model, permissionMode, effort, images, fallbackModel, maxBudgetUsd } = opts;
412
- const sessionId = existingSessionId ?? (0, import_uuid.v4)();
413
- if (this.activeSessions.has(sessionId)) {
414
- await this.killSession(sessionId);
415
- }
416
- const projectId = projectPath.split("/").filter(Boolean).pop() ?? "unknown";
417
- const session = {
418
- id: sessionId,
419
- projectId,
420
- projectPath,
421
- status: "running",
422
- createdAt: Date.now(),
423
- lastActiveAt: Date.now(),
424
- summary: message.slice(0, 80)
425
- };
426
- const resume = opts.resume ?? !!existingSessionId;
427
- const proc = this.spawnClaudeProcess(sessionId, projectPath, resume, model, permissionMode, effort, fallbackModel, maxBudgetUsd);
428
- this.writeUserMessage(proc, message, sessionId, images);
429
- session.pid = proc.pid;
430
- this.activeSessions.set(sessionId, { session, process: proc, model, permissionMode, effort, fallbackModel, maxBudgetUsd });
431
- proc.on("error", (err) => {
432
- console.error(`[ProcessProvider] Session ${sessionId} process error:`, err.message);
433
- this.activeSessions.delete(sessionId);
434
- const syntheticResult = {
435
- type: "result",
436
- subtype: "error",
437
- result: `Process spawn failed: ${err.message}`,
438
- session_id: sessionId,
439
- duration_ms: 0,
440
- is_error: true,
441
- num_turns: 0
442
- };
443
- this.emitter.emit(this.getEventName(sessionId), syntheticResult);
449
+ // src/session/ProjectReader.ts
450
+ var import_promises = require("fs/promises");
451
+ var import_readline = require("readline");
452
+ var import_path = require("path");
453
+ var import_os = require("os");
454
+ var CLAUDE_PROJECTS_DIR = (0, import_path.join)((0, import_os.homedir)(), ".claude", "projects");
455
+ function getSessionFilePath(projectPath, sessionId) {
456
+ return (0, import_path.join)(CLAUDE_PROJECTS_DIR, encodeDirName(projectPath), `${sessionId}.jsonl`);
457
+ }
458
+ async function getSessionModel(projectPath, sessionId) {
459
+ const filePath = getSessionFilePath(projectPath, sessionId);
460
+ let fileHandle;
461
+ try {
462
+ fileHandle = await (0, import_promises.open)(filePath, "r");
463
+ const rl = (0, import_readline.createInterface)({
464
+ input: fileHandle.createReadStream({ encoding: "utf-8" }),
465
+ crlfDelay: Infinity
444
466
  });
445
- this.attachStdoutListener(sessionId, proc);
446
- this.attachStderrListener(sessionId, proc);
447
- this.attachExitListener(sessionId, proc);
448
- return session;
449
- }
450
- /**
451
- * 终止指定会话
452
- *
453
- * kill 进程并从活跃映射中移除。
454
- */
455
- async killSession(sessionId) {
456
- const entry = this.activeSessions.get(sessionId);
457
- if (!entry) {
458
- return;
459
- }
460
- if (entry.process.exitCode === null && entry.process.signalCode === null) {
467
+ let lastModel;
468
+ for await (const line of rl) {
469
+ if (!line.trim()) continue;
461
470
  try {
462
- entry.process.stdin?.end();
471
+ const obj = JSON.parse(line);
472
+ if (obj.type !== "assistant" || !obj.message) continue;
473
+ const model = obj.message.model;
474
+ if (typeof model === "string" && model && model !== "unknown") {
475
+ lastModel = model;
476
+ }
463
477
  } catch {
464
478
  }
465
- await killProcessCrossPlatform(entry.process);
466
479
  }
467
- this.emittedQuestionToolUseIds.delete(sessionId);
468
- this.activeSessions.delete(sessionId);
480
+ return lastModel;
481
+ } catch (err) {
482
+ if (err.code === "ENOENT") return void 0;
483
+ throw err;
484
+ } finally {
485
+ await fileHandle?.close();
469
486
  }
470
- /**
471
- * 向已有会话发送新消息
472
- *
473
- * 快速路径:进程存活时直接写 stdin(毫秒级响应)。
474
- * 慢速路径:进程已退出时 respawn 并 --resume。
475
- */
476
- async sendMessage(sessionId, message, permissionMode, images) {
477
- const entry = this.activeSessions.get(sessionId);
478
- if (!entry) {
479
- throw new Error(`Session ${sessionId} not found or already ended`);
487
+ }
488
+ async function getProjects() {
489
+ try {
490
+ const dirExists = await directoryExists(CLAUDE_PROJECTS_DIR);
491
+ if (!dirExists) {
492
+ return { ok: true, value: [] };
480
493
  }
481
- const modeChanged = permissionMode != null && permissionMode !== (entry.permissionMode ?? "default");
482
- if (!modeChanged && entry.process.exitCode === null && entry.process.signalCode === null && !entry.process.stdin?.destroyed) {
483
- entry.session.status = "running";
484
- entry.session.lastActiveAt = Date.now();
485
- this.writeUserMessage(entry.process, message, sessionId, images);
486
- return;
494
+ const entries = await (0, import_promises.readdir)(CLAUDE_PROJECTS_DIR, { withFileTypes: true });
495
+ const projects = [];
496
+ for (const entry of entries) {
497
+ if (!entry.isDirectory() || entry.name.startsWith(".")) {
498
+ continue;
499
+ }
500
+ const encodedPath = entry.name;
501
+ const decodedPath = decodeDirName(encodedPath);
502
+ const name = decodedPath.split("/").filter(Boolean).pop() ?? encodedPath;
503
+ const projectDir = (0, import_path.join)(CLAUDE_PROJECTS_DIR, encodedPath);
504
+ const { count: sessionCount, latestMtime } = await countJsonlFilesWithMtime(projectDir);
505
+ projects.push({
506
+ id: encodedPath,
507
+ path: decodedPath,
508
+ name,
509
+ sessionCount,
510
+ lastActiveAt: latestMtime
511
+ });
487
512
  }
488
- if (modeChanged) {
489
- console.log(`[ProcessProvider] Session ${sessionId}: permission mode change ${entry.permissionMode ?? "default"} \u2192 ${permissionMode}, respawn`);
490
- if (entry.process.exitCode === null && entry.process.signalCode === null) {
513
+ projects.sort((a, b) => a.name.localeCompare(b.name));
514
+ return { ok: true, value: projects };
515
+ } catch (err) {
516
+ return {
517
+ ok: false,
518
+ error: err instanceof Error ? err : new Error(String(err))
519
+ };
520
+ }
521
+ }
522
+ async function getHistoricalSessions(projectPath) {
523
+ try {
524
+ const encodedPath = encodeDirName(projectPath);
525
+ const projectDir = (0, import_path.join)(CLAUDE_PROJECTS_DIR, encodedPath);
526
+ const dirExists = await directoryExists(projectDir);
527
+ if (!dirExists) {
528
+ return { ok: true, value: [] };
529
+ }
530
+ const entries = await (0, import_promises.readdir)(projectDir, { withFileTypes: true });
531
+ const jsonlFiles = entries.filter((e) => e.isFile() && e.name.endsWith(".jsonl"));
532
+ const mtimeMap = /* @__PURE__ */ new Map();
533
+ await Promise.all(
534
+ jsonlFiles.map(async (entry) => {
535
+ const sessionId = entry.name.slice(0, -6);
536
+ const filePath = (0, import_path.join)(projectDir, entry.name);
491
537
  try {
492
- entry.process.stdin?.end();
538
+ const contentTs = await extractLastTimestamp(filePath);
539
+ if (contentTs) {
540
+ mtimeMap.set(sessionId, contentTs);
541
+ } else {
542
+ const fileStat = await (0, import_promises.stat)(filePath);
543
+ mtimeMap.set(sessionId, fileStat.mtimeMs);
544
+ }
493
545
  } catch {
546
+ mtimeMap.set(sessionId, 0);
494
547
  }
495
- killProcessCrossPlatform(entry.process);
548
+ })
549
+ );
550
+ const uuidDirs = entries.filter(
551
+ (e) => e.isDirectory() && UUID_RE.test(e.name) && !mtimeMap.has(e.name)
552
+ );
553
+ for (const entry of uuidDirs) {
554
+ try {
555
+ const fileStat = await (0, import_promises.stat)((0, import_path.join)(projectDir, entry.name));
556
+ mtimeMap.set(entry.name, fileStat.mtimeMs);
557
+ } catch {
558
+ mtimeMap.set(entry.name, 0);
496
559
  }
497
- } else {
498
- console.log(`[ProcessProvider] Session ${sessionId}: process exited, respawning`);
499
560
  }
500
- const savedPendingQuestion = entry.pendingQuestion;
501
- const newMode = permissionMode ?? entry.permissionMode;
502
- const proc = this.spawnClaudeProcess(sessionId, entry.session.projectPath, true, entry.model, newMode, entry.effort, entry.fallbackModel, entry.maxBudgetUsd);
503
- this.writeUserMessage(proc, message, sessionId, images);
504
- entry.session.status = "running";
505
- entry.session.lastActiveAt = Date.now();
506
- entry.session.pid = proc.pid;
507
- entry.process = proc;
508
- entry.permissionMode = newMode;
509
- entry.pendingQuestion = savedPendingQuestion;
510
- proc.on("error", (err) => {
511
- console.error(`[ProcessProvider] Session ${sessionId} sendMessage process error:`, err.message);
512
- this.activeSessions.delete(sessionId);
513
- const syntheticResult = {
514
- type: "result",
515
- subtype: "error",
516
- result: `Failed to send message: ${err.message}`,
517
- session_id: sessionId,
518
- duration_ms: 0,
519
- is_error: true,
520
- num_turns: 0
521
- };
522
- this.emitter.emit(this.getEventName(sessionId), syntheticResult);
561
+ const indexPath = (0, import_path.join)(projectDir, "sessions-index.json");
562
+ const sessionMap = /* @__PURE__ */ new Map();
563
+ try {
564
+ const indexContent = await (0, import_promises.readFile)(indexPath, "utf-8");
565
+ const indexData = JSON.parse(indexContent);
566
+ if (indexData.version === 1 && Array.isArray(indexData.entries)) {
567
+ for (const entry of indexData.entries) {
568
+ const mtime = mtimeMap.get(entry.sessionId) ?? entry.fileMtime ?? (entry.modified ? new Date(entry.modified).getTime() : 0);
569
+ sessionMap.set(entry.sessionId, {
570
+ sessionId: entry.sessionId,
571
+ lastModified: mtime,
572
+ summary: entry.summary,
573
+ firstPrompt: entry.firstPrompt,
574
+ messageCount: entry.messageCount
575
+ });
576
+ }
577
+ await Promise.all(
578
+ Array.from(sessionMap.values()).filter((s) => (s.messageCount ?? 0) > 0 && !s.summary && !s.firstPrompt).map(async (s) => {
579
+ const filePath = (0, import_path.join)(projectDir, `${s.sessionId}.jsonl`);
580
+ const firstPrompt = await extractFirstPrompt(filePath).catch(() => void 0);
581
+ if (firstPrompt) s.firstPrompt = firstPrompt;
582
+ })
583
+ );
584
+ }
585
+ } catch {
586
+ }
587
+ const uuidDirSet = new Set(uuidDirs.map((e) => e.name));
588
+ for (const [sessionId, mtime] of mtimeMap) {
589
+ if (!sessionMap.has(sessionId)) {
590
+ if (uuidDirSet.has(sessionId)) {
591
+ sessionMap.set(sessionId, { sessionId, lastModified: mtime, messageCount: -1 });
592
+ } else {
593
+ const filePath = (0, import_path.join)(projectDir, `${sessionId}.jsonl`);
594
+ const firstPrompt = await extractFirstPrompt(filePath).catch(() => void 0);
595
+ sessionMap.set(sessionId, { sessionId, lastModified: mtime, firstPrompt });
596
+ }
597
+ }
598
+ }
599
+ const sessions = Array.from(sessionMap.values()).filter((s) => {
600
+ if (s.messageCount === 0) return false;
601
+ if (s.messageCount === -1) return true;
602
+ if (s.firstPrompt === void 0 && s.messageCount === void 0) return false;
603
+ return true;
523
604
  });
524
- this.attachStdoutListener(sessionId, proc);
525
- this.attachStderrListener(sessionId, proc);
526
- this.attachExitListener(sessionId, proc);
527
- }
528
- /**
529
- * 订阅指定会话的 Claude 事件流
530
- *
531
- * @returns 取消订阅函数
532
- */
533
- onEvent(sessionId, callback) {
534
- const eventName = this.getEventName(sessionId);
535
- this.emitter.on(eventName, callback);
536
- return () => {
537
- this.emitter.off(eventName, callback);
605
+ sessions.sort((a, b) => b.lastModified - a.lastModified);
606
+ return { ok: true, value: sessions };
607
+ } catch (err) {
608
+ return {
609
+ ok: false,
610
+ error: err instanceof Error ? err : new Error(String(err))
538
611
  };
539
612
  }
540
- /**
541
- * 获取当前所有活跃会话列表
542
- */
543
- getActiveSessions() {
544
- return Array.from(this.activeSessions.values()).map((entry) => entry.session);
545
- }
546
- /**
547
- * 清理空闲进程
548
- *
549
- * 找出所有 status='idle' lastActiveAt 距今超过 maxIdleMs 的活跃进程,
550
- * kill 进程释放内存。entry 保留在 activeSessions 中,用户下次 sendMessage
551
- * 走 slow path 自动 --resume 重启进程。
552
- *
553
- * @returns sweep 的 sessionId 列表
554
- */
555
- async sweepIdleProcesses(maxIdleMs) {
556
- const now = Date.now();
557
- const swept = [];
558
- for (const [sessionId, entry] of this.activeSessions) {
559
- if (entry.process.exitCode !== null || entry.process.signalCode !== null) continue;
560
- if (entry.session.status !== "idle") continue;
561
- if (now - entry.session.lastActiveAt < maxIdleMs) continue;
562
- const idleMin = Math.round((now - entry.session.lastActiveAt) / 6e4);
563
- console.log(`[ProcessProvider] sweeping idle process: ${sessionId} (idle ${idleMin}m)`);
613
+ }
614
+ async function getSessionHistory(projectPath, sessionId) {
615
+ let fileHandle;
616
+ try {
617
+ const encodedPath = encodeDirName(projectPath);
618
+ const filePath = (0, import_path.join)(CLAUDE_PROJECTS_DIR, encodedPath, `${sessionId}.jsonl`);
619
+ try {
620
+ fileHandle = await (0, import_promises.open)(filePath, "r");
621
+ } catch (err) {
622
+ if (err.code === "ENOENT") return { ok: true, value: [] };
623
+ throw err;
624
+ }
625
+ const rl = (0, import_readline.createInterface)({
626
+ input: fileHandle.createReadStream({ encoding: "utf-8" }),
627
+ crlfDelay: Infinity
628
+ });
629
+ const events = [];
630
+ for await (const line of rl) {
631
+ if (!line.trim()) continue;
564
632
  try {
565
- entry.process.stdin?.end();
633
+ const obj = JSON.parse(line);
634
+ const type = obj.type;
635
+ if (type === "user" && obj.message) {
636
+ const msgContent = obj.message.content;
637
+ if (typeof msgContent === "string") {
638
+ if (msgContent.includes("<local-command") || msgContent.includes("<command-name>")) continue;
639
+ } else if (Array.isArray(msgContent)) {
640
+ const hasText = msgContent.some(
641
+ (b) => b.type === "text" && !b.text?.includes("<local-command") && !b.text?.includes("<command-name>")
642
+ );
643
+ if (!hasText) continue;
644
+ }
645
+ const normalizedContent = typeof msgContent === "string" ? [{ type: "text", text: msgContent }] : Array.isArray(msgContent) ? msgContent.filter((b) => b.type === "text" && typeof b.text === "string") : [];
646
+ if (normalizedContent.length === 0) continue;
647
+ events.push({
648
+ type: "user",
649
+ message: {
650
+ ...obj.message,
651
+ content: normalizedContent
652
+ },
653
+ session_id: sessionId
654
+ });
655
+ } else if (type === "assistant" && obj.message) {
656
+ const content = (obj.message.content ?? []).filter(
657
+ (b) => b.type === "text" || b.type === "tool_use" || b.type === "thinking"
658
+ );
659
+ if (content.length === 0) continue;
660
+ events.push({
661
+ type: "assistant",
662
+ message: {
663
+ id: obj.message.id ?? obj.uuid ?? `hist-${events.length}`,
664
+ model: obj.message.model ?? "unknown",
665
+ role: "assistant",
666
+ content,
667
+ stop_reason: obj.message.stop_reason,
668
+ usage: obj.message.usage
669
+ },
670
+ session_id: sessionId
671
+ });
672
+ }
566
673
  } catch {
567
674
  }
568
- try {
569
- await killProcessCrossPlatform(entry.process);
570
- } catch (err) {
571
- console.error(`[ProcessProvider] sweep kill failed for ${sessionId}:`, err);
572
- continue;
675
+ }
676
+ if (events.length > 0) {
677
+ let totalInputTokens = 0;
678
+ let totalOutputTokens = 0;
679
+ for (const ev of events) {
680
+ if (ev.type === "assistant" && ev.message.usage) {
681
+ totalInputTokens += ev.message.usage.input_tokens ?? 0;
682
+ totalOutputTokens += ev.message.usage.output_tokens ?? 0;
683
+ }
684
+ }
685
+ if (totalInputTokens > 0 || totalOutputTokens > 0) {
686
+ events.push({
687
+ type: "result",
688
+ subtype: "success",
689
+ is_error: false,
690
+ duration_ms: 0,
691
+ num_turns: events.filter((e) => e.type === "user").length,
692
+ result: "",
693
+ session_id: sessionId,
694
+ usage: { input_tokens: totalInputTokens, output_tokens: totalOutputTokens }
695
+ });
573
696
  }
574
- swept.push(sessionId);
575
697
  }
576
- return swept;
698
+ return { ok: true, value: events };
699
+ } catch (err) {
700
+ return {
701
+ ok: false,
702
+ error: err instanceof Error ? err : new Error(String(err))
703
+ };
704
+ } finally {
705
+ await fileHandle?.close();
577
706
  }
578
- /**
579
- * LRU 上限清理
580
- *
581
- * 当活跃进程数超过 maxAlive 时,按 lastActiveAt 升序(最久未用优先)kill
582
- * 状态为 idle 的进程,直到活跃数回到上限以内。
583
- * running / waiting_question 状态的进程永远不会被 kill。
584
- *
585
- * @returns sweep 的 sessionId 列表
586
- */
587
- async sweepLruProcesses(maxAlive) {
588
- const swept = [];
589
- if (maxAlive <= 0) return swept;
590
- const aliveEntries = Array.from(this.activeSessions.entries()).filter(
591
- ([, e]) => e.process.exitCode === null && e.process.signalCode === null
592
- );
593
- if (aliveEntries.length <= maxAlive) return swept;
594
- const idleSorted = aliveEntries.filter(([, e]) => e.session.status === "idle").sort((a, b) => a[1].session.lastActiveAt - b[1].session.lastActiveAt);
595
- let aliveCount = aliveEntries.length;
596
- for (const [sessionId, entry] of idleSorted) {
597
- if (aliveCount <= maxAlive) break;
598
- const idleMin = Math.round((Date.now() - entry.session.lastActiveAt) / 6e4);
599
- console.log(`[ProcessProvider] LRU sweep: ${sessionId} (idle ${idleMin}m, alive=${aliveCount}/${maxAlive})`);
707
+ }
708
+ async function extractLastTimestamp(filePath) {
709
+ let fileHandle;
710
+ try {
711
+ fileHandle = await (0, import_promises.open)(filePath, "r");
712
+ const fileStat = await fileHandle.stat();
713
+ const readSize = Math.min(fileStat.size, 8192);
714
+ const buffer = Buffer.alloc(readSize);
715
+ await fileHandle.read(buffer, 0, readSize, fileStat.size - readSize);
716
+ const tail = buffer.toString("utf-8");
717
+ const lines = tail.split("\n").filter((l) => l.trim());
718
+ for (let i = lines.length - 1; i >= 0; i--) {
600
719
  try {
601
- entry.process.stdin?.end();
720
+ const obj = JSON.parse(lines[i]);
721
+ if (obj.timestamp) {
722
+ const ts = new Date(obj.timestamp).getTime();
723
+ if (!isNaN(ts)) return ts;
724
+ }
602
725
  } catch {
603
726
  }
727
+ }
728
+ } catch {
729
+ } finally {
730
+ await fileHandle?.close();
731
+ }
732
+ return void 0;
733
+ }
734
+ async function extractFirstPrompt(filePath) {
735
+ let fileHandle;
736
+ try {
737
+ fileHandle = await (0, import_promises.open)(filePath, "r");
738
+ const rl = (0, import_readline.createInterface)({
739
+ input: fileHandle.createReadStream({ encoding: "utf-8" }),
740
+ crlfDelay: Infinity
741
+ });
742
+ let lineCount = 0;
743
+ for await (const line of rl) {
744
+ if (++lineCount > 20) break;
745
+ if (!line.trim()) continue;
604
746
  try {
605
- await killProcessCrossPlatform(entry.process);
606
- swept.push(sessionId);
607
- aliveCount--;
608
- } catch (err) {
609
- console.error(`[ProcessProvider] LRU kill failed for ${sessionId}:`, err);
747
+ const obj = JSON.parse(line);
748
+ if (obj.type === "user" && obj.message) {
749
+ const msgContent = obj.message.content;
750
+ let text = "";
751
+ if (typeof msgContent === "string") {
752
+ text = msgContent;
753
+ } else if (Array.isArray(msgContent)) {
754
+ const textBlock = msgContent.find((b) => b.type === "text" && typeof b.text === "string");
755
+ text = textBlock?.text ?? "";
756
+ }
757
+ if (text && !text.includes("<local-command") && !text.includes("<command-name>")) {
758
+ text = text.replace(/<ide_opened_file>[\s\S]*?<\/ide_opened_file>/gi, "");
759
+ text = text.replace(/<[^>]+>/g, "").trim();
760
+ rl.close();
761
+ return text.length > 80 ? text.slice(0, 80) + "..." : text;
762
+ }
763
+ }
764
+ } catch {
610
765
  }
611
766
  }
612
- if (aliveCount > maxAlive) {
613
- console.warn(`[ProcessProvider] LRU sweep: ${aliveCount} alive after sweep > limit ${maxAlive}; remaining are running/waiting`);
614
- }
615
- return swept;
767
+ } catch {
768
+ } finally {
769
+ await fileHandle?.close();
616
770
  }
617
- // ============================================
618
- // 私有方法
619
- // ============================================
620
- /**
621
- * 启动 claude CLI 进程(持久模式,stdin 保持开放接收多条消息)
622
- */
623
- spawnClaudeProcess(sessionId, projectPath, resume = false, model, permissionMode, effort, fallbackModel, maxBudgetUsd) {
624
- const args = [
625
- "--input-format",
626
- "stream-json",
627
- "--output-format",
628
- "stream-json",
629
- "--verbose",
630
- "--include-partial-messages",
631
- "--include-hook-events"
632
- ];
633
- if (resume) {
634
- args.push("--resume", sessionId);
635
- } else {
636
- args.push("--session-id", sessionId);
637
- }
638
- if (model) {
639
- args.push("--model", model);
640
- }
641
- if (permissionMode && permissionMode !== "default") {
642
- args.push("--permission-mode", permissionMode);
643
- }
644
- if (effort) {
645
- args.push("--effort", effort);
646
- }
647
- if (fallbackModel) {
648
- args.push("--fallback-model", fallbackModel);
649
- }
650
- if (maxBudgetUsd != null) {
651
- args.push("--max-budget-usd", String(maxBudgetUsd));
652
- }
653
- const env = { ...process.env, SESSIX_SESSION_ID: sessionId };
654
- delete env.CLAUDECODE;
655
- const proc = (0, import_child_process.spawn)(CLAUDE_PATH, args, {
656
- cwd: projectPath,
657
- env,
658
- stdio: ["pipe", "pipe", "pipe"]
659
- });
660
- return proc;
771
+ return void 0;
772
+ }
773
+ function decodeDirName(dirName) {
774
+ const placeholder = "\0";
775
+ const escaped = dirName.replace(/--/g, placeholder);
776
+ const decoded = escaped.replace(/-/g, "/");
777
+ return decoded.replace(new RegExp(placeholder, "g"), "-");
778
+ }
779
+ function encodeDirName(path2) {
780
+ const escaped = path2.replace(/-/g, "--");
781
+ return escaped.replace(/\//g, "-");
782
+ }
783
+ async function directoryExists(dirPath) {
784
+ try {
785
+ const s = await (0, import_promises.stat)(dirPath);
786
+ return s.isDirectory();
787
+ } catch {
788
+ return false;
789
+ }
790
+ }
791
+ var UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
792
+ async function countJsonlFilesWithMtime(dirPath) {
793
+ try {
794
+ const entries = await (0, import_promises.readdir)(dirPath, { withFileTypes: true });
795
+ const jsonlNames = new Set(
796
+ entries.filter((e) => e.isFile() && e.name.endsWith(".jsonl")).map((e) => e.name.slice(0, -6))
797
+ );
798
+ const uuidDirs = entries.filter(
799
+ (e) => e.isDirectory() && UUID_RE.test(e.name) && !jsonlNames.has(e.name)
800
+ );
801
+ let latestMtime = 0;
802
+ const jsonlEntries = entries.filter((e) => e.isFile() && e.name.endsWith(".jsonl"));
803
+ await Promise.all([
804
+ ...jsonlEntries.map(async (entry) => {
805
+ try {
806
+ const contentTs = await extractLastTimestamp((0, import_path.join)(dirPath, entry.name));
807
+ const ts = contentTs ?? (await (0, import_promises.stat)((0, import_path.join)(dirPath, entry.name))).mtimeMs;
808
+ if (ts > latestMtime) latestMtime = ts;
809
+ } catch {
810
+ }
811
+ }),
812
+ ...uuidDirs.map(async (entry) => {
813
+ try {
814
+ const fileStat = await (0, import_promises.stat)((0, import_path.join)(dirPath, entry.name));
815
+ if (fileStat.mtimeMs > latestMtime) latestMtime = fileStat.mtimeMs;
816
+ } catch {
817
+ }
818
+ })
819
+ ]);
820
+ return { count: jsonlNames.size + uuidDirs.length, latestMtime };
821
+ } catch {
822
+ return { count: 0, latestMtime: 0 };
661
823
  }
824
+ }
825
+
826
+ // src/providers/ProcessProvider.ts
827
+ var CLAUDE_PATH = findClaudePath();
828
+ var ProcessProvider = class {
829
+ /** 活跃会话映射表:sessionId -> { session, process } */
830
+ activeSessions = /* @__PURE__ */ new Map();
831
+ /** 事件发射器,用于分发 Claude 事件流 */
832
+ emitter = new import_events.EventEmitter();
662
833
  /**
663
- * 向持久进程的 stdin 写入一条用户消息(NDJSON 格式)
834
+ * 启动新会话或恢复已有会话
664
835
  *
665
- * 写入失败时合成 error result 事件,确保 SessionManager 能感知到失败。
836
+ * spawn 一个 `claude` CLI 进程,设置工作目录和环境变量,
837
+ * 并开始监听 stdout 的 NDJSON 输出。
666
838
  */
667
- writeUserMessage(proc, message, sessionId, images) {
668
- const content = [];
669
- if (images?.length) {
670
- const ALLOWED_TYPES = /* @__PURE__ */ new Set(["image/jpeg", "image/png", "image/webp", "image/gif"]);
671
- const MAX_IMAGE_BYTES = 5 * 1024 * 1024;
672
- for (let i = 0; i < images.length; i++) {
673
- const img = images[i];
674
- if (!ALLOWED_TYPES.has(img.media_type)) {
675
- if (sessionId) {
676
- this.emitWriteError(sessionId, `Image #${i + 1} rejected: unsupported media_type "${img.media_type}". Only JPEG/PNG/WebP/GIF are accepted.`);
677
- }
678
- return;
679
- }
680
- const sizeBytes = Math.floor(img.data.length * 0.75);
681
- if (sizeBytes > MAX_IMAGE_BYTES) {
682
- if (sessionId) {
683
- const sizeMb = (sizeBytes / (1024 * 1024)).toFixed(1);
684
- this.emitWriteError(sessionId, `Image #${i + 1} rejected: ${sizeMb}MB exceeds 5MB per-image limit.`);
685
- }
686
- return;
687
- }
688
- content.push({
689
- type: "image",
690
- source: { type: "base64", media_type: img.media_type, data: img.data }
691
- });
692
- }
839
+ async startSession(opts) {
840
+ const { projectPath, message, sessionId: existingSessionId, model, permissionMode, effort, images, fallbackModel, maxBudgetUsd } = opts;
841
+ const sessionId = existingSessionId ?? (0, import_uuid.v4)();
842
+ if (this.activeSessions.has(sessionId)) {
843
+ await this.killSession(sessionId);
693
844
  }
694
- content.push({ type: "text", text: message });
695
- const payload = JSON.stringify({
696
- type: "user",
697
- session_id: "",
698
- message: { role: "user", content },
699
- parent_tool_use_id: null
700
- });
701
- if (!proc.stdin || proc.stdin.destroyed) {
702
- console.error(`[ProcessProvider] stdin unavailable, message lost`);
703
- if (sessionId) {
704
- this.emitWriteError(sessionId, "Process stdin closed, message not delivered");
845
+ const projectId = projectPath.split("/").filter(Boolean).pop() ?? "unknown";
846
+ const session = {
847
+ id: sessionId,
848
+ projectId,
849
+ projectPath,
850
+ status: "running",
851
+ createdAt: Date.now(),
852
+ lastActiveAt: Date.now(),
853
+ summary: message.slice(0, 80)
854
+ };
855
+ const resume = opts.resume ?? !!existingSessionId;
856
+ let effectiveModel = model;
857
+ if (resume && !effectiveModel) {
858
+ effectiveModel = await getSessionModel(projectPath, sessionId).catch(() => void 0);
859
+ if (effectiveModel) {
860
+ console.log(`[ProcessProvider] Session ${sessionId}: resume restored original model "${effectiveModel}"`);
705
861
  }
706
- return;
707
862
  }
708
- proc.stdin.write(payload + "\n", (err) => {
709
- if (err && sessionId) {
710
- console.error(`[ProcessProvider] Session ${sessionId} stdin write failed:`, err.message);
711
- this.emitWriteError(sessionId, `Failed to send message: ${err.message}`);
712
- }
713
- });
714
- if (sessionId) {
715
- const syntheticUser = {
716
- type: "user",
863
+ const proc = this.spawnClaudeProcess(sessionId, projectPath, resume, effectiveModel, permissionMode, effort, fallbackModel, maxBudgetUsd);
864
+ this.writeUserMessage(proc, message, sessionId, images);
865
+ session.pid = proc.pid;
866
+ this.activeSessions.set(sessionId, { session, process: proc, model: effectiveModel, permissionMode, effort, fallbackModel, maxBudgetUsd });
867
+ proc.on("error", (err) => {
868
+ console.error(`[ProcessProvider] Session ${sessionId} process error:`, err.message);
869
+ this.activeSessions.delete(sessionId);
870
+ const syntheticResult = {
871
+ type: "result",
872
+ subtype: "error",
873
+ result: `Process spawn failed: ${err.message}`,
717
874
  session_id: sessionId,
718
- message: { role: "user", content }
875
+ duration_ms: 0,
876
+ is_error: true,
877
+ num_turns: 0
719
878
  };
720
- this.emitter.emit(this.getEventName(sessionId), syntheticUser);
721
- }
722
- }
723
- /**
724
- * 发出写入失败的合成错误事件
725
- */
726
- emitWriteError(sessionId, message) {
727
- const syntheticResult = {
728
- type: "result",
729
- subtype: "error",
730
- result: message,
731
- session_id: sessionId,
732
- duration_ms: 0,
733
- is_error: true,
734
- num_turns: 0
735
- };
736
- this.emitter.emit(this.getEventName(sessionId), syntheticResult);
879
+ this.emitter.emit(this.getEventName(sessionId), syntheticResult);
880
+ });
881
+ this.attachStdoutListener(sessionId, proc);
882
+ this.attachStderrListener(sessionId, proc);
883
+ this.attachExitListener(sessionId, proc);
884
+ return session;
737
885
  }
738
886
  /**
739
- * 挂载 stdout 监听器,逐行解析 NDJSON
887
+ * 终止指定会话
888
+ *
889
+ * kill 进程并从活跃映射中移除。
740
890
  */
741
- attachStdoutListener(sessionId, proc) {
742
- if (!proc.stdout) {
743
- console.warn(`[ProcessProvider] Session ${sessionId}: stdout unavailable`);
744
- return;
745
- }
746
- const rl = (0, import_readline.createInterface)({
747
- input: proc.stdout,
748
- crlfDelay: Infinity
749
- });
891
+ async killSession(sessionId) {
750
892
  const entry = this.activeSessions.get(sessionId);
751
- if (entry) {
752
- entry.rl = rl;
893
+ if (!entry) {
894
+ return;
753
895
  }
754
- rl.on("line", (line) => {
755
- const trimmed = line.trim();
756
- if (!trimmed) return;
757
- const result = this.parseLine(trimmed);
758
- if (result.ok) {
759
- const event = result.value;
760
- if (event.type === "assistant") {
761
- for (const block of event.message.content) {
762
- if (block.type === "tool_use") {
763
- const isQuestion = block.name === "AskUserQuestion" || block.name === "AskFollowupQuestion";
764
- if (!isQuestion) continue;
765
- const input = block.input;
766
- let question = "";
767
- let options;
768
- let questions;
769
- if (typeof input.question === "string") {
770
- question = input.question;
771
- options = Array.isArray(input.options) ? input.options : void 0;
772
- } else if (Array.isArray(input.questions) && input.questions.length > 0) {
773
- questions = input.questions.map((q) => {
774
- const item = {
775
- question: typeof q.question === "string" ? q.question : "",
776
- header: typeof q.header === "string" ? q.header : void 0,
777
- multiSelect: typeof q.multiSelect === "boolean" ? q.multiSelect : void 0
778
- };
779
- if (Array.isArray(q.options)) {
780
- item.options = q.options.map((o) => ({
781
- label: typeof o.label === "string" ? o.label : String(o),
782
- description: typeof o.description === "string" ? o.description : void 0
783
- }));
784
- }
785
- return item;
786
- });
787
- const first = questions[0];
788
- question = first.question;
789
- options = first.options?.map((o) => o.label);
790
- }
791
- if (!question) continue;
792
- const prevKey = `${block.id}:${question}:${JSON.stringify(options ?? [])}`;
793
- let sessionSet = this.emittedQuestionToolUseIds.get(sessionId);
794
- if (!sessionSet) {
795
- sessionSet = /* @__PURE__ */ new Set();
796
- this.emittedQuestionToolUseIds.set(sessionId, sessionSet);
797
- }
798
- if (sessionSet.has(prevKey)) continue;
799
- sessionSet.add(prevKey);
800
- console.log(`[ProcessProvider] Session ${sessionId}: detected ${block.name} (toolUseId=${block.id})`);
801
- this.emitter.emit(this.getQuestionEventName(sessionId), {
802
- toolUseId: block.id,
803
- question,
804
- options,
805
- questions
806
- });
807
- }
808
- }
809
- }
810
- this.updateSessionStatus(sessionId, event);
811
- this.emitter.emit(this.getEventName(sessionId), event);
812
- } else {
813
- console.warn(
814
- `[ProcessProvider] Session ${sessionId}: failed to parse line: ${trimmed.substring(0, 100)}`
815
- );
816
- }
817
- });
818
- }
819
- /**
820
- * 挂载 stderr 监听器,记录日志
821
- */
822
- attachStderrListener(sessionId, proc) {
823
- if (!proc.stderr) return;
824
- proc.stderr.on("data", (data) => {
825
- const text = data.toString().trim();
826
- if (text) {
827
- console.error(`[ProcessProvider] Session ${sessionId} stderr: ${text}`);
896
+ if (entry.process.exitCode === null && entry.process.signalCode === null) {
897
+ try {
898
+ entry.process.stdin?.end();
899
+ } catch {
828
900
  }
829
- });
901
+ await killProcessCrossPlatform(entry.process);
902
+ }
903
+ this.activeSessions.delete(sessionId);
830
904
  }
831
905
  /**
832
- * 挂载进程退出监听器
906
+ * 向已有会话发送新消息
833
907
  *
834
- * 当进程退出时发出合成的 result 事件,确保 SessionManager 能感知到退出。
835
- * 正常退出时 Claude 会先通过 stdout 发送真实 result 事件,
836
- * updateSessionStatus 会将 session.status 更新为 idle/error。
837
- * 此时合成事件会重复触发,导致手机端出现两张总结卡。
838
- * 修复:已收到真实 result(status 已为 idle/error)时跳过合成事件。
839
- * 异常退出时(crash/OOM/killed)没有真实 result 事件,合成事件确保状态正确广播。
908
+ * 快速路径:进程存活时直接写 stdin(毫秒级响应)。
909
+ * 慢速路径:进程已退出时 respawn --resume。
840
910
  */
841
- attachExitListener(sessionId, proc) {
842
- proc.once("exit", (code, signal) => {
843
- const entry = this.activeSessions.get(sessionId);
844
- if (!entry) return;
845
- if (entry.process !== proc) return;
846
- if (entry.rl) {
847
- entry.rl.close();
848
- entry.rl = void 0;
849
- }
850
- entry.session.pid = void 0;
911
+ async sendMessage(sessionId, message, permissionMode, images) {
912
+ const entry = this.activeSessions.get(sessionId);
913
+ if (!entry) {
914
+ throw new Error(`Session ${sessionId} not found or already ended`);
915
+ }
916
+ const modeChanged = permissionMode != null && permissionMode !== (entry.permissionMode ?? "default");
917
+ if (!modeChanged && entry.process.exitCode === null && entry.process.signalCode === null && !entry.process.stdin?.destroyed) {
918
+ entry.session.status = "running";
851
919
  entry.session.lastActiveAt = Date.now();
852
- const alreadyHasResult = entry.session.status === "idle" || entry.session.status === "error";
853
- if (alreadyHasResult) return;
854
- const isNormal = isNormalExit(code, signal);
855
- entry.session.status = isNormal ? "idle" : "error";
856
- if (!isNormal) {
857
- console.error(
858
- `[ProcessProvider] Session ${sessionId}: process exited abnormally code=${code} signal=${signal}`
859
- );
920
+ this.writeUserMessage(entry.process, message, sessionId, images);
921
+ return;
922
+ }
923
+ if (modeChanged) {
924
+ console.log(`[ProcessProvider] Session ${sessionId}: permission mode change ${entry.permissionMode ?? "default"} \u2192 ${permissionMode}, respawn`);
925
+ if (entry.process.exitCode === null && entry.process.signalCode === null) {
926
+ try {
927
+ entry.process.stdin?.end();
928
+ } catch {
929
+ }
930
+ killProcessCrossPlatform(entry.process);
860
931
  }
932
+ } else {
933
+ console.log(`[ProcessProvider] Session ${sessionId}: process exited, respawning`);
934
+ }
935
+ const newMode = permissionMode ?? entry.permissionMode;
936
+ const proc = this.spawnClaudeProcess(sessionId, entry.session.projectPath, true, entry.model, newMode, entry.effort, entry.fallbackModel, entry.maxBudgetUsd);
937
+ this.writeUserMessage(proc, message, sessionId, images);
938
+ entry.session.status = "running";
939
+ entry.session.lastActiveAt = Date.now();
940
+ entry.session.pid = proc.pid;
941
+ entry.process = proc;
942
+ entry.permissionMode = newMode;
943
+ proc.on("error", (err) => {
944
+ console.error(`[ProcessProvider] Session ${sessionId} sendMessage process error:`, err.message);
945
+ this.activeSessions.delete(sessionId);
861
946
  const syntheticResult = {
862
947
  type: "result",
863
- subtype: isNormal ? "success" : "error",
948
+ subtype: "error",
949
+ result: `Failed to send message: ${err.message}`,
864
950
  session_id: sessionId,
865
- is_error: !isNormal,
866
- result: isNormal ? "" : `Process exited code=${code} signal=${signal}`,
867
951
  duration_ms: 0,
952
+ is_error: true,
868
953
  num_turns: 0
869
954
  };
870
955
  this.emitter.emit(this.getEventName(sessionId), syntheticResult);
871
956
  });
957
+ this.attachStdoutListener(sessionId, proc);
958
+ this.attachStderrListener(sessionId, proc);
959
+ this.attachExitListener(sessionId, proc);
872
960
  }
873
961
  /**
874
- * 解析一行 NDJSON 文本为 ClaudeStreamEvent
962
+ * 订阅指定会话的 Claude 事件流
963
+ *
964
+ * @returns 取消订阅函数
875
965
  */
876
- parseLine(line) {
877
- try {
878
- const parsed = JSON.parse(line);
879
- return { ok: true, value: parsed };
880
- } catch (err) {
881
- return {
882
- ok: false,
883
- error: err instanceof Error ? err : new Error(String(err))
884
- };
885
- }
966
+ onEvent(sessionId, callback) {
967
+ const eventName = this.getEventName(sessionId);
968
+ this.emitter.on(eventName, callback);
969
+ return () => {
970
+ this.emitter.off(eventName, callback);
971
+ };
886
972
  }
887
973
  /**
888
- * 根据 Claude 事件更新会话状态
974
+ * 获取当前所有活跃会话列表
889
975
  */
890
- updateSessionStatus(sessionId, event) {
891
- const entry = this.activeSessions.get(sessionId);
892
- if (!entry) return;
893
- entry.session.lastActiveAt = Date.now();
894
- switch (event.type) {
895
- case "system":
896
- if (event.subtype === "init") {
897
- entry.session.status = "running";
898
- }
899
- break;
900
- case "assistant":
901
- entry.session.status = "running";
902
- break;
903
- case "result":
904
- entry.session.status = event.is_error ? "error" : "idle";
905
- break;
906
- }
976
+ getActiveSessions() {
977
+ return Array.from(this.activeSessions.values()).map((entry) => entry.session);
907
978
  }
908
979
  /**
909
- * 根据对话上下文生成下一步建议指令
980
+ * 清理空闲进程
910
981
  *
911
- * 使用 --output-format text 做一次性调用,返回纯文本结果。
982
+ * 找出所有 status='idle' lastActiveAt 距今超过 maxIdleMs 的活跃进程,
983
+ * kill 进程释放内存。entry 保留在 activeSessions 中,用户下次 sendMessage
984
+ * 走 slow path 自动 --resume 重启进程。
985
+ *
986
+ * @returns 被 sweep 的 sessionId 列表
912
987
  */
913
- async generateSuggestion(context) {
914
- 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):
915
-
916
- ${context}`;
917
- return new Promise((resolve, reject) => {
918
- const env = { ...process.env };
919
- delete env.CLAUDECODE;
920
- const proc = (0, import_child_process.spawn)(CLAUDE_PATH, ["-p", prompt, "--output-format", "text"], {
921
- cwd: (0, import_node_os2.homedir)(),
922
- env,
923
- stdio: ["pipe", "pipe", "pipe"]
924
- });
925
- proc.stdin.end();
926
- let output = "";
927
- proc.stdout?.on("data", (data) => {
928
- output += data.toString();
929
- });
930
- proc.once("exit", (code) => {
931
- if (code === 0) {
932
- resolve(output.trim());
933
- } else {
934
- reject(new Error(`generateSuggestion process exit code: ${code}`));
935
- }
936
- });
937
- proc.once("error", reject);
938
- });
988
+ async sweepIdleProcesses(maxIdleMs) {
989
+ const now = Date.now();
990
+ const swept = [];
991
+ for (const [sessionId, entry] of this.activeSessions) {
992
+ if (entry.process.exitCode !== null || entry.process.signalCode !== null) continue;
993
+ if (entry.session.status !== "idle") continue;
994
+ if (now - entry.session.lastActiveAt < maxIdleMs) continue;
995
+ const idleMin = Math.round((now - entry.session.lastActiveAt) / 6e4);
996
+ console.log(`[ProcessProvider] sweeping idle process: ${sessionId} (idle ${idleMin}m)`);
997
+ try {
998
+ entry.process.stdin?.end();
999
+ } catch {
1000
+ }
1001
+ try {
1002
+ await killProcessCrossPlatform(entry.process);
1003
+ } catch (err) {
1004
+ console.error(`[ProcessProvider] sweep kill failed for ${sessionId}:`, err);
1005
+ continue;
1006
+ }
1007
+ swept.push(sessionId);
1008
+ }
1009
+ return swept;
939
1010
  }
940
1011
  /**
941
- * 向正在等待中的 AskUserQuestion 提供答案
1012
+ * LRU 上限清理
1013
+ *
1014
+ * 当活跃进程数超过 maxAlive 时,按 lastActiveAt 升序(最久未用优先)kill
1015
+ * 状态为 idle 的进程,直到活跃数回到上限以内。
1016
+ * running / waiting_question 状态的进程永远不会被 kill。
942
1017
  *
943
- * 将答案写入 Claude 进程的 stdin(作为 tool_result),
944
- * Claude 收到后继续执行。
1018
+ * @returns sweep sessionId 列表
945
1019
  */
946
- async answerQuestion(sessionId, toolUseId, answer) {
947
- const entry = this.activeSessions.get(sessionId);
948
- if (!entry) {
949
- throw new Error(`Session ${sessionId} not found`);
1020
+ async sweepLruProcesses(maxAlive) {
1021
+ const swept = [];
1022
+ if (maxAlive <= 0) return swept;
1023
+ const aliveEntries = Array.from(this.activeSessions.entries()).filter(
1024
+ ([, e]) => e.process.exitCode === null && e.process.signalCode === null
1025
+ );
1026
+ if (aliveEntries.length <= maxAlive) return swept;
1027
+ const idleSorted = aliveEntries.filter(([, e]) => e.session.status === "idle").sort((a, b) => a[1].session.lastActiveAt - b[1].session.lastActiveAt);
1028
+ let aliveCount = aliveEntries.length;
1029
+ for (const [sessionId, entry] of idleSorted) {
1030
+ if (aliveCount <= maxAlive) break;
1031
+ const idleMin = Math.round((Date.now() - entry.session.lastActiveAt) / 6e4);
1032
+ console.log(`[ProcessProvider] LRU sweep: ${sessionId} (idle ${idleMin}m, alive=${aliveCount}/${maxAlive})`);
1033
+ try {
1034
+ entry.process.stdin?.end();
1035
+ } catch {
1036
+ }
1037
+ try {
1038
+ await killProcessCrossPlatform(entry.process);
1039
+ swept.push(sessionId);
1040
+ aliveCount--;
1041
+ } catch (err) {
1042
+ console.error(`[ProcessProvider] LRU kill failed for ${sessionId}:`, err);
1043
+ }
950
1044
  }
951
- if (!entry.process.stdin || entry.process.stdin.destroyed) {
952
- throw new Error(`Session ${sessionId} stdin unavailable`);
1045
+ if (aliveCount > maxAlive) {
1046
+ console.warn(`[ProcessProvider] LRU sweep: ${aliveCount} alive after sweep > limit ${maxAlive}; remaining are running/waiting`);
953
1047
  }
954
- const toolResult = JSON.stringify({
955
- type: "user",
956
- session_id: "",
957
- message: {
958
- role: "user",
959
- content: [{ type: "tool_result", tool_use_id: toolUseId, content: answer }]
960
- },
961
- parent_tool_use_id: toolUseId
962
- });
963
- await new Promise((resolve, reject) => {
964
- entry.process.stdin.write(toolResult + "\n", (err) => {
965
- if (err) reject(err);
966
- else resolve();
967
- });
968
- });
969
- console.log(`[ProcessProvider] Session ${sessionId}: AskUserQuestion answered (toolUseId=${toolUseId})`);
1048
+ return swept;
970
1049
  }
971
1050
  /**
972
- * 订阅指定会话的 AskUserQuestion 事件
1051
+ * 枚举可淘汰的老会话
973
1052
  *
974
- * @returns 取消订阅函数
1053
+ * 进程已退出(已被空闲 GC kill)且空闲超过 maxIdleMs 的会话——其 entry 与各 Map
1054
+ * 仍长期占内存。调用方对返回 id 执行 killSession 彻底清除;淘汰后手机端发消息
1055
+ * 会自动走 resume 路径(--resume + JSONL),不影响继续对话。
1056
+ *
1057
+ * @returns 可淘汰的 sessionId 列表(仅枚举,不删除)
975
1058
  */
976
- onQuestion(sessionId, callback) {
977
- const eventName = this.getQuestionEventName(sessionId);
978
- this.emitter.on(eventName, callback);
979
- return () => {
980
- this.emitter.off(eventName, callback);
981
- };
1059
+ listEvictableSessions(maxIdleMs) {
1060
+ if (maxIdleMs <= 0) return [];
1061
+ const now = Date.now();
1062
+ const evictable = [];
1063
+ for (const [sessionId, entry] of this.activeSessions) {
1064
+ if (entry.process.exitCode === null && entry.process.signalCode === null) continue;
1065
+ if (entry.session.status === "running" || entry.session.status === "waiting_question" || entry.session.status === "waiting_approval") continue;
1066
+ if (now - entry.session.lastActiveAt < maxIdleMs) continue;
1067
+ evictable.push(sessionId);
1068
+ }
1069
+ return evictable;
982
1070
  }
1071
+ // ============================================
1072
+ // 私有方法
1073
+ // ============================================
983
1074
  /**
984
- * 生成事件名称
1075
+ * 启动 claude CLI 进程(持久模式,stdin 保持开放接收多条消息)
985
1076
  */
986
- getEventName(sessionId) {
987
- return `claude:${sessionId}`;
1077
+ spawnClaudeProcess(sessionId, projectPath, resume = false, model, permissionMode, effort, fallbackModel, maxBudgetUsd) {
1078
+ const args = [
1079
+ "--input-format",
1080
+ "stream-json",
1081
+ "--output-format",
1082
+ "stream-json",
1083
+ "--verbose",
1084
+ "--include-partial-messages",
1085
+ "--include-hook-events"
1086
+ ];
1087
+ if (resume) {
1088
+ args.push("--resume", sessionId);
1089
+ } else {
1090
+ args.push("--session-id", sessionId);
1091
+ }
1092
+ if (model) {
1093
+ args.push("--model", model);
1094
+ }
1095
+ if (permissionMode && permissionMode !== "default") {
1096
+ args.push("--permission-mode", permissionMode);
1097
+ }
1098
+ if (effort) {
1099
+ args.push("--effort", effort);
1100
+ }
1101
+ if (fallbackModel) {
1102
+ args.push("--fallback-model", fallbackModel);
1103
+ }
1104
+ if (maxBudgetUsd != null) {
1105
+ args.push("--max-budget-usd", String(maxBudgetUsd));
1106
+ }
1107
+ const env = { ...process.env, SESSIX_SESSION_ID: sessionId };
1108
+ delete env.CLAUDECODE;
1109
+ const proc = (0, import_child_process.spawn)(CLAUDE_PATH, args, {
1110
+ cwd: projectPath,
1111
+ env,
1112
+ stdio: ["pipe", "pipe", "pipe"]
1113
+ });
1114
+ return proc;
988
1115
  }
989
1116
  /**
990
- * 生成 AskUserQuestion 内部事件名称
1117
+ * 向持久进程的 stdin 写入一条用户消息(NDJSON 格式)
1118
+ *
1119
+ * 写入失败时合成 error result 事件,确保 SessionManager 能感知到失败。
991
1120
  */
992
- getQuestionEventName(sessionId) {
993
- return `question:${sessionId}`;
994
- }
995
- };
996
-
997
- // src/providers/CodexProvider.ts
998
- var import_child_process2 = require("child_process");
999
- var import_readline2 = require("readline");
1000
- var import_events2 = require("events");
1001
- var import_fs = require("fs");
1002
- var import_path = require("path");
1003
- var import_os = require("os");
1004
- var import_uuid2 = require("uuid");
1005
-
1006
- // src/utils/codexPath.ts
1007
- var import_node_child_process3 = require("child_process");
1008
- var import_node_fs2 = require("fs");
1009
- var import_node_path2 = require("path");
1010
- var import_node_os3 = require("os");
1121
+ writeUserMessage(proc, message, sessionId, images) {
1122
+ const content = [];
1123
+ if (images?.length) {
1124
+ const ALLOWED_TYPES = /* @__PURE__ */ new Set(["image/jpeg", "image/png", "image/webp", "image/gif"]);
1125
+ const MAX_IMAGE_BYTES = 5 * 1024 * 1024;
1126
+ for (let i = 0; i < images.length; i++) {
1127
+ const img = images[i];
1128
+ if (!ALLOWED_TYPES.has(img.media_type)) {
1129
+ if (sessionId) {
1130
+ this.emitWriteError(sessionId, `Image #${i + 1} rejected: unsupported media_type "${img.media_type}". Only JPEG/PNG/WebP/GIF are accepted.`);
1131
+ }
1132
+ return;
1133
+ }
1134
+ const sizeBytes = Math.floor(img.data.length * 0.75);
1135
+ if (sizeBytes > MAX_IMAGE_BYTES) {
1136
+ if (sessionId) {
1137
+ const sizeMb = (sizeBytes / (1024 * 1024)).toFixed(1);
1138
+ this.emitWriteError(sessionId, `Image #${i + 1} rejected: ${sizeMb}MB exceeds 5MB per-image limit.`);
1139
+ }
1140
+ return;
1141
+ }
1142
+ content.push({
1143
+ type: "image",
1144
+ source: { type: "base64", media_type: img.media_type, data: img.data }
1145
+ });
1146
+ }
1147
+ }
1148
+ content.push({ type: "text", text: message });
1149
+ const payload = JSON.stringify({
1150
+ type: "user",
1151
+ session_id: "",
1152
+ message: { role: "user", content },
1153
+ parent_tool_use_id: null
1154
+ });
1155
+ if (!proc.stdin || proc.stdin.destroyed) {
1156
+ console.error(`[ProcessProvider] stdin unavailable, message lost`);
1157
+ if (sessionId) {
1158
+ this.emitWriteError(sessionId, "Process stdin closed, message not delivered");
1159
+ }
1160
+ return;
1161
+ }
1162
+ proc.stdin.write(payload + "\n", (err) => {
1163
+ if (err && sessionId) {
1164
+ console.error(`[ProcessProvider] Session ${sessionId} stdin write failed:`, err.message);
1165
+ this.emitWriteError(sessionId, `Failed to send message: ${err.message}`);
1166
+ }
1167
+ });
1168
+ if (sessionId) {
1169
+ const syntheticUser = {
1170
+ type: "user",
1171
+ session_id: sessionId,
1172
+ message: { role: "user", content }
1173
+ };
1174
+ this.emitter.emit(this.getEventName(sessionId), syntheticUser);
1175
+ }
1176
+ }
1177
+ /**
1178
+ * 发出写入失败的合成错误事件
1179
+ */
1180
+ emitWriteError(sessionId, message) {
1181
+ const syntheticResult = {
1182
+ type: "result",
1183
+ subtype: "error",
1184
+ result: message,
1185
+ session_id: sessionId,
1186
+ duration_ms: 0,
1187
+ is_error: true,
1188
+ num_turns: 0
1189
+ };
1190
+ this.emitter.emit(this.getEventName(sessionId), syntheticResult);
1191
+ }
1192
+ /**
1193
+ * 挂载 stdout 监听器,逐行解析 NDJSON
1194
+ */
1195
+ attachStdoutListener(sessionId, proc) {
1196
+ if (!proc.stdout) {
1197
+ console.warn(`[ProcessProvider] Session ${sessionId}: stdout unavailable`);
1198
+ return;
1199
+ }
1200
+ const rl = (0, import_readline2.createInterface)({
1201
+ input: proc.stdout,
1202
+ crlfDelay: Infinity
1203
+ });
1204
+ const entry = this.activeSessions.get(sessionId);
1205
+ if (entry) {
1206
+ entry.rl = rl;
1207
+ }
1208
+ rl.on("line", (line) => {
1209
+ const trimmed = line.trim();
1210
+ if (!trimmed) return;
1211
+ const result = this.parseLine(trimmed);
1212
+ if (result.ok) {
1213
+ const event = result.value;
1214
+ this.updateSessionStatus(sessionId, event);
1215
+ this.emitter.emit(this.getEventName(sessionId), event);
1216
+ } else {
1217
+ console.warn(
1218
+ `[ProcessProvider] Session ${sessionId}: failed to parse line: ${trimmed.substring(0, 100)}`
1219
+ );
1220
+ }
1221
+ });
1222
+ }
1223
+ /**
1224
+ * 挂载 stderr 监听器,记录日志
1225
+ */
1226
+ attachStderrListener(sessionId, proc) {
1227
+ if (!proc.stderr) return;
1228
+ proc.stderr.on("data", (data) => {
1229
+ const text = data.toString().trim();
1230
+ if (text) {
1231
+ console.error(`[ProcessProvider] Session ${sessionId} stderr: ${text}`);
1232
+ }
1233
+ });
1234
+ }
1235
+ /**
1236
+ * 挂载进程退出监听器
1237
+ *
1238
+ * 当进程退出时发出合成的 result 事件,确保 SessionManager 能感知到退出。
1239
+ * 正常退出时 Claude 会先通过 stdout 发送真实 result 事件,
1240
+ * updateSessionStatus 会将 session.status 更新为 idle/error。
1241
+ * 此时合成事件会重复触发,导致手机端出现两张总结卡。
1242
+ * 修复:已收到真实 result(status 已为 idle/error)时跳过合成事件。
1243
+ * 异常退出时(crash/OOM/killed)没有真实 result 事件,合成事件确保状态正确广播。
1244
+ */
1245
+ attachExitListener(sessionId, proc) {
1246
+ proc.once("exit", (code, signal) => {
1247
+ const entry = this.activeSessions.get(sessionId);
1248
+ if (!entry) return;
1249
+ if (entry.process !== proc) return;
1250
+ if (entry.rl) {
1251
+ entry.rl.close();
1252
+ entry.rl = void 0;
1253
+ }
1254
+ entry.session.pid = void 0;
1255
+ entry.session.lastActiveAt = Date.now();
1256
+ const alreadyHasResult = entry.session.status === "idle" || entry.session.status === "error";
1257
+ if (alreadyHasResult) return;
1258
+ const isNormal = isNormalExit(code, signal);
1259
+ entry.session.status = isNormal ? "idle" : "error";
1260
+ if (!isNormal) {
1261
+ console.error(
1262
+ `[ProcessProvider] Session ${sessionId}: process exited abnormally code=${code} signal=${signal}`
1263
+ );
1264
+ }
1265
+ const syntheticResult = {
1266
+ type: "result",
1267
+ subtype: isNormal ? "success" : "error",
1268
+ session_id: sessionId,
1269
+ is_error: !isNormal,
1270
+ result: isNormal ? "" : `Process exited code=${code} signal=${signal}`,
1271
+ duration_ms: 0,
1272
+ num_turns: 0
1273
+ };
1274
+ this.emitter.emit(this.getEventName(sessionId), syntheticResult);
1275
+ });
1276
+ }
1277
+ /**
1278
+ * 解析一行 NDJSON 文本为 ClaudeStreamEvent
1279
+ */
1280
+ parseLine(line) {
1281
+ try {
1282
+ const parsed = JSON.parse(line);
1283
+ return { ok: true, value: parsed };
1284
+ } catch (err) {
1285
+ return {
1286
+ ok: false,
1287
+ error: err instanceof Error ? err : new Error(String(err))
1288
+ };
1289
+ }
1290
+ }
1291
+ /**
1292
+ * 根据 Claude 事件更新会话状态
1293
+ */
1294
+ updateSessionStatus(sessionId, event) {
1295
+ const entry = this.activeSessions.get(sessionId);
1296
+ if (!entry) return;
1297
+ entry.session.lastActiveAt = Date.now();
1298
+ switch (event.type) {
1299
+ case "system":
1300
+ if (event.subtype === "init") {
1301
+ entry.session.status = "running";
1302
+ }
1303
+ break;
1304
+ case "assistant":
1305
+ entry.session.status = "running";
1306
+ break;
1307
+ case "result":
1308
+ entry.session.status = event.is_error ? "error" : "idle";
1309
+ break;
1310
+ }
1311
+ }
1312
+ /**
1313
+ * 根据对话上下文生成下一步建议指令
1314
+ *
1315
+ * 使用 --output-format text 做一次性调用,返回纯文本结果。
1316
+ */
1317
+ async generateSuggestion(context) {
1318
+ 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):
1319
+
1320
+ ${context}`;
1321
+ return new Promise((resolve, reject) => {
1322
+ const env = { ...process.env };
1323
+ delete env.CLAUDECODE;
1324
+ const proc = (0, import_child_process.spawn)(CLAUDE_PATH, ["-p", prompt, "--output-format", "text"], {
1325
+ cwd: (0, import_node_os2.homedir)(),
1326
+ env,
1327
+ stdio: ["pipe", "pipe", "pipe"]
1328
+ });
1329
+ proc.stdin.end();
1330
+ let output = "";
1331
+ proc.stdout?.on("data", (data) => {
1332
+ output += data.toString();
1333
+ });
1334
+ proc.once("exit", (code) => {
1335
+ if (code === 0) {
1336
+ resolve(output.trim());
1337
+ } else {
1338
+ reject(new Error(`generateSuggestion process exit code: ${code}`));
1339
+ }
1340
+ });
1341
+ proc.once("error", reject);
1342
+ });
1343
+ }
1344
+ /**
1345
+ * 生成事件名称
1346
+ */
1347
+ getEventName(sessionId) {
1348
+ return `claude:${sessionId}`;
1349
+ }
1350
+ };
1351
+
1352
+ // src/providers/CodexProvider.ts
1353
+ var import_child_process2 = require("child_process");
1354
+ var import_readline3 = require("readline");
1355
+ var import_events2 = require("events");
1356
+ var import_fs = require("fs");
1357
+ var import_path2 = require("path");
1358
+ var import_os2 = require("os");
1359
+ var import_uuid2 = require("uuid");
1360
+
1361
+ // src/utils/codexPath.ts
1362
+ var import_node_child_process3 = require("child_process");
1363
+ var import_node_fs2 = require("fs");
1364
+ var import_node_path2 = require("path");
1365
+ var import_node_os3 = require("os");
1011
1366
  function findCodexPath() {
1012
1367
  try {
1013
1368
  const cmd = isWindows ? "where codex" : "which codex";
@@ -1079,9 +1434,9 @@ function isCodexAvailable() {
1079
1434
  }
1080
1435
 
1081
1436
  // src/providers/CodexProvider.ts
1082
- var SESSIX_DIR = (0, import_path.join)((0, import_os.homedir)(), ".sessix");
1083
- var CODEX_SESSIONS_FILE = (0, import_path.join)(SESSIX_DIR, "codex-sessions.json");
1084
- var CODEX_EVENTS_DIR = (0, import_path.join)(SESSIX_DIR, "codex-events");
1437
+ var SESSIX_DIR = (0, import_path2.join)((0, import_os2.homedir)(), ".sessix");
1438
+ var CODEX_SESSIONS_FILE = (0, import_path2.join)(SESSIX_DIR, "codex-sessions.json");
1439
+ var CODEX_EVENTS_DIR = (0, import_path2.join)(SESSIX_DIR, "codex-events");
1085
1440
  var SESSION_EXPIRY_MS = 30 * 24 * 60 * 60 * 1e3;
1086
1441
  var CodexProvider = class {
1087
1442
  activeSessions = /* @__PURE__ */ new Map();
@@ -1228,12 +1583,6 @@ var CodexProvider = class {
1228
1583
  async generateSuggestion(_context) {
1229
1584
  return "";
1230
1585
  }
1231
- async answerQuestion(_sessionId, _toolUseId, _answer) {
1232
- }
1233
- onQuestion(_sessionId, _callback) {
1234
- return () => {
1235
- };
1236
- }
1237
1586
  // ============================================
1238
1587
  // 私有方法
1239
1588
  // ============================================
@@ -1285,7 +1634,7 @@ var CodexProvider = class {
1285
1634
  */
1286
1635
  attachStdoutListener(sessionId, proc) {
1287
1636
  if (!proc.stdout) return;
1288
- const rl = (0, import_readline2.createInterface)({ input: proc.stdout, crlfDelay: Infinity });
1637
+ const rl = (0, import_readline3.createInterface)({ input: proc.stdout, crlfDelay: Infinity });
1289
1638
  const entry = this.activeSessions.get(sessionId);
1290
1639
  if (entry) entry.rl = rl;
1291
1640
  rl.on("line", (line) => {
@@ -1567,9 +1916,9 @@ var CodexProvider = class {
1567
1916
  * 优先从内存读,miss 时从磁盘加载
1568
1917
  */
1569
1918
  getSessionHistory(sessionId) {
1570
- const cached = this.sessionEvents.get(sessionId);
1571
- if (cached && cached.length > 0) return cached;
1572
- const filePath = (0, import_path.join)(CODEX_EVENTS_DIR, `${sessionId}.json`);
1919
+ const cached2 = this.sessionEvents.get(sessionId);
1920
+ if (cached2 && cached2.length > 0) return cached2;
1921
+ const filePath = (0, import_path2.join)(CODEX_EVENTS_DIR, `${sessionId}.json`);
1573
1922
  try {
1574
1923
  if (!(0, import_fs.existsSync)(filePath)) return [];
1575
1924
  const data = JSON.parse((0, import_fs.readFileSync)(filePath, "utf-8"));
@@ -1590,7 +1939,7 @@ var CodexProvider = class {
1590
1939
  (0, import_fs.mkdirSync)(CODEX_EVENTS_DIR, { recursive: true });
1591
1940
  }
1592
1941
  (0, import_fs.writeFileSync)(
1593
- (0, import_path.join)(CODEX_EVENTS_DIR, `${sessionId}.json`),
1942
+ (0, import_path2.join)(CODEX_EVENTS_DIR, `${sessionId}.json`),
1594
1943
  JSON.stringify(events),
1595
1944
  "utf-8"
1596
1945
  );
@@ -1615,7 +1964,7 @@ var CodexProvider = class {
1615
1964
  if (now - m.lastActiveAt > SESSION_EXPIRY_MS) {
1616
1965
  expiredCount++;
1617
1966
  try {
1618
- const eventsFile = (0, import_path.join)(CODEX_EVENTS_DIR, `${sessionId}.json`);
1967
+ const eventsFile = (0, import_path2.join)(CODEX_EVENTS_DIR, `${sessionId}.json`);
1619
1968
  if ((0, import_fs.existsSync)(eventsFile)) (0, import_fs.unlinkSync)(eventsFile);
1620
1969
  } catch {
1621
1970
  }
@@ -1728,7 +2077,6 @@ var ProviderFactory = class {
1728
2077
  };
1729
2078
 
1730
2079
  // src/session/SessionManager.ts
1731
- var import_uuid3 = require("uuid");
1732
2080
  var BUFFER_MAX = 5e3;
1733
2081
  var SessionManager = class {
1734
2082
  provider;
@@ -1736,10 +2084,18 @@ var SessionManager = class {
1736
2084
  sessionAgentType = /* @__PURE__ */ new Map();
1737
2085
  /** 事件回调列表(事件会被转发到 WsBridge) */
1738
2086
  eventCallbacks = [];
2087
+ /** 会话被移除(kill / 淘汰)时的回调列表(用于释放外部模块的会话级状态,如 NotificationService) */
2088
+ sessionRemovedCallbacks = [];
1739
2089
  /** 每个会话的事件流取消订阅函数 */
1740
2090
  unsubscribeMap = /* @__PURE__ */ new Map();
1741
2091
  /** 每个会话的事件缓冲区(用于新订阅者重放)*/
1742
2092
  sessionEventBuffers = /* @__PURE__ */ new Map();
2093
+ /**
2094
+ * 每个会话最近一次 AskUserQuestion tool_use 的真实 id(从 claude_event 流捕获)。
2095
+ * PreToolUse hook payload 不含 tool_use_id,但内联卡片需要它来匹配状态,
2096
+ * 故在转发流事件时记录,askQuestion 时兜底回填。
2097
+ */
2098
+ lastAskQuestionToolUseId = /* @__PURE__ */ new Map();
1743
2099
  /** AskUserQuestion 问题映射:requestId → resolve 回调 + 原始问题内容 */
1744
2100
  pendingQuestions = /* @__PURE__ */ new Map();
1745
2101
  /**
@@ -1848,6 +2204,7 @@ var SessionManager = class {
1848
2204
  this.bufferTruncated.delete(sessionId);
1849
2205
  this.sessionProjectPaths.delete(sessionId);
1850
2206
  this.sessionStats.delete(sessionId);
2207
+ this.lastAskQuestionToolUseId.delete(sessionId);
1851
2208
  const pending = this.pendingAssistantEvents.get(sessionId);
1852
2209
  if (pending) {
1853
2210
  clearTimeout(pending.timer);
@@ -1856,6 +2213,13 @@ var SessionManager = class {
1856
2213
  const provider = this.getProviderForSession(sessionId);
1857
2214
  await provider.killSession(sessionId);
1858
2215
  this.sessionAgentType.delete(sessionId);
2216
+ for (const cb of this.sessionRemovedCallbacks) {
2217
+ try {
2218
+ cb(sessionId);
2219
+ } catch (err) {
2220
+ console.error("[SessionManager] sessionRemoved callback failed:", err);
2221
+ }
2222
+ }
1859
2223
  console.log(`[SessionManager] Session killed: ${sessionId}`);
1860
2224
  }
1861
2225
  /**
@@ -1908,7 +2272,22 @@ var SessionManager = class {
1908
2272
  }
1909
2273
  }
1910
2274
  /**
1911
- * 获取指定会话的所有待回答问题(用于重连时恢复)
2275
+ * 幂等清理单个待回答问题(由 ApprovalProxy onQuestionResolved 触发:
2276
+ * 答案到达 / 325s 超时 / 会话 kill / 服务关闭)。
2277
+ * 已不存在则静默返回(不打 warn,与 handleQuestionResponse 区分)。
2278
+ */
2279
+ clearPendingQuestion(requestId) {
2280
+ const pending = this.pendingQuestions.get(requestId);
2281
+ if (!pending) return;
2282
+ const { sessionId } = pending;
2283
+ this.pendingQuestions.delete(requestId);
2284
+ pending.resolve("");
2285
+ if (!this.hasPendingQuestionsForSession(sessionId)) {
2286
+ this.updateSessionStatus(sessionId, "running");
2287
+ }
2288
+ }
2289
+ /**
2290
+ * 获取指定会话的所有待回答问题(用于重连时恢复)
1912
2291
  */
1913
2292
  getPendingQuestionsForSession(sessionId) {
1914
2293
  const result = [];
@@ -2031,6 +2410,21 @@ var SessionManager = class {
2031
2410
  }
2032
2411
  };
2033
2412
  }
2413
+ /**
2414
+ * 注册"会话被移除"回调(会话 kill 或淘汰时触发,传入 sessionId)。
2415
+ * 用于让外部模块释放会话级状态,如 NotificationService.releaseSession。
2416
+ *
2417
+ * @returns 取消注册的函数
2418
+ */
2419
+ onSessionRemoved(callback) {
2420
+ this.sessionRemovedCallbacks.push(callback);
2421
+ return () => {
2422
+ const index = this.sessionRemovedCallbacks.indexOf(callback);
2423
+ if (index !== -1) {
2424
+ this.sessionRemovedCallbacks.splice(index, 1);
2425
+ }
2426
+ };
2427
+ }
2034
2428
  /**
2035
2429
  * 清理所有资源
2036
2430
  */
@@ -2048,31 +2442,28 @@ var SessionManager = class {
2048
2442
  clearTimeout(pending.timer);
2049
2443
  }
2050
2444
  this.pendingAssistantEvents.clear();
2445
+ for (const pending of this.pendingQuestions.values()) {
2446
+ pending.resolve("");
2447
+ }
2051
2448
  this.pendingQuestions.clear();
2052
2449
  this.lastBroadcastStatus.clear();
2053
2450
  this.eventCallbacks.length = 0;
2451
+ this.sessionRemovedCallbacks.length = 0;
2054
2452
  console.log("[SessionManager] Destroyed");
2055
2453
  }
2056
2454
  // ============================================
2057
2455
  // 内部方法
2058
2456
  // ============================================
2059
2457
  /**
2060
- * 订阅指定会话的事件流(包括 AskUserQuestion 问题事件)
2458
+ * 订阅指定会话的事件流(AskUserQuestion 已改由 ApprovalProxy hook 驱动)
2061
2459
  */
2062
2460
  subscribeToSession(sessionId) {
2063
2461
  const provider = this.getProviderForSession(sessionId);
2064
2462
  const unsubscribeEvent = provider.onEvent(sessionId, (event) => {
2065
2463
  this.handleClaudeEvent(sessionId, event);
2066
2464
  });
2067
- const unsubscribeQuestion = provider.onQuestion(
2068
- sessionId,
2069
- ({ toolUseId, question, options, questions }) => {
2070
- this.handleAskUserQuestion(sessionId, toolUseId, question, options, questions);
2071
- }
2072
- );
2073
2465
  this.unsubscribeMap.set(sessionId, () => {
2074
2466
  unsubscribeEvent();
2075
- unsubscribeQuestion();
2076
2467
  });
2077
2468
  }
2078
2469
  /**
@@ -2106,6 +2497,13 @@ var SessionManager = class {
2106
2497
  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(",")}`);
2107
2498
  }
2108
2499
  }
2500
+ if (event.type === "assistant" && Array.isArray(event.message?.content)) {
2501
+ for (const block of event.message.content) {
2502
+ if (block.type === "tool_use" && (block.name === "AskUserQuestion" || block.name === "AskFollowupQuestion") && typeof block.id === "string") {
2503
+ this.lastAskQuestionToolUseId.set(sessionId, block.id);
2504
+ }
2505
+ }
2506
+ }
2109
2507
  switch (event.type) {
2110
2508
  case "assistant":
2111
2509
  this.bufferAssistantEvent(sessionId, event);
@@ -2221,55 +2619,35 @@ var SessionManager = class {
2221
2619
  return runningStartedAt ? { ...base, runningStartedAt } : base;
2222
2620
  }
2223
2621
  /**
2224
- * 处理 AskUserQuestion 事件:广播问题请求到手机,等待用户回答
2622
+ * ApprovalProxy 在 PreToolUse hook 拦截到 AskUserQuestion 时调用。
2623
+ * 登记 pendingQuestion、广播 question_request、置 waiting_question,
2624
+ * 返回的 Promise 在 handleQuestionResponse 时 resolve。
2225
2625
  */
2226
- handleAskUserQuestion(sessionId, toolUseId, question, options, questions) {
2227
- const existingEntry = Array.from(this.pendingQuestions.entries()).find(
2228
- ([, v]) => v.toolUseId === toolUseId
2229
- );
2230
- if (existingEntry) {
2231
- const [existingRequestId, existingPending] = existingEntry;
2232
- existingPending.question = question;
2233
- existingPending.options = options;
2234
- existingPending.questions = questions;
2235
- existingPending.createdAt = Date.now();
2236
- const updatedRequest = {
2237
- id: existingRequestId,
2238
- sessionId,
2239
- toolUseId,
2240
- question,
2241
- options,
2242
- questions,
2243
- createdAt: existingPending.createdAt
2244
- };
2245
- this.emit({ type: "question_request", request: updatedRequest });
2246
- console.log(`[SessionManager] Session ${sessionId}: AskUserQuestion updated (requestId=${existingRequestId})`);
2247
- return;
2248
- }
2249
- const requestId = (0, import_uuid3.v4)();
2626
+ askQuestion(sessionId, toolUseId, questions, requestId) {
2627
+ const resolvedToolUseId = toolUseId || this.lastAskQuestionToolUseId.get(sessionId) || "";
2250
2628
  const request = {
2251
2629
  id: requestId,
2252
2630
  sessionId,
2253
- toolUseId,
2254
- question,
2255
- options,
2631
+ toolUseId: resolvedToolUseId,
2632
+ question: questions[0]?.question ?? "",
2633
+ options: questions[0]?.options?.map((o) => o.label),
2256
2634
  questions,
2257
2635
  createdAt: Date.now()
2258
2636
  };
2259
2637
  this.updateSessionStatus(sessionId, "waiting_question");
2260
2638
  this.emit({ type: "question_request", request });
2261
- const answerPromise = new Promise((resolve) => {
2262
- this.pendingQuestions.set(requestId, { sessionId, toolUseId, question, options, questions, createdAt: request.createdAt, resolve });
2263
- });
2264
- answerPromise.then(async (answer) => {
2265
- try {
2266
- const provider = this.getProviderForSession(sessionId);
2267
- await provider.answerQuestion(sessionId, toolUseId, answer);
2268
- } catch (err) {
2269
- console.error(`[SessionManager] answerQuestion failed (${sessionId}):`, err);
2270
- }
2271
- }).catch((err) => console.error("[SessionManager] answerPromise rejected:", err));
2272
2639
  console.log(`[SessionManager] Session ${sessionId}: AskUserQuestion pushed (requestId=${requestId})`);
2640
+ return new Promise((resolve) => {
2641
+ this.pendingQuestions.set(requestId, {
2642
+ sessionId,
2643
+ toolUseId: resolvedToolUseId,
2644
+ question: request.question,
2645
+ options: request.options,
2646
+ questions,
2647
+ createdAt: request.createdAt,
2648
+ resolve
2649
+ });
2650
+ });
2273
2651
  }
2274
2652
  /**
2275
2653
  * 清除指定会话的所有待回答问题
@@ -2282,6 +2660,7 @@ var SessionManager = class {
2282
2660
  }
2283
2661
  }
2284
2662
  for (const requestId of toRemove) {
2663
+ this.pendingQuestions.get(requestId)?.resolve("");
2285
2664
  this.pendingQuestions.delete(requestId);
2286
2665
  }
2287
2666
  }
@@ -2301,7 +2680,7 @@ var SessionManager = class {
2301
2680
 
2302
2681
  // src/session/SessionFileWatcher.ts
2303
2682
  var import_chokidar = __toESM(require("chokidar"));
2304
- var import_promises = require("fs/promises");
2683
+ var import_promises2 = require("fs/promises");
2305
2684
  var import_node_readline = require("readline");
2306
2685
  var SessionFileWatcher = class {
2307
2686
  watchers = /* @__PURE__ */ new Map();
@@ -2380,7 +2759,7 @@ var SessionFileWatcher = class {
2380
2759
  let fileHandle;
2381
2760
  let rl;
2382
2761
  try {
2383
- fileHandle = await (0, import_promises.open)(entry.filePath, "r");
2762
+ fileHandle = await (0, import_promises2.open)(entry.filePath, "r");
2384
2763
  const fileStat = await fileHandle.stat();
2385
2764
  const newSize = fileStat.size;
2386
2765
  if (newSize <= entry.byteOffset) return;
@@ -2690,7 +3069,7 @@ var import_node_http = __toESM(require("http"));
2690
3069
  var import_node_fs3 = __toESM(require("fs"));
2691
3070
  var import_node_path3 = __toESM(require("path"));
2692
3071
  var import_node_os4 = __toESM(require("os"));
2693
- var import_uuid4 = require("uuid");
3072
+ var import_uuid3 = require("uuid");
2694
3073
  var ApprovalProxy = class _ApprovalProxy {
2695
3074
  server;
2696
3075
  token;
@@ -2698,10 +3077,16 @@ var ApprovalProxy = class _ApprovalProxy {
2698
3077
  settingsPath = import_node_path3.default.join(import_node_os4.default.homedir(), ".claude", "settings.json");
2699
3078
  /** 待处理的审批请求:requestId -> { resolve, timer, request } */
2700
3079
  pendingApprovals = /* @__PURE__ */ new Map();
3080
+ /** 待回答的 AskUserQuestion:requestId -> { resolve, timer, request } */
3081
+ pendingQuestions = /* @__PURE__ */ new Map();
3082
+ /** 由外部注入:把问题推给手机并等待答案(返回用户答案文本) */
3083
+ questionHandler = null;
2701
3084
  /** 审批请求回调(通知外部推送到手机) */
2702
3085
  approvalRequestCallbacks = [];
2703
3086
  /** 审批 resolve 回调(任何来源的 resolve 都会触发,用于 WS 广播清理 */
2704
3087
  approvalResolvedCallbacks = [];
3088
+ /** 问题 resolve 回调(任何来源的 resolve 都会触发,用于 SessionManager 清理) */
3089
+ questionResolvedCallbacks = [];
2705
3090
  /** Hook 通知回调(PreCompact / PermissionDenied / 等非阻塞 hook) */
2706
3091
  notifyCallbacks = [];
2707
3092
  /** YOLO 模式状态:sessionId -> enabled */
@@ -2765,6 +3150,30 @@ var ApprovalProxy = class _ApprovalProxy {
2765
3150
  }
2766
3151
  }
2767
3152
  }
3153
+ /**
3154
+ * 注册问题 resolve 回调
3155
+ *
3156
+ * 任何来源的 resolve 都会触发:
3157
+ * - resolveQuestion(手机端答案到达)
3158
+ * - 325s 超时自动空答案
3159
+ * - clearPendingQuestionsForSession(会话被 kill)
3160
+ * - close()(服务关闭)
3161
+ *
3162
+ * 用于通知 SessionManager 清理 pendingQuestions,避免会话卡在 waiting_question。
3163
+ */
3164
+ onQuestionResolved(callback) {
3165
+ this.questionResolvedCallbacks.push(callback);
3166
+ }
3167
+ /** 通知所有问题 resolve 回调(内部调用) */
3168
+ notifyQuestionResolved(requestId) {
3169
+ for (const cb of this.questionResolvedCallbacks) {
3170
+ try {
3171
+ cb(requestId);
3172
+ } catch (err) {
3173
+ console.error("[ApprovalProxy] question resolved callback error:", err);
3174
+ }
3175
+ }
3176
+ }
2768
3177
  /**
2769
3178
  * 注册非阻塞 hook 通知回调(如 PreCompact、PermissionDenied)
2770
3179
  *
@@ -2819,6 +3228,42 @@ var ApprovalProxy = class _ApprovalProxy {
2819
3228
  this.notifyApprovalResolved(requestId, decision);
2820
3229
  return true;
2821
3230
  }
3231
+ /** 注入问题处理器(server.ts 接到 SessionManager.askQuestion) */
3232
+ setQuestionHandler(handler) {
3233
+ this.questionHandler = handler;
3234
+ }
3235
+ /** 解析一个待回答问题(手机端答案到达时由 server.ts 调用) */
3236
+ resolveQuestion(requestId, answer) {
3237
+ const pending = this.pendingQuestions.get(requestId);
3238
+ if (!pending) {
3239
+ console.warn(`[ApprovalProxy] Question request not found: ${requestId}`);
3240
+ return false;
3241
+ }
3242
+ clearTimeout(pending.timer);
3243
+ pending.resolve(answer);
3244
+ this.pendingQuestions.delete(requestId);
3245
+ console.log(`[ApprovalProxy] Question answered: ${requestId}`);
3246
+ this.notifyQuestionResolved(requestId);
3247
+ return true;
3248
+ }
3249
+ /** 清理会话的待回答问题(会话被 kill 时,给空答案让 hook 不再阻塞) */
3250
+ clearPendingQuestionsForSession(sessionId) {
3251
+ const toRemove = [];
3252
+ for (const [requestId, pending] of this.pendingQuestions) {
3253
+ if (pending.request.sessionId === sessionId) {
3254
+ toRemove.push(requestId);
3255
+ }
3256
+ }
3257
+ for (const requestId of toRemove) {
3258
+ const pending = this.pendingQuestions.get(requestId);
3259
+ if (!pending) continue;
3260
+ clearTimeout(pending.timer);
3261
+ pending.resolve("");
3262
+ this.pendingQuestions.delete(requestId);
3263
+ console.log(`[ApprovalProxy] Session ${sessionId} killed, cleared pending question ${requestId}`);
3264
+ this.notifyQuestionResolved(requestId);
3265
+ }
3266
+ }
2822
3267
  /** 获取当前待处理的审批数量 */
2823
3268
  getPendingCount() {
2824
3269
  return this.pendingApprovals.size;
@@ -2940,6 +3385,13 @@ var ApprovalProxy = class _ApprovalProxy {
2940
3385
  pending.resolve({ decision: "deny", reason: t("approval.serverClosed") });
2941
3386
  }
2942
3387
  this.pendingApprovals.clear();
3388
+ const pendingQuestionEntries = Array.from(this.pendingQuestions.entries());
3389
+ for (const [requestId, pending] of pendingQuestionEntries) {
3390
+ clearTimeout(pending.timer);
3391
+ pending.resolve("");
3392
+ this.notifyQuestionResolved(requestId);
3393
+ }
3394
+ this.pendingQuestions.clear();
2943
3395
  this.server.close((err) => {
2944
3396
  if (err) {
2945
3397
  reject(err);
@@ -2995,7 +3447,7 @@ var ApprovalProxy = class _ApprovalProxy {
2995
3447
  try {
2996
3448
  const body = await this.parseJsonBody(req);
2997
3449
  const payload = body.payload ?? body;
2998
- const requestId = (0, import_uuid4.v4)();
3450
+ const requestId = (0, import_uuid3.v4)();
2999
3451
  const projectPath = String(body.projectPath ?? "unknown");
3000
3452
  const toolName = String(payload.tool_name ?? body.tool_name ?? "unknown");
3001
3453
  const toolInput = payload.tool_input ?? body.tool_input ?? {};
@@ -3009,6 +3461,51 @@ var ApprovalProxy = class _ApprovalProxy {
3009
3461
  createdAt: Date.now()
3010
3462
  };
3011
3463
  console.log(`[ApprovalProxy] ${t("approval.received")}: ${requestId} (${approvalRequest.toolName})`);
3464
+ if ((approvalRequest.toolName === "AskUserQuestion" || approvalRequest.toolName === "AskFollowupQuestion") && this.questionHandler) {
3465
+ const questions = parseQuestionsFromInput(toolInput);
3466
+ if (questions.length === 0) {
3467
+ this.sendJson(res, 200, { decision: "allow" });
3468
+ return;
3469
+ }
3470
+ const toolUseId = String(
3471
+ payload.tool_use_id ?? body.tool_use_id ?? ""
3472
+ );
3473
+ const qRequest = {
3474
+ id: requestId,
3475
+ sessionId: approvalRequest.sessionId,
3476
+ toolUseId,
3477
+ question: questions[0].question,
3478
+ options: questions[0].options?.map((o) => o.label),
3479
+ questions,
3480
+ createdAt: Date.now()
3481
+ };
3482
+ const answer = await new Promise((resolve) => {
3483
+ const timer = setTimeout(() => {
3484
+ this.pendingQuestions.delete(requestId);
3485
+ console.log(`[ApprovalProxy] Question timeout: ${requestId}`);
3486
+ resolve("");
3487
+ this.notifyQuestionResolved(requestId);
3488
+ }, 325e3);
3489
+ this.pendingQuestions.set(requestId, { resolve, timer, request: qRequest });
3490
+ this.questionHandler(qRequest.sessionId, toolUseId, questions, requestId).then((ans) => {
3491
+ if (ans && this.pendingQuestions.has(requestId)) this.resolveQuestion(requestId, ans);
3492
+ }).catch((err) => console.error("[ApprovalProxy] questionHandler error:", err));
3493
+ });
3494
+ if (!answer) {
3495
+ this.sendJson(res, 200, {
3496
+ decision: "deny",
3497
+ 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",
3498
+ systemMessage: "\u7528\u6237\u672A\u56DE\u7B54 AskUserQuestion\uFF1B\u8BF7\u52FF\u91CD\u8BD5\u8BE5\u5DE5\u5177\uFF0C\u81EA\u884C\u51B3\u7B56\u7EE7\u7EED\u3002"
3499
+ });
3500
+ return;
3501
+ }
3502
+ this.sendJson(res, 200, {
3503
+ decision: "deny",
3504
+ reason: formatQuestionAnswer(questions, answer),
3505
+ 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"
3506
+ });
3507
+ return;
3508
+ }
3012
3509
  if (this.isToolAlwaysAllowed(approvalRequest.toolName, projectPath !== "unknown" ? projectPath : void 0)) {
3013
3510
  console.log(`[ApprovalProxy] ${t("approval.alwaysAllowPassThrough", { tool: approvalRequest.toolName })}`);
3014
3511
  this.sendJson(res, 200, { decision: "allow" });
@@ -3198,6 +3695,49 @@ var ApprovalProxy = class _ApprovalProxy {
3198
3695
  res.end(body);
3199
3696
  }
3200
3697
  };
3698
+ function parseQuestionsFromInput(input) {
3699
+ if (Array.isArray(input.questions) && input.questions.length > 0) {
3700
+ return input.questions.map((q) => {
3701
+ const item = {
3702
+ question: typeof q.question === "string" ? q.question : "",
3703
+ header: typeof q.header === "string" ? q.header : void 0,
3704
+ multiSelect: typeof q.multiSelect === "boolean" ? q.multiSelect : void 0
3705
+ };
3706
+ if (Array.isArray(q.options)) {
3707
+ item.options = q.options.map((o) => ({
3708
+ label: typeof o.label === "string" ? o.label : "",
3709
+ description: typeof o.description === "string" ? o.description : void 0
3710
+ }));
3711
+ }
3712
+ return item;
3713
+ });
3714
+ }
3715
+ if (typeof input.question === "string") {
3716
+ const opts = Array.isArray(input.options) ? input.options.map((o) => ({ label: String(o) })) : void 0;
3717
+ return [{ question: input.question, options: opts }];
3718
+ }
3719
+ return [];
3720
+ }
3721
+ function formatQuestionAnswer(questions, raw) {
3722
+ let pairs = [];
3723
+ try {
3724
+ const parsed = JSON.parse(raw);
3725
+ if (parsed && typeof parsed === "object" && parsed.answers && typeof parsed.answers === "object") {
3726
+ const answers = parsed.answers;
3727
+ 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) }));
3728
+ }
3729
+ } catch {
3730
+ }
3731
+ if (pairs.length === 0) {
3732
+ pairs = [{ q: questions[0]?.question ?? "\uFF08\u672A\u77E5\u95EE\u9898\uFF09", a: raw }];
3733
+ }
3734
+ const body = pairs.map((p, i) => `${i + 1}. \u95EE\u9898\uFF1A${p.q}
3735
+ \u7528\u6237\u56DE\u7B54\uFF1A${p.a}`).join("\n");
3736
+ return `\u3010\u7528\u6237\u5DF2\u901A\u8FC7 Sessix \u56DE\u7B54\u3011
3737
+ ${body}
3738
+
3739
+ \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`;
3740
+ }
3201
3741
 
3202
3742
  // src/mdns/MdnsService.ts
3203
3743
  var import_node_child_process5 = require("child_process");
@@ -3363,7 +3903,7 @@ function getLanAddresses(networkInterfacesFn) {
3363
3903
  }
3364
3904
 
3365
3905
  // src/hooks/HookInstaller.ts
3366
- var import_promises2 = require("fs/promises");
3906
+ var import_promises3 = require("fs/promises");
3367
3907
  var import_node_path4 = require("path");
3368
3908
  var import_node_os6 = require("os");
3369
3909
  var SESSIX_HOOKS_DIR = (0, import_node_path4.join)((0, import_node_os6.homedir)(), ".sessix", "hooks");
@@ -3383,7 +3923,7 @@ var LEGACY_HOOK_COMMANDS = [
3383
3923
  "~/.sessix/hooks/permission-accept.sh"
3384
3924
  ];
3385
3925
  var HOOK_SCRIPT_TEMPLATE = `#!/usr/bin/env node
3386
- // Sessix Approval Hook
3926
+ // Sessix Approval Hook v2 (systemMessage passthrough)
3387
3927
  // \u4EC5\u5728 Sessix \u7BA1\u7406\u7684\u4F1A\u8BDD\u4E2D\u6FC0\u6D3B
3388
3928
 
3389
3929
  const sessionId = process.env.SESSIX_SESSION_ID
@@ -3414,6 +3954,9 @@ process.stdin.on('end', async () => {
3414
3954
  if (decision === 'deny' && data.reason) {
3415
3955
  output.hookSpecificOutput.permissionDecisionReason = String(data.reason)
3416
3956
  }
3957
+ if (data.systemMessage) {
3958
+ output.systemMessage = String(data.systemMessage)
3959
+ }
3417
3960
  process.stdout.write(JSON.stringify(output))
3418
3961
  process.exit(0)
3419
3962
  } catch {
@@ -3553,17 +4096,17 @@ var HookInstaller = class {
3553
4096
  * 4. 更新 Claude Code settings.json 添加 hook 配置
3554
4097
  */
3555
4098
  async install() {
3556
- await (0, import_promises2.mkdir)(SESSIX_HOOKS_DIR, { recursive: true });
3557
- await (0, import_promises2.writeFile)(HOOK_SCRIPT_PATH, HOOK_SCRIPT_TEMPLATE, "utf-8");
3558
- await (0, import_promises2.writeFile)(PERMISSION_ACCEPT_PATH, PERMISSION_ACCEPT_TEMPLATE, "utf-8");
3559
- await (0, import_promises2.writeFile)(COMPACT_HOOK_PATH, COMPACT_HOOK_TEMPLATE, "utf-8");
3560
- await (0, import_promises2.writeFile)(POST_COMPACT_HOOK_PATH, POST_COMPACT_HOOK_TEMPLATE, "utf-8");
3561
- await (0, import_promises2.writeFile)(PERMISSION_DENIED_HOOK_PATH, PERMISSION_DENIED_HOOK_TEMPLATE, "utf-8");
3562
- await (0, import_promises2.chmod)(HOOK_SCRIPT_PATH, 493);
3563
- await (0, import_promises2.chmod)(PERMISSION_ACCEPT_PATH, 493);
3564
- await (0, import_promises2.chmod)(COMPACT_HOOK_PATH, 493);
3565
- await (0, import_promises2.chmod)(POST_COMPACT_HOOK_PATH, 493);
3566
- await (0, import_promises2.chmod)(PERMISSION_DENIED_HOOK_PATH, 493);
4099
+ await (0, import_promises3.mkdir)(SESSIX_HOOKS_DIR, { recursive: true });
4100
+ await (0, import_promises3.writeFile)(HOOK_SCRIPT_PATH, HOOK_SCRIPT_TEMPLATE, "utf-8");
4101
+ await (0, import_promises3.writeFile)(PERMISSION_ACCEPT_PATH, PERMISSION_ACCEPT_TEMPLATE, "utf-8");
4102
+ await (0, import_promises3.writeFile)(COMPACT_HOOK_PATH, COMPACT_HOOK_TEMPLATE, "utf-8");
4103
+ await (0, import_promises3.writeFile)(POST_COMPACT_HOOK_PATH, POST_COMPACT_HOOK_TEMPLATE, "utf-8");
4104
+ await (0, import_promises3.writeFile)(PERMISSION_DENIED_HOOK_PATH, PERMISSION_DENIED_HOOK_TEMPLATE, "utf-8");
4105
+ await (0, import_promises3.chmod)(HOOK_SCRIPT_PATH, 493);
4106
+ await (0, import_promises3.chmod)(PERMISSION_ACCEPT_PATH, 493);
4107
+ await (0, import_promises3.chmod)(COMPACT_HOOK_PATH, 493);
4108
+ await (0, import_promises3.chmod)(POST_COMPACT_HOOK_PATH, 493);
4109
+ await (0, import_promises3.chmod)(PERMISSION_DENIED_HOOK_PATH, 493);
3567
4110
  await this.addHookToSettings();
3568
4111
  console.log("[HookInstaller] Hook installation complete");
3569
4112
  }
@@ -3593,33 +4136,34 @@ var HookInstaller = class {
3593
4136
  let postCompactScriptExists = false;
3594
4137
  let permissionDeniedScriptExists = false;
3595
4138
  try {
3596
- approvalScriptContent = await (0, import_promises2.readFile)(HOOK_SCRIPT_PATH, "utf-8");
4139
+ approvalScriptContent = await (0, import_promises3.readFile)(HOOK_SCRIPT_PATH, "utf-8");
3597
4140
  } catch {
3598
4141
  }
3599
4142
  try {
3600
- await (0, import_promises2.access)(PERMISSION_ACCEPT_PATH);
4143
+ await (0, import_promises3.access)(PERMISSION_ACCEPT_PATH);
3601
4144
  permissionScriptExists = true;
3602
4145
  } catch {
3603
4146
  }
3604
4147
  try {
3605
- await (0, import_promises2.access)(COMPACT_HOOK_PATH);
4148
+ await (0, import_promises3.access)(COMPACT_HOOK_PATH);
3606
4149
  compactScriptExists = true;
3607
4150
  } catch {
3608
4151
  }
3609
4152
  try {
3610
- await (0, import_promises2.access)(POST_COMPACT_HOOK_PATH);
4153
+ await (0, import_promises3.access)(POST_COMPACT_HOOK_PATH);
3611
4154
  postCompactScriptExists = true;
3612
4155
  } catch {
3613
4156
  }
3614
4157
  try {
3615
- await (0, import_promises2.access)(PERMISSION_DENIED_HOOK_PATH);
4158
+ await (0, import_promises3.access)(PERMISSION_DENIED_HOOK_PATH);
3616
4159
  permissionDeniedScriptExists = true;
3617
4160
  } catch {
3618
4161
  }
3619
- const isLatestVersion = approvalScriptContent.includes("permissionDecision");
4162
+ const isLatestVersion = approvalScriptContent.includes("permissionDecision") && approvalScriptContent.includes("Sessix Approval Hook v2");
3620
4163
  const settings = await this.readClaudeSettings();
3621
4164
  const configExists = this.hasHookConfig(settings);
3622
- return isLatestVersion && permissionScriptExists && compactScriptExists && postCompactScriptExists && permissionDeniedScriptExists && configExists;
4165
+ const hasLegacyHook = this.hasHookEntry(settings?.hooks?.PreToolUse, LEGACY_HOOK_COMMANDS[0]) || this.hasHookEntry(settings?.hooks?.PermissionRequest, LEGACY_HOOK_COMMANDS[1]);
4166
+ return isLatestVersion && permissionScriptExists && compactScriptExists && postCompactScriptExists && permissionDeniedScriptExists && configExists && !hasLegacyHook;
3623
4167
  }
3624
4168
  // ============================================
3625
4169
  // 内部方法
@@ -3631,8 +4175,14 @@ var HookInstaller = class {
3631
4175
  let settings = await this.readClaudeSettings();
3632
4176
  let changed = false;
3633
4177
  for (const cmd of LEGACY_HOOK_COMMANDS) {
3634
- this.removeHookCommand(settings, "PreToolUse", cmd);
3635
- this.removeHookCommand(settings, "PermissionRequest", cmd);
4178
+ if (this.hasHookEntry(settings?.hooks?.PreToolUse, cmd)) {
4179
+ this.removeHookCommand(settings, "PreToolUse", cmd);
4180
+ changed = true;
4181
+ }
4182
+ if (this.hasHookEntry(settings?.hooks?.PermissionRequest, cmd)) {
4183
+ this.removeHookCommand(settings, "PermissionRequest", cmd);
4184
+ changed = true;
4185
+ }
3636
4186
  }
3637
4187
  if (!settings.hooks) {
3638
4188
  settings.hooks = {};
@@ -3724,7 +4274,7 @@ var HookInstaller = class {
3724
4274
  */
3725
4275
  async readClaudeSettings() {
3726
4276
  try {
3727
- const content = await (0, import_promises2.readFile)(CLAUDE_SETTINGS_PATH, "utf-8");
4277
+ const content = await (0, import_promises3.readFile)(CLAUDE_SETTINGS_PATH, "utf-8");
3728
4278
  return JSON.parse(content);
3729
4279
  } catch {
3730
4280
  return {};
@@ -3734,8 +4284,8 @@ var HookInstaller = class {
3734
4284
  * 写入 Claude Code settings.json
3735
4285
  */
3736
4286
  async writeClaudeSettings(settings) {
3737
- await (0, import_promises2.mkdir)((0, import_node_path4.join)((0, import_node_os6.homedir)(), ".claude"), { recursive: true });
3738
- await (0, import_promises2.writeFile)(CLAUDE_SETTINGS_PATH, JSON.stringify(settings, null, 2) + "\n", "utf-8");
4287
+ await (0, import_promises3.mkdir)((0, import_node_path4.join)((0, import_node_os6.homedir)(), ".claude"), { recursive: true });
4288
+ await (0, import_promises3.writeFile)(CLAUDE_SETTINGS_PATH, JSON.stringify(settings, null, 2) + "\n", "utf-8");
3739
4289
  }
3740
4290
  /**
3741
4291
  * 检查 settings 中是否已包含所有 Sessix hook 配置
@@ -3776,7 +4326,7 @@ var HookInstaller = class {
3776
4326
  var import_node_path5 = require("path");
3777
4327
  var RECENT_ACTIVITY_MAX = 6;
3778
4328
  var ACTIVITY_PUSH_THROTTLE_MS = 4e3;
3779
- var NotificationService = class {
4329
+ var NotificationService = class _NotificationService {
3780
4330
  constructor(sessionManager, expoChannel = null) {
3781
4331
  this.sessionManager = sessionManager;
3782
4332
  this.expoChannel = expoChannel;
@@ -3818,6 +4368,14 @@ var NotificationService = class {
3818
4368
  * token 注册时启动,flushActivityEnd / removeActivityPushToken 时停止。
3819
4369
  */
3820
4370
  laHeartbeatTimers = /* @__PURE__ */ new Map();
4371
+ /**
4372
+ * 上次推送的内容指纹(status + recentActivity + approvalId)。
4373
+ * 只在内容实际变化时发 priority-10 推送;未变化时低频刷新(30s),
4374
+ * 节省 APNs push budget,避免 iOS 节流导致 LA 停滞。
4375
+ */
4376
+ lastPushedFingerprint = /* @__PURE__ */ new Map();
4377
+ /** 内容未变化时低频刷新间隔(仅刷新 stats/timer,不含内容变化) */
4378
+ static STATS_REFRESH_INTERVAL_MS = 3e4;
3821
4379
  /** 添加通知渠道(id 唯一,可用于后续动态开关) */
3822
4380
  addChannel(id, channel, enabled = true) {
3823
4381
  this.channelMap.set(id, { channel, enabled });
@@ -3874,6 +4432,7 @@ var NotificationService = class {
3874
4432
  this.recentActivityState.delete(sessionId);
3875
4433
  this.lastActivityPushAt.delete(sessionId);
3876
4434
  this.activityCounters.delete(sessionId);
4435
+ this.lastPushedFingerprint.delete(sessionId);
3877
4436
  }
3878
4437
  /** 注入"会话 → 待审批列表"提供器(server.ts 启动时调用) */
3879
4438
  setPendingApprovalsProvider(fn) {
@@ -4020,10 +4579,39 @@ var NotificationService = class {
4020
4579
  this.latestAssistantText.clear();
4021
4580
  for (const timer of this.activityPushTimers.values()) clearTimeout(timer);
4022
4581
  this.activityPushTimers.clear();
4582
+ for (const timer of this.idleEndTimers.values()) clearTimeout(timer);
4583
+ this.idleEndTimers.clear();
4584
+ for (const timer of this.laHeartbeatTimers.values()) clearInterval(timer);
4585
+ this.laHeartbeatTimers.clear();
4023
4586
  this.recentActivityState.clear();
4024
4587
  this.lastActivityPushAt.clear();
4025
4588
  this.pendingPriority.clear();
4026
4589
  this.activityCounters.clear();
4590
+ this.lastPushedFingerprint.clear();
4591
+ }
4592
+ /**
4593
+ * 释放单个会话的全部内存状态(会话被 kill 或淘汰时调用)。
4594
+ * 由 SessionManager.onSessionRemoved 钩子触发,覆盖用户主动 kill 和自动淘汰两条路径。
4595
+ * 幂等:重复调用或对未知会话调用都安全。
4596
+ */
4597
+ releaseSession(sessionId) {
4598
+ this.clearActivityPushTimer(sessionId);
4599
+ this.cancelIdleEndTimer(sessionId);
4600
+ this.stopLaHeartbeat(sessionId);
4601
+ this.clearSessionActivityState(sessionId);
4602
+ this.yoloModeState.delete(sessionId);
4603
+ this.lastActivityPushAt.delete(sessionId);
4604
+ this.lastPushedFingerprint.delete(sessionId);
4605
+ this.pendingPriority.delete(sessionId);
4606
+ }
4607
+ /**
4608
+ * 清空单会话可重建的重状态(recentActivity / 计数器 / 最新文本)。
4609
+ * 会话走到 idle 时调用即可释放内存——resume 后这些状态会随新事件自动重建。
4610
+ */
4611
+ clearSessionActivityState(sessionId) {
4612
+ this.recentActivityState.delete(sessionId);
4613
+ this.activityCounters.delete(sessionId);
4614
+ this.latestAssistantText.delete(sessionId);
4027
4615
  }
4028
4616
  // ============================================
4029
4617
  // 内部方法
@@ -4062,6 +4650,7 @@ var NotificationService = class {
4062
4650
  badge: this.getGlobalPendingCount(),
4063
4651
  data: { type: "task_complete", sessionId: event.sessionId }
4064
4652
  });
4653
+ this.clearSessionActivityState(event.sessionId);
4065
4654
  }
4066
4655
  } else if (event.status === "running" || event.status === "waiting_approval" || event.status === "waiting_question") {
4067
4656
  this.cancelIdleEndTimer(event.sessionId);
@@ -4124,6 +4713,8 @@ var NotificationService = class {
4124
4713
  while (state2.history.length > RECENT_ACTIVITY_MAX) state2.history.shift();
4125
4714
  state2.currentEntries = [];
4126
4715
  state2.currentMessageId = null;
4716
+ state2.accumulatedText = "";
4717
+ state2.countedToolIds = /* @__PURE__ */ new Set();
4127
4718
  }
4128
4719
  return;
4129
4720
  }
@@ -4132,7 +4723,7 @@ var NotificationService = class {
4132
4723
  if (!Array.isArray(msg.content)) return;
4133
4724
  let state = this.recentActivityState.get(sessionId);
4134
4725
  if (!state) {
4135
- state = { history: [], currentMessageId: null, currentEntries: [] };
4726
+ state = { history: [], currentMessageId: null, currentEntries: [], accumulatedText: "", countedToolIds: /* @__PURE__ */ new Set() };
4136
4727
  this.recentActivityState.set(sessionId, state);
4137
4728
  }
4138
4729
  if (state.currentMessageId !== msg.id) {
@@ -4142,16 +4733,25 @@ var NotificationService = class {
4142
4733
  }
4143
4734
  state.currentEntries = [];
4144
4735
  state.currentMessageId = msg.id;
4736
+ state.accumulatedText = "";
4737
+ state.countedToolIds = /* @__PURE__ */ new Set();
4738
+ }
4739
+ for (const block of msg.content) {
4740
+ if (block.type === "text" && typeof block.text === "string") {
4741
+ state.accumulatedText += block.text;
4742
+ }
4145
4743
  }
4146
4744
  const next = [];
4745
+ const accText = this.summarizeText(state.accumulatedText);
4746
+ if (accText.length >= 4) next.push(accText);
4147
4747
  for (const block of msg.content) {
4148
- if (block.type === "text") {
4149
- const line = this.summarizeText(block.text);
4150
- if (line.length >= 4) next.push(line);
4151
- } else if (block.type === "tool_use") {
4748
+ if (block.type === "tool_use") {
4152
4749
  const line = this.summarizeToolCall(block.name, block.input ?? {});
4153
4750
  if (line) next.push(line);
4154
- this.incrementCounter(sessionId, block.name);
4751
+ if (!state.countedToolIds.has(block.id)) {
4752
+ state.countedToolIds.add(block.id);
4753
+ this.incrementCounter(sessionId, block.name);
4754
+ }
4155
4755
  }
4156
4756
  }
4157
4757
  state.currentEntries = next;
@@ -4267,7 +4867,7 @@ var NotificationService = class {
4267
4867
  return;
4268
4868
  }
4269
4869
  if (session.status === "running" || session.status === "waiting_approval" || session.status === "waiting_question") {
4270
- this.scheduleActivityPush(sessionId);
4870
+ this.scheduleActivityPush(sessionId, true);
4271
4871
  }
4272
4872
  }, ACTIVITY_PUSH_THROTTLE_MS);
4273
4873
  this.laHeartbeatTimers.set(sessionId, timer);
@@ -4339,17 +4939,38 @@ var NotificationService = class {
4339
4939
  });
4340
4940
  }
4341
4941
  this.stopLaHeartbeat(sessionId);
4342
- this.recentActivityState.delete(sessionId);
4942
+ this.clearSessionActivityState(sessionId);
4343
4943
  this.lastActivityPushAt.delete(sessionId);
4344
- this.activityCounters.delete(sessionId);
4944
+ this.lastPushedFingerprint.delete(sessionId);
4345
4945
  console.log(`[NotificationService] \u{1F3C1} LA end (${reason}) session=${sessionId.slice(0, 8)}\u2026`);
4346
4946
  }
4947
+ /**
4948
+ * 计算内容指纹:status + recentActivity + latestApproval。
4949
+ * 用于判断 LA 内容是否实际变化,避免重复推送消耗 APNs budget。
4950
+ */
4951
+ computeContentFingerprint(sessionId) {
4952
+ const activity = this.getRecentActivity(sessionId);
4953
+ const session = this.sessionManager.getActiveSessions().find((s) => s.id === sessionId);
4954
+ const approvals = this.pendingApprovalsProvider?.(sessionId) ?? [];
4955
+ const latestApproval = approvals[approvals.length - 1];
4956
+ return `${session?.status ?? ""}|${activity.join(" ")}|${latestApproval?.id ?? ""}`;
4957
+ }
4347
4958
  /** 真正发送一次 LA content push(无 alert) */
4348
4959
  flushActivityPush(sessionId) {
4349
4960
  const channel = this.activityPushChannel;
4350
4961
  if (!channel?.hasToken(sessionId)) return;
4351
4962
  const session = this.sessionManager.getActiveSessions().find((s) => s.id === sessionId);
4352
4963
  if (!session) return;
4964
+ const fingerprint = this.computeContentFingerprint(sessionId);
4965
+ const lastFingerprint = this.lastPushedFingerprint.get(sessionId);
4966
+ const contentChanged = fingerprint !== lastFingerprint;
4967
+ if (!contentChanged) {
4968
+ const lastPush = this.lastActivityPushAt.get(sessionId) ?? 0;
4969
+ if (Date.now() - lastPush < _NotificationService.STATS_REFRESH_INTERVAL_MS) {
4970
+ return;
4971
+ }
4972
+ }
4973
+ this.lastPushedFingerprint.set(sessionId, fingerprint);
4353
4974
  const recentActivity = this.getRecentActivity(sessionId);
4354
4975
  const latestMessage = recentActivity[recentActivity.length - 1] ?? this.latestAssistantText.get(sessionId) ?? "";
4355
4976
  const sessionTitle = this.getSessionTitle(sessionId);
@@ -4378,13 +4999,14 @@ var NotificationService = class {
4378
4999
  };
4379
5000
  }
4380
5001
  contentState.stats = this.buildStatsPayload(session);
4381
- const priority = this.pendingPriority.get(sessionId) ?? "5";
5002
+ const explicitPriority = this.pendingPriority.get(sessionId);
5003
+ const priority = explicitPriority ?? (contentChanged ? "10" : "5");
4382
5004
  this.pendingPriority.delete(sessionId);
4383
5005
  this.lastActivityPushAt.set(sessionId, Date.now());
4384
5006
  const lineCount = recentActivity.length;
4385
5007
  channel.updateActivity(sessionId, contentState, { priority }).then((ok) => {
4386
5008
  if (ok) {
4387
- console.log(`[NotificationService] \u{1F4E1} LA push \u2713 session=${sessionId.slice(0, 8)}\u2026 status=${status} p=${priority} lines=${lineCount}`);
5009
+ console.log(`[NotificationService] \u{1F4E1} LA push \u2713 session=${sessionId.slice(0, 8)}\u2026 status=${status} p=${priority} changed=${contentChanged} lines=${lineCount}`);
4388
5010
  }
4389
5011
  }).catch((err) => {
4390
5012
  console.warn(`[NotificationService] \u{1F4E1} LA push \u2717 session=${sessionId.slice(0, 8)}\u2026:`, err instanceof Error ? err.message : err);
@@ -4513,6 +5135,8 @@ var DesktopNotificationChannel = class {
4513
5135
 
4514
5136
  // src/notification/ExpoNotificationChannel.ts
4515
5137
  var EXPO_PUSH_API = "https://exp.host/--/api/v2/push/send";
5138
+ var EXPO_RECEIPT_API = "https://exp.host/--/api/v2/push/getReceipts";
5139
+ var RECEIPT_CHECK_DELAY_MS = 1e4;
4516
5140
  var ExpoNotificationChannel = class {
4517
5141
  tokens = /* @__PURE__ */ new Set();
4518
5142
  /** push token → WebSocket 连接映射,用于前台抑制 */
@@ -4560,6 +5184,7 @@ var ExpoNotificationChannel = class {
4560
5184
  if (prefs) {
4561
5185
  const notifType = payload.data?.type ?? "";
4562
5186
  if (notifType === "approval_request" && prefs.approval) sound = prefs.approval;
5187
+ else if (notifType === "question_request" && prefs.approval) sound = prefs.approval;
4563
5188
  else if (notifType === "task_complete" && prefs.taskComplete) sound = prefs.taskComplete;
4564
5189
  else if (notifType === "task_error" && prefs.taskError) sound = prefs.taskError;
4565
5190
  }
@@ -4590,6 +5215,7 @@ var ExpoNotificationChannel = class {
4590
5215
  console.warn(`[ExpoNotificationChannel] ${t("notification.pushApiFormatError")}`, JSON.stringify(body));
4591
5216
  return;
4592
5217
  }
5218
+ const receiptIdToToken = /* @__PURE__ */ new Map();
4593
5219
  for (let i = 0; i < body.data.length; i++) {
4594
5220
  const ticket = body.data[i];
4595
5221
  if (ticket.status === "error") {
@@ -4602,13 +5228,63 @@ var ExpoNotificationChannel = class {
4602
5228
  this.soundPreferences.delete(staleToken);
4603
5229
  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`);
4604
5230
  }
5231
+ } else if (ticket.status === "ok" && typeof ticket.id === "string" && targetTokens[i]) {
5232
+ receiptIdToToken.set(ticket.id, targetTokens[i]);
4605
5233
  }
4606
5234
  }
5235
+ this.scheduleReceiptCheck(receiptIdToToken);
4607
5236
  }
4608
5237
  } catch (err) {
4609
5238
  console.warn(`[ExpoNotificationChannel] ${t("notification.sendFailed")}`, err);
4610
5239
  }
4611
5240
  }
5241
+ /**
5242
+ * Expo push 二阶段:延迟查 receipt,暴露 ticket 阶段看不到的 APNs 投递失败。
5243
+ *
5244
+ * 关键诊断点:InvalidCredentials / MismatchSenderId 表示 Expo 项目的 APNs
5245
+ * 凭证配置问题(不是用户机器问题)——这正是"只有开发者能收到推送"的根因,
5246
+ * 且 ticket 全为 ok、不查 receipt 永远静默。
5247
+ */
5248
+ scheduleReceiptCheck(receiptIdToToken) {
5249
+ if (receiptIdToToken.size === 0) return;
5250
+ const timer = setTimeout(async () => {
5251
+ try {
5252
+ const ids = Array.from(receiptIdToToken.keys());
5253
+ const res = await fetch(EXPO_RECEIPT_API, {
5254
+ method: "POST",
5255
+ headers: { "Content-Type": "application/json", Accept: "application/json" },
5256
+ body: JSON.stringify({ ids })
5257
+ });
5258
+ const body = await res.json();
5259
+ if (!res.ok || !body?.data || typeof body.data !== "object") {
5260
+ console.warn("[ExpoNotificationChannel] \u26A0\uFE0F push receipt \u67E5\u8BE2\u5931\u8D25", res.status, JSON.stringify(body));
5261
+ return;
5262
+ }
5263
+ const receipts = body.data;
5264
+ for (const [receiptId, receipt] of Object.entries(receipts)) {
5265
+ if (receipt?.status !== "error") continue;
5266
+ const errorCode = receipt.details?.error ?? "unknown";
5267
+ const token = receiptIdToToken.get(receiptId);
5268
+ console.error(
5269
+ `[ExpoNotificationChannel] \u274C APNs \u6295\u9012\u5931\u8D25 receipt=${receiptId} error=${errorCode}` + (receipt.message ? ` \u2014 ${receipt.message}` : "")
5270
+ );
5271
+ if (errorCode === "DeviceNotRegistered" && token) {
5272
+ this.tokens.delete(token);
5273
+ this.tokenWsMap.delete(token);
5274
+ this.soundPreferences.delete(token);
5275
+ console.warn("[ExpoNotificationChannel] \u26A0\uFE0F \u5DF2\u79FB\u9664\u5931\u6548 token\uFF08receipt DeviceNotRegistered\uFF09\u3002\u91CD\u542F App \u53EF\u91CD\u65B0\u6CE8\u518C\u3002");
5276
+ } else if (errorCode === "InvalidCredentials" || errorCode === "MismatchSenderId") {
5277
+ console.error(
5278
+ "[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"
5279
+ );
5280
+ }
5281
+ }
5282
+ } catch (err) {
5283
+ console.warn("[ExpoNotificationChannel] \u26A0\uFE0F push receipt \u67E5\u8BE2\u5F02\u5E38:", err);
5284
+ }
5285
+ }, RECEIPT_CHECK_DELAY_MS);
5286
+ timer.unref?.();
5287
+ }
4612
5288
  };
4613
5289
 
4614
5290
  // src/notification/ActivityPushChannel.ts
@@ -4903,470 +5579,134 @@ var ActivityPushChannel = class {
4903
5579
  const reason = err instanceof ApnsError ? JSON.parse(err.responseBody || "{}")?.reason ?? err.statusCode : String(err);
4904
5580
  console.log(`[ActivityPushChannel] \u{1F50D} probe ${env} failed: ${reason}`);
4905
5581
  if (isProviderTokenError(err)) {
4906
- console.error(
4907
- `[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`
4908
- );
4909
- throw err;
4910
- }
4911
- if (!isBadDeviceTokenError(err)) {
4912
- throw err;
4913
- }
4914
- }
4915
- }
4916
- this.deadTokens.add(deviceToken);
4917
- for (const [sid, tok] of this.tokens) {
4918
- if (tok === deviceToken) {
4919
- this.tokens.delete(sid);
4920
- console.warn(`[ActivityPushChannel] \u2620\uFE0F dead token, session removed: session=${sid.slice(0, 8)}\u2026 token=${short}\u2026 (Activity ended/iOS reinstalled)`);
4921
- break;
4922
- }
4923
- }
4924
- throw lastErr ?? new Error("APNs send failed: all environments rejected token");
4925
- }
4926
- /** 单次 APNs HTTP/2 请求(内部 helper,不做探测) */
4927
- async sendToAPNsOnce(deviceToken, payload, opts, env) {
4928
- const topic = opts.topic ?? `${this.bundleId}.push-type.liveactivity`;
4929
- const pushType = opts.pushType ?? "liveactivity";
4930
- const jwt = this.getJWT();
4931
- const payloadStr = JSON.stringify(payload);
4932
- const priority = opts.priority ?? "10";
4933
- return new Promise((resolve, reject) => {
4934
- let client;
4935
- try {
4936
- client = this.getHttp2Client(env);
4937
- } catch (err) {
4938
- return reject(err);
4939
- }
4940
- const headers = {
4941
- ":method": "POST",
4942
- ":path": `/3/device/${deviceToken}`,
4943
- "authorization": `bearer ${jwt}`,
4944
- "apns-topic": topic,
4945
- "apns-push-type": pushType,
4946
- "apns-priority": priority,
4947
- "apns-expiration": String(Math.floor(Date.now() / 1e3) + 300),
4948
- "content-type": "application/json",
4949
- "content-length": Buffer.byteLength(payloadStr)
4950
- };
4951
- if (opts.collapseId) {
4952
- headers["apns-collapse-id"] = opts.collapseId;
4953
- }
4954
- const req = client.request(headers);
4955
- let statusCode = 0;
4956
- let responseData = "";
4957
- req.on("response", (headers2) => {
4958
- statusCode = Number(headers2[":status"] ?? 0);
4959
- });
4960
- req.on("data", (chunk) => {
4961
- responseData += chunk;
4962
- });
4963
- req.on("end", () => {
4964
- if (statusCode === 200) {
4965
- resolve();
4966
- } else {
4967
- if (statusCode === 0) {
4968
- const c = this.http2Clients[env];
4969
- c?.destroy();
4970
- delete this.http2Clients[env];
4971
- }
4972
- reject(new ApnsError(statusCode, responseData));
4973
- }
4974
- });
4975
- req.on("error", (err) => {
4976
- reject(err);
4977
- });
4978
- req.write(payloadStr);
4979
- req.end();
4980
- });
4981
- }
4982
- /** 生成或获取缓存的 APNs JWT token */
4983
- getJWT() {
4984
- const now = Math.floor(Date.now() / 1e3);
4985
- if (this.cachedJwt && this.cachedJwt.expiresAt > now) {
4986
- return this.cachedJwt.token;
4987
- }
4988
- const header = Buffer.from(JSON.stringify({
4989
- alg: "ES256",
4990
- kid: this.keyId
4991
- })).toString("base64url");
4992
- const claims = Buffer.from(JSON.stringify({
4993
- iss: this.teamId,
4994
- iat: now
4995
- })).toString("base64url");
4996
- const signingInput = `${header}.${claims}`;
4997
- const sign = crypto.createSign("SHA256");
4998
- sign.update(signingInput);
4999
- const signature = sign.sign({ key: this.authKey, dsaEncoding: "ieee-p1363" }, "base64url");
5000
- const token = `${signingInput}.${signature}`;
5001
- this.cachedJwt = { token, expiresAt: now + 3e3 };
5002
- return token;
5003
- }
5004
- };
5005
- var ApnsError = class extends Error {
5006
- constructor(statusCode, responseBody) {
5007
- super(`APNs returned ${statusCode}: ${responseBody}`);
5008
- this.statusCode = statusCode;
5009
- this.responseBody = responseBody;
5010
- this.name = "ApnsError";
5011
- }
5012
- };
5013
- function isProviderTokenError(err) {
5014
- if (!(err instanceof ApnsError)) return false;
5015
- if (err.statusCode !== 403) return false;
5016
- try {
5017
- const parsed = JSON.parse(err.responseBody);
5018
- return parsed.reason === "InvalidProviderToken" || parsed.reason === "ExpiredProviderToken";
5019
- } catch {
5020
- return false;
5021
- }
5022
- }
5023
- function isBadDeviceTokenError(err) {
5024
- if (!(err instanceof ApnsError)) return false;
5025
- if (err.statusCode !== 400 && err.statusCode !== 403 && err.statusCode !== 410) return false;
5026
- try {
5027
- const parsed = JSON.parse(err.responseBody);
5028
- return parsed.reason === "BadDeviceToken" || parsed.reason === "BadEnvironmentKeyInToken" || parsed.reason === "Unregistered";
5029
- } catch {
5030
- return false;
5031
- }
5032
- }
5033
-
5034
- // src/session/ProjectReader.ts
5035
- var import_promises3 = require("fs/promises");
5036
- var import_readline3 = require("readline");
5037
- var import_path2 = require("path");
5038
- var import_os2 = require("os");
5039
- var CLAUDE_PROJECTS_DIR = (0, import_path2.join)((0, import_os2.homedir)(), ".claude", "projects");
5040
- function getSessionFilePath(projectPath, sessionId) {
5041
- return (0, import_path2.join)(CLAUDE_PROJECTS_DIR, encodeDirName(projectPath), `${sessionId}.jsonl`);
5042
- }
5043
- async function getProjects() {
5044
- try {
5045
- const dirExists = await directoryExists(CLAUDE_PROJECTS_DIR);
5046
- if (!dirExists) {
5047
- return { ok: true, value: [] };
5048
- }
5049
- const entries = await (0, import_promises3.readdir)(CLAUDE_PROJECTS_DIR, { withFileTypes: true });
5050
- const projects = [];
5051
- for (const entry of entries) {
5052
- if (!entry.isDirectory() || entry.name.startsWith(".")) {
5053
- continue;
5054
- }
5055
- const encodedPath = entry.name;
5056
- const decodedPath = decodeDirName(encodedPath);
5057
- const name = decodedPath.split("/").filter(Boolean).pop() ?? encodedPath;
5058
- const projectDir = (0, import_path2.join)(CLAUDE_PROJECTS_DIR, encodedPath);
5059
- const { count: sessionCount, latestMtime } = await countJsonlFilesWithMtime(projectDir);
5060
- projects.push({
5061
- id: encodedPath,
5062
- path: decodedPath,
5063
- name,
5064
- sessionCount,
5065
- lastActiveAt: latestMtime
5066
- });
5067
- }
5068
- projects.sort((a, b) => a.name.localeCompare(b.name));
5069
- return { ok: true, value: projects };
5070
- } catch (err) {
5071
- return {
5072
- ok: false,
5073
- error: err instanceof Error ? err : new Error(String(err))
5074
- };
5075
- }
5076
- }
5077
- async function getHistoricalSessions(projectPath) {
5078
- try {
5079
- const encodedPath = encodeDirName(projectPath);
5080
- const projectDir = (0, import_path2.join)(CLAUDE_PROJECTS_DIR, encodedPath);
5081
- const dirExists = await directoryExists(projectDir);
5082
- if (!dirExists) {
5083
- return { ok: true, value: [] };
5084
- }
5085
- const entries = await (0, import_promises3.readdir)(projectDir, { withFileTypes: true });
5086
- const jsonlFiles = entries.filter((e) => e.isFile() && e.name.endsWith(".jsonl"));
5087
- const mtimeMap = /* @__PURE__ */ new Map();
5088
- await Promise.all(
5089
- jsonlFiles.map(async (entry) => {
5090
- const sessionId = entry.name.slice(0, -6);
5091
- const filePath = (0, import_path2.join)(projectDir, entry.name);
5092
- try {
5093
- const contentTs = await extractLastTimestamp(filePath);
5094
- if (contentTs) {
5095
- mtimeMap.set(sessionId, contentTs);
5096
- } else {
5097
- const fileStat = await (0, import_promises3.stat)(filePath);
5098
- mtimeMap.set(sessionId, fileStat.mtimeMs);
5099
- }
5100
- } catch {
5101
- mtimeMap.set(sessionId, 0);
5102
- }
5103
- })
5104
- );
5105
- const uuidDirs = entries.filter(
5106
- (e) => e.isDirectory() && UUID_RE.test(e.name) && !mtimeMap.has(e.name)
5107
- );
5108
- for (const entry of uuidDirs) {
5109
- try {
5110
- const fileStat = await (0, import_promises3.stat)((0, import_path2.join)(projectDir, entry.name));
5111
- mtimeMap.set(entry.name, fileStat.mtimeMs);
5112
- } catch {
5113
- mtimeMap.set(entry.name, 0);
5114
- }
5115
- }
5116
- const indexPath = (0, import_path2.join)(projectDir, "sessions-index.json");
5117
- const sessionMap = /* @__PURE__ */ new Map();
5118
- try {
5119
- const indexContent = await (0, import_promises3.readFile)(indexPath, "utf-8");
5120
- const indexData = JSON.parse(indexContent);
5121
- if (indexData.version === 1 && Array.isArray(indexData.entries)) {
5122
- for (const entry of indexData.entries) {
5123
- const mtime = mtimeMap.get(entry.sessionId) ?? entry.fileMtime ?? (entry.modified ? new Date(entry.modified).getTime() : 0);
5124
- sessionMap.set(entry.sessionId, {
5125
- sessionId: entry.sessionId,
5126
- lastModified: mtime,
5127
- summary: entry.summary,
5128
- firstPrompt: entry.firstPrompt,
5129
- messageCount: entry.messageCount
5130
- });
5131
- }
5132
- await Promise.all(
5133
- Array.from(sessionMap.values()).filter((s) => (s.messageCount ?? 0) > 0 && !s.summary && !s.firstPrompt).map(async (s) => {
5134
- const filePath = (0, import_path2.join)(projectDir, `${s.sessionId}.jsonl`);
5135
- const firstPrompt = await extractFirstPrompt(filePath).catch(() => void 0);
5136
- if (firstPrompt) s.firstPrompt = firstPrompt;
5137
- })
5138
- );
5139
- }
5140
- } catch {
5141
- }
5142
- const uuidDirSet = new Set(uuidDirs.map((e) => e.name));
5143
- for (const [sessionId, mtime] of mtimeMap) {
5144
- if (!sessionMap.has(sessionId)) {
5145
- if (uuidDirSet.has(sessionId)) {
5146
- sessionMap.set(sessionId, { sessionId, lastModified: mtime, messageCount: -1 });
5147
- } else {
5148
- const filePath = (0, import_path2.join)(projectDir, `${sessionId}.jsonl`);
5149
- const firstPrompt = await extractFirstPrompt(filePath).catch(() => void 0);
5150
- sessionMap.set(sessionId, { sessionId, lastModified: mtime, firstPrompt });
5151
- }
5152
- }
5153
- }
5154
- const sessions = Array.from(sessionMap.values()).filter((s) => {
5155
- if (s.messageCount === 0) return false;
5156
- if (s.messageCount === -1) return true;
5157
- if (s.firstPrompt === void 0 && s.messageCount === void 0) return false;
5158
- return true;
5159
- });
5160
- sessions.sort((a, b) => b.lastModified - a.lastModified);
5161
- return { ok: true, value: sessions };
5162
- } catch (err) {
5163
- return {
5164
- ok: false,
5165
- error: err instanceof Error ? err : new Error(String(err))
5166
- };
5167
- }
5168
- }
5169
- async function getSessionHistory(projectPath, sessionId) {
5170
- try {
5171
- const encodedPath = encodeDirName(projectPath);
5172
- const filePath = (0, import_path2.join)(CLAUDE_PROJECTS_DIR, encodedPath, `${sessionId}.jsonl`);
5173
- const raw = await (0, import_promises3.readFile)(filePath, "utf-8").catch((err) => {
5174
- if (err.code === "ENOENT") return null;
5175
- throw err;
5176
- });
5177
- if (raw === null) return { ok: true, value: [] };
5178
- const lines = raw.split("\n").filter((l) => l.trim());
5179
- const events = [];
5180
- for (const line of lines) {
5181
- try {
5182
- const obj = JSON.parse(line);
5183
- const type = obj.type;
5184
- if (type === "user" && obj.message) {
5185
- const msgContent = obj.message.content;
5186
- if (typeof msgContent === "string") {
5187
- if (msgContent.includes("<local-command") || msgContent.includes("<command-name>")) continue;
5188
- } else if (Array.isArray(msgContent)) {
5189
- const hasText = msgContent.some(
5190
- (b) => b.type === "text" && !b.text?.includes("<local-command") && !b.text?.includes("<command-name>")
5191
- );
5192
- if (!hasText) continue;
5193
- }
5194
- const normalizedContent = typeof msgContent === "string" ? [{ type: "text", text: msgContent }] : Array.isArray(msgContent) ? msgContent.filter((b) => b.type === "text" && typeof b.text === "string") : [];
5195
- if (normalizedContent.length === 0) continue;
5196
- events.push({
5197
- type: "user",
5198
- message: {
5199
- ...obj.message,
5200
- content: normalizedContent
5201
- },
5202
- session_id: sessionId
5203
- });
5204
- } else if (type === "assistant" && obj.message) {
5205
- const content = (obj.message.content ?? []).filter(
5206
- (b) => b.type === "text" || b.type === "tool_use" || b.type === "thinking"
5207
- );
5208
- if (content.length === 0) continue;
5209
- events.push({
5210
- type: "assistant",
5211
- message: {
5212
- id: obj.message.id ?? obj.uuid ?? `hist-${events.length}`,
5213
- model: obj.message.model ?? "unknown",
5214
- role: "assistant",
5215
- content,
5216
- stop_reason: obj.message.stop_reason,
5217
- usage: obj.message.usage
5218
- },
5219
- session_id: sessionId
5220
- });
5582
+ console.error(
5583
+ `[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`
5584
+ );
5585
+ throw err;
5221
5586
  }
5222
- } catch {
5223
- }
5224
- }
5225
- if (events.length > 0) {
5226
- let totalInputTokens = 0;
5227
- let totalOutputTokens = 0;
5228
- for (const ev of events) {
5229
- if (ev.type === "assistant" && ev.message.usage) {
5230
- totalInputTokens += ev.message.usage.input_tokens ?? 0;
5231
- totalOutputTokens += ev.message.usage.output_tokens ?? 0;
5587
+ if (!isBadDeviceTokenError(err)) {
5588
+ throw err;
5232
5589
  }
5233
5590
  }
5234
- if (totalInputTokens > 0 || totalOutputTokens > 0) {
5235
- events.push({
5236
- type: "result",
5237
- subtype: "success",
5238
- is_error: false,
5239
- duration_ms: 0,
5240
- num_turns: events.filter((e) => e.type === "user").length,
5241
- result: "",
5242
- session_id: sessionId,
5243
- usage: { input_tokens: totalInputTokens, output_tokens: totalOutputTokens }
5244
- });
5245
- }
5246
5591
  }
5247
- return { ok: true, value: events };
5248
- } catch (err) {
5249
- return {
5250
- ok: false,
5251
- error: err instanceof Error ? err : new Error(String(err))
5252
- };
5253
- }
5254
- }
5255
- async function extractLastTimestamp(filePath) {
5256
- let fileHandle;
5257
- try {
5258
- fileHandle = await (0, import_promises3.open)(filePath, "r");
5259
- const fileStat = await fileHandle.stat();
5260
- const readSize = Math.min(fileStat.size, 8192);
5261
- const buffer = Buffer.alloc(readSize);
5262
- await fileHandle.read(buffer, 0, readSize, fileStat.size - readSize);
5263
- const tail = buffer.toString("utf-8");
5264
- const lines = tail.split("\n").filter((l) => l.trim());
5265
- for (let i = lines.length - 1; i >= 0; i--) {
5266
- try {
5267
- const obj = JSON.parse(lines[i]);
5268
- if (obj.timestamp) {
5269
- const ts = new Date(obj.timestamp).getTime();
5270
- if (!isNaN(ts)) return ts;
5271
- }
5272
- } catch {
5592
+ this.deadTokens.add(deviceToken);
5593
+ for (const [sid, tok] of this.tokens) {
5594
+ if (tok === deviceToken) {
5595
+ this.tokens.delete(sid);
5596
+ console.warn(`[ActivityPushChannel] \u2620\uFE0F dead token, session removed: session=${sid.slice(0, 8)}\u2026 token=${short}\u2026 (Activity ended/iOS reinstalled)`);
5597
+ break;
5273
5598
  }
5274
5599
  }
5275
- } catch {
5276
- } finally {
5277
- await fileHandle?.close();
5600
+ throw lastErr ?? new Error("APNs send failed: all environments rejected token");
5278
5601
  }
5279
- return void 0;
5280
- }
5281
- async function extractFirstPrompt(filePath) {
5282
- let fileHandle;
5283
- try {
5284
- fileHandle = await (0, import_promises3.open)(filePath, "r");
5285
- const rl = (0, import_readline3.createInterface)({
5286
- input: fileHandle.createReadStream({ encoding: "utf-8" }),
5287
- crlfDelay: Infinity
5288
- });
5289
- let lineCount = 0;
5290
- for await (const line of rl) {
5291
- if (++lineCount > 20) break;
5292
- if (!line.trim()) continue;
5602
+ /** 单次 APNs HTTP/2 请求(内部 helper,不做探测) */
5603
+ async sendToAPNsOnce(deviceToken, payload, opts, env) {
5604
+ const topic = opts.topic ?? `${this.bundleId}.push-type.liveactivity`;
5605
+ const pushType = opts.pushType ?? "liveactivity";
5606
+ const jwt = this.getJWT();
5607
+ const payloadStr = JSON.stringify(payload);
5608
+ const priority = opts.priority ?? "10";
5609
+ return new Promise((resolve, reject) => {
5610
+ let client;
5293
5611
  try {
5294
- const obj = JSON.parse(line);
5295
- if (obj.type === "user" && obj.message) {
5296
- const msgContent = obj.message.content;
5297
- let text = "";
5298
- if (typeof msgContent === "string") {
5299
- text = msgContent;
5300
- } else if (Array.isArray(msgContent)) {
5301
- const textBlock = msgContent.find((b) => b.type === "text" && typeof b.text === "string");
5302
- text = textBlock?.text ?? "";
5303
- }
5304
- if (text && !text.includes("<local-command") && !text.includes("<command-name>")) {
5305
- text = text.replace(/<ide_opened_file>[\s\S]*?<\/ide_opened_file>/gi, "");
5306
- text = text.replace(/<[^>]+>/g, "").trim();
5307
- rl.close();
5308
- return text.length > 80 ? text.slice(0, 80) + "..." : text;
5612
+ client = this.getHttp2Client(env);
5613
+ } catch (err) {
5614
+ return reject(err);
5615
+ }
5616
+ const headers = {
5617
+ ":method": "POST",
5618
+ ":path": `/3/device/${deviceToken}`,
5619
+ "authorization": `bearer ${jwt}`,
5620
+ "apns-topic": topic,
5621
+ "apns-push-type": pushType,
5622
+ "apns-priority": priority,
5623
+ "apns-expiration": String(Math.floor(Date.now() / 1e3) + 300),
5624
+ "content-type": "application/json",
5625
+ "content-length": Buffer.byteLength(payloadStr)
5626
+ };
5627
+ if (opts.collapseId) {
5628
+ headers["apns-collapse-id"] = opts.collapseId;
5629
+ }
5630
+ const req = client.request(headers);
5631
+ req.setTimeout(1e4, () => {
5632
+ req.close(http2.constants.NGHTTP2_CANCEL);
5633
+ });
5634
+ let statusCode = 0;
5635
+ let responseData = "";
5636
+ req.on("response", (headers2) => {
5637
+ statusCode = Number(headers2[":status"] ?? 0);
5638
+ });
5639
+ req.on("data", (chunk) => {
5640
+ responseData += chunk;
5641
+ });
5642
+ req.on("end", () => {
5643
+ if (statusCode === 200) {
5644
+ resolve();
5645
+ } else {
5646
+ if (statusCode === 0) {
5647
+ const c = this.http2Clients[env];
5648
+ c?.destroy();
5649
+ delete this.http2Clients[env];
5309
5650
  }
5651
+ reject(new ApnsError(statusCode, responseData));
5310
5652
  }
5311
- } catch {
5312
- }
5653
+ });
5654
+ req.on("error", (err) => {
5655
+ reject(err);
5656
+ });
5657
+ req.write(payloadStr);
5658
+ req.end();
5659
+ });
5660
+ }
5661
+ /** 生成或获取缓存的 APNs JWT token */
5662
+ getJWT() {
5663
+ const now = Math.floor(Date.now() / 1e3);
5664
+ if (this.cachedJwt && this.cachedJwt.expiresAt > now) {
5665
+ return this.cachedJwt.token;
5313
5666
  }
5314
- } catch {
5315
- } finally {
5316
- await fileHandle?.close();
5667
+ const header = Buffer.from(JSON.stringify({
5668
+ alg: "ES256",
5669
+ kid: this.keyId
5670
+ })).toString("base64url");
5671
+ const claims = Buffer.from(JSON.stringify({
5672
+ iss: this.teamId,
5673
+ iat: now
5674
+ })).toString("base64url");
5675
+ const signingInput = `${header}.${claims}`;
5676
+ const sign = crypto.createSign("SHA256");
5677
+ sign.update(signingInput);
5678
+ const signature = sign.sign({ key: this.authKey, dsaEncoding: "ieee-p1363" }, "base64url");
5679
+ const token = `${signingInput}.${signature}`;
5680
+ this.cachedJwt = { token, expiresAt: now + 3e3 };
5681
+ return token;
5317
5682
  }
5318
- return void 0;
5319
- }
5320
- function decodeDirName(dirName) {
5321
- const placeholder = "\0";
5322
- const escaped = dirName.replace(/--/g, placeholder);
5323
- const decoded = escaped.replace(/-/g, "/");
5324
- return decoded.replace(new RegExp(placeholder, "g"), "-");
5325
- }
5326
- function encodeDirName(path2) {
5327
- const escaped = path2.replace(/-/g, "--");
5328
- return escaped.replace(/\//g, "-");
5329
- }
5330
- async function directoryExists(dirPath) {
5683
+ };
5684
+ var ApnsError = class extends Error {
5685
+ constructor(statusCode, responseBody) {
5686
+ super(`APNs returned ${statusCode}: ${responseBody}`);
5687
+ this.statusCode = statusCode;
5688
+ this.responseBody = responseBody;
5689
+ this.name = "ApnsError";
5690
+ }
5691
+ };
5692
+ function isProviderTokenError(err) {
5693
+ if (!(err instanceof ApnsError)) return false;
5694
+ if (err.statusCode !== 403) return false;
5331
5695
  try {
5332
- const s = await (0, import_promises3.stat)(dirPath);
5333
- return s.isDirectory();
5696
+ const parsed = JSON.parse(err.responseBody);
5697
+ return parsed.reason === "InvalidProviderToken" || parsed.reason === "ExpiredProviderToken";
5334
5698
  } catch {
5335
5699
  return false;
5336
5700
  }
5337
5701
  }
5338
- var UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
5339
- async function countJsonlFilesWithMtime(dirPath) {
5702
+ function isBadDeviceTokenError(err) {
5703
+ if (!(err instanceof ApnsError)) return false;
5704
+ if (err.statusCode !== 400 && err.statusCode !== 403 && err.statusCode !== 410) return false;
5340
5705
  try {
5341
- const entries = await (0, import_promises3.readdir)(dirPath, { withFileTypes: true });
5342
- const jsonlNames = new Set(
5343
- entries.filter((e) => e.isFile() && e.name.endsWith(".jsonl")).map((e) => e.name.slice(0, -6))
5344
- );
5345
- const uuidDirs = entries.filter(
5346
- (e) => e.isDirectory() && UUID_RE.test(e.name) && !jsonlNames.has(e.name)
5347
- );
5348
- let latestMtime = 0;
5349
- const jsonlEntries = entries.filter((e) => e.isFile() && e.name.endsWith(".jsonl"));
5350
- await Promise.all([
5351
- ...jsonlEntries.map(async (entry) => {
5352
- try {
5353
- const contentTs = await extractLastTimestamp((0, import_path2.join)(dirPath, entry.name));
5354
- const ts = contentTs ?? (await (0, import_promises3.stat)((0, import_path2.join)(dirPath, entry.name))).mtimeMs;
5355
- if (ts > latestMtime) latestMtime = ts;
5356
- } catch {
5357
- }
5358
- }),
5359
- ...uuidDirs.map(async (entry) => {
5360
- try {
5361
- const fileStat = await (0, import_promises3.stat)((0, import_path2.join)(dirPath, entry.name));
5362
- if (fileStat.mtimeMs > latestMtime) latestMtime = fileStat.mtimeMs;
5363
- } catch {
5364
- }
5365
- })
5366
- ]);
5367
- return { count: jsonlNames.size + uuidDirs.length, latestMtime };
5706
+ const parsed = JSON.parse(err.responseBody);
5707
+ return parsed.reason === "BadDeviceToken" || parsed.reason === "BadEnvironmentKeyInToken" || parsed.reason === "Unregistered";
5368
5708
  } catch {
5369
- return { count: 0, latestMtime: 0 };
5709
+ return false;
5370
5710
  }
5371
5711
  }
5372
5712
 
@@ -5492,7 +5832,10 @@ var AuthManager = class extends import_events3.EventEmitter {
5492
5832
  email: parsed.email,
5493
5833
  authMethod: parsed.authMethod
5494
5834
  };
5495
- } catch {
5835
+ } catch (err) {
5836
+ console.warn(
5837
+ `[AuthManager] checkAuth \u5931\u8D25 (claudePath=${CLAUDE_PATH2}): ${err instanceof Error ? err.message : String(err)}`
5838
+ );
5496
5839
  return { loggedIn: false };
5497
5840
  }
5498
5841
  }
@@ -5588,7 +5931,7 @@ var AuthManager = class extends import_events3.EventEmitter {
5588
5931
 
5589
5932
  // src/terminal/TerminalExecutor.ts
5590
5933
  var import_node_child_process8 = require("child_process");
5591
- var import_uuid5 = require("uuid");
5934
+ var import_uuid4 = require("uuid");
5592
5935
  var EXEC_TIMEOUT_MS = 30 * 60 * 1e3;
5593
5936
  var TerminalExecutor = class {
5594
5937
  processes = /* @__PURE__ */ new Map();
@@ -5610,7 +5953,7 @@ var TerminalExecutor = class {
5610
5953
  }
5611
5954
  }
5612
5955
  exec(sessionId, command, cwd) {
5613
- const execId = (0, import_uuid5.v4)();
5956
+ const execId = (0, import_uuid4.v4)();
5614
5957
  const shell = isWindows ? "powershell" : process.env.SHELL || "/bin/zsh";
5615
5958
  const args = isWindows ? ["-Command", command] : ["-l", "-c", command];
5616
5959
  const proc = (0, import_node_child_process8.spawn)(shell, args, {
@@ -5687,7 +6030,7 @@ var import_node_util = require("util");
5687
6030
  var import_promises4 = require("fs/promises");
5688
6031
  var import_node_path6 = require("path");
5689
6032
  var import_node_os7 = require("os");
5690
- var import_uuid6 = require("uuid");
6033
+ var import_uuid5 = require("uuid");
5691
6034
  var execAsync = (0, import_node_util.promisify)(import_node_child_process9.exec);
5692
6035
  var BUILD_TIMEOUT_MS = 30 * 60 * 1e3;
5693
6036
  var INSTALL_TIMEOUT_MS = 5 * 60 * 1e3;
@@ -5698,7 +6041,6 @@ var SKIP_DIRS = /* @__PURE__ */ new Set([
5698
6041
  "DerivedData",
5699
6042
  "Pods",
5700
6043
  ".build",
5701
- "build",
5702
6044
  "dist",
5703
6045
  "__pycache__",
5704
6046
  ".next",
@@ -5875,7 +6217,7 @@ ${e.stderr ?? ""}`);
5875
6217
  return null;
5876
6218
  }
5877
6219
  if (override) await this.saveConfig(projectPath, override);
5878
- const buildId = (0, import_uuid6.v4)();
6220
+ const buildId = (0, import_uuid5.v4)();
5879
6221
  const args = buildArgs(config);
5880
6222
  const proc = (0, import_node_child_process9.spawn)("xcodebuild", args, {
5881
6223
  cwd: projectPath,
@@ -5939,7 +6281,7 @@ ${e.stderr ?? ""}`);
5939
6281
  this.emitInstallError(sessionId, "", "\u672A\u627E\u5230\u6784\u5EFA\u914D\u7F6E\uFF0C\u8BF7\u5148\u6784\u5EFA\u4E00\u6B21\n");
5940
6282
  return null;
5941
6283
  }
5942
- const installId = (0, import_uuid6.v4)();
6284
+ const installId = (0, import_uuid5.v4)();
5943
6285
  let appPath;
5944
6286
  try {
5945
6287
  appPath = await this.getAppPath(projectPath, config);
@@ -6510,7 +6852,7 @@ function sourceWeight(s) {
6510
6852
  // src/git/GitExecutor.ts
6511
6853
  var import_node_child_process10 = require("child_process");
6512
6854
  var import_node_util2 = require("util");
6513
- var import_uuid7 = require("uuid");
6855
+ var import_uuid6 = require("uuid");
6514
6856
  var execAsync2 = (0, import_node_util2.promisify)(import_node_child_process10.exec);
6515
6857
  var STATUS_TIMEOUT_MS = 15e3;
6516
6858
  var COMMIT_TIMEOUT_MS = 6e4;
@@ -6630,7 +6972,7 @@ var GitExecutor = class {
6630
6972
  * - 若未提供 files:默认 git add -A(提交所有变更)
6631
6973
  */
6632
6974
  async commit(sessionId, projectPath, message, files, alsoPush) {
6633
- const opId = (0, import_uuid7.v4)();
6975
+ const opId = (0, import_uuid6.v4)();
6634
6976
  this.runSequence(sessionId, opId, "commit", projectPath, [
6635
6977
  files && files.length > 0 ? ["git", "add", "--", ...files] : ["git", "add", "-A"],
6636
6978
  ["git", "commit", "-m", message]
@@ -6646,7 +6988,7 @@ var GitExecutor = class {
6646
6988
  return opId;
6647
6989
  }
6648
6990
  async push(sessionId, projectPath) {
6649
- const opId = (0, import_uuid7.v4)();
6991
+ const opId = (0, import_uuid6.v4)();
6650
6992
  this.runSequence(sessionId, opId, "push", projectPath, [
6651
6993
  ["git", "push"]
6652
6994
  ], PUSH_TIMEOUT_MS).catch((err) => {
@@ -6724,7 +7066,7 @@ var GitExecutor = class {
6724
7066
  var import_promises6 = require("fs/promises");
6725
7067
  var import_node_os8 = require("os");
6726
7068
  var import_node_path8 = require("path");
6727
- var import_uuid8 = require("uuid");
7069
+ var import_uuid7 = require("uuid");
6728
7070
  var MAX_TIMEOUT_MS = 2147483647;
6729
7071
  var ScheduledSessionManager = class {
6730
7072
  tasks = /* @__PURE__ */ new Map();
@@ -6762,7 +7104,7 @@ var ScheduledSessionManager = class {
6762
7104
  /** 注册一个定时任务(payload 由调用方校验) */
6763
7105
  schedule(scheduledAt, payload) {
6764
7106
  const task = {
6765
- id: (0, import_uuid8.v4)(),
7107
+ id: (0, import_uuid7.v4)(),
6766
7108
  scheduledAt,
6767
7109
  createdAt: Date.now(),
6768
7110
  payload
@@ -6866,10 +7208,11 @@ function isValidTask(value) {
6866
7208
  // src/utils/cliCapabilities.ts
6867
7209
  var import_node_child_process11 = require("child_process");
6868
7210
  var DEFAULT_MODELS = [
6869
- { value: "opus", label: "Opus 4.7", sublabel: "Most capable for ambitious work" },
6870
- { value: "claude-opus-4-6", label: "Opus 4.6", sublabel: "Previous generation flagship" },
6871
- { value: "sonnet", label: "Sonnet 4.6", sublabel: "Most efficient for everyday tasks" },
6872
- { value: "haiku", label: "Haiku 4.5", sublabel: "Fastest for quick answers" }
7211
+ { value: "opus", label: "Opus 4.8", sublabel: "Most capable for ambitious work", maxEffort: "max", defaultEffort: "xhigh" },
7212
+ { value: "claude-opus-4-7", label: "Opus 4.7", sublabel: "Previous generation flagship", maxEffort: "max", defaultEffort: "xhigh" },
7213
+ { value: "claude-opus-4-6", label: "Opus 4.6", sublabel: "Earlier generation flagship", maxEffort: "max", defaultEffort: "xhigh" },
7214
+ { value: "sonnet", label: "Sonnet 4.6", sublabel: "Most efficient for everyday tasks", maxEffort: "high", defaultEffort: "high" },
7215
+ { value: "haiku", label: "Haiku 4.5", sublabel: "Fastest for quick answers", maxEffort: "medium", defaultEffort: "medium" }
6873
7216
  ];
6874
7217
  var DEFAULT_CAPABILITIES = {
6875
7218
  effortLevels: ["low", "medium", "high", "xhigh", "max"],
@@ -7002,7 +7345,7 @@ async function start(opts = {}) {
7002
7345
  try {
7003
7346
  token = (await (0, import_promises7.readFile)(tokenFile, "utf8")).trim();
7004
7347
  } catch {
7005
- token = (0, import_uuid9.v4)();
7348
+ token = (0, import_uuid8.v4)();
7006
7349
  await (0, import_promises7.mkdir)(configDir, { recursive: true });
7007
7350
  await (0, import_promises7.writeFile)(tokenFile, token, "utf8");
7008
7351
  }
@@ -7037,6 +7380,7 @@ async function start(opts = {}) {
7037
7380
  const notificationService = new NotificationService(sessionManager, expoChannel);
7038
7381
  notificationService.addChannel("expo", expoChannel, opts.enableExpoPush !== false);
7039
7382
  notificationService.addChannel("mac", new DesktopNotificationChannel(), opts.enableMacNotification !== false);
7383
+ sessionManager.onSessionRemoved((sessionId) => notificationService.releaseSession(sessionId));
7040
7384
  const activityPushOpts = opts.activityPush ?? await loadApnsConfigFromFile();
7041
7385
  if (activityPushOpts) {
7042
7386
  try {
@@ -7194,6 +7538,7 @@ async function start(opts = {}) {
7194
7538
  case "kill_session": {
7195
7539
  wsBridge.broadcast({ type: "status_change", sessionId: event.sessionId, status: "idle" });
7196
7540
  approvalProxy.clearPendingForSession(event.sessionId);
7541
+ approvalProxy.clearPendingQuestionsForSession(event.sessionId);
7197
7542
  await sessionManager.killSession(event.sessionId);
7198
7543
  wsBridge.broadcast({
7199
7544
  type: "session_list",
@@ -7212,6 +7557,7 @@ async function start(opts = {}) {
7212
7557
  }
7213
7558
  case "answer_question": {
7214
7559
  sessionManager.handleQuestionResponse(event.requestId, event.answer);
7560
+ approvalProxy.resolveQuestion(event.requestId, event.answer);
7215
7561
  break;
7216
7562
  }
7217
7563
  case "subscribe": {
@@ -7641,6 +7987,9 @@ async function start(opts = {}) {
7641
7987
  decision: decision.decision
7642
7988
  });
7643
7989
  });
7990
+ approvalProxy.onQuestionResolved((requestId) => {
7991
+ sessionManager.clearPendingQuestion(requestId);
7992
+ });
7644
7993
  approvalProxy.onApprovalRequest((request) => {
7645
7994
  wsBridge.broadcast({ type: "approval_request", request });
7646
7995
  setTimeout(() => {
@@ -7657,6 +8006,9 @@ async function start(opts = {}) {
7657
8006
  notificationService.notifyApproval(request, pendingCount);
7658
8007
  }, 6e4);
7659
8008
  });
8009
+ approvalProxy.setQuestionHandler(
8010
+ (sessionId, toolUseId, questions, requestId) => sessionManager.askQuestion(sessionId, toolUseId, questions, requestId)
8011
+ );
7660
8012
  sessionManager.onEvent((event) => {
7661
8013
  if (event.type !== "question_request") return;
7662
8014
  const { request } = event;
@@ -7717,8 +8069,30 @@ async function start(opts = {}) {
7717
8069
  const idleTimeoutMs = Number(process.env.SESSIX_IDLE_TIMEOUT_MS ?? 30 * 60 * 1e3);
7718
8070
  const idleSweepIntervalMs = Number(process.env.SESSIX_IDLE_SWEEP_INTERVAL_MS ?? 5 * 60 * 1e3);
7719
8071
  const maxActiveProcesses = Number(process.env.SESSIX_MAX_ACTIVE_PROCESSES ?? 15);
8072
+ const sessionEvictMs = Number(process.env.SESSIX_SESSION_EVICT_MS ?? 2 * 60 * 60 * 1e3);
8073
+ let gcFn;
8074
+ const maybeGc = () => {
8075
+ if (gcFn === void 0) {
8076
+ gcFn = globalThis.gc ?? null;
8077
+ if (!gcFn) {
8078
+ try {
8079
+ (0, import_node_v8.setFlagsFromString)("--expose-gc");
8080
+ const fn = (0, import_node_vm.runInNewContext)("gc");
8081
+ gcFn = typeof fn === "function" ? fn : null;
8082
+ } catch {
8083
+ gcFn = null;
8084
+ }
8085
+ }
8086
+ }
8087
+ if (gcFn) {
8088
+ try {
8089
+ gcFn();
8090
+ } catch {
8091
+ }
8092
+ }
8093
+ };
7720
8094
  let idleSweepTimer = null;
7721
- if (idleSweepIntervalMs > 0 && (idleTimeoutMs > 0 || maxActiveProcesses > 0)) {
8095
+ if (idleSweepIntervalMs > 0 && (idleTimeoutMs > 0 || maxActiveProcesses > 0 || sessionEvictMs > 0)) {
7722
8096
  idleSweepTimer = setInterval(async () => {
7723
8097
  try {
7724
8098
  let totalSwept = 0;
@@ -7737,7 +8111,18 @@ async function start(opts = {}) {
7737
8111
  swept.forEach(broadcastShrink);
7738
8112
  totalSwept += swept.length;
7739
8113
  }
8114
+ if (sessionEvictMs > 0 && typeof provider.listEvictableSessions === "function") {
8115
+ const evictable = provider.listEvictableSessions(sessionEvictMs);
8116
+ for (const id of evictable) {
8117
+ await sessionManager.killSession(id);
8118
+ }
8119
+ if (evictable.length > 0) {
8120
+ console.log(`[Server] Idle GC: evicted ${evictable.length} stale session(s)`);
8121
+ totalSwept += evictable.length;
8122
+ }
8123
+ }
7740
8124
  }
8125
+ const hasRunning = sessionManager.getActiveSessions().some((s) => s.status === "running");
7741
8126
  if (totalSwept > 0) {
7742
8127
  console.log(`[Server] Idle GC: swept ${totalSwept} idle session(s)`);
7743
8128
  wsBridge.broadcast({
@@ -7745,6 +8130,9 @@ async function start(opts = {}) {
7745
8130
  sessions: sessionManager.getActiveSessions()
7746
8131
  });
7747
8132
  }
8133
+ if (totalSwept > 0 || !hasRunning) {
8134
+ maybeGc();
8135
+ }
7748
8136
  } catch (err) {
7749
8137
  console.error("[Server] Idle GC failed:", err);
7750
8138
  }
@@ -7799,7 +8187,7 @@ async function start(opts = {}) {
7799
8187
  openPairing: (duration) => pairingManager.open(duration),
7800
8188
  closePairing: () => pairingManager.close(),
7801
8189
  regenerateToken: async () => {
7802
- const newToken = (0, import_uuid9.v4)();
8190
+ const newToken = (0, import_uuid8.v4)();
7803
8191
  await (0, import_promises7.mkdir)(configDir, { recursive: true });
7804
8192
  await (0, import_promises7.writeFile)(tokenFile, newToken, "utf8");
7805
8193
  instance.token = newToken;