Skip to content
Back to skills

Spawn Session

DSecurity

Spawn a detached tmux session running Claude Code in a chosen directory, seeded with an initial prompt, optionally reachable by Remote Control.

  • 13 stars
  • 0 votes
  • 0 copies
  • 2 views
  • Added September 3, 2026
ai-agentsrustgoshellbashgitapi

Works with

  • claude code
  • terminal
  • cli
  • api

Security analysis

D50/100
  • criticalModifies startup scripts or system services for persistence
  • criticalModifies startup scripts or system services for persistence

Pro shows the line behind each finding and how to fix it

Scanned September 21, 2026

npx -y skills add yulonglin/dotfiles --skill spawn-session --agent claude-code

Installs into .claude/skills of the current project.

Are you the author of Spawn Session?

Add the live security badge to your README. It updates with every re-scan.

Security grade badge for Spawn Session
[![Security: D — Skills Directory](https://www.skillsdirectory.com/api/skills/yulonglin-spawn-session/badge)](https://www.skillsdirectory.com/skills/yulonglin-spawn-session)

More formats (shields.io, HTML) on the badges page. Keep it an A: scan every change in CI with Pro.

Download with Pro
SKILL.md
---
name: spawn-session
description: Spawn a detached tmux session running Claude Code in a chosen directory, seeded with an initial prompt, optionally reachable by Remote Control.
disable-model-invocation: true
---

# Spawn a seeded Claude session

`custom_bins/claude-spawn` does the whole thing in one command: new tmux session, cd to the directory, launch Claude Code, submit the seed prompt.

**This skill is user-invoked only** (`disable-model-invocation: true`). A spawned session outlives the conversation that created it and was never watched starting, so the decision to create one stays with the user. If you are Claude and a task looks like it wants a spawned agent, say so and print the command — do not reach for this yourself, and note that a subagent is usually the right tool instead.

## Use it

```bash
claude-spawn -d ~/code/some-repo "Investigate the flaky test in tests/test_auth.py and propose a fix"
```

Creates a detached session named `some-repo-MMDD-HHMM` running Claude Code in that directory with the prompt already submitted.

**It is the session you would have started by hand.** `claude-spawn` aims to be exactly `cd <dir> && claude "<prompt>"`, differing only in being detached, in tmux, and seeded without typing. Your Claude configuration applies unchanged — so if your settings enable Remote Control at startup, or the directory has a Telegram/iMessage channel configured, the spawned session has those too. `-r` asks for Remote Control explicitly; **omitting it does not make the session local-only**, it just means this tool did not ask for it.

The session ends when the agent does — no shell is left behind, so a finished spawn stops showing up as something still running.

**A directory inside a git repo becomes the repo root.** The `claude()` wrapper cds to the git root before launching (so `plansDirectory` resolves), which means `-d ~/code/repo/packages/api` starts the agent at `~/code/repo` with the whole repository in view, not just that package. `claude-spawn` prints a note when this applies. If the task must stay narrow, say so in the prompt — the working directory will not do it for you.

`claude-spawn --help` for the full list. The ones that matter:

| Flag | When |
|---|---|
| `-r` / `-n <name>` | You want to drive it by Remote Control from the phone. Off by default |
| `-s <name>` | The session name matters (you'll `tmux attach` to it by hand) |
| `-a` | Attach immediately instead of leaving it detached |
| `--auto` | Long unattended run that should resume itself after a rate limit — prefixes the window with the `tmux-resume` opt-in prefix |
| `--prompt-file <p>` / `-` | The seed prompt is long or multi-line |
| `-y` | Skip permission prompts. Meant for a worktree you've already accepted risk in |
| `--dry-run` | Show what would happen, change nothing |

## The gates, and why

**The gates are speed bumps, not guarantees.** They catch a combination you stated explicitly and would regret. They do not neutralise capability your own configuration grants — an earlier version tried, and the list of routes to neutralise only grew: every new way to reach a session was a silent hole until someone reviewed for it, and suppressing them quietly countermanded settings chosen on purpose.

**`-y` together with `-r` is refused** unless you add `--allow-remote-yolo`. An unrestricted agent that is also drivable from off-machine is the one combination worth stopping to think about; either alone is ordinary. Note this checks the *flags you passed*, not the capability that results — see the paragraph above.

**Spawning from inside a spawned session is refused** (`CLAUDE_SPAWN_DEPTH`). `--allow-nested` overrides it, but if you're hitting this, check that you meant to. Note what this is: an inherited environment variable. It stops accidental fan-out — the failure mode where a seeded agent reads a task as "spawn more agents" and you find twelve tmux sessions. It is not a containment boundary, because anything running in that session can unset it.

**Every spawn is logged** to `~/.local/state/claude-spawn/spawn.log` — timestamp, session, directory, flags, and a truncated SHA-256 of the prompt. The prompt text is not written to *that log*, only its hash, so an unexpected session can be traced back to a known spawn without the log itself holding the text.

That is a statement about the audit log and nothing else. The seed does reach disk by the ordinary route: it becomes the session's first user message, so it lands in the Claude transcript like anything else you type, and a positional invocation can also be captured in shell history. Hashing in the log narrows one exposure; it does not make the prompt ephemeral.

The prompt is hashed in the log but it is *not* hidden from the machine, and not only for a moment: it is passed to the agent positionally, so it stays in the running Claude process's argv and any local user can read it from `ps` or `/proc/<pid>/cmdline` for as long as the session lives. Claude Code takes its prompt positionally, so seeding inherently means this. Don't put anything in a seed prompt that you would not paste into a shared terminal.

The log is append-only and nothing reaps it — one short line per spawn, so it will not matter for years, but it is not self-limiting either. Truncate it by hand if it ever does, or wire it into the same daily hook as `claude-jobs-reap`.

## Never seed with untrusted text

Do not pipe a web page, an issue body, a PR description, or a file you did not write into the seed prompt. A fresh agent has no conversation context to weigh an injected instruction against, which makes it a softer target than a session that has been running for an hour. If the task is "act on this issue", pass the issue *reference* and let the spawned agent fetch it with its own judgement intact.

## Why not hand-roll it

Three things fail silently if you script this yourself with `tmux new-session` + `send-keys`:

1. **`send-keys` races the shell.** Keys sent to a new pane land before the zsh line editor is ready and get swallowed or mangled. The script uses Claude Code's positional `[prompt]` argument instead — no timing element at all.
2. **A bare command string loses the `claude()` wrapper.** tmux runs command strings under a non-interactive shell, which sources `.zshenv` only, and `.zshenv` does not source aliases. You silently get the raw binary without venv activation, git-root cd, `CLAUDE_CODE_TASK_LIST_ID`, or channel auto-detection. The script uses `zsh -ic`.
3. **Quoting the prompt through bash → sh → zsh eats it.** The script passes it as a tmux session environment variable (`new-session -e`) and clears it from the session environment immediately after, so no quoting layer ever sees the text and it never lingers in `tmux show-environment`.

## A seed is an argument, exactly as if you typed it

The seed goes to Claude positionally, with no option terminator, because that is what a hand-typed session does. Two consequences worth knowing rather than being surprised by:

- A seed that *is* a subcommand name runs that subcommand. `claude-spawn "doctor"` runs `claude doctor`, just as typing it would. Verified against Claude Code 2.1.222.
- A seed starting with a dash is read as an option.

An earlier version put `--` before the seed to prevent both. It was removed: it made the spawned session behave *differently* from one you start yourself, and it pushed the `claude()` wrapper's auto-detected `--channels` past the terminator, silently disabling the channel. If a seed might collide, phrase it as a sentence — "run the doctor command" rather than "doctor".

`claude --tmux` is not an alternative — it requires `--worktree`, so it cannot target an arbitrary directory.

## Task lists scope themselves; setting the ID is only for sharing one on purpose

`CLAUDE_CODE_TASK_LIST_ID` is deliberately NOT auto-generated by the `claude()` wrapper. Claude Code already gives every session its own task list when the variable is unset — measured 2026-08-28 in `~/.claude/tasks/`: **683** `session-<id>` lists it created itself against **11** `<ts>_UTC_<dir>` from ~2 months of the wrapper auto-generating. The auto-generated ID bought nothing the platform was not already doing, and cost a leak no wrapper can close: the ID must be exported into the claude process for Claude Code to read it, so every session that process spawns — **the `claude agents` view included** — inherits it. That is a different path from the shell leak, and it is unreachable from the shell. Do not reintroduce auto-generation; if a session's tasks look wrong, check what set the variable rather than adding logic to manage it.

`claude -t <name>` is the **only** way to set one, and it binds with `local -x` so it is unwound on every exit including a Ctrl-C (PTY-verified: a plain `export` leaks the ID at status 130). The `claude-new` / `claude-with` / `claude-last` / `claude-tasks-list` helpers and the `CLAUDE_CODE_TASK_LIST_PIN` marker were **deleted on 2026-08-28**: zsh history showed zero real uses across 1,283 lines, while they were the last code able to set the variable — i.e. the only remaining way to reintroduce a shared list. Do not restore them; a test asserts they stay gone.

**Every inherited ID is removed from the child's environment**, via `env -u`, with no exceptions — `-t` sets its own, so nothing legitimate arrives by inheritance. This matters because `source ~/.zshrc` cannot unset what an older wrapper already exported, so a long-lived shell keeps its stale ID until it dies; honoring inherited values is how the cross-repo leak survived a re-source (observed 2026-08-28, now pinned by a test). `env -u` rather than `local +x` or `unset` because the value must be absent from the **child**, and `local +x` does not shadow a globally exported variable under bash.

**The `claude daemon` is the last place a stale ID can hide, and no shell fix reaches it.** Sessions started from the `claude agents` view are spawned by the daemon (`claude daemon run`), not by the shell — verified by parentage: every `bg-pty-host` / `bg-spare` is its child. The daemon inherits its environment once, at start, and hands that to every session it spawns for its whole lifetime. So a daemon started from a shell carrying a stale ID keeps distributing it *hours after* the wrapper is fixed and `~/.zshrc` re-sourced (observed 2026-08-28: a daemon from 01:51 still handing out `20260825_060521_UTC_code` to children spawned at 06:38). You cannot unset it from inside a session either — the list is chosen at spawn.

Diagnose by reading the process environment directly, never by inference: `tr '\0' '\n' < /proc/<pid>/environ | grep -E 'CLAUDE_CODE_TASK_LIST|ANTHROPIC_API_KEY'` (an inherited `ANTHROPIC_API_KEY` is the same class of leak: it flips auth away from the claude.ai login and disables the Gmail/Drive/Slack connectors in every spawned session — the wrapper strips it since 2026-09-02, but a daemon started before that, or from a dir whose .envrc binds it, keeps handing it out), across `pgrep -f claude` with `ps -o lstart=` so start times separate pre- from post-fix processes. Fix by restarting the daemon from a clean shell — `unset CLAUDE_CODE_TASK_LIST_ID && claude daemon stop --any --keep-workers`; `--keep-workers` leaves detached sessions running, and the daemon respawns on demand.

**A deleted `env` key behaves differently from a stale task list.** A key still in `settings.json` is re-applied by every new session process, so edits to its value land at once; a key **deleted** from `settings.json` is never written over and survives by inheritance in every process that already held it. So `--keep-workers` defeats the fix here — drop it, or follow with `claude respawn --all` — and kill the stale holders first, since the transient daemon is spawned by whichever client next needs it and inherits *that* client's environment, which makes a month-old `claude agents` viewer a re-infection vector (`claude daemon status` lists them under `holding this daemon open`). `claude respawn` restarts a background session so it picks up the current binary; it cannot replace the daemon, so it clears a key only when the daemon is already clean. Measured 2026-09-11 — chain and evidence in `../jobs/references/task-list-scoping.md`.

Anything that launches a session through tmux must **`unset` both vars inside the command**, not blank them with `-e`: the wrapper no longer regenerates from an empty value, so a blank is passed through as a literal empty list name, and tmux's `-e` can set a variable but not remove one. `custom_bins/claude-spawn` and `_cw_launch` both do this. Such launchers must also go through `zsh -ic` — tmux runs a bare command string under a non-interactive shell that sources no aliases, so `claude` there is the raw binary. Two measured gotchas: tmux `-e` does **not** override `PATH` for a session's initial command, and zsh's `printf %q` emits `$'\t'` which dash (Debian/Ubuntu `/bin/sh`) mis-parses — use POSIX single-quote escaping.

Pinned by `tests/test_claude_task_list_scope.zsh` (15), `tests/test_task_list_pin_bash.sh` (8, bash — `deploy.sh` sources these aliases into `~/.bashrc`, and bash/zsh have diverged here before), `tests/test_claude_task_list_sigint.py` (PTY Ctrl-C) and `tests/test_cw_resume_env.zsh` (`CW_TEST_MUTATE=1` reproduces the leak) — all four mutation-verified.

Attribution

Is this your skill, or is something wrong with this listing? Request removal or report an issue. Author removals are honored within 72 hours.

Comments

Loading comments…

Newsletter

Get the weekly list of safe new skills, and the ones we pulled.