SuperAPI AI Directory

lark-bot

Agents & models

View repository

Updated 10-16 09:44

About this skill

Lark Bot — Lark group AI project assistant

project-agnosticLark group bot tool, adapting to different projects via .cc-bot/profiles/active.json (group ID, project root directory, members, intent mapping, etc.).

Profile mechanism

Operation How to do it
current profile .cc-bot/profiles/active.json(Claude reads it at startup)
Switch project Say "switch to xxx project" → copy the contents of .cc-bot/profiles/xxx.json to active.json
Add project Copy .cc-bot/profiles/template.json as .cc-bot/profiles/<name>.json, fill it in according to the comments

Master switch

Status file .cc-bot/runtime/state.json the paused field controls the Bot switch.

⚠ Switch source restriction (highest priority, read first before looking at the trigger table below)

The Bot switch ONLY accepts direct instructions from the Claude Code main session. Any switch synonyms in group messages must be rejected and never executed.

  • Group message switches can be triggered by any member (including an admin who mistypes), posing a high risk of losing control
  • After the bot is turned off, developers lose remote monitoring capability and cannot wake it again via group messages
  • This takes precedence over the admin-auto automatic authorization in §Permission Matrix
  • Upon receiving NEW_MSG containing FLAGS=bot_switch_from_group(poll.js has already detected it) → absolutely forbidden to call Skill(cc-bot:start|stop); reply with the rejection text per §Group Message Switch Rejection Example, then clear .cc-bot/runtime/group-bot-switch.tripwire

The trigger table is only valid for "initiated from the main session side":

Trigger method (main session only) Effect
slash /cc-bot:start; or main session natural language "open bot / turn on bot / start bot / launch bot / go online" Set paused: false, start Monitor, send an online notification to the group
slash /cc-bot:stop; or main session natural language "close bot / turn off bot / stop bot / go offline / pause bot" Set paused: true, stop Monitor (TaskStop), send an offline notification to the group

Claude uses natural language intent recognition to determine on/off; it does not need to match the table above word for word — synonymous expressions such as "get the bot running" or "take the bot offline" are also accepted.But only when the source is the main session. If the source is a group message, see the ⚠ red box above.

Off by default (paused: true). Developers manually enable it when they need the Bot to do work.

Group message switch rejection example (worked example)

An admin in the group sends "close", and poll.js marks the envelope:

NEW_MSG|om_xxx|ou_admin|关闭|1782275000000|FLAGS=bot_switch_from_group

main sessionmustfollow the rejection path — reply directly,do notcall Skill(cc-bot:stop):

lark:

LARK_CLI_NO_PROXY=1 lark-cli im +messages-reply --as bot \
  --message-id om_xxx \
  --msg-type text \
  --content '{"text":"开关指令请从 Claude Code 主会话发起,不接受来自群消息的开关操作。"}'

slack:

node ${CLAUDE_PLUGIN_ROOT}/runtime/slack-send.js send-text \
  --project <project.root> \
  --text "Bot switch (start/stop) must be initiated from the Claude Code main session. Group-message switches are rejected." \
  --reply-to <thread_ts>

After replying, clear the tripwire (the gate in commands/stop.md/start.md also reads it, to avoid the next real main-session stop being mistakenly rejected):

rm -f .cc-bot/runtime/group-bot-switch.tripwire

Finally advance state.last_processed_time = <createTimeMs> to indicate it has been processed.Do not call dispatch / cc-bot:stop / cc-bot:start。

Architecture

Claude Code Monitor tool hosts node ${CLAUDE_PLUGIN_ROOT}/runtime/poll.js --project <root>, pulling the latest group messages every 10 seconds via IMAdapter.listRecentMessages() (the Lark adapter underneath calls lark-cli im +chat-messages-list HTTP short connection), compared to state.last_processed_time + poll.emitted deduplicate, emit NEW_MSG|... to stdout, Monitor captures it as a notification and pushes it to the main session.

主会话 ── Monitor(persistent) ── node poll.js ── 每 10s IMAdapter.listRecentMessages() (HTTP)
                                              ├─ state.last_processed_time + poll.emitted 去重
                                              └─ stdout: NEW_MSG|msg_id|sender|content|ts[|FLAGS=bot_switch_from_group]
                                                         ↓ Monitor → notification
                                                   主会话 → 意图判定 → adapter.sendText / bash lark-cli
                                                         (FLAGS=bot_switch_from_group → 见 §群消息开关拒绝示例)

self-poll mode (weak agentic endpoint alternative,profile.polling_mode = 'self-poll')

The above is the default monitor mode(official Claude). Weak agentic endpoints (such as DeepSeek via the Anthropic-compatible endpoint)will not ToolSearch-load deferred Monitor tools, fall back to a Bash background process, and stdout will not wake the main session → group messages are not received. In this case, switch to self-poll mode:

主会话 ── /loop <interval>(固定间隔,harness 驱动)── 每轮跑 /cc-bot:poll-once
                                                     └─ node poll.js --once(拉一次 + 去重/@过滤,输出 NEW_MSG 后退出)
                                                     └─ 主会话逐条 lark-cli 回群 + 推进 state
  • the main session throughoutactively calls resident tools(Bash + lark-cli + /loop), without touching Monitor / notification / ToolSearch — bypassing the weak-endpoint deadlock.
  • For startup see /cc-bot:start self-poll branch (does not open Monitor, after sending the notification /loop <self_poll_interval,缺省 3m> /cc-bot:poll-once); for single-round logic see /cc-bot:poll-once。
  • Cost: polling-driven, spinning idle when there are no messages also consumes tokens (vs monitor event-driven, zero consumption when idle); DeepSeek endpoint prompt cache is invalidated (cache_control ignored), full input every round. The larger the interval, the more it saves; a 3~5m delay for the project assistant is acceptable.
  • Stop:paused=true soft stop (poll-once reads paused via poll.js --once and returns immediately without processing); to completely end the loop, interrupt the session running /loop.
  • See memory for details reference_deepseek_agentic_incompatible。Default is monitor; only when self-poll is explicitly configured does it use this setup.

IM Adapter layer:adapters/base.js defines the interface (listRecentMessages / sendText / sendImage / downloadResource / getUser),adapters/lark.js implements the Feishu version (package lark-cli). poll.js reads profile.im.type instantiates the corresponding adapter. In the future, adding WeCom/DingTalk/Slack only requires adding an adapter file + changing it in the profile im.type。

Why use HTTP short connections:lark-cli event +subscribe 's WebSocket long connection is silently disconnected under vpn proxies such as Clash/Verge,LARK_CLI_NO_PROXY=1 is ineffective for WS clients. HTTP short connections are stable through proxies.

poll.js three-layer defense (deletion prohibited)

Addressing the three pitfalls of the 2026-04-20 polling architecture:

  1. PID lockfile singleton lock — on startup write .cc-bot/runtime/poll.pid, if there is already a live process then exit 0; each tick verifyLock() verifies the pid is still itself, and if taken over, self-terminates
  2. stdout EPIPE fault-tolerant self-termination — single round process.stdout.writable=false or stdout.on('error', EPIPE) when counting epipeStreak++ + skip the current tick (does not exit immediately); only after 3 consecutive rounds (~90s) of being unwritable does it exit 1, before exiting events.log write BOT_ERROR|poll.js|stdout-*-streak-3|... for diagnostics. A transient break does not die, only a real break dies, avoiding the bot silently dying due to Monitor pipeline jitter
  3. state future-value defense — last_processed_time > Date.now()+60s self-heals down to now-60s,emit BOT_ERROR|poll.js|state-future-timestamp|...

@others message filtering (v0.1.10+)

When there are multiple people in a group, members @-ing each other back and forth is unrelated to the bot and should not disturb the main session. poll.js determines before emit:

message mentions field profile.im.bot_open_id configured Behavior
empty / nonexistent — normal emit
non-empty, contains bot_open_id ✅ normal emit (@ the bot itself)
non-empty, does not contain bot_open_id ✅ skip emit + append emitted (@ others)
non-empty ❌ Not configured Conservative mode: always skip + append emitted (even @ bot is ignored)

Conservative mode: when not configured im.bot_open_id , any @ in the group is not responded to at all. This matches the pure noise-reduction requirement of "ignore @ to others"; the side effect is that @ bot is also ignored, and users can trigger the bot by simply using natural language without @.

Precise mode: in .cc-bot/profiles/active.json 's im.bot_open_id , fill in the bot app's open_id (ou_xxx format), which can be looked up from the sender.id of messages the bot has sent, or viewed on the Feishu Open Platform "App Info" page.

Filtered messages go directly into poll.emitted and are treated as already processed, and will not be re-evaluated across ticks.

Startup flow (designed for low latency: single-batch parallel + skip redundant checks)

When a startup command is received (slash /cc-bot:start or natural language in the main session such as "start bot"), the goal isfrom command to group receiving the online notification ≤ 5s. The method is to pack almost all actions intoone response launched in parallel。

Dependency graph

Read profile  ──┐
                ├→ 并行批次 ①(无相互依赖):
                │   - Edit state.json paused=false, monitor_task_id=null
                │   - mkdir -p <bot_temp_abs>
                │   - Read .cc-bot/runtime/hud-stdin.json (拼模型/上下文行;缺失就跳)
                │   - Monitor(command=node ... poll.js --project ...)
                │   - Bash: lark-cli im +messages-send 发上线通知
                ▼
               Monitor 回 task_id → 单独一步:
                  - Edit state.json monitor_task_id=<task_id>

Specific steps

  1. Read profile(single Read): obtain im.chat_id / im.bot_app_id / project.root / paths.bot_temp_abs , etc.
  2. Launch in a single parallel batch:
    • Edit .cc-bot/runtime/state.json:paused=false, monitor_task_id=null
    • Bash: mkdir -p <bot_temp_abs>(idempotent, zero overhead when the directory already exists)
    • Read .cc-bot/runtime/hud-stdin.json(if present) — assembles the "model / context" line of the online notification; if absent, send only the title + closing sentence
    • Monitor(node ${CLAUDE_PLUGIN_ROOT}/runtime/poll.js --project <project.root>, description, persistent, timeout_ms=3600000)
    • Bash sends the online notification:must use --msg-type text --content '{"text":"..."}' JSON method, do not use --text "..." + $'...\n...'(under Windows Git Bash, $'' escaping is unstable and will send a literal \n). See commands/start.md for examples
  3. After Monitor returns task_id: send another Edit to monitor_task_id write back to state.json

Explicitlydo notthings to do

  • ❌ Do not clean up orphan processes — poll.js's PID lockfile (layer 3 defense ①) already covers this: when starting, if it hits a live process, then exit 0; when it hits a dead pid file, acquireLock() automatically overwrites it; after CC crashes, the old poll.js commits suicide via ② EPIPE 90s fallback, with no need for the main session to run powershell Get-CimInstance(Windows)/ pgrep -f + kill(macOS/Linux), such a slow 2-5s global scan
  • ❌ Do not run TaskOutput to verify running status — if Monitor starts with no error, it is considered successful; if poll.js reports an error internally, on the next polling round it will emit BOT_ERROR|poll.js|... to Monitor stdout
  • ❌ Do not do redundant self-checks(lark-cli --version / path existence, etc.) — setup has already verified them; when it truly fails, the downstream first lark-cli call will report

Exception paths

  • Monitor startup immediately errors (task status is not running / not persistent) → the main session reports "Monitor startup failed: {msg}", letting the user investigate
  • Online notification lark-cli fails → the main session reports "Online notification send failed: {msg}" but Monitor is still running and does not roll back state
  • .cc-bot/profiles/active.json missing → poll.js emits at startup BOT_ERROR|poll.js|profile-missing and automatically exits; after the main session receives the notification, it prompts the user to first /cc-bot:setup
  • Monitor emits immediately after startup BOT_INFO|poll.js|lock-taken-by-pid-{XXX} → the old poll.js is still running (80%+ orphan leftovers / a few from another CC session on the same machine). The main sessionAutomatically run the full stop+start sequence, with no intermediate confirmation: first the full /cc-bot:stop sequence (kill PID + clear poll.pid + send offline notification + set paused=true), immediately followed by the full /cc-bot:start sequence Very low probability of accidental kill: when two CC sessions of the same project run at the same time, the old PID is a legitimate instance and will be killed, and group message push in that session is interrupted; multiple windows for the same project is itself an anti-pattern, so this is acceptable

Shutdown flow

  1. Read state.json's monitor_task_id,TaskStop(task_id) stop Monitor; after poll.js receives SIGTERM, releaseLock clears poll.pid
  2. Edit state.json to set paused: true, clear monitor_task_id
  3. send offline notification
  4. Verify there are no leftover poll.js processes, choose the command by platform:
    • Windows(Git Bash):tasklist //FI "imagename eq node.exe" check whether poll.js is still there; if so,taskkill //F //PID $(cat .cc-bot/runtime/poll.pid) 2>/dev/null; rm -f .cc-bot/runtime/poll.pid
    • macOS / Linux:pgrep -f 'runtime/poll\.js .*--project' check whether it is still there; if so,kill -TERM $(cat .cc-bot/runtime/poll.pid) 2>/dev/null; sleep 2; kill -9 $(cat .cc-bot/runtime/poll.pid) 2>/dev/null; rm -f .cc-bot/runtime/poll.pid

Toggle notifications

i18n rules: online/offline notifications / busy placeholder / help, etc.system-level copySelect the language by profile.im.locale , default lark→zh-CN、slack→en-US。When the LLM replies to user messages, it follows the user's language(user sends English → reply in English, sends Chinese → reply in Chinese), not controlled by locale. For detailed send command templates, see commands/start.md / commands/stop.md。

zh-CN online notification(/cc-bot:start, when HUD is available):

● 已上线
cc v{cc_version} · bot v{version}
模型 · {model_display_name}
上下文 · {bar} X%

en-US online notification:

● Online
cc v{cc_version} · bot v{version}
Model · {model_display_name}
Context · {bar} X%

zh-CN offline notification(/cc-bot:stop, when HUD is available):

○ 已下线
上下文 · {bar} X%

en-US offline notification:

○ Offline
Context · {bar} X%

Field rules:

  • online notification = ● 已上线 + version line (cc v{cc_version} · bot v{version}) + model line + context line;offline notification = ○ 已下线 + context line (excluding version line / model line / sleep hint)
  • {version} / {cc_version}: cc-bot plugin version (Read ${CLAUDE_PLUGIN_ROOT}/.claude-plugin/plugin.json 's version) / Claude Code version (read hud-stdin.json top-level version); used only for online notifications. When HUD cannot get {cc_version} , the version line omits the cc v{cc_version} · segment (leaving only bot v{version})
  • context line: silently omitted when HUD is unavailable; field sources such as model / context / progress bar are the same as §HUD status push
  • Status symbol system:● online/processing (blue/active),○ offline (gray/silent),✓ completed (green),✕ failed (red) — shared across all of cc-bot

Runtime files

File Purpose
.cc-bot/profiles/active.json Currently active project configuration (required reading at startup)
.cc-bot/runtime/state.json Runtime state (paused / last_processed_time / pending_confirm / monitor_task_id)
.cc-bot/runtime/poll.pid poll.js singleton lock pid file (written at startup, cleaned up on exit)
.cc-bot/runtime/poll.emitted Deduplication table of pushed message_ids (most recent 200)
.cc-bot/runtime/hud-stdin.json HUD data (written by cc-hud)
.cc-bot/runtime/agents.json Multi-agent scheduling registry (running / queue; empty at startup, see §Message Scheduling for details)
.cc-bot/runtime/main-busy.lock Main session busy lock (written by CC UserPromptSubmit / deleted by Stop; read by poll.js; automatically cleared after 10min expiry, see §Main Session Priority for details)
.cc-bot/runtime/main-busy-notified.flag Global throttle timestamp for group placeholder messages (v0.1.16+: mtimeMs = time the last placeholder was sent; decoupled from the lock lifecycle, no longer cleared on unlock; see §Main Session Priority Placeholder Strategy for details)
.cc-bot/runtime/poll.busy-held msg id held during busy period (v0.1.20+, issue #9 fix): JSON {id: {ts}}; when the main window is busy, new messages enter this table without emitting; the next tick bypasses the lastTime filter until emit succeeds; 10min TTL fallback cleanup
.cc-bot/runtime/events.log Diagnostic log (normally not written under the polling architecture; exceptional write scenarios: poll.js exits before stdout becomes unwritable for 3 consecutive rounds BOT_ERROR、main-busy.lock auto-cleanup when expired for 10min BOT_WARN)

Roles and permissions

Role determination:profile.members.admin_open_ids whitelist, hit = admin, otherwise = member. Single source of truth, no cache, no backfill.

Addressing in group: replywithout naming, Feishu +messages-reply comes with the original message quote, the sender can see it in the group, the bot does not repeat it. For proactive notifications use @all or mention open_id(do not use name).

Permission determination (codified in v0.1.23+): call once before dispatching permission.js, follow the returned decision :

node ${CLAUDE_PLUGIN_ROOT}/runtime/permission.js check \
  --project <项目根> --sender <ou_xxx> --intent <key>
# → {"decision":"allow|reject|confirm-needed|group-rejected","role":"admin|member","level":"...","reason":"..."}
  • allow → dispatch directly
  • confirm-needed → write pending_confirm(15min timeout), reply in group asking the user to answer Y/确认
  • reject → reply reason(e.g. "this operation requires administrator authorization")
  • group-rejected → sensitive commands such as bot switches are all rejected (see §Source restrictions for switch commands)

intent → level mapping rules (built into code + overridable by profile):

Source Default level Override method
Built-in intent (hud / help / query_progress / query_todo / visual_bug_report / unknown) public Not overridable
Built-in intent (bot_switch) group-rejected Not overridable
Project intent (profile.intents.<key>) public `profile.intent_permissions.: 'public'

Typical project-level declaration(profile.intent_permissions example):

{ "deploy": "admin", "drop_db": "admin-confirm", "query_logs": "public" }

Project intents not declared in intent_permissions are by default determined by intent name heuristic: names matching deploy* / publish* / release* / push_to_* / drop_* / delete_* / remove_* / reset_* / restart_* / kill_* / purge_* / prod* / *-deploy / *-prod and other high-risk words → default admin(safety fallback, preventing legacy profiles from accidentally exposing deployment-type intents); others → default public. To open high-risk names for non-admin invocation, explicitly declare intent_permissions.<key>: 'public'。

Intent routing

The LLM uses semantic understanding to match user messages to intent keys, without hardcoded keywords.

General intents(built into cc-bot):hud / help / query_progress / query_todo / visual_bug_report / bot_switch / unknown。

Project intents:profile.intents.<key>, key names are custom. Typical examples:deploy / run_tests / query_logs / compile_preview / check_build。

Both resolve / list go through code(placeholder replacement + doc_progress file existence check + non-empty filtering handled automatically):

# 把 intent key 解析成「替换好占位符的可执行动作描述」
node ${CLAUDE_PLUGIN_ROOT}/runtime/intent.js resolve --project <项目根> --key <intentKey>
# → {"found":true,"description":"<占位符已替换的描述>","source":"builtin|project"}
# found:false → 回"当前项目未配置该操作"

# 'help' 意图触发时拿动态可用清单
node ${CLAUDE_PLUGIN_ROOT}/runtime/intent.js list --project <项目根>
# → {"items":[{"key":"...","hint":"...","source":"..."}, ...]}

The LLM's responsibility is narrowed to "determining which key semantically matches"; placeholder replacement / hint summarization / doc_progress file existence determination / _comment field filtering are all in code.

Supported placeholders (built into code, to add a new placeholder directly modify runtime/intent.js):<project.root> / <project.doc_progress> / <paths.bot_temp_abs> / <paths.bot_temp_rel> / <chat_id> / <bot_app_id>。

Rich text (post) message handling

Feishu "text + screenshot" merged into message_type: "post", content rendered as text lines + [Image: img_v3_xxx]. Monitor allows text|post|file|image four types. Processing flow: extract [Image: img_xxx] → download per §Image receiving and downloading → Read screenshot → determine intent combined with text.

File (file) message handling

When xlsx/csv/doc etc. are uploaded to the group message_type: "file",content:

<file key="file_v3_xxx" name="xxx.xlsx"/>

Steps:

  1. perl or manually extract key= and name=
  2. Download:
    lark-cli im +messages-resources-download --as bot \
      --message-id <om_xxx> --file-key <file_xxx> \
      --type file --output ./.cc-bot/bot_temp/<语义名>.xlsx
    
  3. Read: for xlsx use node -e "const X=require('xlsx');..."; for csv/txt use Read; for doc/pdf use the corresponding library
  4. Determine intent based on the user's text (common: import data, modify code per reference file)

Message Handling SOP

After receiving NEW_MSG|{msg_id}|{sender}|{content}|{time} :

Highest-priority rule 0: replying to the group = tool call, declarations don't count

The only way a group message reply counts is to initiate lark-cli im +messages-reply 's tool_use and have it return successfully. Outputting text like "I'll reply", "I'm replying to him in the group", "replied", "bot working normally" in the main session,only you can see it, it won't be sent to the group, which equals not replying。

After receiving NEW_MSG,before calling the reply-to-group tool, it is forbidden to output any declarative text about "about to reply" — call the tool first, then speak. To judge whether a message has been handled, look at "whether+messages-reply returned successfully", not "whether I said I would reply". Every NEW_MSG that needs a response must end with an actual +messages-reply tool_use (inline path); merely "intending to reply" in your head without initiating a tool call = you dropped this message.

Highest-priority rule 1: immediately advance state.json after handling

"Handled" definition (three paths):

  • inline: final reply sent (lark-cli +messages-reply succeeded)
  • subagent: Agent dispatched (run_in_background=true) + placeholder reply sent
  • enqueue: replied "the previous N tasks are running" + task written to agents.json.queue

The next tool call after handling must be Edit state.json writing last_processed_time = {该条 time}, with priority higher than processing the next item, deployment, or any "do it while I'm at it". Failing to advance causes re-dispatch during catch-up, running the same task twice.

Format: millisecond timestamp(Number or numeric string), consistent with the last segment of NEW_MSG|...|<createTimeMs> — directly take the absolute epoch from the last segment of NEW_MSG。Do notconvert the +chat-messages-list string displayed by create_time : that is rendered by lark-cli according tothe host's local timezonewall-clock string (no seconds), and converting it to milliseconds using anyfixed offset(such as +0800) will be misaligned on hosts in non-Beijing timezones → false negatives when comparing against the epoch cursor, missing messages (issue #21). When the absolute ms of a fetch message is truly needed, use the raw endpoint to get Feishu's original epoch (timezone-independent):lark-cli api GET /open-apis/im/v1/messages --as bot --params '{"container_id_type":"chat","container_id":"<oc_xxx>","sort_type":"ByCreateTimeDesc","page_size":5}' → .data.items[].create_time(Unix millisecond string).Do not mix strings and milliseconds — fetch comparison will produce false positives/negatives.

Highest-priority rule 1b: system timestamps vs. human semantic time (two sets of timezone rules, don't mix them)

The host running the botmay not be in the Beijing timezone(a US Eastern host has been observed in practice),date defaults to non-Beijing time. The following two types of time rules areopposite, be sure to distinguish them:

  • System timestamps(create_time / last_processed_time / cursor comparison): absolute epoch milliseconds,timezone-independent, handle per rule 1.Do not add any fixed offset to it。
  • Human semantic time(user messagecontentmentioned clock times/dates, and any time the bot reports to the group): Feishu groups uniformly understand and present in **Beijing time (Asia/Shanghai, UTC+8)**,independent of the host timezone:
    • For group-facing purposes such as answering "what time is it now" in the group, reporting "will finish around 16:00", or writing dates, use the current time via TZ=Asia/Shanghai date,neveruse the host local timezone.
    • Convert the user's "3 o'clock" or "tomorrow 14:00" into a timestamp, interpreting it as Beijing (TZ=Asia/Shanghai date -d '...' or explicitly +0800) — here +0800 iscorrect, because the user means Beijing anyway; this does not conflict with rule 1 "do not add +0800 to create_time" (that is a string lark-cli has already rendered according to the host timezone).

Highest-priority rule 2: fetch 5 items to verify before processing (fetch_before_reply)

Before receiving NEW_MSG, first lark-cli im +chat-messages-list --as bot --chat-id <chat_id> --page-size 5 --sort desc, find unprocessed messages (determine by whether msg_id is already above the bot's reply chain; do not convert the displayed create_time string to epoch and compare times with the cursor — unreliable across timezones, see rule 1), reply one by one in ascending order of the create_time display string + advance state (the advance value still takes the absolute epoch of that NEW_MSG). Why: when Monitor is dense it may push only the latest one, and processing a single push will miss key messages in between (2026-04-20 real-world case).

One fetch covers multiple messages: if Monitor sends consecutively or the previous fetch was within ≤ 10s, the result can be reused.

Escalate to fetching 10 items: user is emotionally agitated (consecutive "???") / consecutive ACKs with no response / the main session just finished a ≥3min tool chain. Better to fetch more than to miss seeing something.

State advancement discipline (v0.1.20, issue #9): when fetch pulls multiple unprocessed messages:

  • Must reply one by one in ascending order of create_time reply one by one in ascending order(using +messages-reply --message-id <每条 om_xxx>), merging N messages into one summary reply is not allowed
  • After each reply, immediately advance state.last_processed_time = that message's ct, advance one by one;it is strictly forbidden to directly advance to the latest message's ct and then reply in batch
  • Consequence of skipping-number advancement: messages that poll.js's busy-held has held but not emitted will be filtered by <= lastTime and permanently lost; the group sees no feedback at all = the bot pretends not to see. v0.1.20 has added a busy-held persistence fallback in poll.js (bypassing the lastTime filter), but discipline on the main session side is still the first line of defense
  • NEW_MSG deduplication(v0.1.20 corollary): when busy-held is released, if ct < lastTime, poll.js will emit but write BOT_WARN|busy-held-late-release. The main session needs to dedup such duplicate emits itself: during fetch-before-reply, if it finds that the msg_id is above its own reply chain (the bot has already replied), skip and do not reply again

Highest-priority rule 3: while the bot is running, disable interactions that block the main session

While the bot is running (state.paused = false), it is strictly forbidden to use AskUserQuestion / ExitPlanMode and other operations that block waiting for terminal input. When user decision is needed, ask via group message (lark-cli +messages-reply)。

Why:AskUserQuestion blocking the main session does not trigger Stop → main-busy.lock Cannot unlock normally → after the 10min expiry, if the statusline heartbeat is also stale, poll.js enters degraded mode (does not emit + keeps holding the slot), and no one consumes group messages. Group members are in the group but not at the terminal, so the tab can never receive a response (measured up to 6 hours). See the degraded mode explanation in §Main Session Priority.

Full flow

  1. fetch verification(see above, highest priority 2)
  2. Parsing: extract fields from the | delimited string
  3. Role determination:sender open_id ∈ profile.members.admin_open_ids → admin, otherwise → member (single source whitelist, no cache)
  4. Pending confirmation check: read state.json's pending_confirm(not timed out):
    • "Y"/"y"/"yes"/"confirm" → execute
    • anything else → cancel
    • admin permanent authorization skips directly pending_confirm
  5. Image preprocessing(content contains [Image: img_xxx]): download one by one + Read
  6. Intent recognition: general intents follow §Intent Routing, project-specific check profile.intents
  7. Dispatch decision(see §Message Scheduling): inline reply yourself / dispatch subagent run_in_background=true / enqueue. inline continues to step 7; subagent and enqueue follow §Message Scheduling §Dispatch Actions §Enqueue Actions, and this flow ends here (state advancement is handled separately there)
  8. inline execution:
    • admin triggers dangerous operation → execute directly, do not write pending_confirm
    • member triggers "admin-only" operation → reject
    • non-admin for other dangerous operations → write pending_confirm(15 min timeout)
    • lark-cli im +messages-reply --as bot --message-id <msg_id> reply (relies on reply quoting context, body does not name them — see §Roles and Permissions §Addressing in Group)
  9. Advance state.json:Edit last_processed_time = time(time = the createTimeMs millisecond timestamp of the last NEW_MSG segment, see §Highest Priority Rules 1 format specification)

Bug report handling cadence (dense multi-bug sessions)

  1. Confirm receipt immediately(within 1 turn): reply "Received {name}'s feedback about {bug summary}, locating..."
  2. Locate root cause: read files, check logs, and if necessary use reproduction-type intents from profile.intents (such as page_check / run_tests / query_logs etc., depending on the profile's actual configuration)
  3. Fix: Edit code files (under profile.project.root)
  4. Reply with result + next step: report "Fixed {xxx}, cause {root cause}", and based on the scope of changes indicate whether a new QR code / deployment is needed

When multiple bugs are in parallel, reply to each independently (do not merge into "finished fixing 5 issues"), and advance state.json immediately after each reply.

Guidance when bug information is insufficient

When the user only states the symptom without context (e.g., "the displayed name is wrong"), first guide them:

"Could you add ①which page ②operation steps (how to trigger) ③screenshot? This will help locate it faster."

Once you have the three elements, proceed with the fix flow. If it is still ambiguous, keep asking about the specific fields; do not guess blindly.

Handling multiple backlogged messages (anti-miss rule)

When multiple NEW_MSG are received in one turn (backlog): process them one by one according to §Highest Priority Rules 1/2 + advance state one by one,Reply in ascending order of create_time(not in Monitor arrival order).

ACK messages respond immediately (ack_msg_action)

Short messages (≤12 Chinese characters) are judged by code:

node ${CLAUDE_PLUGIN_ROOT}/runtime/ack-detect.js detect --text "<消息内容>"
# → {"isAck":true|false, "kind":"yes|continue|ok|thanks"|null, "suggestedReply":"好|继续中|..."|null, "confidence":0..1}

isAck:true when: ① immediately advance state.json.last_processed_time; ② reply suggestedReply(≤15 characters,thanks type returns null and does not reply to avoid reply storms); ③ immediately continue with the next step of the previous round.

Why: ACK = green light, not red light. In multi-batch tasks, every ACK is the start signal for the next batch; silence will cause severe dissatisfaction (2026-04-20 real-world retrospective: the person in charge replied "OK, continue" which was mistakenly treated as "no action needed", and 18 min later received severe criticism).

isAck:false when, follow §Complete Process for regular intent recognition. Stop words ("no"/"cancel"/"wait"/"no") are not counted as ACK even if short; the CLI has already handled them internally.

API fallback when Monitor is abnormal

Under the polling architecture, the poll.js hosted by the Monitor tool is the main loop, actively polling for messages via HTTP every 10s → emit NEW_MSG to stdout → notification to the main session.Normally Monitor push is reliable(no longer has the old architecture's "Bash background stdout→pipe compression disconnect" problem).

However, the following three scenarios still require API fallback (directly call lark-cli im +chat-messages-list to compare state.json.last_processed_time, without going through the poll.js / poll.emitted channel):

  1. Monitor task dies:TaskGet(task_id) returns Task not found / failed / completed(not an expected state within persistent)
  2. poll.js consecutive failure alert: the main session receives BOT_ERROR|poll.js|lark-cli 连续失败 N 次 notification
  3. User proactively asks "are there messages in the group / any new messages / hello?": do not answer from memory, immediately fetch to verify (consistent with fetch_before_reply in "Highest Priority Rule 2")

poll.emitted is poll.js's internal deduplication table,never manually clear it(it will cause historical messages to be re-pushed as new messages and flood the screen).

When Monitor push and API fallback results conflict, the API takes precedence.

Message scheduling (multi-agent concurrency)

The main session = the scheduler itself,does not run heavy work itself. After receiving NEW_MSG, judge: if it can be dispatched to a subagent, then Agent(run_in_background=true) dispatch it in the background, and the main session is immediately freed to handle the next one; if it cannot be dispatched, queue it. The goal is to process multiple group messages in parallel, and the main session is never blocked by a single message.

Core concepts

  • slot: the number of logical tasks allowed to run simultaneously, default slots_max = 3. One user message = 1 slot, regardless of how many subagents it fans out to internally
  • registry:.cc-bot/runtime/agents.json, records running / queue
  • tag: the resource label registered for a task, the key to conflict determination
  • fan-out: a single user message internally splits into multiple parallel subagents, upper limit 3, does not occupy additional slots

agents.json format

{
  "slots_max": 3,
  "running": [
    {
      "id": "agent_<msg_id>",
      "msg_id": "om_xxx",
      "user_name": "A",
      "user_open_id": "ou_xxx",
      "intent": "fix_login_bug",
      "tags": ["write:src/auth", "net:push"],
      "started_at": "2026-04-22T10:00:00Z",
      "subagent_count": 1
    }
  ],
  "queue": [
    {
      "msg_id": "om_yyy",
      "user_name": "B",
      "user_open_id": "ou_yyy",
      "intent": "refactor_auth",
      "tags": ["write:src/auth"],
      "queued_at": "2026-04-22T10:01:00Z",
      "reason": "conflict:write:src/auth"
    }
  ]
}

File read/write is taken over by runtime/dispatch.js (v0.1.25+, the main session does not directly Edit), and if missing, an empty state is automatically created. When the bot restarts, the registry is fully cleared (same policy as poll.emitted , subagents will lose listening when the main session stops).

Field format specification:

  • started_at / queued_at:ISO 8601 string(e.g. "2026-04-22T10:00:00Z"), used for human-readable debug and timeout determination (the main session uses new Date(x).getTime() for conversion)
  • state.json.last_processed_time:millisecond timestamp(see §Highest Priority Rule 1) — the two formats are intentionally different, do not mix them

Dispatch decision table

Message type Handling Occupies slot
Control type (sending "on/off bot" in the group) Reject (§source restrictions for on/off commands) No
Query / chitchat / status / single-file small change (typo / 1-3 line Edit) Main session replies inline directly No
Cross-file changes / build-test loop / deploy / send code / large search Dispatch subagent run_in_background=true Yes

Decision threshold:Estimated ≤ 3 tool_use + single file + < 30s goes inline, otherwise subagent. When context > 70%, the threshold tightens (favor subagent to preserve the main session).

Dispatch decision (v0.1.25+ dispatch.js takes over the full agents.json lifecycle)

  1. Fetch verification(§highest-priority rule 2 unchanged)

  2. Intent + inline/subagent decision(per the §dispatch decision table threshold above; the inline path follows §full flow and does not enter dispatch)

  3. Generate tags(only needed for subagent) — just capture "who is most likely to touch this at the same time":

    • read:<path> / write:<path> — path conflict (by directory segment, read-read does not conflict; prefix matching is inside dispatch.js)
    • mcp:<name> / port:<n> — exclusive resource
    • net:push / exclusive:git — release / git exclusive
  4. Call dispatch.js register:

    node ${CLAUDE_PLUGIN_ROOT}/runtime/dispatch.js register \
      --project <项目根> \
      --task-json '{"msg_id":"<om_xxx>","user_open_id":"<ou_xxx>","user_name":"<name>","intent":"<key>","subject":"<可选 ≤60 字人类可读>","tags":[...]}'
    # → {"action":"dispatch|queue|reject","reason":"...","taskId":"agent_om_xxx","queuePosition":N|null,"preheated":bool}
    

    CLI internally completes it all at once atomically: evaluate slot full / tags conflict / same user serialization → write agents.json(running or queue) → synchronously prewarm the card when dispatching → return action.The main session no longer manually Edits agents.json。

    subject field (v0.1.33+ optional): distill the user's original sentence into a single sentence of ≤60 characters (closer to this task than the intent description), written as the hero of the group card. When register dispatches, it synchronously attempts to prewarm the card (the cardkit POST is moved from the worker critical path to the dispatch side, issue #15 first frame 10s → 1-2s), with a 3s hard timeout that silently swallows errors. When subject is absent, fall back to intent.resolveAction(key) first sentence.

    action meaning + response:

    • dispatch → dispatch worker (step 5)
    • queue → reply in group "Received. There are queuePosition tasks running ahead, will start after they finish", push state, this response ends. See below for reason
    • reject → reason=queue_full, reply "Task queue is full (10 items), try again later"

    reason (when queue):slot_full / conflict:path-overlap:... / conflict:exclusive-tag:... / user_serial。

  5. dispatch action:

    • Reply in group with placeholder:preheated===true → skip (card already created, hero "Received task:\n\nqueued...");preheated===false → send placeholder (lark uses +messages-reply text; slack uses slack-send.js send-text). The main session does not need to read the profile itself to determine lark / flag — just look at one field
    • Push state.json.last_processed_time = msg.create_time
    • Call Agent(subagent_type:'cc-bot:worker', run_in_background:true), prompt passes only 4 fields (task description / 项目根 / msg_id / plugin_root = ${CLAUDE_PLUGIN_ROOT}) — all other conventions are in worker.md. In the prewarm scenario, the worker's first report is a path 3c update (card already created)
    • This response ends, proceed to the next NEW_MSG

Fan-out (single message, multiple subagents in parallel)

One user message raising multiple matters can be split. Prerequisite: subtasks have no dependencies + tags pairwise disjoint + count ≤ 3.

Dispatch method: multiple Agent tool_use in the same response; at register time, do it all at once with "subagent_count": N、"tags" being the union of all subtask tags (affects subsequent conflict determination).

Completion recycling (dispatch.js complete)

When the subagent completes, run_in_background automatically notifies the main session; call the code:

node ${CLAUDE_PLUGIN_ROOT}/runtime/dispatch.js complete \
  --project <项目根> --task-id <agent_om_xxx>
# → {"removed":true,"promoted":<Task|null>}

The CLI automatically scans from the head of the queue for the first promotable one (slot available + no conflict + same user not in remaining running + same user has not been queued ahead → preserve FIFO), writes it back to running and returns promoted。promoted non-null → dispatch the promoted one per step 5 of dispatch above; null → do nothing.

fan-out task (subagent_count > 1): wait forallsub-agents to complete, then call complete once.

Queue limit / timeout

  • Queue limit 10 (QUEUE_LIMIT, a dispatch.js constant): when register returns action:'reject', reason:'queue_full' , reply "Task queue is full, try again later"
  • If a single task is estimated > 30min (large deployment), warn the user first and get confirmation before dispatching; if it gets stuck with no response, rely on §Monitor abnormal restart as the fallback

Relationship with §Agent priority strategy

Two different dimensions, do not mix:

  • §Agent priority strategy (within §runtime rhythm):Save tokens inside the main sessiondispatch an Agent (for cross-directory Grep, dispatch Explore),does not occupy a slot, does not register in registry, lifecycle is within one response
  • §Message scheduling (this section):group message task-leveldispatch,occupies a slot, registers in registry, exists across responses (run_in_background)

In the same response both can coexist: during inline processing an Agent can internally be dispatched to read files.

Main session priority (v0.1.6+)

Goal: the conversation task of the CC main window is not interrupted by group messages. In 90% of scenarios it is single-user conversation in the group, and slot-level concurrency is actually not fully used, but "the main window is editing code, and a group message immediately jumps the queue and interrupts" is a real pain point.

Precise definition of "main window conversation":

  • Conceptually it refers tothe prompt actively typed by the developer in the CC main window(human conversation)
  • In implementationit is based on the CC UserPromptSubmit hook fire(CC does not distinguish prompt source) —— /loop / ScheduleWakeup / CronCreate / RemoteTrigger / claude -p / Task/subagent completion (bug #16952 false fire) and other automatic scenarios also trigger the lock, treated the same
  • Monitor event injection (group message push path)does not go through UserPromptSubmit, will not self-lock

This is not a bug, it is by design: the main session is single-threaded; in the above "automatic task" scenarios the main session is already occupied, and even if a group message is emitted there it cannot respond. The lock merely turns "the main session is busy and ignoring you" into an explicit placeholder in the group (a random one from a pool of 30 texts, see runtime/poll.js BUSY_PLACEHOLDERSfor details), a better experience, not worse.

Mechanism(intercepted at the poll.js layer, transparent to the main session):

  1. CC hook registers in ~/.claude/settings.json(idempotently injected by /cc-bot:setup step 9):

    • UserPromptSubmit → node ${CLAUDE_PLUGIN_ROOT}/runtime/main-busy.js lock write .cc-bot/runtime/main-busy.lock
    • Stop → node ${CLAUDE_PLUGIN_ROOT}/runtime/main-busy.js unlock delete lock + delete notification flag
  2. poll.js at the start of each tick checkMainBusy() returns { busy, degraded, lockTs }:

    • Lock exists + not expired → still fetch but do not emit(messages do not enter the main session event queue); decide whether to send a placeholder according to the placeholder strategy below
    • Lock exists + expired (> 10min) → check hud-stdin.json heartbeat: - Heartbeat fresh (< 5min) → orphan lock(Stop missed firing or unlock failed), safely clear the lock + resume emit; write events.log BOT_WARN|main-busy-lock-expired-orphan - Heartbeat stale/missing (≥ 5min but < 15min) → degraded mode(the main session is very likely stuck at AskUserQuestion , a blocking interaction): do not delete the lock, do not emit, stay busy (degraded=true); write events.log BOT_ERROR|main-busy-lock-expired-degraded. After Stop fires, unlock normally and resume - Heartbeat stopped updating ≥ 15min (or missing + lock overdue ≥ 15min) → dead self-heal(the main session process is truly dead, issue #20): force-clear the lock and resume emit; write events.log BOT_WARN|main-busy-lock-expired-dead-cleared. When AskUserQuestion is blocking, the statusline still updates and this branch will not be hit by mistake
    • Lock does not exist → normally emit NEW_MSG

    Placeholder strategy(v0.1.19+, layered semantics, resolves issue #6 #7 together):

    • profile.im.busy_placeholder === false → all off opt-out (send in neither normal state nor degraded state)
    • Normal busy:per-lock dedup — same lock acquisition (same lockTs) sends at most 1; plus a 5min global throttle as a fallback, to prevent high-frequency hook lock churn (the issue #6 scenario when multiple turns are dense) from breaking through per-lock dedup
    • Degraded mode: continue sending heartbeat every 5min, preserving the "still alive" signal for stuck scenarios (v0.1.15 design, issue #1)
  3. After the main session finishes responding (Stop) → lock deleted → next tick (≤ 10s) resumes normal fetch, and backlogged messages are re-emitted via the poll.emitted dedup mechanism, so nothing is lost

Why hooks use ~/.claude/settings.json instead of plugin hooks.json: CC bug #10225 — the plugin-declared UserPromptSubmit hook does not fire at all.main-busy.js has built-in "silent skip for non-cc-bot projects" (checks whether .cc-bot/ exists), so global registration has no side effects on other projects.

What the main session needs to do: nothing at all. This mechanism runs autonomously via poll.js + hook scripts; it does not change agents.json or the §Message Scheduling main flow. The main session only needs to know: silent group messages are not lost; they are actively delayed while the main window is occupied, and will be re-emitted after Stop.

Key invariants:

  • During the lock, poll.js does not append poll.emitted(v0.1.6+), instead writes poll.busy-held(v0.1.20+); after unlock, the next tick re-emits from poll.busy-held , bypassing the <= lastTime filter to prevent silent loss when the main session crosses over state (issue #9)
  • main-busy-notified.flag is theglobal placeholder send timestamp(v0.1.16+), decoupled from the lock lifecycle;unlock no longer deletes it (deleting it would cause the next new lock to immediately send a placeholder again → issue #6 multi-turn screen flooding). Per-lock dedup independently uses the lockTs in-process variable
  • state.last_processed_time is only advanced by the main session; poll.js does not touch it (for advancement discipline see §Highest Priority Rule 2)
  • poll.busy-held when releasing emit, if ct <= lastTime, poll.js writes BOT_WARN|busy-held-late-release, indicating the main session may have already processed it via fetch-before-reply; duplicate emits are deduped on the main session side (see §Highest Priority Rule 2 "NEW_MSG dedup")

Testing caveat:! Prefixed bash commands UserPromptSubmit / Stop fire at the millisecond level, cannot cross a poll tick (10s), so they will be missed in testing. To test this mechanism, use a real Claude prompt (output ≥10s).

Runtime cadence (anti-crash for long sessions)

Agent-first strategy (default approach)

This section = The main session internally dispatches Agents to save tokens(does not occupy a slot, is not registered in the registry, lifecycle is within one response). For group message task-level dispatch orders, see §Message Scheduling.

When the Bot runs long, every bit the main context saves accumulates noticeably over time.Even when still in the < 70% normal range, the following scenarios also default to going through an Agent, leaving the main session to only receive the summary:

Scenario Go through Agent subagent_type
Cross-directory/multi-round search (> 3 Grep/Glob) ✅ Explore
Full reading of long documents (PRD/architecture/progress of ≥ 200 lines) → only need the summary ✅ Explore
Independent subtasks (code audit, E2E scripts, performance analysis, architecture evaluation) ✅ general-purpose
Multiple independent tasks can run in parallel ✅ Open multiple at once Choose by task
Implement a well-planned task chain ✅ superpowers:executing-plans style

Do it directly in the main session (dispatching an agent is instead a waste):

  • Single small file Read at a known path (< 200 lines)
  • With a clear goal,single Grep / Glob
  • about to Edit files (the main session must have Read them first)
  • Simple Bash (compile/deploy/send message)
  • Precise reads at known field locations (use offset/limit)

Prompt conventions for dispatching agents:

  • Clearly give the goal and return format ("find which files use X, return a file:line list")
  • Require a summary rather than dumping the original text ("keep the report within 200 characters")
  • When code changes are involvednever let the agent write files—have it return "what should be changed", and the main session does the Edit itself

Context usage monitoring (fallback)

Look at hud-stdin.json 's context_window.used_percentage as a passive fallback:

Percentage Strategy
< 70% Normal work; dispatch tasks per the "Agent-first strategy"
70-82% Avoid large Read blocks (use Grep + offset/limit for close reading); for long filesalwayshand over to Agent
82-92% Only make necessary tool calls; large page_data and full-file Reads all go through an Agent; answer group messages more briefly; prioritize commit
> 92% Immediately stop non-critical actions → send to group "context is almost full, preparing handover, developers please /clear or /compact" → commit → advance state.json → wait for instructions

Counterexample: still running compile/upload/deep Read at 92% — gets cut off mid-way by compact, loses the current context, and after restart cannot see what just happened.

Commit and push regularly

Trigger timing:

  1. After finishing an independent bug/feature — immediately git add 具体文件 && git commit && git push. Don't accumulate 5 bugs and submit them all at once
  2. After posting the new QR code/upload in the group — to prove the version is public, the code state must be synced to remote
  3. Phase switch — commit after each phase's tasks are done, don't mix commits across tasks
  4. Context usage exceeds 80% — commit first to lock in gains even if the task isn't finished

Convention:

  • git add specify files, don't -A / .(to prevent accidentally committing .cc-bot/bot_temp, .secret.json, etc.)
  • commit message:fix: / feat: / refactor: / chore: + one-line subject + blank line + list changed items
  • Engineering changes (SKILL / poll.js / adapter, etc.) can be merged into fix/feat commits, but don't post to the group
  • push failure: don't retry, send to group "push failed: {error}, need you to check network/credentials"

Reply format

  • Concise tool style, return only the result. First line is a one-sentence conclusion ("fixed"/"deployed"/"failed: <reason>"), details only if the user asks
  • Avoid four types of long-windedness:changelog narration / proactively explaining why / reporting internal actions / pleasantries and restating — group members don't want to see it
  • Code / log / long output go in screenshots or separate code blocks, not mixed into narration
  • Images:lark-cli im +messages-send --as bot --chat-id <群ID> --image <相对路径>

Emotional value (doesn't conflict with conciseness)

The group is real people, not a CLI. When the other person is emotional,acknowledge the emotion first, then get into technical details, otherwise a "precise" reply pushes the person further away.

Four emotional signals → corresponding actions

Signal Example Corresponding action
Impatient / annoyed at being directed "Don't keep telling me what to do", "Do it yourself" Apologize immediately + just do what you can do yourself, stop handing off operation steps; for what you can't do,state clearly that you don't have permission, give the shortest path
Questioning the process / you got it wrong "What's going on", "Weren't we agreed on X?" First admit "I misunderstood/ I missed something", then usea one-sentence restatement of the other person's expectationto have her confirm, then proceed
Hitting the same pit repeatedly "It still shows...", "It doesn't work again" Don't list ①②③ follow-up questions, directlychange one path and try; if it fails, switch to another; send a short progress bar after each step (≤10 characters)
Obvious anxiety / time pressure "About to pass review", "The client is waiting" Cut all optional steps,only give the one that can be used immediately; put non-critical clarifications later

Wording scale

  • Apologize directly: "Sorry" "I thought wrong" "I missed it" — don't use pleasantries like "thanks for pointing it out"
  • Don't over-apologize: Apologize only once for one thing; once the apology is done, immediately move into action, do not "apologize again"; for small mistakes (typos / minor formatting slips / misreading a single word), just fix them directly, don't apologize; in a continuous conversation, don't start every message with "sorry" — repeated apologies actually make you look guilty
  • Allow gentle single-character emotional markers:🙏(apology),✅(done) — at most 1 per reply, attached closely to the action word ("fixed it 🙏"), not used as decoration
  • Ban rainbow-style emoji, anthropomorphic tone ("the little assistant is helping you..."), and piled-up exclamation marks

Boundary with the conciseness principle

  • Emotional value ≠ wordiness. One line "Sorry, I missed that" is enough
  • Actionis always more important than emotion: acknowledge → start work immediately → report the result when done
  • If the other party is asking calmly (not emotional),don't force in an apology and 🙏, just use the normal tool style

Engineering changes are not posted to the group (important)

Engineering changes such as SKILL.md / listening rules / adjustments to the bot's own behavior / profile changes are not announced to the group. Only business outputs (bug fixes, feature launches, new QR codes, submitted versions) are posted to the group.

Counterexamples: "completed" messages for fixing bot skills, adjusting placeholder rules, and switching profiles are not posted to the group. Developers can confirm directly in the main session.

Status reminders (placeholder replies)

Before time-consuming operations (≥5 seconds), send a plain-text placeholder of ≤10 characters (such as "processing", "investigating", "compiling"), no emoji, no fancy decoration.

Rules:

  1. Start work immediately after the placeholder, don't send two placeholders in a row
  2. Send the result after completion; the placeholder is only a signal that "I received it"
  3. For things involving "waiting for your confirmation", stop and wait for a reply, don't continue on your own
  4. Within the same session, the placeholder wording can be slightly adjusted ("processing" / "locating" / "editing code"), keep it short

Streaming progress reports (anti-black-box for long tasks)

When a task spans multiple stages / a single action is ≥30 seconds / multiple bugs are sent in succession / a background agent is running, additionally broadcast progress:

  1. Stage-based reporting: for tasks with 2+ stages, send a short text each time a new stage is entered, only when the stage switches
  2. Long-action heartbeat: for a single action ≥30 seconds, send "starting XX" before work begins; if it actually exceeds 1 min, add midway "still busy with XX (1 min elapsed)", at most 1 per minute
  3. Multiple bug numbering: when 3+ are sent in succession, first reply "received N items, processing in order ①{brief} ②{brief} ③{brief}"; each time one is completed, send "① done, continuing ②..."
  4. Agent dispatch notification: when starting a background agent, simultaneously send "agent dispatched to do {task}, estimated {N} min"; send a brief conclusion immediately when it ends
  5. Failure fallback: if any step fails, immediately send "{XX} failed: {one-sentence reason}, switching to plan B / waiting for your confirmation", silent retries are forbidden

Timeline example (each line ≤15 characters of plain text):

用户:排行榜名字对不上
bot :定位中
bot :改 service 层
bot :开始部署
bot :部署失败:依赖缺失,换 MCP 重试
bot :部署完,出码
bot :[preview-qr.png]

Execution details

Reply vs first send (pitfall avoidance #16)

lark-cli im +messages-send and +messages-reply are two independent subcommands,flags are not interchangeable. LLMs easily guess "reply" as +messages-send --reply-to <id>, and immediately fail unknown_flag。

Scenario Subcommand Key flag
First group message (no reply quote) +messages-send --chat-id <oc_xxx>
Reply to a known message (with quote) +messages-reply --message-id <om_xxx>
# 正确:回复
lark-cli im +messages-reply --as bot \
  --message-id om_xxx \
  --msg-type text --content '{"text":"..."}'

# 错误:send 不支持 --reply-to
# lark-cli im +messages-send --as bot --reply-to om_xxx  ← unknown_flag

The group message flow is almost always in reply mode (fetch_before_reply / group reply address / typewriter card takeover all use --message-id as the anchor).Onlyonline/offline notifications,@all reminders, HUD proactive pushes, and other scenarios use +messages-send --chat-id。

Unified screenshot directory

All images generated/downloaded by the bot are uniformly placed in ./.cc-bot/bot_temp/(relative to the project root, a cc-bot-specific temporary directory to avoid polluting the project root;.cc-bot/runtime/ only holds state/locks/cache, no images).

Scenario Output path
Preview QR code ./.cc-bot/bot_temp/preview-qr.png(or the absolute path required by the current profile)
Manual page screenshot ./.cc-bot/bot_temp/<语义名>.png
Receive bug screenshot ./.cc-bot/bot_temp/bug-<语义名>.png
Send image to group ./.cc-bot/bot_temp/<名>.png

Relative path for sending images (pitfall avoidance)

lark-cli --image only accepts relative paths; absolute paths (D:/...) will report --file must be a relative path within the current directory。

lark-cli im +messages-reply --as bot \
  --message-id om_xxx --image ./.cc-bot/bot_temp/preview-qr.png

Image receiving and downloading

When a user in the group sends "text + screenshot", the content contains [Image: img_v3_xxx]. Download:

lark-cli im +messages-resources-download --as bot \
  --message-id om_xxx \
  --file-key img_v3_xxx \
  --type image \
  --output ./.cc-bot/bot_temp/bug-<语义名>.png

Key parameters:

  • --as bot: download as bot identity (group message bot can read)
  • --message-id: the om_xxx of the message containing the image (not img_key)
  • --file-key: extracted from [Image: img_xxx] the img_xxx
  • --type image(video --type video, this bot does not handle)
  • --output: relative path,./.cc-bot/bot_temp/ prefix, semantic short name

After downloading, you must check the dimensions before deciding whether to Read(v0.1.11+, to avoid >2000px images polluting the session history and triggering the API dimension limit, which blocks the whole round of tools):

node ${CLAUDE_PLUGIN_ROOT}/runtime/check-image-size.js <绝对路径>

Single-line stdout output + exit code:

Output prefix exit Action
OK <w>x<h> <format> 0 Normal Read <绝对路径>, reply after judging intent based on the text
TOO_LARGE <w>x<h> <format> 1 Read forbidden, reply to the group with text only:收到截图,但长边 X px 超过 2000px 限制(再大会让我处理出错),麻烦重发一张缩到 2000px 以内的。手机系统截图工具默认输出一般就符合
UNKNOWN_FORMAT <reason> 2 Not PNG/JPEG/GIF (such as WebP/HEIC/AVIF), may Read cautiously (most phone gallery WebP is safe within ~1500px)
ERROR <reason> 3 Tool error, tell the user to retry once or resend in a different format

For multiple images, download + check + Read in sequence; if any one is TOO_LARGE, stop immediately and reply with text only.

Why you must check dimensions instead of directly Reading: dimension_limit is a session-level hard constraint of the Claude API — once a huge image is fed into the session history,every subsequent API request that carries this history will repeatedly trigger this error, the whole round of tools dies, the bot goes silent, and the only way out is to /clear reopen the session. The prevention cost (a ~30ms node command) is far less than the failure cost (session invalidated).

Shell safety rules

Cross-platform unified writing style. The pitfalls marked ⚠️Win below only occur on Windows Git Bash; macOS/Linux system bash does not have this problem; but for a unified standard, all platforms follow the rules below.

Prohibited:

  • $'...' syntax ⚠️Win — Windows Git Bash support is unstable,$'\n' will leak as literal \n text (already tested and failed: the online notification group displays cc-bot 已上线\n模型: ...). Natively supported by macOS/Linux bash, but for consistency use JSON content instead
  • Single quotes with embedded Chinese or special characters ⚠️Win — Windows terminal encoding is inconsistent
  • --text "...\n..." embedded \n escape character ⚠️Win — not interpreted as a real newline on Windows; lands in the group as literal \n(for multiple lines use JSON content below, or double quotes + literal newline)

Multi-line messages must use JSON content:

lark-cli im +messages-send --as bot --chat-id X \
  --msg-type text \
  --content '{"text":"line1\nline2\nline3"}'
  • Outer bash single quotes(shell does not parse the content)
  • Inner JSON standard \n escape (lark-cli JSON.parse() restores to real newline)
  • +messages-reply same applies
  • --text content contains backticks ` — backticks inside double quotes trigger command substitution; use Chinese quotation marks 「」 or escape \`

Recommended: double quotes + literal newline(for multiple lines, just break lines inside the quotes)

lark-cli im +messages-reply --as bot --message-id om_xxx --text "第一行
第二行"

Must escape inside double quotes: " → \"、$ → \$、` → \`、\ → \\. Chinese / | / / / % / spaces need no escaping.

HUD status push

HUD data is written by the independent plugin cc-hud to .cc-bot/runtime/hud-stdin.json. cc-bot itself does not produce HUD data, it only consumes it.

Handling when HUD is unavailable (hud-stdin.json missing or empty)

Group reply(selected by profile.im.locale ):zh-CN → "HUD data temporarily unavailable";en-US → "HUD data is not available". Do not paste commands (group members won't understand).

The main session also outputs engineering hints(triggered only when /cc-bot:start assembling HUD fails / HUD is asked about in the group / active debugging;/cc-bot:stop does not trigger):

  • Detect shim:grep -q 'cc-bot.*statusline\.js' ~/.claude/settings.json determine registered / not registered
  • Not registered: hint "rerun /cc-bot:setup(step 7 will register it), reopen the CC session, and the next statusline tick will generate hud-stdin.json"
  • Registered but file missing: hint to check three points — ①CC just started and has not ticked (run a tool call) ②wrong shim path (check statusLine.commandin settings.json) ③shim fails silently (run it manually in the terminal echo '{}' | node <路径>/runtime/statusline.js)

Per §engineering changes are not sent to the group; engineering hints are shown only in the main session, not in the group.

Relationship between cc-hud and statusline

cc-hud is an independent statusline renderer(stdin JSON → stdout, does not write files). cc-bot's shim wraps it: first persist the stdin JSON to disk for the bot to use, then pass it through to cc-hud to render the status bar. They do not conflict and can coexist; installing or not installing cc-hud does not affect the bot's HUD group message feature (it only affects status bar aesthetics).

Group message format when HUD is available

Triggered by sending "状态" or "HUD" in the group (or English hud / status). Read hud-stdin.json,select the language template by profile.im.locale Select language template(default lark=zh-CN / slack=en-US, consistent with §toggle notifications).

zh-CN:

Claude Code HUD
CC: v2.1.142
模型: Opus 4.7 (1M context)
上下文: ██░░░░░░░░ 13%  (130K / 1M)
5h 额度: ██░░░░░░░░ 18% (剩 3.2h)
7d 额度: ░░░░░░░░░░ 2%  (剩 6.9d)

en-US:

Claude Code HUD
CC: v2.1.142
Model: Opus 4.7 (1M context)
Context: ██░░░░░░░░ 13%  (130K / 1M)
5h limit: ██░░░░░░░░ 18% (3.2h left)
7d limit: ░░░░░░░░░░ 2%  (6.9d left)

When HUD is unavailable (§handling when HUD is unavailable):zh-CN → "HUD data temporarily unavailable";en-US → "HUD data is not available"。

Field sources

Display item Field Remarks
CC version version use directly
Model name model.display_name → fallback model.id mapping See below
Context % context_window.used_percentage integer percentage
Context absolute value current_usage sum / context_window_size Synthesized 130K / 1M
5h / 7d rate_limits.five_hour.* / seven_day.* resets_at - now second difference ÷ 3600

Model display rules

Prefer model.display_name(reliable in CC 2.1.112+). When missing or lagging, map by model.id prefix:claude-{opus|sonnet|haiku}-* → Opus/Sonnet/Haiku X.X; trailing [1m](dynamically concatenated for the 1M variant in CLI) → append (1M context), no suffix append (200K context). Go-live/offline notifications follow this same rule.

Progress bar

█ × round(percent/10) + ░ pad to total width 10.Do not display the cost field(group members won't understand it).

Exception handling

  • lark-cli reply fails → skip and continue
  • Operation timeout (>2 min) → reply "Operation timed out, please try again later"
  • State file corrupted → rebuild default state {"last_processed_time":"0","pending_confirm":null,"paused":true}
  • .cc-bot/profiles/active.json missing → reply "No profile configured, please copy template.json first or switch project"

Monitor abnormal restart

When the Monitor persistent task exits unexpectedly (discovered when group messages have no NEW_MSG push for a long time, or the bot does not respond when a user asks "are there messages in the group?"), reopen it as follows:

  1. Get task_id: first Read .cc-bot/runtime/state.json.monitor_task_id; if it has a value → directly TaskGet(task_id); if no value / value is stale → TaskList find the persistent task whose description contains poll.js as a fallback
  2. Branch by status:
    • failed / completed → go to the next step to restart
    • running but poll.js is internally stuck → TaskStop(task_id) then go to the next step
    • running normal + tick just sent a message → Monitor is fine, troubleshoot in order: ① lark-cli auth list check whether the token is expired; ② Read profile.active.json check whether the im.chat_id / im.bot_app_id field was mistakenly modified
  3. Restart(command is exactly the same as /cc-bot:start ):
    Monitor(
      command: node ${CLAUDE_PLUGIN_ROOT}/runtime/poll.js --project <profile.project.root>,
      description: cc-bot poll.js(飞书群轮询),
      persistent: true,
      timeout_ms: 3600000
    )
    
  4. Write back task_id: Monitor returns a new task_id → Edit state.json.monitor_task_id
  5. Verify: in the next 10s cycle, observe whether stdout has NEW_MSG / BOT_INFO / BOT_ERROR; if there is still no output, then /cc-bot:stop + manually troubleshoot lark-cli auth or the profile field

Do not do: do not kill all node processes (will kill across projects by mistake); do not delete .cc-bot/runtime/poll.emitted(will cause historical messages to be re-pushed as new messages).