diff --git a/plugins/codeman/skills/codeman/SKILL.md b/plugins/codeman/skills/codeman/SKILL.md index 014be53a..2d3727c6 100644 --- a/plugins/codeman/skills/codeman/SKILL.md +++ b/plugins/codeman/skills/codeman/SKILL.md @@ -363,9 +363,12 @@ spawn_worker() { mode=$(jq -r .cli <<<"$lm") cm=$(jq -c '{endpointId, modelId}' <<<"$lm") # Readiness is a token the agent's composer draws. The registry's composer data never - # reaches the API, so it is named per agent here: opencode draws `┃` at the start of - # every composer row (its `workDetect.promptGlyph`); claude takes the claude path - # below. Any other agent needs LOCAL_READY_MARK rather than a worker handed back on a guess. + # reaches the API, so it is named per agent here. opencode: `┃`, its `workDetect.promptGlyph`. + # That field is no readiness mark in general (the registry keeps `composerReadyMark` for + # that, and declares none for opencode), but at boot only the composer draws it: measured + # on opencode 1.3.0, fresh home, custom provider, the first `┃` came with the composer, + # and a prompt pasted with its Enter at that moment was submitted. claude takes the claude + # path below. Any other agent needs LOCAL_READY_MARK rather than a worker handed back on a guess. [ "$mode" = opencode ] && mark='┃' mark=${LOCAL_READY_MARK:-$mark} [ "$mode" = claude ] || [ -n "$mark" ] || { @@ -560,10 +563,11 @@ sendwait() { # end-of-turn signal: a `local` worker whose agent is not claude, a hook-less claude, and # every external agent mode but deepseek (a shell worker takes §5.5's echo form instead): # {"token":…,"delivered":true,"matched":true,"answerFile":"","answer":"",…} -# rc 0: the marker printed and the answer file was read. 2: no marker within the timeout -# (default 600 s; the worker may still be working, so nothing is deleted). 3: the worker's -# pane ended. 4: the marker printed, but no answer file turned up within SENDMARK_FILE_WAIT -# seconds of it (default 120). 1: the send or a wait failed (`detail` says why). +# rc 0: the answer file was read, after the marker or once the file held still (below). +# 2: neither within the timeout (default 600 s; the worker may still be working, so nothing +# is deleted). 3: the worker's pane ended. 4: the marker printed, but no answer file turned +# up within SENDMARK_FILE_WAIT seconds of it (default 120). 1: the send or a wait failed +# (`detail` says why). # It adds ONE sentence to your prompt (joined onto one line, as sendwait does): write the # 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 @@ -572,15 +576,22 @@ sendwait() { # 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 -# instruction while it plans. That is why the answer FILE is the second gate. It is read -# from this machine's disk once the marker is out, only when it is a regular file (a -# symlink is refused), at most 256 KB of it. It stays in the worker's folder (§5.14). +# instruction while it plans. That is why the answer FILE is the second gate. ⚠️ And a +# full-screen agent can print the marker without it ever matching: opencode redraws its +# spinner between the pieces a model streams, so the stripped stream reads D⬝⬝O⬝⬝N⬝⬝E_⬝⬝m… +# (measured on opencode 1.3.0 with a reply streamed a character or two at a time; the screen +# showed the whole token). So the file is also the way out WITHOUT the marker: present, not +# empty, and unchanged between two rounds of the marker wait (rounds of at most 10 s), it is +# read and `matched` says false. It is read from this machine's disk, only when it is a +# regular file (a symlink is refused), at most 256 KB of it. It stays in the worker's folder +# (§5.14). # SENDMARK_ANSWER_FILE=0 asks for the marker alone, for a worker that cannot write where it # runs (codex in a folder it runs read-only, an agent that asks before it writes a file, a # worker on another host): rc 0 then means only "the marker printed", and the answer is -# yours to read (last_text for codex, §5.4). +# yours to read (last_text for codex, §5.4). Without the file there is no way out but the +# marker, so on a full-screen agent that can be rc 2 for a reply that finished. sendmark() { - local sid="${1:?}" p="${2:?}" t="${3:-600}" tok mark file='' wd r w='' rem end start got=0 ended=0 matched=false i=0 rc=2 + local sid="${1:?}" p="${2:?}" t="${3:-600}" tok mark file='' wd r w='' rem end start got=0 ended=0 matched=false i=0 rc=2 fp prev='' case "$p" in *$'\n'*|*$'\r'*) p=$(printf '%s\n' "$p" | tr '\r' '\n' | sed -e 's/^[[:space:]]*//' -e 's/[[:space:]]*$//' -e '/^$/d' | paste -sd ' ' -) ;; esac @@ -603,9 +614,9 @@ sendmark() { return "$rc" fi start=$(date +%s); end=$((start + t)) - while :; do # bounded waits of at most 60 s each, the server's default, until the timeout + while :; do # bounded waits of at most 10 s each, until the timeout rem=$((end - $(date +%s))); [ "$rem" -ge 1 ] || break - [ "$rem" -le 60 ] || rem=60 + [ "$rem" -le 10 ] || rem=10 w=$("${CURL[@]}" -G "$API/api/v1/sessions/$sid/wait-output" --data-urlencode "match=$mark" \ --data-urlencode 'from=buffer' --data-urlencode "timeout=$((rem * 1000))") jq -e '.data.wait.matched' <<<"$w" >/dev/null 2>&1 && { matched=true; break; } @@ -613,22 +624,31 @@ sendmark() { jq -e '.success' <<<"$w" >/dev/null 2>&1 || { jq -nc --arg t "$tok" --arg w "$w" '{token:$t,delivered:true,matched:false,answerFile:null,answer:null,detail:($w | fromjson? // $w)}' return 1; } + # No marker this round. The answer file is the other way out (see above): it counts once + # it is a non-empty regular file whose checksum did not change since the last round. + if [ -n "$file" ] && [ ! -L "$file" ] && [ -f "$file" ] && [ -s "$file" ]; then + fp=$(cksum < "$file"); [ "$fp" = "$prev" ] && { got=1; break; }; prev=$fp + else + prev='' + fi done if [ "$matched" = true ] && [ -n "$file" ]; then while :; do [ -L "$file" ] && { echo "sendmark: $file is a symlink, not read" >&2; break; } [ -f "$file" ] && { got=1; break; } [ "$i" -lt "${SENDMARK_FILE_WAIT:-120}" ] || break - # Two seconds of waiting that also notices a worker that died: an exit wait answers at once then. - "${CURL[@]}" -G "$API/api/v1/sessions/$sid/wait" --data-urlencode 'until=exit' --data-urlencode 'timeout=2000' \ - | jq -e '.data.wait.signal == "exit"' >/dev/null 2>&1 && { ended=1; break; } + # Two seconds of waiting that also notices a worker that died: an exit wait answers at + # once then. A wait that did not answer (server gone) still costs its two seconds. + w=$("${CURL[@]}" -G "$API/api/v1/sessions/$sid/wait" --data-urlencode 'until=exit' --data-urlencode 'timeout=2000') + jq -e '.data.wait.signal == "exit" or .data.wait.ended' <<<"$w" >/dev/null 2>&1 && { ended=1; break; } + jq -e '.success' <<<"$w" >/dev/null 2>&1 || sleep 2 i=$((i + 2)) done fi if [ "$got" = 1 ]; then - head -c 262144 "$file" | jq -Rsc --arg t "$tok" --arg f "$file" --argjson s "$(($(date +%s) - start))" \ - --argjson c "$(($(wc -c < "$file")))" \ - '{token:$t,delivered:true,matched:true,answerFile:$f,answer:.,truncated:($c > 262144),waitedSec:$s}' + head -c 262144 "$file" | jq -Rsc --arg t "$tok" --arg f "$file" --argjson m "$matched" \ + --argjson s "$(($(date +%s) - start))" --argjson c "$(($(wc -c < "$file")))" \ + '{token:$t,delivered:true,matched:$m,answerFile:$f,answer:.,truncated:($c > 262144),waitedSec:$s}' return 0 fi jq -nc --arg t "$tok" --arg f "$file" --argjson m "$matched" --argjson e "$ended" --argjson s "$(($(date +%s) - start))" \ @@ -850,18 +870,19 @@ Reach for it when: local_model || exit 1 # one JSON line: model, agent, endpoint; or the reason there is none SID=$(spawn_worker summ local) || exit 1 # starts a stopped runtime first; empty = failed WD=$("${CURL[@]}" "$API/api/v1/sessions/$SID" | jq -r .data.workingDir) -cp ./docs/*.md "$WD/" # its input goes inside its own folder (§5.9) -R=$(sendmark "$SID" 'Summarize each .md file in this folder in two lines' 900); RC=$? +mkdir -p "$WD/in" && cp ./docs/*.md "$WD/in/" # inside its own folder (§5.9), apart from its CLAUDE.md +R=$(sendmark "$SID" 'Summarize each .md file in the folder in, two lines each' 900); RC=$? jq -r '.answer // "(no answer)"' <<<"$R" if [ "$RC" = 0 ]; then delete_session "$SID" >/dev/null -else echo "kept $SID (sendmark rc $RC: 2 no marker yet, 3 ended, 4 no answer file): $R"; fi +else echo "kept $SID (sendmark rc $RC: 2 no answer yet, 3 ended, 4 no answer file): $R"; fi ``` `sendmark` is the local worker's `sendwait`: it adds one sentence to your prompt asking for the answer in `codeman-answer-.md` in the worker's folder and then a split marker, -waits for the marker with `wait-output`, and reads the file back -([§5.16](reference/verbs.md#516-a-worker-on-the-local-model)). Read its `answer`, not the -pane: a full-screen agent's pane is a poor transcript. ⚠️ A marker proves that the model +waits for the marker with `wait-output`, and reads the file back. OpenCode can draw the +marker in pieces that never match, so a file that holds still between two checks ends the +wait too ([§5.16](reference/verbs.md#516-a-worker-on-the-local-model)). Read its `answer`, +not the pane: a full-screen agent's pane is a poor transcript. ⚠️ A marker proves that the model printed a token, not that the work is done or right. Review what comes back the way you would review any first draft. diff --git a/plugins/codeman/skills/codeman/preamble.sh b/plugins/codeman/skills/codeman/preamble.sh index 7901e617..5a83b9bd 100644 --- a/plugins/codeman/skills/codeman/preamble.sh +++ b/plugins/codeman/skills/codeman/preamble.sh @@ -225,9 +225,12 @@ spawn_worker() { mode=$(jq -r .cli <<<"$lm") cm=$(jq -c '{endpointId, modelId}' <<<"$lm") # Readiness is a token the agent's composer draws. The registry's composer data never - # reaches the API, so it is named per agent here: opencode draws `┃` at the start of - # every composer row (its `workDetect.promptGlyph`); claude takes the claude path - # below. Any other agent needs LOCAL_READY_MARK rather than a worker handed back on a guess. + # reaches the API, so it is named per agent here. opencode: `┃`, its `workDetect.promptGlyph`. + # That field is no readiness mark in general (the registry keeps `composerReadyMark` for + # that, and declares none for opencode), but at boot only the composer draws it: measured + # on opencode 1.3.0, fresh home, custom provider, the first `┃` came with the composer, + # and a prompt pasted with its Enter at that moment was submitted. claude takes the claude + # path below. Any other agent needs LOCAL_READY_MARK rather than a worker handed back on a guess. [ "$mode" = opencode ] && mark='┃' mark=${LOCAL_READY_MARK:-$mark} [ "$mode" = claude ] || [ -n "$mark" ] || { @@ -422,10 +425,11 @@ sendwait() { # end-of-turn signal: a `local` worker whose agent is not claude, a hook-less claude, and # every external agent mode but deepseek (a shell worker takes §5.5's echo form instead): # {"token":…,"delivered":true,"matched":true,"answerFile":"","answer":"",…} -# rc 0: the marker printed and the answer file was read. 2: no marker within the timeout -# (default 600 s; the worker may still be working, so nothing is deleted). 3: the worker's -# pane ended. 4: the marker printed, but no answer file turned up within SENDMARK_FILE_WAIT -# seconds of it (default 120). 1: the send or a wait failed (`detail` says why). +# rc 0: the answer file was read, after the marker or once the file held still (below). +# 2: neither within the timeout (default 600 s; the worker may still be working, so nothing +# is deleted). 3: the worker's pane ended. 4: the marker printed, but no answer file turned +# up within SENDMARK_FILE_WAIT seconds of it (default 120). 1: the send or a wait failed +# (`detail` says why). # It adds ONE sentence to your prompt (joined onto one line, as sendwait does): write the # 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 @@ -434,15 +438,22 @@ sendwait() { # 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 -# instruction while it plans. That is why the answer FILE is the second gate. It is read -# from this machine's disk once the marker is out, only when it is a regular file (a -# symlink is refused), at most 256 KB of it. It stays in the worker's folder (§5.14). +# instruction while it plans. That is why the answer FILE is the second gate. ⚠️ And a +# full-screen agent can print the marker without it ever matching: opencode redraws its +# spinner between the pieces a model streams, so the stripped stream reads D⬝⬝O⬝⬝N⬝⬝E_⬝⬝m… +# (measured on opencode 1.3.0 with a reply streamed a character or two at a time; the screen +# showed the whole token). So the file is also the way out WITHOUT the marker: present, not +# empty, and unchanged between two rounds of the marker wait (rounds of at most 10 s), it is +# read and `matched` says false. It is read from this machine's disk, only when it is a +# regular file (a symlink is refused), at most 256 KB of it. It stays in the worker's folder +# (§5.14). # SENDMARK_ANSWER_FILE=0 asks for the marker alone, for a worker that cannot write where it # runs (codex in a folder it runs read-only, an agent that asks before it writes a file, a # worker on another host): rc 0 then means only "the marker printed", and the answer is -# yours to read (last_text for codex, §5.4). +# yours to read (last_text for codex, §5.4). Without the file there is no way out but the +# marker, so on a full-screen agent that can be rc 2 for a reply that finished. sendmark() { - local sid="${1:?}" p="${2:?}" t="${3:-600}" tok mark file='' wd r w='' rem end start got=0 ended=0 matched=false i=0 rc=2 + local sid="${1:?}" p="${2:?}" t="${3:-600}" tok mark file='' wd r w='' rem end start got=0 ended=0 matched=false i=0 rc=2 fp prev='' case "$p" in *$'\n'*|*$'\r'*) p=$(printf '%s\n' "$p" | tr '\r' '\n' | sed -e 's/^[[:space:]]*//' -e 's/[[:space:]]*$//' -e '/^$/d' | paste -sd ' ' -) ;; esac @@ -465,9 +476,9 @@ sendmark() { return "$rc" fi start=$(date +%s); end=$((start + t)) - while :; do # bounded waits of at most 60 s each, the server's default, until the timeout + while :; do # bounded waits of at most 10 s each, until the timeout rem=$((end - $(date +%s))); [ "$rem" -ge 1 ] || break - [ "$rem" -le 60 ] || rem=60 + [ "$rem" -le 10 ] || rem=10 w=$("${CURL[@]}" -G "$API/api/v1/sessions/$sid/wait-output" --data-urlencode "match=$mark" \ --data-urlencode 'from=buffer' --data-urlencode "timeout=$((rem * 1000))") jq -e '.data.wait.matched' <<<"$w" >/dev/null 2>&1 && { matched=true; break; } @@ -475,22 +486,31 @@ sendmark() { jq -e '.success' <<<"$w" >/dev/null 2>&1 || { jq -nc --arg t "$tok" --arg w "$w" '{token:$t,delivered:true,matched:false,answerFile:null,answer:null,detail:($w | fromjson? // $w)}' return 1; } + # No marker this round. The answer file is the other way out (see above): it counts once + # it is a non-empty regular file whose checksum did not change since the last round. + if [ -n "$file" ] && [ ! -L "$file" ] && [ -f "$file" ] && [ -s "$file" ]; then + fp=$(cksum < "$file"); [ "$fp" = "$prev" ] && { got=1; break; }; prev=$fp + else + prev='' + fi done if [ "$matched" = true ] && [ -n "$file" ]; then while :; do [ -L "$file" ] && { echo "sendmark: $file is a symlink, not read" >&2; break; } [ -f "$file" ] && { got=1; break; } [ "$i" -lt "${SENDMARK_FILE_WAIT:-120}" ] || break - # Two seconds of waiting that also notices a worker that died: an exit wait answers at once then. - "${CURL[@]}" -G "$API/api/v1/sessions/$sid/wait" --data-urlencode 'until=exit' --data-urlencode 'timeout=2000' \ - | jq -e '.data.wait.signal == "exit"' >/dev/null 2>&1 && { ended=1; break; } + # Two seconds of waiting that also notices a worker that died: an exit wait answers at + # once then. A wait that did not answer (server gone) still costs its two seconds. + w=$("${CURL[@]}" -G "$API/api/v1/sessions/$sid/wait" --data-urlencode 'until=exit' --data-urlencode 'timeout=2000') + jq -e '.data.wait.signal == "exit" or .data.wait.ended' <<<"$w" >/dev/null 2>&1 && { ended=1; break; } + jq -e '.success' <<<"$w" >/dev/null 2>&1 || sleep 2 i=$((i + 2)) done fi if [ "$got" = 1 ]; then - head -c 262144 "$file" | jq -Rsc --arg t "$tok" --arg f "$file" --argjson s "$(($(date +%s) - start))" \ - --argjson c "$(($(wc -c < "$file")))" \ - '{token:$t,delivered:true,matched:true,answerFile:$f,answer:.,truncated:($c > 262144),waitedSec:$s}' + head -c 262144 "$file" | jq -Rsc --arg t "$tok" --arg f "$file" --argjson m "$matched" \ + --argjson s "$(($(date +%s) - start))" --argjson c "$(($(wc -c < "$file")))" \ + '{token:$t,delivered:true,matched:$m,answerFile:$f,answer:.,truncated:($c > 262144),waitedSec:$s}' return 0 fi jq -nc --arg t "$tok" --arg f "$file" --argjson m "$matched" --argjson e "$ended" --argjson s "$(($(date +%s) - start))" \ diff --git a/plugins/codeman/skills/codeman/reference/verbs.md b/plugins/codeman/skills/codeman/reference/verbs.md index 07f442c0..944e7d05 100644 --- a/plugins/codeman/skills/codeman/reference/verbs.md +++ b/plugins/codeman/skills/codeman/reference/verbs.md @@ -866,11 +866,12 @@ confirms in the window, and no route this skill calls can do it. session's model). It fails the spawn with the answer on stderr and nothing on stdout, and no `confirmed*` flag is ever sent. 5. Readiness: OpenCode's composer bar `┃` (its registry `promptGlyph`) through - `wait-output`, 45 s, and the session deleted on a miss. A claude agent takes the claude - path instead (hooks vetted, `shift+tab`, the trust dialog). Any other agent needs - `LOCAL_READY_MARK` set to a space-free token its composer draws, or the spawn is refused - before a session exists. Not measured here: an OpenCode first-run screen that hides the - composer would read as a miss. + `wait-output`, 45 s, and the session deleted on a miss. Measured on OpenCode 1.3.0 with + a fresh home and a custom provider: nothing draws `┃` before the composer, there was no + first-run screen, and a prompt pasted with its Enter at the first `┃` was submitted. A + claude agent takes the claude path instead (hooks vetted, `shift+tab`, the trust + dialog). Any other agent needs `LOCAL_READY_MARK` set to a space-free token its + composer draws, or the spawn is refused before a session exists. **`sendmark [timeoutSec]`** drives it, and any other agent worker without an end-of-turn signal ([§5.5](#55-markers-for-hook-less-workers)): @@ -881,14 +882,20 @@ an end-of-turn signal ([§5.5](#55-markers-for-hook-less-workers)): marker, so its echo cannot match. - It sends once with the exactly-once body (`$CID-`, an epoch-second `seq`, one `\r`) and a one-second `exit` wait, so `delivered` is the pane's answer, not a guess. -- It waits on `wait-output?match=DONE_&from=buffer` in rounds of at most 60 s until +- It waits on `wait-output?match=DONE_&from=buffer` in rounds of at most 10 s until the timeout (default 600 s), then reads the answer file from this machine's disk: only a regular file (a symlink is refused), at most 256 KB. +- ⚠️ A full-screen agent can print the marker without it ever matching. OpenCode redraws + its spinner between the pieces a model streams, so with a reply streamed a character or + two at a time the stripped stream read `D⬝⬝O⬝⬝N⬝⬝E_⬝⬝m…` while the screen showed the whole + token (measured on OpenCode 1.3.0). So between rounds it also looks at the answer file: + once it is non-empty and its checksum held still from one round to the next, it is read + and the wait ends with `matched: false`. | exit | JSON | Meaning | |------|------|---------| -| 0 | `answer` holds the file | the marker printed and the file was read | -| 2 | `matched: false` | no marker before the timeout. The worker may still be working: re-check the pane, never resend blindly | +| 0 | `answer` holds the file | the file was read: after the marker (`matched: true`), or once it held still (`matched: false`) | +| 2 | `matched: false` | neither the marker nor a settled file before the timeout. The worker may still be working: re-check the pane, never resend blindly | | 3 | `ended: true` or `delivered: false` | the worker's pane ended | | 4 | `matched: true, answer: null` | the marker printed, but no file within `SENDMARK_FILE_WAIT` s (default 120): the model ignored the instruction, or a reasoning model restated the marker while planning and is still at work | | 1 | `detail` | the send or a wait failed | @@ -900,8 +907,9 @@ first draft; review it before you build on it. The answer files stay in the work folder, a scratch case that [§5.14](#514-clean-up) lists for cleanup. `SENDMARK_ANSWER_FILE=0` asks for the marker alone, for a worker that cannot write where it runs (codex in a folder it runs read-only, an agent that asks before it writes a file, -a worker on another host): exit 0 then means only that the marker printed. A worker that -stops on such a question shows up here as exit 2, not as an error. +a worker on another host): exit 0 then means only that the marker printed, and with no +file to fall back on, a full-screen agent can answer exit 2 for a reply that finished. A +worker that stops on such a question shows up here as exit 2, not as an error. Close every local worker you opened: while one is open the window refuses Stop and Remove, naming it. diff --git a/skills/codeman/SKILL.md b/skills/codeman/SKILL.md index 014be53a..2d3727c6 100644 --- a/skills/codeman/SKILL.md +++ b/skills/codeman/SKILL.md @@ -363,9 +363,12 @@ spawn_worker() { mode=$(jq -r .cli <<<"$lm") cm=$(jq -c '{endpointId, modelId}' <<<"$lm") # Readiness is a token the agent's composer draws. The registry's composer data never - # reaches the API, so it is named per agent here: opencode draws `┃` at the start of - # every composer row (its `workDetect.promptGlyph`); claude takes the claude path - # below. Any other agent needs LOCAL_READY_MARK rather than a worker handed back on a guess. + # reaches the API, so it is named per agent here. opencode: `┃`, its `workDetect.promptGlyph`. + # That field is no readiness mark in general (the registry keeps `composerReadyMark` for + # that, and declares none for opencode), but at boot only the composer draws it: measured + # on opencode 1.3.0, fresh home, custom provider, the first `┃` came with the composer, + # and a prompt pasted with its Enter at that moment was submitted. claude takes the claude + # path below. Any other agent needs LOCAL_READY_MARK rather than a worker handed back on a guess. [ "$mode" = opencode ] && mark='┃' mark=${LOCAL_READY_MARK:-$mark} [ "$mode" = claude ] || [ -n "$mark" ] || { @@ -560,10 +563,11 @@ sendwait() { # end-of-turn signal: a `local` worker whose agent is not claude, a hook-less claude, and # every external agent mode but deepseek (a shell worker takes §5.5's echo form instead): # {"token":…,"delivered":true,"matched":true,"answerFile":"","answer":"",…} -# rc 0: the marker printed and the answer file was read. 2: no marker within the timeout -# (default 600 s; the worker may still be working, so nothing is deleted). 3: the worker's -# pane ended. 4: the marker printed, but no answer file turned up within SENDMARK_FILE_WAIT -# seconds of it (default 120). 1: the send or a wait failed (`detail` says why). +# rc 0: the answer file was read, after the marker or once the file held still (below). +# 2: neither within the timeout (default 600 s; the worker may still be working, so nothing +# is deleted). 3: the worker's pane ended. 4: the marker printed, but no answer file turned +# up within SENDMARK_FILE_WAIT seconds of it (default 120). 1: the send or a wait failed +# (`detail` says why). # It adds ONE sentence to your prompt (joined onto one line, as sendwait does): write the # 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 @@ -572,15 +576,22 @@ sendwait() { # 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 -# instruction while it plans. That is why the answer FILE is the second gate. It is read -# from this machine's disk once the marker is out, only when it is a regular file (a -# symlink is refused), at most 256 KB of it. It stays in the worker's folder (§5.14). +# instruction while it plans. That is why the answer FILE is the second gate. ⚠️ And a +# full-screen agent can print the marker without it ever matching: opencode redraws its +# spinner between the pieces a model streams, so the stripped stream reads D⬝⬝O⬝⬝N⬝⬝E_⬝⬝m… +# (measured on opencode 1.3.0 with a reply streamed a character or two at a time; the screen +# showed the whole token). So the file is also the way out WITHOUT the marker: present, not +# empty, and unchanged between two rounds of the marker wait (rounds of at most 10 s), it is +# read and `matched` says false. It is read from this machine's disk, only when it is a +# regular file (a symlink is refused), at most 256 KB of it. It stays in the worker's folder +# (§5.14). # SENDMARK_ANSWER_FILE=0 asks for the marker alone, for a worker that cannot write where it # runs (codex in a folder it runs read-only, an agent that asks before it writes a file, a # worker on another host): rc 0 then means only "the marker printed", and the answer is -# yours to read (last_text for codex, §5.4). +# yours to read (last_text for codex, §5.4). Without the file there is no way out but the +# marker, so on a full-screen agent that can be rc 2 for a reply that finished. sendmark() { - local sid="${1:?}" p="${2:?}" t="${3:-600}" tok mark file='' wd r w='' rem end start got=0 ended=0 matched=false i=0 rc=2 + local sid="${1:?}" p="${2:?}" t="${3:-600}" tok mark file='' wd r w='' rem end start got=0 ended=0 matched=false i=0 rc=2 fp prev='' case "$p" in *$'\n'*|*$'\r'*) p=$(printf '%s\n' "$p" | tr '\r' '\n' | sed -e 's/^[[:space:]]*//' -e 's/[[:space:]]*$//' -e '/^$/d' | paste -sd ' ' -) ;; esac @@ -603,9 +614,9 @@ sendmark() { return "$rc" fi start=$(date +%s); end=$((start + t)) - while :; do # bounded waits of at most 60 s each, the server's default, until the timeout + while :; do # bounded waits of at most 10 s each, until the timeout rem=$((end - $(date +%s))); [ "$rem" -ge 1 ] || break - [ "$rem" -le 60 ] || rem=60 + [ "$rem" -le 10 ] || rem=10 w=$("${CURL[@]}" -G "$API/api/v1/sessions/$sid/wait-output" --data-urlencode "match=$mark" \ --data-urlencode 'from=buffer' --data-urlencode "timeout=$((rem * 1000))") jq -e '.data.wait.matched' <<<"$w" >/dev/null 2>&1 && { matched=true; break; } @@ -613,22 +624,31 @@ sendmark() { jq -e '.success' <<<"$w" >/dev/null 2>&1 || { jq -nc --arg t "$tok" --arg w "$w" '{token:$t,delivered:true,matched:false,answerFile:null,answer:null,detail:($w | fromjson? // $w)}' return 1; } + # No marker this round. The answer file is the other way out (see above): it counts once + # it is a non-empty regular file whose checksum did not change since the last round. + if [ -n "$file" ] && [ ! -L "$file" ] && [ -f "$file" ] && [ -s "$file" ]; then + fp=$(cksum < "$file"); [ "$fp" = "$prev" ] && { got=1; break; }; prev=$fp + else + prev='' + fi done if [ "$matched" = true ] && [ -n "$file" ]; then while :; do [ -L "$file" ] && { echo "sendmark: $file is a symlink, not read" >&2; break; } [ -f "$file" ] && { got=1; break; } [ "$i" -lt "${SENDMARK_FILE_WAIT:-120}" ] || break - # Two seconds of waiting that also notices a worker that died: an exit wait answers at once then. - "${CURL[@]}" -G "$API/api/v1/sessions/$sid/wait" --data-urlencode 'until=exit' --data-urlencode 'timeout=2000' \ - | jq -e '.data.wait.signal == "exit"' >/dev/null 2>&1 && { ended=1; break; } + # Two seconds of waiting that also notices a worker that died: an exit wait answers at + # once then. A wait that did not answer (server gone) still costs its two seconds. + w=$("${CURL[@]}" -G "$API/api/v1/sessions/$sid/wait" --data-urlencode 'until=exit' --data-urlencode 'timeout=2000') + jq -e '.data.wait.signal == "exit" or .data.wait.ended' <<<"$w" >/dev/null 2>&1 && { ended=1; break; } + jq -e '.success' <<<"$w" >/dev/null 2>&1 || sleep 2 i=$((i + 2)) done fi if [ "$got" = 1 ]; then - head -c 262144 "$file" | jq -Rsc --arg t "$tok" --arg f "$file" --argjson s "$(($(date +%s) - start))" \ - --argjson c "$(($(wc -c < "$file")))" \ - '{token:$t,delivered:true,matched:true,answerFile:$f,answer:.,truncated:($c > 262144),waitedSec:$s}' + head -c 262144 "$file" | jq -Rsc --arg t "$tok" --arg f "$file" --argjson m "$matched" \ + --argjson s "$(($(date +%s) - start))" --argjson c "$(($(wc -c < "$file")))" \ + '{token:$t,delivered:true,matched:$m,answerFile:$f,answer:.,truncated:($c > 262144),waitedSec:$s}' return 0 fi jq -nc --arg t "$tok" --arg f "$file" --argjson m "$matched" --argjson e "$ended" --argjson s "$(($(date +%s) - start))" \ @@ -850,18 +870,19 @@ Reach for it when: local_model || exit 1 # one JSON line: model, agent, endpoint; or the reason there is none SID=$(spawn_worker summ local) || exit 1 # starts a stopped runtime first; empty = failed WD=$("${CURL[@]}" "$API/api/v1/sessions/$SID" | jq -r .data.workingDir) -cp ./docs/*.md "$WD/" # its input goes inside its own folder (§5.9) -R=$(sendmark "$SID" 'Summarize each .md file in this folder in two lines' 900); RC=$? +mkdir -p "$WD/in" && cp ./docs/*.md "$WD/in/" # inside its own folder (§5.9), apart from its CLAUDE.md +R=$(sendmark "$SID" 'Summarize each .md file in the folder in, two lines each' 900); RC=$? jq -r '.answer // "(no answer)"' <<<"$R" if [ "$RC" = 0 ]; then delete_session "$SID" >/dev/null -else echo "kept $SID (sendmark rc $RC: 2 no marker yet, 3 ended, 4 no answer file): $R"; fi +else echo "kept $SID (sendmark rc $RC: 2 no answer yet, 3 ended, 4 no answer file): $R"; fi ``` `sendmark` is the local worker's `sendwait`: it adds one sentence to your prompt asking for the answer in `codeman-answer-.md` in the worker's folder and then a split marker, -waits for the marker with `wait-output`, and reads the file back -([§5.16](reference/verbs.md#516-a-worker-on-the-local-model)). Read its `answer`, not the -pane: a full-screen agent's pane is a poor transcript. ⚠️ A marker proves that the model +waits for the marker with `wait-output`, and reads the file back. OpenCode can draw the +marker in pieces that never match, so a file that holds still between two checks ends the +wait too ([§5.16](reference/verbs.md#516-a-worker-on-the-local-model)). Read its `answer`, +not the pane: a full-screen agent's pane is a poor transcript. ⚠️ A marker proves that the model printed a token, not that the work is done or right. Review what comes back the way you would review any first draft. diff --git a/skills/codeman/preamble.sh b/skills/codeman/preamble.sh index 7901e617..5a83b9bd 100644 --- a/skills/codeman/preamble.sh +++ b/skills/codeman/preamble.sh @@ -225,9 +225,12 @@ spawn_worker() { mode=$(jq -r .cli <<<"$lm") cm=$(jq -c '{endpointId, modelId}' <<<"$lm") # Readiness is a token the agent's composer draws. The registry's composer data never - # reaches the API, so it is named per agent here: opencode draws `┃` at the start of - # every composer row (its `workDetect.promptGlyph`); claude takes the claude path - # below. Any other agent needs LOCAL_READY_MARK rather than a worker handed back on a guess. + # reaches the API, so it is named per agent here. opencode: `┃`, its `workDetect.promptGlyph`. + # That field is no readiness mark in general (the registry keeps `composerReadyMark` for + # that, and declares none for opencode), but at boot only the composer draws it: measured + # on opencode 1.3.0, fresh home, custom provider, the first `┃` came with the composer, + # and a prompt pasted with its Enter at that moment was submitted. claude takes the claude + # path below. Any other agent needs LOCAL_READY_MARK rather than a worker handed back on a guess. [ "$mode" = opencode ] && mark='┃' mark=${LOCAL_READY_MARK:-$mark} [ "$mode" = claude ] || [ -n "$mark" ] || { @@ -422,10 +425,11 @@ sendwait() { # end-of-turn signal: a `local` worker whose agent is not claude, a hook-less claude, and # every external agent mode but deepseek (a shell worker takes §5.5's echo form instead): # {"token":…,"delivered":true,"matched":true,"answerFile":"","answer":"",…} -# rc 0: the marker printed and the answer file was read. 2: no marker within the timeout -# (default 600 s; the worker may still be working, so nothing is deleted). 3: the worker's -# pane ended. 4: the marker printed, but no answer file turned up within SENDMARK_FILE_WAIT -# seconds of it (default 120). 1: the send or a wait failed (`detail` says why). +# rc 0: the answer file was read, after the marker or once the file held still (below). +# 2: neither within the timeout (default 600 s; the worker may still be working, so nothing +# is deleted). 3: the worker's pane ended. 4: the marker printed, but no answer file turned +# up within SENDMARK_FILE_WAIT seconds of it (default 120). 1: the send or a wait failed +# (`detail` says why). # It adds ONE sentence to your prompt (joined onto one line, as sendwait does): write the # 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 @@ -434,15 +438,22 @@ sendwait() { # 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 -# instruction while it plans. That is why the answer FILE is the second gate. It is read -# from this machine's disk once the marker is out, only when it is a regular file (a -# symlink is refused), at most 256 KB of it. It stays in the worker's folder (§5.14). +# instruction while it plans. That is why the answer FILE is the second gate. ⚠️ And a +# full-screen agent can print the marker without it ever matching: opencode redraws its +# spinner between the pieces a model streams, so the stripped stream reads D⬝⬝O⬝⬝N⬝⬝E_⬝⬝m… +# (measured on opencode 1.3.0 with a reply streamed a character or two at a time; the screen +# showed the whole token). So the file is also the way out WITHOUT the marker: present, not +# empty, and unchanged between two rounds of the marker wait (rounds of at most 10 s), it is +# read and `matched` says false. It is read from this machine's disk, only when it is a +# regular file (a symlink is refused), at most 256 KB of it. It stays in the worker's folder +# (§5.14). # SENDMARK_ANSWER_FILE=0 asks for the marker alone, for a worker that cannot write where it # runs (codex in a folder it runs read-only, an agent that asks before it writes a file, a # worker on another host): rc 0 then means only "the marker printed", and the answer is -# yours to read (last_text for codex, §5.4). +# yours to read (last_text for codex, §5.4). Without the file there is no way out but the +# marker, so on a full-screen agent that can be rc 2 for a reply that finished. sendmark() { - local sid="${1:?}" p="${2:?}" t="${3:-600}" tok mark file='' wd r w='' rem end start got=0 ended=0 matched=false i=0 rc=2 + local sid="${1:?}" p="${2:?}" t="${3:-600}" tok mark file='' wd r w='' rem end start got=0 ended=0 matched=false i=0 rc=2 fp prev='' case "$p" in *$'\n'*|*$'\r'*) p=$(printf '%s\n' "$p" | tr '\r' '\n' | sed -e 's/^[[:space:]]*//' -e 's/[[:space:]]*$//' -e '/^$/d' | paste -sd ' ' -) ;; esac @@ -465,9 +476,9 @@ sendmark() { return "$rc" fi start=$(date +%s); end=$((start + t)) - while :; do # bounded waits of at most 60 s each, the server's default, until the timeout + while :; do # bounded waits of at most 10 s each, until the timeout rem=$((end - $(date +%s))); [ "$rem" -ge 1 ] || break - [ "$rem" -le 60 ] || rem=60 + [ "$rem" -le 10 ] || rem=10 w=$("${CURL[@]}" -G "$API/api/v1/sessions/$sid/wait-output" --data-urlencode "match=$mark" \ --data-urlencode 'from=buffer' --data-urlencode "timeout=$((rem * 1000))") jq -e '.data.wait.matched' <<<"$w" >/dev/null 2>&1 && { matched=true; break; } @@ -475,22 +486,31 @@ sendmark() { jq -e '.success' <<<"$w" >/dev/null 2>&1 || { jq -nc --arg t "$tok" --arg w "$w" '{token:$t,delivered:true,matched:false,answerFile:null,answer:null,detail:($w | fromjson? // $w)}' return 1; } + # No marker this round. The answer file is the other way out (see above): it counts once + # it is a non-empty regular file whose checksum did not change since the last round. + if [ -n "$file" ] && [ ! -L "$file" ] && [ -f "$file" ] && [ -s "$file" ]; then + fp=$(cksum < "$file"); [ "$fp" = "$prev" ] && { got=1; break; }; prev=$fp + else + prev='' + fi done if [ "$matched" = true ] && [ -n "$file" ]; then while :; do [ -L "$file" ] && { echo "sendmark: $file is a symlink, not read" >&2; break; } [ -f "$file" ] && { got=1; break; } [ "$i" -lt "${SENDMARK_FILE_WAIT:-120}" ] || break - # Two seconds of waiting that also notices a worker that died: an exit wait answers at once then. - "${CURL[@]}" -G "$API/api/v1/sessions/$sid/wait" --data-urlencode 'until=exit' --data-urlencode 'timeout=2000' \ - | jq -e '.data.wait.signal == "exit"' >/dev/null 2>&1 && { ended=1; break; } + # Two seconds of waiting that also notices a worker that died: an exit wait answers at + # once then. A wait that did not answer (server gone) still costs its two seconds. + w=$("${CURL[@]}" -G "$API/api/v1/sessions/$sid/wait" --data-urlencode 'until=exit' --data-urlencode 'timeout=2000') + jq -e '.data.wait.signal == "exit" or .data.wait.ended' <<<"$w" >/dev/null 2>&1 && { ended=1; break; } + jq -e '.success' <<<"$w" >/dev/null 2>&1 || sleep 2 i=$((i + 2)) done fi if [ "$got" = 1 ]; then - head -c 262144 "$file" | jq -Rsc --arg t "$tok" --arg f "$file" --argjson s "$(($(date +%s) - start))" \ - --argjson c "$(($(wc -c < "$file")))" \ - '{token:$t,delivered:true,matched:true,answerFile:$f,answer:.,truncated:($c > 262144),waitedSec:$s}' + head -c 262144 "$file" | jq -Rsc --arg t "$tok" --arg f "$file" --argjson m "$matched" \ + --argjson s "$(($(date +%s) - start))" --argjson c "$(($(wc -c < "$file")))" \ + '{token:$t,delivered:true,matched:$m,answerFile:$f,answer:.,truncated:($c > 262144),waitedSec:$s}' return 0 fi jq -nc --arg t "$tok" --arg f "$file" --argjson m "$matched" --argjson e "$ended" --argjson s "$(($(date +%s) - start))" \ diff --git a/skills/codeman/reference/verbs.md b/skills/codeman/reference/verbs.md index 07f442c0..944e7d05 100644 --- a/skills/codeman/reference/verbs.md +++ b/skills/codeman/reference/verbs.md @@ -866,11 +866,12 @@ confirms in the window, and no route this skill calls can do it. session's model). It fails the spawn with the answer on stderr and nothing on stdout, and no `confirmed*` flag is ever sent. 5. Readiness: OpenCode's composer bar `┃` (its registry `promptGlyph`) through - `wait-output`, 45 s, and the session deleted on a miss. A claude agent takes the claude - path instead (hooks vetted, `shift+tab`, the trust dialog). Any other agent needs - `LOCAL_READY_MARK` set to a space-free token its composer draws, or the spawn is refused - before a session exists. Not measured here: an OpenCode first-run screen that hides the - composer would read as a miss. + `wait-output`, 45 s, and the session deleted on a miss. Measured on OpenCode 1.3.0 with + a fresh home and a custom provider: nothing draws `┃` before the composer, there was no + first-run screen, and a prompt pasted with its Enter at the first `┃` was submitted. A + claude agent takes the claude path instead (hooks vetted, `shift+tab`, the trust + dialog). Any other agent needs `LOCAL_READY_MARK` set to a space-free token its + composer draws, or the spawn is refused before a session exists. **`sendmark [timeoutSec]`** drives it, and any other agent worker without an end-of-turn signal ([§5.5](#55-markers-for-hook-less-workers)): @@ -881,14 +882,20 @@ an end-of-turn signal ([§5.5](#55-markers-for-hook-less-workers)): marker, so its echo cannot match. - It sends once with the exactly-once body (`$CID-`, an epoch-second `seq`, one `\r`) and a one-second `exit` wait, so `delivered` is the pane's answer, not a guess. -- It waits on `wait-output?match=DONE_&from=buffer` in rounds of at most 60 s until +- It waits on `wait-output?match=DONE_&from=buffer` in rounds of at most 10 s until the timeout (default 600 s), then reads the answer file from this machine's disk: only a regular file (a symlink is refused), at most 256 KB. +- ⚠️ A full-screen agent can print the marker without it ever matching. OpenCode redraws + its spinner between the pieces a model streams, so with a reply streamed a character or + two at a time the stripped stream read `D⬝⬝O⬝⬝N⬝⬝E_⬝⬝m…` while the screen showed the whole + token (measured on OpenCode 1.3.0). So between rounds it also looks at the answer file: + once it is non-empty and its checksum held still from one round to the next, it is read + and the wait ends with `matched: false`. | exit | JSON | Meaning | |------|------|---------| -| 0 | `answer` holds the file | the marker printed and the file was read | -| 2 | `matched: false` | no marker before the timeout. The worker may still be working: re-check the pane, never resend blindly | +| 0 | `answer` holds the file | the file was read: after the marker (`matched: true`), or once it held still (`matched: false`) | +| 2 | `matched: false` | neither the marker nor a settled file before the timeout. The worker may still be working: re-check the pane, never resend blindly | | 3 | `ended: true` or `delivered: false` | the worker's pane ended | | 4 | `matched: true, answer: null` | the marker printed, but no file within `SENDMARK_FILE_WAIT` s (default 120): the model ignored the instruction, or a reasoning model restated the marker while planning and is still at work | | 1 | `detail` | the send or a wait failed | @@ -900,8 +907,9 @@ first draft; review it before you build on it. The answer files stay in the work folder, a scratch case that [§5.14](#514-clean-up) lists for cleanup. `SENDMARK_ANSWER_FILE=0` asks for the marker alone, for a worker that cannot write where it runs (codex in a folder it runs read-only, an agent that asks before it writes a file, -a worker on another host): exit 0 then means only that the marker printed. A worker that -stops on such a question shows up here as exit 2, not as an error. +a worker on another host): exit 0 then means only that the marker printed, and with no +file to fall back on, a full-screen agent can answer exit 2 for a reply that finished. A +worker that stops on such a question shows up here as exit 2, not as an error. Close every local worker you opened: while one is open the window refuses Stop and Remove, naming it. diff --git a/test/agent-skill-local-model.test.ts b/test/agent-skill-local-model.test.ts index 6827a68e..1a37b2ad 100644 --- a/test/agent-skill-local-model.test.ts +++ b/test/agent-skill-local-model.test.ts @@ -35,7 +35,8 @@ try { * $ANSWERS/. (the n-th call of that route) or $ANSWERS/. `.code` is the * HTTP status `-w` reports (default 200) and `.delay` seconds to sleep first. A wait-output * for `DONE_` writes $WD/codeman-answer-.md first when $ANSWER_TEXT is set, or a - * symlink to $ANSWER_SYMLINK, the way a worker would before printing its marker. + * symlink to $ANSWER_SYMLINK, the way a worker would before printing its marker; with + * $ANSWER_APPEND it appends that line on every such call, a worker still writing its answer. * * Written without a single dollar-brace, because this is a JS template literal. */ @@ -69,6 +70,7 @@ stub() { case "$q" in *match=DONE_*) tok=$(printf '%s' "$q" | sed -e 's/.*match=DONE_//' -e 's/&.*//') if [ -n "$ANSWER_TEXT" ]; then printf '%s' "$ANSWER_TEXT" > "$WD/codeman-answer-$tok.md"; fi + if [ -n "$ANSWER_APPEND" ]; then printf '%s\n' "$ANSWER_APPEND" >> "$WD/codeman-answer-$tok.md"; fi if [ -n "$ANSWER_SYMLINK" ]; then ln -s "$ANSWER_SYMLINK" "$WD/codeman-answer-$tok.md"; fi ;; esac fi @@ -524,6 +526,58 @@ describe.skipIf(!hasJq)('preamble sendmark', () => { expect(run.calls.some((call) => call.key === 'delete')).toBe(false); }); + 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 + // shows the whole token. The file must still end the wait. + const run = runSkill( + withSession(`sendmark ${SID} 'count to three' 30`), + { input: delivered, 'wait-output': NOT_MATCHED }, + { ANSWER_TEXT: 'one two three' } + ); + expect(run.stderr).toBe(''); + expect(run.rc).toBe(0); + expect(lastJson(run.stdout)).toMatchObject({ delivered: true, matched: false, answer: 'one two three' }); + // The first round finds the file, the second finds it unchanged: two marker waits, then done. + expect(run.calls.map((call) => call.key)).toEqual(['session', 'input', 'wait-output', 'wait-output']); + for (const call of run.calls.slice(2)) expect(call.q).toContain('timeout=10000'); + }); + + it('does not read an answer file that is still changing, and answers 2 at the timeout', () => { + const run = runSkill( + withSession(`sendmark ${SID} 'count to three' 3`), + { input: delivered, 'wait-output': NOT_MATCHED, 'wait-output.delay': '1' }, + { ANSWER_APPEND: 'one more line' } + ); + expect(run.rc).toBe(2); + expect(lastJson(run.stdout)).toMatchObject({ matched: false, answer: null }); + expect(run.calls.filter((call) => call.key === 'wait-output').length).toBeGreaterThanOrEqual(2); + }); + + it('answers 3 when the worker ends while it waits for the answer file after the marker', () => { + const run = runSkill(withSession(`sendmark ${SID} 'count to three' 30`), { + input: delivered, + 'wait-output': MATCHED, + wait: JSON.stringify({ success: true, data: { wait: { signal: 'exit', ended: false, timedOut: false } } }), + }); + expect(run.rc).toBe(3); + expect(lastJson(run.stdout)).toMatchObject({ matched: true, answer: null, ended: true }); + expect(run.calls.map((call) => call.key)).toEqual(['session', 'input', 'wait-output', 'wait']); + }); + + it('still waits two seconds per check for the answer file when the exit wait gets no answer', () => { + const started = Date.now(); + const run = runSkill( + withSession(`sendmark ${SID} 'count to three' 30`), + // No answer file for `wait`: the stub answers success:false, as a server that is gone. + { input: delivered, 'wait-output': MATCHED }, + { SENDMARK_FILE_WAIT: '2' } + ); + expect(run.rc).toBe(4); + expect(run.calls.map((call) => call.key)).toEqual(['session', 'input', 'wait-output', 'wait']); + expect(Date.now() - started).toBeGreaterThanOrEqual(2000); + }); + it('answers 4 when the marker printed but no answer file turned up', () => { const run = runSkill( withSession(`sendmark ${SID} 'count to three' 30`),