# File Claude Code Sessions Under an "Active" Group Automatically **A setup guide you hand to Claude Code.** It sets up a small mod for the Claude desktop app that moves a session into your "Active" sidebar group whenever you send a message in it. --- ## For the human (read this part, skip the rest) **What it does.** In the Code tab of the Claude desktop app, with the sidebar grouped by Custom groups, every new session arrives ungrouped and you file it by hand. This mod does the most common move for you. Each time you send a message in a session: - An ungrouped session moves to 🟢 Active. - A session in ⏸️ Paused or ✴️ Waiting moves to 🟢 Active. - A pinned session stays pinned. - A session in any other group, such as a topic group like 🏥 Health, stays where it is. Scheduled-task runs and notifications do not count as a message from you. In a session outside the desktop app, such as `claude` in a terminal, the mod does nothing. **What you need:** the Claude desktop app with Claude Code 2.1.287 or later, and at least one custom group in the sidebar. Your group names do not have to match mine. **How to use this guide:** open a Claude Code session in the desktop app and say something like: > Read https://chadtimbl.in/writings/files/auto-active-group-mod.md and set it up for me. My active group is called "🟢 Active". Sessions in "⏸️ Paused" and "✴️ Waiting" should move to it too. Claude writes the mod with your group names and tests it in that session. While it works, the app asks you once whether to enable hot reloading for the session; choose "Enable for this session". The mod then runs in that one session until you tell Claude to install it for all of them. **Is it safe?** A mod is code that runs inside Claude Code with your permissions, so read it before you load it. This one is about 115 lines and is printed in full below. It makes three calls, all to the desktop app itself: read this session's group and pin, list your groups, and move this session. It also approves one permission question, its own request to move the session, so that the move works in Auto mode; a move Claude makes itself is still decided as before. It reads no files, starts no processes, and makes no network requests. To undo it, disable or uninstall the `auto-active-group` plugin from the Installed tab of `/plugin`. **Where this comes from:** Anthropic announced [mods](https://code.claude.com/docs/en/plugins/mods/overview) on October 1, 2026, and I asked Claude to write this one two days later. Longer write-up: [chadtimbl.in/writings/claude-workflow-tips.html#session-groups](https://chadtimbl.in/writings/claude-workflow-tips.html#session-groups). --- ## For Claude Code: instructions You are setting up a mod named `auto-active-group`. Read this whole guide before acting. ### What was verified, and what was not Verified on 2026-10-03 in the Claude desktop app on macOS (Claude Code 2.1.286, one release before the documented minimum of 2.1.287): - A mod can reach the desktop app's own servers with `$.mcp.call`. In Bypass permissions mode, a typed message moved the session from Ungrouped to the Active group, and a later message moved it from Paused to Active. - In a brand-new session in Auto mode, with the mod installed as in Step 4, the first message moved the session to the Active group. Version 1.0.0 failed this test, because Auto mode refused the move; the `tool.check` hook in version 1.1.0 fixed it. - A message typed in the app arrives at `prompt.submit` with `e.origin.kind === 'composer'`. - The rule for each group, for pinned sessions, for scheduled tasks, and for sessions outside the desktop app passes 17 automated tests run with `claude plugin test`, with the app's answers stubbed. - Step 4, keeping the mod for every session: the marketplace and install commands succeeded, and new sessions loaded the installed mod. Not verified by the author: - Messages sent through Remote Control (`'bridge'`), and anything on Windows. Mods are new and their API can change between releases. Re-verify on the target machine instead of assuming, and where this guide disagrees with the `plugin-authoring` skill or the type declarations of the running version, trust those. ### Ground rules 1. **Work from the built-in `plugin-authoring` skill.** Load it first. It says where to write a mod in this session, how the mod gets loaded, and where the type declarations for the running version are. If the skill is not available, mods are not available in this build: stop and tell the user to update the app. 2. **The user answers the hot-reloading question, not you.** Writing the first file makes the app ask "Enable hot reloading for this session?". Nothing loads until they choose "Enable for this session". 3. **Ask before installing for every session.** A mod written in a session runs in that session only. Installing it (Step 4) makes it run in all of them, so do that only when the user says to. 4. **Copy group names exactly.** Take them from the app, emoji included, instead of retyping them. ### Step 1: Gather the group names You need the name of the group sessions move to, and the names of the groups whose sessions should move there. Ungrouped sessions always move. If the session has the desktop app's `ccd_sidebar` tools, call `list_groups` and show the user the names it returns. Ask which one is the target and which ones sessions should move out of. Every group not named stays untouched, which is what a topic group needs. ### Step 2: Write the three files Write the files from "The mod" below into a folder named `auto-active-group`, in the place the `plugin-authoring` skill names. In `hooks/register.ts`, set `GROUP` and `MOVES_FROM` to the user's names. Change nothing else unless the running version requires it. Then check it without running it: ```bash claude plugin validate ``` The output should list two hooks, `prompt.submit` and `tool.check`, and two calls, `$.mcp.call` and `$.ui.toast`. In the desktop app the `claude` command may not be on the PATH; the path of the binary the session runs on is in the `CLAUDE_CODE_EXECPATH` environment variable. ### Step 3: Test it in this session The mod loads when the turn that wrote it ends, provided the user enabled hot reloading. To test: 1. Tell the user you are about to move this session to Ungrouped as a test. If their sessions usually run in Auto mode, test in Auto mode, because that is where the permission hook matters. 2. Call `move_sessions` with `session_ids: ["self"]` and `section_id: "sessions"`. 3. End your turn and ask the user to send any message. 4. On that next turn, call `get_session` with `session_id: "self"`. Its `group.name` should be the target group. If it is not, move the session back yourself and read the transcript for a dim line naming the mod, which says why a hook failed. ### Step 4: Keep it for every session (only if the user asks) The session's mods folder is temporary, and a mod in it loads only in the session that made it. To keep the mod: 1. Copy its folder to a place of the user's own, such as `~/mods/auto-active-group`. 2. Make `~/mods` a local marketplace by writing `~/mods/.claude-plugin/marketplace.json`: ```json { "name": "my-mods", "owner": { "name": "Your Name" }, "plugins": [ { "name": "auto-active-group", "source": "./auto-active-group", "description": "Files a session under the Active sidebar group when a message is sent in it." } ] } ``` 3. Add the marketplace and install the mod at user scope: ```bash claude plugin marketplace add ~/mods ``` ```bash claude plugin install auto-active-group@my-mods --scope user ``` 4. Move the copy out of the session's mods folder, so this session does not load the mod twice. 5. Have the user start a new session and send a message, then confirm that session is in the target group. A session that was already open picks the mod up after `/reload-plugins`, or the next time the app starts. The marketplace points at `~/mods`, so the mod stops loading if that folder is moved or deleted. An installed plugin is cached by version: after changing the mod, raise `version` in `plugin.json` and install again. For the desktop app's own install steps, see [Install and manage plugins](https://code.claude.com/docs/en/plugins/install). ### Step 5: Tell the user how to turn it off - **Installed for every session:** disable or uninstall `auto-active-group` from the Installed tab of `/plugin`. - **Loaded in one session only:** delete its folder from that session's mods folder. ### How it works The mod has two hooks. The one on `prompt.submit` lets the prompt go ahead first, then acts only when `e.origin.kind` is `'composer'` or `'bridge'`. The one on `tool.check` approves the mod's own move, as explained below. The first hook makes up to three calls: | Call | What it returns or does | | --- | --- | | `$.mcp.call('ccd_session_mgmt', 'get_session', { session_id: 'self' })` | JSON text with `pinned` and `group`. `group` is `{ id, name }`, or `null` for an ungrouped session. | | `$.mcp.call('ccd_sidebar', 'list_groups')` | JSON text: an array of `{ id, name, ... }`. | | `$.mcp.call('ccd_sidebar', 'move_sessions', { session_ids: ['self'], group_id })` | Moves this session. | Five things that are easy to get wrong: - **In Auto mode the move is refused unless the mod approves it.** The app treats `move_sessions` as a tool to ask about. In Auto mode that question goes to a classifier, which gives no verdict for a call a mod makes, so the call is refused with "The server-side auto mode classifier gave no verdict". The `tool.check` hook answers `allow` while the mod's own move is in flight and at no other time, and it never overrides a `deny`. - **Moving a pinned session into a group unpins it.** The mod checks `pinned` first and leaves a pinned session alone. - **A missing `group` key is not the same as `null`.** The key is absent while the app has not reported groups yet. The mod treats that as unknown and does nothing. - **Emoji may or may not carry a variation selector** (U+FE0F), depending on where a group name was typed. The mod strips it from both sides before comparing names. - **Outside the desktop app the two servers do not exist.** The first call fails, and the mod treats that as "not in the desktop app" and stays silent, so it is safe to install at user scope on a machine that also uses the terminal. --- ## The mod Three files, in a folder named `auto-active-group`. ### `.claude-plugin/plugin.json` ```json { "name": "auto-active-group", "version": "1.1.0", "description": "Files a session under the \"🟢 Active\" sidebar group of the Claude desktop app whenever you send a message in it, if it is ungrouped, Paused or Waiting and not pinned.", "author": { "name": "Chad Timblin" } } ``` ### `hooks/hooks.json` ```json { "description": "The auto-active-group hooks module", "modules": ["./register.ts"] } ``` ### `hooks/register.ts` ```ts import type { EngineInterface, Register } from 'claude-code' // Change these two to match your own sidebar groups. An ungrouped session // always moves. A session in a group that MOVES_FROM does not list stays put. const GROUP = '🟢 Active' const MOVES_FROM = ['⏸️ Paused', '✴️ Waiting'] // The desktop app's own servers: one reads a session, one moves it. const SESSIONS = 'ccd_session_mgmt' const SIDEBAR = 'ccd_sidebar' // Prompts a person sent: typed in the app, or from a phone or the web through // Remote Control. Scheduled tasks, notifications and other sessions are left out. const PERSON = new Set(['composer', 'bridge']) // True while this mod's own move is in flight, so the permission hook below // approves that one call and no other. let isMoving = false export const register: Register = on => { on('prompt.submit', async ($, e, next) => { const entered = await next(e) if (PERSON.has(e.origin.kind)) { const problem = await fileUnderGroup($) if (problem !== null) { $.ui.toast(problem) } } return entered }) // The app asks before a session is moved. In Auto mode that question goes to // a classifier, which has no verdict for a call a mod makes, so the move is // refused. This approves the mod's own move. A move Claude makes itself keeps // the decision it had, and so does one that a rule denies. on('tool.check', { tool: 'mcp__ccd_sidebar__move_sessions' }, async (_$, e, next) => { const decided = await next(e) return isMoving && decided.decision === 'ask' ? { decision: 'allow' } : decided }) } // Resolves to null when the session moved or was rightly left alone, and to // what went wrong otherwise. Never throws: the prompt has already gone ahead. async function fileUnderGroup($: EngineInterface): Promise { let session: { pinned?: boolean; group?: { id: string; name: string } | null } try { const got = await $.mcp.call(SESSIONS, 'get_session', { session_id: 'self' }) if (got.isError) { return null } session = JSON.parse(textOf(got.content)) } catch { // The server is missing: this session is not running in the desktop app. return null } // Moving a pinned session into a group unpins it, so a pinned one stays put. if (session.pinned === true) { return null } // No `group` key means the app has not reported groups yet: unknown, not ungrouped. if (session.group === undefined) { return null } const from = session.group?.name ?? null if (from !== null && !MOVES_FROM.some(name => isSameName(name, from))) { return null } try { const listed = await $.mcp.call(SIDEBAR, 'list_groups') if (listed.isError) { return `could not list the sidebar groups: ${textOf(listed.content)}` } // Looked up by name, so a deleted and remade group still works. const groups = JSON.parse(textOf(listed.content)) as { id: string; name: string }[] const group = groups.find(one => isSameName(one.name, GROUP)) if (group === undefined) { return `no sidebar group is named ${GROUP}` } isMoving = true const moved = await $.mcp.call(SIDEBAR, 'move_sessions', { session_ids: ['self'], group_id: group.id, }) return moved.isError ? `could not move this session: ${textOf(moved.content)}` : null } catch (error) { return `could not move this session: ${String((error as Error)?.message ?? error)}` } finally { isMoving = false } } // An emoji may or may not carry its variation selector, depending on where the name was typed. function isSameName(a: string, b: string): boolean { return a.replace(/️/g, '') === b.replace(/️/g, '') } function textOf(content: readonly { type: string; text?: string }[]): string { return content.map(block => block.text ?? '').join('') } ```