diff --git a/plugins/codeman/skills/codeman/SKILL.md b/plugins/codeman/skills/codeman/SKILL.md index c6ffbf58..75071367 100644 --- a/plugins/codeman/skills/codeman/SKILL.md +++ b/plugins/codeman/skills/codeman/SKILL.md @@ -643,7 +643,7 @@ sendwait() { # complete answer to codeman-answer-.md in the working directory, and only then end # the reply with the word DONE immediately followed by _. The marker is split in what # is typed, so the prompt echoing into the pane never matches it, and the token is new for -# every call (§5.5). The send is sendwait's exactly-once body (per-worker clientId, +# every call and every worker (§5.5). The send is sendwait's exactly-once body (per-worker clientId, # epoch-second seq) with a one-second `exit` wait, so `delivered` says the pane took it. # ⚠️ A marker proves that the model printed the token. Not that it did the work, not that # it did it right, not even that the turn is over: a reasoning model can restate the @@ -667,7 +667,12 @@ sendmark() { p=$(printf '%s\n' "$p" | tr '\r' '\n' | sed -e 's/^[[:space:]]*//' -e 's/[[:space:]]*$//' -e '/^$/d' | paste -sd ' ' -) ;; esac [ -n "$p" ] || { echo "sendmark: empty prompt" >&2; return 1; } - tok=$(printf 'm%x%04x' "$(date +%s)" "$RANDOM"); mark="DONE_$tok" + # The token carries six characters of the WORKER's id on top of the second and $RANDOM. + # Those two repeat: in zsh (the shell Claude Code runs on a Mac) every subshell draws the + # SAME $RANDOM until the parent shell reads it, and this line is a subshell. Two sendmark + # calls started in one second for two workers in ONE folder shared a token, and one + # worker's answer file replaced the other's (seen on a Mac, 2026-11-12). + tok=$(printf 'm%x%04x%s' "$(date +%s)" "$RANDOM" "$(printf '%s' "$sid" | tr -cd 'a-f0-9' | cut -c1-6)"); mark="DONE_$tok" if [ "${SENDMARK_ANSWER_FILE:-1}" != 0 ]; then wd=$("${CURL[@]}" "$API/api/v1/sessions/$sid" | jq -r '.data.workingDir // empty' 2>/dev/null) [ -n "$wd" ] || { echo "sendmark: could not read the working directory of worker $sid" >&2; return 1; } diff --git a/plugins/codeman/skills/codeman/preamble.sh b/plugins/codeman/skills/codeman/preamble.sh index a0fab818..96ed0d2f 100644 --- a/plugins/codeman/skills/codeman/preamble.sh +++ b/plugins/codeman/skills/codeman/preamble.sh @@ -505,7 +505,7 @@ sendwait() { # complete answer to codeman-answer-.md in the working directory, and only then end # the reply with the word DONE immediately followed by _. The marker is split in what # is typed, so the prompt echoing into the pane never matches it, and the token is new for -# every call (§5.5). The send is sendwait's exactly-once body (per-worker clientId, +# every call and every worker (§5.5). The send is sendwait's exactly-once body (per-worker clientId, # epoch-second seq) with a one-second `exit` wait, so `delivered` says the pane took it. # ⚠️ A marker proves that the model printed the token. Not that it did the work, not that # it did it right, not even that the turn is over: a reasoning model can restate the @@ -529,7 +529,12 @@ sendmark() { p=$(printf '%s\n' "$p" | tr '\r' '\n' | sed -e 's/^[[:space:]]*//' -e 's/[[:space:]]*$//' -e '/^$/d' | paste -sd ' ' -) ;; esac [ -n "$p" ] || { echo "sendmark: empty prompt" >&2; return 1; } - tok=$(printf 'm%x%04x' "$(date +%s)" "$RANDOM"); mark="DONE_$tok" + # The token carries six characters of the WORKER's id on top of the second and $RANDOM. + # Those two repeat: in zsh (the shell Claude Code runs on a Mac) every subshell draws the + # SAME $RANDOM until the parent shell reads it, and this line is a subshell. Two sendmark + # calls started in one second for two workers in ONE folder shared a token, and one + # worker's answer file replaced the other's (seen on a Mac, 2026-11-12). + tok=$(printf 'm%x%04x%s' "$(date +%s)" "$RANDOM" "$(printf '%s' "$sid" | tr -cd 'a-f0-9' | cut -c1-6)"); mark="DONE_$tok" if [ "${SENDMARK_ANSWER_FILE:-1}" != 0 ]; then wd=$("${CURL[@]}" "$API/api/v1/sessions/$sid" | jq -r '.data.workingDir // empty' 2>/dev/null) [ -n "$wd" ] || { echo "sendmark: could not read the working directory of worker $sid" >&2; return 1; } diff --git a/plugins/codeman/skills/codeman/reference/endpoints.md b/plugins/codeman/skills/codeman/reference/endpoints.md index 8a41cacf..29b6c400 100644 --- a/plugins/codeman/skills/codeman/reference/endpoints.md +++ b/plugins/codeman/skills/codeman/reference/endpoints.md @@ -216,7 +216,10 @@ you typed matches the moment it is typed. generic marker (`BUILD OK`) matched against stale text, either from `from=buffer` scanning an earlier run or from tmux replaying old screen content as fresh output on an attach/resize/redraw. A unique-per-call token (`DONE_$RANDOM`) makes both `from` modes -safe. +safe. ⚠️ In a fan-out put something of the WORKER into it too (its id, the loop index): zsh, +the shell Claude Code runs on a Mac, hands every subshell the same `$RANDOM` until the +parent shell reads it, so `( … $RANDOM … ) &` twice draws one number. `sendmark` adds six +characters of the worker's id for that reason. ### 6. `jq` parse error instead of an `errorCode` diff --git a/skills/codeman/SKILL.md b/skills/codeman/SKILL.md index c6ffbf58..75071367 100644 --- a/skills/codeman/SKILL.md +++ b/skills/codeman/SKILL.md @@ -643,7 +643,7 @@ sendwait() { # complete answer to codeman-answer-.md in the working directory, and only then end # the reply with the word DONE immediately followed by _. The marker is split in what # is typed, so the prompt echoing into the pane never matches it, and the token is new for -# every call (§5.5). The send is sendwait's exactly-once body (per-worker clientId, +# every call and every worker (§5.5). The send is sendwait's exactly-once body (per-worker clientId, # epoch-second seq) with a one-second `exit` wait, so `delivered` says the pane took it. # ⚠️ A marker proves that the model printed the token. Not that it did the work, not that # it did it right, not even that the turn is over: a reasoning model can restate the @@ -667,7 +667,12 @@ sendmark() { p=$(printf '%s\n' "$p" | tr '\r' '\n' | sed -e 's/^[[:space:]]*//' -e 's/[[:space:]]*$//' -e '/^$/d' | paste -sd ' ' -) ;; esac [ -n "$p" ] || { echo "sendmark: empty prompt" >&2; return 1; } - tok=$(printf 'm%x%04x' "$(date +%s)" "$RANDOM"); mark="DONE_$tok" + # The token carries six characters of the WORKER's id on top of the second and $RANDOM. + # Those two repeat: in zsh (the shell Claude Code runs on a Mac) every subshell draws the + # SAME $RANDOM until the parent shell reads it, and this line is a subshell. Two sendmark + # calls started in one second for two workers in ONE folder shared a token, and one + # worker's answer file replaced the other's (seen on a Mac, 2026-11-12). + tok=$(printf 'm%x%04x%s' "$(date +%s)" "$RANDOM" "$(printf '%s' "$sid" | tr -cd 'a-f0-9' | cut -c1-6)"); mark="DONE_$tok" if [ "${SENDMARK_ANSWER_FILE:-1}" != 0 ]; then wd=$("${CURL[@]}" "$API/api/v1/sessions/$sid" | jq -r '.data.workingDir // empty' 2>/dev/null) [ -n "$wd" ] || { echo "sendmark: could not read the working directory of worker $sid" >&2; return 1; } diff --git a/skills/codeman/preamble.sh b/skills/codeman/preamble.sh index a0fab818..96ed0d2f 100644 --- a/skills/codeman/preamble.sh +++ b/skills/codeman/preamble.sh @@ -505,7 +505,7 @@ sendwait() { # complete answer to codeman-answer-.md in the working directory, and only then end # the reply with the word DONE immediately followed by _. The marker is split in what # is typed, so the prompt echoing into the pane never matches it, and the token is new for -# every call (§5.5). The send is sendwait's exactly-once body (per-worker clientId, +# every call and every worker (§5.5). The send is sendwait's exactly-once body (per-worker clientId, # epoch-second seq) with a one-second `exit` wait, so `delivered` says the pane took it. # ⚠️ A marker proves that the model printed the token. Not that it did the work, not that # it did it right, not even that the turn is over: a reasoning model can restate the @@ -529,7 +529,12 @@ sendmark() { p=$(printf '%s\n' "$p" | tr '\r' '\n' | sed -e 's/^[[:space:]]*//' -e 's/[[:space:]]*$//' -e '/^$/d' | paste -sd ' ' -) ;; esac [ -n "$p" ] || { echo "sendmark: empty prompt" >&2; return 1; } - tok=$(printf 'm%x%04x' "$(date +%s)" "$RANDOM"); mark="DONE_$tok" + # The token carries six characters of the WORKER's id on top of the second and $RANDOM. + # Those two repeat: in zsh (the shell Claude Code runs on a Mac) every subshell draws the + # SAME $RANDOM until the parent shell reads it, and this line is a subshell. Two sendmark + # calls started in one second for two workers in ONE folder shared a token, and one + # worker's answer file replaced the other's (seen on a Mac, 2026-11-12). + tok=$(printf 'm%x%04x%s' "$(date +%s)" "$RANDOM" "$(printf '%s' "$sid" | tr -cd 'a-f0-9' | cut -c1-6)"); mark="DONE_$tok" if [ "${SENDMARK_ANSWER_FILE:-1}" != 0 ]; then wd=$("${CURL[@]}" "$API/api/v1/sessions/$sid" | jq -r '.data.workingDir // empty' 2>/dev/null) [ -n "$wd" ] || { echo "sendmark: could not read the working directory of worker $sid" >&2; return 1; } diff --git a/skills/codeman/reference/endpoints.md b/skills/codeman/reference/endpoints.md index 8a41cacf..29b6c400 100644 --- a/skills/codeman/reference/endpoints.md +++ b/skills/codeman/reference/endpoints.md @@ -216,7 +216,10 @@ you typed matches the moment it is typed. generic marker (`BUILD OK`) matched against stale text, either from `from=buffer` scanning an earlier run or from tmux replaying old screen content as fresh output on an attach/resize/redraw. A unique-per-call token (`DONE_$RANDOM`) makes both `from` modes -safe. +safe. ⚠️ In a fan-out put something of the WORKER into it too (its id, the loop index): zsh, +the shell Claude Code runs on a Mac, hands every subshell the same `$RANDOM` until the +parent shell reads it, so `( … $RANDOM … ) &` twice draws one number. `sendmark` adds six +characters of the worker's id for that reason. ### 6. `jq` parse error instead of an `errorCode` diff --git a/test/agent-skill-local-model.test.ts b/test/agent-skill-local-model.test.ts index a0a961a5..5dccc3ea 100644 --- a/test/agent-skill-local-model.test.ts +++ b/test/agent-skill-local-model.test.ts @@ -773,6 +773,37 @@ describe.skipIf(!hasJq)('preamble sendmark', () => { expect(run.calls.some((call) => call.key === 'delete')).toBe(false); }); + // Seen on a Mac (2026-11-12): Claude Code runs zsh there, and zsh hands every subshell the same + // $RANDOM. A lead sent two workers in ONE folder their tasks in the same second, both prompts + // named the same answer file, and one worker's summary replaced the other's. + it('gives each worker its own token and answer file, in the same second and with the same $RANDOM', () => { + const other = 'b7e1c2d3-0000-4000-8000-00000000beef'; + const run = runSkill( + withSession( + // Two calls at once that draw the same number: what zsh does by itself. bash reseeds its + // generator in every subshell, so here the variable is made a plain one that always reads 7. + `unset RANDOM; RANDOM=7 + ( sendmark ${SID} 'count to three' 1 >/dev/null ) & + ( sendmark ${other} 'count to three' 1 >/dev/null ) & + wait; true` + ), + { input: delivered, 'wait-output': NOT_MATCHED, 'wait-output.delay': '1' } + ); + expect(run.stderr).toBe(''); + const tokens = run.calls + .filter((call) => call.key === 'input') + .map((call) => /codeman-answer-(m[0-9a-f]+)\.md/.exec((JSON.parse(call.d) as { input: string }).input)?.[1] ?? '') + .sort(); + expect(tokens).toHaveLength(2); + // m, eight digits of the second, four of $RANDOM, six of the worker's id. + for (const token of tokens) expect(token).toMatch(/^m[0-9a-f]{18}$/); + // The same draw in both calls... + expect(tokens.map((token) => token.slice(9, 13))).toEqual(['0007', '0007']); + // ...and still two files, told apart by the workers' own ids. + expect(tokens[0]).not.toBe(tokens[1]); + expect(tokens.map((token) => token.slice(13)).sort()).toEqual(['b7e1c2', 'eca000']); + }); + it('reads the answer file without a marker once it held still for a round (a full-screen pane draws the marker in pieces)', () => { // Measured on opencode 1.3.0: a reply streamed a character or two at a time reaches the // stripped stream as D, spinner, O, spinner, N, ... and never matches, while the screen