diff --git a/bin/claudex.sh b/bin/claudex.sh index ee2367e..45706f4 100755 --- a/bin/claudex.sh +++ b/bin/claudex.sh @@ -1,10 +1,16 @@ #!/usr/bin/env bash -# claudex — open a fresh tmux session with `claude` in one window and -# `expediter` in another, then attach (or switch) to it. +# claudex — open a fresh tmux session with `expediter` and `claude` in two +# side-by-side panes, then attach (or switch) to it. # # Run from anywhere via the ~/.local/bin/claudex shim installed by install.sh. # The shim sets EXPEDITER_HOME but this script doesn't need it — it only needs # `claude` and `expediter` to be on PATH (which install.sh guarantees). +# +# Subcommands: +# claudex default: expediter (left) + claude (right) panes +# claudex uno newbie onboarding (daemon + QR + 4 numbered steps) +# claudex tour sonnet fresh tmux session with the Sonnet explainer +# claudex tour haiku fresh tmux session with the Haiku haiku-writer set -u @@ -23,20 +29,99 @@ if ! command -v expediter >/dev/null 2>&1; then exit 1 fi +# --- subcommand dispatch -------------------------------------------------- +# claudex (no args) → existing behavior (claude + expediter) +# claudex uno → newbie onboarding (daemon + QR + 4 steps) +# claudex tour sonnet → fresh tmux session with Sonnet explainer +# claudex tour haiku → fresh tmux session with Haiku haiku-writer +# anything else → usage + exit 1 + +case "${1:-}" in + "") + # Fall through to existing claudex behavior below. + ;; + uno) + # Hand off to expediter with the four newbie-onboarding steps. + # expediter --steps splits on `|` and prints each as a numbered line + # beneath the QR. The user manually opens two new tabs and runs + # `claudex tour sonnet` / `claudex tour haiku` in each. + exec expediter --steps "Scan the QR with your phone.|Open a new tab (Cmd+T) and run: claudex tour sonnet|Open another new tab (Cmd+T) and run: claudex tour haiku|After running all these steps, watch your phone!" + ;; + tour) + MODEL="${2:-}" + case "$MODEL" in + sonnet|haiku) ;; + "") + echo "claudex tour: missing model. Usage: claudex tour [sonnet|haiku]" >&2 + exit 1 + ;; + *) + echo "claudex tour: unknown model '$MODEL'. Usage: claudex tour [sonnet|haiku]" >&2 + exit 1 + ;; + esac + # Tour prompts live in $EXPEDITER_HOME/bin/uno_prompts/.txt so + # they can be edited without touching shell-quoting in this script. + # The shim at ~/.local/bin/claudex sets EXPEDITER_HOME before exec'ing + # this file. + if [ -z "${EXPEDITER_HOME:-}" ] || [ ! -d "$EXPEDITER_HOME" ]; then + echo "claudex tour: EXPEDITER_HOME is not set. Re-run install.sh." >&2 + exit 1 + fi + PROMPT_TXT="$EXPEDITER_HOME/bin/uno_prompts/$MODEL.txt" + if [ ! -f "$PROMPT_TXT" ]; then + echo "claudex tour: prompt file not found at $PROMPT_TXT" >&2 + exit 1 + fi + PROMPT="Read $PROMPT_TXT and respond per its contents." + # Shell-escape the prompt for safe consumption by tmux's /bin/sh -c. + # printf '%q' produces a re-quoted form that survives one more layer + # of shell parsing (tmux runs the new-session command via /bin/sh -c). + TOUR_SESSION="claudex-tour-$MODEL-$(date +%s)" + QUOTED_PROMPT=$(printf '%q' "$PROMPT") + tmux new-session -d -s "$TOUR_SESSION" -n claude -c "$PWD" "claude --model $MODEL $QUOTED_PROMPT" + if [ -n "${TMUX:-}" ]; then + tmux switch-client -t "$TOUR_SESSION" + else + tmux attach-session -t "$TOUR_SESSION" + fi + exit 0 + ;; + *) + echo "claudex: unknown subcommand '$1'" >&2 + echo "Usage: claudex [uno | tour sonnet | tour haiku]" >&2 + exit 1 + ;; +esac + +# --- default behavior (no subcommand) ------------------------------------- # Generate a unique session name. The seconds-since-epoch suffix keeps each # claudex invocation isolated, so running it twice gives you two independent # sessions instead of clobbering the first. SESSION="claudex-$(date +%s)" -# Create the session detached with the claude window first so it's window 1. -tmux new-session -d -s "$SESSION" -n claude -c "$PWD" 'claude' -tmux new-window -t "$SESSION:" -n expediter -c "$PWD" 'expediter' +# One window split into two side-by-side panes: expediter (QR) on the left, +# claude on the right. Side-by-side panes are friendlier to first-time users +# than two windows behind tmux navigation — both processes are visible from +# the moment they attach, no Ctrl-b n required to find the QR. +# tmux's -h flag splits "horizontally" by tmux convention, which actually +# produces panes that sit side-by-side (the new pane is to the right of the +# original). -v would stack them vertically. +# +# `; exec $SHELL` on the expediter pane keeps it open after expediter exits. +# When the daemon is already up, `expediter` prints the QR and returns +# immediately — without this, the pane would close and the user would see +# only the claude pane. +tmux new-session -d -s "$SESSION" -n main -c "$PWD" "expediter; exec \${SHELL:-bash}" +tmux split-window -t "$SESSION:main" -h -c "$PWD" 'claude' -# Select the claude window so the user lands on it (vs. the expediter logs). -tmux select-window -t "$SESSION:claude" +# After the split, pane 0 is expediter (left) and pane 1 is claude (right). +# Land the user on the claude pane so they can start typing immediately; the +# QR remains visible to their left. +tmux select-pane -t "$SESSION:main.1" # If we're already inside tmux, switch-client; otherwise attach. Either way -# the user ends up looking at the new session's claude window. +# the user ends up looking at the new session with both panes visible. if [ -n "${TMUX:-}" ]; then tmux switch-client -t "$SESSION" else diff --git a/bin/expediter.mjs b/bin/expediter.mjs index 69ad81e..b7f4dcf 100755 --- a/bin/expediter.mjs +++ b/bin/expediter.mjs @@ -17,9 +17,17 @@ const PRINT_URL = process.argv.includes('--print-url'); const SHOW_HELP = process.argv.includes('--help') || process.argv.includes('-h'); const TITLE_IDX = process.argv.indexOf('--title'); const TITLE_VALUE = TITLE_IDX >= 0 ? process.argv[TITLE_IDX + 1] : null; +// --steps "||..." — opt-in numbered-steps list appended below the QR. +// Used by `claudex uno` to print newbie-onboarding instructions. Plain +// `expediter` without --steps never prints steps. Steps are pipe-delimited; +// each step renders on its own line prefixed with ". " (1-indexed). +const STEPS_IDX = process.argv.indexOf('--steps'); +const STEPS_RAW = STEPS_IDX !== -1 ? process.argv[STEPS_IDX + 1] : undefined; if (SHOW_HELP) { - console.log('Usage: expediter [--print-url] [--title default|haiku] [--help]'); + console.log( + 'Usage: expediter [--print-url] [--title default|haiku] [--steps "||..."] [--help]' + ); console.log(''); console.log(' --print-url Also print the tethered URL as text (default: QR only).'); console.log(' Use this only if your phone cannot scan the QR — the URL'); @@ -29,6 +37,8 @@ if (SHOW_HELP) { console.log(' /rename), with a whimsical name as fallback. "haiku" uses'); console.log(' the LLM-generated caveman summary. Writes to'); console.log(' ~/.expediter/config.json.'); + console.log(' --steps Pipe-delimited list of numbered steps to print below the QR.'); + console.log(' Opt-in; used by `claudex uno` for newbie-onboarding.'); console.log(' --help, -h Show this message.'); process.exit(0); } @@ -179,6 +189,13 @@ async function printAccess() { console.log(' WARNING: the URL above contains the session token and will stay in'); console.log(' your terminal scrollback. Restart the daemon to invalidate it.'); } + if (STEPS_RAW) { + console.log(''); + const steps = STEPS_RAW.split('|'); + steps.forEach((step, i) => { + console.log(`${i + 1}. ${step}`); + }); + } } // --- main --- diff --git a/bin/uno_prompts/haiku.txt b/bin/uno_prompts/haiku.txt new file mode 100644 index 0000000..5e9ca7d --- /dev/null +++ b/bin/uno_prompts/haiku.txt @@ -0,0 +1 @@ +Write a haiku about a positive, collaborative human-AI future. diff --git a/bin/uno_prompts/sonnet.txt b/bin/uno_prompts/sonnet.txt new file mode 100644 index 0000000..c51011d --- /dev/null +++ b/bin/uno_prompts/sonnet.txt @@ -0,0 +1,32 @@ +You're greeting a new user who just ran `claudex uno` for the first time. They're new to both the expediter and tmux. Output plain terminal text with three numbered sections. + +Format: + +1. What is the expediter? + +A few short sentences, warm and conversational. The real value: it reduces the friction of getting to any agent session. When an agent needs you — a permission request, or an update — your phone alerts you, and tapping the ticket jumps you straight to that session in your Terminal. Keeps you actively in the loop with all your running agents at once, without hunting through tabs for which one needs you. Phone and Mac need to be on the same network. Do NOT frame it as "avoid walking back to your desk" or "watch agents from your phone" — frame it as "actively stay in the loop, get to the right session fast". + +2. What is tmux? + +Really ELI5 — explain it like to a curious 5-year-old. tmux is a tab manager for your terminal. Use a friendly analogy in one or two sentences. Then present two lists. + +First list — sessions/windows/panes. Each line is "Term: explanation". Format like: + +Sessions: separate workspaces, like different projects. +Windows: tabs within a session. +Panes: splits inside a window, for seeing things side-by-side. + +Second list — handy hotkeys. Each line is "command: what it does". Format like: + +Ctrl-b c: opens a new window. +Ctrl-b n: jumps to the next window. +Ctrl-b d: detaches from the session (keeps running in the background). +Ctrl-b &: closes the current window. + +3. How to use the expediter + +For any future session, as long as you run `expediter` and are interacting with Claude Code inside tmux, you'll be able to monitor those agents from your phone. You can also just type `claudex` to open both the expediter and Claude Code at once inside tmux. + +After section 3, a short closing line: If you have any questions, I recommend asking Claude first — Claude knows a lot. Or feel free to message the developer at hi@givemeanudge.com or @akashbert on X. + +No markdown formatting (no bold, italics, bullets, or hash headers). Just plain text with numbered section titles and the line-by-line list format shown above. Conversational tone. Keep each section brief. diff --git a/install.sh b/install.sh index f4aa90f..729f838 100755 --- a/install.sh +++ b/install.sh @@ -523,6 +523,7 @@ esac # --- done ------------------------------------------------------------------ printf '\n%s✦%s Expediter is ready!\n\n' "$GREEN" "$RESET" -printf 'Few ways to use expediter:\n\n' -printf 'expediter start the daemon and print the QR for linking your phone\n' -printf 'claudex open tmux with claude + expediter side-by-side\n' +printf '%sFew ways to use expediter:%s\n\n' "$BOLD" "$RESET" +printf ' %sexpediter%s start the daemon and print the QR for linking your phone\n' "$BOLD" "$RESET" +printf ' %sclaudex%s open tmux with claude + expediter side-by-side\n' "$BOLD" "$RESET" +printf ' %sclaudex uno%s new to tmux or Claude Code? start here\n\n' "$BOLD" "$RESET" diff --git a/src/app.html b/src/app.html index 115b3fb..3db7e57 100644 --- a/src/app.html +++ b/src/app.html @@ -8,6 +8,10 @@ + + + + - - - - {@render children()} diff --git a/src/routes/+page.svelte b/src/routes/+page.svelte index 2159e49..63618e3 100644 --- a/src/routes/+page.svelte +++ b/src/routes/+page.svelte @@ -20,7 +20,19 @@ let tickets = $state([]); let connected = $state(false); + // Sticky: flips true on the first successful onopen and never resets. Without + // it, isDisconnected would flash on every page load and every wake-from- + // background, because `connected` starts false and openStream reopens the + // EventSource on visibilitychange / pageshow before onopen has had a chance + // to fire again. + let everConnected = $state(false); let focusing = $state(null); + // In-memory only; lost on page reload. Marks the most recently tapped ticket + // so the user has a visual "this is the session I jumped into" cue. Goes + // stale if the user switches sessions inside the terminal directly — that's + // accepted for v0; proper "currently focused pane" tracking would need + // AppleScript polling on the server. + let lastTapped = $state(null); let mockMode = false; // Reactive: cleared when /api/ping returns 403 (token died, daemon restarted). // Initial value is read from sessionStorage on the browser; null on the server. @@ -84,6 +96,7 @@ ); eventSource.onopen = () => { connected = true; + everConnected = true; }; eventSource.onerror = () => { connected = false; @@ -155,6 +168,7 @@ return; } focusing = ticket.session_id; + lastTapped = ticket.session_id; try { await fetch('/api/focus', { method: 'POST', @@ -169,7 +183,7 @@ } finally { setTimeout(() => { if (focusing === ticket.session_id) focusing = null; - }, 300); + }, 80); } } @@ -191,6 +205,27 @@ return 'STOP'; } + // Idle Stop/Notification tickets desaturate in step jumps as they age, so a + // glanceable signal of "how stale is this thing" is built into the dock. + // PermissionRequest never fades (load-bearing red attention); working state + // owns its own pastel-green visual and skips fading too. + function staleClass(ticket: Ticket, now: number): string { + if (ticket.working) return ''; + if (ticket.event_type === 'PermissionRequest') return ''; + const ageMin = (now - ticket.created_at) / 60_000; + if (ageMin >= 16) return 'stale-4'; + if (ageMin >= 8) return 'stale-3'; + if (ageMin >= 4) return 'stale-2'; + if (ageMin >= 2) return 'stale-1'; + return ''; + } + + // True only after we successfully connected at least once AND have since lost + // the SSE — i.e. the daemon went away or the network dropped. The + // everConnected guard prevents a false "disconnected" flash on initial load + // and on background-wake reconnects. + const isDisconnected = $derived(!!clientToken && !connected && everConnected); + function formatAge(createdAt: number, now: number): string { const seconds = Math.max(0, Math.floor((now - createdAt) / 1000)); if (seconds < 5) return 'now'; @@ -263,7 +298,7 @@
Expediter - (v0.1) + (v0.7)
@@ -273,18 +308,21 @@
Scan the QR code in your terminal to connect
- {:else if tickets.length === 0} + {:else if tickets.length === 0 && !isDisconnected}
You have zero tickets!
+ {:else if tickets.length === 0} +
{:else} -
    +
      {#each tickets as ticket (ticket.session_id)}
    • {/if} + + {#if isDisconnected} +
      + you are disconnected +
      + {/if} +