- 变更规模:新增 514 / 修改 62 / 重命名 155 / 删除 4(归档重组与文档轮次) - .gitignore 修:`归档/**/db-cwd归一-备份-*/` —— 原规则写绝对层级(归档/db-cwd归一-…), 目录搬进 归档/配置与备份/ 后**静默失效**,43 MB 的 DB 备份又变成未跟踪 - .gitignore 补:嵌套 git 内部数据(归档/内嵌git-20261008/、归档/skills-git-旧线-20261007/dotgit-原样移出/) - .gitignore 补:运行态与部署副本(.workbuddy/collab/、.workbuddy/tools/、.workbuddy/.load-pending、.workbuddy/tmp-*) - .gitignore 补:备份件(*.bak-*) - 未跟踪文件从 2190 降到 890(其余为 归档/ 归档件与 .workbuddy/memory/ 知识文件,按口径入库)
1188 lines
64 KiB
Python
1188 lines
64 KiB
Python
#!/usr/bin/env python
|
||
# -*- coding: utf-8 -*-
|
||
"""
|
||
监管机制 · 结果钩子(SessionEnd)—— **实现「hook 把执行结果交给后台任务」**
|
||
================================================================================
|
||
用户在 2026-09-28 提出的设计:
|
||
「可以通过 hook 把执行结果给到 你的后台任务,根据后台任务接收到的情况,决定如何执行下一步」
|
||
|
||
本脚本 = 这条链上**「交付结果」那一半**。它做的事极少、极稳:
|
||
|
||
某个会话结束
|
||
↓ SessionEnd 钩子(本项目**项目级**配置 → 只作用于本工作区,⛔ 不污染全局)
|
||
↓ 读宿主给的 transcript_path → 取「最后一条 assistant 正文」+ 判「是不是半路断了」
|
||
↓ 追加**一行** JSON 到本地台账 tmp/supervise-inbox/ledger.jsonl
|
||
↓ 退出码 0(无论发生什么)
|
||
后台任务(= 派活监管巡检棒)
|
||
→ 读台账 → 结合登记表 → 决定下一步(重排 / 报告 / 等待)
|
||
|
||
🔴 六条安全性质(**改本文件前先逐条读**,都是"多重检查"的产物)
|
||
1. **绝不写 stdout** —— stdout 是钩子协议通道,乱写可能干扰宿主。本脚本连一个字节都不输出。
|
||
2. **永远 exit 0** —— 任何异常都被吞掉并**只记录到自己的日志**(fail-open)。
|
||
宿主侧该钩子挂了 timeout=10s:万一死循环也会被掐掉,不会卡住会话退出。
|
||
3. **⛔ 不读令牌、⛔ 不联网、⛔ 不起子进程** —— 本脚本继承到的环境里有
|
||
`CODEBUDDY_GATEWAY_PASSWORD`(网关令牌),但那意味着"能创建定时任务"这种**强能力**。
|
||
本脚本**刻意不碰它**:只做本地文件读写 ⇒ 不构成任何权限扩大(R5 干净)。
|
||
4. **只写自己的目录** —— 唯一写点是 `<本工作区>/tmp/supervise-inbox/`。⛔ 不写别线、⛔ 不改配置。
|
||
5. **有界** —— 读 stdin ≤1 MiB、读转录尾部 ≤128 KiB、台账 >8 MiB 就**停写并留痕**(⛔ 不删旧数据)。
|
||
6. **留痕优先** —— "钩子没被调用"与"被调用了但静默失败"必须可分 ⇒ 每次调用都写日志。
|
||
7. 🔴 **路径靠"上溯找标志",⛔ 不靠脚本在第几层**(2026-09-28 实测 bug:脚本一移动,
|
||
`dirname(dirname(__file__))` 就指错 ⇒ 台账**静默写到别处**而测试全绿)。
|
||
自证命令:`python wb-result-hook.py --where`(**只能手工跑**,真实钩子调用⛔不带此参数)。
|
||
|
||
对照实现:本机既有的全局钩子
|
||
`E:/ProgramData/AIProject/ai1net-decision-laya/bridge/decision_bridge.py`
|
||
(它的 docstring 同样强调 fail-open 与"留痕先于一切";本脚本只取这两个约定,⛔ 不复用它任何逻辑)
|
||
|
||
装在哪里(**项目级 = 只影响本工作区**):
|
||
`E:/ProgramData/AIProject/ai1net-dsh-server/.codebuddy/settings.json`
|
||
{"hooks": {"SessionEnd": [{"hooks": [{"type":"command","command":"…python.exe …结果钩子-20260928.py","timeout":10}]}]}}
|
||
⛔ 不要装到 `~/.workbuddy/settings.json`(那是全局,会跑在**所有**会话上)。
|
||
|
||
自测(同一份代码路径,⛔ 不是另写一份测试逻辑):
|
||
python 结果钩子-20260928.py --selftest <payload.json> [transcript.jsonl]
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
# [session-mechanism] roots.env 外置(由 install.py 生成;缺失则回落到按位置推导)
|
||
def _sm_load_roots():
|
||
import os as _os
|
||
_here = _os.path.dirname(_os.path.abspath(__file__))
|
||
for _up in range(4):
|
||
_p = _os.path.join(_here, *([".."] * _up), "roots.env")
|
||
_p = _os.path.normpath(_p)
|
||
if _os.path.isfile(_p):
|
||
try:
|
||
with open(_p, encoding="utf-8") as _f:
|
||
for _ln in _f:
|
||
_ln = _ln.strip()
|
||
if _ln and not _ln.startswith("#") and "=" in _ln:
|
||
_k, _v = _ln.split("=", 1)
|
||
_os.environ.setdefault(_k.strip(), _v.strip())
|
||
except Exception:
|
||
pass
|
||
return
|
||
_sm_load_roots()
|
||
|
||
|
||
import json
|
||
import os
|
||
import subprocess
|
||
import sys
|
||
import tempfile
|
||
import time
|
||
from datetime import datetime
|
||
|
||
# 🔴 2026-10-02:环境定位/体检收敛到 `_env`(同目录优先)——⛔ 不再用 `~` 直拼
|
||
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
||
try:
|
||
import _env
|
||
except Exception:
|
||
sys.stderr.write('[env] 无法导入 _env.py ⇒ 环境定位不可信\n')
|
||
raise
|
||
|
||
MAX_STDIN = 1 << 20 # 1 MiB
|
||
MAX_TAIL = 128 * 1024 # 128 KiB
|
||
LEDGER_MAX = 8 * 1024 * 1024 # 8 MiB 就停写(留痕,不删)
|
||
|
||
# 🔴 2026-09-30 修(用户:"整的后台又开始一闪一闪的"):
|
||
# `python.exe` 是**控制台程序** —— 钩子本身没有控制台,子进程不带 CREATE_NO_WINDOW
|
||
# 就会被 Windows **新分配一个控制台窗口** ⇒ 每跑一次闪一下黑窗。
|
||
# ⇒ 本脚本 spawn 的一切子进程**一律隐窗**(非 Windows 上该标志无意义 ⇒ 取 0)。
|
||
HIDE = 0x08000000 if os.name == "nt" else 0 # CREATE_NO_WINDOW
|
||
|
||
|
||
def _win_pythonw():
|
||
"""常驻/后台子进程优先用 `pythonw.exe`(GUI 子系统)—— Windows 永不为它分配控制台窗口,
|
||
比单靠 CREATE_NO_WINDOW 更稳(2026-10-02 闪屏根治)。⛔ 不存在时回退本解释器。
|
||
|
||
🔴🔴 2026-10-03 **修一处让它每次调用都崩的真缺陷**(取证:`hook.log` 停在 10-02 21:24、
|
||
手工喂 `UserPromptSubmit` 立刻 `NameError: name 'PYW' is not defined`):
|
||
原写法 `PYW = _win_pythonw()` —— 而函数体内部引用 `PYW` ⇒ **调用时 `PYW` 尚未赋值**
|
||
⇒ **模块导入即 NameError** ⇒ 本 hook 自 10-02 起**一次都没真跑过**(三个事件全废)。
|
||
✅ 判据(不得靠"读代码觉得对"):`_win_pythonw()` 的函数体⛔ 不许引用任何尚未赋值的全局。
|
||
✅ 兜底用 `sys.executable`(**当前解释器自身**)—— 天然已定义、且必定可执行。"""
|
||
_me = sys.executable or "python"
|
||
if os.name != "nt":
|
||
return _me
|
||
_d = os.path.dirname(os.path.abspath(_me))
|
||
_pw = os.path.join(_d, "pythonw.exe")
|
||
return _pw if os.path.isfile(_pw) else _me
|
||
PYW = _win_pythonw()
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# 🔴 工作区根**靠上溯发现**,⛔ 不靠"脚本在第几层"(2026-09-28 实测 bug)
|
||
# 事故:脚本原在 `交付物/`,用 `WS = dirname(dirname(__file__))` 正好对;
|
||
# 一移到 `.workbuddy/tools/` ⇒ 同一行变成了 `.workbuddy/` ⇒
|
||
# **台账静默写到 `.workbuddy/tmp/`**,而"测试全绿、退出码 0"。
|
||
# ⇒ 教训:路径**不能依赖脚本自身的相对层数**;用**内容标志**锚定。
|
||
# ---------------------------------------------------------------------------
|
||
WS_MARKER = "state.py" # 本工作区的唯一标志(配合 .workbuddy/ 目录)
|
||
|
||
|
||
def resolve_ws():
|
||
"""环境变量优先;否则从脚本所在目录逐级上溯,找同时含 state.py 与 .workbuddy/ 的目录。"""
|
||
env = os.environ.get("WB_RESULT_HOOK_WS") or os.environ.get("DSH_WS_ROOT")
|
||
if env and os.path.isfile(os.path.join(env, WS_MARKER)):
|
||
return os.path.abspath(env)
|
||
cur = os.path.dirname(os.path.abspath(__file__))
|
||
for _ in range(8):
|
||
if os.path.isfile(os.path.join(cur, WS_MARKER)) and os.path.isdir(os.path.join(cur, ".workbuddy")):
|
||
return cur
|
||
parent = os.path.dirname(cur)
|
||
if parent == cur:
|
||
break
|
||
cur = parent
|
||
return ""
|
||
|
||
|
||
WS = resolve_ws()
|
||
INBOX = os.path.join(WS, "tmp", "supervise-inbox") if WS else ""
|
||
LEDGER = os.path.join(INBOX, "ledger.jsonl") if INBOX else ""
|
||
HOOKLOG = os.path.join(INBOX, "hook.log") if INBOX else ""
|
||
# 兜底日志:连工作区都认不出来时,写系统临时目录(保证"失败也可被发现",⛔ 不静默)
|
||
LOG_FALLBACK = os.path.join(tempfile.gettempdir(), "wb-result-hook.log")
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# 🔴 作用域(2026-09-28 23:2x 重做 —— 用户指出「钩子会不会把相关会话排除掉」)
|
||
# 旧版只有一条 `CARE_PREFIX`,把「被监管的三条线」和「**本工作区**(监管自己的家)」
|
||
# 混在同一个前缀里,且**域外一律静默 return**(只写一行 log)⇒ 两个后果:
|
||
# ① 本工作区的会话被当成被监管对象 ⇒ **监管自己看自己**;
|
||
# ② 被挡下的会话从外面看**与"没触发"完全一样**(同一个坑,本文件 §98 注释已批评过)。
|
||
# ⇒ 现在三改:① 被监管线 = **显式白名单**(与 supervisor 的 WATCH 一处对齐)
|
||
# ② 本工作区标 `scope=home` ⇒ 记账但⛔ 不当被监管线
|
||
# ③ **凡被丢弃的,写 `skipped.jsonl` + 打一行 stderr** ⇒ 排除本身可观测
|
||
# ---------------------------------------------------------------------------
|
||
HOME_WS = "ai1net-dsh-server" # 监管本家(本工作区)——⛔ 不是被监管线
|
||
LINE_WS = { # ⚠️ 改这里**必须同步** wb-supervisor-watch.py 的 WATCH
|
||
"ai1net-dsh-desktop": "客户端线",
|
||
"ai1net-dsh-anywhere": "手机接入线",
|
||
"ai1net_ui": "ai1net_ui",
|
||
}
|
||
CARE_PREFIX = "e:/programdata/aiproject/" # 大项目根:域外一律不读转录
|
||
SKIPPED = os.path.join(INBOX, "skipped.jsonl") if INBOX else ""
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# 🔴 hook → 监管(**零 token** 的那一半)
|
||
# 用户口径(2026-09-28):「可以通过 hook 把执行结果给到**你的后台任务**,
|
||
# 根据后台任务接收到的情况,决定如何执行下一步」。
|
||
# ⇒ 正确落地 = **钩子里限流地跑一轮监管**,因为:
|
||
# · 钩子是**宿主**起的子进程 ⇒ ⛔ 不占任何会话(不会把会话拖死,见 §7.7 事故)
|
||
# · 本地脚本 ⇒ **零 token**(⛔ 不必为此开自动化会话烧钱)
|
||
# ⛔ 三条护栏:① 域外不触发 ② 限流 SUP_THROTTLE 秒 ③ 硬超时 6s + 错误全吞
|
||
# ---------------------------------------------------------------------------
|
||
SUPERVISE = os.path.join(WS, ".workbuddy", "tools", "wb-supervisor-watch.py") if WS else ""
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# 🔴 会话日志上限兜底(2026-09-29 实测定型)
|
||
# `<配置根>/logs/<日期>/sdk/conversations/<sid>.log` 涨到 ~10 MiB 上限后轮转失败,
|
||
# 宿主每批写入抛 EPERM ⇒ **整批丢弃** ⇒ 该会话"确实在执行,但对话窗口不显示内容"。
|
||
# 修法 = 把卡死的 `<sid>.log` **改名挪开**(⛔ 不删),宿主数十秒内重建并恢复写入。
|
||
# 钩子是**宿主起的短命子进程** ⇒ 挂这里兜底最合适:零 token、不占会话、跑完即退。
|
||
# ⛔ 任何异常都不许冒出去;⛔ 只动 `sdk/conversations/*.log`,不动别的。
|
||
# 判据/修法全文 ⇒ 技能 session-mechanism(references/forensics.md §4)
|
||
# ---------------------------------------------------------------------------
|
||
SWEEP = os.path.join(WS, ".workbuddy", "tools", "wb-logcap-sweep.py") if WS else ""
|
||
SWEEP_THROTTLE = 120 # 秒:最快多久扫一次
|
||
SWEEP_TIMEOUT = 8 # 秒:硬超时(留足余量给钩子注册的 timeout)
|
||
SWEEP_STAMP = os.path.join(INBOX, "_logcap.stamp") if INBOX else ""
|
||
|
||
|
||
def maybe_sweep_logcap() -> None:
|
||
"""解除"会话日志撞 10 MiB 上限 ⇒ 写入被拒 ⇒ 输出被丢"。失败一律吞掉。"""
|
||
if not SWEEP or not os.path.isfile(SWEEP):
|
||
return
|
||
try:
|
||
if SWEEP_STAMP and os.path.isfile(SWEEP_STAMP):
|
||
if (time.time() - os.path.getmtime(SWEEP_STAMP)) < SWEEP_THROTTLE:
|
||
return
|
||
except Exception:
|
||
pass
|
||
try:
|
||
p = subprocess.run(
|
||
[PYW, "-u", SWEEP, "--quiet"],
|
||
cwd=WS or None,
|
||
stdout=subprocess.PIPE, stderr=subprocess.STDOUT,
|
||
timeout=max(1.0, min(float(SWEEP_TIMEOUT), _left() - 1.0)), creationflags=HIDE,
|
||
)
|
||
try:
|
||
with open(SWEEP_STAMP, "w", encoding="utf-8") as f:
|
||
f.write(str(int(time.time())))
|
||
except Exception:
|
||
pass
|
||
out = (p.stdout or b"").decode("utf-8", "replace").strip()
|
||
if out:
|
||
log("logcap-sweep: " + out.replace("\n", " | ")[:300])
|
||
except Exception as e:
|
||
try:
|
||
log("logcap-sweep 失败(已忽略): %r" % (e,))
|
||
except Exception:
|
||
pass
|
||
SUP_THROTTLE = 600 # 秒:最快多久跑一次(⛔ 别每次 SessionEnd 都跑)
|
||
SUP_TIMEOUT = 6 # 秒:硬超时(钩子注册的 timeout 是 10s,留足余量)
|
||
SUP_STAMP = os.path.join(INBOX, "_supervise.stamp") if INBOX else ""
|
||
SUP_LOG = os.path.join(INBOX, "_supervise.log") if INBOX else ""
|
||
|
||
|
||
def maybe_run_supervisor(rec) -> None:
|
||
"""受控地跑一轮监管。⛔ 任何异常都不许冒出去(钩子绝不能因为这个失败)。"""
|
||
if not SUPERVISE or not os.path.isfile(SUPERVISE):
|
||
return
|
||
if rec.get("scope") == "outside":
|
||
return
|
||
try:
|
||
if SUP_STAMP and os.path.isfile(SUP_STAMP):
|
||
if (time.time() - os.path.getmtime(SUP_STAMP)) < SUP_THROTTLE:
|
||
return
|
||
except Exception:
|
||
pass
|
||
try:
|
||
p = subprocess.run(
|
||
[PYW, "-u", SUPERVISE, "--once"],
|
||
cwd=WS or None,
|
||
stdout=subprocess.PIPE, stderr=subprocess.STDOUT,
|
||
timeout=max(1.0, min(float(SUP_TIMEOUT), _left() - 1.0)), creationflags=HIDE,
|
||
)
|
||
try:
|
||
with open(SUP_STAMP, "w", encoding="utf-8") as f:
|
||
f.write(str(int(time.time())))
|
||
except Exception:
|
||
pass
|
||
if SUP_LOG:
|
||
try:
|
||
with open(SUP_LOG, "a", encoding="utf-8", newline="\n") as f:
|
||
f.write("\n--- %s 由钩子触发(session=%s line=%s)---\n"
|
||
% (datetime.now().strftime("%Y-%m-%d %H:%M:%S"),
|
||
str(rec.get("session", ""))[:8], rec.get("line")))
|
||
f.write((p.stdout or b"").decode("utf-8", "replace"))
|
||
except Exception:
|
||
pass
|
||
except Exception:
|
||
pass
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# 🔴 2026-09-29 13:4x 加(**取证驱动**:`SessionEnd` 这条路实测不可用)
|
||
# 事实(宿主日志 + 台账,⛔ 非推断):
|
||
# · 宿主自 09-28 13:55 起**未重启**;带本钩子的 `SessionEnd` 注册**从未被投递过**
|
||
# (两日全部 `HookExecutor spawn` 里,`SessionEnd` 那条注册零次出现);
|
||
# · `ledger.jsonl` / `hook.log` 末次真实写入 = 07:28,且全是测试夹具;
|
||
# `gate-done.stamp` **不存在** ⇒ 真实会话收尾从未触发过本钩子;
|
||
# · ⇒「会话收尾 ⇒ 跑一轮协作程序」这条接线**空转**,协作回路自 13:11 起休眠。
|
||
# 修法(**最小**:只换「谁触发 `--once`」,⛔ 不改协作程序本身、⛔ 不扩范围):
|
||
# 把同一个 `--once` 挂到**已被证明会被投递**的事件 `UserPromptSubmit` 上
|
||
# (两日 26 次 spawn 全落本钩子)⇒ ⛔ 不依赖重启宿主(常驻的钟另算,见下方 tick 段)。
|
||
# 护栏:静默 · fail-open · 硬超时 12 s(注册 20 s,留足余量)· 节流 180 s。
|
||
# ---------------------------------------------------------------------------
|
||
CB_GAP = 180 # 秒:最快多久跑一次(⛔ 别每次提示都跑)
|
||
CB_TIMEOUT = 12 # 秒:硬超时
|
||
CB_STAMP = os.path.join(INBOX, "collabd-once.stamp") if INBOX else ""
|
||
|
||
|
||
# 🔴 协作机制的**部署配置属于使用方**(用户 2026-09-30:「技能就是技能 程序就是程序,
|
||
# 谁用产生的文件 放在他自己那里」)⇒ 钩子每次唤起 collabd 时**显式**指过去:
|
||
# · COLLABD_CONFIG = <本工作区>/.workbuddy/collab/collabd.config.json
|
||
# · COLLABD_WORKSPACE = <本工作区>
|
||
# ⛔ 已删掉原来的"回落同目录旧副本"兜底 —— 那是**静默换版本**的经典来源
|
||
# (技能 §9 警告过「指向旧副本 ⇒ 唤醒回路等于没接」);找不到就**停手**,⛔ 不猜。
|
||
# 🔴 2026-10-01 纠错:本行原按 `3×dirname(__file__)` 推根 —— 包内这份推出来**正好=技能包自己**
|
||
# ⇒ 钩子把技能包当工作区交给 collabd ⇒ 在包里长出 `tmp/supervise-inbox/`、且找不到配置 ⇒ 崩。
|
||
# ⇒ 改为**复用上面已算好的 `resolve_ws()`**(env 优先 + 内容标志上溯),⛔ 不再有第二套推法。
|
||
_WS_ROOT = WS
|
||
|
||
|
||
def _collabd_ctx():
|
||
"""返回 (脚本路径, 子进程 env)。⛔ 找不到脚本 ⇒ ("", {}),调用方直接 return。"""
|
||
_p = os.environ.get("COLLABD_PATH") or ""
|
||
if not _p or not os.path.isfile(_p):
|
||
# 🔴 2026-10-01:原 `multi-session-collab` 已并入 `session-mechanism`(本包即主技能)
|
||
# ⇒ **新落点优先**;旧落点只作**过渡兜底**(⛔ 别删:包未装/被移走时不至于静默停摆)。
|
||
# ⛔ 不再写死盘符 —— 一律按 `<配置目录>/skills/...` 推(换机器也成立)。
|
||
_base = _env.config_dir() # 🔴 2026-10-02 共用判据(⛔ 不再用 `~` 直拼)
|
||
for _cand in (
|
||
os.path.join(_base, "skills", "session-mechanism", "scripts", "collabd.py"),
|
||
os.path.join(_base, "skills", "multi-session-collab", "scripts", "collabd.py"),
|
||
):
|
||
if os.path.isfile(_cand):
|
||
_p = _cand
|
||
break
|
||
if not _p or not os.path.isfile(_p):
|
||
return "", {}
|
||
if not _WS_ROOT:
|
||
return "", {} # ⛔ 不知道工作区 ⇒ 停手(⛔ 不拿 cwd/包目录顶替)
|
||
_cenv = dict(os.environ)
|
||
_cenv["COLLABD_WORKSPACE"] = _WS_ROOT
|
||
_cfg = os.path.join(_WS_ROOT, ".workbuddy", "collab", "collabd.config.json")
|
||
if os.path.isfile(_cfg):
|
||
_cenv["COLLABD_CONFIG"] = _cfg
|
||
return _p, _cenv
|
||
|
||
|
||
def maybe_run_collabd_once() -> None:
|
||
"""受控地跑一轮协作程序。⛔ 任何异常都不许冒出去。"""
|
||
if not INBOX:
|
||
return
|
||
_cd, _collabd_env = _collabd_ctx()
|
||
if not _cd:
|
||
return
|
||
try:
|
||
if os.path.isfile(CB_STAMP) and (time.time() - os.path.getmtime(CB_STAMP)) < CB_GAP:
|
||
return
|
||
except Exception:
|
||
pass
|
||
try:
|
||
with open(CB_STAMP, "w", encoding="utf-8", newline="\n") as f:
|
||
f.write(datetime.now().strftime("%Y-%m-%d %H:%M:%S"))
|
||
except Exception:
|
||
pass
|
||
# 🔴 2026-10-02 二修:`--once` 实测 0.5s,但它的**硬超时是 12s** ⇒ 抖动一次就吃掉大半预算;
|
||
# 且本钩子**不需要它的返回值** ⇒ 一律后台(⛔ 不等)。`SessionEnd` 那处保持同步另算
|
||
# (那里有 `_left()` 预算兜底,且语义上要确定性),本函数是 `UserPromptSubmit` 专用。
|
||
try:
|
||
_bg(_cd, ["--once"], env=_collabd_env, tag="once", min_gap=60.0)
|
||
except Exception:
|
||
pass
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# ---------------------------------------------------------------------------
|
||
# 🔴 触发源(2026-10-01 用户定案)· 本钩子只作**补充**,⛔ 不是投递的前置:
|
||
# · **主 = 常驻投递**:`collabd.py --supervise` **一直运行**,按周期自己读队列 ⇒ 自己拨。
|
||
# · **补 = 本钩子**:`UserPromptSubmit` 等事件到了,顺手补投一版 ⇒ 让投递更及时。
|
||
# 用户原话:「协作与投递一直运行(常驻) 因为可能不是所有 队列都是钩子产生的 ,
|
||
# 而且定时任务的方案已经废弃了」
|
||
# ⛔ 「需要投递的时刻必然伴随会话在动」是**错假设** —— 队列可能由**非钩子**的来源产生,
|
||
# 那一刻没有事件、也就没人投递 ⇒ **纯事件驱动必漏**,必须靠常驻的钟补上。
|
||
# ⛔ 已废弃「用自动任务当闹钟」;⛔ 也不靠宿主排期。
|
||
# 事实(源码 + 本机实测):钩子是宿主起的子进程 ⇒ ① 继承网关口令 `CODEBUDDY_GATEWAY_PASSWORD`
|
||
# (2026-09-30 实测本机会话内进程 `len=43`)② ⛔ 不占任何会话 ③ **零 token**;
|
||
# · `UserPromptSubmit` 两日 26 次 spawn 实测**会被宿主投递**(`SessionEnd` 反而 0 次)。
|
||
# 护栏:① scope 非 outside 才触发 ② 节流 TICK_GAP 秒 ③ 硬超时 ④ 一切异常全吞 ⑤ 隐窗
|
||
# ---------------------------------------------------------------------------
|
||
TICK_GAP = 120 # 秒:最快多久唤起一轮(⚠️ 必须 < collabd 的 `wake_min_gap`,否则投递被本层饿死)
|
||
TICK_TIMEOUT = 20 # 秒:硬超时(⛔ 钩子注册的 timeout 必须比它大)
|
||
TICK_STAMP = os.path.join(INBOX, "_tick.stamp") if INBOX else ""
|
||
|
||
|
||
# ── A 方案(2026-09-30 用户拍板):把网关口令**投递**给设备接入垫片 ──────────────
|
||
# 垫片原设计「口令只在 env」⇒ **脱离会话**拉起时读不到口令 ⇒ 一路 503、链路差最后一跳。
|
||
# 本钩子跑在**宿主进程树内** ⇒ 天然读得到口令 ⇒ 由它负责「送」(实现见那个脚本)。
|
||
# ⛔ 静默 · ⛔ 有节流 · ⛔ 异常一律吞(它是**旁路**,⛔ 不得影响钩子、更不得影响 WorkBuddy)。
|
||
_TOKEN_DELIVER = None
|
||
_TOKEN_STAMP = ""
|
||
_TOKEN_GAP = 60.0
|
||
|
||
|
||
def maybe_deliver_gateway_token() -> None:
|
||
"""🔴 A 方案的口令**投递**(送的一方)。说明见 `.workbuddy/collab/deliver-gateway-token.py`。
|
||
|
||
⚠️ 调用方是 `PreToolUse` —— 该事件**必须零 stdout** ⇒ 子进程输出一律丢弃。
|
||
"""
|
||
global _TOKEN_DELIVER, _TOKEN_STAMP
|
||
if not WS:
|
||
return
|
||
if _TOKEN_DELIVER is None:
|
||
_TOKEN_DELIVER = os.path.join(WS, ".workbuddy", "collab", "deliver-gateway-token.py")
|
||
_TOKEN_STAMP = os.path.join(WS, "tmp", "supervise-inbox", "_token-deliver.stamp")
|
||
if not os.path.isfile(_TOKEN_DELIVER):
|
||
return
|
||
try:
|
||
if os.path.isfile(_TOKEN_STAMP) and (time.time() - os.path.getmtime(_TOKEN_STAMP)) < _TOKEN_GAP:
|
||
return
|
||
except Exception:
|
||
pass
|
||
# 🔴 2026-10-02 二修:原实现**同步** `timeout=12` ⇒ 挂在 `PreToolUse ^Bash$` 上
|
||
# ⇒ **本机每一条 Bash 命令执行前都要先等它**(最坏 12s)=「会话无法执行」的第二大致命点。
|
||
# 它不需要任何返回值 ⇒ 一律后台(投递能力不变:detach 只脱离进程树,⛔ 不丢 env 里的口令)。
|
||
try:
|
||
_bg(_TOKEN_DELIVER, [], env=dict(os.environ), tag="tok", min_gap=5.0)
|
||
except Exception:
|
||
pass
|
||
|
||
|
||
# ── 🔴 2026-10-02 加:「**没有相关会话就自动拉起**」(用户口径)────────────────────
|
||
# 用户原话(逐字):「应该是 用户说 协作会话 完成目标 和 继续执行 的时候
|
||
# 如果没有 相关会话就自动拉起」
|
||
# 用户 2026-10-02 13:18 **再订正触发面(逐字)**:「是用户说 **使用协作会话方式 完成目标**
|
||
# 或 **继续完成目标**」⇒ 触发词按这两句取(`GAP_TRIGGERS`)。
|
||
# · 判据面=`collabd.py --gap --json`(**只读**:判"缺没缺"+给现成排期参数)。
|
||
# · 本钩子**只把结论注入当前会话上下文** ⇒ 由**会话**用 `automation_update` 建排期。
|
||
# 🔴 为什么必须绕这一手:**只有自动化能开新会话**,而**脚本写 `automations` 表是双红线**
|
||
# (§2.3.0e ⚠️ 那段)⇒ "缺会话 ⇒ 自动拉起"在架构上**只剩这一条可行通路**。
|
||
# ⛔ 旧行为(写 `NEED-USER.md` 请**用户**去开一条会话)=把机制该干的活推给人 ⇒ 本条取代它。
|
||
# · 触发两条:① 用户话里命中触发词 ⇒ **立刻查**(无视节流) ② 否则按 `GAP_GAP` 节流查一次。
|
||
# · 一切异常吞掉(钩子绝不能因此失败);⛔ 结果为空 ⇒ 不注入(⛔ 不制造噪音)。
|
||
# ⚠️ 触发词=用户原话的两个说法(2026-10-02 13:18 订正):
|
||
# 「使用协作会话方式**完成目标**」/「继续**完成目标**」⇒ 取 `协作会话方式` 与 `完成目标` 两个词;
|
||
# `继续执行` 保留为**宽松兜底**(同一意图的另一种说法,命中只是"多查一次",无副作用)。
|
||
#
|
||
# 🔴🔴 2026-10-02 18:0x **收紧授权面**(P0-24 的**同一个洞、第二个方向**):
|
||
# 事故:用户说「**继续完成目标,并打开协作看板**」⇒ 命中 `完成目标` ⇒ 钩子判定**已授权** ⇒ 注入派活指令。
|
||
# ⛔ 「完成目标」「继续执行」是**通用说法、随处可见**(「继续完成目标」根本不必含"协作"),
|
||
# 拿它当授权 = **系统自己把"用户在说别的"读成"用户要用协作机制"**。
|
||
# 这与 17:5x 的越权是**同一个判据缺陷的两个方向**:那次是"没授权也派"、这次是"乱授权也派"。
|
||
# ⇒ 修法:**授权只认「协作会话」这一个词根**(必须真的在说协作机制),且要求**不是命令式祈使**
|
||
# ("打开协作看板"是用户要**看**看板,⛔ 不是要**建**会话)。通用说法一律降级为"仅通报"。
|
||
# ⇒ 代价(明确接受):用户说「继续完成目标」而**没提协作**时,⛔ 不自动拉起 —— 那就**不拉**,
|
||
# 顶多少一次通报。这是**宁可漏、不可越权**的取舍。
|
||
GAP_AUTH_ROOTS = ("协作会话",) # 🔴 唯一授权词根:要协作机制时一定会说到它
|
||
# ⛔ 刻意**不含**:完成目标 / 继续执行 / 接着做 —— 通用说法,证明不了"要用协作"。
|
||
GAP_TRIGGERS = ("协作会话", "完成目标", "继续执行") # 仅用于「要不要跑 --gap 检查」的节流判断
|
||
GAP_GAP = 120.0 # 秒:非触发词时的最小检查间隔(触发词命中 ⇒ 无视)
|
||
GAP_TIMEOUT = 25 # 秒:硬超时(⛔ 钩子注册的 timeout 必须比它大)
|
||
GAP_STAMP = os.path.join(INBOX, "_gap.stamp") if INBOX else ""
|
||
GAP_MAX = 700 # 字符上限:注入物**每轮**都进上下文(太长会让会话变慢)
|
||
# 🔴 2026-10-02 加:`--gap` 实测 **13.5s**(见 tmp/hook-timing-probe.py 实测)⇒ ⛔ 不能再同步堵在
|
||
# `UserPromptSubmit`(宿主注册 20s)上 ⇒ 结果**落盘缓存**,钩子只读缓存(毫秒级),
|
||
# 后台进程负责刷新。缓存内容最多滞后一个刷新周期(无害:它只是"建议",不是判据)。
|
||
GAP_CACHE = os.path.join(INBOX, "gap-cache.json") if INBOX else ""
|
||
GAP_CACHE_TMP = os.path.join(INBOX, "gap-cache.json.tmp") if INBOX else ""
|
||
GAP_CACHE_TTL = 300.0 # 秒:缓存多久算新鲜
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# 🔴 2026-10-02 加:**钩子总预算**(宿主给每个事件注册的 timeout ≠ 本脚本能用的全部)
|
||
# 事故:UserPromptSubmit 注册 20s,而本脚本在该路径上串了 `--gap`(13.5s) + `--once`(0.5s)
|
||
# + `--tick`(13.5s) ≈ **27.5s** ⇒ 宿主掐断 ⇒
|
||
# `UserPromptSubmit operation blocked by hook: Hook timed out after 20000ms`
|
||
# ⇒ **用户发的每一句话都被拦下**(不只是慢,是提交失败)。
|
||
# ⇒ 三条硬规则:① 每个事件**开局就认领预算**(BUDGET,比注册值少 2s 余量)
|
||
# ② 凡是**要等**的子进程,先问 `_left()` 够不够(不够 ⇒ 跳过并留痕)
|
||
# ③ 不需要结果的活 ⇒ **后台跑**(`_bg`,⛔ 不 wait)⇒ 钩子毫秒级返回
|
||
# ---------------------------------------------------------------------------
|
||
BUDGET = {"UserPromptSubmit": 18.0, "PreToolUse": 25.0, "SessionEnd": 8.0}
|
||
_T0 = time.time()
|
||
_BUDGET = 15.0
|
||
# 同步等待一个"贵活"所需的**实测**耗时(2026-10-02 实测:`--gap` 13.5s | `--tick` 13.5s | `--once` 0.5s)
|
||
GAP_SYNC = 16.0
|
||
TICK_SYNC = 16.0
|
||
_HEAVY_DONE = False # 🔴 本轮已经同步干过一个贵活 ⇒ 另一个只能后台(⛔ 一轮一个,别叠到 27s)
|
||
|
||
|
||
def _hard_exit() -> None:
|
||
"""硬闸触发:留痕后**立刻**退出(fail-open)。"""
|
||
try:
|
||
log("⚠️ 硬闸触发(%.1fs 预算耗尽)⇒ 强制退出。UserPromptSubmit 未产出注入,属 fail-open。" % _BUDGET)
|
||
except Exception:
|
||
pass
|
||
os._exit(0)
|
||
|
||
|
||
def _arm_deadline() -> None:
|
||
"""🔴 **自身硬闸**(2026-10-02 加 · P0-23 的第 4 条硬规则)
|
||
|
||
为什么必须有这道闸:上面三处「改后台」只是**改掉了这一次的三个具体活**;
|
||
🔴 **任何人(含以后的另一个会话)只要再往这条路上加一个同步子进程,故障就原样复发**,
|
||
而复发的第一现场是「用户说不了话」——**不可能等到事后排查**。
|
||
⇒ 由脚本自己对削 patient 底线:**到点强制退出**,绝不让宿主掐。
|
||
|
||
fail-open 方向是明确的:**宁可本轮少注入一段(═ 什么都没发生),也绝不让会话被拦下**。
|
||
|
||
⚠️ `daemon=True` 是必须的:非 daemon 线程会让解释器在退出时**卡住不退出**(等同另一种超时)。
|
||
⚠️ `os._exit(0)` 而非 `sys.exit`:此时主线程可能正卡在 `subprocess.run` 上,
|
||
正常退出路径走不到 ⇒ 只能强杀(留痕已先行写盘)。
|
||
"""
|
||
try:
|
||
import threading
|
||
t = threading.Timer(max(1.0, _BUDGET - 0.5), _hard_exit)
|
||
t.daemon = True
|
||
t.start()
|
||
except Exception:
|
||
pass
|
||
|
||
|
||
def set_budget(event: str) -> None:
|
||
global _BUDGET
|
||
_BUDGET = BUDGET.get(event, 10.0)
|
||
_arm_deadline() # 🔴 开局即上闸:后面无论怎么改,都不可能越过宿主预算
|
||
|
||
|
||
def _left() -> float:
|
||
"""本轮还剩多少秒(⛔ 负数 = 已超预算)。"""
|
||
return _BUDGET - (time.time() - _T0)
|
||
|
||
|
||
def _bg(script: str, args, env=None, tag: str = "bg", out_path=None, min_gap: float = 60.0) -> bool:
|
||
"""**后台**起一个子进程,⛔ 不等它回来(钩子立刻返回)。
|
||
|
||
⛔ 只给"不需要结果"的活(`--tick` / `--gap` 刷新):输出一律落盘,⛔ 不用 PIPE
|
||
(PIPE 的坑:超时后 `communicate()` 会等孙进程放管道 ⇒ **即使 kill 也回不来**)。
|
||
⛔ 同 tag 已有活着的后台 ⇒ 不再起(防止每轮发话堆一个 13s 进程)。
|
||
⚠️ 口令照旧在 env 里(detach 只脱离进程树,⛔ 不丢 env)⇒ 投递能力不受影响。
|
||
"""
|
||
if not script or not os.path.isfile(script):
|
||
return False
|
||
try:
|
||
# ⚠️ 判"上一个还在跑"用**起始时间戳**,⛔ 不用 `os.kill(pid, 0)`:
|
||
# Windows 上 `os.kill` 对 `sig=0` 的行为不保证(有版本直接 TerminateProcess)⇒ 宁可不探活。
|
||
_pf = os.path.join(INBOX, "_bg-%s.stamp" % tag) if INBOX else ""
|
||
if _pf and os.path.isfile(_pf):
|
||
try:
|
||
if (time.time() - os.path.getmtime(_pf)) < min_gap:
|
||
return False # 上次刚起(可能还在跑)⇒ ⛔ 不再堆一个
|
||
except Exception:
|
||
pass
|
||
try:
|
||
if _pf:
|
||
with open(_pf, "w", encoding="utf-8", newline="\n") as f3:
|
||
f3.write(datetime.now().strftime("%Y-%m-%d %H:%M:%S"))
|
||
except Exception:
|
||
pass
|
||
_out = out_path or (os.path.join(INBOX, "_bg-%s.out" % tag) if INBOX else os.path.join(
|
||
tempfile.gettempdir(), "wb-hook-bg-%s.out" % tag))
|
||
f = open(_out, "ab")
|
||
_flags = HIDE
|
||
if os.name == "nt":
|
||
_flags |= 0x00000008 | 0x00000200 # DETACHED_PROCESS | CREATE_NEW_PROCESS_GROUP
|
||
subprocess.Popen([PYW, "-u", script] + [str(a) for a in args],
|
||
env=env or dict(os.environ), stdout=f, stdin=subprocess.DEVNULL,
|
||
stderr=subprocess.DEVNULL, creationflags=_flags, close_fds=True)
|
||
return True
|
||
except Exception as e:
|
||
try:
|
||
log("bg spawn(%s) 失败(已忽略): %r" % (tag, e))
|
||
except Exception:
|
||
pass
|
||
return False
|
||
|
||
|
||
def _prompt_text(payload) -> str:
|
||
"""取用户这轮说的话(⛔ 猜不到 ⇒ 空串,且**不因此放弃检查** —— 节流那条路照样走)。
|
||
|
||
⚠️ 字段名按 Claude-Code 式钩子契约是 `prompt`;`user_prompt` / `text` / `message` 只是兜底
|
||
(⛔ 别把兜底当契约)。拿不到原文 ⇒ 只影响"触发词加急",不影响"没会话就拉起"这件事本身。
|
||
"""
|
||
for k in ("prompt", "user_prompt", "text", "message"):
|
||
v = payload.get(k)
|
||
if isinstance(v, str) and v.strip():
|
||
return v
|
||
if isinstance(v, dict):
|
||
_t = v.get("text") or v.get("content")
|
||
if isinstance(_t, str) and _t.strip():
|
||
return _t
|
||
return ""
|
||
|
||
|
||
def _gap_text(g: dict, authorized: bool) -> str:
|
||
"""`--gap` 的 JSON ⇒ 注入文本(无缺口 ⇒ 空串)。⛔ 纯函数 —— 现算与"读缓存"共用同一段。
|
||
|
||
🔴 2026-10-02 17:5x **结构性修正**(事故:用户没提协作,会话却被注入的指令逼着建了两条排期):
|
||
旧版把「⛔ 不要问用户、直接建排期」**写死在文案里**,而且这段文案被**存进缓存**逐轮复用
|
||
⇒ 每轮都在下命令,**与用户当轮有没有授权完全无关**。那个会话的判断是对的:它照着注入的指令做了。
|
||
⇒ 三处同时改:
|
||
① **授权闸**(`authorized`):只有用户当轮**明确说了**协作类诉求,才输出"自动拉起"指令;
|
||
否则只输出**中性事实**(有几条缺、叫什么),并**明写「⛔ 不要建排期」**。
|
||
② **文案不再命令**:「⛔ 不要问用户」**整句删除** —— 建不建排期是**用户的决定**,
|
||
会话最多**报告**、⛔ 不得代替用户拍板(越权 = 事故,不是"勤快")。
|
||
③ **只报已存在者**:提示里标出哪些**同名排期已经在册**(去重判据),
|
||
⛔ 不让会话把同一条反复排。
|
||
"""
|
||
hard = (g or {}).get("hard") or []
|
||
if not hard:
|
||
return ""
|
||
_lines = []
|
||
for r in hard[:4]:
|
||
p = r.get("plan") or {}
|
||
_nm = p.get("name") or "?"
|
||
_dup = " ⚠️**同名排期已在册** ⇒ ⛔ 别再排一遍" if _nm in _known_plan_names() else ""
|
||
_lines.append("· %s(类别「%s」,在册 %s 条、都不活 / 一条都没有)⇒ `%s`(一次性 %s)%s"
|
||
% (r.get("role_zh") or r.get("role"), r.get("topic") or "(默认类别)",
|
||
r.get("n_total"), _nm, p.get("scheduledAt") or "?", _dup))
|
||
_body = ("本工作区有 **%d 条硬缺**(硬缺=跟进/协作会话没有活着的):\n%s\n"
|
||
% (len(hard), "\n".join(_lines)))
|
||
if authorized:
|
||
out = ("⚠️【缺会话 ⇒ 自动拉起】" + _body +
|
||
"🔴 你**本轮明确说了**要用协作会话方式完成目标 ⇒ 用 `automation_update` 按上面名字建排期"
|
||
"(参数见 `collabd.py --gap`,prompt 现成)把它拉起来。"
|
||
"⚠️ 仍要遵守:① **同名已在册的别重排**;② 建完在回复里**说清这是你依据本轮授权做的**。\n"
|
||
"('把一条能收指令的会话带回来'只有**排期**做得到;`jobs/resume` 给的是 background worker、"
|
||
"进不了 live ⇒ 接不到 reply。⛔ 脚本不许写 `automations` 表。)")
|
||
else:
|
||
out = ("ℹ️【协作机制 · 现状通报】(⛔ **仅通报,不构成行动指令**)\n" + _body +
|
||
"🔴 **本轮用户没有要求使用协作会话** ⇒ ⛔ **不要建任何排期**、⛔ 不要写「请你去开一条会话」、"
|
||
"⛔ 不要把它当待办。真的要做时,用户会明确提出。"
|
||
"\n(口径来源:`session-mechanism` 技能 · 协作机制。**建不建排期是用户的决定**,"
|
||
"会话最多报告,⛔ 不得代用户拍板。)")
|
||
return out[:GAP_MAX]
|
||
|
||
|
||
def _known_plan_names():
|
||
"""在册排期里已存在的**同名**集合(去重判据)。读不到 ⇒ 空集(宁可少提示,⛔ 不错报)。"""
|
||
try:
|
||
db = os.path.join(_env.config_dir(), # 🔴 2026-10-02 共用判据
|
||
"workbuddy.db")
|
||
if not os.path.isfile(db):
|
||
return set()
|
||
import sqlite3 as _sq
|
||
con = _sq.connect("file:%s?mode=ro" % db.replace("\\", "/"), uri=True, timeout=3)
|
||
rows = con.execute("select name from automations where status='ACTIVE'").fetchall()
|
||
con.close()
|
||
return set(r[0] for r in rows if r and r[0])
|
||
except Exception:
|
||
return set()
|
||
|
||
|
||
def _reap_gap_cache() -> None:
|
||
"""后台刷新写完了 ⇒ 把 tmp 收割成正式缓存(**毫秒级**,每轮开头做一次)。
|
||
|
||
⚠️ 后台进程还握着句柄时 `os.replace`/`remove` 会失败 ⇒ 一律吞掉,**下一轮再收**(无害)。
|
||
"""
|
||
if not GAP_CACHE or not GAP_CACHE_TMP or not os.path.isfile(GAP_CACHE_TMP):
|
||
return
|
||
try:
|
||
if os.path.getsize(GAP_CACHE_TMP) < 2:
|
||
return
|
||
with open(GAP_CACHE_TMP, encoding="utf-8", errors="replace") as f:
|
||
g = json.loads(f.read() or "{}")
|
||
if not (g.get("rows") or g.get("topics") or g.get("hard")):
|
||
return # 半截文件/空输出 ⇒ ⛔ 不收(别把缓存写坏)
|
||
_t2 = GAP_CACHE + ".tmp2"
|
||
with open(_t2, "w", encoding="utf-8", newline="\n") as f:
|
||
# 🔴 2026-10-02 17:5x:**存原始 `--gap` JSON**(`_raw`),⛔ 不再存 `_text` 成品文案。
|
||
# 理由:文案是**随授权状态变**的(旧版把「⛔ 不要问用户」烤进了缓存 ⇒ 逐轮越权)。
|
||
# `_raw` 缺失的旧缓存会被 `_cached_gap_text` 判为无效 ⇒ 自动重刷,不做兼容迁移。
|
||
json.dump({"_ts": time.time(), "_raw": g}, f, ensure_ascii=False)
|
||
os.replace(_t2, GAP_CACHE)
|
||
try:
|
||
os.remove(GAP_CACHE_TMP)
|
||
except Exception:
|
||
pass
|
||
except Exception:
|
||
pass
|
||
|
||
|
||
def _cached_gap_text():
|
||
"""(注入文本, 缓存年龄秒)。读不到 ⇒ ("", 1e9)。
|
||
|
||
🔴 2026-10-02 17:5x:文本**在此刻现算**(`_gap_text(raw, authorized)`),
|
||
⛔ 不再返回缓存里烤好的文案 ⇒ 本轮授权状态与呈现**永远一致**。
|
||
🔴 授权只认**用户当轮的明确诉求**(`GAP_TRIGGERS`)⇒ 没提就只通报、不派活。
|
||
"""
|
||
if not GAP_CACHE or not os.path.isfile(GAP_CACHE):
|
||
return "", 1e9
|
||
try:
|
||
_age = time.time() - os.path.getmtime(GAP_CACHE)
|
||
_j = json.loads(open(GAP_CACHE, encoding="utf-8", errors="replace").read() or "{}")
|
||
_raw = _j.get("_raw")
|
||
if not isinstance(_raw, dict) or not _raw.get("hard"):
|
||
return "", 1e9 # 旧版缓存(只有 `_text`)⇒ 判无效,下轮重刷
|
||
_auth = _authorized_now()
|
||
return _gap_text(_raw, _auth), _age
|
||
except Exception:
|
||
return "", 1e9
|
||
|
||
|
||
def _authorized_now() -> bool:
|
||
"""用户**当轮**是否明确要求用协作会话方式完成目标。
|
||
|
||
🔴 判据只有一条:**当轮原话里出现「协作会话」这个词根**(`GAP_AUTH_ROOTS`)。
|
||
⛔ 刻意**不用**这些当授权(2026-10-02 18:0x 教训,两个方向都栽过):
|
||
· 「完成目标」「继续执行」—— **通用说法**,会到处命中(用户说「继续完成目标,并打开协作看板」
|
||
就被误判成授权 ⇒ 白派活);说这话的人**根本没提协作**。
|
||
· 「上一轮说过」「队列有缺口」「机制建议」—— 那些是**系统自己的诉求,不是用户的**。
|
||
⚠️ 也不认命令式祈使(如「打开协作看板」):用户要**看**看板 ≠ 要**建**会话。
|
||
"""
|
||
_t = _prompt_text(_CUR_PAYLOAD)
|
||
if not _t:
|
||
return False
|
||
if not any(r in _t for r in GAP_AUTH_ROOTS):
|
||
return False
|
||
# 用户点名的是"协作机制"才授权;若这句明显在**索取展示**(看板/状态/看看),视为非授权
|
||
if any(k in _t for k in ("看板", "状态", "看看", "打开看板", "看一下")):
|
||
return False
|
||
return True
|
||
|
||
|
||
_CUR_PAYLOAD = {}
|
||
|
||
|
||
def maybe_inject_session_gap(payload) -> str:
|
||
"""缺会话 ⇒ 返回要注入的文本(无缺口 / 查不动 ⇒ 空串)。⛔ 只读,⛔ 不建排期。
|
||
|
||
🔴 2026-10-02 改(事故:`--gap` 实测 13.5s,同步堵在 `UserPromptSubmit` 上 ⇒ 钩子 20s 被掐 ⇒
|
||
用户**每句话都被拦**):
|
||
· **读缓存**为主(毫秒级)——缓存由**后台**进程刷新;
|
||
· 缓存缺失/过期 ⇒ **后台**刷新(⛔ 不等,见「二修」段),本轮读不到就**不注入**。
|
||
🔴 2026-10-02 17:5x 改(事故:会话被注入的指令**逼着建了用户没授权的排期**):
|
||
· **当轮 payload 存进 `_CUR_PAYLOAD`** —— 否则授权判据拿不到用户原话,闸门形同虚设;
|
||
· **注入内容随授权状态变**(通报 vs 派活)—— 见 `_gap_text`。
|
||
"""
|
||
global _HEAVY_DONE, _CUR_PAYLOAD
|
||
if not INBOX or not GAP_STAMP:
|
||
return ""
|
||
_CUR_PAYLOAD = payload or {} # 🔴 先存,后面的授权判据要用
|
||
_reap_gap_cache()
|
||
_txt = _prompt_text(payload)
|
||
_hit = [t for t in GAP_TRIGGERS if t and t in _txt]
|
||
_cached, _age = _cached_gap_text()
|
||
|
||
_need = bool(_hit)
|
||
if not _need:
|
||
if _age >= GAP_CACHE_TTL:
|
||
_need = True
|
||
else:
|
||
try:
|
||
if (not os.path.isfile(GAP_STAMP)
|
||
or (time.time() - os.path.getmtime(GAP_STAMP)) >= GAP_GAP):
|
||
_need = True
|
||
except Exception:
|
||
_need = True
|
||
if _need:
|
||
_cd, _env = _collabd_ctx()
|
||
if _cd:
|
||
try:
|
||
with open(GAP_STAMP, "w", encoding="utf-8", newline="\n") as f:
|
||
f.write(datetime.now().strftime("%Y-%m-%d %H:%M:%S") + "\n")
|
||
except Exception:
|
||
pass
|
||
# 🔴 2026-10-02 二修(15:31 那版**没修干净**:仍留了同步兜底 ⇒ 16:12:38 实测 12.8s,
|
||
# 叠加解释器启动与并发子进程抢 CPU ⇒ 越过宿主 20s ⇒ **用户每句话都被拦**):
|
||
# ⇒ **UserPromptSubmit 上一个子进程都不许同步等**。理由是一条算术题:
|
||
# 宿主注册 20s - 解释器启动与 payload 解析(本机实测 0.3~1.0s,波动不可控)
|
||
# - `--gap` 实测均值 13.5s(且随负载上飘)= 余量不足 6s,
|
||
# 而同一事件还挂着另外 5 个钩子在抢同一颗 CPU ⇒ **任何一次抖动都越线**。
|
||
# ⇒ 缓存没有 / 缓存过期 ⇒ **本轮就少注入一段**(⛔ 绝不为此等待)。
|
||
# 这段注入是**建议**而非判据:缺一轮不产生任何错误行为,下一轮自然补上。
|
||
# ⛔ 判:「是不是真的缺会话」以 `collabd.py --gap` 现跑为准,⛔ 不以本钩子注入与否为准。
|
||
_bg(_cd, ["--gap", "--json"], env=_env, tag="gap",
|
||
out_path=GAP_CACHE_TMP, min_gap=90.0)
|
||
return _cached or ""
|
||
|
||
|
||
def maybe_run_supervisor_tick(rec, wait: bool = False) -> None:
|
||
"""宿主钩子唤起**一轮投递**(单条+握手 / 三条件唤醒)⇒ 由它把通知投给主会话。
|
||
|
||
⛔ 任何异常都不许冒出去(钩子绝不能因为这个失败)。
|
||
⚠️ 本函数**依赖宿主进程树里的网关口令**;在会话外手工跑会得到 `no-token`(属预期,⛔ 不是 bug)。
|
||
🔴 2026-10-02 加 `wait`:**默认后台跑**(`--tick` 实测 13.5s,同步等会把 `UserPromptSubmit`
|
||
顶到 20s 之外 ⇒ 用户发话被拦)。只有调用方明确要结果 **且** 预算充足时才同步等。
|
||
"""
|
||
if not INBOX or (rec or {}).get("scope") == "outside":
|
||
return # 域外不碰(⛔ 不扩大影响面)
|
||
_cd, _collabd_env = _collabd_ctx()
|
||
if not _cd:
|
||
return
|
||
try:
|
||
if TICK_STAMP and os.path.isfile(TICK_STAMP):
|
||
if (time.time() - os.path.getmtime(TICK_STAMP)) < TICK_GAP:
|
||
return
|
||
except Exception:
|
||
pass
|
||
try:
|
||
with open(TICK_STAMP, "w", encoding="utf-8", newline="\n") as f:
|
||
f.write(datetime.now().strftime("%Y-%m-%d %H:%M:%S"))
|
||
except Exception:
|
||
pass
|
||
try:
|
||
# 🔴 一轮只允许一个贵活:`_HEAVY_DONE` ⇒ 本轮已同步跑过 `--gap` ⇒ 这个只能后台。
|
||
if wait and _left() > (TICK_SYNC + 1.0) and not _HEAVY_DONE:
|
||
subprocess.run([PYW, "-u", _cd, "--tick"],
|
||
env=_collabd_env, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL,
|
||
timeout=max(1.0, min(float(TICK_TIMEOUT), _left() - 1.0)),
|
||
check=False, creationflags=HIDE)
|
||
_HEAVY_DONE = True
|
||
else:
|
||
_bg(_cd, ["--tick"], env=_collabd_env, tag="tick", min_gap=90.0) # ⛔ 不 wait ⇒ 钩子毫秒级返回
|
||
except Exception:
|
||
pass
|
||
|
||
|
||
def classify(cwd: str):
|
||
"""会话 cwd ⇒ (scope, line)。
|
||
scope: `line` 被监管线 | `home` 本工作区(监管自己) | `other` AIProject 下其他工作区 | `outside` 域外。
|
||
🔴 必须按**相对 AIProject 的第一段**取工作区名 —— 旧版用 `basename(cwd)`,
|
||
会话 cwd 落在**子目录**时(如 `…/ai1net_ui/docs`)会得到 `docs` ⇒ **线名错位**、
|
||
下游按线名找不到它。"""
|
||
if not cwd:
|
||
return "outside", "?"
|
||
c = cwd if cwd.endswith("/") else cwd + "/"
|
||
if not c.startswith(CARE_PREFIX):
|
||
return "outside", (cwd.rstrip("/").split("/")[-1] or "?")
|
||
rest = c[len(CARE_PREFIX):].strip("/")
|
||
seg = rest.split("/", 1)[0] if rest else "?"
|
||
if seg in LINE_WS:
|
||
return "line", LINE_WS[seg]
|
||
if seg == HOME_WS:
|
||
return "home", HOME_WS
|
||
return "other", seg
|
||
|
||
|
||
def norm_path(p: str) -> str:
|
||
"""路径归一化(比对前必须做)。
|
||
🔴 2026-09-28 实测缺陷:作用域闸门原用**单斜杠字面**比对 cwd,遇到
|
||
`E://ProgramData//AIProject//…`(重复斜杠)就判成"域外" ⇒ **静默跳过、什么都不写**,
|
||
而钩子 rc=0、stdout 空 ⇒ 从外面看**与"没触发"完全一样**。
|
||
⇒ 先反斜杠转正斜杠、再折叠连续斜杠、再小写,然后才比。"""
|
||
import re as _re
|
||
return _re.sub(r"/+", "/", str(p).replace("\\", "/")).lower()
|
||
|
||
# 判「半路断了」:Host 注入的错误重试标记(2026-09-28 实测到的成因)
|
||
ABRUPT_MARKERS = ("error-recovery", "Model error retry", "Tool Not Found")
|
||
|
||
|
||
def log(msg: str) -> None:
|
||
"""留痕。⛔ 这条日志本身失败也绝不能影响会话 —— 全部吞掉。
|
||
工作区认不出来时退到系统临时目录,保证「失败可被发现」(⛔ 不静默)。"""
|
||
for path in (HOOKLOG, LOG_FALLBACK):
|
||
if not path:
|
||
continue
|
||
try:
|
||
os.makedirs(os.path.dirname(path), exist_ok=True)
|
||
with open(path, "a", encoding="utf-8", newline="\n") as f:
|
||
f.write("%s %s\n" % (datetime.now().strftime("%Y-%m-%d %H:%M:%S"), msg))
|
||
return
|
||
except Exception:
|
||
continue
|
||
|
||
|
||
def note_skip(payload, cwd, reason):
|
||
"""🔴 被作用域挡下的会话 **必须留痕**:写 `skipped.jsonl` + 一行 stderr。
|
||
⛔ 不许像旧版那样只 `return 0` —— 那样从外面看**与"没触发"完全一样**(假阴性)。"""
|
||
rec = {
|
||
"ts": datetime.now().strftime("%Y-%m-%d %H:%M:%S"),
|
||
"reason": reason,
|
||
"session": str(payload.get("session_id") or ""),
|
||
"cwd": cwd,
|
||
"event": str(payload.get("hook_event_name") or ""),
|
||
}
|
||
log("skip: %s (cwd=%s session=%s)" % (reason, cwd or "-", rec["session"][:8]))
|
||
if SKIPPED:
|
||
try:
|
||
os.makedirs(INBOX, exist_ok=True)
|
||
with open(SKIPPED, "a", encoding="utf-8", newline="\n") as f:
|
||
f.write(json.dumps(rec, ensure_ascii=False) + "\n")
|
||
except Exception:
|
||
pass
|
||
try: # stderr 也出一行(宿主会把 hook 的 stderr 记进日志)
|
||
sys.stderr.write("[wb-result-hook] skipped %s cwd=%s\n" % (reason, cwd or "-"))
|
||
except Exception:
|
||
pass
|
||
|
||
|
||
def read_tail(path: str) -> str:
|
||
"""读文件尾部(有界)。读不到就返回空串。"""
|
||
try:
|
||
size = os.path.getsize(path)
|
||
with open(path, "rb") as f:
|
||
if size > MAX_TAIL:
|
||
f.seek(size - MAX_TAIL)
|
||
return f.read(MAX_TAIL).decode("utf-8", "replace")
|
||
except Exception as e:
|
||
log("read_tail failed: %s: %s" % (type(e).__name__, e))
|
||
return ""
|
||
|
||
|
||
def last_assistant_text(tail: str) -> str:
|
||
"""从转录尾部取「最后一条 assistant 正文」。逐行宽松解析(坏行跳过)。"""
|
||
out = ""
|
||
for ln in tail.splitlines():
|
||
ln = ln.strip()
|
||
if not ln or '"message"' not in ln:
|
||
continue
|
||
try:
|
||
rec = json.loads(ln)
|
||
except Exception:
|
||
continue
|
||
if rec.get("type") != "message" or rec.get("role") != "assistant":
|
||
continue
|
||
c = rec.get("content")
|
||
if isinstance(c, str):
|
||
out = c
|
||
elif isinstance(c, list):
|
||
parts = []
|
||
for b in c:
|
||
if isinstance(b, dict) and isinstance(b.get("text"), str):
|
||
parts.append(b["text"])
|
||
elif isinstance(b, str):
|
||
parts.append(b)
|
||
if parts:
|
||
out = "\n".join(parts)
|
||
return out
|
||
|
||
|
||
def build_record(payload, transcript_path, mode="run"):
|
||
"""从 payload 抽出要记的一行。所有字段都做类型兜底。"""
|
||
cwd = norm_path(payload.get("cwd") or "")
|
||
scope, line = classify(cwd)
|
||
tail = read_tail(transcript_path) if transcript_path else ""
|
||
final = last_assistant_text(tail)
|
||
abrupt = any(m in tail[-20000:] for m in ABRUPT_MARKERS) if tail else False
|
||
# 判定:太短的收尾 / 命中断裂标记 / 压根没读到正文 ⇒ 可疑
|
||
if not tail:
|
||
verdict = "inbox-missing-transcript"
|
||
elif abrupt:
|
||
verdict = "abrupt"
|
||
elif not final.strip():
|
||
verdict = "no-assistant-text"
|
||
elif len(final) < 40:
|
||
verdict = "short"
|
||
else:
|
||
verdict = "ok"
|
||
return {
|
||
"ts": datetime.now().strftime("%Y-%m-%d %H:%M:%S"),
|
||
"mode": mode,
|
||
"event": str(payload.get("hook_event_name") or ""),
|
||
"session": str(payload.get("session_id") or ""),
|
||
"line": line,
|
||
"scope": scope,
|
||
"cwd": cwd,
|
||
"verdict": verdict,
|
||
"final_head": final.strip().replace("\n", " ")[:300],
|
||
"tail_bytes": len(tail),
|
||
}
|
||
|
||
|
||
def append_ledger(rec: dict) -> str:
|
||
"""追加一行。返回一个简短结果码(用于留痕,⛔ 不输出到 stdout)。"""
|
||
if not LEDGER:
|
||
return "err-no-workspace" # 认不出工作区 ⇒ ⛔ 不瞎猜路径
|
||
try:
|
||
os.makedirs(INBOX, exist_ok=True)
|
||
try:
|
||
if os.path.getsize(LEDGER) > LEDGER_MAX:
|
||
return "skip-ledger-full" # ⛔ 不删旧数据,只停写并留痕
|
||
except OSError:
|
||
pass
|
||
with open(LEDGER, "a", encoding="utf-8", newline="\n") as f:
|
||
f.write(json.dumps(rec, ensure_ascii=False) + "\n")
|
||
return "ok"
|
||
except Exception as e:
|
||
log("append_ledger failed: %s: %s" % (type(e).__name__, e))
|
||
return "err-ledger"
|
||
|
||
|
||
def _dump_unknown_event(payload, argv):
|
||
"""空事件名 ⇒ **全量留痕**(payload 原文 + 相关 env + argv)。
|
||
|
||
2026-10-02:光记 payload keys 不够 —— 宿主在 asar 里根本搜不到 `hook_event_name` 字面量,
|
||
⇒ 只能等一次真实调用把**完整原文**抓下来。同一 session 只留一份(⛔ 不刷屏、不占盘)。
|
||
落到 `tmp/supervise-inbox/_unknown-event-<sid>.json`。
|
||
"""
|
||
try:
|
||
if not INBOX:
|
||
return ""
|
||
sid = (str(payload.get("session_id") or "")[:8] if isinstance(payload, dict) else "") or "nosid"
|
||
p = os.path.join(INBOX, "_unknown-event-%s.json" % sid)
|
||
if os.path.exists(p):
|
||
return p
|
||
keys = [k for k in os.environ
|
||
if any(t in k.upper() for t in ("HOOK", "SESSION", "EVENT", "TRANSCRIPT", "STOP"))]
|
||
data = {"ts": time.strftime("%Y-%m-%d %H:%M:%S"),
|
||
"argv_tail": [str(a)[:200] for a in argv[1:6]],
|
||
"env": {k: str(os.environ.get(k, ""))[:200] for k in sorted(set(keys))[:24]},
|
||
"payload_keys": sorted(payload.keys()) if isinstance(payload, dict) else [],
|
||
"payload": payload}
|
||
tmp = p + ".tmp"
|
||
with open(tmp, "w", encoding="utf-8", errors="replace") as f:
|
||
json.dump(data, f, ensure_ascii=False, indent=1, default=str)
|
||
os.replace(tmp, p)
|
||
return p
|
||
except Exception:
|
||
return ""
|
||
|
||
|
||
def _recover_event(payload):
|
||
"""**反推**被宿主留空的事件名(⛔ 拿不准就返回 "",保持"丢弃但留痕"的旧行为)。
|
||
|
||
判据按 payload **形态**分(与真实抓到的三种 payload 对齐):
|
||
· 含 `prompt` / `user_prompt` ⇒ UserPromptSubmit
|
||
· 含 `tool_name` / `tool_input` ⇒ PreToolUse
|
||
· 只有 `session_id` / `transcript_path` ⇒ SessionEnd(收尾型调用)
|
||
"""
|
||
if not isinstance(payload, dict):
|
||
return ""
|
||
k = set(payload.keys())
|
||
if k & {"prompt", "user_prompt"}:
|
||
return "UserPromptSubmit"
|
||
if k & {"tool_name", "tool_input"}:
|
||
return "PreToolUse"
|
||
if payload.get("session_id") or payload.get("transcript_path"):
|
||
return "SessionEnd"
|
||
return ""
|
||
|
||
|
||
def main(argv) -> int:
|
||
t0 = time.time()
|
||
mode = "run"
|
||
payload = {}
|
||
transcript_path = ""
|
||
|
||
# ---- 诊断模式:打印解析出来的路径(**只能手工跑**;真实钩子调用绝不带此参数)----
|
||
if "--where" in argv:
|
||
sys.stdout.buffer.write(("WS=%s\nINBOX=%s\nLEDGER=%s\nHOOKLOG=%s\nFALLBACK=%s\n"
|
||
% (WS or "(未识别)", INBOX or "-", LEDGER or "-", HOOKLOG or "-", LOG_FALLBACK)
|
||
).encode("utf-8", "replace"))
|
||
return 0
|
||
|
||
# ---- 自测模式:同一份代码路径,喂文件而不是 stdin ----
|
||
if "--selftest" in argv:
|
||
i = argv.index("--selftest")
|
||
mode = "selftest"
|
||
try:
|
||
with open(argv[i + 1], "rb") as f:
|
||
payload = json.loads(f.read(MAX_STDIN).decode("utf-8", "replace") or "{}")
|
||
except Exception as e:
|
||
log("selftest payload read failed: %s: %s" % (type(e).__name__, e))
|
||
return 0
|
||
if len(argv) > i + 2:
|
||
transcript_path = argv[i + 2]
|
||
transcript_path = transcript_path or str(payload.get("transcript_path") or "")
|
||
if not transcript_path and payload.get("_transcript_from_cwd"):
|
||
sid = str(payload.get("session_id") or "")
|
||
enc = str(payload.get("cwd") or "").replace("\\", "-").replace("/", "-").replace(":", "-")
|
||
transcript_path = os.path.join(
|
||
_env.config_dir(), "projects", enc, # 🔴 2026-10-02 共用判据
|
||
sid + ".jsonl") if sid else ""
|
||
else:
|
||
try:
|
||
raw = sys.stdin.buffer.read(MAX_STDIN)
|
||
payload = json.loads(raw.decode("utf-8", "replace") or "{}")
|
||
except Exception as e:
|
||
log("stdin parse failed: %s: %s" % (type(e).__name__, e))
|
||
return 0
|
||
transcript_path = str(payload.get("transcript_path") or "")
|
||
|
||
# ---- 🔴 2026-09-29 新增:UserPromptSubmit ⇒ 把**协作程序的机械摘要注入会话上下文**
|
||
# 这就是"**程序 → 主会话**"的同步通道(用户要的:"他同步给你"):
|
||
# ⛔ 不需要任何自动化、⛔ 不需要"监管棒"角色 —— 用户每次发话时顺手把最新状态喂进来。
|
||
# ⚠️ 只有本事件**允许写 stdout**(stdout 正是钩子协议通道;其他事件仍保持零输出)。
|
||
event = str(payload.get("hook_event_name") or "")
|
||
if not event:
|
||
# 🔴 2026-10-02 取证(**颠覆旧结论**):`hook.log` 15:34:19/20/21 有 3 次 `skip: event=''`
|
||
# ⇒ 宿主**确实调用了本钩子**,但 payload 里 `hook_event_name` 是空的 ⇒ 事件被本分支丢掉,
|
||
# 从外面看就**像"SessionEnd 从未投递"**(这正是 `SessionEnd 从不投递` 这条旧结论的来源 —— **它是错的**)。
|
||
# ⇒ 2026-10-02 **接线完成**(不再等下一棒):
|
||
# ① 把 payload **原全文**落盘(同 session 一份)⇒ 下次出事可精确对号;
|
||
# ② 按 payload **形态反推**事件名(`_recover_event`)⇒ 让 SessionEnd 那条路真正走通。
|
||
# ⛔ 反推不出来才退回旧行为(丢弃并留痕),⛔ 不猜。
|
||
_dump_unknown_event(payload, argv)
|
||
_ev = _recover_event(payload)
|
||
log("probe: 空事件名(宿主已调用)keys=%s ⇒ recover=%r"
|
||
% (sorted(payload.keys())[:12] if isinstance(payload, dict) else type(payload).__name__, _ev))
|
||
if _ev:
|
||
event = _ev
|
||
set_budget(event) # 🔴 2026-10-02:**开局就认领预算**(超时=用户发话被拦,⛔ 不是慢而已)
|
||
if event == "UserPromptSubmit":
|
||
try:
|
||
ctx = "(协作程序暂无摘要)"
|
||
if WS:
|
||
dg = os.path.join(WS, "tmp", "supervise-inbox", "digest.md")
|
||
if os.path.exists(dg):
|
||
with open(dg, "r", encoding="utf-8", errors="replace") as f:
|
||
ctx = f.read().strip()[:600] # 🔴 上限 600 字符:注入物**每轮**都进上下文,太长会让会话变慢
|
||
inbox = os.path.join(WS, "tmp", "supervise-inbox")
|
||
marks = [m for m in ("VACUUM.md", "READY.md", "STALL.md") if os.path.exists(os.path.join(inbox, m))]
|
||
if marks:
|
||
ctx += "\n⚠️ 有信号文件待处理:" + "、".join(marks)
|
||
# 🔴 2026-10-02:缺会话 ⇒ **自动拉起**(判据+方案由 `collabd.py --gap` 给,本钩子只注入)
|
||
_gp = ""
|
||
try:
|
||
_gp = maybe_inject_session_gap(payload)
|
||
except Exception:
|
||
_gp = ""
|
||
if _gp:
|
||
ctx += "\n" + _gp
|
||
sys.stdout.buffer.write(json.dumps(
|
||
{"hookSpecificOutput": {"hookEventName": "UserPromptSubmit",
|
||
"additionalContext": "【协作程序同步 · 自动注入】\n" + ctx}},
|
||
ensure_ascii=False).encode("utf-8", "replace"))
|
||
except Exception:
|
||
pass
|
||
# 🔴 2026-09-29 加:摘要写完后(⛔ 不阻塞注入)顺手驱动协作程序一轮。
|
||
# `SessionEnd` 那条注册实测从未被投递 ⇒ 回路只能挂在这个**确实会被投递**的事件上。
|
||
if _left() > 3.0: # `--once` 实测 0.5s(⛔ 按实测给门槛,别按硬超时 12s)
|
||
maybe_run_collabd_once()
|
||
try: # ⛔ 异常一律吞掉(绝不许因为投递让钩子失败)
|
||
_s, _l = classify(norm_path(payload.get("cwd") or ""))
|
||
maybe_run_supervisor_tick({"scope": _s, "line": _l}) # **后台**投一轮(⛔ 不 wait、⛔ 不靠排期)
|
||
except Exception:
|
||
pass
|
||
log("UserPromptSubmit done in %.0fms (budget %.0fs, left %.1fs)"
|
||
% ((time.time() - t0) * 1000, _BUDGET, _left()))
|
||
return 0
|
||
|
||
# ---- 🔴 2026-09-30 加:`PreToolUse` ⇒ **宿主侧的投递时机** = 主通道 --------------------
|
||
# 触发源(2026-10-01 定案):**主=常驻投递、补=本钩子**;⛔ 不靠自动化排期(已废弃)。
|
||
# 机制:任何会话跑一次 Bash ⇒ 宿主唤起本钩子(它是**宿主的子进程**,自带网关口令、
|
||
# ⛔ 不占会话、**零 token**)⇒ 顺手唤起**一轮投递**,把队列变化投给主会话。
|
||
# ⚠️ 本事件**必须零 stdout**(⚠️ 只有 UserPromptSubmit 允许写协议通道)。
|
||
# ⚠️ 作用域:`outside` 一律不碰(⛔ 不扩大影响面)。
|
||
if event == "PreToolUse":
|
||
try:
|
||
_s, _l = classify(norm_path(payload.get("cwd") or ""))
|
||
if _s in ("line", "home"):
|
||
# ⛔ 不改同步:Bash 前同步等 14s 会让**每条命令都变慢**;投递的**主通道是常驻**,
|
||
# 钩子只是补 ⇒ 一律后台(2026-10-02 实测后台 15s 内跑完,投递照样发生)。
|
||
maybe_run_supervisor_tick({"scope": _s, "line": _l}, wait=False)
|
||
except Exception:
|
||
pass
|
||
# 🔴 A 方案:顺手把网关口令投给设备接入垫片(有 60s 节流;⛔ 零 stdout)
|
||
try:
|
||
if _left() > 14.0: # 该子过程硬超时 12s ⇒ 预算不够就别起(⛔ 起了也必被掐)
|
||
maybe_deliver_gateway_token()
|
||
except Exception:
|
||
pass
|
||
return 0
|
||
|
||
# ---- 闸门 1:只处理 SessionEnd ----
|
||
if event != "SessionEnd":
|
||
log("skip: event=%r (only SessionEnd / UserPromptSubmit / PreToolUse)" % event)
|
||
return 0
|
||
|
||
# ---- 闸门 2:作用域(域外不读转录;但**被丢弃也要留痕**,⛔ 不静默)----
|
||
cwd = norm_path(payload.get("cwd") or "")
|
||
scope, line = classify(cwd)
|
||
if scope == "outside":
|
||
note_skip(payload, cwd, "outside-scope")
|
||
return 0
|
||
|
||
rec = build_record(payload, transcript_path, mode)
|
||
code = append_ledger(rec)
|
||
# 🔴 2026-09-29 加(用户定案架构):**本会话收尾 ⇒ 通知协作程序「这条处理完了」**
|
||
# 这就是用户说的「**处理完成后再通过 hook 让监控后台继续读下一条**」——
|
||
# 用**事件**驱动"继续",⛔ 不靠定时轮询。程序读 `gate-done.stamp` 的 mtime 放行。
|
||
try:
|
||
if WS:
|
||
_gd = os.path.join(WS, "tmp", "supervise-inbox", "gate-done.stamp")
|
||
with open(_gd, "w", encoding="utf-8") as f:
|
||
f.write("%s %s %s\n" % (time.strftime("%Y-%m-%d %H:%M:%S"),
|
||
str(payload.get("session_id") or "")[:8],
|
||
str(rec.get("line") or "")))
|
||
except Exception:
|
||
pass
|
||
|
||
log("done %s line=%s verdict=%s session=%s took=%.0fms tail=%dB"
|
||
% (code, rec["line"], rec["verdict"], rec["session"][:8], (time.time() - t0) * 1000, rec["tail_bytes"]))
|
||
# 🔴 2026-10-02:`SessionEnd` 宿主只给 **10s** ⇒ 每一步都先问预算,超了就跳过并留痕
|
||
# (⛔ 旧行为:`--tick` 13.5s + `--once` 25s 硬超时 ⇒ 这条必然被掐,"投递"其实从未跑完)
|
||
if _left() > 2.0: # 实测 0.95s(⛔ 别按"硬超时 8s"挡,那样在 8s 预算下永远跑不到)
|
||
maybe_sweep_logcap() # 🔴 hook → 解除"会话日志撞上限 ⇒ 输出被丢"(零 token)
|
||
if _left() > 2.0: # 实测 0.96s,同上
|
||
maybe_run_supervisor(rec) # 🔴 hook → 监管(零 token、不占会话;见上方说明)
|
||
maybe_run_supervisor_tick(rec, wait=True) # 🔴 hook → **投递**(预算够才同步等)
|
||
|
||
# 🔴 2026-09-29 改:合并为**单一协作守护程序** `collabd.py`(原 `advance-watch.py` 已被它吸收)
|
||
# `--once` = 跑一轮即退(零 token、不占会话);常驻版由主会话/检查点用宿主后台机制启动。
|
||
try:
|
||
_cd, _collabd_env = _collabd_ctx()
|
||
if _cd and _left() > 5.0:
|
||
subprocess.run([PYW, "-u", _cd, "--once"],
|
||
env=_collabd_env, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL,
|
||
timeout=max(1.0, min(25.0, _left() - 1.0)), check=False, creationflags=HIDE)
|
||
except Exception:
|
||
pass
|
||
return 0
|
||
|
||
|
||
if __name__ == "__main__":
|
||
try:
|
||
sys.exit(main(sys.argv))
|
||
except SystemExit:
|
||
raise
|
||
except BaseException as e: # 连 KeyboardInterrupt 也不许冒出去
|
||
try:
|
||
import traceback as _tb
|
||
log("fatal: %s: %s\n%s" % (type(e).__name__, e, _tb.format_exc()))
|
||
except Exception:
|
||
pass
|
||
sys.exit(0)
|