你在改 Mac 本地项目:~/grok-cc-task/cc-live-monitor/
⋯
思考
{
"command": "ls -la /Users/mengmeng/grok-cc-task/cc-live-monitor/",
"description": "List project files"
}total 736 drwxr-xr-x@ 10 mengmeng staff 320 10月 25 20:14 __pycache__ drwxr-xr-x 16 mengmeng staff 512 10月 25 20:13 . drwxr-xr-x 73 mengmeng staff 2336 10月 25 20:39 .. -rw------- 1 mengmeng staff 25 10月 25 16:43 .phone-token -rw-r--r--@ 1 mengmeng staff 27243 10月 25 20:11 assets.py -rw-r--r--@ 1 mengmeng staff 19132 10月 25 15:48 codex_parse.py -rw-r--r--@ 1 mengmeng staff 4393 10月 25 16:09 md_lite.py -rw-r--r--@ 1 mengmeng staff 36850 10月 25 20:13 PROGRESS.md -rw-r--r--@ 1 mengmeng staff 11061 10月 25 20:13 README.md -rw-r--r--@ 1 mengmeng staff 4343 10月 25 17:07 RESULT.md -rw-r--r--@ 1 mengmeng staff 18175 10月 25 20:10 runs_data.py -rw-r--r--@ 1 mengmeng staff 24632 10月 25 20:12 selftest.py -rw-r--r--@ 1 mengmeng staff 88531 10月 25 20:39 server.log -rw-r--r--@ 1 mengmeng staff 16606 10月 25 20:12 server.py -rw-r--r--@ 1 mengmeng staff 24472 10月 25 15:50 stream_parse.py -rw-r--r--@ 1 mengmeng staff 26370 10月 25 20:11 views.py
{
"command": "cat /Users/mengmeng/grok-cc-task/cc-live-monitor/PROGRESS.md 2>/dev/null | tail -60",
"description": "Read progress log tail"
}1. `run.live` 几乎只认 `~/grok-cc-task/.browser.lock`(pid 活着 + 目录名后缀匹配)。锁被清掉、或不是 `run.sh` 起的会话,即使 `stream.*.jsonl` 还在涨,也不标「运行中」,SSE 自然也不会挂上。
2. 首页侧栏是服务端渲染的静态 HTML,没有轮询;只有整页刷新才重扫。新会话(尤其会话列表之外的新目录)就一直看不见。
### 改动
1. **兜底 live 判定**(`runs_data.py`)
- 保留原锁判定且**优先**:锁命中仍是「运行中 · pid N」。
- 新增 `LIVE_FALLBACK_S = 90` 与 `_newest_record_age()`:当 `runner.log` 没有 `done rc=` / `exit rc=`(state 仍是 unfinished / unknown / queued),且任一 `stream*.jsonl` 或 Codex 记录的 mtime 在 **90 秒**内 → `live=True`、`status=运行中`、`state=running`。
- `status_detail` 记「记录 N 秒前还在更新」,`severity`/`category` 自动跟着变绿、算 running、参与置顶。
- 有 `done rc=` 的 run 一律不参与兜底,所以两小时前中断的不会被重新点亮;90 秒不写就会自己退回「未结束」,SSE 也随之结束。
2. **侧栏自动刷新**(`views.py` + `server.py` + `assets.py`)
- 新增 `GET /api/sidebar?q=&status=&limit=¤t=`:返回 `{sig, items, foot, total, counts, current{name,live,status,severity}}`,`items` 只含条目片段(不含外壳)。
- `views.sidebar_items()` / `sidebar_foot()` / `sidebar_sig()` 抽出来,整页渲染与接口共用同一套 HTML,避免两处样式漂移;`md5(items+foot+live)` 作为 `sig`,内容没变就不动 DOM。
- `assets.SIDEBAR_JS` 每 **4.5 秒**轮询一次;只有 `sig` 变化才替换 `.side-list` / `.side-foot`,替换前后保存/恢复 `scrollTop`,当前会话高亮由服务端按 `current` 参数重新打 `.on`,所以选中态和滚动位置都不丢。切回标签页(`visibilitychange`)立刻补一次。
3. **详情页就地接上实时**
- `LIVE_JS` 拆成可启动模块:脚本一直在页面里(停在最后一页的 Claude 会话都会注入),`__AUTO__` 决定是否立即连。
- 侧栏轮询发现「当前会话 live 由 false 变 true」→ 调 `window.__ccLive.start()`:顶栏状态改「运行中」、页脚提示「已开始实时」、连 `EventSource`;上一次已结束时留下的「不完整」气泡会先清掉,避免和后续事件重复。
- SSE 响应头保持 `Cache-Control: no-cache, no-store` + `X-Accel-Buffering: no`,nginx 反代不缓冲;草稿气泡与光标逻辑未动。
4. **说明文字**:侧栏底部 + 主区页脚各一行小字——`只显示 run.sh 写入 ~/grok-cc-task/runs 的会话;Claude/Codex 桌面 App 直开会看不到。`
### 验收
```text
$ curl /api/sidebar?current=<fake> # 假 run 刚写过 stream
current: {'name': '...', 'live': True, 'status': '运行中', 'severity': 'run'}
items 里出现「运行中」,sig/foot 齐全;同一接口 12–20 ms
$ curl -N /run/<fake>/live?stream=stream.1.jsonl&since=0
event: append → 第一条假消息
event: draft → 实时追加的草稿 + 光标
event: append → 第二条正式消息
```
- 假 run 目录(`runs/20261025-235900-phas7-fake`)删掉后,接口立刻回 `live: False` 且条目消失。
- 真实会话 `20261025-200930-cc-live-realtime`(锁命中)仍为「运行中」;`20261025-170245-cc-live-clean-ui` 这类已结束页面注入 `var AUTO = 0;`(不白连)。
- 三个内联脚本都过 `node --check`;`python3 selftest.py` 全绿(新增兜底 live / 十分钟不动不算 live / 已结束不误判 / 接口集成断言)。
### 变更文件
| 文件 | 变化 |
|------|------|
| `runs_data.py` | `LIVE_FALLBACK_S` + `_newest_record_age()`;锁之外的 90 秒兜底 live |
| `server.py` | 新增 `GET /api/sidebar` 路由与 `_api_sidebar()` |
| `views.py` | 侧栏片段化(`sidebar_items` / `sidebar_foot` / `sidebar_sig` / `sidebar_url`);恒注入 `LIVE_JS`(`__AUTO__`);页脚说明 |
| `assets.py` | 新增 `SIDEBAR_JS`;`LIVE_JS` 改为可 `start()`;`.side-note` / `.foot-hint` 样式 |
| `selftest.py` | 新增第 12/13 个 fixture 与第 [7] 组断言(含起临时 HTTP 服务打接口) |
| `README.md` / `PROGRESS.md` | 路由表、状态判定、自动刷新说明;Phase 7 记录 |
**Phase 7 — 完成。** 服务已重启(`lsof -ti tcp:8787 -sTCP:LISTEN | xargs kill` 后 `nohup python3 server.py >> server.log 2>&1 &`),仍绑 `0.0.0.0:8787`。
## 后续可做(未开始)
1. **回放细节**:Edit/Write 的 diff 视图、Bash 输出按 ANSI 着色、超长结果「加载更多」。
2. **对比与聚合**:同一任务多次尝试并排对比;按天统计成功率与耗时分布;token / 费用汇总。
3. **Codex 深化**:真实 Codex 产物接入后补字段映射;支持 Codex 的增量跟随。
4. **列表性能**:runs 数量再上一个量级时,考虑后台线程预热缓存或落盘索引。思考
{
"file_path": "/Users/mengmeng/grok-cc-task/cc-live-monitor/md_lite.py"
}1 """极简 Markdown → HTML(无第三方依赖)。
2
3 只做聊天里真会出现的语法:标题、列表、代码块、行内代码、粗斜体、链接、引用、分隔线。
4 安全策略:文本先 html.escape 再套标记;链接只放行 http/https,其余按纯文本处理。
5 """
6
7 from __future__ import annotations
8
9 import html
10 import re
11
12 FENCE = re.compile(r"^```\s*([\w+-]*)\s*$")
13 HEADING = re.compile(r"^(#{1,6})\s+(.*)$")
14 UL = re.compile(r"^(\s*)[-*+]\s+(.*)$")
15 OL = re.compile(r"^(\s*)\d+[.)]\s+(.*)$")
16 HR = re.compile(r"^(?:-{3,}|\*{3,}|_{3,})\s*$")
17 QUOTE = re.compile(r"^>\s?(.*)$")
18 CODE_SPAN = re.compile(r"`([^`\n]+)`")
19 BOLD = re.compile(r"\*\*(.+?)\*\*")
20 ITALIC = re.compile(r"(?<![*\w])\*(?!\s)([^*\n]+?)\*(?!\*)")
21 LINK = re.compile(r"\[([^\]\n]+)\]\((https?://[^\s)]+)\)")
22
23
24 def _esc(text: str) -> str:
25 return html.escape(text, quote=False)
26
27
28 def inline(text: str) -> str:
29 """处理行内标记;text 必须已经 escape 过。"""
30 stash: list[str] = []
31
32 def keep(fragment: str) -> str:
33 stash.append(fragment)
34 return f"\x00{len(stash) - 1}\x00"
35
36 text = CODE_SPAN.sub(lambda m: keep(f"<code>{m.group(1)}</code>"), text)
37 text = LINK.sub(
38 lambda m: keep(
39 f'<a href="{m.group(2)}" target="_blank" rel="noreferrer">{m.group(1)}</a>'
40 ),
41 text,
42 )
43 text = BOLD.sub(r"<strong>\1</strong>", text)
44 text = ITALIC.sub(r"<em>\1</em>", text)
45 return re.sub(r"\x00(\d+)\x00", lambda m: stash[int(m.group(1))], text)
46
47
48 def _item(indent: str, body: str) -> str:
49 level = len(indent.expandtabs(2)) // 2
50 cls = ' class="lv2"' if level >= 2 else (' class="lv1"' if level >= 1 else "")
51 return f"<li{cls}>{inline(_esc(body))}</li>"
52
53
54 def render(text: str) -> str:
55 """Markdown 文本 → HTML 片段。永远不抛异常,最差也返回转义后的段落。"""
56 if not text or not text.strip():
57 return ""
58 lines = text.replace("\r\n", "\n").replace("\r", "\n").split("\n")
59 out: list[str] = []
60 para: list[str] = []
61
62 def flush() -> None:
63 if para:
64 out.append("<p>" + "<br>".join(inline(_esc(x)) for x in para) + "</p>")
65 para.clear()
66
67 i = 0
68 while i < len(lines):
69 line = lines[i]
70
71 fence = FENCE.match(line)
72 if fence:
73 flush()
74 lang = fence.group(1)
75 i += 1
76 body: list[str] = []
77 while i < len(lines) and not FENCE.match(lines[i]):
78 body.append(lines[i])
79 i += 1
80 i += 1 # 跳过结束围栏;文件末尾没有围栏也照常收尾
81 cls = f' class="lang-{html.escape(lang, quote=True)}"' if lang else ""
82 code = _esc("\n".join(body))
83 out.append(f"<pre><code{cls}>{code}</code></pre>")
84 continue
85
86 if not line.strip():
87 flush()
88 i += 1
89 continue
90
91 if HR.match(line):
92 flush()
93 out.append("<hr>")
94 i += 1
95 continue
96
97 head = HEADING.match(line)
98 if head:
99 flush()
100 level = len(head.group(1))
101 out.append(f"<h{level}>{inline(_esc(head.group(2).strip()))}</h{level}>")
102 i += 1
103 continue
104
105 if UL.match(line):
106 flush()
107 items: list[str] = []
108 while i < len(lines) and UL.match(lines[i]):
109 m = UL.match(lines[i])
110 items.append(_item(m.group(1), m.group(2)))
111 i += 1
112 out.append("<ul>" + "".join(items) + "</ul>")
113 continue
114
115 if OL.match(line):
116 flush()
117 ordered: list[str] = []
118 while i < len(lines) and OL.match(lines[i]):
119 m = OL.match(lines[i])
120 ordered.append(_item(m.group(1), m.group(2)))
121 i += 1
122 out.append("<ol>" + "".join(ordered) + "</ol>")
123 continue
124
… 已截断(仅显示前 4 KB){
"file_path": "/Users/mengmeng/grok-cc-task/cc-live-monitor/runs_data.py"
}1 """扫描 ~/grok-cc-task/runs,把每个 run 目录整理成结构化信息。
2
3 只读:不写入、不修改 runs 目录下的任何文件,也不改 run.sh。
4 """
5
6 from __future__ import annotations
7
8 import os
9 import re
10 import threading
11 import time
12 from collections import OrderedDict
13 from dataclasses import dataclass, field
14
15 import codex_parse
16
17 DEFAULT_RUNS_DIR = os.path.expanduser("~/grok-cc-task/runs")
18 LOCK_DIR = os.path.expanduser("~/grok-cc-task/.browser.lock")
19
20 RESULT_NAMES = ("result.md", "result.txt", "result.json")
21 STREAM_SUFFIX = ".jsonl"
22 MAX_PREVIEW_BYTES = 400_000
23 MAX_TASK_HEAD = 4_000
24
25 STATUS_FILTERS = ("all", "running", "success", "failed")
26 CACHE_MAX = 4096
27
28 TIME_RE = re.compile(r"^\[(\d{2}):(\d{2}):(\d{2})\]")
29 DONE_RE = re.compile(r"done rc=(-?\d+)")
30 EXIT_RE = re.compile(r"exit rc=(-?\d+)")
31 RESULT_LINE_RE = re.compile(r"RESULT\s+(\S+)\s+ms\s+(\d+)\s+turns\s+(\d+)")
32 MODEL_RE = re.compile(r"attempt\s+1\s+model=(\S+)")
33 EFFORT_RE = re.compile(r"effort=(\S+)")
34 LIVE_WINDOW_S = 3600
35 INTERRUPTED_AFTER_S = 2 * 3600 # 「未结束」超过这么久没动静,当成中断了
36 LIVE_FALLBACK_S = 90 # 没有锁时,stream 最近这么久还在长,就当它还在跑
37
38 # 状态文字都是给人看的;kind 才是给代码判断的
39 STATE_RUNNING = "running"
40 STATE_SUCCESS = "success"
41 STATE_FAILED = "failed" # 真的失败:非零退出
42 STATE_BLOCKED = "blocked" # 出口没过,任务没真正开始
43 STATE_INTERRUPTED = "interrupted"
44 STATE_QUEUED = "queued"
45 STATE_UNFINISHED = "unfinished"
46 STATE_UNKNOWN = "unknown"
47
48
49 @dataclass
50 class FileInfo:
51 name: str
52 size: int
53 mtime: float
54
55
56 @dataclass
57 class RunInfo:
58 name: str
59 path: str
60 mtime: float
61 files: list[FileInfo] = field(default_factory=list)
62 stream_files: list[str] = field(default_factory=list)
63 codex_files: list[str] = field(default_factory=list)
64 result_files: list[str] = field(default_factory=list)
65 has_task: bool = False
66 has_log: bool = False
67 status: str = "未知"
68 state: str = STATE_UNKNOWN
69 status_detail: str = ""
70 live: bool = False
71 flavor: str = "" # "" / claude / codex
72 model: str = ""
73 effort: str = ""
74 result_summary: str = ""
75 duration: str = ""
76
77 @property
78 def mtime_text(self) -> str:
79 return time.strftime("%m-%d %H:%M", time.localtime(self.mtime))
80
81 @property
82 def has_stream(self) -> bool:
83 return bool(self.stream_files)
84
85 @property
86 def has_result(self) -> bool:
87 return bool(self.result_files)
88
89 @property
90 def is_codex(self) -> bool:
91 return self.flavor == "codex"
92
93 @property
94 def category(self) -> str:
95 """列表筛选用的四分类:running / success / failed / other。"""
96 if self.live or self.state == STATE_RUNNING:
97 return "running"
98 if self.state == STATE_SUCCESS:
99 return "success"
100 if self.state == STATE_FAILED:
101 return "failed"
102 return "other"
103
104 @property
105 def severity(self) -> str:
106 """侧栏圆点/顶栏状态色:run / ok / bad / warn / dim。
107
108 只有「真的失败」才给红点;出口没过、已中断用暖黄提醒,不铺一片血红。
109 """
110 if self.live or self.state == STATE_RUNNING:
111 return "run"
112 if self.state == STATE_SUCCESS:
113 return "ok"
114 if self.state == STATE_FAILED:
115 return "bad"
116 if self.state in (STATE_BLOCKED, STATE_INTERRUPTED):
117 return "warn"
118 return "dim"
119
120 @property
121 def is_placeholder(self) -> bool:
122 """既没有 runner.log 也没有 stream/jsonl 记录:纯工件目录,列表里不显示。"""
123 if self.has_log:
124 return False
125 return not any(f.name.endswith(".jsonl") for f in self.files)
126
127 @property
128 def playable_files(self) -> list[str]:
129 """详情页能回放的文件:Claude stream 在前,Codex 记录在后。"""
130 return self.stream_files + self.codex_files
131
132
133 # ---------- 小缓存:列表要扫 500 个目录,日志/任务头不能每次都重读 ----------
134
… 已截断(仅显示前 4 KB)思考
{
"file_path": "/Users/mengmeng/grok-cc-task/cc-live-monitor/views.py"
}1 """HTML 渲染:左侧会话列表 + 右侧聊天主界面 + 原始文件页。
2
3 纯字符串拼接 + html.escape;所有动态文本都必须经过 escape 或 md_lite 渲染。
4 """
5
6 from __future__ import annotations
7
8 import hashlib
9 import html
10 import json
11 import os
12 import re
13 import time
14 import urllib.parse
15 from dataclasses import dataclass, field
16
17 import md_lite
18 import runs_data as rd
19 from assets import CHAT_JS, CSS, LIVE_JS, SIDEBAR_JS # noqa: F401(CSS 供 server 复用)
20 from runs_data import RunInfo, human_size
21 from stream_parse import Draft, Event, Timeline
22
23 DEFAULT_LIST_LIMIT = 60
24 DEFAULT_PAGE_SIZE = 200
25 CHAT_WIDTH = 768
26 SIDEBAR_POLL_MS = 4500 # 侧栏多久拉一次增量(秒级太吵,十秒级又不够实时)
27 SIDEBAR_NOTE = "只显示 run.sh 写入 ~/grok-cc-task/runs 的会话;Claude/Codex 桌面 App 直开会看不到。"
28
29 # 环境噪声:截图占位、技能注入、runner 规则前缀
30 _IMG_NOISE = re.compile(r"^\[Image: original [^\]]*\]$")
31 _SKILL_INJECT = "Base directory for this skill:"
32 _TITLE_PREFIX = re.compile(r"^(目标|任务|task)[::]\s*", re.I)
33
34
35 # ---------- 小工具 ----------
36
37 def _q(name: str) -> str:
38 return urllib.parse.quote(name, safe="")
39
40
41 def _run_url(run: RunInfo, suffix: str = "") -> str:
42 return "/run/" + _q(run.name) + suffix
43
44
45 def _clip(text: str, limit: int) -> str:
46 text = text.strip()
47 return text if len(text) <= limit else text[: limit - 1] + "…"
48
49
50 def _num(n: int) -> str:
51 return f"{n:,}"
52
53
54 def _when(mtime: float) -> str:
55 """侧栏时间:今天只给时分,昨天给「昨天」,更早给月-日。"""
56 local = time.localtime(mtime)
57 now = time.localtime()
58 yesterday = time.localtime(time.time() - 86_400)
59 if (local.tm_year, local.tm_yday) == (now.tm_year, now.tm_yday):
60 return time.strftime("%H:%M", local)
61 if (local.tm_year, local.tm_yday) == (yesterday.tm_year, yesterday.tm_yday):
62 return "昨天"
63 if local.tm_year == now.tm_year:
64 return time.strftime("%m-%d", local)
65 return time.strftime("%Y-%m-%d", local)
66
67
68 _SEV_CLASS = {"run": "run", "ok": "on", "bad": "off", "warn": "warn"}
69
70
71 def _status_class(run: RunInfo) -> str:
72 """顶栏状态色:红只在真的失败时出现,出口未过 / 已中断走 warn。"""
73 return _SEV_CLASS.get(run.severity, "dim")
74
75
76 def _dot_class(run: RunInfo) -> str:
77 return {"run": "dot run", "bad": "dot bad", "warn": "dot warn"}.get(run.severity, "dot")
78
79
80 def run_title(run: RunInfo) -> str:
81 """会话标题:task.txt 第一行(去掉「目标:」这类前缀),没有就用 run 名。"""
82 text = rd.read_task_head(os.path.join(run.path, "task.txt")) if run.has_task else ""
83 for line in text.splitlines():
84 line = _TITLE_PREFIX.sub("", line.strip().lstrip("#").strip())
85 if line:
86 return _clip(line, 52)
87 return run.name
88
89
90 # ---------- 文本清洗 ----------
91
92 def clean_user_text(text: str) -> str:
93 """剥掉 runner 规则前缀、技能注入、截图占位这类环境噪声。"""
94 text = (text or "").strip()
95 if not text:
96 return ""
97 if text.startswith("[RUNNER RULES"):
98 cut = text.find("[TASK]")
99 if cut >= 0:
100 text = text[cut + len("[TASK]"):].strip()
101 if not text:
102 return ""
103 if text.startswith(_SKILL_INJECT):
104 return ""
105 lines = [ln for ln in text.splitlines() if not _IMG_NOISE.match(ln.strip())]
106 return "\n".join(lines).strip()
107
108
109 # ---------- 工具名 / 结果摘要 ----------
110
111 # 工具名说人话:普通人不需要知道 Bash / ToolSearch 是什么
112 TOOL_LABELS = {
113 "bash": "终端",
114 "shell": "终端",
115 "read": "读文件",
116 "write": "写文件",
117 "edit": "改文件",
118 "multiedit": "改文件",
119 "notebookedit": "改文件",
120 "apply_patch": "改文件",
121 "glob": "找文件",
122 "grep": "搜内容",
123 "toolsearch": "找工具",
124 }
125 TOOL_PRE_LIMIT = 4096 # 展开区单个参数/结果的展示上限(约 4KB)
126
127 _STACK_NOISE = (
128 "Traceback (most recent call last)",
129 "During handling of the above exception",
130 "The above exception was the direct cause",
131 )
132
133
134 def tool_label(name: str) -> str:
135 """工具原名 → 一行能看懂的中文名。未知工具去掉 mcp__ 前缀噪音。"""
… 已截断(仅显示前 4 KB){
"file_path": "/Users/mengmeng/grok-cc-task/cc-live-monitor/server.py"
}1 """CC Live Monitor — 最小 HTTP 服务(标准库 http.server)。
2
3 只绑 127.0.0.1:8787,只读访问 ~/grok-cc-task/runs。
4
5 启动:
6 python3 server.py # 127.0.0.1:8787
7 python3 server.py --port 9000 # 换端口
8 """
9
10 from __future__ import annotations
11
12 import argparse
13 import html
14 import json
15 import os
16 import sys
17 import time
18 import urllib.parse
19 from http import HTTPStatus
20 from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
21
22 sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
23
24 import codex_parse
25 import runs_data as rd
26 import stream_parse as sp
27 import views
28
29 HOST = "0.0.0.0" # LAN: phone can use Mac IP:8787
30 PORT = 8787
31 RUNS_DIR = rd.DEFAULT_RUNS_DIR
32 DEFAULT_LIMIT = 60
33 MAX_LIMIT = 2000
34
35 SSE_TICK_S = 0.4 # 轮询文件增长的间隔
36 SSE_PING_S = 10.0 # 无数据时的心跳
37 SSE_IDLE_END_S = 3.0 # 多久没有新字节就去确认任务是否已结束
38
39
40 def _default_stream(run: rd.RunInfo) -> str:
41 """多个记录文件时默认展示内容最多的那个。
42
43 attempts 里常见 0 字节的空文件(重试一次但没产出),按尝试序号取会选中空文件,
44 所以按文件大小取最大者;Claude stream 与 Codex 记录混放时 Claude 优先。
45 """
46 if run.stream_files:
47 pool = run.stream_files
48 else:
49 pool = run.codex_files
50 if not pool:
51 return ""
52 sizes = {f.name: f.size for f in run.files}
53 return max(pool, key=lambda name: (sizes.get(name, 0), name))
54
55
56 class Handler(BaseHTTPRequestHandler):
57 server_version = "cc-live-monitor/0.1"
58 runs_dir = RUNS_DIR
59
60 # ---------- 基础工具 ----------
61
62 def _send(self, status: int, body: str, ctype: str = "text/html; charset=utf-8") -> None:
63 data = body.encode("utf-8", errors="replace")
64 self.send_response(status)
65 self.send_header("Content-Type", ctype)
66 self.send_header("Content-Length", str(len(data)))
67 self.send_header("Cache-Control", "no-store")
68 self.end_headers()
69 if self.command != "HEAD":
70 self.wfile.write(data)
71
72 def _error(self, status: int, message: str) -> None:
73 self._send(
74 status,
75 f"""<!doctype html><html lang="zh-CN"><head><meta charset="utf-8">
76 <meta name="viewport" content="width=device-width, initial-scale=1">
77 <title>{status} · CC Live Monitor</title><style>{views.CSS}</style></head>
78 <body><div class="wrap"><header class="top"><a href="/">← 返回列表</a>
79 <h1>{status}</h1></header><div class="card">{html.escape(message)}</div>
80 </div></body></html>""",
81 )
82
83 def _query_int(self, query: dict[str, list[str]], key: str, default: int) -> int:
84 try:
85 value = int(query.get(key, [str(default)])[0])
86 except (ValueError, IndexError):
87 return default
88 return max(1, min(MAX_LIMIT, value))
89
90 # ---------- 路由 ----------
91
92 def do_GET(self) -> None: # noqa: N802
93 self._route(head=False)
94
95 def do_HEAD(self) -> None: # noqa: N802
96 self._route(head=True)
97
98 def _route(self, head: bool) -> None:
99 try:
100 parsed = urllib.parse.urlsplit(self.path)
101 parts = [urllib.parse.unquote(p) for p in parsed.path.split("/") if p]
102 query = urllib.parse.parse_qs(parsed.query)
103
104 if not parts:
105 return self._page_list(query)
106 if parts[0] == "healthz":
107 return self._send(HTTPStatus.OK, "ok\n", "text/plain; charset=utf-8")
108 if parts[0] == "api" and len(parts) == 2 and parts[1] == "sidebar":
109 return self._api_sidebar(query)
110 if parts[0] != "run" or len(parts) < 2:
111 return self._error(HTTPStatus.NOT_FOUND, "页面不存在")
112
113 name = parts[1]
114 run = rd.find_run(name, self.runs_dir)
115 if run is None:
116 return self._error(HTTPStatus.NOT_FOUND, f"找不到 run:{name}")
117
118 if len(parts) == 3 and parts[2] == "live":
… 已截断(仅显示前 4 KB)思考
{
"command": "cd /Users/mengmeng/grok-cc-task/cc-live-monitor && grep -n \"LIVE_JS\\|SIDEBAR_JS\\|__AUTO__\\|__SINCE__\\|__LIVE_URL__\\|__SIDEBAR_URL__\\|__POLL_MS__\" assets.py | head -40",
"description": "Locate JS templates in assets.py"
}368:SIDEBAR_JS = """ 370: var URL_BASE = __SIDEBAR_URL__; 371: var POLL_MS = __POLL_MS__; 425:LIVE_JS = """ 427: var LIVE_URL = "__LIVE_URL__"; 428: var SINCE = __SINCE__; 429: var AUTO = __AUTO__;
{
"command": "grep -n \"def \\|incomplete\\|class \" stream_parse.py | head -80",
"description": "Outline stream_parse.py"
}42:class Event: 43: kind: str # user / assistant / thinking / tool / tool_result / system / result / incomplete / unknown 45: thinking: str = "" # 仅 incomplete 用:中断前尚未落定的思考 56: def is_collapsible(self) -> bool: 61:class Draft: 70: def is_empty(self) -> bool: 75:class Timeline: 82: def total(self) -> int: 85: def page(self, page: int, size: int = PAGE_SIZE) -> tuple[list[Event], int, int]: 95:class Poll: 108:def _clip(text: str, limit: int) -> str: 115:def _tail(text: str, limit: int = MAX_DRAFT) -> str: 119:def _blocks_text(content) -> str: 145:def _json_text(value, limit: int) -> str: 154:def _tool_summary(name: str, tool_input) -> str: 166:class StreamReader: 169: def __init__(self, path: str, track_deltas: bool = False) -> None: 185: def read_new(self) -> Poll: 189: def read_all(self) -> Timeline: 198: def events_from(self, since: int) -> list[tuple[int, Event]]: 203: def event_at(self, index: int) -> Event | None: 209: def info(self) -> dict: 219: def total(self) -> int: 223: def snapshot(self, started: float | None = None) -> Timeline: 232: def _reset(self) -> None: 242: def _read_locked(self) -> Poll: 284: def _ingest(self, raw: bytes, poll: Poll) -> None: 336: def _emit(self, event: Event) -> None: 339: def _replace_event(self, index: int, event: Event) -> None: 345: def _parse_system(self, rec: dict) -> None: 385: def _parse_assistant(self, rec: dict, poll: Poll) -> None: 437: def _parse_user(self, rec: dict) -> None: 466: def _attach_tool_result(self, block: dict) -> None: 495: def _parse_result(self, rec: dict) -> None: 521: def _ingest_stream_event(self, raw: bytes) -> None: 587:def tail_draft(path: str, window: int = INCOMPLETE_TAIL_BYTES) -> Draft | None: 617:def parse_stream(path: str, incomplete_tail: bool = True) -> Timeline: 620: incomplete_tail=True 时,若文件末尾只有未闭合的流式增量, 621: 追加一条 kind="incomplete" 的降级气泡,避免丢掉最后一段输出。 624: if not incomplete_tail or timeline.error: 630: kind="incomplete", 637: stats["incomplete"] = 1 646:def load_timeline(path: str, incomplete_tail: bool = True) -> Timeline: 649: incomplete_tail 参与缓存键:运行中的 run 只要正式事件,结束后再补末尾草稿。 656: key = (path, incomplete_tail) 662: timeline = parse_stream(path, incomplete_tail=incomplete_tail) 678:def get_live(path: str) -> StreamReader: 690:def drop_live(path: str) -> None:
{
"command": "cat /Users/mengmeng/grok-cc-task/run.sh",
"description": "Read run.sh"
}#!/bin/bash
# Robust headless Claude Code + LCU browser runner.
# Usage: run.sh <name> <prompt-file|-> [hard_timeout_s=600] [idle_s=120]
# env: RUN_EFFORT (default medium), RUN_MODEL (default group/free-first[1m]), RUN_CWD
set -u
NAME="${1:?name}"; PF="${2:?prompt file or -}"; HARD="${3:-600}"; IDLE="${4:-120}"
BASE="$HOME/grok-cc-task"; LOCK="$BASE/.browser.lock"
MODEL="${RUN_MODEL:-group/free-first[1m]}"; EFFORT="${RUN_EFFORT:-medium}"
# 付费模型护栏:Cursor/Grok 额度不到万不得已禁止调用(用户 2026-10-21)。
# Magpie 里 cursor 已 off+unlisted;此处再拦一层,避免 /tmp 旧副本或显式 RUN_MODEL 绕过。
case "$MODEL" in
cursor/*|*grok-4.7*|cursor)
echo "[$(date +%H:%M:%S)] WARN: RUN_MODEL=$MODEL 会烧 Cursor/Grok 额度,已强制改回 group/free-first[1m]" >&2
MODEL="group/free-first[1m]"
;;
esac
RUN_DIR="$BASE/runs/$(date +%Y%m%d-%H%M%S)-$NAME"; mkdir -p "$RUN_DIR"
[ "$PF" = "-" ] && cat > "$RUN_DIR/task.txt" || cp "$PF" "$RUN_DIR/task.txt"
log(){ echo "[$(date +%H:%M:%S)] $*" | tee -a "$RUN_DIR/runner.log" >&2; }
# ---- global lock (mkdir is atomic; stale lock = dead pid) ----
while ! mkdir "$LOCK" 2>/dev/null; do
op=$(cat "$LOCK/pid" 2>/dev/null)
if [ -n "$op" ] && ! kill -0 "$op" 2>/dev/null; then log "stale lock pid $op, removing"; rm -rf "$LOCK"; continue; fi
log "waiting for lock held by pid ${op:-?} ($(cat "$LOCK/name" 2>/dev/null))"; sleep 10
done
echo $$ > "$LOCK/pid"; echo "$NAME" > "$LOCK/name"
trap 'rm -rf "$LOCK"' EXIT
trap 'for c in $(pgrep -P $$); do killtree $c TERM; done; exit 130' INT TERM
PRE="$(cat <<P
[RUNNER RULES - follow strictly]
- Egress check: open ONLY https://www.cip.cc (fallback https://ip.cn) in the browser; it must show 河北 电信 (IP/city NOT fixed—廊坊/石家庄等会跳都算过; need 河北+电信). Do NOT use ip.sb, ipinfo.io, myip.ipip.net (they go via US proxy or 521). If cip.cc does not show both 河北 and 电信, stop and write EGRESS FAIL.
- Every mcp__lcu__js call must be bounded: pass timeout_ms (<=45000), wrap every await (navigation, waitFor, fetch) in Promise.race with a timeout of at most 30s, never wait on load forever, dismiss page dialogs.
- Open your own new tab(s). Append each tab id/URL you open to $RUN_DIR/tabs.txt. Before finishing, close every tab you opened (and only those).
- PDD (yangkeduo/pinduoduo): never conclude 'login invalid' from a masked price like '8?.?' / '前往APP查看价格' or a login popup on a direct goods.html link — that is PDD anti-crawl, not logout. Prefer opening goods from the search results page (click-through) instead of direct goods.html URLs; keep PDD page loads few and slow. If login state matters, check https://mobile.yangkeduo.com/personal.html once (nickname + 我的订单 = logged in; redirect to login.html = logged out). Never clear cookies/site data.
- Never message sellers, never click buy/order. Never restart Clash. Keep the task small; do not deliberate at length—act, save files, finish.
- Write files with the Bash/Write tools exactly as instructed. Finish with a one-line summary.
[TASK]
P
)"
PROMPT="$PRE
$(cat "$RUN_DIR/task.txt")"
SETTINGS="{\"effortLevel\":\"$EFFORT\",\"env\":{\"CLAUDE_CODE_EFFORT_LEVEL\":\"$EFFORT\",\"CLAUDE_CODE_MAX_OUTPUT_TOKENS\":\"32000\"},\"enabledPlugins\":{\"nowledge-mem@nowledge-community\":false,\"codex@openai-codex\":false,\"superpowers@superpowers-marketplace\":false}}"
killtree(){ local c; for c in $(pgrep -P "$1"); do killtree "$c" "$2"; done; kill -"$2" "$1" 2>/dev/null; }
run_once(){ # $1 attempt
local L="$RUN_DIR/stream.$1.jsonl" E="$RUN_DIR/stderr.$1.txt" start now last pid
log "attempt $1 model=$MODEL effort=$EFFORT hard=${HARD}s idle=${IDLE}s"
( cd "${RUN_CWD:-$BASE}" && exec env CLAUDE_CODE_EFFORT_LEVEL="$EFFORT" /opt/homebrew/bin/npx -y @anthropic-ai/claude-code \
-p "$PROMPT" --model "$MODEL" --effort "$EFFORT" --settings "$SETTINGS" --permission-mode bypassPermissions \
--strict-mcp-config --mcp-config "$BASE/mcp-lcu.json" \
--allowedTools "mcp__lcu__js" "mcp__lcu__js_reset" "Bash" "Read" "Write" "Edit" \
--output-format stream-json --verbose --include-partial-messages < /dev/null ) > "$L" 2> "$E" &
pid=$!; start=$(date +%s)
… 已截断(仅显示前 4 KB)思考
{
"file_path": "/Users/mengmeng/grok-cc-task/cc-live-monitor/assets.py",
"offset": 355,
"limit": 200
}355 });
356 }
357
358 window.__ccChat = { rebuild: rebuild };
359 rebuild();
360
361 // 打开页面停在最新一条
362 var sc = document.getElementById('scroller');
363 if (sc) sc.scrollTop = sc.scrollHeight;
364 })();
365 """
366
367 # 侧栏增量刷新:只换 .side-list / .side-foot,保留选中高亮与滚动位置
368 SIDEBAR_JS = """
369 (function () {
370 var URL_BASE = __SIDEBAR_URL__;
371 var POLL_MS = __POLL_MS__;
372 var list = document.getElementById('side-list');
373 if (!list || !window.fetch) return;
374 var foot = document.getElementById('side-foot');
375 var sig = list.getAttribute('data-sig') || '';
376 var currentLive = list.getAttribute('data-live') === '1';
377 var busy = false;
378
379 function apply(data) {
380 if (!data || !data.sig || data.sig === sig) return;
381 sig = data.sig;
382 var top = list.scrollTop;
383 list.innerHTML = data.items || '';
384 list.scrollTop = top;
385 if (foot && typeof data.foot === 'string') foot.innerHTML = data.foot;
386 var cur = data.current || {};
387 var live = !!cur.live;
388 if (live === currentLive) return;
389 currentLive = live;
390 // 详情页打开时它还是「未结束」,现在真的开始跑了 → 就地连上 SSE
391 if (live && window.__ccLive) window.__ccLive.start();
392 }
393
394 function tick() {
395 if (busy || document.hidden) return;
396 busy = true;
397 var ctl = window.AbortController ? new AbortController() : null;
398 var timer = setTimeout(function () {
399 if (ctl) ctl.abort();
400 busy = false;
401 }, 8000);
402 fetch(URL_BASE + '&_=' + Date.now(), {
403 cache: 'no-store',
404 signal: ctl ? ctl.signal : undefined
405 }).then(function (r) {
406 return r.ok ? r.json() : null;
407 }).then(function (data) {
408 clearTimeout(timer);
409 busy = false;
410 apply(data);
411 }).catch(function () {
412 clearTimeout(timer);
413 busy = false; // 网络抖一下不打扰用户,下一轮再来
414 });
415 }
416
417 document.addEventListener('visibilitychange', function () {
418 if (!document.hidden) tick();
419 });
420 setTimeout(tick, 800);
421 setInterval(tick, POLL_MS);
422 })();
423 """
424
425 LIVE_JS = """
426 (function () {
427 var LIVE_URL = "__LIVE_URL__";
428 var SINCE = __SINCE__;
429 var AUTO = __AUTO__;
430 var BOTTOM_PX = 110; // 距离底部多少像素内算「在底部」
431 var PROGRAMMATIC_MS = 700; // 程序滚动后多久内忽略 scroll 事件
432
433 var chat = document.getElementById('chat');
434 var scroller = document.getElementById('scroller');
435 var draftBox = document.getElementById('draft');
436 var note = document.getElementById('live-note');
437 var statEl = document.getElementById('live-stat');
438 var jumpBtn = document.getElementById('jump-latest');
439
440 var maxSeq = SINCE - 1;
441 var stopped = false;
442 var source = null;
443 var backoff = 1000;
444 var followOn = true;
445 var pending = 0;
446 var lastProgrammatic = 0;
447 var lastY = scroller ? scroller.scrollTop : 0;
448
449 function setNote(text, cls) {
450 if (!note) return;
451 note.textContent = text;
452 note.className = 'foot-note ' + (cls || '');
453 }
454 function atBottom() {
455 if (!scroller) return true;
456 return scroller.scrollHeight - scroller.scrollTop - scroller.clientHeight <= BOTTOM_PX;
457 }
458 function updateJump() {
459 if (!jumpBtn) return;
460 jumpBtn.hidden = followOn;
461 jumpBtn.textContent = pending > 0
462 ? '↓ 跳到最新(' + pending + ' 条新内容)'
463 : '↓ 跳到最新';
464 }
465 function scrollToLatest(smooth) {
466 if (!scroller) return;
467 lastProgrammatic = Date.now();
468 scroller.scrollTo({
469 top: scroller.scrollHeight,
470 behavior: smooth === false ? 'auto' : 'smooth'
471 });
472 }
473 function setFollow(on) {
474 followOn = on;
475 if (on) {
476 pending = 0;
477 scrollToLatest();
478 }
479 updateJump();
480 }
481 function follow() {
482 if (followOn) { scrollToLatest(); return; }
483 pending += 1;
484 updateJump();
485 }
… 已截断(仅显示前 4 KB){
"file_path": "/Users/mengmeng/grok-cc-task/cc-live-monitor/stream_parse.py",
"offset": 40,
"limit": 120
}40
41 @dataclass
42 class Event:
43 kind: str # user / assistant / thinking / tool / tool_result / system / result / incomplete / unknown
44 text: str = ""
45 thinking: str = "" # 仅 incomplete 用:中断前尚未落定的思考
46 label: str = "" # system / result / unknown 的短标题
47 tool_name: str = ""
48 tool_input: str = ""
49 tool_summary: str = ""
50 tool_result: str = ""
51 is_error: bool = False
52 tool_id: str = ""
53 speaker: str = "" # 气泡署名;空 = Claude,Codex 记录里写 Codex
54
55 @property
56 def is_collapsible(self) -> bool:
57 return self.kind in ("thinking", "tool", "tool_result", "unknown")
58
59
60 @dataclass
61 class Draft:
62 """正在流式输出、尚未落成正式消息的内容。"""
63
64 active: bool = False
65 text: str = ""
66 thinking: str = ""
67 tool_name: str = ""
68
69 @property
70 def is_empty(self) -> bool:
71 return not (self.text.strip() or self.thinking.strip() or self.tool_name.strip())
72
73
74 @dataclass
75 class Timeline:
76 meta: dict = field(default_factory=dict)
77 stats: dict = field(default_factory=dict)
78 events: list[Event] = field(default_factory=list)
79 error: str = ""
80
81 @property
82 def total(self) -> int:
83 return len(self.events)
84
85 def page(self, page: int, size: int = PAGE_SIZE) -> tuple[list[Event], int, int]:
86 """返回 (本页事件, 当前页, 总页数)。页码从 1 开始。"""
87 size = max(1, min(MAX_PAGE_SIZE, size))
88 pages = max(1, (self.total + size - 1) // size)
89 page = max(1, min(pages, page))
90 start = (page - 1) * size
91 return self.events[start:start + size], page, pages
92
93
94 @dataclass
95 class Poll:
96 """一次增量读取的结果。"""
97
98 appended: list[Event] = field(default_factory=list)
99 updated: list[int] = field(default_factory=list)
100 draft: Draft = field(default_factory=Draft)
101 draft_changed: bool = False
102 meta_changed: bool = False
103 reset: bool = False
104 grew: bool = False
105 error: str = ""
106
107
108 def _clip(text: str, limit: int) -> str:
109 if len(text) <= limit:
110 return text
111 hidden = len(text) - limit
112 return text[:limit] + f"\n…(已截断 {hidden} 字符)"
113
114
115 def _tail(text: str, limit: int = MAX_DRAFT) -> str:
116 return text if len(text) <= limit else "…" + text[-limit:]
117
118
119 def _blocks_text(content) -> str:
120 """把 tool_result 的 content(str 或 block 列表)拍平成文本。"""
121 if isinstance(content, str):
122 return content
123 if isinstance(content, list):
124 parts: list[str] = []
125 images = 0
126 for block in content:
127 if not isinstance(block, dict):
128 parts.append(str(block))
129 continue
130 btype = block.get("type")
131 if btype == "text":
132 parts.append(str(block.get("text", "")))
133 elif btype in ("image", "image_url"):
134 images += 1
135 else:
136 parts.append(f"[{btype}] " + json.dumps(block, ensure_ascii=False)[:500])
137 if images:
138 parts.append(f"(含 {images} 张图片,回放不展示)")
139 return "\n".join(p for p in parts if p)
140 if content is None:
141 return ""
142 return str(content)
143
144
145 def _json_text(value, limit: int) -> str:
146 if isinstance(value, str):
147 return _clip(value, limit)
148 try:
149 return _clip(json.dumps(value, ensure_ascii=False, indent=2), limit)
150 except (TypeError, ValueError):
151 return _clip(str(value), limit)
152
153
154 def _tool_summary(name: str, tool_input) -> str:
155 """折叠状态下显示的一行摘要。"""
156 if not isinstance(tool_input, dict):
157 return _json_text(tool_input, 120).replace("\n", " ")
158 for key in ("command", "file_path", "path", "pattern", "url", "prompt", "query", "description"):
159 value = tool_input.get(key){
"file_path": "/Users/mengmeng/grok-cc-task/cc-live-monitor/assets.py",
"offset": 554,
"limit": 180
}554 backoff = 1000;
555 setNote('实时跟随中', 'live-on');
556 });
557 source.addEventListener('append', function (ev) {
558 var data = JSON.parse(ev.data || '{}');
559 (data.events || []).forEach(function (item) { appendEvent(item.seq, item.html); });
560 follow();
561 });
562 source.addEventListener('update', function (ev) {
563 var data = JSON.parse(ev.data || '{}');
564 updateEvent(data.seq, data.html);
565 });
566 source.addEventListener('draft', function (ev) {
567 var data = JSON.parse(ev.data || '{}');
568 if (data.html) clearPlaceholder();
569 renderDraft(data.html || '');
570 if (data.html && followOn) follow();
571 });
572 source.addEventListener('meta', function (ev) {
573 var data = JSON.parse(ev.data || '{}');
574 if (data.note) setNote(data.note, 'live-on');
575 if (data.stats && statEl) statEl.textContent = data.stats;
576 });
577 source.addEventListener('closed', function (ev) {
578 stopped = true;
579 if (source) { source.close(); source = null; }
580 var data = JSON.parse(ev.data || '{}');
581 setNote('运行已结束' + (data.reason ? ' · ' + data.reason : ''), 'live-off');
582 if (data.incomplete_html && chat) {
583 clearPlaceholder();
584 var wrap = document.createElement('div');
585 wrap.className = 'ev ev-incomplete';
586 wrap.innerHTML = data.incomplete_html;
587 chat.insertBefore(wrap, draftBox || null);
588 regroup();
589 if (followOn) follow();
590 }
591 renderDraft('');
592 var st = document.querySelector('.top-meta .st.run');
593 if (st) {
594 var reason = data.reason || '已结束';
595 var tone = { '成功': 'on', '未完成': 'off', '出口未过': 'warn', '已中断': 'warn', '排队中': 'dim' };
596 st.className = 'st ' + (tone[reason] || 'dim');
597 st.innerHTML = '<span class="dot"></span>' + reason;
598 }
599 });
600 source.addEventListener('reset', function () {
601 stopped = true;
602 if (source) { source.close(); source = null; }
603 setNote('stream 文件被重写,请刷新页面', 'live-off');
604 });
605 source.onerror = function () {
606 if (stopped) return;
607 if (source) { source.close(); source = null; }
608 setNote('连接断开,' + Math.round(backoff / 1000) + ' 秒后重连…', 'live-off');
609 renderDraft('');
610 window.setTimeout(connect, backoff);
611 backoff = Math.min(backoff * 2, 10000);
612 };
613 }
614
615 // 侧栏轮询发现「开始跑了」时也会调它:清掉上一次的结束气泡,接着往下追
616 function start() {
617 if (source) return;
618 if (stopped) {
619 stopped = false;
620 backoff = 1000;
621 if (chat) {
622 Array.prototype.forEach.call(chat.querySelectorAll('.ev-incomplete'), function (w) {
623 if (w.parentNode) w.parentNode.removeChild(w);
624 });
625 }
626 renderDraft('');
627 }
628 hidePager();
629 markRunning();
630 setNote('已开始实时', 'live-on');
631 connect();
632 }
633
634 window.__ccLive = { start: start, isOn: function () { return !!source && !stopped; } };
635 if (AUTO) start();
636 })();
637 """
638
639{
"file_path": "/Users/mengmeng/grok-cc-task/cc-live-monitor/stream_parse.py",
"offset": 160,
"limit": 120
}160 if isinstance(value, str) and value.strip():
161 first = value.strip().splitlines()[0]
162 return first if len(first) <= 120 else first[:119] + "…"
163 return ""
164
165
166 class StreamReader:
167 """增量读取一个 stream jsonl;同一个实例可以持续 poll 文件增长。"""
168
169 def __init__(self, path: str, track_deltas: bool = False) -> None:
170 self.path = path
171 self.track_deltas = track_deltas
172 self.offset = 0
173 self._partial = b""
174 self._lock = threading.Lock()
175 self._dirty: set[int] = set()
176 self.meta: dict = {}
177 self.events: list[Event] = []
178 self.tools: dict[str, int] = {}
179 self.stats = {"lines": 0, "bad": 0, "skipped": 0, "parse_ms": 0}
180 self.draft = Draft()
181 self.error = ""
182
183 # ---------- 对外 ----------
184
185 def read_new(self) -> Poll:
186 with self._lock:
187 return self._read_locked()
188
189 def read_all(self) -> Timeline:
190 """读到底(历史回放用)。"""
191 started = time.time()
192 for _ in range(8):
193 poll = self.read_new()
194 if poll.error or not poll.grew:
195 break
196 return self.snapshot(started)
197
198 def events_from(self, since: int) -> list[tuple[int, Event]]:
199 with self._lock:
200 start = max(0, since)
201 return list(enumerate(self.events[start:], start))
202
203 def event_at(self, index: int) -> Event | None:
204 with self._lock:
205 if 0 <= index < len(self.events):
206 return self.events[index]
207 return None
208
209 def info(self) -> dict:
210 """轻量计数(不复制事件列表)。"""
211 with self._lock:
212 return {
213 "total": len(self.events),
214 "lines": self.stats.get("lines", 0),
215 "skipped": self.stats.get("skipped", 0),
216 "bad": self.stats.get("bad", 0),
217 }
218
219 def total(self) -> int:
220 with self._lock:
221 return len(self.events)
222
223 def snapshot(self, started: float | None = None) -> Timeline:
224 with self._lock:
225 stats = dict(self.stats)
226 if started is not None:
227 stats["parse_ms"] = int((time.time() - started) * 1000)
228 return Timeline(meta=dict(self.meta), stats=stats, events=list(self.events), error=self.error)
229
230 # ---------- 内部 ----------
231
232 def _reset(self) -> None:
233 self.offset = 0
234 self._partial = b""
235 self._dirty.clear()
236 self.meta = {}
237 self.events = []
238 self.tools = {}
239 self.stats = {"lines": 0, "bad": 0, "skipped": 0, "parse_ms": 0}
240 self.draft = Draft()
241
242 def _read_locked(self) -> Poll:
243 poll = Poll()
244 try:
245 size = os.path.getsize(self.path)
246 except OSError as exc:
247 self.error = f"无法读取 stream 文件:{exc}"
248 poll.error = self.error
249 return poll
250
251 if size < self.offset:
252 # 文件被截断或换了一份,从头读并通知调用方
253 self._reset()
254 poll.reset = True
255 if size == self.offset:
256 poll.draft = self.draft
257 return poll
258
259 try:
260 with open(self.path, "rb") as handle:
261 handle.seek(self.offset)
262 chunk = handle.read(size - self.offset)
263 except OSError as exc:
264 self.error = f"无法读取 stream 文件:{exc}"
265 poll.error = self.error
266 return poll
267
268 self.offset = size
269 poll.grew = True
270 first = len(self.events)
271 self._dirty.clear()
272
273 data = self._partial + chunk
274 lines = data.split(b"\n")
275 self._partial = lines.pop() # 最后半行(可能还没写完)留着
… 已截断(仅显示前 4 KB)思考
{
"file_path": "/Users/mengmeng/grok-cc-task/cc-live-monitor/stream_parse.py",
"offset": 585,
"limit": 120
}585
586
587 def tail_draft(path: str, window: int = INCOMPLETE_TAIL_BYTES) -> Draft | None:
588 """重建文件末尾「只有 stream_event、没有 assistant 全量消息」的那段输出。
589
590 运行被 kill 时最后一段流式文本只存在增量里;这里只回看文件尾部
591 (默认 768 KB),用与实时跟随完全相同的草稿逻辑重放一遍,
592 末尾草稿非空就说明这一次输出没有闭合。
593 """
594 try:
595 size = os.path.getsize(path)
596 if size <= 0:
597 return None
598 start = max(0, size - window)
599 with open(path, "rb") as handle:
600 handle.seek(start)
601 data = handle.read()
602 except OSError:
603 return None
604 if b'"stream_event"' not in data:
605 return None
606 lines = data.split(b"\n")
607 if start > 0:
608 lines = lines[1:] # 丢掉窗口起点处的半行
609 probe = StreamReader(path, track_deltas=True)
610 poll = Poll()
611 for raw in lines:
612 if raw.strip():
613 probe._ingest(raw, poll)
614 return None if probe.draft.is_empty else probe.draft
615
616
617 def parse_stream(path: str, incomplete_tail: bool = True) -> Timeline:
618 """一次性解析整个文件(永不抛异常)。
619
620 incomplete_tail=True 时,若文件末尾只有未闭合的流式增量,
621 追加一条 kind="incomplete" 的降级气泡,避免丢掉最后一段输出。
622 """
623 timeline = StreamReader(path).read_all()
624 if not incomplete_tail or timeline.error:
625 return timeline
626 draft = tail_draft(path)
627 if draft is None:
628 return timeline
629 event = Event(
630 kind="incomplete",
631 label="不完整输出",
632 text=draft.text,
633 thinking=draft.thinking,
634 tool_name=draft.tool_name,
635 )
636 stats = dict(timeline.stats)
637 stats["incomplete"] = 1
638 return Timeline(
639 meta=dict(timeline.meta),
640 stats=stats,
641 events=list(timeline.events) + [event],
642 error=timeline.error,
643 )
644
645
646 def load_timeline(path: str, incomplete_tail: bool = True) -> Timeline:
647 """带缓存地解析;文件大小/mtime 变了才重新解析。
648
649 incomplete_tail 参与缓存键:运行中的 run 只要正式事件,结束后再补末尾草稿。
650 """
651 try:
652 st = os.stat(path)
653 except OSError as exc:
654 return Timeline(error=f"无法读取 stream 文件:{exc}")
655
656 key = (path, incomplete_tail)
657 with _timeline_lock:
658 hit = _timeline_cache.get(key)
659 if hit and hit[0] == st.st_size and hit[1] == st.st_mtime:
660 return hit[2]
661
662 timeline = parse_stream(path, incomplete_tail=incomplete_tail)
663
664 with _timeline_lock:
665 if len(_timeline_cache) >= CACHE_MAX:
666 oldest = min(_timeline_cache, key=lambda k: _timeline_cache[k][1])
667 _timeline_cache.pop(oldest, None)
668 _timeline_cache[key] = (st.st_size, st.st_mtime, timeline)
669 return timeline
670
671
672 # ---------- 实时 tail 的句柄 ----------
673
674 _live_lock = threading.Lock()
675 _live: dict[str, StreamReader] = {}
676
677
678 def get_live(path: str) -> StreamReader:
679 """同一个文件的所有 SSE 连接共用一个增量读取器(事件序号因此稳定)。"""
680 with _live_lock:
681 reader = _live.get(path)
682 if reader is None:
683 if len(_live) >= LIVE_MAX:
684 _live.pop(next(iter(_live)), None)
685 reader = StreamReader(path, track_deltas=True)
686 _live[path] = reader
687 return reader
688
689
690 def drop_live(path: str) -> None:
691 with _live_lock:
692 _live.pop(path, None)
693{
"command": "grep -n \"^def \\|^class \\|^# \\[[0-9]\\|def test\\|assert \" selftest.py | head -120",
"description": "Outline selftest.py"
}27:def w(path: str, text: str) -> None: 33:def jsonl(path: str, records) -> None: 37:def build(root: str) -> tuple[str, str]: 172:def check(cond: bool, label: str) -> None: 178:def main() -> int: 407:def _replay_for(run: rd.RunInfo, timeline) -> views.Replay:
思考
{
"file_path": "/Users/mengmeng/grok-cc-task/cc-live-monitor/selftest.py"
}1 """Phase 6 自测:状态文案人话化、空目录过滤、工具名映射、错误摘要截断。
2
3 不碰真实 runs:全部 fixture 建在临时目录里。
4
5 python3 selftest.py # 跑断言
6 python3 selftest.py --keep # 保留 fixture 目录,方便起服务手测
7 """
8
9 from __future__ import annotations
10
11 import json
12 import os
13 import shutil
14 import sys
15 import tempfile
16 import time
17
18 import codex_parse
19 import md_lite
20 import runs_data as rd
21 import stream_parse as sp
22 import views
23
24 FIXTURES = []
25
26
27 def w(path: str, text: str) -> None:
28 os.makedirs(os.path.dirname(path), exist_ok=True)
29 with open(path, "w", encoding="utf-8") as f:
30 f.write(text)
31
32
33 def jsonl(path: str, records) -> None:
34 w(path, "\n".join(json.dumps(r, ensure_ascii=False) for r in records) + "\n")
35
36
37 def build(root: str) -> tuple[str, str]:
38 runs_dir = os.path.join(root, "runs")
39 lock_dir = os.path.join(root, "lock")
40
41 # 1) 运行中(pid 用当前进程,锁名匹配目录后缀)
42 live = os.path.join(runs_dir, "20261025-900001-live-demo")
43 w(os.path.join(live, "task.txt"), "目标:实时跟随测试\n第二行说明\n")
44 w(os.path.join(live, "runner.log"), "[09:00:00] attempt 1 model=test-model effort=high hard=600s idle=120s\n")
45 jsonl(os.path.join(live, "stream.jsonl"), [
46 {"type": "system", "subtype": "init", "model": "test-model", "session_id": "s-live"},
47 {"type": "assistant", "message": {"content": [{"type": "text", "text": "正在干活"}]}},
48 ])
49 w(os.path.join(lock_dir, "pid"), str(os.getpid()))
50 w(os.path.join(lock_dir, "name"), "live-demo")
51
52 # 2) 成功 + 3) 失败
53 ok = os.path.join(runs_dir, "20261025-900002-success-demo")
54 w(os.path.join(ok, "task.txt"), "目标:导出 Chrome 书签并合并\n")
55 w(os.path.join(ok, "runner.log"), "[09:01:00] attempt 1 model=m1 effort=low\n[09:02:00] done rc=0\n")
56 w(os.path.join(ok, "RESULT.md"), "完成\n")
57 jsonl(os.path.join(ok, "stream.jsonl"), [
58 {"type": "system", "subtype": "init", "model": "m1", "session_id": "s-ok"},
59 {"type": "user", "message": {"content": [{"type": "text",
60 "text": "[RUNNER RULES - follow strictly]\n- Egress check: 先看 cip.cc\n[TASK]\n"
61 "目标:导出 Chrome 书签并合并\n"}]}},
62 {"type": "assistant", "message": {"content": [
63 {"type": "thinking", "thinking": "先列一遍书签再合并。"}]}},
64 {"type": "assistant", "message": {"content": [
65 {"type": "text", "text": "## 结论\n\n**已合并** 24 条:\n\n- 个人收藏\n- 书签菜单\n\n"
66 "```bash\nls ~/Library/Safari\n```\n"}]}},
67 {"type": "assistant", "message": {"content": [
68 {"type": "tool_use", "id": "tu1", "name": "Bash",
69 "input": {"command": "ls ~/Library/Safari"}}]}},
70 {"type": "user", "message": {"content": [
71 {"type": "tool_result", "tool_use_id": "tu1", "content": "Bookmarks.plist"}]}},
72 ])
73
74 bad = os.path.join(runs_dir, "20261025-900003-failed-demo")
75 w(os.path.join(bad, "task.txt"), "目标:故意失败的任务\n")
76 w(os.path.join(bad, "runner.log"), "[09:03:00] attempt 1 model=m2\nexit rc=3 after 10s\n")
77
78 # 4) 被杀在流式中途:末尾只有 stream_event
79 trunc = os.path.join(runs_dir, "20261025-900004-truncated-demo")
80 w(os.path.join(trunc, "task.txt"), "目标:写到一半被 kill\n")
81 w(os.path.join(trunc, "runner.log"), "[09:04:00] attempt 1 model=m3\nexit rc=137 after 20s\n")
82 jsonl(os.path.join(trunc, "stream.jsonl"), [
83 {"type": "assistant", "message": {"content": [{"type": "text", "text": "已经写完的一段"}]}},
84 {"type": "stream_event", "event": {"type": "message_start", "message": {}}},
85 {"type": "stream_event", "event": {"type": "content_block_delta",
86 "delta": {"type": "text_delta", "text": "最后一段没写完的话"}}},
87 ])
88
89 # 5) Codex rollout 形态
90 codex = os.path.join(runs_dir, "20261025-900005-codex-demo")
91 w(os.path.join(codex, "task.txt"), "目标:修构建\n")
… 已截断(仅显示前 4 KB)思考
{
"file_path": "/Users/mengmeng/grok-cc-task/cc-live-monitor/stream_parse.py",
"offset": 380,
"limit": 60
}380 label=f"system · {subtype or '未知'}",
381 text=_clip(json.dumps(rec, ensure_ascii=False), 600),
382 )
383 )
384
385 def _parse_assistant(self, rec: dict, poll: Poll) -> None:
386 # 全量消息落定,流式草稿作废
387 if not self.draft.is_empty or self.draft.active:
388 self.draft = Draft()
389 poll.draft_changed = True
390
391 message = rec.get("message") or {}
392 content = message.get("content")
393 if isinstance(content, str):
394 self._emit(Event(kind="assistant", text=_clip(content, MAX_TEXT)))
395 return
396 if not isinstance(content, list):
397 return
398 for block in content:
399 if not isinstance(block, dict):
400 self._emit(Event(kind="unknown", label="未知内容块", text=_clip(str(block), MAX_UNKNOWN)))
401 continue
402 btype = block.get("type")
403 if btype == "text":
404 text = str(block.get("text", ""))
405 if text.strip():
406 self._emit(Event(kind="assistant", text=_clip(text, MAX_TEXT)))
407 elif btype == "thinking":
408 text = str(block.get("thinking") or block.get("text") or "")
409 if text.strip():
410 self._emit(Event(kind="thinking", text=_clip(text, MAX_THINKING)))
411 elif btype == "tool_use":
412 name = str(block.get("name") or "tool")
413 tool_input = block.get("input")
414 tool_id = str(block.get("id") or "")
415 self._emit(
416 Event(
417 kind="tool",
418 tool_name=name,
419 tool_input=_json_text(tool_input, MAX_TOOL_INPUT),
420 tool_summary=_tool_summary(name, tool_input),
421 tool_id=tool_id,
422 )
423 )
424 if tool_id:
425 self.tools[tool_id] = len(self.events) - 1
426 elif btype == "redacted_thinking":
427 self._emit(Event(kind="thinking", text="(该段思考被加密,回放不可读)"))
428 else:
429 self._emit(
430 Event(
431 kind="unknown",
432 label=f"assistant · {btype}",
433 text=_clip(json.dumps(block, ensure_ascii=False), MAX_UNKNOWN),
434 )
435 )
436
437 def _parse_user(self, rec: dict) -> None:
438 message = rec.get("message") or {}
439 content = message.get("content"){
"command": "grep -n \"Timeline(\" codex_parse.py | head; grep -rn \"timeline.events\\[-1\\]\\|\\.total\\b\" --include=*.py . | grep -v selftest | head -20",
"description": "Check Timeline construction and total usage"
}457: return Timeline(error=f"无法读取 Codex 记录:{exc}")
468: return Timeline(meta=parser.meta, stats=parser.stats, events=parser.events)
475: return Timeline(error=f"无法读取 Codex 记录:{exc}")
(eval):1: no matches found: --include=*.py思考
{
"command": "grep -rn \"\\.total\\b\\|events\\[-1\\]\" *.py | grep -v \"^selftest.py\"",
"description": "Find total usages"
}stream_parse.py:88: pages = max(1, (self.total + size - 1) // size)
server.py:197: pages=max(1, (timeline.total + size - 1) // size),
views.py:479: if timeline and timeline.total:
views.py:480: meta.append(f'<span class="sep">·</span><span>{_num(timeline.total)} 条事件</span>')
views.py:574: parts.append(f"<span>共 {_num(replay.timeline.total)} 条</span>")
views.py:652: f'<span id="live-stat" class="foot-stat">共 {_num(replay.timeline.total)} 条事件'{
"command": "sed -n 1,40p stream_parse.py && grep -n \"CACHE_MAX\\|MAX_PAGE_SIZE\\|PAGE_SIZE\" stream_parse.py | head",
"description": "Read stream_parse header"
}"""把 Claude Code 的 stream-json(stream.*.jsonl)解析成可回放的时间线,并支持增量 tail。
设计要点(均来自真实样本抽查):
- `stream_event` 与后到的 `assistant` 全量消息内容重复,历史回放只保留后者。
实时模式额外用 `stream_event` 的增量拼一个「正在输出」草稿(draft),
但**不**把它写进正式事件,保证历史回放和实时 tail 的事件序号完全一致。
- `system/thinking_tokens`、`system/status` 是高频噪音(单次运行上万条),直接跳过。
- `assistant.message.content` 里是 thinking / text / tool_use 块;
`user.message.content` 里是 tool_result(按 tool_use_id 挂回对应的工具卡)。
- 未知 type / 未知块一律降级成 unknown 事件,解析器不允许抛异常。
- 只按完整行(以 \\n 结尾)解析:文件正在写入时,最后半行留在缓冲区等下一次。
只读:不修改 runs 目录下任何文件。
"""
from __future__ import annotations
import json
import os
import threading
import time
from dataclasses import dataclass, field
MAX_TEXT = 20_000
MAX_THINKING = 20_000
MAX_TOOL_INPUT = 6_000
MAX_TOOL_RESULT = 8_000
MAX_UNKNOWN = 2_000
MAX_DRAFT = 6_000
PAGE_SIZE = 50
MAX_PAGE_SIZE = 300
CACHE_MAX = 8
LIVE_MAX = 8
INCOMPLETE_TAIL_BYTES = 768 * 1024
NOISE_SUBTYPES = ("thinking_tokens", "status")
32:PAGE_SIZE = 50
33:MAX_PAGE_SIZE = 300
34:CACHE_MAX = 8
85: def page(self, page: int, size: int = PAGE_SIZE) -> tuple[list[Event], int, int]:
87: size = max(1, min(MAX_PAGE_SIZE, size))
665: if len(_timeline_cache) >= CACHE_MAX:思考
现在开始改代码。先修 P1-1(Markdown 属性注入):
{
"replace_all": false,
"file_path": "/Users/mengmeng/grok-cc-task/cc-live-monitor/md_lite.py",
"old_string": "LINK = re.compile(r\"\\[([^\\]\\n]+)\\]\\((https?://[^\\s)]+)\\)\")\n\n\ndef _esc(text: str) -> str:\n return html.escape(text, quote=False)",
"new_string": "LINK = re.compile(r\"\\[([^\\]\\n]+)\\]\\((https?://[^\\s)]+)\\)\")\nCTRL = re.compile(r\"[\\x00-\\x1f\\x7f]\")\n\n\ndef _esc(text: str) -> str:\n \"\"\"文本节点转义:< > & 处理掉,引号留着(文本里不需要)。\"\"\"\n return html.escape(text, quote=False)\n\n\ndef _attr(value: str) -> str:\n \"\"\"已按文本规则转义过的值 → 可直接放进双引号属性的值。\n\n 文本先走 _esc(quote=False),引号还留在里面;URL 直接拼进 href 会被 `\"`\n 提前闭合属性(例如 `[x](https://e.com/\"onmouseover=...)` 会变成真的\n onmouseover 事件)。这里先反转义一次再按属性规则转义:既补上引号,\n 又不会把已有的 `&` 二次转义成 `&amp;`。\n \"\"\"\n plain = CTRL.sub(\"\", html.unescape(value))\n return html.escape(plain, quote=True)"
}The file /Users/mengmeng/grok-cc-task/cc-live-monitor/md_lite.py has been updated successfully. (file state is current in your context — no need to Read it back)
{
"replace_all": false,
"file_path": "/Users/mengmeng/grok-cc-task/cc-live-monitor/md_lite.py",
"old_string": " text = LINK.sub(\n lambda m: keep(\n f'<a href=\"{m.group(2)}\" target=\"_blank\" rel=\"noreferrer\">{m.group(1)}</a>'\n ),\n text,\n )",
"new_string": " text = LINK.sub(\n lambda m: keep(\n f'<a href=\"{_attr(m.group(2))}\" target=\"_blank\" rel=\"noreferrer\">{m.group(1)}</a>'\n ),\n text,\n )"
}The file /Users/mengmeng/grok-cc-task/cc-live-monitor/md_lite.py has been updated successfully. (file state is current in your context — no need to Read it back)
思考
现在改 runs_data.py(P1-2 符号链接、P1-3 锁精确匹配、P1-5 跟随源):
{
"replace_all": false,
"file_path": "/Users/mengmeng/grok-cc-task/cc-live-monitor/runs_data.py",
"old_string": "import os\nimport re\nimport threading\nimport time\nfrom collections import OrderedDict\nfrom dataclasses import dataclass, field",
"new_string": "import os\nimport re\nimport stat\nimport threading\nimport time\nfrom collections import OrderedDict\nfrom dataclasses import dataclass, field"
}The file /Users/mengmeng/grok-cc-task/cc-live-monitor/runs_data.py has been updated successfully. (file state is current in your context — no need to Read it back)
{
"replace_all": false,
"file_path": "/Users/mengmeng/grok-cc-task/cc-live-monitor/runs_data.py",
"old_string": "STATE_UNFINISHED = \"unfinished\"\nSTATE_UNKNOWN = \"unknown\"\n\n\n@dataclass\nclass FileInfo:\n name: str\n size: int\n mtime: float",
"new_string": "STATE_UNFINISHED = \"unfinished\"\nSTATE_UNKNOWN = \"unknown\"\n\n# 已经收尾的状态:旧式短名锁绝不能把它们重新点亮\nTERMINAL_STATES = (STATE_SUCCESS, STATE_FAILED, STATE_BLOCKED, STATE_INTERRUPTED)\n\n\n@dataclass\nclass FileInfo:\n name: str\n size: int\n mtime: float\n ino: int = 0 # 跟随源的代号用:追加写不变,换文件 / 重写才变"
}The file /Users/mengmeng/grok-cc-task/cc-live-monitor/runs_data.py has been updated successfully. (file state is current in your context — no need to Read it back)
{
"replace_all": false,
"file_path": "/Users/mengmeng/grok-cc-task/cc-live-monitor/runs_data.py",
"old_string": "def _read_head_tail(path: str, head: int = 4096, tail: int = 8192) -> str:\n \"\"\"只读文件头尾,避免把几十 MB 的 stream 读进来。\"\"\"\n try:\n size = os.path.getsize(path)\n with open(path, \"rb\") as f:",
"new_string": "# ---------- 只读安全:真实路径必须落在 runs 根目录内,且不跟符号链接 ----------\n\ndef _within(root: str, target: str) -> bool:\n \"\"\"target 是否等于 root 或落在 root 里面(两边都必须是 realpath)。\"\"\"\n try:\n return os.path.commonpath([root, target]) == root\n except ValueError: # 不同盘 / 空路径\n return False\n\n\ndef _is_regular(path: str) -> bool:\n \"\"\"普通文件才放行;符号链接在 lstat 里是 S_IFLNK,直接排除。\"\"\"\n try:\n st = os.lstat(path)\n except OSError:\n return False\n return stat.S_ISREG(st.st_mode)\n\n\ndef open_nofollow(path: str):\n \"\"\"以 O_NOFOLLOW 打开:检查通过后路径被换成符号链接也读不到目标。\"\"\"\n flags = os.O_RDONLY | getattr(os, \"O_NOFOLLOW\", 0)\n return os.fdopen(os.open(path, flags), \"rb\")\n\n\ndef _read_head_tail(path: str, head: int = 4096, tail: int = 8192) -> str:\n \"\"\"只读文件头尾,避免把几十 MB 的 stream 读进来。\"\"\"\n try:\n size = os.path.getsize(path)\n with open_nofollow(path) as f:"
}The file /Users/mengmeng/grok-cc-task/cc-live-monitor/runs_data.py has been updated successfully. (file state is current in your context — no need to Read it back)
{
"replace_all": false,
"file_path": "/Users/mengmeng/grok-cc-task/cc-live-monitor/runs_data.py",
"old_string": "def read_text(path: str, limit: int = MAX_PREVIEW_BYTES) -> tuple[str, int, bool]:\n \"\"\"读取文本文件,返回 (内容, 总字节数, 是否被截断)。\"\"\"\n total = os.path.getsize(path)\n try:\n with open(path, \"rb\") as f:\n data = f.read(limit)\n except OSError as exc:\n return f\"读取失败:{exc}\", total, False",
"new_string": "def read_text(path: str, limit: int = MAX_PREVIEW_BYTES) -> tuple[str, int, bool]:\n \"\"\"读取文本文件,返回 (内容, 总字节数, 是否被截断)。不跟符号链接。\"\"\"\n try:\n total = os.path.getsize(path)\n except OSError as exc:\n return f\"读取失败:{exc}\", 0, False\n try:\n with open_nofollow(path) as f:\n data = f.read(limit)\n except OSError as exc:\n return f\"读取失败:{exc}\", total, False"
}The file /Users/mengmeng/grok-cc-task/cc-live-monitor/runs_data.py has been updated successfully. (file state is current in your context — no need to Read it back)
{
"replace_all": false,
"file_path": "/Users/mengmeng/grok-cc-task/cc-live-monitor/runs_data.py",
"old_string": " try:\n with open(task_path, \"rb\") as f:\n data = f.read(limit)\n except OSError:\n return \"\"",
"new_string": " try:\n with open_nofollow(task_path) as f:\n data = f.read(limit)\n except OSError:\n return \"\""
}The file /Users/mengmeng/grok-cc-task/cc-live-monitor/runs_data.py has been updated successfully. (file state is current in your context — no need to Read it back)
{
"replace_all": false,
"file_path": "/Users/mengmeng/grok-cc-task/cc-live-monitor/runs_data.py",
"old_string": " try:\n st = entry.stat()\n except OSError:\n continue\n out.append(FileInfo(entry.name, st.st_size, st.st_mtime))",
"new_string": " try:\n st = entry.stat()\n except OSError:\n continue\n out.append(FileInfo(entry.name, st.st_size, st.st_mtime, st.st_ino))"
}The file /Users/mengmeng/grok-cc-task/cc-live-monitor/runs_data.py has been updated successfully. (file state is current in your context — no need to Read it back)
思考
{
"replace_all": false,
"file_path": "/Users/mengmeng/grok-cc-task/cc-live-monitor/runs_data.py",
"old_string": "def build_run(name: str, run_path: str, lock_pid: int, lock_name: str) -> RunInfo:",
"new_string": "def _list_run_dirs(runs_dir: str) -> list[str]:\n try:\n with os.scandir(runs_dir) as it:\n return [e.name for e in it if e.is_dir(follow_symlinks=False)]\n except OSError:\n return []\n\n\ndef resolve_lock_run(\n runs_dir: str = DEFAULT_RUNS_DIR, lock_pid: int = 0, lock_name: str = \"\"\n) -> str:\n \"\"\"锁里的名字 → 具体 run 目录名。没有命中返回 \"\"。\n\n 新锁(run.sh 现在写的)是完整目录名,必须精确相等;\n 旧锁只有短 name,允许后缀匹配,但最多认一个**未终结**的候选(目录名时间戳最新),\n 已成功 / 已失败 / 出口未过 / 已中断的历史同名会话绝不会被点亮。\n \"\"\"\n if not lock_pid or not lock_name or not _pid_alive(lock_pid):\n return \"\"\n names = _list_run_dirs(runs_dir)\n if lock_name in names:\n return lock_name # 精确匹配:run 名就是目录名\n legacy = sorted((n for n in names if n.endswith(\"-\" + lock_name)), reverse=True)\n for name in legacy:\n try:\n info = build_run(name, os.path.join(runs_dir, name), 0, \"\")\n except OSError:\n continue\n if info.state not in TERMINAL_STATES:\n return name\n return \"\"\n\n\ndef build_run(name: str, run_path: str, lock_pid: int = 0, lock_run: str = \"\") -> RunInfo:"
}The file /Users/mengmeng/grok-cc-task/cc-live-monitor/runs_data.py has been updated successfully. (file state is current in your context — no need to Read it back)
{
"replace_all": false,
"file_path": "/Users/mengmeng/grok-cc-task/cc-live-monitor/runs_data.py",
"old_string": " # 正在跑:锁里的 run 名是本目录名的后缀,且进程还活着\n if lock_pid and lock_name and _pid_alive(lock_pid) and name.endswith(\"-\" + lock_name):",
"new_string": " # 正在跑:锁已解析到本目录名(精确匹配;旧短名由 resolve_lock_run 收敛成一个候选)\n if lock_pid and lock_run and _pid_alive(lock_pid) and name == lock_run:"
}The file /Users/mengmeng/grok-cc-task/cc-live-monitor/runs_data.py has been updated successfully. (file state is current in your context — no need to Read it back)
思考
{
"replace_all": false,
"file_path": "/Users/mengmeng/grok-cc-task/cc-live-monitor/runs_data.py",
"old_string": "PIN_ORDER = {\"running\": 0, \"failed\": 1}",
"new_string": "def follow_stream(run: RunInfo) -> str:\n \"\"\"当前该跟随的 Claude stream 文件。\n\n 运行中取**最近写入**的那份:重试会另开 stream.2.jsonl,按大小挑会一直跟着\n 已经停写的旧 attempt。已结束则取内容最多的那份(回放看产出最全的尝试)。\n \"\"\"\n if not run.stream_files:\n return \"\"\n sizes = {f.name: f.size for f in run.files}\n if run.live:\n mtimes = {f.name: f.mtime for f in run.files}\n return max(run.stream_files, key=lambda n: (mtimes.get(n, 0.0), sizes.get(n, 0), n))\n return max(run.stream_files, key=lambda n: (sizes.get(n, 0), n))\n\n\ndef stream_generation(run: RunInfo, stream_name: str) -> str:\n \"\"\"跟随源代号:文件名 + inode。追加写不变,换文件 / 重写才变。\"\"\"\n if not stream_name:\n return \"\"\n for f in run.files:\n if f.name == stream_name:\n return f\"{stream_name}:{f.ino}\"\n return stream_name\n\n\nPIN_ORDER = {\"running\": 0, \"failed\": 1}"
}The file /Users/mengmeng/grok-cc-task/cc-live-monitor/runs_data.py has been updated successfully. (file state is current in your context — no need to Read it back)
{
"replace_all": false,
"file_path": "/Users/mengmeng/grok-cc-task/cc-live-monitor/runs_data.py",
"old_string": " entries.sort(key=lambda e: e.stat().st_mtime, reverse=True)\n lock_pid, lock_name = load_lock()\n runs = []\n for entry in entries:\n try:\n info = build_run(entry.name, entry.path, lock_pid, lock_name)",
"new_string": " entries.sort(key=lambda e: e.stat().st_mtime, reverse=True)\n lock_pid, lock_name = load_lock()\n lock_run = resolve_lock_run(runs_dir, lock_pid, lock_name)\n runs = []\n for entry in entries:\n try:\n info = build_run(entry.name, entry.path, lock_pid, lock_run)"
}The file /Users/mengmeng/grok-cc-task/cc-live-monitor/runs_data.py has been updated successfully. (file state is current in your context — no need to Read it back)
{
"replace_all": false,
"file_path": "/Users/mengmeng/grok-cc-task/cc-live-monitor/runs_data.py",
"old_string": "def find_run(name: str, runs_dir: str = DEFAULT_RUNS_DIR) -> RunInfo | None:\n if not name or \"/\" in name or name.startswith(\".\"):\n return None\n path = os.path.join(runs_dir, name)\n if not os.path.isdir(path):\n return None\n lock_pid, lock_name = load_lock()\n try:\n return build_run(name, path, lock_pid, lock_name)\n except OSError:\n return None\n\n\ndef safe_file(run: RunInfo, filename: str) -> str | None:\n \"\"\"只允许访问该 run 目录下的一级普通文件。\"\"\"\n if not filename or \"/\" in filename or \"\\\\\" in filename or filename.startswith(\".\"):\n return None\n if filename == \"..\":\n return None\n target = os.path.normpath(os.path.join(run.path, filename))\n if os.path.dirname(target) != os.path.normpath(run.path):\n return None\n if not os.path.isfile(target):\n return None\n return target",
"new_string": "def find_run(name: str, runs_dir: str = DEFAULT_RUNS_DIR) -> RunInfo | None:\n if not name or \"/\" in name or \"\\\\\" in name or \"\\x00\" in name or name.startswith(\".\"):\n return None\n root = os.path.realpath(runs_dir)\n path = os.path.realpath(os.path.join(root, name))\n # 链接到根目录外的 run 目录一律不认(即使目录名看起来正常)\n if path == root or not _within(root, path):\n return None\n if not os.path.isdir(path):\n return None\n lock_pid, lock_name = load_lock()\n lock_run = resolve_lock_run(runs_dir, lock_pid, lock_name)\n try:\n return build_run(name, path, lock_pid, lock_run)\n except OSError:\n return None\n\n\ndef safe_file(run: RunInfo, filename: str) -> str | None:\n \"\"\"只允许访问该 run 目录下的一级普通文件;符号链接一律拒绝。\"\"\"\n if (\n not filename\n or \"/\" in filename\n or \"\\\\\" in filename\n or \"\\x00\" in filename\n or filename in (\".\", \"..\")\n or filename.startswith(\".\")\n ):\n return None\n root = os.path.realpath(run.path)\n target = os.path.normpath(os.path.join(root, filename))\n if os.path.dirname(target) != root:\n return None\n # 真实路径仍须在 run 目录里,且文件本身不能是链接\n if not _within(root, os.path.realpath(target)):\n return None\n if not _is_regular(target):\n return None\n return target"
}The file /Users/mengmeng/grok-cc-task/cc-live-monitor/runs_data.py has been updated successfully. (file state is current in your context — no need to Read it back)
思考
{
"command": "grep -n \"open(\" stream_parse.py codex_parse.py",
"description": "Find file opens in parsers"
}codex_parse.py:171: with open(path, "rb") as handle: codex_parse.py:424: with open(path, "rb") as handle: codex_parse.py:437: with open(path, "rb") as handle: stream_parse.py:260: with open(self.path, "rb") as handle: stream_parse.py:599: with open(path, "rb") as handle:
思考
{
"command": "sed -n 1,30p codex_parse.py",
"description": "Read codex_parse imports"
}"""Codex CLI 会话记录(agent-*.jsonl / events.jsonl / rollout-*.jsonl)→ 同一套 Timeline。
Codex 没有官方稳定的 stream-json 契约,这里按已知的几种记录形态做尽力还原:
- rollout 形态:`{"timestamp": ..., "type": "session_meta|turn_context|response_item|event_msg|compacted", "payload": {...}}`
- 旧版 exec 形态:`{"id": ..., "msg": {"type": "agent_message|exec_command_begin|..."}}`
- 兜底:未知记录降级成可折叠原文;完全认不出时给一段说明 + 文件头原文。
原则与 stream_parse 相同:永不抛异常,坏行只计数,只读。
"""
from __future__ import annotations
import json
import os
import re
import threading
import time
from collections import OrderedDict
from stream_parse import Event, Timeline
MAX_TEXT = 20_000
MAX_THINKING = 20_000
MAX_TOOL_INPUT = 6_000
MAX_TOOL_RESULT = 8_000
MAX_UNKNOWN = 1_200
MAX_UNKNOWN_EVENTS = 300
MAX_LINE_BYTES = 16_000_000
CACHE_MAX = 4思考
{
"replace_all": false,
"file_path": "/Users/mengmeng/grok-cc-task/cc-live-monitor/stream_parse.py",
"old_string": "def _clip(text: str, limit: int) -> str:",
"new_string": "def open_nofollow(path: str):\n \"\"\"以 O_NOFOLLOW 打开:路径校验后被换成符号链接也读不到目标。\"\"\"\n return os.fdopen(os.open(path, os.O_RDONLY | getattr(os, \"O_NOFOLLOW\", 0)), \"rb\")\n\n\ndef _clip(text: str, limit: int) -> str:"
}The file /Users/mengmeng/grok-cc-task/cc-live-monitor/stream_parse.py has been updated successfully.
{
"replace_all": false,
"file_path": "/Users/mengmeng/grok-cc-task/cc-live-monitor/stream_parse.py",
"old_string": " events: list[Event] = field(default_factory=list)\n error: str = \"\"",
"new_string": " events: list[Event] = field(default_factory=list)\n error: str = \"\"\n # 合成的「不完整」气泡:只是给页面看的尾巴,**不占**真实事件序号,\n # 否则 SSE 恢复游标会多 1 条,接着追的时候漏掉下一条真实事件。\n tail: Event | None = None"
}The file /Users/mengmeng/grok-cc-task/cc-live-monitor/stream_parse.py has been updated successfully.
{
"replace_all": false,
"file_path": "/Users/mengmeng/grok-cc-task/cc-live-monitor/stream_parse.py",
"old_string": " try:\n with open(self.path, \"rb\") as handle:\n handle.seek(self.offset)",
"new_string": " try:\n with open_nofollow(self.path) as handle:\n handle.seek(self.offset)"
}The file /Users/mengmeng/grok-cc-task/cc-live-monitor/stream_parse.py has been updated successfully.
{
"replace_all": false,
"file_path": "/Users/mengmeng/grok-cc-task/cc-live-monitor/stream_parse.py",
"old_string": " start = max(0, size - window)\n with open(path, \"rb\") as handle:\n handle.seek(start)\n data = handle.read()",
"new_string": " start = max(0, size - window)\n with open_nofollow(path) as handle:\n handle.seek(start)\n data = handle.read()"
}The file /Users/mengmeng/grok-cc-task/cc-live-monitor/stream_parse.py has been updated successfully.
{
"replace_all": false,
"file_path": "/Users/mengmeng/grok-cc-task/cc-live-monitor/stream_parse.py",
"old_string": " stats = dict(timeline.stats)\n stats[\"incomplete\"] = 1\n return Timeline(\n meta=dict(timeline.meta),\n stats=stats,\n events=list(timeline.events) + [event],\n error=timeline.error,\n )",
"new_string": " stats = dict(timeline.stats)\n stats[\"incomplete\"] = 1\n return Timeline(\n meta=dict(timeline.meta),\n stats=stats,\n events=list(timeline.events),\n error=timeline.error,\n tail=event, # 不塞进 events:合成气泡不能占真实事件序号\n )"
}The file /Users/mengmeng/grok-cc-task/cc-live-monitor/stream_parse.py has been updated successfully.
思考
{
"command": "sed -n 165,178p codex_parse.py && echo --- && sed -n 418,442p codex_parse.py",
"description": "Inspect codex open sites"
}return bool(CODEX_NAME_RE.search(name))
def sniff(path: str) -> bool:
"""只看开头几行,判断是不是 Codex 记录。认不出就返回 False。"""
try:
with open(path, "rb") as handle:
data = handle.read(8192)
except OSError:
return False
for raw in data.split(b"\n")[:6]:
raw = raw.strip()
if not raw:
continue
---
]
self._attach(call_id, "\n".join(p for p in pieces if p), _output_is_error(payload))
def _sample_head(path: str) -> str:
try:
with open(path, "rb") as handle:
data = handle.read(4096)
except OSError:
return ""
lines = [ln for ln in data.decode("utf-8", errors="replace").splitlines() if ln.strip()]
return "\n".join(lines[:3])
def parse_codex(path: str) -> Timeline:
"""解析一个 Codex 记录文件,永不抛异常。"""
started = time.time()
parser = _Parser()
try:
with open(path, "rb") as handle:
for raw in handle:
raw = raw.strip()
if not raw:
continue
parser.stats["lines"] += 1{
"command": "python3 - <<'PY'\nimport re\np='codex_parse.py'\ns=open(p).read()\ns=s.replace(\"from stream_parse import Event, Timeline\", \"from stream_parse import Event, Timeline, open_nofollow\")\ns=s.replace('with open(path, \"rb\") as handle:', 'with open_nofollow(path) as handle:')\nopen(p,'w').write(s)\nprint(s.count('open_nofollow(path)'))\nPY",
"description": "Use O_NOFOLLOW in codex parser"
}3
思考
{
"replace_all": false,
"file_path": "/Users/mengmeng/grok-cc-task/cc-live-monitor/runs_data.py",
"old_string": "import codex_parse\n\nDEFAULT_RUNS_DIR",
"new_string": "import codex_parse\nfrom stream_parse import open_nofollow\n\nDEFAULT_RUNS_DIR"
}The file /Users/mengmeng/grok-cc-task/cc-live-monitor/runs_data.py has been updated successfully. (file state is current in your context — no need to Read it back)
{
"replace_all": false,
"file_path": "/Users/mengmeng/grok-cc-task/cc-live-monitor/runs_data.py",
"old_string": "def open_nofollow(path: str):\n \"\"\"以 O_NOFOLLOW 打开:检查通过后路径被换成符号链接也读不到目标。\"\"\"\n flags = os.O_RDONLY | getattr(os, \"O_NOFOLLOW\", 0)\n return os.fdopen(os.open(path, flags), \"rb\")\n\n\n",
"new_string": ""
}The file /Users/mengmeng/grok-cc-task/cc-live-monitor/runs_data.py has been updated successfully. (file state is current in your context — no need to Read it back)
思考
{
"replace_all": false,
"file_path": "/Users/mengmeng/grok-cc-task/cc-live-monitor/views.py",
"old_string": "def _chat_url(run: RunInfo, replay: Replay, page: int) -> str:",
"new_string": "def live_url(run: RunInfo, stream_name: str) -> str:\n \"\"\"SSE 地址;stream_name 为空时返回空串(还没有可跟随的源)。\"\"\"\n if not stream_name:\n return \"\"\n return _run_url(run, \"/live\") + \"?\" + urllib.parse.urlencode({\"stream\": stream_name})\n\n\ndef follow_generation(run: RunInfo | None) -> str:\n \"\"\"侧栏签名 / 页面注入共用的「跟随源代号」;没有可跟随的 stream 时为空。\"\"\"\n if run is None or not run.live or not run.stream_files:\n return \"\"\n return rd.stream_generation(run, rd.follow_stream(run))\n\n\ndef _chat_url(run: RunInfo, replay: Replay, page: int) -> str:"
}The file /Users/mengmeng/grok-cc-task/cc-live-monitor/views.py has been updated successfully. (file state is current in your context — no need to Read it back)
{
"replace_all": false,
"file_path": "/Users/mengmeng/grok-cc-task/cc-live-monitor/views.py",
"old_string": "def sidebar_sig(items_html: str, foot_html: str, live: bool) -> str:\n \"\"\"只在内容真变了才替换 DOM,避免每 4.5 秒重排一次。\"\"\"\n raw = f\"{items_html}\\n{foot_html}\\n{1 if live else 0}\"\n return hashlib.md5(raw.encode(\"utf-8\")).hexdigest()[:12]",
"new_string": "def sidebar_sig(items_html: str, foot_html: str, live: bool, generation: str = \"\") -> str:\n \"\"\"只在内容真变了才替换 DOM,避免每 4.5 秒重排一次。\n\n generation 也要进签名:重试换了 stream 文件时条目 HTML 可能一模一样,\n 不带上的话前端就永远收不到「换源」通知。\n \"\"\"\n raw = f\"{items_html}\\n{foot_html}\\n{1 if live else 0}\\n{generation}\"\n return hashlib.md5(raw.encode(\"utf-8\")).hexdigest()[:12]"
}The file /Users/mengmeng/grok-cc-task/cc-live-monitor/views.py has been updated successfully. (file state is current in your context — no need to Read it back)
{
"replace_all": false,
"file_path": "/Users/mengmeng/grok-cc-task/cc-live-monitor/views.py",
"old_string": " body = sidebar_items(runs, current_name, current, query)\n foot = sidebar_foot(matched, len(runs))\n sig = sidebar_sig(body, foot, bool(current and current.live))",
"new_string": " body = sidebar_items(runs, current_name, current, query)\n foot = sidebar_foot(matched, len(runs))\n sig = sidebar_sig(body, foot, bool(current and current.live), follow_generation(current))"
}The file /Users/mengmeng/grok-cc-task/cc-live-monitor/views.py has been updated successfully. (file state is current in your context — no need to Read it back)
{
"replace_all": false,
"file_path": "/Users/mengmeng/grok-cc-task/cc-live-monitor/views.py",
"old_string": " events: list[Event] = []\n page, pages = replay.page, replay.pages\n if replay.timeline is not None:\n events, page, pages = replay.timeline.page(replay.page, replay.size)\n replay.page, replay.pages = page, pages\n\n first_seq = (page - 1) * replay.size\n for i, ev in enumerate(events):\n inner = render_event(ev)\n if not inner:\n continue\n parts.append(f'<div class=\"ev\" data-seq=\"{first_seq + i}\">{inner}</div>')\n\n if not events:",
"new_string": " events: list[Event] = []\n tail: Event | None = None\n page, pages = replay.page, replay.pages\n if replay.timeline is not None:\n events, page, pages = replay.timeline.page(replay.page, replay.size)\n replay.page, replay.pages = page, pages\n tail = replay.timeline.tail # 合成的「不完整」气泡,只在末页出现\n\n first_seq = (page - 1) * replay.size\n for i, ev in enumerate(events):\n inner = render_event(ev)\n if not inner:\n continue\n parts.append(f'<div class=\"ev\" data-seq=\"{first_seq + i}\">{inner}</div>')\n\n # 合成气泡不写 data-seq:它不在流里,给了序号会把 SSE 恢复游标顶多 1\n if tail is not None and page >= pages:\n inner = render_event(tail)\n if inner:\n parts.append(\n f'<div class=\"ev ev-incomplete ev-synthetic\" data-synthetic=\"1\">{inner}</div>'\n )\n\n if not events and tail is None:"
}The file /Users/mengmeng/grok-cc-task/cc-live-monitor/views.py has been updated successfully. (file state is current in your context — no need to Read it back)
思考
{
"replace_all": false,
"file_path": "/Users/mengmeng/grok-cc-task/cc-live-monitor/views.py",
"old_string": " stats = replay.timeline.stats if replay.timeline else {}\n # 实时只在「有 Claude stream + 停在最后一页」时挂;未在跑也先把脚本放好,\n # 一旦侧栏轮询发现它开始跑,就地连上 SSE,不用整页刷新。\n can_live = (\n bool(replay.stream_files)\n and replay.flavor != \"codex\"\n and not replay.error\n and replay.page >= replay.pages\n )\n if can_live:\n stream_name = replay.stream_name or replay.stream_files[0]\n url = _run_url(run, \"/live\") + \"?\" + urllib.parse.urlencode({\"stream\": stream_name})\n live_js = (\n LIVE_JS.replace(\"__LIVE_URL__\", url)\n .replace(\"__SINCE__\", str(next_seq))\n .replace(\"__AUTO__\", \"1\" if replay.live else \"0\")\n )\n scripts += f\"<script>{live_js}</script>\"",
"new_string": " stats = replay.timeline.stats if replay.timeline else {}\n # Claude 会话(包括还在排队、连 stream 都还没有的)都预置控制器:\n # 一旦侧栏轮询报出可跟随的 stream,就地带 URL 启动,不用整页刷新。\n can_live = (\n replay.flavor != \"codex\"\n and not replay.error\n and replay.page >= replay.pages\n )\n if can_live:\n stream_name = replay.stream_name if replay.stream_files else \"\"\n live_js = (\n LIVE_JS.replace(\"__LIVE_URL__\", json.dumps(live_url(run, stream_name)))\n .replace(\"__SINCE__\", str(next_seq))\n .replace(\"__AUTO__\", \"1\" if replay.live else \"0\")\n .replace(\"__STREAM__\", json.dumps(stream_name, ensure_ascii=False))\n .replace(\n \"__GEN__\",\n json.dumps(rd.stream_generation(run, stream_name), ensure_ascii=False),\n )\n )\n scripts += f\"<script>{live_js}</script>\""
}The file /Users/mengmeng/grok-cc-task/cc-live-monitor/views.py has been updated successfully. (file state is current in your context — no need to Read it back)
思考
{
"replace_all": false,
"file_path": "/Users/mengmeng/grok-cc-task/cc-live-monitor/assets.py",
"old_string": " var busy = false;\n\n function apply(data) {\n if (!data || !data.sig || data.sig === sig) return;\n sig = data.sig;\n var top = list.scrollTop;\n list.innerHTML = data.items || '';\n list.scrollTop = top;\n if (foot && typeof data.foot === 'string') foot.innerHTML = data.foot;\n var cur = data.current || {};\n var live = !!cur.live;\n if (live === currentLive) return;\n currentLive = live;\n // 详情页打开时它还是「未结束」,现在真的开始跑了 → 就地连上 SSE\n if (live && window.__ccLive) window.__ccLive.start();\n }",
"new_string": " var busy = false;\n var lastGen = '';\n\n function apply(data) {\n if (!data) return;\n if (data.sig && data.sig !== sig) {\n sig = data.sig;\n var top = list.scrollTop;\n list.innerHTML = data.items || '';\n list.scrollTop = top;\n if (foot && typeof data.foot === 'string') foot.innerHTML = data.foot;\n }\n var cur = data.current || {};\n currentLive = !!cur.live;\n // 当前会话有了可跟随的源(首次出现,或重试换成了新文件)→ 就地启动 / 换源\n var follow = cur.follow || {};\n if (follow.url && follow.generation && follow.generation !== lastGen && window.__ccLive) {\n lastGen = follow.generation;\n window.__ccLive.start(follow);\n }\n }"
}The file /Users/mengmeng/grok-cc-task/cc-live-monitor/assets.py has been updated successfully.
思考
{
"command": "grep -n \"data-live\\|currentLive\" assets.py views.py",
"description": "Check data-live usage"
}views.py:470: <div class="side-list" id="side-list" data-sig="{sig}" data-current="{html.escape(current_name, quote=True)}" data-live="{1 if current and current.live else 0}">{body}</div>
assets.py:376: var currentLive = list.getAttribute('data-live') === '1';
assets.py:390: currentLive = !!cur.live;思考
{
"replace_all": false,
"file_path": "/Users/mengmeng/grok-cc-task/cc-live-monitor/assets.py",
"old_string": " var sig = list.getAttribute('data-sig') || '';\n var currentLive = list.getAttribute('data-live') === '1';\n var busy = false;",
"new_string": " var sig = list.getAttribute('data-sig') || '';\n var busy = false;"
}The file /Users/mengmeng/grok-cc-task/cc-live-monitor/assets.py has been updated successfully.
{
"replace_all": false,
"file_path": "/Users/mengmeng/grok-cc-task/cc-live-monitor/assets.py",
"old_string": " var cur = data.current || {};\n currentLive = !!cur.live;\n // 当前会话有了可跟随的源(首次出现,或重试换成了新文件)→ 就地启动 / 换源",
"new_string": " var cur = data.current || {};\n // 当前会话有了可跟随的源(首次出现,或重试换成了新文件)→ 就地启动 / 换源"
}The file /Users/mengmeng/grok-cc-task/cc-live-monitor/assets.py has been updated successfully.
Now 改 LIVE_JS(换源 + 游标重置 + 清合成气泡):
{
"replace_all": false,
"file_path": "/Users/mengmeng/grok-cc-task/cc-live-monitor/assets.py",
"old_string": "(function () {\n var LIVE_URL = \"__LIVE_URL__\";\n var SINCE = __SINCE__;\n var AUTO = __AUTO__;\n var BOTTOM_PX = 110; // 距离底部多少像素内算「在底部」",
"new_string": "(function () {\n var LIVE_URL = __LIVE_URL__; // 页面渲染时的源;排队会话为空,等侧栏下发\n var SINCE = __SINCE__;\n var AUTO = __AUTO__;\n var STREAM = __STREAM__; // 页面渲染用的是哪个记录文件\n var PAGE_GEN = __GEN__; // 那个文件的代号(文件名 + inode)\n var BOTTOM_PX = 110; // 距离底部多少像素内算「在底部」"
}The file /Users/mengmeng/grok-cc-task/cc-live-monitor/assets.py has been updated successfully.
{
"replace_all": false,
"file_path": "/Users/mengmeng/grok-cc-task/cc-live-monitor/assets.py",
"old_string": " var maxSeq = SINCE - 1;\n var stopped = false;\n var source = null;",
"new_string": " var maxSeq = SINCE - 1;\n var activeGen = AUTO ? PAGE_GEN : ''; // 当前连着的源;换源时用来判断\n var stopped = false;\n var source = null;"
}The file /Users/mengmeng/grok-cc-task/cc-live-monitor/assets.py has been updated successfully.
{
"replace_all": false,
"file_path": "/Users/mengmeng/grok-cc-task/cc-live-monitor/assets.py",
"old_string": " // 侧栏轮询发现「开始跑了」时也会调它:清掉上一次的结束气泡,接着往下追\n function start() {\n if (source) return;\n if (stopped) {\n stopped = false;\n backoff = 1000;\n if (chat) {\n Array.prototype.forEach.call(chat.querySelectorAll('.ev-incomplete'), function (w) {\n if (w.parentNode) w.parentNode.removeChild(w);\n });\n }\n renderDraft('');\n }\n hidePager();\n markRunning();\n setNote('已开始实时', 'live-on');\n connect();\n }\n\n window.__ccLive = { start: start, isOn: function () { return !!source && !stopped; } };",
"new_string": " // 合成气泡(页面预渲染的 / SSE 结束时临时插的)不在流里:接着追之前先删掉,\n // 否则会跟真正落定的事件重复。\n function clearSynthetic() {\n if (!chat) return;\n Array.prototype.forEach.call(\n chat.querySelectorAll('.ev-synthetic, .ev-incomplete'),\n function (w) { if (w.parentNode) w.parentNode.removeChild(w); }\n );\n }\n\n // 换到新的尝试文件:旧文件的 data-seq 会和新文件撞号,整段清掉从头追\n function resetThread(streamName) {\n if (chat) {\n Array.prototype.forEach.call(chat.querySelectorAll('.ev[data-seq]'), function (w) {\n if (w.parentNode) w.parentNode.removeChild(w);\n });\n }\n clearSynthetic();\n var ph = chat && chat.querySelector('.empty');\n if (ph) ph.parentNode.removeChild(ph);\n maxSeq = -1;\n renderDraft('');\n if (chat && streamName) {\n var note = document.createElement('div');\n note.className = 'msg-note ev-switch';\n note.textContent = '已切换到新的尝试:' + streamName;\n chat.insertBefore(note, draftBox || null);\n }\n }\n\n // 侧栏轮询发现可跟随的源(首次出现 / 重试换文件)时调用;opts 来自 /api/sidebar\n function start(opts) {\n opts = opts || {};\n var url = opts.url || LIVE_URL;\n if (!url) return; // 还没有 stream:等下一轮轮询\n var stream = opts.stream || STREAM;\n var gen = opts.generation || (stream === STREAM ? PAGE_GEN : '');\n var switching = !!gen && !!activeGen && gen !== activeGen;\n if (source && !switching) return; // 已经连着同一个源\n\n if (stream && stream !== STREAM) {\n resetThread(stream); // 重试换了文件:游标归零,从头追\n STREAM = stream;\n pending = 0;\n updateJump();\n } else if (switching) {\n resetThread('');\n }\n LIVE_URL = url;\n if (gen) activeGen = gen;\n stopped = false;\n backoff = 1000;\n clearSynthetic();\n renderDraft('');\n hidePager();\n markRunning();\n setNote('已开始实时', 'live-on');\n connect();\n }\n\n window.__ccLive = { start: start, isOn: function () { return !!source && !stopped; } };"
}The file /Users/mengmeng/grok-cc-task/cc-live-monitor/assets.py has been updated successfully.
思考
{
"replace_all": false,
"file_path": "/Users/mengmeng/grok-cc-task/cc-live-monitor/server.py",
"old_string": "def _default_stream(run: rd.RunInfo) -> str:\n \"\"\"多个记录文件时默认展示内容最多的那个。\n\n attempts 里常见 0 字节的空文件(重试一次但没产出),按尝试序号取会选中空文件,\n 所以按文件大小取最大者;Claude stream 与 Codex 记录混放时 Claude 优先。\n \"\"\"\n if run.stream_files:\n pool = run.stream_files\n else:\n pool = run.codex_files\n if not pool:\n return \"\"\n sizes = {f.name: f.size for f in run.files}\n return max(pool, key=lambda name: (sizes.get(name, 0), name))",
"new_string": "def _default_stream(run: rd.RunInfo) -> str:\n \"\"\"默认展示哪个记录文件。\n\n Claude stream 交给 runs_data.follow_stream:运行中跟最近写入的那份(重试会换\n stream.2.jsonl),已结束取内容最多的那份。Codex 记录仍按大小取最大,\n 避免选中重试留下的 0 字节空文件。\n \"\"\"\n if run.stream_files:\n return rd.follow_stream(run)\n pool = run.codex_files\n if not pool:\n return \"\"\n sizes = {f.name: f.size for f in run.files}\n return max(pool, key=lambda name: (sizes.get(name, 0), name))\n\n\ndef _follow_payload(run: rd.RunInfo | None) -> dict:\n \"\"\"告诉前端「现在能跟随哪个 stream」;没有就全空。\"\"\"\n empty = {\"stream\": \"\", \"generation\": \"\", \"url\": \"\"}\n if run is None or not run.live or not run.stream_files or run.flavor == \"codex\":\n return empty\n stream = rd.follow_stream(run)\n if not stream:\n return empty\n return {\n \"stream\": stream,\n \"generation\": rd.stream_generation(run, stream),\n \"url\": views.live_url(run, stream),\n }"
}The file /Users/mengmeng/grok-cc-task/cc-live-monitor/server.py has been updated successfully. (file state is current in your context — no need to Read it back)
{
"replace_all": false,
"file_path": "/Users/mengmeng/grok-cc-task/cc-live-monitor/server.py",
"old_string": " stream_name = requested if requested in run.stream_files else _default_stream(run)",
"new_string": " stream_name = requested if requested in run.stream_files else (\n rd.follow_stream(run) or _default_stream(run)\n )"
}The file /Users/mengmeng/grok-cc-task/cc-live-monitor/server.py has been updated successfully. (file state is current in your context — no need to Read it back)
{
"replace_all": false,
"file_path": "/Users/mengmeng/grok-cc-task/cc-live-monitor/server.py",
"old_string": " items = views.sidebar_items(runs, current_name, current_run, keyword)\n foot = views.sidebar_foot(matched, len(runs))\n live = bool(current_run and current_run.live)\n payload = {\n \"sig\": views.sidebar_sig(items, foot, live),\n \"items\": items,\n \"foot\": foot,\n \"total\": total,\n \"counts\": counts,\n \"current\": {\n \"name\": current_name,\n \"live\": live,\n \"status\": current_run.status if current_run else \"\",\n \"severity\": current_run.severity if current_run else \"\",\n },\n }",
"new_string": " items = views.sidebar_items(runs, current_name, current_run, keyword)\n foot = views.sidebar_foot(matched, len(runs))\n live = bool(current_run and current_run.live)\n follow = _follow_payload(current_run)\n payload = {\n \"sig\": views.sidebar_sig(items, foot, live, follow[\"generation\"]),\n \"items\": items,\n \"foot\": foot,\n \"total\": total,\n \"counts\": counts,\n \"current\": {\n \"name\": current_name,\n \"live\": live,\n \"status\": current_run.status if current_run else \"\",\n \"severity\": current_run.severity if current_run else \"\",\n \"follow\": follow,\n },\n }"
}The file /Users/mengmeng/grok-cc-task/cc-live-monitor/server.py has been updated successfully. (file state is current in your context — no need to Read it back)
思考
{
"replace_all": false,
"file_path": "/Users/mengmeng/grok-cc-task/run.sh",
"old_string": "echo $$ > \"$LOCK/pid\"; echo \"$NAME\" > \"$LOCK/name\"",
"new_string": "# 锁里写完整 run 目录名(不只短 name):监控端据此精确匹配,避免再次跑同名任务时\n# 把所有同后缀的历史会话都点亮成「运行中」。旧锁只有短 name,监控端做了兼容。\necho $$ > \"$LOCK/pid\"; echo \"${RUN_DIR##*/}\" > \"$LOCK/name\""
}The file /Users/mengmeng/grok-cc-task/run.sh has been updated successfully. (file state is current in your context — no need to Read it back)
思考
现在补 selftest:
{
"replace_all": false,
"file_path": "/Users/mengmeng/grok-cc-task/cc-live-monitor/selftest.py",
"old_string": " w(os.path.join(lock_dir, \"pid\"), str(os.getpid()))\n w(os.path.join(lock_dir, \"name\"), \"live-demo\")",
"new_string": " w(os.path.join(lock_dir, \"pid\"), str(os.getpid()))\n # 新式锁:run.sh 现在写的是完整目录名\n w(os.path.join(lock_dir, \"name\"), \"20261025-900001-live-demo\")"
}The file /Users/mengmeng/grok-cc-task/cc-live-monitor/selftest.py has been updated successfully. (file state is current in your context — no need to Read it back)
{
"replace_all": false,
"file_path": "/Users/mengmeng/grok-cc-task/cc-live-monitor/selftest.py",
"old_string": " quiet_old = time.time() - 600\n os.utime(quiet_stream, (quiet_old, quiet_old))\n\n return runs_dir, lock_dir",
"new_string": " quiet_old = time.time() - 600\n os.utime(quiet_stream, (quiet_old, quiet_old))\n\n # 14) 旧式短名锁的历史同名目录:一个已终结、一个还没结束\n legacy_old = os.path.join(runs_dir, \"20261025-880000-legacy-demo\")\n w(os.path.join(legacy_old, \"task.txt\"), \"目标:上个月跑过的同名任务\\n\")\n w(os.path.join(legacy_old, \"runner.log\"), \"[08:00:00] attempt 1 model=m9\\ndone rc=0\\n\")\n\n legacy_new = os.path.join(runs_dir, \"20261025-900014-legacy-demo\")\n w(os.path.join(legacy_new, \"task.txt\"), \"目标:这次跑到一半的同名任务\\n\")\n w(os.path.join(legacy_new, \"runner.log\"), \"[09:14:00] attempt 1 model=m9\\n\")\n\n # 15) 符号链接:文件与目录都指向 runs 根目录之外\n outside = os.path.join(root, \"outside\")\n w(os.path.join(outside, \"secret.txt\"), \"TOP-SECRET\\n\")\n os.symlink(os.path.join(outside, \"secret.txt\"), os.path.join(ok, \"leak.txt\"))\n os.symlink(outside, os.path.join(ok, \"sub\"))\n os.symlink(outside, os.path.join(runs_dir, \"20261025-900099-escape-link\"))\n\n return runs_dir, lock_dir"
}The file /Users/mengmeng/grok-cc-task/cc-live-monitor/selftest.py has been updated successfully. (file state is current in your context — no need to Read it back)
思考
{
"replace_all": false,
"file_path": "/Users/mengmeng/grok-cc-task/cc-live-monitor/selftest.py",
"old_string": " check(total == 12 and counts[\"running\"] == 2 and counts[\"failed\"] == 2, \"总数、运行中、失败计数\")",
"new_string": " check(total == 14 and counts[\"running\"] == 2 and counts[\"failed\"] == 2, \"总数、运行中、失败计数\")"
}The file /Users/mengmeng/grok-cc-task/cc-live-monitor/selftest.py has been updated successfully. (file state is current in your context — no need to Read it back)
{
"replace_all": false,
"file_path": "/Users/mengmeng/grok-cc-task/cc-live-monitor/selftest.py",
"old_string": " check(m == 4 and all(r.category == \"success\" for r in ok_runs), \"状态筛选 success\")",
"new_string": " check(m == 5 and all(r.category == \"success\" for r in ok_runs), \"状态筛选 success\")"
}The file /Users/mengmeng/grok-cc-task/cc-live-monitor/selftest.py has been updated successfully. (file state is current in your context — no need to Read it back)
{
"replace_all": false,
"file_path": "/Users/mengmeng/grok-cc-task/cc-live-monitor/selftest.py",
"old_string": " tl = sp.load_timeline(trunc_path, incomplete_tail=True)\n last = tl.events[-1]\n check(last.kind == \"incomplete\" and \"最后一段没写完的话\" in last.text, \"未闭合草稿落成 incomplete 事件\")\n check(tl.stats.get(\"incomplete\") == 1, \"统计里标记 incomplete\")\n plain_tl = sp.load_timeline(trunc_path, incomplete_tail=False)\n check(all(ev.kind != \"incomplete\" for ev in plain_tl.events), \"关闭兜底时不加草稿事件\")",
"new_string": " tl = sp.load_timeline(trunc_path, incomplete_tail=True)\n check(tl.tail is not None and tl.tail.kind == \"incomplete\"\n and \"最后一段没写完的话\" in tl.tail.text, \"未闭合草稿落成 incomplete 合成气泡\")\n check(tl.stats.get(\"incomplete\") == 1, \"统计里标记 incomplete\")\n check(tl.total == 1 and all(ev.kind != \"incomplete\" for ev in tl.events),\n \"合成气泡不占真实事件序号\")\n plain_tl = sp.load_timeline(trunc_path, incomplete_tail=False)\n check(plain_tl.tail is None and all(ev.kind != \"incomplete\" for ev in plain_tl.events),\n \"关闭兜底时不加草稿事件\")"
}The file /Users/mengmeng/grok-cc-task/cc-live-monitor/selftest.py has been updated successfully. (file state is current in your context — no need to Read it back)
思考
{
"replace_all": false,
"file_path": "/Users/mengmeng/grok-cc-task/cc-live-monitor/selftest.py",
"old_string": " import threading\n import urllib.request\n\n import server as srv",
"new_string": " import threading\n import urllib.error\n import urllib.request\n\n import server as srv"
}The file /Users/mengmeng/grok-cc-task/cc-live-monitor/selftest.py has been updated successfully. (file state is current in your context — no need to Read it back)
{
"replace_all": false,
"file_path": "/Users/mengmeng/grok-cc-task/cc-live-monitor/selftest.py",
"old_string": " with urllib.request.urlopen(base + \"/healthz\", timeout=10) as resp:\n check(resp.read().strip() == b\"ok\", \"healthz 正常\")\n finally:",
"new_string": " with urllib.request.urlopen(base + \"/healthz\", timeout=10) as resp:\n check(resp.read().strip() == b\"ok\", \"healthz 正常\")\n\n # P1-5:接口下发可跟随的源(stream + generation + url)\n with urllib.request.urlopen(\n base + \"/api/sidebar?current=20261025-900012-fallback-live\", timeout=10\n ) as resp:\n follow_payload = json.loads(resp.read().decode(\"utf-8\"))\n follow = follow_payload[\"current\"][\"follow\"]\n check(\n follow[\"stream\"] == \"stream.jsonl\"\n and follow[\"generation\"].startswith(\"stream.jsonl:\")\n and \"/live?stream=stream.jsonl\" in follow[\"url\"],\n \"接口下发可跟随的 stream / generation / url\",\n )\n with urllib.request.urlopen(\n base + \"/api/sidebar?current=20261025-900002-success-demo\", timeout=10\n ) as resp:\n done_payload = json.loads(resp.read().decode(\"utf-8\"))\n check(done_payload[\"current\"][\"follow\"][\"url\"] == \"\", \"已结束会话不下发跟随源\")\n\n # P1-2:文件接口同样不能借符号链接越界\n try:\n urllib.request.urlopen(\n base + \"/run/20261025-900002-success-demo/file/leak.txt\", timeout=10\n )\n symlink_blocked = False\n except urllib.error.HTTPError as exc:\n symlink_blocked = exc.code == 404\n check(symlink_blocked, \"HTTP 文件接口拒绝符号链接(404)\")\n finally:"
}The file /Users/mengmeng/grok-cc-task/cc-live-monitor/selftest.py has been updated successfully. (file state is current in your context — no need to Read it back)
思考
{
"replace_all": false,
"file_path": "/Users/mengmeng/grok-cc-task/cc-live-monitor/selftest.py",
"old_string": " finally:\n httpd.shutdown()\n httpd.server_close()\n\n print(\"\\nPASS — Phase 7 自测全部通过\")",
"new_string": " finally:\n httpd.shutdown()\n httpd.server_close()\n\n print(\"[8] Astra P1:链接注入 / 符号链接 / 锁匹配 / 恢复游标 / 换源跟随\")\n\n # ---- P1-1 Markdown 链接属性注入 ----\n injected = md_lite.render('[x](https://example.com/\"onmouseover=\"window.name=1)')\n check('onmouseover=\"' not in injected, \"URL 里的引号不会提前闭合 href 属性\")\n check('href=\"https://example.com/"onmouseover="window.name=1\"' in injected,\n \"URL 按属性规则编码\")\n single = md_lite.render(\"[x](https://example.com/'onmouseover='window.name=1)\")\n check(\"onmouseover='\" not in single and \"'onmouseover='\" in single,\n \"单引号同样被编码\")\n amp = md_lite.render(\"[a](https://e.com/?a=1&b=2)\")\n check(\"?a=1&b=2\" in amp and \"&amp;\" not in amp, \"URL 里的 & 不被二次转义\")\n label = md_lite.render(\"[a<b](https://e.com)\")\n check(\">a<b</a>\" in label and \"&lt;\" not in label, \"链接文字不被二次转义\")\n check(\"<a \" not in md_lite.render(\"[x](javascript:alert(1))\"), \"非 http(s) 协议不生成链接\")\n\n # ---- P1-2 符号链接绕过 ----\n ok_run2 = by_run[\"20261025-900002-success-demo\"]\n check(rd.safe_file(ok_run2, \"task.txt\") is not None, \"普通文件仍可读\")\n check(rd.safe_file(ok_run2, \"leak.txt\") is None, \"指向根目录外的符号链接文件被拒绝\")\n check(rd.safe_file(ok_run2, \"sub/secret.txt\") is None, \"借目录符号链接越界被拒绝\")\n check(rd.find_run(\"20261025-900099-escape-link\", runs_dir) is None,\n \"符号链接目录不能当 run 打开\")\n leak_text, _, _ = rd.read_text(os.path.join(ok_run2.path, \"leak.txt\"))\n check(\"TOP-SECRET\" not in leak_text, \"O_NOFOLLOW 让换链接后的读取也拿不到内容\")\n\n # ---- P1-3 锁模糊匹配 ----\n def set_lock(pid: int, name: str) -> None:\n w(os.path.join(lock_dir, \"pid\"), str(pid))\n w(os.path.join(lock_dir, \"name\"), name)\n\n try:\n set_lock(os.getpid(), \"legacy-demo\")\n new_dup = rd.find_run(\"20261025-900014-legacy-demo\", runs_dir)\n old_dup = rd.find_run(\"20261025-880000-legacy-demo\", runs_dir)\n check(new_dup is not None and new_dup.live, \"旧短名锁只认最新的未终结候选\")\n check(old_dup is not None and not old_dup.live, \"同名历史目录不被旧短名锁连带点亮\")\n set_lock(os.getpid(), \"success-demo\")\n succ = rd.find_run(\"20261025-900002-success-demo\", runs_dir)\n check(succ is not None and not succ.live, \"终态绝不被模糊锁覆盖\")\n set_lock(os.getpid(), \"20261025-900003-failed-demo\")\n retry = rd.find_run(\"20261025-900003-failed-demo\", runs_dir)\n check(retry is not None and retry.live, \"完整目录名锁可以点亮重试中的 run\")\n set_lock(os.getpid(), \"fail\")\n none_hit = rd.find_run(\"20261025-900003-failed-demo\", runs_dir)\n check(not none_hit.live, \"短名撞不上完整目录名就不乱点亮\")\n finally:\n set_lock(os.getpid(), \"20261025-900001-live-demo\")\n\n # ---- P1-4 SSE 恢复漏第一条事件 ----\n check('class=\"ev ev-incomplete ev-synthetic\"' in trunc_html\n and 'data-synthetic=\"1\"' in trunc_html, \"合成气泡带独立标记,恢复时可删\")\n check(\"var SINCE = 1;\" in trunc_html, \"页面游标按真实事件数生成(不含合成气泡)\")\n resume_path = os.path.join(root, \"resume.jsonl\")\n jsonl(resume_path, [\n {\"type\": \"assistant\", \"message\": {\"content\": [{\"type\": \"text\", \"text\": \"已经落定\"}]}},\n ])\n with open(resume_path, \"a\", encoding=\"utf-8\") as f:\n f.write(json.dumps({\"type\": \"assistant\", \"message\": {\"content\": [\n {\"type\": \"text\", \"text\": \"恢复后的第一条\"}]}}, ensure_ascii=False) + \"\\n\")\n reader = sp.get_live(resume_path)\n reader.read_new()\n resumed = reader.events_from(1)\n check(len(resumed) == 1 and \"恢复后的第一条\" in resumed[0][1].text,\n \"从真实事件数恢复时不漏第一条新事件\")\n sp.drop_live(resume_path)\n check(\"ev-synthetic\" in views.LIVE_JS, \"恢复时清掉页面预渲染的合成气泡\")\n\n # ---- P1-5 首次 stream / 重试换文件 ----\n files_two = [\n
… 已截断(仅显示前 4 KB)The file /Users/mengmeng/grok-cc-task/cc-live-monitor/selftest.py has been updated successfully. (file state is current in your context — no need to Read it back)
思考
{
"replace_all": false,
"file_path": "/Users/mengmeng/grok-cc-task/cc-live-monitor/selftest.py",
"old_string": " set_lock(os.getpid(), \"20261025-900003-failed-demo\")\n retry = rd.find_run(\"20261025-900003-failed-demo\", runs_dir)\n check(retry is not None and retry.live, \"完整目录名锁可以点亮重试中的 run\")\n set_lock(os.getpid(), \"fail\")\n none_hit = rd.find_run(\"20261025-900003-failed-demo\", runs_dir)\n check(not none_hit.live, \"短名撞不上完整目录名就不乱点亮\")",
"new_string": " set_lock(os.getpid(), \"20261025-900003-failed-demo\")\n retry = rd.find_run(\"20261025-900003-failed-demo\", runs_dir)\n check(retry is not None and retry.live, \"完整目录名锁可以点亮重试中的 run\")"
}The file /Users/mengmeng/grok-cc-task/cc-live-monitor/selftest.py has been updated successfully. (file state is current in your context — no need to Read it back)
{
"command": "python3 selftest.py 2>&1 | tail -60",
"description": "Run selftest"
}[6] 工具名映射 / 错误摘要 / 结果截断 ok Bash → 终端 ok 读写改找搜 五件套 ok ToolSearch → 找工具 ok mcp__lcu__* / browser / js → 浏览器 ok 未知 MCP 工具去掉 mcp__ 前缀 ok 普通工具保留原名 ok 出错摘要行不含 Traceback ok 出错摘要取第一条有效信息 ok 出错时摘要一行短句 ok 超长结果截断到约 4KB 并注明 ok 展开标题用人话名,原名留在 title ok 短结果不加截断提示 ok incomplete 气泡里的思考默认折叠 ok 页面里没有默认展开的思考块 ok 流式草稿的思考也默认折叠 [7] 侧栏自动刷新 / 兜底实时 ok 侧栏底部有「只显示 runs」小字说明 ok 侧栏列表带轮询标记 ok 页面注入侧栏轮询地址 ok 未在跑的会话先备好 SSE 脚本、不自动连 ok SSE 可在发现变 live 后就地启动 ok 运行中页面自动挂 SSE ok 接口返回 JSON ok 接口报告当前会话运行中 ok 侧栏片段 / sig / 页脚都在 ok 接口只回条目片段,不重复外壳 ok healthz 正常 ok 接口下发可跟随的 stream / generation / url ok 已结束会话不下发跟随源 ok HTTP 文件接口拒绝符号链接(404) [8] Astra P1:链接注入 / 符号链接 / 锁匹配 / 恢复游标 / 换源跟随 ok URL 里的引号不会提前闭合 href 属性 ok URL 按属性规则编码 ok 单引号同样被编码 ok URL 里的 & 不被二次转义 ok 链接文字不被二次转义 ok 非 http(s) 协议不生成链接 ok 普通文件仍可读 ok 指向根目录外的符号链接文件被拒绝 ok 借目录符号链接越界被拒绝 ok 符号链接目录不能当 run 打开 ok O_NOFOLLOW 让换链接后的读取也拿不到内容 ok 旧短名锁只认最新的未终结候选 ok 同名历史目录不被旧短名锁连带点亮 ok 终态绝不被模糊锁覆盖 ok 完整目录名锁可以点亮重试中的 run ok 合成气泡带独立标记,恢复时可删 ok 页面游标按真实事件数生成(不含合成气泡) ok 从真实事件数恢复时不漏第一条新事件 ok 恢复时清掉页面预渲染的合成气泡 ok 运行中跟随最近写入的 stream(重试换文件跟得上) ok 已结束回放仍选内容最多的尝试 ok generation 随记录文件变化 ok 实时地址里的文件名会被转义 ok 排队会话预置控制器,等 stream 出现再 start ok 侧栏把 stream / generation 一起下发给 start ok start 支持换源并重置游标 PASS — Phase 7.1 自测全部通过
思考
{
"command": "python3 selftest.py 2>&1 | head -30",
"description": "Check early assertions"
}fixture: /var/folders/zc/8j16fp593pj6_2xzy1snx1340000gn/T/cclm-p4-sfvoi_jp [1] 列表:筛选 / 搜索 / 置顶 ok 总数、运行中、失败计数 ok 没有 runner.log 也没有 jsonl 的工件目录不出现在列表 ok 运行中置顶 ok 失败紧随其后置顶 ok 成功 run 不置顶 ok 状态筛选 success ok 状态筛选 failed ok 按 task.txt 关键字搜索 ok 多关键字 AND + run 名匹配 ok 筛选 + 搜索组合 [2] 末尾不完整草稿 ok 未闭合草稿落成 incomplete 合成气泡 ok 统计里标记 incomplete ok 合成气泡不占真实事件序号 ok 关闭兜底时不加草稿事件 ok 正常文件不误报 ok Codex 文件不会被 Claude 解析器误判 [3] Codex 兼容 ok 列表识别 Codex ok Codex 文件归档 ok Codex 事件映射(实际 ['user', 'thinking', 'tool', 'assistant']) ok 工具调用与结果/出错标记 ok Codex 气泡署名 ok Codex 元数据 ok 未知 Codex 记录降级不崩 ok 普通 jsonl 不误判 Codex [4] 聊天界面渲染 ok 左侧会话栏 + 当前会话高亮
{
"command": "python3 - <<'PY'\nimport assets, subprocess, tempfile, os\n# 语法检查三个内联脚本\njs = assets.LIVE_JS.replace('__LIVE_URL__','\"/x\"').replace('__SINCE__','1').replace('__AUTO__','0').replace('__STREAM__','\"\"').replace('__GEN__','\"\"')\nsb = assets.SIDEBAR_JS.replace('__SIDEBAR_URL__','\"/x\"').replace('__POLL_MS__','4500')\nfor name, src in ((\"live\", js), (\"sidebar\", sb), (\"chat\", assets.CHAT_JS)):\n p = tempfile.NamedTemporaryFile(\"w\", suffix=\".js\", delete=False)\n p.write(src); p.close()\n r = subprocess.run([\"node\", \"--check\", p.name], capture_output=True, text=True)\n print(name, \"OK\" if r.returncode == 0 else r.stderr[:600])\n os.unlink(p.name)\nPY",
"description": "Syntax-check inline JS"
}live OK sidebar OK chat OK
思考
{
"command": "cd /Users/mengmeng/grok-cc-task && ls -la .browser.lock 2>/dev/null && cat .browser.lock/pid .browser.lock/name 2>/dev/null; echo \"---\"; lsof -ti tcp:8787 -sTCP:LISTEN",
"description": "Inspect lock and running server"
}total 16 drwxr-xr-x@ 4 mengmeng staff 128 10月 25 20:39 . drwxr-xr-x 73 mengmeng staff 2336 10月 25 20:44 .. -rw-r--r--@ 1 mengmeng staff 17 10月 25 20:39 name -rw-r--r--@ 1 mengmeng staff 6 10月 25 20:39 pid 21244 cc-live-p1-astra --- 12347
思考
{
"command": "cd /Users/mengmeng/grok-cc-task/cc-live-monitor && python3 - <<'PY'\nimport runs_data as rd\npid, name = rd.load_lock()\nprint(\"lock:\", pid, name, \"alive:\", rd._pid_alive(pid))\nprint(\"resolved:\", rd.resolve_lock_run(rd.DEFAULT_RUNS_DIR, pid, name))\nr = rd.find_run(\"20261025-203931-cc-live-p1-astra\")\nprint(\"current run:\", r and (r.name, r.live, r.status, r.state, [f.name for f in r.files]))\nprint(\"follow:\", r and rd.follow_stream(r), r and rd.stream_generation(r, rd.follow_stream(r)))\nPY",
"description": "Verify lock resolution on real data"
}lock: 21244 cc-live-p1-astra alive: True
resolved: 20261025-203931-cc-live-p1-astra
current run: ('20261025-203931-cc-live-p1-astra', True, '运行中', 'running', ['runner.log', 'stderr.1.txt', 'task.txt', 'stream.1.jsonl'])
follow: stream.1.jsonl stream.1.jsonl:163411945思考
{
"command": "python3 - <<'PY'\nimport runs_data as rd\nruns, total, counts, matched = rd.scan_runs(rd.DEFAULT_RUNS_DIR, limit=None)\nprint(\"total\", total, \"counts\", counts)\nfor r in runs:\n if r.live or r.name.endswith(\"-astra\") or \"astra\" in r.name:\n print(r.live, r.state, r.status, r.name)\nPY",
"description": "Check no historical same-suffix runs lit"
}total 318 counts {'all': 318, 'running': 1, 'success': 214, 'failed': 16, 'other': 87}
True running 运行中 20261025-203931-cc-live-p1-astra思考
{
"command": "ps -o pid=,command= -p 12347",
"description": "Show current server command"
}12347 /opt/homebrew/Cellar/python@3.14/3.14.4_1/Frameworks/Python.framework/Versions/3.14/Resources/Python.app/Contents/MacOS/Python server.py
{
"command": "lsof -ti tcp:8787 -sTCP:LISTEN | xargs kill; sleep 1; nohup python3 server.py >> server.log 2>&1 & sleep 2; lsof -nP -iTCP:8787 -sTCP:LISTEN; curl -s -m 5 http://127.0.0.1:8787/healthz",
"description": "Restart 8787 server"
}COMMAND PID USER FD TYPE DEVICE SIZE/OFF NODE NAME Python 24271 mengmeng 3u IPv4 0xbd05f284704d8f3f 0t0 TCP *:8787 (LISTEN) ok
思考
{
"command": "curl -s -m 8 'http://127.0.0.1:8787/api/sidebar?current=20261025-203931-cc-live-p1-astra' | python3 -c 'import json,sys; d=json.load(sys.stdin); print(d[\"current\"]); print(\"sig\",d[\"sig\"],\"total\",d[\"total\"])'",
"description": "Check sidebar API on real server"
}{'name': '20261025-203931-cc-live-p1-astra', 'live': True, 'status': '运行中', 'severity': 'run', 'follow': {'stream': 'stream.1.jsonl', 'generation': 'stream.1.jsonl:163411945', 'url': '/run/20261025-203931-cc-live-p1-astra/live?stream=stream.1.jsonl'}}
sig 9e012f3e0a3a total 318{
"command": "curl -s -m 8 'http://127.0.0.1:8787/run/20261025-203931-cc-live-p1-astra' | grep -o 'var LIVE_URL = [^;]*;\\|var STREAM = [^;]*;\\|var SINCE = [0-9]*;\\|var AUTO = [01];\\|var PAGE_GEN = [^;]*;'",
"description": "Check injected live controller"
}var AUTO = 0; var LIVE_URL = " var LIVE_URL = " var LIVE_URL = __LIVE_URL__; var STREAM = __STREAM__; var PAGE_GEN = __GEN__; var LIVE_URL = \" var LIVE_URL = __LIVE_URL__; var STREAM = __STREAM__; var PAGE_GEN = __GEN__; var AUTO = 1; var AUTO = 1; var LIVE_URL = " var AUTO = 0; var AUTO = 1; var SINCE = 1; var LIVE_URL = " var AUTO = 0; var STREAM = " var LIVE_URL = " var LIVE_URL = __LIVE_URL__; var LIVE_URL = " var LIVE_URL = " var STREAM = " var SINCE = 1; var SINCE = 1; var LIVE_URL = [^; var LIVE_URL = [^; var STREAM = [^; var PAGE_GEN = [^; var LIVE_URL = "/run/20261025-203931-cc-live-p1-astra/live?stream=stream.1.jsonl"; var SINCE = 121; var AUTO = 1; var STREAM = "stream.1.jsonl"; var PAGE_GEN = "stream.1.jsonl:163411945";
思考
{
"command": "curl -sN -m 4 'http://127.0.0.1:8787/run/20261025-203931-cc-live-p1-astra/live?stream=stream.1.jsonl&since=121' | head -8",
"description": "Smoke test SSE stream"
}: retry: 2000
event: meta
data: {"note": "实时跟随中 · stream.1.jsonl", "stats": "共 0 条事件 · 实时追加中 · 源文件 0 行(跳过流式增量 0 行,坏行 0)"}
event: append
data: {"events": [{"seq": 121, "html": "<div class=\"ev-in\" data-kind=\"thinking\"><details class=\"think\"><summary><span class=\"think-word\">思考</span><span class=\"think-meta\">594 字</span></summary><div class=\"think-body\">The page injects correctly: SINCE=121, AUTO=1, STREAM, PAGE_GEN. The grep noise comes from the template text inside escaped script? Actually the other `var LIVE_URL = __LIVE_URL__;` matches come from the page content being rendered as chat markdown — this session's own stream contains the assets.py source text! That's expected (we're viewing our own conversation which includes code). Fine.\n\nAlso `var AUTO = 0` etc from our conversation quoting. Not a bug.\n\nLet's verify SSE header works: curl -N the live endpoint with since=121, with a short timeout to see meta event. Use `curl -sN -m 3`.</div></details></div>"}, {"seq": 122, "html": "<div class=\"ev-in\" data-kind=\"tool\" data-name=\"终端\" data-err=\"0\"><div class=\"tool-item\"><div class=\"tool-head\"><span class=\"tname\" title=\"Bash\">终端</span><span class=\"tdesc\">curl -sN -m 4 'http://127.0.0.1:8787/run/20261025-203931-cc-live-p1-astra/live?stream=stream.1.jsonl&since=121' | head …</span></div><div class=\"tool-detail\"><div class=\"lbl\">参数</div><pre>{\n "command": "curl -sN -m 4 'http://127.0.0.1:8787/run/20261025-203931-cc-live-p1-astra/live?stream=stream.1.jsonl&since=121' | head -8",\n "description": "Smoke test SSE stream"\n}</pre><div class=\"lbl\">结果</div><div class=\"muted\">(该调用没有留下结果)</div></div></div></div>"}]}思考
{
"command": "grep -n \"api/sidebar\\|## \\|### \" README.md | head -40",
"description": "README outline"
}10:- **Phase 7**:进行中不再靠锁——`stream.*.jsonl` 90 秒内还在长就算「运行中」(锁仍优先),侧栏每 4.5 秒拉 `/api/sidebar` 增量刷新(保留选中与滚动),详情页发现该会话开始跑就地接上 SSE。 15:## 启动 38:## 参数 46:## 路由 53:| `/api/sidebar?q=&status=&limit=¤t=<run>` | 侧栏轮询接口:返回 `sig` + 条目 HTML 片段 + 页脚 + 当前会话的 `live/status`(4.5 秒一次,内容没变不换 DOM) | 61:## 文件 74:## 对话回放怎么解析的 88:## Codex 产物 99:## 状态判定 119:侧栏每 4.5 秒拉一次 `/api/sidebar`,只有 `sig` 变了才替换列表(滚动位置与当前选中高亮都保留),新出现的会话不用整页刷新就能看到。详情页不再整页刷新,改由 SSE 增量推送;如果打开时它还没在跑,之后开始跑会就地连上 SSE 并在页脚提示「已开始实时」。 123:## 实时跟随(Phase 3–4) 134:## 自测 140:全部 fixture 建在临时目录,不碰真实 runs;覆盖状态筛选、关键字搜索、置顶顺序、被杀运行的不完整气泡、Codex 映射与未知记录降级,以及聊天界面(侧栏、task 气泡、Markdown、工具行标记、思考折叠、噪声剥离)。Phase 6 起额外覆盖状态文案映射、空工件目录过滤、圆点分级、工具名映射、错误摘要与 4KB 截断;Phase 7 起覆盖锁丢失时的兜底 live、十分钟没动不算 live、已结束不误判,并起一个临时 HTTP 服务验证 `/api/sidebar`。加 `--keep` 保留 fixture,方便起服务手测。
{
"file_path": "/Users/mengmeng/grok-cc-task/cc-live-monitor/README.md",
"offset": 1,
"limit": 60
}1 # cc-live-monitor 2 3 `~/grok-cc-task/runs` 的只读 Web 监控面板。 4 5 - **Phase 1**:run 列表、`task.txt` / `runner.log` 原文、目录内任意文件查看。 6 - **Phase 2**:把 `stream.*.jsonl` 解析成时间线,详情页提供聊天式回放(气泡 + 可折叠思考/工具卡),顶栏显示 model、session_id、cwd、effort。 7 - **Phase 3**:运行中的 run 走 SSE 实时跟随——服务端 tail 文件增长,页面增量追加,不整页刷新(滚动位置与展开状态都保留),「锁定滚动跟随」默认开,断线自动重连并补发断线期间的事件。 8 - **Phase 4**:上滚自动暂停跟随、可「跳到最新」;被杀运行的最后一段流式输出落成「不完整」气泡;识别 Codex 产物(`agent-*.jsonl` / `events.jsonl` / rollout),用同一套时间线回放。 9 - **Phase 5**:界面重做成 Claude 桌面 App 那种对话形态——左侧会话列表(按时间、标题取 `task.txt` 首行、运行中高亮),右侧一条条「你 / Claude」气泡;`task.txt` 作为第一条用户消息,Claude 回复按 Markdown 渲染,工具调用默认收成一行,思考默认折成一行小字;状态压缩成顶栏一行。 10 - **Phase 7**:进行中不再靠锁——`stream.*.jsonl` 90 秒内还在长就算「运行中」(锁仍优先),侧栏每 4.5 秒拉 `/api/sidebar` 增量刷新(保留选中与滚动),详情页发现该会话开始跑就地接上 SSE。 11 - **Phase 6**:可读性——侧栏与顶栏状态改说人话(出口未过 / 未完成 / 已中断 / 排队中 / 无记录),只有真失败才给红点;工具名映射成中文(调用了 终端 · 浏览器 · 读文件),出错时摘要一行短句,展开区超 4KB 自动截断;既没有 `runner.log` 也没有 jsonl 的空工件目录不再出现在列表里。 12 13 **只读**:不写、不改、不删 runs 目录里的任何文件。 14 15 ## 启动 16 17 ```bash 18 cd ~/grok-cc-task/cc-live-monitor && python3 server.py 19 ``` 20 21 然后打开 <http://127.0.0.1:8787> 22 23 后台常驻: 24 25 ```bash 26 cd ~/grok-cc-task/cc-live-monitor && nohup python3 server.py > server.log 2>&1 & 27 ``` 28 29 停止: 30 31 ```bash 32 lsof -ti tcp:8787 -sTCP:LISTEN | xargs kill 33 ``` 34 35 > 必须带 `-sTCP:LISTEN`。`lsof -ti tcp:8787` 会把**浏览器那边的客户端连接**也列出来, 36 > 直接 `xargs kill` 会连带杀掉 Chrome。 37 38 ## 参数 39 40 ```bash 41 python3 server.py --port 9000 # 换端口 42 python3 server.py --runs-dir ~/grok-cc-task/runs # 换数据目录 43 python3 server.py --host 127.0.0.1 # 只给本机用(默认是 0.0.0.0,手机可访问) 44 ``` 45 46 ## 路由 47 48 | 路径 | 说明 | 49 |------|------| 50 | `/` | 会话界面:左侧列表按时间倒序(默认取 60 条,`?limit=200` 可调,上限 2000),右侧默认打开最新一条 | 51 | `/?q=chrome` | 侧栏搜索:run 名 + `task.txt` 前 4 KB,多词 AND,大小写不敏感 | 52 | `/?status=running\|success\|failed\|all` | 仍然支持状态过滤(无 UI,给脚本用),非法值回落 `all` | 53 | `/api/sidebar?q=&status=&limit=¤t=<run>` | 侧栏轮询接口:返回 `sig` + 条目 HTML 片段 + 页脚 + 当前会话的 `live/status`(4.5 秒一次,内容没变不换 DOM) | 54 | `/run/<name>` | 同一个会话界面,右侧切到指定 run | 55 | `/run/<name>?page=2&size=200` | 聊天的早期事件分页,默认 200 条/页(上限 300) | 56 | `/run/<name>?stream=stream.2.jsonl` | 多次尝试时指定 stream 文件;默认取体积最大的那个 | 57 | `/run/<name>/live?stream=<file>&since=<seq>` | SSE 实时流:`meta` / `append` / `update` / `draft` / `closed` / `reset` | 58 | `/run/<name>/file/<file>` | 目录内单个文件原文(大文件截断到 400 KB) | 59 | `/healthz` | 健康检查,返回 `ok` | 60
思考
{
"replace_all": false,
"file_path": "/Users/mengmeng/grok-cc-task/cc-live-monitor/README.md",
"old_string": "- **Phase 6**:可读性——侧栏与顶栏状态改说人话(出口未过 / 未完成 / 已中断 / 排队中 / 无记录),只有真失败才给红点;工具名映射成中文(调用了 终端 · 浏览器 · 读文件),出错时摘要一行短句,展开区超 4KB 自动截断;既没有 `runner.log` 也没有 jsonl 的空工件目录不再出现在列表里。",
"new_string": "- **Phase 6**:可读性——侧栏与顶栏状态改说人话(出口未过 / 未完成 / 已中断 / 排队中 / 无记录),只有真失败才给红点;工具名映射成中文(调用了 终端 · 浏览器 · 读文件),出错时摘要一行短句,展开区超 4KB 自动截断;既没有 `runner.log` 也没有 jsonl 的空工件目录不再出现在列表里。\n- **Phase 7.1**(Astra P1):Markdown 链接属性按属性规则编码(不再能被 `\"` 注入事件);`/run/<name>/file/...` 拒绝符号链接、真实路径必须落在 runs 根目录内(读取走 `O_NOFOLLOW`);锁写完整 run 目录名并精确匹配,旧短名锁最多认一个未终结候选、绝不点亮终态;合成的「不完整」气泡不再占真实事件序号,SSE 恢复不漏第一条;侧栏下发可跟随的 `stream` / `generation`,排队会话预置控制器、重试换文件能就地重置游标。"
}The file /Users/mengmeng/grok-cc-task/cc-live-monitor/README.md has been updated successfully.
{
"replace_all": false,
"file_path": "/Users/mengmeng/grok-cc-task/cc-live-monitor/README.md",
"old_string": "| `/api/sidebar?q=&status=&limit=¤t=<run>` | 侧栏轮询接口:返回 `sig` + 条目 HTML 片段 + 页脚 + 当前会话的 `live/status`(4.5 秒一次,内容没变不换 DOM) |",
"new_string": "| `/api/sidebar?q=&status=&limit=¤t=<run>` | 侧栏轮询接口:返回 `sig` + 条目 HTML 片段 + 页脚 + 当前会话的 `live/status` 与 `follow{stream,generation,url}`(4.5 秒一次,内容没变不换 DOM) |"
}The file /Users/mengmeng/grok-cc-task/cc-live-monitor/README.md has been updated successfully.
{
"replace_all": false,
"file_path": "/Users/mengmeng/grok-cc-task/cc-live-monitor/README.md",
"old_string": "| `/run/<name>?stream=stream.2.jsonl` | 多次尝试时指定 stream 文件;默认取体积最大的那个 |",
"new_string": "| `/run/<name>?stream=stream.2.jsonl` | 多次尝试时指定 stream 文件;默认运行中跟最近写入的那份,已结束取体积最大的那个 |"
}The file /Users/mengmeng/grok-cc-task/cc-live-monitor/README.md has been updated successfully.
思考
{
"command": "grep -n \"^## 后续可做\" PROGRESS.md && sed -n '/^## 后续可做/,$p' PROGRESS.md | head -5",
"description": "Locate insertion point in PROGRESS.md"
}477:## 后续可做(未开始) ## 后续可做(未开始) 1. **回放细节**:Edit/Write 的 diff 视图、Bash 输出按 ANSI 着色、超长结果「加载更多」。 2. **对比与聚合**:同一任务多次尝试并排对比;按天统计成功率与耗时分布;token / 费用汇总。 3. **Codex 深化**:真实 Codex 产物接入后补字段映射;支持 Codex 的增量跟随。
{
"file_path": "/Users/mengmeng/grok-cc-task/cc-live-monitor/PROGRESS.md",
"offset": 468,
"limit": 12
}468 | `runs_data.py` | `LIVE_FALLBACK_S` + `_newest_record_age()`;锁之外的 90 秒兜底 live | 469 | `server.py` | 新增 `GET /api/sidebar` 路由与 `_api_sidebar()` | 470 | `views.py` | 侧栏片段化(`sidebar_items` / `sidebar_foot` / `sidebar_sig` / `sidebar_url`);恒注入 `LIVE_JS`(`__AUTO__`);页脚说明 | 471 | `assets.py` | 新增 `SIDEBAR_JS`;`LIVE_JS` 改为可 `start()`;`.side-note` / `.foot-hint` 样式 | 472 | `selftest.py` | 新增第 12/13 个 fixture 与第 [7] 组断言(含起临时 HTTP 服务打接口) | 473 | `README.md` / `PROGRESS.md` | 路由表、状态判定、自动刷新说明;Phase 7 记录 | 474 475 **Phase 7 — 完成。** 服务已重启(`lsof -ti tcp:8787 -sTCP:LISTEN | xargs kill` 后 `nohup python3 server.py >> server.log 2>&1 &`),仍绑 `0.0.0.0:8787`。 476 477 ## 后续可做(未开始) 478 479 1. **回放细节**:Edit/Write 的 diff 视图、Bash 输出按 ANSI 着色、超长结果「加载更多」。
{
"replace_all": false,
"file_path": "/Users/mengmeng/grok-cc-task/cc-live-monitor/PROGRESS.md",
"old_string": "**Phase 7 — 完成。** 服务已重启(`lsof -ti tcp:8787 -sTCP:LISTEN | xargs kill` 后 `nohup python3 server.py >> server.log 2>&1 &`),仍绑 `0.0.0.0:8787`。\n\n## 后续可做(未开始)",
"new_string": "**Phase 7 — 完成。** 服务已重启(`lsof -ti tcp:8787 -sTCP:LISTEN | xargs kill` 后 `nohup python3 server.py >> server.log 2>&1 &`),仍绑 `0.0.0.0:8787`。\n\n## Phase 7.1 — Astra P1 修复\n\nCodex GPT-6 Astra 的只读审查给出 5 个 P1,全部修完并补了断言。\n\n### P1-1 Markdown 链接属性注入(`md_lite.py`)\n\n`_esc()` 用 `quote=False`,`\"` 原样留着;`inline()` 又把 URL 直接拼进 `href=\"...\"`,于是\n`[x](https://example.com/\"onmouseover=\"window.name=1)` 会闭合属性、生成真实的事件处理器。\n\n- 新增 `_attr()`:先把已转义的值 `html.unescape` 一次,再按属性规则 `html.escape(quote=True)`。\n 反转义是为了不把 URL 里本来就有的 `&` 二次转义成 `&amp;`;文本节点仍走 `_esc`,不重复转义。\n- 顺带剥掉 URL 里的控制字符。\n\n### P1-2 符号链接绕过路径防护(`runs_data.py` + `stream_parse.py`)\n\n`find_run()` 用 `isdir`、`safe_file()` 用 `isfile` 都会跟链接;目录扫描排除链接并不够,手工敲\n`/run/<name>/file/...` 仍可能读到根目录外。\n\n- `find_run()`:`runs_dir` 与目标都取 `realpath`,必须 `commonpath == 根目录` 且不是根本身。\n- `safe_file()`:先 `lstat` 要求是**普通文件**(`S_IFREG`,符号链接天然被排除),再校验 realpath 仍在 run 目录内;`..` / 子路径 / 隐藏文件照旧拒绝。\n- 读取统一走 `open_nofollow()`(`O_NOFOLLOW`):`read_text` / `read_task_head` / `_read_head_tail` / `StreamReader` / `tail_draft` / Codex 解析。\n 即使检查完才被换成链接,也打不开。\n\n### P1-3 锁模糊匹配点亮历史同名会话(`runs_data.py` + `run.sh`)\n\n旧逻辑 `name.endswith(\"-\" + lock_name)` 无条件覆盖状态:再跑一次同名任务,所有同后缀历史目录都变「运行中」。\n\n- `run.sh` 的锁改写**完整 run 目录名**(`${RUN_DIR##*/}`)。\n- 新增 `resolve_lock_run()`:锁名等于目录名 → 精确命中;否则按旧短名后缀匹配,但只接受 `state` 不在终态\n (成功 / 未完成 / 出口未过 / 已中断)的候选,且按目录名时间戳倒序只取**最新一个**。\n- `build_run()` 不再自己模糊匹配,只认 `name == lock_run`;`scan_runs()` / `find_run()` 共用同一套解析。\n- 完整目录名锁仍可点亮「第一次失败、正在重试」的 run(终态例外只针对模糊锁)。\n\n### P1-4 SSE 恢复漏第一条事件(`views.py` / `stream_parse.py` / `LIVE_JS`)\n\n`parse_stream()` 把合成的 `incomplete` 事件塞进 `events`,它被计入 SINCE,但实时 reader 里没有这条\n→ 恢复游标多 1,下一条真实事件被跳过;页面预渲染的合成气泡也没有 `.ev-incomplete`,`LIVE_JS.start()` 清不掉。\n\n- `Timeline` 新增 `tail` 字段:合成气泡**不进 `events`**,`total` / 分页 / SINCE 只算真实事件。\n- `_thread_parts()` 在末页单独渲染它,带 `ev-incomplete ev-synthetic` 与 `data-synthetic=\"1\"`,不给 `data-seq`。\n- `LIVE_JS.start()` 用 `clearSynthetic()` 同时清 `.ev-synthetic` / `.ev-incomplete`,再接着追。\n\n### P1-5 首次 stream / 重试换文件跟不上(`server.py` / `views.py` / `assets.py`)\n\n排队会话还没有 stream 时页面根本没注入控制器;重试后 `_default_stream()` 按体积取最大,会一直跟着已经停写的旧 attempt。\n\n- `runs_data.follow_stream()`:运行中取**最近写入**的 stream(重试会换 `stream.2.jsonl`),结束后才按体积取最大。\n- `runs_data.stream_generation()`:`文件名:inode`,追加写不变、换文件 / 重写才变。\n- `/api/sidebar` 的 `current` 增加 `follow{stream,generation,url}`,并把它算进 `sig`(否则条目 HTML 没变时前端收不到换源)。\n- 页面:Claude 会话(含排队中、没有 stream 的)一律注入 `LIVE_JS`,`LIVE_URL` 为空时待命;\n `LIVE_JS.start(opts)` 接受新 URL / stream / generation,换源时清掉旧 attempt 的 `data-seq` 节点、插入「已切换到新的尝试」提示并**把游标归零**。\n\n### 验收\n\n```text\n$ python3 selftest.py\n[1]…[8] 全绿(新增第 [8] 组 26 条断言 + 接口侧 follow / 符号链接 404)\nPASS — Phase 7.1 自测全部通过\n```\n\n- 真实数据:旧短名锁 `cc-live-p1-astra`(pid 活着)只解析出 `20261025-203931-cc-live-p1-astra` 一个目录,\n `scan_runs` 里 `running=1`(318 个目录),历史同名会话没有被连带点亮。\n- 服务重启后 `/run/20261025-203931-cc-live-p1-astra` 注入 `SINCE=121 / AUTO=1 / STREAM=\"stream.1.jsonl\"`;\n `curl -N .../live?since=121` 第一条 `append` 就是 `seq: 121`,没有漏。\n- 三个内联脚本(`CHAT_JS` / `SIDEBAR_JS` / `LIVE_JS`)都过 `node --check`。\n\n### 变更文件\n\n| 文件 | 变化 |\n|------|------|\n| `md_lite.py` | `_attr()`:链接 URL 反转义后按属性规则转义,控制字符剥离 |\n| `runs_data.py` | `_within` / `_is_regular` / `resolve_lock_run` / `follow_stream` / `stream_generation`;`find_run` / `safe_file` 真实路径校验;`FileInfo.ino` |\n| `stream_parse.py` | `Timeline.tail`(合成气泡不占序号);`open_nofollow()`;解析与 tail 走 `O_NOFOLLOW` |\n| `codex_parse.py` | 读取 Codex 记录改用 `open_nofollow()` |\n| `views.py` | 合成气泡独立渲染(`ev-synthetic`);`live_url()` / `follow_generation()`;排队会话也注入控制器;`sidebar_sig` 计入 generation |\n| `server.py` | `_follow_payload()`;`/api/sidebar` 下发 `follow`;默认跟随改为「运行中取最新写入」 |\n| `assets.py` | `SIDEBAR_JS` 带 generation 调 `start(follow)`;`LIVE_JS.start(opts)` 换源 / 归零游标 /
… 已截断(仅显示前 4 KB)The file /Users/mengmeng/grok-cc-task/cc-live-monitor/PROGRESS.md has been updated successfully.
思考
{
"file_path": "/Users/mengmeng/grok-cc-task/cc-live-monitor/views.py",
"offset": 538,
"limit": 45
}538
539
540 # ---------- 聊天主体 ----------
541
542 def _thread_parts(run: RunInfo, replay: Replay, task_text: str) -> tuple[str, int]:
543 """返回 (聊天区 HTML, 下一条事件的 seq),SSE 的 since 接在后一个序号上。"""
544 parts: list[str] = []
545
546 task = (task_text or "").strip()
547 if task:
548 parts.append(f'<div class="ev"><div class="ev-in" data-kind="task">{_row_user(task)}</div></div>')
549
550 if replay.live and replay.page < replay.pages:
551 parts.append(
552 f'<div class="msg-note">实时追加只在最后一页可用;'
553 f'<a href="{_chat_url(run, replay, replay.pages)}">跳到末页</a>再自动跟随。</div>'
554 )
555 if replay.note:
556 parts.append(f'<div class="msg-note">{html.escape(replay.note)}</div>')
557
558 events: list[Event] = []
559 tail: Event | None = None
560 page, pages = replay.page, replay.pages
561 if replay.timeline is not None:
562 events, page, pages = replay.timeline.page(replay.page, replay.size)
563 replay.page, replay.pages = page, pages
564 tail = replay.timeline.tail # 合成的「不完整」气泡,只在末页出现
565
566 first_seq = (page - 1) * replay.size
567 for i, ev in enumerate(events):
568 inner = render_event(ev)
569 if not inner:
570 continue
571 parts.append(f'<div class="ev" data-seq="{first_seq + i}">{inner}</div>')
572
573 # 合成气泡不写 data-seq:它不在流里,给了序号会把 SSE 恢复游标顶多 1
574 if tail is not None and page >= pages:
575 inner = render_event(tail)
576 if inner:
577 parts.append(
578 f'<div class="ev ev-incomplete ev-synthetic" data-synthetic="1">{inner}</div>'
579 )
580
581 if not events and tail is None:
582 if replay.error:思考
{
"replace_all": false,
"file_path": "/Users/mengmeng/grok-cc-task/cc-live-monitor/assets.py",
"old_string": " var cur = data.current || {};\n // 当前会话有了可跟随的源(首次出现,或重试换成了新文件)→ 就地启动 / 换源\n var follow = cur.follow || {};\n if (follow.url && follow.generation && follow.generation !== lastGen && window.__ccLive) {\n lastGen = follow.generation;\n window.__ccLive.start(follow);\n }\n }",
"new_string": " var cur = data.current || {};\n // 当前会话有了可跟随的源(首次出现,或重试换成了新文件)→ 就地启动 / 换源;\n // 之前因空闲被服务端收尾(stopped)后又动起来,也要重新接上\n var follow = cur.follow || {};\n if (!follow.url || !follow.generation || !window.__ccLive) return;\n if (follow.generation === lastGen && !window.__ccLive.stopped()) return;\n lastGen = follow.generation;\n window.__ccLive.start(follow);\n }"
}The file /Users/mengmeng/grok-cc-task/cc-live-monitor/assets.py has been updated successfully.
{
"replace_all": false,
"file_path": "/Users/mengmeng/grok-cc-task/cc-live-monitor/assets.py",
"old_string": " window.__ccLive = { start: start, isOn: function () { return !!source && !stopped; } };",
"new_string": " window.__ccLive = {\n start: start,\n isOn: function () { return !!source && !stopped; },\n stopped: function () { return stopped; }\n };"
}The file /Users/mengmeng/grok-cc-task/cc-live-monitor/assets.py has been updated successfully.
思考
{
"command": "python3 selftest.py 2>&1 | tail -8 && python3 - <<'PY'\nimport assets, subprocess, tempfile, os\njs = assets.LIVE_JS.replace('__LIVE_URL__','\"/x\"').replace('__SINCE__','1').replace('__AUTO__','0').replace('__STREAM__','\"\"').replace('__GEN__','\"\"')\nsb = assets.SIDEBAR_JS.replace('__SIDEBAR_URL__','\"/x\"').replace('__POLL_MS__','4500')\nfor name, src in ((\"live\", js), (\"sidebar\", sb)):\n p = tempfile.NamedTemporaryFile(\"w\", suffix=\".js\", delete=False); p.write(src); p.close()\n r = subprocess.run([\"node\", \"--check\", p.name], capture_output=True, text=True)\n print(name, \"OK\" if r.returncode == 0 else r.stderr[:400]); os.unlink(p.name)\nPY",
"description": "Re-run selftest and JS syntax check"
}ok 已结束回放仍选内容最多的尝试 ok generation 随记录文件变化 ok 实时地址里的文件名会被转义 ok 排队会话预置控制器,等 stream 出现再 start ok 侧栏把 stream / generation 一起下发给 start ok start 支持换源并重置游标 PASS — Phase 7.1 自测全部通过 live OK sidebar OK
思考
{
"command": "lsof -ti tcp:8787 -sTCP:LISTEN | xargs kill; sleep 1; nohup python3 server.py >> server.log 2>&1 & sleep 2; lsof -nP -iTCP:8787 -sTCP:LISTEN | tail -1; curl -s -m 5 http://127.0.0.1:8787/healthz; tail -3 server.log",
"description": "Restart server after final edits"
}Python 24782 mengmeng 3u IPv4 0x38f6ccf1cfd6f0c6 0t0 TCP *:8787 (LISTEN) ok PHASE 5 DONE — open http://0.0.0.0:8787 [25/Oct/2026 20:47:53] "GET /api/sidebar?q=&status=all&limit=60¤t=20261025-200930-cc-live-realtime&_=1792932472900 HTTP/1.1" 200 - [25/Oct/2026 20:47:53] "GET /healthz HTTP/1.1" 200 -
思考
{
"command": "python3 - <<'PY'\nimport urllib.request, json, re\nbase=\"http://127.0.0.1:8787\"\nhtml=urllib.request.urlopen(base+\"/run/20261025-203931-cc-live-p1-astra\", timeout=10).read().decode()\nscripts=re.findall(r\"<script>(.*?)</script>\", html, re.S)\nlive=[s for s in scripts if \"__ccLive\" in s and \"EventSource\" in s]\nprint(\"live script blocks:\", len(live))\nprint(\"leftover placeholders:\", [p for p in (\"__LIVE_URL__\",\"__SINCE__\",\"__AUTO__\",\"__STREAM__\",\"__GEN__\") if p in live[-1]])\nfor line in live[-1].splitlines():\n if \"LIVE_URL =\" in line or \"STREAM =\" in line or \"SINCE =\" in line or \"AUTO =\" in line:\n print(line.strip())\nwith urllib.request.urlopen(base+\"/api/sidebar?current=20261025-203931-cc-live-p1-astra\", timeout=10) as r:\n d=json.load(r)\nprint(\"follow:\", d[\"current\"][\"follow\"])\nPY",
"description": "Verify injected script and follow payload"
}live script blocks: 1
leftover placeholders: []
var LIVE_URL = "/run/20261025-203931-cc-live-p1-astra/live?stream=stream.1.jsonl"; // 页面渲染时的源;排队会话为空,等侧栏下发
var SINCE = 147;
var AUTO = 1;
var STREAM = "stream.1.jsonl"; // 页面渲染用的是哪个记录文件
STREAM = stream;
LIVE_URL = url;
follow: {'stream': 'stream.1.jsonl', 'generation': 'stream.1.jsonl:163411945', 'url': '/run/20261025-203931-cc-live-p1-astra/live?stream=stream.1.jsonl'}思考
{
"command": "python3 - <<'PY'\nimport runs_data as rd, views, urllib.parse, re, urllib.request\nruns,total,counts,matched = rd.scan_runs(limit=None)\ncand = next((r for r in runs if not r.stream_files and not r.is_codex and not r.live), None)\nprint(\"candidate:\", cand and (cand.name, cand.status))\nif cand:\n run = rd.find_run(cand.name)\n html = views.page_chat(runs, total, rd.DEFAULT_RUNS_DIR, 60, run=run, replay=views.Replay())\n print(\"has controller:\", \"window.__ccLive\" in html)\n for pat in ('var LIVE_URL = \"\";', 'var STREAM = \"\";', \"var AUTO = 0;\", \"var SINCE = 0;\"):\n print(pat, pat in html)\n print(\"placeholders left:\", [p for p in (\"__LIVE_URL__\",\"__SINCE__\",\"__STREAM__\",\"__GEN__\") if p in html])\nPY",
"description": "Verify pre-provisioned controller on a streamless run"
}candidate: ('20261025-114332-scnet-sms-diag', '排队中')
has controller: True
var LIVE_URL = ""; True
var STREAM = ""; True
var AUTO = 0; True
var SINCE = 0; True
placeholders left: []思考
{
"file_path": "/Users/mengmeng/grok-cc-task/cc-live-monitor/runs_data.py",
"offset": 185,
"limit": 60
}185
186 def load_lock() -> tuple[int, str]:
187 """读取 .browser.lock,返回 (pid, run 名)。没有锁时返回 (0, "")。"""
188 try:
189 with open(os.path.join(LOCK_DIR, "pid"), encoding="utf-8") as f:
190 pid = int(f.read().strip() or 0)
191 except (OSError, ValueError):
192 return 0, ""
193 try:
194 with open(os.path.join(LOCK_DIR, "name"), encoding="utf-8") as f:
195 name = f.read().strip()
196 except OSError:
197 return pid, ""
198 return pid, name
199
200
201 # ---------- 只读安全:真实路径必须落在 runs 根目录内,且不跟符号链接 ----------
202
203 def _within(root: str, target: str) -> bool:
204 """target 是否等于 root 或落在 root 里面(两边都必须是 realpath)。"""
205 try:
206 return os.path.commonpath([root, target]) == root
207 except ValueError: # 不同盘 / 空路径
208 return False
209
210
211 def _is_regular(path: str) -> bool:
212 """普通文件才放行;符号链接在 lstat 里是 S_IFLNK,直接排除。"""
213 try:
214 st = os.lstat(path)
215 except OSError:
216 return False
217 return stat.S_ISREG(st.st_mode)
218
219
220 def _read_head_tail(path: str, head: int = 4096, tail: int = 8192) -> str:
221 """只读文件头尾,避免把几十 MB 的 stream 读进来。"""
222 try:
223 size = os.path.getsize(path)
224 with open_nofollow(path) as f:
225 head_bytes = f.read(min(head, size))
226 if size > head + tail:
227 f.seek(size - tail)
228 tail_bytes = f.read(tail)
229 data = head_bytes + b"\n...\n" + tail_bytes
230 else:
231 data = head_bytes + (f.read() if size > head else b"")
232 except OSError:
233 return ""
234 return data.decode("utf-8", errors="replace")
235
236
237 def read_text(path: str, limit: int = MAX_PREVIEW_BYTES) -> tuple[str, int, bool]:
238 """读取文本文件,返回 (内容, 总字节数, 是否被截断)。不跟符号链接。"""
239 try:
240 total = os.path.getsize(path)
241 except OSError as exc:
242 return f"读取失败:{exc}", 0, False
243 try:
244 with open_nofollow(path) as f:{
"file_path": "/Users/mengmeng/grok-cc-task/cc-live-monitor/assets.py",
"offset": 615,
"limit": 70
}615 setNote('连接断开,' + Math.round(backoff / 1000) + ' 秒后重连…', 'live-off');
616 renderDraft('');
617 window.setTimeout(connect, backoff);
618 backoff = Math.min(backoff * 2, 10000);
619 };
620 }
621
622 // 合成气泡(页面预渲染的 / SSE 结束时临时插的)不在流里:接着追之前先删掉,
623 // 否则会跟真正落定的事件重复。
624 function clearSynthetic() {
625 if (!chat) return;
626 Array.prototype.forEach.call(
627 chat.querySelectorAll('.ev-synthetic, .ev-incomplete'),
628 function (w) { if (w.parentNode) w.parentNode.removeChild(w); }
629 );
630 }
631
632 // 换到新的尝试文件:旧文件的 data-seq 会和新文件撞号,整段清掉从头追
633 function resetThread(streamName) {
634 if (chat) {
635 Array.prototype.forEach.call(chat.querySelectorAll('.ev[data-seq]'), function (w) {
636 if (w.parentNode) w.parentNode.removeChild(w);
637 });
638 }
639 clearSynthetic();
640 var ph = chat && chat.querySelector('.empty');
641 if (ph) ph.parentNode.removeChild(ph);
642 maxSeq = -1;
643 renderDraft('');
644 if (chat && streamName) {
645 var note = document.createElement('div');
646 note.className = 'msg-note ev-switch';
647 note.textContent = '已切换到新的尝试:' + streamName;
648 chat.insertBefore(note, draftBox || null);
649 }
650 }
651
652 // 侧栏轮询发现可跟随的源(首次出现 / 重试换文件)时调用;opts 来自 /api/sidebar
653 function start(opts) {
654 opts = opts || {};
655 var url = opts.url || LIVE_URL;
656 if (!url) return; // 还没有 stream:等下一轮轮询
657 var stream = opts.stream || STREAM;
658 var gen = opts.generation || (stream === STREAM ? PAGE_GEN : '');
659 var switching = !!gen && !!activeGen && gen !== activeGen;
660 if (source && !switching) return; // 已经连着同一个源
661
662 if (stream && stream !== STREAM) {
663 resetThread(stream); // 重试换了文件:游标归零,从头追
664 STREAM = stream;
665 pending = 0;
666 updateJump();
667 } else if (switching) {
668 resetThread('');
669 }
670 LIVE_URL = url;
671 if (gen) activeGen = gen;
672 stopped = false;
673 backoff = 1000;
674 clearSynthetic();
675 renderDraft('');
676 hidePager();
677 markRunning();
678 setNote('已开始实时', 'live-on');
679 connect();
680 }
681
682 window.__ccLive = {
683 start: start,
684 isOn: function () { return !!source && !stopped; },思考
{
"replace_all": false,
"file_path": "/Users/mengmeng/grok-cc-task/cc-live-monitor/assets.py",
"old_string": " if (chat) {\n Array.prototype.forEach.call(chat.querySelectorAll('.ev[data-seq]'), function (w) {\n if (w.parentNode) w.parentNode.removeChild(w);\n });\n }\n clearSynthetic();",
"new_string": " if (chat) {\n Array.prototype.forEach.call(\n chat.querySelectorAll('.ev[data-seq], .ev-switch'),\n function (w) { if (w.parentNode) w.parentNode.removeChild(w); }\n );\n }\n clearSynthetic();"
}The file /Users/mengmeng/grok-cc-task/cc-live-monitor/assets.py has been updated successfully.
思考
{
"command": "python3 selftest.py 2>&1 | tail -3 && python3 - <<'PY'\nimport assets, subprocess, tempfile, os\njs = assets.LIVE_JS.replace('__LIVE_URL__','\"/x\"').replace('__SINCE__','1').replace('__AUTO__','0').replace('__STREAM__','\"\"').replace('__GEN__','\"\"')\np = tempfile.NamedTemporaryFile(\"w\", suffix=\".js\", delete=False); p.write(js); p.close()\nr = subprocess.run([\"node\",\"--check\",p.name], capture_output=True, text=True)\nprint(\"live js\", \"OK\" if r.returncode==0 else r.stderr[:400]); os.unlink(p.name)\nPY",
"description": "Final selftest + JS check"
}ok start 支持换源并重置游标 PASS — Phase 7.1 自测全部通过 live js OK
{
"command": "lsof -ti tcp:8787 -sTCP:LISTEN | xargs kill; sleep 1; nohup python3 server.py >> server.log 2>&1 & sleep 2; lsof -nP -iTCP:8787 -sTCP:LISTEN | tail -1; curl -s -m 5 http://127.0.0.1:8787/healthz; curl -s -m 8 'http://127.0.0.1:8787/api/sidebar?current=20261025-203931-cc-live-p1-astra' | python3 -c 'import json,sys;d=json.load(sys.stdin);print(d[\"current\"][\"live\"], d[\"current\"][\"follow\"][\"stream\"], d[\"current\"][\"follow\"][\"generation\"])'",
"description": "Restart service and verify"
}Python 25013 mengmeng 3u IPv4 0x957d957cfcecb14b 0t0 TCP *:8787 (LISTEN) ok True stream.1.jsonl stream.1.jsonl:163411945
思考
{
"command": "bash -n ../run.sh && echo \"run.sh OK\"; python3 -m py_compile md_lite.py runs_data.py stream_parse.py codex_parse.py views.py server.py selftest.py && echo \"python OK\"",
"description": "Syntax check shell and python"
}run.sh OK python OK
思考
直接执行
5 个 P1 全部改完,python3 selftest.py 全绿(新增第 [8] 组 26 条断言),服务已重启在 0.0.0.0:8787。
改了哪些文件
| 文件 | 改动 |
|------|------|
| md_lite.py | 新增 _attr():URL 先 html.unescape 再按属性规则 escape(quote=True),剥控制字符;链接改用 _attr() |
| runs_data.py | 新增 _within / _is_regular / resolve_lock_run / follow_stream / stream_generation;FileInfo.ino;find_run 真实路径校验;safe_file 拒链接;build_run 只认精确锁名 |
| stream_parse.py | Timeline.tail(合成气泡不占序号);open_nofollow();读取与 tail 走 O_NOFOLLOW |
| codex_parse.py | 3 处读取改 open_nofollow() |
| views.py | 合成气泡独立渲染(ev-incomplete ev-synthetic + data-synthetic,无 data-seq);live_url() / follow_generation();排队会话也注入控制器;sidebar_sig 计入 generation |
| server.py | _follow_payload();/api/sidebar 下发 follow{stream,generation,url};默认跟随改为「运行中取最近写入」 |
| assets.py | SIDEBAR_JS 带 generation 调 start(follow);LIVE_JS.start(opts) 换源、清旧 attempt 的 data-seq、游标归零;clearSynthetic();stopped() 供重新接上 |
| run.sh | 锁写完整 run 目录名(${RUN_DIR##*/}) |
| selftest.py | 新增 fixture:同名历史(成功 + 未结束)、符号链接文件/目录/根外目录;新增 [8] 组断言 + 接口侧断言 |
| README.md / PROGRESS.md | Phase 7.1 条目、路由表、完整记录 |
每个 P1 怎么验的
P1-1:md_lite.render('[x](https://example.com/"onmouseover="window.name=1)') → 输出 href="..."onmouseover="window.name=1",断言 onmouseover=" 不存在;单引号同样编码;再断言 ?a=1&b=2 不会变 &amp;、链接文字 a<b 不变 &lt;(无双转义);javascript: 不生成 <a>。
P1-2:fixture 里放 leak.txt -> 根目录外/secret.txt、sub -> 根目录外/、runs/…-escape-link -> 根目录外/。断言 safe_file 对前两者返回 None、正常 task.txt 仍可读、find_run 不认链接目录、read_text(O_NOFOLLOW)读不到 TOP-SECRET;HTTP /run/<name>/file/leak.txt 返回 404。
P1-3:fixture 造两个同后缀 legacy-demo(旧的 done rc=0、新的未结束)。短名锁下只有新的变 live,旧的不被点亮;短名 success-demo 命中终态 → 仍不 live;完整目录名锁能点亮「失败后重试中」的 run。真实环境用当前旧短名锁 cc-live-p1-astra(pid 21244 活着)跑 scan_runs:318 个目录里 running=1,只解析出 20261025-203931-cc-live-p1-astra。
P1-4:parse_stream 的合成气泡改挂 Timeline.tail,断言 total == 1(真实事件)且 events 里没有 incomplete;页面渲染出 ev-synthetic 标记、var SINCE = 1;(不含合成气泡);再模拟「页面渲染 1 条后文件追加 1 条」,events_from(1) 正好拿到那条新事件;断言 LIVE_JS 里 clearSynthetic 会删 .ev-synthetic / .ev-incomplete。
P1-5:单元断言 follow_stream 运行中取最近写入(stream.2)、结束后取最大(stream.1),stream_generation 随文件变化;接口断言 live 会话下发 follow.stream/generation/url、已结束会话为空;排队会话页面注入 window.__ccLive + var LIVE_URL = ""; var STREAM = ""; var AUTO = 0;;真实排队 run 20261025-114332-scnet-sms-diag 页面同样预置控制器。SSE 实测:/run/…-p1-astra 注入 SINCE=147,curl -N .../live?since=147 第一条 append 就是 seq: 147。
深度交互
generation用 inode 而不是 mtime/size:如果用体积做代号,侧栏每 4.5 秒都会看到「源变了」,前端就会不停重启 SSE。inode 在追加写时不变、换文件才变,正好是「attempt 身份」的语义。代价是跨文件系统/极端场景 inode 可能复用,但目录内短期重试基本不会撞。
- 换源时我选择清掉旧 attempt 的对话,只留
task.txt气泡加一行「已切换到新的尝试」。原因是data-seq是裸整数,两个文件的序号必然撞号,update事件会改错气泡。更彻底的做法是把 seq 做成generation:index,代价是 SSE 协议和since游标要一起改;当前这版更小、更可预测。如果你更想「两次尝试并排留着看」,那就得走复合 seq,是另一个量级的改动。
- 旧短名锁的兼容逻辑仍然要读候选的
runner.log(每个/api/sidebar轮询一次)。这次run.sh已改成写完整目录名,所以下一次运行起就是 O(1) 精确匹配,兼容分支只在旧锁存在时(也就是当前这个会话)走。等这轮跑完、新锁格式生效,这段开销自动消失。
O_NOFOLLOW只挡最后一跳:父目录若是链接,realpath校验已经把它收敛回根目录内,所以不需要openat逐级校验。真要防 TOCTOU 全链路,得上openat(O_NOFOLLOW)逐级打开——对这个「本机只读面板、runs 目录自己人写」的场景属于过度设计。
直接执行
5 个 P1 全部改完,python3 selftest.py 全绿(新增第 [8] 组 26 条断言),服务已重启在 0.0.0.0:8787。
改了哪些文件
| 文件 | 改动 |
|------|------|
| md_lite.py | 新增 _attr():URL 先 html.unescape 再按属性规则 escape(quote=True),剥控制字符;链接改用 _attr() |
| runs_data.py | 新增 _within / _is_regular / resolve_lock_run / follow_stream / stream_generation;FileInfo.ino;find_run 真实路径校验;safe_file 拒链接;build_run 只认精确锁名 |
| stream_parse.py | Timeline.tail(合成气泡不占序号);open_nofollow();读取与 tail 走 O_NOFOLLOW |
| codex_parse.py | 3 处读取改 open_nofollow() |
| views.py | 合成气泡独立渲染(ev-incomplete ev-synthetic + data-synthetic,无 data-seq);live_url() / follow_generation();排队会话也注入控制器;sidebar_sig 计入 generation |
| server.py | _follow_payload();/api/sidebar 下发 follow{stream,generation,url};默认跟随改为「运行中取最近写入」 |
| assets.py | SIDEBAR_JS 带 generation 调 start(follow);LIVE_JS.start(opts) 换源、清旧 attempt 的 data-seq、游标归零;clearSynthetic();stopped() 供重新接上 |
| run.sh | 锁写完整 run 目录名(${RUN_DIR##*/}) |
| selftest.py | 新增 fixture:同名历史(成功 + 未结束)、符号链接文件/目录/根外目录;新增 [8] 组断言 + 接口侧断言 |
| README.md / PROGRESS.md | Phase 7.1 条目、路由表、完整记录 |
每个 P1 怎么验的
P1-1:md_lite.render('[x](https://example.com/"onmouseover="window.name=1)') → 输出 href="..."onmouseover="window.name=1",断言 onmouseover=" 不存在;单引号同样编码;再断言 ?a=1&b=2 不会变 &amp;、链接文字 a<b 不变 &lt;(无双转义);javascript: 不生成 <a>。
P1-2:fixture 里放 leak.txt -> 根目录外/secret.txt、sub -> 根目录外/、runs/…-escape-link -> 根目录外/。断言 safe_file 对前两者返回 None、正常 task.txt 仍可读、find_run 不认链接目录、read_text(O_NOFOLLOW)读不到 TOP-SECRET;HTTP /run/<name>/file/leak.txt 返回 404。
P1-3:fixture 造两个同后缀 legacy-demo(旧的 done rc=0、新的未结束)。短名锁下只有新的变 live,旧的不被点亮;短名 success-demo 命中终态 → 仍不 live;完整目录名锁能点亮「失败后重试中」的 run。真实环境用当前旧短名锁 cc-live-p1-astra(pid 21244 活着)跑 scan_runs:318 个目录里 running=1,只解析出 20261025-203931-cc-live-p1-astra。
P1-4:parse_stream 的合成气泡改挂 Timeline.tail,断言 total == 1(真实事件)且 events 里没有 incomplete;页面渲染出 ev-synthetic 标记、var SINCE = 1;(不含合成气泡);再模拟「页面渲染 1 条后文件追加 1 条」,events_from(1) 正好拿到那条新事件;断言 LIVE_JS 里 clearSynthetic 会删 .ev-synthetic / .ev-incomplete。
P1-5:单元断言 follow_stream 运行中取最近写入(stream.2)、结束后取最大(stream.1),stream_generation 随文件变化;接口断言 live 会话下发 follow.stream/generation/url、已结束会话为空;排队会话页面注入 window.__ccLive + var LIVE_URL = ""; var STREAM = ""; var AUTO = 0;;真实排队 run 20261025-114332-scnet-sms-diag 页面同样预置控制器。SSE 实测:/run/…-p1-astra 注入 SINCE=147,curl -N .../live?since=147 第一条 append 就是 seq: 147。
深度交互
generation用 inode 而不是 mtime/size:如果用体积做代号,侧栏每 4.5 秒都会看到「源变了」,前端就会不停重启 SSE。inode 在追加写时不变、换文件才变,正好是「attempt 身份」的语义。代价是跨文件系统/极端场景 inode 可能复用,但目录内短期重试基本不会撞。
- 换源时我选择清掉旧 attempt 的对话,只留
task.txt气泡加一行「已切换到新的尝试」。原因是data-seq是裸整数,两个文件的序号必然撞号,update事件会改错气泡。更彻底的做法是把 seq 做成generation:index,代价是 SSE 协议和since游标要一起改;当前这版更小、更可预测。如果你更想「两次尝试并排留着看」,那就得走复合 seq,是另一个量级的改动。
- 旧短名锁的兼容逻辑仍然要读候选的
runner.log(每个/api/sidebar轮询一次)。这次run.sh已改成写完整目录名,所以下一次运行起就是 O(1) 精确匹配,兼容分支只在旧锁存在时(也就是当前这个会话)走。等这轮跑完、新锁格式生效,这段开销自动消失。
O_NOFOLLOW只挡最后一跳:父目录若是链接,realpath校验已经把它收敛回根目录内,所以不需要openat逐级校验。真要防 TOCTOU 全链路,得上openat(O_NOFOLLOW)逐级打开——对这个「本机只读面板、runs 目录自己人写」的场景属于过度设计。