sessix-server 0.4.5 → 0.4.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (3) hide show
  1. package/dist/index.js +368 -114
  2. package/dist/server.js +368 -114
  3. package/package.json +1 -1
package/dist/server.js CHANGED
@@ -952,9 +952,13 @@ ${context}`;
952
952
  throw new Error(`Session ${sessionId} stdin unavailable`);
953
953
  }
954
954
  const toolResult = JSON.stringify({
955
- type: "tool_result",
956
- tool_use_id: toolUseId,
957
- content: answer
955
+ type: "user",
956
+ session_id: "",
957
+ message: {
958
+ role: "user",
959
+ content: [{ type: "tool_result", tool_use_id: toolUseId, content: answer }]
960
+ },
961
+ parent_tool_use_id: toolUseId
958
962
  });
959
963
  await new Promise((resolve, reject) => {
960
964
  entry.process.stdin.write(toolResult + "\n", (err) => {
@@ -2965,6 +2969,8 @@ var ApprovalProxy = class _ApprovalProxy {
2965
2969
  this.handleApprovalHook(req, res);
2966
2970
  } else if (req.method === "POST" && pathname === "/hook/notify") {
2967
2971
  this.handleHookNotify(req, res);
2972
+ } else if (req.method === "POST" && pathname === "/api/resolve") {
2973
+ this.handleApiResolve(req, res);
2968
2974
  } else if (req.method === "POST" && pathname === "/pair") {
2969
2975
  this.handlePair(req, res);
2970
2976
  } else if (req.method === "GET" && pathname === "/health") {
@@ -3030,6 +3036,34 @@ var ApprovalProxy = class _ApprovalProxy {
3030
3036
  this.sendJson(res, 200, { decision: "deny", reason: "Server failed to process request" });
3031
3037
  }
3032
3038
  }
3039
+ /**
3040
+ * 移动端 API 端点:Widget Extension / Watch 直接提交审批决策
3041
+ *
3042
+ * 绕过 WebSocket 链路,让 iOS Widget Extension 的 AppIntent 在 App 挂起时
3043
+ * 仍能直接将审批结果提交到服务端。使用 Bearer token 鉴权(与 WS 同一 token)。
3044
+ */
3045
+ async handleApiResolve(req, res) {
3046
+ const authHeader = req.headers.authorization ?? "";
3047
+ const bearerToken = authHeader.startsWith("Bearer ") ? authHeader.slice(7) : "";
3048
+ if (bearerToken !== this.token) {
3049
+ this.sendJson(res, 401, { ok: false, error: "Unauthorized" });
3050
+ return;
3051
+ }
3052
+ try {
3053
+ const body = await this.parseJsonBody(req);
3054
+ const requestId = String(body.requestId ?? "").trim();
3055
+ const decision = String(body.decision ?? "").trim();
3056
+ if (!requestId || decision !== "allow" && decision !== "deny") {
3057
+ this.sendJson(res, 400, { ok: false, error: "requestId and decision (allow|deny) required" });
3058
+ return;
3059
+ }
3060
+ const resolved = this.resolveApproval(requestId, { decision });
3061
+ this.sendJson(res, 200, { ok: resolved });
3062
+ } catch (err) {
3063
+ console.error("[ApprovalProxy] /api/resolve error:", err);
3064
+ this.sendJson(res, 500, { ok: false, error: "Internal error" });
3065
+ }
3066
+ }
3033
3067
  /**
3034
3068
  * 非阻塞 hook 通知端点
3035
3069
  *
@@ -3741,7 +3775,7 @@ var HookInstaller = class {
3741
3775
  // src/notification/NotificationService.ts
3742
3776
  var import_node_path5 = require("path");
3743
3777
  var RECENT_ACTIVITY_MAX = 6;
3744
- var ACTIVITY_PUSH_THROTTLE_MS = 2500;
3778
+ var ACTIVITY_PUSH_THROTTLE_MS = 4e3;
3745
3779
  var NotificationService = class {
3746
3780
  constructor(sessionManager, expoChannel = null) {
3747
3781
  this.sessionManager = sessionManager;
@@ -3766,8 +3800,24 @@ var NotificationService = class {
3766
3800
  activityPushTimers = /* @__PURE__ */ new Map();
3767
3801
  /** 上次推送 LA content 的时间戳(用于节流;首次立即推送) */
3768
3802
  lastActivityPushAt = /* @__PURE__ */ new Map();
3803
+ /** 挂起的优先级提升请求(状态变化时设为 '10',flush 后清除) */
3804
+ pendingPriority = /* @__PURE__ */ new Map();
3805
+ /** sessionId → 累计活动计数器(用于 summary 模式的 activitySummary) */
3806
+ activityCounters = /* @__PURE__ */ new Map();
3769
3807
  /** 提供器:根据 sessionId 取该会话的待审批列表(由 server.ts 注入) */
3770
3808
  pendingApprovalsProvider = null;
3809
+ /**
3810
+ * sessionId → idle 结束定时器。
3811
+ * 会话变为 idle 时启动(30 秒);用户发新消息重回 running 时取消。
3812
+ * 30 秒内无新消息 → 调 endActivity 关闭 LA,同时发横幅通知告知完成。
3813
+ */
3814
+ idleEndTimers = /* @__PURE__ */ new Map();
3815
+ /**
3816
+ * sessionId → LA 心跳定时器(setInterval)。
3817
+ * 确保 Agent 子任务等长时间无 claude_event 的场景下 LA 仍持续更新。
3818
+ * token 注册时启动,flushActivityEnd / removeActivityPushToken 时停止。
3819
+ */
3820
+ laHeartbeatTimers = /* @__PURE__ */ new Map();
3771
3821
  /** 添加通知渠道(id 唯一,可用于后续动态开关) */
3772
3822
  addChannel(id, channel, enabled = true) {
3773
3823
  this.channelMap.set(id, { channel, enabled });
@@ -3802,13 +3852,16 @@ var NotificationService = class {
3802
3852
  this.activityPushChannel.addToken(sessionId, token);
3803
3853
  console.log(`[NotificationService] \u2705 LA push token \u5DF2\u6CE8\u518C (session=${sessionId.slice(0, 8)}\u2026, token=${token.slice(0, 16)}\u2026)`);
3804
3854
  this.scheduleActivityPush(sessionId, true);
3855
+ this.startLaHeartbeat(sessionId);
3805
3856
  }
3806
3857
  /** 移除 ActivityKit push token */
3807
3858
  removeActivityPushToken(sessionId) {
3859
+ this.stopLaHeartbeat(sessionId);
3808
3860
  this.activityPushChannel?.removeToken(sessionId);
3809
3861
  this.clearActivityPushTimer(sessionId);
3810
3862
  this.recentActivityState.delete(sessionId);
3811
3863
  this.lastActivityPushAt.delete(sessionId);
3864
+ this.activityCounters.delete(sessionId);
3812
3865
  }
3813
3866
  /** 注入"会话 → 待审批列表"提供器(server.ts 启动时调用) */
3814
3867
  setPendingApprovalsProvider(fn) {
@@ -3827,7 +3880,7 @@ var NotificationService = class {
3827
3880
  this.yoloModeState.set(sessionId, enabled);
3828
3881
  }
3829
3882
  /** 直接触发审批通知(由 ApprovalProxy 回调调用) */
3830
- notifyApproval(request, pendingCount) {
3883
+ async notifyApproval(request, pendingCount) {
3831
3884
  if (this.yoloModeState.get(request.sessionId)) return;
3832
3885
  const sessionTitle = this.getSessionTitle(request.sessionId);
3833
3886
  const title = pendingCount > 1 ? t("notification.pendingApprovals", { title: sessionTitle, count: pendingCount }) : sessionTitle;
@@ -3837,7 +3890,8 @@ var NotificationService = class {
3837
3890
  const isYoloMode = this.getYoloMode(request.sessionId);
3838
3891
  const recentActivity = this.getRecentActivity(request.sessionId);
3839
3892
  const latestMessage = recentActivity[recentActivity.length - 1] ?? "";
3840
- this.activityPushChannel.updateActivityWithAlert(
3893
+ const session = this.sessionManager.getActiveSessions().find((s) => s.id === request.sessionId);
3894
+ const sent = await this.activityPushChannel.updateActivityWithAlert(
3841
3895
  request.sessionId,
3842
3896
  {
3843
3897
  status: "waitingApproval",
@@ -3852,12 +3906,22 @@ var NotificationService = class {
3852
3906
  pendingCount
3853
3907
  },
3854
3908
  isYoloMode,
3855
- updatedAt: Date.now()
3909
+ updatedAt: Date.now(),
3910
+ displayMode: "summary",
3911
+ activitySummary: this.buildActivitySummary(request.sessionId),
3912
+ startedAt: session?.createdAt,
3913
+ stats: this.buildStatsPayload(session)
3856
3914
  },
3857
3915
  { title, body }
3858
3916
  );
3859
- this.lastActivityPushAt.set(request.sessionId, Date.now());
3860
- return;
3917
+ if (sent) {
3918
+ console.log(`[NotificationService] \u{1F4E1} approval via ActivityKit push session=${request.sessionId.slice(0, 8)}\u2026 tool=${request.toolName}`);
3919
+ this.lastActivityPushAt.set(request.sessionId, Date.now());
3920
+ return;
3921
+ }
3922
+ console.warn(`[NotificationService] \u26A0\uFE0F ActivityKit push \u5931\u8D25\uFF0C\u964D\u7EA7\u5230 Expo push session=${request.sessionId.slice(0, 8)}\u2026`);
3923
+ } else {
3924
+ console.log(`[NotificationService] \u{1F4F2} approval via Expo push session=${request.sessionId.slice(0, 8)}\u2026 tool=${request.toolName}`);
3861
3925
  }
3862
3926
  const dangerLevel = this.getDangerLevel(request.toolName);
3863
3927
  const isDangerous = dangerLevel === "danger" || dangerLevel === "write";
@@ -3891,15 +3955,20 @@ var NotificationService = class {
3891
3955
  if (this.activityPushChannel?.hasToken(request.sessionId)) {
3892
3956
  const isYoloMode = this.getYoloMode(request.sessionId);
3893
3957
  const recentActivity = this.getRecentActivity(request.sessionId);
3958
+ const session = this.sessionManager.getActiveSessions().find((s) => s.id === request.sessionId);
3894
3959
  this.activityPushChannel.updateActivityWithAlert(
3895
3960
  request.sessionId,
3896
3961
  {
3897
- status: "waitingApproval",
3962
+ status: "waitingQuestion",
3898
3963
  sessionTitle,
3899
3964
  latestMessage: request.question.slice(0, 80),
3900
3965
  recentActivity,
3901
3966
  isYoloMode,
3902
- updatedAt: Date.now()
3967
+ updatedAt: Date.now(),
3968
+ displayMode: "summary",
3969
+ activitySummary: this.buildActivitySummary(request.sessionId),
3970
+ startedAt: session?.createdAt,
3971
+ stats: this.buildStatsPayload(session)
3903
3972
  },
3904
3973
  { title: sessionTitle, body }
3905
3974
  );
@@ -3941,6 +4010,8 @@ var NotificationService = class {
3941
4010
  this.activityPushTimers.clear();
3942
4011
  this.recentActivityState.clear();
3943
4012
  this.lastActivityPushAt.clear();
4013
+ this.pendingPriority.clear();
4014
+ this.activityCounters.clear();
3944
4015
  }
3945
4016
  // ============================================
3946
4017
  // 内部方法
@@ -3964,22 +4035,14 @@ var NotificationService = class {
3964
4035
  case "status_change": {
3965
4036
  this.clearActivityPushTimer(event.sessionId);
3966
4037
  if (event.status === "idle") {
3967
- const sessionTitle = this.getSessionTitle(event.sessionId);
3968
- const latestMsg = this.latestAssistantText.get(event.sessionId);
3969
- const body = latestMsg ? `\u2705 ${latestMsg.slice(0, 80)}` : t("notification.taskComplete");
3970
- const isYoloMode = this.getYoloMode(event.sessionId);
4038
+ this.cancelIdleEndTimer(event.sessionId);
3971
4039
  if (this.activityPushChannel?.hasToken(event.sessionId)) {
3972
- this.activityPushChannel.endActivity(event.sessionId, {
3973
- status: "idle",
3974
- sessionTitle,
3975
- latestMessage: body,
3976
- recentActivity: this.getRecentActivity(event.sessionId),
3977
- isYoloMode,
3978
- updatedAt: Date.now()
3979
- });
3980
- this.recentActivityState.delete(event.sessionId);
3981
- this.lastActivityPushAt.delete(event.sessionId);
4040
+ this.scheduleActivityPush(event.sessionId, true, "10");
4041
+ this.scheduleIdleEnd(event.sessionId, 3e4);
3982
4042
  } else {
4043
+ const sessionTitle = this.getSessionTitle(event.sessionId);
4044
+ const latestMsg = this.latestAssistantText.get(event.sessionId);
4045
+ const body = latestMsg ? `\u2705 ${latestMsg.slice(0, 80)}` : t("notification.taskComplete");
3983
4046
  this.notify({
3984
4047
  title: sessionTitle,
3985
4048
  body,
@@ -3988,31 +4051,12 @@ var NotificationService = class {
3988
4051
  data: { type: "task_complete", sessionId: event.sessionId }
3989
4052
  });
3990
4053
  }
4054
+ } else if (event.status === "running" || event.status === "waiting_approval" || event.status === "waiting_question") {
4055
+ this.cancelIdleEndTimer(event.sessionId);
4056
+ this.scheduleActivityPush(event.sessionId, true, "10");
3991
4057
  } else if (event.status === "error") {
3992
- const sessionTitle = this.getSessionTitle(event.sessionId);
3993
- const latestMsg = this.latestAssistantText.get(event.sessionId);
3994
- const body = latestMsg ? `\u274C ${latestMsg.slice(0, 80)}` : t("notification.taskError");
3995
- const isYoloMode = this.getYoloMode(event.sessionId);
3996
- if (this.activityPushChannel?.hasToken(event.sessionId)) {
3997
- this.activityPushChannel.endActivity(event.sessionId, {
3998
- status: "error",
3999
- sessionTitle,
4000
- latestMessage: body,
4001
- recentActivity: this.getRecentActivity(event.sessionId),
4002
- isYoloMode,
4003
- updatedAt: Date.now()
4004
- });
4005
- this.recentActivityState.delete(event.sessionId);
4006
- this.lastActivityPushAt.delete(event.sessionId);
4007
- } else {
4008
- this.notify({
4009
- title: sessionTitle,
4010
- body,
4011
- sound: "default",
4012
- badge: this.getGlobalPendingCount(),
4013
- data: { type: "task_error", sessionId: event.sessionId }
4014
- });
4015
- }
4058
+ this.cancelIdleEndTimer(event.sessionId);
4059
+ this.flushActivityEnd(event.sessionId, "error");
4016
4060
  }
4017
4061
  break;
4018
4062
  }
@@ -4088,6 +4132,7 @@ var NotificationService = class {
4088
4132
  } else if (block.type === "tool_use") {
4089
4133
  const line = this.summarizeToolCall(block.name, block.input ?? {});
4090
4134
  if (line) next.push(line);
4135
+ this.incrementCounter(sessionId, block.name);
4091
4136
  }
4092
4137
  }
4093
4138
  state.currentEntries = next;
@@ -4099,9 +4144,66 @@ var NotificationService = class {
4099
4144
  const combined = [...state.history, ...state.currentEntries];
4100
4145
  return combined.slice(-RECENT_ACTIVITY_MAX);
4101
4146
  }
4147
+ /** 工具名 → 计数器类别映射 */
4148
+ incrementCounter(sessionId, toolName) {
4149
+ let c = this.activityCounters.get(sessionId);
4150
+ if (!c) {
4151
+ c = { filesEdited: 0, commandsRun: 0, searches: 0, filesRead: 0, messagesReceived: 0 };
4152
+ this.activityCounters.set(sessionId, c);
4153
+ }
4154
+ switch (toolName) {
4155
+ case "Edit":
4156
+ case "MultiEdit":
4157
+ case "Write":
4158
+ case "NotebookEdit":
4159
+ c.filesEdited++;
4160
+ break;
4161
+ case "Bash":
4162
+ c.commandsRun++;
4163
+ break;
4164
+ case "Grep":
4165
+ case "Glob":
4166
+ case "WebSearch":
4167
+ case "WebFetch":
4168
+ c.searches++;
4169
+ break;
4170
+ case "Read":
4171
+ c.filesRead++;
4172
+ break;
4173
+ default:
4174
+ break;
4175
+ }
4176
+ }
4177
+ /** 把累计计数器格式化为可读摘要(如"已编辑 3 个文件 · 运行 5 条命令") */
4178
+ buildActivitySummary(sessionId) {
4179
+ const c = this.activityCounters.get(sessionId);
4180
+ if (!c) return "";
4181
+ const parts = [];
4182
+ if (c.filesEdited > 0) parts.push(`\u5DF2\u7F16\u8F91 ${c.filesEdited} \u4E2A\u6587\u4EF6`);
4183
+ if (c.commandsRun > 0) parts.push(`\u8FD0\u884C ${c.commandsRun} \u6761\u547D\u4EE4`);
4184
+ if (c.searches > 0) parts.push(`\u641C\u7D22 ${c.searches} \u6B21`);
4185
+ if (c.filesRead > 0) parts.push(`\u9605\u8BFB ${c.filesRead} \u4E2A\u6587\u4EF6`);
4186
+ return parts.join(" \xB7 ") || "\u4F1A\u8BDD\u8FDB\u884C\u4E2D\u2026";
4187
+ }
4188
+ buildStatsPayload(session) {
4189
+ return {
4190
+ totalInputTokens: session?.stats?.totalInputTokens ?? 0,
4191
+ totalOutputTokens: session?.stats?.totalOutputTokens ?? 0,
4192
+ totalCostUsd: session?.stats?.totalCostUsd,
4193
+ totalDurationMs: session?.stats?.totalDurationMs,
4194
+ runningStartedAt: session?.stats?.runningStartedAt
4195
+ };
4196
+ }
4102
4197
  /** 节流调度 LA content push;首次立即推,后续合并到 throttle window 末尾 */
4103
- scheduleActivityPush(sessionId, force = false) {
4104
- if (!this.activityPushChannel?.hasToken(sessionId)) return;
4198
+ scheduleActivityPush(sessionId, force = false, priority) {
4199
+ if (!this.activityPushChannel) return;
4200
+ if (!this.activityPushChannel.hasToken(sessionId)) {
4201
+ console.warn(`[NotificationService] \u26A0\uFE0F skip LA push: session=${sessionId.slice(0, 8)}\u2026 token \u672A\u6CE8\u518C`);
4202
+ return;
4203
+ }
4204
+ if (priority === "10") {
4205
+ this.pendingPriority.set(sessionId, "10");
4206
+ }
4105
4207
  const now = Date.now();
4106
4208
  const last = this.lastActivityPushAt.get(sessionId) ?? 0;
4107
4209
  const elapsed = now - last;
@@ -4127,6 +4229,101 @@ var NotificationService = class {
4127
4229
  this.activityPushTimers.delete(sessionId);
4128
4230
  }
4129
4231
  }
4232
+ /**
4233
+ * 启动 LA 心跳(每 ACTIVITY_PUSH_THROTTLE_MS 触发一次 scheduleActivityPush)。
4234
+ * 确保 Agent 子会话等长时间无 claude_event 的情况下 LA 仍持续更新。
4235
+ * 心跳只在会话活跃状态(running/waitingApproval/waitingQuestion)下发推送;
4236
+ * scheduleActivityPush 自带节流,心跳与正常事件驱动不冲突。
4237
+ */
4238
+ startLaHeartbeat(sessionId) {
4239
+ if (this.laHeartbeatTimers.has(sessionId)) return;
4240
+ const timer = setInterval(() => {
4241
+ if (!this.activityPushChannel?.hasToken(sessionId)) {
4242
+ this.stopLaHeartbeat(sessionId);
4243
+ return;
4244
+ }
4245
+ const session = this.sessionManager.getActiveSessions().find((s) => s.id === sessionId);
4246
+ if (!session) {
4247
+ this.stopLaHeartbeat(sessionId);
4248
+ return;
4249
+ }
4250
+ if (session.status === "running" || session.status === "waiting_approval" || session.status === "waiting_question") {
4251
+ this.scheduleActivityPush(sessionId);
4252
+ }
4253
+ }, ACTIVITY_PUSH_THROTTLE_MS);
4254
+ this.laHeartbeatTimers.set(sessionId, timer);
4255
+ }
4256
+ /** 停止 LA 心跳 */
4257
+ stopLaHeartbeat(sessionId) {
4258
+ const timer = this.laHeartbeatTimers.get(sessionId);
4259
+ if (timer) {
4260
+ clearInterval(timer);
4261
+ this.laHeartbeatTimers.delete(sessionId);
4262
+ }
4263
+ }
4264
+ /** 取消 idle 结束定时器 */
4265
+ cancelIdleEndTimer(sessionId) {
4266
+ const timer = this.idleEndTimers.get(sessionId);
4267
+ if (timer) {
4268
+ clearTimeout(timer);
4269
+ this.idleEndTimers.delete(sessionId);
4270
+ }
4271
+ }
4272
+ /**
4273
+ * 启动 idle 结束定时器:delayMs 后若会话仍 idle,调 endActivity + 发通知。
4274
+ * 保证多轮对话期间(idle→running→idle)LA 不会过早消失。
4275
+ */
4276
+ scheduleIdleEnd(sessionId, delayMs) {
4277
+ const timer = setTimeout(() => {
4278
+ this.idleEndTimers.delete(sessionId);
4279
+ this.flushActivityEnd(sessionId, "idle");
4280
+ }, delayMs);
4281
+ this.idleEndTimers.set(sessionId, timer);
4282
+ }
4283
+ /**
4284
+ * 结束 LA 并发完成通知。有 LA token → APNs event:end(带横幅);
4285
+ * 无 token → 普通 Expo push。清理所有相关状态。
4286
+ */
4287
+ flushActivityEnd(sessionId, reason) {
4288
+ const sessionTitle = this.getSessionTitle(sessionId);
4289
+ const latestMsg = this.latestAssistantText.get(sessionId);
4290
+ const isError = reason === "error";
4291
+ const body = isError ? `\u274C ${latestMsg?.slice(0, 80) ?? t("notification.taskError")}` : `\u2705 ${latestMsg?.slice(0, 80) ?? t("notification.taskComplete")}`;
4292
+ const isYoloMode = this.getYoloMode(sessionId);
4293
+ const session = this.sessionManager.getActiveSessions().find((s) => s.id === sessionId);
4294
+ this.notify({
4295
+ title: sessionTitle,
4296
+ body,
4297
+ sound: "default",
4298
+ badge: this.getGlobalPendingCount(),
4299
+ data: { type: isError ? "task_error" : "task_complete", sessionId }
4300
+ });
4301
+ if (this.activityPushChannel?.hasToken(sessionId)) {
4302
+ this.activityPushChannel.endActivity(
4303
+ sessionId,
4304
+ {
4305
+ status: isError ? "error" : "completed",
4306
+ sessionTitle,
4307
+ latestMessage: body,
4308
+ recentActivity: this.getRecentActivity(sessionId),
4309
+ isYoloMode,
4310
+ updatedAt: Date.now(),
4311
+ displayMode: "summary",
4312
+ activitySummary: this.buildActivitySummary(sessionId),
4313
+ startedAt: session?.createdAt,
4314
+ stats: this.buildStatsPayload(session)
4315
+ }
4316
+ // 不传 alert——Expo push 已处理通知,event:end 仅用于关闭 LA
4317
+ ).catch((err) => {
4318
+ console.warn("[NotificationService] endActivity (close LA) failed, LA may linger:", err);
4319
+ });
4320
+ }
4321
+ this.stopLaHeartbeat(sessionId);
4322
+ this.recentActivityState.delete(sessionId);
4323
+ this.lastActivityPushAt.delete(sessionId);
4324
+ this.activityCounters.delete(sessionId);
4325
+ console.log(`[NotificationService] \u{1F3C1} LA end (${reason}) session=${sessionId.slice(0, 8)}\u2026`);
4326
+ }
4130
4327
  /** 真正发送一次 LA content push(无 alert) */
4131
4328
  flushActivityPush(sessionId) {
4132
4329
  const channel = this.activityPushChannel;
@@ -4146,7 +4343,10 @@ var NotificationService = class {
4146
4343
  latestMessage,
4147
4344
  recentActivity,
4148
4345
  isYoloMode,
4149
- updatedAt: Date.now()
4346
+ updatedAt: Date.now(),
4347
+ displayMode: "summary",
4348
+ activitySummary: this.buildActivitySummary(sessionId),
4349
+ startedAt: session.createdAt
4150
4350
  };
4151
4351
  if (latestApproval) {
4152
4352
  contentState.approvalInfo = {
@@ -4157,17 +4357,15 @@ var NotificationService = class {
4157
4357
  pendingCount: pendingApprovals.length
4158
4358
  };
4159
4359
  }
4160
- if (session.stats) {
4161
- contentState.stats = {
4162
- totalInputTokens: session.stats.totalInputTokens,
4163
- totalOutputTokens: session.stats.totalOutputTokens,
4164
- totalCostUsd: session.stats.totalCostUsd
4165
- };
4166
- }
4360
+ contentState.stats = this.buildStatsPayload(session);
4361
+ const priority = this.pendingPriority.get(sessionId) ?? "5";
4362
+ this.pendingPriority.delete(sessionId);
4167
4363
  this.lastActivityPushAt.set(sessionId, Date.now());
4168
4364
  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}`);
4365
+ channel.updateActivity(sessionId, contentState, { priority }).then((ok) => {
4366
+ if (ok) {
4367
+ console.log(`[NotificationService] \u{1F4E1} LA push \u2713 session=${sessionId.slice(0, 8)}\u2026 status=${status} p=${priority} lines=${lineCount}`);
4368
+ }
4171
4369
  }).catch((err) => {
4172
4370
  console.warn(`[NotificationService] \u{1F4E1} LA push \u2717 session=${sessionId.slice(0, 8)}\u2026:`, err instanceof Error ? err.message : err);
4173
4371
  });
@@ -4324,12 +4522,13 @@ var ExpoNotificationChannel = class {
4324
4522
  }
4325
4523
  async send(payload) {
4326
4524
  if (this.tokens.size === 0) return;
4327
- const offlineTokens = Array.from(this.tokens).filter((token) => {
4525
+ const isCompletionNotif = payload.data?.type === "task_complete" || payload.data?.type === "task_error";
4526
+ const targetTokens = isCompletionNotif ? Array.from(this.tokens) : Array.from(this.tokens).filter((token) => {
4328
4527
  const ws = this.tokenWsMap.get(token);
4329
4528
  return !ws || ws.readyState !== ws.OPEN;
4330
4529
  });
4331
- if (offlineTokens.length === 0) return;
4332
- const messages = offlineTokens.map((to) => {
4530
+ if (targetTokens.length === 0) return;
4531
+ const messages = targetTokens.map((to) => {
4333
4532
  let sound = payload.sound ?? "default";
4334
4533
  const prefs = this.soundPreferences.get(to);
4335
4534
  if (prefs) {
@@ -4351,7 +4550,7 @@ var ExpoNotificationChannel = class {
4351
4550
  };
4352
4551
  });
4353
4552
  try {
4354
- console.log(`[ExpoNotificationChannel] ${t("notification.sendingPush")} (${offlineTokens.length}/${this.tokens.size} devices)`, offlineTokens);
4553
+ console.log(`[ExpoNotificationChannel] ${t("notification.sendingPush")} (${targetTokens.length}/${this.tokens.size} devices${isCompletionNotif ? ", forced" : ""})`, targetTokens);
4355
4554
  const res = await fetch(EXPO_PUSH_API, {
4356
4555
  method: "POST",
4357
4556
  headers: { "Content-Type": "application/json", Accept: "application/json" },
@@ -4395,6 +4594,12 @@ var ActivityPushChannel = class {
4395
4594
  * 同时维护两个连接 + 探测机制,避免环境配错时静默失败。
4396
4595
  */
4397
4596
  tokenEnv = /* @__PURE__ */ new Map();
4597
+ /**
4598
+ * 两个环境都拒绝的 token(Live Activity 已销毁/过期)。
4599
+ * 标记后跳过所有发送尝试,避免无意义的 HTTP/2 请求。
4600
+ * 当同一 session 注册新 token 时自动清除旧 token 的标记。
4601
+ */
4602
+ deadTokens = /* @__PURE__ */ new Set();
4398
4603
  /** 首次探测顺序(来自配置 hint),未配置则先试 sandbox(开发场景占多数) */
4399
4604
  probeOrder;
4400
4605
  teamId;
@@ -4431,6 +4636,11 @@ var ActivityPushChannel = class {
4431
4636
  }
4432
4637
  /** 注册 Activity push token */
4433
4638
  addToken(sessionId, token) {
4639
+ const oldToken = this.tokens.get(sessionId);
4640
+ if (oldToken && oldToken !== token) {
4641
+ this.tokenEnv.delete(oldToken);
4642
+ this.deadTokens.delete(oldToken);
4643
+ }
4434
4644
  const existed = this.tokens.has(sessionId);
4435
4645
  this.tokens.set(sessionId, token);
4436
4646
  console.log(`[ActivityPushChannel] Token ${existed ? "updated" : "registered"}: session=${sessionId.slice(0, 8)}\u2026 token=${token.slice(0, 16)}\u2026`);
@@ -4439,65 +4649,82 @@ var ActivityPushChannel = class {
4439
4649
  removeToken(sessionId) {
4440
4650
  const tok = this.tokens.get(sessionId);
4441
4651
  this.tokens.delete(sessionId);
4442
- if (tok) this.tokenEnv.delete(tok);
4652
+ if (tok) {
4653
+ this.tokenEnv.delete(tok);
4654
+ this.deadTokens.delete(tok);
4655
+ }
4443
4656
  }
4444
- /** 发送 content-state 更新到指定会话的 Live Activity(纯内容刷新,不响通知) */
4445
- async updateActivity(sessionId, contentState) {
4657
+ /** 发送 content-state 更新到指定会话的 Live Activity(纯内容刷新,不响通知)。返回 true 表示实际发出 */
4658
+ async updateActivity(sessionId, contentState, opts) {
4446
4659
  const token = this.tokens.get(sessionId);
4447
- if (!token) return;
4660
+ if (!token) return false;
4448
4661
  const now = Math.floor(Date.now() / 1e3);
4662
+ const priority = opts?.priority ?? "5";
4449
4663
  const payload = {
4450
4664
  aps: {
4451
4665
  timestamp: now,
4452
4666
  event: "update",
4453
4667
  "content-state": contentState,
4454
- // 2 分钟没新内容就让 LA 进入 stale 状态(系统自动灰化),避免显示陈旧数据
4455
- "stale-date": now + 120
4668
+ "stale-date": now + 600
4456
4669
  }
4457
4670
  };
4458
4671
  try {
4459
- await this.sendToAPNs(token, payload, { priority: "5" });
4672
+ await this.sendToAPNs(token, payload, {
4673
+ priority,
4674
+ collapseId: `state-${sessionId.slice(0, 54)}`
4675
+ });
4676
+ return true;
4460
4677
  } catch (err) {
4461
4678
  console.warn(`[ActivityPushChannel] Update failed session=${sessionId}:`, err);
4679
+ return false;
4462
4680
  }
4463
4681
  }
4464
- /** 发送带通知的 content-state 更新(审批请求时使用) */
4682
+ /** 发送带通知的 content-state 更新(审批请求时使用),返回是否发送成功 */
4465
4683
  async updateActivityWithAlert(sessionId, contentState, alert) {
4466
4684
  const token = this.tokens.get(sessionId);
4467
- if (!token) return;
4685
+ if (!token) return false;
4468
4686
  const now = Math.floor(Date.now() / 1e3);
4469
4687
  const payload = {
4470
4688
  aps: {
4471
4689
  timestamp: now,
4472
4690
  event: "update",
4473
4691
  "content-state": contentState,
4474
- "stale-date": now + 120,
4692
+ "stale-date": now + 600,
4475
4693
  alert,
4476
4694
  sound: "default"
4477
4695
  }
4478
4696
  };
4479
4697
  try {
4480
4698
  await this.sendToAPNs(token, payload, { priority: "10" });
4699
+ return true;
4481
4700
  } catch (err) {
4482
4701
  console.warn(`[ActivityPushChannel] Alert update failed session=${sessionId}:`, err);
4702
+ return false;
4483
4703
  }
4484
4704
  }
4485
- /** 结束指定会话的 Live Activity */
4486
- async endActivity(sessionId, contentState) {
4705
+ /**
4706
+ * 结束指定会话的 Live Activity。
4707
+ * 可选 alert:APNs event=end 时同时推送横幅通知 + 声音,用于在会话完成时提醒用户。
4708
+ */
4709
+ async endActivity(sessionId, contentState, opts) {
4487
4710
  const token = this.tokens.get(sessionId);
4488
4711
  if (!token) return;
4489
4712
  const now = Math.floor(Date.now() / 1e3);
4490
- const payload = {
4491
- aps: {
4492
- timestamp: now,
4493
- event: "end",
4494
- "content-state": contentState
4495
- }
4713
+ const aps = {
4714
+ timestamp: now,
4715
+ event: "end",
4716
+ "content-state": contentState
4496
4717
  };
4718
+ if (opts?.alert) {
4719
+ aps.alert = opts.alert;
4720
+ aps.sound = "default";
4721
+ }
4722
+ const payload = { aps };
4497
4723
  try {
4498
4724
  await this.sendToAPNs(token, payload, { priority: "10" });
4499
4725
  } catch (err) {
4500
- console.warn(`[ActivityPushChannel] End failed session=${sessionId}:`, err);
4726
+ this.tokens.delete(sessionId);
4727
+ throw err;
4501
4728
  }
4502
4729
  this.tokens.delete(sessionId);
4503
4730
  }
@@ -4508,29 +4735,44 @@ var ActivityPushChannel = class {
4508
4735
  /**
4509
4736
  * 发送 APNs,自动处理环境探测。
4510
4737
  * 对每个 token:先用已确认的环境(如有);否则按 probeOrder 顺序探测,
4511
- * 收到 BadDeviceToken 自动切到另一个环境,并把成功的环境绑定到该 token。
4738
+ * 收到 BadDeviceToken / BadEnvironmentKeyInToken 自动切到另一个环境,
4739
+ * 并把成功的环境绑定到该 token。
4512
4740
  */
4513
4741
  async sendToAPNs(deviceToken, payload, opts = {}) {
4742
+ if (this.deadTokens.has(deviceToken)) {
4743
+ throw new Error(`token permanently dead (Activity ended): ${deviceToken.slice(0, 16)}\u2026`);
4744
+ }
4514
4745
  const known = this.tokenEnv.get(deviceToken);
4515
4746
  if (known) {
4516
4747
  return this.sendToAPNsOnce(deviceToken, payload, opts, known);
4517
4748
  }
4749
+ const short = deviceToken.slice(0, 16);
4750
+ console.log(`[ActivityPushChannel] \u{1F50D} probe start token=${short}\u2026 order=[${this.probeOrder.join(",")}]`);
4518
4751
  let lastErr = null;
4519
4752
  for (const env of this.probeOrder) {
4520
4753
  try {
4754
+ console.log(`[ActivityPushChannel] \u{1F50D} probe try ${env} token=${short}\u2026`);
4521
4755
  await this.sendToAPNsOnce(deviceToken, payload, opts, env);
4522
4756
  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
- }
4757
+ console.log(`[ActivityPushChannel] \u2705 probe bound to ${env} (token=${short}\u2026)`);
4526
4758
  return;
4527
4759
  } catch (err) {
4528
4760
  lastErr = err;
4761
+ const reason = err instanceof ApnsError ? JSON.parse(err.responseBody || "{}")?.reason ?? err.statusCode : String(err);
4762
+ console.log(`[ActivityPushChannel] \u{1F50D} probe ${env} failed: ${reason}`);
4529
4763
  if (!isBadDeviceTokenError(err)) {
4530
4764
  throw err;
4531
4765
  }
4532
4766
  }
4533
4767
  }
4768
+ this.deadTokens.add(deviceToken);
4769
+ for (const [sid, tok] of this.tokens) {
4770
+ if (tok === deviceToken) {
4771
+ this.tokens.delete(sid);
4772
+ console.warn(`[ActivityPushChannel] \u2620\uFE0F dead token, session removed: session=${sid.slice(0, 8)}\u2026 token=${short}\u2026 (Activity ended/iOS reinstalled)`);
4773
+ break;
4774
+ }
4775
+ }
4534
4776
  throw lastErr ?? new Error("APNs send failed: all environments rejected token");
4535
4777
  }
4536
4778
  /** 单次 APNs HTTP/2 请求(内部 helper,不做探测) */
@@ -4546,21 +4788,25 @@ var ActivityPushChannel = class {
4546
4788
  } catch (err) {
4547
4789
  return reject(err);
4548
4790
  }
4549
- const req = client.request({
4791
+ const headers = {
4550
4792
  ":method": "POST",
4551
4793
  ":path": `/3/device/${deviceToken}`,
4552
4794
  "authorization": `bearer ${jwt}`,
4553
4795
  "apns-topic": topic,
4554
4796
  "apns-push-type": "liveactivity",
4555
4797
  "apns-priority": priority,
4556
- "apns-expiration": String(Math.floor(Date.now() / 1e3) + 30),
4798
+ "apns-expiration": String(Math.floor(Date.now() / 1e3) + 300),
4557
4799
  "content-type": "application/json",
4558
4800
  "content-length": Buffer.byteLength(payloadStr)
4559
- });
4801
+ };
4802
+ if (opts.collapseId) {
4803
+ headers["apns-collapse-id"] = opts.collapseId;
4804
+ }
4805
+ const req = client.request(headers);
4560
4806
  let statusCode = 0;
4561
4807
  let responseData = "";
4562
- req.on("response", (headers) => {
4563
- statusCode = Number(headers[":status"] ?? 0);
4808
+ req.on("response", (headers2) => {
4809
+ statusCode = Number(headers2[":status"] ?? 0);
4564
4810
  });
4565
4811
  req.on("data", (chunk) => {
4566
4812
  responseData += chunk;
@@ -4617,10 +4863,10 @@ var ApnsError = class extends Error {
4617
4863
  };
4618
4864
  function isBadDeviceTokenError(err) {
4619
4865
  if (!(err instanceof ApnsError)) return false;
4620
- if (err.statusCode !== 400 && err.statusCode !== 410) return false;
4866
+ if (err.statusCode !== 400 && err.statusCode !== 403 && err.statusCode !== 410) return false;
4621
4867
  try {
4622
4868
  const parsed = JSON.parse(err.responseBody);
4623
- return parsed.reason === "BadDeviceToken" || parsed.reason === "Unregistered";
4869
+ return parsed.reason === "BadDeviceToken" || parsed.reason === "BadEnvironmentKeyInToken" || parsed.reason === "Unregistered";
4624
4870
  } catch {
4625
4871
  return false;
4626
4872
  }
@@ -5181,9 +5427,6 @@ var AuthManager = class extends import_events3.EventEmitter {
5181
5427
  }
5182
5428
  };
5183
5429
 
5184
- // src/server.ts
5185
- var import_promises8 = require("fs/promises");
5186
-
5187
5430
  // src/terminal/TerminalExecutor.ts
5188
5431
  var import_node_child_process8 = require("child_process");
5189
5432
  var import_uuid5 = require("uuid");
@@ -6687,7 +6930,8 @@ async function start(opts = {}) {
6687
6930
  onFire: async (task) => {
6688
6931
  const p = task.payload;
6689
6932
  if (p.kind === "create") {
6690
- await (0, import_promises7.mkdir)(p.projectPath, { recursive: true });
6933
+ const dirExists = await (0, import_promises7.stat)(p.projectPath).then((s) => s.isDirectory()).catch(() => false);
6934
+ if (!dirExists) await (0, import_promises7.mkdir)(p.projectPath, { recursive: true });
6691
6935
  const session = await sessionManager.createSession(
6692
6936
  p.projectPath,
6693
6937
  p.message,
@@ -6755,7 +6999,8 @@ async function start(opts = {}) {
6755
6999
  try {
6756
7000
  switch (event.type) {
6757
7001
  case "create_session": {
6758
- await (0, import_promises7.mkdir)(event.projectPath, { recursive: true });
7002
+ const dirExists = await (0, import_promises7.stat)(event.projectPath).then((s) => s.isDirectory()).catch(() => false);
7003
+ if (!dirExists) await (0, import_promises7.mkdir)(event.projectPath, { recursive: true });
6759
7004
  const resumeId = event.resumeSessionId ?? event.newSessionId;
6760
7005
  if (resumeId) sessionFileWatcher.unwatch(resumeId);
6761
7006
  await sessionManager.createSession(
@@ -6815,38 +7060,44 @@ async function start(opts = {}) {
6815
7060
  sessions: sessionManager.getActiveSessions()
6816
7061
  });
6817
7062
  sessionManager.flushPendingAssistant(event.sessionId);
6818
- const bufferedEvents = [...sessionManager.getSessionEvents(event.sessionId)];
6819
7063
  if (sessionManager.isBufferTruncated(event.sessionId)) {
6820
7064
  const projectPath = sessionManager.getSessionProjectPath(event.sessionId);
6821
7065
  if (projectPath) {
6822
7066
  const historyResult = await getSessionHistory(projectPath, event.sessionId);
7067
+ const buffered = [...sessionManager.getSessionEvents(event.sessionId)];
6823
7068
  if (historyResult.ok && historyResult.value.length > 0) {
6824
- const merged = [...historyResult.value, ...bufferedEvents];
7069
+ const merged = [...historyResult.value, ...buffered];
6825
7070
  wsBridge.send(ws, {
6826
7071
  type: "session_history",
6827
7072
  sessionId: event.sessionId,
6828
7073
  events: merged
6829
7074
  });
6830
- } else if (bufferedEvents.length > 0) {
7075
+ } else if (buffered.length > 0) {
6831
7076
  wsBridge.send(ws, {
6832
7077
  type: "session_history",
6833
7078
  sessionId: event.sessionId,
6834
- events: bufferedEvents
7079
+ events: buffered
6835
7080
  });
6836
7081
  }
6837
- } else if (bufferedEvents.length > 0) {
7082
+ } else {
7083
+ const buffered = [...sessionManager.getSessionEvents(event.sessionId)];
7084
+ if (buffered.length > 0) {
7085
+ wsBridge.send(ws, {
7086
+ type: "session_history",
7087
+ sessionId: event.sessionId,
7088
+ events: buffered
7089
+ });
7090
+ }
7091
+ }
7092
+ } else {
7093
+ const buffered = [...sessionManager.getSessionEvents(event.sessionId)];
7094
+ if (buffered.length > 0) {
6838
7095
  wsBridge.send(ws, {
6839
7096
  type: "session_history",
6840
7097
  sessionId: event.sessionId,
6841
- events: bufferedEvents
7098
+ events: buffered
6842
7099
  });
6843
7100
  }
6844
- } else if (bufferedEvents.length > 0) {
6845
- wsBridge.send(ws, {
6846
- type: "session_history",
6847
- sessionId: event.sessionId,
6848
- events: bufferedEvents
6849
- });
6850
7101
  }
6851
7102
  for (const req of approvalProxy.getPendingRequestsForSession(event.sessionId)) {
6852
7103
  wsBridge.send(ws, { type: "approval_request", request: req });
@@ -6943,7 +7194,7 @@ async function start(opts = {}) {
6943
7194
  if (!isStreaming) {
6944
7195
  const filePath = getSessionFilePath(event.projectPath, event.sessionId);
6945
7196
  try {
6946
- const fileStat = await (0, import_promises8.stat)(filePath);
7197
+ const fileStat = await (0, import_promises7.stat)(filePath);
6947
7198
  sessionFileWatcher.watch(event.sessionId, filePath, fileStat.size);
6948
7199
  } catch {
6949
7200
  }
@@ -7027,6 +7278,9 @@ async function start(opts = {}) {
7027
7278
  wsBridge.clearViewingSession(ws);
7028
7279
  break;
7029
7280
  }
7281
+ case "approval_displayed": {
7282
+ break;
7283
+ }
7030
7284
  case "always_allow_tool": {
7031
7285
  approvalProxy.addToClaudeSettings(event.projectPath, event.toolName);
7032
7286
  break;
@@ -7226,7 +7480,7 @@ async function start(opts = {}) {
7226
7480
  if (wsBridge.isViewingSession(request.sessionId)) return;
7227
7481
  const pendingCount = approvalProxy.getPendingRequestsForSession(request.sessionId).length;
7228
7482
  notificationService.notifyApproval(request, pendingCount);
7229
- }, 5e3);
7483
+ }, 3e3);
7230
7484
  setTimeout(() => {
7231
7485
  if (!approvalProxy.isPending(request.id)) return;
7232
7486
  if (wsBridge.isViewingSession(request.sessionId)) return;