From 7d3d7dd16b3c3e1ce571d6235b36b48a46a73c5f Mon Sep 17 00:00:00 2001 From: Mukul Sharma Date: Sun, 18 Oct 2026 21:07:02 -0700 Subject: [PATCH 1/8] feat(bin): one-command cmux launchers for Claude Code sessions Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 6 ++ bin/cmux-claude-send.sh | 23 +++++ bin/cmux-claude-spawn.sh | 44 +++++++++ tests/test_cmux_claude_launcher.py | 147 +++++++++++++++++++++++++++++ 4 files changed, 220 insertions(+) create mode 100755 bin/cmux-claude-send.sh create mode 100755 bin/cmux-claude-spawn.sh create mode 100644 tests/test_cmux_claude_launcher.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 5c471a9..6d60161 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,12 @@ 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`. The spawn + script starts Claude Code in a new unfocused cmux workspace, submits one + message, and prints the workspace, surface, and transcript. The send script + types a message into a running session and confirms it reached the + transcript. Each is one command, so a narrow `permissions.allow` rule can + approve it in auto mode without the classifier. - 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 diff --git a/bin/cmux-claude-send.sh b/bin/cmux-claude-send.sh new file mode 100755 index 0000000..41ab18c --- /dev/null +++ b/bin/cmux-claude-send.sh @@ -0,0 +1,23 @@ +#!/bin/zsh +# Type a message into a running Claude Code session's cmux surface, press Enter, and confirm it reached the transcript. +# usage: cmux-claude-send.sh +# stdout: "delivered" or "not-delivered"; exit 1 when not delivered within 60s. +set -u +[[ $# -eq 3 ]] || { echo "usage: ${0:t} " >&2; exit 2; } +SF="$1"; T="$2"; MSG="$3" +[[ -f "$T" ]] || { echo "no such transcript: $T" >&2; exit 2; } +NEEDLE=$(python3 -c 'import json,sys; print(json.dumps(sys.argv[1].splitlines()[0])[1:-1])' "$MSG") +rpc() { cmux rpc "$1" "$(python3 -c 'import json,sys; d={"surface_id":sys.argv[1]}; d.update(a.split("=",1) for a in sys.argv[2:]); print(json.dumps(d))' "$SF" "${@:2}")"; } +count() { grep -cF -- "$NEEDLE" "$T"; } + +BEFORE=$(count) +rpc surface.send_text "text=$MSG" >/dev/null +sleep 1 +rpc surface.send_key key=enter >/dev/null +for i in {1..30}; do + (( $(count) > BEFORE )) && { echo delivered; exit 0; } + (( i == 5 )) && rpc surface.send_key key=enter >/dev/null + sleep 2 +done +echo not-delivered +exit 1 diff --git a/bin/cmux-claude-spawn.sh b/bin/cmux-claude-spawn.sh new file mode 100755 index 0000000..1d21b99 --- /dev/null +++ b/bin/cmux-claude-spawn.sh @@ -0,0 +1,44 @@ +#!/bin/zsh +# Start Claude Code in a new unfocused cmux workspace and submit one message. +# usage: cmux-claude-spawn.sh [--env KEY=VALUE]... <message> +# stdout: "<workspace> <surface> <transcript>"; exit 1 when the session or its transcript never appears. +set -u +EXPORTS=() +while [[ "${1:-}" == --env ]]; do + [[ "${2:-}" =~ '^[A-Za-z_][A-Za-z0-9_]*=[A-Za-z0-9._:/@%+-]*$' ]] || { echo "bad --env value: ${2:-}" >&2; exit 2; } + EXPORTS+=("$2"); shift 2 +done +[[ $# -eq 3 ]] || { echo "usage: ${0:t} [--env KEY=VALUE]... <cwd> <title> <message>" >&2; exit 2; } +CWD="${1:A}"; TITLE="$2"; MSG="$3" +[[ -d "$CWD" ]] || { echo "no such directory: $CWD" >&2; exit 2; } +SD="$HOME/.claude/projects/${CWD//[^A-Za-z0-9]/-}" +NEEDLE=$(python3 -c 'import json,sys; print(json.dumps(sys.argv[1].splitlines()[0])[1:-1])' "$MSG") +BEFORE=$(find "$SD" -maxdepth 1 -name '*.jsonl' 2>/dev/null | sort) + +LAUNCH="claude" +(( ${#EXPORTS} )) && LAUNCH="export ${EXPORTS[*]}; claude" +OUT=$(CMUX_QUIET=1 cmux new-workspace --cwd "$CWD" --name "$TITLE" --focus false --command "zsh -lic '$LAUNCH'") +WS=$(printf '%s' "$OUT" | grep -oE 'workspace:[0-9]+' | head -1) +[[ -n "$WS" ]] && SF=$(cmux list-pane-surfaces --workspace "$WS" | grep -oE 'surface:[0-9]+' | head -1) +[[ -n "$WS" && -n "${SF:-}" ]] || { echo "spawn failed: $OUT" >&2; exit 1; } + +rpc() { cmux rpc "$1" "$(python3 -c 'import json,sys; d={"surface_id":sys.argv[1]}; d.update(a.split("=",1) for a in sys.argv[2:]); print(json.dumps(d))' "$SF" "${@:2}")"; } +screen() { rpc surface.read_text lines=200 | python3 -c 'import json,sys; print(json.load(sys.stdin).get("text",""))'; } + +for i in {1..60}; do + screen | grep -Eiq 'auto mode|\? for shortcuts|bypass permissions' && break + (( i == 3 )) && rpc surface.send_key key=enter >/dev/null + sleep 1 +done +rpc surface.send_text "text=$MSG" >/dev/null +sleep 1 +rpc surface.send_key key=enter >/dev/null + +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 -qF -- "$NEEDLE" "$f" && { echo "$WS $SF $f"; exit 0; } + done + sleep 2 +done +echo "$WS $SF transcript-not-found" +exit 1 diff --git a/tests/test_cmux_claude_launcher.py b/tests/test_cmux_claude_launcher.py new file mode 100644 index 0000000..cad7b5e --- /dev/null +++ b/tests/test_cmux_claude_launcher.py @@ -0,0 +1,147 @@ +"""cmux-claude-spawn.sh and cmux-claude-send.sh against a fake cmux that plays Claude Code.""" +from __future__ import annotations + +import json +import os +import subprocess +from pathlib import Path + +import pytest + +REPO = Path(__file__).resolve().parent.parent +SPAWN = REPO / "bin" / "cmux-claude-spawn.sh" +SEND = REPO / "bin" / "cmux-claude-send.sh" + +FAKE_CMUX = r'''#!/usr/bin/env python3 +"""Fake cmux: logs every call; on Enter after typed text, appends the text to a transcript like Claude Code does.""" +import json, os, sys +from pathlib import Path + +state = Path(os.environ["FAKE_CMUX_STATE"]) +log = state / "calls.jsonl" +with log.open("a") as fh: + fh.write(json.dumps(sys.argv[1:]) + "\n") +args = sys.argv[1:] +if args[0] == "new-workspace": + print("OK workspace:7") +elif args[0] == "list-pane-surfaces": + print("* surface:9 zsh -lic claude [selected]") +elif args[:2] == ["rpc", "surface.read_text"]: + print(json.dumps({"text": "⏵⏵ auto mode on (shift+tab to cycle)"})) +elif args[:2] == ["rpc", "surface.send_text"]: + (state / "typed").write_text(json.loads(args[2])["text"]) +elif args[:2] == ["rpc", "surface.send_key"]: + typed = state / "typed" + if typed.exists() and os.environ.get("FAKE_CMUX_DROP") != "1": + target = Path(os.environ["FAKE_CMUX_TRANSCRIPT"]) + target.parent.mkdir(parents=True, exist_ok=True) + with target.open("a") as fh: + fh.write(json.dumps({"type": "user", "message": {"role": "user", "content": typed.read_text()}}) + "\n") + typed.unlink() + print("{}") +''' + + +@pytest.fixture +def env(tmp_path): + home = tmp_path / "home" + home.mkdir() + fake_bin = tmp_path / "fakebin" + fake_bin.mkdir() + cmux = fake_bin / "cmux" + cmux.write_text(FAKE_CMUX) + cmux.chmod(0o755) + state = tmp_path / "state" + state.mkdir() + return { + **os.environ, + "HOME": str(home), + "PATH": f"{fake_bin}:{os.environ['PATH']}", + "FAKE_CMUX_STATE": str(state), + } + + +def calls(env): + lines = (Path(env["FAKE_CMUX_STATE"]) / "calls.jsonl").read_text().splitlines() + return [json.loads(line) for line in lines] + + +def project_dir(env, cwd: Path) -> Path: + slug = "".join(c if c.isalnum() else "-" for c in str(cwd.resolve())) + return Path(env["HOME"]) / ".claude" / "projects" / slug + + +def test_spawn_launches_unfocused_claude_and_returns_the_new_transcript(tmp_path, env): + work = tmp_path / "my.repo" + work.mkdir() + transcript = project_dir(env, work) / "abc.jsonl" + env["FAKE_CMUX_TRANSCRIPT"] = str(transcript) + msg = '/archffp --mode=fix FFP-1 "quoted"' + r = subprocess.run(["zsh", str(SPAWN), "--env", "FFP_CHANNEL=beta", "--env", "FFP_EXP_X=on", + str(work), "FFP-1 beta", msg], + capture_output=True, text=True, env=env, timeout=60) + assert r.returncode == 0, r.stderr + assert r.stdout.split() == ["workspace:7", "surface:9", str(transcript)] + new_ws = next(c for c in calls(env) if c[0] == "new-workspace") + assert new_ws == ["new-workspace", "--cwd", str(work.resolve()), "--name", "FFP-1 beta", "--focus", "false", + "--command", "zsh -lic 'export FFP_CHANNEL=beta FFP_EXP_X=on; claude'"] + sent = [json.loads(c[2]) for c in calls(env) if c[:2] == ["rpc", "surface.send_text"]] + assert sent == [{"surface_id": "surface:9", "text": msg}] + + +def test_spawn_without_env_runs_plain_claude(tmp_path, env): + work = tmp_path / "w" + work.mkdir() + env["FAKE_CMUX_TRANSCRIPT"] = str(project_dir(env, work) / "s.jsonl") + r = subprocess.run(["zsh", str(SPAWN), str(work), "t", "hello"], + capture_output=True, text=True, env=env, timeout=60) + assert r.returncode == 0, r.stderr + new_ws = next(c for c in calls(env) if c[0] == "new-workspace") + assert new_ws[-1] == "zsh -lic 'claude'" + + +def test_spawn_ignores_transcripts_that_existed_before_launch(tmp_path, env): + work = tmp_path / "w" + work.mkdir() + old = project_dir(env, work) / "old.jsonl" + old.parent.mkdir(parents=True) + old.write_text(json.dumps({"message": {"content": "hello"}}) + "\n") + new = project_dir(env, work) / "new.jsonl" + env["FAKE_CMUX_TRANSCRIPT"] = str(new) + r = subprocess.run(["zsh", str(SPAWN), str(work), "t", "hello"], + capture_output=True, text=True, env=env, timeout=60) + assert r.returncode == 0, r.stderr + assert r.stdout.split()[2] == str(new) + + +@pytest.mark.parametrize("bad", ["A=1; rm -rf x", "A=$(id)", "A='x'", "1A=x", "A"]) +def test_spawn_refuses_env_values_that_could_break_the_launch_command(tmp_path, env, bad): + r = subprocess.run(["zsh", str(SPAWN), "--env", bad, str(tmp_path), "t", "m"], + capture_output=True, text=True, env=env, timeout=30) + assert r.returncode == 2 + assert "bad --env value" in r.stderr + assert not (Path(env["FAKE_CMUX_STATE"]) / "calls.jsonl").exists() + + +def test_send_confirms_delivery_from_the_transcript(tmp_path, env): + transcript = tmp_path / "t.jsonl" + transcript.write_text(json.dumps({"message": {"content": "earlier"}}) + "\n") + env["FAKE_CMUX_TRANSCRIPT"] = str(transcript) + msg = 'Decision: proceed with "route b".\nSecond line.' + r = subprocess.run(["zsh", str(SEND), "surface:9", str(transcript), msg], + capture_output=True, text=True, env=env, timeout=60) + assert (r.returncode, r.stdout.strip()) == (0, "delivered"), r.stderr + sent = [json.loads(c[2]) for c in calls(env) if c[:2] == ["rpc", "surface.send_text"]] + assert sent == [{"surface_id": "surface:9", "text": msg}] + + +def test_send_does_not_count_an_earlier_copy_of_the_same_message(tmp_path, env): + transcript = tmp_path / "t.jsonl" + transcript.write_text(json.dumps({"message": {"content": "proceed"}}) + "\n") + env["FAKE_CMUX_TRANSCRIPT"] = str(transcript) + env["FAKE_CMUX_DROP"] = "1" + r = subprocess.run(["zsh", str(SEND), "surface:9", str(transcript), "proceed"], + capture_output=True, text=True, env=env, timeout=90) + assert (r.returncode, r.stdout.strip()) == (1, "not-delivered") + enters = [c for c in calls(env) if c[:2] == ["rpc", "surface.send_key"]] + assert len(enters) == 2 From ba81bdf1441034e19a4daaed06453a090a23e282 Mon Sep 17 00:00:00 2001 From: Mukul Sharma <elitecoder.mukul@gmail.com> Date: Sun, 18 Oct 2026 21:07:15 -0700 Subject: [PATCH 2/8] test: make the stale-transcript case sort first Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- tests/test_cmux_claude_launcher.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/test_cmux_claude_launcher.py b/tests/test_cmux_claude_launcher.py index cad7b5e..b61034a 100644 --- a/tests/test_cmux_claude_launcher.py +++ b/tests/test_cmux_claude_launcher.py @@ -103,7 +103,7 @@ def test_spawn_without_env_runs_plain_claude(tmp_path, env): def test_spawn_ignores_transcripts_that_existed_before_launch(tmp_path, env): work = tmp_path / "w" work.mkdir() - old = project_dir(env, work) / "old.jsonl" + old = project_dir(env, work) / "0-old.jsonl" old.parent.mkdir(parents=True) old.write_text(json.dumps({"message": {"content": "hello"}}) + "\n") new = project_dir(env, work) / "new.jsonl" From 5e85d04cd1fcdafb95f5b2d6edfcecc914470870 Mon Sep 17 00:00:00 2001 From: Mukul Sharma <elitecoder.mukul@gmail.com> Date: Sun, 18 Oct 2026 21:34:22 -0700 Subject: [PATCH 3/8] fix(bin): harden the cmux launchers after review Prompt files only, FFP env switches only, auto-mode alias required, guarded cmux paste, no blind Enter, UUID surfaces, self-target refused. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- bin/cmux-claude-send.sh | 30 ++-- bin/cmux-claude-spawn.sh | 54 ++++---- tests/test_cmux_claude_launcher.py | 211 +++++++++++++++++++---------- 3 files changed, 189 insertions(+), 106 deletions(-) diff --git a/bin/cmux-claude-send.sh b/bin/cmux-claude-send.sh index 41ab18c..51747bf 100755 --- a/bin/cmux-claude-send.sh +++ b/bin/cmux-claude-send.sh @@ -1,22 +1,26 @@ #!/bin/zsh -# Type a message into a running Claude Code session's cmux surface, press Enter, and confirm it reached the transcript. -# usage: cmux-claude-send.sh <surface> <transcript> <message> -# stdout: "delivered" or "not-delivered"; exit 1 when not delivered within 60s. +# 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. set -u -[[ $# -eq 3 ]] || { echo "usage: ${0:t} <surface> <transcript> <message>" >&2; exit 2; } -SF="$1"; T="$2"; MSG="$3" +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; } -NEEDLE=$(python3 -c 'import json,sys; print(json.dumps(sys.argv[1].splitlines()[0])[1:-1])' "$MSG") -rpc() { cmux rpc "$1" "$(python3 -c 'import json,sys; d={"surface_id":sys.argv[1]}; d.update(a.split("=",1) for a in sys.argv[2:]); print(json.dumps(d))' "$SF" "${@:2}")"; } -count() { grep -cF -- "$NEEDLE" "$T"; } +[[ "$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 -cF '"type":"user"'; } BEFORE=$(count) -rpc surface.send_text "text=$MSG" >/dev/null -sleep 1 -rpc surface.send_key key=enter >/dev/null -for i in {1..30}; do +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; } - (( i == 5 )) && rpc surface.send_key key=enter >/dev/null sleep 2 done echo not-delivered diff --git a/bin/cmux-claude-spawn.sh b/bin/cmux-claude-spawn.sh index 1d21b99..8102c75 100755 --- a/bin/cmux-claude-spawn.sh +++ b/bin/cmux-claude-spawn.sh @@ -1,42 +1,50 @@ #!/bin/zsh -# Start Claude Code in a new unfocused cmux workspace and submit one message. -# usage: cmux-claude-spawn.sh [--env KEY=VALUE]... <cwd> <title> <message> -# stdout: "<workspace> <surface> <transcript>"; exit 1 when the session or its transcript never appears. +# 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. set -u -EXPORTS=() +PROMPTS="$HOME/.assistant/spawn-prompts" +ENVS=() while [[ "${1:-}" == --env ]]; do - [[ "${2:-}" =~ '^[A-Za-z_][A-Za-z0-9_]*=[A-Za-z0-9._:/@%+-]*$' ]] || { echo "bad --env value: ${2:-}" >&2; exit 2; } - EXPORTS+=("$2"); shift 2 + [[ "${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 KEY=VALUE]... <cwd> <title> <message>" >&2; exit 2; } -CWD="${1:A}"; TITLE="$2"; MSG="$3" +[[ $# -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]/-}" -NEEDLE=$(python3 -c 'import json,sys; print(json.dumps(sys.argv[1].splitlines()[0])[1:-1])' "$MSG") BEFORE=$(find "$SD" -maxdepth 1 -name '*.jsonl' 2>/dev/null | sort) -LAUNCH="claude" -(( ${#EXPORTS} )) && LAUNCH="export ${EXPORTS[*]}; claude" -OUT=$(CMUX_QUIET=1 cmux new-workspace --cwd "$CWD" --name "$TITLE" --focus false --command "zsh -lic '$LAUNCH'") +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 list-pane-surfaces --workspace "$WS" | grep -oE 'surface:[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; } -rpc() { cmux rpc "$1" "$(python3 -c 'import json,sys; d={"surface_id":sys.argv[1]}; d.update(a.split("=",1) for a in sys.argv[2:]); print(json.dumps(d))' "$SF" "${@:2}")"; } -screen() { rpc surface.read_text lines=200 | python3 -c 'import json,sys; print(json.load(sys.stdin).get("text",""))'; } - -for i in {1..60}; do - screen | grep -Eiq 'auto mode|\? for shortcuts|bypass permissions' && break - (( i == 3 )) && rpc surface.send_key key=enter >/dev/null +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"* ]] && { 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 -rpc surface.send_text "text=$MSG" >/dev/null -sleep 1 -rpc surface.send_key key=enter >/dev/null +(( 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 -qF -- "$NEEDLE" "$f" && { echo "$WS $SF $f"; exit 0; } + grep -F -- "$MSG" "$f" 2>/dev/null | grep -qF '"type":"user"' && { echo "$WS $SF $f"; exit 0; } done sleep 2 done diff --git a/tests/test_cmux_claude_launcher.py b/tests/test_cmux_claude_launcher.py index b61034a..105bfb2 100644 --- a/tests/test_cmux_claude_launcher.py +++ b/tests/test_cmux_claude_launcher.py @@ -11,41 +11,47 @@ REPO = Path(__file__).resolve().parent.parent SPAWN = REPO / "bin" / "cmux-claude-spawn.sh" SEND = REPO / "bin" / "cmux-claude-send.sh" +SURFACE = "11111111-2222-3333-4444-555555555555" +AUTO_ALIAS = "alias claude='claude --model \"opus[1m]\" --permission-mode auto --add-dir ~/dev'\n" FAKE_CMUX = r'''#!/usr/bin/env python3 -"""Fake cmux: logs every call; on Enter after typed text, appends the text to a transcript like Claude Code does.""" +"""Fake cmux: logs calls; a submitted paste lands in the transcript the way Claude Code writes it.""" import json, os, sys from pathlib import Path state = Path(os.environ["FAKE_CMUX_STATE"]) -log = state / "calls.jsonl" -with log.open("a") as fh: +with (state / "calls.jsonl").open("a") as fh: fh.write(json.dumps(sys.argv[1:]) + "\n") args = sys.argv[1:] if args[0] == "new-workspace": print("OK workspace:7") elif args[0] == "list-pane-surfaces": - print("* surface:9 zsh -lic claude [selected]") -elif args[:2] == ["rpc", "surface.read_text"]: - print(json.dumps({"text": "⏵⏵ auto mode on (shift+tab to cycle)"})) -elif args[:2] == ["rpc", "surface.send_text"]: - (state / "typed").write_text(json.loads(args[2])["text"]) -elif args[:2] == ["rpc", "surface.send_key"]: - typed = state / "typed" - if typed.exists() and os.environ.get("FAKE_CMUX_DROP") != "1": - target = Path(os.environ["FAKE_CMUX_TRANSCRIPT"]) - target.parent.mkdir(parents=True, exist_ok=True) - with target.open("a") as fh: - fh.write(json.dumps({"type": "user", "message": {"role": "user", "content": typed.read_text()}}) + "\n") - typed.unlink() - print("{}") + print("* 11111111-2222-3333-4444-555555555555 Claude Code [selected]") +elif args[0] == "read-screen": + reads = state / "reads" + n = int(reads.read_text()) + 1 if reads.exists() else 1 + reads.write_text(str(n)) + blank = n <= int(os.environ.get("FAKE_BLANK_READS", "0")) + print("" if blank else os.environ.get("FAKE_SCREEN", "⏵⏵ auto mode on (shift+tab to cycle)")) +elif args[0] == "paste": + if os.environ.get("FAKE_PASTE_REFUSE") == "1": + print("Error: draft_present", file=sys.stderr) + sys.exit(1) + text = args[args.index("--") + 1] + target = Path(os.environ["FAKE_CMUX_TRANSCRIPT"]) + target.parent.mkdir(parents=True, exist_ok=True) + with target.open("a") as fh: + if os.environ.get("FAKE_BUSY") != "1": + fh.write(json.dumps({"type": "user", "message": {"role": "user", "content": text}}, separators=(",", ":")) + "\n") + fh.write(json.dumps({"type": "last-prompt", "lastPrompt": text}, separators=(",", ":")) + "\n") ''' @pytest.fixture def env(tmp_path): home = tmp_path / "home" - home.mkdir() + (home / ".assistant" / "spawn-prompts").mkdir(parents=True) + (home / ".zprofile").write_text(AUTO_ALIAS) fake_bin = tmp_path / "fakebin" fake_bin.mkdir() cmux = fake_bin / "cmux" @@ -53,17 +59,20 @@ def env(tmp_path): cmux.chmod(0o755) state = tmp_path / "state" state.mkdir() - return { - **os.environ, - "HOME": str(home), - "PATH": f"{fake_bin}:{os.environ['PATH']}", - "FAKE_CMUX_STATE": str(state), - } + e = {k: v for k, v in os.environ.items() if not k.startswith(("CMUX_", "ZDOTDIR"))} + e.update(HOME=str(home), PATH=f"{fake_bin}:{os.environ['PATH']}", FAKE_CMUX_STATE=str(state)) + return e + + +def prompt(env, name="prompt-x.md") -> Path: + p = Path(env["HOME"]) / ".assistant" / "spawn-prompts" / name + p.write_text("Do the thing.\n") + return p def calls(env): - lines = (Path(env["FAKE_CMUX_STATE"]) / "calls.jsonl").read_text().splitlines() - return [json.loads(line) for line in lines] + log = Path(env["FAKE_CMUX_STATE"]) / "calls.jsonl" + return [json.loads(line) for line in log.read_text().splitlines()] if log.exists() else [] def project_dir(env, cwd: Path) -> Path: @@ -71,77 +80,139 @@ def project_dir(env, cwd: Path) -> Path: return Path(env["HOME"]) / ".claude" / "projects" / slug -def test_spawn_launches_unfocused_claude_and_returns_the_new_transcript(tmp_path, env): +def run(env, script, *args, timeout=240): + return subprocess.run(["zsh", str(script), *map(str, args)], capture_output=True, text=True, env=env, timeout=timeout) + + +def test_spawn_launches_unfocused_auto_mode_claude_and_returns_the_new_transcript(tmp_path, env): work = tmp_path / "my.repo" work.mkdir() transcript = project_dir(env, work) / "abc.jsonl" env["FAKE_CMUX_TRANSCRIPT"] = str(transcript) - msg = '/archffp --mode=fix FFP-1 "quoted"' - r = subprocess.run(["zsh", str(SPAWN), "--env", "FFP_CHANNEL=beta", "--env", "FFP_EXP_X=on", - str(work), "FFP-1 beta", msg], - capture_output=True, text=True, env=env, timeout=60) - assert r.returncode == 0, r.stderr - assert r.stdout.split() == ["workspace:7", "surface:9", str(transcript)] - new_ws = next(c for c in calls(env) if c[0] == "new-workspace") - assert new_ws == ["new-workspace", "--cwd", str(work.resolve()), "--name", "FFP-1 beta", "--focus", "false", - "--command", "zsh -lic 'export FFP_CHANNEL=beta FFP_EXP_X=on; claude'"] - sent = [json.loads(c[2]) for c in calls(env) if c[:2] == ["rpc", "surface.send_text"]] - assert sent == [{"surface_id": "surface:9", "text": msg}] - - -def test_spawn_without_env_runs_plain_claude(tmp_path, env): - work = tmp_path / "w" - work.mkdir() - env["FAKE_CMUX_TRANSCRIPT"] = str(project_dir(env, work) / "s.jsonl") - r = subprocess.run(["zsh", str(SPAWN), str(work), "t", "hello"], - capture_output=True, text=True, env=env, timeout=60) + pf = prompt(env) + r = run(env, SPAWN, "--env", "FFP_CHANNEL=beta", "--env", "FFP_EXP_LIGHT_LANE=on", work, "FFP-1 beta", pf) assert r.returncode == 0, r.stderr - new_ws = next(c for c in calls(env) if c[0] == "new-workspace") - assert new_ws[-1] == "zsh -lic 'claude'" + assert r.stdout.split() == ["workspace:7", SURFACE, str(transcript)] + assert [c for c in calls(env) if c[0] == "new-workspace"] == [[ + "new-workspace", "--cwd", str(work.resolve()), "--name", "FFP-1 beta", "--focus", "false", + "--env", "FFP_CHANNEL=beta", "--env", "FFP_EXP_LIGHT_LANE=on", "--command", "zsh -lic claude"]] + assert [c for c in calls(env) if c[0] == "paste"] == [ + ["paste", "--surface", SURFACE, "--submit", "--", f"Read {pf} and follow it."]] + assert not [c for c in calls(env) if c[0] == "send-key"] def test_spawn_ignores_transcripts_that_existed_before_launch(tmp_path, env): work = tmp_path / "w" work.mkdir() + pf = prompt(env) old = project_dir(env, work) / "0-old.jsonl" old.parent.mkdir(parents=True) - old.write_text(json.dumps({"message": {"content": "hello"}}) + "\n") + old.write_text(json.dumps({"type": "user", "message": {"content": f"Read {pf} and follow it."}}, + separators=(",", ":")) + "\n") new = project_dir(env, work) / "new.jsonl" env["FAKE_CMUX_TRANSCRIPT"] = str(new) - r = subprocess.run(["zsh", str(SPAWN), str(work), "t", "hello"], - capture_output=True, text=True, env=env, timeout=60) + r = run(env, SPAWN, work, "t", pf) assert r.returncode == 0, r.stderr assert r.stdout.split()[2] == str(new) -@pytest.mark.parametrize("bad", ["A=1; rm -rf x", "A=$(id)", "A='x'", "1A=x", "A"]) -def test_spawn_refuses_env_values_that_could_break_the_launch_command(tmp_path, env, bad): - r = subprocess.run(["zsh", str(SPAWN), "--env", bad, str(tmp_path), "t", "m"], - capture_output=True, text=True, env=env, timeout=30) +def test_spawn_wakes_a_blank_surface_then_waits_for_auto_mode(tmp_path, env): + work = tmp_path / "w" + work.mkdir() + env["FAKE_CMUX_TRANSCRIPT"] = str(project_dir(env, work) / "s.jsonl") + env["FAKE_BLANK_READS"] = "4" + r = run(env, SPAWN, work, "t", prompt(env)) + assert r.returncode == 0, r.stderr + kinds = [c[0] for c in calls(env)] + assert ["send-key", "--surface", SURFACE, "enter"] in calls(env) + assert kinds.index("send-key") < kinds.index("paste") + + +def test_spawn_types_nothing_when_a_dialog_is_showing(tmp_path, env): + work = tmp_path / "w" + work.mkdir() + env["FAKE_SCREEN"] = "Do you trust the files in this folder?\n> 1. Yes, proceed" + r = run(env, SPAWN, work, "t", prompt(env)) + assert (r.returncode, r.stdout.split()) == (1, ["workspace:7", SURFACE, "not-ready"]) + assert not [c for c in calls(env) if c[0] in ("paste", "send-key")] + + +def test_spawn_reports_a_refused_paste(tmp_path, env): + work = tmp_path / "w" + work.mkdir() + env["FAKE_PASTE_REFUSE"] = "1" + r = run(env, SPAWN, work, "t", prompt(env)) + assert (r.returncode, r.stdout.split()) == (1, ["workspace:7", SURFACE, "paste-refused"]) + + +@pytest.mark.parametrize("bad", [ + "PATH=/tmp/evil:/usr/bin", "ANTHROPIC_BASE_URL=https://example.com", "NODE_OPTIONS=-r/tmp/x.js", + "FFP_CHANNEL=beta;id", "FFP_CHANNEL=Beta", "FFP_EXP_X=", "FFP_CHANNEL", +]) +def test_spawn_refuses_env_other_than_ffp_experiment_switches(tmp_path, env, bad): + r = run(env, SPAWN, "--env", bad, tmp_path, "t", prompt(env)) assert r.returncode == 2 assert "bad --env value" in r.stderr - assert not (Path(env["FAKE_CMUX_STATE"]) / "calls.jsonl").exists() + assert calls(env) == [] + + +def test_spawn_refuses_prompt_files_outside_the_staging_folder(tmp_path, env): + outside = tmp_path / "prompt.md" + outside.write_text("x") + staged = prompt(env) + escape = staged.parent / ".." / ".." / ".." / "prompt.md" + for pf in (outside, escape, prompt(env, "has space.md"), staged.parent / "missing.md"): + r = run(env, SPAWN, tmp_path, "t", pf) + assert r.returncode == 2, pf + assert "prompt file must be" in r.stderr + assert calls(env) == [] + +def test_spawn_refuses_when_the_claude_alias_bypasses_permissions(tmp_path, env): + (Path(env["HOME"]) / ".zprofile").write_text( + "alias claude='claude --model x --dangerously-skip-permissions --permission-mode auto'\n") + r = run(env, SPAWN, tmp_path, "t", prompt(env)) + assert r.returncode == 3 + assert "isn't auto mode" in r.stderr + assert calls(env) == [] -def test_send_confirms_delivery_from_the_transcript(tmp_path, env): + +def test_send_confirms_delivery_and_ignores_last_prompt_copies(tmp_path, env): + pf = prompt(env) + msg = f"Read {pf} and follow it." transcript = tmp_path / "t.jsonl" - transcript.write_text(json.dumps({"message": {"content": "earlier"}}) + "\n") + transcript.write_text("".join(json.dumps({"type": "last-prompt", "lastPrompt": msg}) + "\n" for _ in range(3))) env["FAKE_CMUX_TRANSCRIPT"] = str(transcript) - msg = 'Decision: proceed with "route b".\nSecond line.' - r = subprocess.run(["zsh", str(SEND), "surface:9", str(transcript), msg], - capture_output=True, text=True, env=env, timeout=60) + r = run(env, SEND, SURFACE, transcript, pf) assert (r.returncode, r.stdout.strip()) == (0, "delivered"), r.stderr - sent = [json.loads(c[2]) for c in calls(env) if c[:2] == ["rpc", "surface.send_text"]] - assert sent == [{"surface_id": "surface:9", "text": msg}] + assert [c for c in calls(env) if c[0] == "paste"] == [["paste", "--surface", SURFACE, "--submit", "--", msg]] -def test_send_does_not_count_an_earlier_copy_of_the_same_message(tmp_path, env): +def test_send_does_not_count_a_last_prompt_record_as_delivery(tmp_path, env): transcript = tmp_path / "t.jsonl" - transcript.write_text(json.dumps({"message": {"content": "proceed"}}) + "\n") + transcript.write_text("") env["FAKE_CMUX_TRANSCRIPT"] = str(transcript) - env["FAKE_CMUX_DROP"] = "1" - r = subprocess.run(["zsh", str(SEND), "surface:9", str(transcript), "proceed"], - capture_output=True, text=True, env=env, timeout=90) + env["FAKE_BUSY"] = "1" + r = run(env, SEND, SURFACE, transcript, prompt(env)) + assert (r.returncode, r.stdout.strip()) == (1, "not-delivered") + assert len([c for c in calls(env) if c[0] == "paste"]) == 1 + + +def test_send_reports_a_refused_paste(tmp_path, env): + transcript = tmp_path / "t.jsonl" + transcript.write_text("") + env["FAKE_PASTE_REFUSE"] = "1" + r = run(env, SEND, SURFACE, transcript, prompt(env)) assert (r.returncode, r.stdout.strip()) == (1, "not-delivered") - enters = [c for c in calls(env) if c[:2] == ["rpc", "surface.send_key"]] - assert len(enters) == 2 + + +def test_send_refuses_its_own_surface_and_short_refs(tmp_path, env): + transcript = tmp_path / "t.jsonl" + transcript.write_text("") + pf = prompt(env) + own = {**env, "CMUX_SURFACE_ID": SURFACE.upper()} + r = run(own, SEND, SURFACE, transcript, pf) + assert r.returncode == 2 and "own surface" in r.stderr + r = run(env, SEND, "surface:9", transcript, pf) + assert r.returncode == 2 and "must be a UUID" in r.stderr + assert calls(env) == [] From fdb83f8ee95aeaa0de6d4b0e70dc933c24320528 Mon Sep 17 00:00:00 2001 From: Mukul Sharma <elitecoder.mukul@gmail.com> Date: Sun, 18 Oct 2026 21:48:32 -0700 Subject: [PATCH 4/8] fix(bin): count queued messages; point the spawn skill at the launcher Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- CHANGELOG.md | 23 +++++++++++++++++------ bin/cmux-claude-send.sh | 2 +- bin/cmux-claude-spawn.sh | 5 +++-- skills/spawn-claude-workspace/SKILL.md | 17 +++++------------ tests/test_cmux_claude_launcher.py | 19 +++++++++++++++++-- 5 files changed, 43 insertions(+), 23 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 6d60161..2caa238 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,12 +10,23 @@ 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`. The spawn - script starts Claude Code in a new unfocused cmux workspace, submits one - message, and prints the workspace, surface, and transcript. The send script - types a message into a running session and confirms it reached the - transcript. Each is one command, so a narrow `permissions.allow` rule can - approve it in auto mode without the classifier. +- 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 and + refuses its caller's own surface. + +### 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. - 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 diff --git a/bin/cmux-claude-send.sh b/bin/cmux-claude-send.sh index 51747bf..0a5875a 100755 --- a/bin/cmux-claude-send.sh +++ b/bin/cmux-claude-send.sh @@ -16,7 +16,7 @@ SF="$1"; T="$2"; PF="${3:A}" || { 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 -cF '"type":"user"'; } +count() { grep -F -- "$MSG" "$T" | grep -cE '"type":"user"|"operation":"enqueue"'; } 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 diff --git a/bin/cmux-claude-spawn.sh b/bin/cmux-claude-spawn.sh index 8102c75..f99fd63 100755 --- a/bin/cmux-claude-spawn.sh +++ b/bin/cmux-claude-spawn.sh @@ -4,7 +4,7 @@ # <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. +# 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=() @@ -30,6 +30,7 @@ OUT=$(CMUX_QUIET=1 cmux new-workspace --cwd "$CWD" --name "$TITLE" --focus false 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 @@ -44,7 +45,7 @@ CMUX_QUIET=1 cmux paste --surface "$SF" --submit -- "$MSG" >/dev/null || { echo 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 -qF '"type":"user"' && { echo "$WS $SF $f"; exit 0; } + grep -F -- "$MSG" "$f" 2>/dev/null | grep -qE '"type":"user"|"operation":"enqueue"' && { echo "$WS $SF $f"; exit 0; } done sleep 2 done diff --git a/skills/spawn-claude-workspace/SKILL.md b/skills/spawn-claude-workspace/SKILL.md index 7550919..c6b6fa2 100644 --- a/skills/spawn-claude-workspace/SKILL.md +++ b/skills/spawn-claude-workspace/SKILL.md @@ -40,8 +40,9 @@ 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. - Never fall back to Droid if Claude fails. @@ -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: @@ -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" ``` @@ -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) @@ -107,7 +103,6 @@ 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 @@ -115,8 +110,6 @@ 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 diff --git a/tests/test_cmux_claude_launcher.py b/tests/test_cmux_claude_launcher.py index 105bfb2..88528d6 100644 --- a/tests/test_cmux_claude_launcher.py +++ b/tests/test_cmux_claude_launcher.py @@ -41,8 +41,12 @@ target = Path(os.environ["FAKE_CMUX_TRANSCRIPT"]) target.parent.mkdir(parents=True, exist_ok=True) with target.open("a") as fh: - if os.environ.get("FAKE_BUSY") != "1": + mode = os.environ.get("FAKE_MODE", "idle") + if mode == "idle": fh.write(json.dumps({"type": "user", "message": {"role": "user", "content": text}}, separators=(",", ":")) + "\n") + elif mode == "busy": + fh.write(json.dumps({"type": "queue-operation", "operation": "enqueue", "content": text}, separators=(",", ":")) + "\n") + fh.write(json.dumps({"type": "attachment", "attachment": {"type": "queued_command", "prompt": text}}, separators=(",", ":")) + "\n") fh.write(json.dumps({"type": "last-prompt", "lastPrompt": text}, separators=(",", ":")) + "\n") ''' @@ -188,11 +192,22 @@ def test_send_confirms_delivery_and_ignores_last_prompt_copies(tmp_path, env): assert [c for c in calls(env) if c[0] == "paste"] == [["paste", "--surface", SURFACE, "--submit", "--", msg]] +def test_send_counts_a_message_queued_to_a_busy_session(tmp_path, env): + pf = prompt(env) + transcript = tmp_path / "t.jsonl" + transcript.write_text(json.dumps({"type": "queue-operation", "operation": "remove", + "content": f"Read {pf} and follow it."}, separators=(",", ":")) + "\n") + env["FAKE_CMUX_TRANSCRIPT"] = str(transcript) + env["FAKE_MODE"] = "busy" + r = run(env, SEND, SURFACE, transcript, pf) + assert (r.returncode, r.stdout.strip()) == (0, "delivered"), r.stderr + + def test_send_does_not_count_a_last_prompt_record_as_delivery(tmp_path, env): transcript = tmp_path / "t.jsonl" transcript.write_text("") env["FAKE_CMUX_TRANSCRIPT"] = str(transcript) - env["FAKE_BUSY"] = "1" + env["FAKE_MODE"] = "lost" r = run(env, SEND, SURFACE, transcript, prompt(env)) assert (r.returncode, r.stdout.strip()) == (1, "not-delivered") assert len([c for c in calls(env) if c[0] == "paste"]) == 1 From 573bba7bb4fbb9b83f7cc0416eb28feee836f61f Mon Sep 17 00:00:00 2001 From: Mukul Sharma <elitecoder.mukul@gmail.com> Date: Sun, 18 Oct 2026 22:03:53 -0700 Subject: [PATCH 5/8] fix(bin): send refuses a target not in auto mode; changelog and skill fixes Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- CHANGELOG.md | 13 +++++++------ bin/cmux-claude-send.sh | 5 ++++- skills/spawn-claude-workspace/SKILL.md | 4 ++-- tests/test_cmux_claude_launcher.py | 9 +++++++++ 4 files changed, 22 insertions(+), 9 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 2caa238..706a534 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -20,13 +20,9 @@ The version is carried in `pyproject.toml` and `src/assistant/__init__.py` 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 and - refuses its caller's own surface. + 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. -### 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. - 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 @@ -73,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, diff --git a/bin/cmux-claude-send.sh b/bin/cmux-claude-send.sh index 0a5875a..408acf8 100755 --- a/bin/cmux-claude-send.sh +++ b/bin/cmux-claude-send.sh @@ -3,7 +3,8 @@ # 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. +# 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; } @@ -17,6 +18,8 @@ SF="$1"; T="$2"; PF="${3:A}" MSG="Read $PF and follow it." count() { grep -F -- "$MSG" "$T" | grep -cE '"type":"user"|"operation":"enqueue"'; } +[[ "$(CMUX_QUIET=1 cmux read-screen --surface "$SF" --lines 60 2>/dev/null)" == *"auto mode 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 diff --git a/skills/spawn-claude-workspace/SKILL.md b/skills/spawn-claude-workspace/SKILL.md index c6b6fa2..6f05cbc 100644 --- a/skills/spawn-claude-workspace/SKILL.md +++ b/skills/spawn-claude-workspace/SKILL.md @@ -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. @@ -44,7 +44,7 @@ bracket number. Leave naming and color to the daemon. - 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 diff --git a/tests/test_cmux_claude_launcher.py b/tests/test_cmux_claude_launcher.py index 88528d6..c65f430 100644 --- a/tests/test_cmux_claude_launcher.py +++ b/tests/test_cmux_claude_launcher.py @@ -231,3 +231,12 @@ def test_send_refuses_its_own_surface_and_short_refs(tmp_path, env): r = run(env, SEND, "surface:9", transcript, pf) assert r.returncode == 2 and "must be a UUID" in r.stderr assert calls(env) == [] + + +def test_send_refuses_a_target_that_is_not_in_auto_mode(tmp_path, env): + transcript = tmp_path / "t.jsonl" + transcript.write_text("") + env["FAKE_SCREEN"] = "\u23f5\u23f5 bypass permissions on (shift+tab to cycle)" + r = run(env, SEND, SURFACE, transcript, prompt(env)) + assert (r.returncode, r.stdout.strip()) == (3, "not-delivered") + assert not [c for c in calls(env) if c[0] == "paste"] From 92d4c0d44741732ccc3253adeb33820f6b0baf3b Mon Sep 17 00:00:00 2001 From: Mukul Sharma <elitecoder.mukul@gmail.com> Date: Sun, 18 Oct 2026 22:04:15 -0700 Subject: [PATCH 6/8] test: pin the auto-mode alias check, the staging-folder boundary, and a missing transcript Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- tests/test_cmux_claude_launcher.py | 24 ++++++++++++++++++++++++ 1 file changed, 24 insertions(+) diff --git a/tests/test_cmux_claude_launcher.py b/tests/test_cmux_claude_launcher.py index c65f430..e36f2bf 100644 --- a/tests/test_cmux_claude_launcher.py +++ b/tests/test_cmux_claude_launcher.py @@ -240,3 +240,27 @@ def test_send_refuses_a_target_that_is_not_in_auto_mode(tmp_path, env): r = run(env, SEND, SURFACE, transcript, prompt(env)) assert (r.returncode, r.stdout.strip()) == (3, "not-delivered") assert not [c for c in calls(env) if c[0] == "paste"] + + +def test_spawn_refuses_an_alias_that_is_not_auto_mode(tmp_path, env): + (Path(env["HOME"]) / ".zprofile").write_text("alias claude='claude --model x --permission-mode bypassPermissions'\n") + r = run(env, SPAWN, tmp_path, "t", prompt(env)) + assert r.returncode == 3 + assert "isn't auto mode" in r.stderr + assert calls(env) == [] + + +def test_spawn_refuses_a_sibling_of_the_staging_folder(tmp_path, env): + sibling = Path(env["HOME"]) / ".assistant" / "spawn-prompts-x" + sibling.mkdir() + pf = sibling / "p.md" + pf.write_text("x") + r = run(env, SPAWN, tmp_path, "t", pf) + assert r.returncode == 2 + assert calls(env) == [] + + +def test_send_refuses_a_missing_transcript(tmp_path, env): + r = run(env, SEND, SURFACE, tmp_path / "missing.jsonl", prompt(env)) + assert r.returncode == 2 and "no such transcript" in r.stderr + assert calls(env) == [] From 64c26d0375398060a66a9295afe9e064cabed07b Mon Sep 17 00:00:00 2001 From: Mukul Sharma <elitecoder.mukul@gmail.com> Date: Sun, 18 Oct 2026 22:09:34 -0700 Subject: [PATCH 7/8] fix(bin): treat a screen showing bypass mode as not ready Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- bin/cmux-claude-send.sh | 3 ++- bin/cmux-claude-spawn.sh | 2 +- tests/test_cmux_claude_launcher.py | 11 ++++++++++- 3 files changed, 13 insertions(+), 3 deletions(-) diff --git a/bin/cmux-claude-send.sh b/bin/cmux-claude-send.sh index 408acf8..9eb30df 100755 --- a/bin/cmux-claude-send.sh +++ b/bin/cmux-claude-send.sh @@ -18,7 +18,8 @@ SF="$1"; T="$2"; PF="${3:A}" MSG="Read $PF and follow it." count() { grep -F -- "$MSG" "$T" | grep -cE '"type":"user"|"operation":"enqueue"'; } -[[ "$(CMUX_QUIET=1 cmux read-screen --surface "$SF" --lines 60 2>/dev/null)" == *"auto mode on"* ]] \ +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; } diff --git a/bin/cmux-claude-spawn.sh b/bin/cmux-claude-spawn.sh index f99fd63..62c07ef 100755 --- a/bin/cmux-claude-spawn.sh +++ b/bin/cmux-claude-spawn.sh @@ -35,7 +35,7 @@ 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"* ]] && { READY=1; break; } + [[ "$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 diff --git a/tests/test_cmux_claude_launcher.py b/tests/test_cmux_claude_launcher.py index e36f2bf..49a0328 100644 --- a/tests/test_cmux_claude_launcher.py +++ b/tests/test_cmux_claude_launcher.py @@ -236,7 +236,7 @@ def test_send_refuses_its_own_surface_and_short_refs(tmp_path, env): def test_send_refuses_a_target_that_is_not_in_auto_mode(tmp_path, env): transcript = tmp_path / "t.jsonl" transcript.write_text("") - env["FAKE_SCREEN"] = "\u23f5\u23f5 bypass permissions on (shift+tab to cycle)" + env["FAKE_SCREEN"] = "said: auto mode on\n\u23f5\u23f5 bypass permissions on (shift+tab to cycle)" r = run(env, SEND, SURFACE, transcript, prompt(env)) assert (r.returncode, r.stdout.strip()) == (3, "not-delivered") assert not [c for c in calls(env) if c[0] == "paste"] @@ -264,3 +264,12 @@ def test_send_refuses_a_missing_transcript(tmp_path, env): r = run(env, SEND, SURFACE, tmp_path / "missing.jsonl", prompt(env)) assert r.returncode == 2 and "no such transcript" in r.stderr assert calls(env) == [] + + +def test_spawn_types_nothing_into_a_bypass_mode_session(tmp_path, env): + work = tmp_path / "w" + work.mkdir() + env["FAKE_SCREEN"] = "earlier output: auto mode on\n\u23f5\u23f5 bypass permissions on (shift+tab to cycle)" + r = run(env, SPAWN, work, "t", prompt(env)) + assert (r.returncode, r.stdout.split()[2]) == (1, "not-ready") + assert not [c for c in calls(env) if c[0] in ("paste", "send-key")] From 32072e23ac0101c7a6d09a5baaf199e23372c3ba Mon Sep 17 00:00:00 2001 From: Mukul Sharma <elitecoder.mukul@gmail.com> Date: Sun, 18 Oct 2026 23:44:40 -0700 Subject: [PATCH 8/8] test: mutation-prove the cmux launcher scripts for the coverage gate Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- tests/check_change_coverage.py | 2 +- tests/mutation_smoke.py | 42 ++++++++++++++++++++++++++++++++++ 2 files changed, 43 insertions(+), 1 deletion(-) diff --git a/tests/check_change_coverage.py b/tests/check_change_coverage.py index 91161dc..b9fb6e0 100644 --- a/tests/check_change_coverage.py +++ b/tests/check_change_coverage.py @@ -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]]: diff --git a/tests/mutation_smoke.py b/tests/mutation_smoke.py index 1712935..7c2b074 100644 --- a/tests/mutation_smoke.py +++ b/tests/mutation_smoke.py @@ -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", + ), )