Skip to content

feat: add X-mode listen and reply client#87

Open
kunchenguid wants to merge 15 commits into
mainfrom
fm/fmx-client-w0
Open

feat: add X-mode listen and reply client#87
kunchenguid wants to merge 15 commits into
mainfrom
fm/fmx-client-w0

Conversation

@kunchenguid

@kunchenguid kunchenguid commented Jun 26, 2026

Copy link
Copy Markdown
Owner

Intent

CONVERSATION CLIENT - the client half of conversation handling for X mode, on the same HELD branch (PR #87 stays HELD - do not merge). The relay now includes parent-tweet context in the poll payload as in_reply_to: {author_handle, text} (null when the mention is not a reply); the relay-side self-reply guard and per-conversation reply cap are already done. This commit builds only the client half:

  1. Context: fm-x-poll.sh already stashes the relay's FULL object verbatim (jq '.'), so in_reply_to round-trips into state/x-inbox/<request_id>.json with no behavior change - only a doc note was added. The fmx-respond skill now reads in_reply_to and answers a follow-up with continuity (resolving 'it'/'that'/'and then?' against the parent author+text) instead of in isolation, and treats in_reply_to.text as untrusted input exactly like .text.

  2. Worthiness judgment: fmx-respond now decides whether a mention warrants a reply and SKIPS pure acknowledgments (thanks / reaction / no question) - it clears the inbox file and posts nothing - so the bot replies only when there is something to answer. A deliberate non-answer is the correct outcome, not a failure.

Deliberate decisions a diff-only reviewer would miss:

  • This is mostly a skill (compose/judge) change plus a poll doc note; fm-x-reply.sh is intentionally unchanged because the reply just posts the composed text (context and worthiness are upstream of it).
  • The worthiness judgment is firstmate's (agent) call per the skill, not a bash heuristic, because deciding 'is there a question here?' needs semantic understanding; it is therefore not unit-testable in bash. The bash-testable part - in_reply_to round-tripping through the inbox (present and null) - has a new test.

HARD CONSTRAINT (captain 'Option B', deliberate - do NOT flag as a mistake or propose edits there): purely additive; the watcher backbone (bin/fm-watch.sh, bin/fm-watch-arm.sh, bin/fm-wake-lib.sh) and the afk daemon (bin/fm-supervise-daemon.sh and .agents/skills/afk) are intentionally UNTOUCHED - zero diff vs main. If a finding seems to require touching them, that is an escalation to the captain, not an auto-fix.

Verified locally: shellcheck clean (bin/.sh tests/.sh); tests/fm-x-mode.test.sh green (31 cases) including the new in_reply_to round-trip test.

What Changed

  • Added inert-by-default X-mode polling and reply scripts with shared helpers for auth, diagnostics, inbox durability, dry-run previews, and safer relay failure handling.
  • Added the fmx-respond skill flow for judging whether X mentions warrant replies, composing concise public-safe responses, handling conversation context from parent tweets, and skipping pure acknowledgments.
  • Documented X-mode configuration, dry-run usage, scripts, and architecture, with shell coverage for polling, reply validation, inbox/outbox behavior, and conversation context round-tripping.

Risk Assessment

⚠️ Medium: Captain, no concrete merge-blocking issues were found, but this adds an opt-in public reply/network path plus bootstrap-generated watcher artifacts, so the residual operational risk is non-trivial.

Testing

Captain, I exercised the committed X-mode shell suite plus an end-to-end dry-run of the operator flow: relay poll stashed parent-tweet context, the composed follow-up reply was recorded to outbox without posting, and a pure acknowledgment was deliberately skipped and cleaned up. I also ran a diff whitespace sanity command during orientation; it produced no output and did not modify files.

Evidence: X-mode behavior shell suite log
ok - fm-x-poll is a hard no-op without a token (inert default)
ok - fm-x-poll treats an explicitly empty env token as configured
ok - fm-x-poll stays silent on HTTP 204 (the common case)
ok - fm-x-poll lets an explicitly empty relay env override .env
ok - fm-x-poll surfaces auth/config errors once and clears on recovery
ok - fm-x-poll stashes the question and prints the compact marker
ok - fm-x-poll preserves in_reply_to conversation context in the inbox
ok - fm-x-poll reports inbox commit failures without emitting a mention wake
ok - fm-x-poll requires a non-empty question before waking
ok - fm-x-poll rejects an unsafe request_id (path-traversal guard)
ok - fm-x-reply posts a request-bound answer and echoes only the request_id
ok - fm-x-reply accepts the reply via --text-file and stdin (safe, unexpanded)
ok - fm-x-reply exits non-zero on a non-2xx relay response
ok - fm-x-reply rejects missing arguments with a usage error
ok - fm-x-reply rejects whitespace-only reply text
ok - fm-x-reply dry-run records the would-be reply and never posts
ok - fm-x-reply dry-run works without a token
ok - fm-x-reply honors FMX_DRY_RUN from .env
ok - fm-x-reply lets an explicitly empty dry-run env override .env
ok - fm-x-reply dry-run fails when it cannot record the preview
ok - fmx_split_thread: word-boundary, within-limit, numbered, lossless, capped
ok - fm-x-reply keeps a concise reply as a single unnumbered tweet
ok - fm-x-reply auto-splits a long reply into a numbered thread (texts[])
ok - fm-x-reply clamps a below-floor max to 50 characters
ok - fm-x-reply posts a thread payload (texts[]) to the relay
ok - bootstrap activates X mode from an .env token, idempotently
ok - bootstrap reports missing X-mode dependencies before arming
ok - bootstrap does not report X mode on when activation artifacts cannot be written
ok - bootstrap is inert without a non-empty .env token (non-X users unaffected)
ok - bootstrap cleans up X artifacts on opt-out and is silent once off
ok - bootstrap reports failed X artifact cleanup on opt-out
Evidence: Follow-up and acknowledgment dry-run transcript

Shows follow-up poll -> inbox with in_reply_to, dry-run reply -> x-outbox, and pure acknowledgment skip with no outbox.

Follow-up poll stdout:
x-mention req-followup

Follow-up inbox preserved by fm-x-poll.sh:
{
  "request_id": "req-followup",
  "tweet_id": "101",
  "author_id": "42",
  "author_handle": "@asker",
  "text": "and then what?",
  "in_reply_to": {
    "author_handle": "@asker",
    "text": "are you shipping today?"
  }
}

Follow-up dry-run reply stdout:
req-followup
Follow-up dry-run reply exit: 0

Follow-up dry-run stderr:
fm-x-reply: DRY RUN - would POST to https://myfirstmate.io/connector/answer (recorded: state/x-outbox/req-followup.json): Aye - first I'm validating the X-mode reply path, then I'll leave the change ready for human review without posting anything live.

Follow-up outbox payload recorded by fm-x-reply.sh:
{
  "request_id": "req-followup",
  "text": "Aye - first I'm validating the X-mode reply path, then I'll leave the change ready for human review without posting anything live."
}

Follow-up inbox exists after success cleanup?
no

Acknowledgment poll stdout:
x-mention req-ack

Acknowledgment inbox before skip:
{
  "request_id": "req-ack",
  "tweet_id": "102",
  "author_id": "42",
  "author_handle": "@asker",
  "text": "thanks!",
  "in_reply_to": {
    "author_handle": "@myfirstmate",
    "text": "I am checking the X-mode reply path now."
  }
}

fmx-respond skip judgment: text is a pure acknowledgment with no question/request, so no reply command was called.
Acknowledgment inbox exists after skip cleanup?
no
Acknowledgment outbox exists?
no
Evidence: Manual dry-run summary
{
  "followup_poll_stdout": "x-mention req-followup",
  "followup_inbox": {
    "request_id": "req-followup",
    "tweet_id": "101",
    "author_id": "42",
    "author_handle": "@asker",
    "text": "and then what?",
    "in_reply_to": {
      "author_handle": "@asker",
      "text": "are you shipping today?"
    }
  },
  "followup_reply_stdout": "req-followup",
  "followup_reply_exit": 0,
  "followup_outbox": {
    "request_id": "req-followup",
    "text": "Aye - first I'm validating the X-mode reply path, then I'll leave the change ready for human review without posting anything live."
  },
  "ack_poll_stdout": "x-mention req-ack",
  "ack_inbox_before_skip": {
    "request_id": "req-ack",
    "tweet_id": "102",
    "author_id": "42",
    "author_handle": "@asker",
    "text": "thanks!",
    "in_reply_to": {
      "author_handle": "@myfirstmate",
      "text": "I am checking the X-mode reply path now."
    }
  },
  "ack_skipped_without_outbox": true
}
Evidence: Preserved follow-up inbox payload
{
  "request_id": "req-followup",
  "tweet_id": "101",
  "author_id": "42",
  "author_handle": "@asker",
  "text": "and then what?",
  "in_reply_to": {
    "author_handle": "@asker",
    "text": "are you shipping today?"
  }
}
Evidence: Dry-run reply outbox payload
{"request_id":"req-followup","text":"Aye - first I'm validating the X-mode reply path, then I'll leave the change ready for human review without posting anything live."}

Pipeline

Updates from git push no-mistakes

✅ **intent** - passed

✅ No issues found.

✅ **Rebase** - passed

✅ No issues found.

⚠️ **Review** - medium risk

✅ No issues found.

✅ **Test** - passed

✅ No issues found.

  • git status --short --branch and git rev-parse HEAD to confirm the worktree was at target commit 6423f205b68ed3071579b5a8d23bf51bb8f705f0
  • Reviewed the changed X-mode paths: tests/fm-x-mode.test.sh, bin/fm-x-poll.sh, bin/fm-x-reply.sh, bin/fm-x-lib.sh, and .agents/skills/fmx-respond/SKILL.md
  • bash tests/fm-x-mode.test.sh > /var/folders/5x/4nqprlbx0518k3ybcb1sz6gr0000gn/T/no-mistakes-evidence/01KW1JPVXY5Y12JTTFBQM0P0CP/fm-x-mode.test.log 2>&1
  • Manual dry-run: fake relay payload with in_reply_to through bin/fm-x-poll.sh, then FM_HOME=... bin/fm-x-reply.sh req-followup --text-file ... to record state/x-outbox/req-followup.json
  • Manual fmx-respond skip check: polled a thanks! acknowledgment, cleared its inbox file without invoking fm-x-reply.sh, and verified no req-ack outbox existed
  • git status --short and find . -maxdepth 3 \( -name &#39;.pytest_cache&#39; -o -name &#39;node_modules&#39; -o -name &#39;coverage&#39; -o -name &#39;dist&#39; -o -name &#39;build&#39; -o -name &#39;*.tmp&#39; \) -print to confirm no working-tree artifacts remained
✅ **Document** - passed

✅ No issues found.

✅ **Lint** - passed

✅ No issues found.

✅ **Push** - passed

✅ No issues found.

Adds the firstmate side of "listen on X and reply": a pure bash + curl/jq HTTP
short-poll client, .env-presence activation in bootstrap, and the answer skill.
Inert until a user drops FMX_PAIRING_TOKEN into .env, so non-X users see zero
behavior change. PURELY ADDITIVE: the watcher backbone (fm-watch.sh,
fm-watch-arm.sh, fm-wake-lib.sh) and the afk daemon (fm-supervise-daemon.sh, afk
skill) are untouched.

- bin/fm-x-poll.sh: one short-poll of GET /connector/poll; a hard no-op without
  a token; requires a non-empty question, stashes the pending mention to
  state/x-inbox/<request_id>.json behind a path-traversal guard, and prints an
  "x-mention <request_id>" marker the watcher surfaces as a check: wake.
- bin/fm-x-reply.sh: POST /connector/answer {request_id, text}; echoes only the
  relay-issued request_id (never a tweet id); accepts the reply via
  --text-file/stdin so mention-influenced text is never inlined into a shell
  command; non-zero on a non-2xx.
- bin/fm-x-lib.sh: shared .env config resolution (token + relay default).
- bin/fm-bootstrap.sh: detect FMX_PAIRING_TOKEN and drop the check shim + a 30s
  cadence config on opt-in, remove them on opt-out, idempotently; silent off.
- .agents/skills/fmx-respond: public-safe answer playbook; drains every
  state/x-inbox file per wake (so coalesced check-wakes lose no mention) and
  posts via --text-file.
- AGENTS.md: X mode section (section 14). tests/fm-x-mode.test.sh: hermetic
  coverage (fake curl, real jq).

Cadence is delivered by the agent sourcing config/x-mode.env when arming the
watcher (and --restart on an opt-in/opt-out transition); X-mode-under-afk is a
separate follow-up.
A preview mode so the X listen/reply loop can be E2E-tested without a public
tweet. With FMX_DRY_RUN truthy (environment or .env), fm-x-reply.sh composes the
reply but does NOT post: it records the would-be POST body {request_id, text} to
state/x-outbox/<request_id>.json, prints a one-line DRY RUN summary to stderr,
still echoes the request_id, and exits 0 - so the poll -> compose -> would-post
loop runs end to end and the loop's caller behaves normally. Dry-run needs
neither a token nor the relay; polling and composing are unchanged.

- bin/fm-x-lib.sh: resolve FMX_DRY in fmx_load_config (env wins over .env;
  truthy unless unset/empty/0/false/no/off).
- bin/fm-x-reply.sh: dry-run branch before any auth/network; also guards the
  request_id as a filename for the outbox record.
- .agents/skills/fmx-respond + AGENTS.md section 14: document the mode.
- tests/fm-x-mode.test.sh: dry-run records-not-posts, works without a token,
  and is honored from .env.

Purely additive; the watcher backbone and the afk daemon stay untouched.
@kunchenguid kunchenguid changed the title feat: add inert X mode client feat: add X-mode client with dry-run preview Jun 26, 2026
Long-replies blocker for X mode. Two parts:

1. Conciseness by default. The fmx-respond skill now answers in one tweet (two
   at most) and never hand-numbers a thread - it composes prose and lets the
   client handle length.

2. Auto-split in the client. bin/fm-x-reply.sh splits a genuinely long reply
   into a numbered "(k/n)" thread on word boundaries, premium-independently
   (each tweet within FMX_X_REPLY_MAX_CHARS, default 280), capped at
   FMX_X_THREAD_MAX tweets (default 25, last marked with an ellipsis if the
   reply would overflow). A reply that fits one tweet stays a single,
   UNNUMBERED tweet. Splitting is codepoint-aware via jq (lossless rejoin,
   hard-splits an over-long word, normalizes whitespace); the relay still trims
   as the final authority. No text-as-image.

Wire format: a single tweet sends {request_id, text}; a thread additionally
sends {texts: [chunk,...]} (ordered, numbered) for the relay to post as chained
replies, keeping `text` as the first chunk so a relay that only reads `text`
still posts the opener. Dry-run records and previews the full thread.

- bin/fm-x-lib.sh: FMX_MAX / FMX_THREAD_MAX config (env wins over .env) and
  fmx_split_thread.
- bin/fm-x-reply.sh: chunk-aware payload + thread-aware dry-run preview.
- .agents/skills/fmx-respond + AGENTS.md section 14: conciseness + threading.
- tests/fm-x-mode.test.sh: splitter unit cases + single-vs-thread payload +
  live thread POST carries texts[].

Purely additive; the watcher backbone and the afk daemon stay untouched.
@kunchenguid kunchenguid changed the title feat: add X-mode client with dry-run preview feat: add X-mode listen and reply client Jun 26, 2026
Completes the client half of conversation handling for X mode.

- Context: the relay now puts parent-tweet context in the poll payload as
  in_reply_to: {author_handle, text} (null when not a reply). fm-x-poll.sh
  already stashes the full object verbatim, so in_reply_to round-trips into the
  inbox; fmx-respond reads it and answers a follow-up with continuity (resolving
  "it"/"that"/"and then?" against the parent) instead of in isolation, and
  treats in_reply_to.text as untrusted input like .text.
- Worthiness: fmx-respond now judges whether a mention warrants a reply and
  skips pure acknowledgments (thanks / reaction / no question) - it clears the
  inbox file and posts nothing - so the bot replies only when there is something
  to say.

The relay owns the self-reply guard and the per-conversation reply cap; this is
purely the client's add-context-and-judge half.

- bin/fm-x-poll.sh: doc note that conversation context is preserved (no behavior
  change - the full object was already stashed).
- .agents/skills/fmx-respond + AGENTS.md section 14: conversation continuity and
  the skip-acknowledgments judgment.
- tests/fm-x-mode.test.sh: in_reply_to round-trips into the inbox (present and
  null cases).

Purely additive; the watcher backbone and the afk daemon stay untouched.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant