mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-11-15 10:59:32 +01:00
fix(skill): sendmark also ends on a settled answer file
Measured on opencode 1.3.0 in a private tmux socket, with a fake OpenAI-compatible endpoint streaming the reply a character or two at a time: the marker was on the screen, but the stripped client stream read D, spinner, O, spinner, N, ... because opencode redraws its spinner between the streamed pieces, so wait-output never matched it. sendmark then waited its whole timeout and answered 2 for a reply that had finished, with the answer file already written (opencode's write tool wrote it without asking). The marker wait now runs in rounds of at most 10 s, and between rounds the answer file is a way out too: a non-empty regular file whose checksum held still for one round is read, with matched false. The marker stays the fast path. The answer-file wait after a marker no longer spins when the exit wait gets no answer, and treats an ended wait as the worker gone. The same run measured the readiness mark: with a fresh home and a custom provider, the first composer bar came with the composer and a prompt pasted with its Enter at that moment was submitted, so the docs say so instead of "not measured". The example keeps its input in a subfolder, apart from the case's CLAUDE.md. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
5a9de1bc4c
commit
938c213552
7 files changed
+265
-113
No files matched your search
@@ -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":"<path>","answer":"<text>",…}
|
||||
# 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-<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
|
||||
@@ -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-<token>.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.
|
||||
|
||||
|
||||
@@ -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":"<path>","answer":"<text>",…}
|
||||
# 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-<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
|
||||
@@ -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))" \
|
||||
|
||||
@@ -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 <sid> <prompt> [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-<sid>`, 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_<token>&from=buffer` in rounds of at most 60 s until
|
||||
- It waits on `wait-output?match=DONE_<token>&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.
|
||||
|
||||
+47
-26
@@ -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":"<path>","answer":"<text>",…}
|
||||
# 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-<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
|
||||
@@ -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-<token>.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.
|
||||
|
||||
|
||||
+40
-20
@@ -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":"<path>","answer":"<text>",…}
|
||||
# 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-<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
|
||||
@@ -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))" \
|
||||
|
||||
@@ -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 <sid> <prompt> [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-<sid>`, 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_<token>&from=buffer` in rounds of at most 60 s until
|
||||
- It waits on `wait-output?match=DONE_<token>&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.
|
||||
|
||||
@@ -35,7 +35,8 @@ try {
|
||||
* $ANSWERS/<route>.<n> (the n-th call of that route) or $ANSWERS/<route>. `<file>.code` is the
|
||||
* HTTP status `-w` reports (default 200) and `<file>.delay` seconds to sleep first. A wait-output
|
||||
* for `DONE_<token>` writes $WD/codeman-answer-<token>.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`),
|
||||
|
||||
Reference in new issue
Block a user