懶人包
- 每則 LINE 訊息都會開一個新的
claude -p程序,但帶--resume <session id>,所以接的是同一段對話。對話內容存在 Claude Code 自己的 session 檔,服務只記一個 id。- 服務的狀態只有一個
state.json:chatSessionId(常駐聊天 session)加上threads(LINE 訊息 id → session、資料夾、任務卡)。上線兩天,常駐 session 一個檔案就接了 11 則聊天、跨 12 小時。- 同時問好幾件事時,
chat-lanes.ts用 Promise 鏈分線:同一條線依序 resume 同一個 session,不同的線並行;常駐 session 正在忙就用--fork-session分出一段。- 每則訊息先交給 Claude(Sonnet)判讀,一次決定四件事:要排任務、聊天還是問進度;屬於哪個分類;新開還是接續哪張舊任務;要做哪幾種任務。判讀結果直接回給我看,不靠關鍵字規則。
- 分類存進任務卡,下次判讀時舊任務連分類一起列給 Claude,同網址的標 ★ 排最前面,所以「上次那個」會接回同一張卡、同一個 session。判讀沒回來才退回網址規則或 Haiku 分流。
- 長按「回覆」跳過判讀,用被引用訊息的 id 直接查出 session。
- 背景任務一件一張 Markdown 卡片,可以同時跑好幾件。session id 開工前先寫進卡片,服務重啟後用
--resume接著做。
line-talk 是我自己用的 LINE 官方帳號:手機傳訊息,家裡桌機上的 Claude Code 處理完再推回 LINE。這篇照一則訊息實際走過的程式碼,說明它怎麼讓對話接得上。
LINE → Cloudflare Tunnel → Bun 服務(127.0.0.1:8766,server.ts)→ claude -p --resume <id>
程式是 TypeScript,跑在 Bun 上,主要檔案:
| 檔案 | 行數 | 做什麼 |
|---|---|---|
server.ts | 632 | 收 webhook、分派訊息、聊天流程 |
intake.ts | 161 | 收訊判讀的 prompt、解析與回覆文字 |
claude.ts | 79 | 組 claude -p 參數、開程序 |
chat-lanes.ts | 49 | 聊天分線 |
triage.ts | 42 | 判讀失敗時的 Haiku 分流 |
jobs.ts | 219 | 背景任務佇列、任務卡、重啟接續 |
store.ts | 179 | state.json、任務卡讀寫 |
line.ts | 213 | LINE API:reply、push、記訊息 id |
📌 目錄
- 每則訊息都是新程序,為什麼還接得上?
- 一則訊息從進來到回覆走過哪些程式?
- 聊天分線怎麼實作?
- 每則訊息怎麼先讓 Claude 判讀?
- 長按「回覆」怎麼找回原本的 session?
- 背景任務怎麼排、怎麼接續?
- 「之前那個」是怎麼找到的?
- 平常怎麼用:圖文選單
- 上線兩天的數字
- 限制
- 常見問題
🧠 每則訊息都是新程序,為什麼還接得上?
服務裡沒有常駐的 Claude 程序。每則訊息進來,claude.ts 用 Bun.spawn 開一個 claude -p,回答完程序就結束:
// claude.ts
const args = [CLAUDE_BIN, '-p', '--output-format', 'json', '--settings', SETTINGS];
if (opt.systemPrompt) args.push('--append-system-prompt', opt.systemPrompt);
if (opt.sessionId) args.push('--resume', opt.sessionId);
if (opt.sessionId && opt.fork) args.push('--fork-session');
if (opt.newSessionId) args.push('--session-id', opt.newSessionId);
if (opt.quick) args.push('--model', opt.model ?? 'haiku', '--strict-mcp-config', '--tools', '', '--no-session-persistence');
接得上是因為 --resume <session id>。Claude Code 把每段對話存成 ~/.claude/projects/<資料夾>/<session id>.jsonl,--resume 會讀回整段對話再接下去。服務本身只要記住「下一句該 resume 哪個 id」。
記憶分三個地方:
| 東西 | 放哪 | 誰寫 |
|---|---|---|
| 對話內容 | ~/.claude/projects/<資料夾>/<session id>.jsonl | Claude Code |
| 下一句接哪個 session | ~/.local/share/line-talk/state.json | line-talk |
| 做過的任務、收過的訊息 | Obsidian vault 的 line-talk/items/*.md、log.jsonl | line-talk |
state.json 的長相(id 已遮):
{
"chatSessionId": "<uuid>",
"threads": {
"<LINE 訊息 id>": {
"sessionId": "<uuid>",
"cwd": "~/workspace/line-lab/20261023-114229",
"title": "台北狂騎 WO13:破風、後追、隨機起跑格已上線",
"item": "20261023-114229"
}
}
}
chatSessionId:常駐聊天 session。一般聊天都 resume 它;每次聊完把回傳的session_id寫回來,順便記輪數和最後一問一答(server.ts:184-187)。- 常駐 session 接滿 40 輪或超過 24 小時就換新的,最後一問一答帶進新 session 的第一句,話題不會斷。兩個門檻是
CHAT_SESSION_MAX_TURNS、CHAT_SESSION_MAX_AGE_HOURS。 - 聊天出錯(不是逾時),而且出錯的就是常駐 session 時清成
null(server.ts:188),下一句會開乾淨的 session。 - 傳
/new也是清成null(server.ts:309)。 threads:bot 送出的每則 LINE 訊息 id 都對到一個 session,長按回覆時用(後面講)。最多留 3000 筆。
claude -p 程序。state.json 有 24 筆 thread,對到 12 個不同的 session,其中 11 筆是常駐 session。
--settings '{"disableAllHooks":true}' 是因為我桌機的 hooks 是給終端機用的,例如結束前擋一下要我檢查任務清單。LINE 那頭沒人能回應,所以全部關掉。
🔀 一則訊息從進來到回覆走過哪些程式?
以一則普通文字訊息為例:
- 收 webhook(
server.ts的Bun.serve):驗x-line-signature的 HMAC-SHA256,不對回 401;對了先回ok給 LINE,事件丟給handle()背景處理。 - 只理一個人(
handle()):e.source.userId不是設定的 owner 就記 log 丟掉。文字訊息寫一行到log.jsonl,交給onText()。 - 分派(
onText()),照順序判斷:
/ 開頭的指令(/new、/jobs、/research…)→ 直接處理。
- 有 quotedMessageId(長按回覆)→ 查 threads,找到就帶著那個 thread 進聊天。
- 其他全部交給 judge(),由 Claude 判讀(下一節之後詳講),再依判讀結果走:排任務、回進度、接回舊任務的 session 聊,或進一般聊天。
- 判讀沒回來:有網址、圖片或選單模式就照網址規則排任務;其他進聊天,改用 Haiku 分流。
- 先回一句(
routeChat()):叫出 LINE 的「輸入中」動畫,用判讀給的線和 ack,以 replyToken 立刻回。 - 正式回答(
chat()):runClaude(text, { cwd, sessionId, fork, systemPrompt: persona.md }),最長 4 小時,每 15 分鐘 push 一則「⏳ 還在做,已 N 分鐘」。 - 送出並記住(
line.ts的send()):push 回答,第一則訊息帶原訊息的quoteToken,LINE 會顯示成「引用了你那則訊息」。LINE 回傳的每個訊息 id 都用rememberThread()寫進threads。
send() 先用 replyToken,失敗或過期就改 push:
// line.ts
if (replyToken) {
const r = await post('/v2/bot/message/reply', { replyToken, messages });
if (r.ok) ids = await sentIds(r);
}
if (ids === null) {
const p = await post('/v2/bot/message/push', { to: OWNER, messages });
if (p.ok) ids = await sentIds(p);
}
if (thread && ids) for (const id of ids) await rememberThread(id, thread);
🧵 聊天分線怎麼實作?
一件事還在跑時,下一則訊息不用等它。chat-lanes.ts 用一個 Map 存所有正在跑的線,每條線是一條 Promise 鏈:
// chat-lanes.ts
lane.pending += 1;
lane.chain = lane.chain.then(async () => {
try {
const id = await task(lane.session);
if (id) {
lane.session = { id, fork: false };
if (!lane.sessionIds.includes(id)) lane.sessionIds.push(id);
}
} finally {
lane.pending -= 1;
if (lane.pending === 0) lanes.delete(laneId);
}
});
- 同一條線依序跑:新訊息接在
chain後面,前一則做完才輪到它。 - 接同一個 session:每則做完把回傳的 session id 存回
lane.session,下一則就 resume 它。 - 不同線並行:每條線各自一條鏈,互不等待。
- 做完就刪:
pending歸零就從Map拿掉,所以Map裡永遠只有正在忙的線。 - 線的 id:一般聊天是
chat:<時間戳>,長按回覆是thread:<session id>。
server.ts:143-151:
const chatId = (await chatThread())?.sessionId;
const limits = { maxTurns: CHAT_SESSION_MAX_TURNS, maxAgeHours: CHAT_SESSION_MAX_AGE_HOURS };
const rotate = shouldRotate(!!chatId, await chatMeta(), limits);
// 常駐聊天 session 正被別條線使用時,分出一段新對話:保留聊天脈絡,兩邊不互相覆蓋
const fork = !rotate && !!chatId && lanes.findBySession(chatId) !== null;
const model = CHAT_SIMPLE_MODEL && tri.simple ? CHAT_SIMPLE_MODEL : undefined;
const session = { id: rotate ? undefined : chatId, fork };
- 常駐 session 閒著 → 直接
--resume它。 - 常駐 session 正在別條線上跑 →
--resume <id> --fork-session:新的一段看得到之前的對話,但寫進另一個新 session,兩個程序不會同時寫同一個檔。 - 輪數或時間到了 → 不帶 id 開新 session,第一句前面附上上一輪的問答。
- 判讀說這句很簡單(打招呼、道謝)而且有設
CHAT_SIMPLE_MODEL→ 用小模型回。
⚡ 每則訊息怎麼先讓 Claude 判讀?
一則 LINE 訊息進來,要先回答三個問題才能動手:這是新的事還是延續某件舊事、要做什麼、跟以前哪些東西有關。這三件事都靠語意,「上次那個再深入一點」「這篇寫成 blog 就好」用關鍵字規則判不準,所以交給 Claude 判讀,judge() 在 server.ts:212-231:
claude -p --model sonnet --strict-mcp-config --tools '' --no-session-persistence
不載 MCP、不給工具、不留 session,逾時 45 秒。模型由 INTAKE_MODEL 設定,預設 Sonnet。
prompt 給 Claude 看什麼(intake.ts:60-95)
- 我的訊息文字、網址、圖片張數;剛點過選單的話,加上選單選的類型。
- 正在進行的聊天線(標題與已跑幾分鐘)。
- 最多 40 張舊任務卡,同網址的排最前面並標 ★,其餘取最近的。每張一行:
- <任務 id>|<mm/dd>|<類型>|<狀態>|分類:<分類>|<標題>|<網址>|★同網址
要它判斷的四件事
1. kind:job=要排背景任務;status=在問進度、催;chat=其他。
2. topic:這則屬於哪一類,2–8 字的主題。舊任務有接近的分類就沿用同一個字,方便之後歸在一起。
3. resume:同網址、或明顯在延續那件事(「上次那個」「接著做」「再深入」)才接續,填舊任務 id。只是主題相近不算接續,放 related。
4. types:訊息明講要什麼就照講的;沒明講用預設整條鏈。接續舊任務時,已做過的種類不重做。
kind 是 chat 時另外回 lane(同一件事填正在進行的線編號,否則 new)與 simple(打招呼、道謝這類能用小模型回的)。回傳一行 JSON:
| 欄位 | 用途 |
|---|---|
kind | job 排任務、status 回進度、chat 進聊天 |
topic | 分類,寫進任務卡 frontmatter,卡片描述顯示成 [分類] |
resume | 要接續的舊任務 id;有值就從那張卡的 session 接著做 |
types | 依序要做的任務,例如 research → blog |
related | 相關但不接續的舊任務,報告路徑附進新任務的需求 |
reason | 一句話理由,原樣回給我看 |
ack | 30 字內的即時回應 |
排任務時,LINE 上收到的是判讀本身,不是一句「收到」。intakeText() 組成的格式:
收到,我的判讀:
・分類:<topic>
・Session:接續 <mm/dd> <類型>「<舊任務標題>」(新開時寫「新開」)
・要做:<類型> → <類型>
・相關舊事:<最多 3 張>
・理由:<reason>
判錯了一眼就看得出來,可以馬上更正。
解析守門(parseIntake(),intake.ts:99-130)
- 抓第一段
{…}再JSON.parse,壞掉回null。 resume、related只收清單裡真的有的任務 id;types只收已啟用的類型並去重。- 點過選單時,選單的類型強制排第一個。
- kind 是 job 卻沒有任何類型、kind 不認得、ack 空白 → 整個當判讀失敗。
- topic 沒給就填「未分類」。
| 判讀結果 | 做什麼 |
|---|---|
| job,新開 | enqueue() 依 types 排任務,分類寫進卡片 |
| job,接續舊卡 | derive() 從舊卡開新任務,帶著舊卡的報告路徑;舊卡還在排隊或在跑就不重排 |
| status | 回 ack 加上目前的任務清單 |
| chat,接續舊卡且卡上有 session | 進那張卡的 session 聊,cwd 切回它的工作資料夾 |
| chat,其他 | routeChat(),用判讀給的 lane、simple、ack |
逾時或解析失敗不會讓訊息卡住:有網址、圖片或選單模式就照網址規則排任務,回「收到。Claude 判讀這次沒回來,先照規則排」;其他訊息進聊天,改用 triage.ts 的 Haiku 分流(逾時 30 秒)決定進哪條線。
每次判讀在 server.log 寫一行 intake kind=… topic=… resume=… types=…,在 vault 的 log.jsonl 寫一筆 kind:"intake",token 用量記成「聊天(收訊判讀)」,後台看得到這一層花多少。
↩️ 長按「回覆」怎麼找回原本的 session?
這條路不經過判讀,是最準的接話方式。
- bot 每次送訊息,LINE API 回傳
sentMessages[].id。send()把每個 id 對到當時的 thread 寫進state.json。 - 我在 LINE 長按某則 bot 訊息、選「回覆」,webhook 的 message 會帶
quotedMessageId。 onText()用findThread(quotedMessageId)查出{sessionId, cwd, title, item}。routeChat()把它推進thread:<sessionId>這條線,resume 那個 session,cwd也切回當時的資料夾:
// server.ts:123-129
if (req.thread) {
const running = lanes.findBySession(req.thread.sessionId);
const t = lanes.push(running?.id ?? `thread:${req.thread.sessionId}`, { label: req.label, session: { id: req.thread.sessionId, fork: false } }, run);
const ack = typeof judged === 'string' ? `${judged}(接回「${oneLine(req.thread.title, 24)}」那段)` : `收到,${OWNER_NAME},接著這段處理。`;
await send(replyToken, t.joined ? `收到,${OWNER_NAME}。「${t.lane.label}」那件還在做,做完接著處理這則。` : ack);
return t.done;
}
- 引用的是背景任務的完成訊息時,
cwd是那件任務的工作資料夾。追問「README 第三段」時,它是在那個 repo 裡讀。 - 那個 session 還在某條線上跑,就排在它後面,回「那件還在做,做完接著處理這則」。
- 判讀說要接續的舊卡上有 session 時,也走這段程式:
judged是判讀給的 ack,後面補上接回哪一段。 - 引用任務訊息、內容剛好是「寫 blog」「寫成 blog」「寫文章」或「做 shorts」「做成 shorts」時,不進聊天,直接用那件任務開一件新的 blog/Shorts 任務(
derive(),下一節)。
🗂️ 背景任務怎麼排、怎麼接續?
研究一個 repo、改一個網站動輒半小時,放在聊天線會一直報進度。這類事走 jobs.ts 的佇列,同時跑幾件由 JOB_CONCURRENCY 決定(預設 1,我的電腦設 4)。
進佇列的入口
- 判讀為 job 的訊息:
startJudgedJob()依判讀的 types 與 resume 呼叫enqueue()或derive()。 - 判讀沒回來、但有網址、圖片或選單模式:照網址規則
enqueue()。 - 改程式模式下的訊息:
startJob()直接排 build 任務。 /research <內容>這類指令。- 聊天中交代的事:
persona.md寫明「估計超過 5 分鐘的事一律排背景」,Claude 在enqueue.jsonl追加一行,ticker.ts每 30 秒讀一次、清空、逐行enqueue():
{"type": "research", "request": "研究這個 repo 的架構", "urls": ["https://github.com/..."]}
每件任務一張卡
enqueue() 產生台灣時間的 id(20261023-134134,同一秒重複就加 -2),建工作資料夾,寫一張 items/<id>.md。frontmatter 每個值都存成 JSON,讀回時逐行 JSON.parse:
---
id: "20261023-134134"
type: "blog"
status: "done"
title: "Graphify 有裝,但只裝了一半:CLI 全域、Claude Code 整合只在暫存複本"
workDir: "~/workspace/line-lab/20261023-134134"
reportPath: "~/workspace/line-lab/20261023-134134/REPORT.md"
sessionId: "<uuid>"
durationMin: 4
summary: "有裝,但只裝了一半。…"
---
內文放原始需求、LINE 摘要、完整報告,以及一行桌機接手指令:
cd "<工作資料夾>" && claude --resume <sessionId>
session id 先寫進卡片,再開工(startFresh()):
// jobs.ts
const sessionId = crypto.randomUUID();
const startedItem: Item = { ...item, status: 'running', sessionId };
await saveItem(startedItem);
// …組 prompt…
return { item: startedItem, res: await runClaude(prompt, { cwd, newSessionId: sessionId, timeoutMs: JOB_TIMEOUT_MS }) };
- 用
--session-id <自己產的 uuid>開 session,所以程序還沒結束,卡片上就已經有 id。 - 服務啟動時
resumeQueue()掃所有卡片:queued和running都重新排隊。 - 輪到
running的那張,runJob()用--resume <卡片上的 id>接著做,prompt 是job-resume.md:先看工作目錄和REPORT.md做到哪,接著做完,不要從頭重來。 - Claude 回
No conversation found(session 還沒寫入就斷了)就用同一份需求重開。 - 開機自動啟動:服務註冊成 Windows 工作排程,觸發條件是開機(延遲 30 秒)與登入,登入類型 S4U,不用登入也會跑。
runJob() 結尾):
- 讀
REPORT.md的## LINE 摘要段落推到 LINE;有## 等尊上決定段落就附在後面。沒有摘要段落就算失敗。 - 結尾固定一句「長按這則訊息「回覆」,可以接著跟我聊這個題目。」這則的訊息 id 會記成 thread,
item欄位是任務 id,cwd是工作資料夾。 - 快速回覆按鈕:👍 👎、寫成 Blog、做 Shorts、看進度。「寫成 Blog」送出 postback
act=derive&type=blog&item=<id>。 derive()開一件新任務,需求是原需求加一句「延續任務<id>,那次的研究報告在<路徑>,先讀它」。
🔎 「之前那個」是怎麼找到的?
session 只負責「接著剛才那段聊」。我問「上週研究的那個 AI 影片工具」時,常駐 session 可能早就換過了,靠的是另外兩層。
第一層是收訊判讀:
- 每張任務卡有判讀時給的分類(topic),判讀 prompt 要求沿用既有的分類字,同一題材的任務會歸在同一個字下。
- 判讀時舊任務連分類一起列給 Claude,同網址的標 ★ 排最前面。明顯在延續的填
resume,從那張卡的 session 接著做;只是主題相近的填related。 related的報告路徑會附在新任務需求的最後,寫成「相關的舊任務,有需要就讀」,新任務開工時就知道以前做過什麼。
- 每則進出的 LINE 訊息寫一行到 vault 的
log.jsonl(store.ts的logMessage())。 - 每件任務一張
items/*.md,frontmatter 有 status、title、topic、links,內文有摘要與完整報告。 persona.md(每次聊天都用--append-system-prompt帶上)列出這兩個路徑,並寫明被問「之前那個」時先讀檔再回答。
sessionId 和 workDir 接著做。Obsidian vault 怎麼讓 Claude Code 讀寫,我在個人知識庫怎麼讓 AI 真的用上寫過另一條路。
📱 平常怎麼用:圖文選單
| 選單 | postback | 服務做什麼 |
|---|---|---|
| 🔍 研究連結 | act=menu&k=research | 回用法提示,記 10 分鐘的研究模式 |
| ✍️ 寫 Blog | act=menu&k=blog | 同上,下一則訊息直接開 blog 任務 |
| 🎬 做 Shorts | act=menu&k=shorts | 同上,開 Shorts 任務 |
| 🎙️ 錄音・記事・提醒 | act=menu&k=note | 回用法提示;語音用 faster-whisper 在本機轉文字再進聊天 |
| 📋 進度・找舊東西 | act=jobs | 列進行中、排隊、最近 6 件完成的任務 |
| 📊 使用回顧 | act=review | 近 30 天各功能次數、失敗數、👍👎 比例 |
📊 上線兩天的數字
從 2026-10-22 上線到寫這篇,log.jsonl、state.json 與事件紀錄:
| 項目 | 數量 |
|---|---|
| LINE 訊息 | 69 則(我傳 20、它回 49) |
| 長按引用回覆 | 2 則 |
| 背景任務卡片 | 11 張 |
state.json 的 thread | 24 筆,對到 12 個 session |
| 常駐 session 接的聊天 | 11 則,跨 12 小時 |
| 單件任務耗時 | 2–88 分鐘 |
⚠️ 限制
- 只服務一個人:
handle()只收一個 userId,state.json只有一個chatSessionId。要給多人用,兩者都得按使用者拆開。 - 電腦要開著:關機期間 LINE 的 webhook 打不到,那段時間的訊息不會補處理。
- 背景任務同時跑的件數有上限:超過
JOB_CONCURRENCY的任務會排隊,88 分鐘的任務會占住一格。聊天分線不受影響。 - 判讀會判錯:Claude 只看得到訊息文字、網址和舊任務的標題與分類,看不到網址內容。把延續判成新開時,會開一張新卡。判讀內容會回給我看,判錯就更正;要確定接到哪件事就用長按回覆。
- 每則訊息多一次模型呼叫:判讀用 Sonnet,最長 45 秒,token 用量在後台的「收訊判讀」一欄。
- fork 之後常駐 session 只留一份:兩條線做完都會把自己的 session id 寫回
chatSessionId,後完成的那條成為下一句的起點。 - LINE 不渲染 Markdown:
persona.md規定回覆不用表格、標題、粗體、程式碼框,條列用「・」。
❓ 常見問題
每則 LINE 訊息都開新的 session 嗎?
每則都開新的 claude -p 程序,但 session 不是新的:程序帶 --resume <chatSessionId>,讀回同一段對話再接下去。只有傳 /new、常駐 session 出錯、接滿 40 輪或超過 24 小時,或常駐 session 正在忙要 fork 時,才會換成新的 session。判讀說要接續某張舊任務時,則是 resume 那張卡上的 session。
LINE 官方帳號串 Claude Code 要用 API key 嗎?
line-talk 呼叫本機的 claude CLI,用的是 Claude Code 登入的帳號,服務沒有另外設 Anthropic API key。LINE 這邊需要 Messaging API 的 channel secret 和 access token,放在 .env,不進版控。
長按回覆和直接打字差在哪?
直接打字先經過 Claude 判讀,由它決定新開或接續哪張舊任務;長按回覆用被引用訊息的 id 查 state.json,直接 resume 那則訊息背後的 session,連工作資料夾都切回去,不經過判讀。
背景任務做到一半,可以從電腦接手嗎?
可以。任務卡片裡有 cd "<工作資料夾>" && claude --resume <sessionId>,在終端機貼上就是同一個 session。
收訊判讀多久回應?判讀失敗會怎樣?
逾時上限 45 秒,這是程式設定值,不是實測平均。逾時或格式不對時,有網址、圖片就照網址規則排任務,其他訊息改用 Haiku 分流(逾時 30 秒)決定進哪條聊天線。
🔗 延伸資源
- LINE Messaging API reference:webhook、reply、push、quick reply、rich menu
- Claude Code CLI reference:
-p、--resume、--fork-session、--session-id - 個人知識庫怎麼讓 AI 真的用上?Obsidian + Claude Code hook 讀寫兩條路實作
- Claude Code Teams Plugin Webhook 5 步設定教學