
Cc Use
- 2.3k installs
- Updated August 4, 2026
- zc277584121/cc-use
cc-use is an agent skill that apply cc-use agent skill workflows from documented skill.md guidance.
About
cc-use is an agent skill from zc277584121/cc-use that apply cc-use agent skill workflows from documented skill.md guidance. # cc-use You are the outer supervisor. Use this skill's `scripts/cc-use` helper as an implementation detail to start and supervise an inner CC session in tmux. Here, **CC** means a coding command-line agent. Depending on the host and local configuration, that can mean Claude Code, Codex CLI, or another compatible coding CLI. ## When To Use This Developers invoke cc-use during build/integrations work for ai & agent building tasks. The skill documents triggers, prerequisites, and step-by-step workflows grounded in SKILL.md. Compatible with Claude Code, Cursor, and Codex agent runtimes that load marketplace skills. Review the Security Audits panel on this listing before installing in production environments.
- You are the outer supervisor. Use this skill's `scripts/cc-use` helper as an
- implementation detail to start and supervise an inner CC session in tmux.
- Here, **CC** means a coding command-line agent. Depending on the host and local
- configuration, that can mean Claude Code, Codex CLI, or another compatible
- Use cc-use for long-term tasks where the outer session should keep the global
Cc Use by the numbers
- 2,281 all-time installs (skills.sh)
- +215 installs in the week ending Aug 4, 2026 (Skillselion tracking)
- Ranked #455 of 16,546 AI & Agent Building skills by installs in the Skillselion catalog
- Security screen: HIGH risk (skills.sh audit)
- Data as of Aug 4, 2026 (Skillselion catalog sync)
cc-use capabilities & compatibility
- Capabilities
- you are the outer supervisor. use this skill's ` · implementation detail to start and supervise an · here, **cc** means a coding command line agent. · configuration, that can mean claude code, codex · use cc use for long term tasks where the outer s
- Use cases
- orchestration
What cc-use says it does
You are the outer supervisor. Use this skill's `scripts/cc-use` helper as an
implementation detail to start and supervise an inner CC session in tmux.
Here, **CC** means a coding command-line agent. Depending on the host and local
npx skills add https://github.com/zc277584121/cc-use --skill cc-useAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 2.3k |
|---|---|
| Security audit | 2 / 3 scanners passed |
| Last updated | August 4, 2026 |
| Repository | zc277584121/cc-use ↗ |
What it does
Apply cc-use agent skill workflows from documented SKILL.md guidance.
Who is it for?
Developers working on ai & agent building during build tasks.
Skip if: Tasks outside AI & Agent Building scope described in SKILL.md.
When should I use this skill?
Apply cc-use agent skill workflows from documented SKILL.md guidance.
What you get
Completed ai & agent building workflow aligned with SKILL.md steps.
- Schedule registration in schedules.json
- Heartbeat health checks
- Cron execution logs
Files
cc-use
You are the outer supervisor. Use this skill's scripts/cc-use helper as an implementation detail to start and supervise an inner CC session in tmux.
Here, CC means a coding command-line agent. Depending on the host and local configuration, that can mean Claude Code, Codex CLI, or another compatible coding CLI.
When To Use This Skill
Use cc-use for long-term tasks where the outer session should keep the global state clean and deliberate while an inner interactive session handles focused execution. The point is not just parallelism; it is supervision. The outer session keeps the plan, product intent, risks, compatibility concerns, and acceptance criteria in view while the inner session does bounded work.
This helps when a task is long enough that a single conversation may lose local details, pollute context with logs, or drift away from the top-level objective. The inner session can do implementation, command execution, exploration, and interactive TUI work, while the outer session preserves a compact supervisory context and performs final judgment.
Do not pass the whole long-term task to the inner session as one giant prompt. Break it into small, concrete requests, then inspect, steer, and refine like a human supervisor would:
1. Ask for one focused investigation, implementation step, test case, or verification action. 2. Wait for an inspect observation. 3. Read the snapshot semantically. 4. Decide the next short request, wait, intervene, ask the user, or verify. 5. Repeat until the outer session has enough evidence to accept or reject the work.
Good scenarios:
- End-to-end verification: The outer session acts like a tester. It asks the
inner session to run realistic workflows, edge cases, and command sequences, then evaluates the reported result and runs any final checks itself.
- Task decomposition and implementation: For a large project with a plan or
design document, the outer session decomposes the plan into small implementation requests and feeds them to the inner session one at a time.
- Adversarial testing: The outer session designs high-level adversarial
cases based on project history, product positioning, architecture, compatibility requirements, and likely regressions. The inner session executes those cases and reports what happens.
- Interactive product testing: For TUI products such as Claude Code, Codex
CLI, or plugin/skill/MCP workflows, normal one-shot command tests may be insufficient. The inner session can install the in-development skill, MCP server, or plugin inside an interactive terminal environment, then receive follow-up input from the outer session to validate that the integration works in the real TUI.
User Experience
The expected user flow is natural language in the outer TUI:
Use cc-use to implement this long task: ...
You should then:
1. Start or reuse an inner CC session for the same agent family as the outer session. 2. Break the user's request into short, focused inner requests. 3. Send each inner request exactly as written, without wrapper text. 4. Monitor by screen stability, not by parsing agent-specific UI rules. 5. When the screen stays quiet long enough, inspect the saved screen snapshot semantically and decide whether to wait, steer, or verify. 6. Run final acceptance checks yourself from the outer session.
Commands
Run commands from the target project root unless you pass an explicit --project DIR. Use --agent codex from Codex and --agent claude from Claude Code. Do not cross-delegate between agent families.
--project controls the tmux working directory, the derived session name, and the .cc-use/state/ location. If omitted, it defaults to the current directory. Passing --project "$PWD" is recommended when the outer session may change directories.
delegate
Start or reuse the inner session, send one short request exactly as provided, and wait until one observation is emitted:
<skill_dir>/scripts/cc-use delegate "TASK_TEXT" --project "$PWD" --agent codexImportant options:
--project DIR: target project root. Also determines state location.--agent codex|claude: inner agent family. Match the outer agent family.--session NAME: override the derived tmux session name.--profile NAME: Codex only; use only if the user explicitly requests an
inner Codex profile and only when creating the session.
--initial-quiet-seconds N: how long a stable screen must stay quiet before
an observation is emitted. Default is 30.
--poll-interval N: seconds between screen captures while waiting. Default
is 2.
--replace: kill and recreate an existing session. Use only for recovery or
an explicit fresh-start decision.
TASK_TEXT is passed through unchanged. Keep task decomposition in the outer session. Do not ask the helper to add role instructions, policy text, or task wrappers.
For Codex, omit --profile by default. Existing tmux/TUI sessions are reused and do not need the profile on later requests.
Codex permissions: fully bypassed
cc-use launches the inner Codex with --dangerously-bypass-approvals-and-sandbox — the Codex equivalent of Claude Code's --dangerously-skip-permissions. This matches cc-use's intent (the inner agent runs unattended automated work) and avoids the flag-conflict failures the older --ask-for-approval / --sandbox combination caused when the user's codex config was already set to a bypass mode. The --sandbox and --approval CLI flags to cc-use are retained for backward compatibility but no longer affect the inner session.
Expected behavior:
- If the session does not exist, the helper creates a persistent tmux session
named like ccu-<project-name>.
- If the session already exists, the helper reuses it.
- The command normally returns one JSON event after the screen becomes stable.
- If the screen keeps changing, the command may block until the screen becomes
quiet.
monitor
Observe an existing derived session and wait for one observation:
<skill_dir>/scripts/cc-use monitor --project "$PWD" --agent codexUse this after a previous observation suggests waiting, after you have waited based on your own semantic judgment, or after the user asks for status.
Important options:
--project DIR,--agent codex|claude,--session NAME: identify the target
session.
--initial-quiet-seconds N,--poll-interval N: same meaning asdelegate.
Expected behavior:
- If the screen changes, the helper resets the quiet timer and continues
waiting.
- If the screen stays unchanged long enough, the helper saves a snapshot and
emits an inspect observation.
- If the tmux session is gone, the helper emits
session_unavailable.
project-status
Check the derived project/session status without sending input:
<skill_dir>/scripts/cc-use project-status --project "$PWD" --agent codexUse --json when you need machine-readable output:
<skill_dir>/scripts/cc-use project-status --project "$PWD" --agent codex --jsonExpected text output includes:
project: resolved project directory.session: derived or explicit session name.agent: selected agent family.session_available: whether tmux currently has the session.observations: number of saved observations for this session.silence_seconds: seconds since the last detected screen change.seconds_until_next_check: current watch schedule hint from state.
scrollback
If the saved screen snapshot does not include enough context, inspect recent tmux scrollback on demand. This is a temporary read to stdout, not a persistent transcript:
<skill_dir>/scripts/cc-use scrollback --project "$PWD" --agent codex --lines 2000For paged inspection, use tmux line ranges. Negative numbers refer to scrollback history, 0 is the first visible line, and - means the end of the visible pane:
<skill_dir>/scripts/cc-use scrollback --project "$PWD" --agent codex --start -4000 --end -2001
<skill_dir>/scripts/cc-use scrollback --project "$PWD" --agent codex --start -2000 --end -Options:
--lines N: capture from-Nthrough the end of the visible pane. Default is
2000.
--start LINE: explicit tmux capture start line.--end LINE: explicit tmux capture end line. Default is-.
Line semantics come from tmux:
- Negative numbers are lines in scrollback history.
0is the first visible line.-means the end of the visible pane.
Use scrollback only after an inspect observation when the saved snapshot is too narrow. Do not use it as a continuous progress feed while the screen is actively changing.
Low-level commands
These exist for diagnostics and recovery:
<skill_dir>/scripts/cc-use list
<skill_dir>/scripts/cc-use snapshot <session>
<skill_dir>/scripts/cc-use kill <session>Use kill only when the user explicitly asks to close the inner session, or when the session is broken and a fresh session is required.
Keep the inner session running by default. A long-running project may span multiple outer conversations or calendar days, and the existing tmux/TUI session preserves useful continuity for later work.
Only stop the inner session if the user explicitly asks you to close it, or if the session is broken and you have decided a fresh session is required.
Scheduled tasks
cc-use can also register host-local recurring tasks. Use this only when the user explicitly asks for a persistent local cron or heartbeat workflow.
heartbeatperiodically checks a persistent inner tmux session.cronruns a scheduled prompt or executable script.- Schedules are agent-neutral; the default
--agent autofollows the outer
session's agent family when the schedule is created.
- Scheduled runs default to
danger-full-accessandneverapproval so local
recurring tasks do not stall on routine filesystem or network access.
For the full workflow, command examples, migration notes, and troubleshooting, read references/schedules.md.
TUI / tmux recording to GIF
cc-use can also help record an inner TUI session (Claude Code, Codex CLI, or any ratatui-like app running in tmux) to a small animated GIF — useful for README headers, blog posts, and docs.
The recording pipeline is asciinema → agg → gifsicle, plus optional post-processing (cropping the tmux status bar and adding window chrome) with Pillow.
Use this only when the user explicitly asks to record a TUI session or produce a GIF demo. Key constraints:
- Probe dependencies and ask the user before installing anything.
- Always keep the intermediate
.castfile — it is the canonical source and
the user may want to re-render later.
- Produce a small slate of GIF variants (different font sizes) rather than one
fixed spec; let the user choose.
- Bottom-cropping the tmux status bar / shell footer is a per-recording
judgment call. Inspect a busy frame; do not freeze the heuristic into code.
For the full pipeline, principles, and pseudocode for the cropping decision, read references/tui-recording.md.
Monitoring Model
delegate and monitor use adaptive observation:
- If the tmux screen changes, the outer agent does not read details and lets the
inner agent keep working.
- If the screen stays unchanged past the current quiet threshold, cc-use captures
the screen once and emits a neutral inspect observation.
- The helper does not classify stable screens as wait, intervene, or verify.
Always read screen_path and make the semantic decision in the outer session.
- If the snapshot is too narrow, use
scrollback --lines Nor
scrollback --start LINE --end LINE for temporary context. Do not create persistent transcript logs by default.
Typical observation:
{
"event": "observation",
"session": "ccu-my-project",
"observed_at": 1778223935,
"silence_seconds": 20,
"screen_digest": "sha256...",
"screen_path": "/path/to/project/.cc-use/state/ccu-my-project/screens/ccu-my-project-0001.txt",
"decision": {
"action": "inspect",
"next_check_after_seconds": 0,
"reason": "The screen is stable; inspect screen_path semantically before deciding whether to wait, steer, or verify.",
"confidence": 1.0
}
}inspect means only that the screen is stable enough to review. It does not mean the task is complete, blocked, failed, or still running. The outer session must read screen_path and decide.
session_unavailable means tmux no longer has the expected session. Decide whether to restart, report failure, or ask the user.
Outer Decision Rules
After an inspect observation:
- If the snapshot shows final output or a prompt after a completed response, run
outer acceptance checks.
- If it shows tests, builds, downloads, or server commands that may still be
running quietly, wait a reasonable interval and call monitor again.
- If it shows a permission prompt, password prompt, yes/no question, or blocked
input, intervene or ask the user.
- If it shows an error, send one short corrective request or report the blocker.
- If it is too narrow to understand, call
scrollbackonce with enough lines or
an explicit range, then decide.
If the screen is actively changing, delegate or monitor may not return for a while because the quiet timer keeps resetting. This is expected. The helper is designed to avoid consuming active output.
Outer acceptance checks must be run outside the inner session. Check the actual files, run relevant tests or commands, inspect UI if applicable, and confirm the work matches the user's request.
Discipline
- Do not expose tmux/session/state details unless the user asks.
- Do not pass or synthesize environment variables for the inner session.
- Do not kill the inner session at routine task completion; leave it available
for future delegated work.
- Do not rely on the inner screen as proof of success; verify externally.
- Do not use
scrollbackas a persistent transcript. It is a temporary tmux
history read.
- Let the inner agent do implementation work.
- The outer agent owns acceptance testing and final judgment.
Scheduled Tasks
cc-use can register local recurring tasks for long-running project supervision. The schedule feature has two modes:
heartbeat: periodically checks that a persistent inner tmux session is
healthy and responsive.
cron: runs a scheduled prompt or executable script at a calendar time.
Use schedules only when the user explicitly asks to keep a recurring local task running. Schedules are host-local state. They are not portable by themselves and should be migrated deliberately per machine.
Storage And Registration
cc-use stores schedule records under:
~/.cc-use/schedules.json
~/.cc-use/logs/The helper also registers the task with the host scheduler:
- macOS: launchd plist files under
~/Library/LaunchAgents/. - Linux: user crontab entries marked with
#cc-use:<id>.
The registered command calls back into the installed helper:
<skill_dir>/scripts/cc-use schedule-run <id>This means the schedule behavior follows the installed cc-use skill code. If the helper path changes during migration, update the launchd or crontab entry.
Agent Selection
Schedules are agent-neutral. By default, --agent auto uses the same agent family as the outer session that creates the schedule. You may also pass an explicit supported agent family when the user asks for one.
For scheduled tasks, the default execution policy is intentionally broad:
sandbox = danger-full-access
approval = neverThis avoids recurring tasks getting stuck on routine filesystem or network access. Use narrower settings only when the user asks for that tradeoff.
For Codex schedules, --profile NAME is supported and stored in the schedule record. Scheduled Codex exec runs include --skip-git-repo-check so tasks can run from ordinary project or script directories that are not git repositories.
Heartbeat
Create or reuse a heartbeat schedule:
<skill_dir>/scripts/cc-use schedule-add heartbeat NAME \
--project "$PWD" \
--interval-minutes 15 \
--agent auto \
--session ccu-my-projectImportant options:
NAME: human-readable schedule name.--project DIR: project root for state and tmux session derivation.--interval-minutes N: run interval. Default is15.--agent auto: use the same agent family as the creator.--profile NAME: Codex profile, when the user explicitly asks for one.--session NAME: explicit tmux session name.
On first creation, cc-use creates this project-local file if missing:
<project>/.cc-use/heartbeat.mdThe heartbeat runner delegates the heartbeat text to the persistent inner tmux session and records the latest state under:
<project>/.cc-use/heartbeat-state.jsonThe heartbeat should be small and cheap. It should ask the inner session to report whether it is healthy, blocked, or needs attention.
Cron
Create a cron-style scheduled task:
<skill_dir>/scripts/cc-use schedule-add cron NAME \
--project "$PWD" \
--cron-expr "30 22 * * *" \
--prompt "Read ./daily-report.md and follow the instructions." \
--agent autoFor a script-backed task, pass the executable script path as the prompt:
<skill_dir>/scripts/cc-use schedule-add cron NAME \
--project "$PWD" \
--cron-expr "30 22 * * *" \
--prompt "$PWD/.cc-use/daily-report.sh" \
--agent autoIf the prompt is an executable file path, schedule-run executes the script directly. Otherwise, it sends the prompt to the selected non-interactive agent runner.
Use --search when the scheduled Codex task should enable web search:
<skill_dir>/scripts/cc-use schedule-add cron NAME \
--project "$PWD" \
--cron-expr "30 9 * * 5" \
--prompt "Read ./weekly-report-prompt.md and follow the instructions." \
--agent codex \
--profile zilliz \
--searchInspecting And Operating Schedules
List schedules:
<skill_dir>/scripts/cc-use schedule-listShow all schedules or one schedule with its latest log tail:
<skill_dir>/scripts/cc-use schedule-status
<skill_dir>/scripts/cc-use schedule-status <id>Manually trigger a schedule:
<skill_dir>/scripts/cc-use schedule-run <id>Remove a schedule and unregister it from the host scheduler:
<skill_dir>/scripts/cc-use schedule-remove <id>Environment
Scheduled tasks run outside the user's interactive terminal. The runner loads simple exported variables from common shell startup files without executing arbitrary startup commands. This keeps scheduled runs from triggering unrelated interactive shell side effects.
The temporary environment file is created with owner-only permissions and is removed after loading. Do not hardcode user-specific secret variable names in the skill. If a task needs a secret, the user should describe the requirement when creating that local schedule.
Migration
When migrating schedules from another host or another helper path:
1. Back up ~/.cc-use/schedules.json. 2. Back up the current launchd plist files or crontab. 3. Copy or transform schedule records into the new schedules.json. 4. Ensure each record has the intended agent, profile, sandbox, and approval fields. 5. Rewrite launchd or crontab entries so they call the current <skill_dir>/scripts/cc-use schedule-run <id>. 6. Run schedule-list. 7. Manually run representative schedules with schedule-run <id>.
For migrating existing local tasks to Codex with a profile, set:
agent = codex
profile = zilliz
sandbox = danger-full-access
approval = neverThen test at least one heartbeat and one cron task manually before relying on the host scheduler.
Troubleshooting
Use the schedule log first:
tail -120 ~/.cc-use/logs/cron-<id>.log
tail -120 ~/.cc-use/logs/heartbeat-<id>.logCommon failures:
- The helper path in launchd or crontab points to an old installation.
- The scheduled shell environment does not include a required secret.
- The project path no longer exists.
- The prompt path is not executable when a script-backed cron task is expected.
- The selected profile does not exist on that host.
After fixing the issue, rerun:
<skill_dir>/scripts/cc-use schedule-run <id>TUI / tmux session recording
A workflow for turning a live TUI session (Claude Code, Codex CLI, or any ratatui-like full-screen app running in tmux) into a small, clean animated GIF suitable for a README header, blog post, or docs page.
The point of this reference is not to lock in one canonical GIF spec. It is to describe the pipeline, the principles behind each step, and the judgment calls the outer agent should make in each session.
When to use
Use this workflow when the user asks to:
- Record an inner cc-use'd CC TUI doing a demo.
- Capture a tmux pane running a TUI tool (vim, htop, k9s, lazygit, mfs, etc.)
for documentation.
- Produce a GIF for a GitHub README, blog post, or release announcement.
If the goal is real-time screen video (with cursor, window chrome, OS desktop), use a screen recorder instead — this pipeline records terminal state only, not pixels.
Toolchain
| Tool | Role | Where it comes from |
|---|---|---|
asciinema | Record terminal as a timestamped JSON cast | apt / brew / pip |
agg | Render cast to animated GIF | cargo install --locked --git https://github.com/asciinema/agg or brew |
gifsicle | Optimize / quantize GIF | apt / brew |
Pillow (optional) | Crop cruft, add window chrome | uv add pillow in throwaway project |
fonts-noto-color-emoji (optional) | Render emojis in agg output | apt / brew |
Dependency check
Before doing any work, probe the host:
which asciinema agg gifsicle
fc-list | grep -i emoji
python3 -c "from PIL import Image" 2>&1Report what is missing. Ask the user before installing anything, listing the install commands and what each tool is for. Do not install silently.
Reasonable installers:
- Linux (apt):
sudo apt-get install -y asciinema gifsicle fonts-noto-color-emoji - macOS:
brew install asciinema agg gifsicle - Linux
agg:cargo install --locked --git https://github.com/asciinema/agg
(Rust toolchain required; takes a couple of minutes the first time.)
- Pillow: do this inside a scratch
uv init --bareproject rather than polluting
the system Python.
Recording method
The recording target is a tmux session — either one already started by cc-use (ccu-<project>) or a fresh tmux session created for the demo. The general pattern is:
1. Ensure the inner tmux session exists and is at a stable starting state. 2. Background-start asciinema rec wrapped around tmux attach -r -t <session> with a hard timeout cap. Read-only attach prevents asciinema from accidentally delivering keystrokes into the inner session. 3. In the foreground, drive the demo via cc-use delegate ... or tmux send-keys. The inner session renders output; asciinema captures it. 4. Wait for the asciinema timeout to detach and finalize the cast.
A worked example (writing to a project-local scratch dir):
OUT=/path/to/project/tmp/recording-$(date +%s)
mkdir -p "$OUT"
( asciinema rec -y --overwrite \
-c "timeout 50 tmux attach -r -t ccu-<project>" \
--idle-time-limit 2 \
"$OUT/demo.cast" > "$OUT/asciinema.log" 2>&1 ) &
ASCII_PID=$!
sleep 3 # let asciinema attach so initial state is captured
# Drive the demo (blocks until inner agent goes quiet)
<skill_dir>/scripts/cc-use delegate "<demo prompt>" \
--project "$HOME/project" --agent claude \
--initial-quiet-seconds 12
wait $ASCII_PID 2>/dev/nullImportant details:
-r(read-only attach) is critical. Without it, any stray input typed in the
outer shell can interfere with the inner agent.
timeout 50is the maximum recording window. Pick it to comfortably cover
the demo duration + LLM latency budget + a small margin.
--idle-time-limit 2lets the cast preserve long pauses as 2-second pauses,
which keeps the cast file small.
- When asciinema is run from the outer agent's
Bashtool (no real TTY), the
PTY defaults to 80×24. To record at a specific size, ask the user to run the pipeline from their interactive terminal at the desired window size, or use tmux set-option -t <session> window-size manual; tmux resize-window -t <session> -x W -y H before starting the recording.
Always keep the .cast file
A .cast is small (kilobytes), is the canonical intermediate, and contains every keystroke and ANSI sequence with timestamps. Keep it indefinitely.
The agent (now or in a later session) can re-derive any GIF from the cast:
- Re-render with a different theme, font, or speed without re-recording.
- Edit cast events directly (cut idle segments, speed up a region, insert a
pause) — it is plain JSON-lines.
- Re-quantize idle compression at render time without touching the source.
Never auto-delete cast files as part of cleanup. Treat them the way you treat source code, not the way you treat build artifacts.
Rendering principles (agg)
For README-grade GIFs, aim for the pixel-perfect look rather than the smooth/realistic look. The reason is GIF's palette limit: anti-aliased text introduces dozens of subtle edge colors that GIF quantization mangles into visible "fuzz". Pure-color rendering avoids the problem entirely.
Principles:
1. Disable font anti-aliasing: --font-antialiasing off
- Each glyph collapses to a binary foreground / background mask.
- Palette shrinks dramatically — often to fewer than 32 distinct colors.
- GIF quantization becomes effectively lossless.
2. Increase font size: --font-size 22 (or 28 for high-density / slide use)
- Larger glyphs make the aliased stair-steps disappear visually.
- Larger glyphs also help readability when the GIF is embedded at thumbnail
scale in a README. 3. Pick a small-palette theme: monokai, dracula, solarized-dark, nord. Avoid themes with subtle gradients. 4. Speed up + clamp idle: --speed 1.5 --idle-time-limit 1.5 --fps-cap 30.
A baseline command:
agg --theme monokai \
--font-size 22 \
--font-antialiasing off \
--speed 1.5 \
--idle-time-limit 1.5 \
--fps-cap 30 \
"$OUT/demo.cast" "$OUT/demo.raw.gif"Optimization (gifsicle)
With anti-aliasing off, gifsicle is usually lossless and still effective because the source palette is already tiny:
gifsicle -O3 --colors 32 "$OUT/demo.raw.gif" -o "$OUT/demo.opt.gif"Do NOT reach for --lossy=N by default. Lossy quantization is designed to smooth over photographic gradients; on pure-color terminal output it tends to introduce noise rather than reduce size. Try lossy only if the file is too large for the target platform after lossless -O3 --colors.
Produce a slate of options, not one canonical GIF
Do not lock the user into one resolution / quality combination. Render a small panel of variants and let them pick:
| Variant | Font | Typical use | Typical size for 30s |
|---|---|---|---|
| compact | 16 | inline screenshot in dense docs | 70–90 KB |
| standard | 22 | README header on a small project | 90–120 KB |
| hi-dpi | 28 | screencast for slides or blog | 130–180 KB |
Produce all three, report the file paths and sizes, and let the user pick. If the user has stated a target (e.g., "this is for a README header"), bias the recommendation but still render the other variants so they can compare.
Do not render more than 3 variants by default. The user only ever picks one.
Cropping bottom cruft
When recording a TUI in tmux, the bottom of every frame typically contains content the user does NOT want in the final GIF:
- Tmux status bar (full-width row with session name, time).
- Shell prompt info that leaked through (e.g.,
user@host:). - TUI footer hints ("bypass permissions on (shift+tab to cycle)" etc.).
Do not hard-code a fixed crop ratio or a brittle pixel-pattern detector. The right cut depends on the TUI and the theme. Apply the principles below; the outer agent has eyes — use them.
What to look at
Pick a busy mid-recording frame, not the very last frame. The very last frame of a tmux attach recording is often a cleared screen because the alternate screen buffer was restored when tmux client detached. Look at a frame about 50%–80% through the recording, where the TUI is still rendered.
Convenient way to grab one for inspection:
# Extract frame 20 of the GIF as PNG
python3 -c "from PIL import Image; \
img = Image.open('demo.raw.gif'); img.seek(20); \
img.convert('RGB').save('frame.png')"Then read it.
Decision principles
For each candidate cut, look at the frame and decide based on these rules:
1. Tmux status bar (if present): scan rows from the bottom. A row where almost every pixel is the same saturated color is the status bar. Everything from that row down is recording artifact and must go.
2. Shell / agent footer (just above the status bar): inspect a few rows up from the status bar. If they show shell prompt info or hint text the user did not ask to display, cut them too.
3. User-meaningful bottom edge: this is the row to keep as the last visible row in the output. Identify by what the TUI looks like:
- Claude Code / Codex CLI: there is a visible input box with a top and
bottom border (a long horizontal rule of ─ or ═). The bottom border is the natural last row. Keep through it (+ a few pixels of padding).
- Plain REPL prompts (
>,❯,$): there is no border. The prompt row
itself is the last row. Keep through it.
- Full-screen TUIs without a distinct input area (vim, top, htop, k9s):
there is no footer line to cut. The status bar is the only thing to remove.
- Custom TUIs without recognizable structure: pick a row by content. If
unsure, err on the side of keeping too much rather than cutting into real content.
4. Sanity check after cropping: render the cropped result, look at a busy frame, confirm that real content is intact. If the cut went into meaningful pixels, raise the crop boundary by one row-height and retry.
Pseudocode
frame = pick_busy_frame(gif) # not the last frame
status_top = scan_from_bottom(frame, predicate = uniform_saturated_row)
if status_top is None:
# no tmux status bar; the TUI fills to the screen edge
cut_y = frame.height
else:
# there is a status bar; check the rows above it for cruft
cruft_top = status_top
for row in rows_above(status_top, up_to = 5):
if looks_like_shell_or_hint_text(row):
cruft_top = row.y
# now find the meaningful bottom edge above cruft_top
if tui_has_visible_border():
cut_y = bottom_border_row + few_pixels_padding
elif tui_has_prompt_only():
cut_y = prompt_row + few_pixels_padding
else:
cut_y = cruft_top # safe default
return cut_yThis is pseudocode on purpose. Implement it as small judgment calls each time the agent processes a recording, not as a frozen script. Themes change, TUIs change, font sizes change — a frozen script ages badly.
Hard fallback
If the agent really cannot decide (e.g., batch processing with no chance to inspect), the safest no-knowledge cut is:
- Remove only the bottom
gif_height / cast_rowspixels — exactly one
cast-text-row. This kills the tmux status bar (if any) and nothing else.
This preserves more than wanted but never destroys real content.
Adding window chrome
A fake macOS-style window frame is purely cosmetic and can be a fixed script — there is no judgment call here.
Composite onto each cropped frame:
- A solid horizontal title bar at the top (~36 px tall, dark gray for a dark
theme; light gray for a light theme).
- Three traffic-light circles on the left of the title bar (red, yellow,
green).
- A 1px separator line between title bar and content.
Pillow snippet:
TITLE_H = 36
DOT_R = 7
DOT_GAP = 10
DOT_LEFT = 18
TITLE_BG = (40, 40, 44)
BORDER = (60, 60, 64)
TRAFFIC = [(255, 95, 87), (254, 188, 46), (40, 200, 64)]
canvas = Image.new("RGB", (w, h + TITLE_H), TITLE_BG)
canvas.paste(frame, (0, TITLE_H))
draw = ImageDraw.Draw(canvas)
draw.line([(0, TITLE_H - 1), (w, TITLE_H - 1)], fill=BORDER)
cx = DOT_LEFT
for color in TRAFFIC:
draw.ellipse(
[(cx - DOT_R, TITLE_H // 2 - DOT_R),
(cx + DOT_R, TITLE_H // 2 + DOT_R)],
fill=color,
)
cx += DOT_R * 2 + DOT_GAPDo not add rounded corners by default. GIF alpha is binary, so rounded corners look jagged. Keep the rectangle.
If the user has a strong preference (Linux GTK style, Windows style, no chrome at all), respect it — chrome is the easiest thing to change.
Output location
Put intermediate and final files in one directory under the project, named unambiguously. Preferred order:
<project>/tmp/recording-<timestamp>/<project>/.tmp/recording-<timestamp>/<project>/.cc-use/recordings/<timestamp>/(only if no good project home)
Always report the exact paths of every artifact back to the user. The cast path matters because they may want to re-render later.
End-to-end checklist
1. Probe dependencies; ask the user before installing anything. 2. Confirm or create the inner tmux session. 3. Decide an output directory under the project; create it. 4. Start asciinema rec in background, wrapped around tmux attach -r with a timeout. 5. Drive the demo (cc-use delegate or direct tmux send-keys). 6. Wait for the asciinema timeout; verify the cast file exists and is non-empty. 7. Render 2–3 GIF variants with agg --font-antialiasing off at different font sizes. 8. Optimize each with gifsicle -O3 --colors N (lossless first; reach for --lossy only if size requires it). 9. Inspect a busy mid-recording frame; decide the crop policy from principles. Crop all frames. 10. Composite the macOS-style window chrome. 11. Report every artifact path and file size to the user. Recommend one variant based on their stated target (README header, slides, etc.). 12. Keep the cast file. Do not auto-delete intermediates.
When to stop and ask the user
- Dependencies missing → list and ask before installing.
- Output target dimensions / theme / font unspecified → propose defaults but
let them override.
- Recording longer than ~2 minutes → confirm intent (long cast files and
long renders cost minutes).
- Cropping decision ambiguous (no visible border, theme out of distribution) →
show the frame and ask.
- User uses a non-mac chrome style or wants no chrome at all → ask before
composing.
#!/usr/bin/env bash
set -euo pipefail
usage() {
cat <<'EOF'
Usage:
cc-use delegate "TASK" [--project DIR] [--agent codex|claude] [--profile NAME] [--session NAME] [--replace]
cc-use monitor [--project DIR] [--agent codex|claude] [--session NAME]
cc-use project-status [--project DIR] [--agent codex|claude] [--session NAME] [--json]
cc-use scrollback [--project DIR] [--agent codex|claude] [--session NAME] [--lines N] [--start LINE] [--end LINE]
cc-use schedule-add heartbeat NAME [--project DIR] [--interval-minutes N] [--agent codex|claude|auto] [--profile NAME] [--session NAME]
cc-use schedule-add cron NAME [--project DIR] --cron-expr EXPR --prompt TEXT [--agent codex|claude|auto] [--profile NAME] [--search]
cc-use schedule-list
cc-use schedule-status [ID]
cc-use schedule-remove ID
cc-use schedule-run ID
cc-use snapshot SESSION
cc-use kill SESSION
cc-use list
EOF
}
die() {
echo "cc-use: $*" >&2
exit 1
}
json_escape() {
tr '\n\r\t' ' ' | sed 's/\\/\\\\/g; s/"/\\"/g'
}
safe_name() {
printf '%s' "$1" | tr -c '[:alnum:]_-' '-'
}
now_epoch() {
date +%s
}
project_abs() {
(cd "$1" && pwd)
}
cc_dir() {
printf '%s/.cc-use' "$1"
}
state_dir() {
printf '%s/state' "$(cc_dir "$1")"
}
watch_dir() {
local project_dir="$1"
local session="$2"
printf '%s/%s' "$(state_dir "$project_dir")" "$(safe_name "$session")"
}
session_name_for_project() {
local project_dir="$1"
printf 'ccu-%s' "$(safe_name "$(basename "$project_dir")")"
}
validate_agent() {
case "$1" in
codex|claude) ;;
*) die "unsupported agent: $1" ;;
esac
}
detect_agent() {
if [ -n "${CODEX_THREAD_ID:-}" ] || [ -n "${CODEX_CI:-}" ] || [ -n "${CODEX_HOME:-}" ]; then
printf 'codex'
elif [ -n "${CLAUDECODE:-}" ] || [ -n "${CLAUDE_CODE_ENTRYPOINT:-}" ]; then
printf 'claude'
else
printf 'codex'
fi
}
resolve_agent() {
case "$1" in
auto|"") detect_agent ;;
codex|claude) printf '%s' "$1" ;;
*) die "unsupported agent: $1" ;;
esac
}
validate_line_spec() {
local value="$1"
local flag="$2"
local number
[ -n "$value" ] || die "$flag must be '-' or an integer line number"
[ "$value" = "-" ] && return 0
number="${value#-}"
[ -n "$number" ] || die "$flag must be '-' or an integer line number"
case "$number" in
*[!0-9]*) die "$flag must be '-' or an integer line number" ;;
esac
}
hash_text() {
if command -v openssl >/dev/null 2>&1; then
openssl dgst -sha256 -r | awk '{print $1}'
elif command -v sha256sum >/dev/null 2>&1; then
sha256sum | awk '{print $1}'
elif command -v shasum >/dev/null 2>&1; then
LC_ALL=C LANG=C shasum -a 256 | awk '{print $1}'
else
cksum | awk '{print $1}'
fi
}
normalize_screen() {
sed -E $'s/\x1b\\[[0-?]*[ -\\/]*[@-~]//g; s/[[:space:]]+$//'
}
tmux_has_session() {
tmux has-session -t "$1" >/dev/null 2>&1
}
tmux_capture() {
tmux capture-pane -t "$1" -p 2>/dev/null | normalize_screen
}
tmux_capture_scrollback() {
local session="$1"
local start="$2"
local end="$3"
tmux capture-pane -t "$session" -p -S "$start" -E "$end" 2>/dev/null | normalize_screen
}
tmux_send_text() {
local session="$1"
local text="$2"
local tmp
local buffer
tmp="$(mktemp)"
buffer="cc-use-send-$$"
printf '%s' "$text" > "$tmp"
tmux send-keys -t "$session" C-u
tmux load-buffer -b "$buffer" "$tmp"
tmux paste-buffer -d -b "$buffer" -t "$session"
rm -f "$tmp"
sleep 0.5
tmux send-keys -t "$session" Enter
sleep 0.7
tmux send-keys -t "$session" C-m
sleep 0.7
tmux send-keys -t "$session" Enter
}
build_inner_task_prompt() {
printf '%s' "$1"
}
build_codex_command() {
local profile="$1"
# sandbox/approval positional args retained for caller compatibility
# but unused. cc-use launches the inner codex fully bypassed (matches
# Claude Code's --dangerously-skip-permissions risk level); the older
# --ask-for-approval / --sandbox pair was incompatible with codex
# configs already set to bypass mode and caused inner-session startup
# failures.
printf '%q --no-alt-screen --dangerously-bypass-approvals-and-sandbox' "codex"
if [ -n "$profile" ]; then
printf ' --profile %q' "$profile"
fi
}
resolve_codex_binary() {
local codex_path
local prefix
local package_root
local native
codex_path="$(command -v codex 2>/dev/null || true)"
[ -n "$codex_path" ] || die "codex command not found"
# npm global layout: <prefix>/bin/codex -> <prefix>/lib/node_modules/@openai/codex/bin/codex.js
prefix="$(cd "$(dirname "$codex_path")/.." && pwd)"
package_root="$prefix/lib/node_modules/@openai/codex"
if [ -d "$package_root" ]; then
native="$(find "$package_root" -path '*/vendor/*/codex/codex' -type f -perm -111 2>/dev/null | head -1)"
if [ -n "$native" ]; then
printf '%s' "$native"
return
fi
fi
printf '%s' "$codex_path"
}
resolve_claude_binary() {
local claude_path
claude_path="$(command -v claude 2>/dev/null || true)"
[ -n "$claude_path" ] || die "claude command not found"
printf '%s' "$claude_path"
}
build_agent_command() {
local agent="$1"
local profile="$2"
local sandbox="$3"
local approval="$4"
validate_agent "$agent"
case "$agent" in
codex)
build_codex_command "$profile" "$sandbox" "$approval"
;;
claude)
[ -z "$profile" ] || die "--profile is only supported for codex"
printf '%q --dangerously-skip-permissions' "claude"
;;
esac
}
launch_agent_session() {
local session="$1"
local project="$2"
local command="$3"
tmux new-session -d -s "$session" -c "$project"
tmux set-option -t "$session" history-limit 50000 >/dev/null 2>&1 || true
# Start the agent from the pane's interactive shell so user shell startup files
# can populate PATH, provider keys, and other local environment settings.
sleep 1
tmux send-keys -t "$session" "$command" Enter
}
build_agent_exec_command() {
local agent="$1"
local profile="$2"
local sandbox="$3"
local approval="$4"
local project="$5"
local search="$6"
validate_agent "$agent"
case "$agent" in
codex)
printf '%q' "$(resolve_codex_binary)"
if [ -n "$profile" ]; then
printf ' --profile %q' "$profile"
fi
# Same simplification as build_codex_command — see comment there.
printf ' --dangerously-bypass-approvals-and-sandbox'
if [ "$search" = "true" ]; then
printf ' --search'
fi
printf ' exec --skip-git-repo-check --cd %q' "$project"
;;
claude)
[ -z "$profile" ] || die "--profile is only supported for codex"
printf '%q -p' "$(resolve_claude_binary)"
;;
esac
}
write_watch_state() {
local project_dir="$1"
local session="$2"
local digest="$3"
local silence_started="$4"
local next_check="$5"
local observations="$6"
local last_observation_json="${7:-null}"
local state
state="$(watch_dir "$project_dir" "$session")"
mkdir -p "$state"
cat > "$state/watch.env" <<EOF
last_digest=$(printf '%q' "$digest")
silence_started_at=$silence_started
next_check_at=$next_check
observation_count=$observations
EOF
cat > "$state/watch.json" <<EOF
{
"last_digest": "$(printf '%s' "$digest" | json_escape)",
"next_check_at": $next_check,
"observation_count": $observations,
"session": "$(printf '%s' "$session" | json_escape)",
"silence_started_at": $silence_started,
"last_observation": $last_observation_json
}
EOF
}
load_watch_state() {
local project_dir="$1"
local session="$2"
local env_file
env_file="$(watch_dir "$project_dir" "$session")/watch.env"
if [ -f "$env_file" ]; then
# shellcheck disable=SC1090
source "$env_file"
else
last_digest=""
silence_started_at=0
next_check_at=0
observation_count=0
fi
}
decision_for_stable_screen() {
decision_action="inspect"
decision_next=0
decision_confidence="1.0"
decision_reason="The screen is stable; inspect screen_path semantically before deciding whether to wait, steer, or verify."
}
watch_once() {
local project_dir="$1"
local session="$2"
local initial_quiet="$3"
local poll_interval="$4"
local max_observations="$5"
local state
local screens
local history
local curr
local digest
local now
local sleep_for
local observation_json
state="$(watch_dir "$project_dir" "$session")"
screens="$state/screens"
history="$state/watch.observations.jsonl"
mkdir -p "$state" "$screens"
load_watch_state "$project_dir" "$session"
while :; do
now="$(now_epoch)"
curr="$(mktemp)"
if ! tmux_capture "$session" > "$curr"; then
observation_json="{\"event\":\"session_unavailable\",\"session\":\"$(printf '%s' "$session" | json_escape)\",\"observed_at\":$now}"
printf '%s\n' "$observation_json"
printf '%s\n' "$observation_json" >> "$history"
rm -f "$curr"
return 0
fi
digest="$(hash_text < "$curr")"
if [ "$digest" != "${last_digest:-}" ]; then
silence_started_at="$now"
next_check_at=$((now + initial_quiet))
write_watch_state "$project_dir" "$session" "$digest" "$silence_started_at" "$next_check_at" "${observation_count:-0}" "null"
last_digest="$digest"
rm -f "$curr"
sleep "$poll_interval"
continue
fi
if [ "${next_check_at:-0}" -le 0 ]; then
next_check_at=$((now + initial_quiet))
write_watch_state "$project_dir" "$session" "$digest" "${silence_started_at:-$now}" "$next_check_at" "${observation_count:-0}" "null"
fi
if [ "$now" -ge "$next_check_at" ]; then
local silence
local screen_path
silence=$((now - silence_started_at))
observation_count=$((observation_count + 1))
screen_path="$screens/$(safe_name "$session")-$(printf '%04d' "$observation_count").txt"
cp "$curr" "$screen_path"
decision_for_stable_screen
observation_json="{\"event\":\"observation\",\"session\":\"$(printf '%s' "$session" | json_escape)\",\"observed_at\":$now,\"silence_seconds\":$silence,\"screen_digest\":\"$digest\",\"screen_path\":\"$(printf '%s' "$screen_path" | json_escape)\",\"decision\":{\"action\":\"$decision_action\",\"next_check_after_seconds\":$decision_next,\"reason\":\"$(printf '%s' "$decision_reason" | json_escape)\",\"confidence\":$decision_confidence}}"
next_check_at=$((now + decision_next))
write_watch_state "$project_dir" "$session" "$digest" "$silence_started_at" "$next_check_at" "$observation_count" "$observation_json"
printf '%s\n' "$observation_json"
printf '%s\n' "$observation_json" >> "$history"
rm -f "$curr"
[ "$max_observations" -le 0 ] || [ "$observation_count" -ge "$max_observations" ] && return 0
else
rm -f "$curr"
fi
now="$(now_epoch)"
sleep_for="$poll_interval"
if [ "$next_check_at" -gt "$now" ] && [ $((next_check_at - now)) -lt "$sleep_for" ]; then
sleep_for=$((next_check_at - now))
[ "$sleep_for" -le 0 ] && sleep_for=1
fi
sleep "$sleep_for"
done
}
cmd_delegate() {
local task="$1"; shift
local project="."
local session=""
local agent="codex"
local profile=""
local sandbox="workspace-write"
local approval="never"
local replace=false
local initial_quiet=30
local poll_interval=2
local max_observations=1
while [ "$#" -gt 0 ]; do
case "$1" in
--project) project="$2"; shift 2 ;;
--agent) agent="$2"; shift 2 ;;
--session) session="$2"; shift 2 ;;
--profile) profile="$2"; shift 2 ;;
--sandbox) sandbox="$2"; shift 2 ;;
--approval) approval="$2"; shift 2 ;;
--replace) replace=true; shift ;;
--initial-quiet-seconds) initial_quiet="$2"; shift 2 ;;
--poll-interval) poll_interval="$2"; shift 2 ;;
--max-observations) max_observations="$2"; shift 2 ;;
*) die "unknown delegate option: $1" ;;
esac
done
project="$(project_abs "$project")"
validate_agent "$agent"
[ -n "$session" ] || session="$(session_name_for_project "$project" "$agent")"
if $replace && tmux_has_session "$session"; then
tmux kill-session -t "$session" >/dev/null 2>&1 || true
fi
if ! tmux_has_session "$session"; then
local command
command="$(build_agent_command "$agent" "$profile" "$sandbox" "$approval")"
launch_agent_session "$session" "$project" "$command"
echo "launched $session: $command"
sleep 5
tmux send-keys -t "$session" Enter
sleep 1
fi
tmux_send_text "$session" "$(build_inner_task_prompt "$task")"
watch_once "$project" "$session" "$initial_quiet" "$poll_interval" "$max_observations"
}
cmd_monitor() {
local project="."
local agent="codex"
local session=""
local initial_quiet=30
local poll_interval=2
local max_observations=1
while [ "$#" -gt 0 ]; do
case "$1" in
--project) project="$2"; shift 2 ;;
--agent) agent="$2"; shift 2 ;;
--session) session="$2"; shift 2 ;;
--initial-quiet-seconds) initial_quiet="$2"; shift 2 ;;
--poll-interval) poll_interval="$2"; shift 2 ;;
--max-observations) max_observations="$2"; shift 2 ;;
*) die "unknown monitor option: $1" ;;
esac
done
project="$(project_abs "$project")"
validate_agent "$agent"
[ -n "$session" ] || session="$(session_name_for_project "$project" "$agent")"
watch_once "$project" "$session" "$initial_quiet" "$poll_interval" "$max_observations"
}
cmd_scrollback() {
local project="."
local agent="codex"
local session=""
local lines=2000
local start=""
local end="-"
while [ "$#" -gt 0 ]; do
case "$1" in
--project) project="$2"; shift 2 ;;
--agent) agent="$2"; shift 2 ;;
--session) session="$2"; shift 2 ;;
--lines) lines="$2"; shift 2 ;;
--start) start="$2"; shift 2 ;;
--end) end="$2"; shift 2 ;;
*) die "unknown scrollback option: $1" ;;
esac
done
case "$lines" in
''|*[!0-9]*) die "--lines must be a positive integer" ;;
esac
[ "$lines" -gt 0 ] || die "--lines must be a positive integer"
if [ -z "$start" ]; then
start="-$lines"
fi
validate_line_spec "$start" "--start"
validate_line_spec "$end" "--end"
project="$(project_abs "$project")"
validate_agent "$agent"
[ -n "$session" ] || session="$(session_name_for_project "$project" "$agent")"
tmux_capture_scrollback "$session" "$start" "$end"
}
cmd_project_status() {
local project="."
local agent="codex"
local session=""
local as_json=false
while [ "$#" -gt 0 ]; do
case "$1" in
--project) project="$2"; shift 2 ;;
--agent) agent="$2"; shift 2 ;;
--session) session="$2"; shift 2 ;;
--json) as_json=true; shift ;;
*) die "unknown project-status option: $1" ;;
esac
done
project="$(project_abs "$project")"
validate_agent "$agent"
[ -n "$session" ] || session="$(session_name_for_project "$project" "$agent")"
load_watch_state "$project" "$session"
local now silence remaining available
now="$(now_epoch)"
if [ "${silence_started_at:-0}" -le 0 ]; then
silence=0
else
silence=$((now - silence_started_at))
fi
remaining=$((next_check_at - now))
[ "$remaining" -lt 0 ] && remaining=0
if tmux_has_session "$session"; then available=true; else available=false; fi
if $as_json; then
cat <<EOF
{"config":{"project_dir":"$(printf '%s' "$project" | json_escape)","session":"$(printf '%s' "$session" | json_escape)","agent":"$(printf '%s' "$agent" | json_escape)","session_available":$available},"watch":{"observation_count":${observation_count:-0},"silence_seconds":$silence,"seconds_until_next_check":$remaining}}
EOF
else
echo "project: $project"
echo "session: $session"
echo "agent: $agent"
echo "session_available: $available"
echo "observations: ${observation_count:-0}"
echo "silence_seconds: $silence"
echo "seconds_until_next_check: $remaining"
fi
}
schedule_root() {
printf '%s/.cc-use' "$HOME"
}
schedule_db() {
printf '%s/schedules.json' "$(schedule_root)"
}
schedule_log_dir() {
printf '%s/logs' "$(schedule_root)"
}
source_user_shell_profile() {
local env_file
local rc
local old_umask
old_umask="$(umask)"
umask 077
env_file="$(mktemp)"
umask "$old_umask"
chmod 600 "$env_file" 2>/dev/null || true
for rc in "$HOME/.zshrc" "$HOME/.bashrc" "$HOME/.profile"; do
if [ -f "$rc" ]; then
grep -E '^[[:space:]]*export[[:space:]]+[A-Za-z_][A-Za-z0-9_]*=' "$rc" >> "$env_file" 2>/dev/null || true
fi
done
if [ -s "$env_file" ]; then
set -a
# shellcheck disable=SC1090
source "$env_file" >/dev/null 2>&1 || true
set +a
fi
rm -f "$env_file"
}
schedule_init() {
mkdir -p "$(schedule_log_dir)"
if [ ! -f "$(schedule_db)" ]; then
printf '{"schedules":[]}\n' > "$(schedule_db)"
chmod 600 "$(schedule_db)" 2>/dev/null || true
fi
}
schedule_id() {
local prefix="$1"
if command -v openssl >/dev/null 2>&1; then
printf '%s-%s' "$prefix" "$(printf '%s-%s' "$prefix" "$(now_epoch)-$$" | openssl dgst -sha256 -r | awk '{print substr($1,1,8)}')"
else
printf '%s-%s' "$prefix" "$(printf '%s-%s' "$prefix" "$(now_epoch)-$$" | cksum | awk '{print $1}')"
fi
}
schedule_get() {
local id="$1"
jq -e --arg id "$id" '.schedules[] | select(.id == $id)' "$(schedule_db)" 2>/dev/null
}
schedule_platform() {
case "$(uname -s)" in
Darwin) printf 'macos' ;;
Linux) printf 'linux' ;;
*) printf 'unsupported' ;;
esac
}
script_path() {
local dir
dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
printf '%s/%s' "$dir" "$(basename "${BASH_SOURCE[0]}")"
}
launchd_plist_path() {
local id="$1"
printf '%s/Library/LaunchAgents/com.cc-use.%s.plist' "$HOME" "$id"
}
launchd_write_interval() {
local id="$1"
local interval_seconds="$2"
local log_file="$3"
local plist
local script
plist="$(launchd_plist_path "$id")"
script="$(script_path)"
mkdir -p "$HOME/Library/LaunchAgents"
cat > "$plist" <<PLIST
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>Label</key>
<string>com.cc-use.${id}</string>
<key>ProgramArguments</key>
<array>
<string>/bin/bash</string>
<string>${script}</string>
<string>schedule-run</string>
<string>${id}</string>
</array>
<key>StartInterval</key>
<integer>${interval_seconds}</integer>
<key>StandardOutPath</key>
<string>${log_file}</string>
<key>StandardErrorPath</key>
<string>${log_file}</string>
<key>RunAtLoad</key>
<false/>
</dict>
</plist>
PLIST
launchctl unload "$plist" >/dev/null 2>&1 || true
launchctl load "$plist" >/dev/null 2>&1 || true
}
cron_field_to_plist_keys() {
local minute="$1"
local hour="$2"
local day="$3"
local month="$4"
local weekday="$5"
[ "$minute" != "*" ] && printf ' <key>Minute</key><integer>%s</integer>\n' "$minute"
[ "$hour" != "*" ] && printf ' <key>Hour</key><integer>%s</integer>\n' "$hour"
[ "$day" != "*" ] && printf ' <key>Day</key><integer>%s</integer>\n' "$day"
[ "$month" != "*" ] && printf ' <key>Month</key><integer>%s</integer>\n' "$month"
[ "$weekday" != "*" ] && printf ' <key>Weekday</key><integer>%s</integer>\n' "$weekday"
return 0
}
launchd_write_calendar() {
local id="$1"
local cron_expr="$2"
local log_file="$3"
local plist
local script
local minute hour day month weekday
plist="$(launchd_plist_path "$id")"
script="$(script_path)"
read -r minute hour day month weekday <<EOF
$cron_expr
EOF
[ -n "${weekday:-}" ] || die "cron expression must have 5 fields"
mkdir -p "$HOME/Library/LaunchAgents"
{
cat <<PLIST
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>Label</key>
<string>com.cc-use.${id}</string>
<key>ProgramArguments</key>
<array>
<string>/bin/bash</string>
<string>${script}</string>
<string>schedule-run</string>
<string>${id}</string>
</array>
<key>StartCalendarInterval</key>
<dict>
PLIST
cron_field_to_plist_keys "$minute" "$hour" "$day" "$month" "$weekday"
cat <<PLIST
</dict>
<key>StandardOutPath</key>
<string>${log_file}</string>
<key>StandardErrorPath</key>
<string>${log_file}</string>
</dict>
</plist>
PLIST
} > "$plist"
launchctl unload "$plist" >/dev/null 2>&1 || true
launchctl load "$plist" >/dev/null 2>&1 || true
}
crontab_add() {
local id="$1"
local cron_expr="$2"
local marker="#cc-use:${id}"
local script
script="$(script_path)"
(crontab -l 2>/dev/null | grep -v "$marker" || true; printf '%s /bin/bash %q schedule-run %q %s\n' "$cron_expr" "$script" "$id" "$marker") | crontab -
}
schedule_register() {
local id="$1"
local type="$2"
local cron_expr="$3"
local interval_minutes="$4"
local log_file="$5"
case "$(schedule_platform)" in
macos)
if [ "$type" = "heartbeat" ]; then
launchd_write_interval "$id" "$((interval_minutes * 60))" "$log_file"
else
launchd_write_calendar "$id" "$cron_expr" "$log_file"
fi
;;
linux)
if [ "$type" = "heartbeat" ]; then
crontab_add "$id" "*/${interval_minutes} * * * *"
else
crontab_add "$id" "$cron_expr"
fi
;;
*) echo "cc-use: unsupported platform; schedule saved but not registered" >&2 ;;
esac
}
schedule_unregister() {
local id="$1"
local marker="#cc-use:${id}"
local plist
plist="$(launchd_plist_path "$id")"
if [ -f "$plist" ]; then
launchctl unload "$plist" >/dev/null 2>&1 || true
rm -f "$plist"
fi
if command -v crontab >/dev/null 2>&1; then
crontab -l 2>/dev/null | grep -v "$marker" | crontab - 2>/dev/null || true
fi
}
heartbeat_state_file() {
local project="$1"
printf '%s/.cc-use/heartbeat-state.json' "$project"
}
heartbeat_state_init() {
local file="$1"
mkdir -p "$(dirname "$file")"
if [ ! -f "$file" ]; then
cat > "$file" <<'JSON'
{
"last_run": null,
"last_result": null,
"consecutive_ok": 0,
"consecutive_errors": 0,
"last_alert": null,
"last_alert_time": null,
"history": []
}
JSON
fi
}
heartbeat_state_update() {
local file="$1"
local result="$2"
local duration="$3"
local summary="${4:-}"
local now
local tmp
now="$(date -u +"%Y-%m-%dT%H:%M:%SZ")"
tmp="$(mktemp)"
jq --arg now "$now" --arg result "$result" --arg summary "$summary" --argjson duration "$duration" '
.last_run = $now |
.last_result = $result |
if $result == "ok" then
.consecutive_ok += 1 | .consecutive_errors = 0
elif $result == "alert" then
.consecutive_ok = 0 | .consecutive_errors = 0 | .last_alert = $summary | .last_alert_time = $now
elif $result == "error" then
.consecutive_ok = 0 | .consecutive_errors += 1
else . end |
.history = ([{time: $now, result: $result, duration_sec: $duration, summary: $summary}] + .history | .[0:20])
' "$file" > "$tmp" && mv "$tmp" "$file"
}
notify_send() {
local title="$1"
local body="$2"
local config
local notifier
local webhook
config="$(schedule_root)/config.json"
[ -f "$config" ] || return 0
notifier="$(jq -r '.default_notifier // empty' "$config" 2>/dev/null)"
[ -n "$notifier" ] || return 0
webhook="$(jq -r --arg name "$notifier" '.notifiers[]? | select(.name == $name) | .webhook_url // empty' "$config" 2>/dev/null)"
[ -n "$webhook" ] || return 0
curl -s -X POST "$webhook" -H "Content-Type: application/json" -d "$(jq -n --arg text "[$title] $body" '{msg_type:"text",content:{text:$text}}')" >/dev/null 2>&1 || true
}
log_rotate() {
local file="$1"
[ -f "$file" ] || return 0
[ "$(wc -c < "$file" | tr -d ' ')" -le 1048576 ] && return 0
[ -f "${file}.2" ] && rm -f "${file}.3"
[ -f "${file}.1" ] && mv "${file}.1" "${file}.2"
mv "$file" "${file}.1"
}
cmd_schedule_add() {
local type="${1:-}"
[ -n "$type" ] || die "schedule-add requires heartbeat or cron"
shift
case "$type" in
heartbeat) cmd_schedule_add_heartbeat "$@" ;;
cron) cmd_schedule_add_cron "$@" ;;
*) die "unsupported schedule type: $type" ;;
esac
}
cmd_schedule_add_heartbeat() {
local name="${1:-}"
[ -n "$name" ] || die "schedule-add heartbeat requires NAME"
shift
local project="."
local interval=60
local agent="auto"
local profile=""
local session=""
local sandbox="danger-full-access"
local approval="never"
while [ "$#" -gt 0 ]; do
case "$1" in
--project) project="$2"; shift 2 ;;
--interval-minutes) interval="$2"; shift 2 ;;
--agent) agent="$2"; shift 2 ;;
--profile) profile="$2"; shift 2 ;;
--session) session="$2"; shift 2 ;;
--sandbox) sandbox="$2"; shift 2 ;;
--approval) approval="$2"; shift 2 ;;
*) die "unknown schedule-add heartbeat option: $1" ;;
esac
done
project="$(project_abs "$project")"
agent="$(resolve_agent "$agent")"
validate_agent "$agent"
[ -n "$session" ] || session="$(session_name_for_project "$project")"
case "$interval" in ''|*[!0-9]*) die "--interval-minutes must be a positive integer" ;; esac
[ "$interval" -gt 0 ] || die "--interval-minutes must be a positive integer"
schedule_init
local id heartbeat_file log_file now tmp
id="$(schedule_id hb)"
heartbeat_file="$(cc_dir "$project")/heartbeat.md"
log_file="$(schedule_log_dir)/heartbeat-${id}.log"
now="$(date -u +"%Y-%m-%dT%H:%M:%SZ")"
mkdir -p "$(cc_dir "$project")"
if [ ! -f "$heartbeat_file" ]; then
cat > "$heartbeat_file" <<'MD'
# Heartbeat Checklist
Check the items below. If nothing needs attention, respond with exactly:
HEARTBEAT_OK
Otherwise, describe what needs attention.
MD
fi
heartbeat_state_init "$(heartbeat_state_file "$project")"
tmp="$(mktemp)"
jq --arg id "$id" --arg type "heartbeat" --arg name "$name" --arg project "$project" \
--arg agent "$agent" --arg profile "$profile" --arg session "$session" --arg heartbeat_file "$heartbeat_file" \
--arg sandbox "$sandbox" --arg approval "$approval" --arg now "$now" --arg env_path "$PATH" \
--arg platform_id "com.cc-use.${id}" --argjson interval "$interval" \
'.schedules += [{id:$id,type:$type,name:$name,project_dir:$project,agent:$agent,profile:$profile,session_name:$session,heartbeat_file:$heartbeat_file,interval_minutes:$interval,sandbox:$sandbox,approval:$approval,enabled:true,notify:true,auto_restart:true,created_at:$now,platform_id:$platform_id,env_path:$env_path}]' \
"$(schedule_db)" > "$tmp" && mv "$tmp" "$(schedule_db)"
schedule_register "$id" "heartbeat" "" "$interval" "$log_file"
echo "added heartbeat schedule: $name ($id)"
}
cmd_schedule_add_cron() {
local name="${1:-}"
[ -n "$name" ] || die "schedule-add cron requires NAME"
shift
local project="."
local cron_expr=""
local prompt=""
local agent="auto"
local profile=""
local sandbox="danger-full-access"
local approval="never"
local search=false
while [ "$#" -gt 0 ]; do
case "$1" in
--project) project="$2"; shift 2 ;;
--cron-expr) cron_expr="$2"; shift 2 ;;
--prompt) prompt="$2"; shift 2 ;;
--agent) agent="$2"; shift 2 ;;
--profile) profile="$2"; shift 2 ;;
--sandbox) sandbox="$2"; shift 2 ;;
--approval) approval="$2"; shift 2 ;;
--search) search=true; shift ;;
*) die "unknown schedule-add cron option: $1" ;;
esac
done
[ -n "$cron_expr" ] || die "--cron-expr is required"
[ -n "$prompt" ] || die "--prompt is required"
project="$(project_abs "$project")"
agent="$(resolve_agent "$agent")"
validate_agent "$agent"
schedule_init
local id log_file now tmp
id="$(schedule_id cr)"
log_file="$(schedule_log_dir)/cron-${id}.log"
now="$(date -u +"%Y-%m-%dT%H:%M:%SZ")"
tmp="$(mktemp)"
jq --arg id "$id" --arg type "cron" --arg name "$name" --arg project "$project" \
--arg agent "$agent" --arg profile "$profile" --arg cron_expr "$cron_expr" --arg prompt "$prompt" \
--arg sandbox "$sandbox" --arg approval "$approval" --arg now "$now" --arg env_path "$PATH" \
--arg platform_id "com.cc-use.${id}" --argjson search "$search" \
'.schedules += [{id:$id,type:$type,name:$name,project_dir:$project,agent:$agent,profile:$profile,cron_expr:$cron_expr,prompt:$prompt,search:$search,sandbox:$sandbox,approval:$approval,enabled:true,notify:true,created_at:$now,platform_id:$platform_id,env_path:$env_path}]' \
"$(schedule_db)" > "$tmp" && mv "$tmp" "$(schedule_db)"
schedule_register "$id" "cron" "$cron_expr" "" "$log_file"
echo "added cron schedule: $name ($id)"
}
cmd_schedule_list() {
schedule_init
jq -r '.schedules[] | [.id,.type,.name,.agent,(.profile // "-"),(if .type == "heartbeat" then "every \(.interval_minutes)m" else .cron_expr end),(.enabled|tostring)] | @tsv' "$(schedule_db)" |
awk -F '\t' 'BEGIN{printf "%-12s %-10s %-28s %-8s %-12s %-18s %s\n","ID","TYPE","NAME","AGENT","PROFILE","SCHEDULE","ENABLED"} {printf "%-12s %-10s %-28s %-8s %-12s %-18s %s\n",$1,$2,$3,$4,$5,$6,$7}'
}
cmd_schedule_status() {
local id="${1:-}"
schedule_init
if [ -z "$id" ]; then
cmd_schedule_list
return 0
fi
schedule_get "$id" || die "schedule not found: $id"
local log_file
log_file="$(schedule_log_dir)/$(jq -r --arg id "$id" '.schedules[] | select(.id == $id) | if .type == "heartbeat" then "heartbeat-" + .id + ".log" else "cron-" + .id + ".log" end' "$(schedule_db)")"
if [ -f "$log_file" ]; then
echo "log: $log_file"
tail -10 "$log_file"
fi
}
cmd_schedule_remove() {
local id="${1:-}"
[ -n "$id" ] || die "schedule-remove requires ID"
schedule_init
schedule_get "$id" >/dev/null || die "schedule not found: $id"
schedule_unregister "$id"
local tmp
tmp="$(mktemp)"
jq --arg id "$id" '.schedules |= map(select(.id != $id))' "$(schedule_db)" > "$tmp" && mv "$tmp" "$(schedule_db)"
echo "removed schedule: $id"
}
cmd_schedule_run() {
local id="${1:-}"
[ -n "$id" ] || die "schedule-run requires ID"
schedule_init
local entry type
entry="$(schedule_get "$id")" || die "schedule not found: $id"
type="$(printf '%s' "$entry" | jq -r '.type')"
case "$type" in
heartbeat) schedule_run_heartbeat "$entry" ;;
cron) schedule_run_cron "$entry" ;;
*) die "unsupported schedule type: $type" ;;
esac
}
schedule_run_cron() {
local entry="$1"
local id name project agent profile sandbox approval prompt search env_path log_file start duration command output status
id="$(printf '%s' "$entry" | jq -r '.id')"
name="$(printf '%s' "$entry" | jq -r '.name')"
project="$(printf '%s' "$entry" | jq -r '.project_dir')"
agent="$(printf '%s' "$entry" | jq -r '.agent // "codex"')"
profile="$(printf '%s' "$entry" | jq -r '.profile // ""')"
sandbox="$(printf '%s' "$entry" | jq -r '.sandbox // "danger-full-access"')"
approval="$(printf '%s' "$entry" | jq -r '.approval // "never"')"
prompt="$(printf '%s' "$entry" | jq -r '.prompt')"
search="$(printf '%s' "$entry" | jq -r '.search // false')"
env_path="$(printf '%s' "$entry" | jq -r '.env_path // ""')"
source_user_shell_profile
[ -n "$env_path" ] && export PATH="$env_path"
log_file="$(schedule_log_dir)/cron-${id}.log"
log_rotate "$log_file"
start="$(now_epoch)"
{
echo "[$(date '+%Y-%m-%d %H:%M:%S')] === Cron job start: $name ==="
echo "[$(date '+%Y-%m-%d %H:%M:%S')] Project: $project"
echo "[$(date '+%Y-%m-%d %H:%M:%S')] Agent: $agent"
} >> "$log_file"
cd "$project" || die "cannot cd to $project"
set +e
if [ -f "$prompt" ] && [ -x "$prompt" ]; then
output="$("$prompt" 2>&1)"
status=$?
else
command="$(build_agent_exec_command "$agent" "$profile" "$sandbox" "$approval" "$project" "$search")"
output="$(eval "$command"' "$prompt"' 2>&1 < /dev/null)"
status=$?
fi
set -e
duration=$(( $(now_epoch) - start ))
{
echo "[$(date '+%Y-%m-%d %H:%M:%S')] Exit code: $status"
echo "[$(date '+%Y-%m-%d %H:%M:%S')] Duration: ${duration}s"
echo "[$(date '+%Y-%m-%d %H:%M:%S')] Output:"
printf '%s\n' "$output"
echo "[$(date '+%Y-%m-%d %H:%M:%S')] === Cron job end (${duration}s, exit $status) ==="
} >> "$log_file"
if [ "$status" -ne 0 ] && [ "$(printf '%s' "$entry" | jq -r '.notify // false')" = "true" ]; then
notify_send "Cron Failed" "Job '$name' exited with code $status (${duration}s)"
fi
return "$status"
}
schedule_run_heartbeat() {
local entry="$1"
local id name project agent profile sandbox approval session heartbeat_file env_path log_file state_file start duration output summary result
id="$(printf '%s' "$entry" | jq -r '.id')"
name="$(printf '%s' "$entry" | jq -r '.name')"
project="$(printf '%s' "$entry" | jq -r '.project_dir')"
agent="$(printf '%s' "$entry" | jq -r '.agent // "codex"')"
profile="$(printf '%s' "$entry" | jq -r '.profile // ""')"
sandbox="$(printf '%s' "$entry" | jq -r '.sandbox // "danger-full-access"')"
approval="$(printf '%s' "$entry" | jq -r '.approval // "never"')"
session="$(printf '%s' "$entry" | jq -r '.session_name // ""')"
heartbeat_file="$(printf '%s' "$entry" | jq -r '.heartbeat_file')"
env_path="$(printf '%s' "$entry" | jq -r '.env_path // ""')"
source_user_shell_profile
[ -n "$env_path" ] && export PATH="$env_path"
[ -n "$session" ] || session="$(session_name_for_project "$project")"
log_file="$(schedule_log_dir)/heartbeat-${id}.log"
state_file="$(heartbeat_state_file "$project")"
log_rotate "$log_file"
heartbeat_state_init "$state_file"
start="$(now_epoch)"
echo "[$(date '+%Y-%m-%d %H:%M:%S')] === Heartbeat start: $name ===" >> "$log_file"
if [ ! -f "$heartbeat_file" ]; then
duration=$(( $(now_epoch) - start ))
heartbeat_state_update "$state_file" "error" "$duration" "heartbeat file not found"
echo "[$(date '+%Y-%m-%d %H:%M:%S')] ERROR: heartbeat file not found: $heartbeat_file" >> "$log_file"
return 1
fi
output="$("$0" delegate "$(cat "$heartbeat_file")" --project "$project" --agent "$agent" --profile "$profile" --session "$session" --sandbox "$sandbox" --approval "$approval" --initial-quiet-seconds 30 --poll-interval 2 --max-observations 1 2>&1 || true)"
duration=$(( $(now_epoch) - start ))
printf '%s\n' "$output" >> "$log_file"
if "$0" scrollback --project "$project" --agent "$agent" --session "$session" --lines 200 2>/dev/null | grep -q 'HEARTBEAT_OK'; then
result="ok"
summary=""
else
result="alert"
summary="$("$0" scrollback --project "$project" --agent "$agent" --session "$session" --lines 60 2>/dev/null | grep -v '^$' | tail -10 | tr '\n' ' ' | head -c 200)"
fi
heartbeat_state_update "$state_file" "$result" "$duration" "$summary"
echo "[$(date '+%Y-%m-%d %H:%M:%S')] Result: $result ${summary}" >> "$log_file"
if [ "$result" = "alert" ] && [ "$(printf '%s' "$entry" | jq -r '.notify // false')" = "true" ]; then
notify_send "Heartbeat Alert" "$summary"
fi
echo "[$(date '+%Y-%m-%d %H:%M:%S')] === Heartbeat end (${duration}s) ===" >> "$log_file"
}
main() {
local cmd
cmd="${1:-}"
[ -n "$cmd" ] || { usage; exit 1; }
shift || true
case "$cmd" in
delegate)
[ "$#" -ge 1 ] || die "delegate requires TASK"
task="$1"; shift
cmd_delegate "$task" "$@"
;;
monitor)
cmd_monitor "$@"
;;
project-status)
cmd_project_status "$@"
;;
scrollback)
cmd_scrollback "$@"
;;
schedule-add)
cmd_schedule_add "$@"
;;
schedule-list)
cmd_schedule_list "$@"
;;
schedule-status)
cmd_schedule_status "$@"
;;
schedule-remove)
cmd_schedule_remove "$@"
;;
schedule-run)
cmd_schedule_run "$@"
;;
snapshot)
[ "$#" -eq 1 ] || die "snapshot requires SESSION"
tmux_capture "$1"
;;
kill)
[ "$#" -eq 1 ] || die "kill requires SESSION"
tmux kill-session -t "$1" >/dev/null 2>&1 || true
;;
list)
tmux list-sessions -F "#{session_name}" 2>/dev/null || true
;;
*)
usage
exit 1
;;
esac
}
if [ "${BASH_SOURCE[0]}" = "$0" ]; then
main "$@"
fi
Related skills
How it compares
Use cc-use for local agent session heartbeat and cron supervision rather than cloud CI schedulers or generic task runners.
FAQ
What does cc-use do?
Apply cc-use agent skill workflows from documented SKILL.md guidance.
When should I use cc-use?
During build integrations work for ai & agent building.
Is cc-use safe to install?
Review the Security Audits panel on this listing before production use.