Experimental in-process subagent activity for CCometixLine.

This component replaces CCometixLine's four lifecycle command hooks with function hooks. It is packaged into the Rust binary and deployed by the Experimental Mod option in the Agents TUI settings on Claude Code 2.1.273 through 2.1.292. The option defaults to off. On 2.1.293 and newer, ccline renders Claude Code's own subagent panel through subagentStatusLine instead, so this component is not selected there. It does not add tools, prompt content, network requests, or a separate status row.
The implementation was written against the official declarations generated by Claude Code 2.1.273 and re-checked against the declarations the engine writes for Claude Code 2.1.287, the release that announced Mods. The original analysis is DESIGN-mod-zh.md in the local Claude Code source checkout. The relevant contracts are ClassicEventOf, Register, $.clock.now, and $.env.set; comparing the 2.1.273 and 2.1.287 declarations, none of them changed, and the event payloads this component reads (session_id, source, agent_id, agent_type, hook_event_name) are unchanged. The declarations written by 2.1.288, 2.1.289, and 2.1.292 were compared again: these contracts, the four event payloads, and claude-code/testing (describe, test, expect) are unchanged. The official changelog through 2.1.295 lists no change to them; 2.1.293 fixed classic.* hooks being skipped while the plugin hooks worker restarts. That later comparison is static; the most recent real-host run below remains 2.1.287. The official Mods README marks the API as Early Access; the version threshold is a tested API baseline, not a guarantee of future compatibility.
classic.SessionStart, classic.SubagentStart, classic.SubagentStop, and classic.SessionEnd expose the existing event payloads in process. $.env.set changes the environment inherited by subsequent child processes. A real claude --init-only run verified this transport through a downstream command hook without making a model request; a 2.1.287 run in an isolated CLAUDE_CONFIG_DIR re-verified it, with the mod loaded as ccline-agents-mod@skills-dir and the snapshot present in the downstream hook's environment. Skills-directory plugin discovery supplies local installation without a marketplace.
$.agent.list() also exposes native task states, but it does not include the start timestamps used by the current renderer. This component deliberately keeps the existing observation semantics: responded counts observed stop events and makes no success or failure inference. It does not replace ccline's renderer with $.ui.status, which would draw a separate notice and lose the existing segment layout.
Claude lifecycle event
|
v
Mod: update in-memory activity, serialize publications
|
v
$.env.set("CCLINE_AGENTS_SNAPSHOT", JSON snapshot)
|
v
Claude starts its configured status line command
|
v
ccline: read snapshot -> existing Agents renderer -> full-line width fitting
The payload is { "sessions": { "<session_id>": { "agents": { "<agent_id>": { "agent_type": "Explore", "started_at": 100, "responded_at": null } }, "ended": false } } }. Timestamps are Unix seconds, matching the hooks store. Sessions and IDs remain distinct. Publications are serialized, and failures propagate; there is no retry, file fallback, or swallowed rejection in the Mod.
This removes one ccline command invocation per lifecycle event and all Agents activity-file I/O in Mod mode. The status line command still runs at the configured refresh interval. No end-to-end latency multiplier is claimed. Snapshot size grows with observed activity and is subject to the operating system's child-process environment limits.
On S, an enabled Agents segment runs claude --version once. 2.1.293 or newer selects ccline's subagent panel rows whatever experimental_mod says, and the TUI notes that the Mod applies only below that version. With experimental_mod = true, 2.1.273 through 2.1.292 select the Mod; older versions, or a missing executable, select hooks with a visible explanation. An invalid version response or a failed command is an error.
Mod selection deploys .claude-plugin/plugin.json and hooks/ to ~/.claude/skills/ccline-agents-mod/, enables ccline-agents-mod@skills-dir, and sets CLAUDE_CODE_ENABLE_FUNCTION_HOOKS=1 in Claude's user settings. Function hooks were gated behind that experiment through the Early Access releases, and Claude Code 2.1.287 loads a mod with the variable unset, so the write is a no-op there; ccline keeps writing it because one backend serves every version from 2.1.273 through 2.1.292. It removes only the four commands recorded as ccline's own hooks. Every function hook calls next(event) so other integrations still run.
Switching to hooks or the subagent panel, or disabling Agents, restores the original plugin/experiment settings when the installed values have not been edited, retaining unrelated settings. Deployed files stay available for re-enabling, but the plugin has defaultEnabled: false. Restart Claude Code after switching. Re-save after changing the Claude executable or version; the backend is chosen at configuration time, not on each refresh. The selected source is recorded in agents-installation.json. If CCLINE_AGENTS_SNAPSHOT is missing, invalid, or lacks the current session, ccline hides only the Agents segment and renders the rest of the status line without failing. Mod mode does not read hook activity files.
The engine writes the declarations; there is no command to run. Before the mod has loaded, this build's copy sits in the plugin-authoring skill's folder; once the engine has loaded the mod (hot reloading, or claude --plugin-dir mods/agents), it lays .claude-plugin/types/claude-code/index.d.ts, and tsc -p mods/agents type-checks against it. This plugin's tsconfig.json uses the options recommended in that file's header and includes .claude-plugin/types. Run the tsc check after that folder exists. The generated .claude-plugin/types/ is ignored and is not shipped to users. The component does not require Node or TypeScript on users' machines.
npx --yes --package typescript@5.9.3 tsc -p mods/agents/tsconfig.json
claude plugin validate mods/agents --strict
claude plugin test mods/agents
cargo test
cargo clippy --all-targets -- -D warnings
On Claude Code versions that still gate function hooks behind the experiment, prefix the two claude plugin commands with CLAUDE_CODE_ENABLE_FUNCTION_HOOKS=1.
The Mod tests exercise lifecycle state transitions in Claude's test runtime; that test API does not expose callable classic.* methods. The real host startup check separately exercises module discovery, registration, event dispatch, environment inheritance, and Rust snapshot consumption, most recently on 2.1.287. Rust tests cover version selection across the hooks, Mod, and panel backends, invalid snapshots, mutually exclusive backend installation, preservation of other hooks, settings restoration, and the TUI option.
hooks/register.ts 36 lines1import type { EngineInterface, Register } from "claude-code";
2import { Activities, type ActivityEvent } from "./activity";
3
4type State = { activities: Activities; pending: Promise<void> };
5
6function publish($: EngineInterface, state: State, event: ActivityEvent): Promise<void> {
7 // Serialize mutation and publication so an older write cannot overwrite a newer event.
8 // A failed publication stays visible; restarting the plugin starts a new queue.
9 state.pending = state.pending.then(async () => {
10 const snapshot = state.activities.apply(event, Math.floor(await $.clock.now() / 1000));
11 await $.env.set("CCLINE_AGENTS_SNAPSHOT", snapshot);
12 });
13 return state.pending;
14}
15
16export const register: Register = (on) => {
17 const state: State = { activities: new Activities(), pending: Promise.resolve() };
18
19 on("classic.SessionStart", async ($, event, next) => {
20 await publish($, state, event);
21 return next(event);
22 });
23 on("classic.SubagentStart", async ($, event, next) => {
24 await publish($, state, event);
25 return next(event);
26 });
27 on("classic.SubagentStop", async ($, event, next) => {
28 await publish($, state, event);
29 return next(event);
30 });
31 on("classic.SessionEnd", async ($, event, next) => {
32 await publish($, state, event);
33 return next(event);
34 });
35};
36hooks/activity.ts 48 lines1import type { ClassicEventOf } from "claude-code";
2
3export type ActivityEvent = ClassicEventOf[
4 | "classic.SessionStart"
5 | "classic.SessionEnd"
6 | "classic.SubagentStart"
7 | "classic.SubagentStop"
8];
9
10type Agent = { agent_type: string; started_at: number; responded_at: number | null };
11type Activity = { agents: Map<string, Agent>; ended: boolean };
12
13/** Same lifecycle semantics as Rust's Activity; timestamps are epoch seconds. */
14export class Activities {
15 private sessions = new Map<string, Activity>();
16
17 apply(event: ActivityEvent, now: number): string {
18 let activity = this.sessions.get(event.session_id);
19 if (!activity || (event.hook_event_name === "SessionStart" && event.source !== "compact")) {
20 activity = { agents: new Map(), ended: false };
21 this.sessions.set(event.session_id, activity);
22 }
23 switch (event.hook_event_name) {
24 case "SessionEnd":
25 activity.ended = true;
26 break;
27 case "SubagentStart": {
28 if (activity.ended) break;
29 const previous = activity.agents.get(event.agent_id);
30 activity.agents.set(event.agent_id, {
31 agent_type: event.agent_type,
32 started_at: previous && previous.responded_at === null ? previous.started_at : now,
33 responded_at: null,
34 });
35 break;
36 }
37 case "SubagentStop": {
38 const agent = activity.agents.get(event.agent_id);
39 if (agent && agent.responded_at === null) agent.responded_at = now;
40 break;
41 }
42 }
43 return JSON.stringify({ sessions: Object.fromEntries(
44 [...this.sessions].map(([id, state]) => [id, { agents: Object.fromEntries(state.agents), ended: state.ended }]),
45 ) });
46 }
47}
48