sessix-server 0.5.0 → 0.5.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (3) hide show
  1. package/dist/index.js +185 -35
  2. package/dist/server.js +185 -35
  3. package/package.json +1 -1
package/dist/index.js CHANGED
@@ -308,6 +308,8 @@ var import_node_os9 = require("os");
308
308
  var import_node_path9 = require("path");
309
309
  var import_node_child_process12 = require("child_process");
310
310
  var import_node_util3 = require("util");
311
+ var import_node_v8 = require("v8");
312
+ var import_node_vm = require("vm");
311
313
 
312
314
  // src/providers/ProcessProvider.ts
313
315
  var import_child_process = require("child_process");
@@ -450,26 +452,33 @@ function getSessionFilePath(projectPath, sessionId) {
450
452
  }
451
453
  async function getSessionModel(projectPath, sessionId) {
452
454
  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;
455
+ let fileHandle;
456
+ try {
457
+ fileHandle = await (0, import_promises.open)(filePath, "r");
458
+ const rl = (0, import_readline.createInterface)({
459
+ input: fileHandle.createReadStream({ encoding: "utf-8" }),
460
+ crlfDelay: Infinity
461
+ });
462
+ let lastModel;
463
+ for await (const line of rl) {
464
+ if (!line.trim()) continue;
465
+ try {
466
+ const obj = JSON.parse(line);
467
+ if (obj.type !== "assistant" || !obj.message) continue;
468
+ const model = obj.message.model;
469
+ if (typeof model === "string" && model && model !== "unknown") {
470
+ lastModel = model;
471
+ }
472
+ } catch {
468
473
  }
469
- } catch {
470
474
  }
475
+ return lastModel;
476
+ } catch (err) {
477
+ if (err.code === "ENOENT") return void 0;
478
+ throw err;
479
+ } finally {
480
+ await fileHandle?.close();
471
481
  }
472
- return void 0;
473
482
  }
474
483
  async function getProjects() {
475
484
  try {
@@ -598,17 +607,23 @@ async function getHistoricalSessions(projectPath) {
598
607
  }
599
608
  }
600
609
  async function getSessionHistory(projectPath, sessionId) {
610
+ let fileHandle;
601
611
  try {
602
612
  const encodedPath = encodeDirName(projectPath);
603
613
  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;
614
+ try {
615
+ fileHandle = await (0, import_promises.open)(filePath, "r");
616
+ } catch (err) {
617
+ if (err.code === "ENOENT") return { ok: true, value: [] };
606
618
  throw err;
619
+ }
620
+ const rl = (0, import_readline.createInterface)({
621
+ input: fileHandle.createReadStream({ encoding: "utf-8" }),
622
+ crlfDelay: Infinity
607
623
  });
608
- if (raw === null) return { ok: true, value: [] };
609
- const lines = raw.split("\n").filter((l) => l.trim());
610
624
  const events = [];
611
- for (const line of lines) {
625
+ for await (const line of rl) {
626
+ if (!line.trim()) continue;
612
627
  try {
613
628
  const obj = JSON.parse(line);
614
629
  const type = obj.type;
@@ -681,6 +696,8 @@ async function getSessionHistory(projectPath, sessionId) {
681
696
  ok: false,
682
697
  error: err instanceof Error ? err : new Error(String(err))
683
698
  };
699
+ } finally {
700
+ await fileHandle?.close();
684
701
  }
685
702
  }
686
703
  async function extractLastTimestamp(filePath) {
@@ -1025,6 +1042,27 @@ var ProcessProvider = class {
1025
1042
  }
1026
1043
  return swept;
1027
1044
  }
1045
+ /**
1046
+ * 枚举可淘汰的老会话
1047
+ *
1048
+ * 进程已退出(已被空闲 GC kill)且空闲超过 maxIdleMs 的会话——其 entry 与各 Map
1049
+ * 仍长期占内存。调用方对返回 id 执行 killSession 彻底清除;淘汰后手机端发消息
1050
+ * 会自动走 resume 路径(--resume + JSONL),不影响继续对话。
1051
+ *
1052
+ * @returns 可淘汰的 sessionId 列表(仅枚举,不删除)
1053
+ */
1054
+ listEvictableSessions(maxIdleMs) {
1055
+ if (maxIdleMs <= 0) return [];
1056
+ const now = Date.now();
1057
+ const evictable = [];
1058
+ for (const [sessionId, entry] of this.activeSessions) {
1059
+ if (entry.process.exitCode === null && entry.process.signalCode === null) continue;
1060
+ if (entry.session.status === "running" || entry.session.status === "waiting_question" || entry.session.status === "waiting_approval") continue;
1061
+ if (now - entry.session.lastActiveAt < maxIdleMs) continue;
1062
+ evictable.push(sessionId);
1063
+ }
1064
+ return evictable;
1065
+ }
1028
1066
  // ============================================
1029
1067
  // 私有方法
1030
1068
  // ============================================
@@ -2041,10 +2079,18 @@ var SessionManager = class {
2041
2079
  sessionAgentType = /* @__PURE__ */ new Map();
2042
2080
  /** 事件回调列表(事件会被转发到 WsBridge) */
2043
2081
  eventCallbacks = [];
2082
+ /** 会话被移除(kill / 淘汰)时的回调列表(用于释放外部模块的会话级状态,如 NotificationService) */
2083
+ sessionRemovedCallbacks = [];
2044
2084
  /** 每个会话的事件流取消订阅函数 */
2045
2085
  unsubscribeMap = /* @__PURE__ */ new Map();
2046
2086
  /** 每个会话的事件缓冲区(用于新订阅者重放)*/
2047
2087
  sessionEventBuffers = /* @__PURE__ */ new Map();
2088
+ /**
2089
+ * 每个会话最近一次 AskUserQuestion tool_use 的真实 id(从 claude_event 流捕获)。
2090
+ * PreToolUse hook payload 不含 tool_use_id,但内联卡片需要它来匹配状态,
2091
+ * 故在转发流事件时记录,askQuestion 时兜底回填。
2092
+ */
2093
+ lastAskQuestionToolUseId = /* @__PURE__ */ new Map();
2048
2094
  /** AskUserQuestion 问题映射:requestId → resolve 回调 + 原始问题内容 */
2049
2095
  pendingQuestions = /* @__PURE__ */ new Map();
2050
2096
  /**
@@ -2153,6 +2199,7 @@ var SessionManager = class {
2153
2199
  this.bufferTruncated.delete(sessionId);
2154
2200
  this.sessionProjectPaths.delete(sessionId);
2155
2201
  this.sessionStats.delete(sessionId);
2202
+ this.lastAskQuestionToolUseId.delete(sessionId);
2156
2203
  const pending = this.pendingAssistantEvents.get(sessionId);
2157
2204
  if (pending) {
2158
2205
  clearTimeout(pending.timer);
@@ -2161,6 +2208,13 @@ var SessionManager = class {
2161
2208
  const provider = this.getProviderForSession(sessionId);
2162
2209
  await provider.killSession(sessionId);
2163
2210
  this.sessionAgentType.delete(sessionId);
2211
+ for (const cb of this.sessionRemovedCallbacks) {
2212
+ try {
2213
+ cb(sessionId);
2214
+ } catch (err) {
2215
+ console.error("[SessionManager] sessionRemoved callback failed:", err);
2216
+ }
2217
+ }
2164
2218
  console.log(`[SessionManager] Session killed: ${sessionId}`);
2165
2219
  }
2166
2220
  /**
@@ -2351,6 +2405,21 @@ var SessionManager = class {
2351
2405
  }
2352
2406
  };
2353
2407
  }
2408
+ /**
2409
+ * 注册"会话被移除"回调(会话 kill 或淘汰时触发,传入 sessionId)。
2410
+ * 用于让外部模块释放会话级状态,如 NotificationService.releaseSession。
2411
+ *
2412
+ * @returns 取消注册的函数
2413
+ */
2414
+ onSessionRemoved(callback) {
2415
+ this.sessionRemovedCallbacks.push(callback);
2416
+ return () => {
2417
+ const index = this.sessionRemovedCallbacks.indexOf(callback);
2418
+ if (index !== -1) {
2419
+ this.sessionRemovedCallbacks.splice(index, 1);
2420
+ }
2421
+ };
2422
+ }
2354
2423
  /**
2355
2424
  * 清理所有资源
2356
2425
  */
@@ -2374,6 +2443,7 @@ var SessionManager = class {
2374
2443
  this.pendingQuestions.clear();
2375
2444
  this.lastBroadcastStatus.clear();
2376
2445
  this.eventCallbacks.length = 0;
2446
+ this.sessionRemovedCallbacks.length = 0;
2377
2447
  console.log("[SessionManager] Destroyed");
2378
2448
  }
2379
2449
  // ============================================
@@ -2422,6 +2492,13 @@ var SessionManager = class {
2422
2492
  console.log(`[SessionManager] \u{1F9E0} thinking block detected in ${sessionId}: msgId=${event.message.id}, blocks=${thinkingBlocks.length}, len=${thinkingBlocks.map((b) => (b.thinking || "").length).join(",")}`);
2423
2493
  }
2424
2494
  }
2495
+ if (event.type === "assistant" && Array.isArray(event.message?.content)) {
2496
+ for (const block of event.message.content) {
2497
+ if (block.type === "tool_use" && (block.name === "AskUserQuestion" || block.name === "AskFollowupQuestion") && typeof block.id === "string") {
2498
+ this.lastAskQuestionToolUseId.set(sessionId, block.id);
2499
+ }
2500
+ }
2501
+ }
2425
2502
  switch (event.type) {
2426
2503
  case "assistant":
2427
2504
  this.bufferAssistantEvent(sessionId, event);
@@ -2542,10 +2619,11 @@ var SessionManager = class {
2542
2619
  * 返回的 Promise 在 handleQuestionResponse 时 resolve。
2543
2620
  */
2544
2621
  askQuestion(sessionId, toolUseId, questions, requestId) {
2622
+ const resolvedToolUseId = toolUseId || this.lastAskQuestionToolUseId.get(sessionId) || "";
2545
2623
  const request = {
2546
2624
  id: requestId,
2547
2625
  sessionId,
2548
- toolUseId,
2626
+ toolUseId: resolvedToolUseId,
2549
2627
  question: questions[0]?.question ?? "",
2550
2628
  options: questions[0]?.options?.map((o) => o.label),
2551
2629
  questions,
@@ -2557,7 +2635,7 @@ var SessionManager = class {
2557
2635
  return new Promise((resolve) => {
2558
2636
  this.pendingQuestions.set(requestId, {
2559
2637
  sessionId,
2560
- toolUseId,
2638
+ toolUseId: resolvedToolUseId,
2561
2639
  question: request.question,
2562
2640
  options: request.options,
2563
2641
  questions,
@@ -4079,7 +4157,8 @@ var HookInstaller = class {
4079
4157
  const isLatestVersion = approvalScriptContent.includes("permissionDecision") && approvalScriptContent.includes("Sessix Approval Hook v2");
4080
4158
  const settings = await this.readClaudeSettings();
4081
4159
  const configExists = this.hasHookConfig(settings);
4082
- return isLatestVersion && permissionScriptExists && compactScriptExists && postCompactScriptExists && permissionDeniedScriptExists && configExists;
4160
+ const hasLegacyHook = this.hasHookEntry(settings?.hooks?.PreToolUse, LEGACY_HOOK_COMMANDS[0]) || this.hasHookEntry(settings?.hooks?.PermissionRequest, LEGACY_HOOK_COMMANDS[1]);
4161
+ return isLatestVersion && permissionScriptExists && compactScriptExists && postCompactScriptExists && permissionDeniedScriptExists && configExists && !hasLegacyHook;
4083
4162
  }
4084
4163
  // ============================================
4085
4164
  // 内部方法
@@ -4091,8 +4170,14 @@ var HookInstaller = class {
4091
4170
  let settings = await this.readClaudeSettings();
4092
4171
  let changed = false;
4093
4172
  for (const cmd of LEGACY_HOOK_COMMANDS) {
4094
- this.removeHookCommand(settings, "PreToolUse", cmd);
4095
- this.removeHookCommand(settings, "PermissionRequest", cmd);
4173
+ if (this.hasHookEntry(settings?.hooks?.PreToolUse, cmd)) {
4174
+ this.removeHookCommand(settings, "PreToolUse", cmd);
4175
+ changed = true;
4176
+ }
4177
+ if (this.hasHookEntry(settings?.hooks?.PermissionRequest, cmd)) {
4178
+ this.removeHookCommand(settings, "PermissionRequest", cmd);
4179
+ changed = true;
4180
+ }
4096
4181
  }
4097
4182
  if (!settings.hooks) {
4098
4183
  settings.hooks = {};
@@ -4489,12 +4574,40 @@ var NotificationService = class _NotificationService {
4489
4574
  this.latestAssistantText.clear();
4490
4575
  for (const timer of this.activityPushTimers.values()) clearTimeout(timer);
4491
4576
  this.activityPushTimers.clear();
4577
+ for (const timer of this.idleEndTimers.values()) clearTimeout(timer);
4578
+ this.idleEndTimers.clear();
4579
+ for (const timer of this.laHeartbeatTimers.values()) clearInterval(timer);
4580
+ this.laHeartbeatTimers.clear();
4492
4581
  this.recentActivityState.clear();
4493
4582
  this.lastActivityPushAt.clear();
4494
4583
  this.pendingPriority.clear();
4495
4584
  this.activityCounters.clear();
4496
4585
  this.lastPushedFingerprint.clear();
4497
4586
  }
4587
+ /**
4588
+ * 释放单个会话的全部内存状态(会话被 kill 或淘汰时调用)。
4589
+ * 由 SessionManager.onSessionRemoved 钩子触发,覆盖用户主动 kill 和自动淘汰两条路径。
4590
+ * 幂等:重复调用或对未知会话调用都安全。
4591
+ */
4592
+ releaseSession(sessionId) {
4593
+ this.clearActivityPushTimer(sessionId);
4594
+ this.cancelIdleEndTimer(sessionId);
4595
+ this.stopLaHeartbeat(sessionId);
4596
+ this.clearSessionActivityState(sessionId);
4597
+ this.yoloModeState.delete(sessionId);
4598
+ this.lastActivityPushAt.delete(sessionId);
4599
+ this.lastPushedFingerprint.delete(sessionId);
4600
+ this.pendingPriority.delete(sessionId);
4601
+ }
4602
+ /**
4603
+ * 清空单会话可重建的重状态(recentActivity / 计数器 / 最新文本)。
4604
+ * 会话走到 idle 时调用即可释放内存——resume 后这些状态会随新事件自动重建。
4605
+ */
4606
+ clearSessionActivityState(sessionId) {
4607
+ this.recentActivityState.delete(sessionId);
4608
+ this.activityCounters.delete(sessionId);
4609
+ this.latestAssistantText.delete(sessionId);
4610
+ }
4498
4611
  // ============================================
4499
4612
  // 内部方法
4500
4613
  // ============================================
@@ -4532,6 +4645,7 @@ var NotificationService = class _NotificationService {
4532
4645
  badge: this.getGlobalPendingCount(),
4533
4646
  data: { type: "task_complete", sessionId: event.sessionId }
4534
4647
  });
4648
+ this.clearSessionActivityState(event.sessionId);
4535
4649
  }
4536
4650
  } else if (event.status === "running" || event.status === "waiting_approval" || event.status === "waiting_question") {
4537
4651
  this.cancelIdleEndTimer(event.sessionId);
@@ -4820,9 +4934,8 @@ var NotificationService = class _NotificationService {
4820
4934
  });
4821
4935
  }
4822
4936
  this.stopLaHeartbeat(sessionId);
4823
- this.recentActivityState.delete(sessionId);
4937
+ this.clearSessionActivityState(sessionId);
4824
4938
  this.lastActivityPushAt.delete(sessionId);
4825
- this.activityCounters.delete(sessionId);
4826
4939
  this.lastPushedFingerprint.delete(sessionId);
4827
4940
  console.log(`[NotificationService] \u{1F3C1} LA end (${reason}) session=${sessionId.slice(0, 8)}\u2026`);
4828
4941
  }
@@ -5923,7 +6036,6 @@ var SKIP_DIRS = /* @__PURE__ */ new Set([
5923
6036
  "DerivedData",
5924
6037
  "Pods",
5925
6038
  ".build",
5926
- "build",
5927
6039
  "dist",
5928
6040
  "__pycache__",
5929
6041
  ".next",
@@ -7091,10 +7203,11 @@ function isValidTask(value) {
7091
7203
  // src/utils/cliCapabilities.ts
7092
7204
  var import_node_child_process11 = require("child_process");
7093
7205
  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" }
7206
+ { value: "opus", label: "Opus 4.8", sublabel: "Most capable for ambitious work", maxEffort: "max", defaultEffort: "xhigh" },
7207
+ { value: "claude-opus-4-7", label: "Opus 4.7", sublabel: "Previous generation flagship", maxEffort: "max", defaultEffort: "xhigh" },
7208
+ { value: "claude-opus-4-6", label: "Opus 4.6", sublabel: "Earlier generation flagship", maxEffort: "max", defaultEffort: "xhigh" },
7209
+ { value: "sonnet", label: "Sonnet 4.6", sublabel: "Most efficient for everyday tasks", maxEffort: "high", defaultEffort: "high" },
7210
+ { value: "haiku", label: "Haiku 4.5", sublabel: "Fastest for quick answers", maxEffort: "medium", defaultEffort: "medium" }
7098
7211
  ];
7099
7212
  var DEFAULT_CAPABILITIES = {
7100
7213
  effortLevels: ["low", "medium", "high", "xhigh", "max"],
@@ -7262,6 +7375,7 @@ async function start(opts = {}) {
7262
7375
  const notificationService = new NotificationService(sessionManager, expoChannel);
7263
7376
  notificationService.addChannel("expo", expoChannel, opts.enableExpoPush !== false);
7264
7377
  notificationService.addChannel("mac", new DesktopNotificationChannel(), opts.enableMacNotification !== false);
7378
+ sessionManager.onSessionRemoved((sessionId) => notificationService.releaseSession(sessionId));
7265
7379
  const activityPushOpts = opts.activityPush ?? await loadApnsConfigFromFile();
7266
7380
  if (activityPushOpts) {
7267
7381
  try {
@@ -7950,8 +8064,30 @@ async function start(opts = {}) {
7950
8064
  const idleTimeoutMs = Number(process.env.SESSIX_IDLE_TIMEOUT_MS ?? 30 * 60 * 1e3);
7951
8065
  const idleSweepIntervalMs = Number(process.env.SESSIX_IDLE_SWEEP_INTERVAL_MS ?? 5 * 60 * 1e3);
7952
8066
  const maxActiveProcesses = Number(process.env.SESSIX_MAX_ACTIVE_PROCESSES ?? 15);
8067
+ const sessionEvictMs = Number(process.env.SESSIX_SESSION_EVICT_MS ?? 2 * 60 * 60 * 1e3);
8068
+ let gcFn;
8069
+ const maybeGc = () => {
8070
+ if (gcFn === void 0) {
8071
+ gcFn = globalThis.gc ?? null;
8072
+ if (!gcFn) {
8073
+ try {
8074
+ (0, import_node_v8.setFlagsFromString)("--expose-gc");
8075
+ const fn = (0, import_node_vm.runInNewContext)("gc");
8076
+ gcFn = typeof fn === "function" ? fn : null;
8077
+ } catch {
8078
+ gcFn = null;
8079
+ }
8080
+ }
8081
+ }
8082
+ if (gcFn) {
8083
+ try {
8084
+ gcFn();
8085
+ } catch {
8086
+ }
8087
+ }
8088
+ };
7953
8089
  let idleSweepTimer = null;
7954
- if (idleSweepIntervalMs > 0 && (idleTimeoutMs > 0 || maxActiveProcesses > 0)) {
8090
+ if (idleSweepIntervalMs > 0 && (idleTimeoutMs > 0 || maxActiveProcesses > 0 || sessionEvictMs > 0)) {
7955
8091
  idleSweepTimer = setInterval(async () => {
7956
8092
  try {
7957
8093
  let totalSwept = 0;
@@ -7970,7 +8106,18 @@ async function start(opts = {}) {
7970
8106
  swept.forEach(broadcastShrink);
7971
8107
  totalSwept += swept.length;
7972
8108
  }
8109
+ if (sessionEvictMs > 0 && typeof provider.listEvictableSessions === "function") {
8110
+ const evictable = provider.listEvictableSessions(sessionEvictMs);
8111
+ for (const id of evictable) {
8112
+ await sessionManager.killSession(id);
8113
+ }
8114
+ if (evictable.length > 0) {
8115
+ console.log(`[Server] Idle GC: evicted ${evictable.length} stale session(s)`);
8116
+ totalSwept += evictable.length;
8117
+ }
8118
+ }
7973
8119
  }
8120
+ const hasRunning = sessionManager.getActiveSessions().some((s) => s.status === "running");
7974
8121
  if (totalSwept > 0) {
7975
8122
  console.log(`[Server] Idle GC: swept ${totalSwept} idle session(s)`);
7976
8123
  wsBridge.broadcast({
@@ -7978,6 +8125,9 @@ async function start(opts = {}) {
7978
8125
  sessions: sessionManager.getActiveSessions()
7979
8126
  });
7980
8127
  }
8128
+ if (totalSwept > 0 || !hasRunning) {
8129
+ maybeGc();
8130
+ }
7981
8131
  } catch (err) {
7982
8132
  console.error("[Server] Idle GC failed:", err);
7983
8133
  }
package/dist/server.js CHANGED
@@ -313,6 +313,8 @@ var import_node_os9 = require("os");
313
313
  var import_node_path9 = require("path");
314
314
  var import_node_child_process12 = require("child_process");
315
315
  var import_node_util3 = require("util");
316
+ var import_node_v8 = require("v8");
317
+ var import_node_vm = require("vm");
316
318
 
317
319
  // src/providers/ProcessProvider.ts
318
320
  var import_child_process = require("child_process");
@@ -455,26 +457,33 @@ function getSessionFilePath(projectPath, sessionId) {
455
457
  }
456
458
  async function getSessionModel(projectPath, sessionId) {
457
459
  const filePath = getSessionFilePath(projectPath, sessionId);
458
- const raw = await (0, import_promises.readFile)(filePath, "utf-8").catch((err) => {
459
- if (err.code === "ENOENT") return null;
460
- throw err;
461
- });
462
- if (raw === null) return void 0;
463
- const lines = raw.split("\n");
464
- for (let i = lines.length - 1; i >= 0; i--) {
465
- const line = lines[i].trim();
466
- if (!line) continue;
467
- try {
468
- const obj = JSON.parse(line);
469
- if (obj.type !== "assistant" || !obj.message) continue;
470
- const model = obj.message.model;
471
- if (typeof model === "string" && model && model !== "unknown") {
472
- return model;
460
+ let fileHandle;
461
+ try {
462
+ fileHandle = await (0, import_promises.open)(filePath, "r");
463
+ const rl = (0, import_readline.createInterface)({
464
+ input: fileHandle.createReadStream({ encoding: "utf-8" }),
465
+ crlfDelay: Infinity
466
+ });
467
+ let lastModel;
468
+ for await (const line of rl) {
469
+ if (!line.trim()) continue;
470
+ try {
471
+ const obj = JSON.parse(line);
472
+ if (obj.type !== "assistant" || !obj.message) continue;
473
+ const model = obj.message.model;
474
+ if (typeof model === "string" && model && model !== "unknown") {
475
+ lastModel = model;
476
+ }
477
+ } catch {
473
478
  }
474
- } catch {
475
479
  }
480
+ return lastModel;
481
+ } catch (err) {
482
+ if (err.code === "ENOENT") return void 0;
483
+ throw err;
484
+ } finally {
485
+ await fileHandle?.close();
476
486
  }
477
- return void 0;
478
487
  }
479
488
  async function getProjects() {
480
489
  try {
@@ -603,17 +612,23 @@ async function getHistoricalSessions(projectPath) {
603
612
  }
604
613
  }
605
614
  async function getSessionHistory(projectPath, sessionId) {
615
+ let fileHandle;
606
616
  try {
607
617
  const encodedPath = encodeDirName(projectPath);
608
618
  const filePath = (0, import_path.join)(CLAUDE_PROJECTS_DIR, encodedPath, `${sessionId}.jsonl`);
609
- const raw = await (0, import_promises.readFile)(filePath, "utf-8").catch((err) => {
610
- if (err.code === "ENOENT") return null;
619
+ try {
620
+ fileHandle = await (0, import_promises.open)(filePath, "r");
621
+ } catch (err) {
622
+ if (err.code === "ENOENT") return { ok: true, value: [] };
611
623
  throw err;
624
+ }
625
+ const rl = (0, import_readline.createInterface)({
626
+ input: fileHandle.createReadStream({ encoding: "utf-8" }),
627
+ crlfDelay: Infinity
612
628
  });
613
- if (raw === null) return { ok: true, value: [] };
614
- const lines = raw.split("\n").filter((l) => l.trim());
615
629
  const events = [];
616
- for (const line of lines) {
630
+ for await (const line of rl) {
631
+ if (!line.trim()) continue;
617
632
  try {
618
633
  const obj = JSON.parse(line);
619
634
  const type = obj.type;
@@ -686,6 +701,8 @@ async function getSessionHistory(projectPath, sessionId) {
686
701
  ok: false,
687
702
  error: err instanceof Error ? err : new Error(String(err))
688
703
  };
704
+ } finally {
705
+ await fileHandle?.close();
689
706
  }
690
707
  }
691
708
  async function extractLastTimestamp(filePath) {
@@ -1030,6 +1047,27 @@ var ProcessProvider = class {
1030
1047
  }
1031
1048
  return swept;
1032
1049
  }
1050
+ /**
1051
+ * 枚举可淘汰的老会话
1052
+ *
1053
+ * 进程已退出(已被空闲 GC kill)且空闲超过 maxIdleMs 的会话——其 entry 与各 Map
1054
+ * 仍长期占内存。调用方对返回 id 执行 killSession 彻底清除;淘汰后手机端发消息
1055
+ * 会自动走 resume 路径(--resume + JSONL),不影响继续对话。
1056
+ *
1057
+ * @returns 可淘汰的 sessionId 列表(仅枚举,不删除)
1058
+ */
1059
+ listEvictableSessions(maxIdleMs) {
1060
+ if (maxIdleMs <= 0) return [];
1061
+ const now = Date.now();
1062
+ const evictable = [];
1063
+ for (const [sessionId, entry] of this.activeSessions) {
1064
+ if (entry.process.exitCode === null && entry.process.signalCode === null) continue;
1065
+ if (entry.session.status === "running" || entry.session.status === "waiting_question" || entry.session.status === "waiting_approval") continue;
1066
+ if (now - entry.session.lastActiveAt < maxIdleMs) continue;
1067
+ evictable.push(sessionId);
1068
+ }
1069
+ return evictable;
1070
+ }
1033
1071
  // ============================================
1034
1072
  // 私有方法
1035
1073
  // ============================================
@@ -2046,10 +2084,18 @@ var SessionManager = class {
2046
2084
  sessionAgentType = /* @__PURE__ */ new Map();
2047
2085
  /** 事件回调列表(事件会被转发到 WsBridge) */
2048
2086
  eventCallbacks = [];
2087
+ /** 会话被移除(kill / 淘汰)时的回调列表(用于释放外部模块的会话级状态,如 NotificationService) */
2088
+ sessionRemovedCallbacks = [];
2049
2089
  /** 每个会话的事件流取消订阅函数 */
2050
2090
  unsubscribeMap = /* @__PURE__ */ new Map();
2051
2091
  /** 每个会话的事件缓冲区(用于新订阅者重放)*/
2052
2092
  sessionEventBuffers = /* @__PURE__ */ new Map();
2093
+ /**
2094
+ * 每个会话最近一次 AskUserQuestion tool_use 的真实 id(从 claude_event 流捕获)。
2095
+ * PreToolUse hook payload 不含 tool_use_id,但内联卡片需要它来匹配状态,
2096
+ * 故在转发流事件时记录,askQuestion 时兜底回填。
2097
+ */
2098
+ lastAskQuestionToolUseId = /* @__PURE__ */ new Map();
2053
2099
  /** AskUserQuestion 问题映射:requestId → resolve 回调 + 原始问题内容 */
2054
2100
  pendingQuestions = /* @__PURE__ */ new Map();
2055
2101
  /**
@@ -2158,6 +2204,7 @@ var SessionManager = class {
2158
2204
  this.bufferTruncated.delete(sessionId);
2159
2205
  this.sessionProjectPaths.delete(sessionId);
2160
2206
  this.sessionStats.delete(sessionId);
2207
+ this.lastAskQuestionToolUseId.delete(sessionId);
2161
2208
  const pending = this.pendingAssistantEvents.get(sessionId);
2162
2209
  if (pending) {
2163
2210
  clearTimeout(pending.timer);
@@ -2166,6 +2213,13 @@ var SessionManager = class {
2166
2213
  const provider = this.getProviderForSession(sessionId);
2167
2214
  await provider.killSession(sessionId);
2168
2215
  this.sessionAgentType.delete(sessionId);
2216
+ for (const cb of this.sessionRemovedCallbacks) {
2217
+ try {
2218
+ cb(sessionId);
2219
+ } catch (err) {
2220
+ console.error("[SessionManager] sessionRemoved callback failed:", err);
2221
+ }
2222
+ }
2169
2223
  console.log(`[SessionManager] Session killed: ${sessionId}`);
2170
2224
  }
2171
2225
  /**
@@ -2356,6 +2410,21 @@ var SessionManager = class {
2356
2410
  }
2357
2411
  };
2358
2412
  }
2413
+ /**
2414
+ * 注册"会话被移除"回调(会话 kill 或淘汰时触发,传入 sessionId)。
2415
+ * 用于让外部模块释放会话级状态,如 NotificationService.releaseSession。
2416
+ *
2417
+ * @returns 取消注册的函数
2418
+ */
2419
+ onSessionRemoved(callback) {
2420
+ this.sessionRemovedCallbacks.push(callback);
2421
+ return () => {
2422
+ const index = this.sessionRemovedCallbacks.indexOf(callback);
2423
+ if (index !== -1) {
2424
+ this.sessionRemovedCallbacks.splice(index, 1);
2425
+ }
2426
+ };
2427
+ }
2359
2428
  /**
2360
2429
  * 清理所有资源
2361
2430
  */
@@ -2379,6 +2448,7 @@ var SessionManager = class {
2379
2448
  this.pendingQuestions.clear();
2380
2449
  this.lastBroadcastStatus.clear();
2381
2450
  this.eventCallbacks.length = 0;
2451
+ this.sessionRemovedCallbacks.length = 0;
2382
2452
  console.log("[SessionManager] Destroyed");
2383
2453
  }
2384
2454
  // ============================================
@@ -2427,6 +2497,13 @@ var SessionManager = class {
2427
2497
  console.log(`[SessionManager] \u{1F9E0} thinking block detected in ${sessionId}: msgId=${event.message.id}, blocks=${thinkingBlocks.length}, len=${thinkingBlocks.map((b) => (b.thinking || "").length).join(",")}`);
2428
2498
  }
2429
2499
  }
2500
+ if (event.type === "assistant" && Array.isArray(event.message?.content)) {
2501
+ for (const block of event.message.content) {
2502
+ if (block.type === "tool_use" && (block.name === "AskUserQuestion" || block.name === "AskFollowupQuestion") && typeof block.id === "string") {
2503
+ this.lastAskQuestionToolUseId.set(sessionId, block.id);
2504
+ }
2505
+ }
2506
+ }
2430
2507
  switch (event.type) {
2431
2508
  case "assistant":
2432
2509
  this.bufferAssistantEvent(sessionId, event);
@@ -2547,10 +2624,11 @@ var SessionManager = class {
2547
2624
  * 返回的 Promise 在 handleQuestionResponse 时 resolve。
2548
2625
  */
2549
2626
  askQuestion(sessionId, toolUseId, questions, requestId) {
2627
+ const resolvedToolUseId = toolUseId || this.lastAskQuestionToolUseId.get(sessionId) || "";
2550
2628
  const request = {
2551
2629
  id: requestId,
2552
2630
  sessionId,
2553
- toolUseId,
2631
+ toolUseId: resolvedToolUseId,
2554
2632
  question: questions[0]?.question ?? "",
2555
2633
  options: questions[0]?.options?.map((o) => o.label),
2556
2634
  questions,
@@ -2562,7 +2640,7 @@ var SessionManager = class {
2562
2640
  return new Promise((resolve) => {
2563
2641
  this.pendingQuestions.set(requestId, {
2564
2642
  sessionId,
2565
- toolUseId,
2643
+ toolUseId: resolvedToolUseId,
2566
2644
  question: request.question,
2567
2645
  options: request.options,
2568
2646
  questions,
@@ -4084,7 +4162,8 @@ var HookInstaller = class {
4084
4162
  const isLatestVersion = approvalScriptContent.includes("permissionDecision") && approvalScriptContent.includes("Sessix Approval Hook v2");
4085
4163
  const settings = await this.readClaudeSettings();
4086
4164
  const configExists = this.hasHookConfig(settings);
4087
- return isLatestVersion && permissionScriptExists && compactScriptExists && postCompactScriptExists && permissionDeniedScriptExists && configExists;
4165
+ const hasLegacyHook = this.hasHookEntry(settings?.hooks?.PreToolUse, LEGACY_HOOK_COMMANDS[0]) || this.hasHookEntry(settings?.hooks?.PermissionRequest, LEGACY_HOOK_COMMANDS[1]);
4166
+ return isLatestVersion && permissionScriptExists && compactScriptExists && postCompactScriptExists && permissionDeniedScriptExists && configExists && !hasLegacyHook;
4088
4167
  }
4089
4168
  // ============================================
4090
4169
  // 内部方法
@@ -4096,8 +4175,14 @@ var HookInstaller = class {
4096
4175
  let settings = await this.readClaudeSettings();
4097
4176
  let changed = false;
4098
4177
  for (const cmd of LEGACY_HOOK_COMMANDS) {
4099
- this.removeHookCommand(settings, "PreToolUse", cmd);
4100
- this.removeHookCommand(settings, "PermissionRequest", cmd);
4178
+ if (this.hasHookEntry(settings?.hooks?.PreToolUse, cmd)) {
4179
+ this.removeHookCommand(settings, "PreToolUse", cmd);
4180
+ changed = true;
4181
+ }
4182
+ if (this.hasHookEntry(settings?.hooks?.PermissionRequest, cmd)) {
4183
+ this.removeHookCommand(settings, "PermissionRequest", cmd);
4184
+ changed = true;
4185
+ }
4101
4186
  }
4102
4187
  if (!settings.hooks) {
4103
4188
  settings.hooks = {};
@@ -4494,12 +4579,40 @@ var NotificationService = class _NotificationService {
4494
4579
  this.latestAssistantText.clear();
4495
4580
  for (const timer of this.activityPushTimers.values()) clearTimeout(timer);
4496
4581
  this.activityPushTimers.clear();
4582
+ for (const timer of this.idleEndTimers.values()) clearTimeout(timer);
4583
+ this.idleEndTimers.clear();
4584
+ for (const timer of this.laHeartbeatTimers.values()) clearInterval(timer);
4585
+ this.laHeartbeatTimers.clear();
4497
4586
  this.recentActivityState.clear();
4498
4587
  this.lastActivityPushAt.clear();
4499
4588
  this.pendingPriority.clear();
4500
4589
  this.activityCounters.clear();
4501
4590
  this.lastPushedFingerprint.clear();
4502
4591
  }
4592
+ /**
4593
+ * 释放单个会话的全部内存状态(会话被 kill 或淘汰时调用)。
4594
+ * 由 SessionManager.onSessionRemoved 钩子触发,覆盖用户主动 kill 和自动淘汰两条路径。
4595
+ * 幂等:重复调用或对未知会话调用都安全。
4596
+ */
4597
+ releaseSession(sessionId) {
4598
+ this.clearActivityPushTimer(sessionId);
4599
+ this.cancelIdleEndTimer(sessionId);
4600
+ this.stopLaHeartbeat(sessionId);
4601
+ this.clearSessionActivityState(sessionId);
4602
+ this.yoloModeState.delete(sessionId);
4603
+ this.lastActivityPushAt.delete(sessionId);
4604
+ this.lastPushedFingerprint.delete(sessionId);
4605
+ this.pendingPriority.delete(sessionId);
4606
+ }
4607
+ /**
4608
+ * 清空单会话可重建的重状态(recentActivity / 计数器 / 最新文本)。
4609
+ * 会话走到 idle 时调用即可释放内存——resume 后这些状态会随新事件自动重建。
4610
+ */
4611
+ clearSessionActivityState(sessionId) {
4612
+ this.recentActivityState.delete(sessionId);
4613
+ this.activityCounters.delete(sessionId);
4614
+ this.latestAssistantText.delete(sessionId);
4615
+ }
4503
4616
  // ============================================
4504
4617
  // 内部方法
4505
4618
  // ============================================
@@ -4537,6 +4650,7 @@ var NotificationService = class _NotificationService {
4537
4650
  badge: this.getGlobalPendingCount(),
4538
4651
  data: { type: "task_complete", sessionId: event.sessionId }
4539
4652
  });
4653
+ this.clearSessionActivityState(event.sessionId);
4540
4654
  }
4541
4655
  } else if (event.status === "running" || event.status === "waiting_approval" || event.status === "waiting_question") {
4542
4656
  this.cancelIdleEndTimer(event.sessionId);
@@ -4825,9 +4939,8 @@ var NotificationService = class _NotificationService {
4825
4939
  });
4826
4940
  }
4827
4941
  this.stopLaHeartbeat(sessionId);
4828
- this.recentActivityState.delete(sessionId);
4942
+ this.clearSessionActivityState(sessionId);
4829
4943
  this.lastActivityPushAt.delete(sessionId);
4830
- this.activityCounters.delete(sessionId);
4831
4944
  this.lastPushedFingerprint.delete(sessionId);
4832
4945
  console.log(`[NotificationService] \u{1F3C1} LA end (${reason}) session=${sessionId.slice(0, 8)}\u2026`);
4833
4946
  }
@@ -5928,7 +6041,6 @@ var SKIP_DIRS = /* @__PURE__ */ new Set([
5928
6041
  "DerivedData",
5929
6042
  "Pods",
5930
6043
  ".build",
5931
- "build",
5932
6044
  "dist",
5933
6045
  "__pycache__",
5934
6046
  ".next",
@@ -7096,10 +7208,11 @@ function isValidTask(value) {
7096
7208
  // src/utils/cliCapabilities.ts
7097
7209
  var import_node_child_process11 = require("child_process");
7098
7210
  var DEFAULT_MODELS = [
7099
- { value: "opus", label: "Opus 4.7", sublabel: "Most capable for ambitious work" },
7100
- { value: "claude-opus-4-6", label: "Opus 4.6", sublabel: "Previous generation flagship" },
7101
- { value: "sonnet", label: "Sonnet 4.6", sublabel: "Most efficient for everyday tasks" },
7102
- { value: "haiku", label: "Haiku 4.5", sublabel: "Fastest for quick answers" }
7211
+ { value: "opus", label: "Opus 4.8", sublabel: "Most capable for ambitious work", maxEffort: "max", defaultEffort: "xhigh" },
7212
+ { value: "claude-opus-4-7", label: "Opus 4.7", sublabel: "Previous generation flagship", maxEffort: "max", defaultEffort: "xhigh" },
7213
+ { value: "claude-opus-4-6", label: "Opus 4.6", sublabel: "Earlier generation flagship", maxEffort: "max", defaultEffort: "xhigh" },
7214
+ { value: "sonnet", label: "Sonnet 4.6", sublabel: "Most efficient for everyday tasks", maxEffort: "high", defaultEffort: "high" },
7215
+ { value: "haiku", label: "Haiku 4.5", sublabel: "Fastest for quick answers", maxEffort: "medium", defaultEffort: "medium" }
7103
7216
  ];
7104
7217
  var DEFAULT_CAPABILITIES = {
7105
7218
  effortLevels: ["low", "medium", "high", "xhigh", "max"],
@@ -7267,6 +7380,7 @@ async function start(opts = {}) {
7267
7380
  const notificationService = new NotificationService(sessionManager, expoChannel);
7268
7381
  notificationService.addChannel("expo", expoChannel, opts.enableExpoPush !== false);
7269
7382
  notificationService.addChannel("mac", new DesktopNotificationChannel(), opts.enableMacNotification !== false);
7383
+ sessionManager.onSessionRemoved((sessionId) => notificationService.releaseSession(sessionId));
7270
7384
  const activityPushOpts = opts.activityPush ?? await loadApnsConfigFromFile();
7271
7385
  if (activityPushOpts) {
7272
7386
  try {
@@ -7955,8 +8069,30 @@ async function start(opts = {}) {
7955
8069
  const idleTimeoutMs = Number(process.env.SESSIX_IDLE_TIMEOUT_MS ?? 30 * 60 * 1e3);
7956
8070
  const idleSweepIntervalMs = Number(process.env.SESSIX_IDLE_SWEEP_INTERVAL_MS ?? 5 * 60 * 1e3);
7957
8071
  const maxActiveProcesses = Number(process.env.SESSIX_MAX_ACTIVE_PROCESSES ?? 15);
8072
+ const sessionEvictMs = Number(process.env.SESSIX_SESSION_EVICT_MS ?? 2 * 60 * 60 * 1e3);
8073
+ let gcFn;
8074
+ const maybeGc = () => {
8075
+ if (gcFn === void 0) {
8076
+ gcFn = globalThis.gc ?? null;
8077
+ if (!gcFn) {
8078
+ try {
8079
+ (0, import_node_v8.setFlagsFromString)("--expose-gc");
8080
+ const fn = (0, import_node_vm.runInNewContext)("gc");
8081
+ gcFn = typeof fn === "function" ? fn : null;
8082
+ } catch {
8083
+ gcFn = null;
8084
+ }
8085
+ }
8086
+ }
8087
+ if (gcFn) {
8088
+ try {
8089
+ gcFn();
8090
+ } catch {
8091
+ }
8092
+ }
8093
+ };
7958
8094
  let idleSweepTimer = null;
7959
- if (idleSweepIntervalMs > 0 && (idleTimeoutMs > 0 || maxActiveProcesses > 0)) {
8095
+ if (idleSweepIntervalMs > 0 && (idleTimeoutMs > 0 || maxActiveProcesses > 0 || sessionEvictMs > 0)) {
7960
8096
  idleSweepTimer = setInterval(async () => {
7961
8097
  try {
7962
8098
  let totalSwept = 0;
@@ -7975,7 +8111,18 @@ async function start(opts = {}) {
7975
8111
  swept.forEach(broadcastShrink);
7976
8112
  totalSwept += swept.length;
7977
8113
  }
8114
+ if (sessionEvictMs > 0 && typeof provider.listEvictableSessions === "function") {
8115
+ const evictable = provider.listEvictableSessions(sessionEvictMs);
8116
+ for (const id of evictable) {
8117
+ await sessionManager.killSession(id);
8118
+ }
8119
+ if (evictable.length > 0) {
8120
+ console.log(`[Server] Idle GC: evicted ${evictable.length} stale session(s)`);
8121
+ totalSwept += evictable.length;
8122
+ }
8123
+ }
7978
8124
  }
8125
+ const hasRunning = sessionManager.getActiveSessions().some((s) => s.status === "running");
7979
8126
  if (totalSwept > 0) {
7980
8127
  console.log(`[Server] Idle GC: swept ${totalSwept} idle session(s)`);
7981
8128
  wsBridge.broadcast({
@@ -7983,6 +8130,9 @@ async function start(opts = {}) {
7983
8130
  sessions: sessionManager.getActiveSessions()
7984
8131
  });
7985
8132
  }
8133
+ if (totalSwept > 0 || !hasRunning) {
8134
+ maybeGc();
8135
+ }
7986
8136
  } catch (err) {
7987
8137
  console.error("[Server] Idle GC failed:", err);
7988
8138
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "sessix-server",
3
- "version": "0.5.0",
3
+ "version": "0.5.3",
4
4
  "bin": {
5
5
  "sessix-server": "dist/index.js"
6
6
  },