sessix-server 0.4.2 → 0.4.5

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 +1541 -87
  2. package/dist/server.js +1535 -81
  3. package/package.json +1 -1
package/dist/index.js CHANGED
@@ -24,10 +24,10 @@ var __toESM = (mod, isNodeMode, target) => (target = mod != null ? __create(__ge
24
24
  ));
25
25
 
26
26
  // src/index.ts
27
- var import_node_os9 = require("os");
27
+ var import_node_os10 = require("os");
28
28
  var import_node_fs4 = require("fs");
29
- var import_node_path8 = require("path");
30
- var import_node_child_process11 = require("child_process");
29
+ var import_node_path10 = require("path");
30
+ var import_node_child_process13 = require("child_process");
31
31
 
32
32
  // src/i18n/locales/zh.ts
33
33
  var zh = {
@@ -302,12 +302,12 @@ function t(key, params) {
302
302
  }
303
303
 
304
304
  // src/server.ts
305
- var import_uuid7 = require("uuid");
306
- var import_promises5 = require("fs/promises");
307
- var import_node_os8 = require("os");
308
- var import_node_path7 = require("path");
309
- var import_node_child_process10 = require("child_process");
310
- var import_node_util2 = require("util");
305
+ var import_uuid9 = require("uuid");
306
+ var import_promises7 = require("fs/promises");
307
+ var import_node_os9 = require("os");
308
+ var import_node_path9 = require("path");
309
+ var import_node_child_process12 = require("child_process");
310
+ var import_node_util3 = require("util");
311
311
 
312
312
  // src/providers/ProcessProvider.ts
313
313
  var import_child_process = require("child_process");
@@ -538,6 +538,77 @@ var ProcessProvider = class {
538
538
  getActiveSessions() {
539
539
  return Array.from(this.activeSessions.values()).map((entry) => entry.session);
540
540
  }
541
+ /**
542
+ * 清理空闲进程
543
+ *
544
+ * 找出所有 status='idle' 且 lastActiveAt 距今超过 maxIdleMs 的活跃进程,
545
+ * kill 进程释放内存。entry 保留在 activeSessions 中,用户下次 sendMessage
546
+ * 走 slow path 自动 --resume 重启进程。
547
+ *
548
+ * @returns 被 sweep 的 sessionId 列表
549
+ */
550
+ async sweepIdleProcesses(maxIdleMs) {
551
+ const now = Date.now();
552
+ const swept = [];
553
+ for (const [sessionId, entry] of this.activeSessions) {
554
+ if (entry.process.exitCode !== null || entry.process.signalCode !== null) continue;
555
+ if (entry.session.status !== "idle") continue;
556
+ if (now - entry.session.lastActiveAt < maxIdleMs) continue;
557
+ const idleMin = Math.round((now - entry.session.lastActiveAt) / 6e4);
558
+ console.log(`[ProcessProvider] sweeping idle process: ${sessionId} (idle ${idleMin}m)`);
559
+ try {
560
+ entry.process.stdin?.end();
561
+ } catch {
562
+ }
563
+ try {
564
+ await killProcessCrossPlatform(entry.process);
565
+ } catch (err) {
566
+ console.error(`[ProcessProvider] sweep kill failed for ${sessionId}:`, err);
567
+ continue;
568
+ }
569
+ swept.push(sessionId);
570
+ }
571
+ return swept;
572
+ }
573
+ /**
574
+ * LRU 上限清理
575
+ *
576
+ * 当活跃进程数超过 maxAlive 时,按 lastActiveAt 升序(最久未用优先)kill
577
+ * 状态为 idle 的进程,直到活跃数回到上限以内。
578
+ * running / waiting_question 状态的进程永远不会被 kill。
579
+ *
580
+ * @returns 被 sweep 的 sessionId 列表
581
+ */
582
+ async sweepLruProcesses(maxAlive) {
583
+ const swept = [];
584
+ if (maxAlive <= 0) return swept;
585
+ const aliveEntries = Array.from(this.activeSessions.entries()).filter(
586
+ ([, e]) => e.process.exitCode === null && e.process.signalCode === null
587
+ );
588
+ if (aliveEntries.length <= maxAlive) return swept;
589
+ const idleSorted = aliveEntries.filter(([, e]) => e.session.status === "idle").sort((a, b) => a[1].session.lastActiveAt - b[1].session.lastActiveAt);
590
+ let aliveCount = aliveEntries.length;
591
+ for (const [sessionId, entry] of idleSorted) {
592
+ if (aliveCount <= maxAlive) break;
593
+ const idleMin = Math.round((Date.now() - entry.session.lastActiveAt) / 6e4);
594
+ console.log(`[ProcessProvider] LRU sweep: ${sessionId} (idle ${idleMin}m, alive=${aliveCount}/${maxAlive})`);
595
+ try {
596
+ entry.process.stdin?.end();
597
+ } catch {
598
+ }
599
+ try {
600
+ await killProcessCrossPlatform(entry.process);
601
+ swept.push(sessionId);
602
+ aliveCount--;
603
+ } catch (err) {
604
+ console.error(`[ProcessProvider] LRU kill failed for ${sessionId}:`, err);
605
+ }
606
+ }
607
+ if (aliveCount > maxAlive) {
608
+ console.warn(`[ProcessProvider] LRU sweep: ${aliveCount} alive after sweep > limit ${maxAlive}; remaining are running/waiting`);
609
+ }
610
+ return swept;
611
+ }
541
612
  // ============================================
542
613
  // 私有方法
543
614
  // ============================================
@@ -591,7 +662,24 @@ var ProcessProvider = class {
591
662
  writeUserMessage(proc, message, sessionId, images) {
592
663
  const content = [];
593
664
  if (images?.length) {
594
- for (const img of images) {
665
+ const ALLOWED_TYPES = /* @__PURE__ */ new Set(["image/jpeg", "image/png", "image/webp", "image/gif"]);
666
+ const MAX_IMAGE_BYTES = 5 * 1024 * 1024;
667
+ for (let i = 0; i < images.length; i++) {
668
+ const img = images[i];
669
+ if (!ALLOWED_TYPES.has(img.media_type)) {
670
+ if (sessionId) {
671
+ this.emitWriteError(sessionId, `Image #${i + 1} rejected: unsupported media_type "${img.media_type}". Only JPEG/PNG/WebP/GIF are accepted.`);
672
+ }
673
+ return;
674
+ }
675
+ const sizeBytes = Math.floor(img.data.length * 0.75);
676
+ if (sizeBytes > MAX_IMAGE_BYTES) {
677
+ if (sessionId) {
678
+ const sizeMb = (sizeBytes / (1024 * 1024)).toFixed(1);
679
+ this.emitWriteError(sessionId, `Image #${i + 1} rejected: ${sizeMb}MB exceeds 5MB per-image limit.`);
680
+ }
681
+ return;
682
+ }
595
683
  content.push({
596
684
  type: "image",
597
685
  source: { type: "base64", media_type: img.media_type, data: img.data }
@@ -618,6 +706,14 @@ var ProcessProvider = class {
618
706
  this.emitWriteError(sessionId, `Failed to send message: ${err.message}`);
619
707
  }
620
708
  });
709
+ if (sessionId) {
710
+ const syntheticUser = {
711
+ type: "user",
712
+ session_id: sessionId,
713
+ message: { role: "user", content }
714
+ };
715
+ this.emitter.emit(this.getEventName(sessionId), syntheticUser);
716
+ }
621
717
  }
622
718
  /**
623
719
  * 发出写入失败的合成错误事件
@@ -1765,6 +1861,20 @@ var SessionManager = class {
1765
1861
  isBufferTruncated(sessionId) {
1766
1862
  return this.bufferTruncated.has(sessionId);
1767
1863
  }
1864
+ /**
1865
+ * 缩减指定会话的事件缓冲区到最后 N 条,并标记 truncated
1866
+ *
1867
+ * 用于空闲进程被 sweep 后释放内存:缓冲区只为新订阅者重放服务,
1868
+ * 进程已死的会话可以通过 JSONL 文件补全完整历史,不需要保留全部内存事件。
1869
+ * 设置 bufferTruncated 后,客户端 subscribe 收到 session_history 时会从 JSONL 补齐。
1870
+ */
1871
+ shrinkSessionBuffer(sessionId, keepLast = 100) {
1872
+ const buffer = this.sessionEventBuffers.get(sessionId);
1873
+ if (!buffer || buffer.length <= keepLast) return;
1874
+ buffer.splice(0, buffer.length - keepLast);
1875
+ this.bufferTruncated.add(sessionId);
1876
+ console.log(`[SessionManager] Session ${sessionId}: buffer shrunk to ${keepLast}, marked truncated`);
1877
+ }
1768
1878
  /**
1769
1879
  * 获取会话的项目路径(用于截断时从 JSONL 补全历史)
1770
1880
  */
@@ -3625,6 +3735,8 @@ var HookInstaller = class {
3625
3735
 
3626
3736
  // src/notification/NotificationService.ts
3627
3737
  var import_node_path5 = require("path");
3738
+ var RECENT_ACTIVITY_MAX = 6;
3739
+ var ACTIVITY_PUSH_THROTTLE_MS = 2500;
3628
3740
  var NotificationService = class {
3629
3741
  constructor(sessionManager, expoChannel = null) {
3630
3742
  this.sessionManager = sessionManager;
@@ -3643,6 +3755,14 @@ var NotificationService = class {
3643
3755
  latestAssistantText = /* @__PURE__ */ new Map();
3644
3756
  /** 获取全局待审批总数的回调(跨所有会话) */
3645
3757
  globalPendingCountProvider = null;
3758
+ /** sessionId → 最近活动状态(用于 LA content push) */
3759
+ recentActivityState = /* @__PURE__ */ new Map();
3760
+ /** sessionId → 节流定时器(LA content push) */
3761
+ activityPushTimers = /* @__PURE__ */ new Map();
3762
+ /** 上次推送 LA content 的时间戳(用于节流;首次立即推送) */
3763
+ lastActivityPushAt = /* @__PURE__ */ new Map();
3764
+ /** 提供器:根据 sessionId 取该会话的待审批列表(由 server.ts 注入) */
3765
+ pendingApprovalsProvider = null;
3646
3766
  /** 添加通知渠道(id 唯一,可用于后续动态开关) */
3647
3767
  addChannel(id, channel, enabled = true) {
3648
3768
  this.channelMap.set(id, { channel, enabled });
@@ -3670,11 +3790,24 @@ var NotificationService = class {
3670
3790
  }
3671
3791
  /** 注册 ActivityKit push token(由手机端启动 Live Activity 后上报) */
3672
3792
  addActivityPushToken(sessionId, token) {
3673
- this.activityPushChannel?.addToken(sessionId, token);
3793
+ if (!this.activityPushChannel) {
3794
+ console.warn(`[NotificationService] \u26A0\uFE0F \u6536\u5230 LA push token \u4F46 ActivityPushChannel \u672A\u521D\u59CB\u5316 (session=${sessionId.slice(0, 8)}\u2026) \u2014 \u68C0\u67E5 ~/.sessix/apns.json`);
3795
+ return;
3796
+ }
3797
+ this.activityPushChannel.addToken(sessionId, token);
3798
+ console.log(`[NotificationService] \u2705 LA push token \u5DF2\u6CE8\u518C (session=${sessionId.slice(0, 8)}\u2026, token=${token.slice(0, 16)}\u2026)`);
3799
+ this.scheduleActivityPush(sessionId, true);
3674
3800
  }
3675
3801
  /** 移除 ActivityKit push token */
3676
3802
  removeActivityPushToken(sessionId) {
3677
3803
  this.activityPushChannel?.removeToken(sessionId);
3804
+ this.clearActivityPushTimer(sessionId);
3805
+ this.recentActivityState.delete(sessionId);
3806
+ this.lastActivityPushAt.delete(sessionId);
3807
+ }
3808
+ /** 注入"会话 → 待审批列表"提供器(server.ts 启动时调用) */
3809
+ setPendingApprovalsProvider(fn) {
3810
+ this.pendingApprovalsProvider = fn;
3678
3811
  }
3679
3812
  /** 设置全局待审批总数提供者 */
3680
3813
  setGlobalPendingCountProvider(provider) {
@@ -3697,12 +3830,15 @@ var NotificationService = class {
3697
3830
  if (this.activityPushChannel?.hasToken(request.sessionId)) {
3698
3831
  const dangerLevel2 = this.getDangerLevel(request.toolName);
3699
3832
  const isYoloMode = this.getYoloMode(request.sessionId);
3833
+ const recentActivity = this.getRecentActivity(request.sessionId);
3834
+ const latestMessage = recentActivity[recentActivity.length - 1] ?? "";
3700
3835
  this.activityPushChannel.updateActivityWithAlert(
3701
3836
  request.sessionId,
3702
3837
  {
3703
3838
  status: "waitingApproval",
3704
3839
  sessionTitle,
3705
- latestMessage: "",
3840
+ latestMessage,
3841
+ recentActivity,
3706
3842
  approvalInfo: {
3707
3843
  requestId: request.id,
3708
3844
  toolName: request.toolName,
@@ -3715,6 +3851,7 @@ var NotificationService = class {
3715
3851
  },
3716
3852
  { title, body }
3717
3853
  );
3854
+ this.lastActivityPushAt.set(request.sessionId, Date.now());
3718
3855
  return;
3719
3856
  }
3720
3857
  const dangerLevel = this.getDangerLevel(request.toolName);
@@ -3748,17 +3885,20 @@ var NotificationService = class {
3748
3885
  const body = `\u2753 ${request.question.slice(0, 80)}`;
3749
3886
  if (this.activityPushChannel?.hasToken(request.sessionId)) {
3750
3887
  const isYoloMode = this.getYoloMode(request.sessionId);
3888
+ const recentActivity = this.getRecentActivity(request.sessionId);
3751
3889
  this.activityPushChannel.updateActivityWithAlert(
3752
3890
  request.sessionId,
3753
3891
  {
3754
3892
  status: "waitingApproval",
3755
3893
  sessionTitle,
3756
3894
  latestMessage: request.question.slice(0, 80),
3895
+ recentActivity,
3757
3896
  isYoloMode,
3758
3897
  updatedAt: Date.now()
3759
3898
  },
3760
3899
  { title: sessionTitle, body }
3761
3900
  );
3901
+ this.lastActivityPushAt.set(request.sessionId, Date.now());
3762
3902
  return;
3763
3903
  }
3764
3904
  this.notify({
@@ -3792,6 +3932,10 @@ var NotificationService = class {
3792
3932
  this.unsubscribe = null;
3793
3933
  this.yoloModeState.clear();
3794
3934
  this.latestAssistantText.clear();
3935
+ for (const timer of this.activityPushTimers.values()) clearTimeout(timer);
3936
+ this.activityPushTimers.clear();
3937
+ this.recentActivityState.clear();
3938
+ this.lastActivityPushAt.clear();
3795
3939
  }
3796
3940
  // ============================================
3797
3941
  // 内部方法
@@ -3800,15 +3944,20 @@ var NotificationService = class {
3800
3944
  switch (event.type) {
3801
3945
  case "claude_event": {
3802
3946
  this.trackAssistantText(event.sessionId, event.event);
3947
+ this.updateRecentActivity(event.sessionId, event.event);
3948
+ this.scheduleActivityPush(event.sessionId);
3803
3949
  break;
3804
3950
  }
3805
3951
  case "claude_events": {
3806
3952
  for (const e of event.events) {
3807
3953
  this.trackAssistantText(event.sessionId, e);
3954
+ this.updateRecentActivity(event.sessionId, e);
3808
3955
  }
3956
+ this.scheduleActivityPush(event.sessionId);
3809
3957
  break;
3810
3958
  }
3811
3959
  case "status_change": {
3960
+ this.clearActivityPushTimer(event.sessionId);
3812
3961
  if (event.status === "idle") {
3813
3962
  const sessionTitle = this.getSessionTitle(event.sessionId);
3814
3963
  const latestMsg = this.latestAssistantText.get(event.sessionId);
@@ -3819,9 +3968,12 @@ var NotificationService = class {
3819
3968
  status: "idle",
3820
3969
  sessionTitle,
3821
3970
  latestMessage: body,
3971
+ recentActivity: this.getRecentActivity(event.sessionId),
3822
3972
  isYoloMode,
3823
3973
  updatedAt: Date.now()
3824
3974
  });
3975
+ this.recentActivityState.delete(event.sessionId);
3976
+ this.lastActivityPushAt.delete(event.sessionId);
3825
3977
  } else {
3826
3978
  this.notify({
3827
3979
  title: sessionTitle,
@@ -3841,9 +3993,12 @@ var NotificationService = class {
3841
3993
  status: "error",
3842
3994
  sessionTitle,
3843
3995
  latestMessage: body,
3996
+ recentActivity: this.getRecentActivity(event.sessionId),
3844
3997
  isYoloMode,
3845
3998
  updatedAt: Date.now()
3846
3999
  });
4000
+ this.recentActivityState.delete(event.sessionId);
4001
+ this.lastActivityPushAt.delete(event.sessionId);
3847
4002
  } else {
3848
4003
  this.notify({
3849
4004
  title: sessionTitle,
@@ -3885,6 +4040,229 @@ var NotificationService = class {
3885
4040
  getYoloMode(sessionId) {
3886
4041
  return this.yoloModeState.get(sessionId) ?? false;
3887
4042
  }
4043
+ // ============================================
4044
+ // Live Activity 内容推送(后台 LA 实时刷新)
4045
+ // ============================================
4046
+ /**
4047
+ * 把一个 ClaudeStreamEvent 折算到 recentActivity 列表里。
4048
+ * 同一 message.id 内多次 assistant 事件视为流式更新,整段重建 currentEntries;
4049
+ * 切换 message.id 视为新 turn,旧条目沉淀到 history。
4050
+ */
4051
+ updateRecentActivity(sessionId, event) {
4052
+ if (event.type === "result") {
4053
+ const state2 = this.recentActivityState.get(sessionId);
4054
+ if (state2 && state2.currentEntries.length > 0) {
4055
+ state2.history.push(...state2.currentEntries);
4056
+ while (state2.history.length > RECENT_ACTIVITY_MAX) state2.history.shift();
4057
+ state2.currentEntries = [];
4058
+ state2.currentMessageId = null;
4059
+ }
4060
+ return;
4061
+ }
4062
+ if (event.type !== "assistant") return;
4063
+ const msg = event.message;
4064
+ if (!Array.isArray(msg.content)) return;
4065
+ let state = this.recentActivityState.get(sessionId);
4066
+ if (!state) {
4067
+ state = { history: [], currentMessageId: null, currentEntries: [] };
4068
+ this.recentActivityState.set(sessionId, state);
4069
+ }
4070
+ if (state.currentMessageId !== msg.id) {
4071
+ if (state.currentEntries.length > 0) {
4072
+ state.history.push(...state.currentEntries);
4073
+ while (state.history.length > RECENT_ACTIVITY_MAX) state.history.shift();
4074
+ }
4075
+ state.currentEntries = [];
4076
+ state.currentMessageId = msg.id;
4077
+ }
4078
+ const next = [];
4079
+ for (const block of msg.content) {
4080
+ if (block.type === "text") {
4081
+ const line = this.summarizeText(block.text);
4082
+ if (line.length >= 4) next.push(line);
4083
+ } else if (block.type === "tool_use") {
4084
+ const line = this.summarizeToolCall(block.name, block.input ?? {});
4085
+ if (line) next.push(line);
4086
+ }
4087
+ }
4088
+ state.currentEntries = next;
4089
+ }
4090
+ /** 取该会话当前的 recentActivity(history + currentEntries),保留末尾 N 条 */
4091
+ getRecentActivity(sessionId) {
4092
+ const state = this.recentActivityState.get(sessionId);
4093
+ if (!state) return [];
4094
+ const combined = [...state.history, ...state.currentEntries];
4095
+ return combined.slice(-RECENT_ACTIVITY_MAX);
4096
+ }
4097
+ /** 节流调度 LA content push;首次立即推,后续合并到 throttle window 末尾 */
4098
+ scheduleActivityPush(sessionId, force = false) {
4099
+ if (!this.activityPushChannel?.hasToken(sessionId)) return;
4100
+ const now = Date.now();
4101
+ const last = this.lastActivityPushAt.get(sessionId) ?? 0;
4102
+ const elapsed = now - last;
4103
+ if (force || elapsed >= ACTIVITY_PUSH_THROTTLE_MS) {
4104
+ this.clearActivityPushTimer(sessionId);
4105
+ this.flushActivityPush(sessionId);
4106
+ return;
4107
+ }
4108
+ if (this.activityPushTimers.has(sessionId)) return;
4109
+ const wait = ACTIVITY_PUSH_THROTTLE_MS - elapsed;
4110
+ this.activityPushTimers.set(
4111
+ sessionId,
4112
+ setTimeout(() => {
4113
+ this.activityPushTimers.delete(sessionId);
4114
+ this.flushActivityPush(sessionId);
4115
+ }, wait)
4116
+ );
4117
+ }
4118
+ clearActivityPushTimer(sessionId) {
4119
+ const timer = this.activityPushTimers.get(sessionId);
4120
+ if (timer) {
4121
+ clearTimeout(timer);
4122
+ this.activityPushTimers.delete(sessionId);
4123
+ }
4124
+ }
4125
+ /** 真正发送一次 LA content push(无 alert) */
4126
+ flushActivityPush(sessionId) {
4127
+ const channel = this.activityPushChannel;
4128
+ if (!channel?.hasToken(sessionId)) return;
4129
+ const session = this.sessionManager.getActiveSessions().find((s) => s.id === sessionId);
4130
+ if (!session) return;
4131
+ const recentActivity = this.getRecentActivity(sessionId);
4132
+ const latestMessage = recentActivity[recentActivity.length - 1] ?? this.latestAssistantText.get(sessionId) ?? "";
4133
+ const sessionTitle = this.getSessionTitle(sessionId);
4134
+ const isYoloMode = this.getYoloMode(sessionId);
4135
+ const pendingApprovals = this.pendingApprovalsProvider?.(sessionId) ?? [];
4136
+ const latestApproval = pendingApprovals[pendingApprovals.length - 1];
4137
+ const status = latestApproval ? "waitingApproval" : this.mapSessionStatus(session.status);
4138
+ const contentState = {
4139
+ status,
4140
+ sessionTitle,
4141
+ latestMessage,
4142
+ recentActivity,
4143
+ isYoloMode,
4144
+ updatedAt: Date.now()
4145
+ };
4146
+ if (latestApproval) {
4147
+ contentState.approvalInfo = {
4148
+ requestId: latestApproval.id,
4149
+ toolName: latestApproval.toolName,
4150
+ description: String(latestApproval.description ?? "").slice(0, 80),
4151
+ dangerLevel: this.getDangerLevel(latestApproval.toolName),
4152
+ pendingCount: pendingApprovals.length
4153
+ };
4154
+ }
4155
+ if (session.stats) {
4156
+ contentState.stats = {
4157
+ totalInputTokens: session.stats.totalInputTokens,
4158
+ totalOutputTokens: session.stats.totalOutputTokens,
4159
+ totalCostUsd: session.stats.totalCostUsd
4160
+ };
4161
+ }
4162
+ this.lastActivityPushAt.set(sessionId, Date.now());
4163
+ const lineCount = recentActivity.length;
4164
+ channel.updateActivity(sessionId, contentState).then(() => {
4165
+ console.log(`[NotificationService] \u{1F4E1} LA push \u2713 session=${sessionId.slice(0, 8)}\u2026 status=${status} lines=${lineCount}`);
4166
+ }).catch((err) => {
4167
+ console.warn(`[NotificationService] \u{1F4E1} LA push \u2717 session=${sessionId.slice(0, 8)}\u2026:`, err instanceof Error ? err.message : err);
4168
+ });
4169
+ }
4170
+ /** SessionStatus → LiveActivity status 字符串映射(与客户端 mapStatus 一致) */
4171
+ mapSessionStatus(status) {
4172
+ switch (status) {
4173
+ case "running":
4174
+ return "running";
4175
+ case "waiting_approval":
4176
+ return "waitingApproval";
4177
+ case "waiting_question":
4178
+ return "waitingQuestion";
4179
+ case "idle":
4180
+ return "completed";
4181
+ case "completed":
4182
+ return "completed";
4183
+ case "error":
4184
+ return "error";
4185
+ default:
4186
+ return "idle";
4187
+ }
4188
+ }
4189
+ /** 文本块清洗:去多余空白 + 截断到 70 字符 */
4190
+ summarizeText(raw) {
4191
+ if (typeof raw !== "string") return "";
4192
+ const cleaned = raw.replace(/\s+/g, " ").trim();
4193
+ return cleaned.length > 70 ? cleaned.slice(0, 70) + "\u2026" : cleaned;
4194
+ }
4195
+ /** 工具调用摘要(与客户端 summarizeToolCall 行为对齐,简化版只输出中文) */
4196
+ summarizeToolCall(name, input) {
4197
+ const str = (v) => typeof v === "string" ? v : "";
4198
+ const baseName = (p) => {
4199
+ const cleaned = p.split(/[?#]/)[0];
4200
+ const parts = cleaned.split("/");
4201
+ return parts[parts.length - 1] || cleaned;
4202
+ };
4203
+ const trunc = (s, n) => s.length > n ? s.slice(0, n) + "\u2026" : s;
4204
+ switch (name) {
4205
+ case "Bash": {
4206
+ const cmd = str(input.command).split("\n")[0];
4207
+ return cmd ? `\u8FD0\u884C: ${trunc(cmd, 60)}` : "\u6267\u884C\u547D\u4EE4";
4208
+ }
4209
+ case "Edit": {
4210
+ const fp = baseName(str(input.file_path));
4211
+ return fp ? `\u7F16\u8F91 ${fp}` : "\u7F16\u8F91\u6587\u4EF6";
4212
+ }
4213
+ case "MultiEdit": {
4214
+ const fp = baseName(str(input.file_path));
4215
+ return fp ? `\u6279\u91CF\u7F16\u8F91 ${fp}` : "\u6279\u91CF\u7F16\u8F91\u6587\u4EF6";
4216
+ }
4217
+ case "Write": {
4218
+ const fp = baseName(str(input.file_path));
4219
+ return fp ? `\u5199\u5165 ${fp}` : "\u5199\u5165\u6587\u4EF6";
4220
+ }
4221
+ case "Read":
4222
+ case "NotebookEdit": {
4223
+ const fp = baseName(str(input.file_path) || str(input.notebook_path));
4224
+ return fp ? `\u9605\u8BFB ${fp}` : "\u9605\u8BFB\u6587\u4EF6";
4225
+ }
4226
+ case "Grep": {
4227
+ const p = str(input.pattern);
4228
+ return p ? `\u641C\u7D22: ${trunc(p, 50)}` : "\u641C\u7D22\u4EE3\u7801";
4229
+ }
4230
+ case "Glob": {
4231
+ const p = str(input.pattern);
4232
+ return p ? `\u67E5\u627E: ${trunc(p, 50)}` : "\u67E5\u627E\u6587\u4EF6";
4233
+ }
4234
+ case "WebFetch": {
4235
+ const url = str(input.url);
4236
+ let host = url;
4237
+ try {
4238
+ host = new URL(url).hostname;
4239
+ } catch {
4240
+ }
4241
+ return host ? `\u8BF7\u6C42 ${trunc(host, 50)}` : "\u8BF7\u6C42\u7F51\u9875";
4242
+ }
4243
+ case "WebSearch": {
4244
+ const q = str(input.query);
4245
+ return q ? `\u641C\u7D22\u7F51\u9875: ${trunc(q, 50)}` : "\u641C\u7D22\u7F51\u9875";
4246
+ }
4247
+ case "TodoWrite":
4248
+ return "\u66F4\u65B0\u4EFB\u52A1\u6E05\u5355";
4249
+ case "Task":
4250
+ case "Agent": {
4251
+ const desc = str(input.description) || str(input.subagent_type);
4252
+ return desc ? `\u6D3E\u53D1\u4EFB\u52A1: ${trunc(desc, 50)}` : "\u6D3E\u53D1\u5B50\u4EFB\u52A1";
4253
+ }
4254
+ case "ExitPlanMode":
4255
+ return "\u63D0\u4EA4\u8BA1\u5212";
4256
+ case "Skill": {
4257
+ const skill = str(input.skill);
4258
+ return skill ? `\u8C03\u7528\u6280\u80FD: ${trunc(skill, 40)}` : "\u8C03\u7528\u6280\u80FD";
4259
+ }
4260
+ default: {
4261
+ const summary = trunc(JSON.stringify(input), 50);
4262
+ return name ? `${name}: ${summary}` : summary;
4263
+ }
4264
+ }
4265
+ }
3888
4266
  };
3889
4267
 
3890
4268
  // src/notification/DesktopNotificationChannel.ts
@@ -3998,62 +4376,82 @@ var ExpoNotificationChannel = class {
3998
4376
  var http2 = __toESM(require("http2"));
3999
4377
  var fs2 = __toESM(require("fs"));
4000
4378
  var crypto = __toESM(require("crypto"));
4379
+ var APNS_HOSTS = {
4380
+ production: "api.push.apple.com",
4381
+ sandbox: "api.sandbox.push.apple.com"
4382
+ };
4001
4383
  var ActivityPushChannel = class {
4002
4384
  /** sessionId -> activityPushToken */
4003
4385
  tokens = /* @__PURE__ */ new Map();
4386
+ /**
4387
+ * 每个 token 已确认工作的 APNs 环境。
4388
+ * Debug build (aps-environment=development) 的 token 仅在 sandbox 端有效;
4389
+ * Release build (aps-environment=production) 的 token 仅在 production 端有效。
4390
+ * 同时维护两个连接 + 探测机制,避免环境配错时静默失败。
4391
+ */
4392
+ tokenEnv = /* @__PURE__ */ new Map();
4393
+ /** 首次探测顺序(来自配置 hint),未配置则先试 sandbox(开发场景占多数) */
4394
+ probeOrder;
4004
4395
  teamId;
4005
4396
  keyId;
4006
4397
  authKey;
4007
- apnsHost;
4008
4398
  /** 缓存的 JWT token + 过期时间 */
4009
4399
  cachedJwt = null;
4010
- /** 复用的 HTTP/2 长连接 */
4011
- http2Client = null;
4400
+ /** 每个环境一条 HTTP/2 长连接 */
4401
+ http2Clients = {};
4012
4402
  constructor(config) {
4013
4403
  this.teamId = config.teamId;
4014
4404
  this.keyId = config.keyId;
4015
4405
  this.authKey = fs2.readFileSync(config.authKeyPath, "utf-8");
4016
- this.apnsHost = config.sandbox ? "api.sandbox.push.apple.com" : "api.push.apple.com";
4017
- console.log(`[ActivityPushChannel] Initialized (${config.sandbox ? "sandbox" : "production"} mode)`);
4018
- }
4019
- /** 获取或新建 HTTP/2 长连接 */
4020
- getHttp2Client() {
4021
- if (this.http2Client && !this.http2Client.destroyed && !this.http2Client.closed) {
4022
- return this.http2Client;
4023
- }
4024
- this.http2Client = http2.connect(`https://${this.apnsHost}`);
4025
- this.http2Client.on("error", (err) => {
4026
- console.warn("[ActivityPushChannel] HTTP/2 connection error, will reconnect on next request:", err.message);
4027
- this.http2Client?.destroy();
4028
- this.http2Client = null;
4406
+ this.probeOrder = config.sandbox === false ? ["production", "sandbox"] : ["sandbox", "production"];
4407
+ console.log(`[ActivityPushChannel] Initialized (probe order: ${this.probeOrder.join(" \u2192 ")})`);
4408
+ }
4409
+ /** 获取或新建指定环境的 HTTP/2 长连接 */
4410
+ getHttp2Client(env) {
4411
+ const existing = this.http2Clients[env];
4412
+ if (existing && !existing.destroyed && !existing.closed) {
4413
+ return existing;
4414
+ }
4415
+ const client = http2.connect(`https://${APNS_HOSTS[env]}`);
4416
+ client.on("error", (err) => {
4417
+ console.warn(`[ActivityPushChannel] HTTP/2 (${env}) error, will reconnect on next request:`, err.message);
4418
+ client.destroy();
4419
+ if (this.http2Clients[env] === client) delete this.http2Clients[env];
4029
4420
  });
4030
- this.http2Client.on("close", () => {
4031
- this.http2Client = null;
4421
+ client.on("close", () => {
4422
+ if (this.http2Clients[env] === client) delete this.http2Clients[env];
4032
4423
  });
4033
- return this.http2Client;
4424
+ this.http2Clients[env] = client;
4425
+ return client;
4034
4426
  }
4035
4427
  /** 注册 Activity push token */
4036
4428
  addToken(sessionId, token) {
4429
+ const existed = this.tokens.has(sessionId);
4037
4430
  this.tokens.set(sessionId, token);
4038
- console.log(`[ActivityPushChannel] Token registered: session=${sessionId}`);
4431
+ console.log(`[ActivityPushChannel] Token ${existed ? "updated" : "registered"}: session=${sessionId.slice(0, 8)}\u2026 token=${token.slice(0, 16)}\u2026`);
4039
4432
  }
4040
4433
  /** 移除 Activity push token */
4041
4434
  removeToken(sessionId) {
4435
+ const tok = this.tokens.get(sessionId);
4042
4436
  this.tokens.delete(sessionId);
4437
+ if (tok) this.tokenEnv.delete(tok);
4043
4438
  }
4044
- /** 发送 content-state 更新到指定会话的 Live Activity */
4439
+ /** 发送 content-state 更新到指定会话的 Live Activity(纯内容刷新,不响通知) */
4045
4440
  async updateActivity(sessionId, contentState) {
4046
4441
  const token = this.tokens.get(sessionId);
4047
4442
  if (!token) return;
4443
+ const now = Math.floor(Date.now() / 1e3);
4048
4444
  const payload = {
4049
4445
  aps: {
4050
- timestamp: Math.floor(Date.now() / 1e3),
4446
+ timestamp: now,
4051
4447
  event: "update",
4052
- "content-state": contentState
4448
+ "content-state": contentState,
4449
+ // 2 分钟没新内容就让 LA 进入 stale 状态(系统自动灰化),避免显示陈旧数据
4450
+ "stale-date": now + 120
4053
4451
  }
4054
4452
  };
4055
4453
  try {
4056
- await this.sendToAPNs(token, payload);
4454
+ await this.sendToAPNs(token, payload, { priority: "5" });
4057
4455
  } catch (err) {
4058
4456
  console.warn(`[ActivityPushChannel] Update failed session=${sessionId}:`, err);
4059
4457
  }
@@ -4062,17 +4460,19 @@ var ActivityPushChannel = class {
4062
4460
  async updateActivityWithAlert(sessionId, contentState, alert) {
4063
4461
  const token = this.tokens.get(sessionId);
4064
4462
  if (!token) return;
4463
+ const now = Math.floor(Date.now() / 1e3);
4065
4464
  const payload = {
4066
4465
  aps: {
4067
- timestamp: Math.floor(Date.now() / 1e3),
4466
+ timestamp: now,
4068
4467
  event: "update",
4069
4468
  "content-state": contentState,
4469
+ "stale-date": now + 120,
4070
4470
  alert,
4071
4471
  sound: "default"
4072
4472
  }
4073
4473
  };
4074
4474
  try {
4075
- await this.sendToAPNs(token, payload);
4475
+ await this.sendToAPNs(token, payload, { priority: "10" });
4076
4476
  } catch (err) {
4077
4477
  console.warn(`[ActivityPushChannel] Alert update failed session=${sessionId}:`, err);
4078
4478
  }
@@ -4081,15 +4481,16 @@ var ActivityPushChannel = class {
4081
4481
  async endActivity(sessionId, contentState) {
4082
4482
  const token = this.tokens.get(sessionId);
4083
4483
  if (!token) return;
4484
+ const now = Math.floor(Date.now() / 1e3);
4084
4485
  const payload = {
4085
4486
  aps: {
4086
- timestamp: Math.floor(Date.now() / 1e3),
4487
+ timestamp: now,
4087
4488
  event: "end",
4088
4489
  "content-state": contentState
4089
4490
  }
4090
4491
  };
4091
4492
  try {
4092
- await this.sendToAPNs(token, payload);
4493
+ await this.sendToAPNs(token, payload, { priority: "10" });
4093
4494
  } catch (err) {
4094
4495
  console.warn(`[ActivityPushChannel] End failed session=${sessionId}:`, err);
4095
4496
  }
@@ -4099,15 +4500,44 @@ var ActivityPushChannel = class {
4099
4500
  hasToken(sessionId) {
4100
4501
  return this.tokens.has(sessionId);
4101
4502
  }
4102
- /** 发送 APNs HTTP/2 请求 */
4103
- async sendToAPNs(deviceToken, payload) {
4503
+ /**
4504
+ * 发送 APNs,自动处理环境探测。
4505
+ * 对每个 token:先用已确认的环境(如有);否则按 probeOrder 顺序探测,
4506
+ * 收到 BadDeviceToken 自动切到另一个环境,并把成功的环境绑定到该 token。
4507
+ */
4508
+ async sendToAPNs(deviceToken, payload, opts = {}) {
4509
+ const known = this.tokenEnv.get(deviceToken);
4510
+ if (known) {
4511
+ return this.sendToAPNsOnce(deviceToken, payload, opts, known);
4512
+ }
4513
+ let lastErr = null;
4514
+ for (const env of this.probeOrder) {
4515
+ try {
4516
+ await this.sendToAPNsOnce(deviceToken, payload, opts, env);
4517
+ this.tokenEnv.set(deviceToken, env);
4518
+ if (env !== this.probeOrder[0]) {
4519
+ console.log(`[ActivityPushChannel] Token bound to ${env} after probe (token=${deviceToken.slice(0, 16)}\u2026)`);
4520
+ }
4521
+ return;
4522
+ } catch (err) {
4523
+ lastErr = err;
4524
+ if (!isBadDeviceTokenError(err)) {
4525
+ throw err;
4526
+ }
4527
+ }
4528
+ }
4529
+ throw lastErr ?? new Error("APNs send failed: all environments rejected token");
4530
+ }
4531
+ /** 单次 APNs HTTP/2 请求(内部 helper,不做探测) */
4532
+ async sendToAPNsOnce(deviceToken, payload, opts, env) {
4104
4533
  const topic = "com.kachun.sessix.push-type.liveactivity";
4105
4534
  const jwt = this.getJWT();
4106
4535
  const payloadStr = JSON.stringify(payload);
4536
+ const priority = opts.priority ?? "10";
4107
4537
  return new Promise((resolve, reject) => {
4108
4538
  let client;
4109
4539
  try {
4110
- client = this.getHttp2Client();
4540
+ client = this.getHttp2Client(env);
4111
4541
  } catch (err) {
4112
4542
  return reject(err);
4113
4543
  }
@@ -4117,7 +4547,7 @@ var ActivityPushChannel = class {
4117
4547
  "authorization": `bearer ${jwt}`,
4118
4548
  "apns-topic": topic,
4119
4549
  "apns-push-type": "liveactivity",
4120
- "apns-priority": "10",
4550
+ "apns-priority": priority,
4121
4551
  "apns-expiration": String(Math.floor(Date.now() / 1e3) + 30),
4122
4552
  "content-type": "application/json",
4123
4553
  "content-length": Buffer.byteLength(payloadStr)
@@ -4135,10 +4565,11 @@ var ActivityPushChannel = class {
4135
4565
  resolve();
4136
4566
  } else {
4137
4567
  if (statusCode === 0) {
4138
- this.http2Client?.destroy();
4139
- this.http2Client = null;
4568
+ const c = this.http2Clients[env];
4569
+ c?.destroy();
4570
+ delete this.http2Clients[env];
4140
4571
  }
4141
- reject(new Error(`APNs returned ${statusCode}: ${responseData}`));
4572
+ reject(new ApnsError(statusCode, responseData));
4142
4573
  }
4143
4574
  });
4144
4575
  req.on("error", (err) => {
@@ -4171,6 +4602,24 @@ var ActivityPushChannel = class {
4171
4602
  return token;
4172
4603
  }
4173
4604
  };
4605
+ var ApnsError = class extends Error {
4606
+ constructor(statusCode, responseBody) {
4607
+ super(`APNs returned ${statusCode}: ${responseBody}`);
4608
+ this.statusCode = statusCode;
4609
+ this.responseBody = responseBody;
4610
+ this.name = "ApnsError";
4611
+ }
4612
+ };
4613
+ function isBadDeviceTokenError(err) {
4614
+ if (!(err instanceof ApnsError)) return false;
4615
+ if (err.statusCode !== 400 && err.statusCode !== 410) return false;
4616
+ try {
4617
+ const parsed = JSON.parse(err.responseBody);
4618
+ return parsed.reason === "BadDeviceToken" || parsed.reason === "Unregistered";
4619
+ } catch {
4620
+ return false;
4621
+ }
4622
+ }
4174
4623
 
4175
4624
  // src/session/ProjectReader.ts
4176
4625
  var import_promises3 = require("fs/promises");
@@ -4570,6 +5019,45 @@ var PairingManager = class {
4570
5019
  }
4571
5020
  };
4572
5021
 
5022
+ // src/utils/shellPath.ts
5023
+ var import_node_child_process7 = require("child_process");
5024
+ var fixed = false;
5025
+ function fixShellPath() {
5026
+ if (fixed || isWindows) {
5027
+ fixed = true;
5028
+ return;
5029
+ }
5030
+ fixed = true;
5031
+ const shell = process.env.SHELL || "/bin/zsh";
5032
+ const isFish = /\/fish$/.test(shell);
5033
+ const printPathCmd = isFish ? "string join : $PATH" : 'printf "%s" "$PATH"';
5034
+ let raw;
5035
+ try {
5036
+ raw = (0, import_node_child_process7.execFileSync)(shell, ["-l", "-c", printPathCmd], {
5037
+ encoding: "utf8",
5038
+ timeout: 3e3,
5039
+ stdio: ["ignore", "pipe", "ignore"]
5040
+ });
5041
+ } catch (err) {
5042
+ console.warn("[fixShellPath] failed to read login shell PATH:", err);
5043
+ return;
5044
+ }
5045
+ const fromShell = raw.trim();
5046
+ if (!fromShell) return;
5047
+ process.env.PATH = mergePath(fromShell, process.env.PATH || "");
5048
+ }
5049
+ function mergePath(primary, secondary) {
5050
+ const seen = /* @__PURE__ */ new Set();
5051
+ const out = [];
5052
+ for (const seg of primary.split(":").concat(secondary.split(":"))) {
5053
+ if (!seg) continue;
5054
+ if (seen.has(seg)) continue;
5055
+ seen.add(seg);
5056
+ out.push(seg);
5057
+ }
5058
+ return out.join(":");
5059
+ }
5060
+
4573
5061
  // src/auth/AuthManager.ts
4574
5062
  var import_child_process3 = require("child_process");
4575
5063
  var import_child_process4 = require("child_process");
@@ -4689,12 +5177,12 @@ var AuthManager = class extends import_events3.EventEmitter {
4689
5177
  };
4690
5178
 
4691
5179
  // src/server.ts
4692
- var import_promises6 = require("fs/promises");
5180
+ var import_promises8 = require("fs/promises");
4693
5181
 
4694
5182
  // src/terminal/TerminalExecutor.ts
4695
- var import_node_child_process7 = require("child_process");
5183
+ var import_node_child_process8 = require("child_process");
4696
5184
  var import_uuid5 = require("uuid");
4697
- var EXEC_TIMEOUT_MS = 5 * 60 * 1e3;
5185
+ var EXEC_TIMEOUT_MS = 30 * 60 * 1e3;
4698
5186
  var TerminalExecutor = class {
4699
5187
  processes = /* @__PURE__ */ new Map();
4700
5188
  eventCallbacks = [];
@@ -4716,9 +5204,9 @@ var TerminalExecutor = class {
4716
5204
  }
4717
5205
  exec(sessionId, command, cwd) {
4718
5206
  const execId = (0, import_uuid5.v4)();
4719
- const shell = isWindows ? "powershell" : "bash";
4720
- const args = isWindows ? ["-Command", command] : ["-c", command];
4721
- const proc = (0, import_node_child_process7.spawn)(shell, args, {
5207
+ const shell = isWindows ? "powershell" : process.env.SHELL || "/bin/zsh";
5208
+ const args = isWindows ? ["-Command", command] : ["-l", "-c", command];
5209
+ const proc = (0, import_node_child_process8.spawn)(shell, args, {
4722
5210
  cwd,
4723
5211
  stdio: ["ignore", "pipe", "pipe"],
4724
5212
  env: { ...process.env }
@@ -4755,6 +5243,14 @@ var TerminalExecutor = class {
4755
5243
  });
4756
5244
  const timer = setTimeout(() => {
4757
5245
  if (this.processes.has(execId)) {
5246
+ this.emit({
5247
+ type: "terminal_output",
5248
+ sessionId,
5249
+ execId,
5250
+ stream: "stderr",
5251
+ data: `[killed: timeout ${Math.round(EXEC_TIMEOUT_MS / 6e4)}m]
5252
+ `
5253
+ });
4758
5254
  killProcessCrossPlatform(proc);
4759
5255
  }
4760
5256
  }, EXEC_TIMEOUT_MS);
@@ -4779,13 +5275,13 @@ var TerminalExecutor = class {
4779
5275
  };
4780
5276
 
4781
5277
  // src/xcode/XcodeBuildExecutor.ts
4782
- var import_node_child_process8 = require("child_process");
5278
+ var import_node_child_process9 = require("child_process");
4783
5279
  var import_node_util = require("util");
4784
5280
  var import_promises4 = require("fs/promises");
4785
5281
  var import_node_path6 = require("path");
4786
5282
  var import_node_os7 = require("os");
4787
5283
  var import_uuid6 = require("uuid");
4788
- var execAsync = (0, import_node_util.promisify)(import_node_child_process8.exec);
5284
+ var execAsync = (0, import_node_util.promisify)(import_node_child_process9.exec);
4789
5285
  var BUILD_TIMEOUT_MS = 30 * 60 * 1e3;
4790
5286
  var INSTALL_TIMEOUT_MS = 5 * 60 * 1e3;
4791
5287
  var CONFIG_FILE = (0, import_node_path6.join)((0, import_node_os7.homedir)(), ".sessix", "xcode-config.json");
@@ -4974,7 +5470,7 @@ ${e.stderr ?? ""}`);
4974
5470
  if (override) await this.saveConfig(projectPath, override);
4975
5471
  const buildId = (0, import_uuid6.v4)();
4976
5472
  const args = buildArgs(config);
4977
- const proc = (0, import_node_child_process8.spawn)("xcodebuild", args, {
5473
+ const proc = (0, import_node_child_process9.spawn)("xcodebuild", args, {
4978
5474
  cwd: projectPath,
4979
5475
  stdio: ["ignore", "pipe", "pipe"],
4980
5476
  env: { ...process.env, NSUnbufferedIO: "YES" }
@@ -5070,7 +5566,7 @@ ${e.stderr ?? ""}`);
5070
5566
 
5071
5567
  `
5072
5568
  });
5073
- const proc = (0, import_node_child_process8.spawn)(installCmd[0], installCmd.slice(1), {
5569
+ const proc = (0, import_node_child_process9.spawn)(installCmd[0], installCmd.slice(1), {
5074
5570
  cwd: projectPath,
5075
5571
  stdio: ["ignore", "pipe", "pipe"]
5076
5572
  });
@@ -5192,10 +5688,784 @@ function kindOrder(k) {
5192
5688
  return k === "device" ? 0 : k === "simulator" ? 1 : k === "mac" ? 2 : 3;
5193
5689
  }
5194
5690
 
5691
+ // src/commands/CommandDiscovery.ts
5692
+ var import_promises5 = require("fs/promises");
5693
+ var import_node_path7 = require("path");
5694
+ var import_node_crypto = require("crypto");
5695
+ var CACHE_TTL_MS = 5 * 60 * 1e3;
5696
+ var MAX_README_BYTES = 256 * 1024;
5697
+ var SUBPACKAGE_DIRS = ["packages", "apps", "crates", "services"];
5698
+ var MAX_SCAN_PER_DIR = 30;
5699
+ var CommandDiscovery = class {
5700
+ cache = /* @__PURE__ */ new Map();
5701
+ async scan(projectPath, refresh = false) {
5702
+ if (!refresh) {
5703
+ const hit = this.cache.get(projectPath);
5704
+ if (hit && hit.expiresAt > Date.now()) return hit.commands;
5705
+ }
5706
+ const collector = [];
5707
+ await Promise.all([
5708
+ this.scanPackageJson(projectPath, "", collector),
5709
+ this.scanMakefile(projectPath, "", collector),
5710
+ this.scanJustfile(projectPath, "", collector),
5711
+ this.scanCargo(projectPath, "", collector),
5712
+ this.scanCompose(projectPath, "", collector),
5713
+ this.scanReadme(projectPath, "README.md", "readme", collector),
5714
+ this.scanReadme(projectPath, "CLAUDE.md", "claude.md", collector)
5715
+ ]);
5716
+ for (const sub of SUBPACKAGE_DIRS) {
5717
+ const subRoot = (0, import_node_path7.join)(projectPath, sub);
5718
+ let entries;
5719
+ try {
5720
+ entries = await (0, import_promises5.readdir)(subRoot);
5721
+ } catch {
5722
+ continue;
5723
+ }
5724
+ let scanned = 0;
5725
+ for (const name of entries) {
5726
+ if (name.startsWith(".") || scanned >= MAX_SCAN_PER_DIR) continue;
5727
+ const childAbs = (0, import_node_path7.join)(subRoot, name);
5728
+ try {
5729
+ const s = await (0, import_promises5.stat)(childAbs);
5730
+ if (!s.isDirectory()) continue;
5731
+ } catch {
5732
+ continue;
5733
+ }
5734
+ scanned++;
5735
+ const rel = `${sub}/${name}`;
5736
+ await Promise.all([
5737
+ this.scanPackageJson(projectPath, rel, collector),
5738
+ this.scanCargo(projectPath, rel, collector)
5739
+ ]);
5740
+ }
5741
+ }
5742
+ const seen = /* @__PURE__ */ new Set();
5743
+ const deduped = [];
5744
+ for (const c of collector) {
5745
+ if (seen.has(c.id)) continue;
5746
+ seen.add(c.id);
5747
+ deduped.push(c);
5748
+ }
5749
+ deduped.sort((a, b) => {
5750
+ const ca = categoryWeight(a.category) - categoryWeight(b.category);
5751
+ if (ca !== 0) return ca;
5752
+ const sa = sourceWeight(a.source) - sourceWeight(b.source);
5753
+ if (sa !== 0) return sa;
5754
+ return a.title.localeCompare(b.title);
5755
+ });
5756
+ this.cache.set(projectPath, { commands: deduped, expiresAt: Date.now() + CACHE_TTL_MS });
5757
+ return deduped;
5758
+ }
5759
+ invalidate(projectPath) {
5760
+ if (projectPath) this.cache.delete(projectPath);
5761
+ else this.cache.clear();
5762
+ }
5763
+ // ============================================
5764
+ // 各来源扫描器
5765
+ // ============================================
5766
+ async scanPackageJson(rootPath, subDir, out) {
5767
+ const file = subDir ? `${subDir}/package.json` : "package.json";
5768
+ const abs = (0, import_node_path7.join)(rootPath, file);
5769
+ let raw;
5770
+ try {
5771
+ raw = await (0, import_promises5.readFile)(abs, "utf8");
5772
+ } catch {
5773
+ return;
5774
+ }
5775
+ let pkg;
5776
+ try {
5777
+ pkg = JSON.parse(raw);
5778
+ } catch {
5779
+ return;
5780
+ }
5781
+ if (!pkg.scripts) return;
5782
+ for (const [name, script] of Object.entries(pkg.scripts)) {
5783
+ if (typeof script !== "string") continue;
5784
+ const command = subDir ? `npm --workspace=${subDir} run ${name}` : `npm run ${name}`;
5785
+ const title = subDir ? `${pkg.name ?? subDir.split("/").pop()}: ${name}` : name;
5786
+ out.push(makeCommand({
5787
+ title,
5788
+ command,
5789
+ cwd: "",
5790
+ source: "package.json",
5791
+ sourceFile: file,
5792
+ description: script,
5793
+ category: classifyByName(name) ?? classifyByCommand(script)
5794
+ }));
5795
+ }
5796
+ }
5797
+ async scanMakefile(rootPath, subDir, out) {
5798
+ const file = subDir ? `${subDir}/Makefile` : "Makefile";
5799
+ const abs = (0, import_node_path7.join)(rootPath, file);
5800
+ let raw;
5801
+ try {
5802
+ raw = await (0, import_promises5.readFile)(abs, "utf8");
5803
+ } catch {
5804
+ return;
5805
+ }
5806
+ const lines = raw.split("\n");
5807
+ let lastComment;
5808
+ const targetRegex = /^([a-zA-Z][a-zA-Z0-9_-]*)\s*:(?!=)/;
5809
+ for (const line of lines) {
5810
+ const trim = line.trim();
5811
+ if (trim.startsWith("#")) {
5812
+ lastComment = trim.replace(/^#+\s?/, "").trim() || void 0;
5813
+ continue;
5814
+ }
5815
+ if (trim === "") {
5816
+ lastComment = void 0;
5817
+ continue;
5818
+ }
5819
+ const match = targetRegex.exec(line);
5820
+ if (!match) {
5821
+ lastComment = void 0;
5822
+ continue;
5823
+ }
5824
+ const target = match[1];
5825
+ if (target === ".PHONY" || target === "default" && trim.startsWith("default:")) continue;
5826
+ out.push(makeCommand({
5827
+ title: target,
5828
+ command: `make ${target}`,
5829
+ cwd: subDir,
5830
+ source: "makefile",
5831
+ sourceFile: file,
5832
+ description: lastComment,
5833
+ category: classifyByName(target)
5834
+ }));
5835
+ lastComment = void 0;
5836
+ }
5837
+ }
5838
+ async scanJustfile(rootPath, subDir, out) {
5839
+ const file = subDir ? `${subDir}/justfile` : "justfile";
5840
+ const abs = (0, import_node_path7.join)(rootPath, file);
5841
+ let raw;
5842
+ try {
5843
+ raw = await (0, import_promises5.readFile)(abs, "utf8");
5844
+ } catch {
5845
+ return;
5846
+ }
5847
+ const lines = raw.split("\n");
5848
+ let lastComment;
5849
+ const recipeRegex = /^([a-zA-Z][a-zA-Z0-9_-]*)\s*(?:[a-zA-Z0-9_=" ]*)?\s*:/;
5850
+ for (const line of lines) {
5851
+ const trim = line.trim();
5852
+ if (trim.startsWith("#")) {
5853
+ lastComment = trim.replace(/^#+\s?/, "").trim() || void 0;
5854
+ continue;
5855
+ }
5856
+ if (trim === "") {
5857
+ lastComment = void 0;
5858
+ continue;
5859
+ }
5860
+ if (line.startsWith(" ") || line.startsWith(" ")) continue;
5861
+ const match = recipeRegex.exec(line);
5862
+ if (!match) {
5863
+ lastComment = void 0;
5864
+ continue;
5865
+ }
5866
+ const recipe = match[1];
5867
+ out.push(makeCommand({
5868
+ title: recipe,
5869
+ command: `just ${recipe}`,
5870
+ cwd: subDir,
5871
+ source: "justfile",
5872
+ sourceFile: file,
5873
+ description: lastComment,
5874
+ category: classifyByName(recipe)
5875
+ }));
5876
+ lastComment = void 0;
5877
+ }
5878
+ }
5879
+ async scanCargo(rootPath, subDir, out) {
5880
+ const file = subDir ? `${subDir}/Cargo.toml` : "Cargo.toml";
5881
+ const abs = (0, import_node_path7.join)(rootPath, file);
5882
+ try {
5883
+ await (0, import_promises5.stat)(abs);
5884
+ } catch {
5885
+ return;
5886
+ }
5887
+ const presets = [
5888
+ { title: "cargo build", command: "cargo build", category: "build", description: "Compile in debug mode" },
5889
+ { title: "cargo build --release", command: "cargo build --release", category: "build", description: "Compile in release mode" },
5890
+ { title: "cargo run", command: "cargo run", category: "dev", description: "Build and run" },
5891
+ { title: "cargo test", command: "cargo test", category: "test", description: "Run all tests" },
5892
+ { title: "cargo check", command: "cargo check", category: "lint", description: "Type-check without producing binary" },
5893
+ { title: "cargo clippy", command: "cargo clippy", category: "lint", description: "Lint with clippy" },
5894
+ { title: "cargo fmt", command: "cargo fmt", category: "lint", description: "Format source" }
5895
+ ];
5896
+ for (const p of presets) {
5897
+ out.push(makeCommand({
5898
+ title: subDir ? `${subDir.split("/").pop()}: ${p.title}` : p.title,
5899
+ command: p.command,
5900
+ cwd: subDir,
5901
+ source: "cargo",
5902
+ sourceFile: file,
5903
+ description: p.description,
5904
+ category: p.category
5905
+ }));
5906
+ }
5907
+ }
5908
+ async scanCompose(rootPath, subDir, out) {
5909
+ for (const name of ["docker-compose.yml", "docker-compose.yaml", "compose.yml", "compose.yaml"]) {
5910
+ const file = subDir ? `${subDir}/${name}` : name;
5911
+ try {
5912
+ await (0, import_promises5.stat)((0, import_node_path7.join)(rootPath, file));
5913
+ } catch {
5914
+ continue;
5915
+ }
5916
+ const presets = [
5917
+ { title: "docker compose up", cmd: "docker compose up", cat: "dev", desc: "Start services" },
5918
+ { title: "docker compose up -d", cmd: "docker compose up -d", cat: "dev", desc: "Start services in background" },
5919
+ { title: "docker compose down", cmd: "docker compose down", cat: "other", desc: "Stop and remove services" },
5920
+ { title: "docker compose build", cmd: "docker compose build", cat: "build", desc: "Build service images" },
5921
+ { title: "docker compose logs -f", cmd: "docker compose logs -f", cat: "other", desc: "Tail service logs" }
5922
+ ];
5923
+ for (const p of presets) {
5924
+ out.push(makeCommand({
5925
+ title: p.title,
5926
+ command: p.cmd,
5927
+ cwd: subDir,
5928
+ source: "compose",
5929
+ sourceFile: file,
5930
+ description: p.desc,
5931
+ category: p.cat
5932
+ }));
5933
+ }
5934
+ return;
5935
+ }
5936
+ }
5937
+ async scanReadme(rootPath, fileName, source, out) {
5938
+ const abs = (0, import_node_path7.join)(rootPath, fileName);
5939
+ let raw;
5940
+ try {
5941
+ const s = await (0, import_promises5.stat)(abs);
5942
+ if (s.size > MAX_README_BYTES) return;
5943
+ raw = await (0, import_promises5.readFile)(abs, "utf8");
5944
+ } catch {
5945
+ return;
5946
+ }
5947
+ const fenceRegex = /```(?:bash|sh|shell|zsh)\s*\n([\s\S]*?)```/gi;
5948
+ let match;
5949
+ while ((match = fenceRegex.exec(raw)) !== null) {
5950
+ const block = match[1];
5951
+ const blockLines = block.split("\n");
5952
+ let blockHeading;
5953
+ const beforeText = raw.slice(0, match.index).split("\n").reverse();
5954
+ for (const prev of beforeText) {
5955
+ const t2 = prev.trim();
5956
+ if (t2 === "") continue;
5957
+ const head = /^#{1,6}\s+(.+)$/.exec(t2);
5958
+ if (head) blockHeading = head[1].replace(/[#*`]/g, "").trim();
5959
+ break;
5960
+ }
5961
+ const merged = [];
5962
+ let pending = "";
5963
+ for (const rawLine of blockLines) {
5964
+ if (rawLine.trimEnd().endsWith("\\")) {
5965
+ pending += rawLine.trimEnd().slice(0, -1) + " ";
5966
+ continue;
5967
+ }
5968
+ merged.push(pending + rawLine);
5969
+ pending = "";
5970
+ }
5971
+ if (pending) merged.push(pending);
5972
+ for (const rawLine of merged) {
5973
+ const cmd = sanitizeBashLine(rawLine);
5974
+ if (!cmd) continue;
5975
+ const { command: cleanCmd, inlineComment } = splitInlineComment(cmd);
5976
+ const { cwd: cdCwd, command: finalCmd } = splitCdPrefix(cleanCmd);
5977
+ const title = synthesizeTitle(finalCmd);
5978
+ out.push(makeCommand({
5979
+ title,
5980
+ command: finalCmd,
5981
+ cwd: cdCwd,
5982
+ source,
5983
+ sourceFile: fileName,
5984
+ description: inlineComment ?? blockHeading,
5985
+ category: classifyByCommand(finalCmd)
5986
+ }));
5987
+ }
5988
+ }
5989
+ }
5990
+ };
5991
+ function makeCommand(input) {
5992
+ const id = (0, import_node_crypto.createHash)("sha1").update(`${input.source}|${input.sourceFile}|${input.command}|${input.cwd}`).digest("hex").slice(0, 12);
5993
+ return {
5994
+ id,
5995
+ title: input.title,
5996
+ command: input.command,
5997
+ cwd: input.cwd,
5998
+ source: input.source,
5999
+ sourceFile: input.sourceFile,
6000
+ description: input.description,
6001
+ category: input.category ?? classifyByCommand(input.command) ?? "other"
6002
+ };
6003
+ }
6004
+ function sanitizeBashLine(line) {
6005
+ let l = line.trim();
6006
+ if (!l) return null;
6007
+ if (l.startsWith("#")) return null;
6008
+ l = l.replace(/^[$>]\s*/, "");
6009
+ if (!l) return null;
6010
+ if (/^[A-Z_]+=/.test(l) && !/\s/.test(l)) return null;
6011
+ if (l === "EOF" || l === "EOT") return null;
6012
+ if (l.startsWith("//") || l.startsWith("//#")) return null;
6013
+ if (l.length > 400) return null;
6014
+ if (/<.+>/.test(l) && /your[-_]/.test(l.toLowerCase())) return null;
6015
+ return l;
6016
+ }
6017
+ function synthesizeTitle(cmd) {
6018
+ let work = cmd;
6019
+ while (/^[A-Z_][A-Z0-9_]*=/.test(work)) {
6020
+ const m = /^[A-Z_][A-Z0-9_]*=(?:"[^"]*"|'[^']*'|\S+)\s+/.exec(work);
6021
+ if (!m) break;
6022
+ work = work.slice(m[0].length);
6023
+ }
6024
+ const cdMatch = /^cd\s+\S+\s*&&\s*(.+)$/.exec(work);
6025
+ if (cdMatch) work = cdMatch[1];
6026
+ const tokens = work.split(/\s+/).filter(Boolean);
6027
+ const head = tokens.slice(0, 3).join(" ");
6028
+ return head.length > 60 ? head.slice(0, 60) + "\u2026" : head;
6029
+ }
6030
+ function splitCdPrefix(cmd) {
6031
+ const m = /^cd\s+(\S+)\s*&&\s*(.+)$/.exec(cmd);
6032
+ if (!m) return { cwd: "", command: cmd };
6033
+ const path2 = m[1];
6034
+ if (!path2) return { cwd: "", command: cmd };
6035
+ if (path2.startsWith("/") || path2.startsWith("~") || path2.startsWith("-")) {
6036
+ return { cwd: "", command: cmd };
6037
+ }
6038
+ if (path2.split("/").some((seg) => seg === "..")) {
6039
+ return { cwd: "", command: cmd };
6040
+ }
6041
+ return { cwd: path2, command: m[2].trim() };
6042
+ }
6043
+ function splitInlineComment(line) {
6044
+ let inSingle = false;
6045
+ let inDouble = false;
6046
+ for (let i = 0; i < line.length; i++) {
6047
+ const ch = line[i];
6048
+ if (ch === "'" && !inDouble) inSingle = !inSingle;
6049
+ else if (ch === '"' && !inSingle) inDouble = !inDouble;
6050
+ else if (ch === "#" && !inSingle && !inDouble && (i === 0 || /\s/.test(line[i - 1]))) {
6051
+ const cmd = line.slice(0, i).trim();
6052
+ const comment = line.slice(i + 1).trim();
6053
+ return { command: cmd, inlineComment: comment.length > 0 ? comment : void 0 };
6054
+ }
6055
+ }
6056
+ return { command: line };
6057
+ }
6058
+ function classifyByName(name) {
6059
+ const lower = name.toLowerCase();
6060
+ if (/(^|[:_-])(build|compile|bundle|prebuild)([:_-]|$)/.test(lower)) return "build";
6061
+ if (/(^|[:_-])(test|spec|jest|vitest|e2e)([:_-]|$)/.test(lower)) return "test";
6062
+ if (/(^|[:_-])(dev|start|serve|watch|run)([:_-]|$)/.test(lower)) return "dev";
6063
+ if (/(^|[:_-])(lint|format|fmt|check|typecheck)([:_-]|$)/.test(lower)) return "lint";
6064
+ if (/(^|[:_-])(install|setup|init|bootstrap)([:_-]|$)/.test(lower)) return "install";
6065
+ if (/(^|[:_-])(deploy|publish|release|ship)([:_-]|$)/.test(lower)) return "deploy";
6066
+ return void 0;
6067
+ }
6068
+ function classifyByCommand(cmd) {
6069
+ const lower = cmd.toLowerCase();
6070
+ if (/\b(build|compile|bundle|prebuild|tsup|webpack|esbuild|vite build|next build)\b/.test(lower)) return "build";
6071
+ if (/\b(test|jest|vitest|mocha|pytest|cargo test|go test)\b/.test(lower)) return "test";
6072
+ if (/\b(dev|start|serve|watch|nodemon|tsx watch|next dev|expo start)\b/.test(lower)) return "dev";
6073
+ if (/\b(lint|eslint|tsc|tslint|fmt|format|prettier|clippy)\b/.test(lower)) return "lint";
6074
+ if (/\b(install|setup|bootstrap)\b/.test(lower) && !/\binstall\s+/.test(lower)) return "install";
6075
+ if (/^npm install\b|^pnpm install\b|^yarn install\b|^yarn\s*$|^pnpm\s*$/.test(lower)) return "install";
6076
+ if (/\b(deploy|publish|release)\b/.test(lower)) return "deploy";
6077
+ return "other";
6078
+ }
6079
+ function categoryWeight(c) {
6080
+ return {
6081
+ dev: 0,
6082
+ build: 1,
6083
+ test: 2,
6084
+ lint: 3,
6085
+ install: 4,
6086
+ deploy: 5,
6087
+ other: 6
6088
+ }[c];
6089
+ }
6090
+ function sourceWeight(s) {
6091
+ return {
6092
+ "package.json": 0,
6093
+ makefile: 1,
6094
+ justfile: 2,
6095
+ taskfile: 3,
6096
+ cargo: 4,
6097
+ compose: 5,
6098
+ readme: 6,
6099
+ "claude.md": 7
6100
+ }[s];
6101
+ }
6102
+
6103
+ // src/git/GitExecutor.ts
6104
+ var import_node_child_process10 = require("child_process");
6105
+ var import_node_util2 = require("util");
6106
+ var import_uuid7 = require("uuid");
6107
+ var execAsync2 = (0, import_node_util2.promisify)(import_node_child_process10.exec);
6108
+ var STATUS_TIMEOUT_MS = 15e3;
6109
+ var COMMIT_TIMEOUT_MS = 6e4;
6110
+ var PUSH_TIMEOUT_MS = 5 * 60 * 1e3;
6111
+ var GitExecutor = class {
6112
+ eventCallbacks = [];
6113
+ onEvent(callback) {
6114
+ this.eventCallbacks.push(callback);
6115
+ return () => {
6116
+ const idx = this.eventCallbacks.indexOf(callback);
6117
+ if (idx !== -1) this.eventCallbacks.splice(idx, 1);
6118
+ };
6119
+ }
6120
+ emit(event) {
6121
+ for (const cb of this.eventCallbacks) {
6122
+ try {
6123
+ cb(event);
6124
+ } catch (err) {
6125
+ console.error("[GitExecutor] Event callback error:", err);
6126
+ }
6127
+ }
6128
+ }
6129
+ async detectStatus(projectPath) {
6130
+ const opts = { cwd: projectPath, timeout: STATUS_TIMEOUT_MS, maxBuffer: 4 * 1024 * 1024 };
6131
+ try {
6132
+ await execAsync2("git rev-parse --is-inside-work-tree", opts);
6133
+ } catch {
6134
+ return { isRepo: false, hasRemote: false, changes: [] };
6135
+ }
6136
+ let root;
6137
+ try {
6138
+ const { stdout } = await execAsync2("git rev-parse --show-toplevel", opts);
6139
+ root = stdout.trim();
6140
+ } catch {
6141
+ }
6142
+ let branch;
6143
+ try {
6144
+ const { stdout } = await execAsync2("git symbolic-ref --short HEAD", opts);
6145
+ branch = stdout.trim();
6146
+ } catch {
6147
+ branch = void 0;
6148
+ }
6149
+ let upstream;
6150
+ try {
6151
+ const { stdout } = await execAsync2("git rev-parse --abbrev-ref --symbolic-full-name @{u}", opts);
6152
+ upstream = stdout.trim();
6153
+ } catch {
6154
+ }
6155
+ let hasRemote = false;
6156
+ try {
6157
+ const { stdout } = await execAsync2("git remote", opts);
6158
+ hasRemote = stdout.trim().length > 0;
6159
+ } catch {
6160
+ }
6161
+ let ahead;
6162
+ let behind;
6163
+ if (upstream) {
6164
+ try {
6165
+ const { stdout } = await execAsync2("git rev-list --left-right --count HEAD...@{u}", opts);
6166
+ const [a, b] = stdout.trim().split(/\s+/);
6167
+ ahead = Number(a);
6168
+ behind = Number(b);
6169
+ } catch {
6170
+ }
6171
+ }
6172
+ const changes = await this.parsePorcelain(projectPath);
6173
+ return {
6174
+ isRepo: true,
6175
+ root,
6176
+ branch,
6177
+ upstream,
6178
+ hasRemote,
6179
+ changes,
6180
+ ahead,
6181
+ behind
6182
+ };
6183
+ }
6184
+ async parsePorcelain(projectPath) {
6185
+ let stdout;
6186
+ try {
6187
+ const r = await execAsync2("git status --porcelain=v1 -z", {
6188
+ cwd: projectPath,
6189
+ timeout: STATUS_TIMEOUT_MS,
6190
+ maxBuffer: 8 * 1024 * 1024
6191
+ });
6192
+ stdout = r.stdout;
6193
+ } catch {
6194
+ return [];
6195
+ }
6196
+ const changes = [];
6197
+ const records = stdout.split("\0");
6198
+ for (let i = 0; i < records.length; i++) {
6199
+ const rec = records[i];
6200
+ if (!rec) continue;
6201
+ if (rec.length < 3) continue;
6202
+ const x = rec.charAt(0);
6203
+ const y = rec.charAt(1);
6204
+ const path2 = rec.slice(3);
6205
+ const isRename = x === "R" || x === "C";
6206
+ if (isRename) {
6207
+ i += 1;
6208
+ }
6209
+ const untracked = x === "?" && y === "?";
6210
+ const staged = !untracked && x !== " " && x !== "?";
6211
+ changes.push({
6212
+ path: path2,
6213
+ staged,
6214
+ untracked,
6215
+ code: `${x}${y}`
6216
+ });
6217
+ }
6218
+ return changes;
6219
+ }
6220
+ /**
6221
+ * 执行 commit(可选连带 push)。
6222
+ * - 若提供 files:先 git add 这些路径
6223
+ * - 若未提供 files:默认 git add -A(提交所有变更)
6224
+ */
6225
+ async commit(sessionId, projectPath, message, files, alsoPush) {
6226
+ const opId = (0, import_uuid7.v4)();
6227
+ this.runSequence(sessionId, opId, "commit", projectPath, [
6228
+ files && files.length > 0 ? ["git", "add", "--", ...files] : ["git", "add", "-A"],
6229
+ ["git", "commit", "-m", message]
6230
+ ], COMMIT_TIMEOUT_MS).then(async (ok) => {
6231
+ if (ok && alsoPush) {
6232
+ await this.runSequence(sessionId, opId, "push", projectPath, [
6233
+ ["git", "push"]
6234
+ ], PUSH_TIMEOUT_MS);
6235
+ }
6236
+ }).catch((err) => {
6237
+ console.error("[GitExecutor] commit error:", err);
6238
+ });
6239
+ return opId;
6240
+ }
6241
+ async push(sessionId, projectPath) {
6242
+ const opId = (0, import_uuid7.v4)();
6243
+ this.runSequence(sessionId, opId, "push", projectPath, [
6244
+ ["git", "push"]
6245
+ ], PUSH_TIMEOUT_MS).catch((err) => {
6246
+ console.error("[GitExecutor] push error:", err);
6247
+ });
6248
+ return opId;
6249
+ }
6250
+ /**
6251
+ * 顺序执行一组命令,任一失败则停止。返回是否全部成功。
6252
+ * 每条命令的输出和最后一条命令的退出事件统一打到同一 phase。
6253
+ */
6254
+ async runSequence(sessionId, opId, phase, projectPath, commands, timeoutMs) {
6255
+ let lastCode = 0;
6256
+ let lastSignal = null;
6257
+ for (const cmd of commands) {
6258
+ const { code, signal } = await this.runOne(sessionId, opId, phase, projectPath, cmd, timeoutMs);
6259
+ lastCode = code;
6260
+ lastSignal = signal;
6261
+ if (code !== 0) break;
6262
+ }
6263
+ this.emit({ type: "git_exit", sessionId, opId, phase, code: lastCode, signal: lastSignal });
6264
+ return lastCode === 0;
6265
+ }
6266
+ runOne(sessionId, opId, phase, projectPath, cmd, timeoutMs) {
6267
+ return new Promise((resolve) => {
6268
+ const display = cmd.map((p) => /\s/.test(p) ? `"${p}"` : p).join(" ");
6269
+ this.emit({
6270
+ type: "git_output",
6271
+ sessionId,
6272
+ opId,
6273
+ phase,
6274
+ stream: "stdout",
6275
+ data: `$ ${display}
6276
+ `
6277
+ });
6278
+ let proc;
6279
+ try {
6280
+ proc = (0, import_node_child_process10.spawn)(cmd[0], cmd.slice(1), {
6281
+ cwd: projectPath,
6282
+ stdio: ["ignore", "pipe", "pipe"],
6283
+ env: { ...process.env, GIT_TERMINAL_PROMPT: "0" }
6284
+ });
6285
+ } catch (err) {
6286
+ const msg = err instanceof Error ? err.message : String(err);
6287
+ this.emit({ type: "git_output", sessionId, opId, phase, stream: "stderr", data: `[spawn error] ${msg}
6288
+ ` });
6289
+ resolve({ code: 1, signal: null });
6290
+ return;
6291
+ }
6292
+ proc.stdout?.on("data", (chunk) => {
6293
+ this.emit({ type: "git_output", sessionId, opId, phase, stream: "stdout", data: chunk.toString() });
6294
+ });
6295
+ proc.stderr?.on("data", (chunk) => {
6296
+ this.emit({ type: "git_output", sessionId, opId, phase, stream: "stderr", data: chunk.toString() });
6297
+ });
6298
+ proc.on("error", (err) => {
6299
+ this.emit({ type: "git_output", sessionId, opId, phase, stream: "stderr", data: `[error] ${err.message}
6300
+ ` });
6301
+ });
6302
+ const timer = setTimeout(() => {
6303
+ try {
6304
+ proc.kill("SIGTERM");
6305
+ } catch {
6306
+ }
6307
+ }, timeoutMs);
6308
+ proc.on("exit", (code, signal) => {
6309
+ clearTimeout(timer);
6310
+ resolve({ code, signal });
6311
+ });
6312
+ });
6313
+ }
6314
+ };
6315
+
6316
+ // src/scheduling/ScheduledSessionManager.ts
6317
+ var import_promises6 = require("fs/promises");
6318
+ var import_node_os8 = require("os");
6319
+ var import_node_path8 = require("path");
6320
+ var import_uuid8 = require("uuid");
6321
+ var MAX_TIMEOUT_MS = 2147483647;
6322
+ var ScheduledSessionManager = class {
6323
+ tasks = /* @__PURE__ */ new Map();
6324
+ storeFile;
6325
+ onFire;
6326
+ onChange;
6327
+ onFired;
6328
+ persistTimer = null;
6329
+ constructor(opts) {
6330
+ this.storeFile = opts.storeFile ?? (0, import_node_path8.join)((0, import_node_os8.homedir)(), ".sessix", "scheduled-sessions.json");
6331
+ this.onFire = opts.onFire;
6332
+ this.onChange = opts.onChange;
6333
+ this.onFired = opts.onFired;
6334
+ }
6335
+ /** 启动时从磁盘恢复任务表,已过期的立刻触发 */
6336
+ async load() {
6337
+ let raw;
6338
+ try {
6339
+ raw = await (0, import_promises6.readFile)(this.storeFile, "utf8");
6340
+ } catch {
6341
+ return;
6342
+ }
6343
+ let parsed;
6344
+ try {
6345
+ parsed = JSON.parse(raw);
6346
+ } catch {
6347
+ return;
6348
+ }
6349
+ if (!Array.isArray(parsed)) return;
6350
+ for (const item of parsed) {
6351
+ if (!isValidTask(item)) continue;
6352
+ this.scheduleTimer(item);
6353
+ }
6354
+ }
6355
+ /** 注册一个定时任务(payload 由调用方校验) */
6356
+ schedule(scheduledAt, payload) {
6357
+ const task = {
6358
+ id: (0, import_uuid8.v4)(),
6359
+ scheduledAt,
6360
+ createdAt: Date.now(),
6361
+ payload
6362
+ };
6363
+ this.scheduleTimer(task);
6364
+ this.persist();
6365
+ this.notifyChange();
6366
+ return task;
6367
+ }
6368
+ /** 取消任务,返回是否成功 */
6369
+ cancel(id) {
6370
+ const entry = this.tasks.get(id);
6371
+ if (!entry) return false;
6372
+ clearTimeout(entry.timer);
6373
+ this.tasks.delete(id);
6374
+ this.persist();
6375
+ this.notifyChange();
6376
+ return true;
6377
+ }
6378
+ /** 列出所有未触发的任务(按时间升序) */
6379
+ list() {
6380
+ return [...this.tasks.values()].map((e) => e.task).sort((a, b) => a.scheduledAt - b.scheduledAt);
6381
+ }
6382
+ /** 优雅关闭(清空定时器,不删除磁盘任务) */
6383
+ destroy() {
6384
+ for (const { timer } of this.tasks.values()) clearTimeout(timer);
6385
+ this.tasks.clear();
6386
+ if (this.persistTimer) {
6387
+ clearTimeout(this.persistTimer);
6388
+ this.persistTimer = null;
6389
+ }
6390
+ }
6391
+ // ============================================
6392
+ // 内部
6393
+ // ============================================
6394
+ scheduleTimer(task) {
6395
+ const delay = Math.max(0, task.scheduledAt - Date.now());
6396
+ const armDelay = Math.min(delay, MAX_TIMEOUT_MS);
6397
+ const timer = setTimeout(() => {
6398
+ const remaining = task.scheduledAt - Date.now();
6399
+ if (remaining > 1e3) {
6400
+ this.scheduleTimer(task);
6401
+ return;
6402
+ }
6403
+ this.fire(task).catch((err) => {
6404
+ console.error("[ScheduledSessionManager] fire error:", err);
6405
+ });
6406
+ }, armDelay);
6407
+ this.tasks.set(task.id, { task, timer });
6408
+ }
6409
+ async fire(task) {
6410
+ const entry = this.tasks.get(task.id);
6411
+ if (!entry) return;
6412
+ clearTimeout(entry.timer);
6413
+ this.tasks.delete(task.id);
6414
+ this.persist();
6415
+ this.notifyChange();
6416
+ try {
6417
+ const result = await this.onFire(task);
6418
+ this.onFired?.({ id: task.id, sessionId: result.sessionId });
6419
+ } catch (err) {
6420
+ const message = err instanceof Error ? err.message : String(err);
6421
+ console.error(`[ScheduledSessionManager] fire failed for task ${task.id}: ${message}`);
6422
+ this.onFired?.({ id: task.id, error: message });
6423
+ }
6424
+ }
6425
+ notifyChange() {
6426
+ if (!this.onChange) return;
6427
+ this.onChange(this.list());
6428
+ }
6429
+ /** 防抖持久化(500ms) */
6430
+ persist() {
6431
+ if (this.persistTimer) clearTimeout(this.persistTimer);
6432
+ this.persistTimer = setTimeout(() => {
6433
+ this.persistTimer = null;
6434
+ const tasks = [...this.tasks.values()].map((e) => e.task);
6435
+ (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) => {
6436
+ console.error("[ScheduledSessionManager] persist error:", err);
6437
+ });
6438
+ }, 500);
6439
+ }
6440
+ };
6441
+ function isValidTask(value) {
6442
+ if (!value || typeof value !== "object") return false;
6443
+ const v = value;
6444
+ if (typeof v.id !== "string" || typeof v.scheduledAt !== "number" || typeof v.createdAt !== "number") {
6445
+ return false;
6446
+ }
6447
+ const payload = v.payload;
6448
+ if (!payload || typeof payload !== "object") return false;
6449
+ const p = payload;
6450
+ if (p.kind === "create") {
6451
+ return typeof p.projectPath === "string" && typeof p.message === "string";
6452
+ }
6453
+ if (p.kind === "send") {
6454
+ return typeof p.sessionId === "string" && typeof p.message === "string";
6455
+ }
6456
+ return false;
6457
+ }
6458
+
5195
6459
  // src/utils/cliCapabilities.ts
5196
- var import_node_child_process9 = require("child_process");
6460
+ var import_node_child_process11 = require("child_process");
6461
+ var DEFAULT_MODELS = [
6462
+ { value: "opus", label: "Opus 4.7", sublabel: "Most capable for ambitious work" },
6463
+ { value: "sonnet", label: "Sonnet 4.6", sublabel: "Most efficient for everyday tasks" },
6464
+ { value: "haiku", label: "Haiku 4.5", sublabel: "Fastest for quick answers" }
6465
+ ];
5197
6466
  var DEFAULT_CAPABILITIES = {
5198
- effortLevels: ["low", "medium", "high", "xhigh", "max"]
6467
+ effortLevels: ["low", "medium", "high", "xhigh", "max"],
6468
+ models: DEFAULT_MODELS
5199
6469
  };
5200
6470
  async function parseCliCapabilities() {
5201
6471
  const claudePath = findClaudePath();
@@ -5221,7 +6491,7 @@ async function parseCliCapabilities() {
5221
6491
  }
5222
6492
  function runCli(path2, args) {
5223
6493
  return new Promise((resolve) => {
5224
- (0, import_node_child_process9.execFile)(path2, args, { timeout: 5e3 }, (err, stdout) => {
6494
+ (0, import_node_child_process11.execFile)(path2, args, { timeout: 5e3 }, (err, stdout) => {
5225
6495
  if (err) {
5226
6496
  console.warn(`[CliCapabilities] Failed to run ${path2} ${args.join(" ")}:`, err.message);
5227
6497
  resolve(null);
@@ -5235,11 +6505,11 @@ function runCli(path2, args) {
5235
6505
  // src/server.ts
5236
6506
  var WS_PORT = 3745;
5237
6507
  var HTTP_PORT = 3746;
5238
- var execAsync2 = (0, import_node_util2.promisify)(import_node_child_process10.exec);
6508
+ var execAsync3 = (0, import_node_util3.promisify)(import_node_child_process12.exec);
5239
6509
  async function killPortProcess(port) {
5240
6510
  try {
5241
6511
  if (isWindows) {
5242
- const { stdout } = await execAsync2(
6512
+ const { stdout } = await execAsync3(
5243
6513
  `netstat -ano | findstr :${port} | findstr LISTENING`
5244
6514
  );
5245
6515
  const pids = /* @__PURE__ */ new Set();
@@ -5249,20 +6519,53 @@ async function killPortProcess(port) {
5249
6519
  if (pid && /^\d+$/.test(pid) && pid !== "0") pids.add(pid);
5250
6520
  }
5251
6521
  for (const pid of pids) {
5252
- await execAsync2(`taskkill /PID ${pid} /F`).catch(() => {
6522
+ await execAsync3(`taskkill /PID ${pid} /F`).catch(() => {
5253
6523
  });
5254
6524
  }
5255
6525
  } else {
5256
- const { stdout } = await execAsync2(`lsof -ti :${port}`);
6526
+ const { stdout } = await execAsync3(`lsof -ti :${port}`);
5257
6527
  const pids = stdout.trim().split("\n").filter((p) => p && /^\d+$/.test(p));
5258
6528
  if (pids.length > 0) {
5259
- await execAsync2(`kill -9 ${pids.join(" ")}`);
6529
+ await execAsync3(`kill -9 ${pids.join(" ")}`);
5260
6530
  }
5261
6531
  }
5262
6532
  await new Promise((resolve) => setTimeout(resolve, 600));
5263
6533
  } catch {
5264
6534
  }
5265
6535
  }
6536
+ async function loadApnsConfigFromFile() {
6537
+ const path2 = (0, import_node_path9.join)((0, import_node_os9.homedir)(), ".sessix", "apns.json");
6538
+ try {
6539
+ const raw = await (0, import_promises7.readFile)(path2, "utf8");
6540
+ const cfg = JSON.parse(raw);
6541
+ if (typeof cfg.teamId !== "string" || typeof cfg.keyId !== "string" || typeof cfg.authKeyPath !== "string") {
6542
+ console.warn(`[Server] \u26A0\uFE0F ${path2} \u7F3A\u5C11\u5FC5\u9700\u5B57\u6BB5 (teamId / keyId / authKeyPath)\uFF0CLA \u540E\u53F0\u63A8\u9001\u5DF2\u7981\u7528`);
6543
+ return null;
6544
+ }
6545
+ try {
6546
+ await (0, import_promises7.readFile)(cfg.authKeyPath, "utf8");
6547
+ } catch (err) {
6548
+ console.warn(`[Server] \u26A0\uFE0F \u65E0\u6CD5\u8BFB\u53D6 APNs Auth Key: ${cfg.authKeyPath}`, err);
6549
+ return null;
6550
+ }
6551
+ console.log(`[Server] \u2705 \u5DF2\u52A0\u8F7D APNs \u914D\u7F6E (${path2})`);
6552
+ console.log(`[Server] teamId=${cfg.teamId} keyId=${cfg.keyId} sandbox=${cfg.sandbox === true}`);
6553
+ return {
6554
+ teamId: cfg.teamId,
6555
+ keyId: cfg.keyId,
6556
+ authKeyPath: cfg.authKeyPath,
6557
+ sandbox: cfg.sandbox === true
6558
+ };
6559
+ } catch (err) {
6560
+ const code = err.code;
6561
+ if (code === "ENOENT") {
6562
+ console.log(`[Server] \u2139\uFE0F ${path2} \u4E0D\u5B58\u5728\uFF0CLA \u540E\u53F0\u63A8\u9001\u672A\u542F\u7528\uFF08\u524D\u53F0 App \u4ECD\u80FD\u7528\u672C\u5730 Activity.update\uFF09`);
6563
+ } else {
6564
+ console.warn(`[Server] \u26A0\uFE0F \u8BFB\u53D6 ${path2} \u5931\u8D25:`, err);
6565
+ }
6566
+ return null;
6567
+ }
6568
+ }
5266
6569
  async function createWithRetry(label, port, factory) {
5267
6570
  try {
5268
6571
  return await factory();
@@ -5277,8 +6580,9 @@ async function createWithRetry(label, port, factory) {
5277
6580
  }
5278
6581
  }
5279
6582
  async function start(opts = {}) {
5280
- const configDir = (0, import_node_path7.join)((0, import_node_os8.homedir)(), ".sessix");
5281
- const tokenFile = (0, import_node_path7.join)(configDir, "token");
6583
+ fixShellPath();
6584
+ const configDir = (0, import_node_path9.join)((0, import_node_os9.homedir)(), ".sessix");
6585
+ const tokenFile = (0, import_node_path9.join)(configDir, "token");
5282
6586
  let token;
5283
6587
  if (opts.token !== void 0) {
5284
6588
  token = opts.token;
@@ -5288,11 +6592,11 @@ async function start(opts = {}) {
5288
6592
  token = envToken;
5289
6593
  } else {
5290
6594
  try {
5291
- token = (await (0, import_promises5.readFile)(tokenFile, "utf8")).trim();
6595
+ token = (await (0, import_promises7.readFile)(tokenFile, "utf8")).trim();
5292
6596
  } catch {
5293
- token = (0, import_uuid7.v4)();
5294
- await (0, import_promises5.mkdir)(configDir, { recursive: true });
5295
- await (0, import_promises5.writeFile)(tokenFile, token, "utf8");
6597
+ token = (0, import_uuid9.v4)();
6598
+ await (0, import_promises7.mkdir)(configDir, { recursive: true });
6599
+ await (0, import_promises7.writeFile)(tokenFile, token, "utf8");
5296
6600
  }
5297
6601
  }
5298
6602
  }
@@ -5300,6 +6604,9 @@ async function start(opts = {}) {
5300
6604
  const sessionManager = new SessionManager(providerFactory);
5301
6605
  const terminalExecutor = new TerminalExecutor();
5302
6606
  const xcodeBuildExecutor = new XcodeBuildExecutor();
6607
+ const commandDiscovery = new CommandDiscovery();
6608
+ const gitExecutor = new GitExecutor();
6609
+ let scheduledManager = null;
5303
6610
  const approvalProxy = await createWithRetry(
5304
6611
  "ApprovalProxy",
5305
6612
  HTTP_PORT,
@@ -5322,9 +6629,10 @@ async function start(opts = {}) {
5322
6629
  const notificationService = new NotificationService(sessionManager, expoChannel);
5323
6630
  notificationService.addChannel("expo", expoChannel, opts.enableExpoPush !== false);
5324
6631
  notificationService.addChannel("mac", new DesktopNotificationChannel(), opts.enableMacNotification !== false);
5325
- if (opts.activityPush) {
6632
+ const activityPushOpts = opts.activityPush ?? await loadApnsConfigFromFile();
6633
+ if (activityPushOpts) {
5326
6634
  try {
5327
- const activityChannel = new ActivityPushChannel(opts.activityPush);
6635
+ const activityChannel = new ActivityPushChannel(activityPushOpts);
5328
6636
  notificationService.setActivityPushChannel(activityChannel);
5329
6637
  console.log(`[Server] ${t("server.activityPushEnabled")}`);
5330
6638
  } catch (err) {
@@ -5338,7 +6646,7 @@ async function start(opts = {}) {
5338
6646
  let mdnsService = null;
5339
6647
  const pairingManager = new PairingManager({
5340
6648
  token,
5341
- serverName: (0, import_node_os8.hostname)(),
6649
+ serverName: (0, import_node_os9.hostname)(),
5342
6650
  version: "0.2.0",
5343
6651
  onStateChange: (state) => mdnsService?.updatePairingState(state)
5344
6652
  });
@@ -5359,6 +6667,9 @@ async function start(opts = {}) {
5359
6667
  notificationService.setGlobalPendingCountProvider(
5360
6668
  () => approvalProxy.getPendingCount() + sessionManager.getAllPendingQuestions().length + unreadSessionIds.size
5361
6669
  );
6670
+ notificationService.setPendingApprovalsProvider(
6671
+ (sessionId) => approvalProxy.getPendingRequestsForSession(sessionId)
6672
+ );
5362
6673
  let cliCapabilities = null;
5363
6674
  parseCliCapabilities().then((caps) => {
5364
6675
  cliCapabilities = caps;
@@ -5367,6 +6678,49 @@ async function start(opts = {}) {
5367
6678
  const broadcastUnreadSessions = () => {
5368
6679
  wsBridge.broadcast({ type: "unread_sessions", sessionIds: Array.from(unreadSessionIds) });
5369
6680
  };
6681
+ scheduledManager = new ScheduledSessionManager({
6682
+ onFire: async (task) => {
6683
+ const p = task.payload;
6684
+ if (p.kind === "create") {
6685
+ await (0, import_promises7.mkdir)(p.projectPath, { recursive: true });
6686
+ const session = await sessionManager.createSession(
6687
+ p.projectPath,
6688
+ p.message,
6689
+ p.resumeSessionId,
6690
+ p.newSessionId,
6691
+ p.model,
6692
+ p.permissionMode,
6693
+ p.effort,
6694
+ void 0,
6695
+ p.agentType
6696
+ );
6697
+ wsBridge.broadcast({ type: "session_list", sessions: sessionManager.getActiveSessions() });
6698
+ return { sessionId: session.id };
6699
+ }
6700
+ const active = sessionManager.getActiveSessions().find((s) => s.id === p.sessionId);
6701
+ if (active) {
6702
+ await sessionManager.sendMessage(p.sessionId, p.message, p.permissionMode);
6703
+ } else {
6704
+ await sessionManager.createSession(
6705
+ p.projectPath,
6706
+ p.message,
6707
+ p.sessionId,
6708
+ void 0,
6709
+ void 0,
6710
+ p.permissionMode
6711
+ );
6712
+ }
6713
+ wsBridge.broadcast({ type: "session_list", sessions: sessionManager.getActiveSessions() });
6714
+ return { sessionId: p.sessionId };
6715
+ },
6716
+ onChange: (tasks) => {
6717
+ wsBridge.broadcast({ type: "scheduled_session_list", tasks });
6718
+ },
6719
+ onFired: (event) => {
6720
+ wsBridge.broadcast({ type: "scheduled_session_fired", ...event });
6721
+ }
6722
+ });
6723
+ await scheduledManager.load();
5370
6724
  wsBridge.onConnection(async (ws) => {
5371
6725
  const result = await getProjects();
5372
6726
  if (result.ok) {
@@ -5386,14 +6740,17 @@ async function start(opts = {}) {
5386
6740
  wsBridge.send(ws, { type: "unread_sessions", sessionIds: Array.from(unreadSessionIds) });
5387
6741
  }
5388
6742
  if (cliCapabilities) {
5389
- wsBridge.send(ws, { type: "cli_info", effortLevels: cliCapabilities.effortLevels, version: cliCapabilities.version });
6743
+ wsBridge.send(ws, { type: "cli_info", effortLevels: cliCapabilities.effortLevels, version: cliCapabilities.version, models: cliCapabilities.models });
6744
+ }
6745
+ if (scheduledManager) {
6746
+ wsBridge.send(ws, { type: "scheduled_session_list", tasks: scheduledManager.list() });
5390
6747
  }
5391
6748
  });
5392
6749
  wsBridge.onClientEvent(async (event, ws) => {
5393
6750
  try {
5394
6751
  switch (event.type) {
5395
6752
  case "create_session": {
5396
- await (0, import_promises5.mkdir)(event.projectPath, { recursive: true });
6753
+ await (0, import_promises7.mkdir)(event.projectPath, { recursive: true });
5397
6754
  const resumeId = event.resumeSessionId ?? event.newSessionId;
5398
6755
  if (resumeId) sessionFileWatcher.unwatch(resumeId);
5399
6756
  await sessionManager.createSession(
@@ -5581,7 +6938,7 @@ async function start(opts = {}) {
5581
6938
  if (!isStreaming) {
5582
6939
  const filePath = getSessionFilePath(event.projectPath, event.sessionId);
5583
6940
  try {
5584
- const fileStat = await (0, import_promises6.stat)(filePath);
6941
+ const fileStat = await (0, import_promises8.stat)(filePath);
5585
6942
  sessionFileWatcher.watch(event.sessionId, filePath, fileStat.size);
5586
6943
  } catch {
5587
6944
  }
@@ -5744,6 +7101,66 @@ async function start(opts = {}) {
5744
7101
  xcodeBuildExecutor.killInstall(event.installId);
5745
7102
  break;
5746
7103
  }
7104
+ case "schedule_session": {
7105
+ if (!scheduledManager) break;
7106
+ const scheduledAt = Number(event.scheduledAt);
7107
+ if (!Number.isFinite(scheduledAt)) {
7108
+ wsBridge.send(ws, { type: "error", code: "INVALID_MESSAGE", message: "Invalid scheduledAt" });
7109
+ break;
7110
+ }
7111
+ scheduledManager.schedule(scheduledAt, event.payload);
7112
+ break;
7113
+ }
7114
+ case "cancel_scheduled_session": {
7115
+ scheduledManager?.cancel(event.id);
7116
+ break;
7117
+ }
7118
+ case "list_scheduled_sessions": {
7119
+ if (scheduledManager) {
7120
+ wsBridge.send(ws, { type: "scheduled_session_list", tasks: scheduledManager.list() });
7121
+ }
7122
+ break;
7123
+ }
7124
+ case "git_status": {
7125
+ const status = await gitExecutor.detectStatus(event.projectPath);
7126
+ wsBridge.send(ws, { type: "git_status_result", sessionId: event.sessionId, status });
7127
+ break;
7128
+ }
7129
+ case "git_commit": {
7130
+ await gitExecutor.commit(
7131
+ event.sessionId,
7132
+ event.projectPath,
7133
+ event.message,
7134
+ event.files,
7135
+ event.alsoPush
7136
+ );
7137
+ break;
7138
+ }
7139
+ case "git_push": {
7140
+ await gitExecutor.push(event.sessionId, event.projectPath);
7141
+ break;
7142
+ }
7143
+ case "list_project_commands": {
7144
+ try {
7145
+ const commands = await commandDiscovery.scan(event.projectPath, event.refresh ?? false);
7146
+ wsBridge.send(ws, {
7147
+ type: "commands_result",
7148
+ sessionId: event.sessionId,
7149
+ projectPath: event.projectPath,
7150
+ commands
7151
+ });
7152
+ } catch (err) {
7153
+ const message = err instanceof Error ? err.message : String(err);
7154
+ wsBridge.send(ws, {
7155
+ type: "commands_result",
7156
+ sessionId: event.sessionId,
7157
+ projectPath: event.projectPath,
7158
+ commands: [],
7159
+ error: message
7160
+ });
7161
+ }
7162
+ break;
7163
+ }
5747
7164
  default: {
5748
7165
  wsBridge.send(ws, {
5749
7166
  type: "error",
@@ -5782,6 +7199,9 @@ async function start(opts = {}) {
5782
7199
  xcodeBuildExecutor.onEvent((event) => {
5783
7200
  wsBridge.broadcast(event);
5784
7201
  });
7202
+ gitExecutor.onEvent((event) => {
7203
+ wsBridge.broadcast(event);
7204
+ });
5785
7205
  wsBridge.onDisconnect(() => {
5786
7206
  if (wsBridge.getConnectionCount() === 0 && approvalProxy.getPendingCount() > 0) {
5787
7207
  approvalProxy.approveAll(t("server.phoneDisconnected"));
@@ -5799,14 +7219,12 @@ async function start(opts = {}) {
5799
7219
  setTimeout(() => {
5800
7220
  if (!approvalProxy.isPending(request.id)) return;
5801
7221
  if (wsBridge.isViewingSession(request.sessionId)) return;
5802
- if (wsBridge.getConnectionCount() > 0) return;
5803
7222
  const pendingCount = approvalProxy.getPendingRequestsForSession(request.sessionId).length;
5804
7223
  notificationService.notifyApproval(request, pendingCount);
5805
7224
  }, 5e3);
5806
7225
  setTimeout(() => {
5807
7226
  if (!approvalProxy.isPending(request.id)) return;
5808
7227
  if (wsBridge.isViewingSession(request.sessionId)) return;
5809
- if (wsBridge.getConnectionCount() > 0) return;
5810
7228
  console.log(`[Server] ${t("server.approvalRetry", { id: request.id })}`);
5811
7229
  const pendingCount = approvalProxy.getPendingRequestsForSession(request.sessionId).length;
5812
7230
  notificationService.notifyApproval(request, pendingCount);
@@ -5818,13 +7236,11 @@ async function start(opts = {}) {
5818
7236
  setTimeout(() => {
5819
7237
  if (!sessionManager.isQuestionPending(request.id)) return;
5820
7238
  if (wsBridge.isViewingSession(request.sessionId)) return;
5821
- if (wsBridge.getConnectionCount() > 0) return;
5822
7239
  notificationService.notifyQuestion(request);
5823
7240
  }, 5e3);
5824
7241
  setTimeout(() => {
5825
7242
  if (!sessionManager.isQuestionPending(request.id)) return;
5826
7243
  if (wsBridge.isViewingSession(request.sessionId)) return;
5827
- if (wsBridge.getConnectionCount() > 0) return;
5828
7244
  console.log(`[Server] Question ${request.id} not answered in 60s, retrying push`);
5829
7245
  notificationService.notifyQuestion(request);
5830
7246
  }, 6e4);
@@ -5871,6 +7287,42 @@ async function start(opts = {}) {
5871
7287
  console.error(`[Server] ${t("server.hookInstallFailed")}`, err);
5872
7288
  console.log(`[Server] ${t("server.hookContinue")}`);
5873
7289
  }
7290
+ const idleTimeoutMs = Number(process.env.SESSIX_IDLE_TIMEOUT_MS ?? 30 * 60 * 1e3);
7291
+ const idleSweepIntervalMs = Number(process.env.SESSIX_IDLE_SWEEP_INTERVAL_MS ?? 5 * 60 * 1e3);
7292
+ const maxActiveProcesses = Number(process.env.SESSIX_MAX_ACTIVE_PROCESSES ?? 15);
7293
+ let idleSweepTimer = null;
7294
+ if (idleSweepIntervalMs > 0 && (idleTimeoutMs > 0 || maxActiveProcesses > 0)) {
7295
+ idleSweepTimer = setInterval(async () => {
7296
+ try {
7297
+ let totalSwept = 0;
7298
+ const broadcastShrink = (sessionId) => {
7299
+ sessionManager.shrinkSessionBuffer(sessionId, 100);
7300
+ };
7301
+ for (const agentType of ["claude-code", "codex"]) {
7302
+ const provider = providerFactory.getProvider(agentType);
7303
+ if (idleTimeoutMs > 0 && typeof provider.sweepIdleProcesses === "function") {
7304
+ const swept = await provider.sweepIdleProcesses(idleTimeoutMs);
7305
+ swept.forEach(broadcastShrink);
7306
+ totalSwept += swept.length;
7307
+ }
7308
+ if (maxActiveProcesses > 0 && typeof provider.sweepLruProcesses === "function") {
7309
+ const swept = await provider.sweepLruProcesses(maxActiveProcesses);
7310
+ swept.forEach(broadcastShrink);
7311
+ totalSwept += swept.length;
7312
+ }
7313
+ }
7314
+ if (totalSwept > 0) {
7315
+ console.log(`[Server] Idle GC: swept ${totalSwept} idle session(s)`);
7316
+ wsBridge.broadcast({
7317
+ type: "session_list",
7318
+ sessions: sessionManager.getActiveSessions()
7319
+ });
7320
+ }
7321
+ } catch (err) {
7322
+ console.error("[Server] Idle GC failed:", err);
7323
+ }
7324
+ }, idleSweepIntervalMs);
7325
+ }
5874
7326
  const stop = async () => {
5875
7327
  console.log(`[Server] ${t("server.shuttingDown")}`);
5876
7328
  const errors = [];
@@ -5882,6 +7334,7 @@ async function start(opts = {}) {
5882
7334
  errors.push(err);
5883
7335
  }
5884
7336
  };
7337
+ if (idleSweepTimer) clearInterval(idleSweepTimer);
5885
7338
  await attempt(() => authManager.destroy(), "AuthManager");
5886
7339
  await attempt(() => stopMdns(), "mDNS");
5887
7340
  await attempt(() => pairingManager.destroy(), "PairingManager");
@@ -5892,6 +7345,7 @@ async function start(opts = {}) {
5892
7345
  await attempt(() => xcodeBuildExecutor.destroy(), "XcodeBuildExecutor");
5893
7346
  await attempt(() => notificationService.destroy(), "NotificationService");
5894
7347
  await attempt(() => sessionFileWatcher.destroy(), "SessionFileWatcher");
7348
+ await attempt(() => scheduledManager?.destroy(), "ScheduledSessionManager");
5895
7349
  if (errors.length > 0) {
5896
7350
  console.error(`[Server] ${t("server.shutdownWithErrors", { count: errors.length })}`);
5897
7351
  throw errors[0];
@@ -5918,9 +7372,9 @@ async function start(opts = {}) {
5918
7372
  openPairing: (duration) => pairingManager.open(duration),
5919
7373
  closePairing: () => pairingManager.close(),
5920
7374
  regenerateToken: async () => {
5921
- const newToken = (0, import_uuid7.v4)();
5922
- await (0, import_promises5.mkdir)(configDir, { recursive: true });
5923
- await (0, import_promises5.writeFile)(tokenFile, newToken, "utf8");
7375
+ const newToken = (0, import_uuid9.v4)();
7376
+ await (0, import_promises7.mkdir)(configDir, { recursive: true });
7377
+ await (0, import_promises7.writeFile)(tokenFile, newToken, "utf8");
5924
7378
  instance.token = newToken;
5925
7379
  wsBridge.updateToken(newToken);
5926
7380
  approvalProxy.updateToken(newToken);
@@ -5937,7 +7391,7 @@ async function start(opts = {}) {
5937
7391
  var import_qrcode_terminal = __toESM(require("qrcode-terminal"));
5938
7392
  function getPackageVersion() {
5939
7393
  try {
5940
- const pkg = JSON.parse((0, import_node_fs4.readFileSync)((0, import_node_path8.join)(__dirname, "..", "package.json"), "utf8"));
7394
+ const pkg = JSON.parse((0, import_node_fs4.readFileSync)((0, import_node_path10.join)(__dirname, "..", "package.json"), "utf8"));
5941
7395
  return pkg.version ?? "0.0.0";
5942
7396
  } catch {
5943
7397
  return "0.0.0";
@@ -5975,7 +7429,7 @@ async function autoUpdateIfNeeded() {
5975
7429
  console.log(` \u{1F4E6} ${t("startup.autoUpdating", { current: PKG_VERSION, latest })}`);
5976
7430
  console.log();
5977
7431
  try {
5978
- (0, import_node_child_process11.execFileSync)("npx", [`sessix-server@${latest}`], {
7432
+ (0, import_node_child_process13.execFileSync)("npx", [`sessix-server@${latest}`], {
5979
7433
  stdio: "inherit",
5980
7434
  env: { ...process.env, __SESSIX_UPDATED: "1" }
5981
7435
  });
@@ -6081,7 +7535,7 @@ ${t("startup.pairingReopened")}`);
6081
7535
  }
6082
7536
  }
6083
7537
  function getLocalIp() {
6084
- const interfaces = (0, import_node_os9.networkInterfaces)();
7538
+ const interfaces = (0, import_node_os10.networkInterfaces)();
6085
7539
  for (const iface of Object.values(interfaces)) {
6086
7540
  for (const addr of iface ?? []) {
6087
7541
  if (addr.family === "IPv4" && !addr.internal) {