From 4eef417da66f13be9a2d7b3385551b2d21d54558 Mon Sep 17 00:00:00 2001 From: Codeman maintainer Date: Thu, 15 Oct 2026 19:33:09 +0200 Subject: [PATCH] =?UTF-8?q?fix(skill):=20keep=20the=20=C2=A70=20bootstrap?= =?UTF-8?q?=20intact=20when=20Claude=20Code=20loads=20the=20skill=20with?= =?UTF-8?q?=20arguments?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Claude Code rewrites a skill body that it loads with arguments: every bare $1, $2 ... becomes the matching word of the request (0-based). Nine lines of the agent preamble used bare positional parameters, so an agent that pasted the §0 bootstrap (every session without a pre-seeded preamble: Agent Skill injection off, the default, or a remote or Docker session) wrote helpers that queried session "5" with timeout=claude and looked up "5=" instead of CODEMAN_PASSWORD= in the .env, sending every call unauthenticated. - $1/$2 -> ${1}/${2} in preamble.sh and the identical §0 heredoc in SKILL.md; same bash, and Claude Code's pattern (\$\d+(?!\w)) skips it. - CODEMAN_PREAMBLE 1.33.4 -> 1.41.2, so a cache file written by the broken bootstrap (which carries the old stamp) is rewritten on the next run. - A comment at the top of the preamble states the rule. - New test: Claude Code 2.1.300's substitution applied to SKILL.md with sample arguments must leave the body, and the pasted heredoc, unchanged. It fails on the old file with the exact sessions/5 and timeout=claude lines. - plugins/codeman re-synced; patch changeset. Verified with real Claude Code 2.1.300 (the delivered heredoc is now byte-identical to preamble.sh; before, 9 lines differed) and by running the pasted helpers against a local recording server. Co-Authored-By: Claude Opus 5.5 (1M context) --- .changeset/skill-positional-args.md | 5 +++ plugins/codeman/skills/codeman/SKILL.md | 33 ++++++++++--------- plugins/codeman/skills/codeman/preamble.sh | 25 +++++++------- .../skills/codeman/reference/recipes.md | 2 +- skills/codeman/SKILL.md | 33 ++++++++++--------- skills/codeman/preamble.sh | 25 +++++++------- skills/codeman/reference/recipes.md | 2 +- test/agent-skill.test.ts | 24 ++++++++++++++ 8 files changed, 95 insertions(+), 54 deletions(-) create mode 100644 .changeset/skill-positional-args.md diff --git a/.changeset/skill-positional-args.md b/.changeset/skill-positional-args.md new file mode 100644 index 000000000..42f6cc7f7 --- /dev/null +++ b/.changeset/skill-positional-args.md @@ -0,0 +1,5 @@ +--- +'aicodeman': patch +--- + +Codeman skill: the §0 bootstrap now survives Claude Code loading the skill with arguments. Claude Code replaces a bare `$1`, `$2` in a skill body with the words of the request, so an agent that pasted the bootstrap (every session without a pre-seeded preamble: Agent Skill injection off, the default, or a remote or Docker session) wrote helpers that asked about session `5` with `timeout=claude` and never found the password. The shell now uses `${1}` / `${2}`, and the preamble stamp moved to 1.41.2 so a file written by the broken bootstrap is rewritten on the next run. diff --git a/plugins/codeman/skills/codeman/SKILL.md b/plugins/codeman/skills/codeman/SKILL.md index 747626155..2b3f28e85 100644 --- a/plugins/codeman/skills/codeman/SKILL.md +++ b/plugins/codeman/skills/codeman/SKILL.md @@ -70,7 +70,7 @@ later call opens with, and your first REAL call performs them anyway: ```bash . "${XDG_CACHE_HOME:-$HOME/.cache}/codeman-agent-$CODEMAN_SESSION_ID.sh" 2>/dev/null -[ "${CODEMAN_PREAMBLE:-}" = 1.33.4 ] || { echo "preamble missing or stale; run the full §0 block"; exit 1; } +[ "${CODEMAN_PREAMBLE:-}" = 1.41.2 ] || { echo "preamble missing or stale; run the full §0 block"; exit 1; } ``` ⚠️ **Never spend a Bash call on this check alone.** §1's block opens with this same @@ -98,8 +98,11 @@ PRE="${XDG_CACHE_HOME:-$HOME/.cache}/codeman-agent-$CODEMAN_SESSION_ID.sh" mkdir -p "$(dirname "$PRE")" # Rewrite unless the file already ends with THIS version's stamp, so a stale or a # half-written file self-heals here instead of costing you a round trip to rm it. -grep -qs '^CODEMAN_PREAMBLE=1.33.4$' "$PRE" || (umask 077; cat > "$PRE" <<'PREAMBLE' -# ---- Codeman agent preamble 1.33.4 (seeded by Codeman at session spawn; the SKILL.md §0 bootstrap rewrites it when missing or stale) ---- +grep -qs '^CODEMAN_PREAMBLE=1.41.2$' "$PRE" || (umask 077; cat > "$PRE" <<'PREAMBLE' +# ---- Codeman agent preamble 1.41.2 (seeded by Codeman at session spawn; the SKILL.md §0 bootstrap rewrites it when missing or stale) ---- +# Positional parameters are always written with braces, ${1} and never a bare dollar-digit: Claude Code +# replaces a bare one in a skill body with the matching word of the skill's arguments, and §0 is pasted +# from that body (test/agent-skill.test.ts pins it). API="${CODEMAN_API_URL:?CODEMAN_API_URL not set; refusing to guess}" SELF="${CODEMAN_SESSION_ID:?CODEMAN_SESSION_ID not set}" # Credentials, cheapest first. Your session has usually INHERITED the server's @@ -108,7 +111,7 @@ SELF="${CODEMAN_SESSION_ID:?CODEMAN_SESSION_ID not set}" # reads. The data dir is wherever the hook-secret file lives. Values may be # quoted or `export`-prefixed. ENV_FILE="${CODEMAN_HOOK_SECRET_FILE:+${CODEMAN_HOOK_SECRET_FILE%hook-secret}.env}" -envval() { sed -n "s/^\(export \)\{0,1\}$1=//p" "$ENV_FILE" | tail -1 | sed 's/^"\(.*\)"$/\1/; s/^'\''\(.*\)'\''$/\1/'; } +envval() { sed -n "s/^\(export \)\{0,1\}${1}=//p" "$ENV_FILE" | tail -1 | sed 's/^"\(.*\)"$/\1/; s/^'\''\(.*\)'\''$/\1/'; } if [ -z "${CODEMAN_PASSWORD:-}" ] && [ -n "$ENV_FILE" ] && [ -f "$ENV_FILE" ]; then CODEMAN_USERNAME=$(envval CODEMAN_USERNAME) CODEMAN_PASSWORD=$(envval CODEMAN_PASSWORD) @@ -143,15 +146,15 @@ delete_session() { # ---- fast path: the four verbs, already written. §1 composes them. ---- _composer_up() { # -> "true"/"false". `shift+tab` is the one token - "${CURL[@]}" -G "$API/api/v1/sessions/$1/wait-output" \ + "${CURL[@]}" -G "$API/api/v1/sessions/${1}/wait-output" \ --data-urlencode 'match=shift+tab' --data-urlencode 'from=buffer' \ - --data-urlencode "timeout=$2" | jq -r '.data.wait.matched // false' + --data-urlencode "timeout=${2}" | jq -r '.data.wait.matched // false' } _dsh_up() { # -> "true"/"false". The DeepSeek Harness TUI's # composer glyph. Override with DSH_READY_MARK for a profile that draws another one. - "${CURL[@]}" -G "$API/api/v1/sessions/$1/wait-output" \ + "${CURL[@]}" -G "$API/api/v1/sessions/${1}/wait-output" \ --data-urlencode "match=${DSH_READY_MARK:-❯}" --data-urlencode 'from=buffer' \ - --data-urlencode "timeout=$2" | jq -r '.data.wait.matched // false' + --data-urlencode "timeout=${2}" | jq -r '.data.wait.matched // false' } # ---- the workspace-trust dialog: READ the screen, never press Enter blind ---- # Claude Code 2.1.252 dropped the option numbers, REVERSED them, and highlights @@ -167,7 +170,7 @@ _trust_key() { # -> "confirm" | "move" | "" (nothing safe to press) # full=1 returns the RENDERED pane; a claude pane keeps no tmux history, so that # is the current frame rather than every repaint since launch. tail -1 anyway, # because the freshest marked row is the only one still true. - "${CURL[@]}" -G "$API/api/v1/sessions/$1/terminal" --data-urlencode 'full=1' \ + "${CURL[@]}" -G "$API/api/v1/sessions/${1}/terminal" --data-urlencode 'full=1' \ | jq -r '.data.terminalBuffer // empty' \ | sed -e "s/$(printf '\033')\[[0-9;?]*[a-zA-Z]//g" -e "s/$(printf '\033')[()][AB0]//g" \ | tr -d ' \t' | grep -i '❯[0-9.]*\(yes,itrustthisfolder\|no,exit\)' | tail -1 \ @@ -185,7 +188,7 @@ _composer_text() { # -> the composer's text with ALL whitespace removed: " # the LAST `❯` line: Claude Code echoes a submitted prompt with the same glyph higher # up in the transcript, so only the last one says whether the text was taken. local t - t=$("${CURL[@]}" -G "$API/api/v1/sessions/$1/terminal" --data-urlencode 'full=1' \ + t=$("${CURL[@]}" -G "$API/api/v1/sessions/${1}/terminal" --data-urlencode 'full=1' \ | jq -r '.data.terminalBuffer // empty' \ | sed -e "s/$(printf '\033')\[[0-9;?]*[a-zA-Z]//g" -e "s/$(printf '\033')[()][AB0]//g" \ | tr -d '\r' | grep -a '^[[:space:]]*❯' | tail -1) @@ -195,7 +198,7 @@ _composer_text() { # -> the composer's text with ALL whitespace removed: " printf '%s' "$t" | sed 's/^[[:space:]]*❯//' | tr -d '[:space:]' | sed "s/$(printf '\302\240')//g" } _accept_trust() { # -> 0 once it has answered the dialog, 1 if it could not - local sid="$1" k i=1 + local sid="${1}" k i=1 while [ "$i" -le 6 ]; do k=$(_trust_key "$sid") [ -n "$k" ] || return 1 # no dialog on screen, or a layout this cannot read @@ -396,7 +399,7 @@ sendwait() { last_text() { local t="" prev="${2:-}" for _ in $(seq 1 15); do - t=$("${CURL[@]}" "$API/api/v1/sessions/$1/last-response" | jq -r '.data.text // empty') + t=$("${CURL[@]}" "$API/api/v1/sessions/${1}/last-response" | jq -r '.data.text // empty') [ -n "$t" ] && [ "$t" != "$prev" ] && { printf '%s\n' "$t"; return 0; } sleep 1 done @@ -407,10 +410,10 @@ last_text() { # The stamp is the LAST line on purpose (a truncated write leaves it unset) and is kept # bare on purpose: the write condition above anchors on it with $, so an inline comment # here would fail that match and rewrite this file on every single bootstrap. -CODEMAN_PREAMBLE=1.33.4 +CODEMAN_PREAMBLE=1.41.2 PREAMBLE ) -. "$PRE"; [ "${CODEMAN_PREAMBLE:-}" = 1.33.4 ] || { echo "preamble at $PRE is stale or truncated: rm it and re-run this block"; exit 1; } +. "$PRE"; [ "${CODEMAN_PREAMBLE:-}" = 1.41.2 ] || { echo "preamble at $PRE is stale or truncated: rm it and re-run this block"; exit 1; } ``` Every later Bash call that touches the API starts with the same two loader lines from @@ -461,7 +464,7 @@ and no per-call body to hand-build. ```bash . "${XDG_CACHE_HOME:-$HOME/.cache}/codeman-agent-$CODEMAN_SESSION_ID.sh" 2>/dev/null # §0 loader -[ "${CODEMAN_PREAMBLE:-}" = 1.33.4 ] || { echo "preamble missing or stale; run the full §0 block"; exit 1; } +[ "${CODEMAN_PREAMBLE:-}" = 1.41.2 ] || { echo "preamble missing or stale; run the full §0 block"; exit 1; } N=(alpha beta) # INVENT one fresh case name per worker; never list cases first # (a name may carry a mode: `beta:deepseek`, see below) T=('reply with one line: the absolute path of your working directory' diff --git a/plugins/codeman/skills/codeman/preamble.sh b/plugins/codeman/skills/codeman/preamble.sh index 285e885f2..381076022 100644 --- a/plugins/codeman/skills/codeman/preamble.sh +++ b/plugins/codeman/skills/codeman/preamble.sh @@ -1,4 +1,7 @@ -# ---- Codeman agent preamble 1.33.4 (seeded by Codeman at session spawn; the SKILL.md §0 bootstrap rewrites it when missing or stale) ---- +# ---- Codeman agent preamble 1.41.2 (seeded by Codeman at session spawn; the SKILL.md §0 bootstrap rewrites it when missing or stale) ---- +# Positional parameters are always written with braces, ${1} and never a bare dollar-digit: Claude Code +# replaces a bare one in a skill body with the matching word of the skill's arguments, and §0 is pasted +# from that body (test/agent-skill.test.ts pins it). API="${CODEMAN_API_URL:?CODEMAN_API_URL not set; refusing to guess}" SELF="${CODEMAN_SESSION_ID:?CODEMAN_SESSION_ID not set}" # Credentials, cheapest first. Your session has usually INHERITED the server's @@ -7,7 +10,7 @@ SELF="${CODEMAN_SESSION_ID:?CODEMAN_SESSION_ID not set}" # reads. The data dir is wherever the hook-secret file lives. Values may be # quoted or `export`-prefixed. ENV_FILE="${CODEMAN_HOOK_SECRET_FILE:+${CODEMAN_HOOK_SECRET_FILE%hook-secret}.env}" -envval() { sed -n "s/^\(export \)\{0,1\}$1=//p" "$ENV_FILE" | tail -1 | sed 's/^"\(.*\)"$/\1/; s/^'\''\(.*\)'\''$/\1/'; } +envval() { sed -n "s/^\(export \)\{0,1\}${1}=//p" "$ENV_FILE" | tail -1 | sed 's/^"\(.*\)"$/\1/; s/^'\''\(.*\)'\''$/\1/'; } if [ -z "${CODEMAN_PASSWORD:-}" ] && [ -n "$ENV_FILE" ] && [ -f "$ENV_FILE" ]; then CODEMAN_USERNAME=$(envval CODEMAN_USERNAME) CODEMAN_PASSWORD=$(envval CODEMAN_PASSWORD) @@ -42,15 +45,15 @@ delete_session() { # ---- fast path: the four verbs, already written. §1 composes them. ---- _composer_up() { # -> "true"/"false". `shift+tab` is the one token - "${CURL[@]}" -G "$API/api/v1/sessions/$1/wait-output" \ + "${CURL[@]}" -G "$API/api/v1/sessions/${1}/wait-output" \ --data-urlencode 'match=shift+tab' --data-urlencode 'from=buffer' \ - --data-urlencode "timeout=$2" | jq -r '.data.wait.matched // false' + --data-urlencode "timeout=${2}" | jq -r '.data.wait.matched // false' } _dsh_up() { # -> "true"/"false". The DeepSeek Harness TUI's # composer glyph. Override with DSH_READY_MARK for a profile that draws another one. - "${CURL[@]}" -G "$API/api/v1/sessions/$1/wait-output" \ + "${CURL[@]}" -G "$API/api/v1/sessions/${1}/wait-output" \ --data-urlencode "match=${DSH_READY_MARK:-❯}" --data-urlencode 'from=buffer' \ - --data-urlencode "timeout=$2" | jq -r '.data.wait.matched // false' + --data-urlencode "timeout=${2}" | jq -r '.data.wait.matched // false' } # ---- the workspace-trust dialog: READ the screen, never press Enter blind ---- # Claude Code 2.1.252 dropped the option numbers, REVERSED them, and highlights @@ -66,7 +69,7 @@ _trust_key() { # -> "confirm" | "move" | "" (nothing safe to press) # full=1 returns the RENDERED pane; a claude pane keeps no tmux history, so that # is the current frame rather than every repaint since launch. tail -1 anyway, # because the freshest marked row is the only one still true. - "${CURL[@]}" -G "$API/api/v1/sessions/$1/terminal" --data-urlencode 'full=1' \ + "${CURL[@]}" -G "$API/api/v1/sessions/${1}/terminal" --data-urlencode 'full=1' \ | jq -r '.data.terminalBuffer // empty' \ | sed -e "s/$(printf '\033')\[[0-9;?]*[a-zA-Z]//g" -e "s/$(printf '\033')[()][AB0]//g" \ | tr -d ' \t' | grep -i '❯[0-9.]*\(yes,itrustthisfolder\|no,exit\)' | tail -1 \ @@ -84,7 +87,7 @@ _composer_text() { # -> the composer's text with ALL whitespace removed: " # the LAST `❯` line: Claude Code echoes a submitted prompt with the same glyph higher # up in the transcript, so only the last one says whether the text was taken. local t - t=$("${CURL[@]}" -G "$API/api/v1/sessions/$1/terminal" --data-urlencode 'full=1' \ + t=$("${CURL[@]}" -G "$API/api/v1/sessions/${1}/terminal" --data-urlencode 'full=1' \ | jq -r '.data.terminalBuffer // empty' \ | sed -e "s/$(printf '\033')\[[0-9;?]*[a-zA-Z]//g" -e "s/$(printf '\033')[()][AB0]//g" \ | tr -d '\r' | grep -a '^[[:space:]]*❯' | tail -1) @@ -94,7 +97,7 @@ _composer_text() { # -> the composer's text with ALL whitespace removed: " printf '%s' "$t" | sed 's/^[[:space:]]*❯//' | tr -d '[:space:]' | sed "s/$(printf '\302\240')//g" } _accept_trust() { # -> 0 once it has answered the dialog, 1 if it could not - local sid="$1" k i=1 + local sid="${1}" k i=1 while [ "$i" -le 6 ]; do k=$(_trust_key "$sid") [ -n "$k" ] || return 1 # no dialog on screen, or a layout this cannot read @@ -295,7 +298,7 @@ sendwait() { last_text() { local t="" prev="${2:-}" for _ in $(seq 1 15); do - t=$("${CURL[@]}" "$API/api/v1/sessions/$1/last-response" | jq -r '.data.text // empty') + t=$("${CURL[@]}" "$API/api/v1/sessions/${1}/last-response" | jq -r '.data.text // empty') [ -n "$t" ] && [ "$t" != "$prev" ] && { printf '%s\n' "$t"; return 0; } sleep 1 done @@ -306,4 +309,4 @@ last_text() { # The stamp is the LAST line on purpose (a truncated write leaves it unset) and is kept # bare on purpose: the write condition above anchors on it with $, so an inline comment # here would fail that match and rewrite this file on every single bootstrap. -CODEMAN_PREAMBLE=1.33.4 +CODEMAN_PREAMBLE=1.41.2 diff --git a/plugins/codeman/skills/codeman/reference/recipes.md b/plugins/codeman/skills/codeman/reference/recipes.md index 352d4e00b..3f7fde4b1 100644 --- a/plugins/codeman/skills/codeman/reference/recipes.md +++ b/plugins/codeman/skills/codeman/reference/recipes.md @@ -21,7 +21,7 @@ by sourcing the preamble file the §0 bootstrap wrote, and checking its version ```bash . "${XDG_CACHE_HOME:-$HOME/.cache}/codeman-agent-$CODEMAN_SESSION_ID.sh" 2>/dev/null -[ "${CODEMAN_PREAMBLE:-}" = 1.33.4 ] || { echo "preamble missing or stale; re-run the §0 bootstrap"; exit 1; } +[ "${CODEMAN_PREAMBLE:-}" = 1.41.2 ] || { echo "preamble missing or stale; re-run the §0 bootstrap"; exit 1; } ``` Do **not** re-paste the preamble body into each call. Sourcing it is what retires the diff --git a/skills/codeman/SKILL.md b/skills/codeman/SKILL.md index 747626155..2b3f28e85 100644 --- a/skills/codeman/SKILL.md +++ b/skills/codeman/SKILL.md @@ -70,7 +70,7 @@ later call opens with, and your first REAL call performs them anyway: ```bash . "${XDG_CACHE_HOME:-$HOME/.cache}/codeman-agent-$CODEMAN_SESSION_ID.sh" 2>/dev/null -[ "${CODEMAN_PREAMBLE:-}" = 1.33.4 ] || { echo "preamble missing or stale; run the full §0 block"; exit 1; } +[ "${CODEMAN_PREAMBLE:-}" = 1.41.2 ] || { echo "preamble missing or stale; run the full §0 block"; exit 1; } ``` ⚠️ **Never spend a Bash call on this check alone.** §1's block opens with this same @@ -98,8 +98,11 @@ PRE="${XDG_CACHE_HOME:-$HOME/.cache}/codeman-agent-$CODEMAN_SESSION_ID.sh" mkdir -p "$(dirname "$PRE")" # Rewrite unless the file already ends with THIS version's stamp, so a stale or a # half-written file self-heals here instead of costing you a round trip to rm it. -grep -qs '^CODEMAN_PREAMBLE=1.33.4$' "$PRE" || (umask 077; cat > "$PRE" <<'PREAMBLE' -# ---- Codeman agent preamble 1.33.4 (seeded by Codeman at session spawn; the SKILL.md §0 bootstrap rewrites it when missing or stale) ---- +grep -qs '^CODEMAN_PREAMBLE=1.41.2$' "$PRE" || (umask 077; cat > "$PRE" <<'PREAMBLE' +# ---- Codeman agent preamble 1.41.2 (seeded by Codeman at session spawn; the SKILL.md §0 bootstrap rewrites it when missing or stale) ---- +# Positional parameters are always written with braces, ${1} and never a bare dollar-digit: Claude Code +# replaces a bare one in a skill body with the matching word of the skill's arguments, and §0 is pasted +# from that body (test/agent-skill.test.ts pins it). API="${CODEMAN_API_URL:?CODEMAN_API_URL not set; refusing to guess}" SELF="${CODEMAN_SESSION_ID:?CODEMAN_SESSION_ID not set}" # Credentials, cheapest first. Your session has usually INHERITED the server's @@ -108,7 +111,7 @@ SELF="${CODEMAN_SESSION_ID:?CODEMAN_SESSION_ID not set}" # reads. The data dir is wherever the hook-secret file lives. Values may be # quoted or `export`-prefixed. ENV_FILE="${CODEMAN_HOOK_SECRET_FILE:+${CODEMAN_HOOK_SECRET_FILE%hook-secret}.env}" -envval() { sed -n "s/^\(export \)\{0,1\}$1=//p" "$ENV_FILE" | tail -1 | sed 's/^"\(.*\)"$/\1/; s/^'\''\(.*\)'\''$/\1/'; } +envval() { sed -n "s/^\(export \)\{0,1\}${1}=//p" "$ENV_FILE" | tail -1 | sed 's/^"\(.*\)"$/\1/; s/^'\''\(.*\)'\''$/\1/'; } if [ -z "${CODEMAN_PASSWORD:-}" ] && [ -n "$ENV_FILE" ] && [ -f "$ENV_FILE" ]; then CODEMAN_USERNAME=$(envval CODEMAN_USERNAME) CODEMAN_PASSWORD=$(envval CODEMAN_PASSWORD) @@ -143,15 +146,15 @@ delete_session() { # ---- fast path: the four verbs, already written. §1 composes them. ---- _composer_up() { # -> "true"/"false". `shift+tab` is the one token - "${CURL[@]}" -G "$API/api/v1/sessions/$1/wait-output" \ + "${CURL[@]}" -G "$API/api/v1/sessions/${1}/wait-output" \ --data-urlencode 'match=shift+tab' --data-urlencode 'from=buffer' \ - --data-urlencode "timeout=$2" | jq -r '.data.wait.matched // false' + --data-urlencode "timeout=${2}" | jq -r '.data.wait.matched // false' } _dsh_up() { # -> "true"/"false". The DeepSeek Harness TUI's # composer glyph. Override with DSH_READY_MARK for a profile that draws another one. - "${CURL[@]}" -G "$API/api/v1/sessions/$1/wait-output" \ + "${CURL[@]}" -G "$API/api/v1/sessions/${1}/wait-output" \ --data-urlencode "match=${DSH_READY_MARK:-❯}" --data-urlencode 'from=buffer' \ - --data-urlencode "timeout=$2" | jq -r '.data.wait.matched // false' + --data-urlencode "timeout=${2}" | jq -r '.data.wait.matched // false' } # ---- the workspace-trust dialog: READ the screen, never press Enter blind ---- # Claude Code 2.1.252 dropped the option numbers, REVERSED them, and highlights @@ -167,7 +170,7 @@ _trust_key() { # -> "confirm" | "move" | "" (nothing safe to press) # full=1 returns the RENDERED pane; a claude pane keeps no tmux history, so that # is the current frame rather than every repaint since launch. tail -1 anyway, # because the freshest marked row is the only one still true. - "${CURL[@]}" -G "$API/api/v1/sessions/$1/terminal" --data-urlencode 'full=1' \ + "${CURL[@]}" -G "$API/api/v1/sessions/${1}/terminal" --data-urlencode 'full=1' \ | jq -r '.data.terminalBuffer // empty' \ | sed -e "s/$(printf '\033')\[[0-9;?]*[a-zA-Z]//g" -e "s/$(printf '\033')[()][AB0]//g" \ | tr -d ' \t' | grep -i '❯[0-9.]*\(yes,itrustthisfolder\|no,exit\)' | tail -1 \ @@ -185,7 +188,7 @@ _composer_text() { # -> the composer's text with ALL whitespace removed: " # the LAST `❯` line: Claude Code echoes a submitted prompt with the same glyph higher # up in the transcript, so only the last one says whether the text was taken. local t - t=$("${CURL[@]}" -G "$API/api/v1/sessions/$1/terminal" --data-urlencode 'full=1' \ + t=$("${CURL[@]}" -G "$API/api/v1/sessions/${1}/terminal" --data-urlencode 'full=1' \ | jq -r '.data.terminalBuffer // empty' \ | sed -e "s/$(printf '\033')\[[0-9;?]*[a-zA-Z]//g" -e "s/$(printf '\033')[()][AB0]//g" \ | tr -d '\r' | grep -a '^[[:space:]]*❯' | tail -1) @@ -195,7 +198,7 @@ _composer_text() { # -> the composer's text with ALL whitespace removed: " printf '%s' "$t" | sed 's/^[[:space:]]*❯//' | tr -d '[:space:]' | sed "s/$(printf '\302\240')//g" } _accept_trust() { # -> 0 once it has answered the dialog, 1 if it could not - local sid="$1" k i=1 + local sid="${1}" k i=1 while [ "$i" -le 6 ]; do k=$(_trust_key "$sid") [ -n "$k" ] || return 1 # no dialog on screen, or a layout this cannot read @@ -396,7 +399,7 @@ sendwait() { last_text() { local t="" prev="${2:-}" for _ in $(seq 1 15); do - t=$("${CURL[@]}" "$API/api/v1/sessions/$1/last-response" | jq -r '.data.text // empty') + t=$("${CURL[@]}" "$API/api/v1/sessions/${1}/last-response" | jq -r '.data.text // empty') [ -n "$t" ] && [ "$t" != "$prev" ] && { printf '%s\n' "$t"; return 0; } sleep 1 done @@ -407,10 +410,10 @@ last_text() { # The stamp is the LAST line on purpose (a truncated write leaves it unset) and is kept # bare on purpose: the write condition above anchors on it with $, so an inline comment # here would fail that match and rewrite this file on every single bootstrap. -CODEMAN_PREAMBLE=1.33.4 +CODEMAN_PREAMBLE=1.41.2 PREAMBLE ) -. "$PRE"; [ "${CODEMAN_PREAMBLE:-}" = 1.33.4 ] || { echo "preamble at $PRE is stale or truncated: rm it and re-run this block"; exit 1; } +. "$PRE"; [ "${CODEMAN_PREAMBLE:-}" = 1.41.2 ] || { echo "preamble at $PRE is stale or truncated: rm it and re-run this block"; exit 1; } ``` Every later Bash call that touches the API starts with the same two loader lines from @@ -461,7 +464,7 @@ and no per-call body to hand-build. ```bash . "${XDG_CACHE_HOME:-$HOME/.cache}/codeman-agent-$CODEMAN_SESSION_ID.sh" 2>/dev/null # §0 loader -[ "${CODEMAN_PREAMBLE:-}" = 1.33.4 ] || { echo "preamble missing or stale; run the full §0 block"; exit 1; } +[ "${CODEMAN_PREAMBLE:-}" = 1.41.2 ] || { echo "preamble missing or stale; run the full §0 block"; exit 1; } N=(alpha beta) # INVENT one fresh case name per worker; never list cases first # (a name may carry a mode: `beta:deepseek`, see below) T=('reply with one line: the absolute path of your working directory' diff --git a/skills/codeman/preamble.sh b/skills/codeman/preamble.sh index 285e885f2..381076022 100644 --- a/skills/codeman/preamble.sh +++ b/skills/codeman/preamble.sh @@ -1,4 +1,7 @@ -# ---- Codeman agent preamble 1.33.4 (seeded by Codeman at session spawn; the SKILL.md §0 bootstrap rewrites it when missing or stale) ---- +# ---- Codeman agent preamble 1.41.2 (seeded by Codeman at session spawn; the SKILL.md §0 bootstrap rewrites it when missing or stale) ---- +# Positional parameters are always written with braces, ${1} and never a bare dollar-digit: Claude Code +# replaces a bare one in a skill body with the matching word of the skill's arguments, and §0 is pasted +# from that body (test/agent-skill.test.ts pins it). API="${CODEMAN_API_URL:?CODEMAN_API_URL not set; refusing to guess}" SELF="${CODEMAN_SESSION_ID:?CODEMAN_SESSION_ID not set}" # Credentials, cheapest first. Your session has usually INHERITED the server's @@ -7,7 +10,7 @@ SELF="${CODEMAN_SESSION_ID:?CODEMAN_SESSION_ID not set}" # reads. The data dir is wherever the hook-secret file lives. Values may be # quoted or `export`-prefixed. ENV_FILE="${CODEMAN_HOOK_SECRET_FILE:+${CODEMAN_HOOK_SECRET_FILE%hook-secret}.env}" -envval() { sed -n "s/^\(export \)\{0,1\}$1=//p" "$ENV_FILE" | tail -1 | sed 's/^"\(.*\)"$/\1/; s/^'\''\(.*\)'\''$/\1/'; } +envval() { sed -n "s/^\(export \)\{0,1\}${1}=//p" "$ENV_FILE" | tail -1 | sed 's/^"\(.*\)"$/\1/; s/^'\''\(.*\)'\''$/\1/'; } if [ -z "${CODEMAN_PASSWORD:-}" ] && [ -n "$ENV_FILE" ] && [ -f "$ENV_FILE" ]; then CODEMAN_USERNAME=$(envval CODEMAN_USERNAME) CODEMAN_PASSWORD=$(envval CODEMAN_PASSWORD) @@ -42,15 +45,15 @@ delete_session() { # ---- fast path: the four verbs, already written. §1 composes them. ---- _composer_up() { # -> "true"/"false". `shift+tab` is the one token - "${CURL[@]}" -G "$API/api/v1/sessions/$1/wait-output" \ + "${CURL[@]}" -G "$API/api/v1/sessions/${1}/wait-output" \ --data-urlencode 'match=shift+tab' --data-urlencode 'from=buffer' \ - --data-urlencode "timeout=$2" | jq -r '.data.wait.matched // false' + --data-urlencode "timeout=${2}" | jq -r '.data.wait.matched // false' } _dsh_up() { # -> "true"/"false". The DeepSeek Harness TUI's # composer glyph. Override with DSH_READY_MARK for a profile that draws another one. - "${CURL[@]}" -G "$API/api/v1/sessions/$1/wait-output" \ + "${CURL[@]}" -G "$API/api/v1/sessions/${1}/wait-output" \ --data-urlencode "match=${DSH_READY_MARK:-❯}" --data-urlencode 'from=buffer' \ - --data-urlencode "timeout=$2" | jq -r '.data.wait.matched // false' + --data-urlencode "timeout=${2}" | jq -r '.data.wait.matched // false' } # ---- the workspace-trust dialog: READ the screen, never press Enter blind ---- # Claude Code 2.1.252 dropped the option numbers, REVERSED them, and highlights @@ -66,7 +69,7 @@ _trust_key() { # -> "confirm" | "move" | "" (nothing safe to press) # full=1 returns the RENDERED pane; a claude pane keeps no tmux history, so that # is the current frame rather than every repaint since launch. tail -1 anyway, # because the freshest marked row is the only one still true. - "${CURL[@]}" -G "$API/api/v1/sessions/$1/terminal" --data-urlencode 'full=1' \ + "${CURL[@]}" -G "$API/api/v1/sessions/${1}/terminal" --data-urlencode 'full=1' \ | jq -r '.data.terminalBuffer // empty' \ | sed -e "s/$(printf '\033')\[[0-9;?]*[a-zA-Z]//g" -e "s/$(printf '\033')[()][AB0]//g" \ | tr -d ' \t' | grep -i '❯[0-9.]*\(yes,itrustthisfolder\|no,exit\)' | tail -1 \ @@ -84,7 +87,7 @@ _composer_text() { # -> the composer's text with ALL whitespace removed: " # the LAST `❯` line: Claude Code echoes a submitted prompt with the same glyph higher # up in the transcript, so only the last one says whether the text was taken. local t - t=$("${CURL[@]}" -G "$API/api/v1/sessions/$1/terminal" --data-urlencode 'full=1' \ + t=$("${CURL[@]}" -G "$API/api/v1/sessions/${1}/terminal" --data-urlencode 'full=1' \ | jq -r '.data.terminalBuffer // empty' \ | sed -e "s/$(printf '\033')\[[0-9;?]*[a-zA-Z]//g" -e "s/$(printf '\033')[()][AB0]//g" \ | tr -d '\r' | grep -a '^[[:space:]]*❯' | tail -1) @@ -94,7 +97,7 @@ _composer_text() { # -> the composer's text with ALL whitespace removed: " printf '%s' "$t" | sed 's/^[[:space:]]*❯//' | tr -d '[:space:]' | sed "s/$(printf '\302\240')//g" } _accept_trust() { # -> 0 once it has answered the dialog, 1 if it could not - local sid="$1" k i=1 + local sid="${1}" k i=1 while [ "$i" -le 6 ]; do k=$(_trust_key "$sid") [ -n "$k" ] || return 1 # no dialog on screen, or a layout this cannot read @@ -295,7 +298,7 @@ sendwait() { last_text() { local t="" prev="${2:-}" for _ in $(seq 1 15); do - t=$("${CURL[@]}" "$API/api/v1/sessions/$1/last-response" | jq -r '.data.text // empty') + t=$("${CURL[@]}" "$API/api/v1/sessions/${1}/last-response" | jq -r '.data.text // empty') [ -n "$t" ] && [ "$t" != "$prev" ] && { printf '%s\n' "$t"; return 0; } sleep 1 done @@ -306,4 +309,4 @@ last_text() { # The stamp is the LAST line on purpose (a truncated write leaves it unset) and is kept # bare on purpose: the write condition above anchors on it with $, so an inline comment # here would fail that match and rewrite this file on every single bootstrap. -CODEMAN_PREAMBLE=1.33.4 +CODEMAN_PREAMBLE=1.41.2 diff --git a/skills/codeman/reference/recipes.md b/skills/codeman/reference/recipes.md index 352d4e00b..3f7fde4b1 100644 --- a/skills/codeman/reference/recipes.md +++ b/skills/codeman/reference/recipes.md @@ -21,7 +21,7 @@ by sourcing the preamble file the §0 bootstrap wrote, and checking its version ```bash . "${XDG_CACHE_HOME:-$HOME/.cache}/codeman-agent-$CODEMAN_SESSION_ID.sh" 2>/dev/null -[ "${CODEMAN_PREAMBLE:-}" = 1.33.4 ] || { echo "preamble missing or stale; re-run the §0 bootstrap"; exit 1; } +[ "${CODEMAN_PREAMBLE:-}" = 1.41.2 ] || { echo "preamble missing or stale; re-run the §0 bootstrap"; exit 1; } ``` Do **not** re-paste the preamble body into each call. Sourcing it is what retires the diff --git a/test/agent-skill.test.ts b/test/agent-skill.test.ts index 8c4f113d6..f3c9340fe 100644 --- a/test/agent-skill.test.ts +++ b/test/agent-skill.test.ts @@ -163,6 +163,30 @@ describe('preamble single-source (seed + §0 heredoc parity)', () => { expect(preamble).toBe(heredoc); }); + it("SKILL.md's shell survives Claude Code's skill-argument substitution unchanged", async () => { + // When Claude Code loads a skill WITH arguments (the Skill tool passes the user's request words), + // it rewrites the skill body before the model sees it. Measured on 2.1.300: every `$ARGUMENTS[N]`, + // `$ARGUMENTS` and bare `$N` (digits, then no word character) becomes the matching argument, 0-based, + // and a backslash before `$` is consumed as an escape. A bare `$1` in §0 arrived as + // `sessions/5/terminal` and `timeout=claude` for "start 5 claude workers", so an agent pasting §0 + // wrote a broken preamble. `${1}` is the same bash and is never touched; keep it that way. + const skillMd = await readFile(join(packagedDir, 'SKILL.md'), 'utf-8'); + const substitute = (body: string, args: string[]): string => + body + .replace(/(? args[Number(i)] ?? m) + .replace(/\$(\d+)(?!\w)/g, (m, i: string) => args[Number(i)] ?? m) + .replaceAll('$ARGUMENTS', args.join(' ')); + const args = 'start 5 claude workers, one per TODO item'.split(/\s+/); + expect(substitute(skillMd, args)).toBe(skillMd); + + // And the heredoc an agent pastes from the substituted body is still the seeded preamble. + const rendered = substitute(skillMd, args); + const start = rendered.indexOf("<<'PREAMBLE'\n") + "<<'PREAMBLE'\n".length; + const preamble = await readFile(join(packagedDir, 'preamble.sh'), 'utf-8'); + expect(rendered.slice(start, rendered.indexOf('\nPREAMBLE\n', start) + 1)).toBe(preamble); + }); + it('seedAgentSessionPreamble writes the stamped preamble to the XDG cache path, 0600', async () => { const prevXdg = process.env.XDG_CACHE_HOME; const cacheDir = join(casePath, 'xdg-cache');