fix(skill): two workers in one folder no longer share a sendmark answer file

Seen on a MacBook (M5 Pro, 2026-11-12), in a real Claude Code session: the lead started a
Pi worker and an OpenCode worker on the local model in ITS OWN case, sent both their tasks
at once, and reported afterwards that one written answer was lost. Both prompts had named
the same file, codeman-answer-<token>.md, and the second worker's summary replaced the
first one's. The code the workers wrote was not affected.

The token was the second plus $RANDOM. Claude Code runs zsh on a Mac, and zsh hands every
subshell the same $RANDOM until the parent shell reads it (measured there: four subshells,
one number, where bash drew four). The token line is itself a subshell, so under zsh the
"random" part never changed, and two calls in one second shared a token. Workers in their
own cases never noticed: same name, different folders, and the marker is matched per pane.

The token now ends in six characters of the worker's id. The test makes $RANDOM a plain
variable that always reads the same, which is what zsh does, and runs two calls at once.
The reference gets one sentence for hand-built markers in a fan-out.

No new preamble version: 1.51.0 is not released yet.

Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
This commit is contained in:
Codeman maintainer committed 2026-11-12 00:45:39 +01:00
1 parent ee0c9f4db9
commit d873555cc2
7 files changed
+67 -10

No files matched your search

+7 -2
View File
@@ -643,7 +643,7 @@ sendwait() {
# complete answer to codeman-answer-<token>.md in the working directory, and only then end
# the reply with the word DONE immediately followed by _<token>. 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; }
+7 -2
View File
@@ -505,7 +505,7 @@ sendwait() {
# complete answer to codeman-answer-<token>.md in the working directory, and only then end
# the reply with the word DONE immediately followed by _<token>. 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; }
@@ -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`
+7 -2
View File
@@ -643,7 +643,7 @@ sendwait() {
# complete answer to codeman-answer-<token>.md in the working directory, and only then end
# the reply with the word DONE immediately followed by _<token>. 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; }
+7 -2
View File
@@ -505,7 +505,7 @@ sendwait() {
# complete answer to codeman-answer-<token>.md in the working directory, and only then end
# the reply with the word DONE immediately followed by _<token>. 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; }
+4 -1
View File
@@ -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`
+31
View File
@@ -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