claude agents and /bg — An Analysis of the Agent View Implementation
Source:
2.1.139-unbundled/extracted/src/entrypoints/cli.js(build version208bf4b4, 2026-05-11). Official blog reference: Agent view in Claude Code.
"Agent view" is one feature, three entry points, and one on-disk job model:
| Entry point | Behavior |
|---|---|
claude agents (CLI subcommand) | When the daemon is enabled, it takes the fast path: it intercepts before Commander parses argv and mounts the FleetView TUI. When the daemon is disabled, it falls back to the Commander-registered agentsHandler, which is merely a plain-text list of configured subagent definitions. |
claude --bg [task] / --background | A brand-new session is started in the background directly by the on-demand daemon; the parent process prints a prompt and exits. |
/bg (slash command, alias of /background) | Forks the current REPL session into the background, then the foreground process exits. In the interactive REPL, pressing the left arrow key on empty input triggers the same fork path, and additionally remounts FleetView in-process. |
All three paths ultimately converge on ~/.claude/bg-jobs/<short>/state.json files plus a Unix domain socket daemon, namely the "on-demand daemon." FleetView is essentially a polling reader for that directory, plus an RPC client connected to the daemon.
Naming convention used below: obfuscated symbols are renamed to semantic names, with the original name attached on first appearance as /* <original name> */. Unrenamed symbols (oqH, pT, Q7, B9, Q, yH, xH, etc.) are shared helper functions from the host process (state-file I/O, telemetry, graceful shutdown). When porting this to another project, replace them one by one.
1. Data Model
// state.json — one per background job, stored in ~/.claude/bg-jobs/<short>/
interface JobState {
proto: number; // I1, schema version number
short: string; // 8-digit hex prefix, used for addressing in CLI / FleetView
sessionId: string; // full uuid, corresponding to transcript filename
createdAt: string; // ISO
updatedAt: string; // ISO, bumped on every write
firstTerminalAt?: string; // time it first entered a terminal state
// Identity / display
template: { name: string; description: string; initialPrompt?: string };
agent?: string; // if --agent was used, this is the subagent type
intent: string; // first user message, ≤200 characters
name?: string; // explicit -n/--name; otherwise auto-derived
nameSource?: "user" | "auto";
detail?: string; // excerpt from latest assistant output, ≤120 characters
color?: string;
// Activity state
state: "running" | "stopped" | "errored" | "done" | ...;
tempo?: "active" | "idle" | "blocked";
needs?: string; // what the agent is waiting for
block?: { questions?: Question[] }; // when tempo === "blocked"
suggestedReply?: string; // prefilled reply completable with Tab in PeekPane
output?: Record<string, string>; // several headline lines displayed inline
inFlight?: unknown;
resumeSessionId?: string;
// Sorting / pinning
sortOrder?: number;
stateSortOrder?: number;
pinned?: boolean;
// Location
cwd: string;
originCwd?: string;
worktreePath?: string;
worktreeBranch?: string;
worktreeHookBased?: boolean;
// Links
children?: Array<<ruby>id: string; kind: "frame"<rt>"pr" | ...; href: string</rt></ruby>>;
source?: "shell" | "slash" | "fleet" | "spare" | "respawn" | "left_arrow";
// Other bookkeeping
respawnFlags?: string[];
env?: Record<string, string>;
}
interface Question { text: string; options: string[] }
// Row structure constructed in FleetView memory from a JobState
interface JobRow <ruby>id: string; // === short
state: JobState;
activity: "flowing"<rt>"slowing" | "stuck" | "success" | "failure" | "stopped";
tempo?: "active" | "idle" | "blocked";
template: string; // state.template.name
children?: JobState["children"];
origin?: "C8" | ...; // current cwd or another cwd
group?: string;</rt></ruby>
// FleetView reports back to mountFleetView through this action
type FleetAction =
| { type: "done" }
| <ruby>type: "open"; job: JobRow; query?: string; collapsed: string[];
groupMode: "state"<rt>"directory"; jobs: JobRow[];
loopKicks: Map<string,number>; statuses: Map<string,number>;
statusesTs: number; prStatuses: Map<string,unknown>;
respawnResult?: RespawnResult; freshDispatch?: boolean</rt></ruby>;
The “agents” seen by the agentsHandler fallback handler are something else: a list of subagent definitions (activeAgents returned by uk()), grouped by source (user / project / local / plugin / built-in). This has nothing to do with the “session rows” in FleetView.
2. Constants and Copy
// Master switch
const ENV_DISABLE = "CLAUDE_CODE_DISABLE_AGENT_VIEW";
const SETTING_DISABLE = "disableAgentView"; // Managed configuration
const SETTING_DEFAULT_TO_FLEET = "defaultToAgentsView";// User configuration (line 462620)
// Preselect job when launching `claude agents` from a fork
const ENV_PRESELECT = "CLAUDE_AGENTS_SELECT";
// Automatically restart an unfocused fleet view (heartbeat)
const ENV_AUTORELAUNCH = "CLAUDE_AGENTS_AUTO_RELAUNCHED_AT";
const AUTO_RELAUNCH_UNFOCUSED_MS = 3_600_000; // 1 hour
const AUTO_RELAUNCH_MIN_INTERVAL_MS = 21_600_000; // 6 hours
// daemon protocol version
declare const I1: number;
// stdin limit for `claude --bg < piped`
const zB8 = 16 * 1024; // bytes
// Hint block printed after each bg dispatch
function formatPostBackgroundHints(short: string, idle?: string) {
return [
`backgrounded · ${cyan(short)}${idle ? dim(" " + idle) : ""}`,
dim(" claude agents".padEnd(28) + "list sessions"),
dim(` claude attach ${short}`.padEnd(28) + "open in this terminal"),
dim(` claude logs ${short}`.padEnd(28) + "show recent output"),
dim(` claude stop ${short}`.padEnd(28) + "stop this session"),
].join("\n");
}
Several key copy strings related to backgrounding:
"Backgrounding…"— spinner shown while starting in the background."<N> tasks running — the forked session won't carry live processes."— confirmation dialog subtitle."Background anyway (tasks will be abandoned)"/"Stay"— confirm / cancel buttons."Cannot background — session persistence is disabled, so the forked job would have nothing to resume."
3. Host-Framework Contract
The table below lists helper functions that the agent view module depends on and that are provided by other modules in cli.js. When porting to another project, replace each item with the corresponding implementation on your side.
| Symbol | Signature | Line Number | Purpose |
|---|---|---|---|
oqH | (seed) => JobState | used at 542886 | Constructs a new JobState with default values. |
pT | (jobDir, partial) => Promise<void> | used at 542884 | Atomically merge-writes state.json. |
Q7 | (jobDir) => Promise<JobState|null> | 542964 | Reads state.json. |
W4 | (short) => string | 542879 | ~/.claude/bg-jobs/<short>/. |
rW | () => string | 543233 | Jobs root directory; prefix lookup scans this with readdir. |
k_ | () => string | 542814 | Current process session id (set in the fork). |
L7 | () => boolean | 543959 | Whether CLAUDE_JOB_DIR exists → i.e. “am I already a background fork”. |
getCurrentJobDir / Xv3 | () => string | undefined | 544011 | Returns process.env.CLAUDE_JOB_DIR. |
V$ | (req, opts?) => Promise<DaemonResp> | 543031 | Unix socket RPC with the daemon (op: "list" | "dispatch"). |
mW_ | () => Promise<{ok,reason?}> | 543277 | Daemon health check (used by claude attach). |
MQ | (short) => Promise<{outcome,msg?}> | 543286 | RPC to “attach me to this job”. |
PGK | (short, {alreadyInAlt}) => Promise<AttachResult> | 606723 | Handles bidirectional bridging between the local PTY and the daemon job during attach. |
fW6 | (jobId, {force?,knownState?,knownAlive?}) => Promise<RespawnResult> | 606700 | Wakes the worker if it has died (re-spawn). |
B9 | (exitCode, reason, opts?) => Promise<never> | 543885 | Graceful exit for the foreground process. |
aH7.setupGracefulShutdown | — | 651511 | Hooks SIGINT/SIGTERM into B9. |
cH.createRoot / Mu | (opts) => Promise<InkRoot> | 651518 | Creates an Ink root node sharing stdout. |
v3_ | ({exitOnCtrlC}) => Promise<InkRoot> | 606748 | Rebuilds the Ink root after PTY handoff. |
Q5.get(process.stdout)?.handoffAltScreen | — | 606681 | Hands alt-screen control to the PTY for attach. |
Q5.get(process.stdout)?.unmount | — | 606926 | Unmounts Ink before remounting the fleet view in-process. |
Q("tengu_*") / yH / xH / j6 | telemetry sink | various | tengu_bg_dispatch, tengu_background_fork, fleet_view_*. |
IK("allow_remote_sessions") | policy check | 644468 | Organization-level switch. |
Xc = isAgentsFleetEnabled() | — | 149497 | Main gate: merges managed config, env, and fast-path policy. |
fleetGateRejected(verb) | (string) => Promise<never> | 651424 | Prints the rejection reason and exits. |
EAH("claude agents") | — | 604574 | Sets the terminal window title. |
vh() / zAH() | string | 606686/606747 | ANSI sequences for entering / leaving alt-screen. |
b_() / H6(updater) | config store | 604558/605650 | Persists fleetViewGroupMode, etc. |
B26(cwd, {q,collapsed}) / ebK / qxK | bg-view UI state | 604647 | Remembers “last search and collapsed state” by cwd. |
s6() | () => {columns, rows} | 604583 | Terminal size. |
pA() | () => boolean | 604578 | Whether the terminal is currently focused. |
un_() | alt-screen flag | 606680 | Whether alt-screen is owned. |
lT() | () => WorktreeInfo | null | 606956 | Current worktree information. |
g$() / qp() | name lookup | 543840 | User-set / auto-derived job name. |
WE(msg) / oI(msg) | transcript helpers | 543828 | Extract assistant/user message body. |
viH(text) | metadata-text predicate | 543834 | Used to skip slash-command echoes when selecting intent. |
LD(state) | (JobState) => boolean | 544019 | Whether the job is in a terminal state. |
npH(state) | activity classifier | 602954 | "success"/"failure"/"stopped" or undefined. |
The daemon protocol (V$) accepts at least these requests:
type DaemonReq =
| { proto: number; op: "list" }
| { proto: number; op: "dispatch"; d: JobDispatch; timeoutMs?: number };
type JobDispatch = { // 543001-543027
proto: number; short: string; sessionId: string; createdAt: number;
source: "shell"|"slash"|"fleet"|"spare"|"respawn"|"left_arrow";
cwd: string;
launch:
| { mode: "prompt"; args: string[] }
| { mode: "resume"; sessionId: string; fork: boolean; flagArgs: string[] };
respawnFlags: string[]; env: Record<string,string>; reattachEnv?: unknown;
worktree?: { path: string; ownershipToken: string };
isolation: "worktree" | "none";
agent?: string; routine?: string;
seed: { intent: string; name?: string };
cols?: number; rows?: number;
};
4. Registration
4a. /bg Slash Command (line 543995)
const backgroundCommand = {
type: "local-jsx",
name: "background",
aliases: ["bg"],
description: "Continue this session in the background and free the terminal",
immediate: (raw) => !raw.trim(), // Skip confirmation when there is no extra prompt
isEnabled: () => true,
load: () => Promise.resolve().then(() => (_loadBgImpl(), _bgImplExports)),
};
After load() resolves, it yields a module exporting { default: handleBgSlashCommand }, where:
// line 543958 —— Yv3
const handleBgSlashCommand = async (
onDone: (err?: string) => void,
ctx: { messages: Message[] },
args: string
) => {
if (L7() /* this is already a bg session */) {
Q("tengu_background_already_bg", {});
onDone(); q4H();
return null;
}
if (YDH() /* session persistence is disabled */)
return (onDone(
"Cannot background — session persistence is disabled, so the forked job would have nothing to resume."
), null);
const extraPrompt = (args ?? "").trim();
const seed = extractSeedFromTranscript /*Bf6*/ (ctx.messages, extraPrompt);
if (seed === null)
return (onDone("Nothing to background yet — send a message first."), null);
return React.createElement(BackgroundConfirmDialog /*Jv3*/, {
onDone, prompt: extraPrompt, seed, messages: ctx.messages,
});
};
4b. /stop Slash Command (only active inside a bg job, line 544066)
const stopJsx = {
type: "local-jsx", name: "stop", immediate: true, isEnabled: L7,
description: "Stop this background session; transcript and worktree are kept",
requires: { ink: true },
load: () => ({ call: (done) => (done(), markSelfStoppedAndExit("stop_command"), null) }),
};
const stopNonInteractive = {
type: "local", name: "stop", supportsNonInteractive: true,
isEnabled: L7,
load: () => ({ call: () => (markSelfStoppedAndExit("bridge"), { type: "skip" }) }),
};
4c. claude agents Commander Subcommand (line 647846, fallback only)
H.command("agents")
.description("Manage background and configured agents")
.option("--setting-sources <sources>",
"Comma-separated list of setting sources to load (user, project, local).")
.action(async () => {
const [{ agentsHandler }, { createSubcommandRoot }] = await Promise.all([
import("./agents-handler.js"),
import("./subcommand-root.js"),
]);
await agentsHandler(await createSubcommandRoot());
process.exit(0);
});
4d. Global Toggle Configuration Item (line 53308)
disableAgentView: {
type: "boolean",
description:
"Disable agent view (`claude agents`, `--bg`, /background, the on-demand daemon). " +
"Typically set in managed settings. Equivalent to CLAUDE_CODE_DISABLE_AGENT_VIEW=1.",
};
5. Entry / Dispatch — mainCliEntry() (the CLI main)
mainCliEntry (obfuscated name je3) runs before Commander, allowing claude agents to enter FleetView directly without going through the heavyweight imports in main().
// 651265
async function mainCliEntry /*je3*/ () {
const argv = process.argv.slice(2);
// … early-return paths for --version / --daemon-worker / --bg-pty-host / --bg-spare / bridge / daemon, etc.
// ───────── bg verb fast path (line 651387) ─────────
if (
argv[0] === "logs" || argv[0] === "attach" || argv[0] === "stop" ||
argv[0] === "kill" || argv[0] === "respawn" || argv[0] === "rm" ||
argv.includes("--bg") || argv.includes("--background")
) {
if (!isAgentsFleetEnabled()) return fleetGateRejected(verb);
const bg = await import("./bg-cli.js"); // QGK
switch (argv[0]) {
case "logs": return bg.logsHandler(argv[1]);
case "attach": return bg.attachHandler(argv[1]);
case "stop": case "kill": return bg.stopHandler(argv[1]);
case "rm": return bg.rmHandler(argv[1]);
case "respawn": return bg.respawnHandler(argv[1]);
default: {
// --bg / --background is still in argv; the first positional is the prompt
Q("tengu_background", { via_flag: true, via: "flag" });
await bg.handleBgFlag(argv);
return process.exit(process.exitCode ?? 0);
}
}
}
// ───────── fleet-view fast path (line 651485) ─────────
const wantsAgents = argv[0] === "agents" && argsAreOnlyDebugFlags(argv.slice(1));
const wantsDefault = argsAreOnlyDebugFlags(argv) && process.stdin.isTTY && process.stdout.isTTY;
if ((wantsAgents || wantsDefault)) {
enableConfigs();
const defaultsToAgents = getGlobalConfig().defaultToAgentsView === true;
if (wantsAgents || defaultsToAgents) {
const policyErr = await loadFastPathPolicy();
if (policyErr) exitWithError(policyErr);
await ensureFleetGateHydrated();
if (isAgentsFleetEnabled()) {
initSinks(); setupGracefulShutdown(); setIsInteractive(true);
Q("tengu_fleetview", { defaultToAgentsView: defaultsToAgents });
const root = await createRoot({ exitOnCtrlC: false });
consumeEarlyInput();
await mountFleetView(root);
await gracefulShutdown(0, "other", { suppressResumeHint: true });
return;
}
}
}
// … all other cases → main() (i.e. Commander, including the fallback for the agents subcommand)
}
function argsAreOnlyDebugFlags /*caK*/ (argv) {
for (let i = 0; i < argv.length; i++) {
const a = argv[i];
if (["--debug","-d","--debug-to-stderr","-d2e"].includes(a)
|| a.startsWith("--debug=") || a.startsWith("--debug-file=")) continue;
if (a === "--debug-file" && i + 1 < argv.length) { i++; continue; }
return false;
}
return true;
}
Two key conclusions:
claude agentsis the only Commander subcommand hijacked before parsing, so its real implementation ismountFleetView, not the body inside.action()— the latter only runs whenisAgentsFleetEnabled()returns false.- A parameterless
claudealso enters FleetView directly whendefaultToAgentsView: true; otherwise it falls through tomain()normally and enters the REPL.
6. --bg flag → handleBgFlag → daemon dispatch
// line 543089 — sN3
async function handleBgFlag(argvWithoutFlag: string[]) {
// argvWithoutFlag is argv after removing --bg / --background
const rest = argvWithoutFlag.filter((a) => !["--bg","--background"].includes(a));
const stdinTail = await readBgStdin(); // pGK; truncated past 16 KiB
const merged = stdinTail ? withStdinPositional(rest, stdinTail) : rest;
const res = await spawnBgSession(merged); // pHH → aN3
if (!res.ok) {
process.stderr.write(res.error + "\n");
process.exitCode = 1;
return;
}
process.stdout.write(formatPostBackgroundHints(res.short, res.idle ? "(idle — waiting for…)" : undefined) + "\n");
}
6a. spawnBgSession core — spawnBgSessionAndDispatch / aN3 (line 542926)
Here is the pseudocode after splitting it apart:
async function spawnBgSessionAndDispatch /*aN3*/ (
argv, source, cwd, seed /* result of Bf6 */, reattachEnv, t /*ids*/
) {
const { sessionId, short, jobDir, freshDir } = t;
const dashDash = argv.indexOf("--");
const flagArgs = dashDash >= 0 ? argv.slice(0, dashDash) : argv;
const agentName = readFlag("--agent", flagArgs);
const sessionName = readFlag("--name", flagArgs) ?? readFlag("-n", flagArgs);
const resumeId = parseResumeTarget(flagArgs); // FGK
const promptArg = dashDash >= 0
? argv.slice(dashDash + 1).join(" ")
: positionalArgFor(argv, resumeId);
const isResume = flagArgs.some(f => f === "-c" || f === "--continue" ||
f === "-r" || f === "--resume" ||
f.startsWith("--resume=") || f.startsWith("-r="));
const isFork = flagArgs.includes("--fork-session");
const respawnFlags = respawnableFlags(flagArgs); // Ov3
const dispatchFlagArgs = dashDash >= 0 ? respawnFlags : flagsWithoutPositional(respawnFlags);
const resumingSelf = resumeId !== undefined && resumeId === sessionId;
const forkFlag = isResume && !isFork ? ["--fork-session"] : [];
const sessionArgs = resumingSelf ? [] : ["--session-id", sessionId, ...forkFlag];
if (source === "shell" && readFlag("--session-id", flagArgs))
warn("--bg manages the session id; ignoring --session-id (use --resume <id> instead)");
const subagent = agentName
? (await uk(cwd ?? process.cwd())).activeAgents.find(a => a.agentType === agentName)
: undefined;
if (agentName && !subagent && source === "shell")
warn(`no agent named '${agentName}' — spawning with default template`);
const intent = seed?.intent ?? promptArg ?? "";
const isIdle = !agentName && intent === "" && !seed?.detail;
// Pre-write state.json (fleet / spare sources skip this; they carry seed through dispatch)
if (source !== "fleet" && source !== "spare") {
const prior = freshDir ? null : await readJobState(jobDir);
if (prior === null)
writeJobState(jobDir, oqH({
template: {
name: agentName ?? "bg",
description: subagent?.whenToUse ?? "",
initialPrompt: subagent?.initialPrompt,
},
respawnFlags: dispatchFlagArgs,
intent,
name: sessionName ?? seed?.name,
nameSource: sessionName ? "user" : seed?.nameSource,
detail: seed?.detail ?? (isIdle ? "(blocked)" : undefined),
tempo: isIdle ? "blocked" : undefined,
sessionId, cwd: cwd ?? process.cwd(),
worktreePath: seed?.worktree?.path,
worktreeBranch: seed?.worktree?.branch,
worktreeHookBased: seed?.worktree?.hookBased,
originCwd: seed?.worktree?.originCwd,
}));
else if (dispatchFlagArgs.length && !prior.respawnFlags.length)
writeJobState(jobDir, { ...prior, respawnFlags: dispatchFlagArgs });
}
// Dispatch to daemon
const dispatch = {
proto: I1, short, sessionId, createdAt: Date.now(),
source: source === "repl" ? "slash" : source,
cwd: cwd ?? process.cwd(),
launch: isResume && resumeId
? <ruby>mode: "resume", sessionId: resumeId,
fork: !resumingSelf && (isFork<rt>| forkFlag.length > 0),
flagArgs: [...respawnFlags, ...(dashDash >= 0 ? argv.slice(dashDash) : [])]</rt></ruby>
: { mode: "prompt", args: [...sessionArgs, ...positionalsAfterFlags(argv)] },
respawnFlags, env: bgEnvWhitelist(), reattachEnv,
worktree: seed?.worktree && { path: seed.worktree.path, ownershipToken: sessionId },
isolation: subagent?.isolation === "worktree" && subagent.source !== "built-in" ? "worktree" : "none",
agent: agentName, routine: undefined,
seed: { intent, name: sessionName ?? seed?.name },
cols: process.stdout.columns, rows: process.stdout.rows,
};
const r = await daemonRpcDispatch(dispatch); // AB8 → V$
if (r.ok) return { ok: true, short, sessionId, idle: isIdle };
// Fallback branches:
// - ack-timeout / enoconn but op:"list" shows the worker is still alive → treat as success
// - ack-timeout and it does not appear in list → dispatch once more
// - short-alive → friendly "session is already running" error
// - stale-short → "previous session is still shutting down; try again later"
// - daemon-unreachable → exit and suggest running status
// (see lines 543030-543087)
}
6b. Merging stdin as a positional — withStdinPositional / BGK (line 543129)
If the user pipes prompt text into --bg and argv already contains a positional, the stdin tail is appended to the final positional (separated by a newline), so the daemon only sees one merged prompt. If argv contains no positional, a new entry is added after the -- sentinel.
6c. Supporting CLI verbs (from line 543217)
claude logs <prefix>→logsHandler— opens a 500 ms RPC subscription to the daemon, prints thestreamTailsnapshot, then exits.claude attach <prefix>→attachHandler— first performs a health check (mW_), thenMQ(short)takes over the PTY; retries up to 20 times onERESPAWNING; forENOJOB, reads content fromstate.jsonto provide a more useful error message.claude stop / kill / rm / respawn <prefix>→ corresponding handlers (registered together viaM_(QGK,…)at line 542861).
All prefix arguments are resolved through resolveJobByShortPrefix /* jB8 */ (line 543217): it scans ~/.claude/bg-jobs/ with readdir and ensures the prefix is unique.
7. /bg Slash Command — Confirmation Dialog and Seed Extraction
7a. extractSeedFromTranscript — Bf6 (line 543822)
Traverses the transcript from bottom to top and produces { intent, name, nameSource, detail }:
function extractSeedFromTranscript(messages, override) {
let firstUserText = override, hasNonMetaUser = false, lastAssistant: string|undefined;
for (let i = messages.length - 1; i >= 0; i--) {
const m = messages[i];
if (m.type === "assistant" && lastAssistant === undefined) {
const text = WE(m); // body text of the bottom-most assistant message
if (text) lastAssistant = text.replace(/\s+/g," ").trim().slice(0,120);
}
if (m.type === "user" && !m.isMeta && !QY6(m)) {
const text = oI(m)?.trim();
if (text && viH(text)) continue; // skips slash-command echo
hasNonMetaUser = true;
if (!firstUserText && text) firstUserText = text;
}
if (hasNonMetaUser && firstUserText && lastAssistant !== undefined) break;
}
if (!hasNonMetaUser) return null; // "Nothing to background yet"
const userName = g$(jobSessionId());
const autoName = qp(jobSessionId());
return <ruby>intent: (firstUserText<rt>| "(backgrounded)").slice(0, 200),
name: userName ?? autoName,
nameSource: userName ? "user" : autoName ? "auto" : undefined,
detail: lastAssistant,</rt></ruby>;
}
7b. BackgroundConfirmDialog — Jv3 (line 543849)
- Reads two AppState fields via
J_:effortValue(Dv3)taskCount(jv3—state.tasks)
- If the number of in-flight tasks is 0, the component auto-confirms on its first render (
React.useState(taskCount === 0)). - Otherwise, renders a
<ConfirmDialog>with title "Background this session?", subtitle"<N> tasks running — the forked session won't carry live processes.", and buttons "Background anyway (tasks will be abandoned)" and "Stay". - After confirmation:
$ ts const r = await forkCurrentSessionToBackground /*pf6*/ ( seed, extraPrompt, effortValue, "command", messages ); Q("tengu_background_fork", { confirmed: taskCount > 0, inflight_count: taskCount, had_prompt: extraPrompt.length > 0, had_worktree: r.hadWorktree, worktree_handed_off: r.handedOff, }); onDone(); await B9(0, "prompt_input_exit", { suppressResumeHint: true, finalMessage: formatPostBackgroundHints(r.short, r.handedOff ? "(worktree handed off)" : undefined), }); forkCurrentSessionToBackground(pf6, same module) — callsspawnBgSessionwithsource: "slash", hands off the active worktree, and tears down the bridge / daemon listeners (DV()).
7c. markSelfStoppedAndExit — Uf6 (line 544013)
async function markSelfStoppedAndExit(source: "stop_command"|"bridge") {
Q("tengu_bg_agent_action", { action: "stop", source, jobSessionId: jobSessionId() });
const dir = process.env.CLAUDE_JOB_DIR;
if (L7() && dir) {
const now = new Date().toISOString();
const cur = await readJobState(dir);
if (cur && !isJobTerminal(cur))
await writeJobState(dir, {
...cur, state: "stopped", detail: "stopped from session",
tempo: "idle", needs: undefined, block: undefined, inFlight: undefined,
updatedAt: now,
firstTerminalAt: cur.firstTerminalAt ?? now,
});
if (isInteractive()) process.stdout.write(formatExitNotice("Session stopped."));
}
return B9(0, "prompt_input_exit", { suppressResumeHint: true });
}
8. FleetView — TUI Section
8a. mountFleetView — uQ3 (line 606636)
The main loop is simply render → wait for action → maybe attach → unmount and remount. It has exclusive ownership of the alt-screen for the entire lifecycle of the agent view.
async function mountFleetView(inkRoot) {
Q("tengu_bg_agent_action", { action: "list_open" });
setTerminalTitle("claude agents");
const preselectId = process.env.CLAUDE_AGENTS_SELECT;
delete process.env.CLAUDE_AGENTS_SELECT; // one-time use
const persisted = await loadFleetUiState(currentCwd());// qxK
let query = persisted?.q;
let collapsed = persisted?.collapsed;
let jobId = preselectId;
let error: string | undefined;
let groupMode: "state"|"directory"|undefined;
let savedAppState: any;
for (;;) {
const action = await new Promise<FleetAction>((resolve) => {
inkRoot.render(
<ProcessTitleProvider>
<AppStateBoundary
initialState={savedAppState && {...savedAppState, notifications:{current:null,queue:[]}}}
onChangeAppState={({newState}) => savedAppState = newState}
>
<ContextProviders>
<ScrollFrame>
<FleetView
onAction={resolve}
initialJobId={jobId}
initialQuery={query}
initialCollapsed={collapsed}
initialError={error}
initialGroupMode={groupMode}
/>
</ScrollFrame>
</ContextProviders>
</AppStateBoundary>
</ProcessTitleProvider>,
);
});
const inAlt = ownsAltScreen(); // un_
if (inAlt && action.type === "open") handoffAltScreen();
if (!inAlt) inkRoot.render(null);
inkRoot.unmount();
if (action.type === "done") break;
// ── action.type === "open" → attach ──
if (isWindows() && process.stdin.isTTY) { process.stdin.setRawMode(true); process.stdin.ref(); }
const restoreCursor = inAlt
? batchUpdate(() => process.stdout.write(altScreenReentry()))
: () => {};
jobId = action.job.id;
query = action.query;
collapsed = action.collapsed;
groupMode = action.groupMode;
cachedJobs = action.jobs;
cachedLoopKicks = action.loopKicks;
cachedStatuses = action.statuses;
cachedStatusesTs = action.statusesTs;
cachedPrStatuses = action.prStatuses;
cachedFirstMount = true;
const respawn = action.respawnResult ?? await respawnJob(action.job.id, {
knownState: action.freshDispatch ? undefined : action.job.state,
knownAlive: (Date.now() - action.statusesTs < 1500
&& action.statuses.get(action.job.state.resumeSessionId
?? action.job.state.sessionId) !== undefined)
? true : undefined,
});
if (respawn.ok || respawn.alive) {
logFleetViewLifecycle("attach", action.job.state);
process.stdout.write(SET_TITLE(renderJobLabel(action.job.state, true)));
let attach = await attachJob(respawn.short ?? action.job.id, { alreadyInAlt: inAlt });
if (attach.kind === "error" && attach.orphaned) {
// last-ditch effort: force one respawn and retry
const re = await respawnJob(action.job.id, { force: true, knownState: action.job.state });
if (re.ok || re.alive) attach = await attachJob(re.short ?? action.job.id, { alreadyInAlt: inAlt });
else attach = { kind: "error", msg: re.error };
}
if (attach.kind === "error" && !attach.ended) {
error = attach.msg;
xH("fleet_view_open", "attach_failed");
} else {
if (attach.msg) error = attach.msg;
yH("fleet_view_open");
}
logFleetViewLifecycle("detach", action.job.state, { attachDurationMs: Date.now() - tStart });
} else {
error = respawn.error;
xH("fleet_view_open", "respawn_failed");
}
if (inAlt) process.stdout.write(restoreAltMargins());
inkRoot = await createRoot({ exitOnCtrlC: false });
restoreCursor();
}
}
8b. FleetView Component — tmK (line 604467) — State Machine
Internal component state, roughly ordered by useState / useRef order:
| Slot | Purpose |
|---|---|
jobs / setJobs (A, z) | The currently mounted JobRow list. null during bootstrap. Snapshot mirrored into $.current for callbacks. |
incomingJobs (Y, w) | Jobs launched during this mount that are still waiting for state.json to land on disk. |
prStatuses (F, g) | PR check status cache. |
cursorIndex (d, a) | Index into the flattened row list h9. |
peekJob (_H, t) | The job currently being peeked. |
dispatchFolded (qH, i) | Whether the "queued/folded" section is collapsed. |
replyDrafts (e.current) | Map<jobId, draftText>: inline reply drafts in the peek panel. |
replyErr ($H, AH) | Most recent reply failure message. |
helpOpen (s, o) / confirmDelete (HH, KH) / selectMenu (jH, YH) | Various overlay modals. |
exiting (MH, kH) | Secondary exit confirmation; LH = ph(setExiting, () => onAction({type:"done"})) wires together Ctrl+C. |
renaming (WH, DH) | The job currently being renamed, or null. |
attaching (XH, NH) | ID of the job currently being respawned before attach. |
error ($9, L6) | Toast / error row. |
groupMode (tH, v_) — "state" | "directory" | Persisted to b_().fleetViewGroupMode. |
collapsedGroups (UH, BH) — Set<string> | Persisted per cwd. |
manuallyCollapsedPinned (FH, q_) | Used when the pinned section is manually collapsed without affecting the persisted set. |
cwd (L) → ultimately resolved to repo root (_Y(cwd)). | |
pinnedDir map (u, b) | Fast pin mapping by cwd. |
query / cursorOffset (from the multiline input hook v0(...)) | Search box. |
It also imports several helpers from the same module (lines 602820–602850), including repoGroup({cwd}) (Nd8), pickIcon, jobLabel, and childStatusColor.
8c. Key Bindings — handleFleetViewKeyDown / wS (line 605589)
When the overlay is showing (`renaming` / `attaching`):
Ctrl+C Cancel overlay
up/down Pass through to the overlay’s own scrolling logic
other Forward to the overlay’s internal input handling
Global:
Ctrl+C Clear input → close help / delete confirmation if open → otherwise exit
Escape Close help → close delete confirmation → close fold help → close menu → clear input → disarm armed action → onAction("done")
Shift+up/dn Move job within group (`Sy` / `SY`)
Ctrl+R Rename selected job (`renaming = job.id`)
Ctrl+S Toggle grouping mode between "state" ↔ "directory"
Ctrl+G Expand / replace in query
Ctrl+T Pin / unpin selected job
up / Ctrl+P Move cursor (or scroll completion suggestions)
down / Ctrl+N Move cursor
Ctrl+X (when help is open) → Kill armed action target
Tab Accept slash suggestion / toggle `dispatchFolded`
→ Attach current row (i.e. the right-arrow in the blog post)
⌘/Super+1-9 Attach the Nth row in the current cwd group
Return If input is empty: recognize /exit/quit and exit → otherwise submit suggestion → parse "<template> <intent>",
dispatch a new bg job → (Shift+Return ⇒ additionally auto-attach via {type:"open", freshDispatch:true})
Space-empty input Toggle peek mode for current row (i.e. "peek" in the blog post)
The "left arrow key" in FleetView is handled by the multiline input hook (v0’s default cancel) — when the input is empty, it falls back to onCancel: setExiting, then ph takes over to perform the "press once more to confirm exit" flow (LH).
8d. Peek pane — PeekPane / WQ3 (line 603580)
This is the "peek mode" described in the blog post: selecting a row without fully attaching.
- Reads
q.state.updatedAtand rendersr7(now - updated)to show "Last interaction X ago". It re-renders every 1s within the most recent minute, otherwise every 30s. - Picks three informational lines from
q.state.output(excluding duplicates of inline child-list labels), then addsstate.needsandstate.detail, adapting to terminal height. - It has its own
v0input box, bound toreplyDrafts.get(jobId):onSpaceOnEmpty: onBack— Space exits peek.onTabOnEmpty: () => setQuery(suggestedReply); Q("tengu_prompt_suggestion", { outcome: "accepted", source: "fleetview_peek" })— Tab acceptsstate.suggestedReply(only present whentempo === "blocked"and there is no questions form).onExit→ submits the draft viaonReply(formattedDraft); after success, the worker on the daemon side automatically resumes — this is the implementation of "inline replies auto-resume" from the blog post.
- If
state.tempo === "blocked"andstate.block.questionsexists, renders a<QuestionForm>(jH = 2 + opts.length + 1lines of extra layout space).
8e. Visual indicator rules
- Row color / activity glyph =
jobActivityForRow(jobRow, prStatuses)→"flowing" | "slowing" | "stuck" | "success" | "failure" | "stopped". The "slowing" / "stuck" thresholds come fromupdatedAt:tempo === "active": 3 minutes → "flowing"; over 15 minutes → "stuck".- Otherwise: 15 minutes → "flowing"; over 75 minutes → "stuck".
- "awaits input" =
tempo === "blocked"(whenblock.questionsexists) orstate.needsis non-empty. - "actively processing" =
tempo === "active"and not in a terminal state. - "completed" =
LD(state)is true. - "next run time" (looping job) =
vd8(jobRow, loopNextRun), displayed as"in <duration>".
## 9. Persistence
* **One `state.json` per job** — The single source of truth for FleetView and `claude logs/attach/...`. Written via atomic merge with `pT(jobDir, partial)`.
* **`~/.claude/bg-jobs/`** — A collection of `<short>/` subdirectories. stdout/stderr are the worker’s PTY history (read via daemon RPC, **not** from disk).
* **FleetView UI state (by cwd)** — `B26(cwd, { q, collapsed })` uses the repo path as the key, loads via `qxK(repoRoot)`, and writes with a 300 ms debounce.
* **Grouping mode preference** — `b_().fleetViewGroupMode` is written to the user config repository via `H6(updater)`.
* **Daemon socket / heartbeat** — `.fleetview-heartbeat` lock file (`Moq`, line 104916).
---
## 10. End-to-End Timing
```text
A) `claude --bg "ship the redis fix"`
argv enters mainCliEntry() → hits `argv.includes("--bg")` (line 651394)
│
▼
fleetGate ok → handleBgFlag(argv) [543089]
│ stdin (TTY) → ""; rest = argv \ ["--bg"]
▼
spawnBgSession(rest) [pHH, 542905]
│ generates sessionId, short, jobDir
▼
spawnBgSessionAndDispatch [aN3, 542926]
│ • writes state.json (seed data)
│ • constructs JobDispatch
▼
daemon V$ {op:"dispatch", d} [543031]
│ daemon may return ok / ack-timeout / enoconn / short-alive / stale-short
▼ redispatch / rescue loop on ack-timeout
stdout ← formatPostBackgroundHints(short, idle?) [543177]
exit 0
B) Entering `/bg` in a live session
REPL receives /bg → loads handleBgSlashCommand [Yv3, 543958]
│ guards: already bg / persistence disabled
▼
extractSeedFromTranscript walks transcript → {intent,name,detail}
▼
<BackgroundConfirmDialog/> [Jv3, 543849]
│ task=0 auto-confirms; otherwise shows <ConfirmDialog>
▼
forkCurrentSessionToBackground(seed, prompt, effort, "command", messages) [pf6]
│ same dispatch path as --bg, but source="slash"; hands off worktree, detaches bridge
▼
Q("tengu_background_fork", …)
onDone() → B9(0, "prompt_input_exit", {finalMessage: formatPostBackgroundHints(short, …)})
C) `claude agents` (or pressing ← in the REPL)
← in REPL → forkAndOpenFleetViewFromRepl(messages, effort, autoName) [KpK, 606947]
│ extractSeedFromTranscript (allows empty) → wB8 prewrites state.json
│ forkCurrentSessionToBackground starts bg fork
│ Q("tengu_open_agents_via_left")
│ if tengu_bg_leftarrow_inprocess flag is true:
│ → remountFleetViewInProcess (preselects this short)
│ otherwise:
│ → FjH({ args:["agents"], env:{CLAUDE_AGENTS_SELECT: short} }) ← execs a new claude
`claude agents` → mainCliEntry() → argsAreOnlyDebugFlags passes → isAgentsFleetEnabled()
│
▼
mountFleetView(root) [uQ3, 606636]
┌──────────────── loop ────────────────┐
▼ │
<FleetView/> renders → user navigation ──────┤
│ │
▼ onAction === "open" │
respawnJob → attachJob (PTY) │
│ user work → eventually detach │
▼ onAction === "done" │
gracefulShutdown(0) │
└──────────────────────────────────────┘
11. Pitfalls / Correction Notes
claude agentsis a blend of two commands. The Commander-registeredagentsHandler(Cs3) at line 647846 only takes effect when fleet view is disabled; it outputs a list of subagent definitions grouped by source. The "agent view" mentioned in the blog is actually the fast-path FleetView at line 651485./bgis an alias. The real name is/background(line 543999), withbglisted underaliases.immediate: (raw) => !raw.trim()means typing plain/bg<Enter>skips JSX confirmation; including prompt text will get stuck in the React tree./bgand--bguse the same dispatch path. The only differences are: (a) thesourcefield written tostate.jsondiffers; (b) whether the parent process needs to split off a worktree/bridge. The daemon does not distinguish between them at all.L7()("whether in bg") ≠ "session has been backgrounded". It returns true if and only if this process hasCLAUDE_JOB_DIRset — i.e. "I am that fork." The foreground process that just spawned the fork still does not count as "backgrounded"; therefore, after dispatch,/bgcallsB9(0, "prompt_input_exit")to make the foreground exit proactively.CLAUDE_AGENTS_SELECTis one-shot.mountFleetViewdeletes the env var as soon as it reads it (line 606640), preventing a long-lived fleet view from being locked onto some stale id.- The "Background anyway" copy refers to tasks, not files. The
taskCountit reads (jv3 = state => state.tasks) comes from AppState’stasksmap — meaning bash/long-running tools — not in-flight requests. They are two different concepts, even though the UI copy only mentions tasks. - Left-arrow exit only works when input is empty. The actual trigger is the multiline input hook
v0’sonCancel; when input has content, the left arrow only moves the cursor. disableAgentViewis the sole global kill switch. Once set (via managed config or env),claude agentsfalls back toagentsHandler,--bg/--backgroundis blocked by the gate, and although the/backgroundslash command’sisEnabledis always true, its internalpHHstill fails atfleetGateRejected; the daemon also refuses to start it.spawnBgSessionrescues two specific daemon failures:ack-timeout(the worker is still alive, but the daemon’s reply was lost) andenoconn(the RPC socket disappeared mid-flight). It first sendsop:"list"as a probe, then either declares success or attempts one resubmission.- Polling cadence. In FleetView, peek timestamps re-render every 1s (within the last minute) or 30s (older). But the file watcher / RPC poller frequency lives in another module (around
seedLastJobsnear 602820 plus another set of hooks). This part needs to be rewritten separately when porting. claudeenters FleetView by default withdefaultToAgentsView: true. After the user enables it in the settings panel (line 462620), running bareclaudegoes straight in — easy to trigger accidentally.
12. Minimal Implementation Checklist for Porting to Other Projects
If you want to bring "agent view + /bg" to another shell (such as a fork of some Anthropic CLI, or an unrelated assistant CLI):
- Disk model.
~/.claude/bg-jobs/<short>/state.json: atomic merge writes,readdirprefix lookup, and theJobStateschema defined in Section 1. - Daemon. A Unix socket service that supports at least
{op:"list"},{op:"dispatch", d:JobDispatch}, and a per-job PTY attach RPC. On macOS/Linux, use one per-userunix:protocol socket; on Windows, use a named pipe with the same JSON envelope. Include afast_path_policymarker so it can be hard-disabled by managed config. - Worker. Launched by the daemon with the environment from
JobDispatch.env; the environment includesCLAUDE_JOB_DIR=<dir>and--session-id <uuid>, with the prompt as the final positional argument. The worker updates its ownstate.json(intent, name, output, tempo, block). /bgslash command.local-jsx,aliases: ["bg"]. The seed is obtained by scanning the transcript bottom-up (skipping meta and slash-command echoes). Ifstate.tasks > 0, show a confirmation prompt; otherwise confirm automatically.--bgCLI flag. Strip this flag, read piped stdin (truncate to 16 KiB), and invoke the same dispatcher. On success, print a 5-line hint block.claude logs / attach / stop / kill / rm / respawn <prefix>. Resolve the prefix to the full short ID and send the RPC. Be careful withattach: it must take over the PTY and tolerateERESPAWNING.- FleetView TUI.
mountFleetView(inkRoot)loop: render → wait for<ruby>type:"open"<rt>"done"</rt></ruby>→ ifopen, respawn + attachJob + render again after detach.- Inside the component: UI state persisted by cwd (query + collapsed), state/directory grouping toggle, dual-purpose peek panel (both view and return), multiline input, rename overlay, delete confirmation overlay, help overlay, drag-to-reorder within groups.
- Maintain a separate
attachingref to prevent the same row from being triggered twice by double-clicks.
- Gates. Managed config
disableAgentView+ envCLAUDE_CODE_DISABLE_AGENT_VIEW+fast_path_policyreturned by org policy. Every entry point must pass through the sameisAgentsFleetEnabled(). - Telemetry. At minimum:
tengu_background(flag/slash),tengu_background_fork,tengu_bg_dispatch,tengu_bg_dispatch_fallback,tengu_bg_dispatch_rescued,tengu_open_agents_via_left,tengu_fleetview,tengu_bg_agent_action,tengu_prompt_suggestion, plus allfleet_view_*action verbs (open / rename / pin_toggle / reorder / stop / attach / detach). - Left arrow → agents. Hook the REPL input box when it is "empty + left arrow": (a) seed a fresh bg fork from the current transcript, (b) tear down the current worktree/bridge, (c) prefer in-process remounting of fleet view; fall back to
execif that fails. - Risk-disclosure gating.
--bgcombined withbypassPermissionsorautopermission mode must first be confirmed once interactively — do not bypass the checks around lines 543643-543646, to avoid headless dispatch sneaking past the UX gate.
13. Symbol Mapping Quick Reference
bg-cli and slash command modules (QGK / gW_)
| Renamed | Obfuscated name | Line |
|---|---|---|
preSeedReplBgJob | wB8 | 542877 |
spawnBgSession | pHH | 542905 |
spawnBgSessionAndDispatch | aN3 | 542926 |
markBgDispatchSucceeded | IGK | 542802 |
markBgDispatchFellThrough | OB8 | 542820 |
handleBgFlag | sN3 | 543089 |
readBgStdin | pGK | 543108 |
withStdinPositional | BGK | 543129 |
humanizeDispatchFailureReason | tN3 | 543163 |
formatPostBackgroundHints | mf6 | 543177 |
bgVerbExtraArgsNote | UGK | 543188 |
resolveJobByShortPrefix | jB8 | 543217 |
logsHandler | eN3 | 543246 |
attachHandler | Hv3 | 543271 |
stopHandler | qv3 | (see export at 542863) |
rmHandler | Kv3 | (see export at 542865) |
respawnHandler | _v3 | (see export at 542866) |
parseResumeTarget | FGK | (see export at 542869) |
flagsWithoutPositional | gGK | (see export at 542873) |
extractSeedFromTranscript | Bf6 | 543822 |
BackgroundConfirmDialog | Jv3 | 543849 |
handleBgSlashCommand | Yv3 | 543958 |
BG_CLI_FLAG_ALIASES (["--bg","--background"]) | oN3 | 543733 |
forkCurrentSessionToBackground | pf6 | (calls at 543875, 606981) |
BG_SLASH_COMMAND_DEF | Mv3 / fv3 | 543999 |
getCurrentJobDir | Xv3 | 544011 |
markSelfStoppedAndExit | Uf6 | 544013 |
stopSlashJsxCall | Pv3 | 544047 |
stopBridgeNonInteractiveCall | Wv3 | 544055 |
FleetView module (bd8 / xd8)
| Renamed | Obfuscated name | Line |
|---|---|---|
FleetView (component) | tmK | 604467 |
mountFleetView | uQ3 | 606636 |
remountFleetViewInProcess | emK | 606921 |
forkAndOpenFleetViewFromRepl | KpK | 606947 |
PeekPane (component) | WQ3 | 603580 |
renderJobLabel | ZW6 | 602868 |
formatJobAgeOrCountdown | vd8 | 602863 |
classifyJobActivity | jW6 | 602953 |
deriveStateBand | IvH | 602971 |
repoGroupKey | Nd8 | 604497 |
parseDispatchInput | Zd8 | 603040 |
parseSearchQuery | BmK | 603002 |
pruneMap | Pd8 | 602855 |
logFleetViewLifecycle("peek"|"attach"|"detach") | Rd8 | 603598 |
handleFleetViewKeyDown | wS | 605589 |
swapAdjacentJobsInGroup | SY | 605532 |
attachJobOrSurfaceError | YS | 605454 |
runRowKeyAction | lj | 605425 |
CLI Entry and Commander
| Renamed | Obfuscated name | Line |
|---|---|---|
mainCliEntry | je3 | 651265 |
argsAreOnlyDebugFlags | caK | 651245 |
agentsHandler (fallback — lists agent definitions, not jobs) | Cs3 | 644428 |
AgentsList (fallback component) | FoK | 644390 |
formatAgentDefinitionRow | BoK | 644383 |
renderAgentSourceGroup | Es3 | 644416 |
isAgentsFleetEnabled | Xc | 149497 |
fleetGateRejected | (re-exported under the same name) | 651424 |
Configuration / Constants
| Renamed | Obfuscated name | Line |
|---|---|---|
SETTING_DISABLE ("disableAgentView") | y | 53308 |
SETTING_DEFAULT_TO_FLEET ("defaultToAgentsView") | — | 462620 |
ENV_PRESELECT ("CLAUDE_AGENTS_SELECT") | — | 606639 |
ENV_AUTORELAUNCH ("CLAUDE_AGENTS_AUTO_RELAUNCHED_AT") | Xd8 | 606765 |
AUTO_RELAUNCH_UNFOCUSED_MS | YW6 | 606763 |
AUTO_RELAUNCH_MIN_INTERVAL_MS | umK | 606764 |
FLEETVIEW_HEARTBEAT (.fleetview-heartbeat) | Moq | 104916 |
Comments
Replies are public immediately and may be moderated for policy violations.