sessix-server 0.4.8 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (3) hide show
  1. package/dist/index.js +1273 -867
  2. package/dist/server.js +1273 -867
  3. package/package.json +1 -1
package/dist/server.js CHANGED
@@ -307,7 +307,7 @@ 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");
@@ -316,7 +316,7 @@ var import_node_util3 = require("util");
316
316
 
317
317
  // src/providers/ProcessProvider.ts
318
318
  var import_child_process = require("child_process");
319
- var import_readline = require("readline");
319
+ var import_readline2 = require("readline");
320
320
  var import_events = require("events");
321
321
  var import_node_os2 = require("os");
322
322
  var import_uuid = require("uuid");
@@ -367,12 +367,60 @@ function isNormalExit(code, signal) {
367
367
  }
368
368
 
369
369
  // src/utils/claudePath.ts
370
+ function resolveStable(candidate) {
371
+ if (!candidate) return null;
372
+ try {
373
+ const real = (0, import_node_fs.realpathSync)(candidate.trim());
374
+ (0, import_node_fs.accessSync)(real, import_node_fs.constants.X_OK);
375
+ return real;
376
+ } catch {
377
+ return null;
378
+ }
379
+ }
380
+ function resolveViaLoginShell() {
381
+ if (isWindows) return null;
382
+ const shell = process.env.SHELL || "/bin/zsh";
383
+ try {
384
+ const out = (0, import_node_child_process2.execSync)(`${shell} -ilc 'command -v claude' 2>/dev/null`, {
385
+ encoding: "utf-8",
386
+ timeout: 8e3
387
+ }).trim().split("\n").filter(Boolean).pop();
388
+ return resolveStable(out);
389
+ } catch {
390
+ return null;
391
+ }
392
+ }
393
+ function resolveViaFnm() {
394
+ if (isWindows) return null;
395
+ const base = (0, import_node_path.join)((0, import_node_os.homedir)(), ".fnm", "node-versions");
396
+ try {
397
+ const versions = (0, import_node_fs.readdirSync)(base).filter((v) => /^v?\d+\./.test(v)).sort(
398
+ (a, b) => b.localeCompare(a, void 0, { numeric: true, sensitivity: "base" })
399
+ );
400
+ for (const v of versions) {
401
+ const p = resolveStable((0, import_node_path.join)(base, v, "installation", "bin", "claude"));
402
+ if (p) return p;
403
+ }
404
+ } catch {
405
+ }
406
+ return null;
407
+ }
408
+ var cached = null;
370
409
  function findClaudePath() {
410
+ if (cached) return cached;
411
+ const override = resolveStable(process.env.SESSIX_CLAUDE_PATH);
412
+ if (override) return cached = log(override, "env:SESSIX_CLAUDE_PATH");
371
413
  try {
372
414
  const cmd = isWindows ? "where claude" : "which claude";
373
- return (0, import_node_child_process2.execSync)(cmd, { encoding: "utf-8" }).trim().split("\n")[0];
415
+ const which = (0, import_node_child_process2.execSync)(cmd, { encoding: "utf-8" }).trim().split("\n")[0];
416
+ const stable = resolveStable(which);
417
+ if (stable) return cached = log(stable, "which");
374
418
  } catch {
375
419
  }
420
+ const fnm = resolveViaFnm();
421
+ if (fnm) return cached = log(fnm, "fnm-scan");
422
+ const viaShell = resolveViaLoginShell();
423
+ if (viaShell) return cached = log(viaShell, "login-shell");
376
424
  const candidates = isWindows ? [
377
425
  (0, import_node_path.join)(process.env.LOCALAPPDATA ?? "", "Programs", "claude", "claude.exe"),
378
426
  (0, import_node_path.join)((0, import_node_os.homedir)(), "AppData", "Local", "Programs", "claude", "claude.exe"),
@@ -383,153 +431,521 @@ function findClaudePath() {
383
431
  "/opt/homebrew/bin/claude"
384
432
  ];
385
433
  for (const candidate of candidates) {
434
+ const stable = resolveStable(candidate);
435
+ if (stable) return cached = log(stable, "candidate");
436
+ }
437
+ console.warn(
438
+ "[claudePath] \u672A\u80FD\u5B9A\u4F4D claude\uFF0C\u515C\u5E95\u4F7F\u7528\u88F8 'claude'\uFF08PATH \u4E0D\u542B claude \u65F6\u4F1A\u5931\u8D25\uFF09"
439
+ );
440
+ return cached = "claude";
441
+ }
442
+ function log(path2, via) {
443
+ console.log(`[claudePath] \u89E3\u6790\u5230 claude: ${path2} (via ${via})`);
444
+ return path2;
445
+ }
446
+
447
+ // src/session/ProjectReader.ts
448
+ var import_promises = require("fs/promises");
449
+ var import_readline = require("readline");
450
+ var import_path = require("path");
451
+ var import_os = require("os");
452
+ var CLAUDE_PROJECTS_DIR = (0, import_path.join)((0, import_os.homedir)(), ".claude", "projects");
453
+ function getSessionFilePath(projectPath, sessionId) {
454
+ return (0, import_path.join)(CLAUDE_PROJECTS_DIR, encodeDirName(projectPath), `${sessionId}.jsonl`);
455
+ }
456
+ async function getSessionModel(projectPath, sessionId) {
457
+ const filePath = getSessionFilePath(projectPath, sessionId);
458
+ const raw = await (0, import_promises.readFile)(filePath, "utf-8").catch((err) => {
459
+ if (err.code === "ENOENT") return null;
460
+ throw err;
461
+ });
462
+ if (raw === null) return void 0;
463
+ const lines = raw.split("\n");
464
+ for (let i = lines.length - 1; i >= 0; i--) {
465
+ const line = lines[i].trim();
466
+ if (!line) continue;
386
467
  try {
387
- (0, import_node_fs.accessSync)(candidate, import_node_fs.constants.X_OK);
388
- return candidate;
468
+ const obj = JSON.parse(line);
469
+ if (obj.type !== "assistant" || !obj.message) continue;
470
+ const model = obj.message.model;
471
+ if (typeof model === "string" && model && model !== "unknown") {
472
+ return model;
473
+ }
389
474
  } catch {
390
475
  }
391
476
  }
392
- return "claude";
477
+ return void 0;
393
478
  }
394
-
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);
479
+ async function getProjects() {
480
+ try {
481
+ const dirExists = await directoryExists(CLAUDE_PROJECTS_DIR);
482
+ if (!dirExists) {
483
+ return { ok: true, value: [] };
415
484
  }
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)
485
+ const entries = await (0, import_promises.readdir)(CLAUDE_PROJECTS_DIR, { withFileTypes: true });
486
+ const projects = [];
487
+ for (const entry of entries) {
488
+ if (!entry.isDirectory() || entry.name.startsWith(".")) {
489
+ continue;
490
+ }
491
+ const encodedPath = entry.name;
492
+ const decodedPath = decodeDirName(encodedPath);
493
+ const name = decodedPath.split("/").filter(Boolean).pop() ?? encodedPath;
494
+ const projectDir = (0, import_path.join)(CLAUDE_PROJECTS_DIR, encodedPath);
495
+ const { count: sessionCount, latestMtime } = await countJsonlFilesWithMtime(projectDir);
496
+ projects.push({
497
+ id: encodedPath,
498
+ path: decodedPath,
499
+ name,
500
+ sessionCount,
501
+ lastActiveAt: latestMtime
502
+ });
503
+ }
504
+ projects.sort((a, b) => a.name.localeCompare(b.name));
505
+ return { ok: true, value: projects };
506
+ } catch (err) {
507
+ return {
508
+ ok: false,
509
+ error: err instanceof Error ? err : new Error(String(err))
425
510
  };
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);
444
- });
445
- this.attachStdoutListener(sessionId, proc);
446
- this.attachStderrListener(sessionId, proc);
447
- this.attachExitListener(sessionId, proc);
448
- return session;
449
511
  }
450
- /**
451
- * 终止指定会话
452
- *
453
- * kill 进程并从活跃映射中移除。
454
- */
455
- async killSession(sessionId) {
456
- const entry = this.activeSessions.get(sessionId);
457
- if (!entry) {
458
- return;
512
+ }
513
+ async function getHistoricalSessions(projectPath) {
514
+ try {
515
+ const encodedPath = encodeDirName(projectPath);
516
+ const projectDir = (0, import_path.join)(CLAUDE_PROJECTS_DIR, encodedPath);
517
+ const dirExists = await directoryExists(projectDir);
518
+ if (!dirExists) {
519
+ return { ok: true, value: [] };
459
520
  }
460
- if (entry.process.exitCode === null && entry.process.signalCode === null) {
521
+ const entries = await (0, import_promises.readdir)(projectDir, { withFileTypes: true });
522
+ const jsonlFiles = entries.filter((e) => e.isFile() && e.name.endsWith(".jsonl"));
523
+ const mtimeMap = /* @__PURE__ */ new Map();
524
+ await Promise.all(
525
+ jsonlFiles.map(async (entry) => {
526
+ const sessionId = entry.name.slice(0, -6);
527
+ const filePath = (0, import_path.join)(projectDir, entry.name);
528
+ try {
529
+ const contentTs = await extractLastTimestamp(filePath);
530
+ if (contentTs) {
531
+ mtimeMap.set(sessionId, contentTs);
532
+ } else {
533
+ const fileStat = await (0, import_promises.stat)(filePath);
534
+ mtimeMap.set(sessionId, fileStat.mtimeMs);
535
+ }
536
+ } catch {
537
+ mtimeMap.set(sessionId, 0);
538
+ }
539
+ })
540
+ );
541
+ const uuidDirs = entries.filter(
542
+ (e) => e.isDirectory() && UUID_RE.test(e.name) && !mtimeMap.has(e.name)
543
+ );
544
+ for (const entry of uuidDirs) {
461
545
  try {
462
- entry.process.stdin?.end();
546
+ const fileStat = await (0, import_promises.stat)((0, import_path.join)(projectDir, entry.name));
547
+ mtimeMap.set(entry.name, fileStat.mtimeMs);
463
548
  } catch {
549
+ mtimeMap.set(entry.name, 0);
464
550
  }
465
- await killProcessCrossPlatform(entry.process);
466
- }
467
- this.emittedQuestionToolUseIds.delete(sessionId);
468
- this.activeSessions.delete(sessionId);
469
- }
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`);
480
551
  }
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;
552
+ const indexPath = (0, import_path.join)(projectDir, "sessions-index.json");
553
+ const sessionMap = /* @__PURE__ */ new Map();
554
+ try {
555
+ const indexContent = await (0, import_promises.readFile)(indexPath, "utf-8");
556
+ const indexData = JSON.parse(indexContent);
557
+ if (indexData.version === 1 && Array.isArray(indexData.entries)) {
558
+ for (const entry of indexData.entries) {
559
+ const mtime = mtimeMap.get(entry.sessionId) ?? entry.fileMtime ?? (entry.modified ? new Date(entry.modified).getTime() : 0);
560
+ sessionMap.set(entry.sessionId, {
561
+ sessionId: entry.sessionId,
562
+ lastModified: mtime,
563
+ summary: entry.summary,
564
+ firstPrompt: entry.firstPrompt,
565
+ messageCount: entry.messageCount
566
+ });
567
+ }
568
+ await Promise.all(
569
+ Array.from(sessionMap.values()).filter((s) => (s.messageCount ?? 0) > 0 && !s.summary && !s.firstPrompt).map(async (s) => {
570
+ const filePath = (0, import_path.join)(projectDir, `${s.sessionId}.jsonl`);
571
+ const firstPrompt = await extractFirstPrompt(filePath).catch(() => void 0);
572
+ if (firstPrompt) s.firstPrompt = firstPrompt;
573
+ })
574
+ );
575
+ }
576
+ } catch {
487
577
  }
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) {
491
- try {
492
- entry.process.stdin?.end();
493
- } catch {
578
+ const uuidDirSet = new Set(uuidDirs.map((e) => e.name));
579
+ for (const [sessionId, mtime] of mtimeMap) {
580
+ if (!sessionMap.has(sessionId)) {
581
+ if (uuidDirSet.has(sessionId)) {
582
+ sessionMap.set(sessionId, { sessionId, lastModified: mtime, messageCount: -1 });
583
+ } else {
584
+ const filePath = (0, import_path.join)(projectDir, `${sessionId}.jsonl`);
585
+ const firstPrompt = await extractFirstPrompt(filePath).catch(() => void 0);
586
+ sessionMap.set(sessionId, { sessionId, lastModified: mtime, firstPrompt });
494
587
  }
495
- killProcessCrossPlatform(entry.process);
496
588
  }
497
- } else {
498
- console.log(`[ProcessProvider] Session ${sessionId}: process exited, respawning`);
499
589
  }
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);
590
+ const sessions = Array.from(sessionMap.values()).filter((s) => {
591
+ if (s.messageCount === 0) return false;
592
+ if (s.messageCount === -1) return true;
593
+ if (s.firstPrompt === void 0 && s.messageCount === void 0) return false;
594
+ return true;
523
595
  });
524
- this.attachStdoutListener(sessionId, proc);
525
- this.attachStderrListener(sessionId, proc);
526
- this.attachExitListener(sessionId, proc);
596
+ sessions.sort((a, b) => b.lastModified - a.lastModified);
597
+ return { ok: true, value: sessions };
598
+ } catch (err) {
599
+ return {
600
+ ok: false,
601
+ error: err instanceof Error ? err : new Error(String(err))
602
+ };
527
603
  }
528
- /**
529
- * 订阅指定会话的 Claude 事件流
530
- *
531
- * @returns 取消订阅函数
532
- */
604
+ }
605
+ async function getSessionHistory(projectPath, sessionId) {
606
+ try {
607
+ const encodedPath = encodeDirName(projectPath);
608
+ const filePath = (0, import_path.join)(CLAUDE_PROJECTS_DIR, encodedPath, `${sessionId}.jsonl`);
609
+ const raw = await (0, import_promises.readFile)(filePath, "utf-8").catch((err) => {
610
+ if (err.code === "ENOENT") return null;
611
+ throw err;
612
+ });
613
+ if (raw === null) return { ok: true, value: [] };
614
+ const lines = raw.split("\n").filter((l) => l.trim());
615
+ const events = [];
616
+ for (const line of lines) {
617
+ try {
618
+ const obj = JSON.parse(line);
619
+ const type = obj.type;
620
+ if (type === "user" && obj.message) {
621
+ const msgContent = obj.message.content;
622
+ if (typeof msgContent === "string") {
623
+ if (msgContent.includes("<local-command") || msgContent.includes("<command-name>")) continue;
624
+ } else if (Array.isArray(msgContent)) {
625
+ const hasText = msgContent.some(
626
+ (b) => b.type === "text" && !b.text?.includes("<local-command") && !b.text?.includes("<command-name>")
627
+ );
628
+ if (!hasText) continue;
629
+ }
630
+ const normalizedContent = typeof msgContent === "string" ? [{ type: "text", text: msgContent }] : Array.isArray(msgContent) ? msgContent.filter((b) => b.type === "text" && typeof b.text === "string") : [];
631
+ if (normalizedContent.length === 0) continue;
632
+ events.push({
633
+ type: "user",
634
+ message: {
635
+ ...obj.message,
636
+ content: normalizedContent
637
+ },
638
+ session_id: sessionId
639
+ });
640
+ } else if (type === "assistant" && obj.message) {
641
+ const content = (obj.message.content ?? []).filter(
642
+ (b) => b.type === "text" || b.type === "tool_use" || b.type === "thinking"
643
+ );
644
+ if (content.length === 0) continue;
645
+ events.push({
646
+ type: "assistant",
647
+ message: {
648
+ id: obj.message.id ?? obj.uuid ?? `hist-${events.length}`,
649
+ model: obj.message.model ?? "unknown",
650
+ role: "assistant",
651
+ content,
652
+ stop_reason: obj.message.stop_reason,
653
+ usage: obj.message.usage
654
+ },
655
+ session_id: sessionId
656
+ });
657
+ }
658
+ } catch {
659
+ }
660
+ }
661
+ if (events.length > 0) {
662
+ let totalInputTokens = 0;
663
+ let totalOutputTokens = 0;
664
+ for (const ev of events) {
665
+ if (ev.type === "assistant" && ev.message.usage) {
666
+ totalInputTokens += ev.message.usage.input_tokens ?? 0;
667
+ totalOutputTokens += ev.message.usage.output_tokens ?? 0;
668
+ }
669
+ }
670
+ if (totalInputTokens > 0 || totalOutputTokens > 0) {
671
+ events.push({
672
+ type: "result",
673
+ subtype: "success",
674
+ is_error: false,
675
+ duration_ms: 0,
676
+ num_turns: events.filter((e) => e.type === "user").length,
677
+ result: "",
678
+ session_id: sessionId,
679
+ usage: { input_tokens: totalInputTokens, output_tokens: totalOutputTokens }
680
+ });
681
+ }
682
+ }
683
+ return { ok: true, value: events };
684
+ } catch (err) {
685
+ return {
686
+ ok: false,
687
+ error: err instanceof Error ? err : new Error(String(err))
688
+ };
689
+ }
690
+ }
691
+ async function extractLastTimestamp(filePath) {
692
+ let fileHandle;
693
+ try {
694
+ fileHandle = await (0, import_promises.open)(filePath, "r");
695
+ const fileStat = await fileHandle.stat();
696
+ const readSize = Math.min(fileStat.size, 8192);
697
+ const buffer = Buffer.alloc(readSize);
698
+ await fileHandle.read(buffer, 0, readSize, fileStat.size - readSize);
699
+ const tail = buffer.toString("utf-8");
700
+ const lines = tail.split("\n").filter((l) => l.trim());
701
+ for (let i = lines.length - 1; i >= 0; i--) {
702
+ try {
703
+ const obj = JSON.parse(lines[i]);
704
+ if (obj.timestamp) {
705
+ const ts = new Date(obj.timestamp).getTime();
706
+ if (!isNaN(ts)) return ts;
707
+ }
708
+ } catch {
709
+ }
710
+ }
711
+ } catch {
712
+ } finally {
713
+ await fileHandle?.close();
714
+ }
715
+ return void 0;
716
+ }
717
+ async function extractFirstPrompt(filePath) {
718
+ let fileHandle;
719
+ try {
720
+ fileHandle = await (0, import_promises.open)(filePath, "r");
721
+ const rl = (0, import_readline.createInterface)({
722
+ input: fileHandle.createReadStream({ encoding: "utf-8" }),
723
+ crlfDelay: Infinity
724
+ });
725
+ let lineCount = 0;
726
+ for await (const line of rl) {
727
+ if (++lineCount > 20) break;
728
+ if (!line.trim()) continue;
729
+ try {
730
+ const obj = JSON.parse(line);
731
+ if (obj.type === "user" && obj.message) {
732
+ const msgContent = obj.message.content;
733
+ let text = "";
734
+ if (typeof msgContent === "string") {
735
+ text = msgContent;
736
+ } else if (Array.isArray(msgContent)) {
737
+ const textBlock = msgContent.find((b) => b.type === "text" && typeof b.text === "string");
738
+ text = textBlock?.text ?? "";
739
+ }
740
+ if (text && !text.includes("<local-command") && !text.includes("<command-name>")) {
741
+ text = text.replace(/<ide_opened_file>[\s\S]*?<\/ide_opened_file>/gi, "");
742
+ text = text.replace(/<[^>]+>/g, "").trim();
743
+ rl.close();
744
+ return text.length > 80 ? text.slice(0, 80) + "..." : text;
745
+ }
746
+ }
747
+ } catch {
748
+ }
749
+ }
750
+ } catch {
751
+ } finally {
752
+ await fileHandle?.close();
753
+ }
754
+ return void 0;
755
+ }
756
+ function decodeDirName(dirName) {
757
+ const placeholder = "\0";
758
+ const escaped = dirName.replace(/--/g, placeholder);
759
+ const decoded = escaped.replace(/-/g, "/");
760
+ return decoded.replace(new RegExp(placeholder, "g"), "-");
761
+ }
762
+ function encodeDirName(path2) {
763
+ const escaped = path2.replace(/-/g, "--");
764
+ return escaped.replace(/\//g, "-");
765
+ }
766
+ async function directoryExists(dirPath) {
767
+ try {
768
+ const s = await (0, import_promises.stat)(dirPath);
769
+ return s.isDirectory();
770
+ } catch {
771
+ return false;
772
+ }
773
+ }
774
+ var UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
775
+ async function countJsonlFilesWithMtime(dirPath) {
776
+ try {
777
+ const entries = await (0, import_promises.readdir)(dirPath, { withFileTypes: true });
778
+ const jsonlNames = new Set(
779
+ entries.filter((e) => e.isFile() && e.name.endsWith(".jsonl")).map((e) => e.name.slice(0, -6))
780
+ );
781
+ const uuidDirs = entries.filter(
782
+ (e) => e.isDirectory() && UUID_RE.test(e.name) && !jsonlNames.has(e.name)
783
+ );
784
+ let latestMtime = 0;
785
+ const jsonlEntries = entries.filter((e) => e.isFile() && e.name.endsWith(".jsonl"));
786
+ await Promise.all([
787
+ ...jsonlEntries.map(async (entry) => {
788
+ try {
789
+ const contentTs = await extractLastTimestamp((0, import_path.join)(dirPath, entry.name));
790
+ const ts = contentTs ?? (await (0, import_promises.stat)((0, import_path.join)(dirPath, entry.name))).mtimeMs;
791
+ if (ts > latestMtime) latestMtime = ts;
792
+ } catch {
793
+ }
794
+ }),
795
+ ...uuidDirs.map(async (entry) => {
796
+ try {
797
+ const fileStat = await (0, import_promises.stat)((0, import_path.join)(dirPath, entry.name));
798
+ if (fileStat.mtimeMs > latestMtime) latestMtime = fileStat.mtimeMs;
799
+ } catch {
800
+ }
801
+ })
802
+ ]);
803
+ return { count: jsonlNames.size + uuidDirs.length, latestMtime };
804
+ } catch {
805
+ return { count: 0, latestMtime: 0 };
806
+ }
807
+ }
808
+
809
+ // src/providers/ProcessProvider.ts
810
+ var CLAUDE_PATH = findClaudePath();
811
+ var ProcessProvider = class {
812
+ /** 活跃会话映射表:sessionId -> { session, process } */
813
+ activeSessions = /* @__PURE__ */ new Map();
814
+ /** 事件发射器,用于分发 Claude 事件流 */
815
+ emitter = new import_events.EventEmitter();
816
+ /**
817
+ * 启动新会话或恢复已有会话
818
+ *
819
+ * 会 spawn 一个 `claude` CLI 进程,设置工作目录和环境变量,
820
+ * 并开始监听 stdout 的 NDJSON 输出。
821
+ */
822
+ async startSession(opts) {
823
+ const { projectPath, message, sessionId: existingSessionId, model, permissionMode, effort, images, fallbackModel, maxBudgetUsd } = opts;
824
+ const sessionId = existingSessionId ?? (0, import_uuid.v4)();
825
+ if (this.activeSessions.has(sessionId)) {
826
+ await this.killSession(sessionId);
827
+ }
828
+ const projectId = projectPath.split("/").filter(Boolean).pop() ?? "unknown";
829
+ const session = {
830
+ id: sessionId,
831
+ projectId,
832
+ projectPath,
833
+ status: "running",
834
+ createdAt: Date.now(),
835
+ lastActiveAt: Date.now(),
836
+ summary: message.slice(0, 80)
837
+ };
838
+ const resume = opts.resume ?? !!existingSessionId;
839
+ let effectiveModel = model;
840
+ if (resume && !effectiveModel) {
841
+ effectiveModel = await getSessionModel(projectPath, sessionId).catch(() => void 0);
842
+ if (effectiveModel) {
843
+ console.log(`[ProcessProvider] Session ${sessionId}: resume restored original model "${effectiveModel}"`);
844
+ }
845
+ }
846
+ const proc = this.spawnClaudeProcess(sessionId, projectPath, resume, effectiveModel, permissionMode, effort, fallbackModel, maxBudgetUsd);
847
+ this.writeUserMessage(proc, message, sessionId, images);
848
+ session.pid = proc.pid;
849
+ this.activeSessions.set(sessionId, { session, process: proc, model: effectiveModel, permissionMode, effort, fallbackModel, maxBudgetUsd });
850
+ proc.on("error", (err) => {
851
+ console.error(`[ProcessProvider] Session ${sessionId} process error:`, err.message);
852
+ this.activeSessions.delete(sessionId);
853
+ const syntheticResult = {
854
+ type: "result",
855
+ subtype: "error",
856
+ result: `Process spawn failed: ${err.message}`,
857
+ session_id: sessionId,
858
+ duration_ms: 0,
859
+ is_error: true,
860
+ num_turns: 0
861
+ };
862
+ this.emitter.emit(this.getEventName(sessionId), syntheticResult);
863
+ });
864
+ this.attachStdoutListener(sessionId, proc);
865
+ this.attachStderrListener(sessionId, proc);
866
+ this.attachExitListener(sessionId, proc);
867
+ return session;
868
+ }
869
+ /**
870
+ * 终止指定会话
871
+ *
872
+ * kill 进程并从活跃映射中移除。
873
+ */
874
+ async killSession(sessionId) {
875
+ const entry = this.activeSessions.get(sessionId);
876
+ if (!entry) {
877
+ return;
878
+ }
879
+ if (entry.process.exitCode === null && entry.process.signalCode === null) {
880
+ try {
881
+ entry.process.stdin?.end();
882
+ } catch {
883
+ }
884
+ await killProcessCrossPlatform(entry.process);
885
+ }
886
+ this.activeSessions.delete(sessionId);
887
+ }
888
+ /**
889
+ * 向已有会话发送新消息
890
+ *
891
+ * 快速路径:进程存活时直接写 stdin(毫秒级响应)。
892
+ * 慢速路径:进程已退出时 respawn 并 --resume。
893
+ */
894
+ async sendMessage(sessionId, message, permissionMode, images) {
895
+ const entry = this.activeSessions.get(sessionId);
896
+ if (!entry) {
897
+ throw new Error(`Session ${sessionId} not found or already ended`);
898
+ }
899
+ const modeChanged = permissionMode != null && permissionMode !== (entry.permissionMode ?? "default");
900
+ if (!modeChanged && entry.process.exitCode === null && entry.process.signalCode === null && !entry.process.stdin?.destroyed) {
901
+ entry.session.status = "running";
902
+ entry.session.lastActiveAt = Date.now();
903
+ this.writeUserMessage(entry.process, message, sessionId, images);
904
+ return;
905
+ }
906
+ if (modeChanged) {
907
+ console.log(`[ProcessProvider] Session ${sessionId}: permission mode change ${entry.permissionMode ?? "default"} \u2192 ${permissionMode}, respawn`);
908
+ if (entry.process.exitCode === null && entry.process.signalCode === null) {
909
+ try {
910
+ entry.process.stdin?.end();
911
+ } catch {
912
+ }
913
+ killProcessCrossPlatform(entry.process);
914
+ }
915
+ } else {
916
+ console.log(`[ProcessProvider] Session ${sessionId}: process exited, respawning`);
917
+ }
918
+ const newMode = permissionMode ?? entry.permissionMode;
919
+ const proc = this.spawnClaudeProcess(sessionId, entry.session.projectPath, true, entry.model, newMode, entry.effort, entry.fallbackModel, entry.maxBudgetUsd);
920
+ this.writeUserMessage(proc, message, sessionId, images);
921
+ entry.session.status = "running";
922
+ entry.session.lastActiveAt = Date.now();
923
+ entry.session.pid = proc.pid;
924
+ entry.process = proc;
925
+ entry.permissionMode = newMode;
926
+ proc.on("error", (err) => {
927
+ console.error(`[ProcessProvider] Session ${sessionId} sendMessage process error:`, err.message);
928
+ this.activeSessions.delete(sessionId);
929
+ const syntheticResult = {
930
+ type: "result",
931
+ subtype: "error",
932
+ result: `Failed to send message: ${err.message}`,
933
+ session_id: sessionId,
934
+ duration_ms: 0,
935
+ is_error: true,
936
+ num_turns: 0
937
+ };
938
+ this.emitter.emit(this.getEventName(sessionId), syntheticResult);
939
+ });
940
+ this.attachStdoutListener(sessionId, proc);
941
+ this.attachStderrListener(sessionId, proc);
942
+ this.attachExitListener(sessionId, proc);
943
+ }
944
+ /**
945
+ * 订阅指定会话的 Claude 事件流
946
+ *
947
+ * @returns 取消订阅函数
948
+ */
533
949
  onEvent(sessionId, callback) {
534
950
  const eventName = this.getEventName(sessionId);
535
951
  this.emitter.on(eventName, callback);
@@ -743,7 +1159,7 @@ var ProcessProvider = class {
743
1159
  console.warn(`[ProcessProvider] Session ${sessionId}: stdout unavailable`);
744
1160
  return;
745
1161
  }
746
- const rl = (0, import_readline.createInterface)({
1162
+ const rl = (0, import_readline2.createInterface)({
747
1163
  input: proc.stdout,
748
1164
  crlfDelay: Infinity
749
1165
  });
@@ -757,56 +1173,6 @@ var ProcessProvider = class {
757
1173
  const result = this.parseLine(trimmed);
758
1174
  if (result.ok) {
759
1175
  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
1176
  this.updateSessionStatus(sessionId, event);
811
1177
  this.emitter.emit(this.getEventName(sessionId), event);
812
1178
  } else {
@@ -937,70 +1303,21 @@ ${context}`;
937
1303
  proc.once("error", reject);
938
1304
  });
939
1305
  }
940
- /**
941
- * 向正在等待中的 AskUserQuestion 提供答案
942
- *
943
- * 将答案写入 Claude 进程的 stdin(作为 tool_result),
944
- * Claude 收到后继续执行。
945
- */
946
- async answerQuestion(sessionId, toolUseId, answer) {
947
- const entry = this.activeSessions.get(sessionId);
948
- if (!entry) {
949
- throw new Error(`Session ${sessionId} not found`);
950
- }
951
- if (!entry.process.stdin || entry.process.stdin.destroyed) {
952
- throw new Error(`Session ${sessionId} stdin unavailable`);
953
- }
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})`);
970
- }
971
- /**
972
- * 订阅指定会话的 AskUserQuestion 事件
973
- *
974
- * @returns 取消订阅函数
975
- */
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
- };
982
- }
983
1306
  /**
984
1307
  * 生成事件名称
985
1308
  */
986
1309
  getEventName(sessionId) {
987
1310
  return `claude:${sessionId}`;
988
1311
  }
989
- /**
990
- * 生成 AskUserQuestion 内部事件名称
991
- */
992
- getQuestionEventName(sessionId) {
993
- return `question:${sessionId}`;
994
- }
995
1312
  };
996
1313
 
997
1314
  // src/providers/CodexProvider.ts
998
1315
  var import_child_process2 = require("child_process");
999
- var import_readline2 = require("readline");
1316
+ var import_readline3 = require("readline");
1000
1317
  var import_events2 = require("events");
1001
1318
  var import_fs = require("fs");
1002
- var import_path = require("path");
1003
- var import_os = require("os");
1319
+ var import_path2 = require("path");
1320
+ var import_os2 = require("os");
1004
1321
  var import_uuid2 = require("uuid");
1005
1322
 
1006
1323
  // src/utils/codexPath.ts
@@ -1079,9 +1396,9 @@ function isCodexAvailable() {
1079
1396
  }
1080
1397
 
1081
1398
  // 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");
1399
+ var SESSIX_DIR = (0, import_path2.join)((0, import_os2.homedir)(), ".sessix");
1400
+ var CODEX_SESSIONS_FILE = (0, import_path2.join)(SESSIX_DIR, "codex-sessions.json");
1401
+ var CODEX_EVENTS_DIR = (0, import_path2.join)(SESSIX_DIR, "codex-events");
1085
1402
  var SESSION_EXPIRY_MS = 30 * 24 * 60 * 60 * 1e3;
1086
1403
  var CodexProvider = class {
1087
1404
  activeSessions = /* @__PURE__ */ new Map();
@@ -1228,12 +1545,6 @@ var CodexProvider = class {
1228
1545
  async generateSuggestion(_context) {
1229
1546
  return "";
1230
1547
  }
1231
- async answerQuestion(_sessionId, _toolUseId, _answer) {
1232
- }
1233
- onQuestion(_sessionId, _callback) {
1234
- return () => {
1235
- };
1236
- }
1237
1548
  // ============================================
1238
1549
  // 私有方法
1239
1550
  // ============================================
@@ -1285,7 +1596,7 @@ var CodexProvider = class {
1285
1596
  */
1286
1597
  attachStdoutListener(sessionId, proc) {
1287
1598
  if (!proc.stdout) return;
1288
- const rl = (0, import_readline2.createInterface)({ input: proc.stdout, crlfDelay: Infinity });
1599
+ const rl = (0, import_readline3.createInterface)({ input: proc.stdout, crlfDelay: Infinity });
1289
1600
  const entry = this.activeSessions.get(sessionId);
1290
1601
  if (entry) entry.rl = rl;
1291
1602
  rl.on("line", (line) => {
@@ -1567,9 +1878,9 @@ var CodexProvider = class {
1567
1878
  * 优先从内存读,miss 时从磁盘加载
1568
1879
  */
1569
1880
  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`);
1881
+ const cached2 = this.sessionEvents.get(sessionId);
1882
+ if (cached2 && cached2.length > 0) return cached2;
1883
+ const filePath = (0, import_path2.join)(CODEX_EVENTS_DIR, `${sessionId}.json`);
1573
1884
  try {
1574
1885
  if (!(0, import_fs.existsSync)(filePath)) return [];
1575
1886
  const data = JSON.parse((0, import_fs.readFileSync)(filePath, "utf-8"));
@@ -1590,7 +1901,7 @@ var CodexProvider = class {
1590
1901
  (0, import_fs.mkdirSync)(CODEX_EVENTS_DIR, { recursive: true });
1591
1902
  }
1592
1903
  (0, import_fs.writeFileSync)(
1593
- (0, import_path.join)(CODEX_EVENTS_DIR, `${sessionId}.json`),
1904
+ (0, import_path2.join)(CODEX_EVENTS_DIR, `${sessionId}.json`),
1594
1905
  JSON.stringify(events),
1595
1906
  "utf-8"
1596
1907
  );
@@ -1615,7 +1926,7 @@ var CodexProvider = class {
1615
1926
  if (now - m.lastActiveAt > SESSION_EXPIRY_MS) {
1616
1927
  expiredCount++;
1617
1928
  try {
1618
- const eventsFile = (0, import_path.join)(CODEX_EVENTS_DIR, `${sessionId}.json`);
1929
+ const eventsFile = (0, import_path2.join)(CODEX_EVENTS_DIR, `${sessionId}.json`);
1619
1930
  if ((0, import_fs.existsSync)(eventsFile)) (0, import_fs.unlinkSync)(eventsFile);
1620
1931
  } catch {
1621
1932
  }
@@ -1728,7 +2039,6 @@ var ProviderFactory = class {
1728
2039
  };
1729
2040
 
1730
2041
  // src/session/SessionManager.ts
1731
- var import_uuid3 = require("uuid");
1732
2042
  var BUFFER_MAX = 5e3;
1733
2043
  var SessionManager = class {
1734
2044
  provider;
@@ -1907,6 +2217,21 @@ var SessionManager = class {
1907
2217
  this.updateSessionStatus(sessionId, "running");
1908
2218
  }
1909
2219
  }
2220
+ /**
2221
+ * 幂等清理单个待回答问题(由 ApprovalProxy onQuestionResolved 触发:
2222
+ * 答案到达 / 325s 超时 / 会话 kill / 服务关闭)。
2223
+ * 已不存在则静默返回(不打 warn,与 handleQuestionResponse 区分)。
2224
+ */
2225
+ clearPendingQuestion(requestId) {
2226
+ const pending = this.pendingQuestions.get(requestId);
2227
+ if (!pending) return;
2228
+ const { sessionId } = pending;
2229
+ this.pendingQuestions.delete(requestId);
2230
+ pending.resolve("");
2231
+ if (!this.hasPendingQuestionsForSession(sessionId)) {
2232
+ this.updateSessionStatus(sessionId, "running");
2233
+ }
2234
+ }
1910
2235
  /**
1911
2236
  * 获取指定会话的所有待回答问题(用于重连时恢复)
1912
2237
  */
@@ -2048,6 +2373,9 @@ var SessionManager = class {
2048
2373
  clearTimeout(pending.timer);
2049
2374
  }
2050
2375
  this.pendingAssistantEvents.clear();
2376
+ for (const pending of this.pendingQuestions.values()) {
2377
+ pending.resolve("");
2378
+ }
2051
2379
  this.pendingQuestions.clear();
2052
2380
  this.lastBroadcastStatus.clear();
2053
2381
  this.eventCallbacks.length = 0;
@@ -2057,22 +2385,15 @@ var SessionManager = class {
2057
2385
  // 内部方法
2058
2386
  // ============================================
2059
2387
  /**
2060
- * 订阅指定会话的事件流(包括 AskUserQuestion 问题事件)
2388
+ * 订阅指定会话的事件流(AskUserQuestion 已改由 ApprovalProxy hook 驱动)
2061
2389
  */
2062
2390
  subscribeToSession(sessionId) {
2063
2391
  const provider = this.getProviderForSession(sessionId);
2064
2392
  const unsubscribeEvent = provider.onEvent(sessionId, (event) => {
2065
2393
  this.handleClaudeEvent(sessionId, event);
2066
2394
  });
2067
- const unsubscribeQuestion = provider.onQuestion(
2068
- sessionId,
2069
- ({ toolUseId, question, options, questions }) => {
2070
- this.handleAskUserQuestion(sessionId, toolUseId, question, options, questions);
2071
- }
2072
- );
2073
2395
  this.unsubscribeMap.set(sessionId, () => {
2074
2396
  unsubscribeEvent();
2075
- unsubscribeQuestion();
2076
2397
  });
2077
2398
  }
2078
2399
  /**
@@ -2221,55 +2542,34 @@ var SessionManager = class {
2221
2542
  return runningStartedAt ? { ...base, runningStartedAt } : base;
2222
2543
  }
2223
2544
  /**
2224
- * 处理 AskUserQuestion 事件:广播问题请求到手机,等待用户回答
2545
+ * ApprovalProxy 在 PreToolUse hook 拦截到 AskUserQuestion 时调用。
2546
+ * 登记 pendingQuestion、广播 question_request、置 waiting_question,
2547
+ * 返回的 Promise 在 handleQuestionResponse 时 resolve。
2225
2548
  */
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)();
2549
+ askQuestion(sessionId, toolUseId, questions, requestId) {
2250
2550
  const request = {
2251
2551
  id: requestId,
2252
2552
  sessionId,
2253
2553
  toolUseId,
2254
- question,
2255
- options,
2554
+ question: questions[0]?.question ?? "",
2555
+ options: questions[0]?.options?.map((o) => o.label),
2256
2556
  questions,
2257
2557
  createdAt: Date.now()
2258
2558
  };
2259
2559
  this.updateSessionStatus(sessionId, "waiting_question");
2260
2560
  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
2561
  console.log(`[SessionManager] Session ${sessionId}: AskUserQuestion pushed (requestId=${requestId})`);
2562
+ return new Promise((resolve) => {
2563
+ this.pendingQuestions.set(requestId, {
2564
+ sessionId,
2565
+ toolUseId,
2566
+ question: request.question,
2567
+ options: request.options,
2568
+ questions,
2569
+ createdAt: request.createdAt,
2570
+ resolve
2571
+ });
2572
+ });
2273
2573
  }
2274
2574
  /**
2275
2575
  * 清除指定会话的所有待回答问题
@@ -2282,6 +2582,7 @@ var SessionManager = class {
2282
2582
  }
2283
2583
  }
2284
2584
  for (const requestId of toRemove) {
2585
+ this.pendingQuestions.get(requestId)?.resolve("");
2285
2586
  this.pendingQuestions.delete(requestId);
2286
2587
  }
2287
2588
  }
@@ -2301,7 +2602,7 @@ var SessionManager = class {
2301
2602
 
2302
2603
  // src/session/SessionFileWatcher.ts
2303
2604
  var import_chokidar = __toESM(require("chokidar"));
2304
- var import_promises = require("fs/promises");
2605
+ var import_promises2 = require("fs/promises");
2305
2606
  var import_node_readline = require("readline");
2306
2607
  var SessionFileWatcher = class {
2307
2608
  watchers = /* @__PURE__ */ new Map();
@@ -2380,7 +2681,7 @@ var SessionFileWatcher = class {
2380
2681
  let fileHandle;
2381
2682
  let rl;
2382
2683
  try {
2383
- fileHandle = await (0, import_promises.open)(entry.filePath, "r");
2684
+ fileHandle = await (0, import_promises2.open)(entry.filePath, "r");
2384
2685
  const fileStat = await fileHandle.stat();
2385
2686
  const newSize = fileStat.size;
2386
2687
  if (newSize <= entry.byteOffset) return;
@@ -2690,7 +2991,7 @@ var import_node_http = __toESM(require("http"));
2690
2991
  var import_node_fs3 = __toESM(require("fs"));
2691
2992
  var import_node_path3 = __toESM(require("path"));
2692
2993
  var import_node_os4 = __toESM(require("os"));
2693
- var import_uuid4 = require("uuid");
2994
+ var import_uuid3 = require("uuid");
2694
2995
  var ApprovalProxy = class _ApprovalProxy {
2695
2996
  server;
2696
2997
  token;
@@ -2698,10 +2999,16 @@ var ApprovalProxy = class _ApprovalProxy {
2698
2999
  settingsPath = import_node_path3.default.join(import_node_os4.default.homedir(), ".claude", "settings.json");
2699
3000
  /** 待处理的审批请求:requestId -> { resolve, timer, request } */
2700
3001
  pendingApprovals = /* @__PURE__ */ new Map();
3002
+ /** 待回答的 AskUserQuestion:requestId -> { resolve, timer, request } */
3003
+ pendingQuestions = /* @__PURE__ */ new Map();
3004
+ /** 由外部注入:把问题推给手机并等待答案(返回用户答案文本) */
3005
+ questionHandler = null;
2701
3006
  /** 审批请求回调(通知外部推送到手机) */
2702
3007
  approvalRequestCallbacks = [];
2703
3008
  /** 审批 resolve 回调(任何来源的 resolve 都会触发,用于 WS 广播清理 */
2704
3009
  approvalResolvedCallbacks = [];
3010
+ /** 问题 resolve 回调(任何来源的 resolve 都会触发,用于 SessionManager 清理) */
3011
+ questionResolvedCallbacks = [];
2705
3012
  /** Hook 通知回调(PreCompact / PermissionDenied / 等非阻塞 hook) */
2706
3013
  notifyCallbacks = [];
2707
3014
  /** YOLO 模式状态:sessionId -> enabled */
@@ -2765,6 +3072,30 @@ var ApprovalProxy = class _ApprovalProxy {
2765
3072
  }
2766
3073
  }
2767
3074
  }
3075
+ /**
3076
+ * 注册问题 resolve 回调
3077
+ *
3078
+ * 任何来源的 resolve 都会触发:
3079
+ * - resolveQuestion(手机端答案到达)
3080
+ * - 325s 超时自动空答案
3081
+ * - clearPendingQuestionsForSession(会话被 kill)
3082
+ * - close()(服务关闭)
3083
+ *
3084
+ * 用于通知 SessionManager 清理 pendingQuestions,避免会话卡在 waiting_question。
3085
+ */
3086
+ onQuestionResolved(callback) {
3087
+ this.questionResolvedCallbacks.push(callback);
3088
+ }
3089
+ /** 通知所有问题 resolve 回调(内部调用) */
3090
+ notifyQuestionResolved(requestId) {
3091
+ for (const cb of this.questionResolvedCallbacks) {
3092
+ try {
3093
+ cb(requestId);
3094
+ } catch (err) {
3095
+ console.error("[ApprovalProxy] question resolved callback error:", err);
3096
+ }
3097
+ }
3098
+ }
2768
3099
  /**
2769
3100
  * 注册非阻塞 hook 通知回调(如 PreCompact、PermissionDenied)
2770
3101
  *
@@ -2819,6 +3150,42 @@ var ApprovalProxy = class _ApprovalProxy {
2819
3150
  this.notifyApprovalResolved(requestId, decision);
2820
3151
  return true;
2821
3152
  }
3153
+ /** 注入问题处理器(server.ts 接到 SessionManager.askQuestion) */
3154
+ setQuestionHandler(handler) {
3155
+ this.questionHandler = handler;
3156
+ }
3157
+ /** 解析一个待回答问题(手机端答案到达时由 server.ts 调用) */
3158
+ resolveQuestion(requestId, answer) {
3159
+ const pending = this.pendingQuestions.get(requestId);
3160
+ if (!pending) {
3161
+ console.warn(`[ApprovalProxy] Question request not found: ${requestId}`);
3162
+ return false;
3163
+ }
3164
+ clearTimeout(pending.timer);
3165
+ pending.resolve(answer);
3166
+ this.pendingQuestions.delete(requestId);
3167
+ console.log(`[ApprovalProxy] Question answered: ${requestId}`);
3168
+ this.notifyQuestionResolved(requestId);
3169
+ return true;
3170
+ }
3171
+ /** 清理会话的待回答问题(会话被 kill 时,给空答案让 hook 不再阻塞) */
3172
+ clearPendingQuestionsForSession(sessionId) {
3173
+ const toRemove = [];
3174
+ for (const [requestId, pending] of this.pendingQuestions) {
3175
+ if (pending.request.sessionId === sessionId) {
3176
+ toRemove.push(requestId);
3177
+ }
3178
+ }
3179
+ for (const requestId of toRemove) {
3180
+ const pending = this.pendingQuestions.get(requestId);
3181
+ if (!pending) continue;
3182
+ clearTimeout(pending.timer);
3183
+ pending.resolve("");
3184
+ this.pendingQuestions.delete(requestId);
3185
+ console.log(`[ApprovalProxy] Session ${sessionId} killed, cleared pending question ${requestId}`);
3186
+ this.notifyQuestionResolved(requestId);
3187
+ }
3188
+ }
2822
3189
  /** 获取当前待处理的审批数量 */
2823
3190
  getPendingCount() {
2824
3191
  return this.pendingApprovals.size;
@@ -2940,6 +3307,13 @@ var ApprovalProxy = class _ApprovalProxy {
2940
3307
  pending.resolve({ decision: "deny", reason: t("approval.serverClosed") });
2941
3308
  }
2942
3309
  this.pendingApprovals.clear();
3310
+ const pendingQuestionEntries = Array.from(this.pendingQuestions.entries());
3311
+ for (const [requestId, pending] of pendingQuestionEntries) {
3312
+ clearTimeout(pending.timer);
3313
+ pending.resolve("");
3314
+ this.notifyQuestionResolved(requestId);
3315
+ }
3316
+ this.pendingQuestions.clear();
2943
3317
  this.server.close((err) => {
2944
3318
  if (err) {
2945
3319
  reject(err);
@@ -2995,7 +3369,7 @@ var ApprovalProxy = class _ApprovalProxy {
2995
3369
  try {
2996
3370
  const body = await this.parseJsonBody(req);
2997
3371
  const payload = body.payload ?? body;
2998
- const requestId = (0, import_uuid4.v4)();
3372
+ const requestId = (0, import_uuid3.v4)();
2999
3373
  const projectPath = String(body.projectPath ?? "unknown");
3000
3374
  const toolName = String(payload.tool_name ?? body.tool_name ?? "unknown");
3001
3375
  const toolInput = payload.tool_input ?? body.tool_input ?? {};
@@ -3009,6 +3383,51 @@ var ApprovalProxy = class _ApprovalProxy {
3009
3383
  createdAt: Date.now()
3010
3384
  };
3011
3385
  console.log(`[ApprovalProxy] ${t("approval.received")}: ${requestId} (${approvalRequest.toolName})`);
3386
+ if ((approvalRequest.toolName === "AskUserQuestion" || approvalRequest.toolName === "AskFollowupQuestion") && this.questionHandler) {
3387
+ const questions = parseQuestionsFromInput(toolInput);
3388
+ if (questions.length === 0) {
3389
+ this.sendJson(res, 200, { decision: "allow" });
3390
+ return;
3391
+ }
3392
+ const toolUseId = String(
3393
+ payload.tool_use_id ?? body.tool_use_id ?? ""
3394
+ );
3395
+ const qRequest = {
3396
+ id: requestId,
3397
+ sessionId: approvalRequest.sessionId,
3398
+ toolUseId,
3399
+ question: questions[0].question,
3400
+ options: questions[0].options?.map((o) => o.label),
3401
+ questions,
3402
+ createdAt: Date.now()
3403
+ };
3404
+ const answer = await new Promise((resolve) => {
3405
+ const timer = setTimeout(() => {
3406
+ this.pendingQuestions.delete(requestId);
3407
+ console.log(`[ApprovalProxy] Question timeout: ${requestId}`);
3408
+ resolve("");
3409
+ this.notifyQuestionResolved(requestId);
3410
+ }, 325e3);
3411
+ this.pendingQuestions.set(requestId, { resolve, timer, request: qRequest });
3412
+ this.questionHandler(qRequest.sessionId, toolUseId, questions, requestId).then((ans) => {
3413
+ if (ans && this.pendingQuestions.has(requestId)) this.resolveQuestion(requestId, ans);
3414
+ }).catch((err) => console.error("[ApprovalProxy] questionHandler error:", err));
3415
+ });
3416
+ if (!answer) {
3417
+ this.sendJson(res, 200, {
3418
+ decision: "deny",
3419
+ 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",
3420
+ systemMessage: "\u7528\u6237\u672A\u56DE\u7B54 AskUserQuestion\uFF1B\u8BF7\u52FF\u91CD\u8BD5\u8BE5\u5DE5\u5177\uFF0C\u81EA\u884C\u51B3\u7B56\u7EE7\u7EED\u3002"
3421
+ });
3422
+ return;
3423
+ }
3424
+ this.sendJson(res, 200, {
3425
+ decision: "deny",
3426
+ reason: formatQuestionAnswer(questions, answer),
3427
+ 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"
3428
+ });
3429
+ return;
3430
+ }
3012
3431
  if (this.isToolAlwaysAllowed(approvalRequest.toolName, projectPath !== "unknown" ? projectPath : void 0)) {
3013
3432
  console.log(`[ApprovalProxy] ${t("approval.alwaysAllowPassThrough", { tool: approvalRequest.toolName })}`);
3014
3433
  this.sendJson(res, 200, { decision: "allow" });
@@ -3198,6 +3617,49 @@ var ApprovalProxy = class _ApprovalProxy {
3198
3617
  res.end(body);
3199
3618
  }
3200
3619
  };
3620
+ function parseQuestionsFromInput(input) {
3621
+ if (Array.isArray(input.questions) && input.questions.length > 0) {
3622
+ return input.questions.map((q) => {
3623
+ const item = {
3624
+ question: typeof q.question === "string" ? q.question : "",
3625
+ header: typeof q.header === "string" ? q.header : void 0,
3626
+ multiSelect: typeof q.multiSelect === "boolean" ? q.multiSelect : void 0
3627
+ };
3628
+ if (Array.isArray(q.options)) {
3629
+ item.options = q.options.map((o) => ({
3630
+ label: typeof o.label === "string" ? o.label : "",
3631
+ description: typeof o.description === "string" ? o.description : void 0
3632
+ }));
3633
+ }
3634
+ return item;
3635
+ });
3636
+ }
3637
+ if (typeof input.question === "string") {
3638
+ const opts = Array.isArray(input.options) ? input.options.map((o) => ({ label: String(o) })) : void 0;
3639
+ return [{ question: input.question, options: opts }];
3640
+ }
3641
+ return [];
3642
+ }
3643
+ function formatQuestionAnswer(questions, raw) {
3644
+ let pairs = [];
3645
+ try {
3646
+ const parsed = JSON.parse(raw);
3647
+ if (parsed && typeof parsed === "object" && parsed.answers && typeof parsed.answers === "object") {
3648
+ const answers = parsed.answers;
3649
+ 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) }));
3650
+ }
3651
+ } catch {
3652
+ }
3653
+ if (pairs.length === 0) {
3654
+ pairs = [{ q: questions[0]?.question ?? "\uFF08\u672A\u77E5\u95EE\u9898\uFF09", a: raw }];
3655
+ }
3656
+ const body = pairs.map((p, i) => `${i + 1}. \u95EE\u9898\uFF1A${p.q}
3657
+ \u7528\u6237\u56DE\u7B54\uFF1A${p.a}`).join("\n");
3658
+ return `\u3010\u7528\u6237\u5DF2\u901A\u8FC7 Sessix \u56DE\u7B54\u3011
3659
+ ${body}
3660
+
3661
+ \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`;
3662
+ }
3201
3663
 
3202
3664
  // src/mdns/MdnsService.ts
3203
3665
  var import_node_child_process5 = require("child_process");
@@ -3363,7 +3825,7 @@ function getLanAddresses(networkInterfacesFn) {
3363
3825
  }
3364
3826
 
3365
3827
  // src/hooks/HookInstaller.ts
3366
- var import_promises2 = require("fs/promises");
3828
+ var import_promises3 = require("fs/promises");
3367
3829
  var import_node_path4 = require("path");
3368
3830
  var import_node_os6 = require("os");
3369
3831
  var SESSIX_HOOKS_DIR = (0, import_node_path4.join)((0, import_node_os6.homedir)(), ".sessix", "hooks");
@@ -3383,7 +3845,7 @@ var LEGACY_HOOK_COMMANDS = [
3383
3845
  "~/.sessix/hooks/permission-accept.sh"
3384
3846
  ];
3385
3847
  var HOOK_SCRIPT_TEMPLATE = `#!/usr/bin/env node
3386
- // Sessix Approval Hook
3848
+ // Sessix Approval Hook v2 (systemMessage passthrough)
3387
3849
  // \u4EC5\u5728 Sessix \u7BA1\u7406\u7684\u4F1A\u8BDD\u4E2D\u6FC0\u6D3B
3388
3850
 
3389
3851
  const sessionId = process.env.SESSIX_SESSION_ID
@@ -3414,6 +3876,9 @@ process.stdin.on('end', async () => {
3414
3876
  if (decision === 'deny' && data.reason) {
3415
3877
  output.hookSpecificOutput.permissionDecisionReason = String(data.reason)
3416
3878
  }
3879
+ if (data.systemMessage) {
3880
+ output.systemMessage = String(data.systemMessage)
3881
+ }
3417
3882
  process.stdout.write(JSON.stringify(output))
3418
3883
  process.exit(0)
3419
3884
  } catch {
@@ -3553,17 +4018,17 @@ var HookInstaller = class {
3553
4018
  * 4. 更新 Claude Code settings.json 添加 hook 配置
3554
4019
  */
3555
4020
  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);
4021
+ await (0, import_promises3.mkdir)(SESSIX_HOOKS_DIR, { recursive: true });
4022
+ await (0, import_promises3.writeFile)(HOOK_SCRIPT_PATH, HOOK_SCRIPT_TEMPLATE, "utf-8");
4023
+ await (0, import_promises3.writeFile)(PERMISSION_ACCEPT_PATH, PERMISSION_ACCEPT_TEMPLATE, "utf-8");
4024
+ await (0, import_promises3.writeFile)(COMPACT_HOOK_PATH, COMPACT_HOOK_TEMPLATE, "utf-8");
4025
+ await (0, import_promises3.writeFile)(POST_COMPACT_HOOK_PATH, POST_COMPACT_HOOK_TEMPLATE, "utf-8");
4026
+ await (0, import_promises3.writeFile)(PERMISSION_DENIED_HOOK_PATH, PERMISSION_DENIED_HOOK_TEMPLATE, "utf-8");
4027
+ await (0, import_promises3.chmod)(HOOK_SCRIPT_PATH, 493);
4028
+ await (0, import_promises3.chmod)(PERMISSION_ACCEPT_PATH, 493);
4029
+ await (0, import_promises3.chmod)(COMPACT_HOOK_PATH, 493);
4030
+ await (0, import_promises3.chmod)(POST_COMPACT_HOOK_PATH, 493);
4031
+ await (0, import_promises3.chmod)(PERMISSION_DENIED_HOOK_PATH, 493);
3567
4032
  await this.addHookToSettings();
3568
4033
  console.log("[HookInstaller] Hook installation complete");
3569
4034
  }
@@ -3593,30 +4058,30 @@ var HookInstaller = class {
3593
4058
  let postCompactScriptExists = false;
3594
4059
  let permissionDeniedScriptExists = false;
3595
4060
  try {
3596
- approvalScriptContent = await (0, import_promises2.readFile)(HOOK_SCRIPT_PATH, "utf-8");
4061
+ approvalScriptContent = await (0, import_promises3.readFile)(HOOK_SCRIPT_PATH, "utf-8");
3597
4062
  } catch {
3598
4063
  }
3599
4064
  try {
3600
- await (0, import_promises2.access)(PERMISSION_ACCEPT_PATH);
4065
+ await (0, import_promises3.access)(PERMISSION_ACCEPT_PATH);
3601
4066
  permissionScriptExists = true;
3602
4067
  } catch {
3603
4068
  }
3604
4069
  try {
3605
- await (0, import_promises2.access)(COMPACT_HOOK_PATH);
4070
+ await (0, import_promises3.access)(COMPACT_HOOK_PATH);
3606
4071
  compactScriptExists = true;
3607
4072
  } catch {
3608
4073
  }
3609
4074
  try {
3610
- await (0, import_promises2.access)(POST_COMPACT_HOOK_PATH);
4075
+ await (0, import_promises3.access)(POST_COMPACT_HOOK_PATH);
3611
4076
  postCompactScriptExists = true;
3612
4077
  } catch {
3613
4078
  }
3614
4079
  try {
3615
- await (0, import_promises2.access)(PERMISSION_DENIED_HOOK_PATH);
4080
+ await (0, import_promises3.access)(PERMISSION_DENIED_HOOK_PATH);
3616
4081
  permissionDeniedScriptExists = true;
3617
4082
  } catch {
3618
4083
  }
3619
- const isLatestVersion = approvalScriptContent.includes("permissionDecision");
4084
+ const isLatestVersion = approvalScriptContent.includes("permissionDecision") && approvalScriptContent.includes("Sessix Approval Hook v2");
3620
4085
  const settings = await this.readClaudeSettings();
3621
4086
  const configExists = this.hasHookConfig(settings);
3622
4087
  return isLatestVersion && permissionScriptExists && compactScriptExists && postCompactScriptExists && permissionDeniedScriptExists && configExists;
@@ -3724,7 +4189,7 @@ var HookInstaller = class {
3724
4189
  */
3725
4190
  async readClaudeSettings() {
3726
4191
  try {
3727
- const content = await (0, import_promises2.readFile)(CLAUDE_SETTINGS_PATH, "utf-8");
4192
+ const content = await (0, import_promises3.readFile)(CLAUDE_SETTINGS_PATH, "utf-8");
3728
4193
  return JSON.parse(content);
3729
4194
  } catch {
3730
4195
  return {};
@@ -3734,8 +4199,8 @@ var HookInstaller = class {
3734
4199
  * 写入 Claude Code settings.json
3735
4200
  */
3736
4201
  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");
4202
+ await (0, import_promises3.mkdir)((0, import_node_path4.join)((0, import_node_os6.homedir)(), ".claude"), { recursive: true });
4203
+ await (0, import_promises3.writeFile)(CLAUDE_SETTINGS_PATH, JSON.stringify(settings, null, 2) + "\n", "utf-8");
3739
4204
  }
3740
4205
  /**
3741
4206
  * 检查 settings 中是否已包含所有 Sessix hook 配置
@@ -3776,7 +4241,7 @@ var HookInstaller = class {
3776
4241
  var import_node_path5 = require("path");
3777
4242
  var RECENT_ACTIVITY_MAX = 6;
3778
4243
  var ACTIVITY_PUSH_THROTTLE_MS = 4e3;
3779
- var NotificationService = class {
4244
+ var NotificationService = class _NotificationService {
3780
4245
  constructor(sessionManager, expoChannel = null) {
3781
4246
  this.sessionManager = sessionManager;
3782
4247
  this.expoChannel = expoChannel;
@@ -3818,6 +4283,14 @@ var NotificationService = class {
3818
4283
  * token 注册时启动,flushActivityEnd / removeActivityPushToken 时停止。
3819
4284
  */
3820
4285
  laHeartbeatTimers = /* @__PURE__ */ new Map();
4286
+ /**
4287
+ * 上次推送的内容指纹(status + recentActivity + approvalId)。
4288
+ * 只在内容实际变化时发 priority-10 推送;未变化时低频刷新(30s),
4289
+ * 节省 APNs push budget,避免 iOS 节流导致 LA 停滞。
4290
+ */
4291
+ lastPushedFingerprint = /* @__PURE__ */ new Map();
4292
+ /** 内容未变化时低频刷新间隔(仅刷新 stats/timer,不含内容变化) */
4293
+ static STATS_REFRESH_INTERVAL_MS = 3e4;
3821
4294
  /** 添加通知渠道(id 唯一,可用于后续动态开关) */
3822
4295
  addChannel(id, channel, enabled = true) {
3823
4296
  this.channelMap.set(id, { channel, enabled });
@@ -3835,9 +4308,21 @@ var NotificationService = class {
3835
4308
  removePushToken(token) {
3836
4309
  this.expoChannel?.removeToken(token);
3837
4310
  }
4311
+ /** 注册原生 APNs device token(直连 APNs,优先于 Expo 推送服务) */
4312
+ addNativePushToken(token, ws) {
4313
+ this.activityPushChannel?.addAlertToken(token, ws);
4314
+ if (this.activityPushChannel?.hasAlertTokens()) {
4315
+ console.log("[NotificationService] \u2705 \u76F4\u8FDE APNs alert token \u5DF2\u6CE8\u518C\uFF0CExpo \u63A8\u9001\u6E20\u9053\u964D\u7EA7\u5907\u7528");
4316
+ }
4317
+ }
4318
+ /** 移除原生 APNs device token */
4319
+ removeNativePushToken(token) {
4320
+ this.activityPushChannel?.removeAlertToken(token);
4321
+ }
3838
4322
  /** 更新通知音效偏好 */
3839
4323
  setSoundPreferences(prefs) {
3840
4324
  this.expoChannel?.setSoundPreferences(prefs);
4325
+ this.activityPushChannel?.setAlertSoundPreferences(prefs);
3841
4326
  }
3842
4327
  /** 设置 ActivityKit Push 渠道(可选,需要 APNs 认证配置) */
3843
4328
  setActivityPushChannel(channel) {
@@ -3862,6 +4347,7 @@ var NotificationService = class {
3862
4347
  this.recentActivityState.delete(sessionId);
3863
4348
  this.lastActivityPushAt.delete(sessionId);
3864
4349
  this.activityCounters.delete(sessionId);
4350
+ this.lastPushedFingerprint.delete(sessionId);
3865
4351
  }
3866
4352
  /** 注入"会话 → 待审批列表"提供器(server.ts 启动时调用) */
3867
4353
  setPendingApprovalsProvider(fn) {
@@ -4012,6 +4498,7 @@ var NotificationService = class {
4012
4498
  this.lastActivityPushAt.clear();
4013
4499
  this.pendingPriority.clear();
4014
4500
  this.activityCounters.clear();
4501
+ this.lastPushedFingerprint.clear();
4015
4502
  }
4016
4503
  // ============================================
4017
4504
  // 内部方法
@@ -4063,12 +4550,19 @@ var NotificationService = class {
4063
4550
  }
4064
4551
  }
4065
4552
  notify(payload) {
4066
- for (const { channel, enabled } of this.channelMap.values()) {
4553
+ const hasDirectApns = this.activityPushChannel?.hasAlertTokens() === true;
4554
+ for (const [id, { channel, enabled }] of this.channelMap.entries()) {
4067
4555
  if (!enabled) continue;
4556
+ if (id === "expo" && hasDirectApns) continue;
4068
4557
  channel.send(payload).catch((err) => {
4069
4558
  console.error("[NotificationService] Notification send failed:", err);
4070
4559
  });
4071
4560
  }
4561
+ if (hasDirectApns && this.activityPushChannel) {
4562
+ this.activityPushChannel.send(payload).catch((err) => {
4563
+ console.error("[NotificationService] Direct APNs push failed, no fallback:", err);
4564
+ });
4565
+ }
4072
4566
  }
4073
4567
  /** 从 assistant 事件中提取最新文本消息 */
4074
4568
  trackAssistantText(sessionId, event) {
@@ -4105,6 +4599,8 @@ var NotificationService = class {
4105
4599
  while (state2.history.length > RECENT_ACTIVITY_MAX) state2.history.shift();
4106
4600
  state2.currentEntries = [];
4107
4601
  state2.currentMessageId = null;
4602
+ state2.accumulatedText = "";
4603
+ state2.countedToolIds = /* @__PURE__ */ new Set();
4108
4604
  }
4109
4605
  return;
4110
4606
  }
@@ -4113,7 +4609,7 @@ var NotificationService = class {
4113
4609
  if (!Array.isArray(msg.content)) return;
4114
4610
  let state = this.recentActivityState.get(sessionId);
4115
4611
  if (!state) {
4116
- state = { history: [], currentMessageId: null, currentEntries: [] };
4612
+ state = { history: [], currentMessageId: null, currentEntries: [], accumulatedText: "", countedToolIds: /* @__PURE__ */ new Set() };
4117
4613
  this.recentActivityState.set(sessionId, state);
4118
4614
  }
4119
4615
  if (state.currentMessageId !== msg.id) {
@@ -4123,16 +4619,25 @@ var NotificationService = class {
4123
4619
  }
4124
4620
  state.currentEntries = [];
4125
4621
  state.currentMessageId = msg.id;
4622
+ state.accumulatedText = "";
4623
+ state.countedToolIds = /* @__PURE__ */ new Set();
4624
+ }
4625
+ for (const block of msg.content) {
4626
+ if (block.type === "text" && typeof block.text === "string") {
4627
+ state.accumulatedText += block.text;
4628
+ }
4126
4629
  }
4127
4630
  const next = [];
4631
+ const accText = this.summarizeText(state.accumulatedText);
4632
+ if (accText.length >= 4) next.push(accText);
4128
4633
  for (const block of msg.content) {
4129
- if (block.type === "text") {
4130
- const line = this.summarizeText(block.text);
4131
- if (line.length >= 4) next.push(line);
4132
- } else if (block.type === "tool_use") {
4634
+ if (block.type === "tool_use") {
4133
4635
  const line = this.summarizeToolCall(block.name, block.input ?? {});
4134
4636
  if (line) next.push(line);
4135
- this.incrementCounter(sessionId, block.name);
4637
+ if (!state.countedToolIds.has(block.id)) {
4638
+ state.countedToolIds.add(block.id);
4639
+ this.incrementCounter(sessionId, block.name);
4640
+ }
4136
4641
  }
4137
4642
  }
4138
4643
  state.currentEntries = next;
@@ -4248,7 +4753,7 @@ var NotificationService = class {
4248
4753
  return;
4249
4754
  }
4250
4755
  if (session.status === "running" || session.status === "waiting_approval" || session.status === "waiting_question") {
4251
- this.scheduleActivityPush(sessionId);
4756
+ this.scheduleActivityPush(sessionId, true);
4252
4757
  }
4253
4758
  }, ACTIVITY_PUSH_THROTTLE_MS);
4254
4759
  this.laHeartbeatTimers.set(sessionId, timer);
@@ -4285,6 +4790,7 @@ var NotificationService = class {
4285
4790
  * 无 token → 普通 Expo push。清理所有相关状态。
4286
4791
  */
4287
4792
  flushActivityEnd(sessionId, reason) {
4793
+ console.log(`[NotificationService] \u{1F514} flushActivityEnd(${reason}) session=${sessionId.slice(0, 8)}\u2026 expoAvailable=${this.expoChannel?.isAvailable() ?? false}`);
4288
4794
  const sessionTitle = this.getSessionTitle(sessionId);
4289
4795
  const latestMsg = this.latestAssistantText.get(sessionId);
4290
4796
  const isError = reason === "error";
@@ -4322,14 +4828,36 @@ var NotificationService = class {
4322
4828
  this.recentActivityState.delete(sessionId);
4323
4829
  this.lastActivityPushAt.delete(sessionId);
4324
4830
  this.activityCounters.delete(sessionId);
4831
+ this.lastPushedFingerprint.delete(sessionId);
4325
4832
  console.log(`[NotificationService] \u{1F3C1} LA end (${reason}) session=${sessionId.slice(0, 8)}\u2026`);
4326
4833
  }
4834
+ /**
4835
+ * 计算内容指纹:status + recentActivity + latestApproval。
4836
+ * 用于判断 LA 内容是否实际变化,避免重复推送消耗 APNs budget。
4837
+ */
4838
+ computeContentFingerprint(sessionId) {
4839
+ const activity = this.getRecentActivity(sessionId);
4840
+ const session = this.sessionManager.getActiveSessions().find((s) => s.id === sessionId);
4841
+ const approvals = this.pendingApprovalsProvider?.(sessionId) ?? [];
4842
+ const latestApproval = approvals[approvals.length - 1];
4843
+ return `${session?.status ?? ""}|${activity.join(" ")}|${latestApproval?.id ?? ""}`;
4844
+ }
4327
4845
  /** 真正发送一次 LA content push(无 alert) */
4328
4846
  flushActivityPush(sessionId) {
4329
4847
  const channel = this.activityPushChannel;
4330
4848
  if (!channel?.hasToken(sessionId)) return;
4331
4849
  const session = this.sessionManager.getActiveSessions().find((s) => s.id === sessionId);
4332
4850
  if (!session) return;
4851
+ const fingerprint = this.computeContentFingerprint(sessionId);
4852
+ const lastFingerprint = this.lastPushedFingerprint.get(sessionId);
4853
+ const contentChanged = fingerprint !== lastFingerprint;
4854
+ if (!contentChanged) {
4855
+ const lastPush = this.lastActivityPushAt.get(sessionId) ?? 0;
4856
+ if (Date.now() - lastPush < _NotificationService.STATS_REFRESH_INTERVAL_MS) {
4857
+ return;
4858
+ }
4859
+ }
4860
+ this.lastPushedFingerprint.set(sessionId, fingerprint);
4333
4861
  const recentActivity = this.getRecentActivity(sessionId);
4334
4862
  const latestMessage = recentActivity[recentActivity.length - 1] ?? this.latestAssistantText.get(sessionId) ?? "";
4335
4863
  const sessionTitle = this.getSessionTitle(sessionId);
@@ -4358,13 +4886,14 @@ var NotificationService = class {
4358
4886
  };
4359
4887
  }
4360
4888
  contentState.stats = this.buildStatsPayload(session);
4361
- const priority = this.pendingPriority.get(sessionId) ?? "5";
4889
+ const explicitPriority = this.pendingPriority.get(sessionId);
4890
+ const priority = explicitPriority ?? (contentChanged ? "10" : "5");
4362
4891
  this.pendingPriority.delete(sessionId);
4363
4892
  this.lastActivityPushAt.set(sessionId, Date.now());
4364
4893
  const lineCount = recentActivity.length;
4365
4894
  channel.updateActivity(sessionId, contentState, { priority }).then((ok) => {
4366
4895
  if (ok) {
4367
- console.log(`[NotificationService] \u{1F4E1} LA push \u2713 session=${sessionId.slice(0, 8)}\u2026 status=${status} p=${priority} lines=${lineCount}`);
4896
+ console.log(`[NotificationService] \u{1F4E1} LA push \u2713 session=${sessionId.slice(0, 8)}\u2026 status=${status} p=${priority} changed=${contentChanged} lines=${lineCount}`);
4368
4897
  }
4369
4898
  }).catch((err) => {
4370
4899
  console.warn(`[NotificationService] \u{1F4E1} LA push \u2717 session=${sessionId.slice(0, 8)}\u2026:`, err instanceof Error ? err.message : err);
@@ -4493,6 +5022,8 @@ var DesktopNotificationChannel = class {
4493
5022
 
4494
5023
  // src/notification/ExpoNotificationChannel.ts
4495
5024
  var EXPO_PUSH_API = "https://exp.host/--/api/v2/push/send";
5025
+ var EXPO_RECEIPT_API = "https://exp.host/--/api/v2/push/getReceipts";
5026
+ var RECEIPT_CHECK_DELAY_MS = 1e4;
4496
5027
  var ExpoNotificationChannel = class {
4497
5028
  tokens = /* @__PURE__ */ new Set();
4498
5029
  /** push token → WebSocket 连接映射,用于前台抑制 */
@@ -4521,7 +5052,13 @@ var ExpoNotificationChannel = class {
4521
5052
  console.log(`[ExpoNotificationChannel] ${t("notification.soundPrefsUpdated")}`);
4522
5053
  }
4523
5054
  async send(payload) {
4524
- if (this.tokens.size === 0) return;
5055
+ if (this.tokens.size === 0) {
5056
+ const isCompletion = payload.data?.type === "task_complete" || payload.data?.type === "task_error";
5057
+ if (isCompletion) {
5058
+ console.warn("[ExpoNotificationChannel] \u26A0\uFE0F \u65E0 Expo push token\uFF0C\u5B8C\u6210\u901A\u77E5\u65E0\u6CD5\u63A8\u9001\u3002\u8BF7\u786E\u8BA4\u624B\u673A\u7AEF register_push_token \u5DF2\u53D1\u9001\uFF08\u91CD\u542F App \u53EF\u5F3A\u5236\u91CD\u65B0\u6CE8\u518C\uFF09");
5059
+ }
5060
+ return;
5061
+ }
4525
5062
  const isCompletionNotif = payload.data?.type === "task_complete" || payload.data?.type === "task_error";
4526
5063
  const targetTokens = isCompletionNotif ? Array.from(this.tokens) : Array.from(this.tokens).filter((token) => {
4527
5064
  const ws = this.tokenWsMap.get(token);
@@ -4534,6 +5071,7 @@ var ExpoNotificationChannel = class {
4534
5071
  if (prefs) {
4535
5072
  const notifType = payload.data?.type ?? "";
4536
5073
  if (notifType === "approval_request" && prefs.approval) sound = prefs.approval;
5074
+ else if (notifType === "question_request" && prefs.approval) sound = prefs.approval;
4537
5075
  else if (notifType === "task_complete" && prefs.taskComplete) sound = prefs.taskComplete;
4538
5076
  else if (notifType === "task_error" && prefs.taskError) sound = prefs.taskError;
4539
5077
  }
@@ -4564,16 +5102,76 @@ var ExpoNotificationChannel = class {
4564
5102
  console.warn(`[ExpoNotificationChannel] ${t("notification.pushApiFormatError")}`, JSON.stringify(body));
4565
5103
  return;
4566
5104
  }
4567
- for (const ticket of body.data) {
5105
+ const receiptIdToToken = /* @__PURE__ */ new Map();
5106
+ for (let i = 0; i < body.data.length; i++) {
5107
+ const ticket = body.data[i];
4568
5108
  if (ticket.status === "error") {
4569
- console.error(`[ExpoNotificationChannel] ${t("notification.pushFailed")} ${ticket.message} (${ticket.details?.error ?? "unknown"})`);
5109
+ const errorCode = ticket.details?.error ?? "unknown";
5110
+ console.error(`[ExpoNotificationChannel] ${t("notification.pushFailed")} ${ticket.message} (${errorCode})`);
5111
+ if (errorCode === "DeviceNotRegistered" && targetTokens[i]) {
5112
+ const staleToken = targetTokens[i];
5113
+ this.tokens.delete(staleToken);
5114
+ this.tokenWsMap.delete(staleToken);
5115
+ this.soundPreferences.delete(staleToken);
5116
+ 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`);
5117
+ }
5118
+ } else if (ticket.status === "ok" && typeof ticket.id === "string" && targetTokens[i]) {
5119
+ receiptIdToToken.set(ticket.id, targetTokens[i]);
4570
5120
  }
4571
5121
  }
5122
+ this.scheduleReceiptCheck(receiptIdToToken);
4572
5123
  }
4573
5124
  } catch (err) {
4574
5125
  console.warn(`[ExpoNotificationChannel] ${t("notification.sendFailed")}`, err);
4575
5126
  }
4576
5127
  }
5128
+ /**
5129
+ * Expo push 二阶段:延迟查 receipt,暴露 ticket 阶段看不到的 APNs 投递失败。
5130
+ *
5131
+ * 关键诊断点:InvalidCredentials / MismatchSenderId 表示 Expo 项目的 APNs
5132
+ * 凭证配置问题(不是用户机器问题)——这正是"只有开发者能收到推送"的根因,
5133
+ * 且 ticket 全为 ok、不查 receipt 永远静默。
5134
+ */
5135
+ scheduleReceiptCheck(receiptIdToToken) {
5136
+ if (receiptIdToToken.size === 0) return;
5137
+ const timer = setTimeout(async () => {
5138
+ try {
5139
+ const ids = Array.from(receiptIdToToken.keys());
5140
+ const res = await fetch(EXPO_RECEIPT_API, {
5141
+ method: "POST",
5142
+ headers: { "Content-Type": "application/json", Accept: "application/json" },
5143
+ body: JSON.stringify({ ids })
5144
+ });
5145
+ const body = await res.json();
5146
+ if (!res.ok || !body?.data || typeof body.data !== "object") {
5147
+ console.warn("[ExpoNotificationChannel] \u26A0\uFE0F push receipt \u67E5\u8BE2\u5931\u8D25", res.status, JSON.stringify(body));
5148
+ return;
5149
+ }
5150
+ const receipts = body.data;
5151
+ for (const [receiptId, receipt] of Object.entries(receipts)) {
5152
+ if (receipt?.status !== "error") continue;
5153
+ const errorCode = receipt.details?.error ?? "unknown";
5154
+ const token = receiptIdToToken.get(receiptId);
5155
+ console.error(
5156
+ `[ExpoNotificationChannel] \u274C APNs \u6295\u9012\u5931\u8D25 receipt=${receiptId} error=${errorCode}` + (receipt.message ? ` \u2014 ${receipt.message}` : "")
5157
+ );
5158
+ if (errorCode === "DeviceNotRegistered" && token) {
5159
+ this.tokens.delete(token);
5160
+ this.tokenWsMap.delete(token);
5161
+ this.soundPreferences.delete(token);
5162
+ console.warn("[ExpoNotificationChannel] \u26A0\uFE0F \u5DF2\u79FB\u9664\u5931\u6548 token\uFF08receipt DeviceNotRegistered\uFF09\u3002\u91CD\u542F App \u53EF\u91CD\u65B0\u6CE8\u518C\u3002");
5163
+ } else if (errorCode === "InvalidCredentials" || errorCode === "MismatchSenderId") {
5164
+ console.error(
5165
+ "[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"
5166
+ );
5167
+ }
5168
+ }
5169
+ } catch (err) {
5170
+ console.warn("[ExpoNotificationChannel] \u26A0\uFE0F push receipt \u67E5\u8BE2\u5F02\u5E38:", err);
5171
+ }
5172
+ }, RECEIPT_CHECK_DELAY_MS);
5173
+ timer.unref?.();
5174
+ }
4577
5175
  };
4578
5176
 
4579
5177
  // src/notification/ActivityPushChannel.ts
@@ -4587,6 +5185,14 @@ var APNS_HOSTS = {
4587
5185
  var ActivityPushChannel = class {
4588
5186
  /** sessionId -> activityPushToken */
4589
5187
  tokens = /* @__PURE__ */ new Map();
5188
+ /** 原生 device token 集合(用于普通 alert push,绕过 Expo 推送服务) */
5189
+ alertTokens = /* @__PURE__ */ new Set();
5190
+ /** alert token -> WebSocket 映射(用于前台在线过滤) */
5191
+ alertTokenWsMap = /* @__PURE__ */ new Map();
5192
+ /** alert token 已确认的 APNs 环境(独立于 LA token 的探测结果) */
5193
+ alertTokenEnv = /* @__PURE__ */ new Map();
5194
+ /** per-alert-token 通知音效偏好 */
5195
+ alertSoundPreferences = /* @__PURE__ */ new Map();
4590
5196
  /**
4591
5197
  * 每个 token 已确认工作的 APNs 环境。
4592
5198
  * Debug build (aps-environment=development) 的 token 仅在 sandbox 端有效;
@@ -4605,6 +5211,7 @@ var ActivityPushChannel = class {
4605
5211
  teamId;
4606
5212
  keyId;
4607
5213
  authKey;
5214
+ bundleId;
4608
5215
  /** 缓存的 JWT token + 过期时间 */
4609
5216
  cachedJwt = null;
4610
5217
  /** 每个环境一条 HTTP/2 长连接 */
@@ -4613,6 +5220,7 @@ var ActivityPushChannel = class {
4613
5220
  this.teamId = config.teamId;
4614
5221
  this.keyId = config.keyId;
4615
5222
  this.authKey = fs2.readFileSync(config.authKeyPath, "utf-8");
5223
+ this.bundleId = config.bundleId ?? "com.kachun.sessix";
4616
5224
  this.probeOrder = config.sandbox === false ? ["production", "sandbox"] : ["sandbox", "production"];
4617
5225
  console.log(`[ActivityPushChannel] Initialized (probe order: ${this.probeOrder.join(" \u2192 ")})`);
4618
5226
  }
@@ -4657,557 +5265,335 @@ var ActivityPushChannel = class {
4657
5265
  /** 发送 content-state 更新到指定会话的 Live Activity(纯内容刷新,不响通知)。返回 true 表示实际发出 */
4658
5266
  async updateActivity(sessionId, contentState, opts) {
4659
5267
  const token = this.tokens.get(sessionId);
4660
- if (!token) return false;
4661
- const now = Math.floor(Date.now() / 1e3);
4662
- const priority = opts?.priority ?? "5";
4663
- const payload = {
4664
- aps: {
4665
- timestamp: now,
4666
- event: "update",
4667
- "content-state": contentState,
4668
- "stale-date": now + 600
4669
- }
4670
- };
4671
- try {
4672
- await this.sendToAPNs(token, payload, {
4673
- priority,
4674
- collapseId: `state-${sessionId.slice(0, 54)}`
4675
- });
4676
- return true;
4677
- } catch (err) {
4678
- console.warn(`[ActivityPushChannel] Update failed session=${sessionId}:`, err);
4679
- return false;
4680
- }
4681
- }
4682
- /** 发送带通知的 content-state 更新(审批请求时使用),返回是否发送成功 */
4683
- async updateActivityWithAlert(sessionId, contentState, alert) {
4684
- const token = this.tokens.get(sessionId);
4685
- if (!token) return false;
4686
- const now = Math.floor(Date.now() / 1e3);
4687
- const payload = {
4688
- aps: {
4689
- timestamp: now,
4690
- event: "update",
4691
- "content-state": contentState,
4692
- "stale-date": now + 600,
4693
- alert,
4694
- sound: "default"
4695
- }
4696
- };
4697
- try {
4698
- await this.sendToAPNs(token, payload, { priority: "10" });
4699
- return true;
4700
- } catch (err) {
4701
- console.warn(`[ActivityPushChannel] Alert update failed session=${sessionId}:`, err);
4702
- return false;
4703
- }
4704
- }
4705
- /**
4706
- * 结束指定会话的 Live Activity。
4707
- * 可选 alert:APNs event=end 时同时推送横幅通知 + 声音,用于在会话完成时提醒用户。
4708
- */
4709
- async endActivity(sessionId, contentState, opts) {
4710
- const token = this.tokens.get(sessionId);
4711
- if (!token) return;
4712
- const now = Math.floor(Date.now() / 1e3);
4713
- const aps = {
4714
- timestamp: now,
4715
- event: "end",
4716
- "content-state": contentState
4717
- };
4718
- if (opts?.alert) {
4719
- aps.alert = opts.alert;
4720
- aps.sound = "default";
4721
- }
4722
- const payload = { aps };
4723
- try {
4724
- await this.sendToAPNs(token, payload, { priority: "10" });
4725
- } catch (err) {
4726
- this.tokens.delete(sessionId);
4727
- throw err;
4728
- }
4729
- this.tokens.delete(sessionId);
4730
- }
4731
- /** 检查是否有指定会话的 token */
4732
- hasToken(sessionId) {
4733
- return this.tokens.has(sessionId);
4734
- }
4735
- /**
4736
- * 发送 APNs,自动处理环境探测。
4737
- * 对每个 token:先用已确认的环境(如有);否则按 probeOrder 顺序探测,
4738
- * 收到 BadDeviceToken / BadEnvironmentKeyInToken 自动切到另一个环境,
4739
- * 并把成功的环境绑定到该 token。
4740
- */
4741
- async sendToAPNs(deviceToken, payload, opts = {}) {
4742
- if (this.deadTokens.has(deviceToken)) {
4743
- throw new Error(`token permanently dead (Activity ended): ${deviceToken.slice(0, 16)}\u2026`);
4744
- }
4745
- const known = this.tokenEnv.get(deviceToken);
4746
- if (known) {
4747
- return this.sendToAPNsOnce(deviceToken, payload, opts, known);
4748
- }
4749
- const short = deviceToken.slice(0, 16);
4750
- console.log(`[ActivityPushChannel] \u{1F50D} probe start token=${short}\u2026 order=[${this.probeOrder.join(",")}]`);
4751
- let lastErr = null;
4752
- for (const env of this.probeOrder) {
4753
- try {
4754
- console.log(`[ActivityPushChannel] \u{1F50D} probe try ${env} token=${short}\u2026`);
4755
- await this.sendToAPNsOnce(deviceToken, payload, opts, env);
4756
- this.tokenEnv.set(deviceToken, env);
4757
- console.log(`[ActivityPushChannel] \u2705 probe bound to ${env} (token=${short}\u2026)`);
4758
- return;
4759
- } catch (err) {
4760
- lastErr = err;
4761
- const reason = err instanceof ApnsError ? JSON.parse(err.responseBody || "{}")?.reason ?? err.statusCode : String(err);
4762
- console.log(`[ActivityPushChannel] \u{1F50D} probe ${env} failed: ${reason}`);
4763
- if (!isBadDeviceTokenError(err)) {
4764
- throw err;
4765
- }
4766
- }
4767
- }
4768
- this.deadTokens.add(deviceToken);
4769
- for (const [sid, tok] of this.tokens) {
4770
- if (tok === deviceToken) {
4771
- this.tokens.delete(sid);
4772
- console.warn(`[ActivityPushChannel] \u2620\uFE0F dead token, session removed: session=${sid.slice(0, 8)}\u2026 token=${short}\u2026 (Activity ended/iOS reinstalled)`);
4773
- break;
4774
- }
4775
- }
4776
- throw lastErr ?? new Error("APNs send failed: all environments rejected token");
4777
- }
4778
- /** 单次 APNs HTTP/2 请求(内部 helper,不做探测) */
4779
- async sendToAPNsOnce(deviceToken, payload, opts, env) {
4780
- const topic = "com.kachun.sessix.push-type.liveactivity";
4781
- const jwt = this.getJWT();
4782
- const payloadStr = JSON.stringify(payload);
4783
- const priority = opts.priority ?? "10";
4784
- return new Promise((resolve, reject) => {
4785
- let client;
4786
- try {
4787
- client = this.getHttp2Client(env);
4788
- } catch (err) {
4789
- return reject(err);
4790
- }
4791
- const headers = {
4792
- ":method": "POST",
4793
- ":path": `/3/device/${deviceToken}`,
4794
- "authorization": `bearer ${jwt}`,
4795
- "apns-topic": topic,
4796
- "apns-push-type": "liveactivity",
4797
- "apns-priority": priority,
4798
- "apns-expiration": String(Math.floor(Date.now() / 1e3) + 300),
4799
- "content-type": "application/json",
4800
- "content-length": Buffer.byteLength(payloadStr)
4801
- };
4802
- if (opts.collapseId) {
4803
- headers["apns-collapse-id"] = opts.collapseId;
4804
- }
4805
- const req = client.request(headers);
4806
- let statusCode = 0;
4807
- let responseData = "";
4808
- req.on("response", (headers2) => {
4809
- statusCode = Number(headers2[":status"] ?? 0);
4810
- });
4811
- req.on("data", (chunk) => {
4812
- responseData += chunk;
4813
- });
4814
- req.on("end", () => {
4815
- if (statusCode === 200) {
4816
- resolve();
4817
- } else {
4818
- if (statusCode === 0) {
4819
- const c = this.http2Clients[env];
4820
- c?.destroy();
4821
- delete this.http2Clients[env];
4822
- }
4823
- reject(new ApnsError(statusCode, responseData));
4824
- }
4825
- });
4826
- req.on("error", (err) => {
4827
- reject(err);
4828
- });
4829
- req.write(payloadStr);
4830
- req.end();
4831
- });
4832
- }
4833
- /** 生成或获取缓存的 APNs JWT token */
4834
- getJWT() {
4835
- const now = Math.floor(Date.now() / 1e3);
4836
- if (this.cachedJwt && this.cachedJwt.expiresAt > now) {
4837
- return this.cachedJwt.token;
4838
- }
4839
- const header = Buffer.from(JSON.stringify({
4840
- alg: "ES256",
4841
- kid: this.keyId
4842
- })).toString("base64url");
4843
- const claims = Buffer.from(JSON.stringify({
4844
- iss: this.teamId,
4845
- iat: now
4846
- })).toString("base64url");
4847
- const signingInput = `${header}.${claims}`;
4848
- const sign = crypto.createSign("SHA256");
4849
- sign.update(signingInput);
4850
- const signature = sign.sign({ key: this.authKey, dsaEncoding: "ieee-p1363" }, "base64url");
4851
- const token = `${signingInput}.${signature}`;
4852
- this.cachedJwt = { token, expiresAt: now + 3e3 };
4853
- return token;
4854
- }
4855
- };
4856
- var ApnsError = class extends Error {
4857
- constructor(statusCode, responseBody) {
4858
- super(`APNs returned ${statusCode}: ${responseBody}`);
4859
- this.statusCode = statusCode;
4860
- this.responseBody = responseBody;
4861
- this.name = "ApnsError";
4862
- }
4863
- };
4864
- function isBadDeviceTokenError(err) {
4865
- if (!(err instanceof ApnsError)) return false;
4866
- if (err.statusCode !== 400 && err.statusCode !== 403 && err.statusCode !== 410) return false;
4867
- try {
4868
- const parsed = JSON.parse(err.responseBody);
4869
- return parsed.reason === "BadDeviceToken" || parsed.reason === "BadEnvironmentKeyInToken" || parsed.reason === "Unregistered";
4870
- } catch {
4871
- return false;
4872
- }
4873
- }
4874
-
4875
- // src/session/ProjectReader.ts
4876
- var import_promises3 = require("fs/promises");
4877
- var import_readline3 = require("readline");
4878
- var import_path2 = require("path");
4879
- var import_os2 = require("os");
4880
- var CLAUDE_PROJECTS_DIR = (0, import_path2.join)((0, import_os2.homedir)(), ".claude", "projects");
4881
- function getSessionFilePath(projectPath, sessionId) {
4882
- return (0, import_path2.join)(CLAUDE_PROJECTS_DIR, encodeDirName(projectPath), `${sessionId}.jsonl`);
4883
- }
4884
- async function getProjects() {
4885
- try {
4886
- const dirExists = await directoryExists(CLAUDE_PROJECTS_DIR);
4887
- if (!dirExists) {
4888
- return { ok: true, value: [] };
4889
- }
4890
- const entries = await (0, import_promises3.readdir)(CLAUDE_PROJECTS_DIR, { withFileTypes: true });
4891
- const projects = [];
4892
- for (const entry of entries) {
4893
- if (!entry.isDirectory() || entry.name.startsWith(".")) {
4894
- continue;
5268
+ if (!token) return false;
5269
+ const now = Math.floor(Date.now() / 1e3);
5270
+ const priority = opts?.priority ?? "5";
5271
+ const payload = {
5272
+ aps: {
5273
+ timestamp: now,
5274
+ event: "update",
5275
+ "content-state": contentState,
5276
+ "stale-date": now + 600
4895
5277
  }
4896
- const encodedPath = entry.name;
4897
- const decodedPath = decodeDirName(encodedPath);
4898
- const name = decodedPath.split("/").filter(Boolean).pop() ?? encodedPath;
4899
- const projectDir = (0, import_path2.join)(CLAUDE_PROJECTS_DIR, encodedPath);
4900
- const { count: sessionCount, latestMtime } = await countJsonlFilesWithMtime(projectDir);
4901
- projects.push({
4902
- id: encodedPath,
4903
- path: decodedPath,
4904
- name,
4905
- sessionCount,
4906
- lastActiveAt: latestMtime
5278
+ };
5279
+ try {
5280
+ await this.sendToAPNs(token, payload, {
5281
+ priority,
5282
+ collapseId: `state-${sessionId.slice(0, 54)}`
4907
5283
  });
5284
+ return true;
5285
+ } catch (err) {
5286
+ console.warn(`[ActivityPushChannel] Update failed session=${sessionId}:`, err);
5287
+ return false;
4908
5288
  }
4909
- projects.sort((a, b) => a.name.localeCompare(b.name));
4910
- return { ok: true, value: projects };
4911
- } catch (err) {
4912
- return {
4913
- ok: false,
4914
- error: err instanceof Error ? err : new Error(String(err))
4915
- };
4916
5289
  }
4917
- }
4918
- async function getHistoricalSessions(projectPath) {
4919
- try {
4920
- const encodedPath = encodeDirName(projectPath);
4921
- const projectDir = (0, import_path2.join)(CLAUDE_PROJECTS_DIR, encodedPath);
4922
- const dirExists = await directoryExists(projectDir);
4923
- if (!dirExists) {
4924
- return { ok: true, value: [] };
4925
- }
4926
- const entries = await (0, import_promises3.readdir)(projectDir, { withFileTypes: true });
4927
- const jsonlFiles = entries.filter((e) => e.isFile() && e.name.endsWith(".jsonl"));
4928
- const mtimeMap = /* @__PURE__ */ new Map();
4929
- await Promise.all(
4930
- jsonlFiles.map(async (entry) => {
4931
- const sessionId = entry.name.slice(0, -6);
4932
- const filePath = (0, import_path2.join)(projectDir, entry.name);
4933
- try {
4934
- const contentTs = await extractLastTimestamp(filePath);
4935
- if (contentTs) {
4936
- mtimeMap.set(sessionId, contentTs);
4937
- } else {
4938
- const fileStat = await (0, import_promises3.stat)(filePath);
4939
- mtimeMap.set(sessionId, fileStat.mtimeMs);
4940
- }
4941
- } catch {
4942
- mtimeMap.set(sessionId, 0);
4943
- }
4944
- })
4945
- );
4946
- const uuidDirs = entries.filter(
4947
- (e) => e.isDirectory() && UUID_RE.test(e.name) && !mtimeMap.has(e.name)
4948
- );
4949
- for (const entry of uuidDirs) {
4950
- try {
4951
- const fileStat = await (0, import_promises3.stat)((0, import_path2.join)(projectDir, entry.name));
4952
- mtimeMap.set(entry.name, fileStat.mtimeMs);
4953
- } catch {
4954
- mtimeMap.set(entry.name, 0);
5290
+ /** 发送带通知的 content-state 更新(审批请求时使用),返回是否发送成功 */
5291
+ async updateActivityWithAlert(sessionId, contentState, alert) {
5292
+ const token = this.tokens.get(sessionId);
5293
+ if (!token) return false;
5294
+ const now = Math.floor(Date.now() / 1e3);
5295
+ const payload = {
5296
+ aps: {
5297
+ timestamp: now,
5298
+ event: "update",
5299
+ "content-state": contentState,
5300
+ "stale-date": now + 600,
5301
+ alert,
5302
+ sound: "default"
4955
5303
  }
5304
+ };
5305
+ try {
5306
+ await this.sendToAPNs(token, payload, { priority: "10" });
5307
+ return true;
5308
+ } catch (err) {
5309
+ console.warn(`[ActivityPushChannel] Alert update failed session=${sessionId}:`, err);
5310
+ return false;
4956
5311
  }
4957
- const indexPath = (0, import_path2.join)(projectDir, "sessions-index.json");
4958
- const sessionMap = /* @__PURE__ */ new Map();
5312
+ }
5313
+ /**
5314
+ * 结束指定会话的 Live Activity。
5315
+ * 可选 alert:APNs event=end 时同时推送横幅通知 + 声音,用于在会话完成时提醒用户。
5316
+ */
5317
+ async endActivity(sessionId, contentState, opts) {
5318
+ const token = this.tokens.get(sessionId);
5319
+ if (!token) return;
5320
+ const now = Math.floor(Date.now() / 1e3);
5321
+ const aps = {
5322
+ timestamp: now,
5323
+ event: "end",
5324
+ "content-state": contentState
5325
+ };
5326
+ if (opts?.alert) {
5327
+ aps.alert = opts.alert;
5328
+ aps.sound = "default";
5329
+ }
5330
+ const payload = { aps };
4959
5331
  try {
4960
- const indexContent = await (0, import_promises3.readFile)(indexPath, "utf-8");
4961
- const indexData = JSON.parse(indexContent);
4962
- if (indexData.version === 1 && Array.isArray(indexData.entries)) {
4963
- for (const entry of indexData.entries) {
4964
- const mtime = mtimeMap.get(entry.sessionId) ?? entry.fileMtime ?? (entry.modified ? new Date(entry.modified).getTime() : 0);
4965
- sessionMap.set(entry.sessionId, {
4966
- sessionId: entry.sessionId,
4967
- lastModified: mtime,
4968
- summary: entry.summary,
4969
- firstPrompt: entry.firstPrompt,
4970
- messageCount: entry.messageCount
4971
- });
4972
- }
4973
- await Promise.all(
4974
- Array.from(sessionMap.values()).filter((s) => (s.messageCount ?? 0) > 0 && !s.summary && !s.firstPrompt).map(async (s) => {
4975
- const filePath = (0, import_path2.join)(projectDir, `${s.sessionId}.jsonl`);
4976
- const firstPrompt = await extractFirstPrompt(filePath).catch(() => void 0);
4977
- if (firstPrompt) s.firstPrompt = firstPrompt;
4978
- })
4979
- );
4980
- }
4981
- } catch {
5332
+ await this.sendToAPNs(token, payload, { priority: "10" });
5333
+ } catch (err) {
5334
+ this.tokens.delete(sessionId);
5335
+ throw err;
4982
5336
  }
4983
- const uuidDirSet = new Set(uuidDirs.map((e) => e.name));
4984
- for (const [sessionId, mtime] of mtimeMap) {
4985
- if (!sessionMap.has(sessionId)) {
4986
- if (uuidDirSet.has(sessionId)) {
4987
- sessionMap.set(sessionId, { sessionId, lastModified: mtime, messageCount: -1 });
4988
- } else {
4989
- const filePath = (0, import_path2.join)(projectDir, `${sessionId}.jsonl`);
4990
- const firstPrompt = await extractFirstPrompt(filePath).catch(() => void 0);
4991
- sessionMap.set(sessionId, { sessionId, lastModified: mtime, firstPrompt });
4992
- }
4993
- }
5337
+ this.tokens.delete(sessionId);
5338
+ }
5339
+ /** 检查是否有指定会话的 token */
5340
+ hasToken(sessionId) {
5341
+ return this.tokens.has(sessionId);
5342
+ }
5343
+ // ============================================
5344
+ // 普通 Alert Push(直连 APNs,绕过 Expo 推送服务)
5345
+ // ============================================
5346
+ /** 注册原生 APNs device token,用于发送普通 alert 通知 */
5347
+ addAlertToken(token, ws) {
5348
+ this.alertTokens.add(token);
5349
+ if (ws) this.alertTokenWsMap.set(token, ws);
5350
+ console.log(`[ActivityPushChannel] Alert token registered (${this.alertTokens.size} device(s))`);
5351
+ }
5352
+ /** 移除原生 APNs device token */
5353
+ removeAlertToken(token) {
5354
+ this.alertTokens.delete(token);
5355
+ this.alertTokenWsMap.delete(token);
5356
+ this.alertTokenEnv.delete(token);
5357
+ this.alertSoundPreferences.delete(token);
5358
+ }
5359
+ /** 是否有可用的 alert token */
5360
+ hasAlertTokens() {
5361
+ return this.alertTokens.size > 0;
5362
+ }
5363
+ /** 更新 alert token 音效偏好 */
5364
+ setAlertSoundPreferences(prefs) {
5365
+ for (const token of this.alertTokens) {
5366
+ this.alertSoundPreferences.set(token, prefs);
4994
5367
  }
4995
- const sessions = Array.from(sessionMap.values()).filter((s) => {
4996
- if (s.messageCount === 0) return false;
4997
- if (s.messageCount === -1) return true;
4998
- if (s.firstPrompt === void 0 && s.messageCount === void 0) return false;
4999
- return true;
5000
- });
5001
- sessions.sort((a, b) => b.lastModified - a.lastModified);
5002
- return { ok: true, value: sessions };
5003
- } catch (err) {
5004
- return {
5005
- ok: false,
5006
- error: err instanceof Error ? err : new Error(String(err))
5007
- };
5008
5368
  }
5009
- }
5010
- async function getSessionHistory(projectPath, sessionId) {
5011
- try {
5012
- const encodedPath = encodeDirName(projectPath);
5013
- const filePath = (0, import_path2.join)(CLAUDE_PROJECTS_DIR, encodedPath, `${sessionId}.jsonl`);
5014
- const raw = await (0, import_promises3.readFile)(filePath, "utf-8").catch((err) => {
5015
- if (err.code === "ENOENT") return null;
5016
- throw err;
5369
+ /**
5370
+ * 发送普通 alert 推送通知(直连 APNs,sandbox/production 自动探测)。
5371
+ * 实现 NotificationChannel.send 接口,可注册到 NotificationService。
5372
+ */
5373
+ async send(payload) {
5374
+ if (this.alertTokens.size === 0) return;
5375
+ const isCompletionNotif = payload.data?.type === "task_complete" || payload.data?.type === "task_error";
5376
+ const targetTokens = isCompletionNotif ? Array.from(this.alertTokens) : Array.from(this.alertTokens).filter((token) => {
5377
+ const ws = this.alertTokenWsMap.get(token);
5378
+ return !ws || ws.readyState !== ws.OPEN;
5017
5379
  });
5018
- if (raw === null) return { ok: true, value: [] };
5019
- const lines = raw.split("\n").filter((l) => l.trim());
5020
- const events = [];
5021
- for (const line of lines) {
5022
- try {
5023
- const obj = JSON.parse(line);
5024
- const type = obj.type;
5025
- if (type === "user" && obj.message) {
5026
- const msgContent = obj.message.content;
5027
- if (typeof msgContent === "string") {
5028
- if (msgContent.includes("<local-command") || msgContent.includes("<command-name>")) continue;
5029
- } else if (Array.isArray(msgContent)) {
5030
- const hasText = msgContent.some(
5031
- (b) => b.type === "text" && !b.text?.includes("<local-command") && !b.text?.includes("<command-name>")
5032
- );
5033
- if (!hasText) continue;
5034
- }
5035
- const normalizedContent = typeof msgContent === "string" ? [{ type: "text", text: msgContent }] : Array.isArray(msgContent) ? msgContent.filter((b) => b.type === "text" && typeof b.text === "string") : [];
5036
- if (normalizedContent.length === 0) continue;
5037
- events.push({
5038
- type: "user",
5039
- message: {
5040
- ...obj.message,
5041
- content: normalizedContent
5042
- },
5043
- session_id: sessionId
5044
- });
5045
- } else if (type === "assistant" && obj.message) {
5046
- const content = (obj.message.content ?? []).filter(
5047
- (b) => b.type === "text" || b.type === "tool_use" || b.type === "thinking"
5048
- );
5049
- if (content.length === 0) continue;
5050
- events.push({
5051
- type: "assistant",
5052
- message: {
5053
- id: obj.message.id ?? obj.uuid ?? `hist-${events.length}`,
5054
- model: obj.message.model ?? "unknown",
5055
- role: "assistant",
5056
- content,
5057
- stop_reason: obj.message.stop_reason,
5058
- usage: obj.message.usage
5059
- },
5060
- session_id: sessionId
5061
- });
5062
- }
5063
- } catch {
5380
+ if (targetTokens.length === 0) return;
5381
+ console.log(`[ActivityPushChannel] Alert push \u2192 ${targetTokens.length}/${this.alertTokens.size} device(s)${isCompletionNotif ? " (forced)" : ""}`);
5382
+ await Promise.all(targetTokens.map(async (deviceToken) => {
5383
+ let sound = payload.sound ?? "default";
5384
+ const prefs = this.alertSoundPreferences.get(deviceToken);
5385
+ if (prefs) {
5386
+ const notifType = payload.data?.type ?? "";
5387
+ if (notifType === "approval_request" && prefs.approval) sound = prefs.approval;
5388
+ else if (notifType === "task_complete" && prefs.taskComplete) sound = prefs.taskComplete;
5389
+ else if (notifType === "task_error" && prefs.taskError) sound = prefs.taskError;
5064
5390
  }
5065
- }
5066
- if (events.length > 0) {
5067
- let totalInputTokens = 0;
5068
- let totalOutputTokens = 0;
5069
- for (const ev of events) {
5070
- if (ev.type === "assistant" && ev.message.usage) {
5071
- totalInputTokens += ev.message.usage.input_tokens ?? 0;
5072
- totalOutputTokens += ev.message.usage.output_tokens ?? 0;
5073
- }
5391
+ const apnsPayload = {
5392
+ aps: {
5393
+ alert: { title: payload.title, ...payload.subtitle ? { subtitle: payload.subtitle } : {}, body: payload.body },
5394
+ ...payload.badge !== void 0 ? { badge: payload.badge } : {},
5395
+ ...sound && sound !== "none" ? { sound } : {},
5396
+ ...payload.categoryId ? { category: payload.categoryId } : {}
5397
+ },
5398
+ ...payload.data ?? {}
5399
+ };
5400
+ try {
5401
+ await this.sendAlertToAPNs(deviceToken, apnsPayload);
5402
+ } catch (err) {
5403
+ console.warn(`[ActivityPushChannel] Alert push failed for token ${deviceToken.slice(0, 16)}\u2026:`, err instanceof Error ? err.message : err);
5074
5404
  }
5075
- if (totalInputTokens > 0 || totalOutputTokens > 0) {
5076
- events.push({
5077
- type: "result",
5078
- subtype: "success",
5079
- is_error: false,
5080
- duration_ms: 0,
5081
- num_turns: events.filter((e) => e.type === "user").length,
5082
- result: "",
5083
- session_id: sessionId,
5084
- usage: { input_tokens: totalInputTokens, output_tokens: totalOutputTokens }
5085
- });
5405
+ }));
5406
+ }
5407
+ /** isAvailable 实现(NotificationChannel 接口) */
5408
+ isAvailable() {
5409
+ return this.alertTokens.size > 0;
5410
+ }
5411
+ /**
5412
+ * 发送 alert 通知到指定 device token,自动探测 sandbox/production。
5413
+ * 使用独立的 alertTokenEnv 映射(与 LA token 环境探测隔离)。
5414
+ */
5415
+ async sendAlertToAPNs(deviceToken, payload) {
5416
+ const known = this.alertTokenEnv.get(deviceToken);
5417
+ if (known) {
5418
+ return this.sendToAPNsOnce(deviceToken, payload, { priority: "10", pushType: "alert", topic: this.bundleId }, known);
5419
+ }
5420
+ const short = deviceToken.slice(0, 16);
5421
+ console.log(`[ActivityPushChannel] \u{1F50D} alert probe start token=${short}\u2026 order=[${this.probeOrder.join(",")}]`);
5422
+ let lastErr = null;
5423
+ for (const env of this.probeOrder) {
5424
+ try {
5425
+ console.log(`[ActivityPushChannel] \u{1F50D} alert probe try ${env} token=${short}\u2026`);
5426
+ await this.sendToAPNsOnce(deviceToken, payload, { priority: "10", pushType: "alert", topic: this.bundleId }, env);
5427
+ this.alertTokenEnv.set(deviceToken, env);
5428
+ console.log(`[ActivityPushChannel] \u2705 alert probe bound to ${env} (token=${short}\u2026)`);
5429
+ return;
5430
+ } catch (err) {
5431
+ lastErr = err;
5432
+ const reason = err instanceof ApnsError ? JSON.parse(err.responseBody || "{}")?.reason ?? err.statusCode : String(err);
5433
+ console.log(`[ActivityPushChannel] \u{1F50D} alert probe ${env} failed: ${reason}`);
5434
+ if (isProviderTokenError(err)) throw err;
5435
+ if (!isBadDeviceTokenError(err)) throw err;
5086
5436
  }
5087
5437
  }
5088
- return { ok: true, value: events };
5089
- } catch (err) {
5090
- return {
5091
- ok: false,
5092
- error: err instanceof Error ? err : new Error(String(err))
5093
- };
5438
+ throw lastErr ?? new Error("APNs alert send failed: all environments rejected token");
5094
5439
  }
5095
- }
5096
- async function extractLastTimestamp(filePath) {
5097
- let fileHandle;
5098
- try {
5099
- fileHandle = await (0, import_promises3.open)(filePath, "r");
5100
- const fileStat = await fileHandle.stat();
5101
- const readSize = Math.min(fileStat.size, 8192);
5102
- const buffer = Buffer.alloc(readSize);
5103
- await fileHandle.read(buffer, 0, readSize, fileStat.size - readSize);
5104
- const tail = buffer.toString("utf-8");
5105
- const lines = tail.split("\n").filter((l) => l.trim());
5106
- for (let i = lines.length - 1; i >= 0; i--) {
5440
+ /**
5441
+ * 发送 APNs,自动处理环境探测。
5442
+ * 对每个 token:先用已确认的环境(如有);否则按 probeOrder 顺序探测,
5443
+ * 收到 BadDeviceToken / BadEnvironmentKeyInToken 自动切到另一个环境,
5444
+ * 并把成功的环境绑定到该 token。
5445
+ */
5446
+ async sendToAPNs(deviceToken, payload, opts = {}) {
5447
+ if (this.deadTokens.has(deviceToken)) {
5448
+ throw new Error(`token permanently dead (Activity ended): ${deviceToken.slice(0, 16)}\u2026`);
5449
+ }
5450
+ const known = this.tokenEnv.get(deviceToken);
5451
+ if (known) {
5452
+ return this.sendToAPNsOnce(deviceToken, payload, opts, known);
5453
+ }
5454
+ const short = deviceToken.slice(0, 16);
5455
+ console.log(`[ActivityPushChannel] \u{1F50D} probe start token=${short}\u2026 order=[${this.probeOrder.join(",")}]`);
5456
+ let lastErr = null;
5457
+ for (const env of this.probeOrder) {
5107
5458
  try {
5108
- const obj = JSON.parse(lines[i]);
5109
- if (obj.timestamp) {
5110
- const ts = new Date(obj.timestamp).getTime();
5111
- if (!isNaN(ts)) return ts;
5459
+ console.log(`[ActivityPushChannel] \u{1F50D} probe try ${env} token=${short}\u2026`);
5460
+ await this.sendToAPNsOnce(deviceToken, payload, opts, env);
5461
+ this.tokenEnv.set(deviceToken, env);
5462
+ console.log(`[ActivityPushChannel] \u2705 probe bound to ${env} (token=${short}\u2026)`);
5463
+ return;
5464
+ } catch (err) {
5465
+ lastErr = err;
5466
+ const reason = err instanceof ApnsError ? JSON.parse(err.responseBody || "{}")?.reason ?? err.statusCode : String(err);
5467
+ console.log(`[ActivityPushChannel] \u{1F50D} probe ${env} failed: ${reason}`);
5468
+ if (isProviderTokenError(err)) {
5469
+ console.error(
5470
+ `[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`
5471
+ );
5472
+ throw err;
5473
+ }
5474
+ if (!isBadDeviceTokenError(err)) {
5475
+ throw err;
5112
5476
  }
5113
- } catch {
5114
5477
  }
5115
5478
  }
5116
- } catch {
5117
- } finally {
5118
- await fileHandle?.close();
5479
+ this.deadTokens.add(deviceToken);
5480
+ for (const [sid, tok] of this.tokens) {
5481
+ if (tok === deviceToken) {
5482
+ this.tokens.delete(sid);
5483
+ console.warn(`[ActivityPushChannel] \u2620\uFE0F dead token, session removed: session=${sid.slice(0, 8)}\u2026 token=${short}\u2026 (Activity ended/iOS reinstalled)`);
5484
+ break;
5485
+ }
5486
+ }
5487
+ throw lastErr ?? new Error("APNs send failed: all environments rejected token");
5119
5488
  }
5120
- return void 0;
5121
- }
5122
- async function extractFirstPrompt(filePath) {
5123
- let fileHandle;
5124
- try {
5125
- fileHandle = await (0, import_promises3.open)(filePath, "r");
5126
- const rl = (0, import_readline3.createInterface)({
5127
- input: fileHandle.createReadStream({ encoding: "utf-8" }),
5128
- crlfDelay: Infinity
5129
- });
5130
- let lineCount = 0;
5131
- for await (const line of rl) {
5132
- if (++lineCount > 20) break;
5133
- if (!line.trim()) continue;
5489
+ /** 单次 APNs HTTP/2 请求(内部 helper,不做探测) */
5490
+ async sendToAPNsOnce(deviceToken, payload, opts, env) {
5491
+ const topic = opts.topic ?? `${this.bundleId}.push-type.liveactivity`;
5492
+ const pushType = opts.pushType ?? "liveactivity";
5493
+ const jwt = this.getJWT();
5494
+ const payloadStr = JSON.stringify(payload);
5495
+ const priority = opts.priority ?? "10";
5496
+ return new Promise((resolve, reject) => {
5497
+ let client;
5134
5498
  try {
5135
- const obj = JSON.parse(line);
5136
- if (obj.type === "user" && obj.message) {
5137
- const msgContent = obj.message.content;
5138
- let text = "";
5139
- if (typeof msgContent === "string") {
5140
- text = msgContent;
5141
- } else if (Array.isArray(msgContent)) {
5142
- const textBlock = msgContent.find((b) => b.type === "text" && typeof b.text === "string");
5143
- text = textBlock?.text ?? "";
5144
- }
5145
- if (text && !text.includes("<local-command") && !text.includes("<command-name>")) {
5146
- text = text.replace(/<ide_opened_file>[\s\S]*?<\/ide_opened_file>/gi, "");
5147
- text = text.replace(/<[^>]+>/g, "").trim();
5148
- rl.close();
5149
- return text.length > 80 ? text.slice(0, 80) + "..." : text;
5499
+ client = this.getHttp2Client(env);
5500
+ } catch (err) {
5501
+ return reject(err);
5502
+ }
5503
+ const headers = {
5504
+ ":method": "POST",
5505
+ ":path": `/3/device/${deviceToken}`,
5506
+ "authorization": `bearer ${jwt}`,
5507
+ "apns-topic": topic,
5508
+ "apns-push-type": pushType,
5509
+ "apns-priority": priority,
5510
+ "apns-expiration": String(Math.floor(Date.now() / 1e3) + 300),
5511
+ "content-type": "application/json",
5512
+ "content-length": Buffer.byteLength(payloadStr)
5513
+ };
5514
+ if (opts.collapseId) {
5515
+ headers["apns-collapse-id"] = opts.collapseId;
5516
+ }
5517
+ const req = client.request(headers);
5518
+ req.setTimeout(1e4, () => {
5519
+ req.close(http2.constants.NGHTTP2_CANCEL);
5520
+ });
5521
+ let statusCode = 0;
5522
+ let responseData = "";
5523
+ req.on("response", (headers2) => {
5524
+ statusCode = Number(headers2[":status"] ?? 0);
5525
+ });
5526
+ req.on("data", (chunk) => {
5527
+ responseData += chunk;
5528
+ });
5529
+ req.on("end", () => {
5530
+ if (statusCode === 200) {
5531
+ resolve();
5532
+ } else {
5533
+ if (statusCode === 0) {
5534
+ const c = this.http2Clients[env];
5535
+ c?.destroy();
5536
+ delete this.http2Clients[env];
5150
5537
  }
5538
+ reject(new ApnsError(statusCode, responseData));
5151
5539
  }
5152
- } catch {
5153
- }
5540
+ });
5541
+ req.on("error", (err) => {
5542
+ reject(err);
5543
+ });
5544
+ req.write(payloadStr);
5545
+ req.end();
5546
+ });
5547
+ }
5548
+ /** 生成或获取缓存的 APNs JWT token */
5549
+ getJWT() {
5550
+ const now = Math.floor(Date.now() / 1e3);
5551
+ if (this.cachedJwt && this.cachedJwt.expiresAt > now) {
5552
+ return this.cachedJwt.token;
5154
5553
  }
5155
- } catch {
5156
- } finally {
5157
- await fileHandle?.close();
5554
+ const header = Buffer.from(JSON.stringify({
5555
+ alg: "ES256",
5556
+ kid: this.keyId
5557
+ })).toString("base64url");
5558
+ const claims = Buffer.from(JSON.stringify({
5559
+ iss: this.teamId,
5560
+ iat: now
5561
+ })).toString("base64url");
5562
+ const signingInput = `${header}.${claims}`;
5563
+ const sign = crypto.createSign("SHA256");
5564
+ sign.update(signingInput);
5565
+ const signature = sign.sign({ key: this.authKey, dsaEncoding: "ieee-p1363" }, "base64url");
5566
+ const token = `${signingInput}.${signature}`;
5567
+ this.cachedJwt = { token, expiresAt: now + 3e3 };
5568
+ return token;
5158
5569
  }
5159
- return void 0;
5160
- }
5161
- function decodeDirName(dirName) {
5162
- const placeholder = "\0";
5163
- const escaped = dirName.replace(/--/g, placeholder);
5164
- const decoded = escaped.replace(/-/g, "/");
5165
- return decoded.replace(new RegExp(placeholder, "g"), "-");
5166
- }
5167
- function encodeDirName(path2) {
5168
- const escaped = path2.replace(/-/g, "--");
5169
- return escaped.replace(/\//g, "-");
5170
- }
5171
- async function directoryExists(dirPath) {
5570
+ };
5571
+ var ApnsError = class extends Error {
5572
+ constructor(statusCode, responseBody) {
5573
+ super(`APNs returned ${statusCode}: ${responseBody}`);
5574
+ this.statusCode = statusCode;
5575
+ this.responseBody = responseBody;
5576
+ this.name = "ApnsError";
5577
+ }
5578
+ };
5579
+ function isProviderTokenError(err) {
5580
+ if (!(err instanceof ApnsError)) return false;
5581
+ if (err.statusCode !== 403) return false;
5172
5582
  try {
5173
- const s = await (0, import_promises3.stat)(dirPath);
5174
- return s.isDirectory();
5583
+ const parsed = JSON.parse(err.responseBody);
5584
+ return parsed.reason === "InvalidProviderToken" || parsed.reason === "ExpiredProviderToken";
5175
5585
  } catch {
5176
5586
  return false;
5177
5587
  }
5178
5588
  }
5179
- var UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
5180
- async function countJsonlFilesWithMtime(dirPath) {
5589
+ function isBadDeviceTokenError(err) {
5590
+ if (!(err instanceof ApnsError)) return false;
5591
+ if (err.statusCode !== 400 && err.statusCode !== 403 && err.statusCode !== 410) return false;
5181
5592
  try {
5182
- const entries = await (0, import_promises3.readdir)(dirPath, { withFileTypes: true });
5183
- const jsonlNames = new Set(
5184
- entries.filter((e) => e.isFile() && e.name.endsWith(".jsonl")).map((e) => e.name.slice(0, -6))
5185
- );
5186
- const uuidDirs = entries.filter(
5187
- (e) => e.isDirectory() && UUID_RE.test(e.name) && !jsonlNames.has(e.name)
5188
- );
5189
- let latestMtime = 0;
5190
- const jsonlEntries = entries.filter((e) => e.isFile() && e.name.endsWith(".jsonl"));
5191
- await Promise.all([
5192
- ...jsonlEntries.map(async (entry) => {
5193
- try {
5194
- const contentTs = await extractLastTimestamp((0, import_path2.join)(dirPath, entry.name));
5195
- const ts = contentTs ?? (await (0, import_promises3.stat)((0, import_path2.join)(dirPath, entry.name))).mtimeMs;
5196
- if (ts > latestMtime) latestMtime = ts;
5197
- } catch {
5198
- }
5199
- }),
5200
- ...uuidDirs.map(async (entry) => {
5201
- try {
5202
- const fileStat = await (0, import_promises3.stat)((0, import_path2.join)(dirPath, entry.name));
5203
- if (fileStat.mtimeMs > latestMtime) latestMtime = fileStat.mtimeMs;
5204
- } catch {
5205
- }
5206
- })
5207
- ]);
5208
- return { count: jsonlNames.size + uuidDirs.length, latestMtime };
5593
+ const parsed = JSON.parse(err.responseBody);
5594
+ return parsed.reason === "BadDeviceToken" || parsed.reason === "BadEnvironmentKeyInToken" || parsed.reason === "Unregistered";
5209
5595
  } catch {
5210
- return { count: 0, latestMtime: 0 };
5596
+ return false;
5211
5597
  }
5212
5598
  }
5213
5599
 
@@ -5333,7 +5719,10 @@ var AuthManager = class extends import_events3.EventEmitter {
5333
5719
  email: parsed.email,
5334
5720
  authMethod: parsed.authMethod
5335
5721
  };
5336
- } catch {
5722
+ } catch (err) {
5723
+ console.warn(
5724
+ `[AuthManager] checkAuth \u5931\u8D25 (claudePath=${CLAUDE_PATH2}): ${err instanceof Error ? err.message : String(err)}`
5725
+ );
5337
5726
  return { loggedIn: false };
5338
5727
  }
5339
5728
  }
@@ -5429,7 +5818,7 @@ var AuthManager = class extends import_events3.EventEmitter {
5429
5818
 
5430
5819
  // src/terminal/TerminalExecutor.ts
5431
5820
  var import_node_child_process8 = require("child_process");
5432
- var import_uuid5 = require("uuid");
5821
+ var import_uuid4 = require("uuid");
5433
5822
  var EXEC_TIMEOUT_MS = 30 * 60 * 1e3;
5434
5823
  var TerminalExecutor = class {
5435
5824
  processes = /* @__PURE__ */ new Map();
@@ -5451,7 +5840,7 @@ var TerminalExecutor = class {
5451
5840
  }
5452
5841
  }
5453
5842
  exec(sessionId, command, cwd) {
5454
- const execId = (0, import_uuid5.v4)();
5843
+ const execId = (0, import_uuid4.v4)();
5455
5844
  const shell = isWindows ? "powershell" : process.env.SHELL || "/bin/zsh";
5456
5845
  const args = isWindows ? ["-Command", command] : ["-l", "-c", command];
5457
5846
  const proc = (0, import_node_child_process8.spawn)(shell, args, {
@@ -5528,7 +5917,7 @@ var import_node_util = require("util");
5528
5917
  var import_promises4 = require("fs/promises");
5529
5918
  var import_node_path6 = require("path");
5530
5919
  var import_node_os7 = require("os");
5531
- var import_uuid6 = require("uuid");
5920
+ var import_uuid5 = require("uuid");
5532
5921
  var execAsync = (0, import_node_util.promisify)(import_node_child_process9.exec);
5533
5922
  var BUILD_TIMEOUT_MS = 30 * 60 * 1e3;
5534
5923
  var INSTALL_TIMEOUT_MS = 5 * 60 * 1e3;
@@ -5716,7 +6105,7 @@ ${e.stderr ?? ""}`);
5716
6105
  return null;
5717
6106
  }
5718
6107
  if (override) await this.saveConfig(projectPath, override);
5719
- const buildId = (0, import_uuid6.v4)();
6108
+ const buildId = (0, import_uuid5.v4)();
5720
6109
  const args = buildArgs(config);
5721
6110
  const proc = (0, import_node_child_process9.spawn)("xcodebuild", args, {
5722
6111
  cwd: projectPath,
@@ -5780,7 +6169,7 @@ ${e.stderr ?? ""}`);
5780
6169
  this.emitInstallError(sessionId, "", "\u672A\u627E\u5230\u6784\u5EFA\u914D\u7F6E\uFF0C\u8BF7\u5148\u6784\u5EFA\u4E00\u6B21\n");
5781
6170
  return null;
5782
6171
  }
5783
- const installId = (0, import_uuid6.v4)();
6172
+ const installId = (0, import_uuid5.v4)();
5784
6173
  let appPath;
5785
6174
  try {
5786
6175
  appPath = await this.getAppPath(projectPath, config);
@@ -6351,7 +6740,7 @@ function sourceWeight(s) {
6351
6740
  // src/git/GitExecutor.ts
6352
6741
  var import_node_child_process10 = require("child_process");
6353
6742
  var import_node_util2 = require("util");
6354
- var import_uuid7 = require("uuid");
6743
+ var import_uuid6 = require("uuid");
6355
6744
  var execAsync2 = (0, import_node_util2.promisify)(import_node_child_process10.exec);
6356
6745
  var STATUS_TIMEOUT_MS = 15e3;
6357
6746
  var COMMIT_TIMEOUT_MS = 6e4;
@@ -6471,7 +6860,7 @@ var GitExecutor = class {
6471
6860
  * - 若未提供 files:默认 git add -A(提交所有变更)
6472
6861
  */
6473
6862
  async commit(sessionId, projectPath, message, files, alsoPush) {
6474
- const opId = (0, import_uuid7.v4)();
6863
+ const opId = (0, import_uuid6.v4)();
6475
6864
  this.runSequence(sessionId, opId, "commit", projectPath, [
6476
6865
  files && files.length > 0 ? ["git", "add", "--", ...files] : ["git", "add", "-A"],
6477
6866
  ["git", "commit", "-m", message]
@@ -6487,7 +6876,7 @@ var GitExecutor = class {
6487
6876
  return opId;
6488
6877
  }
6489
6878
  async push(sessionId, projectPath) {
6490
- const opId = (0, import_uuid7.v4)();
6879
+ const opId = (0, import_uuid6.v4)();
6491
6880
  this.runSequence(sessionId, opId, "push", projectPath, [
6492
6881
  ["git", "push"]
6493
6882
  ], PUSH_TIMEOUT_MS).catch((err) => {
@@ -6565,7 +6954,7 @@ var GitExecutor = class {
6565
6954
  var import_promises6 = require("fs/promises");
6566
6955
  var import_node_os8 = require("os");
6567
6956
  var import_node_path8 = require("path");
6568
- var import_uuid8 = require("uuid");
6957
+ var import_uuid7 = require("uuid");
6569
6958
  var MAX_TIMEOUT_MS = 2147483647;
6570
6959
  var ScheduledSessionManager = class {
6571
6960
  tasks = /* @__PURE__ */ new Map();
@@ -6603,7 +6992,7 @@ var ScheduledSessionManager = class {
6603
6992
  /** 注册一个定时任务(payload 由调用方校验) */
6604
6993
  schedule(scheduledAt, payload) {
6605
6994
  const task = {
6606
- id: (0, import_uuid8.v4)(),
6995
+ id: (0, import_uuid7.v4)(),
6607
6996
  scheduledAt,
6608
6997
  createdAt: Date.now(),
6609
6998
  payload
@@ -6708,6 +7097,7 @@ function isValidTask(value) {
6708
7097
  var import_node_child_process11 = require("child_process");
6709
7098
  var DEFAULT_MODELS = [
6710
7099
  { value: "opus", label: "Opus 4.7", sublabel: "Most capable for ambitious work" },
7100
+ { value: "claude-opus-4-6", label: "Opus 4.6", sublabel: "Previous generation flagship" },
6711
7101
  { value: "sonnet", label: "Sonnet 4.6", sublabel: "Most efficient for everyday tasks" },
6712
7102
  { value: "haiku", label: "Haiku 4.5", sublabel: "Fastest for quick answers" }
6713
7103
  ];
@@ -6842,7 +7232,7 @@ async function start(opts = {}) {
6842
7232
  try {
6843
7233
  token = (await (0, import_promises7.readFile)(tokenFile, "utf8")).trim();
6844
7234
  } catch {
6845
- token = (0, import_uuid9.v4)();
7235
+ token = (0, import_uuid8.v4)();
6846
7236
  await (0, import_promises7.mkdir)(configDir, { recursive: true });
6847
7237
  await (0, import_promises7.writeFile)(tokenFile, token, "utf8");
6848
7238
  }
@@ -7034,6 +7424,7 @@ async function start(opts = {}) {
7034
7424
  case "kill_session": {
7035
7425
  wsBridge.broadcast({ type: "status_change", sessionId: event.sessionId, status: "idle" });
7036
7426
  approvalProxy.clearPendingForSession(event.sessionId);
7427
+ approvalProxy.clearPendingQuestionsForSession(event.sessionId);
7037
7428
  await sessionManager.killSession(event.sessionId);
7038
7429
  wsBridge.broadcast({
7039
7430
  type: "session_list",
@@ -7052,6 +7443,7 @@ async function start(opts = {}) {
7052
7443
  }
7053
7444
  case "answer_question": {
7054
7445
  sessionManager.handleQuestionResponse(event.requestId, event.answer);
7446
+ approvalProxy.resolveQuestion(event.requestId, event.answer);
7055
7447
  break;
7056
7448
  }
7057
7449
  case "subscribe": {
@@ -7236,6 +7628,14 @@ async function start(opts = {}) {
7236
7628
  notificationService.removePushToken(event.token);
7237
7629
  break;
7238
7630
  }
7631
+ case "register_native_push_token": {
7632
+ notificationService.addNativePushToken(event.token, ws);
7633
+ break;
7634
+ }
7635
+ case "unregister_native_push_token": {
7636
+ notificationService.removeNativePushToken(event.token);
7637
+ break;
7638
+ }
7239
7639
  case "update_notification_sounds": {
7240
7640
  notificationService.setSoundPreferences(event.preferences);
7241
7641
  break;
@@ -7473,6 +7873,9 @@ async function start(opts = {}) {
7473
7873
  decision: decision.decision
7474
7874
  });
7475
7875
  });
7876
+ approvalProxy.onQuestionResolved((requestId) => {
7877
+ sessionManager.clearPendingQuestion(requestId);
7878
+ });
7476
7879
  approvalProxy.onApprovalRequest((request) => {
7477
7880
  wsBridge.broadcast({ type: "approval_request", request });
7478
7881
  setTimeout(() => {
@@ -7489,6 +7892,9 @@ async function start(opts = {}) {
7489
7892
  notificationService.notifyApproval(request, pendingCount);
7490
7893
  }, 6e4);
7491
7894
  });
7895
+ approvalProxy.setQuestionHandler(
7896
+ (sessionId, toolUseId, questions, requestId) => sessionManager.askQuestion(sessionId, toolUseId, questions, requestId)
7897
+ );
7492
7898
  sessionManager.onEvent((event) => {
7493
7899
  if (event.type !== "question_request") return;
7494
7900
  const { request } = event;
@@ -7631,7 +8037,7 @@ async function start(opts = {}) {
7631
8037
  openPairing: (duration) => pairingManager.open(duration),
7632
8038
  closePairing: () => pairingManager.close(),
7633
8039
  regenerateToken: async () => {
7634
- const newToken = (0, import_uuid9.v4)();
8040
+ const newToken = (0, import_uuid8.v4)();
7635
8041
  await (0, import_promises7.mkdir)(configDir, { recursive: true });
7636
8042
  await (0, import_promises7.writeFile)(tokenFile, newToken, "utf8");
7637
8043
  instance.token = newToken;