Compare commits

..

30 Commits

Author SHA1 Message Date
alt-glitch
1cb65a5d8f fix(billing): reactive charge gating — drop card preflight, react to 403 (scope→reauth, no-card→portal) 2026-06-25 19:07:07 +05:30
alt-glitch
6de4899099 refactor(billing): apply safe simplify-pass fixes
Three low-risk cleanups from a parallel simplify review (reuse/quality/efficiency):
- dev fixture portal URL: reuse the prod host (was drifted to staging-* — a real
  mismatch vs subscription_view's _DEV_FIXTURE_PORTAL)
- TUI billingOverlay choose(): collapse two byte-identical branches (needsCard +
  the not-full else both = portal-or-close at index 0) into one tail; the only
  divergent path (full && !needsCard → buy/auto/limit) stays explicit
- /topup overview comment: correct the stale 'buy_flow detects no_payment_method'
  note (the overview's no-card gate fires first, so reaching Add funds implies a
  card on file)

Skipped (judgment): the orphaned CreditsView.depleted field (harmless, on a live
dataclass), the defensive card gates in _billing_buy_flow/_confirm_and_charge
(cheap correct defense on the money path), and folding the no-card handoff into a
shared helper (touches 4 money-path sites for tidiness — not worth the risk here).
2026-06-25 17:29:29 +05:30
alt-glitch
13a435529d fix(billing): card-on-file heads-up, no-card portal gate, /usage bar ordering, modal glyph
In-terminal charge (POST /charge against the org's server-held card, no card ref
leaves the client):
- card present: confirm screen shows 'Your card saved on the portal will be
  charged' + a 'Manage on portal' escape option (CLI); heads-up line (TUI)
- no card on file: /topup overview + buy flow detect it and route to the portal
  to add a card, instead of offering a charge that 403s no_payment_method

/usage bar ordering: route the dollar block through _cprint consistently. The
Plan: line (_cprint) and the bar (raw print) flushed to different buffers under
patch_stdout and interleaved nondeterministically; now Plan: -> bar -> status/CTA
is stable across all states.

Modal glyph: strip the leading emoji from bordered _prompt_text_input_modal
titles — it measures 1 char but renders 2 columns, shifting the box's right
border (the stray '|'). Includes the f-string 'Pay $X?' title.

Small /credits -> /topup string bits in cli.py ride along with the surrounding
charge edits (the fold lives in the sibling refactor commit).
2026-06-25 17:13:25 +05:30
alt-glitch
beb2c5fb3b refactor(billing): fold /credits into /topup
/credits is redundant now that /topup shows the dollar balance + portal handoff.
Make 'credits' (and 'billing') aliases of /topup so typing /credits still works,
resolving to topup everywhere (CLI, gateway, Slack, TUI, autocomplete, help).

Remove the standalone /credits surface across 6 places:
- CLI _show_credits handler + dispatch
- gateway _handle_credits_command -> renamed _handle_topup_command, copy softened
  to 'Manage billing on the portal' (the messaging billing surface; /topup is now
  gateway-available so messaging keeps billing — credits was the only one before)
- TUI commands/credits.ts + creditsCommand.test.ts (deleted), registry entry
- tui_gateway credits.view RPC + the CreditsViewResponse type
- Slack _SLACK_VIA_HERMES_ONLY: credits -> topup

Sweep user-facing /credits -> /topup (usage-block hint, depletion notice) and
stale doc-comments. OpenRouter's /credits endpoint URL left untouched. Tests
updated (test_credits_folds_into_topup) or pruned for the removed symbols.
2026-06-25 17:13:16 +05:30
alt-glitch
9eee09fede feat(billing/dev): add HERMES_DEV_BILLING_FIXTURE for offline card/scope testing
build_billing_state short-circuits to a fixture when HERMES_DEV_BILLING_FIXTURE
is set (mirrors HERMES_DEV_CREDITS_FIXTURE for the usage model). States:
nocard | card | card-autoreload | notadmin | billing-off | logged-out — so the
card-on-file gate, admin role, and kill-switch paths are exercisable offline
without a live portal. Env-var gated; returns None when unset (no prod leak).

Adds 8 behavior tests asserting the card/admin/billing-on contract per state.
2026-06-25 17:13:08 +05:30
alt-glitch
35de78c1fa fix(billing): guard non-JSON 2xx responses in the billing HTTP client
A 2xx response with a non-JSON body — e.g. a reverse-proxy / SPA fallback HTML
page served when a billing route isn't actually mounted on a deployment — hit
json.loads() on the success path of _request() and raised a raw
json.JSONDecodeError. That escaped the typed-BillingError contract, so callers'
`except BillingError` missed it and fell through to a generic fail-open that
rendered as a misleading "not logged in" (observed when /api/billing/subscription
was briefly unshipped on staging: 200 text/html, x-matched-path /[...notFound]).

Now a non-JSON 2xx body raises a typed BillingError(error="endpoint_unavailable")
so surfaces degrade gracefully ("could not load …") instead of crashing or
mislabeling a valid session as logged-out. The 4xx/5xx path already guarded its
.json(); this closes the same hole on the success path.

Test: tests/hermes_cli/test_nous_billing_request.py — non-JSON 2xx → typed
error (not JSONDecodeError, not BillingAuthError), empty body → {}, valid JSON
parses.
2026-06-25 12:30:45 +05:30
alt-glitch
70b9a72687 feat(cli/topup): mirror overview reorder + in-flight reauth resume
CLI parity with the TUI /topup rehaul, from the same shared usage model.

- _billing_overview: balance in the title, the two-bar dollar usage (plan name
  on the plan bar, top-up "never expires") in place of the old cap spend bar,
  "Add funds" first, dollars throughout — no "credits", no scope preflight.
- _billing_handle_scope_required: now takes the held amount + idempotency key
  and runs the in-flight flow — "Enable terminal billing" → browser device-flow
  → re-check the org kill-switch → press-Enter to resume → replay the held
  charge (reusing the key so a double-submit collapses to one). Stops leaking
  the raw billing:manage scope.
- Charge-error + buy/auto-reload copy de-crufted to terminal-billing/dollars.
- Tests updated to the new overview + buy copy.
2026-06-24 19:51:29 +05:30
alt-glitch
a15e7b7789 feat(tui/topup): reorder overview + in-flight reauth with press-Enter resume
Reworks the /topup overlay per the Jun 19 review and the no-preflight decision.

Overview:
- Balance leads in the title ("Top up · balance $X"); the shared two-bar dollar
  usage (plan + top-up) renders below. Dropped the old monthly-cap spend bar.
- "Add funds" is the first action (was "Buy credits"); auto-reload / monthly
  limit / manage-on-portal follow. Dollars only — no "credits" anywhere.
- No "Enable terminal billing" menu item and NO scope preflight: whether the
  terminal can charge is discovered reactively at pay time. (We deliberately do
  not read/refresh the OAuth token to gate UI.)

Step-up (reached only on a charge's insufficient_scope 403):
- New 4-phase flow that keeps the modal mounted: prompt (one-time-setup
  heads-up) → waiting (browser authorize) → granted (explicit "Press Enter to
  resume") → replay the held charge → settle. The press-Enter beat is the
  reassuring "you're back, finish your purchase" moment.
- Renamed user copy "Allow Remote Spending" → "Enable terminal billing"; never
  leaks the raw billing:manage scope (guarded by the render test).
- topup.ts error copy de-crufted to terminal-billing wording, emoji removed.

Tests: step-up prompt copy, the no-raw-scope invariant, and new overview tests
(balance-in-title, Add-funds-first, two-bar usage, no "credits").
2026-06-24 19:51:21 +05:30
alt-glitch
cd0c662273 feat(billing): embed dollar usage model into billing.state for /topup
The /topup overview renders the same two-bar dollar usage (plan + top-up) as
/usage and /subscription. Embed the shared usage model into the billing.state
RPC payload (mirrors subscription.state) so the overlay gets the bars from its
single fetch, and add the `usage` field to BillingStateResponse.
2026-06-24 19:51:12 +05:30
alt-glitch
14295741ec feat(cli): mirror dollar usage bars on /usage + /subscription
CLI parity with the TUI billing rework, from the same shared usage model.

- _print_nous_credits_block (/usage) and _subscription_overview render the
  two-bar dollar view (plan name on the bar, "$X left of $Y · N% used",
  top-up "never expires", total spendable) instead of the credits-worded block.
- Dollars only — dropped the tier catalog (no more "$N/mo (… credits)") and
  every user-facing "credits"; team copy says "shared balance".
- Human renewal date via the shared format_renews; status line dedupes the
  "$X left"; free upsell + <$5 low alert with ASCII markers.
- /subscription manage modal no longer dumps the raw manage-subscription URL
  in its detail — the [1] Open / [2] Copy link / [3] Cancel options carry it.
  Title is "Manage your subscription" (no in-terminal plan change). The raw URL
  stays only in the non-interactive / not-admin fallbacks, which have no menu.
- /usage token-usage panel (model, tokens, cost, context) left untouched.
2026-06-24 17:56:59 +05:30
alt-glitch
46176e7cb1 feat(tui): dollar usage bars on /usage + /subscription, drop tier picker
Render the shared two-bar dollar model in both overlays; strip "credits" and
the in-terminal tier selection per UX feedback.

- overlayPrimitives.tsx: UsageBars (themed plan/top-up bars — gold allowance,
  green top-up) + usageBarsText for the /usage panel. Plan name labels the
  bar; "$X left of $Y · N% used" (disambiguated so the % matches); top-up
  "never expires".
- subscriptionOverlay.tsx: status line dedupes ($X left once; bar carries the
  breakdown), human renewal date, state-matched nudges (free upsell / <$5
  low alert) with box-safe ASCII markers (! / >) instead of the width-unstable
  emoji that broke the border. Tier picker removed — overview shows usage +
  plan, then "Manage on portal" / "Close" (free users get "Start a
  subscription"). No "credits" anywhere.
- session.ts: /usage renders the dollar bars + balance summary, falling back
  to the legacy credits lines only when the model is unavailable; CTA reworded.
- gatewayTypes.ts: UsageModelData/UsageBarData wire types + usage on
  SessionUsageResponse/SubscriptionStateResponse.
- Tests updated to the new contract (no "credits", "left of", dedup, markers).
2026-06-24 17:56:53 +05:30
alt-glitch
1a15c4c34c feat(billing): shared dollar usage model + two-bar view (drop "credits")
Single source of truth for the /usage and /subscription usage bars across
TUI + CLI. Reads the NAS account-info dollar fields (subscription/top-up/total
remaining, monthly allowance, renewal) and produces a surface-agnostic model:
two full-resolution bars (plan allowance + purchased top-up), a status
classification (free | healthy | low | depleted), and a human renewal date.

- agent/billing_usage.py: UsageModel/UsageBar, usage_model_from_account
  (fail-open), build_usage_model (HERMES_DEV_CREDITS_FIXTURE-aware),
  format_renews (ISO -> "Jul 24, 2026", Windows-safe), $5 low-balance threshold.
- tui_gateway/server.py: _serialize_usage_model/_serialize_usage_bar, a
  usage.bars RPC, and the model embedded into subscription.state so the overlay
  renders the same bars from its single fetch.
- Dollars only, never "credits"; two separate bars (not a crammed
  three-segment one) for legibility at terminal widths.
- tests/agent/test_billing_usage.py: status classification, bar math
  (clamp/over-cap), NaN/Inf rejection, fail-open invariants.
2026-06-24 17:56:37 +05:30
alt-glitch
a83550b5ab feat(tui/topup): resumable 'Allow Remote Spending' step-up on the charge path
Phase 4: when a charge returns insufficient_scope, the /topup modal no longer
tears down with a 'run /billing again' ConfirmReq. Instead it stays MOUNTED and
switches to a step-up screen:
- charge() is now awaitable, returning a discriminated outcome (submitted |
  needs_remote_spending | error) so the overlay can route without closing.
- StepUpScreen: 'Allow Remote Spending' → await the device-flow grant (browser
  opens via the existing out-of-band billing.step_up.verification event) →
  replay the held charge (pendingCharge.amount) and settle, with no command
  re-run. Never surfaces the raw billing:manage scope.
- armStepUp's fire-and-forget ConfirmReq replaced by requestRemoteSpending();
  the leaky 'billing:manage' / 'Re-authorize' / 'run /billing again' copy is gone.

Tests: charge-outcome routing, step-up grant/deny, and a render test asserting
the step-up copy holds the amount and never leaks billing:manage.
Per handoff 2026-06-24_remote-spending-TUI-contract-handoff.md §2 (Grady #6).
2026-06-24 15:31:50 +05:30
alt-glitch
a75aea8f8a refactor(subscription): remove dead step-up scaffolding from /subscription
/subscription only opens a browser deep-link to manage-subscription — that needs
no billing scope, so it can never hit insufficient_scope. Drop the never-fired
'stepup' screen type, requestRemoteSpending ctx fn, and resumeScreen bookkeeping
(leftovers from a superseded plan). The resumable step-up lives on /topup, where
the charge actually gets gated.
2026-06-24 15:31:44 +05:30
alt-glitch
37154fa36f feat(billing): CF-4 Remote-Spending revoked-terminal UX (NAS PR #481)
Wire the Remote-Spending gate denial contract end to end:
- nous_billing: BillingRemoteSpendingRevoked (403 remote_spending_revoked →
  reconnect) + BillingSessionRevoked (401 session_revoked → re-login), distinct
  from insufficient_scope; capture actor/code/recovery; 503 stays transient.
- gateway _serialize_billing_error threads the new typed kinds + actor/code/
  recovery to the TUI.
- TUI renderBillingError: actor-aware revoke copy, kills the spend overlay
  immediately (no 15-min zombie button), handles session_revoked, the dual-
  emitted cli_billing_disabled/remote_spending_disabled, role_required,
  idempotency_conflict; poll treats a mid-poll revoke as ambiguous (check
  balance before retry), not a failure.
- CLI _billing_render_charge_error: same denial matrix, actor-aware copy.

Tests: gate-contract mapping + envelope (py) and revoke/session/disabled (TUI).
Per handoff 2026-06-24_remote-spending-TUI-contract-handoff.md.
2026-06-24 15:06:16 +05:30
alt-glitch
1a082b780c feat(subscription): CLI /subscription handler, drop dunning, current:null no-plan
- CLI _show_subscription mirrors the TUI overlay (plan read + tier list + usage
  bar + browser deep-link via subscription_manage_url); credits render as counts.
- Adapt to the updated NAS read contract: remove is_past_due/dunning everywhere
  (a card-failing subscriber returns as a normal plan now), and treat no-plan as
  current:null (parser returns None) rather than an all-null object.
- HERMES_DEV_SUBSCRIPTION_FIXTURE env-driven fixtures + ui-tui fixture harness
  drive every state (CLI + live TUI) with no portal.

Verified against handoff 2026-06-24_subscription-tui-handoff.md.
2026-06-24 15:06:10 +05:30
alt-glitch
cb8a19ae3a feat(cli): /subscription + /upgrade, /billing→/topup rename, /usage CTAs
Add the classic-CLI half of the terminal billing surface to match the TUI:
- /subscription (alias /upgrade) command + /topup (renamed /billing, keeps
  'billing' as a back-compat alias) in the command registry.
- Drop the stale 'billing' entry from _SLACK_VIA_HERMES_ONLY (now cli_only).
2026-06-24 15:06:01 +05:30
alt-glitch
e78bf4b7d8 chore(subscription): drop unused format_money import 2026-06-24 08:12:02 +05:30
alt-glitch
a5902cd267 fix(subscription): drop manage-link gateway RPC, build URL locally
The NAS POST /api/billing/subscription/manage-link endpoint was dropped
(it added no server work — the target is the static /manage-subscription
page, not a Stripe-minted secret). Build the URL client-side instead:
{portal_base}/manage-subscription?org_id=<org.id>.

- Remove subscription.manage_link gateway RPC (server.py)
- Remove get_subscription_manage_link helper (subscription_view.py)
- Remove post_subscription_manage_link (nous_billing.py)
- Remove SubscriptionManageLinkResponse type (gatewayTypes.ts)
- Add org_id to SubscriptionState + wire through serializer + TS type
- openManageLink() builds the URL locally via buildManageUrl(), opens
  it with the existing openExternalUrl(), no gateway round-trip
- Drop targetTierId param from openManageLink (v1 sends everyone to
  /manage-subscription; no tier deep-link needed)
- Fix stale test expectations (Stripe copy → subscription page copy)
2026-06-24 08:03:45 +05:30
alt-glitch
3831e78d37 feat(tui/subscription): team-context screen — redirect to /topup for team orgs
Parse the NAS context:'personal'|'team' field (defaults to 'personal' for
unknown/missing values), emit it on the gateway wire, add it to
SubscriptionStateResponse. When context is 'team', SubscriptionOverlay
renders a dedicated read-only screen instead of the tier picker:

  'This terminal is connected to {org_name}. Teams run on shared
   credits — use /topup to add funds. Personal subscriptions live
   on your personal account.'

The screen closes on Enter or Esc. The personal/tier-picker path is
unchanged.
2026-06-24 07:35:27 +05:30
alt-glitch
2958145f11 feat(tui/subscription): render cancellation-scheduled note with headline precedence
Parse cancelAtPeriodEnd + cancellationEffectiveAt from the NAS contract
(camelCase) in the agent parser (_parse_current), emit cancel_at_period_end
+ cancellation_effective_at from the gateway serializer, extend the
SubscriptionStateResponse type, and render a warn note in OverviewScreen:
'Cancels on {date} — your plan stays active until then.'

Headline precedence when multiple flags co-occur:
  past-due > cancel-scheduled > downgrade-pending > active
The downgradeNote guard is tightened to suppress when cancel is scheduled,
so at most one status line renders at a time.
2026-06-24 07:34:08 +05:30
alt-glitch
e4c46be204 fix(tui/subscription): stop saying Stripe in deep-link copy + fix manage link kind type
Replace all user-facing 'Stripe' mentions in the /subscription overlay and
sys messages with 'your subscription page' — the deep-link target is NAS's
own /manage-subscription page, not the Stripe hosted portal. Stripe only
legitimately appears later at actual Checkout. Also add 'manage' to the
SubscriptionManageLinkResponse.kind union (NAS emits kind:'manage'; was
previously missing from the TypeScript type causing silent narrowing errors).
2026-06-24 07:33:07 +05:30
alt-glitch
e854528a85 feat(tui): add /subscription command + overlay wiring
- subscription.ts: SubscriptionOverlayCtx closure (openManageLink,
  refreshState, requestRemoteSpending) + run handler that fetches
  subscription.state and opens the overlay. Alias /upgrade.
- registry.ts: spread subscriptionCommands into SLASH_COMMANDS.
- appOverlays.tsx: render SubscriptionOverlay when overlay.subscription set.
- useInputHandlers.ts: Esc closes subscription overlay; promptOverlay OR
  includes subscription so input is intercepted while open.
- subscriptionCommand.test.ts: 4 tests (fetch+open, logged-out sys line,
  /upgrade alias, /subscription resolves).
2026-06-23 20:45:31 +05:30
alt-glitch
66d22cac97 feat(tui): build SubscriptionOverlay — overview + confirm + handoff
Pure-render Ink component mirroring billingOverlay.tsx's structure.
Overview screen covers all 5 states (free-upgradeable, mid-tier,
top-tier, not-admin, downgrade-pending) + dunning. Confirm screen is
y/n deep-link to Stripe (NO in-terminal charge). Handoff is the
transient 'Opening Stripe' screen. Imports shared primitives from
overlayPrimitives.tsx. 8 render tests via renderSync covering every
state.
2026-06-23 20:39:50 +05:30
alt-glitch
1cecb2fbb9 feat(tui): add subscription overlay state types + store slot
Add SubscriptionScreen, SubscriptionOverlayCtx, SubscriptionOverlayState
to interfaces.ts and a 'subscription' slot to OverlayState. Wire it into
overlayStore.ts (buildOverlayState + $isBlocked). NOT added to
resetFlowOverlays preserve list — flow-scoped like billing, drops on
turn end.
2026-06-23 20:27:54 +05:30
alt-glitch
ac8a790b67 feat(gateway): add subscription.state + subscription.manage_link RPCs
- agent/subscription_view.py: SubscriptionState dataclass + fail-open
  build_subscription_state() (mirrors billing_view pattern) +
  get_subscription_manage_link() for the Stripe deep-link.
- hermes_cli/nous_billing.py: get_subscription_state() +
  post_subscription_manage_link() HTTP helpers for the two NAS endpoints
  (WS1 Phase A/C). The manage-link endpoint raises BillingScopeRequired
  when Remote-Spending is missing (Phase 4 step-up trigger).
- tui_gateway/server.py: _serialize_subscription_state() +
  subscription.state RPC (fail-open) + subscription.manage_link RPC
  (returns {ok,kind,url} or typed error envelope via
  _serialize_billing_error). NOT added to _LONG_HANDLERS — synchronous
  HTTP round-trip, not a device flow.
2026-06-23 20:25:58 +05:30
alt-glitch
df4350c5ad feat(tui): add subscription wire types
Add SubscriptionTierOption, SubscriptionStateResponse, and
SubscriptionManageLinkResponse to gatewayTypes.ts. Type-only — no
usages yet. Mirrors the BillingStateResponse conventions (snake_case,
Decimals as strings) and reuses BillingErrorPayload for error mapping.
2026-06-23 20:19:47 +05:30
alt-glitch
75e4bfd183 feat(tui): add /subscription + /topup CTAs to /usage output
Every /usage render now ends with 'Run /subscription to change plan
· /topup to add credits' — both the healthy (with-calls) and depleted
(no-calls) paths. Strings-only change, no WS1 dependency.
2026-06-23 20:18:35 +05:30
alt-glitch
aab4ba454f refactor(tui): extract overlay primitives to shared module
Lift MenuRow, ActionRow, footer, and barCells() out of billingOverlay.tsx
into overlayPrimitives.tsx so the upcoming subscriptionOverlay.tsx can
import them instead of duplicating. spendBar now calls barCells() —
output is byte-identical. Pure behavior-preserving refactor.
2026-06-23 20:15:39 +05:30
alt-glitch
30a1254c36 feat(tui): rename /billing slash command to /topup
Behavior-preserving rename of the /billing command surface to /topup.
Changes: billing.ts → topup.ts (export topupCommands, name 'topup', new
help string), registry.ts import+spread updated, billingOverlay.tsx
overview header 'Usage credits' → 'Top up credits', billingCommand.test.ts
→ topupCommand.test.ts with import/lookup/call updated. RPC method names
(billing.state, billing.charge, etc.) and component/symbol names unchanged.
2026-06-23 19:45:27 +05:30
669 changed files with 11699 additions and 64680 deletions

View File

@@ -1,62 +0,0 @@
name: Detect affected areas
description: >-
Classify a PR's changed files into CI work lanes (python, frontend, site,
scan, deps, mcp_catalog) so the orchestrator can conditionally call only
the sub-workflows a PR can affect. Outputs are always "true" on push/dispatch
events and fail open (everything "true") when the diff cannot be computed.
outputs:
python:
description: Run Python tests / ruff / ty / windows-footguns.
value: ${{ steps.classify.outputs.python }}
frontend:
description: Run the TypeScript typecheck matrix + desktop build.
value: ${{ steps.classify.outputs.frontend }}
docker_meta:
description: Docker setup and meta files have changed.
value: ${{ steps.classify.outputs.docker_meta }}
site:
description: Build the Docusaurus docs site.
value: ${{ steps.classify.outputs.site }}
scan:
description: Run the supply-chain critical-pattern scanner.
value: ${{ steps.classify.outputs.scan }}
deps:
description: Check pyproject.toml dependency upper bounds.
value: ${{ steps.classify.outputs.deps }}
mcp_catalog:
description: Require MCP catalog security review label.
value: ${{ steps.classify.outputs.mcp_catalog }}
runs:
using: composite
steps:
- name: Classify changed files
id: classify
shell: bash
env:
GH_TOKEN: ${{ github.token }}
REPO: ${{ github.repository }}
EVENT_NAME: ${{ github.event_name }}
BASE_SHA: ${{ github.event.pull_request.base.sha }}
HEAD_SHA: ${{ github.event.pull_request.head.sha }}
run: |
set -euo pipefail
# Only pull_request events are gated. Other events (push, release,
# dispatch) leave CHANGED empty, so the classifier fails open and every
# lane runs. Post-merge / on-demand validation is never weakened.
if [ "$EVENT_NAME" = "pull_request" ]; then
# Use the compare endpoint with the pinned base/head SHAs from the
# event payload instead of the "current PR files" endpoint. The SHAs
# are frozen at trigger time, so the file list is deterministic even
# if the PR receives a new push between trigger and detect.
CHANGED="$(gh api \
--paginate \
"repos/${REPO}/compare/${BASE_SHA}...${HEAD_SHA}" \
--jq '.files[].filename' || true)"
fi
echo "Changed files:"
printf '%s\n' "${CHANGED:-(none)}"
printf '%s\n' "${CHANGED:-}" | python3 scripts/ci/classify_changes.py

View File

@@ -1,50 +0,0 @@
name: Retry a flaky command
description: >-
Run a shell command, retrying on non-zero exit. For dependency installs
(npm ci, uv sync) whose only failures are transient network/toolchain
flakes — a node-gyp header fetch, a registry blip — so CI self-heals
instead of needing a manual re-run.
inputs:
command:
description: Shell command to run (and retry).
required: true
attempts:
description: Max attempts before giving up.
default: "3"
delay:
description: Seconds to wait between attempts.
default: "10"
working-directory:
description: Directory to run in.
default: "."
runs:
using: composite
steps:
- shell: bash
working-directory: ${{ inputs.working-directory }}
# command goes through env, never interpolated into the script body, so
# a command with quotes/specials can't break or inject into the runner.
env:
_CMD: ${{ inputs.command }}
_ATTEMPTS: ${{ inputs.attempts }}
_DELAY: ${{ inputs.delay }}
run: |
set -uo pipefail
n=0
while :; do
n=$((n + 1))
echo "::group::attempt $n/$_ATTEMPTS: $_CMD"
if bash -c "$_CMD"; then
echo "::endgroup::"
exit 0
fi
echo "::endgroup::"
if [ "$n" -ge "$_ATTEMPTS" ]; then
echo "::error::failed after $n attempts: $_CMD"
exit 1
fi
echo "::warning::attempt $n failed; retrying in ${_DELAY}s: $_CMD"
sleep "$_DELAY"
done

View File

@@ -0,0 +1,100 @@
name: Build Windows Installer
on:
workflow_dispatch:
permissions:
contents: read
jobs:
# Gate: workflow_dispatch is already restricted to users with write access,
# but we want ADMIN-only. Explicitly check the triggering actor's repo
# permission via the API and fail fast for anyone below admin.
authorize:
name: Authorize (admins only)
runs-on: ubuntu-latest
timeout-minutes: 5
steps:
- name: Check actor is a repo admin
env:
GH_TOKEN: ${{ github.token }}
ACTOR: ${{ github.actor }}
run: |
set -euo pipefail
perm=$(gh api \
"repos/${{ github.repository }}/collaborators/${ACTOR}/permission" \
--jq '.permission')
echo "Actor '${ACTOR}' has permission: ${perm}"
if [ "${perm}" != "admin" ]; then
echo "::error::'${ACTOR}' is not a repo admin (permission=${perm}). Refusing to build/sign."
exit 1
fi
echo "Authorized: '${ACTOR}' is an admin."
build:
name: Hermes-Setup.exe
needs: authorize
runs-on: windows-latest
timeout-minutes: 30
permissions:
contents: read
# Required for OIDC auth to Azure (azure/login federated credentials).
id-token: write
steps:
- name: Checkout code
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- name: Setup Node.js
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4
with:
node-version: 22
cache: npm
- name: Install npm dependencies
run: npm ci
- name: Setup Rust
uses: dtolnay/rust-toolchain@29eef336d9b2848a0b548edc03f92a220660cdb8 # stable
- name: Cache Rust targets
uses: Swatinem/rust-cache@e18b497796c12c097a38f9edb9d0641fb99eee32 # v2
with:
workspaces: apps/bootstrap-installer/src-tauri
- name: Build installer
run: npm run tauri:build
working-directory: apps/bootstrap-installer
- name: Azure login (OIDC)
uses: azure/login@a457da9ea143d694b1b9c7c869ebb04ebe844ef5 # v2
with:
client-id: ${{ secrets.AZURE_CLIENT_ID }}
tenant-id: ${{ secrets.AZURE_TENANT_ID }}
subscription-id: ${{ secrets.AZURE_SUBSCRIPTION_ID }}
- name: Sign Hermes-Setup.exe with Azure Artifact Signing
uses: azure/artifact-signing-action@c7ab2a863ab5f9a846ddb8265964877ef296ee82 # v2
with:
endpoint: ${{ vars.AZURE_SIGNING_ENDPOINT }}
signing-account-name: ${{ vars.AZURE_SIGNING_ACCOUNT_NAME }}
certificate-profile-name: ${{ vars.AZURE_SIGNING_CERTIFICATE_PROFILE }}
# Sign both the raw exe and the bundled NSIS installer.
files-folder: ${{ github.workspace }}\apps\bootstrap-installer\src-tauri\target\release
files-folder-filter: exe
files-folder-recurse: true
file-digest: SHA256
timestamp-rfc3161: http://timestamp.acs.microsoft.com
timestamp-digest: SHA256
- name: Upload NSIS installer
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: Hermes-Setup-installer
path: apps/bootstrap-installer/src-tauri/target/release/bundle/nsis/*.exe
- name: Upload raw exe
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: Hermes-Setup-exe
path: apps/bootstrap-installer/src-tauri/target/release/Hermes-Setup.exe

View File

@@ -1,145 +0,0 @@
name: CI
# Orchestrator workflow. Runs ``detect-changes`` once, then conditionally
# calls the sub-workflows that a PR can actually affect. A final
# ``all-checks-pass`` gate job aggregates results so branch protection only
# needs to require a single check.
#
# Sub-workflows are triggered via ``workflow_call`` and keep their own job
# definitions, matrices, and concurrency settings. They no longer have
# ``push:`` / ``pull_request:`` triggers of their own — everything flows
# through this file.
on:
pull_request:
push:
branches: [main]
permissions:
contents: read
pull-requests: write # needed by lint (PR comment) + supply-chain (PR comment)
actions: read # needed by osv-scanner (SARIF upload)
security-events: write # needed by osv-scanner (SARIF upload)
concurrency:
group: ci-${{ github.ref }}
cancel-in-progress: ${{ github.event_name == 'pull_request' }}
jobs:
# ─────────────────────────────────────────────────────────────────────
# detect: run the classifier once. Every downstream job reads its outputs
# to decide whether to run. On push/dispatch the classifier fails open
# (all lanes true) so post-merge validation is never weakened.
# ─────────────────────────────────────────────────────────────────────
detect:
runs-on: ubuntu-latest
outputs:
python: ${{ steps.classify.outputs.python }}
frontend: ${{ steps.classify.outputs.frontend }}
site: ${{ steps.classify.outputs.site }}
scan: ${{ steps.classify.outputs.scan }}
deps: ${{ steps.classify.outputs.deps }}
docker_meta: ${{ steps.classify.outputs.docker_meta }}
mcp_catalog: ${{ steps.classify.outputs.mcp_catalog }}
event_name: ${{ github.event_name }}
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- name: Detect affected areas
id: classify
uses: ./.github/actions/detect-changes
# ─────────────────────────────────────────────────────────────────────
# Lane-gated sub-workflows. Each runs in parallel after detect finishes.
# Skipped workflows (if condition is false) don't spin up runners.
# ─────────────────────────────────────────────────────────────────────
tests:
needs: detect
if: needs.detect.outputs.python == 'true'
uses: ./.github/workflows/tests.yml
lint:
needs: detect
if: needs.detect.outputs.python == 'true'
uses: ./.github/workflows/lint.yml
with:
event_name: ${{ needs.detect.outputs.event_name }}
typecheck:
needs: detect
if: needs.detect.outputs.frontend == 'true'
uses: ./.github/workflows/typecheck.yml
docs-site:
needs: detect
if: needs.detect.outputs.site == 'true'
uses: ./.github/workflows/docs-site-checks.yml
history-check:
needs: detect
if: needs.detect.outputs.event_name == 'pull_request'
uses: ./.github/workflows/history-check.yml
contributor-check:
needs: detect
if: needs.detect.outputs.python == 'true'
uses: ./.github/workflows/contributor-check.yml
uv-lockfile:
needs: detect
uses: ./.github/workflows/uv-lockfile-check.yml
docker-lint:
needs: detect
if: needs.detect.outputs.docker_meta == 'true'
uses: ./.github/workflows/docker-lint.yml
supply-chain:
needs: detect
if: needs.detect.outputs.event_name == 'pull_request' && (needs.detect.outputs.scan == 'true' || needs.detect.outputs.deps == 'true' || needs.detect.outputs.mcp_catalog == 'true')
uses: ./.github/workflows/supply-chain-audit.yml
with:
event_name: ${{ needs.detect.outputs.event_name }}
scan: ${{ needs.detect.outputs.scan == 'true' }}
deps: ${{ needs.detect.outputs.deps == 'true' }}
mcp_catalog: ${{ needs.detect.outputs.mcp_catalog == 'true' }}
osv-scanner:
needs: detect
uses: ./.github/workflows/osv-scanner.yml
# ─────────────────────────────────────────────────────────────────────
# Gate: runs after everything. ``if: always()`` ensures it reports a
# status even when some deps were skipped. Only actual ``failure``
# results cause it to fail; ``skipped`` is treated as success.
#
# Branch protection should require ONLY this check.
# ─────────────────────────────────────────────────────────────────────
all-checks-pass:
name: All required checks pass
needs:
- tests
- lint
- typecheck
- docs-site
- history-check
- contributor-check
- uv-lockfile
- docker-lint
- supply-chain
- osv-scanner
if: always()
runs-on: ubuntu-latest
steps:
- name: Evaluate job results
env:
RESULTS: ${{ toJSON(needs.*.result) }}
run: |
echo "$RESULTS" | python3 -c "
import json, sys
results = json.load(sys.stdin)
failed = [r for r in results if r == 'failure']
if failed:
print(f'::error::{len(failed)} job(s) failed')
sys.exit(1)
print('All checks passed (or were skipped)')
"

View File

@@ -1,8 +1,11 @@
name: Contributor Attribution Check
on:
workflow_call:
# No paths filter — the job must always run so the required check
# reports a status (path-gated workflows leave checks "pending" forever
# when no matching files change, which blocks merge).
pull_request:
branches: [main]
permissions:
contents: read
@@ -14,7 +17,21 @@ jobs:
with:
fetch-depth: 0 # Full history needed for git log
- name: Check if relevant files changed
id: filter
run: |
BASE="${{ github.event.pull_request.base.sha }}"
HEAD="${{ github.event.pull_request.head.sha }}"
CHANGED=$(git diff --name-only "$BASE"..."$HEAD" -- '*.py' '**/*.py' '.github/workflows/contributor-check.yml' || true)
if [ -n "$CHANGED" ]; then
echo "run=true" >> "$GITHUB_OUTPUT"
else
echo "run=false" >> "$GITHUB_OUTPUT"
echo "No Python files changed, skipping attribution check."
fi
- name: Check for unmapped contributor emails
if: steps.filter.outputs.run == 'true'
run: |
# Get the merge base between this PR and main
MERGE_BASE=$(git merge-base origin/main HEAD)

View File

@@ -11,7 +11,19 @@ name: Docker / shell lint
# activate script doesn't exist at lint time.
on:
workflow_call:
push:
branches: [main]
paths:
- Dockerfile
- docker/**
- .hadolint.yaml
- .github/workflows/docker-lint.yml
# No paths filter — the job must always run so the required check
# reports a status (path-gated workflows leave checks "pending" forever
# when no matching files change, which blocks merge).
pull_request:
branches: [main]
permissions:
contents: read

View File

@@ -16,6 +16,7 @@ on:
# reports a status (path-gated workflows leave checks "pending" forever
# when no matching files change, which blocks merge).
pull_request:
branches: [main]
release:
types: [published]
@@ -55,21 +56,13 @@ jobs:
- name: Checkout code
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
# The image build + smoke test + integration tests run ONLY on
# push-to-main and release — never on PRs. They are the heaviest jobs
# in CI (~15-45 min) and a broken build surfaces on the main push (and
# is gated pre-merge by docker-lint + uv-lockfile-check). Every step
# below is skipped on PRs, so the job still reports green and the
# required check never hangs.
- name: Set up Docker Buildx
if: github.event_name != 'pull_request'
uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3
# Build once, load into the local daemon for smoke testing. Cached
# to gha with a per-arch scope; the push step below reuses every
# layer from this build.
- name: Build image (amd64, smoke test)
if: github.event_name != 'pull_request'
uses: docker/build-push-action@bcafcacb16a39f128d818304e6c9c0c18556b85f # v7.1.0
with:
context: .
@@ -83,7 +76,6 @@ jobs:
cache-to: type=gha,mode=max,scope=docker-amd64
- name: Smoke test image
if: github.event_name != 'pull_request'
uses: ./.github/actions/hermes-smoke-test
with:
image: ${{ env.IMAGE_NAME }}:test
@@ -110,15 +102,12 @@ jobs:
# cheapest path to coverage on every PR that touches docker code.
# ---------------------------------------------------------------------
- name: Install uv (for docker tests)
if: github.event_name != 'pull_request'
uses: astral-sh/setup-uv@d4b2f3b6ecc6e67c4457f6d3e41ec42d3d0fcb86 # v5
- name: Set up Python 3.11 (for docker tests)
if: github.event_name != 'pull_request'
run: uv python install 3.11
- name: Install Python dependencies (for docker tests)
if: github.event_name != 'pull_request'
run: |
uv venv .venv --python 3.11
source .venv/bin/activate
@@ -129,7 +118,6 @@ jobs:
uv pip install -e ".[dev]"
- name: Run docker integration tests
if: github.event_name != 'pull_request'
env:
# Skip rebuild; use the image already loaded by the build step.
HERMES_TEST_IMAGE: ${{ env.IMAGE_NAME }}:test
@@ -202,9 +190,7 @@ jobs:
- name: Checkout code
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
# arm64 build runs only on push-to-main and release (see build-amd64).
- name: Set up Docker Buildx
if: github.event_name != 'pull_request'
uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3
# Log in to ghcr.io so the registry-backed build cache below can be
@@ -215,21 +201,41 @@ jobs:
# crashed the build before the smoke test (the reason the gha cache
# was removed from arm64 PRs in the first place).
- name: Log in to ghcr.io (build cache)
if: github.event_name != 'pull_request'
uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4.1.0
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
# Build once, load into the local daemon for smoke testing, then push
# by digest below. Reads AND writes the registry-backed cache so the
# push reuses layers from this build and the next build starts warm.
# Build once, load into the local daemon for smoke testing.
#
# PR builds use the registry-backed cache READ-ONLY (cache-from only):
# they pull warm layers pushed by the most recent main build but never
# write, so rapid PR pushes don't race on cache writes or pollute the
# cache ref. This restores warm-cache speed to arm64 PR builds (which
# were running fully uncached and were ~45% slower than amd64, making
# them the job most often cancelled on supersede).
#
# Registry cache (type=registry on ghcr.io) is used instead of the gha
# cache that previously broke here: its credential is the job-lifetime
# GITHUB_TOKEN, not a short-lived SAS token, so the cold-build-outlives-
# token failure mode cannot recur.
- name: Build image (arm64, smoke test, cache read-only PR)
if: github.event_name == 'pull_request'
uses: docker/build-push-action@bcafcacb16a39f128d818304e6c9c0c18556b85f # v7.1.0
with:
context: .
file: Dockerfile
load: true
platforms: linux/arm64
tags: ${{ env.IMAGE_NAME }}:test
build-args: |
HERMES_GIT_SHA=${{ github.sha }}
cache-from: type=registry,ref=ghcr.io/nousresearch/hermes-agent:buildcache-arm64
# Main/release builds read AND write the registry cache so the digest
# push below reuses layers from this smoke-test build, and so the next
# PR/main build starts warm.
- name: Build image (arm64, smoke test, cached publish)
if: github.event_name != 'pull_request'
uses: docker/build-push-action@bcafcacb16a39f128d818304e6c9c0c18556b85f # v7.1.0
@@ -245,7 +251,6 @@ jobs:
cache-to: type=registry,ref=ghcr.io/nousresearch/hermes-agent:buildcache-arm64,mode=max
- name: Smoke test image
if: github.event_name != 'pull_request'
uses: ./.github/actions/hermes-smoke-test
with:
image: ${{ env.IMAGE_NAME }}:test

View File

@@ -1,7 +1,13 @@
name: Docs Site Checks
on:
workflow_call:
# No paths filter — the job must always run so the required check
# reports a status (path-gated workflows leave checks "pending" forever
# when no matching files change, which blocks merge).
pull_request:
branches: [main]
workflow_dispatch:
permissions:
contents: read
@@ -19,19 +25,15 @@ jobs:
cache-dependency-path: website/package-lock.json
- name: Install website dependencies
uses: ./.github/actions/retry
with:
command: npm ci
working-directory: website
run: npm ci
working-directory: website
- uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
with:
python-version: "3.11"
- name: Install ascii-guard
uses: ./.github/actions/retry
with:
command: python -m pip install ascii-guard==2.3.0 pyyaml==6.0.3
run: python -m pip install ascii-guard==2.3.0 pyyaml==6.0.3
- name: Extract skill metadata for dashboard
run: python3 website/scripts/extract-skills.py

View File

@@ -14,7 +14,11 @@ name: History Check
# the PR head and main to be non-empty.
on:
workflow_call:
# No paths filter — the job must always run so the required check
# reports a status (path-gated workflows leave checks "pending" forever
# when no matching files change, which blocks merge).
pull_request:
branches: [main]
permissions:
contents: read

View File

@@ -9,12 +9,18 @@ name: Lint (ruff + ty)
# enforcement fails.
on:
workflow_call:
inputs:
event_name:
description: The event name from the calling orchestrator (pull_request or push).
type: string
required: true
push:
branches: [main]
paths-ignore:
- "**/*.md"
- "docs/**"
- "website/**"
# No paths filter — the job must always run so the required check
# reports a status (path-gated workflows leave checks "pending" forever
# when no matching files change, which blocks merge).
pull_request:
branches: [main]
permissions:
contents: read
@@ -27,7 +33,6 @@ concurrency:
jobs:
lint-diff:
name: ruff + ty diff
if: inputs.event_name == 'pull_request'
runs-on: ubuntu-latest
timeout-minutes: 10
steps:
@@ -40,16 +45,16 @@ jobs:
uses: astral-sh/setup-uv@d4b2f3b6ecc6e67c4457f6d3e41ec42d3d0fcb86 # v5
- name: Install ruff + ty
uses: ./.github/actions/retry
with:
command: uv tool install ruff && uv tool install ty
run: |
uv tool install ruff
uv tool install ty
- name: Determine base ref
id: base
run: |
# For PRs, diff against the merge base with the target branch.
# For pushes to main, diff against the previous commit on main.
if [ "${{ inputs.event_name }}" = "pull_request" ]; then
if [ "${{ github.event_name }}" = "pull_request" ]; then
BASE_SHA=$(git merge-base "origin/${{ github.base_ref }}" HEAD)
BASE_REF="origin/${{ github.base_ref }}"
else
@@ -105,7 +110,7 @@ jobs:
--base-ty .lint-reports/base/ty.json \
--head-ty .lint-reports/head/ty.json \
--base-ref "${{ steps.base.outputs.ref }}" \
--head-ref "${{ inputs.event_name == 'pull_request' && github.head_ref || github.ref_name }}" \
--head-ref "${{ github.event_name == 'pull_request' && github.head_ref || github.ref_name }}" \
--output .lint-reports/summary.md
cat .lint-reports/summary.md >> "$GITHUB_STEP_SUMMARY"
@@ -117,7 +122,7 @@ jobs:
retention-days: 14
- name: Post / update PR comment
if: inputs.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name == github.repository
if: github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name == github.repository
continue-on-error: true
uses: actions/github-script@60a0d83039c74a4aee543508d2ffcb1c3799cdea # v7
with:
@@ -167,9 +172,7 @@ jobs:
uses: astral-sh/setup-uv@d4b2f3b6ecc6e67c4457f6d3e41ec42d3d0fcb86 # v5
- name: Install ruff
uses: ./.github/actions/retry
with:
command: uv tool install ruff
run: uv tool install ruff
- name: ruff check .
# No --exit-zero, no || true. Exit code propagates to the job,

View File

@@ -1,8 +1,8 @@
name: OSV-Scanner
# Scans lockfiles (uv.lock, package-lock.json) against the OSV vulnerability
# database. Runs on every PR/push (via the ci.yml orchestrator's workflow_call)
# and on a weekly schedule against main.
# database. Runs on every PR that touches a lockfile and on a weekly schedule
# against main.
#
# This is detection-only — OSV-Scanner does NOT open PRs or modify pins.
# It reports known CVEs in currently-pinned dependency versions so we can
@@ -10,9 +10,9 @@ name: OSV-Scanner
# (full SHA / exact version) is preserved; only the notification signal
# is added.
#
# Complements the supply-chain-audit.yml workflow (which scans for malicious
# code patterns in PR diffs) by covering the orthogonal "currently-pinned
# dep became known-vulnerable" case.
# Complements the existing supply-chain-audit.yml workflow (which scans
# for malicious code patterns in PR diffs) by covering the orthogonal
# "currently-pinned dep became known-vulnerable" case.
#
# Uses Google's officially-recommended reusable workflow, pinned by SHA.
# Findings land in the repo's Security tab (Code Scanning > OSV-Scanner).
@@ -20,7 +20,19 @@ name: OSV-Scanner
# vulnerabilities in pinned deps that we may need to patch deliberately.
on:
workflow_call:
# No paths filter — the job must always run so the required check
# reports a status (path-gated workflows leave checks "pending" forever
# when no matching files change, which blocks merge).
pull_request:
branches: [main]
push:
branches: [main]
paths:
- "uv.lock"
- "pyproject.toml"
- "package.json"
- "package-lock.json"
- "website/package-lock.json"
schedule:
# Weekly scan against main — catches CVEs published after merge for
# deps that haven't changed since.

View File

@@ -1,5 +1,16 @@
name: Supply Chain Audit
on:
# No paths filter — the jobs must always run so required checks
# report a status (path-gated workflows leave checks "pending" forever
# when no matching files change, which blocks merge).
pull_request:
types: [opened, synchronize, reopened]
permissions:
pull-requests: write
contents: read
# Narrow, high-signal scanner. Only fires on critical indicators of supply
# chain attacks (e.g. the litellm-style payloads). Low-signal heuristics
# (plain base64, plain exec/eval, dependency/Dockerfile/workflow edits,
@@ -8,40 +19,56 @@ name: Supply Chain Audit
# the scanner. Keep this file's checks ruthlessly narrow: if you find
# yourself adding WARNING-tier patterns here again, make a separate
# advisory-only workflow instead.
#
# Path-gating is handled centrally by the ``ci.yml`` orchestrator's
# ``detect`` job. The orchestrator passes ``scan`` / ``deps`` /
# ``mcp_catalog`` booleans as inputs; this workflow's jobs gate on those
# inputs instead of re-computing the diff.
on:
workflow_call:
inputs:
event_name:
description: The event name from the calling orchestrator.
type: string
required: true
scan:
description: Whether supply-chain-relevant files changed.
type: boolean
required: true
deps:
description: Whether pyproject.toml changed.
type: boolean
required: true
mcp_catalog:
description: Whether the MCP catalog / installer changed.
type: boolean
required: true
permissions:
pull-requests: write
contents: read
jobs:
# ── Path filter (shared by both scan and dep-bounds) ───────────────
changes:
runs-on: ubuntu-latest
outputs:
# True when any file the scanner cares about changed in this PR
scan: ${{ steps.filter.outputs.scan }}
# True when pyproject.toml changed in this PR
deps: ${{ steps.filter.outputs.deps }}
# True when the curated MCP catalog / bundled MCP manifests changed.
mcp_catalog: ${{ steps.filter.outputs.mcp_catalog }}
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
fetch-depth: 0
- name: Check for relevant file changes
id: filter
run: |
BASE="${{ github.event.pull_request.base.sha }}"
HEAD="${{ github.event.pull_request.head.sha }}"
SCAN_FILES=$(git diff --name-only "$BASE"..."$HEAD" -- \
'*.py' '**/*.py' '*.pth' '**/*.pth' \
'setup.py' 'setup.cfg' \
'sitecustomize.py' 'usercustomize.py' '__init__.pth' \
'pyproject.toml' || true)
if [ -n "$SCAN_FILES" ]; then
echo "scan=true" >> "$GITHUB_OUTPUT"
else
echo "scan=false" >> "$GITHUB_OUTPUT"
fi
DEPS_FILES=$(git diff --name-only "$BASE"..."$HEAD" -- 'pyproject.toml' || true)
if [ -n "$DEPS_FILES" ]; then
echo "deps=true" >> "$GITHUB_OUTPUT"
else
echo "deps=false" >> "$GITHUB_OUTPUT"
fi
MCP_CATALOG_FILES=$(git diff --name-only "$BASE"..."$HEAD" -- \
'optional-mcps/**' \
'hermes_cli/mcp_catalog.py' || true)
if [ -n "$MCP_CATALOG_FILES" ]; then
echo "mcp_catalog=true" >> "$GITHUB_OUTPUT"
else
echo "mcp_catalog=false" >> "$GITHUB_OUTPUT"
fi
scan:
name: Scan PR for critical supply chain risks
if: inputs.scan
needs: changes
if: needs.changes.outputs.scan == 'true'
runs-on: ubuntu-latest
steps:
- name: Checkout
@@ -84,7 +111,7 @@ jobs:
fi
# --- base64 decode + exec/eval on the same line (the litellm attack pattern) ---
B64_EXEC_HITS=$(echo "$DIFF" | grep -n '^+' | grep -iE 'base64\.(b64decode|decodebytes|urlsafe_b64decode)' | grep -iE 'exec\(|eval\(' | head -10 || true)
B64_EXEC_HITS=$(echo "$DIFF" | grep -n '^\+' | grep -iE 'base64\.(b64decode|decodebytes|urlsafe_b64decode)' | grep -iE 'exec\(|eval\(' | head -10 || true)
if [ -n "$B64_EXEC_HITS" ]; then
FINDINGS="${FINDINGS}
### 🚨 CRITICAL: base64 decode + exec/eval combo
@@ -98,7 +125,7 @@ jobs:
fi
# --- subprocess with encoded/obfuscated command argument ---
PROC_HITS=$(echo "$DIFF" | grep -n '^+' | grep -E 'subprocess\.(Popen|call|run)\s*\(' | grep -iE 'base64|\\x[0-9a-f]{2}|chr\(' | head -10 || true)
PROC_HITS=$(echo "$DIFF" | grep -n '^\+' | grep -E 'subprocess\.(Popen|call|run)\s*\(' | grep -iE 'base64|\\x[0-9a-f]{2}|chr\(' | head -10 || true)
if [ -n "$PROC_HITS" ]; then
FINDINGS="${FINDINGS}
### 🚨 CRITICAL: subprocess with encoded/obfuscated command
@@ -160,9 +187,23 @@ jobs:
echo "::error::CRITICAL supply chain risk patterns detected in this PR. See the PR comment for details."
exit 1
# Gate: reports success when scan was skipped (no relevant files changed).
# This ensures the required check always gets a status.
scan-gate:
name: Scan PR for critical supply chain risks
needs: changes
# always() so the gate still reports SUCCESS even if `changes` fails/is
# skipped — without it, a failed dependency would leave the required
# check unreported (i.e. "pending"), the exact failure mode this fixes.
if: always() && needs.changes.outputs.scan != 'true'
runs-on: ubuntu-latest
steps:
- run: echo "No supply-chain-relevant files changed, skipping scan."
dep-bounds:
name: Check PyPI dependency upper bounds
if: inputs.deps
needs: changes
if: needs.changes.outputs.deps == 'true'
runs-on: ubuntu-latest
steps:
- name: Checkout
@@ -212,7 +253,7 @@ jobs:
$(cat /tmp/unbounded.txt)
\`\`\`
**Fix:** Add an upper bound, e.g. \`"package>=1.2.0,<2"\`
**Fix:** Add an upper bound, e.g. \`\"package>=1.2.0,<2\"\`
---
*See PR #2810 and CONTRIBUTING.md for the full policy rationale.*"
@@ -225,9 +266,23 @@ jobs:
echo "::error::PyPI dependencies without upper bounds detected. Add <next_major ceiling per CONTRIBUTING.md policy."
exit 1
# Gate: reports success when dep-bounds was skipped (no pyproject.toml changed).
# This ensures the required check always gets a status.
dep-bounds-gate:
name: Check PyPI dependency upper bounds
needs: changes
# always() so the gate still reports SUCCESS even if `changes` fails/is
# skipped — without it, a failed dependency would leave the required
# check unreported (i.e. "pending"), the exact failure mode this fixes.
if: always() && needs.changes.outputs.deps != 'true'
runs-on: ubuntu-latest
steps:
- run: echo "No pyproject.toml changes, skipping dependency bounds check."
mcp-catalog-review:
name: MCP catalog security review
if: inputs.mcp_catalog
needs: changes
if: needs.changes.outputs.mcp_catalog == 'true'
runs-on: ubuntu-latest
steps:
- name: Checkout
@@ -262,3 +317,11 @@ jobs:
gh pr comment "$PR" --body "$BODY" || echo "::warning::Could not post PR comment (expected for fork PRs)"
echo "::error::MCP catalog changes require the mcp-catalog-reviewed label."
exit 1
mcp-catalog-review-gate:
name: MCP catalog security review
needs: changes
if: always() && needs.changes.outputs.mcp_catalog != 'true'
runs-on: ubuntu-latest
steps:
- run: echo "No MCP catalog changes, skipping MCP catalog security review."

View File

@@ -1,12 +1,21 @@
name: Tests
on:
workflow_call:
push:
branches: [main]
paths-ignore:
- "**/*.md"
- "docs/**"
# No paths filter — the job must always run so the required check
# reports a status (path-gated workflows leave checks "pending" forever
# when no matching files change, which blocks merge).
pull_request:
branches: [main]
permissions:
contents: read
# Cancel in-progress runs for the same ref
# Cancel in-progress runs for the same PR/branch
concurrency:
group: tests-${{ github.ref }}
cancel-in-progress: true
@@ -40,7 +49,7 @@ jobs:
RG_VERSION=15.1.0
RG_SHA256=1c9297be4a084eea7ecaedf93eb03d058d6faae29bbc57ecdaf5063921491599
RG_TARBALL=ripgrep-${RG_VERSION}-x86_64-unknown-linux-musl.tar.gz
curl -sSfL --retry 3 --retry-delay 5 -o "$RG_TARBALL" \
curl -sSfL -o "$RG_TARBALL" \
"https://github.com/BurntSushi/ripgrep/releases/download/${RG_VERSION}/${RG_TARBALL}"
echo "${RG_SHA256} ${RG_TARBALL}" | sha256sum -c -
tar -xzf "$RG_TARBALL"
@@ -69,9 +78,7 @@ jobs:
# fails if the lock is out of sync with pyproject.toml), giving a
# reproducible env. It also creates .venv itself, so no separate
# `uv venv` step is needed.
uses: ./.github/actions/retry
with:
command: uv sync --locked --python 3.11 --extra all --extra dev
run: uv sync --locked --python 3.11 --extra all --extra dev
- name: Minimize uv cache
# Optimized for CI: prunes pre-built wheels that are cheap to
@@ -164,7 +171,7 @@ jobs:
RG_VERSION=15.1.0
RG_SHA256=1c9297be4a084eea7ecaedf93eb03d058d6faae29bbc57ecdaf5063921491599
RG_TARBALL=ripgrep-${RG_VERSION}-x86_64-unknown-linux-musl.tar.gz
curl -sSfL --retry 3 --retry-delay 5 -o "$RG_TARBALL" \
curl -sSfL -o "$RG_TARBALL" \
"https://github.com/BurntSushi/ripgrep/releases/download/${RG_VERSION}/${RG_TARBALL}"
echo "${RG_SHA256} ${RG_TARBALL}" | sha256sum -c -
tar -xzf "$RG_TARBALL"
@@ -193,9 +200,7 @@ jobs:
# fails if the lock is out of sync with pyproject.toml), giving a
# reproducible env. It also creates .venv itself, so no separate
# `uv venv` step is needed.
uses: ./.github/actions/retry
with:
command: uv sync --locked --python 3.11 --extra all --extra dev
run: uv sync --locked --python 3.11 --extra all --extra dev
- name: Minimize uv cache
# Optimized for CI: prunes pre-built wheels that are cheap to

View File

@@ -2,7 +2,13 @@
name: Typecheck
on:
workflow_call:
push:
branches: [main]
# No paths filter — the job must always run so the required check
# reports a status (path-gated workflows leave checks "pending" forever
# when no matching files change, which blocks merge).
pull_request:
branches: [main]
jobs:
typecheck:
@@ -18,14 +24,7 @@ jobs:
with:
node-version: 22
cache: npm
# --ignore-scripts: typecheck only needs the TS sources + type defs, not
# native builds. Skipping install scripts drops node-pty's node-gyp
# header fetch — the transient flake that killed this job pre-`tsc` — and
# is faster. retry covers the remaining registry blips.
-
uses: ./.github/actions/retry
with:
command: npm ci --ignore-scripts
- run: npm ci
- run: npm run --prefix ${{ matrix.package }} typecheck
# Production build of the desktop renderer. `typecheck` runs `tsc` only,
@@ -42,10 +41,5 @@ jobs:
with:
node-version: 22
cache: npm
# Keep install scripts here: the production build may need node-pty's
# native binary. retry handles the transient install-time fetch flakes.
-
uses: ./.github/actions/retry
with:
command: npm ci
- run: npm ci
- run: npm run --prefix apps/desktop build

View File

@@ -44,14 +44,25 @@ name: uv.lock check
# the same way. Better to catch it here than after merge.
on:
workflow_call:
push:
branches: [main]
paths:
- "pyproject.toml"
- "uv.lock"
- ".github/workflows/uv-lockfile-check.yml"
# No paths filter — the job must always run so the required check
# reports a status (path-gated workflows leave checks "pending" forever
# when no matching files change, which blocks merge).
pull_request:
branches: [main]
permissions:
contents: read
concurrency:
group: uv-lockfile-check-${{ github.event.pull_request.number || github.ref }}
cancel-in-progress: true
cancel-in-progress: ${{ github.event_name == 'pull_request' }}
jobs:
check:

View File

@@ -290,19 +290,6 @@ ENV HERMES_TUI_DIR=/opt/hermes/ui-tui
ENV HERMES_HOME=/opt/data
ENV HERMES_WRITE_SAFE_ROOT=/opt/data
ENV HERMES_DISABLE_LAZY_INSTALLS=1
# The published image seals /opt/hermes (root-owned, read-only) so a runtime
# lazy install can't mutate the agent's own venv and brick it. But opt-in
# backends (Firecrawl web search, Exa, Feishu, …) keep their SDKs in
# tools/lazy_deps.py — deliberately NOT baked into [all] (see pyproject.toml
# policy 2026-05-12: one quarantined release must not break every install).
# Redirect those lazy installs to a writable dir on the durable data volume.
# lazy_deps appends this dir to the END of sys.path, so a package installed
# here can only ADD modules — it can never shadow or downgrade a core module,
# so the sealed-venv guarantee holds even with installs re-enabled. The dir
# is seeded + chowned to the hermes user by docker/stage2-hook.sh and lives
# on the /opt/data volume, so it persists across container recreates / image
# updates (an ABI stamp invalidates it if a rebuild bumps the interpreter).
ENV HERMES_LAZY_INSTALL_TARGET=/opt/data/lazy-packages
# `docker exec` privilege-drop shim. When operators run
# `docker exec <c> hermes ...` they default to root, and any file the

View File

@@ -23,11 +23,6 @@ except ModuleNotFoundError:
# new code but ``uv pip install -e .`` didn't finish. Missing bootstrap
# means UTF-8 stdio setup is skipped on Windows; POSIX is unaffected.
pass
else:
# Stop a ``utils/``/``proxy/``/``ui/`` package in the launch directory from
# shadowing Hermes's own modules — ``hermes acp`` can be started from any
# cwd, including a project that has same-named packages on its path.
hermes_bootstrap.harden_import_path()
import argparse
import asyncio

View File

@@ -74,7 +74,7 @@ _POLISHED_TOOLS = {
"kanban_create", "kanban_show", "kanban_comment", "kanban_complete",
"kanban_block", "kanban_link", "kanban_heartbeat",
"yb_query_group_info", "yb_query_group_members", "yb_search_sticker",
"yb_send_dm", "yb_send_sticker",
"yb_send_dm", "yb_send_sticker", "mixture_of_agents",
}

View File

@@ -214,7 +214,7 @@ def build_nous_credits_snapshot(account_info) -> Optional[AccountUsageSnapshot]:
return None
details.append(f"Top up: {nous_portal_topup_url(account_info)}")
details.append("(or run /credits)")
details.append("(or run /topup)")
plan = getattr(sub, "plan", None) if sub is not None else None
return AccountUsageSnapshot(
@@ -340,7 +340,7 @@ def _snapshot_from_credits_state(state) -> Optional[AccountUsageSnapshot]:
@dataclass(frozen=True)
class CreditsView:
"""Surface-agnostic data for the ``/credits`` command.
"""Surface-agnostic data for the ``/topup`` balance view.
One portal fetch, one parse — consumed identically by the CLI panel, the
gateway button, and any other money surface. Fail-open: when not logged in
@@ -356,11 +356,11 @@ class CreditsView:
def build_credits_view(*, markdown: bool = False, timeout: float = 10.0) -> CreditsView:
"""Build the /credits view: balance block + identity line + top-up URL.
"""Build the /topup balance view: balance block + identity line + top-up URL.
Reuses the same account fetch + snapshot + URL builder as the /usage credits
block, so the numbers always match. The balance block is the rendered
snapshot MINUS its trailing top-up/command-hint lines (the /credits surface
snapshot MINUS its trailing top-up/command-hint lines (the /topup surface
supplies its own affordance). Fail-open → ``CreditsView(logged_in=False)``.
"""
not_logged_in = CreditsView(logged_in=False)
@@ -386,7 +386,7 @@ def build_credits_view(*, markdown: bool = False, timeout: float = 10.0) -> Cred
timeout=timeout
)
except Exception:
logger.debug("credits ▸ /credits portal fetch failed (fail-open)", exc_info=True)
logger.debug("credits ▸ /topup portal fetch failed (fail-open)", exc_info=True)
return not_logged_in
if account is None or not getattr(account, "logged_in", False):
@@ -394,8 +394,8 @@ def build_credits_view(*, markdown: bool = False, timeout: float = 10.0) -> Cred
snapshot = build_nous_credits_snapshot(account)
# Balance lines = the snapshot block minus the two trailing affordance lines
# ("Top up: <url>" + "(or run /credits)") that build_nous_credits_snapshot
# appends for the /usage surface. /credits renders its own button/panel.
# ("Top up: <url>" + "(or run /topup)") that build_nous_credits_snapshot
# appends for the /usage surface. /topup renders its own button/panel.
balance_lines: list[str] = []
if snapshot is not None:
rendered = render_account_usage_lines(snapshot, markdown=markdown)

View File

@@ -106,12 +106,7 @@ def _custom_provider_extra_body_for_agent(
base_url: str,
custom_providers: List[Dict[str, Any]],
) -> Optional[Dict[str, Any]]:
provider_norm = (provider or "").strip().lower()
if provider_norm == "custom":
provider_key_filter = ""
elif provider_norm.startswith("custom:"):
provider_key_filter = provider_norm.split(":", 1)[1].strip()
else:
if (provider or "").strip().lower() != "custom":
return None
target_url = _normalized_custom_base_url(base_url)
@@ -122,13 +117,6 @@ def _custom_provider_extra_body_for_agent(
for entry in custom_providers or []:
if not isinstance(entry, dict):
continue
if provider_key_filter:
entry_keys = {
str(entry.get("provider_key", "") or "").strip().lower(),
str(entry.get("name", "") or "").strip().lower(),
}
if provider_key_filter not in entry_keys:
continue
if _normalized_custom_base_url(entry.get("base_url")) != target_url:
continue
extra_body = entry.get("extra_body")
@@ -719,15 +707,6 @@ def init_agent(
print("🔑 Using credentials: Microsoft Entra ID")
elif isinstance(effective_key, str) and len(effective_key) > 12:
print(f"🔑 Using token: {effective_key[:8]}...{effective_key[-4:]}")
elif agent.provider == "moa":
from agent.moa_loop import MoAClient
agent.api_mode = "chat_completions"
agent.client = MoAClient(agent.model or "default")
agent._client_kwargs = {}
agent.api_key = api_key or "moa-virtual-provider"
agent.base_url = base_url or "moa://local"
if not agent.quiet_mode:
print(f"🤖 AI Agent initialized with MoA preset: {agent.model}")
elif agent.api_mode == "bedrock_converse":
# AWS Bedrock — uses boto3 directly, no OpenAI client needed.
# Region is extracted from the base_url or defaults to us-east-1.
@@ -1527,7 +1506,6 @@ def init_agent(
# 3. Check general plugin system (user-installed plugins)
# 4. Fall back to built-in ContextCompressor
_selected_engine = None
_copy_failed = False
_engine_name = "compressor" # default
try:
_ctx_cfg = _agent_cfg.get("context", {}) if isinstance(_agent_cfg, dict) else {}
@@ -1545,35 +1523,15 @@ def init_agent(
# Try general plugin system as fallback
if _selected_engine is None:
_candidate = None
try:
from hermes_cli.plugins import get_plugin_context_engine
_candidate = get_plugin_context_engine()
if _candidate and _candidate.name == _engine_name:
_selected_engine = _candidate
except Exception:
_candidate = None
if _candidate is not None and _candidate.name == _engine_name:
# Deep-copy the shared plugin singleton so a child agent's
# update_model() can't mutate the parent's compressor (#42449).
# Copy can fail for engines holding uncopyable state (locks, DB
# connections, clients); in that case fall back to the built-in
# compressor with an ACCURATE message rather than silently
# mislabelling it "not found".
import copy
try:
_selected_engine = copy.deepcopy(_candidate)
except Exception as _copy_err:
_copy_failed = True
_ra().logger.warning(
"Context engine '%s' could not be safely copied for this "
"agent (%s) — falling back to built-in compressor. Plugin "
"engines that hold uncopyable state (locks, DB connections) "
"should implement __deepcopy__ to copy only mutable budget "
"state.",
_engine_name, _copy_err,
)
_selected_engine = None
pass
if _selected_engine is None and not _copy_failed:
if _selected_engine is None:
_ra().logger.warning(
"Context engine '%s' not found — falling back to built-in compressor",
_engine_name,
@@ -1663,27 +1621,16 @@ def init_agent(
for t in agent.tools
if isinstance(t, dict)
}
from agent.memory_manager import normalize_tool_schema as _normalize_tool_schema
for _raw_schema in agent.context_compressor.get_tool_schemas():
_schema = _normalize_tool_schema(_raw_schema)
if _schema is None:
# A schema with no resolvable name (e.g. an already-wrapped
# entry) would append a nameless tool that strict providers
# 400 on, disabling the whole toolset (#47707). Skip it.
_ra().logger.warning(
"Context engine returned a tool schema with no resolvable "
"name; skipping to avoid poisoning the request (%r)",
_raw_schema,
)
continue
_tname = _schema["name"]
if _tname in _existing_tool_names:
for _schema in agent.context_compressor.get_tool_schemas():
_tname = _schema.get("name", "")
if _tname and _tname in _existing_tool_names:
continue # already registered via plugin/cache path
_wrapped = {"type": "function", "function": _schema}
agent.tools.append(_wrapped)
agent.valid_tool_names.add(_tname)
agent._context_engine_tool_names.add(_tname)
_existing_tool_names.add(_tname)
if _tname:
agent.valid_tool_names.add(_tname)
agent._context_engine_tool_names.add(_tname)
_existing_tool_names.add(_tname)
# Notify context engine of session start
if hasattr(agent, "context_compressor") and agent.context_compressor:

View File

@@ -1697,27 +1697,6 @@ def switch_model(agent, new_model, new_provider, api_key='', base_url='', api_mo
old_model, old_provider, new_model, new_provider,
)
# ── Persist billing route to session DB ──
# The agent's _session_db / session_id may not be set in all contexts
# (tests, bare agents without a session DB, etc.). This ensures the
# dashboard Model cards show the actual provider after a mid-session
# /model switch instead of the stale session-creation provider.
# See #48248 for the full bug description.
_session_db = getattr(agent, "_session_db", None)
_session_id = getattr(agent, "session_id", None)
if _session_db is not None and _session_id:
try:
_session_db.update_session_billing_route(
_session_id,
provider=agent.provider,
base_url=agent.base_url,
billing_mode=getattr(agent, "api_mode", None),
)
except Exception:
logger.warning(
"Failed to persist billing route after model switch",
exc_info=True,
)
def invoke_tool(agent, function_name: str, function_args: dict, effective_task_id: str,

View File

@@ -1297,15 +1297,7 @@ def run_oauth_setup_token() -> Optional[str]:
# Stores credentials in ~/.hermes/.anthropic_oauth.json (our own file).
_OAUTH_CLIENT_ID = "9d1c250a-e61b-44d9-88ed-5944d1962f5e"
# Anthropic migrated the OAuth token endpoint to platform.claude.com;
# console.anthropic.com now 404s. Callers should iterate _OAUTH_TOKEN_URLS
# (new host first, console fallback). _OAUTH_TOKEN_URL is kept as the primary
# for backward compatibility with existing imports and now points at the live host.
_OAUTH_TOKEN_URLS = [
"https://platform.claude.com/v1/oauth/token",
"https://console.anthropic.com/v1/oauth/token",
]
_OAUTH_TOKEN_URL = _OAUTH_TOKEN_URLS[0]
_OAUTH_TOKEN_URL = "https://console.anthropic.com/v1/oauth/token"
_OAUTH_REDIRECT_URI = "https://console.anthropic.com/oauth/code/callback"
_OAUTH_SCOPES = "org:create_api_key user:profile user:inference"
_HERMES_OAUTH_FILE = get_hermes_home() / ".anthropic_oauth.json"
@@ -1403,34 +1395,18 @@ def run_hermes_oauth_login_pure() -> Optional[Dict[str, Any]]:
"code_verifier": verifier,
}).encode()
# Anthropic migrated the OAuth token endpoint to platform.claude.com;
# console.anthropic.com now 404s. Try the new host first, then fall
# back to console for older deployments (mirrors the refresh path).
result = None
last_error = None
for endpoint in _OAUTH_TOKEN_URLS:
req = urllib.request.Request(
endpoint,
data=exchange_data,
headers={
"Content-Type": "application/json",
"User-Agent": f"claude-cli/{_get_claude_code_version()} (external, cli)",
},
method="POST",
)
try:
with urllib.request.urlopen(req, timeout=15) as resp:
result = json.loads(resp.read().decode())
break
except Exception as exc:
last_error = exc
logger.debug("Anthropic token exchange failed at %s: %s", endpoint, exc)
continue
req = urllib.request.Request(
_OAUTH_TOKEN_URL,
data=exchange_data,
headers={
"Content-Type": "application/json",
"User-Agent": f"claude-cli/{_get_claude_code_version()} (external, cli)",
},
method="POST",
)
if result is None:
raise last_error if last_error is not None else ValueError(
"Anthropic token exchange failed"
)
with urllib.request.urlopen(req, timeout=15) as resp:
result = json.loads(resp.read().decode())
except Exception as e:
print(f"Token exchange failed: {e}")
return None

View File

@@ -101,7 +101,6 @@ class _OpenAIProxy:
OpenAI = _OpenAIProxy() # module-level name, resolves lazily on call/isinstance
from agent.credential_pool import load_pool
from agent.model_metadata import MINIMUM_CONTEXT_LENGTH, get_model_context_length
from hermes_cli.config import get_hermes_home
from hermes_constants import OPENROUTER_BASE_URL
from utils import base_url_host_matches, base_url_hostname, env_float, model_forces_max_completion_tokens, normalize_proxy_env_vars
@@ -2471,7 +2470,7 @@ def _is_payment_error(exc: Exception) -> bool:
# but sometimes wrap them in 429 or other codes.
# Daily quota exhaustion from Bedrock, Vertex AI, and similar providers
# uses different language but is semantically identical to credit exhaustion.
if status in {402, 403, 404, 429, None}:
if status in {402, 404, 429, None}:
if any(kw in err_lower for kw in (
"credits", "insufficient funds",
"can only afford", "billing",
@@ -2480,8 +2479,6 @@ def _is_payment_error(exc: Exception) -> bool:
"balance_depleted", "no usable credits",
"model_not_supported_on_free_tier",
"not available on the free tier",
"requires a subscription", "upgrade for access",
"upgrade for higher limits", "reached your session usage limit",
# Daily / monthly / weekly quota exhaustion keywords
"quota exceeded", "quota_exceeded",
"too many tokens per day", "daily limit",
@@ -2700,60 +2697,6 @@ def _is_model_not_found_error(exc: Exception) -> bool:
))
def _is_model_incompatible_error(exc: Exception) -> bool:
"""Detect "this route cannot serve this model" 400s (capability mismatch).
Distinct from :func:`_is_model_not_found_error` (the model does not exist
anywhere): here the model name is valid but the *current provider/account*
is structurally unable to run it. The canonical case is a configured
fallback that cannot run the main model — e.g. an ``openai-codex`` /
ChatGPT-account fallback asked to compress a ``glm-5.2`` conversation::
Error code: 400 - {'detail': "The 'glm-5.2' model is not supported
when using Codex with a ChatGPT account."}
The candidate authenticates fine and builds a client, so the auth and
payment predicates don't fire and the call would otherwise raise and
abort the whole auxiliary task (commonly compression — which then drops
middle turns and churns the session, destroying the prompt cache).
Treating it as a fallback-worthy capability error lets the chain skip the
incapable route and continue to the next candidate, mirroring the
context-window feasibility screen (#52392).
Billing/quota 400s belong to :func:`_is_payment_error`; "model does not
exist" 400s belong to :func:`_is_model_not_found_error`. This predicate
explicitly excludes both so the three don't overlap.
"""
status = getattr(exc, "status_code", None)
if status not in {400, None}:
return False
err_lower = str(exc).lower()
# Not-found 400s ("invalid model ID", "model does not exist") are owned by
# _is_model_not_found_error. Billing/free-tier 400s are owned by the
# payment path — key on the billing keywords directly here rather than
# calling _is_payment_error(), because that predicate is status-gated
# ({402,403,404,429,None}) and would not recognise a 400-coded billing
# body, letting it leak into this capability bucket.
if _is_model_not_found_error(exc):
return False
if any(kw in err_lower for kw in (
"credits", "insufficient funds", "billing", "out of funds",
"balance_depleted", "no usable credits", "payment required",
"free tier", "free-tier", "not available on the free tier",
"model_not_supported_on_free_tier", "quota",
)):
return False
return any(kw in err_lower for kw in (
"is not supported when using", # codex/ChatGPT-account model gating
"model is not supported",
"not supported with this",
"not supported for this account",
"model_not_supported",
"does not support this model",
"unsupported model",
))
def _evict_cached_clients(provider: str) -> None:
"""Drop cached auxiliary clients for a provider so fresh creds are used."""
normalized = _normalize_aux_provider(provider)
@@ -3204,88 +3147,6 @@ def _try_main_agent_model_fallback(
return client, resolved_model or main_model, label
# ── Context-window screening for runtime fallback chains (issue #52392) ──
#
# When the runtime auxiliary fallback chain selects a candidate that is
# reachable but has a context window smaller than the compression task
# requires, the call errors out instead of continuing to the next, viable
# candidate. The startup feasibility check in
# ``agent.conversation_compression.check_compression_model_feasibility``
# already filters too-small auxiliary models at startup, but the runtime
# fallback chain (``_try_configured_fallback_chain`` and
# ``_try_main_fallback_chain``) does not apply the same filter, so
# compression can stop at the first alive door even if the room behind it
# is too small.
#
# The helpers below screen each candidate by its effective context window
# before it is returned. ``None`` results from ``get_model_context_length``
# are passed through (we cannot prove a model is too small, so we do not
# block it). This preserves the existing fallback surface for
# unrecognised/custom models while closing the gap on the well-known ones.
def _task_minimum_context_length(task: Optional[str]) -> Optional[int]:
"""Return the minimum context length required for an auxiliary task.
Only ``compression`` carries an explicit minimum today (the same
``MINIMUM_CONTEXT_LENGTH`` (64K) floor that
``check_compression_model_feasibility`` already enforces at startup).
Other tasks (``vision``, ``title_generation``, ``web_extract``,
``skills_hub``, ``mcp``, ``session_search``) return ``None`` — they
have no per-task context floor and the runtime chain must remain
permissive for them.
Returns ``None`` for an empty/``None`` task name so the helper is a
safe no-op when called from generic sites.
"""
if not task:
return None
if task == "compression":
return MINIMUM_CONTEXT_LENGTH
return None
def _candidate_context_window(
provider: str,
model: str,
base_url: str = "",
api_key: str = "",
) -> Optional[int]:
"""Resolve the effective context window for a fallback candidate.
Thin wrapper around :func:`agent.model_metadata.get_model_context_length`
that swallows probe failures (returns ``None``). Callers treat
``None`` as "unknown — pass through" so the existing fallback
surface is preserved when the context-length resolver chain cannot
determine a value (custom endpoints, models not in the registry,
offline endpoints).
Best-effort, never raises — the runtime fallback chain must keep
moving even if the resolver hits a probe error.
"""
if not model:
return None
try:
ctx = get_model_context_length(
model,
base_url=base_url,
api_key=api_key,
provider=provider,
)
except Exception as exc:
logger.debug(
"Auxiliary fallback: could not resolve context window for %s/%s: %s",
provider, model, exc,
)
return None
# ``get_model_context_length`` returns an int (with a 256K default
# fallback when nothing else matches). We still propagate ``None`` if
# a future change returns ``Optional[int]`` — being explicit is
# cheap and the test suite covers both shapes.
if isinstance(ctx, int) and ctx > 0:
return ctx
return None
def _try_configured_fallback_chain(
task: str,
failed_provider: str,
@@ -3310,7 +3171,6 @@ def _try_configured_fallback_chain(
skip = failed_provider.lower().strip()
tried = []
min_ctx = _task_minimum_context_length(task)
for i, entry in enumerate(chain):
if not isinstance(entry, dict):
@@ -3328,20 +3188,6 @@ def _try_configured_fallback_chain(
fb_client, resolved_model = None, None
if fb_client is not None:
if min_ctx is not None and resolved_model:
fb_ctx = _candidate_context_window(
fb_provider,
resolved_model,
base_url=str(entry.get("base_url") or ""),
api_key=_fallback_entry_api_key(entry) or "",
)
if fb_ctx is not None and fb_ctx < min_ctx:
logger.info(
"Auxiliary %s: skipping %s (%s context=%d < min=%d), continuing chain",
task, label, resolved_model, fb_ctx, min_ctx,
)
tried.append(f"{label} (context too small: {fb_ctx}<{min_ctx})")
continue
logger.info(
"Auxiliary %s: %s on %s — configured fallback to %s (%s)",
task, reason, failed_provider, label, resolved_model or fb_model or "default",
@@ -3357,28 +3203,6 @@ def _try_configured_fallback_chain(
return None, None, ""
def _try_configured_fallback_for_unavailable_client(
task: Optional[str],
failed_provider: str,
) -> Tuple[Optional[Any], Optional[str], str]:
"""Try task fallback_chain when an explicit aux provider cannot build.
This covers the "no client" case before any request is sent: missing
raw env key, unavailable OAuth/pool credentials, or provider resolver
returning ``(None, None)``. It deliberately stops at the configured
per-task fallback chain; the main-agent model remains the last-resort
runtime fallback for request-time capacity errors.
"""
explicit = (failed_provider or "").strip().lower()
if not task or not explicit or explicit in {"auto"}:
return None, None, ""
return _try_configured_fallback_chain(
task,
explicit,
reason="provider unavailable",
)
def _fallback_entry_api_key(entry: Dict[str, Any]) -> Optional[str]:
"""Resolve inline or env-backed API key from a fallback-chain entry."""
explicit = str(entry.get("api_key") or "").strip()
@@ -3437,7 +3261,6 @@ def _try_main_fallback_chain(
main_norm = (_read_main_provider() or "").strip().lower()
skip = {p for p in (failed_norm, main_norm, "auto") if p}
tried: List[str] = []
min_ctx = _task_minimum_context_length(task)
for i, entry in enumerate(chain):
if not isinstance(entry, dict):
@@ -3461,20 +3284,6 @@ def _try_main_fallback_chain(
logger.debug("Auxiliary %s: main fallback %s failed to resolve: %s", task or "call", label, exc)
fb_client, resolved_model = None, None
if fb_client is not None:
if min_ctx is not None:
fb_ctx = _candidate_context_window(
fb_provider,
resolved_model or fb_model,
base_url=str(entry.get("base_url") or ""),
api_key=_fallback_entry_api_key(entry) or "",
)
if fb_ctx is not None and fb_ctx < min_ctx:
logger.info(
"Auxiliary %s: skipping %s (context=%d < min=%d), continuing chain",
task or "call", label, fb_ctx, min_ctx,
)
tried.append(f"{label} (context too small: {fb_ctx}<{min_ctx})")
continue
logger.info(
"Auxiliary %s: %s on %s — main fallback chain to %s (%s)",
task or "call", reason, failed_provider or "auto", label,
@@ -5535,30 +5344,21 @@ def call_llm(
)
if client is None:
# When the user explicitly chose a non-OpenRouter provider but no
# credentials were found, honor the task fallback_chain before
# raising. Missing raw env keys are recoverable for auxiliary
# tasks because fallback entries may use OAuth / credential-pool
# auth (for example openai-codex).
# credentials were found, fail fast instead of silently routing
# through OpenRouter (which causes confusing 404s).
_explicit = (resolved_provider or "").strip().lower()
if _explicit and _explicit not in {"auto", "openrouter", "custom"}:
fb_client, fb_model, fb_label = _try_configured_fallback_for_unavailable_client(
task, _explicit,
raise RuntimeError(
f"Provider '{_explicit}' is set in config.yaml but no API key "
f"was found. Set the {_explicit.upper()}_API_KEY environment "
f"variable, or switch to a different provider with `hermes model`."
)
if fb_client is not None:
client, final_model = fb_client, fb_model
resolved_provider = fb_label or resolved_provider
else:
raise RuntimeError(
f"Provider '{_explicit}' is set in config.yaml but no API key "
f"was found. Set the {_explicit.upper()}_API_KEY environment "
f"variable, or switch to a different provider with `hermes model`."
)
# For auto/custom with no credentials, try the full auto chain
# rather than hardcoding OpenRouter (which may be depleted).
# Pass model=None so each provider uses its own default —
# resolved_model may be an OpenRouter-format slug that doesn't
# work on other providers.
if client is None and not resolved_base_url:
if not resolved_base_url:
logger.info("Auxiliary %s: provider %s unavailable, trying auto-detection chain",
task or "call", resolved_provider)
client, final_model = _get_cached_client("auto", main_runtime=main_runtime, task=task)
@@ -5857,7 +5657,6 @@ def call_llm(
_is_payment_error(first_err)
or _is_connection_error(first_err)
or _is_rate_limit_error(first_err)
or _is_model_incompatible_error(first_err)
)
# Respect explicit provider choice for transient errors (auth, request
# validation, etc.) but allow fallback when the provider clearly cannot
@@ -5868,19 +5667,7 @@ def call_llm(
is_auto = resolved_provider in {"auto", "", None}
# Capacity errors bypass the explicit-provider gate: the provider
# literally cannot serve this request regardless of user intent.
# Rate limits are included: after retries are exhausted, a 429 means
# the provider cannot serve this request — fall back. See #52228.
# Model-incompatibility 400s are also a hard capability mismatch (the
# route cannot run this model at all — e.g. a codex/ChatGPT-account
# fallback asked to compress a glm-5.2 conversation), so they bypass
# the explicit-provider gate and continue to the next candidate
# instead of aborting the auxiliary task and churning the session.
is_capacity_error = (
_is_payment_error(first_err)
or _is_connection_error(first_err)
or _is_rate_limit_error(first_err)
or _is_model_incompatible_error(first_err)
)
is_capacity_error = _is_payment_error(first_err) or _is_connection_error(first_err)
if should_fallback and (is_auto or is_capacity_error):
if _is_payment_error(first_err):
reason = "payment error"
@@ -5893,8 +5680,6 @@ def call_llm(
)
elif _is_rate_limit_error(first_err):
reason = "rate limit"
elif _is_model_incompatible_error(first_err):
reason = "model incompatible with route"
else:
reason = "connection error"
logger.info("Auxiliary %s: %s on %s (%s), trying fallback",
@@ -6069,21 +5854,12 @@ async def async_call_llm(
if client is None:
_explicit = (resolved_provider or "").strip().lower()
if _explicit and _explicit not in {"auto", "openrouter", "custom"}:
fb_client, fb_model, fb_label = _try_configured_fallback_for_unavailable_client(
task, _explicit,
raise RuntimeError(
f"Provider '{_explicit}' is set in config.yaml but no API key "
f"was found. Set the {_explicit.upper()}_API_KEY environment "
f"variable, or switch to a different provider with `hermes model`."
)
if fb_client is not None:
client, final_model = _to_async_client(
fb_client, fb_model or "", is_vision=(task == "vision")
)
resolved_provider = fb_label or resolved_provider
else:
raise RuntimeError(
f"Provider '{_explicit}' is set in config.yaml but no API key "
f"was found. Set the {_explicit.upper()}_API_KEY environment "
f"variable, or switch to a different provider with `hermes model`."
)
if client is None and not resolved_base_url:
if not resolved_base_url:
logger.info("Auxiliary %s: provider %s unavailable, trying auto-detection chain",
task or "call", resolved_provider)
client, final_model = _get_cached_client("auto", async_mode=True, main_runtime=main_runtime, task=task)
@@ -6333,22 +6109,12 @@ async def async_call_llm(
_is_payment_error(first_err)
or _is_connection_error(first_err)
or _is_rate_limit_error(first_err)
or _is_model_incompatible_error(first_err)
)
# Capacity errors (payment/quota/connection/rate-limit) bypass the
# explicit-provider gate — the provider cannot serve the request
# regardless of user intent. Rate limits are included: after retries
# are exhausted, a 429 means the provider is at capacity. See #52228.
# Capacity errors (payment/quota/connection) bypass the explicit-provider
# gate — the provider cannot serve the request regardless of user intent.
# See #26803: daily token quota must fall back like a 402 credit error.
# Model-incompatibility 400s (route cannot run this model at all)
# bypass the gate too — see the sync call_llm() path for rationale.
is_auto = resolved_provider in {"auto", "", None}
is_capacity_error = (
_is_payment_error(first_err)
or _is_connection_error(first_err)
or _is_rate_limit_error(first_err)
or _is_model_incompatible_error(first_err)
)
is_capacity_error = _is_payment_error(first_err) or _is_connection_error(first_err)
if should_fallback and (is_auto or is_capacity_error):
if _is_payment_error(first_err):
reason = "payment error"
@@ -6357,8 +6123,6 @@ async def async_call_llm(
)
elif _is_rate_limit_error(first_err):
reason = "rate limit"
elif _is_model_incompatible_error(first_err):
reason = "model incompatible with route"
else:
reason = "connection error"
logger.info("Auxiliary %s (async): %s on %s (%s), trying fallback",

331
agent/billing_usage.py Normal file
View File

@@ -0,0 +1,331 @@
"""Shared dollar-denominated usage model for the billing/subscription surfaces.
The single source of truth behind the ``/usage`` and ``/subscription`` usage
bars (TUI + CLI). User feedback (Jun 2026): the terminal surfaces show
**dollars**, never "credits", and every usage bar must make the monthly
subscription allowance and separately-purchased top-up dollars distinctly
visible.
Data source: the NAS account-info fetch (``NousPortalAccountInfo``), whose
``paid_service_access_info`` carries the three dollar magnitudes we render
(despite the legacy ``*_credits`` field names, these are USD floats):
- ``subscription_credits_remaining`` -> plan dollars left this month
- ``purchased_credits_remaining`` -> top-up dollars left (rolls over)
- ``total_usable_credits`` -> total spendable
plus ``subscription.monthly_credits`` (the plan's monthly $ allowance, the
denominator for the "% used" plan bar) and ``current_period_end`` (renewal).
Design: two SEPARATE bars (decided with the user) rather than one crammed
three-segment bar — at terminal widths three same-glyph density segments are
unreadable. The plan bar is "spent vs allowance this month" (carries % used);
the top-up bar is "money you bought, doesn't expire". Each gets full
resolution and a single fill glyph, so the bar is never ambiguous and never
relies on color.
Fail-open everywhere: any missing/non-finite field degrades to fewer bars or a
magnitudes-only view; a logged-out / unreachable portal yields
``available=False`` and the surface shows nothing.
"""
from __future__ import annotations
import logging
import math
import os
from dataclasses import dataclass, field
from typing import Any, Optional
logger = logging.getLogger(__name__)
# Below this TOTAL spendable ($), a paid account is flagged "low" — the alert
# state that nudges top-up/upgrade before a mid-run cutoff. Product threshold
# (user feedback): "any amount below $5 should be an alert status."
LOW_BALANCE_THRESHOLD_USD = 5.0
def _finite(value: Any) -> Optional[float]:
"""Return value as a float iff it's a real finite number (not bool/NaN/Inf)."""
if isinstance(value, bool) or not isinstance(value, (int, float)):
return None
f = float(value)
return f if math.isfinite(f) else None
def _fmt_usd(value: Optional[float]) -> str:
"""``$X.YY`` for display. ``None`` -> ``$0.00`` (callers gate on presence)."""
return f"${(value or 0.0):,.2f}"
def format_renews(value: Optional[str]) -> Optional[str]:
"""Format an ISO date/timestamp as a human date, e.g. ``Jul 24, 2026``.
Accepts ``2026-07-24``, ``2026-07-24T11:05:01.000Z``, etc. Returns the raw
string unchanged if it can't be parsed (never raises), and ``None`` for
empty input.
"""
if not value:
return None
from datetime import datetime
text = str(value).strip()
if not text:
return None
iso = text[:-1] + "+00:00" if text.endswith("Z") else text
try:
dt = datetime.fromisoformat(iso)
except ValueError:
# Fall back to a bare date prefix (YYYY-MM-DD) if present.
try:
dt = datetime.strptime(text[:10], "%Y-%m-%d")
except ValueError:
return text
# %-d isn't portable to Windows; build the day without a leading zero.
return f"{dt.strftime('%b')} {dt.day}, {dt.year}"
@dataclass(frozen=True)
class UsageBar:
"""One full-resolution bar: ``spent`` of ``total``, plus a remaining figure.
``kind`` is ``"plan"`` (monthly allowance, shows % used) or ``"topup"``
(purchased dollars, no denominator — ``spent`` is 0 and ``total`` ==
``remaining`` so it renders as a full bar of available balance).
"""
kind: str # "plan" | "topup"
remaining_usd: float
total_usd: float
spent_usd: float = 0.0
@property
def pct_used(self) -> Optional[int]:
if self.kind != "plan" or self.total_usd <= 0:
return None
return max(0, min(100, round(self.spent_usd / self.total_usd * 100)))
@property
def fill_fraction(self) -> float:
"""Fraction of the bar that should read as 'remaining' (filled)."""
if self.total_usd <= 0:
return 0.0
return max(0.0, min(1.0, self.remaining_usd / self.total_usd))
@dataclass(frozen=True)
class UsageModel:
"""Surface-agnostic dollar usage model shared by /usage and /subscription.
``status`` classifies the account for copy selection:
- ``"free"`` : no paid access / no subscription (free models only)
- ``"low"`` : paid, but total spendable < $5 (ALERT)
- ``"healthy"`` : paid, total spendable >= $5
- ``"depleted"`` : paid access lost (balance exhausted)
"""
available: bool
status: str = "free"
plan_name: Optional[str] = None
renews_at: Optional[str] = None
renews_display: Optional[str] = None
subscription_remaining_usd: Optional[float] = None
topup_remaining_usd: Optional[float] = None
total_spendable_usd: Optional[float] = None
plan_bar: Optional[UsageBar] = None
topup_bar: Optional[UsageBar] = None
@property
def is_low(self) -> bool:
return self.status == "low"
@property
def is_free(self) -> bool:
return self.status == "free"
@property
def has_topup(self) -> bool:
return bool(self.topup_remaining_usd and self.topup_remaining_usd > 0)
def usage_model_from_account(account_info: Any) -> UsageModel:
"""Build a :class:`UsageModel` from a ``NousPortalAccountInfo``. Fail-open.
Returns ``UsageModel(available=False)`` when there's no usable account info
(logged out, no entitlement block). Never raises.
"""
try:
if account_info is None or not getattr(account_info, "logged_in", False):
return UsageModel(available=False)
access = getattr(account_info, "paid_service_access_info", None)
sub = getattr(account_info, "subscription", None)
paid = getattr(account_info, "paid_service_access", None)
sub_remaining = _finite(getattr(access, "subscription_credits_remaining", None)) if access else None
topup_remaining = _finite(getattr(access, "purchased_credits_remaining", None)) if access else None
total_usable = _finite(getattr(access, "total_usable_credits", None)) if access else None
plan_name = getattr(sub, "plan", None) if sub is not None else None
renews_at = getattr(sub, "current_period_end", None) if sub is not None else None
monthly = _finite(getattr(sub, "monthly_credits", None)) if sub is not None else None
has_subscription = bool(plan_name) or (monthly is not None and monthly > 0)
# Total spendable: prefer the server's total; else sum the parts we have.
if total_usable is not None:
total_spendable = total_usable
else:
parts = [v for v in (sub_remaining, topup_remaining) if v is not None]
total_spendable = sum(parts) if parts else None
# Status classification.
if paid is False:
status = "depleted"
elif not has_subscription and not (topup_remaining and topup_remaining > 0):
# No plan and no purchased balance -> free-models-only.
status = "free"
elif total_spendable is not None and total_spendable < LOW_BALANCE_THRESHOLD_USD:
status = "low"
else:
status = "healthy"
# Plan bar — only with a positive monthly allowance AND a remaining we
# can place on it. spent = cap - remaining, clamped (a debt/over-cap
# balance reads as fully spent rather than a nonsensical negative).
plan_bar: Optional[UsageBar] = None
if monthly is not None and monthly > 0 and sub_remaining is not None:
remaining = max(0.0, min(monthly, sub_remaining))
plan_bar = UsageBar(
kind="plan",
remaining_usd=remaining,
total_usd=monthly,
spent_usd=max(0.0, monthly - sub_remaining),
)
# Top-up bar — only when there are purchased dollars to show. No
# denominator (top-up has no monthly cap), so it renders full = balance.
topup_bar: Optional[UsageBar] = None
if topup_remaining is not None and topup_remaining > 0:
topup_bar = UsageBar(
kind="topup",
remaining_usd=topup_remaining,
total_usd=topup_remaining,
spent_usd=0.0,
)
return UsageModel(
available=True,
status=status,
plan_name=plan_name,
renews_at=renews_at,
renews_display=format_renews(renews_at),
subscription_remaining_usd=sub_remaining,
topup_remaining_usd=topup_remaining,
total_spendable_usd=total_spendable,
plan_bar=plan_bar,
topup_bar=topup_bar,
)
except Exception:
logger.debug("usage ▸ model build failed (fail-open)", exc_info=True)
return UsageModel(available=False)
def build_usage_model(*, timeout: float = 10.0) -> UsageModel:
"""Fetch account-info and build the shared usage model. Fail-open.
Dev override: ``HERMES_DEV_CREDITS_FIXTURE`` short-circuits to a fixture so
every usage state is testable without a live account (mirrors the existing
``/usage`` credits-block fixture path).
"""
fixture = _dev_fixture_usage_model()
if fixture is not None:
return fixture
try:
from hermes_cli.auth import get_provider_auth_state
tok = (get_provider_auth_state("nous") or {}).get("access_token")
if not (isinstance(tok, str) and tok.strip()):
return UsageModel(available=False)
except Exception:
return UsageModel(available=False)
try:
import concurrent.futures
from hermes_cli.nous_account import get_nous_portal_account_info
with concurrent.futures.ThreadPoolExecutor(max_workers=1) as pool:
account = pool.submit(get_nous_portal_account_info, force_fresh=True).result(timeout=timeout)
return usage_model_from_account(account)
except Exception:
logger.debug("usage ▸ portal fetch failed (fail-open)", exc_info=True)
return UsageModel(available=False)
# =============================================================================
# Dev fixtures (throwaway scaffolding — env-var driven, no live portal)
# =============================================================================
def _dev_fixture_usage_model() -> Optional[UsageModel]:
"""Map ``HERMES_DEV_CREDITS_FIXTURE`` to a usage model for offline UX work.
Recognized names: ``free | healthy | low | topup | depleted``. Returns
``None`` when the env var is unset (real portal path runs).
"""
name = (os.getenv("HERMES_DEV_CREDITS_FIXTURE") or "").strip().lower()
if not name:
return None
if name == "free":
return UsageModel(available=True, status="free", plan_name=None)
if name in ("healthy", "mid"):
return UsageModel(
available=True,
status="healthy",
plan_name="Plus",
renews_at="2026-07-01",
subscription_remaining_usd=14.0,
total_spendable_usd=14.0,
plan_bar=UsageBar(kind="plan", remaining_usd=14.0, total_usd=20.0, spent_usd=6.0),
)
if name in ("topup", "top-up"):
return UsageModel(
available=True,
status="healthy",
plan_name="Plus",
renews_at="2026-07-01",
subscription_remaining_usd=14.0,
topup_remaining_usd=12.0,
total_spendable_usd=26.0,
plan_bar=UsageBar(kind="plan", remaining_usd=14.0, total_usd=20.0, spent_usd=6.0),
topup_bar=UsageBar(kind="topup", remaining_usd=12.0, total_usd=12.0, spent_usd=0.0),
)
if name == "low":
return UsageModel(
available=True,
status="low",
plan_name="Plus",
renews_at="2026-07-01",
subscription_remaining_usd=3.4,
total_spendable_usd=3.4,
plan_bar=UsageBar(kind="plan", remaining_usd=3.4, total_usd=20.0, spent_usd=16.6),
)
if name == "depleted":
return UsageModel(
available=True,
status="depleted",
plan_name="Plus",
renews_at="2026-07-01",
subscription_remaining_usd=0.0,
total_spendable_usd=0.0,
plan_bar=UsageBar(kind="plan", remaining_usd=0.0, total_usd=20.0, spent_usd=20.0),
)
return None

View File

@@ -15,6 +15,7 @@ We keep them as :class:`decimal.Decimal` end-to-end and only format for display.
from __future__ import annotations
import logging
import os
import uuid
from dataclasses import dataclass, field
from decimal import Decimal, InvalidOperation
@@ -202,7 +203,15 @@ def build_billing_state(*, timeout: float = 15.0) -> BillingState:
Returns ``BillingState(logged_in=False)`` when not logged in. On a portal/HTTP
failure, returns ``logged_in=False`` with ``error`` set so the surface can show
a clear message rather than crashing.
Dev override: ``HERMES_DEV_BILLING_FIXTURE`` short-circuits to a fixture so the
card-on-file / admin / scope states are testable offline (mirrors
``HERMES_DEV_CREDITS_FIXTURE`` for the usage model).
"""
fixture = _dev_fixture_billing_state()
if fixture is not None:
return fixture
try:
from hermes_cli.nous_billing import (
BillingAuthError,
@@ -243,6 +252,68 @@ def _fallback_portal_url(base: str) -> str:
return f"{base.rstrip('/')}/billing?topup=open"
# =============================================================================
# Dev fixtures (throwaway scaffolding — env-var driven, no live portal)
# =============================================================================
def _dev_fixture_billing_state() -> Optional[BillingState]:
"""Map ``HERMES_DEV_BILLING_FIXTURE`` to a :class:`BillingState` for offline UX.
Recognized names::
nocard logged in · billing on · admin · NO card on file
card card on file · auto-reload off
card-autoreload card on file · auto-reload on
notadmin logged in · MEMBER role (billing actions disabled)
billing-off logged in · admin · per-org kill-switch OFF
logged-out not logged in
Returns ``None`` when the env var is unset (the real portal path runs).
Mirrors ``HERMES_DEV_CREDITS_FIXTURE``; the usage *bar* still comes from
``HERMES_DEV_CREDITS_FIXTURE`` (set both to pair a bar with a billing state).
"""
name = (os.getenv("HERMES_DEV_BILLING_FIXTURE") or "").strip().lower()
if not name:
return None
# Shared fixture portal host (matches subscription_view._DEV_FIXTURE_PORTAL —
# prod host, not staging; the ?topup=open suffix is the /topup deep-link).
portal = "https://portal.nousresearch.com/billing?topup=open"
common: dict[str, Any] = dict(
org_id="org_acme",
org_slug="acme",
org_name="Acme Inc",
role="OWNER",
balance_usd=Decimal("3.40"),
cli_billing_enabled=True,
charge_presets=(Decimal("10"), Decimal("25"), Decimal("50")),
min_usd=Decimal("5"),
max_usd=Decimal("500"),
portal_url=portal,
)
card = CardInfo(brand="Visa", last4="4242")
autoreload_on = AutoReload(enabled=True, threshold_usd=Decimal("5"), reload_to_usd=Decimal("25"))
if name in ("logged-out", "logged_out", "loggedout"):
return BillingState(logged_in=False)
if name == "nocard":
return BillingState(logged_in=True, card=None, **common)
if name == "card":
return BillingState(logged_in=True, card=card, **common)
if name in ("card-autoreload", "card_autoreload", "autoreload"):
return BillingState(logged_in=True, card=card, auto_reload=autoreload_on, **common)
if name in ("notadmin", "not-admin", "member"):
opts = {**common, "role": "MEMBER"}
return BillingState(logged_in=True, card=card, **opts)
if name in ("billing-off", "billing_off", "off"):
opts = {**common, "cli_billing_enabled": False}
return BillingState(logged_in=True, card=None, **opts)
# Unknown name → logged-out so the misconfiguration is visible.
return BillingState(logged_in=False, error=f"unknown HERMES_DEV_BILLING_FIXTURE: {name}")
# =============================================================================
# Idempotency
# =============================================================================

View File

@@ -83,59 +83,6 @@ _PROJECT_MARKERS = (
# Agent-instruction files surfaced separately from manifests in the snapshot.
_CONTEXT_FILES = ("AGENTS.md", "CLAUDE.md", ".cursorrules")
# Source-file extensions that make a git repo a *code* workspace even with no
# manifest. Without this, `git init` on a notes/writing/research folder (a huge
# non-coding use case) would flip the whole session into the coding posture just
# for having a `.git`. A manifest still wins on its own (see `_PROJECT_MARKERS`).
_CODE_EXTENSIONS = frozenset({
".py", ".pyi", ".ipynb", ".js", ".jsx", ".ts", ".tsx", ".mjs", ".cjs",
".go", ".rs", ".java", ".kt", ".kts", ".scala", ".rb", ".php", ".c", ".h",
".cc", ".cpp", ".hpp", ".cs", ".swift", ".m", ".mm", ".dart", ".ex", ".exs",
".lua", ".sh", ".bash", ".zsh", ".sql", ".vue", ".svelte", ".r", ".jl",
".hs", ".clj", ".erl", ".pl",
})
# Dirs never worth scanning for the code check (deps/build/vcs/venv noise).
_CODE_SCAN_SKIP_DIRS = frozenset({
".git", "node_modules", "venv", ".venv", "__pycache__", "dist", "build",
"target", ".next", ".turbo", "vendor",
})
# Bounded sweep: a code workspace reveals itself in the first handful of entries.
_CODE_SCAN_MAX_ENTRIES = 500
def _has_code_files(root: Path) -> bool:
"""Cheap, bounded check for source files in a repo's top two levels.
Lets a git repo of loose scripts (no manifest) still read as a code
workspace while a bare notes/writing repo does not. Scans the root and its
immediate subdirectories only, capped at ``_CODE_SCAN_MAX_ENTRIES`` stats —
a handful of readdirs at session start, not a full walk.
"""
seen = 0
stack = [(root, True)]
while stack:
directory, is_root = stack.pop()
try:
with os.scandir(directory) as entries:
for entry in entries:
seen += 1
if seen > _CODE_SCAN_MAX_ENTRIES:
return False
name = entry.name
try:
if entry.is_file():
if os.path.splitext(name)[1].lower() in _CODE_EXTENSIONS:
return True
elif is_root and entry.is_dir() and name not in _CODE_SCAN_SKIP_DIRS and not name.startswith("."):
stack.append((Path(entry.path), False))
except OSError:
continue
except OSError:
continue
return False
# Lockfile → package manager, checked in priority order.
_PY_LOCKFILES = (("uv.lock", "uv"), ("poetry.lock", "poetry"), ("Pipfile.lock", "pipenv"))
_JS_LOCKFILES = (
@@ -421,16 +368,10 @@ def _detect_profile_name(mode: str, platform: str, cwd_str: str) -> str:
if platform and platform.strip().lower() not in INTERACTIVE_CODING_PLATFORMS:
return GENERAL_PROFILE.name
cwd = Path(cwd_str)
# A recognized project root (manifest / AGENTS.md / .cursorrules) is a code
# workspace on its own — cheap stat checks, no scan.
if _marker_root(cwd) is not None:
return CODING_PROFILE.name
git_root = _git_root(cwd)
if git_root is not None and git_root == _home():
git_root = None # dotfiles repo at $HOME — not a code workspace
# A bare git repo only counts when it actually holds code, so `git init` on a
# notes/writing/research folder stays in the general posture.
if git_root is not None and _has_code_files(git_root):
if git_root is not None or _marker_root(cwd) is not None:
return CODING_PROFILE.name
return GENERAL_PROFILE.name

View File

@@ -890,15 +890,7 @@ class ContextCompressor(ContextEngine):
# This is independent of the abort_on_summary_failure config flag:
# rotating on a broken credential is never the right behavior.
self._last_summary_auth_failure: bool = False
# Set when summary generation ultimately fails due to a transient
# network/connection error (httpx/httpcore connection drop, premature
# stream close, etc.) — distinct from auth failures but treated the
# same way by compress(): ABORT and preserve the session unchanged
# rather than destroy the middle window for a deterministic
# "summary unavailable" marker. Retrying once the network recovers is
# strictly better than discarding context for a transient blip
# (#29559, #25585). Independent of abort_on_summary_failure.
self._last_summary_network_failure: bool = False
# When a user-configured summary model fails and we recover by
# retrying on the main model, record the failure so gateway /
# CLI callers can still warn the user even though compression
# succeeded. Silent recovery would hide the broken config.
@@ -1695,7 +1687,6 @@ This compaction should PRIORITISE preserving all information related to the focu
self._summary_model_fallen_back = False
self._last_summary_error = None
self._last_summary_auth_failure = False
self._last_summary_network_failure = False
return self._with_summary_prefix(summary)
except Exception as e:
# ``call_llm`` raises ``RuntimeError`` for two very different cases:
@@ -1828,15 +1819,6 @@ This compaction should PRIORITISE preserving all information related to the focu
if len(err_text) > 220:
err_text = err_text[:217].rstrip() + "..."
self._last_summary_error = err_text
# A terminal connection/network failure (we reach this branch only
# after any main-model fallback has already been tried or is
# unavailable). Flag it so compress() ABORTS and preserves the
# session unchanged instead of destroying the middle window for a
# placeholder marker — retrying once the network recovers is
# strictly better than dropping context (#29559, #25585). Mirrors
# the auth-failure carve-out; independent of abort_on_summary_failure.
if _is_streaming_closed:
self._last_summary_network_failure = True
logger.warning(
"Failed to generate context summary: %s. "
"Further summary attempts paused for %d seconds.",
@@ -2400,7 +2382,6 @@ This compaction should PRIORITISE preserving all information related to the focu
self._last_aux_model_failure_model = None
self._last_compress_aborted = False
self._last_summary_auth_failure = False
self._last_summary_network_failure = False
# Manual /compress (force=True) bypasses the failure cooldown so the
# user can retry immediately after an auto-compress abort. Without
@@ -2517,21 +2498,15 @@ This compaction should PRIORITISE preserving all information related to the focu
# surface a warning.
# Default is False (historical behavior).
#
# EXCEPTION — auth AND transient network failures always abort. A
# 401/403 from the summary call means the credential or endpoint is
# broken (invalid/blocked key, or a token pointed at the wrong
# inference host). A connection/stream-close error means the network
# blipped at the compaction moment (#29559). In BOTH cases rotating into
# EXCEPTION — auth failures always abort. A 401/403 from the summary
# call means the credential or endpoint is broken (invalid/blocked
# key, or a token pointed at the wrong inference host). Rotating into
# a child session with a placeholder summary on a broken credential
# strands the user on a degraded session for zero benefit — every
# subsequent call fails the same way. So when the failure was an auth
# error we abort regardless of abort_on_summary_failure, preserving
# the conversation unchanged until the credential is fixed.
if not summary and (
self.abort_on_summary_failure
or self._last_summary_auth_failure
or self._last_summary_network_failure
):
if not summary and (self.abort_on_summary_failure or self._last_summary_auth_failure):
n_skipped = compress_end - compress_start
self._last_summary_dropped_count = 0 # nothing actually dropped
self._last_summary_fallback_used = False
@@ -2546,15 +2521,6 @@ This compaction should PRIORITISE preserving all information related to the focu
"with /compress or start fresh with /new.",
n_skipped,
)
elif self._last_summary_network_failure:
logger.warning(
"Summary generation failed with a network/connection "
"error — aborting compression. %d message(s) preserved "
"unchanged; the session was NOT rotated. This is "
"transient: retry with /compress once connectivity "
"recovers, or continue the conversation as-is.",
n_skipped,
)
else:
logger.warning(
"Summary generation failed — aborting compression "

View File

@@ -90,7 +90,6 @@ def check_compression_model_feasibility(agent: Any) -> None:
try:
from agent.auxiliary_client import (
_resolve_task_provider_model,
_try_configured_fallback_for_unavailable_client,
get_text_auxiliary_client,
)
from agent.model_metadata import (
@@ -98,6 +97,10 @@ def check_compression_model_feasibility(agent: Any) -> None:
get_model_context_length,
)
client, aux_model = get_text_auxiliary_client(
"compression",
main_runtime=agent._current_main_runtime(),
)
# Best-effort aux provider label for the warning message. The
# configured provider may be "auto", in which case we fall back
# to the client's base_url hostname so the user can still tell
@@ -106,19 +109,6 @@ def check_compression_model_feasibility(agent: Any) -> None:
_aux_cfg_provider, _, _, _, _ = _resolve_task_provider_model("compression")
except Exception:
_aux_cfg_provider = ""
client, aux_model = get_text_auxiliary_client(
"compression",
main_runtime=agent._current_main_runtime(),
)
if client is None or not aux_model:
fb_client, fb_model, fb_label = _try_configured_fallback_for_unavailable_client(
"compression",
_aux_cfg_provider,
)
if fb_client is not None and fb_model:
client, aux_model = fb_client, fb_model
if "(" in fb_label and fb_label.endswith(")"):
_aux_cfg_provider = fb_label.rsplit("(", 1)[1][:-1]
if client is None or not aux_model:
if _aux_cfg_provider and _aux_cfg_provider != "auto":
msg = (

View File

@@ -35,7 +35,6 @@ from agent.turn_context import build_turn_context
from agent.turn_retry_state import TurnRetryState
from agent.memory_manager import build_memory_context_block
from agent.message_sanitization import (
close_interrupted_tool_sequence,
_repair_tool_call_arguments,
_sanitize_messages_non_ascii,
_sanitize_messages_surrogates,
@@ -56,7 +55,7 @@ from agent.model_metadata import (
)
from agent.process_bootstrap import _install_safe_stdio
from agent.prompt_caching import apply_anthropic_cache_control
from agent.retry_utils import adaptive_rate_limit_backoff, jittered_backoff
from agent.retry_utils import jittered_backoff
from agent.trajectory import has_incomplete_scratchpad
from agent.usage_pricing import estimate_usage_cost, normalize_usage
from hermes_constants import PARTIAL_STREAM_STUB_ID
@@ -502,7 +501,6 @@ def run_conversation(
stream_callback: Optional[callable] = None,
persist_user_message: Optional[str] = None,
persist_user_timestamp: Optional[float] = None,
moa_config: Optional[dict[str, Any]] = None,
) -> Dict[str, Any]:
"""
Run a complete conversation with tool calling until completion.
@@ -525,19 +523,6 @@ def run_conversation(
Returns:
Dict: Complete conversation result with final response and message history
"""
if moa_config is None:
try:
from hermes_cli.moa_config import decode_moa_turn
_decoded_message, _decoded_moa_config = decode_moa_turn(user_message)
if _decoded_moa_config is not None:
user_message = _decoded_message
moa_config = _decoded_moa_config
if persist_user_message is None:
persist_user_message = _decoded_message
except Exception:
pass
# ── Per-turn setup (the prologue) ──
# All once-per-turn setup — stdio guarding, retry-counter resets, user
# message sanitization, todo/nudge hydration, system-prompt restore-or-
@@ -816,29 +801,6 @@ def run_conversation(
if effective_system:
api_messages = [{"role": "system", "content": effective_system}] + api_messages
if moa_config:
try:
from agent.moa_loop import aggregate_moa_context
_moa_context = aggregate_moa_context(
user_prompt=original_user_message if isinstance(original_user_message, str) else str(original_user_message),
api_messages=api_messages,
reference_models=moa_config.get("reference_models") or [],
aggregator=moa_config.get("aggregator") or {},
temperature=float(moa_config.get("reference_temperature", 0.6) or 0.6),
aggregator_temperature=float(moa_config.get("aggregator_temperature", 0.4) or 0.4),
max_tokens=int(moa_config.get("max_tokens", 4096) or 4096),
)
if _moa_context:
for _msg in reversed(api_messages):
if _msg.get("role") == "user":
_base = _msg.get("content", "")
if isinstance(_base, str):
_msg["content"] = _base + "\n\n" + _moa_context
break
except Exception as _moa_exc:
logger.warning("MoA context aggregation failed: %s", _moa_exc)
# Inject ephemeral prefill messages right after the system prompt
# but before conversation history. Same API-call-time-only pattern.
if agent.prefill_messages:
@@ -1160,7 +1122,7 @@ def run_conversation(
# stream. Mirror the ACP exclusion used for Responses
# API upgrade (lines ~1083-1085).
elif (
agent.provider in {"copilot-acp", "moa"}
agent.provider == "copilot-acp"
or str(agent.base_url or "").lower().startswith("acp://copilot")
or str(agent.base_url or "").lower().startswith("acp+tcp://")
):
@@ -1434,12 +1396,10 @@ def run_conversation(
while time.time() < sleep_end:
if agent._interrupt_requested:
agent._vprint(f"{agent.log_prefix}⚡ Interrupt detected during retry wait, aborting.", force=True)
_interrupt_text = f"Operation interrupted during retry ({_failure_hint}, attempt {retry_count}/{max_retries})."
close_interrupted_tool_sequence(messages, _interrupt_text)
agent._persist_session(messages, conversation_history)
agent.clear_interrupt()
return {
"final_response": _interrupt_text,
"final_response": f"Operation interrupted during retry ({_failure_hint}, attempt {retry_count}/{max_retries}).",
"messages": messages,
"api_calls": api_call_count,
"completed": False,
@@ -2703,12 +2663,10 @@ def run_conversation(
# Check for interrupt before deciding to retry
if agent._interrupt_requested:
agent._vprint(f"{agent.log_prefix}⚡ Interrupt detected during error handling, aborting retries.", force=True)
_interrupt_text = f"Operation interrupted: handling API error ({error_type}: {agent._clean_error_message(str(api_error))})."
close_interrupted_tool_sequence(messages, _interrupt_text)
agent._persist_session(messages, conversation_history)
agent.clear_interrupt()
return {
"final_response": _interrupt_text,
"final_response": f"Operation interrupted: handling API error ({error_type}: {agent._clean_error_message(str(api_error))}).",
"messages": messages,
"api_calls": api_call_count,
"completed": False,
@@ -3579,38 +3537,16 @@ def run_conversation(
except (TypeError, ValueError):
pass
wait_time = _retry_after if _retry_after else jittered_backoff(retry_count, base_delay=2.0, max_delay=60.0)
_backoff_policy = None
if is_rate_limited and not _retry_after:
wait_time, _backoff_policy = adaptive_rate_limit_backoff(
retry_count,
base_url=str(_base),
model=_model,
error=api_error,
default_wait=wait_time,
)
if is_rate_limited:
_policy_note = ""
if _backoff_policy == "zai_coding_overload_long":
_policy_note = " (Z.AI Coding overload adaptive long backoff)"
elif _backoff_policy == "zai_coding_overload_short":
_policy_note = " (Z.AI Coding overload short retry)"
_rate_limit_status = f"⏱️ Rate limited. Waiting {wait_time:.1f}s (attempt {retry_count + 1}/{max_retries}){_policy_note}..."
# Normal retries are buffered to avoid noisy transient chatter. Long
# Z.AI Coding waits are different: they can last minutes, so surface
# progress immediately instead of making the TUI look frozen.
if _backoff_policy == "zai_coding_overload_long":
agent._emit_status(_rate_limit_status)
else:
agent._buffer_status(_rate_limit_status)
agent._buffer_status(f"⏱️ Rate limited. Waiting {wait_time:.1f}s (attempt {retry_count + 1}/{max_retries})...")
else:
agent._buffer_status(f"⏳ Retrying in {wait_time:.1f}s (attempt {retry_count}/{max_retries})...")
logger.warning(
"Retrying API call in %ss (attempt %s/%s) %s policy=%s error=%s",
"Retrying API call in %ss (attempt %s/%s) %s error=%s",
wait_time,
retry_count,
max_retries,
agent._client_log_context(),
_backoff_policy or "default",
api_error,
)
# Sleep in small increments so we can respond to interrupts quickly
@@ -3620,12 +3556,10 @@ def run_conversation(
while time.time() < sleep_end:
if agent._interrupt_requested:
agent._vprint(f"{agent.log_prefix}⚡ Interrupt detected during retry wait, aborting.", force=True)
_interrupt_text = f"Operation interrupted: retrying API call after error (retry {retry_count}/{max_retries})."
close_interrupted_tool_sequence(messages, _interrupt_text)
agent._persist_session(messages, conversation_history)
agent.clear_interrupt()
return {
"final_response": _interrupt_text,
"final_response": f"Operation interrupted: retrying API call after error (retry {retry_count}/{max_retries}).",
"messages": messages,
"api_calls": api_call_count,
"completed": False,
@@ -4116,19 +4050,6 @@ def run_conversation(
messages.append(assistant_msg)
agent._emit_interim_assistant_message(assistant_msg)
try:
# Persist the assistant tool-call turn before any tool
# side effects run. If a destructive tool restarts or
# terminates Hermes mid-turn, resume logic still sees the
# exact tool-call block that already executed.
agent._flush_messages_to_session_db(messages, conversation_history)
except Exception as exc:
logger.warning(
"Incremental tool-call persistence failed before execution "
"(session=%s): %s",
agent.session_id or "none",
exc,
)
# Close any open streaming display (response box, reasoning
# box) before tool execution begins. Intermediate turns may
@@ -4558,10 +4479,9 @@ def run_conversation(
final_msg = agent._build_assistant_message(assistant_message, finish_reason)
# Pop thinking-only prefill and empty-response retry
# scaffolding before appending either a final response or a
# verification-stop follow-up. These internal turns are only
# for the next API retry and should not become durable
# transcript context.
# scaffolding before appending the final response. These
# internal turns are only for the next API retry and should
# not become durable transcript context.
while (
messages
and isinstance(messages[-1], dict)
@@ -4573,44 +4493,6 @@ def run_conversation(
):
messages.pop()
try:
from agent.verification_stop import (
build_verify_on_stop_nudge,
verify_on_stop_enabled,
)
if verify_on_stop_enabled():
_verify_nudge = build_verify_on_stop_nudge(
session_id=getattr(agent, "session_id", None),
changed_paths=getattr(agent, "_turn_file_mutation_paths", set()),
attempts=getattr(agent, "_verification_stop_nudges", 0),
)
else:
_verify_nudge = None
except Exception:
logger.debug("verification stop-loop check failed", exc_info=True)
_verify_nudge = None
if _verify_nudge:
agent._verification_stop_nudges = (
getattr(agent, "_verification_stop_nudges", 0) + 1
)
final_msg["finish_reason"] = "verification_required"
messages.append(final_msg)
# Keep the attempted final answer in model history so the
# synthetic user nudge preserves role alternation, but do
# not surface it to the user as an interim answer. The
# whole point of this guard is to prevent premature
# "done" claims before checks run.
messages.append({
"role": "user",
"content": _verify_nudge,
"_verification_stop_synthetic": True,
})
agent._session_messages = messages
agent._emit_status("↻ Verification required before finishing")
continue
messages.append(final_msg)
_turn_exit_reason = f"text_response(finish_reason={finish_reason})"

View File

@@ -355,7 +355,7 @@ def evaluate_credits_notices(
if show_depleted and "credits.depleted" not in active:
to_show.append(
AgentNotice(
text="✕ Credit access paused · run /credits to top up",
text="✕ Credit access paused · run /topup to top up",
level="error",
kind=CREDITS_NOTICE_KIND,
key="credits.depleted",

View File

@@ -6,7 +6,6 @@ Used by AIAgent._execute_tool_calls for CLI feedback.
import logging
import os
import re
import sys
import threading
import time
@@ -178,167 +177,6 @@ def _truncate_preview(text: str, max_len: int | None) -> str:
return text
_SHELL_SILENT_HEADS = {"cd", "pushd", "popd", "export", "set", "unset", "source", ".", "true", "false", ":"}
_SHELL_PIPE_TAIL_HEADS = {"head", "tail", "wc", "sort", "uniq"}
def _shell_basename(head: str) -> str:
return head.rsplit("/", 1)[-1] if head else ""
def _split_shell_words(segment: str) -> list[str]:
words: list[str] = []
buf: list[str] = []
quote: str | None = None
for i, ch in enumerate(segment):
if quote:
buf.append(ch)
if ch == quote and (i == 0 or segment[i - 1] != "\\"):
quote = None
continue
if ch in {"'", '"'}:
quote = ch
buf.append(ch)
continue
if ch.isspace():
if buf:
words.append("".join(buf))
buf = []
continue
buf.append(ch)
if buf:
words.append("".join(buf))
return words
def _strip_shell_pipe_tail(segment: str) -> str:
words = _split_shell_words(segment)
out: list[str] = []
for i, word in enumerate(words):
if word == "|" and _shell_basename(words[i + 1] if i + 1 < len(words) else "") in _SHELL_PIPE_TAIL_HEADS:
break
out.append(word)
return " ".join(out).strip()
def _split_shell_compound(command: str) -> list[str]:
segments: list[str] = []
buf: list[str] = []
quote: str | None = None
i = 0
while i < len(command):
ch = command[i]
if quote:
buf.append(ch)
if ch == quote and (i == 0 or command[i - 1] != "\\"):
quote = None
i += 1
continue
if ch in {"'", '"'}:
quote = ch
buf.append(ch)
i += 1
continue
op_len = 2 if command.startswith("&&", i) or command.startswith("||", i) else 1 if ch in {";", "\n"} else 0
if op_len:
segment = _strip_shell_pipe_tail("".join(buf).strip())
if segment:
segments.append(segment)
buf = []
i += op_len
continue
buf.append(ch)
i += 1
segment = _strip_shell_pipe_tail("".join(buf).strip())
if segment:
segments.append(segment)
return segments
def _shell_head_word(segment: str) -> str:
words = _split_shell_words(segment)
index = 0
while index < len(words) and re.match(r"^[A-Za-z_]\w*=", words[index]):
index += 1
return _shell_basename(words[index] if index < len(words) else "")
def _clean_shell_segment(segment: str) -> str:
words = _split_shell_words(segment)
out: list[str] = []
i = 0
while i < len(words):
word = words[i]
if re.match(r"^\d*(?:>>?|<)$", word):
i += 2
continue
if re.match(r"^\d*(?:>&|<&)\d+$", word) or re.match(r"^\d*>&\d+$", word):
i += 1
continue
out.append(word)
i += 1
return " ".join(out).strip()
def _is_shell_boundary_echo(segment: str) -> bool:
words = _split_shell_words(segment)
if _shell_basename(words[0] if words else "") != "echo":
return False
rest = " ".join(words[1:])
return bool(re.search(r"-{2,}|_exit=|(?:^|\s|=)\$[?{]|PIPESTATUS", rest))
def summarize_shell_command(command: str) -> str:
"""Compact shell wrapper/plumbing for display while preserving raw command elsewhere."""
original = _oneline(command)
if not original:
return ""
segments = _split_shell_compound(original)
if len(segments) <= 1:
return _clean_shell_segment(segments[0] if segments else original) or original
core: list[str] = []
for segment in segments:
cleaned = _clean_shell_segment(segment)
head = _shell_head_word(cleaned)
if cleaned and head not in _SHELL_SILENT_HEADS and not _is_shell_boundary_echo(cleaned):
core.append(cleaned)
if not core:
return original
if len(core) == 1:
return core[0]
count = len(core) - 1
return f"{core[0]} + {count} {'command' if count == 1 else 'commands'}"
def _read_file_line_label(args: dict) -> str:
offset = args.get("offset")
limit = args.get("limit")
if not isinstance(offset, int) or offset <= 0:
return ""
if not isinstance(limit, int) or limit <= 1:
return f"L{offset}"
return f"L{offset}-{offset + limit - 1}"
def _delegate_task_goal_parts(tasks: Any, *, per_goal_len: int) -> tuple[int, list[str]]:
if not isinstance(tasks, list):
return 0, []
@@ -368,7 +206,7 @@ def build_tool_preview(tool_name: str, args: dict, max_len: int | None = None) -
"search_files": "pattern", "browser_navigate": "url",
"browser_click": "ref", "browser_type": "text",
"image_generate": "prompt", "text_to_speech": "text",
"vision_analyze": "question",
"vision_analyze": "question", "mixture_of_agents": "user_prompt",
"skill_view": "name", "skills_list": "category",
"cronjob": "action",
"execute_code": "code", "delegate_task": "goal",
@@ -415,23 +253,6 @@ def build_tool_preview(tool_name: str, args: dict, max_len: int | None = None) -
else:
return f"planning {len(todos_arg)} task(s)"
if tool_name in {"terminal", "execute_code"}:
key = "code" if tool_name == "execute_code" else "command"
command = args.get(key)
if command is None:
return None
preview = summarize_shell_command(str(command))
return _truncate_preview(preview, max_len) if preview else None
if tool_name == "read_file":
path = args.get("path") or args.get("file") or args.get("filepath")
if path is None:
return None
label = Path(str(path).replace("\\", "/")).name or str(path)
line_label = _read_file_line_label(args)
preview = f"{label} {line_label}".strip()
return _truncate_preview(preview, max_len) if preview else None
if tool_name == "session_search":
query = _oneline(args.get("query", ""))
return f"recall: \"{query[:25]}{'...' if len(query) > 25 else ''}\""
@@ -1122,7 +943,7 @@ def get_cute_tool_message(
return _wrap(f"┊ 📄 fetch {_trunc(domain, 35)}{extra} {dur}")
return _wrap(f"┊ 📄 fetch pages {dur}")
if tool_name == "terminal":
return _wrap(f"┊ 💻 $ {_trunc(build_tool_preview(tool_name, args) or args.get('command', ''), 42)} {dur}")
return _wrap(f"┊ 💻 $ {_trunc(args.get('command', ''), 42)} {dur}")
if tool_name == "process":
action = args.get("action", "?")
sid = args.get("session_id", "")[:12]
@@ -1130,7 +951,7 @@ def get_cute_tool_message(
"wait": f"wait {sid}", "kill": f"kill {sid}", "write": f"write {sid}", "submit": f"submit {sid}"}
return _wrap(f"┊ ⚙️ proc {labels.get(action, f'{action} {sid}')} {dur}")
if tool_name == "read_file":
return _wrap(f"┊ 📖 read {_trunc(build_tool_preview(tool_name, args) or args.get('path', ''), 42)} {dur}")
return _wrap(f"┊ 📖 read {_path(args.get('path', ''))} {dur}")
if tool_name == "write_file":
return _wrap(f"┊ ✍️ write {_path(args.get('path', ''))} {dur}")
if tool_name == "patch":
@@ -1216,6 +1037,8 @@ def get_cute_tool_message(
return _wrap(f"┊ 🔊 speak {_trunc(args.get('text', ''), 30)} {dur}")
if tool_name == "vision_analyze":
return _wrap(f"┊ 👁️ vision {_trunc(args.get('question', ''), 30)} {dur}")
if tool_name == "mixture_of_agents":
return _wrap(f"┊ 🧠 reason {_trunc(args.get('user_prompt', ''), 30)} {dur}")
if tool_name == "send_message":
return _wrap(f"┊ 📨 send {args.get('target', '?')}: \"{_trunc(args.get('message', ''), 25)}\" {dur}")
if tool_name == "cronjob":

View File

@@ -1,136 +0,0 @@
#!/usr/bin/env python3
"""``/learn`` — build the standards-guided prompt that turns whatever the user
described into a reusable skill.
``/learn`` is open-ended. The user can point it at anything they can describe:
a directory of code, an API doc URL, a workflow they just walked the agent
through in this conversation, or pasted notes. This module builds ONE prompt
that instructs the live agent to:
1. Gather the sources the user named, using the tools it already has
(``read_file`` / ``search_files`` for dirs, ``web_extract`` for URLs, the
current conversation for "what I just did", the user's text for pasted
material).
2. Author a single ``SKILL.md`` via ``skill_manage`` that follows the Hermes
skill-authoring standards (description <=60 chars, the modern section
order, Hermes-tool framing, no invented commands).
There is no separate distillation engine and no model-tool footprint: the
agent does the work with its existing toolset, so this works identically on
local, Docker, and remote terminal backends. Every surface (CLI ``/learn``,
gateway ``/learn``, the dashboard "Learn a skill" panel) calls
:func:`build_learn_prompt` and feeds the result to the agent as a normal turn.
"""
from __future__ import annotations
# The house-style rules, distilled from AGENTS.md "Skill authoring standards
# (HARDLINE)" and the hermes-agent-dev new-skill salvage reference. Embedded in
# the prompt so the agent authors skills the way a maintainer would by hand.
_AUTHORING_STANDARDS = """\
Follow the Hermes skill-authoring standards exactly. These are the same
HARDLINE rules a maintainer enforces in review:
Frontmatter:
- name: lowercase-hyphenated, <=64 chars, no spaces.
- description: ONE sentence, **<=60 characters**, ends with a period. State the
capability, not the implementation. No marketing words (powerful,
comprehensive, seamless, advanced, robust). Do NOT repeat the skill name. If
the description contains a colon, wrap the whole value in double quotes.
This is the most-violated rule and it is NOT cosmetic: the system-prompt
skill index truncates the description to 60 chars and loads it every
session, so anything past char 60 is silently cut and never routes. After
you write the description, COUNT the characters; if it is over 60, cut it
down before saving — do not ship a sentence and hope.
Good (<=60): `Search arXiv papers by keyword, author, or ID.`
Bad (123): `A comprehensive skill that lets the agent search arXiv for
academic papers using keywords, authors, and categories.`
- version: 0.1.0
- author: always the literal value `Hermes`. NEVER fill it from the host
environment — the OS/login username (e.g. the `user=` line in your
environment hints), git config, or any identity you can probe must not be
written. Skills get shared and published, so an environment-derived name is
a privacy leak the user never opted into; the skill names itself as Hermes.
- platforms: declare `[macos]`, `[linux]`, and/or `[windows]` IF the skill
uses OS-bound primitives (osascript/apt/systemctl => the matching OS; /proc,
os.setsid, signal.SIGKILL => linux; fcntl/termios => POSIX). Prefer fixing it
cross-platform first (tempfile.gettempdir(), pathlib.Path, psutil); gate only
when the dependency is genuinely platform-bound. Omit the field for portable
skills.
- metadata.hermes.tags: a few Capitalized, Relevant, Tags.
Body section order (omit a section only if it genuinely has no content):
1. "# <Human Title>" then a 2-3 sentence intro: what it does, what it does NOT
do, and the key dependency stance (e.g. "stdlib only").
2. "## When to Use" — bullet list of concrete trigger phrases.
3. "## Prerequisites" — exact env vars, install steps, credentials.
4. "## How to Run" — the canonical invocation, framed through Hermes tools.
5. "## Quick Reference" — a flat command/endpoint list, no narration.
6. "## Procedure" — numbered steps with copy-paste-exact commands.
7. "## Pitfalls" — known limits, rate limits, things that look broken but aren't.
8. "## Verification" — a single command/check that proves the skill worked.
Hermes-tool framing (this is what makes it a skill, not shell docs):
- Frame running scripts as "invoke through the `terminal` tool".
- Reference Hermes tools by name in backticks: `terminal`, `read_file`,
`write_file`, `search_files`, `patch`, `web_extract`, `web_search`,
`vision_analyze`, `browser_navigate`, `delegate_task`, `image_generate`,
`text_to_speech`, `cronjob`, `memory`, `skill_view`, `execute_code`.
- Do NOT name shell utilities the agent already has wrapped: say `read_file`
not cat/head/tail, `search_files` not grep/rg/find/ls, `patch` not sed/awk,
`web_extract` not curl-to-scrape, `write_file` not echo>file or heredocs.
- Third-party CLIs (ffmpeg, gh, an SDK) are fine inside a script file, but the
prose still frames them as "invoke through the `terminal` tool". If the
skill needs an MCP server, name it and document its setup in Prerequisites.
Quality bar:
- Prefer exact commands, endpoint URLs, function signatures, and config keys
that appear VERBATIM in the source. NEVER invent flags, paths, or APIs — if
you didn't see it in the source, don't write it.
- Keep it tight and scannable: ~100 lines for a simple skill, ~200 for a
complex one. Don't re-paste the source docs.
- Don't write a router/index/hub skill that only points at other skills.
- Larger scripts/parsers belong in a `scripts/` file (add via
`skill_manage` write_file), referenced from SKILL.md by relative path — not
inlined for the agent to re-type every run. References go in `references/`,
templates in `templates/`."""
def build_learn_prompt(user_request: str) -> str:
"""Build the agent prompt for an open-ended ``/learn`` request.
Args:
user_request: the free-text the user gave after ``/learn`` — a
description of the workflow, paths, URLs, or "what I just did".
Returns:
A complete instruction the agent runs as a normal turn. The agent
gathers the described sources with its existing tools and authors the
skill via ``skill_manage``.
"""
req = (user_request or "").strip()
if not req:
req = (
"the workflow we just went through in this conversation — review "
"the steps taken and distill them into a reusable skill"
)
return (
"[/learn] The user wants you to learn a reusable skill from the "
"source(s) they described below, and save it.\n\n"
f"WHAT TO LEARN FROM:\n{req}\n\n"
"Do this:\n"
"1. Gather the material. Resolve whatever the user named using the "
"tools you already have — `read_file`/`search_files` for local files "
"or directories, `web_extract` for URLs, the current conversation "
"history if they referred to something you just did, and the text "
"they pasted as-is. If the request is ambiguous about scope, make a "
"reasonable choice and note it; do not stall.\n"
"2. Author ONE SKILL.md and save it with the `skill_manage` tool "
"(action=\"create\"). Pick a sensible category. If the procedure needs "
"a non-trivial script, add it under the skill's `scripts/` with "
"`skill_manage` write_file and reference it by relative path.\n\n"
f"{_AUTHORING_STANDARDS}\n\n"
"When done, tell the user the skill name, its category, and a "
"one-line summary of what it captured."
)

View File

@@ -46,39 +46,6 @@ logger = logging.getLogger(__name__)
_SYNC_DRAIN_TIMEOUT_S = 5.0
def normalize_tool_schema(schema: Any) -> Optional[Dict[str, Any]]:
"""Return a function-tool dict with a resolvable top-level ``name``.
Context engines and memory providers expose tool schemas via
``get_tool_schemas()``. The expected shape is a bare function schema
(``{"name": ..., "description": ..., "parameters": ...}``) which callers
wrap as ``{"type": "function", "function": schema}``.
Some providers instead return an entry that is *already* in OpenAI tool
form (``{"type": "function", "function": {"name": ...}}``). Wrapping that
a second time produces ``{"type": "function", "function": {"type":
"function", "function": {...}}}`` whose ``function`` has no top-level
``name``. Strict providers (e.g. DeepSeek) reject the *entire* request
with ``tools[N].function: missing field name`` (HTTP 400), so one bad
schema disables the whole toolset and breaks every turn (#47707).
This helper normalizes both shapes to the bare function schema and
returns ``None`` for anything without a resolvable name, so callers can
skip-with-warning rather than appending a nameless tool.
"""
if not isinstance(schema, dict):
return None
# Unwrap an already-wrapped OpenAI tool entry.
if schema.get("type") == "function" and isinstance(schema.get("function"), dict):
schema = schema["function"]
if not isinstance(schema, dict):
return None
name = schema.get("name", "")
if not name or not isinstance(name, str):
return None
return schema
def memory_provider_tools_enabled(enabled_toolsets: Optional[List[str]]) -> bool:
"""Return whether external memory-provider tools should be exposed."""
if enabled_toolsets is None:
@@ -125,17 +92,11 @@ def inject_memory_provider_tools(agent: Any) -> int:
agent.valid_tool_names = valid_tool_names
added = 0
for raw_schema in get_schemas():
schema = normalize_tool_schema(raw_schema)
if schema is None:
logger.warning(
"Memory provider returned a tool schema with no resolvable "
"name; skipping to avoid poisoning the request (%r)",
raw_schema,
)
for schema in get_schemas():
if not isinstance(schema, dict):
continue
tool_name = schema["name"]
if tool_name in existing_tool_names:
tool_name = schema.get("name", "")
if not tool_name or tool_name in existing_tool_names:
continue
tools.append({"type": "function", "function": schema})
valid_tool_names.add(tool_name)
@@ -409,11 +370,8 @@ class MemoryManager:
_core_tool_names = set(_HERMES_CORE_TOOLS)
# Index tool names → provider for routing
for raw_schema in provider.get_tool_schemas():
schema = normalize_tool_schema(raw_schema)
if schema is None:
continue
tool_name = schema["name"]
for schema in provider.get_tool_schemas():
tool_name = schema.get("name", "")
if tool_name in _core_tool_names:
logger.warning(
"Memory provider '%s' tool '%s' shadows a reserved core "
@@ -700,19 +658,11 @@ class MemoryManager:
seen = set()
for provider in self._providers:
try:
for raw_schema in provider.get_tool_schemas():
schema = normalize_tool_schema(raw_schema)
if schema is None:
logger.warning(
"Memory provider '%s' returned a tool schema with "
"no resolvable name; skipping (%r)",
provider.name, raw_schema,
)
continue
name = schema["name"]
for schema in provider.get_tool_schemas():
name = schema.get("name", "")
if name in _core_tool_names:
continue
if name not in seen:
if name and name not in seen:
schemas.append(schema)
seen.add(name)
except Exception as e:

View File

@@ -279,38 +279,6 @@ def _repair_tool_call_arguments(raw_args: str, tool_name: str = "?") -> str:
return "{}"
def close_interrupted_tool_sequence(messages: list, final_response: Any = None) -> bool:
"""Append a synthetic assistant turn when an interrupted tail is a tool result.
A turn cut short by ``/stop`` can leave the transcript ending on a raw
``tool`` message (a tool finished, or its execution was cancelled, but the
model never streamed a closing assistant turn). Persisting that tail means
the next user message lands as ``… tool → user`` — a role-alternation
violation that strict providers (Gemini, Claude) react to by hallucinating
a continuation of the user's message and ignoring prior context, which
reads to the user as "lost context" (#48879).
``finalize_turn`` closes this on the happy interrupt path, but the
retry/backoff/error interrupt aborts in ``conversation_loop`` ``return``
early and never reach it — this shared helper closes the sequence on all of
them. ``final_response`` is usually empty on an interrupt, so an explicit
placeholder is used rather than an empty-content assistant turn.
Mutates ``messages`` in place. Returns True if a closing turn was appended.
"""
if not messages:
return False
last = messages[-1]
if not isinstance(last, dict) or last.get("role") != "tool":
return False
text = final_response if isinstance(final_response, str) else ""
messages.append({
"role": "assistant",
"content": text.strip() or "Operation interrupted.",
})
return True
def _strip_non_ascii(text: str) -> str:
"""Remove non-ASCII characters, replacing with closest ASCII equivalent or removing.
@@ -463,7 +431,6 @@ def _sanitize_structure_non_ascii(payload: Any) -> bool:
__all__ = [
"_SURROGATE_RE",
"close_interrupted_tool_sequence",
"_sanitize_surrogates",
"_sanitize_structure_surrogates",
"_sanitize_messages_surrogates",

View File

@@ -1,306 +0,0 @@
"""Mixture-of-Agents runtime helpers for /moa turns.
The slash command is deliberately not a model tool. It marks one user turn as
MoA-enabled; the normal Hermes agent loop still owns tool calling and turn
termination, while this module gathers reference-model context before each model
iteration.
"""
from __future__ import annotations
import logging
from concurrent.futures import ThreadPoolExecutor
from typing import Any
from agent.auxiliary_client import call_llm
from agent.transports import get_transport
logger = logging.getLogger(__name__)
# Upper bound on concurrent reference-model calls. References are independent
# advisory calls (no tools, no inter-dependence), so we fan them out the same
# way delegate_task runs a batch: all in flight at once, results collected when
# every reference finishes. Presets rarely list more than a handful of
# references; this cap just protects against a pathologically large preset
# opening dozens of sockets at once.
_MAX_REFERENCE_WORKERS = 8
def _slot_label(slot: dict[str, str]) -> str:
return f"{slot.get('provider', '').strip()}:{slot.get('model', '').strip()}"
def _run_reference(
slot: dict[str, str],
ref_messages: list[dict[str, Any]],
*,
temperature: float,
max_tokens: int,
) -> tuple[str, str]:
"""Call one reference model and return ``(label, text)``.
Never raises: a failed reference becomes a labelled note so the aggregator
can still act with partial context. Designed to run inside a thread pool —
``call_llm`` is synchronous/blocking, so threads (not asyncio) are the right
concurrency primitive, mirroring ``delegate_task``'s batch fan-out.
"""
label = _slot_label(slot)
try:
response = call_llm(
task="moa_reference",
provider=slot["provider"],
model=slot["model"],
messages=ref_messages,
temperature=temperature,
max_tokens=max_tokens,
)
return label, _extract_text(response) or "(empty response)"
except Exception as exc:
logger.warning("MoA reference model %s failed: %s", label, exc)
return label, f"[failed: {exc}]"
def _run_references_parallel(
reference_models: list[dict[str, str]],
ref_messages: list[dict[str, Any]],
*,
temperature: float,
max_tokens: int,
) -> list[tuple[str, str]]:
"""Fan out all reference models in parallel, returning outputs in order.
Like ``delegate_task``'s batch mode, every reference is dispatched at once
and we block until all of them finish before handing the joined results to
the aggregator. Output order matches ``reference_models`` so the
``Reference {idx}`` labelling stays stable. MoA presets that reference
another MoA preset are skipped here (recursion guard) with a labelled note.
"""
if not reference_models:
return []
results: list[tuple[str, str] | None] = [None] * len(reference_models)
futures = {}
workers = min(_MAX_REFERENCE_WORKERS, len(reference_models))
with ThreadPoolExecutor(max_workers=workers) as executor:
for idx, slot in enumerate(reference_models):
if slot.get("provider") == "moa":
results[idx] = (
_slot_label(slot),
"[skipped: MoA presets cannot recursively reference MoA]",
)
continue
futures[
executor.submit(
_run_reference,
slot,
ref_messages,
temperature=temperature,
max_tokens=max_tokens,
)
] = idx
# Collect every reference before returning — the aggregator needs the
# complete set, so there is no early-exit / first-completed path here.
for future, idx in futures.items():
results[idx] = future.result()
return [r for r in results if r is not None]
def _reference_messages(messages: list[dict[str, Any]]) -> list[dict[str, Any]]:
"""Build an advisory-safe view of the conversation for reference models.
Reference calls are advisory: they never call tools and never emit the
``tool_calls`` the main model did. Replaying the full transcript verbatim
(a) re-bills the ~8K-token Hermes system prompt per reference per
iteration and (b) risks 400s from strict providers (Mistral, Fireworks)
that reject orphan ``tool`` messages or ``tool_calls`` the reference never
produced. We keep only the user/assistant *text* turns, dropping the
system prompt, any ``tool``-role messages, and any ``tool_calls`` payloads.
"""
trimmed: list[dict[str, Any]] = []
for msg in messages:
role = msg.get("role")
if role not in ("user", "assistant"):
# Drop system prompt and tool-result messages.
continue
content = msg.get("content")
if not isinstance(content, str):
# Skip non-text (multimodal/tool-call-only) assistant turns.
if not content:
continue
text = content if isinstance(content, str) else ""
if role == "assistant" and not text.strip():
# Assistant turn that was purely tool calls — nothing advisory.
continue
trimmed.append({"role": role, "content": text})
if not trimmed:
# Degenerate case (e.g. first turn was stripped): fall back to a
# minimal user turn so the reference still has something to answer.
for msg in reversed(messages):
if msg.get("role") == "user" and isinstance(msg.get("content"), str):
return [{"role": "user", "content": msg["content"]}]
return trimmed
def _extract_text(response: Any) -> str:
try:
transport = get_transport("chat_completions")
if transport is None:
raise RuntimeError("chat_completions transport unavailable")
normalized = transport.normalize_response(response)
text = (normalized.content or "").strip()
if text:
return text
except Exception:
pass
try:
content = response.choices[0].message.content
return (content or "").strip()
except Exception:
return ""
def aggregate_moa_context(
*,
user_prompt: str,
api_messages: list[dict[str, Any]],
reference_models: list[dict[str, str]],
aggregator: dict[str, str],
temperature: float = 0.6,
aggregator_temperature: float = 0.4,
max_tokens: int = 4096,
) -> str:
"""Run configured reference models and synthesize their advice.
Failures are returned as model-specific notes instead of aborting the normal
agent loop; the main model can still act with partial context.
"""
reference_outputs: list[tuple[str, str]] = []
ref_messages = _reference_messages(api_messages)
reference_outputs = _run_references_parallel(
reference_models,
ref_messages,
temperature=temperature,
max_tokens=max_tokens,
)
joined = "\n\n".join(
f"Reference {idx}{label}:\n{text}"
for idx, (label, text) in enumerate(reference_outputs, start=1)
)
synth_prompt = (
"You are the aggregator in a Mixture of Agents process. Synthesize the "
"reference responses into concise, actionable guidance for the main "
"Hermes agent. Focus on next steps, tool-use strategy, risks, and any "
"disagreements. Do not answer the user directly unless that is all that "
"is needed; produce context the main agent should use in its normal loop.\n\n"
f"Original user prompt:\n{user_prompt}\n\n"
f"Reference responses:\n{joined}"
)
agg_label = _slot_label(aggregator)
try:
response = call_llm(
task="moa_aggregator",
provider=aggregator["provider"],
model=aggregator["model"],
messages=[{"role": "user", "content": synth_prompt}],
temperature=aggregator_temperature,
max_tokens=max_tokens,
)
synthesis = _extract_text(response)
except Exception as exc:
logger.warning("MoA aggregator model %s failed: %s", agg_label, exc)
synthesis = ""
if not synthesis:
synthesis = joined
return (
"[Mixture of Agents context — use this as private guidance for the "
"normal Hermes agent loop. You may call tools, continue reasoning, or "
"finish normally.]\n"
f"Aggregator: {agg_label}\n"
f"References: {', '.join(_slot_label(slot) for slot in reference_models)}\n\n"
f"{synthesis.strip()}"
)
class MoAChatCompletions:
"""OpenAI-chat-compatible facade where the aggregator is the acting model."""
def __init__(self, preset_name: str):
self.preset_name = preset_name or "default"
def create(self, **api_kwargs: Any) -> Any:
from hermes_cli.config import load_config
from hermes_cli.moa_config import resolve_moa_preset
preset = resolve_moa_preset(load_config().get("moa") or {}, self.preset_name)
messages = list(api_kwargs.get("messages") or [])
reference_models = preset.get("reference_models") or []
aggregator = preset.get("aggregator") or {}
max_tokens = int(preset.get("max_tokens", api_kwargs.get("max_tokens") or 4096) or 4096)
temperature = float(preset.get("reference_temperature", 0.6) or 0.6)
aggregator_temperature = float(preset.get("aggregator_temperature", api_kwargs.get("temperature") or 0.4) or 0.4)
# When the preset is disabled, skip the reference fan-out and let the
# configured aggregator act alone — it is the preset's acting model, so
# a disabled MoA preset is simply "use the aggregator directly."
if not preset.get("enabled", True):
reference_models = []
reference_outputs: list[tuple[str, str]] = []
ref_messages = _reference_messages(messages)
reference_outputs = _run_references_parallel(
reference_models,
ref_messages,
temperature=temperature,
max_tokens=max_tokens,
)
agg_messages = [dict(m) for m in messages]
if reference_outputs:
joined = "\n\n".join(
f"Reference {idx}{label}:\n{text}"
for idx, (label, text) in enumerate(reference_outputs, start=1)
)
guidance = (
"[Mixture of Agents reference context]\n"
f"Preset: {self.preset_name}\n"
f"Aggregator/acting model: {_slot_label(aggregator)}\n"
f"References: {', '.join(label for label, _ in reference_outputs)}\n\n"
"Use the reference responses below as private context. You are the aggregator and acting model: "
"answer the user directly or call tools as needed.\n\n"
f"{joined}"
)
for msg in reversed(agg_messages):
if msg.get("role") == "user" and isinstance(msg.get("content"), str):
msg["content"] = msg["content"] + "\n\n" + guidance
break
else:
agg_messages.append({"role": "user", "content": guidance})
if aggregator.get("provider") == "moa":
raise RuntimeError("MoA aggregator cannot be another MoA preset")
agg_kwargs = dict(api_kwargs)
agg_kwargs["messages"] = agg_messages
agg_kwargs["model"] = aggregator.get("model")
agg_kwargs["temperature"] = aggregator_temperature
return call_llm(
task="moa_aggregator",
provider=aggregator.get("provider"),
model=aggregator.get("model"),
messages=agg_messages,
temperature=aggregator_temperature,
max_tokens=agg_kwargs.get("max_tokens"),
tools=agg_kwargs.get("tools"),
extra_body=agg_kwargs.get("extra_body"),
)
class MoAClient:
def __init__(self, preset_name: str):
self.chat = type("_MoAChat", (), {})()
self.chat.completions = MoAChatCompletions(preset_name)

View File

@@ -1,51 +0,0 @@
"""Petdex pet engine — shared core for the CLI, TUI, and desktop surfaces.
Petdex (https://github.com/crafter-station/petdex) is a public gallery of
animated sprite "pets" for coding agents. Each pet is a ``pet.json`` plus a
``spritesheet.{webp,png}`` of 192×208 px cells. Current Codex/petdex sheets use
an 8-column × 9-row atlas; older Hermes/petdex sheets used an 8-row atlas.
Hermes infers the row taxonomy from the sheet and maps agent activity onto
idle/run/review/failed/wave/jump.
This package is the **single source of truth** for the feature so the base
CLI (Python) and TUI (Ink, via ``tui_gateway``) never duplicate the hard
parts:
- :mod:`agent.pet.constants` — frame geometry + the :class:`PetState` enum.
- :mod:`agent.pet.state` — map agent activity → a :class:`PetState`.
- :mod:`agent.pet.manifest` — fetch the public petdex manifest.
- :mod:`agent.pet.store` — install / list / resolve pets on disk
(profile-aware via ``get_hermes_home()``).
- :mod:`agent.pet.render` — decode a spritesheet and encode frames for a
terminal (kitty / iTerm2 / sixel graphics
protocols, with a Unicode half-block
fallback).
Rendering in the Electron desktop is necessarily TypeScript (canvas), but it
reuses the same on-disk store and the same state semantics.
The whole feature is a *display* concern: it adds no model tool, mutates no
system prompt or toolset, and therefore has zero effect on prompt caching.
"""
from agent.pet.constants import (
DEFAULT_SCALE,
FRAME_H,
FRAME_W,
FRAMES_PER_STATE,
LOOP_MS,
STATE_ROWS,
PetState,
)
from agent.pet.state import derive_pet_state
__all__ = [
"DEFAULT_SCALE",
"FRAME_H",
"FRAME_W",
"FRAMES_PER_STATE",
"LOOP_MS",
"STATE_ROWS",
"PetState",
"derive_pet_state",
]

View File

@@ -1,167 +0,0 @@
"""Pet sprite geometry + animation-state taxonomy.
These values are the common petdex/Codex pet geometry. The real ``pet.json``
usually only carries ``id``/``displayName``/``description``/``spritesheetPath``;
row taxonomy is inferred from the atlas shape so Hermes can render both legacy
8-row sheets and current 9-row Codex sheets.
"""
from __future__ import annotations
from enum import Enum
# Frame geometry (pixels). Current Codex/petdex spritesheets are 8 columns x 9
# rows (1536x1872), while older Hermes/petdex sheets used 9 columns x 8 rows
# (1728x1664). Renderers derive both row taxonomy and real column count from the
# concrete sheet, so either shape works.
FRAME_W = 192
FRAME_H = 208
# Frames consumed per animation state (the petdex web app uses CSS
# ``steps(6)``). A sheet may physically contain more columns; we only step
# through the first ``FRAMES_PER_STATE``.
FRAMES_PER_STATE = 6
# Full-loop duration for one state, milliseconds (petdex default).
LOOP_MS = 1100
# Default on-screen scale relative to native frame size. ``display.pet.scale``
# is the single master scalar: the desktop canvas multiplies its native pixels
# by it and every terminal surface derives its half-block/kitty column width
# from it (see :func:`cols_for_scale`), so one number shrinks all three
# interfaces together. (petdex's own clients render at 0.7; we default smaller
# so the kitty/GUI mascot stays a glanceable corner sprite. The half-block
# fallback can't shrink as far — see ``UNICODE_MIN_COLS`` — and clamps to its
# legibility floor instead.)
DEFAULT_SCALE = 0.33
# User-settable scale bounds (``/pet scale``, desktop slider). Floor keeps the
# pet clickable/visible; ceiling stops a fat-fingered value from filling the
# screen. The unicode fallback additionally clamps to ``UNICODE_MIN_COLS``.
MIN_SCALE = 0.1
MAX_SCALE = 3.0
def clamp_scale(scale: float) -> float:
"""Clamp *scale* to ``[MIN_SCALE, MAX_SCALE]`` (the single validation point)."""
return max(MIN_SCALE, min(MAX_SCALE, scale))
# Terminal cells one native frame spans at ``scale == 1.0``. A cell is ~8px
# wide, a frame is ``FRAME_W`` (192) px → 24 cells. This mirrors the kitty
# graphics placement (``scaled_px // 8``) so at full scale every renderer agrees.
BASE_UNICODE_COLS = FRAME_W // 8
# Legibility floor for the half-block fallback. A half-block cell samples the
# sprite at only 1 horizontal + 2 vertical taps, so below this width a 192×208
# pet collapses into an unreadable blob *regardless* of scale. kitty/GUI draw
# true pixels and have no such floor — that's why the same ``scale: 0.33`` is
# crisp there but mush in half-blocks. ``scale`` shrinks the unicode pet down
# TO this floor (and grows it above), instead of past it into noise.
UNICODE_MIN_COLS = 16
def cols_for_scale(scale: float) -> int:
"""Half-block width implied by *scale*, clamped to the legibility floor.
Above the floor it tracks the kitty cell box (``scaled_px // 8``) so the two
renderers converge at larger sizes; below it the floor keeps the sprite
readable rather than letting it devolve into a blob.
"""
return max(UNICODE_MIN_COLS, round(BASE_UNICODE_COLS * (scale or DEFAULT_SCALE)))
def resolve_cols(scale: float, unicode_cols: int = 0) -> int:
"""Resolve terminal width: explicit *unicode_cols* override, else from *scale*."""
return int(unicode_cols) if unicode_cols and int(unicode_cols) > 0 else cols_for_scale(scale)
class PetState(str, Enum):
"""Animation state a pet can be shown in.
These are Hermes' activity state names. They are not always identical to the
source atlas row names: Codex-format pets use rows like ``jumping`` /
``running`` while the UI keeps the shorter ``jump`` / ``run`` names.
"""
IDLE = "idle"
WAVE = "wave"
RUN = "run"
FAILED = "failed"
REVIEW = "review"
JUMP = "jump"
WAITING = "waiting"
# Legacy Hermes/petdex row order (top -> bottom) used by the older 8-row,
# 9-column atlas shape.
LEGACY_STATE_ROWS: list[str] = [
PetState.IDLE.value,
PetState.WAVE.value,
PetState.RUN.value,
PetState.FAILED.value,
PetState.REVIEW.value,
PetState.JUMP.value,
"extra1",
"extra2",
]
# Current Petdex row order (top -> bottom) used by 1536x1872 atlases:
# 8 columns x 9 rows of 192x208 cells.
CODEX_STATE_ROWS: list[str] = [
PetState.IDLE.value,
"running-right",
"running-left",
"waving",
"jumping",
PetState.FAILED.value,
PetState.WAITING.value,
"running",
PetState.REVIEW.value,
]
# Default/fallback for callers without a sheet. Prefer the current 9-row Codex
# format because generated pets and the public Codex pet contract use it.
STATE_ROWS: list[str] = CODEX_STATE_ROWS
# Canonical Hermes activity names -> accepted row-name aliases in descending
# preference. This keeps our internal state names stable (`wave`/`jump`/`run`)
# while matching Petdex's current `waving`/`jumping`/`running` taxonomy.
STATE_ALIASES: dict[str, tuple[str, ...]] = {
PetState.IDLE.value: (PetState.IDLE.value,),
PetState.WAVE.value: (PetState.WAVE.value, "waving"),
PetState.JUMP.value: (PetState.JUMP.value, "jumping"),
PetState.RUN.value: (PetState.RUN.value, "running"),
PetState.FAILED.value: (PetState.FAILED.value,),
PetState.REVIEW.value: (PetState.REVIEW.value,),
PetState.WAITING.value: (PetState.WAITING.value,),
}
def state_aliases_for(state: "PetState | str") -> tuple[str, ...]:
"""Return accepted row-name aliases for *state* (always non-empty)."""
value = state.value if isinstance(state, PetState) else str(state)
aliases = STATE_ALIASES.get(value)
return aliases if aliases else (value,)
def state_rows_for_grid(row_count: int | None) -> list[str]:
"""Return the row taxonomy for a spritesheet with *row_count* rows."""
try:
rows = int(row_count or 0)
except (TypeError, ValueError):
rows = 0
if rows >= len(CODEX_STATE_ROWS):
return CODEX_STATE_ROWS
return LEGACY_STATE_ROWS
def state_row_index(state: "PetState | str", row_count: int | None = None) -> int:
"""Return the spritesheet row index for *state* (clamped, never raises)."""
rows = state_rows_for_grid(row_count)
for name in state_aliases_for(state):
try:
return rows.index(name)
except ValueError:
continue
return 0 # fall back to the idle row

View File

@@ -1,29 +0,0 @@
"""Pet generation — base-draft → hatch pipeline.
Public surface used by the gateway RPCs, the CLI ``hermes pets generate``
command, and tests:
- :func:`generate_base_drafts` / :func:`hatch_pet` — the two-step flow.
- :class:`HatchResult`, :class:`GenerationError`.
- :mod:`atlas` — deterministic frame extraction + atlas composition/validation.
Image generation is delegated to the active reference-capable
:class:`~agent.image_gen_provider.ImageGenProvider` (OpenAI gpt-image-2 or Krea);
atlas assembly is fully deterministic so it's testable without any API calls.
"""
from __future__ import annotations
from agent.pet.generate.imagegen import GenerationError
from agent.pet.generate.orchestrate import (
HatchResult,
generate_base_drafts,
hatch_pet,
)
__all__ = [
"GenerationError",
"HatchResult",
"generate_base_drafts",
"hatch_pet",
]

File diff suppressed because it is too large Load Diff

View File

@@ -1,251 +0,0 @@
"""Thin image-generation layer for pet sprites.
Wraps the active :class:`~agent.image_gen_provider.ImageGenProvider` with the
two things sprite generation needs that the agent-facing ``image_generate`` tool
doesn't expose: **N variants** (loop) and **reference-image grounding** (so each
animation row stays the same character as the chosen base).
Reference grounding only works on providers that support it — currently OpenAI
``gpt-image-2`` (image edits) and Krea (style references). We resolve to one of
those and surface a clear, actionable error otherwise rather than silently
producing an ungrounded, drifting pet.
"""
from __future__ import annotations
import logging
import os
from dataclasses import dataclass
from pathlib import Path
logger = logging.getLogger(__name__)
# Providers that can ground generation on a reference image, in preference order
# (Nous Portal → OpenAI → OpenRouter → …). OpenRouter/Nous run a quality-first
# model chain and may fall back depending on account access and endpoint behavior,
# so fidelity can vary by configured backend + model availability.
_REF_CAPABLE = ("nous", "openai", "openai-codex", "openrouter", "krea")
# Friendly display label per reference-capable provider, surfaced in the desktop
# pet-gen picker.
_PROVIDER_LABELS: dict[str, str] = {
"nous": "Nous Portal",
"openrouter": "OpenRouter",
"openai": "OpenAI",
"openai-codex": "OpenAI (Codex)",
"krea": "Krea",
}
def _forced_provider_from_env() -> str | None:
"""Optional QA override to force a pet-gen backend.
`HERMES_PET_IMAGE_PROVIDER=<name>` (e.g. `openrouter`) bypasses the normal
active/default provider resolution for pet generation only. Unknown values are
ignored so existing users are unaffected.
"""
forced = os.environ.get("HERMES_PET_IMAGE_PROVIDER", "").strip().lower()
return forced if forced in _REF_CAPABLE else None
class GenerationError(RuntimeError):
"""Raised on any image-generation failure (no provider, API error, IO)."""
@dataclass(frozen=True)
class SpriteProvider:
"""Resolved provider plus whether it can take reference images."""
name: str
provider: object
supports_references: bool
def _discover() -> None:
try:
from hermes_cli.plugins import _ensure_plugins_discovered
_ensure_plugins_discovered()
except Exception as exc: # noqa: BLE001 - discovery is best-effort
logger.debug("image-gen plugin discovery failed: %s", exc)
def resolve_provider(*, require_references: bool = True, prefer: str | None = None) -> SpriteProvider:
"""Pick the image provider to use for sprite work.
Preference: an explicit *prefer* choice (the desktop pet-gen picker) when it's
reference-capable and configured, then the configured/active provider when
it's reference-capable, else the first available reference-capable provider.
With *require_references* off we fall back to any available provider (used for
prompt-only base drafts).
"""
_discover()
from agent.image_gen_registry import get_active_provider, get_provider
# QA override: force one provider for pet-gen iteration regardless of the
# globally active image_gen backend.
forced = _forced_provider_from_env()
if forced:
chosen = get_provider(forced)
if chosen is not None and chosen.is_available():
return SpriteProvider(name=forced, provider=chosen, supports_references=True)
# An explicit user pick wins when it's reference-capable and has credentials;
# otherwise we ignore it and fall through to the normal resolution.
if prefer:
chosen = get_provider(prefer)
if prefer in _REF_CAPABLE and chosen is not None and chosen.is_available():
return SpriteProvider(name=prefer, provider=chosen, supports_references=True)
# Configured / active provider first.
active = None
try:
active = get_active_provider()
except Exception: # noqa: BLE001
active = None
if active is not None:
name = getattr(active, "name", "")
if name in _REF_CAPABLE and active.is_available():
return SpriteProvider(name=name, provider=active, supports_references=True)
# Any available reference-capable provider.
for name in _REF_CAPABLE:
provider = get_provider(name)
if provider is not None and provider.is_available():
return SpriteProvider(name=name, provider=provider, supports_references=True)
if not require_references and active is not None and active.is_available():
return SpriteProvider(
name=getattr(active, "name", "unknown"), provider=active, supports_references=False
)
raise GenerationError(
"Pet generation needs an image backend that supports reference images. "
"Open `hermes tools` → Image Generation and configure Nous Portal, "
"OpenRouter, or OpenAI (gpt-image-2) with an API key."
)
def list_sprite_providers() -> list[dict]:
"""The reference-capable providers available to pick for pet generation.
Returns ``[{name, label, default}]`` for every ref-capable provider the user
actually has credentials for, in preference order, marking the one
:func:`resolve_provider` would choose with no explicit preference. Empty when
none is configured (the picker hides itself). Best-effort: discovery hiccups
yield an empty list.
"""
_discover()
from agent.image_gen_registry import get_provider
try:
default_name = resolve_provider(require_references=True).name
except GenerationError:
default_name = ""
out: list[dict] = []
for name in _REF_CAPABLE:
provider = get_provider(name)
if provider is None or not provider.is_available():
continue
out.append(
{
"name": name,
"label": _PROVIDER_LABELS.get(name, name),
"default": name == default_name,
}
)
return out
def _save_local(image_ref: str, *, prefix: str) -> Path:
"""Return a local path for *image_ref*, downloading it if it's a URL."""
if image_ref.startswith(("http://", "https://")):
from agent.image_gen_provider import save_url_image
return Path(save_url_image(image_ref, prefix=prefix))
return Path(image_ref)
def _rejected_background(error: str) -> bool:
"""True when a provider error is specifically about the ``background`` param.
Transparent backgrounds are a per-model capability (e.g. some gpt-image tiers
reject ``background=transparent`` outright). We detect that one rejection so
we can retry without the flag rather than failing the whole pet — our chroma
key pass makes the result transparent regardless.
"""
lowered = (error or "").lower()
return "background" in lowered and ("not supported" in lowered or "transparent" in lowered)
def generate(
prompt: str,
*,
n: int = 1,
reference_images: list[Path] | None = None,
provider: SpriteProvider | None = None,
prefix: str = "pet_gen",
aspect_ratio: str = "square",
) -> list[Path]:
"""Generate *n* sprite images and return their local paths.
*reference_images* grounds the output on a base image (required for rows).
*aspect_ratio* picks the canvas: ``"square"`` for single-character base
drafts, ``"landscape"`` for multi-frame row strips (the wider 1536px canvas
gives every frame real horizontal room so winged poses don't have to be
shrunk to avoid touching their neighbors).
We *ask* for a transparent background, but fall back to an opaque generation
(cleaned up downstream by the chroma-key pass) on models that reject the
flag. Raises :class:`GenerationError` if nothing usable comes back.
"""
sprite = provider or resolve_provider(require_references=bool(reference_images))
if reference_images and not sprite.supports_references:
raise GenerationError(
f"image backend '{sprite.name}' cannot use reference images; "
"configure OpenAI gpt-image-2 or Krea for pet generation"
)
refs = [str(p) for p in (reference_images or [])]
def _run(extra: dict) -> tuple[Path | None, str]:
kwargs: dict = {"aspect_ratio": aspect_ratio, **extra}
if refs:
# Providers disagree on the ref kwarg name: our OpenRouter/Nous
# backends read ``reference_images``, OpenAI's gpt-image-2 reads
# ``reference_image_urls``. Send both; each ignores the other.
kwargs["reference_images"] = refs
kwargs["reference_image_urls"] = refs
try:
result = sprite.provider.generate(prompt, **kwargs)
except Exception as exc: # noqa: BLE001 - normalize provider crashes
logger.debug("provider.generate crashed: %s", exc)
return None, str(exc)
if not isinstance(result, dict) or not result.get("success"):
return None, (result or {}).get("error", "unknown error") if isinstance(result, dict) else "no result"
image_ref = result.get("image")
if not image_ref:
return None, "provider returned no image"
try:
return _save_local(str(image_ref), prefix=prefix), ""
except Exception as exc: # noqa: BLE001
return None, f"could not save generated image: {exc}"
out: list[Path] = []
last_error = ""
allow_transparent = True
for _ in range(max(1, n)):
path, err = _run({"background": "transparent"} if allow_transparent else {})
# Model doesn't support the transparent flag → drop it for this and every
# remaining variant (no point re-probing a capability we just disproved).
if path is None and allow_transparent and _rejected_background(err):
allow_transparent = False
path, err = _run({})
if path is not None:
out.append(path)
else:
last_error = err
if not out:
raise GenerationError(last_error or "image generation produced no output")
return out

View File

@@ -1,358 +0,0 @@
"""Pet generation orchestration — the base-draft → hatch flow.
Two steps, mirroring the UX across every surface:
1. :func:`generate_base_drafts` — a handful of prompt-only "what should this pet
look like" variants. Cheap; the user picks one (or retries for a fresh set).
2. :func:`hatch_pet` — takes the chosen base and generates one grounded row
strip per Hermes state, slices each into frames, composes the atlas, validates
it, and writes the pet into the store.
Splitting it this way bounds cost (4 cheap base calls per round; the ~6 row
calls happen once, on the pet you actually keep) and gives each UI a natural
preview/loading point.
"""
from __future__ import annotations
import logging
import time
from concurrent.futures import ThreadPoolExecutor, as_completed
from dataclasses import dataclass
from pathlib import Path
from typing import Callable
from agent.pet.generate import atlas, imagegen, prompts
from agent.pet.generate.imagegen import GenerationError, SpriteProvider
logger = logging.getLogger(__name__)
# (event, detail) — e.g. ("row", "idle"), ("compose", ""), ("save", "<slug>").
ProgressFn = Callable[[str, str], None]
# Image generations are independent network calls, so we fan them out instead of
# blocking on each in turn — a hatch is ~8 row calls that would otherwise run
# back-to-back and routinely blow past the client's RPC timeout. Capped so we
# don't hammer the provider's rate limit (one cold call can still be slow).
_MAX_PARALLEL_GENERATIONS = 4
# How many times to (re)generate a single row before accepting a best-effort
# slice. Early attempts demand clean per-pose gutters; the last is lenient so a
# stubborn row still yields frames instead of dropping out entirely.
_ROW_GEN_ATTEMPTS = 3
_MIN_FILLED_STATES = 6
_REQUIRED_STATES = frozenset({"idle", "running-right", "waving"})
@dataclass(frozen=True)
class HatchResult:
"""Outcome of a successful :func:`hatch_pet`."""
slug: str
display_name: str
spritesheet: Path
states: list[str]
validation: dict
def _harden_transparency(path: Path) -> Path:
"""Key out any solid backdrop the provider painted; save as an RGBA PNG.
``background=transparent`` is requested on every call, but image models honor
it inconsistently — some still paint a flat (often near-white) backdrop. We
run the same chroma-key pass the row extractor uses so every base draft the
user picks between (and the reference the rows are grounded on) is a clean
cutout. Best-effort: a decode failure leaves the original untouched.
"""
from PIL import Image
try:
with Image.open(path) as opened:
keyed = atlas.remove_background(opened.convert("RGBA"))
# Zero the RGB of any leftover semi-transparent edge pixels so a keyed
# draft has no colored halo when composited on the dark UI.
keyed = atlas._clear_transparent_rgb(keyed)
out = path.with_suffix(".png")
keyed.save(out, format="PNG")
return out
except Exception as exc: # noqa: BLE001 - cosmetic; fall back to the raw image
logger.debug("base draft transparency hardening failed for %s: %s", path, exc)
return path
def generate_base_drafts(
concept: str,
*,
n: int = 4,
style: str = "auto",
reference_images: list[Path] | None = None,
provider: SpriteProvider | None = None,
on_draft: Callable[[int, Path], None] | None = None,
is_cancelled: Callable[[], bool] | None = None,
) -> list[Path]:
"""Generate *n* candidate base looks for *concept*; returns image paths.
Each draft is hardened to a transparent cutout (see :func:`_harden_transparency`).
Drafts are generated concurrently and *on_draft(index, path)* fires as each
one finishes (not at the end) so callers can stream previews to the UI
instead of leaving it blank until the whole batch is done.
*is_cancelled*, when supplied, is polled cooperatively: a draft that hasn't
started yet is skipped, and once it trips we stop staging/streaming further
drafts and cancel any queued work (already-in-flight provider calls can't be
hard-killed, but their results are dropped).
"""
# A user reference image (e.g. their own pet) grounds every draft, so it
# needs a reference-capable provider — same requirement as the row passes.
refs = reference_images or None
sprite = provider or imagegen.resolve_provider(require_references=bool(refs))
cancelled = is_cancelled or (lambda: False)
# Each draft is its own one-shot generation, run concurrently so the user
# waits for one image, not N. A single draft failing must not sink the set.
# Each gets a distinct variation nudge so the options aren't near-duplicates.
logger.info("pet generate: drafting %d base looks for %r (style=%s)", n, concept, style)
def _one(index: int) -> tuple[int, Path | None, str | None]:
if cancelled():
return index, None, None
t0 = time.monotonic()
variation = prompts.BASE_VARIATIONS[index % len(prompts.BASE_VARIATIONS)]
prompt = prompts.build_base_prompt(concept, style=style, variation=variation)
try:
out = imagegen.generate(prompt, n=1, reference_images=refs, provider=sprite, prefix="pet_base")
except Exception as exc: # noqa: BLE001 - tolerate a single failed draft
logger.warning("pet generate: draft %d failed after %.1fs: %s", index, time.monotonic() - t0, exc)
return index, None, str(exc)
if not out:
logger.warning("pet generate: draft %d produced no image", index)
return index, None, "the image provider returned no image"
logger.info("pet generate: draft %d ready in %.1fs", index, time.monotonic() - t0)
return index, _harden_transparency(out[0]), None
workers = max(1, min(n, _MAX_PARALLEL_GENERATIONS))
results: dict[int, Path] = {}
errors: list[str] = []
with ThreadPoolExecutor(max_workers=workers) as pool:
futures = [pool.submit(_one, i) for i in range(n)]
# as_completed runs in *this* (the caller's) thread, so on_draft — and any
# gateway event it emits — inherits the request's bound transport, unlike
# the worker threads above.
for fut in as_completed(futures):
if cancelled():
logger.info("pet generate: cancelled — dropping remaining drafts")
for pending in futures:
pending.cancel()
break
index, path, err = fut.result()
if path is None:
if err:
errors.append(err)
continue
results[index] = path
if on_draft is not None:
try:
on_draft(index, path)
except Exception as exc: # noqa: BLE001 - progress is best-effort
logger.debug("on_draft callback failed: %s", exc)
drafts = [results[i] for i in sorted(results)]
if not drafts and not cancelled():
# Surface *why* — every draft failed for a reason (a content-policy refusal
# on a name like "minion", a provider/auth error, …); the most common one
# is the representative cause. Far more useful than "no usable drafts".
raise GenerationError(_drafts_failed_reason(errors))
return drafts
def _drafts_failed_reason(errors: list[str]) -> str:
"""The representative reason a draft round produced nothing, humanized."""
if not errors:
return "image generation produced no usable drafts"
from collections import Counter
return _humanize_image_error(Counter(errors).most_common(1)[0][0])
def _humanize_image_error(error: str) -> str:
"""Turn a raw provider error into a friendly, actionable sentence.
The big one is moderation: image models refuse trademarked characters and
real people (e.g. "minion"), which reads as an opaque 400 otherwise.
"""
low = error.lower()
if any(s in low for s in ("moderation_blocked", "safety system", "content policy", "content_policy")):
return (
"The image provider blocked this prompt — its safety filter rejects "
"trademarked characters and real people. Try an original description."
)
if any(s in low for s in ("api key", "unauthorized", "401", "auth")):
return "The image provider rejected the request — check your API key in Settings → Providers."
if "rate limit" in low or "429" in low:
return "The image provider is rate-limiting — wait a moment and try again."
# Otherwise the first line, trimmed of the noisy provider envelope.
return error.splitlines()[0].strip()[:200]
def hatch_pet(
*,
base_image: str | Path,
slug: str,
display_name: str = "",
description: str = "",
concept: str = "",
style: str = "auto",
on_progress: ProgressFn | None = None,
provider: SpriteProvider | None = None,
is_cancelled: Callable[[], bool] | None = None,
) -> HatchResult:
"""Turn an approved base image into a full, installed Hermes pet.
Generates a grounded row strip per state, extracts frames, composes +
validates the atlas, and registers it. The idle row falls back to the base
look so the pet always renders. Raises :class:`GenerationError` on failure.
*is_cancelled*, when supplied, is polled cooperatively: rows that haven't
started are skipped, queued rows are cancelled, and once every row is done we
abort (raising :class:`GenerationError`) before composing/saving so a stopped
hatch never writes a half-built pet.
"""
base = Path(base_image)
if not base.is_file():
raise GenerationError(f"base image not found: {base}")
sprite = provider or imagegen.resolve_provider(require_references=True)
progress = on_progress or (lambda *_: None)
cancelled = is_cancelled or (lambda: False)
label = concept or display_name or slug
frames_by_state: dict[str, list] = {}
total_rows = len(atlas.ROW_SPECS)
logger.info("pet hatch %r: generating %d animation rows", slug, total_rows)
# Generate every state's row strip concurrently — they're independent
# grounded calls, so the hatch waits for the slowest row, not their sum. A
# single row failing is tolerated (idle is guaranteed below).
def _gen_row(spec: tuple[str, int, int]) -> tuple[str, list | None]:
state, _row, count = spec
if cancelled():
return state, None
t0 = time.monotonic()
last_exc: Exception | None = None
# Self-healing: a model occasionally returns a row whose poses are touching
# (no clean gutters), which slices badly. We retry such rolls; only the
# final attempt falls back to lenient ``auto`` slicing so a stubborn row
# still yields *something* rather than dropping the whole row.
for attempt in range(_ROW_GEN_ATTEMPTS):
if cancelled():
return state, None
strict = attempt < _ROW_GEN_ATTEMPTS - 1
try:
strips = imagegen.generate(
prompts.build_row_prompt(state, count, label, style=style),
n=1,
reference_images=[base],
provider=sprite,
prefix=f"pet_row_{state}",
# Wider canvas → each frame gets real horizontal room, so winged
# poses keep a full, healthy size and still leave clean gutters.
aspect_ratio="landscape",
)
# ``components`` requires clean per-pose gutters (raises otherwise),
# so a touching roll is rejected and regenerated; the last attempt
# uses ``auto`` (equal-slot fallback, never raises). Raw (fit=False)
# so normalize_cells registers the whole pet at once.
method = "components" if strict else "auto"
frames = atlas.extract_strip_frames(strips[0], count, method=method, fit=False)
logger.info(
"pet hatch %r: row %r ready in %.1fs (attempt %d)",
slug, state, time.monotonic() - t0, attempt + 1,
)
return state, frames
except Exception as exc: # noqa: BLE001 - retried; one bad row is tolerated
last_exc = exc
logger.warning(
"pet hatch %r: row %r attempt %d/%d failed: %s",
slug, state, attempt + 1, _ROW_GEN_ATTEMPTS, exc,
)
logger.warning(
"pet hatch %r: row %r gave up after %.1fs: %s",
slug, state, time.monotonic() - t0, last_exc,
)
return state, None
# running-left is derived by mirroring running-right (guaranteed-consistent
# and one fewer generation), so we don't generate it directly.
generated_specs = [spec for spec in atlas.ROW_SPECS if spec[0] != "running-left"]
workers = max(1, min(len(generated_specs), _MAX_PARALLEL_GENERATIONS))
done = 0
with ThreadPoolExecutor(max_workers=workers) as pool:
futures = [pool.submit(_gen_row, spec) for spec in generated_specs]
# as_completed runs on the caller (request) thread, so progress events
# emitted here inherit the request transport — unlike the worker threads.
for fut in as_completed(futures):
if cancelled():
logger.info("pet hatch %r: cancelled — dropping remaining rows", slug)
for pending in futures:
pending.cancel()
break
state, frames = fut.result()
done += 1
progress("row", f"{state}:{done}:{total_rows}")
if frames:
frames_by_state[state] = frames
if cancelled():
raise GenerationError("hatch cancelled")
# Derive running-left from the approved running-right row (per-frame mirror,
# preserving order/timing). Missing running-right is rejected below; a pet
# without its canonical walk cycle is a failed hatch, not a shippable mascot.
right = frames_by_state.get("running-right")
if right:
done += 1
progress("row", f"running-left:{done}:{total_rows}")
frames_by_state["running-left"] = atlas.mirror_frames(right)
logger.info("pet hatch %r: row 'running-left' mirrored from running-right", slug)
else:
logger.warning("pet hatch %r: no running-right to mirror; left walk left empty", slug)
# Idle is the resting state the renderer falls back to — guarantee it.
if not frames_by_state.get("idle"):
progress("row", "idle-fallback")
frames_by_state["idle"] = [atlas.single_frame(base, fit=False)]
progress("compose", "")
logger.info("pet hatch %r: composing atlas from %d states", slug, len(frames_by_state))
# One shared scale + baseline across every state so the pet never slides or
# pulses size between frames; compose just packs the normalized cells.
sheet = atlas.compose_atlas(atlas.normalize_cells(frames_by_state))
validation = atlas.validate_atlas(sheet)
if not validation["ok"]:
raise GenerationError("; ".join(validation["errors"]) or "atlas validation failed")
filled_states = set(validation["filled_states"])
missing_required = sorted(_REQUIRED_STATES - filled_states)
if missing_required:
raise GenerationError(f"missing required animation row(s): {', '.join(missing_required)}")
if len(filled_states) < _MIN_FILLED_STATES:
raise GenerationError(
f"only {len(filled_states)}/{len(atlas.ROW_SPECS)} animation rows were usable; regenerate"
)
from agent.pet import store
progress("save", slug)
logger.info("pet hatch %r: saving pet", slug)
pet = store.register_local_pet(
sheet,
slug=slug,
display_name=display_name or slug,
description=description,
)
return HatchResult(
slug=pet.slug,
display_name=pet.display_name,
spritesheet=pet.spritesheet,
states=validation["filled_states"],
validation=validation,
)

View File

@@ -1,183 +0,0 @@
"""Prompt builders for pet generation.
Two prompt shapes: a *base* prompt (prompt-only, produces the canonical look the
user picks between) and per-*state* *row* prompts (grounded on the chosen base,
produce one horizontal strip of N poses). Prompts stay concise and
sprite-production oriented; the identity lock and "one transparent row" framing
matter more than flowery description.
We generate the full petdex/Codex nine-state set (see
:data:`agent.pet.generate.atlas.ROW_SPECS`) so a hatched pet is a valid
``petdex submit`` spritesheet.
"""
from __future__ import annotations
# What each petdex/Codex state should depict (kept short — these go straight into
# the row prompt). Phrased to avoid the common sprite-gen failure modes (detached
# effects, motion lines, shadows). Critical distinction: ``running`` is the
# *working* state (in place), while ``running-right`` / ``running-left`` are the
# actual directional walk/run cycles.
STATE_ACTIONS: dict[str, str] = {
"idle": "a calm idle loop: subtle breathing, a tiny blink or gentle bob, no big gestures",
"running-right": (
"a sideways walk/run locomotion cycle moving to the RIGHT: the character "
"faces and travels right with clear directional steps, a smooth gait loop"
),
"running-left": (
"a sideways walk/run locomotion cycle moving to the LEFT: the character "
"faces and travels left with clear directional steps (the mirror of the "
"right-facing run)"
),
"waving": "a friendly greeting: raising a paw/hand/limb to wave, clear up-and-down gesture",
"jumping": "a happy celebration jump: anticipation, lift off the ground, peak, and land",
"failed": "a sad or deflated reaction: slumped, dejected, small frown — readable but not noisy",
"waiting": (
"an expectant 'waiting on you' pose: looking up/out as if asking for input "
"or approval — distinct from idle and review"
),
"running": (
"focused active work, staying IN PLACE (NOT walking or foot-running): "
"leaning in, concentrating, busy 'thinking / processing / typing' energy"
),
"review": "careful inspection: a focused lean, head tilt, studying something intently",
}
_STYLE_HINTS: dict[str, str] = {
# Default to the popular petdex look: crisp 16-bit PIXEL ART, not the smooth
# 2D illustration (let alone 3D render) gpt-image reaches for by default.
"auto": (
" Style: crisp 16-bit PIXEL-ART game sprite — visible square pixels, a small "
"limited palette, clean dark outline, flat cel shading, chunky chibi "
"proportions, like a classic SNES/JRPG party member or a petdex.dev mascot. "
"Absolutely NOT 3D-rendered, NOT a smooth painted or vector illustration, "
"NOT photorealistic — no soft gradients, no realistic lighting, no figurine look."
),
"pixel": " Render in clean 16-bit pixel-art style with visible square pixels and a limited palette.",
"plush": " Render as a soft plush toy.",
"clay": " Render as a claymation / soft 3D clay figure.",
"sticker": " Render as a glossy die-cut sticker.",
"flat-vector": " Render in flat vector mascot style.",
"3d-toy": " Render as a glossy 3D toy.",
"painterly": " Render in a soft painterly style.",
}
_BACKGROUND = (
"Center the character on a SINGLE flat, uniform, high-contrast chroma-key "
"background — pure hot magenta #FF00FF (only if magenta appears on the "
"character, use pure green #00FF00 instead). The background is ONE continuous "
"even color that completely surrounds the character with NO gradient, "
"vignette, texture, pattern, scenery, shadow, ground line, frame, border, "
"panel, comic cell, gutter line, grid, or divider of any kind, so it keys out "
"cleanly. The background color must not appear anywhere on the character. "
"No text, no labels, no speech bubbles, no UI."
)
def style_hint(style: str | None) -> str:
return _STYLE_HINTS.get((style or "auto").strip().lower(), "")
# Row strips are generated on the wider landscape canvas (see imagegen.generate /
# orchestrate). The extra width is what lets each pose stay a healthy size AND
# leave a real gutter — used here only to cite concrete pixel numbers.
_ASSUMED_STRIP_WIDTH = 1536
def _spacing_spec(frame_count: int) -> tuple[int, int]:
"""(per-pose width px, gap px) for a row of *frame_count* poses.
Pixel counts alone don't hold — the model fills each slot edge-to-edge with
the full wingspan, so neighbors touch even when bodies are spaced. The lever
that works is proportional containment on a wide canvas: give each pose its
own equal cell and keep the ENTIRE silhouette (wings/tail/halo included)
inside it. On the 1536px landscape strip ~70% occupancy still leaves a
generous gutter, so the pet stays a normal, good-looking size — no shrinking.
"""
slots = max(1, frame_count)
slot_w = _ASSUMED_STRIP_WIDTH / slots
pose_px = round(slot_w * 0.7)
gap_px = max(48, round(slot_w * 0.3))
return pose_px, gap_px
# Per-draft nudges so the 4 base options are actually distinct — gpt-image returns
# near-duplicates for a single prompt. We vary the *look* (palette, build,
# expression, accents), NOT the pose, so the chosen base still grounds clean,
# consistent animation rows.
BASE_VARIATIONS: tuple[str, ...] = (
"",
"a distinctly different colour palette and markings",
"a heavier, broader silhouette with sturdier proportions",
"a different facial structure and expression matching the concept tone, with unique accent/accessory details",
"a leaner, taller build and an alternate colour scheme",
"bolder, more saturated colours and a stronger expression matching the concept tone",
)
def build_base_prompt(concept: str, *, style: str | None = "auto", variation: str = "") -> str:
"""The base look: a single, clean, centered full-body mascot.
*variation* differentiates one draft from the next (see :data:`BASE_VARIATIONS`).
"""
concept = (concept or "a distinctive mascot creature").strip()
nudge = f" Make this design distinct: {variation}." if variation else ""
return (
f"A stylized mascot pet character: {concept}. "
"Honor the requested tone and mood exactly (cute, eerie, scary, menacing, whimsical, etc.) "
"while staying non-graphic. "
"Compact, whole-body silhouette that reads clearly at small size, "
"clear readable facial features, simple consistent palette. "
# A neutral, symmetric, at-rest stance makes the cleanest identity anchor
"Neutral front-facing standing pose, upright and symmetric, arms/limbs "
"relaxed at the sides, feet together on the ground, any cape/accessories "
"hanging straight and still."
f"{nudge} "
f"{_BACKGROUND}{style_hint(style)}"
)
def build_row_prompt(state: str, frame_count: int, concept: str, *, style: str | None = "auto") -> str:
"""A row strip: *frame_count* poses of the SAME character, left→right.
The attached base image is the identity source of truth; the prompt locks
species, palette, face, and props to it.
"""
action = STATE_ACTIONS.get(state, "a simple idle pose")
concept = (concept or "the mascot").strip()
pose_px, gap_px = _spacing_spec(frame_count)
return (
f"Using the attached reference image as the exact same character "
f"(same species, face, colors, markings, proportions, and props), "
"preserving the same emotional tone/mood (e.g., scary stays scary, cute stays cute), "
f"draw a single WIDE horizontal strip of {frame_count} animation frames showing {action}. "
f"LAYOUT: arrange {frame_count} poses in ONE horizontal row at equal spacing, "
"each pose centered in its own imaginary equal region. Draw NO panel borders, "
"NO comic cells, NO boxes, NO vertical divider/gutter lines, NO grid, NO frame "
"outlines between poses — the backdrop is one unbroken flat field behind all of them. "
"Fill the WHOLE strip with the SAME single flat chroma-key color as the attached "
"reference image's background (identical hue in every frame, no per-pose color shifts). "
f"SPACING (critical): draw each pose at a consistent, healthy, clearly "
f"visible size (roughly {pose_px}px wide on a {_ASSUMED_STRIP_WIDTH}px "
f"strip) — do NOT shrink it tiny — but keep its ENTIRE silhouette "
f"(wings, tail, halo, horns, cape, every appendage) fully INSIDE its own "
f"cell. Leave at least {gap_px}px of empty chroma-key background between "
f"neighboring silhouettes at their closest point (wingtip to wingtip), and "
f"the same empty margin before the first pose and after the last. If a wing, "
f"cape, or tail would reach into a neighbor, FOLD or angle it inward rather "
f"than letting it cross the gap. Silhouettes must NEVER touch, overlap, "
f"share a shadow, share a ground line, share motion trails, or merge into "
f"one connected shape. "
# Registration: a clean sprite sheet keeps the character locked in place
# so only the action moves — this is what stops the loop sliding/pulsing.
"REGISTRATION (critical): the character is the SAME height and SAME width "
"in every frame, drawn at the SAME scale, centered over the SAME point, "
"with all feet aligned to the SAME invisible horizontal baseline across the "
"whole strip — this baseline is conceptual ONLY: draw NO ground line, floor, "
"platform, horizon, or contact shadow beneath the feet. Keep the body's center, size, and stance fixed frame to "
"frame — ONLY the limbs/features the action needs may move. Capes, cloaks, "
"bags, and scarves stay in the SAME place and shape every frame (no "
"swinging, flowing, or drifting) unless the action itself requires it. No "
"pose is cropped at the strip edges. "
f"{_BACKGROUND}{style_hint(style)}"
)

View File

@@ -1,165 +0,0 @@
"""Fetch the public petdex manifest.
``https://petdex.dev/api/manifest`` 307-redirects to a JSON document on R2:
{
"generatedAt": "...",
"total": 2926,
"pets": [
{"slug": "boba", "displayName": "Boba", "kind": "creature",
"submittedBy": "railly",
"spritesheetUrl": "https://assets.petdex.dev/.../spritesheet.webp",
"petJsonUrl": "https://assets.petdex.dev/.../pet.json",
"zipUrl": "https://assets.petdex.dev/.../boba.zip"},
...
]
}
Read-only and unauthenticated; no credentials involved.
"""
from __future__ import annotations
import logging
import threading
import time
from dataclasses import dataclass
logger = logging.getLogger(__name__)
MANIFEST_URL = "https://petdex.dev/api/manifest"
_DEFAULT_TIMEOUT = 10.0
# In-process cache for the (large, slow, identical-per-call) manifest. The list
# is a static CDN object that barely changes, yet a single session can ask for
# it many times — every gallery open, plus a full re-fetch per install/select
# (``find_entry``). A short TTL collapses those into one network hit without
# going stale for long. Cleared by :func:`clear_cache` (tests).
_MANIFEST_TTL = 300.0
_cache: tuple[float, list[ManifestEntry]] | None = None
_prefetch_lock = threading.Lock()
_prefetching = False
def clear_cache() -> None:
"""Drop the cached manifest (forces the next fetch to hit the network)."""
global _cache
_cache = None
def _cache_is_warm() -> bool:
return _cache is not None and time.monotonic() - _cache[0] < _MANIFEST_TTL
def prefetch(*, timeout: float = _DEFAULT_TIMEOUT) -> None:
"""Warm the manifest cache in a daemon thread — idempotent, never blocks.
The desktop picker calls this when it loads the (instant) local-only gallery
so the full petdex catalog is usually cached by the time it's requested,
without ever holding up the user's own pets on a network round-trip.
"""
global _prefetching
if _cache_is_warm():
return
with _prefetch_lock:
if _prefetching:
return
_prefetching = True
def _run() -> None:
global _prefetching
try:
fetch_manifest(timeout=timeout)
except Exception as exc: # noqa: BLE001 - best-effort warm
logger.debug("petdex manifest prefetch failed: %s", exc)
finally:
_prefetching = False
threading.Thread(target=_run, name="petdex-prefetch", daemon=True).start()
@dataclass(frozen=True)
class ManifestEntry:
"""A single pet's row in the manifest."""
slug: str
display_name: str
kind: str
submitted_by: str
spritesheet_url: str
pet_json_url: str
zip_url: str
@classmethod
def from_dict(cls, data: dict) -> "ManifestEntry":
return cls(
slug=str(data.get("slug", "")).strip(),
display_name=str(data.get("displayName", "") or data.get("slug", "")),
kind=str(data.get("kind", "") or "pet"),
submitted_by=str(data.get("submittedBy", "") or ""),
spritesheet_url=str(data.get("spritesheetUrl", "") or ""),
pet_json_url=str(data.get("petJsonUrl", "") or ""),
zip_url=str(data.get("zipUrl", "") or ""),
)
class ManifestError(RuntimeError):
"""Raised when the manifest can't be fetched or parsed."""
def fetch_manifest(*, timeout: float = _DEFAULT_TIMEOUT, force: bool = False) -> list[ManifestEntry]:
"""Return every approved pet from the public manifest.
Cached in-process for ``_MANIFEST_TTL`` seconds (pass ``force=True`` to
bypass). Follows the 307 redirect to R2. Raises :class:`ManifestError` on
any network/parse failure so callers can surface a clean message.
"""
global _cache
if not force and _cache is not None and time.monotonic() - _cache[0] < _MANIFEST_TTL:
return _cache[1]
try:
import httpx
except ImportError as exc: # pragma: no cover - httpx is a core dep
raise ManifestError("httpx is required to fetch the petdex manifest") from exc
try:
resp = httpx.get(
MANIFEST_URL,
timeout=timeout,
follow_redirects=True,
headers={"User-Agent": "hermes-agent-petdex"},
)
resp.raise_for_status()
payload = resp.json()
except Exception as exc: # noqa: BLE001 - normalize to one error type
raise ManifestError(f"could not fetch petdex manifest: {exc}") from exc
pets = payload.get("pets") if isinstance(payload, dict) else None
if not isinstance(pets, list):
raise ManifestError("petdex manifest had no 'pets' array")
entries: list[ManifestEntry] = []
for raw in pets:
if not isinstance(raw, dict):
continue
entry = ManifestEntry.from_dict(raw)
if entry.slug and entry.spritesheet_url:
entries.append(entry)
_cache = (time.monotonic(), entries)
return entries
def find_entry(slug: str, *, timeout: float = _DEFAULT_TIMEOUT) -> ManifestEntry | None:
"""Return the manifest entry for *slug*, or ``None`` if not listed."""
slug = slug.strip().lower()
for entry in fetch_manifest(timeout=timeout):
if entry.slug.lower() == slug:
return entry
return None

View File

@@ -1,618 +0,0 @@
"""Decode a pet spritesheet and encode frames for a terminal.
Shared by the base CLI (writes the escape bytes to its own stdout) and the
TUI (``tui_gateway`` ships the encoded bytes to Ink, which writes them) so the
decode + capability-detection + protocol-encoding logic exists exactly once.
Supported output modes, in fidelity order:
- ``kitty`` — the kitty graphics protocol (kitty, Ghostty, WezTerm).
- ``iterm`` — iTerm2 inline images (iTerm2, WezTerm).
- ``sixel`` — DEC sixel (xterm -ti vt340, foot, mlterm, WezTerm, …).
- ``unicode`` — 24-bit half-block downscale; works in any truecolor terminal.
Frame decoding requires Pillow (a core Hermes dependency). If Pillow or the
spritesheet is unavailable the renderer degrades to ``unicode`` text or an
empty string rather than raising.
"""
from __future__ import annotations
import base64
import io
import logging
import os
import sys
from functools import lru_cache
from pathlib import Path
from agent.pet.constants import (
DEFAULT_SCALE,
FRAME_H,
FRAME_W,
FRAMES_PER_STATE,
PetState,
state_row_index,
)
logger = logging.getLogger(__name__)
# Public render-mode names accepted by ``display.pet.render_mode``.
RENDER_MODES = ("auto", "kitty", "iterm", "sixel", "unicode", "off")
# ─────────────────────────────────────────────────────────────────────────
# Terminal capability detection
# ─────────────────────────────────────────────────────────────────────────
def detect_terminal_graphics() -> str:
"""Best-effort detection of the richest graphics protocol available.
Env-based (non-blocking — we never issue a DA1/terminal query that could
hang a pipe). Returns one of ``kitty`` / ``iterm`` / ``sixel`` /
``unicode``. Conservative: unknown terminals get ``unicode``, which works
anywhere with truecolor.
"""
term = os.environ.get("TERM", "").lower()
term_program = os.environ.get("TERM_PROGRAM", "").lower()
# The VS Code / Cursor integrated terminal sets TERM_PROGRAM=vscode
# authoritatively but does NOT scrub the terminal env vars it inherits when
# launched from another emulator (ITERM_SESSION_ID, KITTY_WINDOW_ID, …).
# Trusting those leaks emits an image protocol the embedded xterm.js can't
# display — you get a blank frame. Inline images there are opt-in
# (terminal.integrated.enableImages), so default to half-blocks, which
# always render in its truecolor grid. Users who enabled images can pin
# display.pet.render_mode explicitly.
if term_program == "vscode":
return "unicode"
# kitty graphics protocol
if os.environ.get("KITTY_WINDOW_ID") or "kitty" in term or "ghostty" in term:
return "kitty"
if term_program in {"ghostty"}:
return "kitty"
# WezTerm speaks both kitty and iterm; prefer kitty (richer placement).
if term_program == "wezterm" or os.environ.get("WEZTERM_PANE"):
return "kitty"
# iTerm2 inline images
if term_program == "iterm.app" or os.environ.get("ITERM_SESSION_ID"):
return "iterm"
# sixel-capable terminals (env heuristics only)
if term_program in {"mintty"} or "foot" in term or "mlterm" in term:
return "sixel"
if "sixel" in term:
return "sixel"
return "unicode"
def resolve_mode(configured: str | None, *, stream=None) -> str:
"""Resolve the effective render mode from config + the environment.
``configured`` is ``display.pet.render_mode`` (``auto`` → detect). Returns
``off`` when not attached to a TTY (no point emitting graphics into a pipe
or logfile).
"""
mode = (configured or "auto").strip().lower()
if mode not in RENDER_MODES:
mode = "auto"
if mode == "off":
return "off"
stream = stream or sys.stdout
try:
if not (hasattr(stream, "isatty") and stream.isatty()):
return "off"
except (ValueError, OSError):
return "off"
if mode == "auto":
return detect_terminal_graphics()
return mode
# ─────────────────────────────────────────────────────────────────────────
# Frame decoding
# ─────────────────────────────────────────────────────────────────────────
def _open_sheet(path: Path):
from PIL import Image
img = Image.open(path)
return img.convert("RGBA")
# Max alpha at/below which a frame counts as blank padding. petdex sheets are
# left-packed: a state with fewer real frames than ``FRAMES_PER_STATE`` fills
# the trailing columns with fully transparent cells. Animating into one flashes
# the pet blank, so we stop the row at the first such gap.
_BLANK_ALPHA = 8
def _frame_is_blank(frame) -> bool:
"""True if *frame* has no meaningfully opaque pixel (transparent padding)."""
return frame.getchannel("A").getextrema()[1] <= _BLANK_ALPHA
@lru_cache(maxsize=16)
def _raw_frames(
sheet_path: str,
state_value: str,
frame_w: int,
frame_h: int,
frames_per_state: int,
) -> tuple:
"""Cropped, padding-trimmed RGBA frames for one state row (unscaled).
Steps across the row until the first blank column so pets with ragged
per-state frame counts never animate into empty padding. Cached; returns
``()`` on any decode failure.
"""
try:
sheet = _open_sheet(Path(sheet_path))
cols = max(1, sheet.width // frame_w)
rows = max(1, sheet.height // frame_h)
row = state_row_index(state_value, rows)
top = row * frame_h
# Clamp the row to the sheet (some pets ship fewer rows than the 8 the
# taxonomy reserves).
if top + frame_h > sheet.height:
top = max(0, sheet.height - frame_h)
frames = []
for i in range(min(frames_per_state, cols)):
left = i * frame_w
frame = sheet.crop((left, top, left + frame_w, top + frame_h))
if _frame_is_blank(frame):
break # trailing transparent padding — real frames end here
frames.append(frame)
return tuple(frames)
except Exception as exc: # noqa: BLE001 - cosmetic feature, never fatal
logger.debug("pet frame decode failed (%s, %s): %s", sheet_path, state_value, exc)
return ()
@lru_cache(maxsize=8)
def _frames_for(
sheet_path: str,
state_value: str,
frame_w: int,
frame_h: int,
frames_per_state: int,
scale_w: int,
scale_h: int,
):
"""Return padding-trimmed RGBA frames for one state row, scaled.
Thin scaling layer over :func:`_raw_frames`; both are cached so repeated
frame requests during animation are free.
"""
raw = _raw_frames(sheet_path, state_value, frame_w, frame_h, frames_per_state)
if not raw or (scale_w, scale_h) == (frame_w, frame_h):
return list(raw)
from PIL import Image
return [f.resize((scale_w, scale_h), Image.LANCZOS) for f in raw]
def state_frame_counts(
sheet_path: str | Path,
*,
frame_w: int = FRAME_W,
frame_h: int = FRAME_H,
frames_per_state: int = FRAMES_PER_STATE,
) -> dict[str, int]:
"""Map each driven :class:`PetState` → its real (padding-trimmed) frame count.
The single source of truth for "how many frames does this state actually
have?". The CLI/TUI consume the trimmed frame lists directly; the gateway
ships this map to the desktop canvas, which steps its own loop.
"""
return {
state.value: len(
_raw_frames(str(sheet_path), state.value, frame_w, frame_h, frames_per_state)
)
for state in PetState
}
# ─────────────────────────────────────────────────────────────────────────
# Encoders
# ─────────────────────────────────────────────────────────────────────────
def _png_bytes(frame) -> bytes:
buf = io.BytesIO()
frame.save(buf, format="PNG")
return buf.getvalue()
def _kitty_apc(ctrl: str, data: str) -> str:
"""Emit a kitty APC escape for *data*, chunked into ≤4096-byte ``m`` pieces."""
chunk = 4096
if len(data) <= chunk:
return f"\x1b_G{ctrl},m=0;{data}\x1b\\"
out = [f"\x1b_G{ctrl},m=1;{data[:chunk]}\x1b\\"]
rest = data[chunk:]
while rest:
piece, rest = rest[:chunk], rest[chunk:]
out.append(f"\x1b_Gm={1 if rest else 0};{piece}\x1b\\")
return "".join(out)
def _encode_kitty(frame, *, cell_cols: int | None = None, cell_rows: int | None = None) -> str:
"""Encode one frame via the kitty graphics protocol (transmit + display).
``a=T`` transmits & displays at the cursor; ``c``/``r`` request a display
box in terminal cells so successive frames overwrite the same area.
"""
ctrl = "f=100,a=T,q=2"
if cell_cols:
ctrl += f",c={cell_cols}"
if cell_rows:
ctrl += f",r={cell_rows}"
return _kitty_apc(ctrl, base64.standard_b64encode(_png_bytes(frame)).decode("ascii"))
# ─────────────────────────────────────────────────────────────────────────
# kitty Unicode placeholders
#
# Ink (the TUI's React-for-terminal layer) owns the screen and measures every
# cell's width, so it can't host raw kitty image escapes (no width to count,
# clobbered on the next repaint). kitty's *Unicode placeholder* protocol is the
# grid-safe path: transmit the image once (q=2, virtual placement U=1), then the
# host app prints ordinary-width placeholder cells (U+10EEEE + diacritics) whose
# foreground color encodes the image id. Ink counts those as width-1 text, so
# layout stays correct and the terminal paints the image underneath.
# https://sw.kovidgoyal.net/kitty/graphics-protocol/#unicode-placeholders
# ─────────────────────────────────────────────────────────────────────────
_KITTY_PLACEHOLDER = "\U0010eeee"
# Row/column diacritics, in order (index → diacritic). Verbatim from kitty's
# gen/rowcolumn-diacritics.txt (Unicode 6.0.0, combining class 230). Index i is
# the diacritic that encodes the number i; we only ever need the row index.
_ROWCOL_DIACRITICS: tuple[int, ...] = (
0x0305, 0x030D, 0x030E, 0x0310, 0x0312, 0x033D, 0x033E, 0x033F, 0x0346, 0x034A,
0x034B, 0x034C, 0x0350, 0x0351, 0x0352, 0x0357, 0x035B, 0x0363, 0x0364, 0x0365,
0x0366, 0x0367, 0x0368, 0x0369, 0x036A, 0x036B, 0x036C, 0x036D, 0x036E, 0x036F,
0x0483, 0x0484, 0x0485, 0x0486, 0x0487, 0x0592, 0x0593, 0x0594, 0x0595, 0x0597,
0x0598, 0x0599, 0x059C, 0x059D, 0x059E, 0x059F, 0x05A0, 0x05A1, 0x05A8, 0x05A9,
0x05AB, 0x05AC, 0x05AF, 0x05C4, 0x0610, 0x0611, 0x0612, 0x0613, 0x0614, 0x0615,
0x0616, 0x0617, 0x0657, 0x0658, 0x0659, 0x065A, 0x065B, 0x065D, 0x065E, 0x06D6,
0x06D7, 0x06D8, 0x06D9, 0x06DA, 0x06DB, 0x06DC, 0x06DF, 0x06E0, 0x06E1, 0x06E2,
0x06E4, 0x06E7, 0x06E8, 0x06EB, 0x06EC, 0x0730, 0x0732, 0x0733, 0x0735, 0x0736,
0x073A, 0x073D, 0x073F, 0x0740, 0x0741, 0x0743, 0x0745, 0x0747, 0x0749, 0x074A,
0x07EB, 0x07EC, 0x07ED, 0x07EE, 0x07EF, 0x07F0, 0x07F1, 0x07F3, 0x0816, 0x0817,
0x0818, 0x0819, 0x081B, 0x081C, 0x081D, 0x081E, 0x081F, 0x0820, 0x0821, 0x0822,
0x0823, 0x0825, 0x0826, 0x0827, 0x0829, 0x082A, 0x082B, 0x082C, 0x082D, 0x0951,
0x0953, 0x0954, 0x0F82, 0x0F83, 0x0F86, 0x0F87, 0x135D, 0x135E, 0x135F, 0x17DD,
0x193A, 0x1A17, 0x1A75, 0x1A76, 0x1A77, 0x1A78, 0x1A79, 0x1A7A, 0x1A7B, 0x1A7C,
0x1B6B, 0x1B6D, 0x1B6E, 0x1B6F, 0x1B70, 0x1B71, 0x1B72, 0x1B73, 0x1CD0, 0x1CD1,
0x1CD2, 0x1CDA, 0x1CDB, 0x1CE0, 0x1DC0, 0x1DC1, 0x1DC3, 0x1DC4, 0x1DC5, 0x1DC6,
0x1DC7, 0x1DC8, 0x1DC9, 0x1DCB, 0x1DCC, 0x1DD1, 0x1DD2, 0x1DD3, 0x1DD4, 0x1DD5,
0x1DD6, 0x1DD7, 0x1DD8, 0x1DD9, 0x1DDA, 0x1DDB, 0x1DDC, 0x1DDD, 0x1DDE, 0x1DDF,
0x1DE0, 0x1DE1, 0x1DE2, 0x1DE3, 0x1DE4, 0x1DE5, 0x1DE6, 0x1DFE, 0x20D0, 0x20D1,
0x20D4, 0x20D5, 0x20D6, 0x20D7, 0x20DB, 0x20DC, 0x20E1, 0x20E7, 0x20E9, 0x20F0,
0x2CEF, 0x2CF0, 0x2CF1, 0x2DE0, 0x2DE1, 0x2DE2, 0x2DE3, 0x2DE4, 0x2DE5, 0x2DE6,
0x2DE7, 0x2DE8, 0x2DE9, 0x2DEA, 0x2DEB, 0x2DEC, 0x2DED, 0x2DEE, 0x2DEF, 0x2DF0,
0x2DF1, 0x2DF2, 0x2DF3, 0x2DF4, 0x2DF5, 0x2DF6, 0x2DF7, 0x2DF8, 0x2DF9, 0x2DFA,
0x2DFB, 0x2DFC, 0x2DFD, 0x2DFE, 0x2DFF, 0xA66F, 0xA67C, 0xA67D, 0xA6F0, 0xA6F1,
0xA8E0, 0xA8E1, 0xA8E2, 0xA8E3, 0xA8E4, 0xA8E5, 0xA8E6, 0xA8E7, 0xA8E8, 0xA8E9,
0xA8EA, 0xA8EB, 0xA8EC, 0xA8ED, 0xA8EE, 0xA8EF, 0xA8F0, 0xA8F1, 0xAAB0, 0xAAB2,
0xAAB3, 0xAAB7, 0xAAB8, 0xAABE, 0xAABF, 0xAAC1, 0xFE20, 0xFE21, 0xFE22, 0xFE23,
0xFE24, 0xFE25, 0xFE26, 0x10A0F, 0x10A38, 0x1D185, 0x1D186, 0x1D187, 0x1D188,
0x1D189, 0x1D1AA, 0x1D1AB, 0x1D1AC, 0x1D1AD, 0x1D242, 0x1D243, 0x1D244,
)
def kitty_image_id(slug: str) -> int:
"""Stable per-pet image id in ``[1, 0x7FFF]``.
The id is encoded in the placeholder's 24-bit foreground color, so it must
be non-zero and fit comfortably under ``0xFFFFFF``. A small CRC keeps it
deterministic per slug (so re-renders reuse the same terminal-side image)
while making collisions between two different pets unlikely.
"""
import zlib
return (zlib.crc32(slug.encode("utf-8")) % 0x7FFE) + 1
def kitty_color_hex(image_id: int) -> str:
"""Hex foreground color (``#rrggbb``) that encodes *image_id* for kitty."""
return "#%06x" % (image_id & 0xFFFFFF)
def kitty_placeholder_rows(cols: int, rows: int) -> list[str]:
"""Build the placeholder text grid for an *rows*×*cols* image.
Each line is one row of the grid: the first cell carries the row diacritic
(column defaults to 0), and the remaining ``cols-1`` bare placeholders let
the terminal auto-increment the column. The foreground color (the image id)
is applied by the caller / Ink, not embedded here.
"""
cols = max(1, cols)
out: list[str] = []
for r in range(max(1, rows)):
idx = min(r, len(_ROWCOL_DIACRITICS) - 1)
first = _KITTY_PLACEHOLDER + chr(_ROWCOL_DIACRITICS[idx])
out.append(first + _KITTY_PLACEHOLDER * (cols - 1))
return out
def _encode_kitty_virtual(frame, *, image_id: int, cols: int, rows: int) -> str:
"""Transmit a frame as a kitty *virtual* placement for Unicode placeholders.
``a=T`` transmits and creates the placement in one shot; ``U=1`` marks it
virtual (no on-screen output, cursor untouched); ``q=2`` suppresses the
terminal's OK/error replies that would otherwise corrupt the host app's
output. Re-sending with the same ``i`` replaces the image, so the static
placeholder cells animate underneath.
"""
ctrl = f"a=T,U=1,i={image_id},c={cols},r={rows},f=100,q=2"
return _kitty_apc(ctrl, base64.standard_b64encode(_png_bytes(frame)).decode("ascii"))
def _encode_iterm(frame, *, cell_cols: int | None = None, cell_rows: int | None = None) -> str:
"""Encode one frame as an iTerm2 inline image (OSC 1337 File)."""
payload = base64.standard_b64encode(_png_bytes(frame)).decode("ascii")
size = len(payload)
args = [f"inline=1", f"size={size}", "preserveAspectRatio=1"]
if cell_cols:
args.append(f"width={cell_cols}")
if cell_rows:
args.append(f"height={cell_rows}")
return f"\x1b]1337;File={';'.join(args)}:{payload}\x07"
def _encode_sixel(frame) -> str:
"""Encode one frame as DEC sixel.
Quantizes to an adaptive palette (≤255 colors) and emits the sixel band
stream. Pillow has no sixel writer, so this is a compact hand-rolled
encoder. Transparent pixels render as background (color register skipped).
"""
from PIL import Image
rgba = frame
# Composite onto transparent-as-skip: track alpha to decide background.
pal = rgba.convert("RGB").quantize(colors=255, method=Image.MEDIANCUT)
palette = pal.getpalette() or []
px = pal.load()
alpha = rgba.getchannel("A").load()
w, h = pal.size
out = ["\x1bP0;1;0q", '"1;1;%d;%d' % (w, h)]
# Color register definitions (sixel uses 0..100 scale).
used = sorted({px[x, y] for y in range(h) for x in range(w)})
for idx in used:
r = palette[idx * 3] if idx * 3 < len(palette) else 0
g = palette[idx * 3 + 1] if idx * 3 + 1 < len(palette) else 0
b = palette[idx * 3 + 2] if idx * 3 + 2 < len(palette) else 0
out.append("#%d;2;%d;%d;%d" % (idx, r * 100 // 255, g * 100 // 255, b * 100 // 255))
# Emit in 6-row bands.
for band in range(0, h, 6):
for color_idx in used:
line = ["#%d" % color_idx]
run_char = None
run_len = 0
def flush():
nonlocal run_char, run_len
if run_char is None:
return
if run_len > 3:
line.append("!%d%s" % (run_len, run_char))
else:
line.append(run_char * run_len)
run_char, run_len = None, 0
for x in range(w):
bits = 0
for bit in range(6):
y = band + bit
if y < h and alpha[x, y] > 32 and px[x, y] == color_idx:
bits |= 1 << bit
ch = chr(63 + bits)
if ch == run_char:
run_len += 1
else:
flush()
run_char, run_len = ch, 1
flush()
out.append("".join(line) + "$") # carriage return within band
out.append("-") # next band
out.append("\x1b\\")
return "".join(out)
_HALF_BLOCK = ""
# A single half-block cell: top pixel + bottom pixel as (r, g, b, a) tuples.
Cell = tuple[tuple[int, int, int, int], tuple[int, int, int, int]]
def _downscale_cells(frame, *, target_cols: int) -> list[list[Cell]]:
"""Downscale a frame to a grid of half-block cells.
Each cell pairs a top and bottom pixel so one terminal row encodes two
pixel rows. Returns rows of ``((tr,tg,tb,ta),(br,bg,bb,ba))`` — the
framework-neutral representation shared by the ANSI encoder (CLI) and the
structured ``cells`` API (Ink).
"""
from PIL import Image
target_cols = max(4, target_cols)
aspect = frame.height / max(1, frame.width)
target_rows = max(2, int(round(target_cols * aspect * 0.5)) * 2)
small = frame.resize((target_cols, target_rows), Image.LANCZOS).convert("RGBA")
px = small.load()
grid: list[list[Cell]] = []
for y in range(0, target_rows, 2):
row: list[Cell] = []
for x in range(target_cols):
top = px[x, y]
bottom = px[x, y + 1] if y + 1 < target_rows else (0, 0, 0, 0)
row.append((top, bottom))
grid.append(row)
return grid
def _encode_unicode(frame, *, target_cols: int) -> str:
"""Downscale to truecolor ANSI half-blocks (one char = 2 vertical pixels)."""
lines: list[str] = []
for row in _downscale_cells(frame, target_cols=target_cols):
cells: list[str] = []
for (tr, tg, tb, ta), (br, bg, bb, ba) in row:
if ta < 32 and ba < 32:
cells.append("\x1b[0m ") # fully transparent → blank
continue
cells.append(f"\x1b[38;2;{tr};{tg};{tb}m\x1b[48;2;{br};{bg};{bb}m{_HALF_BLOCK}")
lines.append("".join(cells) + "\x1b[0m")
return "\n".join(lines)
# ─────────────────────────────────────────────────────────────────────────
# Public renderer
# ─────────────────────────────────────────────────────────────────────────
class PetRenderer:
"""Holds a pet's spritesheet and yields encoded frames per (state, index).
Construct once per pet, then call :meth:`frame` on an animation timer.
Cheap to call repeatedly — decoded frames are cached.
"""
def __init__(
self,
spritesheet: str | Path,
*,
mode: str = "unicode",
scale: float = DEFAULT_SCALE,
unicode_cols: int = 20,
frame_w: int = FRAME_W,
frame_h: int = FRAME_H,
frames_per_state: int = FRAMES_PER_STATE,
) -> None:
self.spritesheet = str(spritesheet)
self.mode = mode if mode in RENDER_MODES else "unicode"
self.scale = scale
self.unicode_cols = unicode_cols
self.frame_w = frame_w
self.frame_h = frame_h
self.frames_per_state = frames_per_state
@property
def available(self) -> bool:
return self.mode != "off" and Path(self.spritesheet).is_file()
def frame_count(self, state: PetState | str) -> int:
return len(self._frames(state))
def _frames(self, state: PetState | str):
value = state.value if isinstance(state, PetState) else str(state)
scale_w = max(1, int(self.frame_w * self.scale))
scale_h = max(1, int(self.frame_h * self.scale))
return _frames_for(
self.spritesheet,
value,
self.frame_w,
self.frame_h,
self.frames_per_state,
scale_w,
scale_h,
)
def cells(self, state: PetState | str, index: int, *, cols: int | None = None) -> list[list[Cell]]:
"""Return one frame as a half-block cell grid (framework-neutral).
Used by the TUI, which renders the grid with native Ink color props
instead of raw ANSI. Returns ``[]`` when no frame is available.
"""
frames = self._frames(state)
if not frames:
return []
frame = frames[index % len(frames)]
return _downscale_cells(frame, target_cols=cols or self.unicode_cols)
def _cell_box(self, frame) -> tuple[int, int]:
"""Terminal cell box for a scaled frame (~8×16 px per cell).
Must match :meth:`frame` graphics sizing — kitty stretches the image to
fill ``c``×``r`` cells, so these must reflect the scaled pixel
dimensions, not a native-aspect column count (that upscales small pets).
"""
return max(1, frame.width // 8), max(1, frame.height // 16)
def kitty_payload(self, state: PetState | str, *, image_id: int) -> dict | None:
"""Build the kitty Unicode-placeholder payload for one state.
Returns ``{cols, rows, placeholder, frames}`` where ``frames`` is a
list of transmit escapes (one per animation frame, all reusing
``image_id``) and ``placeholder`` is the static text grid Ink paints.
Placement geometry is derived from the scaled frame pixels (via
:meth:`_cell_box`), not ``unicode_cols`` — kitty upscales to fill
``c``×``r`` cells. ``None`` when no frame is available.
"""
frames = self._frames(state)
if not frames:
return None
cols, rows = self._cell_box(frames[0])
return {
"cols": cols,
"rows": rows,
"placeholder": kitty_placeholder_rows(cols, rows),
"frames": [
_encode_kitty_virtual(f, image_id=image_id, cols=cols, rows=rows) for f in frames
],
}
def frame(self, state: PetState | str, index: int) -> str:
"""Return the encoded escape string for one frame, or ``""``.
``index`` is taken modulo the available frame count so callers can pass
a free-running counter.
"""
if self.mode == "off":
return ""
frames = self._frames(state)
if not frames:
return ""
frame = frames[index % len(frames)]
cell_cols, cell_rows = self._cell_box(frame)
try:
if self.mode == "kitty":
return _encode_kitty(frame, cell_cols=cell_cols, cell_rows=cell_rows)
if self.mode == "iterm":
return _encode_iterm(frame, cell_cols=cell_cols, cell_rows=cell_rows)
if self.mode == "sixel":
return _encode_sixel(frame)
return _encode_unicode(frame, target_cols=self.unicode_cols)
except Exception as exc: # noqa: BLE001 - degrade silently
logger.debug("pet frame encode failed (mode=%s): %s", self.mode, exc)
return ""
def build_renderer(
spritesheet: str | Path,
*,
configured_mode: str | None = None,
scale: float = DEFAULT_SCALE,
unicode_cols: int = 20,
stream=None,
) -> PetRenderer:
"""Convenience factory: resolve the mode from config+env, then construct."""
mode = resolve_mode(configured_mode, stream=stream)
return PetRenderer(
spritesheet,
mode=mode,
scale=scale,
unicode_cols=unicode_cols,
)

View File

@@ -1,81 +0,0 @@
"""Map agent activity → a :class:`PetState`.
This is the one place the "what is the agent doing right now?""which
animation row?" decision lives. Each surface feeds it the signals it already
tracks:
- CLI — ``KawaiiSpinner`` waiting/thinking state + tool outcomes.
- TUI — gateway ``tool.start/complete`` + ``message.delta/complete`` events.
- Desktop — the ``$busy``/``$awaitingResponse``/tool-event nanostores
(re-implemented in TS, but mirroring this priority order).
Keeping the priority order here (and documenting it) lets the TypeScript
mirror stay faithful without a second design.
"""
from __future__ import annotations
from collections.abc import Iterable
from typing import Any
from agent.pet.constants import PetState
def todos_all_done(todos: Iterable[Any] | None) -> bool:
"""True iff there's ≥1 todo and every one is completed/cancelled.
The "celebrate" beat (``JUMP``) fires when a plan finishes; this mirrors
the TUI's ``isTodoDone`` so the trigger is defined once across surfaces.
Accepts dicts (``{"status": ...}``) or objects with a ``status`` attr.
"""
items = list(todos or [])
if not items:
return False
def _status(t: Any) -> Any:
return t.get("status") if isinstance(t, dict) else getattr(t, "status", None)
return all(_status(t) in ("completed", "cancelled") for t in items)
def derive_pet_state(
*,
busy: bool = False,
awaiting_input: bool = False,
error: bool = False,
celebrate: bool = False,
just_completed: bool = False,
tool_running: bool = False,
reasoning: bool = False,
) -> PetState:
"""Resolve the animation state from coarse activity signals.
Priority (highest first) — only one row can show at a time, so the most
salient signal wins:
1. ``error`` → ``FAILED`` (a tool/turn just failed)
2. ``celebrate`` → ``JUMP`` (explicit success beat, e.g. todos done)
3. ``just_completed`` → ``WAVE`` (turn finished cleanly / greeting)
4. ``awaiting_input`` → ``WAITING`` (blocked on the user — a clarify/approval
prompt is open; this outranks the in-flight signals below because the turn
is paused on *you*, even though a tool is technically mid-call)
5. ``tool_running`` → ``RUN`` (a tool is executing)
6. ``reasoning`` → ``REVIEW`` (model is thinking / reading)
7. ``busy`` → ``RUN`` (turn in flight, unspecified work)
8. otherwise → ``IDLE``
"""
if error:
return PetState.FAILED
if celebrate:
return PetState.JUMP
if just_completed:
return PetState.WAVE
if awaiting_input:
return PetState.WAITING
if tool_running:
return PetState.RUN
if reasoning:
return PetState.REVIEW
if busy:
return PetState.RUN
return PetState.IDLE

View File

@@ -1,503 +0,0 @@
"""On-disk pet store — install / list / resolve pets.
Pets live under ``get_hermes_home()/pets/<slug>/`` so every profile gets its
own set (we deliberately do **not** reuse petdex's ``~/.codex/pets`` default —
that's owned by the petdex npm CLI and isn't profile-aware). Each installed
pet directory holds:
pets/<slug>/
pet.json # {id, displayName, description, spritesheetPath}
spritesheet.webp # (or .png)
The active pet is resolved from the caller-supplied ``display.pet.slug`` config
value (falling back to the first installed pet), so this module stays free of
the config loader.
"""
from __future__ import annotations
import json
import logging
import re
from dataclasses import dataclass
from pathlib import Path
from hermes_constants import get_hermes_home
logger = logging.getLogger(__name__)
_DOWNLOAD_TIMEOUT = 60.0
class PetStoreError(RuntimeError):
"""Raised on install/IO failures."""
@dataclass(frozen=True)
class InstalledPet:
"""A pet present on disk."""
slug: str
display_name: str
description: str
directory: Path
spritesheet: Path
created_by: str = "" # "generator" for pets hatched locally; "" for petdex installs
@property
def exists(self) -> bool:
return self.spritesheet.is_file()
@property
def generated(self) -> bool:
return self.created_by == "generator"
def pets_dir() -> Path:
"""Return the profile-scoped pets directory (created on demand)."""
path = get_hermes_home() / "pets"
path.mkdir(parents=True, exist_ok=True)
return path
def _read_pet_json(directory: Path) -> dict:
pet_json = directory / "pet.json"
if not pet_json.is_file():
return {}
try:
return json.loads(pet_json.read_text(encoding="utf-8"))
except (OSError, ValueError) as exc:
logger.debug("unreadable pet.json in %s: %s", directory, exc)
return {}
def _resolve_spritesheet(directory: Path, meta: dict) -> Path:
"""Find the spritesheet for a pet dir.
Honors ``spritesheetPath`` from pet.json, else probes the conventional
filenames (``spritesheet.{webp,png}`` and petdex R2's ``sprite.webp``).
"""
declared = str(meta.get("spritesheetPath", "") or "").strip()
if declared:
candidate = directory / declared
if candidate.is_file():
return candidate
for name in ("spritesheet.webp", "spritesheet.png", "sprite.webp", "sprite.png"):
candidate = directory / name
if candidate.is_file():
return candidate
# Default expectation even if missing, so callers get a stable path.
return directory / "spritesheet.webp"
def _safe_slug(slug: str) -> str:
"""Normalize a slug to a single bare path segment.
Pet slugs index into ``pets_dir()/<slug>/`` for load/remove, so a value
carrying path separators (``../``, absolute paths) could escape the pets
directory. Strip every separator and reject ``.``/``..`` so callers can
only ever name a direct child of the pets directory.
"""
segment = Path(str(slug).strip()).name
if segment in ("", ".", ".."):
return ""
return segment
def load_pet(slug: str) -> InstalledPet | None:
"""Return the :class:`InstalledPet` for *slug*, or ``None`` if absent."""
slug = _safe_slug(slug)
if not slug:
return None
directory = pets_dir() / slug
if not directory.is_dir():
return None
meta = _read_pet_json(directory)
return InstalledPet(
slug=slug,
display_name=str(meta.get("displayName", "") or slug),
description=str(meta.get("description", "") or ""),
directory=directory,
spritesheet=_resolve_spritesheet(directory, meta),
created_by=str(meta.get("createdBy", "") or ""),
)
def installed_pets() -> list[InstalledPet]:
"""Return every installed pet (dirs containing a usable spritesheet)."""
out: list[InstalledPet] = []
for child in sorted(pets_dir().iterdir()):
if not child.is_dir():
continue
pet = load_pet(child.name)
if pet and pet.exists:
out.append(pet)
return out
def resolve_active_pet(configured_slug: str | None = None) -> InstalledPet | None:
"""Resolve which pet to display.
Precedence: the configured slug (``display.pet.slug``) if it's installed,
otherwise the first installed pet alphabetically, otherwise ``None``.
"""
if configured_slug:
pet = load_pet(configured_slug.strip())
if pet and pet.exists:
return pet
pets = installed_pets()
return pets[0] if pets else None
def install_pet(slug: str, *, force: bool = False, timeout: float = _DOWNLOAD_TIMEOUT) -> InstalledPet:
"""Download *slug* from the manifest into the pets directory.
Idempotent: a fully-installed pet is returned as-is unless *force*. Raises
:class:`PetStoreError` / :class:`~agent.pet.manifest.ManifestError` on
failure.
"""
from agent.pet.manifest import find_entry
slug = _safe_slug(slug)
if not slug:
raise PetStoreError("invalid pet slug")
existing = load_pet(slug)
if existing and existing.exists and not force:
return existing
entry = find_entry(slug, timeout=timeout)
if entry is None:
raise PetStoreError(f"pet '{slug}' is not in the petdex manifest")
# Host-pin every asset URL to petdex. The manifest is trusted (HTTPS from
# petdex.dev), but pin the asset hosts too so a compromised/spoofed manifest
# can't redirect the download at an arbitrary host. Matches thumbnail_png.
if not _is_petdex_host(entry.spritesheet_url):
raise PetStoreError(f"refusing non-petdex spritesheet host for '{slug}'")
directory = pets_dir() / slug
directory.mkdir(parents=True, exist_ok=True)
sprite_ext = ".png" if entry.spritesheet_url.lower().split("?")[0].endswith(".png") else ".webp"
sprite_path = directory / f"spritesheet{sprite_ext}"
_download(entry.spritesheet_url, sprite_path, timeout=timeout)
# Fetch the upstream pet.json if present; otherwise synthesize a minimal
# one so the local layout is self-describing.
meta: dict = {}
if entry.pet_json_url and _is_petdex_host(entry.pet_json_url):
try:
meta = _download_json(entry.pet_json_url, timeout=timeout)
except Exception as exc: # noqa: BLE001 - non-fatal, fall back below
logger.debug("pet.json fetch failed for %s: %s", slug, exc)
if not isinstance(meta, dict) or not meta:
meta = {"id": slug, "displayName": entry.display_name, "description": ""}
meta["spritesheetPath"] = sprite_path.name
meta.setdefault("id", slug)
meta.setdefault("displayName", entry.display_name)
(directory / "pet.json").write_text(json.dumps(meta, indent=2), encoding="utf-8")
pet = load_pet(slug)
if pet is None or not pet.exists:
raise PetStoreError(f"install of '{slug}' did not produce a spritesheet")
return pet
def slugify(name: str) -> str:
"""Lowercase, hyphenate, and strip a display name into a filesystem slug."""
slug = re.sub(r"[^a-z0-9]+", "-", (name or "").strip().lower()).strip("-")
return slug or "pet"
def unique_slug(name: str) -> str:
"""A :func:`slugify` result that doesn't collide with an existing pet dir."""
base = slugify(name)
slug = base
counter = 2
while (pets_dir() / slug).exists():
slug = f"{base}-{counter}"
counter += 1
return slug
def _write_spritesheet(source, dest: Path) -> None:
"""Write *source* (PIL image, bytes, or path) as a lossless WebP at *dest*."""
if isinstance(source, (bytes, bytearray)):
dest.write_bytes(bytes(source))
return
from PIL import Image
if isinstance(source, (str, Path)):
with Image.open(source) as opened:
image = opened.convert("RGBA")
else:
image = source.convert("RGBA")
image.save(dest, format="WEBP", lossless=True, quality=100, method=6, exact=True)
def register_local_pet(
spritesheet,
*,
slug: str,
display_name: str = "",
description: str = "",
) -> InstalledPet:
"""Write a locally-generated pet into the store and return it.
*spritesheet* may be a PIL image, raw WebP/PNG bytes, or a path. The pet
appears in :func:`installed_pets` immediately, and because :func:`install_pet`
returns an already-on-disk pet before consulting the manifest, it can be
adopted (``pet.select`` / ``/pet <slug>``) without a manifest entry.
"""
slug = slugify(slug)
directory = pets_dir() / slug
directory.mkdir(parents=True, exist_ok=True)
sprite_path = directory / "spritesheet.webp"
try:
_write_spritesheet(spritesheet, sprite_path)
except Exception as exc: # noqa: BLE001 - normalize to one error type
raise PetStoreError(f"could not write spritesheet for '{slug}': {exc}") from exc
meta = {
"id": slug,
"displayName": display_name or slug,
"description": description or "",
"spritesheetPath": sprite_path.name,
"createdBy": "generator",
}
(directory / "pet.json").write_text(json.dumps(meta, indent=2), encoding="utf-8")
pet = load_pet(slug)
if pet is None or not pet.exists:
raise PetStoreError(f"register of generated pet '{slug}' did not produce a spritesheet")
return pet
def export_pet(slug: str) -> tuple[str, bytes]:
"""Zip an installed pet's folder (pet.json + spritesheet) → (filename, bytes).
Dotfiles (cached thumbs, backups) are skipped so the archive is a clean,
re-importable pet package. Raises :class:`PetStoreError` if not installed.
"""
import io
import zipfile
root = pets_dir()
directory = root / slug.strip()
# Guard against traversal: the target must be a direct child of pets_dir.
if directory.resolve().parent != root.resolve() or not directory.is_dir():
raise PetStoreError(f"pet '{slug}' is not installed")
name = directory.name
buf = io.BytesIO()
with zipfile.ZipFile(buf, "w", zipfile.ZIP_DEFLATED) as archive:
for path in sorted(directory.iterdir()):
if path.is_file() and not path.name.startswith("."):
archive.write(path, f"{name}/{path.name}")
return f"{name}.zip", buf.getvalue()
_THUMB_FRAME_W = 192
_THUMB_FRAME_H = 208
_THUMB_W = 96 # rendered ~40px; 2x+ keeps it crisp on HiDPI
def _thumbs_dir() -> Path:
path = pets_dir() / ".thumbs"
path.mkdir(parents=True, exist_ok=True)
return path
def _is_petdex_host(url: str) -> bool:
"""True only for petdex.dev hosts — bounds server-side fetch (anti-SSRF)."""
from urllib.parse import urlparse
try:
host = (urlparse(url).hostname or "").lower()
except ValueError:
return False
return host == "petdex.dev" or host.endswith(".petdex.dev")
def thumbnail_png(slug: str, *, source_url: str = "", timeout: float = 30.0) -> bytes | None:
"""Return a small idle-frame PNG for *slug*, cached on disk.
Crops the top-left (idle, frame 0) cell of the spritesheet and downsamples
it to a thumbnail. Source preference: an installed spritesheet on disk, else
*source_url* — but only when it points at petdex (so the gateway never
fetches an arbitrary client-supplied URL). Returns ``None`` when there's no
usable source or Pillow/network fails; callers render a placeholder.
Doing this server-side sidesteps the renderer's CSP / R2 hotlink limits that
break a direct ``<img src=cdn>`` and lets the result ride the authenticated
gateway as a same-origin data URL.
"""
slug = slug.strip()
if not slug:
return None
cache = _thumbs_dir() / f"{slug}.png"
if cache.is_file():
try:
return cache.read_bytes()
except OSError:
pass
sheet_bytes: bytes | None = None
pet = load_pet(slug)
if pet and pet.exists:
try:
sheet_bytes = pet.spritesheet.read_bytes()
except OSError:
sheet_bytes = None
if sheet_bytes is None and source_url and _is_petdex_host(source_url):
try:
import httpx
resp = httpx.get(
source_url,
timeout=timeout,
follow_redirects=True,
headers={"User-Agent": "hermes-agent-petdex"},
)
resp.raise_for_status()
sheet_bytes = resp.content
except Exception as exc: # noqa: BLE001 - cosmetic, degrade to placeholder
logger.debug("thumb fetch failed for %s: %s", slug, exc)
if not sheet_bytes:
return None
try:
import io
from PIL import Image
with Image.open(io.BytesIO(sheet_bytes)) as im:
frame = im.convert("RGBA").crop(
(0, 0, min(_THUMB_FRAME_W, im.width), min(_THUMB_FRAME_H, im.height))
)
height = round(_THUMB_W * _THUMB_FRAME_H / _THUMB_FRAME_W)
frame = frame.resize((_THUMB_W, height), Image.NEAREST)
buf = io.BytesIO()
frame.save(buf, format="PNG")
data = buf.getvalue()
except Exception as exc: # noqa: BLE001
logger.debug("thumb crop failed for %s: %s", slug, exc)
return None
try:
cache.write_bytes(data)
except OSError:
pass
return data
def remove_pet(slug: str) -> bool:
"""Delete an installed pet directory. Returns True if anything was removed."""
import shutil
slug = _safe_slug(slug)
if not slug:
return False
# The cached thumbnail lives in pets/.thumbs/<slug>.png — OUTSIDE the pet
# dir, so rmtree won't catch it. Drop it too, or a later pet that reuses this
# slug renders this one's stale thumbnail.
try:
(_thumbs_dir() / f"{slug}.png").unlink(missing_ok=True)
except OSError:
pass
directory = pets_dir() / slug
if not directory.is_dir():
return False
shutil.rmtree(directory, ignore_errors=True)
return not directory.exists()
def rename_pet(slug: str, display_name: str) -> str | None:
"""Rename a pet's ``displayName`` AND realign its slug/dir to match.
Generated pets are hatched under a provisional, prompt-derived slug; when
the user names the pet on the reveal screen we make that name the real
identity so lists/subtitles show what they typed, not the prompt. The dir is
renamed to ``slugify(name)`` (and the cached thumbnail moved alongside it)
whenever that yields a free, different slug — otherwise the slug is left as
is. Returns the resulting slug on success, or ``None`` on failure.
"""
slug = _safe_slug(slug)
display_name = (display_name or "").strip()
if not slug or not display_name:
return None
directory = pets_dir() / slug
pet_json = directory / "pet.json"
if not pet_json.is_file():
return None
try:
meta = json.loads(pet_json.read_text(encoding="utf-8"))
except (OSError, ValueError):
meta = {}
if not isinstance(meta, dict):
meta = {}
meta["displayName"] = display_name
new_slug = slug
desired = slugify(display_name)
if desired and desired != slug and not (pets_dir() / desired).exists():
try:
directory.rename(pets_dir() / desired)
try:
(_thumbs_dir() / f"{slug}.png").rename(_thumbs_dir() / f"{desired}.png")
except OSError:
pass
directory = pets_dir() / desired
pet_json = directory / "pet.json"
new_slug = desired
meta["id"] = new_slug
except OSError:
new_slug = slug # keep the provisional slug if the move fails
try:
pet_json.write_text(json.dumps(meta, indent=2), encoding="utf-8")
except OSError:
return None
return new_slug
def _download(url: str, dest: Path, *, timeout: float) -> None:
import httpx
try:
with httpx.stream(
"GET",
url,
timeout=timeout,
follow_redirects=True,
headers={"User-Agent": "hermes-agent-petdex"},
) as resp:
resp.raise_for_status()
tmp = dest.with_suffix(dest.suffix + ".part")
with tmp.open("wb") as fh:
for chunk in resp.iter_bytes():
fh.write(chunk)
tmp.replace(dest)
except Exception as exc: # noqa: BLE001
raise PetStoreError(f"download failed for {url}: {exc}") from exc
def _download_json(url: str, *, timeout: float) -> dict:
import httpx
resp = httpx.get(
url,
timeout=timeout,
follow_redirects=True,
headers={"User-Agent": "hermes-agent-petdex"},
)
resp.raise_for_status()
data = resp.json()
return data if isinstance(data, dict) else {}

View File

@@ -243,10 +243,7 @@ KANBAN_GUIDANCE = (
"- **Workspace.** `cd $HERMES_KANBAN_WORKSPACE` first. For a `worktree` kind "
"with no `.git`, `git worktree add <path> "
"${HERMES_KANBAN_BRANCH:-wt/$HERMES_KANBAN_TASK}` from the main repo, then "
"cd there. For a project-linked task the workspace is a fresh "
"`<repo>/.worktrees/<task-id>` and `$HERMES_KANBAN_BRANCH` a deterministic "
"`<project-slug>/<task-id>` — the main repo is two levels up, so run "
"`git worktree add` from there.\n"
"cd there.\n"
"- **Deliverables.** Files a human wants go in "
"`kanban_complete(artifacts=[<absolute paths>])` (top-level param; paths in "
"`metadata` are NOT uploaded). Files must exist at completion.\n"
@@ -712,24 +709,7 @@ PLATFORM_HINTS = {
"(those are only intercepted on messaging platforms like Telegram, "
"Discord, Slack, etc.; on the CLI they render as literal text). "
"When referring to a file you created or changed, just state its "
"absolute path in plain text; the user can open it from there. "
"Cron jobs scheduled from this session are LOCAL-ONLY: their output is "
"saved (viewable via cronjob action='list') but is NOT delivered back "
"into this terminal — there is no live-delivery channel here. If the "
"user wants to be notified when a job runs, the job's `deliver` must "
"target a gateway-connected messaging platform (e.g. deliver='telegram' "
"or 'all'). Do not promise the user that a deliver='origin' or "
"default-deliver cron job will message them in this session."
),
"tui": (
"You are running in the Hermes terminal UI (TUI). "
"Cron jobs scheduled from this session are LOCAL-ONLY: their output is "
"saved (viewable via cronjob action='list') but is NOT delivered back "
"into this TUI session — there is no live-delivery channel here. If the "
"user wants to be notified when a job runs, the job's `deliver` must "
"target a gateway-connected messaging platform (e.g. deliver='telegram' "
"or 'all'). Do not promise the user that a deliver='origin' or "
"default-deliver cron job will message them in this session."
"absolute path in plain text; the user can open it from there."
),
"sms": (
"You are communicating via SMS. Keep responses concise and use plain text "

View File

@@ -8,7 +8,6 @@ rate-limited provider concurrently.
import random
import threading
import time
from typing import Any
# Monotonic counter for jitter seed uniqueness within the same process.
# Protected by a lock to avoid race conditions in concurrent retry paths
@@ -16,14 +15,6 @@ from typing import Any
_jitter_counter = 0
_jitter_lock = threading.Lock()
# Z.AI Coding Plan's GLM-5.2 endpoint often returns HTTP 429 code 1305
# ("The service may be temporarily overloaded...") for otherwise valid
# Hermes requests. Short retries tend to hammer the same overloaded window;
# after a few normal retries, progressively widen the wait window. Keep the
# cap interactive-friendly: a simple TUI message should fail visibly in minutes,
# not sit silent for 20+ minutes.
_ZAI_CODING_OVERLOAD_LONG_BACKOFF = (30.0, 60.0, 90.0, 120.0)
def jittered_backoff(
attempt: int,
@@ -64,66 +55,3 @@ def jittered_backoff(
jitter = rng.uniform(0, jitter_ratio * delay)
return delay + jitter
def _error_text(error: Any) -> str:
"""Best-effort flattened provider error text for retry classification."""
parts = [
error,
getattr(error, "message", None),
getattr(error, "body", None),
getattr(error, "response", None),
]
return " ".join(str(part) for part in parts if part is not None).lower()
def is_zai_coding_overload_error(*, base_url: str | None, model: str | None, error: Any) -> bool:
"""Return True for Z.AI Coding Plan transient overload 429s.
The coding-plan endpoint reports overload as HTTP 429 with body code 1305
and message "The service may be temporarily overloaded...". Treat only
that narrow shape specially so ordinary quota/billing 429s still fail fast
through the existing classifier.
"""
base = (base_url or "").lower()
model_name = (model or "").lower()
status = getattr(error, "status_code", None)
text = _error_text(error)
return (
status == 429
and "api.z.ai/api/coding/paas/v4" in base
and "glm-5.2" in model_name
and ("1305" in text or "temporarily overloaded" in text)
)
def adaptive_rate_limit_backoff(
attempt: int,
*,
base_url: str | None,
model: str | None,
error: Any,
default_wait: float,
short_attempts: int = 3,
) -> tuple[float, str | None]:
"""Provider-aware rate-limit backoff.
For most providers this returns ``default_wait`` unchanged. For Z.AI
Coding Plan GLM-5.2 overloads, keep the first ``short_attempts`` retries on
the normal short exponential schedule, then switch to progressively longer
waits (30s → 60s → 90s → 120s, capped) plus light jitter.
``attempt`` is 1-based, matching the retry loop's logged attempt number.
Returns ``(wait_seconds, reason_label)`` where ``reason_label`` is suitable
for status/log decoration when a provider-specific policy fired.
"""
if not is_zai_coding_overload_error(base_url=base_url, model=model, error=error):
return default_wait, None
if attempt <= short_attempts:
return default_wait, "zai_coding_overload_short"
idx = min(attempt - short_attempts - 1, len(_ZAI_CODING_OVERLOAD_LONG_BACKOFF) - 1)
base_delay = _ZAI_CODING_OVERLOAD_LONG_BACKOFF[idx]
# A smaller jitter ratio keeps long waits readable while still avoiding
# synchronized retry storms across concurrent Hermes sessions.
return jittered_backoff(1, base_delay=base_delay, max_delay=base_delay, jitter_ratio=0.2), "zai_coding_overload_long"

353
agent/subscription_view.py Normal file
View File

@@ -0,0 +1,353 @@
"""Surface-agnostic core for the ``/subscription`` TUI screen.
Companion to :mod:`agent.billing_view` — same fail-open philosophy: when not
logged in or the portal is unreachable, return a struct with ``logged_in=False``
and let the surface degrade gracefully (never crash). Money is decimal end-to-end
(server emits decimal strings); we only format for display.
The TUI ``SubscriptionOverlay`` is **deep-link only** — it never charges
in-terminal. The manage URL is built locally on the TUI side from the
``portal_url`` and ``org_id`` fields in the subscription state.
WS1 dependency: ``GET /api/billing/subscription`` is a NAS endpoint (WS1 Phase A).
Until it ships, the fail-open contract handles 404s — the builder returns
``logged_in=False`` and the surface degrades gracefully.
"""
from __future__ import annotations
import logging
import os
from dataclasses import dataclass
from decimal import Decimal
from typing import Any, Optional
from agent.billing_view import parse_money
logger = logging.getLogger(__name__)
# =============================================================================
# Parsed sub-structures
# =============================================================================
@dataclass(frozen=True)
class SubscriptionTier:
"""A plan tier in the catalog."""
tier_id: str
name: str
tier_order: int
dollars_per_month: Optional[Decimal] = None
monthly_credits: Optional[Decimal] = None
is_current: bool = False
is_enabled: bool = True
@dataclass(frozen=True)
class CurrentSubscription:
"""The user's active subscription. ``None`` (not this object) = no plan.
When present, ``tier_id`` / ``tier_name`` / ``monthly_credits`` /
``cycle_ends_at`` are always set (NAS guarantees a present ``current`` is a
fully-populated plan). Only ``credits_remaining`` and the cancel/downgrade
fields are optional.
"""
tier_id: Optional[str] = None
tier_name: Optional[str] = None
monthly_credits: Optional[Decimal] = None
credits_remaining: Optional[Decimal] = None
cycle_ends_at: Optional[str] = None # ISO
pending_downgrade_tier_name: Optional[str] = None
pending_downgrade_at: Optional[str] = None # ISO
cancel_at_period_end: bool = False
cancellation_effective_at: Optional[str] = None # ISO
@dataclass(frozen=True)
class SubscriptionState:
"""Parsed ``GET /api/billing/subscription`` — the overview screen's data.
Fail-open: ``logged_in=False`` (and empty fields) when not logged in or the
portal is unreachable.
"""
logged_in: bool
org_name: Optional[str] = None
org_id: Optional[str] = None # org.id from the NAS response
role: Optional[str] = None # "OWNER" | "ADMIN" | "MEMBER"
context: str = "personal" # "personal" | "team"
current: Optional[CurrentSubscription] = None
tiers: tuple[SubscriptionTier, ...] = ()
portal_url: Optional[str] = None
# When the fetch failed (vs cleanly not-logged-in), the message for the surface.
error: Optional[str] = None
@property
def is_admin(self) -> bool:
"""True for OWNER/ADMIN — the roles that can change plans."""
return (self.role or "").upper() in ("OWNER", "ADMIN")
@property
def can_change_plan(self) -> bool:
"""True when the UI should offer plan-change actions (role gate from NAS)."""
return self.is_admin
# =============================================================================
# Payload parsing
# =============================================================================
def _parse_tier(raw: Any) -> Optional[SubscriptionTier]:
if not isinstance(raw, dict):
return None
tier_id = raw.get("tierId") or raw.get("id")
name = raw.get("name")
if not (isinstance(tier_id, str) and isinstance(name, str)):
return None
return SubscriptionTier(
tier_id=tier_id,
name=name,
tier_order=int(raw.get("tierOrder") or raw.get("order") or 0),
dollars_per_month=parse_money(raw.get("dollarsPerMonth") or raw.get("priceUsd")),
monthly_credits=parse_money(raw.get("monthlyCredits")),
is_current=bool(raw.get("isCurrent")),
is_enabled=bool(raw.get("isEnabled", True)),
)
def _parse_current(raw: Any) -> Optional[CurrentSubscription]:
# "No plan" is wire-represented as current:null (free personal OR team) —
# the old all-null-object shape is gone. A present current is a real plan,
# so guard on a real tier id and return None otherwise.
if not isinstance(raw, dict):
return None
tier_id = raw.get("tierId") or raw.get("id")
if not tier_id:
return None
return CurrentSubscription(
tier_id=tier_id,
tier_name=raw.get("tierName") or raw.get("name"),
monthly_credits=parse_money(raw.get("monthlyCredits")),
credits_remaining=parse_money(raw.get("creditsRemaining")),
cycle_ends_at=raw.get("cycleEndsAt"),
pending_downgrade_tier_name=raw.get("pendingDowngradeTierName"),
pending_downgrade_at=raw.get("pendingDowngradeAt"),
cancel_at_period_end=bool(raw.get("cancelAtPeriodEnd")),
cancellation_effective_at=raw.get("cancellationEffectiveAt") or None,
)
def subscription_state_from_payload(
payload: dict[str, Any], *, portal_url: Optional[str] = None
) -> SubscriptionState:
"""Map a raw ``/api/billing/subscription`` JSON dict into :class:`SubscriptionState`."""
raw_org = payload.get("org")
org: dict[str, Any] = raw_org if isinstance(raw_org, dict) else {}
tiers: list[SubscriptionTier] = []
for item in payload.get("tiers") or ():
parsed = _parse_tier(item)
if parsed is not None:
tiers.append(parsed)
raw_context = payload.get("context")
context = raw_context if raw_context in ("personal", "team") else "personal"
return SubscriptionState(
logged_in=True,
org_name=org.get("name"),
org_id=org.get("id") or None,
role=org.get("role"),
context=context,
current=_parse_current(payload.get("current")),
tiers=tuple(tiers),
portal_url=portal_url,
)
# =============================================================================
# Fail-open builders (the surface front doors)
# =============================================================================
def build_subscription_state(*, timeout: float = 15.0) -> SubscriptionState:
"""Fetch + parse ``GET /api/billing/subscription``. Fail-open.
Returns ``SubscriptionState(logged_in=False)`` when not logged in. On a
portal/HTTP failure, returns ``logged_in=False`` with ``error`` set so the
surface can show a clear message rather than crashing.
Dev override: when ``HERMES_DEV_SUBSCRIPTION_FIXTURE`` names a fixture state,
``/subscription`` renders from that fixture instead of the real portal — so
every plan/cancel/downgrade/team/not-admin state is testable on both
the CLI and TUI without a live account. Throwaway scaffolding; see
:func:`dev_fixture_subscription_state`.
"""
fixture = dev_fixture_subscription_state()
if fixture is not None:
return fixture
try:
from hermes_cli.nous_billing import (
BillingAuthError,
BillingError,
_absolutize_portal_url,
get_subscription_state,
resolve_portal_base_url,
)
except Exception:
return SubscriptionState(logged_in=False, error="billing client unavailable")
try:
payload = get_subscription_state(timeout=timeout)
except BillingAuthError:
return SubscriptionState(logged_in=False)
except BillingError as exc:
logger.debug("subscription ▸ /state fetch failed (fail-open)", exc_info=True)
return SubscriptionState(logged_in=False, error=str(exc))
except Exception:
logger.debug("subscription ▸ /state unexpected error (fail-open)", exc_info=True)
return SubscriptionState(logged_in=False, error="could not load subscription state")
raw_portal = payload.get("portalUrl") if isinstance(payload, dict) else None
portal_url = _absolutize_portal_url(raw_portal) if raw_portal else None
if not portal_url:
try:
portal_url = resolve_portal_base_url()
except Exception:
portal_url = None
return subscription_state_from_payload(payload, portal_url=portal_url)
def subscription_manage_url(state: SubscriptionState) -> Optional[str]:
"""Build ``{portal_origin}/manage-subscription?org_id=<id>`` from a state.
Mirrors the TUI's ``buildManageUrl`` (``subscription.ts``): the deep-link
target is NAS's OWN ``/manage-subscription`` page (NOT the Stripe Billing
Portal — decided Jun 23), which routes upgrade→Checkout / downgrade→scheduled
internally. ``org_id`` pins the page to the right account in multi-org
situations. Returns ``None`` when no portal URL is resolvable.
"""
from urllib.parse import urlencode, urlsplit, urlunsplit
if not state.portal_url:
return None
try:
parts = urlsplit(state.portal_url)
except Exception:
return None
if not parts.scheme or not parts.netloc:
return None
query = urlencode({"org_id": state.org_id}) if state.org_id else ""
return urlunsplit((parts.scheme, parts.netloc, "/manage-subscription", query, ""))
# =============================================================================
# Dev fixtures (throwaway scaffolding — env-var driven, no live portal)
# =============================================================================
_DEV_FIXTURE_PORTAL = "https://portal.nousresearch.com/billing"
def _dev_tiers(current_id: Optional[str]) -> tuple[SubscriptionTier, ...]:
specs = [
("free", "Free", 0, Decimal("0"), Decimal("0")),
("plus", "Plus", 1, Decimal("20"), Decimal("1000")),
("super", "Super", 2, Decimal("50"), Decimal("3000")),
("ultra", "Ultra", 3, Decimal("99"), Decimal("7000")),
]
return tuple(
SubscriptionTier(
tier_id=tid,
name=name,
tier_order=order,
dollars_per_month=price,
monthly_credits=credits,
is_current=(tid == current_id),
is_enabled=True,
)
for tid, name, order, price, credits in specs
)
def _dev_current(**over: Any) -> CurrentSubscription:
base: dict[str, Any] = dict(
tier_id="plus",
tier_name="Plus",
monthly_credits=Decimal("1000"),
credits_remaining=Decimal("420"),
cycle_ends_at="2026-07-01",
)
base.update(over)
return CurrentSubscription(**base)
def dev_fixture_subscription_state() -> Optional[SubscriptionState]:
"""Return a fixture :class:`SubscriptionState` for ``HERMES_DEV_SUBSCRIPTION_FIXTURE``.
Lets every CLI/TUI subscription state be exercised without a live portal:
free | mid | top | not-admin | downgrade | cancel | team |
logged-out
Returns ``None`` when the env var is unset/empty (the real portal path runs).
Throwaway scaffolding — mirrors ``HERMES_DEV_CREDITS_FIXTURE``.
"""
name = (os.getenv("HERMES_DEV_SUBSCRIPTION_FIXTURE") or "").strip().lower()
if not name:
return None
common = dict(org_name="Acme Inc", org_id="org_acme", role="OWNER", portal_url=_DEV_FIXTURE_PORTAL)
if name in ("logged-out", "logged_out", "loggedout"):
return SubscriptionState(logged_in=False)
if name == "free":
return SubscriptionState(logged_in=True, current=None, tiers=_dev_tiers(None), **common)
if name in ("mid", "mid-tier"):
return SubscriptionState(logged_in=True, current=_dev_current(), tiers=_dev_tiers("plus"), **common)
if name in ("top", "top-tier"):
return SubscriptionState(
logged_in=True,
current=_dev_current(tier_id="ultra", tier_name="Ultra", monthly_credits=Decimal("7000"), credits_remaining=Decimal("5000")),
tiers=_dev_tiers("ultra"),
**common,
)
if name in ("not-admin", "member"):
return SubscriptionState(
logged_in=True,
current=_dev_current(),
tiers=_dev_tiers("plus"),
org_name="Acme Inc",
org_id="org_acme",
role="MEMBER",
portal_url=_DEV_FIXTURE_PORTAL,
)
if name == "downgrade":
return SubscriptionState(
logged_in=True,
current=_dev_current(tier_id="super", tier_name="Super", monthly_credits=Decimal("3000"), credits_remaining=Decimal("1500"), pending_downgrade_tier_name="Plus", pending_downgrade_at="2026-07-15"),
tiers=_dev_tiers("super"),
**common,
)
if name == "cancel":
return SubscriptionState(
logged_in=True,
current=_dev_current(cancel_at_period_end=True, cancellation_effective_at="2026-07-01"),
tiers=_dev_tiers("plus"),
**common,
)
if name == "team":
return SubscriptionState(logged_in=True, context="team", current=None, tiers=(), org_name="Acme Engineering", org_id="org_eng", role="OWNER", portal_url=_DEV_FIXTURE_PORTAL)
# Unknown name → behave as logged-out so the misconfiguration is visible.
return SubscriptionState(logged_in=False, error=f"unknown HERMES_DEV_SUBSCRIPTION_FIXTURE: {name}")

View File

@@ -11,8 +11,7 @@ Pure module-level utilities extracted from ``run_agent.py``:
``_append_subdir_hint_to_multimodal`` — envelope helpers for the
``{"_multimodal": True, "content": [...], "text_summary": ...}`` dict
shape returned by tools like ``computer_use``.
* ``_extract_file_mutation_targets`` / ``_extract_landed_file_mutation_paths`` /
``_extract_error_preview`` —
* ``_extract_file_mutation_targets`` / ``_extract_error_preview``
per-turn file-mutation verifier inputs.
* ``_trajectory_normalize_msg`` — strip image blobs from a message for
trajectory saving.
@@ -270,35 +269,6 @@ def _extract_file_mutation_targets(tool_name: str, args: Dict[str, Any]) -> List
return []
def _extract_landed_file_mutation_paths(
tool_name: str,
args: Dict[str, Any],
result: Any,
) -> List[str]:
"""Return the concrete file paths a successful mutation reports."""
targets = _extract_file_mutation_targets(tool_name, args)
if tool_name not in _FILE_MUTATING_TOOLS or not isinstance(result, str):
return targets
try:
data = json.loads(result.strip())
except Exception:
return targets
if not isinstance(data, dict):
return targets
files = data.get("files_modified")
if isinstance(files, list):
landed = [str(p) for p in files if p]
if landed:
return landed
resolved = data.get("resolved_path")
if resolved:
return [str(resolved)]
return targets
def _extract_error_preview(result: Any, max_len: int = 180) -> str:
"""Pull a one-line error summary out of a tool result for footer display."""
text = _multimodal_text_summary(result) if result is not None else ""
@@ -441,7 +411,6 @@ __all__ = [
"_multimodal_text_summary",
"_append_subdir_hint_to_multimodal",
"_extract_file_mutation_targets",
"_extract_landed_file_mutation_paths",
"_extract_error_preview",
"_trajectory_normalize_msg",
"make_tool_result_message",

View File

@@ -69,35 +69,12 @@ def _budget_for_agent(agent) -> BudgetConfig:
_MAX_TOOL_WORKERS = 8
def _flush_session_db_after_tool_progress(
agent,
messages: list,
*,
stage: str,
) -> None:
"""Best-effort incremental SessionDB flush for tool-call progress.
Tool execution can perform side effects that terminate or restart the
current Hermes process before the normal turn-end persistence path runs.
Flush the already-appended assistant/tool messages immediately so the
transcript survives destructive-but-valid tool calls.
"""
try:
agent._flush_messages_to_session_db(messages)
except Exception as exc:
logger.warning("Incremental tool-call persistence failed after %s: %s", stage, exc)
def _ra():
"""Lazy reference to ``run_agent`` so patches like ``run_agent._set_interrupt`` work."""
import run_agent
return run_agent
def _is_interpreter_shutdown_submit_error(exc: RuntimeError) -> bool:
return "cannot schedule new futures after interpreter shutdown" in str(exc)
def _emit_terminal_post_tool_call(
agent,
*,
@@ -302,11 +279,6 @@ def execute_tool_calls_concurrent(agent, assistant_message, messages: list, effe
f"[Tool execution cancelled — {tc.function.name} was skipped due to user interrupt]",
tc.id,
))
_flush_session_db_after_tool_progress(
agent,
messages,
stage=f"cancelled tool result {tc.function.name}",
)
return
# ── Parse args + pre-execution bookkeeping ───────────────────────
@@ -609,40 +581,13 @@ def execute_tool_calls_concurrent(agent, assistant_message, messages: list, effe
if runnable_calls:
max_workers = min(len(runnable_calls), _MAX_TOOL_WORKERS)
with concurrent.futures.ThreadPoolExecutor(max_workers=max_workers) as executor:
for submit_index, (i, tc, name, args) in enumerate(runnable_calls):
for i, tc, name, args in runnable_calls:
# Propagate the agent turn's ContextVars (e.g.
# _approval_session_key) AND thread-local approval/sudo
# callbacks into the worker thread; clears callbacks on exit.
try:
f = executor.submit(
propagate_context_to_thread(_run_tool), i, tc, name, args, parsed_calls[i][3]
)
except RuntimeError as submit_error:
if not _is_interpreter_shutdown_submit_error(submit_error):
raise
skipped_calls = runnable_calls[submit_index:]
logger.warning(
"interpreter shutdown while scheduling concurrent tools; "
"skipping %d unsubmitted tool(s)",
len(skipped_calls),
)
for skipped_i, _tc, skipped_name, skipped_args in skipped_calls:
if results[skipped_i] is None:
middleware_trace = parsed_calls[skipped_i][3]
result = (
f"Error executing tool '{skipped_name}': "
"Python interpreter is shutting down; tool was not started"
)
results[skipped_i] = (
skipped_name,
skipped_args,
result,
0.0,
True,
False,
middleware_trace,
)
break
f = executor.submit(
propagate_context_to_thread(_run_tool), i, tc, name, args, parsed_calls[i][3]
)
futures.append(f)
# Wait for all to complete with periodic heartbeats so the
@@ -823,11 +768,6 @@ def execute_tool_calls_concurrent(agent, assistant_message, messages: list, effe
# String results pass through unchanged.
_tool_content = agent._tool_result_content_for_active_model(name, function_result)
messages.append(make_tool_result_message(name, _tool_content, tc.id))
_flush_session_db_after_tool_progress(
agent,
messages,
stage=f"tool result {name}",
)
# ── Per-tool /steer drain ───────────────────────────────────
# Same as the sequential path: drain between each collected
@@ -863,16 +803,13 @@ def execute_tool_calls_sequential(agent, assistant_message, messages: list, effe
agent._vprint(f"{agent.log_prefix}⚡ Interrupt: skipping {len(remaining_calls)} tool call(s)", force=True)
for skipped_tc in remaining_calls:
skipped_name = skipped_tc.function.name
messages.append(make_tool_result_message(
skipped_name,
f"[Tool execution cancelled — {skipped_name} was skipped due to user interrupt]",
skipped_tc.id,
))
_flush_session_db_after_tool_progress(
agent,
messages,
stage=f"cancelled tool result {skipped_name}",
)
skip_msg = {
"role": "tool",
"name": skipped_name,
"content": f"[Tool execution cancelled — {skipped_name} was skipped due to user interrupt]",
"tool_call_id": skipped_tc.id,
}
messages.append(skip_msg)
break
function_name = tool_call.function.name
@@ -1465,11 +1402,6 @@ def execute_tool_calls_sequential(agent, assistant_message, messages: list, effe
# (see parallel path for rationale). String results pass through.
_tool_content = agent._tool_result_content_for_active_model(function_name, function_result)
messages.append(make_tool_result_message(function_name, _tool_content, tool_call.id))
_flush_session_db_after_tool_progress(
agent,
messages,
stage=f"tool result {function_name}",
)
# ── Per-tool /steer drain ───────────────────────────────────
# Drain pending steer BETWEEN individual tool calls so the
@@ -1496,11 +1428,6 @@ def execute_tool_calls_sequential(agent, assistant_message, messages: list, effe
f"[Tool execution skipped — {skipped_name} was not started. User sent a new message]",
skipped_tc.id,
))
_flush_session_db_after_tool_progress(
agent,
messages,
stage=f"skipped tool result {skipped_name}",
)
break
if agent.tool_delay > 0 and i < len(assistant_message.tool_calls):

View File

@@ -5,47 +5,12 @@ This transport owns format conversion and normalization — NOT client lifecycle
streaming, or the _run_codex_stream() call path.
"""
import hashlib
import json
from typing import Any, Dict, List, Optional
from agent.transports.base import ProviderTransport
from agent.transports.types import NormalizedResponse, ToolCall
def _content_cache_key(instructions: str, tools: Optional[List[Dict[str, Any]]]) -> Optional[str]:
"""Content-address the prompt cache key from the static request prefix.
Returns ``pck_<sha256[:24]>`` of (instructions + sorted tool schemas), or
None when there is nothing static to key on. The cache key is a routing
hint only — never a correctness boundary — so two requests sharing a system
prompt and tool set intentionally resolve to the same warm prefix bucket.
The fix this exists for: recurring cron jobs build session_id as
``cron_<id>_<timestamp>``, so using session_id as the cache key made every
fire cache-cold. The static prefix (identity + tools) is identical across
fires, so hashing it gives a stable key that stays warm within the
provider's cache TTL. Sorting tools by name keeps the hash insertion-order
independent.
"""
if not instructions and not tools:
return None
tools_part = ""
if tools:
sorted_tools = sorted(
(t for t in tools if isinstance(t, dict)),
key=lambda t: str(t.get("name") or t.get("type") or ""),
)
tools_part = json.dumps(
sorted_tools, sort_keys=True, ensure_ascii=False, separators=(",", ":")
)
# \x00 separator so instructions ending in the tool JSON can't collide with
# a request whose instructions contain that JSON and whose tools are empty.
content = f"{instructions or ''}\x00{tools_part}"
digest = hashlib.sha256(content.encode("utf-8", errors="replace")).hexdigest()[:24]
return f"pck_{digest}"
class ResponsesApiTransport(ProviderTransport):
"""Transport for api_mode='codex_responses'.
@@ -106,10 +71,7 @@ class ResponsesApiTransport(ProviderTransport):
params:
instructions: str — system prompt (extracted from messages[0] if not given)
reasoning_config: dict | None — {effort, enabled}
session_id: str | None — transcript/session id; drives the xAI
x-grok-conv-id header and the Codex cache-scope headers, and is
the fallback prompt_cache_key when there is no static prefix to
content-address
session_id: str | None — used for prompt_cache_key + xAI conv header
max_tokens: int | None — max_output_tokens
timeout: float | None — per-request timeout forwarded to the SDK
request_overrides: dict | None — extra kwargs merged in
@@ -250,17 +212,10 @@ class ResponsesApiTransport(ProviderTransport):
kwargs["parallel_tool_calls"] = True
session_id = params.get("session_id")
# prompt_cache_key is content-addressed from the static prefix
# (instructions + tools), NOT session_id — recurring cron jobs carry a
# per-fire timestamp in session_id (cron_<id>_<ts>) that made every run
# cache-cold. session_id is left untouched for transcript isolation and
# the cache-scope routing headers below. Falls back to session_id when
# there is no static content to hash.
cache_key = _content_cache_key(instructions, response_tools) or session_id
# xAI Responses takes prompt_cache_key in extra_body (set further
# down); GitHub Models opts out of cache-key routing entirely.
if not is_github_responses and not is_xai_responses and cache_key:
kwargs["prompt_cache_key"] = cache_key
if not is_github_responses and not is_xai_responses and session_id:
kwargs["prompt_cache_key"] = session_id
if reasoning_enabled and is_xai_responses:
from agent.model_metadata import grok_supports_reasoning_effort
@@ -371,7 +326,7 @@ class ResponsesApiTransport(ProviderTransport):
merged_extra_body: Dict[str, Any] = {}
if isinstance(existing_extra_body, dict):
merged_extra_body.update(existing_extra_body)
merged_extra_body.setdefault("prompt_cache_key", cache_key)
merged_extra_body.setdefault("prompt_cache_key", session_id)
kwargs["extra_body"] = merged_extra_body
return kwargs

View File

@@ -217,9 +217,7 @@ class CodexEventProjector:
def _project_mcp_tool_call(self, item: dict, item_id: str) -> ProjectionResult:
server = item.get("server") or "mcp"
tool = item.get("tool") or "unknown"
# Mirror the native MCP tool-name convention (mcp__server__tool) so the
# deterministic call_id input stays consistent with registration names.
call_id = _deterministic_call_id(f"mcp__{server}__{tool}", item_id)
call_id = _deterministic_call_id(f"mcp_{server}_{tool}", item_id)
args = item.get("arguments") or {}
if not isinstance(args, dict):
args = {"arguments": args}

View File

@@ -29,10 +29,7 @@ from dataclasses import dataclass
from typing import Any, Dict, List, Optional
from agent.iteration_budget import IterationBudget
from agent.model_metadata import (
estimate_messages_tokens_rough,
estimate_request_tokens_rough,
)
from agent.model_metadata import estimate_request_tokens_rough
logger = logging.getLogger(__name__)
@@ -60,34 +57,6 @@ def _compression_made_progress(
return orig_tokens > 0 and new_tokens < orig_tokens * 0.95
def _should_run_preflight_estimate(
messages: List[Dict[str, Any]],
protect_first_n: int,
protect_last_n: int,
threshold_tokens: int,
) -> bool:
"""Cheap gate for the (expensive) full preflight token estimate.
Returns ``True`` when either:
(a) message count exceeds the protected ranges (the historical gate), or
(b) a cheap char-based estimate already crosses the configured threshold
— the few-but-huge case from issue #27405 that the count-only gate
would silently skip (a handful of very large messages never trips
the count condition, so compression was never attempted and the
turn hit a hard context-overflow error).
Branch (b) uses ``estimate_messages_tokens_rough`` (the shared char-based
estimator) so a single large base64 image isn't mistaken for ~250K tokens.
It intentionally undercounts vs. the full request estimate — it omits the
system prompt and tool schemas — because it is only a *hint* deciding
whether to pay for the authoritative ``estimate_request_tokens_rough``,
which (together with ``should_compress``) makes the real decision.
"""
if len(messages) > protect_first_n + protect_last_n + 1:
return True
return estimate_messages_tokens_rough(messages) >= threshold_tokens
@dataclass
class TurnContext:
"""Values produced by the turn prologue and consumed by the turn loop."""
@@ -142,13 +111,7 @@ def build_turn_context(
# Guard stdio against OSError from broken pipes (systemd/headless/daemon).
install_safe_stdio()
# NOTE: the DB session row is created later, AFTER the system prompt is
# restored/built (see _ensure_db_session() below the system-prompt block).
# Creating it here — before _cached_system_prompt is populated — inserts a
# row with system_prompt=NULL on a fresh API/gateway agent that carries
# client-managed history, which then trips the "stored system prompt is
# null; rebuilding from scratch" warning and a needless first-turn prefix
# cache miss. (Issue #45499.)
agent._ensure_db_session()
# Tell auxiliary_client what the live main provider/model are for this turn.
try:
@@ -315,11 +278,6 @@ def build_turn_context(
active_system_prompt = agent._cached_system_prompt
# Create the DB session row now that _cached_system_prompt is populated, so
# the persisted snapshot is written non-NULL on the first turn (Issue
# #45499). Idempotent: _ensure_db_session() no-ops once the row exists.
agent._ensure_db_session()
# Crash-resilience: persist the inbound user turn as soon as the session row exists.
try:
agent._persist_session(messages, conversation_history)
@@ -331,14 +289,10 @@ def build_turn_context(
)
# ── Preflight context compression ──
# Gate the (expensive) full token estimate behind a cheap pre-check.
# See ``_should_run_preflight_estimate`` for the OR semantics that fix
# issue #27405 (a few very large messages slipping past the count gate).
if agent.compression_enabled and _should_run_preflight_estimate(
messages,
agent.context_compressor.protect_first_n,
agent.context_compressor.protect_last_n,
agent.context_compressor.threshold_tokens,
if (
agent.compression_enabled
and len(messages) > agent.context_compressor.protect_first_n
+ agent.context_compressor.protect_last_n + 1
):
_preflight_tokens = estimate_request_tokens_rough(
messages,
@@ -438,8 +392,6 @@ def build_turn_context(
# Per-turn file-mutation verifier state.
agent._turn_failed_file_mutations = {}
agent._turn_file_mutation_paths = set()
agent._verification_stop_nudges = 0
# Record the execution thread so interrupt()/clear_interrupt() can scope
# the tool-level interrupt signal to THIS agent's thread only.

View File

@@ -166,25 +166,6 @@ def finalize_turn(
# same empty-response loop again.
try:
agent._drop_trailing_empty_response_scaffolding(messages)
# When the turn was interrupted and the last message is a tool
# result, append a synthetic assistant message to close the
# tool-call sequence. Without this, the session persists a
# ``tool → user`` alternation that strict providers (Gemini,
# Claude) reject, causing them to hallucinate a continuation of
# the user's message on the next turn (#48879).
#
# ``_drop_trailing_empty_response_scaffolding`` only rewinds the
# tool tail when an empty-response scaffolding flag is present; a
# clean ``/stop`` interrupt after a successful tool sets no such
# flag, so the tool result survives as the tail and we close it
# here instead. On an interrupt ``final_response`` is typically
# empty, so fall back to an explicit placeholder rather than
# persisting an empty-content assistant turn.
if interrupted:
from agent.message_sanitization import close_interrupted_tool_sequence
close_interrupted_tool_sequence(messages, final_response)
agent._persist_session(messages, conversation_history)
except Exception as _persist_err:
_cleanup_errors.append(f"persist_session: {_persist_err}")

View File

@@ -1,618 +0,0 @@
"""Coding verification evidence ledger.
This module records what the agent actually proved while working in a code
workspace. It is deliberately passive: it never decides to run a suite, never
blocks completion, and never upgrades targeted checks into "repo green".
"""
from __future__ import annotations
import json
import re
import shlex
import sqlite3
import tempfile
import threading
from dataclasses import dataclass
from datetime import datetime, timedelta, timezone
from pathlib import Path
from typing import Any, Optional
from hermes_constants import get_hermes_home
_DB_LOCK = threading.Lock()
_MAX_OUTPUT_SUMMARY_CHARS = 2000
_MAX_EVIDENCE_AGE_DAYS = 30
_MAX_EVENTS_PER_SESSION_ROOT = 100
_MAX_TOTAL_UNREFERENCED_EVENTS = 10_000
_AD_HOC_SCRIPT_NAME_PREFIXES = ("hermes-verify-", "hermes-ad-hoc-")
_VERIFY_SCHEMA_VERSION = 1
_SHELL_SPLIT_RE = re.compile(r"\s*(?:&&|\|\||;)\s*")
@dataclass(frozen=True)
class VerificationEvidence:
"""A classified command result worth recording."""
command: str
canonical_command: str
kind: str
scope: str
status: str
exit_code: int
cwd: str
root: str
session_id: str
output_summary: str = ""
def _utc_now() -> str:
return datetime.now(timezone.utc).isoformat()
def _retention_cutoff() -> str:
return (datetime.now(timezone.utc) - timedelta(days=_MAX_EVIDENCE_AGE_DAYS)).isoformat()
def _db_path() -> Path:
return get_hermes_home() / "verification_evidence.db"
def _connect() -> sqlite3.Connection:
path = _db_path()
path.parent.mkdir(parents=True, exist_ok=True)
conn = sqlite3.connect(path)
conn.execute("PRAGMA journal_mode=WAL")
conn.execute("PRAGMA busy_timeout=5000")
conn.row_factory = sqlite3.Row
_ensure_schema(conn)
return conn
def _ensure_schema(conn: sqlite3.Connection) -> None:
conn.execute(
"""
CREATE TABLE IF NOT EXISTS meta (
key TEXT PRIMARY KEY,
value TEXT NOT NULL
)
"""
)
conn.execute(
"""
CREATE TABLE IF NOT EXISTS verification_events (
id INTEGER PRIMARY KEY AUTOINCREMENT,
created_at TEXT NOT NULL,
session_id TEXT NOT NULL,
cwd TEXT NOT NULL,
root TEXT NOT NULL,
command TEXT NOT NULL,
canonical_command TEXT NOT NULL,
kind TEXT NOT NULL,
scope TEXT NOT NULL,
status TEXT NOT NULL,
exit_code INTEGER NOT NULL,
output_summary TEXT NOT NULL
)
"""
)
conn.execute(
"""
CREATE TABLE IF NOT EXISTS verification_state (
session_id TEXT NOT NULL,
root TEXT NOT NULL,
last_event_id INTEGER,
last_edit_at TEXT,
changed_paths_json TEXT NOT NULL DEFAULT '[]',
PRIMARY KEY (session_id, root)
)
"""
)
conn.execute(
"""
CREATE INDEX IF NOT EXISTS idx_verification_events_session_root
ON verification_events(session_id, root, id DESC)
"""
)
conn.execute(
"INSERT OR REPLACE INTO meta(key, value) VALUES ('schema_version', ?)",
(str(_VERIFY_SCHEMA_VERSION),),
)
conn.commit()
def _split_segment_tokens(command: str) -> list[list[str]]:
segments: list[list[str]] = []
for segment in _SHELL_SPLIT_RE.split(command.strip()):
if not segment:
continue
try:
tokens = shlex.split(segment)
except ValueError:
continue
if tokens:
segments.append(tokens)
return segments
def _clean_token(token: str) -> str:
token = token.strip()
while token.startswith("./"):
token = token[2:]
return token
def _canonical_tokens(canonical: str) -> list[str]:
try:
return [_clean_token(t) for t in shlex.split(canonical) if t]
except ValueError:
return []
def _find_subsequence(tokens: list[str], needle: list[str]) -> Optional[int]:
if not tokens or not needle or len(needle) > len(tokens):
return None
cleaned = [_clean_token(t) for t in tokens]
for idx in range(0, len(cleaned) - len(needle) + 1):
if cleaned[idx:idx + len(needle)] == needle:
return idx
return None
def _strip_command_prefix(tokens: list[str]) -> list[str]:
"""Remove harmless command prefixes before matching canonical commands."""
remaining = list(tokens)
if remaining and remaining[0] == "env":
remaining = remaining[1:]
while remaining and "=" in remaining[0] and not remaining[0].startswith("-"):
remaining = remaining[1:]
while remaining and remaining[0] in {"command", "time", "noglob"}:
remaining = remaining[1:]
return remaining
def _equivalent_needles(needle: list[str]) -> list[list[str]]:
"""Return command spellings equivalent to the detected canonical command."""
candidates = [needle]
if len(needle) >= 3 and needle[1] == "run":
package_manager = needle[0]
script_name = needle[2]
if package_manager in {"npm", "pnpm", "yarn", "bun"}:
candidates.append([package_manager, script_name])
if len(needle) == 1 and "/" in needle[0]:
candidates.extend([["bash", needle[0]], ["sh", needle[0]]])
if needle == ["pytest"]:
candidates.extend(
[
["python", "-m", "pytest"],
["python3", "-m", "pytest"],
["uv", "run", "pytest"],
["poetry", "run", "pytest"],
["pipenv", "run", "pytest"],
]
)
return candidates
def _find_canonical_match(command: str, canonical_commands: list[str]) -> Optional[tuple[str, list[str]]]:
"""Return ``(canonical, trailing_args)`` for the first detected command."""
segments = _split_segment_tokens(command)
for canonical in canonical_commands:
needle = _canonical_tokens(canonical)
if not needle:
continue
for tokens in segments:
candidate_tokens = _strip_command_prefix(tokens)
for candidate in _equivalent_needles(needle):
if candidate_tokens[:len(candidate)] == candidate:
return canonical, candidate_tokens[len(candidate):]
return None
def _kind_for_command(canonical: str) -> str:
lowered = canonical.lower()
if any(word in lowered for word in ("lint", "eslint", "ruff")):
return "lint"
if any(word in lowered for word in ("typecheck", "tsc", "mypy", "pyright", "ty")):
return "typecheck"
if "build" in lowered:
return "build"
if "fmt" in lowered or "format" in lowered:
return "format"
if "check" in lowered and "test" not in lowered:
return "check"
return "test"
def _looks_like_target(arg: str) -> bool:
if not arg or arg.startswith("-") or "=" in arg:
return False
return (
"/" in arg
or "\\" in arg
or "::" in arg
or arg.endswith((".py", ".js", ".jsx", ".ts", ".tsx", ".rs", ".go", ".java"))
or arg.startswith(("test_", "tests", "spec", "__tests__"))
)
def _scope_for_args(args: list[str]) -> str:
return "targeted" if any(_looks_like_target(arg) for arg in args) else "full"
def _is_under_temp_dir(token: str) -> bool:
if not token or token.startswith("-"):
return False
try:
path = Path(token).expanduser()
if not path.is_absolute():
return False
resolved = path.resolve()
temp_root = Path(tempfile.gettempdir()).resolve()
return resolved == temp_root or temp_root in resolved.parents
except Exception:
return False
def _is_under_root(token: str, root: str | Path | None) -> bool:
if not root:
return False
try:
path = Path(token).expanduser().resolve()
root_path = Path(root).expanduser().resolve()
return path == root_path or root_path in path.parents
except Exception:
return False
def _is_temp_script_path(token: str, root: str | Path | None) -> bool:
try:
name = Path(token).expanduser().name
except Exception:
return False
return (
name.startswith(_AD_HOC_SCRIPT_NAME_PREFIXES)
and _is_under_temp_dir(token)
and not _is_under_root(token, root)
)
def _ad_hoc_script_args(tokens: list[str], root: str | Path | None) -> Optional[list[str]]:
candidate_tokens = _strip_command_prefix(tokens)
if not candidate_tokens:
return None
command = candidate_tokens[0]
if _is_temp_script_path(command, root):
return candidate_tokens[1:]
if command in {"python", "python3", "node", "bash", "sh", "ruby", "perl"}:
for idx, token in enumerate(candidate_tokens[1:], start=1):
if token == "--":
continue
if _is_temp_script_path(token, root):
return candidate_tokens[idx + 1:]
if not token.startswith("-"):
return None
return None
def _find_ad_hoc_match(command: str, root: str | Path | None) -> Optional[list[str]]:
for tokens in _split_segment_tokens(command):
trailing_args = _ad_hoc_script_args(tokens, root)
if trailing_args is not None:
return trailing_args
return None
def _summarize_output(output: str) -> str:
text = (output or "").strip()
if len(text) <= _MAX_OUTPUT_SUMMARY_CHARS:
return text
head = _MAX_OUTPUT_SUMMARY_CHARS // 3
tail = _MAX_OUTPUT_SUMMARY_CHARS - head
return (
text[:head]
+ f"\n... [{len(text) - _MAX_OUTPUT_SUMMARY_CHARS} chars omitted] ...\n"
+ text[-tail:]
)
def _prune_old_events(conn: sqlite3.Connection, *, session_id: str, root: str) -> None:
"""Bound ledger growth without deleting the current state pointer."""
cutoff = _retention_cutoff()
conn.execute(
"""
DELETE FROM verification_events
WHERE session_id = ?
AND root = ?
AND id NOT IN (
SELECT id FROM verification_events
WHERE session_id = ? AND root = ?
ORDER BY id DESC
LIMIT ?
)
""",
(session_id, root, session_id, root, _MAX_EVENTS_PER_SESSION_ROOT),
)
conn.execute(
"""
DELETE FROM verification_state
WHERE (
last_edit_at IS NOT NULL
AND last_edit_at < ?
)
OR (
last_edit_at IS NULL
AND last_event_id IN (
SELECT id FROM verification_events
WHERE created_at < ?
)
)
""",
(cutoff, cutoff),
)
conn.execute(
"""
DELETE FROM verification_events
WHERE created_at < ?
AND id NOT IN (
SELECT last_event_id FROM verification_state
WHERE last_event_id IS NOT NULL
)
""",
(cutoff,),
)
conn.execute(
"""
DELETE FROM verification_events
WHERE id NOT IN (
SELECT id FROM verification_events
ORDER BY id DESC
LIMIT ?
)
AND id NOT IN (
SELECT last_event_id FROM verification_state
WHERE last_event_id IS NOT NULL
)
""",
(_MAX_TOTAL_UNREFERENCED_EVENTS,),
)
def classify_verification_command(
command: str,
*,
cwd: str | Path | None = None,
session_id: str | None = None,
exit_code: int = 0,
output: str = "",
) -> Optional[VerificationEvidence]:
"""Classify a terminal command as verification evidence, if applicable."""
if not command or not isinstance(command, str):
return None
try:
from agent.coding_context import project_facts_for
facts = project_facts_for(cwd)
except Exception:
facts = None
if not facts:
return None
verify_commands = list(facts.get("verifyCommands") or [])
match = _find_canonical_match(command, verify_commands)
is_ad_hoc = False
if match is None and not verify_commands:
ad_hoc_args = _find_ad_hoc_match(command, facts.get("root"))
if ad_hoc_args is not None:
match = ("ad-hoc verification script", ad_hoc_args)
is_ad_hoc = True
if match is None:
return None
canonical, trailing_args = match
return VerificationEvidence(
command=command,
canonical_command=canonical,
kind="ad_hoc" if is_ad_hoc else _kind_for_command(canonical),
scope="targeted" if is_ad_hoc else _scope_for_args(trailing_args),
status="passed" if int(exit_code) == 0 else "failed",
exit_code=int(exit_code),
cwd=str(Path(cwd or ".").resolve()),
root=str(facts.get("root") or Path(cwd or ".").resolve()),
session_id=str(session_id or "default"),
output_summary=_summarize_output(output),
)
def record_terminal_result(
*,
command: str,
cwd: str | Path | None,
session_id: str | None,
exit_code: int,
output: str = "",
) -> Optional[dict[str, Any]]:
"""Record a foreground terminal result when it is verification evidence."""
evidence = classify_verification_command(
command,
cwd=cwd,
session_id=session_id,
exit_code=exit_code,
output=output,
)
if evidence is None:
return None
created_at = _utc_now()
with _DB_LOCK:
with _connect() as conn:
cur = conn.execute(
"""
INSERT INTO verification_events(
created_at, session_id, cwd, root, command, canonical_command,
kind, scope, status, exit_code, output_summary
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
""",
(
created_at,
evidence.session_id,
evidence.cwd,
evidence.root,
evidence.command,
evidence.canonical_command,
evidence.kind,
evidence.scope,
evidence.status,
evidence.exit_code,
evidence.output_summary,
),
)
if cur.lastrowid is None:
raise RuntimeError("verification event insert did not return an id")
event_id = int(cur.lastrowid)
conn.execute(
"""
INSERT INTO verification_state(
session_id, root, last_event_id, last_edit_at, changed_paths_json
) VALUES (?, ?, ?, NULL, '[]')
ON CONFLICT(session_id, root) DO UPDATE SET
last_event_id = excluded.last_event_id,
last_edit_at = NULL,
changed_paths_json = '[]'
""",
(evidence.session_id, evidence.root, event_id),
)
_prune_old_events(conn, session_id=evidence.session_id, root=evidence.root)
conn.commit()
return {"id": event_id, **evidence.__dict__, "created_at": created_at}
def mark_workspace_edited(
*,
session_id: str | None,
cwd: str | Path | None,
paths: list[str] | tuple[str, ...] | None = None,
) -> Optional[dict[str, Any]]:
"""Mark verification evidence stale after a successful file edit."""
try:
from agent.coding_context import project_facts_for
facts = project_facts_for(cwd)
except Exception:
facts = None
if not facts:
return None
sid = str(session_id or "default")
root = str(facts.get("root") or Path(cwd or ".").resolve())
changed_paths = sorted({str(p) for p in (paths or []) if p})
edited_at = _utc_now()
with _DB_LOCK:
with _connect() as conn:
row = conn.execute(
"""
SELECT changed_paths_json FROM verification_state
WHERE session_id = ? AND root = ?
""",
(sid, root),
).fetchone()
existing: set[str] = set()
if row is not None:
try:
existing = set(json.loads(row["changed_paths_json"] or "[]"))
except (TypeError, ValueError):
existing = set()
merged = sorted((existing | set(changed_paths)))[-200:]
conn.execute(
"""
INSERT INTO verification_state(
session_id, root, last_event_id, last_edit_at, changed_paths_json
) VALUES (?, ?, NULL, ?, ?)
ON CONFLICT(session_id, root) DO UPDATE SET
last_edit_at = excluded.last_edit_at,
changed_paths_json = excluded.changed_paths_json
""",
(sid, root, edited_at, json.dumps(merged)),
)
conn.commit()
return {"session_id": sid, "root": root, "last_edit_at": edited_at, "changed_paths": changed_paths}
def verification_status(
*,
session_id: str | None,
cwd: str | Path | None,
) -> dict[str, Any]:
"""Return the best known verification state for a session/workspace."""
try:
from agent.coding_context import project_facts_for
facts = project_facts_for(cwd)
except Exception:
facts = None
if not facts:
return {"status": "not_applicable", "evidence": None}
sid = str(session_id or "default")
root = str(facts.get("root") or Path(cwd or ".").resolve())
with _DB_LOCK:
with _connect() as conn:
state = conn.execute(
"""
SELECT last_event_id, last_edit_at, changed_paths_json
FROM verification_state
WHERE session_id = ? AND root = ?
""",
(sid, root),
).fetchone()
if state is None:
return {
"status": "unverified",
"evidence": None,
"root": root,
"session_id": sid,
"changed_paths": [],
}
event = None
if state["last_event_id"] is not None:
event = conn.execute(
"SELECT * FROM verification_events WHERE id = ?",
(state["last_event_id"],),
).fetchone()
changed_paths: list[str] = []
try:
changed_paths = json.loads(state["changed_paths_json"] or "[]")
except (TypeError, ValueError):
changed_paths = []
if event is None:
return {
"status": "unverified",
"evidence": None,
"root": root,
"session_id": sid,
"changed_paths": changed_paths,
}
evidence = dict(event)
if state["last_edit_at"] and state["last_edit_at"] > evidence["created_at"]:
status = "stale"
else:
status = evidence["status"]
return {
"status": status,
"evidence": evidence,
"root": root,
"session_id": sid,
"changed_paths": changed_paths,
}

View File

@@ -1,164 +0,0 @@
"""Turn-end verification guard for coding edits.
This module is intentionally policy-only. It never runs checks itself; it turns
the passive verification ledger into a bounded follow-up when the model tries to
finish immediately after editing code without fresh evidence.
"""
from __future__ import annotations
import os
import tempfile
from pathlib import Path
from typing import Any, Iterable
_MAX_CHANGED_PATHS_IN_NUDGE = 8
def verify_on_stop_enabled(config: dict[str, Any] | None = None) -> bool:
"""Return whether edit -> verify-before-finish behavior is enabled."""
env = os.environ.get("HERMES_VERIFY_ON_STOP")
if env is not None:
return env.strip().lower() not in {"0", "false", "no", "off"}
if config is None:
try:
from hermes_cli.config import load_config
config = load_config()
except Exception:
config = {}
agent_cfg = (config or {}).get("agent") if isinstance(config, dict) else None
if isinstance(agent_cfg, dict) and "verify_on_stop" in agent_cfg:
return bool(agent_cfg.get("verify_on_stop"))
return True
def _candidate_cwds(paths: Iterable[str]) -> list[Path]:
candidates: list[Path] = []
seen: set[str] = set()
for raw in paths:
if not raw:
continue
try:
path = Path(raw).expanduser()
candidate = path if path.is_dir() else path.parent
resolved = str(candidate.resolve())
except Exception:
continue
if resolved not in seen:
seen.add(resolved)
candidates.append(Path(resolved))
return candidates
def _verification_snapshot(
*,
session_id: str | None,
changed_paths: list[str],
) -> tuple[dict[str, Any], dict[str, Any]] | None:
"""Return ``(status, facts)`` for the first edited workspace needing proof."""
try:
from agent.coding_context import project_facts_for
from agent.verification_evidence import verification_status
except Exception:
return None
first_snapshot: tuple[dict[str, Any], dict[str, Any]] | None = None
for cwd in _candidate_cwds(changed_paths):
facts = project_facts_for(cwd)
if not facts:
continue
status = verification_status(session_id=session_id, cwd=cwd)
snapshot = (status, facts)
if first_snapshot is None:
first_snapshot = snapshot
if str(status.get("status") or "unverified") != "passed":
return snapshot
return first_snapshot
def _format_changed_paths(paths: list[str]) -> str:
shown = paths[:_MAX_CHANGED_PATHS_IN_NUDGE]
lines = [f"- `{path}`" for path in shown]
remaining = len(paths) - len(shown)
if remaining > 0:
lines.append(f"- ... and {remaining} more")
return "\n".join(lines)
def _status_detail(status: dict[str, Any]) -> str:
state = str(status.get("status") or "unverified")
evidence = status.get("evidence") if isinstance(status.get("evidence"), dict) else None
if not evidence:
return state
command = evidence.get("canonical_command") or evidence.get("command")
summary = str(evidence.get("output_summary") or "").strip()
parts = [state]
if command:
parts.append(f"last command `{command}`")
if summary:
max_summary = 1200
if len(summary) > max_summary:
summary = summary[:max_summary].rstrip() + "\n... [truncated]"
parts.append(f"last output:\n{summary}")
return "\n".join(parts)
def build_verify_on_stop_nudge(
*,
session_id: str | None,
changed_paths: Iterable[str],
attempts: int = 0,
max_attempts: int = 2,
) -> str | None:
"""Return a synthetic follow-up when edited code lacks fresh verification."""
paths = sorted({str(p) for p in changed_paths if p})
if not paths or attempts >= max_attempts:
return None
snapshot = _verification_snapshot(session_id=session_id, changed_paths=paths)
if snapshot is None:
return None
status, facts = snapshot
verify_commands = [
str(cmd).strip()
for cmd in (facts.get("verifyCommands") or [])
if str(cmd).strip()
]
state = str(status.get("status") or "unverified")
if state == "passed":
return None
if verify_commands:
command_instruction = (
"Run the relevant verification command now ("
+ ", ".join(f"`{cmd}`" for cmd in verify_commands[:3])
+ (", ..." if len(verify_commands) > 3 else "")
+ "), read any failure, repair the code, and summarize what passed."
)
else:
temp_dir = tempfile.gettempdir()
command_instruction = (
"No canonical test/lint/build command was detected. Create a focused "
f"temporary verification script under `{temp_dir}` using an OS-safe "
"`tempfile` path with a `hermes-verify-` filename prefix, run it "
"against the changed behavior, clean it up when possible, and "
"summarize it explicitly as ad-hoc verification rather than suite "
"green."
)
return (
"[System: You edited code in this turn, but the workspace does not have "
"fresh passing verification evidence yet.\n\n"
f"Verification status: {_status_detail(status)}\n\n"
f"Changed paths:\n{_format_changed_paths(paths)}\n\n"
f"{command_instruction} If verification is not possible, explain the "
"concrete blocker instead of claiming the work is fully verified.]"
)
__all__ = ["build_verify_on_stop_nudge", "verify_on_stop_enabled"]

View File

@@ -17,5 +17,5 @@
"lib": "@/lib",
"hooks": "@/hooks"
},
"iconLibrary": "tabler"
"iconLibrary": "lucide"
}

View File

@@ -1,5 +1,3 @@
const fs = require('node:fs')
const _READY_RE = /^HERMES_DASHBOARD_READY port=(\d+)/m
// The announcement clock starts the instant the backend process is spawned —
@@ -96,75 +94,8 @@ function waitForDashboardPort(child, timeoutMs = resolvePortAnnounceTimeoutMs())
})
}
function readDashboardReadyFile(readyFile) {
if (!readyFile) return null
try {
const parsed = JSON.parse(fs.readFileSync(readyFile, 'utf8'))
const port = Number(parsed?.port)
return Number.isInteger(port) && port > 0 ? port : null
} catch {
return null
}
}
function waitForDashboardReadyFile(readyFile, child, timeoutMs = resolvePortAnnounceTimeoutMs()) {
return new Promise((resolve, reject) => {
let done = false
let interval = null
function cleanup() {
if (done) return
done = true
clearTimeout(timer)
if (interval) clearInterval(interval)
child.off('exit', onExit)
child.off('error', onError)
}
function check() {
const port = readDashboardReadyFile(readyFile)
if (port) {
cleanup()
resolve(port)
}
}
function onExit(code, signal) {
cleanup()
reject(new Error(`Hermes backend: exited before port announcement (${signal || code})`))
}
function onError(err) {
cleanup()
reject(err)
}
const timer = setTimeout(() => {
cleanup()
reject(new Error(`Timed out waiting for Hermes backend port announcement (${timeoutMs}ms)`))
}, timeoutMs)
child.on('exit', onExit)
child.on('error', onError)
interval = setInterval(check, 50)
if (typeof interval.unref === 'function') interval.unref()
check()
})
}
function waitForDashboardPortAnnouncement(child, options = {}) {
const timeoutMs = options.timeoutMs ?? resolvePortAnnounceTimeoutMs()
if (options.readyFile) {
return waitForDashboardReadyFile(options.readyFile, child, timeoutMs)
}
return waitForDashboardPort(child, timeoutMs)
}
module.exports = {
waitForDashboardPort,
waitForDashboardPortAnnouncement,
waitForDashboardReadyFile,
readDashboardReadyFile,
resolvePortAnnounceTimeoutMs,
DEFAULT_PORT_ANNOUNCE_TIMEOUT_MS,
MIN_PORT_ANNOUNCE_TIMEOUT_MS,

View File

@@ -14,15 +14,9 @@
const test = require('node:test')
const assert = require('node:assert/strict')
const { EventEmitter } = require('node:events')
const fs = require('node:fs')
const os = require('node:os')
const path = require('node:path')
const {
readDashboardReadyFile,
waitForDashboardPort,
waitForDashboardPortAnnouncement,
waitForDashboardReadyFile,
resolvePortAnnounceTimeoutMs,
DEFAULT_PORT_ANNOUNCE_TIMEOUT_MS,
MIN_PORT_ANNOUNCE_TIMEOUT_MS,
@@ -125,75 +119,3 @@ test('a late announcement after timeout does not throw (listeners torn down)', a
child.stdout.emit('data', 'HERMES_DASHBOARD_READY port=9999\n')
})
})
// ---------------------------------------------------------------------------
// ready-file port announcement
// ---------------------------------------------------------------------------
function mkTmpReadyFile() {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'hermes-ready-test-'))
return {
dir,
file: path.join(dir, 'ready.json'),
cleanup: () => fs.rmSync(dir, { recursive: true, force: true })
}
}
test('readDashboardReadyFile returns a valid port from JSON', () => {
const tmp = mkTmpReadyFile()
try {
fs.writeFileSync(tmp.file, JSON.stringify({ port: 4567 }))
assert.equal(readDashboardReadyFile(tmp.file), 4567)
} finally {
tmp.cleanup()
}
})
test('readDashboardReadyFile ignores missing, malformed, or invalid files', () => {
const tmp = mkTmpReadyFile()
try {
assert.equal(readDashboardReadyFile(tmp.file), null)
fs.writeFileSync(tmp.file, '{')
assert.equal(readDashboardReadyFile(tmp.file), null)
fs.writeFileSync(tmp.file, JSON.stringify({ port: 0 }))
assert.equal(readDashboardReadyFile(tmp.file), null)
} finally {
tmp.cleanup()
}
})
test('waitForDashboardReadyFile resolves when the ready file appears', async () => {
const tmp = mkTmpReadyFile()
const child = makeFakeChild()
try {
const p = waitForDashboardReadyFile(tmp.file, child, 1000)
setTimeout(() => fs.writeFileSync(tmp.file, JSON.stringify({ port: 8765 })), 20)
assert.equal(await p, 8765)
} finally {
tmp.cleanup()
}
})
test('waitForDashboardPortAnnouncement uses ready file when provided', async () => {
const tmp = mkTmpReadyFile()
const child = makeFakeChild()
try {
const p = waitForDashboardPortAnnouncement(child, { readyFile: tmp.file, timeoutMs: 1000 })
setTimeout(() => fs.writeFileSync(tmp.file, JSON.stringify({ port: 9876 })), 20)
assert.equal(await p, 9876)
} finally {
tmp.cleanup()
}
})
test('waitForDashboardReadyFile rejects when the child exits before file readiness', async () => {
const tmp = mkTmpReadyFile()
const child = makeFakeChild()
try {
const p = waitForDashboardReadyFile(tmp.file, child, 1000)
child.emit('exit', 1, null)
await assert.rejects(p, /exited before port announcement/)
} finally {
tmp.cleanup()
}
})

View File

@@ -1,98 +0,0 @@
'use strict'
// Repo-first discovery: walk bounded roots for git repos using only Node's `fs`
// — no native addon, so it just works for anyone who pulls main (no
// electron-rebuild). Mirrors how GitHub Desktop scans: stop at the first `.git`
// (don't descend into a repo), cap depth, and skip heavy non-repo trees so the
// first scan stays fast. Results are cached by the backend after the first run.
const fs = require('node:fs')
const os = require('node:os')
const path = require('node:path')
const fsp = fs.promises
// Shallow on purpose: real projects live a few levels under home
// (`~/www/repo`, `~/code/org/repo`); deeper `.git` dirs are almost always
// fixtures/vendored/eval checkouts (e.g. `~/www/ha-evals/tasks/*/repo`). Repos
// you actually use but keep deeper still surface via session-derived discovery,
// so this only prunes noise, never repos with history.
const DEFAULT_MAX_DEPTH = 3
const MAX_CONCURRENCY = 32
// Big trees that are never themselves repos and would waste the walk. Anything
// hidden (dotdirs like .cache/.Trash/.npm) is skipped wholesale below, so this
// only needs the non-hidden heavyweights.
const JUNK_DIRS = new Set(['Applications', 'Library', 'node_modules', 'site-packages', 'vendor', 'venv'])
async function mapLimit(items, limit, fn) {
let cursor = 0
async function worker() {
while (cursor < items.length) {
const index = cursor
cursor += 1
await fn(items[index])
}
}
await Promise.all(Array.from({ length: Math.min(limit, items.length) }, worker))
}
/**
* Scan `roots` (default: the home dir) for git repositories. Returns deduped
* `{ root, label }` entries. `options.maxDepth` caps recursion (default 3).
*/
async function scanGitRepos(roots, options = {}) {
const maxDepth = Number(options.maxDepth) || DEFAULT_MAX_DEPTH
const searchRoots = Array.isArray(roots) && roots.length > 0 ? roots : [os.homedir()]
const found = new Map()
async function walk(dir, depth) {
if (depth > maxDepth) {
return
}
let entries
try {
entries = await fsp.readdir(dir, { withFileTypes: true })
} catch {
return // unreadable / permission denied
}
// A `.git` DIRECTORY marks a real repo root (a main checkout). A `.git`
// FILE is a linked worktree or submodule — those belong to their parent
// repo as lanes, not as separate projects, so we don't list them (and we
// keep descending in case a real repo sits deeper). This is what kills the
// worktree/eval-repo duplicate explosion.
if (entries.some(entry => entry.name === '.git' && entry.isDirectory())) {
const root = dir.replace(/[/\\]+$/, '')
found.set(root, path.basename(root) || root)
return
}
const subdirs = []
for (const entry of entries) {
// Real directories only (skip symlinks to avoid loops), no hidden dirs, no
// known heavy trees.
if (!entry.isDirectory() || entry.name.startsWith('.') || JUNK_DIRS.has(entry.name)) {
continue
}
subdirs.push(path.join(dir, entry.name))
}
await mapLimit(subdirs, MAX_CONCURRENCY, sub => walk(sub, depth + 1))
}
await mapLimit(
searchRoots.map(root => String(root || '').trim()).filter(Boolean),
MAX_CONCURRENCY,
root => walk(root, 0)
)
return [...found.entries()].map(([root, label]) => ({ label, root }))
}
module.exports = { scanGitRepos }

View File

@@ -1,679 +0,0 @@
'use strict'
// Git ops backing the coding rail + Codex-style review pane. Built on `simple-git`
// (a maintained wrapper around the system git binary — same git the rest of the
// app shells to, no native build) so we read structured status()/diffSummary()
// results instead of hand-parsing porcelain. Reads degrade to null/empty on a
// non-repo / remote backend; mutations reject so the renderer can toast.
const { execFile } = require('node:child_process')
const fs = require('node:fs/promises')
const path = require('node:path')
const simpleGit = require('simple-git')
const { resolveRequestedPathForIpc } = require('./hardening.cjs')
const COMMIT_CONTEXT_DIFF_MAX_CHARS = 120_000
const COMMIT_CONTEXT_UNTRACKED_MAX = 80
const UNTRACKED_LINE_COUNT_CONCURRENCY = 16
const UNTRACKED_LINE_COUNT_MAX_BYTES = 1024 * 1024
// GUI-launched Electron apps on macOS inherit only a minimal PATH (no
// /opt/homebrew/bin or /usr/local/bin), so `gh` — and the `git` gh shells out
// to — aren't found. Augment PATH with the resolved gh dir + the common
// package-manager bins so gh runs the same way it does in a terminal.
function ghEnv(ghBin) {
const extra = [ghBin ? path.dirname(ghBin) : '', '/opt/homebrew/bin', '/usr/local/bin', '/usr/bin'].filter(
dir => dir && dir !== '.'
)
return { ...process.env, PATH: [...extra, process.env.PATH].filter(Boolean).join(path.delimiter) }
}
// Run the `gh` CLI in a repo. Resolves { ok, stdout } so callers branch on
// availability/auth without a throw. gh missing/unauthed → ok:false.
function runGh(args, cwd, ghBin) {
return new Promise(resolve => {
execFile(
ghBin || 'gh',
args,
{ cwd, env: ghEnv(ghBin), windowsHide: true, timeout: 30_000, maxBuffer: 8 * 1024 * 1024 },
(err, stdout) => resolve({ ok: !err, stdout: String(stdout || '') })
)
})
}
function gitFor(cwd, gitBin) {
return simpleGit({ baseDir: cwd, binary: gitBin || 'git', maxConcurrentProcesses: 4, trimmed: false })
}
// simple-git reports renames as `old => new` (and `dir/{old => new}/f`); resolve
// to the NEW path so the row addresses the real file for diff/stage.
function resolveRenamePath(raw) {
const path = String(raw || '').trim()
if (!path.includes(' => ')) {
return path
}
const brace = path.match(/^(.*)\{(.*) => (.*)\}(.*)$/)
if (brace) {
const [, prefix, , to, suffix] = brace
return `${prefix}${to}${suffix}`.replace(/\/{2,}/g, '/')
}
return path.split(' => ').pop().trim()
}
// DiffResult.files → Map<path, {added, removed}> (binary files carry no line
// delta).
function countsByPath(summary) {
const map = new Map()
for (const file of summary.files) {
map.set(resolveRenamePath(file.file), {
added: file.binary ? 0 : file.insertions,
removed: file.binary ? 0 : file.deletions
})
}
return map
}
// Untracked files don't appear in diffSummary(); count insertions from disk so
// the review tree can show +N for new files (matches an all-add diff view).
// Insertions = line count: newline bytes, plus one for a final unterminated
// line. Binary (NUL byte) → 0, mirroring git numstat's "-".
async function untrackedInsertions(cwd, relPath) {
try {
const fullPath = path.join(cwd, relPath)
const stat = await fs.stat(fullPath)
if (!stat.isFile() || stat.size > UNTRACKED_LINE_COUNT_MAX_BYTES) {
return 0
}
const buf = await fs.readFile(fullPath)
if (buf.includes(0)) {
return 0
}
let lines = 0
for (const byte of buf) {
if (byte === 10) {
lines++
}
}
return buf.length > 0 && buf[buf.length - 1] !== 10 ? lines + 1 : lines
} catch {
return 0
}
}
function capText(text, maxChars, label = 'truncated') {
const value = String(text || '')
if (value.length <= maxChars) {
return value
}
return `${value.slice(0, maxChars)}\n# ${label}: ${value.length - maxChars} chars omitted\n`
}
async function fillUntrackedCounts(cwd, files) {
const pending = files.filter(file => file.status === '?' && file.added === 0 && file.removed === 0)
for (let i = 0; i < pending.length; i += UNTRACKED_LINE_COUNT_CONCURRENCY) {
await Promise.all(
pending.slice(i, i + UNTRACKED_LINE_COUNT_CONCURRENCY).map(async file => {
file.added = await untrackedInsertions(cwd, file.path)
})
)
}
}
// Resolve the base ref for "all branch changes": merge-base with the remote
// default branch (origin/HEAD), falling back to common trunk names.
async function branchBase(git) {
const candidates = []
try {
const head = (await git.revparse(['--abbrev-ref', 'origin/HEAD'])).trim()
if (head) {
candidates.push(head)
}
} catch {
// No origin/HEAD configured.
}
candidates.push('origin/main', 'origin/master', 'main', 'master')
for (const ref of candidates) {
try {
const base = (await git.raw(['merge-base', 'HEAD', ref])).trim()
if (base) {
return base
}
} catch {
// Ref doesn't exist; try the next candidate.
}
}
return null
}
// Resolve the repo's default branch NAME ("main" / "master" / …), preferring
// the remote's HEAD, then common local trunk names. Null when none is found
// (e.g. a fresh repo with only a feature branch). Used to offer "branch off the
// trunk" regardless of which branch you're currently on.
async function defaultBranchName(git) {
try {
const head = (await git.revparse(['--abbrev-ref', 'origin/HEAD'])).trim()
// "origin/main" → "main"; skip the bare "origin/HEAD" placeholder.
if (head && head !== 'origin/HEAD') {
return head.replace(/^origin\//, '')
}
} catch {
// No origin/HEAD configured.
}
// Prefer a local trunk, then a remote-only one (returns the clean name either
// way) so "branch off main" works even before main is checked out locally.
for (const ref of ['refs/heads/main', 'refs/heads/master', 'refs/remotes/origin/main', 'refs/remotes/origin/master']) {
try {
await git.raw(['rev-parse', '--verify', '--quiet', ref])
return ref.replace(/^refs\/(?:heads|remotes\/origin)\//, '')
} catch {
// Ref doesn't exist; try the next candidate.
}
}
return null
}
// A status file's single-letter classification, preferring the staged (index)
// code over the worktree code; untracked wins (simple-git marks both '?').
function statusLetter(file) {
if (file.index === '?' || file.working_dir === '?') {
return '?'
}
const code = file.index && file.index !== ' ' ? file.index : file.working_dir
return (code || 'M').toUpperCase()
}
const isStaged = file => Boolean(file.index && file.index !== ' ' && file.index !== '?')
async function reviewList(repoPath, scope, baseRef, gitBin) {
let cwd
try {
cwd = resolveRequestedPathForIpc(repoPath, { purpose: 'Review list' })
} catch {
return { files: [], base: null }
}
const git = gitFor(cwd, gitBin)
try {
if (scope === 'branch' || scope === 'lastTurn') {
const base = scope === 'branch' ? await branchBase(git) : baseRef
if (!base) {
return { files: [], base: null }
}
const range = scope === 'branch' ? `${base}...HEAD` : base
const summary = await git.diffSummary([range])
const files = summary.files.map(file => ({
path: resolveRenamePath(file.file),
added: file.binary ? 0 : file.insertions,
removed: file.binary ? 0 : file.deletions,
status: 'M',
staged: false
}))
// "Last turn" also surfaces files created since the baseline (untracked).
if (scope === 'lastTurn') {
const status = await git.status()
for (const path of status.not_added) {
if (!files.some(f => f.path === path)) {
files.push({ path, added: 0, removed: 0, status: '?', staged: false })
}
}
}
files.sort((a, b) => a.path.localeCompare(b.path))
await fillUntrackedCounts(cwd, files)
return { files, base }
}
// Default: uncommitted (staged + unstaged + untracked), one row per path.
const [status, staged, unstaged] = await Promise.all([
git.status(),
git.diffSummary(['--cached']),
git.diffSummary([])
])
const stagedCounts = countsByPath(staged)
const unstagedCounts = countsByPath(unstaged)
const files = status.files.map(file => {
const filePath = resolveRenamePath(file.path)
const sc = stagedCounts.get(filePath) || { added: 0, removed: 0 }
const uc = unstagedCounts.get(filePath) || { added: 0, removed: 0 }
return {
path: filePath,
added: sc.added + uc.added,
removed: sc.removed + uc.removed,
status: statusLetter(file),
staged: isStaged(file)
}
})
files.sort((a, b) => a.path.localeCompare(b.path))
await fillUntrackedCounts(cwd, files)
return { files, base: null }
} catch {
return { files: [], base: null }
}
}
async function reviewDiff(repoPath, filePath, scope, baseRef, staged, gitBin) {
let cwd
try {
cwd = resolveRequestedPathForIpc(repoPath, { purpose: 'Review diff' })
} catch {
return ''
}
const git = gitFor(cwd, gitBin)
const safe = args => git.diff(args).catch(() => '')
if (scope === 'branch') {
const base = await branchBase(git)
return base ? safe([`${base}...HEAD`, '--', filePath]) : ''
}
if (scope === 'lastTurn') {
return baseRef ? safe([baseRef, '--', filePath]) : ''
}
if (staged) {
return safe(['--cached', '--', filePath])
}
const worktree = await safe(['--', filePath])
if (worktree.trim()) {
return worktree
}
// Untracked file: no worktree diff exists, so synthesize an all-add diff via
// --no-index (exits non-zero by design when files differ, so go around
// simple-git's reject-on-nonzero with a raw execFile).
return new Promise(resolve => {
execFile(
gitBin || 'git',
['diff', '--no-index', '--', '/dev/null', filePath],
{ cwd, windowsHide: true, timeout: 30_000, maxBuffer: 32 * 1024 * 1024 },
(_err, stdout) => resolve(String(stdout || ''))
)
})
}
// Working-tree-vs-HEAD diff for ONE file — the "what changed since the last
// commit" view used by the file preview. Unlike reviewDiff this never synthesizes
// a full-add for a clean tracked file (so a pristine file shows no diff); it only
// all-adds a genuinely untracked file.
async function fileDiffVsHead(repoPath, filePath, gitBin) {
let cwd
try {
cwd = resolveRequestedPathForIpc(repoPath, { purpose: 'File diff' })
} catch {
return ''
}
const git = gitFor(cwd, gitBin)
const head = await git.diff(['HEAD', '--', filePath]).catch(() => '')
if (head.trim()) {
return head
}
// No tracked changes vs HEAD. Only synthesize an all-add diff for a file git
// doesn't know yet; a clean tracked file must return empty.
const status = await git.raw(['status', '--porcelain', '--', filePath]).catch(() => '')
if (!status.trim().startsWith('??')) {
return ''
}
return new Promise(resolve => {
execFile(
gitBin || 'git',
['diff', '--no-index', '--', '/dev/null', filePath],
{ cwd, windowsHide: true, timeout: 30_000, maxBuffer: 32 * 1024 * 1024 },
(_err, stdout) => resolve(String(stdout || ''))
)
})
}
async function reviewStage(repoPath, filePath, gitBin) {
const cwd = resolveRequestedPathForIpc(repoPath, { purpose: 'Review stage' })
await gitFor(cwd, gitBin).raw(filePath ? ['add', '--', filePath] : ['add', '-A'])
return { ok: true }
}
async function reviewUnstage(repoPath, filePath, gitBin) {
const cwd = resolveRequestedPathForIpc(repoPath, { purpose: 'Review unstage' })
await gitFor(cwd, gitBin).raw(filePath ? ['reset', '-q', 'HEAD', '--', filePath] : ['reset', '-q', 'HEAD'])
return { ok: true }
}
// Discard changes back to the committed state. Destructive — the renderer
// confirms first. Restores tracked files and removes untracked ones.
async function reviewRevert(repoPath, filePath, gitBin) {
const cwd = resolveRequestedPathForIpc(repoPath, { purpose: 'Review revert' })
const git = gitFor(cwd, gitBin)
if (filePath) {
await git.raw(['checkout', 'HEAD', '--', filePath]).catch(() => undefined)
await git.raw(['clean', '-fd', '--', filePath]).catch(() => undefined)
} else {
await git.raw(['checkout', 'HEAD', '--', '.']).catch(() => undefined)
await git.raw(['clean', '-fd']).catch(() => undefined)
}
return { ok: true }
}
// Resolve a ref to a commit sha (captures the turn baseline for "Last turn").
async function reviewRevParse(repoPath, ref, gitBin) {
let cwd
try {
cwd = resolveRequestedPathForIpc(repoPath, { purpose: 'Review rev-parse' })
} catch {
return null
}
try {
return (await gitFor(cwd, gitBin).revparse([ref || 'HEAD'])).trim() || null
} catch {
return null
}
}
// Commit the working tree. Mirrors VS Code: if nothing is staged, stage
// everything first ("commit all"), then commit. Optionally push afterward,
// setting upstream on the first push.
async function reviewCommit(repoPath, message, push, gitBin) {
const cwd = resolveRequestedPathForIpc(repoPath, { purpose: 'Review commit' })
const git = gitFor(cwd, gitBin)
const status = await git.status()
if (status.staged.length === 0) {
await git.raw(['add', '-A'])
}
await git.commit(message)
if (push) {
const fresh = await git.status()
if (fresh.tracking) {
await git.push()
} else if (fresh.current) {
await git.raw(['push', '-u', 'origin', fresh.current])
}
}
return { ok: true }
}
// Gather the context the model needs to draft a commit message: the diff of
// what *will* be committed (staged when anything is staged, else everything
// vs HEAD — mirroring reviewCommit's "stage all when nothing staged" rule),
// the names of untracked files (which carry no diff), and recent commit
// subjects for style. Diff is capped so the payload stays bounded. Reads only.
async function reviewCommitContext(repoPath, gitBin) {
let cwd
try {
cwd = resolveRequestedPathForIpc(repoPath, { purpose: 'Review commit context' })
} catch {
return { diff: '', recent: '' }
}
const git = gitFor(cwd, gitBin)
const safe = args => git.diff(args).catch(() => '')
let status
try {
status = await git.status()
} catch {
return { diff: '', recent: '' }
}
// What will land: staged changes if any, otherwise all tracked changes vs HEAD.
let diff = capText(
status.staged.length > 0 ? await safe(['--cached']) : await safe(['HEAD']),
COMMIT_CONTEXT_DIFF_MAX_CHARS,
'diff truncated for commit-message generation'
)
// Untracked files have no diff — list them so new files aren't invisible.
const untracked = status.not_added || []
if (untracked.length > 0) {
const visible = untracked.slice(0, COMMIT_CONTEXT_UNTRACKED_MAX)
const omitted = untracked.length - visible.length
const note =
`\n# New (untracked) files:\n${visible.map(p => `# ${p}`).join('\n')}\n` +
(omitted > 0 ? `# ... ${omitted} more omitted\n` : '')
diff = diff ? `${diff}${note}` : note
}
const recent = await git.raw(['log', '-n', '10', '--pretty=format:%s']).catch(() => '')
return { diff: diff || '', recent: String(recent || '').trim() }
}
async function reviewPush(repoPath, gitBin) {
const cwd = resolveRequestedPathForIpc(repoPath, { purpose: 'Review push' })
const git = gitFor(cwd, gitBin)
const status = await git.status()
if (status.tracking) {
await git.push()
} else if (status.current) {
await git.raw(['push', '-u', 'origin', status.current])
}
return { ok: true }
}
// gh availability + auth + whether this branch already has a PR. Reads only;
// drives the PR button's enabled/label state. `ghReady` is false when gh is
// missing OR not authenticated — either way the PR action can't run.
async function reviewShipInfo(repoPath, ghBin) {
let cwd
try {
cwd = resolveRequestedPathForIpc(repoPath, { purpose: 'Review ship info' })
} catch {
return { ghReady: false, pr: null }
}
const auth = await runGh(['auth', 'status'], cwd, ghBin)
if (!auth.ok) {
return { ghReady: false, pr: null }
}
const view = await runGh(['pr', 'view', '--json', 'url,state,number'], cwd, ghBin)
if (!view.ok) {
// gh exits non-zero when no PR exists for the branch — that's not an error.
return { ghReady: true, pr: null }
}
try {
const pr = JSON.parse(view.stdout)
return { ghReady: true, pr: pr && pr.url ? { url: pr.url, state: pr.state, number: pr.number } : null }
} catch {
return { ghReady: true, pr: null }
}
}
// Create a PR for the current branch (pushing first so gh has a remote ref),
// letting gh fill title/body from the commits. Returns the new PR url.
async function reviewCreatePr(repoPath, gitBin, ghBin) {
const cwd = resolveRequestedPathForIpc(repoPath, { purpose: 'Review create PR' })
await reviewPush(repoPath, gitBin).catch(() => undefined)
const created = await runGh(['pr', 'create', '--fill'], cwd, ghBin)
if (!created.ok) {
throw new Error('gh pr create failed (is gh installed and authenticated?)')
}
const url = created.stdout.trim().split('\n').filter(Boolean).pop() || ''
return { url }
}
// Compact working-tree status for the composer coding rail: branch, ahead/behind,
// per-state change counts, +/- vs HEAD, and a capped changed-file list.
async function repoStatus(repoPath, gitBin) {
let cwd
try {
cwd = resolveRequestedPathForIpc(repoPath, { purpose: 'Repo status' })
} catch {
return null
}
// Session cwds can point at a deleted worktree for a moment (or forever in a
// stale row). simple-git throws at construction time on a missing baseDir, so
// fail soft and hide the coding rail instead of spamming IPC handler errors.
try {
const stat = await fs.stat(cwd)
if (!stat.isDirectory()) {
return null
}
} catch {
return null
}
let git
try {
git = gitFor(cwd, gitBin)
} catch {
return null
}
let status
try {
status = await git.status()
} catch {
// Not a repo / git unavailable / remote backend.
return null
}
const detached = typeof status.detached === 'boolean' ? status.detached : !status.current
const files = status.files.map(file => ({
path: file.path,
staged: isStaged(file),
unstaged: Boolean(file.working_dir && file.working_dir !== ' ' && file.working_dir !== '?'),
untracked: file.index === '?' || file.working_dir === '?',
conflicted: file.index === 'U' || file.working_dir === 'U'
}))
const result = {
branch: detached ? null : status.current || null,
defaultBranch: await defaultBranchName(git),
detached,
ahead: status.ahead || 0,
behind: status.behind || 0,
staged: files.filter(f => f.staged).length,
unstaged: files.filter(f => f.unstaged).length,
untracked: status.not_added.length,
conflicted: status.conflicted.length,
changed: files.length,
added: 0,
removed: 0,
files: files.slice(0, 200)
}
// +/- vs HEAD (staged + unstaged tracked changes). No HEAD yet → leave 0.
try {
const summary = await git.diffSummary(['HEAD'])
result.added = summary.insertions
result.removed = summary.deletions
} catch {
// No commits yet.
}
// `git diff HEAD` ignores untracked files, so a turn that only creates new
// files (the common case — a fresh module, a demo dir) showed +0 in the rail
// while the review pane counted them. Fold untracked insertions into `added`
// so the rail matches reality. Bounded (size cap + concurrency) like the
// review tree; only the capped file slice is counted so a huge untracked tree
// can't stall the probe.
try {
const untracked = status.not_added.slice(0, 500)
for (let i = 0; i < untracked.length; i += UNTRACKED_LINE_COUNT_CONCURRENCY) {
const batch = await Promise.all(
untracked.slice(i, i + UNTRACKED_LINE_COUNT_CONCURRENCY).map(path => untrackedInsertions(cwd, path))
)
result.added += batch.reduce((sum, n) => sum + n, 0)
}
} catch {
// Best-effort: a probe failure just leaves untracked lines uncounted.
}
return result
}
module.exports = {
branchBase,
fileDiffVsHead,
repoStatus,
resolveRenamePath,
reviewCommit,
reviewCommitContext,
reviewCreatePr,
reviewDiff,
reviewList,
reviewPush,
reviewRevParse,
reviewRevert,
reviewShipInfo,
reviewStage,
reviewUnstage
}

View File

@@ -1,22 +0,0 @@
'use strict'
const assert = require('node:assert/strict')
const test = require('node:test')
const { resolveRenamePath } = require('./git-review-ops.cjs')
test('resolveRenamePath: plain path is unchanged', () => {
assert.equal(resolveRenamePath('src/a.ts'), 'src/a.ts')
})
test('resolveRenamePath: simple rename resolves to the new path', () => {
assert.equal(resolveRenamePath('old.ts => new.ts'), 'new.ts')
})
test('resolveRenamePath: brace rename resolves to the new path', () => {
assert.equal(resolveRenamePath('src/{old => new}/file.ts'), 'src/new/file.ts')
})
test('resolveRenamePath: brace rename collapsing a segment', () => {
assert.equal(resolveRenamePath('src/{lib => }/file.ts'), 'src/file.ts')
})

View File

@@ -1,339 +0,0 @@
'use strict'
// Git-driven worktree operations for the desktop "Start work" flow: spin up a
// fresh worktree the lightest way (`git worktree add -b`), list real worktrees,
// and remove them. Git is the source of truth; the renderer just drives these.
const path = require('node:path')
const fs = require('node:fs')
const { execFile } = require('node:child_process')
const { resolveRequestedPathForIpc } = require('./hardening.cjs')
function runGit(gitBin, args, cwd) {
return new Promise((resolve, reject) => {
execFile(
gitBin,
args,
{ cwd, windowsHide: true, timeout: 30_000, maxBuffer: 8 * 1024 * 1024 },
(err, stdout, stderr) => {
if (err) {
err.stderr = String(stderr || '')
reject(err)
return
}
resolve(String(stdout || ''))
}
)
})
}
// Parse `git worktree list --porcelain`. The first record is the main worktree.
function parseWorktrees(out) {
const trees = []
let cur = null
for (const line of out.split('\n')) {
if (line.startsWith('worktree ')) {
if (cur) {
trees.push(cur)
}
cur = { path: line.slice(9).trim(), branch: null, detached: false, bare: false, locked: false }
} else if (!cur) {
continue
} else if (line.startsWith('branch ')) {
cur.branch = line.slice(7).trim().replace(/^refs\/heads\//, '')
} else if (line === 'detached') {
cur.detached = true
} else if (line === 'bare') {
cur.bare = true
} else if (line.startsWith('locked')) {
cur.locked = true
}
}
if (cur) {
trees.push(cur)
}
return trees
}
async function listWorktrees(repoPath, gitBin) {
let resolved
try {
resolved = resolveRequestedPathForIpc(repoPath, { purpose: 'Worktree list' })
} catch {
return []
}
try {
const out = await runGit(gitBin, ['worktree', 'list', '--porcelain'], resolved)
return parseWorktrees(out).map((tree, index) => ({
path: tree.path,
branch: tree.branch,
isMain: index === 0,
detached: tree.detached,
locked: tree.locked
}))
} catch {
return []
}
}
// A git-ref-safe branch name (spaces → "-", drop forbidden chars, trim edges),
// or "" when nothing usable remains. Mirrors the renderer's `gitRef`, so a bad
// value can't reach `git` no matter the caller (the GUI also enforces live).
function sanitizeBranch(name) {
return String(name || '')
.replace(/\s+/g, '-')
.replace(/[^\w./-]/g, '')
.replace(/-{2,}/g, '-')
.replace(/\/{2,}/g, '/')
.replace(/\.{2,}/g, '.')
.replace(/^[-./]+|[-./]+$/g, '')
}
function slugify(name) {
const slug = String(name || '')
.trim()
.toLowerCase()
.replace(/[^a-z0-9]+/g, '-')
.replace(/^-+|-+$/g, '')
.slice(0, 40)
.replace(/-+$/g, '')
return slug || 'work'
}
const TRUNK_BRANCHES = ['main', 'master']
async function gitLine(gitBin, args, cwd) {
try {
return (await runGit(gitBin, args, cwd)).trim()
} catch {
return ''
}
}
async function defaultBranch(gitBin, cwd) {
const remote = (await gitLine(gitBin, ['symbolic-ref', '--quiet', '--short', 'refs/remotes/origin/HEAD'], cwd)).replace(
/^origin\//,
''
)
if (remote) {
return remote
}
const configured = await gitLine(gitBin, ['config', '--get', 'init.defaultBranch'], cwd)
if (configured) {
return configured
}
for (const branch of TRUNK_BRANCHES) {
if (await gitLine(gitBin, ['show-ref', '--verify', `refs/heads/${branch}`], cwd)) {
return branch
}
}
return ''
}
// A brand-new project folder isn't a git repo — and a freshly-init'd one has no
// commit to branch from — so `git worktree add` would fail. Make the dir a repo
// with a root commit on the user's behalf so worktrees "just work". No-op for a
// repo that already has commits; never touches the user's files (the seed commit
// is `--allow-empty`), and never inits a dir that already lives inside a repo.
async function ensureGitRepo(gitBin, dir) {
let needsRoot = false
try {
const inside = (await runGit(gitBin, ['rev-parse', '--is-inside-work-tree'], dir)).trim()
if (inside !== 'true') {
await runGit(gitBin, ['init'], dir)
needsRoot = true
} else {
// Repo exists; a worktree still needs a HEAD to branch from.
try {
await runGit(gitBin, ['rev-parse', '--verify', 'HEAD'], dir)
} catch {
needsRoot = true
}
}
} catch {
await runGit(gitBin, ['init'], dir)
needsRoot = true
}
if (needsRoot) {
// Inline identity so the seed commit lands even with no global git config.
await runGit(
gitBin,
['-c', 'user.email=hermes@localhost', '-c', 'user.name=Hermes', 'commit', '--allow-empty', '-m', 'Initial commit'],
dir
)
}
}
// Resolve the repo's MAIN worktree root, so `.worktrees/` always nests under the
// primary checkout even when called from a linked worktree.
async function mainRoot(gitBin, cwd) {
const list = await listWorktrees(cwd, gitBin)
const main = list.find(tree => tree.isMain)
return main ? main.path : cwd
}
function uniqueDir(base) {
let dir = base
let n = 1
while (fs.existsSync(dir)) {
n += 1
dir = `${base}-${n}`
}
return dir
}
async function addExistingBranchWorktree(gitBin, root, name) {
const branch = sanitizeBranch(name)
if (!branch) {
throw new Error('Branch name is required.')
}
if (branch === (await defaultBranch(gitBin, root))) {
await runGit(gitBin, ['switch', branch], root)
return { path: root, branch, repoRoot: root }
}
const dir = uniqueDir(path.join(root, '.worktrees', slugify(branch)))
await runGit(gitBin, ['worktree', 'add', dir, branch], root)
return { path: dir, branch, repoRoot: root }
}
async function addWorktree(repoPath, options, gitBin) {
const resolved = resolveRequestedPathForIpc(repoPath, { purpose: 'Worktree add' })
// A new project's folder may not be a git repo yet — init it (with a root
// commit) so the worktree has something to branch from.
await ensureGitRepo(gitBin, resolved)
const root = await mainRoot(gitBin, resolved)
const opts = options || {}
if (opts.existingBranch) {
return addExistingBranchWorktree(gitBin, root, opts.existingBranch)
}
const slug = slugify(opts.name || `work-${Date.now().toString(36)}`)
const branch = sanitizeBranch(opts.branch) || `hermes/${slug}`
const dir = uniqueDir(path.join(root, '.worktrees', slug))
const args = ['worktree', 'add', '-b', branch, dir]
if (opts.base) {
args.push(String(opts.base))
}
try {
await runGit(gitBin, args, root)
} catch (err) {
// Branch name may already exist — retry checking out the existing branch
// into a fresh worktree dir instead of failing the whole flow.
if (/already exists/i.test(err.stderr || '')) {
await runGit(gitBin, ['worktree', 'add', dir, branch], root)
} else {
throw err
}
}
return { path: dir, branch, repoRoot: root }
}
async function removeWorktree(repoPath, worktreePath, options, gitBin) {
const resolvedRepo = resolveRequestedPathForIpc(repoPath, { purpose: 'Worktree remove (repo)' })
const resolvedTree = resolveRequestedPathForIpc(worktreePath, { purpose: 'Worktree remove (tree)' })
const root = await mainRoot(gitBin, resolvedRepo)
const args = ['worktree', 'remove']
if (options && options.force) {
args.push('--force')
}
args.push(resolvedTree)
await runGit(gitBin, args, root)
return { removed: resolvedTree }
}
// List local branches for the "convert a branch into a worktree" picker, most
// recently committed first. Each carries whether it's already checked out in a
// worktree and, when checked out, that worktree's path. Empty on a non-repo /
// remote backend where the probe can't run.
async function listBranches(repoPath, gitBin) {
let resolved
try {
resolved = resolveRequestedPathForIpc(repoPath, { purpose: 'Branch list' })
} catch {
return []
}
try {
const out = await runGit(
gitBin,
['for-each-ref', '--format=%(refname:short)', '--sort=-committerdate', 'refs/heads'],
resolved
)
const trees = await listWorktrees(resolved, gitBin)
const pathByBranch = new Map(trees.filter(tree => tree.branch).map(tree => [tree.branch, tree.path]))
const trunk = await defaultBranch(gitBin, resolved)
return out
.split('\n')
.map(line => line.trim())
.filter(Boolean)
.map(name => ({
name,
checkedOut: pathByBranch.has(name),
isDefault: Boolean(trunk && name === trunk),
worktreePath: pathByBranch.get(name) || null
}))
} catch {
return []
}
}
async function switchBranch(repoPath, branch, gitBin) {
const resolved = resolveRequestedPathForIpc(repoPath, { purpose: 'Branch switch' })
const target = sanitizeBranch(branch)
if (!target) {
throw new Error('Branch name is required.')
}
await runGit(gitBin, ['switch', target], resolved)
return { branch: target }
}
module.exports = {
addWorktree,
ensureGitRepo,
listBranches,
listWorktrees,
parseWorktrees,
removeWorktree,
sanitizeBranch,
switchBranch
}

View File

@@ -1,214 +0,0 @@
'use strict'
const assert = require('node:assert/strict')
const { execFileSync } = require('node:child_process')
const fs = require('node:fs')
const os = require('node:os')
const path = require('node:path')
const test = require('node:test')
const {
addWorktree,
ensureGitRepo,
listBranches,
parseWorktrees,
sanitizeBranch,
switchBranch
} = require('./git-worktree-ops.cjs')
test('sanitizeBranch: spaces → hyphens, forbidden chars dropped, edges trimmed', () => {
assert.equal(sanitizeBranch('beach vibes'), 'beach-vibes')
assert.equal(sanitizeBranch('feat/cool thing'), 'feat/cool-thing')
assert.equal(sanitizeBranch(' wip~^:? '), 'wip')
assert.equal(sanitizeBranch('///'), '')
})
test('parseWorktrees: main checkout + linked worktree', () => {
const out = [
'worktree /repo',
'HEAD abc123',
'branch refs/heads/main',
'',
'worktree /repo/.worktrees/feat',
'HEAD def456',
'branch refs/heads/hermes/feat',
''
].join('\n')
const trees = parseWorktrees(out)
assert.equal(trees.length, 2)
assert.equal(trees[0].path, '/repo')
assert.equal(trees[0].branch, 'main')
assert.equal(trees[1].path, '/repo/.worktrees/feat')
assert.equal(trees[1].branch, 'hermes/feat')
})
test('parseWorktrees: detached + locked flags', () => {
const out = ['worktree /repo/wt', 'HEAD abc', 'detached', 'locked reason', ''].join('\n')
const trees = parseWorktrees(out)
assert.equal(trees.length, 1)
assert.equal(trees[0].detached, true)
assert.equal(trees[0].locked, true)
assert.equal(trees[0].branch, null)
})
test('parseWorktrees: empty input', () => {
assert.deepEqual(parseWorktrees(''), [])
})
test('ensureGitRepo: inits a plain dir with a root commit so worktrees branch', async () => {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'hermes-wt-'))
const git = (...args) => execFileSync('git', args, { cwd: dir }).toString().trim()
try {
await ensureGitRepo('git', dir)
assert.match(git('rev-parse', '--verify', 'HEAD'), /^[0-9a-f]{7,}$/)
// The whole point: a worktree can now branch off the seeded root commit.
execFileSync('git', ['worktree', 'add', '-b', 'wt', path.join(dir, '.worktrees', 'wt')], { cwd: dir })
assert.ok(fs.existsSync(path.join(dir, '.worktrees', 'wt')))
// Idempotent: an already-committed repo gets no extra commit.
await ensureGitRepo('git', dir)
assert.equal(git('rev-list', '--count', 'HEAD'), '1')
} finally {
fs.rmSync(dir, { recursive: true, force: true })
}
})
test('switchBranch: switches a normal checkout branch', async () => {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'hermes-switch-'))
const git = (...args) => execFileSync('git', args, { cwd: dir }).toString().trim()
try {
await ensureGitRepo('git', dir)
execFileSync('git', ['branch', 'feature'], { cwd: dir })
await switchBranch(dir, 'feature', 'git')
assert.equal(git('branch', '--show-current'), 'feature')
} finally {
fs.rmSync(dir, { recursive: true, force: true })
}
})
test('listBranches: lists locals and flags the checked-out branch', async () => {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'hermes-branches-'))
try {
await ensureGitRepo('git', dir)
const current = execFileSync('git', ['branch', '--show-current'], { cwd: dir }).toString().trim()
execFileSync('git', ['branch', 'feature'], { cwd: dir })
const branches = await listBranches(dir, 'git')
const names = branches.map(b => b.name).sort()
assert.deepEqual(names, [current, 'feature'].sort())
// The repo's own checkout is flagged; the unused branch is convertible.
assert.equal(branches.find(b => b.name === current).checkedOut, true)
assert.equal(branches.find(b => b.name === current).isDefault, true)
assert.equal(fs.realpathSync(branches.find(b => b.name === current).worktreePath), fs.realpathSync(dir))
assert.equal(branches.find(b => b.name === 'feature').checkedOut, false)
assert.equal(branches.find(b => b.name === 'feature').isDefault, false)
assert.equal(branches.find(b => b.name === 'feature').worktreePath, null)
} finally {
fs.rmSync(dir, { recursive: true, force: true })
}
})
test('listBranches: flags a free default branch as default, not checked out', async () => {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'hermes-branches-default-'))
const git = (...args) => execFileSync('git', args, { cwd: dir }).toString().trim()
try {
await ensureGitRepo('git', dir)
const trunk = git('branch', '--show-current')
execFileSync('git', ['switch', '-c', 'rawr'], { cwd: dir })
const branches = await listBranches(dir, 'git')
const defaultBranch = branches.find(b => b.name === trunk)
assert.equal(defaultBranch.checkedOut, false)
assert.equal(defaultBranch.isDefault, true)
assert.equal(defaultBranch.worktreePath, null)
} finally {
fs.rmSync(dir, { recursive: true, force: true })
}
})
test('listBranches: a branch claimed by a worktree is flagged checked out', async () => {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'hermes-branches-wt-'))
try {
await ensureGitRepo('git', dir)
execFileSync('git', ['branch', 'feature'], { cwd: dir })
// addWorktree converts the existing "feature" branch into a worktree.
const result = await addWorktree(dir, { existingBranch: 'feature' }, 'git')
assert.equal(result.branch, 'feature')
assert.ok(fs.existsSync(result.path))
const branches = await listBranches(dir, 'git')
assert.equal(branches.find(b => b.name === 'feature').checkedOut, true)
} finally {
fs.rmSync(dir, { recursive: true, force: true })
}
})
test('listBranches: empty on a non-repo path', async () => {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'hermes-nonrepo-'))
try {
assert.deepEqual(await listBranches(dir, 'git'), [])
} finally {
fs.rmSync(dir, { recursive: true, force: true })
}
})
test('addWorktree: existingBranch checks the branch out without a new branch', async () => {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'hermes-convert-'))
const git = (...args) => execFileSync('git', args, { cwd: dir }).toString().trim()
try {
await ensureGitRepo('git', dir)
execFileSync('git', ['branch', 'cool/feature'], { cwd: dir })
const before = git('branch', '--list').split('\n').length
const result = await addWorktree(dir, { existingBranch: 'cool/feature' }, 'git')
// No new branch was created — only the existing one is checked out.
assert.equal(git('branch', '--list').split('\n').length, before)
assert.equal(result.branch, 'cool/feature')
// Dir is named off the branch slug, nested under the main repo's .worktrees.
assert.match(result.path, /[/\\]\.worktrees[/\\]cool-feature/)
assert.equal(
execFileSync('git', ['branch', '--show-current'], { cwd: result.path }).toString().trim(),
'cool/feature'
)
} finally {
fs.rmSync(dir, { recursive: true, force: true })
}
})
test('addWorktree: existing default branch switches the main checkout, not .worktrees/main', async () => {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'hermes-convert-default-'))
const git = (...args) => execFileSync('git', args, { cwd: dir }).toString().trim()
try {
await ensureGitRepo('git', dir)
const trunk = git('branch', '--show-current')
execFileSync('git', ['switch', '-c', 'rawr'], { cwd: dir })
const result = await addWorktree(dir, { existingBranch: trunk }, 'git')
assert.equal(result.branch, trunk)
assert.equal(fs.realpathSync(result.path), fs.realpathSync(dir))
assert.equal(git('branch', '--show-current'), trunk)
assert.equal(fs.existsSync(path.join(dir, '.worktrees', trunk)), false)
} finally {
fs.rmSync(dir, { recursive: true, force: true })
}
})

View File

@@ -0,0 +1,174 @@
'use strict'
// Resolve git-worktree relationships for a set of session cwds, reading git's
// on-disk metadata directly (no `git` spawn per path):
//
// - A normal checkout has a `.git` DIRECTORY at its root → it's the main
// worktree; its repo root IS that directory's parent.
// - A linked worktree has a `.git` FILE: `gitdir: <repo>/.git/worktrees/<name>`.
// That admin dir's `commondir` points back at the shared `<repo>/.git`, whose
// parent is the main repo root.
//
// Grouping by repoRoot therefore clusters a repo's main checkout with all of its
// linked worktrees, regardless of how the worktree directories are named. The
// branch (read from the worktree's own HEAD) gives each worktree a meaningful
// label.
const fs = require('node:fs')
const path = require('node:path')
const { resolveRequestedPathForIpc } = require('./hardening.cjs')
// Walk up from `start` to the nearest ancestor that carries a `.git` entry
// (file for a linked worktree, dir for the main checkout). Capped so a stray
// path can't loop forever.
function findGitHost(start, fsImpl) {
let dir = start
for (let i = 0; i < 64; i += 1) {
const dotgit = path.join(dir, '.git')
try {
if (fsImpl.existsSync(dotgit)) {
return dir
}
} catch {
return null
}
const parent = path.dirname(dir)
if (parent === dir) {
return null
}
dir = parent
}
return null
}
function readBranch(gitDir, fsImpl) {
try {
const head = fsImpl.readFileSync(path.join(gitDir, 'HEAD'), 'utf8').trim()
const ref = head.match(/^ref:\s*refs\/heads\/(.+)$/)
if (ref) {
return ref[1]
}
// Detached HEAD: surface a short sha so the worktree still gets a label.
return /^[0-9a-f]{7,40}$/i.test(head) ? head.slice(0, 8) : null
} catch {
return null
}
}
// Given the directory that owns the `.git` entry, resolve its worktree identity.
function resolveFromHost(host, fsImpl) {
const dotgit = path.join(host, '.git')
let stat
try {
stat = fsImpl.statSync(dotgit)
} catch {
return null
}
if (stat.isDirectory()) {
return {
repoRoot: host,
worktreeRoot: host,
isMainWorktree: true,
branch: readBranch(dotgit, fsImpl)
}
}
// Linked worktree: `.git` is a file pointing at the admin dir.
let contents
try {
contents = fsImpl.readFileSync(dotgit, 'utf8').trim()
} catch {
return null
}
const match = contents.match(/^gitdir:\s*(.+)$/m)
if (!match) {
return null
}
const adminDir = path.resolve(host, match[1].trim())
// `commondir` resolves to the shared `<repo>/.git`; fall back to walking two
// levels up from `<repo>/.git/worktrees/<name>` if it's missing.
let commonDir
try {
const rel = fsImpl.readFileSync(path.join(adminDir, 'commondir'), 'utf8').trim()
commonDir = path.resolve(adminDir, rel)
} catch {
commonDir = path.dirname(path.dirname(adminDir))
}
return {
repoRoot: path.dirname(commonDir),
worktreeRoot: host,
isMainWorktree: false,
branch: readBranch(adminDir, fsImpl)
}
}
function resolveWorktree(startPath, fsImpl = fs) {
let resolved
try {
resolved = resolveRequestedPathForIpc(startPath, { purpose: 'Worktree lookup' })
} catch {
return null
}
let start = resolved
try {
const stat = fsImpl.statSync(resolved)
if (!stat.isDirectory()) {
start = path.dirname(resolved)
}
} catch {
return null
}
const host = findGitHost(start, fsImpl)
if (!host) {
return null
}
return resolveFromHost(host, fsImpl)
}
// Batch entry point for the renderer: maps each requested cwd to its worktree
// info (or null when it isn't inside a git checkout / can't be read). Dedupes so
// many sessions sharing a cwd cost one lookup.
async function worktreesForIpc(cwds, options = {}) {
const fsImpl = options.fs || fs
const list = Array.isArray(cwds) ? cwds : []
const out = {}
for (const cwd of list) {
if (typeof cwd !== 'string' || !cwd.trim() || cwd in out) {
continue
}
out[cwd] = resolveWorktree(cwd, fsImpl)
}
return out
}
module.exports = {
resolveWorktree,
worktreesForIpc
}

View File

@@ -12,7 +12,6 @@ const {
powerMonitor,
protocol,
safeStorage,
screen,
session,
shell,
systemPreferences
@@ -38,7 +37,7 @@ const { canImportHermesCli, verifyHermesCli } = require('./backend-probes.cjs')
const { createLinkTitleWindow } = require('./link-title-window.cjs')
const { probeGatewayWebSocket } = require('./gateway-ws-probe.cjs')
const { adoptServedDashboardToken } = require('./dashboard-token.cjs')
const { waitForDashboardPortAnnouncement } = require('./backend-ready.cjs')
const { waitForDashboardPort } = require('./backend-ready.cjs')
const { serializeJsonBody, setJsonRequestHeaders } = require('./oauth-net-request.cjs')
const { fetchMarketplaceThemes, searchMarketplaceThemes } = require('./vscode-marketplace.cjs')
const { buildDesktopBackendEnv, normalizeHermesHomeRoot } = require('./backend-env.cjs')
@@ -55,25 +54,8 @@ const {
buildRelaunchScript
} = require('./update-relaunch.cjs')
const { gitRootForIpc } = require('./git-root.cjs')
const { addWorktree, listBranches, listWorktrees, removeWorktree, switchBranch } = require('./git-worktree-ops.cjs')
const {
fileDiffVsHead,
repoStatus,
reviewCommit,
reviewCommitContext,
reviewCreatePr,
reviewDiff,
reviewList,
reviewPush,
reviewRevParse,
reviewRevert,
reviewShipInfo,
reviewStage,
reviewUnstage
} = require('./git-review-ops.cjs')
const { scanGitRepos } = require('./git-repo-scan.cjs')
const { worktreesForIpc } = require('./git-worktrees.cjs')
const { OFFICIAL_REPO_HTTPS_URL, isOfficialSshRemote } = require('./update-remote.cjs')
const { resolveBehindCount, shouldCountCommits } = require('./update-count.cjs')
const { runRebuildWithRetry } = require('./update-rebuild.cjs')
const {
buildPosixCleanupScript,
@@ -85,13 +67,6 @@ const {
uninstallArgsForMode
} = require('./desktop-uninstall.cjs')
const { isPackagedInstallPath: isPackagedInstallPathUnderRoots } = require('./workspace-cwd.cjs')
const {
MIN_WIDTH: WINDOW_MIN_WIDTH,
MIN_HEIGHT: WINDOW_MIN_HEIGHT,
sanitizeWindowState,
computeWindowOptions,
debounce
} = require('./window-state.cjs')
const {
authModeFromStatus,
buildGatewayWsUrl,
@@ -345,7 +320,6 @@ const BOOTSTRAP_MARKER_SCHEMA_VERSION = 1
const DESKTOP_CONNECTION_CONFIG_PATH = path.join(app.getPath('userData'), 'connection.json')
const DESKTOP_UPDATE_CONFIG_PATH = path.join(app.getPath('userData'), 'updates.json')
const DESKTOP_WINDOW_STATE_PATH = path.join(app.getPath('userData'), 'window-state.json')
// active-profile.json records which Hermes profile the desktop launches its
// local backend as. When set, startHermes() passes `hermes --profile <name>
// dashboard …`, which deterministically pins HERMES_HOME (see
@@ -760,9 +734,6 @@ let rendererReloadTimes = []
// instead of re-running install.ps1 in a hot loop. Cleared explicitly by
// the renderer's "Reload and retry" path or by quitting the app.
let bootstrapFailure = null
// Latched non-bootstrap backend spawn failure — stops getConnection() from
// respawning hermes dashboard children in a tight loop while boot is broken.
let backendStartFailure = null
// Active first-launch install, so the renderer's Cancel button (and app quit)
// can abort the in-flight install.sh/ps1 instead of leaving it running.
let bootstrapAbortController = null
@@ -1273,39 +1244,6 @@ function isCommandScript(command) {
return IS_WINDOWS && /\.(cmd|bat)$/i.test(command || '')
}
function unwrapWindowsVenvHermesCommand(command, dashboardArgs) {
if (!IS_WINDOWS || !command || isCommandScript(command)) return null
const resolved = path.resolve(String(command))
if (!/^hermes(?:\.exe)?$/i.test(path.basename(resolved))) return null
const scriptsDir = path.dirname(resolved)
if (path.basename(scriptsDir).toLowerCase() !== 'scripts') return null
const venvRoot = path.dirname(scriptsDir)
const python = getNoConsoleVenvPython(venvRoot)
if (!fileExists(python)) return null
const root = path.dirname(venvRoot)
return {
label: `existing Hermes no-console Python at ${python}`,
command: python,
args: ['-m', 'hermes_cli.main', ...dashboardArgs],
bootstrap: false,
env: buildDesktopBackendEnv({
hermesHome: HERMES_HOME,
pythonPathEntries: [
...(directoryExists(root) ? [root] : []),
...getVenvSitePackagesEntries(venvRoot)
],
venvRoot
}),
kind: 'python',
readyFile: true,
shell: false
}
}
function normalizeExecutablePathForCompare(commandPath) {
if (!commandPath) return null
@@ -1526,99 +1464,6 @@ function getVenvPython(venvRoot) {
return path.join(venvRoot, IS_WINDOWS ? path.join('Scripts', 'python.exe') : path.join('bin', 'python'))
}
function readVenvHome(venvRoot) {
try {
const cfg = fs.readFileSync(path.join(venvRoot, 'pyvenv.cfg'), 'utf8')
const match = cfg.match(/^home\s*=\s*(.+?)\s*$/im)
return match ? match[1].trim() : null
} catch {
return null
}
}
function getNoConsoleVenvPython(venvRoot) {
if (!IS_WINDOWS) return getVenvPython(venvRoot)
// Prefer the venv's own pythonw shim — it carries pyvenv.cfg / site-packages
// wiring. Falling back to the base uv/python.org pythonw.exe skips the venv
// and breaks imports (yaml, hermes_cli, …) even when PYTHONPATH is patched.
const venvPythonw = path.join(venvRoot, 'Scripts', 'pythonw.exe')
if (fileExists(venvPythonw)) return venvPythonw
const baseHome = readVenvHome(venvRoot)
if (baseHome) {
const basePythonw = path.join(baseHome, 'pythonw.exe')
if (fileExists(basePythonw)) return basePythonw
}
return venvPythonw
}
function toNoConsolePython(pythonPath) {
if (!IS_WINDOWS || !pythonPath) return pythonPath
const resolved = String(pythonPath)
if (/pythonw\.exe$/i.test(resolved)) return resolved
if (/python\.exe$/i.test(resolved)) {
const pythonw = path.join(path.dirname(resolved), 'pythonw.exe')
if (fileExists(pythonw)) return pythonw
}
return pythonPath
}
function applyWindowsNoConsoleSpawnHints(backend) {
if (!IS_WINDOWS || !backend?.command) return backend
const usesHermesModule =
backend.kind === 'python' ||
(Array.isArray(backend.args) &&
backend.args[0] === '-m' &&
backend.args[1] === 'hermes_cli.main')
if (!usesHermesModule) return backend
backend.command = toNoConsolePython(backend.command)
if (/pythonw\.exe$/i.test(path.basename(String(backend.command || '')))) {
backend.readyFile = true
}
return backend
}
function getVenvSitePackagesEntries(venvRoot) {
const entries = []
if (!venvRoot) return entries
if (IS_WINDOWS) {
const sitePackages = path.join(venvRoot, 'Lib', 'site-packages')
if (directoryExists(sitePackages)) entries.push(sitePackages)
return entries
}
const version = (() => {
try {
const cfg = fs.readFileSync(path.join(venvRoot, 'pyvenv.cfg'), 'utf8')
const match = cfg.match(/^version_info\s*=\s*(\d+\.\d+)/im)
return match ? match[1].trim() : null
} catch {
return null
}
})()
if (version) {
const sitePackages = path.join(venvRoot, 'lib', `python${version}`, 'site-packages')
if (directoryExists(sitePackages)) entries.push(sitePackages)
}
return entries
}
function makeDashboardReadyFile() {
const dir = path.join(app.getPath('userData'), 'backend-ready')
fs.mkdirSync(dir, { recursive: true })
return path.join(dir, `dashboard-${process.pid}-${Date.now()}-${crypto.randomBytes(6).toString('hex')}.json`)
}
// resolveGitBinary — locate git.exe on Windows. A fresh installer-driven
// install only has PortableGit under %LOCALAPPDATA%\hermes\git (never on
// PATH), so a bare spawn('git') ENOENTs and self-update checks fail with
@@ -1648,30 +1493,6 @@ function resolveGitBinary() {
return _gitBinaryCache
}
// resolveGhBinary — locate the GitHub CLI. GUI-launched apps get a minimal PATH
// that omits Homebrew (/opt/homebrew/bin, /usr/local/bin) where `gh` usually
// lives, so a bare spawn('gh') ENOENTs even though `gh` works in the user's
// terminal. Check the common install locations first, then PATH. Cached.
let _ghBinaryCache = null
function resolveGhBinary() {
if (_ghBinaryCache) return _ghBinaryCache
const candidates = []
if (IS_WINDOWS) {
candidates.push(path.join(process.env['ProgramFiles'] || 'C:\\Program Files', 'GitHub CLI', 'gh.exe'))
if (process.env.LOCALAPPDATA) {
candidates.push(path.join(process.env.LOCALAPPDATA, 'Microsoft', 'WinGet', 'Links', 'gh.exe'))
}
} else {
const home = app.getPath('home')
candidates.push('/opt/homebrew/bin/gh', '/usr/local/bin/gh', '/usr/bin/gh', path.join(home, '.local', 'bin', 'gh'))
}
_ghBinaryCache = candidates.find(fileExists) || findOnPath('gh') || 'gh'
return _ghBinaryCache
}
function recentHermesLog() {
return hermesLog.slice(-20).join('\n')
}
@@ -1701,36 +1522,6 @@ function writeDesktopUpdateConfig(config) {
writeFileAtomic(DESKTOP_UPDATE_CONFIG_PATH, JSON.stringify(config, null, 2))
}
// ─── Main-window geometry persistence (window-state.json) ──────────────────
function readWindowState() {
try {
return sanitizeWindowState(JSON.parse(fs.readFileSync(DESKTOP_WINDOW_STATE_PATH, 'utf8')))
} catch {
return null
}
}
// Persist the window's restored (non-maximized) bounds plus its maximized flag.
// getNormalBounds() keeps the pre-maximize size, so un-maximizing next session
// lands back where the user actually sized the window.
function persistWindowState() {
if (!mainWindow || mainWindow.isDestroyed() || mainWindow.isMinimized()) return
try {
const { x, y, width, height } = mainWindow.getNormalBounds()
fs.mkdirSync(path.dirname(DESKTOP_WINDOW_STATE_PATH), { recursive: true })
writeFileAtomic(
DESKTOP_WINDOW_STATE_PATH,
JSON.stringify({ x, y, width, height, isMaximized: mainWindow.isMaximized() }, null, 2)
)
} catch (err) {
rememberLog(`[window-state] persist failed: ${err?.message || err}`)
}
}
// resized/moved fire many times mid-drag on Linux; debounce to one write.
const schedulePersistWindowState = debounce(persistWindowState, 250)
// Match the backend's source resolution but bias toward a real git checkout.
// Dev → SOURCE_REPO_ROOT. Packaged/CLI install → ACTIVE_HERMES_ROOT.
// HERMES_DESKTOP_HERMES_ROOT always wins so devs can pin a worktree.
@@ -1876,34 +1667,15 @@ async function checkUpdates() {
}
const git = args => runGit(args, { cwd: updateRoot }).then(r => r.stdout.trim())
const [currentSha, targetSha, dirtyStr, currentBranch, shallowStr, mergeBaseStr] = await Promise.all([
const [currentSha, targetSha, countStr, dirtyStr, currentBranch] = await Promise.all([
git(['rev-parse', 'HEAD']),
git(['rev-parse', `origin/${branch}`]),
git(['rev-list', `HEAD..origin/${branch}`, '--count']),
git(['status', '--porcelain']),
git(['rev-parse', '--abbrev-ref', 'HEAD']),
git(['rev-parse', '--is-shallow-repository']),
// merge-base exits non-zero with empty stdout when HEAD shares no common
// ancestor with the freshly fetched tip — exactly the shallow-clone case.
git(['merge-base', 'HEAD', `origin/${branch}`])
git(['rev-parse', '--abbrev-ref', 'HEAD'])
])
const isShallow = shallowStr === 'true'
const hasMergeBase = Boolean(mergeBaseStr)
// Only enumerate the commit count when it is meaningful. On a shallow checkout
// with no merge-base, `rev-list --count` walks the entire remote ancestry
// (thousands of commits, see #51922) and resolveBehindCount discards the
// result anyway in favour of a SHA compare — so skip the expensive query.
const countStr = shouldCountCommits({ isShallow, hasMergeBase })
? await git(['rev-list', `HEAD..origin/${branch}`, '--count'])
: ''
const behind = resolveBehindCount({
countStr,
currentSha,
targetSha,
isShallow,
hasMergeBase
})
const behind = Number.parseInt(countStr, 10) || 0
const commits = behind > 0 ? await readCommitLog(updateRoot, branch) : []
return {
@@ -2759,25 +2531,20 @@ function createPythonBackend(root, label, dashboardArgs, options = {}) {
const python = findPythonForRoot(root)
if (!python) return null
const venvRoot = path.join(root, 'venv')
const venvPython = getVenvPython(venvRoot)
const command =
IS_WINDOWS && fileExists(venvPython) ? getNoConsoleVenvPython(venvRoot) : toNoConsolePython(python)
return applyWindowsNoConsoleSpawnHints({
return {
kind: 'python',
label,
command,
command: python,
args: ['-m', 'hermes_cli.main', ...dashboardArgs],
env: buildDesktopBackendEnv({
hermesHome: HERMES_HOME,
pythonPathEntries: [root],
venvRoot
venvRoot: path.join(root, 'venv')
}),
root,
bootstrap: Boolean(options.bootstrap),
shell: false
})
}
}
// createActiveBackend — build a backend pointing at ACTIVE_HERMES_ROOT, the
@@ -2786,14 +2553,11 @@ function createPythonBackend(root, label, dashboardArgs, options = {}) {
// ensureRuntime() to create / refresh it before launch.
function createActiveBackend(dashboardArgs) {
const venvPython = getVenvPython(VENV_ROOT)
const command = fileExists(venvPython)
? getNoConsoleVenvPython(VENV_ROOT)
: toNoConsolePython(findSystemPython())
return applyWindowsNoConsoleSpawnHints({
return {
kind: 'python',
label: `Hermes at ${ACTIVE_HERMES_ROOT}`,
command,
command: fileExists(venvPython) ? venvPython : findSystemPython(),
args: ['-m', 'hermes_cli.main', ...dashboardArgs],
env: buildDesktopBackendEnv({
hermesHome: HERMES_HOME,
@@ -2803,7 +2567,7 @@ function createActiveBackend(dashboardArgs) {
root: ACTIVE_HERMES_ROOT,
bootstrap: true,
shell: false
})
}
}
function resolveHermesBackend(dashboardArgs) {
@@ -2864,11 +2628,6 @@ function resolveHermesBackend(dashboardArgs) {
}
if (hermesCommand) {
const unwrapped = unwrapWindowsVenvHermesCommand(hermesCommand, dashboardArgs)
if (unwrapped) {
return unwrapped
}
// Smoke-test the candidate before trusting it. A `hermes` shim
// left behind by a half-uninstalled pip install (or a venv
// entry-point pointing at a deleted interpreter) still resolves
@@ -2878,7 +2637,7 @@ function resolveHermesBackend(dashboardArgs) {
// and lets the resolver fall through to step 6 / bootstrap.
const shellForProbe = isCommandScript(hermesCommand)
if (verifyHermesCli(hermesCommand, { shell: shellForProbe })) {
return unwrapWindowsVenvHermesCommand(hermesCommand, dashboardArgs) || {
return {
label: `existing Hermes CLI at ${hermesCommand}`,
command: hermesCommand,
args: dashboardArgs,
@@ -2908,15 +2667,15 @@ function resolveHermesBackend(dashboardArgs) {
// failure, fall through to step 6 so the bootstrap runner pulls
// a uv-managed 3.11 into %LOCALAPPDATA%\hermes\hermes-agent\venv.
if (canImportHermesCli(python)) {
return applyWindowsNoConsoleSpawnHints({
return {
kind: 'python',
label: `installed hermes_cli module via ${python}`,
command: toNoConsolePython(python),
command: python,
args: ['-m', 'hermes_cli.main', ...dashboardArgs],
bootstrap: false,
env: {},
shell: false
})
}
}
rememberLog(`Ignoring system Python ${python}: hermes_cli is not importable; falling through to bootstrap.`)
}
@@ -2950,7 +2709,7 @@ function resolveHermesBackend(dashboardArgs) {
async function ensureRuntime(backend) {
if (!backend.bootstrap) {
await advanceBootProgress('runtime.external', `Using ${backend.label}`, 32)
return applyWindowsNoConsoleSpawnHints(backend)
return backend
}
// backend.kind === 'bootstrap-needed' means resolveHermesBackend couldn't
@@ -3090,7 +2849,7 @@ async function ensureRuntime(backend) {
)
}
backend.command = getNoConsoleVenvPython(VENV_ROOT)
backend.command = venvPython
backend.label = `Hermes at ${ACTIVE_HERMES_ROOT} (venv: ${VENV_ROOT})`
updateBootProgress({
phase: 'runtime.ready',
@@ -3099,9 +2858,10 @@ async function ensureRuntime(backend) {
running: true,
error: null
})
return applyWindowsNoConsoleSpawnHints(backend)
return backend
}
function fetchJson(url, token, options = {}) {
return new Promise((resolve, reject) => {
const body = options.body === undefined ? undefined : Buffer.from(JSON.stringify(options.body))
@@ -5012,7 +4772,6 @@ function resetBootProgressForReconnect() {
function resetHermesConnection() {
connectionPromise = null
backendStartFailure = null
if (hermesProcess && !hermesProcess.killed) {
hermesProcess.kill('SIGTERM')
@@ -5174,7 +4933,6 @@ async function spawnPoolBackend(profile, entry) {
const backend = await ensureRuntime(resolveHermesBackend(dashboardArgs))
const hermesCwd = resolveHermesCwd()
const webDist = resolveWebDist()
const readyFile = backend.readyFile ? makeDashboardReadyFile() : null
rememberLog(`Starting Hermes backend for profile "${profile}" via ${backend.label}`)
@@ -5195,8 +4953,7 @@ async function spawnPoolBackend(profile, entry) {
// Marks this dashboard backend as desktop-spawned so it runs the cron
// scheduler tick loop (the gateway isn't running under the app).
HERMES_DESKTOP: '1',
HERMES_WEB_DIST: webDist,
...(readyFile ? { HERMES_DESKTOP_READY_FILE: readyFile } : {})
HERMES_WEB_DIST: webDist
},
shell: backend.shell,
stdio: ['ignore', 'pipe', 'pipe']
@@ -5229,10 +4986,7 @@ async function spawnPoolBackend(profile, entry) {
})
// Discover the ephemeral port the child bound to
const port = await Promise.race([waitForDashboardPortAnnouncement(child, { readyFile }), startFailed])
if (readyFile) {
fs.unlink(readyFile, () => {})
}
const port = await Promise.race([waitForDashboardPort(child), startFailed])
entry.port = port
const baseUrl = `http://127.0.0.1:${port}`
@@ -5345,9 +5099,6 @@ async function startHermes() {
if (bootstrapFailure) {
throw bootstrapFailure
}
if (backendStartFailure) {
throw backendStartFailure
}
if (connectionPromise) return connectionPromise
connectionPromise = (async () => {
@@ -5401,7 +5152,6 @@ async function startHermes() {
const backend = await ensureRuntime(resolveHermesBackend(dashboardArgs))
const hermesCwd = resolveHermesCwd()
const webDist = resolveWebDist()
const readyFile = backend.readyFile ? makeDashboardReadyFile() : null
await advanceBootProgress('backend.spawn', `Starting Hermes backend via ${backend.label}`, 84)
rememberLog(`Starting Hermes backend via ${backend.label}`)
@@ -5428,8 +5178,7 @@ async function startHermes() {
// Marks this dashboard backend as desktop-spawned so it runs the cron
// scheduler tick loop (the gateway isn't running under the app).
HERMES_DESKTOP: '1',
HERMES_WEB_DIST: webDist,
...(readyFile ? { HERMES_DESKTOP_READY_FILE: readyFile } : {})
HERMES_WEB_DIST: webDist
},
shell: backend.shell,
stdio: ['ignore', 'pipe', 'pipe']
@@ -5485,16 +5234,12 @@ async function startHermes() {
await advanceBootProgress('backend.port', 'Waiting for Hermes backend to launch', 86)
// Discover the ephemeral port the child bound to
const port = await Promise.race([waitForDashboardPortAnnouncement(hermesProcess, { readyFile }), backendStartFailed])
if (readyFile) {
fs.unlink(readyFile, () => {})
}
const port = await Promise.race([waitForDashboardPort(hermesProcess), backendStartFailed])
const baseUrl = `http://127.0.0.1:${port}`
await advanceBootProgress('backend.wait', 'Waiting for Hermes backend to become ready', 90)
await Promise.race([waitForHermes(baseUrl, token), backendStartFailed])
backendReady = true
backendStartFailure = null
const authToken = await adoptServedDashboardToken(baseUrl, token, {
// The exit/error handlers null hermesProcess when the child dies.
childAlive: () => hermesProcess !== null && hermesProcess.exitCode === null && !hermesProcess.killed,
@@ -5520,7 +5265,6 @@ async function startHermes() {
}
})().catch(error => {
const message = error instanceof Error ? error.message : String(error)
backendStartFailure = error instanceof Error ? error : new Error(message)
updateBootProgress(
{
error: message,
@@ -5641,149 +5385,13 @@ function createNewSessionWindow() {
return spawnSecondaryWindow({ newSession: true })
}
// The pet overlay: a single transparent, frameless, always-on-top window that
// hosts ONLY the floating mascot. Shift-clicking the in-window pet "pops it out"
// here so it can leave the app's bounds and stay visible while Hermes is
// minimized (Codex-style task-completion glance). It carries no gateway
// connection of its own — the main renderer is the single source of truth and
// pushes pet state over IPC (hermes:pet-overlay:state); the overlay just renders
// it. Control flows back (pop-in, composer submit) via hermes:pet-overlay:control.
let petOverlayWindow = null
function petOverlayUrl() {
if (DEV_SERVER) {
return `${DEV_SERVER.endsWith('/') ? DEV_SERVER.slice(0, -1) : DEV_SERVER}/?win=overlay#/`
}
return `${pathToFileURL(resolveRendererIndex()).toString()}?win=overlay#/`
}
function spawnPetOverlayWindow(bounds) {
const win = new BrowserWindow({
width: Math.max(80, Math.round(bounds?.width || 220)),
height: Math.max(80, Math.round(bounds?.height || 220)),
x: Number.isFinite(bounds?.x) ? Math.round(bounds.x) : undefined,
y: Number.isFinite(bounds?.y) ? Math.round(bounds.y) : undefined,
frame: false,
transparent: true,
resizable: false,
movable: true,
minimizable: false,
maximizable: false,
fullscreenable: false,
// Windows/Linux need this so the helper window does not get its own
// taskbar/alt-tab entry. On macOS, cmd-tab is app-level and this can make
// the whole app look like it vanished when the only newly-created visible
// window is a frameless overlay. Use NSPanel + Mission Control hiding below
// instead, leaving the main Hermes app as the Dock/cmd-tab anchor.
skipTaskbar: !IS_MAC,
hasShadow: false,
alwaysOnTop: true,
// macOS panels are non-activating helper windows and can float over full
// screen spaces without becoming the app's main switcher window.
type: IS_MAC ? 'panel' : undefined,
hiddenInMissionControl: IS_MAC,
// Non-activating: the overlay must never become the app's key/main window,
// or it (a frameless, taskbar-skipping panel) becomes the app's switcher
// anchor and the Hermes icon drops out of cmd/alt-tab — especially when the
// main window is minimized. We flip this on only while the composer needs
// the keyboard (see hermes:pet-overlay:set-focusable).
focusable: false,
show: false,
// Fully transparent — the renderer paints only the sprite + bubble.
backgroundColor: '#00000000',
webPreferences: {
preload: path.join(__dirname, 'preload.cjs'),
contextIsolation: true,
sandbox: true,
nodeIntegration: false,
devTools: true,
// Keep the sprite animating + bubble updating while the main window is
// minimized/blurred — the whole point of the overlay.
backgroundThrottling: false
}
})
// Float above other apps and follow the user across desktops so the pet is
// always reachable. `floating` + `type: panel` is the macOS NSPanel path; the
// more aggressive `screen-saver` level can interfere with normal app/window
// switching semantics.
win.setAlwaysOnTop(true, IS_MAC ? 'floating' : 'screen-saver')
win.setHiddenInMissionControl?.(true)
try {
// Electron docs: macOS may transform process type on each
// setVisibleOnAllWorkspaces() call unless skipTransformProcessType=true,
// which briefly hides the Dock/cmd-tab presence. Keep Hermes in the normal
// ForegroundApplication class so shift-clicking the pet never drops the app
// out of app switchers.
win.setVisibleOnAllWorkspaces(
true,
IS_MAC ? { visibleOnFullScreen: true, skipTransformProcessType: true } : undefined
)
} catch {
// Not supported everywhere — best effort.
}
wireCommonWindowHandlers(win)
win.once('ready-to-show', () => {
if (!win.isDestroyed()) win.showInactive()
})
win.on('closed', () => {
if (petOverlayWindow === win) {
petOverlayWindow = null
}
// If the overlay went away on its own (e.g. ⌘W), tell the main renderer to
// pop the pet back in so it doesn't stay hidden. Harmless echo when we're
// the ones who closed it (popInPet already cleared the active flag).
if (mainWindow && !mainWindow.isDestroyed()) {
mainWindow.webContents.send('hermes:pet-overlay:control', { type: 'pop-in' })
}
})
win.loadURL(petOverlayUrl())
return win
}
function openPetOverlay(bounds) {
if (petOverlayWindow && !petOverlayWindow.isDestroyed()) {
if (bounds) {
petOverlayWindow.setBounds({
x: Math.round(bounds.x),
y: Math.round(bounds.y),
width: Math.max(80, Math.round(bounds.width)),
height: Math.max(80, Math.round(bounds.height))
})
}
petOverlayWindow.showInactive()
return petOverlayWindow
}
petOverlayWindow = spawnPetOverlayWindow(bounds)
return petOverlayWindow
}
function closePetOverlay() {
if (petOverlayWindow && !petOverlayWindow.isDestroyed()) {
petOverlayWindow.close()
}
petOverlayWindow = null
}
function createWindow() {
const icon = getAppIconPath()
const savedWindowState = readWindowState()
mainWindow = new BrowserWindow({
...computeWindowOptions(savedWindowState, screen.getAllDisplays()),
minWidth: WINDOW_MIN_WIDTH,
minHeight: WINDOW_MIN_HEIGHT,
width: 1220,
height: 800,
minWidth: 400,
minHeight: 620,
title: 'Hermes',
// Frameless title bar on every platform so the renderer can paint the
// "hide sidebar" button (and other left-side titlebar tools) flush with
@@ -5825,8 +5433,6 @@ function createWindow() {
}
}
if (savedWindowState?.isMaximized) mainWindow.maximize()
mainWindow.once('ready-to-show', () => {
if (mainWindow && !mainWindow.isDestroyed()) mainWindow.show()
})
@@ -5836,19 +5442,6 @@ function createWindow() {
mainWindow.on('will-leave-full-screen', () => sendWindowStateChanged(false))
mainWindow.on('leave-full-screen', () => sendWindowStateChanged(false))
// Reopen where the user left off. resized/moved settle once per drag; close is
// the cross-platform backstop, flushed synchronously before the window is gone.
mainWindow.on('resized', schedulePersistWindowState)
mainWindow.on('moved', schedulePersistWindowState)
mainWindow.on('maximize', schedulePersistWindowState)
mainWindow.on('unmaximize', schedulePersistWindowState)
mainWindow.on('close', () => schedulePersistWindowState.flush())
// The overlay rides the main window — closing the app's primary window must
// tear it down too (otherwise it strands as an orphan that blocks
// window-all-closed from quitting on Windows/Linux).
mainWindow.on('closed', () => closePetOverlay())
wireCommonWindowHandlers(mainWindow)
mainWindow.webContents.on('render-process-gone', (_event, details) => {
@@ -5969,116 +5562,6 @@ ipcMain.handle('hermes:window:openNewSession', async () => {
return { ok: true }
})
// --- Pet overlay (pop-out mascot) -----------------------------------------
// `request` is `{ bounds, screen }`. A fresh pop-out passes viewport-space
// bounds (screen=false): convert to screen space by adding the main window's
// content origin so the pet lands where it sat in-window. A remembered/dragged
// spot passes screen-space bounds (screen=true) and is used as-is. We return the
// resolved screen bounds so the renderer can persist exactly where it opened.
ipcMain.handle('hermes:pet-overlay:open', async (_event, request) => {
const bounds = request && request.bounds ? request.bounds : request
const isScreen = Boolean(request && request.screen)
let screenBounds = bounds
try {
if (bounds && !isScreen && mainWindow && !mainWindow.isDestroyed()) {
const content = mainWindow.getContentBounds()
screenBounds = {
x: content.x + (bounds.x || 0),
y: content.y + (bounds.y || 0),
width: bounds.width,
height: bounds.height
}
}
} catch {
// Fall back to raw bounds if the window geometry is unavailable.
}
openPetOverlay(screenBounds)
return { ok: true, bounds: screenBounds }
})
ipcMain.handle('hermes:pet-overlay:close', async () => {
closePetOverlay()
return { ok: true }
})
// Drag: the overlay reports a new absolute screen position (it already knows the
// pointer's screen coords), we just move the window.
ipcMain.on('hermes:pet-overlay:set-bounds', (_event, bounds) => {
if (!petOverlayWindow || petOverlayWindow.isDestroyed() || !bounds) {
return
}
petOverlayWindow.setBounds({
x: Math.round(bounds.x),
y: Math.round(bounds.y),
width: Math.max(80, Math.round(bounds.width)),
height: Math.max(80, Math.round(bounds.height))
})
})
// Click-through: the overlay window is a full rectangle but only the pet pixels
// should be interactive. The renderer toggles this as the cursor enters/leaves
// the sprite so transparent margins pass clicks to whatever is behind.
ipcMain.on('hermes:pet-overlay:ignore-mouse', (_event, ignore) => {
if (petOverlayWindow && !petOverlayWindow.isDestroyed()) {
petOverlayWindow.setIgnoreMouseEvents(Boolean(ignore), { forward: true })
}
})
// The overlay is a non-activating panel (focusable:false) so it never steals
// the app's cmd/alt-tab anchor from the main window. But the pop-up composer
// needs the keyboard, so the renderer asks us to flip it focusable + focus it
// while the composer is open, then back to non-activating when it closes.
ipcMain.on('hermes:pet-overlay:set-focusable', (_event, focusable) => {
if (!petOverlayWindow || petOverlayWindow.isDestroyed()) {
return
}
petOverlayWindow.setFocusable(Boolean(focusable))
if (focusable) {
petOverlayWindow.focus()
}
})
// Main renderer → overlay: forward the latest pet state for the overlay to render.
ipcMain.on('hermes:pet-overlay:state', (_event, payload) => {
if (petOverlayWindow && !petOverlayWindow.isDestroyed()) {
petOverlayWindow.webContents.send('hermes:pet-overlay:state', payload)
}
})
// Overlay → main renderer: control messages (pop back in, composer submit).
ipcMain.on('hermes:pet-overlay:control', (_event, payload) => {
if (!mainWindow || mainWindow.isDestroyed()) {
return
}
// Double-click toggles the app window: hide it away if it's up front, bring it
// back if it's minimized/buried. Pure window control — nothing for the
// renderer to do, so don't forward it.
if (payload && payload.type === 'toggle-app') {
if (mainWindow.isMinimized() || !mainWindow.isVisible()) {
mainWindow.show()
mainWindow.focus()
} else {
mainWindow.minimize()
}
return
}
// The mail icon means "take me to the app": raise the main window (it may be
// minimized or buried) before the renderer navigates to the latest thread.
if (payload && payload.type === 'open-app') {
if (mainWindow.isMinimized()) {
mainWindow.restore()
}
mainWindow.show()
mainWindow.focus()
}
mainWindow.webContents.send('hermes:pet-overlay:control', payload)
})
ipcMain.handle('hermes:bootstrap:reset', async () => {
// Renderer's "Reload and retry" path. Clear the latched failure and
// reset connection state so the next startHermes() call restarts the
@@ -6086,7 +5569,6 @@ ipcMain.handle('hermes:bootstrap:reset', async () => {
rememberLog('[bootstrap] reset requested by renderer; clearing latched failure')
await teardownPrimaryBackendAndWait()
bootstrapFailure = null
backendStartFailure = null
bootstrapState = {
active: false,
manifest: null,
@@ -6113,7 +5595,6 @@ ipcMain.handle('hermes:bootstrap:repair', async () => {
rememberLog(`[bootstrap] failed to remove marker during repair: ${error.message}`)
}
bootstrapFailure = null
backendStartFailure = null
resetHermesConnection()
return { ok: true }
})
@@ -6795,164 +6276,7 @@ ipcMain.handle('hermes:fs:readDir', async (_event, dirPath) => readDirForIpc(dir
ipcMain.handle('hermes:fs:gitRoot', async (_event, startPath) => gitRootForIpc(startPath))
// Reveal a path in the OS file manager (Finder / Explorer / Files).
ipcMain.handle('hermes:fs:reveal', async (_event, targetPath) => {
const target = String(targetPath || '').trim()
if (!target) {
return false
}
try {
shell.showItemInFolder(target)
return true
} catch {
return false
}
})
// Rename a file/folder in place. The renderer passes the existing path + a new
// base name; the destination is resolved in the SAME parent dir so a rename can
// never move the item elsewhere or traverse out. Rejects on a name collision.
ipcMain.handle('hermes:fs:rename', async (_event, targetPath, newName) => {
const src = String(targetPath || '').trim()
const name = String(newName || '').trim()
if (!src || !name || name === '.' || name === '..' || name.includes('/') || name.includes('\\')) {
throw new Error('Invalid rename')
}
const dst = path.join(path.dirname(src), name)
if (dst === src) {
return { path: dst }
}
if (fs.existsSync(dst)) {
throw new Error(`"${name}" already exists`)
}
await fs.promises.rename(src, dst)
return { path: dst }
})
// Write a small UTF-8 text file (e.g. a project's IDEA.md at creation). The path
// is hardened (resolveRequestedPathForIpc) and the parent must already exist —
// this never creates directory trees or escapes the allowed roots, and content
// is size-capped so it can't be abused as a bulk-write primitive.
ipcMain.handle('hermes:fs:writeText', async (_event, filePath, content) => {
const raw = String(filePath || '').trim()
if (!raw) {
throw new Error('Invalid path')
}
const text = String(content ?? '')
if (text.length > 1_000_000) {
throw new Error('Content too large')
}
const resolved = resolveRequestedPathForIpc(expandUserPath(raw), { purpose: 'Write text file' })
if (!directoryExists(path.dirname(resolved))) {
throw new Error('Parent directory does not exist')
}
await fs.promises.writeFile(resolved, text, 'utf8')
return { path: resolved }
})
// Move a file/folder to the OS trash (recoverable) — the VS Code "Delete"
// default. `shell.trashItem` routes to Finder/Explorer/Files trash per platform.
ipcMain.handle('hermes:fs:trash', async (_event, targetPath) => {
const target = String(targetPath || '').trim()
if (!target) {
throw new Error('Invalid delete')
}
await shell.trashItem(target)
return true
})
// Git-driven worktree management ("Start work" flow). Errors surface to the
// renderer as rejected promises so it can toast a friendly message.
ipcMain.handle('hermes:git:worktreeList', async (_event, repoPath) =>
listWorktrees(repoPath, resolveGitBinary())
)
ipcMain.handle('hermes:git:worktreeAdd', async (_event, repoPath, options) =>
addWorktree(repoPath, options || {}, resolveGitBinary())
)
ipcMain.handle('hermes:git:worktreeRemove', async (_event, repoPath, worktreePath, options) =>
removeWorktree(repoPath, worktreePath, options || {}, resolveGitBinary())
)
ipcMain.handle('hermes:git:branchSwitch', async (_event, repoPath, branch) =>
switchBranch(repoPath, branch, resolveGitBinary())
)
ipcMain.handle('hermes:git:branchList', async (_event, repoPath) =>
listBranches(repoPath, resolveGitBinary())
)
// Compact repo status (branch, ahead/behind, change counts + files) for the
// composer coding rail. Returns null on a non-repo / remote backend so the rail
// hides cleanly rather than erroring.
ipcMain.handle('hermes:git:repoStatus', async (_event, repoPath) => repoStatus(repoPath, resolveGitBinary()))
// Codex-style review pane: list changed files for a scope, fetch one file's
// unified diff, and stage / unstage / revert. Reads return empty on failure;
// mutations reject so the renderer can toast.
ipcMain.handle('hermes:git:review:list', async (_event, repoPath, scope, baseRef) =>
reviewList(repoPath, scope, baseRef, resolveGitBinary())
)
ipcMain.handle('hermes:git:review:diff', async (_event, repoPath, filePath, scope, baseRef, staged) =>
reviewDiff(repoPath, filePath, scope, baseRef, staged, resolveGitBinary())
)
// Working-tree-vs-HEAD diff for one file (the preview's "show the diff" view).
ipcMain.handle('hermes:git:fileDiff', async (_event, repoPath, filePath) =>
fileDiffVsHead(repoPath, filePath, resolveGitBinary())
)
ipcMain.handle('hermes:git:review:stage', async (_event, repoPath, filePath) =>
reviewStage(repoPath, filePath ?? null, resolveGitBinary())
)
ipcMain.handle('hermes:git:review:unstage', async (_event, repoPath, filePath) =>
reviewUnstage(repoPath, filePath ?? null, resolveGitBinary())
)
ipcMain.handle('hermes:git:review:revert', async (_event, repoPath, filePath) =>
reviewRevert(repoPath, filePath ?? null, resolveGitBinary())
)
ipcMain.handle('hermes:git:review:revParse', async (_event, repoPath, ref) =>
reviewRevParse(repoPath, ref, resolveGitBinary())
)
ipcMain.handle('hermes:git:review:commit', async (_event, repoPath, message, push) =>
reviewCommit(repoPath, message, Boolean(push), resolveGitBinary())
)
ipcMain.handle('hermes:git:review:commitContext', async (_event, repoPath) =>
reviewCommitContext(repoPath, resolveGitBinary())
)
ipcMain.handle('hermes:git:review:push', async (_event, repoPath) => reviewPush(repoPath, resolveGitBinary()))
ipcMain.handle('hermes:git:review:shipInfo', async (_event, repoPath) => reviewShipInfo(repoPath, resolveGhBinary()))
ipcMain.handle('hermes:git:review:createPr', async (_event, repoPath) =>
reviewCreatePr(repoPath, resolveGitBinary(), resolveGhBinary())
)
// Repo-first project discovery: scan bounded roots for git repos (pure fs walk,
// no native addon). Never throws to the renderer — failures yield an empty list.
ipcMain.handle('hermes:git:scanRepos', async (_event, roots, options) => {
try {
return await scanGitRepos(roots || [], options || {})
} catch {
return []
}
})
ipcMain.handle('hermes:fs:worktrees', async (_event, cwds) => worktreesForIpc(cwds))
ipcMain.handle('hermes:terminal:start', async (event, payload = {}) => {
if (!nodePty) {
@@ -7448,10 +6772,6 @@ function configureSpellChecker() {
}
app.on('before-quit', () => {
// The always-on-top overlay isn't a "real" app window; close it so a stray
// pet can't keep the process alive or float over a quit app.
closePetOverlay()
// Quitting mid-install should stop the installer, not orphan it.
if (bootstrapAbortController) {
try {

View File

@@ -7,32 +7,6 @@ contextBridge.exposeInMainWorld('hermesDesktop', {
getGatewayWsUrl: profile => ipcRenderer.invoke('hermes:gateway:ws-url', profile),
openSessionWindow: (sessionId, opts) => ipcRenderer.invoke('hermes:window:openSession', sessionId, opts),
openNewSessionWindow: () => ipcRenderer.invoke('hermes:window:openNewSession'),
petOverlay: {
// Main renderer → main process: window lifecycle + drag. `request` is
// `{ bounds, screen }`; resolves with the screen bounds it actually used.
open: request => ipcRenderer.invoke('hermes:pet-overlay:open', request),
close: () => ipcRenderer.invoke('hermes:pet-overlay:close'),
setBounds: bounds => ipcRenderer.send('hermes:pet-overlay:set-bounds', bounds),
setIgnoreMouse: ignore => ipcRenderer.send('hermes:pet-overlay:ignore-mouse', ignore),
// Flip the overlay focusable (and focus it) while the composer needs keys.
setFocusable: focusable => ipcRenderer.send('hermes:pet-overlay:set-focusable', focusable),
// Main renderer → overlay (forwarded by main): push the latest pet state.
pushState: payload => ipcRenderer.send('hermes:pet-overlay:state', payload),
// Overlay → main renderer (forwarded by main): pop back in / composer submit.
control: payload => ipcRenderer.send('hermes:pet-overlay:control', payload),
// Overlay subscribes to state pushes.
onState: callback => {
const listener = (_event, payload) => callback(payload)
ipcRenderer.on('hermes:pet-overlay:state', listener)
return () => ipcRenderer.removeListener('hermes:pet-overlay:state', listener)
},
// Main renderer subscribes to overlay control messages.
onControl: callback => {
const listener = (_event, payload) => callback(payload)
ipcRenderer.on('hermes:pet-overlay:control', listener)
return () => ipcRenderer.removeListener('hermes:pet-overlay:control', listener)
}
},
getBootProgress: () => ipcRenderer.invoke('hermes:boot-progress:get'),
getConnectionConfig: profile => ipcRenderer.invoke('hermes:connection-config:get', profile),
saveConnectionConfig: payload => ipcRenderer.invoke('hermes:connection-config:save', payload),
@@ -82,35 +56,7 @@ contextBridge.exposeInMainWorld('hermesDesktop', {
getRecentLogs: () => ipcRenderer.invoke('hermes:logs:recent'),
readDir: dirPath => ipcRenderer.invoke('hermes:fs:readDir', dirPath),
gitRoot: startPath => ipcRenderer.invoke('hermes:fs:gitRoot', startPath),
revealPath: targetPath => ipcRenderer.invoke('hermes:fs:reveal', targetPath),
renamePath: (targetPath, newName) => ipcRenderer.invoke('hermes:fs:rename', targetPath, newName),
writeTextFile: (filePath, content) => ipcRenderer.invoke('hermes:fs:writeText', filePath, content),
trashPath: targetPath => ipcRenderer.invoke('hermes:fs:trash', targetPath),
git: {
worktreeList: repoPath => ipcRenderer.invoke('hermes:git:worktreeList', repoPath),
worktreeAdd: (repoPath, options) => ipcRenderer.invoke('hermes:git:worktreeAdd', repoPath, options),
worktreeRemove: (repoPath, worktreePath, options) =>
ipcRenderer.invoke('hermes:git:worktreeRemove', repoPath, worktreePath, options),
branchSwitch: (repoPath, branch) => ipcRenderer.invoke('hermes:git:branchSwitch', repoPath, branch),
branchList: repoPath => ipcRenderer.invoke('hermes:git:branchList', repoPath),
repoStatus: repoPath => ipcRenderer.invoke('hermes:git:repoStatus', repoPath),
fileDiff: (repoPath, filePath) => ipcRenderer.invoke('hermes:git:fileDiff', repoPath, filePath),
scanRepos: (roots, options) => ipcRenderer.invoke('hermes:git:scanRepos', roots, options),
review: {
list: (repoPath, scope, baseRef) => ipcRenderer.invoke('hermes:git:review:list', repoPath, scope, baseRef),
diff: (repoPath, filePath, scope, baseRef, staged) =>
ipcRenderer.invoke('hermes:git:review:diff', repoPath, filePath, scope, baseRef, staged),
stage: (repoPath, filePath) => ipcRenderer.invoke('hermes:git:review:stage', repoPath, filePath),
unstage: (repoPath, filePath) => ipcRenderer.invoke('hermes:git:review:unstage', repoPath, filePath),
revert: (repoPath, filePath) => ipcRenderer.invoke('hermes:git:review:revert', repoPath, filePath),
revParse: (repoPath, ref) => ipcRenderer.invoke('hermes:git:review:revParse', repoPath, ref),
commit: (repoPath, message, push) => ipcRenderer.invoke('hermes:git:review:commit', repoPath, message, push),
commitContext: repoPath => ipcRenderer.invoke('hermes:git:review:commitContext', repoPath),
push: repoPath => ipcRenderer.invoke('hermes:git:review:push', repoPath),
shipInfo: repoPath => ipcRenderer.invoke('hermes:git:review:shipInfo', repoPath),
createPr: repoPath => ipcRenderer.invoke('hermes:git:review:createPr', repoPath)
}
},
worktrees: cwds => ipcRenderer.invoke('hermes:fs:worktrees', cwds),
terminal: {
dispose: id => ipcRenderer.invoke('hermes:terminal:dispose', id),
resize: (id, size) => ipcRenderer.invoke('hermes:terminal:resize', id, size),

View File

@@ -1,28 +0,0 @@
'use strict'
// Whether `git rev-list HEAD..origin/<branch> --count` produces a meaningful
// number worth computing. On a SHALLOW checkout (installer clones with
// --depth 1) the local history often shares no merge-base with the freshly
// fetched origin tip, so the count enumerates the entire remote ancestry and
// returns a bogus huge number (e.g. 12104) — see #51922. resolveBehindCount
// discards that bogus count in favour of a SHA compare, so the caller should
// SKIP the expensive rev-list entirely in that case rather than run it and
// throw the result away.
function shouldCountCommits({ isShallow, hasMergeBase }) {
return !(isShallow && !hasMergeBase)
}
// Resolve how many commits the local checkout is behind origin for the desktop
// update indicator. When the count isn't meaningful (shallow + no merge-base)
// fall back to a binary up-to-date check by SHA, exactly like the official-SSH
// path in checkUpdates() and the CLI guard in hermes_cli/banner.py. Full clones
// (developers / Docker dev images) keep the exact count path unchanged.
function resolveBehindCount({ countStr, currentSha, targetSha, isShallow, hasMergeBase }) {
if (!shouldCountCommits({ isShallow, hasMergeBase })) {
if (currentSha && targetSha && currentSha === targetSha) return 0
return 1 // behind by an unknown amount — show a generic "update available"
}
return Number.parseInt(countStr, 10) || 0
}
module.exports = { resolveBehindCount, shouldCountCommits }

View File

@@ -1,79 +0,0 @@
'use strict'
const test = require('node:test')
const assert = require('node:assert/strict')
const { resolveBehindCount, shouldCountCommits } = require('./update-count.cjs')
// FAIL-BEFORE: pre-fix the function did `Number.parseInt(countStr) || 0`
// unconditionally, so a shallow checkout with no merge-base surfaced the bogus
// rev-list count (e.g. 12104). This asserts the new shallow/no-merge-base branch.
test('shallow checkout with no merge-base does NOT trust the bogus rev-list count', () => {
assert.equal(resolveBehindCount({
countStr: '12104', currentSha: 'aaa', targetSha: 'bbb',
isShallow: true, hasMergeBase: false,
}), 1)
})
test('shallow checkout with no merge-base but identical SHA reports up-to-date', () => {
assert.equal(resolveBehindCount({
countStr: '12104', currentSha: 'abc', targetSha: 'abc',
isShallow: true, hasMergeBase: false,
}), 0)
})
test('shallow checkout WITH a merge-base keeps the exact count (reliable)', () => {
assert.equal(resolveBehindCount({
countStr: '3', currentSha: 'aaa', targetSha: 'bbb',
isShallow: true, hasMergeBase: true,
}), 3)
})
test('full (non-shallow) clone keeps the exact count path unchanged', () => {
assert.equal(resolveBehindCount({
countStr: '7', currentSha: 'aaa', targetSha: 'bbb',
isShallow: false, hasMergeBase: true,
}), 7)
})
test('up-to-date full clone reports 0', () => {
assert.equal(resolveBehindCount({
countStr: '0', currentSha: 'x', targetSha: 'x',
isShallow: false, hasMergeBase: true,
}), 0)
})
test('non-numeric count falls back to 0 (defensive, unchanged behaviour)', () => {
assert.equal(resolveBehindCount({
countStr: '', currentSha: 'aaa', targetSha: 'bbb',
isShallow: false, hasMergeBase: true,
}), 0)
})
// shouldCountCommits gates the expensive `rev-list --count` in checkUpdates().
// FAIL-BEFORE: in the shallow + no-merge-base case the caller ran rev-list
// unconditionally and discarded the bogus result; this predicate lets the
// caller SKIP the whole-ancestry enumeration in exactly that case (#51922).
test('shallow checkout with no merge-base SKIPS the rev-list count', () => {
assert.equal(shouldCountCommits({ isShallow: true, hasMergeBase: false }), false)
})
test('shallow checkout WITH a merge-base still runs the count', () => {
assert.equal(shouldCountCommits({ isShallow: true, hasMergeBase: true }), true)
})
test('full (non-shallow) clone always runs the count', () => {
assert.equal(shouldCountCommits({ isShallow: false, hasMergeBase: true }), true)
assert.equal(shouldCountCommits({ isShallow: false, hasMergeBase: false }), true)
})
// The skip path produces an empty countStr; resolveBehindCount must NOT trust
// it and must fall through to the SHA compare (mirrors the live call site).
test('skipped-count path resolves via SHA compare, never via empty countStr', () => {
assert.equal(resolveBehindCount({
countStr: '', currentSha: 'aaa', targetSha: 'bbb',
isShallow: true, hasMergeBase: false,
}), 1)
assert.equal(resolveBehindCount({
countStr: '', currentSha: 'same', targetSha: 'same',
isShallow: true, hasMergeBase: false,
}), 0)
})

View File

@@ -1,117 +0,0 @@
/**
* Pure geometry helpers for window-state.json — restoring the main window's
* size, position, and maximized flag across launches. Side-effect-free so the
* part that actually matters (rejecting garbage + off-screen bounds) is
* unit-testable without booting Electron; main.cjs owns the file I/O and the
* live `screen` displays.
*/
// Defaults mirror the historical hardcoded BrowserWindow size; MIN_* mirror its
// minWidth/minHeight so a restored size never undershoots what the live window
// allows. A fresh install (no saved state) is byte-identical to before.
const DEFAULT_WIDTH = 1220
const DEFAULT_HEIGHT = 800
const MIN_WIDTH = 400
const MIN_HEIGHT = 620
// Keep at least this much of the window over a display work area before we trust
// a saved position, so the title bar stays grabbable after a monitor unplugs.
const MIN_VISIBLE = 48
const finite = v => typeof v === 'number' && Number.isFinite(v)
const clamp = (v, lo, hi) => Math.max(lo, Math.min(v, hi))
// Parse raw JSON → clean state, or null if garbage. width/height are required
// and floored; x/y survive only as a finite pair; isMaximized is strict.
function sanitizeWindowState(raw) {
if (!raw || typeof raw !== 'object' || !finite(raw.width) || !finite(raw.height)) return null
const state = {
width: Math.max(MIN_WIDTH, Math.round(raw.width)),
height: Math.max(MIN_HEIGHT, Math.round(raw.height)),
isMaximized: raw.isMaximized === true
}
if (finite(raw.x) && finite(raw.y)) {
state.x = Math.round(raw.x)
state.y = Math.round(raw.y)
}
return state
}
// True when `bounds` overlaps some display's work area by ≥ MIN_VISIBLE on both
// axes. `displays` is Electron's screen.getAllDisplays() shape.
function onScreen(bounds, displays) {
if (!Array.isArray(displays)) return false
return displays.some(({ workArea: a } = {}) => {
if (!a) return false
const x = Math.min(bounds.x + bounds.width, a.x + a.width) - Math.max(bounds.x, a.x)
const y = Math.min(bounds.y + bounds.height, a.y + a.height) - Math.max(bounds.y, a.y)
return x >= MIN_VISIBLE && y >= MIN_VISIBLE
})
}
// Sanitized state (or null) → BrowserWindow size/position options. Always sets
// width/height, capped to the largest current display so a size saved on a
// since-disconnected bigger monitor can't exceed any screen the user now has.
// Sets x/y only when still on-screen; otherwise Electron centers the window.
function computeWindowOptions(state, displays) {
const opts = {
width: finite(state?.width) ? state.width : DEFAULT_WIDTH,
height: finite(state?.height) ? state.height : DEFAULT_HEIGHT
}
const cap = (Array.isArray(displays) ? displays : []).reduce(
(m, { workArea: a } = {}) =>
a && finite(a.width) && finite(a.height)
? { width: Math.max(m.width, a.width), height: Math.max(m.height, a.height) }
: m,
{ width: 0, height: 0 }
)
if (cap.width && cap.height) {
opts.width = clamp(opts.width, MIN_WIDTH, cap.width)
opts.height = clamp(opts.height, MIN_HEIGHT, cap.height)
}
if (
state &&
finite(state.x) &&
finite(state.y) &&
onScreen({ x: state.x, y: state.y, width: opts.width, height: opts.height }, displays)
) {
opts.x = state.x
opts.y = state.y
}
return opts
}
// Trailing debounce: collapse a burst of resize/move events (Linux fires many
// mid-drag) into a single run `delayMs` after the last. `.flush()` runs now and
// cancels the pending timer — used on close, before the window is gone.
function debounce(fn, delayMs) {
let timer = null
const debounced = () => {
clearTimeout(timer)
timer = setTimeout(() => {
timer = null
fn()
}, delayMs)
}
debounced.flush = () => {
clearTimeout(timer)
timer = null
fn()
}
return debounced
}
module.exports = {
DEFAULT_WIDTH,
DEFAULT_HEIGHT,
MIN_WIDTH,
MIN_HEIGHT,
MIN_VISIBLE,
sanitizeWindowState,
onScreen,
computeWindowOptions,
debounce
}

View File

@@ -1,135 +0,0 @@
/**
* Unit tests for the pure window-state geometry helpers. These cover the logic
* that protects the user: garbage rejection, off-screen fallback, oversized
* clamping, and the debounce that collapses mid-drag write storms.
*/
const test = require('node:test')
const assert = require('node:assert/strict')
const {
DEFAULT_WIDTH,
DEFAULT_HEIGHT,
MIN_WIDTH,
MIN_HEIGHT,
sanitizeWindowState,
onScreen,
computeWindowOptions,
debounce
} = require('./window-state.cjs')
// A single 1920×1080 monitor (work area trimmed for the taskbar).
const PRIMARY = [{ workArea: { x: 0, y: 0, width: 1920, height: 1040 } }]
// A laptop panel left behind after a bigger external monitor is unplugged.
const LAPTOP = [{ workArea: { x: 0, y: 0, width: 1366, height: 728 } }]
// ─── sanitizeWindowState ───────────────────────────────────────────────────
test('sanitizeWindowState rejects missing/garbage input', () => {
for (const bad of [null, undefined, 'nope', 42, {}, { width: 'x', height: 800 }, { width: NaN, height: 800 }, { width: 1000 }]) {
assert.equal(sanitizeWindowState(bad), null)
}
})
test('sanitizeWindowState keeps a valid full state and rounds HiDPI fractions', () => {
assert.deepEqual(sanitizeWindowState({ x: 100.6, y: 50.2, width: 1400.4, height: 900.7, isMaximized: true }), {
x: 101,
y: 50,
width: 1400,
height: 901,
isMaximized: true
})
})
test('sanitizeWindowState floors size to the minimums', () => {
const state = sanitizeWindowState({ width: 10, height: 10 })
assert.equal(state.width, MIN_WIDTH)
assert.equal(state.height, MIN_HEIGHT)
})
test('sanitizeWindowState drops a partial position but keeps the size', () => {
assert.deepEqual(sanitizeWindowState({ x: 100, width: 1400, height: 900 }), {
width: 1400,
height: 900,
isMaximized: false
})
})
test('sanitizeWindowState treats isMaximized strictly', () => {
assert.equal(sanitizeWindowState({ width: 1400, height: 900, isMaximized: 'yes' }).isMaximized, false)
})
// ─── onScreen ──────────────────────────────────────────────────────────────
test('onScreen accepts a window on the primary or a secondary display', () => {
const dual = [...PRIMARY, { workArea: { x: 1920, y: 0, width: 2560, height: 1400 } }]
assert.equal(onScreen({ x: 100, y: 100, width: 1220, height: 800 }, PRIMARY), true)
assert.equal(onScreen({ x: 2200, y: 200, width: 1220, height: 800 }, dual), true)
})
test('onScreen rejects off-screen, slivers, and bad input', () => {
assert.equal(onScreen({ x: 3000, y: 100, width: 1220, height: 800 }, PRIMARY), false) // past right edge
assert.equal(onScreen({ x: 100, y: -900, width: 1220, height: 800 }, PRIMARY), false) // above top
assert.equal(onScreen({ x: 1910, y: 100, width: 1220, height: 800 }, PRIMARY), false) // ~10px sliver
assert.equal(onScreen({ x: 0, y: 0, width: 1220, height: 800 }, []), false)
assert.equal(onScreen({ x: 0, y: 0, width: 1220, height: 800 }, null), false)
})
// ─── computeWindowOptions ──────────────────────────────────────────────────
test('computeWindowOptions falls back to defaults with no saved state', () => {
assert.deepEqual(computeWindowOptions(null, PRIMARY), { width: DEFAULT_WIDTH, height: DEFAULT_HEIGHT })
})
test('computeWindowOptions restores an on-screen position', () => {
const saved = sanitizeWindowState({ x: 200, y: 150, width: 1400, height: 900 })
assert.deepEqual(computeWindowOptions(saved, PRIMARY), { width: 1400, height: 900, x: 200, y: 150 })
})
test('computeWindowOptions keeps the size but drops an off-screen position', () => {
const saved = sanitizeWindowState({ x: 5000, y: 150, width: 1400, height: 900 })
assert.deepEqual(computeWindowOptions(saved, PRIMARY), { width: 1400, height: 900 })
})
test('computeWindowOptions clamps a size larger than the only display', () => {
const saved = sanitizeWindowState({ width: 2560, height: 1440 })
assert.deepEqual(computeWindowOptions(saved, LAPTOP), { width: 1366, height: 728 })
})
test('computeWindowOptions keeps the MIN floor on a sub-minimum display', () => {
const tiny = [{ workArea: { x: 0, y: 0, width: 360, height: 480 } }]
const saved = sanitizeWindowState({ width: 2000, height: 1500 })
assert.deepEqual(computeWindowOptions(saved, tiny), { width: MIN_WIDTH, height: MIN_HEIGHT })
})
test('computeWindowOptions does not clamp when displays are unknown', () => {
const saved = sanitizeWindowState({ width: 2560, height: 1440 })
assert.deepEqual(computeWindowOptions(saved, []), { width: 2560, height: 1440 })
})
// ─── debounce ──────────────────────────────────────────────────────────────
test('debounce coalesces a burst into one trailing run', t => {
t.mock.timers.enable({ apis: ['setTimeout'] })
let calls = 0
const d = debounce(() => { calls += 1 }, 250)
d(); d(); d()
assert.equal(calls, 0)
t.mock.timers.tick(249)
assert.equal(calls, 0)
t.mock.timers.tick(1)
assert.equal(calls, 1)
})
test('debounce.flush runs now and cancels the pending timer', t => {
t.mock.timers.enable({ apis: ['setTimeout'] })
let calls = 0
const d = debounce(() => { calls += 1 }, 250)
d()
d.flush()
assert.equal(calls, 1)
t.mock.timers.tick(1000)
assert.equal(calls, 1)
})

View File

@@ -12,8 +12,7 @@ function readElectronFile(name) {
}
function requireHiddenChildOptions(source, needle) {
const match = needle instanceof RegExp ? needle.exec(source) : null
const index = needle instanceof RegExp ? match?.index ?? -1 : source.indexOf(needle)
const index = source.indexOf(needle)
assert.notEqual(index, -1, `missing call site: ${needle}`)
const snippet = source.slice(index, index + 700)
assert.match(
@@ -29,28 +28,14 @@ test('desktop background child processes opt into hidden Windows consoles', () =
assert.match(source, /function hiddenWindowsChildOptions\(options = \{\}\)/)
requireHiddenChildOptions(source, "execFileSync(\n 'reg'")
requireHiddenChildOptions(source, /execFileSync\(\s*pyExe/)
requireHiddenChildOptions(source, /spawn\(\s*resolveGitBinary\(\)/)
requireHiddenChildOptions(source, 'execFileSync(pyExe')
requireHiddenChildOptions(source, 'spawn(resolveGitBinary()')
requireHiddenChildOptions(source, "execFileSync('taskkill'")
requireHiddenChildOptions(source, /spawn\(\s*command,\s*args/)
requireHiddenChildOptions(source, 'spawn(command, args')
requireHiddenChildOptions(source, "spawn('curl'")
requireHiddenChildOptions(source, /spawn\(\s*backend\.command,\s*backend\.args/)
requireHiddenChildOptions(source, /hermesProcess = spawn\(\s*backend\.command,\s*backend\.args/)
requireHiddenChildOptions(source, /spawn\(\s*py,\s*\['-m', 'hermes_cli\.main', 'uninstall', '--gui-summary'\]/)
assert.match(source, /function unwrapWindowsVenvHermesCommand\(command, dashboardArgs\)/)
assert.match(source, /existing Hermes no-console Python at/)
assert.match(source, /function getNoConsoleVenvPython\(venvRoot\)/)
assert.match(source, /function toNoConsolePython\(pythonPath\)/)
assert.match(source, /function applyWindowsNoConsoleSpawnHints\(backend\)/)
assert.match(source, /function readVenvHome\(venvRoot\)/)
assert.match(source, /path\.join\(venvRoot, 'Scripts', 'pythonw\.exe'\)/)
assert.match(source, /backendStartFailure/)
assert.match(source, /HERMES_DESKTOP_READY_FILE/)
assert.match(source, /readyFile: true/)
assert.match(source, /function getVenvSitePackagesEntries\(venvRoot\)/)
assert.match(source, /path\.join\(venvRoot, 'Lib', 'site-packages'\)/)
assert.match(source, /args: \['-m', 'hermes_cli\.main', \.\.\.dashboardArgs\]/)
requireHiddenChildOptions(source, 'spawn(backend.command, backend.args')
requireHiddenChildOptions(source, 'hermesProcess = spawn(backend.command, backend.args')
requireHiddenChildOptions(source, "spawn(py, ['-m', 'hermes_cli.main', 'uninstall', '--gui-summary']")
})
test('intentional or interactive desktop child processes stay documented', () => {

View File

@@ -18,7 +18,7 @@
"profile:main": "wait-on http://127.0.0.1:5174 && cross-env XCURSOR_SIZE=24 HERMES_DESKTOP_DEV_SERVER=http://127.0.0.1:5174 electron --inspect=9229 .",
"profile:main:cpu": "wait-on http://127.0.0.1:5174 && cross-env XCURSOR_SIZE=24 NODE_OPTIONS=--cpu-prof HERMES_DESKTOP_DEV_SERVER=http://127.0.0.1:5174 electron .",
"start": "npm run build && electron .",
"build": "node scripts/assert-root-install.cjs && node scripts/write-build-stamp.cjs && node scripts/stage-native-deps.cjs && tsc -b && vite build && node scripts/bundle-electron-main.mjs && npm run postbuild",
"build": "node scripts/assert-root-install.cjs && node scripts/write-build-stamp.cjs && node scripts/stage-native-deps.cjs && tsc -b && vite build && npm run postbuild",
"postbuild": "node scripts/assert-dist-built.cjs",
"prebuilder": "node scripts/patch-electron-builder-mac-binary.cjs",
"builder": "cross-env NODE_OPTIONS=--max-old-space-size=16384 node scripts/run-electron-builder.cjs",
@@ -37,7 +37,7 @@
"test:desktop:nsis": "node scripts/test-desktop.mjs nsis",
"test:desktop:existing": "node scripts/test-desktop.mjs existing",
"test:desktop:fresh": "node scripts/test-desktop.mjs fresh",
"test:desktop:platforms": "node --test electron/bootstrap-platform.test.cjs electron/hardening.test.cjs electron/backend-env.test.cjs electron/backend-probes.test.cjs electron/backend-ready.test.cjs electron/bootstrap-runner.test.cjs electron/connection-config.test.cjs electron/dashboard-token.test.cjs electron/gateway-ws-probe.test.cjs electron/oauth-net-request.test.cjs electron/desktop-uninstall.test.cjs electron/session-windows.test.cjs electron/link-title-window.test.cjs electron/workspace-cwd.test.cjs electron/fs-read-dir.test.cjs electron/git-root.test.cjs electron/git-worktree-ops.test.cjs electron/windows-child-process.test.cjs electron/update-remote.test.cjs electron/update-count.test.cjs electron/update-rebuild.test.cjs electron/update-marker.test.cjs electron/update-relaunch.test.cjs electron/windows-user-env.test.cjs electron/window-state.test.cjs",
"test:desktop:platforms": "node --test electron/bootstrap-platform.test.cjs electron/hardening.test.cjs electron/backend-env.test.cjs electron/backend-probes.test.cjs electron/backend-ready.test.cjs electron/bootstrap-runner.test.cjs electron/connection-config.test.cjs electron/dashboard-token.test.cjs electron/gateway-ws-probe.test.cjs electron/oauth-net-request.test.cjs electron/desktop-uninstall.test.cjs electron/session-windows.test.cjs electron/link-title-window.test.cjs electron/workspace-cwd.test.cjs electron/fs-read-dir.test.cjs electron/git-root.test.cjs electron/windows-child-process.test.cjs electron/update-remote.test.cjs electron/update-rebuild.test.cjs electron/update-marker.test.cjs electron/update-relaunch.test.cjs electron/windows-user-env.test.cjs",
"typecheck": "tsc -p . --noEmit",
"lint": "eslint src/ electron/",
"lint:fix": "eslint src/ electron/ --fix",
@@ -93,7 +93,6 @@
"remark-math": "^6.0.0",
"remend": "^1.3.0",
"shiki": "^4.0.2",
"simple-git": "^3.36.0",
"streamdown": "^2.5.0",
"tailwind-merge": "^3.5.0",
"tailwindcss": "^4.2.4",

View File

@@ -1,33 +0,0 @@
#!/usr/bin/env node
// bundle-electron-main.mjs — bundles electron/main.cjs into a single
// self-contained file so the nix build doesn't need to ship node_modules/.
//
// `electron` is provided by the runtime; `node-pty` is staged separately
// via stage-native-deps.cjs. `preload.cjs` is NOT require()'d by main —
// Electron loads it via path.join(__dirname, 'preload.cjs') — so it stays
// as a separate file and doesn't need bundling.
import { build } from 'esbuild'
import { resolve, dirname } from 'node:path'
import { fileURLToPath } from 'node:url'
import { renameSync } from 'node:fs'
const here = dirname(fileURLToPath(import.meta.url))
const root = resolve(here, '..')
const entry = resolve(root, 'electron/main.cjs')
const tmp = resolve(root, 'electron/main.bundled.cjs')
await build({
entryPoints: [entry],
bundle: true,
platform: 'node',
format: 'cjs',
target: 'node20',
outfile: tmp,
external: ['electron', 'node-pty'],
logLevel: 'info'
})
// Overwrite the original with the bundled version.
renameSync(tmp, entry)
console.log(`bundled ${entry}`)

View File

@@ -4,15 +4,14 @@ import { type ReactNode, useEffect, useMemo, useState } from 'react'
import { useElapsedSeconds } from '@/components/chat/activity-timer'
import { ActivityTimerText } from '@/components/chat/activity-timer-text'
import { FadeText } from '@/components/ui/fade-text'
import { Codicon } from '@/components/ui/codicon'
import { GlyphSpinner } from '@/components/ui/glyph-spinner'
import { type Translations, useI18n } from '@/i18n'
import { AlertCircle, CheckCircle2 } from '@/lib/icons'
import { AlertCircle, CheckCircle2, Sparkles } from '@/lib/icons'
import { useEnterAnimation } from '@/lib/use-enter-animation'
import { cn } from '@/lib/utils'
import { $activeSessionId } from '@/store/session'
import {
$subagentsBySession,
allSubagents,
buildSubagentTree,
type SubagentNode,
type SubagentStatus,
@@ -78,12 +77,15 @@ interface AgentsViewProps {
export function AgentsView({ onClose }: AgentsViewProps) {
const { t } = useI18n()
const activeSessionId = useStore($activeSessionId)
const subagentsBySession = useStore($subagentsBySession)
// Aggregate every session, matching the status-bar indicator — a subagent
// running in a background session must still be visible here, or the two
// desync ("Agents N running" vs an empty tree).
const tree = useMemo(() => buildSubagentTree(allSubagents(subagentsBySession)), [subagentsBySession])
const activeSubagents = useMemo(
() => (activeSessionId ? (subagentsBySession[activeSessionId] ?? []) : []),
[activeSessionId, subagentsBySession]
)
const tree = useMemo(() => buildSubagentTree(activeSubagents), [activeSubagents])
return (
<OverlayView
@@ -210,7 +212,7 @@ function SubagentTree({ tree }: { tree: SubagentNode[] }) {
if (tree.length === 0) {
return (
<div className="grid place-items-center gap-3 py-12 text-center">
<Codicon className="text-muted-foreground/60" name="hubot" size="1.5rem" />
<Sparkles className="size-6 text-muted-foreground/60" />
<p className="text-sm font-medium text-foreground/90">{t.agents.emptyTitle}</p>
<p className="max-w-md text-xs leading-relaxed text-muted-foreground/75">{t.agents.emptyDesc}</p>
</div>

View File

@@ -1,106 +0,0 @@
// @vitest-environment jsdom
import { act, cleanup, render } from '@testing-library/react'
import { useCallback, useRef } from 'react'
import { afterEach, describe, expect, it, vi } from 'vitest'
afterEach(cleanup)
// Regression repro for #49903: on desktop v0.17.0 the composer threw an
// uncaught `Error: Composer is not available` at startup and the input went
// unresponsive. The throw comes from @assistant-ui/core's composer-runtime —
// every *mutator* (setText/send/…) does `if (!core) throw new Error("Composer
// is not available")` when the thread's composer core isn't bound yet. Unlike
// the read path (`s.composer.text`, which is null-safe: `runtime?.text ?? ""`),
// the mutators have no graceful fallback. ChatBar's mount-time effects (draft
// restore, clearDraft, external inserts) push text via `aui.composer().setText`
// before the core binds, and the popout refactor (#49488) widened that window,
// so the throw surfaced as an uncaught error that wedged the input.
//
// The fix wraps every `aui.composer().setText` call in a `setComposerText`
// helper that swallows the unbound-core throw — the contentEditable DOM +
// draftRef already hold the text and the draft⇄editor sync re-applies it once
// the core attaches, so nothing is lost. This Harness mirrors that helper
// faithfully (same try/catch shape) over a fake `aui` whose composer can be
// toggled bound/unbound, the way the assistant-ui runtime behaves across mount.
interface FakeComposer {
setText: (value: string) => void
}
// Mirror of index.tsx's `useAui()` composer surface: composer() returns a
// runtime whose setText throws exactly like @assistant-ui/core when unbound.
function makeFakeAui(bound: { current: boolean }, applied: string[]) {
const composer: FakeComposer = {
setText(value: string) {
if (!bound.current) {
throw new Error('Composer is not available')
}
applied.push(value)
}
}
return { composer: () => composer }
}
function Harness({
bound,
applied,
onError
}: {
applied: string[]
bound: { current: boolean }
onError: (err: unknown) => void
}) {
const aui = useRef(makeFakeAui(bound, applied)).current
// Verbatim mirror of the production `setComposerText` helper in index.tsx.
const setComposerText = useCallback(
(value: string) => {
try {
aui.composer().setText(value)
} catch {
// Composer core not bound yet — swallow so the input stays usable.
}
},
[aui]
)
// A draft-restore-on-mount that fires while the core may still be unbound,
// exactly like loadIntoComposer/clearDraft do on startup.
try {
setComposerText('restored draft')
} catch (err) {
onError(err)
}
return null
}
describe('setComposerText guard (#49903)', () => {
it('swallows the unbound-core throw at startup instead of crashing the renderer', () => {
const applied: string[] = []
const bound = { current: false }
const onError = vi.fn()
expect(() => render(<Harness applied={applied} bound={bound} onError={onError} />)).not.toThrow()
// The guard absorbed the throw — nothing escaped to the renderer, and no
// assistant-ui write landed (core was unbound).
expect(onError).not.toHaveBeenCalled()
expect(applied).toEqual([])
})
it('writes through to the composer once the core is bound', () => {
const applied: string[] = []
const bound = { current: true }
const onError = vi.fn()
act(() => {
render(<Harness applied={applied} bound={bound} onError={onError} />)
})
expect(onError).not.toHaveBeenCalled()
expect(applied).toEqual(['restored draft'])
})
})

View File

@@ -13,7 +13,6 @@ import {
DropdownMenuTrigger
} from '@/components/ui/dropdown-menu'
import { Kbd } from '@/components/ui/kbd'
import { Tip } from '@/components/ui/tooltip'
import { useI18n } from '@/i18n'
import { Clipboard, FileText, FolderOpen, type IconComponent, ImageIcon, Link, MessageSquareText } from '@/lib/icons'
import { cn } from '@/lib/utils'
@@ -43,23 +42,22 @@ export function ContextMenu({
return (
<>
<DropdownMenu>
<Tip label={state.tools.label} side="top">
<DropdownMenuTrigger asChild>
<Button
aria-label={state.tools.label}
className={cn(
GHOST_ICON_BTN,
'data-[state=open]:bg-(--chrome-action-hover) data-[state=open]:text-foreground'
)}
disabled={!state.tools.enabled}
size="icon"
type="button"
variant="ghost"
>
<Codicon name="add" size="0.875rem" />
</Button>
</DropdownMenuTrigger>
</Tip>
<DropdownMenuTrigger asChild>
<Button
aria-label={state.tools.label}
className={cn(
GHOST_ICON_BTN,
'data-[state=open]:bg-(--chrome-action-hover) data-[state=open]:text-foreground'
)}
disabled={!state.tools.enabled}
size="icon"
title={state.tools.label}
type="button"
variant="ghost"
>
<Codicon name="add" size="0.875rem" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="start" className={cn('w-60', composerPanelCard)} side="top" sideOffset={6}>
<DropdownMenuLabel className="px-2 pb-0.5 pt-0.5 text-[0.625rem] font-semibold uppercase tracking-wider text-(--ui-text-tertiary)">
{c.attachLabel}

View File

@@ -10,8 +10,8 @@
* steal focus from the composer effect.
*/
import type { InlineRefInput } from './inline-refs'
import { RICH_INPUT_SLOT } from './rich-editor'
import type { InlineRefInput } from './inline-refs'
export type ComposerTarget = 'edit' | 'main'
export type ComposerInsertMode = 'block' | 'inline'
@@ -34,13 +34,6 @@ interface InsertRefsDetail {
const FOCUS_EVENT = 'hermes:composer-focus'
const INSERT_EVENT = 'hermes:composer-insert'
const INSERT_REFS_EVENT = 'hermes:composer-insert-refs'
const SUBMIT_EVENT = 'hermes:composer-submit'
const VOICE_TOGGLE_EVENT = 'hermes:composer-voice-toggle'
interface SubmitDetail {
target: ComposerTarget
text: string
}
let activeTarget: ComposerTarget = 'main'
@@ -112,30 +105,6 @@ export const requestComposerInsertRefs = (
export const onComposerInsertRefsRequest = (handler: (detail: InsertRefsDetail) => void) =>
subscribe<InsertRefsDetail>(INSERT_REFS_EVENT, handler)
/** Submit a prompt through a composer as if the user typed + sent it. Lets
* external panels (e.g. the review pane's "let the agent ship it" button) hand
* the agent a task without the user round-tripping through the input. */
export const requestComposerSubmit = (
text: string,
{ target = 'active' }: { target?: ComposerTarget | 'active' } = {}
) => {
const trimmed = text.trim()
if (trimmed) {
dispatch<SubmitDetail>(SUBMIT_EVENT, { target: resolve(target), text: trimmed })
}
}
export const onComposerSubmitRequest = (handler: (detail: SubmitDetail) => void) =>
subscribe<SubmitDetail>(SUBMIT_EVENT, handler)
/** Toggle the active composer's voice conversation — the `composer.voice`
* hotkey (Ctrl+B) reaching into the composer that owns the voice state. */
export const requestVoiceToggle = () => dispatch<{ at: number }>(VOICE_TOGGLE_EVENT, { at: Date.now() })
export const onComposerVoiceToggleRequest = (handler: () => void) =>
subscribe<{ at: number }>(VOICE_TOGGLE_EVENT, () => handler())
/**
* Focus a composer input across React commit + browser focus restore.
*

View File

@@ -45,8 +45,8 @@ import {
$composerPoppedOut,
POPOUT_WIDTH_REM,
readPopoutBounds,
setComposerPopoutPosition,
setComposerPoppedOut
setComposerPoppedOut,
setComposerPopoutPosition
} from '@/store/composer-popout'
import {
$queuedPromptsBySession,
@@ -60,10 +60,8 @@ import {
updateQueuedPrompt
} from '@/store/composer-queue'
import { $statusItemsBySession } from '@/store/composer-status'
import { notify } from '@/store/notifications'
import { $previewStatusBySession } from '@/store/preview-status'
import { listRepoBranches, requestStartWorkSession, startWorkInRepo, switchBranchInRepo } from '@/store/projects'
import { toggleReview } from '@/store/review'
import { notify } from '@/store/notifications'
import { $gatewayState, $messages, setSessionPickerOpen } from '@/store/session'
import { $threadScrolledUp } from '@/store/thread-scroll'
import { isSecondaryWindow } from '@/store/windows'
@@ -81,9 +79,7 @@ import {
markActiveComposer,
onComposerFocusRequest,
onComposerInsertRefsRequest,
onComposerInsertRequest,
onComposerSubmitRequest,
onComposerVoiceToggleRequest
onComposerInsertRequest
} from './focus'
import { HelpHint } from './help-hint'
import { useAtCompletions } from './hooks/use-at-completions'
@@ -111,7 +107,6 @@ import {
slashChipElement
} from './rich-editor'
import { ComposerStatusStack } from './status-stack'
import { CodingStatusRow } from './status-stack/coding-row'
import { detectTrigger, extractClipboardImageBlobs, textBeforeCaret, type TriggerState } from './text-utils'
import { ComposerTriggerPopover } from './trigger-popover'
import type { ChatBarProps } from './types'
@@ -198,32 +193,6 @@ export function ChatBar({
}: ChatBarProps) {
const aui = useAui()
const draft = useAuiState(s => s.composer.text)
// assistant-ui's composer *mutators* (setText/send/…) throw "Composer is not
// available" when the thread's composer core isn't bound yet — and unlike the
// read path (`s.composer.text`, which is null-safe), there's no graceful
// fallback. There's a startup/thread-swap window where this ChatBar's mount
// effects (draft restore, clearDraft, external inserts) run before the core
// binds; the popout refactor (#49488) widened it by moving the composer out
// of the contain wrapper into a sibling of the thread, so the throw began
// surfacing as an uncaught error that wedged the desktop input (#49903).
//
// Guard every mutation: if the core isn't ready, no-op the assistant-ui write.
// The contentEditable DOM + draftRef already hold the text, and the
// draft⇄editor sync reconciles composer state once the core attaches, so the
// draft is never lost — only the (premature) state push is skipped.
const setComposerText = useCallback(
(value: string) => {
try {
aui.composer().setText(value)
} catch {
// Composer core not bound yet — DOM/draftRef carry the text; the sync
// effect re-applies it after bind. Swallow so the input stays usable.
}
},
[aui]
)
const attachments = useStore($composerAttachments)
const queuedPromptsBySession = useStore($queuedPromptsBySession)
const statusItemsBySession = useStore($statusItemsBySession)
@@ -401,7 +370,7 @@ export function ChatBar({
const next = `${base}${sep}${value}`
draftRef.current = next
setComposerText(next)
aui.composer().setText(next)
const editor = editorRef.current
@@ -412,7 +381,7 @@ export function ChatBar({
setFocusRequestId(id => id + 1)
},
[setComposerText]
[aui]
)
useEffect(() => {
@@ -622,7 +591,7 @@ export function ChatBar({
const nextDraft = `${currentDraft}${sep}${text}`
draftRef.current = nextDraft
setComposerText(nextDraft)
aui.composer().setText(nextDraft)
// Push the new text into the contentEditable editor directly. Setting the
// assistant-ui composer state alone is not enough: the draft→editor sync
@@ -655,7 +624,7 @@ export function ChatBar({
}
draftRef.current = nextDraft
setComposerText(nextDraft)
aui.composer().setText(nextDraft)
requestMainFocus()
return true
@@ -741,7 +710,7 @@ export function ChatBar({
if (nextDraft !== draftRef.current) {
draftRef.current = nextDraft
setComposerText(nextDraft)
aui.composer().setText(nextDraft)
}
window.setTimeout(refreshTrigger, 0)
@@ -867,7 +836,7 @@ export function ChatBar({
renderComposerContents(editor, prefix)
placeCaretEnd(editor)
draftRef.current = composerPlainText(editor)
setComposerText(draftRef.current)
aui.composer().setText(draftRef.current)
closeTrigger()
runAction()
requestMainFocus()
@@ -895,7 +864,7 @@ export function ChatBar({
const finish = () => {
draftRef.current = composerPlainText(editor)
setComposerText(draftRef.current)
aui.composer().setText(draftRef.current)
requestMainFocus()
keepTriggerOpen ? window.setTimeout(refreshTrigger, 0) : closeTrigger()
}
@@ -1347,91 +1316,17 @@ export function ChatBar({
}
const clearDraft = useCallback(() => {
setComposerText('')
aui.composer().setText('')
draftRef.current = ''
if (editorRef.current) {
editorRef.current.replaceChildren()
}
}, [setComposerText])
// Hand a worktree off to the controller: open a fresh session anchored there,
// carrying the composer draft as its first turn. Clearing here means the draft
// travels to the new session instead of getting stashed under this one.
const openInWorktree = useCallback(
(path: string) => {
const text = draftRef.current
clearDraft()
clearComposerAttachments()
requestStartWorkSession(path, text)
},
[clearDraft]
)
// Branch off into a NEW worktree (base = branch name, or current HEAD). A
// create failure throws back to the row (which toasts) before we touch the
// draft; a missing cwd / remote backend no-ops (the row hides the affordance).
const handleBranchOff = useCallback(
async (branch: string, base?: string) => {
const repoPath = cwd?.trim()
const result = repoPath && (await startWorkInRepo(repoPath, { base, branch, name: branch }))
if (result) {
openInWorktree(result.path)
}
},
[cwd, openInWorktree]
)
// Convert an EXISTING branch into a fresh worktree + session (no new branch).
// Mirrors handleBranchOff's hand-off: create the worktree, then open a session
// anchored there carrying the draft.
const handleConvertBranch = useCallback(
async (branch: string, path?: null | string, isDefault?: boolean) => {
if (path?.trim()) {
openInWorktree(path)
return
}
const repoPath = cwd?.trim()
if (repoPath && isDefault) {
await switchBranchInRepo(repoPath, branch)
openInWorktree(repoPath)
return
}
const result = repoPath && (await startWorkInRepo(repoPath, { existingBranch: branch }))
if (result) {
openInWorktree(result.path)
}
},
[cwd, openInWorktree]
)
const handleListBranches = useCallback(async () => {
const repoPath = cwd?.trim()
return repoPath ? listRepoBranches(repoPath) : []
}, [cwd])
const handleSwitchBranch = useCallback(
async (branch: string) => {
const repoPath = cwd?.trim()
if (repoPath) {
await switchBranchInRepo(repoPath, branch)
}
},
[cwd]
)
}, [aui])
const loadIntoComposer = (text: string, attachments: ComposerAttachment[]) => {
draftRef.current = text
setComposerText(text)
aui.composer().setText(text)
$composerAttachments.set(cloneAttachments(attachments))
const editor = editorRef.current
@@ -1752,41 +1647,6 @@ export function ChatBar({
}
}, [autoDrainNext, busy, queuedPrompts.length])
// Esc cancels the in-flight turn when the CHAT has focus — not just the
// composer input (which has its own handler above). Clicking into the
// transcript and hitting Esc now stops the run, matching the Stop button.
// Intentional only: we bail if (a) the composer/another field already
// handled Esc (defaultPrevented), (b) focus is in any input/textarea/
// contenteditable (you're typing, not stopping), or (c) a dialog/popover is
// open — Esc must close that overlay, never double as canceling the stream
// behind it. A latest-handler ref keeps the listener registered once.
const escCancelRef = useRef<(event: globalThis.KeyboardEvent) => void>(() => {})
escCancelRef.current = (event: globalThis.KeyboardEvent) => {
if (event.key !== 'Escape' || event.defaultPrevented || !busy) {
return
}
const active = document.activeElement as HTMLElement | null
if (active && (active.tagName === 'INPUT' || active.tagName === 'TEXTAREA' || active.isContentEditable)) {
return
}
if (document.querySelector('[role="dialog"],[role="alertdialog"],[data-radix-popper-content-wrapper]')) {
return
}
event.preventDefault()
triggerHaptic('cancel')
void Promise.resolve(onCancel())
}
useEffect(() => {
const onKeyDown = (event: globalThis.KeyboardEvent) => escCancelRef.current(event)
window.addEventListener('keydown', onKeyDown)
return () => window.removeEventListener('keydown', onKeyDown)
}, [])
// Queue-edit cleanup: on session swap the scope effect already stashed the
// edit snapshot; only restore into the composer when still on the same scope.
useEffect(() => {
@@ -1819,22 +1679,6 @@ export function ChatBar({
.catch(restore)
}
// External "submit this prompt" requests (e.g. the review pane's agent-ship
// button) route through the same send path. A ref keeps the listener stable
// while always calling the latest dispatchSubmit closure.
const dispatchSubmitRef = useRef(dispatchSubmit)
dispatchSubmitRef.current = dispatchSubmit
useEffect(
() =>
onComposerSubmitRequest(({ target, text }) => {
if (target === 'main' && !inputDisabled) {
dispatchSubmitRef.current(text)
}
}),
[inputDisabled]
)
const submitDraft = () => {
if (disabled) {
return
@@ -1855,7 +1699,7 @@ export function ChatBar({
if (domText !== draftRef.current) {
draftRef.current = domText
setComposerText(domText)
aui.composer().setText(domText)
}
}
@@ -1974,24 +1818,6 @@ export function ChatBar({
pendingResponse
})
// The `composer.voice` hotkey (Ctrl+B) toggles the conversation. Starting
// with STT unconfigured lets the conversation surface its own "configure
// speech-to-text" notice rather than silently no-opping.
const toggleVoiceConversation = useCallback(() => {
if (disabled) {
return
}
if (voiceConversationActive) {
setVoiceConversationActive(false)
void conversation.end()
} else {
setVoiceConversationActive(true)
}
}, [conversation, disabled, voiceConversationActive])
useEffect(() => onComposerVoiceToggleRequest(toggleVoiceConversation), [toggleVoiceConversation])
const contextMenu = (
<ContextMenu
onInsertText={insertText}
@@ -2228,7 +2054,7 @@ export function ChatBar({
<div className="relative w-full rounded-[inherit]">
<div
className={cn(
'group/composer-surface relative z-4 isolate grid grid-rows-[auto_1fr] overflow-hidden rounded-[inherit] border border-[color-mix(in_srgb,var(--dt-composer-ring)_calc(18%*var(--composer-ring-strength)),var(--dt-input))]',
'group/composer-surface relative z-4 isolate rounded-[inherit] border border-[color-mix(in_srgb,var(--dt-composer-ring)_calc(18%*var(--composer-ring-strength)),var(--dt-input))] transition-[border-color] duration-200 ease-out focus-within:border-[color-mix(in_srgb,var(--dt-composer-ring)_calc(45%*var(--composer-ring-strength)),transparent)]',
COMPOSER_DROP_FADE_CLASS,
dragActive && COMPOSER_DROP_ACTIVE_CLASS
)}
@@ -2243,14 +2069,6 @@ export function ChatBar({
composerSurfaceGlass
)}
/>
<CodingStatusRow
onBranchOff={handleBranchOff}
onConvertBranch={handleConvertBranch}
onListBranches={handleListBranches}
onOpen={toggleReview}
onOpenWorktree={openInWorktree}
onSwitchBranch={handleSwitchBranch}
/>
<div
className={cn(
'relative z-1 flex min-h-0 w-full flex-col gap-(--composer-row-gap) overflow-hidden rounded-[inherit] px-(--composer-surface-pad-x) py-(--composer-surface-pad-y) transition-opacity duration-200 ease-out',

View File

@@ -5,7 +5,6 @@ import { ModelMenuCloseContext } from '@/app/shell/model-menu-panel'
import { Button } from '@/components/ui/button'
import { DropdownMenu, DropdownMenuContent, DropdownMenuTrigger } from '@/components/ui/dropdown-menu'
import { GlyphSpinner } from '@/components/ui/glyph-spinner'
import { Tip } from '@/components/ui/tooltip'
import { useI18n } from '@/i18n'
import { ChevronDown } from '@/lib/icons'
import { formatModelStatusLabel } from '@/lib/model-status-label'
@@ -75,36 +74,34 @@ export function ModelPill({
if (!model.modelMenuContent) {
return (
<Tip label={copy.openModelPicker} side="top">
<Button
aria-label={copy.openModelPicker}
className={pillClass}
disabled={disabled}
onClick={() => setModelPickerOpen(true)}
type="button"
variant="ghost"
>
{label}
</Button>
</Tip>
<Button
aria-label={copy.openModelPicker}
className={pillClass}
disabled={disabled}
onClick={() => setModelPickerOpen(true)}
title={copy.openModelPicker}
type="button"
variant="ghost"
>
{label}
</Button>
)
}
return (
<DropdownMenu onOpenChange={setOpen} open={open}>
<Tip label={title} side="top">
<DropdownMenuTrigger asChild>
<Button
aria-label={title}
className={pillClass}
disabled={disabled}
type="button"
variant="ghost"
>
{label}
</Button>
</DropdownMenuTrigger>
</Tip>
<DropdownMenuTrigger asChild>
<Button
aria-label={title}
className={pillClass}
disabled={disabled}
title={title}
type="button"
variant="ghost"
>
{label}
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-64 p-0" side="top" sideOffset={8}>
<ModelMenuCloseContext.Provider value={() => setOpen(false)}>
{model.modelMenuContent}

View File

@@ -1,475 +0,0 @@
import { useStore } from '@nanostores/react'
import { memo, useCallback, useEffect, useRef, useState } from 'react'
import { StatusRow } from '@/components/chat/status-row'
import { Button } from '@/components/ui/button'
import { Codicon } from '@/components/ui/codicon'
import {
Command,
CommandEmpty,
CommandGroup,
CommandInput,
CommandItem,
CommandList
} from '@/components/ui/command'
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle
} from '@/components/ui/dialog'
import { DiffCount } from '@/components/ui/diff-count'
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuTrigger
} from '@/components/ui/dropdown-menu'
import { SanitizedInput } from '@/components/ui/sanitized-input'
import type { HermesGitBranch } from '@/global'
import { useI18n } from '@/i18n'
import { gitRef } from '@/lib/sanitize'
import { $repoStatus, $repoWorktrees } from '@/store/coding-status'
import { notifyError } from '@/store/notifications'
import { $newWorktreeRequest } from '@/store/projects'
// Tiny uppercase section header, matching the composer "+" menu's labels.
const MENU_SECTION = 'text-[0.625rem] font-semibold uppercase tracking-wider text-(--ui-text-tertiary)'
interface BranchActionCopy {
branchCreateWorktree: string
branchOpenExisting: string
branchSwitchHome: string
}
const branchActionLabel = (branch: HermesGitBranch, copy: BranchActionCopy) => {
if (branch.checkedOut) {
return copy.branchOpenExisting
}
return branch.isDefault ? copy.branchSwitchHome : copy.branchCreateWorktree
}
interface CodingStatusRowProps {
/** Branch the current draft off into a fresh worktree + session, based on
* `base` (a branch name; omitted = current HEAD). The composer owns the
* draft, so it supplies the orchestration; the row just collects the new
* branch name + base. Omitted (e.g. remote backend) hides the affordance. */
onBranchOff?: (branch: string, base?: string) => Promise<void>
/** Check an existing branch out into a fresh worktree + session (no new
* branch). Drives the dialog's "convert a branch" picker. */
onConvertBranch?: (branch: string, path?: null | string, isDefault?: boolean) => Promise<void>
/** List the repo's local branches for the "convert a branch" picker. */
onListBranches?: () => Promise<HermesGitBranch[]>
/** Open the review pane (changed files + diffs). */
onOpen?: () => void
/** Jump into an existing worktree (open a fresh session anchored there). */
onOpenWorktree?: (path: string) => void
/** Switch the current repo checkout to another branch. */
onSwitchBranch?: (branch: string) => Promise<void>
}
/**
* The always-on coding-context row, the BASE of the composer status stack:
* current branch, dirty summary (+/-), and ahead/behind. A touch more prominent
* than the per-turn rows above it (larger branch label, accent glyph), and the
* entry point to the review pane. Hidden when the active session isn't in a
* local git repo (the probe returns null).
*/
export const CodingStatusRow = memo(function CodingStatusRow({
onBranchOff,
onConvertBranch,
onListBranches,
onOpen,
onOpenWorktree,
onSwitchBranch
}: CodingStatusRowProps) {
const { t } = useI18n()
const s = t.statusStack.coding
const p = t.sidebar.projects
const status = useStore($repoStatus)
const worktrees = useStore($repoWorktrees)
const [branchOpen, setBranchOpen] = useState(false)
const [branchName, setBranchName] = useState('')
const [branchBase, setBranchBase] = useState<string | undefined>(undefined)
const [branchPending, setBranchPending] = useState(false)
const [convertMode, setConvertMode] = useState(false)
const [branches, setBranches] = useState<HermesGitBranch[]>([])
const [branchesLoading, setBranchesLoading] = useState(false)
const loadBranches = useCallback(async () => {
if (!onListBranches) {
return
}
setBranchesLoading(true)
try {
setBranches(await onListBranches())
} catch {
setBranches([])
} finally {
setBranchesLoading(false)
}
}, [onListBranches])
// Open the name dialog for a chosen base. Deferred so the dropdown finishes
// closing before the dialog grabs focus (Radix focus-trap handoff races
// otherwise).
const startBranch = (base: string | undefined) => {
setBranchBase(base)
setBranchName('')
setConvertMode(false)
setTimeout(() => setBranchOpen(true), 0)
}
const startConvert = () => {
setBranchBase(undefined)
setBranchName('')
setConvertMode(true)
void loadBranches()
setTimeout(() => setBranchOpen(true), 0)
}
const enterConvert = () => {
setConvertMode(true)
void loadBranches()
}
const convertBranch = async (branch: HermesGitBranch) => {
if (branchPending || !branch || !onConvertBranch) {
return
}
setBranchPending(true)
try {
await onConvertBranch(branch.name, branch.worktreePath, branch.isDefault)
setBranchOpen(false)
} catch (err) {
notifyError(err, p.startWorkFailed)
} finally {
setBranchPending(false)
}
}
// Global ⌘⇧B (workspace.newWorktree): open the name dialog for a worktree off
// current HEAD. The rail only renders inside a repo, so the hotkey naturally
// no-ops elsewhere. Guarded by a token ref so it fires on the keypress, not on
// mount or unrelated re-renders.
const worktreeReq = useStore($newWorktreeRequest)
const lastWorktreeReqRef = useRef(worktreeReq)
useEffect(() => {
if (worktreeReq === lastWorktreeReqRef.current) {
return
}
lastWorktreeReqRef.current = worktreeReq
if (!onBranchOff) {
return
}
setBranchBase(undefined)
setBranchName('')
setConvertMode(false)
setBranchOpen(true)
}, [onBranchOff, worktreeReq])
const submitBranch = async () => {
const branch = branchName.trim()
if (branchPending || !branch || !onBranchOff) {
return
}
setBranchPending(true)
try {
await onBranchOff(branch, branchBase)
setBranchOpen(false)
setBranchName('')
} catch (err) {
notifyError(err, p.startWorkFailed)
} finally {
setBranchPending(false)
}
}
const switchToBranch = async (branch: string) => {
if (!onSwitchBranch) {
return
}
try {
await onSwitchBranch(branch)
} catch (err) {
notifyError(err, s.switchFailed(branch))
}
}
if (!status) {
return null
}
const branchLabel = status.detached ? s.detached : status.branch || s.noBranch
// The kebab offers branching off the trunk and/or the current branch. The
// worktree-add bases the new branch on `base` (a branch name; undefined =
// current HEAD). We dedupe so "on main" shows a single trunk entry, and fall
// back to a plain off-HEAD branch when no trunk is detected.
const current = status.detached ? null : status.branch
const branchTargets: { base: string | undefined; label: string }[] = []
// Current branch first (the 99% "branch off where I am"), then the trunk just
// below it ("New branch from main"), deduped when they're the same.
if (current) {
branchTargets.push({ base: current, label: s.branchOffFrom(current) })
}
if (status.defaultBranch && status.defaultBranch !== current) {
branchTargets.push({ base: status.defaultBranch, label: s.branchOffFrom(status.defaultBranch) })
}
if (branchTargets.length === 0) {
branchTargets.push({ base: undefined, label: s.newBranch })
}
const switchTarget = onSwitchBranch && current && status.defaultBranch && status.defaultBranch !== current ? status.defaultBranch : null
// Other worktrees to jump into — everything except the one we're already in
// (matched by its checked-out branch) and the bare/main placeholder entry.
const otherWorktrees = onOpenWorktree
? worktrees.filter(w => w.path && !w.detached && w.branch && w.branch !== current)
: []
const hasLineDelta = status.added > 0 || status.removed > 0
// Untracked files carry no line delta vs HEAD, so surface them as a count when
// they're the only change (otherwise +/- tells the story).
const untrackedOnly = !hasLineDelta && status.untracked > 0
return (
<>
<StatusRow
// The base "where am I working" strip is part of the composer surface
// itself, so it inherits the composer's width and clipped top radius.
className="coding-status-bar min-h-7 rounded-t-[inherit] rounded-b-none border-b border-(--ui-stroke-tertiary) px-3.5 py-1.5 hover:bg-transparent"
// Static branch glyph — never the loading spinner. This row only renders
// once `status` exists, so a spinner here only ever fired on *refreshes*
// of an already-loaded repo (window focus, turn settle), reading as an
// annoying icon "blip" with no first-load value. Refreshes are silent.
leading={<Codicon className="text-(--ui-green)" name="git-branch" size="0.8rem" />}
onActivate={onOpen}
>
<div className="flex min-w-0 flex-1 items-center gap-1">
<span
className="min-w-0 truncate text-xs font-normal text-muted-foreground/92 transition-colors group-hover/status-row:text-foreground/90"
title={branchLabel}
>
{branchLabel}
</span>
{/* Branch actions kebab — same pattern as the session/worktree rows.
ALWAYS laid out; only its opacity flips on hover/focus/open, so
revealing it never reflows the row (no layout shift). pointer-events
follow opacity so the invisible trigger isn't clickable at rest. */}
{onBranchOff && (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
aria-label={s.newBranch}
className="pointer-events-none size-4 shrink-0 text-muted-foreground/60 opacity-0 transition hover:text-foreground group-hover/status-row:pointer-events-auto group-hover/status-row:opacity-100 group-focus-within/status-row:pointer-events-auto group-focus-within/status-row:opacity-100 data-[state=open]:pointer-events-auto data-[state=open]:opacity-100"
onClick={event => event.stopPropagation()}
onKeyDown={event => {
// The row's onActivate also fires on Enter/Space; keep it from
// opening the review pane when the kebab is the focus target.
if (event.key === 'Enter' || event.key === ' ') {
event.stopPropagation()
}
}}
size="icon-xs"
variant="ghost"
>
<Codicon name="kebab-vertical" size="0.8rem" />
</Button>
</DropdownMenuTrigger>
{/* The row sits at the bottom of the screen (above the composer),
so the menu opens upward. */}
<DropdownMenuContent align="end" className="w-60" side="top" sideOffset={6}>
<DropdownMenuLabel className={MENU_SECTION}>{s.newBranch}</DropdownMenuLabel>
{branchTargets.map(target => (
<DropdownMenuItem key={target.base ?? '__head__'} onSelect={() => startBranch(target.base)}>
<span className="truncate">{target.label}</span>
</DropdownMenuItem>
))}
{switchTarget && (
<DropdownMenuItem onSelect={() => void switchToBranch(switchTarget)}>
<span className="truncate">{s.switchTo(switchTarget)}</span>
</DropdownMenuItem>
)}
<DropdownMenuSeparator />
<DropdownMenuLabel className={MENU_SECTION}>{s.worktrees}</DropdownMenuLabel>
{otherWorktrees.map(worktree => (
<DropdownMenuItem key={worktree.path} onSelect={() => onOpenWorktree?.(worktree.path)}>
<span className="truncate">{worktree.branch}</span>
</DropdownMenuItem>
))}
{/* Create a fresh worktree off the current HEAD (the generic
"spin up a worktree here", mirroring the sidebar's + button). */}
<DropdownMenuItem onSelect={() => startBranch(undefined)}>
<span className="truncate">{p.startWork}</span>
</DropdownMenuItem>
{/* Check an EXISTING branch out into a worktree (no new branch). */}
{onConvertBranch && (
<DropdownMenuItem onSelect={() => startConvert()}>
<span className="truncate">{p.convertBranch}</span>
</DropdownMenuItem>
)}
</DropdownMenuContent>
</DropdownMenu>
)}
</div>
{(status.ahead > 0 || status.behind > 0) && (
<span className="ml-auto flex shrink-0 items-center gap-1.5 text-[0.68rem] leading-4 text-muted-foreground/75 tabular-nums">
{status.ahead > 0 && (
<span className="flex items-center gap-0.5" title={s.ahead(status.ahead)}>
<span aria-hidden></span>
{status.ahead}
</span>
)}
{status.behind > 0 && (
<span className="flex items-center gap-0.5" title={s.behind(status.behind)}>
<span aria-hidden></span>
{status.behind}
</span>
)}
</span>
)}
{hasLineDelta ? (
<DiffCount
added={status.added}
className={`text-[0.72rem] leading-4 ${status.ahead === 0 && status.behind === 0 ? 'ml-auto' : ''}`}
removed={status.removed}
/>
) : untrackedOnly ? (
<span
className={`shrink-0 text-[0.72rem] leading-4 text-amber-500/90 ${status.ahead === 0 && status.behind === 0 ? 'ml-auto' : ''}`}
>
{s.changed(status.untracked)}
</span>
) : null}
</StatusRow>
<Dialog onOpenChange={open => !branchPending && setBranchOpen(open)} open={branchOpen}>
<DialogContent className="max-w-md">
<DialogHeader>
<DialogTitle>{convertMode ? p.convertBranchTitle : p.newWorktreeTitle}</DialogTitle>
<DialogDescription>
{convertMode ? p.convertBranchDesc : p.newWorktreeDesc}
{!convertMode && branchBase && (
<span className="mt-1 block text-(--ui-text-secondary)">{s.branchOffFrom(branchBase)}</span>
)}
</DialogDescription>
</DialogHeader>
{convertMode ? (
<Command
className="rounded-md border border-(--ui-stroke-tertiary)"
// The branch name is the authoritative key; filter on it directly.
filter={(value, search) => (value.toLowerCase().includes(search.toLowerCase()) ? 1 : 0)}
>
<CommandInput autoFocus disabled={branchPending} placeholder={p.convertBranchPlaceholder} />
<CommandList className="max-h-64">
<CommandEmpty>{branchesLoading ? p.branchesLoading : p.noBranches}</CommandEmpty>
<CommandGroup>
{branches.map(branch => (
<CommandItem
disabled={branchPending}
key={branch.name}
onSelect={() => void convertBranch(branch)}
value={branch.name}
>
<Codicon className="shrink-0 text-(--ui-text-tertiary)" name="git-branch" size="0.8rem" />
<span className="truncate">{branch.name}</span>
<span className="ml-auto shrink-0 text-[0.625rem] text-(--ui-text-tertiary)">
{branchActionLabel(branch, p)}
</span>
</CommandItem>
))}
</CommandGroup>
</CommandList>
</Command>
) : (
<SanitizedInput
autoFocus
disabled={branchPending}
onKeyDown={event => {
if (event.key === 'Enter') {
event.preventDefault()
void submitBranch()
} else if (event.key === 'Escape') {
setBranchOpen(false)
}
}}
onValueChange={setBranchName}
placeholder={p.branchPlaceholder}
sanitize={gitRef}
value={branchName}
/>
)}
{convertMode ? (
<DialogFooter className="sm:justify-start">
<Button
className="px-0 text-(--ui-text-secondary) hover:text-foreground"
disabled={branchPending}
onClick={() => setConvertMode(false)}
type="button"
variant="link"
>
{t.common.cancel}
</Button>
</DialogFooter>
) : (
<DialogFooter className="sm:justify-between">
{onConvertBranch ? (
<Button
className="px-0 text-(--ui-text-secondary) hover:text-foreground"
disabled={branchPending}
onClick={enterConvert}
type="button"
variant="link"
>
{p.convertBranchInstead}
</Button>
) : (
<span />
)}
<div className="flex items-center gap-2">
<Button disabled={branchPending} onClick={() => setBranchOpen(false)} type="button" variant="ghost">
{t.common.cancel}
</Button>
<Button
disabled={branchPending || !branchName.trim()}
onClick={() => void submitBranch()}
type="button"
>
{p.startWork}
</Button>
</div>
</DialogFooter>
)}
</DialogContent>
</Dialog>
</>
)
})

View File

@@ -30,19 +30,6 @@ import { StatusItemRow } from './status-row'
// emit no event when they die). Only armed while a running row is on screen.
const BACKGROUND_POLL_MS = 5_000
// A localhost/loopback preview is only meaningful while its dev server is up, so
// we tie it to a live background process rather than persisting dismissals or
// letting dead URLs pile up. File previews (a real on-disk artifact) stand alone.
const isLocalhostPreview = (target: string): boolean => /\b(?:localhost|127\.0\.0\.1|0\.0\.0\.0)\b/i.test(target)
// Real codicons per group (no sparkles): a checklist for todos, a bot for
// subagents, a background process glyph for background tasks.
const GROUP_ICON: Record<StatusGroup['type'], string> = {
todo: 'checklist',
subagent: 'hubot',
background: 'server-process'
}
const groupLabel = (group: StatusGroup, s: Translations['statusStack']) => {
if (group.type === 'todo') {
return s.todos(group.items.filter(i => i.todoStatus === 'completed').length, group.items.length)
@@ -87,10 +74,6 @@ export function ComposerStatusStack({ queue, sessionId }: ComposerStatusStackPro
const hasRunningBackground = groups.some(g => g.type === 'background' && g.items.some(i => i.state === 'running'))
// Drop localhost previews once no dev server is left running — that's what made
// dead `localhost:5174` chips stick around. On-disk file previews are kept.
const visiblePreviews = previews.filter(item => hasRunningBackground || !isLocalhostPreview(item.target))
useEffect(() => {
if (!sessionId || !hasRunningBackground) {
return
@@ -106,18 +89,6 @@ export function ComposerStatusStack({ queue, sessionId }: ComposerStatusStackPro
const openSubagent = (item: ComposerStatusItem) =>
item.sessionId ? void openSessionInNewWindow(item.sessionId, { watch: true }) : openAgents()
// Preview links live as child rows of the background group — a localhost dev
// server and its preview are the same thing — so they no longer float as an
// odd, differently-indented standalone block under the stack.
const previewRows =
visiblePreviews.length > 0 && sessionId
? visiblePreviews.map(item => (
<PreviewStatusRow item={item} key={item.id} onDismiss={id => dismissPreviewArtifact(sessionId, id)} />
))
: []
const hasBackgroundGroup = groups.some(g => g.type === 'background')
const sections: { key: string; node: ReactNode }[] = groups.map(group => ({
key: group.type,
node: (
@@ -136,7 +107,11 @@ export function ComposerStatusStack({ queue, sessionId }: ComposerStatusStackPro
) : undefined
}
defaultCollapsed={group.type !== 'todo'}
icon={<Codicon className="text-muted-foreground/70" name={GROUP_ICON[group.type]} size="0.8rem" />}
icon={
group.type === 'todo' ? (
<Codicon className="text-muted-foreground/70" name="checklist" size="0.8rem" />
) : undefined
}
label={groupLabel(group, t.statusStack)}
>
{group.items.map(item => (
@@ -145,20 +120,25 @@ export function ComposerStatusStack({ queue, sessionId }: ComposerStatusStackPro
key={item.id}
onDismiss={sessionId ? id => dismissBackgroundProcess(sessionId, id) : undefined}
onOpen={() => openSubagent(item)}
onStop={sessionId ? id => void stopBackgroundProcess(sessionId, id) : undefined}
onStop={sessionId ? id => stopBackgroundProcess(sessionId, id) : undefined}
/>
))}
{group.type === 'background' && previewRows}
</StatusSection>
)
}))
// No background group to host them (e.g. a standalone on-disk file preview):
// keep the previews as their own row block so they don't disappear.
if (previewRows.length > 0 && !hasBackgroundGroup) {
if (previews.length > 0 && sessionId) {
sections.push({
key: 'preview',
node: <div className="px-1 py-0.5">{previewRows}</div>
// Not a collapsible group — preview links just sit there, one line each,
// each individually closeable.
node: (
<div className="px-1 py-0.5">
{previews.map(item => (
<PreviewStatusRow item={item} key={item.id} onDismiss={id => dismissPreviewArtifact(sessionId, id)} />
))}
</div>
)
})
}
@@ -210,10 +190,12 @@ export function ComposerStatusStack({ queue, sessionId }: ComposerStatusStackPro
return (
<div
// Sits in the overlay lane above the composer. The composer root has pt-2
// before the actual surface; translate by that amount so the stack returns
// to its original attachment point without intruding into the repo strip.
className="absolute inset-x-0 bottom-full z-3 max-h-[40vh] translate-y-2 overflow-y-auto"
// Sits above the composer (bottom-full), nudged down by the shell's 0.5rem
// top pad (pt-2 on composer-root) plus 1px so its bottom edge overlaps the
// composer surface's top border. z BELOW the surface (z-4) so the surface's
// top border paints over our transparent bottom border — one seam, no
// double line.
className="absolute inset-x-0 bottom-full z-3 max-h-[40vh] translate-y-[calc(0.5rem+1px)] overflow-y-auto"
onPointerDownCapture={() => blurComposerInput()}
ref={stackRef}
>
@@ -223,19 +205,17 @@ export function ComposerStatusStack({ queue, sessionId }: ComposerStatusStackPro
Rounded top, square bottom; the bottom border is TRANSPARENT — the
composer surface's visible top border (which sits at a higher z) is the
single shared seam, so the two read as one fused capsule. */}
<div
className={cn(
composerDockCard('top'),
// Inset (mx-2) so the stack reads slightly narrower than the composer
// surface below it — the original look.
'mx-2 overflow-hidden rounded-b-none border-b border-b-transparent pt-0.5',
'transition-opacity duration-200 ease-out',
scrolledUp ? 'opacity-30 group-hover/composer:opacity-100' : 'opacity-100'
)}
>
{sections.map(section => (
<div key={section.key}>{section.node}</div>
))}
<div className={cn(composerDockCard('top'), 'mx-2 rounded-b-none border-b border-b-transparent pt-0.5 pb-1')}>
<div
className={cn(
'transition-opacity duration-200 ease-out',
scrolledUp ? 'opacity-30 group-hover/composer:opacity-100' : 'opacity-100'
)}
>
{sections.map(section => (
<div key={section.key}>{section.node}</div>
))}
</div>
</div>
</div>
)

View File

@@ -6,6 +6,7 @@ import { Button } from '@/components/ui/button'
import { Codicon } from '@/components/ui/codicon'
import { Tip } from '@/components/ui/tooltip'
import { useI18n } from '@/i18n'
import { ChevronRight, X } from '@/lib/icons'
import { normalizeOrLocalPreviewTarget } from '@/lib/local-preview'
import { cn } from '@/lib/utils'
import { PREVIEW_PANE_ID } from '@/store/layout'
@@ -75,47 +76,50 @@ export const PreviewStatusRow = memo(function PreviewStatusRow({ item, onDismiss
return (
<StatusRow
leading={
<Codicon aria-hidden className={cn('text-muted-foreground/70', opening && 'animate-pulse')} name="globe" size="0.8rem" />
}
// Plain click opens the link in the browser; ⌘/Ctrl-click opens it in the
// in-app preview pane instead. (isOpen still toggles the pane closed.)
onActivate={event => {
if (event.metaKey || event.ctrlKey) {
void togglePreview()
} else {
void openInBrowser()
}
}}
leading={<ChevronRight aria-hidden className="size-3 text-muted-foreground/80" />}
onActivate={() => void togglePreview()}
trailing={
<Tip label={t.statusStack.dismiss}>
<Button
aria-label={t.statusStack.dismiss}
className="-my-1 size-4 rounded-md text-muted-foreground/60 hover:text-foreground/90"
onClick={event => {
event.stopPropagation()
onDismiss(item.id)
}}
size="icon-xs"
type="button"
variant="ghost"
>
<Codicon name="close" size="0.75rem" />
</Button>
</Tip>
<span className="-my-1 flex items-center gap-0.5">
<Tip label={t.preview.openInBrowser}>
<Button
aria-label={t.preview.openInBrowser}
className="size-4 rounded-md text-muted-foreground/60 hover:text-foreground/90"
onClick={event => {
event.stopPropagation()
void openInBrowser()
}}
size="icon-xs"
type="button"
variant="ghost"
>
<Codicon name="link-external" size="0.75rem" />
</Button>
</Tip>
<Tip label={t.statusStack.dismiss}>
<Button
aria-label={t.statusStack.dismiss}
className="size-4 rounded-md text-muted-foreground/60 hover:text-foreground/90"
onClick={event => {
event.stopPropagation()
onDismiss(item.id)
}}
size="icon-xs"
type="button"
variant="ghost"
>
<X size={12} />
</Button>
</Tip>
</span>
}
trailingVisible
>
<Tip
label={
<span className="flex flex-col gap-0.5">
<span>{item.target}</span>
<span className="opacity-70">{t.preview.linkHint}</span>
</span>
}
>
<span className="min-w-0 max-w-[18rem] truncate text-[0.73rem] leading-4 text-foreground/92">{item.label}</span>
</Tip>
<span className="min-w-0 max-w-[18rem] truncate text-[0.73rem] leading-4 text-foreground/92" title={item.target}>
{item.label}
</span>
<span className={cn('shrink-0 text-[0.62rem] leading-4 text-muted-foreground/70', opening && 'animate-pulse')}>
{opening ? t.preview.opening : isOpen ? t.preview.hide : t.preview.openPreview}
</span>
</StatusRow>
)
})

View File

@@ -8,6 +8,7 @@ import { DisclosureCaret } from '@/components/ui/disclosure-caret'
import { GlyphSpinner } from '@/components/ui/glyph-spinner'
import { Tip } from '@/components/ui/tooltip'
import { type Translations, useI18n } from '@/i18n'
import { ArrowUpRight, X } from '@/lib/icons'
import type { TodoStatus } from '@/lib/todos'
import { cn } from '@/lib/utils'
import type { ComposerStatusItem } from '@/store/composer-status'
@@ -49,7 +50,7 @@ function leadingGlyph(item: ComposerStatusItem, s: Translations['statusStack']):
return (
<GlyphSpinner
ariaLabel={s.running}
className="text-[0.85rem] leading-none text-muted-foreground/80"
className="text-[0.9rem] leading-none text-muted-foreground/80"
spinner="braille"
/>
)
@@ -116,11 +117,11 @@ export const StatusItemRow = memo(function StatusItemRow({ item, onDismiss, onOp
type="button"
variant="ghost"
>
<Codicon name="close" size="0.75rem" />
<X size={12} />
</Button>
</Tip>
) : canOpen ? (
<Codicon aria-hidden className="text-muted-foreground/55" name="link-external" size="0.85rem" />
<ArrowUpRight aria-hidden className="size-3.5 text-muted-foreground/55" />
) : undefined
}
>

View File

@@ -88,10 +88,7 @@ interface ChatViewProps extends Omit<React.ComponentProps<'div'>, 'onSubmit'> {
onThreadMessagesChange: (messages: readonly ThreadMessage[]) => void
onEdit: (message: AppendMessage) => Promise<void>
onReload: (parentId: string | null) => Promise<void>
onRestoreToMessage?: (
messageId: string,
target?: { text?: string; userOrdinal?: number | null }
) => Promise<void>
onRestoreToMessage?: (messageId: string) => Promise<void>
onRetryResume: (sessionId: string) => void
onTranscribeAudio?: (audio: Blob) => Promise<string>
onDismissError?: (messageId: string) => void

View File

@@ -6,7 +6,7 @@ import type {
MouseEvent as ReactMouseEvent,
ReactNode
} from 'react'
import { Fragment, useEffect, useMemo, useState } from 'react'
import { useEffect, useMemo, useState } from 'react'
import ShikiHighlighter from 'react-shiki'
import { Streamdown } from 'streamdown'
@@ -14,21 +14,15 @@ import { requestComposerFocus, requestComposerInsertRefs } from '@/app/chat/comp
import { droppedFileInlineRef } from '@/app/chat/composer/inline-refs'
import { HERMES_PATHS_MIME } from '@/app/chat/hooks/use-composer-actions'
import { isAddSelectionShortcut } from '@/app/right-sidebar/terminal/selection'
import { FileDiffPanel } from '@/components/chat/diff-lines'
import { chunkTextLines, useFixedRowWindow } from '@/components/chat/fixed-row-window'
import { PageLoader } from '@/components/page-loader'
import { translateNow, useI18n } from '@/i18n'
import { desktopFileDiff, desktopGitRoot, readDesktopFileDataUrl, readDesktopFileText } from '@/lib/desktop-fs'
import { shikiLanguageForFilename } from '@/lib/markdown-code'
import { readDesktopFileDataUrl, readDesktopFileText } from '@/lib/desktop-fs'
import { cn } from '@/lib/utils'
import type { PreviewTarget } from '@/store/preview'
import { $currentCwd } from '@/store/session'
const SHIKI_THEME = { dark: 'github-dark-default', light: 'github-light-default' } as const
const TEXT_PREVIEW_MAX_BYTES = 512 * 1024
const SOURCE_CHUNK_LINES = 200
const SOURCE_LINE_PX = 20
const SOURCE_OVERSCAN_LINES = 400
type EmptyStateTone = 'neutral' | 'warning'
@@ -132,8 +126,6 @@ interface LocalPreviewState {
binary?: boolean
byteSize?: number
dataUrl?: string
/** Working-tree-vs-HEAD unified diff, when the file has uncommitted changes. */
diff?: string
error?: string
language?: string
loading: boolean
@@ -307,44 +299,28 @@ function MarkdownPreview({ text }: { text: string }) {
)
}
function PreviewModeSwitcher({
active,
modes,
onSelect
}: {
active: PreviewViewMode
modes: PreviewViewMode[]
onSelect: (mode: PreviewViewMode) => void
}) {
function PreviewToggle({ asSource, onToggle }: { asSource: boolean; onToggle: () => void }) {
const { t } = useI18n()
const label: Record<PreviewViewMode, string> = {
diff: t.preview.diff,
rendered: t.preview.renderedPreview,
source: t.preview.source
}
return (
<div className="flex shrink-0 justify-end gap-3 border-b border-border/40 px-3 py-1">
{modes.map(mode => (
<button
className={cn(
'text-[0.625rem] font-bold underline-offset-4 transition-colors',
mode === active
? 'text-foreground underline decoration-current/30'
: 'text-muted-foreground hover:text-foreground'
)}
key={mode}
onClick={() => onSelect(mode)}
type="button"
>
{label[mode]}
</button>
))}
<div className="sticky top-0 z-10 flex justify-end border-b border-border/40 bg-transparent px-3 py-1 backdrop-blur">
<button
className="text-[0.625rem] font-bold text-muted-foreground underline decoration-current/20 underline-offset-4 transition-colors hover:text-foreground"
onClick={onToggle}
type="button"
>
{asSource ? t.preview.renderedPreview : t.preview.source}
</button>
</div>
)
}
// Gutter and Shiki output share `font-mono text-xs leading-relaxed py-3` so
// each line aligns vertically. The selection overlay relies on the same
// `text-xs * leading-relaxed = 1.21875rem` line-height to position itself.
const SOURCE_LINE_HEIGHT_REM = 1.21875
const SOURCE_PAD_Y_REM = 0.75
interface LineSelection {
end: number
start: number
@@ -361,18 +337,7 @@ function startLineDrag(event: ReactDragEvent<HTMLElement>, filePath: string, { e
function SourceView({ filePath, language, text }: { filePath: string; language: string; text: string }) {
const { t } = useI18n()
const chunks = useMemo(() => chunkTextLines(text, SOURCE_CHUNK_LINES), [text])
const lastChunk = chunks.at(-1)
const totalLines = lastChunk ? lastChunk.start + lastChunk.lines.length : 0
const { afterRows, beforeRows, endChunk, onScroll, scrollerRef, startChunk } = useFixedRowWindow({
overscanRows: SOURCE_OVERSCAN_LINES,
rowPx: SOURCE_LINE_PX,
rowsPerChunk: SOURCE_CHUNK_LINES,
totalRows: totalLines
})
const visibleChunks = chunks.slice(startChunk, endChunk + 1)
const lineCount = useMemo(() => Math.max(1, text.split('\n').length), [text])
const [selection, setSelection] = useState<LineSelection | null>(null)
const inSelection = (line: number) => selection != null && line >= selection.start && line <= selection.end
@@ -429,76 +394,69 @@ function SourceView({ filePath, language, text }: { filePath: string; language:
}, [filePath, selection])
return (
<div className="h-full overflow-auto" onScroll={onScroll} ref={scrollerRef}>
<div className="grid min-w-max grid-cols-[auto_minmax(0,1fr)] font-mono text-[0.7rem] leading-relaxed">
{beforeRows > 0 && (
<div aria-hidden className="col-span-2" style={{ height: beforeRows * SOURCE_LINE_PX }} />
)}
{visibleChunks.map(chunk => (
<Fragment key={chunk.start}>
<div className="select-none text-right text-muted-foreground/55">
{chunk.lines.map((_lineText, offset) => {
const line = chunk.start + offset + 1
const selected = inSelection(line)
<div className="grid min-w-max grid-cols-[auto_minmax(0,1fr)] font-mono text-xs leading-relaxed">
<div className="select-none py-3 text-right text-muted-foreground/55">
{Array.from({ length: lineCount }, (_, index) => {
const line = index + 1
const selected = inSelection(line)
return (
<div
className={cn(
'h-5 w-9 cursor-pointer pr-2 leading-5 tabular-nums transition-colors',
selected
? 'bg-amber-200/45 text-amber-900 dark:bg-amber-300/20 dark:text-amber-100'
: 'hover:text-foreground'
)}
draggable
key={line}
onClick={event => handleLineClick(event, line)}
onDragStart={event => handleDragStart(event, line)}
title={t.preview.sourceLineTitle}
>
{line}
</div>
)
})}
return (
<div
className={cn(
'cursor-pointer px-3 tabular-nums transition-colors',
selected
? 'bg-amber-200/45 text-amber-900 dark:bg-amber-300/20 dark:text-amber-100'
: 'hover:text-foreground'
)}
draggable
key={line}
onClick={event => handleLineClick(event, line)}
onDragStart={event => handleDragStart(event, line)}
title={t.preview.sourceLineTitle}
>
{line}
</div>
<div className="preview-source-code min-w-0 [&_pre]:m-0" data-selectable-text="true">
<ShikiHighlighter
addDefaultStyles={false}
as="div"
defaultColor="light-dark()"
delay={80}
language={language || 'text'}
showLanguage={false}
theme={SHIKI_THEME}
>
{chunk.text}
</ShikiHighlighter>
</div>
</Fragment>
))}
{afterRows > 0 && (
<div aria-hidden className="col-span-2" style={{ height: afterRows * SOURCE_LINE_PX }} />
)
})}
</div>
<div
className="relative [&_pre]:m-0 [&_pre]:px-3 [&_pre]:py-3 [&_pre]:bg-transparent!"
data-selectable-text="true"
>
{selection && (
<div
aria-hidden
className="pointer-events-none absolute inset-x-0 bg-amber-200/35 dark:bg-amber-300/10"
style={{
top: `calc(${SOURCE_PAD_Y_REM}rem + ${selection.start - 1} * ${SOURCE_LINE_HEIGHT_REM}rem)`,
height: `calc(${selection.end - selection.start + 1} * ${SOURCE_LINE_HEIGHT_REM}rem)`
}}
/>
)}
<ShikiHighlighter
addDefaultStyles={false}
as="div"
defaultColor="light-dark()"
delay={80}
language={language || 'text'}
showLanguage={false}
theme={SHIKI_THEME}
>
{text}
</ShikiHighlighter>
</div>
</div>
)
}
type PreviewViewMode = 'diff' | 'rendered' | 'source'
export function LocalFilePreview({ reloadKey, target }: { reloadKey: number; target: PreviewTarget }) {
const { t } = useI18n()
const [state, setState] = useState<LocalPreviewState>({ loading: true })
const [forcePreview, setForcePreview] = useState(false)
// User-picked view; null = auto (diff when changed, else rendered markdown,
// else source). Reset when the previewed file changes.
const [userMode, setUserMode] = useState<null | PreviewViewMode>(null)
const [renderMarkdownAsSource, setRenderMarkdownAsSource] = useState(false)
const filePath = filePathForTarget(target)
const isImage = target.previewKind === 'image'
useEffect(() => {
setUserMode(null)
}, [filePath, reloadKey])
// HTML files are rendered as source code, not in a webview - so they take
// the same path as plain text files. `previewKind === 'binary'` arrives
// when the file is forcibly previewed past the binary refusal screen.
@@ -550,22 +508,6 @@ export function LocalFilePreview({ reloadKey, target }: { reloadKey: number; tar
text: shouldBlock ? undefined : result.text,
truncated: result.truncated
})
// Best-effort: fetch the file's working-tree-vs-HEAD diff so the
// preview can offer a DIFF view when there are uncommitted changes.
// Empty (clean file / not a repo / remote) just hides the option.
if (!shouldBlock) {
try {
const root = await desktopGitRoot(filePath)
const diff = root ? await desktopFileDiff(root, filePath) : ''
if (active && diff.trim()) {
setState(prev => (prev.text === result.text ? { ...prev, diff } : prev))
}
} catch {
// No diff available; the preview just shows source.
}
}
}
} catch (error) {
if (active) {
@@ -629,50 +571,21 @@ export function LocalFilePreview({ reloadKey, target }: { reloadKey: number; tar
if (isText && state.text !== undefined) {
const isMarkdown = (state.language || target.language) === 'markdown'
const hasDiff = Boolean(state.diff && state.diff.trim())
// Order the toggle reads left→right; default lands on the most useful view.
const modes: PreviewViewMode[] = []
if (isMarkdown) {
modes.push('rendered')
}
modes.push('source')
if (hasDiff) {
modes.push('diff')
}
const autoMode: PreviewViewMode = hasDiff ? 'diff' : isMarkdown ? 'rendered' : 'source'
const mode = userMode && modes.includes(userMode) ? userMode : autoMode
const showRendered = isMarkdown && !renderMarkdownAsSource
return (
<div className="flex h-full flex-col overflow-hidden bg-transparent">
<div className="h-full overflow-auto bg-transparent">
{state.truncated && (
<div className="border-b border-border/60 bg-muted/35 px-3 py-1.5 text-[0.68rem] text-muted-foreground">
{t.preview.truncated}
</div>
)}
{modes.length > 1 && <PreviewModeSwitcher active={mode} modes={modes} onSelect={setUserMode} />}
<div className="min-h-0 flex-1 overflow-auto">
{mode === 'rendered' ? (
<MarkdownPreview text={state.text} />
) : mode === 'diff' ? (
<FileDiffPanel
className="mx-0 mb-0 h-full max-h-none"
diff={state.diff ?? ''}
fullText={state.text}
path={filePath}
showLineNumbers
/>
) : (
<SourceView
filePath={filePath}
language={shikiLanguageForFilename(filePath) || state.language || 'text'}
text={state.text}
/>
)}
</div>
{isMarkdown && <PreviewToggle asSource={!showRendered} onToggle={() => setRenderMarkdownAsSource(s => !s)} />}
{showRendered ? (
<MarkdownPreview text={state.text} />
) : (
<SourceView filePath={filePath} language={state.language || 'text'} text={state.text} />
)}
</div>
)
}

View File

@@ -3,19 +3,10 @@ import { useEffect, useMemo } from 'react'
import type { SetTitlebarToolGroup } from '@/app/shell/titlebar-controls'
import { Codicon } from '@/components/ui/codicon'
import {
ContextMenu,
ContextMenuContent,
ContextMenuItem,
ContextMenuSeparator,
ContextMenuTrigger
} from '@/components/ui/context-menu'
import { Tip } from '@/components/ui/tooltip'
import { translateNow, useI18n } from '@/i18n'
import { formatCombo } from '@/lib/keybinds/combo'
import { cn } from '@/lib/utils'
import {
$panesFlipped,
$rightRailActiveTabId,
RIGHT_RAIL_PREVIEW_TAB_ID,
type RightRailTabId,
@@ -25,10 +16,8 @@ import {
$filePreviewTabs,
$previewReloadRequest,
$previewTarget,
closeOtherRightRailTabs,
closeRightRail,
closeRightRailTab,
closeRightRailTabsToRight,
type PreviewTarget
} from '@/store/preview'
@@ -67,7 +56,6 @@ export function ChatPreviewRail({ onRestartServer, setTitlebarToolGroup }: ChatP
const { t } = useI18n()
const previewReloadRequest = useStore($previewReloadRequest)
const activeTabId = useStore($rightRailActiveTabId)
const panesFlipped = useStore($panesFlipped)
const filePreviewTabs = useStore($filePreviewTabs)
const previewTarget = useStore($previewTarget)
@@ -94,92 +82,68 @@ export function ChatPreviewRail({ onRestartServer, setTitlebarToolGroup }: ChatP
const isPreview = activeTab.id === RIGHT_RAIL_PREVIEW_TAB_ID
return (
<aside
className={cn(
'relative flex h-full w-full min-w-0 flex-col overflow-hidden border-(--ui-stroke-tertiary) bg-(--ui-editor-surface-background) text-(--ui-text-tertiary)',
panesFlipped ? 'border-r' : 'border-l'
)}
>
<aside className="relative flex h-full w-full min-w-0 flex-col overflow-hidden border-l border-(--ui-stroke-tertiary) bg-(--ui-editor-surface-background) text-(--ui-text-tertiary)">
<div className="group/rail-tabs flex h-(--titlebar-height) shrink-0 border-b border-(--ui-stroke-tertiary) bg-(--ui-sidebar-surface-background)">
<div
className="flex min-w-0 flex-1 overflow-x-auto overflow-y-hidden overscroll-x-contain [-ms-overflow-style:none] [scrollbar-width:none] [&::-webkit-scrollbar]:hidden"
role="tablist"
>
{tabs.map((tab, index) => {
{tabs.map(tab => {
const active = tab.id === activeTab.id
const hasOthers = tabs.length > 1
const hasTabsToRight = index < tabs.length - 1
return (
<ContextMenu key={tab.id}>
<ContextMenuTrigger asChild>
<div
className={cn(
'group/tab relative flex h-full min-w-0 max-w-48 shrink-0 items-center text-[0.6875rem] font-medium [-webkit-app-region:no-drag] last:border-r last:border-(--ui-stroke-quaternary)',
active
? 'bg-(--ui-editor-surface-background) text-foreground [--tab-bg:var(--ui-editor-surface-background)]'
: 'border-r border-(--ui-stroke-quaternary) text-(--ui-text-tertiary) [--tab-bg:var(--ui-sidebar-surface-background)] hover:bg-(--chrome-action-hover) hover:text-foreground'
)}
// Middle-click closes the tab, matching browser/IDE muscle
// memory. `onMouseDown` swallows the middle-button press so
// Chromium doesn't switch into autoscroll mode.
onAuxClick={event => {
if (event.button !== 1) {
return
}
<div
className={cn(
'group/tab relative flex h-full min-w-0 max-w-48 shrink-0 items-center text-[0.6875rem] font-medium [-webkit-app-region:no-drag] last:border-r last:border-(--ui-stroke-quaternary)',
active
? 'bg-(--ui-editor-surface-background) text-foreground [--tab-bg:var(--ui-editor-surface-background)]'
: 'border-r border-(--ui-stroke-quaternary) text-(--ui-text-tertiary) [--tab-bg:var(--ui-sidebar-surface-background)] hover:bg-(--chrome-action-hover) hover:text-foreground'
)}
key={tab.id}
// Middle-click closes the tab, matching browser/IDE muscle
// memory. `onMouseDown` swallows the middle-button press so
// Chromium doesn't switch into autoscroll mode.
onAuxClick={event => {
if (event.button !== 1) {
return
}
event.preventDefault()
closeRightRailTab(tab.id)
}}
onMouseDown={event => {
if (event.button === 1) {
event.preventDefault()
}
}}
event.preventDefault()
closeRightRailTab(tab.id)
}}
onMouseDown={event => {
if (event.button === 1) {
event.preventDefault()
}
}}
>
{active && (
<span aria-hidden="true" className="absolute inset-x-0 top-0 h-px bg-(--ui-stroke-primary)" />
)}
<Tip label={tab.label}>
<button
aria-selected={active}
className="flex h-full min-w-0 max-w-full items-center overflow-hidden pl-3 pr-2 text-left outline-none"
onClick={() => selectRightRailTab(tab.id)}
role="tab"
type="button"
>
{active && (
<span aria-hidden="true" className="absolute inset-x-0 top-0 h-px bg-(--ui-stroke-primary)" />
)}
<Tip label={tab.target.path || tab.target.url || tab.label}>
<button
aria-selected={active}
className="flex h-full min-w-0 max-w-full items-center overflow-hidden pl-3 pr-2 text-left outline-none"
onClick={() => selectRightRailTab(tab.id)}
role="tab"
type="button"
>
<span className="block min-w-0 truncate">{tab.label}</span>
</button>
</Tip>
<span
aria-hidden="true"
className="pointer-events-none absolute inset-y-0 right-0 w-9 bg-[linear-gradient(to_right,transparent,var(--tab-bg)_55%)] opacity-0 transition-opacity group-hover/tab:opacity-100 group-focus-within/tab:opacity-100"
/>
<button
aria-label={t.preview.closeTab(tab.label)}
className="pointer-events-none absolute right-1.5 top-1/2 grid size-4 -translate-y-1/2 place-items-center rounded-sm text-(--ui-text-tertiary) opacity-0 transition-[background-color,color,opacity] hover:bg-(--ui-bg-secondary) hover:text-foreground focus-visible:pointer-events-auto focus-visible:opacity-100 group-hover/tab:pointer-events-auto group-hover/tab:opacity-100 group-focus-within/tab:pointer-events-auto group-focus-within/tab:opacity-100"
onClick={() => closeRightRailTab(tab.id)}
type="button"
>
<Codicon name="close" size="0.75rem" />
</button>
</div>
</ContextMenuTrigger>
<ContextMenuContent>
<ContextMenuItem onSelect={() => closeRightRailTab(tab.id)}>
{t.common.close}
<span className="ml-auto pl-4 text-(--ui-text-tertiary)">{formatCombo('mod+w')}</span>
</ContextMenuItem>
<ContextMenuItem disabled={!hasOthers} onSelect={() => closeOtherRightRailTabs(tab.id)}>
{t.preview.closeOthers}
</ContextMenuItem>
<ContextMenuItem disabled={!hasTabsToRight} onSelect={() => closeRightRailTabsToRight(tab.id)}>
{t.preview.closeToRight}
</ContextMenuItem>
<ContextMenuSeparator />
<ContextMenuItem onSelect={closeRightRail}>{t.preview.closeAll}</ContextMenuItem>
</ContextMenuContent>
</ContextMenu>
<span className="block min-w-0 truncate">{tab.label}</span>
</button>
</Tip>
<span
aria-hidden="true"
className="pointer-events-none absolute inset-y-0 right-0 w-9 bg-[linear-gradient(to_right,transparent,var(--tab-bg)_55%)] opacity-0 transition-opacity group-hover/tab:opacity-100 group-focus-within/tab:opacity-100"
/>
<button
aria-label={t.preview.closeTab(tab.label)}
className="pointer-events-none absolute right-1.5 top-1/2 grid size-4 -translate-y-1/2 place-items-center rounded-sm text-(--ui-text-tertiary) opacity-0 transition-[background-color,color,opacity] hover:bg-(--ui-bg-secondary) hover:text-foreground focus-visible:pointer-events-auto focus-visible:opacity-100 group-hover/tab:pointer-events-auto group-hover/tab:opacity-100 group-focus-within/tab:pointer-events-auto group-focus-within/tab:opacity-100"
onClick={() => closeRightRailTab(tab.id)}
type="button"
>
<Codicon name="close" size="0.75rem" />
</button>
</div>
)
})}
</div>

View File

@@ -1,158 +0,0 @@
import type * as React from 'react'
import { Codicon } from '@/components/ui/codicon'
import { cn } from '@/lib/utils'
// Shared, content-agnostic sidebar chrome — used by both the flat session
// sections and the project/workspace tree, so it lives outside either to keep
// imports one-directional (no index <-> projects cycle).
/** `loaded/total` when there's more on the server, else just the loaded count. */
export const countLabel = (loaded: number, total: number): string =>
total > loaded ? `${loaded}/${total}` : String(loaded)
/** The muted count chip next to a section/workspace label. */
export function SidebarCount({ children }: { children: React.ReactNode }) {
return <span className="text-[0.6875rem] font-medium text-(--ui-text-quaternary)">{children}</span>
}
// ── Row geometry (session row is canonical — everything composes these) ─────
//
// Height lives ONLY on SidebarRowShell (min-h-[1.625rem]). Inset children
// stretch to fill the cell and center content internally — never items-center
// on the shell grid, or short clusters (projects) float 12px off sessions.
const rowMinH = 'min-h-[1.625rem]'
const rowPadX = 'pl-2 pr-1'
const rowGap = 'gap-1.5'
const rowLead = 'grid size-3.5 shrink-0 place-items-center'
const rowInset = cn(rowPadX, rowGap, 'flex h-full min-w-0 items-center self-stretch py-0.5')
const rowLabel = 'min-w-0 truncate text-[0.8125rem] leading-none text-(--ui-text-secondary)'
/** Codicon size in sidebar row leads — matches the file tree (`tree.tsx`). */
export const SIDEBAR_LEAD_ICON_SIZE = '0.875rem' as const
/** Vertical stack of rows (gap-px, single column). */
export function SidebarRowStack({ className, ...props }: React.ComponentProps<'div'>) {
return <div className={cn('grid grid-cols-[minmax(0,1fr)] gap-px', className)} {...props} />
}
/** Nested rows (session previews, worktree bodies). */
export function SidebarRowNest({ className, ...props }: React.ComponentProps<'div'>) {
return <SidebarRowStack className={cn('pb-1 pl-4', className)} {...props} />
}
/** Outer grid — sole owner of row height. */
export function SidebarRowShell({
actions,
children,
className,
...props
}: React.ComponentProps<'div'> & { actions?: React.ReactNode }) {
return (
<div className={cn(rowMinH, 'grid grid-cols-[minmax(0,1fr)_auto] items-stretch rounded-md', className)} {...props}>
{children}
{actions ? <div className="flex shrink-0 items-center self-center">{actions}</div> : null}
</div>
)
}
/** Multi-control left cluster (project rows). */
export function SidebarRowCluster({ className, ...props }: React.ComponentProps<'div'>) {
return <div className={cn(rowInset, className)} {...props} />
}
/** Session row main tap target. */
export function SidebarRowBody({ className, ...props }: React.ComponentProps<'button'>) {
return <button className={cn(rowInset, 'bg-transparent text-left', className)} type="button" {...props} />
}
/** Tappable label — underline/truncate live on the inner span, not the button. */
export function SidebarRowLink({
className,
labelClassName,
children,
...props
}: React.ComponentProps<'button'> & { labelClassName?: string }) {
return (
<button className={cn('min-w-0 shrink bg-transparent p-0 text-left', className)} type="button" {...props}>
<span className={cn(rowLabel, labelClassName)}>{children}</span>
</button>
)
}
/** Fixed leading column (dot, icon, drag handle). */
export function SidebarRowLead({ className, ...props }: React.ComponentProps<'span'>) {
return <span className={cn(rowLead, className)} {...props} />
}
/** Standard row label typography. */
export function SidebarRowLabel({ className, ...props }: React.ComponentProps<'span'>) {
return <span className={cn(rowLabel, className)} {...props} />
}
/** Dot ↔ grabber swap for dnd-kit reorder rows. */
export function SidebarRowGrab({
ariaLabel,
children,
className,
dragging = false,
dragHandleProps,
leadClassName
}: {
ariaLabel: string
children: React.ReactNode
className?: string
dragging?: boolean
dragHandleProps?: React.HTMLAttributes<HTMLElement>
leadClassName?: string
}) {
return (
<SidebarRowLead
{...dragHandleProps}
aria-label={ariaLabel}
className={cn(
'group/handle relative cursor-grab touch-none overflow-hidden active:cursor-grabbing',
leadClassName,
className
)}
data-reorder-handle
onClick={event => event.stopPropagation()}
>
<span className="grid size-full place-items-center transition-opacity group-hover/handle:opacity-0 group-focus-within/handle:opacity-0">
{children}
</span>
<Codicon
className={cn(
'absolute text-(--ui-text-quaternary) opacity-0 transition-opacity group-hover/handle:opacity-80 group-focus-within/handle:opacity-80 hover:text-(--ui-text-secondary)',
dragging && 'text-(--ui-text-secondary) opacity-100'
)}
name="grabber"
size="0.75rem"
/>
</SidebarRowLead>
)
}
/** Icon/dot slot inside SidebarRowLead — caps visual size so rows align. */
export function SidebarRowLeadGlyph({
children,
className,
style
}: {
children: React.ReactNode
className?: string
style?: React.CSSProperties
}) {
return (
<span
className={cn(
'grid size-full place-items-center text-(--ui-text-tertiary) [&_.codicon]:leading-none',
className
)}
style={style}
>
{children}
</span>
)
}

View File

@@ -3,7 +3,6 @@ import { useEffect, useMemo, useState } from 'react'
import { Codicon } from '@/components/ui/codicon'
import { DisclosureCaret } from '@/components/ui/disclosure-caret'
import { GlyphSpinner } from '@/components/ui/glyph-spinner'
import { SidebarGroup, SidebarGroupContent } from '@/components/ui/sidebar'
import { Tip } from '@/components/ui/tooltip'
import { getCronJobRuns, type SessionInfo } from '@/hermes'
@@ -329,7 +328,7 @@ function CronJobSidebarRuns({ jobId, onOpenRun }: { jobId: string; onOpenRun: (s
<div className="mb-1 ml-[1.375rem] flex flex-col gap-px">
{runs === null ? (
<div className="flex items-center gap-1.5 py-1 pl-1 text-[0.6875rem] text-(--ui-text-tertiary)">
<GlyphSpinner ariaLabel={c.loading} className="text-[0.75rem]" />
<Codicon name="loading" size="0.75rem" spinning />
</div>
) : runs.length === 0 ? (
<div className="py-1 pl-1 text-[0.6875rem] text-(--ui-text-tertiary)">{c.noRuns}</div>

File diff suppressed because it is too large Load Diff

View File

@@ -1,5 +1,4 @@
import { Codicon } from '@/components/ui/codicon'
import { GlyphSpinner } from '@/components/ui/glyph-spinner'
import { useI18n } from '@/i18n'
interface SidebarLoadMoreRowProps {
@@ -8,22 +7,24 @@ interface SidebarLoadMoreRowProps {
loading?: boolean
}
// Compact "load more" affordance shared by recents, messaging, and cron. Kept
// intentionally identical to workspace "show more" controls (ellipsis button)
// so pagination reads as one interaction everywhere.
// "Load N more" affordance shared by the recents, messaging, and cron sections.
// The chevron sits in the same w-3.5 column the rows use for their dot, so it
// lines up with the list above.
export function SidebarLoadMoreRow({ step, onClick, loading = false }: SidebarLoadMoreRowProps) {
const { t } = useI18n()
const label = loading ? t.sidebar.loading : step > 0 ? t.sidebar.loadCount(step) : t.sidebar.loadMore
return (
<button
aria-label={label}
className="ml-auto grid size-5 place-items-center rounded-sm bg-transparent text-(--ui-text-tertiary) transition-colors hover:bg-(--ui-control-hover-background) hover:text-foreground disabled:cursor-default disabled:opacity-60 disabled:hover:bg-transparent disabled:hover:text-(--ui-text-tertiary)"
className="flex min-h-5 items-center gap-1.5 self-start bg-transparent pl-2 text-left text-[0.6875rem] text-(--ui-text-tertiary) transition-colors duration-100 ease-out hover:text-foreground hover:transition-none disabled:cursor-default disabled:opacity-60 disabled:hover:text-(--ui-text-tertiary)"
disabled={loading}
onClick={onClick}
type="button"
>
{loading ? <GlyphSpinner ariaLabel={label} className="text-[0.75rem]" /> : <Codicon name="ellipsis" size="0.75rem" />}
<span className="grid w-3.5 shrink-0 place-items-center">
<Codicon className="opacity-70" name={loading ? 'loading' : 'chevron-down'} size="0.75rem" spinning={loading} />
</span>
<span>{label}</span>
</button>
)
}

View File

@@ -1,12 +1,3 @@
/** New ids first, then ids still present in the persisted order. */
export function reconcileFreshFirst(currentIds: string[], orderIds: string[]): string[] {
const current = new Set(currentIds)
const retained = orderIds.filter(id => current.has(id))
const retainedSet = new Set(retained)
return [...currentIds.filter(id => !retainedSet.has(id)), ...retained]
}
export function resolveManualSessionOrderIds(currentIds: string[], orderIds: string[], manual: boolean): string[] {
if (!manual || !currentIds.length || !orderIds.length) {
return []
@@ -19,5 +10,8 @@ export function resolveManualSessionOrderIds(currentIds: string[], orderIds: str
return []
}
return reconcileFreshFirst(currentIds, orderIds)
const retainedSet = new Set(retained)
const fresh = currentIds.filter(id => !retainedSet.has(id))
return [...fresh, ...retained]
}

View File

@@ -24,7 +24,6 @@ import { useNavigate } from 'react-router-dom'
import { Button } from '@/components/ui/button'
import { Codicon } from '@/components/ui/codicon'
import { ColorSwatches } from '@/components/ui/color-swatches'
import { ContextMenu, ContextMenuContent, ContextMenuItem, ContextMenuTrigger } from '@/components/ui/context-menu'
import { Popover, PopoverAnchor, PopoverContent } from '@/components/ui/popover'
import { Tip, Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip'
@@ -495,14 +494,30 @@ function ProfileSquare({ active, color, label, onDelete, onRecolor, onRename, on
collisionPadding={{ bottom: 44, left: 8, right: 8, top: 8 }}
side="top"
>
<ColorSwatches
clearIcon="sync"
clearLabel={p.autoColor}
onChange={pickColor}
swatches={PROFILE_SWATCHES}
swatchLabel={p.setColor}
value={color}
/>
<div className="grid grid-cols-6 gap-1.5">
{PROFILE_SWATCHES.map(swatch => (
<button
aria-label={p.setColor(swatch)}
className="size-5 rounded-full transition-transform hover:scale-110"
key={swatch}
onClick={() => pickColor(swatch)}
style={{
backgroundColor: swatch,
boxShadow: swatch === color ? '0 0 0 2px var(--ui-bg-elevated), 0 0 0 3.5px currentColor' : undefined,
color: swatch
}}
type="button"
/>
))}
</div>
<button
className="mt-2 flex w-full items-center justify-center gap-1.5 rounded-md py-1 text-xs text-(--ui-text-tertiary) transition hover:bg-(--ui-control-hover-background) hover:text-foreground"
onClick={() => pickColor(null)}
type="button"
>
<Codicon name="sync" size="0.75rem" />
{p.autoColor}
</button>
</PopoverContent>
</Popover>
)

View File

@@ -1,289 +0,0 @@
import { useStore } from '@nanostores/react'
import { useEffect, useRef, useState } from 'react'
import { Button } from '@/components/ui/button'
import { Codicon } from '@/components/ui/codicon'
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from '@/components/ui/dialog'
import { GenerateButton } from '@/components/ui/generate-button'
import { Input } from '@/components/ui/input'
import { Textarea } from '@/components/ui/textarea'
import { useI18n } from '@/i18n'
import { type ProjectIdeaTemplate, randomIdeaTemplates } from '@/lib/project-idea-templates'
import { cn } from '@/lib/utils'
import { notifyError } from '@/store/notifications'
import {
$projectDialog,
addProjectFolder,
closeProjectDialog,
createProject,
generateProjectIdea,
pickProjectFolder,
renameProject
} from '@/store/projects'
// Single dialog mounted once in the sidebar; it renders create / rename /
// add-folder flows driven by the $projectDialog atom. Folders are chosen via
// the native directory picker (reused from the default-project-dir setting).
export function ProjectDialog() {
const { t } = useI18n()
const p = t.sidebar.projects
const state = useStore($projectDialog)
const open = state !== null
const mode = state?.mode ?? 'create'
const [name, setName] = useState('')
const [folders, setFolders] = useState<string[]>([])
const [idea, setIdea] = useState('')
const [templates, setTemplates] = useState<ProjectIdeaTemplate[]>([])
const [generatingIdea, setGeneratingIdea] = useState(false)
const [submitting, setSubmitting] = useState(false)
const nameRef = useRef<HTMLInputElement>(null)
useEffect(() => {
if (open) {
setName(state?.name ?? '')
setFolders([])
setIdea('')
setTemplates(randomIdeaTemplates())
setGeneratingIdea(false)
setSubmitting(false)
if (mode !== 'add-folder') {
window.setTimeout(() => nameRef.current?.select(), 0)
}
}
}, [open, mode, state?.name])
const onOpenChange = (next: boolean) => {
if (!next) {
closeProjectDialog()
}
}
// One submit beat for every flow: guard re-entry, run the write, close on
// success, surface a toast on failure. Callers pass only the write.
const runSubmit = async (write: () => Promise<unknown>) => {
if (submitting) {
return
}
setSubmitting(true)
try {
await write()
closeProjectDialog()
} catch (err) {
notifyError(err, p.createFailed)
} finally {
setSubmitting(false)
}
}
const pickFolder = async () => {
const dir = await pickProjectFolder()
if (!dir) {
return
}
const projectId = state?.projectId
if (mode === 'add-folder' && projectId) {
await runSubmit(() => addProjectFolder(projectId, dir))
return
}
setFolders(prev => (prev.includes(dir) ? prev : [...prev, dir]))
}
const submit = async () => {
const trimmed = name.trim()
const projectId = state?.projectId
if (mode === 'rename' && projectId) {
if (trimmed) {
await runSubmit(() => renameProject(projectId, trimmed))
}
return
}
// A project owns sessions by folder (cwd-prefix), so creation requires at
// least one — a folder-less project couldn't hold a session anyway.
if (mode === 'create' && trimmed && folders.length) {
await runSubmit(() => createProject({ folders, idea: idea.trim() || undefined, name: trimmed, use: true }))
}
}
const generateIdea = async () => {
if (generatingIdea) {
return
}
setGeneratingIdea(true)
try {
const text = await generateProjectIdea(name)
if (text) {
setIdea(text)
}
} finally {
setGeneratingIdea(false)
}
}
const title = mode === 'rename' ? p.renameTitle : mode === 'add-folder' ? p.addFolderTitle : p.createTitle
return (
<Dialog onOpenChange={onOpenChange} open={open}>
<DialogContent className="max-w-md">
<DialogHeader>
<DialogTitle>{title}</DialogTitle>
{mode === 'create' && <DialogDescription>{p.createDesc}</DialogDescription>}
</DialogHeader>
{mode !== 'add-folder' && (
<Input
autoFocus
disabled={submitting}
onChange={event => setName(event.target.value)}
onKeyDown={event => {
if (event.key === 'Enter') {
event.preventDefault()
void submit()
} else if (event.key === 'Escape') {
onOpenChange(false)
}
}}
placeholder={p.namePlaceholder}
ref={nameRef}
value={name}
/>
)}
{mode === 'create' && (
<div className="flex flex-col gap-1.5">
<span className="text-[0.6875rem] font-medium text-(--ui-text-tertiary)">{p.foldersLabel}</span>
{folders.length === 0 ? (
<span className="text-[0.75rem] text-(--ui-text-quaternary)">{p.noFolders}</span>
) : (
<ul className="flex flex-col gap-1">
{folders.map((folder, index) => (
<li
className={cn(
'flex items-center gap-2 rounded-md bg-(--ui-control-hover-background) px-2 py-1 text-[0.75rem]'
)}
key={folder}
>
<Codicon className="shrink-0 text-(--ui-text-tertiary)" name="folder" size="0.75rem" />
<span className="min-w-0 flex-1 truncate" title={folder}>
{folder}
</span>
{index === 0 && (
<span className="shrink-0 text-[0.625rem] uppercase text-(--ui-text-quaternary)">
{p.primaryBadge}
</span>
)}
<Button
aria-label={p.removeFolder}
className="size-5 shrink-0 text-(--ui-text-quaternary) hover:text-foreground"
onClick={() => setFolders(prev => prev.filter(f => f !== folder))}
size="icon-xs"
type="button"
variant="ghost"
>
<Codicon name="close" size="0.75rem" />
</Button>
</li>
))}
</ul>
)}
<Button
className="self-start"
disabled={submitting}
onClick={() => void pickFolder()}
size="sm"
type="button"
variant="ghost"
>
<Codicon name="add" size="0.75rem" />
{p.addFolder}
</Button>
</div>
)}
{mode === 'create' && (
<div className="flex flex-col gap-1.5">
<span className="text-[0.6875rem] font-medium text-(--ui-text-tertiary)">{p.ideaLabel}</span>
<div className="relative">
<Textarea
className="min-h-20 pr-8 text-[0.8125rem]"
disabled={submitting}
onChange={event => setIdea(event.target.value)}
placeholder={p.ideaPlaceholder}
value={idea}
/>
<GenerateButton
className="absolute top-1 right-1"
disabled={submitting}
generating={generatingIdea}
generatingLabel={p.ideaGenerating}
label={p.ideaGenerate}
onGenerate={() => void generateIdea()}
/>
</div>
<div className="flex flex-wrap items-center gap-1">
{templates.map(template => (
<button
className="flex items-center gap-1 rounded-full border border-(--ui-stroke-tertiary) px-2 py-0.5 text-[0.6875rem] text-(--ui-text-secondary) transition-colors hover:border-(--ui-stroke-secondary) hover:bg-(--ui-control-hover-background) hover:text-foreground disabled:opacity-50"
disabled={submitting}
key={template.label}
onClick={() => setIdea(template.idea)}
type="button"
>
<span aria-hidden>{template.emoji}</span>
{template.label}
</button>
))}
<Button
aria-label={p.ideaShuffle}
className="size-5 text-(--ui-text-quaternary) hover:text-foreground"
disabled={submitting}
onClick={() => setTemplates(randomIdeaTemplates())}
size="icon-xs"
type="button"
variant="ghost"
>
<Codicon name="refresh" size="0.75rem" />
</Button>
</div>
</div>
)}
{mode === 'add-folder' && (
<Button disabled={submitting} onClick={() => void pickFolder()} type="button">
<Codicon name="folder-opened" size="0.875rem" />
{p.addFolder}
</Button>
)}
{mode !== 'add-folder' && (
<DialogFooter>
<Button disabled={submitting} onClick={() => onOpenChange(false)} type="button" variant="ghost">
{t.common.cancel}
</Button>
<Button
disabled={submitting || !name.trim() || (mode === 'create' && folders.length === 0)}
onClick={() => void submit()}
type="button"
>
{mode === 'rename' ? t.common.save : p.create}
</Button>
</DialogFooter>
)}
</DialogContent>
</Dialog>
)
}

View File

@@ -1,265 +0,0 @@
import { useStore } from '@nanostores/react'
import type * as React from 'react'
import { useMemo, useState } from 'react'
import { Button } from '@/components/ui/button'
import { Codicon } from '@/components/ui/codicon'
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from '@/components/ui/dialog'
import type { HermesGitWorktree } from '@/global'
import type { SessionInfo } from '@/hermes'
import { useI18n } from '@/i18n'
import { $dismissedWorktreeIds, dismissWorktree } from '@/store/layout'
import { notifyError } from '@/store/notifications'
import { removeWorktreePath } from '@/store/projects'
import { SidebarRowStack } from '../chrome'
import { useWorkspaceNodeOpen } from './model'
import { SidebarWorkspaceGroup } from './workspace-group'
import {
mergeRepoWorktreeGroups,
overlayRepoLanes,
type SidebarProjectTree,
type SidebarSessionGroup,
type SidebarWorkspaceTree
} from './workspace-groups'
import { WorkspaceAddButton, WorkspaceHeader } from './workspace-header'
// The entered project's body. Main-checkout sessions render directly — no
// redundant repo/branch header (the breadcrumb already names the project). Only
// linked worktrees nest, shown by branch. Multi-folder projects keep per-repo
// headers so the folders stay distinguishable.
export function EnteredProjectContent({
project,
renderRows,
onNewSession,
repoWorktrees,
liveSessions,
removedSessionIds
}: {
project: SidebarProjectTree
renderRows: (sessions: SessionInfo[]) => React.ReactNode
onNewSession?: (path: null | string) => void
repoWorktrees?: Record<string, HermesGitWorktree[]>
liveSessions?: SessionInfo[]
removedSessionIds?: ReadonlySet<string>
}) {
if (!project.repos.length) {
return null
}
const single = project.repos.length === 1
return (
<>
{project.repos.map(repo => (
<RepoFlatSection
discoveredWorktrees={repo.path ? repoWorktrees?.[repo.path] : undefined}
key={repo.id}
liveSessions={liveSessions}
onNewSession={onNewSession}
removedSessionIds={removedSessionIds}
renderRows={renderRows}
repo={repo}
showHeader={!single}
/>
))}
</>
)
}
function RepoFlatSection({
repo,
showHeader,
renderRows,
onNewSession,
discoveredWorktrees,
liveSessions,
removedSessionIds
}: {
repo: SidebarWorkspaceTree
showHeader: boolean
renderRows: (sessions: SessionInfo[]) => React.ReactNode
onNewSession?: (path: null | string) => void
discoveredWorktrees?: HermesGitWorktree[]
liveSessions?: SessionInfo[]
removedSessionIds?: ReadonlySet<string>
}) {
const { t } = useI18n()
const s = t.sidebar
const [open, toggleOpen] = useWorkspaceNodeOpen(repo.id)
const dismissedWorktrees = useStore($dismissedWorktreeIds)
// The repo's session lanes already come fully built from the backend; this
// only injects empty VISUAL lanes from a live `git worktree list`.
const mergedGroups = useMemo(() => mergeRepoWorktreeGroups(repo, discoveredWorktrees), [repo, discoveredWorktrees])
// Optimistic placement runs against the MERGED lane set (backend + visual
// git-worktree lanes) so out-of-tree/sibling worktrees — which exist as visual
// lanes before the snapshot carries their sessions — get the new row. The
// overlay drops lanes it empties, so re-merge to restore still-real worktrees.
const overlaidGroups = useMemo(() => {
if (!(liveSessions?.length || removedSessionIds?.size)) {
return mergedGroups
}
const { groups } = overlayRepoLanes({ ...repo, groups: mergedGroups }, liveSessions ?? [], removedSessionIds)
return mergeRepoWorktreeGroups({ id: repo.id, path: repo.path, groups }, discoveredWorktrees)
}, [repo, mergedGroups, discoveredWorktrees, liveSessions, removedSessionIds])
const discoveredWorktreePaths = useMemo(
() =>
new Set(
(discoveredWorktrees ?? [])
.map(worktree => worktree.path?.trim())
.filter((path): path is string => Boolean(path))
),
[discoveredWorktrees]
)
// Main lanes are always visible; linked worktrees can be user-dismissed.
// A live `git worktree list` hit wins over an old dismissal: if git says the
// worktree exists again (or still exists after "hide from sidebar"), surface it.
const ordered = overlaidGroups.filter(
group => group.isMain || !dismissedWorktrees.includes(group.id) || (group.path && discoveredWorktreePaths.has(group.path))
)
const repoCount = ordered.reduce((sum, group) => sum + group.sessions.length, 0)
// Removal asks how: actually `git worktree remove` it, or just hide the lane
// and leave the worktree on disk. A dirty worktree escalates to a force prompt
// instead of erroring (those changes are usually throwaway).
const [removeTarget, setRemoveTarget] = useState<null | SidebarSessionGroup>(null)
const [forceTarget, setForceTarget] = useState<null | SidebarSessionGroup>(null)
const removeViaGit = async (group: SidebarSessionGroup, force = false) => {
if (!repo.path || !group.path) {
return
}
try {
await removeWorktreePath(repo.path, group.path, { force })
dismissWorktree(group.id)
} catch (err) {
// git refuses a non-force remove on a dirty/locked worktree — offer force
// rather than dead-ending on an error toast.
if (!force && /force|modified|untracked|dirty|locked|contains/i.test(String((err as Error)?.message ?? ''))) {
setForceTarget(group)
} else {
notifyError(err, s.projects.removeWorktreeFailed)
}
}
}
const body = (
<>
{ordered.map(group => (
<SidebarWorkspaceGroup
group={group}
key={group.id}
// The kanban bucket is read-only: it aggregates many task worktrees, so
// "new session here" and "remove worktree" have no single target.
onNewSession={group.isKanban ? undefined : onNewSession}
onRemove={group.isMain || group.isKanban ? undefined : () => setRemoveTarget(group)}
renderRows={renderRows}
/>
))}
</>
)
// Both removal prompts share the shape (hide-from-sidebar + cancel + a
// destructive action); only the copy and the destructive handler differ.
const worktreeDialog = (
target: null | SidebarSessionGroup,
setTarget: (next: null | SidebarSessionGroup) => void,
description: string,
destructiveLabel: string,
onDestructive: (group: SidebarSessionGroup) => void
) => (
<Dialog onOpenChange={isOpen => !isOpen && setTarget(null)} open={Boolean(target)}>
<DialogContent>
<DialogHeader>
<DialogTitle>{`${s.projects.removeWorktree} "${target?.label ?? ''}"?`}</DialogTitle>
<DialogDescription>{description}</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button onClick={() => setTarget(null)} variant="ghost">
{t.common.cancel}
</Button>
<Button
onClick={() => {
if (target) {
dismissWorktree(target.id)
}
setTarget(null)
}}
variant="secondary"
>
{s.projects.removeFromSidebar}
</Button>
<Button
onClick={() => {
setTarget(null)
if (target) {
onDestructive(target)
}
}}
variant="destructive"
>
{destructiveLabel}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
)
const removeDialog = (
<>
{worktreeDialog(
removeTarget,
setRemoveTarget,
s.projects.removeWorktreeConfirm,
s.projects.removeWorktree,
group => void removeViaGit(group)
)}
{worktreeDialog(
forceTarget,
setForceTarget,
s.projects.removeWorktreeDirty,
s.projects.forceRemove,
group => void removeViaGit(group, true)
)}
</>
)
if (!showHeader) {
return (
<>
{body}
{removeDialog}
</>
)
}
return (
<SidebarRowStack>
<WorkspaceHeader
action={
onNewSession && <WorkspaceAddButton label={s.newSessionIn(repo.label)} onClick={() => onNewSession(repo.path)} />
}
count={repoCount}
emphasis
icon={<Codicon className="shrink-0 text-(--ui-text-tertiary)" name="repo" size="0.75rem" />}
label={repo.label}
onToggle={toggleOpen}
open={open}
title={repo.path ?? undefined}
/>
{open && <SidebarRowStack className="pl-2.5">{body}</SidebarRowStack>}
{removeDialog}
</SidebarRowStack>
)
}

View File

@@ -1,15 +0,0 @@
// Public surface of the project/worktree sidebar, consumed by the sidebar root.
export { EnteredProjectContent } from './entered-content'
export { PROJECT_PREVIEW_COUNT, projectTreeCwd, sortProjectsForOverview, useRepoWorktreeMap } from './model'
export { ProjectBackRow, ProjectOverviewRow } from './overview-row'
export { ProjectMenu } from './project-menu'
export { SidebarWorkspaceGroup } from './workspace-group'
export {
overlayLiveLanes,
overlayLivePreviews,
sessionRecency,
type SidebarProjectTree,
type SidebarSessionGroup,
type SidebarWorkspaceTree
} from './workspace-groups'
export { StartWorkButton } from './workspace-header'

View File

@@ -1,128 +0,0 @@
import { useStore } from '@nanostores/react'
import { useEffect, useMemo, useState } from 'react'
import type { HermesGitWorktree } from '@/global'
import type { SessionInfo } from '@/hermes'
import { mapPool } from '@/lib/pool'
import { $sidebarWorkspaceCollapsedIds, toggleWorkspaceNodeCollapsed } from '@/store/layout'
import { $worktreeRefreshToken } from '@/store/projects'
import { sessionRecency, type SidebarProjectTree } from './workspace-groups'
// Page size when revealing more already-loaded rows within a workspace group.
export const SIDEBAR_GROUP_PAGE = 5
// Recent sessions previewed under each project in the overview.
export const PROJECT_PREVIEW_COUNT = 3
// Max concurrent `git worktree list` probes when a project spans many repos.
const WORKTREE_PROBE_CONCURRENCY = 4
const pathListKey = (paths: string[]): string =>
paths.map(path => path.trim()).filter(Boolean).sort((a, b) => a.localeCompare(b)).join('\n')
// Every session in a project, across its repos/worktrees (order-agnostic).
const projectSessions = (project: SidebarProjectTree): SessionInfo[] =>
project.repos.flatMap(repo => repo.groups.flatMap(group => group.sessions))
export const projectTreeCwd = (project: SidebarProjectTree): null | string =>
project.path || project.repos.find(repo => repo.path)?.path || null
// Overview rows carry their activity stamp from the backend (lanes are empty in
// overview mode), falling back to loaded session times when present.
const projectActivityTime = (project: SidebarProjectTree): number =>
Math.max(
project.lastActive ?? 0,
projectSessions(project).reduce((latest, s) => Math.max(latest, sessionRecency(s)), 0)
)
// The project's most-recent sessions, for the overview preview under each row.
export const latestProjectSessions = (project: SidebarProjectTree, limit: number): SessionInfo[] =>
[...projectSessions(project)].sort((a, b) => sessionRecency(b) - sessionRecency(a)).slice(0, limit)
export function sortProjectsForOverview(
projects: SidebarProjectTree[],
activeProjectId: null | string
): SidebarProjectTree[] {
return [...projects].sort((a, b) => {
const aActive = Boolean(activeProjectId && a.id === activeProjectId && !a.isAuto)
const bActive = Boolean(activeProjectId && b.id === activeProjectId && !b.isAuto)
if (aActive !== bActive) {
return aActive ? -1 : 1
}
if (!a.isAuto !== !b.isAuto) {
return a.isAuto ? 1 : -1
}
const aHasSessions = a.sessionCount > 0
const bHasSessions = b.sessionCount > 0
if (aHasSessions !== bHasSessions) {
return aHasSessions ? -1 : 1
}
return projectActivityTime(b) - projectActivityTime(a) || a.label.localeCompare(b.label, undefined, { sensitivity: 'base' })
})
}
// Project drill-in lanes are git-driven: source them from `git worktree list` so
// linked worktrees still appear even when their sessions aren't in the recents
// payload currently loaded in memory.
export function useRepoWorktreeMap(
repoPaths: string[],
enabled: boolean
): [Record<string, HermesGitWorktree[]>, boolean] {
const [map, setMap] = useState<Record<string, HermesGitWorktree[]>>({})
const [loading, setLoading] = useState(false)
const key = useMemo(() => pathListKey(repoPaths), [repoPaths])
// Refetch when a worktree is added/removed so a new lane shows immediately.
const refreshToken = useStore($worktreeRefreshToken)
useEffect(() => {
const git = window.hermesDesktop?.git
if (!enabled || !repoPaths.length || !git?.worktreeList) {
setMap({})
setLoading(false)
return
}
let cancelled = false
setLoading(true)
// Bounded so a many-repo project doesn't spawn a `git` process per repo at once.
void mapPool(repoPaths, WORKTREE_PROBE_CONCURRENCY, async repoPath => {
try {
return [repoPath, await git.worktreeList(repoPath)] as const
} catch {
return [repoPath, []] as const
}
})
.then(entries => void (cancelled || setMap(Object.fromEntries(entries))))
.finally(() => void (cancelled || setLoading(false)))
return () => {
cancelled = true
}
}, [enabled, key, repoPaths, refreshToken])
return [map, loading]
}
// Persisted open/collapse for a repo/worktree node. Lets a project's folder
// layout auto-restore when you enter it, and survive reloads.
//
// The persisted set is an OVERRIDE of `defaultOpen`, not an absolute "collapsed"
// list: XOR lets one store serve both polarities. A default-open node (repo,
// populated lane) lists collapses; a default-collapsed node (an EMPTY lane — no
// sessions yet) instead records an explicit expand. So empty worktree/branch
// lanes start collapsed and only open when the user clicks in.
export function useWorkspaceNodeOpen(id: string, defaultOpen = true): [boolean, () => void] {
const collapsed = useStore($sidebarWorkspaceCollapsedIds)
const overridden = collapsed.includes(id)
return [defaultOpen ? !overridden : overridden, () => toggleWorkspaceNodeCollapsed(id)]
}

View File

@@ -1,155 +0,0 @@
import type * as React from 'react'
import { useRef } from 'react'
import { Codicon } from '@/components/ui/codicon'
import { DisclosureCaret } from '@/components/ui/disclosure-caret'
import type { SessionInfo } from '@/hermes'
import { useI18n } from '@/i18n'
import { cn } from '@/lib/utils'
import {
SIDEBAR_LEAD_ICON_SIZE,
SidebarRowBody,
SidebarRowCluster,
SidebarRowGrab,
SidebarRowLabel,
SidebarRowLead,
SidebarRowLeadGlyph,
SidebarRowLink,
SidebarRowNest,
SidebarRowShell
} from '../chrome'
import { latestProjectSessions, PROJECT_PREVIEW_COUNT, useWorkspaceNodeOpen } from './model'
import { ProjectMenu } from './project-menu'
import type { SidebarProjectTree } from './workspace-groups'
import { WorkspaceAddButton } from './workspace-header'
// A bare color dot (no icon) or an icon glyph — tinted by `color` when set, else
// the lead's default tertiary. The glyph wrapper centers + caps size either way.
export function projectIcon({ color, icon }: SidebarProjectTree) {
if (color && !icon) {
return (
<SidebarRowLeadGlyph>
<span aria-hidden="true" className="size-1 rounded-full" style={{ backgroundColor: color }} />
</SidebarRowLeadGlyph>
)
}
return (
<SidebarRowLeadGlyph style={color ? { color } : undefined}>
<Codicon name={icon || 'folder-library'} size={SIDEBAR_LEAD_ICON_SIZE} />
</SidebarRowLeadGlyph>
)
}
export function ProjectBackRow({ label, onClick }: { label: string; onClick: () => void }) {
return (
<SidebarRowShell>
<SidebarRowBody
className="group/back w-full text-(--ui-text-tertiary) opacity-40 hover:text-foreground"
onClick={onClick}
>
<SidebarRowLead>
<SidebarRowLeadGlyph>
<Codicon name="arrow-left" size={SIDEBAR_LEAD_ICON_SIZE} />
</SidebarRowLeadGlyph>
</SidebarRowLead>
<SidebarRowLabel className="text-xs underline-offset-4 group-hover/back:underline">{label}</SidebarRowLabel>
</SidebarRowBody>
</SidebarRowShell>
)
}
interface ProjectOverviewRowProps {
project: SidebarProjectTree
onEnter?: (id: string) => void
onNewSession?: (path: null | string) => void
renderRows?: (sessions: SessionInfo[]) => React.ReactNode
activeProjectId?: null | string
previewSessions?: SessionInfo[]
reorderable?: boolean
dragging?: boolean
dragHandleProps?: React.HTMLAttributes<HTMLElement>
ref?: React.Ref<HTMLDivElement>
style?: React.CSSProperties
}
export function ProjectOverviewRow({
project,
onEnter,
onNewSession,
renderRows,
activeProjectId,
previewSessions,
reorderable = false,
dragging = false,
dragHandleProps,
ref,
style
}: ProjectOverviewRowProps) {
const { t } = useI18n()
const s = t.sidebar
const isActive = project.id === activeProjectId
const [open, toggleOpen] = useWorkspaceNodeOpen(project.id)
// The appearance popover anchors here (the full row) so it opens flush with
// the sidebar's content edge regardless of which side the sidebar is on.
const rowRef = useRef<HTMLDivElement>(null)
const fetched = (previewSessions ?? []).slice(0, PROJECT_PREVIEW_COUNT)
const preview = renderRows ? (fetched.length ? fetched : latestProjectSessions(project, PROJECT_PREVIEW_COUNT)) : []
const lead = reorderable ? (
<SidebarRowGrab
ariaLabel={s.projects.reorder(project.label)}
dragging={dragging}
dragHandleProps={dragHandleProps}
leadClassName="overflow-visible"
>
{projectIcon(project)}
</SidebarRowGrab>
) : (
<SidebarRowLead>{projectIcon(project)}</SidebarRowLead>
)
return (
<div className={cn(dragging && 'relative z-10')} ref={ref} style={style}>
<SidebarRowShell
actions={
<>
{onNewSession && <WorkspaceAddButton label={s.newSessionIn(project.label)} onClick={() => onNewSession(project.path)} />}
<ProjectMenu anchorRef={rowRef} isActive={isActive} project={project} />
</>
}
className={cn('group/workspace', dragging && 'cursor-grabbing bg-(--ui-sidebar-surface-background)')}
ref={rowRef}
>
<SidebarRowCluster className="min-w-0 flex-1">
{lead}
<SidebarRowLink
aria-label={s.projects.enter(project.label)}
labelClassName={cn('hover:text-foreground hover:underline', isActive && 'text-foreground')}
onClick={() => onEnter?.(project.id)}
>
{project.label}
</SidebarRowLink>
{preview.length > 0 ? (
<button
aria-label={s.projects.toggle(project.label)}
className="flex flex-1 items-center self-stretch bg-transparent p-0"
onClick={toggleOpen}
type="button"
>
<DisclosureCaret
className="shrink-0 text-(--ui-text-tertiary) opacity-0 transition group-hover/workspace:opacity-100"
open={open}
/>
</button>
) : (
<span className="flex-1" />
)}
</SidebarRowCluster>
</SidebarRowShell>
{open && preview.length > 0 && <SidebarRowNest>{renderRows?.(preview)}</SidebarRowNest>}
</div>
)
}

View File

@@ -1,206 +0,0 @@
import { useStore } from '@nanostores/react'
import type * as React from 'react'
import { useState } from 'react'
import { Codicon } from '@/components/ui/codicon'
import { ColorSwatches } from '@/components/ui/color-swatches'
import { ConfirmDialog } from '@/components/ui/confirm-dialog'
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger
} from '@/components/ui/dropdown-menu'
import { Popover, PopoverAnchor, PopoverContent } from '@/components/ui/popover'
import { useI18n } from '@/i18n'
import { PROFILE_SWATCHES } from '@/lib/profile-color'
import { cn } from '@/lib/utils'
import { $panesFlipped, dismissAutoProject } from '@/store/layout'
import {
copyPath,
deleteProject,
openProjectAddFolder,
openProjectRename,
revealPath,
setActiveProject,
updateProject
} from '@/store/projects'
import type { SidebarProjectTree } from './workspace-groups'
// Curated codicons for the project glyph (tinted by the chosen color).
const ICONS = [
'folder-library', 'repo', 'rocket', 'beaker', 'flame', 'star-full', 'heart',
'zap', 'target', 'lightbulb', 'tools', 'device-desktop', 'device-mobile', 'terminal',
'dashboard', 'globe', 'broadcast', 'cloud', 'database', 'package', 'book',
'organization', 'bug', 'shield', 'key', 'gift', 'telescope', 'home'
]
// Per-project actions, modeled on git GUIs (GitHub Desktop / GitKraken): reveal
// in the file manager, copy path, and "Remove from sidebar" (never deletes files
// — auto projects are dismissed, explicit ones drop their entry). Explicit
// projects additionally get rename / add folder / set active. Hidden until the
// row is hovered (group/workspace), matching the + affordance.
export function ProjectMenu({
project,
isActive,
scoped = false,
onExitScope,
anchorRef
}: {
project: SidebarProjectTree
isActive: boolean
// True when rendered in the entered-project header, so removal can leave the
// now-defunct scope.
scoped?: boolean
onExitScope?: () => void
// Anchor the appearance popover to the whole row instead of the kebab, so it
// opens flush against the sidebar's content-facing edge — otherwise a
// right-side sidebar drags the picker across the entire panel (the kebab
// lives at the row's outer edge). Falls back to the kebab when absent.
anchorRef?: React.RefObject<HTMLElement | null>
}) {
const { t } = useI18n()
const p = t.sidebar.projects
const target = { id: project.id, name: project.label }
const [confirmDeleteOpen, setConfirmDeleteOpen] = useState(false)
const [appearanceOpen, setAppearanceOpen] = useState(false)
// Open toward the content area: right when the sidebar is on the left, left
// when the panes are flipped (sidebar on the right).
const panesFlipped = useStore($panesFlipped)
const removeAuto = () => {
dismissAutoProject(project.id)
if (scoped) {
onExitScope?.()
}
}
const confirmDelete = async () => {
await deleteProject(project.id)
if (scoped) {
onExitScope?.()
}
}
const trigger = (
<DropdownMenuTrigger asChild>
<button
aria-label={p.menu}
className={cn(
'grid size-4 shrink-0 place-items-center rounded-sm bg-transparent text-(--ui-text-quaternary) opacity-0 transition-opacity hover:bg-(--ui-control-hover-background) hover:text-foreground data-[state=open]:opacity-100',
// In the project header reveal on the whole header hover; in overview
// rows reveal on the row hover.
scoped ? 'group-hover/section:opacity-100' : 'group-hover/workspace:opacity-100'
)}
onClick={event => event.stopPropagation()}
type="button"
>
<Codicon name="kebab-vertical" size="0.75rem" />
</button>
</DropdownMenuTrigger>
)
return (
<Popover onOpenChange={setAppearanceOpen} open={appearanceOpen}>
{/* Position the appearance popover against the row (when a ref is wired);
the kebab is only the dropdown trigger then. */}
{anchorRef ? <PopoverAnchor virtualRef={anchorRef as React.RefObject<HTMLElement>} /> : null}
<DropdownMenu>
{anchorRef ? trigger : <PopoverAnchor asChild>{trigger}</PopoverAnchor>}
{/* Closing the menu refocuses the trigger (also the popover anchor),
which the appearance popover would read as focus-outside and die on.
Suppress that refocus so it survives. */}
<DropdownMenuContent align="end" className="w-48" onCloseAutoFocus={event => event.preventDefault()} sideOffset={6}>
{!project.isAuto && (
<>
<DropdownMenuItem onSelect={() => openProjectRename(target)}>
<Codicon name="edit" size="0.875rem" />
<span>{p.menuRename}</span>
</DropdownMenuItem>
<DropdownMenuItem onSelect={() => setAppearanceOpen(true)}>
<Codicon name="symbol-color" size="0.875rem" />
<span>{p.menuAppearance}</span>
</DropdownMenuItem>
<DropdownMenuItem onSelect={() => openProjectAddFolder(target)}>
<Codicon name="new-folder" size="0.875rem" />
<span>{p.menuAddFolder}</span>
</DropdownMenuItem>
<DropdownMenuItem disabled={isActive} onSelect={() => void setActiveProject(project.id)}>
<Codicon name="target" size="0.875rem" />
<span>{p.menuSetActive}</span>
</DropdownMenuItem>
<DropdownMenuSeparator />
</>
)}
<DropdownMenuItem disabled={!project.path} onSelect={() => void revealPath(project.path)}>
<Codicon name="folder-opened" size="0.875rem" />
<span>{p.reveal}</span>
</DropdownMenuItem>
<DropdownMenuItem disabled={!project.path} onSelect={() => void copyPath(project.path)}>
<Codicon name="copy" size="0.875rem" />
<span>{p.copyPath}</span>
</DropdownMenuItem>
<DropdownMenuSeparator />
{project.isAuto ? (
<DropdownMenuItem onSelect={removeAuto} variant="destructive">
<Codicon name="trash" size="0.875rem" />
<span>{p.removeFromSidebar}</span>
</DropdownMenuItem>
) : (
<DropdownMenuItem onSelect={() => setConfirmDeleteOpen(true)} variant="destructive">
<Codicon name="trash" size="0.875rem" />
<span>{`${p.menuDelete}`}</span>
</DropdownMenuItem>
)}
</DropdownMenuContent>
</DropdownMenu>
<PopoverContent
align="start"
className="w-auto p-2"
onClick={event => event.stopPropagation()}
side={panesFlipped ? 'left' : 'right'}
sideOffset={6}
>
<ColorSwatches
clearIcon="circle-slash"
clearLabel={p.noColor}
onChange={color => void updateProject(project.id, { color })}
swatches={PROFILE_SWATCHES}
value={project.color ?? null}
/>
{/* Same 6 columns + gap as the swatch grid so the popover keeps the
profile picker's width (icons flex to fill, not fixed-width). */}
<div className="mt-2 grid grid-cols-6 gap-1.5">
{ICONS.map(name => (
<button
aria-label={name}
className={cn(
'grid aspect-square place-items-center rounded-md text-(--ui-text-tertiary) transition hover:bg-(--ui-control-hover-background)',
project.icon === name && 'bg-(--ui-control-active-background) text-foreground'
)}
key={name}
onClick={() => void updateProject(project.id, { icon: project.icon === name ? null : name })}
style={project.icon === name && project.color ? { color: project.color } : undefined}
type="button"
>
<Codicon name={name} size="0.8125rem" />
</button>
))}
</div>
</PopoverContent>
<ConfirmDialog
confirmLabel={p.menuDelete}
description={p.deleteConfirm}
destructive
onClose={() => setConfirmDeleteOpen(false)}
onConfirm={confirmDelete}
open={confirmDeleteOpen}
title={`${p.menuDelete} "${project.label}"?`}
/>
</Popover>
)
}

Some files were not shown because too many files have changed in this diff Show More