吃灰iPhone 6再就业:给Claude Code做个红绿灯看板
最近深度使用 Claude Code,体验是真不错,但有个痛点:任务一跑十几分钟,人不可能干等着。起身倒杯水、回个消息,回来发现它早就停在"是否允许执行 Bash"上干等我了,白白空转半天。
先是用 Notification hook 接了 iMessage 提醒,能用,但不够——提醒是"叫你一下",我想要的是抬头一瞥就知道现在什么状态。翻箱倒柜找出一台吃灰多年的 iPhone 6,思路就来了:做个网页红绿灯,常亮摆在显示器旁边。红灯闪 = 有任务等我确认,黄灯 = 正在干活,绿灯 = 干完了。

整体思路
一开始想在 Mac 上起个局域网 server,后来觉得多余——直接白嫖 Cloudflare Worker + D1,手机打开域名就能看,Mac 上一个常驻进程都不用跑,出门在外也能瞄一眼家里的任务:
Claude Code hooks --curl--> Cloudflare Worker (D1) <--轮询-- 手机网页数据模型学的是发帖子:hook 每次触发就往上 POST 一条,每个项目只显示最新一条,新消息自动把旧的顶掉,完全不需要管会话生命周期,也不需要任何配置——session_id、项目路径都是 hook 输入里自带的。
事件和灯色的映射:
| Hook 事件 | 灯色 | 含义 |
|---|---|---|
| UserPromptSubmit | 🟡 黄 | 正在干活 |
| Notification(要授权) | 🔴 红·整块闪烁·置顶 | 等你确认 |
| Stop / 等输入 | 🟢 绿 | 干完了 |
页面上每个项目一条全宽色块,整块就是灯的颜色,红块闪烁且永远排第一,干完超过 3 小时的自动变暗,横屏自动变两列。
部署
三步,全程白嫖免费额度。
1. Worker + D1
npx wrangler login
npx wrangler d1 create claude-status
npx wrangler d1 execute claude-status --remote --command \
"CREATE TABLE IF NOT EXISTS sessions (session_id TEXT PRIMARY KEY, project TEXT, cwd TEXT, state TEXT, message TEXT, ts INTEGER)"wrangler.toml(database_id 换成上一步输出的,TOKEN 用 openssl rand -hex 12 自己生成一个):
name = "claude-status"
main = "worker.js"
compatibility_date = "2026-07-01"
# 有自己域名托管在 CF 的话,顺手套一个
routes = [
{ pattern = "status.example.com", custom_domain = true }
]
[vars]
TOKEN = "<你生成的密钥>"
[[d1_databases]]
binding = "DB"
database_name = "claude-status"
database_id = "<你的database_id>"worker.js 完整代码(三个路由:上报、查询、页面,页面 HTML 直接内嵌,单文件搞定):
// POST /api/report hook 提交事件(Bearer token)
// GET /api/status 网页轮询,每个项目(按 cwd)只返回最新一条
// GET / 红绿灯页面(?k=token)
const HTML = `<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1, user-scalable=no">
<meta name="apple-mobile-web-app-capable" content="yes">
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
<title>Claude 红绿灯</title>
<style>
/* 面向 iOS 12 Safari:不用 flex gap / aspect-ratio / :is() 等新特性 */
html, body { height: 100%; }
body {
margin: 0;
background: #0b0d10;
color: #e8eaed;
font-family: -apple-system, "PingFang SC", sans-serif;
-webkit-user-select: none;
-webkit-tap-highlight-color: transparent;
}
#banner {
display: none;
position: fixed; top: 0; left: 0; right: 0;
padding: 28px 12px 8px;
background: #7f1d1d; color: #fff;
text-align: center; font-size: 15px;
z-index: 10;
}
#app { padding: 30px 12px 44px; }
/* 每个项目一条全宽色块,整块就是灯 */
.card {
border-radius: 16px;
padding: 20px 18px;
margin-bottom: 12px;
}
.card .project { font-size: 24px; font-weight: 700; margin-bottom: 4px; }
.card .meta { font-size: 16px; opacity: .85; }
.card.working { background: #f5c400; color: #201a00; }
.card.done { background: #1e8a45; color: #eafff0; }
.card.blocked {
background: #e0362b; color: #fff;
animation: blink .9s steps(1, end) infinite;
}
.card.blocked .project { font-size: 28px; }
/* 干完超过 3 小时的项目变暗保留 */
.card.stale { opacity: .35; }
/* 硬闪烁:亮/灭各半个周期,需要确认时隔着房间都能看见 */
@keyframes blink {
0%, 49% { opacity: 1; }
50%, 100% { opacity: .15; }
}
#empty { text-align: center; color: #6b7078; font-size: 18px; padding-top: 40vh; }
/* JS 驱动的心跳行:页面假死时它会停(CSS 闪烁是 GPU 画的,停不下来,会骗人) */
#beat {
position: fixed; bottom: 0; left: 0; right: 0;
padding: 6px 0 8px;
text-align: center; font-size: 12px; color: #565b63;
background: rgba(11,13,16,.85);
}
/* 横屏:两列色块(iOS 12 无 flex gap,用 margin 排) */
@media (orientation: landscape) {
#app { display: flex; flex-wrap: wrap; align-items: flex-start; padding: 20px 8px 36px; }
.card {
width: calc(50% - 8px);
box-sizing: border-box;
margin: 0 4px 8px;
padding: 16px 14px;
}
.card .project { font-size: 22px; }
.card.blocked .project { font-size: 24px; }
#empty { width: 100%; padding-top: 30vh; }
}
</style>
<div id="banner">⚠️ 连不上服务器,显示的是最后一次的状态</div>
<div id="app"><div id="empty">还没有任务</div></div>
<div id="beat">等待首次连接…</div>
<script>
var m = location.search.match(/[?&]k=([^&]*)/);
var KEY = m ? m[1] : "";
var skew = 0; // 服务器时间 - 手机时间,算"几分钟前"以服务器为准
var lastFingerprint = ""; // 上次渲染的数据指纹,数据没变就不动 DOM
var lastOkAt = 0; // 最近一次成功拉到数据的本机时间
var startAt = Date.now();
var RELOAD_AFTER = 6 * 3600 * 1000; // 每 6 小时整页重载一次,重置内存
var live = []; // 当前卡片的 {card, timeEl, ts, state},原地更新时间用
function fmtAgo(ts) {
var sec = Math.round((Date.now() + skew) / 1000 - ts);
if (sec < 60) return "刚刚";
if (sec < 3600) return Math.floor(sec / 60) + " 分钟前";
if (sec < 86400) return Math.floor(sec / 3600) + " 小时前";
return Math.floor(sec / 86400) + " 天前";
}
function el(tag, cls, text) {
var e = document.createElement(tag);
if (cls) e.className = cls;
if (text) e.textContent = text;
return e;
}
function banner(show) {
document.getElementById("banner").style.display = show ? "block" : "none";
}
function render(list) {
var app = document.getElementById("app");
app.innerHTML = "";
live = [];
if (!list.length) {
var e = el("div", "", "还没有任务");
e.id = "empty";
app.appendChild(e);
return;
}
for (var i = 0; i < list.length; i++) {
var s = list[i];
var card = el("div", "card " + s.state);
card.appendChild(el("div", "project", s.project));
var meta = el("div", "meta");
meta.appendChild(document.createTextNode(s.message + " · "));
var t = el("span", "", fmtAgo(s.ts));
meta.appendChild(t);
card.appendChild(meta);
app.appendChild(card);
live.push({ card: card, timeEl: t, ts: s.ts, state: s.state });
}
refreshTimes();
}
// 只原地改时间文字和 stale 类,不重建 DOM —— 老设备防内存搅动的关键
function refreshTimes() {
var staleBefore = (Date.now() + skew) / 1000 - 3 * 3600;
for (var i = 0; i < live.length; i++) {
var it = live[i];
it.timeEl.textContent = fmtAgo(it.ts);
var stale = it.state === "done" && it.ts < staleBefore;
var cls = "card " + it.state + (stale ? " stale" : "");
if (it.card.className !== cls) it.card.className = cls;
}
}
// XHR 带超时,挂起的请求会被掐掉,不会在连接池里堆积
function poll() {
var xhr = new XMLHttpRequest();
xhr.open("GET", "/api/status?k=" + KEY + "&_=" + Date.now(), true);
xhr.timeout = 8000;
xhr.onload = function () {
var d = null;
try { d = JSON.parse(xhr.responseText); } catch (e) {}
if (xhr.status !== 200 || !d) { banner(true); return; }
skew = d.now * 1000 - Date.now();
lastOkAt = Date.now();
banner(false);
var fp = JSON.stringify(d.sessions);
if (fp !== lastFingerprint) {
lastFingerprint = fp;
render(d.sessions || []);
}
};
xhr.onerror = xhr.ontimeout = function () { banner(true); };
xhr.send();
}
function beat() {
var e = document.getElementById("beat");
if (!lastOkAt) {
e.textContent = "等待首次连接…";
} else {
var sec = Math.round((Date.now() - lastOkAt) / 1000);
e.textContent = "最后更新:" + (sec <= 3 ? "刚刚" : sec + " 秒前");
}
// 到点且连接健康才整页重载;断网时推迟,免得重载卡在浏览器错误页回不来
if (Date.now() - startAt > RELOAD_AFTER &&
lastOkAt && Date.now() - lastOkAt < 10000) {
location.reload();
}
}
poll();
setInterval(poll, 2500);
setInterval(function () { refreshTimes(); beat(); }, 1000);
</script>
`;
function json(obj, status) {
return new Response(JSON.stringify(obj), {
status: status || 200,
headers: {
"Content-Type": "application/json; charset=utf-8",
"Cache-Control": "no-store",
},
});
}
export default {
async fetch(request, env) {
const url = new URL(request.url);
const bearer = (request.headers.get("Authorization") || "").replace("Bearer ", "");
const key = url.searchParams.get("k") || bearer;
if (key !== env.TOKEN) {
return new Response("forbidden", { status: 403 });
}
if (url.pathname === "/api/report" && request.method === "POST") {
let e;
try {
e = await request.json();
} catch (err) {
return json({ error: "bad json" }, 400);
}
if (!e.session_id || !e.state) {
return json({ error: "missing fields" }, 400);
}
await env.DB.prepare(
`INSERT INTO sessions (session_id, project, cwd, state, message, ts)
VALUES (?1, ?2, ?3, ?4, ?5, ?6)
ON CONFLICT(session_id) DO UPDATE SET
project = ?2, cwd = ?3, state = ?4, message = ?5, ts = ?6`
).bind(
String(e.session_id),
String(e.project || "未知项目"),
String(e.cwd || ""),
String(e.state),
String(e.message || ""),
Number(e.ts) || Math.floor(Date.now() / 1000)
).run();
// 顺手清掉一周前的老会话
await env.DB.prepare("DELETE FROM sessions WHERE ts < ?1")
.bind(Math.floor(Date.now() / 1000) - 7 * 86400).run();
return json({ ok: true });
}
if (url.pathname === "/api/status") {
// 每个项目(按 cwd 分组)只取最新一条,新事件自然顶走旧会话的
const rs = await env.DB.prepare(
`SELECT session_id, project, state, message, ts FROM (
SELECT *, ROW_NUMBER() OVER (
PARTITION BY COALESCE(NULLIF(cwd, ''), session_id)
ORDER BY ts DESC
) AS rn FROM sessions
) WHERE rn = 1
ORDER BY (state = 'blocked') DESC, ts DESC LIMIT 20`
).all();
return json({ now: Date.now() / 1000, sessions: rs.results });
}
if (url.pathname === "/") {
return new Response(HTML, {
headers: {
"Content-Type": "text/html; charset=utf-8",
"Cache-Control": "no-store",
},
});
}
return new Response("not found", { status: 404 });
},
};npx wrangler deploy2. 挂 hook
每台跑 Claude Code 的机器上执行一遍下面这段(需要 jq,Mac 没有的话 brew install jq;重复运行不会重复添加),URL 和 TOKEN 换成自己的:
mkdir -p ~/.claude/hooks
cat > ~/.claude/hooks/status-feed.sh <<'HOOK'
#!/bin/bash
# Claude Code 状态 hook:把会话事件发到红绿灯看板
URL="https://status.example.com/api/report"
TOKEN="<你生成的密钥>"
INPUT=$(cat)
EVENT=$(jq -r '.hook_event_name // empty' <<<"$INPUT" 2>/dev/null)
MSG=$(jq -r '.message // empty' <<<"$INPUT" 2>/dev/null)
case "$EVENT" in
UserPromptSubmit)
STATE=working; TEXT="正在干活" ;;
Stop)
STATE=done; TEXT="干完了,等你下一步" ;;
Notification)
case "$MSG" in
*"needs your permission"*)
STATE=blocked
TOOL=${MSG#*permission to use }
if [ "$TOOL" = "$MSG" ]; then TEXT="等你授权"; else TEXT="等你授权:$TOOL"; fi ;;
*"waiting for your input"*)
STATE=done; TEXT="等你指令" ;;
*)
STATE=blocked; TEXT="${MSG:-需要你看一眼}" ;;
esac ;;
*)
exit 0 ;;
esac
JSON=$(jq -c -n \
--arg sid "$(jq -r '.session_id // empty' <<<"$INPUT" 2>/dev/null)" \
--arg cwd "$(jq -r '.cwd // empty' <<<"$INPUT" 2>/dev/null)" \
--arg state "$STATE" \
--arg text "$TEXT" \
--argjson ts "$(date +%s)" \
'{session_id: $sid, cwd: $cwd,
project: (if $cwd == "" then "未知项目" else ($cwd | split("/") | last) end),
state: $state, message: $text, ts: $ts}')
curl -s -m 8 -X POST \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d "$JSON" "$URL" >/dev/null 2>&1
exit 0
HOOK
chmod +x ~/.claude/hooks/status-feed.sh
S=~/.claude/settings.json
[ -f "$S" ] || echo '{}' > "$S"
if grep -q 'status-feed.sh' "$S"; then
echo "已配置过,跳过 settings.json"
else
H="{\"type\":\"command\",\"command\":\"$HOME/.claude/hooks/status-feed.sh 2>/dev/null || true\",\"async\":true,\"timeout\":10}"
jq --argjson h "$H" '
.hooks //= {} |
.hooks.UserPromptSubmit = ((.hooks.UserPromptSubmit // []) + [{"hooks":[$h]}]) |
.hooks.Stop = ((.hooks.Stop // []) + [{"hooks":[$h]}]) |
.hooks.Notification = ((.hooks.Notification // []) + [{"hooks":[$h]}])
' "$S" > "$S.tmp" && mv "$S.tmp" "$S"
echo "配置完成"
fi注意 hook 对新开的会话才生效,已经开着的窗口重启一下会话。
3. 手机设置
- Safari 打开
https://status.example.com/?k=<你生成的密钥> - 分享按钮 →
添加到主屏幕,从主屏幕进就是全屏无地址栏 设置->显示与亮度->自动锁定->永不(找不到"永不"选项的,先去关掉低电量模式)- 插上电,摆到显示器旁边,收工
踩坑记录
都是实测踩出来的,按杀伤力排序。
1. 页面会假死,而且死了还骗人。 最大的坑。红灯挂着闪了 4 个小时,凑近一看数据根本没更新——CSS 动画是 GPU 合成器画的,JS 引擎被系统冻结了它照样闪。iPhone 6 就 1GB 内存,最初每 2.5 秒推倒重建一次 DOM,几个小时的内存搅动直接把网页进程搅死。修复四板斧(上面代码里都有):
- 数据没变化就不动 DOM,只原地更新时间文字
- fetch 换成带 8 秒超时的 XHR,挂起请求不堆积
- 每 6 小时自动整页 reload,重置内存(断网时会推迟,避免 reload 卡在错误页)
- 底部加一行 JS 驱动的"最后更新:刚刚"——判断页面死活看这行字,别信闪烁动画
2. iOS 12 的 Safari 是个老古董。 ?.、??、flex 的 gap 统统不能用,写之前先查 caniuse。经过实测 fetch、CSS 变量、calc()、媒体查询都没问题。
3. 存储别选 KV。 免费版 KV 每天只有 1000 次写,Claude 用得勤的话一天几百个 hook 事件,贴线。D1 免费 10 万写/天,随便造。
4. 轮询频率算着来。 Workers 免费 10 万请求/天,1 秒一轮就是 8.6 万,太极限;2.5 秒一轮不到 3.5 万,看灯这种场景完全无感。
效果
现在这台 2014 年的老同志天天在工位上站岗,哪个项目在等确认,红块隔着半个屋子闪给你看。成本:0 元(手机是旧的,Cloudflare 是免费的)。