sessix-server 0.4.5 → 0.4.7

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 +369 -115
  2. package/dist/server.js +369 -115
  3. package/package.json +1 -1
package/dist/index.js CHANGED
@@ -947,9 +947,13 @@ ${context}`;
947
947
  throw new Error(`Session ${sessionId} stdin unavailable`);
948
948
  }
949
949
  const toolResult = JSON.stringify({
950
- type: "tool_result",
951
- tool_use_id: toolUseId,
952
- content: answer
950
+ type: "user",
951
+ session_id: "",
952
+ message: {
953
+ role: "user",
954
+ content: [{ type: "tool_result", tool_use_id: toolUseId, content: answer }]
955
+ },
956
+ parent_tool_use_id: toolUseId
953
957
  });
954
958
  await new Promise((resolve, reject) => {
955
959
  entry.process.stdin.write(toolResult + "\n", (err) => {
@@ -2960,6 +2964,8 @@ var ApprovalProxy = class _ApprovalProxy {
2960
2964
  this.handleApprovalHook(req, res);
2961
2965
  } else if (req.method === "POST" && pathname === "/hook/notify") {
2962
2966
  this.handleHookNotify(req, res);
2967
+ } else if (req.method === "POST" && pathname === "/api/resolve") {
2968
+ this.handleApiResolve(req, res);
2963
2969
  } else if (req.method === "POST" && pathname === "/pair") {
2964
2970
  this.handlePair(req, res);
2965
2971
  } else if (req.method === "GET" && pathname === "/health") {
@@ -3025,6 +3031,34 @@ var ApprovalProxy = class _ApprovalProxy {
3025
3031
  this.sendJson(res, 200, { decision: "deny", reason: "Server failed to process request" });
3026
3032
  }
3027
3033
  }
3034
+ /**
3035
+ * 移动端 API 端点:Widget Extension / Watch 直接提交审批决策
3036
+ *
3037
+ * 绕过 WebSocket 链路,让 iOS Widget Extension 的 AppIntent 在 App 挂起时
3038
+ * 仍能直接将审批结果提交到服务端。使用 Bearer token 鉴权(与 WS 同一 token)。
3039
+ */
3040
+ async handleApiResolve(req, res) {
3041
+ const authHeader = req.headers.authorization ?? "";
3042
+ const bearerToken = authHeader.startsWith("Bearer ") ? authHeader.slice(7) : "";
3043
+ if (bearerToken !== this.token) {
3044
+ this.sendJson(res, 401, { ok: false, error: "Unauthorized" });
3045
+ return;
3046
+ }
3047
+ try {
3048
+ const body = await this.parseJsonBody(req);
3049
+ const requestId = String(body.requestId ?? "").trim();
3050
+ const decision = String(body.decision ?? "").trim();
3051
+ if (!requestId || decision !== "allow" && decision !== "deny") {
3052
+ this.sendJson(res, 400, { ok: false, error: "requestId and decision (allow|deny) required" });
3053
+ return;
3054
+ }
3055
+ const resolved = this.resolveApproval(requestId, { decision });
3056
+ this.sendJson(res, 200, { ok: resolved });
3057
+ } catch (err) {
3058
+ console.error("[ApprovalProxy] /api/resolve error:", err);
3059
+ this.sendJson(res, 500, { ok: false, error: "Internal error" });
3060
+ }
3061
+ }
3028
3062
  /**
3029
3063
  * 非阻塞 hook 通知端点
3030
3064
  *
@@ -3736,7 +3770,7 @@ var HookInstaller = class {
3736
3770
  // src/notification/NotificationService.ts
3737
3771
  var import_node_path5 = require("path");
3738
3772
  var RECENT_ACTIVITY_MAX = 6;
3739
- var ACTIVITY_PUSH_THROTTLE_MS = 2500;
3773
+ var ACTIVITY_PUSH_THROTTLE_MS = 4e3;
3740
3774
  var NotificationService = class {
3741
3775
  constructor(sessionManager, expoChannel = null) {
3742
3776
  this.sessionManager = sessionManager;
@@ -3761,8 +3795,24 @@ var NotificationService = class {
3761
3795
  activityPushTimers = /* @__PURE__ */ new Map();
3762
3796
  /** 上次推送 LA content 的时间戳(用于节流;首次立即推送) */
3763
3797
  lastActivityPushAt = /* @__PURE__ */ new Map();
3798
+ /** 挂起的优先级提升请求(状态变化时设为 '10',flush 后清除) */
3799
+ pendingPriority = /* @__PURE__ */ new Map();
3800
+ /** sessionId → 累计活动计数器(用于 summary 模式的 activitySummary) */
3801
+ activityCounters = /* @__PURE__ */ new Map();
3764
3802
  /** 提供器:根据 sessionId 取该会话的待审批列表(由 server.ts 注入) */
3765
3803
  pendingApprovalsProvider = null;
3804
+ /**
3805
+ * sessionId → idle 结束定时器。
3806
+ * 会话变为 idle 时启动(30 秒);用户发新消息重回 running 时取消。
3807
+ * 30 秒内无新消息 → 调 endActivity 关闭 LA,同时发横幅通知告知完成。
3808
+ */
3809
+ idleEndTimers = /* @__PURE__ */ new Map();
3810
+ /**
3811
+ * sessionId → LA 心跳定时器(setInterval)。
3812
+ * 确保 Agent 子任务等长时间无 claude_event 的场景下 LA 仍持续更新。
3813
+ * token 注册时启动,flushActivityEnd / removeActivityPushToken 时停止。
3814
+ */
3815
+ laHeartbeatTimers = /* @__PURE__ */ new Map();
3766
3816
  /** 添加通知渠道(id 唯一,可用于后续动态开关) */
3767
3817
  addChannel(id, channel, enabled = true) {
3768
3818
  this.channelMap.set(id, { channel, enabled });
@@ -3797,13 +3847,16 @@ var NotificationService = class {
3797
3847
  this.activityPushChannel.addToken(sessionId, token);
3798
3848
  console.log(`[NotificationService] \u2705 LA push token \u5DF2\u6CE8\u518C (session=${sessionId.slice(0, 8)}\u2026, token=${token.slice(0, 16)}\u2026)`);
3799
3849
  this.scheduleActivityPush(sessionId, true);
3850
+ this.startLaHeartbeat(sessionId);
3800
3851
  }
3801
3852
  /** 移除 ActivityKit push token */
3802
3853
  removeActivityPushToken(sessionId) {
3854
+ this.stopLaHeartbeat(sessionId);
3803
3855
  this.activityPushChannel?.removeToken(sessionId);
3804
3856
  this.clearActivityPushTimer(sessionId);
3805
3857
  this.recentActivityState.delete(sessionId);
3806
3858
  this.lastActivityPushAt.delete(sessionId);
3859
+ this.activityCounters.delete(sessionId);
3807
3860
  }
3808
3861
  /** 注入"会话 → 待审批列表"提供器(server.ts 启动时调用) */
3809
3862
  setPendingApprovalsProvider(fn) {
@@ -3822,7 +3875,7 @@ var NotificationService = class {
3822
3875
  this.yoloModeState.set(sessionId, enabled);
3823
3876
  }
3824
3877
  /** 直接触发审批通知(由 ApprovalProxy 回调调用) */
3825
- notifyApproval(request, pendingCount) {
3878
+ async notifyApproval(request, pendingCount) {
3826
3879
  if (this.yoloModeState.get(request.sessionId)) return;
3827
3880
  const sessionTitle = this.getSessionTitle(request.sessionId);
3828
3881
  const title = pendingCount > 1 ? t("notification.pendingApprovals", { title: sessionTitle, count: pendingCount }) : sessionTitle;
@@ -3832,7 +3885,8 @@ var NotificationService = class {
3832
3885
  const isYoloMode = this.getYoloMode(request.sessionId);
3833
3886
  const recentActivity = this.getRecentActivity(request.sessionId);
3834
3887
  const latestMessage = recentActivity[recentActivity.length - 1] ?? "";
3835
- this.activityPushChannel.updateActivityWithAlert(
3888
+ const session = this.sessionManager.getActiveSessions().find((s) => s.id === request.sessionId);
3889
+ const sent = await this.activityPushChannel.updateActivityWithAlert(
3836
3890
  request.sessionId,
3837
3891
  {
3838
3892
  status: "waitingApproval",
@@ -3847,12 +3901,22 @@ var NotificationService = class {
3847
3901
  pendingCount
3848
3902
  },
3849
3903
  isYoloMode,
3850
- updatedAt: Date.now()
3904
+ updatedAt: Date.now(),
3905
+ displayMode: "summary",
3906
+ activitySummary: this.buildActivitySummary(request.sessionId),
3907
+ startedAt: session?.createdAt,
3908
+ stats: this.buildStatsPayload(session)
3851
3909
  },
3852
3910
  { title, body }
3853
3911
  );
3854
- this.lastActivityPushAt.set(request.sessionId, Date.now());
3855
- return;
3912
+ if (sent) {
3913
+ console.log(`[NotificationService] \u{1F4E1} approval via ActivityKit push session=${request.sessionId.slice(0, 8)}\u2026 tool=${request.toolName}`);
3914
+ this.lastActivityPushAt.set(request.sessionId, Date.now());
3915
+ return;
3916
+ }
3917
+ console.warn(`[NotificationService] \u26A0\uFE0F ActivityKit push \u5931\u8D25\uFF0C\u964D\u7EA7\u5230 Expo push session=${request.sessionId.slice(0, 8)}\u2026`);
3918
+ } else {
3919
+ console.log(`[NotificationService] \u{1F4F2} approval via Expo push session=${request.sessionId.slice(0, 8)}\u2026 tool=${request.toolName}`);
3856
3920
  }
3857
3921
  const dangerLevel = this.getDangerLevel(request.toolName);
3858
3922
  const isDangerous = dangerLevel === "danger" || dangerLevel === "write";
@@ -3886,15 +3950,20 @@ var NotificationService = class {
3886
3950
  if (this.activityPushChannel?.hasToken(request.sessionId)) {
3887
3951
  const isYoloMode = this.getYoloMode(request.sessionId);
3888
3952
  const recentActivity = this.getRecentActivity(request.sessionId);
3953
+ const session = this.sessionManager.getActiveSessions().find((s) => s.id === request.sessionId);
3889
3954
  this.activityPushChannel.updateActivityWithAlert(
3890
3955
  request.sessionId,
3891
3956
  {
3892
- status: "waitingApproval",
3957
+ status: "waitingQuestion",
3893
3958
  sessionTitle,
3894
3959
  latestMessage: request.question.slice(0, 80),
3895
3960
  recentActivity,
3896
3961
  isYoloMode,
3897
- updatedAt: Date.now()
3962
+ updatedAt: Date.now(),
3963
+ displayMode: "summary",
3964
+ activitySummary: this.buildActivitySummary(request.sessionId),
3965
+ startedAt: session?.createdAt,
3966
+ stats: this.buildStatsPayload(session)
3898
3967
  },
3899
3968
  { title: sessionTitle, body }
3900
3969
  );
@@ -3936,6 +4005,8 @@ var NotificationService = class {
3936
4005
  this.activityPushTimers.clear();
3937
4006
  this.recentActivityState.clear();
3938
4007
  this.lastActivityPushAt.clear();
4008
+ this.pendingPriority.clear();
4009
+ this.activityCounters.clear();
3939
4010
  }
3940
4011
  // ============================================
3941
4012
  // 内部方法
@@ -3959,22 +4030,14 @@ var NotificationService = class {
3959
4030
  case "status_change": {
3960
4031
  this.clearActivityPushTimer(event.sessionId);
3961
4032
  if (event.status === "idle") {
3962
- const sessionTitle = this.getSessionTitle(event.sessionId);
3963
- const latestMsg = this.latestAssistantText.get(event.sessionId);
3964
- const body = latestMsg ? `\u2705 ${latestMsg.slice(0, 80)}` : t("notification.taskComplete");
3965
- const isYoloMode = this.getYoloMode(event.sessionId);
4033
+ this.cancelIdleEndTimer(event.sessionId);
3966
4034
  if (this.activityPushChannel?.hasToken(event.sessionId)) {
3967
- this.activityPushChannel.endActivity(event.sessionId, {
3968
- status: "idle",
3969
- sessionTitle,
3970
- latestMessage: body,
3971
- recentActivity: this.getRecentActivity(event.sessionId),
3972
- isYoloMode,
3973
- updatedAt: Date.now()
3974
- });
3975
- this.recentActivityState.delete(event.sessionId);
3976
- this.lastActivityPushAt.delete(event.sessionId);
4035
+ this.scheduleActivityPush(event.sessionId, true, "10");
4036
+ this.scheduleIdleEnd(event.sessionId, 3e4);
3977
4037
  } else {
4038
+ const sessionTitle = this.getSessionTitle(event.sessionId);
4039
+ const latestMsg = this.latestAssistantText.get(event.sessionId);
4040
+ const body = latestMsg ? `\u2705 ${latestMsg.slice(0, 80)}` : t("notification.taskComplete");
3978
4041
  this.notify({
3979
4042
  title: sessionTitle,
3980
4043
  body,
@@ -3983,31 +4046,12 @@ var NotificationService = class {
3983
4046
  data: { type: "task_complete", sessionId: event.sessionId }
3984
4047
  });
3985
4048
  }
4049
+ } else if (event.status === "running" || event.status === "waiting_approval" || event.status === "waiting_question") {
4050
+ this.cancelIdleEndTimer(event.sessionId);
4051
+ this.scheduleActivityPush(event.sessionId, true, "10");
3986
4052
  } else if (event.status === "error") {
3987
- const sessionTitle = this.getSessionTitle(event.sessionId);
3988
- const latestMsg = this.latestAssistantText.get(event.sessionId);
3989
- const body = latestMsg ? `\u274C ${latestMsg.slice(0, 80)}` : t("notification.taskError");
3990
- const isYoloMode = this.getYoloMode(event.sessionId);
3991
- if (this.activityPushChannel?.hasToken(event.sessionId)) {
3992
- this.activityPushChannel.endActivity(event.sessionId, {
3993
- status: "error",
3994
- sessionTitle,
3995
- latestMessage: body,
3996
- recentActivity: this.getRecentActivity(event.sessionId),
3997
- isYoloMode,
3998
- updatedAt: Date.now()
3999
- });
4000
- this.recentActivityState.delete(event.sessionId);
4001
- this.lastActivityPushAt.delete(event.sessionId);
4002
- } else {
4003
- this.notify({
4004
- title: sessionTitle,
4005
- body,
4006
- sound: "default",
4007
- badge: this.getGlobalPendingCount(),
4008
- data: { type: "task_error", sessionId: event.sessionId }
4009
- });
4010
- }
4053
+ this.cancelIdleEndTimer(event.sessionId);
4054
+ this.flushActivityEnd(event.sessionId, "error");
4011
4055
  }
4012
4056
  break;
4013
4057
  }
@@ -4083,6 +4127,7 @@ var NotificationService = class {
4083
4127
  } else if (block.type === "tool_use") {
4084
4128
  const line = this.summarizeToolCall(block.name, block.input ?? {});
4085
4129
  if (line) next.push(line);
4130
+ this.incrementCounter(sessionId, block.name);
4086
4131
  }
4087
4132
  }
4088
4133
  state.currentEntries = next;
@@ -4094,9 +4139,66 @@ var NotificationService = class {
4094
4139
  const combined = [...state.history, ...state.currentEntries];
4095
4140
  return combined.slice(-RECENT_ACTIVITY_MAX);
4096
4141
  }
4142
+ /** 工具名 → 计数器类别映射 */
4143
+ incrementCounter(sessionId, toolName) {
4144
+ let c = this.activityCounters.get(sessionId);
4145
+ if (!c) {
4146
+ c = { filesEdited: 0, commandsRun: 0, searches: 0, filesRead: 0, messagesReceived: 0 };
4147
+ this.activityCounters.set(sessionId, c);
4148
+ }
4149
+ switch (toolName) {
4150
+ case "Edit":
4151
+ case "MultiEdit":
4152
+ case "Write":
4153
+ case "NotebookEdit":
4154
+ c.filesEdited++;
4155
+ break;
4156
+ case "Bash":
4157
+ c.commandsRun++;
4158
+ break;
4159
+ case "Grep":
4160
+ case "Glob":
4161
+ case "WebSearch":
4162
+ case "WebFetch":
4163
+ c.searches++;
4164
+ break;
4165
+ case "Read":
4166
+ c.filesRead++;
4167
+ break;
4168
+ default:
4169
+ break;
4170
+ }
4171
+ }
4172
+ /** 把累计计数器格式化为可读摘要(如"已编辑 3 个文件 · 运行 5 条命令") */
4173
+ buildActivitySummary(sessionId) {
4174
+ const c = this.activityCounters.get(sessionId);
4175
+ if (!c) return "";
4176
+ const parts = [];
4177
+ if (c.filesEdited > 0) parts.push(`\u5DF2\u7F16\u8F91 ${c.filesEdited} \u4E2A\u6587\u4EF6`);
4178
+ if (c.commandsRun > 0) parts.push(`\u8FD0\u884C ${c.commandsRun} \u6761\u547D\u4EE4`);
4179
+ if (c.searches > 0) parts.push(`\u641C\u7D22 ${c.searches} \u6B21`);
4180
+ if (c.filesRead > 0) parts.push(`\u9605\u8BFB ${c.filesRead} \u4E2A\u6587\u4EF6`);
4181
+ return parts.join(" \xB7 ") || "\u4F1A\u8BDD\u8FDB\u884C\u4E2D\u2026";
4182
+ }
4183
+ buildStatsPayload(session) {
4184
+ return {
4185
+ totalInputTokens: session?.stats?.totalInputTokens ?? 0,
4186
+ totalOutputTokens: session?.stats?.totalOutputTokens ?? 0,
4187
+ totalCostUsd: session?.stats?.totalCostUsd,
4188
+ totalDurationMs: session?.stats?.totalDurationMs,
4189
+ runningStartedAt: session?.stats?.runningStartedAt
4190
+ };
4191
+ }
4097
4192
  /** 节流调度 LA content push;首次立即推,后续合并到 throttle window 末尾 */
4098
- scheduleActivityPush(sessionId, force = false) {
4099
- if (!this.activityPushChannel?.hasToken(sessionId)) return;
4193
+ scheduleActivityPush(sessionId, force = false, priority) {
4194
+ if (!this.activityPushChannel) return;
4195
+ if (!this.activityPushChannel.hasToken(sessionId)) {
4196
+ console.warn(`[NotificationService] \u26A0\uFE0F skip LA push: session=${sessionId.slice(0, 8)}\u2026 token \u672A\u6CE8\u518C`);
4197
+ return;
4198
+ }
4199
+ if (priority === "10") {
4200
+ this.pendingPriority.set(sessionId, "10");
4201
+ }
4100
4202
  const now = Date.now();
4101
4203
  const last = this.lastActivityPushAt.get(sessionId) ?? 0;
4102
4204
  const elapsed = now - last;
@@ -4122,6 +4224,101 @@ var NotificationService = class {
4122
4224
  this.activityPushTimers.delete(sessionId);
4123
4225
  }
4124
4226
  }
4227
+ /**
4228
+ * 启动 LA 心跳(每 ACTIVITY_PUSH_THROTTLE_MS 触发一次 scheduleActivityPush)。
4229
+ * 确保 Agent 子会话等长时间无 claude_event 的情况下 LA 仍持续更新。
4230
+ * 心跳只在会话活跃状态(running/waitingApproval/waitingQuestion)下发推送;
4231
+ * scheduleActivityPush 自带节流,心跳与正常事件驱动不冲突。
4232
+ */
4233
+ startLaHeartbeat(sessionId) {
4234
+ if (this.laHeartbeatTimers.has(sessionId)) return;
4235
+ const timer = setInterval(() => {
4236
+ if (!this.activityPushChannel?.hasToken(sessionId)) {
4237
+ this.stopLaHeartbeat(sessionId);
4238
+ return;
4239
+ }
4240
+ const session = this.sessionManager.getActiveSessions().find((s) => s.id === sessionId);
4241
+ if (!session) {
4242
+ this.stopLaHeartbeat(sessionId);
4243
+ return;
4244
+ }
4245
+ if (session.status === "running" || session.status === "waiting_approval" || session.status === "waiting_question") {
4246
+ this.scheduleActivityPush(sessionId);
4247
+ }
4248
+ }, ACTIVITY_PUSH_THROTTLE_MS);
4249
+ this.laHeartbeatTimers.set(sessionId, timer);
4250
+ }
4251
+ /** 停止 LA 心跳 */
4252
+ stopLaHeartbeat(sessionId) {
4253
+ const timer = this.laHeartbeatTimers.get(sessionId);
4254
+ if (timer) {
4255
+ clearInterval(timer);
4256
+ this.laHeartbeatTimers.delete(sessionId);
4257
+ }
4258
+ }
4259
+ /** 取消 idle 结束定时器 */
4260
+ cancelIdleEndTimer(sessionId) {
4261
+ const timer = this.idleEndTimers.get(sessionId);
4262
+ if (timer) {
4263
+ clearTimeout(timer);
4264
+ this.idleEndTimers.delete(sessionId);
4265
+ }
4266
+ }
4267
+ /**
4268
+ * 启动 idle 结束定时器:delayMs 后若会话仍 idle,调 endActivity + 发通知。
4269
+ * 保证多轮对话期间(idle→running→idle)LA 不会过早消失。
4270
+ */
4271
+ scheduleIdleEnd(sessionId, delayMs) {
4272
+ const timer = setTimeout(() => {
4273
+ this.idleEndTimers.delete(sessionId);
4274
+ this.flushActivityEnd(sessionId, "idle");
4275
+ }, delayMs);
4276
+ this.idleEndTimers.set(sessionId, timer);
4277
+ }
4278
+ /**
4279
+ * 结束 LA 并发完成通知。有 LA token → APNs event:end(带横幅);
4280
+ * 无 token → 普通 Expo push。清理所有相关状态。
4281
+ */
4282
+ flushActivityEnd(sessionId, reason) {
4283
+ const sessionTitle = this.getSessionTitle(sessionId);
4284
+ const latestMsg = this.latestAssistantText.get(sessionId);
4285
+ const isError = reason === "error";
4286
+ const body = isError ? `\u274C ${latestMsg?.slice(0, 80) ?? t("notification.taskError")}` : `\u2705 ${latestMsg?.slice(0, 80) ?? t("notification.taskComplete")}`;
4287
+ const isYoloMode = this.getYoloMode(sessionId);
4288
+ const session = this.sessionManager.getActiveSessions().find((s) => s.id === sessionId);
4289
+ this.notify({
4290
+ title: sessionTitle,
4291
+ body,
4292
+ sound: "default",
4293
+ badge: this.getGlobalPendingCount(),
4294
+ data: { type: isError ? "task_error" : "task_complete", sessionId }
4295
+ });
4296
+ if (this.activityPushChannel?.hasToken(sessionId)) {
4297
+ this.activityPushChannel.endActivity(
4298
+ sessionId,
4299
+ {
4300
+ status: isError ? "error" : "completed",
4301
+ sessionTitle,
4302
+ latestMessage: body,
4303
+ recentActivity: this.getRecentActivity(sessionId),
4304
+ isYoloMode,
4305
+ updatedAt: Date.now(),
4306
+ displayMode: "summary",
4307
+ activitySummary: this.buildActivitySummary(sessionId),
4308
+ startedAt: session?.createdAt,
4309
+ stats: this.buildStatsPayload(session)
4310
+ }
4311
+ // 不传 alert——Expo push 已处理通知,event:end 仅用于关闭 LA
4312
+ ).catch((err) => {
4313
+ console.warn("[NotificationService] endActivity (close LA) failed, LA may linger:", err);
4314
+ });
4315
+ }
4316
+ this.stopLaHeartbeat(sessionId);
4317
+ this.recentActivityState.delete(sessionId);
4318
+ this.lastActivityPushAt.delete(sessionId);
4319
+ this.activityCounters.delete(sessionId);
4320
+ console.log(`[NotificationService] \u{1F3C1} LA end (${reason}) session=${sessionId.slice(0, 8)}\u2026`);
4321
+ }
4125
4322
  /** 真正发送一次 LA content push(无 alert) */
4126
4323
  flushActivityPush(sessionId) {
4127
4324
  const channel = this.activityPushChannel;
@@ -4141,7 +4338,10 @@ var NotificationService = class {
4141
4338
  latestMessage,
4142
4339
  recentActivity,
4143
4340
  isYoloMode,
4144
- updatedAt: Date.now()
4341
+ updatedAt: Date.now(),
4342
+ displayMode: "summary",
4343
+ activitySummary: this.buildActivitySummary(sessionId),
4344
+ startedAt: session.createdAt
4145
4345
  };
4146
4346
  if (latestApproval) {
4147
4347
  contentState.approvalInfo = {
@@ -4152,17 +4352,15 @@ var NotificationService = class {
4152
4352
  pendingCount: pendingApprovals.length
4153
4353
  };
4154
4354
  }
4155
- if (session.stats) {
4156
- contentState.stats = {
4157
- totalInputTokens: session.stats.totalInputTokens,
4158
- totalOutputTokens: session.stats.totalOutputTokens,
4159
- totalCostUsd: session.stats.totalCostUsd
4160
- };
4161
- }
4355
+ contentState.stats = this.buildStatsPayload(session);
4356
+ const priority = this.pendingPriority.get(sessionId) ?? "5";
4357
+ this.pendingPriority.delete(sessionId);
4162
4358
  this.lastActivityPushAt.set(sessionId, Date.now());
4163
4359
  const lineCount = recentActivity.length;
4164
- channel.updateActivity(sessionId, contentState).then(() => {
4165
- console.log(`[NotificationService] \u{1F4E1} LA push \u2713 session=${sessionId.slice(0, 8)}\u2026 status=${status} lines=${lineCount}`);
4360
+ channel.updateActivity(sessionId, contentState, { priority }).then((ok) => {
4361
+ if (ok) {
4362
+ console.log(`[NotificationService] \u{1F4E1} LA push \u2713 session=${sessionId.slice(0, 8)}\u2026 status=${status} p=${priority} lines=${lineCount}`);
4363
+ }
4166
4364
  }).catch((err) => {
4167
4365
  console.warn(`[NotificationService] \u{1F4E1} LA push \u2717 session=${sessionId.slice(0, 8)}\u2026:`, err instanceof Error ? err.message : err);
4168
4366
  });
@@ -4319,12 +4517,13 @@ var ExpoNotificationChannel = class {
4319
4517
  }
4320
4518
  async send(payload) {
4321
4519
  if (this.tokens.size === 0) return;
4322
- const offlineTokens = Array.from(this.tokens).filter((token) => {
4520
+ const isCompletionNotif = payload.data?.type === "task_complete" || payload.data?.type === "task_error";
4521
+ const targetTokens = isCompletionNotif ? Array.from(this.tokens) : Array.from(this.tokens).filter((token) => {
4323
4522
  const ws = this.tokenWsMap.get(token);
4324
4523
  return !ws || ws.readyState !== ws.OPEN;
4325
4524
  });
4326
- if (offlineTokens.length === 0) return;
4327
- const messages = offlineTokens.map((to) => {
4525
+ if (targetTokens.length === 0) return;
4526
+ const messages = targetTokens.map((to) => {
4328
4527
  let sound = payload.sound ?? "default";
4329
4528
  const prefs = this.soundPreferences.get(to);
4330
4529
  if (prefs) {
@@ -4346,7 +4545,7 @@ var ExpoNotificationChannel = class {
4346
4545
  };
4347
4546
  });
4348
4547
  try {
4349
- console.log(`[ExpoNotificationChannel] ${t("notification.sendingPush")} (${offlineTokens.length}/${this.tokens.size} devices)`, offlineTokens);
4548
+ console.log(`[ExpoNotificationChannel] ${t("notification.sendingPush")} (${targetTokens.length}/${this.tokens.size} devices${isCompletionNotif ? ", forced" : ""})`, targetTokens);
4350
4549
  const res = await fetch(EXPO_PUSH_API, {
4351
4550
  method: "POST",
4352
4551
  headers: { "Content-Type": "application/json", Accept: "application/json" },
@@ -4390,6 +4589,12 @@ var ActivityPushChannel = class {
4390
4589
  * 同时维护两个连接 + 探测机制,避免环境配错时静默失败。
4391
4590
  */
4392
4591
  tokenEnv = /* @__PURE__ */ new Map();
4592
+ /**
4593
+ * 两个环境都拒绝的 token(Live Activity 已销毁/过期)。
4594
+ * 标记后跳过所有发送尝试,避免无意义的 HTTP/2 请求。
4595
+ * 当同一 session 注册新 token 时自动清除旧 token 的标记。
4596
+ */
4597
+ deadTokens = /* @__PURE__ */ new Set();
4393
4598
  /** 首次探测顺序(来自配置 hint),未配置则先试 sandbox(开发场景占多数) */
4394
4599
  probeOrder;
4395
4600
  teamId;
@@ -4426,6 +4631,11 @@ var ActivityPushChannel = class {
4426
4631
  }
4427
4632
  /** 注册 Activity push token */
4428
4633
  addToken(sessionId, token) {
4634
+ const oldToken = this.tokens.get(sessionId);
4635
+ if (oldToken && oldToken !== token) {
4636
+ this.tokenEnv.delete(oldToken);
4637
+ this.deadTokens.delete(oldToken);
4638
+ }
4429
4639
  const existed = this.tokens.has(sessionId);
4430
4640
  this.tokens.set(sessionId, token);
4431
4641
  console.log(`[ActivityPushChannel] Token ${existed ? "updated" : "registered"}: session=${sessionId.slice(0, 8)}\u2026 token=${token.slice(0, 16)}\u2026`);
@@ -4434,65 +4644,82 @@ var ActivityPushChannel = class {
4434
4644
  removeToken(sessionId) {
4435
4645
  const tok = this.tokens.get(sessionId);
4436
4646
  this.tokens.delete(sessionId);
4437
- if (tok) this.tokenEnv.delete(tok);
4647
+ if (tok) {
4648
+ this.tokenEnv.delete(tok);
4649
+ this.deadTokens.delete(tok);
4650
+ }
4438
4651
  }
4439
- /** 发送 content-state 更新到指定会话的 Live Activity(纯内容刷新,不响通知) */
4440
- async updateActivity(sessionId, contentState) {
4652
+ /** 发送 content-state 更新到指定会话的 Live Activity(纯内容刷新,不响通知)。返回 true 表示实际发出 */
4653
+ async updateActivity(sessionId, contentState, opts) {
4441
4654
  const token = this.tokens.get(sessionId);
4442
- if (!token) return;
4655
+ if (!token) return false;
4443
4656
  const now = Math.floor(Date.now() / 1e3);
4657
+ const priority = opts?.priority ?? "5";
4444
4658
  const payload = {
4445
4659
  aps: {
4446
4660
  timestamp: now,
4447
4661
  event: "update",
4448
4662
  "content-state": contentState,
4449
- // 2 分钟没新内容就让 LA 进入 stale 状态(系统自动灰化),避免显示陈旧数据
4450
- "stale-date": now + 120
4663
+ "stale-date": now + 600
4451
4664
  }
4452
4665
  };
4453
4666
  try {
4454
- await this.sendToAPNs(token, payload, { priority: "5" });
4667
+ await this.sendToAPNs(token, payload, {
4668
+ priority,
4669
+ collapseId: `state-${sessionId.slice(0, 54)}`
4670
+ });
4671
+ return true;
4455
4672
  } catch (err) {
4456
4673
  console.warn(`[ActivityPushChannel] Update failed session=${sessionId}:`, err);
4674
+ return false;
4457
4675
  }
4458
4676
  }
4459
- /** 发送带通知的 content-state 更新(审批请求时使用) */
4677
+ /** 发送带通知的 content-state 更新(审批请求时使用),返回是否发送成功 */
4460
4678
  async updateActivityWithAlert(sessionId, contentState, alert) {
4461
4679
  const token = this.tokens.get(sessionId);
4462
- if (!token) return;
4680
+ if (!token) return false;
4463
4681
  const now = Math.floor(Date.now() / 1e3);
4464
4682
  const payload = {
4465
4683
  aps: {
4466
4684
  timestamp: now,
4467
4685
  event: "update",
4468
4686
  "content-state": contentState,
4469
- "stale-date": now + 120,
4687
+ "stale-date": now + 600,
4470
4688
  alert,
4471
4689
  sound: "default"
4472
4690
  }
4473
4691
  };
4474
4692
  try {
4475
4693
  await this.sendToAPNs(token, payload, { priority: "10" });
4694
+ return true;
4476
4695
  } catch (err) {
4477
4696
  console.warn(`[ActivityPushChannel] Alert update failed session=${sessionId}:`, err);
4697
+ return false;
4478
4698
  }
4479
4699
  }
4480
- /** 结束指定会话的 Live Activity */
4481
- async endActivity(sessionId, contentState) {
4700
+ /**
4701
+ * 结束指定会话的 Live Activity。
4702
+ * 可选 alert:APNs event=end 时同时推送横幅通知 + 声音,用于在会话完成时提醒用户。
4703
+ */
4704
+ async endActivity(sessionId, contentState, opts) {
4482
4705
  const token = this.tokens.get(sessionId);
4483
4706
  if (!token) return;
4484
4707
  const now = Math.floor(Date.now() / 1e3);
4485
- const payload = {
4486
- aps: {
4487
- timestamp: now,
4488
- event: "end",
4489
- "content-state": contentState
4490
- }
4708
+ const aps = {
4709
+ timestamp: now,
4710
+ event: "end",
4711
+ "content-state": contentState
4491
4712
  };
4713
+ if (opts?.alert) {
4714
+ aps.alert = opts.alert;
4715
+ aps.sound = "default";
4716
+ }
4717
+ const payload = { aps };
4492
4718
  try {
4493
4719
  await this.sendToAPNs(token, payload, { priority: "10" });
4494
4720
  } catch (err) {
4495
- console.warn(`[ActivityPushChannel] End failed session=${sessionId}:`, err);
4721
+ this.tokens.delete(sessionId);
4722
+ throw err;
4496
4723
  }
4497
4724
  this.tokens.delete(sessionId);
4498
4725
  }
@@ -4503,29 +4730,44 @@ var ActivityPushChannel = class {
4503
4730
  /**
4504
4731
  * 发送 APNs,自动处理环境探测。
4505
4732
  * 对每个 token:先用已确认的环境(如有);否则按 probeOrder 顺序探测,
4506
- * 收到 BadDeviceToken 自动切到另一个环境,并把成功的环境绑定到该 token。
4733
+ * 收到 BadDeviceToken / BadEnvironmentKeyInToken 自动切到另一个环境,
4734
+ * 并把成功的环境绑定到该 token。
4507
4735
  */
4508
4736
  async sendToAPNs(deviceToken, payload, opts = {}) {
4737
+ if (this.deadTokens.has(deviceToken)) {
4738
+ throw new Error(`token permanently dead (Activity ended): ${deviceToken.slice(0, 16)}\u2026`);
4739
+ }
4509
4740
  const known = this.tokenEnv.get(deviceToken);
4510
4741
  if (known) {
4511
4742
  return this.sendToAPNsOnce(deviceToken, payload, opts, known);
4512
4743
  }
4744
+ const short = deviceToken.slice(0, 16);
4745
+ console.log(`[ActivityPushChannel] \u{1F50D} probe start token=${short}\u2026 order=[${this.probeOrder.join(",")}]`);
4513
4746
  let lastErr = null;
4514
4747
  for (const env of this.probeOrder) {
4515
4748
  try {
4749
+ console.log(`[ActivityPushChannel] \u{1F50D} probe try ${env} token=${short}\u2026`);
4516
4750
  await this.sendToAPNsOnce(deviceToken, payload, opts, env);
4517
4751
  this.tokenEnv.set(deviceToken, env);
4518
- if (env !== this.probeOrder[0]) {
4519
- console.log(`[ActivityPushChannel] Token bound to ${env} after probe (token=${deviceToken.slice(0, 16)}\u2026)`);
4520
- }
4752
+ console.log(`[ActivityPushChannel] \u2705 probe bound to ${env} (token=${short}\u2026)`);
4521
4753
  return;
4522
4754
  } catch (err) {
4523
4755
  lastErr = err;
4756
+ const reason = err instanceof ApnsError ? JSON.parse(err.responseBody || "{}")?.reason ?? err.statusCode : String(err);
4757
+ console.log(`[ActivityPushChannel] \u{1F50D} probe ${env} failed: ${reason}`);
4524
4758
  if (!isBadDeviceTokenError(err)) {
4525
4759
  throw err;
4526
4760
  }
4527
4761
  }
4528
4762
  }
4763
+ this.deadTokens.add(deviceToken);
4764
+ for (const [sid, tok] of this.tokens) {
4765
+ if (tok === deviceToken) {
4766
+ this.tokens.delete(sid);
4767
+ console.warn(`[ActivityPushChannel] \u2620\uFE0F dead token, session removed: session=${sid.slice(0, 8)}\u2026 token=${short}\u2026 (Activity ended/iOS reinstalled)`);
4768
+ break;
4769
+ }
4770
+ }
4529
4771
  throw lastErr ?? new Error("APNs send failed: all environments rejected token");
4530
4772
  }
4531
4773
  /** 单次 APNs HTTP/2 请求(内部 helper,不做探测) */
@@ -4541,21 +4783,25 @@ var ActivityPushChannel = class {
4541
4783
  } catch (err) {
4542
4784
  return reject(err);
4543
4785
  }
4544
- const req = client.request({
4786
+ const headers = {
4545
4787
  ":method": "POST",
4546
4788
  ":path": `/3/device/${deviceToken}`,
4547
4789
  "authorization": `bearer ${jwt}`,
4548
4790
  "apns-topic": topic,
4549
4791
  "apns-push-type": "liveactivity",
4550
4792
  "apns-priority": priority,
4551
- "apns-expiration": String(Math.floor(Date.now() / 1e3) + 30),
4793
+ "apns-expiration": String(Math.floor(Date.now() / 1e3) + 300),
4552
4794
  "content-type": "application/json",
4553
4795
  "content-length": Buffer.byteLength(payloadStr)
4554
- });
4796
+ };
4797
+ if (opts.collapseId) {
4798
+ headers["apns-collapse-id"] = opts.collapseId;
4799
+ }
4800
+ const req = client.request(headers);
4555
4801
  let statusCode = 0;
4556
4802
  let responseData = "";
4557
- req.on("response", (headers) => {
4558
- statusCode = Number(headers[":status"] ?? 0);
4803
+ req.on("response", (headers2) => {
4804
+ statusCode = Number(headers2[":status"] ?? 0);
4559
4805
  });
4560
4806
  req.on("data", (chunk) => {
4561
4807
  responseData += chunk;
@@ -4596,7 +4842,7 @@ var ActivityPushChannel = class {
4596
4842
  const signingInput = `${header}.${claims}`;
4597
4843
  const sign = crypto.createSign("SHA256");
4598
4844
  sign.update(signingInput);
4599
- const signature = sign.sign(this.authKey, "base64url");
4845
+ const signature = sign.sign({ key: this.authKey, dsaEncoding: "ieee-p1363" }, "base64url");
4600
4846
  const token = `${signingInput}.${signature}`;
4601
4847
  this.cachedJwt = { token, expiresAt: now + 3e3 };
4602
4848
  return token;
@@ -4612,10 +4858,10 @@ var ApnsError = class extends Error {
4612
4858
  };
4613
4859
  function isBadDeviceTokenError(err) {
4614
4860
  if (!(err instanceof ApnsError)) return false;
4615
- if (err.statusCode !== 400 && err.statusCode !== 410) return false;
4861
+ if (err.statusCode !== 400 && err.statusCode !== 403 && err.statusCode !== 410) return false;
4616
4862
  try {
4617
4863
  const parsed = JSON.parse(err.responseBody);
4618
- return parsed.reason === "BadDeviceToken" || parsed.reason === "Unregistered";
4864
+ return parsed.reason === "BadDeviceToken" || parsed.reason === "BadEnvironmentKeyInToken" || parsed.reason === "Unregistered";
4619
4865
  } catch {
4620
4866
  return false;
4621
4867
  }
@@ -5176,9 +5422,6 @@ var AuthManager = class extends import_events3.EventEmitter {
5176
5422
  }
5177
5423
  };
5178
5424
 
5179
- // src/server.ts
5180
- var import_promises8 = require("fs/promises");
5181
-
5182
5425
  // src/terminal/TerminalExecutor.ts
5183
5426
  var import_node_child_process8 = require("child_process");
5184
5427
  var import_uuid5 = require("uuid");
@@ -6682,7 +6925,8 @@ async function start(opts = {}) {
6682
6925
  onFire: async (task) => {
6683
6926
  const p = task.payload;
6684
6927
  if (p.kind === "create") {
6685
- await (0, import_promises7.mkdir)(p.projectPath, { recursive: true });
6928
+ const dirExists = await (0, import_promises7.stat)(p.projectPath).then((s) => s.isDirectory()).catch(() => false);
6929
+ if (!dirExists) await (0, import_promises7.mkdir)(p.projectPath, { recursive: true });
6686
6930
  const session = await sessionManager.createSession(
6687
6931
  p.projectPath,
6688
6932
  p.message,
@@ -6750,7 +6994,8 @@ async function start(opts = {}) {
6750
6994
  try {
6751
6995
  switch (event.type) {
6752
6996
  case "create_session": {
6753
- await (0, import_promises7.mkdir)(event.projectPath, { recursive: true });
6997
+ const dirExists = await (0, import_promises7.stat)(event.projectPath).then((s) => s.isDirectory()).catch(() => false);
6998
+ if (!dirExists) await (0, import_promises7.mkdir)(event.projectPath, { recursive: true });
6754
6999
  const resumeId = event.resumeSessionId ?? event.newSessionId;
6755
7000
  if (resumeId) sessionFileWatcher.unwatch(resumeId);
6756
7001
  await sessionManager.createSession(
@@ -6810,38 +7055,44 @@ async function start(opts = {}) {
6810
7055
  sessions: sessionManager.getActiveSessions()
6811
7056
  });
6812
7057
  sessionManager.flushPendingAssistant(event.sessionId);
6813
- const bufferedEvents = [...sessionManager.getSessionEvents(event.sessionId)];
6814
7058
  if (sessionManager.isBufferTruncated(event.sessionId)) {
6815
7059
  const projectPath = sessionManager.getSessionProjectPath(event.sessionId);
6816
7060
  if (projectPath) {
6817
7061
  const historyResult = await getSessionHistory(projectPath, event.sessionId);
7062
+ const buffered = [...sessionManager.getSessionEvents(event.sessionId)];
6818
7063
  if (historyResult.ok && historyResult.value.length > 0) {
6819
- const merged = [...historyResult.value, ...bufferedEvents];
7064
+ const merged = [...historyResult.value, ...buffered];
6820
7065
  wsBridge.send(ws, {
6821
7066
  type: "session_history",
6822
7067
  sessionId: event.sessionId,
6823
7068
  events: merged
6824
7069
  });
6825
- } else if (bufferedEvents.length > 0) {
7070
+ } else if (buffered.length > 0) {
6826
7071
  wsBridge.send(ws, {
6827
7072
  type: "session_history",
6828
7073
  sessionId: event.sessionId,
6829
- events: bufferedEvents
7074
+ events: buffered
6830
7075
  });
6831
7076
  }
6832
- } else if (bufferedEvents.length > 0) {
7077
+ } else {
7078
+ const buffered = [...sessionManager.getSessionEvents(event.sessionId)];
7079
+ if (buffered.length > 0) {
7080
+ wsBridge.send(ws, {
7081
+ type: "session_history",
7082
+ sessionId: event.sessionId,
7083
+ events: buffered
7084
+ });
7085
+ }
7086
+ }
7087
+ } else {
7088
+ const buffered = [...sessionManager.getSessionEvents(event.sessionId)];
7089
+ if (buffered.length > 0) {
6833
7090
  wsBridge.send(ws, {
6834
7091
  type: "session_history",
6835
7092
  sessionId: event.sessionId,
6836
- events: bufferedEvents
7093
+ events: buffered
6837
7094
  });
6838
7095
  }
6839
- } else if (bufferedEvents.length > 0) {
6840
- wsBridge.send(ws, {
6841
- type: "session_history",
6842
- sessionId: event.sessionId,
6843
- events: bufferedEvents
6844
- });
6845
7096
  }
6846
7097
  for (const req of approvalProxy.getPendingRequestsForSession(event.sessionId)) {
6847
7098
  wsBridge.send(ws, { type: "approval_request", request: req });
@@ -6938,7 +7189,7 @@ async function start(opts = {}) {
6938
7189
  if (!isStreaming) {
6939
7190
  const filePath = getSessionFilePath(event.projectPath, event.sessionId);
6940
7191
  try {
6941
- const fileStat = await (0, import_promises8.stat)(filePath);
7192
+ const fileStat = await (0, import_promises7.stat)(filePath);
6942
7193
  sessionFileWatcher.watch(event.sessionId, filePath, fileStat.size);
6943
7194
  } catch {
6944
7195
  }
@@ -7022,6 +7273,9 @@ async function start(opts = {}) {
7022
7273
  wsBridge.clearViewingSession(ws);
7023
7274
  break;
7024
7275
  }
7276
+ case "approval_displayed": {
7277
+ break;
7278
+ }
7025
7279
  case "always_allow_tool": {
7026
7280
  approvalProxy.addToClaudeSettings(event.projectPath, event.toolName);
7027
7281
  break;
@@ -7221,7 +7475,7 @@ async function start(opts = {}) {
7221
7475
  if (wsBridge.isViewingSession(request.sessionId)) return;
7222
7476
  const pendingCount = approvalProxy.getPendingRequestsForSession(request.sessionId).length;
7223
7477
  notificationService.notifyApproval(request, pendingCount);
7224
- }, 5e3);
7478
+ }, 3e3);
7225
7479
  setTimeout(() => {
7226
7480
  if (!approvalProxy.isPending(request.id)) return;
7227
7481
  if (wsBridge.isViewingSession(request.sessionId)) return;