message_agent resolved every target locally first and tried the relay only when that failed.
A connection-qualified target like `hermes@mini` has no exact folder match, so local
resolution fell through to friendly-name alias matching. alias_forms slugifies the '@' into
'-' and also collapses it, so `hermes@mini` became {hermes-mini, hermesmini}. A local bot
titled or named "Hermes Mini" owned that alias: the DM was delivered to it with no relay
envelope queued, and its content landed in the wrong bot's transcript and memory. That
qualified form is exactly what the relay hands out for a remote row whose bare forms collide
(remote_target_forms' handle@connection fallback, #116983), and what qualify_sender_stamp puts
on replies, so replies misrouted the same way.
A target that still carries an '@' after the mention prefix is now tried against the relay
roster first. When it names a remote row it goes to the relay. An '@' friendly name that no
connection answers to still resolves locally, as #113410 made punctuated names do.
- tools/browser_tool_install.py: keep pm-clean's frozen old-updater stub; main's
UTF-8 decode fix touched only the npx prefetch body it replaces.
- tests/hermes_cli/test_update_scoped_reconciliation.py: keep pm-clean's test
subset (catch-up rides the PM completion owner) and take main's gateway-less
host evidence (#120740): the updated seed that holds the host at a running
gateway, and the two gateway-less matrices for the source change that merged
cleanly into update_cmd_fleet.py.
The Desktop serve backend polls /api/fs/default-cwd, whose git-branch probe
captured git output with text=True and no encoding. subprocess then decodes
with locale.getencoding() - cp936 on zh-CN Windows - so a UTF-8 branch name or
git's localized stderr raised UnicodeDecodeError inside communicate()'s
_readerthread on every poll ([gateway-crash] ... 'gbk' codec can't decode).
Pin encoding='utf-8', errors='replace' on the remaining in-process captures
whose children emit UTF-8: the git branch probe, the self-repo guard's git
alias read, the Bot Mode DM transport (a Hermes CLI child, stdio forced to
UTF-8; builds on the salvaged errors='replace'), the agent-browser npx probe,
the lightpanda help probe, and the cua-driver stderr drain thread.
Fixes#83851
Same class as the cron script runner: text=True decodes with the locale codec under strict error handling, so one undecodable byte in a child's output raises UnicodeDecodeError inside subprocess.run. That is a ValueError - neither except (subprocess.SubprocessError, OSError) nor except TimeoutExpired catches it - so it escaped into user-visible paths instead of the intended error handling.
tools/bot_mode_dm._run_local_turn: a transport that exits 0 but prints a byte the locale cannot decode crashed the delivery instead of re-emitting the transport's streams (stdout is the reply text the completion notification carries back).
agent.command_token_source._mint: a key_cmd printing a non-UTF-8 byte killed token minting with a traceback instead of the documented CommandTokenError path, so provider auth failed in a way the caller could not report.
Both decode with errors=replace now: the exit code and the surrounding checks still decide what happens, and damaged bytes appear as U+FFFD in the text we carry.
Branch semantics kept where main and PM disagree: update_cmd_deps.py,
constraints-termux.txt, the Electron update-api-check module and the
post-swap hand-off test stay deleted; the pending-fleet-restart catch-up
and the local_runtime tag/download ladder stay retired (PM owns engines).
Ported from main onto the branch's shape: profile_scoped_chore for the
auto-archive and plugin-update housekeeping chores, the local-runtime
cross-process boot lock and residency cap, the checkpoint tmp_pack sweep,
the cua daemon-liveness status probe, the remote-served Desktop update
flag (posix.sh / windows.ps1), sign-in for env-pinned remote gateways
(urlDisabled on RemoteSetupFields), the uvloop extra split (uvicorn
without [standard]), and the umask-scoping spawn test.
uv.lock regenerated with pm.build_env --lock-only; new utf-8 reads from
main switched to utf-8-sig (check-windows-footguns).
Local message_agent, the Desktop relay, `hermes peer dm` and `hermes peer run`
all hand a turn to the live Bot Chat owner's mailbox and then need the same
thing: poll the receipt until the owner settles it, the budget lapses, or the
caller wants out. Each lane carried (or lacked) its own copy of that loop,
which is how the class drifted — the relay answered with a receipt sentence
(#115316), the two peer lanes never asked the owner at all (#114959, #115174).
`tools/bot_live_delivery.py::await_delivery` (+ `await_delivery_async` for the
aiohttp handlers, which must not park a worker thread for 300 s) is now the one
loop; `_wait_live_dm`, `bot_relay.deliver`, `_answer_through_live_bot_chat`
and `_execute_run_via_live_owner` call it. `should_stop` carries the peer-run
`/stop` check the runs lane needs.
Every gateway's `default` profile is `@hermes`, so a remote default titled
"CoS Bot" was unreachable by any bare form: `resolve_remote_target` matched
handle/profile only, the prompt roster offered it as `@hermes` (which local
resolution routes to THIS gateway's default), and the DM it sent arrived
stamped `(@hermes)` so a reply hit the recipient's own default.
- `tools/bot_relay.py`: `_target_aliases` adds the Bot Mode title's mention
slugs; `resolve_remote_target` accepts them (exact handle/profile keeps
precedence over a colliding title); `remote_target_forms` picks the shortest
form no LOCAL profile and no other remote row answers to — bare handle,
title slug, then `handle@connection` — independent of roster order;
`qualify_sender_stamp` rewrites a relayed `Message from 🤖 … (@handle):`
to that form.
- `tools/bot_mode_probe.py::local_taken_forms` feeds the local handles and
friendly-name slugs into the roster paragraph; `bot_mode_dm` lists ambiguous
candidates with the same forms.
- `tui_gateway/methods_bot_relay.py::bot_relay.deliver` re-stamps the sender
before the transport branch (the recipient knows `from_connection`; the
sender does not know its own Desktop id).
Slim redo of #103767's Python half (@fangliquanflq): that PR always
qualified every remote form with its connection id and reworked the Desktop
mention Map; this keeps bare forms where they are unambiguous and leaves the
Desktop picker untouched.
Part of #103731
Salvages #103767
Co-authored-by: fangliquanflq <fangliquanflq@users.noreply.github.com>
A bot DM's reply comes back as the delivery process's completion notification,
which carried the last 2000 characters of that process's output — the right
tail for a build log, but a bot may send 16,000 characters (MESSAGE_MAX_CHARS)
and its teammate's answer routinely runs longer than 2000. The sender got the
tail of the reply, header included in the cut, with nothing saying so, and
relayed it as the whole answer.
The registry now sizes a completion per process (ProcessSession
.completion_output_chars, checkpointed) and declares a cut (output_cut) when
the output did not fit; the notice names the cut and the process log that has
the rest. tools/bot_mode_dm._spawn_delivery — the one spawn point for every
DM lane (local, live-owner, Desktop relay waiter) — asks for MESSAGE_MAX_CHARS
plus header room. Everything else keeps the 2000-char tail.
Fixes#115334
Conflicts resolved toward the PM model: main's lazy_deps/update_cmd_deps/npm
stamp machinery stays deleted (PM + scripts/build/node-deps.mjs own it), the
systemd ExecStop stop-mark rides the installation launcher, legacy
linux_only/macos_only/windows_only markers are rewritten to platforms(), and
finalize_update_receipt carries pending manual-serve obligations forward
again (lost when the ContextVar receipt rewrite crossed c0aa3ce354).
Test harness: the real-home I/O guard exempts /proc/<pid>/fd metadata reads
(deleted-WAL holder scans) and run_tests.sh drops ~/.hermes PATH entries so
shutil.which() cannot trip the tripwire.
The cross-machine reply waiter was spawned as `python -c '<generated
source>'` through terminal_tool from the SENDER's turn. The approval gate
flags inline interpreter code ("script execution via -e/-c flag"), and
`approvals.single_query_mode` defaults to `deny` for the one-shot `-Q` turn
a bot replies from — so exactly when bots talked to each other (a teammate
answering a DM from its own delivery turn), the waiter was refused. The
message was delivered and, since 54d7f75590, honestly reported as queued with
a notification_error; the reply still never woke the sender, and every
cross-machine bot-to-bot exchange ended after one hop.
Spawn it as `tools/bot_mode_dm.py --wait-reply <reply file> <label>
<budget>`, the shape the local delivery runner already uses and the gate
lets through. The runner's --wait-reply mode is the old generated loop,
stdlib only, same three notifications and exit codes. Roster fields ride as
shlex-quoted argv instead of source text, so a hostile handle or connection
id is a label, not code, and the Windows path rewrite is the delivery
runner's (forward slashes for Git Bash), replacing the raw-string trick.
teknium1 suggested this follow-up when closing #97051.
Resolved toward the branch: PM provisions uv/python (main's install.ps1 uv-shim
salvage + its test and workflow steps dropped), the shim re-exec stays retired,
package.json carries no electron-builder block (afterExtract identity stamp wired
into electron-builder.config.cjs instead; after-pack.mjs keeps signing only),
Desktop workspace-deps helpers stay retired. Main's scratch-dir bootstrap
(export_scratch_tmp_env) is taken and re-run after profile resolution.
Two gaps left by the previous commit (#101142):
- The live-owner branch of _start_delivery rebuilt its ack from scratch and dropped
_spawn_delivery's reply_delivery verdict, so an api_server sender messaging a
Desktop-open teammate was still told to finish its turn while the reply sat in the
runner's stdout. The branch now propagates reply_delivery and, when it is "poll",
carries the same process(action='wait') instruction.
- The poll advisory alone still depended on the model obeying it. When terminal_tool
refuses the completion promise, _spawn_delivery now arms _persist_reply_when_done:
once the tracked runner exits, its output (the reply or the delivery failure) is
appended to the sender's session transcript as a DELIVERY row
(display_kind=process_complete, the shape push surfaces persist for the same
completion — mirrors gateway.wake.persist_delegation_delivery), unless the sender
already read it via process(action='wait'/'log'). The ack says so.
Also: the poll detail tells the sender to call wait again on status=timeout
(ProcessRegistry.wait is clamped to TERMINAL_TIMEOUT), and the tool description
qualifies the notification promise with the reply_delivery="poll" exception.
On an api_server-bound Bot Chat (supports_async_delivery=False) terminal_tool refuses
the notify_on_complete promise — no completion watcher is registered — but
_spawn_delivery ignored that verdict and acked "that process's completion
notification carries the delivery outcome". The recipient ran, its reply sat in the
finished process's stdout, and nothing ever reached the sender (#101142).
The ack now reads terminal_tool's notify_on_complete field: when the promise was refused
it reports reply_delivery="poll" and tells the sender to retrieve the outcome with
process(action='wait', session_id=<id>) before ending the turn — the return path the
surface actually supports. Push-capable senders keep the notification ack byte-for-byte
(reply_delivery="notification").
The `--run-delivery` catch-all in tools/bot_mode_dm.py printed a structured
`{error, reason}` only for target_busy; any other exception went to stderr as
prose, so the sender's completion notification had no reason to branch on
(auth failure vs. transient) for the local lane while the relay lane had one.
The vocabulary-guarded helper the relay lane introduced moves beside the
vocabulary it guards (tools/bot_failure_reasons.delivery_failure_reason) and
both lanes call it: an exception's own `reason` is trusted only when it is
`target_busy` or in ALL_REASONS, otherwise the text is classified. The runner
now prints `{error, reason}` JSON on stdout for every exception (exit 1 kept).
Test: a classifiable, an unclassifiable and an out-of-vocabulary-reason
exception in --run-delivery each yield JSON with a vocabulary reason.
Review finding on #114941: the CLI-runner branch of `_spawn_delivery` still
answered `status: sent` with only a `process_id`, while the live-owner branch
answers `queued`/`claimed` + `delivery_id` and the relay branch `queued`. One
tool, three vocabularies; `sent` also reads as a delivery receipt, which is the
misreading this PR exists to remove.
`_spawn_delivery` now returns `status: queued`, a `delivery_id` and the
`process_id`. For CLI-runner deliveries the id is the same sha256 of the DM
file path the runner pins when it admits to a live owner (`_dm_delivery_id`,
replacing three inline copies), so the ack and any live receipt correlate; the
relay branch passes the envelope id (also on its notification_error ack).
`sent_at` becomes `queued_at`. Tool schema, Bot Mode user guide and the
assertions in tests/tools follow; two invariant tests pin the CLI-runner and
relay ids (red before, green after).
The CLI-runner fallback of `message_agent` returns `status: sent` synchronously
while delivery runs in a background process; when that process dies (e.g. the
`hermes` entrypoint missing from PATH in the Docker image, #114628) the failure
only arrives as the background-process completion notification. The old
`detail` ("Message dispatched ... when the delivery completes, its notification
carries the reply") and the schema description ("delivery acknowledgement")
read as a delivery receipt, which misled the calling agent into reporting a
successful hand-off and routing around the tool.
Reword `detail` and the tool description to say the ack is the hand-off to a
background delivery process, not a delivery receipt, and that the completion
notification carries the outcome — the reply or the delivery failure. Update
the Bot Mode user guide to match. `status: sent` is unchanged (renaming to
`queued` + `delivery_id` is a return-contract change left to the maintainer).
Fixes#114628
Supersedes #111562
Reconcile plugin declarations and validation through PM's atomic generation publication; preserve external runtimes, target markers, and conflict refusal. Keep one source-update completion owner and port upstream lifecycle changes to the PM desktop/runtime paths.
Folds the backend half of #113396 (#89720) into this branch so there is ONE
friendly-name resolver: `_display_name` reads the Bot Mode title, then the
profile.yaml `display_name` written by `hermes profile rename` (the Desktop's
botFriendlyNames order), then the @handle — so the renamed primary signs
`Maia (@hermes)` instead of `hermes (@hermes)`. Reaching it by `maia` /
`@maia` already works through `_resolve_local_name`, which stays the single
resolver (fail-closed on ambiguity, punctuated names accepted).
`local_alias_map` docstring now states what the code does: an exact folder
id wins by design; a friendly name colliding with another folder id never
steals it, and ambiguity is only between friendly names.
Docs: the Direct-messages bullet lists the accepted target forms (profile
name, friendly name, Desktop @-slug) and the fail-closed ambiguity rule.
Review follow-up for #113410 / #113396.
message_agent keyed local targets on the profile FOLDER id only, while the Desktop
autocompletes the bot's friendly name (profile.yaml display_name, or the Bot Mode
title) as its @-slug. "@scribe" for folder `writer`, or "Dr. Foo" for folder `foo`,
came back "No teammate named …" (a punctuated name was even "Invalid target"), and
the model went hunting for the teammate under ~/.hermes/profiles/ instead.
- tools/bot_mode_probe.py: alias_forms() mirrors the Desktop's mentionNameForms
(slug + collapsed, reserved tokens dropped); local_alias_map() builds
form → {folder ids} from every local profile's display_name / title.
- tools/bot_mode_dm.py::_resolve_local_name: exact folder id first, then a unique
alias; two bots sharing a friendly name fail closed (never the one that sorts
first). The handle-shape check no longer rejects a friendly name that resolves.
- The protocol roster line leads with a display_name that differs from the folder
id and title, so an untagged "talk to Scribe" maps to `@writer` from the prompt.
Closes#100671
ensure_message_agent_tool() returned True on the schema-present branch without
re-adding message_agent to agent.valid_tool_names. A long-lived Bot Chat whose
tool surface was rebuilt (compaction / MCP refresh republishes the allowlist from
the registry snapshot, which never contains the injected tool) then advertised
message_agent while the executor rejected every call, and the model fell back to
hermes -p shellouts that time out. Success now means both halves hold.
Re-applied onto the current any()-shaped gate; semantic change and test are the
contributor's (#96109).
Closes#96105
Both bot-to-bot DM retry gates (tools/bot_mode_dm.py::_run_local_turn and
tui_gateway/methods_bot_relay.py bot_relay.deliver) classified a failed turn from
`(proc.stderr or proc.stdout)`. A failed `hermes … -Q` turn prints the provider
prose on stdout (it is the turn's final_response) and `session_id: …` on stderr
on every run, so the classifier only ever saw the banner: every 429/5xx/context
overflow classified `unknown`, the policy-gated re-run never fired, and the relay
sender always got `reason: unknown`. Classify from both streams in the order the
CLI writes them (tools.bot_failure_reasons.turn_failure_text) at both sites.
Classifying alone double-delivers: the failed attempt's turn-start persist already
wrote the DM as the Bot Chat's unanswered tail row, and the re-run (a fresh
process replaying the same session + payload) appended a second identical copy.
The re-run's child env now carries HERMES_RESUME_UNANSWERED_TURN=1
(tools.bot_relay.retry_turn_env); the quiet one-shot consumes it before the turn
(hermes_cli.quiet_single_query.adopt_unanswered_turn) and re-stages the
identical unanswered tail row as the pending CLI user dict, stamped durable, so
_stage_turn_user_message reuses it and the flush writes no second row. Opt-in
by design: an identical tail alone cannot tell a re-run from a person's
deliberate re-send.
Slimmer redo of #105529 by @jonpol01 (same diagnosis and shape; the marker is
consumed at the CLI seam that already reads the dispatcher's env instead of
inside agent/turn_context.py).
Co-authored-by: John Paul Soliva <soliva.johnpaul@icloud.com>
terminal_tool's approval gate answers `status: pending_approval` with an
EMPTY `error` (#28323) and no `session_id`, so _spawn_delivery's specific
branch (`if parsed.get("error")`) was skipped and every unanswered
approval fell through to "Delivery to X failed to start: no process id
returned" — blaming the spawn for an approval nobody in a non-interactive
turn (api_server, `hermes peer dm`, cron) could grant.
- _spawn_delivery: the pending shape gets its own message (the runner
command needs terminal approval nobody in this turn can grant); a
local/peer DM adds "nothing was sent — approve it or add it to
command_allowlist and send again". Ownership is never transferred, so
the existing finally still reclaims the plaintext DM file.
- _try_relay_delivery: the envelope is queued on disk BEFORE the reply
waiter spawns and the Desktop drains it independently, so ANY waiter
spawn failure is a lost wake-up, not a failed delivery; reporting it as
an error made the sender resend and deliver the message twice. The
relay path now returns the shape _start_delivery's live-owner branch
already uses (status queued + notification_error + "Do NOT resend")
instead of inventing a new status value nothing reads.
Slimmer redo of #92971 by @jonpol01 (same diagnosis, same relay/local
split on `dm_file is None`); the source-text contract test and the
`sent_no_reply_wake` status were dropped.
Fixes#111716
Co-authored-by: John Paul Soliva <soliva.johnpaul@icloud.com>
The live branch of _run_delivery returns from _wait_live_dm before the
try/finally that removes <dm>.txt, so a settled delivery dropped its
.live.json intent but left the sibling .txt — the same plaintext — on disk.
_wait_live_dm now takes the dm file and removes both on settled; pending
and failed outcomes still keep both for the retry.
Review finding: settled live path unlinked <dm>.live.json but the sibling <dm>.txt survived.
A live DM's intent file (<dm file>.live.json — owner, delivery id and the
message plaintext) has to outlive its runner so a retry replays the same
delivery id instead of minting a second one. Nothing ever removed it: the
runner unlinks only the dm file, and cleanup_bot_dm_cache sweeps only *.txt,
so every live DM left its plaintext in the DM cache directory indefinitely.
Now the sweep reaps intents past the stale cutoff, and a delivery the owner
settled drops its intent immediately — nothing retries a settled delivery.
Follow-up to the salvaged #110786 commit:
- `_is_bot_mode_session` mirrors the system-prompt gate (`agent._session_title_hint`
first, then the live DB title) instead of reading `pending_title`/`title` off the
session dict: `pending_title` is cleared after turn 1 and the record never carries
`title`, so the contributor's gate matched only the very first Bot Chat turn.
- `tools/bot_mode_dm.py::_run_local_turn` (the `hermes -p X chat -c "Bot Chat" -Q`
transport behind `message_agent` when no live owner holds the target) re-emits ""
for a successful bare marker — the third delivery path of the same class.
- Tests trimmed to one invariant per surface (live completion, relay RPC, one-shot
transport), each proven red on origin/main sources.
- Bot Mode docs gain a "Staying silent" line pointing at the shared token list.
Under gateway.multiplex_profiles (and the Desktop/dashboard backend serving named
profiles) os.environ holds the LAUNCH profile's .env. Five spawn sites built a
child's env from it while acting for another profile, so the child saw the
launch profile's HERMES_HOME (bot_relay, key_cmd), its credentials, HERMES_MODEL
and TERMINAL_* policy, and none of the served profile's own .env:
- tui_gateway/server.py _SlashWorker: pinned HERMES_HOME but kept the launch
base with tier-2 credentials + settings.
- tools/bot_relay.py delivery_env (relay RPC + --run-delivery): dict(os.environ).
- tools/browser_tool.py _build_browser_env: re-added BROWSERBASE/FIRECRAWL/
BROWSER_USE keys from os.environ after the scrub.
- plugins/platforms/a2a/adapter.py _forward_to_profile: {**os.environ}.
- agent/command_token_source.py _mint: key_cmd helper inherited os.environ.
tools.environments.local.served_profile_child_env is the one builder: pin the
target home, drop the launch profile's .env residue and bridged TERMINAL_*
(strip_launch_profile_env), and for children that legitimately run with the
profile's credentials (agent worker, token helper) overlay the target profile's
own secrets - what a standalone `hermes -p X` loads itself, never a sibling's.
The browser keeps the provider scrub and re-adds only its passthrough keys via
get_secret. Outside multiplex the env is unchanged.
Live proof from inside the child (launch A, served B, multiplex on): all five
children print HERMES_HOME == B, see B_MARKER=b from B's .env and do not see
A_MARKER; the browser child gets B's FIRECRAWL_API_KEY. On base every one leaked
A_MARKER and lacked B_MARKER; bot_relay and key_cmd also had A's HERMES_HOME.
Bot-to-bot message_agent delivery builds both transport argvs (local
teammate chat and peer dm) with a bare "hermes" as argv[0]. Since #96631
the delivery runner spawns under terminal_tool's isolated host-local
environment, which does not inherit the gateway's PATH — so on
docker/service installs (venv at /opt/hermes/.venv) every delivery exits
with FileNotFoundError: 'hermes'.
Resolve the CLI with bot_relay._hermes_cli() (#93590) — the venv sibling
of this interpreter, then shutil.which, then the bare name — at both
argv construction sites. The turn-lock matcher in _delivery_lock()
already matches argv[0] by basename, so absolute paths lock exactly as
before.
Fixes#100662
worktree_gc archived untracked files under ~/.hermes regardless of the active
profile or HERMES_HOME (and the Windows LOCALAPPDATA default); dashboard_procs'
remote-lock dir, both bot_mode `_default_home` copies, methods_bot_relay's
`_relay_root` (a hand copy of get_default_hermes_root's profiles/ strip) and
load_hermes_dotenv's default home all re-derived env-or-`~/.hermes` by hand and
so diverged from the platform default. Each now calls the canonical getter
with the intent it already had: get_hermes_home() where the active profile
matters (archive), get_process_hermes_home() where the process asset must stay
visible under a routed-profile override (locks, bot mode, startup .env),
get_default_hermes_root() for install-wide relay state.
Each copy re-implemented temp+replace by hand and lacked one or more of
fsync, symlink preservation, atomic_replace's Windows-contention retry and
EXDEV/bind-mount fallback, mode preservation, or interrupt-safe temp
cleanup. Three (gateway/session_persistence, cron/suggestions,
agent/shell_hooks) were verbatim inlines of utils._atomic_write; two
modules defined their own directory-fsync helper, now utils.fsync_directory.
plugins/google_meet/_jsonfile.write_json_atomic is deleted (callers use the
canonical helper directly).
Behavior change: every one of these writers now fsyncs the payload, keeps a
pre-existing target's mode, cleans its temp file on BaseException, and
survives Windows AV/indexer contention and cross-device renames the way
config writes already did. cron/suggestions.json is 0600 from creation
(previously chmod'ed after the replace). Skipped on purpose: cron/jobs.py
two-phase staging, gateway/status._write_json_excl (create-only lock),
kanban_transfer staging (not atomic writers); tools/skill_usage.
_write_suppressed_names lives inside a PLUGIN-COMPAT block.
message_agent sent a bare bot:<profile> id through hermes peer dm, so a remote coder and the recipient's own coder shared one author id. The peer branch now sends bot:<hostname>/<profile>, with the hostname cleaned like any author field and slashes dropped. The direct local path keeps the bare id.
When the target Bot Chat is already open on this gateway, the relay handler delivers through
`prompt.submit` with `queued: true`, and that branch dropped the envelope's sender. The model
still saw the text prefix, but the turn reached the agent unattributed, the exact case the
subprocess branch fixes.
The relay handler now stamps the author on the submit as a `DeliveryAuthor`, an in-process object
a JSON client cannot build, so `prompt.submit` accepts it the way it accepts a hosted-room callback
and refuses a dict with error 4124. The busy queue keeps an authored envelope in its own slot, the
drain hands the author to the turn runner, and the runner passes it to an agent that declares the
keyword. A plain prompt after an authored dm carries no author.
Local deliveries to a desktop-owned Bot Chat take the live-owner mailbox instead. The admission
intent and the mailbox record now carry the author, a retry under the same id with a different
author is refused, and the owner gateway hands the author to the turn it runs. Isolated compute
turns still run unattributed, because the compute-host frame has no author field.
a bot dm arrived as an ordinary user message. the only trace of the sender
was the "Message from" text prefix, which the model reads and nothing else
does. the recipient's memory provider saw its own configured user.
message_agent now passes the sender as {"id": "bot:<profile>", "name":
<handle>, "is_bot": true} to the delivery runner (--author <json>), which sets
HERMES_TURN_AUTHOR on the recipient one-shot only. the -Q turn reads it and
passes turn_author into run_conversation. the desktop relay forwards the
envelope's from_profile/from_handle to bot_relay.deliver, which sets the same
variable on its delivery turn. the runner drops any inherited author first so
a delivery without one stays unattributed. the text prefix is unchanged.
Merge upstream b1f003e186 while preserving PM runtime ownership and
Python 3.14 worker startup, Windows signing, and macOS wait recovery.
Keep retired runtime modules deleted. Port upstream updater preflight
checks into the checkout strategy and preserve live build logging.
Carry checkpoint filename handling and process recovery into the current
module layout. Regenerate locks and adapt incoming platform test markers.
Focused Python and JavaScript tests, desktop and root-test typechecks,
conflict-path lint checks, lock validation, and retired-import checks pass.
The full test suite and packaged release builds were not run.
Route local producers to durable owner ingress before attempting the unowned
CLI lane. Preserve per-run/per-message IDs and receipt-first retry handling;
never fall back after ambiguous admission. Report cron admission as queued,
not completed or failed, in job status, the execution ledger and CLI/tool UX.
Native isolated Electron validation reproduces SESSION_NOT_OWNED on main for
both idle and busy owners. Fixed owner consumes idle cron, busy cron, local
DM and mounted-chat cron exactly once, keeps its lease, yields to queued
human input, and preserves the prior model-request prefix and tool schema.
Inference alone used a deterministic loopback wire stub; no paid model call.
Emit the one-shot reason marker outside the CLI facade; parse whole codes before falling back to legacy prose. Explicit coordination and unknown codes cannot be labeled target_busy.
Fixes#104784
Co-authored-by: William Echo <2054936695@qq.com>
Merge d86627a7f3 into pm-clean. Keep the shared bounded ripgrep transport and preserve translate_path=False for probe patterns. Targeted canonical search tests: 19 passed, 3 POSIX-only skips on Windows. Full integrated CI has not been run for this merge.
The salvaged fix ran the injector against a copy.copy(agent) whose
tools/valid_tool_names pointed at the staged pair, wrapped in a blanket
try/except. A shallow copy of a live AIAgent (locks, DB handle, in-flight
attribute writes from the late-binding thread) is a workaround for the gate
mutating in place, and the except turned any failure into a silently
published snapshot WITHOUT message_agent.
Extract the gate as tools.bot_mode_dm.message_agent_authorized(agent) (the
same predicate ensure_message_agent_tool already used), and have the
snapshot builder append message_agent_tool_schema() to the staged list
directly when it passes. No copy, no swallow; same tests, same live
behaviour (compaction / between-turns / resume keep the tool; ordinary
sessions scrubbed).
For each issue anchor present in BASE 63279301bc non-test .py and absent on HEAD, the BASE comment/docstring block was re-attached at the HEAD location of the code it explained (matched by the distinctive code line / enclosing def). Sentences already covered by an existing HEAD comment were deduped; the issue number always survives. Insert-only: no code lines changed.