最近深度使用 Claude Code,体验是真不错,但有个痛点:任务一跑十几分钟,人不可能干等着。起身倒杯水、回个消息,回来发现它早就停在"是否允许执行 Bash"上干等我了,白白空转半天。

先是用 Notification hook 接了 iMessage 提醒,能用,但不够——提醒是"叫你一下",我想要的是抬头一瞥就知道现在什么状态。翻箱倒柜找出一台吃灰多年的 iPhone 6,思路就来了:做个网页红绿灯,常亮摆在显示器旁边。红灯闪 = 有任务等我确认,黄灯 = 正在干活,绿灯 = 干完了。

红绿灯.jpg

整体思路

一开始想在 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 deploy

2. 挂 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. 手机设置

  1. Safari 打开 https://status.example.com/?k=<你生成的密钥>
  2. 分享按钮 → 添加到主屏幕,从主屏幕进就是全屏无地址栏
  3. 设置 -> 显示与亮度 -> 自动锁定 -> 永不(找不到"永不"选项的,先去关掉低电量模式)
  4. 插上电,摆到显示器旁边,收工

踩坑记录

都是实测踩出来的,按杀伤力排序。

1. 页面会假死,而且死了还骗人。 最大的坑。红灯挂着闪了 4 个小时,凑近一看数据根本没更新——CSS 动画是 GPU 合成器画的,JS 引擎被系统冻结了它照样闪。iPhone 6 就 1GB 内存,最初每 2.5 秒推倒重建一次 DOM,几个小时的内存搅动直接把网页进程搅死。修复四板斧(上面代码里都有):

  1. 数据没变化就不动 DOM,只原地更新时间文字
  2. fetch 换成带 8 秒超时的 XHR,挂起请求不堆积
  3. 每 6 小时自动整页 reload,重置内存(断网时会推迟,避免 reload 卡在错误页)
  4. 底部加一行 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 是免费的)。

标签: claude, claude code

添加新评论