
Taskmaster
- 514 repo stars
- Updated March 11, 2026
- blader/taskmaster
Taskmaster is a Codex CLI wrapper and expect-PTY injector that keeps an agent working until it emits an explicit parseable done signal.
About
Taskmaster is a wrapper around the Codex CLI that keeps an agent working until it emits an explicit parseable done signal. It runs Codex with session recording, polls the log for task-complete events, and checks for a TASKMASTER_DONE token. When the token is missing it injects a follow-up message into the same running process over an expect PTY bridge, and stops when the token appears.
- Codex wrapper plus an expect-PTY injector that keeps work moving until a done signal
- Uses a parseable TASKMASTER_DONE::<session_id> completion token
- Polls the session log and auto-injects a follow-up when the token is missing
Taskmaster by the numbers
- Data as of Aug 4, 2026 (Skillselion catalog sync)
taskmaster capabilities & compatibility
- Capabilities
- orchestration · automation · agent continuation
- Use cases
- orchestration
- Platforms
- macOS · Linux
What taskmaster says it does
Codex wrapper plus same-process expect PTY injector that keeps work moving until an explicit parseable done signal is emitted.
Taskmaster for Codex uses session-log polling plus automatic continuation.
TASKMASTER_DONE::<session_id>
npx skills add https://github.com/blader/taskmaster --skill taskmasterAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| repo stars | ★ 514 |
|---|---|
| Last updated | March 11, 2026 |
| Repository | blader/taskmaster ↗ |
What it does
Keep a Codex CLI session running until it emits a parseable done token, auto-injecting continue prompts otherwise.
Who is it for?
Driving long-running Codex CLI tasks to a deterministic completion signal.
Skip if: Agents that already expose native writable stop hooks.
When should I use this skill?
You want a Codex session to continue automatically until an explicit, parseable completion marker is produced.
What you get
The agent keeps working with auto-injected follow-ups until it emits the parseable TASKMASTER_DONE token.
- A Codex run that continues until a parseable TASKMASTER_DONE token is emitted
By the numbers
- poll interval 1 second
- version 4.2.0
- done token prefix TASKMASTER_DONE
Files
Taskmaster
Taskmaster for Codex uses session-log polling plus automatic continuation. Codex TUI does not currently expose arbitrary writable stop hooks, so this skill implements the same completion contract externally.
How It Works
1. Run Codex via wrapper: run-taskmaster-codex.sh sets CODEX_TUI_RECORD_SESSION=1 and a log path. 2. Injector parses log events and checks completion on each task_complete event. 3. Parseable token contract: TASKMASTER_DONE::<session_id> 4. Token missing:
- inject follow-up user message into the same running process via
expect PTY bridge transport, using the shared compliance prompt. 5. Token present: no further injection.
Parseable Done Signal
When the work is genuinely complete, the agent must include this exact line in its final response (on its own line):
TASKMASTER_DONE::<session_id>This gives external automation a deterministic completion marker to parse.
Configuration
TASKMASTER_MAX(default0): max warning count before suppression in the
stop hook. 0 means unlimited warnings.
Fixed behavior (not configurable):
- Done token prefix:
TASKMASTER_DONE - Poll interval:
1second - Transport: expect only
- Expect payload mode and submit delay are fixed
Setup
Install and run:
bash ~/.codex/skills/taskmaster/install.sh
codex-taskmaster#!/usr/bin/env bash
#
# Taskmaster installer for Codex and Claude.
#
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
CODEX_ROOT="$HOME/.codex"
CLAUDE_ROOT="$HOME/.claude"
CODEX_SKILL_DIR="$CODEX_ROOT/skills/taskmaster"
CLAUDE_SKILL_DIR="$CLAUDE_ROOT/skills/taskmaster"
CODEX_VENDOR_BIN_DIR="$CODEX_ROOT/bin"
CODEX_BIN_DIR="${TASKMASTER_CODEX_BIN_DIR:-$HOME/.local/bin}"
CODEX_LAUNCHER_LINK="$CODEX_BIN_DIR/codex-taskmaster"
CODEX_SHIM_LINK="$CODEX_BIN_DIR/codex"
CODEX_LEGACY_LAUNCHER_LINK="$CODEX_VENDOR_BIN_DIR/codex-taskmaster"
CODEX_LEGACY_SHIM_LINK="$CODEX_VENDOR_BIN_DIR/codex"
SHELL_NAME="$(basename "${SHELL:-}")"
CLAUDE_HOOKS_DIR="$CLAUDE_ROOT/hooks"
CLAUDE_HOOK_LINK="$CLAUDE_HOOKS_DIR/taskmaster-check-completion.sh"
CLAUDE_SETTINGS_PATH="$CLAUDE_ROOT/settings.json"
CLAUDE_HOOK_COMMAND="~/.claude/hooks/taskmaster-check-completion.sh"
safe_copy() {
local src="$1"
local dst="$2"
if [[ "$(cd "$(dirname "$src")" && pwd)/$(basename "$src")" == "$(cd "$(dirname "$dst")" && pwd)/$(basename "$dst")" ]]; then
return 0
fi
cp "$src" "$dst"
}
copy_skill_files() {
local skill_dir="$1"
mkdir -p "$skill_dir/hooks"
mkdir -p "$skill_dir/docs"
safe_copy "$SCRIPT_DIR/SKILL.md" "$skill_dir/SKILL.md"
safe_copy "$SCRIPT_DIR/README.md" "$skill_dir/README.md"
safe_copy "$SCRIPT_DIR/LICENSE" "$skill_dir/LICENSE"
safe_copy "$SCRIPT_DIR/docs/SPEC.md" "$skill_dir/docs/SPEC.md"
safe_copy "$SCRIPT_DIR/install.sh" "$skill_dir/install.sh"
safe_copy "$SCRIPT_DIR/uninstall.sh" "$skill_dir/uninstall.sh"
safe_copy "$SCRIPT_DIR/taskmaster-compliance-prompt.sh" "$skill_dir/taskmaster-compliance-prompt.sh"
safe_copy "$SCRIPT_DIR/run-taskmaster-codex.sh" "$skill_dir/run-taskmaster-codex.sh"
safe_copy "$SCRIPT_DIR/check-completion.sh" "$skill_dir/check-completion.sh"
safe_copy "$SCRIPT_DIR/hooks/check-completion.sh" "$skill_dir/hooks/check-completion.sh"
safe_copy "$SCRIPT_DIR/hooks/inject-continue-codex.sh" "$skill_dir/hooks/inject-continue-codex.sh"
safe_copy "$SCRIPT_DIR/hooks/run-codex-expect-bridge.exp" "$skill_dir/hooks/run-codex-expect-bridge.exp"
safe_copy "$SCRIPT_DIR/hooks/run-codex-resume-bridge.exp" "$skill_dir/hooks/run-codex-resume-bridge.exp"
chmod +x "$skill_dir/install.sh"
chmod +x "$skill_dir/uninstall.sh"
chmod +x "$skill_dir/taskmaster-compliance-prompt.sh"
chmod +x "$skill_dir/run-taskmaster-codex.sh"
chmod +x "$skill_dir/check-completion.sh"
chmod +x "$skill_dir/hooks/check-completion.sh"
chmod +x "$skill_dir/hooks/inject-continue-codex.sh"
chmod +x "$skill_dir/hooks/run-codex-expect-bridge.exp"
chmod +x "$skill_dir/hooks/run-codex-resume-bridge.exp"
}
codex_detected() {
command -v codex >/dev/null 2>&1 || [[ -d "$CODEX_ROOT" ]]
}
claude_detected() {
command -v claude >/dev/null 2>&1 || [[ -d "$CLAUDE_ROOT" ]]
}
resolve_link_target() {
local link_path="$1"
local raw_target
local target_dir
raw_target="$(readlink "$link_path")"
if [[ "$raw_target" == /* ]]; then
printf '%s\n' "$raw_target"
return 0
fi
target_dir="$(cd "$(dirname "$link_path")" && cd "$(dirname "$raw_target")" && pwd)"
printf '%s/%s\n' "$target_dir" "$(basename "$raw_target")"
}
remove_taskmaster_link_if_present() {
local link_path="$1"
local resolved_target
[[ -L "$link_path" ]] || return 0
resolved_target="$(resolve_link_target "$link_path")"
case "$resolved_target" in
"$CODEX_SKILL_DIR/run-taskmaster-codex.sh"|"$CODEX_LAUNCHER_LINK"|"$CODEX_LEGACY_LAUNCHER_LINK")
rm -f "$link_path"
echo " Codex: removed Taskmaster-managed link at $link_path"
;;
esac
}
detect_shell_rc_path() {
case "$SHELL_NAME" in
zsh)
printf '%s\n' "$HOME/.zshrc"
;;
bash)
printf '%s\n' "$HOME/.bashrc"
;;
*)
return 1
;;
esac
}
ensure_shell_wrapper_block() {
local rc_path="$1"
local launcher_dir="$2"
if ! command -v python3 >/dev/null 2>&1; then
echo " Codex: python3 not found; add $launcher_dir to PATH manually" >&2
return 0
fi
python3 - "$rc_path" "$launcher_dir" <<'PY'
import os
import sys
rc_path = os.path.expanduser(sys.argv[1])
launcher_dir = os.path.expanduser(sys.argv[2])
start = "# TASKMASTER CODEX WRAPPER"
end = "# END TASKMASTER CODEX WRAPPER"
block = f"""{start}
taskmaster_codex_bin="{launcher_dir}"
case ":$PATH:" in
*":$taskmaster_codex_bin:"*) ;;
*) export PATH="$taskmaster_codex_bin:$PATH" ;;
esac
{end}
"""
try:
with open(rc_path, "r", encoding="utf-8") as f:
content = f.read()
except FileNotFoundError:
content = ""
if start in content and end in content:
before, rest = content.split(start, 1)
_, after = rest.split(end, 1)
new_content = before.rstrip() + "\n\n" + block + after.lstrip("\n")
else:
stripped = content.rstrip()
if stripped:
new_content = stripped + "\n\n" + block
else:
new_content = block
os.makedirs(os.path.dirname(rc_path), exist_ok=True)
with open(rc_path, "w", encoding="utf-8") as f:
f.write(new_content.rstrip() + "\n")
PY
echo " Codex: ensured $launcher_dir is early on PATH via $rc_path"
}
install_codex_shim_if_requested() {
if [[ "${TASKMASTER_INSTALL_CODEX_SHIM:-1}" != "1" ]]; then
remove_taskmaster_link_if_present "$CODEX_SHIM_LINK"
echo " Codex: leaving \`codex\` unmanaged; use \`codex-taskmaster\`"
return 0
fi
if [[ -e "$CODEX_SHIM_LINK" && ! -L "$CODEX_SHIM_LINK" ]]; then
echo " Codex: skipped shim at $CODEX_SHIM_LINK (existing file is not a symlink)"
return 0
fi
ln -sf "$CODEX_SKILL_DIR/run-taskmaster-codex.sh" "$CODEX_SHIM_LINK"
echo " Codex: linked codex shim at $CODEX_SHIM_LINK"
}
ensure_claude_stop_hook() {
local settings_path="$1"
local hook_command="$2"
if ! command -v python3 >/dev/null 2>&1; then
echo " Claude: python3 not found; add Stop hook manually -> $hook_command" >&2
return 0
fi
python3 - "$settings_path" "$hook_command" <<'PY'
import json
import os
import sys
settings_path = sys.argv[1]
hook_command = sys.argv[2]
if os.path.exists(settings_path):
try:
with open(settings_path, "r", encoding="utf-8") as f:
data = json.load(f)
except json.JSONDecodeError:
print(f" Claude: settings is not valid JSON ({settings_path}); add Stop hook manually.", file=sys.stderr)
sys.exit(0)
else:
data = {}
if not isinstance(data, dict):
print(f" Claude: settings root is not an object ({settings_path}); add Stop hook manually.", file=sys.stderr)
sys.exit(0)
container = None
if isinstance(data.get("hooks"), dict):
container = data["hooks"]
else:
container = data
stop_hooks = container.get("Stop")
if not isinstance(stop_hooks, list):
stop_hooks = []
container["Stop"] = stop_hooks
exists = False
for entry in stop_hooks:
if not isinstance(entry, dict):
continue
hooks = entry.get("hooks")
if not isinstance(hooks, list):
continue
for hook in hooks:
if not isinstance(hook, dict):
continue
if hook.get("type") == "command" and hook.get("command") == hook_command:
exists = True
break
if exists:
break
if not exists:
stop_hooks.append(
{
"matcher": "*",
"hooks": [
{
"type": "command",
"command": hook_command,
}
],
}
)
os.makedirs(os.path.dirname(settings_path), exist_ok=True)
with open(settings_path, "w", encoding="utf-8") as f:
json.dump(data, f, indent=2)
f.write("\n")
if exists:
print(" Claude: Stop hook already configured")
else:
print(" Claude: added Stop hook to settings")
PY
}
install_codex() {
local shell_rc_path=""
copy_skill_files "$CODEX_SKILL_DIR"
mkdir -p "$CODEX_BIN_DIR"
ln -sf "$CODEX_SKILL_DIR/run-taskmaster-codex.sh" "$CODEX_LAUNCHER_LINK"
install_codex_shim_if_requested
if [[ "$CODEX_BIN_DIR" != "$CODEX_VENDOR_BIN_DIR" ]]; then
remove_taskmaster_link_if_present "$CODEX_LEGACY_LAUNCHER_LINK"
remove_taskmaster_link_if_present "$CODEX_LEGACY_SHIM_LINK"
fi
echo " Codex: installed skill files to $CODEX_SKILL_DIR"
echo " Codex: linked launcher at $CODEX_LAUNCHER_LINK"
echo " Codex: launcher dir is user-managed so Codex upgrades should not overwrite it"
if [[ "${TASKMASTER_INSTALL_SHELL_WRAPPER:-1}" == "1" ]]; then
if shell_rc_path="$(detect_shell_rc_path)"; then
ensure_shell_wrapper_block "$shell_rc_path" "$CODEX_BIN_DIR"
else
echo " Codex: unsupported shell '$SHELL_NAME'; ensure $CODEX_BIN_DIR is ahead of the real Codex binary on PATH"
fi
fi
}
install_claude() {
copy_skill_files "$CLAUDE_SKILL_DIR"
mkdir -p "$CLAUDE_HOOKS_DIR"
ln -sf "$CLAUDE_SKILL_DIR/check-completion.sh" "$CLAUDE_HOOK_LINK"
ln -sf "$CLAUDE_SKILL_DIR/taskmaster-compliance-prompt.sh" "$CLAUDE_HOOKS_DIR/taskmaster-compliance-prompt.sh"
chmod +x "$CLAUDE_HOOK_LINK"
echo " Claude: installed skill files to $CLAUDE_SKILL_DIR"
echo " Claude: linked Stop hook at $CLAUDE_HOOK_LINK"
ensure_claude_stop_hook "$CLAUDE_SETTINGS_PATH" "$CLAUDE_HOOK_COMMAND"
}
INSTALL_TARGET="${TASKMASTER_INSTALL_TARGET:-auto}"
INSTALL_CODEX=0
INSTALL_CLAUDE=0
case "$INSTALL_TARGET" in
auto)
if codex_detected; then
INSTALL_CODEX=1
fi
if claude_detected; then
INSTALL_CLAUDE=1
fi
if [[ "$INSTALL_CODEX" -eq 0 && "$INSTALL_CLAUDE" -eq 0 ]]; then
INSTALL_CODEX=1
INSTALL_CLAUDE=1
echo "No Codex/Claude install detected; defaulting to both targets."
fi
;;
codex)
INSTALL_CODEX=1
;;
claude)
INSTALL_CLAUDE=1
;;
both)
INSTALL_CODEX=1
INSTALL_CLAUDE=1
;;
*)
echo "Invalid TASKMASTER_INSTALL_TARGET='$INSTALL_TARGET' (expected: auto|codex|claude|both)" >&2
exit 4
;;
esac
echo "Installing Taskmaster..."
if [[ "$INSTALL_CODEX" -eq 1 ]]; then
install_codex
fi
if [[ "$INSTALL_CLAUDE" -eq 1 ]]; then
install_claude
fi
echo ""
echo "Done."
if [[ "$INSTALL_CODEX" -eq 1 ]]; then
echo ""
echo "Codex usage:"
echo " codex [codex args]"
echo " codex-taskmaster [codex args]"
echo " Disable codex shim install: TASKMASTER_INSTALL_CODEX_SHIM=0 bash ~/.codex/skills/taskmaster/install.sh"
fi
if [[ "$INSTALL_CLAUDE" -eq 1 ]]; then
echo ""
echo "Claude usage:"
echo " Claude Stop hook is configured at $CLAUDE_HOOK_COMMAND"
fi
#!/usr/bin/env bash
#
# Run Codex with Taskmaster continuation transport.
# Default: same-process expect injection so the original TUI stays alive.
# Alternate transport: relaunch the same session with `codex resume`.
#
set -euo pipefail
SOURCE_PATH="${BASH_SOURCE[0]}"
while [[ -L "$SOURCE_PATH" ]]; do
SOURCE_DIR="$(cd -P "$(dirname "$SOURCE_PATH")" && pwd)"
SOURCE_PATH="$(readlink "$SOURCE_PATH")"
[[ "$SOURCE_PATH" != /* ]] && SOURCE_PATH="$SOURCE_DIR/$SOURCE_PATH"
done
SCRIPT_DIR="$(cd -P "$(dirname "$SOURCE_PATH")" && pwd)"
INJECTOR="$SCRIPT_DIR/hooks/inject-continue-codex.sh"
EXPECT_INJECT_BRIDGE="$SCRIPT_DIR/hooks/run-codex-expect-bridge.exp"
RESUME_BRIDGE="$SCRIPT_DIR/hooks/run-codex-resume-bridge.exp"
ORIGINAL_ARGS=("$@")
TASKMASTER_RESUME_EXIT_CODE=90
TASKMASTER_CODEX_BIN_DIR="${TASKMASTER_CODEX_BIN_DIR:-$HOME/.local/bin}"
resolve_real_codex_bin() {
<<<<<<< HEAD
local candidate
local wrapper_path="$SOURCE_PATH"
local wrapper_cmd="$TASKMASTER_CODEX_BIN_DIR/codex-taskmaster"
local codex_shim="$TASKMASTER_CODEX_BIN_DIR/codex"
local legacy_wrapper_cmd="$HOME/.codex/bin/codex-taskmaster"
local legacy_codex_shim="$HOME/.codex/bin/codex"
while IFS= read -r candidate; do
[[ -n "$candidate" ]] || continue
case "$candidate" in
"$wrapper_path"|"$wrapper_cmd"|"$codex_shim"|"$legacy_wrapper_cmd"|"$legacy_codex_shim")
=======
local candidate shebang
while IFS= read -r candidate; do
[[ -n "$candidate" && -x "$candidate" ]] || continue
# Skip any bash/sh script wrapper (taskmaster shim, superset wrapper, etc.)
shebang="$(head -c 128 "$candidate" 2>/dev/null || true)"
case "$shebang" in
"#!/bin/bash"*|"#!/usr/bin/env bash"*|"#!/bin/sh"*|"#!/usr/bin/env sh"*)
>>>>>>> efaa056 (chore(skills): auto-sync 2026-03-06T06:15:18Z)
continue
;;
esac
echo "$candidate"
return 0
done < <(which -a codex 2>/dev/null | awk '!seen[$0]++')
return 1
}
<<<<<<< HEAD
real_codex_requires_clean_path() {
case "$1" in
"$HOME"/.superset/bin/*|"$HOME"/.superset-*/bin/*)
return 0
;;
*)
return 1
;;
esac
}
strip_taskmaster_shims_from_path() {
local current_path="$1"
local path_entry
local filtered=()
local IFS=:
read -r -a path_parts <<< "$current_path"
for path_entry in "${path_parts[@]}"; do
[[ -n "$path_entry" ]] || continue
case "$path_entry" in
"$TASKMASTER_CODEX_BIN_DIR"|"$HOME"/.codex/bin|"$HOME"/.codex/tmp/arg0/*)
continue
;;
esac
filtered+=("$path_entry")
done
if [[ ${#filtered[@]} -eq 0 ]]; then
printf '%s\n' "$current_path"
return 0
fi
printf '%s\n' "$(IFS=:; echo "${filtered[*]}")"
}
=======
# Re-entry guard: if we've already been invoked (e.g. via superset wrapper → taskmaster → expect
# → superset wrapper → taskmaster again), skip ALL wrappers and exec the real binary directly.
if [[ "${__TASKMASTER_ACTIVE:-}" == "1" ]]; then
# Find the real codex binary by skipping all bash/sh script wrappers.
while IFS= read -r __tm_candidate; do
[[ -n "$__tm_candidate" && -x "$__tm_candidate" ]] || continue
__tm_shebang="$(head -c 128 "$__tm_candidate" 2>/dev/null || true)"
case "$__tm_shebang" in
"#!/bin/bash"*|"#!/usr/bin/env bash"*|"#!/bin/sh"*|"#!/usr/bin/env sh"*)
continue # Skip bash/sh wrapper scripts
;;
esac
exec "$__tm_candidate" "$@"
done < <(which -a codex 2>/dev/null | awk '!seen[$0]++')
echo "Could not resolve real codex binary on re-entry." >&2
exit 4
fi
export __TASKMASTER_ACTIVE=1
>>>>>>> efaa056 (chore(skills): auto-sync 2026-03-06T06:15:18Z)
if ! command -v codex >/dev/null 2>&1; then
echo "codex CLI not found in PATH." >&2
exit 4
fi
REAL_CODEX_BIN="${TASKMASTER_REAL_CODEX_BIN:-}"
# If the provided binary is a bash wrapper (e.g. superset shim), ignore it and resolve ourselves.
if [[ -n "$REAL_CODEX_BIN" && -x "$REAL_CODEX_BIN" ]]; then
__tm_shebang="$(head -c 128 "$REAL_CODEX_BIN" 2>/dev/null || true)"
case "$__tm_shebang" in
"#!/bin/bash"*|"#!/usr/bin/env bash"*|"#!/bin/sh"*|"#!/usr/bin/env sh"*)
REAL_CODEX_BIN=""
;;
esac
fi
if [[ -z "$REAL_CODEX_BIN" ]]; then
REAL_CODEX_BIN="$(resolve_real_codex_bin || true)"
fi
if [[ -z "$REAL_CODEX_BIN" ]] || [[ ! -x "$REAL_CODEX_BIN" ]]; then
echo "Could not resolve real codex binary. Set TASKMASTER_REAL_CODEX_BIN." >&2
exit 4
fi
REAL_CODEX_PATH="$PATH"
if real_codex_requires_clean_path "$REAL_CODEX_BIN"; then
REAL_CODEX_PATH="$(strip_taskmaster_shims_from_path "$PATH")"
fi
is_known_subcommand() {
case "$1" in
exec|e|review|login|logout|mcp|mcp-server|app-server|app|completion|sandbox|debug|apply|a|resume|fork|cloud|features|help)
return 0
;;
*)
return 1
;;
esac
}
# Pass through for non-interactive codex command families and generic help/version.
for arg in ${ORIGINAL_ARGS[@]+"${ORIGINAL_ARGS[@]}"}; do
case "$arg" in
-h|--help|-V|--version)
exec env PATH="$REAL_CODEX_PATH" "$REAL_CODEX_BIN" "${ORIGINAL_ARGS[@]}"
;;
esac
done
first_non_option=""
for arg in ${ORIGINAL_ARGS[@]+"${ORIGINAL_ARGS[@]}"}; do
if [[ "$arg" == "--" ]]; then
break
fi
if [[ "$arg" == -* ]]; then
continue
fi
first_non_option="$arg"
break
done
if [[ -n "$first_non_option" ]] && is_known_subcommand "$first_non_option"; then
exec env PATH="$REAL_CODEX_PATH" "$REAL_CODEX_BIN" "${ORIGINAL_ARGS[@]}"
fi
PASSTHROUGH_ARGS=()
while [[ $# -gt 0 ]]; do
case "$1" in
--)
shift
while [[ $# -gt 0 ]]; do
PASSTHROUGH_ARGS+=("$1")
shift
done
;;
*)
PASSTHROUGH_ARGS+=("$1")
shift
;;
esac
done
if [[ ! -x "$INJECTOR" ]]; then
echo "Missing executable injector script: $INJECTOR" >&2
exit 4
fi
if [[ ! -x "$EXPECT_INJECT_BRIDGE" ]]; then
echo "Missing executable expect bridge: $EXPECT_INJECT_BRIDGE" >&2
exit 4
fi
if [[ ! -x "$RESUME_BRIDGE" ]]; then
echo "Missing executable resume bridge: $RESUME_BRIDGE" >&2
exit 4
fi
if ! command -v expect >/dev/null 2>&1; then
echo "expect is required." >&2
exit 4
fi
if ! command -v jq >/dev/null 2>&1; then
echo "jq is required." >&2
exit 4
fi
build_log_path() {
local timestamp
timestamp="$(date -u +%Y%m%dT%H%M%SZ)-$$-1"
echo "$HOME/.codex/log/taskmaster-session-${timestamp}.jsonl"
}
prepare_log_env() {
local log_path="$1"
mkdir -p "$(dirname "$log_path")"
: > "$log_path"
export CODEX_TUI_RECORD_SESSION=1
export CODEX_TUI_SESSION_LOG_PATH="$log_path"
}
cleanup_background() {
local pid="$1"
if [[ -n "$pid" ]]; then
if kill -0 "$pid" >/dev/null 2>&1; then
kill "$pid" >/dev/null 2>&1 || true
fi
wait "$pid" >/dev/null 2>&1 || true
fi
}
codex_supports_interactive_resume() {
env PATH="$REAL_CODEX_PATH" "$REAL_CODEX_BIN" resume --help >/dev/null 2>&1
}
build_resume_passthrough_args() {
local token
local next_idx
local idx=0
RESUME_PASSTHROUGH_ARGS=()
while [[ $idx -lt ${#ORIGINAL_ARGS[@]} ]]; do
token="${ORIGINAL_ARGS[$idx]}"
case "$token" in
--)
break
;;
-c|--config|--enable|--disable|-i|--image|-m|--model|--local-provider|-p|--profile|-s|--sandbox|-a|--ask-for-approval|-C|--cd|--add-dir)
RESUME_PASSTHROUGH_ARGS+=("$token")
next_idx=$((idx + 1))
if [[ $next_idx -lt ${#ORIGINAL_ARGS[@]} ]]; then
RESUME_PASSTHROUGH_ARGS+=("${ORIGINAL_ARGS[$next_idx]}")
fi
idx=$((idx + 2))
;;
--config=*|--enable=*|--disable=*|--image=*|--model=*|--local-provider=*|--profile=*|--sandbox=*|--ask-for-approval=*|--cd=*|--add-dir=*)
RESUME_PASSTHROUGH_ARGS+=("$token")
idx=$((idx + 1))
;;
--oss|--full-auto|--dangerously-bypass-approvals-and-sandbox|--search|--no-alt-screen)
RESUME_PASSTHROUGH_ARGS+=("$token")
idx=$((idx + 1))
;;
-h|--help|-V|--version)
idx=$((idx + 1))
;;
-*)
# Preserve unknown standalone-looking flags until the initial prompt.
RESUME_PASSTHROUGH_ARGS+=("$token")
idx=$((idx + 1))
;;
*)
break
;;
esac
done
}
read_next_prompt_from_queue() {
local queue_dir="$1"
local prompt_file=""
while IFS= read -r prompt_file; do
[[ -n "$prompt_file" ]] || continue
break
done < <(find "$queue_dir" -maxdepth 1 -type f -name 'inject.*.txt' -print 2>/dev/null | LC_ALL=C sort)
if [[ -z "$prompt_file" ]]; then
return 1
fi
NEXT_QUEUE_PROMPT="$(cat "$prompt_file")"
rm -f "$prompt_file"
return 0
}
run_resume_mode() {
local log_path
local queue_dir
local injector_pid=""
local codex_exit=0
local session_id=""
local -a current_cmd=()
build_resume_passthrough_args
log_path="$(build_log_path)"
prepare_log_env "$log_path"
queue_dir="$(mktemp -d "${TMPDIR:-/tmp}/taskmaster-emit.XXXXXX")"
"$INJECTOR" \
--follow \
--log "$log_path" \
--emit-dir "$queue_dir" &
injector_pid="$!"
PATH="$REAL_CODEX_PATH"
export PATH
current_cmd=("$REAL_CODEX_BIN" "${PASSTHROUGH_ARGS[@]}")
while true; do
"$RESUME_BRIDGE" "$queue_dir" "${current_cmd[@]}" || codex_exit=$?
if [[ "$codex_exit" -ne "$TASKMASTER_RESUME_EXIT_CODE" ]]; then
break
fi
if ! read_next_prompt_from_queue "$queue_dir"; then
echo "Taskmaster resume transport requested relaunch without a queued prompt." >&2
codex_exit=4
break
fi
session_id="$(<"$queue_dir/session_id" 2>/dev/null || true)"
if [[ -z "$session_id" ]]; then
echo "Taskmaster resume transport could not determine the Codex session id." >&2
codex_exit=4
break
fi
current_cmd=("$REAL_CODEX_BIN" resume "${RESUME_PASSTHROUGH_ARGS[@]}")
current_cmd+=("$session_id")
current_cmd+=("$NEXT_QUEUE_PROMPT")
codex_exit=0
done
cleanup_background "$injector_pid"
rm -rf "$queue_dir"
return "$codex_exit"
}
run_expect_mode() {
local log_path
local queue_dir
local injector_pid=""
local codex_exit=0
log_path="$(build_log_path)"
prepare_log_env "$log_path"
queue_dir="$(mktemp -d "${TMPDIR:-/tmp}/taskmaster-emit.XXXXXX")"
"$INJECTOR" \
--follow \
--log "$log_path" \
--emit-dir "$queue_dir" &
injector_pid="$!"
PATH="$REAL_CODEX_PATH"
export PATH
if [[ ${#PASSTHROUGH_ARGS[@]} -gt 0 ]]; then
"$EXPECT_INJECT_BRIDGE" "$queue_dir" "$REAL_CODEX_BIN" "${PASSTHROUGH_ARGS[@]}" || codex_exit=$?
else
"$EXPECT_INJECT_BRIDGE" "$queue_dir" "$REAL_CODEX_BIN" || codex_exit=$?
fi
cleanup_background "$injector_pid"
rm -rf "$queue_dir"
return "$codex_exit"
}
TASKMASTER_CODEX_TRANSPORT="${TASKMASTER_CODEX_TRANSPORT:-expect}"
case "$TASKMASTER_CODEX_TRANSPORT" in
auto)
run_expect_mode
;;
resume)
if ! codex_supports_interactive_resume; then
echo "Codex CLI does not support interactive session resume on this version." >&2
exit 4
fi
run_resume_mode
;;
expect)
run_expect_mode
;;
*)
echo "Unknown TASKMASTER_CODEX_TRANSPORT: $TASKMASTER_CODEX_TRANSPORT" >&2
exit 4
;;
esac
exit $?
#!/usr/bin/env bash
#
# Stop hook: keep firing until the agent emits an explicit done signal.
#
# The stop is allowed only after the transcript contains:
# TASKMASTER_DONE::<session_id>
#
# Optional env vars:
# TASKMASTER_MAX Max number of blocks before allowing stop (default: 0 = infinite)
#
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
# shellcheck disable=SC1091
source "$SCRIPT_DIR/taskmaster-compliance-prompt.sh"
INPUT=$(cat)
SESSION_ID=$(echo "$INPUT" | jq -r '.session_id')
TRANSCRIPT=$(echo "$INPUT" | jq -r '.transcript_path')
# Expand leading ~ to $HOME (tilde not expanded inside quotes by bash)
TRANSCRIPT="${TRANSCRIPT/#\~/$HOME}"
if [ -z "$SESSION_ID" ] || [ "$SESSION_ID" = "null" ]; then
SESSION_ID="unknown-session"
fi
# --- skip subagents: they have very short transcripts ---
if [ -f "$TRANSCRIPT" ]; then
LINE_COUNT=$(wc -l < "$TRANSCRIPT" 2>/dev/null || echo "0")
if [ "$LINE_COUNT" -lt 20 ]; then
exit 0
fi
fi
# --- counter ---
COUNTER_DIR="${TMPDIR:-/tmp}/taskmaster"
mkdir -p "$COUNTER_DIR"
COUNTER_FILE="${COUNTER_DIR}/${SESSION_ID}"
MAX=${TASKMASTER_MAX:-0}
COUNT=0
if [ -f "$COUNTER_FILE" ]; then
COUNT=$(cat "$COUNTER_FILE" 2>/dev/null || echo "0")
fi
transcript_has_done_signal() {
local transcript_path="$1"
local done_signal="$2"
[ -f "$transcript_path" ] || return 1
tail -400 "$transcript_path" 2>/dev/null \
| jq -Rr '
fromjson?
| select(.type == "response_item" and .payload.type == "message" and .payload.role == "assistant")
| .payload.content[]?
| select(.type == "output_text")
| .text // empty
' 2>/dev/null \
| grep -Fq "$done_signal"
}
# --- done signal detection ---
DONE_SIGNAL="TASKMASTER_DONE::${SESSION_ID}"
HAS_DONE_SIGNAL=false
HAS_RECENT_ERRORS=false
# Check last_assistant_message first (available immediately, unlike transcript)
LAST_MSG=$(echo "$INPUT" | jq -r '.last_assistant_message // ""')
if echo "$LAST_MSG" | grep -Fq "$DONE_SIGNAL" 2>/dev/null; then
HAS_DONE_SIGNAL=true
fi
# Fall back to transcript search
if [ "$HAS_DONE_SIGNAL" = false ] && [ -f "$TRANSCRIPT" ]; then
if transcript_has_done_signal "$TRANSCRIPT" "$DONE_SIGNAL"; then
HAS_DONE_SIGNAL=true
fi
fi
if [ -f "$TRANSCRIPT" ]; then
TAIL_40=$(tail -40 "$TRANSCRIPT" 2>/dev/null || true)
if echo "$TAIL_40" | grep -qi '"is_error":\s*true' 2>/dev/null; then
HAS_RECENT_ERRORS=true
fi
fi
if [ "$HAS_DONE_SIGNAL" = true ]; then
rm -f "$COUNTER_FILE"
exit 0
fi
NEXT=$((COUNT + 1))
echo "$NEXT" > "$COUNTER_FILE"
# Optional escape hatch. Default is infinite (0) so hook keeps firing.
if [ "$MAX" -gt 0 ] && [ "$NEXT" -ge "$MAX" ]; then
rm -f "$COUNTER_FILE"
exit 0
fi
if [ "$HAS_RECENT_ERRORS" = true ]; then
PREAMBLE="Recent tool errors were detected. Resolve them before declaring done."
else
PREAMBLE="Stop is blocked until completion is explicitly confirmed."
fi
if [ "$MAX" -gt 0 ]; then
LABEL="TASKMASTER (${NEXT}/${MAX})"
else
LABEL="TASKMASTER (${NEXT})"
fi
# --- reprompt ---
SHARED_PROMPT="$(build_taskmaster_compliance_prompt "$DONE_SIGNAL")"
REASON="${LABEL}: ${PREAMBLE}
${SHARED_PROMPT}"
jq -n --arg reason "$REASON" '{ decision: "block", reason: $reason }'
Taskmaster
Product & Technical Specification
Version: 4.2.0 Scope:
taskmaster/check-completion.shtaskmaster/taskmaster-compliance-prompt.shtaskmaster/hooks/inject-continue-codex.shtaskmaster/hooks/run-codex-expect-bridge.exptaskmaster/run-taskmaster-codex.shtaskmaster/install.shtaskmaster/uninstall.sh
1. Goal
Prevent premature agent stopping and provide a deterministic, machine-parseable completion signal while remaining usable across long-lived Codex sessions.
Taskmaster enforces explicit completion through a done-token contract and continuation/hook feedback when that contract is not satisfied.
Both Codex and Claude paths consume the same shared compliance prompt text from taskmaster-compliance-prompt.sh.
2. Completion Contract
A turn is considered complete only when assistant output includes:
TASKMASTER_DONE::<session_id><session_id>is session-scoped.- The line must be emitted only when that turn's work is truly complete.
- Automation can parse this line as the authoritative completion marker for the
completed turn without disabling monitoring for later turns in the same Codex process.
3. Architecture
3.1 Codex Wrapper Path
run-taskmaster-codex.sh:
1. Resolves real Codex binary and enables session logging. 2. Starts queue-emitter injector (hooks/inject-continue-codex.sh). 3. Runs Codex in managed expect PTY (hooks/run-codex-expect-bridge.exp). 4. On incomplete turn (missing done token), injector emits continuation prompt files and expect bridge injects them into the same running process. 5. On complete turn (done token present), injector skips injection for that turn and keeps following the session log for subsequent turns. 6. Interactive codex resume ... launches stay on this managed path rather than bypassing Taskmaster as a direct passthrough.
3.2 Claude Stop-Hook Path
check-completion.sh:
1. Executes as Claude Stop hook command. 2. Verifies done token in session transcript. 3. If missing, returns a blocking decision with compliance instructions. 4. If present, allows stop.
3.3 Queue Emitter
hooks/inject-continue-codex.sh:
- Follows Codex session log.
- Handles
task_complete/turn_completeevents. - Dedupe by turn-id/signature.
- Writes continuation payloads as
inject.*.txtqueue files.
3.4 Expect Bridge
hooks/run-codex-expect-bridge.exp:
- Polls queue files.
- Injects payload into the same Codex PTY via bracketed paste.
- Submits prompt with Enter after fixed short delay.
4. Installation Behavior
install.sh auto-detects Codex and/or Claude and installs matching targets. uninstall.sh auto-detects and removes matching targets.
Override knobs:
TASKMASTER_INSTALL_TARGET=auto|codex|claude|bothTASKMASTER_UNINSTALL_TARGET=auto|codex|claude|both
5. Configuration
Configurable:
TASKMASTER_MAX(default0): warning cap in stop-hook checks.
Fixed:
- done token prefix:
TASKMASTER_DONE - poll interval:
1second - Codex transport: expect only
- expect payload mode + submit timing
6. Operational Notes
- Enforcement is same-process for Codex and stop-hook based for Claude.
- There is no standalone monitor-only mode in this design.
#!/usr/bin/env bash
#
# Stop hook: keep firing until the agent emits an explicit done signal.
#
# The stop is allowed only after the transcript contains:
# TASKMASTER_DONE::<session_id>
#
# Optional env vars:
# TASKMASTER_MAX Max number of blocks before allowing stop (default: 0 = infinite)
#
set -u
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
# shellcheck disable=SC1091
source "$SCRIPT_DIR/../taskmaster-compliance-prompt.sh"
INPUT=$(cat)
SESSION_ID=$(echo "$INPUT" | jq -r '.session_id')
TRANSCRIPT=$(echo "$INPUT" | jq -r '.transcript_path')
TRANSCRIPT="${TRANSCRIPT/#\~/$HOME}"
if [ -z "$SESSION_ID" ] || [ "$SESSION_ID" = "null" ]; then
SESSION_ID="unknown-session"
fi
# --- skip subagents: they have very short transcripts ---
if [ -f "$TRANSCRIPT" ]; then
LINE_COUNT=$(wc -l < "$TRANSCRIPT" 2>/dev/null || echo "0")
if [ "$LINE_COUNT" -lt 20 ]; then
exit 0
fi
fi
# --- counter ---
COUNTER_DIR="${TMPDIR:-/tmp}/taskmaster"
mkdir -p "$COUNTER_DIR"
COUNTER_FILE="${COUNTER_DIR}/${SESSION_ID}"
MAX=${TASKMASTER_MAX:-0}
COUNT=0
if [ -f "$COUNTER_FILE" ]; then
COUNT=$(cat "$COUNTER_FILE" 2>/dev/null || echo "0")
fi
transcript_has_done_signal() {
local transcript_path="$1"
local done_signal="$2"
[ -f "$transcript_path" ] || return 1
tail -400 "$transcript_path" 2>/dev/null \
| jq -Rr '
fromjson?
| select(.type == "response_item" and .payload.type == "message" and .payload.role == "assistant")
| .payload.content[]?
| select(.type == "output_text")
| .text // empty
' 2>/dev/null \
| grep -Fq "$done_signal"
}
# --- done signal detection ---
DONE_SIGNAL="TASKMASTER_DONE::${SESSION_ID}"
HAS_DONE_SIGNAL=false
HAS_RECENT_ERRORS=false
# Primary: check last_assistant_message (most reliable — no transcript parsing needed)
LAST_MSG=$(echo "$INPUT" | jq -r '.last_assistant_message // ""')
if [ -n "$LAST_MSG" ] && echo "$LAST_MSG" | grep -Fq "$DONE_SIGNAL" 2>/dev/null; then
HAS_DONE_SIGNAL=true
fi
# Fallback: check transcript file if last_assistant_message didn't match
if [ "$HAS_DONE_SIGNAL" = false ] && [ -f "$TRANSCRIPT" ]; then
if transcript_has_done_signal "$TRANSCRIPT" "$DONE_SIGNAL"; then
HAS_DONE_SIGNAL=true
fi
if tail -40 "$TRANSCRIPT" 2>/dev/null | grep -qi '"is_error":\s*true'; then
HAS_RECENT_ERRORS=true
fi
fi
if [ "$HAS_DONE_SIGNAL" = true ]; then
rm -f "$COUNTER_FILE"
exit 0
fi
NEXT=$((COUNT + 1))
echo "$NEXT" > "$COUNTER_FILE"
# Optional escape hatch. Default is infinite (0) so hook keeps firing.
if [ "$MAX" -gt 0 ] && [ "$NEXT" -ge "$MAX" ]; then
rm -f "$COUNTER_FILE"
exit 0
fi
if [ "$HAS_RECENT_ERRORS" = true ]; then
PREAMBLE="Recent tool errors were detected. Resolve them before declaring done."
else
PREAMBLE="Stop is blocked until completion is explicitly confirmed."
fi
if [ "$MAX" -gt 0 ]; then
LABEL="TASKMASTER (${NEXT}/${MAX})"
else
LABEL="TASKMASTER (${NEXT})"
fi
# --- reprompt ---
SHARED_PROMPT="$(build_taskmaster_compliance_prompt "$DONE_SIGNAL")"
REASON="${LABEL}: ${PREAMBLE}
${SHARED_PROMPT}"
jq -n --arg reason "$REASON" '{ decision: "block", reason: $reason }'
#!/usr/bin/env bash
#
# Codex Taskmaster same-process injector (queue-emitter mode).
# Watches a Codex session log and, on each incomplete task_complete/turn_complete,
# writes a continuation prompt file into the expect bridge queue.
#
# Exit codes:
# 0 = at least one done token observed
# 2 = completion(s) observed but no done token
# 3 = no completion events observed
# 4 = invalid usage / prerequisites
#
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
# shellcheck disable=SC1091
source "$SCRIPT_DIR/../taskmaster-compliance-prompt.sh"
usage() {
cat <<'USAGE'
Usage:
inject-continue-codex.sh --log <session_log.jsonl> --emit-dir <dir> [--state-dir <dir>] [--follow] [--follow-latest-dir <dir>] [--latest-glob <glob>] [--quiet]
Options:
--log <path> Path to CODEX_TUI_SESSION_LOG_PATH file.
--emit-dir <dir> Emit injection prompts as files in <dir>.
--state-dir <dir> Persist follow-state in <dir> so a restarted injector resumes cleanly.
--follow Follow live updates until session_end.
--follow-latest-dir <dir> While following, switch to the newest matching log in <dir>.
--latest-glob <glob> Glob used under --follow-latest-dir. Default: taskmaster-session-*.jsonl
--quiet Suppress non-error output.
-h, --help Show help.
USAGE
}
LOG_PATH="${CODEX_TUI_SESSION_LOG_PATH:-}"
EMIT_DIR=""
STATE_DIR=""
FOLLOW=0
QUIET=1
DONE_PREFIX="TASKMASTER_DONE"
POLL_INTERVAL="1"
FOLLOW_LATEST_DIR=""
LATEST_GLOB="taskmaster-session-*.jsonl"
while [[ $# -gt 0 ]]; do
case "$1" in
--log)
LOG_PATH="${2:-}"
shift 2
;;
--emit-dir)
EMIT_DIR="${2:-}"
shift 2
;;
--state-dir)
STATE_DIR="${2:-}"
shift 2
;;
--follow)
FOLLOW=1
shift
;;
--follow-latest-dir)
FOLLOW_LATEST_DIR="${2:-}"
shift 2
;;
--latest-glob)
LATEST_GLOB="${2:-}"
shift 2
;;
--quiet)
QUIET=1
shift
;;
-h|--help)
usage
exit 0
;;
*)
echo "Unknown argument: $1" >&2
usage
exit 4
;;
esac
done
if [[ -z "$LOG_PATH" ]]; then
echo "Missing --log (or CODEX_TUI_SESSION_LOG_PATH)." >&2
exit 4
fi
if [[ -z "$EMIT_DIR" ]]; then
echo "Missing --emit-dir." >&2
exit 4
fi
if ! command -v jq >/dev/null 2>&1; then
echo "jq is required." >&2
exit 4
fi
mkdir -p "$EMIT_DIR"
STATE_FILE=""
if [[ -n "$STATE_DIR" ]]; then
mkdir -p "$STATE_DIR"
STATE_FILE="$STATE_DIR/injector-state.env"
fi
RUNTIME_LOG="${TASKMASTER_RUNTIME_LOG:-}"
SESSION_ID=""
DONE_FOUND=0
SESSION_ENDED=0
TASK_COMPLETE_COUNT=0
INJECTION_COUNT=0
LAST_HANDLED_TURN_ID=""
LAST_HANDLED_SIG=""
CURRENT_LOG_PATH=""
OFFSET=0
PENDING_PARTIAL_LINE=""
log_runtime() {
local message="$1"
[[ -n "$RUNTIME_LOG" ]] || return 0
mkdir -p "$(dirname "$RUNTIME_LOG")"
printf '[%s] %s\n' "$(date -u +%Y-%m-%dT%H:%M:%SZ)" "$message" >>"$RUNTIME_LOG"
}
save_state() {
[[ -n "$STATE_FILE" ]] || return 0
# Best-effort: state dir may already be cleaned up by parent
[[ -d "$(dirname "$STATE_FILE")" ]] || return 0
cat >"$STATE_FILE" <<EOF
LOG_PATH=$(printf '%q' "$LOG_PATH")
CURRENT_LOG_PATH=$(printf '%q' "$CURRENT_LOG_PATH")
OFFSET=$(printf '%q' "$OFFSET")
SESSION_ID=$(printf '%q' "$SESSION_ID")
DONE_FOUND=$(printf '%q' "$DONE_FOUND")
SESSION_ENDED=$(printf '%q' "$SESSION_ENDED")
TASK_COMPLETE_COUNT=$(printf '%q' "$TASK_COMPLETE_COUNT")
INJECTION_COUNT=$(printf '%q' "$INJECTION_COUNT")
LAST_HANDLED_TURN_ID=$(printf '%q' "$LAST_HANDLED_TURN_ID")
LAST_HANDLED_SIG=$(printf '%q' "$LAST_HANDLED_SIG")
EOF
}
load_state() {
[[ -n "$STATE_FILE" && -f "$STATE_FILE" ]] || return 0
# shellcheck disable=SC1090
source "$STATE_FILE"
}
on_exit() {
local rc=$?
save_state 2>/dev/null || true
log_runtime "injector_exit rc=${rc} current_log=${CURRENT_LOG_PATH:-$LOG_PATH} session_id=${SESSION_ID:-} task_completes=${TASK_COMPLETE_COUNT} injections=${INJECTION_COUNT} session_ended=${SESSION_ENDED}" 2>/dev/null || true
}
trap on_exit EXIT
load_state
log_runtime "injector_start log=${LOG_PATH} follow=${FOLLOW} latest_dir=${FOLLOW_LATEST_DIR:-} state_file=${STATE_FILE:-}"
build_reprompt() {
local sid="$1"
local token
local shared_prompt
if [[ -n "$sid" && "$sid" != "null" ]]; then
token="${DONE_PREFIX}::${sid}"
else
token="${DONE_PREFIX}::<session_id>"
fi
shared_prompt="$(build_taskmaster_compliance_prompt "$token")"
cat <<RE-PROMPT
TASKMASTER: Stop is blocked until completion is explicitly confirmed.
${shared_prompt}
RE-PROMPT
}
is_done_text() {
local text="$1"
[[ -n "$text" ]] || return 1
if [[ -n "$SESSION_ID" ]]; then
[[ "$text" == *"${DONE_PREFIX}::${SESSION_ID}"* ]]
else
[[ "$text" == *"${DONE_PREFIX}::"* ]]
fi
}
clear_pending_prompts() {
rm -f "$EMIT_DIR"/inject.*.txt
}
mark_done() {
DONE_FOUND=1
}
inject_prompt() {
local turn_id="$1"
local sid_for_prompt="$2"
local prompt_file
local prompt
prompt="$(build_reprompt "$sid_for_prompt")"
prompt_file="$(mktemp "$EMIT_DIR/inject.XXXXXX")"
mv "$prompt_file" "$prompt_file.txt"
prompt_file="$prompt_file.txt"
printf '%s' "$prompt" > "$prompt_file"
INJECTION_COUNT=$((INJECTION_COUNT + 1))
log_runtime "queued continuation prompt turn=${turn_id:-<unknown>} count=${INJECTION_COUNT} file=${prompt_file}"
if [[ "$QUIET" -eq 0 ]]; then
echo "[TASKMASTER] queued continuation prompt for turn ${turn_id:-<unknown>} (count=${INJECTION_COUNT}, file=${prompt_file})." >&2
fi
}
process_line() {
local line="$1"
[[ -n "$line" ]] || return 0
local kind msg_type sid thread_id turn_id msg_text sig
kind="$(jq -Rr 'fromjson? | .kind // empty' <<<"$line" 2>/dev/null || true)"
[[ -n "$kind" ]] || return 0
case "$kind" in
codex_event)
msg_type="$(jq -Rr 'fromjson? | .payload.msg.type // empty' <<<"$line" 2>/dev/null || true)"
case "$msg_type" in
session_configured)
sid="$(jq -Rr 'fromjson? | .payload.msg.session_id // empty' <<<"$line" 2>/dev/null || true)"
if [[ -n "$sid" && "$sid" != "null" ]]; then
SESSION_ID="$sid"
[[ "$QUIET" -eq 1 ]] || echo "[TASKMASTER] attached to session $SESSION_ID" >&2
fi
;;
task_complete|turn_complete)
TASK_COMPLETE_COUNT=$((TASK_COMPLETE_COUNT + 1))
sid="$(jq -Rr 'fromjson? | .payload.msg.session_id // empty' <<<"$line" 2>/dev/null || true)"
thread_id="$(jq -Rr 'fromjson? | .payload.msg.thread_id // empty' <<<"$line" 2>/dev/null || true)"
turn_id="$(jq -Rr 'fromjson? | .payload.msg.turn_id // empty' <<<"$line" 2>/dev/null || true)"
msg_text="$(jq -Rr 'fromjson? | .payload.msg.last_agent_message // ""' <<<"$line" 2>/dev/null || true)"
if [[ -z "$SESSION_ID" ]]; then
if [[ -n "$sid" && "$sid" != "null" ]]; then
SESSION_ID="$sid"
elif [[ -n "$thread_id" && "$thread_id" != "null" ]]; then
SESSION_ID="$thread_id"
fi
fi
if [[ -n "$turn_id" && "$turn_id" == "$LAST_HANDLED_TURN_ID" ]]; then
return
fi
if [[ -z "$turn_id" ]]; then
sig="$(printf '%s' "$msg_text" | cksum | awk '{print $1":"$2}')"
if [[ -n "$sig" && "$sig" == "$LAST_HANDLED_SIG" ]]; then
return
fi
LAST_HANDLED_SIG="$sig"
else
LAST_HANDLED_TURN_ID="$turn_id"
fi
if is_done_text "$msg_text"; then
mark_done
[[ "$QUIET" -eq 1 ]] || echo "[TASKMASTER] done token detected; no injection for turn ${turn_id:-<unknown>}." >&2
else
inject_prompt "$turn_id" "$SESSION_ID"
fi
;;
esac
;;
session_end)
SESSION_ENDED=1
;;
esac
}
process_chunk() {
local chunk="$1"
local has_complete_tail="${2:-0}"
local combined_chunk
local line
local trailing_partial=""
combined_chunk="${PENDING_PARTIAL_LINE}${chunk}"
PENDING_PARTIAL_LINE=""
if [[ "$has_complete_tail" == "1" ]]; then
combined_chunk+=$'\n'
elif [[ "$combined_chunk" != *$'\n' ]]; then
trailing_partial="${combined_chunk##*$'\n'}"
if [[ "$combined_chunk" == "$trailing_partial" ]]; then
PENDING_PARTIAL_LINE="$combined_chunk"
return 0
fi
combined_chunk="${combined_chunk%$trailing_partial}"
PENDING_PARTIAL_LINE="$trailing_partial"
fi
while IFS= read -r line; do
process_line "$line" || true
done <<<"$combined_chunk"
}
latest_log_path() {
local dir="$1"
local glob="$2"
local latest=""
local expanded=()
shopt -s nullglob
expanded=("$dir"/$glob)
shopt -u nullglob
if [[ ${#expanded[@]} -eq 0 ]]; then
return 0
fi
latest="$(ls -t "${expanded[@]}" 2>/dev/null | head -n 1 || true)"
[[ -n "$latest" ]] && printf '%s\n' "$latest"
}
switch_log_if_needed() {
local latest=""
if [[ -z "$FOLLOW_LATEST_DIR" ]]; then
return 0
fi
latest="$(latest_log_path "$FOLLOW_LATEST_DIR" "$LATEST_GLOB")"
if [[ -z "$latest" ]]; then
return 0
fi
if [[ "$latest" == "$CURRENT_LOG_PATH" ]]; then
return 0
fi
CURRENT_LOG_PATH="$latest"
LOG_PATH="$latest"
OFFSET=0
SESSION_ID=""
SESSION_ENDED=0
LAST_HANDLED_TURN_ID=""
LAST_HANDLED_SIG=""
save_state
log_runtime "injector_switch_log log=${CURRENT_LOG_PATH}"
[[ "$QUIET" -eq 1 ]] || echo "[TASKMASTER] switched to latest session log: $CURRENT_LOG_PATH" >&2
}
if [[ "$FOLLOW" -eq 1 ]]; then
CURRENT_LOG_PATH="$LOG_PATH"
while [[ ! -f "$LOG_PATH" ]]; do
switch_log_if_needed
sleep "$POLL_INTERVAL"
done
elif [[ ! -f "$LOG_PATH" ]]; then
echo "Log path does not exist: $LOG_PATH" >&2
exit 4
fi
while true; do
switch_log_if_needed
if [[ ! -f "$LOG_PATH" ]]; then
if [[ "$FOLLOW" -eq 1 ]]; then
sleep "$POLL_INTERVAL"
continue
fi
echo "Log path does not exist: $LOG_PATH" >&2
exit 4
fi
local_size="$(wc -c <"$LOG_PATH" 2>/dev/null || echo 0)"
if [[ "$local_size" -lt "$OFFSET" ]]; then
OFFSET=0
fi
if [[ "$local_size" -gt "$OFFSET" ]]; then
chunk="$(tail -c +"$((OFFSET + 1))" "$LOG_PATH" 2>/dev/null || true)"
has_complete_tail=0
last_byte_hex="$(tail -c 1 "$LOG_PATH" 2>/dev/null | od -An -t x1 | tr -d '[:space:]')"
if [[ "$last_byte_hex" == "0a" ]]; then
has_complete_tail=1
fi
process_chunk "$chunk" "$has_complete_tail"
OFFSET="$local_size"
fi
if [[ "$FOLLOW" -eq 0 ]]; then
break
fi
if [[ -z "$FOLLOW_LATEST_DIR" && "$SESSION_ENDED" -eq 1 ]]; then
break
fi
sleep "$POLL_INTERVAL"
done
if [[ "$TASK_COMPLETE_COUNT" -eq 0 ]]; then
exit 3
fi
if [[ "$INJECTION_COUNT" -gt 0 ]]; then
exit 2
fi
if [[ "$DONE_FOUND" -eq 1 ]]; then
exit 0
fi
exit 2
#!/usr/bin/env expect
#
# Run Codex in a managed PTY and inject queued prompts from files.
#
# Usage:
# run-codex-expect-bridge.exp <queue_dir> <command> [args...]
#
set timeout -1
if {$argc < 2} {
puts stderr {Usage: run-codex-expect-bridge.exp <queue_dir> <command> [args...]}
exit 4
}
set queue_dir [lindex $argv 0]
set cmd [lrange $argv 1 end]
set submit_delay_ms 180
if {![file isdirectory $queue_dir]} {
file mkdir $queue_dir
}
proc clear_pending_injections {queue_dir} {
foreach f [glob -nocomplain -types f -- "$queue_dir/inject.*.txt"] {
file delete -force -- $f
}
}
proc send_injected_payload {payload submit_delay_ms} {
if {[string length $payload] > 0} {
# Always use bracketed paste framing so Codex treats this as paste,
# not as a rapid key burst where Enter can be interpreted as newline.
send -- "\033\[200~"
send -- $payload
send -- "\033\[201~"
}
if {$submit_delay_ms > 0} {
after $submit_delay_ms
}
send -- "\r"
}
proc inject_pending {queue_dir submit_delay_ms} {
set files [lsort [glob -nocomplain -types f -- "$queue_dir/inject.*.txt"]]
foreach f $files {
if {[catch {open $f r} fh]} {
continue
}
fconfigure $fh -encoding utf-8 -translation lf
set payload [read $fh]
close $fh
file delete -force -- $f
send_injected_payload $payload $submit_delay_ms
}
return 0
}
proc periodic_inject {queue_dir submit_delay_ms} {
if {[inject_pending $queue_dir $submit_delay_ms]} {
return
}
after 200 [list periodic_inject $queue_dir $submit_delay_ms]
}
spawn -noecho {*}$cmd
log_user 1
periodic_inject $queue_dir $submit_delay_ms
interact
set ws [wait]
if {[llength $ws] >= 4} {
set status [lindex $ws 3]
} else {
set status 0
}
exit $status
#!/usr/bin/env bash
#
# Taskmaster installer for Codex and Claude.
#
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
CODEX_ROOT="$HOME/.codex"
CLAUDE_ROOT="$HOME/.claude"
CODEX_SKILL_DIR="$CODEX_ROOT/skills/taskmaster"
CLAUDE_SKILL_DIR="$CLAUDE_ROOT/skills/taskmaster"
CODEX_BIN_DIR="$CODEX_ROOT/bin"
CODEX_LAUNCHER_LINK="$CODEX_BIN_DIR/codex-taskmaster"
CODEX_SHIM_LINK="$CODEX_BIN_DIR/codex"
SUPERSET_CODEX_WRAPPER="$HOME/.superset/bin/codex"
CLAUDE_HOOKS_DIR="$CLAUDE_ROOT/hooks"
CLAUDE_HOOK_LINK="$CLAUDE_HOOKS_DIR/taskmaster-check-completion.sh"
CLAUDE_SETTINGS_PATH="$CLAUDE_ROOT/settings.json"
CLAUDE_HOOK_COMMAND="~/.claude/hooks/taskmaster-check-completion.sh"
safe_copy() {
local src="$1"
local dst="$2"
local src_abs=""
local dst_abs=""
local dst_dir=""
src_abs="$(cd -P "$(dirname "$src")" && pwd)/$(basename "$src")"
dst_dir="$(dirname "$dst")"
mkdir -p "$dst_dir"
dst_abs="$(cd -P "$dst_dir" && pwd)/$(basename "$dst")"
if [[ "$src_abs" == "$dst_abs" ]]; then
return 0
fi
cp "$src" "$dst"
}
copy_skill_files() {
local skill_dir="$1"
mkdir -p "$skill_dir/hooks"
mkdir -p "$skill_dir/docs"
safe_copy "$SCRIPT_DIR/SKILL.md" "$skill_dir/SKILL.md"
safe_copy "$SCRIPT_DIR/README.md" "$skill_dir/README.md"
safe_copy "$SCRIPT_DIR/LICENSE" "$skill_dir/LICENSE"
safe_copy "$SCRIPT_DIR/docs/SPEC.md" "$skill_dir/docs/SPEC.md"
safe_copy "$SCRIPT_DIR/install.sh" "$skill_dir/install.sh"
safe_copy "$SCRIPT_DIR/uninstall.sh" "$skill_dir/uninstall.sh"
safe_copy "$SCRIPT_DIR/taskmaster-compliance-prompt.sh" "$skill_dir/taskmaster-compliance-prompt.sh"
safe_copy "$SCRIPT_DIR/run-taskmaster-codex.sh" "$skill_dir/run-taskmaster-codex.sh"
safe_copy "$SCRIPT_DIR/check-completion.sh" "$skill_dir/check-completion.sh"
safe_copy "$SCRIPT_DIR/hooks/check-completion.sh" "$skill_dir/hooks/check-completion.sh"
safe_copy "$SCRIPT_DIR/hooks/inject-continue-codex.sh" "$skill_dir/hooks/inject-continue-codex.sh"
safe_copy "$SCRIPT_DIR/hooks/run-codex-expect-bridge.exp" "$skill_dir/hooks/run-codex-expect-bridge.exp"
chmod +x "$skill_dir/install.sh"
chmod +x "$skill_dir/uninstall.sh"
chmod +x "$skill_dir/taskmaster-compliance-prompt.sh"
chmod +x "$skill_dir/run-taskmaster-codex.sh"
chmod +x "$skill_dir/check-completion.sh"
chmod +x "$skill_dir/hooks/check-completion.sh"
chmod +x "$skill_dir/hooks/inject-continue-codex.sh"
chmod +x "$skill_dir/hooks/run-codex-expect-bridge.exp"
}
codex_detected() {
command -v codex >/dev/null 2>&1 || [[ -d "$CODEX_ROOT" ]]
}
claude_detected() {
command -v claude >/dev/null 2>&1 || [[ -d "$CLAUDE_ROOT" ]]
}
ensure_claude_stop_hook() {
local settings_path="$1"
local hook_command="$2"
if ! command -v python3 >/dev/null 2>&1; then
echo " Claude: python3 not found; add Stop hook manually -> $hook_command" >&2
return 0
fi
python3 - "$settings_path" "$hook_command" <<'PY'
import json
import os
import sys
settings_path = sys.argv[1]
hook_command = sys.argv[2]
if os.path.exists(settings_path):
try:
with open(settings_path, "r", encoding="utf-8") as f:
data = json.load(f)
except json.JSONDecodeError:
print(f" Claude: settings is not valid JSON ({settings_path}); add Stop hook manually.", file=sys.stderr)
sys.exit(0)
else:
data = {}
if not isinstance(data, dict):
print(f" Claude: settings root is not an object ({settings_path}); add Stop hook manually.", file=sys.stderr)
sys.exit(0)
container = None
if isinstance(data.get("hooks"), dict):
container = data["hooks"]
else:
container = data
stop_hooks = container.get("Stop")
if not isinstance(stop_hooks, list):
stop_hooks = []
container["Stop"] = stop_hooks
exists = False
for entry in stop_hooks:
if not isinstance(entry, dict):
continue
hooks = entry.get("hooks")
if not isinstance(hooks, list):
continue
for hook in hooks:
if not isinstance(hook, dict):
continue
if hook.get("type") == "command" and hook.get("command") == hook_command:
exists = True
break
if exists:
break
if not exists:
stop_hooks.append(
{
"matcher": "*",
"hooks": [
{
"type": "command",
"command": hook_command,
}
],
}
)
os.makedirs(os.path.dirname(settings_path), exist_ok=True)
with open(settings_path, "w", encoding="utf-8") as f:
json.dump(data, f, indent=2)
f.write("\n")
if exists:
print(" Claude: Stop hook already configured")
else:
print(" Claude: added Stop hook to settings")
PY
}
ensure_superset_codex_prefers_taskmaster() {
local wrapper_path="$1"
if [[ ! -f "$wrapper_path" ]]; then
return 0
fi
if ! command -v python3 >/dev/null 2>&1; then
echo " Codex: python3 not found; update $wrapper_path manually to prefer codex-taskmaster" >&2
return 0
fi
python3 - "$wrapper_path" <<'PY'
from pathlib import Path
import sys
wrapper_path = Path(sys.argv[1]).expanduser()
text = wrapper_path.read_text(encoding="utf-8")
if "find_taskmaster_or_real_binary()" in text:
print(" Codex: Superset wrapper already prefers codex-taskmaster")
raise SystemExit(0)
needle = 'REAL_BIN="$(find_real_binary "codex")"'
if needle not in text:
print(f" Codex: Superset wrapper format not recognized; skipped {wrapper_path}", file=sys.stderr)
raise SystemExit(0)
replacement = """find_taskmaster_or_real_binary() {
local taskmaster_bin=""
taskmaster_bin="$(find_real_binary "codex-taskmaster" || true)"
if [ -n "$taskmaster_bin" ]; then
printf "%s\\n" "$taskmaster_bin"
return 0
fi
find_real_binary "codex"
}
REAL_BIN="$(find_taskmaster_or_real_binary)" """
text = text.replace(needle, replacement, 1)
text = text.replace(
"Superset: codex not found in PATH. Install it and ensure it is on PATH, then retry.",
"Superset: codex or codex-taskmaster not found in PATH. Install it and ensure it is on PATH, then retry.",
)
wrapper_path.write_text(text, encoding="utf-8")
print(" Codex: updated Superset wrapper to prefer codex-taskmaster")
PY
}
install_codex() {
copy_skill_files "$CODEX_SKILL_DIR"
mkdir -p "$CODEX_BIN_DIR"
ln -sf "$CODEX_SKILL_DIR/run-taskmaster-codex.sh" "$CODEX_LAUNCHER_LINK"
ln -sf "$CODEX_SKILL_DIR/run-taskmaster-codex.sh" "$CODEX_SHIM_LINK"
echo " Codex: installed skill files to $CODEX_SKILL_DIR"
echo " Codex: linked launcher at $CODEX_LAUNCHER_LINK"
echo " Codex: linked shim at $CODEX_SHIM_LINK"
ensure_superset_codex_prefers_taskmaster "$SUPERSET_CODEX_WRAPPER"
}
install_claude() {
copy_skill_files "$CLAUDE_SKILL_DIR"
mkdir -p "$CLAUDE_HOOKS_DIR"
ln -sf "$CLAUDE_SKILL_DIR/check-completion.sh" "$CLAUDE_HOOK_LINK"
ln -sf "$CLAUDE_SKILL_DIR/taskmaster-compliance-prompt.sh" "$CLAUDE_HOOKS_DIR/taskmaster-compliance-prompt.sh"
chmod +x "$CLAUDE_HOOK_LINK"
echo " Claude: installed skill files to $CLAUDE_SKILL_DIR"
echo " Claude: linked Stop hook at $CLAUDE_HOOK_LINK"
ensure_claude_stop_hook "$CLAUDE_SETTINGS_PATH" "$CLAUDE_HOOK_COMMAND"
}
INSTALL_TARGET="${TASKMASTER_INSTALL_TARGET:-auto}"
INSTALL_CODEX=0
INSTALL_CLAUDE=0
case "$INSTALL_TARGET" in
auto)
if codex_detected; then
INSTALL_CODEX=1
fi
if claude_detected; then
INSTALL_CLAUDE=1
fi
if [[ "$INSTALL_CODEX" -eq 0 && "$INSTALL_CLAUDE" -eq 0 ]]; then
INSTALL_CODEX=1
INSTALL_CLAUDE=1
echo "No Codex/Claude install detected; defaulting to both targets."
fi
;;
codex)
INSTALL_CODEX=1
;;
claude)
INSTALL_CLAUDE=1
;;
both)
INSTALL_CODEX=1
INSTALL_CLAUDE=1
;;
*)
echo "Invalid TASKMASTER_INSTALL_TARGET='$INSTALL_TARGET' (expected: auto|codex|claude|both)" >&2
exit 4
;;
esac
echo "Installing Taskmaster..."
if [[ "$INSTALL_CODEX" -eq 1 ]]; then
install_codex
fi
if [[ "$INSTALL_CLAUDE" -eq 1 ]]; then
install_claude
fi
echo ""
echo "Done."
if [[ "$INSTALL_CODEX" -eq 1 ]]; then
echo ""
echo "Codex usage:"
echo " codex [codex args]"
fi
if [[ "$INSTALL_CLAUDE" -eq 1 ]]; then
echo ""
echo "Claude usage:"
echo " Claude Stop hook is configured at $CLAUDE_HOOK_COMMAND"
fi
MIT License
Copyright (c) 2025
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
Taskmaster
Taskmaster is a completion guard for coding agents.
It addresses a common failure mode: the agent makes partial progress, writes a summary, and stops before the user goal is actually finished.
Philosophy
Taskmaster is built around one idea: progress is not completion.
- Evidence over narrative:
The agent should not be allowed to stop based on a convincing summary alone. Completion must be explicit and machine-checkable.
- Same-session recovery:
When a turn is incomplete, the right move is to continue in the same running session, not restart from scratch.
- Goal re-anchoring:
Compliance prompts force the model back to the user’s actual request, not its own local notion of “good enough”.
- Automation-safe signaling:
A deterministic done token makes completion parseable for wrappers and CI-style flows.
Core Contract
A run is complete only when the assistant emits:
TASKMASTER_DONE::<session_id>If that token is missing at stop time, Taskmaster blocks stop and pushes the current turn to continue. Codex monitoring stays active for later turns in the same long-lived session.
Enforcement Prompt
Taskmaster uses one shared compliance prompt for both Codex and Claude.
- Codex: the wrapper/injector path injects this shared prompt back into the
same running session when stop conditions are not met.
- Claude: the Stop hook returns this same shared prompt as the block reason.
The shared prompt source lives in taskmaster-compliance-prompt.sh.
How It Works
- Codex path:
- Runs through a wrapper (
codexshim /codex-taskmasterlauncher). - Enables Codex session logs.
- Watches
task_complete/turn_completeevents. - If done token is missing, injects a continuation prompt into the same
running Codex process via expect PTY.
- A done token suppresses injection for that completed turn only; it does
not permanently disable Taskmaster for future turns in the same session.
- Claude path:
- Registers a
Stopcommand hook. - Hook runs
check-completion.sh. - If done token is missing, the stop is blocked with corrective feedback.
Install
bash ~/.codex/skills/taskmaster/install.shAuto-detection behavior:
- Installs Codex integration when
codexor~/.codexexists. - Installs Claude integration when
claudeor~/.claudeexists. - If both are present, installs both.
- If neither is detected, defaults to both.
Optional target override:
TASKMASTER_INSTALL_TARGET=codex bash ~/.codex/skills/taskmaster/install.sh
TASKMASTER_INSTALL_TARGET=claude bash ~/.codex/skills/taskmaster/install.sh
TASKMASTER_INSTALL_TARGET=both bash ~/.codex/skills/taskmaster/install.shInstalled artifacts:
- Codex:
~/.codex/skills/taskmaster/~/.codex/bin/codex-taskmaster~/.codex/bin/codex(shim to Taskmaster wrapper)- Claude:
~/.claude/skills/taskmaster/~/.claude/hooks/taskmaster-check-completion.sh- Stop-hook entry added to
~/.claude/settings.json
Usage
Codex
Run normally:
codex [args]Explicit alias is also available:
codex-taskmaster [args]Interactive resume is also supported:
codex resume [session-or-thread]Claude
Run Claude normally after install. Taskmaster hook enforcement is automatic.
Configuration
TASKMASTER_MAX(default0):- Limits stop-block warnings in hook checks.
0means unlimited warnings.
Uninstall
bash ~/.codex/skills/taskmaster/uninstall.shAuto-detection behavior mirrors install and removes Taskmaster from detected Codex/Claude environments.
Optional target override:
TASKMASTER_UNINSTALL_TARGET=codex bash ~/.codex/skills/taskmaster/uninstall.sh
TASKMASTER_UNINSTALL_TARGET=claude bash ~/.codex/skills/taskmaster/uninstall.sh
TASKMASTER_UNINSTALL_TARGET=both bash ~/.codex/skills/taskmaster/uninstall.shRequirements
bashjq- Codex integration:
- Codex CLI
expect- Claude integration:
- Claude Code with
Stophooks enabled python3(for install/uninstall settings updates)
License
MIT
#!/usr/bin/env bash
#
# Run Codex with Taskmaster same-process continuation (expect transport).
#
set -euo pipefail
SOURCE_PATH="${BASH_SOURCE[0]}"
while [[ -L "$SOURCE_PATH" ]]; do
SOURCE_DIR="$(cd -P "$(dirname "$SOURCE_PATH")" && pwd)"
SOURCE_PATH="$(readlink "$SOURCE_PATH")"
[[ "$SOURCE_PATH" != /* ]] && SOURCE_PATH="$SOURCE_DIR/$SOURCE_PATH"
done
SCRIPT_DIR="$(cd -P "$(dirname "$SOURCE_PATH")" && pwd)"
INJECTOR="$SCRIPT_DIR/hooks/inject-continue-codex.sh"
EXPECT_BRIDGE="$SCRIPT_DIR/hooks/run-codex-expect-bridge.exp"
ORIGINAL_ARGS=("$@")
resolve_real_codex_bin() {
local candidate
local wrapper_path="$SOURCE_PATH"
local wrapper_cmd="$HOME/.codex/bin/codex-taskmaster"
local codex_shim="$HOME/.codex/bin/codex"
while IFS= read -r candidate; do
[[ -n "$candidate" ]] || continue
case "$candidate" in
"$wrapper_path"|"$wrapper_cmd"|"$codex_shim")
continue
;;
esac
# Skip shell script wrappers (superset, etc.) — only accept real binaries
if head -c 2 "$candidate" 2>/dev/null | grep -q '^#!'; then
continue
fi
echo "$candidate"
return 0
done < <(which -a codex 2>/dev/null | awk '!seen[$0]++')
return 1
}
if ! command -v codex >/dev/null 2>&1; then
echo "codex CLI not found in PATH." >&2
exit 4
fi
REAL_CODEX_BIN="${TASKMASTER_REAL_CODEX_BIN:-}"
if [[ -z "$REAL_CODEX_BIN" ]]; then
REAL_CODEX_BIN="$(resolve_real_codex_bin || true)"
fi
if [[ -z "$REAL_CODEX_BIN" ]] || [[ ! -x "$REAL_CODEX_BIN" ]]; then
echo "Could not resolve real codex binary. Set TASKMASTER_REAL_CODEX_BIN." >&2
exit 4
fi
is_known_subcommand() {
case "$1" in
exec|e|review|login|logout|mcp|mcp-server|app-server|app|completion|sandbox|debug|apply|a|fork|cloud|features|help)
return 0
;;
*)
return 1
;;
esac
}
# Pass through for non-interactive codex command families and generic help/version.
for arg in ${ORIGINAL_ARGS[@]+"${ORIGINAL_ARGS[@]}"}; do
case "$arg" in
-h|--help|-V|--version)
exec "$REAL_CODEX_BIN" "${ORIGINAL_ARGS[@]}"
;;
esac
done
first_non_option=""
for arg in ${ORIGINAL_ARGS[@]+"${ORIGINAL_ARGS[@]}"}; do
if [[ "$arg" == "--" ]]; then
break
fi
if [[ "$arg" == -* ]]; then
continue
fi
first_non_option="$arg"
break
done
if [[ -n "$first_non_option" ]] && is_known_subcommand "$first_non_option"; then
exec "$REAL_CODEX_BIN" "${ORIGINAL_ARGS[@]}"
fi
PASSTHROUGH_ARGS=()
while [[ $# -gt 0 ]]; do
case "$1" in
--)
shift
while [[ $# -gt 0 ]]; do
PASSTHROUGH_ARGS+=("$1")
shift
done
;;
*)
PASSTHROUGH_ARGS+=("$1")
shift
;;
esac
done
if [[ ! -x "$INJECTOR" ]]; then
echo "Missing executable injector script: $INJECTOR" >&2
exit 4
fi
if [[ ! -x "$EXPECT_BRIDGE" ]]; then
echo "Missing executable expect bridge: $EXPECT_BRIDGE" >&2
exit 4
fi
if ! command -v expect >/dev/null 2>&1; then
echo "expect is required." >&2
exit 4
fi
if ! command -v jq >/dev/null 2>&1; then
echo "jq is required." >&2
exit 4
fi
build_log_path() {
local timestamp
timestamp="$(date -u +%Y%m%dT%H%M%SZ)-$$-1"
echo "$HOME/.codex/log/taskmaster-session-${timestamp}.jsonl"
}
prepare_log_env() {
local log_path="$1"
mkdir -p "$(dirname "$log_path")"
: > "$log_path"
export CODEX_TUI_RECORD_SESSION=1
export CODEX_TUI_SESSION_LOG_PATH="$log_path"
}
cleanup_background() {
local pid="$1"
if [[ -n "$pid" ]]; then
if kill -0 "$pid" >/dev/null 2>&1; then
kill "$pid" >/dev/null 2>&1 || true
fi
wait "$pid" >/dev/null 2>&1 || true
fi
}
run_injector_supervisor() {
local log_path="$1"
local queue_dir="$2"
local state_dir="$3"
local stop_file="$4"
local runtime_log="$5"
local status=0
while [[ ! -f "$stop_file" ]]; do
TASKMASTER_RUNTIME_LOG="$runtime_log" "$INJECTOR" \
--follow \
--log "$log_path" \
--emit-dir "$queue_dir" \
--state-dir "$state_dir" || status=$?
if [[ -f "$stop_file" ]]; then
break
fi
printf '[%s] supervisor_restart injector_status=%s log=%s\n' \
"$(date -u +%Y-%m-%dT%H:%M:%SZ)" "$status" "$log_path" >>"$runtime_log"
sleep 1
done
}
run_expect_mode() {
local log_path
local queue_dir
local state_dir
local stop_file
local runtime_log
local injector_pid=""
local codex_exit=0
log_path="$(build_log_path)"
prepare_log_env "$log_path"
queue_dir="$(mktemp -d "${TMPDIR:-/tmp}/taskmaster-emit.XXXXXX")"
state_dir="$(mktemp -d "${TMPDIR:-/tmp}/taskmaster-state.XXXXXX")"
stop_file="$state_dir/stop"
runtime_log="$state_dir/runtime.log"
run_injector_supervisor "$log_path" "$queue_dir" "$state_dir" "$stop_file" "$runtime_log" &
injector_pid="$!"
# Details written to runtime_log; no stderr noise on startup
if [[ ${#PASSTHROUGH_ARGS[@]} -gt 0 ]]; then
"$EXPECT_BRIDGE" "$queue_dir" "$REAL_CODEX_BIN" "${PASSTHROUGH_ARGS[@]}" || codex_exit=$?
else
"$EXPECT_BRIDGE" "$queue_dir" "$REAL_CODEX_BIN" || codex_exit=$?
fi
: > "$stop_file"
cleanup_background "$injector_pid"
rm -rf "$queue_dir"
rm -rf "$state_dir"
return "$codex_exit"
}
run_expect_mode
exit $?
#!/usr/bin/env bash
#
# Shared Taskmaster compliance prompt text.
#
build_taskmaster_compliance_prompt() {
local done_signal="$1"
cat <<EOF2
Before stopping, do each of these checks:
1. GOAL CONFRONTATION (do this FIRST — before any other check). Answer these questions explicitly in your response. Do not skip or rephrase them:
a. What is the user's stated goal or success criterion? Write it out verbatim.
b. Is it achieved RIGHT NOW? Answer "yes" or "no". Not "partially", not "mostly", not "significant progress was made". Yes or no.
c. If no: you are NOT DONE. Do not proceed to emit the done signal. Go do more work. The ONLY exception is if the user explicitly told you to stop or deprioritized the goal. There is no other valid reason to stop. Not "hard technical constraint", not "tried N approaches" — if the goal is not met and the user has not told you to stop, keep working.
"Diminishing returns", "distributed profile", "no single dominant hotspot", "would require broader architectural changes", or any variation of "I made good progress" are NOT valid reasons to stop. These are rationalizations. The user asked for the goal, not for progress toward it.
2. RE-READ THE ORIGINAL USER MESSAGE(S). List every discrete request or acceptance criterion. For each one, confirm it is fully addressed — not just started, FULLY done. If the user explicitly changed their mind, withdrew a request, or told you to stop or skip something, treat that item as resolved and do NOT continue working on it.
3. CHECK THE TASK LIST. Review every task. Any task not marked completed? Do it now — unless the user indicated it is no longer wanted.
4. CHECK THE PLAN. Walk through each step — INCLUDING verification steps. Any step skipped or partially done? Finish it — unless the user redirected or deprioritized it. If the plan includes ANY verification steps (builds, tests, lints, type-checks, manual validation, smoke tests, functional tests, or any other form of verification), you MUST actually execute them and see them pass — do not skip them or just claim they pass.
5. CHECK FOR ERRORS OR INCOMPLETE WORK. Did anything fail or remain unfinished? Fix it. This applies to ALL types of problems — logic errors, missing functionality, incomplete refactors, broken scripts, configuration issues, or anything else that prevents the work from being fully done.
6. CHECK FOR LOOSE ENDS. Any TODO comments, placeholder code, missing tests, untested changes, or follow-ups noted but not acted on?
7. CHECK FOR BLOCKERS. If something is blocking you, do NOT give up. You are a world-class engineer with access to a full development environment, a terminal, every tool you need, and all the information on the internet. If it does not violate the laws of physics, it is within your ability to solve it. Try a different approach, read more code, search for examples, re-examine your assumptions. Never declare something a blocker and stop. Solve it. This includes pre-existing bugs, broken tests, missing data, infrastructure issues, or anything else in the environment that prevents your work from being validated. "I didn't cause this bug" is not an excuse to stop — if it blocks your task, fix it. You own the outcome, not just your diff.
IMPORTANT: The user's latest instructions always take priority. If the user said to stop, move on, or skip something, respect that — do not force completion of work the user no longer wants.
DO NOT NARRATE — EXECUTE: If any incomplete work remains, your ONLY job is to DO that work right now. Do NOT respond by explaining what the remaining tasks are, describing their complexity, listing their dependencies, or analyzing how difficult they will be. Do NOT ask the user for permission or direction to proceed. Do NOT write summaries of what is left. Just DO the work. The user asked you to do it — that IS your direction. Every sentence you spend describing remaining work instead of doing it is wasted. Open files, write code, run commands, fix bugs. Act.
PROGRESS IS NOT COMPLETION: Making progress toward a goal is not the same as achieving it. "I improved X from 1345ms to 866ms" does not satisfy a goal of "<500ms". Describing remaining work with phrases like "would require deeper analysis" or "needs broader architectural changes" is narrating — not doing. If the goal is not met, your job is to keep working, not to write a summary of why the remaining work is hard.
HONESTY CHECK: Before marking anything as "not possible" or "skipped", ask yourself: did you actually TRY, or are you rationalizing skipping it because it seems hard or inconvenient? "I can't do X" is almost never true — what you mean is "I haven't tried X yet." If you haven't attempted something, you don't get to claim it's impossible. Attempt it first.
When and only when everything is genuinely 100% done (or explicitly deprioritized by the user), include this exact line in your final response on its own line:
${done_signal}
Do NOT emit that done signal early. If any work remains, continue working now.
EOF2
}
#!/usr/bin/env bash
set -euo pipefail
TEST_TMPDIR="$(mktemp -d "${TMPDIR:-/tmp}/taskmaster-inject-test.XXXXXX")"
trap 'rm -rf "$TEST_TMPDIR"' EXIT
INJECTOR="/Users/blader/.codex/skills/taskmaster/hooks/inject-continue-codex.sh"
LOG_PATH="$TEST_TMPDIR/session.jsonl"
EMIT_DIR="$TEST_TMPDIR/emit"
mkdir -p "$EMIT_DIR"
cat > "$LOG_PATH" <<'EOF'
{"kind":"codex_event","payload":{"msg":{"type":"session_configured","session_id":"session-123"}}}
{"kind":"codex_event","payload":{"msg":{"type":"task_complete","turn_id":"turn-1","last_agent_message":"Work is incomplete."}}}
{"kind":"session_end"}
EOF
set +e
"$INJECTOR" --log "$LOG_PATH" --emit-dir "$EMIT_DIR"
status_missing_done=$?
set -e
if [[ "$status_missing_done" -ne 2 ]]; then
printf 'expected missing-done final_answer case to exit 2, got %q\n' "$status_missing_done" >&2
exit 1
fi
emit_count="$(find "$EMIT_DIR" -type f | wc -l | tr -d ' ')"
if [[ "$emit_count" -ne 1 ]]; then
printf 'expected one queued continuation prompt, found %q\n' "$emit_count" >&2
exit 1
fi
rm -f "$EMIT_DIR"/*
cat > "$LOG_PATH" <<'EOF'
{"kind":"codex_event","payload":{"msg":{"type":"session_configured","session_id":"session-123"}}}
{"kind":"codex_event","payload":{"msg":{"type":"task_complete","turn_id":"turn-2","last_agent_message":"Done.\nTASKMASTER_DONE::session-123"}}}
{"kind":"session_end"}
EOF
set +e
"$INJECTOR" --log "$LOG_PATH" --emit-dir "$EMIT_DIR"
status_with_done=$?
set -e
if [[ "$status_with_done" -ne 0 ]]; then
printf 'expected done-token final_answer case to exit 0, got %q\n' "$status_with_done" >&2
exit 1
fi
emit_count="$(find "$EMIT_DIR" -type f | wc -l | tr -d ' ')"
if [[ "$emit_count" -ne 0 ]]; then
printf 'expected no queued continuation prompt for done-token case, found %q\n' "$emit_count" >&2
exit 1
fi
cat > "$LOG_PATH" <<'EOF'
{"kind":"codex_event","payload":{"msg":{"type":"session_configured","session_id":"session-123"}}}
{"kind":"codex_event","payload":{"msg":{"type":"task_complete","turn_id":"turn-blank","last_agent_message":"Done.\nTASKMASTER_DONE::session-123"}}}
{"kind":"session_end"}
EOF
rm -f "$EMIT_DIR"/*
set +e
"$INJECTOR" --log "$LOG_PATH" --emit-dir "$EMIT_DIR"
status_with_blank_line=$?
set -e
if [[ "$status_with_blank_line" -ne 0 ]]; then
printf 'expected blank-line log with done token to exit 0, got %q\n' "$status_with_blank_line" >&2
exit 1
fi
emit_count="$(find "$EMIT_DIR" -type f | wc -l | tr -d ' ')"
if [[ "$emit_count" -ne 0 ]]; then
printf 'expected no queued continuation prompt for blank-line done-token case, found %q\n' "$emit_count" >&2
exit 1
fi
cat > "$LOG_PATH" <<'EOF'
{"kind":"codex_event","payload":{"msg":{"type":"session_configured","session_id":"session-123"}}}
{"kind":"codex_event","payload":{"msg":{"type":"task_complete","turn_id":"turn-1","last_agent_message":"Needs more work."}}}
{"kind":"codex_event","payload":{"msg":{"type":"task_complete","turn_id":"turn-2","last_agent_message":"Done.\nTASKMASTER_DONE::session-123"}}}
{"kind":"codex_event","payload":{"msg":{"type":"task_complete","turn_id":"turn-3","last_agent_message":"A later turn is incomplete again."}}}
{"kind":"session_end"}
EOF
rm -f "$EMIT_DIR"/*
set +e
"$INJECTOR" --log "$LOG_PATH" --emit-dir "$EMIT_DIR"
status_done_not_terminal=$?
set -e
if [[ "$status_done_not_terminal" -ne 2 ]]; then
printf 'expected later incomplete turn after done token to exit 2, got %q\n' "$status_done_not_terminal" >&2
exit 1
fi
emit_count="$(find "$EMIT_DIR" -type f | wc -l | tr -d ' ')"
if [[ "$emit_count" -ne 2 ]]; then
printf 'expected two queued continuation prompts across incomplete turns, found %q\n' "$emit_count" >&2
exit 1
fi
echo "ok"
#!/usr/bin/env bash
set -euo pipefail
SCRIPT="/Users/blader/.codex/skills/taskmaster/install.sh"
assert_single_block() {
local rc_path="$1"
local count
count="$(rg -c '^# TASKMASTER CODEX WRAPPER$' "$rc_path")"
if [[ "$count" != "1" ]]; then
printf 'expected exactly one Taskmaster block in %s, got %s\n' "$rc_path" "$count" >&2
exit 1
fi
}
TEST_HOME_SUPERSET="$(mktemp -d "${TMPDIR:-/tmp}/taskmaster-install-test-superset.XXXXXX")"
TEST_HOME_PLAIN="$(mktemp -d "${TMPDIR:-/tmp}/taskmaster-install-test-plain.XXXXXX")"
trap 'rm -rf "$TEST_HOME_SUPERSET" "$TEST_HOME_PLAIN"' EXIT
mkdir -p "$TEST_HOME_SUPERSET/.superset/bin"
printf '#!/usr/bin/env bash\n' > "$TEST_HOME_SUPERSET/.superset/bin/codex"
chmod +x "$TEST_HOME_SUPERSET/.superset/bin/codex"
cat > "$TEST_HOME_SUPERSET/.zshrc" <<'EOF'
export PATH="$HOME/.local/bin:$PATH"
# Prefer the Superset codex wrapper over taskmaster and npm-global shims.
export PATH="$HOME/.superset/bin:$PATH"
EOF
HOME="$TEST_HOME_SUPERSET" SHELL="/bin/zsh" TASKMASTER_INSTALL_TARGET=codex bash "$SCRIPT" >/dev/null
superset_block_line="$(rg -n '^# TASKMASTER CODEX WRAPPER$' "$TEST_HOME_SUPERSET/.zshrc" | cut -d: -f1)"
superset_path_line="$(rg -n '^[^\n]*\.superset/bin[^\n]*$' "$TEST_HOME_SUPERSET/.zshrc" | tail -n 1 | cut -d: -f1)"
if [[ -z "$superset_block_line" || -z "$superset_path_line" || "$superset_block_line" -ge "$superset_path_line" ]]; then
printf 'expected Taskmaster block before Superset PATH line in %s\n' "$TEST_HOME_SUPERSET/.zshrc" >&2
sed -n '1,120p' "$TEST_HOME_SUPERSET/.zshrc" >&2
exit 1
fi
assert_single_block "$TEST_HOME_SUPERSET/.zshrc"
HOME="$TEST_HOME_SUPERSET" SHELL="/bin/zsh" TASKMASTER_INSTALL_TARGET=codex bash "$SCRIPT" >/dev/null
assert_single_block "$TEST_HOME_SUPERSET/.zshrc"
cat > "$TEST_HOME_PLAIN/.zshrc" <<'EOF'
export PATH="/opt/homebrew/bin:$PATH"
EOF
HOME="$TEST_HOME_PLAIN" SHELL="/bin/zsh" TASKMASTER_INSTALL_TARGET=codex bash "$SCRIPT" >/dev/null
plain_block_line="$(rg -n '^# TASKMASTER CODEX WRAPPER$' "$TEST_HOME_PLAIN/.zshrc" | cut -d: -f1)"
plain_existing_line="$(rg -n '^export PATH="/opt/homebrew/bin:\$PATH"$' "$TEST_HOME_PLAIN/.zshrc" | cut -d: -f1)"
if [[ -z "$plain_block_line" || -z "$plain_existing_line" || "$plain_block_line" -le "$plain_existing_line" ]]; then
printf 'expected Taskmaster block appended after existing PATH content in %s\n' "$TEST_HOME_PLAIN/.zshrc" >&2
sed -n '1,120p' "$TEST_HOME_PLAIN/.zshrc" >&2
exit 1
fi
assert_single_block "$TEST_HOME_PLAIN/.zshrc"
echo "ok"
#!/usr/bin/env bash
set -euo pipefail
TEST_TMPDIR="$(mktemp -d "${TMPDIR:-/tmp}/taskmaster-bridge-test.XXXXXX")"
trap 'rm -rf "$TEST_TMPDIR"' EXIT
cat > "$TEST_TMPDIR/fake-child.sh" <<'EOF'
#!/usr/bin/env bash
set -euo pipefail
IFS= read -r line
printf '%s\n' "$line" > "$TASKMASTER_BRIDGE_TEST_OUT"
EOF
chmod +x "$TEST_TMPDIR/fake-child.sh"
printf '%s' 'Bridge hello' > "$TEST_TMPDIR/inject.0001.txt"
export TASKMASTER_BRIDGE_TEST_OUT="$TEST_TMPDIR/out.txt"
script -q /dev/null \
/Users/blader/.codex/skills/taskmaster/hooks/run-codex-expect-bridge.exp \
"$TEST_TMPDIR" \
"$TEST_TMPDIR/fake-child.sh" \
>/dev/null 2>&1 || true
actual="$(cat "$TASKMASTER_BRIDGE_TEST_OUT")"
expected=$'\E[200~Bridge hello\E[201~'
if [[ "$actual" != "$expected" ]]; then
printf 'expected bracketed-paste payload, got: %q\n' "$actual" >&2
exit 1
fi
echo "ok"
TEST_TMPDIR_DSR="$(mktemp -d "${TMPDIR:-/tmp}/taskmaster-bridge-dsr-test.XXXXXX")"
trap 'rm -rf "$TEST_TMPDIR" "$TEST_TMPDIR_DSR"' EXIT
cat > "$TEST_TMPDIR_DSR/fake-dsr-child.py" <<'EOF'
#!/usr/bin/env python3
import os
import select
import sys
import termios
import tty
fd = sys.stdin.fileno()
attrs = termios.tcgetattr(fd)
try:
tty.setcbreak(fd)
sys.stdout.write("\x1b[6n")
sys.stdout.flush()
response = b""
ready, _, _ = select.select([sys.stdin.buffer], [], [], 2.0)
if ready:
response = os.read(fd, 64)
finally:
termios.tcsetattr(fd, termios.TCSADRAIN, attrs)
with open(os.environ["TASKMASTER_BRIDGE_TEST_OUT_DSR"], "wb") as fh:
fh.write(response)
EOF
chmod +x "$TEST_TMPDIR_DSR/fake-dsr-child.py"
export TASKMASTER_BRIDGE_TEST_OUT_DSR="$TEST_TMPDIR_DSR/out.bin"
script -q /dev/null \
/Users/blader/.codex/skills/taskmaster/hooks/run-codex-expect-bridge.exp \
"$TEST_TMPDIR_DSR" \
"$TEST_TMPDIR_DSR/fake-dsr-child.py" \
>/dev/null 2>&1 || true
python3 - <<'EOF' "$TEST_TMPDIR_DSR/out.bin"
import sys
actual = open(sys.argv[1], "rb").read()
expected = b"\x1b[1;1R"
if actual != expected:
raise SystemExit(f"expected cursor-position response {expected!r}, got {actual!r}")
EOF
echo "ok"
TEST_TMPDIR_DELAYED_DSR="$(mktemp -d "${TMPDIR:-/tmp}/taskmaster-bridge-delayed-dsr-test.XXXXXX")"
trap 'rm -rf "$TEST_TMPDIR" "$TEST_TMPDIR_DSR" "$TEST_TMPDIR_DELAYED_DSR"' EXIT
cat > "$TEST_TMPDIR_DELAYED_DSR/fake-delayed-dsr-child.py" <<'EOF'
#!/usr/bin/env python3
import os
import select
import sys
import termios
import time
import tty
fd = sys.stdin.fileno()
attrs = termios.tcgetattr(fd)
try:
tty.setcbreak(fd)
time.sleep(1.3)
sys.stdout.write("\x1b[6n")
sys.stdout.flush()
response = b""
ready, _, _ = select.select([sys.stdin.buffer], [], [], 2.0)
if ready:
response = os.read(fd, 64)
finally:
termios.tcsetattr(fd, termios.TCSADRAIN, attrs)
with open(os.environ["TASKMASTER_BRIDGE_TEST_OUT_DELAYED_DSR"], "wb") as fh:
fh.write(response)
EOF
chmod +x "$TEST_TMPDIR_DELAYED_DSR/fake-delayed-dsr-child.py"
export TASKMASTER_BRIDGE_TEST_OUT_DELAYED_DSR="$TEST_TMPDIR_DELAYED_DSR/out.bin"
script -q /dev/null \
/Users/blader/.codex/skills/taskmaster/hooks/run-codex-expect-bridge.exp \
"$TEST_TMPDIR_DELAYED_DSR" \
"$TEST_TMPDIR_DELAYED_DSR/fake-delayed-dsr-child.py" \
>/dev/null 2>&1 || true
python3 - <<'EOF' "$TEST_TMPDIR_DELAYED_DSR/out.bin"
import sys
actual = open(sys.argv[1], "rb").read()
expected = b"\x1b[1;1R"
if actual != expected:
raise SystemExit(f"expected delayed cursor-position response {expected!r}, got {actual!r}")
EOF
echo "ok"
TEST_TMPDIR_CAPS="$(mktemp -d "${TMPDIR:-/tmp}/taskmaster-bridge-caps-test.XXXXXX")"
trap 'rm -rf "$TEST_TMPDIR" "$TEST_TMPDIR_DSR" "$TEST_TMPDIR_DELAYED_DSR" "$TEST_TMPDIR_CAPS"' EXIT
cat > "$TEST_TMPDIR_CAPS/fake-terminal-cap-child.py" <<'EOF'
#!/usr/bin/env python3
import os
import select
import sys
import termios
import tty
fd = sys.stdin.fileno()
attrs = termios.tcgetattr(fd)
try:
tty.setcbreak(fd)
sys.stdout.write("\x1b[?u\x1b[c")
sys.stdout.flush()
response = b""
deadline = 2.0
while deadline > 0:
ready, _, _ = select.select([sys.stdin.buffer], [], [], deadline)
if not ready:
break
response += os.read(fd, 64)
if response == b"\x1b[?0u\x1b[?1;2c":
break
deadline = 0.2
finally:
termios.tcsetattr(fd, termios.TCSADRAIN, attrs)
with open(os.environ["TASKMASTER_BRIDGE_TEST_OUT_CAPS"], "wb") as fh:
fh.write(response)
EOF
chmod +x "$TEST_TMPDIR_CAPS/fake-terminal-cap-child.py"
export TASKMASTER_BRIDGE_TEST_OUT_CAPS="$TEST_TMPDIR_CAPS/out.bin"
script -q /dev/null \
/Users/blader/.codex/skills/taskmaster/hooks/run-codex-expect-bridge.exp \
"$TEST_TMPDIR_CAPS" \
"$TEST_TMPDIR_CAPS/fake-terminal-cap-child.py" \
>/dev/null 2>&1 || true
python3 - <<'EOF' "$TEST_TMPDIR_CAPS/out.bin"
import sys
actual = open(sys.argv[1], "rb").read()
expected = b"\x1b[?0u\x1b[?1;2c"
if actual != expected:
raise SystemExit(f"expected terminal capability responses {expected!r}, got {actual!r}")
EOF
echo "ok"
TEST_TMPDIR_AUTO_PASTE="$(mktemp -d "${TMPDIR:-/tmp}/taskmaster-bridge-auto-paste-test.XXXXXX")"
trap 'rm -rf "$TEST_TMPDIR" "$TEST_TMPDIR_DSR" "$TEST_TMPDIR_DELAYED_DSR" "$TEST_TMPDIR_CAPS" "$TEST_TMPDIR_AUTO_PASTE"' EXIT
cat > "$TEST_TMPDIR_AUTO_PASTE/fake-auto-paste-child.sh" <<'EOF'
#!/usr/bin/env bash
set -euo pipefail
sleep 1
EOF
chmod +x "$TEST_TMPDIR_AUTO_PASTE/fake-auto-paste-child.sh"
printf 'line one\nline two' > "$TEST_TMPDIR_AUTO_PASTE/inject.0001.txt"
export SUPERSET_CODEX_TRACE_PATH="$TEST_TMPDIR_AUTO_PASTE/trace.log"
unset TASKMASTER_EXPECT_PASTE_MODE
script -q /dev/null \
/Users/blader/.codex/skills/taskmaster/hooks/run-codex-expect-bridge.exp \
"$TEST_TMPDIR_AUTO_PASTE" \
"$TEST_TMPDIR_AUTO_PASTE/fake-auto-paste-child.sh" \
>/dev/null 2>&1 || true
if ! rg -n 'inject_send mode=bracketed' "$TEST_TMPDIR_AUTO_PASTE/trace.log" >/dev/null 2>&1; then
printf 'expected auto multiline injection to resolve to bracketed paste\n' >&2
sed -n '1,120p' "$TEST_TMPDIR_AUTO_PASTE/trace.log" >&2 || true
exit 1
fi
echo "ok"
#!/usr/bin/env bash
set -euo pipefail
TEST_TMPDIR="$(mktemp -d "${TMPDIR:-/tmp}/taskmaster-runner-test.XXXXXX")"
trap 'rm -rf "$TEST_TMPDIR"' EXIT
SKILL_DIR="$TEST_TMPDIR/taskmaster"
BIN_DIR="$TEST_TMPDIR/bin"
REAL_BIN_DIR="$TEST_TMPDIR/real-bin"
HOME_DIR="$TEST_TMPDIR/home"
LOG_DIR="$HOME_DIR/.codex/log"
EXPECT_OUT="$TEST_TMPDIR/expect-invocations.log"
REAL_OUT="$TEST_TMPDIR/real-invocations.log"
mkdir -p "$SKILL_DIR/hooks" "$BIN_DIR" "$REAL_BIN_DIR" "$LOG_DIR"
cp /Users/blader/.codex/skills/taskmaster/run-taskmaster-codex.sh "$SKILL_DIR/run-taskmaster-codex.sh"
cat > "$SKILL_DIR/hooks/inject-continue-codex.sh" <<'EOF'
#!/usr/bin/env bash
set -euo pipefail
while [[ $# -gt 0 ]]; do
case "$1" in
--emit-dir)
emit_dir="$2"
shift 2
;;
*)
shift
;;
esac
done
mkdir -p "${emit_dir:?missing emit dir}"
sleep 0.1
EOF
chmod +x "$SKILL_DIR/hooks/inject-continue-codex.sh"
cat > "$SKILL_DIR/hooks/run-codex-expect-bridge.exp" <<'EOF'
#!/usr/bin/env bash
set -euo pipefail
queue_dir="$1"
shift
printf 'EXPECT:%s\n' "$*" >> "${TASKMASTER_TEST_EXPECT_OUT:?missing expect out}"
"$@"
EOF
chmod +x "$SKILL_DIR/hooks/run-codex-expect-bridge.exp"
cat > "$REAL_BIN_DIR/codex" <<'EOF'
#!/usr/bin/env bash
set -euo pipefail
printf 'REAL:%s\n' "$*" >> "${TASKMASTER_TEST_REAL_OUT:?missing real out}"
EOF
chmod +x "$REAL_BIN_DIR/codex"
cat > "$BIN_DIR/codex" <<EOF
#!/usr/bin/env bash
exec "$SKILL_DIR/run-taskmaster-codex.sh" "\$@"
EOF
chmod +x "$BIN_DIR/codex"
export HOME="$HOME_DIR"
export PATH="$BIN_DIR:$REAL_BIN_DIR:/usr/bin:/bin"
export TASKMASTER_REAL_CODEX_BIN="$REAL_BIN_DIR/codex"
export TASKMASTER_TEST_EXPECT_OUT="$EXPECT_OUT"
export TASKMASTER_TEST_REAL_OUT="$REAL_OUT"
"$BIN_DIR/codex" resume session-123
if [[ ! -f "$EXPECT_OUT" ]]; then
printf 'expected resume to use expect bridge\n' >&2
exit 1
fi
if ! grep -F "resume session-123" "$EXPECT_OUT" >/dev/null 2>&1; then
printf 'expected resume expect invocation in %s\n' "$EXPECT_OUT" >&2
cat "$EXPECT_OUT" >&2
exit 1
fi
if ! grep -F "REAL:resume session-123" "$REAL_OUT" >/dev/null 2>&1; then
printf 'expected resume to reach real codex through wrapper\n' >&2
cat "$REAL_OUT" >&2
exit 1
fi
rm -f "$EXPECT_OUT" "$REAL_OUT"
"$BIN_DIR/codex" exec status
if [[ -f "$EXPECT_OUT" ]]; then
printf 'expected exec to bypass expect bridge\n' >&2
cat "$EXPECT_OUT" >&2
exit 1
fi
if ! grep -F "REAL:exec status" "$REAL_OUT" >/dev/null 2>&1; then
printf 'expected exec to call real codex directly\n' >&2
cat "$REAL_OUT" >&2
exit 1
fi
echo "ok"
#!/usr/bin/env bash
#
# Taskmaster uninstaller for Codex and Claude.
#
set -euo pipefail
CODEX_ROOT="$HOME/.codex"
CLAUDE_ROOT="$HOME/.claude"
CODEX_SKILL_DIR="$CODEX_ROOT/skills/taskmaster"
CLAUDE_SKILL_DIR="$CLAUDE_ROOT/skills/taskmaster"
CODEX_LAUNCHER_LINK="$CODEX_ROOT/bin/codex-taskmaster"
CODEX_SHIM_LINK="$CODEX_ROOT/bin/codex"
CODEX_RUNNER_PATH="$CODEX_SKILL_DIR/run-taskmaster-codex.sh"
CLAUDE_HOOK_LINK="$CLAUDE_ROOT/hooks/taskmaster-check-completion.sh"
CLAUDE_CHECK_SCRIPT="$CLAUDE_SKILL_DIR/check-completion.sh"
CLAUDE_SETTINGS_PATH="$CLAUDE_ROOT/settings.json"
codex_detected() {
command -v codex >/dev/null 2>&1 || [[ -d "$CODEX_ROOT" ]]
}
claude_detected() {
command -v claude >/dev/null 2>&1 || [[ -d "$CLAUDE_ROOT" ]]
}
codex_artifacts_detected() {
[[ -e "$CODEX_SKILL_DIR" || -L "$CODEX_LAUNCHER_LINK" || -L "$CODEX_SHIM_LINK" ]]
}
claude_artifacts_detected() {
[[ -e "$CLAUDE_SKILL_DIR" || -L "$CLAUDE_HOOK_LINK" || -f "$CLAUDE_SETTINGS_PATH" ]]
}
resolve_link_target() {
local link_path="$1"
local raw_target
local target_dir
raw_target="$(readlink "$link_path")"
if [[ "$raw_target" == /* ]]; then
printf '%s\n' "$raw_target"
return 0
fi
target_dir="$(cd "$(dirname "$link_path")" && cd "$(dirname "$raw_target")" && pwd)"
printf '%s/%s\n' "$target_dir" "$(basename "$raw_target")"
}
remove_symlink_if_target() {
local link_path="$1"
shift
local expected_targets=("$@")
local resolved_target
local expected
if [[ ! -L "$link_path" ]]; then
if [[ -e "$link_path" ]]; then
echo " Skipped $link_path (not a symlink)"
else
echo " Link not found (already removed): $link_path"
fi
return 0
fi
resolved_target="$(resolve_link_target "$link_path")"
for expected in "${expected_targets[@]}"; do
if [[ "$resolved_target" == "$expected" ]]; then
rm -f "$link_path"
echo " Removed $link_path"
return 0
fi
done
echo " Skipped $link_path (not a Taskmaster link -> $resolved_target)"
}
remove_dir_if_exists() {
local dir_path="$1"
if [[ -d "$dir_path" ]]; then
rm -rf "$dir_path"
echo " Removed $dir_path"
else
echo " Directory not found (already removed): $dir_path"
fi
}
remove_claude_stop_hook_from_settings() {
local settings_path="$1"
if [[ ! -f "$settings_path" ]]; then
echo " Claude: settings not found (already removed): $settings_path"
return 0
fi
if ! command -v python3 >/dev/null 2>&1; then
echo " Claude: python3 not found; remove Stop hook manually from $settings_path" >&2
return 0
fi
python3 - "$settings_path" <<'PY'
import json
import os
import sys
settings_path = sys.argv[1]
hook_commands = {
"~/.claude/hooks/taskmaster-check-completion.sh",
os.path.expanduser("~/.claude/hooks/taskmaster-check-completion.sh"),
"~/.claude/skills/taskmaster/check-completion.sh",
os.path.expanduser("~/.claude/skills/taskmaster/check-completion.sh"),
}
try:
with open(settings_path, "r", encoding="utf-8") as f:
data = json.load(f)
except json.JSONDecodeError:
print(f" Claude: settings is not valid JSON ({settings_path}); remove Stop hook manually.", file=sys.stderr)
sys.exit(0)
if not isinstance(data, dict):
print(f" Claude: settings root is not an object ({settings_path}); remove Stop hook manually.", file=sys.stderr)
sys.exit(0)
changed = False
def strip_stop_hooks(container):
global changed
stop_list = container.get("Stop")
if not isinstance(stop_list, list):
return
new_stop = []
for entry in stop_list:
if not isinstance(entry, dict):
new_stop.append(entry)
continue
hooks = entry.get("hooks")
if not isinstance(hooks, list):
new_stop.append(entry)
continue
kept_hooks = []
for hook in hooks:
if (
isinstance(hook, dict)
and hook.get("type") == "command"
and isinstance(hook.get("command"), str)
and hook.get("command") in hook_commands
):
changed = True
continue
kept_hooks.append(hook)
if kept_hooks:
entry_copy = dict(entry)
entry_copy["hooks"] = kept_hooks
new_stop.append(entry_copy)
else:
changed = True
if new_stop:
container["Stop"] = new_stop
elif "Stop" in container:
del container["Stop"]
changed = True
if isinstance(data.get("hooks"), dict):
strip_stop_hooks(data["hooks"])
if not data["hooks"]:
del data["hooks"]
changed = True
strip_stop_hooks(data)
if changed:
with open(settings_path, "w", encoding="utf-8") as f:
json.dump(data, f, indent=2)
f.write("\n")
print(" Claude: removed Taskmaster Stop hook from settings")
else:
print(" Claude: Taskmaster Stop hook not found in settings")
PY
}
uninstall_codex() {
echo "Removing Taskmaster from Codex..."
remove_symlink_if_target "$CODEX_SHIM_LINK" "$CODEX_LAUNCHER_LINK" "$CODEX_RUNNER_PATH"
remove_symlink_if_target "$CODEX_LAUNCHER_LINK" "$CODEX_RUNNER_PATH"
remove_dir_if_exists "$CODEX_SKILL_DIR"
}
uninstall_claude() {
echo "Removing Taskmaster from Claude..."
remove_claude_stop_hook_from_settings "$CLAUDE_SETTINGS_PATH"
remove_symlink_if_target "$CLAUDE_HOOK_LINK" "$CLAUDE_CHECK_SCRIPT"
remove_dir_if_exists "$CLAUDE_SKILL_DIR"
}
UNINSTALL_TARGET="${TASKMASTER_UNINSTALL_TARGET:-auto}"
UNINSTALL_CODEX=0
UNINSTALL_CLAUDE=0
case "$UNINSTALL_TARGET" in
auto)
if codex_artifacts_detected || codex_detected; then
UNINSTALL_CODEX=1
fi
if claude_artifacts_detected || claude_detected; then
UNINSTALL_CLAUDE=1
fi
;;
codex)
UNINSTALL_CODEX=1
;;
claude)
UNINSTALL_CLAUDE=1
;;
both)
UNINSTALL_CODEX=1
UNINSTALL_CLAUDE=1
;;
*)
echo "Invalid TASKMASTER_UNINSTALL_TARGET='$UNINSTALL_TARGET' (expected: auto|codex|claude|both)" >&2
exit 4
;;
esac
if [[ "$UNINSTALL_CODEX" -eq 0 && "$UNINSTALL_CLAUDE" -eq 0 ]]; then
echo "No Codex/Claude environment detected. Nothing to uninstall."
exit 0
fi
if [[ "$UNINSTALL_CODEX" -eq 1 ]]; then
uninstall_codex
fi
if [[ "$UNINSTALL_CLAUDE" -eq 1 ]]; then
uninstall_claude
fi
echo ""
echo "Done. Taskmaster uninstall complete."
Related skills
FAQ
How does taskmaster know work is done?
The agent must include the exact line TASKMASTER_DONE::<session_id> in its final response, giving automation a deterministic completion marker.
What happens if the done token is missing?
It injects a follow-up user message into the same running process over an expect PTY bridge using the shared compliance prompt.