Update 2026-05-17. Bài này ban đầu mô tả độ trễ “khoảng một phút” rồi session foreground sẽ tự xuất hiện. Sau khi test thực tế và đọc lại binary, narrative đó không chính xác. Thực tế là session foreground gõ
claudethường không tự xuất hiện trong FleetView, dù đợi bao lâu. Cách deterministic duy nhất là gõ slash command/bgtrong session đó. Phần “Vì sao trễ chứ không phải không” đã được viết lại theo evidence mới. Phần kiến trúc daemon vàFp9còn nguyên (đúng theo binary).
Tình huống
Bạn mở terminal, gõ claude để bắt đầu một session interactive bình thường. Rồi mở thêm một terminal khác chạy claude agents để xem FleetView. Session vừa mở không có ở đó.
Đợi vài phút, mở lại claude agents. Vẫn không có. Có khi đợi lâu hơn nữa cũng không bao giờ thấy.
Trong session foreground đó, gõ slash command /bg. Quay lại claude agents. Lần này nó xuất hiện ngay.
Câu hỏi: cơ chế nào quyết định session nào hiện và session nào không?
Câu trả lời ngắn: có một daemon supervisor đứng giữa CLI và mọi session. FleetView render dựa trên hai nguồn: file state.json trên disk và roster của daemon. Session foreground không tự sinh state.json, mà cũng không lọt vào diện orphan adoption (vì source vẫn là spare). Gõ /bg thì worker được đánh dấu background, daemon viết state.json, FleetView thấy. Phần còn lại của bài là chi tiết.
Hai nguồn dữ liệu của claude agents
FleetView (chính là UI khi gõ claude agents) đọc song song từ hai nguồn:
~/.claude/jobs/<short>/state.json. Mỗi session có một thư mục dạng~/.claude/jobs/<8-hex>/chứastate.json,timeline.jsonl, các tool output. Filestate.jsonchứa intent, name, sessionId, cwd, template, backend, các timestamp.~/.claude/daemon/roster.json. Một file duy nhất do daemon supervisor maintain, liệt kê tất cả worker process đang sống, mỗi entry kèm pid, socket, cwd, và quan trọng nhất làdispatch.source.
Hai nguồn này không trùng nhau. Roster có thể có entry mà chưa có state.json tương ứng, và ngược lại có những state.json của session đã kết thúc nhưng vẫn còn trên disk.
Daemon là gì
Từ phiên bản 2.x, mỗi máy có một claude-daemon supervisor process chạy nền. Khi bạn gõ claude trong terminal, CLI không spawn một Node runtime mới. Nó kết nối tới daemon qua socket ở /tmp/cc-daemon-<uid>/, daemon hand cho bạn một worker process sẵn có, và terminal của bạn được attach vào PTY của worker đó.
Lý do tồn tại của daemon là tốc độ. Spawn một Node + load full bundle Claude Code mất 2-3 giây. Daemon giải quyết bằng cách pre-spawn sẵn vài worker idle, gọi là spare pool. Khi bạn cần session mới, daemon nhặt một spare đã warm sẵn, gửi cwd và args qua socket, và worker bắt đầu phục vụ gần như tức thì.
Có thể tự kiểm chứng bằng cách xem roster:
jq '.workers | to_entries[] | {short:.key, source:.value.dispatch.source, pid:.value.pid, cwd:.value.cwd}' \
~/.claude/daemon/roster.json
Ví dụ output (đã rút gọn):
{"short":"24824189","source":"spare","pid":55646,"cwd":"~/WORK/some-project"}
{"short":"49d1c2a1","source":"slash","pid":4831,"cwd":"~/WORK/some-project"}
{"short":"e7f11305","source":"fleet","pid":38278,"cwd":"~/.claude"}
{"short":"8424e42a","source":"spare","pid":62852,"cwd":"~/.claude"}
dispatch.source là field then chốt. Trong binary có 5 giá trị:
spare: worker đến từ spare pool, idle hoặc vừa được claim.slash: session được dispatch qua slash command như/agents, từ resume, hoặc RemoteTrigger / Cron / Telegram bridge.fleet: session do FleetView dispatch (gõ Enter trong UIclaude agents).shell: session dispatch trực tiếp qua daemon protocol từ shell (path này có nhưng ít gặp; thườngclaudetrong terminal đi qua claim spare thay vì path này).user: liên quan tới một số agent template do user định nghĩa.
Field này được set một lần lúc dispatch và không có code path nào re-assign sau đó. Đó là chi tiết quan trọng cho phần “vì sao trễ” bên dưới.
Orphan adoption: vì sao có session bị filter
FleetView không phải cứ thấy worker trong roster là render. Có một hàm tên là Fp9 trong binary lo việc merge hai nguồn dữ liệu. Logic rút gọn:
function Fp9(jobsFromDisk, workersFromRoster) {
const known = new Set(jobsFromDisk.map(j => j.id));
const orphans = workersFromRoster.filter(w =>
!known.has(w.short) // roster có nhưng disk chưa có state.json
&& w.source !== "spare" // BỎ QUA spare workers
&& !w.dying // BỎ QUA worker đang shutdown
);
for (const w of orphans) {
mkdirSync(`~/.claude/jobs/${w.short}`, { recursive: true });
writeFileSync(`~/.claude/jobs/${w.short}/state.json`,
JSON.stringify(buildState(w)),
{ flag: "wx" }
);
emitTelemetry("tengu_bg_roster_orphan_adopted");
}
return [...jobsFromDisk, ...orphans];
}
Ba điểm cần chú ý:
- Filter
source !== "spare". Spare workers bị bỏ ra khỏi diện orphan adoption. Worker có source là spare sẽ chỉ xuất hiện trong UI nếu nó đã cóstate.jsontrên disk (nằm trongjobsFromDisk). Spare worker idle thì cả hai đều không có, nên không hiện. flag: "wx"(write exclusive). Nếustate.jsonđã tồn tại sẵn, ghi sẽ thất bại im lặng. Tôn trọng state do worker tự viết.- Telemetry
tengu_bg_roster_orphan_adopted. Sự kiện này được emit mỗi lần FleetView phát hiện một worker chưa có disk state và phải tự sinh. Nếu bật telemetry và filter event này, có thể đếm chính xác số lần FleetView “nhận nuôi” session.
Vì sao session foreground không xuất hiện (mặc dù worker đang sống)
Quay lại tình huống đầu bài. Khi bạn gõ claude trong terminal:
- CLI yêu cầu daemon
claimSpare. Daemon nhặt một spare worker, gửi cwd và args qua socket. Terminal của bạn được attach vào PTY của worker. - Roster vẫn ghi worker đó là
dispatch.source: "spare"ngay tại thời điểm bạn vừa được gắn vào. Quan trọng: source field không được re-assign sau khi claim. Worker này sẽ giữ sourcesparecho đến khi exit, trừ khi user trigger explicit transition (như/bg). - Worker bắt đầu chạy UI cho bạn, nhưng không tự ghi
state.jsonngay. State chỉ được ghi khi có lý do (sẽ giải thích phần dưới). - Bạn mở
claude agents. FleetView load roster, chạyFp9. Worker này có sourcesparenên bị filter khỏi orphan adoption. Không cóstate.jsontrên disk nên cũng không nằm trongjobsFromDisk. Kết quả: không xuất hiện. - Nhiều phút sau, mở lại
claude agents. Vẫn không xuất hiện. Vì source không bao giờ update, và worker không tự ghi state cho đến khi có sự kiện cụ thể.
Có thể verify bằng test thực tế. Mở hai pane tmux, cả hai cùng cd vào một project rồi gõ claude. Cả hai có UI lên, prompt sẵn sàng nhận input. Mở claude agents ở pane thứ ba. Không pane nào trong hai pane trên xuất hiện. Đợi 5 phút, vẫn không xuất hiện.
Trong một pane bất kỳ, gõ /bg. Quay lại claude agents. Lúc này pane đó hiện ra ngay, kèm cwd và một initial name. Pane còn lại (chưa /bg) vẫn vắng mặt.
Đó là bằng chứng: session foreground không tự xuất hiện theo thời gian. Cần một transition explicit để worker đánh dấu mình là background, ghi state.json, và bước vào tầm nhìn của FleetView.
Hai cách deterministic: /bg và claude --bg
Có hai con đường explicit đẩy session “đi vào tầm nhìn” của FleetView. Cả hai đều ghi state.json ngay và làm session hiện trong list ở lần render kế tiếp.
Cách 1: /bg (slash command bên trong session đang chạy)
Slash command /bg chạy bên trong một session foreground đang interactive. Tác dụng:
- Daemon đánh dấu worker đó là background. Source field thực ra vẫn là gốc (
spareban đầu), nhưng worker được transition sang một trạng thái nội bộ “đã được user move ra background”, khiến nó hợp lệ để được FleetView render. Cơ chế cụ thể có thể khác nhau giữa các phiên bản; điểm quan sát ổn định là sau/bg, session luôn hiện ra. - Worker ghi
state.jsonngay lập tức với initial name (sinh từ context đầu session, ví dụinitial-greeting). File này nằm trongjobsFromDiskcủa lầnFp9tiếp theo, nên FleetView render. - Terminal được detach khỏi PTY của worker. Session vẫn sống, giữ context, chỉ là terminal không attach nữa. Daemon trả về cho bạn shell prompt với hint reconnect:
claude attach <short>,claude logs <short>,claude stop <short>.
Sau /bg, refresh claude agents (hoặc đợi auto-poll vài giây). Session sẽ hiện. Không có race condition, không cần đợi 30-60s.
Workflow điển hình:
cd ~/WORK/some-project
claude # foreground UI
# làm việc một lúc, sau đó:
/bg # convert ra background, terminal detach
claude agents # FleetView thấy ngay
Cách 2: claude --bg (cờ shell, start background trực tiếp)
Cờ --bg cho claude bỏ qua giai đoạn foreground luôn. Daemon spawn worker mới với cwd inherit từ shell, đánh dấu background ngay, ghi state.json, in short ID + hint commands ra terminal, trả về prompt shell:
cd ~/WORK/some-project
claude --bg
# backgrounded · ea956980 (idle, send a prompt to start)
# claude agents list sessions
# claude attach ea956980 open in this terminal
# claude logs ea956980 show recent output
# claude stop ea956980 stop this session
claude agents # FleetView thấy ngay, status idle
Session tạo bởi --bg ở trạng thái idle (chưa có prompt). Dispatch prompt đầu qua một trong các cách:
claude attach <short>rồi gõ prompt trực tiếp (pull session về foreground tại terminal đang đứng).claude agentsmở UI, navigate tới session, gõ prompt.- RemoteTrigger, Telegram bridge, hoặc SDK gọi vào session theo short ID.
So sánh các kịch bản
| Kịch bản | Source | Có state.json? | Hiện trong FleetView? |
|---|---|---|---|
| Dispatch trực tiếp từ FleetView | fleet | Có (FleetView ghi ngay) | Hiện ngay |
claude foreground, để nguyên | spare | Không tự ghi | Không bao giờ (cho đến khi /bg hoặc activity nội bộ trigger) |
claude foreground, sau đó /bg | spare (giữ nguyên) | Có (worker ghi khi background) | Hiện ngay sau /bg |
claude --bg từ shell | spare (background-flagged) | Có (worker ghi ngay khi spawn) | Hiện ngay sau spawn, status idle |
Cách 1 hữu ích khi đang interactive rồi mới muốn detach. Cách 2 hữu ích khi pre-create session cho automation, RemoteTrigger, hoặc batch script không cần qua UI.
Bảng phân loại theo cách khởi tạo
Tổng kết khi nào session xuất hiện trong claude agents:
| Cách khởi tạo session | dispatch.source | Hiện trong claude agents? |
|---|---|---|
| Dispatch trực tiếp từ FleetView | fleet | Ngay (state.json có ngay từ đầu) |
Slash command /agents, RemoteTrigger, Cron, Telegram bridge | slash | Ngay |
| Resume một session cũ | slash | Ngay |
Gõ claude trong terminal (claim từ spare pool), foreground | spare | Không (cho đến khi user gõ /bg hoặc một sự kiện nội bộ trigger ghi state.json) |
claude foreground rồi /bg | spare (không đổi) | Ngay sau /bg (worker ghi state.json + detach PTY) |
claude --bg từ shell | spare (background-flagged ngay) | Ngay sau spawn (worker ghi state.json idle) |
| Worker còn ngồi idle trong spare pool | spare | Không (đúng nghĩa pre-warmed, chưa có session) |
claude -p print mode | (không vào roster) | Không (ephemeral, --no-session-persistence) |
| Worker đang dying | bất kỳ | Không (filter !dying) |
Khác biệt giữa dispatch FleetView (source fleet) và claude foreground (source spare) ở chỗ: với FleetView, code dispatch khởi tạo entry với source là fleet ngay và ghi state.json ngay; với claude foreground, code đi qua claim spare nên entry giữ source là spare và không có state.json. Hai path khác nhau, không phải cùng path nhưng “daemon update sau”.
Verify thủ công
Hai lệnh giúp soi trực tiếp:
# Roster đang có worker nào, source là gì
jq '.workers | to_entries[] | {short:.key, source:.value.dispatch.source, cwd:.value.cwd, pid:.value.pid}' \
~/.claude/daemon/roster.json
# State.json đã được FleetView/worker ghi ra
ls -la ~/.claude/jobs/*/state.json 2>/dev/null
So sánh hai list:
- Worker có trong roster với
source != "spare"mà chưa cóstate.jsontương ứng: ứng viên sẽ được orphan adopt ở lần FleetView refresh tiếp theo. - Worker có
source: "spare"mà chưa cóstate.json: bị filter khỏi orphan adoption, sẽ KHÔNG xuất hiện. Đây là tình trạng của session foreground (claudetrong terminal) trước khi/bg. Hoặc của spare worker pre-spawned chưa được dùng. - Worker có
source: "spare"mà ĐÃ cóstate.json: hiện trong UI (vì state.json nằm trongjobsFromDisk, filter spare chỉ áp dụng cho orphan adoption). state.jsonkhông có entry roster tương ứng: session đã kết thúc, file disk còn lại như tổ giấy của một worker đã exit.
Tại sao thiết kế thế này
Vài quan sát về lựa chọn thiết kế:
Roster có thể phình to với nhiều spare. Mỗi spare worker là một Node process tốn vài chục MB RAM. Số lượng spare configurable, mặc định thường là 1-3. Nếu thấy roster có nhiều entry source: "spare" chưa được claim, đó là pool đang được làm đầy chứ không phải bug.
flag: "wx" rất quan trọng. Worker tự ghi state khi nó có context (ví dụ khi đặt tên session, khi user submit prompt đầu tiên). Nếu Fp9 ghi đè, sẽ làm mất nameSource và intent thật. Việc dùng exclusive write đảm bảo orphan adoption chỉ tạo file khi worker chưa kịp tự ghi.
Hai tầng visibility tách rời. Roster là sự thật runtime (worker đang sống), state.json là sự thật phục vụ UI (FleetView có gì để hiển thị). Tách rời cho phép FleetView render nhanh mà không phải hỏi daemon mỗi lần, và cho phép daemon restart mà không mất session view (vì state.json đã trên disk).
Cwd-as-group: nhóm session theo thư mục, không có group rỗng
UI của FleetView group session theo cwd. Bạn thấy header dạng ~/WORK/some-project rồi bên dưới là các session đang sống trong cwd đó. Header này là derived view từ tập hợp session active, không phải một entity riêng được lưu ở đâu cả.
Hệ quả: nếu bạn kill tất cả session trong một cwd (mỗi session bấm Ctrl+X xác nhận), cwd header đó biến mất luôn khỏi list. Không có “group rỗng” để click vào và dispatch session mới ngay tại cwd đó. UI render lại chỉ với những cwd còn ít nhất một session sống.
Cơ chế bên dưới: FleetView lấy cwd từ state.json của các session active (đã qua Fp9), group lại, sort, render. Khi không còn session nào trong một cwd, không có row nào để group, header không được tạo.
Có hai tác dụng phụ:
- Muốn dispatch session mới vào cwd nào đó từ FleetView UI, bạn cần cwd đó đang hiện trong list. Hoặc dùng
@<alias>autocomplete nếu cwdMap có entry. Nếu cả hai đều không có, đường duy nhất là quit FleetView,cdvào folder trong terminal, mở lạiclaude agents. - Workflow practical: với cwd bạn thường xuyên dùng, leave ít nhất một session sống (kể cả session idle hoặc session vừa
/bgxong xả ra) để cwd luôn ở trong list. Cost vài chục MB RAM một spare worker, nhưng đỡ phải quit-and-relaunch terminal mỗi khi quay lại project đó.
Khi nào nên quan tâm
Phần lớn thời gian, độ trễ này không ảnh hưởng workflow. Bạn quan tâm nó khi:
- Đang debug một session không hiện ra như mong đợi. Check roster trước, xem
dispatch.source. - Viết script monitor số session active. Đừng chỉ đếm
ls ~/.claude/jobs/, đó là chỉ disk view. Đếmjq '.workers | length' roster.jsonmới đúng là worker đang chạy. - Reap worker zombie. Nếu thấy roster còn nhiều entry mà supervisor pid không chạy, lệnh
claude daemon stopsẽ dọn (gặp ở case crash, không thường xuyên).
Kết
claude agents không phải view đơn giản trên disk. Nó là kết quả của một thuật toán merge giữa roster runtime (daemon) và state disk (jobs). Spare worker pool là tối ưu cho startup time, đánh đổi bằng việc session foreground (gõ thẳng claude) nằm ngoài tầm nhìn của FleetView cho đến khi user explicit move sang background bằng /bg.
Nếu bạn vẫn nghĩ “đợi một lúc nó tự hiện”, đó là kỳ vọng từ phiên bản trước hoặc giả định không khớp với thực tế binary 2.1.143. Cách deterministic và đáng dùng hằng ngày là gõ /bg ngay trong session nào bạn muốn track, hoặc start claude --bg từ shell khi biết trước session sẽ chạy nền. Cả hai pattern này đều được giải thích chi tiết ở bài standalone Cách start session trong folder cụ thể từ claude agents, tương ứng là “Cách 3” và “Cách 4”.
Bài tiếp theo của series sẽ nói về setting worktree.bgIsolation thêm vào 2.1.143, cho phép background session edit working copy trực tiếp thay vì luôn lấy worktree riêng. Đó là chuyện isolation, không phải visibility, nên xứng đáng một bài riêng.
Bài thuộc series Claude Code từ zero. Series plan tại bài giới thiệu.