sessix-server 0.5.0 → 0.5.6

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 +342 -81
  2. package/dist/server.js +337 -76
  3. package/package.json +1 -1
package/dist/index.js CHANGED
@@ -24,9 +24,9 @@ var __toESM = (mod, isNodeMode, target) => (target = mod != null ? __create(__ge
24
24
  ));
25
25
 
26
26
  // src/index.ts
27
- var import_node_os10 = require("os");
28
- var import_node_fs4 = require("fs");
29
- var import_node_path10 = require("path");
27
+ var import_node_os12 = require("os");
28
+ var import_node_fs6 = require("fs");
29
+ var import_node_path12 = require("path");
30
30
  var import_node_child_process13 = require("child_process");
31
31
 
32
32
  // src/i18n/locales/zh.ts
@@ -304,10 +304,12 @@ function t(key, params) {
304
304
  // src/server.ts
305
305
  var import_uuid8 = require("uuid");
306
306
  var import_promises7 = require("fs/promises");
307
- var import_node_os9 = require("os");
308
- var import_node_path9 = require("path");
307
+ var import_node_os11 = require("os");
308
+ var import_node_path11 = require("path");
309
309
  var import_node_child_process12 = require("child_process");
310
310
  var import_node_util3 = require("util");
311
+ var import_node_v8 = require("v8");
312
+ var import_node_vm = require("vm");
311
313
 
312
314
  // src/providers/ProcessProvider.ts
313
315
  var import_child_process = require("child_process");
@@ -444,32 +446,54 @@ var import_promises = require("fs/promises");
444
446
  var import_readline = require("readline");
445
447
  var import_path = require("path");
446
448
  var import_os = require("os");
449
+
450
+ // src/utils/modelValidation.ts
451
+ function isUsableModel(model) {
452
+ if (typeof model !== "string") return false;
453
+ const trimmed = model.trim();
454
+ if (!trimmed) return false;
455
+ if (trimmed === "unknown") return false;
456
+ if (trimmed.startsWith("<")) return false;
457
+ return true;
458
+ }
459
+ function sanitizeModel(model) {
460
+ return isUsableModel(model) ? model.trim() : void 0;
461
+ }
462
+
463
+ // src/session/ProjectReader.ts
447
464
  var CLAUDE_PROJECTS_DIR = (0, import_path.join)((0, import_os.homedir)(), ".claude", "projects");
448
465
  function getSessionFilePath(projectPath, sessionId) {
449
466
  return (0, import_path.join)(CLAUDE_PROJECTS_DIR, encodeDirName(projectPath), `${sessionId}.jsonl`);
450
467
  }
451
468
  async function getSessionModel(projectPath, sessionId) {
452
469
  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;
462
- try {
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;
470
+ let fileHandle;
471
+ try {
472
+ fileHandle = await (0, import_promises.open)(filePath, "r");
473
+ const rl = (0, import_readline.createInterface)({
474
+ input: fileHandle.createReadStream({ encoding: "utf-8" }),
475
+ crlfDelay: Infinity
476
+ });
477
+ let lastModel;
478
+ for await (const line of rl) {
479
+ if (!line.trim()) continue;
480
+ try {
481
+ const obj = JSON.parse(line);
482
+ if (obj.type !== "assistant" || !obj.message) continue;
483
+ const model = obj.message.model;
484
+ if (isUsableModel(model)) {
485
+ lastModel = model;
486
+ }
487
+ } catch {
468
488
  }
469
- } catch {
470
489
  }
490
+ return lastModel;
491
+ } catch (err) {
492
+ if (err.code === "ENOENT") return void 0;
493
+ throw err;
494
+ } finally {
495
+ await fileHandle?.close();
471
496
  }
472
- return void 0;
473
497
  }
474
498
  async function getProjects() {
475
499
  try {
@@ -598,17 +622,23 @@ async function getHistoricalSessions(projectPath) {
598
622
  }
599
623
  }
600
624
  async function getSessionHistory(projectPath, sessionId) {
625
+ let fileHandle;
601
626
  try {
602
627
  const encodedPath = encodeDirName(projectPath);
603
628
  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;
629
+ try {
630
+ fileHandle = await (0, import_promises.open)(filePath, "r");
631
+ } catch (err) {
632
+ if (err.code === "ENOENT") return { ok: true, value: [] };
606
633
  throw err;
634
+ }
635
+ const rl = (0, import_readline.createInterface)({
636
+ input: fileHandle.createReadStream({ encoding: "utf-8" }),
637
+ crlfDelay: Infinity
607
638
  });
608
- if (raw === null) return { ok: true, value: [] };
609
- const lines = raw.split("\n").filter((l) => l.trim());
610
639
  const events = [];
611
- for (const line of lines) {
640
+ for await (const line of rl) {
641
+ if (!line.trim()) continue;
612
642
  try {
613
643
  const obj = JSON.parse(line);
614
644
  const type = obj.type;
@@ -681,6 +711,8 @@ async function getSessionHistory(projectPath, sessionId) {
681
711
  ok: false,
682
712
  error: err instanceof Error ? err : new Error(String(err))
683
713
  };
714
+ } finally {
715
+ await fileHandle?.close();
684
716
  }
685
717
  }
686
718
  async function extractLastTimestamp(filePath) {
@@ -1025,6 +1057,27 @@ var ProcessProvider = class {
1025
1057
  }
1026
1058
  return swept;
1027
1059
  }
1060
+ /**
1061
+ * 枚举可淘汰的老会话
1062
+ *
1063
+ * 进程已退出(已被空闲 GC kill)且空闲超过 maxIdleMs 的会话——其 entry 与各 Map
1064
+ * 仍长期占内存。调用方对返回 id 执行 killSession 彻底清除;淘汰后手机端发消息
1065
+ * 会自动走 resume 路径(--resume + JSONL),不影响继续对话。
1066
+ *
1067
+ * @returns 可淘汰的 sessionId 列表(仅枚举,不删除)
1068
+ */
1069
+ listEvictableSessions(maxIdleMs) {
1070
+ if (maxIdleMs <= 0) return [];
1071
+ const now = Date.now();
1072
+ const evictable = [];
1073
+ for (const [sessionId, entry] of this.activeSessions) {
1074
+ if (entry.process.exitCode === null && entry.process.signalCode === null) continue;
1075
+ if (entry.session.status === "running" || entry.session.status === "waiting_question" || entry.session.status === "waiting_approval") continue;
1076
+ if (now - entry.session.lastActiveAt < maxIdleMs) continue;
1077
+ evictable.push(sessionId);
1078
+ }
1079
+ return evictable;
1080
+ }
1028
1081
  // ============================================
1029
1082
  // 私有方法
1030
1083
  // ============================================
@@ -1046,17 +1099,22 @@ var ProcessProvider = class {
1046
1099
  } else {
1047
1100
  args.push("--session-id", sessionId);
1048
1101
  }
1049
- if (model) {
1050
- args.push("--model", model);
1102
+ const safeModel = sanitizeModel(model);
1103
+ if (model && !safeModel) {
1104
+ console.warn(`[ProcessProvider] Session ${sessionId}: ignoring invalid model "${model}", falling back to CLI default`);
1105
+ }
1106
+ if (safeModel) {
1107
+ args.push("--model", safeModel);
1051
1108
  }
1109
+ const safeFallbackModel = sanitizeModel(fallbackModel);
1052
1110
  if (permissionMode && permissionMode !== "default") {
1053
1111
  args.push("--permission-mode", permissionMode);
1054
1112
  }
1055
1113
  if (effort) {
1056
1114
  args.push("--effort", effort);
1057
1115
  }
1058
- if (fallbackModel) {
1059
- args.push("--fallback-model", fallbackModel);
1116
+ if (safeFallbackModel) {
1117
+ args.push("--fallback-model", safeFallbackModel);
1060
1118
  }
1061
1119
  if (maxBudgetUsd != null) {
1062
1120
  args.push("--max-budget-usd", String(maxBudgetUsd));
@@ -2041,10 +2099,18 @@ var SessionManager = class {
2041
2099
  sessionAgentType = /* @__PURE__ */ new Map();
2042
2100
  /** 事件回调列表(事件会被转发到 WsBridge) */
2043
2101
  eventCallbacks = [];
2102
+ /** 会话被移除(kill / 淘汰)时的回调列表(用于释放外部模块的会话级状态,如 NotificationService) */
2103
+ sessionRemovedCallbacks = [];
2044
2104
  /** 每个会话的事件流取消订阅函数 */
2045
2105
  unsubscribeMap = /* @__PURE__ */ new Map();
2046
2106
  /** 每个会话的事件缓冲区(用于新订阅者重放)*/
2047
2107
  sessionEventBuffers = /* @__PURE__ */ new Map();
2108
+ /**
2109
+ * 每个会话最近一次 AskUserQuestion tool_use 的真实 id(从 claude_event 流捕获)。
2110
+ * PreToolUse hook payload 不含 tool_use_id,但内联卡片需要它来匹配状态,
2111
+ * 故在转发流事件时记录,askQuestion 时兜底回填。
2112
+ */
2113
+ lastAskQuestionToolUseId = /* @__PURE__ */ new Map();
2048
2114
  /** AskUserQuestion 问题映射:requestId → resolve 回调 + 原始问题内容 */
2049
2115
  pendingQuestions = /* @__PURE__ */ new Map();
2050
2116
  /**
@@ -2153,6 +2219,7 @@ var SessionManager = class {
2153
2219
  this.bufferTruncated.delete(sessionId);
2154
2220
  this.sessionProjectPaths.delete(sessionId);
2155
2221
  this.sessionStats.delete(sessionId);
2222
+ this.lastAskQuestionToolUseId.delete(sessionId);
2156
2223
  const pending = this.pendingAssistantEvents.get(sessionId);
2157
2224
  if (pending) {
2158
2225
  clearTimeout(pending.timer);
@@ -2161,6 +2228,13 @@ var SessionManager = class {
2161
2228
  const provider = this.getProviderForSession(sessionId);
2162
2229
  await provider.killSession(sessionId);
2163
2230
  this.sessionAgentType.delete(sessionId);
2231
+ for (const cb of this.sessionRemovedCallbacks) {
2232
+ try {
2233
+ cb(sessionId);
2234
+ } catch (err) {
2235
+ console.error("[SessionManager] sessionRemoved callback failed:", err);
2236
+ }
2237
+ }
2164
2238
  console.log(`[SessionManager] Session killed: ${sessionId}`);
2165
2239
  }
2166
2240
  /**
@@ -2351,6 +2425,21 @@ var SessionManager = class {
2351
2425
  }
2352
2426
  };
2353
2427
  }
2428
+ /**
2429
+ * 注册"会话被移除"回调(会话 kill 或淘汰时触发,传入 sessionId)。
2430
+ * 用于让外部模块释放会话级状态,如 NotificationService.releaseSession。
2431
+ *
2432
+ * @returns 取消注册的函数
2433
+ */
2434
+ onSessionRemoved(callback) {
2435
+ this.sessionRemovedCallbacks.push(callback);
2436
+ return () => {
2437
+ const index = this.sessionRemovedCallbacks.indexOf(callback);
2438
+ if (index !== -1) {
2439
+ this.sessionRemovedCallbacks.splice(index, 1);
2440
+ }
2441
+ };
2442
+ }
2354
2443
  /**
2355
2444
  * 清理所有资源
2356
2445
  */
@@ -2374,6 +2463,7 @@ var SessionManager = class {
2374
2463
  this.pendingQuestions.clear();
2375
2464
  this.lastBroadcastStatus.clear();
2376
2465
  this.eventCallbacks.length = 0;
2466
+ this.sessionRemovedCallbacks.length = 0;
2377
2467
  console.log("[SessionManager] Destroyed");
2378
2468
  }
2379
2469
  // ============================================
@@ -2422,6 +2512,13 @@ var SessionManager = class {
2422
2512
  console.log(`[SessionManager] \u{1F9E0} thinking block detected in ${sessionId}: msgId=${event.message.id}, blocks=${thinkingBlocks.length}, len=${thinkingBlocks.map((b) => (b.thinking || "").length).join(",")}`);
2423
2513
  }
2424
2514
  }
2515
+ if (event.type === "assistant" && Array.isArray(event.message?.content)) {
2516
+ for (const block of event.message.content) {
2517
+ if (block.type === "tool_use" && (block.name === "AskUserQuestion" || block.name === "AskFollowupQuestion") && typeof block.id === "string") {
2518
+ this.lastAskQuestionToolUseId.set(sessionId, block.id);
2519
+ }
2520
+ }
2521
+ }
2425
2522
  switch (event.type) {
2426
2523
  case "assistant":
2427
2524
  this.bufferAssistantEvent(sessionId, event);
@@ -2542,10 +2639,11 @@ var SessionManager = class {
2542
2639
  * 返回的 Promise 在 handleQuestionResponse 时 resolve。
2543
2640
  */
2544
2641
  askQuestion(sessionId, toolUseId, questions, requestId) {
2642
+ const resolvedToolUseId = toolUseId || this.lastAskQuestionToolUseId.get(sessionId) || "";
2545
2643
  const request = {
2546
2644
  id: requestId,
2547
2645
  sessionId,
2548
- toolUseId,
2646
+ toolUseId: resolvedToolUseId,
2549
2647
  question: questions[0]?.question ?? "",
2550
2648
  options: questions[0]?.options?.map((o) => o.label),
2551
2649
  questions,
@@ -2557,7 +2655,7 @@ var SessionManager = class {
2557
2655
  return new Promise((resolve) => {
2558
2656
  this.pendingQuestions.set(requestId, {
2559
2657
  sessionId,
2560
- toolUseId,
2658
+ toolUseId: resolvedToolUseId,
2561
2659
  question: request.question,
2562
2660
  options: request.options,
2563
2661
  questions,
@@ -4079,7 +4177,8 @@ var HookInstaller = class {
4079
4177
  const isLatestVersion = approvalScriptContent.includes("permissionDecision") && approvalScriptContent.includes("Sessix Approval Hook v2");
4080
4178
  const settings = await this.readClaudeSettings();
4081
4179
  const configExists = this.hasHookConfig(settings);
4082
- return isLatestVersion && permissionScriptExists && compactScriptExists && postCompactScriptExists && permissionDeniedScriptExists && configExists;
4180
+ const hasLegacyHook = this.hasHookEntry(settings?.hooks?.PreToolUse, LEGACY_HOOK_COMMANDS[0]) || this.hasHookEntry(settings?.hooks?.PermissionRequest, LEGACY_HOOK_COMMANDS[1]);
4181
+ return isLatestVersion && permissionScriptExists && compactScriptExists && postCompactScriptExists && permissionDeniedScriptExists && configExists && !hasLegacyHook;
4083
4182
  }
4084
4183
  // ============================================
4085
4184
  // 内部方法
@@ -4091,8 +4190,14 @@ var HookInstaller = class {
4091
4190
  let settings = await this.readClaudeSettings();
4092
4191
  let changed = false;
4093
4192
  for (const cmd of LEGACY_HOOK_COMMANDS) {
4094
- this.removeHookCommand(settings, "PreToolUse", cmd);
4095
- this.removeHookCommand(settings, "PermissionRequest", cmd);
4193
+ if (this.hasHookEntry(settings?.hooks?.PreToolUse, cmd)) {
4194
+ this.removeHookCommand(settings, "PreToolUse", cmd);
4195
+ changed = true;
4196
+ }
4197
+ if (this.hasHookEntry(settings?.hooks?.PermissionRequest, cmd)) {
4198
+ this.removeHookCommand(settings, "PermissionRequest", cmd);
4199
+ changed = true;
4200
+ }
4096
4201
  }
4097
4202
  if (!settings.hooks) {
4098
4203
  settings.hooks = {};
@@ -4489,12 +4594,40 @@ var NotificationService = class _NotificationService {
4489
4594
  this.latestAssistantText.clear();
4490
4595
  for (const timer of this.activityPushTimers.values()) clearTimeout(timer);
4491
4596
  this.activityPushTimers.clear();
4597
+ for (const timer of this.idleEndTimers.values()) clearTimeout(timer);
4598
+ this.idleEndTimers.clear();
4599
+ for (const timer of this.laHeartbeatTimers.values()) clearInterval(timer);
4600
+ this.laHeartbeatTimers.clear();
4492
4601
  this.recentActivityState.clear();
4493
4602
  this.lastActivityPushAt.clear();
4494
4603
  this.pendingPriority.clear();
4495
4604
  this.activityCounters.clear();
4496
4605
  this.lastPushedFingerprint.clear();
4497
4606
  }
4607
+ /**
4608
+ * 释放单个会话的全部内存状态(会话被 kill 或淘汰时调用)。
4609
+ * 由 SessionManager.onSessionRemoved 钩子触发,覆盖用户主动 kill 和自动淘汰两条路径。
4610
+ * 幂等:重复调用或对未知会话调用都安全。
4611
+ */
4612
+ releaseSession(sessionId) {
4613
+ this.clearActivityPushTimer(sessionId);
4614
+ this.cancelIdleEndTimer(sessionId);
4615
+ this.stopLaHeartbeat(sessionId);
4616
+ this.clearSessionActivityState(sessionId);
4617
+ this.yoloModeState.delete(sessionId);
4618
+ this.lastActivityPushAt.delete(sessionId);
4619
+ this.lastPushedFingerprint.delete(sessionId);
4620
+ this.pendingPriority.delete(sessionId);
4621
+ }
4622
+ /**
4623
+ * 清空单会话可重建的重状态(recentActivity / 计数器 / 最新文本)。
4624
+ * 会话走到 idle 时调用即可释放内存——resume 后这些状态会随新事件自动重建。
4625
+ */
4626
+ clearSessionActivityState(sessionId) {
4627
+ this.recentActivityState.delete(sessionId);
4628
+ this.activityCounters.delete(sessionId);
4629
+ this.latestAssistantText.delete(sessionId);
4630
+ }
4498
4631
  // ============================================
4499
4632
  // 内部方法
4500
4633
  // ============================================
@@ -4532,6 +4665,7 @@ var NotificationService = class _NotificationService {
4532
4665
  badge: this.getGlobalPendingCount(),
4533
4666
  data: { type: "task_complete", sessionId: event.sessionId }
4534
4667
  });
4668
+ this.clearSessionActivityState(event.sessionId);
4535
4669
  }
4536
4670
  } else if (event.status === "running" || event.status === "waiting_approval" || event.status === "waiting_question") {
4537
4671
  this.cancelIdleEndTimer(event.sessionId);
@@ -4820,9 +4954,8 @@ var NotificationService = class _NotificationService {
4820
4954
  });
4821
4955
  }
4822
4956
  this.stopLaHeartbeat(sessionId);
4823
- this.recentActivityState.delete(sessionId);
4957
+ this.clearSessionActivityState(sessionId);
4824
4958
  this.lastActivityPushAt.delete(sessionId);
4825
- this.activityCounters.delete(sessionId);
4826
4959
  this.lastPushedFingerprint.delete(sessionId);
4827
4960
  console.log(`[NotificationService] \u{1F3C1} LA end (${reason}) session=${sessionId.slice(0, 8)}\u2026`);
4828
4961
  }
@@ -5015,6 +5148,36 @@ var DesktopNotificationChannel = class {
5015
5148
  }
5016
5149
  };
5017
5150
 
5151
+ // src/notification/pushTokenStore.ts
5152
+ var import_node_fs4 = require("fs");
5153
+ var import_node_os7 = require("os");
5154
+ var import_node_path6 = require("path");
5155
+ var DEFAULT_PUSH_TOKENS_FILE = (0, import_node_path6.join)((0, import_node_os7.homedir)(), ".sessix", "push-tokens.json");
5156
+ function loadPushTokens(filePath = DEFAULT_PUSH_TOKENS_FILE) {
5157
+ try {
5158
+ const parsed = JSON.parse((0, import_node_fs4.readFileSync)(filePath, "utf-8"));
5159
+ if (!Array.isArray(parsed)) return [];
5160
+ return parsed.filter((t2) => typeof t2 === "string");
5161
+ } catch {
5162
+ return [];
5163
+ }
5164
+ }
5165
+ function savePushTokens(tokens, filePath = DEFAULT_PUSH_TOKENS_FILE) {
5166
+ const deduped = [];
5167
+ const seen = /* @__PURE__ */ new Set();
5168
+ for (const t2 of tokens) {
5169
+ if (typeof t2 !== "string" || seen.has(t2)) continue;
5170
+ seen.add(t2);
5171
+ deduped.push(t2);
5172
+ }
5173
+ try {
5174
+ (0, import_node_fs4.mkdirSync)((0, import_node_path6.dirname)(filePath), { recursive: true });
5175
+ (0, import_node_fs4.writeFileSync)(filePath, JSON.stringify(deduped, null, 2), "utf-8");
5176
+ } catch (err) {
5177
+ console.warn("[pushTokenStore] \u5199\u5165 push token \u5931\u8D25:", err);
5178
+ }
5179
+ }
5180
+
5018
5181
  // src/notification/ExpoNotificationChannel.ts
5019
5182
  var EXPO_PUSH_API = "https://exp.host/--/api/v2/push/send";
5020
5183
  var EXPO_RECEIPT_API = "https://exp.host/--/api/v2/push/getReceipts";
@@ -5025,18 +5188,35 @@ var ExpoNotificationChannel = class {
5025
5188
  tokenWsMap = /* @__PURE__ */ new Map();
5026
5189
  /** per-token 通知音效偏好 */
5027
5190
  soundPreferences = /* @__PURE__ */ new Map();
5191
+ /** push token 持久化文件路径 */
5192
+ pushTokensFile;
5193
+ constructor(opts = {}) {
5194
+ this.pushTokensFile = opts.pushTokensFile ?? DEFAULT_PUSH_TOKENS_FILE;
5195
+ for (const token of loadPushTokens(this.pushTokensFile)) {
5196
+ this.tokens.add(token);
5197
+ }
5198
+ if (this.tokens.size > 0) {
5199
+ console.log(`[ExpoNotificationChannel] \u4ECE\u78C1\u76D8\u91CD\u8F7D ${this.tokens.size} \u4E2A push token`);
5200
+ }
5201
+ }
5202
+ /** 把当前 token 集合落盘,供进程重启后重载。 */
5203
+ persist() {
5204
+ savePushTokens(Array.from(this.tokens), this.pushTokensFile);
5205
+ }
5028
5206
  isAvailable() {
5029
5207
  return this.tokens.size > 0;
5030
5208
  }
5031
5209
  addToken(token, ws) {
5032
5210
  this.tokens.add(token);
5033
5211
  if (ws) this.tokenWsMap.set(token, ws);
5212
+ this.persist();
5034
5213
  console.log(`[ExpoNotificationChannel] ${t("notification.tokenRegistered", { count: this.tokens.size })}`);
5035
5214
  }
5036
5215
  removeToken(token) {
5037
5216
  this.tokens.delete(token);
5038
5217
  this.tokenWsMap.delete(token);
5039
5218
  this.soundPreferences.delete(token);
5219
+ this.persist();
5040
5220
  console.log(`[ExpoNotificationChannel] ${t("notification.tokenRemoved", { count: this.tokens.size })}`);
5041
5221
  }
5042
5222
  /** 更新某个 token 的音效偏好 */
@@ -5108,6 +5288,7 @@ var ExpoNotificationChannel = class {
5108
5288
  this.tokens.delete(staleToken);
5109
5289
  this.tokenWsMap.delete(staleToken);
5110
5290
  this.soundPreferences.delete(staleToken);
5291
+ this.persist();
5111
5292
  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
5293
  }
5113
5294
  } else if (ticket.status === "ok" && typeof ticket.id === "string" && targetTokens[i]) {
@@ -5154,6 +5335,7 @@ var ExpoNotificationChannel = class {
5154
5335
  this.tokens.delete(token);
5155
5336
  this.tokenWsMap.delete(token);
5156
5337
  this.soundPreferences.delete(token);
5338
+ this.persist();
5157
5339
  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
5340
  } else if (errorCode === "InvalidCredentials" || errorCode === "MismatchSenderId") {
5159
5341
  console.error(
@@ -5653,6 +5835,9 @@ var PairingManager = class {
5653
5835
 
5654
5836
  // src/utils/shellPath.ts
5655
5837
  var import_node_child_process7 = require("child_process");
5838
+ var import_node_fs5 = require("fs");
5839
+ var import_node_path7 = require("path");
5840
+ var import_node_os8 = require("os");
5656
5841
  var fixed = false;
5657
5842
  function fixShellPath() {
5658
5843
  if (fixed || isWindows) {
@@ -5660,23 +5845,62 @@ function fixShellPath() {
5660
5845
  return;
5661
5846
  }
5662
5847
  fixed = true;
5848
+ const fromShell = readLoginShellPath();
5849
+ if (fromShell) {
5850
+ process.env.PATH = mergePath(fromShell, process.env.PATH || "");
5851
+ }
5852
+ const stableNodeDir = resolveStableNodeDir();
5853
+ if (stableNodeDir) {
5854
+ process.env.PATH = mergePath(stableNodeDir, process.env.PATH || "");
5855
+ }
5856
+ }
5857
+ function readLoginShellPath() {
5663
5858
  const shell = process.env.SHELL || "/bin/zsh";
5664
5859
  const isFish = /\/fish$/.test(shell);
5665
5860
  const printPathCmd = isFish ? "string join : $PATH" : 'printf "%s" "$PATH"';
5666
- let raw;
5861
+ for (const flags of [["-i", "-l", "-c"], ["-l", "-c"]]) {
5862
+ try {
5863
+ const raw = (0, import_node_child_process7.execFileSync)(shell, [...flags, printPathCmd], {
5864
+ encoding: "utf8",
5865
+ timeout: 4e3,
5866
+ stdio: ["ignore", "pipe", "ignore"]
5867
+ }).trim();
5868
+ if (raw) return raw;
5869
+ } catch {
5870
+ }
5871
+ }
5872
+ return null;
5873
+ }
5874
+ function resolveStableNodeDir() {
5875
+ const exe = isWindows ? "node.exe" : "node";
5876
+ for (const dir of (process.env.PATH || "").split(":")) {
5877
+ if (!dir) continue;
5878
+ try {
5879
+ const p = (0, import_node_path7.join)(dir, exe);
5880
+ (0, import_node_fs5.accessSync)(p, import_node_fs5.constants.X_OK);
5881
+ return (0, import_node_path7.dirname)((0, import_node_fs5.realpathSync)(p));
5882
+ } catch {
5883
+ }
5884
+ }
5885
+ return scanFnmNodeDir();
5886
+ }
5887
+ function scanFnmNodeDir() {
5888
+ const base = (0, import_node_path7.join)((0, import_node_os8.homedir)(), ".fnm", "node-versions");
5667
5889
  try {
5668
- raw = (0, import_node_child_process7.execFileSync)(shell, ["-l", "-c", printPathCmd], {
5669
- encoding: "utf8",
5670
- timeout: 3e3,
5671
- stdio: ["ignore", "pipe", "ignore"]
5672
- });
5673
- } catch (err) {
5674
- console.warn("[fixShellPath] failed to read login shell PATH:", err);
5675
- return;
5890
+ const versions = (0, import_node_fs5.readdirSync)(base).filter((v) => /^v?\d+\./.test(v)).sort(
5891
+ (a, b) => b.localeCompare(a, void 0, { numeric: true, sensitivity: "base" })
5892
+ );
5893
+ for (const v of versions) {
5894
+ const dir = (0, import_node_path7.join)(base, v, "installation", "bin");
5895
+ try {
5896
+ (0, import_node_fs5.accessSync)((0, import_node_path7.join)(dir, "node"), import_node_fs5.constants.X_OK);
5897
+ return dir;
5898
+ } catch {
5899
+ }
5900
+ }
5901
+ } catch {
5676
5902
  }
5677
- const fromShell = raw.trim();
5678
- if (!fromShell) return;
5679
- process.env.PATH = mergePath(fromShell, process.env.PATH || "");
5903
+ return null;
5680
5904
  }
5681
5905
  function mergePath(primary, secondary) {
5682
5906
  const seen = /* @__PURE__ */ new Set();
@@ -5910,20 +6134,19 @@ var TerminalExecutor = class {
5910
6134
  var import_node_child_process9 = require("child_process");
5911
6135
  var import_node_util = require("util");
5912
6136
  var import_promises4 = require("fs/promises");
5913
- var import_node_path6 = require("path");
5914
- var import_node_os7 = require("os");
6137
+ var import_node_path8 = require("path");
6138
+ var import_node_os9 = require("os");
5915
6139
  var import_uuid5 = require("uuid");
5916
6140
  var execAsync = (0, import_node_util.promisify)(import_node_child_process9.exec);
5917
6141
  var BUILD_TIMEOUT_MS = 30 * 60 * 1e3;
5918
6142
  var INSTALL_TIMEOUT_MS = 5 * 60 * 1e3;
5919
- var CONFIG_FILE = (0, import_node_path6.join)((0, import_node_os7.homedir)(), ".sessix", "xcode-config.json");
6143
+ var CONFIG_FILE = (0, import_node_path8.join)((0, import_node_os9.homedir)(), ".sessix", "xcode-config.json");
5920
6144
  var SKIP_DIRS = /* @__PURE__ */ new Set([
5921
6145
  "node_modules",
5922
6146
  ".git",
5923
6147
  "DerivedData",
5924
6148
  "Pods",
5925
6149
  ".build",
5926
- "build",
5927
6150
  "dist",
5928
6151
  "__pycache__",
5929
6152
  ".next",
@@ -5970,7 +6193,7 @@ var XcodeBuildExecutor = class {
5970
6193
  return this.configCache;
5971
6194
  }
5972
6195
  async writeConfigs(store) {
5973
- await (0, import_promises4.mkdir)((0, import_node_path6.join)((0, import_node_os7.homedir)(), ".sessix"), { recursive: true });
6196
+ await (0, import_promises4.mkdir)((0, import_node_path8.join)((0, import_node_os9.homedir)(), ".sessix"), { recursive: true });
5974
6197
  await (0, import_promises4.writeFile)(CONFIG_FILE, JSON.stringify(store, null, 2), "utf8");
5975
6198
  this.configCache = store;
5976
6199
  }
@@ -6023,7 +6246,7 @@ var XcodeBuildExecutor = class {
6023
6246
  if (SKIP_DIRS.has(name)) continue;
6024
6247
  if (name.startsWith(".")) continue;
6025
6248
  if (name.endsWith(".xcodeproj") || name.endsWith(".xcworkspace")) continue;
6026
- const childPath = (0, import_node_path6.join)(currentPath, name);
6249
+ const childPath = (0, import_node_path8.join)(currentPath, name);
6027
6250
  await this.scanDir(rootPath, childPath, depth + 1, results);
6028
6251
  }
6029
6252
  }
@@ -6250,7 +6473,7 @@ ${e.stderr ?? ""}`);
6250
6473
  if (!builtDir || !productName) {
6251
6474
  throw new Error("\u65E0\u6CD5\u4ECE -showBuildSettings \u4E2D\u8BFB\u53D6 BUILT_PRODUCTS_DIR / FULL_PRODUCT_NAME");
6252
6475
  }
6253
- return (0, import_node_path6.join)(builtDir, productName);
6476
+ return (0, import_node_path8.join)(builtDir, productName);
6254
6477
  }
6255
6478
  // ============================================
6256
6479
  // 清理
@@ -6322,7 +6545,7 @@ function kindOrder(k) {
6322
6545
 
6323
6546
  // src/commands/CommandDiscovery.ts
6324
6547
  var import_promises5 = require("fs/promises");
6325
- var import_node_path7 = require("path");
6548
+ var import_node_path9 = require("path");
6326
6549
  var import_node_crypto = require("crypto");
6327
6550
  var CACHE_TTL_MS = 5 * 60 * 1e3;
6328
6551
  var MAX_README_BYTES = 256 * 1024;
@@ -6346,7 +6569,7 @@ var CommandDiscovery = class {
6346
6569
  this.scanReadme(projectPath, "CLAUDE.md", "claude.md", collector)
6347
6570
  ]);
6348
6571
  for (const sub of SUBPACKAGE_DIRS) {
6349
- const subRoot = (0, import_node_path7.join)(projectPath, sub);
6572
+ const subRoot = (0, import_node_path9.join)(projectPath, sub);
6350
6573
  let entries;
6351
6574
  try {
6352
6575
  entries = await (0, import_promises5.readdir)(subRoot);
@@ -6356,7 +6579,7 @@ var CommandDiscovery = class {
6356
6579
  let scanned = 0;
6357
6580
  for (const name of entries) {
6358
6581
  if (name.startsWith(".") || scanned >= MAX_SCAN_PER_DIR) continue;
6359
- const childAbs = (0, import_node_path7.join)(subRoot, name);
6582
+ const childAbs = (0, import_node_path9.join)(subRoot, name);
6360
6583
  try {
6361
6584
  const s = await (0, import_promises5.stat)(childAbs);
6362
6585
  if (!s.isDirectory()) continue;
@@ -6397,7 +6620,7 @@ var CommandDiscovery = class {
6397
6620
  // ============================================
6398
6621
  async scanPackageJson(rootPath, subDir, out) {
6399
6622
  const file = subDir ? `${subDir}/package.json` : "package.json";
6400
- const abs = (0, import_node_path7.join)(rootPath, file);
6623
+ const abs = (0, import_node_path9.join)(rootPath, file);
6401
6624
  let raw;
6402
6625
  try {
6403
6626
  raw = await (0, import_promises5.readFile)(abs, "utf8");
@@ -6428,7 +6651,7 @@ var CommandDiscovery = class {
6428
6651
  }
6429
6652
  async scanMakefile(rootPath, subDir, out) {
6430
6653
  const file = subDir ? `${subDir}/Makefile` : "Makefile";
6431
- const abs = (0, import_node_path7.join)(rootPath, file);
6654
+ const abs = (0, import_node_path9.join)(rootPath, file);
6432
6655
  let raw;
6433
6656
  try {
6434
6657
  raw = await (0, import_promises5.readFile)(abs, "utf8");
@@ -6469,7 +6692,7 @@ var CommandDiscovery = class {
6469
6692
  }
6470
6693
  async scanJustfile(rootPath, subDir, out) {
6471
6694
  const file = subDir ? `${subDir}/justfile` : "justfile";
6472
- const abs = (0, import_node_path7.join)(rootPath, file);
6695
+ const abs = (0, import_node_path9.join)(rootPath, file);
6473
6696
  let raw;
6474
6697
  try {
6475
6698
  raw = await (0, import_promises5.readFile)(abs, "utf8");
@@ -6510,7 +6733,7 @@ var CommandDiscovery = class {
6510
6733
  }
6511
6734
  async scanCargo(rootPath, subDir, out) {
6512
6735
  const file = subDir ? `${subDir}/Cargo.toml` : "Cargo.toml";
6513
- const abs = (0, import_node_path7.join)(rootPath, file);
6736
+ const abs = (0, import_node_path9.join)(rootPath, file);
6514
6737
  try {
6515
6738
  await (0, import_promises5.stat)(abs);
6516
6739
  } catch {
@@ -6541,7 +6764,7 @@ var CommandDiscovery = class {
6541
6764
  for (const name of ["docker-compose.yml", "docker-compose.yaml", "compose.yml", "compose.yaml"]) {
6542
6765
  const file = subDir ? `${subDir}/${name}` : name;
6543
6766
  try {
6544
- await (0, import_promises5.stat)((0, import_node_path7.join)(rootPath, file));
6767
+ await (0, import_promises5.stat)((0, import_node_path9.join)(rootPath, file));
6545
6768
  } catch {
6546
6769
  continue;
6547
6770
  }
@@ -6567,7 +6790,7 @@ var CommandDiscovery = class {
6567
6790
  }
6568
6791
  }
6569
6792
  async scanReadme(rootPath, fileName, source, out) {
6570
- const abs = (0, import_node_path7.join)(rootPath, fileName);
6793
+ const abs = (0, import_node_path9.join)(rootPath, fileName);
6571
6794
  let raw;
6572
6795
  try {
6573
6796
  const s = await (0, import_promises5.stat)(abs);
@@ -6947,8 +7170,8 @@ var GitExecutor = class {
6947
7170
 
6948
7171
  // src/scheduling/ScheduledSessionManager.ts
6949
7172
  var import_promises6 = require("fs/promises");
6950
- var import_node_os8 = require("os");
6951
- var import_node_path8 = require("path");
7173
+ var import_node_os10 = require("os");
7174
+ var import_node_path10 = require("path");
6952
7175
  var import_uuid7 = require("uuid");
6953
7176
  var MAX_TIMEOUT_MS = 2147483647;
6954
7177
  var ScheduledSessionManager = class {
@@ -6959,7 +7182,7 @@ var ScheduledSessionManager = class {
6959
7182
  onFired;
6960
7183
  persistTimer = null;
6961
7184
  constructor(opts) {
6962
- this.storeFile = opts.storeFile ?? (0, import_node_path8.join)((0, import_node_os8.homedir)(), ".sessix", "scheduled-sessions.json");
7185
+ this.storeFile = opts.storeFile ?? (0, import_node_path10.join)((0, import_node_os10.homedir)(), ".sessix", "scheduled-sessions.json");
6963
7186
  this.onFire = opts.onFire;
6964
7187
  this.onChange = opts.onChange;
6965
7188
  this.onFired = opts.onFired;
@@ -7064,7 +7287,7 @@ var ScheduledSessionManager = class {
7064
7287
  this.persistTimer = setTimeout(() => {
7065
7288
  this.persistTimer = null;
7066
7289
  const tasks = [...this.tasks.values()].map((e) => e.task);
7067
- (0, import_promises6.mkdir)((0, import_node_path8.join)(this.storeFile, ".."), { recursive: true }).then(() => (0, import_promises6.writeFile)(this.storeFile, JSON.stringify(tasks, null, 2), "utf8")).catch((err) => {
7290
+ (0, import_promises6.mkdir)((0, import_node_path10.join)(this.storeFile, ".."), { recursive: true }).then(() => (0, import_promises6.writeFile)(this.storeFile, JSON.stringify(tasks, null, 2), "utf8")).catch((err) => {
7068
7291
  console.error("[ScheduledSessionManager] persist error:", err);
7069
7292
  });
7070
7293
  }, 500);
@@ -7091,10 +7314,11 @@ function isValidTask(value) {
7091
7314
  // src/utils/cliCapabilities.ts
7092
7315
  var import_node_child_process11 = require("child_process");
7093
7316
  var DEFAULT_MODELS = [
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" },
7096
- { value: "sonnet", label: "Sonnet 4.6", sublabel: "Most efficient for everyday tasks" },
7097
- { value: "haiku", label: "Haiku 4.5", sublabel: "Fastest for quick answers" }
7317
+ { value: "opus", label: "Opus 4.8", sublabel: "Most capable for ambitious work", maxEffort: "max", defaultEffort: "xhigh" },
7318
+ { value: "claude-opus-4-7", label: "Opus 4.7", sublabel: "Previous generation flagship", maxEffort: "max", defaultEffort: "xhigh" },
7319
+ { value: "claude-opus-4-6", label: "Opus 4.6", sublabel: "Earlier generation flagship", maxEffort: "max", defaultEffort: "xhigh" },
7320
+ { value: "sonnet", label: "Sonnet 4.6", sublabel: "Most efficient for everyday tasks", maxEffort: "high", defaultEffort: "high" },
7321
+ { value: "haiku", label: "Haiku 4.5", sublabel: "Fastest for quick answers", maxEffort: "medium", defaultEffort: "medium" }
7098
7322
  ];
7099
7323
  var DEFAULT_CAPABILITIES = {
7100
7324
  effortLevels: ["low", "medium", "high", "xhigh", "max"],
@@ -7167,7 +7391,7 @@ async function killPortProcess(port) {
7167
7391
  }
7168
7392
  }
7169
7393
  async function loadApnsConfigFromFile() {
7170
- const path2 = (0, import_node_path9.join)((0, import_node_os9.homedir)(), ".sessix", "apns.json");
7394
+ const path2 = (0, import_node_path11.join)((0, import_node_os11.homedir)(), ".sessix", "apns.json");
7171
7395
  try {
7172
7396
  const raw = await (0, import_promises7.readFile)(path2, "utf8");
7173
7397
  const cfg = JSON.parse(raw);
@@ -7214,8 +7438,8 @@ async function createWithRetry(label, port, factory) {
7214
7438
  }
7215
7439
  async function start(opts = {}) {
7216
7440
  fixShellPath();
7217
- const configDir = (0, import_node_path9.join)((0, import_node_os9.homedir)(), ".sessix");
7218
- const tokenFile = (0, import_node_path9.join)(configDir, "token");
7441
+ const configDir = (0, import_node_path11.join)((0, import_node_os11.homedir)(), ".sessix");
7442
+ const tokenFile = (0, import_node_path11.join)(configDir, "token");
7219
7443
  let token;
7220
7444
  if (opts.token !== void 0) {
7221
7445
  token = opts.token;
@@ -7262,6 +7486,7 @@ async function start(opts = {}) {
7262
7486
  const notificationService = new NotificationService(sessionManager, expoChannel);
7263
7487
  notificationService.addChannel("expo", expoChannel, opts.enableExpoPush !== false);
7264
7488
  notificationService.addChannel("mac", new DesktopNotificationChannel(), opts.enableMacNotification !== false);
7489
+ sessionManager.onSessionRemoved((sessionId) => notificationService.releaseSession(sessionId));
7265
7490
  const activityPushOpts = opts.activityPush ?? await loadApnsConfigFromFile();
7266
7491
  if (activityPushOpts) {
7267
7492
  try {
@@ -7279,7 +7504,7 @@ async function start(opts = {}) {
7279
7504
  let mdnsService = null;
7280
7505
  const pairingManager = new PairingManager({
7281
7506
  token,
7282
- serverName: (0, import_node_os9.hostname)(),
7507
+ serverName: (0, import_node_os11.hostname)(),
7283
7508
  version: "0.2.0",
7284
7509
  onStateChange: (state) => mdnsService?.updatePairingState(state)
7285
7510
  });
@@ -7950,8 +8175,30 @@ async function start(opts = {}) {
7950
8175
  const idleTimeoutMs = Number(process.env.SESSIX_IDLE_TIMEOUT_MS ?? 30 * 60 * 1e3);
7951
8176
  const idleSweepIntervalMs = Number(process.env.SESSIX_IDLE_SWEEP_INTERVAL_MS ?? 5 * 60 * 1e3);
7952
8177
  const maxActiveProcesses = Number(process.env.SESSIX_MAX_ACTIVE_PROCESSES ?? 15);
8178
+ const sessionEvictMs = Number(process.env.SESSIX_SESSION_EVICT_MS ?? 2 * 60 * 60 * 1e3);
8179
+ let gcFn;
8180
+ const maybeGc = () => {
8181
+ if (gcFn === void 0) {
8182
+ gcFn = globalThis.gc ?? null;
8183
+ if (!gcFn) {
8184
+ try {
8185
+ (0, import_node_v8.setFlagsFromString)("--expose-gc");
8186
+ const fn = (0, import_node_vm.runInNewContext)("gc");
8187
+ gcFn = typeof fn === "function" ? fn : null;
8188
+ } catch {
8189
+ gcFn = null;
8190
+ }
8191
+ }
8192
+ }
8193
+ if (gcFn) {
8194
+ try {
8195
+ gcFn();
8196
+ } catch {
8197
+ }
8198
+ }
8199
+ };
7953
8200
  let idleSweepTimer = null;
7954
- if (idleSweepIntervalMs > 0 && (idleTimeoutMs > 0 || maxActiveProcesses > 0)) {
8201
+ if (idleSweepIntervalMs > 0 && (idleTimeoutMs > 0 || maxActiveProcesses > 0 || sessionEvictMs > 0)) {
7955
8202
  idleSweepTimer = setInterval(async () => {
7956
8203
  try {
7957
8204
  let totalSwept = 0;
@@ -7970,7 +8217,18 @@ async function start(opts = {}) {
7970
8217
  swept.forEach(broadcastShrink);
7971
8218
  totalSwept += swept.length;
7972
8219
  }
8220
+ if (sessionEvictMs > 0 && typeof provider.listEvictableSessions === "function") {
8221
+ const evictable = provider.listEvictableSessions(sessionEvictMs);
8222
+ for (const id of evictable) {
8223
+ await sessionManager.killSession(id);
8224
+ }
8225
+ if (evictable.length > 0) {
8226
+ console.log(`[Server] Idle GC: evicted ${evictable.length} stale session(s)`);
8227
+ totalSwept += evictable.length;
8228
+ }
8229
+ }
7973
8230
  }
8231
+ const hasRunning = sessionManager.getActiveSessions().some((s) => s.status === "running");
7974
8232
  if (totalSwept > 0) {
7975
8233
  console.log(`[Server] Idle GC: swept ${totalSwept} idle session(s)`);
7976
8234
  wsBridge.broadcast({
@@ -7978,6 +8236,9 @@ async function start(opts = {}) {
7978
8236
  sessions: sessionManager.getActiveSessions()
7979
8237
  });
7980
8238
  }
8239
+ if (totalSwept > 0 || !hasRunning) {
8240
+ maybeGc();
8241
+ }
7981
8242
  } catch (err) {
7982
8243
  console.error("[Server] Idle GC failed:", err);
7983
8244
  }
@@ -8051,7 +8312,7 @@ async function start(opts = {}) {
8051
8312
  var import_qrcode_terminal = __toESM(require("qrcode-terminal"));
8052
8313
  function getPackageVersion() {
8053
8314
  try {
8054
- const pkg = JSON.parse((0, import_node_fs4.readFileSync)((0, import_node_path10.join)(__dirname, "..", "package.json"), "utf8"));
8315
+ const pkg = JSON.parse((0, import_node_fs6.readFileSync)((0, import_node_path12.join)(__dirname, "..", "package.json"), "utf8"));
8055
8316
  return pkg.version ?? "0.0.0";
8056
8317
  } catch {
8057
8318
  return "0.0.0";
@@ -8195,7 +8456,7 @@ ${t("startup.pairingReopened")}`);
8195
8456
  }
8196
8457
  }
8197
8458
  function getLocalIp() {
8198
- const interfaces = (0, import_node_os10.networkInterfaces)();
8459
+ const interfaces = (0, import_node_os12.networkInterfaces)();
8199
8460
  for (const iface of Object.values(interfaces)) {
8200
8461
  for (const addr of iface ?? []) {
8201
8462
  if (addr.family === "IPv4" && !addr.internal) {