feat(bin): add self-hosted Discord relay - #11
Merged
Merged
Conversation
…henguid#4894) * fix(bin): let a background Claude session keep owning its session lock Session-lock ownership was decided by process ancestry alone. Under an unattended Claude session the model loop runs in a transient bg-spare bridged to the front-end by a shared daemon; when that bridge is recycled the contiguous claude-named ancestry from a hook to the recorded owner breaks while the owner pid stays alive, so the Stop auto-arm stood down as a foreign live owner, the turn-end guard ended every turn with its read-only diagnostic, and fm-lock.sh refused - a self-sustaining outage until restart. Ownership is now ancestry membership OR a trusted same-session id, never id-first: - fm-session-lock-lib.sh accepts CLAUDE_CODE_SESSION_ID only when CLAUDE_PID is a Claude-shaped member of the current contiguous run, compares it against the id recorded in state/.lock-session, and requires the recorded pid to still be a live harness. No id, no sidecar, an untrusted id, a different id, or a dead recorded pid leaves the ancestry verdict unchanged. Ids are never read from ps argv. - fm-lock.sh accepts a same-session holder at both refusal sites, writes, refreshes, and clears the sidecar only under its claim lock (including the early already-mine exit, skipped only while the deferred startup sweep leases that lock), keeps it byte-identical across a same-session confirmation, records CLAUDE_PID on lock line 1 for a session with a trusted id so a shared daemon or front-end that outlives the session never keeps a dead session's lock alive, never rewrites a live line 1 on a same-session confirmation, and names the recorded id in the live-owner refusal. - The .lock line-1 format is unchanged, so every reader that takes the whole first line as the pid keeps working; the guard's foreign-owner exit is unchanged and inherits the fix through the shared predicate. Tests: the ancestry suite drives the ancestry and id signals apart in a deterministic process table (asserting the divergence) and runs a real orphaned front-end/daemon/pty-host/spare tree through six phases with the real lock, auto-arm, and guard scripts; the foreign-owner repro keeps its negative control and adds a same-id positive control. Disclosure: no live unattended Claude background session ran on the verifying machine. The topology is documented by the real process listings in kunchenguid#3902, kunchenguid#2314, kunchenguid#3398, and kunchenguid#4066; coverage is the structural predicate plus the executable fixtures, not a live pass. Residual: bin/fm-sessionstart-nudge.sh keeps its own private ancestry walk (it only decides whether to print a nudge) and may nudge on a resume in the recycled case. Out of scope, deliberately: no structured lock format, no guard budget changes, no daemon-identity rejection, no fork lineage. * no-mistakes(review): Wait for claim lock; revert failed sidecars * no-mistakes(review): Revalidate ownership after wait; restore sidecars * no-mistakes(review): Roll back sidecar by publication phase * no-mistakes(review): Restore sidecar only if lock line is unchanged * no-mistakes(review): Trust session ids without a spelling allowlist * no-mistakes(review): Disarm sidecar rollback before backup cleanup * no-mistakes(document): Updated session-lock ownership documentation
* feat: park main under the away posture on Pi While the away-posture record exists on a Pi primary, the supervision branch takes every actionable wake, no processing turn opens on main, captain rows accumulate for the return brief, and main's standing authority relocates to the branch through the existing guarded scripts. - lib/fm-branch-dispatch.ts: read the record at every routing decision; while it exists claim check, decision-owned, and heartbeat rows too, keeping the two broken-queue vetoes; expose checkSeqs so a claimed check row lifts task scoping. - fm-primary-pi-watch.ts: offer every actionable row under the record; a declined wake and every watcher-failure alarm still reach main. - fm-branch-supervision.ts: drop the legacy .afk decline; append a fixed POSTURE: AWAY tail carrying the record's read-back verbatim per wake; open no processing request while the record exists, re-checked immediately before a request would open and at every run boundary; present the accumulated rows at the first run boundary after archive. - fm-lease-lib.sh: fm_lease_forbid_branch passes the branch for opted-in actions only while fm-afk-contract.sh validate succeeds on a confirmed live record; PR merge, fresh spawn, and decision answer opt in, local landing never does. - fm-send.sh: a --resolve-key naming an open needs-decision or captain-held task is a decision answer and meets the partition; blocked: keys stay steering. - fm-spawn.sh: enforce the record's spend cap for a fresh ordinary spawn by either actor; relaunches and secondmates exempt. - fm-branch-prompt.sh: fixed Postures section and the verbatim ask-user-authority policy; the prefix stays byte-stable. - fm-afk-return.sh: count what the away session handled from the store. - docs, afk skill, AGENTS.md stub: main parked on Pi, green merge gate absolute while away. - tests: watcher and branch extension suites, fleet-record, merge, and decision-answer suites cover the relocation, the vetoes, the tail, the parked processing turn, the cancellation, the re-presentation, and the spend cap; dated live-guard evidence recorded. * no-mistakes(review): Refuse branch merge after preflight archive race * no-mistakes(review): Fix away wake, spawn, and processing races * no-mistakes(review): Suppress parked processing; narrow away-only rejection * no-mistakes(review): Abort dedicated processing; gate branch spawn once * no-mistakes(review): Stamp away-only on the dispatch offer * no-mistakes(review): Treat invalid away records as spend-cap absence * no-mistakes(review): Drop spawn test hook; abort processing-opened runs * no-mistakes(review): Bind abort to opening prompt; cap-read absence * no-mistakes(review): Limit away branch spawn to queued work only * no-mistakes(document): Correct AFK posture documentation
* ci: simplify CI job timeouts to a three-tier policy Replace the scattered per-job timeout values (10m parallel, 25m lint, 30m serial, 10m macOS) with three readable tiers, each a hang tripwire with headroom rather than a packing estimate: - fast (5m): coverage guard, repo invariants, timing aggregate - normal (30m, one shared budget): lint partitions, portable parallel shards, portable serial shards, macOS stock Bash - heavy (Herdr only): 20m step tripwire on the family run so always() cleanup still runs, under a 75m job-level last-resort backstop The workflow's header comment states the policy and points at docs/fm-test-portable-shards.md "Timeouts", which now owns it, and each job names its tier beside timeout-minutes. tests/fm-ci-workflow.test.sh asserts the policy against the parsed workflow instead of the old per-job minute values: every job joins exactly one tier, exactly three distinct job-level values exist, the fast tier stays within 5-10 minutes, the normal budget stays at least double the modeled parallel lane sum reported by fm-test-run.sh --check-coverage, and the Herdr step tripwire stays below its job backstop with an always() cleanup after it. Concurrency supersession, shard counts, lane membership, and fail-fast settings are unchanged. * no-mistakes(review): Decouple the normal timeout from packing estimates * no-mistakes(review): Assert Herdr teardown follows the family run * no-mistakes(review): Pin Herdr family-run timeout to 20 minutes * no-mistakes(review): Ignore comments when identifying Herdr steps * no-mistakes(review): Identify Herdr steps by declarative ids * no-mistakes(document): Clarify authoritative three-tier timeout policy
…nchenguid#4895) * fix(bin): keep supervisor status closes from waking the same home A drain that already folded OPEN DECISIONS has presented those bytes even when the watcher has no matching seen marker. Treat that fold, and the presentation cursor, as known so the bookkeeping close stays quiet while later worker lines still signal. * no-mistakes(review): Keep folded worker failures waking past supervisor closes * no-mistakes(review): Wake on unlisted folded worker lines; batch multi-key closes * no-mistakes(review): Stop folded worker resolved lines from counting as already read * no-mistakes(document): Correct self-announced close marker contract in docs
* Stop steering operators away from Herdr * no-mistakes(review): Neutralize remaining Herdr opt-out documentation wording
…enguid#4973) * fix(bin): treat a live no-mistakes run as current after rebase A running run on the task's branch is authoritative regardless of head. Matching only the local head made a rebased in-flight run look failed. * no-mistakes(review): restrict coarse live-any-head to foreign-branch answers * no-mistakes(review): reject gate-parked runs from the executing predicate * no-mistakes(review): hoist gate-marker patterns into single run-lib owner * no-mistakes(review): require live daemon for head-free run binding * no-mistakes(review): require answered daemon-down before unbinding live runs * no-mistakes(review): extend daemon guard to anchored continuation routes * no-mistakes(review): delete live-any-head; restore dead-daemon verdict * no-mistakes(review): keep parked gates parked; name dead daemon everywhere * no-mistakes(review): set dead-daemon verdict instead of emitting early * no-mistakes(review): align selected route with legacy dead-daemon handling * no-mistakes(review): drop unproven-record binds; narrow coarse gate reading * no-mistakes(review): narrow header, drop vestigial guard, retarget tests * no-mistakes(review): revert coarse gate override; require answered-down probe * no-mistakes(review): cache one daemon probe; stop duplicating run id * no-mistakes(review): restrict coarse dead-daemon verdict to moved-off rows * no-mistakes(review): delete coarse dead-daemon extension and gate note * no-mistakes(review): delete remaining coarse dead-daemon block and stale docs * no-mistakes(document): document rebase-safe live-run bind and unverified-record verdict
…4994) * fix(bin): stage the launch command in a private file and type a short source line A long launch line typed while the fresh pane shell is still busy waits in the terminal's canonical line buffer, which drops input past about 1,024 bytes on macOS, so the pane was left at an unfinished command with no agent running. fm-spawn now writes the assembled command to the task's own temp root under umask 077 and types only a short line that sources it. Refs kunchenguid#4559 * fix(bin): keep the per-task temp root private before staging the launch command The root lives at a predictable path under /tmp and now holds the whole launch command. Create it with mode 0700, refuse one that already exists as anything but a directory owned by this user that nobody else can write, and tighten an owned one, so no other local user can plant or swap the staged file. Refs kunchenguid#4559 * fix(bin): enforce private staged launch file mode * test(spawn): cover long staged Claude launches * no-mistakes(review): Namespace launch files and prove truncation staging * no-mistakes(review): Use immutable per-spawn launch filenames * no-mistakes(document): Document staged launch delivery safeguards * no-mistakes(ci): Updated eight behavior tests/fakes to execute or inspect immutable staged launch files instead of expecting inline launch commands. This restores Muse, secondmate lifecycle/restart, remote trace/parent binding, compact-adviser, and Orca coverage. All affected tests, dispatch-profile regression, fixture tests, syntax checks, ShellCheck, and git diff checks pass --------- Co-authored-by: Vytautas Stankus <svycka@gmail.com>
* Add isolated Herdr runbook to test instructions * no-mistakes(review): Drop substring matching from test.instructions contract * no-mistakes(review): Assert commands.test key absence in YAML * Drop unit-first sentence and instructions contract test Captain-scoped follow-up on the Herdr-lab test.instructions ship: keep the lab safety runbook only, and leave the no-mistakes contract test focused on commands.test absence.
…uid#4873) (kunchenguid#5001) * docs(vision): accept vendor-semantics and 9k contract-ceiling amendments (kunchenguid#4873) Replace the pixels-of-today's-UI rule with a quarantined, version-pinned surface-adapter exception recorded as standing debt. Cap the always-loaded contract at 9,000 words and require prune-or-trigger before a crossing change lands. Co-authored-by: Kun Chen <kunchenguid@users.noreply.github.com> * docs(vision): restore accepted three-sentence vendor-semantics form (kunchenguid#4873) Replace the compressed paraphrase with the issue's accepted wording: a named quarantined version-pinned adapter, expected to break, recorded as standing debt that never hardens into a shared contract. Co-authored-by: Kun Chen <kunchenguid@users.noreply.github.com> --------- Co-authored-by: Cursor Agent <cursoragent@cursor.com> Co-authored-by: Kun Chen <kunchenguid@users.noreply.github.com>
…or-owed gate (kunchenguid#4974) * fix(watch): recheck a gate awaiting a human instead of wedge-escalating it A lane whose validation run is parked at a gate waiting on a human decision is correctly quiet, but nothing in its status line says so: the evidence is the pipeline's own gate state rather than anything the worker wrote. The wedge timer read that silence as a suspected wedge and climbed the escalation ladder for as long as the wait lasted, and each escalation cost a supervising turn. The landed declared-wait consult does not reach it, because a live ordinary crewmate never reports a declared pause, and raising FM_STALE_ESCALATE_SECS would delay genuine wedge detection for every lane by the same amount. The threshold now reads a second, independent record when the status line accounts for nothing: whether the crew's current state is a gate whose answer is owed by a human. That is minted only from the gate's own findings table, by a row whose `action` column is exactly `ask-user`, located by position out of the table header the way nm_gate_step_row already reads its row - never searched for over the run payload, where a finding's free-text description or a branch name satisfies a search just as well. A gate awaiting the CREWMATE's own answer keeps the unchanged escalation schedule, reason and demand-deep-inspection wording, because a crewmate that goes quiet before answering its own gate is exactly the wedge the ladder exists to catch. Each kind of wait now carries the human it is on, the action that clears it, and whether that human is the captain as data alongside the verdict, rather than as wording chosen per branch where the recheck is written, so the deferral cannot word one kind of wait as another and a new kind cannot ship without deciding all of them. A parked gate has no written record of when its wait began, so its recheck publishes no wait age at all rather than one read from the quiet window this deferral resets on every pass, which would report the same small number for a gate of any age. Like every other captain-facing recheck here it is absorbed in silence while the away-posture record exists, arming no throttle, so the recheck is owed in full the moment the record is archived. The consult runs only in the at-threshold branch that was about to escalate, beside the worktree walk already there, and only for lanes whose status line explained nothing. Closes kunchenguid#3055 * no-mistakes(review): require an unanswered decision before deferring a parked gate * no-mistakes(review): reset the away-silenced timer, fail-safe findings parse, US-joined wait records * test(watch): pass the pane hash wedge_timer_check now takes Upstream gave wedge_timer_check a sixth <pane-hash> argument for its dead-record probe. The malformed-wait-record rounds drive the real function directly, so they pass one, and stub fm_backend_agent_state to a live agent so the probe that runs after a refused deferral keeps the unchanged ladder rather than reading a backend the child shell has none of. * no-mistakes(review): Bind parked-gate wait to its run, owe it firstmate * no-mistakes(document): correct wait-kind count, crew-state reader scope, gate-key coupling * feat(watch): make the parked-gate wait deferral opt-in The wedge timer deferring a lane parked at a validation gate is new supervision behaviour rather than a restored one, and it decides which lanes give up the escalation ladder, so it now ships as a default-off per-home option instead of changing every home on upgrade. config/wedge-defer-parked-gate arms it. The flag is read before the decision fold, so an unconfigured home spends no fold or current-state read, writes no record, and keeps the unchanged escalation schedule, reasons and demand-deep-inspection wording; a test counts the reader calls in both directions to pin that. It is not inherited by secondmate homes: each home supervises its own crew and owns that trade separately, the same reason config/turnend-churn-absorb is home-local. The away-posture absorb returns to leaving the idle timer alone, which it had restarted only because the costly consult could reach it. A parked-gate wait is owed to the supervisor rather than the captain, so it never enters that branch, and the recheck owed on return is again owed in full the moment the record is archived. * test(watch): pin that the away-silenced hold leaves the idle timer alone The absorb no longer restarts the timer, so the recheck owed on return is owed in full rather than a cadence into the return. Nothing asserted that, so a restart could be reintroduced silently. * no-mistakes(review): document away-silence rationale, pin captured gate component * no-mistakes(test): anchor gate row scan to the braced findings header * no-mistakes(document): pin same-block gate row invariant in crew-state comment
…uid#5007) * fix(control): let the owning seat reclaim a task whose endpoint is gone A destroyed pane or workspace made `missing` a terminal state. Relaunch accepted only `dead` and said to stop the agent first; exit refused `missing` and said to reconcile the task first; there is no reconcile verb. Each command named the other as its prerequisite, so a task whose terminal went away could not be reclaimed by anything, and a no-mistakes approval it was parked on had no seat left to answer it. `missing` is agent-free a fortiori: there is no endpoint, so there is no agent in it. Widen the existing guards rather than add a verb. - fm-spawn --relaunch accepts a positively proven `missing` and creates one fresh endpoint in the recorded worktree; the record it already republishes rebinds the task to it. A `dead` endpoint is still adopted in place. - fm-control exit reports `endpoint-gone` instead of dying, so the relaunch transaction's stop step no longer dead-ends, and re-resolves the endpoint from the record before verifying the replacement. The duplicate-agent refusal is untouched: both verdicts come from the same recovery-grade classifier, which claims `missing` only from positive absence, so `alive`, `ambiguous`, and `unreadable` all still refuse. The backends' own create paths refuse a live same-labeled endpoint as a second independent guard. The worktree, its branch, commits, uncommitted changes, armed poll and registration, record rows, and status log are all untouched - a reclaim is a recovery, never a teardown. A secondmate is excluded: its gone-endpoint recovery already has one owner in the session-start liveness sweep, so relaunch refuses and names it rather than becoming a second path to the same outcome. Tests reproduce both halves of the deadlock, the reclaim succeeding, unlanded work surviving it, and the refusals that still hold. * no-mistakes(review): prove endpoint absence per backend before reclaim rebinds * no-mistakes(review): give exit and relaunch one absence proof; pin herdr rebind session * no-mistakes(review): narrow endpoint reclaim to herdr; tmux refuses honestly * no-mistakes(review): stop refusals and docs asserting unestablished causes * no-mistakes(review): stop herdr fixture helper losing tmp-root registration * no-mistakes(review): document workspace drift and absence-probe server residue * no-mistakes(review): correct rebind limitation to its one reachable case * no-mistakes(review): stop claiming reclaim leaves instructions untouched * no-mistakes(document): scope fm-control-lib purity claim, note reclaim coverage * no-mistakes(rebase): read the staged launch file in the herdr fixture Rebasing onto main picked up kunchenguid#4994, which stages a long worker launch command into a script and delivers the short `. '<path>'` line instead of the literal command. The tmux fake and tests/fixtures.sh were updated for that; the herdr fake this branch adds was written before it and still keyed "an agent now exists on this pane" off the literal `encode launch-brief` text, so after the rebase it never marked the rebound pane live and the reclaim's alive-wait read `dead`. Dereference the staged file first, exactly as the tmux fake above does. Test-fixture only; no production path changes. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * no-mistakes(document): note reclaim placement in herdr and scripts inventories --------- Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
…3764) * test(status): reproduce missing event emission time * wip(status): preserve optional event emission time * test(status): document indirect clock stub invocation * no-mistakes(review): Preserve historical status bytes during reply recovery * no-mistakes(test): Fix timestamped status assertions and remote fixture dependencies * no-mistakes(review): Preserve captain regex overrides for timestamped status events * no-mistakes(document): Clarify status event timing and publication contracts * no-mistakes(lint): Quote literal done to satisfy ShellCheck * no-mistakes(ci): Captain, updated .github/workflows/ci.yml to expect 19 snapshot tests instead of 18, matching the PR’s added regression. Reproduced the failure before the fix. Stock Bash 3.2.57 verification passed: parse sweep, 19 snapshot tests, 53 Bearings tests, and the public-followup regression. Workflow lint and diff checks passed * no-mistakes(test): Preserve terminal notifications with malformed timestamp tags * no-mistakes(test): Stamp Rovo spawn failures with emission time * no-mistakes(document): Verify status event documentation * no-mistakes(lint): Fix ShellCheck quoting in status emission-time tests * no-mistakes(ci): Captain, fixed four lifecycle assertions to accept emission timestamps while preserving publication and retry checks. Reproduced the CI failure before the fix. The lifecycle suite now passes with six Beads capability skips; syntax, targeted ShellCheck, and diff checks passed * no-mistakes(ci): Captain, fixed malformed timestamp colons hiding actionable events using shared normalization. Original bytes and unknown ages are preserved. Regression reproduced before the fix; classifier and remote-reply suites, targeted lint, syntax, and diff checks passed * no-mistakes(review): Stamp remote escalations at call sites, drop new flag * no-mistakes(review): Accept stamped escalation and close lines in test assertions * no-mistakes(review): Restore reserved-key answered-note guard for stamped closes * test(status): accept optional emission time in PR-provenance assertions The kunchenguid#4148 provenance test landed on main with exact unstamped greps. Parent-channel lines from this branch carry [at=<epoch>], so strip only that tag before the same exact match. No production change. * no-mistakes(review): Accept stamped ready signal in PR fallback scrape * no-mistakes(review): Drop relay flag, stamp parent events at call sites * no-mistakes(review): Stamp worker terminal-signal instructions, revert fm-on fixture * no-mistakes(review): Accept optional stamp in live cmux drift guard * no-mistakes(review): Restore original test invocation order in two suites * no-mistakes(review): Strip only well-formed numeric status time tags * no-mistakes(document): Drop stale unstamped PR-ready line spelling from channel doc * no-mistakes(review): Stamp agy spawn-failure status lines with event time * fix(bin): normalize status event times in-shell and freeze the budget test clock Two paths made a status event's emission time cost more than it should. The captain-relevance fallback piped every line through awk to drop a well-formed `[at=<epoch>]` tag before matching, so a supervisor sweep paid a fork per line just to prepare a regex match. Shell parameter expansion does the same strip with no fork, and the retry-dedup scan now reuses that one helper instead of carrying a second copy of the rule in awk. The copies had already drifted: the shell side stripped tags from lines with no colon, which the awk rule left whole, so a colonless line could be mistaken for one already recorded. One definition, checked against the awk rule it replaces over the edge cases and a 4000-line fuzz. tests/fm-contributions.test.sh froze its fixture clock only in exhaust mode. In hang mode the poll set DEADLINE to the real now plus a one-second budget, and when the second ticked before the first forge call the loop broke without ever calling gh: forge/calls was never written and the assertion failed reading a missing file. Freezing the clock in both modes removes the dependence on wall time; the bounded call is still cut by the real timeout, so the observation the test asserts still starts. Emission time stays optional on new status records, and legacy or malformed lines keep an unknown age. * no-mistakes(review): Stamp ask-user escalation line and fix Kimi status assertion * no-mistakes(document): Drop stale unstamped done-line spelling from watcher docs * test: fold emission-time snapshot coverage into the fixture case Drop the incidental ci.yml 18-to-19 count hunk so the PR no longer touches workflows. Keep every emission-time assertion by folding it into test_fixture_snapshot_json. * no-mistakes(review): replace brief date substitution with epoch placeholder; drop emitted_at_epoch * no-mistakes(review): align untimed normalizer with epoch parser; tolerate placeholder stamp in PR scrape * no-mistakes(review): strip undelimited at-tags; correct brief stamp header * no-mistakes(review): normalize stamps at both captain-regex sites; restore mtime freshness * no-mistakes(review): strip colon-bearing stamps for relevance; fix headers and test oracles * no-mistakes(review): narrow escalation match to stamp tolerance; pin note verb * no-mistakes(review): read note and key past colon-bearing stamps * test(status): keep inactive reconcile assertions stamp-tolerant These two oracles were made stamp-tolerant while resolving one of the branch's merges from main. The rebase drops merge commits, so that adaptation was lost and both assertions went back to matching an exact substring that a stamped line no longer contains: the tag lands before the colon, so "failed [key=k]: ..." is now "failed [key=k] [at=N]: ...". Strip a well-formed tag before matching, as the branch's other oracles do. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * no-mistakes(review): unstamp fold colon tests; reserve stamp width in cap * no-mistakes(document): correct stale unstamped status-line spellings in docs * no-mistakes(document): quote brief-test literals for lint; correct stamp-helper contract comments * no-mistakes(ci): rename subshell-local epoch in delivery-race stub The serialization test overrides fm_pending_reply_mark_delivered inside a (..) subshell. Its `epoch` local collided with the same name in status_line_at_epoch/status_stamp_line, which this branch added and this suite now calls at top level, so ShellCheck 0.11.0 reported SC2030 and failed Lint 2. The stub already prefixes its other locals with `pending_` for the same reason; `epoch` was the leftover. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
- FM_DISCORD_ALLOW_DMS was exported but never checked in fm-discord-poll.js; DMs were ingested unconditionally. Now skipped when the flag is false. - The poller fetched only the newest 10 messages with no cursor, so a burst of 11+ messages between polls could permanently skip an older mention. Now persists a per-channel last-seen message id and polls with after=. - Multi-chunk replies in fm-discord-reply.js had no per-chunk progress, so a retry after a partial failure would repost already-delivered chunks. Now persists send progress per request id and resumes from the first unsent chunk, clearing the record on full completion.
Ivory2024
force-pushed
the
fm/firstmate-discord-selfhosted-relay-20260920
branch
from
September 20, 2026 09:59
49901bc to
214c664
Compare
added 8 commits
September 20, 2026 19:32
…er regression coverage
….omo evidence files .omo/evidence/*.md were pipeline review artifacts accidentally committed; add .omo/ to .gitignore to stop recurrence.
…ip check, zellij tab_id) over the simpler earlier version, keep [at=] status-line stripping in resolve-key tests, classify restored .omo evidence files
fm-x-dismiss.sh now sources fm-discord-lib.sh and calls fm_discord_is_selfhosted_request, which reads the context registry and triggers fmx_context_registry_prune as a side effect. The dismiss call in this test had no FMX_NOW_OVERRIDE, so prune ran against the real wall clock, saw the offer marker's FMX_NOW_OVERRIDE-stamped recorded_at as ~2.8 years old, and deleted it, causing a spurious re-offer. Give the dismiss call the same mocked clock as the surrounding poll calls.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Intent
Add additive self-hosted Discord connection for Firstmate with bot token, collision handling excluding channel 1551134713727426570 by default, documentation in docs/configuration.md, and regression tests in tests/fm-discord-selfhosted.test.sh
What Changed
1551134713727426570.x-reply/x-dismissrouting with generated local artifacts..envconfiguration and added regression coverage for no-token behavior, message ingestion and collision filtering, dry-run routing, identity validation, and bootstrap activation.Risk Assessment
🚨 High: The change currently does not integrate its generated check with the watcher, can bypass configured dry-run safety and writes protected state without guarded publication, so the intended feature is not safe to merge.
Testing
The targeted connector regression covered ingestion, collision exclusion, DM cursor advancement, reply routing, symlink safety, and bootstrap activation/failure handling. These results were not live product validation. The mandated live Herdr surface remains untested due to the missing Herdr client.
herdrexecutable is unavailable on PATH, with no repository-local binary supplied. Provide the approved Herdr client and a real Discord bot token/test channel, then rerun through `bin/f…Evidence: Focused self-hosted Discord regression
Evidence: Herdr lab setup blocker
Pipeline
Updates from git push no-mistakes
✅ **intent** - passed
✅ No issues found.
✅ **Rebase** - passed
✅ No issues found.
bin/fm-bootstrap.sh:1166- Token-only activation does not reach the watcher.bin/fm-bootstrap.sh:1166createsdiscord-watch.check.sh, butbin/fm-watch.sh:2375-2384special-cases onlyx-watch.check.sh; the Discord file falls into the generic custom-check path, which requires an uncreated.check-trustregistration.discord-mode.envis also not consumed by the existing cadence setup. Thus the required self-hosted connection never polls or emits wakes.bin/fm-x-reply.sh:323- Dry-run can post publicly when configured through.env.fm-x-reply.shloadsFMX_DRY_RUNintoFMX_DRYand then routes atbin/fm-x-reply.sh:323-326; the Discord adapter only checks the rawFMX_DRY_RUNenvironment variable atbin/fm-discord-reply.js:25. Because the wrapper does not export the.envvalue,FMX_DRY_RUN=1in.envis ignored and a live Discord POST occurs.bin/fm-discord-poll.js:147- The new Node poller writes inbox, context, and offer files with rawwriteFileSyncatbin/fm-discord-poll.js:147-149, and the reply adapter writes outbox files atbin/fm-discord-reply.js:76, without the repository's guarded private-artifact publication. A pre-existing symlink or hardlink under these state directories can redirect writes to another file, and an existing file's mode is not tightened by themodeoption. This violates the protected state-file invariant and can cause data disclosure or overwrite.bin/fm-discord-poll.js:80-FM_DISCORD_ALLOW_DMSis an extra user-visible option not required by the stated intent, and its documentedfalsevalue is ignored:bin/fm-discord-lib.sh:62-67exports it, butbin/fm-discord-poll.js:80-85accepts every DM unconditionally. For an explicitly listed DM channel, a message with no guild ID is ingested even when the operator setsFM_DISCORD_ALLOW_DMS=false. Remove this option as unnecessary, or authorize retaining and implementing its behavior.tests/fm-discord-selfhosted.test.sh:33- The added regression tests do not exercise the new ingestion or collision behavior.test_ingestion_payload_shape_and_wakewrites the inbox/context files and prints the wake line itself (tests/fm-discord-selfhosted.test.sh:33-68) instead of runningfm-discord-poll.js;test_collision_exclusion_filterreimplements the comma split in a separatenode -esnippet (tests/fm-discord-selfhosted.test.sh:114-120). These assertions can pass while the actual poller or watcher is broken.bin/fm-discord-poll.js:89- Every accepted Discord message createsstate/x-context/<request_id>.offered.jsonatbin/fm-discord-poll.js:89-90and:149, but this new poller never invokes the existing context/offer retention pruning used by the hosted poll path. A long-running self-hosted home therefore accumulates one marker per handled message indefinitely.🔧 Fix applied.
8 issues (4 errors, 4 warnings) still open:
bin/fm-bootstrap.sh:1166- Token-only activation does not reach the watcher.bin/fm-bootstrap.sh:1166createsdiscord-watch.check.sh, butbin/fm-watch.sh:2375-2384special-cases onlyx-watch.check.sh; the Discord file falls into the generic custom-check path, which requires an uncreated.check-trustregistration.discord-mode.envis also not consumed by the existing cadence setup. Thus the required self-hosted connection never polls or emits wakes.bin/fm-discord-poll.js:147- The new Node poller writes inbox, context, and offer files with rawwriteFileSyncatbin/fm-discord-poll.js:147-149, and the reply adapter writes outbox files atbin/fm-discord-reply.js:76, without the repository's guarded private-artifact publication. A pre-existing symlink or hardlink under these state directories can redirect writes to another file, and an existing file's mode is not tightened by themodeoption. This violates the protected state-file invariant and can cause data disclosure or overwrite.bin/fm-discord-poll.js:80-FM_DISCORD_ALLOW_DMSis an extra user-visible option not required by the stated intent, and its documentedfalsevalue is ignored:bin/fm-discord-lib.sh:62-67exports it, butbin/fm-discord-poll.js:80-85accepts every DM unconditionally. For an explicitly listed DM channel, a message with no guild ID is ingested even when the operator setsFM_DISCORD_ALLOW_DMS=false. Remove this option as unnecessary, or authorize retaining and implementing its behavior.bin/fm-discord-poll.js:89- Every accepted Discord message createsstate/x-context/<request_id>.offered.jsonatbin/fm-discord-poll.js:89-90and:149, but this new poller never invokes the existing context/offer retention pruning used by the hosted poll path. A long-running self-hosted home therefore accumulates one marker per handled message indefinitely.bin/fm-discord-poll.js:161- A poll can surface multiple new messages, butbin/fm-discord-poll.js:161prints onex-mentionline per message. The watcher consumes the entire multiline result as one check payload atbin/fm-watch.sh:2388/:2446, sanitizes embedded newlines, and queues one wake. With two accepted messages in one poll, both inboxes and offer markers are created but only one request is reliably surfaced; the next poll skips both markers. The regression fixture attests/fm-discord-selfhosted.test.sh:36exercises only one accepted message. Preserve the one-request-per-wake contract by processing or surfacing each request separately.bin/fm-discord-reply.js:55- The new reply path readsstate/x-context/<id>.jsonthrough rawexistsSync/readFileSyncatbin/fm-discord-reply.js:55-60; the poll path similarly treats anyexistsSyncresult atbin/fm-discord-poll.js:106as a valid offer marker. A pre-existing symlink or hardlink can therefore make a reply adopt channel/message IDs from an unrelated file, or permanently suppress ingestion for a message. Use the existing private-artifact identity validation and atomic offer-claim helpers at the shared boundary.bin/fm-bootstrap.sh:1173- The fix round added watcher dispatch fordiscord-watch.check.sh, but the cadence artifact is still unused.bin/fm-bootstrap.sh:1173writesconfig/discord-mode.envanddocs/configuration.md:673promises 30-second polling, while the active arm paths source onlyx-mode.env(.pi/extensions/fm-primary-pi-watch.ts:985,.omp/extensions/fm-primary-omp-watch.ts:895, andbin/fm-claude-stop-autoarm.sh:250).bin/fm-watch.sh:233therefore keeps the default 300-second interval unless the caller manually exportsFM_CHECK_INTERVAL. Source the Discord cadence through the same shared arm path or remove the false cadence claim.bin/fm-discord-poll.js:86- The poller fetches only the newest 10 messages per channel atbin/fm-discord-poll.js:86and keeps no Discord message cursor. If 11 messages arrive between polls and the mention is the 11th-oldest, it leaves the API window permanently without creating an inbox or marker. The documented 30-second cadence is also currently inactive, making this easier to reach. A durable cursor/backfill strategy is needed for lossless ingestion and would extend the stated additive slice, so that remedy requires authorization.🔧 Fix applied.
9 issues (3 errors, 6 warnings) still open:
bin/fm-bootstrap.sh:1166- Token-only activation does not reach the watcher.bin/fm-bootstrap.sh:1166createsdiscord-watch.check.sh, butbin/fm-watch.sh:2375-2384special-cases onlyx-watch.check.sh; the Discord file falls into the generic custom-check path, which requires an uncreated.check-trustregistration.discord-mode.envis also not consumed by the existing cadence setup. Thus the required self-hosted connection never polls or emits wakes.bin/fm-discord-poll.js:147- The new Node poller writes inbox, context, and offer files with rawwriteFileSyncatbin/fm-discord-poll.js:147-149, and the reply adapter writes outbox files atbin/fm-discord-reply.js:76, without the repository's guarded private-artifact publication. A pre-existing symlink or hardlink under these state directories can redirect writes to another file, and an existing file's mode is not tightened by themodeoption. This violates the protected state-file invariant and can cause data disclosure or overwrite.bin/fm-discord-poll.js:80-FM_DISCORD_ALLOW_DMSis an extra user-visible option not required by the stated intent, and its documentedfalsevalue is ignored:bin/fm-discord-lib.sh:62-67exports it, butbin/fm-discord-poll.js:80-85accepts every DM unconditionally. For an explicitly listed DM channel, a message with no guild ID is ingested even when the operator setsFM_DISCORD_ALLOW_DMS=false. Remove this option as unnecessary, or authorize retaining and implementing its behavior.bin/fm-discord-poll.js:89- Every accepted Discord message createsstate/x-context/<request_id>.offered.jsonatbin/fm-discord-poll.js:89-90and:149, but this new poller never invokes the existing context/offer retention pruning used by the hosted poll path. A long-running self-hosted home therefore accumulates one marker per handled message indefinitely.bin/fm-discord-poll.js:161- A poll can surface multiple new messages, butbin/fm-discord-poll.js:161prints onex-mentionline per message. The watcher consumes the entire multiline result as one check payload atbin/fm-watch.sh:2388/:2446, sanitizes embedded newlines, and queues one wake. With two accepted messages in one poll, both inboxes and offer markers are created but only one request is reliably surfaced; the next poll skips both markers. The regression fixture attests/fm-discord-selfhosted.test.sh:36exercises only one accepted message. Preserve the one-request-per-wake contract by processing or surfacing each request separately.bin/fm-bootstrap.sh:1173- The fix round added watcher dispatch fordiscord-watch.check.sh, but the cadence artifact is still unused.bin/fm-bootstrap.sh:1173writesconfig/discord-mode.envanddocs/configuration.md:673promises 30-second polling, while the active arm paths source onlyx-mode.env(.pi/extensions/fm-primary-pi-watch.ts:985,.omp/extensions/fm-primary-omp-watch.ts:895, andbin/fm-claude-stop-autoarm.sh:250).bin/fm-watch.sh:233therefore keeps the default 300-second interval unless the caller manually exportsFM_CHECK_INTERVAL. Source the Discord cadence through the same shared arm path or remove the false cadence claim.bin/fm-discord-poll.js:86- The poller fetches only the newest 10 messages per channel atbin/fm-discord-poll.js:86and keeps no Discord message cursor. If 11 messages arrive between polls and the mention is the 11th-oldest, it leaves the API window permanently without creating an inbox or marker. The documented 30-second cadence is also currently inactive, making this easier to reach. A durable cursor/backfill strategy is needed for lossless ingestion and would extend the stated additive slice, so that remedy requires authorization.bin/fm-discord-poll.js:145- The cursor fix leaves disabled DMs unadvanced:bin/fm-discord-poll.js:145continues beforewriteCursor, making the duplicate guard at:153-155unreachable. A DM arriving after the last permitted message is fetched on every poll because the channel cursor never advances. Consolidate the guard and advance the cursor once; sibling site:bin/fm-discord-poll.js:153-155.bin/fm-bootstrap.sh:1166- Bootstrap ignores both private-artifact publication failures atbin/fm-bootstrap.sh:1166and:1173, then always prints that Discord mode is armed at:1175. If either artifact path is invalid, unsafe, or unwritable, the watcher receives no valid shim/cadence but the operator is told activation succeeded. Propagate the failure or emit an explicit inactive result.🔧 Fix applied.
14 issues (5 errors, 9 warnings) still open:
bin/fm-bootstrap.sh:1166- Token-only activation does not reach the watcher.bin/fm-bootstrap.sh:1166createsdiscord-watch.check.sh, butbin/fm-watch.sh:2375-2384special-cases onlyx-watch.check.sh; the Discord file falls into the generic custom-check path, which requires an uncreated.check-trustregistration.discord-mode.envis also not consumed by the existing cadence setup. Thus the required self-hosted connection never polls or emits wakes.bin/fm-discord-poll.js:147- The new Node poller writes inbox, context, and offer files with rawwriteFileSyncatbin/fm-discord-poll.js:147-149, and the reply adapter writes outbox files atbin/fm-discord-reply.js:76, without the repository's guarded private-artifact publication. A pre-existing symlink or hardlink under these state directories can redirect writes to another file, and an existing file's mode is not tightened by themodeoption. This violates the protected state-file invariant and can cause data disclosure or overwrite.bin/fm-discord-poll.js:80-FM_DISCORD_ALLOW_DMSis an extra user-visible option not required by the stated intent, and its documentedfalsevalue is ignored:bin/fm-discord-lib.sh:62-67exports it, butbin/fm-discord-poll.js:80-85accepts every DM unconditionally. For an explicitly listed DM channel, a message with no guild ID is ingested even when the operator setsFM_DISCORD_ALLOW_DMS=false. Remove this option as unnecessary, or authorize retaining and implementing its behavior.bin/fm-discord-poll.js:89- Every accepted Discord message createsstate/x-context/<request_id>.offered.jsonatbin/fm-discord-poll.js:89-90and:149, but this new poller never invokes the existing context/offer retention pruning used by the hosted poll path. A long-running self-hosted home therefore accumulates one marker per handled message indefinitely.bin/fm-discord-poll.js:161- A poll can surface multiple new messages, butbin/fm-discord-poll.js:161prints onex-mentionline per message. The watcher consumes the entire multiline result as one check payload atbin/fm-watch.sh:2388/:2446, sanitizes embedded newlines, and queues one wake. With two accepted messages in one poll, both inboxes and offer markers are created but only one request is reliably surfaced; the next poll skips both markers. The regression fixture attests/fm-discord-selfhosted.test.sh:36exercises only one accepted message. Preserve the one-request-per-wake contract by processing or surfacing each request separately.bin/fm-bootstrap.sh:1173- The fix round added watcher dispatch fordiscord-watch.check.sh, but the cadence artifact is still unused.bin/fm-bootstrap.sh:1173writesconfig/discord-mode.envanddocs/configuration.md:673promises 30-second polling, while the active arm paths source onlyx-mode.env(.pi/extensions/fm-primary-pi-watch.ts:985,.omp/extensions/fm-primary-omp-watch.ts:895, andbin/fm-claude-stop-autoarm.sh:250).bin/fm-watch.sh:233therefore keeps the default 300-second interval unless the caller manually exportsFM_CHECK_INTERVAL. Source the Discord cadence through the same shared arm path or remove the false cadence claim.bin/fm-discord-poll.js:86- The poller fetches only the newest 10 messages per channel atbin/fm-discord-poll.js:86and keeps no Discord message cursor. If 11 messages arrive between polls and the mention is the 11th-oldest, it leaves the API window permanently without creating an inbox or marker. The documented 30-second cadence is also currently inactive, making this easier to reach. A durable cursor/backfill strategy is needed for lossless ingestion and would extend the stated additive slice, so that remedy requires authorization.bin/fm-discord-poll.js:145- The cursor fix leaves disabled DMs unadvanced:bin/fm-discord-poll.js:145continues beforewriteCursor, making the duplicate guard at:153-155unreachable. A DM arriving after the last permitted message is fetched on every poll because the channel cursor never advances. Consolidate the guard and advance the cursor once; sibling site:bin/fm-discord-poll.js:153-155.bin/fm-bootstrap.sh:1166- Bootstrap ignores both private-artifact publication failures atbin/fm-bootstrap.sh:1166and:1173, then always prints that Discord mode is armed at:1175. If either artifact path is invalid, unsafe, or unwritable, the watcher receives no valid shim/cadence but the operator is told activation succeeded. Propagate the failure or emit an explicit inactive result.bin/fm-discord-poll.js:214- The cursor fix round can permanently suppress a first wake. AfterclaimOffer()creates the durable offer marker,writeCursor()atbin/fm-discord-poll.js:214can fail on an unsafe, unwritable, or otherwise invalid cursor path; the outer catch at:219-221then exits without printingx-mention. The next poll sees the offer marker, skips emission, and leaves the inbox stranded. Preserve at-least-once wake delivery when cursor publication fails. The other cursor sites at:139,:149,:153,:164, and:210are non-first-delivery paths.bin/fm-discord-poll.js:127- The cursor fix round still drops messages beyond the Discord page window.bin/fm-discord-poll.js:127requests only 10 messages after the cursor; if 11 messages arrive and the mention is among the oldest omitted records, the code advances the cursor through the returned newer records and permanently skips that mention. The regression server attests/fm-discord-selfhosted.test.sh:48-50filters byafterbut does not enforce the API limit, so it cannot expose this sequence. Paginate or otherwise drain the bounded backlog before advancing past it..pi/extensions/fm-primary-pi-watch.ts:985- The cadence fix round sourcesdiscord-mode.envonly in the Pi, OMP, and Claude arm paths (.pi/extensions/fm-primary-pi-watch.ts:985,.omp/extensions/fm-primary-omp-watch.ts:895,bin/fm-claude-stop-autoarm.sh:251). Active sibling arm paths still source onlyx-mode.env:.opencode/plugins/fm-primary-watch-arm.js:345andbin/fm-turnend-guard-cursor.sh:287. A self-hosted Discord home using those paths therefore keeps the default 300-second check interval despitebin/fm-bootstrap.sh:1173anddocs/configuration.md:673promising 30-second polling.bin/fm-bootstrap.sh:1176- The latest bootstrap failure fix reports self-hosted Discord as inactive but leaves partial or stale artifacts. If Node is missing atbin/fm-bootstrap.sh:1153, if shim publication fails at:1166, or if cadence publication fails at:1176after the shim was published, existing/generated artifacts are not removed. The watcher can consequently continue dispatching a stale shim at the wrong cadence while bootstrap says the mode is inactive. Remove the complete artifact pair on every failed activation path.bin/fm-discord-reply.js:151- Self-hosted multi-message replies are posted sequentially atbin/fm-discord-reply.js:115-161, whilebin/fm-x-reply.sh:301-327intentionally produces multiple Discord-sized chunks. If the first POST succeeds and a later POST fails or is rate-limited, the command exits nonzero after partial external delivery; a retry sends the first chunk again because no per-chunk progress or idempotency record exists. The defect is source-verifiable, but the smallest honest remedy requires durable send progress/idempotency beyond this additive slice, so retaining this behavior needs authorization or an explicitly accepted duplicate-delivery policy.herdrexecutable is unavailable on PATH, with no repository-local binary supplied. Provide the approved Herdr client and a real Discord bot token/test channel, then rerun through `bin/f…tests/fm-discord-selfhosted.test.shbin/fm-herdr-lab.sh prepare fm-lab-discord-20260920node --versionjq --versiongit status --short --branch✅ **Document** - passed
✅ No issues found.
✅ **Lint** - passed
✅ No issues found.
✅ **Push** - passed
✅ No issues found.