Skip to content
18 changes: 18 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,19 @@ The version is carried in `pyproject.toml` and `src/assistant/__init__.py`
## [Unreleased]

### Added
- Add `bin/cmux-claude-spawn.sh` and `bin/cmux-claude-send.sh`, one-command
launchers that a narrow `permissions.allow` rule can approve in auto mode.
The spawn script starts the `claude` alias in a new unfocused cmux workspace
and has it read one prompt file; the send script has a running session read
one prompt file. Both accept only files under `~/.assistant/spawn-prompts/`,
send with `cmux paste --submit` (which refuses over a draft or open dialog),
and confirm delivery from the transcript, counting a message queued to a
busy session. The spawn script accepts only `FFP_CHANNEL` and `FFP_EXP_*`
environment switches, refuses a `claude` alias that isn't auto mode or
carries `--dangerously-skip-permissions`, and types nothing until the
session shows auto mode. The send script takes surface UUIDs only, refuses
its caller's own surface, and refuses a target that isn't showing auto mode.

- Add the `cmux-ws-numberer` LaunchAgent as an opt-in feature. It adds a `[N]`
suffix to cmux workspace titles and gives each workspace an unused sidebar
color. Enable it with `install.sh --apply --with-ws-numberer`, answer the
Expand Down Expand Up @@ -56,6 +69,11 @@ The version is carried in `pyproject.toml` and `src/assistant/__init__.py`
- Add an attention overview with compact task cards, expandable return notes,
and reminders to finish older pending work before starting another task.

### Changed
- `spawn-claude-workspace` launches `engine=claude` with
`bin/cmux-claude-spawn.sh` and no longer adds
`--dangerously-skip-permissions`; child sessions run in auto mode.

### Fixed
- Bring the warm Slack session up again on Claude Code 2.1.303 and cmux 0.65.
Claude Code now writes several hundred KB of records right after each prompt,
Expand Down
31 changes: 31 additions & 0 deletions bin/cmux-claude-send.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
#!/bin/zsh
# Have a running Claude Code session read one staged prompt file, and confirm it reached the transcript.
# usage: cmux-claude-send.sh <surface-uuid> <transcript> <prompt-file>
# <prompt-file> must be a file under ~/.assistant/spawn-prompts/; the session receives "Read <file> and follow it."
# stdout: "delivered" or "not-delivered"
# exit: 0 delivered; 1 refused by cmux (draft or dialog open) or not seen within 120s; 2 bad arguments;
# 3 the target isn't showing auto mode.
set -u
PROMPTS="$HOME/.assistant/spawn-prompts"
[[ $# -eq 3 ]] || { echo "usage: ${0:t} <surface-uuid> <transcript> <prompt-file>" >&2; exit 2; }
SF="$1"; T="$2"; PF="${3:A}"
[[ "$SF" =~ '^[0-9A-Fa-f]{8}-[0-9A-Fa-f]{4}-[0-9A-Fa-f]{4}-[0-9A-Fa-f]{4}-[0-9A-Fa-f]{12}$' ]] \
|| { echo "surface must be a UUID: $SF" >&2; exit 2; }
[[ "${SF:u}" != "${${CMUX_SURFACE_ID:-}:u}" ]] || { echo "refusing to type into this session's own surface" >&2; exit 2; }
[[ -f "$T" ]] || { echo "no such transcript: $T" >&2; exit 2; }
[[ "$PF" == "${PROMPTS:A}"/* && -f "$PF" && "$PF" =~ '^[A-Za-z0-9._/-]+$' ]] \
|| { echo "prompt file must be a plain-named file under $PROMPTS: $3" >&2; exit 2; }

MSG="Read $PF and follow it."
count() { grep -F -- "$MSG" "$T" | grep -cE '"type":"user"|"operation":"enqueue"'; }
SCREEN=$(CMUX_QUIET=1 cmux read-screen --surface "$SF" --lines 60 2>/dev/null)
[[ "$SCREEN" == *"auto mode on"* && "$SCREEN" != *"bypass permissions on"* ]] \
|| { echo not-delivered; echo "refusing: the target session isn't showing auto mode" >&2; exit 3; }
BEFORE=$(count)
CMUX_QUIET=1 cmux paste --surface "$SF" --submit -- "$MSG" >/dev/null || { echo not-delivered; echo "cmux refused the paste (draft or dialog open?)" >&2; exit 1; }
for i in {1..60}; do
(( $(count) > BEFORE )) && { echo delivered; exit 0; }
sleep 2
done
echo not-delivered
exit 1
53 changes: 53 additions & 0 deletions bin/cmux-claude-spawn.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
#!/bin/zsh
# Start Claude Code (auto mode) in a new unfocused cmux workspace and have it read one staged prompt file.
# usage: cmux-claude-spawn.sh [--env FFP_CHANNEL=<v>|FFP_EXP_<ID>=<v>]... <cwd> <title> <prompt-file>
# <prompt-file> must be a file under ~/.assistant/spawn-prompts/; the session receives "Read <file> and follow it."
# stdout: "<workspace-ref> <surface-uuid> <transcript>"
# exit: 0 submitted and seen in a new transcript; 1 not ready, refused by cmux, or never seen; 2 bad arguments;
# 3 the claude alias isn't auto mode. Runs up to about 4 minutes; give the caller a 5-minute timeout.
set -u
PROMPTS="$HOME/.assistant/spawn-prompts"
ENVS=()
while [[ "${1:-}" == --env ]]; do
[[ "${2:-}" =~ '^(FFP_CHANNEL|FFP_EXP_[A-Z0-9_]+)=[a-z0-9]+$' ]] || { echo "bad --env value: ${2:-}" >&2; exit 2; }
ENVS+=(--env "$2"); shift 2
done
[[ $# -eq 3 ]] || { echo "usage: ${0:t} [--env FFP_CHANNEL=<v>|FFP_EXP_<ID>=<v>]... <cwd> <title> <prompt-file>" >&2; exit 2; }
CWD="${1:A}"; TITLE="$2"; PF="${3:A}"
[[ -d "$CWD" ]] || { echo "no such directory: $CWD" >&2; exit 2; }
[[ "$PF" == "${PROMPTS:A}"/* && -f "$PF" && "$PF" =~ '^[A-Za-z0-9._/-]+$' ]] \
|| { echo "prompt file must be a plain-named file under $PROMPTS: $3" >&2; exit 2; }

ALIAS=$(zsh -lic 'alias claude' 2>/dev/null)
[[ "$ALIAS" == *"--permission-mode auto"* && "$ALIAS" != *--dangerously-skip-permissions* ]] \
|| { echo "refusing: the claude alias isn't auto mode: $ALIAS" >&2; exit 3; }

MSG="Read $PF and follow it."
SD="$HOME/.claude/projects/${CWD//[^A-Za-z0-9]/-}"
BEFORE=$(find "$SD" -maxdepth 1 -name '*.jsonl' 2>/dev/null | sort)

OUT=$(CMUX_QUIET=1 cmux new-workspace --cwd "$CWD" --name "$TITLE" --focus false "${ENVS[@]}" --command "zsh -lic claude")
WS=$(printf '%s' "$OUT" | grep -oE 'workspace:[0-9]+' | head -1)
[[ -n "$WS" ]] && SF=$(CMUX_QUIET=1 cmux list-pane-surfaces --workspace "$WS" --id-format uuids | grep -oE '[0-9A-Fa-f]{8}-[0-9A-Fa-f-]{27}' | head -1)
[[ -n "$WS" && -n "${SF:-}" ]] || { echo "spawn failed: $OUT" >&2; exit 1; }
echo "spawned $WS $SF" >&2

READY=0
for i in {1..90}; do
SCREEN=$(CMUX_QUIET=1 cmux read-screen --surface "$SF" --lines 60 2>/dev/null)
[[ "$SCREEN" == *"auto mode on"* && "$SCREEN" != *"bypass permissions on"* ]] && { READY=1; break; }
[[ -z "${SCREEN//[[:space:]]/}" ]] && (( i % 5 == 3 )) && CMUX_QUIET=1 cmux send-key --surface "$SF" enter >/dev/null 2>&1
sleep 1
done
(( READY )) || { echo "$WS $SF not-ready"; echo "session never showed auto mode; nothing was typed" >&2; exit 1; }

CMUX_QUIET=1 cmux paste --surface "$SF" --submit -- "$MSG" >/dev/null || { echo "$WS $SF paste-refused"; exit 1; }

for i in {1..60}; do
for f in ${(f)"$(find "$SD" -maxdepth 1 -name '*.jsonl' 2>/dev/null | sort | comm -13 <(print -r -- "$BEFORE") -)"}; do
grep -F -- "$MSG" "$f" 2>/dev/null | grep -qE '"type":"user"|"operation":"enqueue"' && { echo "$WS $SF $f"; exit 0; }
done
sleep 2
done
echo "$WS $SF transcript-not-found"
exit 1
21 changes: 7 additions & 14 deletions skills/spawn-claude-workspace/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ Two engines are supported, selected by the `engine` input:

- Always pass `--focus false` to `cmux new-workspace`.
- Never stream a long prompt through cmux. Stage it under `~/.assistant/spawn-prompts/` and send a short `Read <path>...` instruction.
- Use `surface.send_text` followed by an explicit `surface.send_key` Enter.
- `engine=droid`: use `surface.send_text` followed by an explicit `surface.send_key` Enter. `engine=claude`: the scripts below use `cmux paste --submit`, which refuses over a draft or open dialog.
- Never select the new workspace automatically.
- The transcript is the authoritative submission signal — a cmux success response alone is NOT confirmation.

Expand All @@ -40,10 +40,11 @@ bracket number. Leave naming and color to the daemon.

### `claude`

- Launch via the login-shell `claude` alias PLUS an explicit `--dangerously-skip-permissions`. The alias may drift and drop the flag; adding it explicitly is idempotent and prevents an unattended session stalling on the first permission gate. The alias still supplies `--model`, `--add-dir`, etc.
- The command must run in a login shell so the alias expands: `zsh -lic 'claude --dangerously-skip-permissions'`.
- Launch with `/Users/mukuls/.claude/bin/cmux-claude-spawn.sh`. It starts the login-shell `claude` alias, which runs in auto mode, so every child session is checked by the same auto-mode classifier. Never add `--dangerously-skip-permissions`; the script refuses to launch when the alias carries it or isn't auto mode.
- Run the script by that exact absolute path, as one command with nothing piped, chained, or captured around it, so its `permissions.allow` rule applies. Give it a 5-minute timeout.
- To message a running child session, stage a file under `~/.assistant/spawn-prompts/` and run `/Users/mukuls/.claude/bin/cmux-claude-send.sh <surface-uuid> <transcript> <prompt-file>`, the same way.
- Do NOT hardcode `--model`/`--add-dir` — the alias owns them. Override `--model` only when the task genuinely needs a different tier.
- Claude transcripts: `~/.claude/projects/<project-slug>/<session-id>.jsonl`. `<project-slug>` is the cwd with every `/` replaced by `-` (leading `-` included). Sessions share one project dir; confirmation is a NEW jsonl appearing after spawn.
- Claude transcripts: `~/.claude/projects/<project-slug>/<session-id>.jsonl`. `<project-slug>` is the cwd with every non-alphanumeric character replaced by `-` (leading `-` included). Sessions share one project dir; confirmation is a NEW jsonl appearing after spawn.
- Never fall back to Droid if Claude fails.

## Inputs
Expand All @@ -56,14 +57,14 @@ bracket number. Leave naming and color to the daemon.

## Procedure

For `engine=claude`, do step 3, then run `/Users/mukuls/.claude/bin/cmux-claude-spawn.sh [--env FFP_CHANNEL=<v>|FFP_EXP_<ID>=<v>]... <cwd> <title> <prompt-file>` in place of steps 1, 2, and 4-7. It prints `<workspace-ref> <surface-uuid> <transcript>` and exits 0 only when the transcript shows the instruction. On a nonzero exit, leave the workspace and prompt file intact, report the refs it printed, and don't spawn a duplicate. The steps below are for `engine=droid`.

1. Verify cmux and the engine's prerequisites:

```bash
cmux ping >/dev/null
# engine=droid:
test -f "$HOME/.assistant/droid-glm-settings.json"
# engine=claude:
zsh -lic 'alias claude' | grep -q 'claude --model'
```

2. Resolve the cwd physically and derive the engine's transcript directory:
Expand All @@ -74,9 +75,6 @@ CWD=$(python3 -c 'import os,sys; print(os.path.realpath(sys.argv[1]))' "$CWD")
# engine=droid:
SLUG=$(python3 -c 'import os,sys; print(os.path.realpath(sys.argv[1]).replace("/", "-"))' "$CWD")
SESSION_DIR="$HOME/.factory/sessions/$SLUG"
# engine=claude (project slug: leading dash + slashes to dashes):
CSLUG=$(python3 -c 'import os,sys; print(os.path.realpath(sys.argv[1]).replace("/", "-"))' "$CWD")
SESSION_DIR="$HOME/.claude/projects/$CSLUG"
mkdir -p "$SESSION_DIR" "$HOME/.assistant/spawn-prompts"
```

Expand All @@ -96,8 +94,6 @@ LAUNCH_CMD="droid --settings '$HOME/.assistant/droid-glm-settings.json' --auto h
if test -f "$HOME/.claude/CLAUDE.md"; then
LAUNCH_CMD="$LAUNCH_CMD --append-system-prompt-file '$HOME/.claude/CLAUDE.md'"
fi
# engine=claude (login shell so the alias expands; explicit dsp for unattended runs):
LAUNCH_CMD="zsh -lic 'claude --dangerously-skip-permissions'"
OUT=$(cmux new-workspace --cwd "$CWD" --name "$TITLE" --focus false --command "$LAUNCH_CMD")
WS_REF=$(printf '%s' "$OUT" | grep -oE 'workspace:[0-9]+' | head -n1)
SURFACE_REF=$(cmux list-pane-surfaces --workspace "$WS_REF" | grep -oE 'surface:[0-9]+' | head -n1)
Expand All @@ -107,16 +103,13 @@ test -n "$WS_REF" && test -n "$SURFACE_REF"
5. Poll the whole screen until the engine is ready. Read 200 lines and accept an engine-appropriate marker:

- **droid**: `GLM-5.2`, `allow all commands`, `Skills (<n>)`, or `? for help`. Do not accept a Claude banner.
- **claude**: the Claude Code prompt box / `? for shortcuts`, `bypass permissions` (the dsp banner), or the model id `claude-opus`. Do not accept a Droid banner.

```bash
READY=0
for _ in $(seq 1 30); do
SCREEN=$(cmux rpc surface.read_text "$(python3 -c 'import json,sys; print(json.dumps({"surface_id":sys.argv[1],"lines":200}))' "$SURFACE_REF")" | python3 -c 'import json,sys; print(json.load(sys.stdin).get("text", ""))')
# engine=droid:
if printf '%s' "$SCREEN" | grep -Eq 'GLM-?5\.2|allow all commands|Skills \([0-9]+\)|\? for help'; then READY=1; break; fi
# engine=claude:
if printf '%s' "$SCREEN" | grep -Eiq 'bypass permissions|\? for shortcuts|claude-opus|Welcome to Claude'; then READY=1; break; fi
sleep 1
done
test "$READY" = 1
Expand Down
2 changes: 1 addition & 1 deletion tests/check_change_coverage.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@
# Shell files with no line-coverage tool; each changed code hunk needs a
# mutation_smoke check that breaks only changed code lines in it. A check can
# still be written to dodge this, so review new checks like any test.
MUTATION_PROVEN = {"install.sh"}
MUTATION_PROVEN = {"install.sh", "bin/cmux-claude-spawn.sh", "bin/cmux-claude-send.sh"}


def changed_lines(repo: Path, base: str) -> dict[str, set[int]]:
Expand Down
42 changes: 42 additions & 0 deletions tests/mutation_smoke.py
Original file line number Diff line number Diff line change
Expand Up @@ -154,6 +154,48 @@
"",
"tests/test_install_optin_prompt.py::test_apply_removes_the_legacy_ws_numberer_plist",
),
(
"spawn accepts only FFP experiment switches",
"bin/cmux-claude-spawn.sh",
"\"${2:-}\" =~ '^(FFP_CHANNEL|FFP_EXP_[A-Z0-9_]+)=[a-z0-9]+$'",
"\"${2:-}\" =~ '^[A-Za-z_]+=.+$'",
"tests/test_cmux_claude_launcher.py::test_spawn_refuses_env_other_than_ffp_experiment_switches",
),
(
"spawn requires an auto-mode claude alias",
"bin/cmux-claude-spawn.sh",
'"$ALIAS" == *"--permission-mode auto"* && ',
"",
"tests/test_cmux_claude_launcher.py::test_spawn_refuses_an_alias_that_is_not_auto_mode",
),
(
"spawn reads prompts only from the staging folder",
"bin/cmux-claude-spawn.sh",
'"$PF" == "${PROMPTS:A}"/*',
'"$PF" == "${PROMPTS:A}"*',
"tests/test_cmux_claude_launcher.py::test_spawn_refuses_a_sibling_of_the_staging_folder",
),
(
"spawn keeps the new workspace unfocused",
"bin/cmux-claude-spawn.sh",
"--focus false",
"--focus true",
"tests/test_cmux_claude_launcher.py::test_spawn_launches_unfocused_auto_mode_claude_and_returns_the_new_transcript",
),
(
"send refuses the caller's own surface",
"bin/cmux-claude-send.sh",
'[[ "${SF:u}" != "${${CMUX_SURFACE_ID:-}:u}" ]] ||',
"true ||",
"tests/test_cmux_claude_launcher.py::test_send_refuses_its_own_surface_and_short_refs",
),
(
"send refuses a target in bypass mode",
"bin/cmux-claude-send.sh",
' && "$SCREEN" != *"bypass permissions on"*',
"",
"tests/test_cmux_claude_launcher.py::test_send_refuses_a_target_that_is_not_in_auto_mode",
),
)


Expand Down
Loading
Loading