The interesting part of going from ¥0 to ¥1.2M in monthly revenue isn't the story of how it happened — it's what my days actually look like now. The short version: I don't wait for anything anymore.
Why this design works
In the mass-production phase of solo development, the first wall you hit isn't talent or money. It's accumulated wait time.
Take an image-generation batch for an iOS app. You want ten monster images. The most straightforward implementation looks like this:
for task in task1 task2 task3 ... task10; do
codex exec "画像生成: $task"
done
If Codex averages 90 seconds per image, ten images is 900 seconds — 15 minutes. For those 15 minutes, the CPU is mostly idle. While one Codex worker runs, the next task just sits outside the queue doing nothing.
The other problem is visibility on failure. Run things sequentially in a loop and you end up in the situation where "you find out task #1 failed only after task #10 finishes." You have error logs, but nothing tells you which task is in which state right now.
This is what the design of orchestrate-codex-worker.sh solves. One script = one worker. Parallelism is the caller's job. State is externalized to files.
Because the worker sticks to a single function, the parent script (article-daily-stock.sh) can launch as many workers in the background as it wants. And since each worker keeps writing its own state to a status-file, the parent can read every job's progress even before it joins with wait.
The mechanism that makes wait time disappear
Run 10 tasks in parallel and total elapsed time converges to "the slowest worker's processing time" + "startup overhead." Ten 90-second tasks in parallel turn 15 minutes total into roughly 90-odd seconds.
That said, Codex calls an LLM internally during its API calls, so pushing the parallelism too high runs you into rate limits. The practical ceiling depends on project scale, but in the morning article-generation batch, launchd splits the jobs across two runs at 8:00 and 10:35, which levels out the parallel load per run.
The premise that workers operate as a "swarm"
The system prompt inside the script contains this line:
You are one worker in an ECC tmux/worktree swarm.
That isn't just rhetoric. Each worker is assumed to run inside an independent git worktree. Passing -C "$(pwd)" to codex exec is the implementation of that assumption. Workers don't touch anything outside their own worktree. The prompt spells it out:
- Work only in the current git worktree.
- Do not touch sibling worktrees or the parent repo checkout.
This is why 10 workers can run simultaneously on different branches and different worktrees without colliding. The precondition for parallelism is enforced at the code level.
The value of "state lives in a file"
Monitoring, debugging, and notification all reduce to file polling. Just watch cat the status-file and you can confirm every worker is alive. The handoff-file holds the post-completion deliverables (Summary, Files Changed, Validation, Remaining Risks) as structured text, so a downstream script can parse it into an aggregate report, pipe it to Slack, or write it into an Obsidian note. No external dependencies, no database — the whole design closes over shell scripts alone.
The overall flow
Startup chain (launchd → worker swarm)
launchd (08:00 / 10:35)
│
└─► claude-quota-guard.py --job com.shun.article-daily
│ クォータOK?
↓
run-and-notify.sh zenn "Zenn記事ストック生成"
│ Discord通知(開始)+終了後に結果通知
↓
article-daily-stock.sh apply
├─ orchestrate-codex-worker.sh task_01.md handoff_01.md status_01.md &
├─ orchestrate-codex-worker.sh task_02.md handoff_02.md status_02.md &
├─ orchestrate-codex-worker.sh task_03.md handoff_03.md status_03.md &
│ …(N並列)
└─ wait → handoff_*.md を集約 → レポート生成
The StartCalendarInterval in com.shun.article-daily.plist has two entries.
<key>StartCalendarInterval</key>
<array>
<dict>
<key>Hour</key><integer>8</integer>
<key>Minute</key><integer>0</integer>
</dict>
<dict>
<key>Hour</key><integer>10</integer>
<key>Minute</key><integer>35</integer>
</dict>
</array>
8:00 is the main first-thing-in-the-morning batch; 10:35 is a sub-batch for topping up the difference. The plist runs with LowPriorityIO: true and Nice: 10, so it doesn't load down the Mac while I'm working. ProcessType: Background is also specified, which lets the macOS scheduler run it at power-efficient moments.
claude-quota-guard.py sits at the head of the startup chain to pre-check remaining Claude API quota and abort immediately if there isn't enough. It prevents the situation where the batch starts and then runs out of quota halfway through.
Inside a single worker
orchestrate-codex-worker.sh <task-file> <handoff-file> <status-file>
│
├─ 引数チェック(3引数でなければ即exit)
├─ write_status "running" → status-file に書き込み
│
├─ mktemp prompt_file ← システムプロンプト構築
├─ mktemp output_file ← Codex出力の受け口
│
├─ codex exec -p yolo -m gpt-5.4 --color never \
│ -C "$(pwd)" -o output_file - < prompt_file
│
├─ 成功時 ─────────────────────────────────────
│ handoff-file に書き込み:
│ # Handoff
│ - Completed: <ISO8601タイムスタンプ>
│ - Branch: `<ブランチ名>`
│ - Worktree: `<絶対パス>`
│ <output_file の内容>
│ ## Git Status
│ <git status --short>
│ write_status "completed"
│
└─ 失敗時 ─────────────────────────────────────
handoff-file に失敗サマリを書き込み
write_status "failed"
exit 1
The argument count is fixed at three. A [[ $# -ne 3 ]] guard at the top of the script errors out immediately on either too few or too many. That simple constraint makes the wrapper shell easy to write.
The shape of the codex exec call
Here's the actual code.
if codex exec -p yolo -m gpt-5.4 --color never -C "$(pwd)" -o "$output_file" - < "$prompt_file"; then
Let's go through the flags one at a time.
-p yolo — sets prompt mode to yolo. No confirmation dialogs; file operations and command execution are all auto-approved. Mandatory for unattended batches.
-m gpt-5.4 — pins the model to GPT-5.4. For "mass-production" tasks like drafting articles or assembling image-generation prompts, this model is plenty, and it conserves Claude API quota.
--color never — emits no ANSI escape sequences. It keeps color codes from becoming noise when you later text-process log files or the handoff-file.
-C "$(pwd)" — explicitly sets the worker's working directory. Each worker is launched from the caller's worktree, so $(pwd) automatically resolves to that worktree's path.
-o "$output_file" — writes Codex's final output to a file. Separating it from stdout keeps the script's control-flow echoes from mixing with Codex output.
- < "$prompt_file" — passes the prompt via stdin. Writing it to a file and redirecting avoids shell quoting problems, so multi-line prompts with special characters are safe.
The system prompt handed to the worker
prompt_file is built as a heredoc inside the script.
cat > "$prompt_file" <<EOF
You are one worker in an ECC tmux/worktree swarm.
Rules:
- Work only in the current git worktree.
- Do not touch sibling worktrees or the parent repo checkout.
- Complete the task from the task file below.
- Do not spawn subagents or external agents for this task.
- Report progress and final results in stdout only.
- Do not write handoff or status files yourself; the launcher manages those artifacts.
- If you change code or docs, keep the scope narrow and defensible.
- In your final response, include exactly these sections:
1. Summary
2. Files Changed
3. Validation
4. Remaining Risks
Task file: $task_file
$(cat "$task_file")
EOF
What stands out is the explicit prohibition: "do not write the handoff or status files yourself." Managing output files is entirely orchestrate-codex-worker.sh's job. By design, the Codex worker concentrates purely on executing the task.
It also demands the four-section structure "Summary / Files Changed / Validation / Remaining Risks." That's a format contract for the downstream aggregation script that parses the handoff-file. Because what the worker outputs is decided up front, the aggregator only has to split on section headers — no cost of interpreting free-form LLM prose.
write_status — the skeleton of real-time monitoring
write_status() {
local state="$1"
local details="$2"
cat > "$status_file" <<EOF
# Status
- State: $state
- Updated: $(timestamp)
- Branch: $(git rev-parse --abbrev-ref HEAD)
- Worktree: \`$(pwd)\`
$details
EOF
}
write_status is called three times: "failed" when the task file can't be read, "running" at the start of execution, and then either "completed" or "failed" at the end depending on success or failure.
timestamp() returns UTC ISO8601 via date -u +"%Y-%m-%dT%H:%M:%SZ". It's pinned to UTC to avoid mixed time zones when cross-referencing logs from multiple workers.
Since it embeds the branch name via git rev-parse --abbrev-ref HEAD and the worktree path via $(pwd), just reading the status-file tells you instantly which worker is running on which branch in which worktree. That works as the identifier when N workers are running in parallel.
The cleanup implementation
prompt_file="$(mktemp)"
output_file="$(mktemp)"
cleanup() {
rm -f "$prompt_file" "$output_file"
}
trap cleanup EXIT
With trap cleanup EXIT, no temp files survive normal exit, error exit, or signal interruption. Ten parallel workers produce ten pairs of temp files, so thorough cleanup directly prevents /tmp from bloating.
set -euo pipefail is also at the top of the script. -e exits immediately when a command fails, -u errors on undefined variable references, and -o pipefail propagates errors from the middle of a pipeline. In an unattended batch, "swallow the error and keep going" is a breeding ground for bugs, so these three flags aren't optional.
Implementation details
The one line that auto-creates directories
Right after the argument check, before the script's main processing begins, there's this line.
mkdir -p "$(dirname "$handoff_file")" "$(dirname "$status_file")"
It's easy to overlook, but without it the script doesn't work. cat > "$handoff_file" fails immediately if the target file's parent directory doesn't exist. The caller (article-daily-stock.sh) assembles and passes the handoff-file and status-file paths, so the worker has no guarantee that the directory already exists.
The -p flag is an idempotent operation: do nothing if it exists, create it if it doesn't, create intermediate paths recursively if needed. Ten parallel workers can mkdir -p the same directory without conflict. Directory creation isn't atomic at the POSIX level, but since -p succeeds even when the directory already exists, concurrent execution by multiple processes is fine.
Putting $(dirname "$handoff_file") and $(dirname "$status_file") into the same mkdir -p rather than calling it twice is simply clearer. The fewer times an operation that mutates filesystem state appears in the code, the easier it is to track.
Checking the task file up front
if [[ ! -r "$task_file" ]]; then
write_status "failed" "- Error: task file is missing or unreadable (\`$task_file\`)"
{
echo "# Handoff"
echo
echo "- Failed: $(timestamp)"
echo "- Branch: \`$(git rev-parse --abbrev-ref HEAD)\`"
echo "- Worktree: \`$(pwd)\`"
echo
echo "Task file is missing or unreadable: \`$task_file\`"
} > "$handoff_file"
exit 1
fi
Because set -euo pipefail is in effect, the script would stop anyway if cat "$task_file" failed. The reason for putting this guard first is that it's the only moment where you can record the reason for the failure in the handoff-file.
If you only discover the file is unreadable after calling codex exec, all you get at that point is a nonzero exit code from codex exec — there's no place to record why it failed. Checking readability first lets you write causes like "the task file was corrupt" or "the path was wrong" explicitly into the handoff-file. When exactly one of ten parallel runs comes back failed, opening the handoff-file tells you immediately that "task_03.md couldn't be read." That's one fewer round trip in debugging.
[[ ! -r "$task_file" ]] uses -r (readable). If the file exists but you lack permission, it's treated the same way. That's more accurate than an existence check with -f.
Why the last line of the handoff-file is git status --short
On success, the handoff-file ends like this.
cat "$output_file"
echo
echo "## Git Status"
echo
git status --short
} > "$handoff_file"
git status --short is appended at the end to verify whether Codex actually changed files, no matter what it claims. Codex's final output (the contents of output_file) has a "Files Changed" section, but that's the LLM's self-report. git status --short doesn't lie.
The downstream aggregation script can read this ## Git Status section and decide whether changes actually happened. If the output is empty, "the task completed but no files changed"; if there are lines like M src/main.swift, "real work landed." That judgment is mechanical. It's an expression of the design principle of not trusting LLM output without a second opinion.
Building PATH in the plist
<key>EnvironmentVariables</key>
<dict>
<key>PATH</key>
<string>~/.nvm/versions/node/v24.13.0/bin:/opt/homebrew/bin:/opt/homebrew/sbin:/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin:~/.local/bin</string>
</dict>
launchd reads neither ~/.zshrc nor ~/.zprofile. It doesn't go through a login shell, so nvm initialization never runs. That means a process launched from launchd has only /usr/bin:/bin:/usr/sbin:/sbin in its default PATH, and ~/.nvm/versions/node/v24.13.0/bin/codex is completely invisible.
That's why PATH is written explicitly under EnvironmentVariables. Put the nvm-managed Node binary path first, follow it with the Homebrew paths, and close with the standard paths. The order matters.
Earlier entries win. Since codex lives under nvm, if you don't put that first, the system's older Node gets found instead. Placing /opt/homebrew/bin after nvm is for the same reason: giving Homebrew-installed tools priority over nvm breaks version management.
What RunAtLoad: false means
<key>RunAtLoad</key>
<false/>
Set it to true and the job runs every time the Mac boots. The article-generation batch makes API calls, so every boot burns API usage. Adding RunAtLoad: true on top of a StartCalendarInterval schedule can mean "once at boot + 8:00 + 10:35 = three runs" (behavior varies with the macOS version and boot timing). false is the intended setting.
Logs are split by StandardOutPath and StandardErrorPath into ~/.claude/logs/article-daily.out.log and ~/.claude/logs/article-daily.err.log respectively. stdout carries normal status messages while stderr carries errors and exceptional output, so monitoring just tail -f ~/.claude/logs/article-daily.err.log is enough to tell whether anything's wrong.
Where I got stuck
1. Launched from launchd, everything died with "codex: command not found"
The first time I wrote the plist and registered it with launchd, every worker ended in failed. The error log said only codex: command not found.
Running ~/.claude/scripts/article-daily-stock.sh apply by hand from a terminal worked fine. Launching from launchd didn't. The difference wasn't obvious to me at first.
The cause was PATH. In a terminal, ~/.zprofile is read, nvm is initialized, and codex is on the path. launchd runs in a completely separate process space, so none of that initialization happens.
The way to check is simple: make launchd dump the PATH it actually has into a log.
/usr/bin/env > ~/.claude/logs/env-dump.txt
Wedge a one-liner like that into the plist's ProgramArguments and run it, and launchd's whole environment lands in a file. When I actually checked, PATH contained only the four paths PATH=/usr/bin:/bin:/usr/sbin:/sbin.
The fix is just adding an EnvironmentVariables block to the plist. But it does require hardcoding the nvm version — the v24.13.0 part. Bump the Node version in nvm and you have to update the plist too. It's annoying, but as long as launchd can't dynamically initialize nvm, there's no way around it.
2. Forgetting --color never broke downstream aggregation
There was a time I specified -m gpt-5.4 but omitted --color never. On the console, Codex's output displayed nicely with colors and looked perfectly normal.
The problem showed up when the aggregation script ran. The script that parses the handoff-file and extracts the "Summary" section started returning mojibake-looking mystery strings.
The cause was ANSI escape sequences. Things like \e[32m (green) and \e[0m (reset) had been mixed into the handoff-file contents. When you search for a ## Summary header, the actual file contains something like \e[1m## Summary\e[0m, so a simple string match stops working.
cat handoff_01.md in a terminal renders the colors, so the problem is invisible. Only when I checked the bytes with cat -A handoff_01.md or od -c handoff_01.md | head did control characters like ^[[32m become visible.
The single flag --color never fixes it completely. Once output goes into a file, ANSI codes are all harm and no benefit. Since then, my rule when writing codex exec is that -o <file> and --color never always come as a set.
3. set -euo pipefail bit me somewhere I didn't expect
Because set -euo pipefail is enabled, any command in the script exiting nonzero terminates the whole script immediately. That's the intended behavior, but it bit me in a place I hadn't anticipated.
It was git rev-parse --abbrev-ref HEAD inside the write_status function. Calling it from a normal worktree is fine, but when I called it for testing from a directory outside a git repository, git rev-parse exited with an error. set -e caught that, write_status bailed out midway, and the status-file was left empty — followed by a cat failure. A chain reaction.
The symptoms were "the status-file stays at 0 bytes" and "the worker exits leaving no logs at all." The error log did contain fatal: not a git repository and the git rev-parse error, but the script that goes to read the status-file decided "the file is empty" and emitted a different error, so pinning down the root cause took a while.
In production the worker always runs inside a git worktree, so there's no real damage — but this is a class of bug you only find if you test in an environment close to production. Testing in some random local directory breaks the preconditions and produces an entirely different failure mode. Since then, I always verify script behavior inside a temporary worktree created with git worktree add.
4. Setting RunAtLoad: true billed me at Mac boot
I once updated the plist with RunAtLoad: true on an experimental basis. The goal was "I want to verify behavior immediately after registering with launchd." The idea being that the job runs the moment you launchctl load, making verification faster.
The problem was that I forgot to set it back to RunAtLoad: false. The next morning I rebooted the Mac, the article-generation batch ran right after boot, and then it ran again at 8:00. That's three runs in one day (boot, 8:00, 10:35). API consumption spiked, and I only noticed when three Discord notifications arrived.
Fixing it is just launchctl unload, set RunAtLoad back to false, and launchctl load again — but it took me two days to notice (on the day three Discord notifications arrived in a row, I thought "something's off").
I now operate under the rule "whenever you change the value of RunAtLoad, put a comment in the same commit." If the true is intentional, say so explicitly. Leave it unmarked and later you can't tell what was meant.
5. Backslashes vanished inside a heredoc
In the heredoc that builds the system prompt, an early version of the script had broken notation for wrapping the worktree path in backticks.
cat > "$prompt_file" <<EOF
- Worktree: `$(pwd)`
EOF
Run this and the backticks in the prompt get interpreted by the shell. $(pwd) expands inside the heredoc, but the backticks are also processed as command substitution, producing something like double expansion.
What I intended was "embed the worktree path in the prompt wrapped in backticks." I wanted to emit `/path/to/worktree` as a Markdown code span, but the backticks disappeared and only the bare path string remained.
The fix is one of two options: escape with \`, or quote the heredoc delimiter to stop variable expansion entirely. The script adopted the former — inserting backslashes selectively. In the current code, the \` notation is used when writing to the status file inside write_status.
- Worktree: \`$(pwd)\`
With this, $(pwd) is expanded by the shell into the path, and the backticks remain in the output as-is. Escaping inside a heredoc differs in places from the shell's ordinary quoting rules, so once you get stuck the cause is hard to see. Getting in the habit of piping it to echo and checking the output is the fastest route.
Gotchas
Beyond the five I covered narratively above, there are plenty of other holes I've fallen into in production. Here's an exhaustive list.
Cranking parallelism too high wiped out the whole batch on rate limits — I launched 10 tasks at once and multiple workers hit Codex's internal API rate limit and exited nonzero at nearly the same time, with
set -euo pipefailstopping every one of them instantly. Now I splitStartCalendarIntervalinto two entries at 8:00 and 10:35, which reduces the concurrent launches per run and levels out API load. The parallelism ceiling varies by API plan and model, so starting at 3–5 and watching is the safe side.waitwas swallowing the exit codes of failed workers — When the parent scriptwaits on multiple$!values (background PIDs) at once, Bash returns only the exit code of the last child to finish. With 9 successes and 1 failure,waitcan return0and the whole batch gets treated as "successful." To detect this properly, either collect codes individually withwait $pid; result=$?, or reliably grep forState: failedin the status-files after aggregation.Updated the plist but forgot
launchctl unload/load— Editing and saving the plist file directly does nothing on its own; launchd doesn't watch for changes the way inotify would. Only after runninglaunchctl unload ~/Library/LaunchAgents/com.shun.article-daily.plist && launchctl load ~/Library/LaunchAgents/com.shun.article-daily.plistdoes the new configuration take effect. Nine times out of ten, "I fixed it but the behavior didn't change" is this.Killing with
kill -9left temp files piling up in/tmp—trap cleanup EXITcatches SIGTERM and SIGINT, but SIGKILL has the kernel remove the process directly, so the trap never runs at all. As a rule, usekill -TERM <pid>when you force-stop something. If debris still accumulates, the practical answer is runningfind /tmp -name 'tmp.*' -mmin +60 -deleteperiodically as a separate launchd job.Task files had
\r(Windows line endings) mixed in — When you expand$(cat "$task_file")in the heredoc, a file with\rat line ends produces a prompt where every line handed to Codex is\r-terminated. It's invisible to a casual grep, and it surfaced only as "Codex is interpreting the instructions strangely." The safe move is confirming CRLF withfile task_01.mdand converting up front withsed -i '' 's/\r//' task_01.md.Omitting the branch on
git worktree addproduced a detached HEAD — Add a worktree without specifying a branch, as ingit worktree add /tmp/wt-01, and the worktree ends up in a detached HEAD state. Callinggit rev-parse --abbrev-ref HEADinsidewrite_statusin that state returns the stringHEAD. The aggregation script then treatsBranch: HEADin the status-file as a normal branch name, and the aggregate results come out wrong. The correct form is always cutting a branch:git worktree add -b worker-01 /tmp/wt-01.output_file ballooned to tens of MB and squeezed the disk — When Codex produces large volumes of output on a long task, the
$output_filecreated bymktempcan reach tens of MB. If 10 parallel workers hit that state at once,/tmpburns through hundreds of MB in one go.trap cleanup EXITremoves them so it isn't normally a problem, but if the next batch runs right after a SIGKILL stop, the debris adds up. The root fix is slicing task files finely into "one file = one unit of work" so the output volume per task stays controlled.article-daily.err.loggrew without bound — The plist'sStandardErrorPathdoes not rotate logs. Let a batch that runs twice every morning dump stderr freely and you're at hundreds of MB in a month. The easy remedy is a single line at the top ofarticle-daily-stock.shthat truncates it:> ~/.claude/logs/article-daily.err.log. You don't keep persistent logs, but for a "just check today's errors" workflow it's enough. If you need long-term retention, add it to yournewsyslogconfiguration.Mismatching the plist's
Labelvalue and the file name got launchd lost — I wrotecom.shun.article-dailyinLabelbut named the filearticle-daily.plist. It doesn't show up inlaunchctl list, and passing the correct Label tolaunchctl unloadreturns "not found." Follow the macOS convention of naming the file<Label>.plistand this problem is a one-time affair.A network drop left codex exec hanging and never returning — If the HTTP connection to the API is cut mid-flight,
codex execcan stay blocked with no timeout response. The parent script'swaitended up never returning. The workaround is applying a timeout from the outside at the call site, e.g.timeout 600 codex exec .... Designing tasks that exceed 600 seconds to be split up finely in the first place also lowers network-disconnect risk.I handed the same worktree path to multiple workers — During testing,
-C "$(pwd)"pointed at the same path for every worker, and 10 workers simultaneously rewrote the same files in the same worktree. Thegit status --shortresults got mixed together and it became impossible to trace which worker made which change after the fact. The one-worker-one-worktree principle has to be enforced not just by stating it in the prompt (Work only in the current git worktree), but structurally, by having the caller pass physically distinct worktree paths to-C.
Best practices
Here are 14 judgments accumulated from production use, along with the code that justifies them.
1. Never omit set -euo pipefail
This setting on line 1 of the script is the lifeline of an unattended batch. -e stops the moment any command exits nonzero, -u surfaces variable-name typos instantly, and -o pipefail keeps errors mid-pipeline from being swallowed. If a batch running 10-wide silently continues past errors, diagnosis cost multiplies by the number of jobs.
2. Put trap cleanup EXIT immediately after mktemp
prompt_file="$(mktemp)"
output_file="$(mktemp)"
cleanup() { rm -f "$prompt_file" "$output_file" }
trap cleanup EXIT
Files created with mktemp must be removed on every route: signal, error, and normal exit. Write the trap later and debris survives "the case where it errored out before the trap was registered." Placing it right after mktemp is the iron rule.
3. Write --color never and -o <file> as a set
Once codex exec output goes into a file, ANSI escape sequences are pure noise. Forget --color never and strings like \e[1m## Summary\e[0m contaminate the handoff-file, wiping out downstream section splitting. Treat the combination with -o "$output_file" as a single idiom so you never write just one of them.
4. write_status writes at exactly three points: start, success, failure
To make worker state readable from outside, it's important to narrow down when you write. This script has only three patterns: running (just before codex exec), completed (successful exit), and failed (error exit). Writing fine-grained progress introduces a race where the reader reads a file mid-write.
5. Put the task-file readability check before codex exec
The reason the [[ ! -r "$task_file" ]] check sits ahead of the codex exec call is that "this is the only timing at which you can write the failure reason into the handoff-file." Judge it later and no record of why it failed survives. When exactly one of ten parallel jobs comes back failed, you want to preserve the state where opening the handoff-file immediately tells you "the task file couldn't be read."
6. Always put the one mkdir -p line before any writes
mkdir -p "$(dirname "$handoff_file")" "$(dirname "$status_file")"
cat > "$handoff_file" fails immediately if the parent directory doesn't exist. Since the caller assembles and passes the handoff-file and status-file paths, the worker has no guarantee the directory exists beforehand. -p is an idempotent "do nothing if it exists" operation, so concurrent execution across 10 workers is safe.
7. Write PATH out in full in launchd's EnvironmentVariables
launchd reads neither ~/.zshrc nor ~/.zprofile. The bare launchd environment's PATH is only the four paths /usr/bin:/bin:/usr/sbin:/sbin. If you want to use the Node binary under nvm, spelling it out in the plist's EnvironmentVariables is the only option. Earlier paths win, so ordering it nvm → Homebrew → standard paths expresses your version-management priorities directly.
8. Default to RunAtLoad: false, and when you change it, leave the reason in the commit message
RunAtLoad: true has the convenience of letting you verify behavior immediately after registering with launchd, but in production it increases API consumption at Mac boot. Combined with StartCalendarInterval it can mean up to three runs: "boot + two scheduled times." false is the right default; when you switch it to true, writing "for testing — revert this" in the commit message lowers the risk of leaving it forgotten.
9. Get a second opinion on the LLM's self-report via git status --short
The design that puts git status --short output at the end of the handoff-file exists to verify what Codex wrote under "Files Changed." If the output is empty, "nothing changed"; if it's M src/main.swift, "real work landed." That judgment is mechanical. Reading git's state directly is faster, cheaper, and more honest than having a second AI interpret the first one's output.
10. Demand the four-section output structure in the prompt
1. Summary
2. Files Changed
3. Validation
4. Remaining Risks
Spelling this structure out in the prompt_file heredoc is a format contract with the aggregation script. Splitting mechanically on fixed section headers saves both tokens and cost compared to running Codex's free-form response through another LLM for interpretation. If "what is written where" is settled at the prompt stage, the downstream implementation is simple text processing.
11. Always verify behavior inside a temporary worktree created with git worktree add
Run a set -euo pipefail script under preconditions different from production (outside a git repo, detached HEAD, undefined variables) and it stops somewhere unexpected, making debugging hard. Creating a temporary worktree with git worktree add -b test-worker /tmp/wt-test and calling the worker from inside it is the verification method closest to production.
12. Split stderr and stdout log files, and monitor only err.log
Point the plist's StandardOutPath and StandardErrorPath at separate files and just watching tail -f ~/.claude/logs/article-daily.err.log lets you notice problems instantly. Write both to the same file and normal logs mix with errors, delaying discovery of anomalies. Being in a state where "if stderr is quiet, today's batch was fine" lowers the mental cost of unattended operation.
13. When you update the nvm version, fix the plist's EnvironmentVariables in the same motion
The path ~/.nvm/versions/node/v24.13.0/bin written in the plist becomes invalid the instant you bump the Node version in nvm. The most dangerous case is not noticing until the next morning's launchd job dies entirely with codex: command not found. The countermeasures are either building the plist update into your nvm upgrade procedure, or leveraging the ~/.nvm/alias/default symlink to structurally reduce the dependence on a pinned version.
14. Always state the working directory explicitly with -C "$(pwd)"
Without -C, codex exec runs in the caller's current directory. If parallel workers all run in the same CWD, or the parent script has cd'd somewhere, execution happens in an unintended worktree. Writing -C "$(pwd)" without fail lets the code enforce the fact that "this worker runs in this worktree."
Wrapping up
The design philosophy of orchestrate-codex-worker.sh in one line: make the worker single-purpose and push state out into files.
Running 10 tasks sequentially takes 900 seconds total; run them N-wide and it converges to the single slowest task plus startup overhead. What that parallelism requires isn't Kubernetes or Docker — it's three things: & (background launch), wait (synchronization), and cat > "$status_file" (externalized state). All of it fits in a 108-line shell script.
com.shun.article-daily.plist kicks off the chain at 8:00 and 10:35 every morning, and the whole sequence — quota guard, Discord notification, N parallel workers, result aggregation — runs to completion without human intervention. Before I even open my Mac, the article drafts are already done. The reality behind ¥1.2M in monthly revenue is the sum of how many of these "systems that run while I sleep" I've managed to stack up. Not talent, not volume — an accumulation of designs that push wait time toward zero is what ends up turning into revenue.
The full picture of the system, the breakdown of the ¥1.2M, and the 30-day procedure are collected in a paid note.
📕 Claude Code自律環境で、実際どう稼ぐか ― 仕組み・実例・始め方・サポート
Written by **Lily* — I ship iOS apps and automate my content stack with Claude Code.
Follow along: Portfolio · X · GitHub*
Top comments (0)