Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -95,9 +95,9 @@ state/ volatile runtime signals; gitignored
.pr-check-migration-scan-v1 private marker proving the non-executing scan disabled every unsafe legacy check; .pr-check-migration-v1 separately records completed private repairs
x-watch.check.sh generated X-mode relay poll shim; present only when opted in (section 14)
x-inbox/ generated X-mode pending mention payloads; fmx-respond drains it (section 14)
x-context/ generated X-mode durable per-request reply context (platform/budget), keyed by request_id; survives inbox cleanup so a delayed follow-up recovers the original platform (section 14; bin/fm-x-lib.sh)
x-context/ generated X-mode durable per-request reply context and one-wake offer markers, keyed by request_id; survives inbox cleanup and expires within seven days (section 14; bin/fm-x-lib.sh)
x-outbox/ generated X-mode dry-run reply and dismiss previews; inspect it when FMX_DRY_RUN is set (section 14)
x-poll.error generated X-mode relay diagnostic dedupe marker
x-poll.error x-poll.claim-error generated X-mode relay and offer-claim diagnostic dedupe markers
.wake-queue durable queued wakes: epoch<TAB>seq<TAB>kind<TAB>key<TAB>payload
.afk durable away-mode flag; present = sub-supervisor may inject escalations (set by /afk, cleared on user return)
.watch.lock .wake-queue.lock watcher singleton and queue serialization locks
Expand Down
66 changes: 66 additions & 0 deletions bin/fm-x-lib.sh
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,8 @@
# fmx_context_registry_set <state> <request_id> <platform> <reply-max> [refresh]
# - persist the durable per-request reply context;
# refresh=1 resets its retention timestamp
# fmx_offer_registry_claim <state> <request_id> - atomically claim the durable
# one-wake offer marker; 0=new, 1=existing, 2=error
# fmx_context_registry_prune <state> - remove records older than seven days
# fmx_context_registry_get <state> <request_id> - read the durable per-request
# reply context, or the empty shape when absent
Expand Down Expand Up @@ -176,6 +178,44 @@ fmx_private_artifact_publish_stdin() {
fi
}

# Publish stdin as a new private artifact without replacing an existing path.
# The hard-link claim is atomic within the prepared directory, so concurrent
# callers cannot both create the destination. Returns 0 when this caller created
# it, 1 when another valid private artifact already owns the path, and 2 on an
# unsafe path or publication failure.
fmx_private_artifact_publish_stdin_once() {
local dir=$1 base=$2 mode=$3 device tmp dest
case "$base" in
''|.*|*/*) return 2 ;;
esac
case "$mode" in
600|700) ;;
*) return 2 ;;
esac
device=$(fmx_private_artifact_dir_prepare "$dir") || return 2
dest="$dir/$base"
tmp=$(umask 077; mktemp "$dir/.${base}.fm-x.XXXXXX" 2>/dev/null) || return 2
if ! cat > "$tmp" \
|| ! chmod "$mode" "$tmp" 2>/dev/null \
|| ! fmx_single_link_file_mode_valid "$tmp" "$mode" "$device"; then
rm -f -- "$tmp"
return 2
fi
if ln -- "$tmp" "$dest" 2>/dev/null; then
rm -f -- "$tmp"
if fmx_single_link_file_mode_valid "$dest" "$mode" "$device"; then
return 0
fi
rm -f -- "$dest"
return 2
fi
rm -f -- "$tmp"
if fmx_single_link_file_mode_valid "$dest" "$mode" "$device"; then
return 1
fi
return 2
}

fmx_private_artifact_file_valid() {
local dir=$1 base=$2 mode=$3 device
case "$base" in
Expand Down Expand Up @@ -494,6 +534,32 @@ fmx_context_registry_set() {
| fmx_private_artifact_publish_stdin "$dir" "$rid.json" 600) || return 1
}

# fmx_offer_registry_claim <state> <request_id>: atomically claim the durable
# one-wake marker at state/x-context/<request_id>.offered.json. The marker uses
# the context registry's recorded_at retention contract, so its first claim
# survives inbox cleanup and expires with the relay's bounded follow-up window.
# Returns 0 only to the caller that created the marker, 1 when a valid marker
# already exists, and 2 on invalid input or a publication failure.
fmx_offer_registry_claim() {
local state=$1 rid=$2 dir now record rc
case "$rid" in
''|.*|*[!A-Za-z0-9._-]*) return 2 ;;
esac
fmx_context_registry_prune "$state"
now=${FMX_NOW_OVERRIDE:-$(date +%s)}
case "$now" in
''|*[!0-9]*) return 2 ;;
esac
[ "${#now}" -le 18 ] || return 2
record=$(jq -cn --arg rid "$rid" --argjson recorded_at "$now" \
'{request_id:$rid, recorded_at:$recorded_at}') || return 2
dir="$state/x-context"
printf '%s\n' "$record" \
| fmx_private_artifact_publish_stdin_once "$dir" "$rid.offered.json" 600
rc=$?
return "$rc"
}

# fmx_context_registry_get <state> <request_id>: print the durable per-request
# reply context as {"platform":"...","reply_max_chars":"..."} (the same shape as
# the inbox and relay extractors), or the empty shape when no record exists.
Expand Down
45 changes: 39 additions & 6 deletions bin/fm-x-poll.sh
Original file line number Diff line number Diff line change
Expand Up @@ -11,11 +11,12 @@
# Behavior when X mode is on:
# HTTP 204 / empty / missing text -> print nothing, exit 0 (no wake)
# auth/config errors -> print one rate-limited diagnostic
# a mention JSON with non-empty text -> stash the full object to
# a newly offered mention with non-empty text -> stash the full object to
# state/x-inbox/<request_id>.json, record the durable per-request reply
# context to state/x-context/<request_id>.json (best-effort; see
# fm-x-lib.sh), and print one compact line "x-mention <request_id>" (which
# becomes the watcher's check: wake payload)
# context to state/x-context/<request_id>.json (best-effort), atomically
# claim state/x-context/<request_id>.offered.json, and print one compact
# line "x-mention <request_id>" (which becomes the watcher wake payload)
# an already offered request_id -> print nothing, exit 0
# The full object is stashed verbatim, so any conversation context the relay
# includes (in_reply_to: {author_handle, text}, null for a fresh mention) is
# preserved for fmx-respond to handle follow-ups with continuity. The durable
Expand All @@ -39,6 +40,7 @@ fmx_load_config
[ -n "$FMX_TOKEN" ] || exit 0

ERROR_FILE="$STATE/x-poll.error"
CLAIM_ERROR_FILE="$STATE/x-poll.claim-error"

emit_error_once() {
local msg=$1
Expand All @@ -56,6 +58,22 @@ clear_error() {
rm -f "$ERROR_FILE" 2>/dev/null || true
}

emit_claim_error_once() {
local msg=$1
if fmx_private_artifact_file_valid "$STATE" "x-poll.claim-error" 600 \
&& [ "$(cat "$CLAIM_ERROR_FILE" 2>/dev/null)" = "$msg" ]; then
return 0
fi
printf '%s\n' "$msg" \
| fmx_private_artifact_publish_stdin "$STATE" "x-poll.claim-error" 600 2>/dev/null || true
printf 'x-mode-error %s\n' "$msg"
}

clear_claim_error() {
fmx_private_artifact_dir_device "$STATE" >/dev/null 2>&1 || return 0
rm -f "$CLAIM_ERROR_FILE" 2>/dev/null || true
}

command -v curl >/dev/null 2>&1 || { emit_error_once "missing curl"; exit 0; }
command -v jq >/dev/null 2>&1 || { emit_error_once "missing jq"; exit 0; }

Expand Down Expand Up @@ -100,6 +118,16 @@ case "$REQ" in
''|.*|*[!A-Za-z0-9._-]*) clear_error; exit 0 ;;
esac

# The offer marker outlives the inbox file, which fmx-respond removes after a
# successful answer or dismiss. Checking it before the inbox stash keeps both a
# still-pending request and the relay's brief post-answer re-offer silent without
# recreating a drained inbox. The startup prune above bounds marker retention.
if fmx_private_artifact_file_valid "$STATE/x-context" "$REQ.offered.json" 600; then
clear_error
clear_claim_error
exit 0
fi

INBOX="$STATE/x-inbox"
# Stash the full mention object atomically so a concurrent reader never sees a
# half-written file.
Expand All @@ -123,5 +151,10 @@ if [ -n "$POLL_CTX" ]; then
fmx_context_registry_set "$STATE" "$REQ" "$POLL_PLATFORM" "$POLL_MAX" 2>/dev/null || true
fi

clear_error
printf 'x-mention %s\n' "$REQ"
fmx_offer_registry_claim "$STATE" "$REQ"
offer_rc=$?
case "$offer_rc" in
0) clear_error; clear_claim_error; printf 'x-mention %s\n' "$REQ" ;;
1) clear_error; clear_claim_error; exit 0 ;;
*) emit_claim_error_once "cannot record mention offer"; exit 0 ;;
esac
3 changes: 2 additions & 1 deletion docs/architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -190,7 +190,8 @@ Destructive, irreversible, or security-sensitive asks are escalated for trusted-
The relay uses owner-only routing: a mention delivered to a home is from that home's owner, while parent-thread context may still include other public accounts.
On the locked session-start bootstrap step, that token creates the local polling and watcher-cadence artifacts described in the [X mode configuration reference](configuration.md#x-mode-env).
Without the token, the locked session-start bootstrap step removes those artifacts on opt-out and otherwise stays silent, so non-X users see no behavior change.
Pending mentions are stored as `state/x-inbox/<request_id>.json`; the `fmx-respond` agent-only skill drains that inbox, uses `in_reply_to` parent-post context for conversational continuity, classifies each mention as an actionable request, question, or pure acknowledgment, and submits public-safe replies through `bin/fm-x-reply.sh`.
Newly offered mentions are stored as `state/x-inbox/<request_id>.json` and wake firstmate once per retained request ID; the [X mode configuration reference](configuration.md#x-mode-env) owns the durable offer-marker and re-offer contract.
The `fmx-respond` agent-only skill drains that inbox, uses `in_reply_to` parent-post context for conversational continuity, classifies each mention as an actionable request, question, or pure acknowledgment, and submits public-safe replies through `bin/fm-x-reply.sh`.
When a reply has a real visual artifact, `--image <path>` attaches one local PNG, JPEG, GIF, WebP, BMP, or TIFF to the relay's optional `{media_type,data_base64}` image object.
Actionable reversible requests run through firstmate's normal intake, backlog, dispatch, investigation, or ship lifecycle.
Work that completes in the answering turn gets one outcome reply.
Expand Down
7 changes: 5 additions & 2 deletions docs/configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -288,7 +288,9 @@ Its request handling remains in X-specific `bin/` scripts and the `fmx-respond`

`bin/fm-x-poll.sh` calls `GET /connector/poll` with `Authorization: Bearer <FMX_PAIRING_TOKEN>`.
HTTP 204 is silent.
A pending mention with non-empty `text` is stored at `state/x-inbox/<request_id>.json` and wakes firstmate with `x-mention <request_id>`.
A newly offered pending mention with non-empty `text` is stored at `state/x-inbox/<request_id>.json` and wakes firstmate exactly once with `x-mention <request_id>`.
The poll atomically claims `state/x-context/<request_id>.offered.json` before emitting that wake, and subsequent offers of the same request stay silent even after the inbox is drained following an answer or dismiss.
Offer markers share the context registry's bounded seven-day retention, so losing or expiring the local marker lets a relay offer wake firstmate again.
The full relay object is preserved, including `in_reply_to: {author_handle, text}` when the mention is a reply in a conversation or `null` for fresh mentions.
At the same time the poll records a durable per-request reply context at `state/x-context/<request_id>.json` (`{request_id, platform, reply_max_chars, recorded_at}`) from the same authoritative relay payload, best-effort and keyed by `request_id` so concurrent requests never overwrite each other; it survives the inbox cleanup that follows the acknowledgement, so a delayed follow-up can recover the original platform and split budget even with no task link.
`recorded_at` begins as the locally observed first-seen Unix epoch and remains unchanged when the same request is polled again.
Expand All @@ -307,8 +309,9 @@ This is what keeps a delayed request-id follow-up on the original platform's bud
In that case the link is still recorded but `bin/fm-x-link.sh` prints a loud warning; and when either a follow-up's platform or explicit budget cannot be authoritatively resolved from any source, `bin/fm-x-reply.sh` refuses it (fail-safe exit 8) rather than posting with a local default - firstmate holds and retries it once both values are recoverable.
Fresh links start with `x_followups=0` and the current timestamp; when relinking the same relay request onto a successor task, pass paired `--carry-count <n> --carry-ts <epoch>` flags plus any prior `x_platform=` and `x_reply_max_chars=` as `--carry-platform <x|discord> --carry-max <n>` so the successor preserves the already-consumed follow-up count, original 7-day window, and reply split budget.
Pure acknowledgments or mentions with nothing to answer are dismissed through `bin/fm-x-dismiss.sh` before the local inbox file is cleared.
Dismiss sends `POST /connector/dismiss` with `{request_id}`, posts no text, and tells the relay to drop the request instead of re-offering it or falling back to an offline auto-reply; on success it also clears that request's durable per-request context, since a dismissed mention never gets a follow-up.
Dismiss sends `POST /connector/dismiss` with `{request_id}`, posts no text, and tells the relay to drop the request instead of re-offering it or falling back to an offline auto-reply; on success it clears that request's durable reply-context record, while the separate offer marker remains for its bounded retention so a brief relay re-offer stays silent.
Relay auth or config problems are reported once as `x-mode-error ...` until recovery.
A failed durable offer claim is likewise reported once as `x-mode-error cannot record mention offer` and remains deduplicated through quiet no-pending polls until a later offer confirms an existing valid marker or claims a new one.
Live replies are posted by `bin/fm-x-reply.sh`, which sends `POST /connector/answer` with `{request_id,text}` for one-message replies.
Add `--image <path>` to attach one local PNG, JPEG, GIF, WebP, BMP, or TIFF as `{media_type,data_base64}` in the relay's optional `image` object.
Completion follow-ups use `bin/fm-x-followup.sh`, which checks the local `state/<id>.meta` link and sends the same payload shape through `POST /connector/followup` by calling `bin/fm-x-reply.sh --followup`, up to three times per link within the window.
Expand Down
2 changes: 1 addition & 1 deletion docs/scripts.md
Original file line number Diff line number Diff line change
Expand Up @@ -79,7 +79,7 @@ The shared no-mistakes gate refusal for fleet lifecycle entrypoints is summarize
| `fm-harness.sh` | Detect the running harness and resolve crew or secondmate harness, model, and effort |
| `fm-lock.sh` | Per-home firstmate session lock |
| `fm-x-lib.sh` | Shared X-mode config, relay, and reply-threading helpers |
| `fm-x-poll.sh` | One bounded X relay poll: stash pending mentions, print `x-mention <request_id>` |
| `fm-x-poll.sh` | One bounded X relay poll: stash newly offered mentions and emit their once-only wake |
| `fm-x-reply.sh` | Post or dry-run preview a composed X-mode reply or follow-up |
| `fm-x-dismiss.sh` | Dismiss a skipped X-mode mention at the relay without replying |
| `fm-x-link.sh` | Link a spawned task to its originating X-mode mention in task meta |
Expand Down
Loading