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/index.js CHANGED
@@ -302,7 +302,7 @@ function t(key, params) {
302
302
  }
303
303
 
304
304
  // src/server.ts
305
- var import_uuid9 = require("uuid");
305
+ var import_uuid8 = require("uuid");
306
306
  var import_promises7 = require("fs/promises");
307
307
  var import_node_os9 = require("os");
308
308
  var import_node_path9 = require("path");
@@ -311,7 +311,7 @@ var import_node_util3 = require("util");
311
311
 
312
312
  // src/providers/ProcessProvider.ts
313
313
  var import_child_process = require("child_process");
314
- var import_readline = require("readline");
314
+ var import_readline2 = require("readline");
315
315
  var import_events = require("events");
316
316
  var import_node_os2 = require("os");
317
317
  var import_uuid = require("uuid");
@@ -362,12 +362,60 @@ function isNormalExit(code, signal) {
362
362
  }
363
363
 
364
364
  // src/utils/claudePath.ts
365
+ function resolveStable(candidate) {
366
+ if (!candidate) return null;
367
+ try {
368
+ const real = (0, import_node_fs.realpathSync)(candidate.trim());
369
+ (0, import_node_fs.accessSync)(real, import_node_fs.constants.X_OK);
370
+ return real;
371
+ } catch {
372
+ return null;
373
+ }
374
+ }
375
+ function resolveViaLoginShell() {
376
+ if (isWindows) return null;
377
+ const shell = process.env.SHELL || "/bin/zsh";
378
+ try {
379
+ const out = (0, import_node_child_process2.execSync)(`${shell} -ilc 'command -v claude' 2>/dev/null`, {
380
+ encoding: "utf-8",
381
+ timeout: 8e3
382
+ }).trim().split("\n").filter(Boolean).pop();
383
+ return resolveStable(out);
384
+ } catch {
385
+ return null;
386
+ }
387
+ }
388
+ function resolveViaFnm() {
389
+ if (isWindows) return null;
390
+ const base = (0, import_node_path.join)((0, import_node_os.homedir)(), ".fnm", "node-versions");
391
+ try {
392
+ const versions = (0, import_node_fs.readdirSync)(base).filter((v) => /^v?\d+\./.test(v)).sort(
393
+ (a, b) => b.localeCompare(a, void 0, { numeric: true, sensitivity: "base" })
394
+ );
395
+ for (const v of versions) {
396
+ const p = resolveStable((0, import_node_path.join)(base, v, "installation", "bin", "claude"));
397
+ if (p) return p;
398
+ }
399
+ } catch {
400
+ }
401
+ return null;
402
+ }
403
+ var cached = null;
365
404
  function findClaudePath() {
405
+ if (cached) return cached;
406
+ const override = resolveStable(process.env.SESSIX_CLAUDE_PATH);
407
+ if (override) return cached = log(override, "env:SESSIX_CLAUDE_PATH");
366
408
  try {
367
409
  const cmd = isWindows ? "where claude" : "which claude";
368
- return (0, import_node_child_process2.execSync)(cmd, { encoding: "utf-8" }).trim().split("\n")[0];
410
+ const which = (0, import_node_child_process2.execSync)(cmd, { encoding: "utf-8" }).trim().split("\n")[0];
411
+ const stable = resolveStable(which);
412
+ if (stable) return cached = log(stable, "which");
369
413
  } catch {
370
414
  }
415
+ const fnm = resolveViaFnm();
416
+ if (fnm) return cached = log(fnm, "fnm-scan");
417
+ const viaShell = resolveViaLoginShell();
418
+ if (viaShell) return cached = log(viaShell, "login-shell");
371
419
  const candidates = isWindows ? [
372
420
  (0, import_node_path.join)(process.env.LOCALAPPDATA ?? "", "Programs", "claude", "claude.exe"),
373
421
  (0, import_node_path.join)((0, import_node_os.homedir)(), "AppData", "Local", "Programs", "claude", "claude.exe"),
@@ -378,153 +426,521 @@ function findClaudePath() {
378
426
  "/opt/homebrew/bin/claude"
379
427
  ];
380
428
  for (const candidate of candidates) {
429
+ const stable = resolveStable(candidate);
430
+ if (stable) return cached = log(stable, "candidate");
431
+ }
432
+ console.warn(
433
+ "[claudePath] \u672A\u80FD\u5B9A\u4F4D claude\uFF0C\u515C\u5E95\u4F7F\u7528\u88F8 'claude'\uFF08PATH \u4E0D\u542B claude \u65F6\u4F1A\u5931\u8D25\uFF09"
434
+ );
435
+ return cached = "claude";
436
+ }
437
+ function log(path2, via) {
438
+ console.log(`[claudePath] \u89E3\u6790\u5230 claude: ${path2} (via ${via})`);
439
+ return path2;
440
+ }
441
+
442
+ // src/session/ProjectReader.ts
443
+ var import_promises = require("fs/promises");
444
+ var import_readline = require("readline");
445
+ var import_path = require("path");
446
+ var import_os = require("os");
447
+ var CLAUDE_PROJECTS_DIR = (0, import_path.join)((0, import_os.homedir)(), ".claude", "projects");
448
+ function getSessionFilePath(projectPath, sessionId) {
449
+ return (0, import_path.join)(CLAUDE_PROJECTS_DIR, encodeDirName(projectPath), `${sessionId}.jsonl`);
450
+ }
451
+ async function getSessionModel(projectPath, sessionId) {
452
+ const filePath = getSessionFilePath(projectPath, sessionId);
453
+ const raw = await (0, import_promises.readFile)(filePath, "utf-8").catch((err) => {
454
+ if (err.code === "ENOENT") return null;
455
+ throw err;
456
+ });
457
+ if (raw === null) return void 0;
458
+ const lines = raw.split("\n");
459
+ for (let i = lines.length - 1; i >= 0; i--) {
460
+ const line = lines[i].trim();
461
+ if (!line) continue;
381
462
  try {
382
- (0, import_node_fs.accessSync)(candidate, import_node_fs.constants.X_OK);
383
- return candidate;
463
+ const obj = JSON.parse(line);
464
+ if (obj.type !== "assistant" || !obj.message) continue;
465
+ const model = obj.message.model;
466
+ if (typeof model === "string" && model && model !== "unknown") {
467
+ return model;
468
+ }
384
469
  } catch {
385
470
  }
386
471
  }
387
- return "claude";
472
+ return void 0;
388
473
  }
389
-
390
- // src/providers/ProcessProvider.ts
391
- var CLAUDE_PATH = findClaudePath();
392
- var ProcessProvider = class {
393
- /** 活跃会话映射表:sessionId -> { session, process } */
394
- activeSessions = /* @__PURE__ */ new Map();
395
- /** 事件发射器,用于分发 Claude 事件流 */
396
- emitter = new import_events.EventEmitter();
397
- /** 已发射的 AskUserQuestion toolUseId 集合,按会话隔离(避免 partial message 重复触发) */
398
- emittedQuestionToolUseIds = /* @__PURE__ */ new Map();
399
- /**
400
- * 启动新会话或恢复已有会话
401
- *
402
- * 会 spawn 一个 `claude` CLI 进程,设置工作目录和环境变量,
403
- * 并开始监听 stdout 的 NDJSON 输出。
404
- */
405
- async startSession(opts) {
406
- const { projectPath, message, sessionId: existingSessionId, model, permissionMode, effort, images, fallbackModel, maxBudgetUsd } = opts;
407
- const sessionId = existingSessionId ?? (0, import_uuid.v4)();
408
- if (this.activeSessions.has(sessionId)) {
409
- await this.killSession(sessionId);
474
+ async function getProjects() {
475
+ try {
476
+ const dirExists = await directoryExists(CLAUDE_PROJECTS_DIR);
477
+ if (!dirExists) {
478
+ return { ok: true, value: [] };
410
479
  }
411
- const projectId = projectPath.split("/").filter(Boolean).pop() ?? "unknown";
412
- const session = {
413
- id: sessionId,
414
- projectId,
415
- projectPath,
416
- status: "running",
417
- createdAt: Date.now(),
418
- lastActiveAt: Date.now(),
419
- summary: message.slice(0, 80)
480
+ const entries = await (0, import_promises.readdir)(CLAUDE_PROJECTS_DIR, { withFileTypes: true });
481
+ const projects = [];
482
+ for (const entry of entries) {
483
+ if (!entry.isDirectory() || entry.name.startsWith(".")) {
484
+ continue;
485
+ }
486
+ const encodedPath = entry.name;
487
+ const decodedPath = decodeDirName(encodedPath);
488
+ const name = decodedPath.split("/").filter(Boolean).pop() ?? encodedPath;
489
+ const projectDir = (0, import_path.join)(CLAUDE_PROJECTS_DIR, encodedPath);
490
+ const { count: sessionCount, latestMtime } = await countJsonlFilesWithMtime(projectDir);
491
+ projects.push({
492
+ id: encodedPath,
493
+ path: decodedPath,
494
+ name,
495
+ sessionCount,
496
+ lastActiveAt: latestMtime
497
+ });
498
+ }
499
+ projects.sort((a, b) => a.name.localeCompare(b.name));
500
+ return { ok: true, value: projects };
501
+ } catch (err) {
502
+ return {
503
+ ok: false,
504
+ error: err instanceof Error ? err : new Error(String(err))
420
505
  };
421
- const resume = opts.resume ?? !!existingSessionId;
422
- const proc = this.spawnClaudeProcess(sessionId, projectPath, resume, model, permissionMode, effort, fallbackModel, maxBudgetUsd);
423
- this.writeUserMessage(proc, message, sessionId, images);
424
- session.pid = proc.pid;
425
- this.activeSessions.set(sessionId, { session, process: proc, model, permissionMode, effort, fallbackModel, maxBudgetUsd });
426
- proc.on("error", (err) => {
427
- console.error(`[ProcessProvider] Session ${sessionId} process error:`, err.message);
428
- this.activeSessions.delete(sessionId);
429
- const syntheticResult = {
430
- type: "result",
431
- subtype: "error",
432
- result: `Process spawn failed: ${err.message}`,
433
- session_id: sessionId,
434
- duration_ms: 0,
435
- is_error: true,
436
- num_turns: 0
437
- };
438
- this.emitter.emit(this.getEventName(sessionId), syntheticResult);
439
- });
440
- this.attachStdoutListener(sessionId, proc);
441
- this.attachStderrListener(sessionId, proc);
442
- this.attachExitListener(sessionId, proc);
443
- return session;
444
506
  }
445
- /**
446
- * 终止指定会话
447
- *
448
- * kill 进程并从活跃映射中移除。
449
- */
450
- async killSession(sessionId) {
451
- const entry = this.activeSessions.get(sessionId);
452
- if (!entry) {
453
- return;
507
+ }
508
+ async function getHistoricalSessions(projectPath) {
509
+ try {
510
+ const encodedPath = encodeDirName(projectPath);
511
+ const projectDir = (0, import_path.join)(CLAUDE_PROJECTS_DIR, encodedPath);
512
+ const dirExists = await directoryExists(projectDir);
513
+ if (!dirExists) {
514
+ return { ok: true, value: [] };
454
515
  }
455
- if (entry.process.exitCode === null && entry.process.signalCode === null) {
516
+ const entries = await (0, import_promises.readdir)(projectDir, { withFileTypes: true });
517
+ const jsonlFiles = entries.filter((e) => e.isFile() && e.name.endsWith(".jsonl"));
518
+ const mtimeMap = /* @__PURE__ */ new Map();
519
+ await Promise.all(
520
+ jsonlFiles.map(async (entry) => {
521
+ const sessionId = entry.name.slice(0, -6);
522
+ const filePath = (0, import_path.join)(projectDir, entry.name);
523
+ try {
524
+ const contentTs = await extractLastTimestamp(filePath);
525
+ if (contentTs) {
526
+ mtimeMap.set(sessionId, contentTs);
527
+ } else {
528
+ const fileStat = await (0, import_promises.stat)(filePath);
529
+ mtimeMap.set(sessionId, fileStat.mtimeMs);
530
+ }
531
+ } catch {
532
+ mtimeMap.set(sessionId, 0);
533
+ }
534
+ })
535
+ );
536
+ const uuidDirs = entries.filter(
537
+ (e) => e.isDirectory() && UUID_RE.test(e.name) && !mtimeMap.has(e.name)
538
+ );
539
+ for (const entry of uuidDirs) {
456
540
  try {
457
- entry.process.stdin?.end();
541
+ const fileStat = await (0, import_promises.stat)((0, import_path.join)(projectDir, entry.name));
542
+ mtimeMap.set(entry.name, fileStat.mtimeMs);
458
543
  } catch {
544
+ mtimeMap.set(entry.name, 0);
459
545
  }
460
- await killProcessCrossPlatform(entry.process);
461
- }
462
- this.emittedQuestionToolUseIds.delete(sessionId);
463
- this.activeSessions.delete(sessionId);
464
- }
465
- /**
466
- * 向已有会话发送新消息
467
- *
468
- * 快速路径:进程存活时直接写 stdin(毫秒级响应)。
469
- * 慢速路径:进程已退出时 respawn 并 --resume。
470
- */
471
- async sendMessage(sessionId, message, permissionMode, images) {
472
- const entry = this.activeSessions.get(sessionId);
473
- if (!entry) {
474
- throw new Error(`Session ${sessionId} not found or already ended`);
475
546
  }
476
- const modeChanged = permissionMode != null && permissionMode !== (entry.permissionMode ?? "default");
477
- if (!modeChanged && entry.process.exitCode === null && entry.process.signalCode === null && !entry.process.stdin?.destroyed) {
478
- entry.session.status = "running";
479
- entry.session.lastActiveAt = Date.now();
480
- this.writeUserMessage(entry.process, message, sessionId, images);
481
- return;
547
+ const indexPath = (0, import_path.join)(projectDir, "sessions-index.json");
548
+ const sessionMap = /* @__PURE__ */ new Map();
549
+ try {
550
+ const indexContent = await (0, import_promises.readFile)(indexPath, "utf-8");
551
+ const indexData = JSON.parse(indexContent);
552
+ if (indexData.version === 1 && Array.isArray(indexData.entries)) {
553
+ for (const entry of indexData.entries) {
554
+ const mtime = mtimeMap.get(entry.sessionId) ?? entry.fileMtime ?? (entry.modified ? new Date(entry.modified).getTime() : 0);
555
+ sessionMap.set(entry.sessionId, {
556
+ sessionId: entry.sessionId,
557
+ lastModified: mtime,
558
+ summary: entry.summary,
559
+ firstPrompt: entry.firstPrompt,
560
+ messageCount: entry.messageCount
561
+ });
562
+ }
563
+ await Promise.all(
564
+ Array.from(sessionMap.values()).filter((s) => (s.messageCount ?? 0) > 0 && !s.summary && !s.firstPrompt).map(async (s) => {
565
+ const filePath = (0, import_path.join)(projectDir, `${s.sessionId}.jsonl`);
566
+ const firstPrompt = await extractFirstPrompt(filePath).catch(() => void 0);
567
+ if (firstPrompt) s.firstPrompt = firstPrompt;
568
+ })
569
+ );
570
+ }
571
+ } catch {
482
572
  }
483
- if (modeChanged) {
484
- console.log(`[ProcessProvider] Session ${sessionId}: permission mode change ${entry.permissionMode ?? "default"} \u2192 ${permissionMode}, respawn`);
485
- if (entry.process.exitCode === null && entry.process.signalCode === null) {
486
- try {
487
- entry.process.stdin?.end();
488
- } catch {
573
+ const uuidDirSet = new Set(uuidDirs.map((e) => e.name));
574
+ for (const [sessionId, mtime] of mtimeMap) {
575
+ if (!sessionMap.has(sessionId)) {
576
+ if (uuidDirSet.has(sessionId)) {
577
+ sessionMap.set(sessionId, { sessionId, lastModified: mtime, messageCount: -1 });
578
+ } else {
579
+ const filePath = (0, import_path.join)(projectDir, `${sessionId}.jsonl`);
580
+ const firstPrompt = await extractFirstPrompt(filePath).catch(() => void 0);
581
+ sessionMap.set(sessionId, { sessionId, lastModified: mtime, firstPrompt });
489
582
  }
490
- killProcessCrossPlatform(entry.process);
491
583
  }
492
- } else {
493
- console.log(`[ProcessProvider] Session ${sessionId}: process exited, respawning`);
494
584
  }
495
- const savedPendingQuestion = entry.pendingQuestion;
496
- const newMode = permissionMode ?? entry.permissionMode;
497
- const proc = this.spawnClaudeProcess(sessionId, entry.session.projectPath, true, entry.model, newMode, entry.effort, entry.fallbackModel, entry.maxBudgetUsd);
498
- this.writeUserMessage(proc, message, sessionId, images);
499
- entry.session.status = "running";
500
- entry.session.lastActiveAt = Date.now();
501
- entry.session.pid = proc.pid;
502
- entry.process = proc;
503
- entry.permissionMode = newMode;
504
- entry.pendingQuestion = savedPendingQuestion;
505
- proc.on("error", (err) => {
506
- console.error(`[ProcessProvider] Session ${sessionId} sendMessage process error:`, err.message);
507
- this.activeSessions.delete(sessionId);
508
- const syntheticResult = {
509
- type: "result",
510
- subtype: "error",
511
- result: `Failed to send message: ${err.message}`,
512
- session_id: sessionId,
513
- duration_ms: 0,
514
- is_error: true,
515
- num_turns: 0
516
- };
517
- this.emitter.emit(this.getEventName(sessionId), syntheticResult);
585
+ const sessions = Array.from(sessionMap.values()).filter((s) => {
586
+ if (s.messageCount === 0) return false;
587
+ if (s.messageCount === -1) return true;
588
+ if (s.firstPrompt === void 0 && s.messageCount === void 0) return false;
589
+ return true;
518
590
  });
519
- this.attachStdoutListener(sessionId, proc);
520
- this.attachStderrListener(sessionId, proc);
521
- this.attachExitListener(sessionId, proc);
591
+ sessions.sort((a, b) => b.lastModified - a.lastModified);
592
+ return { ok: true, value: sessions };
593
+ } catch (err) {
594
+ return {
595
+ ok: false,
596
+ error: err instanceof Error ? err : new Error(String(err))
597
+ };
522
598
  }
523
- /**
524
- * 订阅指定会话的 Claude 事件流
525
- *
526
- * @returns 取消订阅函数
527
- */
599
+ }
600
+ async function getSessionHistory(projectPath, sessionId) {
601
+ try {
602
+ const encodedPath = encodeDirName(projectPath);
603
+ const filePath = (0, import_path.join)(CLAUDE_PROJECTS_DIR, encodedPath, `${sessionId}.jsonl`);
604
+ const raw = await (0, import_promises.readFile)(filePath, "utf-8").catch((err) => {
605
+ if (err.code === "ENOENT") return null;
606
+ throw err;
607
+ });
608
+ if (raw === null) return { ok: true, value: [] };
609
+ const lines = raw.split("\n").filter((l) => l.trim());
610
+ const events = [];
611
+ for (const line of lines) {
612
+ try {
613
+ const obj = JSON.parse(line);
614
+ const type = obj.type;
615
+ if (type === "user" && obj.message) {
616
+ const msgContent = obj.message.content;
617
+ if (typeof msgContent === "string") {
618
+ if (msgContent.includes("<local-command") || msgContent.includes("<command-name>")) continue;
619
+ } else if (Array.isArray(msgContent)) {
620
+ const hasText = msgContent.some(
621
+ (b) => b.type === "text" && !b.text?.includes("<local-command") && !b.text?.includes("<command-name>")
622
+ );
623
+ if (!hasText) continue;
624
+ }
625
+ const normalizedContent = typeof msgContent === "string" ? [{ type: "text", text: msgContent }] : Array.isArray(msgContent) ? msgContent.filter((b) => b.type === "text" && typeof b.text === "string") : [];
626
+ if (normalizedContent.length === 0) continue;
627
+ events.push({
628
+ type: "user",
629
+ message: {
630
+ ...obj.message,
631
+ content: normalizedContent
632
+ },
633
+ session_id: sessionId
634
+ });
635
+ } else if (type === "assistant" && obj.message) {
636
+ const content = (obj.message.content ?? []).filter(
637
+ (b) => b.type === "text" || b.type === "tool_use" || b.type === "thinking"
638
+ );
639
+ if (content.length === 0) continue;
640
+ events.push({
641
+ type: "assistant",
642
+ message: {
643
+ id: obj.message.id ?? obj.uuid ?? `hist-${events.length}`,
644
+ model: obj.message.model ?? "unknown",
645
+ role: "assistant",
646
+ content,
647
+ stop_reason: obj.message.stop_reason,
648
+ usage: obj.message.usage
649
+ },
650
+ session_id: sessionId
651
+ });
652
+ }
653
+ } catch {
654
+ }
655
+ }
656
+ if (events.length > 0) {
657
+ let totalInputTokens = 0;
658
+ let totalOutputTokens = 0;
659
+ for (const ev of events) {
660
+ if (ev.type === "assistant" && ev.message.usage) {
661
+ totalInputTokens += ev.message.usage.input_tokens ?? 0;
662
+ totalOutputTokens += ev.message.usage.output_tokens ?? 0;
663
+ }
664
+ }
665
+ if (totalInputTokens > 0 || totalOutputTokens > 0) {
666
+ events.push({
667
+ type: "result",
668
+ subtype: "success",
669
+ is_error: false,
670
+ duration_ms: 0,
671
+ num_turns: events.filter((e) => e.type === "user").length,
672
+ result: "",
673
+ session_id: sessionId,
674
+ usage: { input_tokens: totalInputTokens, output_tokens: totalOutputTokens }
675
+ });
676
+ }
677
+ }
678
+ return { ok: true, value: events };
679
+ } catch (err) {
680
+ return {
681
+ ok: false,
682
+ error: err instanceof Error ? err : new Error(String(err))
683
+ };
684
+ }
685
+ }
686
+ async function extractLastTimestamp(filePath) {
687
+ let fileHandle;
688
+ try {
689
+ fileHandle = await (0, import_promises.open)(filePath, "r");
690
+ const fileStat = await fileHandle.stat();
691
+ const readSize = Math.min(fileStat.size, 8192);
692
+ const buffer = Buffer.alloc(readSize);
693
+ await fileHandle.read(buffer, 0, readSize, fileStat.size - readSize);
694
+ const tail = buffer.toString("utf-8");
695
+ const lines = tail.split("\n").filter((l) => l.trim());
696
+ for (let i = lines.length - 1; i >= 0; i--) {
697
+ try {
698
+ const obj = JSON.parse(lines[i]);
699
+ if (obj.timestamp) {
700
+ const ts = new Date(obj.timestamp).getTime();
701
+ if (!isNaN(ts)) return ts;
702
+ }
703
+ } catch {
704
+ }
705
+ }
706
+ } catch {
707
+ } finally {
708
+ await fileHandle?.close();
709
+ }
710
+ return void 0;
711
+ }
712
+ async function extractFirstPrompt(filePath) {
713
+ let fileHandle;
714
+ try {
715
+ fileHandle = await (0, import_promises.open)(filePath, "r");
716
+ const rl = (0, import_readline.createInterface)({
717
+ input: fileHandle.createReadStream({ encoding: "utf-8" }),
718
+ crlfDelay: Infinity
719
+ });
720
+ let lineCount = 0;
721
+ for await (const line of rl) {
722
+ if (++lineCount > 20) break;
723
+ if (!line.trim()) continue;
724
+ try {
725
+ const obj = JSON.parse(line);
726
+ if (obj.type === "user" && obj.message) {
727
+ const msgContent = obj.message.content;
728
+ let text = "";
729
+ if (typeof msgContent === "string") {
730
+ text = msgContent;
731
+ } else if (Array.isArray(msgContent)) {
732
+ const textBlock = msgContent.find((b) => b.type === "text" && typeof b.text === "string");
733
+ text = textBlock?.text ?? "";
734
+ }
735
+ if (text && !text.includes("<local-command") && !text.includes("<command-name>")) {
736
+ text = text.replace(/<ide_opened_file>[\s\S]*?<\/ide_opened_file>/gi, "");
737
+ text = text.replace(/<[^>]+>/g, "").trim();
738
+ rl.close();
739
+ return text.length > 80 ? text.slice(0, 80) + "..." : text;
740
+ }
741
+ }
742
+ } catch {
743
+ }
744
+ }
745
+ } catch {
746
+ } finally {
747
+ await fileHandle?.close();
748
+ }
749
+ return void 0;
750
+ }
751
+ function decodeDirName(dirName) {
752
+ const placeholder = "\0";
753
+ const escaped = dirName.replace(/--/g, placeholder);
754
+ const decoded = escaped.replace(/-/g, "/");
755
+ return decoded.replace(new RegExp(placeholder, "g"), "-");
756
+ }
757
+ function encodeDirName(path2) {
758
+ const escaped = path2.replace(/-/g, "--");
759
+ return escaped.replace(/\//g, "-");
760
+ }
761
+ async function directoryExists(dirPath) {
762
+ try {
763
+ const s = await (0, import_promises.stat)(dirPath);
764
+ return s.isDirectory();
765
+ } catch {
766
+ return false;
767
+ }
768
+ }
769
+ var UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
770
+ async function countJsonlFilesWithMtime(dirPath) {
771
+ try {
772
+ const entries = await (0, import_promises.readdir)(dirPath, { withFileTypes: true });
773
+ const jsonlNames = new Set(
774
+ entries.filter((e) => e.isFile() && e.name.endsWith(".jsonl")).map((e) => e.name.slice(0, -6))
775
+ );
776
+ const uuidDirs = entries.filter(
777
+ (e) => e.isDirectory() && UUID_RE.test(e.name) && !jsonlNames.has(e.name)
778
+ );
779
+ let latestMtime = 0;
780
+ const jsonlEntries = entries.filter((e) => e.isFile() && e.name.endsWith(".jsonl"));
781
+ await Promise.all([
782
+ ...jsonlEntries.map(async (entry) => {
783
+ try {
784
+ const contentTs = await extractLastTimestamp((0, import_path.join)(dirPath, entry.name));
785
+ const ts = contentTs ?? (await (0, import_promises.stat)((0, import_path.join)(dirPath, entry.name))).mtimeMs;
786
+ if (ts > latestMtime) latestMtime = ts;
787
+ } catch {
788
+ }
789
+ }),
790
+ ...uuidDirs.map(async (entry) => {
791
+ try {
792
+ const fileStat = await (0, import_promises.stat)((0, import_path.join)(dirPath, entry.name));
793
+ if (fileStat.mtimeMs > latestMtime) latestMtime = fileStat.mtimeMs;
794
+ } catch {
795
+ }
796
+ })
797
+ ]);
798
+ return { count: jsonlNames.size + uuidDirs.length, latestMtime };
799
+ } catch {
800
+ return { count: 0, latestMtime: 0 };
801
+ }
802
+ }
803
+
804
+ // src/providers/ProcessProvider.ts
805
+ var CLAUDE_PATH = findClaudePath();
806
+ var ProcessProvider = class {
807
+ /** 活跃会话映射表:sessionId -> { session, process } */
808
+ activeSessions = /* @__PURE__ */ new Map();
809
+ /** 事件发射器,用于分发 Claude 事件流 */
810
+ emitter = new import_events.EventEmitter();
811
+ /**
812
+ * 启动新会话或恢复已有会话
813
+ *
814
+ * 会 spawn 一个 `claude` CLI 进程,设置工作目录和环境变量,
815
+ * 并开始监听 stdout 的 NDJSON 输出。
816
+ */
817
+ async startSession(opts) {
818
+ const { projectPath, message, sessionId: existingSessionId, model, permissionMode, effort, images, fallbackModel, maxBudgetUsd } = opts;
819
+ const sessionId = existingSessionId ?? (0, import_uuid.v4)();
820
+ if (this.activeSessions.has(sessionId)) {
821
+ await this.killSession(sessionId);
822
+ }
823
+ const projectId = projectPath.split("/").filter(Boolean).pop() ?? "unknown";
824
+ const session = {
825
+ id: sessionId,
826
+ projectId,
827
+ projectPath,
828
+ status: "running",
829
+ createdAt: Date.now(),
830
+ lastActiveAt: Date.now(),
831
+ summary: message.slice(0, 80)
832
+ };
833
+ const resume = opts.resume ?? !!existingSessionId;
834
+ let effectiveModel = model;
835
+ if (resume && !effectiveModel) {
836
+ effectiveModel = await getSessionModel(projectPath, sessionId).catch(() => void 0);
837
+ if (effectiveModel) {
838
+ console.log(`[ProcessProvider] Session ${sessionId}: resume restored original model "${effectiveModel}"`);
839
+ }
840
+ }
841
+ const proc = this.spawnClaudeProcess(sessionId, projectPath, resume, effectiveModel, permissionMode, effort, fallbackModel, maxBudgetUsd);
842
+ this.writeUserMessage(proc, message, sessionId, images);
843
+ session.pid = proc.pid;
844
+ this.activeSessions.set(sessionId, { session, process: proc, model: effectiveModel, permissionMode, effort, fallbackModel, maxBudgetUsd });
845
+ proc.on("error", (err) => {
846
+ console.error(`[ProcessProvider] Session ${sessionId} process error:`, err.message);
847
+ this.activeSessions.delete(sessionId);
848
+ const syntheticResult = {
849
+ type: "result",
850
+ subtype: "error",
851
+ result: `Process spawn failed: ${err.message}`,
852
+ session_id: sessionId,
853
+ duration_ms: 0,
854
+ is_error: true,
855
+ num_turns: 0
856
+ };
857
+ this.emitter.emit(this.getEventName(sessionId), syntheticResult);
858
+ });
859
+ this.attachStdoutListener(sessionId, proc);
860
+ this.attachStderrListener(sessionId, proc);
861
+ this.attachExitListener(sessionId, proc);
862
+ return session;
863
+ }
864
+ /**
865
+ * 终止指定会话
866
+ *
867
+ * kill 进程并从活跃映射中移除。
868
+ */
869
+ async killSession(sessionId) {
870
+ const entry = this.activeSessions.get(sessionId);
871
+ if (!entry) {
872
+ return;
873
+ }
874
+ if (entry.process.exitCode === null && entry.process.signalCode === null) {
875
+ try {
876
+ entry.process.stdin?.end();
877
+ } catch {
878
+ }
879
+ await killProcessCrossPlatform(entry.process);
880
+ }
881
+ this.activeSessions.delete(sessionId);
882
+ }
883
+ /**
884
+ * 向已有会话发送新消息
885
+ *
886
+ * 快速路径:进程存活时直接写 stdin(毫秒级响应)。
887
+ * 慢速路径:进程已退出时 respawn 并 --resume。
888
+ */
889
+ async sendMessage(sessionId, message, permissionMode, images) {
890
+ const entry = this.activeSessions.get(sessionId);
891
+ if (!entry) {
892
+ throw new Error(`Session ${sessionId} not found or already ended`);
893
+ }
894
+ const modeChanged = permissionMode != null && permissionMode !== (entry.permissionMode ?? "default");
895
+ if (!modeChanged && entry.process.exitCode === null && entry.process.signalCode === null && !entry.process.stdin?.destroyed) {
896
+ entry.session.status = "running";
897
+ entry.session.lastActiveAt = Date.now();
898
+ this.writeUserMessage(entry.process, message, sessionId, images);
899
+ return;
900
+ }
901
+ if (modeChanged) {
902
+ console.log(`[ProcessProvider] Session ${sessionId}: permission mode change ${entry.permissionMode ?? "default"} \u2192 ${permissionMode}, respawn`);
903
+ if (entry.process.exitCode === null && entry.process.signalCode === null) {
904
+ try {
905
+ entry.process.stdin?.end();
906
+ } catch {
907
+ }
908
+ killProcessCrossPlatform(entry.process);
909
+ }
910
+ } else {
911
+ console.log(`[ProcessProvider] Session ${sessionId}: process exited, respawning`);
912
+ }
913
+ const newMode = permissionMode ?? entry.permissionMode;
914
+ const proc = this.spawnClaudeProcess(sessionId, entry.session.projectPath, true, entry.model, newMode, entry.effort, entry.fallbackModel, entry.maxBudgetUsd);
915
+ this.writeUserMessage(proc, message, sessionId, images);
916
+ entry.session.status = "running";
917
+ entry.session.lastActiveAt = Date.now();
918
+ entry.session.pid = proc.pid;
919
+ entry.process = proc;
920
+ entry.permissionMode = newMode;
921
+ proc.on("error", (err) => {
922
+ console.error(`[ProcessProvider] Session ${sessionId} sendMessage process error:`, err.message);
923
+ this.activeSessions.delete(sessionId);
924
+ const syntheticResult = {
925
+ type: "result",
926
+ subtype: "error",
927
+ result: `Failed to send message: ${err.message}`,
928
+ session_id: sessionId,
929
+ duration_ms: 0,
930
+ is_error: true,
931
+ num_turns: 0
932
+ };
933
+ this.emitter.emit(this.getEventName(sessionId), syntheticResult);
934
+ });
935
+ this.attachStdoutListener(sessionId, proc);
936
+ this.attachStderrListener(sessionId, proc);
937
+ this.attachExitListener(sessionId, proc);
938
+ }
939
+ /**
940
+ * 订阅指定会话的 Claude 事件流
941
+ *
942
+ * @returns 取消订阅函数
943
+ */
528
944
  onEvent(sessionId, callback) {
529
945
  const eventName = this.getEventName(sessionId);
530
946
  this.emitter.on(eventName, callback);
@@ -738,7 +1154,7 @@ var ProcessProvider = class {
738
1154
  console.warn(`[ProcessProvider] Session ${sessionId}: stdout unavailable`);
739
1155
  return;
740
1156
  }
741
- const rl = (0, import_readline.createInterface)({
1157
+ const rl = (0, import_readline2.createInterface)({
742
1158
  input: proc.stdout,
743
1159
  crlfDelay: Infinity
744
1160
  });
@@ -752,56 +1168,6 @@ var ProcessProvider = class {
752
1168
  const result = this.parseLine(trimmed);
753
1169
  if (result.ok) {
754
1170
  const event = result.value;
755
- if (event.type === "assistant") {
756
- for (const block of event.message.content) {
757
- if (block.type === "tool_use") {
758
- const isQuestion = block.name === "AskUserQuestion" || block.name === "AskFollowupQuestion";
759
- if (!isQuestion) continue;
760
- const input = block.input;
761
- let question = "";
762
- let options;
763
- let questions;
764
- if (typeof input.question === "string") {
765
- question = input.question;
766
- options = Array.isArray(input.options) ? input.options : void 0;
767
- } else if (Array.isArray(input.questions) && input.questions.length > 0) {
768
- questions = input.questions.map((q) => {
769
- const item = {
770
- question: typeof q.question === "string" ? q.question : "",
771
- header: typeof q.header === "string" ? q.header : void 0,
772
- multiSelect: typeof q.multiSelect === "boolean" ? q.multiSelect : void 0
773
- };
774
- if (Array.isArray(q.options)) {
775
- item.options = q.options.map((o) => ({
776
- label: typeof o.label === "string" ? o.label : String(o),
777
- description: typeof o.description === "string" ? o.description : void 0
778
- }));
779
- }
780
- return item;
781
- });
782
- const first = questions[0];
783
- question = first.question;
784
- options = first.options?.map((o) => o.label);
785
- }
786
- if (!question) continue;
787
- const prevKey = `${block.id}:${question}:${JSON.stringify(options ?? [])}`;
788
- let sessionSet = this.emittedQuestionToolUseIds.get(sessionId);
789
- if (!sessionSet) {
790
- sessionSet = /* @__PURE__ */ new Set();
791
- this.emittedQuestionToolUseIds.set(sessionId, sessionSet);
792
- }
793
- if (sessionSet.has(prevKey)) continue;
794
- sessionSet.add(prevKey);
795
- console.log(`[ProcessProvider] Session ${sessionId}: detected ${block.name} (toolUseId=${block.id})`);
796
- this.emitter.emit(this.getQuestionEventName(sessionId), {
797
- toolUseId: block.id,
798
- question,
799
- options,
800
- questions
801
- });
802
- }
803
- }
804
- }
805
1171
  this.updateSessionStatus(sessionId, event);
806
1172
  this.emitter.emit(this.getEventName(sessionId), event);
807
1173
  } else {
@@ -932,70 +1298,21 @@ ${context}`;
932
1298
  proc.once("error", reject);
933
1299
  });
934
1300
  }
935
- /**
936
- * 向正在等待中的 AskUserQuestion 提供答案
937
- *
938
- * 将答案写入 Claude 进程的 stdin(作为 tool_result),
939
- * Claude 收到后继续执行。
940
- */
941
- async answerQuestion(sessionId, toolUseId, answer) {
942
- const entry = this.activeSessions.get(sessionId);
943
- if (!entry) {
944
- throw new Error(`Session ${sessionId} not found`);
945
- }
946
- if (!entry.process.stdin || entry.process.stdin.destroyed) {
947
- throw new Error(`Session ${sessionId} stdin unavailable`);
948
- }
949
- const toolResult = JSON.stringify({
950
- type: "user",
951
- session_id: "",
952
- message: {
953
- role: "user",
954
- content: [{ type: "tool_result", tool_use_id: toolUseId, content: answer }]
955
- },
956
- parent_tool_use_id: toolUseId
957
- });
958
- await new Promise((resolve, reject) => {
959
- entry.process.stdin.write(toolResult + "\n", (err) => {
960
- if (err) reject(err);
961
- else resolve();
962
- });
963
- });
964
- console.log(`[ProcessProvider] Session ${sessionId}: AskUserQuestion answered (toolUseId=${toolUseId})`);
965
- }
966
- /**
967
- * 订阅指定会话的 AskUserQuestion 事件
968
- *
969
- * @returns 取消订阅函数
970
- */
971
- onQuestion(sessionId, callback) {
972
- const eventName = this.getQuestionEventName(sessionId);
973
- this.emitter.on(eventName, callback);
974
- return () => {
975
- this.emitter.off(eventName, callback);
976
- };
977
- }
978
1301
  /**
979
1302
  * 生成事件名称
980
1303
  */
981
1304
  getEventName(sessionId) {
982
1305
  return `claude:${sessionId}`;
983
1306
  }
984
- /**
985
- * 生成 AskUserQuestion 内部事件名称
986
- */
987
- getQuestionEventName(sessionId) {
988
- return `question:${sessionId}`;
989
- }
990
1307
  };
991
1308
 
992
1309
  // src/providers/CodexProvider.ts
993
1310
  var import_child_process2 = require("child_process");
994
- var import_readline2 = require("readline");
1311
+ var import_readline3 = require("readline");
995
1312
  var import_events2 = require("events");
996
1313
  var import_fs = require("fs");
997
- var import_path = require("path");
998
- var import_os = require("os");
1314
+ var import_path2 = require("path");
1315
+ var import_os2 = require("os");
999
1316
  var import_uuid2 = require("uuid");
1000
1317
 
1001
1318
  // src/utils/codexPath.ts
@@ -1074,9 +1391,9 @@ function isCodexAvailable() {
1074
1391
  }
1075
1392
 
1076
1393
  // src/providers/CodexProvider.ts
1077
- var SESSIX_DIR = (0, import_path.join)((0, import_os.homedir)(), ".sessix");
1078
- var CODEX_SESSIONS_FILE = (0, import_path.join)(SESSIX_DIR, "codex-sessions.json");
1079
- var CODEX_EVENTS_DIR = (0, import_path.join)(SESSIX_DIR, "codex-events");
1394
+ var SESSIX_DIR = (0, import_path2.join)((0, import_os2.homedir)(), ".sessix");
1395
+ var CODEX_SESSIONS_FILE = (0, import_path2.join)(SESSIX_DIR, "codex-sessions.json");
1396
+ var CODEX_EVENTS_DIR = (0, import_path2.join)(SESSIX_DIR, "codex-events");
1080
1397
  var SESSION_EXPIRY_MS = 30 * 24 * 60 * 60 * 1e3;
1081
1398
  var CodexProvider = class {
1082
1399
  activeSessions = /* @__PURE__ */ new Map();
@@ -1223,12 +1540,6 @@ var CodexProvider = class {
1223
1540
  async generateSuggestion(_context) {
1224
1541
  return "";
1225
1542
  }
1226
- async answerQuestion(_sessionId, _toolUseId, _answer) {
1227
- }
1228
- onQuestion(_sessionId, _callback) {
1229
- return () => {
1230
- };
1231
- }
1232
1543
  // ============================================
1233
1544
  // 私有方法
1234
1545
  // ============================================
@@ -1280,7 +1591,7 @@ var CodexProvider = class {
1280
1591
  */
1281
1592
  attachStdoutListener(sessionId, proc) {
1282
1593
  if (!proc.stdout) return;
1283
- const rl = (0, import_readline2.createInterface)({ input: proc.stdout, crlfDelay: Infinity });
1594
+ const rl = (0, import_readline3.createInterface)({ input: proc.stdout, crlfDelay: Infinity });
1284
1595
  const entry = this.activeSessions.get(sessionId);
1285
1596
  if (entry) entry.rl = rl;
1286
1597
  rl.on("line", (line) => {
@@ -1562,9 +1873,9 @@ var CodexProvider = class {
1562
1873
  * 优先从内存读,miss 时从磁盘加载
1563
1874
  */
1564
1875
  getSessionHistory(sessionId) {
1565
- const cached = this.sessionEvents.get(sessionId);
1566
- if (cached && cached.length > 0) return cached;
1567
- const filePath = (0, import_path.join)(CODEX_EVENTS_DIR, `${sessionId}.json`);
1876
+ const cached2 = this.sessionEvents.get(sessionId);
1877
+ if (cached2 && cached2.length > 0) return cached2;
1878
+ const filePath = (0, import_path2.join)(CODEX_EVENTS_DIR, `${sessionId}.json`);
1568
1879
  try {
1569
1880
  if (!(0, import_fs.existsSync)(filePath)) return [];
1570
1881
  const data = JSON.parse((0, import_fs.readFileSync)(filePath, "utf-8"));
@@ -1585,7 +1896,7 @@ var CodexProvider = class {
1585
1896
  (0, import_fs.mkdirSync)(CODEX_EVENTS_DIR, { recursive: true });
1586
1897
  }
1587
1898
  (0, import_fs.writeFileSync)(
1588
- (0, import_path.join)(CODEX_EVENTS_DIR, `${sessionId}.json`),
1899
+ (0, import_path2.join)(CODEX_EVENTS_DIR, `${sessionId}.json`),
1589
1900
  JSON.stringify(events),
1590
1901
  "utf-8"
1591
1902
  );
@@ -1610,7 +1921,7 @@ var CodexProvider = class {
1610
1921
  if (now - m.lastActiveAt > SESSION_EXPIRY_MS) {
1611
1922
  expiredCount++;
1612
1923
  try {
1613
- const eventsFile = (0, import_path.join)(CODEX_EVENTS_DIR, `${sessionId}.json`);
1924
+ const eventsFile = (0, import_path2.join)(CODEX_EVENTS_DIR, `${sessionId}.json`);
1614
1925
  if ((0, import_fs.existsSync)(eventsFile)) (0, import_fs.unlinkSync)(eventsFile);
1615
1926
  } catch {
1616
1927
  }
@@ -1723,7 +2034,6 @@ var ProviderFactory = class {
1723
2034
  };
1724
2035
 
1725
2036
  // src/session/SessionManager.ts
1726
- var import_uuid3 = require("uuid");
1727
2037
  var BUFFER_MAX = 5e3;
1728
2038
  var SessionManager = class {
1729
2039
  provider;
@@ -1902,6 +2212,21 @@ var SessionManager = class {
1902
2212
  this.updateSessionStatus(sessionId, "running");
1903
2213
  }
1904
2214
  }
2215
+ /**
2216
+ * 幂等清理单个待回答问题(由 ApprovalProxy onQuestionResolved 触发:
2217
+ * 答案到达 / 325s 超时 / 会话 kill / 服务关闭)。
2218
+ * 已不存在则静默返回(不打 warn,与 handleQuestionResponse 区分)。
2219
+ */
2220
+ clearPendingQuestion(requestId) {
2221
+ const pending = this.pendingQuestions.get(requestId);
2222
+ if (!pending) return;
2223
+ const { sessionId } = pending;
2224
+ this.pendingQuestions.delete(requestId);
2225
+ pending.resolve("");
2226
+ if (!this.hasPendingQuestionsForSession(sessionId)) {
2227
+ this.updateSessionStatus(sessionId, "running");
2228
+ }
2229
+ }
1905
2230
  /**
1906
2231
  * 获取指定会话的所有待回答问题(用于重连时恢复)
1907
2232
  */
@@ -2043,6 +2368,9 @@ var SessionManager = class {
2043
2368
  clearTimeout(pending.timer);
2044
2369
  }
2045
2370
  this.pendingAssistantEvents.clear();
2371
+ for (const pending of this.pendingQuestions.values()) {
2372
+ pending.resolve("");
2373
+ }
2046
2374
  this.pendingQuestions.clear();
2047
2375
  this.lastBroadcastStatus.clear();
2048
2376
  this.eventCallbacks.length = 0;
@@ -2052,22 +2380,15 @@ var SessionManager = class {
2052
2380
  // 内部方法
2053
2381
  // ============================================
2054
2382
  /**
2055
- * 订阅指定会话的事件流(包括 AskUserQuestion 问题事件)
2383
+ * 订阅指定会话的事件流(AskUserQuestion 已改由 ApprovalProxy hook 驱动)
2056
2384
  */
2057
2385
  subscribeToSession(sessionId) {
2058
2386
  const provider = this.getProviderForSession(sessionId);
2059
2387
  const unsubscribeEvent = provider.onEvent(sessionId, (event) => {
2060
2388
  this.handleClaudeEvent(sessionId, event);
2061
2389
  });
2062
- const unsubscribeQuestion = provider.onQuestion(
2063
- sessionId,
2064
- ({ toolUseId, question, options, questions }) => {
2065
- this.handleAskUserQuestion(sessionId, toolUseId, question, options, questions);
2066
- }
2067
- );
2068
2390
  this.unsubscribeMap.set(sessionId, () => {
2069
2391
  unsubscribeEvent();
2070
- unsubscribeQuestion();
2071
2392
  });
2072
2393
  }
2073
2394
  /**
@@ -2216,55 +2537,34 @@ var SessionManager = class {
2216
2537
  return runningStartedAt ? { ...base, runningStartedAt } : base;
2217
2538
  }
2218
2539
  /**
2219
- * 处理 AskUserQuestion 事件:广播问题请求到手机,等待用户回答
2540
+ * ApprovalProxy 在 PreToolUse hook 拦截到 AskUserQuestion 时调用。
2541
+ * 登记 pendingQuestion、广播 question_request、置 waiting_question,
2542
+ * 返回的 Promise 在 handleQuestionResponse 时 resolve。
2220
2543
  */
2221
- handleAskUserQuestion(sessionId, toolUseId, question, options, questions) {
2222
- const existingEntry = Array.from(this.pendingQuestions.entries()).find(
2223
- ([, v]) => v.toolUseId === toolUseId
2224
- );
2225
- if (existingEntry) {
2226
- const [existingRequestId, existingPending] = existingEntry;
2227
- existingPending.question = question;
2228
- existingPending.options = options;
2229
- existingPending.questions = questions;
2230
- existingPending.createdAt = Date.now();
2231
- const updatedRequest = {
2232
- id: existingRequestId,
2233
- sessionId,
2234
- toolUseId,
2235
- question,
2236
- options,
2237
- questions,
2238
- createdAt: existingPending.createdAt
2239
- };
2240
- this.emit({ type: "question_request", request: updatedRequest });
2241
- console.log(`[SessionManager] Session ${sessionId}: AskUserQuestion updated (requestId=${existingRequestId})`);
2242
- return;
2243
- }
2244
- const requestId = (0, import_uuid3.v4)();
2544
+ askQuestion(sessionId, toolUseId, questions, requestId) {
2245
2545
  const request = {
2246
2546
  id: requestId,
2247
2547
  sessionId,
2248
2548
  toolUseId,
2249
- question,
2250
- options,
2549
+ question: questions[0]?.question ?? "",
2550
+ options: questions[0]?.options?.map((o) => o.label),
2251
2551
  questions,
2252
2552
  createdAt: Date.now()
2253
2553
  };
2254
2554
  this.updateSessionStatus(sessionId, "waiting_question");
2255
2555
  this.emit({ type: "question_request", request });
2256
- const answerPromise = new Promise((resolve) => {
2257
- this.pendingQuestions.set(requestId, { sessionId, toolUseId, question, options, questions, createdAt: request.createdAt, resolve });
2258
- });
2259
- answerPromise.then(async (answer) => {
2260
- try {
2261
- const provider = this.getProviderForSession(sessionId);
2262
- await provider.answerQuestion(sessionId, toolUseId, answer);
2263
- } catch (err) {
2264
- console.error(`[SessionManager] answerQuestion failed (${sessionId}):`, err);
2265
- }
2266
- }).catch((err) => console.error("[SessionManager] answerPromise rejected:", err));
2267
2556
  console.log(`[SessionManager] Session ${sessionId}: AskUserQuestion pushed (requestId=${requestId})`);
2557
+ return new Promise((resolve) => {
2558
+ this.pendingQuestions.set(requestId, {
2559
+ sessionId,
2560
+ toolUseId,
2561
+ question: request.question,
2562
+ options: request.options,
2563
+ questions,
2564
+ createdAt: request.createdAt,
2565
+ resolve
2566
+ });
2567
+ });
2268
2568
  }
2269
2569
  /**
2270
2570
  * 清除指定会话的所有待回答问题
@@ -2277,6 +2577,7 @@ var SessionManager = class {
2277
2577
  }
2278
2578
  }
2279
2579
  for (const requestId of toRemove) {
2580
+ this.pendingQuestions.get(requestId)?.resolve("");
2280
2581
  this.pendingQuestions.delete(requestId);
2281
2582
  }
2282
2583
  }
@@ -2296,7 +2597,7 @@ var SessionManager = class {
2296
2597
 
2297
2598
  // src/session/SessionFileWatcher.ts
2298
2599
  var import_chokidar = __toESM(require("chokidar"));
2299
- var import_promises = require("fs/promises");
2600
+ var import_promises2 = require("fs/promises");
2300
2601
  var import_node_readline = require("readline");
2301
2602
  var SessionFileWatcher = class {
2302
2603
  watchers = /* @__PURE__ */ new Map();
@@ -2375,7 +2676,7 @@ var SessionFileWatcher = class {
2375
2676
  let fileHandle;
2376
2677
  let rl;
2377
2678
  try {
2378
- fileHandle = await (0, import_promises.open)(entry.filePath, "r");
2679
+ fileHandle = await (0, import_promises2.open)(entry.filePath, "r");
2379
2680
  const fileStat = await fileHandle.stat();
2380
2681
  const newSize = fileStat.size;
2381
2682
  if (newSize <= entry.byteOffset) return;
@@ -2685,7 +2986,7 @@ var import_node_http = __toESM(require("http"));
2685
2986
  var import_node_fs3 = __toESM(require("fs"));
2686
2987
  var import_node_path3 = __toESM(require("path"));
2687
2988
  var import_node_os4 = __toESM(require("os"));
2688
- var import_uuid4 = require("uuid");
2989
+ var import_uuid3 = require("uuid");
2689
2990
  var ApprovalProxy = class _ApprovalProxy {
2690
2991
  server;
2691
2992
  token;
@@ -2693,10 +2994,16 @@ var ApprovalProxy = class _ApprovalProxy {
2693
2994
  settingsPath = import_node_path3.default.join(import_node_os4.default.homedir(), ".claude", "settings.json");
2694
2995
  /** 待处理的审批请求:requestId -> { resolve, timer, request } */
2695
2996
  pendingApprovals = /* @__PURE__ */ new Map();
2997
+ /** 待回答的 AskUserQuestion:requestId -> { resolve, timer, request } */
2998
+ pendingQuestions = /* @__PURE__ */ new Map();
2999
+ /** 由外部注入:把问题推给手机并等待答案(返回用户答案文本) */
3000
+ questionHandler = null;
2696
3001
  /** 审批请求回调(通知外部推送到手机) */
2697
3002
  approvalRequestCallbacks = [];
2698
3003
  /** 审批 resolve 回调(任何来源的 resolve 都会触发,用于 WS 广播清理 */
2699
3004
  approvalResolvedCallbacks = [];
3005
+ /** 问题 resolve 回调(任何来源的 resolve 都会触发,用于 SessionManager 清理) */
3006
+ questionResolvedCallbacks = [];
2700
3007
  /** Hook 通知回调(PreCompact / PermissionDenied / 等非阻塞 hook) */
2701
3008
  notifyCallbacks = [];
2702
3009
  /** YOLO 模式状态:sessionId -> enabled */
@@ -2760,6 +3067,30 @@ var ApprovalProxy = class _ApprovalProxy {
2760
3067
  }
2761
3068
  }
2762
3069
  }
3070
+ /**
3071
+ * 注册问题 resolve 回调
3072
+ *
3073
+ * 任何来源的 resolve 都会触发:
3074
+ * - resolveQuestion(手机端答案到达)
3075
+ * - 325s 超时自动空答案
3076
+ * - clearPendingQuestionsForSession(会话被 kill)
3077
+ * - close()(服务关闭)
3078
+ *
3079
+ * 用于通知 SessionManager 清理 pendingQuestions,避免会话卡在 waiting_question。
3080
+ */
3081
+ onQuestionResolved(callback) {
3082
+ this.questionResolvedCallbacks.push(callback);
3083
+ }
3084
+ /** 通知所有问题 resolve 回调(内部调用) */
3085
+ notifyQuestionResolved(requestId) {
3086
+ for (const cb of this.questionResolvedCallbacks) {
3087
+ try {
3088
+ cb(requestId);
3089
+ } catch (err) {
3090
+ console.error("[ApprovalProxy] question resolved callback error:", err);
3091
+ }
3092
+ }
3093
+ }
2763
3094
  /**
2764
3095
  * 注册非阻塞 hook 通知回调(如 PreCompact、PermissionDenied)
2765
3096
  *
@@ -2814,6 +3145,42 @@ var ApprovalProxy = class _ApprovalProxy {
2814
3145
  this.notifyApprovalResolved(requestId, decision);
2815
3146
  return true;
2816
3147
  }
3148
+ /** 注入问题处理器(server.ts 接到 SessionManager.askQuestion) */
3149
+ setQuestionHandler(handler) {
3150
+ this.questionHandler = handler;
3151
+ }
3152
+ /** 解析一个待回答问题(手机端答案到达时由 server.ts 调用) */
3153
+ resolveQuestion(requestId, answer) {
3154
+ const pending = this.pendingQuestions.get(requestId);
3155
+ if (!pending) {
3156
+ console.warn(`[ApprovalProxy] Question request not found: ${requestId}`);
3157
+ return false;
3158
+ }
3159
+ clearTimeout(pending.timer);
3160
+ pending.resolve(answer);
3161
+ this.pendingQuestions.delete(requestId);
3162
+ console.log(`[ApprovalProxy] Question answered: ${requestId}`);
3163
+ this.notifyQuestionResolved(requestId);
3164
+ return true;
3165
+ }
3166
+ /** 清理会话的待回答问题(会话被 kill 时,给空答案让 hook 不再阻塞) */
3167
+ clearPendingQuestionsForSession(sessionId) {
3168
+ const toRemove = [];
3169
+ for (const [requestId, pending] of this.pendingQuestions) {
3170
+ if (pending.request.sessionId === sessionId) {
3171
+ toRemove.push(requestId);
3172
+ }
3173
+ }
3174
+ for (const requestId of toRemove) {
3175
+ const pending = this.pendingQuestions.get(requestId);
3176
+ if (!pending) continue;
3177
+ clearTimeout(pending.timer);
3178
+ pending.resolve("");
3179
+ this.pendingQuestions.delete(requestId);
3180
+ console.log(`[ApprovalProxy] Session ${sessionId} killed, cleared pending question ${requestId}`);
3181
+ this.notifyQuestionResolved(requestId);
3182
+ }
3183
+ }
2817
3184
  /** 获取当前待处理的审批数量 */
2818
3185
  getPendingCount() {
2819
3186
  return this.pendingApprovals.size;
@@ -2935,6 +3302,13 @@ var ApprovalProxy = class _ApprovalProxy {
2935
3302
  pending.resolve({ decision: "deny", reason: t("approval.serverClosed") });
2936
3303
  }
2937
3304
  this.pendingApprovals.clear();
3305
+ const pendingQuestionEntries = Array.from(this.pendingQuestions.entries());
3306
+ for (const [requestId, pending] of pendingQuestionEntries) {
3307
+ clearTimeout(pending.timer);
3308
+ pending.resolve("");
3309
+ this.notifyQuestionResolved(requestId);
3310
+ }
3311
+ this.pendingQuestions.clear();
2938
3312
  this.server.close((err) => {
2939
3313
  if (err) {
2940
3314
  reject(err);
@@ -2990,7 +3364,7 @@ var ApprovalProxy = class _ApprovalProxy {
2990
3364
  try {
2991
3365
  const body = await this.parseJsonBody(req);
2992
3366
  const payload = body.payload ?? body;
2993
- const requestId = (0, import_uuid4.v4)();
3367
+ const requestId = (0, import_uuid3.v4)();
2994
3368
  const projectPath = String(body.projectPath ?? "unknown");
2995
3369
  const toolName = String(payload.tool_name ?? body.tool_name ?? "unknown");
2996
3370
  const toolInput = payload.tool_input ?? body.tool_input ?? {};
@@ -3004,6 +3378,51 @@ var ApprovalProxy = class _ApprovalProxy {
3004
3378
  createdAt: Date.now()
3005
3379
  };
3006
3380
  console.log(`[ApprovalProxy] ${t("approval.received")}: ${requestId} (${approvalRequest.toolName})`);
3381
+ if ((approvalRequest.toolName === "AskUserQuestion" || approvalRequest.toolName === "AskFollowupQuestion") && this.questionHandler) {
3382
+ const questions = parseQuestionsFromInput(toolInput);
3383
+ if (questions.length === 0) {
3384
+ this.sendJson(res, 200, { decision: "allow" });
3385
+ return;
3386
+ }
3387
+ const toolUseId = String(
3388
+ payload.tool_use_id ?? body.tool_use_id ?? ""
3389
+ );
3390
+ const qRequest = {
3391
+ id: requestId,
3392
+ sessionId: approvalRequest.sessionId,
3393
+ toolUseId,
3394
+ question: questions[0].question,
3395
+ options: questions[0].options?.map((o) => o.label),
3396
+ questions,
3397
+ createdAt: Date.now()
3398
+ };
3399
+ const answer = await new Promise((resolve) => {
3400
+ const timer = setTimeout(() => {
3401
+ this.pendingQuestions.delete(requestId);
3402
+ console.log(`[ApprovalProxy] Question timeout: ${requestId}`);
3403
+ resolve("");
3404
+ this.notifyQuestionResolved(requestId);
3405
+ }, 325e3);
3406
+ this.pendingQuestions.set(requestId, { resolve, timer, request: qRequest });
3407
+ this.questionHandler(qRequest.sessionId, toolUseId, questions, requestId).then((ans) => {
3408
+ if (ans && this.pendingQuestions.has(requestId)) this.resolveQuestion(requestId, ans);
3409
+ }).catch((err) => console.error("[ApprovalProxy] questionHandler error:", err));
3410
+ });
3411
+ if (!answer) {
3412
+ this.sendJson(res, 200, {
3413
+ decision: "deny",
3414
+ 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",
3415
+ systemMessage: "\u7528\u6237\u672A\u56DE\u7B54 AskUserQuestion\uFF1B\u8BF7\u52FF\u91CD\u8BD5\u8BE5\u5DE5\u5177\uFF0C\u81EA\u884C\u51B3\u7B56\u7EE7\u7EED\u3002"
3416
+ });
3417
+ return;
3418
+ }
3419
+ this.sendJson(res, 200, {
3420
+ decision: "deny",
3421
+ reason: formatQuestionAnswer(questions, answer),
3422
+ 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"
3423
+ });
3424
+ return;
3425
+ }
3007
3426
  if (this.isToolAlwaysAllowed(approvalRequest.toolName, projectPath !== "unknown" ? projectPath : void 0)) {
3008
3427
  console.log(`[ApprovalProxy] ${t("approval.alwaysAllowPassThrough", { tool: approvalRequest.toolName })}`);
3009
3428
  this.sendJson(res, 200, { decision: "allow" });
@@ -3193,6 +3612,49 @@ var ApprovalProxy = class _ApprovalProxy {
3193
3612
  res.end(body);
3194
3613
  }
3195
3614
  };
3615
+ function parseQuestionsFromInput(input) {
3616
+ if (Array.isArray(input.questions) && input.questions.length > 0) {
3617
+ return input.questions.map((q) => {
3618
+ const item = {
3619
+ question: typeof q.question === "string" ? q.question : "",
3620
+ header: typeof q.header === "string" ? q.header : void 0,
3621
+ multiSelect: typeof q.multiSelect === "boolean" ? q.multiSelect : void 0
3622
+ };
3623
+ if (Array.isArray(q.options)) {
3624
+ item.options = q.options.map((o) => ({
3625
+ label: typeof o.label === "string" ? o.label : "",
3626
+ description: typeof o.description === "string" ? o.description : void 0
3627
+ }));
3628
+ }
3629
+ return item;
3630
+ });
3631
+ }
3632
+ if (typeof input.question === "string") {
3633
+ const opts = Array.isArray(input.options) ? input.options.map((o) => ({ label: String(o) })) : void 0;
3634
+ return [{ question: input.question, options: opts }];
3635
+ }
3636
+ return [];
3637
+ }
3638
+ function formatQuestionAnswer(questions, raw) {
3639
+ let pairs = [];
3640
+ try {
3641
+ const parsed = JSON.parse(raw);
3642
+ if (parsed && typeof parsed === "object" && parsed.answers && typeof parsed.answers === "object") {
3643
+ const answers = parsed.answers;
3644
+ 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) }));
3645
+ }
3646
+ } catch {
3647
+ }
3648
+ if (pairs.length === 0) {
3649
+ pairs = [{ q: questions[0]?.question ?? "\uFF08\u672A\u77E5\u95EE\u9898\uFF09", a: raw }];
3650
+ }
3651
+ const body = pairs.map((p, i) => `${i + 1}. \u95EE\u9898\uFF1A${p.q}
3652
+ \u7528\u6237\u56DE\u7B54\uFF1A${p.a}`).join("\n");
3653
+ return `\u3010\u7528\u6237\u5DF2\u901A\u8FC7 Sessix \u56DE\u7B54\u3011
3654
+ ${body}
3655
+
3656
+ \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`;
3657
+ }
3196
3658
 
3197
3659
  // src/mdns/MdnsService.ts
3198
3660
  var import_node_child_process5 = require("child_process");
@@ -3358,7 +3820,7 @@ function getLanAddresses(networkInterfacesFn) {
3358
3820
  }
3359
3821
 
3360
3822
  // src/hooks/HookInstaller.ts
3361
- var import_promises2 = require("fs/promises");
3823
+ var import_promises3 = require("fs/promises");
3362
3824
  var import_node_path4 = require("path");
3363
3825
  var import_node_os6 = require("os");
3364
3826
  var SESSIX_HOOKS_DIR = (0, import_node_path4.join)((0, import_node_os6.homedir)(), ".sessix", "hooks");
@@ -3378,7 +3840,7 @@ var LEGACY_HOOK_COMMANDS = [
3378
3840
  "~/.sessix/hooks/permission-accept.sh"
3379
3841
  ];
3380
3842
  var HOOK_SCRIPT_TEMPLATE = `#!/usr/bin/env node
3381
- // Sessix Approval Hook
3843
+ // Sessix Approval Hook v2 (systemMessage passthrough)
3382
3844
  // \u4EC5\u5728 Sessix \u7BA1\u7406\u7684\u4F1A\u8BDD\u4E2D\u6FC0\u6D3B
3383
3845
 
3384
3846
  const sessionId = process.env.SESSIX_SESSION_ID
@@ -3409,6 +3871,9 @@ process.stdin.on('end', async () => {
3409
3871
  if (decision === 'deny' && data.reason) {
3410
3872
  output.hookSpecificOutput.permissionDecisionReason = String(data.reason)
3411
3873
  }
3874
+ if (data.systemMessage) {
3875
+ output.systemMessage = String(data.systemMessage)
3876
+ }
3412
3877
  process.stdout.write(JSON.stringify(output))
3413
3878
  process.exit(0)
3414
3879
  } catch {
@@ -3548,17 +4013,17 @@ var HookInstaller = class {
3548
4013
  * 4. 更新 Claude Code settings.json 添加 hook 配置
3549
4014
  */
3550
4015
  async install() {
3551
- await (0, import_promises2.mkdir)(SESSIX_HOOKS_DIR, { recursive: true });
3552
- await (0, import_promises2.writeFile)(HOOK_SCRIPT_PATH, HOOK_SCRIPT_TEMPLATE, "utf-8");
3553
- await (0, import_promises2.writeFile)(PERMISSION_ACCEPT_PATH, PERMISSION_ACCEPT_TEMPLATE, "utf-8");
3554
- await (0, import_promises2.writeFile)(COMPACT_HOOK_PATH, COMPACT_HOOK_TEMPLATE, "utf-8");
3555
- await (0, import_promises2.writeFile)(POST_COMPACT_HOOK_PATH, POST_COMPACT_HOOK_TEMPLATE, "utf-8");
3556
- await (0, import_promises2.writeFile)(PERMISSION_DENIED_HOOK_PATH, PERMISSION_DENIED_HOOK_TEMPLATE, "utf-8");
3557
- await (0, import_promises2.chmod)(HOOK_SCRIPT_PATH, 493);
3558
- await (0, import_promises2.chmod)(PERMISSION_ACCEPT_PATH, 493);
3559
- await (0, import_promises2.chmod)(COMPACT_HOOK_PATH, 493);
3560
- await (0, import_promises2.chmod)(POST_COMPACT_HOOK_PATH, 493);
3561
- await (0, import_promises2.chmod)(PERMISSION_DENIED_HOOK_PATH, 493);
4016
+ await (0, import_promises3.mkdir)(SESSIX_HOOKS_DIR, { recursive: true });
4017
+ await (0, import_promises3.writeFile)(HOOK_SCRIPT_PATH, HOOK_SCRIPT_TEMPLATE, "utf-8");
4018
+ await (0, import_promises3.writeFile)(PERMISSION_ACCEPT_PATH, PERMISSION_ACCEPT_TEMPLATE, "utf-8");
4019
+ await (0, import_promises3.writeFile)(COMPACT_HOOK_PATH, COMPACT_HOOK_TEMPLATE, "utf-8");
4020
+ await (0, import_promises3.writeFile)(POST_COMPACT_HOOK_PATH, POST_COMPACT_HOOK_TEMPLATE, "utf-8");
4021
+ await (0, import_promises3.writeFile)(PERMISSION_DENIED_HOOK_PATH, PERMISSION_DENIED_HOOK_TEMPLATE, "utf-8");
4022
+ await (0, import_promises3.chmod)(HOOK_SCRIPT_PATH, 493);
4023
+ await (0, import_promises3.chmod)(PERMISSION_ACCEPT_PATH, 493);
4024
+ await (0, import_promises3.chmod)(COMPACT_HOOK_PATH, 493);
4025
+ await (0, import_promises3.chmod)(POST_COMPACT_HOOK_PATH, 493);
4026
+ await (0, import_promises3.chmod)(PERMISSION_DENIED_HOOK_PATH, 493);
3562
4027
  await this.addHookToSettings();
3563
4028
  console.log("[HookInstaller] Hook installation complete");
3564
4029
  }
@@ -3588,30 +4053,30 @@ var HookInstaller = class {
3588
4053
  let postCompactScriptExists = false;
3589
4054
  let permissionDeniedScriptExists = false;
3590
4055
  try {
3591
- approvalScriptContent = await (0, import_promises2.readFile)(HOOK_SCRIPT_PATH, "utf-8");
4056
+ approvalScriptContent = await (0, import_promises3.readFile)(HOOK_SCRIPT_PATH, "utf-8");
3592
4057
  } catch {
3593
4058
  }
3594
4059
  try {
3595
- await (0, import_promises2.access)(PERMISSION_ACCEPT_PATH);
4060
+ await (0, import_promises3.access)(PERMISSION_ACCEPT_PATH);
3596
4061
  permissionScriptExists = true;
3597
4062
  } catch {
3598
4063
  }
3599
4064
  try {
3600
- await (0, import_promises2.access)(COMPACT_HOOK_PATH);
4065
+ await (0, import_promises3.access)(COMPACT_HOOK_PATH);
3601
4066
  compactScriptExists = true;
3602
4067
  } catch {
3603
4068
  }
3604
4069
  try {
3605
- await (0, import_promises2.access)(POST_COMPACT_HOOK_PATH);
4070
+ await (0, import_promises3.access)(POST_COMPACT_HOOK_PATH);
3606
4071
  postCompactScriptExists = true;
3607
4072
  } catch {
3608
4073
  }
3609
4074
  try {
3610
- await (0, import_promises2.access)(PERMISSION_DENIED_HOOK_PATH);
4075
+ await (0, import_promises3.access)(PERMISSION_DENIED_HOOK_PATH);
3611
4076
  permissionDeniedScriptExists = true;
3612
4077
  } catch {
3613
4078
  }
3614
- const isLatestVersion = approvalScriptContent.includes("permissionDecision");
4079
+ const isLatestVersion = approvalScriptContent.includes("permissionDecision") && approvalScriptContent.includes("Sessix Approval Hook v2");
3615
4080
  const settings = await this.readClaudeSettings();
3616
4081
  const configExists = this.hasHookConfig(settings);
3617
4082
  return isLatestVersion && permissionScriptExists && compactScriptExists && postCompactScriptExists && permissionDeniedScriptExists && configExists;
@@ -3719,7 +4184,7 @@ var HookInstaller = class {
3719
4184
  */
3720
4185
  async readClaudeSettings() {
3721
4186
  try {
3722
- const content = await (0, import_promises2.readFile)(CLAUDE_SETTINGS_PATH, "utf-8");
4187
+ const content = await (0, import_promises3.readFile)(CLAUDE_SETTINGS_PATH, "utf-8");
3723
4188
  return JSON.parse(content);
3724
4189
  } catch {
3725
4190
  return {};
@@ -3729,8 +4194,8 @@ var HookInstaller = class {
3729
4194
  * 写入 Claude Code settings.json
3730
4195
  */
3731
4196
  async writeClaudeSettings(settings) {
3732
- await (0, import_promises2.mkdir)((0, import_node_path4.join)((0, import_node_os6.homedir)(), ".claude"), { recursive: true });
3733
- await (0, import_promises2.writeFile)(CLAUDE_SETTINGS_PATH, JSON.stringify(settings, null, 2) + "\n", "utf-8");
4197
+ await (0, import_promises3.mkdir)((0, import_node_path4.join)((0, import_node_os6.homedir)(), ".claude"), { recursive: true });
4198
+ await (0, import_promises3.writeFile)(CLAUDE_SETTINGS_PATH, JSON.stringify(settings, null, 2) + "\n", "utf-8");
3734
4199
  }
3735
4200
  /**
3736
4201
  * 检查 settings 中是否已包含所有 Sessix hook 配置
@@ -3771,7 +4236,7 @@ var HookInstaller = class {
3771
4236
  var import_node_path5 = require("path");
3772
4237
  var RECENT_ACTIVITY_MAX = 6;
3773
4238
  var ACTIVITY_PUSH_THROTTLE_MS = 4e3;
3774
- var NotificationService = class {
4239
+ var NotificationService = class _NotificationService {
3775
4240
  constructor(sessionManager, expoChannel = null) {
3776
4241
  this.sessionManager = sessionManager;
3777
4242
  this.expoChannel = expoChannel;
@@ -3813,6 +4278,14 @@ var NotificationService = class {
3813
4278
  * token 注册时启动,flushActivityEnd / removeActivityPushToken 时停止。
3814
4279
  */
3815
4280
  laHeartbeatTimers = /* @__PURE__ */ new Map();
4281
+ /**
4282
+ * 上次推送的内容指纹(status + recentActivity + approvalId)。
4283
+ * 只在内容实际变化时发 priority-10 推送;未变化时低频刷新(30s),
4284
+ * 节省 APNs push budget,避免 iOS 节流导致 LA 停滞。
4285
+ */
4286
+ lastPushedFingerprint = /* @__PURE__ */ new Map();
4287
+ /** 内容未变化时低频刷新间隔(仅刷新 stats/timer,不含内容变化) */
4288
+ static STATS_REFRESH_INTERVAL_MS = 3e4;
3816
4289
  /** 添加通知渠道(id 唯一,可用于后续动态开关) */
3817
4290
  addChannel(id, channel, enabled = true) {
3818
4291
  this.channelMap.set(id, { channel, enabled });
@@ -3830,9 +4303,21 @@ var NotificationService = class {
3830
4303
  removePushToken(token) {
3831
4304
  this.expoChannel?.removeToken(token);
3832
4305
  }
4306
+ /** 注册原生 APNs device token(直连 APNs,优先于 Expo 推送服务) */
4307
+ addNativePushToken(token, ws) {
4308
+ this.activityPushChannel?.addAlertToken(token, ws);
4309
+ if (this.activityPushChannel?.hasAlertTokens()) {
4310
+ console.log("[NotificationService] \u2705 \u76F4\u8FDE APNs alert token \u5DF2\u6CE8\u518C\uFF0CExpo \u63A8\u9001\u6E20\u9053\u964D\u7EA7\u5907\u7528");
4311
+ }
4312
+ }
4313
+ /** 移除原生 APNs device token */
4314
+ removeNativePushToken(token) {
4315
+ this.activityPushChannel?.removeAlertToken(token);
4316
+ }
3833
4317
  /** 更新通知音效偏好 */
3834
4318
  setSoundPreferences(prefs) {
3835
4319
  this.expoChannel?.setSoundPreferences(prefs);
4320
+ this.activityPushChannel?.setAlertSoundPreferences(prefs);
3836
4321
  }
3837
4322
  /** 设置 ActivityKit Push 渠道(可选,需要 APNs 认证配置) */
3838
4323
  setActivityPushChannel(channel) {
@@ -3857,6 +4342,7 @@ var NotificationService = class {
3857
4342
  this.recentActivityState.delete(sessionId);
3858
4343
  this.lastActivityPushAt.delete(sessionId);
3859
4344
  this.activityCounters.delete(sessionId);
4345
+ this.lastPushedFingerprint.delete(sessionId);
3860
4346
  }
3861
4347
  /** 注入"会话 → 待审批列表"提供器(server.ts 启动时调用) */
3862
4348
  setPendingApprovalsProvider(fn) {
@@ -4007,6 +4493,7 @@ var NotificationService = class {
4007
4493
  this.lastActivityPushAt.clear();
4008
4494
  this.pendingPriority.clear();
4009
4495
  this.activityCounters.clear();
4496
+ this.lastPushedFingerprint.clear();
4010
4497
  }
4011
4498
  // ============================================
4012
4499
  // 内部方法
@@ -4058,12 +4545,19 @@ var NotificationService = class {
4058
4545
  }
4059
4546
  }
4060
4547
  notify(payload) {
4061
- for (const { channel, enabled } of this.channelMap.values()) {
4548
+ const hasDirectApns = this.activityPushChannel?.hasAlertTokens() === true;
4549
+ for (const [id, { channel, enabled }] of this.channelMap.entries()) {
4062
4550
  if (!enabled) continue;
4551
+ if (id === "expo" && hasDirectApns) continue;
4063
4552
  channel.send(payload).catch((err) => {
4064
4553
  console.error("[NotificationService] Notification send failed:", err);
4065
4554
  });
4066
4555
  }
4556
+ if (hasDirectApns && this.activityPushChannel) {
4557
+ this.activityPushChannel.send(payload).catch((err) => {
4558
+ console.error("[NotificationService] Direct APNs push failed, no fallback:", err);
4559
+ });
4560
+ }
4067
4561
  }
4068
4562
  /** 从 assistant 事件中提取最新文本消息 */
4069
4563
  trackAssistantText(sessionId, event) {
@@ -4100,6 +4594,8 @@ var NotificationService = class {
4100
4594
  while (state2.history.length > RECENT_ACTIVITY_MAX) state2.history.shift();
4101
4595
  state2.currentEntries = [];
4102
4596
  state2.currentMessageId = null;
4597
+ state2.accumulatedText = "";
4598
+ state2.countedToolIds = /* @__PURE__ */ new Set();
4103
4599
  }
4104
4600
  return;
4105
4601
  }
@@ -4108,7 +4604,7 @@ var NotificationService = class {
4108
4604
  if (!Array.isArray(msg.content)) return;
4109
4605
  let state = this.recentActivityState.get(sessionId);
4110
4606
  if (!state) {
4111
- state = { history: [], currentMessageId: null, currentEntries: [] };
4607
+ state = { history: [], currentMessageId: null, currentEntries: [], accumulatedText: "", countedToolIds: /* @__PURE__ */ new Set() };
4112
4608
  this.recentActivityState.set(sessionId, state);
4113
4609
  }
4114
4610
  if (state.currentMessageId !== msg.id) {
@@ -4118,16 +4614,25 @@ var NotificationService = class {
4118
4614
  }
4119
4615
  state.currentEntries = [];
4120
4616
  state.currentMessageId = msg.id;
4617
+ state.accumulatedText = "";
4618
+ state.countedToolIds = /* @__PURE__ */ new Set();
4619
+ }
4620
+ for (const block of msg.content) {
4621
+ if (block.type === "text" && typeof block.text === "string") {
4622
+ state.accumulatedText += block.text;
4623
+ }
4121
4624
  }
4122
4625
  const next = [];
4626
+ const accText = this.summarizeText(state.accumulatedText);
4627
+ if (accText.length >= 4) next.push(accText);
4123
4628
  for (const block of msg.content) {
4124
- if (block.type === "text") {
4125
- const line = this.summarizeText(block.text);
4126
- if (line.length >= 4) next.push(line);
4127
- } else if (block.type === "tool_use") {
4629
+ if (block.type === "tool_use") {
4128
4630
  const line = this.summarizeToolCall(block.name, block.input ?? {});
4129
4631
  if (line) next.push(line);
4130
- this.incrementCounter(sessionId, block.name);
4632
+ if (!state.countedToolIds.has(block.id)) {
4633
+ state.countedToolIds.add(block.id);
4634
+ this.incrementCounter(sessionId, block.name);
4635
+ }
4131
4636
  }
4132
4637
  }
4133
4638
  state.currentEntries = next;
@@ -4243,7 +4748,7 @@ var NotificationService = class {
4243
4748
  return;
4244
4749
  }
4245
4750
  if (session.status === "running" || session.status === "waiting_approval" || session.status === "waiting_question") {
4246
- this.scheduleActivityPush(sessionId);
4751
+ this.scheduleActivityPush(sessionId, true);
4247
4752
  }
4248
4753
  }, ACTIVITY_PUSH_THROTTLE_MS);
4249
4754
  this.laHeartbeatTimers.set(sessionId, timer);
@@ -4280,6 +4785,7 @@ var NotificationService = class {
4280
4785
  * 无 token → 普通 Expo push。清理所有相关状态。
4281
4786
  */
4282
4787
  flushActivityEnd(sessionId, reason) {
4788
+ console.log(`[NotificationService] \u{1F514} flushActivityEnd(${reason}) session=${sessionId.slice(0, 8)}\u2026 expoAvailable=${this.expoChannel?.isAvailable() ?? false}`);
4283
4789
  const sessionTitle = this.getSessionTitle(sessionId);
4284
4790
  const latestMsg = this.latestAssistantText.get(sessionId);
4285
4791
  const isError = reason === "error";
@@ -4317,14 +4823,36 @@ var NotificationService = class {
4317
4823
  this.recentActivityState.delete(sessionId);
4318
4824
  this.lastActivityPushAt.delete(sessionId);
4319
4825
  this.activityCounters.delete(sessionId);
4826
+ this.lastPushedFingerprint.delete(sessionId);
4320
4827
  console.log(`[NotificationService] \u{1F3C1} LA end (${reason}) session=${sessionId.slice(0, 8)}\u2026`);
4321
4828
  }
4829
+ /**
4830
+ * 计算内容指纹:status + recentActivity + latestApproval。
4831
+ * 用于判断 LA 内容是否实际变化,避免重复推送消耗 APNs budget。
4832
+ */
4833
+ computeContentFingerprint(sessionId) {
4834
+ const activity = this.getRecentActivity(sessionId);
4835
+ const session = this.sessionManager.getActiveSessions().find((s) => s.id === sessionId);
4836
+ const approvals = this.pendingApprovalsProvider?.(sessionId) ?? [];
4837
+ const latestApproval = approvals[approvals.length - 1];
4838
+ return `${session?.status ?? ""}|${activity.join(" ")}|${latestApproval?.id ?? ""}`;
4839
+ }
4322
4840
  /** 真正发送一次 LA content push(无 alert) */
4323
4841
  flushActivityPush(sessionId) {
4324
4842
  const channel = this.activityPushChannel;
4325
4843
  if (!channel?.hasToken(sessionId)) return;
4326
4844
  const session = this.sessionManager.getActiveSessions().find((s) => s.id === sessionId);
4327
4845
  if (!session) return;
4846
+ const fingerprint = this.computeContentFingerprint(sessionId);
4847
+ const lastFingerprint = this.lastPushedFingerprint.get(sessionId);
4848
+ const contentChanged = fingerprint !== lastFingerprint;
4849
+ if (!contentChanged) {
4850
+ const lastPush = this.lastActivityPushAt.get(sessionId) ?? 0;
4851
+ if (Date.now() - lastPush < _NotificationService.STATS_REFRESH_INTERVAL_MS) {
4852
+ return;
4853
+ }
4854
+ }
4855
+ this.lastPushedFingerprint.set(sessionId, fingerprint);
4328
4856
  const recentActivity = this.getRecentActivity(sessionId);
4329
4857
  const latestMessage = recentActivity[recentActivity.length - 1] ?? this.latestAssistantText.get(sessionId) ?? "";
4330
4858
  const sessionTitle = this.getSessionTitle(sessionId);
@@ -4353,13 +4881,14 @@ var NotificationService = class {
4353
4881
  };
4354
4882
  }
4355
4883
  contentState.stats = this.buildStatsPayload(session);
4356
- const priority = this.pendingPriority.get(sessionId) ?? "5";
4884
+ const explicitPriority = this.pendingPriority.get(sessionId);
4885
+ const priority = explicitPriority ?? (contentChanged ? "10" : "5");
4357
4886
  this.pendingPriority.delete(sessionId);
4358
4887
  this.lastActivityPushAt.set(sessionId, Date.now());
4359
4888
  const lineCount = recentActivity.length;
4360
4889
  channel.updateActivity(sessionId, contentState, { priority }).then((ok) => {
4361
4890
  if (ok) {
4362
- console.log(`[NotificationService] \u{1F4E1} LA push \u2713 session=${sessionId.slice(0, 8)}\u2026 status=${status} p=${priority} lines=${lineCount}`);
4891
+ console.log(`[NotificationService] \u{1F4E1} LA push \u2713 session=${sessionId.slice(0, 8)}\u2026 status=${status} p=${priority} changed=${contentChanged} lines=${lineCount}`);
4363
4892
  }
4364
4893
  }).catch((err) => {
4365
4894
  console.warn(`[NotificationService] \u{1F4E1} LA push \u2717 session=${sessionId.slice(0, 8)}\u2026:`, err instanceof Error ? err.message : err);
@@ -4488,6 +5017,8 @@ var DesktopNotificationChannel = class {
4488
5017
 
4489
5018
  // src/notification/ExpoNotificationChannel.ts
4490
5019
  var EXPO_PUSH_API = "https://exp.host/--/api/v2/push/send";
5020
+ var EXPO_RECEIPT_API = "https://exp.host/--/api/v2/push/getReceipts";
5021
+ var RECEIPT_CHECK_DELAY_MS = 1e4;
4491
5022
  var ExpoNotificationChannel = class {
4492
5023
  tokens = /* @__PURE__ */ new Set();
4493
5024
  /** push token → WebSocket 连接映射,用于前台抑制 */
@@ -4516,7 +5047,13 @@ var ExpoNotificationChannel = class {
4516
5047
  console.log(`[ExpoNotificationChannel] ${t("notification.soundPrefsUpdated")}`);
4517
5048
  }
4518
5049
  async send(payload) {
4519
- if (this.tokens.size === 0) return;
5050
+ if (this.tokens.size === 0) {
5051
+ const isCompletion = payload.data?.type === "task_complete" || payload.data?.type === "task_error";
5052
+ if (isCompletion) {
5053
+ 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");
5054
+ }
5055
+ return;
5056
+ }
4520
5057
  const isCompletionNotif = payload.data?.type === "task_complete" || payload.data?.type === "task_error";
4521
5058
  const targetTokens = isCompletionNotif ? Array.from(this.tokens) : Array.from(this.tokens).filter((token) => {
4522
5059
  const ws = this.tokenWsMap.get(token);
@@ -4529,6 +5066,7 @@ var ExpoNotificationChannel = class {
4529
5066
  if (prefs) {
4530
5067
  const notifType = payload.data?.type ?? "";
4531
5068
  if (notifType === "approval_request" && prefs.approval) sound = prefs.approval;
5069
+ else if (notifType === "question_request" && prefs.approval) sound = prefs.approval;
4532
5070
  else if (notifType === "task_complete" && prefs.taskComplete) sound = prefs.taskComplete;
4533
5071
  else if (notifType === "task_error" && prefs.taskError) sound = prefs.taskError;
4534
5072
  }
@@ -4559,16 +5097,76 @@ var ExpoNotificationChannel = class {
4559
5097
  console.warn(`[ExpoNotificationChannel] ${t("notification.pushApiFormatError")}`, JSON.stringify(body));
4560
5098
  return;
4561
5099
  }
4562
- for (const ticket of body.data) {
5100
+ const receiptIdToToken = /* @__PURE__ */ new Map();
5101
+ for (let i = 0; i < body.data.length; i++) {
5102
+ const ticket = body.data[i];
4563
5103
  if (ticket.status === "error") {
4564
- console.error(`[ExpoNotificationChannel] ${t("notification.pushFailed")} ${ticket.message} (${ticket.details?.error ?? "unknown"})`);
5104
+ const errorCode = ticket.details?.error ?? "unknown";
5105
+ console.error(`[ExpoNotificationChannel] ${t("notification.pushFailed")} ${ticket.message} (${errorCode})`);
5106
+ if (errorCode === "DeviceNotRegistered" && targetTokens[i]) {
5107
+ const staleToken = targetTokens[i];
5108
+ this.tokens.delete(staleToken);
5109
+ this.tokenWsMap.delete(staleToken);
5110
+ this.soundPreferences.delete(staleToken);
5111
+ 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`);
5112
+ }
5113
+ } else if (ticket.status === "ok" && typeof ticket.id === "string" && targetTokens[i]) {
5114
+ receiptIdToToken.set(ticket.id, targetTokens[i]);
4565
5115
  }
4566
5116
  }
5117
+ this.scheduleReceiptCheck(receiptIdToToken);
4567
5118
  }
4568
5119
  } catch (err) {
4569
5120
  console.warn(`[ExpoNotificationChannel] ${t("notification.sendFailed")}`, err);
4570
5121
  }
4571
5122
  }
5123
+ /**
5124
+ * Expo push 二阶段:延迟查 receipt,暴露 ticket 阶段看不到的 APNs 投递失败。
5125
+ *
5126
+ * 关键诊断点:InvalidCredentials / MismatchSenderId 表示 Expo 项目的 APNs
5127
+ * 凭证配置问题(不是用户机器问题)——这正是"只有开发者能收到推送"的根因,
5128
+ * 且 ticket 全为 ok、不查 receipt 永远静默。
5129
+ */
5130
+ scheduleReceiptCheck(receiptIdToToken) {
5131
+ if (receiptIdToToken.size === 0) return;
5132
+ const timer = setTimeout(async () => {
5133
+ try {
5134
+ const ids = Array.from(receiptIdToToken.keys());
5135
+ const res = await fetch(EXPO_RECEIPT_API, {
5136
+ method: "POST",
5137
+ headers: { "Content-Type": "application/json", Accept: "application/json" },
5138
+ body: JSON.stringify({ ids })
5139
+ });
5140
+ const body = await res.json();
5141
+ if (!res.ok || !body?.data || typeof body.data !== "object") {
5142
+ console.warn("[ExpoNotificationChannel] \u26A0\uFE0F push receipt \u67E5\u8BE2\u5931\u8D25", res.status, JSON.stringify(body));
5143
+ return;
5144
+ }
5145
+ const receipts = body.data;
5146
+ for (const [receiptId, receipt] of Object.entries(receipts)) {
5147
+ if (receipt?.status !== "error") continue;
5148
+ const errorCode = receipt.details?.error ?? "unknown";
5149
+ const token = receiptIdToToken.get(receiptId);
5150
+ console.error(
5151
+ `[ExpoNotificationChannel] \u274C APNs \u6295\u9012\u5931\u8D25 receipt=${receiptId} error=${errorCode}` + (receipt.message ? ` \u2014 ${receipt.message}` : "")
5152
+ );
5153
+ if (errorCode === "DeviceNotRegistered" && token) {
5154
+ this.tokens.delete(token);
5155
+ this.tokenWsMap.delete(token);
5156
+ this.soundPreferences.delete(token);
5157
+ console.warn("[ExpoNotificationChannel] \u26A0\uFE0F \u5DF2\u79FB\u9664\u5931\u6548 token\uFF08receipt DeviceNotRegistered\uFF09\u3002\u91CD\u542F App \u53EF\u91CD\u65B0\u6CE8\u518C\u3002");
5158
+ } else if (errorCode === "InvalidCredentials" || errorCode === "MismatchSenderId") {
5159
+ console.error(
5160
+ "[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"
5161
+ );
5162
+ }
5163
+ }
5164
+ } catch (err) {
5165
+ console.warn("[ExpoNotificationChannel] \u26A0\uFE0F push receipt \u67E5\u8BE2\u5F02\u5E38:", err);
5166
+ }
5167
+ }, RECEIPT_CHECK_DELAY_MS);
5168
+ timer.unref?.();
5169
+ }
4572
5170
  };
4573
5171
 
4574
5172
  // src/notification/ActivityPushChannel.ts
@@ -4582,6 +5180,14 @@ var APNS_HOSTS = {
4582
5180
  var ActivityPushChannel = class {
4583
5181
  /** sessionId -> activityPushToken */
4584
5182
  tokens = /* @__PURE__ */ new Map();
5183
+ /** 原生 device token 集合(用于普通 alert push,绕过 Expo 推送服务) */
5184
+ alertTokens = /* @__PURE__ */ new Set();
5185
+ /** alert token -> WebSocket 映射(用于前台在线过滤) */
5186
+ alertTokenWsMap = /* @__PURE__ */ new Map();
5187
+ /** alert token 已确认的 APNs 环境(独立于 LA token 的探测结果) */
5188
+ alertTokenEnv = /* @__PURE__ */ new Map();
5189
+ /** per-alert-token 通知音效偏好 */
5190
+ alertSoundPreferences = /* @__PURE__ */ new Map();
4585
5191
  /**
4586
5192
  * 每个 token 已确认工作的 APNs 环境。
4587
5193
  * Debug build (aps-environment=development) 的 token 仅在 sandbox 端有效;
@@ -4600,6 +5206,7 @@ var ActivityPushChannel = class {
4600
5206
  teamId;
4601
5207
  keyId;
4602
5208
  authKey;
5209
+ bundleId;
4603
5210
  /** 缓存的 JWT token + 过期时间 */
4604
5211
  cachedJwt = null;
4605
5212
  /** 每个环境一条 HTTP/2 长连接 */
@@ -4608,6 +5215,7 @@ var ActivityPushChannel = class {
4608
5215
  this.teamId = config.teamId;
4609
5216
  this.keyId = config.keyId;
4610
5217
  this.authKey = fs2.readFileSync(config.authKeyPath, "utf-8");
5218
+ this.bundleId = config.bundleId ?? "com.kachun.sessix";
4611
5219
  this.probeOrder = config.sandbox === false ? ["production", "sandbox"] : ["sandbox", "production"];
4612
5220
  console.log(`[ActivityPushChannel] Initialized (probe order: ${this.probeOrder.join(" \u2192 ")})`);
4613
5221
  }
@@ -4652,557 +5260,335 @@ var ActivityPushChannel = class {
4652
5260
  /** 发送 content-state 更新到指定会话的 Live Activity(纯内容刷新,不响通知)。返回 true 表示实际发出 */
4653
5261
  async updateActivity(sessionId, contentState, opts) {
4654
5262
  const token = this.tokens.get(sessionId);
4655
- if (!token) return false;
4656
- const now = Math.floor(Date.now() / 1e3);
4657
- const priority = opts?.priority ?? "5";
4658
- const payload = {
4659
- aps: {
4660
- timestamp: now,
4661
- event: "update",
4662
- "content-state": contentState,
4663
- "stale-date": now + 600
4664
- }
4665
- };
4666
- try {
4667
- await this.sendToAPNs(token, payload, {
4668
- priority,
4669
- collapseId: `state-${sessionId.slice(0, 54)}`
4670
- });
4671
- return true;
4672
- } catch (err) {
4673
- console.warn(`[ActivityPushChannel] Update failed session=${sessionId}:`, err);
4674
- return false;
4675
- }
4676
- }
4677
- /** 发送带通知的 content-state 更新(审批请求时使用),返回是否发送成功 */
4678
- async updateActivityWithAlert(sessionId, contentState, alert) {
4679
- const token = this.tokens.get(sessionId);
4680
- if (!token) return false;
4681
- const now = Math.floor(Date.now() / 1e3);
4682
- const payload = {
4683
- aps: {
4684
- timestamp: now,
4685
- event: "update",
4686
- "content-state": contentState,
4687
- "stale-date": now + 600,
4688
- alert,
4689
- sound: "default"
4690
- }
4691
- };
4692
- try {
4693
- await this.sendToAPNs(token, payload, { priority: "10" });
4694
- return true;
4695
- } catch (err) {
4696
- console.warn(`[ActivityPushChannel] Alert update failed session=${sessionId}:`, err);
4697
- return false;
4698
- }
4699
- }
4700
- /**
4701
- * 结束指定会话的 Live Activity。
4702
- * 可选 alert:APNs event=end 时同时推送横幅通知 + 声音,用于在会话完成时提醒用户。
4703
- */
4704
- async endActivity(sessionId, contentState, opts) {
4705
- const token = this.tokens.get(sessionId);
4706
- if (!token) return;
4707
- const now = Math.floor(Date.now() / 1e3);
4708
- const aps = {
4709
- timestamp: now,
4710
- event: "end",
4711
- "content-state": contentState
4712
- };
4713
- if (opts?.alert) {
4714
- aps.alert = opts.alert;
4715
- aps.sound = "default";
4716
- }
4717
- const payload = { aps };
4718
- try {
4719
- await this.sendToAPNs(token, payload, { priority: "10" });
4720
- } catch (err) {
4721
- this.tokens.delete(sessionId);
4722
- throw err;
4723
- }
4724
- this.tokens.delete(sessionId);
4725
- }
4726
- /** 检查是否有指定会话的 token */
4727
- hasToken(sessionId) {
4728
- return this.tokens.has(sessionId);
4729
- }
4730
- /**
4731
- * 发送 APNs,自动处理环境探测。
4732
- * 对每个 token:先用已确认的环境(如有);否则按 probeOrder 顺序探测,
4733
- * 收到 BadDeviceToken / BadEnvironmentKeyInToken 自动切到另一个环境,
4734
- * 并把成功的环境绑定到该 token。
4735
- */
4736
- async sendToAPNs(deviceToken, payload, opts = {}) {
4737
- if (this.deadTokens.has(deviceToken)) {
4738
- throw new Error(`token permanently dead (Activity ended): ${deviceToken.slice(0, 16)}\u2026`);
4739
- }
4740
- const known = this.tokenEnv.get(deviceToken);
4741
- if (known) {
4742
- return this.sendToAPNsOnce(deviceToken, payload, opts, known);
4743
- }
4744
- const short = deviceToken.slice(0, 16);
4745
- console.log(`[ActivityPushChannel] \u{1F50D} probe start token=${short}\u2026 order=[${this.probeOrder.join(",")}]`);
4746
- let lastErr = null;
4747
- for (const env of this.probeOrder) {
4748
- try {
4749
- console.log(`[ActivityPushChannel] \u{1F50D} probe try ${env} token=${short}\u2026`);
4750
- await this.sendToAPNsOnce(deviceToken, payload, opts, env);
4751
- this.tokenEnv.set(deviceToken, env);
4752
- console.log(`[ActivityPushChannel] \u2705 probe bound to ${env} (token=${short}\u2026)`);
4753
- return;
4754
- } catch (err) {
4755
- lastErr = err;
4756
- const reason = err instanceof ApnsError ? JSON.parse(err.responseBody || "{}")?.reason ?? err.statusCode : String(err);
4757
- console.log(`[ActivityPushChannel] \u{1F50D} probe ${env} failed: ${reason}`);
4758
- if (!isBadDeviceTokenError(err)) {
4759
- throw err;
4760
- }
4761
- }
4762
- }
4763
- this.deadTokens.add(deviceToken);
4764
- for (const [sid, tok] of this.tokens) {
4765
- if (tok === deviceToken) {
4766
- this.tokens.delete(sid);
4767
- console.warn(`[ActivityPushChannel] \u2620\uFE0F dead token, session removed: session=${sid.slice(0, 8)}\u2026 token=${short}\u2026 (Activity ended/iOS reinstalled)`);
4768
- break;
4769
- }
4770
- }
4771
- throw lastErr ?? new Error("APNs send failed: all environments rejected token");
4772
- }
4773
- /** 单次 APNs HTTP/2 请求(内部 helper,不做探测) */
4774
- async sendToAPNsOnce(deviceToken, payload, opts, env) {
4775
- const topic = "com.kachun.sessix.push-type.liveactivity";
4776
- const jwt = this.getJWT();
4777
- const payloadStr = JSON.stringify(payload);
4778
- const priority = opts.priority ?? "10";
4779
- return new Promise((resolve, reject) => {
4780
- let client;
4781
- try {
4782
- client = this.getHttp2Client(env);
4783
- } catch (err) {
4784
- return reject(err);
4785
- }
4786
- const headers = {
4787
- ":method": "POST",
4788
- ":path": `/3/device/${deviceToken}`,
4789
- "authorization": `bearer ${jwt}`,
4790
- "apns-topic": topic,
4791
- "apns-push-type": "liveactivity",
4792
- "apns-priority": priority,
4793
- "apns-expiration": String(Math.floor(Date.now() / 1e3) + 300),
4794
- "content-type": "application/json",
4795
- "content-length": Buffer.byteLength(payloadStr)
4796
- };
4797
- if (opts.collapseId) {
4798
- headers["apns-collapse-id"] = opts.collapseId;
4799
- }
4800
- const req = client.request(headers);
4801
- let statusCode = 0;
4802
- let responseData = "";
4803
- req.on("response", (headers2) => {
4804
- statusCode = Number(headers2[":status"] ?? 0);
4805
- });
4806
- req.on("data", (chunk) => {
4807
- responseData += chunk;
4808
- });
4809
- req.on("end", () => {
4810
- if (statusCode === 200) {
4811
- resolve();
4812
- } else {
4813
- if (statusCode === 0) {
4814
- const c = this.http2Clients[env];
4815
- c?.destroy();
4816
- delete this.http2Clients[env];
4817
- }
4818
- reject(new ApnsError(statusCode, responseData));
4819
- }
4820
- });
4821
- req.on("error", (err) => {
4822
- reject(err);
4823
- });
4824
- req.write(payloadStr);
4825
- req.end();
4826
- });
4827
- }
4828
- /** 生成或获取缓存的 APNs JWT token */
4829
- getJWT() {
4830
- const now = Math.floor(Date.now() / 1e3);
4831
- if (this.cachedJwt && this.cachedJwt.expiresAt > now) {
4832
- return this.cachedJwt.token;
4833
- }
4834
- const header = Buffer.from(JSON.stringify({
4835
- alg: "ES256",
4836
- kid: this.keyId
4837
- })).toString("base64url");
4838
- const claims = Buffer.from(JSON.stringify({
4839
- iss: this.teamId,
4840
- iat: now
4841
- })).toString("base64url");
4842
- const signingInput = `${header}.${claims}`;
4843
- const sign = crypto.createSign("SHA256");
4844
- sign.update(signingInput);
4845
- const signature = sign.sign({ key: this.authKey, dsaEncoding: "ieee-p1363" }, "base64url");
4846
- const token = `${signingInput}.${signature}`;
4847
- this.cachedJwt = { token, expiresAt: now + 3e3 };
4848
- return token;
4849
- }
4850
- };
4851
- var ApnsError = class extends Error {
4852
- constructor(statusCode, responseBody) {
4853
- super(`APNs returned ${statusCode}: ${responseBody}`);
4854
- this.statusCode = statusCode;
4855
- this.responseBody = responseBody;
4856
- this.name = "ApnsError";
4857
- }
4858
- };
4859
- function isBadDeviceTokenError(err) {
4860
- if (!(err instanceof ApnsError)) return false;
4861
- if (err.statusCode !== 400 && err.statusCode !== 403 && err.statusCode !== 410) return false;
4862
- try {
4863
- const parsed = JSON.parse(err.responseBody);
4864
- return parsed.reason === "BadDeviceToken" || parsed.reason === "BadEnvironmentKeyInToken" || parsed.reason === "Unregistered";
4865
- } catch {
4866
- return false;
4867
- }
4868
- }
4869
-
4870
- // src/session/ProjectReader.ts
4871
- var import_promises3 = require("fs/promises");
4872
- var import_readline3 = require("readline");
4873
- var import_path2 = require("path");
4874
- var import_os2 = require("os");
4875
- var CLAUDE_PROJECTS_DIR = (0, import_path2.join)((0, import_os2.homedir)(), ".claude", "projects");
4876
- function getSessionFilePath(projectPath, sessionId) {
4877
- return (0, import_path2.join)(CLAUDE_PROJECTS_DIR, encodeDirName(projectPath), `${sessionId}.jsonl`);
4878
- }
4879
- async function getProjects() {
4880
- try {
4881
- const dirExists = await directoryExists(CLAUDE_PROJECTS_DIR);
4882
- if (!dirExists) {
4883
- return { ok: true, value: [] };
4884
- }
4885
- const entries = await (0, import_promises3.readdir)(CLAUDE_PROJECTS_DIR, { withFileTypes: true });
4886
- const projects = [];
4887
- for (const entry of entries) {
4888
- if (!entry.isDirectory() || entry.name.startsWith(".")) {
4889
- continue;
5263
+ if (!token) return false;
5264
+ const now = Math.floor(Date.now() / 1e3);
5265
+ const priority = opts?.priority ?? "5";
5266
+ const payload = {
5267
+ aps: {
5268
+ timestamp: now,
5269
+ event: "update",
5270
+ "content-state": contentState,
5271
+ "stale-date": now + 600
4890
5272
  }
4891
- const encodedPath = entry.name;
4892
- const decodedPath = decodeDirName(encodedPath);
4893
- const name = decodedPath.split("/").filter(Boolean).pop() ?? encodedPath;
4894
- const projectDir = (0, import_path2.join)(CLAUDE_PROJECTS_DIR, encodedPath);
4895
- const { count: sessionCount, latestMtime } = await countJsonlFilesWithMtime(projectDir);
4896
- projects.push({
4897
- id: encodedPath,
4898
- path: decodedPath,
4899
- name,
4900
- sessionCount,
4901
- lastActiveAt: latestMtime
5273
+ };
5274
+ try {
5275
+ await this.sendToAPNs(token, payload, {
5276
+ priority,
5277
+ collapseId: `state-${sessionId.slice(0, 54)}`
4902
5278
  });
5279
+ return true;
5280
+ } catch (err) {
5281
+ console.warn(`[ActivityPushChannel] Update failed session=${sessionId}:`, err);
5282
+ return false;
4903
5283
  }
4904
- projects.sort((a, b) => a.name.localeCompare(b.name));
4905
- return { ok: true, value: projects };
4906
- } catch (err) {
4907
- return {
4908
- ok: false,
4909
- error: err instanceof Error ? err : new Error(String(err))
4910
- };
4911
5284
  }
4912
- }
4913
- async function getHistoricalSessions(projectPath) {
4914
- try {
4915
- const encodedPath = encodeDirName(projectPath);
4916
- const projectDir = (0, import_path2.join)(CLAUDE_PROJECTS_DIR, encodedPath);
4917
- const dirExists = await directoryExists(projectDir);
4918
- if (!dirExists) {
4919
- return { ok: true, value: [] };
4920
- }
4921
- const entries = await (0, import_promises3.readdir)(projectDir, { withFileTypes: true });
4922
- const jsonlFiles = entries.filter((e) => e.isFile() && e.name.endsWith(".jsonl"));
4923
- const mtimeMap = /* @__PURE__ */ new Map();
4924
- await Promise.all(
4925
- jsonlFiles.map(async (entry) => {
4926
- const sessionId = entry.name.slice(0, -6);
4927
- const filePath = (0, import_path2.join)(projectDir, entry.name);
4928
- try {
4929
- const contentTs = await extractLastTimestamp(filePath);
4930
- if (contentTs) {
4931
- mtimeMap.set(sessionId, contentTs);
4932
- } else {
4933
- const fileStat = await (0, import_promises3.stat)(filePath);
4934
- mtimeMap.set(sessionId, fileStat.mtimeMs);
4935
- }
4936
- } catch {
4937
- mtimeMap.set(sessionId, 0);
4938
- }
4939
- })
4940
- );
4941
- const uuidDirs = entries.filter(
4942
- (e) => e.isDirectory() && UUID_RE.test(e.name) && !mtimeMap.has(e.name)
4943
- );
4944
- for (const entry of uuidDirs) {
4945
- try {
4946
- const fileStat = await (0, import_promises3.stat)((0, import_path2.join)(projectDir, entry.name));
4947
- mtimeMap.set(entry.name, fileStat.mtimeMs);
4948
- } catch {
4949
- mtimeMap.set(entry.name, 0);
5285
+ /** 发送带通知的 content-state 更新(审批请求时使用),返回是否发送成功 */
5286
+ async updateActivityWithAlert(sessionId, contentState, alert) {
5287
+ const token = this.tokens.get(sessionId);
5288
+ if (!token) return false;
5289
+ const now = Math.floor(Date.now() / 1e3);
5290
+ const payload = {
5291
+ aps: {
5292
+ timestamp: now,
5293
+ event: "update",
5294
+ "content-state": contentState,
5295
+ "stale-date": now + 600,
5296
+ alert,
5297
+ sound: "default"
4950
5298
  }
5299
+ };
5300
+ try {
5301
+ await this.sendToAPNs(token, payload, { priority: "10" });
5302
+ return true;
5303
+ } catch (err) {
5304
+ console.warn(`[ActivityPushChannel] Alert update failed session=${sessionId}:`, err);
5305
+ return false;
4951
5306
  }
4952
- const indexPath = (0, import_path2.join)(projectDir, "sessions-index.json");
4953
- const sessionMap = /* @__PURE__ */ new Map();
5307
+ }
5308
+ /**
5309
+ * 结束指定会话的 Live Activity。
5310
+ * 可选 alert:APNs event=end 时同时推送横幅通知 + 声音,用于在会话完成时提醒用户。
5311
+ */
5312
+ async endActivity(sessionId, contentState, opts) {
5313
+ const token = this.tokens.get(sessionId);
5314
+ if (!token) return;
5315
+ const now = Math.floor(Date.now() / 1e3);
5316
+ const aps = {
5317
+ timestamp: now,
5318
+ event: "end",
5319
+ "content-state": contentState
5320
+ };
5321
+ if (opts?.alert) {
5322
+ aps.alert = opts.alert;
5323
+ aps.sound = "default";
5324
+ }
5325
+ const payload = { aps };
4954
5326
  try {
4955
- const indexContent = await (0, import_promises3.readFile)(indexPath, "utf-8");
4956
- const indexData = JSON.parse(indexContent);
4957
- if (indexData.version === 1 && Array.isArray(indexData.entries)) {
4958
- for (const entry of indexData.entries) {
4959
- const mtime = mtimeMap.get(entry.sessionId) ?? entry.fileMtime ?? (entry.modified ? new Date(entry.modified).getTime() : 0);
4960
- sessionMap.set(entry.sessionId, {
4961
- sessionId: entry.sessionId,
4962
- lastModified: mtime,
4963
- summary: entry.summary,
4964
- firstPrompt: entry.firstPrompt,
4965
- messageCount: entry.messageCount
4966
- });
4967
- }
4968
- await Promise.all(
4969
- Array.from(sessionMap.values()).filter((s) => (s.messageCount ?? 0) > 0 && !s.summary && !s.firstPrompt).map(async (s) => {
4970
- const filePath = (0, import_path2.join)(projectDir, `${s.sessionId}.jsonl`);
4971
- const firstPrompt = await extractFirstPrompt(filePath).catch(() => void 0);
4972
- if (firstPrompt) s.firstPrompt = firstPrompt;
4973
- })
4974
- );
4975
- }
4976
- } catch {
5327
+ await this.sendToAPNs(token, payload, { priority: "10" });
5328
+ } catch (err) {
5329
+ this.tokens.delete(sessionId);
5330
+ throw err;
4977
5331
  }
4978
- const uuidDirSet = new Set(uuidDirs.map((e) => e.name));
4979
- for (const [sessionId, mtime] of mtimeMap) {
4980
- if (!sessionMap.has(sessionId)) {
4981
- if (uuidDirSet.has(sessionId)) {
4982
- sessionMap.set(sessionId, { sessionId, lastModified: mtime, messageCount: -1 });
4983
- } else {
4984
- const filePath = (0, import_path2.join)(projectDir, `${sessionId}.jsonl`);
4985
- const firstPrompt = await extractFirstPrompt(filePath).catch(() => void 0);
4986
- sessionMap.set(sessionId, { sessionId, lastModified: mtime, firstPrompt });
4987
- }
4988
- }
5332
+ this.tokens.delete(sessionId);
5333
+ }
5334
+ /** 检查是否有指定会话的 token */
5335
+ hasToken(sessionId) {
5336
+ return this.tokens.has(sessionId);
5337
+ }
5338
+ // ============================================
5339
+ // 普通 Alert Push(直连 APNs,绕过 Expo 推送服务)
5340
+ // ============================================
5341
+ /** 注册原生 APNs device token,用于发送普通 alert 通知 */
5342
+ addAlertToken(token, ws) {
5343
+ this.alertTokens.add(token);
5344
+ if (ws) this.alertTokenWsMap.set(token, ws);
5345
+ console.log(`[ActivityPushChannel] Alert token registered (${this.alertTokens.size} device(s))`);
5346
+ }
5347
+ /** 移除原生 APNs device token */
5348
+ removeAlertToken(token) {
5349
+ this.alertTokens.delete(token);
5350
+ this.alertTokenWsMap.delete(token);
5351
+ this.alertTokenEnv.delete(token);
5352
+ this.alertSoundPreferences.delete(token);
5353
+ }
5354
+ /** 是否有可用的 alert token */
5355
+ hasAlertTokens() {
5356
+ return this.alertTokens.size > 0;
5357
+ }
5358
+ /** 更新 alert token 音效偏好 */
5359
+ setAlertSoundPreferences(prefs) {
5360
+ for (const token of this.alertTokens) {
5361
+ this.alertSoundPreferences.set(token, prefs);
4989
5362
  }
4990
- const sessions = Array.from(sessionMap.values()).filter((s) => {
4991
- if (s.messageCount === 0) return false;
4992
- if (s.messageCount === -1) return true;
4993
- if (s.firstPrompt === void 0 && s.messageCount === void 0) return false;
4994
- return true;
4995
- });
4996
- sessions.sort((a, b) => b.lastModified - a.lastModified);
4997
- return { ok: true, value: sessions };
4998
- } catch (err) {
4999
- return {
5000
- ok: false,
5001
- error: err instanceof Error ? err : new Error(String(err))
5002
- };
5003
5363
  }
5004
- }
5005
- async function getSessionHistory(projectPath, sessionId) {
5006
- try {
5007
- const encodedPath = encodeDirName(projectPath);
5008
- const filePath = (0, import_path2.join)(CLAUDE_PROJECTS_DIR, encodedPath, `${sessionId}.jsonl`);
5009
- const raw = await (0, import_promises3.readFile)(filePath, "utf-8").catch((err) => {
5010
- if (err.code === "ENOENT") return null;
5011
- throw err;
5364
+ /**
5365
+ * 发送普通 alert 推送通知(直连 APNs,sandbox/production 自动探测)。
5366
+ * 实现 NotificationChannel.send 接口,可注册到 NotificationService。
5367
+ */
5368
+ async send(payload) {
5369
+ if (this.alertTokens.size === 0) return;
5370
+ const isCompletionNotif = payload.data?.type === "task_complete" || payload.data?.type === "task_error";
5371
+ const targetTokens = isCompletionNotif ? Array.from(this.alertTokens) : Array.from(this.alertTokens).filter((token) => {
5372
+ const ws = this.alertTokenWsMap.get(token);
5373
+ return !ws || ws.readyState !== ws.OPEN;
5012
5374
  });
5013
- if (raw === null) return { ok: true, value: [] };
5014
- const lines = raw.split("\n").filter((l) => l.trim());
5015
- const events = [];
5016
- for (const line of lines) {
5017
- try {
5018
- const obj = JSON.parse(line);
5019
- const type = obj.type;
5020
- if (type === "user" && obj.message) {
5021
- const msgContent = obj.message.content;
5022
- if (typeof msgContent === "string") {
5023
- if (msgContent.includes("<local-command") || msgContent.includes("<command-name>")) continue;
5024
- } else if (Array.isArray(msgContent)) {
5025
- const hasText = msgContent.some(
5026
- (b) => b.type === "text" && !b.text?.includes("<local-command") && !b.text?.includes("<command-name>")
5027
- );
5028
- if (!hasText) continue;
5029
- }
5030
- const normalizedContent = typeof msgContent === "string" ? [{ type: "text", text: msgContent }] : Array.isArray(msgContent) ? msgContent.filter((b) => b.type === "text" && typeof b.text === "string") : [];
5031
- if (normalizedContent.length === 0) continue;
5032
- events.push({
5033
- type: "user",
5034
- message: {
5035
- ...obj.message,
5036
- content: normalizedContent
5037
- },
5038
- session_id: sessionId
5039
- });
5040
- } else if (type === "assistant" && obj.message) {
5041
- const content = (obj.message.content ?? []).filter(
5042
- (b) => b.type === "text" || b.type === "tool_use" || b.type === "thinking"
5043
- );
5044
- if (content.length === 0) continue;
5045
- events.push({
5046
- type: "assistant",
5047
- message: {
5048
- id: obj.message.id ?? obj.uuid ?? `hist-${events.length}`,
5049
- model: obj.message.model ?? "unknown",
5050
- role: "assistant",
5051
- content,
5052
- stop_reason: obj.message.stop_reason,
5053
- usage: obj.message.usage
5054
- },
5055
- session_id: sessionId
5056
- });
5057
- }
5058
- } catch {
5375
+ if (targetTokens.length === 0) return;
5376
+ console.log(`[ActivityPushChannel] Alert push \u2192 ${targetTokens.length}/${this.alertTokens.size} device(s)${isCompletionNotif ? " (forced)" : ""}`);
5377
+ await Promise.all(targetTokens.map(async (deviceToken) => {
5378
+ let sound = payload.sound ?? "default";
5379
+ const prefs = this.alertSoundPreferences.get(deviceToken);
5380
+ if (prefs) {
5381
+ const notifType = payload.data?.type ?? "";
5382
+ if (notifType === "approval_request" && prefs.approval) sound = prefs.approval;
5383
+ else if (notifType === "task_complete" && prefs.taskComplete) sound = prefs.taskComplete;
5384
+ else if (notifType === "task_error" && prefs.taskError) sound = prefs.taskError;
5059
5385
  }
5060
- }
5061
- if (events.length > 0) {
5062
- let totalInputTokens = 0;
5063
- let totalOutputTokens = 0;
5064
- for (const ev of events) {
5065
- if (ev.type === "assistant" && ev.message.usage) {
5066
- totalInputTokens += ev.message.usage.input_tokens ?? 0;
5067
- totalOutputTokens += ev.message.usage.output_tokens ?? 0;
5068
- }
5386
+ const apnsPayload = {
5387
+ aps: {
5388
+ alert: { title: payload.title, ...payload.subtitle ? { subtitle: payload.subtitle } : {}, body: payload.body },
5389
+ ...payload.badge !== void 0 ? { badge: payload.badge } : {},
5390
+ ...sound && sound !== "none" ? { sound } : {},
5391
+ ...payload.categoryId ? { category: payload.categoryId } : {}
5392
+ },
5393
+ ...payload.data ?? {}
5394
+ };
5395
+ try {
5396
+ await this.sendAlertToAPNs(deviceToken, apnsPayload);
5397
+ } catch (err) {
5398
+ console.warn(`[ActivityPushChannel] Alert push failed for token ${deviceToken.slice(0, 16)}\u2026:`, err instanceof Error ? err.message : err);
5069
5399
  }
5070
- if (totalInputTokens > 0 || totalOutputTokens > 0) {
5071
- events.push({
5072
- type: "result",
5073
- subtype: "success",
5074
- is_error: false,
5075
- duration_ms: 0,
5076
- num_turns: events.filter((e) => e.type === "user").length,
5077
- result: "",
5078
- session_id: sessionId,
5079
- usage: { input_tokens: totalInputTokens, output_tokens: totalOutputTokens }
5080
- });
5400
+ }));
5401
+ }
5402
+ /** isAvailable 实现(NotificationChannel 接口) */
5403
+ isAvailable() {
5404
+ return this.alertTokens.size > 0;
5405
+ }
5406
+ /**
5407
+ * 发送 alert 通知到指定 device token,自动探测 sandbox/production。
5408
+ * 使用独立的 alertTokenEnv 映射(与 LA token 环境探测隔离)。
5409
+ */
5410
+ async sendAlertToAPNs(deviceToken, payload) {
5411
+ const known = this.alertTokenEnv.get(deviceToken);
5412
+ if (known) {
5413
+ return this.sendToAPNsOnce(deviceToken, payload, { priority: "10", pushType: "alert", topic: this.bundleId }, known);
5414
+ }
5415
+ const short = deviceToken.slice(0, 16);
5416
+ console.log(`[ActivityPushChannel] \u{1F50D} alert probe start token=${short}\u2026 order=[${this.probeOrder.join(",")}]`);
5417
+ let lastErr = null;
5418
+ for (const env of this.probeOrder) {
5419
+ try {
5420
+ console.log(`[ActivityPushChannel] \u{1F50D} alert probe try ${env} token=${short}\u2026`);
5421
+ await this.sendToAPNsOnce(deviceToken, payload, { priority: "10", pushType: "alert", topic: this.bundleId }, env);
5422
+ this.alertTokenEnv.set(deviceToken, env);
5423
+ console.log(`[ActivityPushChannel] \u2705 alert probe bound to ${env} (token=${short}\u2026)`);
5424
+ return;
5425
+ } catch (err) {
5426
+ lastErr = err;
5427
+ const reason = err instanceof ApnsError ? JSON.parse(err.responseBody || "{}")?.reason ?? err.statusCode : String(err);
5428
+ console.log(`[ActivityPushChannel] \u{1F50D} alert probe ${env} failed: ${reason}`);
5429
+ if (isProviderTokenError(err)) throw err;
5430
+ if (!isBadDeviceTokenError(err)) throw err;
5081
5431
  }
5082
5432
  }
5083
- return { ok: true, value: events };
5084
- } catch (err) {
5085
- return {
5086
- ok: false,
5087
- error: err instanceof Error ? err : new Error(String(err))
5088
- };
5433
+ throw lastErr ?? new Error("APNs alert send failed: all environments rejected token");
5089
5434
  }
5090
- }
5091
- async function extractLastTimestamp(filePath) {
5092
- let fileHandle;
5093
- try {
5094
- fileHandle = await (0, import_promises3.open)(filePath, "r");
5095
- const fileStat = await fileHandle.stat();
5096
- const readSize = Math.min(fileStat.size, 8192);
5097
- const buffer = Buffer.alloc(readSize);
5098
- await fileHandle.read(buffer, 0, readSize, fileStat.size - readSize);
5099
- const tail = buffer.toString("utf-8");
5100
- const lines = tail.split("\n").filter((l) => l.trim());
5101
- for (let i = lines.length - 1; i >= 0; i--) {
5435
+ /**
5436
+ * 发送 APNs,自动处理环境探测。
5437
+ * 对每个 token:先用已确认的环境(如有);否则按 probeOrder 顺序探测,
5438
+ * 收到 BadDeviceToken / BadEnvironmentKeyInToken 自动切到另一个环境,
5439
+ * 并把成功的环境绑定到该 token。
5440
+ */
5441
+ async sendToAPNs(deviceToken, payload, opts = {}) {
5442
+ if (this.deadTokens.has(deviceToken)) {
5443
+ throw new Error(`token permanently dead (Activity ended): ${deviceToken.slice(0, 16)}\u2026`);
5444
+ }
5445
+ const known = this.tokenEnv.get(deviceToken);
5446
+ if (known) {
5447
+ return this.sendToAPNsOnce(deviceToken, payload, opts, known);
5448
+ }
5449
+ const short = deviceToken.slice(0, 16);
5450
+ console.log(`[ActivityPushChannel] \u{1F50D} probe start token=${short}\u2026 order=[${this.probeOrder.join(",")}]`);
5451
+ let lastErr = null;
5452
+ for (const env of this.probeOrder) {
5102
5453
  try {
5103
- const obj = JSON.parse(lines[i]);
5104
- if (obj.timestamp) {
5105
- const ts = new Date(obj.timestamp).getTime();
5106
- if (!isNaN(ts)) return ts;
5454
+ console.log(`[ActivityPushChannel] \u{1F50D} probe try ${env} token=${short}\u2026`);
5455
+ await this.sendToAPNsOnce(deviceToken, payload, opts, env);
5456
+ this.tokenEnv.set(deviceToken, env);
5457
+ console.log(`[ActivityPushChannel] \u2705 probe bound to ${env} (token=${short}\u2026)`);
5458
+ return;
5459
+ } catch (err) {
5460
+ lastErr = err;
5461
+ const reason = err instanceof ApnsError ? JSON.parse(err.responseBody || "{}")?.reason ?? err.statusCode : String(err);
5462
+ console.log(`[ActivityPushChannel] \u{1F50D} probe ${env} failed: ${reason}`);
5463
+ if (isProviderTokenError(err)) {
5464
+ console.error(
5465
+ `[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`
5466
+ );
5467
+ throw err;
5468
+ }
5469
+ if (!isBadDeviceTokenError(err)) {
5470
+ throw err;
5107
5471
  }
5108
- } catch {
5109
5472
  }
5110
5473
  }
5111
- } catch {
5112
- } finally {
5113
- await fileHandle?.close();
5474
+ this.deadTokens.add(deviceToken);
5475
+ for (const [sid, tok] of this.tokens) {
5476
+ if (tok === deviceToken) {
5477
+ this.tokens.delete(sid);
5478
+ console.warn(`[ActivityPushChannel] \u2620\uFE0F dead token, session removed: session=${sid.slice(0, 8)}\u2026 token=${short}\u2026 (Activity ended/iOS reinstalled)`);
5479
+ break;
5480
+ }
5481
+ }
5482
+ throw lastErr ?? new Error("APNs send failed: all environments rejected token");
5114
5483
  }
5115
- return void 0;
5116
- }
5117
- async function extractFirstPrompt(filePath) {
5118
- let fileHandle;
5119
- try {
5120
- fileHandle = await (0, import_promises3.open)(filePath, "r");
5121
- const rl = (0, import_readline3.createInterface)({
5122
- input: fileHandle.createReadStream({ encoding: "utf-8" }),
5123
- crlfDelay: Infinity
5124
- });
5125
- let lineCount = 0;
5126
- for await (const line of rl) {
5127
- if (++lineCount > 20) break;
5128
- if (!line.trim()) continue;
5484
+ /** 单次 APNs HTTP/2 请求(内部 helper,不做探测) */
5485
+ async sendToAPNsOnce(deviceToken, payload, opts, env) {
5486
+ const topic = opts.topic ?? `${this.bundleId}.push-type.liveactivity`;
5487
+ const pushType = opts.pushType ?? "liveactivity";
5488
+ const jwt = this.getJWT();
5489
+ const payloadStr = JSON.stringify(payload);
5490
+ const priority = opts.priority ?? "10";
5491
+ return new Promise((resolve, reject) => {
5492
+ let client;
5129
5493
  try {
5130
- const obj = JSON.parse(line);
5131
- if (obj.type === "user" && obj.message) {
5132
- const msgContent = obj.message.content;
5133
- let text = "";
5134
- if (typeof msgContent === "string") {
5135
- text = msgContent;
5136
- } else if (Array.isArray(msgContent)) {
5137
- const textBlock = msgContent.find((b) => b.type === "text" && typeof b.text === "string");
5138
- text = textBlock?.text ?? "";
5139
- }
5140
- if (text && !text.includes("<local-command") && !text.includes("<command-name>")) {
5141
- text = text.replace(/<ide_opened_file>[\s\S]*?<\/ide_opened_file>/gi, "");
5142
- text = text.replace(/<[^>]+>/g, "").trim();
5143
- rl.close();
5144
- return text.length > 80 ? text.slice(0, 80) + "..." : text;
5494
+ client = this.getHttp2Client(env);
5495
+ } catch (err) {
5496
+ return reject(err);
5497
+ }
5498
+ const headers = {
5499
+ ":method": "POST",
5500
+ ":path": `/3/device/${deviceToken}`,
5501
+ "authorization": `bearer ${jwt}`,
5502
+ "apns-topic": topic,
5503
+ "apns-push-type": pushType,
5504
+ "apns-priority": priority,
5505
+ "apns-expiration": String(Math.floor(Date.now() / 1e3) + 300),
5506
+ "content-type": "application/json",
5507
+ "content-length": Buffer.byteLength(payloadStr)
5508
+ };
5509
+ if (opts.collapseId) {
5510
+ headers["apns-collapse-id"] = opts.collapseId;
5511
+ }
5512
+ const req = client.request(headers);
5513
+ req.setTimeout(1e4, () => {
5514
+ req.close(http2.constants.NGHTTP2_CANCEL);
5515
+ });
5516
+ let statusCode = 0;
5517
+ let responseData = "";
5518
+ req.on("response", (headers2) => {
5519
+ statusCode = Number(headers2[":status"] ?? 0);
5520
+ });
5521
+ req.on("data", (chunk) => {
5522
+ responseData += chunk;
5523
+ });
5524
+ req.on("end", () => {
5525
+ if (statusCode === 200) {
5526
+ resolve();
5527
+ } else {
5528
+ if (statusCode === 0) {
5529
+ const c = this.http2Clients[env];
5530
+ c?.destroy();
5531
+ delete this.http2Clients[env];
5145
5532
  }
5533
+ reject(new ApnsError(statusCode, responseData));
5146
5534
  }
5147
- } catch {
5148
- }
5535
+ });
5536
+ req.on("error", (err) => {
5537
+ reject(err);
5538
+ });
5539
+ req.write(payloadStr);
5540
+ req.end();
5541
+ });
5542
+ }
5543
+ /** 生成或获取缓存的 APNs JWT token */
5544
+ getJWT() {
5545
+ const now = Math.floor(Date.now() / 1e3);
5546
+ if (this.cachedJwt && this.cachedJwt.expiresAt > now) {
5547
+ return this.cachedJwt.token;
5149
5548
  }
5150
- } catch {
5151
- } finally {
5152
- await fileHandle?.close();
5549
+ const header = Buffer.from(JSON.stringify({
5550
+ alg: "ES256",
5551
+ kid: this.keyId
5552
+ })).toString("base64url");
5553
+ const claims = Buffer.from(JSON.stringify({
5554
+ iss: this.teamId,
5555
+ iat: now
5556
+ })).toString("base64url");
5557
+ const signingInput = `${header}.${claims}`;
5558
+ const sign = crypto.createSign("SHA256");
5559
+ sign.update(signingInput);
5560
+ const signature = sign.sign({ key: this.authKey, dsaEncoding: "ieee-p1363" }, "base64url");
5561
+ const token = `${signingInput}.${signature}`;
5562
+ this.cachedJwt = { token, expiresAt: now + 3e3 };
5563
+ return token;
5153
5564
  }
5154
- return void 0;
5155
- }
5156
- function decodeDirName(dirName) {
5157
- const placeholder = "\0";
5158
- const escaped = dirName.replace(/--/g, placeholder);
5159
- const decoded = escaped.replace(/-/g, "/");
5160
- return decoded.replace(new RegExp(placeholder, "g"), "-");
5161
- }
5162
- function encodeDirName(path2) {
5163
- const escaped = path2.replace(/-/g, "--");
5164
- return escaped.replace(/\//g, "-");
5165
- }
5166
- async function directoryExists(dirPath) {
5565
+ };
5566
+ var ApnsError = class extends Error {
5567
+ constructor(statusCode, responseBody) {
5568
+ super(`APNs returned ${statusCode}: ${responseBody}`);
5569
+ this.statusCode = statusCode;
5570
+ this.responseBody = responseBody;
5571
+ this.name = "ApnsError";
5572
+ }
5573
+ };
5574
+ function isProviderTokenError(err) {
5575
+ if (!(err instanceof ApnsError)) return false;
5576
+ if (err.statusCode !== 403) return false;
5167
5577
  try {
5168
- const s = await (0, import_promises3.stat)(dirPath);
5169
- return s.isDirectory();
5578
+ const parsed = JSON.parse(err.responseBody);
5579
+ return parsed.reason === "InvalidProviderToken" || parsed.reason === "ExpiredProviderToken";
5170
5580
  } catch {
5171
5581
  return false;
5172
5582
  }
5173
5583
  }
5174
- var UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
5175
- async function countJsonlFilesWithMtime(dirPath) {
5584
+ function isBadDeviceTokenError(err) {
5585
+ if (!(err instanceof ApnsError)) return false;
5586
+ if (err.statusCode !== 400 && err.statusCode !== 403 && err.statusCode !== 410) return false;
5176
5587
  try {
5177
- const entries = await (0, import_promises3.readdir)(dirPath, { withFileTypes: true });
5178
- const jsonlNames = new Set(
5179
- entries.filter((e) => e.isFile() && e.name.endsWith(".jsonl")).map((e) => e.name.slice(0, -6))
5180
- );
5181
- const uuidDirs = entries.filter(
5182
- (e) => e.isDirectory() && UUID_RE.test(e.name) && !jsonlNames.has(e.name)
5183
- );
5184
- let latestMtime = 0;
5185
- const jsonlEntries = entries.filter((e) => e.isFile() && e.name.endsWith(".jsonl"));
5186
- await Promise.all([
5187
- ...jsonlEntries.map(async (entry) => {
5188
- try {
5189
- const contentTs = await extractLastTimestamp((0, import_path2.join)(dirPath, entry.name));
5190
- const ts = contentTs ?? (await (0, import_promises3.stat)((0, import_path2.join)(dirPath, entry.name))).mtimeMs;
5191
- if (ts > latestMtime) latestMtime = ts;
5192
- } catch {
5193
- }
5194
- }),
5195
- ...uuidDirs.map(async (entry) => {
5196
- try {
5197
- const fileStat = await (0, import_promises3.stat)((0, import_path2.join)(dirPath, entry.name));
5198
- if (fileStat.mtimeMs > latestMtime) latestMtime = fileStat.mtimeMs;
5199
- } catch {
5200
- }
5201
- })
5202
- ]);
5203
- return { count: jsonlNames.size + uuidDirs.length, latestMtime };
5588
+ const parsed = JSON.parse(err.responseBody);
5589
+ return parsed.reason === "BadDeviceToken" || parsed.reason === "BadEnvironmentKeyInToken" || parsed.reason === "Unregistered";
5204
5590
  } catch {
5205
- return { count: 0, latestMtime: 0 };
5591
+ return false;
5206
5592
  }
5207
5593
  }
5208
5594
 
@@ -5328,7 +5714,10 @@ var AuthManager = class extends import_events3.EventEmitter {
5328
5714
  email: parsed.email,
5329
5715
  authMethod: parsed.authMethod
5330
5716
  };
5331
- } catch {
5717
+ } catch (err) {
5718
+ console.warn(
5719
+ `[AuthManager] checkAuth \u5931\u8D25 (claudePath=${CLAUDE_PATH2}): ${err instanceof Error ? err.message : String(err)}`
5720
+ );
5332
5721
  return { loggedIn: false };
5333
5722
  }
5334
5723
  }
@@ -5424,7 +5813,7 @@ var AuthManager = class extends import_events3.EventEmitter {
5424
5813
 
5425
5814
  // src/terminal/TerminalExecutor.ts
5426
5815
  var import_node_child_process8 = require("child_process");
5427
- var import_uuid5 = require("uuid");
5816
+ var import_uuid4 = require("uuid");
5428
5817
  var EXEC_TIMEOUT_MS = 30 * 60 * 1e3;
5429
5818
  var TerminalExecutor = class {
5430
5819
  processes = /* @__PURE__ */ new Map();
@@ -5446,7 +5835,7 @@ var TerminalExecutor = class {
5446
5835
  }
5447
5836
  }
5448
5837
  exec(sessionId, command, cwd) {
5449
- const execId = (0, import_uuid5.v4)();
5838
+ const execId = (0, import_uuid4.v4)();
5450
5839
  const shell = isWindows ? "powershell" : process.env.SHELL || "/bin/zsh";
5451
5840
  const args = isWindows ? ["-Command", command] : ["-l", "-c", command];
5452
5841
  const proc = (0, import_node_child_process8.spawn)(shell, args, {
@@ -5523,7 +5912,7 @@ var import_node_util = require("util");
5523
5912
  var import_promises4 = require("fs/promises");
5524
5913
  var import_node_path6 = require("path");
5525
5914
  var import_node_os7 = require("os");
5526
- var import_uuid6 = require("uuid");
5915
+ var import_uuid5 = require("uuid");
5527
5916
  var execAsync = (0, import_node_util.promisify)(import_node_child_process9.exec);
5528
5917
  var BUILD_TIMEOUT_MS = 30 * 60 * 1e3;
5529
5918
  var INSTALL_TIMEOUT_MS = 5 * 60 * 1e3;
@@ -5711,7 +6100,7 @@ ${e.stderr ?? ""}`);
5711
6100
  return null;
5712
6101
  }
5713
6102
  if (override) await this.saveConfig(projectPath, override);
5714
- const buildId = (0, import_uuid6.v4)();
6103
+ const buildId = (0, import_uuid5.v4)();
5715
6104
  const args = buildArgs(config);
5716
6105
  const proc = (0, import_node_child_process9.spawn)("xcodebuild", args, {
5717
6106
  cwd: projectPath,
@@ -5775,7 +6164,7 @@ ${e.stderr ?? ""}`);
5775
6164
  this.emitInstallError(sessionId, "", "\u672A\u627E\u5230\u6784\u5EFA\u914D\u7F6E\uFF0C\u8BF7\u5148\u6784\u5EFA\u4E00\u6B21\n");
5776
6165
  return null;
5777
6166
  }
5778
- const installId = (0, import_uuid6.v4)();
6167
+ const installId = (0, import_uuid5.v4)();
5779
6168
  let appPath;
5780
6169
  try {
5781
6170
  appPath = await this.getAppPath(projectPath, config);
@@ -6346,7 +6735,7 @@ function sourceWeight(s) {
6346
6735
  // src/git/GitExecutor.ts
6347
6736
  var import_node_child_process10 = require("child_process");
6348
6737
  var import_node_util2 = require("util");
6349
- var import_uuid7 = require("uuid");
6738
+ var import_uuid6 = require("uuid");
6350
6739
  var execAsync2 = (0, import_node_util2.promisify)(import_node_child_process10.exec);
6351
6740
  var STATUS_TIMEOUT_MS = 15e3;
6352
6741
  var COMMIT_TIMEOUT_MS = 6e4;
@@ -6466,7 +6855,7 @@ var GitExecutor = class {
6466
6855
  * - 若未提供 files:默认 git add -A(提交所有变更)
6467
6856
  */
6468
6857
  async commit(sessionId, projectPath, message, files, alsoPush) {
6469
- const opId = (0, import_uuid7.v4)();
6858
+ const opId = (0, import_uuid6.v4)();
6470
6859
  this.runSequence(sessionId, opId, "commit", projectPath, [
6471
6860
  files && files.length > 0 ? ["git", "add", "--", ...files] : ["git", "add", "-A"],
6472
6861
  ["git", "commit", "-m", message]
@@ -6482,7 +6871,7 @@ var GitExecutor = class {
6482
6871
  return opId;
6483
6872
  }
6484
6873
  async push(sessionId, projectPath) {
6485
- const opId = (0, import_uuid7.v4)();
6874
+ const opId = (0, import_uuid6.v4)();
6486
6875
  this.runSequence(sessionId, opId, "push", projectPath, [
6487
6876
  ["git", "push"]
6488
6877
  ], PUSH_TIMEOUT_MS).catch((err) => {
@@ -6560,7 +6949,7 @@ var GitExecutor = class {
6560
6949
  var import_promises6 = require("fs/promises");
6561
6950
  var import_node_os8 = require("os");
6562
6951
  var import_node_path8 = require("path");
6563
- var import_uuid8 = require("uuid");
6952
+ var import_uuid7 = require("uuid");
6564
6953
  var MAX_TIMEOUT_MS = 2147483647;
6565
6954
  var ScheduledSessionManager = class {
6566
6955
  tasks = /* @__PURE__ */ new Map();
@@ -6598,7 +6987,7 @@ var ScheduledSessionManager = class {
6598
6987
  /** 注册一个定时任务(payload 由调用方校验) */
6599
6988
  schedule(scheduledAt, payload) {
6600
6989
  const task = {
6601
- id: (0, import_uuid8.v4)(),
6990
+ id: (0, import_uuid7.v4)(),
6602
6991
  scheduledAt,
6603
6992
  createdAt: Date.now(),
6604
6993
  payload
@@ -6703,6 +7092,7 @@ function isValidTask(value) {
6703
7092
  var import_node_child_process11 = require("child_process");
6704
7093
  var DEFAULT_MODELS = [
6705
7094
  { value: "opus", label: "Opus 4.7", sublabel: "Most capable for ambitious work" },
7095
+ { value: "claude-opus-4-6", label: "Opus 4.6", sublabel: "Previous generation flagship" },
6706
7096
  { value: "sonnet", label: "Sonnet 4.6", sublabel: "Most efficient for everyday tasks" },
6707
7097
  { value: "haiku", label: "Haiku 4.5", sublabel: "Fastest for quick answers" }
6708
7098
  ];
@@ -6837,7 +7227,7 @@ async function start(opts = {}) {
6837
7227
  try {
6838
7228
  token = (await (0, import_promises7.readFile)(tokenFile, "utf8")).trim();
6839
7229
  } catch {
6840
- token = (0, import_uuid9.v4)();
7230
+ token = (0, import_uuid8.v4)();
6841
7231
  await (0, import_promises7.mkdir)(configDir, { recursive: true });
6842
7232
  await (0, import_promises7.writeFile)(tokenFile, token, "utf8");
6843
7233
  }
@@ -7029,6 +7419,7 @@ async function start(opts = {}) {
7029
7419
  case "kill_session": {
7030
7420
  wsBridge.broadcast({ type: "status_change", sessionId: event.sessionId, status: "idle" });
7031
7421
  approvalProxy.clearPendingForSession(event.sessionId);
7422
+ approvalProxy.clearPendingQuestionsForSession(event.sessionId);
7032
7423
  await sessionManager.killSession(event.sessionId);
7033
7424
  wsBridge.broadcast({
7034
7425
  type: "session_list",
@@ -7047,6 +7438,7 @@ async function start(opts = {}) {
7047
7438
  }
7048
7439
  case "answer_question": {
7049
7440
  sessionManager.handleQuestionResponse(event.requestId, event.answer);
7441
+ approvalProxy.resolveQuestion(event.requestId, event.answer);
7050
7442
  break;
7051
7443
  }
7052
7444
  case "subscribe": {
@@ -7231,6 +7623,14 @@ async function start(opts = {}) {
7231
7623
  notificationService.removePushToken(event.token);
7232
7624
  break;
7233
7625
  }
7626
+ case "register_native_push_token": {
7627
+ notificationService.addNativePushToken(event.token, ws);
7628
+ break;
7629
+ }
7630
+ case "unregister_native_push_token": {
7631
+ notificationService.removeNativePushToken(event.token);
7632
+ break;
7633
+ }
7234
7634
  case "update_notification_sounds": {
7235
7635
  notificationService.setSoundPreferences(event.preferences);
7236
7636
  break;
@@ -7468,6 +7868,9 @@ async function start(opts = {}) {
7468
7868
  decision: decision.decision
7469
7869
  });
7470
7870
  });
7871
+ approvalProxy.onQuestionResolved((requestId) => {
7872
+ sessionManager.clearPendingQuestion(requestId);
7873
+ });
7471
7874
  approvalProxy.onApprovalRequest((request) => {
7472
7875
  wsBridge.broadcast({ type: "approval_request", request });
7473
7876
  setTimeout(() => {
@@ -7484,6 +7887,9 @@ async function start(opts = {}) {
7484
7887
  notificationService.notifyApproval(request, pendingCount);
7485
7888
  }, 6e4);
7486
7889
  });
7890
+ approvalProxy.setQuestionHandler(
7891
+ (sessionId, toolUseId, questions, requestId) => sessionManager.askQuestion(sessionId, toolUseId, questions, requestId)
7892
+ );
7487
7893
  sessionManager.onEvent((event) => {
7488
7894
  if (event.type !== "question_request") return;
7489
7895
  const { request } = event;
@@ -7626,7 +8032,7 @@ async function start(opts = {}) {
7626
8032
  openPairing: (duration) => pairingManager.open(duration),
7627
8033
  closePairing: () => pairingManager.close(),
7628
8034
  regenerateToken: async () => {
7629
- const newToken = (0, import_uuid9.v4)();
8035
+ const newToken = (0, import_uuid8.v4)();
7630
8036
  await (0, import_promises7.mkdir)(configDir, { recursive: true });
7631
8037
  await (0, import_promises7.writeFile)(tokenFile, newToken, "utf8");
7632
8038
  instance.token = newToken;