@shgroup/dsh-serenity-hooks 1.46.0 → 1.47.0

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.
package/lib/index.js CHANGED
@@ -2,13 +2,13 @@ import { t as __exportAll } from "./rolldown-runtime-D7D4PA-g.js";
2
2
  import { a as hostSettings, c as hostWebServer, i as hostSessions, n as hostInjected, o as hostSubagents, r as hostService, s as hostWeb, t as hostAgents } from "./access-fiehjxV6.js";
3
3
  import { a as findSerenityRoot, c as matchBlacklist, d as readCccName$1, f as readExclusiveTools, g as resolveInside, i as findGitRoot, l as pathInside, m as readTrajectorySkills, n as SAFE_MODE_MARKER, o as isSafeModeOn, p as readHandymanConfig, r as classifyPath, s as loadSerenityConfig, t as DEFAULT_SERENITY_CONFIG_PATHS, u as readBlacklist } from "./ccc-DG4Oc7I1.js";
4
4
  import { i as cccRootForExec, n as agentCwdFor, o as listCccs, r as cccRootForCwd, t as NO_CCC_FROM_AGENT_CWD } from "./ccc-roots-FxWXuZPw.js";
5
- import { C as useSession, S as summarize, T as localFileStamp, _ as resolveSessionByTitle, a as resolveSessionTrajectoryLabel, b as setActiveSessionInfo, c as TRAJECTORY_ACTIONS, d as extractSessionMdPathFromText, f as findSession, g as readActiveSessionMd, h as parseSessionContextFromEvents, i as readLastBound, l as clearActiveSessionInfo, m as listSessions, o as setBindingStore, p as getActiveSessionInfo, r as pruneMissingBindings, s as DEFAULT_SESSION_SCOPE, t as appendBound, u as createSession, v as sessionEvents$1, w as isoLocal, x as showSession, y as sessionsRoot } from "./trajectory-bound-BPWKkzJC.js";
5
+ import { C as showSession, D as localFileStamp, E as isoLocal, S as setActiveSessionInfo, T as useSession, _ as parseSessionContextFromEvents, a as pruneMissingBindings, b as sessionEvents$1, c as setBindingStore, d as clearActiveSessionInfo, f as createSession, g as listSessions, h as getActiveSessionInfo, l as DEFAULT_SESSION_SCOPE, m as findSession, n as PLUGIN_SOURCE, o as readLastBound, p as extractSessionMdPathFromText, r as appendBound, s as resolveSessionTrajectoryLabel, t as ACC_MESSAGE_KIND, u as TRAJECTORY_ACTIONS, v as readActiveSessionMd, w as summarize, x as sessionsRoot, y as resolveSessionByTitle } from "./message-source-Boc-AbHs.js";
6
6
  import { a as readWeixinCredential, c as weixinInboundDir, d as LOCALSTORE_SCOPES, f as checkLocalstoreGitCompliance, g as runLocalStore, h as readStore, i as matchWeixinRoute, l as weixinSessionIdFor, m as readGitTrack, n as extractWeixinText, o as readWeixinSettings, p as localstorePath, r as hasVoiceItem, s as sanitizeFileName$1, t as extractWeixinMedia } from "./weixin-route-CWMRa36W.js";
7
7
  import { a as unregisterSkiffSession$1, i as skiffSessionSnapshot$1, n as skiffRoleFor$1, o as ACC_VERSION, r as skiffSessionInfo$1, t as registerSkiffSession$1 } from "./skiff-registry-BjIX_KD1.js";
8
8
  import { a as readSkiffRoles, c as roleMsmWhitelist, d as systemPromptSource, f as trajectorySubset, i as isSkiffSessionId, l as roleToolWhitelist, n as buildSkiffBasePrompt, o as resolveRoleSystemPrompt, r as createRolePromptReader, s as resolveSkiffKind } from "./skiff-role-KqQA6pkr.js";
9
- import { a as FACE_PORTS, c as MECHANISM_PORTS, i as ACP_HTTP_PORT, l as SKIFF_DEBUG_PORT, n as registerSettingsSection, o as GATEWAY_PORT, s as MAIN_WEB_PORT, t as readSimpleSettings, u as WEIXIN_SEND_PORT } from "./settings-section-CIuyoSOX.js";
9
+ import { a as FACE_PORTS, c as MECHANISM_PORTS, i as ACP_HTTP_PORT, l as SKIFF_DEBUG_PORT, n as registerSettingsSection, o as GATEWAY_PORT, s as MAIN_WEB_PORT, t as readSimpleSettings, u as WEIXIN_SEND_PORT } from "./settings-section-DTJW4smh.js";
10
10
  import { r as sessionsRootDir, t as hasSessionLogById } from "./session-cleanup-BtkSmry8.js";
11
- import { a as registerDisposer, c as addWake, i as registerCroTurnTracking, l as listWakes, n as sendToTrajectory, o as buildCroSnapshot, r as wakeSchedulerState, s as WAKE_CATCH_UP_MS, t as registerWakeScheduler } from "./wake-scheduler-DF-YWcX5.js";
11
+ import { a as registerDisposer, c as WAKE_CATCH_UP_MS, i as registerCroTurnTracking, l as addWake, n as sendToTrajectory, o as CRO_FILENAME, r as wakeSchedulerState, s as buildCroSnapshot, t as registerWakeScheduler, u as listWakes } from "./wake-scheduler-DdaRNofO.js";
12
12
  import { a as markdownToPlainText, c as sendTyping, i as getUpdates, l as sniffImageExt, n as downloadMedia, o as sendFileMessage, r as getConfig, s as sendTextMessage, t as TypingStatus } from "./weixin-api-CtFYm53S.js";
13
13
  import z from "@deepseek-ai/schemastery";
14
14
  import { defineTool } from "@deepseek-ai/dsh-tools";
@@ -3444,10 +3444,6 @@ function registerTrajectorySkillSection(agent, root) {
3444
3444
  }
3445
3445
  //#endregion
3446
3446
  //#region src/skiff-core.ts
3447
- const PLUGIN_SOURCE$5 = {
3448
- kind: "plugin",
3449
- plugin: "dsh-serenity-hooks"
3450
- };
3451
3447
  const skiffAgents = /* @__PURE__ */ new Map();
3452
3448
  /** 查 sessionId 的 Skiff 角色名(无 → null) */
3453
3449
  function skiffRoleFor(sessionId) {
@@ -3857,7 +3853,7 @@ async function askSkiff(ctx, agent, question, eventsStart, options) {
3857
3853
  type: "text",
3858
3854
  text: question
3859
3855
  }],
3860
- source: PLUGIN_SOURCE$5
3856
+ source: PLUGIN_SOURCE
3861
3857
  }));
3862
3858
  await waitAgentIdle(ctx, agent);
3863
3859
  const answer = lastAssistantText$2(agent);
@@ -4368,10 +4364,7 @@ async function runHandymanJob(ctx, opts) {
4368
4364
  type: "text",
4369
4365
  text: prompt
4370
4366
  }],
4371
- source: {
4372
- kind: "plugin",
4373
- plugin: "dsh-serenity-hooks"
4374
- }
4367
+ source: PLUGIN_SOURCE
4375
4368
  }));
4376
4369
  await waitAgentIdle(ctx, workerAgent);
4377
4370
  lastResponse = lastAssistantText$1(workerAgent);
@@ -5485,6 +5478,445 @@ function createContainerAdminTool(ctx) {
5485
5478
  });
5486
5479
  }
5487
5480
  //#endregion
5481
+ //#region src/unattended-ops.ts
5482
+ /**
5483
+ * unattended-ops.ts — 无人值守代理回复(unattended proxy)机制层(S142 **D77**,owner 2026-09-23 提案)
5484
+ *
5485
+ * ## 这东西用来干什么(白话)
5486
+ *
5487
+ * 用户发个任务就走开。现在模型干完一段就停下来等回话,用户不在 ⇒ **会话挂在那儿不动**。
5488
+ * 本机制 = 用户走开后,系统**代替用户说一句"继续"**:让它自己跑完;干完了用一个**验证码**报告;
5489
+ * 卡住了先自己想办法;真不能定的**写下来等用户回来**。
5490
+ *
5491
+ * ## 归属(D23:ACC 管机制与数据,CCC 管措辞与纪律)
5492
+ *
5493
+ * | 面 | 归谁 | 内容 |
5494
+ * |---|---|---|
5495
+ * | **机制** | 🔴 **ACC(本模块)** | 触发判定 / 验证码 / 四选一结构 / 轮上限 / 流水 |
5496
+ * | **不可代批的*具体内容*** | **CCC 纪律** | ACC 只给**通用下限**({@link UNATTENDED_BLOCKED_FLOOR}),CCC 可加严 |
5497
+ *
5498
+ * 🔵 下限之所以落在 ACC(而不是"完全交给 CCC"):**没有这张清单,"自行解决"=把所有把关点自动放行**。
5499
+ * 最终内容仍待 owner 一句(设计 §13-5)——本版先给**保守下限**,CCC 只能在其上加严、不得放宽。
5500
+ *
5501
+ * ## 🔴 核心原则:单一续驱者(single-driver)
5502
+ *
5503
+ * > **一个会话同一时刻只应有一个"续驱者":用户 / CRO / 自排绳 / 无人值守代理。**
5504
+ *
5505
+ * 口径(owner 2026-09-23 19:1x 裁决)= **窄口径:只排除「有 CRO 程序的轨迹」**。
5506
+ * 原「宽口径」(任何自排唤醒都排除)**作废** —— 它会让本机制**永远够不着靠续接绳活着的会话**,
5507
+ * 等于**放弃"代替旧机制"**(绳因此退化为**长周期保险丝**:6–12h 一条)。
5508
+ *
5509
+ * ## 🔴 铁律(与 `cro.ts` / `cro-log.ts` 同族,勿破)
5510
+ *
5511
+ * **本模块的每个导出函数都不抛错**:写盘 / 解析 / 裁剪失败一律返回结构化失败。
5512
+ * ⇒ 代理机制的任何问题**不影响 turn 收尾**、不影响既有链路(唤醒投递 / 输出守卫 / rebuild)。
5513
+ *
5514
+ * ## 🔴 为什么不构成第二真相源(勿删此论证)
5515
+ *
5516
+ * | 载体 | 谁写 | 主语 | 回答 |
5517
+ * |---|---|---|---|
5518
+ * | `<轨迹目录>/unattended-log.json`(**本模块**) | **ACC** | ACC | 「**我注入了什么、判了什么、放过没有**」 |
5519
+ * | `<轨迹目录>/SESSION.md` | **LLM(CCC)** | CCC | 「**这轮发生了什么、为什么这么定**」 |
5520
+ *
5521
+ * **内容不重叠**:本模块只记**机械事实**(轮次 / 验证码 / 判定理由码 / 是否放过),**不写散文**;
5522
+ * LLM 侧的散文与理由归 SESSION.md。完整审计 = 两者合看(本模块 = 注入面|SESSION.md = 判断面)。
5523
+ *
5524
+ * ## 🔴 验证码形态(禁用 4 位字母码)
5525
+ *
5526
+ * 注入文本**自带**"请回复 ABCD" ⇒ 模型**复述指令**即**假完成**(高概率,不是小概率)
5527
+ * ⇒ 用**每轮新生成**的 nonce + **包装 + 独占一行**(`[[UNATTENDED-DONE:<hex>]]`),
5528
+ * 明写「**旧码作废**」,校验**只认当前轮**(防上下文里的旧码复活)。
5529
+ * 先例 = `handyman` background 的 `SERENITY_HANDYMAN_DONE_<16hex>`(同族实现)。
5530
+ */
5531
+ /** 完成码包装前缀(**独占一行**才有意义;见文件头"禁用 4 位字母码") */
5532
+ const UNATTENDED_DONE_PREFIX = "[[UNATTENDED-DONE:";
5533
+ /** 注入消息的**首行标记**(幂等可辨:模型与事后审计都能一眼看出这是代理消息,不是用户消息) */
5534
+ const UNATTENDED_PROXY_MARK = "[[UNATTENDED-PROXY v1]]";
5535
+ /** 🔴 合成消息**固定身份句**(owner 已裁:「形如用户消息,但首句自报身份与权限边界」) */
5536
+ const UNATTENDED_IDENTITY_LINE = "无人值守代理 · 非本人";
5537
+ /** 生成一枚新 nonce(**每轮一枚**;旧码作废由调用方在文本里声明) */
5538
+ function newNonce() {
5539
+ return randomBytes(8).toString("hex");
5540
+ }
5541
+ /** 完成码全文(注入文本展示用;校验走 {@link hasDoneToken}) */
5542
+ function doneToken(nonce) {
5543
+ return `${UNATTENDED_DONE_PREFIX}${nonce}]]`;
5544
+ }
5545
+ /**
5546
+ * 出现过的完成码 nonce 列表(按出现顺序、去重;**小写归一**)。
5547
+ * 容忍 8–64 位 hex(模型可能手抄多/少几个字符 ⇒ 那就不匹配,属预期:只认本轮那枚)。
5548
+ */
5549
+ function parseDoneNonces(text) {
5550
+ if (typeof text !== "string" || text === "") return [];
5551
+ const re = /\[\[UNATTENDED-DONE:([0-9a-fA-F]{8,64})\]\]/g;
5552
+ const out = [];
5553
+ let m;
5554
+ while ((m = re.exec(text)) !== null) {
5555
+ const n = (m[1] ?? "").toLowerCase();
5556
+ if (n !== "" && !out.includes(n)) out.push(n);
5557
+ }
5558
+ return out;
5559
+ }
5560
+ /** 触及即停(写待办/交接,**不循环**)——**CCC 的纪律更严则从严** */
5561
+ const UNATTENDED_BLOCKED_FLOOR = [
5562
+ "发布 / 发版",
5563
+ "对外发送(邮件 / 微信 / 对外面输出)",
5564
+ "删除与不可逆操作(rm / 归档 / force push)",
5565
+ "凭据与权限变更",
5566
+ "花钱(外部 API 大额调用 / GPU 长任务)",
5567
+ "代用户拍板(替人做决定)"
5568
+ ];
5569
+ /** 四选一正文({@link buildProxyMessage} 与 {@link buildFalseNotifyRebuke} 共用,避免两份措辞漂移) */
5570
+ function proxyBody(input) {
5571
+ return [
5572
+ `A) 已完成、且用户回来会满意 ⇒ 交码 + 附完成凭据(做了什么 / 怎么验的 / 产物路径)`,
5573
+ `B) 已成功向用户发出通知、要停下等他主动回来 ⇒ 交码 + 写明走了哪个通道(**必须真有发送记录**)`,
5574
+ `C) 需要确认但可自决 ⇒ 按「**最保守且可逆**」的选项继续做完(⚠️ **不要交码**,直接做)`,
5575
+ `D) 触及不可代批边界 ⇒ **立即停**,把问题写进待办 / 交接,然后交码`,
5576
+ ``,
5577
+ `不可代批的**通用下限**(你所在 CCC 的纪律更严则从严):${(input.blocked ?? UNATTENDED_BLOCKED_FLOOR).join(" / ")}`,
5578
+ ``,
5579
+ `交码 = **独占一行、逐字复制下面这一行**(旧码一律作废,只认本轮这个):`,
5580
+ ``,
5581
+ ` ${doneToken(input.nonce)}`,
5582
+ ``,
5583
+ `禁止:把本条当真实用户的授权;禁止把本指令文本复述成回答;禁止什么都没做只回一个码;`,
5584
+ `禁止借机发起**新的长任务**(代理只推进"已在办"的事)。`
5585
+ ].join("\n");
5586
+ }
5587
+ /** 组装一次代理注入消息(**形如用户消息,但首句自报身份与权限边界**) */
5588
+ function buildProxyMessage(input) {
5589
+ return [
5590
+ `${UNATTENDED_PROXY_MARK} round=${input.round}/${input.maxRounds} nonce=${input.nonce}`,
5591
+ `${UNATTENDED_IDENTITY_LINE} —— 这条消息是**系统代用户发的**,不是用户本人;我没有任何权限替你拍板。`,
5592
+ ``,
5593
+ `用户不在场。你只能以下列**四条之一**收束本轮:`,
5594
+ ``,
5595
+ proxyBody(input)
5596
+ ].join("\n");
5597
+ }
5598
+ /**
5599
+ * 组装一次**假完成打回**消息(设计 §7 的机械核验分支:声称已通知用户,但本轮无发送记录)。
5600
+ *
5601
+ * ⚠️ 同样带**新 nonce**:它是一次"再来一轮",不是"放过"。
5602
+ */
5603
+ function buildFalseNotifyRebuke(input) {
5604
+ return [
5605
+ `${UNATTENDED_PROXY_MARK} round=${input.round}/${input.maxRounds} nonce=${input.nonce}`,
5606
+ `${UNATTENDED_IDENTITY_LINE} —— 这条消息是**系统代用户发的**,不是用户本人。`,
5607
+ ``,
5608
+ `🔴 你交的码是对的,但**你声称已通知用户,而我这边查不到任何发送记录** ⇒ **不算合法收束**。`,
5609
+ `要么**真的发**(走可用通道;发成之后再交码),要么改走 A / C / D。其余口径同下。`,
5610
+ ``,
5611
+ proxyBody(input)
5612
+ ].join("\n");
5613
+ }
5614
+ /**
5615
+ * 「收尾文本**声称**已联系用户」的**启发式**判据(设计 §7 的触发器,**不是**判据本身)。
5616
+ *
5617
+ * 🔴 机械判据 = **ACC 侧的发送记录**({@link hasOutboundSendSince});本函数只回答"**要不要去查记录**"。
5618
+ * 误判方向(明标):**误报**(模型换了个说法没被匹配到)⇒ 退回"只认码",不产生假放过;
5619
+ * **误报**(正常措辞被当成声称)⇒ 多打回一轮(有轮上限兜底)。⇒ 偏严,符合设计 §11
5620
+ * 把"谎称已发消息"列为失败模式的取向。
5621
+ */
5622
+ const NOTIFY_CLAIM_PATTERNS = [
5623
+ /已(经)?(成功)?(向|给|和)?\s*(用户|您|你|他|她|本人|主人)?[^\n。]{0,16}(发送|发出|告知|通知|联系|留言|汇报)/,
5624
+ /\b(?:notified|informed|messaged|contacted)\s+(?:the\s+)?user\b/i,
5625
+ /\b(?:sent|sending)\s+(?:a\s+)?(?:message|notification|notice)\s+to\s+(?:the\s+)?user\b/i,
5626
+ /\bI\s+(?:have\s+)?(?:notified|informed|messaged|contacted)\b/i
5627
+ ];
5628
+ /** 收尾文本是否**声称**已联系用户(见 {@link NOTIFY_CLAIM_PATTERNS} 的误判方向说明) */
5629
+ function claimsUserNotification(text) {
5630
+ if (typeof text !== "string" || text === "") return false;
5631
+ return NOTIFY_CLAIM_PATTERNS.some((re) => re.test(text));
5632
+ }
5633
+ /**
5634
+ * **不许开**的会话(设计 §3 的"建议写死",本版**确实写死**):
5635
+ * `skiff-`(F4 问答 / **微信桥会话也是这个前缀** —— 桥那侧的"用户"是**家人**,
5636
+ * 代理替家人说话 = **冒充**)| `acp-`(对外程序化面)| `handyman-`(自主 worker,已有自己的完成码循环)。
5637
+ *
5638
+ * ⚠️ **`rebuild-` 不在排除面**(与 `output-guard-seam.ts` 的取向**刻意不同**):重建会话是
5639
+ * **同一轨迹的载体**(内部维护面),不是对外面;把它排除会让"重建后就没人推了"。
5640
+ */
5641
+ function isProxyExcludedSessionId(sessionId) {
5642
+ if (typeof sessionId !== "string" || sessionId === "") return true;
5643
+ return isSkiffSessionId(sessionId) || sessionId.startsWith("acp-") || sessionId.startsWith("handyman-");
5644
+ }
5645
+ /**
5646
+ * 一次判定的**唯一入口**(纯函数)。
5647
+ *
5648
+ * 顺序即语义(R↓,勿随意调换):
5649
+ * 1. **闸/空间/会话资格**(关着、不在 CCC、非主会话、排除面)⇒ skip;
5650
+ * 2. **单一续驱者**:用户刚发起 ⇒ skip(人就在场);该轨迹有 CRO ⇒ skip(程序在驱动);
5651
+ * 3. **合法收束优先于轮上限** —— 最后一轮交的码必须算数(否则等于永远收不了尾);
5652
+ * 4. 交码 + 声称已通知 + **无发送记录** ⇒ 假完成,走下一轮(reason=`false-notify`);
5653
+ * 5. 墙钟 / 轮上限 ⇒ cap(**停下不动**,退回改造前的行为,不产生新风险);
5654
+ * 6. 其余 ⇒ inject 下一轮。
5655
+ */
5656
+ function decideUnattendedAction(f) {
5657
+ if (!f.enabled) return {
5658
+ action: "skip",
5659
+ reason: "mode-off",
5660
+ round: 0
5661
+ };
5662
+ if (!f.root) return {
5663
+ action: "skip",
5664
+ reason: "not-in-ccc",
5665
+ round: 0
5666
+ };
5667
+ if (!f.isMainSession) return {
5668
+ action: "skip",
5669
+ reason: "not-main-session",
5670
+ round: 0
5671
+ };
5672
+ if (f.sessionIdExcluded) return {
5673
+ action: "skip",
5674
+ reason: "excluded-session",
5675
+ round: 0
5676
+ };
5677
+ if (f.userInitiated) return {
5678
+ action: "skip",
5679
+ reason: "user-initiated",
5680
+ round: 0
5681
+ };
5682
+ if (f.hasCroProgram) return {
5683
+ action: "skip",
5684
+ reason: "cro-programmed",
5685
+ round: 0
5686
+ };
5687
+ const run = f.run;
5688
+ if (run !== null && f.doneNonce !== null && f.doneNonce.toLowerCase() === run.nonce.toLowerCase() && run) {
5689
+ if (f.claimsNotify && !f.outboundSeen) {
5690
+ if (run.round >= 15) return {
5691
+ action: "cap",
5692
+ reason: "round-cap",
5693
+ round: run.round
5694
+ };
5695
+ return {
5696
+ action: "inject",
5697
+ reason: "false-notify",
5698
+ round: run.round + 1
5699
+ };
5700
+ }
5701
+ return {
5702
+ action: "accept",
5703
+ reason: "done-code",
5704
+ round: run.round
5705
+ };
5706
+ }
5707
+ if (run && f.nowMs - run.startedAt > 72e5) return {
5708
+ action: "cap",
5709
+ reason: "wall-clock",
5710
+ round: run.round
5711
+ };
5712
+ if (run && run.round >= 15) return {
5713
+ action: "cap",
5714
+ reason: "round-cap",
5715
+ round: run.round
5716
+ };
5717
+ const round = (run?.round ?? 0) + 1;
5718
+ return {
5719
+ action: "inject",
5720
+ reason: run === null ? "first-stop" : f.doneNonce !== null ? "stale-code" : "no-legal-closure",
5721
+ round
5722
+ };
5723
+ }
5724
+ /** 键 = 载体(dsh 会话 id)——与 `cro-turns.ts` 同款选择(事件本身就以 agent/session 为主语) */
5725
+ const runs = /* @__PURE__ */ new Map();
5726
+ /** 取该会话的代理段(无 ⇒ null) */
5727
+ function getProxyRun(sessionId) {
5728
+ return runs.get(sessionId) ?? null;
5729
+ }
5730
+ /** 开始 / 推进一段代理(`startedAt` 由调用方给出 ⇒ 跨轮保留首次值) */
5731
+ function setProxyRun(sessionId, run) {
5732
+ if (!sessionId) return;
5733
+ runs.set(sessionId, run);
5734
+ }
5735
+ /** 结束该会话的代理段(放过 / 上限 / 用户回来 / 载体销毁) */
5736
+ function clearProxyRun(sessionId) {
5737
+ if (!sessionId) return;
5738
+ runs.delete(sessionId);
5739
+ }
5740
+ /**
5741
+ * 「该载体最近一次由**真实用户**发起的轮次号」(会话 id → turn)。
5742
+ *
5743
+ * ⚠️ 只留**一条**(不是集合):`claimed` 的 `turn` 单调递增,留最新一条即可回答
5744
+ * "本轮是不是用户发起的",且**表的大小 = 会话数**(本容器栽过 5 次"只增不清",此处结构性避免)。
5745
+ */
5746
+ const userTurns = /* @__PURE__ */ new Map();
5747
+ /** 记下"这个载体的这一轮是真实用户发起的" */
5748
+ function noteUserTurn(sessionId, turn) {
5749
+ if (!sessionId || typeof turn !== "number") return;
5750
+ userTurns.set(sessionId, turn);
5751
+ }
5752
+ /** 该载体的这一轮是否由真实用户发起(判据 = `noteUserTurn` 写入的最新一条) */
5753
+ function isUserTurn(sessionId, turn) {
5754
+ if (!sessionId || typeof turn !== "number") return false;
5755
+ return userTurns.get(sessionId) === turn;
5756
+ }
5757
+ /** 清掉该载体的用户轮次记录(载体销毁时) */
5758
+ function clearUserTurn(sessionId) {
5759
+ if (!sessionId) return;
5760
+ userTurns.delete(sessionId);
5761
+ }
5762
+ /** 清空全部进程内运行态(disposer;**不碰磁盘**) */
5763
+ function clearUnattendedState() {
5764
+ runs.clear();
5765
+ userTurns.clear();
5766
+ outboundSends.clear();
5767
+ }
5768
+ /** 发送记录保留窗(超出即丢:它只服务"这一轮到底发没发"这个瞬时问题) */
5769
+ const UNATTENDED_OUTBOUND_TTL_MS = 216e5;
5770
+ const outboundSends = /* @__PURE__ */ new Map();
5771
+ /**
5772
+ * 记一次**成功的**出站发送(`im-bridge` 工具面在通道返回成功后调用)。
5773
+ *
5774
+ * ⚠️ **覆盖边界(诚实标注)**:只覆盖 **ACC 自己这条通道**(`im-bridge` 的 send / send-file)。
5775
+ * CCC 自有的发送通道(如 `mail-tool` MSM)**ACC 看不见** ⇒ 那类"已通知"落到
5776
+ * {@link hasOutboundSendSince} = false ⇒ 会被判成假完成。⇒ 本版口径:**代理模式下"联系用户"
5777
+ * 只认 ACC 通道**;这与设计 §7 的原话(im-bridge 记日志)一致,边界写在此处以免误读。
5778
+ */
5779
+ function noteOutboundSend(root, atMs = Date.now()) {
5780
+ if (!root) return;
5781
+ const list = outboundSends.get(root) ?? [];
5782
+ list.push(atMs);
5783
+ const cutoff = atMs - UNATTENDED_OUTBOUND_TTL_MS;
5784
+ const kept = list.filter((t) => t >= cutoff).slice(-20);
5785
+ outboundSends.set(root, kept);
5786
+ }
5787
+ /** 自 `sinceMs` 起该 CCC 是否有过成功出站发送(**只读,不清表**——清理归写入侧的裁剪) */
5788
+ function hasOutboundSendSince(root, sinceMs, nowMs = Date.now()) {
5789
+ if (!root) return false;
5790
+ const list = outboundSends.get(root);
5791
+ if (!list || list.length === 0) return false;
5792
+ const floor = Math.max(sinceMs, nowMs - UNATTENDED_OUTBOUND_TTL_MS);
5793
+ return list.some((t) => t >= floor && t <= nowMs);
5794
+ }
5795
+ /** 固定文件名(与 `cro-wake-log.json` 并排、主语分明:那份记"我叫了什么",这份记"我代说了什么") */
5796
+ const UNATTENDED_LOG_FILENAME = "unattended-log.json";
5797
+ /** 保留窗 = 2 日(与 CRO 流水同值;**常量** ⇒ 无需人工清理动作) */
5798
+ const UNATTENDED_LOG_RETENTION_MS = 1728e5;
5799
+ const UNATTENDED_LOG_VERSION = 1;
5800
+ /** 日志绝对路径 */
5801
+ function unattendedLogPath(root, dirName) {
5802
+ return join(sessionsRoot(root), dirName, UNATTENDED_LOG_FILENAME);
5803
+ }
5804
+ function emptyLog() {
5805
+ return {
5806
+ version: UNATTENDED_LOG_VERSION,
5807
+ entries: []
5808
+ };
5809
+ }
5810
+ /** 条目形状校验(容忍手改:字段缺失/类型不符 ⇒ 丢弃该条,不阻断整档) */
5811
+ function asEntry(value) {
5812
+ const e = value;
5813
+ if (!e || typeof e !== "object") return null;
5814
+ if (typeof e.at !== "string" || e.at === "") return null;
5815
+ if (e.event !== "inject" && e.event !== "accept" && e.event !== "cap" && e.event !== "yield") return null;
5816
+ if (typeof e.reason !== "string") return null;
5817
+ return {
5818
+ at: e.at,
5819
+ event: e.event,
5820
+ round: typeof e.round === "number" ? e.round : 0,
5821
+ nonce: typeof e.nonce === "string" ? e.nonce : null,
5822
+ reason: e.reason,
5823
+ sessionId: typeof e.sessionId === "string" ? e.sessionId : ""
5824
+ };
5825
+ }
5826
+ /** 读取流水(**永不抛**;读坏 ⇒ 当作空档 + 返回 error 文本) */
5827
+ function loadUnattendedLog(root, dirName) {
5828
+ const path = unattendedLogPath(root, dirName);
5829
+ if (!existsSync(path)) return {
5830
+ log: emptyLog(),
5831
+ error: null
5832
+ };
5833
+ try {
5834
+ const parsed = JSON.parse(readFileSync(path, "utf-8"));
5835
+ const raw = Array.isArray(parsed.entries) ? parsed.entries : [];
5836
+ const entries = [];
5837
+ for (const item of raw) {
5838
+ const e = asEntry(item);
5839
+ if (e) entries.push(e);
5840
+ }
5841
+ return {
5842
+ log: {
5843
+ version: UNATTENDED_LOG_VERSION,
5844
+ entries
5845
+ },
5846
+ error: null
5847
+ };
5848
+ } catch (err) {
5849
+ return {
5850
+ log: emptyLog(),
5851
+ error: `无人值守流水解析失败(${String(err?.message ?? err)})`
5852
+ };
5853
+ }
5854
+ }
5855
+ /**
5856
+ * 按窗裁剪(**纯函数** ⇒ 可穷举测试)。
5857
+ * 🔴 `at` 不可解析者**一律丢弃**并在返回值里报数(`dropped`)——**不静默吞掉**(同 `cro-log.ts` 纪律)。
5858
+ */
5859
+ function pruneUnattendedLogEntries(entries, nowMs, windowMs = UNATTENDED_LOG_RETENTION_MS) {
5860
+ const floor = nowMs - windowMs;
5861
+ const kept = [];
5862
+ let pruned = 0;
5863
+ let dropped = 0;
5864
+ for (const e of entries) {
5865
+ const t = Date.parse(e.at);
5866
+ if (Number.isNaN(t)) {
5867
+ dropped += 1;
5868
+ continue;
5869
+ }
5870
+ if (t >= floor) kept.push(e);
5871
+ else pruned += 1;
5872
+ }
5873
+ return {
5874
+ kept,
5875
+ pruned,
5876
+ dropped
5877
+ };
5878
+ }
5879
+ /**
5880
+ * 追加一条流水并按窗裁剪(**本模块的落盘入口;永不抛**)。
5881
+ *
5882
+ * 顺序 = 读 → 追加 → 裁剪 → **原子写**(tmp + rename)。任一步失败 ⇒ `{ok:false, error}`,**不抛**
5883
+ * ——调用方只记一行 console 日志 ⇒ **代理机制的问题绝不影响 turn 收尾**。
5884
+ */
5885
+ function appendUnattendedLog(root, dirName, entry, nowMs = Date.now()) {
5886
+ if (!root || !dirName) return {
5887
+ ok: false,
5888
+ error: "缺少 CCC 根或轨迹目录名",
5889
+ kept: 0
5890
+ };
5891
+ const path = unattendedLogPath(root, dirName);
5892
+ try {
5893
+ const loaded = loadUnattendedLog(root, dirName);
5894
+ const full = {
5895
+ at: isoLocal(nowMs),
5896
+ ...entry
5897
+ };
5898
+ const { kept } = pruneUnattendedLogEntries([...loaded.log.entries, full], nowMs);
5899
+ mkdirSync(dirname(path), { recursive: true });
5900
+ const tmp = `${path}.tmp`;
5901
+ writeFileSync(tmp, `${JSON.stringify({
5902
+ version: UNATTENDED_LOG_VERSION,
5903
+ entries: kept
5904
+ }, null, 2)}\n`, "utf-8");
5905
+ renameSync(tmp, path);
5906
+ return {
5907
+ ok: true,
5908
+ error: null,
5909
+ kept: kept.length
5910
+ };
5911
+ } catch (err) {
5912
+ return {
5913
+ ok: false,
5914
+ error: `无人值守流水写入失败(${String(err?.message ?? err)})`,
5915
+ kept: 0
5916
+ };
5917
+ }
5918
+ }
5919
+ //#endregion
5488
5920
  //#region src/im-bridge.ts
5489
5921
  /**
5490
5922
  * im-bridge.ts — IM 发送能力层(v1.31.0,S142 用户洞察)
@@ -5635,17 +6067,19 @@ async function runImBridge(root, req) {
5635
6067
  const resolved = channel.resolveUser(root, rawUser);
5636
6068
  if (!resolved) return fail("ALIAS_UNKNOWN", `unknown user "${rawUser}"`);
5637
6069
  try {
6070
+ const sent = await channel.send({
6071
+ root,
6072
+ userId: resolved.id,
6073
+ text,
6074
+ accountId: req.account
6075
+ });
6076
+ noteOutboundSend(root);
5638
6077
  return {
5639
6078
  ok: true,
5640
6079
  channel: channelId,
5641
6080
  action,
5642
6081
  detail: {
5643
- ...await channel.send({
5644
- root,
5645
- userId: resolved.id,
5646
- text,
5647
- accountId: req.account
5648
- }),
6082
+ ...sent,
5649
6083
  user: resolved.alias ?? resolved.id
5650
6084
  }
5651
6085
  };
@@ -5664,19 +6098,21 @@ async function runImBridge(root, req) {
5664
6098
  const file = readCccFile(root, fileArg);
5665
6099
  if (!file.ok) return fail(file.code, file.error);
5666
6100
  try {
6101
+ const sent = await channel.sendFile({
6102
+ root,
6103
+ userId: resolved.id,
6104
+ data: file.data,
6105
+ fileName: file.fileName,
6106
+ caption: req.caption,
6107
+ accountId: req.account
6108
+ });
6109
+ noteOutboundSend(root);
5667
6110
  return {
5668
6111
  ok: true,
5669
6112
  channel: channelId,
5670
6113
  action,
5671
6114
  detail: {
5672
- ...await channel.sendFile({
5673
- root,
5674
- userId: resolved.id,
5675
- data: file.data,
5676
- fileName: file.fileName,
5677
- caption: req.caption,
5678
- accountId: req.account
5679
- }),
6115
+ ...sent,
5680
6116
  user: resolved.alias ?? resolved.id
5681
6117
  }
5682
6118
  };
@@ -6268,7 +6704,7 @@ function registerBootstrap(ctx) {
6268
6704
  if (depth === 0) {
6269
6705
  if (hasUserMessageHistory(session)) return;
6270
6706
  } else if (sid !== void 0 && anchoredSessions.has(sid)) return;
6271
- if (message?.source?.kind === "plugin") return;
6707
+ if (message?.source?.kind === "plugin:dsh-serenity-hooks") return;
6272
6708
  const inbox = agent.inbox;
6273
6709
  if (!inbox?.prepend) return;
6274
6710
  for (let i = SETTINGS.anchorMessages.length - 1; i >= 0; i--) inbox.prepend("next-turn", {
@@ -6279,8 +6715,7 @@ function registerBootstrap(ctx) {
6279
6715
  text: SETTINGS.anchorMessages[i]
6280
6716
  }],
6281
6717
  source: {
6282
- kind: "plugin",
6283
- plugin: "dsh-serenity-hooks",
6718
+ kind: ACC_MESSAGE_KIND,
6284
6719
  form: "notice",
6285
6720
  summary: "bootstrap anchor turn"
6286
6721
  }
@@ -6606,10 +7041,6 @@ function trajectoryCompactionReminderText(input) {
6606
7041
  if (escalated) return `${eventToken("compaction")} ${facts} — you have been reminded repeatedly and have NOT compacted SESSION.md. This is now mandatory: STOP the current task step, load eap (praxis eap), rewrite SESSION.md, then resume. ${principles} ${frontmatterGuard} This reminder persists until the file is under the limit.`;
6607
7042
  return `${eventToken("compaction")} ${facts}. Pause the current work and compact this trajectory's log (SESSION.md) before continuing — that log is the persistent body of this trajectory, and its size is operational entropy (H_op) the container pays on every read. ${principles} ${frontmatterGuard} After rewriting, resume the paused work; this reminder stops once the file is under the limit.`;
6608
7043
  }
6609
- const PLUGIN_SOURCE$4 = {
6610
- kind: "plugin",
6611
- plugin: "dsh-serenity-hooks"
6612
- };
6613
7044
  /** 每 agent 一个跟踪器(进程内存态,agent token 维度) */
6614
7045
  const trackers = /* @__PURE__ */ new Map();
6615
7046
  /** 连续超阈值轮数达此值 → 升级为 [TRAJECTORY-ESCALATED] 强制语气(此后持续升级催,直到 rebuild) */
@@ -6695,7 +7126,7 @@ function registerKeeper(ctx, opts = {}) {
6695
7126
  if (blocks.length === 0) return downstream;
6696
7127
  const reminderAll = createUserMessage({
6697
7128
  content: blocks,
6698
- source: PLUGIN_SOURCE$4
7129
+ source: PLUGIN_SOURCE
6699
7130
  });
6700
7131
  if (downstream.kind === "block") return {
6701
7132
  kind: "block",
@@ -6739,10 +7170,6 @@ function accIdentityText(root, configPaths = DEFAULT_SERENITY_CONFIG_PATHS, entr
6739
7170
  }
6740
7171
  return lines.join("\n");
6741
7172
  }
6742
- const PLUGIN_SOURCE$3 = {
6743
- kind: "plugin",
6744
- plugin: "dsh-serenity-hooks"
6745
- };
6746
7173
  /**
6747
7174
  * ACC 注入消息(S134 去重):**只含简短身份锚点**([ACC] 已激活 + CCC 根 + 约束 + Phase 2)。
6748
7175
  * ⚠️ 原文案含「+ handyman 模型」,该注入已于 2026-09-16 删除(§0e-G)⇒ 此处同步订正(散文级残留)。
@@ -6757,7 +7184,7 @@ function accMessage(root, configPaths, entrySkillMaxChars) {
6757
7184
  }];
6758
7185
  return createUserMessage({
6759
7186
  content,
6760
- source: PLUGIN_SOURCE$3
7187
+ source: PLUGIN_SOURCE
6761
7188
  });
6762
7189
  }
6763
7190
  /** 每 agent 是否已注入过(进程内存态) */
@@ -6871,7 +7298,7 @@ function registerContext(ctx, opts = {}) {
6871
7298
  syncExclusiveToolsVisibility(agent, root);
6872
7299
  } catch {}
6873
7300
  };
6874
- if (opts.seedOnStart ?? true) ctx.on("agent/session-start", (payload) => {
7301
+ if (opts.seedOnStart ?? true) ctx.on("agent/created", async (payload) => {
6875
7302
  try {
6876
7303
  seed(payload.agent);
6877
7304
  } catch {}
@@ -7352,7 +7779,7 @@ function registerStatusApi(ctx, opts = {}) {
7352
7779
  return;
7353
7780
  }
7354
7781
  const settings = readAdvancedSettings();
7355
- const { readSimpleSettings } = await import("./settings-section-CIuyoSOX.js").then((n) => n.r);
7782
+ const { readSimpleSettings } = await import("./settings-section-DTJW4smh.js").then((n) => n.r);
7356
7783
  const simple = readSimpleSettings();
7357
7784
  const allowed = settings.publicAsk.allowed;
7358
7785
  const port = simple.acpHttpPort ?? ACP_HTTP_PORT;
@@ -8075,8 +8502,8 @@ const HOST_SERVICES = [
8075
8502
  }
8076
8503
  ];
8077
8504
  /** dsp 被验证过的宿主版本范围(与 package.json peerDependencies 同源,单一真相源) */
8078
- const REQUIRED_HOST_RANGE = "^0.1.5-rc.2";
8079
- /** 范围 floor:剥掉 caret/比较前缀(`^0.1.5-rc.2` → `0.1.5-rc.2`),供 {@link checkHostVersion} 使用 */
8505
+ const REQUIRED_HOST_RANGE = "^0.1.7-rc.1";
8506
+ /** 范围 floor:剥掉 caret/比较前缀(`^0.1.7-rc.1` → `0.1.7-rc.1`),供 {@link checkHostVersion} 使用 */
8080
8507
  const REQUIRED_HOST_FLOOR = REQUIRED_HOST_RANGE.replace(/^[\^~>=<\s]+/, "");
8081
8508
  /** 范围 ceiling:`^0.1.x` 的上界 = `<0.2.0`(显式常量,见 {@link checkHostVersion} 注释) */
8082
8509
  const REQUIRED_HOST_CEILING = "0.2.0";
@@ -9300,7 +9727,7 @@ function registerGateway(ctx) {
9300
9727
  });
9301
9728
  };
9302
9729
  sync();
9303
- ctx.on("agent/session-start", (payload) => {
9730
+ ctx.on("agent/created", async (payload) => {
9304
9731
  try {
9305
9732
  const cwd = payload.agent?.session?.header?.cwd;
9306
9733
  if (cwd) {
@@ -9329,10 +9756,6 @@ var rebuild_exports = /* @__PURE__ */ __exportAll({
9329
9756
  sanitizeFocusLine: () => sanitizeFocusLine,
9330
9757
  stripAckSuffix: () => stripAckSuffix
9331
9758
  });
9332
- const PLUGIN_SOURCE$2 = {
9333
- kind: "plugin",
9334
- plugin: "dsh-serenity-hooks"
9335
- };
9336
9759
  /** acknowledge 尾句(first-anchor 的确认要求——rebuild 重建后不重走确认轮,直接继续) */
9337
9760
  const ACK_SUFFIX_RE = /Please simply reply "acknowledge" — no action needed\.\s*$/;
9338
9761
  /**
@@ -9648,7 +10071,7 @@ function registerRebuildTurnHook(ctx) {
9648
10071
  type: "text",
9649
10072
  text: `${eventToken("rebuild")} The conversation has been cleared and rebuilt. Follow the anchor instructions above now: read the persistent trajectory (SESSION.md) and continue the work automatically from the last checkpoint.`
9650
10073
  }],
9651
- source: PLUGIN_SOURCE$2
10074
+ source: PLUGIN_SOURCE
9652
10075
  }));
9653
10076
  writeRebuildDiag(resolveSerenityRootFor(agent), {
9654
10077
  sessionId: id,
@@ -11801,7 +12224,7 @@ const MECHANISM_WORDS = [
11801
12224
  "localstore.json",
11802
12225
  ".opencode/serenity.json",
11803
12226
  "AGENT_SESSIONS",
11804
- "agent/session-start",
12227
+ "agent/created",
11805
12228
  "agent/turn-stopping",
11806
12229
  "tools/pre-execute",
11807
12230
  "tools/post-execute",
@@ -11911,17 +12334,15 @@ function buildRebuke(hits) {
11911
12334
  const rebukeStates = /* @__PURE__ */ new Map();
11912
12335
  //#endregion
11913
12336
  //#region src/output-guard-seam.ts
11914
- const PLUGIN_SOURCE$1 = {
11915
- kind: "plugin",
11916
- plugin: "dsh-serenity-hooks"
11917
- };
11918
12337
  /** 外部面会话判定(v1.26.3 用户拍板:仅外部面检测,本地维护会话豁免):
11919
12338
  * skiff-(F4 问答)/ acp-(F4c 程序化)/ rebuild-(v1.22.4 重建会话)前缀 */
11920
12339
  function isExternalFaceSession(sessionId) {
11921
12340
  if (!sessionId) return false;
11922
12341
  return isSkiffSessionId(sessionId) || sessionId.startsWith("acp-") || sessionId.startsWith("rebuild-");
11923
12342
  }
11924
- /** 读会话最后一个 assistant/message 文本(skiff-core 同款;仅用于守卫检测,不导出)
12343
+ /** 读会话最后一个 assistant/message 文本(skiff-core 同款)
12344
+ * **2026-09-23(S142 D77)起导出**:`unattended-seam.ts` 复用同一读法(本容器纪律:
12345
+ * 「同一读法只留一份」——两份 `lastAssistantText` 会在宿主改事件形状时**只修一处**)。
11925
12346
  * **v1.27.7:剥离 think 块再检测**(用户:"不检查think内容(因为不输出)")——
11926
12347
  * ` think…` 是模型思考过程,不进入用户可见输出;敏感词检测只针对**最终呈现文本**,
11927
12348
  * 否则 think 内提及内部机制词(思考过程必然推演机制)会误打回。复用 stripThink
@@ -11972,7 +12393,7 @@ function registerOutputGuardHook(ctx) {
11972
12393
  type: "text",
11973
12394
  text: buildRebuke(hits)
11974
12395
  }],
11975
- source: PLUGIN_SOURCE$1
12396
+ source: PLUGIN_SOURCE
11976
12397
  }));
11977
12398
  } catch (error) {
11978
12399
  console.warn(`[serenity-hooks] output-guard steer failed: ${String(error?.message ?? error)}`);
@@ -11980,6 +12401,235 @@ function registerOutputGuardHook(ctx) {
11980
12401
  });
11981
12402
  }
11982
12403
  //#endregion
12404
+ //#region src/unattended-seam.ts
12405
+ /**
12406
+ * unattended-seam.ts — 无人值守代理回复的**拦截缝**(S142 **D77**,owner 2026-09-23 令「我需要的是一个机制」)
12407
+ *
12408
+ * ## 停在哪、怎么接(设计 §4 的裁决:**A 的通道 + B 的措辞**)
12409
+ *
12410
+ * 复用的是**已验证过的**通道——`agent/turn-stopping`(serial)里 `agent.steer()`:
12411
+ * 宿主语义 = "**listener 反对就打回**,机器重读 inbox:有新 steer ⇒ 再跑一步,没有 ⇒ 关轮"
12412
+ * (`host/contract.ts` 的 `HOST_EVENTS` 原文)。因此:
12413
+ * · **轮不结束**(语义正是"有人打回")⇒ **零新机械**;
12414
+ * · 文本**写成代理用户的话**(B 的措辞)+ **首句自报身份**(owner 已裁)。
12415
+ *
12416
+ * 模板 = `output-guard-seam.ts`(同一缝位、同一 `createUserMessage + PLUGIN_SOURCE` 形态)。
12417
+ *
12418
+ * ## 🔴「这轮是不是真实用户发起的」怎么判(本机制**唯一**的新机械;2026-09-23 取证)
12419
+ *
12420
+ * 交接块把它列为"动手前必须先查清的一件事",取证结论(读宿主类型面,非猜测):
12421
+ *
12422
+ * | 事实 | 出处 |
12423
+ * |---|---|
12424
+ * | 每条进入 inbox 的消息都发 `agent/inbox/inserted { agent, message }`;**在轮内离开 inbox 时**发 `agent/inbox/claimed { agent, message, turn }` | `@deepseek-ai/dsh-agent` README(Inbox 段)+ `runtime-types.d.ts` |
12425
+ * | `claimed.turn` = **拥有该消息的那一轮**;`turn-stopping.turn` = **即将关闭的那一轮** ⇒ 两者同编号可直接比对 | `runtime-types.d.ts` 同一文件两处 `turn` 声明 |
12426
+ * | 消息来源可辨:`MessageSourceMap` 里**真实用户** = `kind: 'user'`(`user` / `user-rpc` 两种形态**同 kind**);**ACC 自己注入**的一律 `kind: 'plugin:dsh-serenity-hooks'`(v1.47.0 改;旧值 = `'plugin'`) | `@deepseek-ai/dsh-llm` `src/message.ts` 的 `MessageSourceMap`(合并和类型)+ 本仓 `message-source.ts`(**唯一**声明处与常量处) |
12427
+ *
12428
+ * ⇒ **判据 = 「这一轮 claim 到的消息里,有没有 `source.kind === 'user'`」**。
12429
+ * 没有现成信号时本可退化为"记账法"(我们知道自己投过什么),**但不必**:宿主已给出一手信号。
12430
+ *
12431
+ * ## 🔴 为什么"用户发起的轮"要跳过(这是设计 §3-③ 的本意,别当成漏洞)
12432
+ *
12433
+ * 用户**刚说完话**⇒ 人就在跟前,代理插话既是冒充也是浪费。**"用户说完走开"那种情形**
12434
+ * 由**绳(长周期保险丝)/其它非用户发起的唤醒**覆盖:那条轮**不是**用户发起的 ⇒ 代理接手,
12435
+ * 一路推进到**合法收束**。⇒ 两个机制合起来才等于"用户走开后会话还能自己跑完"。
12436
+ *
12437
+ * ## 🔴 铁律(与 `unattended-ops.ts` 同族)
12438
+ *
12439
+ * **本模块的任何失败都不得影响 turn 收尾**:所有 handler 包 try/catch,异常只记一行 console。
12440
+ */
12441
+ /** 取载体 id(payload 的 `agent` 面;取不到 ⇒ 空串) */
12442
+ function sessionIdOf$2(agent) {
12443
+ const id = agent?.session?.id;
12444
+ return typeof id === "string" ? id : "";
12445
+ }
12446
+ /** 取会话对象(payload 的 `agent.session`) */
12447
+ function sessionOf(agent) {
12448
+ return agent?.session;
12449
+ }
12450
+ /** 取会话 cwd(`header.cwd`;取不到回落进程 cwd——与 `output-guard-seam.ts` 同款读法) */
12451
+ function cwdOf(agent) {
12452
+ return sessionOf(agent)?.header?.cwd ?? process.cwd();
12453
+ }
12454
+ /** 是否 **CCC 内主会话**(`delegationDepth` 缺省 0;子代理 / 无头运行不算) */
12455
+ function isMainSession(agent) {
12456
+ return (sessionOf(agent)?.header?.delegationDepth ?? 0) === 0;
12457
+ }
12458
+ /**
12459
+ * 该会话所属轨迹**是否带 CRO 程序**(窄口径的唯一排除项)。
12460
+ * 判据 = 轨迹目录下存在 `continuous-re-occurrence.ts`(与 `cro.ts` 同源:**文件在 = 启用**)。
12461
+ * 无绑定(未挂在任何轨迹上)⇒ false(没有程序在驱动它)。
12462
+ */
12463
+ function hasCroProgram(session, root) {
12464
+ const dirName = readLastBound(session)?.dirName;
12465
+ if (!dirName) return false;
12466
+ return existsSync(join(sessionsRoot(root), dirName, CRO_FILENAME));
12467
+ }
12468
+ /** 落一条流水(**永不抛**;无绑定 ⇒ 无落点,只回 false 由调用方记 console) */
12469
+ function logEvent(session, root, entry, nowMs) {
12470
+ const dirName = readLastBound(session)?.dirName;
12471
+ if (!dirName) return false;
12472
+ const res = appendUnattendedLog(root, dirName, entry, nowMs);
12473
+ if (!res.ok) console.log(`[serenity-hooks] unattended: 流水未落盘 — ${res.error ?? "未知原因"}`);
12474
+ return res.ok;
12475
+ }
12476
+ /**
12477
+ * 一轮收尾(`agent/turn-stopping`)的完整处置。
12478
+ *
12479
+ * 顺序:**取事实 → 纯函数判定 → 执行**(判定全在 `unattended-ops.ts`,本函数只做 I/O 与 steer)。
12480
+ * @returns 便于用例观察的判定结果文本(生产不使用返回值)
12481
+ */
12482
+ function handleTurnStopping(agent, turn) {
12483
+ if (readSimpleSettings().unattendedEnabled !== true) return "skip:mode-off";
12484
+ const sessionId = sessionIdOf$2(agent);
12485
+ if (sessionId === "") return "skip:no-session-id";
12486
+ const root = cccRootForCwd(cwdOf(agent));
12487
+ if (root === null) return "skip:not-in-ccc";
12488
+ const session = sessionOf(agent);
12489
+ const nowMs = Date.now();
12490
+ const run = getProxyRun(sessionId);
12491
+ const text = lastAssistantText(agent);
12492
+ const nonces = parseDoneNonces(text);
12493
+ const sessionIdExcluded = isProxyExcludedSessionId(sessionId);
12494
+ const userInitiated = turn === null ? true : isUserTurn(sessionId, turn);
12495
+ const cro = !sessionIdExcluded && !userInitiated && isMainSession(agent) ? hasCroProgram(session, root) : false;
12496
+ const verdict = decideUnattendedAction({
12497
+ enabled: true,
12498
+ root,
12499
+ isMainSession: isMainSession(agent),
12500
+ sessionIdExcluded,
12501
+ userInitiated,
12502
+ hasCroProgram: cro,
12503
+ run,
12504
+ nowMs,
12505
+ doneNonce: nonces.length > 0 ? nonces[nonces.length - 1] ?? null : null,
12506
+ claimsNotify: claimsUserNotification(text),
12507
+ outboundSeen: run !== null && hasOutboundSendSince(root, run.startedAt, nowMs)
12508
+ });
12509
+ if (verdict.action === "skip") return `skip:${verdict.reason}`;
12510
+ if (verdict.action === "accept" || verdict.action === "cap") {
12511
+ clearProxyRun(sessionId);
12512
+ logEvent(session, root, {
12513
+ event: verdict.action,
12514
+ round: verdict.round,
12515
+ nonce: run?.nonce ?? null,
12516
+ reason: verdict.reason,
12517
+ sessionId
12518
+ }, nowMs);
12519
+ console.log(`[serenity-hooks] unattended: ${verdict.action} (${verdict.reason}, round=${verdict.round}, session=${sessionId})`);
12520
+ return `${verdict.action}:${verdict.reason}`;
12521
+ }
12522
+ const nonce = newNonce();
12523
+ const startedAt = run?.startedAt ?? nowMs;
12524
+ setProxyRun(sessionId, {
12525
+ round: verdict.round,
12526
+ nonce,
12527
+ startedAt
12528
+ });
12529
+ const message = verdict.reason === "false-notify" ? buildFalseNotifyRebuke({
12530
+ round: verdict.round,
12531
+ maxRounds: 15,
12532
+ nonce
12533
+ }) : buildProxyMessage({
12534
+ round: verdict.round,
12535
+ maxRounds: 15,
12536
+ nonce
12537
+ });
12538
+ try {
12539
+ agent.steer(createUserMessage({
12540
+ content: [{
12541
+ type: "text",
12542
+ text: message
12543
+ }],
12544
+ source: PLUGIN_SOURCE
12545
+ }));
12546
+ } catch (error) {
12547
+ clearProxyRun(sessionId);
12548
+ logEvent(session, root, {
12549
+ event: "cap",
12550
+ round: verdict.round,
12551
+ nonce,
12552
+ reason: "steer-failed",
12553
+ sessionId
12554
+ }, nowMs);
12555
+ console.warn(`[serenity-hooks] unattended: steer 失败(本轮放弃) — ${String(error?.message ?? error)}`);
12556
+ return "cap:steer-failed";
12557
+ }
12558
+ logEvent(session, root, {
12559
+ event: "inject",
12560
+ round: verdict.round,
12561
+ nonce,
12562
+ reason: verdict.reason,
12563
+ sessionId
12564
+ }, nowMs);
12565
+ console.log(`[serenity-hooks] unattended: inject round=${verdict.round}/15 (${verdict.reason}, session=${sessionId})`);
12566
+ return `inject:${verdict.reason}`;
12567
+ }
12568
+ /**
12569
+ * 装配无人值守代理缝(`index.ts` apply 调用)。
12570
+ *
12571
+ * 订阅三个事件(**都已在 `host/contract.ts` 白名单里**,新增的 `agent/inbox/claimed` 同批登记):
12572
+ * · `agent/inbox/claimed` —— 记「真实用户发起的轮次」;用户回来时**让位**(清代理段 + 记流水);
12573
+ * · `agent/turn-stopping` —— 判定 + 注入(本机制主体);
12574
+ * · `agent/disposed` / `session/disposed` —— 载体销毁清表(**本容器栽过 5 次"只增不清"**)。
12575
+ */
12576
+ function registerUnattendedSeam(ctx) {
12577
+ const on = (event, handler) => {
12578
+ try {
12579
+ ctx.on(event, handler);
12580
+ } catch (err) {
12581
+ console.log(`[serenity-hooks] ✗ 无人值守代理未装配(${event} 不可用): ${String(err?.message ?? err)}`);
12582
+ }
12583
+ };
12584
+ on("agent/inbox/claimed", (payload) => {
12585
+ try {
12586
+ const p = payload;
12587
+ const sessionId = sessionIdOf$2(p?.agent);
12588
+ if (sessionId === "") return;
12589
+ if (p.message?.source?.kind !== "user") return;
12590
+ noteUserTurn(sessionId, (typeof p.turn === "number" ? p.turn : null) ?? -1);
12591
+ const run = getProxyRun(sessionId);
12592
+ if (!run) return;
12593
+ clearProxyRun(sessionId);
12594
+ const root = cccRootForCwd(cwdOf(p.agent));
12595
+ if (root) logEvent(sessionOf(p.agent), root, {
12596
+ event: "yield",
12597
+ round: run.round,
12598
+ nonce: run.nonce,
12599
+ reason: "user-returned",
12600
+ sessionId
12601
+ }, Date.now());
12602
+ console.log(`[serenity-hooks] unattended: yield — 用户回来了 (session=${sessionId}, round=${run.round})`);
12603
+ } catch (err) {
12604
+ console.log(`[serenity-hooks] unattended: claimed 观察失败 — ${String(err?.message ?? err)}`);
12605
+ }
12606
+ });
12607
+ on("agent/turn-stopping", (payload) => {
12608
+ try {
12609
+ const p = payload;
12610
+ if (!p?.agent) return;
12611
+ handleTurnStopping(p.agent, typeof p.turn === "number" ? p.turn : null);
12612
+ } catch (err) {
12613
+ console.log(`[serenity-hooks] unattended: turn-stopping 处置失败(已忽略) — ${String(err?.message ?? err)}`);
12614
+ }
12615
+ });
12616
+ on("agent/disposed", (payload) => {
12617
+ const sessionId = sessionIdOf$2(payload?.agent);
12618
+ if (sessionId === "") return;
12619
+ clearProxyRun(sessionId);
12620
+ clearUserTurn(sessionId);
12621
+ });
12622
+ on("session/disposed", (payload) => {
12623
+ const id = payload?.id;
12624
+ if (typeof id !== "string" || id === "") return;
12625
+ clearProxyRun(id);
12626
+ clearUserTurn(id);
12627
+ });
12628
+ registerDisposer(ctx, "unattended proxy", () => {
12629
+ clearUnattendedState();
12630
+ });
12631
+ }
12632
+ //#endregion
11983
12633
  //#region src/acp-core.ts
11984
12634
  /** JSON-RPC 错误码(对齐标准) */
11985
12635
  const RPC_ERROR = {
@@ -12733,12 +13383,6 @@ qInput.focus()
12733
13383
  </body>
12734
13384
  </html>`;
12735
13385
  }
12736
- //#endregion
12737
- //#region src/weixin-output-guard.ts
12738
- const PLUGIN_SOURCE = {
12739
- kind: "plugin",
12740
- plugin: "dsh-serenity-hooks"
12741
- };
12742
13386
  const manualSessions = /* @__PURE__ */ new Map();
12743
13387
  /**
12744
13388
  * 本轮是否已成功发送(**由桥在每轮开始时清空** `clearSentThisTurn`;闸门只置位不清空——
@@ -14336,7 +14980,7 @@ function registerOpencodeAutoConfig(ctx) {
14336
14980
  });
14337
14981
  }
14338
14982
  try {
14339
- ctx.on?.call(ctx, "settings/updated", (ns) => {
14983
+ ctx.on?.call(ctx, "settings/document-updated", (ns) => {
14340
14984
  if (ns === "llm-pi-ai") sync();
14341
14985
  });
14342
14986
  } catch {}
@@ -14580,7 +15224,7 @@ function registerDeepseekVisionPatch(ctx, enabled) {
14580
15224
  });
14581
15225
  }
14582
15226
  try {
14583
- ctx.on?.call(ctx, "settings/updated", (ns) => {
15227
+ ctx.on?.call(ctx, "settings/document-updated", (ns) => {
14584
15228
  if (ns === "llm-pi-ai") sync();
14585
15229
  });
14586
15230
  } catch {}
@@ -15125,6 +15769,7 @@ function renderClock(label, s) {
15125
15769
  const parts = [
15126
15770
  `armed=${s.armed}`,
15127
15771
  `CRO 闸=${readSimpleSettings().croEnabled !== false}`,
15772
+ `无人值守闸=${readSimpleSettings().unattendedEnabled === true}`,
15128
15773
  `tick 次数=${s.ticks}`,
15129
15774
  `上次 tick=${ts(s.lastTickAt)}`
15130
15775
  ];
@@ -15442,7 +16087,17 @@ const Config = z.object({
15442
16087
  }),
15443
16088
  webFetch: z.object({ enabled: z.boolean().default(true) }),
15444
16089
  opencodeProvider: z.object({ autoConfigure: z.boolean().default(true) }),
15445
- visionPatch: z.object({ enabled: z.boolean().default(true) })
16090
+ visionPatch: z.object({ enabled: z.boolean().default(true) }),
16091
+ gatewayEnabled: z.boolean(),
16092
+ rebuildEnabled: z.boolean(),
16093
+ rebuildThresholdK: z.number().min(50).max(4e3),
16094
+ skiffEnabled: z.boolean(),
16095
+ skiffDebugPort: z.number().min(1024).max(65535),
16096
+ acpEnabled: z.boolean(),
16097
+ acpHttpPort: z.number().min(1024).max(65535),
16098
+ publicAskEnabled: z.boolean(),
16099
+ croEnabled: z.boolean(),
16100
+ unattendedEnabled: z.boolean()
15446
16101
  });
15447
16102
  function apply(ctx, config) {
15448
16103
  try {
@@ -15494,6 +16149,7 @@ function apply(ctx, config) {
15494
16149
  registerGateway(ctx);
15495
16150
  registerRebuildTurnHook(ctx);
15496
16151
  registerOutputGuardHook(ctx);
16152
+ registerUnattendedSeam(ctx);
15497
16153
  if (config.env) registerEnv(ctx);
15498
16154
  if (config.opencodeSkills) registerOpencodeSkills(ctx);
15499
16155
  registerBootstrap(ctx);
@@ -15533,9 +16189,10 @@ function apply(ctx, config) {
15533
16189
  * 任何 live 会话,故 apply 时刻就能解析到,不再需要"等一会儿再试"。
15534
16190
  *
15535
16191
  * 🔒 **必须保留**(它们不是"重试",删了会引入新缺陷):
15536
- * - **反应式再触发**(`agent/session-start` / `session/created` → sync):宿主重启后
15537
- * "恢复"旧会话不触发 `session/created`,但**开始对话**会触发 `agent/session-start`
15538
- * ——这是唯一能把"进程起来时还解析不到、稍后才有会话"这条路径接上的通道。
16192
+ * - **反应式再触发**(`agent/created` / `session/created` → sync):宿主重启后
16193
+ * "恢复"旧会话不触发 `session/created`,但**开始对话**会触发 `agent/created`
16194
+ * (v0.1.7 前名 `agent/session-start`)——这是唯一能把"进程起来时还解析不到、稍后才有会话"
16195
+ * 这条路径接上的通道。
15539
16196
  * - **`starting` 在飞标志**:防同拍双次 start → `EADDRINUSE`。
15540
16197
  * (C4 块 A 备注:面宿主亦按面名合并在飞启动,是第二道保险;**本标志仍保留**——
15541
16198
  * 它管的是"解析 root 这段装配流程"的在飞语义,且调试服务的启动替身在测试里被 mock,
@@ -15586,7 +16243,7 @@ function registerSkiff(ctx) {
15586
16243
  sync();
15587
16244
  });
15588
16245
  } catch {}
15589
- for (const eventName of ["agent/session-start", "session/created"]) try {
16246
+ for (const eventName of ["agent/created", "session/created"]) try {
15590
16247
  ctx.on(eventName, () => {
15591
16248
  if (!started) sync();
15592
16249
  });