Have a session report which state it is in, not only that it changed

The marker a session writes is consumed by the watcher that pushes it, so
after a notification there is nothing left to read - and with no watcher
running, nothing has read it at all. Neither front door can answer "which of
these sessions is waiting for me" from that.

The hook now also keeps one file in a level/ directory the host creates: the
state the session is in, overwritten rather than consumed. Still a name and no
content, still only ever about the session writing it. UserPromptSubmit
supplies the other half and pushes nothing, because the edge markers keep
their two-name enum.
This commit is contained in:
2026-10-15 14:23:13 -07:00
parent b12b303780
commit 1a4ba0d192
4 changed files with 111 additions and 12 deletions
+6 -1
View File
@@ -3532,8 +3532,13 @@ if remote_enabled; then
mkdir -p -m 700 "$DEJIMA_STATE_DIR/sessions" "$DEJIMA_STATE_DIR/notify"
DEJIMA_SESSION_FILE="$DEJIMA_STATE_DIR/sessions/$DEJIMA_RUN_ID"
DEJIMA_NOTIFY_DIR="$DEJIMA_STATE_DIR/notify/$DEJIMA_RUN_ID"
# level/ is where the hook records which state the session is in, as
# opposed to the edge markers beside it that the watcher consumes. Created
# here rather than by the hook for the same reason the parent is: the host
# owns this tree's shape, and a session that could create the directory
# could create it as something else.
# shellcheck disable=SC2174
mkdir -p -m 700 "$DEJIMA_NOTIFY_DIR"
mkdir -p -m 700 "$DEJIMA_NOTIFY_DIR" "$DEJIMA_NOTIFY_DIR/level"
# Verify what was just created, because everything downstream assumes it.
# A leaf left behind root-owned by an earlier container restart (see
+15 -9
View File
@@ -13,14 +13,14 @@
# Registers the `done` notification handler into dejima's settings layer, in
# the shape Claude Code reads them.
#
# Stop only, though the hook and the host-side watcher both understand
# `waiting` as well. Claude Code raises Notification when it needs permission
# to run something and when the prompt has sat idle, and a dejima session runs
# with permission prompts off - so the first never happens, and what is left
# is a timer that expires shortly after the Stop it follows. Registering both
# put two pushes on the operator's phone for one event, a minute apart, the
# second saying nothing the first had not. `waiting` stays in the enum for a
# harness that can say it without having stopped first.
# No Notification handler, though the hook and the host-side watcher both
# understand `waiting` as well. Claude Code raises Notification when it needs
# permission to run something and when the prompt has sat idle, and a dejima
# session runs with permission prompts off - so the first never happens, and
# what is left is a timer that expires shortly after the Stop it follows.
# Registering both put two pushes on the operator's phone for one event, a
# minute apart, the second saying nothing the first had not. `waiting` stays in
# the enum for a harness that can say it without having stopped first.
#
# Claude Code merges hook entries across settings layers rather than letting
# the higher one replace the lower, so these are added to whatever the
@@ -40,7 +40,13 @@ DEJIMA_HOOK_DIR=/usr/local/lib/dejima/hooks
harness_register_notify() {
local settings="$1" tmp
tmp="$(mktemp "${TMPDIR:-/tmp}/dejima-settings-remote.XXXXXX.json")"
if jq '.hooks.Stop = ((.hooks.Stop // []) + [{"matcher": "", "hooks": [{"type": "command", "command": "'"$DEJIMA_HOOK_DIR"'/dejima-notify.sh done"}]}])' \
# UserPromptSubmit is the complement of Stop, and it is registered for the
# level alone: the hook writes no notification edge for `running`, so this
# adds nothing to the phone. What it adds is the difference between "this
# session has stopped" and "nothing has been heard from this session",
# which a picker cannot tell apart from the Stop event by itself.
if jq '.hooks.Stop = ((.hooks.Stop // []) + [{"matcher": "", "hooks": [{"type": "command", "command": "'"$DEJIMA_HOOK_DIR"'/dejima-notify.sh done"}]}])
| .hooks.UserPromptSubmit = ((.hooks.UserPromptSubmit // []) + [{"matcher": "", "hooks": [{"type": "command", "command": "'"$DEJIMA_HOOK_DIR"'/dejima-notify.sh running"}]}])' \
"$settings" > "$tmp"; then
mv "$tmp" "$settings"
else
+22 -2
View File
@@ -20,7 +20,7 @@ set -euo pipefail
# Sanitizing an arbitrary name into a marker would make the file name a
# channel the sandbox controls, which is the one thing this hook must not be.
case "${1:-}" in
waiting|done) ;;
waiting|done|running) ;;
*) exit 0 ;;
esac
@@ -32,4 +32,24 @@ esac
[ -n "${DEJIMA_NOTIFY_DIR:-}" ] || exit 0
[ -d "$DEJIMA_NOTIFY_DIR" ] || exit 0
: > "$DEJIMA_NOTIFY_DIR/$1"
# The notification edge, for dejima-notify-watch to consume and push. Two
# names, because that is the set the watcher iterates: a marker it never looks
# for is a file nothing ever removes.
case "$1" in
waiting|done) : > "$DEJIMA_NOTIFY_DIR/$1" ;;
esac
# The level: which of those the session is in NOW, rather than that it just
# changed. Nothing consumes it, so it answers "which sessions need me" for a
# picker or a status line, and it does that whether or not a watcher is
# running - the edge above is gone the moment one is. Still a name and no
# content, and still only ever about this session: the directory is this run's
# own, so the worst a compromised session can do here is misreport itself.
#
# Siblings first, then the new one: a reader between the two calls sees no
# state rather than two, and "unknown" is a thing the readers already render.
[ -d "$DEJIMA_NOTIFY_DIR/level" ] || exit 0
for _other in waiting done running; do
[ "$_other" = "$1" ] || rm -f "$DEJIMA_NOTIFY_DIR/level/$_other"
done
: > "$DEJIMA_NOTIFY_DIR/level/$1"
+68
View File
@@ -204,6 +204,18 @@ for arg in "$@"; do
[ -z "${SANDBOX_TEST_SETTINGS_COPY:-}" ] || cp "$SANDBOX_SETTINGS_FILE" "$SANDBOX_TEST_SETTINGS_COPY"
break
done
# The marker directory and what the host created inside it, while it still
# exists: bin/dejima's EXIT trap rm -rf's the whole thing, so from outside the
# run there is nothing left to look at. The host path comes off the mount
# argument, which is the only place this stub can learn it.
if [ -n "${SANDBOX_TEST_LEVEL_PROBE:-}" ]; then
for arg in "$@"; do
case "$arg" in
*:/run/dejima-notify)
[ ! -d "${arg%%:*}/level" ] || echo level >> "$SANDBOX_TEST_LEVEL_PROBE" ;;
esac
done
fi
exit 0
EOF
chmod +x "$LAUNCH_DIR/docker"
@@ -232,6 +244,7 @@ launch() {
HOME="$LAUNCH_DIR/home" PATH="$LAUNCH_DIR:$PATH" \
SANDBOX_TEST_DOCKER_LOG="$LAUNCH_DIR/docker-log" \
SANDBOX_TEST_SETTINGS_COPY="$LAUNCH_DIR/settings-copy.json" \
SANDBOX_TEST_LEVEL_PROBE="$LAUNCH_DIR/level-probe" \
SSH_AUTH_SOCK= ALLOWED_DOMAINS= \
"${DEJIMA_BIN:-$REPO_ROOT/bin/dejima}" "$@"
}
@@ -1456,6 +1469,46 @@ EOF
rm -rf "$NOTIFY" "$payload_file"
}
@test "the notify hook keeps a level beside the edge, and only the edge is pushable" {
# The edge markers are consumed by the watcher, so after a push there is
# nothing left to read - and with no watcher running, nothing has read them
# at all. The level answers "which state is this session in now" for the
# picker and the status line, which is a different question from "it just
# changed" and needs an answer that survives being read.
NOTIFY="$(mktemp -d)"
mkdir "$NOTIFY/level"
# `running` is level-only: the watcher iterates waiting and done, so an
# edge marker it never looks for would be a file nothing ever removes.
DEJIMA_NOTIFY_DIR="$NOTIFY" run "$REPO_ROOT/hooks/dejima-notify.sh" running
[ "$status" -eq 0 ]
[ "$(ls -A "$NOTIFY" | sort | tr '\n' ' ')" = "level " ] \
|| { echo "running left an edge marker: $(ls -A "$NOTIFY")"; return 1; }
[ "$(ls -A "$NOTIFY/level")" = running ]
[ ! -s "$NOTIFY/level/running" ]
# Exactly one level at a time, so a reader never has to choose.
DEJIMA_NOTIFY_DIR="$NOTIFY" run "$REPO_ROOT/hooks/dejima-notify.sh" done
[ "$status" -eq 0 ]
[ "$(ls -A "$NOTIFY/level")" = done ] \
|| { echo "level holds: $(ls -A "$NOTIFY/level")"; return 1; }
[ -f "$NOTIFY/done" ] || { echo "done left no edge marker"; return 1; }
# The same whitelist covers the level: a name outside the enum writes
# neither, and leaves the state that was there.
DEJIMA_NOTIFY_DIR="$NOTIFY" run "$REPO_ROOT/hooks/dejima-notify.sh" 'running;done'
[ "$status" -eq 0 ]
[ "$(ls -A "$NOTIFY/level")" = done ]
# A run directory with no level/ is a launch from before it existed. The
# edge still works there, which is what keeps notifications independent.
OLD="$(mktemp -d)"
DEJIMA_NOTIFY_DIR="$OLD" run "$REPO_ROOT/hooks/dejima-notify.sh" waiting
[ "$status" -eq 0 ]
[ -f "$OLD/waiting" ]
rm -rf "$NOTIFY" "$OLD"
}
@test "the notify hook refuses any event name outside the enum" {
NOTIFY="$(mktemp -d)"
for bad in 'error' '../escape' 'waiting done' '' '$(id)' 'waiting;done' '.'; do
@@ -1504,6 +1557,21 @@ EOF
run jq -r '[.hooks[][].hooks[].command] | join(" ")' "$LAUNCH_DIR/settings-copy.json"
[[ "$output" != *operator-desktop-notify.sh* ]]
[[ "$output" != *operator-tts.sh* ]]
# The complement of Stop, for the level and not for the phone: without it
# a picker cannot tell "this session has stopped" from "nothing has been
# heard from this session". It pushes nothing, because the hook writes no
# edge marker for `running`.
grep -q 'dejima-notify.sh running' "$LAUNCH_DIR/settings-copy.json" \
|| { echo "nothing marks the session as working again"; return 1; }
[ "$(jq -r '.hooks.UserPromptSubmit[0].hooks[0].command' "$LAUNCH_DIR/settings-copy.json")" \
= "/usr/local/lib/dejima/hooks/dejima-notify.sh running" ]
# And the directory the hook writes that level into is the host's to
# create, like the marker directory it sits in. Observed from inside the
# run, because the EXIT trap takes the whole tree with it.
grep -qxF level "$LAUNCH_DIR/level-probe" \
|| { echo "no level directory was created inside the marker directory"; return 1; }
rm -rf "$PROJ"
}