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