omniterm 0.2.27

Web-based tmux terminal manager — one browser tab to watch and drive your AI coding agents
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
use std::sync::Arc;
use std::sync::atomic::{AtomicU8, AtomicU64, Ordering};
use std::time::Duration;

use sqlx::sqlite::SqlitePool;
use tokio::time::interval;

use crate::acp::chat_persistence;
use crate::acp::client::{AcpClient, SystemNotice, TurnEndEvent};
use crate::acp::permission::{
    MAX_PERM_NOTICE_REQUESTS, PermissionRequestSummary, pick_auto_option,
    summarize_permission_request,
};
use crate::acp::supervisor::AcpSupervisor;

/// 静默待命回收阈值(秒)默认值:无进行中 prompt、无未决权限、且距最后活动满 5 分钟即回收。
/// 实际阈值可由 `run_reaper` 的 `Arc<AtomicU64>` 在运行时覆盖(main.rs 从 settings 表注入),
/// 此常量作为 DB 无配置时的兜底默认值。
pub const IDLE_RECYCLE_SECS: u64 = 300;

/// 权限请求无响应兜底阈值(秒)默认值:有未决权限但久无活动满 30 分钟则按
/// [`PermissionTimeoutMode`] 行动(默认取消并回收)。实际阈值与模式均可由
/// settings 表运行时覆盖(main.rs 注入 `PermissionTimeoutConfig`),此常量作为
/// DB 无配置时的兜底默认值。
/// 注:PermissionManager 不做超时自动应答(ACP 规范 Cancelled 仅用于
/// session/cancel 语义,且审批须等真人决策),此为无人应答时的唯一兜底。
pub const REQUIRES_ACTION_RECYCLE_SECS: u64 = 1800;

/// 权限请求超时时长(settings 表 `acp_perm_timeout_secs`,秒)的档位规则。
///
/// 2026-10-01 起时长改用秒制并新增两个档位:面板滑块按 30 秒步进,用户可在
/// 「总是」(= 不等待)与 30 秒之间取舍。`[PERM_TIMEOUT_NEVER_SECS]` 是特档:
/// 不表示「马上超时」,而是**没有超时触发点**——语义随模式而定(见
/// [`run_reaper`] 中的三分支):
///
/// - `auto` + 总是:权限请求一出现就在下一个 tick 自动应答(不等待用户)
/// - `abort`/`wait` + 总是:没有触发点 = 永不超时(等同一直等待),同时跳过
///   prompt-stale 定稿,避免把「等审批」的回合误判卡死(与 D1 wait 模式同因)
pub const PERM_TIMEOUT_NEVER_SECS: u64 = 0;
/// 滑块步进(秒):全程 30 秒一档,`0` 与 `30..=3600` 的 30 秒倍数都合法。
pub const PERM_TIMEOUT_STEP_SECS: u64 = 30;
/// 滑块上限(秒,1 小时),与分钟制滑块的 60 分钟上限一致。
pub const MAX_PERM_TIMEOUT_SECS: u64 = 3600;
/// 兜底默认值(秒),DB 无配置时沿用 2026-08-18 起的 30 分钟。
pub const DEFAULT_PERM_TIMEOUT_SECS: u64 = REQUIRES_ACTION_RECYCLE_SECS;

/// 时长是否落在允许的档位上(`0` 或 ≤1 小时的 30 秒倍数)。
pub fn is_valid_perm_timeout_secs(secs: u64) -> bool {
    secs <= MAX_PERM_TIMEOUT_SECS && secs.is_multiple_of(PERM_TIMEOUT_STEP_SECS)
}

/// 解析 settings 表中的秒制时长。缺失/非数字/越档位一律 `None`,调用方回退
/// (见 [`DEFAULT_PERM_TIMEOUT_SECS`])——非法值不静默改写成别的档位。
pub fn perm_timeout_secs_from_setting(setting: Option<&str>) -> Option<u64> {
    let secs = setting.map(str::trim)?.parse::<u64>().ok()?;
    is_valid_perm_timeout_secs(secs).then_some(secs)
}

/// 兼容 2026-10-01 之前的分钟制 key(`acp_perm_timeout_min`):换算成秒后仍要
/// 过档位校验(旧的整数分钟天然都是 30 秒的倍数)。新 key 一旦写入即以新 key
/// 为准,本函数只在新 key 缺失时兜底。
pub fn perm_timeout_secs_from_legacy_min(setting: Option<&str>) -> Option<u64> {
    let secs = setting.map(str::trim)?.parse::<u64>().ok()?.checked_mul(60)?;
    is_valid_perm_timeout_secs(secs).then_some(secs)
}

/// 超时时长折算成中文文案(text 列兜底 / 后端测试断言)。整分钟走「分钟」,
/// 其余(30 秒档、90 秒…)走「秒」;`0`(「总是」档)返回空串——该档位不是
/// 「马上超时」,句式由调用方另行选择。
fn format_perm_duration(secs: u64) -> String {
    if secs.is_multiple_of(60) { format!("{} 分钟", secs / 60) } else { format!("{secs} 秒") }
}

/// prompt 卡死兜底阈值(秒):有进行中 prompt 但久无 agent 通知满 10 分钟,
/// 强制定稿 turn 并广播结束。兜底不发送 PromptResponse 的 agent(§8 多实现兼容)。
/// 定稿后下一轮 idle 检查会按常规回收进程。
pub const PROMPT_STALE_SECS: u64 = 600;

/// 看护任务扫描间隔(秒)。
const TICK_SECS: u64 = 30;

/// 权限超时「超时中止」行动写入会话的 system 消息 label(i18n key;前端未命中
/// key 时原样显示,2026-08-18 起的历史中文数据靠该回退保持可读)。
pub const SYSTEM_LABEL_PERM_TIMEOUT_ABORT: &str = "system.permTimeout.abort";

/// 权限超时「自动推进」行动写入会话的 system 消息 label(i18n key)。
pub const SYSTEM_LABEL_PERM_TIMEOUT_AUTO: &str = "system.permTimeout.auto";

/// 权限请求超时后的行为模式(settings 表 `acp_perm_timeout_mode`,线格式为
/// 小写字符串;`Abort` 为默认,保留 2026-08-18 起的安全策略)。
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum PermissionTimeoutMode {
    /// 超时取消请求并回收会话(agent 终止)——默认。
    #[default]
    Abort,
    /// 超时自动代替用户应答未决审批,agent 继续执行(不 cancel、不杀会话)。
    Auto,
    /// 一直等待:权限未决期间不做任何超时动作(含 prompt-stale 定稿)。
    Wait,
}

impl PermissionTimeoutMode {
    /// 线格式(API / settings 表存储值,白名单校验用)。
    pub fn as_str(&self) -> &'static str {
        match self {
            PermissionTimeoutMode::Abort => "abort",
            PermissionTimeoutMode::Auto => "auto",
            PermissionTimeoutMode::Wait => "wait",
        }
    }

    /// 从线格式解析;非白名单值(含空/大小写不符)返回 `None`,调用方回退默认。
    pub fn from_str_opt(s: &str) -> Option<Self> {
        match s.trim() {
            "abort" => Some(PermissionTimeoutMode::Abort),
            "auto" => Some(PermissionTimeoutMode::Auto),
            "wait" => Some(PermissionTimeoutMode::Wait),
            _ => None,
        }
    }

    fn as_u8(self) -> u8 {
        match self {
            PermissionTimeoutMode::Abort => 0,
            PermissionTimeoutMode::Auto => 1,
            PermissionTimeoutMode::Wait => 2,
        }
    }

    fn from_u8(v: u8) -> Self {
        match v {
            1 => PermissionTimeoutMode::Auto,
            2 => PermissionTimeoutMode::Wait,
            _ => PermissionTimeoutMode::Abort,
        }
    }
}

/// 权限超时配置(模式 + 秒级阈值)。原子字段使 reaper 每个 tick 读到最新值
/// (PUT 路由热更新,无需重启);`Default` 即 [`PermissionTimeoutMode::Abort`]
/// + [`REQUIRES_ACTION_RECYCLE_SECS`],与 DB 无配置时的行为一致。
#[derive(Debug)]
pub struct PermissionTimeoutConfig {
    mode: AtomicU8,
    secs: AtomicU64,
}

impl Default for PermissionTimeoutConfig {
    fn default() -> Self {
        Self::new(PermissionTimeoutMode::Abort, REQUIRES_ACTION_RECYCLE_SECS)
    }
}

impl PermissionTimeoutConfig {
    pub fn new(mode: PermissionTimeoutMode, secs: u64) -> Self {
        Self { mode: AtomicU8::new(mode.as_u8()), secs: AtomicU64::new(secs) }
    }

    /// 读取当前 (模式, 秒级阈值)。
    pub fn snapshot(&self) -> (PermissionTimeoutMode, u64) {
        (
            PermissionTimeoutMode::from_u8(self.mode.load(Ordering::Relaxed)),
            self.secs.load(Ordering::Relaxed),
        )
    }

    /// 热更新(PUT 路由写入后调用;reaper 下一 tick 即生效)。
    pub fn store(&self, mode: PermissionTimeoutMode, secs: u64) {
        self.mode.store(mode.as_u8(), Ordering::Relaxed);
        self.secs.store(secs, Ordering::Relaxed);
    }
}

/// 超时行动前写入聊天并广播的系统消息:`label` 为 i18n key,`text` 为中文兜底
/// 文案(`text` 列,与 2026-08-18 起的 system 行语义一致),`detail` 供前端
/// 本地化渲染\"错过了什么\"(请求工具/内容预览/可选项/实际动作)。
struct PermissionTimeoutNotice {
    label: &'static str,
    text: String,
    detail: serde_json::Value,
}

/// 把请求摘要拼成一行中文说明(text 列用;前端渲染走 detail + i18n)。
fn format_request_line(summary: &PermissionRequestSummary) -> String {
    let tool = match (&summary.tool, &summary.kind) {
        (Some(t), Some(k)) if t != k => format!("{t}({k})"),
        (Some(t), _) => t.clone(),
        (None, Some(k)) => k.clone(),
        (None, None) => "未知工具".to_string(),
    };
    let mut parts = vec![format!("请求:{tool}")];
    if let Some(content) = &summary.content {
        let mut line = format!("内容:{content}");
        if summary.content_omitted > 0 {
            line.push_str(&format!("…(已省略 {} 字符)", summary.content_omitted));
        }
        parts.push(line);
    }
    if !summary.options.is_empty() {
        parts.push(format!("可选项:{}", summary.options.join(" / ")));
    }
    parts.join(";")
}

/// 组装「超时中止」告知:取消 + 回收前让用户知道错过了哪笔请求、当时有哪些选项。
fn build_perm_notice_abort(
    summaries: &[PermissionRequestSummary],
    extra: usize,
    secs: u64,
) -> PermissionTimeoutNotice {
    let mut text = format!(
        "权限请求 {}未获响应,系统已自动取消该请求并回收会话(agent 已终止)。可重新打开会话继续。",
        format_perm_duration(secs)
    );
    for s in summaries {
        text.push(' ');
        text.push_str(&format_request_line(s));
    }
    if extra > 0 {
        text.push_str(&format!("(另有 {extra} 项审批一并取消)"));
    }
    PermissionTimeoutNotice {
        label: SYSTEM_LABEL_PERM_TIMEOUT_ABORT,
        text,
        detail: detail_json(summaries.first(), None, secs, extra),
    }
}

/// 组装「自动推进」告知:代替用户选了哪个选项、原请求是什么。
///
/// `secs == 0`(面板「总是」档)表示**没有等待**:文案改用不报时长的句式
/// (「未获响应」而非「N 秒未获响应」),与 i18n 的 `system.permTimeout.autoAlways`
/// 一一对应。
fn build_perm_notice_auto(
    summary: &PermissionRequestSummary,
    selected: &str,
    secs: u64,
) -> PermissionTimeoutNotice {
    let waited = if secs == PERM_TIMEOUT_NEVER_SECS {
        "权限请求未获响应".to_string()
    } else {
        format!("权限请求 {}未获响应", format_perm_duration(secs))
    };
    let mut text = format!("{waited},已按设置自动选择「{selected}」继续执行。");
    text.push(' ');
    text.push_str(&format_request_line(summary));
    PermissionTimeoutNotice {
        label: SYSTEM_LABEL_PERM_TIMEOUT_AUTO,
        text,
        detail: detail_json(Some(summary), Some(selected), secs, 0),
    }
}

fn detail_json(
    summary: Option<&PermissionRequestSummary>,
    selected: Option<&str>,
    secs: u64,
    extra: usize,
) -> serde_json::Value {
    serde_json::json!({
        // 秒制时长(前端按 <60s 显示秒、否则显示分钟)。2026-10-01 前的历史行
        // 只有 `minutes`,前端读不到 seconds 时按 minutes × 60 回退。
        "seconds": secs,
        "tool": summary.and_then(|s| s.tool.clone()),
        "kind": summary.and_then(|s| s.kind.clone()),
        "content": summary.and_then(|s| s.content.clone()),
        "content_omitted": summary.map_or(0, |s| s.content_omitted),
        "options": summary.map(|s| s.options.clone()).unwrap_or_default(),
        "selected": selected,
        "extra": extra,
    })
}

/// 落库(刷新后 hydrate 仍可见)+ 广播给在线 WS 连接(shutdown/cancel 前连接仍存活)。
async fn persist_and_broadcast(
    db: &SqlitePool,
    session_id: &str,
    client: &AcpClient,
    notice: &PermissionTimeoutNotice,
) {
    let blocks = serde_json::json!([
        { "type": "system", "label": notice.label, "detail": notice.detail }
    ])
    .to_string();
    if let Err(e) =
        chat_persistence::insert_message(db, session_id, "system", &notice.text, Some(&blocks))
            .await
    {
        tracing::warn!(
            session_id = %session_id,
            error = %e,
            "reaper: failed to persist permission-timeout system message"
        );
    }
    client.notify_system_message(SystemNotice {
        label: notice.label.to_string(),
        detail: Some(notice.detail.clone()),
    });
}

/// 自动推进:代替用户应答全部未决审批(不 cancel、不杀会话,agent 继续执行),
/// 并为每笔被应答的请求落一条带详情的 system 消息(§P1:消息数受
/// [`MAX_PERM_NOTICE_REQUESTS`] 限制,超出的笔数只应答不再逐条告知)。
///
/// 返回是否至少成功应答一项;一项都解析不出(agent 发了无合法选项的请求)时
/// 返回 `false`,调用方降级为超时中止——不能解析就绝不瞎猜,也不能永久挂起。
async fn auto_advance_permissions(
    db: &SqlitePool,
    session_id: &str,
    client: &AcpClient,
    secs: u64,
) -> bool {
    let events = client.pending_permission_events().await;
    let mut resolved = 0usize;
    for event in &events {
        let Some((option_id, selected)) = pick_auto_option(&event.request) else {
            tracing::warn!(
                session_id = %session_id,
                "reaper: permission request has no selectable option; skipping auto-advance for it"
            );
            continue;
        };
        if !client.resolve_permission(&event.id, &option_id).await {
            // 已被其他路径(用户点击 / cancel_all)解决,无需再告知。
            continue;
        }
        resolved += 1;
        if resolved > MAX_PERM_NOTICE_REQUESTS {
            continue;
        }
        let summary = summarize_permission_request(&event.request);
        let notice = build_perm_notice_auto(&summary, &selected, secs);
        persist_and_broadcast(db, session_id, client, &notice).await;
    }
    if resolved > MAX_PERM_NOTICE_REQUESTS {
        tracing::warn!(
            session_id = %session_id,
            resolved,
            cap = MAX_PERM_NOTICE_REQUESTS,
            "reaper: auto-advance resolved more permissions than the notice cap; extra ones answered without a chat notice"
        );
    }
    resolved > 0
}

/// 空闲回收看护任务。
///
/// 周期性遍历 supervisor 中所有 ACP client,按后端可观测的活跃度信号决定回收:
/// - idle 超时(静默待命)→ 强制 `shutdown` kill 子进程
/// - 权限请求超时无响应(requires_action 但无人应答)→ 按 [`PermissionTimeoutMode`]
///   行动:`Abort` 先取消再 kill(原安全策略);`Auto` 代替用户应答让 agent 继续;
///   `Wait` 不做任何动作(含跳过下面的 prompt-stale 定稿)
/// - prompt 卡死(有进行中 prompt 但久无通知)→ 强制定稿 turn,不杀进程
///
/// 活跃判定逻辑见 `AcpClient::is_idle_stale` / `is_permission_stale` / `is_prompt_stale`。
/// 进程所有权在后端,回收即 kill 子进程、释放内存。`shutdown` 走 shared reference,
/// 即使 WS 连接仍持有 `Arc<AcpClient>` 也会立即触发连接任务退出、杀子进程,保证
/// supervisor 移除与进程死亡同步(否则 Sidebar 的 `acp_process_alive` 与实际进程
/// 存活脱节)。
///
/// `idle_recycle_secs` 为共享的 idle 回收阈值(秒):main.rs 从 settings 表读取
/// `acp_idle_recycle_min` 换算后注入,可在运行时热更新。每个 tick 判定前动态
/// `load`,改动无需重启即可生效;缺省兜底见 [`IDLE_RECYCLE_SECS`]。
///
/// `perm_timeout` 为共享的权限超时配置(模式 + 秒级阈值):main.rs 从 settings 表
/// 读取 `acp_perm_timeout_mode` / `acp_perm_timeout_secs` 注入,运行时热更新;
/// 缺省兜底见 [`PermissionTimeoutConfig::default`]。阈值 `0` 是「总是」档
/// ([`PERM_TIMEOUT_NEVER_SECS`],语义见其文档注释)。
///
/// `db` 用于权限超时行动时写入 system 告知消息(agent 被取消/被自动应答的原因,
/// 用户刷新会话后仍可见);idle 回收(用户完全不用)不写。
pub async fn run_reaper(
    supervisor: AcpSupervisor,
    db: SqlitePool,
    idle_recycle_secs: Arc<AtomicU64>,
    perm_timeout: Arc<PermissionTimeoutConfig>,
) {
    let mut ticker = interval(Duration::from_secs(TICK_SECS));
    loop {
        ticker.tick().await;

        // 1) 快照 + 判定(不在持锁状态下做 async 回收)
        // idle / 权限超时阈值每次判定前动态读取,使运行时改配置即时生效。
        let idle_secs = idle_recycle_secs.load(Ordering::Relaxed);
        let (perm_mode, perm_secs) = perm_timeout.snapshot();
        let mut to_reap: Vec<(String, bool /*perm_stale*/)> = Vec::new();
        for (sid, client) in supervisor.snapshot().await {
            if client.is_idle_stale(idle_secs).await {
                to_reap.push((sid, false));
                continue;
            }
            let pending = client.pending_permissions().await;
            if pending > 0 {
                // 时长档位语义(`0` = 面板「总是」档,不是「马上超时」):
                // - auto:有未决请求即视为到点 → 下一个 tick 自动应答,不等待;
                // - abort:没有触发点 = 永不超时(用户要「不中止」请用 wait 模式);
                // - wait:同 D1,无任何超时动作。
                let fired = if perm_secs == PERM_TIMEOUT_NEVER_SECS {
                    perm_mode == PermissionTimeoutMode::Auto
                } else {
                    client.is_permission_stale(perm_secs).await
                };
                if fired {
                    match perm_mode {
                        PermissionTimeoutMode::Wait => {
                            // 一直等待:权限未决期间不做任何超时动作。回合保持
                            // \"等审批\"语义,用户回来时 banner 由 pending_events
                            // 重放恢复(见 ws/acp.rs 连接重放)。
                        }
                        PermissionTimeoutMode::Auto => {
                            if !auto_advance_permissions(&db, &sid, &client, perm_secs).await {
                                // 一个选项都解析不出来:不能永久挂起,降级为超时中止。
                                tracing::warn!(
                                    session_id = %sid,
                                    "reaper: auto-advance found no selectable option; falling back to abort"
                                );
                                to_reap.push((sid, true));
                            }
                        }
                        PermissionTimeoutMode::Abort => to_reap.push((sid, true)),
                    }
                    continue;
                }
                if perm_secs == PERM_TIMEOUT_NEVER_SECS || perm_mode == PermissionTimeoutMode::Wait
                {
                    // 无超时触发点(「总是」档 / wait 模式)的未决审批同样跳过
                    // prompt-stale:把\"等审批\"的回合误判为卡死会强制作废并广播
                    // 结束,与用户随后的应答产生竞态。
                    continue;
                }
            }
            if client.is_prompt_stale(PROMPT_STALE_SECS) {
                tracing::warn!(
                    session_id = %sid,
                    "prompt active but no agent activity for {}s; force-finalizing turn",
                    PROMPT_STALE_SECS
                );
                client.mark_prompt_idle();
                // `abnormal: false`:同样是**合成的非协议原因**(宿主侧观测到的
                // prompt 卡死定稿,不是协议回了非正常 stopReason);且本路径不写留痕
                // system 消息(计划 D3 之外),error 语义会造成「错误提示但无解释」。
                // 保持既有行为不变。
                client.notify_turn_end(TurnEndEvent::Done {
                    stop_reason: "InactivityTimeout".into(),
                    row_id: client.turn_row_id(),
                    duration: client.turn_timing(),
                    abnormal: false,
                });
            }
        }

        // 2) 回收
        for (sid, perm_stale) in to_reap {
            if let Some(client) = supervisor.dispose(&sid).await {
                if perm_stale {
                    // 权限超时回收前,先让用户知道 agent 为什么消失、错过了什么:
                    // 1) 持久化 system 消息(含请求工具/内容预览/可选项,刷新后
                    //    hydrate 仍可见);
                    // 2) 广播给在线 WS 连接(shutdown 前,连接仍存活);
                    // 3) 再 cancel + kill(安全策略不变)。
                    let events = client.pending_permission_events().await;
                    let summaries: Vec<PermissionRequestSummary> = events
                        .iter()
                        .take(MAX_PERM_NOTICE_REQUESTS)
                        .map(|e| summarize_permission_request(&e.request))
                        .collect();
                    let notice = build_perm_notice_abort(
                        &summaries,
                        events.len().saturating_sub(MAX_PERM_NOTICE_REQUESTS),
                        perm_secs,
                    );
                    persist_and_broadcast(&db, &sid, &client, &notice).await;
                    // 先取消卡住的权限请求,避免 agent 永久阻塞
                    let _ = client.cancel();
                }
                // 强制回收:即使仍有 WS 连接持有 Arc 引用也主动 teardown 并 killpg
                // 杀 agent 进程组(D2)。
                // 旧实现依赖 `Arc::try_unwrap` 在引用归零后自然 drop 再杀进程,但
                // WS handler 持 `Option<Arc<AcpClient>>` 时引用永远不会归零 → 进程
                // 存活、可继续对话,而 supervisor 已移除该 session,`list_sessions`
                // 报 `acp_process_alive=false`,Sidebar 显示「已释放」与实际进程存活
                // 不一致,且进程脱离 reaper 管辖后无限驻留。`shutdown` 走 shared
                // reference 触发连接任务退出 + omniterm 侧 killpg 杀进程组,
                // WS 随之断开。详见 acp::agent_proc。
                client.shutdown().await;
            }
        }
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn mode_wire_format_is_whitelisted() {
        assert_eq!(PermissionTimeoutMode::default(), PermissionTimeoutMode::Abort);
        for mode in
            [PermissionTimeoutMode::Abort, PermissionTimeoutMode::Auto, PermissionTimeoutMode::Wait]
        {
            assert_eq!(PermissionTimeoutMode::from_str_opt(mode.as_str()), Some(mode));
        }
        // 白名单外一律 None(大小写不符、空串、旧值)。
        for bad in ["", "Abort", "AUTO", "never", "0"] {
            assert_eq!(PermissionTimeoutMode::from_str_opt(bad), None, "bad mode: {bad:?}");
        }
        // trim 容忍(DB 值可能带空白,与 acp_idle_recycle_secs_from_setting 一致)。
        assert_eq!(
            PermissionTimeoutMode::from_str_opt(" auto "),
            Some(PermissionTimeoutMode::Auto)
        );
    }

    #[test]
    fn config_defaults_match_legacy_behavior() {
        let cfg = PermissionTimeoutConfig::default();
        assert_eq!(cfg.snapshot(), (PermissionTimeoutMode::Abort, REQUIRES_ACTION_RECYCLE_SECS));
    }

    #[test]
    fn config_snapshot_store_roundtrip() {
        let cfg = PermissionTimeoutConfig::new(PermissionTimeoutMode::Wait, 600);
        assert_eq!(cfg.snapshot(), (PermissionTimeoutMode::Wait, 600));
        cfg.store(PermissionTimeoutMode::Auto, 120);
        assert_eq!(cfg.snapshot(), (PermissionTimeoutMode::Auto, 120));
        // 未知编码字节回退 Abort(不 panic)。
        cfg.mode.store(9, Ordering::Relaxed);
        assert_eq!(cfg.snapshot().0, PermissionTimeoutMode::Abort);
    }

    #[test]
    fn perm_timeout_secs_accepts_only_slider_notches() {
        for ok in [
            PERM_TIMEOUT_NEVER_SECS,
            PERM_TIMEOUT_STEP_SECS,
            60,
            DEFAULT_PERM_TIMEOUT_SECS,
            MAX_PERM_TIMEOUT_SECS,
        ] {
            assert!(is_valid_perm_timeout_secs(ok), "should accept {ok}");
            assert_eq!(perm_timeout_secs_from_setting(Some(&ok.to_string())), Some(ok));
        }
        // 非 30 秒倍数 / 越上限 / 非数字 → None(调用方回退默认,不静默改档)。
        for bad in [1, 45, 59, MAX_PERM_TIMEOUT_SECS + PERM_TIMEOUT_STEP_SECS, u64::MAX] {
            assert!(!is_valid_perm_timeout_secs(bad), "should reject {bad}");
        }
        for bad in [None, Some(""), Some("abc"), Some(" 45 "), Some("-30")] {
            assert_eq!(perm_timeout_secs_from_setting(bad), None, "bad value: {bad:?}");
        }
        // trim 容忍(DB 值可能带空白)。
        assert_eq!(perm_timeout_secs_from_setting(Some(" 1800 ")), Some(1800));
    }

    #[test]
    fn perm_timeout_secs_legacy_minutes_fallback() {
        // 2026-10-01 前的分钟制记录:新 key 缺失时换算成秒(整数分钟都是 30 秒倍数)。
        assert_eq!(perm_timeout_secs_from_legacy_min(Some("45")), Some(2700));
        assert_eq!(perm_timeout_secs_from_legacy_min(Some(" 1 ")), Some(60));
        // 越档 / 溢出 / 非数字 → None。
        for bad in [None, Some(""), Some("abc"), Some("61"), Some("18446744073709551615")] {
            assert_eq!(perm_timeout_secs_from_legacy_min(bad), None, "bad legacy: {bad:?}");
        }
    }

    fn sample_summary() -> PermissionRequestSummary {
        PermissionRequestSummary {
            tool: Some("Bash".into()),
            kind: Some("execute".into()),
            content: Some("git push origin main".into()),
            content_omitted: 0,
            options: vec!["允许一次".into(), "总是允许".into(), "拒绝".into()],
        }
    }

    #[test]
    fn abort_notice_carries_request_details() {
        let notice = build_perm_notice_abort(&[sample_summary()], 0, DEFAULT_PERM_TIMEOUT_SECS);
        assert_eq!(notice.label, SYSTEM_LABEL_PERM_TIMEOUT_ABORT);
        assert!(notice.text.contains("30 分钟"), "{}", notice.text);
        assert!(notice.text.contains("git push origin main"), "{}", notice.text);
        assert!(notice.text.contains("可选项:允许一次 / 总是允许 / 拒绝"), "{}", notice.text);
        let detail = &notice.detail;
        assert_eq!(detail["seconds"], DEFAULT_PERM_TIMEOUT_SECS);
        assert_eq!(detail["tool"], "Bash");
        assert_eq!(detail["options"][1], "总是允许");
        assert_eq!(detail["extra"], 0);
        assert!(detail["selected"].is_null());
    }

    #[test]
    fn notice_duration_spells_out_sub_minute_notches() {
        // 30 秒档:中文兜底文案不能用「0 分钟」,必须报秒。
        let notice = build_perm_notice_abort(&[sample_summary()], 0, 30);
        assert!(notice.text.starts_with("权限请求 30 秒未获响应"), "{}", notice.text);
        assert_eq!(notice.detail["seconds"], 30);
        // 非整分钟档报秒(90 秒不折成 1.5 分钟——档位本身就是 30 秒粒度)。
        let notice = build_perm_notice_abort(&[], 0, 90);
        assert!(notice.text.starts_with("权限请求 90 秒未获响应"), "{}", notice.text);
        // 整分钟档走分钟。
        let notice = build_perm_notice_abort(&[], 0, 60);
        assert!(notice.text.starts_with("权限请求 1 分钟未获响应"), "{}", notice.text);
    }

    #[test]
    fn auto_notice_for_never_notch_omits_duration() {
        // 「总是」档(secs=0)= 不等待:文案不报时长,detail 仍带 seconds=0 供前端判档。
        let notice = build_perm_notice_auto(&sample_summary(), "总是允许", PERM_TIMEOUT_NEVER_SECS);
        assert_eq!(notice.label, SYSTEM_LABEL_PERM_TIMEOUT_AUTO);
        assert!(
            notice.text.starts_with("权限请求未获响应,已按设置自动选择「总是允许」继续执行。"),
            "{}",
            notice.text
        );
        assert!(
            !notice.text.contains("0 分钟") && !notice.text.contains("0 秒"),
            "{}",
            notice.text
        );
        assert_eq!(notice.detail["seconds"], PERM_TIMEOUT_NEVER_SECS);
    }

    #[test]
    fn abort_notice_reports_extra_requests() {
        let notice = build_perm_notice_abort(&[sample_summary()], 3, DEFAULT_PERM_TIMEOUT_SECS);
        assert!(notice.text.contains("另有 3 项审批一并取消"), "{}", notice.text);
        assert_eq!(notice.detail["extra"], 3);
    }

    #[test]
    fn auto_notice_names_the_selected_option() {
        let notice = build_perm_notice_auto(&sample_summary(), "总是允许", 600);
        assert_eq!(notice.label, SYSTEM_LABEL_PERM_TIMEOUT_AUTO);
        assert!(notice.text.contains("自动选择「总是允许」"), "{}", notice.text);
        assert!(notice.text.contains("10 分钟"), "{}", notice.text);
        assert_eq!(notice.detail["selected"], "总是允许");
        assert_eq!(notice.detail["seconds"], 600);
    }

    #[test]
    fn auto_notice_marks_omitted_content() {
        let mut s = sample_summary();
        s.content = Some("x".repeat(10));
        s.content_omitted = 42;
        let notice = build_perm_notice_auto(&s, "允许一次", 5);
        assert!(notice.text.contains("已省略 42 字符"), "{}", notice.text);
        assert_eq!(notice.detail["content_omitted"], 42);
    }

    #[test]
    fn abort_notice_without_summary_still_readable() {
        // 竞态:cancel_all 已清空 pending 才走到回收——文案不残缺。
        let notice = build_perm_notice_abort(&[], 0, DEFAULT_PERM_TIMEOUT_SECS);
        assert!(notice.text.starts_with("权限请求 30 分钟未获响应"), "{}", notice.text);
        assert!(notice.detail["tool"].is_null());
    }
}