712 lines
36 KiB
Python
712 lines
36 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
|
|||
|
|
|
|||
|
|
import json
|
|||
|
|
import os
|
|||
|
|
import subprocess
|
|||
|
|
import sys
|
|||
|
|
import tempfile
|
|||
|
|
import time
|
|||
|
|
from datetime import datetime
|
|||
|
|
|
|||
|
|
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
|
|||
|
|
|
|||
|
|
# ---------------------------------------------------------------------------
|
|||
|
|
# 🔴 工作区根**靠上溯发现**,⛔ 不靠"脚本在第几层"(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")
|
|||
|
|
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`,不动别的。
|
|||
|
|
# 判据/修法全文 ⇒ 技能 workbuddy-session-forensics §2h-1
|
|||
|
|
# ---------------------------------------------------------------------------
|
|||
|
|
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(
|
|||
|
|
[sys.executable, "-u", SWEEP, "--quiet"],
|
|||
|
|
cwd=WS or None,
|
|||
|
|
stdout=subprocess.PIPE, stderr=subprocess.STDOUT,
|
|||
|
|
timeout=SWEEP_TIMEOUT, 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(
|
|||
|
|
[sys.executable, "-u", SUPERVISE, "--once"],
|
|||
|
|
cwd=WS or None,
|
|||
|
|
stdout=subprocess.PIPE, stderr=subprocess.STDOUT,
|
|||
|
|
timeout=SUP_TIMEOUT, 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 全落本钩子)⇒ ⛔ 不依赖常驻、⛔ 不依赖重启宿主。
|
|||
|
|
# 护栏:静默 · 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 警告过「指向旧副本 ⇒ 唤醒回路等于没接」);找不到就**停手**,⛔ 不猜。
|
|||
|
|
_WS_ROOT = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
|||
|
|
|
|||
|
|
|
|||
|
|
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 = os.environ.get("CODEBUDDY_CONFIG_DIR") or os.path.join(
|
|||
|
|
os.path.expanduser("~"), ".workbuddy")
|
|||
|
|
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 "", {}
|
|||
|
|
_env = dict(os.environ)
|
|||
|
|
_env["COLLABD_WORKSPACE"] = _WS_ROOT
|
|||
|
|
_cfg = os.path.join(_WS_ROOT, ".workbuddy", "collab", "collabd.config.json")
|
|||
|
|
if os.path.isfile(_cfg):
|
|||
|
|
_env["COLLABD_CONFIG"] = _cfg
|
|||
|
|
return _p, _env
|
|||
|
|
|
|||
|
|
|
|||
|
|
def _supervise_switch_on() -> bool:
|
|||
|
|
"""🔴🔴 **协作程序总开关**(2026-10-05 用户明令「提供一个启动的开关给我来控制」)。
|
|||
|
|
|
|||
|
|
开关文件= `<工作区>/.workbuddy/collab/supervise.switch`,内容为 `on` ⇒ 开。
|
|||
|
|
🔴 **判据是 fail-safe**:文件**不存在**/读不到/内容不是 `on` ⇒ 一律判**关**
|
|||
|
|
(⛔ 绝不许"读不到就当开" —— 那正是"用户没让它跑它却一直在弹窗"的成因)。
|
|||
|
|
|
|||
|
|
用户手动控制:
|
|||
|
|
开 ⇒ 写 `on` 到该文件;关 ⇒ 改成 `off` 或直接删掉文件。
|
|||
|
|
"""
|
|||
|
|
try:
|
|||
|
|
_cd, _env = _collabd_ctx()
|
|||
|
|
if not _cd:
|
|||
|
|
return False
|
|||
|
|
_sw = os.path.join(os.path.dirname(_cd), "supervise.switch")
|
|||
|
|
if not os.path.isfile(_sw):
|
|||
|
|
return False
|
|||
|
|
with open(_sw, encoding="utf-8", errors="replace") as f:
|
|||
|
|
return f.read().strip().lower() == "on"
|
|||
|
|
except Exception:
|
|||
|
|
return False
|
|||
|
|
|
|||
|
|
|
|||
|
|
def maybe_run_collabd_once() -> None:
|
|||
|
|
"""受控地跑一轮常驻程序。⛔ 任何异常都不许冒出去。"""
|
|||
|
|
if not INBOX:
|
|||
|
|
return
|
|||
|
|
if not _supervise_switch_on():
|
|||
|
|
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
|
|||
|
|
try:
|
|||
|
|
subprocess.run([sys.executable, "-u", _cd, "--once"],
|
|||
|
|
env=_collabd_env, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL,
|
|||
|
|
timeout=CB_TIMEOUT, check=False, creationflags=HIDE)
|
|||
|
|
except Exception:
|
|||
|
|
pass
|
|||
|
|
|
|||
|
|
|
|||
|
|
# ---------------------------------------------------------------------------
|
|||
|
|
# 🔴 2026-09-30 立 · **投递(监督程序)由宿主钩子唤起** —— 用户口径:「又给我整到自动任务去了」
|
|||
|
|
# ⇒ **投递 ⛔ 不靠自动化排期、⛔ 不靠常驻进程**。
|
|||
|
|
# 事实(源码 + 本机实测):
|
|||
|
|
# · 钩子是**宿主起的子进程** ⇒ ① 继承网关口令 `CODEBUDDY_GATEWAY_PASSWORD`
|
|||
|
|
# (2026-09-30 实测本机会话内进程 `len=43`)② ⛔ 不占任何会话 ③ **零 token**;
|
|||
|
|
# · `UserPromptSubmit` 两日 26 次 spawn 实测**会被宿主投递**(`SessionEnd` 反而 0 次)。
|
|||
|
|
# · 🔴🔴 **2026-10-05 `--tick` 已删**(用户:「检查程序 常驻 自己不会判断吗,非要什么 tick
|
|||
|
|
# once 去触发?」)⇒ 上面这套"钩子唤起投递轮"的**前提整段作废**:
|
|||
|
|
# · 投递 **2026-10-03 整体退役**(⛔ 已无"投给主会话"这回事);
|
|||
|
|
# · 建检查会话排期 + park 指纹探针 ⇒ **搬进 `--supervise` 常驻主循环**(常驻自己判);
|
|||
|
|
# · 钩子在协作链上**只剩一件事**:`UserPromptSubmit` 时 `maybe_ensure_supervise()`
|
|||
|
|
# 顺手续命常驻(⛔ 不判定、⛔ 不建排期、⛔ 不投递)。
|
|||
|
|
# ⇒ **判定的权威在常驻**(一直运行、自己拨),钩子⛔ 不再是任何判定的触发源。
|
|||
|
|
# ---------------------------------------------------------------------------
|
|||
|
|
ENSURE_GAP_UNUSED = 120 # 🔴 2026-10-05:`--tick` 删除后本层不再节流;`--ensure` 自带幂等与节流
|
|||
|
|
TICK_TIMEOUT = 20 # 秒:硬超时(⛔ 钩子注册的 timeout 必须比它大)
|
|||
|
|
# 🔴 2026-10-05 删:`TICK_STAMP`(`_tick.stamp`)不再写 —— `--tick` 已删,
|
|||
|
|
# ⛔ 留着会让看板/工具把它当"常驻程序还在跑"的证据(戳会一直变旧 ⇒ 读数说谎)。
|
|||
|
|
|
|||
|
|
|
|||
|
|
# ── 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
|
|||
|
|
try:
|
|||
|
|
subprocess.run([sys.executable, _TOKEN_DELIVER],
|
|||
|
|
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, timeout=12,
|
|||
|
|
env=dict(os.environ),
|
|||
|
|
creationflags=getattr(subprocess, "CREATE_NO_WINDOW", 0))
|
|||
|
|
except Exception:
|
|||
|
|
pass
|
|||
|
|
|
|||
|
|
|
|||
|
|
def maybe_ensure_supervise(rec) -> None:
|
|||
|
|
"""🔴 宿主钩子**顺手续命常驻**(幂等):常驻不在 ⇒ 就地补起。
|
|||
|
|
|
|||
|
|
🔴🔴 2026-10-05 **`--tick` 已删**(用户:「检查程序 常驻 自己不会判断吗,非要什么 tick once
|
|||
|
|
去触发?」)⇒ 建检查会话排期、park 指纹探针两件事**全部搬进 `--supervise` 主循环**。
|
|||
|
|
⛔ 本函数**只留"续命"这一件**(⛔ 不再唤起任何投递/判定轮)——
|
|||
|
|
常驻总会被打断(换会话/宿主回收),没有这一下就没人把它带回来。
|
|||
|
|
⛔ 任何异常都不许冒出去(钩子绝不能因为这个失败)。
|
|||
|
|
⚠️ `--ensure` 内部自带节流与幂等(已在跑 ⇒ 直接返回),⛔ 这里不再加一层戳。
|
|||
|
|
"""
|
|||
|
|
if not INBOX or (rec or {}).get("scope") == "outside":
|
|||
|
|
return # 域外不碰(⛔ 不扩大影响面)
|
|||
|
|
if not _supervise_switch_on():
|
|||
|
|
return # 🔴 总开关关着 ⇒ ⛔ 不补起常驻(用户要的停止)
|
|||
|
|
_cd, _collabd_env = _collabd_ctx()
|
|||
|
|
if not _cd:
|
|||
|
|
return
|
|||
|
|
try:
|
|||
|
|
subprocess.run([sys.executable, "-u", _cd, "--ensure"],
|
|||
|
|
env=_collabd_env, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL,
|
|||
|
|
timeout=TICK_TIMEOUT, check=False, creationflags=HIDE)
|
|||
|
|
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 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(
|
|||
|
|
os.path.expanduser("~"), ".workbuddy", "projects", enc,
|
|||
|
|
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 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)
|
|||
|
|
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` 那条注册实测从未被投递 ⇒ 回路只能挂在这个**确实会被投递**的事件上。
|
|||
|
|
maybe_run_collabd_once()
|
|||
|
|
try: # ⛔ 异常一律吞掉(绝不许因为投递让钩子失败)
|
|||
|
|
_s, _l = classify(norm_path(payload.get("cwd") or ""))
|
|||
|
|
maybe_ensure_supervise({"scope": _s, "line": _l}) # 顺手续命常驻(⛔ 不建排期、不投递)
|
|||
|
|
except Exception:
|
|||
|
|
pass
|
|||
|
|
return 0
|
|||
|
|
|
|||
|
|
# ---- 🔴 2026-09-30 加:`PreToolUse` ⇒ **宿主侧的投递时机** = 主通道 --------------------
|
|||
|
|
# 用户口径(「又给我整到自动任务去了」):**投递 ⛔ 不靠自动化排期、⛔ 不靠常驻进程**。
|
|||
|
|
# 机制:任何会话跑一次 Bash ⇒ 宿主唤起本钩子(它是**宿主的子进程**,自带网关口令、
|
|||
|
|
# ⛔ 不占会话、**零 token**)⇒ 顺手唤起**一轮监督程序**,把队列变化投给主会话。
|
|||
|
|
# ⚠️ 本事件**必须零 stdout**(⚠️ 只有 UserPromptSubmit 允许写协议通道)。
|
|||
|
|
# ⚠️ 作用域:`outside` 一律不碰(⛔ 不扩大影响面)。
|
|||
|
|
if event == "PreToolUse":
|
|||
|
|
# 🔴🔴 2026-10-05 `--tick` 删除:本事件原先唤起"投递轮"⇒ **整块删除**(投递早已退役,
|
|||
|
|
# 且建排期/park 探针已搬进常驻,钩子这里⛔ 不再做任何协作判定)。
|
|||
|
|
# ⛔ 不补 `--ensure`:本事件**极高频**(每次 Bash 都触发),续命交给 `UserPromptSubmit`。
|
|||
|
|
# 🔴 A 方案:顺手把网关口令投给设备接入垫片(有 60s 节流;⛔ 零 stdout)
|
|||
|
|
try:
|
|||
|
|
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"]))
|
|||
|
|
maybe_sweep_logcap() # 🔴 hook → 解除"会话日志撞上限 ⇒ 输出被丢"(零 token)
|
|||
|
|
maybe_run_supervisor(rec) # 🔴 hook → 监管(零 token、不占会话;见上方说明)
|
|||
|
|
|
|||
|
|
# 🔴 2026-09-29 改:合并为**单一协作守护程序** `collabd.py`(原 `advance-watch.py` 已被它吸收)
|
|||
|
|
# `--once` = 跑一轮即退(零 token、不占会话);常驻版由主会话/检查点用宿主后台机制启动。
|
|||
|
|
try:
|
|||
|
|
_cd, _collabd_env = _collabd_ctx()
|
|||
|
|
if _cd:
|
|||
|
|
subprocess.run([sys.executable, "-u", _cd, "--once"],
|
|||
|
|
env=_collabd_env, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL,
|
|||
|
|
timeout=25, 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:
|
|||
|
|
log("fatal: %s: %s" % (type(e).__name__, e))
|
|||
|
|
except Exception:
|
|||
|
|
pass
|
|||
|
|
sys.exit(0)
|